当 Android 项目从单模块扩展到多个业务模块,页面跳转很容易从几行 `navigate()` 变成一张难以维护的网:路由参数散落、模块互相依赖、深链规则重复,线上出现跳转失败时也很难定位。本文从 Navigation 的基本边界出发,逐步搭建一套适合真实项目的多模块导航方案,并补齐深链校验、统一入口和故障追踪。
小型应用通常只有一个导航图,页面可以直接引用目标 Fragment 的 action:
findNavController().navigate(
HomeFragmentDirections.actionHomeToDetail(articleId)
)这种写法类型安全、直观,在单模块内非常合适。问题出现在业务拆分后:首页模块如果直接引用详情模块生成的 Directions 类,就会形成编译期依赖;更多业务接入后,模块之间可能出现环状依赖。
多模块导航要解决的并不只是“能跳过去”,还包括这些约束:
一套稳定的导航结构通常分为三层:
层次 | 负责内容 | 不应该负责 |
|---|---|---|
业务页面 | 发出“去哪里”的意图 | 解析 URI、判断登录态 |
路由契约 | 定义目的地和参数 | 持有 Activity 或 Fragment |
导航执行器 | 解析契约、检查前置条件、执行跳转 | 处理具体业务数据 |
业务模块只依赖轻量的路由契约模块,应用壳模块负责组装实际导航实现。这样既保留模块边界,也不会把所有跳转逻辑塞进一个巨大的工具类。
相比到处拼字符串,密封接口更适合表达应用内部的目的地:
sealed interface AppRoute {
data class ArticleDetail(val articleId: Long) : AppRoute
data class UserProfile(val userId: String) : AppRoute
data class WebPage(val url: String) : AppRoute
}
interface AppNavigator {
fun navigate(route: AppRoute): NavigationResult
}
sealed interface NavigationResult {
data object Success : NavigationResult
data class Rejected(val reason: String) : NavigationResult
}调用方不需要知道详情页属于哪个 Gradle 模块,也不需要知道导航图中的 destination id:
val result = navigator.navigate(AppRoute.ArticleDetail(articleId = 1024L))契约应保持稳定和精简。不要把数据库实体或网络响应对象直接作为路由参数,否则页面跳转会和数据层模型绑定。通常只传稳定标识,目标页面再通过 Repository 加载数据,更容易处理进程重建和数据过期。
应用壳模块可以访问各业务导航图,因此适合提供 `AppNavigator` 的实现:
class NavControllerAppNavigator(
private val navControllerProvider: () -> NavController
) : AppNavigator {
override fun navigate(route: AppRoute): NavigationResult {
val controller = navControllerProvider()
return runCatching {
when (route) {
is AppRoute.ArticleDetail -> controller.navigate(
Uri.parse("myapp://article/${route.articleId}")
)
is AppRoute.UserProfile -> controller.navigate(
Uri.parse("myapp://user/${route.userId}")
)
is AppRoute.WebPage -> controller.navigate(
R.id.webFragment,
bundleOf("url" to route.url)
)
}
NavigationResult.Success
}.getOrElse { error ->
NavigationResult.Rejected(error.message ?: "unknown navigation error")
}
}
}这里使用 URI 作为跨模块导航协议,是因为业务模块只需要声明自己的 deep link,不必让调用方引用目标导航图生成的类。模块内部跳转仍然可以继续使用 Safe Args,两者并不冲突。
详情模块在自己的导航图中维护目的地和参数:
<fragment
android:id="@+id/articleDetailFragment"
android:name="com.example.article.ArticleDetailFragment">
<argument
android:name="articleId"
app:argType="long" />
<deepLink app:uri="myapp://article/{articleId}" />
</fragment>应用壳模块通过 `` 聚合各业务导航图:
<navigation
android:id="@+id/app_graph"
app:startDestination="@id/home_graph">
<include app:graph="@navigation/home_graph" />
<include app:graph="@navigation/article_graph" />
<include app:graph="@navigation/profile_graph" />
</navigation>这种组织方式让目的地归业务模块所有,应用壳只做组合。新增业务模块时,不需要修改其他业务模块的代码。
外部链接不能直接等同于内部可信路由。浏览器、短信或其他应用都可能构造 Intent,因此至少要检查 scheme、host、path 和参数范围。
可以让入口 Activity 先解析 URI,再转换为内部路由:
class DeepLinkParser {
fun parse(uri: Uri): AppRoute? {
if (uri.scheme != "https" || uri.host != "www.example.com") return null
val segments = uri.pathSegments
return when {
segments.size == 2 && segments[0] == "article" -> {
val id = segments[1].toLongOrNull() ?: return null
if (id <= 0) null else AppRoute.ArticleDetail(id)
}
segments.size == 2 && segments[0] == "user" -> {
val userId = segments[1].takeIf { it.matches(Regex("[A-Za-z0-9_-]{1,64}")) }
userId?.let(AppRoute::UserProfile)
}
else -> null
}
}
}不要将外部 URL 参数直接交给 WebView,也不要仅凭某个 query 参数决定敏感页面。涉及支付、账号绑定或隐私信息时,目标页面必须再次校验用户身份和业务状态。
对于 HTTPS App Links,还应在 Manifest 中启用域名验证:
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="www.example.com"
android:pathPrefix="/article" />
</intent-filter>同时在站点部署 `/.well-known/assetlinks.json`,让系统验证域名与应用签名的归属关系。自定义 scheme 可以保留给应用内部使用,但不适合作为可信外部入口,因为其他应用也可能声明相同 scheme。
当某些页面需要登录时,调用方不应该重复写登录判断。可以在导航执行器前增加拦截器:
fun interface RouteInterceptor {
fun intercept(route: AppRoute): AppRoute
}
class LoginInterceptor(
private val session: UserSession,
private val pendingRouteStore: PendingRouteStore
) : RouteInterceptor {
override fun intercept(route: AppRoute): AppRoute {
val requiresLogin = route is AppRoute.UserProfile
if (!requiresLogin || session.isLoggedIn) return route
pendingRouteStore.save(route)
return AppRoute.Login
}
}登录成功后读取并消费待执行路由。这里要注意“消费”语义,避免配置变化或重复回调导致同一个页面被打开多次。待执行路由如果需要落盘,也只应保存必要参数,并设置有效期。
实际项目还可以按需加入这些拦截器:
拦截器应返回明确结果,不要悄悄吞掉跳转。调用方或统一监控模块需要知道请求最终是成功、改道还是被拒绝。
深链跳转经常伴随返回栈问题。用户从通知打开详情页时,按返回键应该回到应用首页还是离开应用,需要在产品层面先定义。
对于应用内重复点击,可以结合 `launchSingleTop` 和当前目的地判断:
val options = navOptions {
launchSingleTop = true
restoreState = true
}
if (navController.currentDestination?.id != R.id.articleDetailFragment) {
navController.navigate(uri, options)
}但仅比较 destination id 可能过度拦截:用户从文章 A 跳到文章 B 时,目的地相同、参数不同,跳转仍然有效。更稳妥的方式是为一次导航生成业务键,例如 `article:1024`,在短时间窗口内只拦截相同键。
底部导航的多返回栈场景,应让每个 Tab 保存自己的状态,并使用 `popUpTo`、`saveState`、`restoreState` 配合恢复。不要手工维护 Fragment 栈与 Navigation 栈两套状态源。
线上日志至少应记录以下信息:
可以统一封装事件:
data class NavigationEvent(
val source: String,
val routeName: String,
val result: String,
val currentDestination: String?,
val durationMs: Long
)手机号、Token、完整 URL 查询参数等敏感数据不要进入日志。对于无法识别的外部深链,记录规则版本和路径模板即可,既能支持排查,也能降低泄露风险。
路由解析器是纯 Kotlin 逻辑,适合用单元测试覆盖合法和恶意输入:
class DeepLinkParserTest {
private val parser = DeepLinkParser()
@Test
fun validArticleLink_isParsed() {
val route = parser.parse(Uri.parse("https://www.example.com/article/1024"))
assertEquals(AppRoute.ArticleDetail(1024L), route)
}
@Test
fun invalidArticleId_isRejected() {
val route = parser.parse(Uri.parse("https://www.example.com/article/not-a-number"))
assertNull(route)
}
@Test
fun unknownHost_isRejected() {
val route = parser.parse(Uri.parse("https://evil.example/article/1024"))
assertNull(route)
}
}仪器测试则重点验证真实导航图:参数是否正确注入、登录改道后能否恢复、冷启动深链的返回栈是否符合预期。还可以在 CI 中运行 `adb shell am start`,覆盖 App Links 的端到端入口。
字符串路由看似解耦,实际会把拼写错误和参数错误推迟到运行时。至少应使用密封类型或集中定义的契约,并在边界处完成类型转换。
全局静态引用容易造成生命周期问题,也让测试变得困难。更合适的是注入 `AppNavigator`,由应用壳在当前宿主生命周期内提供执行能力。
URI 合法不代表操作有权限。深链解析负责输入安全,目标页面或用例层仍要执行账号、资源和操作权限校验。
模块内部页面关系明确时,Safe Args 更简单且类型安全。统一路由主要解决跨模块和外部入口,不必替代 Navigation 的全部能力。
多模块导航的核心不是引入一个“万能路由框架”,而是建立清晰的所有权:业务模块维护自己的页面和深链,契约模块表达稳定的跳转意图,应用壳负责组装、拦截和执行。外部深链再经过严格校验和可观测链路,页面跳转才能在项目扩张后依然可维护、可测试,也更容易排查线上问题。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。