启动崩溃之 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”。是不是很熟悉?

一个典型的测试流程

把上面步骤串起来,就是咱们这章的核心逻辑。我画了张图,方便你理解。

NullPointerException 启动崩溃测试流程 构造含NPE的Activity 启动(create)触发崩溃 编写Robolectric测试 断言捕获NullPointerException 验证异常消息/修复 关键点 崩溃必须在onCreate/onResume触发 才算启动崩溃 断言方式 @Test(expected=...) 或 assertThrows 推荐 assertThrows 便于检查消息

常见陷阱与应对

陷阱场景为什么崩测试中如何暴露
Intent extra 取值为 nullgetStringExtra 返回 null,然后调用 .equals()用 Intent 不带 extra 启动 Activity
Fragment 中 view 未绑定onCreateView 返回的 root 里缺少某个 ID配置 Layout 变体(如横屏)重跑测试
依赖注入未完成注入字段为 null跳过 DI 初始化,直接创建 Activity
SharedPreferences 获取前未初始化prefs 变量为 null不在 test 中注入 Shadow,保持原始 null
我的习惯:每个启动 Activity 至少写一个“空状态测试”,比如无网络、无 Intent extra、无缓存数据的情况。这些是最容易出 NPE 的地方。跑一遍测试,比上线后看崩溃堆栈舒服多了。

结语

NullPointerException 在启动时特别恶心,因为用户看到的就是闪退,连个提示都没有。通过 Robolectric,我们能在开发阶段就把它模拟出来,用测试锁死它。这一招,我每次新项目都会用,几乎成了肌肉记忆。

记住一个原则:凡是在启动路径上可能为 null 的地方,都必须有测试覆盖。别怕写测试麻烦,比起半夜被报警叫起来修复线上崩溃,这点功夫太值了。