Jetpack Navigation 3:领航未来

Nav3 出世已经有一段时间了,翻过几篇文章,有些概念还是不太理解,还是自己动手写一篇文章吧,理清一下思路。

为什么要起新号?

我第一回听到 Nav3 时,心里是一万个问号:Goooooogle...不是哥们...搞什么呢?号废了就重开?

Jetpack Navigation(以下简称 Nav2)在 2018 年首次推出,在过去 7 年时间里它早已经被广泛使用,难道它有什么问题吗?如果有为什么不解决呢?而是等到今天才想着再生一个?

马化腾曾说过:"有时候你什么都没做错,错就错在你太老了",其实 Nav2 并没有什么大问题,错就错在它太老了,在过去 7 年里,Android 开发生态发生了翻天覆地的变化,包括程序架构、测试方式、UI 构建方式等等。

为什么要创建 Nav3,因为 Nav2 已经无法满足未来的发展需求了。

这几年手机屏幕越来越大,异形屏、折叠屏层出不穷,堂堂一个官方 Nav2 库,居然只支持单窗格布局,也就是说不管你的设备屏幕多大,永远只能显示一个路由的 UI 内容。支持多窗格布局是 Nav3 的重要目标之一。

其次,在可预见的未来,Compose 必将会成为 Android UI 的主流构建方式,因为是声明式 UI,讲究状态驱动。同样的,对于导航系统来说,也应该积极拥抱适应这种变化,Navigation 应该变得更加"声明式"或者说"响应式",显示哪个路由,应该由状态(backstack)来驱动,但是 Nav2 的导航返回栈完全是由 Nav2 库内部管理的,对开发者而言是个黑盒,无法直接操作控制。到了 Nav3,导航返回栈将完全由开发者自己管理,Nav3 只负责根据返回栈的状态来显示对应的 UI 内容,灵活性直接 next level。

依赖引入

在开始之前,我们需要先在项目里引入 Nav3 的依赖:

toml 复制代码
# 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"}
kotlin 复制代码
// 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:

因为会话列表页在整个 app 里仅有一个实例,所以我们使用 data object 来定义它。如果是聊天会话详情页,它并不唯一,这时我们可以使用 data class 来定义它:

字段 id 用于唯一标识某个会话。

至于 Backstack,它无非就是一个 Key 的列表,表示当前导航返回栈里的所有路由:

当然,为了让 Backstack 可以被观察,我们会使用 SnapshotStateList<> 而不是普通的 List<>,毕竟我们希望能够在 UI 上实时反映出 Backstack 的变化,而且 Nav3 也仅支持 Compose。

处在 Backstack 最后的 Key 就是当前显示的路由,例如,我们的 Backstack 列表里有一个 ConversationList,那么当前显示的路由就是会话列表页:

我们往 Backstack 里添加一个 ConversationDetail 实例,那么当前显示的路由就变成了会话详情页:

既然导航到新路由是向 Backstack 里添加 Key,返回上一路由自然是从 Backstack 里移除 Key。

显而易见,在 Nav3 里,导航其实就是往 Backstack 列表里添加或移除 Key 而已,navController?不存在的。

在 Nav2 里,如果要实现 singleTop 导航模式,就得在调用 NavController.navigate() 方法时传递 NavOptions,而在 Nav3 里,由于 Backstack 完全由我们自己管理,所以我们只需要在添加 Key 前检查 Backstack 里是否已经存在该 Key 即可,灵活性大大提升。

那么,Nav3 是怎么根据 Key 来显示对应的 UI 内容的呢?或者说,我们的 Key 和 UI 内容之间是怎么关联起来的呢?

我们可以先猜想一下,如果我们是 Nav3 的设计者,可以怎么做?一种思路是使用提供者模式(Provider Pattern),让开发者传递一个函数给 Nav3,这个函数接收一个 Key 作为参数,返回对应的 Composable UI 内容:

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

然后我们就可以在 MyNav3 里调用这个提供者函数来显示对应的 UI 内容了:

kotlin 复制代码
// 伪代码
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 内容,还有一些其他信息(比如动画效果):

kotlin 复制代码
// 伪代码
class DestinationDescriptor<T: Any>(
  key: T,                                       // 路由的 Key
  enterTransition: EnterTransition = fadeIn(),  // 进入动画,默认淡入
  // ...
  content: @Composable (T) -> Unit,             // 实际 UI 内容
)
kotlin 复制代码
// 伪代码
@Composable
fun<T: Any> MyNav3(
  backStack: SnapshotStateList<T>,                          // 导航返回栈
  descriptorProvider: (key: T) -> DestinationDescriptor<T>, // 路由描述符提供者,根据一个 Key 返回对应的路由描述符
)

现在我们的代码就更加灵活了,开发者可以在路由描述符里配置各种信息:

kotlin 复制代码
// 伪代码
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。

Nav3 的最后一块版图就是导航容器,它会观察 backstack 的变化,当其发生变化时,它会向 EntryProvider 请求对应的 NavEntry,然后显示其 UI 内容。在 Nav2 里,这个容器是 NavHost,而在 Nav3 里它是 NavDisplay:

简单实际例子

百闻不如一见,以上面的聊天应用为例,包含两个页面:会话列表页和会话详情页,通过 Nav3 实现导航的代码如下:

kotlin 复制代码
// 定义路由
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 的结构图,现在是不是能更好理解了?

导航状态管理

还记得在上面我们是怎么声明导航状态的吗?

kotlin 复制代码
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 序列化。

kotlin 复制代码
@Serializable
data object ConversationList : NavKey

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

val backStack: NavBackStack<NavKey> = rememberNavBackStack(ConversationList) // 默认显示会话列表页 

// 注意必须引入 kotlinx serialization 的依赖库和插件

依然很简单,对吧!

一个导航库的目标,不仅仅是"切换屏幕",而是要提供一套完整的、健壮的"UI 导航和状态管理"框架,为此 Google 在 Nav2 中内置了与其他 Jetpack 架构组件(如 Lifecycle、SaveState、ViewModel)的集成:

集成的组件 解决的核心问题 提供的能力
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。

kotlin 复制代码
NavDisplay(
    entryDecorators = listOf(
        // 添加 view model store 装饰器
        rememberViewModelStoreNavEntryDecorator(), // 注意必须引入 androidx.lifecycle:lifecycle-viewmodel-navigation3 依赖库
        // 添加状态保存的默认装饰器
        rememberSaveableStateHolderNavEntryDecorator(),
    ),
    backStack = backStack,
    entryProvider = entryProvider { },
)

我们看一下 NavDisplay 的源码:

kotlin 复制代码
@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 的两个拓展函数用于导航和构建导航图:

此时我们就可以在 :app 模块的 NavHost 调用相应函数组装导航图了。

如果 :conversation 模块不需要与其他 feature 模块进行通信,还可以更进一步在 :conversation 模块内部封装一个 NavGraphBuilder.conversationGraph() 函数用于组装嵌套导航图。

之所以能这么拆分组装导航图,得益于 NavHost 构建导航图的 DSL API 设计:


再来看 Nav3 设置导航图的地方,其实就是 entryProvider 嘛,它的类型是 (T) -> NavEntry<T>,并不是 DSL API:

kotlin 复制代码
@Composable
NavDisplay(
  // ...
  entryProvider: (T) -> NavEntry<T>
)

那咋办,哎别慌,还有救!Nav3 提供了一个 entryProvider 的 DSL API:

这个 entryProvider() 函数的返回值就是 (T) -> NavEntry<T>,那我们就可以这么写:

diff 复制代码
NavDisplay(
   // ...
-  entryProvider = { key ->
-
-  }
+  entryProvider = entryProvider { // this: EntryProviderScope
+
+  }
)

以文章最开始的例子,我们可以这么改:

diff 复制代码
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)中了。

kotlin 复制代码
// :conversation 模块
data object ConversionList

fun EntryProviderScope<Any>.conversationListEntry(
  backStack: SnapshotStateList<Any> // 这里的 backStack 其实相当于 NavController
) {
  entry<ConversionList> {
    ConversionListContent(...)
  }
}

@Composable
private fun ConversionListContent(...) // ui 内容可以设置为私有
diff 复制代码
// :app 模块
NavDisplay(
   ...
   entryProvider = entryProvider { // this: EntryProviderScope
     ...
-    entry<ConversationList> {
-	   ConversationListContent(...)
-	 }
+    conversationListEntry(backStack)
   },
)

优雅 🥂 无需多言


接下来我们把事情变得复杂一点,我们已经有一个 :conversation 模块了,主要有两个页面:

  • 会话列表页 ConversationList
  • 会话详情页 ConversationDetail

现在我们添加一个 :profile 个人资料模块,用户可以在会话详情页面可以点击头像,打开联系人的资料。

要从 :conversation 模块跳转到 :profile 模块,咋搞,页面的 Key 是定义在不同模块里的,总不能让 :conversation 模块依赖 :profile 模块吧: ![[feature模块不要互相依赖.png]] 千万别这么干,feature 模块之间是不应该相互依赖的。

我们可以公开每个模块的导航事件,然后通过更上层的 :app 模块来连接这些导航事件:

这样写没什么问题,不过实际上 :app 模块在这里实际承担了比较多的职责。既然 ConversationListScreen(onProfileClicked) 里包含参数 onProfileClicked,这证明 ConversationListScreen 它本身是知道自己需要跳转到个人资料页的,这个过程和 :app 模块有半毛钱关系吗?一定要 :app 模块来参与吗?

有更好的做法吗?有的。

我们可以把 feature 模块进行拆分,分为 :feature:api:feature:impl。主要就是把导航的 Key 拆分到 :feature:api 里:

  • :feature:impl 用于包含界面的实际内容,各个 :feature:impl 之间严格隔离,对话模块 :conversation:impl 不应该也不能访问个人资料模块 :profile:impl 的 UI。
  • :feature:api 仅定义页面导航的 Key。
  • :feature:impl 除了依赖于对应的 :feature:api,还可以依赖其他 feature 模块的 :feature:api,例如::conversation:impl 除了依赖 :conversation:api,还可以依赖 :profile:api,因为它需要跳转到 Profile 模块。
kotlin 复制代码
// :conversation:api
data object ConversationList
data class ConversationDetail(val id: Int)
kotlin 复制代码
// :profile:api
data class Profile(prifileId: Int)
kotlin 复制代码
// :conversation:impl 
// 同时依赖 :conversation:api 和 :profile:api
fun EntryProviderScope<Any>.conversationListEntry(backStack: SnapshotStateList<Any>) {
  entry<ConversionList> {
    ConversionListContent(
      onConversationClicked = { id ->
        backStack.add(ConversationDetail(id))
      }
    )
  }
}

fun EntryProviderScope<Any>.conversationDetailsEntry(backStack: SnapshotStateList<Any>) {
  entry<ConversationDetail> { key ->
    ConversionDetailsContent(
      conversationId = key.id,
      onProfilePhotoClicked = { profileId ->
        backStack.add(Profile(profileId))
      }
    )
  }
}

@Composable
private ConversionListContent(...)

@Composable
private ConversionDetailsContent(...)
kotlin 复制代码
// :profile:impl <-- :profile:api
fun EntryProviderScope<Any>.profileEntry() {
  entry<Profile> { key ->
    ProfileContent(profileId = key.id)
  }
}

@Composable
private ProfileContent(...)

🎉🎉🎉

自定义动画

如果要设置全局的进退场动画,可以直接给 NavDisplay() 传递参数 transitionSpecpopTransitionSpec

很简单没啥好说的。

如果要给某个页面设置单独的进出场动画呢?假设我们想让个人资料页面是从下往上弹入,从上往下退出。 我们前面说过,NavEntry 充当了"路由描述符"的角色,它的首要职责当然是描述 UI,除此之外,它还可以描述一个路由的进出场动画,换句话说,NavEntry 除了包含 UI 还可以包含一些其他的与路由相关的额外信息(存储在 metaData 里):

那么应该这么写:

diff 复制代码
// :profile:impl
fun EntryProviderScope<Any>.profileEntry() {
  entry<Profile>(
+   metaData = ???
  ) { key ->
    ProfileContent(profileId = key.id)
  }
}

问题来了,我应该怎么填这个 Map<> 呢?难道 value 填字符串 "进场动画", key 填一个 EnterTransition 实例?

不知道你有没有想过,为什么 NavEntry 要这么设计?像 NavDisplay 一样,直接搞两个参数 transitionSpec & popTransitionSpec 不就完事了吗,不用想要填什么 Key、也不用想要传递什么类型的 Value。

设计成 mataData: Map<String, Any> 主要还是为了灵活性,开发者可能会开发一些和 Nav3 配套的自定义组件,这样设计可以让开发者给 NavEntry 绑定任何数据。更灵活也意味着更易出错,因为失去了约束,没有任何的类型安全保障。

好消息是,Nav3 提供了一些辅助函数,使得我们可以方便地构建 Map<String, Any>

kotlin 复制代码
public object NavDisplay {
  public fun transitionSpec(
    transitionSpec: AnimatedContentTransitionScope<Scene<*>>.() -> ContentTransform?  
  ): Map<String, Any> = mapOf(TRANSITION_SPEC to transitionSpec)

  public fun popTransitionSpec(  
    popTransitionSpec: AnimatedContentTransitionScope<Scene<*>>.() -> ContentTransform?  
  ): Map<String, Any> = mapOf(POP_TRANSITION_SPEC to popTransitionSpec)
  ...
}

所以我们可以这么用:

diff 复制代码
// :profile:impl
fun EntryProviderScope<Any>.profileEntry() {
  entry<Profile>(
+    metadata = NavDisplay.transitionSpec {  
+      slideInVertically { it } togetherWith ExitTransition.KeepUntilTransitionsFinished  
+    } + NavDisplay.popTransitionSpec {  
+      EnterTransition.None togetherWith slideOutVertically { it }  
+    } + NavDisplay.predictivePopTransitionSpec {  
+      EnterTransition.None togetherWith slideOutVertically { it }  
+    },  
  ) { key ->
    ProfileContent(profileId = key.id)
  }
}

因为这些辅助函数返回值是 Map<String, Any>,所以我们可以把结果相加,从而构建 navEntry 的最终元数据。

Scene 实现多窗格布局

文章的开头曾提到:支持多窗格布局是 Nav3 的重要目标之一,场景(Scene)正是为此而生!这是一个新概念,在 Nav2 里并没有对应的东西,这也很合理,毕竟 Sense 创建出来就是为了解决 Nav2 解决不了的大屏多设备适配问题。

NavDisplay 使用 NavEntry 决定显示什么内容 ,而 Scene 则决定了以什么样的形式渲染NavEntry 的内容。默认情况下,NavDisplay 使用 SinglePaneScene,这个 Scene 仅渲染返回栈的最后一个页面,也就是单页模式。

Scene 可以决定同时渲染多个 NavEntry 的内容。假设你的设备是一台平板或者折叠手机,它有足够大的屏幕来并排显示两个窗口,我们只要创建一个 TwoPaneScene 即可:

你可以根据想要的显示效果来创建任何你想要的 Scene:

问题是,同时存在多个 Scene 时,NavDisplay 如何决定使用哪个 Scene?

其采用一种叫做 Scene Straigtegies(场景策略)的技术方案。

Scene Straigtegies(场景策略)只会干一件事,那就是问:"我可以创建一个 Scene 吗?"如果可以,它就返回一个 Scene,否则它就返回 null。

假设我们有两个 Scene Straigtegies(场景策略),它们前后排好队:

  • 第一个 SceneStraigtegy1 问:"我可以创建一个 Scene1 吗?"
    • 如果可以,那么 SceneStraigtegy1 返回 Scene1,NavDisplay 根据 Scene1 渲染界面。
    • 如果不可以,那么 第二个 SceneStraigtegy2 问:"我可以创建一个 Scene2 吗?"
      • 如果可以,那么 SceneStraigtegy2 返回 Scene2,NavDisplay 根据 Scene2 渲染界面。
      • 如果不可以,那么 NavDisplay 最终会使用 SinglePaneStraigtegy,即以单页模式显示 BackStack 的最后一个 NavEntry。

假设我们正在实现一个 ListDetailScene:

它首先问:"我可以创建一个 ListDetailScene 吗?" 其实它问的是什么,它问的是------"请问你的屏幕够大吗?"

如果是一台普通手机,那显然是不够大,所以 NavDisplay 只能使用 SinglePaneStraigtegy 来渲染单个 NavEntry。

那如果是平板设备呢?屏幕的确足够大,ListDetailScene 得到肯定的回答,它就返回一个 ListDetailScene,这个 ListDetailScene 会打开 BackStack,它会检查:

  • 栈顶的 NavEntryB 有没有带有"详情"通行证;
  • 栈顶下一个 NavEntryA 有没有带有"列表"通行证;

这个"通行证"是什么意思呢?可以理解为 NavEntry 携带的一些额外信息,能够帮助 ListDetailScene 判断是否当前 NavEntry 是否符合要求。想象一下,如果处在栈顶的两个页面都是详情页,那就不应该并排显示嘛,我们只是想列表页和详情页并排显示。

如果检查通过,那么 NavDisplay 根据 ListDetailScene 渲染界面,否则,就只能使用兜底的 SinglePaneStraigtegy 了。


OK,相信大家已经充分了解 Scene 和 SceneStraigtegy 的概念了,下面来实战一下,实现一个 ListDetailScene:

好消息是,官方的 Material 库已经为我们提供了现成的 ListDetailSceneStrategy,我们根本不需要自己实现,只需把它用起来就好。

首先,我们得告诉 NavDisplay 要使用什么 SceneStraigtegy:

kotlin 复制代码
NavDisplay(
  sceneStrategy = rememberListDetailSceneStrategy<Any>(),
  ...
)
// 注意要引入 "androidx.compose.material3.adaptive:adaptive-navigation3"

不是说 SceneStraigtegy(场景策略)是一个有序列表吗?怎么这里就只提供了一个 ListDetailSceneStrategy?如果不满足这个策略那怎么显示?别担心,如果任何策略都不满足,会自动回退到 SinglePaneSceneStrategy。当然,你也可以使用中缀函数 then 显式指定两个 SceneStrategy:

kotlin 复制代码
NavDisplay(
  sceneStrategy = 
    rememberListDetailSceneStrategy<Any>() then SinglePaneSceneStrategy(),
  ...
)

这样写和前面是等价的。

其次,我们需要告诉 NavEntry 它们在 Scene 中扮演什么角色,它们是"列表页"还是"详情页",本质上就是为 NavEntry 提供一些额外的信息,这个我们前面已经学过了,就是利用 NavEntry 的 metaData 参数:

diff 复制代码
// :conversation:impl 
fun EntryProviderScope<Any>.conversationListEntry(backStack: SnapshotStateList<Any>) {
  entry<ConversionList>(
+    metaData = ListDetailSceneStrategy.listPane()
  ) {
    ConversionListContent(...)
  }
}

fun EntryProviderScope<Any>.conversationDetailsEntry(backStack: SnapshotStateList<Any>) {
  entry<ConversationDetail>(
+    metaData = ListDetailSceneStrategy.detailPane()
  ) { key ->
    ConversionDetailsContent(...)
  }
}

同样的,我们也不需要手动构建 Map<String, Any>,直接调用相应的辅助函数即可。

真香!😊

自定义场景 Scene

你说,要是我也写一个双窗格布局,屏幕够宽我就左右并排显式,屏幕不够宽就上下并排显式,有没有搞头?

Google:行,这项目我投了!

在开始动手前,我们先来看看 Nav3 默认的 SinglePaneScene 源码,方便我们待会借鉴(抄。

kotlin 复制代码
public class SinglePaneSceneStrategy<T : Any> : SceneStrategy<T> {
  override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T> {
    return SinglePaneScene(
      key = entries.last().contentKey,
      entry = entries.last(),
      previousEntries = entries.dropLast(1),
    )
  }
}

可以看到单页策略 SinglePaneSceneStrategy 非常简单,继承自 SceneStrategy,方法 calculateScene() 接收 List<NavEntry<T>>,也就是返回栈里的所有条目,这个方法负责计算并返回 Scene,如果返回 null 就是不采用 Scene,此时会交由下一个 SceneStrategy 来计算。

由于单页模式的场景非常简单,无脑地显示返回栈最后一个 NavEntry 的内容就行了,所以 calculateScene() 总是返回一个 SinglePaneScene。

再来看 SinglePaneScene 构造函数的三个参数:

  • key: Any:唯一标识符,主要用于动画用途。
  • entry: NavEntry<T>:要显示内容的 NavEntry。
  • previousEntries: List<NavEntry<T>>:当前页之前的 NavEntry 列表,主要用于预测性返回动画。
kotlin 复制代码
internal data class SinglePaneScene<T : Any>(
  override val key: Any,
  val entry: NavEntry<T>,
  override val previousEntries: List<NavEntry<T>>,
) : Scene<T> {
  override val entries: List<NavEntry<T>> = listOf(entry)
  override val content: @Composable () -> Unit = { entry.Content() }
    ...
}

另外,SinglePaneScene 还重写了 entriescontent 两个变量:

  • entries: List<NavEntry<T>>:表示当前的场景中显示的 NavEntry,由于这里是单页模式场景,自然就是栈顶它自己。
  • content: @Composable () -> Unit:表示该场景渲染的内容,因为是单页模式,直接调用栈顶 NavEntry 的 Content() 函数即可。

照着葫芦画瓢,我们先创建一个 TwoPaneScene:

kotlin 复制代码
class TwoPaneScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val firstEntry: NavEntry<T>,
    val secondEntry: NavEntry<T>,
) : Scene<T> {
  override val entries: List<NavEntry<T>> = listOf(firstEntry, secondEntry)

  override val content: @Composable (() -> Unit) = {
    val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
    if (windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
      Row(modifier = Modifier.fillMaxSize()) {
        Box(modifier = Modifier.weight(0.5f)) { firstEntry.Content() }
        Box(modifier = Modifier.weight(0.5f)) { secondEntry.Content() }
      }
    } else {
      Column(modifier = Modifier.fillMaxSize()) {
        Box(modifier = Modifier.weight(0.5f)) { firstEntry.Content() }
        Box(modifier = Modifier.weight(0.5f)) { secondEntry.Content() }
      }
    }
  }

  companion object {
    internal const val TWO_PANE_KEY = "TwoPane"

    fun twoPane(): Map<String, Any> = mapOf(TWO_PANE_KEY to true)
  }
}

代码和 SinglePaneScene 类似,只不过因为我们是双窗格布局,所以要传入 firstEntrysecondEntry。在渲染内容时,我们判断屏幕的宽度是否足够,如果是那就横向布局,否则就纵向布局。

其次,创建了一个辅助函数,方便开发者快速构建 NavEntry 的元数据。

然后我们就可以创建 TwoPaneSceneStrategy 了:

kotlin 复制代码
class TwoPaneSceneStrategy<T : Any>() : SceneStrategy<T> {
  override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {
    val lastTwoEntries = entries.takeLast(2)

    if (
        lastTwoEntries.size != 2 ||
            !lastTwoEntries.all { it.metadata.containsKey(TwoPaneScene.TWO_PANE_KEY) }
    ) return null

    val firstEntry = lastTwoEntries.first()
    val secondEntry = lastTwoEntries.last()

    val sceneKey = Pair(firstEntry.contentKey, secondEntry.contentKey)

    return TwoPaneScene(
        key = sceneKey,
        previousEntries = entries.dropLast(1),
        firstEntry = firstEntry,
        secondEntry = secondEntry,
    )
  }
}

calculateScene() 方法里,我们获取最后两个 NavEntry,判断它们是否携带 TWO_PANE_KEY,如果不是就返回 null,如果是则返回 TwoPaneScene。

kotlin 复制代码
NavDisplay(
  sceneStrategy = TwoPaneSceneStrategy<Any>(),
  entryProvider = entryProvider {
    entry<RouteA>(
      metadata = TwoPaneScene.twoPane(),
    ) {
      ScreenA(onOpenScreenB = { backStack.add(RouteB(content = "From Screen A")) })
    }

    entry<RouteB>(
      metadata = TwoPaneScene.twoPane(),
    ) { key ->
      ScreenB(content = key.content)
    }
  },
)

使用起来也非常简单,给 NavDisplay 设置 TwoPaneSceneStrategy 场景策略,同时调用 TwoPaneScene.twoPane() 给两个 NavEntry 设置相应的元数据。

条件导航

过去 Nav2 里的 BackStack 完全由其内部持有,彼时处理条件导航的痛苦依然历历在目。好在一切都过去了,下面来看看 Nav3 是如何解决这个痛点的。

首先我写了 3 个简单页面:

  • Login:登录页
  • Home:首页(默认页面,用户无需登录)
  • Profile:个人资料页(必须登录)

我们创建一个自定义的 AppBackStack 类,要求传入初始路由和登录路由。AppBackStack 里面声明一个空接口 RequiresLogin,如果路由 Key 实现了该接口,则表明跳转相应页面要求先登录。当然,AppBackStack 内部需要有一个可被观察的列表来存储 Back Stack,所以这里使用了 mutableStateListOf(),初始值为默认路由。

kotlin 复制代码
class AppBackStack<T : Any>(
    private val startRoute: T,
    private val loginRoute: T,
) {
    
  interface RequiresLogin
    
  var isLoggedIn by mutableStateOf(false)
    private set
    
  val backStack = mutableStateListOf(startRoute)
}

后续在往 Back Stack 添加路由时,我们就可以通过 is RequiresLogin 判断是否需要登录。

diff 复制代码
class AppBackStack<T : Any>(
    private val startRoute: T,
    private val loginRoute: T,
) {
    
  interface RequiresLogin
    
  val backStack = mutableStateListOf(startRoute)
  
+ private var onLoginSuccessRoute: T? = null

+  fun add(route: T) {
+    if (route is RequiresLogin && !isLoggedIn) {
+      onLoginSuccessRoute = route
+      backStack.add(loginRoute)
+    } else {
+      backStack.add(route)
+    }
+  }

+  fun login() {
+    isLoggedIn = true
+    backStack.add(onLoginSuccessRoute ?: startRoute)
+    onLoginSuccessRoute = null
+    backStack.remove(loginRoute)
+  }
}

如果目标页面要求先登录,那么就把目标页面先暂时存到 onLoginSuccessRoute,再跳转到登录页面,后续登录成功了再跳转到前面暂存的目标页面。当然如果目标页面不要求登录,或者我们已经登录,那么就直接跳转。

diff 复制代码
class AppBackStack<T : Any>(...) {

  interface RequiresLogin

  private var onLoginSuccessRoute: T? = null

  var isLoggedIn by mutableStateOf(false)
    private set

  val backStack = mutableStateListOf(startRoute)

  fun add(route: T) { ... }

+  fun logout() {
+    isLoggedIn = false
+    backStack.removeAll { it is RequiresLogin }
+  }
}

如果退出登录,那么就重置 isLoggedIn,并把所有要求登录的页面全部弹出。(这一块可以根据具体业务来写,也可以全部弹出,导航到登录页)

diff 复制代码
class AppBackStack<T : Any>(...) {
  ...
  val backStack = mutableStateListOf(startRoute)
  
+  fun remove() = backStack.removeLastOrNull()
  ...
}

最后再添加一个便捷函数用于弹出栈顶。

好了,整个 AppBackStack 就完成了,完整代码如下:

kotlin 复制代码
class AppBackStack<T : Any>(
    private val startRoute: T,
    private val loginRoute: T,
) {

  interface RequiresLogin

  private var onLoginSuccessRoute: T? = null

  var isLoggedIn by mutableStateOf(false)
    private set

  val backStack = mutableStateListOf(startRoute)

  fun add(route: T) {
    if (route is RequiresLogin && !isLoggedIn) {
      onLoginSuccessRoute = route
      backStack.add(loginRoute)
    } else {
      backStack.add(route)
    }
  }

  fun remove() = backStack.removeLastOrNull()

  fun login() {
    isLoggedIn = true
    backStack.add(onLoginSuccessRoute ?: startRoute)
    onLoginSuccessRoute = null
    backStack.remove(loginRoute)
  }

  fun logout() {
    isLoggedIn = false
    backStack.removeAll { it is RequiresLogin }
  }
}

我们把它用起来:

kotlin 复制代码
data object Home
data object Login
data object Profile : AppBackStack.RequiresLogin

val appBackStack: AppBackStack<Any> = remember {
  AppBackStack(startRoute = Home, loginRoute = Login)
}

NavDisplay(
  backStack = appBackStack.backStack,
  entryProvider = entryProvider {
    entry<Login> {
      LoginScreen(
        onLoginSuccess = { appBackStack.login() },
        onSkipLogin = { appBackStack.remove() },
      )
    }

  entry<Home> {
    Home(
      isLoggedIn = appBackStack.isLoggedIn,
      onOpenProfile = { appBackStack.add(Profile) },
      onOpenLoginScreen = { appBackStack.add(Login) },
    )
  }

  entry<Profile> { 
    Profile(onLogout = { appBackStack.logout() }) }
  },
)

不得不佩服 Nav3 设计上的灵活性,受限于篇幅(实际上已经很长了),不能把所有的内容全部讲完,其实官方有一个 github 仓库:Navigation 3 - Code recipes,里面有非常多的实际例子,比如如何将结果返回给上一个路由,大家感兴趣可以去探索一下。

截至到发稿,Nav3 仍处于 β 阶段,关于 deeplink 还没有消息,emm,后面再看看决定要不要再写下集吧。Bye~


参考链接

相关推荐
云存储小天使3 小时前
安卓蛙、苹果蛙为什么难互通?
android
陈大头铃儿响叮当5 小时前
Android Studio升级后,Flutter运行android设备报错
android·flutter·android studio
勤劳打代码5 小时前
isar_flutter_libs 引发 Namespace not specified
android·flutter·groovy
奔跑吧 android6 小时前
【android bluetooth 协议分析 18】【PBAP详解 2】【车机为何不显示电话号码为空的联系人信息】
android·蓝牙电话·hfp·pbap·电话簿
深盾科技6 小时前
安卓二次打包技术深度拆解:从逆向篡改到防护逻辑
android
4Forsee6 小时前
【Android】消息机制
android·java·前端
2501_915921438 小时前
iOS 虚拟位置设置实战,多工具协同打造精准调试与场景模拟环境
android·ios·小程序·https·uni-app·iphone·webview
龚礼鹏8 小时前
Android 图像显示框架三——演示demo以及解析
android·交互
QuantumLeap丶8 小时前
《Flutter全栈开发实战指南:从零到高级》- 11 -状态管理Provider
android·flutter·ios