一、混淆规则语法:-keep、-dontwarn、-keepclassmembers 等常用指令
混淆规则说白了就是告诉 R8 :“哪些代码你动不得,哪些警告你闭嘴。” 我刚开始接触 ProGuard 那会儿,觉得规则文件就是复制粘贴就完事了。结果第一次上线就遇到一个诡异的崩溃——某个动态注册的广播接收器被直接移除了,因为它“看起来没有被直接引用”。
从那以后,我形成了个习惯:每一条规则都要搞清楚它到底在保护什么。今天我们就来把这几个最常用的指令掰开揉碎。
1.1 -keep 指令:我就是要保你周全
-keep 是所有规则里权限最大的那个。它告诉 R8:指定类或成员,你不能重命名、不能优化、更不能移除。
我个人习惯在三种场景下使用:
- 反射目标——比如 Hook 框架、Gson/Fastjson 的映射类;
- JNI 调用的方法——Java 侧的名称变了,C++ 那边就找不到入口;
- 第三方 SDK 的入口——你控制不了它的源码,只能保住它声明的方法签名。
最基本的写法很简单:
-keep class com.example.hook.HookBridge { *; }
嗯,这里要注意,{ *; } 表示这个类的所有成员都不能动。如果你只想保一个特定的方法:
-keep class com.example.hook.HookBridge {
public void hookMethod();
}
你可能想问,如果我用的是接口或者抽象类呢?-keep 也支持通配符,比如 ** 表示匹配任意包名:
-keep class **.Glue { *; }
我在项目中遇到过一个问题:某个 SDK 使用了 -keep class * extends android.app.Activity { *; },结果所有继承 Activity 的类都被保住了,包体积直接多了 2M。你以为 -keep 只是保安全?其实它也在逆兼容优化——保得越宽,R8 能干的活越少。所以我的建议是:能用精确签名就别用 { *; }。
$ 被引用,我写了 -keep class com.example.Outer$Inner { *; },但忘了外部类也会被混淆。结果反编译后类名变了,内部类持有外部类的引用报错。正确的姿势是用 -keep class com.example.Outer { *; },连外部类一起保。
1.2 -dontwarn 指令:让它闭嘴
这个指令字面意思就是“不要警告”。R8 在编译时会扫描所有代码路径,如果发现某些类或方法不存在(通常是因为可选依赖、反射或系统版本差异),就会抛出警告甚至报错。
你想想看,你的应用引用了某个库,这个库的代码里写了一行 import android.util.LogEx;,但 LogEx 只在某些系统版本才有。R8 就抱怨:“你这个类找不到!” 这时候 -dontwarn 就派上用场了:
-dontwarn android.util.LogEx
注意,-dontwarn 不会阻止 R8 移除你引用的这个类——它只是让警告不显示。如果你的代码确实依赖 LogEx 并直接调用了它的方法,那运行时仍然会崩溃。所以我的经验是:先确认这个类是可选的(比如被 try/catch 包裹的兼容性代码),再用 -dontwarn。
说实话,当年我接手了一个老项目,打开构建日志,30 多条警告全是 Could not find class xxx。我懒得一个个排查,直接加了一句 -dontwarn **。结果其中一个缺失的类恰好被业务逻辑直接引用了,上线后 Crash 率飙升 3%。好吧,从那以后我发誓再也不用通配符来堵住所有警告了。
高效的做法是:
- 先 compileOnly 排除可选依赖;
- 然后用
-dontwarn精确到具体类名; - 最后保留一条
-dontwarn **兜底——但仅限于测试构建,release 构建一定要去掉。
1.3 -keepclassmembers 指令:只保成员,不保类
这个指令比较有意思。-keep 连类名带成员一起保;而 -keepclassmembers 只保护类的内部成员(方法、字段),但类名本身可以被重命名或移除。
什么场景会用?比如你有大量 data class,你只在乎它们的字段名保持不变(因为序列化框架靠字段名匹配),而类名叫什么无所谓:
-keepclassmembers class com.example.model.** { *; }
或者你只关心某个特定的字段:
-keepclassmembers class com.example.User {
private String name;
private int age;
}
这样做的好处是:R8 可以优化掉一些未使用的类或方法,最大程度减小包体积。你想想看,如果你的 App 有 200 个 model 类,但你只关心 10 个字段名,用 -keepclassmembers 就能让 R8 把其他不需要的字段和方法全部优化掉。
嗯,这里有一个很隐蔽的陷阱:如果类被合并或移除了,-keepclassmembers 也救不了它。R8 在分析一个类没有“存活”的必要性时,可能会把整个类干掉,即便它的成员被保住了。为什么呢?因为“存活”是类级别的判断。举个例子:某个 model 类只在某个永远不走的 if 分支里被 new 出来,R8 会把这个分支剪掉,然后整个类就没了。
-keep 保护类名,再配合 -keepclassmembers 细化到关键成员。比如:-keep class com.example.User-keepclassmembers class com.example.User { private String name; }这样既保留了类,又保护了关键字段。
1.4 其他常用指令速查
除了上面三个核心指令,还有几个我日常工作中高频使用的:
| 指令 | 作用 | 典型场景 |
|---|---|---|
-keepnames |
只保留原名,不阻止优化/移除 | 只需保留方法名不变,不影响包体积 |
-keepclassmembernames |
只保留成员原名 | 只关心某个方法在序列化时不被重命名 |
-keepattributes |
保留字节码属性 | 保留 Signature、Annotation 等用于反射 |
-dontoptimize |
关闭 R8 优化步骤 | 调试阶段或遇到奇怪的优化崩溃 |
1.5 实战:一条完整的混淆规则怎么写
下面是一个我从真实项目中抽出来的配置片段,你可以参考:
-keepattributes *Annotation*
-keepattributes Signature
# 保留所有 Gson 和 OkHttp 的类以及成员
-keep class com.google.gson.** { *; }
-keep class okhttp3.** { *; }
# 只保留自定义 View 的成员(用于反射 inflate)
-keepclassmembers class com.example.ui.** extends android.view.View {
public <init>(android.content.Context, android.util.AttributeSet);
}
# 抑制系统库中缺失类的警告
-dontwarn android.support.v4.**
-dontwarn androidx.legacy.**
1.6 总结:该怎么写才不会崩
说白了,混淆规则的书写就是一场精确度的平衡游戏:
- 规则写得太宽(比如到处用
{ *; })→ 包体积变大,优化失效; - 规则写得太窄(比如只保类名不保成员)→ 反射找不到字段,运行时异常;
-dontwarn滥用 → 掩盖了真正的依赖缺失错误。
我个人习惯的做法是:先全量编译一次,收集所有警告;针对每个警告去确认它是否真的安全;最后用精确指令一个个解决,而不是无脑加通配符。我记得有一次排查一个第三方 SDK 的崩溃,花了整整两天,最后发现是 SDK 内部用 Class.forName() 动态加载了某个实现类——而我没有在规则文件中保留那个实现类的完整构造函数签名。
-keep class ** { *; } 跑一次测试,确保功能正常;然后逐步缩小保护范围,每次构建完都跑一遍核心测试用例,直到找到最精简的配置。