WebGL 应用与微信小游戏运行时架构、内存组成和分析方法
WebGL 应用与微信小游戏运行时架构、内存组成和分析方法 只讨论 浏览器 WebGL 应用、Unity WebGL、Emscripten/WebAssembly、微信小游戏运行时,以及它们在内存快照和 Android 真机内存分析中的表现 。其中部分示例来自本会话里的 heap snapshot 结构,例如 HEAPU8 ArrayBuffer 、 FS nameTable contents Uint8Array ArrayBu…
本文目录 81 个章节
WebGL 应用与微信小游戏运行时架构、内存组成和分析方法
只讨论 浏览器 WebGL 应用、Unity WebGL、Emscripten/WebAssembly、微信小游戏运行时,以及它们在内存快照和 Android 真机内存分析中的表现。其中部分示例来自本会话里的 heap snapshot 结构,例如 HEAPU8 -> ArrayBuffer、FS -> nameTable -> contents -> Uint8Array -> ArrayBuffer 这类 retainer path,用作通用解读样例。
1. WebGL 应用的整体分层架构
一个现代 WebGL 游戏或图形应用,通常不是“JavaScript 调用 GPU”这么简单,而是由多层运行时组成。理解内存时,必须先分清每一层。
1.1 从下到上的典型架构
硬件层
CPU / RAM / GPU / 显存 / 显示系统
↓
操作系统层
Android / iOS / Windows / macOS
进程管理、内存页、图形驱动、文件系统、网络
↓
浏览器或宿主运行时
Chrome / Safari / Firefox / Edge
或微信小游戏宿主、XWeb、小游戏 JS Runtime
↓
JavaScript 引擎
V8 / JavaScriptCore / QuickJS-like runtime / 宿主自研封装
负责 JS 对象、闭包、字符串、ArrayBuffer、GC、JIT/Bytecode
↓
WebAssembly 引擎
加载 .wasm
编译 Wasm code
管理 WebAssembly.Memory、Table、Instance、Module
↓
WebGL 实现层
WebGLRenderingContext / WebGL2RenderingContext
浏览器或宿主把 WebGL API 转成 OpenGL ES / Metal / Vulkan / Direct3D / ANGLE / 原生图形 API
↓
Canvas 层
HTMLCanvasElement 或小游戏 Canvas
作为 WebGL 绘制目标
↓
Emscripten Runtime
Module、HEAPU8、FS、dynCall、wasm exports、JS glue code
↓
游戏引擎运行时
Unity WebGL / Cocos / Laya / PlayCanvas / Three.js / Babylon.js
↓
游戏业务层
C# / TypeScript / JavaScript / Lua / C++ 逻辑
场景、资源、UI、网络、插件、监控、截图、音频
每一层都可能分配内存,而且这些内存不一定都能在 JavaScript heap snapshot 里看到。
2. 浏览器 WebGL 应用的核心组成
2.1 JavaScript 应用层
普通 WebGL 应用的上层通常是 JavaScript 或 TypeScript。它负责:
1. 创建 Canvas;
2. 获取 WebGL context;
3. 加载资源;
4. 管理渲染循环;
5. 创建 shader / texture / buffer / framebuffer;
6. 处理输入、网络、音频、UI;
7. 与 Wasm 或引擎 runtime 通信。
常见代码形态:
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
const tex = gl.createTexture();
const fbo = gl.createFramebuffer();
const buffer = gl.createBuffer();
这类 JS 对象本身通常很小,但它们对应的底层 native / GPU 资源可能很大。
2.2 WebGL API 层
WebGL 是暴露给 JavaScript 的图形 API。它的对象通常包括:
WebGLTexture 纹理对象
WebGLBuffer 顶点 / 索引 / uniform buffer
WebGLFramebuffer 离屏渲染目标
WebGLRenderbuffer 深度 / 模板 / 多采样 renderbuffer
WebGLShader 顶点 / 片元 shader
WebGLProgram shader program
WebGLQuery GPU query
WebGLSync GPU 同步对象
例如 framebufferTexture2D() 会把一个 texture 附加到 framebuffer 上,作为 framebuffer 的颜色、深度或模板附件。(MDN Web Docs)
内存理解重点:
JS heap 里看到的 WebGLTexture 对象:
只是一个 JS wrapper。
真正的大内存:
可能在 GPU 显存、图形驱动、浏览器 renderer native memory、Android Graphics / GL mtrack 中。
所以,WebGL 纹理泄露、FBO 泄露、PBO 泄露,经常 不会 在 JS heap snapshot 中表现为大对象,但会在 Android dumpsys meminfo 的 Graphics、GL mtrack、EGL mtrack、Private Other、RssAnon 等指标里表现出来。
2.3 WebAssembly 层
WebAssembly 是浏览器中运行 C/C++/Rust/C# 转译代码的低层执行格式。Unity WebGL、部分 Cocos 模块、物理引擎、音视频编码器、图像编码器都可能使用 Wasm。
WebAssembly 应用通常包含:
.wasm 文件
编译后的二进制代码。
WebAssembly.Module
解析后的 Wasm 模块。
WebAssembly.Instance
实例化后的运行实例。
WebAssembly.Memory
Wasm 线性内存。
WebAssembly.Table
函数表,常用于间接调用、函数指针、dynCall。
在 heap snapshot 中,常见表现包括:
Module
Table
WasmDispatchTable
WasmTrustedInstanceData
Managed<wasm::NativeModule>
WebAssembly.Memory.buffer
ArrayBuffer
system / JSArrayBufferData
Managed<wasm::NativeModule> 通常表示 V8 / JS 引擎保存的 Wasm 编译产物、元数据或 native module 结构。它不是业务 JS 对象,也不一定能由业务代码直接释放。
3. Unity WebGL 的架构
Unity WebGL 是理解小游戏 WebGL 内存的核心场景之一。
3.1 Unity WebGL 的编译链路
Unity WebGL 的大致编译链路是:
C# 游戏代码
↓ IL2CPP
C++ 代码
↓ Emscripten
WebAssembly
Unity 引擎 C/C++ runtime
↓ Emscripten
WebAssembly
Unity WebGL loader / framework JS
↓
浏览器或小游戏宿主
Unity 文档说明,Unity WebGL 使用 Emscripten 将 Unity runtime 的 C/C++ 代码交叉编译成 WebAssembly;C# 脚本则通过 IL2CPP 转成 C++,再由 Emscripten 编译成 Wasm。(Unity Documentation)
3.2 Unity WebGL 构建产物
Unity WebGL 构建后通常会产生:
index.html
浏览器入口页面。
.loader.js
加载器脚本。
.framework.js
Unity WebGL JavaScript framework / glue code。
.wasm
Unity runtime + IL2CPP 代码编译后的 Wasm。
.data / .data.unityweb / data.unity3d
资源数据包,包含场景、资源、序列化数据等。
StreamingAssets / AssetBundles / Addressables
运行时资源。
Unity 官方文档也说明 Web Build folder 会包含运行 Web 应用所需的文件。(Unity Documentation)
3.3 Unity Heap / Wasm Heap
Unity WebGL 有一个非常重要的内存概念:Unity Heap。
Unity 文档说明,Unity heap 是一个连续内存块,用于保存 Unity runtime objects,包括 managed/native objects、assets、scenes、shaders 等;这个 heap 会以 WebAssembly Memory 的形式存在,其 buffer 是一个可调整大小的 ArrayBuffer。(Unity Documentation)
在 heap snapshot 中,它通常表现为:
Module
-> HEAPU8
-> Uint8Array
-> ArrayBuffer
-> system / JSArrayBufferData
示例 retainer path:
ArrayBuffer retained=256MB
<- Uint8Array
<- HEAPU8
<- Module
本会话的快照示例中也出现了 ArrayBuffer 256MB -> HEAPU8 这种典型结构。
专业解释:
HEAPU8 不是一块新内存。
HEAPU8 是 WebAssembly.Memory.buffer 的 Uint8Array 视图。
真正的大内存是 backing store,也就是 system / JSArrayBufferData。
4. Emscripten Runtime 的核心概念
Emscripten 是把 C/C++ 编译到 WebAssembly 的工具链。Unity WebGL、很多 C++ 游戏引擎、图像编码器、物理库都会间接使用它。
4.1 Module
Module 是 Emscripten JavaScript glue code 的核心对象。它通常保存:
Module.HEAP8
Module.HEAPU8
Module.HEAP16
Module.HEAPU16
Module.HEAP32
Module.HEAPU32
Module.HEAPF32
Module.HEAPF64
Module._malloc
Module._free
Module.ccall
Module.cwrap
Module.FS
Module.canvas
Module.asm
Module.wasmMemory
Module 本身不一定很大,但它能引用很多大对象。例如:
Module.HEAPU8.buffer
Module.FS.nameTable
Module.canvas
Module.asm.__indirect_function_table
所以 heap snapshot 中看到 Module 作为 retainer 时,不能直接说“Module 分配了这些内存”,更准确的话术是:
Module是 Emscripten runtime 的根对象,它让 Wasm heap、FS、函数表等结构保持可达。
4.2 HEAPU8 / HEAP32 / HEAPF32
这些是对同一个 WebAssembly.Memory.buffer 的不同 typed array 视图。
HEAPU8 Uint8Array,按字节访问 Wasm 内存
HEAP8 Int8Array
HEAPU16 Uint16Array
HEAP32 Int32Array
HEAPF32 Float32Array
HEAPF64 Float64Array
它们都不是独立的大内存。真正大的是:
WebAssembly.Memory.buffer
-> ArrayBuffer
-> native: system / JSArrayBufferData
4.3 Wasm 线性内存
Wasm 线性内存可以理解为 C/C++ 程序看到的一整块连续地址空间。C++ 里的:
malloc()
new
std::vector
std::string
Unity native objects
IL2CPP managed heap
decoded asset data
temporary buffers
最终很多都会落在这块线性内存里。
关键特征:
1. Wasm heap 增长后通常不会自动缩回。
2. C++ 对象释放后,只是 allocator 内部可复用,不代表 WebAssembly.Memory.buffer 变小。
3. JavaScript heap snapshot 能看到 Wasm Memory 的 ArrayBuffer 容量,但不一定能知道里面哪些 C++ 对象占用。
4. 如果 heap snapshot 前后 HEAPU8.buffer.byteLength 没变,不代表 C++ 内部没有分配变化;需要看 sbrk、malloc 统计或引擎内部内存统计。
4.4 Emscripten FS:MEMFS / IDBFS / WasmFS
Emscripten 提供一个虚拟文件系统,使 C/C++ 代码可以使用类似 POSIX 的文件 API。
常见文件系统:
MEMFS
内存文件系统。
文件内容保存在 JS/Wasm 内存中。
默认经常挂载在 /。
IDBFS
IndexedDB-backed 文件系统。
通常先在内存中维护文件,再与 IndexedDB 同步。
NODEFS
Node.js 环境下映射本地文件系统。
WasmFS
新的 Wasm-based 文件系统层。
Emscripten 文档说明,MEMFS 会在 runtime 初始化时挂载到 /;预加载文件可以在启动时放进 MEMFS。(Emscripten) Emscripten 也有专门的 File System API 文档,并说明 WasmFS 是新的高性能、多线程文件系统层。(Emscripten)
在 heap snapshot 中,MEMFS 文件常见 retainer path 是:
FS
-> nameTable
-> FSNode
-> contents
-> Uint8Array
-> ArrayBuffer
-> system / JSArrayBufferData
本会话的快照示例中就出现了 FS -> nameTable -> contents -> Uint8Array -> ArrayBuffer 43MB 的结构。
专业解释:
这条链说明“大内存是一个 Emscripten FS 文件的内容”。 它不能单独证明这个文件是谁写入的,需要结合文件路径、内容头、日志或 FS dump 才能归因。
5. 微信小游戏与浏览器 WebGL 应用的区别
微信小游戏可以运行 WebGL 和 Wasm,但它不是标准浏览器页面。
5.1 运行环境差异
标准浏览器 WebGL 应用通常运行在:
HTML 页面
DOM
Canvas
浏览器网络 API
浏览器存储 API
标准 DevTools
微信小游戏运行在:
微信宿主 App
小游戏 JS runtime
微信封装的 Canvas / WebGL API
wx API
小游戏文件系统
小游戏网络 API
小游戏包体 / 分包 / 资源缓存机制
微信开发者工具 / 真机远程调试
Cocos Creator 文档对微信小游戏的描述很适合作为通用理解:微信小游戏运行环境是微信小程序环境的扩展,在小程序环境基础上提供了 WebGL 接口封装,但这些接口由微信团队通过原生实现封装,不能等同于浏览器环境。(Cocos Creator)
因此,同样一段 WebGL 或 Unity WebGL 代码:
在 Chrome 浏览器里:
内存可能表现为 renderer process + GPU process。
在微信小游戏里:
内存可能表现为 com.tencent.mm:appbrandX、tools、xweb、GPU/native mtrack、Private Other 等多个进程或类别。
5.2 Canvas / WebGL 差异
浏览器中:
const canvas = document.querySelector("canvas");
const gl = canvas.getContext("webgl");
小游戏中通常是:
const canvas = wx.createCanvas();
const gl = canvas.getContext("webgl");
差异包括:
1. 没有完整 DOM。
2. Canvas 由小游戏宿主创建和管理。
3. WebGL API 由宿主封装,底层可能走微信/XWeb/平台图形实现。
4. 部分扩展、像素读取、canvas 导出、文件转换行为可能和浏览器不同。
5. 真机内存归因通常更依赖 Android/iOS 系统工具,而不是单靠 JS heap snapshot。
5.3 文件系统差异
标准浏览器 WebGL:
Emscripten MEMFS / IDBFS
Browser Cache
IndexedDB
Fetch / XHR response cache
微信小游戏:
小游戏包体
分包
远程资源
微信本地缓存目录
小游戏文件系统 API
Unity / Emscripten 虚拟 FS
宿主内部资源缓存
在小游戏里,Emscripten FS、Unity datapackage、小游戏宿主缓存、网络下载缓存可能同时存在。它们在内存中可能表现为:
JS ArrayBuffer
Uint8Array
FSNode.contents
Native file buffer
Network response buffer
Decompression buffer
小游戏宿主内部 cache
5.4 Unity WebGL 转小游戏的额外成本
Unity / 团结引擎的小游戏 WebGL 平台文档指出,WebGL 运行机制和文件系统让内存结构与原生 App 很不一样,通常会多出 Wasm 加载与编译、Wasm Heap 未使用部分、JS File System 等内存;文档还特别提示,Emscripten 使用 JS + IndexedDB 模拟文件系统时,JS 中可能始终存有文件 copy,因此在微信小游戏中应尽量避免使用 Emscripten 文件系统。(Unity User Manual)
这对内存分析很重要:
小游戏 WebGL 内存高,不一定是业务泄露。
它可能是:
Wasm 文件加载和编译成本;
Unity Heap 预留容量;
data package / FS 文件内容;
浏览器/宿主 JIT 和 code cache;
GPU/Canvas/WebGL native 资源;
资源下载、解压、缓存;
多进程 PSS 统计口径导致的波动。
6. WebGL / 小游戏应用的内存组成
从内存分析角度,可以把 WebGL 应用内存分成以下几类。
6.1 JavaScript Heap
包括:
普通 Object
Array
Map / Set / WeakMap
Closure
Function
Promise
字符串
TypedArray wrapper
ArrayBuffer wrapper
事件回调
定时器回调
模块对象
小游戏 runtime JS 对象
引擎 JS glue code
在 heap snapshot 中常见类型:
object
array
string
concatenated string
closure
code
object shape / system Map
system / Context
注意:
ArrayBuffer wrapper 很小。
真正的二进制 backing store 通常体现在 native: system / JSArrayBufferData。
6.2 ArrayBuffer / TypedArray backing store
典型对象链:
Uint8Array
-> buffer
-> ArrayBuffer
-> backing_store
-> system / JSArrayBufferData
常见来源:
1. Wasm Memory buffer;
2. 网络 response ArrayBuffer;
3. 文件读取结果;
4. 图片/音频/压缩包二进制;
5. WebGL readPixels 结果;
6. Emscripten FS 文件 contents;
7. 上传 request body;
8. 解压缩中间 buffer;
9. 编码器输入 / 输出 buffer。
在 heap snapshot 中,如果看到:
native | system / JSArrayBufferData | 43MB
不要停在这个节点,要往 retainer path 往上看:
是 HEAPU8 持有?
是 FSNode.contents 持有?
是 Response / Promise 持有?
是 Upload queue 持有?
是 WebGL readback buffer 持有?
本会话快照示例中 system / JSArrayBufferData 排名前两项是 256MB 和 43MB,说明大头是 ArrayBuffer backing store,而不是普通 JS 对象。
6.3 Wasm Linear Memory / Unity Heap
表现:
Module.HEAPU8.buffer
ArrayBuffer
system / JSArrayBufferData
常见大小:
64MB
128MB
192MB
256MB
512MB
更高
取决于:
INITIAL_MEMORY
ALLOW_MEMORY_GROWTH
MAXIMUM_MEMORY
Unity Player Settings
运行时峰值分配
资源规模
C++ allocator 行为
分析重点:
HEAPU8.buffer.byteLength:
表示 Wasm heap 容量。
C++ 已使用内存:
需要看 malloc/sbrk/Unity profiler/引擎统计。
释放 C++ 对象:
不一定让 HEAPU8.buffer 变小。
Wasm heap 增长:
一旦扩容,通常成为新的常驻高水位。
6.4 Wasm 编译产物和引擎内部结构
heap snapshot 中可能看到:
Managed<wasm::NativeModule>
WasmTrustedInstanceData
WasmDispatchTable
WasmImportData
Table
Code
BytecodeArray
FeedbackCell
FeedbackVector
SharedFunctionInfo
专业解释:
Managed<wasm::NativeModule>
Wasm 编译后的 native module / 引擎内部结构。
WasmDispatchTable / Table
Wasm 函数表、间接调用表、函数指针相关结构。
BytecodeArray / FeedbackVector / Code
JavaScript 引擎为 JS 函数生成的字节码、反馈向量、优化信息。
WAGame.js / framework.js / plugin.js string
运行时代码或源码字符串,不一定是泄露。
本会话快照示例中存在 Managed<wasm::NativeModule> 35.23MB、WasmTrustedInstanceData、WasmDispatchTable、Table 等,这类对象通常属于 Wasm/JS 引擎运行时成本。
6.5 Emscripten FS / Unity datapackage / 资源包
典型 retainer path:
FS
-> nameTable
-> Array[index]
-> FSNode
-> contents
-> Uint8Array
-> ArrayBuffer
-> JSArrayBufferData
可能来源:
1. Unity data package;
2. 首包资源;
3. 预加载文件;
4. 运行时写入的临时文件;
5. 下载缓存;
6. 截图临时文件;
7. 日志文件;
8. AssetBundle / Addressables cache。
重要判断:
FS -> nameTable -> contents 只能证明“这是 FS 文件内容”。
不能直接证明“这是哪个模块写入的”。
必须进一步看:
文件路径;
文件头;
FS dump;
写入调用栈;
运行日志;
阶段差分。
如果文件头是 UnityWebData1.0,通常更像 Unity datapackage,而不是业务临时缓存。
6.6 WebGL / GPU / Graphics 资源内存
常见资源:
Texture
Renderbuffer
Framebuffer
Vertex Buffer
Index Buffer
Uniform Buffer
Shader
Program
PBO / Pixel Buffer Object
VAO
Transform feedback
这些资源大多不在 JS heap 中以真实大小体现。
典型内存估算:
RGBA8 texture:
width × height × 4 bytes
RGBA8 texture with mipmaps:
width × height × 4 × 1.33 左右
Depth24/Stencil8:
width × height × 4 bytes
双缓冲 / 多缓冲:
单份大小 × buffer count
PBO ring:
requiredBytes × ringSize
例如:
1280 × 720 × 4 ≈ 3.52 MB
1920 × 1080 × 4 ≈ 7.91 MB
2560 × 1440 × 4 ≈ 14.06 MB
三份 2560×1440 RGBA ≈ 42.19 MB
所以“截图、离屏渲染、readPixels、PBO ring、canvas 导出”很容易产生 20–50MB 级别的波动,但不一定在 JS heap snapshot 中出现。
6.7 Canvas backing store
Canvas 本身也有 backing store。实际像素尺寸通常是:
cssWidth × devicePixelRatio
cssHeight × devicePixelRatio
例如:
CSS 1280×720
devicePixelRatio = 2
实际 backing store = 2560×1440
RGBA = 2560×1440×4 ≈ 14.06 MB
如果同时有:
主 Canvas
离屏 Canvas
截图 Canvas
临时 Canvas
WebGL framebuffer
导出图片 buffer
内存会快速增加。
6.8 网络、解压和上传内存
常见链路:
fetch / wx.request
-> response ArrayBuffer
-> 解压 buffer
-> 解析 buffer
-> 写入 FS 或 Wasm heap
-> 上传 body copy
常见重复副本:
网络 response buffer
解压前 compressed buffer
解压后 uncompressed buffer
Wasm heap copy
FS contents copy
业务 cache copy
上传 request body copy
宿主内部 native copy
优化目标不是“完全没有 buffer”,而是减少同一份数据在同一时间窗口内出现多份副本。
6.9 图片、音频、字体和视频
图片资源可能经历:
compressed image file
decoded RGBA pixels
GPU texture
mipmap chain
CPU-side cache
音频资源可能经历:
compressed audio file
decoded PCM
audio engine buffer
streaming buffer
native audio cache
字体资源可能经历:
font file
glyph cache
texture atlas
layout cache
这些通常也不完全体现在 JS heap snapshot 中。
7. Chrome / 微信开发者工具 Heap Snapshot 术语
Chrome DevTools 文档把 heap snapshot 用于分析对象分布和内存泄露;Memory 面板里常见 Shallow Size 和 Retained Size 两个概念。(Chrome for Developers)
7.1 Shallow Size / Self Size
含义:
对象自身占用的内存。
例如:
ArrayBuffer wrapper self_size = 52 B
Uint8Array wrapper self_size = 60 B
这不代表 buffer 只有几十字节。真正数据可能在 backing store。
7.2 Retained Size
含义:
如果这个对象被释放,并且它支配的对象也变为不可达,总共可以释放的内存。
Retained Size 适合找 dominator,但要小心:
某个 Object retained 很大,可能只是因为它是一个根容器。
它不一定是分配源。
例如:
FS object retained 43MB
专业解释不是“FS 对象自己有 43MB”,而是:
FS 通过 nameTable 支配了某个 FSNode.contents,而 contents 持有 43MB ArrayBuffer。
7.3 Distance
含义:
对象到 GC Root 的最短引用距离。
距离越小,越接近根对象。常见根:
window / globalThis
GameGlobal
Module
GC roots
Global handles
Native contexts
Internalized strings
距离小不代表泄露,只说明它容易长期存活。
7.4 Retainer Path
Retainer path 是:
谁持有了这个对象?
这个对象为什么还活着?
它不是 allocation stack。
正确表述:
Retainer path 证明“当前由谁保活”。
Allocation stack 才证明“当初由谁分配”。
例如:
FS -> nameTable -> contents -> Uint8Array -> ArrayBuffer
只能证明:
这个 ArrayBuffer 当前作为一个 FS 文件内容存在。
不能单凭这条链判断:
是谁写了这个文件?
这个文件是什么?
是否是泄露?
7.5 常见 node type 解读
| type | 含义 | 常见来源 |
|---|---|---|
object |
JS 对象 | Object、ArrayBuffer、Uint8Array、Module、FS |
array |
JS 数组或内部数组 | object elements、nameTable、属性数组 |
string |
字符串 | 源码字符串、日志、JSON、base64 |
code |
JS 引擎代码对象 | BytecodeArray、Code、SharedFunctionInfo |
closure |
函数闭包 | 回调、模块函数、事件监听 |
native |
JS 引擎外部/native 对象 | JSArrayBufferData |
hidden |
引擎内部对象 | WasmNativeModule、Map、Context |
synthetic |
快照合成根节点 | GC roots、Global handles |
8. 典型快照结构的专业解读
8.1 system / JSArrayBufferData
看到:
native: system / JSArrayBufferData 256MB
专业解读:
这是某个 ArrayBuffer 的外部 backing store。
它是字节数据真正占用的地方。
需要往上找 backing_store 的 owner ArrayBuffer。
继续看 retainer:
JSArrayBufferData
<- backing_store
ArrayBuffer
<- buffer
Uint8Array
<- HEAPU8
Module
结论:
这是 Wasm linear memory / Unity Heap。
如果 retainer 是:
JSArrayBufferData
<- ArrayBuffer
<- Uint8Array
<- contents
FSNode
<- nameTable
FS
结论:
这是 Emscripten FS 文件内容。
8.2 Module -> HEAPU8 -> ArrayBuffer
专业含义:
Module 是 Emscripten runtime 全局对象。
HEAPU8 是 Wasm memory 的 Uint8Array 视图。
ArrayBuffer 是 WebAssembly.Memory.buffer。
JSArrayBufferData 是 backing store。
不要说:
Module 分配了 256MB。
应该说:
Module 持有 Wasm linear memory 的视图,使这块 Wasm heap 在 retainer path 中可见。
8.3 FS -> nameTable -> contents -> Uint8Array
专业含义:
Emscripten FS 中某个文件节点持有文件内容。
可能是:
Unity data package
预加载资源
临时文件
缓存文件
日志文件
截图文件
下载文件
排查方式:
1. dump FS 文件路径;
2. 打印文件 size;
3. 读取文件头;
4. hook FS.writeFile / FS.write;
5. 结合日志和时间点。
8.4 Managed<wasm::NativeModule>
专业含义:
JS 引擎中与 Wasm module 编译结果相关的 native 内部结构。
常见归属:
Wasm 代码编译产物
Wasm 函数元数据
JIT / baseline / optimized code
引擎内部缓存
通常不能通过业务 JS 直接释放。减少它的方式通常是:
减小 Wasm 文件体积;
裁剪未用代码;
按需加载模块;
Wasm 分包;
避免一次性加载全部功能。
8.5 BytecodeArray / FeedbackVector / SharedFunctionInfo
专业含义:
JavaScript 引擎为 JS 代码生成的执行数据。
常见来源:
Unity framework.js
小游戏 runtime JS
引擎 glue code
业务 bundle
插件 bundle
Babel runtime
polyfill
这类对象在 heap snapshot 里可能很多,但一般不是 20–50MB 驻留增长的主因,除非出现大量动态生成代码、eval、new Function 或反复加载 bundle。
8.6 string 大对象
常见来源:
源码字符串
内联 source map
base64 图片
base64 音频
JSON payload
日志缓存
错误堆栈
压缩包文本
专业判断:
源码字符串:
多数是运行时或打包成本,不一定是泄露。
base64:
高风险,因为体积比二进制更大,并且字符串在 JS heap 里很明显。
JSON:
如果长期缓存大型上报包或配置包,可能泄露。
日志:
如果数组无上限,会持续增长。
9. Android 真机内存指标
在微信小游戏真机排查中,JS heap snapshot 只覆盖一部分内存。Android 进程内存更适合用:
adb shell dumpsys meminfo <pid>
/proc/<pid>/smaps_rollup
/proc/<pid>/status
showmap
Perfetto
heapprofd
GPU/mtrack 指标
Android 官方文档说明 dumpsys meminfo 可用于查看进程内存,且通常关注 Pss Total 和 Private Dirty;Private Dirty 很重要,因为它是进程私有且不能轻易回收的脏页。(Android Developers) Perfetto 文档也强调共享内存会映射到多个进程,PSS 会按比例分摊,因此分析时经常要关注 Private Dirty 来避免共享页干扰。(Android Git Repositories)
9.1 RSS
Resident Set Size
当前实际驻留在物理内存中的页总量。
特点:
包含共享页。
容易受系统缓存、共享库、其他进程影响。
不能直接等同于“这个进程独占了多少内存”。
9.2 PSS
Proportional Set Size
按比例分摊共享页后的内存。
例如某个 100MB 共享库被 4 个进程共享,每个进程 PSS 可能只算 25MB。
优点:
适合估算进程对系统总内存的贡献。
缺点:
共享页归属变化会造成波动。
跨运行比较时容易不稳定。
9.3 Private Dirty
进程私有、已修改、不能直接丢弃的内存页。
这是判断真实驻留增长的重要指标。
如果某阶段:
Private Dirty +30MB
RssAnon +30MB
持续 60 秒不回落
这比单纯 PSS +30MB 更能证明有真实私有内存增长。
9.4 RssAnon / RssFile / RssShmem
来自 /proc/<pid>/status。
RssAnon
匿名内存,通常对应 native heap、Wasm memory、malloc、匿名 mmap。
RssFile
file-backed mapping,例如 so、dex、wasm 文件、资源文件映射。
RssShmem
共享内存,例如图形 buffer、ashmem、共享映射。
判断:
RssAnon 增长:
更像 malloc / Wasm heap / native heap。
RssFile 增长:
更像文件映射、代码、资源、cache。
RssShmem / Graphics 增长:
更像图形缓冲、Canvas、GPU、Surface。
9.5 dumpsys meminfo 常见分类
| 分类 | 通常含义 |
|---|---|
| Java Heap | Java/Kotlin 对象,微信宿主或插件 Java 层 |
| Native Heap | C/C++ malloc/new |
| Code | so、dex、oat、jit code、wasm/code mapping |
| Stack | 线程栈 |
| Graphics | 图形相关内存 |
| GL mtrack / EGL mtrack | OpenGL/EGL 资源 |
| Private Other | 不容易归类的 native/private memory,WebGL/XWeb/宿主内部常见 |
| System | 系统归属或分摊内存 |
微信小游戏中,Private Other 增长并不罕见,因为 WebGL、XWeb、Canvas、宿主桥接、资源缓存都可能落在这里。
10. 为什么“启动前 700MB,另一次启动前 800MB”很常见
WebGL 小游戏内存基线波动大,常见原因:
1. 微信多进程状态不同;
2. appbrand 进程复用;
3. 上一次小游戏残留缓存;
4. XWeb / JS 引擎 code cache;
5. Wasm 编译缓存;
6. 资源缓存;
7. GPU / Canvas / Surface 状态不同;
8. Android 系统内存压力不同;
9. PSS 共享页分摊变化;
10. 是否已加载 Unity datapackage;
11. 是否已触发音频、字体、图片、网络、WebGL context。
因此,专业测试口径应是:
同一进程、同一次运行、同一阶段、稳定后采样。
不要比较:
Run A 初始 700MB
Run B 初始 800MB
而要比较:
同一个 PID:
T0: WebGL/Unity 加载完成,目标模块未启动
T1: 目标模块启动后
T2: 首次功能触发后
T3: 清理后
11. 内存增长、驻留、泄露的专业定义
11.1 峰值内存
某个操作期间短时间增加,随后回落。
例如:
下载资源时 response buffer + 解压 buffer 同时存在。
截图时 readPixels buffer + encode buffer 同时存在。
上传时 body copy + native copy 同时存在。
峰值问题会导致 OOM,但不是泄露。
11.2 驻留内存
操作完成后仍然保留,并且长期稳定存在。
例如:
初始化后的 WebGL texture cache
Wasm heap 扩容后的容量
引擎单例缓存
常驻资源
全局 buffer pool
驻留不一定是 bug,但需要判断是否必要、是否过早、是否过大。
11.3 泄露
重复执行同一操作后,稳定基线持续上升,且没有释放路径。
例如:
每次截图后多一个未 delete 的 texture;
每次请求后 pending map 少删一个 entry;
每次 start 后多注册一组事件监听;
每次写 FS 临时文件后未 unlink;
每次 push 日志无上限。
证明泄露要看趋势,不看单次。
12. WebGL 应用内存分析方法
12.1 分阶段基线法
推荐阶段:
T0: 宿主进程已稳定,游戏未加载
T1: WebGL/Unity runtime 加载完成
T2: data package / 首包资源加载完成
T3: 主场景进入,静置 60 秒
T4: 目标功能初始化前
T5: 目标功能初始化后
T6: 首次功能调用后
T7: 功能完成后静置 60 秒
T8: stop / destroy / cleanup 后
T9: 重复 N 次后
每个阶段采集:
JS heap snapshot
HEAPU8.buffer.byteLength
FS 大文件列表
WebGL 资源统计
dumpsys meminfo
smaps_rollup
log marker
12.2 JS Heap Snapshot 分析步骤
步骤:
1. 按 retained size 排序;
2. 找大对象;
3. 区分 wrapper 和 backing store;
4. 展开 retainer path;
5. 判断对象类别:
Wasm heap?
FS file?
network buffer?
WebGL readback?
string/base64?
closure queue?
6. 做前后 snapshot diff;
7. 对无法归因的大 buffer 加分配栈或 tag。
重点:
Retainer path 回答“为什么还活着”。
Allocation instrumentation 回答“哪里分配的”。
Snapshot diff 回答“阶段之间新增了什么”。
12.3 Android 真机分析步骤
推荐命令:
adb shell "ps -A | grep -i com.tencent.mm"
找到小游戏进程,例如:
com.tencent.mm:appbrand0
com.tencent.mm:appbrand1
com.tencent.mm:tools
com.tencent.mm:toolsmp
然后采样:
adb shell "PID=<pid>; while true; do \
echo '===== MEM '$(date +%s)' pid='$PID' ====='; \
dumpsys meminfo $PID | sed -n '1,120p'; \
echo '----- smaps_rollup -----'; \
cat /proc/$PID/smaps_rollup 2>/dev/null | grep -E 'Rss:|Pss:|Private_Clean:|Private_Dirty:|SwapPss:'; \
echo '----- status -----'; \
cat /proc/$PID/status 2>/dev/null | grep -E 'VmRSS|VmHWM|RssAnon|RssFile|RssShmem|VmSwap'; \
sleep 2; \
done"
分析顺序:
1. 先看同 PID;
2. 再看所有微信相关进程合计;
3. 优先看 Private Dirty、RssAnon;
4. 再看 PSS、Graphics、Private Other;
5. 结合 log marker 对齐操作阶段。
12.4 Perfetto / heapprofd
Perfetto 的 native heap profiling 可以按 malloc/free 调用栈聚合内存,但它只能看到 profiling 开始后的 native 分配,并且目标进程需要满足设备和权限条件。(Android Git Repositories)
在微信小游戏场景下限制较多:
1. 目标进程是微信,不是自己的 APK;
2. 普通 user 设备可能不能 profile 微信进程;
3. userdebug/root 设备更适合;
4. 仍需结合业务 marker 才能知道哪个阶段触发。
可用时,Perfetto 适合回答:
Native heap 增长到底来自哪个 native 调用栈?
是 malloc?
是 mmap?
是图形资源?
是线程栈?
是 JIT/code?
13. 常见高风险内存路径
13.1 截图 / readPixels
典型路径:
WebGL framebuffer
-> readPixels
-> JS Uint8Array
-> Wasm encoder input
-> JPEG/PNG output
-> upload body
高风险点:
1. 每次 new Uint8Array(width × height × 4);
2. 多个 readback ring slot;
3. raw RGBA 同时存在 JS + Wasm + FS 三份;
4. 使用 canvas.toTempFilePath / readFile 产生宿主内部 copy;
5. base64 导出;
6. 上传回调闭包持有大 buffer;
7. GL texture/FBO/PBO 没有 delete。
优化:
1. readback buffer pool;
2. 限制 ring slot 数量;
3. 直接写入 Wasm HEAPU8.subarray;
4. 避免 raw RGBA 落 FS;
5. 上传完成后清理引用;
6. 显式 delete WebGL resources。
13.2 WebGL texture / framebuffer
危险模式:
const tex = gl.createTexture();
const fbo = gl.createFramebuffer();
// 使用后没有 gl.deleteTexture(tex)
// 使用后没有 gl.deleteFramebuffer(fbo)
专业建议:
所有 create* 都应有对应 delete*。
纹理缓存要有容量上限。
场景切换、功能关闭、Canvas 重建时要释放 GPU 资源。
13.3 FS 文件缓存
危险模式:
FS.writeFile("/tmp/large.raw", rawBytes);
// 忘记 FS.unlink
结果:
FS.nameTable
-> FSNode.contents
-> Uint8Array
-> ArrayBuffer
优化:
1. 临时文件用完 unlink;
2. 不把 raw 大文件写入 FS;
3. 对 FS 文件做 size dump;
4. 对 FS.writeFile / FS.write 加 hook;
5. 避免在小游戏中滥用 Emscripten FS cache。
13.4 网络 response / upload body
危险模式:
response ArrayBuffer
-> Uint8Array copy
-> Wasm heap copy
-> FS copy
-> request body copy
优化:
1. 使用 view / subarray,避免 slice 复制;
2. 限制同时 inflight 请求;
3. 上传完成后释放 body 引用;
4. 不缓存完整历史包;
5. 大文件流式处理或分块处理。
13.5 Base64
危险模式:
binary image
-> base64 string
-> JSON
-> upload
问题:
1. base64 体积膨胀;
2. 字符串在 JS heap 中明显变大;
3. JSON stringify 可能再产生副本;
4. 上传时可能再复制。
优化:
优先使用 ArrayBuffer / Uint8Array / Blob-like binary API。
13.6 事件监听和定时器
危险模式:
setInterval(...)
requestAnimationFrame(loop)
wx.onShow(callback)
wx.onHide(callback)
canvas.addEventListener(...)
重复注册但不注销,会导致:
closure retained
context retained
large state retained
module retained
buffer retained
优化:
1. start/stop 成对;
2. on/off 成对;
3. timer id 统一管理;
4. 多次 start 前先检查是否已经注册;
5. stop 后断开大对象引用。
14. 内存快照快速判断表
| 快照表现 | 专业含义 | 下一步 |
|---|---|---|
HEAPU8 -> ArrayBuffer 256MB |
Wasm linear memory / Unity Heap | 看是否启动前已存在,是否 grow |
FS -> nameTable -> contents -> Uint8Array |
Emscripten FS 文件内容 | dump 路径、文件头、写入栈 |
system / JSArrayBufferData 很大 |
ArrayBuffer backing store | 找 owner ArrayBuffer 和 retainer |
Managed<wasm::NativeModule> 很大 |
Wasm 编译产物 | 减小 wasm、分包、懒加载 |
WasmDispatchTable / Table |
Wasm 函数表 | 通常不是泄露 |
大 string |
源码、base64、JSON、日志 | 看内容和 retainer |
system / Context 很多 |
闭包上下文 | 查事件、timer、Promise、模块缓存 |
Object retained 很大 |
容器支配子图 | 展开子节点,不要误判 |
WeakMap table |
弱映射内部结构 | 注意 key/value 是否仍被强引用 |
| JS heap 无增长但 Android 内存涨 | native/GPU/mmap/宿主缓存 | 看 meminfo、smaps、Perfetto |
15. 优化原则
15.1 延迟初始化
不要在启动阶段初始化所有重资源。
启动阶段只初始化核心逻辑。
截图、编码、上传、音频、复杂 WebGL FBO、离屏 Canvas 应按需初始化。
15.2 复用 buffer
适合复用:
readPixels buffer
YUV buffer
encode output buffer
network body buffer
decompression scratch
tile buffer
temporary typed arrays
注意:
复用不是无限缓存。
buffer pool 必须有最大容量和释放时机。
15.3 避免重复副本
优先:
view = HEAPU8.subarray(ptr, ptr + len);
谨慎:
array.slice()
buffer.slice()
Array.from()
JSON.stringify(largeObject)
base64 encode
FS.readFile()
FS.writeFile()
15.4 显式释放 WebGL 资源
gl.deleteTexture(tex);
gl.deleteBuffer(buf);
gl.deleteFramebuffer(fbo);
gl.deleteRenderbuffer(rbo);
gl.deleteProgram(program);
gl.deleteShader(shader);
同时清空 JS 引用:
tex = null;
fbo = null;
15.5 控制 Emscripten FS
临时文件:
用完 unlink。
大 raw 数据:
尽量不进 FS。
缓存文件:
有容量限制和淘汰策略。
持久化:
明确 sync 时机和内存副本成本。
15.6 控制 Wasm heap 峰值
1. 避免同一时间保留多个大 vector/string/buffer;
2. 分块处理大资源;
3. 降低启动阶段 malloc 峰值;
4. 调整 INITIAL_MEMORY / MAXIMUM_MEMORY;
5. 避免一次操作把 heap grow 到高水位;
6. 使用引擎 profiler 或 allocator 统计确认真实使用量。
15.7 用阶段差分而不是单点比较
专业测试报告应该写:
同一 PID 下,T0 到 T1:
Private Dirty 中位数 +X MB;
RssAnon 中位数 +Y MB;
JS heap retained +Z MB;
HEAPU8.buffer 未变化;
FS 大文件未新增;
Graphics +N MB;
持续 60 秒未回落。
不要写:
这次启动前 700MB,另一次 800MB,所以 SDK/模块涨了 100MB。
16. 推荐的分析流程模板
16.1 证明是否增长
1. 选定同一设备、同一微信版本、同一小游戏包;
2. 冷启动微信或清理 appbrand 进程;
3. 找到目标 PID;
4. 进入游戏并等待稳定;
5. 打 T0 marker;
6. 执行目标操作;
7. 打 T1/T2/T3 marker;
8. 每阶段等待 60 秒;
9. 采集 meminfo + smaps + heap snapshot;
10. 至少重复 5 次,取中位数。
16.2 证明是否泄露
for i in 1..10:
start feature
wait stable
stop feature
wait stable
collect memory
判断:
每轮结束后的稳定基线持续上升:
泄露或未释放缓存。
第一轮上升后稳定:
驻留成本。
操作期间上升,结束后回落:
峰值成本。
16.3 归因路径
JS heap 增长:
heap snapshot / retainer / allocation stack。
Wasm heap capacity 增长:
HEAPU8.buffer.byteLength / sbrk / malloc stats。
FS 增长:
FS dump / file path / file head / FS.write hook。
GPU/Graphics 增长:
WebGL resource counter / delete audit / dumpsys Graphics / GL mtrack。
Native heap 增长:
dumpsys Native Heap / smaps RssAnon / Perfetto heapprofd。
Private Other 增长:
XWeb/宿主/图形/匿名 mmap,需要 marker + Perfetto + 功能开关实验。
17. 标准术语表
| 术语 | 专业解释 |
|---|---|
| JS Heap | JavaScript 引擎管理的对象堆 |
| GC Root | 垃圾回收根对象,从它可达的对象不会被回收 |
| Shallow Size / Self Size | 对象自身大小 |
| Retained Size | 对象释放后可能连带释放的大小 |
| Retainer Path | 当前是谁持有该对象 |
| Allocation Stack | 对象创建时的调用栈 |
| ArrayBuffer | JS 二进制缓冲区对象 |
| JSArrayBufferData | ArrayBuffer 的 native backing store |
| TypedArray | Uint8Array、Float32Array 等 ArrayBuffer 视图 |
| WebAssembly.Memory | Wasm 线性内存对象 |
| HEAPU8 | Emscripten 对 Wasm memory 的 Uint8Array 视图 |
| Module | Emscripten runtime 全局对象 |
| MEMFS | Emscripten 内存文件系统 |
| IDBFS | IndexedDB-backed Emscripten 文件系统 |
| FSNode.contents | Emscripten FS 文件内容 |
| Wasm NativeModule | JS 引擎中的 Wasm 编译产物 |
| WebGLTexture | WebGL 纹理 wrapper |
| Framebuffer | 离屏或目标渲染缓冲组合 |
| Renderbuffer | 深度/模板/多采样渲染缓冲 |
| PBO | Pixel Buffer Object,用于像素传输 |
| PSS | 共享页按比例分摊后的进程内存 |
| RSS | 进程驻留物理内存 |
| Private Dirty | 进程私有且不可直接丢弃的脏页 |
| RssAnon | 匿名驻留内存,常对应 malloc/Wasm/native |
| RssFile | 文件映射驻留内存 |
| RssShmem | 共享内存驻留 |
| Graphics / GL mtrack | 图形/GPU/EGL/OpenGL 相关内存 |
| Private Other | Android meminfo 中不易归类的私有内存 |
18. 最重要的心智模型
分析 WebGL / 微信小游戏内存时,应该始终把内存分成四个层面:
1. JS 可见对象
Object / Array / Closure / String / ArrayBuffer wrapper。
2. JS 外部 backing store
system / JSArrayBufferData,大型二进制数据。
3. Wasm / Emscripten / Unity runtime
HEAPU8、Unity Heap、FS、Wasm compiled module、Table。
4. 宿主和系统层
WebGL/GPU、Canvas、XWeb、微信小游戏 runtime、Android native heap、Graphics、Private Other。
一个 solid 的内存结论必须回答:
1. 增长发生在哪个阶段?
2. 增长属于 JS heap、Wasm heap、FS、GPU、native,还是宿主缓存?
3. 是峰值、驻留,还是泄露?
4. 是同一进程内稳定复现,还是跨运行波动?
5. 快照中的 retainer path 证明了什么?
6. allocation stack 或打点是否证明了分配来源?
7. 这块内存对功能是否必要?
8. 是否过早分配?
9. 是否可以延迟、复用、缩容或释放?
最终,专业表述应从:
“启动后内存涨了 50MB”
升级为:
“在同一 appbrand 进程内,目标功能启动后 60 秒稳定区间,Private Dirty 中位数增加 28MB,RssAnon 增加 31MB;JS heap snapshot 未新增大型 retained object,HEAPU8.buffer 未增长,FS 大文件未新增;因此该增长更可能位于 native / graphics / 宿主 runtime 层。下一步应按 WebGL resource、Canvas readback、网络 body、native heap 四类路径继续分解。”
这就是 WebGL / 微信小游戏内存分析中最重要的专业化思考方式。