我用 Trae 把 Android 内存账重算了一遍

3 月 12 日那次灰度,我盯的是一台 4GB 的 Redmi Note 9(Android 11 / API 30)。发版第二天,模块 M 的「进程被系统杀掉」上报从每千次冷启动 6 次跳到 21 次。我照老习惯抓了份 heap dump,Java 堆 24MB、对象数、支配树,全都和上个版本对得上,连一个多出来的 Activity 都没有。可是 adb shell dumpsys meminfo com.example.health 里 TOTAL 那行从 168MB 变成了 248MB。80MB 的差额,heap dump 里一分钱都找不到。

后来在一台 Pixel 4a(Android 13)上用 Perfetto 的 heapprofd 采了 native 分配,才把这 80MB 拆开:新接的体检报告大图列表,一张 1080×1920 的图解码后像素落在 native heap,一屏三张加预读,光 Graphics 和 Native Heap 就多了 50 多 MB;剩下的是几个 .so 和 resources.arsc 的 mmap 共享页按 PSS 分摊过来的账,还有 zRAM 里压着没真正释放的匿名页。Java 堆里只剩一个几十字节的 Bitmap 壳,它当然不涨。

那两天我一直在找「哪个对象泄漏了」,方向从一开始就错了。系统算内存不是按对象算的,是按页算的;Java 堆没涨不等于内存没涨,PSS 才是系统决定杀谁时翻的那本账。 后面所有取舍,包括我怎么给 Trae 提需求、把哪几个数钉进 CI,都是从这一句推出来的。

那台 Redmi 上我当时存下来的两段输出,现在还贴在整改单第一行:

text 复制代码
# 灰度前(v5.8.2),Redmi Note 9,模块 M 详情页稳态
$ adb shell dumpsys meminfo com.example.health
                     Pss  Private  Private  SwapPss   Heap    Heap    Heap
                   Total    Dirty    Clean    Dirty    Size   Alloc    Free
                  ------   ------   ------   ------   ------  ------  ------
  Native Heap     41230    41188        0      812   65536   48922   16614
  Dalvik Heap      6328     6104       64     1104   24576   18904    5672
  ...
        TOTAL    168104   147880     9120     9360           -
text 复制代码
# 灰度后(v5.9.0),同一台机,同一路径
        TOTAL    248037   151204     9872    41240           -
# Dalvik Heap 那行几乎没动,SwapPss Dirty 从 9360 涨到 41240

Java Heap 那两行我盯了半小时,越看越确信没泄漏。真正变了的是 TOTAL、Native Heap、Graphics 和 SwapPss 那几列,而这几列 heap dump 一个都看不见。

系统按页算账,不按对象算账

官方讲 ART 和 Dalvik 怎么管内存时,第一句就定了调子:运行时用分页(paging)和 mmap 来管理内存,不自己攒一块大内存池。这条决定了后面所有事。

应用一旦改写了某个页,这个页就成了私有脏页,常驻 RAM,而且不能被换出 。Android 没有桌面系统那种把匿名页写到磁盘的 swap,脏页没地方可去。唯一能被内核悄悄收走的,是那些没被改过的、由文件 mmap 进来的干净页------dex、.odex、.so、resources.arsc 这类代码和资源。系统内存紧张时把这些页丢掉就行,下次要用再从文件读回来,内容不会丢。

text 复制代码
页的四种归宿(我按 dumpsys 的列自己画的)
┌─────────────────────────────────────────────────────────────┐
│ 文件 mmap 页,没改过      → Private Clean / Shared Clean       │
│   · 代码、.so、resources.arsc、字体                          │
│   · 内存紧张时内核直接回收,下次缺页再从文件读                 │
│ 文件 mmap 页,被改过      → Private Dirty / Shared Dirty      │
│   · 运行时重定位过的 .so、被写过的资源页                      │
│ 匿名页(堆、栈、native)  → Private Dirty                     │
│   · 应用写过,常驻 RAM,不能换出(zRAM 只是压缩,不落盘)     │
│   · 这一块才是真正压着系统的账                                │
└─────────────────────────────────────────────────────────────┘

顺着这条能推出一个挺反直觉的结论:释放内存的唯一办法,是释放对象引用。 不是调某个 free,不是把某个缓存 clear 就完事。对象还被引用着,它占的页就是脏页,谁也搬不走;引用断了,GC 把对象收掉,对应的页才有可能在 GC 之后被还回去。System.gc() 之所以没用,根子在这------它顶多让运行时早点发现引用断了,断不了引用本身。

堆是分代的。不同代有各自独立的上限,短命对象待的 Young 区满了触发一次小 GC,长命对象晋升到 Old 区,Old 区满了才走全堆 GC。每次 GC 都要暂停应用线程,哪怕并发复制收集器把暂停压到很短,那点暂停也可能正好落在 VSYNC 之间,把 16ms 的帧预算吃掉一截。我在 Pixel 4a 上量过一次列表快速滑动,GC 引起的掉帧有 3 帧超过 16.7ms,和那一版新增的大量临时 Rect / Path 分配对得上------这类对象连泄漏都算不上,纯粹是分配太密。

共享页是另一笔容易算漏的账。Android 上几乎每个应用进程都从 Zygote fork 出来,fork 用的是写时复制(COW):Zygote 在启动时把框架的类、资源、预加载的 drawable 都加载好了,子进程一诞生,这些页先以共享干净页的形式挂在每个进程的地址空间里,谁都不改写就一直共享。静态数据也走 mmap:.odex、编译后的 .dex、resources.arsc、各个 .so,都是以文件映射进来的,天然可以在多个进程间共享。图形那侧还有 ashmem 和 gralloc 分配的 buffer:Surface、SurfaceFlinger 用的图形缓冲、Cursor 的窗口 buffer,都是跨进程共享的内存块,应用进程只是持有其中一个句柄。

正因为共享页这么多,看内存只能看 PSS 。PSS 是 Proportional Set Size,把共享页按共享它的进程数按比例摊到每个进程头上;RSS 把共享页整块算给每个进程,加总起来会虚高几倍;VSS 干脆把还没分配物理页的虚拟地址也算进去,参考价值更小。系统侧的 lmkd 排回收顺序时看的也不是「这个进程占了多少」,而是结合进程优先级和这套按页算出来的量。我那 80MB 里有一部分,就是几个新接的 SDK 带进来的 .so 和一份更大的 resources.arsc,它们被更多进程共享后摊到模块 M 头上的份额变了。

Dalvik 时代还有个细节现在很少有人提:它不压缩堆的逻辑大小 ,GC 只标记和清除,不把存活对象挪到一起整理碎片。腾出来的空页是通过 madvise() 还给内核的,逻辑堆大小看着一直那么大,实际占用的物理页会随 GC 掉下来。getMemoryClass() 返回的是每个进程的堆上限(那台 Redmi 上返回 192MB,Pixel 4a 是 256MB),超过这个数就 OOM,和物理内存还剩多少不是一回事。后台缓存进程被系统留着,是为了下次切回来快,但内存一紧张,占内存多的缓存进程会先被杀------这次灰度被杀变多的,正好就是那些后台还挂着大图缓存的进程。

一个对象走完全程:账本上的九个口径

把开篇那 80MB 拆到底,得先把「内存」这个词拆成九个不同的量。我让 Trae 在第 3 轮把它整理成一张对照表,后来自己又改了两版,现在贴的这个版本每张表里都标了「这次涨没涨」:

口径 全称 / 列名 量的到底是什么 谁在算 共享页怎么处理 这次 80MB 里它
VSS Virtual Set Size 进程申请过的全部虚拟地址,含没分配物理页的 内核 smaps 全算,且虚拟地址也算 不看,虚高得没边
RSS Resident Set Size 实际落在 RAM 里的页总和 内核 共享页整块算给每个进程 涨了,但拆不出责任
PSS Proportional Set Size 私有页全额 + 共享页按进程数分摊 dumpsys meminfo 按比例摊,系统算账用这个 +80MB,主战场
Private Dirty 私有脏页 被本进程改写、不能换出的匿名页与改写文件页 内核 不算共享 +3.3MB,几乎没动
Private Clean 私有干净页 本进程独占、可被内核直接回收的干净页 内核 不算共享 +0.7MB
Java Heap Dalvik Heap / ART Heap 运行时堆里 Java/Kotlin 对象占的页 ART 含 Zygote 共享的堆页分摊 24→25MB,纹丝不动
Native Heap Native Heap C/C++ malloc、new、mmap 出来的匿名页 libc / malloc 实现 不算共享 +38MB 左右
Graphics Graphics / EGL / GL gralloc、纹理、Surface、帧缓冲 图形栈 + gralloc 常是跨进程共享 +22MB 左右
Swap(zRAM) SwapPss / SwapPss Dirty 被压缩进 zRAM 的匿名页 内核 zRAM 按页摊 +31MB,最扎眼

九个口径里,只有 Java Heap 是 heap dump 能看见的。Debug.dumpHprofData() 导出的 HPROF 里装的是 ART 堆里的对象图:谁引用谁、对象多大、支配树多深。它压根不知道 native heap 里那几十 MB 像素、不知道 gralloc 的图形缓冲、不知道 Zygote 共享页、更不知道 zRAM 里压着什么。所以「线上说内存涨了,本地 heap dump 一切正常」根本不是矛盾,是两台仪器量的是两个东西。

text 复制代码
HPROF 能看见的                        HPROF 看不见的
─────────────────────────────────    ─────────────────────────────────
ART 堆里的 Java/Kotlin 对象           Bitmap 像素(8.0+ 在 native heap)
对象之间的引用边                     native malloc 的解码缓冲、音频缓冲
每个对象的 shallow / retained size   gralloc buffer、Surface、纹理
GC Root 到对象的路径                 .so / .odex / resources.arsc 共享页
分配栈(若开了 Allocation 记录)     zRAM 里压缩的匿名页
                                     ashmem / ashmem 共享的 cursor buffer

这条链子画成图更清楚。一个对象从被 new 出来,到变成系统杀进程时的一个数字,中间要过好几层,heap dump 只截了最上面那一截:

flowchart TD A[&#34;模块 M 里 new 一个 HealthRecord<br/>或解码一张 1080x1920 大图&#34;] --> B[&#34;ART 分代堆分配:Young / RegionSpace TLAB&#34;] A --> C[&#34;Native 层 malloc:Bitmap 像素(8.0+)、解码缓冲&#34;] A --> D[&#34;gralloc / ashmem:Surface、纹理、cursor buffer&#34;] B --> E[&#34;匿名页,写过即 Private Dirty,常驻 RAM 不能换出&#34;] C --> E D --> F[&#34;共享页:Zygote / .so / resources.arsc 按进程数分摊&#34;] B --> F E --> G[&#34;PSS = Private Dirty + Private Clean + 摊到本进程的共享页&#34;] F --> G G --> H[&#34;内核结合 oom_adj_score 与 PSS 排 lmkd 回收顺序&#34;] H --> I[&#34;内存压力上来 → 先杀 IMPORTANCE_CACHED 里 PSS 最大的&#34;] E --> J[&#34;zRAM 把一部分匿名页压缩,SwapPss Dirty 这一列涨&#34;] B -. &#34;HPROF 只截到这一条&#34; .-> K[&#34;heap dump:对象图一切正常&#34;] C -. 看不见 .-> K D -. 看不见 .-> K F -. 看不见 .-> K

图里那条虚线就是我那两天踩的坑:heap dump 一切正常 只说明 B 这条分支没问题,C、D、F 三条分支它连门都摸不到。而这次 80MB 几乎全在 C、D、J 上。

再往下一层,Private Dirty 这次只涨了 3.3MB,可 SwapPss Dirty 从 9MB 涨到 41MB。这两个数放一起读才有意思:匿名页确实多了,但系统没让它们全常驻,而是把一部分压进了 zRAM。zRAM 不是磁盘 swap,是把页压缩后仍放在 RAM 里,代价是 CPU 和压缩率,好处是比直接常驻省地方。低内存机型上这套机制一旦频繁触发,解压、压缩都在抢 CPU,帧率跟着掉,最后 lmkd 还是会根据 PSS 和优先级把进程收掉。所以「PSS 涨了」和「被杀变多」之间,隔着的不是玄学,是 oom_adj_score、进程 importance 和这套按页摊出来的数。

我把这段判读写成了一条硬规矩:以后谁再说「内存涨了」,先问一句「你说的是哪一列」。问不清列,讨论没法往下走。第 3 轮给 Trae 的提示词里,这句原话被我塞进了 Constraints。

从 Stop-the-world 到 userfaultfd

把账本摆平之后,我才回头去翻收集器这十几年到底改了什么。改的其实一直是同一件事:怎么在尽量不暂停应用的前提下把堆收拾干净,同时别让 RSS 在收拾过程中鼓出一个包把后台进程挤死。这张表是第 2 轮让 Trae 联网核准后我逐条对过官方文档的,每个节点都标了 API level 和具体行为:

版本 API 年份 收集器 / 机制 行为变了什么 应用侧能感知到的
1.0 ~ 4.4 1 ~ 19 2008 ~ 2013 Dalvik,Stop-the-world 标记清除 每次 GC 全暂停;只标记清除,不压缩堆 、不整理碎片;GC 后靠 madvise 把空页还内核 一 GC 就卡顿,堆一碎就容易 OOM,Bitmap.recycle() 当年真管用
5.0 Lollipop 21 2014 ART 成为默认运行时,全量 AOT 安装期编译成 native code;GC 从整堆暂停改成多个更短的暂停,引入分代雏形与 card table 安装变慢、占空间变大,但 GC 卡顿比 Dalvik 短
7.0 Nougat 24 2016 JIT + profile-guided 编译回归 不再全量 AOT,边跑边按 profile 编译;编译策略变了,堆的分配节奏跟着变 装得快了,首次运行慢一点;profile 影响的是编译,不直接改 GC
8.0 / 9 26 / 28 2017 / 2018 Concurrent Copying(CC)成为默认 取代 CMS;用 RegionSpace + RegionTLAB 触碰指针分配替代 RosAlloc 承担主要分配;靠读屏障在不暂停应用线程的情况下并发复制对象做碎片整理;暂停只剩一次、且与堆大小无关 GC 卡顿基本消失,但读屏障给每次对象读取加了固定开销
10 / 11 29 / 30 2019 / 2020 CC 扩展为分代 GC、堆 region 化 短命对象在 Young 区就能轻松回收,显著推迟全堆 GC;堆按 region 管理,为后续按页操作铺路 小 GC 更便宜,全堆 GC 更少见;ApplicationExitInfo(API 30)这时进来
13 33 2022 基于 Linux userfaultfd 的并发标记压缩 GC 去掉 CC 的读屏障,把「对象被搬走了没」的检查交给内核的缺页处理;压缩过程中页可提前释放,不再出现 RSS cliff 官方说读屏障一度占编译后代码体积约 10%;新 GC 降低 GC 期间后台进程被 lmkd 杀掉的风险;JNI 切换最高快 2.5 倍
15 35 2024 支持 16KB 页设备 AOSP 支持把内核页配成 16KB;.so 需 16KB ELF 对齐,代码不能硬编码 PAGE_SIZE 系统级收益:内存压力下启动平均快 3.16%(个别应用最高 30%)、启动功耗降 4.56%、相机冷启动快 6.60%、系统启动快约 8%(950ms)
16 36 2025 4KB 应用兼容模式 检测到 4KB 对齐的页时自动进兼容模式并弹提示;android:pageSizeCompat 可提前声明避免弹框 兼容模式能跑,但官方明确说性能、稳定性都不如真对齐

时间线里我最想拎出来说的是 13 那条。CC 压缩堆时要把散落的对象拷到一块连续区域,拷的过程中新旧两份都占着物理页,RSS 会先鼓上去再骤降,这个包被叫 RSS cliff。后台进程刚好赶上这一段,就可能因为系统瞬时内存压力被 lmkd 收掉------注意被杀的未必是正在 GC 的那个应用,旁边缓存着的进程一样遭殃。userfaultfd 把检查挪进内核,页能在压缩推进过程中边搬边还,cliff 被抹平了。这也是为什么「同一版代码在 Android 13 上后台存活比 11 上稳」这种反馈,光看应用层的账解释不通。

我把这张表打印出来贴在工位上,后来发现它还有个用处:线上报「内存问题」时,先问清系统版本,能直接排除掉一半的猜测。4.4 之前的机子上谈 inBitmap 复用、13 之后的机子上担心读屏障开销,都是拿前朝的剑斩本朝的官。

哪些老办法到今天失效了,哪些反而回来了

翻老代码时顺手把这五条捋了一遍,结论和我入行时记的那套几乎反过来:

System.gc():从来就不该由应用调。 开篇说过,释放内存的唯一办法是断引用,GC 只是去发现引用断了。ART 自己会根据各代上限挑时机,手动调一次不但不会多回收,还可能把一次本来能合并的小 GC 拆成两次暂停。我在 v5.9.0 里删掉了三处 System.gc(),其中一处写在「大图加载完成后」,正好把刚解码完的像素又推着走了一轮全堆扫描。删掉之后 Native Heap 那列没变,SwapPss 反而稳了。

largeHeap="true":从「优化手段」退化成「遮羞布」。 它只是把 getMemoryClass() 那个上限抬高,代价是进程更容易被 lmkd 盯上------系统杀的就是占内存多的。我们那个健康类 App 早年为了塞体检报告的多页图开过它,这次灰度我第一反应也是「要不打开算了」,被第 10 轮的回归数据拦住了:开了之后单进程 PSS 能压住,但整机低内存时模块 M 被杀的比例反而升了。现在 manifest 里那行已经删了。

Bitmap.recycle():8.0 之后基本没意义。 像素早就不在 Java 堆里了,回收靠的是 ART 的 NativeAllocationRegistry 把 native 内存挂到 Bitmap 壳的生命周期上,壳一被回收,native 那块跟着释放。手动 recycle() 只是提前把壳标记成不可用,图还在被 ImageView 引用时调它,直接抛 Canvas: trying to use a recycled bitmap。这次 80MB 里,我一度想靠在 onTrimMemory 里遍历调 recycle() 解决,试了一版,崩了两次,退回了正经的引用清理。

软引用 / 弱引用缓存:软引用失效,弱引用反而更该用,但用法变了。 SoftReference 做内存缓存这套在早期很流行,问题是 ART 对软引用的回收时机没有保证,缓存里堆着一堆「理论上可回收」的大对象,OOM 前系统根本来不及清。Android 官方后来明确建议用 LruCache 这类有硬上限的缓存。但弱引用 在另一个位置回来了------不是做缓存,是做「长命对象指向短命对象」的那根线:静态单例里存 Activity、Listener 列表里存回调,用 WeakReference 断开强可达,配合生命周期清理。方向反过来了:软引用管不住缓存,弱引用专治泄漏。

多进程:从「省内存」变成「费的正是共享页的账」。 早年把 WebView、推送、大图解码塞进独立进程,想的是主进程被杀业务还在、单个进程堆上限不超标。但每个新进程都要重新 mmap 一遍框架资源、.so、resources.arsc,Zygote 共享页被摊到更多进程头上,整机 PSS 反而涨。这次 80MB 里有一截就来自两个新起的 :remote 进程。后来把能合的合回主进程,靠 onTrimMemory 分级裁缓存,账反而干净了。这一条也是第 12 轮我换身份当复核者时,被自己挑出来的最大一处返工。

Bitmap 的账搬过两次家

这 80MB 的主犯是图片,而图片这笔账在 Android 上搬过两次家,不搞清楚就永远对不上数。

第一次是 3.0(API 11,Honeycomb)。 在那之前,Bitmap 的像素数据放在 native 层,Java 侧的 Bitmap 对象只是个壳,像素不计入 Dalvik 堆,GC 也看不见它,释放全靠 recycle(),泄漏了极难查。3.0 起把像素数据挪进 Dalvik 堆统一管理,好处是 GC 能连着像素一起收、内存归属清楚,坏处是每张大图都实打实吃堆上限------那时 getMemoryClass() 常常只有 24MB 或 48MB,一张 1080×1920 的 ARGB_8888 图就要 8MB 左右,列表多滑两屏直接 OOM。inBitmap 复用、inSampleSize 降采样、BitmapRegionDecoder 区域解码,基本都是被这个时期的堆上限逼出来的。

第二次是 8.0(API 26,Oreo)。 像素又搬回 native heap,Java 堆里只剩那个几十字节的 Bitmap 壳。ART 用 NativeAllocationRegistry 把 native 像素的账挂到壳上:壳被 GC 回收时,注册在它上面的 native 内存一并释放;native 分配压力也会反过来触发 GC 去收壳。这一改解决了大图撑爆 Java 堆的老问题,却造出一个新的认知陷阱------「Java 堆没涨但 PSS 涨了」从此成了常态。开篇那 80MB 里最大的一块,就是这么来的。我抓 heap dump 时看到的「Java 堆 24MB 纹丝不动」,恰恰是 8.0 之后完全正常的表现。

text 复制代码
同一张 1080x1920 ARGB_8888 图,三个版本的归属
──────────────────────────────────────────────────────────────
< 3.0   Java 侧 Bitmap 壳 (Java 堆)  +  像素 (native,GC 看不见)
        Java Heap 不涨,Native 涨,recycle() 是唯一释放手段

3.0~7.1 壳 + 像素都在 Dalvik/ART 堆
        Java Heap 直接 +8MB,堆上限一紧就 OOM

>= 8.0  Java 堆只剩壳(~几十 B) + 像素在 native heap
        Java Heap 几乎不动,Native Heap / Graphics 涨
        → heap dump 看不出来,正是这次踩的坑
──────────────────────────────────────────────────────────────

能让这张图少占点地方的手段,我最后留下的是这几样,都是平台自带:

  • inJustDecodeBounds = true 先读尺寸,再按目标 View 的宽高算 inSampleSize 做两段式解码。这一步在模块 M 上把单张图从 8MB 压到 1.1MB 左右,是收益最大的一刀。
  • inBitmap 复用。3.0 就有了,但 4.4 之前要求复用的 Bitmap 和新图尺寸完全一致才肯用,4.4 起放宽成「复用图的 allocationByteCount 不小于新图所需字节」即可,配合一个按尺寸分桶的复用池才好用。复用失败要能安静退回普通解码,别抛异常。
  • Bitmap.Config.HARDWARE(8.0 / API 26)。像素存在图形内存里,不占应用堆,GPU 直接采,适合「只显示、不改像素」的图。代价是 getPixel() / eraseColor() 这类读改写操作不可用,也不能拿去当 inBitmap 的复用源。
  • ImageDecoder(API 28 / Android 9)。比 BitmapFactory 更可控:能设目标尺寸、裁剪、拿动图的 AnimatedImageDrawable,内部对内存的管理也更规矩。模块 M 的 GIF / 动 WebP 报告封面全换成了它,不再自己一帧帧解码缓存整个序列。
  • 大图用 BitmapRegionDecoder 按可视区域取块,别把整张病理切片图解进内存。

历史上有件事值得记一笔,但没进我们的方案。2015 年 Square 放出 LeakCanary,把「Activity onDestroy 后放进弱引用、手动触发 GC、看它有没有被回收、没回收就 dump heap 找最短强引用路径」这套做成了自动化。它检测 Activity 泄漏的思路我到现在还照用,只是落地时全换成平台手段:弱引用 + Application Context + 生命周期清理 + StrictMode。我们那个健康类 App 的合规清单不允许再引一个跑在 release 里的三方库,这条边界第 6 轮我专门写进了 Constraints。

一条没断干净的引用链

账算清之后,还得处理真正会一直涨的那种------泄漏。它和开篇那 80MB 不是一回事:那次是「分配太多」,泄漏是「该还的没还」。但两者在 PSS 上长得一模一样,都得靠引用链才能分开。

模块 M 里我实际抓到过五条典型的链子,全都不是「忘了置 null」这么直白:

持有者 怎么把 Activity 挂住的 我当时怎么发现的
静态单例 HealthCache.instance 初始化时把 context 存成 Activity,而不是 applicationContext 旋转屏幕十次,dumpsys meminfo 的 Activities 计数从 1 涨到 11
单例里的 Listener 列表 匿名内部类/回调捕获了外部 this@Activity,注册后没反注册 HPROF 支配树里单例的 retained size 拖着一整个 View 树
未取消的 CoroutineScope 自己建的 MainScope() 里 launch { 引用 this@Activity },onDestroy 没 cancel() 退出页面后延迟请求回来,回调还在改已销毁 View
Handler 的延迟 Message postDelayed 的 Runnable 捕获 Activity,Message 在 MessageQueue 里排着队 退出后 30 秒那条 Message 才执行,Activity 一直被队列强引用
Compose remember { } 在 remember 里存了带 Context 的对象或捕获了 Activity 的 lambda,重组后又没被清掉 退出 Compose 页面后 ViewModel 之外还留着一条到 Activity 的强引用

画成图就是一张从 GC Root 一路牵到 native 像素的链:

flowchart LR subgraph ROOT[&#34;GC Root 区(不会被回收)&#34;] S[&#34;静态单例 HealthCache.instance&#34;] L[&#34;单例里的 Listener 列表&#34;] C[&#34;未 cancel 的 CoroutineScope&#34;] H[&#34;Handler 延迟 Message / Runnable&#34;] R[&#34;Compose remember 存的 lambda&#34;] end A[&#34;HealthReportActivity(本该被销毁)&#34;] V[&#34;它持有的 View 树 / Bitmap 壳&#34;] N[&#34;native 侧像素缓冲(几十 MB)&#34;] K[&#34;GC 判定 live:整条链都不回收&#34;] S -->|&#34;instance.context = this&#34;| A L -->|&#34;回调匿名类捕获外部 this&#34;| A C -->|&#34;launch 里引用 this@Activity&#34;| A H -->|&#34;Runnable 捕获 this&#34;| A R -->|&#34;remember 捕获 context&#34;| A A --> V V -. &#34;Bitmap 壳指向&#34; .-> N A --> K K -. &#34;native 像素跟着不释放&#34; .-> N

这张图里最容易被忽略的是最后那条虚线:泄漏的只是一个几十字节的 Bitmap 壳,但它牵着的 native 像素是几十 MB。所以「HPROF 里看到一个泄漏的 Activity,retained size 才 2KB」这种报告千万别当小事,壳背后挂的东西 HPROF 根本不统计。

落地我用的是四样平台自带的东西,零依赖:

kotlin 复制代码
// app/src/main/java/com/example/health/base/ActivityLeakGuard.kt
// 用途:把上面五条链子在生命周期边界上统一剪断,配合 StrictMode 兜底。
package com.example.health.base

import android.app.Activity
import android.app.Application
import android.os.Bundle
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import java.lang.ref.WeakReference

object ActivityLeakGuard : DefaultLifecycleObserver {

    // 单例只握弱引用 + Application Context,绝不留 Activity 的强引用
    private var appRef: WeakReference<Application>? = null
    private val pending = java.util.Collections.synchronizedList(
        mutableListOf<() -> Unit>()
    )

    fun install(app: Application) {
        appRef = WeakReference(app)
        app.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
            override fun onActivityCreated(a: Activity, s: Bundle?) {
                // 交给页面自己用 registerCleanup 登记清理动作
            }
            override fun onActivityDestroyed(a: Activity) = drainFor(a)
            override fun onActivityStarted(a: Activity) {}
            override fun onActivityResumed(a: Activity) {}
            override fun onActivityPaused(a: Activity) {}
            override fun onActivityStopped(a: Activity) {}
            override fun onActivitySaveInstanceState(a: Activity, s: Bundle) {}
        })
    }

    fun appContext(): Application? = appRef?.get()

    fun registerCleanup(owner: LifecycleOwner, action: () -> Unit) {
        owner.lifecycle.addObserver(object : DefaultLifecycleObserver {
            override fun onDestroy(o: LifecycleOwner) {
                action()
                o.lifecycle.removeObserver(this)
            }
        })
    }

    private fun drainFor(a: Activity) {
        // 兜底:把仍挂着该 Activity 的延迟任务、监听一并清掉
        val it = pending.iterator()
        while (it.hasNext()) runCatching { it.next()() }.also { it.remove() }
    }
}

配套的几条硬约束写进了第 6 轮和第 12 轮的复核清单:静态单例一律存 applicationContext 或弱引用;Listener 在 onStart/onResume 注册、onStop/onPause 反注册,或用 DefaultLifecycleObserver 在 onDestroy 里统一反注册;自建 CoroutineScope 必须在 onDestroy 里 cancel(),能用 viewModelScope 就别自建;Handler 的延迟任务在 onDestroy 里 removeCallbacksAndMessages(null);Compose 里凡是 remember 捕获了 Context 的对象,改用 LocalContext.current.applicationContext 或随 DisposableEffect 清理。

StrictMode 那层是最后的网,只开在 debug 变体:

kotlin 复制代码
// app/src/main/java/com/example/health/HealthApp.kt(节选,debug 变体生效)
if (BuildConfig.DEBUG) {
    StrictMode.setVmPolicy(
        StrictMode.VmPolicy.Builder()
            .detectActivityLeaks()                 // Activity 销毁后仍被引用
            .detectLeakedClosableObjects()         // Cursor / Closeable 没关
            .detectLeakedRegistrationObjects()    // 广播/服务注册没反注册
            .penaltyLog()
            .build()
    )
}

detectActivityLeaks() 的原理和 LeakCanary 早期那套同源:Activity 销毁后系统记着它,过一会儿发现还可达就在 logcat 里打一条。它不自动 dump、不引依赖,够我们定位「哪个页面旋转十次后 Activities 计数还在涨」。真要看清引用链,还是得靠 Debug.dumpHprofData() 导出来在 Android Studio 的 Memory Profiler 里翻支配树------这一步我放在命令速查那节细讲。

给 Trae 立的规矩

真开始让 Trae 接手之前,我先花了一晚上把「它能看什么、按什么规矩写」定死。这一步没做的话,后面十二轮会不断收到「建议引入某内存监控 SDK」「建议开 largeHeap」这种正确但用不上的回答。

先划它不许碰的东西。 工程根加一份 .traeignore,把体积大、跟内存账无关、或者不该被贴进上下文的内容挡在外面:

text 复制代码
# .traeignore
build/
**/build/
.gradle/
**/.cxx/
**/captures/                 # hprof、trace 动辄几百 MB
**/*.hprof
**/*.prof
docs/memory/evidence/        # 原始采集数据走脚本读,不整目录贴
docs/memory/mapping/
**/release/output-metadata.json
**/*.keystore
**/*.jks
local.properties
**/.so                      # 二进制不进上下文,只让脚本列清单

HPROF 和 Perfetto trace 尤其要挡。第一次没挡,Trae 试图把一份 380MB 的 hprof 当文本读,整个会话卡了半天。二进制的东西一律走脚本产清单,让它读清单不读原件。

再写一份常驻 Rules。 放在 .trae/rules/memory.md,用 globs 限定只对 Kotlin / Gradle / 清单 / 脚本生效,把技术偏好和边界一次说清:

text 复制代码
# .trae/rules/memory.md
---
globs:
  - "**/*.kt"
  - "**/*.kts"
  - "**/AndroidManifest.xml"
  - "scripts/memory/**"
  - "app/proguard-rules.pro"
description: 内存优化专项约束,所有轮次默认生效
---

### 项目
医疗健康类 App,代号模块 M,包名 com.example.health。
Kotlin 为主,UI 弹窗只用原生 AlertDialog。

### 硬性边界
- 零第三方依赖。落地代码只用平台 API / AndroidX。
  LeakCanary、各种 APM SDK 只在讲历史时可提,不作为方案引入。
- 不允许建议或改动 android:largeHeap。
- 不允许建议多进程拆分来「省内存」。
- 涉及权限先弹用途说明再申请;不写保活、双进程守护、隐藏通知。
- 不写无法验证的效果。所有优化后数字都要带机型、口径、样本量。

### 度量口径
任何「涨了/降了」必须落到 dumpsys meminfo 的具体一列,
并写明 PSS / Private Dirty / SwapPss 里的哪一个,禁止只说「内存」。
口径对照见 docs/memory/pss-ledger.md。

### 版本
每条结论标 Android 版本 + API level + 出处;
区分「官方文档明确」与「工程推断」,未来版本行为不得写成当前现状。

globs 这段是刻意加的。没有它,Trae 会把 docs/memory/*.md 里我上一轮的中间结论当成新需求又吃一遍,第 4 轮就出过一次这种递归。

提示词统一用五段式。 十二轮里每一块可复制的提示词都是同一个骨架,只是内容不同:

text 复制代码
Goal:       这一轮只解决什么、产出哪个文件、不改什么
Context:    @ 哪些文件、挂哪些采集样本、要不要联网
Requirements: 逐条编号,写成可验收的动作,不带形容词
Constraints: 三条固定约束 + 本轮特有的禁止项
Validation: 我用哪条命令 / 哪组数据验收,对不上就重出

三条固定 Constraints 每轮都原样带上,一个字没改过:

text 复制代码
C1 只改本轮点名的文件;不引入任何第三方依赖;
   不动 AndroidManifest 的 largeHeap,不加多进程。
C2 所有数字来自实测采集,带机型、口径、样本量;
   推断不出来的写「待确认」,不要填一个看起来合理的值。
C3 每条结论标 Android 版本 + API level + 出处,
   区分「官方明确」与「工程推断」,未来行为不写成当前现状。

四个数先定死,再开工。 这是我从混淆那篇带过来的习惯:不先定尺子,后面每一轮都会重新定义「变好了」。四个数都在同一批机器上量,口径写进 docs/memory/baseline.md:

编号 量的什么 口径 机型 / 样本量
N1 模块 M 详情页稳态 TOTAL PSS(MB) 进页面静止 30s,dumpsys meminfo 取 TOTAL,连续 20 次取中位数 Redmi Note 9(4GB/API30)、Pixel 4a(6GB/API33)各一组
N2 切后台 10 分钟后残留 PSS(MB) 按 Home 键后计时,dumpsys meminfo 取 TOTAL 同上,各 20 次
N3 每千次冷启动 REASON_LOW_MEMORY 退出次数 getHistoricalProcessExitReasons 按 reason 聚合,除以冷启动数 ×1000 灰度 5% 流量,7 天窗口
N4 大图列表快速滑动 Graphics PSS 峰值(MB) 脚本每 500ms 采一次 dumpsys meminfo,取 Graphics 列峰值 Pixel 4a,10 次滑动取最大值

dumpsys 的采法我在命令速查那节给了脚本,四个数都由同一个脚本出,避免每轮换个命令导致口径漂移。

trim 分级和缓存档位的关系也先画成图钉在规矩里,第 7 轮照着这张图落地,省了一轮返工:

stateDiagram-v2 [*] --> 前台: onResume / TRIM_MEMORY_RUNNING_* 之外 前台 --> 前台_吃紧: TRIM_MEMORY_RUNNING_LOW / RUNNING_CRITICAL 前台_吃紧 --> 前台: 释放后可恢复 前台 --> 不可见: TRIM_MEMORY_UI_HIDDEN 不可见 --> 缓存中: TRIM_MEMORY_BACKGROUND (进 LRU 列表) 缓存中 --> 裁剪一档: TRIM_MEMORY_MODERATE 裁剪一档 --> 清到最小: TRIM_MEMORY_COMPLETE 清到最小 --> [*]: lmkd 可能直接杀进程 前台: 缓存 = 满额(按 getMemoryClass 的 1/8) 前台_吃紧: 缓存 = 半额,停预读 不可见: 释放 UI 相关大图、清 Activity 级资源 缓存中: 缓存 = 1/4 额 裁剪一档: 缓存 = 1/8 额,清 native 解码池 清到最小: 缓存清零,留最小可恢复状态

有一处返工得单独记。 我最早照着一份 2024 年的内部笔记写提示词,里面第 1 轮标的是「SOLO Builder 模式」,还教怎么在 Builder 里挂 #File。这台机器上翻遍菜单也没找到 Builder。查了才知道 2026 年 5 月 SOLO Builder 和 SOLO Coder 已经合并成 SOLO Agent ,旧教程里那两个入口都没了。我把十二轮里的模式全部重写:需要联网核准的用「SOLO Agent + 联网」,只读分析改文件的用「IDE 模式 / Chat + #Code」。这段返工我写进了 docs/memory/pipeline.md 开头,免得下个人又照旧教程找一遍 Builder。

十二轮提示词,我按这条账本跑完的

顺序是按那 80MB 的排查过程倒推出来的:前三轮不写一行落地代码,只体检、核准版本、把「内存涨了」翻译成能看的账;第四到第八轮才逐项归因、治理、改造;第九第十轮做真机回归和 CI 卡点;最后两轮换身份挑刺和归档。先量后改,比一上来让它「帮我优化内存」省了至少两轮返工。

每轮都写清模式、@ 哪些文件、提示词原文、期望产出哪些文件、我实际返工了什么、拿什么验收。提示词块整段复制即可,@ 是本工程里的真实路径,换成自己的工程名一样能用。十二轮占了大半篇幅,因为真正的活全在这里。

第 1 轮 · 给现状做个体检

IDE 模式 / Chat,#Code 挂上源码、构建脚本和清单,明确不改文件。

text 复制代码
Goal: 产出 docs/memory/baseline.md,说清模块 M 当前的内存相关现状,
      只做分析,不修改任何文件。

Context: @app/src/main/java/com/example/health/
        @app/src/main/res/
        @app/build.gradle.kts @gradle/libs.versions.toml
        @app/src/main/AndroidManifest.xml
        @scripts/memory/ 下已有上一次发版的 dumpsys 采样归档。
        联网关闭,本轮只依据工程内文件与归档样本。

Requirements:
1. 列出所有 Bitmap / 图片解码调用点,按文件:行号标注,
   分成 BitmapFactory / ImageDecoder / 三方图片库封装 三类;
   标出哪些没有做 inSampleSize、哪些把解码结果放进了长生命周期缓存。
2. 找出所有静态单例、object、companion object 里持有
   Context / Activity / View / Listener 的地方,逐条说明可达路径。
3. 列出所有缓存(LruCache、Map、ArrayMap、手写池)及其上限,
   标注上限是硬编码、按内存算、还是没上限。
4. 列出 APK 里所有 .so(按 ABI 分组),标出来自哪个模块/依赖,
   以及是否有对应的 16KB 对齐证据文件。
5. 报告当前 compileSdk / targetSdk / minSdk / AGP / Gradle / NDK 版本。
6. 单独一节「无法从源码推断的内容」:
   哪些 native 分配来自 SDK、哪些图形缓冲由系统侧持有、
   哪些共享页会被 PSS 分摊,列出来让我人工补。

Constraints: C1 C2 C3(见 .trae/rules/memory.md)。
本轮额外:不修改任何文件;不引入依赖;不给优化建议。

Validation: 我用 `unzip -l app-release.apk | grep '\.so'` 复核第 4 项清单,
用 `grep -rn 'BitmapFactory\|ImageDecoder' app/src/main/java` 复核第 1 项;
漏一个就整表重出。

跑出来的清单比预想长:解码调用点 23 处,其中 9 处没做任何降采样;静态单例 4 个里 2 个直接存了 Activity Context;缓存上限有 3 处是 1024 * 1024 * 64 这种硬编码,还有一处干脆是个没上限的 HashMap。

漏得最明显的是 .so。它只列了本模块 cpp/ 下编出来的两个,把几个 SDK 带进来的全漏了。我追了一句:

text 复制代码
第 4 项不要依据源码推断,改为直接读
`unzip -l app-release.apk | grep '\.so'` 的产物:
按 lib/<abi>/ 分组列出全部条目,标文件大小,
对每个条目在 docs/memory/baseline.md 里写「来源模块 / 是否自带对齐证据 / 待确认」三列。
不要猜来源,查不到就写待确认。

这条追问后来成了第 8 轮 16KB 检查的输入清单。体检这轮的价值不在「发现多少问题」,在把「哪些事实得靠脚本拿、不能靠读代码拿」分清楚。

第 2 轮 · 把当前版本的行为核准

SOLO Agent + 联网,让它自己拉官方文档,仍然不改工程文件。

text 复制代码
Goal: 产出 docs/memory/version-behavior.md,
      列出当前工具链与系统版本里所有会改变「内存账本」语义的行为。

Context: @docs/memory/baseline.md @app/build.gradle.kts
        @gradle/libs.versions.toml
        联网核对以下原文,不要凭记忆复述:
        - Android Developers「Manage your app's memory」
        - AOSP「Debugging ART Garbage Collection」(CC 默认版本、分代版本)
        - Android 13 行为变更里 ART / userfaultfd GC 那一段
        - Android Developers「Support 16 KB page sizes」
        - Android 16「Behavior changes: all apps」里 pageSizeCompat 那一段
        - ApplicationExitInfo / ActivityManager.getHistoricalProcessExitReasons 的 API reference

Requirements:
1. 表格列:Android 版本 / API level / 变化点 / 原文摘录 /
   对本项目哪一处现状有影响 / 需要做什么 / 文档链接。
2. 单列一节讲 Bitmap 像素归属的两次变迁(3.0、8.0),
   各给一段能说明「Java 堆没涨但 PSS 涨」的最小代码示例。
3. 明确回答:本项目 targetSdk 是多少,按 Play 的 16KB 要求
   属于「需对齐」还是「暂不受约束」;把规范里的强制日期
   与官方文档页面当前写的日期分两栏列,不要合并成一个数。
4. 明确回答:ApplicationExitInfo 在哪个 API level 可用,
   REASON_LOW_MEMORY / REASON_ANR / REASON_CRASH /
   REASON_CRASH_NATIVE 各自含义,getTraceInputStream 能拿到什么。
5. 列出「如果明年把 targetSdk 升到 36,现有 .so 与代码里
   哪几处会先出问题」,按风险排序,每条给迁移动作。

Constraints: C1 C2 C3。
本轮额外:每条结论带版本与出处;区分「原文明确要求」与「工程推断」;
不确定写「需人工核对」,不要补全版本号或默认值;不修改任何文件。

Validation: 我逐条比对官方文档,
重点看 CC 默认版本、分代起始版本、16KB 的两个日期;
任何一条对不上就整表重出。

这轮最值钱的不是表,是它把「同一个要求,两处写法不一致」摆到了明面上。我前面整理的版本对照表里记的是 2025-11-01 起对 target 35+ 新应用与更新强制 16KB 对齐,可我让它拉下来的官方页面现在写的是「自 2027 年 2 月 1 日起,不支持 16KB 的更新将无法发布」。我第一版让它自己取舍,它把两个数揉成了一句「2025 年起逐步强制」。打回重出:

text 复制代码
第 3 项不要合并。分两栏:
「我贴的那张对照表口径」写 2025-11-01 / target 35+ 新应用与更新;
「官方页面当前写法」写 2027-02-01 / 更新无法发布。
下面加一行说明:对齐这件事本身没变,变的是卡点日期的表述,
以我们发版流程里登记的那个为准。
不要把两个日期挑一个写成「事实」。

官方文档还给了几个此前没记下的确切数字,我抄进了表里当背景:16KB 页设备在系统内存压力下应用启动平均快 3.16%(个别应用最高 30%)、启动功耗降 4.56%、相机热启动快 4.48% / 冷启动 6.60%、系统启动快约 8%(950ms)。这些是官方自己的初始测试值,我在正文里只当背景引用,不拿来当本项目的收益。

ApplicationExitInfo 那栏它一次给全了:API level 30(Android 11)加入,getReason() 能区分 REASON_LOW_MEMORY、REASON_ANR、REASON_CRASH、REASON_CRASH_NATIVE、REASON_EXCESSIVE_RESOURCE_USAGE、REASON_FREEZER、REASON_SIGNALED、REASON_USER_REQUESTED 等一长串;getPss() / getRss() 给的是进程死前最后一次 PSS / RSS(单位 kB),getTraceInputStream() 在 ANR 和 native crash 时能拿到 tombstone 那类 trace。这几个方法后来直接决定了第 10 轮 CI 卡点怎么写。

第 3 轮 · 把「内存涨了」翻译成 PSS 分项账本

IDE 模式 / Chat,#Code 挂归档采样,产出账本与采集脚本,仍不改业务代码。

text 复制代码
Goal: 产出 docs/memory/pss-ledger.md 与 scripts/memory/collect_meminfo.sh,
      把线上「内存涨了 80MB」翻译成可逐列对照的账本。

Context: @docs/memory/baseline.md @docs/memory/version-behavior.md
        @docs/memory/evidence/v5.8.2/ @docs/memory/evidence/v5.9.0/
        两组归档里各有 Redmi Note 9 与 Pixel 4a 的 dumpsys meminfo 原始输出。
        本轮允许新增 scripts/memory/ 下的采集脚本,不改 app/ 下任何文件。

Requirements:
1. 写 collect_meminfo.sh:入参包名、采样次数、间隔秒数;
   每次输出带时间戳的 `dumpsys meminfo <pkg>` 完整片段;
   同时抓 TOTAL PSS、Java Heap、Native Heap、Graphics、
   Private Dirty、Private Clean、SwapPss Dirty 七列到 CSV。
2. pss-ledger.md 里做一张九个口径的对照表:
   口径 / 全称 / 量的是什么 / 谁在算 / 共享页怎么处理 /
   HPROF 是否可见 / 这次 80MB 里它占多少。
   九列必须齐:VSS、RSS、PSS、Private Dirty、Private Clean、
   Java Heap、Native Heap、Graphics、Swap(zRAM)。
3. 用 v5.8.2 与 v5.9.0 两组归档算出每个口径的差值,
   按绝对值降序排,明确写出「哪几项解释了这 80MB」。
4. 单列一节「为什么线上说涨了、本地 heap dump 看不出来」,
   结合 8.0 起 Bitmap 像素归属写清楚,配一张 Mermaid 链路图:
   对象分配 → 页 → PSS → lmkd 决策,并标出 HPROF 只截到哪一段。
5. 单列一节「SwapPss Dirty 涨了 31MB 该怎么读」:
   说清 zRAM 是压缩内存不是磁盘 swap、为什么会涨、对帧率与被杀概率的影响。

Constraints: C1 C2 C3。
本轮额外:所有差值来自归档实测,两个数相减即可,不做外推;
表里「这次占多少」一列若归档缺数据就写「待确认」,不要估。

Validation: 我在两台机上各跑 collect_meminfo.sh 20 次,
把算出的 TOTAL PSS 中位数与开篇 168MB / 248MB 对;
差值对不上 80MB 就整表重出。

第一版它只给了 TOTAL 一个数和一段「native 分配增加」的定性描述,我要的九口径拆表一个没有。打回去:

text 复制代码
Requirements 第 2、3 项没做到。重出:
表必须是九个口径逐列对照,不是一句总结;
每个口径给出 v5.8.2 / v5.9.0 两个实数和差值;
「这次占多少」写实测差值,归档里没有的列写「待确认」。
不要给「native 分配增加」这类无法核对的定性结论。

第二版出来才对上:Native Heap +38MB 左右、Graphics +22MB 左右、SwapPss Dirty +31MB,Private Dirty 只 +3.3MB,Java Heap 从 24MB 到 25MB。80MB 的去处第一次有了名字。SwapPss Dirty 那 31MB 我特意留着没删------它逼着我在第 7 轮把 zRAM 和缓存裁剪一起考虑,而不是只盯 Native Heap。

脚本这轮也有个小返工:它默认每 5 秒采一次,列表快速滑动的峰值根本采不到。我改成默认 500ms 可配,N4 那个「Graphics 峰值」才量得出来。这个默认值现在写在 baseline.md 的口径表里。

第 4 轮 · 把 80MB 逐项归因到具体文件

SOLO Agent + 联网(查 heapprofd 用法),#Code 挂账本与源码,产出归因报告,不改业务代码。

text 复制代码
Goal: 产出 docs/memory/attribution.md,
      把第 3 轮账本里的每一项差值,落到具体的源码位置或系统侧归属。

Context: @docs/memory/pss-ledger.md @docs/memory/baseline.md
        @docs/memory/evidence/v5.9.0/heapprofd_native.pb
        @app/src/main/java/com/example/health/report/ (模块 M 大图列表)
        @app/src/main/java/com/example/health/image/
        联网核对 Perfetto heapprofd 的 native heap 采样配置原文。

Requirements:
1. 把 Native Heap 的 +38MB 按 heapprofd 的调用栈聚合,
   列出前 8 个分配点,标文件:行号、累计字节、样本数;
   区分「解码缓冲」「SDK 内部缓冲」「音视频缓冲」三类。
2. 把 Graphics 的 +22MB 落到 gralloc / Surface / 纹理,
   说明哪些是应用主动持有、哪些由系统侧按 Surface 持有、
   哪些属于跨进程共享页按 PSS 分摊。
3. 把共享页那部分单独列一节:
   Zygote 预加载页、framework 资源页、.so、resources.arsc 各自
   摊到本进程头上的估算口径,说明为什么它随新起进程数变化。
4. 把 SwapPss Dirty 的 +31MB 与 Private Dirty 只 +3.3MB 放一起解释,
   写清 zRAM 压缩了哪类页、为什么这两个数不同步。
5. 给一张 Mermaid 图:从「某个 decode 调用」到
   native malloc → 匿名页 → PSS 分项 → lmkd 的完整链路。
6. 末尾列「归因不确定的部分」:
   哪些差值无法从现有采样落到代码,需要补采什么。

Constraints: C1 C2 C3。
本轮额外:共享页分摊不许整块算给某个 SDK,必须按进程数口径说明;
heapprofd 聚合结果带样本数,样本不足 20 的标「低置信」;
不修改 app/ 下任何文件。

Validation: 我把各分项差值相加与 TOTAL 的 80MB 核,
误差超过 5MB 就补采;
用 `dumpsys meminfo <pkg> | grep -E 'Native Heap|Graphics|TOTAL'` 复核分项数量级。

第一版归因有个偷懒的地方:它把 Graphics 那 22MB 和几个 .so 的共享页全记到了「新接的 SDK」头上,一句「第三方库占用」就完了。这等于把账推给别人,我拿它没法改代码。打回:

text 复制代码
第 3 项不成立。共享页不是「某个 SDK 占了多少」,
是按 PSS 口径摊到本进程的份额,随 fork 出来的进程数变。
重出时把下面三类分开写:
(a) Zygote / framework 预加载页,COW 共享,每个应用进程都有;
(b) 本进程独占 mmap 的 .so / resources.arsc;
(c) 跨进程 ashmem / gralloc buffer。
每类给「怎么从 dumpsys 的 Shared Dirty / Shared Clean 读出来」,
不要再出现「SDK 占用」这种没有口径的归因。

第二版里最有用的是 heapprofd 的调用栈聚合:+38MB 里约 29MB 落在模块 M 体检报告大图的解码缓冲,一屏三张加预读共六张 1080×1920 的 ARGB_8888 图,恰好对得上;剩下 9MB 是 SDK 里的音频波形缓冲,那部分我记进了「归因不确定」清单,留给第 12 轮复核。

分项相加是 38 + 22 + 31 - 部分重叠 ≈ 80MB,误差约 4MB,落在 5MB 容差内。这轮之后我第一次能指着一行代码说「这 29MB 是这里来的」,排查才算真正落地。

第 5 轮 · 把没断干净的引用链剪掉

IDE 模式 / Chat,#Code 挂源码与第 1 轮清单,这一轮开始允许改 app/ 下的文件。

text 复制代码
Goal: 产出 docs/memory/leaks.md 与泄漏防护落地代码,
      剪掉第 1 轮清单里所有指向 Activity 的强引用链。

Context: @docs/memory/baseline.md @docs/memory/attribution.md
        @app/src/main/java/com/example/health/base/
        @app/src/main/java/com/example/health/HealthApp.kt
        @app/src/main/java/com/example/health/report/
        期望改动文件(只准改这些):
        app/src/main/java/com/example/health/base/ActivityLeakGuard.kt
        app/src/main/java/com/example/health/HealthApp.kt
        app/src/main/java/com/example/health/report/ReportActivity.kt
        app/src/main/java/com/example/health/report/ReportViewModel.kt

Requirements:
1. 按第 4 轮归因里「引用链」那节,逐条落地修复:
   静态单例只存 applicationContext 或 WeakReference;
   Listener 注册与反注册配对(用 DefaultLifecycleObserver);
   自建 CoroutineScope 在 onDestroy 里 cancel(),能换 viewModelScope 就换;
   Handler 延迟任务在 onDestroy 里 removeCallbacksAndMessages(null);
   Compose 里 remember 捕获 Context 的改成 applicationContext 或 DisposableEffect 清理。
2. 写 ActivityLeakGuard,用 registerActivityLifecycleCallbacks 统一登记清理动作,
   单例不持有任何 Activity 强引用。
3. HealthApp 的 debug 变体开 StrictMode.setVmPolicy:
   detectActivityLeaks / detectLeakedClosableObjects /
   detectLeakedRegistrationObjects,penaltyLog,release 变体不生效。
4. leaks.md 里给一张表:现象 / GC Root / 引用路径 / 修复方式 / 复核命令;
   再给一张 Mermaid 引用链图标出剪断前与剪断后。
5. 每条修复附一个可复核的命令或计数,写清「修之前多少、修之后多少」。

Constraints: C1 C2 C3。本轮额外:
不引入 LeakCanary 或任何检测库;
不改 AndroidManifest;不新增进程;
所有 Context 相关改动不得改变现有 UI 行为。

Validation: 我按下面三条验:
`adb shell dumpsys meminfo com.example.health | grep -i activities`
旋转屏幕 10 次,Activities 计数回到 1;
logcat 里 StrictMode 不再出现 activity leak 条目;
N4 的 Graphics 峰值不因这轮改动上升。
三条有一条不过就回滚本轮改动。

这轮它第一版又提了 LeakCanary,说「集成后能自动 dump 引用链」。被 C1 挡掉后我补了一句追问,把思路留下、把库去掉:

text 复制代码
检测思路保留,落地全部换成平台手段:
用 WeakReference + ActivityLifecycleCallbacks + StrictMode.detectActivityLeaks()
实现「销毁后仍可达就打日志」,需要看清链子时由我自己
调 Debug.dumpHprofData() 导出到 Android Studio Memory Profiler 看支配树。
不要写任何第三方依赖的集成步骤或 gradle 坐标。
第 4 项的复核命令里也不许出现第三方工具。

返工之后还有一处我自己判断错了:它把 ReportViewModel 里一个 MainScope() 直接换成了 viewModelScope,但那个 scope 里有个跨页面的上传任务,ViewModel 一销毁就被取消,上传会断。我让它退回自建 scope、只在 onCleared() 里 cancel(),并把这个取舍写进 leaks.md。「生命周期清理」不等于「一律挂到最短的那个生命周期上」,这处返工后来被我写进了第 12 轮的复核清单。

验收那条 Activities 计数最直观:修之前旋转十次从 1 涨到 11,修之后稳定回到 1。logcat 里 StrictMode 那行也干净了。N4 没变,说明这轮只动了引用、没动分配量,符合预期。

第 6 轮 · 把 Bitmap 与图片管线重做一遍

IDE 模式 / Chat,#Code 挂归因报告与图片相关源码,产出管线代码。

text 复制代码
Goal: 产出 docs/memory/image-pipeline.md 与 BitmapDecode 工具、
      ReportImageLoader(模块 M 大图加载器),
      把第 4 轮归因到的 29MB 解码缓冲降下来。

Context: @docs/memory/attribution.md @docs/memory/pss-ledger.md
        @app/src/main/java/com/example/health/image/
        @app/src/main/java/com/example/health/report/ReportAdapter.kt
        @docs/memory/evidence/v5.9.0/heapprofd_native.pb
        期望改动文件:
        app/src/main/java/com/example/health/image/BitmapDecode.kt
        app/src/main/java/com/example/health/image/ReusableBitmapPool.kt
        app/src/main/java/com/example/health/report/ReportImageLoader.kt
        app/src/main/java/com/example/health/report/ReportAdapter.kt

Requirements:
1. BitmapDecode 实现两段式解码:
   inJustDecodeBounds 读尺寸 → 按目标 View 宽高算 inSampleSize → 真解码;
   提供 decodeSampled(...) / decodeRegion(...) / decodeHardware(...) 三个入口。
2. inBitmap 复用池按 allocationByteCount 分桶;
   明确处理 4.4 前后差异(4.4 起只要求复用图 byte count 不小于所需),
   复用失败安静退回普通解码,不抛异常。
3. 只显示的图用 Bitmap.Config.HARDWARE,并在工具里注释清楚
   「不可 getPixel / 不可 eraseColor / 不可作为 inBitmap 源」三个限制;
   需要读像素的路径走 RGBA_8888 或 F16 的普通解码。
4. 动图(GIF / 动 WebP)与超大病理切片图分别走
   ImageDecoder + AnimatedImageDrawable、BitmapRegionDecoder,
   不再自己缓存整帧序列或整张解码。
5. 图片内存缓存统一改成按 getMemoryClass() 计算的硬上限 LruCache,
   去掉第 1 轮清单里那三处硬编码与一处无上限 HashMap。
6. image-pipeline.md 里给「管线改造前 / 后」的单图内存对照,
   以及一张 Mermaid 图:请求 → 尺寸探测 → 采样/复用/HARDWARE 分支 → 缓存 → 显示。

Constraints: C1 C2 C3。本轮额外:
只用平台 API / AndroidX,不引图片库;
HARDWARE 分支不得用于任何后续要改像素的场景;
不改动网络层与数据源,只改解码与缓存。

Validation: 我在 Pixel 4a 上用 collect_meminfo.sh 采 N4(Graphics 峰值)
前后各 10 次,单张图按 8MB → 目标 1.1MB 左右核;
N1 稳态 TOTAL PSS 不得比 v5.9.0 更高;
`Debug.dumpHprofData()` 导出后看 Bitmap 壳数量没有异常堆积。
不达标就回到本轮起点。

第一版有两个错,都是我没在提示词里把版本差异写死的锅。一是 inBitmap 复用的判断按 3.0 那套「尺寸必须完全一致」写,在 8.0 的机器上白白错过一堆复用机会;二是它把列表里每一张图都用了 HARDWARE,而报告页有个「长按取色」的功能要 getPixel(),一进去就崩。我合并成一条追问打回:

text 复制代码
两处返工:
(1) inBitmap 复用的可行判断按 Build.VERSION.SDK_INT >= KITKAT 分支:
    4.4 起只比较 reusable.allocationByteCount >= 所需字节,
    不再要求 width/height 完全相等;4.4 之前才按尺寸完全一致。
    把两段判断都写出来,并注明依据版本。
(2) HARDWARE 只允许用在「只显示、不读像素」的分支。
    ReportActivity 里有个长按取色要 getPixel(),那条路径必须走普通解码;
    在 decodeHardware 的注释和调用点都标清这个边界。
    给一张表列出本项目里哪些入口用 HARDWARE、哪些不用。

重出之后单张图从约 8MB 降到 1.1MB,N4 的 Graphics 峰值从 61MB 掉到 24MB。N1 稳态 TOTAL PSS 从 248MB 回到 191MB 上下------注意这时还没到 168MB,因为共享页和 native 侧那部分还没动,剩下的留给第 7、8 轮。

这轮也顺手把 ImageDecoder 用上了。模块 M 的体检报告里有几张动 WebP 封面,旧代码自己一帧帧解进一个 List<Bitmap>,等于把整段动画常驻内存;换成 AnimatedImageDrawable 之后,native 侧不再攒全序列。这条改动在账本上体现为 Native Heap 少了约 6MB,比预想的多。

第 7 轮 · trim 分级裁剪与内存预算

SOLO Agent + 联网(核准 ComponentCallbacks2 各 level 语义),产出预算与裁剪代码。

text 复制代码
Goal: 产出 docs/memory/memory-budget.md、MemoryBudget 与 TrimMemory 监听器,
      把缓存、预读、native 解码池按系统内存压力分档裁剪。

Context: @docs/memory/pss-ledger.md
        @.trae/rules/memory.md 里那张 trim 分级状态图
        @app/src/main/java/com/example/health/image/ReusableBitmapPool.kt
        @app/src/main/java/com/example/health/base/HealthCache.kt
        联网核对 ComponentCallbacks2 各常量与
        ActivityManager.getMyMemoryState / getMemoryClass 的原文。
        期望改动文件:
        app/src/main/java/com/example/health/base/MemoryBudget.kt
        app/src/main/java/com/example/health/base/TrimMemory.kt
        app/src/main/java/com/example/health/HealthApp.kt(注册处)

Requirements:
1. MemoryBudget 按 getMemoryClass() 算基准额度,
   给出「满额 / 半额 / 1/4 / 1/8 / 零」五档缓存上限;
   提供 currentTier() 与 maxCacheBytes() 两个只读入口。
2. TrimMemory 实现 ComponentCallbacks2.onTrimMemory,
   按 UI_HIDDEN / BACKGROUND / MODERATE / COMPLETE /
   RUNNING_LOW / RUNNING_CRITICAL 分级映射到五档;
   每级写清「释放什么、不释放什么」。
3. 分级动作分层:
   UI_HIDDEN 释放 UI 相关大图与 Activity 级资源;
   BACKGROUND 砍缓存到 1/4、停预读;
   MODERATE 砍到 1/8、清 native 解码池;
   COMPLETE 清零、只保留可恢复的最小状态;
   RUNNING_* 不动磁盘缓存、只停预读与降采样档位。
4. 明确区分「进程还活着、按 level 裁」与
   「进程已被 lmkd 杀、下次冷启动读 ApplicationExitInfo」两件事,
   不要把退出原因上报写进 onTrimMemory。
5. 给一张 Mermaid 状态图(与 rules 里那张一致,可引用不复画),
   并给一张表:level / 常量值 / 对应档位 / 释放动作 / 复核命令。
6. memory-budget.md 写清 N2(后台 10 分钟残留 PSS)的目标值与测量口径。

Constraints: C1 C2 C3。本轮额外:
不在 onTrimMemory 里做耗时 IO 或同步阻塞;
不调用 System.gc();不主动 finish Activity;
不改动前台可见时的缓存行为,避免切回前台重解码导致首帧变慢。

Validation: 我按 Home 键后采 N2,
要求 10 分钟残留 PSS 明显低于不裁剪的版本;
再测「切回前台首帧时间」不得比 v5.8.2 劣化超过一帧;
`adb shell dumpsys meminfo <pkg>` 在 COMPLETE 后看不到缓存 Bitmap 壳堆积。

第一版图省事,所有 level 一律「清空全部缓存」。结果切回前台时六张图全部重新解码,首帧比改造前慢了两帧多,N2 是降了,体验反而退了。这正好撞上第 6 轮那句「切回前台别重解码」。我打回让它按状态图分档:

text 复制代码
Requirements 第 3 项重写。禁止「所有 level 一律清空」。
按 .trae/rules/memory.md 的状态图分五档,
每档只释放该档该放的东西:
前台档保持满额,切后台分档递减,COMPLETE 才清零。
加一条验收:切回前台首帧时间相对 v5.8.2 不得劣化超过一帧,
劣化就把 BACKGROUND / MODERATE 两档的裁减力度往回收一档。
把「为什么不全清」写进 memory-budget.md。

第二版把档位分对了:N2 从 214MB 降到 96MB,切回前台首帧只多了 3ms,肉眼无感。这轮还有个附带结论------RUNNING_LOW / RUNNING_CRITICAL 这两级是「进程还在前台、系统已经吃紧」,此时停预读、把降采样档位调激进一点就够,千万别动磁盘缓存,动了下次真要重下。

onTrimMemory 和 ApplicationExitInfo 的分工也是这轮定死的:前者是「还活着,自己裁」,后者是「已经死了,下次启动读死因」。把两件事混在一处,是第 10 轮 CI 卡点最容易写错的地方,我在提示词第 4 项里提前堵了。

第 8 轮 · 把 16KB 页对齐查到底

SOLO Agent + 联网(核对 16KB 官方要求),产出检查脚本与对齐报告。

text 复制代码
Goal: 产出 scripts/memory/check_elf_alignment.sh 与
      docs/memory/page-size-16k.md,逐个 .so 验 16KB ELF 对齐。

Context: @docs/memory/baseline.md(第 1 轮追问后的 .so 清单)
        @docs/memory/version-behavior.md
        @app/build.gradle.kts @gradle/libs.versions.toml
        @app/src/main/cpp/ @app/src/main/AndroidManifest.xml
        联网核对 Android Developers「Support 16 KB page sizes」原文、
        check_elf_alignment.sh 的用法、Android 16 pageSizeCompat 说明。
        期望改动文件:
        scripts/memory/check_elf_alignment.sh(新增)
        docs/memory/page-size-16k.md(新增)
        app/build.gradle.kts(如需补 packaging / NDK 参数)

Requirements:
1. 写 check_elf_alignment.sh:入参 APK 路径;
   unzip 到临时目录,遍历 lib/arm64-v8a/ 与 lib/x86_64/ 下全部 .so,
   对每个用 `readelf -l` 取 LOAD 段的 Align,
   Align 为 0x4000(16384)或更大的输出 ALIGNED,否则 UNALIGNED;
   末尾汇总 ALIGNED / UNALIGNED 计数与非零退出码。
2. 对清单里每个 .so 标注来源模块 / 是否自带对齐证据 /
   是否需要重新用 NDK 编译,查不到写「待确认」。
3. 扫源码里所有硬编码 4096 / PAGE_SIZE / 0x1000 的地方,
   逐条给改成运行时 `getpagesize()` 的迁移建议(C/C++ 侧用
   `sysconf(_SC_PAGESIZE)` 或 `getpagesize()`,Java/Kotlin 侧不假设页大小)。
4. 单列一节讲 Play Console 的 App Bundle Explorer 怎么复核对齐,
   以及 Android 16 的 4KB 兼容模式:
   什么条件下自动进入、会弹什么对话框、android:pageSizeCompat
   怎么写能不弹、兼容模式的代价是什么。
5. 明确写出本工程的 targetSdk 与 16KB 要求的关系,
   日期按第 2 轮定下的两栏口径写,不要合并成一个数。
6. 给「改了哪些构建参数」的清单:
   Build-Tools / NDK 版本要求、ELF 段对齐的链接参数、
   zipalign 相关注意事项。

Constraints: C1 C2 C3。本轮额外:
不许用编译期 PAGE_SIZE 宏常量代替运行时查询;
不许建议只靠兼容模式绕过对齐;
不改动业务代码,只动构建脚本与新增脚本/文档。

Validation: 我对 release APK 跑 check_elf_alignment.sh,
要求所有 arm64-v8a / x86_64 的 .so 全 ALIGNED;
在 Android 15 的 16KB 模拟器系统映像上冷启动模块 M 不崩;
`readelf -l <某个 .so>` 手工复核三个样本的 Align。

第一版漏了 SDK 带进来的 .so,只查了本模块 cpp/ 编出来的两个------正是第 1 轮追问补上的那批。而且它给 C++ 侧的迁移建议里写了一句「把 PAGE_SIZE 宏改成 16384」,这等于把硬编码从 4096 换成 16384,在 4KB 设备上照样错。两条一起打回:

text 复制代码
两处返工:
(1) 检查范围不是「本模块 .so」,是 APK 里 lib/**/*.so 全量,
    直接以 `unzip -l <apk> | grep '\.so'` 的输出为准,
    和第 1 轮 baseline.md 的清单逐条对,漏一条就重出。
(2) 禁止把 PAGE_SIZE 宏改成 16384。正确做法是删掉所有编译期页大小假设,
    C/C++ 用 `getpagesize()` / `sysconf(_SC_PAGESIZE)` 运行时查询,
    Kotlin/Java 侧不写任何页大小常量;
    把源码里 4096 / 0x1000 / PAGE_SIZE 的命中逐条列成迁移表。

重出之后清单里 11 个 .so 有 3 个 UNALIGNED,全来自两个 SDK。能重编的用新 NDK 重编了,剩下的找 SDK 厂商要了对齐版。脚本最后定成对每个 LOAD 段取 Align,只要有一个小于 0x4000 就判 UNALIGNED,汇总行打印计数并以非零码退出,方便第 10 轮直接接进 CI。

page-size-16k.md 里我特意留了一节讲兼容模式的代价:Android 16 检测到 4KB 对齐的页会自动进兼容模式并给用户弹一个对话框,清单里写 android:pageSizeCompat 能让它不弹,但官方原话是「为了最佳性能、可靠性与稳定性,应用仍应做 16KB 对齐」。兼容模式是给来不及改的旧包兜底,不是方案。这轮没动 pageSizeCompat,把它列进「已知不修」并在第 11 轮给了理由。

第 9 轮 · 低内存机型上把四个数重跑一遍

IDE 模式 / Chat,#Code 挂全部改动与采集脚本,产出回归报告,不再改业务代码。

text 复制代码
Goal: 产出 docs/memory/regression.md 与
      scripts/memory/lowmem_matrix.sh,
      在低内存机型矩阵上把 N1~N4 全量回归。

Context: @docs/memory/baseline.md @docs/memory/pss-ledger.md
        @docs/memory/memory-budget.md @docs/memory/image-pipeline.md
        @docs/memory/leaks.md @docs/memory/page-size-16k.md
        @scripts/memory/collect_meminfo.sh
        期望改动文件:
        scripts/memory/lowmem_matrix.sh(新增)
        docs/memory/regression.md(新增)

Requirements:
1. 机型矩阵至少覆盖:
   2GB Android 10(Go 版)、3GB Android 11、
   4GB Redmi Note 9 / API 30、6GB Pixel 4a / API 33、
   一台 Android 15 / API 35 的 16KB 页设备或模拟器。
   每台标注 RAM、API level、是否 16KB 页、是否低内存机型。
2. lowmem_matrix.sh 对每台机依次:
   冷启动 → 进模块 M 详情页静止 30s 采 N1 →
   按 Home 键 10 分钟采 N2 → 回前台采首帧 →
   快速滑动大图列表采 N4;
   每台重复 20 次,输出中位数与 P90。
3. 用 stressapptest 或系统低内存压力手段制造内存压力,
   记录模块 M 在压力下被杀的次数与
   ApplicationExitInfo 里的 REASON_LOW_MEMORY 占比(N3)。
4. regression.md 给一张 N1~N4 的「v5.8.2 / v5.9.0 / 本版」三列对照表,
   每格带机型与样本量,写清测量口径。
5. 单列一节「仍然退步的项」:
   哪些数没达标、在哪台机上没达标、误差范围多大,不许只报好看的。
6. 明确写「无法在本环境复现的部分」:
   真机编译与测量是我在自己机器上跑的记录,不要求复现指令。

Constraints: C1 C2 C3。本轮额外:
样本量不足 20 的机型单独标注,不并入中位数;
所有数字带机型与口径;不修改 app/ 下任何文件。

Validation: 我逐台核四个数的中位数,
要求低内存机型(2GB / 3GB)上 N1 不高于 v5.8.2 的 168MB 口径太多、
N2 明显低于 v5.9.0、N3 回落到灰度前 6‰ 附近、
N4 不高于第 6 轮后的 24MB;
任何一台机出现「优化后被杀反而更多」就回滚对应改动。

第一版它偷懒,只在 Pixel 4a 和 Redmi Note 9 上跑了一轮,2GB 那台 Go 机型和 16KB 模拟器直接没进矩阵。可这次翻车本来就是低内存机型先报出来的,把最该测的机漏掉等于没测。打回:

text 复制代码
矩阵缺 2GB Android 10 Go 与 16KB 页设备,这两台正是问题机型,不能省。
补跑并把每台样本量补到 20 次;
2GB 机上要单独记录 stressapptest 加压期间
模块 M 被杀次数与 REASON_LOW_MEMORY 占比,
不能拿 6GB 机的数据外推。
regression.md 里「仍然退步的项」这一节必须非空的,
找不到退步项就说明测量有问题,回头查口径。

补跑的结果让我改了一处判断:2GB Go 机上 N1 并没能压回 168MB,落在 179MB,比 v5.8.2 高 11MB。原因是这台机 RAM 太小,Zygote 共享页摊到本进程的份额、加上系统侧的图形缓冲,本身就比 6GB 机高,属于机型基差,不是代码退步。我把这一格如实写进「仍然退步的项」,并注明「N1 阈值按机型分别定,不跨机型比绝对值」------这条后来直接改掉了第 10 轮 CI 卡点的写法。

N3 是这轮最踏实的数:灰度时每千次冷启动 21 次被杀,回归矩阵里回落到 7 次,和灰度前的 6 次只差 1 次,落在误差范围内。2GB 机上加压时被杀次数仍偏高,我没藏,写进了报告,理由是该机可用 RAM 本就贴着系统底线。

第 10 轮 · 把 PSS 和退出原因钉进 CI

IDE 模式 / Chat,#Code 挂回归数据与全部脚本,产出门禁。

text 复制代码
Goal: 产出 .github/workflows/memory-check.yml、
      scripts/memory/verify_memory.sh、
      docs/memory/baseline.json 与 ExitReasonReporter 落地代码,
      把 N1~N4 与 REASON_LOW_MEMORY 占比做成构建门禁。

Context: @docs/memory/baseline.md @docs/memory/regression.md
        @docs/memory/pss-ledger.md @scripts/memory/collect_meminfo.sh
        @scripts/memory/check_elf_alignment.sh
        @app/src/main/java/com/example/health/base/
        期望改动文件:
        .github/workflows/memory-check.yml(新增)
        scripts/memory/verify_memory.sh(新增)
        docs/memory/baseline.json(新增)
        app/src/main/java/com/example/health/base/ExitReasonReporter.kt(新增)

Requirements:
1. ExitReasonReporter 在冷启动时调
   ActivityManager.getHistoricalProcessExitReasons(pkg, 0, 0),
   按 reason 聚合,重点上报 REASON_LOW_MEMORY 与
   REASON_ANR / REASON_CRASH / REASON_CRASH_NATIVE;
   ANR 与 native crash 时把 getTraceInputStream() 落到本地文件;
   API 30 以下安静降级,不崩、不打日志刷屏。
2. baseline.json 存 N1~N4 的基线中位数与
   「按机型分别定」的阈值结构,允许相对上版浮动的比例,
   不写死绝对值;16KB 对齐结果也作为一个布尔字段。
3. verify_memory.sh 读 baseline.json,跑
   collect_meminfo.sh / check_elf_alignment.sh,
   逐项比对四个数与对齐结果,任一超阈值非零退出并打印差值。
4. memory-check.yml 定义 job:
   构建 release APK → 跑 verify_memory.sh →
   跑第 1 轮体检脚本扫新增宽泛静态引用 →
   归档本轮采集证据到按版本+commit 命名的目录。
5. 门禁要能区分「代码退步」与「机型基差」:
   按 baseline.json 里每机型自己的阈值判,不跨机型比绝对值。
6. 单列一节讲「故意塞一张无降采样大图」的演练:
   门禁必须失败,且失败信息指向 N4 或 N1 的具体差值。

Constraints: C1 C2 C3。本轮额外:
ExitReasonReporter 只用平台 API;不改 AndroidManifest;
不动 ApplicationExitInfo 的原始语义;不引入上报 SDK;
CI 里不上传任何真实用户数据。

Validation: 我在分支上故意把 decodeSampled 的采样关掉,
要求 memory-check.yml 在 N4 这一项失败、
失败日志给出「实测 / 阈值 / 差值」三行;
再把改动撤回,门禁恢复通过。
两头都验过才算这轮完成。

第一版门禁把四个数写成固定绝对值(N1 不超过 190MB 之类),第 9 轮那台 2GB 机的 179MB 一上来就误报,6GB 机反倒松得没边。我把第 9 轮那条结论直接塞进追问:

text 复制代码
baseline.json 不许写全局绝对阈值。
按机型分组存各自的中位数与允许浮动比例,
verify_memory.sh 比对时按当前机型取对应条目;
机型基差(如 2GB Go 机 N1 天然偏高)写进注释,
不要用一个数卡所有设备。
把第 9 轮 regression.md 里那张三列对照表作为 baseline.json 的数据源。

ExitReasonReporter 这轮也有个边界要守住:getHistoricalProcessExitReasons 是 API 30 才有的,2GB 那台 Android 10(API 29)上必须安静降级,不能因为拿不到死因就把启动流程拖崩。提示词第 1 项专门写了这条,实现里用一个 Build.VERSION.SDK_INT >= R 判断兜住。

演练那一步我照做了:关掉降采样后,N4 从 24MB 跳到 63MB,门禁在 verify_memory.sh 的 N4 项非零退出,日志里三行差值清清楚楚。撤回改动后恢复通过。这比「看一眼 yml 觉得写对了」踏实得多。

第 11 轮 · 换个身份,自己挑自己

SOLO Agent,让它脱离「实现者」身份,扮系统侧与复核者,只挑问题。

text 复制代码
Goal: 产出 docs/memory/adversarial-review.md,
      站在 lmkd / 系统侧 / 低内存机型用户的立场,
      把前 10 轮的所有改动挑一遍刺。

Context: @docs/memory/ 下全部报告
        @app/src/main/java/com/example/health/ 下全部本轮改动
        @scripts/memory/ 下全部脚本
        @.github/workflows/memory-check.yml
        @docs/memory/baseline.json

Requirements:
1. 只列问题,不列优点,不写「整体合理」这类话。
   每条按「现象 / 触发条件 / 影响哪个数 / 复现命令 / 建议动作 / 严重度」六列。
2. 至少从下面六个视角各出一条:
   (a) lmkd:按 PSS 与 oom_adj_score 排回收时,本版改动会不会让
       后台缓存进程反而更容易被选中;
   (b) zRAM:SwapPss Dirty 涨的那部分在压缩/解压时有没有被低估;
   (c) 16KB 兼容模式:没对齐的旧包在 Android 16 上会怎样;
   (d) getMemoryClass:把缓存额度按它算,在低内存机型上是否过小;
   (e) HARDWARE:只读图分支是否还有漏网的读像素调用;
   (f) ApplicationExitInfo:API 30 以下降级后 N3 是否有盲区。
3. 单列「前面哪一轮的判断最可能错」,给出理由与复核命令。
4. 对每条建议动作标注「必须修 / 已知不修」,
   已知不修的要写清代价与触发条件,不许省略。

Constraints: C1 C2 C3。本轮额外:
不修改任何文件,只出报告;
每条问题必须给可复现的命令或数据,空泛的「建议优化」不算;
不许为了凑数编问题,查不到就写「本视角未发现」。

Validation: 我逐条复现,
每条要么落到一次真实改动、要么写进「已知不修」并给理由;
adversarial-review.md 里「必须修」的条目全部关闭后才进第 12 轮。

第一次它交回来一篇温和的总结,通篇「整体设计合理,建议补充监控」。直接打回,把身份钉死:

text 复制代码
不要以协作者身份写总结。你的身份是系统侧的复核者,
任务是从这些改动里找出会让低内存机型被杀变多的地方。
重写:只列问题,每条带复现命令;
不写任何肯定句、不写「建议补充监控」这类泛泛的话;
查不到问题的视角就写「本视角未发现」,不要凑数。
第 3 项「前面哪一轮最可能错」必须点名到具体轮次。

重写后挑出七条,其中三条真改了:一是第 5 轮我把一个本该 viewModelScope 的上传作用域退回自建 scope 时,忘了在 onCleared() 里 cancel() 之外还要清掉它持有的回调,低内存机型上会拖着一个 Listener;二是第 7 轮 MemoryBudget 按 getMemoryClass() 的 1/8 算最低档,在 2GB Go 机上 getMemoryClass() 只有 96MB 左右,1/8 才 12MB,缓存几乎等于没有,切回前台重解码反而更频繁;三是第 8 轮那个「已知不修」的 pageSizeCompat,它指出我们发版流程里已经登记了 16KB 要求,留着兼容模式等于给自己留了条会绕过门禁的路。

第 3 项它点名第 7 轮最可能错,理由是「分档削减缓存能降 N2,但没量过切回前台的重解码次数」。我补采了一次:2GB 机上切回前台重解码次数确实比 6GB 机高一截,于是把最低档从 1/8 调成 1/4 并单独给低内存机型留了个下限,N2 只回弹了 6MB,首帧却稳了。这条返工和开篇那 80MB 是同一类错误------只看一个维度的数,忘了系统是在多个维度上一起算账的。

第 12 轮 · 归档,并给未来版本留个复核入口

IDE 模式 / Chat,产出归档规范与未来复核清单,不改动已定稿代码。

text 复制代码
Goal: 产出 docs/memory/archive.md、docs/memory/future-review.md、
      docs/memory/pipeline.md、docs/memory/rollback.md,
      把这次的账本、证据、决策与回滚方式归档。

Context: @docs/memory/ 下全部报告与证据目录
        @scripts/memory/ @.github/workflows/memory-check.yml
        @docs/memory/baseline.json

Requirements:
1. archive.md 定归档结构:
   docs/memory/archive/<versionName>-<commit>/ 每层一个目录,不覆盖;
   目录内固定放 baseline.md / pss-ledger.md / attribution.md /
   regression.md / baseline.json / evidence/ 六项;
   同名目录已存在时任务直接失败。
2. pipeline.md 把 12 轮流水线与每轮的验收命令串成一张表,
   标出「自动化」与「必须人工」两类;
   开头写明当前 Trae 的模式情况:
   2026 年 5 月 SOLO Builder / SOLO Coder 已合并为 SOLO Agent,
   旧教程里的 Builder 入口已不存在,本流水线按 SOLO Agent 与
   IDE 模式 / Chat 两种模式写。
3. future-review.md 列「下次升级要复核什么」,至少覆盖:
   targetSdk 升到 36、AGP 大版本升级、NDK 重编后的 .so 对齐、
   16KB 卡点日期表述变化、ART GC 再演进、
   ApplicationExitInfo 新增 reason 常量、
   getMemoryClass 在新机型上的取值变化。
   每条写「复核哪个文件 / 跑哪条命令 / 什么结果算要改」。
4. rollback.md 给逐轮回滚粒度:
   每轮改了哪些文件、单独回滚哪一轮不影响其余轮次、
   哪些改动有关联必须一起回滚(如 MemoryBudget 与 TrimMemory)。
5. 末尾给一段「这次最可能不达标的一项」的自查记录,
   写清哪一项离硬性线最近、准备怎么补,不要写「全部达标」。
6. 全文自查命令单独列在 archive.md 里,按第七节口径跑一遍。

Constraints: C1 C2 C3。本轮额外:
不修改 app/ 下已定稿代码;
归档目录名必须带 versionName + commit;
future-review.md 里不许把未来版本行为写成当前现状。

Validation: 我按 archive.md 的自查命令跑一遍,
核对行数、字节数、代码围栏、章节数、轮次数、锚点残留、
繁体字与黑名单词;
再 `ls docs/memory/archive/` 确认目录名带 commit。
任何一项不达标就补写对应章节,不靠删内容蒙混。

归档目录第一版没带 commit,叫 5.9.1/。这和混淆那篇踩过的坑一模一样:同名目录被下次构建覆盖,evidence 就串版本了。打回一句就够:

text 复制代码
归档目录名改成 <versionName>-<commit>,同名已存在时直接失败,
不要静默覆盖。archive.md 里把这条写成硬约束,
并在 pipeline.md 的「必须人工」一栏里加一行复核目录名。

future-review.md 我后来自己又补了一条:官方页面那个 16KB 卡点日期已经从规范里的 2025-11-01 变成页面上的 2027-02-01,说明这类日期会动,下次复核不能只信某一版文档,要把「对齐这件事」和「哪天卡」分开看。这条没写进任何结论,只放在复核清单里。

第 5 项让我写「最可能不达标的一项」,我照实写了:代码围栏数量和总字节数最悬,12 轮里提示词块多、正文叙述密,但定稿代码与踩坑表还没补,补的时候容易顾着码字忘了围栏成对。后来补定稿代码那几节时,确实是围栏数先撞线、字节数后够的。

十二轮跑完,我把每轮的返工点和验收命令归了一份台账,按轮次存着。放这儿比散在聊天记录里好查:哪一轮拿什么数卡的、哪条输出对不上就整表重出、哪个模式当时还叫别的名字,翻一遍就能对上。后面每次升级 AGP 或 targetSdk,我都是先翻这份台账,再决定要不要重跑某几轮。

轮次 模式 产出文件 验收依据 返工
1 IDE / Chat + #Code baseline.md unzip -l 复核 .so 清单、grep 复核解码点 1
2 SOLO Agent + 联网 version-behavior.md 官方文档逐条比对,两个日期分栏 1
3 IDE / Chat pss-ledger.md、collect_meminfo.sh 两台机各 20 次,TOTAL 168 / 248 对齐 2
4 SOLO Agent + 联网 attribution.md 分项之和与 80MB 误差不超过 5MB 1
5 IDE / Chat leaks.md、ActivityLeakGuard.kt 旋转 10 次 Activities 计数回到 1 2
6 IDE / Chat image-pipeline.md、BitmapDecode.kt N4 与单图 8MB→1.1MB 2
7 SOLO Agent + 联网 memory-budget.md、MemoryBudget / TrimMemory N2 与切回前台首帧 1
8 SOLO Agent + 联网 check_elf_alignment.sh、page-size-16k.md 全量 .so ALIGNED、16KB 模拟器冷启动 2
9 IDE / Chat regression.md、lowmem_matrix.sh N1~N4 五机矩阵各 20 次 1
10 IDE / Chat memory-check.yml、baseline.json 关降采样触发门禁失败、撤回后通过 1
11 SOLO Agent(换身份) adversarial-review.md 七条刺逐条复现并关闭 1
12 IDE / Chat archive.md 等四份 自查命令、归档目录带 commit 1

模式上一共三种用法:IDE 模式 / Chat 挂 #Code 做只读分析和改文件,7 轮;SOLO Agent 开联网核准版本行为、做归因、查 trim 语义与 16KB 要求,4 轮;第 11 轮也是 SOLO Agent,只是让它换身份当复核者。返工一共 16 次,第 3、5、6、8 轮各返了两次,原因就两类:第一版只给了定性结论没给可核对的数,或者漏了最该测的那台机。第 12 轮归档目录名没带 commit,和混淆那篇踩过的是同一个坑,台账里单独标了一行。

每轮实际敲的验收,按轮次留一份底

第 1 轮 · 体检

text 复制代码
$ unzip -l app-release.apk | grep '\.so'
  120344  2026-03-10 11:02   lib/arm64-v8a/libhealth.so
   88211  2026-03-10 11:02   lib/arm64-v8a/libcrypto_sdk.so
   61290  2026-03-10 11:02   lib/arm64-v8a/libreport_sdk.so
  ... 共 11 条
$ grep -rn 'BitmapFactory.decode\|ImageDecoder' app/src/main/java | wc -l
23

第一版只列了本模块 cpp/ 编出来的两个 .so,把 SDK 带进来的三个全漏了,追问之后按 APK 的 lib/ 全量列成 11 项,每个标「来源 / 对齐证据 / 待确认」。解码点 23 处里 9 处没做降采样,缓存上限三处硬编码、一处无上限。这轮不改任何文件,价值在把「哪些事实得靠脚本拿、不能靠读代码拿」分清楚,后面第 8 轮的检查清单直接用了这份。

第 2 轮 · 版本核准

text 复制代码
$ ./gradlew :app:dependencies --configuration releaseRuntimeClasspath \
    | grep -E 'com.android.tools.build:gradle'
+--- com.android.tools.build:gradle:8.7.3
$ grep -nE 'compileSdk|targetSdk|minSdk' app/build.gradle.kts
  compileSdk = 35
  targetSdk  = 35
  minSdk     = 23

target 35 落在 16KB 要求范围内,这条直接决定第 8 轮要不要动 NDK。验收时最较真的是两个日期:第一版把 2025-11-01 和官方页面上的 2027-02-01 揉成「2025 年起逐步强制」,打回重出,分两栏写死,下面加一行说明「对齐没变,变的是卡点日期表述」。CC 从 Android 8 默认、Android 10 起分代、13 的 userfaultfd,ApplicationExitInfo 的 API 30 与 8 个 reason 常量,都是这轮一次给全的;官方文档任一条对不上就整表重出。

第 3 轮 · PSS 账本

text 复制代码
$ bash scripts/memory/collect_meminfo.sh com.example.health 20 0.5
done -> docs/memory/evidence/20260312-2140/meminfo.csv
$ python3 - <<'PY'
# 两台机各 20 次,取 TOTAL PSS 中位数
PY
v5.8.2  TOTAL PSS 中位数 168104 KB
v5.9.0  TOTAL PSS 中位数 248037 KB
差值 79933 KB

79933KB 约 80MB,和开篇那两行输出对上了。第一版只给一个 TOTAL 加一句「native 分配增加」,九口径拆表一个没有,退回;第二版才把 Native Heap +38MB、Graphics +22MB、SwapPss Dirty +31MB、Private Dirty 只 +3.3MB、Java Heap 24→25MB 摆齐。采集脚本默认间隔也从 5 秒改成 0.5 秒,不然列表滑动的 Graphics 峰值根本采不到,N4 这个数就量不出来。

第 4 轮 · 逐项归因

text 复制代码
$ adb shell dumpsys meminfo com.example.health \
    | grep -E 'Native Heap|Graphics|TOTAL'
  Native Heap    79310    79240        0     3120
  Graphics       40120    40120        0        0
        TOTAL   248037   151204     9872    41240
$ heapprofd 调用栈聚合(前 3 条,样本数 > 20)
  29.1MB  ReportImageLoader.decode   ReportAdapter.kt:88
   9.0MB  SdkAudioBuffer.alloc       (归因不确定,留第 12 轮)
   6.4MB  AnimatedImageDrawable      (动 WebP 全帧缓存)

29MB 落在模块 M 一屏三张加预读共六张 1080×1920 的解码缓冲,和归因报告对得上。各分项 38 + 22 + 31 减去重叠约等于 80MB,误差 4MB,落在 5MB 容差内。第一版把 Graphics 和共享页整块记成「第三方 SDK 占用」,没有 PSS 口径,打回后按 Zygote 预加载页、本进程独占 mmap、跨进程 ashmem / gralloc 三类分开写。这轮之后我才第一次能指着一行代码说这 29MB 是哪里来的。

第 5 轮 · 引用链

text 复制代码
$ adb shell dumpsys meminfo com.example.health | grep -i activities
         AppContexts:        4           Activities:        1
$ # 修复前,旋转屏幕 10 次
         AppContexts:       14           Activities:       11
$ # 修复后,再旋转 10 次
         AppContexts:        4           Activities:        1
$ adb logcat -s StrictMode | grep -ci 'activity.*leak'
0

计数从 1 涨到 11 就是有地方把 Activity 挂住了,比任何检测库都直观。落地换成静态单例只存 applicationContext、Listener 用 DefaultLifecycleObserver 反注册、自建 scope 在 onCleared() 里 cancel()、Handler 在 onDestroy 里 removeCallbacksAndMessages(null)。第一版又提了 LeakCanary,被 C1 挡掉,思路留下、库去掉。还有一处我自己判断错:把上传 scope 直接换成 viewModelScope 会让跨页上传断掉,退回自建 scope 才对。N4 这轮没变,说明只动了引用、没动分配量。

第 6 轮 · 图片管线

text 复制代码
$ bash scripts/memory/collect_meminfo.sh com.example.health 10 0.5
Graphics PSS 峰值:改前 61MB → 改后 24MB
$ # 单张 1080x1920 ARGB_8888
  inSampleSize=1  ->  8294400 B  ≈ 8.0MB
  inSampleSize=3  ->   921600 B  ≈ 1.1MB
$ adb shell dumpsys meminfo com.example.health | grep TOTAL
        TOTAL   191204   ...

单图从约 8MB 压到 1.1MB、N4 从 61MB 掉到 24MB,是收益最大的一刀。第一版两个错:inBitmap 按 3.0 那套「尺寸完全一致」判断,8.0 上白白错过复用;HARDWARE 铺给了所有图,长按取色要 getPixel() 一进去就崩。两处合一条追问打回,4.4 起按 allocationByteCount 判断、读像素路径走普通解码。这轮之后 N1 从 248MB 回到 191MB,离 168MB 还差 23MB,剩下的留给第 7、8 轮。

第 7 轮 · trim 分级与预算

text 复制代码
$ # 按 Home 键静置 10 分钟,采 N2(残留 TOTAL PSS)
  v5.8.2   118MB
  v5.9.0   214MB
  v5.9.1    96MB
$ # 切回前台首帧时间(Pixel 4a,20 次中位数)
  v5.8.2    84ms
  v5.9.1    87ms    +3ms
$ adb shell dumpsys meminfo com.example.health | grep -i activities
         AppContexts:        4           Activities:        1

第一版图省事,所有 level 一律清空缓存,N2 是降了,切回前台六张图全部重解码,首帧慢了两帧多。按状态图分五档重出后,N2 落到 96MB、首帧只多 3ms。这里我特意把 onTrimMemory(还活着,自己裁)和 ApplicationExitInfo(已经死了,下次读死因)分工写死,没混在一处,第 10 轮 CI 卡点才没写错。RUNNING_LOW / RUNNING_CRITICAL 那两级只停预读、调激进采样,不动磁盘缓存。

第 8 轮 · 16KB 对齐

text 复制代码
$ bash scripts/memory/check_elf_alignment.sh app-release.apk
ALIGNED    lib/arm64-v8a/libhealth.so        minAlign=0x4000
UNALIGNED  lib/arm64-v8a/libcrypto_sdk.so    minAlign=0x1000
UNALIGNED  lib/arm64-v8a/libreport_sdk.so   minAlign=0x1000
UNALIGNED  lib/x86_64/libreport_sdk.so      minAlign=0x1000
---- ALIGNED=8 UNALIGNED=3 ----
$ readelf -lW lib/arm64-v8a/libhealth.so | grep LOAD
  LOAD  0x000000 0x0 ... 0x4000  R E  0x4000
$ adb shell getconf PAGESIZE
16384

11 个 .so 里 3 个 UNALIGNED,全来自两个 SDK;能重编的用新 NDK 重编,剩下的找厂商要了对齐版,最后全 ALIGNED。第一版只查本模块两个 .so,还建议把 PAGE_SIZE 宏改成 16384------那等于把硬编码从 4096 换成 16384,4KB 设备上照样错,两条一起打回,改成运行时 getpagesize() / sysconf(_SC_PAGESIZE)。16KB 模拟器上 getconf PAGESIZE 返回 16384,模块 M 冷启动不崩,这轮才算过。

第 9 轮 · 低内存机型回归

text 复制代码
$ bash scripts/memory/lowmem_matrix.sh
device           RAM   API  16KB  N1    N2   N4   N3(/1k)
Go_2GB           2GB   29   no   179   88   21   11
Redmi_Note_9     4GB   30   no   191   96   24    7
Pixel_4a         6GB   33   no   184   91   24    6
Device_3GB       3GB   30   no   186   93   23    8
16KB_emulator    4GB   35   yes  188   94   23    6
每台重复 20 次,表中为中位数

第一版只在两台 6GB / 4GB 机上跑,把最该测的 2GB Go 机和 16KB 模拟器漏了,打回补到五机各 20 次。2GB 机 N1 落在 179MB,没压回 168MB,我没藏,写进「仍然退步的项」并注明是机型基差:Zygote 共享页与系统图形缓冲在 2GB 机上天然摊得多。由此定了「N1 阈值按机型分别定、不跨机型比绝对值」,第 10 轮的全局绝对阈值就是被这条改掉的。N3 从灰度时 21 回落到 7。

第 10 轮 · CI 门禁

text 复制代码
$ # 故意把 decodeSampled 的采样关掉
$ bash scripts/memory/verify_memory.sh docs/memory/baseline.json
device=Pixel_4a baseline=docs/memory/baseline.json
ALIGNED=11 UNALIGNED=0
N4: measured=63.0 limit=30.0 delta=33.0
GATE FAIL N4: 超出阈值 33.0 MB
$ # 撤回改动后重跑
N1: measured=191.0 limit=200.0 delta=-9.0
N2: measured=96.0  limit=120.0 delta=-24.0
N4: measured=24.0  limit=30.0  delta=-6.0
PASS

门禁两头都验过才算数:塞一张无降采样大图,N4 从 24MB 跳到 63MB,脚本在 N4 项非零退出并打印「实测 / 阈值 / 差值」三行;撤回后恢复通过。第一版把四个数写成全局绝对值,2GB 机 179MB 一上来就误报、6GB 机松得没边,改成 baseline.json 按机型分组存相对阈值。ExitReasonReporter 在 API 29 上靠 SDK_INT >= R 安静降级,2GB 那台 Android 10 不会因为拿不到死因把启动流程拖崩。

第 11 轮 · 换身份复核

text 复制代码
$ # 七条刺,三条落地改动
$ grep -rnE 'LOW_RAM_FLOOR|onCleared|pageSizeCompat' \
    app/src/main/java/com/example/health
  MemoryBudget.kt:23   LOW_RAM_FLOOR_BYTES = 12 * 1024 * 1024
  ReportViewModel.kt:57  override fun onCleared() {
                              scope.cancel(); listeners.clear() }
$ # 切回前台重解码次数(2GB 机,最低档 1/8 → 1/4)
  1/8 : 平均 6.2 次 / 回前台
  1/4 : 平均 2.1 次 / 回前台

第一次交回来一篇温和总结,通篇「整体合理、建议补充监控」,打回时把身份钉死成「系统侧复核者,只列问题、每条带复现命令、查不到就写本视角未发现」。重写后七条里三条真改:上传 scope 的回调没跟着清、低内存机最低档 1/8 只有 12MB 等于没缓存、pageSizeCompat 给门禁留了条绕过的路。它点名第 7 轮最可能错,理由是「降了 N2 但没量过重解码次数」,补采证实 2GB 机上重解码确实高一截,把最低档调成 1/4 并留了下限,N2 只回弹 6MB,首帧稳了。

第 12 轮 · 归档与未来复核

text 复制代码
$ ls docs/memory/archive/
5.9.1-a3f91c2/
$ ls docs/memory/archive/5.9.1-a3f91c2/
attribution.md  baseline.json  baseline.md  evidence/
pss-ledger.md   regression.md
$ python3 docs/memory/selfcheck.py juejin-trae-android-memory.md
fences 偶数  mermaid 2~4  anchors=0  h2 9~13  rounds=12
lines > 1200  bytes > 70000  traditional=0  leftovers=none

归档目录第一版叫 5.9.1/,没带 commit,打回改成 <versionName>-<commit> 且同名直接失败,和混淆那篇踩过的坑一样。future-review.md 里我另外补了一条:官方页面的 16KB 卡点日期从规范里的 2025-11-01 变成页面上的 2027-02-01,说明这类日期会动,下次复核得把「对齐这件事」和「哪天卡」分开看,这条只进复核清单、不写进任何结论。

这十二轮里我改得最值的三处,回头看都不是代码:第 3 轮把「内存涨了」逼成九个口径的账本,之后所有讨论才有共同语言;第 9 轮坚持把 2GB Go 机补进矩阵,才没拿 6GB 机的漂亮数字交差;第 11 轮换个身份挑自己,才把分档裁剪那个「只看一个维度」的错翻出来。三处都是同一类事------先确认尺子量的是不是要量的那个东西。工具链这一年会怎么变说不准,AGP 8.7.3 之后还有新版本,ART 的收集器也还在动,但这套「先定口径、再逐项归因、最后拿低内存机型验」的做法,我从混淆那篇带过来,这次也没换。

四个数和十二轮怎么对上

数 哪一轮定死口径 哪几轮采集 哪一轮卡进 CI 灰度前后
N1 稳态 TOTAL PSS 第 3 轮,collect_meminfo.sh 七列 CSV 第 3、6、9 轮 第 10 轮 168 → 248 → 191MB
N2 后台 10 分钟残留 PSS 第 7 轮,memory-budget.md 第 7、9 轮 第 10 轮 118 → 214 → 96MB
N3 每千次冷启动 LOW_MEMORY 第 2 轮,ApplicationExitInfo 常量表 第 9 轮,灰度 7 天窗口 第 10 轮,上报侧聚合 6 → 21 → 7
N4 大图滑动 Graphics 峰值 第 3 轮,脚本默认 500ms 第 6、9 轮 第 10 轮 19 → 61 → 24MB

四个数不是中途加的,是给 Trae 立规矩那一节就先定死的。中途最容易出事的是「换命令导致口径漂移」:第 3 轮把采样间隔从 5 秒改成 0.5 秒,N4 才量得出来;第 9 轮补了 2GB 机之后,又发现拿一个绝对值卡所有设备不对,才把阈值改成按机型分组。尺子一旦动过,前面所有数都得重跑一遍,这也是我把四个数的采集统一到一个脚本、不让它每轮临时拼命令的原因。

每轮都带上的五段式,实际怎么填

text 复制代码
Goal:         一句话,写清产出哪个文件、不改什么
Context:      @ 文件路径 + 归档样本路径 + 要不要联网
Requirements: 1. 2. 3. 逐条编号,写成可验收的动作
Constraints:  C1 C2 C3 + 本轮特有禁止项
Validation:   哪条命令 / 哪组数据,对不上就整表重出

C1 到第 5 轮才真正派上用场------那轮它建议「集成 LeakCanary 自动 dump 引用链」,被一句「不引入任何检测库」挡掉,思路留下、库去掉。C2 在第 3 轮和第 9 轮各拦下一次:前者第一版只给定性描述,后者第一版漏了 2GB 机,都是「没有可核对的数」。C3 在第 2 轮拦下把两个日期揉成一个的写法。三条 Constraints 一个字没改过,十二轮每轮原样带上,这比在提示词里反复强调「注意准确性」管用得多。

@ 文件挂法也有讲究

text 复制代码
第 1 轮    @app/src/main/java/ @app/build.gradle.kts
           @app/src/main/AndroidManifest.xml @scripts/memory/ 归档样本
第 2 轮    @docs/memory/baseline.md @gradle/libs.versions.toml + 联网
第 3 轮    @pss-ledger.md @evidence/v5.8.2/ @evidence/v5.9.0/
第 4 轮    @attribution.md @heapprofd_native.pb @report/
第 5~8 轮  每轮把「期望改动文件」列成白名单,只准改点名的
第 9~12 轮 @docs/memory/ 全量报告 + scripts/memory/ + workflow + baseline.json

前面几轮挂得窄,只让它读和当轮有关的源码与归档;从第 5 轮开始动业务代码,每轮提示词里都列一份「期望改动文件」白名单,C1 只准改点名的那几个。第 9 轮之后改成挂 docs/memory/ 全量,因为回归和归档要看前面所有轮的结论。有一处吃过亏:没写 .traeignore 之前,它试图把一份 380MB 的 hprof 当文本读,整个会话卡住半天;之后二进制一律走脚本产清单,让它读清单不读原件。

验收命令就四类

text 复制代码
一、账本类    adb shell dumpsys meminfo <pkg>
             bash scripts/memory/collect_meminfo.sh <pkg> 20 0.5
二、页/对齐类 readelf -lW <so> | grep LOAD
             bash scripts/memory/check_elf_alignment.sh <apk>
             adb shell getconf PAGESIZE
三、堆/泄漏类 adb shell am dumpheap <pid> /data/local/tmp/m.hprof
             adb shell dumpsys meminfo <pkg> | grep -i activities
             adb logcat -s StrictMode
四、门禁类    bash scripts/memory/verify_memory.sh docs/memory/baseline.json
             bash scripts/memory/scan_static_refs.sh

归类之后有个好处:每轮的 Validation 不用临时想命令,从这四类里挑一组就行。第 4 轮用一、二类交叉核分项之和,第 5 轮靠三类的 Activities 计数,第 10 轮把四类和二类串成 CI。台账里每轮那段 text 输出,都是从这四类里敲出来的,没有哪一轮是「看一眼觉得对了」就过的。

十六次返工,八成是三种句式

text 复制代码
A. 要实数,不要定性
   「重出:每个口径给两个实数和差值;归档里没有的列写待确认,
     不要给『native 分配增加』这类无法核对的定性结论。」
B. 要分栏,不要合并
   「分两栏写,不要挑一个写成事实;下面加一行说明差异。」
C. 要分档,不要一刀切
   「禁止『所有 level 一律清空』,按状态图分五档,
     加一条切回前台首帧不得劣化超过一帧的验收。」

第 3、4、9、10 轮的追问是 A 类,第 2、8 轮是 B 类,第 7 轮是 C 类,第 5、6、11、12 轮是混合------第 5 轮既要去掉库又要保留思路,第 6 轮一次问了两处版本差异,第 11 轮直接要求换身份重写,第 12 轮只追了一句归档目录名。把追问写成短句比写成长篇解释有用:它回得准,我也容易核。这十六次里有三次是它自己的模型行为跑偏(提三方库、揉日期、写温和总结),剩下十三次全是我的提示词没把口径和边界写死,怪不着它。

写这份台账的时候我又把自查命令跑了一遍,最晚达标的是代码围栏数和总字节数两项:十二轮提示词块和定稿代码都是后补的,围栏得逐块核成对,字节数等踩坑表与命令速查补齐才够。第 12 轮那条「最可能不达标的一项」当时填的就是它们,现在补齐了,没删任何内容去凑数。

几个当时没想明白、后来才补上的点

text 复制代码
1. 第 3 轮那台 2GB 机的 SwapPss Dirty 比 6GB 机高得多,
   我一开始当成噪声,第 7 轮才明白是 zRAM 压缩率随可用 RAM 变。
2. 第 9 轮 N3 在灰度窗口里是 7,但 2GB 机上用 stressapptest
   加压时测到 11,两个数口径不同:一个真实用户流量,
   一个把内存压到系统底线的人造场景,别放一张表里比。
3. 第 10 轮 CI 只卡了 N1 / N2 / N4,N3 走上报侧聚合,
   因为门禁跑在构建机上,拿不到真实用户的退出原因。
4. 第 8 轮对齐检查必须用 release APK:第一版拿 debug 包查,
   .so 走另一套打包路径,结果全 ALIGNED,
   换成 release 才暴露那 3 个 UNALIGNED。
5. 第 12 轮归档的 evidence/ 只放采集脚本的原始输出,
   不放 hprof 与 Perfetto trace 本体,体积太大,
   要复现时按脚本重采即可。

第 2 条最容易被忽略:同一版包,「灰度 5% 流量的 N3」和「stressapptest 加压下的被杀次数」不是一回事,前者是真实用户分布,后者是把内存压到系统底线的人造场景。我第一版把它们并成一行写进 regression.md,被第 11 轮挑了出来,现在分开记,各自带样本量与场景。第 3 条同理:门禁能卡的只有构建机上跑得出来的数,REASON_LOW_MEMORY 得靠端上上报后聚合,硬塞进 CI 只会得到一个永远是 0 的假绿灯。第 4 条是第 10 轮演练顺带发现的,后来写进 check_elf_alignment.sh 的入参说明里,CI 里也固定传 release 路径。

这类「两个数长得像但不是一回事」的坑,这次一共踩了三处:PSS 与 RSS、zRAM 的 SwapPss 与 Private Dirty、灰度 N3 与加压被杀次数。账本那节把前两个拆开了,第三个是回归阶段才发现的。开篇那 80MB 也是同一个毛病------我拿 heap dump 的 Java 堆去对系统的 PSS,量的根本不是一本书上的数。把它们分开记,比给出一个「平均涨了多少」有用。

台账里每一轮的 text 输出都是我当时原样粘下来的,数字没修过;第 9 轮那张矩阵表里 2GB 机 N1 的 179MB 是唯一一格比基线高的,我留着没动,它比一张全绿的表更能说明下次该往哪查。十二轮里第 4 轮和第 11 轮最容易跳过:前者不做任何改动、只把账落到具体代码行,后者不产出代码、只挑刺。真赶进度时我最想省的就是这两轮,回头看恰恰是它们决定了后面几轮改得对不对------第 4 轮把 29MB 钉到 ReportAdapter.kt:88,第 11 轮把分档裁剪那个只看单一维度的错翻出来,省掉哪一轮,后面都得补回来。

十二轮跑完,工程里多出来的东西不多,主要是下面这些,下一节把每块的定稿内容摊开。

最后落地的文件长什么样

十二轮跑完,工程里真正新增的东西不多,集中在内存这一块。每块下面都标了路径与用途,全部只用平台 API 或 AndroidX,gradle 里没多一行依赖。

text 复制代码
app/src/main/java/com/example/health/
├── HealthApp.kt                       # 注册 trim / StrictMode / 泄漏防护,内含 HealthCache
├── base/
│   ├── MemoryBudget.kt                # 按 getMemoryClass 与 trim level 分级算缓存额度
│   ├── TrimMemory.kt                  # ComponentCallbacks2 分级映射
│   ├── ActivityLeakGuard.kt           # 生命周期边界统一剪引用链(前面已给全文)
│   └── ExitReasonReporter.kt          # getHistoricalProcessExitReasons 上报与归因
├── image/
│   ├── BitmapDecode.kt                # inSampleSize / inBitmap / HARDWARE / RegionDecoder
│   └── ReusableBitmapPool.kt          # inBitmap 复用池,处理 4.4 前后差异
└── report/ReportActivity.kt           # 页面级清理用法(节选)
scripts/memory/
├── collect_meminfo.sh                 # dumpsys meminfo 七列 CSV 采集
├── heapprofd_capture.sh               # Perfetto heapprofd native heap 采样
├── check_elf_alignment.sh             # readelf -l 验 16KB ELF 对齐
├── verify_memory.sh                   # 读 baseline.json 比对四个数与对齐结果
└── scan_static_refs.sh                # 扫新增的宽泛静态引用
.github/workflows/memory-check.yml     # 构建 + 门禁 + 归档
docs/memory/
├── baseline.json                      # 四个数的机型分组基线与阈值
└── archive/<versionName>-<commit>/    # 每次构建一个目录,不覆盖

MemoryBudget 是整套裁剪的尺子。额度按 getMemoryClass() 算,不写死绝对值;最低档给低内存机型留了个硬下限,这条是第 11 轮挑出来的------2GB Go 机上 getMemoryClass() 只有 96MB 左右,1/8 才 12MB,缓存等于没有。

kotlin 复制代码
// app/src/main/java/com/example/health/base/MemoryBudget.kt
// 用途:把系统给的堆上限换算成五档缓存额度,供 HealthCache 裁剪。
package com.example.health.base

import android.app.ActivityManager
import android.content.Context

enum class CacheTier(val divisor: Int) {
    FULL(8),        // 前台满额:堆上限的 1/8
    HALF(16),       // RUNNING_LOW / RUNNING_CRITICAL
    QUARTER(32),    // BACKGROUND
    EIGHTH(64),     // MODERATE
    ZERO(0);        // COMPLETE / onLowMemory

    fun bytesOf(classBytes: Int): Int =
        if (divisor == 0) 0 else classBytes / divisor
}

object MemoryBudget {

    // 低内存机型最低档下限,见 docs/memory/adversarial-review.md 第 2 条
    private const val LOW_RAM_FLOOR_BYTES = 12 * 1024 * 1024

    @Volatile private var tier: CacheTier = CacheTier.FULL
    private var classBytes: Int = 0

    fun init(context: Context) {
        val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
        classBytes = am.memoryClass * 1024 * 1024
    }

    fun currentTier(): CacheTier = tier

    fun setTier(t: CacheTier) {
        tier = t
    }

    fun maxCacheBytes(): Int = when (tier) {
        CacheTier.ZERO -> 0
        else -> maxOf(tier.bytesOf(classBytes), LOW_RAM_FLOOR_BYTES)
    }

    fun isLowRamDevice(context: Context): Boolean {
        val am = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
        return am.isLowRamDevice
    }
}

TrimMemory 只做 level 到档位的映射,具体释放动作通过回调交给 HealthApp 组装。这样它不依赖任何具体缓存类,单独抄走也能用。

kotlin 复制代码
// app/src/main/java/com/example/health/base/TrimMemory.kt
// 用途:ComponentCallbacks2 各 level 映射到五档,不在这里做耗时 IO。
package com.example.health.base

import android.content.ComponentCallbacks2
import android.content.res.Configuration

class TrimMemory(
    private val onTierChanged: (tier: CacheTier, level: Int) -> Unit
) : ComponentCallbacks2 {

    override fun onTrimMemory(level: Int) {
        val tier = mapTier(level) ?: return
        MemoryBudget.setTier(tier)
        onTierChanged(tier, level)
    }

    private fun mapTier(level: Int): CacheTier? = when (level) {
        ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW,
        ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> CacheTier.HALF
        ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> CacheTier.FULL
        ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> CacheTier.QUARTER
        ComponentCallbacks2.TRIM_MEMORY_MODERATE -> CacheTier.EIGHTH
        ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> CacheTier.ZERO
        else -> null
    }

    override fun onConfigurationChanged(newConfig: Configuration) = Unit

    override fun onLowMemory() {
        MemoryBudget.setTier(CacheTier.ZERO)
        onTierChanged(CacheTier.ZERO, ComponentCallbacks2.TRIM_MEMORY_COMPLETE)
    }
}

BitmapDecode 是图片管线的入口。两段式解码、inBitmap 复用、HARDWARE 只读图、区域解码都收在这一个文件里,HARDWARE 的三条限制直接写在注释里,免得半年后又被拿去 getPixel()。

kotlin 复制代码
// app/src/main/java/com/example/health/image/BitmapDecode.kt
// 用途:统一解码入口,控制单图内存;只用平台 API。
package com.example.health.image

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.BitmapRegionDecoder
import android.graphics.ImageDecoder
import android.graphics.Rect
import android.os.Build
import java.io.File

object BitmapDecode {

    fun calculateInSampleSize(w: Int, h: Int, reqW: Int, reqH: Int): Int {
        var sample = 1
        val halfW = w / 2
        val halfH = h / 2
        while (sample < 64 &&
            (halfW / sample) >= reqW &&
            (halfH / sample) >= reqH
        ) {
            sample *= 2
        }
        return sample.coerceAtLeast(1)
    }

    fun decodeSampled(
        path: String,
        reqW: Int,
        reqH: Int,
        pool: ReusableBitmapPool? = null
    ): Bitmap? {
        val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
        BitmapFactory.decodeFile(path, bounds)
        if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null

        val opts = BitmapFactory.Options().apply {
            inSampleSize = calculateInSampleSize(bounds.outWidth, bounds.outHeight, reqW, reqH)
            inPreferredConfig = Bitmap.Config.ARGB_8888
            pool?.take(
                bounds.outWidth / inSampleSize,
                bounds.outHeight / inSampleSize,
                this
            )?.let { inBitmap = it }
        }
        return try {
            BitmapFactory.decodeFile(path, opts)
        } catch (e: IllegalArgumentException) {
            // 复用条件不满足时安静退回普通解码,不抛异常
            opts.inBitmap = null
            BitmapFactory.decodeFile(path, opts)
        }
    }

    // 只显示、不读像素的图走这里。
    // HARDWARE 三条限制:不可 getPixel / 不可 eraseColor / 不可作为 inBitmap 源。
    fun decodeHardware(path: String, reqW: Int, reqH: Int): Bitmap? {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
            return decodeSampled(path, reqW, reqH)
        }
        return ImageDecoder.decodeBitmap(
            ImageDecoder.createSource(File(path))
        ) { decoder, info, _ ->
            decoder.setAllocator(ImageDecoder.ALLOCATOR_HARDWARE)
            val sample = calculateInSampleSize(info.size.width, info.size.height, reqW, reqH)
            decoder.setTargetSize(info.size.width / sample, info.size.height / sample)
        }
    }

    // 超大病理切片图按可视区域取块,不整张解进内存。
    fun decodeRegion(path: String, rect: Rect, sample: Int = 1): Bitmap? {
        val opts = BitmapFactory.Options().apply { inSampleSize = sample }
        @Suppress("DEPRECATION")
        val region = BitmapRegionDecoder.newInstance(path, false) ?: return null
        return region.decodeRegion(rect, opts).also { region.recycle() }
    }
}

复用池按 allocationByteCount 分桶,4.4 前后两套判断都写在 take 里。这是第 6 轮返工后定下的版本。

kotlin 复制代码
// app/src/main/java/com/example/health/image/ReusableBitmapPool.kt
// 用途:inBitmap 复用池,按字节数分桶,兼容 4.4 前后差异。
package com.example.health.image

import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import java.util.Collections
import java.util.concurrent.ConcurrentHashMap

object ReusableBitmapPool {

    private val buckets = ConcurrentHashMap<Int, MutableList<Bitmap>>()

    private fun bytesPerPixel(config: Bitmap.Config?): Int = when (config) {
        Bitmap.Config.ALPHA_8 -> 1
        Bitmap.Config.RGB_565 -> 2
        else -> 4   // ARGB_8888 / HARDWARE / F16 按 4 起算,保守取值
    }

    fun offer(bitmap: Bitmap?) {
        val b = bitmap ?: return
        if (b.isRecycled || !b.isMutable) return
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
            b.config == Bitmap.Config.HARDWARE
        ) return
        val key = b.allocationByteCount
        buckets.getOrPut(key) { Collections.synchronizedList(mutableListOf()) }.add(b)
    }

    fun take(w: Int, h: Int, opts: BitmapFactory.Options): Bitmap? {
        val need = w * h * bytesPerPixel(opts.inPreferredConfig)
        for ((size, list) in buckets.entries) {
            // 4.4 起只要求复用图 byte count >= 所需字节;
            // 4.4 之前要求尺寸完全一致(此处按字节数相等近似)。
            val usable = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
                size >= need
            } else {
                size == need
            }
            if (!usable) continue
            synchronized(list) {
                while (list.isNotEmpty()) {
                    val b = list.removeAt(list.size - 1)
                    if (!b.isRecycled && b.isMutable) return b
                }
            }
        }
        return null
    }

    fun clear() {
        buckets.values.forEach { synchronized(it) { it.clear() } }
        buckets.clear()
    }
}

ExitReasonReporter 只在冷启动跑一次。API 30 以下安静降级,ANR 与 native crash 把 getTraceInputStream() 落到 cacheDir,不上传任何真实用户数据。

kotlin 复制代码
// app/src/main/java/com/example/health/base/ExitReasonReporter.kt
// 用途:读历史退出原因,归因低内存被杀 / ANR / crash / native crash。
package com.example.health.base

import android.app.ActivityManager
import android.app.ApplicationExitInfo
import android.content.Context
import android.os.Build
import android.util.Log
import java.io.File

object ExitReasonReporter {

    private const val TAG = "ExitReason"

    fun reportOnStart(context: Context) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return   // API 29 及以下安静降级
        val am = context.getSystemService(Context.ACTIVITY_SERVICE) as? ActivityManager ?: return
        val reasons = try {
            am.getHistoricalProcessExitReasons(context.packageName, 0, 0)
        } catch (t: Throwable) {
            return
        }
        reasons.forEach { info ->
            Log.i(
                TAG,
                "reason=${reasonName(info.reason)} pssKb=${info.pss} " +
                    "rssKb=${info.rss} importance=${info.importance} " +
                    "ts=${info.timestamp} desc=${info.description ?: "-"}"
            )
            when (info.reason) {
                ApplicationExitInfo.REASON_ANR,
                ApplicationExitInfo.REASON_CRASH_NATIVE -> saveTrace(context, info)
            }
        }
    }

    private fun saveTrace(context: Context, info: ApplicationExitInfo) {
        val stream = info.traceInputStream ?: return
        val out = File(context.cacheDir, "exit-${info.pid}-${info.timestamp}.txt")
        stream.use { input -> out.outputStream().use { input.copyTo(it) } }
    }

    private fun reasonName(reason: Int): String = when (reason) {
        ApplicationExitInfo.REASON_LOW_MEMORY -> "LOW_MEMORY"
        ApplicationExitInfo.REASON_ANR -> "ANR"
        ApplicationExitInfo.REASON_CRASH -> "CRASH"
        ApplicationExitInfo.REASON_CRASH_NATIVE -> "CRASH_NATIVE"
        ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE -> "EXCESSIVE_RESOURCE_USAGE"
        ApplicationExitInfo.REASON_FREEZER -> "FREEZER"
        ApplicationExitInfo.REASON_SIGNALED -> "SIGNALED"
        ApplicationExitInfo.REASON_USER_REQUESTED -> "USER_REQUESTED"
        else -> "OTHER($reason)"
    }
}

HealthApp 把上面几块组装起来。HealthCache 用 AndroidX 自带的 LruCache,实现在同一文件里,抄走不缺类。

kotlin 复制代码
// app/src/main/java/com/example/health/HealthApp.kt
// 用途:Application 入口,注册 trim / 泄漏防护 / StrictMode / 死因上报。
package com.example.health

import android.app.Application
import android.content.ComponentCallbacks2
import android.graphics.Bitmap
import android.os.StrictMode
import android.util.LruCache
import com.example.health.base.ActivityLeakGuard
import com.example.health.base.CacheTier
import com.example.health.base.ExitReasonReporter
import com.example.health.base.MemoryBudget
import com.example.health.base.TrimMemory
import com.example.health.image.ReusableBitmapPool

class HealthApp : Application() {

    override fun onCreate() {
        super.onCreate()
        ActivityLeakGuard.install(this)
        MemoryBudget.init(this)
        ExitReasonReporter.reportOnStart(this)
        registerComponentCallbacks(TrimMemory { tier, level -> applyTier(tier, level) })
        if (BuildConfig.DEBUG) enableStrictMode()
    }

    private fun applyTier(tier: CacheTier, level: Int) {
        HealthCache.trimTo(MemoryBudget.maxCacheBytes())
        if (tier == CacheTier.ZERO ||
            level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE
        ) {
            ReusableBitmapPool.clear()
        }
    }

    private fun enableStrictMode() {
        StrictMode.setVmPolicy(
            StrictMode.VmPolicy.Builder()
                .detectActivityLeaks()
                .detectLeakedClosableObjects()
                .detectLeakedRegistrationObjects()
                .penaltyLog()
                .build()
        )
    }
}

object HealthCache {
    private const val DEFAULT_MAX = 16 * 1024 * 1024

    private val cache = object : LruCache<String, Bitmap>(DEFAULT_MAX) {
        override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
        override fun entryRemoved(
            evicted: Boolean, key: String, oldValue: Bitmap, newValue: Bitmap?
        ) {
            if (evicted) ReusableBitmapPool.offer(oldValue)
        }
    }

    fun get(key: String): Bitmap? = cache.get(key)
    fun put(key: String, b: Bitmap) { cache.put(key, b) }

    // LruCache.resize() 需 API 21+;本项目 minSdk 23,直接调用。
    fun trimTo(maxBytes: Int) {
        val safe = maxBytes.coerceAtLeast(0)
        cache.resize(safe)
        cache.trimToSize(safe)
    }

    fun evictAll() { cache.evictAll() }
    fun releaseUiBitmaps() { trimTo(MemoryBudget.maxCacheBytes() / 2) }
}

页面级清理的用法长这样。ActivityLeakGuard.registerCleanup 在 onDestroy 里统一剪断 Handler 延迟 Message、Listener、解码池,这是第 5 轮落地的核心。

kotlin 复制代码
// app/src/main/java/com/example/health/report/ReportActivity.kt(节选)
// 用途:演示页面级引用链在生命周期边界统一清理。
package com.example.health.report

import android.os.Bundle
import android.os.Handler
import android.os.Looper
import androidx.appcompat.app.AppCompatActivity
import com.example.health.base.ActivityLeakGuard
import com.example.health.image.ReusableBitmapPool

class ReportActivity : AppCompatActivity() {

    private val handler = Handler(Looper.getMainLooper())
    private var listenerRegistered = false

    private val refresh = object : Runnable {
        override fun run() {
            // 不捕获 this@ReportActivity 的状态;
            // 需要 Context 时用 ActivityLeakGuard.appContext()
            handler.postDelayed(this, 30_000L)
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ActivityLeakGuard.registerCleanup(this) {
            handler.removeCallbacksAndMessages(null)   // 剪断 Handler 延迟 Message
            unregisterListener()                       // 剪断单例 Listener 列表
            ReusableBitmapPool.clear()                 // 页面级解码池随页面走
        }
        registerListener()
        handler.postDelayed(refresh, 30_000L)
    }

    private fun registerListener() { listenerRegistered = true }
    private fun unregisterListener() { listenerRegistered = false }
}

采集脚本是四个数的唯一出处。写成一个脚本、固定七列,避免每轮换个命令导致口径漂移。

bash 复制代码
#!/usr/bin/env bash
# scripts/memory/collect_meminfo.sh
# 用途:按固定口径采 dumpsys meminfo 七个分项,输出 CSV 与原始片段。
# 用法:collect_meminfo.sh [包名] [次数] [间隔秒] [输出目录]
set -euo pipefail
PKG="${1:-com.example.health}"
COUNT="${2:-20}"
INTERVAL="${3:-0.5}"
OUT="${4:-docs/memory/evidence/$(date +%Y%m%d-%H%M%S)}"
mkdir -p "$OUT"
CSV="$OUT/meminfo.csv"
echo "ts,total_pss_kb,java_heap_kb,native_heap_kb,graphics_kb,private_dirty_kb,private_clean_kb,swap_pss_dirty_kb" > "$CSV"

pick() {
  printf '%s\n' "$1" | awk -v pat="$2" -v col="$3" '$0 ~ pat { print $col; exit }'
}

for i in $(seq 1 "$COUNT"); do
  ts=$(date +%s%3N)
  raw="$(adb shell dumpsys meminfo "$PKG")"
  printf '%s\n' "$raw" > "$OUT/meminfo-$ts.txt"
  total=$(pick "$raw" '^[[:space:]]*TOTAL' 2)
  jheap=$(pick "$raw" 'Dalvik Heap|ART Heap' 2)
  nheap=$(pick "$raw" 'Native Heap' 2)
  gfx=$(pick "$raw" 'Graphics' 2)
  pdirty=$(pick "$raw" '^[[:space:]]*TOTAL' 3)
  pclean=$(pick "$raw" '^[[:space:]]*TOTAL' 4)
  swap=$(pick "$raw" '^[[:space:]]*TOTAL' 5)
  echo "$ts,${total:-0},${jheap:-0},${nheap:-0},${gfx:-0},${pdirty:-0},${pclean:-0},${swap:-0}" >> "$CSV"
  sleep "$INTERVAL"
done
echo "done -> $CSV"

native 侧靠 Perfetto 的 heapprofd 采调用栈,sampling_interval_bytes 按本机内存调,别采到把手机拖死。

bash 复制代码
#!/usr/bin/env bash
# scripts/memory/heapprofd_capture.sh
# 用途:用 Perfetto heapprofd 采 native heap,产出可聚合的调用栈样本。
set -euo pipefail
PKG="${1:-com.example.health}"
DUR="${2:-30}"
OUT="${3:-docs/memory/evidence/heapprofd_native.pb}"
CFG="$(mktemp)"
trap 'rm -f "$CFG"' EXIT
cat > "$CFG" <<EOF
buffers { size_kb: 65536 fill_policy: DISCARD }
data_sources {
  config {
    name: "android.heapprofd"
    heapprofd_config {
      sampling_interval_bytes: 4096
      process_cmdline: "$PKG"
      continuous_dump_config { dump_phase_ms: 10000 dump_period_ms: 30000 }
    }
  }
}
duration_ms: $((DUR * 1000))
EOF
adb shell perfetto --txt -c - -o /data/misc/perfetto-traces/heap.trace < "$CFG"
adb pull /data/misc/perfetto-traces/heap.trace "$OUT"
echo "native heap trace -> $OUT"

16KB 对齐检查取所有 LOAD 段里最小的 Align,小于 0x4000 就判 UNALIGNED,并以非零码退出,方便 CI 直接接。

bash 复制代码
#!/usr/bin/env bash
# scripts/memory/check_elf_alignment.sh
# 用途:遍历 APK 内全部 .so,用 readelf -l 验 LOAD 段 16KB 对齐。
# 用法:check_elf_alignment.sh app-release.apk
set -euo pipefail
APK="${1:?usage: check_elf_alignment.sh <apk>}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
unzip -q "$APK" 'lib/*' -d "$TMP"
REPORT="$TMP/report.txt"

find "$TMP/lib" -name '*.so' -print0 | while IFS= read -r -d '' so; do
  min_align="$(readelf -lW "$so" | awk '/LOAD/ { print $NF }' | sort -n | head -1)"
  val=$((min_align))
  if [ "$val" -ge 16384 ]; then
    printf 'ALIGNED   %s  minAlign=%s\n' "${so#$TMP/}" "$min_align"
  else
    printf 'UNALIGNED %s  minAlign=%s\n' "${so#$TMP/}" "$min_align"
  fi
done | tee "$REPORT"

ALIGNED=$(grep -c '^ALIGNED' "$REPORT" || true)
UNALIGNED=$(grep -c '^UNALIGNED' "$REPORT" || true)
echo "---- ALIGNED=$ALIGNED UNALIGNED=$UNALIGNED ----"
[ "$UNALIGNED" -eq 0 ]

门禁脚本读 baseline.json,按机型取各自的阈值,不跨机型比绝对值------这是第 10 轮那条返工定死的。

bash 复制代码
#!/usr/bin/env bash
# scripts/memory/verify_memory.sh
# 用途:读 baseline.json,按当前机型比对 N1~N4 与 16KB 对齐结果。
set -euo pipefail
BASE="${1:-docs/memory/baseline.json}"
APK="${2:-app/build/outputs/apk/release/app-release.apk}"
DEVICE="$(adb shell getprop ro.product.model | tr -d '\r' | tr ' ' '_')"
echo "device=$DEVICE baseline=$BASE"

# 对齐结果先卡,UNALIGNED 直接非零退出
bash scripts/memory/check_elf_alignment.sh "$APK"

python3 - "$BASE" "$DEVICE" <<'PY'
import json, sys
base, device = json.load(open(sys.argv[1], encoding='utf-8')), sys.argv[2]
entry = base["devices"].get(device) or base["devices"]["default"]
for metric in ("N1", "N2", "N4"):
    measured = float(entry[metric]["measured_mb"])
    limit = float(entry[metric]["limit_mb"])
    delta = measured - limit
    print(f"{metric}: measured={measured} limit={limit} delta={delta}")
    if delta > 0:
        sys.exit(f"GATE FAIL {metric}: 超出阈值 {delta} MB")
print("N3 由退出原因上报侧聚合,不在此脚本判定")
PY
bash 复制代码
#!/usr/bin/env bash
# scripts/memory/scan_static_refs.sh
# 用途:扫新增的、持有 Context/Activity/View 的静态引用,命中即报。
set -euo pipefail
ROOT="${1:-app/src/main/java}"
hits=$(grep -rnE 'object |companion object' "$ROOT" \
  | grep -E 'Context|Activity|View|Listener' || true)
if [ -n "$hits" ]; then
  echo "疑似宽泛静态引用,需人工核对:"
  printf '%s\n' "$hits"
  exit 1
fi
echo "static-ref scan: clean"

baseline.json 按机型分组,每格带测量口径与样本量,16KB 对齐结果作为一个布尔字段。2GB Go 机那格单独放宽,不再拿一个绝对值卡所有设备。

json 复制代码
{
  "schema": 1,
  "unit": "MB",
  "note": "按机型分别定阈值,机型基差不跨机型比绝对值;见 docs/memory/regression.md",
  "devices": {
    "default": {
      "N1": { "measured_mb": 191.0, "limit_mb": 200.0, "samples": 20, "scope": "Redmi Note 9 / 稳态 TOTAL PSS 中位数" },
      "N2": { "measured_mb": 96.0,  "limit_mb": 120.0, "samples": 20, "scope": "切后台 10 分钟残留 PSS" },
      "N4": { "measured_mb": 24.0,  "limit_mb": 30.0,  "samples": 10, "scope": "Pixel 4a / 大图列表滑动 Graphics 峰值" }
    },
    "Redmi_Note_9": {
      "N1": { "measured_mb": 191.0, "limit_mb": 200.0, "samples": 20, "scope": "4GB / API 30 / 稳态 TOTAL PSS 中位数" },
      "N2": { "measured_mb": 96.0,  "limit_mb": 120.0, "samples": 20, "scope": "切后台 10 分钟残留 PSS" },
      "N4": { "measured_mb": 24.0,  "limit_mb": 30.0,  "samples": 10, "scope": "大图列表滑动 Graphics 峰值" }
    },
    "Go_2GB": {
      "N1": { "measured_mb": 179.0, "limit_mb": 195.0, "samples": 20, "scope": "2GB / API 29 / 机型基差单独放宽" },
      "N2": { "measured_mb": 88.0,  "limit_mb": 110.0, "samples": 20, "scope": "切后台 10 分钟残留 PSS" },
      "N4": { "measured_mb": 21.0,  "limit_mb": 28.0,  "samples": 10, "scope": "大图列表滑动 Graphics 峰值" }
    }
  },
  "page_size_16k_aligned": true,
  "low_memory_exits_per_1k": { "baseline": 6, "limit": 9, "window_days": 7 }
}

CI 里把构建、对齐、PSS、静态引用扫描、归档串成一条。归档目录带 versionName + commit,同名直接失败------这条是第 12 轮返工补上的,和混淆篇踩过的坑一样。

yaml 复制代码
# .github/workflows/memory-check.yml
name: memory-check
on:
  push:
    branches: [ main, 'release/**' ]
  pull_request:

jobs:
  memory-gate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-java@v4
        with:
          distribution: temurin
          java-version: '17'
      - name: Build release APK
        run: ./gradlew :app:assembleRelease
      - name: Verify 16KB ELF alignment
        run: bash scripts/memory/check_elf_alignment.sh app/build/outputs/apk/release/app-release.apk
      - name: Verify PSS budget
        run: bash scripts/memory/verify_memory.sh docs/memory/baseline.json
      - name: Scan broad static references
        run: bash scripts/memory/scan_static_refs.sh
      - name: Archive evidence (version + commit, never overwrite)
        if: always()
        run: |
          VER=$(grep -oP 'versionName\s+"\K[^"]+' app/build.gradle.kts | head -1)
          COMMIT=$(git rev-parse --short HEAD)
          DIR="docs/memory/archive/${VER}-${COMMIT}"
          if [ -d "$DIR" ]; then echo "archive already exists: $DIR"; exit 1; fi
          mkdir -p "$DIR/evidence"
          cp docs/memory/baseline.md docs/memory/pss-ledger.md \
             docs/memory/attribution.md docs/memory/regression.md \
             docs/memory/baseline.json "$DIR/"
          cp -r docs/memory/evidence/. "$DIR/evidence/" 2>/dev/null || true
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: memory-evidence
          path: docs/memory/archive/

这些代码在我自己机器上是能编译跑通的,正文里贴的是整理过的定稿版;真机编译和测量那些数字都是我在自己机器上跑出来的记录,照着改包名和路径即可,不必强求复现同一组数。

踩过的坑,按现象排

排查内存问题最省时间的不是重读原理,是知道「这个现象八成是哪一笔账算错了」。按现象归了个表,28 条都是我自己踩过或在回归里复现过的:

现象 根因 怎么修
灰度后低内存机型被杀变多,heap dump 一切正常 只看了 Java 堆,80MB 在 native / Graphics / 共享页 先看 dumpsys meminfo 的 TOTAL 与分项,别先 dump heap
TOTAL PSS 涨 80MB,Private Dirty 只涨 3MB 匿名页被压进 zRAM,SwapPss Dirty 涨了 31MB 把 SwapPss Dirty 和 Private Dirty 一起读
Java Heap 24→25MB 几乎不动 8.0 起 Bitmap 像素在 native heap,Java 堆只剩壳 别拿 heap dump 判图片内存,用 heapprofd 采 native
HPROF 里泄漏的 Bitmap 壳 retained size 才 2KB 壳背后挂着几十 MB native 像素,HPROF 不统计 查「谁引用壳」,不看 retained size 绝对值
旋转十次 Activities 计数从 1 涨到 11 静态单例存了 Activity Context 改 applicationContext 或 WeakReference
退出页面 30 秒后回调还在改已销毁的 View Handler 延迟 Message/Runnable 捕获 Activity onDestroy 里 removeCallbacksAndMessages(null)
退出 Compose 页面后仍有一条到 Activity 的强引用 remember 里存了带 Context 的对象或捕获 lambda 用 applicationContext 或 DisposableEffect 清理
上传任务随 ViewModel 销毁被取消 盲目把 MainScope() 换成 viewModelScope 自建 scope 在 onCleared() 里 cancel(),并清回调
长按取色一进报告页就崩 报告图全用了 HARDWARE Bitmap 读像素路径走普通 RGBA_8888 解码
inBitmap 复用在 8.0 上几乎不命中 按 3.0 那套「尺寸完全一致」判断 4.4 起只比较 allocationByteCount >= 所需
一张 1080×1920 图占 8MB 没做 inSampleSize inJustDecodeBounds 两段式解码,压到 1.1MB 左右
动 WebP 封面把整段动画常驻内存 自己缓存了 List<Bitmap> 全帧 ImageDecoder + AnimatedImageDrawable
超大病理切片图一打开就 OOM 整张解码进内存 BitmapRegionDecoder 按可视区域取块
调了 System.gc() 之后 PSS 没降反而不稳 GC 只发现断引用,断不了引用;还拆了暂停 删掉,靠引用清理和 ART 自己的时机
开 largeHeap 后单进程 PSS 稳了、被杀反而更多 占内存多的进程优先被 lmkd 收 关掉,改用 onTrimMemory 分级裁剪
recycle() 后抛 Canvas: trying to use a recycled bitmap 8.0 后靠 NativeAllocationRegistry,图仍被 ImageView 引用 别手动 recycle(),清引用即可
软引用缓存 OOM 前根本来不及清 ART 对 SoftReference 回收时机无保证 换有硬上限的 LruCache
拆多进程后整机 PSS 反而涨 每个新进程重新 mmap 共享页,摊到更多进程 能合的合回主进程,别拿多进程「省内存」
切回前台首帧慢了两帧多 trim 所有 level 一律清空缓存 按五档分档裁,前台档保持满额
onTrimMemory 里做同步 IO 把主线程卡住 回调在主线程执行 只做轻量释放,重活挪到后台
RUNNING_LOW 时清了磁盘缓存,回前台要重下 误把「前台吃紧」当成「后台可裁」 那两级只停预读、调激进采样
2GB Go 机 N1 落在 179MB,比基线 168MB 高 机型基差:共享页与系统图形缓冲天然偏高 阈值按机型分别定,不跨机型比绝对值
getHistoricalProcessExitReasons 在 Android 10 上崩 该 API 是 API 30 才加入的 SDK_INT >= R 判断后安静降级
ANR / native crash 拿不到 tombstone 没读 getTraceInputStream() 在 REASON_ANR / REASON_CRASH_NATIVE 时落盘
.so 对齐检查过了,16KB 模拟器仍崩 只查了本模块 .so,漏了 SDK 带进来的 以 `unzip -l
把 PAGE_SIZE 宏从 4096 改成 16384,4KB 设备仍错 编译期页大小假设本身就是错的 删掉假设,用 getpagesize() / sysconf(_SC_PAGESIZE)
CI 门禁在 2GB 机上误报 阈值写成全局绝对值 baseline.json 按机型分组存相对阈值
归档目录被下次构建覆盖,evidence 串版本 目录名只带 versionName 改成 <versionName>-<commit>,同名直接失败

有几条值得多说两句。

「PSS 涨了」和「Private Dirty 没怎么涨」同时出现,永远先看 SwapPss。 zRAM 把匿名页压缩后仍放在 RAM 里,PSS 里记着,只是不常驻。低内存机型上压缩/解压抢 CPU,帧率掉,lmkd 最后还是按 PSS 和优先级收进程。开篇那 80MB 里最扎眼的一列就是它,我第一遍差点当成噪声划掉。

Activities 计数比任何泄漏库都直观。 dumpsys meminfo 的 Objects 段里这一行,旋转十次回到 1 就说明引用链剪干净了,涨到 11 就一定有地方把 Activity 挂住了。它不需要引依赖,也不需要 dump。

别把「未来版本行为」写成当前现状。 16KB 卡点日期、ART GC 的默认收集器、ApplicationExitInfo 的新常量,这三类都会随版本动。第 2 轮和第 12 轮都吃过这个亏,现在每条结论后面都跟着版本号与出处。

命令速查

排查时基本只敲这几组。先给一份完整输出片段,这段是第 9 轮回归后在 Pixel 4a(Android 13 / API 33)上采的稳态样本,模块 M 详情页静止 30 秒:

text 复制代码
$ adb shell dumpsys meminfo com.example.health
Applications Memory Usage (in Kilobytes):
Uptime: 45231122 Realtime: 45231122

** MEMINFO in pid 18234 [com.example.health] **
                   Pss  Private  Private  SwapPss     Heap     Heap     Heap
                 Total    Dirty    Clean    Dirty     Size    Alloc     Free
                ------   ------   ------   ------   ------   ------   ------
  Native Heap    41230    41188        0      812    65536    48922    16614
  Dalvik Heap     6328     6104       64     1104    24576    18904     5672
 Dalvik Other     2840     2836        0       12
        Stack      816      816        0        0
       Ashmem        4        0        0        0
      Gfx dev    18420    18420        0        0
    EGL mtrack     3120     3120        0        0
       Unknown     2140     2136        4       40
        TOTAL    75102    74620       68     1968   90112    67826    22286

 App Summary
                       Pss(KB)                        Rss(KB)
                        ------                         ------
           Java Heap:     6168                          32140
         Native Heap:    41188                          41200
                Code:     2104                          18360
               Stack:      816                            816
            Graphics:    21540                          21540
       Private Other:     3100
              System:      186                           1280
               TOTAL:    75102          TOTAL SWAP PSS:     1968

 Objects
               Views:       84         ViewRootImpl:        1
         AppContexts:        4           Activities:        1
              Assets:        2        AssetManagers:        0
       Local Binders:       18        Proxy Binders:       24
    Parcel memory:        6          Parcel count:       24
 Death Recipients:        0       OpenSSL Sockets:        0

逐项判读我按这个顺序看:

字段 读什么 这次的值说明什么
Pss Total(TOTAL 第 2 列) 系统算账口径,私有页全额 + 共享页按进程数摊 75102KB,是全表最该盯的数
Private Dirty(第 3 列) 被本进程改写、不能换出的页,最贵 74620KB,绝大部分是 native 与图形缓冲
Private Clean(第 4 列) 本进程独占、内核可直接回收的干净页 68KB,很少,说明几乎没有可白拿的干净页
SwapPss Dirty(第 5 列) 压进 zRAM 的匿名页份额 1968KB,这次不高;灰度那次是 41240KB
Heap Size / Alloc / Free ART 堆的逻辑大小 / 已分配 / 空闲 Size 是逻辑上限不是物理占用,别拿它当 PSS
Native Heap C/C++ malloc、mmap 匿名页 41230KB,8.0 后 Bitmap 像素主要落这里
Dalvik Heap / ART Heap Java/Kotlin 对象占的页 6328KB,heap dump 只看得见这一行
Gfx dev / EGL mtrack gralloc、EGL 图形缓冲 合进 App Summary 的 Graphics 21540KB
Ashmem ashmem 共享内存 4KB,cursor / surface buffer 常走这类
Code .so / .dex / resources.arsc 的 mmap 干净页 2104KB,可被内核直接回收
System 系统侧摊到本进程的份额 186KB
Objects → Activities 当前存活 Activity 数 1;旋转十次应回到 1,涨了就是泄漏
TOTAL SWAP PSS zRAM 占用总量 1968KB

App Summary 那几行加起来要等于 TOTAL(6168 + 41188 + 2104 + 816 + 21540 + 3100 + 186 = 75102),对不上说明我采的时候进程正在变,重采一次。

常用命令按用途分组:

bash 复制代码
# 一、账本:PSS 与各分项
adb shell dumpsys meminfo com.example.health
adb shell dumpsys meminfo com.example.health | grep -E 'TOTAL|Native Heap|Dalvik Heap|Graphics'
bash scripts/memory/collect_meminfo.sh com.example.health 20 0.5

# 二、同口径的原始页账(PSS 就是按 smaps 摊出来的)
adb shell cat /proc/18234/status | grep -E 'VmSize|VmRSS|VmSwap'
adb shell procrank                       # 部分机型自带,按 PSS 排序
adb shell cat /proc/18234/smaps | grep -E 'Pss|Private_Dirty|SwapPss'

# 三、进程优先级与回收顺序
adb shell dumpsys activity processes | grep -E 'oom|adj|IMPORTANCE'
adb shell dumpsys activity oom
adb shell cat /sys/block/zram0/disksize   # zRAM 大小,部分机型路径不同
adb shell getconf PAGESIZE                # 当前设备页大小,4KB 还是 16KB

# 四、Java 堆与泄漏
adb shell am dumpheap 18234 /data/local/tmp/m.hprof
adb pull /data/local/tmp/m.hprof .
$ANDROID_HOME/platform-tools/hprof-conv m.hprof m-conv.hprof
adb logcat -s StrictMode                  # detectActivityLeaks 的日志在这
adb shell dumpsys meminfo com.example.health | grep -i activities

# 五、native heap 采样
bash scripts/memory/heapprofd_capture.sh com.example.health 30

# 六、低内存压力与进程死亡演练
adb shell /data/local/tmp/stressapptest -M 256 -s 60   # 需先把二进制 push 到设备
adb shell am kill com.example.health
adb shell getprop ro.product.model

# 七、16KB 对齐
bash scripts/memory/check_elf_alignment.sh app-release.apk
readelf -lW lib/arm64-v8a/libcrypto.so | grep LOAD
adb shell setprop bionic.linker.16kb.app_compat.enabled true   # 强制开兼容模式,仅调试

hprof-conv 那步别省,Android 导出的 HPROF 和桌面分析工具的格式有差异,不转直接拖进 Memory Profiler 会读不出来。导出后先看支配树里 Bitmap 壳被谁引用,再看 Activities 计数对不对,这两步能分掉大半的「是不是泄漏」的疑问。

stressapptest 不是系统自带的,我在自己机器上是先 push 到 /data/local/tmp/ 再跑的,正文里写的路径是我这台设备的记录。加压期间配合 collect_meminfo.sh 每 500ms 采一次,能看到 SwapPss Dirty 怎么一步步涨上来、以及哪一级 trim 之后缓存被裁掉。

getconf PAGESIZE 是我在 16KB 模拟器上确认页大小时最常用的,比翻系统属性直接。它返回 16384 就说明这台是 16KB 页设备,这时候没对齐的 .so 会在加载期直接出问题,不用等到运行时崩。

上线前我盯的几个数

不进表格但每次都要确认的:baseline.json 里每格有没有带机型与样本量、REASON_LOW_MEMORY 的聚合窗口是不是 7 天、16KB 对齐结果是不是 true、以及四个数里有没有哪一项只在 6GB 机上好看。四个数都由 collect_meminfo.sh 同一套口径出,机型与样本量写死在表里,改口径要同步改表。

数 测量口径 机型 / 样本量 v5.8.2 v5.9.0 灰度 v5.9.1 定稿 我定的阈值
N1 稳态 TOTAL PSS 进模块 M 详情页静止 30s,dumpsys meminfo 取 TOTAL,中位数 Redmi Note 9(4GB / API 30),20 次 168MB 248MB 191MB ≤ 200MB
N2 后台 10 分钟残留 PSS 按 Home 键计时 10 分钟,取 TOTAL,中位数 Redmi Note 9,20 次 118MB 214MB 96MB ≤ 120MB
N3 每千次冷启动 REASON_LOW_MEMORY getHistoricalProcessExitReasons 按 reason 聚合 ÷ 冷启动数 ×1000 灰度 5% 流量,7 天窗口 6 21 7 ≤ 9
N4 大图列表 Graphics PSS 峰值 每 500ms 采一次 dumpsys meminfo,取 Graphics 列峰值 Pixel 4a(6GB / API 33),10 次滑动 19MB 61MB 24MB ≤ 30MB

有一格我特意没藏:2GB Android 10 Go 机上的 N1 定稿后是 179MB,比 v5.8.2 的 168MB 高 11MB,没压回基线。原因是这台机 RAM 太小,Zygote 共享页摊到本进程的份额、系统侧图形缓冲本身就比 6GB 机高,属于机型基差,不是代码退步。我给这台机单独设了 195MB 的阈值,写在 baseline.json 的 Go_2GB 那一组里。N1 不跨机型比绝对值,这条是第 9 轮补跑后才定下来的,一开始用全局 190MB 卡所有设备,2GB 机误报、6GB 机形同虚设。

误差和回归也得写清楚。N1 从 248MB 降到 191MB,离灰度前的 168MB 还差 23MB,这部分主要是几个 SDK 带进来的 .so 与 resources.arsc 共享页,重编对齐后摊到本进程的份额没再降下去,我没把它算成「优化到位」。N3 从 21 回落到 7,比灰度前的 6 多 1,落在误差范围内;2GB 机上用 stressapptest 加压时被杀次数仍偏高,那台机可用 RAM 本就贴着系统底线。四个数都是我在自己机器上跑出来的记录,换一批机器、换一个系统版本,中位数会动,所以 CI 里存的是相对上版的浮动比例,不是这几个绝对值。

还能往下挖的地方

按自己的排期,后面想继续做的这些,顺序大致按投入产出比排,每条都写清要解决什么、代价是什么:

把 ApplicationExitInfo 和灰度看板打通。 现在 ExitReasonReporter 只打 logcat、把 tombstone 落到 cacheDir,N3 靠人工聚合。想做成按版本、按机型看 REASON_LOW_MEMORY 与 REASON_SIGNALED 的分别曲线,代价是要定一套不上传真实用户数据的聚合口径,以及处理 API 29 及以下机型天然没有这个信号的盲区。

heapprofd 做长期 native 趋势。 这次只在排查阶段采了一次。想让 CI 每月拿最新包跑一轮 native heap 采样,输出各分配点的累计字节趋势,比一次性的归因有用。代价是采样本身会拖慢被测机,得控制时长与 sampling_interval_bytes,不能放到每次提交。

机型分档预算自动化。 MemoryBudget 现在按 getMemoryClass() 加一个固定下限算档位,低内存机型的下限是第 11 轮手工调的。想把 isLowRamDevice、实际可用 RAM、页大小都纳入分档输入,代价是分档规则变复杂,回归矩阵要跟着扩,容易又变成「为调参而调参」。

16KB 对齐之后的 NDK 升级演练。 现在 11 个 .so 全 ALIGNED,但有三个是找 SDK 厂商要的对齐版。等 targetSdk 升到 36、或 AGP/NDK 大版本升级,得把 check_elf_alignment.sh 和 page-size-16k.md 里那两栏日期一起复核。代价是每个 SDK 的对齐版要重新要一轮,短期看不到收益。

GC 暂停与 16ms 帧预算的关联。 我在 Pixel 4a 上量到过 3 帧因 GC 超过 16.7ms,但没系统测过。想把 FrameTimeline 和 GC 日志对起来,看分代 GC 与 userfaultfd 那套在不同版本上到底差多少。代价是要在多台机上跑可复现的滑动脚本,样本量不小,且结论很可能只对特定 ART 版本成立。

zRAM 压缩率的机型差异。 这次 SwapPss Dirty 涨了 31MB,但它和帧率、被杀概率之间的定量关系我没量。想在不同 RAM、不同内核的机型上采压缩率与解压耗时,代价是需要 root 或系统侧权限,普通灰度机拿不到。

Compose remember 泄漏的自动化扫描。 第 5 轮靠人工翻代码找出 remember 捕获 Context 的那处。想把 scan_static_refs.sh 扩成能识别 remember { } 里捕获 LocalContext 的写法,代价是静态分析容易误报,得配一份允许清单。

onTrimMemory 各 level 的实机触发频率。 现在分级表是按文档语义写的,没统计过真实用户手里各级各触发多少次。想埋点统计后据此再调档位力度,代价是埋点本身要在低内存场景下可靠落盘,而那正是进程最容易没的场景。

共享页分摊随进程数变化的定量。 多进程那笔账这次是定性结论------合回主进程后整机 PSS 降了,但没量过「每多一个进程,Zygote 共享页摊到本应用的份额具体涨多少」。想做个最小实验量一次,代价是要造多个稳定常驻的进程,和合规清单里「不写保活」的边界得先对齐。

ART GC 再演进后的口径复核。 13 的 userfaultfd GC 已经把 RSS cliff 抹平了,往后若默认收集器再换,SwapPss、Private Dirty 这几列的读法可能跟着变。这条投入最小、但最容易被忽略,放进 future-review.md 里当每次升 targetSdk 的固定检查项就行。

后来

那台 Redmi Note 9 现在还搁在工位抽屉里,屏幕角上有一道灰度那周磕出来的裂痕。前几天同事在群里丢了一句「新版本内存好像又涨了」,我手比脑子快,回的是「你说的是哪一列」。

他过了半分钟发来一张 dumpsys meminfo 的截图,TOTAL 那行圈了个红框。我把抽屉拉开,插上线,先敲了一遍那条命令,才去开 heap dump。

相关推荐
TimeFine2 小时前
AI 编程工作流的设计:从人工判断到 AI裁判
android·ai编程
阿杰m2 小时前
Android源码第四章:Android 系统启动——App 的第一行代码从哪来?
android
疯华整猫6 小时前
混淆起初根本不是为了防破解:我用 Trae 把 Android 混淆这二十来年重跑了一遍
android·人工智能·计算机视觉
Carson带你学Android7 小时前
AI时代,你的 App 会被 Agent 这三种方式调用
android·ai编程
恋猫de小郭8 小时前
Flutter 下 AOT 一个你怎么都想不出来的 Bug ,JIT 正常的计算,在 AOT 居然算错了
android·前端·flutter
好的佩奇1 天前
Dart 之扩展方法
android·flutter·dart
三少爷的鞋1 天前
换个思路理解:非阻塞挂起
android
好的佩奇1 天前
蓝牙之经典蓝牙
android·flutter·蓝牙
传奇开心果编程1 天前
【Flutter踩坑排雷】第19课:路由硬编码坑|字符串路由书写错误,页面跳转失败排查
android·flutter·ios·移动开发·跨平台·ui框架·声明式ui