Hilt 在多模块项目中的落地实战:依赖注入的边界与模块化设计
引言
在单模块项目中,Hilt 的依赖注入配置通常比较直接:定义 Module、标注 @Inject、编译通过即可使用。但当项目逐步模块化后,依赖注入的复杂度会显著上升:
- 如何在 feature 模块中注入来自 data 模块的 Repository?
- 不同模块的 Hilt Component 如何协调?
- 测试时如何替换跨模块的依赖?
- 模块边界应该如何划分,才能让依赖注入保持清晰?
本文将从多模块项目的实际场景出发,梳理 Hilt 在模块化架构中的落地思路、常见问题与解决方案。
多模块 Hilt 的基本配置
Gradle 配置
在多模块项目中,Hilt 的配置需要在多个模块的 build.gradle 中分别声明:
app 模块(应用模块):
plugins {
id("com.android.application")
id("kotlin-android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.48")
kapt("com.google.dagger:hilt-compiler:2.48")
}
feature 模块、data 模块等(库模块):
plugins {
id("com.android.library")
id("kotlin-android")
id("kotlin-kapt")
id("dagger.hilt.android.plugin") // 每个模块都需要
}
dependencies {
implementation("com.google.dagger:hilt-android:2.48")
kapt("com.google.dagger:hilt-compiler:2.48")
}
Application 类的配置
Hilt 的入口仍然是 @HiltAndroidApp 标注的 Application 类,它只能存在于 app 模块:
@HiltAndroidApp
class MyApplication : Application()
其他模块不需要再定义 Application,它们会共享 app 模块的 Hilt Component。
跨模块依赖注入的常见问题
问题一:feature 模块无法直接依赖 data 模块的实现类
假设项目结构如下:
:app
:feature:home
:data:repository
:data:network
在 :feature:home 中,ViewModel 需要注入 UserRepository:
// feature/home 模块
@HiltViewModel
class HomeViewModel @Inject constructor(
private val userRepository: UserRepository // 编译失败:找不到 UserRepository
) : ViewModel()
原因::feature:home 没有依赖 :data:repository 模块,无法访问其中的类。
解决方案:通过接口解耦
- 在
:core:domain或:data:repository的公开接口部分定义接口:
// core/domain 模块
interface UserRepository {
suspend fun getUser(id: String): User
}
- 在
:data:repository中实现接口:
// data/repository 模块
class UserRepositoryImpl @Inject constructor(
private val api: UserApi
) : UserRepository {
override suspend fun getUser(id: String): User {
return api.fetchUser(id)
}
}
- 在
:data:repository的 Hilt Module 中绑定接口与实现:
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}
- 在
:feature:home中依赖接口:
@HiltViewModel
class HomeViewModel @Inject constructor(
private val userRepository: UserRepository // 注入接口
) : ViewModel()
模块依赖关系:
:feature:home -> :core:domain (接口)
:data:repository -> :core:domain (接口)
:app -> :feature:home, :data:repository
这样,:feature:home 只依赖接口,不依赖具体实现,模块边界更清晰。
模块边界的划分与接口设计
推荐的模块结构
:app // 应用入口,组装所有模块
:core:domain // 业务接口与 Model
:core:common // 通用工具、扩展函数
:data:network // 网络层实现(Retrofit、OkHttp)
:data:local // 本地存储(Room、DataStore)
:data:repository // Repository 实现
:feature:home // 首页功能模块
:feature:profile // 个人资料功能模块
依赖原则
- feature 模块:只依赖
:core:domain和:core:common,不依赖其他 feature 或 data 实现 - data 模块:实现
:core:domain中的接口,可以相互依赖(如:data:repository依赖:data:network) - app 模块:依赖所有 feature 和 data 模块,负责组装
接口设计的注意事项
- 接口放在 domain 模块,不要放在 data 模块内部,否则 feature 模块无法直接依赖
- 返回值使用 domain 模型,不要暴露 DTO 或数据库 Entity
- 接口粒度适中,不要为了"解耦"而过度拆分,导致接口爆炸
测试替换与 Mock 注入
问题:测试时如何替换 Repository?
在单元测试中,我们通常需要用 Fake 或 Mock 实现替换真实的 Repository,但 Hilt 默认使用 SingletonComponent 中的绑定,无法轻易替换。
解决方案一:使用 @TestInstallIn
Hilt 提供了 @TestInstallIn 注解,可以在测试中替换 Module:
// test 目录
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class] // 替换生产环境的 Module
)
abstract class FakeRepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(
impl: FakeUserRepository
): UserRepository
}
class FakeUserRepository @Inject constructor() : UserRepository {
override suspend fun getUser(id: String): User {
return User(id, "Fake User")
}
}
测试代码:
@HiltAndroidTest
class HomeViewModelTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject
lateinit var repository: UserRepository // 自动注入 FakeUserRepository
@Test
fun testGetUser() = runTest {
val user = repository.getUser("123")
assertEquals("Fake User", user.name)
}
}
解决方案二:抽取独立的测试模块
如果多个测试类需要共享 Fake 实现,可以将 Fake 实现和 Module 放在独立的 test-shared 模块中:
:test-shared
- FakeUserRepository.kt
- FakeRepositoryModule.kt
在测试模块的 build.gradle 中依赖:
testImplementation(project(":test-shared"))
实战案例:网络层与存储层的模块化注入
案例:构建一个离线优先的用户信息获取流程
模块结构:
:core:domain -> UserRepository 接口
:data:network -> UserApi (Retrofit)
:data:local -> UserDao (Room)
:data:repository -> UserRepositoryImpl (组合 network + local)
:feature:profile -> ProfileViewModel (使用 UserRepository)
data/network 模块:
interface UserApi {
@GET("users/{id}")
suspend fun fetchUser(@Path("id") id: String): UserDto
}
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideUserApi(retrofit: Retrofit): UserApi {
return retrofit.create(UserApi::class.java)
}
}
data/local 模块:
@Entity(tableName = "users")
data class UserEntity(
@PrimaryKey val id: String,
val name: String
)
@Dao
interface UserDao {
@Query("SELECT * FROM users WHERE id = :id")
suspend fun getUser(id: String): UserEntity?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertUser(user: UserEntity)
}
@Database(entities = [UserEntity::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"app_database"
).build()
}
@Provides
fun provideUserDao(database: AppDatabase): UserDao {
return database.userDao()
}
}
data/repository 模块:
class UserRepositoryImpl @Inject constructor(
private val userApi: UserApi,
private val userDao: UserDao
) : UserRepository {
override suspend fun getUser(id: String): User {
// 先读本地
val cachedUser = userDao.getUser(id)
if (cachedUser != null) {
return cachedUser.toDomain()
}
// 再请求网络
val remoteUser = userApi.fetchUser(id)
val entity = UserEntity(remoteUser.id, remoteUser.name)
userDao.insertUser(entity)
return entity.toDomain()
}
private fun UserEntity.toDomain() = User(id, name)
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}
feature/profile 模块:
@HiltViewModel
class ProfileViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
private val _userState = MutableStateFlow<User?>(null)
val userState: StateFlow<User?> = _userState.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
_userState.value = userRepository.getUser(id)
}
}
}
依赖关系图:
:app
-> :feature:profile (依赖 :core:domain)
-> :data:repository (依赖 :core:domain, :data:network, :data:local)
-> :data:network
-> :data:local
这样的设计让 :feature:profile 完全不感知网络和数据库的实现细节,只通过接口交互,测试时可以轻松替换 Fake 实现。
常见问题排查
问题:编译时提示 "Hilt component not found"
原因:某个模块没有正确配置 Hilt 插件或依赖。
解决方案:
- 确认所有需要注入的模块都添加了
dagger.hilt.android.plugin - 确认
kapt("com.google.dagger:hilt-compiler:2.48")在所有模块中都配置了 - 清理构建缓存:
./gradlew clean
问题:注入的实例为 null
原因:可能是 Module 的 @InstallIn 注解配置错误,或者 Component 生命周期不匹配。
解决方案:
- 检查 Module 是否正确安装到了
SingletonComponent - 检查被注入的类是否标注了
@Inject构造函数 - 检查 ViewModel 是否使用了
@HiltViewModel注解
问题:循环依赖
原因:两个类相互依赖,Hilt 无法确定注入顺序。
解决方案:
- 重构代码,打破循环依赖(推荐)
- 使用
Provider<T>或Lazy<T>延迟注入:
class A @Inject constructor(
private val bProvider: Provider<B>
) {
fun doSomething() {
val b = bProvider.get() // 延迟获取 B 的实例
}
}
总结
Hilt 在多模块项目中的核心思路是:
- 接口与实现分离:接口定义在 domain 模块,实现在 data 模块,feature 模块只依赖接口
- 模块边界清晰:feature 不依赖 feature,feature 不依赖 data 实现,依赖关系单向流动
- 测试友好:通过
@TestInstallIn替换 Module,或者抽取独立的测试模块 - 统一的 Component:所有模块共享 app 模块的
@HiltAndroidApp,不需要在每个模块中重复定义
当项目规模持续增长时,良好的模块化设计配合 Hilt 的依赖注入能力,可以让代码保持清晰、可测试、可维护。
推荐阅读: