有个周五晚上,我把刚打出来的 release 包拖进 jadx,想确认一下某个接口签名有没有被改掉。结果包一打开我就愣了:UserRepositoryImpl、HealthRecordViewModel、LocationPermissionHelper,业务类名一个没动,包名整整齐齐,连方法名都是原样。开了 minifyEnabled true 快两年,混淆了个寂寞。
翻 proguard-rules.pro,第二行就躺着这么一条:
pro
# 历史遗留,谁都不敢删
-keep class com.example.** { *; }
写完这行的人早离职了,评论区那句「谁都不敢删」是后面某次发版加的。它把整个业务包连类带成员全 keep 住,R8 既不敢删也不敢改名字。更丢人的是证据一直摆在眼皮底下------seeds.txt 比 mapping.txt 还厚,几万行,我看了两年都没觉得不对劲。
那天晚上我把这条规则删了,跑完构建,线上灰度三小时后就收到一串 ClassNotFoundException。又花了两天把反射、JNI、序列化、Compose 的坑挨个补回来。补到最后我脑子里冒出个问题:我到底在防谁?
后来去翻这东西的老底,答案挺意外的。混淆最初压根不是拿来防破解的,它是个省体积的副产物。 这个认知一旦转过来,后面所有取舍都变了------包括我该怎么向 Trae 提需求。
混淆最早要解决的,是「装不下」
1996 年 Java 刚火起来那会儿,有人发现一件事:这语言的字节码太好反编译了。
原因在 JVM 的设计里。.class 文件为了支持延迟链接和动态加载,把类名、方法名、字段名、描述符这些符号信息原封不动地留在常量池里;JVM 又是个没有通用寄存器的简单栈机,指令语义偏高级,控制流结构清晰。IBM 的 Daniel Ford 在 1996 年 5 月写了个叫 Jive 的反编译器,一个月后荷兰开发者 Hanpeter van Vliet 放出了 Mocha 的 beta------第一个公开可用的 Java 反编译器,能把字节码还原成几乎能读的源码。
有意思的是,van Vliet 同时发布了另一个工具叫 Crema,是个混淆器。一边造矛一边造盾,同一个人,同一个月。Mocha 引出的争议不小,他一度把工具从网上撤了下来;同年 12 月他因癌症去世,才 34 岁。后来 Borland 把 Mocha 和 Crema 的源码都买了过去,JBuilder 里内置的反编译器就基于 Mocha,自带的混淆器用的招数是把 ASCII 变量名替换成控制字符。再往后的 JAD 更直接,它能自动把被混淆器搞乱的标识符重新转成合法名字。
所以混淆从诞生第一天起,就不是「加密」,是把符号表里的语义擦掉 。UserRepositoryImpl 变成 a,字节码一行没改,反编译器照样能还原出完整逻辑,只是人读起来费劲了。这一点到今天也没变。
真正让混淆变成刚需的是另一件事。2002 年 5 月 1 日,比利时工程师 Eric Lafortune 往 ProGuard 仓库提交了第一行代码,6 月发布 1.0。他后来说这就是个业余项目,「不过我对业余项目挺认真的」。当时的目标是给 Java 应用瘦 JAR 包,覆盖桌面、服务器,以及 J2ME 那些存储和内存都抠得很紧的早期移动设备。ProGuard 1.0 只有两个功能:shrinking(后来大家叫 tree shaking)和名字混淆,字节码优化是 2004 年 8 月才加的,方法内联、常量传播、两百多条 peephole 优化要到 2007 年的 4.0。
那时候短名字为什么值钱,得看 dex 的结构。类名、方法名、字段名在 dex 里不是各存一份,而是统一进 string_ids / string_data,type_ids、field_ids、method_ids 全都用索引指过去。com.example.app.data.user.HealthRecordRepositoryImpl 这一串 UTF-8 得五十多个字节,换成 a.b.c 只要五个。一个稍微大点的 App 里几万个符号,累积下来是实打实的体积,而且短字符串在常量池里复用率更高。J2ME 年代一个包能不能塞进设备,就差这点空间。
这条主线到今天都还在。Android 官方讲 R8 的文档里,obfuscation 那一节是这么写的:「为了减小 DEX 文件的体积,R8 缩短类、字段和方法的名字」 ,举的例子是 com.example.MyActivity 变成 a.b.a。它把混淆归在「应用优化」里,跟方法内联、类合并、tree shaking 并列,压根没放在安全章节。
我把三个词重新排了一下,后面写规则时一直贴着这张表:
| 手段 | 最初要解决的 | 顺带带来的 | 判断它好不好用的尺子 |
|---|---|---|---|
| Shrinking | 删掉没被调用的代码,包太大 | 减少攻击面 | usage.txt 删了多少、dex 小了多少 |
| Optimization | 跑得快、体积小 | 结构变化让静态分析更难 | 方法数、冷启动、包体积 |
| Obfuscation | 短名字省 dex 空间 | 语义被擦掉,逆向成本上升 | 重命名覆盖率、mapping 可还原性 |
把混淆当安全技术,是后来才有的叙事。它骨子里是个体积工具。想明白这个,就不会为了「看起来更安全」去加一堆保守 keep------那等于把体积优化也一起废掉,开篇那条 -keep class com.example.** { *; } 就是活例子。
名字到底是怎么被改掉的
先看东西长什么样。Java .class 文件里,类名、方法名、字段名、描述符都躺在常量池的 CONSTANT_Utf8 项里,形如 Lcom/example/UserRepository; 或者 (Ljava/lang/String;)Lhealth/Record;。改名字不是替换一个字符串就完事,描述符里出现的每一处类型名都得跟着改 ,否则加载时直接 VerifyError。
到了 dex 这一层,结构更紧凑:
text
dex
├── header # magic、checksum、各段偏移
├── string_ids # 所有字符串的索引,指向 string_data
├── string_data # 实际的 UTF-8 字节,类名/方法名/字段名都在这
├── type_ids # 指向 string_ids 的下标
├── proto_ids # 方法签名(返回类型 + 参数列表)
├── field_ids # class_idx + type_idx + name_idx
├── method_ids # class_idx + proto_idx + name_idx
├── class_defs # 类定义、superclass、interfaces、注解
├── code # 字节码
└── map_list
method_ids 里的 name_idx 指向 string_ids,class_idx 指向 type_ids 再指回 string_ids。重命名就是把这几张表里的索引目标统一换掉,保证引用关系自洽。这也是为什么混淆必须放在编译链的最后阶段------前面的内联、类合并还在改结构,名字动早了就全乱了。
编译链这几年换过一次,两张图对着看最清楚:
R8 在 AGP 3.3 引入、3.4 成为默认,最大的变化不是把工具换了个名字,而是把脱糖、缩减、优化、混淆、dex 编译合成了一次全程序分析 。以前 ProGuard 分多遍扫 .class,dx 再单独编 dex,中间信息是断的;现在 R8 拿着完整的引用图做决策,能删掉更多死代码、合并更多类。
R8 里负责改名字的是 com.android.tools.r8.naming.Minifier,分三个子阶段跑:ClassNameMinifier → MethodNameMinifier → FieldNameMinifier,每一步产出一个 NamingLens 给后面的阶段用。名字分配策略比想象中朴素,就是按字典顺序取 a、b、c......用完接 aa、ab,BaseMinificationNamingStrategy.nextString() 干的就是这事。所以混淆后满屏的 a.b.c 不是随机生成的,是「最短可用标识符」的贪心结果------又绕回省体积那条线上了。
四类混淆,R8 只管前两类
我把常见手法按作用对象分了四层,这个划分后面跟 Trae 提需求时反复用到:
| 层 | 做法 | R8 做不做 | 代价 |
|---|---|---|---|
| 标识符 | 类/方法/字段改名、包结构打散 | 做,核心能力 | 反射、JNI、序列化全得配 keep |
| 结构 | 方法内联、类合并、无用分支消除 | 做,属于 optimization | 堆栈变浅、行号变化、反射 private 成员失效 |
| 控制流 | 扁平化、不透明谓词、垃圾指令 | 不做 | 包变大、跑变慢、容易被模式识别 |
| 数据与字符串 | 字符串加密、常量隐藏、资源名混淆 | 不做(资源缩减是另一回事) | 运行期解密有开销,密钥仍在端上 |
第三、四层是 DexGuard 这类商业方案、加固壳、OLLVM 那些 native 工具的地盘。开了 isMinifyEnabled = true 只拿到前两层,别指望它能藏住硬编码在字符串里的接口地址或者密钥。我上一篇写加固时踩过这个认知差,这次专门把边界写出来。
哪些名字不能动
改名字的自由度,全被「运行期靠字符串找东西」的场景吃掉了:
- Manifest 里声明的四大组件。系统按类名字符串去实例化,改了就起不来。好在 aapt2 打包时会自动生成一份 keep 规则,不用手写。
- JNI 静态注册 。native 层按
Java_包名_类名_方法名找符号,类名一改就UnsatisfiedLinkError。要么 keep,要么改成RegisterNatives动态注册。 - 反射 。
Class.forName("com.example.Foo")这种字符串拼接,R8 静态分析看不见,最容易在灰度炸。 - 序列化 。
kotlinx.serialization、Gson 的TypeToken、Room 生成的 DAO/实体、Parcelable 的CREATOR,字段名和构造函数名常常是协议的一部分。 - 注解值里引用的类 、资源文件名与
getIdentifier()拼出来的资源名、外部 SDK 通过反射调的回调。 - 对外暴露的 API。library 模块的 public 接口被别的应用依赖,改了就破坏二进制兼容。
这里的取舍特别朴素:keep 加得越多越安全(不会崩),但混淆率和体积收益越低。一个健康的 proguard-rules.pro 里,每条 keep 都应该能回答「去掉会在什么场景崩」,答不上来的就是债务。
mapping.txt:混淆的「撤销键」
改完名字,线上崩溃堆栈会变成 at a.b.c.a(SourceFile:12)。R8 同时吐出 mapping.txt,记录原名到新名的双向映射:
text
com.example.app.data.UserRepositoryImpl -> a.b.c.d:
java.lang.String baseUrl -> a
14:32:void <init>(android.content.Context) -> <init>
41:56:public health.Record load(java.lang.String) -> a
# {"id":"com.android.tools.r8.mapping","version":"2.2"}
com.example.app.ui.health.HealthRecordViewModel -> a.b.e.a:
...
有了它,retrace 能把混淆堆栈还原回源码行号。前提是 -keepattributes SourceFile, LineNumberTable(AGP 8.6 起 LineNumberTable 默认保留,SourceFile 8.2 起默认保留),加上 -renamesourcefileattribute 把真实源文件名抹成统一值。
两件事我一直当铁律:mapping 必须和 APK 一一对应归档 ,文件名带 versionName + commit,被下一次构建覆盖就等于那次发版的崩溃永远读不懂;mapping 绝不能打包进 APK 或传到公开的地方,它一旦泄露,混淆基本白做------拿着 mapping 和 jadx,还原出来的代码跟源码只差注释。
顺带说个反直觉的点:现在官方文档把混淆叫做 minification,把可还原性做得越来越重(8.6 改进了低 minSdk 的 retrace,9.x 还在加)。这本身就说明,官方的优先级是「优化 + 出问题能查」,不是「让逆向者看不懂」。
一个名字,走完全程
抽象讲完容易糊,拿我们项目里真实存在的一个类,从头到尾看一遍。
源码里它是这样:
kotlin
package com.example.app.data.health
class HealthRecordRepositoryImpl(
private val api: HealthApi,
) : HealthRecordRepository {
override suspend fun load(userId: String): HealthRecord {
val raw = api.fetch("/v3/health/records?uid=$userId")
return HealthRecordParser.parse(raw)
}
}
编译成 .class,常量池里躺着这些 CONSTANT_Utf8:
text
Lcom/example/app/data/health/HealthRecordRepositoryImpl;
Ljava/lang/String;
(Ljava/lang/String;)Lhealth/HealthRecord;
load
fetch
/v3/health/records?uid=
进 dex 之后,这些字符串进 string_data,type_ids / method_ids 用索引指过去;R8 的 ClassNameMinifier、MethodNameMinifier、FieldNameMinifier 跑完,索引还是那些索引,指向的内容变了:
text
com.example.app.data.health.HealthRecordRepositoryImpl -> a.b.c.d
load -> a
api -> a
HealthRecordParser -> a.b.c.e
mapping.txt 把这些对应关系一条条记下来,jadx 打开最终包看到的是:
java
package a.b.c;
public final class d implements a {
private final b a;
public final Object a(String str, Continuation continuation) {
Object a2 = this.a.a("/v3/health/records?uid=" + str, continuation);
return e.a(a2);
}
}
把五处摆在一起,混淆到底改了什么就很清楚了:
| 位置 | 这个类长什么样 | 有没有语义 |
|---|---|---|
| 源码 | HealthRecordRepositoryImpl.load(userId) |
全有 |
.class 常量池 |
Lcom/example/.../HealthRecordRepositoryImpl; |
全有,反编译器能还原源码 |
dex string_ids |
指向 a.b.c.d / a |
名字没了,结构还在 |
mapping.txt |
... -> a.b.c.d |
全有,这是撤销键 |
| jadx 还原 | d.a(String) + 明文接口路径 |
名字没了,逻辑和字符串还在 |
看最后一列就明白:混淆擦掉的是名字,不是语义 。接口地址 /v3/health/records?uid=、字段名(如果序列化协议要求保留)、资源名、注解值里的类名、字符串常量,全都原样躺着。顺着 a.a(...) 往上追两三层,业务结构照样能画出来。
所以每次有人问「混淆了是不是接口就不会被扒」,我都拿这张表回。真要护住接口地址,得靠服务端鉴权和别把密钥放客户端,不是靠 R8 把类名改成 d。反过来,混淆该拿的收益一点没少:光这个类,类名从五十多字节变成六个,方法名从四个变成一个,全工程几万个符号累积起来就是那 3.5MB。
这二十来年,工具链换了几轮
我把查到的时间线按「对写规则的人有什么影响」重排了一遍。年份这东西平时用不上,一旦构建行为突然变了,能省掉半天瞎猜:
| 时间 | 发生了什么 | 对混淆的实际影响 |
|---|---|---|
| 1996.05 / 06 | IBM 的 Jive、van Vliet 的 Mocha 反编译器与 Crema 混淆器 | 混淆的起点是「擦掉符号表语义」,不是加密 |
| 2002.05 / 06 | ProGuard 首次提交、1.0 发布,借用 Mark Welsh 的 RetroGuard 的类解析代码 | 只有 shrinking + 名字混淆,为 J2ME 省包体积 |
| 2004.08 / 2007.09 | 字节码优化加入;4.0 带来内联、常量传播、250+ peephole | 混淆开始和优化绑在一起 |
| 2010.12 | ProGuard 随 Android 2.3 Gingerbread 集成进 Android SDK | proguard-android.txt 时代开始,minifyEnabled 成了标配 |
| 2012 / 2014 | DexGuard 诞生;Guardsquare 成立 | 字符串加密、控制流混淆这些走向商业方案,开源线不再覆盖 |
| 2018 · AGP 3.3 | R8 作为可选项引入(useProguard false),ProGuard 仍是默认 |
规则语法沿用,行为开始分叉 |
| 2019.04 · AGP 3.4 | R8 成为默认,脱糖/缩减/混淆/优化/dex 合成一步 | 官方 release notes 明说:可能移除 ProGuard 不会移除的代码,需要补规则 |
| AGP 7.0 | ProGuard 移除,useProguard / android.enableR8 开关删掉,Java 11 起步 |
回退通道没了;full mode 作为 opt-in 出现 |
| 2023 · AGP 8.0 | full mode 成为默认 | 老规则语义大面积变化,android.r8.failOnMissingClasses 默认开启,missing_rules.txt 出现 |
| AGP 8.2 / 8.3 | -allowaccessmodification 默认开;SourceFile 默认保留;精确资源缩减默认 |
private 成员可能被改成 public;堆栈还原条件变好 |
| AGP 8.6 | retrace 改进到所有 minSdk(此前要 26+),LineNumberTable 默认保留 |
低版本设备上的崩溃也能还原了 |
| AGP 8.12 | optimizedResourceShrinking 可选(android.r8.optimizedResourceShrinking=true) |
资源第一次真正进入 R8 的引用图 |
| AGP 9.0 | 资源优化缩减默认;proguard-android.txt 不再支持(它带 -dontoptimize);library 的全局选项被过滤;Kotlin null check 优化默认 |
一批老项目的构建配置直接报错 |
| AGP 9.1 | -repackageclasses 对 app 默认开启 |
不用再手写打散包结构,要退出用 -dontrepackage |
| AGP 9.2 alpha | 成员名模式支持取反;-keepattributes *Annotation* 不再匹配 RuntimeInvisible* 家族 |
靠通配符省事的规则要逐条改写 |
| AGP 9.3 | 新 optimization { enable = true } DSL、keepRules source set、KMP consumer rules |
配置入口整个换了一层 |
| 2026.04 | 独立项目 ProGuard 发到 7.9.1 | 源头还在维护,但 Android 这条线已经完全走 R8 了 |
看这张表最容易产生的错觉是「规则语法一直没变,所以老配置应该还能用」。恰恰相反。语法没变,语义变了四轮。
full mode 到底改了什么
AGP 8.0 之后默认的全量模式,跟以前的兼容模式有四处差异,每一处都能让一个跑了五年的老 App 在灰度崩掉:
| 差异 | 兼容模式 | full mode | 典型翻车 |
|---|---|---|---|
| 属性保留 | Signature、InnerClasses、EnclosingMethod 默认保留 |
只对显式 keep 的类与成员保留 | 靠泛型签名做反射的代码静默拿不到类型 |
| 默认构造器 | 类被保留时隐式保留 | 不再隐式保留 | Class.newInstance() / getDeclaredConstructor().newInstance() 抛异常 |
| 可见性 | 不动 | 为启用内联,private 可能被改成 public | 反射查找 getDeclaredMethods() 后按 private 过滤的逻辑失效 |
| 引用图判定 | 相对保守 | 只被反射字符串引用的类会被判定为不可达,直接从 dex 删掉 | 插件化、SPI、按类名字符串实例化的功能整块消失 |
第四条是当年升级 AGP 8 时最集中爆的一类问题:代码里明明有 Class.forName("com.example.Foo"),R8 看不见这个字符串跟 Foo 的关系,直接把类删了,而且编译期一声不吭。好在同一版本给了 android.r8.failOnMissingClasses=true 和 app/build/outputs/mapping/release/missing_rules.txt,把推断出来的 keep 规则写在那儿给人抄。我的习惯是只抄那一条、收窄到具体类,别顺手把整个包加进去。
还有一个更细的:AGP 9.0 起 android.r8.strictFullModeForKeepRules=true 之后,连裸的 -keep class A 都不再隐式保住默认构造器了。规则写得越「像样」,越容易被这种语义迁移扎一下。
Kotlin 和 Compose 把规则这件事变简单了,也变隐蔽了
Kotlin 项目里,绝大部分 keep 规则不该由人写:
kotlinx.serialization、Hilt/Dagger、Moshi codegen 这些,各自的 Gradle 插件会自动产出规则;手抄一份到proguard-rules.pro是重复且容易过时的。- Compose 的每个 artifact(
runtime、ui、foundation、material3......)都自带consumer-rules.pro,R8 自动收。最忌讳的就是自己加-keep class androidx.compose.** { *; }------宽泛 keep 会把 lambda 分组、sourceInformation 剥离、常量折叠、ComposerImpl去虚拟化全挡掉,优化直接废掉。 proguard-android.txt与proguard-android-optimize.txt只差一个词,前者带-dontoptimize,等于把优化 pass 全关了。AGP 9.0 起干脆不再支持前者。这个坑古老到 2026 年还有人踩。- Compose 的 debug 和 release 完全是两个性能世界,官方博客里给过大约 75% 启动、60% 帧渲染的差异量级。只测 debug 等于没测混淆。
我后来立了条规矩:proguard-rules.pro 里出现任何 androidx.compose 开头的 keep,一律先当 bug 处理。
资源那一支,走的是另一条路
代码混淆之外还有一支叫资源混淆,早年第三方方案(微信的 AndResGuard 那一类)把 res/drawable/xxx 路径改短来压包,同时改 resources.arsc。效果不错,但代价是碰到 Resources.getIdentifier() 拼出来的资源名、第三方 SDK 里硬编码的路径、以及 ROM 的资源查找时容易出问题。我一直没在自己的项目里引这类库,和一贯的取舍一致:能不动构建管线就不动。
官方这几年把这块慢慢收编了。老管线里,AAPT2 为了防止「资源引用到的类被 R8 删掉」,会生成一批无条件的 keep 规则;结果就是一堆本来该死的代码被保住,连带它引用的资源也留下来。AGP 8.12 的 optimizedResourceShrinking 把资源直接放进 R8 的引用图,代码和资源一起判定可达性,不再需要那些无条件规则;官方博客里说,跨形态共享大量资源和代码的应用,包体积改善能超过 50%。AGP 9.0 起它变成默认行为。
对写规则的人,实际影响就两条:tools:keep / tools:discard 别再当主力,动态拼出来的资源名(getIdentifier、服务端下发的资源 key)要单独列清单,否则资源优化缩减会把它当无用资源删掉。
一句话概括这条演进
混淆的重心,从「改名字让人看不懂」,挪到了「全程序优化 + 出事能还原」。官方越来越把它当性能工具在打磨:内联、类合并、资源引用图、retrace、Startup Profile,全是这条线上的投入。谁还把它当「防破解开关」,谁就会在每次 AGP 升级时被语义迁移绊一跤。
想明白这些,我给 Trae 提的需求就跟第一次完全不同了。第一次我说的是「帮我把这个项目的混淆做得更强」,它给我回了一版看起来很专业的规则文件,里面加了三条 -dontwarn、两条全包 keep,还贴心地把 -dontobfuscate 注释掉留着备用。现在我要求的是:先量,再删,最后才是加。
给 Trae 立的规矩
模式分工和上一篇写加固时一样:读代码、看调用链用 IDE 模式的 Chat,挂 #Code 上下文;改文件、跑构建、迭代修错误用 Agent;核对 AGP 版本行为和官方文档开 SOLO 联网。去年那批教程里写的 Builder 早就在 2026 年 5 月并进 SOLO Agent 了,这次没再找错地方。
.traeignore 这次的重点不一样。加固那篇要挡的是密钥和签名文件;混淆这篇反而需要 把 mapping.txt、seeds.txt、usage.txt 挂进上下文分析,但不能让 build/ 下几万个中间文件把索引撑爆:
gitignore
# .traeignore
**/keystore/**
**/*.jks
**/*.keystore
local.properties
**/.env
**/.env.*
# build 产物整体忽略,只把下面三份归档单独挂上下文
**/build/intermediates/**
**/build/tmp/**
**/build/outputs/logs/**
**/build/outputs/apk/**
**/mapping/**
!docs/obfuscation/mapping-samples/**
**/decompiled/**
项目规则放在 .trae/rules/obfuscation.md,alwaysApply: true。这份是我删了那条兜底 keep、灰度崩了两次之后重写的,每一条都对应一次具体返工:
markdown
---
description: 修改 proguard-rules.pro / 构建脚本 / keep 规则时的总纲,
所有涉及 R8、混淆、mapping、release 构建的对话都遵循
alwaysApply: true
---
# 项目背景
医疗健康类 Android 应用,Kotlin + Compose 与少量 XML View 混用,
多模块,minSdk 26 / targetSdk 36,AGP 8.x,R8 full mode 已开启,
含 kotlinx.serialization、Room、Hilt、若干 JNI 方法与第三方 SDK。
# 目标排序
混淆的第一目标是减小 DEX 体积,第二目标是抬高静态分析成本,
「看起来更安全」不算目标。不要用保守 keep 换心理安慰。
# 硬约束
- 不引入任何第三方混淆 / 加固 / 字符串加密库,不改编译链。
- 禁止 `-keep class <业务包>.** { *; }` 这类兜底规则,
禁止 `-keep class androidx.** { *; }` 这类框架级宽泛 keep。
- 每条新增 keep 必须写明:依据(哪份文档、哪个运行时行为)、
去掉后会在什么场景崩、对应的回归用例。
答不上来就不要写,标「待确认」等我判断。
- 不改业务代码去「配合」混淆。发现反射拼类名这类写法时,
先给 A 收窄 keep / B 改成编译期常量引用 两个方案与代价,我选完再动手。
- 不新增 `-dontwarn`、`-dontoptimize`、`-dontobfuscate`;
发现已有的要单独报出来,不要顺手删。
- release 必须用 `proguard-android-optimize.txt`,
不是 `proguard-android.txt`。
- mapping / seeds / usage 绝不打进 APK、绝不进公开仓库;
归档目录名带 versionName + commit,同名构建直接失败,不做覆盖。
- Compose / kotlinx.serialization / Hilt 的 consumer rules 由插件提供,
不手写重复规则,只验证它们确实生效。
- 涉及 AGP / R8 版本行为时标出版本与文档出处,
不确定写「需人工核对」,不要补全版本号或默认值。
# 交付要求
- 先给现状数据(seeds / usage / mapping 条目数、dex 体积、混淆率),
再谈改动;数字取实测,不给估计值。
- 每轮列出改动文件、验证命令、以及本轮产出的可归档文件。
- 规则文件按「为什么需要」分组加注释:反射 / JNI / 序列化 /
Parcelable / SDK 要求 / 注解生成类 / 对外 API。
- 不写 TODO、不写占位符、不写「其余略」。
提示词还是五段式,Trae 官方文档里那个结构,用在「改动面广、验收严」的任务上刚好:
text
Goal: 这轮结束时应该存在什么,可观察、可验收
Context: @ 哪些文件 / 上一轮改了什么 / 当前构建配置
Requirements: 逐条列出行为
Constraints: 明确不许改什么
Validation: 跑哪条命令、看哪个文件、什么结果算过
Constraints 里固定带这三句,比长篇背景管用:
text
- 不引第三方混淆库,不改编译链,不加兜底或框架级宽泛 keep
- 不改业务代码去适配混淆;反射点先给方案让我选
- 涉及 AGP / R8 版本行为标出处,不确定写「需人工核对」,不要补全
再加一个前面没提但很关键的约定:先把「怎么算混淆好了」定义清楚,否则 Trae 会用自己的标准交差。我用的是四个数:
text
重命名覆盖率 = mapping.txt 里出现 `->` 的类条目数 / 参与混淆的类总数
seeds 条目数 = 被 keep 住的类与成员数量(越少越好,但要有依据)
usage 条目数 = 被删掉的类与成员数量
dex 体积 = classes*.dex 字节数之和(与未混淆基线比)
四个数缺一不可。只看覆盖率,它可以用 -dontshrink 把类全留着换一个漂亮数字;只看体积,它可能靠激进删代码把功能删没了。后面每一轮的 Validation 都绕着这四个数转。
十二轮提示词,我按这个顺序跑完的
顺序是踩坑踩出来的:前四轮只做体检、核对版本、归并规则来源、删兜底,一行新增 keep 都不写;中间五轮才动规则和字典;最后三轮做验证、CI 卡点和回滚。先量后改,比一上来让它「重写混淆规则」省了至少两轮返工。
每轮都写清楚模式、@ 哪些文件、提示词原文、我实际改了什么、拿什么验收、跑偏时怎么追。提示词块整段复制即可。
第 1 轮 · 给现状做个体检
IDE 模式 / Chat,#Code 挂上所有规则文件和构建脚本,明确不改文件。
text
Goal: 产出 docs/obfuscation/baseline.md,说清楚这个项目当前的混淆状态,
只做分析,不修改任何文件。
Context: @app/proguard-rules.pro @app/build.gradle.kts
@gradle.properties @gradle/libs.versions.toml
以及各模块的 consumer-rules.pro 与 build.gradle.kts。
@docs/obfuscation/mapping-samples/ 下有上一次 release 的
mapping.txt / seeds.txt / usage.txt 归档样本。
Requirements:
1. 列出所有生效的 keep 规则来源,分成五类并标注来源文件:
本模块 proguard-rules.pro / 库模块 consumer-rules.pro /
aapt2 自动生成 / Gradle 插件生成 /
getDefaultProguardFile 引入的默认规则。
2. 找出所有「宽泛规则」:匹配范围超过一个包、
带 `{ *; }` 全成员、或带 `**` 的 keep;
逐条说明它覆盖了哪些类、为什么危险。
3. 找出所有 -dontwarn / -dontoptimize / -dontobfuscate /
-dontshrink,标明是谁加的、能不能去掉。
4. 报告当前 AGP / Gradle / R8 版本,以及 full mode 是否开启
(检查 gradle.properties 里有没有 android.enableR8.fullMode=false)。
5. 用归档样本算出四个数:重命名覆盖率、seeds 条目数、
usage 条目数、classes*.dex 体积之和。给出计算口径与命令。
6. 单独一节「无法从构建配置推断的内容」:
哪些类实际靠反射调用、哪些资源靠 getIdentifier 拼名字、
有没有外部 SDK 文档要求的 keep,列出来让我人工补。
Constraints: 不修改任何文件;不引入依赖;
数字全部来自归档样本实测,不给估计值;
推断不出来的写「待确认」,不要填一个看起来合理的值。
Validation: 我核对 seeds.txt 条目数与规则条数的对应关系,
并用 `unzip -l <apk> classes*.dex` 复核 dex 体积;
对不上就指出差异,重出报告。
这轮跑出来的结果比我预想还难看:seeds 三万七千多行,重命名覆盖率不到一成。除了那条 -keep class com.example.** { *; },还有两条 SDK 文档抄来的 -keep class com.third.** { *; },以及五条 -dontwarn------某位前辈为了让构建通过加的,警告被咽下去了,问题还在。
我追了一句,把「谁加的」问清楚:
text
把每条宽泛规则和 dontwarn 的 git blame 信息列出来
(提交 hash、时间、提交信息),并按「有明确文档依据 /
历史遗留无依据 / 为了压掉构建警告」三类归档。
不要建议删除,只给事实,我来决定。
这条追问后来贴在整改单里,比「优化混淆配置」这种描述有用得多。
第 2 轮 · 把当前版本的行为核准
SOLO 模式,开联网,让它自己拉官方文档。
text
Goal: 产出 docs/obfuscation/version-behavior.md,
列出当前工具链里所有会改变 keep 规则语义的版本行为。
Context: @docs/obfuscation/baseline.md @gradle.properties
联网核对以下原文,不要凭记忆复述:
- Android Developers「Enable app optimization with R8」及
「AGP and R8 version behavior changes」表
- AGP 3.4.0 release notes(R8 成为默认那一段)
- AGP 8.12 / 9.x 关于 optimizedResourceShrinking、
proguard-android.txt、repackageclasses、keepRules source set 的说明
- R8 retrace 与 mapping 的官方文档
Requirements:
1. 表格列:AGP 版本 / 变化点 / 原文摘录 / 对本项目哪一条现有规则有影响 /
需要做什么 / 文档链接。
2. 单列一节讲 full mode 与兼容模式的差异,
至少覆盖属性保留、默认构造器、可见性修改、反射类被删四类,
每类给一个能复现的最小代码示例。
3. 明确回答:本项目当前用的
getDefaultProguardFile("...") 是哪个文件,
它带不带 -dontoptimize,当前 AGP 还支持吗。
4. 明确回答:本项目有没有设置 android.r8.optimizedResourceShrinking,
当前 AGP 下它是默认开启还是要手动开。
5. 列出「如果明年升级到 AGP 9.x,现有规则里哪几条会先出问题」,
按风险排序,每条给迁移动作。
Constraints: 每条结论带版本与出处;区分「原文明确要求」与「工程推断」;
不确定写「需人工核对」,不要补全版本号或默认值;
不修改任何文件。
Validation: 我逐条比对官方文档,
重点看 full mode 四差异与 9.x 的规则过滤;
任何一条对不上就整表重出。
这轮最值钱的不是表,是它把「语法没变、语义变了」这件事具体化成了四条可复现的最小代码。我照着第一条写了个靠泛型签名做反射的小测试,在 full mode 下确实静默拿不到类型,编译期零报错。这种东西靠读 release notes 很难有体感。
有一处它把 AGP 9.3 的新 DSL 说成了「所有版本都支持」,我打回去重出:
text
9.3 的 `optimization { enable = true }` 和 keepRules source set
只在该版本及之后成立,把它标清楚,并注明 legacy DSL 仍受支持。
不要把未来版本的行为写成当前项目的现状。
第 3 轮 · 规则到底从哪来,谁覆盖谁
IDE 模式 / Chat。
text
Goal: 产出 docs/obfuscation/rule-sources.md,
把最终参与构建的那份规则拼出来,并标出冲突。
Context: @docs/obfuscation/baseline.md
@app/build/outputs/mapping/release/configuration.txt
(R8 会把最终合并后的配置写在这里,路径按实际产物调整)
Requirements:
1. 从 R8 的 configuration.txt(或等价产物)还原出
本次 release 实际生效的完整规则集,按来源分段标注。
2. 找出重复规则、互相矛盾的规则
(例如同一处既被 keep 又被 assumenosideeffects)、
以及被后面的规则覆盖掉的写法,逐条列出。
3. 对每个库模块,确认它的 consumer-rules.pro 是否被主工程收到;
没被收到的列出模块名与原因。
4. 确认 kotlinx.serialization / Hilt / Room / Compose 的
插件生成规则确实生效,指出产物文件路径;
不要让我再手写一遍。
5. 给出一条可以本地复现「打印最终生效配置」的命令,
并把输出归档到 docs/obfuscation/evidence/。
Constraints: 不修改任何规则文件;
不合并或化简规则,只做还原与标注;
找不到的产物明确说「本地没有,用哪条 task 生成」。
Validation: 我拿 configuration.txt 与 baseline.md 对一遍来源标注,
随机抽三条规则定位到原始文件。
「谁覆盖谁」这块平时没人查,直到某天发现一条自己写的 keep 压根没生效。我这轮就查到两个库模块的 consumer-rules.pro 没被收进主工程------consumerProguardFiles 漏配了,之前一直靠主工程里一条宽泛 keep 兜着,所以没暴露。
第 4 轮 · 把兜底 keep 拆掉
IDE 模式 / Agent,第一轮真正动文件。
text
Goal: 删除或收窄 baseline.md 里列出的宽泛 keep 规则,
产出规则 diff 与回归清单,本轮不新增任何 keep。
Context: @app/proguard-rules.pro @docs/obfuscation/baseline.md
@docs/obfuscation/rule-sources.md
@.trae/rules/obfuscation.md
Requirements:
1. 逐条处理宽泛规则:能直接删的删;
删不掉的收窄到具体类或具体成员,并写明依据。
每条给出「原规则 / 新规则 / 影响哪些类 / 删除后的风险场景」。
2. 本轮不新增任何 keep,也不加任何 -dontwarn。
遇到需要保住的场景,先记进「待补清单」,
下一轮按反射 / JNI / 序列化分组处理。
3. 保留规则文件里原有的分组注释结构,
每条改动在 diff 里能看出前后对照。
4. 跑一次 :app:assembleRelease,把新的
mapping.txt / seeds.txt / usage.txt 归档到
docs/obfuscation/evidence/round-04/,
并算出与第 1 轮相同的四个数,做成前后对比表。
5. 产出 docs/obfuscation/regression.md:
列出删掉这些规则后必须人工验证的功能,
按「反射 / JNI / 序列化 / 组件启动 / SDK 初始化 / Compose 首屏」
分组,每项写清验证路径。
Constraints: 不改业务代码;不引依赖;
不加兜底或框架级宽泛 keep;
构建失败时先报告完整错误,不要自动加 -dontwarn 压掉;
数字取实测,不给估计值。
Validation: 构建通过;四个数的前后差值写进对比表;
regression.md 里每一项都要有可执行的验证路径,
没有的就标「待确认」。
这一版跑完,重命名覆盖率从不到一成跳到八成多,dex 小了将近 4MB。我当时挺得意,直接拿去灰度,三小时后就收到了 ClassNotFoundException------就是开篇说的那次。问题不在 Trae,它的 regression.md 里明明列了「反射拼类名」这一项,是我没跑。
后来我让它把这轮重做了一遍,加了句硬约束:
text
在 regression.md 每一项后面补一条「自动化还是人工」的标记,
能写成 instrumentation 测试的当场写出来,
不能自动化的明确写「必须人工」并给操作步骤。
不要把所有项都列成待办,我看不出优先级。
改完之后,清单里 17 项有 11 项变成了能跑的测试,剩下 6 项才是我真要手动点的。
第 5 轮 · 把该留的一个个挑出来
IDE 模式 / Agent。这轮是整套流程里最费人的一轮,也是唯一在「加规则」。
text
Goal: 为反射 / JNI / 序列化 / Parcelable / SDK 要求逐类生成最小 keep 规则,
每条带依据与回归用例,产出 docs/obfuscation/keep-rationale.md。
Context: @app/proguard-rules.pro @docs/obfuscation/regression.md
@docs/obfuscation/rule-sources.md
@.trae/rules/obfuscation.md
Requirements:
1. 静态扫描整个工程,找出所有「按字符串找类或成员」的写法:
Class.forName / ClassLoader.loadClass / getDeclaredMethod /
getDeclaredField / Method.invoke / 字符串拼包名 / 注解值里的类名 /
Resources.getIdentifier / 反射调用 Saver 或 Creator。
每条给文件与行号,标注「R8 能静态看见」还是「看不见」。
2. 对 R8 看不见的,逐条给两个方案:
A 收窄 keep(给出精确到类或成员的规则),
B 改成编译期常量引用或 RegisterNatives 动态注册,从根上不靠反射。
列清各自代价,不要直接选一个动手,等我确认。
3. JNI:列出所有 native 方法及其所在类,
确认 -keepclasseswithmembernames class * { native <methods>; }
是否覆盖;给出改成 RegisterNatives 的迁移成本估算。
4. 序列化:验证 kotlinx.serialization / Room / Hilt 的插件生成规则
已生效(指出产物路径),只处理插件没覆盖的自定义部分;
不要重复写框架规则。Parcelable 的 CREATOR 单独一组。
5. 每条新增规则按「为什么需要」分组,注释里写:
依据(文档或运行时行为)、去掉会在什么场景崩、对应哪个回归用例。
6. 输出 keep-rationale.md:规则 / 依据 / 风险场景 / 回归用例 /
是否可自动化,五列齐全。
Constraints: 不引依赖;不改业务代码(方案 B 只给不改);
禁止 -keep class ** 这类兜底和框架级宽泛 keep;
禁止用 -dontwarn 压掉构建警告;
每条规则的范围收窄到最小的类或成员;
不确定依据的写「待确认」,不要编 SDK 文档要求。
Validation:
- ./gradlew :app:assembleRelease 通过
- 把 keep-rationale.md 里每条规则与 seeds.txt 对照,
确认没有规则意外覆盖到整包
- 算出新的四个数,与上轮对比,说明覆盖率下降了多少、为什么值得
- 跑 regression.md 里所有标了「自动化」的用例
这轮 Trae 第一版给了个 -keep class **.Saver { *; },理由写得挺像回事:「Compose 的 rememberSaveable Saver 可能被反射访问」。实际上绝大多数 Saver 都能从非反射调用链追到,R8 自己会保住;这条宽泛 keep 反而把内联和消除挡掉了。我打回时的追问是:
text
逐条说明这些 Saver 里,哪几个真的被按名字反射加载。
给出调用点文件与行号;拿不出调用点的全部删掉,
并在回归清单里标注「删掉后需要验证哪些页面」。
删完只剩两个真需要保住的,而且都是 INSTANCE 字段那种精确到成员的规则。这种「看起来合理但没证据」的规则,是混淆率最大的隐形杀手。
另一处返工是它想给某个 @Serializable 类手写三条 keep。我让它先去 rule-sources.md 里查插件生成的规则,查完发现插件已经覆盖了,手写那份纯属重复。后来这条写进了 Rules 里,成了固定约束。
第 6 轮 · full mode 的四类差异,逐个复现
IDE 模式 / Agent,接真机跑。
text
Goal: 用最小用例验证 full mode 会不会动到本项目现有代码,
产出 docs/obfuscation/evidence/full-mode-check.md。
Context: @docs/obfuscation/version-behavior.md
@app/proguard-rules.pro @docs/obfuscation/keep-rationale.md
Requirements:
1. 针对 full mode 与兼容模式的四类差异,各写一个能复现的
instrumentation 测试(用项目已有测试框架,不引新依赖):
a) 泛型签名反射:靠 Signature 属性拿泛型参数类型
b) 默认构造器:Class.newInstance() / getDeclaredConstructor().newInstance()
c) visible 变化:getDeclaredMethods() 后按 private 过滤
d) 只被反射字符串引用的类:确认它还在 dex 里
2. 每个测试先说明「如果 R8 动了手脚,会怎么失败」,
再给测试代码与预期结果;跑完把实测结果写进证据文件。
3. 对失败项,先给 A 收窄 keep / B 改代码去掉隐式依赖 两个方案,
列清代价,等我确认再改;不要自动放宽规则。
4. 检查 app/build/outputs/mapping/release/missing_rules.txt
是否存在、内容是什么;把它推断出的规则逐条与
keep-rationale.md 比对,只采纳有明确调用点的,且收窄到具体类。
5. 确认 android.r8.failOnMissingClasses 的当前取值,
以及构建有没有因为缺类报错;有就列出具体类与依赖来源。
6. 输出一份「当前 full mode 下仍然依赖隐式行为」的清单,
标出下次 AGP 升级时最先要复查的几条。
Constraints: 不引新测试框架;不改业务代码(方案 B 只给不改);
不加兜底 keep;不加 -dontwarn;
测试失败先报告完整用例与堆栈,不要改规则掩盖。
Validation: 四个测试全部跑出结论(通过或明确失败);
full-mode-check.md 里有每个用例的原始输出;
采纳的每条规则都能在 missing_rules.txt 或调用点里找到依据。
四个用例里挂了一个:Class.newInstance() 拿不到默认构造器,正是 full mode 第二条。两个方案摆在我面前------加一条 void <init>(); 的 keep,或者把那个实例化改成工厂方法。我选了后者,顺手清掉一处十年前的代码。这类地方 AI 直接给补丁最快,但不问一句就永远不知道自己在还什么债。
第 7 轮 · 字典和包结构,试过之后我退回了默认
IDE 模式 / Agent。这轮的结论是「不做」,但过程值得记下来。
text
Goal: 评估自定义混淆字典与包结构打散对本项目的收益,
给出实测数据后再决定是否启用。
Context: @app/proguard-rules.pro
@docs/obfuscation/evidence/round-04/(上一轮归档的 mapping 与 dex)
@.trae/rules/obfuscation.md
Requirements:
1. 生成三份候选混淆字典,分别用:
a) 单字母最短标识符(贴近 R8 默认策略)
b) 常见单词
c) 非 ASCII / 不可见字符
写到 scripts/obfuscation/dictionaries/ 下,每份注明
合法标识符约束(哪些字符在 dex 层可用、哪些会被反编译器改写)。
2. 分别用 -obfuscationdictionary / -classobfuscationdictionary /
-packageobfuscationdictionary 打三个包,
每个包记录:dex 体积、mapping 可读性、
jadx 反编译后标识符的实际呈现、构建耗时。
3. 单独评估 -repackageclasses:
列出本项目所有「按包名字符串查找或过滤」的代码,
说明打散包结构会不会影响它们;
当前 AGP 版本下它是默认开启还是要手动加,
标出版本与出处。
4. 评估 -adaptclassstrings / -adaptresourcefilenames 的影响面。
5. 给出结论表:每个选项的收益、风险、是否建议启用。
收益为负的直接写「不建议」,不要为了凑方案硬说有好处。
Constraints: 不引第三方库;不改业务代码;
不启用会让 dex 变大的字典;
涉及 AGP 默认行为标版本与出处,不确定写「需人工核对」;
三个候选包都要能从干净构建复现。
Validation: 三组实测数字摆在一起;
任意一组与预期不符就先解释原因,不要改数据;
结论表里每个「建议启用」都要有数字支撑。
结果挺反直觉:花式字典是负收益 。非 ASCII 字符在 dex 层确实能当标识符,但 jadx 这类反编译器会自动把它们转换成合法名字------JAD 在 1990 年代就干这事了,等于白忙一场;而且长标识符让 string_data 变大,dex 反而胖了一圈。默认的最短名字既最省空间,也最不容易出兼容问题。
-repackageclasses 倒是留下来了:打散包结构之后 dex 又小了一点,且项目里没有按包名字符串做过滤的代码。不过它给了我一句提醒------当前 AGP 版本要手写,而 9.1 起对 app 是默认行为,将来升级得把这条规则删掉,否则重复。这类「版本相关的配置债」我让它单独列成一张表,跟 version-behavior.md 挂在一起。
这轮我特意在提示词里写了「收益为负直接写不建议」。不写这句,它一定会把三个选项都圆成「各有适用场景」。
第 8 轮 · 资源那半边,别让动态资源名被删掉
IDE 模式 / Agent。
text
Goal: 把资源缩减与代码混淆对齐,产出动态资源名清单与验证报告。
Context: @app/src/main/res/ @app/build.gradle.kts
@docs/obfuscation/version-behavior.md
@gradle.properties
Requirements:
1. 静态扫描所有动态资源引用:
Resources.getIdentifier、按字符串拼的 resource name、
服务端下发的资源 key、SDK 文档里要求保留的资源、
AndroidManifest / 通知图标 / 快捷方式里引用的资源。
逐条给文件与行号,标注「R8 可见」还是「不可见」。
2. 对不可见的,说明在当前 AGP 的资源缩减管线下会不会被删,
给出 res/raw/keep.xml 里 tools:keep 的最小写法,
或者改成编译期 R.xxx 引用的方案与代价,等我确认再动手。
3. 确认 isShrinkResources 与 isMinifyEnabled 都开启,
并核对 android.r8.optimizedResourceShrinking 在当前 AGP 下
是默认还是要手动开(标版本与出处)。
4. 分别打两个包(开 / 关资源缩减),
记录 APK 体积、resources.arsc 大小、res/ 条目数、
以及 classes*.dex 体积,做成对比表。
5. 产出 docs/obfuscation/dynamic-resources.md:
清单、保留方式、对应的验证路径(每个动态资源名怎么点得到)。
6. 输出 docs/obfuscation/evidence/resource-check.md,
含两次构建的原始数字与 aapt dump resources 的抽样结果。
Constraints: 不引第三方资源混淆库;不改 resources.arsc 生成流程;
不改业务代码(方案只给不改);
tools:keep 的范围收窄到具体资源,不写整个目录通配;
数字取实测,不给估计值。
Validation:
- 两个包都能安装并走通动态资源相关页面
- 逐条走完 dynamic-resources.md 里的验证路径
- 用 aapt dump resources 确认保留项确实还在
第一版它给了一条 tools:keep="@drawable/*",理由是「通知图标可能被动态引用」。我让它把「可能」变成调用点,最后只剩三个具体图标。这类通配是资源版的兜底 keep,效果一样:缩减收益全被吃掉,还留个看起来很安全的假象。
第 9 轮 · mapping 丢了,混淆就等于白做
IDE 模式 / Agent。这轮我等了很久才做,代价是一次线上崩溃读到怀疑人生。
text
Goal: 建立 mapping / seeds / usage 的版本化归档与崩溃还原演练,
产出可复现的 retrace 记录。
Context: @app/build.gradle.kts @docs/obfuscation/baseline.md
@.trae/rules/obfuscation.md
Requirements:
1. 写一个 Gradle 任务,在 assembleRelease 之后把
mapping.txt / seeds.txt / usage.txt / configuration.txt
复制到 docs/obfuscation/mapping/<versionName>-<gitShortCommit>/,
目录里附一份 build-info.json:versionName、versionCode、commit、
构建时间、AGP / R8 版本、规则文件哈希、APK 的 SHA-256。
2. 归档路径与 APK 产物一一对应,
命名里不许出现 latest / current 这类会被覆盖的名字。
3. 核对 -keepattributes SourceFile, LineNumberTable 与
-renamesourcefileattribute 的当前配置,
说明当前 AGP 下哪些是默认保留、哪些要手写(标版本与出处)。
4. 造一条混淆后的模拟堆栈,用当前 cmdline-tools 里的
retrace 还原到源码行号,把命令、mapping 路径、
还原前后的堆栈原文都写进证据文件。
再用「故意拿错版本的 mapping」跑一次,记录会出现什么结果。
5. 静态检查构建产物:确认 mapping.txt 没有被打进 APK、
没有出现在 assets / res 里、没有进版本库的公开目录。
6. 说明线上崩溃平台(Play Console 或项目现有崩溃 SDK)
上传 mapping 的方式,只写项目当前实际用的那个,
不确定的标「待确认」,不要编 API。
Constraints: 不改业务代码;不引新依赖;
mapping 绝不进 APK、绝不进公开仓库;
归档不做覆盖,同名构建直接报错;
不确定处写「需人工核对」。
Validation:
- 打两个不同 commit 的包,确认生成两个独立归档目录且哈希不同
- retrace 能还原到正确行号,拿错 mapping 时能明确报错
- unzip -l <apk> 里找不到 mapping / seeds / usage
「故意拿错 mapping」那一步是我加的。还原出来的行号看着挺合理,但指向的是另一个类------这比直接报错危险得多,排查时会把人带到完全错的方向。把这种失败样本留在证据文件里,比写一句「注意版本对应」管用。
还有个细节:我最初把 mapping 归档进了 Git,被同事提醒才撤掉。混淆的本质就是把名字藏起来,mapping 是完整的解密表,放进公开仓库跟没混淆区别不大。Rules 里那句「mapping 绝不进公开仓库」就是那次之后加的。
第 10 轮 · 真机回归,把四个数验一遍
IDE 模式 / Agent,接设备矩阵。
text
Goal: 出一份 release 混淆包的回归报告,落到 docs/obfuscation/evidence/regression.md。
Context: @docs/obfuscation/regression.md @docs/obfuscation/keep-rationale.md
@docs/obfuscation/evidence/ @app/build/outputs/
Requirements:
1. 设备矩阵:Android 8 / 10 / 12 / 14 / 16 各一台,
列出型号、系统版本、ABI。debug 与 release 各跑一轮,
方便对比(Compose 在两者下性能差异很大)。
2. 每台覆盖:安装、冷启动、登录、JNI 调用路径、
序列化/反序列化往返、Parcelable 读写、
deep link 跳转、通知点击、Compose 首屏与列表滚动、
动态资源名相关的页面、升级安装覆盖。
过滤 ClassNotFoundException / NoSuchMethodError / VerifyError /
UnsatisfiedLinkError / NoClassDefFoundError / ANR / 崩溃。
3. 把 regression.md 里标了「自动化」的用例全部跑一遍,
标「必须人工」的逐条走完并记录结果。
4. 重算四个数(重命名覆盖率、seeds 条目数、usage 条目数、
dex 体积),与第 1 轮基线、第 4 轮、第 5 轮做四列对比。
5. 交叉检查:mapping.txt 里被重命名的类、
seeds.txt 里被 keep 的类、keep-rationale.md 里登记的规则,
找出「被 keep 但没有登记原因」和
「登记了规则但 mapping 里没对应记录」两类差异,输出 diff 文件。
6. jadx 抽查最终包:业务类名是否已改、
有没有明文接口地址或密钥、mapping 有没有被打进包、
Log.* 是否被移除;截图或输出片段归档。
7. 冷启动用 adb shell am start -W -S,每台 7 次取中位数,
与未混淆基线包对比,记录 TotalTime 差值。
Constraints: 不改业务代码去适配某台设备;
不因为个别机型异常放宽规则;
数字取实测,不给估计值;
查到明文串先报位置,不要顺手改业务代码。
Validation:
- 矩阵内所有设备核心路径通过,无新增堆栈类型
- 两类 diff 文件落在 evidence/ 下,未登记原因的 keep 必须逐条解释
- 四个数的对比表完整,覆盖率与体积的变化方向一致
- jadx 抽查里找不到 mapping 与登记过的明文串
这轮出过一个有意思的发现:某台 Android 10 设备上,一个通过 getIdentifier 取图标的功能挂了,其他机型都正常。查下来是那个资源只在那台设备的密度限定目录下存在,资源缩减把它当成了无用项。第 8 轮的 dynamic-resources.md 里本来登记了这条,验证路径却只在高密度机型上点过。我把这条补进了矩阵要求里------动态资源名要按资源限定符分别验,不能只挑一台。
第 11 轮 · 把四个数钉进 CI
IDE 模式 / Agent。
text
Goal: 让混淆质量变成每次构建都会检查的硬卡点,产出脚本与 CI 配置。
Context: @app/build.gradle.kts @.github/workflows/
@docs/obfuscation/baseline.md @docs/obfuscation/keep-rationale.md
Requirements:
1. 写 scripts/obfuscation/verifyObfuscation.sh,接受 APK 与
mapping 归档目录两个参数,输出一份机器可读的 JSON 与一份人读的 md:
- 重命名覆盖率、seeds 条目数、usage 条目数、dex 体积之和
- 与基线对比的差值
- 兜底 keep 检测:匹配 `-keep class ...**`、
带 `{ *; }` 的全成员规则、框架包名开头的 keep
- 禁用项检测:-dontwarn / -dontoptimize / -dontobfuscate / -dontshrink
- mapping / seeds / usage / configuration 四份归档是否齐全
- 规则文件哈希与 build-info 里记录的是否一致
2. 卡点阈值:覆盖率不得低于基线减 5 个百分点,
dex 体积不得比基线大 3%,出现任何兜底 keep 或禁用项即失败。
失败时打印具体是哪条规则、哪个文件、哪一行,不要只报「不通过」。
3. 把 retrace 演练也接进 CI:造一条模拟混淆堆栈,
用当次归档的 mapping 还原,还原不到预期行号就失败。
4. CI job:assembleRelease → 归档 → verifyObfuscation.sh →
instrumentation 测试 → 上传 evidence 产物;
PR 上把四个数的前后对比贴成评论(用项目现有方式)。
5. 输出 docs/obfuscation/pipeline.md:
任务顺序、每步产物、失败定位方式、本地复现的单条命令。
Constraints: 只用 python3 / unzip / aapt / grep 这类现成工具,
不引第三方二进制;不自动修正规则或基线,只报告;
阈值写成配置项,不硬编码在脚本逻辑里;
私钥、口令不写入任何文件。
Validation:
- 拿当前产物跑一次应通过
- 人为往测试分支加一条 `-keep class com.example.** { *; }`,
确认脚本非零退出并打印那一条
- 人为删掉归档里的 seeds.txt,确认脚本报出缺哪份文件
- CI 上重跑一次,两次 JSON 除构建时间外一致
这套卡点上线第二周就抓到一次:某个 SDK 升了个小版本,它自带的 consumer-rules.pro 里多了一条覆盖整包的 keep,混淆率掉了 7 个百分点。以前这种事只有等包体积报警才会被发现,现在 PR 上直接标红。这也算把「谁都不敢删」的反面------「谁也没注意被加回来了」------堵上了。
第 12 轮 · 换个身份,自己挑自己
SOLO 模式,三步走。
text
Goal: 从逆向者与未来版本两个视角复核这套配置,
产出问题清单与回滚预案。
Context: @docs/obfuscation/ 整个目录 @app/build/outputs/
第一步 · 逆向者视角:
1. 假设你只拿得到最终 APK,手上有 jadx / apktool,
列出你能还原出多少业务结构:包名、模块划分、
接口地址、密钥、数据流。逐项说明「靠什么还原的」。
2. 指出这套配置里最没用的三条规则,
和最容易被绕过的一个环节,说明绕过成本。
3. 检查有没有把 mapping 或可反推命名的信息带进产物,
包括注释、SourceFile、字符串、资源名、BuildConfig 字段。
4. 明确写出:R8 混淆防不住什么,不要淡化。
第二步 · 未来版本视角:
5. 假设项目升级到 AGP 9.x,逐条指出
docs/obfuscation/version-behavior.md 里哪些变化会让现有配置出错:
proguard-android.txt、-repackageclasses 重复、
-keepattributes 通配符、strictFullModeForKeepRules、
全局选项过滤、keepRules source set。
每条给迁移动作与验证方式,按风险排序。
第三步 · 回滚:
6. 输出 docs/obfuscation/rollback.md:
- 出现「混淆导致线上功能异常」时,最短的恢复路径是什么
(不改代码、不引依赖、能在一个发版周期内生效)
- 每一类规则的关闭方式,以及关掉之后
覆盖率 / 体积 / 崩溃可还原性会怎么变
- mapping 归档丢失时,历史崩溃怎么处理
- 误删了某个类导致灰度崩溃时,定位与补规则的完整步骤
- 责任人占位与演练记录位置
Constraints: 不修改任何规则文件与业务代码;
不编造检测结果;
复核结论区分「已确认问题」与「需人工核对」;
发现不了问题就写「本轮未发现」,并说明检查范围的局限。
Validation: 我把逆向者视角列的每条
「能还原出什么」拿最终 APK 实际试一遍;
未来版本清单逐条对官方文档;
回滚路径在预发环境演练一次并记录耗时。
第一步它给了一句我挺认可的结论:混淆之后,接口地址、字段名、字符串常量、资源名全都还在,jadx 一样能顺着调用链把业务结构画出来,混淆挡的只是「读的速度」,不是「能不能读」。真正让接口地址不外泄的是别把它硬编码在客户端,不是加一条混淆规则。这句话我原样留进了文档。
它也老实写了局限:这轮只看静态产物,没挂 Frida 做动态观测,运行期的类名还原、反射调用观测都没覆盖。把边界写出来,比给一个「全部通过」可信得多。
最后落地的文件长什么样
十二轮跑完,工程里多出来的东西不多,主要是这些:
text
app/
├── proguard-rules.pro # 按「为什么需要」分组,每条带依据
├── build.gradle.kts # release 配置 + mapping 归档任务
└── src/main/res/raw/keep.xml # tools:keep,收窄到三个具体资源
scripts/obfuscation/
├── verifyObfuscation.sh # 四个数 + 兜底规则检测
├── retrace-smoke.sh # 崩溃还原演练
└── dictionaries/ # 三组候选字典,实测后未启用,留作对比记录
.github/workflows/obfuscation-check.yml
docs/obfuscation/
├── baseline.md # 第 1 轮的现状体检
├── version-behavior.md # AGP / R8 版本行为核对
├── rule-sources.md # 最终生效规则的来源归并
├── keep-rationale.md # 每条 keep 的依据与回归用例
├── dynamic-resources.md # 动态资源名清单
├── regression.md # 回归清单,标自动化 / 必须人工
├── pipeline.md # 流水线与失败定位
├── rollback.md # 回滚预案
├── mapping/<version>-<commit>/ # 每次构建一个目录,不覆盖
└── evidence/ # 各轮实测数据与 diff
定稿的 proguard-rules.pro,和两年前那版最大的区别不是内容,是每条规则都能回答「去掉会怎样」:
pro
# app/proguard-rules.pro
# --- JNI --------------------------------------------------------
# native 层按 Java_包名_类名_方法名 找符号,类名改了就 UnsatisfiedLinkError。
# 依据:app/src/main/cpp/ 下静态注册的 6 个方法,见 keep-rationale.md #JNI-01~06。
# 去掉后:健康数据加密模块首次调用即崩,回归用例 JniSmokeTest。
-keepclasseswithmembernames,includedescriptorclasses class * {
native <methods>;
}
# --- 反射(R8 静态不可见)---------------------------------------
# 依据:LegacyPluginLoader.kt:88 用字符串拼类名后 Class.forName。
# 方案 B 已评估(改成常量引用需改插件协议),本期先收窄 keep。
# 去掉后:老版插件入口 ClassNotFoundException,回归用例 PluginLoaderTest。
-keepnames class com.example.app.legacy.plugin.** { *; }
# 注意:这是全工程唯一带 ** 的 keep,CI 白名单里单独登记。
# --- 默认构造器(full mode)-------------------------------------
# 依据:AGP 8.0 起 full mode 不再隐式保留默认构造器;
# RecordFactory.kt:34 用 getDeclaredConstructor().newInstance()。
# 去掉后:NoSuchMethodException,回归用例 FullModeCheckTest#b。
-keepclassmembers class com.example.app.data.RecordDraft {
public <init>();
}
# --- 泛型签名(full mode)---------------------------------------
# full mode 下 Signature 只对显式 keep 的类与成员保留;
# 依据:ApiTypeResolver.kt:52 靠 getGenericReturnType 取参数化类型。
-keepattributes Signature
-keep,allowoptimization,allowshrinking,allowobfuscation class com.example.app.data.ApiEnvelope
# --- Parcelable / 序列化 -----------------------------------------
-keepclassmembers class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
# kotlinx.serialization / Room / Hilt 的规则由各自 Gradle 插件生成,
# 产物路径见 rule-sources.md,此处不重复写。
# Compose 各 artifact 自带 consumer-rules.pro,同样不手写。
# --- 注解 --------------------------------------------------------
-keepattributes *Annotation*, InnerClasses, EnclosingMethod
-keepclassmembers,allowoptimization,allowshrinking,allowobfuscation class * {
@androidx.annotation.Keep *;
}
# --- 崩溃可还原 --------------------------------------------------
-keepattributes SourceFile, LineNumberTable
-renamesourcefileattribute SourceFile
# --- release 日志清理 --------------------------------------------
# 前提:没有 -dontoptimize,proguard-android-optimize.txt 不带它
-assumenosideeffects class android.util.Log {
public static int v(...);
public static int d(...);
public static int i(...);
}
# --- 包结构打散 --------------------------------------------------
# 已核对全工程没有按包名字符串做查找或过滤的代码。
# 版本债:AGP 9.1 起对 app 默认开启,升级后要删掉这一行。
-repackageclasses
-adaptclassstrings
# --- 明确不用的 --------------------------------------------------
# 自定义混淆字典:实测非 ASCII 字典让 dex 变大且被 jadx 自动改写,
# 收益为负,见 evidence/dictionary-eval.md。
# 字符串加密 / 控制流混淆:不属于 R8 能力范围,不引第三方库。
build.gradle.kts 里改动集中在 release 块和归档任务:
kotlin
// app/build.gradle.kts(节选)
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
isDebuggable = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
// 不写 proguard-android.txt:它带 -dontoptimize,会把优化 pass 全关掉
}
}
}
// 每次 assembleRelease 后归档,目录名含 versionName + git short commit
val archiveObfuscation by tasks.registering(Copy::class.java) {
val version = providers.provider { "5.9.0" } // 由版本目录注入
val commit = providers.exec { commandLine("git", "rev-parse", "--short", "HEAD") }
.standardOutput.asText.map { it.trim() }
from(layout.buildDirectory.dir("outputs/mapping/release"))
into(rootProject.layout.projectDirectory.dir("docs/obfuscation/mapping/${version.get()}-${commit.get()}"))
include("mapping.txt", "seeds.txt", "usage.txt", "configuration.txt")
// 同名目录已存在时直接失败,绝不覆盖
duplicatesStrategy = DuplicatesStrategy.FAIL
}
校验脚本核心就三段,用 python3 读文本算数,不依赖任何额外工具:
bash
#!/usr/bin/env bash
# scripts/obfuscation/verifyObfuscation.sh
set -euo pipefail
APK="$1"; MAPPING_DIR="$2"
echo "== 四个数 =="
python3 scripts/obfuscation/metrics.py "$APK" "$MAPPING_DIR" \
--baseline docs/obfuscation/baseline.md \
--out docs/obfuscation/evidence/metrics.json
echo "== 兜底 keep 与禁用项 =="
if grep -nE '^\s*-keep\s+class\s+[^*]*\*\*|^\s*-keep\s+class\s+androidx\.' app/proguard-rules.pro \
| grep -v 'legacy.plugin' ; then
echo "发现未登记的宽泛 keep,见上面行号" >&2
exit 1
fi
if grep -nE '^\s*-(dontwarn|dontoptimize|dontobfuscate|dontshrink)' app/proguard-rules.pro ; then
echo "发现禁用项" >&2
exit 1
fi
echo "== 归档完整性 =="
for f in mapping.txt seeds.txt usage.txt configuration.txt build-info.json; do
test -f "$MAPPING_DIR/$f" || { echo "缺少 $f" >&2; exit 1; }
done
echo "== mapping 没被打包 =="
if unzip -l "$APK" | grep -iE 'mapping|seeds|usage|configuration' ; then
echo "mapping 相关文件被打进 APK" >&2
exit 1
fi
echo "== 崩溃还原演练 =="
bash scripts/obfuscation/retrace-smoke.sh "$MAPPING_DIR/mapping.txt"
CI 里把它放在 release 构建之后、上传产物之前:
yaml
# .github/workflows/obfuscation-check.yml(节选)
name: obfuscation-check
on: [pull_request, workflow_dispatch]
jobs:
verify:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: 构建 release
run: ./gradlew :app:assembleRelease archiveObfuscation
- name: 四个数与规则卡点
run: |
APK=$(find app/build/outputs/apk/release -name '*.apk' | head -1)
MAP=$(find docs/obfuscation/mapping -mindepth 1 -maxdepth 1 -type d | sort | tail -1)
bash scripts/obfuscation/verifyObfuscation.sh "$APK" "$MAP"
- name: 反射与 JNI 用例
run: ./gradlew :app:connectedReleaseAndroidTest
- name: 上传证据
if: always()
uses: actions/upload-artifact@v4
with:
name: obfuscation-evidence-${{ github.sha }}
path: |
docs/obfuscation/evidence/
docs/obfuscation/mapping/
最后每次发版会留一份这样的报告,四个数摆在一起,谁都能一眼看出这次是不是退步了:
text
docs/obfuscation/evidence/metrics.md · 5.9.0 (a3f91c2)
基线(第1轮) 删兜底后 补 keep 后 最终
重命名覆盖率 8.7% 84.2% 79.6% 79.6%
seeds 条目数 37,412 6,881 7,204 7,204
usage 条目数 1,903 14,766 14,443 14,443
classes*.dex 体积 21.4 MB 17.6 MB 17.9 MB 17.9 MB
冷启动中位数(Android 14) 891 ms 827 ms 831 ms 831 ms
未登记原因的 keep:0
被 keep 但 rationale 无对应:0
mapping 归档:5.9.0-a3f91c2 ✓ retrace 演练 ✓
从 8.7% 到 79.6%,中途那次灰度崩溃换来的,是现在这 7,204 条 seeds 每一条都能说出为什么留着。冷启动反而比基线快了 60ms------方法内联、类合并、dex 变小一起起作用,这又绕回最开始那句话:混淆本来就是个优化。
踩过的坑,按报错排
排查混淆问题最省时间的不是重读原理,是知道「这个报错八成是哪条规则没对齐」。按报错归了个表:
| 现象 | 根因 | 怎么修 |
|---|---|---|
| jadx 打开全是原类名 | 存在 -keep class 业务包.** { *; } 之类兜底 |
先量 seeds 条目数与覆盖率,逐条删,别直接重写规则 |
删掉兜底后 ClassNotFoundException |
字符串拼类名做反射,R8 静态看不见 | 收窄 keep,或改成编译期常量引用;后者更干净 |
NoSuchMethodException: <init> |
full mode 不再隐式保留默认构造器 | 显式 keep public <init>();,或改工厂方法 |
泛型反射拿到 null 或原始类型 |
full mode 下 Signature 只对显式 keep 的类与成员保留 |
-keepattributes Signature 并连类一起 keep |
getDeclaredMethods() 按 private 过滤后找不到 |
-allowaccessmodification 把 private 改成 public 以便内联 |
规则里显式写 private,或别按可见性过滤 |
| 某个类整个从 dex 消失 | 只被反射字符串引用,被判定不可达 | 查 missing_rules.txt,只抄对应那一条并收窄 |
UnsatisfiedLinkError |
JNI 静态注册按类名找符号 | keep native 方法所在类,或改 RegisterNatives 动态注册 |
| 反序列化后字段全是默认值 | 字段名被改,与协议对不上 | 靠插件生成的规则或 @SerialName 固定名字,别手写整类 keep |
| Parcelable 读写抛异常 | CREATOR 被删或改名 |
-keepclassmembers ... Parcelable$Creator |
Gson TypeToken 泛型丢失 |
Signature 没保住 |
同上,并确认 keep 范围收窄到相关类 |
| Compose release 性能没变化 / 首屏异常 | 用了 proguard-android.txt(带 -dontoptimize),或加了 Compose 宽泛 keep |
换 proguard-android-optimize.txt,删掉框架级 keep,信任 consumer rules |
| 动态图标偶发不显示 | getIdentifier 拼出来的资源名被资源缩减删掉 |
tools:keep 收窄到具体资源,或改成 R.xxx 编译期引用 |
| 只有某个密度/语言的机型资源找不到 | 限定目录下的资源被判为无用 | 动态资源清单按限定符分别验,矩阵里加对应设备 |
VerifyError |
描述符里的类型名没跟着改(多工具链或二次处理 dex 常见) | 别在 R8 之后再改 dex,回到单一编译链 |
NoClassDefFoundError |
传递依赖被 shrink 掉 | 查 usage.txt,确认是直接依赖还是可选依赖 |
崩溃堆栈全是 a.b.c 且还原不了 |
mapping 被下次构建覆盖 | 归档目录带 versionName + commit,同名直接失败 |
| retrace 还原到不相干的行号 | 拿错了 mapping 版本 | 用 APK 的 SHA-256 反查 build-info.json 再取归档 |
| 崩溃平台显示乱码 | 没给平台上传对应 mapping | 发版流程里加一步上传,写进 pipeline.md |
release 里还有 Log.d |
-dontoptimize 让 -assumenosideeffects 失效 |
去掉 -dontoptimize,别用非 optimize 版默认规则 |
| 混淆率某次突然掉了几个点 | SDK 升级带来的新 consumer-rules.pro |
CI 卡点报 diff,逐条看来源,别急着改自己的规则 |
| 构建突然因「缺类」失败 | AGP 8.0 起 failOnMissingClasses 默认开启 |
查依赖来源补依赖或加精确规则,别加 -dontwarn 压掉 |
| 升级 AGP 9.x 后构建报错 | proguard-android.txt 不再支持;library 全局选项被过滤;-repackageclasses 变成默认 |
逐条按 version-behavior.md 迁移,删掉重复配置 |
| 花式字典混淆后 jadx 照样读得顺 | 反编译器自动把非法标识符转成合法名字,JAD 九十年代就这么干 | 别用非 ASCII 字典,最短标识符最省 dex |
| Trae 给出「某 SDK 文档要求」但查无此条 | 提示词没约束「不确定怎么办」 | Constraints 固定加「标需人工核对,不要补全」 |
有几条值得多说两句。
seeds 比 mapping 厚,永远是第一个信号。 一个健康的 release 构建,seeds.txt 应该是几千行的量级,不该是几万行。我现在的习惯是打包完先看这两个文件的行数比,再决定要不要往下查。
-dontwarn 是最贵的一行配置。 它不解决任何问题,只是让构建闭嘴。我们项目里那五条,三条对应着后来真出过的运行期问题。现在 CI 里直接把这四个 dont* 设成禁用项,加一条就失败。
全 mode 那一类问题,编译期一声不吭。 泛型签名丢失、默认构造器被删、private 被改成 public、类被整体删掉,四种都能通过编译、通过单元测试(很多单测跑在 debug 变体上),只在 release 的特定路径炸。所以第 10 轮那条「debug 与 release 各跑一轮」不是形式主义,是唯一能抓到它们的办法。
命令速查
排查时基本只敲这几条:
bash
# 构建与归档
./gradlew :app:assembleRelease archiveObfuscation
./gradlew :app:minifyReleaseWithR8 --dry-run
# 四个数
grep -c ' -> ' docs/obfuscation/mapping/*/mapping.txt
wc -l docs/obfuscation/mapping/*/seeds.txt
wc -l docs/obfuscation/mapping/*/usage.txt
unzip -l app-release.apk | grep 'classes.*\.dex'
# 最终产物里到底有什么
aapt dump badging app-release.apk
aapt dump resources app-release.apk | head -60
unzip -l app-release.apk | grep -iE 'mapping|seeds|usage|configuration' # 应该为空
jadx -d /tmp/decompiled app-release.apk
unzip -p app-release.apk classes.dex | strings | grep -iE 'secret|token|api/'
# 崩溃还原(当前 cmdline-tools 里的 retrace)
$ANDROID_HOME/cmdline-tools/latest/bin/retrace \
docs/obfuscation/mapping/5.9.0-a3f91c2/mapping.txt stacktrace.txt
# 规则是谁加的
git blame app/proguard-rules.pro
# 真机
adb shell am start -W -S com.example.app/.MainActivity
adb logcat -v brief | grep -E \
'ClassNotFoundException|NoSuchMethodError|VerifyError|UnsatisfiedLinkError|ANR|FATAL'
git blame 那条看着不搭,实际用得最多。混淆规则文件是最典型的「只增不减」文件,一条规则活五年没人敢动,先查它是为了解决什么问题加的,比猜安全得多。
上线前我盯的几个数
不进表格但每次都要确认的:keep-rationale.md 里每条规则有没有对应的回归用例、dynamic-resources.md 的验证路径是不是全走完了、mapping 归档目录名带没带 commit、retrace 演练还原到了正确行号、以及 CI 那四个数有没有比上一版退步。
| 项 | 基线 | 最终 | 我定的阈值 |
|---|---|---|---|
| 重命名覆盖率 | 8.7% | 79.6% | 不低于上版减 5 个点 |
| 未登记原因的 keep | 9 条 | 0 | 0 |
| classes*.dex 体积 | 21.4MB | 17.9MB | 不比上版大 3% |
| retrace 演练 | 未做 | 通过 | 每次必过 |
| mapping 归档完整率 | 常丢 | 4/4 | 100% |
| 动态资源验证路径 | 0 | 11/11 | 全通过 |
| 冷启动中位数 | 891ms | 831ms | 不劣化 |
覆盖率没有一路冲到九十几个点,是刻意的:剩下那两成里,有老插件协议、有 JNI、有对外暴露的 API,都是真的动不了。硬把它们也改掉,换来的只是下次灰度再崩一次。混淆率不是越高越好,是每一分都有依据才好 ------这句话现在写在 keep-rationale.md 的第一行。
还能往下挖的地方
按自己的排期,后面想继续做的这些,顺序大致按投入产出比:
把 Baseline Profile / Startup Profile 和 R8 一起调。 官方文档里提过,开了 R8 之后建议配 Startup Profile,把启动路径上的类提前编译。混淆改了类名,profile 也得跟着重新生成,这块我们还没串起来。
库模块 consumer rules 的完备性自动化。 现在靠 rule-sources.md 手工核对哪些模块的规则被主工程收到了,第 3 轮漏过两个。想做成构建期检查,缺 consumerProguardFiles 就直接报错。
AGP 9.3 新 DSL 的迁移演练。 optimization { enable = true } 和 keepRules source set 会把配置入口整个换一层,趁现在规则文件已经收拾干净,先在分支上跑一遍,把 version-behavior.md 里那张风险清单验掉。
资源优化缩减默认之后的回归。 AGP 9.0 起它自动生效,我们现在的动态资源清单是在 8.x 管线下验的。升级前得把 11 条验证路径重跑一遍,尤其那几个按密度限定目录走的。
把 jadx 自测做成定期任务。 第 12 轮逆向者视角那套检查,现在是人工做。想让 CI 每个月拿最新包跑一次,输出「能还原出多少业务结构」的趋势,比一次性的结论有用。
用 ProGuardCORE 做静态分析。 不引运行时依赖,只在构建机上扫一遍反射调用点和字符串拼类名,把「R8 看不见」的那类代码提前报出来。这能把第 5 轮从人工翻代码变成自动清单。
mapping 与崩溃平台打通。 现在还是发版时手动上传,想接进流水线,并且把「APK 哈希 → 归档目录 → 崩溃平台 mapping 版本」三者对上,出现拿错 mapping 的情况直接报警。
逐项评估 R8 的优化 pass。 方法内联、类合并、-processkotlinnullchecks 这些各自的收益和代价,我们只看了总量没拆开。尤其类合并,对靠类名做埋点的逻辑有影响,值得单独量一次。
混淆字典那个负收益结论再验一次。 三组字典是在一台构建机、一个版本上测的,换到大 dex 项目可能不一样。结论先留着,别写成通用规律。
把「四个数」做成长期趋势看板。 覆盖率、seeds、usage、dex 体积按发版记录下来,SDK 升级、AGP 升级、模块拆分带来的影响一眼能看出来。第 11 轮那次 SDK 悄悄拉低覆盖率,如果有看板会更早发现。
后来
那条 -keep class com.example.** { *; } 最后还是删了。注释我没舍得扔,改成了另一行留在 proguard-rules.pro 开头:
pro
# 删除任何规则前,先看 docs/obfuscation/keep-rationale.md 里它为什么存在。
从「谁都不敢删」到「先查为什么」,其实只是把恐惧换成了证据。这一年 Tools 链换了好几轮,AGP 从 8 走到 9 的规则语义也在变,但这套做法没怎么变:先量四个数,再问每条规则的依据,最后才动手。
有点讽刺的是,折腾这一圈之后,我反而不再把混淆当安全措施了。接口地址别硬编码、密钥别放客户端、权限别多申一样,这些事比把 UserRepositoryImpl 改成 a.b.c 有用得多。混淆该干的活,二十多年前就定好了------让包小一点、跑快一点,顺手让想抄代码的人多花点时间。
现在每次打开 jadx,看到满屏的 a.b.c,我想的不是「这下安全了」,而是「这次的 dex 又小了 3.5MB」。