启动崩溃之 NullPointerException — 模拟与捕获
讲真,NullPointerException 应该是每个 Android 开发最熟的异常了。我入行头两年,至少有一半的崩溃都是它搞出来的。尤其是在启动阶段,Activity 的 onCreate 里少判了个空,直接白屏闪退。这章咱们就专门说说,怎么用 Robolectric 模拟这种场景,把 NPE 揪出来。
NPE 为什么爱在启动时出现
启动时生命周期短,很多对象还没初始化就被调用了。说白了就是时序问题。比如从 Intent 拿数据、访问数据库、或者依赖注入没完成。你想想看,如果 onCreate 里直接 getIntent().getStringExtra("key"),而 Intent 没带这个 extra,返回 null,再对它调方法,啪,奔溃。
onCreate 用了 findViewById 的结果直接操作,但布局里某个 View 在某个配置下 inflate 失败返回 null。测试死活没发现,上线后大量闪退。从那时候起,我养成了“所有 findViewById 之后立刻判空”的习惯。
第一步:构造一个会崩的启动场景
我们先写一个简单的 Activity,故意埋个 NPE。假设它要从 SharedPreferences 读一个值,但忘记初始化 Preference 对象了。
public class CrashOnStartActivity extends AppCompatActivity {
private SharedPreferences prefs; // 故意不初始化
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_crash);
// 这里会崩:prefs 是 null
String name = prefs.getString("user_name", "default");
TextView tv = findViewById(R.id.tv_greeting);
tv.setText("Hello, " + name);
}
}
注意,实际项目中这种低级错误很少,但换个场景 —— 比如依赖注入框架在某个分支条件下没注入 —— 就很容易出现。嗯,这里要注意的是:崩溃必须在 onCreate 或 onResume 里触发,才算启动崩溃。
第二步:用 Robolectric 跑测试,验证它崩了
我们的目标不是修复它,而是先模拟崩溃,再写测试来捕获崩溃。Robolectric 的测试运行在 JVM 上,所以 NPE 会直接抛出。我们可以用 JUnit 的 @Test(expected = …) 来断言。
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 28) // 指定 API 级别,确保一致性
public class CrashOnStartActivityTest {
@Test(expected = NullPointerException.class)
public void shouldCrashWhenPrefsIsNull() {
// 启动 Activity —— 会触发 onCreate,继而抛 NPE
ActivityController<CrashOnStartActivity> controller =
Robolectric.buildActivity(CrashOnStartActivity.class);
controller.create(); // 这一步就会抛 NullPointerException
}
}
跑一下测试,绿了?等等,测试通过是因为 它确实抛了 NPE,并且我们预期它抛。这里有个坑:如果把 expected 写别的异常,测试就会失败。我建议你加一条日志确认异常信息,更直观。
@Test(expected = Exception.class) 兜底,运行一次看日志里的异常全名,再改精确。
进阶:捕获崩溃后分析异常信息
只用 expected 只能知道确实崩了,但看不到具体日志。如果想拿到异常对象做断言,可以用 try-catch 或 JUnit 的 Assert.assertThrows。
@Test
public void shouldCrashWithCorrectMessage() {
NullPointerException thrown = assertThrows(NullPointerException.class, () -> {
Robolectric.buildActivity(CrashOnStartActivity.class).create();
});
// 断言异常消息包含关键线索
assertTrue(thrown.getMessage().contains("prefs"));
}
这样一旦测试运行,你会看到异常信息:“Attempt to invoke virtual method 'java.lang.String android.content.SharedPreferences.getString(java.lang.String, java.lang.String)' on a null object reference”。是不是很熟悉?
一个典型的测试流程
把上面步骤串起来,就是咱们这章的核心逻辑。我画了张图,方便你理解。
常见陷阱与应对
| 陷阱场景 | 为什么崩 | 测试中如何暴露 |
|---|---|---|
| Intent extra 取值为 null | getStringExtra 返回 null,然后调用 .equals() | 用 Intent 不带 extra 启动 Activity |
| Fragment 中 view 未绑定 | onCreateView 返回的 root 里缺少某个 ID | 配置 Layout 变体(如横屏)重跑测试 |
| 依赖注入未完成 | 注入字段为 null | 跳过 DI 初始化,直接创建 Activity |
| SharedPreferences 获取前未初始化 | prefs 变量为 null | 不在 test 中注入 Shadow,保持原始 null |
结语
NullPointerException 在启动时特别恶心,因为用户看到的就是闪退,连个提示都没有。通过 Robolectric,我们能在开发阶段就把它模拟出来,用测试锁死它。这一招,我每次新项目都会用,几乎成了肌肉记忆。
记住一个原则:凡是在启动路径上可能为 null 的地方,都必须有测试覆盖。别怕写测试麻烦,比起半夜被报警叫起来修复线上崩溃,这点功夫太值了。