导航与模块化:在多模块项目中使用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在编译时不可见,需要在运行时动态加载。
步骤是这样的:
- 在动态模块中定义NavGraph,但不要在主模块的NavGraph中include它
- 在动态模块中创建一个「导航入口Fragment」,它负责加载该模块的NavGraph
- 主模块通过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