包体积优化:如何优化R8 Keep 规则

143 阅读14分钟

写在前面

大家好,我是三雒。

前段时间,我们在 Google Play Console 的 Release dashboard 上看到了一条 R8 优化建议:

Google Play Console 的 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 ... 这样单独使用 * 时,它等价于 **,会覆盖所有包。extendsimplements 可以按继承关系匹配,注解和 -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:

R8 Configuration Analyzer 基线报告

我们的基线报告中,三项整体 score 都在 65.6%~65.8% 左右。最醒目的不是 Classes 和 Methods,而是 Fields:只有约 39.7% 的字段可以参与处理。

继续下钻到 Project 区域,可以按单条 Keep 规则查看 Total、Classes、Fields、Methods,以及这条规则阻止了 OBFUSCATE、OPTIMIZE、SHRINK 中的哪些能力:

优化前 Top Keep 规则

优化前的 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;被三条规则重复命中,在整体分数里也只算一次。

这带来三个容易误解的结论:

  1. 单条规则的百分比不能直接相加,因为规则之间可能高度重叠;
  2. 删除一条显示 15% 的规则,总分不一定提高 15%,因为同一批 item 可能仍被其他规则保护;
  3. 删除规则后,R8 会重新计算可达性和优化结果,live item 分母也可能变化。

所以 Analyzer score 衡量的是配置允许 R8 处理多少代码节点,不是包体积,也不是运行时性能分数。最终收益仍要回到 mapping、DEX、资源和 APK/AAB 做同条件对比。

从报告到规则优化

拿到排名后不能直接从上往下删。报告给的是收益线索,真正决定能不能动的是动态入口和回归成本。

我们的处理过程可以压缩成四步:

  1. 固定线上版本对应的源码、依赖、R8 和 Release 变体,保存基线报告;
  2. 先追踪规则来源,再看 class、field、method 数量和重叠关系;
  3. 审计反射(Gson序列化)、Native调用Java/Kotlin、ServiceLoader、Java Serialization、@JavascriptInterface 和官方 consumer rules;
  4. 每次只收敛一个规则族,重新生成报告,再用真正经过 R8 的 APK/AAB 回归受影响功能。

这里最容易犯的错误,是看到“代码里没搜到 Class.forName”就认为规则没用。Manifest、AAPT、序列化、JNI 和跨版本本地数据,都可能构成 R8 静态分析看不见的入口。

几条关键规则的评估过程如下。

Markdown:两条 Top 规则为什么要一起看。 第一条 -keep class ... { *; } 已经保留类和全部成员,第二条 -keepclassmembers ... { *; } 与它高度重叠。只删第二条几乎不会有收益,所以我们把它们作为一个规则族处理。随后检查字符串反射、Java Serialization、JNI、ServiceLoader 和库内插件注册,没有发现依赖包内类名或成员名的开放式动态访问,最终删除整包规则,只保留可以枚举的插件接口和工厂契约。

Serializable:规则合理,不代表当前工程需要。 这条规则本身是标准 Java Serialization 的防护网,会保留实现 Serializable 类的字段以及 writeObjectreadObjectwriteReplacereadResolve 等特殊入口。我们没有直接删除,而是先追踪来源,发现它来自一个已无业务调用的 Cookie 持久化依赖;再反编译最终 APK,核对 ObjectOutputStreamObjectInputStream、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.txtseeds.txtusage.txt 用来对照保留和删除结果;APK Analyzer 或 apkanalyzer 用来确认 DEX 与资源体积,JADX 则用来审计最终产物里的反射和序列化。

优化效果

先看 R8 Configuration Analyzer 的三项 score:

指标基线优化后提升
Optimization65.5596%88.8070%+23.2474 pp
Obfuscation65.7036%88.9963%+23.2927 pp
Shrinking65.7758%89.0617%+23.2859 pp

其中第一阶段只处理两条 Markdown 整包规则,三项 score 就从约 65.7% 提升到约 77.6%。后续再逐步收敛 CameraX、业务 Markdown 上层、图片编辑库、全局 Serializable 和重复规则。

在同一源码、依赖和 internalRelease 变体上做 A/B,APK 和 AAB 的结果是:

产物基线优化后变化
APK41,542,320 B39,725,322 B-1.73 MiB(-4.37%)
AAB67,233,080 B65,795,743 B-1.37 MiB(-2.14%)

主要收益来自 DEX 和被整包规则连带保留的资源:

项目基线优化后变化
APK DEX 压缩体积9,502,010 B8,740,842 B-761,168 B(-8.01%)
APK DEX 原始体积20,928,632 B18,113,140 B-2,815,492 B(-13.45%)
APK resources.arsc2,757,264 B1,911,584 B-845,680 B(-30.67%)
AAB resources.pb 压缩体积891,193 B527,622 B-363,571 B(-40.80%)
DEX 文件数32-1

Analyzer score 的增量不能直接换算成包体收益。score 按节点计数,包体按字节计数,中间还会受到代码结构、资源引用、压缩率和 R8 后续优化决策影响。两组数据放在一起,是为了同时观察“配置放开了多少”和“最终产物实际少了多少”。

用规则约束 AI 添加 Keep

这次治理还有一个后续问题:人会因为排查成本高而加整包 Keep,AI 也一样。只要提示词是“修复 Release 混淆崩溃”,模型很容易选择最稳妥、同时也最宽泛的方案:

-keep class com.example.** { *; }
-dontwarn com.example.**

短期看,它可能让构建或运行恢复;长期看,它把真实动态入口藏起来,还会持续阻塞 shrinking、optimization 和 obfuscation。

我们最终把 Keep 规则治理原则写进 Android 项目的 AI 协作约束,核心只有几条:

  1. 先证明具体动态入口,再新增规则;“Release 可能出问题”不能作为证据;
  2. 禁止无证据的整包 Keep、全局成员 Keep 和宽泛 -dontwarn
  3. 只限制必要能力:只需保名就放开 shrinking/optimization,只反射一个成员就只 Keep 该成员;
  4. 先检查 Manifest、XML、Parcelable 和官方 AAR 是否已经提供保护;
  5. Library 的动态契约写进 owner 模块的 consumer rules,App 不重复兜底;
  6. 提交前检查最终 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、混淆或包体优化时,必须完整读取详细规则;详细审计完成前,禁止新增或放宽整包规则。真正的动态入口、模块归属、ignoreFromdontwarn 和验证要求放在 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, 包体、启动和内存上的收益只是这件事做对之后顺带发生的结果。

参考资料