3. 常见崩溃类型分析
说实话,搞 SystemUI 这么久,我最头疼的不是功能有多复杂,而是崩溃千奇百怪——有时候你明明没动什么,它自己就挂了。
我个人习惯把常见崩溃分成六大类,每一类都有它自己的“脾气”。摸清脾气,复现起来才有章法。下面这张图就是我的心法总结,你先扫一眼,心里有个底。
3.1 空指针异常(NullPointerException)
这个应该是最常见的吧?我估计每个搞 Android 的都被它折磨过。SystemUI 里的空指针尤其隐蔽,因为很多 view 和 service 的初始化时机非常刁钻。
典型场景:
- 快速下拉通知栏时,某个 View 还没 attach 就被调用了 setText()
- 锁屏界面启动相机时,底层 Service 返回了 null
- 系统主题切换后,Resources 对象被释放了,但引用还保留着
我的一次亲历:有一次线上反馈说,部分用户升级后通知栏完全打不开。我去查 log,发现全是空指针——问题出在 StatusBarIconView 的构造函数里,某个可绘制对象在低内存下被回收了。后来我加了判空和加载 fallback 图标,才搞定。
复现技巧:
- 使用
Thread.sleep()模拟网络或 IO 延迟,让异步初始化撞上 UI 操作。 - 在
onCreate()里故意把某个成员变量设成 null,然后立马触发界面刷新。 - 结合 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) 就稳定了。
复现技巧:
- 同时接收大量通知(比如用脚本 push 100+ 条),然后快速滑动通知列表。
- 结合
Intent广播频繁增删快捷开关。 - 利用
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。
常见阻塞操作:
- 在主线程执行网络请求(比如下载天气图标)
- 在主线程执行
File IO或SharedPreferences写入 - 死锁:两个线程互相等待对方释放锁
- 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 就少一大半。