Android API 版本兼容性工程体系:从编译期检查到运行时降级的全链路策略

去年接手一个老项目的崩溃治理,Firebase 上报了一个高频 crash,堆栈只有 3 行:

java.lang.NoSuchMethodError: android.view.WindowInsetsController#setSystemBarsAppearance

setSystemBarsAppearance 是 API 30 引入的方法,项目的 minSdk 是 23。开发机跑 Android 13,功能一切正常。但线上大量用户还在用 Android 9、10,这些设备上没有这个方法,直接崩溃。

这个 bug 暴露的不是某行代码的问题,而是整个团队缺少版本兼容意识。开发阶段感知不到调用是不安全的,测试也没覆盖低版本设备,最终由用户来触发 crash。

建立从编译期到运行时的 API 版本兼容工程体系,逻辑就一条:让不兼容的调用在编译阶段就暴露,不要拿用户当测试员。

编译期防线:NewApi Lint

Android Lint 内置了 NewApi 检查项,能扫描出所有调用了高于 minSdkVersion 的 API。

Lint 维护了一个 API 版本数据库,记录每个类、方法、字段的最低 API Level。扫描时对比调用目标的 API Level 和项目的 minSdkVersion,不匹配就报 warning。

默认这只是个黄色警告,IDE 里一条下划线,随手就能忽略。不想被忽略,在 build.gradle 里把它升级为 error:

android {
    lint {
        warningsAsErrors = true
        check += "NewApi"
    }
}

配置完以后,任何未加版本保护的 API 调用都会让编译挂掉。

但 NewApi 有两个盲区。一是反射调用 —— Class.forName()Method.invoke() 访问的 API,Lint 追踪不到调用链,不会报任何警告。二是第三方 SDK —— aar 通常不带源码,SDK 内部的 API 调用默认不参与 Lint 扫描。

第一个盲区后面讲反射降级时会细说。第二个盲区,实际项目里我倾向于在集成测试阶段用低版本设备跑全量功能,比逐个审查 SDK 源码的可操作性高得多。

SDK_INT 判断的两种模式

编译期防住了显式调用,剩下的靠运行时判断。核心就一个常量:Build.VERSION.SDK_INT

最直接的写法是 if-else:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
    window.insetsController?.setSystemBarsAppearance(
        WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS,
        WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS
    )
} else {
    @Suppress("DEPRECATION")
    window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
}

有两个容易踩的坑。

第一,else 分支里的 API 也有版本下限SYSTEM_UI_FLAG_LIGHT_STATUS_BAR 从 API 23 开始才有。如果你的 minSdk 是 21,上面这段代码在 Android 5.0 设备上进了 else 分支照样崩溃。写版本判断时不能只盯着 if 分支,else 分支同样需要验证。

第二,@Suppress("DEPRECATION") 要加注释。不加注释的 Suppress 在 Code Review 时会被怀疑是偷懒。加一行 // 低版本兼容:API 23-29 使用旧 API,后续维护者能立刻理解设计意图。

多版本差异较大时,用 when 做多分支比嵌套 if-else 更清晰:

when {
    Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> handleApi33Plus()
    Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> handleApi30To32()
    else -> handleLegacy()
}

分支数控制在 3-4 个,按行为发生变化的版本节点划分,别写成每个 API Level 一个分支。

反射与 Compat:两种降级路径

SDK_INT 判断的前提是高版本 API 的类在低版本 SDK 中也存在,只是运行时不一定执行到。但有些情况是类本身在低版本 SDK 中不存在 —— 用低版本 compileSdk 编译时,连这个类的引用都写不出来。

这时候上反射:

try {
    val method = Class.forName("android.view.WindowInsetsController")
        .getMethod("setSystemBarsAppearance", Int::class.javaPrimitiveType)
    method.invoke(controller, 8) // APPEARANCE_LIGHT_STATUS_BARS = 1 << 3
} catch (e: Exception) {
    // 降级处理
}

常量 APPEARANCE_LIGHT_STATUS_BARS 这里硬编码为 8。低版本 SDK 里没有这个常量符号,编译期拿不到值,只能手动计算。Android 框架层的常量值变更概率极低,但这种硬编码本质上是在对抗框架设计,只适合少数突破性场景。

更稳妥的方案是用 AndroidX 的 Compat 类。WindowInsetsControllerCompat 内部封装了反射和 SDK_INT 判断:

val controller = WindowInsetsControllerCompat(window, window.decorView)
controller.isAppearanceLightStatusBars = true

一行代码,所有版本统一行为。代价是引入 AndroidX 依赖,但如果项目已经在用 AndroidX,这个代价为零。

我的判断标准很简单:Compat 能覆盖的场景用 Compat,覆盖不了的才考虑反射 + 硬编码常量。 维护成本差异太大 —— Compat 类的 API 变更跟着 AndroidX 版本升级同步,自己写的反射代码完全靠人工维护。踩过的坑:某次 AndroidX 升级后 Compat 行为有细微变化,改一行版本号就行;自己维护的反射代码,光定位问题就花了一下午。

工程化落地:把检查嵌进 CI

上述手段如果只停在代码规范文档里,效果会大打折扣。几项工程化措施能让版本兼容成为团队的默认行为。

CI 里强制跑 Lint。 在 CI 脚本中加入 ./gradlew lintRelease,Lint 报 error 时构建直接失败。任何未经版本保护的 API 调用都过不了合并检查。

建立 @ChecksSdkIntAtLeast 注解体系。 AndroidX 提供了这个注解,标记方法的 SDK_INT 判断逻辑。IDE 能识别它,追踪到调用链,避免误报 lint 警告。半年后回来看代码也能理解当时的兼容设计意图。

Code Review 清单固定一条。 每次 CR 确认:新增的 API 调用是否与 minSdk 匹配?是否做了版本保护?对培养新人的兼容意识,这条规则比任何技术方案都管用。

低版本设备自动化测试。 在 Firebase Test Lab 上用 minSdk 版本的设备跑核心流程。这能抓到静态分析覆盖不到的问题:反射调用里的常量值错误、第三方 SDK 在低版本上的行为差异。

几条实践原则

把 NewApi Lint 设为 error,10 分钟配置,能拦截 90% 以上的版本兼容 crash —— 投入产出比最高的动作,没有之一。

优先使用 AndroidX Compat 库。Compat 覆盖不到的场景,用 SDK_INT + if-else 做版本判断。反射 + 硬编码常量是最后手段,只在类不存在的场景使用。

minSdk 不要轻易调低。每次调低都意味着多一个版本区间的兼容成本。我参与过的项目里,minSdk 在初期调研确定,之后只升不降。