🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

Jetpack Navigation 3:領航未來

header.png

Nav3 出世已經有一段時間了,翻過幾篇文章,有些概念還是不太理解,還是自己動手寫一篇文章吧,理清一下思路。

為什麼要起新號?

我第一次聽到 Nav3 時,心裡是一萬個問號:Goooooogle...不是哥們...搞什麼呢?號廢了就重開?

google_nav2-nav3.png

Jetpack Navigation(以下簡稱 Nav2)在 2018 年首次推出,在過去 7 年時間裡它早已經被廣泛使用,難道它有什麼問題嗎?如果有為什麼不解決呢?而是等到今天才想着再生一個?

馬化騰曾說過:“有時候你什麼都沒做錯,錯就錯在你太老了”,其實 Nav2 並沒有什麼大問題,錯就錯在它太老了,在過去的 7 年裡,Android 開發生態發生了翻天覆地的變化,包括程式架構、測試方式、UI 建構方式等等。

為什麼要創建 Nav3,因為 Nav2 已經無法滿足未來的發展需求了。

這幾年手機螢幕越來越大,異形螢幕、摺疊螢幕層出不窮,堂堂一個官方 Nav2 庫居然只支持單窗格佈局,也就是說不管你的設備螢幕多大,永遠只能顯示一個路由的 UI 內容。支持多窗格佈局是 Nav3 的重要目標之一。

單窗格佈局vs多窗格佈局.png

其次,在可預見的未來,Compose 必將會成為 Android UI 的主流建構方式,因為是聲明式 UI,講究狀態驅動。同樣的,對於導航系統來說,也應該積極擁抱適應這種變化,Navigation 應該變得更加“聲明式”或者說“響應式”,顯示哪個路由,應該由狀態(backstack)來驅動,但是 Nav2 的導航返回棧完全是由 Nav2 庫內部管理的,對開發者而言是個黑盒,無法直接操作控制。到了 Nav3,導航返回棧將完全由開發者自己管理,Nav3 只負責根據返回棧的狀態來顯示對應的 UI 內容,靈活性直接提升到下一個層次。

embrace_compose_state.png

依賴引入

在開始之前,我們需要先在專案裡引入 Nav3 的依賴:

# libs.versions.toml
[versions]
nav3Core = "1.0.0-beta01"
lifecycleViewmodelNav3 = "2.10.0-beta01"
kotlinSerialization = "2.1.21"
kotlinxSerializationCore = "1.8.1"
material3AdaptiveNav3 = "1.3.0-alpha02"

[libraries]
# Nav3 核心庫
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "nav3Core" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "nav3Core" }

# 與 ViewModel 集成的支持庫(可選)
androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecycle-viewmodel-navigation3", version.ref = "lifecycleViewmodelNav3" }
# Kotlinx Serialization(用於序列化,可選)
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinxSerializationCore" }
# Material3 多窗格佈局元件(可選)
androidx-material3-adaptive-navigation3 = { group = "androidx.compose.material3.adaptive", name = "adaptive-navigation3", version.ref = "material3AdaptiveNav3" }

[plugins]
# 序列化插件(可選)
jetbrains-kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"}
// app/build.gradle.kts
plugins {
    alias(libs.plugins.jetbrains.kotlin.serialization)
}

dependencies {
    implementation(libs.androidx.navigation3.ui)
    implementation(libs.androidx.navigation3.runtime)
    implementation(libs.androidx.lifecycle.viewmodel.navigation3)
    implementation(libs.androidx.material3.adaptive.navigation3)
    implementation(libs.kotlinx.serialization.core)
}

另外,請將 compileSdk 設置為 36 或更高版本。

基本概念

Key 是 Nav3 的基本組成部分之一,它代表一個可導航的路由,就像瀏覽器裡的 URL 一樣(key 是唯一的路由標識符),你的 Key 可以是任意類型,通常來說我們會使用 data class 來定義 Key。例如這裡有一個聊天會話列表頁面,那麼我們可以定義一個 data object 作為該路由的 Key:

data_object_key.png

因為會話列表頁在整個 app 裡僅有一個實例,所以我們使用 data object 來定義它。如果是聊天會話詳情頁,它並不唯一,這時我們可以使用 data class 來定義它:

data_class_key.png

字段 id 用於唯一標識某個會話。

至於 Backstack,它無非就是一個 Key 的列表,表示當前導航返回棧裡的所有路由:

backStack.png

當然,為了讓 Backstack 可以被觀察,我們會使用 SnapshotStateList<> 而不是普通的 List<>,畢竟我們希望能夠在 UI 上即時反映出 Backstack 的變化,而且 Nav3 也僅支持 Compose。

處在 Backstack 最後的 Key 就是當前顯示的路由,例如,我們的 Backstack 列表裡有一個 ConversationList,那麼當前顯示的路由就是會話列表頁:

ConversationList.png

我們往 Backstack 裡添加一個 ConversationDetail 實例,那麼當前顯示的路由就變成了會話詳情頁:

ConversationDetail.png

既然導航到新路由是向 Backstack 裡添加 Key,返回上一路由自然是從 Backstack 裡移除 Key。

remove.png

顯而易見,在 Nav3 裡,導航其實就是往 Backstack 列表裡添加或移除 Key 而已,navController?不存在的。

在 Nav2 裡,如果要實現 singleTop 導航模式,就得在調用 NavController.navigate() 方法時傳遞 NavOptions,而在 Nav3 裡,由於 Backstack 完全由我們自己管理,所以我們只需要在添加 Key 前檢查 Backstack 裡是否已經存在該 Key 即可,靈活性大大提升。

那麼,Nav3 是怎麼根據 Key 來顯示對應的 UI 內容的呢?或者說,我們的 Key 和 UI 內容之間是怎麼關聯起來的呢?

key_to_ui.png

我們可以先猜想一下,如果我們是 Nav3 的設計者,可以怎麼做?一種思路是使用提供者模式(Provider Pattern),讓開發者傳遞一個函數給 Nav3,這個函數接收一個 Key 作為參數,返回對應的 Composable UI 內容:

// 伪代码
@Composable
fun MyNav3(
  backStack: SnapshotStateList<Any>,
  uiProvider: @Composable (key: Any) -> Unit,
) {
}

然後我們就可以在 MyNav3 裡調用這個提供者函數來顯示對應的 UI 內容了:

// 伪代码
MyNav3(
  backStack = backStack,
  uiProvider = { key ->
    when (key) {
      is ConversationList -> {
        ConversationListContent()
      }
      is ConversationDetail -> {
        ConversationDetailContent(conversationId = key.id)
      }
      else -> error("Unknown key: $key")
    }
  }
)

不錯,我們的思路是對的,根據不同的 Key 類型來返回對應的 UI 內容。但這樣做是有缺陷的,如果開發者想要給某個路由設置特定的動畫效果,那該怎麼辦呢?

現在的問題在於,key 僅直接和 UI 內容綁定,無法攜帶其他信息,比如動畫效果、窗口大小限制等等。

我們可以讓 Key 不直接和 UI 內容關聯,而是和一個“路由描述符”(Destination Descriptor)關聯,這個描述符裡除了包含 UI 內容,還有一些其他信息(比如動畫效果):

// 伪代码
class DestinationDescriptor<T: Any>(
  key: T,                                       // 路由的 Key
  enterTransition: EnterTransition = fadeIn(),  // 進入動畫,默認淡入
  // ...
  content: @Composable (T) -> Unit,             // 實際 UI 內容
)
@Composable
fun MyNav3<T: Any>(
  backStack: SnapshotStateList<T>,                          // 導航返回棧
  descriptorProvider: (key: T) -> DestinationDescriptor<T>, // 路由描述符提供者,根據一個 Key 返回對應的路由描述符
) {
}

現在我們的代碼就更加靈活了,開發者可以在路由描述符裡配置各種信息:

MyNav3(
  backStack = backStack,
  descriptorProvider = { key ->
    when (key) {
      is ConversationList -> DestinationDescriptor(
        key = key,
        content = { ConversationListContent() }
      )
      is ConversationDetail -> DestinationDescriptor(
        key = key,
        enterTransition = scaleIn(), // 會話詳情頁使用縮放動畫
        content = { ConversationDetailContent(conversationId = key.id) }
      )
      else -> error("Unknown key: $key")
    }
  },
)

恭喜你,這正是 Nav3 的設計思路!在 Nav3 裡,NavEntry 充當了路由描述符的角色,它包含了具體 key 實例、UI 內容以及其他各種配置信息,而 EntryProvider 則是負責根據 Key 來返回對應的 NavEntry。

entryProvider.png

Nav3 的最後一塊版圖就是導航容器,它會觀察 backstack 的變化,當其發生變化時,它會向 EntryProvider 請求對應的 NavEntry,然後顯示其 UI 內容。在 Nav2 裡,這個容器是 NavHost,而在 Nav3 裡它是 NavDisplay:

navDisplay.png

簡單實際例子

百聞不如一見,以上面的聊天應用為例,包含兩個頁面:會話列表頁和會話詳情頁,通過 Nav3 實現導航的代碼如下:

// 定義路由
data object ConversationList
data class ConversationDetail(val id: Int)

val backStack: SnapshotStateList<Any> = remember {  
  mutableStateListOf<Any>(ConversationList) // 默認顯示會話列表頁  
}

NavDisplay(
    backStack = backStack,
    onBack = { // 返回時,移除棧頂元素
      backStack.removeLastOrNull()
    },
    entryProvider = { key ->
      when (key) {
        is ConversationList ->
            NavEntry(
                key = key,
                content = {
                  ConversationListContent(
                    onConversationClick = { conversationId ->
                      backStack.add(ConversationDetail(id = conversationId)) 
                    }
                  )
                },
            )

        is ConversationDetail ->
            NavEntry(
                key = key,
                content = {
                  ConversationDetailContent(key.id)
                },
            )

        else -> error("Invalid key: $key")
      }
    },
)

以上就是入門 Nav3 所需的所有知識了,還挺簡單的,對吧。再來看一遍 Nav3 的結構圖,現在是不是能更好理解了?

nav3架構圖.png

導航狀態管理

還記得在上面我們是怎麼聲明導航狀態的嗎?

val backStack: SnapshotStateList<Any> = remember {  
  mutableStateListOf<Any>(ConversationList)
}

無可否認,發生配置更改(如旋轉螢幕)和進程終止,remember{} 中的狀態會被重置,這就意味著 Backstack 會丟失,從而導致導航狀態丟失,這顯然是不可接受的。

你可以把 Backstack 提升到 ViewModel 裡進行管理,但這樣也仍有問題,因為即使你把 Backstack 放到 ViewModel 裡,進程終止時它仍然會丟失。

聰明的你會想到使用 rememberSaveable{} 來保存 Backstack 狀態,沒錯,這確實是個可行的方案,但問題是 rememberSaveable{} 底層使用的是 SavedStateHandle,而 SavedStateHandle 只能保存基本數據類型和實現了 Parcelable 或 Serializable 接口的對象,這就要求你的 Key 類型必須實現這些接口,所以這並不算一個特別好的解決方案,實現起來略麻煩。

為了解決這個問題,Nav3 提供了一個保存和恢復 Backstack 的便捷方法:rememberNavBackStack(),它用起來和 SnapshotStateList 差不多,事實上它底層實現就是 SnapshotStateList,但它會自動處理狀態保存和恢復的問題。不過,它要求 Key 類型要實現 NavKey 接口,同時能夠被 Kotlinx Serialization 序列化。

@Serializable
data object ConversationList : NavKey

@Serializable
data class ConversationDetail(val id: Int) : NavKey

val backStack: NavBackStack<NavKey> = rememberNavBackStack(ConversationList) // 默認顯示會話列表頁 
// 注意必須引入 kotlinx serialization 的依賴庫和插件

依然很簡單,對吧!

限定 ViewModel 的作用域為 NavEntry

一個導航庫的目標,不僅僅是“切換螢幕”,而是要提供一套完整的、健壯的“UI 導航和狀態管理”框架,為此 Google 在 Nav2 中內置了與其他 Jetpack 架構組件(如 Lifecycle、SaveState、ViewModel)的集成:

NavigationComponent.png

lifecycleOwner_ViewModelStoreOwner_SavedStateRegistryOwner.png

集成的組件 解決的核心問題 提供的能力
Lifecycle “何時” 加載/釋放資源? 狀態協調:知道螢幕是可見、在後台還是已銷毀。
ViewModel 如何在“配置更改”(如旋轉)中存活? 內存狀態:提供一個在 UI 重建時不被銷毀的場所。
saveState 如何在“進程終止”(內存回收)中存活? 持久狀態:提供一個在進程重建時可恢復的場所。

透過將這三者統一集成在 NavBackStackEntry 上,Nav2 才真正成為了一個完整的、健壯的“UI 導航和狀態管理”框架,而不只是單純的“頁面跳轉”工具。


下面我們重點看一下 ViewModel 和導航庫的集成。

在 Android 中,ViewModel 的實例被存儲在一個名為 ViewModelStore 的對象中。而持有 ViewModelStore 的對象被稱為 ViewModelStoreOwner。常見的 ViewModelStoreOwner 有 Activity、Fragment 以及 NavBackStackEntry(Nav2 中的導航返回棧條目)。

在 Nav2 裡,通常會使用 hiltViewModel()viewModel() 來獲取 ViewModel 實例,這些方法會自動查找最近的 NavBackStackEntry (ViewModelStoreOwner) 來獲取對應作用域的 ViewModel 實例。

💡 哎!那 Nav3 的 NavEntry 肯定也是 ViewModelStoreOwner 吧?很遺憾,NavEntry 並不是 ViewModelStoreOwner。但是!得益於 Nav3 的靈活設計,借助 NavEntryDecorator(裝飾器)可以給每個 NavEntry 關聯唯一的 ViewModelStoreOwner,從而實現將 ViewModel 的作用域限定為 NavEntry。

NavDisplay(
    entryDecorators = listOf(
        // 添加 view model store 裝飾器
        rememberViewModelStoreNavEntryDecorator(), // 注意必須引入 androidx.lifecycle:lifecycle-viewmodel-navigation3 依賴庫
        // 添加狀態保存的默認裝飾器
        rememberSaveableStateHolderNavEntryDecorator(),
    ),
    backStack = backStack,
    entryProvider = entryProvider { },
)

我們看一下 NavDisplay 的源碼:

@Composable  
public fun <T : Any> NavDisplay(  
  backStack: List<T>,    
  onBack: () -> Unit = {  
        if (backStack is MutableList<T>) {  
            backStack.removeLastOrNull()  
        }  
    },  
  entryDecorators: List<NavEntryDecorator<T>> =  
      listOf(rememberSaveableStateHolderNavEntryDecorator()),  
    // ...  
  entryProvider: (key: T) -> NavEntry<T>,  
) {  
}

注意參數 entryDecorators 本來就有默認值,作用是給 NavEntry 添加 SaveableStateHolder 裝飾器,我們在添加 ViewModelStore 裝飾器時,注意不要忘了默認值原有的裝飾器,否則使用 rememberSaveable{} 保存狀態會出問題。

多模組導航

多模組導航(Multi-module Navigation)是一種架構模式,它將導航圖(NavGraph)的定義分散到各自的功能模組(Feature Module)中,而不是集中在單一的 :app 模組裡。

依國際慣例,我們先看看在 Nav2 裡是怎麼實現多模組導航的。假設有一個 feature 模組 :conversation,裡面有 3 個螢幕,螢幕的 @Composable 函數可見性可以設置為 private,另外定義 NavController 和 NavGraphBuilder 的兩個擴展函數用於導航和構建導航圖:

多模組導航.png

此時我們就可以在 :app 模組的 NavHost 調用相應函數組裝導航圖了。

如果 :conversation 模組不需要與其他 feature 模組進行通信,還可以更進一步在 :conversation 模組內部封裝一個 NavGraphBuilder.conversationGraph() 函數用於組裝嵌套導航圖。

嵌套導航圖.png

之所以能這麼拆分組裝導航圖,得益於 NavHost 構建導航圖的 DSL API 設計:

NavGraphBuilder_DSL.png


再來看 Nav3 設置導航圖的地方,其實就是 entryProvider 嘛,它的類型是 (T) -> NavEntry<T>,並不是 DSL API:

@Composable
NavDisplay(
  // ...
  entryProvider: (T) -> NavEntry<T>
)

那怎麼辦,哎別慌,還有救!Nav3 提供了一個 entryProvider 的 DSL API:

EntryProviderScope_DSL.png

這個 entryProvider() 函數的返回值就是 (T) -> NavEntry<T>,那我們就可以這麼寫:

NavDisplay(
   // ...
   entryProvider = entryProvider { // this: EntryProviderScope
     // ...
   }
)

以文章最開始的例子,我們可以這麼改:

NavDisplay(
  // ...
-  entryProvider = { key ->
-    when (key) {
-      ...
-      is ConversationDetail -> NavEntry(
-           key = key,
-           content = {
-             ConversationDetailContent(key.id)
-           },
-         )
-      else -> error("Invalid key: $key")
-    }
-  },
+  entryProvider = entryProvider { // this: EntryProviderScope
+    ...
+    entry<ConversationDetail> { key ->
+      ConversationDetailContent(key.id)
+    }
+  },
)

EntryProviderScope 作用域下可以調用 entry() 函數,透過泛型指定 Key 類型,傳入 UI 內容即可。再也不需要手動判斷 Key 的類型,也不需要手動構建返回 NavEntry 了。

既然有了 DSL API,那麼自然就能仿照 Nav2 的方式,將導航圖的定義分散到各自的功能模組(Feature Module)中了。

// :conversation 模組
data object ConversionList

fun EntryProviderScope<Any>.conversationListEntry(
  backStack: SnapshotStateList<Any> // 這裡的 backStack 其實相當於 NavController
) {
  entry<ConversionList> {
    ConversionListContent(...)
  }
}

@Composable
private fun ConversionListContent(...) // ui 內容可以設置為私有
// :app 模組
NavDisplay(
   ...
-  entryProvider = { key ->
-    when (key) {
-      ...
-      entry<ConversationList> {
-        ConversationListContent(...)
-      }
-    }
-  },
+  entryProvider = entryProvider { // this: EntryProviderScope
+    conversationListEntry(backStack)
+  },
)

優雅 🥂 無需多言


接下來我們把事情變得複雜一點,我們已經有一個 :conversation 模組了,主要有兩個頁面:

  • 會話列表頁 ConversationList
  • 會話詳情頁 ConversationDetail

現在我們添加一個 :profile 個人資料模組,用戶可以在會話詳情頁面可以點擊頭像,打開聯絡人的資料。

conversation_and_profile_module.png

要從 :conversation 模組跳轉到 :profile 模組,怎麼搞,頁面的 Key 是定義在不同模組裡的,總不能讓 :conversation 模組依賴 :profile 模組吧:

千萬別這麼幹,feature 模組之間是不應該相互依賴的。

我們可以公開每個模組的導航事件,然後通過更上層的 :app 模組來連接這些導航事件:

hoist_nav_event_to_app_module.png

這樣寫沒什麼問題,不過實際上 :app 模組在這裡實際承擔了比較多的職責。既然 ConversationListScreen(onProfileClicked) 裡包含參數 onProfileClicked...


原文出處:https://juejin.cn/post/7568873939033260082


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝17   💬4   ❤️6
557
🥈
我愛JS
📝1   💬4   ❤️2
47
🥉
酷豪
1
#5
1
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付