经过 R8 压缩混淆的 release 包一崩溃,堆栈里就全是 at a.c.a(SourceFile:3) 这样的帧:名字没了,行号指向的方法早已被合并或内联。想看懂它,得拿 R8 为这一次构建生成的 mapping.txt 跑一遍 retrace。平时这意味着要先找到 Android SDK 的命令行工具,或者装好 JDK 再下载 r8.jar。
这个页面在浏览器里完成同样的事:粘贴堆栈,选择 mapping 文件,点 还原堆栈。还原逻辑移植自 R8 自带的 retrace,并在我们的测试堆栈上与 Google 官方 retrace 的输出逐行比对(细节见下文)。mapping 只在你的设备上读取,不会上传。
能做什么
输入
- 堆栈:原样粘贴即可。JVM 的
printStackTrace()输出、带E AndroidRuntime:前缀的 logcat 崩溃日志、从 Play 管理中心或 Crashlytics 复制的堆栈都行。异常头、Caused by:、Suppressed:、... 12 more以及 retrace 不认识的行都会原样保留在原位置。 - mapping 文件:必须是崩溃那次构建的。用 Android Gradle 插件构建时,它在
app/build/outputs/mapping/<variant>/mapping.txt;.aab里也带了一份,路径是BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map。大型应用的 mapping 动辄几百 MB,这里按流读取,内存里只保留堆栈提到的那些类。
输出:和 retrace mapping.txt trace.txt 打印的一样,名字全部还原:
- 类名、方法名、源文件和行号:
(SourceFile:4)会变回(Main.java:52)。mapping 里没有记录源文件名时,按 retrace 的规则从类名推断:Foo$Bar推成Foo.java,UtilsKt推成Utils.kt。 - 内联帧展开:R8 把
Parser.digits()内联进readToken()、再内联进parse()之后,堆栈里只剩一帧;还原时会按从内到外的顺序重新展开成多帧。我们的示例里,38 行输入还原后变成 48 行。 - 有歧义的帧列出所有可能,多出来的候选以
<OR>开头。常见于堆栈没有行号、而两个方法被改成了同一个名字,或者一段混淆行号对应多行原始代码。 - 去掉编译器生成的帧:合成的 lambda 桥接方法、R8 的 outline(它的行号会借助
outlineCallsite表折算到调用方),以及rewriteFrame规则要求删除的帧。比如接收者为 null、实际在调用处抛出的NullPointerException,R8 会去掉其上方那个被内联的被调方法帧。 - 异常头里的类名也会改回来(
a.g: token expired变成com.example.app.ObfuscatedException: token expired)。
有变化的行会高亮,<OR> 候选和内联帧各有标记,顶部汇总了还原、内联展开、歧义和移除的帧数。复制 会把结果放进剪贴板,下载 .txt 保存成文件。
用示例试试 会载入一份真实的堆栈和 mapping:来自我们自己写的一个小 Java 程序,用 R8 9.5.26 以 class 文件模式编译后在 JVM 上运行得到。
原理
mapping 是按行组织的文本。com.example.Parser -> a.e: 开始一个类;3:6:int digits(java.lang.String):51:54 -> a 这样的成员行表示:方法 a 的混淆行号 3 到 6 对应原始代码第 51 到 54 行。混淆行号区间相同的连续几行构成一个内联组。其余信息写在 JSON 注释里:格式版本、sourceFile、synthesized、outline、outlineCallsite、rewriteFrame 和 residualsignature。
工具先用 retrace 默认的正则表达式解析粘贴的堆栈,得到需要查找的混淆类名;然后流式读取 mapping,这些类完整保留,其他类只记下源文件名。这些名字也要留着,因为内联帧所属的类可能根本没在堆栈里出现过。之后每一帧都按 R8 retrace 的步骤查找:先按行号筛选该方法的区间,筛不到再退回到不带行号的区间,按签名和原始行号拆成候选,再套用 outline 和 rewrite 规则,最后排序并用 <OR> 连接。
代码按 R8 的类逐个函数对应移植(ProguardMapReader、StackTraceRegularExpressionParser、RetraceMethodResultImpl、RetraceFrameResultImpl、StringRetrace),R8 一些出人意料的行为也照样保留。举个例子:a.b: not positive: -4 这一行,默认正则会把 -4 当成类名,于是整行保持原样,a.b 也不会被还原。Google 的 retrace 打印的也是这一行。R8 读不了的 mapping 行会直接报错,并给出行号。
怎样和 Google 的 retrace 对照
每个测试用例都先用 java -cp r8-9.5.26.jar com.android.tools.r8.retrace.Retrace mapping.txt trace.txt 跑一遍,我们的输出必须逐字节相同,否则单元测试失败:
- 真实堆栈:上面那个 Java 测试程序经 R8 9.5.26 编译为 class 文件,在 JDK 17 上运行,抛出 8 个异常,带 cause 和多层内联(还原后 48 行)。
- 遍历测试:对四份 mapping 里的每个混淆方法,分别构造每个区间的首尾行、区间外侧一行、
0、65535、无行号、Unknown Source和Native Method的帧,中间穿插异常头。四份 mapping 分别是 R8 的 class 文件输出、R8 面向最低 API 26 的 dex 输出、一份手写的 mapping(含 outline、rewriteFrame、合成 lambda 和有歧义的区间),以及一份没有版本头的 ProGuard 风格 mapping(合计 963 行)。 - 行格式:同一帧套上 logcat 前缀、
app//类加载器前缀、java.base@17/模块前缀、~[app.jar:?]后缀、r8-map-id-…和.kt文件名,以及行号0、-1和超出 int 范围的行号。
在仓库提交的测试之外,我们还用 R8 9.5.26 对照了:
- R8 自己 retrace 测试集里的 63 个堆栈用例:全部一致;
- R8 为 Guava 33.3.1 和 kotlin-stdlib 2.0.21 生成的 mapping(class 文件模式 0.85 MB,dex 模式 1.15 MB,其中 dex 版含 131 条 outline 和
rewriteFrame信息)上的遍历堆栈:共约 68,500 行,全部一致; - 一份 143.5 MiB、158 万行的 mapping,配一段 3,000 行的堆栈:在 Chromium 和 WebKit 中整个过程都不到半秒(Apple 芯片 Mac 上用 Playwright 测得),输出一致。
Android SDK 自带的 cmdline-tools/latest/bin/retrace(R8 9.0.32)对这些用例的输出也完全相同。
文件如何保持私密
mapping 和堆栈都不会上传:
- mapping 在你设备上的 Web Worker 里用
Blob.stream()读取,堆栈也只发给这个 Worker,不会发往任何其他地方。 - 堆栈行和 mapping 里的名字都以纯文本插入页面,不会被当作 HTML 解析或执行。
- 统计事件只有粗粒度信息:成功或错误码、帧数和命中 mapping 的帧数所在区间(
0、1-10、11-100……)、mapping 大小区间和耗时区间。绝不包含类名、方法名、文件名、堆栈中的任何一行或 mapping 里的任何内容。完整列表见隐私政策。 - 工具页不展示广告。
如何自己验证
- 选择 mapping 之前,打开浏览器开发者工具,切到 Network(网络) 面板,然后还原一段堆栈。你只会看到页面资源和 Worker 脚本
/_astro/retrace-worker-*.js的请求,没有任何请求带着你的文件或堆栈。 - 也可以先加载页面,再断网(关掉 Wi-Fi,或在 Network 面板勾选 Offline),然后还原,照样能用。
- Worker 运行在内容安全策略
default-src 'none'; script-src 'self'; connect-src 'none'之下,浏览器不允许它发出任何网络请求。
局限
- mapping 必须对应那次构建。 每次 release 构建都会生成新的 mapping。用错了 mapping,照样能还原出看起来像样的名字,但它们是错的。如果汇总里显示堆栈中的类名一个都没找到,基本就是文件拿错了。
- 只支持默认行格式。 命令行 retrace 可以用
--regex适配自定义日志格式、用--verbose打印完整签名,这里都不支持。帧只要在行内某处形如at 类.方法(文件:行号)就能识别。 - 不还原字段和类型。 retrace 的默认正则只认帧和类名,所以异常消息正文里的混淆名(
Attempt to invoke virtual method 'a.b.c()')会保持原样,命令行 retrace 也一样。 - 原生帧(
#00 pc 0004b1c8 /data/app/.../lib/arm64/libapp.so)不做符号化,那需要未 strip 的.so和ndk-stack,参见用 adb logcat 排查崩溃。 - 超长行。 超过 4,096 个字符的行不解析、原样输出,以免正则匹配拖慢页面;命令行 retrace 仍会解析它们。
常见问题
为什么有些帧带着 <OR>?
这一帧能对上不止一个原始位置。最常见的情况是堆栈里没有行号(Unknown Source,或只有 SourceFile 没有数字),而好几个方法被改成了同一个名字;或者 mapping 写的是混淆行 1 到 2 对应原始行 60 到 65,哪一行都有可能。R8 的 retrace 会把候选全部列出来,这里也一样。
堆栈里写的是 Unknown Source:12,没有文件名,能还原吗?
能。查找只用到行号。较新的 R8 有时还会输出指令偏移或 0:65535 这种兜底区间,而不是真实行号;只要 mapping 里有对应信息,retrace 都能把它们换算回源码行号。
已经上架 Google Play 的版本,去哪找 mapping.txt?
从你存档的构建产物里找,或者从当初上传的 .aab 里取(BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map)。在 Play 管理中心的 App bundle 浏览器里,反混淆文件也和对应版本列在一起。
这和 Crashlytics、Play 管理中心的反混淆有什么区别?
解决的是同一个问题。那些服务拿你上传的 mapping 在服务器上还原;这个页面在本地完成,适合处理来自 logcat、测试同学或 bug 报告、根本没进过这些平台的堆栈。
合规
请用于你自己开发或维护的应用、开源项目、CTF 题目,或你有权分析的软件。详见免责声明。
相关教程
- 用 R8 mapping.txt 和 retrace 还原混淆后的 Android 崩溃堆栈:mapping.txt 从哪来、行号区间和内联组怎么读,以及 SDK 的
retrace和本页的对比。 - 用 adb logcat 和 bugreport 排查 Android 应用崩溃:先抓到崩溃,再到这里还原 Java 帧。
- JADX MCP Server 配置:用 Claude Code 和 REA 反编译 APK:在反编译器里打开还原出来的类,看字节码到底做了什么。
- 用 REA 分析 Android APK:在你有权分析的 APK 上使用 JADX、Apktool 和 ADB。
- APK/AAB 分析与上架体检:发版前检查 release 包本身(Manifest、签名、16 KB 对齐)。