三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

[Android 从零到一] Hilt 在多模块项目中的落地实战:依赖注入的边界与模块化设计

[Android 从零到一] Hilt 在多模块项目中的落地实战:依赖注入的边界与模块化设计

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 模块,无法访问其中的类。

解决方案:通过接口解耦

1. 在 :core:domain:data:repository 的公开接口部分定义接口:

// core/domain 模块

interface UserRepository { suspend fun getUser(id: String): User }

2. 在 :data:repository 中实现接口:

// data/repository 模块

class UserRepositoryImpl @Inject constructor( private val api: UserApi ) : UserRepository { override suspend fun getUser(id: String): User { return api.fetchUser(id) } }

3. 在 :data:repository 的 Hilt Module 中绑定接口与实现:

@Module

@InstallIn(SingletonComponent::class) abstract class RepositoryModule {

@Binds @Singleton abstract fun bindUserRepository( impl: UserRepositoryImpl ): UserRepository }

4. 在 :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 模块,负责组装

接口设计的注意事项

1. 接口放在 domain 模块,不要放在 data 模块内部,否则 feature 模块无法直接依赖 2. 返回值使用 domain 模型,不要暴露 DTO 或数据库 Entity 3. 接口粒度适中,不要为了"解耦"而过度拆分,导致接口爆炸

---

测试替换与 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(null) val userState: StateFlow = _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 插件或依赖。

解决方案

1. 确认所有需要注入的模块都添加了 dagger.hilt.android.plugin 2. 确认 kapt("com.google.dagger:hilt-compiler:2.48") 在所有模块中都配置了 3. 清理构建缓存:./gradlew clean

问题:注入的实例为 null

原因:可能是 Module 的 @InstallIn 注解配置错误,或者 Component 生命周期不匹配。

解决方案

- 检查 Module 是否正确安装到了 SingletonComponent - 检查被注入的类是否标注了 @Inject 构造函数 - 检查 ViewModel 是否使用了 @HiltViewModel 注解

问题:循环依赖

原因:两个类相互依赖,Hilt 无法确定注入顺序。

解决方案

1. 重构代码,打破循环依赖(推荐) 2. 使用 ProviderLazy 延迟注入:

class A @Inject constructor(

private val bProvider: Provider ) { fun doSomething() { val b = bProvider.get() // 延迟获取 B 的实例 } }

---

总结

Hilt 在多模块项目中的核心思路是:

1. 接口与实现分离:接口定义在 domain 模块,实现在 data 模块,feature 模块只依赖接口 2. 模块边界清晰:feature 不依赖 feature,feature 不依赖 data 实现,依赖关系单向流动 3. 测试友好:通过 @TestInstallIn 替换 Module,或者抽取独立的测试模块 4. 统一的 Component:所有模块共享 app 模块的 @HiltAndroidApp,不需要在每个模块中重复定义

当项目规模持续增长时,良好的模块化设计配合 Hilt 的依赖注入能力,可以让代码保持清晰、可测试、可维护。

---

推荐阅读: - [Hilt 官方文档](https://dagger.dev/hilt/) - [Android 模块化最佳实践](https://developer.android.com/topic/modularization)

← 返回列表