4、组件树与作用域:Hilt组件的生命周期管理及其对内存和性能的影响
Hilt的组件模型,说白了就是一套跟Android组件生命周期绑定的作用域体系。我刚开始学的时候也觉得就是几个注解而已,但后来在线上遇到过OOM,追根溯源才发现是作用域用错了——组件没及时释放,对象一直攥着不撒手。嗯,这节课我们重点聊聊这张组件树到底怎么长,以及它怎么影响你的内存和性能。
4.1 组件树长什么样?
Hilt官方把组件关系画成一张层次结构。我习惯叫它“组件树”。每个组件都对应一个Android组件的生命周期范围,而且子组件可以访问父组件的绑定,反之不行。
下面这张表列出最常用的几个组件,以及它们的作用域和生命周期:
| 组件 | 作用域注解 | 生命周期起点 | 生命周期终点 |
|---|---|---|---|
| SingletonComponent | @Singleton | Application.onCreate() | Application.onDestroy() |
| ActivityRetainedComponent | @ActivityRetainedScoped | Activity.onCreate() | Activity.onDestroy()(Configuration变化时不销毁) |
| ActivityComponent | @ActivityScoped | Activity.onCreate() | Activity.onDestroy()(每次重建都重新创建) |
| FragmentComponent | @FragmentScoped | Fragment.onAttach() | Fragment.onDestroy() |
| ViewModelComponent | @HiltViewModel(自带作用域) | ViewModel被创建时 | ViewModel.onCleared() |
| ServiceComponent | @ServiceScoped | Service.onCreate() | Service.onDestroy() |
注意ActivityRetainedComponent和ActivityComponent的区别:前者在配置变化(比如旋转屏幕)时存活,后者会被销毁重建。我踩过这方面的坑,之前把一个大的Bitmap缓存标成@ActivityScoped,每次旋转屏幕都重新加载,用户反馈卡顿。后来改成@ActivityRetainedScoped,问题就解决了。
4.2 作用域绑定:代码里怎么体现?
我们来看一个简单的例子——给一个Repository配上@Singleton,让它跟着Application走:
@Singleton
class UserRepository @Inject constructor(
private val api: UserApi
) {
// ...
}
如果你在Activity里注入这个UserRepository,它只会在SingletonComponent里创建一次。但如果你在同一个Activity里同时注入了@ActivityScoped的SomePresenter:
@ActivityScoped
class SomePresenter @Inject constructor(
private val repo: UserRepository
) {
// ...
}
SomePresenter会在ActivityComponent里实例化,但它持有的UserRepository还是那个单例。这完全没问题,因为子组件的依赖允许继承父组件的绑定。
但是反过来——如果你在SingletonComponent里尝试注入一个@ActivityScoped的对象,编译器就会报错。因为父组件看不到子组件的绑定。这个规则能防止你把一个本该随Activity销毁的对象泄漏到Application全局,我觉得设计得挺聪明的。
4.3 对内存和性能的影响
作用域越大,占用的内存时间越长。这不是哲学问题,是实实在在的隐患。我碰到过一个真实案例:团队同事把一个本该作用在Fragment上的数据缓存标成了@Singleton,结果每次进入页面都往缓存里塞东西,用户翻了几十个页面后应用占用内存超过500MB。这就是典型的“大作用域滥用”。
下面这张图帮你直观理解组件树层级与内存关系:
4.3.1 性能隐形杀手:作用域间依赖查找
每次注入时,Hilt都得沿着组件树往上找。你想想看,如果每次都从SingletonComponent查到底层,寻路开销虽然不大,但高频场景下(比如列表滑动的每个item注入)会影响帧率。我建议:
- 优先使用最精确的作用域,比如Fragment内用的就标
@FragmentScoped。 - 不要为了省事把所有东西都堆到
@Singleton里。
4.4 避坑指南:组件泄漏的几种典型场景
这里的“泄漏”不一定是内存泄漏,更多是指对象生命周期被不必要地延长了。下面几个是我排查时经常发现的模式:
- Activity Retained组件滥用:把图片Loader、定位管理器等标成
@ActivityRetainedScoped,但Activity内部有个Fragment只访问一次,结果这个对象跟着Activity一直活到销毁。 - ViewModel作用域误判:ViewModelComponent的实例在ViewModel被清理前一直存活。我曾经在一个多Fragment的页面里,把每个Fragment共享的数据缓存放在主Fragment的ViewModel里,但子Fragment注入了@ActivityRetainedScoped的对象,那个对象持有了ViewModel的引用——导致ViewModel泄漏。
- Singleton缓存不清理:这个最常见。我在项目里排查过一个消息推送SDK,它的连接池被标成@Singleton,每次Activity拿去用完后没有释放连接,结果App挂一会就爆了。
我自己的排查套路:在Android Studio的Profiler里抓内存快照,然后看Hilt生成的Component类实例数量。比如DaggerSingletonComponent应该只有一个,DaggerActivityComponent应该等于启动过的Activity个数(在旋转时可能多于1个,但销毁后应该被GC回收)。如果数量不对,十有八九是作用域写岔了。
4.5 最佳实践总结
| 场景 | 推荐作用域 | 理由 |
|---|---|---|
| 网络客户端、数据库实例 | @Singleton | 整个App共享一个连接池/实例,节省资源 |
| 用户登录状态、导航控制器 | @ActivityRetainedScoped | 配置变化不销毁,状态持续 |
| 当前界面UI控制器、Dialog相关 | @ActivityScoped | 随Activity重建,简洁明了 |
| Fragment内持有的Presenter、PagerAdapter | @FragmentScoped | 最精确的生命周期,避免泄漏到Fragment之外 |
| 与UI无关的数据预处理、计算 | @HiltViewModel | ViewModel自带作用域,配合SavedStateHandle省心 |
最后送你一句话:作用域选得越小,你半夜被线上报警吵醒的概率就越低。 这不是玩笑——我上周刚帮一个团队排查过线上OOM,最后的原因就是一个@Singleton的列表缓存没有被清理。嗯,先说到这里,下次你再遇到内存异常,不妨先从组件作用域入手看看。