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 使用,代码更简洁

我在项目中见过太多因为限定符用错导致的运行时崩溃。嗯,花五分钟设计好限定符,能省下未来五小时的排查时间。

一句话总结:限定符不是锦上添花,而是多实现场景下的必需品。别偷懒,该定义就定义。

Hilt 限定符核心知识体系 @Qualifier 限定符 为什么需要? • 同一接口多个实现 • 类型歧义,编译报错 • 需要给类型打标签 两种实现方式 自定义 @Qualifier ✓ 强类型安全 ✓ IDE 可检查 ✓ 重构友好 @Named 字符串 ✗ 字符串易写错 ✗ 无类型检查 ✗ 重构困难 典型使用场景 • 多环境(Prod/Mock) • 多数据源(Remote/Local) • 多版本(V1/V2) • 多功能(Main/Background) 最佳实践 配合 @Binds 使用 · 命名见名知意 · 大型项目用自定义注解
公众号:蓝海资料掘金营,微信deep3321