引言
release 包与 debug 包的性能差距,很多时候不是「调试器拖慢」那么简单,而是两个编译期杠杆在起作用:R8 把未被引用的类、方法与字段删掉,把调用链内联,把类名压短;Baseline Profile 则告诉 ART「启动时用到的这些方法请提前 AOT 编译」,避免每次冷启动都靠解释执行和 JIT 现编。
两者的收益都是可量化的:R8 全量优化通常能砍掉 30%~50% 的方法数与 20%~40% 的包体,Baseline Profile 官方数据是冷启动提升 20%~30%、卡顿减少 10%~20%。但两者也都容易踩坑:R8 会误删反射调用的类,表现是「debug 正常、release 崩溃」;Baseline Profile 不随版本更新就会失效,几个月后收益归零。
本文按「R8 流程 → keep 规则 → 排查手段 → Baseline Profile → 分发与验证」的顺序展开。启动耗时的测量方法与链路拆解不在本文重复,见 Android 性能优化与启动加速 ;本文聚焦编译期做了什么、为什么这么做。
目录
- R8 在构建链路中的位置
- 开启收缩与优化的配置
- keep 规则编写
- 常见误伤与排查
- 资源收缩
- mapping 文件与崩溃还原
- Baseline Profile 的工作原理
- 生成与集成 Baseline Profile
- Cloud Profiles 与 Play 分发
- 用 Macrobenchmark 验证收益
- 与 Compose、KMP 的配合
1. R8 在构建链路中的位置
R8 是 AGP 内置的代码处理工具,取代了早期的 ProGuard + D8 组合。它在 dex 生成之前对 class 文件做四件事,顺序固定:
| 阶段 | 做什么 | 典型收益 |
|---|---|---|
| 收缩(shrinking) | 删除不可达的类、方法、字段 | 方法数大幅下降 |
| 优化(optimization) | 内联、类合并、常量传播、移除未用参数 | 调用链变短,运行更快 |
| 混淆(obfuscation) | 重命名类与成员为短名 | 包体更小,增加逆向成本 |
| 脱糖(desugaring) | 把新语法降级到目标 API | 兼容旧版本 |
顺序很重要:收缩与优化在前,混淆在后。这解释了一个常见困惑——为什么开了混淆后某些反射代码才崩溃:因为收缩阶段已经把「看起来没人用」的类删了,混淆只是把剩下的改了个名字。
AGP 8.0 起默认启用 R8 full mode。full mode 相比兼容模式(compat mode)更激进:它不再为「通过反射访问」的代码保留默认规则,也不再默认保留所有 enum 的 values()/valueOf()。收益是包体更小,代价是必须自己补 keep 规则,否则运行时抛 ClassNotFoundException 或 NoSuchMethodException。
# gradle.properties
android.enableR8.fullMode=true # AGP 8.0+ 默认即为 true,显式写出便于排查
2. 开启收缩与优化的配置
release 构建的配置是起点,缺一项收益就少一截:
android {
buildTypes {
release {
isMinifyEnabled = true // 开启 R8 收缩 + 优化 + 混淆
isShrinkResources = true // 开启资源收缩(依赖上一项)
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
proguard-android-optimize.txt 与 proguard-android.txt 的区别在于前者启用了优化(内联、类合并),后者只做收缩与混淆。现代项目应统一用 optimize 版本;只有遇到 R8 优化导致的诡异 bug(极少见)才临时降级。另外记住 isMinifyEnabled = false 时 isShrinkResources = true 不生效(构建会给出警告),这是最常见的「资源没被裁掉」的原因。
3. keep 规则编写
keep 规则的语法只有五六个关键字,但组合出的语义差别很大:
| 规则 | 保留对象 | 是否保留名字 |
|---|---|---|
-keep | 类与成员 | 是(不混淆) |
-keepclassmembers | 仅成员(类本身可被删) | 是 |
-keepnames | 不删,但允许改名 | 否 |
-keepclasseswithmembers | 含指定成员的类 | 是 |
-keepattributes | 元数据(注解、泛型签名) | 不适用 |
实践中最常用的四条:
# 1. 数据模型:被 Gson/Jackson 反射序列化时必须保留字段名
-keep class com.example.app.model.** { *; }
# 2. 注解与泛型签名:反射读注解、Retrofit 解析泛型返回值都依赖它们
-keepattributes *Annotation*, Signature, InnerClasses, EnclosingMethod
# 3. 枚举的 values()/valueOf():full mode 下不再默认保留
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
# 4. 原生方法:JNI 按名字查找,改名即崩溃
-keepclasseswithmembernames class * {
native <methods>;
}
条件保留(-if)能显著减少过度保留。它的语义是「如果存在 X,则保留 Y」,特别适合框架适配:
# 只有当类带有 @Serializable 注解时才保留其伴生对象
-if class * extends kotlinx.serialization.internal.GeneratedSerializer
-keep class <1> { *; }
-dontwarn 应该慎用。它会压制 R8 对「引用了不存在的类」的警告,最常见的场景是可选依赖(如某 SDK 引用了只在特定版本存在的类)。正确做法是精确到包:
-dontwarn com.example.optional.**
宽泛的 -dontwarn ** 会让真正的问题(本该报错的引用)静默通过,等到运行时才崩。
4. 常见误伤与排查
R8 的排查流程是固定的:先看 R8 到底删了什么,再看 keep 规则为什么没拦住。
./gradlew :app:minifyReleaseWithR8
# 产物位于 app/build/outputs/mapping/release/
# mapping.txt 类名 → 混淆名的映射
# usage.txt 被移除的代码(排查误删的第一站)
# seeds.txt 被保留的代码
# configuration.txt 实际生效的全部规则(含依赖库自带规则)
configuration.txt 是最有价值的文件:它把「你写的规则 + 所有 AAR 的 consumer rules + AGP 默认规则」合并展开,用来确认某条规则是否真的生效。
四类高频误伤:
| 误伤类型 | 现象 | 修法 |
|---|---|---|
| Gson 反射 | release 下字段全为 null | keep 数据模型类 |
| Retrofit 接口 | IllegalArgumentException: No Retrofit annotation found | Retrofit 2.11+ 自带 consumer rules,低版本需手动 keep |
枚举 valueOf | NoSuchMethodException | keep 枚举的 values/valueOf |
| 反射构造 Fragment | InstantiationException | keep Fragment 子类 |
一个实用的技巧是「先用 -dontobfuscate 验证是不是混淆导致的」:
# 临时加在 proguard-rules.pro,只关混淆、保留收缩与优化
-dontobfuscate
如果加上这行问题消失,说明是名字被改了(需要 -keepnames);如果问题依旧,说明是收缩删掉了代码(需要 -keep)。这一步能把排查范围缩小一半。
5. 资源收缩
资源收缩由 isShrinkResources = true 开启,它依赖代码收缩的结果:R8 找出代码中引用到的资源 id,未引用的 drawable、layout、string 被移除。但有两类资源它看不到:
- 运行时按名字取的资源:
resources.getIdentifier("icon_$name", "drawable", pkg)。 - 被
res/raw/keep.xml之外的配置引用的资源:如通过assets中的 JSON 指定。
解决办法是显式声明保留:
<!-- app/src/main/res/raw/keep.xml -->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/icon_*,@string/dynamic_*"
tools:discard="@layout/unused_debug_*"
tools:shrinkMode="strict" />
shrinkMode 有两个取值:
| 模式 | 行为 | 风险 |
|---|---|---|
safe(默认) | 只删除确定未被引用的资源 | 保守,可能残留 |
strict | 结合 keep.xml 声明精确删除 | 过度删除会导致运行时资源缺失 |
strict 模式在大型项目中收益可观(尤其多语言 strings.xml),但必须配合完整的 tools:keep 清单。建议先用 safe 跑一轮,通过 ./gradlew :app:analyzeReleaseResources(或构建日志)确认被删清单合理后再切 strict。
6. mapping 文件与崩溃还原
混淆后的崩溃栈长这样,没有 mapping 完全无法定位:
java.lang.NullPointerException
at a.b.c.d(SourceFile:2)
at com.example.app.e.a(Unknown Source:15)
上传 mapping 是 release 流程的必做项,三个渠道:
| 渠道 | 配置方式 | 说明 |
|---|---|---|
| Play Console | AAB 内自带 mapping,控制台自动还原 | 上传 AAB 即可,无需额外操作 |
| Crashlytics | 在 CrashlyticsExtension 上设 mappingFileUploadEnabled = true | 需要 firebase-crashlytics 插件 |
| 自建崩溃平台 | -printmapping 或读取 mapping.txt | CI 中把文件归档并随版本索引 |
要让栈轨迹保留行号,还需要两行规则:
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile # 隐藏原始文件名,同时保留行号
缺 LineNumberTable 时崩溃栈只有 Unknown Source,只有方法名可用;缺 -renamesourcefileattribute 时原始源文件名会泄漏。两者一起用,既保留可定位性又不暴露文件结构。完整的上架流程与 mapping 上传细节见 Google Play 上架与签名打包
。
7. Baseline Profile 的工作原理
Android 7.0 之后,应用安装时不再做全量 AOT 编译,而是「部分编译 + 运行时 JIT」。好处是安装快、包体小,代价是冷启动时要靠解释执行与 JIT 现编,首屏因此变慢。
Baseline Profile(基线配置文件)就是一份「哪些方法值得提前编译」的清单,文件格式是一行行的方法签名:
HSPLcom/example/app/MainActivity;->onCreate(Landroid/os/Bundle;)V
HSPLcom/example/app/ui/HomeScreenKt;->HomeScreen(Landroidx/compose/runtime/Composer;I)V
三个字母前缀的含义分别是编译标志、优先级与编译原因(H 表示 hot、S 表示 startup、P 表示 post-startup)。它的作用链路是:
- 构建时把
baseline-prof.txt打进 APK/AAB。 - 安装时(Android 12+ 由系统安装器,低版本由
ProfileInstaller库)把 profile 交给 ART。 - ART 在后台按 profile 做 AOT 编译(
dex2oat),生成.odex/.vdex。 - 冷启动时,profile 覆盖的方法直接执行编译后的机器码,跳过解释与 JIT 预热。
因此有个关键结论:Baseline Profile 不减小包体,反而略微增大(多了 profile 文件与 AOT 产物);它买的是启动速度。这与 R8 的收益方向正好相反,两者叠加时需要重新测量,避免把同一段路径优化两遍。
8. 生成与集成 Baseline Profile
生成需要一个独立的 baselineprofile 测试模块,用 BaselineProfileRule 模拟真实启动路径:
// baselineprofile/build.gradle.kts
plugins {
alias(libs.plugins.android.test) // com.android.test
alias(libs.plugins.baselineprofile) // androidx.baselineprofile
}
android {
namespace = "com.example.baselineprofile"
compileSdk = 35
defaultConfig { minSdk = 28; targetSdk = 35 }
targetProjectPath = ":app"
}
dependencies {
implementation("androidx.benchmark:benchmark-macro-junit4:1.3.4")
}
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test
fun generate() = rule.collect(packageName = "com.example.app") {
pressHome()
startActivityAndWait() // 覆盖冷启动路径
device.findObject(By.text("详情")).click() // 覆盖二级页面
device.waitForIdle()
}
}
生成与集成命令:
./gradlew :app:generateReleaseBaselineProfile # 生成并写入 app/src/release/generated/baselineProfiles/
./gradlew :app:assembleRelease # 打包时自动包含 profile
覆盖率是收益的关键:profile 只覆盖了首页,二级页面的启动路径就没有优化。实践建议是把主流程(启动 → 首页 → 核心二级页 → 返回)都走一遍,并在每次重大功能变更后重新生成。androidx.profileinstaller:profileinstaller:1.4.0 会被 AGP 自动引入,负责在 API 30 及以下安装 profile。
一个容易被忽略的细节:Baseline Profile 只覆盖启动路径。对「首页加载完之后的滚动卡顿」,它帮助有限——那属于运行期优化,需要靠 Cloud Profiles 或代码层面的重组优化解决。
9. Cloud Profiles 与 Play 分发
Baseline Profile 是开发者「猜测」的启动路径,Cloud Profiles 则是 Google 从真实用户的执行轨迹聚合出来的 profile。它的覆盖面更广,且会随用户行为变化自动更新。
启用步骤:
- 应用已发布到 Play 且有一定用户量(聚合需要足够的采样)。
- Play Console → 发布 → 概览 → 性能 → 基准配置文件,开启 Cloud Profiles。
- 应用需引入
androidx.profileinstaller(AGP 会自动加,但需确认未被exclude)。
两者的关系不是替代而是叠加:
| 维度 | Baseline Profile | Cloud Profile |
|---|---|---|
| 来源 | 开发者编写的启动路径 | 真实用户聚合 |
| 覆盖 | 启动与主流程 | 真实高频路径 |
| 更新 | 随版本发布 | Play 定期下发,无需发版 |
| 生效时机 | 安装后首次编译 | 应用重启后逐步生效 |
| 依赖 | baseline-prof.txt | profileinstaller + Play 服务 |
推荐做法是两者都开:Baseline Profile 保证「装完第一次启动」就快,Cloud Profile 保证「用一段时间后」整体更快。这也是官方在 Android 12 之后主推的组合。
10. 用 Macrobenchmark 验证收益
优化必须有对照,Macrobenchmark 通过 CompilationMode 控制编译状态,把「有 profile」与「无 profile」的差异量化出来:
@Test
fun startup_withBaselineProfile() = benchmarkRule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.Partial(BaselineProfileMode.Require), // 强制使用 profile
) {
pressHome()
startActivityAndWait()
}
// 对照用例把 compilationMode 换成 CompilationMode.None(),其余完全相同
两个测试跑完对比 timeToInitialDisplayMs 与 timeToFullDisplayMs,差值就是 profile 的真实收益。BaselineProfileMode.Require 会在 profile 缺失时直接失败,适合放进 CI 作为「profile 必须存在」的门禁。
CompilationMode 的三种取值决定了测量口径:
| 取值 | 含义 | 用途 |
|---|---|---|
None() | 不预编译,全靠解释与 JIT | 最差基线 |
Partial() | 使用已安装的 profile | 真实用户场景 |
Full() | 全量 AOT | 理论上限 |
注意测量必须在真机上进行且设备温度稳定:模拟器的编译行为与真机差异很大,Full() 模式的耗时也常被误读成「优化上限」——真实用户永远不会达到它。
11. 与 Compose、KMP 的配合
Compose 项目有两个额外注意点。一是 Compose 编译器生成的代码量大,R8 的收缩收益比 View 体系更明显,但 Compose 的 @Composable 函数通过 Composer 参数传递,内联与类合并更容易触发 keep 规则的边界问题;正常情况下不需要为 Compose 写 keep 规则,AGP 与 Compose 的 consumer rules 已经处理。
二是 Baseline Profile 对 Compose 首帧的收益尤其大,因为首帧要执行的组合函数数量多、JIT 预热成本高。生成 profile 时应把首屏的关键 Composable 路径都覆盖到。
KMP 项目则要注意源集与产物对齐:共享模块编译出的 klib 与 Android 侧的 AAR 是两套产物,R8 只作用于 Android 侧最终 dex,因此共享模块的反射使用(如 kotlinx.serialization 的序列化器查找)必须在 Android 侧有对应的 keep 规则。这部分与模块拆分、构建产物对齐的整体思路相关,可参考 Android 多模块架构与 Gradle 优化 ;跨平台构建的可复现性问题则与 可复现构建 中的讨论同源。
权衡取舍
| 手段 | 收益方向 | 成本 | 何时用 |
|---|---|---|---|
| R8 收缩 + 优化 | 包体、方法数、启动 | keep 规则维护、排查成本 | 所有 release 构建,必开 |
| R8 混淆 | 包体、逆向成本 | 崩溃需 mapping 还原 | 所有 release 构建,必开 |
资源收缩 safe | 包体 | 几乎无 | 默认开启 |
资源收缩 strict | 包体(多语言场景明显) | 需要完整 keep 清单 | 资源量大且能维护清单时 |
| Baseline Profile | 冷启动 20%~30% | 需独立模块与重新生成流程 | 冷启动是核心指标时 |
| Cloud Profiles | 真实路径优化 | 需上架且有用户量 | 应用已发布,建议开启 |
-dontobfuscate | 便于调试 | 包体变大、可逆向 | 只用于临时排查,不进生产 |
取舍的核心判断是「优化的是体积还是速度」:R8 两头都赚,Baseline Profile 只赚速度且略增体积。当包体是硬约束(如新兴市场、低端机型分发)时,应优先把 R8 的收益吃满,再评估 profile 的体积代价。
常见坑清单
| 坑 | 现象 | 规避方式 |
|---|---|---|
只开 isShrinkResources 没开 isMinifyEnabled | 资源没被裁掉 | 两项一起开 |
| full mode 下漏了枚举 keep | NoSuchMethodException | keep values/valueOf |
| Gson 模型未 keep | release 下字段全为 null | -keep class ...model.** |
| 反射构造 Fragment 未 keep | InstantiationException | keep Fragment 子类 |
宽泛 -dontwarn ** | 掩盖真实缺失引用 | 精确到包名 |
缺 LineNumberTable | 崩溃栈全是 Unknown Source | -keepattributes SourceFile,LineNumberTable |
| mapping 未归档 | 线上崩溃无法定位 | CI 按版本归档 mapping |
| Baseline Profile 不更新 | 收益随迭代衰减到零 | 每次重大变更重新生成 |
| profile 只覆盖首页 | 二级页面依旧慢 | 生成时走完主流程 |
用 Full() 当优化上限 | 高估收益 | 用 Partial() 对照 |
| 在模拟器上测 profile 收益 | 数据不可信 | 真机、温度稳定、10 次取中位 |
-dontobfuscate 进了生产 | 包体增大且易逆向 | 只用于本地排查 |
小结
R8 与 Baseline Profile 的落地要点可以收成四句话:
- R8 的收益来自删除与内联:收缩删掉不可达代码,优化把调用链缩短,混淆只负责改名字——顺序决定了「为什么开了混淆才崩」。
- keep 规则要精准:优先
-if条件保留,慎用宽泛-dontwarn,用configuration.txt确认规则真的生效。 - profile 必须随版本更新:Baseline Profile 是快照,不是一次配置;生成时覆盖主流程,并把
BaselineProfileMode.Require放进 CI 防回归。 - 一切以真机测量为准:Macrobenchmark 的
Partial与None对照才是收益的真相,模拟器与Full()都会误导。
两个杠杆叠加使用时记住一件事:它们优化的路径高度重叠(都是启动),因此必须重新测量——把 R8 做满之后再评估 profile 的边际收益,比同时上马再猜哪个起作用要靠谱得多。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。