15、Hilt进阶:@Binds、@Qualifier、@Singleton、ViewModel注入、Fragment注入、实战:模块化注入

好,咱们继续聊Hilt。上一章我们把Hilt的入门姿势捋了一遍,你大概已经能跑通一个基本的依赖注入了。但说实话,那只是冰山一角。真正到了项目里,你会发现事情远没那么简单——接口要绑定实现、同一个类型要区分不同实例、ViewModel和Fragment怎么注入、多模块项目怎么解耦……这些才是日常开发里天天要面对的问题。

我个人习惯把Hilt的进阶用法分成三个层次:绑定技巧作用域管理模块化架构。咱们一个一个来拆。

15.1 @Binds:接口与实现解耦

先问个问题:如果你的代码里依赖的是一个接口,Hilt怎么知道该注入哪个实现?

嗯,这就是@Binds的用武之地。它专门用来告诉Hilt:「当有人要这个接口时,你给我这个实现。」

看个例子。假设我们有一个日志接口:

interface Logger {
    fun log(message: String)
}

class FileLogger @Inject constructor() : Logger {
    override fun log(message: String) {
        // 写入文件
    }
}

现在我们要在Module里绑定它。注意,@Binds必须用在抽象方法上,而且方法参数就是实现类,返回值是接口:

@Module
@InstallIn(SingletonComponent::class)
abstract class LoggerModule {

    @Binds
    abstract fun bindLogger(fileLogger: FileLogger): Logger
}

这里有个坑,我刚开始用的时候踩过——@Binds方法不能和@Provides混在同一个抽象类里。如果你既有抽象绑定又有具体提供,得拆成两个Module,或者把@Provides放到companion object里。

我的习惯:接口绑定统一用@Binds,具体对象创建用@Provides。这样职责清晰,也好维护。

15.2 @Qualifier:区分同类型的不同实例

有时候你会遇到这种情况:同一个接口,需要注入两个不同的实现。比如一个本地缓存、一个远程缓存,都是Cache接口。这时候Hilt就懵了——你到底要哪个?

解决方案就是自定义限定符。用@Qualifier注解给每个实现打上标签。

先定义两个注解:

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalCache

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RemoteCache

然后在Module里分别提供:

@Module
@InstallIn(SingletonComponent::class)
object CacheModule {

    @LocalCache
    @Provides
    fun provideLocalCache(): Cache = LocalCacheImpl()

    @RemoteCache
    @Provides
    fun provideRemoteCache(): Cache = RemoteCacheImpl()
}

注入的时候带上限定符:

class Repository @Inject constructor(
    @LocalCache private val localCache: Cache,
    @RemoteCache private val remoteCache: Cache
) { ... }
我曾经犯过的错:限定符注解的@Retention一定要写BINARY或者RUNTIME,默认CLASS会导致Hilt在编译期找不到。排查了半天,最后发现是注解保留策略的问题。

15.3 @Singleton:控制对象生命周期

默认情况下,Hilt每次注入都会创建一个新实例。但有些对象我们希望全局唯一,比如数据库实例、网络客户端。这时候就用@Singleton

用法很简单,在提供方法上加注解:

@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
    return OkHttpClient.Builder()
        .connectTimeout(30, TimeUnit.SECONDS)
        .build()
}

或者直接在实现类上加:

@Singleton
class UserRepository @Inject constructor(
    private val api: ApiService
) { ... }

这里要理解一个关键点:@Singleton的作用域是Component级别的。如果你用的是SingletonComponent,那这个对象在整个应用生命周期内只有一个实例。如果你用的是ActivityComponent,那@Singleton就不适用了,得用@ActivityScoped。

作用域注解 绑定的Component 生命周期
@Singleton SingletonComponent Application
@ActivityScoped ActivityComponent Activity
@FragmentScoped FragmentComponent Fragment
@ViewModelScoped ViewModelComponent ViewModel
核心原则:作用域越大,对象存活越久。别把大作用域的对象注入到小作用域里,否则会造成内存泄漏。比如把@Singleton的Context注入到Activity里没问题,反过来就不行。

15.4 ViewModel注入

ViewModel的注入是Hilt的一大亮点。以前我们要用ViewModelProvider.Factory,写一堆模板代码。现在只需要一个注解。

第一步,让ViewModel用@HiltViewModel注解:

@HiltViewModel
class MainViewModel @Inject constructor(
    private val repository: UserRepository
) : ViewModel() {
    ...
}

第二步,在Activity或Fragment里直接用by viewModels()

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    private val viewModel: MainViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 直接用,不用管Factory
    }
}

你可能会问:如果ViewModel需要SavedStateHandle怎么办?Hilt也考虑到了,直接注入就行:

@HiltViewModel
class DetailViewModel @Inject constructor(
    private val savedStateHandle: SavedStateHandle,
    private val repository: UserRepository
) : ViewModel() { ... }
我建议:所有ViewModel都走Hilt注入。这样不仅省了Factory代码,而且ViewModel的依赖关系一目了然,测试时也容易mock。

15.5 Fragment注入

Fragment的注入和Activity类似,但有个细节要注意——Fragment必须配合宿主Activity一起使用@AndroidEntryPoint。如果Activity没加注解,Fragment的注入会直接报错。

看个完整例子:

@AndroidEntryPoint
class HomeFragment : Fragment() {

    @Inject
    lateinit var analytics: AnalyticsService

    private val viewModel: HomeViewModel by viewModels()

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        // analytics和viewModel都已经注入好了
    }
}

这里有个常见的坑:@Inject lateinit var不能在onAttach之前使用。因为Hilt的注入发生在onAttach之后。如果你在构造函数里访问注入的字段,会拿到null。

15.6 实战:模块化注入

终于到了重头戏。真实项目往往是多模块的,比如app模块、core模块、feature模块。Hilt怎么跨模块注入?

核心思路是:每个模块提供自己的Module,然后通过@InstallIn指定到合适的Component

假设我们有这样的模块结构:

  • core:network — 提供ApiService、OkHttpClient
  • core:database — 提供Room数据库、Dao
  • feature:home — 依赖core模块,提供HomeViewModel
  • app — 主模块,组装所有功能

core:network模块里:

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient { ... }

    @Provides
    @Singleton
    fun provideApiService(client: OkHttpClient): ApiService { ... }
}

feature:home模块里:

@Module
@InstallIn(ViewModelComponent::class)
object HomeModule {

    @Provides
    @ViewModelScoped
    fun provideHomeUiState(): HomeUiState { ... }
}

然后在app模块的Application类上加上@HiltAndroidApp,所有模块的Module就会被自动发现和组装。

这里有个关键点:Hilt的模块发现是编译期的。只要你的模块依赖关系正确,Hilt就能跨模块找到对应的Provider。不需要像Dagger那样手动写Component。

我曾经踩过的坑:如果feature模块依赖了core模块,但core模块的Module没有用@InstallIn声明Component,Hilt会报「无法提供依赖」的错误。排查方法很简单——看编译错误信息,它会告诉你哪个依赖找不到,然后去对应的模块检查@InstallIn。

最后,咱们用一张图来总结Hilt进阶的核心脉络:

Hilt进阶核心知识体系 绑定技巧 @Binds → 接口绑定实现 @Qualifier → 区分同类型 @Provides → 具体对象创建 作用域管理 @Singleton → 全局单例 @ActivityScoped → Activity级 @FragmentScoped → Fragment级 组件注入 @HiltViewModel → ViewModel @AndroidEntryPoint → Fragment by viewModels() → 自动获取 模块化注入架构 core:network → 提供网络依赖 | core:database → 提供数据库依赖 feature:home → 依赖core模块 | app → 组装所有模块 实战要点 编译期发现依赖 → 跨模块自动组装 → 避免内存泄漏

说实话,Hilt的进阶用法并不复杂,但需要你在实际项目中反复练习。我刚开始做模块化注入时,也遇到过依赖找不到、作用域不匹配的问题。但只要你理解了绑定、作用域、组件注入这三个核心概念,再复杂的项目也能理清楚。

嗯,这一章的内容就到这。记住,Hilt不是银弹,但它能帮你把依赖管理的复杂度降到最低。用好它,你的代码会变得更干净、更可测试。


公众号:蓝海资料掘金营,微信deep3321