Android R8 混淆与 Baseline Profile

R8 与 Baseline Profile 是 release 包性能的两个编译期杠杆:一个把无用代码删掉并内联,一个把启动关键路径提前 AOT 编译。本文讲透 R8 的收缩优化混淆三段流程、keep 规则编写与反射误伤排查、资源收缩、mapping 与崩溃还原、Baseline Profile 的生成集成、Play 云端 profile 分发与 Macrobenchmark 验证。

引言

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 性能优化与启动加速 ;本文聚焦编译期做了什么、为什么这么做。


目录

  1. R8 在构建链路中的位置
  2. 开启收缩与优化的配置
  3. keep 规则编写
  4. 常见误伤与排查
  5. 资源收缩
  6. mapping 文件与崩溃还原
  7. Baseline Profile 的工作原理
  8. 生成与集成 Baseline Profile
  9. Cloud Profiles 与 Play 分发
  10. 用 Macrobenchmark 验证收益
  11. 与 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 下字段全为 nullkeep 数据模型类
Retrofit 接口IllegalArgumentException: No Retrofit annotation foundRetrofit 2.11+ 自带 consumer rules,低版本需手动 keep
枚举 valueOfNoSuchMethodExceptionkeep 枚举的 values/valueOf
反射构造 FragmentInstantiationExceptionkeep 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 ConsoleAAB 内自带 mapping,控制台自动还原上传 AAB 即可,无需额外操作
Crashlytics在 CrashlyticsExtension 上设 mappingFileUploadEnabled = true需要 firebase-crashlytics 插件
自建崩溃平台-printmapping 或读取 mapping.txtCI 中把文件归档并随版本索引

要让栈轨迹保留行号,还需要两行规则:

-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)。它的作用链路是:

  1. 构建时把 baseline-prof.txt 打进 APK/AAB。
  2. 安装时(Android 12+ 由系统安装器,低版本由 ProfileInstaller 库)把 profile 交给 ART。
  3. ART 在后台按 profile 做 AOT 编译(dex2oat),生成 .odex/.vdex。
  4. 冷启动时,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。它的覆盖面更广,且会随用户行为变化自动更新。

启用步骤:

  1. 应用已发布到 Play 且有一定用户量(聚合需要足够的采样)。
  2. Play Console → 发布 → 概览 → 性能 → 基准配置文件,开启 Cloud Profiles。
  3. 应用需引入 androidx.profileinstaller(AGP 会自动加,但需确认未被 exclude)。

两者的关系不是替代而是叠加:

维度Baseline ProfileCloud Profile
来源开发者编写的启动路径真实用户聚合
覆盖启动与主流程真实高频路径
更新随版本发布Play 定期下发,无需发版
生效时机安装后首次编译应用重启后逐步生效
依赖baseline-prof.txtprofileinstaller + 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 下漏了枚举 keepNoSuchMethodExceptionkeep values/valueOf
Gson 模型未 keeprelease 下字段全为 null-keep class ...model.**
反射构造 Fragment 未 keepInstantiationExceptionkeep Fragment 子类
宽泛 -dontwarn **掩盖真实缺失引用精确到包名
缺 LineNumberTable崩溃栈全是 Unknown Source-keepattributes SourceFile,LineNumberTable
mapping 未归档线上崩溃无法定位CI 按版本归档 mapping
Baseline Profile 不更新收益随迭代衰减到零每次重大变更重新生成
profile 只覆盖首页二级页面依旧慢生成时走完主流程
用 Full() 当优化上限高估收益用 Partial() 对照
在模拟器上测 profile 收益数据不可信真机、温度稳定、10 次取中位
-dontobfuscate 进了生产包体增大且易逆向只用于本地排查

小结

R8 与 Baseline Profile 的落地要点可以收成四句话:

  1. R8 的收益来自删除与内联:收缩删掉不可达代码,优化把调用链缩短,混淆只负责改名字——顺序决定了「为什么开了混淆才崩」。
  2. keep 规则要精准:优先 -if 条件保留,慎用宽泛 -dontwarn,用 configuration.txt 确认规则真的生效。
  3. profile 必须随版本更新:Baseline Profile 是快照,不是一次配置;生成时覆盖主流程,并把 BaselineProfileMode.Require 放进 CI 防回归。
  4. 一切以真机测量为准:Macrobenchmark 的 Partial 与 None 对照才是收益的真相,模拟器与 Full() 都会误导。

两个杠杆叠加使用时记住一件事:它们优化的路径高度重叠(都是启动),因此必须重新测量——把 R8 做满之后再评估 profile 的边际收益,比同时上马再猜哪个起作用要靠谱得多。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android 测试体系:单元测试、Espresso 与 Compose 测试
  3. WorkManager 后台任务调度