深入 Android 兼容性框架 CompatChange 全链路:从 PlatformCompat 行为开关到 targetSdk 版本治理的系统级适配工程
一次 targetSdk 升级引发的”灵异事件”
去年把一个老项目从 targetSdk 28 升到 31,测试报了一堆怪问题:同一个 APK,在旧设备上正常,在 Android 12 上部分页面变透明、PendingIntent 直接抛异常。查了三天,根因都指向同一件事——系统行为随 targetSdk 版本切换了。
Android 每年发新版本都会改一批行为,比如非 SDK 接口限制、后台启动限制、Parcel 读取策略。这些变更不能无差别作用于所有应用,否则每次升级都会让老应用大面积崩溃。系统于是引入一套开关机制:CompatChange,给每个行为变更分配一个 change ID,再按应用的 targetSdk 决定开关状态。
这套机制就是 PlatformCompat(平台兼容性框架),系统侧的核心实现。理解了它,targetSdk 适配就从”逐个踩坑”变成”可预测的工程”。
CompatChange:给每个行为变更发一个身份证
系统把所有行为变更抽象成 CompatChange 对象,核心字段包括:
id:变更的唯一 ID,写死在系统源码里name:人类可读的名称enableAfterTargetSdk/enableSinceTargetSdk:门槛disabled/enabled:默认状态loggingOnly:是否只记日志不改行为
比如 Android 12 的 UNTRUSTED_TOUCH_EVENTS_BLOCKED(ID 158002751),enableAfterTargetSdk = 30,即 targetSdk 大于等于 31 的应用才会屏蔽不可信触摸事件。
系统侧的定义位置在 AOSP 的 ChangeId 类中,每个变更是一条静态常量:
public static final long UNTRUSTED_TOUCH_EVENTS_BLOCKED = 158002751L;
门槛信息写在 XML 配置里,由系统启动时加载:
<compat-change
id="158002751"
name="UNTRUSTED_TOUCH_EVENTS_BLOCKED"
enableAfterTargetSdk="30" />
enableAfterTargetSdk="30" 表示 targetSdk 大于 30 的应用才启用这个变更行为。同一个 ID 可能同时出现在 framework-compat-config.xml 和厂商 overlay 里,后者覆盖前者。
PlatformCompat 的判定链路
应用启动或调用相关 API 时,系统通过 PlatformCompat 服务查询变更状态。判定链路大致分四步:
- 系统代码调用
isChangeEnabled(changeId, packageName, user, binder) PlatformCompat从缓存或配置中读取该 ID 的变更对象- 结合应用的
targetSdkVersion计算门槛:enableAfterTargetSdk、enableSinceTargetSdk - 返回最终布尔值,调用方据此走新旧两套逻辑
判断逻辑简化后是这个函数:
boolean isChangeEnabled(CompatChange change, int appTargetSdk) {
if (change.enableAfterTargetSdk() != NO_SDK) {
return appTargetSdk > change.enableAfterTargetSdk();
}
if (change.enableSinceTargetSdk() != NO_SDK) {
return appTargetSdk >= change.enableSinceTargetSdk();
}
return change.enabled();
}
注意 enableAfterTargetSdk 是严格大于,enableSinceTargetSdk 是大于等于,这是看源码时容易忽略的细节。Android 12 的 UNTRUSTED_TOUCH_EVENTS_BLOCKED 配了 enableAfterTargetSdk=30,所以 targetSdk 31 才触发,targetSdk 30 不受影响。
同一个 change 对不同应用结果不同,判定依赖各自 targetSdk。PlatformCompat 不缓存布尔结果,每次查询都拿应用当前的 targetSdkVersion 现算;应用升级后 targetSdk 变了,下次查询自然按新值判定。
调试开关:把隐式行为变成显式可测
真正好用的是 adb 命令行开关:可以在任意设备上强制启用或关闭某个 change,不用改 targetSdk 就能提前验证新行为。
# 查看某个应用启用了哪些 change
adb shell am compat list-changes com.example.app
# 强制启用(changeId 从 AOSP ChangeId 源码查)
adb shell am compat enable 158002751 com.example.app
# 强制关闭
adb shell am compat disable 158002751 com.example.app
am compat 背后调用的是 ActivityManagerShellCommand,最终落到 PlatformCompat 的 override 接口。这个 override 存储在 PackageManager 的运行时状态里,重启应用后依然生效,卸载重装才清除。
还有一个更直接的排查手段:platform_compat 日志。每次变更判定时系统会打一条日志,tag 固定为 platform_compat:
adb logcat -s platform_compat
输出类似:
Compatibility change info reported: CHANGE; UNTRUSTED_TOUCH_EVENTS_BLOCKED;
158002751; targetSdkVersion=31; ENABLED; package=com.example.app
实际项目中我靠这行日志定位过一个输入法问题:日志显示某个 change 是 DISABLED,业务代码却按已启用去处理,方向错了半天。注意这条日志只在 userdebug/eng 构建上默认打印,user 构建默认关闭,release 设备抓不到,这是系统有意控制的。
三类控制方式与厂商 overlay 的坑
兼容性开关不只 targetSdk 一种控制来源,完整优先级从高到低:
- 运行时 override:
am compat enable/disable手动设置 - 厂商/运营商 overlay:
/vendor/etc/compatconfig/下的自定义 XML - 默认配置:AOSP 自带的 framework-compat-config.xml 及 targetSdk 门槛
厂商 overlay 是最容易漏掉的一层。国内不少 ROM 会关闭或改写某些 change,导致同一个 APK 在原生系统和定制系统上行为不一致。我踩过的一个坑是 PHONE_STATE_LISTENER_LIMIT_CHANGE 在某个 ROM 上被厂商强制 disabled,后台电话监听相关逻辑表现完全不同。
排查时先确认配置来源:
adb shell dumpsys platform_compat
这个命令能导出当前设备加载的所有 change 及其状态、来源。我一般把它保存成文本,diff 原生 AOSP 配置和厂商配置,快速锁定被改写的 ID。
把适配做成工程:提前验证的方法论
我的做法是不等到升 targetSdk 才验证行为,而是在当前版本就遍历验证。具体三步:
第一步,盘点影响面。 从 AOSP 的 ChangeId 源码里按目标版本筛选出有门槛的 change,重点看 enableAfterTargetSdk 落在旧 targetSdk 和新 targetSdk 之间的 ID。这一步能产出清单,比盲目升级高效得多。
第二步,逐个开关注入。 在旧 targetSdk 的应用上,用 am compat enable 把目标版本的 change 全部打开,跑完整回归。哪个页面崩了、哪个 API 抛异常,当场就能和 change ID 对上号,不用等升级后全网排查。
第三步,写进 CI 校验。 把清单转成脚本,在 CI 环境里对 debug 包执行批量 enable,配合 logcat -s platform_compat 采集变更判定结果,作为适配进度的自动化证据。
我更倾向在测试环境用 override 而非直接改 targetSdk 来预演,因为 override 可以逐个开启、逐个回滚,定位粒度更细。直接改 targetSdk 一次激活几十个 change,问题混在一起很难拆解。
targetSdk 治理的本质,是把”系统版本升级”这个外部事件,转化为内部可枚举、可开关、可回归的工程任务。PlatformCompat 提供了这套开关,剩下的就是把它用进流程里。