背景

Unity WebGL生成的wasm过程是从C#代码经过IL2CPP转换成为C++代码,再由Emscripten编译成为wasm代码包。对于微信小游戏这种对包体大小敏感的平台,wasm代码包往往占据了大量的体积,而其中有不少并不合理的代码占用。

本文介绍如何获取wasm代码包,使用twiggy分析每个函数的内存大小,以及如何利用符号表将混淆后的函数名还原为C#方法名,从而精确定位每个C#代码的wasm占用情况。

一、获取wasm代码包

1.1 从Unity构建产物获取

Unity WebGL构建后,在构建输出目录中会生成以下关键文件:

文件 说明
Build/xxx.framework.js JavaScript框架文件
Build/xxx.wasm wasm代码包(本文分析目标)
Build/xxx.data 资源数据文件
Build/xxx.loader.js 加载器脚本
Build/xxx.symbols.json 调试符号文件(重要)

直接从构建产物中获取wasm文件即可进行分析。

1.2 从微信小游戏包获取

微信小游戏的包体结构中,wasm文件通常位于:

1
2
game/Build/xxx.wasm
game/Build/xxx.symbols.json (仅在Development构建中存在)

如果是已发布的小游戏包(.wxapkg格式),可以使用unveilr等工具解包后提取wasm文件。

1.3 构建注意事项

为了能完整分析,建议构建时注意:

  • Development构建:会生成.symbols.json符号文件,这对后续函数名还原至关重要
  • Strip Engine Code:开启Managed Stripping Level可以减少不必要的代码,但分析前建议先关闭,以了解完整的代码占用
  • 构建完成后保留整个构建目录,不要删除中间产物

二、使用twiggy分析wasm函数大小

2.1 twiggy简介

twiggy 是由Rust/WASM工作组开发的wasm代码大小分析工具。它分析二进制的调用图,能够回答两个核心问题:

  1. 为什么某个函数被包含在二进制中 — 谁调用了它
  2. 某个函数的保留大小(Retained Size) — 如果移除该函数及其导致的死代码,能节省多少空间

2.2 安装twiggy

twiggy基于Rust构建,需要先安装Rust工具链:

1
2
3
4
5
# 安装Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

# 安装twiggy
cargo install twiggy

2.3 查看wasm体积Top项

twiggy top 命令列出wasm中体积最大的项:

1
twiggy top Release.wasm

输出示例:

1
2
3
4
5
6
7
8
9
10
 Shallow Bytes │ Shallow % │ Item
───────────────┼───────────┼──────────────────────────────────
2847256 │ 8.42% │ "function table"
2275840 │ 6.73% │ il2cpp_codegen_initialize_method
1589248 │ 4.70% │ RuntimeInvoker_True_Void_t28
1234567 │ 3.65% │ PlayerUpdate_m1234567890
987654 │ 2.92% │ InventorySystem_m2345678901
876543 │ 59% │ ... (etc)
───────────────┼───────────┼──────────────────────────────────
33817308 │ 100.00% │ Σ
  • Shallow Bytes:函数自身占用的字节数
  • **Shallow %**:占总体积的百分比
  • Item:函数名(IL2CPP编译后的名称,后面会讲如何还原)

2.4 分析函数的保留大小

twiggy dominators 命令计算每个函数的保留大小 — 即移除该函数及其所有仅被该函数依赖的死代码后,能节省的总空间:

1
twiggy dominators Release.wasm

输出示例:

1
2
3
4
5
6
7
 Retained Bytes │ Retained % │ Dominator Tree
────────────────┼────────────┼──────────────────────────────────────
33817308 │ 100.00% │ Σ
8234567 │ 24.35% │ il2cpp_codegen_initialize_method
4567890 │ 13.50% │ PlayerUpdate_m1234567890
2345678 │ 6.94% │ InventorySystem_m2345678901
1234567 │ 3.65% │ UIManager_m3456789012

保留大小比浅大小更有参考价值,因为它考虑了函数的依赖链。

2.5 追溯函数被包含的原因

twiggy paths 命令追踪某个函数为什么被包含在二进制中:

1
twiggy paths Release.wasm PlayerUpdate_m1234567890

输出示例:

1
2
3
4
5
6
PlayerUpdate_m1234567890
└── GameManager_Update_m9876543210
└── RuntimeInvoker_True_Void_t28
└── il2cpp_codegen_initialize_method
└── __wasm_call_ctors
└── start

这个调用链说明了PlayerUpdate_m1234567890函数被包含的原因,从入口start开始追踪到最终调用方。

2.6 查看函数调用者

twiggy callers 命令列出所有调用指定函数的地方:

1
twiggy callers Release.wasm il2cpp_codegen_initialize_method

2.7 可视化分析

twiggy treemap 命令生成一个HTML格式的矩形树图,可视化展示每个函数的体积占比:

1
twiggy treemap Release.wasm -o treemap.html

在浏览器中打开treemap.html,可以直观地看到哪些函数占据了最多的空间,鼠标悬停可查看详细信息。

三、使用符号表还原C#函数名

3.1 问题:IL2CPP的函数名混淆

通过twiggy分析得到的函数名形如 PlayerUpdate_m1234567890,这是IL2CPP编译后的命名规则:

  • 用户代码{MethodName}_m{MethodIndex},如 Update_m1234567890
  • Runtime调用RuntimeInvoker_{ReturnType}_{ParameterTypes},如 RuntimeInvoker_True_Void_t28
  • 反向P/Invokeil2cpp_reverse_pinvoke_method_{MethodName}
  • 内部函数il2cpp_codegen_initialize_methodil2cpp_runtime_class_init

其中 m 后面的数字是IL2CPP为每个方法分配的全局唯一索引。我们需要利用符号表将这个索引映射回C#的方法名。

3.2 获取符号表

符号表有以下几种获取途径:

途径一:Development构建的symbols.json

Unity WebGL的Development构建会在构建目录下生成 xxx.symbols.json 文件,包含函数地址到方法名的映射:

1
2
3
4
5
6
7
{
"symbols": {
"0x123456": "GameManager.Update()",
"0x234567": "InventorySystem.AddItem(UnityEngine.GameObject)",
...
}
}

途径二:IL2CPP生成的C++源码

在构建临时目录(Library/Il2cppBuildCache/)中,IL2CPP生成的C++源码包含了函数名映射信息。关键文件:

  • Il2CppTypeDefinitions.cpp — 类型定义
  • Il2CppInvokerTable.cpp — 调用器表,包含 m 索引与方法名的对应
  • Il2CppReversePInvokeWrapperTable.cpp — 反向P/Invoke包装

途径三:metadata.dat

IL2CPP会在构建时生成 global-metadata.dat 文件,其中包含了完整的类型和方法名字符串。可以使用工具解析:

1
2
# 使用Il2CppDumper提取metadata信息
Il2CppDumper <executable> <global-metadata.dat> <output-dir>

Il2CppDumper 是一个开源工具,能从IL2CPP的binary和metadata中提取完整的类型信息、方法名、字段名等,输出为dump.cs文件。

3.3 编写符号表转换脚本

获取到符号映射后,我们需要将twiggy输出的混淆函数名批量替换为C#方法名。以下是一个Python脚本示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#!/usr/bin/env python3
"""将twiggy分析结果中的IL2CPP函数名还原为C#方法名"""

import json
import re
import sys

def load_symbol_map(symbols_json_path):
"""从Unity生成的symbols.json加载符号映射"""
with open(symbols_json_path, 'r') as f:
data = json.load(f)
# 构建地址 -> C#方法名的映射
return data.get('symbols', {})

def load_method_map_from_dump(dump_cs_path):
"""从Il2CppDumper输出的dump.cs加载方法映射
提取格式: 方法名_mXXXXX -> 完整C#签名
"""
method_map = {}
pattern = re.compile(r'void\s+(\w+)_m(\d+)\s*\(')
with open(dump_cs_path, 'r', encoding='utf-8') as f:
for line in f:
match = pattern.search(line)
if match:
method_name = match.group(1)
method_index = match.group(2)
key = f"{method_name}_m{method_index}"
method_map[key] = line.strip()
return method_map

def resolve_twiggy_output(twiggy_output, method_map, symbols_map):
"""将twiggy输出中的函数名替换为C#方法名"""
# 匹配IL2CPP方法名模式: MethodName_mXXXXX
method_pattern = re.compile(r'(\w+)_m(\d+)')

resolved_lines = []
for line in twiggy_output.split('\n'):
for match in method_pattern.finditer(line):
key = match.group(0)
if key in method_map:
line = line.replace(key, f"{key} [{method_map[key]}]")
return resolved_lines.append(line)

return '\n'.join(resolved_lines)

if __name__ == '__main__':
if len(sys.argv) < 3:
print("用法: python resolve_symbols.py <twiggy_output.txt> <symbols.json>")
sys.exit(1)

twiggy_file = sys.argv[1]
symbols_file = sys.argv[2]

symbols_map = load_symbol_map(symbols_file)

with open(twiggy_file, 'r') as f:
twiggy_output = f.read()

# 先用twiggy top导出原始分析结果
# twiggy top Release.wasm > twiggy_output.txt
result = resolve_twiggy_output(twiggy_output, {}, symbols_map)
print(result)

使用方法:

1
2
3
4
5
# 1. 导出twiggy分析结果
twiggy top Release.wasm -n 200 > twiggy_top.txt

# 2. 使用符号表还原函数名
python resolve_symbols.py twiggy_top.txt Release.symbols.json > resolved_top.txt

四、分析每个C#代码的wasm占用

4.1 完整分析流程

将前面的步骤组合起来,完整的分析流程如下:

1
2
3
4
5
6
7
8
9
10
11
1. Unity Development构建
└─> 获取 xxx.wasm + xxx.symbols.json

2. twiggy top -n 500
└─> 获取按体积排序的函数列表

3. 符号表转换
└─> 将混淆函数名还原为C#方法名

4. 按类聚合
└─> 统计每个C#类的wasm总占用

4.2 按C#类聚合统计

还原函数名后,可以按类名聚合,统计每个类在wasm中的总占用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#!/usr/bin/env python3
"""按C#类名聚合统计wasm占用"""

import re
import sys
from collections import defaultdict

def aggregate_by_class(resolved_twiggy_file):
"""从已还原函数名的twiggy结果中,按类聚合体积"""
# 匹配格式: Shallow Bytes │ Shallow % │ ClassName.MethodName
pattern = re.compile(r'^\s*(\d+)\s+│\s+[\d.]+%\s+│\s+(\w+)\.(\w+)')

class_sizes = defaultdict(int)
class_methods = defaultdict(list)

with open(resolved_twiggy_file, 'r') as f:
for line in f:
match = pattern.match(line)
if match:
size = int(match.group(1))
class_name = match.group(2)
method_name = match.group(3)
class_sizes[class_name] += size
class_methods[class_name].append((method_name, size))

# 按体积降序排列
sorted_classes = sorted(class_sizes.items(), key=lambda x: x[1], reverse=True)

print(f"{'Class':<40} {'Total Bytes':>12} {'Methods':>8}")
print("─" * 65)
for class_name, total_size in sorted_classes[:30]:
method_count = len(class_methods[class_name])
print(f"{class_name:<40} {total_size:>12,} {method_count:>8}")

if __name__ == '__main__':
aggregate_by_class(sys.argv[1])

输出示例:

1
2
3
4
5
6
7
8
Class                                    Total Bytes  Methods
─────────────────────────────────────────────────────────────────
GameManager 456,789 12
UIManager 234,567 8
InventorySystem 198,432 15
NetworkManager 156,789 6
BattleController 123,456 10
...

4.3 使用wasm-objdump辅助分析

wabt(WebAssembly Binary Toolkit)提供了额外的分析能力:

1
2
3
4
5
6
7
8
9
10
11
# 安装wabt
brew install wabt

# 查看wasm文件头部信息和段
wasm-objdump -h Release.wasm

# 反汇编查看函数详情
wasm-objdump -d Release.wasm | head -200

# 列出所有导入/导出的函数
wasm-objdump -x Release.wasm | grep -A 1 "export\["

wasm2wat可以将wasm二进制转换为可读的文本格式:

1
2
3
4
wasm2wat Release.wasm -o Release.wat

# 搜索特定函数
grep -n "PlayerUpdate" Release.wat

4.4 典型分析结果与优化方向

通过对多个项目的分析,常见的wasm体积大户通常有:

类别 典型占用 优化方向
IL2CPP Runtime初始化代码 5%-10% 无法减少,属于运行时必要开销
泛型方法实例化 5%-15% 减少不必要的泛型组合,使用link.xml保留必要项
反射相关代码 3%-8% 减少反射使用,配置Managed Stripping Level
JSON/XML序列化 5%-10% 使用代码生成式序列化替代反射式
Linq和集合操作 3%-5% 热点路径避免Linq,使用for循环替代
第三方SDK 10%-30% 裁剪不需要的SDK功能模块
日志系统 2%-5% Release构建移除Debug.Log调用

4.5 针对性的优化措施

根据分析结果,可以采取以下优化措施:

1. 提高Managed Stripping Level

Project Settings > Player > Managed Stripping Level中:

  • Low:仅移除明确未使用的代码
  • Medium:更积极地移除,推荐大多数项目使用
  • High:最激进的裁剪,需要仔细测试

配合link.xml保留必要的反射调用:

1
2
3
4
5
<linker>
<assembly fullname="YourAssembly">
<type fullname="YourNamespace.YourClass" preserve="all"/>
</assembly>
</linker>

2. 减少泛型实例化

IL2CPP为每个泛型的不同参数组合生成独立的代码。例如:

1
2
3
// 这会生成两份几乎相同的wasm代码
List<int> intList = new List<int>();
List<string> strList = new List<string>();

避免不必要的新泛型实例化,特别是对值类型的泛型组合。

3. 使用代码生成替代反射

1
2
3
4
5
// ❌ 反射式序列化,会产生大量wasm代码
JsonUtility.FromJson<T>(json);

// ✅ 代码生成式序列化,体积更小
// 使用Source Generator或第三方工具生成序列化代码

4. 条件编译移除Debug代码

1
2
3
4
5
[System.Diagnostics.Conditional("UNITY_EDITOR")]
void DebugLog(string msg)
{
Debug.Log(msg);
}

Release构建中不会包含这些方法的调用代码。

五、总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
C#源码 ──IL2CPP──> C++代码 ──Emscripten──> wasm二进制

┌───────┴───────┐
│ │
twiggy top symbols.json
│ │
函数体积排名 函数名映射
│ │
└───────┬───────┘

还原后的C#方法体积

按类聚合统计

定位优化目标

核心工具链:

工具 用途
twiggy wasm函数体积分析和调用图追踪
wabt wasm二进制检查(wasm-objdump、wasm2wat)
Il2CppDumper IL2CPP metadata解析,提取方法名映射
symbols.json Unity Development构建生成的符号文件

通过这套分析流程,可以精确定位每个C#类和方法在wasm中的实际占用,避免盲目优化,有针对性地减小微信小游戏的包体大小。