第3章:Hilt组件与作用域 —— @Singleton、@ActivityScoped等归纳总结

说实话,刚接触Hilt那会儿,我最头疼的就是这一坨注解。@Singleton、@ActivityScoped、@FragmentScoped……看得人眼花缭乱。到底什么时候该用哪个?用错了会怎样?

嗯,要搞懂这个,先得明白一个道理:作用域控制的是对象的生命周期。

说白了,你告诉Hilt:“这个对象我要用多久?是和App共存亡,还是Activity关闭时它就该消失?”

我的习惯:先想清楚业务场景,再选作用域。反过来搞,容易写出“幽灵对象”——应该销毁的没销毁,白白占用内存。

3.1 为什么需要作用域?

想象一下,你在Activity里注入了一个网络服务对象。如果不做任何限制,每次进入该Activity,Hilt都会创建新的实例。来回进出几次,内存里就躺着好几个相同的网络服务了。

我在项目中遇到过这种情况。一个新闻列表页,用户频繁滑动进入详情再返回,结果内存里攒了十几个ViewModel实例。界面倒是没崩,但滑动明显卡顿了。一查,就是作用域没设对。

作用域的作用就是:绑定额外的生命周期,让Hilt知道什么时候该重用对象,什么时候该清理。

3.2 Hilt的标准作用域注解

Hilt内置了几种标准作用域,每个都对应一个Android组件的生命周期。我们来看一下:

注解 绑定的生命周期 单例范围 典型使用场景
@Singleton Application 整个App全局 网络客户端、数据库、SharedPreferences
@ActivityRetainedScoped Activity(含配置变更) Activity存活期间 ViewModel、配置不变时需保留的对象
@ActivityScoped Activity Activity创建到销毁 与Activity绑定的业务逻辑、UI控制器
@FragmentScoped Fragment Fragment创建到销毁 Fragment内使用的特殊依赖
@ViewScoped View View存活期间 自定义View内的临时依赖
@ServiceScoped Service Service创建到销毁 后台服务内使用的依赖
重要提醒:作用域必须与组件层级匹配。比如@ActivityScoped只能在ActivityComponent内使用,你不能在Fragment里声明一个@ActivityScoped的注入点——编译时就报错了。

3.3 具体注解详解

3.3.1 @Singleton —— 全局单例

这是作用域里“老大”,绑定在Application的生命周期上。一个App里,用@Singleton标记的依赖只会生成一个实例,直到进程结束。

@Module
@InstallIn(SingletonComponent::class)
object NetModule {
    
    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .build()
    }
}

换个角度理解:你写的每个网络请求都经过同一个OkHttpClient,连接池、缓存都能共用。要是每个Activity都new一个,那连接就不互通了,性能也会下降。

注意:@Singleton虽然方便,别滥用。不是所有对象都适合“活”整个App周期。我曾经把一个用户登录信息标记为@Singleton,结果用户登出后,其他地方拿到的还是旧数据——这不就出问题了嘛。

3.3.2 @ActivityRetainedScoped —— 配置变更不死

这个是Hilt为了方便ViewModel保留而设计的。屏幕旋转时Activity会重建,但@ActivityRetainedScoped的对象不会跟着销毁——它们被保留在新创建的Activity里。

@HiltViewModel
class MainViewModel @Inject constructor(
    private val userRepo: UserRepository,
    @ActivityRetainedScoped private val analyticsTracker: AnalyticsTracker
) : ViewModel() {
    // analyticsTracker会在旋转后继续工作
    // 不需要重新创建
}

你的场景:比如正在上传一个大文件的进度追踪器,屏幕转了一下就丢了进度,你想想用户得多崩溃。

3.3.3 @ActivityScoped —— 与Activity共存亡

和Activity生命周期严格绑定。Activity创建时生成实例,Activity销毁时实例跟着销毁。如果你在Activity里使用@ActivityScoped,它会在这个Activity的所有注入点中复用。

@ActivityScoped
class MusicPlayerManager @Inject constructor(
    private val audioFocusHelper: AudioFocusHelper
) {
    // Activity内音乐播放的管理器
    // 每次Activity重建都会new一个
}

为什么不用@Singleton?因为音乐播放器有时只需要在当前页面工作,切到其他App页面时,跟当前Activity相关的播放状态就可以释放掉了。

小心:如果你在Activity A和Activity B中同时使用同一个@ActivityScoped对象,它们彼此不共享实例。每个Activity都有自己的副本。

3.3.4 @FragmentScoped —— 更精细的控制

适用于Fragment内部的临时依赖。比如一个只在FragmentA里使用的数据加载器,其他Fragment都不需要。

@FragmentScoped
class ImagePreloader @Inject constructor(
    private val imageLoader: ImageLoader
) {
    // 只在当前Fragment中预加载图片
    // Fragment销毁后自动释放
}

经验之谈:我个人通常只在Fragment需要持有一些只在当前页面有用的对象时才用@FragmentScoped。大部分时候,把依赖提到Activity层就够用了。

3.4 作用域与组件层级的关系

别以为选了注解就完事了。你还得注意组件层级——注解要和对应的组件配套使用。

Hilt的组件层级是从上到下,逐层包含的:

  • SingletonComponent(Application级别)—— 最顶层,所有组件都能用
  • ActivityRetainedComponent —— 位于Activity的上一层
  • ActivityComponent —— Activity级别
  • FragmentComponent —— Fragment级别
  • ViewComponent —— View级别
  • ViewWithFragmentComponent —— 在Fragment内的View
  • ServiceComponent —— Service级别

你想想看,每个上层组件都能访问下层组件的依赖吗?不行。下层组件可以访问上层组件的依赖,但上层组件不能访问下层组件的依赖——因为上层组件在生命周期上更早创建,下层组件可能还没创建呢。

// 正确:ActivityComponent可以访问Singleton提供的依赖
@ActivityScoped
class ActivityDataFetcher @Inject constructor(
    private val apiClient: ApiClient  // 来自SingletonComponent
)

// 错误:SingletonComponent不能访问ActivityScoped的依赖
// @Singleton
// class GlobalManager @Inject constructor(
//     private val fetcher: ActivityDataFetcher  // ❌ 编译报错
// )

3.5 作用域使用的思维导图

我把自己日常判断作用域的逻辑画成一张图。照着它走,一般不出错:

Hilt作用域选择决策树 依赖需要长存吗? 全局使用 @Singleton 依组件绑定 配置变更后保留? @ActivityRetainedScoped Activity/Fragment层级? Fragment内用?→ @FragmentScoped 否则:@ActivityScoped 提示 不确定时,先从最小的 作用域开始,慢慢扩展
重要提示:选错作用域在编译时不会报错(除了组件不匹配的情况),但运行时可能造成内存泄漏或逻辑错误。我见过一个案例:把Activity的业务逻辑标记成@Singleton,结果切换用户后,页面还在展示旧用户信息。排查了半天才发现是作用域问题。

3.6 作用域常见陷阱总结

  • 陷阱1:在@Singleton的对象里持有Activity的Context。这会导致Activity无法被回收。
  • 陷阱2:Fragment里用@ActivityScoped,Fragment之间虽然共享实例,但如果在FragmentA修改了状态,FragmentB会受到影响——有时候这正是你想要的,但有时候不是。
  • 陷阱3:过度使用@Singleton,导致对象不易清理。记住:Singleton是“永久”,不是“方便”。

好,总结一下。作用域这一块,其实没那么玄乎。核心就是:想清楚你的对象该活多久,然后对上号。和组件层级匹配好,Hilt自然会帮你管好生命周期。

还有一点我的小经验:建议先从无作用域开始(即每次创建新实例),跑通了再逐步加上作用域优化。一上来就加@Singleton,容易不小心埋下坑。