4. 常见混淆错误类型
说实话,这三类异常是混淆后最让人头疼的运行时问题。我刚开始做混淆时,总以为只要没编译报错就万事大吉,结果一跑起来就崩。你想想看,类在编译时还在,运行时却找不到了,这背后无非是混淆规则没覆盖到位。
这三类异常本质都一样:混淆把代码结构改了,但运行时还在按老名字去找。只不过找的东西不同——类、方法、字段。下面我挨个讲清楚,每个怎么发生、怎么定位。
4.1 三类异常的本质对比
先看一张对比表,快速建立整体认知。后面我再逐个展开。
| 异常类型 | 运行时实际错误 | 核心成因 | 堆栈关键词 | 首选定位文件 |
|---|---|---|---|---|
| ClassNotFoundException | ClassNotFoundException 或 NoClassDefFoundError |
整个类被移除 / 类名被混淆且反射用了原名 / 类路径变更 | ClassNotFoundException: + 全限定类名 |
seeds.txt |
| MethodNotFoundException | NoSuchMethodError |
方法被内联 / 移除 / 签名因参数混淆而变更 | NoSuchMethodError: + 方法名+描述符 |
mapping.txt 方法映射 |
| FieldNotFoundException | NoSuchFieldError |
字段被优化移除 / 字段名被混淆但被反射或序列化依赖 | NoSuchFieldError: + 字段名 |
mapping.txt 字段映射 |
记住一个原则:混淆只保证编译通过,不保证运行时反射、序列化、JNI 等动态调用还能找到目标。这三类异常 90% 以上都是 keep 规则写漏了。
4.2 ClassNotFoundException —— 类蒸发了
这个异常最直接:JVM 或 ART 在类加载时,根本找不到这个类的字节码。为什么会这样?
- 整个类被移除了:R8 如果发现某个类"没被使用",默认会直接删掉。注意,这里的"没被使用"是指没有直接的代码引用,反射、Class.forName() 这种不算。
- 类名被重命名了:类虽然保留了,但名字从
com.example.Foo变成了a.a。如果你在配置文件或代码里硬编码了原类名,必然找不到。 - 类路径变了:某些场景下类被移动到其他包(较少见,但多模块项目可能发生)。
怎么定位?我个人习惯分三步走:
- 看完整堆栈:找到
ClassNotFoundException: com.example.Foo这一行,确认是哪个类出了问题。 - 查
seeds.txt:这个文件记录了 R8 决定保留的所有类和成员。打开它,搜索这个类名。如果找不到,说明类被移除了;如果找到了但名字变了(比如变成了a.a),说明类被重命名了。 - 查
mapping.txt:如果 seeds.txt 里类名已经变了,去 mapping.txt 里找原始名到混淆名的映射,确认对应关系。
小技巧:我一般在 proguard-rules.pro 里先加一条 -keep class com.example.Foo,如果问题消失,那就确认是 keep 规则遗漏。然后再细化到具体的成员 keep。
我在项目中遇到过这样的情况:一个工具类只在 AndroidManifest.xml 里通过 name 属性引用,代码里没有任何 import。结果混淆后直接 ClassNotFoundException。嗯,这种隐式引用特别容易漏掉。
4.3 MethodNotFoundException —— 方法失踪了
这个比类找不到更隐蔽。类还在,方法没了。运行时抛 NoSuchMethodError,你一看堆栈,方法名是对的呀,怎么就说找不到呢?
常见原因有这几个:
- 方法被内联了:R8 的优化阶段会把简单的 getter/setter 或短方法直接内联到调用处,方法本身就不再独立存在了。
- 方法被移除了:如果 R8 认为某个方法未被调用(同样是静态分析层面),就会直接删掉。
- 签名变了:这是最坑的。方法名可能没变,但参数类型被混淆了。比如
foo(List<String>)变成了foo(List),泛型擦除 + 类型混淆后,签名对不上。 - keep 规则只保了类没保方法:
-keep class Foo只会保留类本身,不会保留它的方法。需要用-keep class Foo { *; }才全保留。
定位方法:
- 从崩溃堆栈中找到方法名和描述符(比如
com.example.Foo.bar(String)Z)。 - 打开
mapping.txt,搜索原始方法名bar。看它是否被映射到了其他名字,或者干脆没有映射记录(说明被移除了)。 - 如果方法名还在但签名变了,检查参数类型对应的类是否也被混淆了。这种情况需要同步 keep 参数类型。
特别注意:反射调用方法时,用 getMethod("bar", String.class) 这种形式,如果 String.class 没被混淆没问题,但如果参数类型是一个自定义类,那个类被混淆了,方法签名就匹配不上了。我曾经因为这个排查了整整一下午……
我个人习惯在 mapping.txt 里先 grep 一下方法名,看它在混淆前后的对应关系。如果 mapping 里根本没有这个方法,那大概率是被内联或移除了。
4.4 FieldNotFoundException —— 字段凭空消失
字段找不到,抛 NoSuchFieldError。这个在序列化、反序列化、或者反射读字段的场景下特别常见。
成因分析:
- 字段被优化移除:R8 发现某个字段从未被读取或写入(静态分析),就会把它删掉。注意,反射读写不算"被使用"。
- 字段名被混淆:字段名从
userName变成了a,但代码里通过field.get("userName")反射访问,自然找不到。 - keep 规则遗漏:只 keep 了类,没 keep 字段。或者 keep 了字段但只 keep 了特定类型(比如
keepclassmembers写漏了)。
怎么定位?
- 看异常信息中的字段名,确认是哪个字段出了问题。
- 查
mapping.txt,搜索原始字段名。看它有没有被映射到新名字,或者完全没有记录。 - 如果字段用于序列化(比如
Serializable或Parcelable),必须用-keepclassmembers class * implements java.io.Serializable { *; }这类规则保住所有字段。
避坑指南:我曾经负责的一个 SDK,里面有个 Config 类,字段全是 public 的,直接被外部通过反射赋值。混淆后字段名全变了,调用方全部报 FieldNotFoundException。那之后我养成了一个习惯——所有对外暴露的字段,要么加 @Keep,要么单独写 keep 规则,绝不留侥幸。
4.5 通用定位方法论 —— 一套组合拳
上面三种异常虽然各自不同,但定位思路是共通的。我把这套方法总结成了四个步骤,你可以直接照着做。
- 抓完整堆栈:别只看第一行,把堆栈全部拉出来。很多时候真正的调用链在后面几行。
- 查 mapping.txt:这是最核心的武器。用 Retrace 工具或者手动 grep,找到混淆前后的对应关系。
- 检查 seeds.txt:确认类和方法是否在保留名单里。如果不在,加 keep 规则。
- 逐步收紧验证:先加宽松规则(比如
-keep class com.example.** { *; })确认问题消失,再逐步收紧到最小规则集。
下面这张图总结了三种异常的排查流程,你可以保存下来当 cheat sheet。
核心心法:遇到混淆崩溃,别慌。先确认是类、方法还是字段的问题,然后去 mapping.txt 和 seeds.txt 里对账。对不上?那就是 keep 规则没写全。对上了但名字变了?那就是反射/序列化代码里用了硬编码的原名。两种情况的修复方式不同,但定位路径是一样的。
我建议你在项目里专门建一个 混淆规则排查笔记 文档,把每次遇到的异常案例记录下来。时间长了,你会形成一套自己的直觉——看到某个堆栈,马上就能猜到是哪条规则写漏了。这个积累过程,没人能帮你走,但一旦走通了,你就是团队里的混淆专家。