全 AI 实战: 一次语音房 Flutter内核 native 内存泄露的定位与修复

构建:Flutter profile/release,Impeller + Vulkan,Flutter SDK 固定为 3.41.5

这篇记录复盘一次语音房 native 内存问题的完整定位和修复过程。它不是只记录最终数值,而是把中间的误判、采集工具修正、符号准备、对象持有链、修复实现和复测边界都写下来,便于后续继续排查剩余增长时不重复走弯路。

本文采用"总-分-总"的结构:先给整体投入和结论,再展开 native heap 与 Dart root path 的定位过程,最后回到修复效果和后续边界。

总:整体耗时、规模和结论

这次问题不是一个"看一眼 DevTools 就能确认"的 Dart 泄露,而是 Flutter engine、Skia/FreeType、Impeller glyph atlas、Riverpod 作用域、VM Service 诊断工具和 Android native PSS 交叉在一起的问题。可量化的实机采集与复测从 2026-09-10 12:36:41 开始,到 21:25:44 的 2 小时复测被动采集结束,跨度约 8 小时 49 分钟;中间还包括自定义 engine 构建、符号校验、工具修正、回归测试、profile/release APK 构建和多轮进退房验证。

最终留下两批核心证据:

  • 修复阶段 artifacts 约 11 GiB,evidence-sha256.txt 覆盖 4937 个文件,并通过逐文件复验。
  • 2 小时复测 artifacts 约 1.3 GiB,evidence-sha256.txt 覆盖 1092 个文件,并通过逐文件复验。
  • 两批数据合计约 12.3 GiB、6029 个已哈希验证文件。
  • 2 小时复测完成 5 个正式采样点,130 次受保护面板操作,同一 PID,未重启应用。

阶段性结论:

  • 原始场景里曾观察到 Native Heap PSS 从约 213.5 MiB 增长到 769.8 MiB,增长约 556.3 MiB。
  • 最大的已确认 native 保留问题是 OPPO 中文字体 face 重复 clone 后被 Impeller glyph atlas 间接持有,原版 OPPO SC 相关 variation 数组可达到约 354.5 MiB。
  • 另一个已确认问题是资料卡 Riverpod 子作用域释放不完整,关闭资料卡或退房后仍能从 GC root 追到资料卡 controller、公屏 controller 和公屏 render tree。
  • 已分别修复字体身份复用和资料卡作用域捕获问题,并通过实机对象级验证。
  • 修复后 2 小时留房复测没有复现 500+ MiB 增长,但 Native Heap PSS 仍从 141.21 MiB 增长到 208.40 MiB,增长 67.19 MiB。这个剩余增长不能忽略,也不能在当前证据下直接说成 Dart 泄露或公屏泄露。

AI 在这次排查中做了什么

这次修复过程里,AI 的价值不是"猜一个优化点",而是把一个跨层问题拆成可验证的证据链,并持续修正会污染结论的工具。整个过程中 AI 主要承担了五类工作。

第一类是排查编排。AI 根据用户要求控制实机进入房间、在每次采样前遍历房间内常见面板、记录时间点、执行 GC、抓 Dart heap、采 native meminfo、保存 screenshots/XML/JSON,并在 guard 条件不满足时停止而不是盲点。这样每个采样点都有"当时确实在房间内、确实操作过哪些入口、采样时 PID 是否一致"的证据。

第二类是符号和构建闭环。AI 没有把 stripped APK 里的地址当成不可解释的黑盒,而是找到 profile 构建里的未剥离 libflutter.so、匹配 libapp.so / AOT 信息,检查 build ID,打包 Perfetto 符号 bundle,并记录有效函数名覆盖。后续自定义 engine 修复也由 AI 固定 Flutter commit、构建 Android profile/release engine、生成 APK、回读设备上安装包 SHA-256,再和本地 manifest 对齐。

第三类是分析工具修正。AI 在排查中发现并修复了 heapprofd buffer overrun、timeline 元数据污染、VM Service object ID zone 强持有、UI 自动化旧 XML 复用、native 扫描非驻留页扰动、空 symbol 覆盖真实 frame name 等问题。每一个修正都带测试或复验,否则这次结论很容易把诊断工具的副作用当成业务泄露。

第四类是跨层对象链还原。native 侧,AI 没有停在 heapprofd callsite,而是继续用 DWARF 结构偏移和 /proc/<pid>/mem 只读读取,追到 GlyphAtlas -> ScaledFont -> TypefaceSkia -> SkTypeface -> FreeType face -> glyphoffsets。Dart 侧,AI 用 VM Service GC、heap snapshot、retaining path、全对象 BFS 索引和字段检查,从 GC root 反查到 ExternalProviderSubscription -> provider override -> RoomProfileCardSheet.onStartPrivateChat -> _VoiceRoomPageState

第五类是约束结论。AI 明确区分"已修复问题"和"剩余观察项":OPPO 字体 face 重复和资料卡作用域泄露可以写成已修复;修复后 67.19 MiB native PSS 增长只能写成剩余 native 渲染/cache/driver 侧候选方向,不能直接说成公屏、Dart 未 GC 或 DrawImageRect retained leak。

关键提示词如何驱动排查

这次排查能持续深入,关键不只是 AI 自动执行,而是提示词持续把方向从"泛泛看内存"压到"对象级证据"。几个关键提示约束了整个过程:

  • "刚进入房间时统计一下内存对象数据,我说再统计的时候,你再统计下,看下多出哪些对象":要求建立 t0 基线,而不是只看单点内存。
  • "可以使用 --profile 版本":明确允许使用 profile VM Service、Dart GC、heap snapshot 和 retaining path。
  • "I ran gc. so check again":要求区分 GC 前后的 Dart 可回收对象和真正 retained 对象。
  • "what does occupy the 260-280M memory?":把问题从总量追问到对象归属,推动 native heap profile 和符号化。
  • "we need a native heap profile?"、"Can u do it?":将排查升级到 heapprofd / Perfetto native sampled heap。
  • "为什么不自己安装 trace_processor_shell":要求 AI 补齐分析工具链,而不是停在环境缺失。
  • "直到把泄露的问题找出来":把目标从生成报告改成闭环定位和修复。
  • "需要深入到 flutter 渲染层哪些对象被持有了,持有这些对象在 dart 层是谁引用了":明确要求 native 对象链和 Dart root path 双向分析。
  • "你跟踪的是 1 小时房间内所有对象,不只是资料卡":防止把单一入口误当成整体房间结论。
  • "每次采集对象前,把房间内该有的面板尽量每个界面都点击 1 次":定义了正式采样前的覆盖动作。
  • "发现了 bug 要记录下来,或者修复也行,一个 bug 一个提交":要求把诊断工具 bug、业务 bug、engine bug 分开记录和提交。
  • "现在东西太少了,估计都产生不了多少内存泄露":推动重新跑更真实、更长时间的房间负载。
  • "测试 2 小时,每半小时提取一次数据":形成最后的 0/30/60/90/120 分钟复测。

这些提示词的共同点是:不断要求 AI 拿证据,而不是给解释;不断要求按真实设备、真实房间、真实时间线推进,而不是用模拟数据或短时样本替代。最终的排查路线也正是由这些约束塑形:先做基线,再做增量,再做 GC 后复核,再上 native heap profile,再补符号,再读 native 对象,再回到 Dart GC root,最后用长测验证修复是否复现。

1. 问题从哪里开始

这次排查的触发点是语音房长时间停留后,进程内存持续增长,用户体感和系统观测都指向房间内页面存在明显内存压力。最初的问题不是一个简单的 Dart 对象泄露,因为执行 GC 后 Dart heap 没有表现出与 500 MiB 级别增长相匹配的上涨。

因此排查方向很快转到 native 侧:

  • Flutter profile 构建,保留 VM Service,便于在留房过程中触发 GC、抓完整 Dart heap、查 retaining path。
  • 准备未剥离的 libflutter.solibapp.so 符号,避免只看到 _kDartIsolateSnapshotInstructions 或匿名 native 地址。
  • 用 Perfetto/heapprofd 做 native sampled heap,并结合 /proc/<pid>/smapsdumpsys meminfo、Dart heap snapshot、native typed census 交叉验证。
  • 每个采样点前都执行受保护的房间面板遍历,尽量覆盖在线用户、资料卡、排行榜、公告、礼物、工具菜单、消息入口、小时榜等常见入口。

这里最重要的原则是:不能只凭一个指标下结论。Native Heap PSS、GL mtrack、Dart heap used、Dart shallow bytes、heapprofd sampled bytes、FreeType 数组 payload 都是不同口径,不能直接相加,也不能互相替代。

2. 为什么必须先解决符号问题

最早的 native 栈里能看到 Flutter/Impeller 渲染路径,例如:

  • impeller::DlDispatcherBase::drawImageRect
  • impeller::Canvas::DrawImageRect
  • flutter::DrawImageRectOp::dispatch
  • flutter::DisplayList::Dispatch
  • impeller::RenderToTarget
  • flutter::GPUSurfaceVulkanImpeller::AcquireFrame
  • impeller::TypographerContextSkia::CreateGlyphAtlas

这些栈说明语音房内的图片、文字、列表、资料卡和榜单确实在驱动渲染分配,但这还不是"谁保留了内存"的证据。DrawImageRect 的大量分配可能只是分配 churn;它出现在分配栈里,不等于它对应的对象最终被保留。

所以后续必须带符号表继续做:

  • 使用 profile 构建产物中的未剥离 libflutter.so
  • 使用匹配 APK 的 libapp.so / AOT 符号。
  • 检查 build ID,确认 trace、APK、符号来自同一套二进制。
  • libflutter.solibapp.so 进行有效函数名覆盖检查。

最终主要根因路径里的 Flutter/Dart 源码、native 类型和实际引用链都拿到了。第三方 LiteAV/IM 的私有库因为本 APK 内是 stripped 版本,只能按库做有限归类,不能冒充对象级归因。

2.1 native heap 对象是怎么查到的

native heap 和 Dart heap 最大的区别是:native heap 本身没有统一的"对象表"。Dart VM 可以告诉我们某个 isolate 里有哪些对象、对象属于哪个 class、对象引用了谁;但 libc malloc 只知道某段内存是被分配出来的,不知道它是 GlyphAtlasTT_FaceRec_、Vulkan command buffer,还是某个 SDK 内部结构。

所以这次 native 对象定位分成三层做。

第一层是确认"增长在 native heap"。每个正式采样点都保存 dumpsys meminfo,并取明细里的 Native Heap PSS,而不是 App Summary 里的 Native Heap private dirty。PSS 是进程实际分摊驻留内存口径,适合看长时间增长趋势;private dirty、GL mtrack、Dart heap used 都保留,但不和 PSS 混算。

第二层是用 heapprofd 找增长 callsite。heapprofd 记录的是 sampled malloc/free 事件和分配栈,因此它回答的是"哪些调用路径产生了仍存活的 sampled allocation"。分析时不能只看累计 alloc,也必须把同一时间窗口内的 free 算进去。本轮工具里 native_heap_analysis.py 会按 heap_profile_allocation 计算:

text 复制代码
live bytes at T = sum(alloc_size * alloc_count) - sum(free_size * free_count)

并且保留负数 callsite 和长尾项。这样可以避免一个路径早期分配很多、后面已经释放,却还被误认为 retained growth。

第三层是从 callsite 走到真实 native 对象。heapprofd 只能告诉我们栈里有 TypographerContextSkia::CreateGlyphAtlas、FreeType、HarfBuzz 或 DrawImageRect,但它不能直接说"这个 malloc 对应的 C++ 对象现在被谁持有"。所以后面做了 typed native census:

  • 用匹配 libflutter.so 的 DWARF 信息导出结构体字段偏移,保存为 native layouts。
  • llvm-readelf / build ID 确认 APK 里的 libflutter.so 和用于解析的未剥离符号是一致的。
  • 通过 adb run-as com.xxx.www 以应用身份只读打开 /proc/<pid>/mem
  • 读取 /proc/<pid>/maps,只在目标进程已映射、可读、驻留的地址范围内解析,避免把分页读入造成诊断扰动。
  • 对 FreeType face list 做 font_census.py,统计 face family、style、variation 数组地址和 payload 大小。
  • 对 Impeller glyph atlas 做 atlas_census.py,扫描并校验 GlyphAtlasContext,再沿 shared_ptr / sk_sp / FreeType 指针链逐段解引用。
  • 对 Vulkan recycler 做 render_census.py,只读统计 pool、cached command buffer、pending buffer、waiting fence 和共享待回收队列。

这里的关键不是"扫到一个地址就算对象",而是每一层都要做结构校验。例如 atlas context 需要满足纹理尺寸、map capacity、entry 数量、指针落在合法映射、shared_ptr 形态、typeface 链路能继续解到 FreeType face 等条件。FreeType face 也不是只看字符串,而是同时读 family、style、blend、glyphoffsets 指针和元素数,再把 payload 大小算出来。

最终我们不是从 heapprofd 直接宣称"字体泄露",而是把三类证据对上了:

  • PSS 证明 native 驻留在增长。
  • heapprofd 证明文字 shaping / glyph atlas / FreeType 路径有 retained sampled allocation。
  • typed census 证明 Impeller glyph atlas 通过 typeface 链实际持有大量 OPPO SC FreeType face,且每个 face 下面有 235,384 bytes 的 glyphoffsets 数组。

因此,OPPO Sans 4.0 SC 1579 个 face、约 354.5 MiB 数组 payload 这个结论不是按栈名猜的,而是通过进程内存里的 native 对象字段读出来的。

2.2 为什么不能把 native 栈直接当成对象归属

这次有一个容易误判的点:DrawImageRect 在 allocation churn 中很显眼,但它不是这次已确认的 retained leak。

原因是 heapprofd 的 callsite 表示"谁分配过",不是"现在谁持有"。一个绘制路径可以产生大量短生命周期分配,只要后续释放,它就不是 retained growth。相反,字体问题的关键在于我们能从 glyph atlas 的长期对象一路解到 FreeType face 和 glyphoffsets 数组,说明它被 cache/map/shared pointer 持有。

所以本轮的判断标准是:

  • 只有分配栈:只能叫候选方向或分配 churn。
  • 分配栈 + 同类对象数量增长:可以提高怀疑,但仍不是最终归因。
  • 分配栈 + typed native object + 可验证持有链 + payload 对账:才能写成已确认 native 保留问题。

3. 第一个根因:OPPO 中文字体 face 被重复保留

真正把问题从"渲染路径很热"推进到"某类 native 对象被持有"的,是 FreeType face 和 Impeller glyph atlas 的只读内存遍历。

在原版进程中,通过 run-as 只读访问 /proc/<pid>/mem,结合本次 ELF 的 DWARF 结构偏移,遍历 FreeType driver face list 和 Impeller atlas。这里没有调用目标进程函数,也没有写目标进程内存,只做只读读取。

关键发现:

  • 某个时点 FreeType face list 中有 462 个 OPPO Sans 4.0 SC face。
  • 这些 face 分布在 400、500、600、700 四种字重。
  • 每个 OPPO SC face 的 glyphoffsets 数组 payload 是 235,384 bytes。
  • 462 份数组合计约 103.7 MiB。
  • 更晚的原版连续会话中,OPPO SC face 达到 1579 个,相关数组约 354.5 MiB。
  • 主 glyph atlas 中也能交叉匹配到大量 atlas 持有的 OPPO SC face,数组 payload 约 331.8 MiB。

这说明内存不是凭 heapprofd 栈猜出来的,而是实际读到了 native 对象链:

text 复制代码
GlyphAtlasContext.atlas_ (shared_ptr)
  -> GlyphAtlas.font_atlas_map_[ScaledFont]
  -> ScaledFont.font.typeface_ (shared_ptr)
  -> TypefaceSkia.typeface_ (sk_sp)
  -> SkTypeface_AndroidSystem / SkTypeface_proxy.fRealTypeface
  -> SkTypeface_FreeTypeStream.fFaceRec
  -> FaceRec.fFace
  -> TT_FaceRec_.blend
  -> GX_BlendRec_.glyphoffsets

这条链非常关键,因为它证明:

  • 字体 variation 数组确实在 native 内存里。
  • 这些数组不是一次性临时分配后自然消失。
  • Impeller glyph atlas 的字体 map 会跨帧持有 ScaledFont,进而持有 Skia typeface 和 FreeType face。
  • 即使 Dart paragraph 释放了,引擎共享 atlas 仍可能继续持有这些 face。

4. 字体为什么会重复

对应 Flutter 3.41.5 和匹配 Skia revision 的代码路径如下:

  1. Flutter paragraph 样式会把 FontWeight 转换为 wght variation 参数。
  2. FontCollection::defaultFallback 根据字符、语言和字体 manager 找 fallback 字体。
  3. 匹配到字体后调用 FontArguments::CloneTypeface
  4. CloneTypeface 调用 SkTypeface::makeClone
  5. 在 OPPO PJV110 上,相同中文字体、相同字重会产生大量不同 SkTypeface / FreeType face。
  6. Impeller 的 TypefaceSkia::GetHash/IsEqual 使用 SkTypeface 对象身份。
  7. 不同 clone 身份会变成不同 glyph atlas 字体项。

也就是说,业务层只是正常显示中文文本,但 engine 层把相同字体参数的 fallback clone 变成了大量不同 native face。公屏、榜单、昵称、资料卡都可能触发这条路径。公屏是已观察到的触发入口之一,但 atlas 是引擎共享资源,不能把全部 atlas 字节都说成公屏独占。

5. 字体修复怎么做

修复没有先改业务文案、没有先绕过中文字体,也没有直接裁剪 atlas。因为已确认的保留机制发生在 engine fallback typeface clone 身份上,所以修复点放在 Flutter engine 使用的 Skia FontCollection

实现思路:

  • 在独立 engine checkout /tmp/engine-remediation/flutter 上固定 Flutter commit 2c9eb20739dfec95e2c74bd3dfa4601b0a8a36aa
  • 不升级共享的 ~/sdk/flutter
  • 在 fallback 字体 clone 处增加复用缓存。
  • 以原始字体 unique ID 和完整 FontArguments 作为 key。
  • 字重、variation、collection index、palette 参数都参与相等判断。
  • 字体匹配仍保持原顺序:先按字符、语言和 manager 找到正确 fallback,再复用这个 fallback 的 clone。
  • 使用已有 SkLRUCache,最多 256 项。
  • clearCaches() 时清理新增缓存,避免长期引用无法释放。

这个修复的本质是:不改变字体选择和排版语义,只稳定相同 source/arguments 的 typeface 身份,避免同一个字体参数重复制造 native face。

验证结果:

  • 引擎 FontCollection 相关测试通过,包括相同参数复用、区分字重/来源、clearCaches() 解除引用、缓存有界。
  • Android profile/release 引擎均重新编译。
  • profile APK 中 libflutter.so build ID 与修复符号一致。
  • 仅引擎修复版本中,OPPO SC face 从原来的上千个稳定到 10 个,variation 数组稳定为 2.24 MiB。
  • 后续组合修复和 2 小时复测中,OPPO SC face 仍稳定为 10 个。

注意:这个修复在自定义 engine 里。普通 flutter build 不会自动包含该 engine 补丁,必须使用本轮记录的补丁目录和构建入口。

6. 第二个根因:资料卡作用域泄露房间 UI

字体问题修掉后,长测和退房检查又暴露了另一个问题:真正退房后,仍能在 Dart heap 里看到资料卡和公屏相关对象。

当时的对象包括:

  • RoomProfileCardController
  • RoomChatAnchorController
  • _RetainedRenderList
  • _MeasuredRow

这些对象不是"等待 GC 的不可达垃圾"。完整 heap 最短路径可以从 GC root 追到它们,大致链路是:

text 复制代码
WidgetsFlutterBinding._observers
 -> 首页仍活跃的 ConsumerState
 -> 根 ProviderContainer
 -> currentUserSessionProvider element.dependents
 -> ExternalProviderSubscription
 -> scoped StateNotifierProvider element
 -> override closure
 -> RoomProfileCardSheet.onStartPrivateChat
 -> _VoiceRoomPageState
 -> 公屏 element/render tree

这里首页 ConsumerState 只是进入根容器的一条路径,不表示首页本身泄露。真正的问题是资料卡子作用域里的 provider override 闭包捕获了 Sheet 回调,而回调又捕获了房间页面状态,最后把公屏 element/render tree 一起挂住。

进一步看 Riverpod 3.3.2 的行为,单个 family 实例 override 的 pointer 属于子容器,但 family directory 的 targetContainer 仍可能指向父/根容器。dispose 遍历时按 directory 的 targetContainer 过滤,会漏掉这个实例。于是子 ProviderContainer 已经 disposed,但 provider/controller 仍然没有真正释放。

6.1 Dart 层是怎么从 GC root 反查的

Dart 层反查和 native 层不同。Dart VM 能提供对象图,所以这里的核心是:先确认目标对象在 GC 后仍存活,再从 root 查最短可达路径,最后看路径里哪个业务引用不该继续存在。

实际流程如下。

第一步,进入目标场景并固定采样点。每个采样点前先做受保护面板遍历,让资料卡、公屏、榜单、礼物等入口都至少打开过一次。然后等待 settle,记录 GC 前 meminfo。

第二步,通过 VM Service 触发 GC。profile 版本保留 VM Service,所以可以请求 Dart GC,再抓 allocation profile 和 heap snapshot。这里不能只看一次 getAllocationProfile,因为它给的是 class 维度数量和大小,不能告诉我们对象由谁持有。

第三步,针对重点 class 查询 retaining path。早期目标包括:

  • RoomProfileCardController
  • RoomChatAnchorController
  • _RetainedRenderList
  • _MeasuredRow
  • RoomPublicChatMessage
  • TextPainter
  • RenderParagraph

查询 retaining path 时必须使用独立 VM Service ID zone。原因是 VM Service 的 object ID 会强引用被查询过的对象;如果使用默认 zone,诊断工具本身可能让对象继续活着。修正后的采集流程是:创建临时 ID zone,查询对象和 retaining path,落盘后删除 ID zone,再次 GC,再抓全 heap。

第四步,保存完整 heap.dartheap,离线建立全对象索引。index_heap.dart 读取 heap snapshot 后,从 synthetic root 开始 BFS,给每个可达对象保存一条最短 root path:

text 复制代码
snapshotId
class
library
shallowBytes
externalBytes
rootReachable
parentSnapshotId
parentReferenceIndex
parentField

这个索引解决两个问题:

  • 不只看少数 retaining path 示例,而是能对任意 class 的全部实例重建路径。
  • 可以区分 root reachable 对象和 snapshot 里已经不可达但尚在文件中的对象。

第五步,用 read_heap_paths.py 对目标 class 导出完整路径。例如资料卡问题里,退出房间并 GC 后仍能导出 RoomProfileCardControllerRoomChatAnchorController_RetainedRenderList_MeasuredRow 的完整最短路径。路径里出现了 ExternalProviderSubscription -> provider override -> RoomProfileCardSheet.onStartPrivateChat -> _VoiceRoomPageState,这才把问题从"某些 class 还在"推进到"哪个引用把它们挂住"。

第六步,读取关键对象字段做语义确认。只看到路径还不够,还要检查 controller 的状态和容器状态。例如当时看到:

  • ProviderContainer._disposed 已经是 true。
  • 目标 controller 的 _mounted 仍然是 true。
  • 资料卡 _cardActive 仍然是 true。
  • 公屏 render/list 仍然通过页面状态可达。

这说明它不是"刚退出还没来得及回收",而是子作用域释放后仍有 provider/controller 被父级引用链保留。

6.2 怎样判断 Dart 对象是不是泄露

这次没有简单把"房间里还有对象"都判成泄露。判断标准是结合生命周期:

  • 留在房间内时,RoomChatAnchorController_RetainedRenderList、当前消息列表存在是合理的,因为页面仍 active。
  • 退出房间后,资料卡 controller、公屏 controller、公屏 render tree、消息 widget tree 应该不可达。
  • 多次进退房后,这些对象数量不能累计增长。
  • 如果对象仍可从 GC root 到达,且路径经过已经关闭的 sheet/scope/page 回调,才是泄露证据。

因此,2 小时留房复测里 RoomPublicChatMessage 从 18 到 171 不能单独写成泄露。它是在 active room 中的当前公屏消息,数量也有波动。相反,早期退房后仍保留的 RoomProfileCardController 和公屏 render tree,才是明确的生命周期泄露。

7. 资料卡修复怎么做

资料卡修复的原则是:不要让 provider override 闭包捕获 Sheet 或页面回调。

修复后增加 createRoomProfileCardScopeOverride,让子作用域 family 只捕获必要的 room/user 数据。非目标用户仍走原默认创建路径,不改变兄弟资料卡和普通打开路径。

修复后验证:

  • 关闭子 scope 后,controller mounted 变为 false。
  • 兄弟 scope 不受影响。
  • 实机组合修复长测中,资料卡 controller 全程为 0。
  • 真正退房后,RoomProfileCardControllerRoomChatAnchorController_RetainedRenderList_MeasuredRowRoomPublicChatMessage 均为 0。
  • 三次重复进退房后,上述对象仍为 0。

这说明资料卡关闭和退房后的 Dart 引用链已经被切断。这个问题和前面的 native 字体问题是两类不同泄露:一个发生在 Flutter engine native 字体对象身份;另一个发生在业务层 Riverpod 作用域和闭包捕获。

8. 中间修了哪些诊断工具问题

这次排查过程中,工具本身也暴露出几个容易导致误判的问题。它们不是业务泄露根因,但如果不修,会直接污染结论。

heapprofd buffer overrun

最初长时间 heapprofd 采集出现 heapprofd_buffer_overranheapprofd_client_error。这类 trace 不能用来计算完整 t60 存活量,只能作为无效证据保留。

后续调整采样密度、client buffer、周期 flush、背压和健康检查,确保正式 trace 结束时 stats 没有非零错误项。

timeline 诊断对象污染

原版某轮 trace 中出现 96 MiB 级别的大数组。重新分类后确认它来自 VM timeline async-track 元数据哈希表扩容,不是业务 Dart heap。后续复测前关闭 VM timeline streams,避免把诊断工具自己的内存增长误判为业务泄露。

Dart VM object ID zone 持有对象

VM Service 查询 retaining path 时,如果使用默认 ID zone,查询过的对象会被 VM Service object ID zone 强持有。后续采集器改成每次独立 zone,路径落盘后删除 zone,再 GC,再抓全堆。

这个修正很重要,否则"为了证明对象是否泄露而查询对象"本身可能让对象继续活着。

UI 自动化旧 XML 复用

uiautomator dump 在持续动画页面上可能输出 ERROR: could not get idle state.,但进程返回码仍是 0。旧工具使用固定文件名,有机会读到前一次 XML,造成"语义树和截图不一致"。

后续改为 FreshHierarchy / UiAutomation 实时输出 XML,不等待 idle,不回读旧文件。所有面板点击都用实时语义树、唯一节点、截图和守卫条件确认,防止误点。

native 内存只读扫描扰动

第一次 atlas 全 heap 扫描读到了非驻留页,导致后续 meminfo 的 Native Heap Private Clean 变化明显。这是诊断扰动,不是业务增长。

后续工具改为查询 pagemap,仅扫描驻留页,并优先复用已确认的 context 地址做验证读取。

9. 修复后的长测结果

组合修复版本包含两个核心修复:

  • engine fallback typeface reuse。
  • 资料卡作用域释放修复。

在 65 分钟 profile 低干扰复测中:

GC 后 Native PSS MiB 缓存池数 缓存 command buffer 存活消息对象
t0 132.02 11 88 15
t15 156.40 20 204 58
t30 191.52 27 330 115
t45 213.83 28 366 174
t60 205.65 28 353 150
t65 193.90 29 314 164

这一轮证明了两个已确认问题没有复现:

  • OPPO SC face 全程 10 个,数组 2.24 MiB。
  • 资料卡 controller 全程 0。
  • 退出后公屏和资料卡相关对象归零。

但这轮的 PSS 预算没有完全通过。t15 -> t65 正式端点增长 37.50 MiB,超过了原建议的 30 MiB 预算。后半段有回落,说明存在 cache 高水位和复用,但不能把所有增长说成已经完全解决。

10. 2 小时复测结果

随后又做了 2 小时留房复测。采样点是 0 / 30 / 60 / 90 / 120 分钟。每个点都完成:

  • 26 步受保护面板遍历。
  • settle。
  • 显式 Dart GC。
  • 完整 Dart heap。
  • all-class index。
  • native font / atlas ownership。
  • Vulkan recycler counts。
  • meminfo。

这轮没有启动 heapprofd,目的是避免 profiler buffer 和 timeline 造成额外扰动。

正式数据:

Scheduled minute Native Heap PSS Delta from t0 Dart heap used GL mtrack
0 141.21 0 35.234 460.17
30 167.21 +26.00 35.438 407.39
60 200.21 +59.00 35.445 421.68
90 210.33 +69.13 35.414 421.81
120 208.40 +67.19 35.448 439.46

对象级观测:

Object/owner t0 t30 t60 t90 t120
OPPO Sans 4.0 SC faces 10 10 10 10 10
OPPO SC glyph-offset arrays, MiB 2.24 2.24 2.24 2.24 2.24
RoomProfileCardController 0 0 0 0 0
RoomChatAnchorController 1 1 1 1 1
_RetainedRenderList 1 1 1 1 1
_MeasuredRow 8 8 9 8 8
RoomPublicChatMessage 18 170 121 49 171
TextPainter 512 487 453 474 471
RenderParagraph 74 73 71 72 73
Recycled Vulkan pools 11 16 25 24 25
Recycled command buffers 106 188 264 315 318
Pending command buffers 0 0 0 0 0
Waiting fences 1 0 0 1 1
Shared reclaimable resources 0 0 0 0 0

这组数据说明:

  • 500+ MiB 的大增长没有复现。
  • 字体重复 face 问题没有复现。
  • 资料卡作用域泄露没有复现。
  • Dart heap used 只增长约 0.214 MiB,不支持"主要增长来自 Dart 未 GC"的结论。
  • RoomPublicChatMessage 有波动,但不是单调增长;房间仍在使用时它的存在不能单独证明泄露。
  • Vulkan cached command buffer 数量从 106 增到 318,说明 native 渲染/cache 侧仍是剩余增长候选方向。

这轮仍然不能宣称"所有内存问题已关闭"。Native Heap PSS 从 141.21 MiB 到 208.40 MiB,增长 67.19 MiB;t30 -> t120 增长 41.19 MiB,仍超过 warmup 后 30 MiB 的预算。

11. 为什么不能把剩余 67 MiB 归因给公屏

公屏是重要触发入口,但当前证据不支持"剩余 67 MiB 基本都是公屏"。

原因:

  • Dart heap used 只有 0.214 MiB 级别变化。
  • 全类 shallow bytes 端点比较是下降的,不是增长 67 MiB。
  • TextPainterRenderParagraph 数量没有单调增长。
  • RoomPublicChatMessage 数量波动,且房间仍在 active 状态。
  • OPPO SC 字体数组只增加约 30 KiB,不是几十 MiB。
  • GL mtrack 是独立 graphics 指标,不能和 Native Heap PSS 混算。

剩余增长更像 native 渲染/cache/driver 侧驻留。Vulkan command buffer cache 数量增长是一个可见信号,但当前没有 driver allocation trace,不能把 67 MiB 精确拆成 command buffer、render target、glyph texture、VMA 或 Mali 私有分配各多少。

12. 这次最有价值的经验

第一,Flutter 应用的 native 内存问题不能只看 Dart heap。Dart 对象可以是触发者,也可以是引用入口,但真正占用可能在 Skia、Impeller、FreeType、HarfBuzz、Vulkan driver 或第三方 SDK。

第二,分配栈不是 retained ownership。DrawImageRectParagraphImpl::layoutCreateGlyphAtlas 出现在栈里,只能说明它们参与了分配。要判断泄露,必须继续看对象是否存活、由谁持有、是否从 GC root 或 native cache 可达。

第三,采集工具会改变现场。heapprofd buffer、VM timeline、VM Service object ID zone、UI hierarchy dump、native /proc/<pid>/mem 读取都可能产生扰动。每一次工具修正都要记录,否则后面的人会把诊断副作用当业务问题。

第四,修复要打在确认的持有机制上。字体问题修在 engine fallback clone 身份复用;资料卡问题修在 provider override 的捕获边界。没有证据支持的全局换字体、强行清 atlas、重写公屏列表、裁剪 Vulkan driver cache 都没有实施。

第五,验收结论要保守。两个已确认泄露已经修掉,不等于整进程所有内存预算已经通过。2 小时仍有 67.19 MiB Native Heap PSS 增长,后续需要继续追 native 渲染/cache 侧驻留。

13. 当前保留的数据

主要报告:

  • docs/performance/memory/voice-room-memory-remediation-2026-09-10.md
  • docs/performance/memory/voice-room-memory-two-hour-pjv110-2026-09-10.md

主要原始数据目录:

  • docs/performance/memory/artifacts/2026-09-10-pjv110-remediation/
  • docs/performance/memory/artifacts/2026-09-10-pjv110-two-hour/

这些 artifacts 是本地诊断资料,Git 忽略,不应作为普通代码提交的一部分。heap、截图、trace 可能包含会话数据,应按敏感诊断资料处理。

14. 后续建议

后续排查应聚焦剩余 native PSS 增长,而不是重复证明已经修掉的两个问题。

建议下一步:

  • 做一轮低频、长时、低扰动 native allocation trace,只针对修复后版本。
  • 增加 Vulkan/Impeller cache 高水位和释放事件的更细粒度只读观测。
  • 分开验证"留房 active cache 高水位"和"退房后 cache 释放"。
  • 对比 release 与 profile,但不要混用 profile 的 GC/VM Service 介入结果去推导 release。
  • 如果要改 Vulkan recycler 或 render target cache 策略,必须先证明它是 retained growth,不要只凭 command buffer 数量改策略。

总:最后回看

回头看这次修复,真正解决问题的不是某一个工具,而是证据链闭环:先用 PSS 发现 native 驻留增长,再用 heapprofd 找候选分配路径,再用符号化和 typed native census 读出真实 native 对象链;Dart 侧则通过 GC、retaining path、全 heap 索引和退出后复核,确认业务引用是否还从 root 可达。

AI 在这个过程中主要起到持续编排和反复校验的作用:它能长时间执行机械但严格的采集流程,也能在数据互相矛盾时继续追问口径是否一致、符号是否匹配、工具是否污染现场、对象是否真的从 root 可达。最终两个确认缺陷都不是靠猜测修掉的,而是先找到可解释的持有链,再把修复落到对应的持有机制上。

这次修复的阶段性判断是:500+ MiB 级别的大泄露已经被压下去,已确认的字体重复 face 和资料卡作用域泄露都通过了对象级验证;但修复后仍存在约 60 MiB 量级的 native PSS 增长,需要作为下一阶段独立问题继续追。

相关推荐
wuyk5556 小时前
从零吃透 MQTT 通信|第 11 章 MQTT 项目调试验证、性能优化、常见疑难问题、OTA 升级基础
c语言·开发语言·stm32·学习·性能优化
忆~遂愿8 小时前
Paperless-ngx 部署实战:PostgreSQL + Redis 文档库与固定公网访问
性能优化
天天喝旺仔17 小时前
Go 并发编程:Goroutine 与 Channel 实战
云原生·性能优化·架构·go
iNeuOS工业互联网1 天前
iNeuOS工业互联网操作系统,重要更新:性能优化、安全漏洞与BUG修复,远程控制响应效率大幅提升
性能优化·bug
Web3&Basketball2 天前
LLM 峰谷定价怎么吃满:一个能对账的错峰调度器
python·性能优化·大模型·任务调度·成本优化·deepseek·推理优化
yunwei372 天前
eBPF 教程:追踪 CUDA GPU 操作
linux·后端·性能优化
狼爷2 天前
磁盘IO打满怎么办?我用5个真实案例,总结了这套可复用的排查方法论
后端·性能优化
hhzz2 天前
【OpenCV 入门到精通 05】核心操作与像素处理:ROI、运算与性能优化
人工智能·python·opencv·性能优化
HwJack203 天前
HarmonyOS批量数据与性能优化实战:万条数据入库与不卡 UI 的查询
ui·性能优化·harmonyos