第4章 依赖注入未初始化导致NPE(Dagger/Hilt)

说实话,Dagger这类编译期注入框架的好处是运行时性能好,但代价就是——编译通过≠运行不崩。我在项目里接过好几个线上NPE,一查堆栈全是某个注入字段是null。这种崩溃最气人的地方是:代码看着都对,注解没少,编译也过了,一运行就挂。

这一章我们就专门聊依赖注入未初始化导致的NPE。我会把最常见的那几个坑逐个拆开,告诉你为什么会null,怎么复现,以及修复的最优姿势。

4.1 经典场景:Activity/Fragment里注入为null

先看一个我调试了半小时的案例。

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {

    @Inject lateinit var userManager: UserManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // 这里调用userManager,看起来没问题
        userManager.login()
    }
}

编译通过,运行直接炸:kotlin.UninitializedPropertyAccessException: lateinit property userManager has not been initialized。嗯,Kotlin的lateinit没初始化,本质上就是null。堆栈指向userManager.login那一行。

请注意:@AndroidEntryPoint的Activity必须在super.onCreate之前完成注入。上面的代码看似调用了super.onCreate,但如果你在onCreate的最前面访问注入字段(甚至在super.onCreate之前),依然会NPE。

正确的做法是什么?我个人习惯是在super.onCreate之后再使用注入字段,而不是之前。另外要注意:@Inject lateinit不能在init块或构造参数里访问——因为那时Dagger还没完成注入。

4.2 Hilt组件未创建或作用域不匹配

另一个高频问题是:你注入一个单例对象,但Hilt组件根本没有被创建。典型场景——在普通类(非AndroidEntryPoint)里用@Inject字段,却忘了手动注入。

class MyWorker {
    @Inject lateinit var api: ApiService

    fun doWork() {
        api.fetch() // NPE! api为null
    }
}

// 调用处
val worker = MyWorker()
worker.doWork() // 没有注入,api = null

为什么会这样?因为MyWorker没有被@AndroidEntryPoint或@HiltViewModel修饰,Dagger不会自动注入它的字段。你需要手动调用EntryPoints.get()或者通过构造注入来创建它。

如果你希望一个普通类也能拿到注入的依赖,最简单的方式是:把它做成构造注入,而不是字段注入。
class MyWorker @Inject constructor(private val api: ApiService) {
    fun doWork() {
        api.fetch() // 此时api已经由Dagger提供
    }
}
// 然后通过Hilt的组件获取MyWorker实例

4.3 作用域导致的“幽灵NPE”

还有一类更隐蔽的:不同作用域的组件混用。比如你在@ActivityScoped的类里注入了@FragmentScoped的实例——等一下,这本来就不对。但更常见的是:你在@Singleton里注入了@ActivityScoped的依赖,编译不过;但是如果你把作用域写错了(比如漏写了),就能编译通过,运行时却拿到null。

作用域 生命周期 典型注入目标
@Singleton Application 全局单例
@ActivityScoped Activity Activity内共享
@FragmentScoped Fragment Fragment内共享
@ViewModelScoped ViewModel ViewModel实例

我遇到过这样的崩溃:一个@ActivityScoped的Presenter,内部注入了UserRepository,而这个Repository是@Singleton的,没问题。但是Fragment里又单独注入了一个@FragmentScoped的Presenter,两者看起来像,但作用域不同。某个事件回调里Fragment试图通过requireActivity()获取Activity的Presenter,结果拿到的是null——因为Dagger为Fragment创建了另一个组件树。

核心原则:依赖的作用域范围不能大于注入点的组件。例如你可以在Activity里注入Singleton依赖(可以),但不能在Singleton里注入ActivityScoped依赖(编译报错或NPE)。

4.4 实战:读堆栈定位注入NPE

当堆栈长这样时,你怎么判断是注入问题?

Caused by: java.lang.NullPointerException: 
    Attempt to invoke interface method 'void com.example.data.UserRepository.login()' on a null object reference
    at com.example.ui.LoginViewModel.login(LoginViewModel.java:25)
    ...
    // 注意这里通常没有Dagger相关类栈

没有Dagger堆栈?那是因为NPE发生在你的业务代码里。但字段是null,就需要往前查:这个ViewModel是怎么创建的?如果它是通过@HiltViewModel创建的,那就检查构造参数是否漏了注解;如果它是手动new的,那100%是注入没走通。

我的排查步骤:

  1. 看崩溃类是否有@HiltViewModel或@AndroidEntryPoint。
  2. 看字段是用@Inject还是手动赋值。
  3. 看构造参数:如果是构造注入,检查Module里是否提供了正确的绑定。
  4. 检查是否用了EntryPoints手动注入但时机不对(比如在构造方法里调用)。
  5. 打日志输出字段是否null——有时编译期过但运行时Module没有提供实例。

4.5 修复模板(避坑指南)

我曾经在一个大型模块化项目里,因为某个子Module忘记添加@InstallIn注解,导致App层怎么都拿不到依赖,编译通过但运行全部NPE。排查了一整个下午才发现是Missing binding,但编译器居然没报错(因为用了@Binds + 接口)。所以:强烈推荐在build.gradle里启用Dagger的严格校验。
// 在app/build.gradle中
hilt {
    enableAggregatingTask = false // 不要聚合,便于定位问题
}

针对Activity/Fragment字段注入NPE,修复模板:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject lateinit var viewModelFactory: ViewModelProvider.Factory

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState) // 必须在前
        // 此时注入已完成
        val viewModel = ViewModelProvider(this, viewModelFactory).get(MainViewModel::class.java)
    }
}

如果是普通类需要注入,使用EntryPoint接口:

@EntryPoint
@InstallIn(SingletonComponent::class)
interface MyWorkerEntryPoint {
    fun myWorker(): MyWorker
}

// 使用
val entryPoint = EntryPoints.get(applicationContext, MyWorkerEntryPoint::class.java)
val worker = entryPoint.myWorker()
worker.doWork()
在Android 12+中,EntryPoints.get()需要先初始化Hilt组件。如果你在Application的attachBaseContext之前调用就会NPE。我建议在onCreate之后调用。
依赖注入初始化流程与NPE典型触发点 编译阶段(@Inject注解) @Inject lateinit字段 构造注入 @Module/@Binds 运行时阶段(Hilt组件) Hilt组件创建 递归注入依赖 字段赋值(反射) NPE触发点(红色) @AndroidEntryPoint缺失 手动new对象 作用域不匹配 Module未提供 检查清单:是否标注@AndroidEntryPoint?是否手动new?作用域是否正确?Module是否@InstallIn?

这张图梳理了从编译期注解到运行期注入的完整链路,红色节点就是NPE最常出现的位置。你对照着排查,基本能覆盖90%的注入NPE场景。

最后补充一句:如果实在查不到,试试把@Inject改成@JvmField暴露字段,然后在模块测试里写个单元测试验证绑定——这是我个人排查注入问题的终极手段。