Babylon.js 内存泄漏测试工具指南:用 Playwright 检测事件泄漏与对象泄漏

原创2026-09-30 15:21:051,701 阅读
文章标签:图形学游戏开发3D渲染

Babylon.js 内存泄漏测试工具指南:用 Playwright 检测事件泄漏与对象泄漏

导读

@babylonjs/test-tools 是 Babylon.js 仓库内部维护的一组建构中的测试工具,专门用于在浏览器端自动化测试中发现两类内存问题:事件泄漏(事件被注册后,在对象 dispose 时未被移除)与对象泄漏(对象被 dispose 后仍残留在内存中)。本文以 packages/public/@babylonjs/test-tools/readme.md 为骨架,结合仓库内 @tools/test-tools 与 memlab 内存泄漏测试包的实际实现,讲解如何把这些工具接入 Playwright 测试流程、如何对特定类做对象计数校验、如何借助 sourcemap 还原 TypeScript 源码级的泄漏堆栈,并给出完整的可复制代码示例。

工具定位:面向 Babylon.js 内部的一组建构中测试套件

该包在 package.json 中声明为 @babylonjs/test-tools,版本随 Babylon.js 主版本推进(当前仓库为 9.28.0),通过 tsc -b tsconfig.build.json 编译输出 dist/index.js 与 dist/index.d.ts,属于仓库 packages/public 下随发布流程一起构建的工具包。

它的定位非常明确:

  1. 事件泄漏:检测“注册了但未随 dispose 移除”的事件。Babylon.js 大量使用 Observable 机制(如场景、引擎上的 onNewSceneAddedObservable、onNewCameraAddedObservable 等),事件回调若在对象释放时仍挂在 Observable 上,就会导致对象被外部引用而无法被垃圾回收。
  2. 对象泄漏:检测“dispose 之后仍驻留在内存中”的对象。这类泄漏通常意味着 dispose() 没有清理干净内部引用,长期累积会造成内存不断膨胀。

官方文档同时给出明确提醒:该包主要供 Babylon.js 内部测试使用,使用时务必谨慎,且不会在任何时候保证向后兼容。因此在依赖它的版本升级时,需要留意函数签名与行为的变化。

使用前提:面向 Playwright 的浏览器端测试

整套库是为 Playwright 浏览器端测试设计的。所有泄漏检测函数都需要通过 page.evaluate(...) 注入到被测页面上下文中执行,因为:

  • 事件注册/移除发生在页面内的真实引擎与场景对象上;
  • 对象计数需要访问页面内 window 上的类与实例;
  • 堆栈采集需要在创建点同步记录调用栈。

最小测试环境

测试页面通常是一个加载了 Babylon.js 构建产物的 HTML(如仓库中 testsMemoryLeaks 使用的 empty.html),页面内需存在 #babylon-canvas 画布,并将需要检测的类挂到 window 对象上。工具包内 window.d.ts 展示了测试页面扩展 window 接口的约定,例如 window.engine、window.scene、window.eventsRegistered、window.classesConstructed 等字段,供浏览器侧函数读写。

检测事件泄漏:注入监听增强与结果断言

事件泄漏检测由两个函数配合完成:evaluateEventListenerAugmentation 与 assertEventLeaks。

第一步:在每个测试前注入事件监听增强

为了让“注册/移除”的统计对每个测试都生效,需要在 beforeEach 中通过页面求值运行 evaluateEventListenerAugmentation:

// 在 Playwright 测试文件中
import { evaluateEventListenerAugmentation, assertEventLeaks } from "@babylonjs/test-tools";

beforeEach(async () => {
    await page.goto(`${baseURL}/test.html`, {
        waitUntil: "load",
        timeout: 0,
    });
    // 增强所有事件注册/移除路径,开始记录
    await page.evaluate(evaluateEventListenerAugmentation);
});

这段代码做了两件事:跳转到测试页面(timeout: 0 表示不受默认超时限制,适合加载较重的引擎资源),然后注入监听增强器。增强后,页面内所有事件监听(addEventListener/removeEventListener,以及 Babylon.js 的 Observable add/remove)都会被统计到 window.eventsRegistered 中——该结构体按事件名记录 numberAdded(注册次数)、numberRemoved(移除次数)、registeredFunctions 以及每次注册时的 stackTraces(见 window.d.ts)。

第二步:在测试结束时断言无泄漏

测试完成后,调用 assertEventLeaks(page) 校验注册与移除是否平衡:

// 测试内部:
test("Should do the thing it should do!", async () => {
    // 在页面上执行你的业务逻辑
    // 运行你的其他断言
    // ...

    // 校验是否有事件泄漏,传入 Playwright 的 page
    await assertEventLeaks(page);
});

关键注意事项(原文档特别强调):assertEventLeaks 内部会运行 expect 断言,因此不要把它放在 afterEach 中——如果把它放在 afterEach,异常会被吞掉或与其它钩子逻辑混淆,导致断言失效或难以定位失败来源。正确做法是在每个测试用例体内、业务断言之后显式调用。

检测对象计数:快照对比与自动断言

对象计数提供两种粒度:对 Object.prototype 级别的全部对象计数,或对特定类计数。之所以要区分,是因为浏览器端无法直接把 ES6 风格的类传入(Playwright 的 evaluate 参数需可序列化),因此需要把待检测的类先暴露到页面的 window 上(原文档注明:这一限制后续会扩展改进)。

全量对象计数(Object.prototype 粒度)

在测试开始和结束时各拍一次快照,然后对差值做断言:

// 测试内部:
test("Should do the thing it should do!", async () => {
    const init = await countObjects(page);
    // 在页面上执行你的业务逻辑
    // 运行你的其他断言
    // ...

    // 再次计数,得到测试结束时的状态
    const valuesAtTheEnd = await countObjects(page);
    // 手动断言:例如对象净增长必须小于 200
    expect(valuesAtTheEnd.numberOfObjects - init.numberOfObjects).toBeLessThan(200);
});

这里 init 与 valuesAtTheEnd 都包含 numberOfObjects 字段。通过对比两次快照的对象数量差值,可以捕获“业务逻辑结束后仍有大量对象残留”的泄漏信号。差值阈值(如 200)由开发者根据被测场景的合理对象量级自行设定。

自动断言:countCurrentObjects

如果不想手写差值断言,可以直接使用 countCurrentObjects 在测试末尾自动完成对比与断言:

// 测试内部:
test("Should do the thing it should do!", async () => {
    const init = await countObjects(page);
    // 在页面上执行你的业务逻辑
    // 运行你的其他断言
    // ...

    // 自动运行 expect 规则,对比 init 与当前状态
    await countCurrentObjects(page, init);
});

针对特定类的对象计数

当只关心某一组组件是否被正确释放时,向 countObjects 传入类清单。每项包含:

字段 类型 是否必填 说明
globalClassName string 必填 类在页面 window 上的全局名称,例如 "FirstComponent"(若挂在命名空间下需写全路径,如 "BABYLON.Scene",这与 utils.ts 中 ClassesToCheck 的写法一致)
disposeFunctionName string 可选 类的 dispose 方法名。提供后才会检查该对象是否真的被 dispose 过;不提供则跳过 dispose 检查
// 测试内部:
test("Should do the thing it should do!", async () => {
    const classes = [
        {
            globalClassName: "FirstComponent",
            disposeFunctionName: "dispose",
        },
        {
            globalClassName: "SecondComponent",
            disposeFunctionName: "dispose",
        },
    ];
    const init = await countObjects(page, classes);
    // 在页面上执行你的业务逻辑
    // 运行你的其他断言
    // ...

    await countCurrentObjects(page, init, classes);
});

提供 disposeFunctionName 后,工具会检查每个实例的 dispose 标记(参考 utils.ts 中 prepareLeakDetection 的做法:把类的原型 dispose 包装一层,置 __disposeCalled = true),从而判断泄漏对象是否“根本没被调用过 dispose”。

获取泄漏对象的创建堆栈:onComponentCreated

默认情况下,工具知道“有对象未被释放”,但不知道它是谁、在哪里被创建。要拿到创建位置的堆栈,需要在这些对象被创建时主动通知工具:

由于无法扩展类的构造函数(ES6 类构造器不可被静态拦截),你必须自行在创建处调用 onComponentCreated 函数,传入刚创建的对象。

// 在业务组件工厂里:
function createComponent() {
    const component = new MyComponent(/* ... */);
    onComponentCreated(component);   // 标记组件并记录当前堆栈
    return component;
}

onComponentCreated 会为该实例生成唯一标识、记录创建时的调用栈,并写入 window.classesConstructed 映射表(见 window.d.ts 中 StacktracedObject 结构:包含 id、stackTrace、className、可选的 disposeCalled)。这样当检测到未被移除的对象时,就能通过该表反查出它的创建位置,定位“是谁在什么时候把它 new 出来的”。

Sourcemaps 与堆栈追踪:还原 TypeScript 源码位置

事件泄漏与特定类检查都能产出“事件/对象创建时”的堆栈,这在测试较大组件集合时尤为关键——它可以快速把泄漏责任锁定到具体模块。

  • 纯 JavaScript 场景:堆栈直接给出源码中的正确行列位置,无需额外处理。
  • TypeScript 或压缩场景:运行时堆栈对应的是编译产物,必须依赖 sourcemap 还原为 TS 源码位置。

使用 sourcemap 有两个硬性要求(原文档明确说明):

  1. sourcemap 必须是独立的 .map 文件,不能内嵌在 JS 文件里;
  2. 测试 HTML 必须引入 sourcemapped-stacktrace 库:
<script src="https://cdn.jsdelivr.net/npm/sourcemapped-stacktrace@1.1.11/dist/sourcemapped-stacktrace.min.js"></script>

引入后,浏览器侧会存在 window.sourceMappedStackTrace,工具在采集堆栈时优先调用其 mapStackTrace(可配置 { cacheGlobally: true, sync: true } 加速重复映射,见 utils.ts),映射失败时回退到原始 err.stack,保证检测流程不会因 sourcemap 缺失而中断。

仓库配套:从 test-tools 到 memlab 的内存泄漏防线

@babylonjs/test-tools 主要解决“浏览器内事件/对象计数”问题;而仓库内还有两个紧密相关的配套体系,可以在写测试时互为补充:

1. @tools/test-tools:通用浏览器测试工具箱

packages/tools/testTools 提供面向 Babylon.js 全仓测试的公共函数,包括:

  • getGlobalConfig({ usesDevHost }):解析测试的 baseUrl、root、snippetUrl、pgRoot、assetsUrl,默认指向 http://localhost:1337(Babylon Server),可用 CDN_BASE_URL 或 --enable-https 覆盖;
  • evaluateInitEngine / evaluateCreateScene / evaluateRenderScene / evaluateDisposeScene / evaluateDisposeEngine:在浏览器内完成引擎与场景的创建、渲染、释放,并在释放后主动调用 window.gc && window.gc() 加速回收,便于泄漏检测在稳定状态下进行;
  • prepareLeakDetection(classes):为指定类注入 dispose 标记,并挂接引擎/场景的新增对象 Observable 采集堆栈。

其中默认的 ClassesToCheck 清单(utils.ts)为 BABYLON.Camera、BABYLON.TransformNode、BABYLON.Scene、BABYLON.Vector3、BABYLON.BaseTexture、BABYLON.Material——这正是“特定类计数”思路的仓库内实践。

2. @tools/memory-leak-tests:基于 memlab 的整包级泄漏门禁

在 packages/tools/testsMemoryLeaks 中,Babylon.js 用 Meta 的 memlab 构建了面向 CI 的内存泄漏运行器,职责与 test-tools 互补:

  • 四类套件:ci(PR 门禁,核心场景)、extended(本地补充场景)、packages(各子包确定性场景)、all(全部);
  • 通过 scenarios.ts 中的 DefaultScenarioDefinitions 声明场景,覆盖 @babylonjs/core(音频/物理/粒子/导航/渲染/材质/纹理/后期处理)、@babylonjs/gui、@babylonjs/loaders、@babylonjs/materials、@babylonjs/post-processes、@babylonjs/procedural-textures、@babylonjs/serializers 等包;
  • 运行命令(根入口):
npm run test:memory-leaks          # PR 门禁:ci 套件
npm run test:memory-leaks:unit     # 运行运行器自身的单元测试
npm run test:all -w @tools/memory-leak-tests   # 全部场景
npm run list -w @tools/memory-leak-tests       # 列出场景
npm run test -w @tools/memory-leak-tests -- --scenario core-playground-2FDQT5-1508  # 运行单个场景
  • 运行器在 runner.ts 中通过 RunScenario 组装 memlab 的 run(options),检测到泄漏即抛出 MemoryLeakRunnerError 并附上 memlab 产物目录;默认 failFast: true、skipWarmup: true(场景自身已做确定性 set 与 settle,跳过 memlab 通用预热可避免挂起);
  • 浏览器侧动作(browserActions.ts)在页面内完成引擎创建、场景加载、渲染若干帧、可选地启动/停止动画组、模拟相机移动、开关 inspector,再等待 settleAfterReadyMs / settleAfterDisposeMs 让异步清理稳定,最后才让 memlab 拍照对比快照。

这套体系证明了泄漏测试的完整闭环:test-tools 负责细粒度的浏览器内事件/对象审计,memlab 负责粗粒度但可自动化的堆快照对比,两者都遵循“创建 → 使用 → dispose → 等待稳定 → 对比”的同一节奏。

最佳实践与注意事项

综合原文档与仓库实现,接入 @babylonjs/test-tools 时建议遵循以下规则:

  1. beforeEach 注入增强,用例体内断言:evaluateEventListenerAugmentation 放 beforeEach;assertEventLeaks 放测试体内,绝不放进 afterEach。
  2. 首尾快照成对出现:countObjects 必须在业务动作之前调用以取得基线,业务结束后再计数或直接 countCurrentObjects 自动断言。
  3. 特定类检查必须暴露到 window:ES6 类无法直接序列化进浏览器,务必在测试页面把类挂到 window 上(如 window.FirstComponent = FirstComponent),再以 globalClassName 引用。
  4. 用 disposeFunctionName 区分“没释放”与“释放不干净”:不传该字段,工具只报对象残留;传了之后,还能从堆栈记录中确认实例是否真正执行过 dispose。
  5. 想要堆栈必做 sourcemap:TS/压缩产物下,独立 .map 文件 + sourcemapped-stacktrace 缺一不可,否则拿到的堆栈指向编译产物,难以直接阅读。
  6. 给业务组件加 onComponentCreated 埋点:这是唯一能在创建瞬间记录堆栈的手段,建议在框架层或工厂函数中统一调用,避免逐个手写。
  7. 关注版本兼容性:该包不承诺向后兼容,升级 Babylon.js 版本后应回归运行全部泄漏测试;在写新测试时优先复用仓库内的既有辅助函数(getGlobalConfig、evaluateInitEngine 等),保持与 CI 门禁一致的环境。

总结

@babylonjs/test-tools 用“事件注册/移除统计 + 对象计数快照 + 创建堆栈回放”三件套,把内存泄漏从“肉眼观察任务管理器”变成“可断言、可定位、可回归”的自动化测试:evaluateEventListenerAugmentation/assertEventLeaks 负责事件泄漏,countObjects/countCurrentObjects/onComponentCreated 负责对象泄漏,sourcemap 支持则让堆栈直达 TypeScript 源码。结合仓库内的 @tools/test-tools 与 memlab 内存泄漏运行器,开发者可以在 Playwright 之外获得一套从浏览器内审计到堆快照对比的完整防线,为 Babylon.js 这类长生命周期渲染引擎的长期内存健康提供保障。

登录后查看全文
Babylon.js