4. 模块与组件:Hilt 的骨架和血脉
说实话,刚接触 Hilt 那会儿,我最头疼的就是搞不清 Module 和 Component 到底谁管谁。你说 Module 是提供依赖的,Component 是管理生命周期的,但实际项目里它俩纠缠在一起,稍不留神就写出个性能黑洞。今天咱就把这事彻底捋清楚。
4.1 Hilt 模块(Module)的本质
Module 说白了就是个「依赖工厂」。你告诉 Hilt:「嘿,这个接口我来实现,那个对象我要单例」。但它不是随便写个类加个注解就完事的——你得选对类型。
| Module 类型 | 注解 | 典型场景 |
|---|---|---|
| 抽象 Module | @Module + @Binds | 接口绑定实现类 |
| 具象 Module | @Module + @Provides | 第三方库对象(Retrofit、OkHttp) |
| 安装型 Module | @InstallIn | 指定该 Module 在哪个 Component 里生效 |
我早期犯过一个低级错误——把 @Provides 方法写成静态的,以为能省点内存。结果 Hilt 在编译期直接报错,说「静态方法只能用在 Kotlin object 里」。后来我查了源码才明白:Hilt 的代码生成器对非静态方法做了解析优化,静态方法反而破坏了一些内联机制。记住:能用 @Binds 就别用 @Provides,前者生成的字节码更轻量。
4.2 组件(Component)的作用域链
Hilt 的 Component 不是单独存在的,它是个严格的层级结构。从 Application 到 Activity 再到 View,每层 Component 都继承自上一级。这就像公司组织架构:总公司(SingletonComponent)→ 事业部(ActivityComponent)→ 项目组(FragmentComponent)。
@Scope 注解决定了对象的生命周期。用 @Singleton 标注的对象,在整个应用里只有一个;用 @ActivityScoped 的,跟 Activity 同生共死。
为什么会这样?Hilt 在编译期会为每个 Component 生成一个入口类(比如 MyApp_HiltComponents),里面维护了一个对象容器。当你第一次请求某个作用域对象时,Component 会实例化它,然后缓存起来。Activity 销毁时,对应的 Component 也会被 GC 回收——前提是你不小心把 Activity 的引用传给了 Singleton 对象。
@Singleton 的 UserManager,里面持有 Activity 的 Context。结果 Activity 退出了,UserManager 还抱着 Context 不放,直接内存泄漏。解决办法:用 @ActivityScoped 的 Context 替换,或者在 UserManager 里用 WeakReference。
4.3 模块与组件的匹配法则
写 Module 时,@InstallIn 一定要选对。选小了,依赖在高层级拿不到;选大了,对象生命周期被拉长,浪费内存。我一般按这个原则来:
- 网络库、数据库:@InstallIn(SingletonComponent::class) —— 整个应用共享一份。
- 用户会话、登录状态:@ActivityScoped + @InstallIn(ActivityComponent::class) —— Activity 独享。
- ViewModel:不用 @InstallIn,Hilt 自动处理。
- UI 相关的工具:FragmentScoped —— 别再大了,不然 Fragment 回收了对象还活着。
你想想看,如果把一个 Activity 级别的对象装进 SingletonComponent,那它就会一直活到应用退出。Activity 退出了几十次,那对象还在,这不纯属浪费吗?
4.4 组件层次结构(SVG 图)
下面这张图我让你看明白 Hilt 的组件树。每个节点代表一个 Component,箭头表示「可访问上级的依赖」——但上级不能访问下级的。
记住一个原则:依赖查找是「向上」的。比如在 FragmentComponent 里你请求 @Singleton 的 AppConfig,Hilt 会沿着 FragmentComponent → ActivityComponent → ActivityRetainedComponent → SingletonComponent 一路找上去。反过来不行——你不能在 Application 里注入一个 @ActivityScoped 的对象,编译期直接报错。这个机制保证了生命周期安全。
4.5 性能敏感点:Module 的安装开销
很多人觉得 Module 就是几个注解,没性能影响。其实不是。每多一个 @Module,Hilt 就会多生成一个组件入口类的内部方法。假设你有 50 个 Module,每个 Module 有 5 个 @Provides 方法,那组件初始化的时候就要执行 250 次方法调用。虽然单次忽略不计,但在冷启动时这些调用会累加到主线程上。
我做过一次测试:在低端机(4 核 2GB 内存)上,50 个 Module 的 App 冷启动比 10 个 Module 的慢了 80ms。你看,就是这 80ms,用户可能就多看到了一个白屏。
- 把多个小 Module 合并成一个大的,比如所有网络相关的绑定放在
NetworkModule里。 - 用
@Binds代替@Provides的无参构造,减少方法调用。 - 如果你确定某个依赖只会在特定的 Activity 里用到,不要把它装在 SingletonComponent 里,降级到 ActivityComponent。
4.6 实战:一个典型 Module 的定义
直接看代码吧。假设我需要一个 Retrofit 实例,绑定到一个 NetworkApi 接口:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideUserApi(retrofit: Retrofit): UserApi {
return retrofit.create(UserApi::class.java)
}
}
注意到没有?object 关键字让 Module 变成了单例,也满足了 Hilt 对静态方法的要求。@InstallIn(SingletonComponent::class) 意味着这些依赖在整个应用里只初始化一次。如果你的 UserApi 只在登录页面用到,那就应该改到 @InstallIn(ActivityComponent::class) 里,配合 @ActivityScoped。
Context 直接放进 @Singleton 的 Module 里提供。除非你指定 @ApplicationContext(Hilt 内置的 Qualifier)。否则你拿到的是 Activity Context,这会导致内存泄漏。我修复过一个线上问题,就是同事在 Singleton 的 Module 里意外传入了 Fragment 的 Context。
4.7 总结一张表
我把本章核心点整理成表格,方便你对照记忆:
| 概念 | 作用 | 性能影响 | 我的建议 |
|---|---|---|---|
| @Module | 提供依赖实例 | 每个 Module 增加编译期生成代码量 | 合并同类 Module,减少数量 |
| @InstallIn | 绑定到指定 Component | 层级越高对象生命周期越长,内存越大 | 按需选择,不滥用 Singleton |
| @Scope | 约束对象实例数 | 单例缓存减少创建,但可能持有大对象 | 合理使用,避免大对象常驻 |
| Component 层级 | 管理依赖的搜索范围 | 搜索链越长,首次注入耗时略增 | 将高频依赖放在靠近使用处的 Component |
| @Binds vs @Provides | 绑定接口 vs 提供对象 | @Binds 字节码更轻 | 优先用 @Binds |
嗯,这一章的信息量不小。你只要把 Module 和 Component 的关系理清楚,再记住「向上查找、向下限界」的原则,Hilt 的绝大多数坑就能提前避开。下回你写一个新 Module,可以想想:这个依赖谁用?活多久?放在哪个层级最合适?