经过 R8 压缩混淆的 release 包一崩溃,堆栈里就全是 at a.c.a(SourceFile:3) 这样的帧:名字没了,行号指向的方法早已被合并或内联。想看懂它,得拿 R8 为这一次构建生成的 mapping.txt 跑一遍 retrace。平时这意味着要先找到 Android SDK 的命令行工具,或者装好 JDK 再下载 r8.jar。
这个页面在浏览器里完成同样的事:粘贴堆栈,选择 mapping 文件,点 还原堆栈。还原逻辑移植自 R8 自带的 retrace,并在我们的测试堆栈上与 Google 官方 retrace 的输出逐行比对(细节见下文)。mapping 只在你的设备上读取,不会上传。
使用步骤
- 复制混淆后的堆栈(logcat、Play 管理中心、Crashlytics 或
printStackTrace()的输出),找到崩溃那次构建的mapping.txt:app/build/outputs/mapping/<variant>/mapping.txt,或.aab里的BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map。 - 把堆栈粘贴到“混淆后的堆栈”,把 mapping 拖到拖放区或点“选择 mapping.txt”,再点“还原堆栈”。点“用示例试试”会载入一组现成的堆栈和 mapping。
- 先看摘要一行(还原了多少帧、展开了多少内联帧、多少帧有歧义),再看堆栈:高亮的是改动过的行,左侧竖条标出从内联展开的帧,
<OR>列出其他可能的原始位置。
真实输出示例
我们把堆栈粘贴到中文页面,选择对应的 mapping.txt,点“还原堆栈”,复制了摘要和还原结果。输入里的帧形如 at a.e.a(r8-map-id-ff4b8eee…:1)。输出在第二个异常之后截断,保留的每一行都原样照抄;完整结果与下面 Google retrace 的输出逐字节相同。
样本: “用示例试试”载入的那组堆栈和 mapping.txt:本站测试集里的一个小 Java 程序,用 R8 9.5.26 压缩(--classfile,mapping 格式 2.2)后在 JVM 上运行得到
共 26 帧:还原 26 帧,展开内联帧 10 个,有歧义 0 帧,移除 0 帧;另有 0 帧不在 mapping 中,保持原样。
mapping:67 行,共 10 个类;堆栈里的 11 个类名找到 7 个;读取耗时 1 ms。 · R8 9.5.26 · 格式版本 2.2
com.example.retrace.FixtureException: unexpected character at 0 in token of length 1
at com.example.retrace.Parser.fail(Main.java:58)
at com.example.retrace.Parser.digits(Main.java:52)
at com.example.retrace.Parser.readToken(Main.java:47)
at com.example.retrace.Parser.parse(Main.java:41)
at com.example.retrace.Main.lambda$main$0(Main.java:16)
at com.example.retrace.Main.main(Main.java:25)
java.lang.IllegalStateException: cannot reserve box
at com.example.retrace.Inventory.reserve(Main.java:73)
at com.example.retrace.Main.lambda$main$1(Main.java:17)
at com.example.retrace.Main.main(Main.java:25)
Caused by: com.example.retrace.FixtureException: over capacity by 5
at com.example.retrace.Inventory.checkCapacity(Main.java:78)
at com.example.retrace.Inventory.reserve(Main.java:70)
... 2 more
…能做什么
输入
- 堆栈:原样粘贴即可。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 和堆栈都不会上传:
- 工具运行在一个单独的框架(iframe)里,来源是
tools.rea-reverse.com。下面说的处理都在这个框架里、在你的设备上完成。 - 这个页面在正文之后展示一个广告。广告脚本运行在
rea-reverse.com上,而框架属于另一个源(origin),浏览器不允许广告脚本或本页的任何其他脚本读取你拖入的 mapping、粘贴的堆栈或结果。 - 框架的内容安全策略拦截它的脚本能发起的所有请求(
connect-src 'none',也不加载任何第三方资源)。框架不能自行打开窗口或让本页跳转;只有你点击报告里的链接时才会打开页面。它传给本页的只有自身高度和下面这些统计事件。 - mapping 在你设备上的 Web Worker 里用
Blob.stream()读取,堆栈也只发给这个 Worker,不会发往任何其他地方。 - 堆栈行和 mapping 里的名字都以纯文本插入框架,不会被当作 HTML 解析或执行。
- 统计事件只有粗粒度信息:成功或错误码、帧数和命中 mapping 的帧数所在区间(
0、1-10、11-100……)、mapping 大小区间和耗时区间。绝不包含类名、方法名、文件名、堆栈中的任何一行或 mapping 里的任何内容。框架把事件交给本页,由本页发送。完整列表见隐私政策。 - 不想要任何广告和统计?在单独页面打开工具。
如何自己验证
- 打开浏览器开发者工具的 Network(网络) 面板,加载本页。发往
tools.rea-reverse.com的请求就是框架:它的 HTML、样式、字体和脚本,以及 Worker 脚本/_astro/retrace-worker-*.js。发往 Google 域名的请求是本页的广告。 - 清空日志,选择 mapping,再还原一段堆栈。在过滤框里输入
domain:tools.rea-reverse.com:最多只会再出现一次 Worker 脚本/_astro/retrace-worker-*.js(为下一次运行准备的新 Worker),没有任何请求带着你的数据。广告请求可能还会继续,但它们来自本页,不带框架里的任何内容。 - 点开来自
tools.rea-reverse.com的proguard-retrace/文档,查看 Headers:它的Content-Security-Policy里有connect-src 'none'和frame-ancestors https://rea-reverse.com。在 Chrome 里,Application(应用)> Frames 也能看到框架的源和策略。Worker 运行在default-src 'none'; script-src 'self'; connect-src 'none'这条策略下,浏览器不允许它发出任何网络请求。 - 检查隔离:在 Console(控制台) 里,上下文选
top,运行document.getElementById('tool-frame').contentDocument,结果是null;frames[0].document会抛出SecurityError。广告脚本拿到的也是这个结果。 - 或者先打开页面再断网(关掉 Wi-Fi,或在 Network 面板勾选 Offline),然后还原,照样能用。
局限
- 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 仍会解析它们。
合规
请用于你自己开发或维护的应用、开源项目、CTF 题目,或你有权分析的软件。详见免责声明。
对应的命令行
同一组输入交给 Android SDK 命令行工具里 Google 的 retrace(Retrace 9.0.32)。它的输出和本页逐字节相同(diff 没有任何输出);这里有删节:
$ retrace mapping.txt trace.txt
com.example.retrace.FixtureException: unexpected character at 0 in token of length 1
at com.example.retrace.Parser.fail(Main.java:58)
at com.example.retrace.Parser.digits(Main.java:52)
at com.example.retrace.Parser.readToken(Main.java:47)
at com.example.retrace.Parser.parse(Main.java:41)
at com.example.retrace.Main.lambda$main$0(Main.java:16)
at com.example.retrace.Main.main(Main.java:25)
…
Exception in thread "main" java.lang.IllegalStateException: cannot reserve crate
at com.example.retrace.Inventory.reserve(Main.java:73)
at com.example.retrace.Main.main(Main.java:12)
Caused by: com.example.retrace.FixtureException: over capacity by 2
at com.example.retrace.Inventory.checkCapacity(Main.java:78)
at com.example.retrace.Inventory.reserve(Main.java:70)
... 1 more常见问题
能不上传 mapping.txt 在线运行 ProGuard retrace 吗?
能。本页在浏览器里读取 mapping 并在本地还原,什么都不上传。这一点很重要,因为 mapping.txt 暴露了你原始的类名和方法名。
怎样用 mapping.txt 还原 R8 混淆的堆栈?
粘贴堆栈,选择 R8 为那次构建写出的 mapping.txt,点“还原堆栈”。命令行里用 Android SDK 命令行工具的 retrace mapping.txt trace.txt 效果相同,在我们的测试堆栈上两者输出完全一致。
为什么有些帧带着 <OR>?
这一帧能对上不止一个原始位置:通常是堆栈里没有行号、又有好几个方法被改成了同一个名字,或者一行混淆代码对应一段原始行。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 报告、从没进过这些平台的堆栈。
更新于
相关教程
- 用 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 对齐)。