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

相关推荐
独行soc20 分钟前
2026年渗透测试面试题总结-18(题目+回答)
android·网络·安全·web安全·渗透测试·安全狮
王码码20351 小时前
Flutter for OpenHarmony 实战之基础组件:第二十七篇 BottomSheet — 动态底部弹窗与底部栏菜单
android·flutter·harmonyos
2501_915106321 小时前
app 上架过程,安装包准备、证书与描述文件管理、安装测试、上传
android·ios·小程序·https·uni-app·iphone·webview
vistaup1 小时前
OKHTTP 默认构建包含 android 4.4 的TLS 1.2 以及设备时间不对兼容
android·okhttp
常利兵1 小时前
ButterKnife在Android 35 + Gradle 8.+环境下的适配困境与现代化迁移指南
android
撩得Android一次心动1 小时前
Android LiveData 全面解析:使用Java构建响应式UI【源码篇】
android·java·android jetpack·livedata
熊猫钓鱼>_>1 小时前
移动端开发技术选型报告:三足鼎立时代的开发者指南(2026年2月)
android·人工智能·ios·app·鸿蒙·cpu·移动端
Rainman博12 小时前
WMS-窗口relayout&FinishDrawing
android
baidu_2474386114 小时前
Android ViewModel定时任务
android·开发语言·javascript
有位神秘人14 小时前
Android中Notification的使用详解
android·java·javascript