Valdi 内存泄漏调试指南:使用 Hermes 堆转储(Heap Snapshot)定位泄漏对象

原创2026-09-21 10:41:061,446 阅读
文章标签:跨平台UI组件前端移动开发

Valdi 内存泄漏调试指南:使用 Hermes 堆转储(Heap Snapshot)定位泄漏对象

Valdi 运行时支持将 VM 内的 JavaScript 堆以 V8 Heap Snapshot 格式整体导出,这份快照记录了 VM 中所有存活对象与函数及其相互引用关系,可直接交给 Chrome DevTools 或 Meta Memlab 等工具分析,从而定位"对象为何迟迟不被回收"这类内存泄漏问题。本篇指南将完整讲解堆转储的启用前提、通过 VSCode 调试器触发的操作步骤、Chrome DevTools 中的分析技巧,以及 Valdi 特有的原生侧引用(System / ExportedReferences)泄漏排查方法,并结合仓库源码说明堆转储的底层实现原理。

前置条件:启用 Hermes 引擎

堆转储(Heap Dump)目前仅支持 Hermes 引擎,因此在开始之前需要确认应用运行在 Hermes 上,具体启用方式参见 Hermes 调试器指南:

  • 通过 valdi bootstrap 生成的项目默认会尽可能使用 Hermes;
  • 其他项目需要在 .bazelrc 中加入构建选项:
    build --valdi_js_engine=hermes
    
  • Hermes 集成在 iOS 与 Android 的 debug 构建中编译启用,在 macOS 的所有构建类型中启用;
  • 调试(包括堆转储请求)依赖 Valdi hot reloader 正在运行并与设备连接。

从源码结构看,堆转储能力确实以条件编译形式内置于运行时:在 JavaScriptRuntime.cpp 中,dumpHeap 这一内部 API 只有在 Valdi::shouldEnableJsHeapDump() 成立时才被绑定到 runtime 对象上,dumpHeap() 本体(JavaScriptRuntime.cpp)同样受该开关保护,未启用时返回空数据。

请求堆转储:通过 VSCode 调试器触发

堆转储的获取方式是:将 VSCode 调试器连接到正在运行的 Valdi 应用,然后请求一次 Heap Snapshot(堆快照)。

  1. 确保 Hermes 已启用、应用以 debug 模式运行、hot reloader 已连接;
  2. 在 VSCode 中选择 Valdi attach 调试配置并启动调试会话(绿色三角或按 F5)。该配置由 valdi projectsync 自动生成到 .vscode/launch.json,当新增模块后需要重新执行 valdi projectsync 以刷新源码映射;
  3. 调试器底部状态栏变为橙色即表示附加成功;
  4. 在调试会话的 Profiling 面板中请求 Heap Snapshot,即可得到 heapsnapshot 文件。

值得注意的是,堆转储的导出在运行时是跨 JS 上下文统一执行的:JavaScriptRuntime::dumpHeap() 通过 dispatchSynchronouslyOnJsThread 调度到 JS 线程,并调用 lockAllJSContexts 锁住当前所有 JS 上下文后,再交给 _javaScriptBridge.dumpHeap(...) 生成完整的 V8 格式数据(见 JavaScriptRuntime.cpp)。这意味着快照并不是单个页面的局部视图,而是整个 VM 存活对象的全局快照。

堆转储格式与底层实现

Valdi 导出的堆转储使用 V8 格式,因此天然兼容 Chrome DevTools 与 Meta Memlab。堆转储中记录了 VM 内所有当前存活的对象与函数,以及它们之间的关系,是一个完整的对象关系图。

从源码结构看,这份 V8 格式快照由 JavaScriptHeapDumpBuilder.hpp 中的 JavaScriptHeapDumpBuilder 生成,它按照 V8 快照规范维护 nodes、edges、strings 表,并定义了完整的节点类型与边类型枚举:

  • 节点类型(Node Type):HIDDEN(VM 内部元素,在 DevTools 中归入 (system) 类别,虽非用户对象但常出现在 retainer 路径中)、ARRAY(内部列表,含 (object properties) / (object elements),对应对象的字符串键属性与数字键属性)、STRING、OBJECT(用户自定义对象,以及因被嵌套函数使用而不得不在堆上分配的 Context)、CODE、CLOSURE、REGEXP、NUMBER、NATIVE、SYNTHETIC(不对应真实内存分配,用于区分各类 GC 根)、SYMBOL、BIGINT、OBJECT_SHAPE;
  • 边类型(Edge Type):CONTEXT、ELEMENT(数字键属性)、PROPERTY(命名属性)、INTERNAL、HIDDEN(不对应 JS 可见名称但仍重要的引用,例如函数闭包的 context)、SHORTCUT(对某条路径的更易读表示,如 Function.prototype.bind 生成的绑定参数捷径边)、WEAK(弱引用不保持目标存活,在 Retainers 视图中会被省略)。

在引擎侧,QuickJSJavaScriptContext::dumpHeap(QuickJSJavaScriptContext.cpp)展示了具体的堆遍历方式:通过 JS_SetRuntimeOpaque 将 builder 挂到运行时上,随后调用 JS_VisitAllGCObjects 让引擎回调每个 GC 对象的起始、属性边与内部边,逐一把对象和引用写入快照。可以推断,Valdi 通过统一的 JavaScriptHeapDumpBuilder 屏蔽了各 JS 引擎在快照导出上的差异,从而保证最终产出的是标准、可被外部工具直接消费的 V8 格式文件。

在 Chrome DevTools 中分析堆转储

拿到 heapsnapshot 文件后,使用 Chrome DevTools 打开分析:

  1. 打开 Chrome,进入菜单栏 View(视图) -> Developer(开发者) -> Developer Tools(开发者工具);
  2. 切换到 Memory(内存) 面板,点击 Load(加载) 并选择 heapsnapshot 文件:

在 Chrome 开发者工具 Memory 面板中加载 heapsnapshot 文件

理解浅层大小与保留大小

Memory 视图会列出快照中的所有对象及其构造函数名。选中对象后,右侧面板展示两个关键指标:

  • shallow size(浅层大小):对象自身直接占用的内存字节数;
  • retained size(保留大小):对象通过引用链间接"拖住"的内存总量,即若该对象被回收,能够一并释放的内存量。排查内存泄漏时,retained size 是判断"某个根对象到底占了多少内存"的核心指标。

查看对象持有的引用

展开某个对象,工具会列出该对象持有的全部引用(references)。引用呈现规则值得注意:

  • 灰色引用(例如 <shape>):这是 VM 持有的隐式隐藏引用(对应上文的 HIDDEN 边),并非用户代码显式创建的属性;
  • 其他类型的引用:对应对象的属性(property)或数组元素(element);
  • 当前快照只导出引用类型,数字(number)与布尔值(boolean)这类原始值不会出现在引用列表中。

在 Memory 工具中展开对象查看其持有的引用字段

用 Retainers 逆向定位存活原因

窗口底部是 Retainers(保留者) 区域,列出所有引用当前选中对象的对象。它是内存泄漏排查中最有价值的视角:它回答的不是"这个对象指向谁",而是"谁在让这个对象保持存活"。沿着 Retainers 逐级向上追溯,通常可以找到泄漏的源头——某个本应被释放的全局缓存、事件监听器或长期存活的容器对象。

在 Memory 工具侧栏中展开更多条目查看 Retainers 保留者链

原生侧引用:排查 System / ExportedReferences

在 Valdi 这类跨平台框架中,还有一个特有的泄漏来源:TypeScript 对象被导出(export)到原生代码。

当 TypeScript 中的对象或函数被导出到原生(C++、Objective-C 或 Kotlin)代码时,Valdi 运行时会为它创建一个引用,并关联一个对应的原生对象。只要这个原生对象仍存活,该 JS 对象就会被一直保留;反之,当关联的原生对象被释放(deallocated)或完成垃圾回收后,导出的引用才会随之拆除(torn down)。

这类原生侧引用在堆转储中统一归入名为 System / ExportedReferences 的数组。从源码实现可以看到这一点:QuickJSJavaScriptContext::dumpHeap 会取出所有被 stash(暂存)的 JS 值,并将它们作为一个独立的 HIDDEN 类型节点写入快照,节点名即 "System / ExportedReferences",随后按元素索引为每个导出的值建立边(见 QuickJSJavaScriptContext.cpp)。

在实际排查中的判断方法:

  1. 在 DevTools 的 Retainers 视图中,如果某个疑似泄漏的 JS 对象被 System / ExportedReferences 所持有,说明它被原生侧"借用"了;
  2. 此时应到对应的原生代码(C++ / Objective-C / Kotlin)中查找持有该导出引用、却迟迟未释放的对象;
  3. 确认原生对象是否在正确时机调用析构/释放逻辑——只有原生对象被回收,导出的 JS 引用才会被拆除,内存才能被 GC 正常回收。

排查流程小结

把以上要点串起来,一个完整的 Valdi 内存泄漏排查闭环如下:

  1. 准备:确认 .bazelrc 中已设置 build --valdi_js_engine=hermes,应用以 debug 模式运行,hot reloader 已连接;
  2. 采集:在 VSCode 中附加 Valdi attach 调试会话,在 Profiling 面板请求 Heap Snapshot,得到 heapsnapshot 文件;
  3. 初筛:在 Chrome DevTools 的 Memory 面板加载快照,按 retained size 排序,找出"保留内存最大但本不该存在"的对象;
  4. 追链:利用 Retainers 逐级向上追溯,判断对象是被 JS 侧全局引用还是被 System / ExportedReferences 原生引用持有;
  5. 定性:JS 侧引用检查模块级变量、闭包、事件监听器;原生侧引用则定位到具体的 C++/Objective-C/Kotlin 持有者并修复其生命周期;
  6. 验证:修复后重新导出堆转储,确认对象可被正常回收,retained size 显著下降。

对于需要自动化、回归化验证的团队,还可以将导出的 heapsnapshot 文件交给支持 V8 快照格式的 Memlab 类工具做批量对象分析,将内存泄漏检查纳入常规测试流程。

登录后查看全文
Valdi