一、混淆规则语法:-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 ** { *; } 跑一次测试,确保功能正常;然后逐步缩小保护范围,每次构建完都跑一遍核心测试用例,直到找到最精简的配置。
R8 混淆规则指令体系 混淆规则 (ProGuard/R8) 保留指令(阻止移除/重命名) 成员级指令(只保成员不保类) 告警 & 属性指令 -keep -keepnames -keepclass
members -keepclass
membernames
-keepattributes -dontwarn -dontoptimize 核心建议:-keep 应优先使用精确签名避免过度保护 -dontwarn 仅用于确认安全的可选依赖 / 兼容性代码 目的:预防混淆后崩溃