背景 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代码大小分析工具。它分析二进制的调用图,能够回答两个核心问题:
为什么某个函数被包含在二进制中 — 谁调用了它
某个函数的保留大小(Retained Size) — 如果移除该函数及其导致的死代码,能节省多少空间
2.2 安装twiggy twiggy基于Rust构建,需要先安装Rust工具链:
1 2 3 4 5 curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh cargo install twiggy
2.3 查看wasm体积Top项 twiggy top 命令列出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/Invoke :il2cpp_reverse_pinvoke_method_{MethodName}
内部函数 :il2cpp_codegen_initialize_method、il2cpp_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包装
IL2CPP会在构建时生成 global-metadata.dat 文件,其中包含了完整的类型和方法名字符串。可以使用工具解析:
1 2 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 """将twiggy分析结果中的IL2CPP函数名还原为C#方法名""" import jsonimport reimport sysdef load_symbol_map (symbols_json_path ): """从Unity生成的symbols.json加载符号映射""" with open (symbols_json_path, 'r' ) as f: data = json.load(f) 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#方法名""" 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() result = resolve_twiggy_output(twiggy_output, {}, symbols_map) print (result)
使用方法:
1 2 3 4 5 twiggy top Release.wasm -n 200 > twiggy_top.txt 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 """按C#类名聚合统计wasm占用""" import reimport sysfrom collections import defaultdictdef aggregate_by_class (resolved_twiggy_file ): """从已还原函数名的twiggy结果中,按类聚合体积""" 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 brew install wabt 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 List<int > intList = new List<int >(); List<string > strList = new List<string >();
避免不必要的新泛型实例化,特别是对值类型的泛型组合。
3. 使用代码生成替代反射 1 2 3 4 5 JsonUtility.FromJson<T>(json);
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中的实际占用,避免盲目优化,有针对性地减小微信小游戏的包体大小。