首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >[Android 从零到一] Navigation 多模块导航与深链治理:让页面跳转可维护、可追踪

[Android 从零到一] Navigation 多模块导航与深链治理:让页面跳转可维护、可追踪

原创
作者头像
hunter android
发布2026-07-20 10:52:21
发布2026-07-20 10:52:21
180
举报

Navigation 多模块导航与深链治理:让页面跳转可维护、可追踪

当 Android 项目从单模块扩展到多个业务模块,页面跳转很容易从几行 `navigate()` 变成一张难以维护的网:路由参数散落、模块互相依赖、深链规则重复,线上出现跳转失败时也很难定位。本文从 Navigation 的基本边界出发,逐步搭建一套适合真实项目的多模块导航方案,并补齐深链校验、统一入口和故障追踪。

单模块导航为什么会逐渐失控

小型应用通常只有一个导航图,页面可以直接引用目标 Fragment 的 action:

代码语言:javascript
复制
findNavController().navigate(
    HomeFragmentDirections.actionHomeToDetail(articleId)
)

这种写法类型安全、直观,在单模块内非常合适。问题出现在业务拆分后:首页模块如果直接引用详情模块生成的 Directions 类,就会形成编译期依赖;更多业务接入后,模块之间可能出现环状依赖。

多模块导航要解决的并不只是“能跳过去”,还包括这些约束:

  • 业务模块不直接依赖彼此的实现
  • 路由参数有明确类型和校验规则
  • App 内跳转与外部深链复用同一套入口
  • 登录、实验开关等前置条件可以统一处理
  • 跳转失败能够留下足够的诊断信息

先划分导航的职责

一套稳定的导航结构通常分为三层:

层次

负责内容

不应该负责

业务页面

发出“去哪里”的意图

解析 URI、判断登录态

路由契约

定义目的地和参数

持有 Activity 或 Fragment

导航执行器

解析契约、检查前置条件、执行跳转

处理具体业务数据

业务模块只依赖轻量的路由契约模块,应用壳模块负责组装实际导航实现。这样既保留模块边界,也不会把所有跳转逻辑塞进一个巨大的工具类。

用路由契约表达跳转意图

相比到处拼字符串,密封接口更适合表达应用内部的目的地:

代码语言:javascript
复制
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:

代码语言:javascript
复制
val result = navigator.navigate(AppRoute.ArticleDetail(articleId = 1024L))

契约应保持稳定和精简。不要把数据库实体或网络响应对象直接作为路由参数,否则页面跳转会和数据层模型绑定。通常只传稳定标识,目标页面再通过 Repository 加载数据,更容易处理进程重建和数据过期。

在应用壳模块完成路由映射

应用壳模块可以访问各业务导航图,因此适合提供 `AppNavigator` 的实现:

代码语言:javascript
复制
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,两者并不冲突。

每个业务模块声明自己的深链

详情模块在自己的导航图中维护目的地和参数:

代码语言:javascript
复制
<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>

应用壳模块通过 `` 聚合各业务导航图:

代码语言:javascript
复制
<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,再转换为内部路由:

代码语言:javascript
复制
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 中启用域名验证:

代码语言:javascript
复制
<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。

把登录和业务前置条件做成拦截链

当某些页面需要登录时,调用方不应该重复写登录判断。可以在导航执行器前增加拦截器:

代码语言:javascript
复制
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` 和当前目的地判断:

代码语言:javascript
复制
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 栈两套状态源。

为导航建立可观测性

线上日志至少应记录以下信息:

  • 来源页面或外部入口类型
  • 目标路由名称,不记录敏感参数原文
  • 解析、拦截、执行各阶段的结果
  • 当前 destination 和应用版本
  • 异常类型与脱敏后的错误信息

可以统一封装事件:

代码语言:javascript
复制
data class NavigationEvent(
    val source: String,
    val routeName: String,
    val result: String,
    val currentDestination: String?,
    val durationMs: Long
)

手机号、Token、完整 URL 查询参数等敏感数据不要进入日志。对于无法识别的外部深链,记录规则版本和路径模板即可,既能支持排查,也能降低泄露风险。

测试不要只覆盖“能打开页面”

路由解析器是纯 Kotlin 逻辑,适合用单元测试覆盖合法和恶意输入:

代码语言:javascript
复制
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 的端到端入口。

常见误区

把全局路由做成任意字符串跳转

字符串路由看似解耦,实际会把拼写错误和参数错误推迟到运行时。至少应使用密封类型或集中定义的契约,并在边界处完成类型转换。

让业务模块持有全局 NavController

全局静态引用容易造成生命周期问题,也让测试变得困难。更合适的是注入 `AppNavigator`,由应用壳在当前宿主生命周期内提供执行能力。

只校验深链格式,不校验业务权限

URI 合法不代表操作有权限。深链解析负责输入安全,目标页面或用例层仍要执行账号、资源和操作权限校验。

所有跳转都强行走跨模块协议

模块内部页面关系明确时,Safe Args 更简单且类型安全。统一路由主要解决跨模块和外部入口,不必替代 Navigation 的全部能力。

落地检查清单

  • 业务模块只依赖路由契约,不依赖其他业务实现
  • 路由只传稳定标识,不传大型对象或数据层实体
  • 外部深链统一校验 scheme、host、path 和参数
  • HTTPS App Links 配置域名验证
  • 登录等前置条件通过统一拦截器处理
  • 重复导航同时比较目的地和业务参数
  • 导航失败有脱敏日志与明确结果
  • 解析器、导航图、冷启动入口都有测试覆盖

总结

多模块导航的核心不是引入一个“万能路由框架”,而是建立清晰的所有权:业务模块维护自己的页面和深链,契约模块表达稳定的跳转意图,应用壳负责组装、拦截和执行。外部深链再经过严格校验和可观测链路,页面跳转才能在项目扩张后依然可维护、可测试,也更容易排查线上问题。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • Navigation 多模块导航与深链治理:让页面跳转可维护、可追踪
    • 单模块导航为什么会逐渐失控
    • 先划分导航的职责
    • 用路由契约表达跳转意图
    • 在应用壳模块完成路由映射
    • 每个业务模块声明自己的深链
    • 外部深链必须经过统一入口
    • 把登录和业务前置条件做成拦截链
    • 正确处理返回栈和重复导航
    • 为导航建立可观测性
    • 测试不要只覆盖“能打开页面”
    • 常见误区
      • 把全局路由做成任意字符串跳转
      • 让业务模块持有全局 NavController
      • 只校验深链格式,不校验业务权限
      • 所有跳转都强行走跨模块协议
    • 落地检查清单
    • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档