4、组件树与作用域:Hilt组件的生命周期管理及其对内存和性能的影响

Hilt的组件模型,说白了就是一套跟Android组件生命周期绑定的作用域体系。我刚开始学的时候也觉得就是几个注解而已,但后来在线上遇到过OOM,追根溯源才发现是作用域用错了——组件没及时释放,对象一直攥着不撒手。嗯,这节课我们重点聊聊这张组件树到底怎么长,以及它怎么影响你的内存和性能。

4.1 组件树长什么样?

Hilt官方把组件关系画成一张层次结构。我习惯叫它“组件树”。每个组件都对应一个Android组件的生命周期范围,而且子组件可以访问父组件的绑定,反之不行。

下面这张表列出最常用的几个组件,以及它们的作用域和生命周期:

组件作用域注解生命周期起点生命周期终点
SingletonComponent@SingletonApplication.onCreate()Application.onDestroy()
ActivityRetainedComponent@ActivityRetainedScopedActivity.onCreate()Activity.onDestroy()(Configuration变化时不销毁)
ActivityComponent@ActivityScopedActivity.onCreate()Activity.onDestroy()(每次重建都重新创建)
FragmentComponent@FragmentScopedFragment.onAttach()Fragment.onDestroy()
ViewModelComponent@HiltViewModel(自带作用域)ViewModel被创建时ViewModel.onCleared()
ServiceComponent@ServiceScopedService.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。这就是典型的“大作用域滥用”。

下面这张图帮你直观理解组件树层级与内存关系:

Hilt组件树与生命周期 说明:箭头方向表示“子组件→父组件”,子组件可以获取父组件的绑定。 SingletonComponent 生命周期:整个App进程 ActivityRetainedComponent 存活于Activity(配置变化不销毁) ActivityComponent 每次Activity重建都重新创建 FragmentComponent 生命周期:Fragment onAttach → onDestroy ViewModelComponent 生命周期:ViewModel被创建 → onCleared ServiceComponent 生命周期:Service onCreate → onDestroy

4.3.1 性能隐形杀手:作用域间依赖查找

每次注入时,Hilt都得沿着组件树往上找。你想想看,如果每次都从SingletonComponent查到底层,寻路开销虽然不大,但高频场景下(比如列表滑动的每个item注入)会影响帧率。我建议:

  • 优先使用最精确的作用域,比如Fragment内用的就标@FragmentScoped。
  • 不要为了省事把所有东西都堆到@Singleton里。

4.4 避坑指南:组件泄漏的几种典型场景

这里的“泄漏”不一定是内存泄漏,更多是指对象生命周期被不必要地延长了。下面几个是我排查时经常发现的模式:

  1. Activity Retained组件滥用:把图片Loader、定位管理器等标成@ActivityRetainedScoped,但Activity内部有个Fragment只访问一次,结果这个对象跟着Activity一直活到销毁。
  2. ViewModel作用域误判:ViewModelComponent的实例在ViewModel被清理前一直存活。我曾经在一个多Fragment的页面里,把每个Fragment共享的数据缓存放在主Fragment的ViewModel里,但子Fragment注入了@ActivityRetainedScoped的对象,那个对象持有了ViewModel的引用——导致ViewModel泄漏。
  3. 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无关的数据预处理、计算@HiltViewModelViewModel自带作用域,配合SavedStateHandle省心

最后送你一句话:作用域选得越小,你半夜被线上报警吵醒的概率就越低。 这不是玩笑——我上周刚帮一个团队排查过线上OOM,最后的原因就是一个@Singleton的列表缓存没有被清理。嗯,先说到这里,下次你再遇到内存异常,不妨先从组件作用域入手看看。