Android Compose UI Navigation 導航

在現代的 Android 開發 (Single Activity Architecture) 中,頁面切換不再是啟動新的 Activity,而是切換不同的 Composable。官方推薦使用 Navigation Compose 函式庫來管理這些路由。

從 Navigation 2.8.0 開始,官方正式支援 Type-Safe Navigation(型別安全導航),這是目前推薦的最佳實作方式。

安裝依賴

為了使用 Type-Safe Navigation,我們需要 navigation-compose 以及 kotlinx.serialization 的整合。

// build.gradle.kts (Module level)

plugins {
    // ...
    kotlin("plugin.serialization") version "2.0.0" // 確保你的 Kotlin 版本對應
}

dependencies {
    val navVersion = "2.8.0" // 或更新版本
    implementation("androidx.navigation:navigation-compose:$navVersion")
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
}

核心元件

Navigation Compose 由三個主要部分組成:

  1. NavController:導航的「司機」。負責執行跳轉 (navigate)、返回 (popBackStack) 等操作。
  2. NavHost:導航的「容器」。它會根據當前的路由 (Route) 顯示對應的 Composable。
  3. NavGraph:導航的「地圖」。在 NavHost 中定義了有哪些目的地 (Destination) 以及路徑。

Step-by-Step 實作

1. 定義 Routes -> 導航路由

使用 Kotlin 的 data classobject 來定義路由,並加上 @Serializable。藉由 @Serializable 定義路由物件,Navigation 框架能自動處理路徑解析與參數對應。這種做法最大的優勢在於型別安全:所有導航參數都會在編譯時進行檢查,徹底杜絕了因參數缺失或型別錯誤而導致的 Runtime Crash。

import kotlinx.serialization.Serializable

// 不需要參數的頁面,使用 object
@Serializable
object ProductList

// 需要參數的頁面,使用 data class
@Serializable
data class ProductDetail(val productId: String)

2. 建立 NavHost -> 導航容器

在你的主畫面 (例如 MainActivitysetContent 中) 設定 NavHost

NavHost 是 Compose Navigation 的核心元件,主要負責兩件事:

  1. 管理導航圖 (NavGraph):集中定義應用程式中所有可用的頁面路徑及其對應的 Composable 畫面。
  2. 處理畫面切換:與 NavController 協作。當開發者呼叫 navController.navigate() 時,NavHost 會根據傳入的路由自動尋找並載入對應的畫面。
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute

@Composable
fun MyApp() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = ProductList // 設定起始頁面
    ) {
        // 定義 ProductList 頁面
        composable<ProductList> {
            ProductListScreen(
                // 最佳實踐:透過 Lambda 向上傳遞導航事件
                onProductClick = { productId ->
                    navController.navigate(ProductDetail(productId))
                }
            )
        }

        // 定義 ProductDetail 頁面,並自動解析參數
        composable<ProductDetail> { backStackEntry ->
            // 取得傳入的參數
            val route: ProductDetail = backStackEntry.toRoute()
            
            ProductDetailScreen(
                productId = route.productId,
                onBack = {
                    navController.popBackStack()
                }
            )
        }
    }
}

3. 設計 Screen Composable -> 解耦邏輯

在 Jetpack Compose 中,為了讓 UI 元件(Screen)更容易測試且與導航邏輯解耦,我們不應該在 UI 元件內部直接呼叫 navController.navigate()

相反地,我們會將導航事件「提升 (Hoist)」到上層。也就是說,UI 元件只負責「顯示」和「回報使用者動作」,至於點擊後要去哪裡,由 NavHost 決定。

實作方式: 在 Composable 函式的參數中定義 Lambda Callbacks。例如:

  • onProductClick: (String) -> Unit:當點擊商品時回報。
  • onBack: () -> Unit:當點擊返回時回報。

這樣做的好處是:

  1. 方便預覽 (Preview):不需要 Mock NavController 就能在 IDE 預覽畫面。
  2. 容易測試:單元測試時只需驗證 Lambda 是否被呼叫。
  3. 單一真理來源:所有導航路徑都集中在 NavHost 管理,一目了然。

對應上面的 MyApp,我們的 Screen 實作如下:

@Composable
fun ProductListScreen(
    onProductClick: (String) -> Unit // 將導航動作暴露為 Lambda
) {
    Column {
        Text("商品列表", style = MaterialTheme.typography.titleLarge)
        
        // 點擊時,只呼叫 Lambda,不碰 NavController
        Button(onClick = { onProductClick("pixel-9") }) {
            Text("查看 Pixel 9")
        }
        Button(onClick = { onProductClick("iphone-16") }) {
            Text("查看 iPhone 16")
        }
    }
}

@Composable
fun ProductDetailScreen(
    productId: String, 
    onBack: () -> Unit // 將返回動作暴露為 Lambda
) {
    Column {
        Text("商品詳情 ID: $productId", style = MaterialTheme.typography.headlineMedium)
        Button(onClick = onBack) {
            Text("返回")
        }
    }
}

4. 監聽導航狀態 (Observing Navigation State)

在開發 App 時,我們常需要知道「現在在哪個頁面?」,最常見的場景就是 Bottom Navigation Bar(底部導航列)。當使用者切換頁面時,底部的 Tab 應該要亮起對應的項目。

為了達成這個即時更新的效果,我們需要使用 currentBackStackEntryAsState()

// 1. 取得當前的 NavBackStackEntry,並將其轉為 Compose State
val navBackStackEntry by navController.currentBackStackEntryAsState()

// 2. 從 Entry 中取得當前的 Destination (目的地)
val currentDestination = navBackStackEntry?.destination

// 3. 判斷目前的畫面是否為 "ProductList"
if (currentDestination?.hasRoute<ProductList>() == true) { ... }

這裡有三個關鍵概念需要釐清:

(1) State 與 Recomposition (重組)

currentBackStackEntryAsState() 回傳的是一個 State<NavBackStackEntry?>

在 Jetpack Compose 中,State 是驅動 UI 更新的靈魂。簡單來說:

  1. 監聽:當你在 Composable 函式中讀取這個 State 的值 (例如 val entry by ...) 時,Compose 就會「訂閱」這個 State。
  2. 更新:當使用者的導航位置改變時(例如按下按鈕跳轉),NavController 會更新這個 State 的值。
  3. 重組 (Recomposition):因為 State 變了,Compose 系統會自動重新執行這個 Composable 函式,也就是重繪畫面

正是因為這個機制,我們的 Bottom Bar 才能在換頁的瞬間,自動切換亮起的 Tab。

(2) NavBackStackEntry

NavBackStackEntry 代表的是 Back Stack (返回堆疊) 中的一個實體。你可以把它想像成一個「活著的頁面包裹」。

它不僅僅包含路由名稱,還攜帶了這個頁面所有的生命週期資訊與資料:

  • Destination: 知道這個頁面是誰 (Route 資訊)。
  • Arguments: 攜帶的參數 (Bundle)。
  • Lifecycle: 這是最重要的!每個 Entry 都有自己的生命週期 (LifecycleOwner)。當頁面被推入堆疊,狀態為 RESUMED;被蓋住時為 STOPPED;被彈出時則為 DESTROYED。
  • ViewModelStore:這意味著你可以將 ViewModel 的生命週期綁定到這個特定的 Entry 上 (Scope),而不是整個 Activity。

(3) 實戰範例:底部導航列同步

@Composable
fun MainScreen() {
    val navController = rememberNavController()
    
    // 監聽導航堆疊的變化
    // 只要換頁,navBackStackEntry 就會改變,MainScreen 就會重組
    val navBackStackEntry by navController.currentBackStackEntryAsState()
    val currentDestination = navBackStackEntry?.destination

    Scaffold(
        bottomBar = {
            NavigationBar {
                // 判斷當前是否在 Home 頁面
                // 使用 hasRoute<T>() 來做型別安全的檢查
                val isHome = currentDestination?.hasRoute<Home>() == true
                
                NavigationBarItem(
                    selected = isHome,
                    onClick = { navController.navigate(Home) },
                    icon = { Icon(Icons.Default.Home, contentDescription = null) },
                    label = { Text("Home") }
                )
                
                // ... 其他 Items
            }
        }
    ) { innerPadding ->
        NavHost(navController, startDestination = Home, Modifier.padding(innerPadding)) {
           // ...
        }
    }
}

進階技巧

清除堆疊 (Pop Up To)

假設你在登入流程:Splash -> Login -> Home。當使用者登入成功進入 Home 時,按返回鍵不應該回到 Login,而是退出 App。

navController.navigate(Home) {
    popUpTo(Login) { inclusive = true } // 清除 Login 及其之上的所有頁面
}

取得 NavController

通常我們只在 NavHost 所在層級建立一次 NavController。如果深層元件需要導航(例如 BottomBar),也可以傳遞同一個實體,或使用 Event Hoisting 一路傳上去(推薦)。

為什麼要用 Type-Safe Navigation?

在舊版 Navigation 中,我們使用字串拼湊路徑,例如: navController.navigate("detail/$productId")

這有許多缺點:

  1. 容易拼錯字detail 拼成 detial 就崩潰了。
  2. 參數型別不安全:取值時需要手動轉型,忘記傳參數編譯器也不會報錯。
  3. 重構困難:改路由名稱需要全域搜尋取代。

新版使用 Serialization 機制,編譯器會幫你檢查參數型別與必要性,這是現代 Android 開發的標準配備。