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

简介: 本文提出一套面向多模块 Android 项目的导航治理方案:通过密封接口定义路由契约、应用壳模块统一映射与拦截、深链校验+可观测性建设,解耦业务模块、保障类型安全、统一处理登录/权限等前置逻辑,并支持可追踪、易测试的跨模块跳转。

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

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

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

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

findNavController().navigate(
    HomeFragmentDirections.actionHomeToDetail(articleId)
)

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

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

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

先划分导航的职责

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

层次 负责内容 不应该负责
业务页面 发出“去哪里”的意图 解析 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>

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

<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 保存自己的状态,并使用 popUpTosaveStaterestoreState 配合恢复。不要手工维护 Fragment 栈与 Navigation 栈两套状态源。

为导航建立可观测性

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

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

可以统一封装事件:

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 的端到端入口。

常见误区

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

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

让业务模块持有全局 NavController

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

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

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

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

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

落地检查清单

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

总结

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

相关文章
|
28天前
|
存储 数据采集 JSON
[鸿蒙从零到一] ArkUI 列表与网格实战:List、Grid 与 LazyForEach
本文详解ArkUI中List与Grid组件的实战应用,涵盖静态列表、网格切换、懒加载(LazyForEach)、滚动定位、空/错/加载态处理等核心场景,并强调稳定key、精准数据通知与性能优化要点,助开发者构建高性能集合页面。
83 0
|
存储 机器学习/深度学习 缓存
一看就懂!图解 Kotlin SharedFlow 缓存系统
一看就懂!图解 Kotlin SharedFlow 缓存系统
591 2
|
28天前
|
测试技术 Shell 开发工具
前台服务适配与线上排查:通知权限、启动限制和任务保活
本文详解前台服务的合规实现:涵盖通知权限适配、后台启动限制规避、多版本系统兼容及线上问题排查方法,强调按场景选型(如导航/播放用前台服务,同步用WorkManager),避免滥用保活,助你构建稳定长任务能力。
94 0
|
26天前
|
XML 数据采集 缓存
RecyclerView 多类型列表实战:稳定刷新、状态恢复与性能治理
本文详解RecyclerView多类型列表实战:通过密封类建模、稳定ID、DiffUtil精准对比、payload局部刷新、状态隔离与嵌套列表恢复等手段,解决闪烁、错位、状态串行等顽疾,兼顾性能、可维护性与扩展性。
80 0
|
28天前
|
人工智能 安全 前端开发
AI 时代文本加盐钓鱼攻击回潮的 LLM 检测失效机制与分层防御研究
本文基于Barracuda 2026年百万级文本加盐钓鱼攻击实证,揭示AI邮件过滤因忽略HTML“源码-渲染”双层结构而失效的根源;提出“源码清洗—可视文本还原—分层语义加权”三层检测架构,配套可落地Python代码,将识别率提升至93.4%,并构建覆盖网关、云服务、终端与行业的全链路防御体系。(239字)
77 0
|
28天前
|
人工智能 安全 网络安全
生成式 AI 赋能下旅游场景网络诈骗攻击机理与全域防御研究
本文揭示2026年AI驱动的旅游诈骗新态势:攻击者利用大模型、图像/语音生成技术批量伪造房源、订单通知与客服对话,结合钓鱼二维码、仿冒WiFi及私域渗透实施精准欺诈。研究拆解四大攻击链路,提出“AI对抗AI”多模态鉴伪体系,并构建平台-终端-支付-运营四层全域防御框架,配套可落地检测代码,实现100%全链路拦截。(239字)
104 0
|
28天前
|
数据采集 人工智能 数据挖掘
企业有多个AI应用,员工却不知道怎么用:一次AI工作助理路由改造实践
当一个任务能够被拆解、调用、评估、人工确认并持续改进时,智能体才真正从Demo进入业务。
159 1
|
28天前
|
IDE JavaScript 前端开发
【全网最详细】VS2022下载、安装、使用一篇搞定(附社区版安装包)
Visual Studio 2022(VS2022)是微软推出的首个原生64位IDE,突破4GB内存限制,性能更强。支持C/C++、C#、Python、JavaScript等多语言,提供免费社区版(功能完整)、专业版和企业版。推荐初学者与个人开发者使用社区版,安装灵活、学习成本低,是高效开发的理想选择。(239字)
|
存储 缓存 NoSQL
跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)
本文将要分享的是如何从零实现一套基于Netty框架的分布式高可用IM系统,它将支持长连接网关管理、单聊、群聊、聊天记录查询、离线消息存储、消息推送、心跳、分布式唯一ID、红包、消息同步等功能,并且还支持集群部署。
14135 1