4. 作用域错误引发的空指针崩溃

谈到依赖注入,作用域是个绕不开的坎儿。我最早接手一个组件化项目时,心想不就是加几个注解嘛,结果线上崩溃率直接飙升。调查下来,八成都是作用域没配好,导致空指针。今天我就把最典型的几种踩坑场景掰开揉碎讲清楚。

4.1 作用域到底是什么?

说白了,作用域就是告诉 DI 框架:“这个对象该活多久”。

  • @Singleton —— 整个应用进程只有一个实例。进程在,它就在。
  • @ActivityScoped ——Activity 生命周期内唯一。Activity 销毁它就跟着没了。
  • @FragmentScoped —— 跟 Fragment 的生命周期绑定。
  • @ViewModelScoped —— 配合 ViewModel 使用,挡住配置变更。

你想想看,如果我把一个 @ActivityScoped 的对象注入了 @Singleton 的类里,会发生什么?Activity 一关,那个对象就被释放了,但单例还在引用着它。这时候再调用它——砰,空指针。

⚠️ 经典误区: 很多人以为 @Singleton 加上了就万事大吉。实际上,它在 Dagger 里只是一个作用域标记,你还要确保组件之间的继承关系正确。

4.2 崩溃复现:Singleton 持有 ActivityScoped 对象

来看一段我亲自修过的代码:

// 错误写法
@Singleton
public class GlobalManager {
    @Inject
    UserSession userSession;  // 假设 UserSession 是 @ActivityScoped
}

// Activity 里
@ActivityScoped
public class UserSession {
    public String getToken() { return ; }
}

这个 GlobalManager 是单例,UserSession 绑定 Activity 生命周期。当 Activity 被 finish 后,userSession 变为 null。下次任何地方调用 GlobalManager.getUserSession(),直接空指针。

正确的做法是:不要让短生命周期的对象被长生命周期的对象直接持有。 要么改用 @Reusable,要么通过 Provider<UserSession> 懒加载并在调用时判断 null。

💡 我的习惯: 在单例里我几乎不用 @Inject 注入回调或 UI 层对象。需要时,通过 Lazy 或 Provider 拿,并做非空校验。

4.3 另一个坑:ActivityScoped 组件里注入 Singleton 子组件

这种情况更隐蔽。我们经常把网络层、数据库层做成单例,然后在 Activity 组件里直接依赖它们。看起来没问题——单例不会死。但如果你不小心在子组件里又创建了一个新的单例实例,Dagger 会报编译错误。可一旦你用 @Subcomponent 并且没加 @Singleton 限制,就可能在运行时拿到不同的实例。

举个例子:

// AppComponent 是 @Singleton
@Singleton
@Component(modules = {NetworkModule.class})
public interface AppComponent {
    void inject(App app);
}

// ActivityComponent 是 @ActivityScoped
@ActivityScoped
@Subcomponent(modules = {ActivityModule.class})
public interface ActivityComponent {
    void inject(MainActivity activity);
}

// 错误:ActivityModule 里提供了 @Singleton 的 OkHttpClient
@Module
public class ActivityModule {
    @Provides
    @Singleton
    OkHttpClient provideOkHttp() {  // 这里 @Singleton 与本组件 @ActivityScoped 冲突
        return new OkHttpClient();
    }
}

Dagger 会在编译时报错,因为 @ActivityScoped 的子组件不能提供 @Singleton 作用域的对象。但如果你在 ActivityModule 里忘记加 @Singleton,OkHttpClient 就会在每个 Activity 里重新创建,浪费资源不说,还可能因为连接池不一致导致莫名崩溃。

4.4 作用域正确配置表

场景 推荐作用域 不推荐作用域 典型错误后果
全局网络库 / 数据库 @Singleton @ActivityScoped Activity 销毁后资源未释放,内存泄漏
Activity 级别的 ViewModel 依赖 @ActivityScoped @Singleton 单例持有 Activity 引用,导致泄漏
Fragment 级别的 UI 状态 @FragmentScoped @ActivityScoped 多个 Fragment 复用同一状态,数据混乱
短时任务(如一次网络请求) @Reusable 或无作用域 @Singleton 全局对象膨胀,不必要

4.5 作用域层级与依赖关系图

下面这张图帮你看清楚不同作用域的生命周期范围,以及严禁的依赖方向。

作用域层级与依赖方向(正确 vs 错误) @Singleton 应用进程级 @ActivityScoped Activity 生命周期 @FragmentScoped Fragment 生命周期 ✅ 允许依赖 ✅ 允许依赖 ❌ 禁止:@FragmentScoped 注入 @ActivityScoped ❌ 禁止:短作用域注入长作用域 核心原则:长作用域组件不能依赖短作用域提供的对象 否则短作用域对象释放后,长作用域引用变成野引用 → 空指针

4.6 避坑指南

  • 我曾经遇到过一个线上崩溃:@ActivityScoped 的 Repository 里注入了 @Singleton 的缓存管理器。本来没问题,直到某次 Activity 被回收后,Repository 也被回收,但缓存管理器还在 old 引用里持有它——后来用 @Reusable 解决了。
  • 建议在每个 Component 的 @Component 或 @Subcomponent 注解里明确声明作用域,编译阶段就能发现大部分冲突。
  • 使用 Dagger 的 @BindsInstance 绑定 Activity 实例时,务必确保该 Activity 组件的作用域与 @ActivityScoped 一致。
  • 千万别在 @Module 的 @Provides 方法上瞎加作用域。比如你在 AppModule 里给一个工具类加了 @ActivityScoped,Dagger 会报错,因为 AppModule 属于 @Singleton 组件。
🔑 一句话总结: 依赖的方向只能从长到短,不能反过来。长作用域需要短作用域对象时,用 Provider 或 Lazy 并在使用时判空。

好了,作用域这块说复杂也复杂,说简单也简单——记住生命周期谁长谁短,箭头方向别画反了,大部分崩溃就能提前规避。下个案例我们聊聊多重绑定冲突导致的诡异行为,那才叫一个头大。