3. 常见崩溃类型分析

说实话,搞 SystemUI 这么久,我最头疼的不是功能有多复杂,而是崩溃千奇百怪——有时候你明明没动什么,它自己就挂了。

我个人习惯把常见崩溃分成六大类,每一类都有它自己的“脾气”。摸清脾气,复现起来才有章法。下面这张图就是我的心法总结,你先扫一眼,心里有个底。

SystemUI 崩溃类型分类与关联 SystemUI 崩溃 空指针异常 数组越界 资源未释放 Binder死亡通知 多线程并发问题 ANR触发时机 💡 实际开发中,一个崩溃往往由多个因素交织引发。比如多线程下的空指针, Binder 超时导致 ANR。复现时不能只看表象,要顺藤摸瓜找到根因。

3.1 空指针异常(NullPointerException)

这个应该是最常见的吧?我估计每个搞 Android 的都被它折磨过。SystemUI 里的空指针尤其隐蔽,因为很多 view 和 service 的初始化时机非常刁钻。

典型场景:

  • 快速下拉通知栏时,某个 View 还没 attach 就被调用了 setText()
  • 锁屏界面启动相机时,底层 Service 返回了 null
  • 系统主题切换后,Resources 对象被释放了,但引用还保留着

我的一次亲历:有一次线上反馈说,部分用户升级后通知栏完全打不开。我去查 log,发现全是空指针——问题出在 StatusBarIconView 的构造函数里,某个可绘制对象在低内存下被回收了。后来我加了判空和加载 fallback 图标,才搞定。

复现技巧:

  1. 使用 Thread.sleep() 模拟网络或 IO 延迟,让异步初始化撞上 UI 操作。
  2. 在 onCreate() 里故意把某个成员变量设成 null,然后立马触发界面刷新。
  3. 结合 Monkey 随机操作,配合 adb shell am start -W 快速切换 Activity。
// 示例:模拟异步加载后空指针
Handler handler = new Handler(Looper.getMainLooper());
new Thread(() -> {
    // 假装在加载数据
    SystemClock.sleep(500);
    handler.post(() -> {
        // 如果 mTargetView 在睡眠期间被置空了,这里必崩
        mTargetView.setText("加载完成");
    });
}).start();

💡 我的习惯是,所有从 findViewById 获取的 View 都要判空。不要觉得“不可能为 null”,系统回收比你想象中勤快。

3.2 数组越界(ArrayIndexOutOfBoundsException)

数组越界在 SystemUI 里常常跟列表操作有关。你想想看,通知列表、快捷开关列表、最近任务列表……只要列表数据变了但 UI 没同步刷新,越界分分钟来。

常见根因:

  • 在 notifyDataSetChanged() 之前,列表内容已经被外部修改了 index
  • 多线程下同时对 ArrayList 做增删和遍历
  • 使用 RecyclerView 时,onBindViewHolder 里取 item 没加边界检查

⚠️ 我曾经遇到过一个隐蔽的越界:通知分组后,某个子通知被移除但父通知的引用没清理,导致后续遍历时访问了 -1 索引。定位了整整两天才发现——后来强制用 for (i in indices) 替代 for (i in size-1 downTo 0) 就稳定了。

复现技巧:

  1. 同时接收大量通知(比如用脚本 push 100+ 条),然后快速滑动通知列表。
  2. 结合 Intent 广播频繁增删快捷开关。
  3. 利用 adb shell input swipe 快速切换最近任务。

3.3 资源未释放

这个其实是“慢性崩溃”——内存慢慢往上涨,最后被 LMK 一把干掉。SystemUI 里最容易漏掉的是:

  • BroadcastReceiver 没反注册:比如锁屏时钟 receiver,每次亮屏都注册一次,导致多个实例存活。
  • ContentObserver 没移除:监听系统设置变化后忘记调用 unregister()。
  • Cursor / Bitmap 没关闭:虽然现在有 try-with-resources,但老代码里到处都是泄露。

我印象最深的一次:SystemUI 运行 3 小时后自动崩溃,log 显示 OutOfMemoryError。用 MAT 一查,是 BlurUtils 里的一个静态 ArrayList 不断追加模糊位图,但 never clear。加了个按时间轮替的逻辑就解决了。

3.4 多线程并发问题

SystemUI 里线程池用得非常多,比如 AsyncTask、HandlerThread、IntentService。并发问题导致的崩溃往往最难复现,因为它需要精确的时序碰撞。

高发地点:

场景典型 race condition
通知栏下拉/收回动画属性被两个线程同时修改
亮屏/灭屏切换SurfaceFlinger 与 UI 线程竞争
多用户切换UserManager 回调与数据加载交叉
// 经典线程不安全写法
private List mData;
public void updateData(List newData) {
    new Thread(() -> {
        // 这里在子线程修改 mData
        mData.addAll(newData);  // 如果主线程同时遍历 mData → ConcurrentModificationException
    }).start();
}

✅ 我的避坑指南:所有的集合遍历要么用 CopyOnWriteArrayList,要么在遍历前加一把 synchronized 锁。别偷懒。

3.5 Binder 死亡通知

SystemUI 通过 Binder 跟很多系统服务通信,比如 WindowManager、NotificationManager、StatusBarManager。当远程服务进程挂掉或者 Binder 驱动异常时,你的回调会收到 DeadObjectException。

什么时候容易触发?

  • 系统低内存时,system_server 重启
  • 权限变更后重新绑定服务
  • 开发者手动 adb shell kill 某个系统进程

⚠️ 请注意:Binder 崩溃不会导致 App 直接闪退,但往往让 SystemUI 进入半死不活状态——点击没反应、界面卡住。复现时可以用 adb shell kill <pid_of_system_server> 来模拟。

3.6 ANR 触发时机

ANR 不是“崩溃”,但比崩溃更恶心——用户只能点“等待”或“关闭”。SystemUI 的主要线程(UI Thread)如果被阻塞超过 5 秒,就会触发 ANR。

常见阻塞操作:

  1. 在主线程执行网络请求(比如下载天气图标)
  2. 在主线程执行 File IO 或 SharedPreferences 写入
  3. 死锁:两个线程互相等待对方释放锁
  4. View 的 measure/layout 递归调用导致栈溢出(也算超时)
// 典型 ANR 触发器
public void onExpand() {
    // 千万别这么干 —— 在主线程做大量计算
    for (int i = 0; i < 100000; i++) {
        // 模拟 CPU 密集型计算,block 主线程 5s 以上
        Math.sin(i);
    }
}

我记得有一次,通知栏快速展开时 ANR 概率很高。后来用 Systrace 一抓,发现是 StatusBarWindowView 的 onMeasure 里做了复杂布局计算。把所有测量逻辑扔到 View.post() 里延迟执行,ANR 就消失了。

总结:复现崩溃的核心思路

不管你面对哪种崩溃类型,记住这三个步骤:

  • 模拟边界条件:低内存、高并发、快速操作
  • 捕捉现场日志:使用 adb logcat -b all -v threadtime 抓取完整 log
  • 压测复现:Monkey + 脚本循环操作,放大概率

💡 最后说句掏心窝的话:很多崩溃不是 bug,而是设计时没考虑极端情况。写 SystemUI 的代码,时刻问自己一句:“这里如果某个线程慢了 2 秒会怎样?”——想通了,bug 就少一大半。