第3章 混淆基本概念:名称混淆、代码压缩、优化、预校验

这一章咱们聊点实在的。很多新手刚接触ProGuard时,看到那一堆配置选项就开始头晕。其实说白了,ProGuard的核心动作就四个:名称混淆、代码压缩、优化和预校验。理解这四个概念,你就能看懂大半的混淆规则。

ProGuard四大核心操作 输入:未混淆的APK / AAR (class文件 + 资源) 名称混淆 类/方法/字段重命名 代码压缩 删除未使用的代码 优化 内联、简化、合并 预校验 添加StackMap帧 输出:混淆后的APK / AAR 💡 顺序说明:先代码压缩(移除无用类),再名称混淆(重命名),然后优化(内联简化), 最后预校验(添加StackMap表)。实际运行时可以独立启用/禁用。

3.1 名称混淆(Obfuscation)

这是大家最熟悉的功能。把类名、方法名、字段名改成 a、b、c 这种短名字。目的是增加逆向分析难度。

我遇到过不少团队,一上来就开混淆,结果运行时报 ClassNotFoundException。为啥?因为反射、JNI、JSON 序列化这些场景,代码里用字符串引用的类名,混淆后就不匹配了。

混淆前后的对比:

// 混淆前
public class LoginActivity extends Activity {
    private String userName;
    public void onLogin() { ... }
}

// 混淆后
public class a extends Activity {
    private String b;
    public void c() { ... }
}

保留规则的核心思路:

  • 所有反射调用的类、方法 — 加 -keep
  • JNI 的 native 方法 — 加 -keepclasseswithmembernames
  • Gson / FastJson 的数据模型 — 加 -keep class * implements Serializable
  • 枚举、注解 — 默认就要保留,但最好显式写出来

我个人习惯,把所有的 keep 规则按模块分开写在不同的文件中,比如 proguard-retrofit.pro、proguard-gson.pro。然后通过 -include 引入。这样后期维护一眼就能找到。

3.2 代码压缩(Shrinking)

代码压缩有点像「扫地出门」——把没用的类、方法、字段全部删掉。它基于 入口点(Entry Points) 的引用分析。ProGuard 从入口开始,沿着调用链标记所有“活的”代码,剩下的全部丢弃。

我曾经接手过一个遗留项目,打出来的 APK 有 50MB。一跑压缩,直接干到 28MB。查了一下,里面竟然塞了好几个不用的第三方 SDK 的 jar 包。压缩开关默认是开的:

-dontshrink    # 关闭压缩(不推荐长期关)

注意:压缩是有风险的。如果某个类只在 AndroidManifest.xml 或资源文件中被间接引用,ProGuard 可能分析不到。这种情况下需要手动 -keep 声明。否则编译没问题,运行时就崩了。

压缩还有一个副产品:「输出映射表」。建议每次构建都保留 mapping.txt,这样出 bug 时能反查崩溃堆栈。我的项目 CI 流程里会自动把 mapping 文件归档到版本号目录下。

3.3 优化(Optimization)

优化这一步挺有意思。它不是在删除代码,而是在“改造”代码。比如:

  • 方法内联 — 把短方法直接内嵌到调用处,减少调用开销
  • 常量折叠 — 编译期计算常量表达式,比如 2 + 3 直接变成 5
  • 分支简化 — 去掉永远不会执行的分支
  • 类合并 — 把小类合并到大类里,减少类数量

优化的默认等级是 -optimizationpasses 5。但我建议不要盲目加大轮数,有时候过度的优化反而会引入问题。特别是涉及泛型和可变参数时,我踩过坑:优化后 List<String> 的类型擦除出了问题,导致运行时类型转换异常。后来我加了一行:

-dontoptimize   # 或者针对某些类 -keepclassmembers 规则

不过也别因噎废食。大多数情况下优化是安全的,能让代码体积再小 5%-15%,执行效率略微提升。

3.4 预校验(Preverification)

这个知识点很多教程都不讲,但实实在在影响着你的打包速度。预校验是 Java 6 以后引入的机制,用于加速 class 文件的校验流程。ProGuard 会在输出的 class 文件中插入 StackMapTable 属性。有了它,Java 虚拟机在校验时就不用重新推导一遍栈帧了。

举个例子:

# 开启预校验(默认是开的)
-microedition
# 关闭预校验(一般只用于 Java Micro Edition 等老旧平台)
-dontpreverify

大多数 Android 项目不需要改动这个开关,保持默认就好。不过如果你的项目使用了 Java 8 的 lambda 表达式,或者在混淆后出现 VerifyError,那就要检查一下是不是预校验出了问题。

什么时候需要关掉预校验?

  • 目标平台是 Java 5 或更早(几乎遇不到)
  • 编译时遇到 StackMapTable 相关的 VerifyError,且确认不是其他原因
  • 使用某些老旧的混淆后处理工具,不兼容 StackMap

3.5 四个操作的执行顺序与开关控制

ProGuard 的处理流是按序串行的:压缩 → 优化 → 混淆 → 预校验。每一步都可以独立控制。我给你整理了一张表,方便查阅:

操作 默认状态 关闭命令 典型场景
名称混淆 开启 -dontobfuscate 调试版本、需要精确堆栈时关闭
代码压缩 开启 -dontshrink 发布版本强烈建议开启
优化 开启(5轮) -dontoptimize 第三方 SDK 过度优化导致崩溃时关闭
预校验 开启 -dontpreverify Java ME 或遇到 VerifyError 时尝试关闭

一句话总结配置原则:

  1. 日常 debug 版本:压缩 + 混淆都关掉,只为加速构建和保留清晰堆栈
  2. 线上 release 版本:四项全开,但要为反射、JNI 等场景添加合适的 -keep
  3. 如果遇到编译或运行异常,每次只关一项,定位问题再调整

我记得刚带团队那会儿,有个同事把 -dontshrink 写在了 release 配置里,结果包体积大了两倍。排查了好几天才找到原因。从那以后我在 Code Review 里都会盯一眼混淆参数。

3.6 映射文件(Mapping)的重要性

虽然这不是核心操作,但和名称混淆强相关。ProGuard 每一次构建都会生成一个 mapping.txt,里面记录了原始的类名/方法名与混淆后的名字的对应关系。没有它,线上崩溃堆栈里全是 a.a.b(),根本没法定位。

# 映射文件示例
com.example.LoginActivity -> a:
    java.lang.String userName -> b
    void onLogin() -> c

建议的做法:在打包脚本里把 mapping 文件重命名为 mapping-{versionCode}-{versionName}.txt,存到独立的目录或云存储上。出问题后,用 retrace 工具反解堆栈:

java -jar proguard.jar retrace mapping.txt crash-stack.txt

千万别把 mapping 文件提交到 GitHub 公共仓库!它等于泄露了你混淆后的内部符号表,攻击者拿到后可以直接还原。我在安全审计中就见过一个开源项目不小心把 mapping 传上去,被安全团队警告了。

3.7 小结

这四个概念是 ProGuard 的基石。搞懂了它们,写规则就不会盲目。压缩要小心反射,优化要留点余地,名称混淆要配合 mapping。预校验……如果你不用 Java ME 或者遇到了奇奇怪怪的 VerifyError,保持默认就好。