4、@Provides与@Binds的编译期生成逻辑归纳总结

@Provides 和 @Binds 是 Hilt 模块里最常用的两个注解。很多同学觉得它们只是声明依赖的两种方式,换着用就行了。其实在编译期,Hilt 对它们的处理逻辑完全不同。我踩过几次坑之后,才算真正搞明白背后的区别。

4.1 核心区别:谁在「干活」

一句话概括:@Provides 是手动撸代码,@Binds 是告诉 Hilt 去接口实现类里找。

你想想看,@Provides 修饰的方法里,你亲自写了 new 对象、调构造、甚至做一些初始化逻辑。Hilt 编译期会把这个方法直接变成工厂类的核心代码。而 @Binds 只是声明「接口 A 的实现类是 B」,Hilt 会自己去 B 的 @Inject 构造函数里生成实例。

我在项目中重构一个网络模块时,曾经把 @Binds 误写成 @Provides,结果发现每次注入都走了额外的方法调用,编译出来的工厂类多了一层包装。后来切回 @Binds,代码体积和编译速度都有改善。

编译期产出对比(以 OkHttpClient 为例)

注解编译后工厂类实例化方式
@ProvidesNetworkModule_ProvideOkHttpClientFactory调用 module.provideOkHttpClient()
@Binds无独立工厂,复用实现类的 Factory直接 new OkHttpClient() + 依赖注入

4.2 @Provides 的编译期生成逻辑

我们来看一个典型场景。假设你有一个网络配置类,需要从外部传参:

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

    @Provides
    @Singleton
    fun provideOkHttpClient(
        cache: Cache,
        interceptor: Interceptor
    ): OkHttpClient {
        return OkHttpClient.Builder()
            .cache(cache)
            .addInterceptor(interceptor)
            .build()
    }
}

Hilt 编译期会做这几件事:

  1. 生成 Factory 类:命名为 NetworkModule_ProvideOkHttpClientFactory,里面包含一个静态方法 provideOkHttpClient(...),参数就是方法里的依赖(Cache, Interceptor)。
  2. 添加 @Inject 标记:把这个 Factory 注册到 Hilt 的依赖图中,声明它提供 OkHttpClient 类型。
  3. 插入调用链:当其他组件需要 OkHttpClient 时,Hilt 会调用这个 Factory 的 get() 方法,内部再调用你的 provideOkHttpClient 方法。

我个人的习惯:如果依赖的构造很简单(比如单例配置),我倾向用 @Binds + @Inject 构造函数。但如果需要复杂的 builder 模式、额外校验、或者依赖外部参数,那就老老实实写 @Provides。别为了省代码写出隐患。

4.3 @Binds 的编译期生成逻辑

@Binds 只能修饰抽象方法(abstract fun),而且方法体必须为空。它相当于告诉 Hilt:「这个接口,你用那个实现类来搞定。」

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

    @Binds
    @Singleton
    abstract fun bindApiService(impl: ApiServiceImpl): ApiService
}

Hilt 编译期对 @Binds 的处理逻辑完全不同:

  1. 不生成新的 Factory:因为实现类 ApiServiceImpl 已经有自己的 @Inject 构造函数,Hilt 会直接复用 ApiServiceImpl_Factory。
  2. 建立类型映射:在依赖图中注册一条从 ApiService 到 ApiServiceImpl 的绑定关系。
  3. 运行时无缝替换:当别人注入 ApiService 时,Hilt 查绑定关系,最终拿到 ApiServiceImpl 的实例。

我曾经栽过的坑:@Binds 方法的参数类型必须与返回值类型有继承/实现关系,否则编译直接报错。而且参数不能是抽象类,必须是具体实现类。如果你在 @Binds 方法里写了任何非空的方法体,Hilt 编译期会直接抛出 @Binds methods must have an empty body 错误。

4.4 编译期生成的代码结构对比

为了直观展示,我用一个经典的场景来对比。假设我们需要提供 Retrofit 实例:

  • @Provides 版本:Hilt 会生成类似下面的工厂类(简化版):
    public final class NetworkModule_ProvideRetrofitFactory implements Factory<Retrofit> {
        @Override
        public Retrofit get() {
            return NetworkModule.INSTANCE.provideRetrofit(baseUrl, okHttpClient);
        }
    }
  • @Binds 版本:假设你定义 RetrofitProvider 接口,然后写一个实现类:
    class RetrofitProviderImpl @Inject constructor(
        private val baseUrl: String,
        private val okHttpClient: OkHttpClient
    ) : RetrofitProvider {
        override fun get(): Retrofit { ... }
    }
    
    @Module
    abstract class BindModule {
        @Binds abstract fun bindRetrofitProvider(impl: RetrofitProviderImpl): RetrofitProvider
    }
    此时 Hilt 只生成 RetrofitProviderImpl_Factory,而 @Binds 方法本身进依赖图里只是做映射,没有额外 Factory。

你发现没?@Provides 生成的 Factory 里直接调了你的模块方法,而 @Binds 完全绕过了模块方法,靠实现类的 Factory 走通。这就是编译期生成逻辑的核心差异。

一张图总结 @Provides vs @Binds 的编译期流程

@Provides vs @Binds 编译期生成逻辑 @Provides 路径 Module 内 @Provides 方法 APT 扫描到 @Provides 生成 xxx_ProvideXxx_Factory Factory.get() 调用 module 方法 注入到 @Singleton 组件 @Binds 路径 Module 内 @Binds 抽象方法 APT 解析 @Binds 绑定关系 不生成新 Factory,复用实现类的 在依赖图中注册接口→实现类映射 注入时查映射,直接拿实现类实例

4.5 实战选择建议

聊了这么多编译期细节,最终你得知道怎么选。我根据自己的经验整理了一个决策表:

场景推荐注解原因
依赖需要复杂构造(Builder、校验、配置)@Provides方法体可以写任意逻辑
接口只有一个实现类,且构造简单@Binds编译更轻量,无额外 Factory
需要注入第三方库的对象(如 OkHttpClient)@Provides你无法修改第三方类的 @Inject 构造函数
项目中使用接口编程,实现类经常切换@Binds容易替换绑定,只需要改 Module 里的抽象方法参数
依赖有多个构造参数,且部分来自其他模块@Provides参数自动由 Hilt 传入,方法体内可灵活处理

嗯,这里要注意:如果你的 @Binds 方法的参数类型写了父类(而不是具体实现类),编译虽然能过,但 Hilt 会报 Cannot find a binding for ParentType 之类的错误。因为你没有告诉它具体用哪个子类。我遇到过几次这种问题,后来养成习惯,@Binds 的参数一律写具体实现类。

我曾经踩的另一个坑:在同一个 Module 里同时放 @Provides 和 @Binds 方法,结果 Hilt 说类型冲突。后来查了文档才知道,同一个 Module 不要混合提供相同类型的绑定。如果你必须同时存在,考虑拆分成两个 Module,或者用 @Qualifier 区分。不然编译期会报 DuplicateBindings 错误。

最后总结一下:编译期生成逻辑决定了你代码的灵活性和体积。我个人建议,默认优先用 @Binds + @Inject 构造函数,只有遇到第三方库或复杂初始化时才切到 @Provides。这样你的模块代码更干净,Hilt 编译出来的 Factory 也更少。下次你在 Gradle 构建日志里看到几百个 Factory 类时,就知道怎么精简了。