第十五章:Hilt多模块项目:多模块架构下的依赖管理、模块间依赖隔离
多模块架构,说白了就是把一个巨型App拆成若干个独立的小模块。每个模块各司其职,互不干扰。这样做的好处很明显——编译快了、团队协作顺了、代码复用也方便了。
但问题来了:依赖怎么管理?
模块A要用模块B里的Repository,模块C又依赖模块D的ViewModel。如果处理不好,依赖关系就会乱成一锅粥。我在项目中就见过一个惨案——两个模块互相依赖,编译时直接报循环依赖错误,排查了整整两天。
嗯,今天我们就来聊聊,在Hilt的多模块项目中,如何优雅地管理依赖,以及如何实现模块间的依赖隔离。
15.1 多模块依赖管理的核心原则
我个人习惯把多模块依赖管理归纳为三条原则:
- 单向依赖:模块只能依赖比自己更「底层」的模块,不能反向依赖
- 接口隔离:模块之间通过接口通信,不暴露具体实现
- 按需提供:每个模块只提供自己负责的依赖,不越界
你想想看,如果每个模块都遵守这三条,依赖关系就会像一棵树,清晰明了。而不是一张蜘蛛网,牵一发而动全身。
核心思想:依赖管理不是「怎么注入」,而是「怎么设计模块边界」。Hilt只是工具,架构设计才是根本。
15.2 模块分层与依赖方向
我一般把模块分为三层:
| 层级 | 模块示例 | 依赖方向 |
|---|---|---|
| 应用层 | app、feature-home、feature-profile | 依赖业务层和基础层 |
| 业务层 | data、domain、repository | 依赖基础层 |
| 基础层 | core、common、network、database | 不依赖任何业务模块 |
举个例子:feature-home 模块可以依赖 data 模块,但 data 模块绝对不能依赖 feature-home。一旦出现反向依赖,你的项目就会陷入「改一个模块,所有模块都要重新编译」的噩梦。
我曾经接手过一个项目,基础层居然依赖了业务层的某个工具类。结果每次改业务代码,基础层都要重新编译,整个构建时间从3分钟变成了15分钟。嗯,后来我花了整整一周才把这种循环依赖清理干净。
15.3 Hilt中的模块间依赖隔离
Hilt本身提供了几种机制来实现依赖隔离。我来逐一说说。
15.3.1 使用@Module和@InstallIn限定作用域
每个模块的Hilt配置,都应该限定在自己的作用域内。比如:
// 在 :data 模块中
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides
@Singleton
fun provideUserRepository(
api: UserApi,
dao: UserDao
): UserRepository {
return UserRepositoryImpl(api, dao)
}
}
这样,UserRepository 只在 :data 模块内部提供。其他模块如果想用,必须通过接口来获取,而不是直接引用 UserRepositoryImpl。
小技巧:把接口定义在 :domain 或 :core 模块中,实现类放在 :data 模块。这样其他模块只依赖接口,不依赖实现。说白了就是「面向接口编程」在依赖注入中的体现。
15.3.2 使用@EntryPoint突破隔离
有时候,某个模块需要访问另一个模块的依赖,但又不方便直接依赖。这时候可以用 @EntryPoint。
举个例子::core:analytics 模块需要访问 :data 模块的 UserRepository,但 :core:analytics 是基础层,不应该依赖业务层。
// 在 :data 模块中定义 EntryPoint
@EntryPoint
@InstallIn(SingletonComponent::class)
interface DataEntryPoint {
fun getUserRepository(): UserRepository
}
// 在 :core:analytics 模块中使用
class AnalyticsService {
fun trackUserAction(context: Context) {
val entryPoint = EntryPointAccessors.fromApplication(
context.applicationContext,
DataEntryPoint::class.java
)
val repo = entryPoint.getUserRepository()
// 使用 repo
}
}
这样,:core:analytics 模块不需要直接依赖 :data 模块,只需要知道 DataEntryPoint 接口即可。依赖关系变成了「运行时获取」,而不是「编译时依赖」。
注意:@EntryPoint 是「后门」,不要滥用。我一般只在跨模块的「基础设施」场景下使用,比如日志、分析、崩溃上报等。如果业务代码大量使用 EntryPoint,说明你的模块划分有问题。
15.4 多模块依赖管理的实战配置
下面是一个典型的多模块项目的 build.gradle.kts 配置示例:
// :app 模块
dependencies {
implementation(project(":feature:home"))
implementation(project(":feature:profile"))
implementation(project(":data"))
implementation(project(":core:network"))
implementation(project(":core:database"))
}
// :feature:home 模块
dependencies {
implementation(project(":data"))
implementation(project(":core:common"))
}
// :data 模块
dependencies {
implementation(project(":core:network"))
implementation(project(":core:database"))
implementation(project(":domain"))
}
// :core:network 模块
dependencies {
// 只依赖第三方库,不依赖任何业务模块
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.11.0")
}
你看,依赖方向是单向的:app → feature → data → core。每个模块只依赖比自己更底层的模块。
15.5 模块间依赖隔离的避坑指南
我在实践中踩过不少坑,这里分享几个典型的:
- 坑1:模块间共享同一个Hilt组件。比如两个模块都用了
@InstallIn(SingletonComponent::class),结果一个模块的依赖被另一个模块意外覆盖。解决方案:尽量使用@InstallIn(ActivityComponent::class)或@InstallIn(FragmentComponent::class)缩小作用域。 - 坑2:在基础层引用业务层的类型。比如
:core:network模块的拦截器里直接引用了:data模块的实体类。解决方案:在基础层定义通用接口,业务层实现这些接口。 - 坑3:模块间传递Hilt的@Module。比如在
:app模块中直接@Module(includes = [DataModule::class])。这样会导致模块耦合。解决方案:每个模块自己管理自己的Module,不要跨模块引用。
我曾经在一个项目里,因为模块A的Module被模块B引用,导致模块B必须依赖模块A的所有第三方库。结果模块B的APK体积直接翻了一倍。嗯,从那以后我再也不敢跨模块引用Module了。
15.6 多模块依赖管理的知识体系
下面这张图展示了多模块依赖管理的核心逻辑:
15.7 总结
多模块项目的依赖管理,说白了就是「划清界限、各司其职」。Hilt提供了 @Module、@InstallIn、@EntryPoint 等工具来帮助我们实现依赖隔离。但工具只是辅助,真正的核心在于模块划分和接口设计。
我个人建议:在项目初期就规划好模块分层,定义好接口边界。不要等到代码写了一半再来重构,那时候的成本就高得多了。
一句话总结:依赖隔离不是限制你,而是保护你。模块之间保持距离,项目才能走得更远。
公众号:蓝海资料掘金营,微信deep3321