在 2026 年 6 月 13 日华为开发者大会上,华为宣布 KMP/CMP 正式支持鸿蒙。
在这之前,我就实现了一个支持安卓和 iOS 的 KMP/CMP 跨平台路由框架,KMP/CMP 支持鸿蒙后,我也在第一时间让我的路由框架支持了鸿蒙。
天衢(Github地址:https://github.com/peiyunfei/TianQu)是一个专为 KMP/CMP 打造的纯 Kotlin + 协程驱动的现代路由框架,支持鸿蒙、Android、iOS。
它彻底摆脱了传统 Android 路由框架对 JVM ASM 字节码插桩的依赖与深层嵌套的回调地狱,全面拥抱挂起函数,为您提供无侵入、强解耦、类型安全以及功能极其丰富的跨模块导航与服务发现解决方案。
天衢(tiān qú)出自《楚辞·九思·遭厄》:“蹑天衢兮长驱,踵九阳兮戏荡。”意为天上的大道,宽阔的通衢。寓意本框架为 KMP/CMP 跨平台开发提供一条平坦、宽广、畅通无阻的路由大道。
📱 平台支持
🌟 核心特性概览
天衢路由最大的亮点在于对 Kotlin 协程的极致拥抱。我们在核心架构的每一层都去除了传统的回调接口,采用挂起函数重构了复杂的异步流,带来了前所未有的开发体验。
挂起式页面通信:通过 awaitNavigateForResult 无阻塞等待下一个页面的返回值,彻底消灭 onActivityResult 的回调嵌套。
挂起式路由守卫:RouterGuard 的拦截方法为挂起方法,您可以在守卫中自由发起网络请求、下载动态模块或弹窗请求权限,无需额外的线程切换与闭包等待。
挂起式全局降级:RouterHandler 的 404 页面未找到或外部路由处理均为协程环境,轻松实现异步容错与重定向。
2.纯 KSP 编译期扫描:完全无侵入,无需手动注册,支持增量编译。3.全自动跨模块聚合:业务模块按需生成子路由表,App 主模块自动聚合所有子模块,彻底解决多模块组件化难题。4.强大的传参机制:支持 URL Path 变量 (/user/{id})、Query 参数 (?id=1)、复杂大对象 (extra) 以及基于数据类的强类型安全传递。
5.跨模块服务发现:轻松实现接口下沉与依赖反转,提供优雅的 rememberService<T>() 协程安全挂起加载,确保服务单例的线程与协程安全。
6.生命周期与 ViewModel 绑定:内置专属页面作用域,提供 tianquViewModel<T>()。支持跨平台无反射的 @InjectViewModel 自动依赖注入 tianQuViewModelInject<T>(),当页面出栈时,不仅自动销毁 ViewModel,还会自动取消其绑定的所有协程任务,彻底告别内存泄漏与空指针。
7.多类型路由支持:支持配置页面为全屏页面(RouteType.SCREEN)或悬浮弹窗(RouteType.DIALOG),通过同一套 Navigator 接口无缝调起和传参,甚至支持弹窗返回结果给底层全屏页面。
8.并发预加载引擎:内置基于 CompletableDeferred 的非阻塞预加载器 (RoutePreloader)。在转场动画播放的同时,后台协程高并发加载目标页数据,实现“瞬开”体验。
9.离线 DeepLink 意图缓存:内置基于协程无界 Channel 的意图队列。冷启动或多线程高频触发外部唤起时,底层协程会自动缓冲并按序消费路由,绝不丢失任何一次跳转意图。
10.高级导航表现:内置单例模式控制 (SINGLE_TOP, SINGLE_TASK) 且支持零重组代价的参数复用更新、多返回栈(无缝衔接底层 Tab 栏)、自定义动画过渡、Compose 共享元素动画及 404 全局降级。
📦 引入与配置
目前可通过 Maven Central 引入最新版天衢路由依赖。
1. 添加仓库
⚠️ 注意:需要在 settings.gradle.kts 的 pluginManagement 和 dependencyResolutionManagement 中,将以下仓库添加到第一位(鸿蒙所依赖的 Kotlin/CMP fork 版本仅存于此仓库):
// settings.gradle.ktspluginManagement { repositories { // 鸿蒙所依赖的 Kotlin/CMP 仅存于此仓库 maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/") // 必须放第一位 google() mavenCentral() gradlePluginPortal() }}dependencyResolutionManagement { repositories { // 鸿蒙所依赖的 Kotlin/CMP 仅存于此仓库 maven("https://maven.eazytec-cloud.com/nexus/repository/maven-public/") // 必须放第一位 google() mavenCentral() }}
在项目根目录或 gradle/libs.versions.toml 中配置:
[versions]tianqu-router-annotations = "1.0.8" # 替换为最新版本号tianqu-router-processor = "1.0.8"tianqu-router-runtime = "1.0.8"ksp = "2.2.21-2.0.4"[libraries]tianqu-router-annotations = { module = "io.gitee.zhongte:tianqu-router-annotations", version.ref = "tianqu-router-annotations" }tianqu-router-processor = { module = "io.gitee.zhongte:tianqu-router-processor", version.ref = "tianqu-router-processor" }tianqu-router-runtime = { module = "io.gitee.zhongte:tianqu-router-runtime", version.ref = "tianqu-router-runtime" }[plugins]ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
2. 业务子模块配置 (Feature Module)
子模块需要依赖 annotations 注解库和运行时库,并应用 KSP 处理器来生成自身的路由表。同时可以通过 KSP 参数 tianqu.moduleName 指定生成的类名前缀。注意:必须配置 Java 17 并在 commonMain 中添加 KSP 生成的代码目录,否则会导致编译时找不到生成的类。
// feature-a/build.gradle.ktsplugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.ksp) // 引入 KSP}kotlin { // 确保编译目标使用 Java 17 jvmToolchain(17) // 或者在 androidTarget/jvm 中显式指定 jvmTarget = "17" sourceSets { commonMain.dependencies { implementation(libs.tianqu.router.annotations) // 注解库 implementation(libs.tianqu.router.runtime) // 运行时库 } }}// 【必填项】:为该模块生成的 Router 注册类指定模块前缀,建议直接使用 project.name 动态获取ksp { arg("tianqu.moduleName", project.name)}dependencies { add("kspCommonMainMetadata", libs.tianqu.router.processor)}// 【必填项】:将 KSP 生成的代码目录添加到 commonMain 的 source sets 中kotlin.sourceSets.commonMain { kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")}// 【必填项】:确保所有 Kotlin 编译任务在 KSP 生成 metadata 之后执行tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach { if (name != "kspCommonMainKotlinMetadata") { dependsOn("kspCommonMainKotlinMetadata") }}
3. 主工程模块配置 (App Module) 【极其重要❗】
主工程除了依赖 runtime 核心库外,必须在 KSP 配置中声明自己为 App 模块,并且同样需要配置 Java 17 和 KSP 代码生成目录关联。
// composeApp/build.gradle.ktsplugins { alias(libs.plugins.ksp)}// 【必填项】:告知处理器当前模块为主工程,需要聚合全工程路由表ksp { arg("tianqu.moduleName", project.name) arg("tianqu.isApp", "true") }kotlin { jvmToolchain(17) sourceSets { commonMain.dependencies { implementation(libs.tianqu.router.annotations) implementation(libs.tianqu.router.runtime) // 必须引入 Runtime // 依赖你的所有业务模块 implementation(project(":feature-a")) implementation(project(":feature-b")) } }}dependencies { add("kspCommonMainMetadata", libs.tianqu.router.processor)}// 【必填项】:将 KSP 生成的聚合路由表代码目录添加到 commonMain 的 source sets 中kotlin.sourceSets.commonMain { kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")}// 【必填项】:确保 KSP 任务优先执行tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask<*>>().configureEach { if (name != "kspCommonMainKotlinMetadata") { dependsOn("kspCommonMainKotlinMetadata") }}
🚀 基础路由导航与全局初始化
1. 全局初始化 RouterHost
在 Compose Multiplatform 的共享 UI 层 (App.kt) 根节点,调用 rememberAppTianQuState 完成框架装配,并使用 RouterHost 承载。
框架会自动完成跨模块服务初始化、路由事件监听和返回键拦截,业务只需声明自己的策略。
// App.kt@ComposablefunApp() { // 1. 定义路由拦截器(只有业务策略,框架装配已内部处理) val guards = remember { listOf( object : RouterGuard { override funmatches(context: RouterContext): Boolean { return context.url.startsWith("/user") } override suspend funcanActivate(context: RouterContext, chain: GuardChain): Boolean { println("🚀 [局部拦截器] 进入 User 模块,URL: ${context.url}") return chain.proceed(context) } } ) } // 2. 一行完成框架装配: // - 自动注入 GlobalRouteAggregator.routers 和 services(无需手动传) // - 自动初始化跨模块服务、监听路由事件、挂载返回键拦截 val navigator = rememberTianQuApp { // 自动注入 KSP 生成的产物 routes = GlobalRouteAggregator.routers serviceProviders = GlobalRouteAggregator.services // 传入第一个页面的路径 startRoute = "/main_tab" this.guards = guards onRouteEvent = { event, nav -> when (event) { is RouterEvent.NotFound -> nav.navigateTo("/main_tab") // 404 降级,如果找不到目标页面,跳转到/main_tab is RouterEvent.Navigated -> println("✅ 跳转成功: ${event.url}") } } } MaterialTheme { // 3. RouterHost 接管全部路由渲染,Navigator 可随时通过 LocalNavigator.current 获取 RouterHost(navigator = navigator) }}
2. 获取 Navigator 的多种方式
页面跳转需要先拿到 Navigator 对象,然后调用 navigateTo。
获取 Navigator 对象,除了通过 CompositionLocal 获取,如果您处于协程作用域内,还可以直接从上下文中提取!
import shijing.tianqu.router.Routerimport shijing.tianqu.router.RouterContextimport shijing.tianqu.runtime.LocalNavigatorimport shijing.tianqu.runtime.rememberRouterScopeimport shijing.tianqu.runtime.Navigatorimport kotlinx.coroutines.launch@Router(path = "/home", transition = "Fade") // 可选设置动画@ComposablefunHomeScreen(context: RouterContext) { // 方式一:在 Compose 作用域内获取 (最常用) val navigator = LocalNavigator.current // 方式二:在挂起函数 / 专属协程作用域内获取 (极客玩法) val routerScope = rememberRouterScope() Button(onClick = { routerScope.launch { // 通过协程上下文直接提取当前的 Navigator val nav = coroutineContext[Navigator] ?: return@launch // 跳转到目标页面 nav.navigateTo("/profile") } }) { Text("跳转个人资料页") }}
3. 页面状态保存(remember vs rememberSaveable)【重要】
天衢底层的 RouterHost 通过 rememberSaveableStateHolder 管理返回栈 —— 当 A 跳转到 B 时,A 会被移出组合树;从 B 返回时,A 重新进入组合树并恢复状态。
框架已帮你处理了底层的 SaveableStateHolder,业务侧只需要用 rememberSaveable 声明需要跨页面保留的 UI 状态即可。
@Router("/home")@ComposablefunHomeScreen(context: RouterContext) { // ❌ 错误写法:跳转后返回,该状态会丢失(重置为 0) // var testCounter by remember { mutableStateOf(0) } // ✅ 正确写法:跳转后返回,状态依然保留 var testCounter by rememberSaveable { mutableStateOf(0) } Button(onClick = { testCounter++ }) { Text("计数值: $testCounter") }}
4. 物理返回键/侧滑手势返回拦截 (Android/iOS)
在 Android 上,通常需要拦截系统的物理返回键;在 iOS 上,则是侧滑返回。
天衢框架已在 router-runtime 内部内置了跨平台 BackHandler 实现,并由 rememberAppTianQuState 默认自动挂载,无需业务层手动编写任何 expect/actual 桥接代码。
默认行为:当导航栈中多于 1 个页面时,物理返回键/侧滑手势会自动触发出栈。
如需禁用默认的全局返回拦截(例如某些特殊场景需要完全自定义),可通过配置关闭:
val navigator = rememberAppTianQuState { startRoute = “/main_tab” enableBackHandler = false // 关闭框架默认的返回键拦截}
⚠️ 注意:对于鸿蒙来说,需要在 harmonyApp/entry/src/main/ets/pages/Index.ets 重写 onBackPress ,不是onBackPressed,方法名别写错。
一般来说,Index.ets是在创建项目的时候自动生成的,但自动生成的onBackPress方法可能会出错,可能会生成onBackPressed,多加了ed,一定要检查onBackPress方法是否正确。
// 这是正确的方法onBackPress(): boolean | void { return this.controller ? this.controller.onBackPress() : false;}// 这是错误的,编译器自动生成的可能会是onBackPressedonBackPressed(): boolean { return this.controller ? this.controller.onBackPress() : false; }
如需在某个深层子页面拦截返回(例如表单页阻止意外返回),直接在该页面调用 BackHandler 即可,它会优先于全局拦截消费返回事件:
@Router(path = “/edit_form”)@ComposablefunEditFormScreen(context: RouterContext) { val navigator = LocalNavigator.current var hasUnsavedChanges by remember { mutableStateOf(true) } // 子页面的 BackHandler 优先消费,不会与全局冲突 BackHandler(enabled = hasUnsavedChanges) { // 弹窗提示用户有未保存的内容 println(“⚠️ 有未保存的内容,请先保存再退出”) }}
💡 多层嵌套拦截提示:Compose 的 BackHandler 遵循”就近拦截(子优先)”原则。子页面的 BackHandler 会优先消费返回事件,完美覆盖全局的默认退栈逻辑,不会产生冲突。
5. 路由启动模式(LaunchMode):栈顶复用与栈内复用
天衢路由全面支持了类似 Android Activity 的高级启动模式 (LaunchMode),并且由于采用纯 Compose 状态驱动,复用页面时只有在参数发生实质性变化时才会触发重组,实现了真正的“零开销复用”。
如何使用:在 @Router 注解中指定 launchMode 即可。目前支持三种模式:
LaunchMode.STANDARD (默认):每次跳转都创建新页面并入栈。
LaunchMode.SINGLE_TOP:栈顶复用。如果当前栈顶已经是该页面,则复用栈顶页面,不创建新实例,同时将新的参数传递给旧页面。
LaunchMode.SINGLE_TASK:栈内复用。如果导航栈中已经存在该页面,则将其上的所有页面弹出栈,使该页面重新回到栈顶并复用,同时传递新参数。
import shijing.tianqu.router.LaunchModeimport shijing.tianqu.router.Router// 示例:设置栈内复用模式。// 假设栈为 [Home, Task, Detail],此时再跳到 /task,Detail 会被出栈,回到 [Home, Task]。@Router(path = "/task", launchMode = LaunchMode.SINGLE_TASK)@ComposablefunSingleTaskScreen(context: RouterContext) { // context 会自动更新为最新传递进来的参数,并按需触发 Compose 重组。 // 如果传递的新参数与旧参数完全相同(实质内容一样),底层引擎会拦截赋值,不会引发任何 UI 重组! Text("Task Page: ${context.queryParams["id"]}")}
💡 最佳实践:得益于 Compose 的 SaveableStateHolder,在 SINGLE_TASK 触发上层页面退栈而使复用页面重新展示时,原页面的滚动位置、输入框状态等均会原样保留且无缝衔接动画,完美还原 Android 的 onNewIntent 体验!
6. 自定义页面切换动画
天衢路由内置了常见的转场动画(如 Fade, Slide, Scale, None),默认使用的是Slide,这是安卓上经典的页面切换动画。
如果您需要极其炫酷的自定义动画,可以直接使用 @Transition 注解和 BaseTransitionStrategy 轻松实现,且完全支持 Compose 官方的动画 API!
实现步骤:
- 继承
BaseTransitionStrategy 并重写入场/出场动画。 - 使用
@Transition 注解标记,并起一个引用名称。 - 在目标页面的
@Router 注解中使用这个名称。
import androidx.compose.animation.*import androidx.compose.animation.core.tweenimport shijing.tianqu.router.Routerimport shijing.tianqu.router.Transitionimport shijing.tianqu.runtime.transition.BaseTransitionStrategy// 1. 定义自定义动画策略@Transition(name = "RotateScale")class RotateScaleTransitionStrategy : BaseTransitionStrategy() { // 入场动画:缩放并淡入 override fungetEnterTransition( scope: AnimatedContentTransitionScope<StackEntry>, initial: StackEntry, target: StackEntry, isPop: Boolean ): EnterTransition { return scaleIn(initialScale = 0.5f, animationSpec = tween(500)) + fadeIn(animationSpec = tween(500)) } // 出场动画:缩放并淡出 override fungetExitTransition( scope: AnimatedContentTransitionScope<StackEntry>, initial: StackEntry, target: StackEntry, isPop: Boolean ): ExitTransition { return scaleOut(targetScale = 1.5f, animationSpec = tween(500)) + fadeOut(animationSpec = tween(500)) }}// 2. 在页面路由中引用它@Router(path = "/demo_anim", transition = "RotateScale")@ComposablefunDemoAnimScreen(context: RouterContext) { // 页面内容...}
KSP 会自动将您的自定义动画收集到聚合器中,跳转时无缝衔接。
🎯 传参大满贯:各种传参场景与代码示例
天衢路由全面覆盖了不同场景的数据传递需求,不再有传统框架传参类型容易丢失的问题。
场景一:基于 URL 的基础类型传参 (Path & Query)
适用于深层链接 (DeepLink) 直接唤起 App,或者简单的 ID、状态位传递。
发起跳转:
// 跳转并拼接 Path 与 Query 参数navigator.navigateTo("app://shijing.tianqu/user/1001?source=home_banner&vip=true")
目标页面解析参数:
@Router(path = "/user/{id}")@ComposablefunUserDetailScreen(context: RouterContext) { // 1. 从 context.pathParams 获取路径里的 {id} val userId = context.pathParams["id"] // 2. 从 context.queryParams 获取问号后的参数 (注意是 List,因为可能存在多个同名 key) val source = context.queryParams["source"]?.firstOrNull() val isVip = context.queryParams["vip"]?.firstOrNull()?.toBoolean() ?: false Text("用户ID: $userId, 来源: $source, VIP: $isVip")}
场景二:传递内存级别复杂大对象 (Extra)
对于非序列化、直接传递的大对象(例如 Bitmap,或某些体积巨大、不方便格式化的类实例),可以直接通过 extra 字段传递。
发起跳转:
// 定义任意业务模型data class UserProfile(val name: String, val age: Int, val isVip: Boolean)// 使用 extra 传递对象val profile = UserProfile(name = "Kotlin开发者", age = 25, isVip = true)navigator.navigateTo("/profile", extra = profile)
目标页面安全解包:
@Router(path = "/profile")@ComposablefunProfileScreen(context: RouterContext) { // 使用 as? 进行安全的类型强转 val profileData = context.extra as? UserProfile if (profileData != null) { Text("你好,${profileData.name},年龄 ${profileData.age}") } else { Text("未接收到复杂参数对象") }}
场景三:基于 Kotlinx.serialization 的强类型安全传参
如果希望享受绝对的类型安全检查,支持序列化并严防类型错误,请使用强类型传参。
前置准备:引入序列化插件与依赖
在项目根目录或 gradle/libs.versions.toml 中配置:
[versions]kotlinx-serialization = "1.9.1-0.3.0"[libraries]# 序列化kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }[plugins]ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }# 序列化插件kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
在需要使用强类型传参的模块的 build.gradle.kts 中,引入 Kotlin 官方的序列化插件及运行库:
plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidApplication) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.ksp) // 引入序列化插件 alias(libs.plugins.kotlinSerialization)}kotlin { sourceSets { commonMain.dependencies { implementation(libs.tianqu.router.annotations) implementation(libs.tianqu.router.runtime) // 添加依赖 implementation(libs.kotlinx.serialization.json) } }}
步骤 1:定义可序列化的数据类
import kotlinx.serialization.Serializable@Serializabledata class UserDetailArgs( val userId: Long, val username: String, val isVip: Boolean, val scores: List<Int>)
步骤 2:发起强类型安全跳转 (navigateArgs)
// 使用 navigateArgs 方法navigator.navigateArgs( path = "/typesafe_demo", args = UserDetailArgs( userId = 999L, username = "天衢路由", isVip = true, scores = listOf(100, 98) ))
步骤 3:目标页面强类型提取 (getTypedArgs<T>())
import shijing.tianqu.runtime.getTypedArgs@Router(path = "/typesafe_demo")@ComposablefunTypeSafeScreen(context: RouterContext) { // 根据泛型类型自动反序列化,如果解析失败或缺失则为 null val args = context.getTypedArgs<UserDetailArgs>() if (args != null) { Text("✅ 类型安全解析成功!用户名: ${args.username}") }}
⚡️ 极致性能:非阻塞并发数据预加载 (Route Pre-fetching)
传统的页面开发模式往往面临两难选择:要么先跳转再请求(进入页面后有一段骨架屏/Loading 空白期等待数据),要么先请求再跳转(用户点击按钮后界面卡住无响应,等数据回来了才突然跳转)。
天衢路由作为一款基于协程驱动的现代框架,彻底解决了这个问题!我们提供了页面跳转与数据加载并发执行的预加载能力:在执行 navigateTo 的毫秒级瞬间,底层会通过 async 启动预加载协程,并在同时播放页面的 Compose 转场动画。
我们完美利用了动画过渡的 300~500 毫秒“空窗期”,等动画播完时,数据往往已经加载完毕,实现真正的“零秒开启、无缝平滑”极致体验。完全不阻塞主线程!
步骤 1:定义你的预加载器 (实现 RoutePreloader)
class UserDetailPreloader : RoutePreloader { override suspend funpreload(context: RouterContext): Any? { val userId = context.pathParams["id"] ?: return null println("🚀 [后台线程] 开始预加载用户 $userId 的详细数据...") // 模拟耗时网络请求(此挂起不会阻塞 UI 线程与转场动画!) delay(500) return UserDetailArgs(userId = userId.toLong(), username = "预加载大神", isVip = true, scores = emptyList()) }}
步骤 2:注册到 Navigator 初始化中
// 一定要先创建对象val preloaders = remember { mapOf("/demo_preload" to UserDetailPreloader()) }val navigator = rememberAppTianQuState { startRoute = "/home" this.preloaders = preloaders // 绑定路由路径与对应的预加载器}
请注意,不能通过下面的代码传递预加载器。直接写 preloaders = mapOf(...),这意味着每次重组时都会创建一个新的 Map 对象。rememberAppTianQuState 会根据参数(包括 preloaders)来 remember 导航器。因为传进去的 Map 对象一直在变,导致 Compose 认为参数变了,从而不断地重新创建 Navigator 对象并清空页面栈,最终表现出来的就是不断刷新的“白屏”。
// 错误示例val navigator = rememberAppTianQuState { startRoute = "/home" this.preloaders = mapOf("/demo_preload" to UserDetailPreloader()) // 这是错误的写法}
如果不想在启动的时候就传递预加载器,可以通过下面的方式:
// 在跳转之前传递预加载器navigator.registerPreloader("/demo_preload", UserDetailPreloader())navigator.navigateTo("/demo_preload")
步骤 3:在目标页面一键提取数据 (rememberPreloadData<T>())无需关心复杂的协程等待逻辑,框架在底层已经将获取到的 Deferred 无缝传递给了页面,使用即可:
@Router(path = "/user_detail/{id}")@ComposablefunUserDetailScreen(context: RouterContext) { // 🌟 在 Compose 中非阻塞地挂起等待预加载结果 val preloadedData = rememberPreloadData<UserDetailArgs>() if (preloadedData == null) { // 动画期间,由于数据还在飞奔请求中,可能会短暂显示 Loading CircularProgressIndicator() } else { // 数据到达,直接渲染!此时转场动画可能刚巧播完,完美衔接! Text("加载完毕,用户:${preloadedData.username}") }}
🔄 页面结果回传 (页面间双向通信)
处理“选择联系人”、“修改设置后返回数据”等需求时,天衢提供了极为优雅的挂起式协程解决方案。
1. 目标页:如何发送返回值并返回?
无论调用方采用哪种方式,目标页面只需要调用 popBackStack(result) 即可!它支持传递任何类型的对象。
@Router(path = "/settings")@ComposablefunSettingsScreen(context: RouterContext) { val navigator = LocalNavigator.current Button(onClick = { // 关闭当前页面,并将字符串对象作为结果弹回上一个页面 navigator.popBackStack(result = "这里是修改后的最新设置数据") }) { Text("保存并返回") }}
2. 调用方做法一:协程挂起式 (awaitNavigateForResult) 【⭐ 强烈推荐】
告别嵌套“地狱回调”,像写同步代码一样写异步等待!(⚠️ 警告:请务必在与页面生命周期绑定的 navigator.coroutineScope 内启动协程,否则可能会因为屏幕重组导致协程取消!)
⚠️ 结果展示状态必须使用 rememberSaveable如果你要把 awaitNavigateForResult() 得到的返回值展示在当前页面 UI 上,承接结果的状态必须写成 rememberSaveable,不要写成普通 remember。
因为从目标页返回后,当前页可能发生状态恢复;此时如果使用 remember,即使已经拿到结果,界面状态也可能被重置,表现为“拿不到返回值”或“返回值一闪而过”。
val navigator = LocalNavigator.current// 正确写法:用 rememberSaveable 保存页面恢复后的结果展示状态var returnedResult by rememberSaveable { mutableStateOf<String?>(null) }Button(onClick = { // 启动伴随页面的安全协程 navigator.coroutineScope.launch { // 🌟 代码在此处挂起!直到用户从设置页返回才会继续往下执行,非阻塞主线程 val result = navigator.awaitNavigateForResult("/settings") // 检查返回值类型并消费 if (result is String) { returnedResult = "协程获取: $result" } }}) { Text("前往设置页 (等待结果)")}if (returnedResult != null) { Text("收到返回值: $returnedResult")}
3. 调用方做法二:经典回调监听 (navigateWithResult)
如果您处于非协程环境(如对接特定的 Swift 闭包),或个人偏爱经典回调:
Button(onClick = { navigator.navigateWithResult("/settings").onResult { result -> if (result is String) { returnedResult = "回调获取: $result" } }}) { Text("前往设置页 (回调获取结果)")}
🔌 跨模块解耦:服务发现 (Service Discovery)
路由不仅为了页面跳转,也是为了跨模块间的接口调用与反向依赖注入。上层 app 可以调用底层 feature 模块的具体实现,而无需互相强依赖。
1. 定义接口 (放在基础 Common 模块)
interface UserService { suspend fungetUserName(): String}
2. 具体实现 (放在具体的 Feature 模块)
添加 @Service 注解,KSP 处理器会自动将其注册到服务表中。
import shijing.tianqu.router.Service@Serviceclass UserServiceImpl : UserService { override suspend fungetUserName(): String { return "天衢注册用户" }}
3. 在任意位置获取服务 (支持挂起与普通获取)
天衢提供了两种灵活的方式来获取服务实例:
方式一:在 Compose 中基于协程挂起获取 (rememberService)结合 Compose 最佳实践,无阻塞安全获取。
import shijing.tianqu.runtime.utils.rememberService@ComposablefunHomeScreen(context: RouterContext) { // 🔥 自动安全挂起加载,支持自动重组 val userService = rememberService<UserService>() // 因为服务加载可能需要时间,使用安全调用符处理空状态 val userName = userService?.getUserName() ?: "正在异步加载..." Text("欢迎您, $userName")}
方式二:在任意非 Compose 代码中普通获取 (ServiceManager.getService)如果您在 ViewModel、Repository 或是普通的方法中,希望直接拿到服务实例:
import shijing.tianqu.runtime.service.ServiceManagerfunfetchUserData() { // 返回对应的实现类,如果不存在则抛出异常或返回null (具体看您的封装) val userService = ServiceManager.getService<UserService>() // 如果您在普通协程里,也有挂起函数版本: // val userService = ServiceManager.getServiceAsync<UserService>()}
🧬 ViewModel 与页面生命周期深度绑定
原生的 viewModel() 在纯 Compose Multiplatform 项目中往往缺乏路由弹栈感知能力。
天衢提供了与单次页面路由同生共死的专有 ViewModel,并支持三种不同的获取方式,满足各种复杂场景的需求。
1. 声明标准的 ViewModel
import androidx.lifecycle.ViewModelimport kotlinx.coroutines.flow.MutableStateFlowclass CounterViewModel : ViewModel() { val count = MutableStateFlow(0) init { println("ViewModel 初始化分配") } // 🌟 当该路由页面被 pop 出栈时,此方法自动触发,释放内存! override funonCleared() { super.onCleared() println("该页面已被销毁,释放各种流与资源!") }}
2. 获取 ViewModel 的三种方式
在路由页面中,您可以通过以下三种方式获取与当前页面生命周期绑定的 ViewModel:
方式一:反射无参构造获取(tianquViewModel<T>())
最简单直接的方式,适用于 ViewModel 只有无参构造函数的情况。在 JVM/Android 平台上通过反射实例化。
import shijing.tianqu.runtime.tianquViewModel@Router(path = "/demo_viewmodel")@ComposablefunDemoViewModelScreen(context: RouterContext) { // 自动通过反射调用无参构造函数创建 ViewModel val viewModel = tianquViewModel<CounterViewModel>() // ...}
- 优点:使用极其简单,无需额外配置,开箱即用。
- 缺点:依赖反射机制,在 iOS 等 Kotlin/Native 平台上不支持无参反射实例化,会导致 Crash;不支持带参数的构造函数。
方式二:自定义 Factory 获取(tianquViewModel<T>(factory))
适用于 ViewModel 需要传递参数(如 Repository、UseCase)或在 iOS 平台上运行的情况。
import shijing.tianqu.runtime.tianquViewModelimport androidx.lifecycle.ViewModelProviderimport androidx.lifecycle.viewmodel.CreationExtrasimport kotlin.reflect.KClass// 自定义 Factoryclass CounterViewModelFactory : ViewModelProvider.Factory { override fun<T : ViewModel>create(modelClass: KClass<T>, extras: CreationExtras): T { return CounterViewModel() as T // 如果有参数,可以在这里传入 }}@Router(path = "/demo_viewmodel")@ComposablefunDemoViewModelScreen(context: RouterContext) { // 传入自定义 Factory 进行实例化 val viewModel = tianquViewModel<CounterViewModel>(factory = CounterViewModelFactory()) // ...}
- 优点:灵活性最高,支持带参数的构造函数;完全兼容所有平台(包括 iOS)。
- 缺点:每次使用都需要手动编写和传入 Factory,样板代码较多,略显繁琐。
方式三:基于 KSP 的全自动依赖注入(tianQuViewModelInject<T>()) 【⭐ 强烈推荐】
结合了前两者的优点,通过 @InjectViewModel 注解在编译期生成工厂代码,实现无反射、跨平台的自动注入。
步骤 A:在 ViewModel 上添加注解
import shijing.tianqu.router.InjectViewModel@InjectViewModel // 🌟 标记此 ViewModel 需要生成注入工厂classCounterViewModel : ViewModel(){ // ...}
步骤 B:在页面中一键获取
import shijing.tianqu.runtime.tianQuViewModelInject@Router(path = "/demo_viewmodel")@ComposablefunDemoViewModelScreen(context: RouterContext) { // 底层自动从 ServiceLocator 获取生成的工厂并实例化,无需反射! val viewModel = tianQuViewModelInject<CounterViewModel>() // ...}
- 优点:无反射(性能极佳)、完全跨平台(完美支持 iOS)、无样板代码(使用极其简单)。
- 缺点:目前生成的默认工厂仅支持无参构造函数(若需带参构造,请继续使用方式二)。
💡 内存泄漏终结者:无论您使用哪种方式获取,当点击返回,当前页面被 popBackStack 移出栈时,框架将主动触发 ViewModel 的 onCleared(),杜绝任何内存泄漏。
🧩 协程驱动的动态按需懒加载 (Dynamic Feature)
在大型项目中,为了减小包体积或加快初始启动速度,某些业务模块可以设计为动态下载与按需加载。在传统路由中,这往往需要复杂的异步回调与占位页面。
而在天衢路由中,得益于底层的协程架构与强大的全局守卫 (RouterGuard),您可以像写同步代码一样轻松实现带有等待 UI 的动态模块加载。
核心原理:
- 业务层调用挂起函数
navigator.push("/dynamic_route"),并在协程外层控制 “Loading 转圈” UI 状态。 - 全局
RouterGuard 拦截到请求,判断该 URL 属于某个未加载的模块。 - 守卫在内部使用挂起执行下载逻辑(如下载 js bundle、加载 dex、或从服务器拉取配置)。
- 下载完成后,守卫动态注册新模块的路由表:
navigator.registerDynamicRoutes(...)。 - 业务层
navigator.push 挂起恢复,隐藏 “Loading” 状态。
完整演示代码:
// 1. 定义动态加载守卫class DynamicFeatureGuard : RouterGuard { // 拦截特定前缀的路由 override funmatches(context: RouterContext): Boolean { return context.url.startsWith("/dynamic_feature") } override suspend funcanActivate(context: RouterContext, chain: GuardChain): Boolean { val navigator = chain.navigator // 判断路由表中是否已经有了该页面,如果没有才需要模拟下载 if (navigator.backStack.none { it.url == context.url } && !navigator.hasRoute(context.url)) { println("正在异步下载并加载动态模块...") // 模拟真实环境下的网络下载耗时 delay(2000) // 下载完成后,动态将新模块的节点注册进当前导航器中 navigator.registerDynamicRoutes(listOf( RouterNode( path = "/dynamic_feature", composable = { ctx -> DynamicFeatureScreen(ctx) } ) )) println("动态模块加载完毕,路由已注册!") } // 模块已就绪,放行 return chain.proceed(context) }}// 2. 业务侧发起调用并展示 Loading UI@ComposablefunHomeScreen(context: RouterContext) { val navigator = LocalNavigator.current val coroutineScope = rememberCoroutineScope() // 维护按钮的加载状态 var isDynamicLoading by rememberSaveable { mutableStateOf(false) } Button( onClick = { coroutineScope.launch { isDynamicLoading = true // 1. 显示转圈 // 2. 使用 suspend 的 push(),它会等待所有异步守卫(包含下载)执行完毕 navigator.push("/dynamic_feature") isDynamicLoading = false // 3. 页面跳转成功或被拒绝后,隐藏转圈 } } ) { if (isDynamicLoading) { CircularProgressIndicator(modifier = Modifier.size(20.dp), color = Color.White) Spacer(modifier = Modifier.width(8.dp)) Text("正在下载模块...") } else { Text("✨ 动态按需加载模块") } }}
通过这种方式,我们不仅完美隔离了底层模块的动态下载逻辑,同时让上层 UI 得到了优雅的状态管理,一切都得益于 Kotlin 协程 suspend 机制的强大力量。
🎨 其他极客能力全景公开
1. 多返回栈嵌套管理与 Tab 状态持久化
App 主页往往包含底部的多个 Tab。天衢底层深度打通了 SaveableStateHolder,完美解决“切换 Tab 重新渲染导致输入内容与滚动条丢失”的问题。这是一种原生的“单宿主+多挂载点”轻量实现:
@Router(path = "/main_tab")@ComposablefunMainTabScreen(context: RouterContext) { // 1. 保存 Tab 选项索引 var selectedTab by rememberSaveable { mutableStateOf(0) } // 2. 拿到自带的状态持久化容器 val saveableStateHolder = rememberSaveableStateHolder() Scaffold( bottomBar = { // ... 底部导航栏切换 selectedTab } ) { // 3. 为不同 Tab 提供状态独立作用域 if (selectedTab == 0) { // 通过 SaveableStateProvider 包裹,哪怕切换到别的 Tab,它的内部状态依然被持久保留! saveableStateHolder.SaveableStateProvider("tab_home") { val homeNode = GlobalRouteAggregator.routers.find { it.path == "/home" } homeNode?.composable?.invoke(RouterContext("/home")) } } else { saveableStateHolder.SaveableStateProvider("tab_profile") { val profileNode = GlobalRouteAggregator.routers.find { it.path == "/profile" } profileNode?.composable?.invoke(RouterContext("/profile")) } } }}
2. Compose 原生共享元素动画 (Shared Element)
天衢基于 Compose 1.7 实现了原生的共享元素能力。您只需用 routerSharedBounds(key) 为发起方和接收方的组件打上相同的 Key 标签,点击跳转时,框架将为您自动呈现惊艳的形变飞跃动画。
import shijing.tianqu.runtime.transition.routerSharedBounds// 页面 A (发起页) 中的组件Box( modifier = Modifier .routerSharedBounds(key = "shared_avatar_id") // 第一步:声明共享标签 .size(100.dp) .clickable { navigator.navigateTo("/detail") })// 页面 B (详情页) 中的组件Image( modifier = Modifier .routerSharedBounds(key = "shared_avatar_id") // 第二步:目标页使用同一标签 .fillMaxWidth())
3. 全局路由 404 降级拦截处理
意外跳转到了未注册的页面怎么办?天衢不会发生崩溃,而是触发一个 NotFound 事件!
您只需在 rememberAppTianQuState 中配置 onRouteEvent,即可实现自由调度或重定向。
val navigator = rememberAppTianQuState { startRoute = "/home" onRouteEvent = { event, nav -> when (event) { // 🌟 拦截到了空路由 /not_exist_page is RouterEvent.NotFound -> { println("⚠️ [全局降级拦截] 找不到路由: ${event.url}") // 您可以选择在这里跳转到一个专用的 H5 容错升级页面,或者回退到首页 nav.navigateTo("/home") } is RouterEvent.Navigated -> { println("ℹ️ 跳转成功: ${event.url}") } } }}
4. 离线 DeepLink 意图缓存 (Offline DeepLink Caching)
当应用在后台被系统杀死,或者尚未启动时,用户通过外部 DeepLink 唤起 App。此时底层的 UI 引擎和 Navigator 尚未初始化完毕,如果直接执行跳转,意图将会丢失。天衢路由内置了 DeepLinkManager,利用协程无界 Channel 实现了离线意图的 FIFO 缓存。
步骤 1:在原生平台入口接收并分发 DeepLink
// Android MainActivity.kt 或 iOS AppDelegate// 此时 Compose 和 Navigator 可能还未初始化val url = intent.data?.toString() ?: returnDeepLinkManager.dispatch(url)
步骤 2:Navigator 自动消费当 Navigator 初始化完成后,它会自动在内部协程中收集并消费这些被缓存的意图,确保用户无缝跳转到目标页面,绝不丢失任何一次外部唤醒!
5. 声明一个弹窗路由 (Dialog)
天衢支持将普通路由节点声明为弹窗类型,弹窗将悬浮展示在现有页面之上。您可以通过 BackHandler 和 clickable 拦截区域自由控制物理返回键与点击外部的关闭逻辑。
@Router( path = "/demo_dialog", // 指定类型为弹窗 type = RouteType.DIALOG)@ComposablefunDemoDialogScreen(context: RouterContext) { val navigator = LocalNavigator.current var dismissOnClickOutside by remember { mutableStateOf(true) } var dismissOnBackPress by remember { mutableStateOf(true) } BackHandler(enabled = true) { if (dismissOnBackPress) { navigator.popBackStack() } } Box( modifier = Modifier .fillMaxSize() .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null ) { if (dismissOnClickOutside) { navigator.popBackStack() } }, contentAlignment = Alignment.BottomCenter ) { Box( modifier = Modifier .fillMaxWidth() .wrapContentHeight() .background( color = MaterialTheme.colorScheme.surface, shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp) ) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null ) {} .padding(24.dp) ) { Column { Text("业务层控制弹窗行为演示") Switch( checked = dismissOnClickOutside, onCheckedChange = { dismissOnClickOutside = it } ) Switch( checked = dismissOnBackPress, onCheckedChange = { dismissOnBackPress = it } ) Button(onClick = { navigator.popBackStack() }) { Text("关闭弹窗") } } } }}
上面的示例对应仓库中的 composeApp/src/commonMain/kotlin/shijing/tianqu/screens/DemoDialogScreen.kt,说明天衢不只支持全屏页面跳转,也支持将路由节点作为弹窗悬浮在当前页面之上。
弹窗的遮罩、点击空白关闭、返回键关闭等交互由业务层自己定义。
感谢您选择使用天衢,希望这条大道能让您的跨平台开发之旅风雨无阻!
如果你在接入的过程中,遇到问题,可以下载源码(链接:https://github.com/peiyunfei/TianQu),让 AI 参考源码,给你解决问题。后续提供一个skill,只需一句话,就可以让 AI 自动接入天衢。
最后点击查看实现原理(链接:https://github.com/peiyunfei/TianQu/tree/master/%E6%96%87%E6%A1%A3)。