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 处理后,类名方法名被压缩成单字母,同时做了内联。当被内联进来的代码抛异常时,栈帧上留下的是内联位置所属的类,而真实调用点已经被抹掉了 —— 这一帧只是"哪段代码被内联到了这里"的记账,不是"谁调用了这里"。

判断依据有两条:

  1. 包名规律。a.D 这种单字母类名 + 单字母方法名,是 R8 压缩的典型产物,而不是任何库的公开 API。
  2. 上下文矛盾。异常类型是 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
}

两个细节值得说:

  1. 降级要选"不崩"而不是"精确"。IME 高度在 API 30 以下没有对应值,按 0 返回;界面可能不会随键盘上推,但不会闪退。兼容性修复的第一目标是活下来,不是完美还原。
  2. 封装之后必须核对存量调用点。改完 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 兼容与并发时序,光靠"能编译"不够。这次做了三层:

  1. 编译:gradle :app:compileReleaseKotlin,EXIT=0。
  2. 静态检查对照 baseline:跑 detekt 前先 git stash -u 跑一遍看历史告警数,再 stash pop 跑自己的改动。结果是本次 0 新增告警,而仓库本身有 7 项历史告警 —— 如果不做对照,很容易把历史红当成自己引入的。
  3. 真机回归(唯一没做完的一步):API 兼容问题必须用对应系统版本实跑。这次临时没抓到 Android 10 的机器,结论只能到"代码路径已封死",不能宣称"已修复"。

第 3 点要诚实写出来:没有实机复现的兼容性修复,可信度打七折。


结论清单

如果只记四条:

  1. NoSuchMethodError 是 Error,catch (Exception) 拦不住,跨版本 API 必须靠 SDK_INT 分支,不能靠异常兜底。
  2. Release 包堆栈第一帧可能是 R8 内联的假帧,按"包名规律 + 上下文矛盾"识别,往下找第一个有业务语义的帧。
  3. minSdk 是允许装机的下限,不是 API 可用的下限。用了 API 30 的方法而 minSdk = 29,就等于给 Android 10 用户埋了必现崩溃;测试机全是新系统时永远发现不了。
  4. 前台服务的顺序契约优先于业务初始化,"启动中"不能因为业务失败被当成"取消启动"。
posted @ 2026-11-12 10:55  axtin  阅读(2)  评论(0)    收藏  举报