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

相关推荐
Kapaseker20 小时前
你不看会后悔的2025年终总结
android·kotlin
alexhilton1 天前
务实的模块化:连接模块(wiring modules)的妙用
android·kotlin·android jetpack
ji_shuke1 天前
opencv-mobile 和 ncnn-android 环境配置
android·前端·javascript·人工智能·opencv
sunnyday04261 天前
Spring Boot 项目中使用 Dynamic Datasource 实现多数据源管理
android·spring boot·后端
幽络源小助理1 天前
下载安装AndroidStudio配置Gradle运行第一个kotlin程序
android·开发语言·kotlin
inBuilder低代码平台1 天前
浅谈安卓Webview从初级到高级应用
android·java·webview
豌豆学姐1 天前
Sora2 短剧视频创作中如何保持人物一致性?角色创建接口教程
android·java·aigc·php·音视频·uniapp
白熊小北极1 天前
Android Jetpack Compose折叠屏感知与适配
android
HelloBan1 天前
setHintTextColor不生效
android
洞窝技术1 天前
从0到30+:智能家居配网协议融合的实战与思考
android