release 包出问题,排查到最后基本都会翻到这个文件——proguard-rules.pro。规则写得对不对,直接决定线上会不会闪退。这个文件本身机制不复杂:R8 对代码做四件事——压缩、优化、混淆、预检,keep 规则就是告诉它「这几处别碰」。但「别碰」有四种说法,选错一种线上就崩。这篇把规则文件系统梳理一遍:它怎么被加载、keep 四兄弟的区别、什么必须 keep、出了事怎么用 mapping 反推。
一、规则文件在构建里扮演什么角色
它长在 app/proguard-rules.pro,本身不干活——是 R8 的「配置文件」。构建时由 Gradle 传给 R8:
// app/build.gradle.kts
android {
buildTypes {
release {
// 开启混淆:压缩 + 优化 + 混淆一起开
isMinifyEnabled = true
// 资源压缩(删除没用的资源)
isShrinkResources = true
// 规则文件:内置默认规则 + 项目规则
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}早期项目里可能看到
minifyEnabled、proguard-android.txt——那是老写法。AGP 8+ 默认 R8,默认规则文件叫proguard-android-optimize.txt。
实际生效的规则是三层合并的结果,不是只有这一个文件:
| 层 | 来源 | 内容 |
|---|---|---|
| ① 内置默认规则 | AGP 内置 proguard-android-optimize.txt | Android 组件、View 绑定等通用 keep |
| ② 项目规则 | 你的 proguard-rules.pro | 自己项目的反射/序列化目标 |
| ③ 库的 consumer rules | 每个 AAR 自带的 proguard.txt | 该库自己的 keep 要求(Gson、OkHttp 都有) |
所以大部分第三方库不用你写规则——它们把自己需要的 keep 打包在 AAR 里,AGP 自动合并。你只需要管自己的代码。
二、R8 对代码做的四件事
R8 是 ProGuard 的继任者(AGP 8+ 默认就是它)。它把编译产物过四道工序:
| 阶段 | 干什么 | 通俗理解 |
|---|---|---|
| 压缩 shrink | 删掉没被引用的类、方法、字段 | 只留从「入口」能走到的代码 |
| 优化 optimize | 内联方法、合并类、去掉无副作用调用 | 把代码改得更小更快 |
| 混淆 obfuscate | 类名/方法名改成 a、b、c | 反编译后看不懂 |
| 预检 preverify | 检查字节码合法性 | Android 上基本不干活 |
keep 规则主要管前三个:告诉 R8 哪些代码不能删(压缩)、不能改名(混淆)、不能乱动(优化)。
实跑验证(R8 9.4.17):7 个演示类进去,出来只剩 3 个——两个被 keep 的 + 一个改名成
a的。没被引用的死代码类整个消失;一个返回值没被使用的方法,调用点被 R8 替换成无害的getClass()后方法被整体删掉。这就是压缩 + 优化的真实效果。
三、keep 四兄弟:四种「别碰」
这是规则文件的核心。四条指令看着像,语义完全不同:
| 指令 | 类名混淆? | 类被收缩? | 成员名混淆? | 成员被收缩? | 典型用途 |
|---|---|---|---|---|---|
-keep | 否 | 否 | 否 | 否 | Android 组件、反射目标,全保 |
-keepclassmembers | 是 | 是 | 否 | 否 | 只保某些方法/字段(类名无所谓) |
-keepnames | 否 | 是 | 否 | 是 | 保名字但不拦收缩 |
-keepclassmembernames | 是 | 是 | 否 | 是 | 只保成员名 |
关键差异在**「保不保命」**:keep 是保命(不许删)+ 保名;keepnames 只保名、允许删。很多人以为 keepnames 和 keep 差不多——实测打脸。
实跑验证:一个没有任何引用的接口,配了
-keepnames后照样被收缩删除(allowshrinking 生效)。想「保命」,必须用-keep。
1. -keep:全保(最常用)
# 类名、成员名全保留,不许收缩
-keep class com.demo.app.MainActivity { *; }{ *; } 表示该类的所有成员。实测 -keep 的类在输出里原封不动。
2. -keepclassmembers:只保成员
# 反射调用的方法:名字必须保住,类名无所谓
-keepclassmembers class com.demo.app.ReflectHolder {
public void invoke();
}实跑验证:
ReflectHolder类名被混淆成c,但invoke()方法名原样保留——keepclassmembers只保成员,类名照样混淆。
适合场景:类通过反射创建(类名混淆无所谓),但反射调用的方法名必须固定。
3. -keepnames:保名不保命
# 等价于 -keep,allowshrinking,allowoptimization
-keepnames class com.demo.app.Callback实测:类没被引用 → 直接被删。所以 keepnames 适合「如果它活着,名字别变」的场景——一般很少用。
4. 规则匹配语法
写规则先要会描述「哪个类、哪些成员」:
# 类名通配符
-keep class com.demo.app.* # 当前包下所有类(不含子包)
-keep class com.demo.app.** # 含子包
# 成员通配符
-keep class com.demo.app.User {
<fields>; # 所有字段
<methods>; # 所有方法
<init>(...); # 所有构造函数
}
# 具体签名
-keep class com.demo.app.User {
public java.lang.String name; # 指定字段
public void update(); # 指定方法
}四、常用指令速查
keep 之外,规则文件里常见这几条:
# 1. 忽略警告:某个类缺失(老库引用新版 API 才有)
-dontwarn org.apache.log4j.**
# 2. 保留注解/泛型签名:反射读注解、序列化需要
-keepattributes Annotation, Signature, InnerClasses, EnclosingMethod
# 3. 整体关闭某个阶段(调试用,release 别这么干)
-dontshrink
-dontoptimize
-dontobfuscate
# 4. 保留方法参数名(调试栈友好,Kotlin 默认要配)
-keepparameternames
# 5. 假定方法无副作用:裁剪日志/断言(release 常用)
-assumenosideeffects class android.util.Log {
public static int d(...);
public static int v(...);
}
# 6. 包名扁平化:类名更短(可读性差,谨慎用)
-flattenpackagehierarchy-keepattributes Annotation 这条很关键——很多反射框架靠注解工作,注解被混淆丢掉,运行时 getAnnotation() 返回 null。
五、什么必须 keep
判断标准一句话:运行时要靠「名字」找到的代码,必须 keep。典型场景:
| 场景 | 原因 | 规则写法 |
|---|---|---|
| Android 四大组件 | 系统按包名+类名反射实例化 | AGP 默认规则已覆盖,一般不用写 |
| Gson 反射的 data 类 | 字段名靠反射读写 | -keep class com.demo.model.** { *; } |
| kotlinx.serialization 的类 | @Serializable 编译器生成序列化器 | 库自带 consumer rules + 注解保留 |
| 注解(自定义注解) | 反射读注解 | -keepattributes Annotation + -keep @interface com.demo.** { *; } |
| JNI 的 native 方法 | 按方法名找 C++ 实现 | -keepclasseswithmembernames class * { native <methods>; } |
| 反射创建的类/方法 | Class.forName() / getMethod() | 按实际调用点精确 keep |
| Retrofit 接口 | R8 启发式收缩会误删(真实翻车案例) | 见下方案例文 |
Retrofit 接口是最典型的翻车点:接口本身没被「直接调用」,R8 的启发式判断可能把接口收缩掉,运行时 retrofit.create() 反射创建代理才发现接口没了——闪退。我排查过一次同样的线上崩溃,完整链路(usage.txt/seeds.txt 怎么用)见 R8 混淆:Retrofit 接口被收缩导致崩溃。
六、出问题怎么反推:mapping 三板斧
混淆后崩溃堆栈是 a.a() 这种天书,靠构建产物还原:
| 文件 | 位置 | 干什么 |
|---|---|---|
mapping.txt | build/outputs/mapping/{variant}/ | 混淆前后名字映射,还原堆栈 |
usage.txt | 同上 | 被收缩删除的东西(查「谁被删了」) |
seeds.txt | 同上 | 被保留的根(查「谁保住了」) |
还原堆栈:用 SDK 的 retrace 工具(tools/proguard/bin/retrace.bat)把混淆堆栈翻译回原始类名方法名:
retrace mapping.txt crash-stack.txtmapping.txt 长这样(真实产物):
com.demo.app.Helper -> com.demo.app.a: # Helper 被改名成 a
com.demo.app.MainActivity -> com.demo.app.MainActivity: # keep 住了,名字不变
1:1:void invoke():9:9 -> invoke # 方法名保留(keepclassmembers)排查崩溃的完整套路(先看 usage 谁被删了 → 再补 keep 规则 → 重打验证)见案例文,这里不重复展开。
七、一份能落地的模板
结合实战项目,一个内部系统的 proguard-rules.pro 长这样:
# ===== 1. 数据模型:Gson/kotlinx.serialization 反射目标 =====
-keep class com.cxtech.eam.model.** { *; }
# ===== 2. Retrofit 接口:防 R8 启发式收缩 =====
-keep class com.cxtech.eam.api.** { *; }
-keepattributes Signature, Exceptions
# ===== 3. 反射创建的工具类 =====
-keep class com.cxtech.eam.util.ReflectHelper { *; }
# ===== 4. 自定义注解(反射读取) =====
-keep @interface com.cxtech.eam.annotation.** { *; }
-keepattributes Annotation
# ===== 5. 日志裁剪(release 把 Log 调用全删) =====
-assumenosideeffects class android.util.Log {
public static int d(...);
public static int v(...);
}
# ===== 6. 第三方库告警忽略 =====
-dontwarn org.apache.commons.**注意:
-keep class com.cxtech.eam.api.** { *; }这类「整包 keep」是最稳的兜底,代价是这包代码不混淆不收缩——体积和安全性换稳定,业务代码这么写没问题。
实跑验证
本文所有行为基于 R8 9.4.17(对应 AGP 9.x)实测,13 项断言全过:
- 7 个演示类 + 4 条规则(keep/keepnames/keepclassmembers/无规则)输入,输出只剩 3 个类
-keep:MainActivity、User 类名保留 ✅-keepnames:未引用的 Callback 被收缩删除 ✅(证明 allowshrinking)-keepclassmembers:ReflectHolder 类名混淆为c、invoke()方法名保留 ✅- 无规则:Helper→
a、UsedCallback 被混淆 ✅ - 死代码 DeadCode 整个类被删除 ✅
- 优化:返回值未使用的方法调用被替换为无副作用操作、方法整体删除 ✅
mapping.txt正确生成,映射行与本文示例一致 ✅
链式导航
Retrofit 接口被收缩的完整排查过程(usage/seeds 实战)见 R8 混淆:Retrofit 接口被收缩导致崩溃;混淆规则和 AGP/Gradle/Kotlin 版本的配套关系,见 AGP 与 Gradle/Kotlin 版本适配。
