什么是 Hilt Module?如何自定义一个提供 Repository 实例的 Module?

解读

国内大厂面试中,这道题出现的频率仅次于“Hilt 与 Dagger 的区别”。面试官真正想确认的是:

  1. 你是否理解 Hilt 的“约定大于配置”思想——Module 只是 Dagger 的语法糖,但 Hilt 通过 @InstallIn 把组件生命周期管起来;
  2. 你能否在实战中写出“可测试、可替换、作用域正确”的 Repository 提供逻辑,而不是只会 @Inject constructor;
  3. 对作用域、线程、Context 来源等细节是否有坑位意识,比如 AndroidStudio 默认模板生成的 Module 经常把 @Singleton 误用在可配置对象上,导致热修复或单元测试时无法替换。

因此,回答时要先给出 Hilt Module 的准确定义,再给出一段可直接拷贝到项目里跑的代码,并主动把“为什么用 @Singleton”“为什么传 ApplicationContext”这两个高频追问点前置解释掉。

知识点

  1. Hilt Module:被 @Module 注解的普通类,配合 @InstallIn 指定 Android 组件(SingletonComponent/ActivityComponent 等),告诉 Hilt 如何提供无法用构造函数注入的实例。
  2. @Provides:Module 内方法级注解,显式声明实例化逻辑;返回值类型即依赖类型。
  3. @Singleton / @ViewModelScoped:作用域注解,必须和 @InstallIn 的组件一一对应,否则编译期报错。
  4. 限定符:当同一类型存在多实现时,用自定义限定符(如 @Named、@Qualifier)消除歧义。
  5. 组件层级:SingletonComponent → ViewModelComponent → ActivityComponent → FragmentComponent,生命周期依次缩短,禁止向上依赖。
  6. 静态生成规则:Hilt 在 kapt/apt 阶段生成 HiltModules_xxx 类,最终并入 Dagger 图;因此 Module 必须保证无参构造且非 final。
  7. 国内场景:国内项目往往同时存在 Google 官方组件与厂商定制 SDK,Repository 的依赖树里既有 RoomDatabase 又有第三方 AAR 中的 Context,需要显式提供 ApplicationContext 防止内存泄漏。

答案

Hilt Module 是“告诉 Hilt 如何创建那些不能自动构造的实例”的载体;通过 @Module + @InstallIn 把实例创建逻辑挂载到 Android 生命周期组件上,从而与 Hilt 的自动注入图打通。

下面给出国内项目中最常见场景:Repository 依赖了 Room 的 DAO 与 Retrofit 的 Service,且需要全局单例。

步骤 1:声明 Repository 接口与实现

interface UserRepository { suspend fun login(mobile: String): User }
class UserRepositoryImpl @Inject constructor(
    private val api: UserApi,
    private val dao: UserDao
) : UserRepository { 
    override suspend fun login(mobile: String): User {
        val net = api.login(mobile)
        dao.insert(net)
        return net
    }
}

步骤 2:自定义 Module

@Module
@InstallIn(SingletonComponent::class) // 跟随 Application 生命周期
object RepositoryModule {

    @Provides
    @Singleton
    fun provideUserRepository(
        api: UserApi,
        dao: UserDao
    ): UserRepository = UserRepositoryImpl(api, dao)
}

步骤 3:在 ViewModel 中直接使用

@HiltViewModel
class LoginViewModel @Inject constructor(
    private val repo: UserRepository
) : ViewModel() {
    fun login(mobile: String) = viewModelScope.launch {
        val user = repo.login(mobile)
        // ...
    }
}

要点解释

  • 用 @Singleton 保证 Repository 在应用生命周期内只有一份,避免重复创建数据库连接。
  • 不直接 @Inject constructor 到 UserRepositoryImpl,是因为接口与实现分离后,Hilt 无法对接口做动态代理;Module 负责把实现绑定到接口。
  • 如果以后要做单元测试,只需在 androidTest 目录再写一个 @Module 并用 @TestInstallIn 替换掉正式实现即可,符合国内持续集成要求。

拓展思考

  1. 多实现冲突:当项目里同时存在 LocalUserRepository 和 RemoteUserRepository,可用自定义限定符
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class Local
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class Remote

在 Module 里分别 @Provides @Local 和 @Provides @Remote,调用方用 @Local UserRepository 注入即可,避免 Named 字符串写错。

  1. 作用域误用:Repository 如果持有 Activity 级 Context,却声明 @Singleton,会导致内存泄漏;正确姿势是只注入 Application,或用 @ActivityScoped + ActivityComponent。

  2. 替换策略:国内渠道包经常需要“华为账号/小米账号”两套 Repository 实现,可在 buildVariant 维度提供不同 Module,利用 Hilt 的 @TestInstallIn 原理做“正式替换”,比手写工厂优雅。

  3. 性能陷阱:Module 中 @Provides 方法若创建成本较高(如加密数据库),建议改成 @Provides @Singleton 并确保内部无耗时初始化;否则主线程触发注入会掉帧,面试时可主动提到“用 Dagger 的 Lazy 或 Kotlin 的 by lazy 延迟初始化”。

  4. 与 Jetpack Startup 结合:国内厂商对自启动管控严格,可在 Initializer 里只负责初始化数据库,Repository 仍由 Hilt 注入,避免双初始化带来的耗时与崩溃。