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 由三個主要部分組成:
- NavController:導航的「司機」。負責執行跳轉 (
navigate)、返回 (popBackStack) 等操作。 - NavHost:導航的「容器」。它會根據當前的路由 (Route) 顯示對應的 Composable。
- NavGraph:導航的「地圖」。在 NavHost 中定義了有哪些目的地 (Destination) 以及路徑。
Step-by-Step 實作
1. 定義 Routes -> 導航路由
使用 Kotlin 的 data class 或 object 來定義路由,並加上 @Serializable。藉由 @Serializable 定義路由物件,Navigation 框架能自動處理路徑解析與參數對應。這種做法最大的優勢在於型別安全:所有導航參數都會在編譯時進行檢查,徹底杜絕了因參數缺失或型別錯誤而導致的 Runtime Crash。
import kotlinx.serialization.Serializable
// 不需要參數的頁面,使用 object
@Serializable
object ProductList
// 需要參數的頁面,使用 data class
@Serializable
data class ProductDetail(val productId: String)
2. 建立 NavHost -> 導航容器
在你的主畫面 (例如 MainActivity 的 setContent 中) 設定 NavHost。
NavHost 是 Compose Navigation 的核心元件,主要負責兩件事:
- 管理導航圖 (NavGraph):集中定義應用程式中所有可用的頁面路徑及其對應的 Composable 畫面。
- 處理畫面切換:與 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:當點擊返回時回報。
這樣做的好處是:
- 方便預覽 (Preview):不需要 Mock
NavController就能在 IDE 預覽畫面。 - 容易測試:單元測試時只需驗證 Lambda 是否被呼叫。
- 單一真理來源:所有導航路徑都集中在
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 更新的靈魂。簡單來說:
- 監聽:當你在 Composable 函式中讀取這個
State的值 (例如val entry by ...) 時,Compose 就會「訂閱」這個 State。 - 更新:當使用者的導航位置改變時(例如按下按鈕跳轉),
NavController會更新這個 State 的值。 - 重組 (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")
這有許多缺點:
- 容易拼錯字:
detail拼成detial就崩潰了。 - 參數型別不安全:取值時需要手動轉型,忘記傳參數編譯器也不會報錯。
- 重構困難:改路由名稱需要全域搜尋取代。
新版使用 Serialization 機制,編譯器會幫你檢查參數型別與必要性,這是現代 Android 開發的標準配備。