删掉 LeakCanary 那天,我用 Trae 自己撸了一套:最难的不是分析堆,是决定什么时候 dump
九月中旬做隐私合规清点,安全同事拿着一张表挨个问 debug 依赖的数据去向。问到 LeakCanary 时她问的是「它弹的那个通知,点开能不能导出 hprof?hprof 里有没有用户数据」。我当场答不上来,回去把一份自己 debug 包导出的堆快照拖进文本编辑器搜了一下,里面躺着身份证号、就诊人姓名、一个还没过期的 token,全是 java.lang.String 的明文。Debug.dumpHprofData() 抓的是整个 Java 堆,它不认识什么叫个人信息。
那周我把 debugImplementation 那行删了。删完清净了半个月,十月八号灰度开始报 OOM,集中在模块 M 的报告页,Redmi Note 11 上一进一出七八次就被系统杀掉。以前这时候通知栏会跳一条 Leaks,现在什么都没有。我第一反应是自己写一个:Activity onDestroy 之后拿个 WeakReference 兜住,五秒后看还在不在,在就报泄漏。写了不到两百行,跑起来误报一串------刚按返回键的正常页面被标成泄漏,真出问题的那个报告页反而没抓到。
翻了两天源码我才明白自己漏了什么。用了这么多年 LeakCanary,我熟悉的其实全在堆快照之后:引用链怎么看、~~~ 标的是哪一条、Leaking: YES 什么意思。可它凭什么决定「现在该 dump 了」,我一点概念都没有。而 Square 自己写的历史里,这句话几乎是原话:缺的从来不是一个分析堆的办法,是知道什么时候值得费这个劲------一个触发器。后来我用 Trae 把这条链路重跑了一遍,从弱引用探针到端外的 hprof 解析,再到把整套流程固化成一个能复用的开发 Skill。这篇记的是这一趟。
缺的从来不是分析堆,是一个触发器
在没有 LeakCanary 之前,查内存泄漏是个体力活:随便挑个时刻 dump 一份堆,从设备上拉下来,丢进 Eclipse MAT 里翻,看哪个类的实例数不对劲。大多数 dump 什么也看不出来,偶尔看出点东西,离真正引入问题的那次改动已经隔了很久,早对不上号了。
Square 官方文档讲这段往事时写得很直白:缺的不是分析 heap dump 的手段,是「知道抓这一次值不值得------a trigger」。LeakCanary 是围绕这个触发器造出来的,它后来最出名的那些东西,包括在设备上直接分析堆,全都是之后才加上去的。
第一个版本甚至不是个库,也不叫这个名字。2015 年 3 月,Pierre-Yves Ricau 在 Square 那台收银 App 里写了个 154 行的 debug-only 类,叫 ActivityMemoryDiaper------内存尿布。通知图标是一块带别针的尿布。它干的事按序就五步:用弱引用盯着每一个被销毁的 Activity,等五秒,强制一次 GC,如果 Activity 还在就 dump 堆,弹一条通知,点开是一封带 .hprof 附件的邮件,正文写着「你刚发现了一个内存泄漏,Register Android 团队爱你,请把文件发给 seller-android@ 让我们修掉」。
名字是 Slack 上三条消息定的。P-Y 问「memory-diaper 当项目名合适吗,我想不出更好的双关了」,Logan Johnson 回「不行,有点恶心而且不好笑,我不想在一页 PPT 上看到它」,Eric Denman 说「要不往矿井里的金丝雀那个方向想想」。金丝雀就这么留下了,尿布进了历史。
真正让我停下来的,是检测手法在头两周被推翻了两次。
text
第 1 版 对 Activity 本身持一个 WeakReference
↓ 九天后
第 2 版 往 Activity 上挂一个对象,看它有没有被 finalize
commit: Detect memory leaks with no weak references
「避免持有指向 activity 的弱引用,那可能延长它的生命周期,
分析堆快照时也容易把人绕晕」
↓ 又一周
第 3 版 不看回收,看「弱可达」
commit: Detect weakly reachable leak tags
「LeakTag 一旦变成弱可达,理论上就会立刻入队,
这比 finalize() 被调用要早,能少一些由弱引用引起的误报」
第三版就是今天还在用的原理。两次改动的动机,头一次是「工具改变了它正在测量的东西」,第二次是「弱可达比实际回收更早、更干净」。我十月八号自己写的那两百行,恰好把这两个坑原样踩了一遍:我给 Activity 挂的是弱引用本身,判定条件写的是「引用没被清空」。当时还觉得挺顺,报警率 60% 以上也只当是阈值太松。
有个数字能说明这套触发器到底值多少钱:Square 在自家收银 App 里第一次启用之后,靠着找出来并修掉的那几个泄漏,把 OOM 崩溃率降了 94%。不是靠更准的分析器,是靠「在该 dump 的时候 dump」,让人每次打开都能看到一条跟刚才那次操作直接相关的证据。
我把这句话抄进了 docs/leak-design.md 的第一行,后面所有取舍都从它推:检测层要做的是造证据 ,分析层要做的才是读证据。
先看清它到底分几层
决定自己撸之前,我先把这个库拆成了模块看了一遍。原来一直以为它就是「一个依赖、一条通知」,实际上 2.x 之后切得很细,各层之间各有各的边界。
| 模块 | 干什么 | 在哪跑 | 我能不能自己写 |
|---|---|---|---|
object-watcher |
KeyedWeakReference、ReferenceQueue、retained 计数 |
平台无关,可进 release | 能,几百行 |
object-watcher-android |
生命周期挂载,自动 watch Activity / Fragment / View / ViewModel | App 进程 | 能,AndroidX 就够 |
leakcanary-android |
触发器、dump、分析、通知、Leaks UI | 仅 debug | 检测与触发能,UI 能简 |
shark-hprof / shark-graph |
解析 hprof 二进制、建对象图与索引 | 任意 JVM | 不该自己写全 |
shark |
最短路径、dominator tree、ObjectInspector、分组签名 | 端上 / CLI | 端外做最小版 |
plumber-android |
打补丁修 AOSP 已知泄漏 | App 进程 | 换成白名单表 |
shark-cli |
命令行分析已有 hprof、脱敏、堆增长检测 | 开发机 | 换成 Python 脚本 |
leakcanary-android-test / uiautomator |
UI 测试里跑重复场景查堆增长 | 仪器化测试 | 后面单列 |
看完这张表,边界就清楚了:检测与触发这一层是 154 行那个量级,用平台 API 完全写得动;分析层从 HAHA 换到 Shark 之后,是从约 6000 行 Java 长到约 16000 行 Kotlin 的工程,其中大半是 hprof 解析、数据结构适配和测试。我把后者重写一遍毫无意义,何况这轮整改的初衷就是别再往debug 包里塞看不透的第三方代码。
所以我给自己定的分工是:设备上只做「发现 + 取证 + 脱敏」,端外的开发机上做一个够用的最小 hprof 解析器,把最短引用链跑出来。分析器不追求覆盖所有 ART / 厂商堆格式,跑不通就明确报「这份 dump 不在支持范围」,绝不假装成功。这个能力边界我写进了 Trae 的 Rules,后面第 8 轮它想顺手把 dominator tree 也实现了,被我打回去了。
还有一层不能照搬:它的自动安装。AppWatcherInstaller 是个 ContentProvider,靠 manifest merger 注进去,系统在主线程上调它的 onCreate(),时机早于 Application.onCreate(),所以 2.x 之后连 LeakCanary.install(this) 都不用写。方便是真方便,代价也是真的------它拖在冷启动最前面那条链上,而且我这次整改要的恰恰是「什么时候初始化、初始化了什么,都能一眼审出来」。我们项目改成在 Application.onCreate() 里显式装,debug 构建才装,装的时候把开关和阈值一起打印到 logcat,安全同事复查时能直接看到证据。
一个 Activity 从销毁到通知,中间走了四步
官方文档把整个流程切成四段:检测留存对象 → dump 堆 → 分析堆 → 分类泄漏。我按自己项目的调用顺序重画了一遍,把每一步实际调用的平台 API 标在旁边。
四步里容易被跳过的其实是第一步的第 2 段:判定不是「等五秒看看」,而是等五秒、清队列、强制 GC、再清队列、还活着才算。顺序错了结果完全不同,我第一版就是少了一次 GC,把大量「本来会被下一次 GC 收掉」的对象算成了泄漏。
状态机比时序图更能说明为什么要有两段:
几个参数不是拍脑袋定的,我挨个查了它的来由:
| 参数 | 默认值 | 为什么是这个数 |
|---|---|---|
| 延迟检查 | 5 秒 | 给 onDestroy 之后那些延迟任务、消息队列一点收尾时间,太短会误报 |
| 可见时阈值 | 5 个 retained | dump 会让 ART 挂起所有线程,每个留存都 dump 一次手机就在冻结和分析之间来回抖 |
| 不可见时阈值 | 1 个 retained | 按 Home 之后用户看不到冻结, five 秒之内就把堆抓下来 |
| 通知点击 | 立即 dump | 人主动要看,跳过阈值 |
| 重排间隔 | 2000ms | 日志里那句 Rescheduling check ... in 2000ms because found only 4 retained objects (< 5 while app visible) |
前台 5 / 后台 1 这条是 2.0 重写时定的,起因是和 Uber 那边聊落地时记下的一张抱怨清单:1.x 一次只处理一个泄漏,泄漏一多,手机就在 dump(冻结)和分析之间来回切换,人根本没法用;大部分人不知道它能配多少东西;API 是围绕「替换哪个组件」组织的,不是围绕「我想干什么」组织的。这张清单直接催生了批量报告,也把配置 API 改成了 AppWatcher.config / LeakCanary.config 这种按用例改的方式。
我做的时候把阈值又往外推了一点:医疗类 App 的报告页里 WebView 和图片缓存多,一次 dump 在 Redmi Note 11 上要冻 600ms 上下,所以加了条「同一进程 60 秒内最多 dump 一次」的限流,并把 dump 挪到 HandlerThread 上做。冻结本身消不掉,能做的是别让它挡在用户正在点的那条路径上。
弱引用探针:看的是弱可达,不是回收
这层是整套东西的地基,也是我自己写第一版错得最离谱的地方。
WeakReference 有个常被忽略的语义:当一个对象只剩弱引用指向它时,GC 会在回收它之前 ,把这个 WeakReference 对象本身放进创建时绑定的 ReferenceQueue。也就是说,队列里出现这个 ref,不等于对象已经被回收,只等于它已经弱可达(weakly reachable)。LeakCanary 观察的是前者,不是后者------官方设计文档里专门有一节叫 "watch then confirm",讲的就是这个区分。
这带来两个直接好处。一是更早:入队发生在 finalize() 之前,而 finalize() 什么时候跑、跑不跑都不保证,还可能把对象复活。二是更干净:我们只持有一个很小的 KeyedWeakReference,不去碰目标对象本身,也就不会像第一版那样「工具改变了被测量的东西」。
KeyedWeakReference 比 JDK 那个 WeakReference 多了几个字段,每个都有用:
kotlin
// app/src/debug/java/com/example/health/leak/KeyedWeakReference.kt
// 只存在于 debug 源集,release 包里根本不编这个类
class KeyedWeakReference(
referent: Any,
val key: String, // UUID,后面靠它在 hprof 里认人
val description: String, // "MainActivity received onDestroy()"
val watchUptimeMs: Long, // 用来算留存时长
queue: ReferenceQueue<Any>
) : WeakReference<Any>(referent, queue)
ObjectWatcher 的核心就三个动作,加起来不到一百行:
kotlin
// app/src/debug/java/com/example/health/leak/ObjectWatcher.kt
class ObjectWatcher(
private val clock: () -> Long = { SystemClock.uptimeMillis() },
private val checkExecutor: Executor
) {
private val queue = ReferenceQueue<Any>()
private val watchedKeys = ConcurrentHashMap<String, KeyedWeakReference>()
@Volatile private var retainedCount = 0
val retainedObjectCount: Int get() = retainedCount
@Synchronized
fun watch(watchedObject: Any, description: String) {
removeWeaklyReachableObjects() // 先清历史账
val key = UUID.randomUUID().toString()
val ref = KeyedWeakReference(
watchedObject, key, description, clock(), queue
)
watchedKeys[key] = ref
checkExecutor.execute { // 5 秒后确认
moveToRetained(key)
}
}
private fun removeWeaklyReachableObjects() {
var ref: KeyedWeakReference?
while (queue.poll().also { ref = it as? KeyedWeakReference } != null) {
watchedKeys.remove(ref!!.key) // 入队 = 已弱可达 = 不算泄漏
}
}
@Synchronized
private fun moveToRetained(key: String) {
removeWeaklyReachableObjects()
if (!watchedKeys.containsKey(key)) return // 已被回收
GcConfirmer.forceGc() // 主动制造一次机会
removeWeaklyReachableObjects() // 关键:GC 之后再清一次
if (watchedKeys.containsKey(key)) {
retainedCount++
onRetained(key, watchedKeys[key]!!.description)
}
}
}
GcConfirmer 里那几行看着有点土,但是必要的:
kotlin
// app/src/debug/java/com/example/health/leak/GcConfirmer.kt
object GcConfirmer {
fun forceGc() {
Runtime.getRuntime().gc()
System.runFinalization()
// enqueue 是异步的,给 ReferenceHandler 一点时间把 ref 塞进队列
Thread.sleep(100)
Runtime.getRuntime().gc()
}
}
sleep(100) 是我返工加的。头一版跑完 GC 立刻 poll,队列还是空的,误报率下不来;查到 ReferenceQueue 的入队由独立的 ReferenceHandler 线程完成,才补了这一等。后来 Trae 在第 6 轮把这个硬编码的 100ms 换成了「最多轮询 5 次、每次 20ms、命中就退出」,比死等稳,也更容易在没有 Runtime.gc() 保证的机型上退化。
有个对照我一直贴在 docs/leak-design.md 里,同一个 key 在五个地方长得完全不一样,把它理顺之后我才算真的看懂这层:
| 位置 | 这个 key 的形态 | 谁能读到 |
|---|---|---|
watchedKeys |
ConcurrentHashMap 的 String 键,指向一个 KeyedWeakReference |
进程内,实时 |
ReferenceQueue |
已弱可达时,ref 对象本身在队列里,key 只是它的字段 | 进程内,GC 之后 |
| hprof | 一个 KeyedWeakReference 实例,字段 key 是 UTF-8 字符串常量 |
端外解析时按类名找到它 |
| leak trace | 从 GC Root 一路指到那个实例的引用链,末尾标 key = xxx |
报告页 |
| 分组签名 | sha1Hash(拼接每条可疑引用),跟 key 无关,同因不同实例归一组 |
归档、去重 |
最后一行是很容易混的:key 用来认单个对象 ,signature 用来认同一个 bug 。2.x 的日志里能看到 Signature: ce9dee3a1feb859fd3b3a9ff51e3ddfd8efbc6,四个 retained 对象被归成 2 个不同泄漏,靠的就是它。官方给的算法很直白,把每条标了 ~~~ 的可疑引用拼起来取 SHA-1:
kotlin
val signature = sha1Hash(
"com.example.LeakingSingleton.leakedViews" +
"java.util.ArrayList.elementData" +
"java.lang.Object[].[x]"
)
// dbfa277d7e5624792e8b60bc950cd164190a11aa
我照这个思路写了个简化版,但把「数组下标」也归一成 [x],否则同一个泄漏每次下标不同就会分成两行。这个细节官方文档里写了,Trae 第 10 轮第一版漏了,报告里同一处泄漏刷了十几行,我盯着看了半天才反应过来。
hprof 里躺着的到底是什么
这部分我原本打算直接跳过------反正端外有 MAT 和 Android Studio。可真到自己写解析器的时候才发现,不把二进制格式拆开,报告里的引用链就永远是别人给的黑盒结论。
Debug.dumpHprofData(path) 写出来的是 JVM TI Agent 定义的 HPROF 二进制。文件头是一行以 \0 结尾的版本串(Android 上常见 JAVA PROFILE 1.0.3),紧跟一个 u4 的 identifier size(4 或 8,取决于是不是 64 位 id),再跟一个 u8 时间戳。之后就是一串记录,每条记录的结构固定:
text
record := tag(u1) + timestamp(u4) + length(u4) + body(length 字节)
顶层的 tag 不多:
| Tag | 名称 | 里面是什么 |
|---|---|---|
0x01 |
STRING_IN_UTF8 |
id + UTF-8 字节,类名、字段名都从这来 |
0x02 |
LOAD_CLASS |
class serial + class object id + stack serial + name string id |
0x04 / 0x05 |
STACK_FRAME / STACK_TRACE |
分配时的调用栈 |
0x0C |
HEAP_DUMP |
完整堆,内含子记录 |
0x1C |
HEAP_DUMP_SEGMENT |
分段堆,Android 上更常见 |
0x2C |
HEAP_DUMP_END |
分段结束 |
坑在 HEAP_DUMP_SEGMENT 里:它内部的子记录没有 length 字段 ,只有一个 u1 子 tag 加定长或按结构推断的 body,得按 tag 逐种手工推进游标。我第一次写的时候按顶层记录的格式去读,直接从第二条开始就错位,解析出来的类名全是乱码。
子记录分两类。一类是 GC Root,每一个都指向一个对象 id:
| 子 tag | GC Root 类型 | 常见来源 |
|---|---|---|
0xFF |
ROOT_UNKNOWN |
厂商 / ART 未分类 |
0x01 |
ROOT_JNI_GLOBAL |
native 全局引用 |
0x02 |
ROOT_JNI_LOCAL |
活跃 JNI 调用的局部引用 |
0x03 |
ROOT_JAVA_FRAME |
Java 栈帧局部变量 |
0x04 |
ROOT_NATIVE_STACK |
native 栈 |
0x05 |
ROOT_STICKY_CLASS |
系统类(bootstrap 加载的类) |
0x06 |
ROOT_THREAD_BLOCK |
线程阻塞对象 |
0x07 |
ROOT_MONITOR_USED |
正在用的同步锁 |
0x08 |
ROOT_THREAD_OBJECT |
活跃 Thread 对象 |
Android 还扩了几个:0xFE HEAP_DUMP_INFO(多堆标识)、0x89 ROOT_INTERNED_STRING、0x8A ROOT_FINALIZING、0x8B ROOT_DEBUGGER、0x8C ROOT_REFERENCE_CLEANUP、0x8D ROOT_VM_INTERNAL、0x8E ROOT_JNI_MONITOR、0x90 ROOT_UNREACHABLE。我第一版只认了前八个,碰到 0xFE 直接抛异常,在 Android 13 的 dump 上跑一次崩一次。
另一类是对象:
text
0x20 CLASS_DUMP
class object id, stack serial, super class id, class loader id,
signers id, protection domain id, reserved × 2,
instance size(u4),
常量池(u2 数量 + 每项的 index/type/value),
静态字段(u2 数量 + name string id + type + value),
实例字段(u2 数量 + name string id + type)
0x21 INSTANCE_DUMP
object id, stack serial, class object id, 后续字节数(u4),
字段值字节流(本类字段在前,依次向上到父类)
0x22 OBJECT_ARRAY_DUMP
array object id, stack serial, 元素数(u4), array class object id, [id]*
0x23 PRIMITIVE_ARRAY_DUMP
array object id, stack serial, 元素数(u4), 元素类型(u1), 紧凑字节流
字段类型码是 2=object, 4=boolean, 5=char, 6=float, 7=double, 8=byte, 9=short, 10=int, 11=long。只有 2 是引用,其余全是标量------这条决定了 Pass2 里哪些字节要当边写进图。
两遍扫描是 Shark 比 HAHA 省内存的关键。HAHA 是 Android Studio 那套 perflib 的重新打包,会把整个堆加载进内存;Shark 用 HprofInMemoryIndex 存「对象 id → 文件偏移」,配合 LongLongScatterMap 这种原始类型集合把索引压到最小,再用 RandomAccessHprofReader 按需 seek、用 Kotlin Sequence 懒加载。官方 changelog 里那组数我记了很久:一份 160MB 的堆快照,在 Android Studio 里打开要 2GB 内存,Shark 只要 40MB。这也是 1.x 的 HeapAnalyzerService 得单开一个进程、2.x 才能把分析挪回主进程的原因。
BFS 从所有 GC Root 出发(实现上会挂一个合成的 SuperRoot 连到每个根),逐层向外扩,第一次碰到目标 id 时的那条路径就是最短强引用路径。为什么非要最短:更长的路径通常绕过了本来就该清理的中间节点,看不出该改哪一行;最短路径能把嫌疑收敛到最靠近泄漏对象的那一两个引用上。Shark 还有一层 VirtualInstanceReferenceReader,把 ArrayList.elementData、HashMap.table 这种集合内部结构扁平化,让引用链显示成「你写代码时的样子」而不是「它实现时的样子」------这招来自 Android Studio 团队 Nathan Paige 给 BLeak 写的 expanders,LeakCanary 到 2.8 才搬过来。
再往上还有 dominator tree 算 retained size:如果从 GC Root 到 B 的每条路都得经过 A,那么 A 支配 B,A 被回收时 B 才能一起走。这个数回答的是「这个泄漏到底占了多少」,跟「是谁拽着它」是两个问题。我端外那个脚本只做了 BFS,没做支配树------那是另外一坨工程,硬做只会得到一个算错的数,不如明确写「本工具不提供 retained size」。
还有两件事跟我们这轮整改直接相关。一是混淆:release 包的堆里类名是 a.b.c,引用链等于废纸。读混淆堆这件事是 2019 到 2020 十个月里分三块补上的------Shark 学会解析 ProGuard mapping 并在建链时套回去、一个 Gradle 插件把 mapping 文件当 asset 塞进 APK、同样的反混淆做成 shark-cli 的 -m 选项。我这边把 mapping.txt 跟 hprof 放进同一份归档目录,解析时按版本取。
二是脱敏,也是安全同事那一问逼出来的。Shark 有个 HprofPrimitiveArrayStripper,把 PRIMITIVE_ARRAY_DUMP 的内容整个替换成零,shark-cli 里对应 strip-hprof 命令。我在 App 端 dump 之后立刻做一遍同样的事再允许文件离开设备,因为堆里的字符串和图片像素都在基本类型数组里。补上之后我重新搜了一遍那份快照,身份证号、姓名、token 全没了,引用链还在------路径只依赖对象 id 和字段名,不依赖字段的字面值。这一点我让 Trae 在第 10 轮专门写了回归用例,后面每版都跑。
从尿布到金丝雀,这十一年改了什么
把版本线排完,会发现改的方向很一致:检测那一层几乎没动,动的几乎全在「怎么让人愿意天天开着它」。
| 时间 | 版本 | 具体变了什么 |
|---|---|---|
| 2015-03 | 未命名 | Square 收银 App 里 154 行 ActivityMemoryDiaper,弱引用 + 5 秒 + 强制 GC + 邮件附件 |
| 2015-05-08 | 1.3 | GitHub 初次导入,Java,LeakCanary.install(this) 返回 RefWatcher;debugCompile 配 no-op 给 release;HAHA 分析、独立进程 HeapAnalyzerService、DisplayLeakService 发通知 |
| 2016-2018 | 1.4~1.6.3 | 支持 support Fragment、AndroidExcludedRefs 白名单、可展开每个字段的完整引用链 |
| 2019-04 第一周 | 2.0 起点 | Square hack week 立项;头四天 Colin Marsch 和 Brandon Cheng 把 1.x 代码逐类翻成 Kotlin |
| 2019-04-23 06:43 | 2.0-alpha-1 | 巴黎时间,Android Makers 现场打的 tag,演讲叫《LeakCanary 2: Leaner, Better, Faster, Kotliner!》;前一周 67 个提交,测试用的是大会自己的 App |
| 2019-11-27 | 2.0 正式版 | 7 个月、3 个 alpha、5 个 beta、23 位贡献者、493 次提交;约 6000 行 Java → 约 16000 行 Kotlin;Shark 取代 HAHA;自动安装;前台 5 / 后台 1 批量报告;按类型分组 |
| 2020-12-24 | 2.6 | 开始盯 View.onDetachedFromWindow() 之后的 root view、以及 Service.onDestroy() 之后的服务 |
| 2022-01-04 | 2.8 | 搬进 expanders(集合按写法展示);Android 12、WorkManager、多进程、App Startup 适配 |
| 2019-2020 | 跨版本 | 读混淆堆的三件套陆续补齐:Shark 解析 mapping、Gradle 插件把 mapping 塞进 APK、shark-cli -m |
| 2022-2024 | 2.9.1~2.14 | 2.10 试过 Neo4j 探索堆;2.12、2.13 常规迭代;2.14(2024-04-17)修掉一个 SettableFuture 依赖------WorkManager 后续版本删这个类时,2.8~2.13 会在分析阶段崩 |
| 2024-01-09 | 3.0-alpha-1 | 开始做 heap growth:ObjectGrowthDetector 在 UI 测试里反复跑同一场景,看堆是否持续增长 |
| 2026-06-25 | 3.0-alpha-9 | 仍是 unreleased;shark-heap-growth 并回主模块,新增 leakcanary-android-test / uiautomator |
截至我写这篇的时候,稳定版还是 2.14,3.0 一直是 alpha,官网 changelog 顶部挂着 Unreleased。这点我特意让 Trae 第 2 轮联网核了一遍------网上不少 2020 年的教程把 2.0 说成「2020 年发布」,把 LeakSentry 当成还在用的 API,其实那是 alpha 期的名字,正式版里已经换成 AppWatcher 了。照着旧教程写,第一步就编译不过。
2.0 那次重写最值得琢磨的不是 Shark,是那三处报告改动,全是「跟人相处」的问题:
text
1. 引用链的方向
设计笔记本来主张把 GC Root 放底部(类比堆栈轨迹),
2018 年 10 月一天之内画了六个原型,
最后还是定成 Root 在顶、每行一个引用、对象状态标在旁边。
2. 不再打印每个字段
1.x 能把链上每个对象的所有字段展开,信息是全,
但没人看得完。换成 ObjectInspector 贴的短标签,
只说值得说的那句,比如 "View.mContext references a destroyed activity"。
3. 报告变成批量
1.x 每个 retained 都触发一次;2.x 等进后台或前台攒够 5 个,
一次分析找出全部泄漏,再把相同的归到一组。
顺带说一个反直觉的分支:3.0 alpha 里的 heap growth 检测,是整套东西里唯一不需要 watch 任何对象就能发现泄漏的部分。它让同一个 UI 场景反复跑,看堆里的对象数是不是一直涨。有意思的是这套机制复用的正是 2.8 搬进来的 expanders,而 expanders 又来自 Android Studio 团队拿来自查 IDE 的 BLeak harness------再往上追,BLeak 那篇论文(John Vilk 和 Emery D. Berger,PLDI 2018)研究的是 Web 应用里的 JavaScript 内存泄漏。一条从 JS 论文拐到 Android 堆分析的线,中间隔了四年。
哪些老办法到今天失效了,哪些反而回来了,我按自己项目记了一张:
| 做法 | 现状 | 原因 |
|---|---|---|
| 随机时刻 dump + 拖进 MAT 肉眼看 | 失效 | 没有触发器,dump 和问题之间对不上时间 |
finalize() 判回收 |
失效 | 时机不定、可能复活对象、误报高 |
| 对目标对象直接持弱引用 | 失效 | 工具改变被测量对象,且污染堆里的引用关系 |
| 独立分析进程 | 基本失效 | Shark 内存降到 1/50,主进程就能跑 |
release 里塞 no-op artifact |
失效 | 2.x 靠 debugImplementation 从源头隔离 |
| 每发现一个就 dump | 失效 | 手机在冻结与分析之间反复抖 |
弱可达 + ReferenceQueue |
一直在 | 2015 年第三版定下,十一年没换 |
| 5 秒等待 + 强制 GC | 一直在 | 给延迟任务和消息队列留收尾时间 |
| 最短强引用路径 | 一直在 | BFS 天然给出最短,最靠近该改的那行代码 |
| 「什么时候该取证」这个判断 | 反而更重要了 | 端上分析再快,dump 一次的代价没变 |
把现成封装对到我们项目里
这一节是整改里最花时间的部分:不是抄代码,是决定每个模块在我们的工程里由谁承担、边界画在哪。我们那个健康类 App 当时的状况是------模块 M 承担报告页与就诊记录,debug 包里不许出现会导出全堆的第三方依赖,release 包里不许出现任何检测代码,线上 OOM 上报里希望能带一个「销毁后没被回收的对象数」。
我让 Trae 把这张映射表先列出来,再由我逐条划掉它想多做的部分:
| LeakCanary 的封装 | 我们项目里的对应物 | 处理方式 |
|---|---|---|
object-watcher / ObjectWatcher |
leak/ObjectWatcher.kt |
照搬设计,自己实现,约 110 行 |
object-watcher-android 生命周期挂载 |
leak/LeakWatchers.kt |
照搬清单 ,用 ActivityLifecycleCallbacks + AndroidX 自己写 |
AppWatcherInstaller(ContentProvider) |
LeakDetector.install() |
改:显式调用,仅 debug,拒绝自动安装 |
HeapDumpTrigger |
leak/DumpTrigger.kt |
照搬策略,阈值加严,加 60 秒限流 |
HeapDumper / Debug.dumpHprofData |
leak/HeapDumper.kt |
照搬 API,加私有目录与立即脱敏 |
Shark 全套分析 |
tools/hprof_min.py |
缩小:只做 Pass1 索引 + Pass2 建边 + BFS,不碰支配树 |
Leaks UI / 通知 |
通知 + 一个 debug 报告页 | 简化 :原生 NotificationCompat + 原生 AlertDialog 说明用途 |
OnHeapAnalyzedListener 上报 |
脱敏后写本地 JSON,人工决定是否上传 | 改:默认不上传,避免个人信息出设备 |
AndroidReferenceMatchers / plumber-android |
assets/known-leaks.txt |
换成表:只做白名单,不给系统打补丁 |
object-watcher-android 进 release 计数 |
leak/ReleaseRetainedCounter.kt |
照搬思路,只留计数,release 里不 dump、不落文件 |
leakcanary-android-instrumentation |
Espresso 里反复进出报告页 | 后面做,先不进这一版 |
三条边界是我在评审时加的,都跟这轮合规整改直接相关:
其一,dump 出来的文件在离开设备之前必须先把 PRIMITIVE_ARRAY_DUMP 清零。hprof 里的 String 底层就是基本类型数组,就诊记录、身份证号、token 全在里面;引用链只需要对象 id 和字段名,不需要字面值。脱敏之后我拿 strings 扫过一遍,什么都没剩。
其二,release 包里只保留一个极薄的计数器和一个 retainedObjectCount 的读取口,挂到 OOM 崩溃上报的元数据里。这个用法其实正是官方给 object-watcher-android 设计的场景------它「代码量很小,对 release 应用应该没有影响」,只保留弱引用,不触发 dump、不做别的。我把这段挪过来自己实现,是为了让安全同事审依赖时能一眼看全:没有网络、没有文件、没有第三方类。
其三,自动安装不采纳。ContentProvider.onCreate() 在主线程、早于 Application.onCreate(),写起来省事,但它把「谁初始化了什么」藏进了 manifest merger 的产物里。合规复查要的是可枚举,我宁可在 Application.onCreate() 里多写一行,把开关、阈值、源集一起打进 logcat。
Gradle 这边的隔离也就三行,比 1.x 那个 no-op 依赖干净得多:
kotlin
// app/build.gradle.kts
android {
// debug 专属实现只放在 debug 源集,release 编译单元里根本看不到这些类
sourceSets {
getByName("debug") { java.srcDirs("src/debug/java") }
}
}
dependencies {
// 检测层零第三方依赖;只用到 AndroidX 的 lifecycle / core
implementation("androidx.core:core-ktx:1.13.1")
debugImplementation("androidx.fragment:fragment-ktx:1.8.2")
}
xml
<!-- app/src/debug/AndroidManifest.xml:debug 源集单独合并 -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".leak.LeakReportActivity"
android:exported="false"
android:label="泄漏报告" />
</application>
</manifest>
Application 里的入口长这样,显式、可审计:
kotlin
// app/src/main/java/com/example/health/MyApplication.kt
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
// 反射调用 debug 源集的实现,release 编译单元没有这个类
runCatching {
Class.forName("com.example.health.leak.LeakDetector")
.getMethod("install", Application::class.java, Boolean::class.java)
.invoke(null, this, true)
}.onFailure { Log.w("LeakDetector", "install skipped", it) }
Log.i("LeakDetector", "debug watcher on, threshold=5/1, dump=off in release")
}
}
}
用反射跨源集这一步是 Trae 第 5 轮的提议,我一开始嫌绕,想直接在 main 里放个接口。后来发现那样 release 里也会编进接口和空实现,虽然没行为,但审依赖时又多一处要解释的东西,就改成现在这样。它有个缺点我如实记在 docs/leak-design.md:Class.forName 在 R8 之后要靠 keep 规则保住 debug 类名,而 debug 包通常不开混淆,实际没踩到;哪天要给 debug 开 minify,这条得先补规则。
给 Trae 立的规矩
前面几篇我都是先写 Rules 再开工,这次也一样。区别是这轮多了个约束:hprof 是敏感数据,绝不能进上下文、更不能进仓库。
text
# .traeignore
**/*.hprof
**/*-stripped.hprof
**/captures/
**/leak-reports/
**/mapping.txt
**/build/outputs/mapping/
app/src/debug/java/**/generated/
**/leak_baseline.tmp.json
Rules 放在 .trae/rules/leak_rules.md,带 globs 限定只对检测层生效:
markdown
---
description: 自建内存泄漏检测器的实现与核查规则
globs:
- "app/src/debug/java/com/example/health/leak/**"
- "app/src/main/java/com/example/health/MyApplication.kt"
- "tools/hprof_*.py"
- "tools/*leak*.sh"
---
# 角色与边界
你在帮一个医疗健康类 Android App 自建泄漏检测层。
检测层只用平台 API 与 AndroidX,不引入任何第三方检测/分析库。
端外解析器只做最小可用版,遇到不认识的 hprof 子记录要明确报错,不许静默跳过。
# 三条不可协商的约束
1. 不引第三方库当方案。讲原理可以提 LeakCanary / Shark / MAT,落地代码里不许出现它们的依赖或包名。
2. 任何离开设备的堆快照,必须先清零 PRIMITIVE_ARRAY_DUMP;
报告里不许出现字符串字面值、图片像素、用户标识。
3. release 编译单元里不许出现 watcher / dumper / 分析代码,
只保留一个 retained 计数的读取口;不许声称优化了没实测过的数字。
# 检测语义
- 判定顺序固定为:延迟 → 清队列 → 强制 GC → 再清队列 → 仍在表才算 retained。
- 观察弱可达,不观察实际回收;不许用 finalize() 判回收;不许对目标对象多持有任何强引用。
- 前台阈值 5、后台阈值 1、同进程 60 秒内最多 dump 一次;阈值可配但要写进 build-info。
# 能力与验收
- 端外解析器支持 JAVA PROFILE 1.0.3、idSize 4/8、0x1C 分段;
不支持的 tag 抛异常并在报告里列出,不许猜。
- 每个改动都要给出一条可复现的验证命令与一组带机型/样本量的数字。
- mapping.txt 与 hprof 同目录归档,但不进版本库。
提示词统一用五段式,这个骨架从前面几篇一直沿用下来,改的只是约束:
text
【角色与边界】你在什么工程里、只改哪一层、不许碰什么
【目标】这一轮只解决一个问题,写清完成的定义
【上下文】@ 具体文件与目录,不给整仓;附上前一轮的产出路径
【约束】套 leak_rules.md;不引库、不落未脱敏堆、不写没实测过的数字
【验收】一条命令或一组数据,写清机型、样本量、口径
三条固定 Constraints 是「不引第三方库 / 堆快照出设备前脱敏 / release 零检测代码」。模式上是 SOLO Agent------2026 年 5 月 Builder 和 Coder 合并之后,旧教程里「先出计划、等我确认再动手」那两段式已经找不到了,这个返工我在第 1 轮就撞上:Trae 直接把六个文件一次性写完,我原本准备的「确认计划」那句追问根本没派上用场,只能改成一次投喂自带证据和验收。
开工前先把四个数定死在 docs/metrics-leak.md,每一轮都 @ 它,谁也不许临时换口径:
| 数 | 口径 | 整改前基线 | 目标 |
|---|---|---|---|
| 误报率 | 假阳性数 / 总 retained,40 个构造样本 + 20 轮正常进出 | 62%(我手写的第一版) | ≤ 10% |
| 真阳性召回 | 8 类已知泄漏样本被抓到的数量 | 5 / 8 | 8 / 8 |
| dump 冻结 P90 | Pixel 6,冷态十轮,Choreographer 掉帧计时 |
1180ms(同步在主线程) | ≤ 700ms |
| release 洁净度 | release APK 里检测类数量 / OOM 上报带 retained 数的比例 | 0 类 / 0% | 0 类 / ≥ 95% |
第四个数的目标看着奇怪,其实两头都得守:一头是 release 里一个检测类都不能有,另一头是线上 OOM 上报要能带上那个计数。我第一版把计数器也一起挪进 debug 源集,结果线上 OOM 报告里那个字段全空,被 Trae 在第 11 轮换身份复核时挑了出来。
十二轮提示词,按这个顺序跑完的
十二轮不是十二句「帮我写个内存泄漏检测」。前四轮我一行业务代码都不让它碰:先把项目里那些「销毁后本该被回收」的对象扫清楚,把版本行为核到一手来源,把四步流程映射到我们的模块,再定下检测层的接口。中间六轮才逐个组件落地,最后两轮做回归、卡 CI、换身份挑刺。
每轮我都写了在哪个模式、@ 了哪些文件、整段提示词、期望产出哪些文件、我实际返工了什么、用哪条命令或哪组数据验收。模式统一是 SOLO Agent,提示词里的路径都按项目真实结构写,全选复制就能投。轮次编号后来原样用进了 docs/CHANGELOG-leak.md 和 Skill 的工作流阶段号,改起来会牵一串,所以一次定死。
第 1 轮 · 现状体检:到底有哪些对象该被盯着
模式:SOLO Agent,@app/src/main、@app/src/debug、@app/build.gradle.kts、@app/src/main/AndroidManifest.xml。
text
【角色与边界】
你在我们那个健康类 Android App 里做一次只读体检,不要修改任何文件。
目标是给「自建泄漏检测层」准备一份准确的现状清单,不写实现代码。
【目标】
产出 docs/leak-inventory.md,包含四张表:
1) 所有有明确销毁时机的对象类型(Activity、Fragment、Fragment View、
ViewModel、Service、detached root view、自定义 Presenter / Manager),
逐个列出类名、所在模块、销毁回调是哪个方法、是否有静态引用嫌疑;
2) 现有的静态单例、object、companion object 里持有 Context / View /
Listener / 协程 Job 的位置,标文件与行号;
3) 当前 debug 与 release 的依赖差异,特别是任何会 dump 堆、上传文件、
采集设备信息的依赖;
4) AndroidManifest 里所有 ContentProvider 声明(含由依赖 merge 进来的),
标出它们的初始化时机风险。
【上下文】
@app/src/main @app/src/debug @app/build.gradle.kts
@app/src/main/AndroidManifest.xml
项目包名 com.example.health,模块 M 负责报告页与就诊记录。
优先看模块 M,其余模块只列数量与典型代表,不要全量展开。
【约束】
套 leak_rules.md。只读,不写代码,不引第三方库。
不要下「存在泄漏」的结论,只陈述「这里有一个生命周期边界」。
不确定的地方标 NEED_VERIFY,不许猜。
【验收】
把 inventory 里第 2 张表的每一条,用一条 adb 命令或一段静态检索命令
说明怎么复核;最后给出「本次体检覆盖多少个源文件、跳过多少个」。
期望产出:docs/leak-inventory.md。
我实际返工的是第 4 张表。它第一版把 androidx.startup.InitializationProvider 也列成了「我们自己声明的 ContentProvider」,还建议我去改它的 onCreate;我打回去让它区分「本项目源码里声明的」和「依赖 merge 进来的」,并在每条后面标来源产物路径。这个区分后面救了我一次:第 5 轮讨论要不要用自动安装时,我直接翻这张表就能说明「现在已经有几个 provider 排在 Application 前面了,不要再添一个」。
验收命令:
bash
adb shell dumpsys package com.example.health | grep -i -A2 'ContentProvider'
# 对照 inventory 第 4 表,逐个确认「本项目声明」与「merge 进来」两栏
grep -rnE 'object |companion object|: Context|: View|Listener' app/src/main/java | wc -l
第一轮跑完它报「覆盖 214 个源文件,跳过 3 个生成目录」,静态引用嫌疑 37 处,其中模块 M 占 19 处。这个数后来成了误报治理的样本池。
第 2 轮 · 联网核准:把版本行为核到一手来源
模式:SOLO Agent(联网检索开),@docs/leak-inventory.md、@docs/metrics-leak.md。
text
【角色与边界】
你负责给这次自建检测层建立一份可追溯的事实底座,不写实现代码。
只采信 Square 官方文档、官方 changelog、Android 平台官方文档;
社区博客只能当线索,结论必须回到一手来源核对。
【目标】
产出 docs/version-facts-leak.md,逐条给出「事实 + 一手来源 URL + 标注日期」:
1) LeakCanary 1.x 与 2.x 的检测流程差异:RefWatcher / ObjectWatcher、
HAHA / Shark、独立分析进程 / 主进程、自动安装方式、no-op artifact 去留;
2) 2.0 正式版的准确发布日期、alpha1 的时间与场合、代码规模变化;
3) 默认参数:等待时长、前台阈值、后台阈值、点击通知的行为、日志原文;
4) Debug.dumpHprofData 的适用条件、dump 期间 ART 的行为、文件落盘要求;
5) ReferenceQueue 的语义:弱可达与已回收的先后、为什么 finalization
不适合当判据;
6) 当前稳定版与 3.0 alpha 的状态,以及 2.8~2.13 与 WorkManager 那个
已知兼容问题的准确表述;
7) hprof 二进制的顶层 tag、0x1C 子记录 tag、Android 扩展 tag 清单。
【上下文】
@docs/leak-inventory.md @docs/metrics-leak.md
已知容易写错的点:网上大量教程把 2.0 写成 2020 年发布,
把 LeakSentry 当成正式版 API。请专门核这两条并给出结论。
【约束】
套 leak_rules.md。每条事实必须带 URL;两个来源冲突时并列写出、
不许挑一个当事实、不许揉成「逐步」这类模糊说法。
拿不准的写 UNCONFIRMED,不要编版本号和日期。
【验收】
输出一张「常见错误说法 → 一手来源里的准确表述」对照表,不少于 6 行。
最后列一遍本次实际打开过的 URL。
期望产出:docs/version-facts-leak.md。
这轮返工了两处,都写进正文了。第一处是 2.0 的日期:它第一版搜到几篇中文教程,写了「2020 年 2.0 发布」,我让它回到官方 changelog 和 history 页,才改成 2019-11-27 正式版、2019-04-23 06:43 巴黎时间 alpha1 ,并把 LeakSentry(alpha 期名字)与正式版 AppWatcher 的关系单列一行。第二处是 16KB 页表那种跨主题的日期冲突,它习惯性揉成「逐步强制」,我按前几篇的规矩让它分两栏并列写,不挑一个当事实。
验收我用的是「错误说法对照表」行数,加上一条本地校对:
bash
grep -c '^|' docs/version-facts-leak.md
grep -nE '2019-11-27|2019-04-23|LeakSentry|AppWatcher|前台|阈值' docs/version-facts-leak.md
第 3 轮 · 原理与资产映射:把四步流程落到模块 M
模式:SOLO Agent,@docs/leak-inventory.md、@docs/version-facts-leak.md、@app/src/main/java/com/example/health/report/。
text
【角色与边界】
你做架构映射,产出设计文档,不写实现代码。
看这份文档的是组里另一个 Android 开发和一位做合规复查的同事,
两人都没读过 LeakCanary 源码。
【目标】
产出 docs/leak-design.md,包含:
1) 一张四步流程图(Mermaid):检测留存 → dump → 分析 → 分类,
每一步标出由我们哪个文件承担、调用哪个平台 API;
2) 检测层与分析层的边界表:哪些能力自己实现、哪些缩成最小版、
哪些明确不做,每一项写清理由;
3) 「一个 key 走完全程」对照表:UUID 在 watchedKeys、ReferenceQueue、
hprof、leak trace、分组签名五处的形态;
4) 隐私边界章节:哪些数据会进 hprof、脱敏在哪一步做、
脱敏后哪些能力会失效、什么情况下文件才允许离开设备;
5) release 侧只保留什么、为什么、怎么验证它没混进别的类;
6) 四个度量数的定义与测量方式,直接引用 docs/metrics-leak.md,不许改写。
【上下文】
@docs/leak-inventory.md @docs/version-facts-leak.md
@app/src/main/java/com/example/health/report/
项目现状:debug 包不允许有会导出全堆的第三方依赖;
release 包不允许有检测代码;线上 OOM 上报希望带 retained 计数。
【约束】
套 leak_rules.md。检测层零第三方依赖;分析层不许承诺覆盖所有
ART / 厂商堆格式;不许把「自动安装」写进方案,理由要写明。
设计文档里不许出现 TODO、占位符、让人自己补内容这类话。
【验收】
让合规复查同事能只凭第 4 章回答「hprof 里有没有用户数据、怎么处理的」;
让另一个开发能只凭第 2 章说出「我们不做 dominator tree 的原因」。
末尾给出这份设计与官方四步流程的逐条对应表。
期望产出:docs/leak-design.md。
它第一版把第 2 章写成了「能力对照表」,顺手承诺了 retained size 和 native 内存归因------那两样是 Shark 里 DominatorTree 和 AndroidNativeSizeMapper 的活,我端外脚本根本没打算做。我打回时补了一句约束:「凡是你没打算在第 8~9 轮实现的,一律写进『明确不做』栏,并给理由」。重出之后这张表才有用。第二处返工是第 4 章,它把「脱敏」写成一句原则,没说清 PRIMITIVE_ARRAY_DUMP 清零之后 String 字面值就没了、但引用链不受影响,我让它把这个因果补上。
验收方式很土但有效:把第 4 章单独发给安全同事,她回了句「能看懂,不用再问我」,这轮算过。
第 4 轮 · 最小 ObjectWatcher:把探针写出来
模式:SOLO Agent,@docs/leak-design.md、@docs/version-facts-leak.md。
text
【角色与边界】
你只实现检测层最内核的一个类,不碰生命周期挂载、不碰 dump、不碰 UI。
目标目录 app/src/debug/java/com/example/health/leak/。
【目标】
产出两个文件:
- KeyedWeakReference.kt:继承 WeakReference<Any>,带 key(UUID)、
description、watchUptimeMs 三个字段,构造时绑定 ReferenceQueue;
- ObjectWatcher.kt:提供 watch(Any, String)、removeWeaklyReachableObjects()、
moveToRetained(key)、retainedObjectCount。
【上下文】
@docs/leak-design.md @docs/version-facts-leak.md
判定顺序必须严格是:延迟 → 清队列 → 强制 GC → 再清队列 →
仍在表才算 retained。观察弱可达,不观察实际回收。
参考官方设计文档里 "watch then confirm" 那一节,但代码自己写。
【约束】
套 leak_rules.md。只用 JDK + Android SDK 平台 API,零第三方依赖。
watchedKeys 必须是线程安全的;不许对 watchedObject 产生任何额外强引用
(包括日志里拼接它、包括把它塞进任何闭包或集合)。
不许用 finalize();不许把「引用没被清空」当泄漏判据。
延迟与 GC 相关常量抽成构造参数,默认值 5 秒,便于测试注入 fake clock。
【验收】
附一段可直接跑的 JVM 单元测试(不依赖 Android 设备):
1) 一个不再被引用的对象,在强制 GC 之后不该计入 retained;
2) 一个被静态字段拽住的对象,应该计入 retained;
3) 同一个对象 watch 两次,只计一次;
4) 给出「用 fake clock 把 5 秒压成 0」的注入示例。
说明每个用例对应上面哪一条判定顺序。
期望产出:KeyedWeakReference.kt、ObjectWatcher.kt、ObjectWatcherTest.kt。
这轮返工最典型,也最值得记:它第一版在 watch() 里写了一句 Log.d("Leak", "watching $watchedObject")。字符串插值会调用 toString(),虽然不至于长久持有,但已经违反「不对目标对象产生额外引用」这条;更麻烦的是某些 toString() 实现会读字段、甚至抛异常。我让它改成只打 description 和 key。第二处是 moveToRetained 里只在开头清了一次队列,漏了 GC 之后的第二次,正好是我十月八号手写那版犯的错------把判定顺序整段贴进提示词的【上下文】才压住。
验收就是那四个单测,加上一条本地检索:
bash
./gradlew :app:testDebugUnitTest --tests '*ObjectWatcherTest*'
grep -n 'watchedObject' app/src/debug/java/com/example/health/leak/*.kt
# 检查除了构造 KeyedWeakReference 之外,没有第二处用到目标对象
第 5 轮 · 生命周期挂载:谁在什么时候交出对象
模式:SOLO Agent,@app/src/debug/java/com/example/health/leak/ObjectWatcher.kt、@docs/leak-inventory.md、@app/src/main/java/com/example/health/MyApplication.kt。
text
【角色与边界】
你实现生命周期挂载层与显式安装入口,只改 debug 源集与 MyApplication 里
那个开关,不碰业务代码、不改任何 XML 布局。
【目标】
产出:
- LeakWatchers.kt:注册 Application.ActivityLifecycleCallbacks 盯
onActivityDestroyed;用 AndroidX FragmentManager 的
FragmentLifecycleCallbacks 盯 onFragmentViewDestroyed 与
onFragmentDestroyed;盯 View.onDetachedFromWindow 之后的 root view;
盯 ViewModel.onCleared。每类对象交出时附一句可辨识的 description;
- LeakDetector.kt:install(Application, Boolean) 显式入口,
把 watcher、trigger、dumper 串起来,把开关、阈值、源集打进 logcat;
- MyApplication.kt 里 debug 分支的调用(跨源集用反射,理由写注释);
- app/src/debug/AndroidManifest.xml 里一个 exported=false 的报告页声明。
【上下文】
@app/src/debug/java/com/example/health/leak/ObjectWatcher.kt
@docs/leak-inventory.md(第 1 张表是对象清单,按它逐个覆盖,
没覆盖到的在文档里列出来)
@app/src/main/java/com/example/health/MyApplication.kt
【约束】
套 leak_rules.md。不许用 ContentProvider 自动安装,
理由:主线程、早于 Application.onCreate、把初始化藏进 merge 产物;
要能在 logcat 里一眼审出「装了什么」。
不许引第三方库;Fragment / ViewModel 只用 AndroidX;
同一个对象不许被两条回调重复 watch(给去重策略并说明)。
权限与通知相关:用原生 AlertDialog 先说明用途再申请,不做静默行为。
【验收】
给出 adb 命令序列:打开报告页、按返回、五秒后看 logcat 里
Watching / Retained 两行;再给出「旋转屏幕三次」的重复挂载检查命令,
确认没有 duplicate watch。
期望产出:LeakWatchers.kt、LeakDetector.kt、改动的 MyApplication.kt、debug manifest。
返工两处。一是它把 Fragment 的 view 和 Fragment 本身都 watch 了一遍,同一个 onDestroyView 场景会重复计数,我让它按 identityHashCode 做一层短时去重,并在 docs/leak-design.md 里写清「view 与 fragment 是两条独立证据,但同一次销毁只算一个 retained」。二是跨源集:它一开始直接在 main 里 import 了 debug 的类,编译当然过不了;改成反射之后又忘了 R8 的 keep 说明,我让它把那条风险写进注释和 docs/leak-design.md,不藏起来。
验收命令:
bash
adb logcat -s LeakDetector LeakWatcher | grep -E 'Watching|Retained|install'
# Watching instance of ... (Activity received Activity#onDestroy() callback)
# 5 秒后:Scheduling check for retained objects because found new object retained
adb shell am start -n com.example.health/.report.ReportActivity
adb shell input keyevent 4 # 返回,触发 onDestroy
第 6 轮 · 确认逻辑与阈值:把误报压下来
模式:SOLO Agent,@app/src/debug/java/com/example/health/leak/、@docs/metrics-leak.md。
text
【角色与边界】
你只改确认逻辑与触发策略,不改 ObjectWatcher 的 watch() 语义,
不写 dump 实现,不写 UI。
【目标】
产出:
- GcConfirmer.kt:把硬编码的 Thread.sleep(100) 换成
「最多轮询 5 次、每次 20ms、命中即退出」的入队等待,
并说明为什么 Runtime.gc() 不保证立刻 enqueue;
- DumpTrigger.kt:前台 5 / 后台 1 阈值、应用可见性监听、
同进程 60 秒限流、点击通知立即 dump、阈值可配且写进 build-info;
- 一份 docs/false-positive.md:列出至少 12 类会造成假阳性的情形,
每类给出「现象 → 根因 → 判定上怎么排除」,含配置变更、
延迟消息、协程 Job、系统已知泄漏、WeakReference 被提前置空。
【上下文】
@app/src/debug/java/com/example/health/leak/
@docs/metrics-leak.md(误报率目标 ≤ 10%,口径不许改)
第一版手写实现的误报率 62%,主要原因是漏了 GC 之后的第二次 poll。
【约束】
套 leak_rules.md。不许为了让数字好看而放宽判据(比如延长等待到 30 秒),
只能改确认逻辑;不许静默吞掉异常。
可见性判断用 Application.ActivityLifecycleCallbacks,不引第三方库。
所有阈值集中在一个 Config 数据类里,便于真机回归时切换。
【验收】
用 40 个构造样本 + 20 轮正常进出复测误报率,给出
Pixel 6 与 Redmi Note 11 两台机器的分组数字,写清样本构成;
并给出一条命令能 dump 出当前生效的 Config。
期望产出:GcConfirmer.kt、DumpTrigger.kt、docs/false-positive.md。
这轮有个我自己判断错的地方:Trae 提议把等待时长从 5 秒提到 8 秒来压误报,我第一眼觉得挺合理,差点批了。后来按 docs/metrics-leak.md 的口径一算,误报率是下来了,但真阳性召回跟着掉------报告页那个泄漏本来在 5 秒后才稳定暴露,等 8 秒反而更容易被系统回收掉一部分中间对象。我让它把「放宽判据」这条路明确写进 false-positive.md 的反例栏,标注「数字会好看,但牺牲召回」。这类返工比改一个 bug 值钱。
验收那组数字:Pixel 6 上误报率 62% → 9%,Redmi Note 11 上 65% → 11%,8 类样本召回 5/8 → 8/8。第二台机器略超 10% 的目标,我没让它调参掩盖,而是在报告里标了「低内存机型仍超 1 个百分点,原因是系统回收更激进,留到下轮」。
第 7 轮 · dump 与冻结控制:把代价关在后台线程里
模式:SOLO Agent,@app/src/debug/java/com/example/health/leak/DumpTrigger.kt、@docs/leak-design.md。
text
【角色与边界】
你实现 HeapDumper 与脱敏前置处理,只在 debug 源集改动。
不碰 ObjectWatcher、不碰端外解析器、不碰业务代码。
【目标】
产出:
- HeapDumper.kt:用 Debug.dumpHprofData 把堆写到应用私有目录
(context.filesDir/leak/),在 HandlerThread 上执行,
失败要有明确错误码与 logcat 输出,不许静默;
- HprofSanitizer.kt:dump 完成后的第一件事,遍历 0x1C 段里的
PRIMITIVE_ARRAY_DUMP 子记录,把内容字节清零后重写文件,
原文件立刻 delete;对外只暴露脱敏后的文件;
- 一段原生 AlertDialog 的用途说明:debug 包第一次 dump 之前,
告诉使用者「将要抓取应用内存快照,可能短暂停顿,文件仅保存在本机、
已做脱敏」,确认后才执行;用 AlertDialog,不引第三方弹窗库;
- docs/dump-cost.md:记录 dump 期间 ART 的行为、为什么冻结消不掉、
我们做了哪些补偿、哪些没做。
【上下文】
@app/src/debug/java/com/example/health/leak/DumpTrigger.kt
@docs/leak-design.md(第 4 章是隐私边界,严格按它实现)
项目约束:hprof 里的 String 底层是基本类型数组,含就诊人姓名、
身份证号与 token;脱敏后引用链必须仍然可读。
【约束】
套 leak_rules.md。三条硬约束全部生效,特别是「任何离开设备的堆快照
必须先清零 PRIMITIVE_ARRAY_DUMP」。
不许写外部存储、不许上传到任何服务、不许进版本库。
零第三方依赖;不引 Shark / HAHA / MAT 的任何类。
【验收】
给出 adb 命令:触发一次 dump → 拉脱敏文件 → 用 strings 检索
三类敏感字段,结果必须为空;再用端外解析器读同一份文件,
确认 GC Root 与引用链仍能解出。
另外给出十轮冷态掉帧计时,报 P50 / P90,写清机型。
期望产出:HeapDumper.kt、HprofSanitizer.kt、docs/dump-cost.md、第一次 dump 的用途说明弹窗。
这轮返工两次,都是隐私边界。第一次是它把脱敏做成「dump 完写一份 -stripped.hprof」,原文件留在原目录等下一轮清理------那就等于留了一份未脱敏的全堆在设备上,安全同事那关根本过不了。我让它改成「原地重写 + 原文件立即删除 + 只有一个输出文件」。第二次是它想在脱敏前先算一遍文件 hash 用于归档,我把这条也砍了:hash 本身不泄露内容,但那段代码路径会短暂持有未脱敏文件的完整读取,没必要。
验收命令与结果:
bash
adb shell am broadcast -a com.example.health.DEBUG_DUMP # debug 专用
adb pull /data/data/com.example.health/files/leak/ /tmp/leak-check/
strings /tmp/leak-check/*.hprof | grep -E '身份证|就诊人|Bearer |token' | wc -l
# 0
python3 tools/hprof_min.py /tmp/leak-check/*.hprof --roots | head -12
# 仍能列出 ROOT_STICKY_CLASS / ROOT_JAVA_FRAME / ...
Pixel 6 十轮冷态,冻结 P50 590ms、P90 640ms,比第一版同步在主线程那次 1180ms 好,但我没把它写成「优化了一半」------冻结本身没变短,只是不再挡交互,这两个数不是同一个口径,报告里分了两行写。
第 8 轮 · 端外最小 hprof 解析器:只做能验的那部分
模式:SOLO Agent,@docs/version-facts-leak.md(第 7 节的 tag 表)、@docs/leak-design.md。
text
【角色与边界】
你在开发机 / CI 上用 Python 写一个最小 hprof 解析器,
只做「建索引 + 建边 + 找路径」,不做支配树、不做 retained size、
不做 native 归因。写进 tools/,不碰 App 代码。
【目标】
产出 tools/hprof_min.py,命令行参数:
--index 只跑 Pass1,打印记录统计与 GC Root 数量
--find KEY 找 KeyedWeakReference 实例并输出目标对象 id
--path ID BFS 输出从 GC Root 到该 id 的最短强引用路径
--strict 遇到不支持的 tag 立即抛错并列出已解析到的偏移
支持:
header 里 JAVA PROFILE 1.0.3、idSize 为 4 或 8、timestamp;
顶层 0x01 / 0x02 / 0x04 / 0x05 / 0x0C / 0x1C / 0x2C;
0x1C 内部 ROOT_* 八种 + 0xFE HEAP_DUMP_INFO;
0x20 CLASS_DUMP / 0x21 INSTANCE_DUMP / 0x22 / 0x23;
Basic Type 2=object(仅此一种写边),其余按长度跳过。
Pass1 建「id → 文件偏移 / 类名 / 字段布局 / 根集合」,
Pass2 按偏移随机 seek 解析字段字节流并建出边表。
【上下文】
@docs/version-facts-leak.md @docs/leak-design.md
注意:HEAP_DUMP_SEGMENT 的子记录没有 length 字段,
必须按子 tag 各自的结构推进游标;idSize 必须从 header 读,不许写死。
【约束】
套 leak_rules.md。只用 Python 标准库(struct / mmap / argparse / json)。
不许静默跳过未知 tag;不许猜测厂商私有 tag 的布局。
能力边界写进文件头注释和 --help,明确列出「本工具不提供什么」。
不许把 hprof 内容打印到 stdout,只允许输出 id、类名、字段名、路径。
【验收】
用两份样本自测:一份 Pixel 6 / Android 13 / idSize 8,
一份模拟器 / Android 11 / idSize 4。
给出每份的 Pass1 记录数、对象数、GC Root 数、解析耗时与峰值内存;
再给一条命令验证「改坏一个字节后 --strict 会报错而不是给出错误路径」。
期望产出:tools/hprof_min.py。
这轮踩的坑最密,也最像真实的二进制解析。它第一版用了这么一行:
python
# 错误写法
strings[rid()] = f.read(length - id_size).decode()
Python 先算等号右边,f.read() 把文件指针吃掉了,rid() 于是从下一条记录的头里取了 4 字节当 key------不会抛异常,只会静默错位,解析出来的类名全是乱码。我让它改成先读 id、再读字节,并加一条断言检查读完后游标位置。
第二处是把 0x1C 的子记录当成顶层记录去读 length,直接从第二条开始跑飞;第三处是 idSize 写死 4,在 64 位机上全错;第四处是没处理 0xFE HEAP_DUMP_INFO,Android 13 的 dump 每碰一次就抛异常。四个坑我全写进了后面的踩坑表。
验收数据:Pixel 6 那份 96MB 的脱敏堆,Pass1 约 4.1 秒、峰值内存 38MB;模拟器那份 41MB,1.7 秒、17MB。跟 Shark 的 40MB 量级对得上,但我没敢写「性能接近 Shark」------我只做了两遍扫描和 BFS,人家还有虚拟引用读取、支配树、对象检查器,不是一回事。
bash
python3 tools/hprof_min.py captures/report.hprof --index
python3 tools/hprof_min.py captures/report.hprof --find <key> --path --strict
printf '\x00' | dd of=captures/broken.hprof bs=1 seek=64 conv=notrunc
python3 tools/hprof_min.py captures/broken.hprof --index --strict # 必须非零退出
第 9 轮 · 最短路径与状态标注:让引用链能读
模式:SOLO Agent,@tools/hprof_min.py、@docs/version-facts-leak.md。
text
【角色与边界】
你在端外解析器里补最短路径与标注,不新增解析能力,不改 App 代码。
【目标】
产出:
- tools/leak_trace.py:从所有 GC Root 出发做 BFS,
找到目标 id 后回溯出路径;输出格式要求 Root 在顶、
每行一个引用、对象状态标在旁边;
- 状态标注规则:Leaking: YES / NO / UNKNOWN 三态,
至少实现这几条判据:Activity 且 mDestroyed=true → YES;
Fragment 且 mFragmentManager=null → YES;
View 且 mContext 指向已销毁 Activity → YES;
系统类 / Thread / Class 对象 → NO;其余 UNKNOWN;
- 虚拟引用扁平化:把 ArrayList.elementData、HashMap.table、
Object[] 下标这类内部结构显示成「写代码时的样子」,
数组下标统一归一成 [x],避免同一泄漏因下标不同分成多行;
- 可疑引用标记:把路径里最靠近泄漏对象的 1~3 条引用
用 ~~~ 标出,并说明选择依据。
【上下文】
@tools/hprof_min.py @docs/version-facts-leak.md
参考官方报告里那组典型输出,但代码自己写;
GC Root 类型名要与 hprof 子 tag 表一一对应。
【约束】
套 leak_rules.md。不许输出任何字段的字面值(脱敏后本来也没有),
只输出类名、字段名、状态标签。
BFS 第一次命中即返回,不许为了「找更好的路径」继续遍历。
判据不足时诚实写 UNKNOWN,不许猜 YES。
【验收】
用第 6 轮那 8 类构造泄漏样本逐份跑,
给出每份的路径长度、命中的 GC Root 类型、标 YES 的节点数;
再构造一份「同一泄漏出现 4 次」的堆,验证下标归一后只输出 1 行。
期望产出:tools/leak_trace.py。
返工一处,是下标归一。它第一版把 Object[].[3] 原样拼进签名,同一处泄漏四次出现就刷了四行------正是第 9 轮开头我提到的那个问题,官方文档里写得很清楚,它还是漏了。我把官方给的那段 sha1Hash(... + "java.lang.Object[].[x]") 例子贴进【上下文】,重出才对。另一处小返工是状态标注太激进:它给一个 ArrayList 也标了 YES,理由是「在泄漏路径上」。我让它把判据收紧成「只有能明确判断生命周期已结束的对象才标 YES,其余一律 UNKNOWN」,宁可少标也不误导。
第 10 轮 · 报告、分组签名与脱敏复检
模式:SOLO Agent,@tools/leak_trace.py、@app/src/debug/java/com/example/health/leak/、@docs/leak-design.md。
text
【角色与边界】
你把前面所有产出串成一份人能看的报告,并补上脱敏回归测试。
不新增检测能力,不改判定语义。
【目标】
产出:
- tools/leak_report.py:读 trace 输出,按 signature 分组,
生成 leak-reports/<日期>-<构建号>.md 与一份 JSON 摘要;
signature = sha1(路径里所有可疑引用的拼接),数组下标归一;
- 报告里必须包含:构建信息、机型与系统版本、dump 时刻、
每个泄漏的最短路径、状态标注、可疑引用、分组计数、
以及「本次分析不在支持范围内」的部分(若有);
- 报告分类:Application Leak(我们代码里的问题)与
Library Leak(系统 / 三方已知问题),后者从
assets/known-leaks.txt 白名单匹配,不进阻塞项;
- tests/test_sanitizer.py:脱敏回归,构造含姓名、身份证、token
的堆样本,断言 sanitize 之后 strings 检索为空、
且引用链仍可解出;这份用例每版都跑。
- 报告开头写一句「本次取证在脱敏后进行」,
并列出脱敏步骤作用的 hprof 子记录类型。
【上下文】
@tools/leak_trace.py @docs/leak-design.md
@app/src/debug/java/com/example/health/leak/HprofSanitizer.kt
已知白名单:Android 12 的 UiModeManager、AOSP
PermissionControllerManager 那一类,逐条写清来源与影响版本。
【约束】
套 leak_rules.md。报告里不许出现字符串字面值、图片像素、用户标识;
不许把 hprof、mapping、报告目录写进版本库;
不许声称「零误报」,白名单匹配要显式列出命中了哪一条。
【验收】
给一条命令从一份真实 dump 生成报告,再给一条命令跑脱敏回归;
报告里的字段数、分组数、白名单命中数都要能被脚本复算。
期望产出:tools/leak_report.py、tests/test_sanitizer.py、assets/known-leaks.txt。
这轮我加了个前面没想到的要求:报告开头那句「本次取证在脱敏后进行」。起因是安全同事复查时问「我怎么知道这份报告对应的是脱敏后的文件」,一句话的事,但没这句话她就得去翻脚本。Trae 第一版把脱敏信息塞在报告末尾的附录里,我让她挪到开头第一行。
脱敏回归用例后来真抓到过一次回归:某次有人为了排查问题临时加了个「保留字符串前 8 位便于识别」的开关,测试当场挂掉。这条我写进了 docs/CHANGELOG-leak.md,备注是「不要为了调试方便开后门」。
bash
python3 tools/leak_report.py captures/report.hprof --build 8.2.2-312 --out leak-reports/
python3 -m unittest tests.test_sanitizer -v
grep -cE '^\| ~~~|Leaking: YES' leak-reports/*.md
第 11 轮 · 真机回归与误报治理:两台机、八类样本
模式:SOLO Agent,@docs/metrics-leak.md、@docs/false-positive.md、@app/src/debug/、@app/src/main/。
text
【角色与边界】
你负责回归脚本与样本工程,不改判定语义、不放宽阈值、不改口径。
目标是拿数据说话,不是把数字做好看。
【目标】
产出:
- app/src/debug/java/.../LeakSamples.kt:8 类可手动触发的构造泄漏
(静态 Activity 引用、未注销 Listener、Handler 延迟消息、
协程 Job 持有、单例里的 View、匿名内部类 this$0、
ThreadLocal 未清理、注册后未反注册的广播接收者),
每类一个 debug 入口,标注预期路径形状;
- tools/run_leak_suite.sh:脚本化造态(force-stop → 冷启 →
按固定序列进出 → 触发 dump → 拉文件 → 生成报告),
两台机各跑十轮,输出 metrics-leak.json;
- docs/regression-leak.md:四个度量数的前后对比,
每台机分组列出,写清样本量、系统版本、是否冷态;
超标的项明确标注,不许四舍五入抹掉;
- release 侧复核:解包 release APK,确认 dex 里没有
KeyedWeakReference / ObjectWatcher / HeapDumper / HprofSanitizer,
并确认 OOM 上报元数据里能读到 retainedObjectCount。
【上下文】
@docs/metrics-leak.md(四个数的定义,一个字不许改)
@docs/false-positive.md @app/src/debug/ @app/src/main/
机型:Pixel 6 / Android 13,Redmi Note 11 / Android 11。
第一版手写实现基线:误报率 62% / 65%,召回 5/8。
【约束】
套 leak_rules.md。不许为了达标修改判据或延长等待;
不许把 Library Leak 算进误报率的分子;
release 复核必须给出可复现的解包与检索命令,不许只写结论。
【验收】
四个数逐项列出「目标 / 实测 / 是否达标 / 未达标原因」;
release 复核给出 dex 字符串检索输出片段。
期望产出:LeakSamples.kt、tools/run_leak_suite.sh、docs/regression-leak.md、metrics-leak.json。
这轮有个数字我没让它美化:Redmi Note 11 的误报率 11%,超目标 1 个百分点。Trae 提议把那台机的三个假阳性样本归到「系统回收激进」并从分子里剔掉,我否了------口径一改,前面所有轮次的对比全废。报告里就写 11%,注明未达标与原因。
release 复核那段命令是安全同事最关心的:
bash
unzip -p app/build/outputs/apk/release/app-release.apk classes.dex > /tmp/r.dex
strings /tmp/r.dex | grep -E 'KeyedWeakReference|HeapDumper|HprofSanitizer|dumpHprofData' | wc -l
# 0
strings /tmp/r.dex | grep -E 'retainedObjectCount|ReleaseRetainedCounter' | head -3
# 只保留计数读取口
第 12 轮 · 把十二轮固化成 Skill,再钉进 CI
模式:SOLO Agent,@docs/、@tools/、.trae/rules/leak_rules.md、@app/src/debug/java/com/example/health/leak/。
text
【角色与边界】
这一轮不新增功能。你要把前 11 轮跑通的整套流程,
固化成一个可被 Trae 反复调用的开发 Skill,
并把它接进 CI,最后换一个身份对整套东西做对抗性复核。
【目标】
产出 .trae/skills/leak-detector/ 完整技能包:
SKILL.md(带 name / description frontmatter,description 里写清触发词)、
references/ 三份速查、templates/ 三份模板、
scripts/ 三份脚本、checklists/ 两份清单;
产出 .github/workflows/leak-gate.yml:
每次 PR 跑单测、脱敏回归、release 洁净度检索、四个数门槛;
产出 docs/adversarial-review.md:换成三个身份各挑一次刺------
A 合规复查同事:追问个人信息有没有任何一条路径能出设备;
B 另一个 Android 开发:追问误报、多进程、混淆后的可读性;
C 没用过这套工具的人:只看报告能不能定位到该改哪一行。
每个身份至少列 5 个问题、给出证据文件路径与结论;
对答不上来的问题标 OPEN,不许含糊过去。
【上下文】
@docs/(leak-design / version-facts-leak / metrics-leak /
false-positive / regression-leak / CHANGELOG-leak)
@tools/ @.trae/rules/leak_rules.md
@app/src/debug/java/com/example/health/leak/
轮次编号 1~12 要与 docs/CHANGELOG-leak.md 一致,不许重排。
【约束】
套 leak_rules.md。SKILL.md 里不许塞完整实现代码,
只放触发条件、工作流阶段、输入输出契约、验收门槛与资料指针;
description 必须含可命中的触发词,不许写成「内存优化相关」这种泛泛一句。
CI 里不许上传任何 hprof 或报告产物;不许写让人自己补内容这类话。
对抗性复核不许由你自己既提问又判定通过,
每个结论都要指向一个具体文件与一条可复跑的命令。
【验收】
给出「在一个空项目里只装这个 Skill、投一句触发词」的复现步骤;
给出 CI 四个门槛的实际输出片段;
列出 adversarial-review 里仍然 OPEN 的条目,一条都不许藏。
期望产出:.trae/skills/leak-detector/ 完整目录、.github/workflows/leak-gate.yml、docs/adversarial-review.md。
这轮返工三次,全在 Skill 本身。CI 那边只有一处:它第一版把 leak-reports/ 作为 artifact 上传,方便「在网页上看报告」------直接踩了第三条约束,我让它改成只在 CI 日志里打印摘要 JSON,产物一律留在 runner 本地并随任务结束销毁。
对抗性复核最后留了 3 个 OPEN:多进程下 watcher 归属、混淆 debug 包的 keep 规则、以及低内存机型那 1 个百分点的误报。三条我都写进了「还能往下挖的地方」,没在文章里假装解决。
bash
python3 .trae/skills/leak-detector/scripts/verify_skill.py
# SKILL.md frontmatter OK / 触发词 OK / 引用文件齐全 12/12 / 脚本可执行 3/3
./gradlew :app:testDebugUnitTest && python3 -m unittest tests.test_sanitizer
bash .trae/skills/leak-detector/scripts/check_release.sh app-release.apk
把十二轮固化成一个 Skill
十二轮跑完,东西是有了,问题也跟着来了。两周后隔壁组同事问能不能借这套流程去他们项目里用,我把聊天记录翻出来,发现根本没法借:第 3 轮的边界结论散在一段追问里,第 6 轮「不许放宽判据」的返工只有我当时知道,第 7 轮那条「原文件立即删除」是因为安全同事问过一句话才加的。换个项目、换个对话窗口,这些约束全得重讲一遍,而且大概率会被漏掉。
Rules 管不住这件事。.trae/rules/leak_rules.md 能约束「写出来的代码长什么样」,管不了「按什么顺序做、每一阶段交什么、做到什么程度算过」。我需要的其实是一层更高的东西:一个能被一句话触发、自带资料索引、自带工作流阶段、自带验收脚本的能力包。Trae 里对应的就是 Skill。
目录我按「渐进披露」排的------SKILL.md 只放路由和契约,细节全放进按需加载的 references/,模板和脚本单独放,别一上来把上下文撑满:
text
.trae/skills/leak-detector/
├── SKILL.md # 唯一入口,约 120 行
├── references/
│ ├── principles.md # 弱可达 / 四步流程 / 判定顺序
│ ├── hprof-format.md # record tag 表、GC Root 类型、Basic Type
│ ├── version-timeline.md # 1.x → 3.0 alpha 的行为差异与常见错误说法
│ └── privacy-redline.md # 医疗数据脱敏红线与三条硬约束
├── templates/
│ ├── prompt-five-part.md # 角色与边界 / 目标 / 上下文 / 约束 / 验收
│ ├── watcher.kt.tpl # KeyedWeakReference + ObjectWatcher 骨架
│ └── leak-report.md.tpl # 报告模板,首行固定写脱敏声明
├── scripts/
│ ├── verify_skill.py # 自检:frontmatter / 触发词 / 引用齐全 / 脚本可执行
│ ├── hprof_min.py # 端外最小解析器(第 8 轮产出的副本)
│ └── check_release.sh # 解包 release APK,检索检测类
└── checklists/
├── acceptance.md # 12 项验收,绑定四个度量数
└── adversarial.md # 三身份对抗性复核提问清单
SKILL.md 全文如下,frontmatter 是能被正确触发的关键:
markdown
---
name: leak-detector
description: >-
用平台 API 自建 Android 内存泄漏检测层的完整工作流。当用户说
「自己实现 LeakCanary」「自己撸一套泄漏检测」「去掉第三方内存检测依赖」
「分析 hprof / 泄漏引用链」「泄漏检测怎么接进项目与 CI」「把泄漏排查
流程做成技能包」时使用。产出零第三方依赖的 ObjectWatcher、生命周期
挂载、堆快照脱敏、端外最短引用链解析、分组报告与 CI 门禁;
内置隐私脱敏红线、误报治理与对抗性复核。不适用于 native 内存分析、
性能卡顿定位、以及直接接入现成 LeakCanary 依赖的场景。
---
# 自建泄漏检测层
## 何时用 / 何时别用
用:需要一套可审计、零第三方依赖、能进 CI 的泄漏检测;
需要读懂或自建 hprof 引用链;需要把别人的封装拆到自己工程里。
别用:只想加一行 debugImplementation 就完事;
查 native 内存(那是 heapprofd 的活,跟 hprof 不是一回事);
查帧率 / 启动耗时。
## 输入契约
开工前要求用户提供并 @ 下列文件,缺哪个就在第 1 轮补哪个:
- 项目源码目录(至少一个模块)、build.gradle(.kts)、AndroidManifest
- 生命周期对象清单(没有则由 S1 生成)
- 版本事实表(没有则由 S2 联网生成)
- 度量定义(没有则由本技能按默认四数生成,写进 docs/metrics-leak.md)
用户没给隐私要求时,按最严的默认走:堆快照不许离开设备。
## 工作流(阶段号 = 提示词轮次,不许重排)
S1 现状体检 → docs/leak-inventory.md,只读,不下结论
S2 联网核准 → docs/version-facts-leak.md,每条事实带一手来源 URL
S3 原理资产映射 → docs/leak-design.md,写清「明确不做」的能力
S4 最小 Watcher → KeyedWeakReference / ObjectWatcher + 4 个 JVM 单测
S5 生命周期挂载 → LeakWatchers / 显式 install,拒绝自动安装
S6 确认与阈值 → GcConfirmer / DumpTrigger / false-positive.md
S7 dump 与脱敏 → HeapDumper / HprofSanitizer / 用途说明 AlertDialog
S8 端外解析器 → tools/hprof_min.py,只做 Pass1 + Pass2 + BFS
S9 最短路径标注 → tools/leak_trace.py,三态标注 + 下标归一
S10 报告与回归 → 分组签名、白名单、脱敏回归测试
S11 真机回归 → 两台机各十轮,四个数逐项列「目标/实测/是否达标」
S12 固化与门禁 → 本技能自检、CI 四门槛、三身份对抗性复核
每个阶段必须产出可验收的文件;没有产出就停在这一阶段,不许往下跳。
阶段含义与返工记录见 docs/CHANGELOG-leak.md。
## 三条不可协商的约束
C1 检测层只用平台 API 与 AndroidX,落地代码里不许出现第三方检测库。
C2 任何离开设备的堆快照必须先清零 PRIMITIVE_ARRAY_DUMP,
原文件立即删除;报告里不许出现字符串字面值与用户标识。
C3 release 编译单元里不许出现 watcher / dumper / 分析代码,
只保留一个 retained 计数的读取口。
违反任一条,无论代码是否可运行,本阶段判为不通过。
## 输出契约
docs/ 下 7 份文档、app/src/debug/.../leak/ 下 6 个 Kotlin 文件、
tools/ 下 3 个脚本、tests/ 下 1 份脱敏回归、CI yaml 1 份、
metrics-leak.json 1 份。完整清单见 checklists/acceptance.md。
## 验收门槛
误报率 ≤ 10%(40 构造样本 + 20 轮正常进出,逐台机列)
真阳性召回 8/8
dump 冻结 P90 ≤ 700ms(十轮冷态,写清机型与计时口径)
release 检测类 0 个 / OOM 上报带 retained 数 ≥ 95%
未达标项必须原样列出,不许调参掩盖、不许改口径。
跑:python3 scripts/verify_skill.py && bash scripts/check_release.sh <apk>
## 失败回退
端外解析器遇到不支持的 tag:--strict 抛错并列出偏移,
报告标「本次分析不在支持范围内」,不许猜布局、不许静默跳过。
真机跑不出冷态:明确写出设备与系统版本,把该轮标 INVALID 重跑。
任何阶段拿不准的事实:写 UNCONFIRMED,保留冲突的两个来源。
## 怎么迭代这个技能
每次出现新的假阳性类型,往 references/principles.md 与
checklists/adversarial.md 各补一条,并在 CHANGELOG 记轮次;
每次平台版本行为变化,更新 version-timeline.md 并重跑 S2;
description 里的触发词只增不删,改完必须跑 verify_skill.py。
这个 Skill 我返工了三次,三次都不是代码问题。
第一次是 description 写成了「Android 内存优化相关的开发规范」。听着挺正式,结果投「帮我自己撸一套 LeakCanary」根本不触发------Skill 是靠 description 里的词被匹配到的,写抽象了就等于没写。改成现在这样,把用户真会说的几种说法全塞进去,还补了「不适用于」那半句,避免查卡顿时也被拉进来。
第二次是我把 ObjectWatcher.kt 和 hprof_min.py 的全文塞进了 SKILL.md,想着「一个文件拿走就能用」。结果每次触发都要把两三百行实现一起读进上下文,还没开始干活额度就吃掉一截,而且代码改一版技能里那份就旧一版。改成只在 SKILL.md 里放路径指针,实现留在工程里、模板留在 templates/。判断标准很简单:会被改的不要放进 SKILL.md,只放不会轻易改的契约。
第三次是没有验收脚本。前两版 Skill 跑完,Trae 会自己说「已完成全部阶段」,我翻文件发现 S7 的脱敏回归根本没跑、S11 的两个指标是它照着目标值编的。补了 verify_skill.py 之后,它检查的是文件系统里的实际产物------frontmatter 字段、触发词数量、references/ 引用是否都存在、三个脚本是否可执行、四个数是否来自 metrics-leak.json 而不是文本里的一句话。AI 自称通过不算数,脚本说了才算。
python
# .trae/skills/leak-detector/scripts/verify_skill.py(节选)
import pathlib, re, sys, json
root = pathlib.Path(__file__).resolve().parents[1]
text = (root / "SKILL.md").read_text(encoding="utf-8")
problems = []
fm = re.match(r"^---\n(.*?)\n---\n", text, re.S)
if not fm:
problems.append("SKILL.md 缺 frontmatter")
else:
block = fm.group(1)
if "name: leak-detector" not in block:
problems.append("name 不匹配")
triggers = ["自己实现 LeakCanary", "自己撸一套", "hprof", "CI"]
missing = [t for t in triggers if t not in block]
if missing:
problems.append(f"description 缺触发词: {missing}")
if len(text.splitlines()) > 200:
problems.append("SKILL.md 超过 200 行,实现代码该搬到 templates/")
for rel in [
"references/principles.md", "references/hprof-format.md",
"references/version-timeline.md", "references/privacy-redline.md",
"templates/prompt-five-part.md", "templates/watcher.kt.tpl",
"templates/leak-report.md.tpl", "checklists/acceptance.md",
"checklists/adversarial.md",
]:
if not (root / rel).is_file():
problems.append(f"缺引用文件: {rel}")
metrics = pathlib.Path("metrics-leak.json")
if not metrics.is_file():
problems.append("缺 metrics-leak.json,四个数不许只写在文档里")
else:
data = json.loads(metrics.read_text(encoding="utf-8"))
for key in ["false_positive_rate", "recall", "dump_freeze_p90_ms",
"release_detector_classes", "oom_with_retained_ratio"]:
if key not in data:
problems.append(f"度量缺项: {key}")
print("SKILL.md frontmatter OK / 触发词 OK / 引用文件齐全"
if not problems else "\n".join(problems))
sys.exit(1 if problems else 0)
装法没什么玄的:把整个目录放进项目的 .trae/skills/ 下,新开一个对话,投一句「用 leak-detector 帮我在这个项目里自己撸一套泄漏检测」,它会先按 S1 出体检清单,而不是上来就写代码。想只跑某一段,就说「按 S6 做误报治理」,阶段号对得上 docs/CHANGELOG-leak.md,返工时能直接定位到是哪一轮的约束没hold住。
我后来把这个目录打包给隔壁组,他们项目里没有医疗数据,privacy-redline.md 那条最严的默认就显得过重,于是按「怎么迭代这个技能」那节改了 references/、SKILL.md 一行没动。这大概就是把它做成 Skill 而不是写成一篇文档的区别:约束固化在能被脚本检查的地方,业务差异留在可替换的资料层。
最后落地的文件长什么样
前面各轮贴过 KeyedWeakReference.kt、ObjectWatcher.kt、GcConfirmer.kt、MyApplication.kt、verify_skill.py 和 Gradle / Manifest 片段,这里不重复。下面是完整文件树和其余几块的定稿内容,每块标了路径与用途。全部用平台 API、AndroidX 或标准库,零第三方依赖;版本是 AGP 8.2.2、compileSdk 34、targetSdk 34、minSdk 24,我在自己机器上跑过构建和单测,换个版本未必一模一样。
text
app/
├── src/main/java/com/example/health/
│ ├── MyApplication.kt # debug 分支显式 install
│ └── leak/ReleaseRetainedCounter.kt # release 里唯一的薄计数读取口
├── src/debug/java/com/example/health/leak/
│ ├── KeyedWeakReference.kt # 带 UUID key 的弱引用
│ ├── ObjectWatcher.kt # watch / 清队列 / 确认
│ ├── GcConfirmer.kt # 强制 GC + 入队等待
│ ├── DumpTrigger.kt # 阈值 / 限流 / 可见性
│ ├── LeakWatchers.kt # 生命周期挂载
│ ├── HeapDumper.kt # dump 到私有目录
│ ├── HprofSanitizer.kt # 基本类型数组清零
│ ├── LeakDetector.kt # 显式安装入口
│ ├── LeakReportActivity.kt # debug 报告页
│ └── LeakSamples.kt # 8 类构造泄漏样本
├── src/debug/assets/known-leaks.txt # 已知系统 / 三方泄漏白名单
└── src/debug/AndroidManifest.xml # 报告页声明,exported=false
tools/
├── hprof_min.py # Pass1 索引 + Pass2 建边
├── leak_trace.py # BFS 最短路径 + 三态标注
├── leak_report.py # 分组签名 + 报告生成
└── run_leak_suite.sh # 两台机各十轮回归
tests/test_sanitizer.py # 脱敏回归,每版必跑
docs/ leak-design.md / version-facts-leak.md / leak-inventory.md /
metrics-leak.md / false-positive.md / dump-cost.md /
regression-leak.md / adversarial-review.md / CHANGELOG-leak.md
.trae/rules/leak_rules.md
.trae/skills/leak-detector/ # 上一章那个 Skill 包
.github/workflows/leak-gate.yml
metrics-leak.json
生命周期挂载这块,去重是关键:
kotlin
// app/src/debug/java/com/example/health/leak/LeakWatchers.kt
internal object LeakWatchers {
private val recent = HashMap<Int, Long>() // identityHashCode → 时刻
private const val DEDUPE_MS = 1_000L
private fun offer(watcher: ObjectWatcher, obj: Any, desc: String) {
val id = System.identityHashCode(obj)
val now = SystemClock.uptimeMillis()
synchronized(recent) {
if (now - (recent[id] ?: -1L) < DEDUPE_MS) return
recent[id] = now
if (recent.size > 256) {
recent.entries.removeIf { now - it.value > DEDUPE_MS * 10 }
}
}
watcher.watch(obj, desc) // 只传对象,不 toString、不进日志
}
fun install(app: Application, watcher: ObjectWatcher) {
app.registerActivityLifecycleCallbacks(
object : Application.ActivityLifecycleCallbacks {
override fun onActivityDestroyed(a: Activity) {
offer(watcher, a, "${a.javaClass.name} received Activity#onDestroy()")
}
override fun onActivityCreated(a: Activity, s: Bundle?) = Unit
override fun onActivityStarted(a: Activity) = Unit
override fun onActivityResumed(a: Activity) = Unit
override fun onActivityPaused(a: Activity) = Unit
override fun onActivityStopped(a: Activity) = Unit
override fun onActivitySaveInstanceState(a: Activity, s: Bundle) = Unit
}
)
// Fragment / ViewModel 用 AndroidX 的 FragmentLifecycleCallbacks 与
// ViewModel 的 onCleared 挂钩,同样走 offer();
// root view 用 View.OnAttachStateChangeListener 盯 detach。
// 同一对象被两条回调命中时,靠 recent 表里那一秒窗口收敛成一个 retained。
}
}
DumpTrigger 里我把所有可调的数集中在一个数据类,真机回归时能直接 dump 出来对账:
kotlin
// app/src/debug/java/com/example/health/leak/DumpTrigger.kt
data class LeakConfig(
val watchDelayMs: Long = 5_000L,
val visibleThreshold: Int = 5,
val hiddenThreshold: Int = 1,
val dumpCooldownMs: Long = 60_000L,
val maxDumpPerSession: Int = 3
)
internal class DumpTrigger(
private val config: LeakConfig,
private val onDump: () -> Unit
) {
@Volatile private var visible = true
@Volatile private var lastDumpMs = 0L
@Volatile private var dumpCount = 0
private val lock = Any()
fun onVisibilityChanged(isVisible: Boolean) {
visible = isVisible
if (!isVisible) check(force = false)
}
fun onRetained(count: Int) = check(force = false)
fun forceDump() = check(force = true)
private fun check(force: Boolean) {
synchronized(lock) {
val threshold = if (visible) config.visibleThreshold
else config.hiddenThreshold
val now = SystemClock.uptimeMillis()
if (!force && count() < threshold) return
if (!force && now - lastDumpMs < config.dumpCooldownMs) return
if (dumpCount >= config.maxDumpPerSession) return
lastDumpMs = now
dumpCount++
}
onDump()
}
private fun count(): Int = LeakDetector.retainedCount()
}
HeapDumper 与 HprofSanitizer 是隐私边界所在,两段必须连着读:
kotlin
// app/src/debug/java/com/example/health/leak/HeapDumper.kt
internal class HeapDumper(private val app: Application) {
fun dumpAsync(onDone: (Result<File>) -> Unit) {
val thread = HandlerThread("leak-dump").apply { start() }
Handler(thread.looper).post {
val dir = File(app.filesDir, "leak").apply { mkdirs() }
val raw = File(dir, "raw.hprof")
val result = runCatching {
Debug.dumpHprofData(raw.absolutePath) // ART suspend all
val safe = HprofSanitizer.sanitizeInPlace(raw)
safe
}
if (!result.isSuccess) {
raw.delete() // 失败也不留原始堆
}
thread.looper.quit()
onDone(result)
}
}
}
kotlin
// app/src/debug/java/com/example/health/leak/HprofSanitizer.kt
// 原地重写 HEAP_DUMP_SEGMENT 里的 0x23 PRIMITIVE_ARRAY_DUMP 内容字节。
// 只改数组元素区,不动长度与类型字段,否则后面的子记录会全部错位。
internal object HprofSanitizer {
fun sanitizeInPlace(raw: File): File {
val bytes = raw.readBytes()
val idSize = readIdSize(bytes) // 从 header 读 4 或 8
var pos = headerLength(bytes)
while (pos < bytes.size) {
val tag = bytes[pos].toInt() and 0xFF
val len = readU4(bytes, pos + 5)
val body = pos + 9
if (tag == 0x0C || tag == 0x1C) {
scrubSegment(bytes, body, body + len, idSize)
}
pos = body + len
}
val out = File(raw.parent, "dump-stripped.hprof")
out.writeBytes(bytes)
raw.delete() // 原文件立即删除
return out
}
private fun scrubSegment(b: ByteArray, from: Int, to: Int, idSize: Int) {
var p = from
while (p < to) {
val sub = b[p].toInt() and 0xFF
p++
when (sub) {
0x23 -> { // PRIMITIVE_ARRAY_DUMP
p += idSize + 4 + 4 // array id + stack serial + count
val count = readU4(b, p - 4)
val elemType = b[p].toInt() and 0xFF
p += 1
val width = widthOf(elemType)
val end = p + count * width
for (i in p until end) b[i] = 0 // 只清元素,不动结构
p = end
}
0x21 -> p += skipInstance(b, p, idSize)
0x20 -> p += skipClassDump(b, p, idSize)
0x22 -> p += idSize + 4 + 4 + idSize + readU4(b, p + idSize + 4) * idSize
0xFE -> p += 4 + idSize
in GC_ROOT_TAGS -> p += rootWidth(sub, idSize)
0x2C -> return
else -> throw IllegalArgumentException(
"unsupported hprof sub-tag 0x${sub.toString(16)} at $p"
)
}
}
}
}
那个 else -> throw 是刻意的。第 8 轮定下的能力边界就在这儿:遇到不认识的子 tag 宁可整份 dump 作废,也不许猜布局继续走------猜错一次,后面所有偏移全歪,出来的引用链看着完整其实是错的,比报错危险得多。
release 侧只留这一小块,安全同事审依赖时翻到这个文件的第 12 行就能结束:
kotlin
// app/src/main/java/com/example/health/leak/ReleaseRetainedCounter.kt
// 这一版由 debug 侧的计数镜像写入;release 不 dump、不落文件、不联网。
object ReleaseRetainedCounter {
@Volatile private var count = 0
fun updateFromDebugMirror(value: Int) { count = value }
/** 只在 OOM 上报组装元数据时读取,供归因分类用 */
fun snapshot(): Int = count
}
kotlin
// 挂进既有的崩溃上报组装处(示意)
val meta = bundleOf(
"retained_object_count" to ReleaseRetainedCounter.snapshot(),
"oom_heap_state" to "unknown" // 不编造没测过的堆数据
)
端外解析器的骨架,重点是两次 seek 与不许写死 idSize:
python
#!/usr/bin/env python3
# tools/hprof_min.py
# 能力边界:只支持 JAVA PROFILE 1.0.3 / idSize 4 或 8 / 0x1C 分段;
# 只做 Pass1 索引 + Pass2 建边 + BFS 最短路径。
# 不提供:dominator tree、retained size、native 内存归因、厂商私有 tag。
import argparse, json, mmap, struct, sys
from collections import deque
BASIC_WIDTH = {2: None, 4: 1, 5: 2, 6: 4, 7: 8,
8: 1, 9: 2, 10: 4, 11: 8} # 2=object,宽度=idSize
def u1(b, p): return b[p]
def u2(b, p): return struct.unpack_from(">H", b, p)[0]
def u4(b, p): return struct.unpack_from(">I", b, p)[0]
def uid(b, p, n): return int.from_bytes(b[p:p+n], "big")
def parse_header(b):
end = b.index(b"\x00")
version = b[:end].decode("ascii", "replace")
id_size = u4(b, end + 1)
if version != "JAVA PROFILE 1.0.3" or id_size not in (4, 8):
raise ValueError(f"unsupported hprof: {version} / id={id_size}")
return version, id_size, end + 1 + 4 + 8
class Index:
def __init__(self, b, id_size, start):
self.b, self.n, self.p = b, id_size, start
self.strings, self.classes, self.roots = {}, {}, []
self.offsets, self.layouts = {}, {}
self.stats = {}
def pass1(self):
while self.p < len(self.b):
tag = u1(self.b, self.p)
length = u4(self.b, self.p + 5)
body = self.p + 9
self.stats[tag] = self.stats.get(tag, 0) + 1
if tag == 0x01: # STRING_IN_UTF8
# 先取 id,再读字节:顺序反了会静默错位
sid = uid(self.b, body, self.n)
self.strings[sid] = self.b[body + self.n: body + length]
elif tag == 0x02: # LOAD_CLASS
cid = uid(self.b, body + 4, self.n)
name_id = uid(self.b, body + 4 + self.n + 4, self.n)
self.classes[cid] = name_id
elif tag in (0x0C, 0x1C):
self.scan_segment(body, body + length, collect=True)
elif tag == 0x2C:
pass
self.p = body + length
return self
def scan_segment(self, start, end, collect=False):
# 子记录没有 length 字段,按 tag 各自的结构推进游标
p = start
while p < end:
sub = u1(self.b, p); p += 1
if sub in ROOT_TAGS:
oid = uid(self.b, p, self.n)
if collect: self.roots.append((ROOT_TAGS[sub], oid))
p += self.n + ROOT_TAGS[sub][1]
elif sub == 0x20: # CLASS_DUMP
cid = uid(self.b, p, self.n)
if collect:
self.layouts[cid], used = read_class_layout(self.b, p, self.n)
self.offsets[cid] = p
else:
used = class_layout_size(self.b, p, self.n)
p += used
elif sub == 0x21: # INSTANCE_DUMP
oid = uid(self.b, p, self.n)
cid = uid(self.b, p + self.n + 4, self.n)
size = u4(self.b, p + self.n + 4 + self.n)
if collect: self.offsets[oid] = (p, cid, size)
p += self.n + 4 + self.n + 4 + size
elif sub == 0x22: # OBJECT_ARRAY_DUMP
cnt = u4(self.b, p + self.n + 4)
p += self.n + 4 + 4 + self.n + cnt * self.n
elif sub == 0x23: # PRIMITIVE_ARRAY_DUMP
cnt = u4(self.b, p + self.n + 4)
w = BASIC_WIDTH[u1(self.b, p + self.n + 4 + 4)]
p += self.n + 4 + 4 + 1 + cnt * w
elif sub == 0xFE: # HEAP_DUMP_INFO
p += 4 + self.n
elif sub == 0x2C:
return
else:
raise ValueError(f"unsupported sub-tag 0x{sub:02x} at {p}")
def shortest_path(index, target):
# 从所有 GC Root 做 BFS,第一次命中即最短强引用路径
parent, seen, q = {}, set(), deque()
for kind, rid in index.roots:
q.append(rid); seen.add(rid); parent[rid] = ("ROOT", kind, None)
while q:
cur = q.popleft()
if cur == target:
path = []
while cur in parent:
node, kind, field = parent[cur]
path.append((cur, kind, field))
cur = node if node != "ROOT" else None
if cur is None: break
return list(reversed(path))
for nxt, field in edges_of(index, cur):
if nxt not in seen:
seen.add(nxt); parent[nxt] = (cur, "REF", field); q.append(nxt)
return None
if __name__ == "__main__":
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("hprof")
ap.add_argument("--index", action="store_true")
ap.add_argument("--find")
ap.add_argument("--path")
ap.add_argument("--strict", action="store_true")
a = ap.parse_args()
with open(a.hprof, "rb") as f, mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as b:
_, n, start = parse_header(b)
idx = Index(b, n, start).pass1()
if a.index:
print(json.dumps({
"records": idx.stats, "objects": len(idx.offsets),
"gc_roots": len(idx.roots), "classes": len(idx.classes)
}, indent=2))
ROOT_TAGS、edges_of()、read_class_layout() 这几个函数按第 8 轮那张 tag 表展开,篇幅关系不铺开;文件里都有,注释标了对应哪条 record。要点只有两个:Pass2 只把 Basic Type = 2 的字段写成边,以及 --strict 下任何未知 tag 都非零退出。
白名单和基线长这样,都是能被脚本复算的纯数据:
text
# app/src/debug/assets/known-leaks.txt
# 格式:匹配到的类名或字段名 | 来源 | 影响版本 | 分类
android.app.UiModeManager | AOSP 已知 | API 31~33 | Library Leak
android.permission.PermissionControllerManager | AOSP 2559 | API 34 | Library Leak
com.android.internal.*.mContext | 厂商 ROM | 见备注 | Library Leak
json
{
"build": "8.2.2-312",
"devices": ["Pixel 6 / Android 13", "Redmi Note 11 / Android 11"],
"samples": 10,
"cold_start_force_stop": true,
"metrics": {
"false_positive_rate": {"pixel6": 0.09, "redmi": 0.11, "target": 0.10},
"recall": {"hit": 8, "total": 8, "target": 8},
"dump_freeze_p90_ms": {"pixel6": 640, "target": 700},
"release_detector_classes": 0,
"oom_with_retained_ratio": 0.96
},
"sanitized_before_export": true,
"mapping_archived_with_dump": "captures/20261021/mapping.txt",
"open_items": ["multi-process ownership", "debug minify keep rule", "redmi 1pt over target"]
}
CI 里不传产物,只打印摘要:
yaml
# .github/workflows/leak-gate.yml
name: leak-gate
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: watcher 单测
run: ./gradlew :app:testDebugUnitTest --tests '*ObjectWatcherTest*'
- name: 脱敏回归
run: python3 -m unittest tests.test_sanitizer -v
- name: Skill 自检
run: python3 .trae/skills/leak-detector/scripts/verify_skill.py
- name: 四个数门槛
run: python3 tools/check_metrics.py metrics-leak.json
- name: release 洁净度
run: bash .trae/skills/leak-detector/scripts/check_release.sh app-release.apk
# 不上传 hprof / mapping / leak-reports,产物随 runner 销毁
check_metrics.py 里我留了一条硬规则:误报率超目标就直接失败,但把未达标原因原样打出来,不许脚本自己四舍五入。Redmi 那 0.11 现在是作为警告输出的,标注「已知未达标,见 docs/regression-leak.md 第 3 节」,没让它变成绿色。
踩过的坑,按报错排
这一趟真正花时间的不是写代码,是这些现象第一次出现时我把它认成了别的东西。按当时看到的报错或现象排,一条条对下来比讲一遍流程有用。
| 现象 | 根因 | 修复 |
|---|---|---|
| 刚按返回键的正常页面被标泄漏,误报 60%+ | 判定只清了一次队列,漏了 GC 之后的第二次 poll | 固定顺序:延迟 → 清队列 → GC → 再清队列 → 判定 |
| 弱引用明明没清空,对象却再也没被用到 | 混淆了「弱可达」与「已回收」,入队发生在回收之前 | 观察 ReferenceQueue 是否入队,不看 referent 是否为 null |
| 强制 GC 后队列仍是空的 | Runtime.gc() 不保证立刻 enqueue,由 ReferenceHandler 异步入队 |
改成最多轮询 5 次、每次 20ms、命中即退出 |
finalize() 判回收时结果忽有忽无 |
时机不定、可能对象复活、还拉长 GC 暂停 | 彻底不用 finalization,回到弱可达 |
| 堆里多出一个指向 Activity 的弱引用,MAT 里看着乱 | 第一版对目标对象直接持弱引用 | 只持 KeyedWeakReference,日志里只打 key 与 description |
Log.d("$watchedObject") 之后偶发异常 |
字符串插值调 toString(),某些实现读字段甚至抛错 |
禁止对目标对象做任何插值或拼接 |
| 报告页进出七八次就 OOM,但一条通知都没有 | 依赖被摘掉后没有任何触发器 | 自建检测层,取证与判定留在设备内 |
| dump 一触发界面就卡死近 1.2 秒 | Debug.dumpHprofData 期间 ART 挂起全部线程,且当时在主线程 |
挪到 HandlerThread,加冷态掉帧计时与限流 |
| 手机在冻结和分析之间反复抖 | 每个 retained 都 dump 一次 | 前台攒 5 / 后台 1,60 秒冷却,单次会话最多 3 次 |
| 按 Home 后半天不出报告 | 可见性没监听,前台阈值一直是 5 | ActivityLifecycleCallbacks 维护 visible,切后台即降到 1 |
| 同一个销毁场景计了两个 retained | Fragment 的 view 与 Fragment 本身被两条回调都 watch | identityHashCode + 1 秒窗口去重 |
| 静态单例在旋转屏幕后反复报警 | 配置变更会重建 Activity,但单例是长生命周期,属于误判路径 | 写进 false-positive.md,按生命周期边界而非类名判定 |
| 报告里同一处泄漏刷了十几行 | 分组签名把 Object[].[3] 原样拼进去,下标不同就算不同 bug |
下标统一归一成 [x] |
sha1 每组都变 |
可疑引用集合里混进了每次不同的 description / 时间戳 | 签名只取路径中标记 ~~~ 的引用,别夹带元数据 |
堆里类名全是 a.b.c,引用链没法看 |
release 包开了混淆,mapping 没跟 dump 一起归档 | 同目录归档 mapping.txt,解析时按构建号取 |
strings xxx.hprof 能搜出姓名和 token |
String 底层是 PRIMITIVE_ARRAY_DUMP,dump 抓的是整个堆 |
dump 后立刻清零基本类型数组,原文件立即删除 |
| 脱敏之后引用链也解不出来了 | 顺手把数组长度 / 类型字段也清了,后续子记录全错位 | 只清元素区,不动结构字段 |
| 端外脚本解析出的类名全是乱码 | strings[rid()] = f.read(...) 求值顺序把文件指针吃掉 |
先读 id 再读字节,并断言游标位置 |
| idSize 写死 4,在 Pixel 6 上全错 | 64 位 id 的 dump 用的是 8 | 从 header 读 u4,4 与 8 都支持 |
| 从第二条子记录开始偏移全飞 | 把 0x1C 子记录当顶层记录读 length |
子记录无 length,按 tag 各自结构推进游标 |
| Android 13 的 dump 每跑必崩 | 没处理 0xFE HEAP_DUMP_INFO |
补 Android 扩展 tag;未知 tag 一律抛错不猜 |
| 白名单里明明有,通知还是清不干净 | 只按类名前缀匹配,混淆后的类名对不上 | 白名单同时存原始名与 mapping 还原后的名 |
System.gc() 一调就判泄漏 |
把「GC 跑过」当成「该回收的都回收了」 | GC 只是制造一次机会,判据仍是二次 poll 后 key 在不在 |
release APK 里检出了 dumpHprofData 字符串 |
检测类放在了 main 源集,只在运行时用 BuildConfig.DEBUG 挡 |
全部实现挪进 debug 源集,跨源集用反射入口 |
| 线上 OOM 上报的 retained 字段全空 | 把计数器也一并挪进 debug 源集了 | main 里只留一个薄计数读取口,审依赖时能一眼看完 |
| 日志爆炸还夹着用户可见文案 | 报告里展开了链上每个对象的所有字段 | 换成三态标签 + 短说明,只输出类名与字段名 |
| CI 全绿但脱敏回归根本没跑 | Skill 早期版本让 Trae 自己声明「已完成」 | 加 verify_skill.py 检查实际产物,脚本说了才算 |
| Skill 投触发词没反应 | description 写成「内存优化相关规范」,匹配不到 |
把用户真会说的说法写进 description,并跑自检 |
表里前三行是同一场翻车的三个侧面,十月八号那两百行就是这么废掉的。第 15 到 19 行是同一个下午连着踩的,全都出在端外解析器上------二进制格式这东西,错一个字节不会报错,只会给你一份看起来很像样的错误结论。
命令速查
按用途分三组,重点是输出里该看哪几行。
bash
# --- 造态与触发 ---
adb shell am force-stop com.example.health # 造冷态
adb shell am start -n com.example.health/.report.ReportActivity
adb shell input keyevent 4 # 返回,触发 onDestroy
adb shell am broadcast -a com.example.health.DEBUG_DUMP # debug 专用,立即 dump
adb logcat -s LeakDetector LeakWatcher | grep -E 'Watching|Retained|install'
# Watching instance of ... (Activity received Activity#onDestroy() callback)
# 5 秒后:Scheduling check for retained objects because found new object retained
# Rescheduling check ... in 2000ms because found only 4 retained objects (< 5 while app visible)
# --- 取回与脱敏复核 ---
adb pull /data/data/com.example.health/files/leak/ /tmp/leak-check/
strings /tmp/leak-check/*-stripped.hprof | grep -E '身份证|就诊人|Bearer |token' | wc -l
# 必须是 0;非 0 说明脱敏被绕过,这份文件不许离开当前目录
strings /tmp/leak-check/*-stripped.hprof | grep -c 'JAVA PROFILE'
# 1 ------ header 还在,说明只是数组内容被清零
# --- 端外解析 ---
python3 tools/hprof_min.py captures/report.hprof --index
python3 tools/hprof_min.py captures/report.hprof --find <key> --path --strict
python3 tools/leak_report.py captures/report.hprof --build 8.2.2-312 --out leak-reports/
python3 -m unittest tests.test_sanitizer -v
python3 .trae/skills/leak-detector/scripts/verify_skill.py
bash .trae/skills/leak-detector/scripts/check_release.sh app-release.apk
--index 的输出我一般先看三个数:
json
{
"records": {"1": 18422, "2": 3901, "28": 1, "44": 0},
"objects": 214803,
"gc_roots": 1874,
"classes": 3901
}
28 是 0x1C 的十进制,出现 1 次说明这份 dump 是单段;44 是 0x2C 结束标记。如果 gc_roots 是 0,别急着看路径,先回去查 GC Root 子 tag 是不是解析漏了------路径一定出不来。
release 洁净度那组命令单独列,安全同事每季度复查一次:
bash
unzip -p app-release.apk classes.dex > /tmp/r.dex
strings /tmp/r.dex | grep -E 'KeyedWeakReference|ObjectWatcher|HeapDumper|HprofSanitizer|dumpHprofData' | wc -l
# 0
strings /tmp/r.dex | grep -E 'retainedObjectCount|ReleaseRetainedCounter' | head -3
# 只允许出现这一个读取口
adb shell dumpsys package com.example.health | grep -i -A2 'ContentProvider'
# 确认没有为了自动安装偷偷 merge 进来的 provider
上线前我盯的几个数
四个数定在 docs/metrics-leak.md,从第一版手写实现到最后这一版,口径一次没改。样本是 Pixel 6 / Android 13 与 Redmi Note 11 / Android 11,每台机冷态十轮,每轮先 am force-stop 再造态。
| 数 | 口径 | 手写第一版 | 定稿 | 目标 |
|---|---|---|---|---|
| 误报率 | 假阳性 / 总 retained,40 个构造样本 + 20 轮正常进出 | Pixel 6 62% / Redmi 65% | 9% / 11% | ≤ 10% |
| 真阳性召回 | 8 类构造泄漏样本被抓到的数量 | 5 / 8 | 8 / 8 | 8 / 8 |
| dump 冻结 P90 | Pixel 6 冷态十轮,掉帧计时 | 1180ms(同步在主线程) | 640ms | ≤ 700ms |
| release 洁净度 | release dex 里检测类数 / OOM 上报带 retained 数比例 | 0 类 / 0% | 0 类 / 96% | 0 类 / ≥ 95% |
有两处我没让它好看。一是 Redmi Note 11 的误报率 11%,超目标 1 个百分点,原因是这台机系统回收更激进、队列入队时机飘得厉害,Trae 提议把三个样本剔出分子,我没同意------口径一改,前面十一轮的对比全废。二是「冻结 P90 从 1180 降到 640」并不等于快了一半:dump 本身的挂起时长没变短,只是不再挡住用户正在点的那条路径,报告里这两件事分开写了两行。
真要说这轮最有用的数字,其实是那个 0:strings 检索敏感字段的结果是 0,release dex 里检测类的结果也是 0。这轮整改的初衷本来就不是把检测做得多准,是让「它到底抓了什么、放在哪、会不会出设备」变成能一条命令验出来的事。
还能往下挖的地方
按我自己排的投入产出比,从最值得做的往下:
| 方向 | 解决什么 | 代价 |
|---|---|---|
| 多进程下 watcher 归属 | 现在每个进程都装一份,推送进程会重复 dump | 要按进程名分流,并给主进程加仲裁,改动跨 3 个文件 |
| 低内存机型误报那 1 个百分点 | Redmi 上入队时机飘,假阳性偏多 | 得按机型内存类动态调整轮询次数,容易变成调参掩盖 |
| debug 包开 minify 后的 keep 规则 | 现在靠反射跨源集,debug 没混淆所以没踩到 | 要补 keep 并把风险写进 Rules,和混淆那套规则对账 |
| 端上轻量分析 | 现在必须把文件拉到开发机才能看路径 | 要把 Shark 那套两遍扫描搬进设备,工作量最大 |
| heap growth / BLeak | 不 watch 任何对象也能发现「堆一直在涨」 | 3.0 还是 alpha,API 不稳,得先隔离实验分支 |
| 混淆 mapping 自动归档 | 现在靠人把 mapping 放进 captures 目录 | 加一个 Gradle 任务按构建号拷,顺手校验版本匹配 |
与 ApplicationExitInfo 打通 |
OOM 上报现在只有 retained 计数,没有退出原因 | 要区分 REASON_LOW_MEMORY 与别的退出原因,别混为一谈 |
| Compose 场景 | remember / 副作用持有导致的泄漏还没进构造样本 |
得补 3~4 类样本,还要处理重组计数 |
| 脱敏证据链留痕 | 现在只证明了「检索为空」,没证明「哪一步做的」 | 给每次 dump 写一份不含内容的审计记录,合规复查会用 |
| 把 Skill 做成团队私有包 | 隔壁组改了隐私章节,版本开始分叉 | 加版本号与回归样例,改 description 时跑自检 |
前两项我打算在下一个版本里做掉。有一条我没列进来:native 泄漏。那是 heapprofd 采 native 堆分配的事,跟 Debug.dumpHprofData 抓的 Java 堆完全不是一套东西,混在一起讲只会把「泄漏」这个词又搞糊。
后来
十一月初把那台 Redmi Note 11 的回归报告打出来贴在隔板上,误报率那一栏 11% 我用笔圈了一下,没涂掉。同事路过问这是不是没过,我说没过,他说那还贴着,我说贴着才记得住。安全同事后来复查只问了一句「报告第一行那句脱敏声明是谁写的」,我说是我让她上次问完之后加的,她点点头就走了。