导航与模块化:在多模块项目中使用Navigation、模块间导航、动态功能模块与Navigation

说实话,模块化这个话题,我每次在团队里聊起来都能聊一下午。不是因为它有多复杂,而是因为它直接决定了你项目的未来——是越写越轻松,还是越写越痛苦。

今天咱们就聊聊,Navigation组件在模块化项目里到底该怎么用。我踩过的坑不少,希望能帮你绕过去。

为什么模块化需要Navigation?

先问个问题:你拆了模块,但模块之间怎么跳转?

很多团队的做法是:每个模块暴露一个Activity,然后通过Intent跳转。嗯,这确实能跑。但问题来了——

  • 每个模块都要注册Activity到Manifest,耦合还在
  • 参数传递全靠Intent的Bundle,类型安全?不存在的
  • 动画、深层链接、返回栈管理,每个模块各搞一套

我个人习惯是:既然用了单Activity架构,那就把Navigation贯彻到底。模块化不是让你放弃Navigation,而是让你把它用得更聪明。

核心思路:每个模块拥有自己的NavGraph,根模块负责组合这些子图。

模块间导航的三种方案

我在项目中试过好几种方式,最后沉淀下来三种比较靠谱的。咱们一个一个看。

方案一:通过Deep Link实现模块间跳转

这是最灵活的方式,也是Google官方推荐的做法之一。

每个模块定义自己的Deep Link URI,比如:

<!-- feature_profile模块的NavGraph -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/profile_graph"
    app:startDestination="@id/profileFragment">

    <fragment
        android:id="@+id/profileFragment"
        android:name="com.example.feature.profile.ProfileFragment">
        <deepLink app:uri="app://profile/{userId}" />
    </fragment>

</navigation>

然后在根模块的NavGraph里,通过Deep Link跳转:

// 在根模块中跳转到profile模块
val uri = "app://profile/${userId}".toUri()
findNavController().navigate(uri)

优点:完全解耦,模块之间不需要任何依赖。

缺点:URI是字符串,重构时容易遗漏。我建议把URI定义成常量放在一个公共模块里。

我的经验:Deep Link方案适合大型项目,尤其是团队分工明确、模块独立迭代的场景。但记得做好URI的版本管理,否则线上出问题很难排查。

方案二:通过ServiceLocator或接口暴露导航目标

这个方案更「Kotlin」一些。每个模块暴露一个接口,定义它支持的导航目标:

// 公共模块中定义接口
interface ProfileNavigation {
    fun navigateToProfile(userId: String)
}

// feature_profile模块实现
class ProfileNavigationImpl @Inject constructor(
    private val navController: NavController
) : ProfileNavigation {
    override fun navigateToProfile(userId: String) {
        navController.navigate(
            ProfileFragmentDirections.toProfileDetail(userId)
        )
    }
}

// 其他模块通过接口调用
class HomeViewModel @Inject constructor(
    private val profileNavigation: ProfileNavigation
) : ViewModel() {
    fun onUserClicked(userId: String) {
        profileNavigation.navigateToProfile(userId)
    }
}

这里用到了依赖注入(Hilt/Koin),模块之间只依赖接口,不依赖具体实现。

优点:类型安全,IDE重构友好。

缺点:需要依赖注入框架,接口数量会随着模块增多而膨胀。

注意:接口定义要放在公共模块(比如core:navigation)里,否则模块之间还是会有循环依赖。我曾经因为接口放错位置,折腾了一整天。

方案三:通过路由框架(如ARouter、WMRouter)

这个方案其实和Deep Link类似,但路由框架提供了更丰富的功能:拦截器、降级策略、参数自动注入等。

// 在目标Fragment上添加注解
@Route(path = "/profile/detail")
class ProfileDetailFragment : Fragment() {
    @Autowired
    lateinit var userId: String
}

// 跳转时
ARouter.getInstance()
    .build("/profile/detail")
    .withString("userId", userId)
    .navigation()

优点:功能全面,社区成熟。

缺点:额外引入框架,注解处理可能拖慢编译速度。

我个人不太推荐在Navigation组件之外再套一层路由框架。既然用了Navigation,就把它用透。除非你的项目有动态化需求,或者需要支持插件化。

动态功能模块与Navigation

动态功能模块(Dynamic Feature Module)是Google Play的「按需下载」功能。用户用到某个模块时才下载,不用就不占空间。

嗯,这里有个坑——Navigation组件默认不支持动态功能模块。你需要做一些额外工作。

如何让Navigation支持动态模块?

核心思路是:动态模块的NavGraph在编译时不可见,需要在运行时动态加载。

步骤是这样的:

  1. 在动态模块中定义NavGraph,但不要在主模块的NavGraph中include它
  2. 在动态模块中创建一个「导航入口Fragment」,它负责加载该模块的NavGraph
  3. 主模块通过Deep Link或接口跳转到这个入口Fragment

代码示例:

// 动态模块中的入口Fragment
class DynamicFeatureEntryFragment : Fragment() {

    override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
        return inflater.inflate(R.layout.fragment_entry, container, false)
    }

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)

        // 动态加载该模块的NavGraph
        val navHost = childFragmentManager.findFragmentById(R.id.dynamic_nav_host) as NavHostFragment
        val navController = navHost.navController

        // 注意:这里的NavGraph资源在动态模块中
        navController.setGraph(R.navigation.dynamic_feature_graph)
    }
}

我曾经踩过的坑:动态模块中的NavGraph资源ID在编译时是动态分配的,如果你在主模块中硬编码了R.navigation.xxx,编译会直接报错。解决方案是:所有资源引用都放在动态模块内部,主模块只通过Fragment类名或Deep Link来触发。

动态模块的返回栈处理

动态模块的返回栈和主模块是独立的。用户从主模块进入动态模块后,按返回键应该先回到动态模块的上一页,再回到主模块。

这个行为默认是支持的,但有一个边界情况:如果动态模块下载失败怎么办?

我的做法是:在跳转前检查模块是否已下载,如果没有,先触发下载:

// 使用SplitInstallManager检查模块状态
val splitInstallManager = SplitInstallManagerFactory.create(context)

fun navigateToDynamicFeature() {
    if (splitInstallManager.installedModules.contains("feature_ar")) {
        // 已安装,直接跳转
        findNavController().navigate(R.id.action_to_ar_feature)
    } else {
        // 未安装,先下载
        val request = SplitInstallRequest.newBuilder()
            .addModule("feature_ar")
            .build()

        splitInstallManager.startInstall(request)
            .addOnSuccessListener {
                // 下载成功后跳转
                findNavController().navigate(R.id.action_to_ar_feature)
            }
    }
}

小技巧:下载过程中可以显示一个进度条,用户体验会好很多。我一般会在公共模块里封装一个DownloadStateFlow,各个模块统一使用。

模块化项目中的NavGraph组织

最后聊聊文件结构。我见过太多项目把所有的导航都塞进一个nav_graph.xml里,那画面太美我不敢看。

合理的做法是:

  • 每个模块有自己的nav_graph.xml,只包含该模块内部的页面跳转
  • 根模块有一个nav_graph.xml,通过引入各个子模块的图
  • 模块之间的跳转,通过Deep Link或接口完成

举个例子:

<!-- app模块的nav_graph.xml -->
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/root_graph"
    app:startDestination="@id/homeFragment">

    <include app:graph="@navigation/home_nav" />
    <include app:graph="@navigation/profile_nav" />
    <include app:graph="@navigation/settings_nav" />

</navigation>

这样每个模块的导航逻辑是自治的,修改一个模块不会影响其他模块。说白了,这就是「高内聚、低耦合」在导航层的落地。

总结一下

模块化导航没有银弹。Deep Link方案最灵活,接口方案最安全,路由框架方案最全面。选哪个,取决于你的团队规模和项目复杂度。

我个人更倾向于接口方案 + Deep Link兜底。接口保证开发时的类型安全,Deep Link作为动态模块和跨团队协作的桥梁。两者结合,基本能覆盖所有场景。

动态功能模块这块,记住一个原则:动态模块的导航逻辑要自包含。不要依赖主模块的任何导航资源,否则你会被各种ClassNotFoundException折磨到怀疑人生。

好了,导航与模块化就聊到这里。下一章咱们会深入Navigation的源码,看看它到底是怎么工作的。嗯,到时候你会发现,很多「黑魔法」其实没那么神秘。


公众号:蓝海资料掘金营,微信deep3321