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 只截了最上面那一截:
图里那条虚线就是我那两天踩的坑: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 像素的链:
这张图里最容易被忽略的是最后那条虚线:泄漏的只是一个几十字节的 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 轮照着这张图落地,省了一轮返工:
有一处返工得单独记。 我最早照着一份 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。