Jetpack Compose Navigation3:返回栈管理、大屏适配与自定义策略

Navigation3 相比 Navigation2 来说,区别主要有两点:

  1. 导航返回栈从由 Navigation 内部控制,变为由开发者控制。
  2. Navigation2 只适用于单页模式,一个屏幕只能显示一个路由对应的页面。而 Navigation3 可适配大屏。

添加依赖

kotlin 复制代码
plugins {
    // Kotlin serialization plugin
    id("org.jetbrains.kotlin.plugin.serialization") version "2.0.21"
}

dependencies {
    // Navigation3
    implementation("androidx.navigation3:navigation3-runtime:1.0.0-rc01")
    implementation("androidx.navigation3:navigation3-ui:1.0.0-rc01")

    // Navigation3 Adaptive
    implementation("androidx.compose.material3.adaptive:adaptive-navigation3:1.3.0-alpha03")

    // Kotlin Serialization
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.6.0")

    // Navigation3 ViewModel
    implementation("androidx.lifecycle:lifecycle-viewmodel-navigation3:2.10.0-rc01")
}

compileSdkVersion 需要 36 以上:

kotlin 复制代码
android {
    compileSdk = 36
}

简单用法

我们以商品列表页和商品详情页为例:

kotlin 复制代码
data object ProductList
data class ProductDetail(
    val name: String,
    val description: String
)

@Composable
fun ProductList(
    products: List<ProductDetail>,
    onProductClick: (ProductDetail) -> Unit,
    modifier: Modifier = Modifier
) {
    LazyColumn(modifier = modifier.fillMaxSize()) {
        items(products) { product ->
            Text(
                text = product.name,
                modifier = Modifier
                    .fillMaxSize()
                    .clickable { onProductClick(product) }
                    .padding(16.dp)
            )
        }
    }
}

@Composable
fun ProductDetail(
    product: ProductDetail,
    modifier: Modifier = Modifier
) {
    Column(
        modifier = modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = product.name,
            fontWeight = FontWeight.Bold,
            fontSize = 30.sp
        )
        Column(
            modifier = Modifier
                .fillMaxSize()
                .weight(1f),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(
                text = product.description,
                fontSize = 26.sp
            )
        }
    }
}

注意:ProductDetailProductList 有两重意思:既封装了页面的数据,又作为页面的导航节点。

然后是 MyApp 函数:

kotlin 复制代码
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ComposeNavigation3TestTheme {
                MyApp()
            }
        }
    }
}

@Composable
fun MyApp(modifier: Modifier = Modifier) {
    // 返回栈,初始页面是商品列表
    // Navigation3 会自动展示栈顶页面
    val backStack = remember { mutableStateListOf<Any>(ProductList) }
    NavDisplay( // 入口函数
        backStack = backStack, // backStack参数是导航返回栈
        onBack = { // onBack回调是返回的监听
            // 返回逻辑为:移除栈顶元素
            backStack.removeLastOrNull()
        },
        entryProvider = { key -> // entryProvider回调用于定义每个页面的NavEntry
            when (key) {
                // 商品列表页面
                is ProductList -> NavEntry(key) {
                    val products = mutableListOf<ProductDetail>()
                    repeat(30) {
                        products.add(ProductDetail("Product $it", "Description of Product $it"))
                    }
                    ProductList(
                        products = products,
                        onProductClick = { product ->
                            // 跳转到商品详情页面
                            backStack.add(ProductDetail(product.name, product.description))
                        },
                        modifier
                    )
                }
                // 商品详情页面
                is ProductDetail -> NavEntry(key) {
                    ProductDetail(
                        product = key,
                        modifier
                    )
                }

                else -> error("Unknown key: $key")
            }
        }
    )
}

运行效果:

可以看到,商品列表页向商品详情页传递数据非常容易,只需将数据加入到返回栈,详情页自然能在 entryProvider 回调中收到。

保存和管理导航状态

现在,我们旋转屏幕,会导致 Activity 的重建,导致返回栈被清空。

解决方法也很简单:只需给每一个 data class 加上 @Serializable 注解,然后实现 NavKey 接口。

状态恢复要求数据是可序列化的

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

@Serializable
data class ProductDetail(
    val name: String,
    val description: String
) : NavKey

再将返回栈的声明改为 val backStack = rememberNavBackStack(ProductList) 即可。

大屏适配

Navigation 3 中的场景(Scene)解决了大屏适配的问题,它可以显示一个或多个 NavEntry 实例,也就是同时显示多个 NavEntry 页面。

我们可以给 NavDisplay 函数传入一个 sceneStrategy 参数,来指定场景策略。默认策略是 SinglePaneSceneStrategy,单页场景模式。

我们来使用 ListDetailSceneStrategy,列表详情页面场景策略,它会自动根据当前屏幕大小进行页面分配。

kotlin 复制代码
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
fun MyApp(modifier: Modifier = Modifier) {
    val backStack = rememberNavBackStack(ProductList)
    // 创建ListDetailSceneStrategy实例
    val listDetailStrategy = rememberListDetailSceneStrategy<Any>()
    NavDisplay(
        backStack = backStack,
        onBack = {
            backStack.removeLastOrNull()
        },
        sceneStrategy = listDetailStrategy, // 使用ListDetailSceneStrategy
        entryProvider = { key ->
            when (key) {
                is ProductList -> NavEntry(
                    key = key,
                    metadata = ListDetailSceneStrategy.listPane( // 修改处
                        detailPlaceholder = {
                            Column(
                                modifier = Modifier
                                    .fillMaxSize(),
                                verticalArrangement = Arrangement.Center,
                                horizontalAlignment = Alignment.CenterHorizontally
                            ) {
                                Text("Choose a product from the list", fontSize = 26.sp)
                            }
                        }
                    )
                ) {
                    val products = mutableListOf<ProductDetail>()
                    repeat(30) {
                        products.add(ProductDetail("Product $it", "Description of Product $it"))
                    }
                    ProductList(
                        products = products,
                        onProductClick = { product ->
                            backStack.add(ProductDetail(product.name, product.description))
                        },
                        modifier
                    )
                }

                is ProductDetail -> NavEntry(
                    key = key,
                    metadata = ListDetailSceneStrategy.detailPane() // 修改处
                ) {
                    ProductDetail(
                        product = key,
                        modifier
                    )
                }

                else -> error("Unknown key: $key")
            }
        }
    )
}

我们给列表页和详情页的 NavEntry 都传入了 metadata 参数,通过 listPane()detailPane() 来告知了 Navigation 列表页和详情页的 NavEntry 分别是哪个。

我们还给 listPane() 传入了 detailPlaceholder(详情页占位符)参数,这样未选中列表项时,会默认显示占位的内容。

运行效果:

自定义场景

为了完成自定义场景,我们先来简单看看内置的单页模式场景的源码:

kotlin 复制代码
internal data class SinglePaneScene<T : Any>(
    override val key: Any, // 唯一标识符,用于动画
    val entry: NavEntry<T>, // 当前页的 NavEntry
    override val previousEntries: List<NavEntry<T>>,  // 添加当前页之前的 NavEntry 列表,用于预测性返回动画
) : Scene<T> { // 实现 Scene 接口
    override val entries: List<NavEntry<T>> = listOf(entry) // 当前场景需要显示的 NavEntry

    override val content: @Composable () -> Unit = { entry.Content() } // 当前场景的内容

    // equals、hashCode、toString 方法
}

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),
        )
    }
}

简单了解过后,我们来完成双页模式场景:

kotlin 复制代码
data class DoublePaneScene<T : Any>(
    override val key: Any,
    override val previousEntries: List<NavEntry<T>>,
    val startEntry: NavEntry<T>, // 左侧面板导航条目
    val endEntry: NavEntry<T> // 右侧面板导航条目
) : Scene<T> {
    override val entries: List<NavEntry<T>> = listOf(startEntry, endEntry)

    override val content: @Composable (() -> Unit) = {
        // 获取窗口大小信息
        val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
        // 如果宽度大于等于中屏,则显示双列
        if (windowSizeClass.isWidthAtLeastBreakpoint(WIDTH_DP_MEDIUM_LOWER_BOUND)) {
            Row(modifier = Modifier.fillMaxSize()) {
                Column(modifier = Modifier.weight(0.22f)) {
                    startEntry.Content()
                }
                Column(modifier = Modifier.weight(0.78f)) {
                    endEntry.Content()
                }
            }
        } else {
            endEntry.Content()
        }
    }

    companion object {
        internal const val START_PANE_KEY = "StartPane"
        internal const val END_PANE_KEY = "EndPane"
        fun startPane() = mapOf(START_PANE_KEY to true)
        fun endPane() = mapOf(END_PANE_KEY to true)
    }
}

首先,我们接收了两个 NavEntry 实例,待会将会传递进来,分别代表了左侧和右侧的页面。

然后在 content 中,完成了页面的布局:如果是大屏设备,则双列显示;否则,只显示右侧页面。

kotlin 复制代码
class DoublePaneSceneStrategy<T : Any> : SceneStrategy<T> {
    override fun SceneStrategyScope<T>.calculateScene(entries: List<NavEntry<T>>): Scene<T>? {
        // 过滤出拥有特定元数据的 NavEntry
        val startPane =
            entries.filter { it.metadata.containsKey(DoublePaneScene.START_PANE_KEY) }
        val endPane =
            entries.filter { it.metadata.containsKey(DoublePaneScene.END_PANE_KEY) }
        if (startPane.isEmpty() || endPane.isEmpty()) {
            // 不同时存在两个面板,说明当前场景策略无法处理
            // 默认会被单页模式场景策略处理
            return null
        }

        val startEntry = startPane.last()
        val endEntry = endPane.last()
        // 构建一个 Key 用于标识场景
        val sceneKey = Pair(startEntry, endEntry)
        return DoublePaneScene(
            key = sceneKey,
            previousEntries = entries.dropLast(1),
            startEntry = startEntry,
            endEntry = endEntry
        )
    }

}

我们找到特定的两个 NavEntry(待会使用时,需要将指定的元数据传入),然后创建 DoublePaneScene 实例返回即可。

具体使用:

kotlin 复制代码
@Composable
fun MyApp(modifier: Modifier = Modifier) {
    val backStack = rememberNavBackStack(ProductList)
    NavDisplay(
        backStack = backStack,
        onBack = {
            backStack.removeLastOrNull()
        },
        sceneStrategy = DoublePaneSceneStrategy(), // 使用DoublePaneScene
        entryProvider = { key ->
            when (key) {
                is ProductList -> NavEntry(
                    key = key,
                    // 传递特定元数据
                    metadata = DoublePaneScene.startPane()
                ) {
                    val products = mutableListOf<ProductDetail>()
                    repeat(30) {
                        products.add(ProductDetail("Product $it", "Description of Product $it"))
                    }
                    ProductList(
                        products = products,
                        onProductClick = { product ->
                            backStack.add(ProductDetail(product.name, product.description))
                        },
                        modifier
                    )
                }

                is ProductDetail -> NavEntry(
                    key = key,
                    // 传递特定元数据
                    metadata = DoublePaneScene.endPane()
                ) {
                    ProductDetail(
                        product = key,
                        modifier
                    )
                }

                else -> error("Unknown key: $key")
            }
        }
    )
}

运行效果:

可以看到,每次点击列表项时,左侧的列表页都会"闪烁"一下。并且按下返回键时,会回到上一个详情页。

这与官方的 ListDetailSceneStrategy 效果并不同,这是因为官方的 ListDetailSceneStrategy 会安装自己的 BackHandler,在大屏幕设备上,按下返回键会直接清空堆栈。而我们的 DoublePaneSceneStrategy 只负责显示,并不会有额外的优化。

"闪烁"是因为 NavDisplay 依靠 sceneKey 来判断是否是同一个场景,当我们点击了新的列表项后,NavDisplay 发现场景已经变化,会销毁并重建整个场景,而这个过程就是我们看到的"闪烁"。

参考

参考郭霖大佬的博客:写给初学者的Jetpack Compose教程,Navigation 3

相关推荐
CIb0la6 小时前
安卓16系统升级后(Google pixel 8/8pro 9/9pro xl 10/10pro xl)救砖及Root方法
android·运维·生活
Ya-Jun6 小时前
项目实战Now in Android:项目模块说明
android·架构·kotlin
@Aurora.7 小时前
【MySQL】基础
android
ooooooctober7 小时前
PHP代码审计框架性思维的建立
android·开发语言·php
q***82918 小时前
图文详述:MySQL的下载、安装、配置、使用
android·mysql·adb
沐怡旸9 小时前
【底层机制】Ashmem匿名共享内存:原理与应用深度解析
android·面试
用户2018792831679 小时前
Activity结束动画与System.exit(0)的黑屏之谜
android
Proud lion10 小时前
Apipost 脚本高频场景最佳实践:搞定接口签名验证、登录令牌刷新、动态参数生成等
android
介一安全10 小时前
【Frida Android】实战篇5:SSL Pinning 证书绑定绕过 Hook 教程(二)
android·网络安全·逆向·安全性测试·frida