ProGuard 和 R8 的规则文件编写技巧

ProGuard 和 R8 的规则文件编写技巧

ProGuard 和 R8 的规则文件编写技巧


ProGuard 和 R8 的规则文件编写技巧


从一次 4MB 的体积回退说起


去年维护一个老牌金融类 App 时,我踩了一个相当经典的坑。团队把构建工具从 AGP 7.2 升级到 8.0,R8 的 full mode 默认开启,发版后线上崩溃率从 0.02% 飙到 1.8%。核心报错是 ClassNotFoundExceptionNoSuchMethodError,堆栈指向的类明明就在源码里。回滚版本、逐条比对规则文件,最后定位到问题:R8 full mode 的类合并(class merging)策略比兼容模式激进得多,我们项目里大量依赖反射调用的 SDK 在 ProGuard 时代能跑,到 R8 full mode 直接被打散重组。


这个 case 让我重新梳理了一遍 ProGuard/R8 规则的编写逻辑。说实话,很多 Android 开发者对规则文件的态度是"从 SDK 文档里复制粘贴,编译过了就不管",但 AGP 8 之后,这种策略越来越行不通。这篇文章想聊的,就是怎么写出真正靠谱、可维护的规则,而不是一堆 -keep 的堆砌。


规则解析的底层差异:为什么同样的文件在 ProGuard 和 R8 表现不同


先搞清楚一个基础事实:R8 兼容 ProGuard 的规则语法,但解析语义有差异。Google 在官方文档里写过这件事,但很多人没认真看。


ProGuard 的规则文件是过程式的,规则按顺序执行,后写的规则可以覆盖前面的。R8 则做了大量优化,规则被解析成声明式的内部表示,某些情况下顺序不再严格保证。最直观的例子是 -keep-keepnames 的交互。ProGuard 里,如果你先写 -keep class com.example.__PLACEHOLDER_ITALIC_2__; } 再写 -keepnames class com.example.Foo,后者对 Foo 的保留会生效;R8 在某些版本里会把这两条合并优化,导致 Foo 的类名保留行为不符合预期。


我遇到的具体场景是:一个使用了动态代理的模块,需要保留接口名但允许混淆实现类。ProGuard 时代的写法是:


-keepnames interface com.example.api.** { *; }
-keep class * implements com.example.api.** { *; }

升级到 R8 后,这个接口在某些构建变体下还是被混淆了。查 R8 的 issue tracker(https://issuetracker.google.com/issues/265959057),发现这是 R8 规则合并的已知行为。修正方案是显式拆分,把接口保留单独写成:


-keep,allowobfuscation interface com.example.api.** { *; }

注意 allowobfuscationallowshrinking 的修饰符组合,这在 ProGuard 文档里存在但很少被强调,在 R8 里却是控制行为的关键。


另一个深层差异是 R8 的 full mode 会启用类合并和垂直类合并(vertical class merging)。ProGuard 只做水平合并(horizontal class merging,即把互相继承的类扁平化),R8 full mode 会把没有继承关系的类也合并,只要它们的字段和方法集合满足条件。这直接导致反射调用 Class.forName() 的代码大面积失效,因为目标类可能已经被合并进另一个类,原始类名从 dex 里消失了。


AGP 8.0 的 release note 里写了这件事,但埋在很后面。我的建议是:如果你的项目有大量反射(尤其是第三方 SDK 反射),升级 AGP 8 后第一件事是在 gradle.properties 里加 android.enableR8.fullMode=false,确认基线稳定后再逐步开启。这不是保守,是务实。


`-keep` 家族的真正区别:别再用 `-keep class` 打天下


规则文件里最容易被滥用的是 -keep 的各种变体。我见过太多项目里通篇 -keep class ... { *; },完全不管 -keepnames-keepclassmembers-keepclasseswithmembers 的区别。这种写法在小型项目里能跑,但体积优化几乎没做,而且容易掩盖真正的规则缺失。


逐个说清楚:


-keep 是最强保留:类名、方法名、字段名都不混淆,类也不被 shrink 掉。相当于对编译器说"这个类完整保留,别动"。


-keepnames 只保留类名,允许 shrink(如果类没被引用,整个类会被删掉)和混淆(如果类被保留下来,成员名可以混淆)。这个在序列化场景很有用,比如 Gson 的 SerializedName 注解类,类名需要保留给反射找,但字段本身有注解保护不需要额外 keep。


-keepclassmembers 保留类的成员,但类名本身可以混淆。适合那种"类名无所谓,但里面的方法签名必须匹配"的场景,比如 JNI 调用的 native 方法,或者某些框架要求的回调方法签名。


-keepclasseswithmembers 是条件保留:只有当类包含指定成员时,才保留整个类。这个在 ProGuard 时代很有用,比如保留所有带有 @Keep 注解的类。但 R8 对注解的处理有优化,实际效果可能不如预期,后面会专门讲。


-keepattributes 控制字节码属性的保留,比如 SignatureExceptionsInnerClassesEnclosingMethod。Gson、Jackson 这类库需要 Signature 来解析泛型类型,InnerClassesEnclosingMethod 则影响 Kotlin 的元数据(metadata)处理。AGP 8 之后 R8 默认会剥离更多属性,如果你的项目用了 Kotlin 反射或者 Ktor 序列化,可能需要显式加:


-keepattributes Signature, InnerClasses, EnclosingMethod, *Annotation*

注意 __PLACEHOLDER_ITALIC_0__ 这个写法,保留所有注解属性。R8 在某些版本里对注解的默认剥离策略比较激进,导致运行时注解查询返回空。


反射的防护策略:从黑名单到白名单


反射是 ProGuard/R8 规则编写里最头疼的部分,因为静态分析天然无法追踪反射调用。传统的做法是"哪里报错 keep 哪里",属于黑名单策略,维护成本高,而且线上崩溃往往是漏网之鱼。


更可靠的做法是建立白名单机制,把反射调用的目标集中管理。具体实现有几种路径:


路径一:注解驱动。 自定义 @KeepForReflection 注解,在规则文件里统一处理:


@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE, ElementType.METHOD, ElementType.FIELD})
public @interface KeepForReflection {
}

规则文件:

-keep @com.example.keep.KeepForReflection class * { *; }
-keepclassmembers class * {
    @com.example.keep.KeepForReflection <methods>;
    @com.example.keep.KeepForReflection <fields>;
}

这个方案的好处是反射需求在源码里显式声明,代码审查时能发现不合理的反射。缺点是注解本身需要保留,而且 R8 对注解的优化可能导致规则匹配失败。AGP 8.1 之后有个变化:R8 会尝试删除"明显没用"的注解,如果你的注解只被规则文件引用、在代码里没直接读取,Annotation 属性可能被剥离。保险起见,给注解类本身也加 @Retention(RUNTIME),并在规则里保留注解属性。


路径二:配置文件集中化。 不分散在各处的 -keep,而是建立一个 reflect-keep.pro 文件,按模块分类,配合 Gradle 的 consumerProguardFiles 让 library 模块自带规则。这是官方推荐的做法,但执行起来有坑:consumerProguardFiles 的规则只在 app 模块开启 minification 时生效,如果 library 单独测试(比如跑单元测试用 debug 构建且没开 minify),这些规则不会参与编译,可能掩盖问题。


我的实践是 library 模块里同时写 consumerProguardFiles 和测试用的显式规则,并在 CI 里加一个 minifyEnabled=true 的测试构建,强制验证规则完整性。


路径三:替代反射。 这是治本但成本高的方案。Kotlin 的 KClass、Java 的 MethodHandle、或者代码生成(KSP、APT)都可以消除部分反射。比如 Retrofit 和 Room 都是在编译期生成代码,运行时完全没有反射。如果你的项目里有大量手写的反射工具类,值得评估能否迁移到代码生成。


规则文件的调试:从黑箱到可见


写规则最难的是验证"这条规则到底生效没"。ProGuard 时代有个 -printmapping 输出混淆映射,-printseeds 输出保留的类,-printusage 输出被删除的代码。R8 继承了这些选项,但输出格式和细节有差异。


AGP 里开启这些输出的标准配置:


android.buildTypes.release {
    minifyEnabled true
    proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    consumerProguardFiles 'consumer-rules.pro'
    
    // 关键:开启详细输出
    proguardOptions '-printmapping', 'build/outputs/mapping/release/mapping.txt'
    proguardOptions '-printseeds', 'build/outputs/mapping/release/seeds.txt'
    proguardOptions '-printusage', 'build/outputs/mapping/release/usage.txt'
    proguardOptions '-printconfiguration', 'build/outputs/mapping/release/configuration.txt'
}

configuration.txt 特别有用,它输出 R8 最终使用的完整规则配置,包括所有 consumerProguardFiles 合并后的结果。如果你怀疑某个 library 的规则没生效,先查这个文件确认规则是否真的被包含。


R8 还提供了一个更强的诊断工具:`-whyareyoukeeping`。这个选项在 ProGuard 里不存在,是 R8 特有的。用法是:


-whyareyoukeeping class com.example.MyClass

构建输出里会打印这个类被保留的完整原因链,比如"被 com.example.OtherClass 的字段引用,而 OtherClass-keep 规则保留"。这对理清复杂的依赖关系极其有效,尤其是处理"这个类明明没写 keep 为什么还在"的疑惑。


另一个实用工具是 R8 的 trace references 功能。AGP 7.3 引入,命令行调用:


./gradlew app:traceReferences

它会分析你的 keep 规则是否过度保留,输出建议删除的规则。注意这是"建议",不是绝对正确,需要人工判断。我通常在新项目初始化或者大版本升级后跑一次,清理历史遗留的冗余规则。


第三方 SDK 的规则治理:不要复制粘贴


几乎每个 Android 项目都依赖大量第三方库,它们的官方文档里往往附带"推荐 ProGuard 规则"。我的建议是:不要盲目复制,理解后再决定


典型的问题有几种:


过度保留。 某知名推送 SDK 的官方规则里写 -keep class com.push.sdk.__PLACEHOLDER_ITALIC_1__; },把整个包名下所有类完整保留。实际分析下来,只有入口类和几个回调接口需要保留,内部实现完全可以混淆。这种过度保留在大型项目里可能浪费几百 KB 的 dex 体积。


规则过时。 很多 SDK 的规则是从 ProGuard 时代复制下来的,没针对 R8 优化。比如某支付 SDK 的规则里用了 -libraryjars,这在 R8 里是无效甚至有害的,因为 R8 的输入处理机制和 ProGuard 不同。


隐藏依赖。 某些 SDK 在运行时动态加载其他类,或者通过 content provider 自动初始化(AndroidX Startup 的滥用),这些路径在静态分析里不可见,官方规则也可能遗漏。需要自己用 -whyareyoukeeping 和运行时测试补全。


我的治理策略是:在项目里建立 third-party-rules/ 目录,每个 SDK 一个 .pro 文件,命名带版本号,比如 alipay-15.8.11.pro。文件头部注释写明规则来源(官方文档链接、issue 链接、自己的修改说明),定期(通常每个大版本升级后)重新审计。这个做法借鉴了 Square 的 okhttp 项目的规则管理方式,他们在 GitHub 仓库里维护了很干净的规则文件历史。


Kotlin 特有的陷阱:协程、内联、元数据


Kotlin 给 ProGuard/R8 规则带来了额外复杂度,主要是三个点。


协程的挂起函数。 Kotlin 编译器会把 suspend fun 转换成状态机,生成带有 $suspendImpl 后缀的桥接方法。这些方法在反射调用或者序列化时可能被直接引用,但规则文件里通常不会显式保留。Kotlin 标准库从 1.6 开始提供了官方规则 kotlinx-coroutines-core.pro,但某些边缘场景仍需要补充,比如自定义的 CoroutineContext.Element 实现类。


内联函数。 inline fun 在编译期被展开到调用点,原始方法可能被 R8 认为"没被直接调用"而删除或改名。如果内联函数里有 reified 泛型参数,编译器会生成额外的类型检查代码,引用原始类名。这类代码对混淆特别敏感。Kotlin 官方文档建议对内联函数所在的类加 @JvmName 或者显式 keep,但实际项目里更常见的问题是:内联函数里调用了某个内部 API,这个 API 被 R8 的垂直合并优化掉了,导致展开后的代码引用了一个不存在的类。


Kotlin 元数据(Metadata)。 Kotlin 编译器会在类文件里写入 @Metadata 注解,包含类名、方法签名、参数名等信息,供 Kotlin 反射使用。R8 默认会尝试保留或重写这些元数据,但处理逻辑有 bug 的历史。Kotlin 1.7 之前有个知名问题:R8 重写了 @Metadata 里的类名,但和实际 dex 里的类名不一致,导致 Kotlin 反射 Class.forName() 失败。解决方案是升级 Kotlin 版本,或者显式保留元数据:


-keepattributes RuntimeVisibleAnnotations
-keep class kotlin.Metadata { *; }

Kotlin 1.8 之后,Google 和 JetBrains 联合修复了大部分元数据兼容问题,但我的建议仍然是:如果项目重度依赖 Kotlin 反射(比如用了 Ktor client/server 的序列化),在 CI 里加专门的反射兼容性测试,覆盖常见的 KClassKType 操作。


体积优化的进阶:不要只算 dex 大小


写规则文件的一个隐藏目标是控制包体积,但评估维度不能只算 classes.dex


R8 的 -shrinkresources 选项(通常配合 minifyEnabled 一起开)会删除没被引用的资源文件,但它的判断依据是代码里的显式引用(R.drawable.xxx 或者 getResources().getIdentifier() 能追踪到的)。如果资源是通过反射或者运行时拼接名字加载的,-shrinkresources 会误判为无用而删除。


常见的坑:主题换肤系统、插件化框架、多语言动态加载。这些场景下需要在 res/raw/keep.xml 里显式声明保留:


<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:keep="@drawable/skin_*,@layout/plugin_*"
    tools:shrinkMode="strict" />

tools:shrinkMode="strict" 会让 R8 更保守,只保留明确引用的资源,但配合 tools:keep 可以精确控制。如果不加 shrinkMode,R8 的默认行为是"模糊匹配",可能保留大量实际上没用的资源。


另一个维度是 字符串常量池。R8 在混淆时会重写字符串引用,但如果规则文件里大量用了 -keep 保留完整类名,这些类名会以字符串形式留在 dex 的常量池里,无法被混淆压缩。评估方法是看 mapping.txt 里的类名映射:如果某个包名下的类映射都是 -> a-> b 这种短名,说明压缩有效;如果大量保留原名,检查对应的 keep 规则是否必要。


工具推荐:实际在用的几个


写规则文件离不开工具辅助,这里列几个我长期使用的,包括它们的局限。


Android Studio 的 APK Analyzer。 内置工具,Build → Analyze APK,可以对比两个 APK 的 dex、资源差异。分析规则效果时,我通常先打一个 minifyEnabled=false 的基线包,再打开 minifyEnabled=true 的优化包,对比 classes.dex 的类列表,看哪些类预期被删除但实际还在。局限是只能看最终产物,不能看中间过程,对于"为什么这个类被保留"需要配合其他工具。


R8 的命令行工具。 独立版本在 https://r8.googlesource.com/r8,可以脱离 Gradle 直接运行,方便调试单个规则文件。用法是下载 r8.jar 后:


java -jar r8.jar \
  --release \
  --pg-conf rules.pro \
  --lib android.jar \
  --output out \
  input1.jar input2.jar

这个工具对 library 开发者特别有用,可以验证 consumerProguardFiles 在独立环境下是否有效。局限是配置复杂,需要手动准备所有输入 jar 和依赖。


JADX。 开源反编译工具,https://github.com/skylot/jadx。不是专门用于规则调试,但反编译优化后的 APK 是验证混淆效果的终极手段。我常用它检查:某个类是否被正确混淆、某个方法是否被内联删除、Kotlin 的 data class 的 copy() 方法是否被保留。JADX 的 GUI 版本支持直接拖入 APK,对比 mapping.txt 可以还原原始类名。局限是反编译不完全准确,某些 R8 优化后的代码结构 JADX 会解析失败,需要结合 smali 看字节码。


ProGuard 的 ReTrace。 虽然 R8 替代了 ProGuard 的优化和混淆功能,但 proguardgui.jar 里的 ReTrace 工具仍然是最稳定的映射还原工具。R8 自带的 retrace 命令在 AGP 7.3 之后引入,但早期版本有堆栈还原不完整的问题。我的习惯是保留 proguardgui 的独立下载(https://github.com/Guardsquare/proguard),专门用来还原线上崩溃堆栈。局限是 ProGuard 社区版的更新频率低于 R8,某些新的映射格式可能不支持。


Kotlin 的 binary-compatibility-validator。 这是 JetBrains 官方插件,https://github.com/Kotlin/binary-compatibility-validator。原本用于检查库 API 兼容性,但我发现它可以辅助生成 keep 规则:把项目的公开 API dump 出来,对比 R8 优化后的 API 残留,快速发现"应该公开但被删了"或者"不该公开但暴露了"的问题。配置在 Gradle 里:


plugins {
    id("org.jetbrains.kotlinx.binary-compatibility-validator") version "0.13.2"
}

运行 ./gradlew apiDump 生成 .api 文件,里面是所有公开 API 的签名。局限是只针对 Kotlin 代码,Java 代码需要额外处理。


一个完整的规则文件结构示例


最后分享一个我目前在用的规则文件组织方式,不保证最优,但可维护性比较好。


项目结构:

app/
  proguard/
    common.pro          # 通用规则,不依赖具体库
    android.pro         # Android 框架相关
    kotlin.pro          # Kotlin 语言特性
    reflect.pro         # 项目自己的反射白名单
    third-party/
      okhttp.pro        # 带版本号注释
      retrofit.pro
      gson.pro
      ...
  build.gradle          # 引用上述文件

common.pro 的内容框架:

# 基础属性保留
-keepattributes *Annotation*, Signature, Exceptions, InnerClasses, EnclosingMethod

# 泛型签名不被擦除
-keepparameternames

# 异常堆栈还原
-keepattributes SourceFile, LineNumberTable
-renamesourcefileattribute SourceFile

# 本地方法
-keepclasseswithmembernames class * {
    native <methods>;
}

# 枚举
-keepclassmembers enum * {
    public static **[] values();
    public static ** valueOf(java.lang.String);
}

# Parcelable
-keepclassmembers class * implements android.os.Parcelable {
    public static final ** CREATOR;
}

reflect.pro 是项目特化的,按模块分块:

# ===== 模块:支付回调 =====
# 支付 SDK 通过反射调用 com.example.payment.Callback 的实现
# 来源:支付宝 SDK 文档 v15.8.11,issue #1234
-keep interface com.example.payment.Callback { *; }
-keep class * implements com.example.payment.Callback { *; }

# ===== 模块:插件化加载 =====
# PluginLoader 通过 Class.forName 加载插件入口
# 已验证:R8 full mode 下必须显式 keep,不能 rely on keepnames
-keep class com.example.plugin.PluginEntry { *; }

每个 .pro 文件头部用注释写明适用范围、版本信息、修改历史。build.gradle 里用 proguardFiles 按顺序引入,注意顺序在 R8 里不像 ProGuard 那么关键,但保留习惯有助于排查。


写在最后


ProGuard 和 R8 的规则编写是个"看起来简单,做起来细碎"的技术点。它的难点不在于语法,而在于理解编译器的优化策略、掌握调试工具、建立可持续的治理流程。AGP 8 之后,R8 full mode 的默认开启把很多历史债务暴露出来,这也是我花时间整理这套方法的直接动力。


如果你正在经历类似的升级阵痛,我的建议是先稳住:关闭 full mode,建立基线,逐条审计规则,用工具验证而不是靠猜测。规则文件的质量直接决定优化效果和稳定性,值得投入专门的精力。

DownloadManager 的大文件下载,断点续传靠谱吗 2026-07-06
GitHub Actions 的 Android CI 模板优化 2026-07-06

评论区