第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创建了另一个组件树。
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%是注入没走通。
我的排查步骤:
- 看崩溃类是否有
@HiltViewModel或@AndroidEntryPoint。 - 看字段是用
@Inject还是手动赋值。 - 看构造参数:如果是构造注入,检查Module里是否提供了正确的绑定。
- 检查是否用了
EntryPoints手动注入但时机不对(比如在构造方法里调用)。 - 打日志输出字段是否null——有时编译期过但运行时Module没有提供实例。
4.5 修复模板(避坑指南)
@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()
EntryPoints.get()需要先初始化Hilt组件。如果你在Application的attachBaseContext之前调用就会NPE。我建议在onCreate之后调用。
这张图梳理了从编译期注解到运行期注入的完整链路,红色节点就是NPE最常出现的位置。你对照着排查,基本能覆盖90%的注入NPE场景。
最后补充一句:如果实在查不到,试试把@Inject改成@JvmField暴露字段,然后在模块测试里写个单元测试验证绑定——这是我个人排查注入问题的终极手段。