Navigation3 NavBackStack 最佳写法 —— 在AI和官方Skills拉胯时

本以为使用顶尖大语言模型 + Android官方Skills,能够在AI时代轻松解决Nav3的迁移,但没想到又进了一次兔子洞。

TL;DR

无论是Android还是KMP,我推荐在创建NavBackStack时,一律采用Closed polymorphism并使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Serializable
internal sealed interface AppRoute : NavKey {
@Serializable
data object Home : AppRoute

@Serializable
data class Detail(val id: Long) : AppRoute
}

val backStack: NavBackStack<AppRoute> = rememberSerializable(
serializer = serializer(),
) {
NavBackStack(AppRoute.Home)
}

这样的方法最简单、最直观、不崩溃、活过进程重启、跨平台。

简单的方法

通常大家接触到的第一个方法是:

1
2
rememberNavBackStack(Home)
// or rememberNavBackStack(Home("start"), Details(42))

其实现为:

1
2
3
4
5
6
7
8
@Composable
public fun rememberNavBackStack(vararg elements: NavKey): NavBackStack<NavKey> {
return rememberSerializable(
serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer())
) {
NavBackStack(*elements)
}
}

这里的NavKeySerializer在用反射,其注释也提到了on **Android**, state is saved/restored using a **reflection-based serializer**; on **other platforms this will fail at runtime**,但是后半句不对,因为其他平台,也就是使用KMP时,压根没有这个只写NavKey的方法,自然谈不上fail at runtime

那在KMP中要怎么写呢?

混乱的现状

官方文档官方Sample上个月的Talk、还有各种教程和AI生成的代码中,有五花八门的写法,包括polymorphicsubclasssubclassesOfSealed等等。都不会提醒什么情况是出于什么考虑该选择什么代码。

我让AI使用android studio命令,调用nav3的Skill,参考官方的Samples,再加上自定义的最佳实践Skill,到最后也没能产出一个最佳实践。于是只能自己上了。

长长的文档

最有用的还是Kotlin Serialization的文档。别看链接那么多,它把单篇文档中的小标题也写上了。

如果不亲自阅读,是无法对Kotlin Serialization有一个全面的掌握的。比如插件和kotlinx-serialization-core负责serializer,而kotlinx-serialization-json负责JSON,而nav3与JSON无关,所以只需要前者。我记得好像官方文档一开始教的是json的版本?

在第四个文档,我们可以知道Kotlin Serialization会自动支持Closed polymorphism,也就是sealed,而并不需要特殊处理。

而Nav3选择默认Open polymorphism的原因,除了想要把选择权更多地交给开发者,还要受其推荐的多模块影响:

Feature module dependency diagram showing how implementation modules can depend on api modules.

而说实话,这种仅为了跳转页面,就去依赖其他功能模块的做法,有点污染模块关系的意味,就像AndroidX Paging这种让所有模块都增加依赖的做法,并不是一个可扩展的路径。个人更偏好将页面跳转交给调用者。

最终的结论

我们的要求如下:

  • 一个跨平台的统一写法。否则针对特定平台就要特定假设,这通常意味着注释、文档、甚至专门的Skill。
  • 避免反射。反射性能差、容易破坏R8/Proguard、不跨平台
  • 使用Closed polymorphism来管理所有页面
  • 能够解决进程重启的问题。否则直接用mutableStateListOf就好了。

这样我们可以得出最佳实践:

1
2
3
4
5
val backStack: NavBackStack<AppRoute> = rememberSerializable(
serializer = serializer(),
) {
NavBackStack(AppRoute.Home)
}

需要手动声明类型名,否则类型推断会错误

要注意的是,如果是Open polymorphism,就得用SavedStateConfiguration了。

Bonus

  • back得用removeLastOrNull,还有手动确保列表不空
  • 使用Snapshot.withMutableSnapshot确保中间状态列表不空
  • 声明类型mutableListOf<AppRoute>(AppRoute.Home, AppRoute.Settings)才能在Kotlin/Native通过编译