catch (Exception) 拦不住的启动闪退:一次 Android 10 兼容崩溃的三层定位
现象:一台 Android 10 在凌晨两点反复闪退
线上崩溃上报里出现过这样一条:某台 OPPO 设备(Android 10,SDK 29),凌晨 02:00 前后连续上报 4 次崩溃,每次都是刚打开就退出。同一个 APK 在其他机器上跑得好好的,Android 12 以上一台都没事。
堆栈长这样:
java.lang.NoSuchMethodError: No static method systemBars()I in class Landroid/view/WindowInsets$Type;
at androidx.media3.exoplayer.source.chunk.a.D
at io.github.aixtin.nyral.MainActivity.adjustBrowserWindow$app_release
at io.github.aixtin.nyral.MainActivity$setupSystemGestures$3.onGlobalLayout
第一条帧就很可疑:androidx.media3.exoplayer.source.chunk.a.D。一个播放器库的内部类,怎么会出现在 Activity 的布局回调里?
这篇文章把这次定位过程拆成三层,因为每一层单独看都会得出错误结论。
第一层:堆栈第一行是假的
androidx.media3.exoplayer.source.chunk.a.D 不是真的调用者。
Release 包经过 R8 处理后,类名方法名被压缩成单字母,同时做了内联。当被内联进来的代码抛异常时,栈帧上留下的是内联位置所属的类,而真实调用点已经被抹掉了 —— 这一帧只是"哪段代码被内联到了这里"的记账,不是"谁调用了这里"。
判断依据有两条:
- 包名规律。
a.D这种单字母类名 + 单字母方法名,是 R8 压缩的典型产物,而不是任何库的公开 API。 - 上下文矛盾。异常类型是
NoSuchMethodError,涉及WindowInsets,与播放器的 chunk 加载逻辑毫无关系。
所以第一层结论:别顺着第一帧去查播放器,往下看第一个有业务语义的帧 —— MainActivity.adjustBrowserWindow,被 onGlobalLayout 触发,也就是首帧布局。
第二层:catch (Exception) 拦不住它
NoSuchMethodError 在 Java 体系里属于 Error,不是 Exception。
// 拦不住
try {
adjustBrowserWindow()
} catch (e: Exception) {
// NoSuchMethodError 不在这里被捕获
}
Error 与 Exception 的共同父类是 Throwable,常规写法 catch (e: Exception) 只覆盖 Exception 分支。NoSuchMethodError、NoClassDefFoundError、OutOfMemoryError 这些都会直接穿透,向上走到主线程未捕获处理器,结果是进程被杀。
很多人第一反应是"加个 try/catch 兜一下就好",在这里完全不成立。就算写成 catch (e: Throwable),也等于把系统性错误当成业务异常吞掉,问题只是从"闪退"变成"界面错乱",并没有解决。
正确的做法是从根上不调用不存在的 API,靠 Build.VERSION.SDK_INT 分支,而不是靠异常捕获。
第三层:为什么只有 Android 10
原因在 minSdk 与 API 等级的交界处。
| API | 等级 | 说明 |
|---|---|---|
WindowInsets.Type(含 systemBars()) |
API 30(Android 11)新增 | 本项目的目标调用方式 |
WindowInsets.getInsets(int) |
API 30 新增 | 同上 |
WindowInsets.systemWindowInsets |
早期就有,API 30 起废弃 | Android 10 上唯一可用路径 |
项目 minSdk |
29(Android 10) | 交界点正好落在这里 |
也就是说:minSdk 29 允许装机,但代码走的是 API 30 才有的方法。Android 10 上第一次布局回调就会去找 WindowInsets$Type.systemBars(),类里根本没有这个方法,直接 NoSuchMethodError。
而 onGlobalLayout 是首帧必然触发的回调,所以表现是"启动即闪",100% 命中,不存在偶发。
顺便排掉一个常见误判:这类崩溃在 Android 11 及以上永远不会复现,测试机全是新系统时,这个 bug 可以躺很久不被发现。崩溃上报里它的版本分布也很说明问题 —— 只出现在一个版本,且该版本用户里只有老设备会报。
修法:把跨版本 API 收敛到单一封装
散落在调用点各写 if (SDK_INT >= 30) 是最容易漏的做法(这次全仓有 5 处调用点)。所以新增一个统一封装,让所有调用点只认一个入口:
object InsetsX {
private const val API_R = 30
/** 系统栏(状态栏 + 导航栏)内边距,API < 30 走废弃字段 */
fun systemBars(insets: WindowInsets): Insets {
return if (Build.VERSION.SDK_INT >= API_R) {
insets.getInsets(WindowInsets.Type.systemBars())
} else {
@Suppress("DEPRECATION")
Insets(
insets.systemWindowInsets.left,
insets.systemWindowInsets.top,
insets.systemWindowInsets.right,
insets.systemWindowInsets.bottom
)
}
}
/** 输入法高度,API < 30 无此概念,按 0 降级 */
fun ime(insets: WindowInsets): Int =
if (Build.VERSION.SDK_INT >= API_R) insets.getInsets(WindowInsets.Type.ime()).bottom else 0
}
两个细节值得说:
- 降级要选"不崩"而不是"精确"。IME 高度在 API 30 以下没有对应值,按 0 返回;界面可能不会随键盘上推,但不会闪退。兼容性修复的第一目标是活下来,不是完美还原。
- 封装之后必须核对存量调用点。改完
adjustBrowserWindow,还要确认别处没有裸调。这次核查结果是:MainActivity另外两处WindowInsets.Type虽然存在,但都包在SDK_INT >= 30的分支里;GuideActivity那处有SDK_INT >= 35保护;封装内部自己判版本。全部有守卫,无残留。
同一批上报里的第二类崩溃:前台服务起不来
同一时间段的日志里还有一类,症状很像、根因完全不同:
android.app.RemoteServiceException$ForegroundServiceDidNotStartInTimeException
at TaskService ...
startForegroundService() 之后必须在时限内调用 startForeground(),否则系统抛这个异常。这次有两条独立成因叠加:
成因 A:顺序问题。 onCreate 里先做了一堆重初始化(配置读取、密钥库解密),然后才 startForeground()。冷启动时这些操作本身就要占主线程时间,叠上其他初始化就可能超时。
成因 B:竞态(主因)。 服务 start() 之后如果立刻失败(比如配置缺失,同步回调 onError),紧接着会 stop()。此时服务实例可能还没被创建完,stopService 等于取消了这次启动,但系统记账仍在"已请求前台服务、未前台化",约 10 秒后判超时。
线上表现是"连续点重试按钮 5~6 次后闪退",正好对应这条路径。
修法是把前台化提到最前,并给失败路径加状态标记:
private var fgStarted = false
private var pendingStop = false
override fun onCreate() {
super.onCreate()
createChannel()
startForegroundCompat() // 先满足系统契约
fgStarted = true
// 其余初始化后移
}
override fun stop() {
if (!fgStarted) {
pendingStop = true // 还没前台化,只登记,不要取消启动
return
}
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
override fun onStartCommand(...): Int {
if (pendingStop) {
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
return START_NOT_STICKY
}
核心思想:系统契约优先于业务初始化。先让服务合法,再谈业务;失败路径不能把"正在启动"变成"取消启动"。
验证怎么做才算验证过
改动涉及 API 兼容与并发时序,光靠"能编译"不够。这次做了三层:
- 编译:
gradle :app:compileReleaseKotlin,EXIT=0。 - 静态检查对照 baseline:跑
detekt前先git stash -u跑一遍看历史告警数,再stash pop跑自己的改动。结果是本次 0 新增告警,而仓库本身有 7 项历史告警 —— 如果不做对照,很容易把历史红当成自己引入的。 - 真机回归(唯一没做完的一步):API 兼容问题必须用对应系统版本实跑。这次临时没抓到 Android 10 的机器,结论只能到"代码路径已封死",不能宣称"已修复"。
第 3 点要诚实写出来:没有实机复现的兼容性修复,可信度打七折。
结论清单
如果只记四条:
NoSuchMethodError是 Error,catch (Exception)拦不住,跨版本 API 必须靠SDK_INT分支,不能靠异常兜底。- Release 包堆栈第一帧可能是 R8 内联的假帧,按"包名规律 + 上下文矛盾"识别,往下找第一个有业务语义的帧。
minSdk是允许装机的下限,不是 API 可用的下限。用了 API 30 的方法而minSdk = 29,就等于给 Android 10 用户埋了必现崩溃;测试机全是新系统时永远发现不了。- 前台服务的顺序契约优先于业务初始化,"启动中"不能因为业务失败被当成"取消启动"。

浙公网安备 33010602011771号