写在前面
大家好,我是三雒。
前段时间,我们在 Google Play Console 的 Release dashboard 上看到了一条 R8 优化建议:
Console 提示当前构建的 Optimization rate 只有 46%,Obfuscation rate 和 Shrinking rate 也只有 47%。但是我们项目的 Release 构建全局也没有 -dontoptimize、-dontobfuscate 或 -dontshrink,R8 明明正常在跑,为什么混淆/Shrinking/优化 rate 都这么低呢?
那就只有一个可能,Keep 规则太离谱了呗,过度 Keep 导致的。一个开发一年的项目里有上百条 Keep 规则,它们影响范围和权重是不一样的,我们还是要从 ROI 高的 Keep 规则开始治理。所以继续往下拆解分析的话,问题自然变成了:每条 Keep 规则到底挡住了多少优化? 只有先量化类、字段和方法的影响范围,才能大致判断潜在收益。再结合反射、序列化、Native 调用 Java 等使用分析和测试成本,才能决定真正的执行顺序。
这让我想起 21~22 年在优化抖音和抖音极速版包体积时的一段经历。当时为了确定每条 Keep 规则究竟保住了多少类和成员,我们只能自己魔改 ProGuard 来统计每条规则的影响范围。但是现在,R8 Configuration Analyzer 已经把这件事做成了官方工具:先从整体 score 看配置质量,再下钻到单条规则的 class、field、method 影响范围,提供精确的比例数据,给我们提供优先级依据。
Keep 规则到底 Keep 了什么
大家应该都知道,ProGuard 和 R8 能用来做代码混淆。但它们做的其实不只是改名,还会负责无用代码裁剪和代码优化:
- Obfuscation:缩短类和成员名称;
- Shrinking:删除不可达的类、字段和方法;
- Optimization:执行内联、类合并、去虚化、常量传播等代码变换。
普通的整包规则:
-keep class com.example.somepackage.** { *; }
并不只是“保持类名”。没有额外修饰符时,它会保留命中的类和全部成员,同时拦住 shrinking、obfuscation 和 optimization。如果类似规则来自 App、本地 Library 和第三方 AAR,并且层层叠加,即使 minifyEnabled=true,R8 实际能动的代码也可能很有限。
Keep 规则也不是只有“全保留”和“全删除”两档。理解它时,可以拆成两个相互独立的维度:匹配哪些类和成员,以及允许 R8 对这些目标执行哪些处理。
维度一:类和成员的匹配范围
# 只保留类名;类如果不可达,仍然可以被删除
-keepnames class com.example.EntryPoint
# 保留一个类和指定方法
-keep class com.example.MyClass {
void reflectedMethod();
}
# 只约束指定成员;宿主类本身不可达时,整个类仍可删除
-keepclassmembers class com.example.MyClass {
java.lang.String reflectedField;
}
# 匹配包和全部子包;范围很宽,应尽量避免长期使用
-keep class com.example.feature.** { *; }
# 匹配某个接口的所有实现,并只保留构造函数
-keep class * implements com.example.Plugin {
public <init>();
}
这里的 * 通常不跨包分隔符,** 可以跨包层级匹配;但像 class * implements ... 这样单独使用 * 时,它等价于 **,会覆盖所有包。extends 和 implements 可以按继承关系匹配,注解和 -if 则可以继续增加条件。匹配范围越宽,命中的类和成员通常越多,但是否能删除、改名或优化,还要看第二个维度。
维度二:shrinking、obfuscation 和 optimization 的控制
没有 allow* modifier 的普通 -keep,默认会同时限制删除、改名和代码优化。三个 modifier 可以分别把能力还给 R8:
# 允许删除未使用代码,但仍限制改名和优化
-keep,allowshrinking class com.example.OptionalEntry { *; }
# 允许改名,但仍不允许删除或优化
-keep,allowobfuscation class com.example.Factory {
<init>();
}
# 保留成员,但允许 R8 继续优化方法体
-keepclassmembers,allowoptimization class com.example.MyClass {
void callback();
}
# 只要求最终存活的类保持名称;允许删除和代码优化
-keep,allowshrinking,allowoptimization class com.example.NamedEntry
# 接口实现由动态注册发现,但运行时不依赖实现类原名
-keep,allowobfuscation,allowoptimization class * implements com.example.Plugin {
public <init>();
}
allowshrinking 允许删除不可达目标,allowobfuscation 允许改名,allowoptimization 允许内联、合并等代码优化;三者可以组合使用。上面的接口示例允许实现类改名和优化,但没有 allowshrinking,因为动态注册场景仍要求这些实现类存在。如果运行时还依赖实现类原名,就不能加 allowobfuscation。
这两个维度要一起考虑:先把类和成员匹配到最小范围,再只限制动态契约真正依赖的能力。真正应该避免的是长期使用 -keep class some.package.** { *; } 这类没有动态入口证据、同时关闭三类处理的整包兜底。
R8 Configuration Analyzer
如何生成报告
AGP 9.3 及以上可以直接运行独立任务,不必先完整生成 APK 或 AAB:
./gradlew :app:analyzeReleaseR8Config
默认报告位置是:
app/build/reports/r8/r8-config-analyzer-release.html
完整 Release 构建也会在 mapping 目录生成报告:
app/build/outputs/mapping/release/configanalyzer.html
AGP 9.2 及更早版本,可以在开启 R8 的构建任务上增加系统参数:
./gradlew :app:minifyReleaseWithR8 --no-daemon \
-Dcom.android.tools.r8.dumpkeepradiushtmltodirectory=/tmp/r8analysis
分析时必须固定代码、依赖、R8 版本和构建变体。否则报告变化可能来自代码规模或工具链,而不是 Keep 规则本身。
报告怎么看
报告顶部的 Summary 会给出 Obfuscation、Optimization、Shrinking 三项整体 score,并拆分 Classes、Fields、Methods:
我们的基线报告中,三项整体 score 都在 65.6%~65.8% 左右。最醒目的不是 Classes 和 Methods,而是 Fields:只有约 39.7% 的字段可以参与处理。
继续下钻到 Project 区域,可以按单条 Keep 规则查看 Total、Classes、Fields、Methods,以及这条规则阻止了 OBFUSCATE、OPTIMIZE、SHRINK 中的哪些能力:
优化前的 Top 规则很有代表性:前两条 Markdown 规则高度重叠;第三条是作用于全工程的 Serializable 成员规则;后面则是 CameraX、业务 Markdown 上层和图片编辑库的整包 Keep。
例如第一条 Markdown 规则只命中 494 个类,却命中 65,747 个字段。原因是工程当时仍使用传递式 R class,整包 Keep 把大量 R/R$* 字段也一起保住了。只看“这个库有几百个类”时,很难意识到真正的影响来自后面那 6 万多个字段。
报告还会显示规则来源、重叠和 subsumed 关系。来源很重要,因为一条规则可能来自 App、本地 Library、第三方 AAR 或 AGP 默认配置;只删除源码仓里的一份,并不代表它已经从最终配置消失。
这里还要划清一个边界:Play Console 和 Analyzer 使用了相同的三项名称,但 Google 没有公开 Play Console 卡片的完整分母、权重和统计阶段。线上 46%/47%/47% 与本地约 65.7% 的差异显然不只是取整,因此 Analyzer 适合做本地同口径 A/B,不适合用来预测 Console 上线后的精确数字。
分数和统计口径
Analyzer 在 R8 初始可达性分析后统计三类 live item:存活的 class、reachable/referenced field,以及 live/targeted method。类、字段、方法各算一个 item,不按字节数或方法体大小加权。
T = 存活的 class + field + method
B_X = 被至少一条 Keep 规则阻止能力 X 的去重 item 数
Score_X = 100 × (T - B_X) / T
这里的 X 分别是 SHRINK、OPTIMIZE 和 OBFUSCATE。一个 item 只要被任意一条规则加上对应的 DONT_X 约束,就会进入 B_X;被三条规则重复命中,在整体分数里也只算一次。
这带来三个容易误解的结论:
- 单条规则的百分比不能直接相加,因为规则之间可能高度重叠;
- 删除一条显示 15% 的规则,总分不一定提高 15%,因为同一批 item 可能仍被其他规则保护;
- 删除规则后,R8 会重新计算可达性和优化结果,live item 分母也可能变化。
所以 Analyzer score 衡量的是配置允许 R8 处理多少代码节点,不是包体积,也不是运行时性能分数。最终收益仍要回到 mapping、DEX、资源和 APK/AAB 做同条件对比。
从报告到规则优化
拿到排名后不能直接从上往下删。报告给的是收益线索,真正决定能不能动的是动态入口和回归成本。
我们的处理过程可以压缩成四步:
- 固定线上版本对应的源码、依赖、R8 和 Release 变体,保存基线报告;
- 先追踪规则来源,再看 class、field、method 数量和重叠关系;
- 审计反射(Gson序列化)、Native调用Java/Kotlin、
ServiceLoader、Java Serialization、@JavascriptInterface和官方 consumer rules; - 每次只收敛一个规则族,重新生成报告,再用真正经过 R8 的 APK/AAB 回归受影响功能。
这里最容易犯的错误,是看到“代码里没搜到 Class.forName”就认为规则没用。Manifest、AAPT、序列化、JNI 和跨版本本地数据,都可能构成 R8 静态分析看不见的入口。
几条关键规则的评估过程如下。
Markdown:两条 Top 规则为什么要一起看。 第一条 -keep class ... { *; } 已经保留类和全部成员,第二条 -keepclassmembers ... { *; } 与它高度重叠。只删第二条几乎不会有收益,所以我们把它们作为一个规则族处理。随后检查字符串反射、Java Serialization、JNI、ServiceLoader 和库内插件注册,没有发现依赖包内类名或成员名的开放式动态访问,最终删除整包规则,只保留可以枚举的插件接口和工厂契约。
Serializable:规则合理,不代表当前工程需要。 这条规则本身是标准 Java Serialization 的防护网,会保留实现 Serializable 类的字段以及 writeObject、readObject、writeReplace、readResolve 等特殊入口。我们没有直接删除,而是先追踪来源,发现它来自一个已无业务调用的 Cookie 持久化依赖;再反编译最终 APK,核对 ObjectOutputStream、ObjectInputStream、Bundle/Intent、磁盘持久化和 WorkManager Data。最终选择删除无调用依赖,让规则随依赖一起消失,而不是在 App 侧粗暴屏蔽规则。
CameraX:依赖官方规则,但风险要在真机验证。 本地曾额外 Keep 整个 androidx.camera.**。CameraX 官方 AAR 已经为 Provider 和设备 Quirk 提供了更窄的 consumer rules,App 也没有按类名反射 CameraX 接口,因此本地整包规则可以删除。不过真正的风险集中在厂商 Quirk,仍然需要用真机覆盖权限、前后摄、对焦、闪光灯、拍照和前后台切换。
业务 Markdown 上层:从整包 Keep 收窄到真实反射点。 代码里确实存在 Class.forName 和字段名访问,但目标可以确定到单个 Span 类和一个字段。这里不能直接删除规则,而是改成定点 -keep / -keepclassmembers。Activity 由 Manifest/AAPT 处理,Parcelable 的 CREATOR 由 Android 默认规则处理,不再连带 Keep 整个包。
图片编辑库:先区分“库有能力”和“产品有入口”。 AAR 自带的整包规则覆盖 Manifest 组件、自定义 View 和 Parcelable 数据。我们逐项检查反射、XML、JNI、ServiceLoader 以及真实产品调用链,确认平台规则已经覆盖必要入口,而且当前图片编辑链路基本不可达,才过滤宽泛 consumer rules。依赖升级后仍需重新审计,不能把这次结论永久化。
另一条经验是先看最终合并产物,而不是只 grep 仓库里的 .pro 文件。configuration.txt 用来确认规则最终是否生效及其来源,mapping.txt、seeds.txt、usage.txt 用来对照保留和删除结果;APK Analyzer 或 apkanalyzer 用来确认 DEX 与资源体积,JADX 则用来审计最终产物里的反射和序列化。
优化效果
先看 R8 Configuration Analyzer 的三项 score:
| 指标 | 基线 | 优化后 | 提升 |
|---|---|---|---|
| Optimization | 65.5596% | 88.8070% | +23.2474 pp |
| Obfuscation | 65.7036% | 88.9963% | +23.2927 pp |
| Shrinking | 65.7758% | 89.0617% | +23.2859 pp |
其中第一阶段只处理两条 Markdown 整包规则,三项 score 就从约 65.7% 提升到约 77.6%。后续再逐步收敛 CameraX、业务 Markdown 上层、图片编辑库、全局 Serializable 和重复规则。
在同一源码、依赖和 internalRelease 变体上做 A/B,APK 和 AAB 的结果是:
| 产物 | 基线 | 优化后 | 变化 |
|---|---|---|---|
| APK | 41,542,320 B | 39,725,322 B | -1.73 MiB(-4.37%) |
| AAB | 67,233,080 B | 65,795,743 B | -1.37 MiB(-2.14%) |
主要收益来自 DEX 和被整包规则连带保留的资源:
| 项目 | 基线 | 优化后 | 变化 |
|---|---|---|---|
| APK DEX 压缩体积 | 9,502,010 B | 8,740,842 B | -761,168 B(-8.01%) |
| APK DEX 原始体积 | 20,928,632 B | 18,113,140 B | -2,815,492 B(-13.45%) |
APK resources.arsc | 2,757,264 B | 1,911,584 B | -845,680 B(-30.67%) |
AAB resources.pb 压缩体积 | 891,193 B | 527,622 B | -363,571 B(-40.80%) |
| DEX 文件数 | 3 | 2 | -1 |
Analyzer score 的增量不能直接换算成包体收益。score 按节点计数,包体按字节计数,中间还会受到代码结构、资源引用、压缩率和 R8 后续优化决策影响。两组数据放在一起,是为了同时观察“配置放开了多少”和“最终产物实际少了多少”。
用规则约束 AI 添加 Keep
这次治理还有一个后续问题:人会因为排查成本高而加整包 Keep,AI 也一样。只要提示词是“修复 Release 混淆崩溃”,模型很容易选择最稳妥、同时也最宽泛的方案:
-keep class com.example.** { *; }
-dontwarn com.example.**
短期看,它可能让构建或运行恢复;长期看,它把真实动态入口藏起来,还会持续阻塞 shrinking、optimization 和 obfuscation。
我们最终把 Keep 规则治理原则写进 Android 项目的 AI 协作约束,核心只有几条:
- 先证明具体动态入口,再新增规则;“Release 可能出问题”不能作为证据;
- 禁止无证据的整包 Keep、全局成员 Keep 和宽泛
-dontwarn; - 只限制必要能力:只需保名就放开 shrinking/optimization,只反射一个成员就只 Keep 该成员;
- 先检查 Manifest、XML、Parcelable 和官方 AAR 是否已经提供保护;
- Library 的动态契约写进 owner 模块的 consumer rules,App 不重复兜底;
- 提交前检查最终
configuration.txt,并用 minified APK/AAB 回归,不能只看构建成功或 Analyzer score。
不过,把所有细节直接塞进 AGENTS.md 也会带来新的问题:每个 Android 任务都会把一大段 R8 规则读进上下文,即使它只是改一行 UI。
所以我们采用了一个简单的懒加载结构:
apps/android/AGENTS.md
└── .agents/references/r8-keep-rules.md
AGENTS.md 只保留关键词路由和两条硬约束:任务涉及 R8、ProGuard、Keep、consumer rules、混淆或包体优化时,必须完整读取详细规则;详细审计完成前,禁止新增或放宽整包规则。真正的动态入口、模块归属、ignoreFrom、dontwarn 和验证要求放在 reference 中,只有命中相关任务才读取。
这不是另一套神秘的 rules 引擎,而是利用 AGENTS.md 作为稳定入口,把低频、长篇规则做成按需引用。这样既能约束 AI 不再顺手加过度 Keep,也不会让所有 Android 任务都承担额外上下文成本。
写到最后
从21~22 年魔改 ProGuard 到现在,我们解决问题的思路一直没变,就是先从ROI高的规则入手,所以我们需要每条Keep规则Keep的类,字段和方法的数据去给我们提供优先级决策。R8 Configuration Analyzer 把这件事从Progurad补丁变成了官方能力, 但Keep数据并不会替我们做修改的风险判断,一条规则命中上万个成员,不代表它一定能删,如果能删除,那删除之后有哪些风险,是否需要回填更小范围的Keep规则,这些需要更精细的分析。
我们大致可以从 反射、ServiceLoader、Java Serialization、Gson序列化、Native反射Java 、@JavascriptInterface 等等维度让AI进行分析,确定移除后的风险和合理Keep规则。
理想的目标是让每一条留下来的 Keep 规则都是最小粒度的必要Keep, 包体、启动和内存上的收益只是这件事做对之后顺带发生的结果。