删掉 LeakCanary 那天,我用 Trae 自己撸了一套:最难的不是分析堆,是决定什么时候 dump

3 阅读59分钟

删掉 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 说「要不往矿井里的金丝雀那个方向想想」。金丝雀就这么留下了,尿布进了历史。

真正让我停下来的,是检测手法在头两周被推翻了两次。

第 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 之后切得很细,各层之间各有各的边界。

flowchart TB
  subgraph DEV[设备上 · App 进程内]
    A[ActivityLifecycleCallbacks<br/>Fragment / View / ViewModel 回调] --> B[AppWatcher]
    B --> C[ObjectWatcher<br/>KeyedWeakReference + ReferenceQueue]
    C --> D[HeapDumpTrigger<br/>5 秒 / 前台 5 后台 1]
    D --> E[HeapDumper<br/>Debug.dumpHprofData]
    E --> F[HeapAnalyzer<br/>Shark 端上分析]
    F --> G[OnHeapAnalyzedListener<br/>通知 / 上报 / Leaks UI]
  end
  subgraph HOST[开发机 / CI]
    H[shark-cli<br/>analyze / dump-process / strip-hprof / heap-growth] --> F
    I[Gradle plugin<br/>mapping 塞进 APK asset] --> F
  end
  J[plumber-android<br/>修已知 AOSP 泄漏] -.-> DEV
模块干什么在哪跑我能不能自己写
object-watcherKeyedWeakReference、ReferenceQueue、retained 计数平台无关,可进 release能,几百行
object-watcher-android生命周期挂载,自动 watch Activity / Fragment / View / ViewModelApp 进程能,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 / uiautomatorUI 测试里跑重复场景查堆增长仪器化测试后面单列

看完这张表,边界就清楚了:检测与触发这一层是 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 标在旁边。

sequenceDiagram
  participant U as 用户 / 系统
  participant L as ActivityLifecycleCallbacks
  participant W as ObjectWatcher
  participant Q as ReferenceQueue
  participant T as HeapDumpTrigger
  participant D as HeapDumper
  participant A as 端外分析器

  U->>L: onActivityDestroyed(activity)
  L->>W: watch(activity, &#34;收到 onDestroy()&#34;)
  W->>W: 生成 UUID key
  W->>W: KeyedWeakReference(obj, key, queue)
  W->>W: watchedKeys[key] = ref
  W->>T: 投递延迟 5 秒的检查
  Note over Q: GC 把已回收对象的 ref 入队
  T->>Q: 第一次 poll,清掉已入队 key
  T->>T: Runtime.gc() + runFinalization()
  T->>Q: 第二次 poll,再清一遍
  T->>T: key 仍在表里 → retained +1
  T->>T: 前台 retained >= 5 或后台 >= 1 ?
  T->>D: Debug.dumpHprofData(path)
  D->>D: 私有目录落盘 + primitive array 置零
  D->>A: 拉取脱敏后的 hprof
  A->>A: Pass1 建索引 / Pass2 建边 / BFS 最短路径
  A-->>U: 报告页 + 分组签名

四步里容易被跳过的其实是第一步的第 2 段:判定不是「等五秒看看」,而是等五秒、清队列、强制 GC、再清队列、还活着才算。顺序错了结果完全不同,我第一版就是少了一次 GC,把大量「本来会被下一次 GC 收掉」的对象算成了泄漏。

状态机比时序图更能说明为什么要有两段:

stateDiagram-v2
  [*] --> Watching: onDestroy → watch()
  Watching --> WeaklyReachable: GC 后 ref 入 ReferenceQueue
  WeaklyReachable --> Cleared: poll 命中,从 watchedKeys 移除
  Watching --> Suspect: 5 秒到期且未在队列
  Suspect --> WeaklyReachable: 强制 GC 后入队
  Suspect --> Retained: 二次 poll 后仍在表
  Retained --> Counting: retained 计数 +1
  Counting --> Dumping: 达阈值
  Dumping --> Analyzing: hprof 落盘
  Analyzing --> [*]: 出报告

几个参数不是拍脑袋定的,我挨个查了它的来由:

参数默认值为什么是这个数
延迟检查5 秒给 onDestroy 之后那些延迟任务、消息队列一点收尾时间,太短会误报
可见时阈值5 个 retaineddump 会让 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 多了几个字段,每个都有用:

// 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 的核心就三个动作,加起来不到一百行:

// 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 里那几行看着有点土,但是必要的:

// 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 的形态谁能读到
watchedKeysConcurrentHashMap 的 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:

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 时间戳。之后就是一串记录,每条记录的结构固定:

record := tag(u1) + timestamp(u4) + length(u4) + body(length 字节)

顶层的 tag 不多:

Tag名称里面是什么
0x01STRING_IN_UTF8id + UTF-8 字节,类名、字段名都从这来
0x02LOAD_CLASSclass serial + class object id + stack serial + name string id
0x04 / 0x05STACK_FRAME / STACK_TRACE分配时的调用栈
0x0CHEAP_DUMP完整堆,内含子记录
0x1CHEAP_DUMP_SEGMENT分段堆,Android 上更常见
0x2CHEAP_DUMP_END分段结束

坑在 HEAP_DUMP_SEGMENT 里:它内部的子记录没有 length 字段,只有一个 u1 子 tag 加定长或按结构推断的 body,得按 tag 逐种手工推进游标。我第一次写的时候按顶层记录的格式去读,直接从第二条开始就错位,解析出来的类名全是乱码。

子记录分两类。一类是 GC Root,每一个都指向一个对象 id:

子 tagGC Root 类型常见来源
0xFFROOT_UNKNOWN厂商 / ART 未分类
0x01ROOT_JNI_GLOBALnative 全局引用
0x02ROOT_JNI_LOCAL活跃 JNI 调用的局部引用
0x03ROOT_JAVA_FRAMEJava 栈帧局部变量
0x04ROOT_NATIVE_STACKnative 栈
0x05ROOT_STICKY_CLASS系统类(bootstrap 加载的类)
0x06ROOT_THREAD_BLOCK线程阻塞对象
0x07ROOT_MONITOR_USED正在用的同步锁
0x08ROOT_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 上跑一次崩一次。

另一类是对象:

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 里哪些字节要当边写进图。

flowchart LR
  subgraph P1[Pass 1 · 建索引]
    A[读 header 拿 idSize] --> B[扫所有 record]
    B --> C[STRING: id → 名字]
    B --> D[LOAD_CLASS: classId → 类名]
    B --> E[CLASS_DUMP: 类字段布局]
    B --> F[GC Root: 收集根 id]
    B --> G[对象 id → 文件偏移]
  end
  subgraph P2[Pass 2 · 建边]
    H[按偏移随机 seek] --> I[按类布局解析字段字节流]
    I --> J[type=2 的字段写出边]
    J --> K[对象图]
  end
  P1 --> P2
  K --> L[BFS 从 SuperRoot 到目标 id]
  L --> M[最短强引用路径 = leak trace]

两遍扫描是 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-081.3GitHub 初次导入,Java,LeakCanary.install(this) 返回 RefWatcher;debugCompile 配 no-op 给 release;HAHA 分析、独立进程 HeapAnalyzerService、DisplayLeakService 发通知
2016-20181.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:432.0-alpha-1巴黎时间,Android Makers 现场打的 tag,演讲叫《LeakCanary 2: Leaner, Better, Faster, Kotliner!》;前一周 67 个提交,测试用的是大会自己的 App
2019-11-272.0 正式版7 个月、3 个 alpha、5 个 beta、23 位贡献者、493 次提交;约 6000 行 Java → 约 16000 行 Kotlin;Shark 取代 HAHA;自动安装;前台 5 / 后台 1 批量报告;按类型分组
2020-12-242.6开始盯 View.onDetachedFromWindow() 之后的 root view、以及 Service.onDestroy() 之后的服务
2022-01-042.8搬进 expanders(集合按写法展示);Android 12、WorkManager、多进程、App Startup 适配
2019-2020跨版本读混淆堆的三件套陆续补齐:Shark 解析 mapping、Gradle 插件把 mapping 塞进 APK、shark-cli -m
2022-20242.9.1~2.142.10 试过 Neo4j 探索堆;2.12、2.13 常规迭代;2.14(2024-04-17)修掉一个 SettableFuture 依赖——WorkManager 后续版本删这个类时,2.8~2.13 会在分析阶段崩
2024-01-093.0-alpha-1开始做 heap growth:ObjectGrowthDetector 在 UI 测试里反复跑同一场景,看堆是否持续增长
2026-06-253.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,是那三处报告改动,全是「跟人相处」的问题:

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 / ObjectWatcherleak/ObjectWatcher.kt照搬设计,自己实现,约 110 行
object-watcher-android 生命周期挂载leak/LeakWatchers.kt照搬清单,用 ActivityLifecycleCallbacks + AndroidX 自己写
AppWatcherInstaller(ContentProvider)LeakDetector.install()改:显式调用,仅 debug,拒绝自动安装
HeapDumpTriggerleak/DumpTrigger.kt照搬策略,阈值加严,加 60 秒限流
HeapDumper / Debug.dumpHprofDataleak/HeapDumper.kt照搬 API,加私有目录与立即脱敏
Shark 全套分析tools/hprof_min.py缩小:只做 Pass1 索引 + Pass2 建边 + BFS,不碰支配树
Leaks UI / 通知通知 + 一个 debug 报告页简化:原生 NotificationCompat + 原生 AlertDialog 说明用途
OnHeapAnalyzedListener 上报脱敏后写本地 JSON,人工决定是否上传改:默认不上传,避免个人信息出设备
AndroidReferenceMatchers / plumber-androidassets/known-leaks.txt换成表:只做白名单,不给系统打补丁
object-watcher-android 进 release 计数leak/ReleaseRetainedCounter.kt照搬思路,只留计数,release 里不 dump、不落文件
leakcanary-android-instrumentationEspresso 里反复进出报告页后面做,先不进这一版

三条边界是我在评审时加的,都跟这轮合规整改直接相关:

其一,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 依赖干净得多:

// 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")
}
<!-- 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 里的入口长这样,显式、可审计:

// 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 是敏感数据,绝不能进上下文、更不能进仓库。

# .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 限定只对检测层生效:

---
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 同目录归档,但不进版本库。

提示词统一用五段式,这个骨架从前面几篇一直沿用下来,改的只是约束:

【角色与边界】你在什么工程里、只改哪一层、不许碰什么
【目标】这一轮只解决一个问题,写清完成的定义
【上下文】@ 具体文件与目录,不给整仓;附上前一轮的产出路径
【约束】套 leak_rules.md;不引库、不落未脱敏堆、不写没实测过的数字
【验收】一条命令或一组数据,写清机型、样本量、口径

三条固定 Constraints 是「不引第三方库 / 堆快照出设备前脱敏 / release 零检测代码」。模式上是 SOLO Agent——2026 年 5 月 Builder 和 Coder 合并之后,旧教程里「先出计划、等我确认再动手」那两段式已经找不到了,这个返工我在第 1 轮就撞上:Trae 直接把六个文件一次性写完,我原本准备的「确认计划」那句追问根本没派上用场,只能改成一次投喂自带证据和验收。

开工前先把四个数定死在 docs/metrics-leak.md,每一轮都 @ 它,谁也不许临时换口径:

数口径整改前基线目标
误报率假阳性数 / 总 retained,40 个构造样本 + 20 轮正常进出62%(我手写的第一版)≤ 10%
真阳性召回8 类已知泄漏样本被抓到的数量5 / 88 / 8
dump 冻结 P90Pixel 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。

【角色与边界】
你在我们那个健康类 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 前面了,不要再添一个」。

验收命令:

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。

【角色与边界】
你负责给这次自建检测层建立一份可追溯的事实底座,不写实现代码。
只采信 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 页表那种跨主题的日期冲突,它习惯性揉成「逐步强制」,我按前几篇的规矩让它分两栏并列写,不挑一个当事实。

验收我用的是「错误说法对照表」行数,加上一条本地校对:

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/。

【角色与边界】
你做架构映射,产出设计文档,不写实现代码。
看这份文档的是组里另一个 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。

【角色与边界】
你只实现检测层最内核的一个类,不碰生命周期挂载、不碰 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 之后的第二次,正好是我十月八号手写那版犯的错——把判定顺序整段贴进提示词的【上下文】才压住。

验收就是那四个单测,加上一条本地检索:

./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。

【角色与边界】
你实现生命周期挂载层与显式安装入口,只改 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,不藏起来。

验收命令:

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。

【角色与边界】
你只改确认逻辑与触发策略,不改 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。

【角色与边界】
你实现 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 本身不泄露内容,但那段代码路径会短暂持有未脱敏文件的完整读取,没必要。

验收命令与结果:

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。

【角色与边界】
你在开发机 / 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。

这轮踩的坑最密,也最像真实的二进制解析。它第一版用了这么一行:

# 错误写法
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,人家还有虚拟引用读取、支配树、对象检查器,不是一回事。

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。

【角色与边界】
你在端外解析器里补最短路径与标注,不新增解析能力,不改 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。

【角色与边界】
你把前面所有产出串成一份人能看的报告,并补上脱敏回归测试。
不新增检测能力,不改判定语义。

【目标】
产出:
- 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,备注是「不要为了调试方便开后门」。

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/。

【角色与边界】
你负责回归脚本与样本工程,不改判定语义、不放宽阈值、不改口径。
目标是拿数据说话,不是把数字做好看。

【目标】
产出:
- 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 复核那段命令是安全同事最关心的:

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/。

【角色与边界】
这一轮不新增功能。你要把前 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 个百分点的误报。三条我都写进了「还能往下挖的地方」,没在文章里假装解决。

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/,模板和脚本单独放,别一上来把上下文撑满:

.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 是能被正确触发的关键:

---
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 自称通过不算数,脚本说了才算。

# .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,我在自己机器上跑过构建和单测,换个版本未必一模一样。

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

生命周期挂载这块,去重是关键:

// 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 出来对账:

// 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 是隐私边界所在,两段必须连着读:

// 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)
        }
    }
}
// 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 行就能结束:

// 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
}
// 挂进既有的崩溃上报组装处(示意)
val meta = bundleOf(
    "retained_object_count" to ReleaseRetainedCounter.snapshot(),
    "oom_heap_state" to "unknown"          // 不编造没测过的堆数据
)

端外解析器的骨架,重点是两次 seek 与不许写死 idSize:

#!/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 都非零退出。

白名单和基线长这样,都是能被脚本复算的纯数据:

# 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
{
  "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 里不传产物,只打印摘要:

# .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 后半天不出报告可见性没监听,前台阈值一直是 5ActivityLifecycleCallbacks 维护 visible,切后台即降到 1
同一个销毁场景计了两个 retainedFragment 的 view 与 Fragment 本身被两条回调都 watchidentityHashCode + 1 秒窗口去重
静态单例在旋转屏幕后反复报警配置变更会重建 Activity,但单例是长生命周期,属于误判路径写进 false-positive.md,按生命周期边界而非类名判定
报告里同一处泄漏刷了十几行分组签名把 Object[].[3] 原样拼进去,下标不同就算不同 bug下标统一归一成 [x]
sha1 每组都变可疑引用集合里混进了每次不同的 description / 时间戳签名只取路径中标记 ~~~ 的引用,别夹带元数据
堆里类名全是 a.b.c,引用链没法看release 包开了混淆,mapping 没跟 dump 一起归档同目录归档 mapping.txt,解析时按构建号取
strings xxx.hprof 能搜出姓名和 tokenString 底层是 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 行是同一个下午连着踩的,全都出在端外解析器上——二进制格式这东西,错一个字节不会报错,只会给你一份看起来很像样的错误结论。

命令速查

按用途分三组,重点是输出里该看哪几行。

# --- 造态与触发 ---
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 的输出我一般先看三个数:

{
  "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 洁净度那组命令单独列,安全同事每季度复查一次:

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 / 88 / 88 / 8
dump 冻结 P90Pixel 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% 我用笔圈了一下,没涂掉。同事路过问这是不是没过,我说没过,他说那还贴着,我说贴着才记得住。安全同事后来复查只问了一句「报告第一行那句脱敏声明是谁写的」,我说是我让她上次问完之后加的,她点点头就走了。