5. Hilt限定符:@Qualifier自定义限定符、@Named注解的使用场景
依赖注入里有个经典问题:同一个接口有多个实现,容器怎么知道你要哪个?
举个例子。你有一个网络请求接口,线上用OkHttp,测试用Mock数据。或者你有两个数据库,一个存用户信息,一个存缓存数据。这时候如果直接注入,Dagger或Hilt会懵掉——它不知道你要哪个。
嗯,这就是限定符(Qualifier)要解决的问题。
5.1 为什么需要限定符?
先看一个让我头疼过的场景。
我曾经维护过一个老项目,里面有个 ApiService 接口,线上和测试环境用了不同的实现。之前的同事怎么处理的?他搞了个 Environment 枚举,然后在每个调用处写 if-else 判断。代码里到处都是这种判断,看着就烦。
用依赖注入的话,你可能会这么写:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideMockClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.build()
}
}
问题来了。两个方法都返回 OkHttpClient,Hilt 编译时会直接报错:
error: [Dagger/DuplicateBindings] OkHttpClient is bound multiple times
这就是典型的「类型歧义」。Hilt 说:你给了两个一样的类型,我选不了。
解决方案就是加限定符。
5.2 @Qualifier 自定义限定符
Hilt 提供了 @Qualifier 注解,用来给同一个类型打上不同的标签。
我个人习惯先定义两个注解:
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class ProdOkHttp
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class MockOkHttp
然后在提供依赖的地方加上限定符:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@ProdOkHttp
@Provides
@Singleton
fun provideProdOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.build()
}
@MockOkHttp
@Provides
@Singleton
fun provideMockOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.build()
}
}
注入的时候也加上对应的限定符:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
@ProdOkHttp
lateinit var okHttpClient: OkHttpClient
// 或者
@Inject
@MockOkHttp
lateinit var mockClient: OkHttpClient
}
这样 Hilt 就知道你要哪个了。说白了,限定符就是给类型加了个「别名」。
核心要点:@Qualifier 注解必须配合 @Retention(AnnotationRetention.BINARY) 使用。这是 Dagger/Hilt 的要求,不加的话编译期可能拿不到注解信息。
5.3 @Named 注解的使用场景
除了自定义限定符,Hilt 还内置了一个 @Named 注解。它本质上也是 @Qualifier,只不过用字符串来区分。
比如上面的例子,用 @Named 可以写成:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Named("prod")
@Provides
@Singleton
fun provideProdOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.build()
}
@Named("mock")
@Provides
@Singleton
fun provideMockOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(5, TimeUnit.SECONDS)
.build()
}
}
注入时:
@Inject
@Named("prod")
lateinit var okHttpClient: OkHttpClient
看起来更简洁对吧?但我个人不太推荐大量使用 @Named。为什么?
- 字符串容易写错。你写
"prod",我写"prd",编译不报错,运行才炸。 - 没有类型安全。自定义注解是强类型的,IDE 能帮你检查。
- 重构困难。字符串改名要全局搜索替换,注解改起来方便多了。
那什么时候用 @Named?我个人觉得,简单场景、快速原型、或者团队约定好的少量字符串,可以用。大型项目还是老老实实自定义注解。
我的建议:如果只有两三个限定场景,用 @Named 省事。如果超过五个,或者要跨模块共享,请务必自定义 @Qualifier。我在项目中吃过字符串拼写错误的亏,排查了整整一下午。
5.4 实战:多数据源场景
讲个真实案例。我之前做一个新闻客户端,需要同时接入两个数据源:
- 远程数据源:从服务器拉取新闻列表
- 本地数据源:从 Room 数据库读取缓存
两个数据源都实现了同一个 NewsDataSource 接口:
interface NewsDataSource {
suspend fun getNews(): List<News>
}
class RemoteNewsDataSource @Inject constructor(
private val api: NewsApi
) : NewsDataSource {
override suspend fun getNews(): List<News> {
return api.fetchNews()
}
}
class LocalNewsDataSource @Inject constructor(
private val dao: NewsDao
) : NewsDataSource {
override suspend fun getNews(): List<News> {
return dao.getAllNews()
}
}
现在问题来了:ViewModel 里需要注入这两个数据源,但类型都是 NewsDataSource。
解决方案就是自定义限定符:
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Remote
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class Local
@Module
@InstallIn(SingletonComponent::class)
object DataSourceModule {
@Remote
@Provides
@Singleton
fun provideRemoteDataSource(impl: RemoteNewsDataSource): NewsDataSource {
return impl
}
@Local
@Provides
@Singleton
fun provideLocalDataSource(impl: LocalNewsDataSource): NewsDataSource {
return impl
}
}
ViewModel 里这样注入:
@HiltViewModel
class NewsViewModel @Inject constructor(
@Remote private val remoteDataSource: NewsDataSource,
@Local private val localDataSource: NewsDataSource
) : ViewModel() {
fun loadNews() {
viewModelScope.launch {
// 先读缓存
val cached = localDataSource.getNews()
// 再拉远程
val fresh = remoteDataSource.getNews()
}
}
}
你看,代码清晰多了。每个数据源的职责一目了然,而且完全由 Hilt 管理生命周期。
5.5 限定符与 @Binds 的结合
上面用的是 @Provides,其实 @Binds 也能配合限定符。我个人更推荐 @Binds,因为它更简洁,而且不产生额外对象。
@Module
@InstallIn(SingletonComponent::class)
abstract class DataSourceModule {
@Remote
@Binds
@Singleton
abstract fun bindRemoteDataSource(impl: RemoteNewsDataSource): NewsDataSource
@Local
@Binds
@Singleton
abstract fun bindLocalDataSource(impl: LocalNewsDataSource): NewsDataSource
}
效果完全一样,但代码量更少。注意 @Binds 只能用在抽象方法上,而且参数类型必须和返回类型有继承关系。
注意:@Binds 方法不能和 @Provides 方法混在同一个 Module 里。如果你既有 @Binds 又有 @Provides,请拆成两个 Module,或者把 @Provides 方法改成静态的。
5.6 限定符的命名规范
最后聊个细节:限定符怎么命名?
我在团队里推行过一套规范,分享给你:
| 场景 | 推荐命名 | 示例 |
|---|---|---|
| 环境区分 | Prod / Mock / Debug | @ProdOkHttp, @MockApi |
| 数据源区分 | Remote / Local / Memory | @RemoteDataSource, @LocalPrefs |
| 版本区分 | V1 / V2 / Legacy | @V1Api, @LegacyRepo |
| 功能区分 | Main / Background / Cache | @MainDispatcher, @CachePool |
命名要见名知意。别搞个 @A、@B 这种,三个月后你自己都看不懂。
5.7 本章小结
限定符是 Hilt 依赖注入里绕不开的知识点。说白了,它就是给类型打标签,解决「多个实现选哪个」的问题。
记住三个要点:
- 自定义
@Qualifier注解,强类型、安全、可重构 @Named用字符串区分,简单场景可用,复杂项目慎用- 配合
@Binds使用,代码更简洁
我在项目中见过太多因为限定符用错导致的运行时崩溃。嗯,花五分钟设计好限定符,能省下未来五小时的排查时间。
一句话总结:限定符不是锦上添花,而是多实现场景下的必需品。别偷懒,该定义就定义。