0%

背景

Unity开发中有很多重复性操作:创建GameObject、调整材质、管理场景层级……如果能用自然语言让AI帮你做这些事,效率会提升很多。

MCP for Unity 就是这样一个桥梁——它通过 MCP(Model Context Protocol)协议把 AI 助手(Claude Code、Cursor 等)和 Unity 编辑器连接起来,让 AI 可以直接操控你的 Unity 项目。

本文记录从零安装 Unity MCP 并跑通 Claude Code 连接的完整流程。

前置要求

  • Unity 2021.3 LTS+
  • Python 3.10+
  • uv(Python 包管理器,替代 pip)
  • Claude Code(或其他 MCP 客户端)

安装流程

第一步:安装 uv

uv 是一个极快的 Python 包管理器,Unity MCP 的 Python 服务器通过它来运行。

1
curl -LsSf https://astral.sh/uv/install.sh | sh

安装完成后确认:

1
uv --version

macOS 用户如果遇到 command not found,需要把 ~/.local/bin 加入 PATH:

1
2
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

第二步:安装 Unity 包

在 Unity 编辑器中:Window > Package Manager > 左上角 + > Add package from git URL...

输入:

1
https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#main

想用最新 beta 版本则用:

1
https://github.com/CoplayDev/unity-mcp.git?path=/MCPForUnity#beta

Unity 会自动拉取 Git 仓库并编译三个程序集:MCPForUnity.EditorMCPForUnity.RuntimeMCPDynamic

第三步:启动 MCP 服务器

  1. Unity 中打开 Window > MCP for Unity
  2. 点击 Start Server
  3. 服务器会在 localhost:8080 启动(默认端口)
  4. 看到 🟢 “Connected ✓” 表示服务器已就绪

第四步:配置 Claude Code 连接

在 Unity 的 MCP 窗口中,从下拉菜单选择你的客户端并点击 Configure,或者手动配置。

HTTP 模式(推荐)

在项目根目录创建 .mcp.json

1
2
3
4
5
6
7
8
{
"mcpServers": {
"unityMCP": {
"type": "http",
"url": "http://localhost:8080/mcp"
}
}
}

也可以用命令行添加:

1
claude mcp add unityMCP --transport http --url http://localhost:8080/mcp

Stdio 模式

不需要在 Unity 中手动 Start Server,Claude Code 会自动启动 Python 进程:

1
claude mcp add unityMCP uvx --from mcpforunityserver mcp-for-unity --transport stdio

第五步:验证连接

重启 Claude Code,然后试试:

1
在场景中创建一个红色立方体
1
创建一个金属质感的金色材质,应用到一个新的球体上

如果 AI 能成功操作 Unity,说明连接已建立。

架构原理

理解架构有助于排查问题:

1
2
3
4
5
6
7
8
9
10
Claude Code
│ HTTP / stdio

Python MCP Server (FastMCP, 端口 8080)
│ WebSocket + HTTP

Unity Editor Plugin (C#)
│ Unity Editor API

Scene, Assets, Scripts

关键点:

  • Python 服务器是中间层,负责 MCP 协议转换,通过 WebSocket 连回 Unity 插件
  • Unity 插件通过反射扫描 [McpForUnityTool][McpForUnityResource] 特性,自动注册所有工具
  • 调用链路:Claude Code → HTTP POST → Python 服务器 → WebSocket → Unity 插件 → 执行 Unity API → 结果原路返回

两种传输模式对比

模式 配置 特点
HTTP "type": "http", "url": "http://localhost:8080/mcp" 多客户端共享,需在 Unity 中手动 Start Server
Stdio "command": "uvx", "args": ["--from", "mcpforunityserver", "mcp-for-unity", "--transport", "stdio"] 单客户端独占,Claude Code 自动启动 Python 进程

HTTP 模式适合多个 AI 客户端同时连接同一个 Unity 实例;Stdio 模式更简单,适合单人工作流。

内置工具一览

安装后立即可用的部分工具:

类别 工具 说明
场景 manage_scene 创建/打开/保存场景,多场景编辑
对象 manage_gameobject 创建/修改/查找/删除 GameObject
组件 manage_components 添加/修改/获取组件
材质 manage_material 创建和修改材质
脚本 create_script / manage_script 创建和修改 C# 脚本
资产 manage_asset 管理项目资产
编辑器 manage_editor 播放/暂停/停止,撤销/重做
控制台 read_console 读取 Unity 日志
截图 screenshot_camera 从摄像机截图
批量 batch_execute 批量执行操作,速度提升 10-100x

性能提示:需要连续执行多个操作时,优先使用 batch_execute,比逐个调用快得多。

常见问题

Unity Bridge 不连接

  • 检查 Window > MCP for Unity 窗口状态
  • 尝试重启 Unity 编辑器
  • 确认服务器端口没被占用

Python 服务器启动失败

  • 确认 uv --version 能正常运行
  • 检查 Python 版本是否 3.10+
  • 查看 Unity Console 中的报错信息

Claude Code 连接不上

  • 确保 Unity 中 MCP 服务器已启动(HTTP 模式)
  • 检查 .mcp.json 中的 URL 是否正确
  • 尝试重启 Claude Code

macOS dyld 错误

这是 Python 环境问题,参考 Common Setup Problems 排查。

参考

背景

之前每次更新博客都要手动执行 hexo clean && hexo deploy,需要本地安装 Node.js 和项目依赖,换台电脑就很不方便。将部署流程整合到 GitHub Actions 后,只需要把文章推送到仓库,CI 会自动构建并部署到 GitHub Pages。

整体架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
本地推送代码到 hexo 分支


GitHub Actions 触发 workflow

├── checkout 代码
├── 安装 Node.js 依赖
├── 克隆 Butterfly 主题
├── hexo generate 生成静态文件


peaceiris/actions-gh-pages


推送到 lixiandea.github.io (master 分支)


GitHub Pages 自动上线

配置步骤

第一步:创建 workflow 文件

在仓库根目录创建 .github/workflows/main.yml

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
name: Deploy Hexo Blog

on:
push:
branches: [hexo]

jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- run: npm ci

- name: Clone Butterfly theme
run: |
rm -rf themes/butterfly
git clone https://github.com/jerryc127/hexo-theme-butterfly.git themes/butterfly

- run: npx hexo clean
- run: npx hexo generate

- name: Deploy to GitHub Pages
uses: peaceiris/actions-gh-pages@v4
with:
personal_token: ${{ secrets.DEPLOY_TOKEN }}
external_repository: lixiandea/lixiandea.github.io
publish_branch: master
publish_dir: ./public
commit_message: "deploy: ${{ github.event.head_commit.message }}"

第二步:创建 Personal Access Token

由于源仓库(hexo_source_file)和部署目标仓库(lixiandea.github.io)是两个不同的仓库,GitHub Actions 默认的 GITHUB_TOKEN 只能访问当前仓库,无法跨仓库推送。因此需要创建 Personal Access Token(PAT)。

  1. 打开 https://github.com/settings/tokens
  2. 点击 Generate new token (classic)
  3. 勾选 repo 权限(完整的仓库访问权限)
  4. 设置过期时间(建议选择较长的周期,避免频繁更换)
  5. 生成并复制 token

第三步:配置 Repository Secret

将 PAT 存储为仓库的 Secret,避免在代码中暴露:

  1. 打开源仓库 → SettingsSecrets and variablesActions
  2. 点击 New repository secret
  3. Name 填 DEPLOY_TOKEN
  4. Value 粘贴刚才的 token
  5. 保存

第四步:推送代码触发部署

将 workflow 文件推送到 hexo 分支,GitHub Actions 会自动运行:

1
2
3
git add .github/workflows/main.yml
git commit -m "添加GitHub Actions自动部署"
git push origin hexo

可以在仓库的 Actions 页面查看运行状态。

关键问题与解决

主题的 submodule 问题

我的 Butterfly 主题是通过 git submodule 引入的,但 .gitmodules 文件丢失导致 submodule 引用损坏。在 CI 环境中克隆代码后 themes/butterfly/ 目录为空,hexo generate 会报 No layout 错误。

解决方案:将主题目录添加到 .gitignore,在 workflow 中动态克隆:

1
2
3
4
- name: Clone Butterfly theme
run: |
rm -rf themes/butterfly
git clone https://github.com/jerryc127/hexo-theme-butterfly.git themes/butterfly

本地开发时也需要保留主题文件,所以 .gitignore 中添加:

1
themes/butterfly/

本地已有主题目录不会被删除,只是不再被 git 跟踪。

为什么不用 hexo deploy

hexo deploy 需要配置 git 凭据才能推送到远程仓库。在 GitHub Actions 中配置 git 认证比较麻烦,需要手动设置 user.nameuser.email,还要处理 HTTPS 或 SSH 认证。

peaceiris/actions-gh-pages 封装了这些逻辑:

  • 自动配置 git 用户信息
  • 通过 personal_token 认证
  • 支持 commit_message 自定义(如使用源仓库的 commit message)
  • 自动处理强制推送和清理

为什么 GITHUB_TOKEN 不够用

GITHUB_TOKEN 是 GitHub Actions 自动生成的令牌,但它的权限仅限于当前仓库。我的场景是:

  • 源仓库:lixiandea/hexo_source_file(私有)
  • 目标仓库:lixiandea/lixiandea.github.io(公开)

从源仓库推送到目标仓库属于跨仓库操作,必须使用有 repo 权限的 PAT。

最终效果

配置完成后,工作流变成:

1
2
3
4
1. 本地写文章 / 修改博客
2. git push origin hexo
3. 自动构建 + 自动部署
4. 访问 https://lixiandea.github.io/ 查看更新

不再需要本地执行 hexo deploy,也不需要在每台电脑上安装 Node.js 环境。只需要一个能 push 代码的终端即可。

总结

步骤 说明
创建 workflow 文件 .github/workflows/main.yml,监听 hexo 分支推送
创建 PAT GitHub Settings → Developer settings → Personal access tokens
配置 Secret 仓库 Settings → Secrets → Actions → DEPLOY_TOKEN
推送触发 代码推送到 hexo 分支后自动构建部署

核心依赖:peaceiris/actions-gh-pages 处理跨仓库推送部署。

背景

写博客是个好习惯,但每次都要花大量时间组织内容、排版、调整格式,很容易劝退。如果能用AI工具自动生成博客内容,同时保证质量,那效率就能提升很多。

本文介绍如何使用 Claude Code(Anthropic推出的AI编程CLI工具)配合 火山方舟Coding Plan,搭建一套自动化写Hexo博客的工作流,并重点讲解如何避免API Key泄漏。

什么是Claude Code

Claude Code 是Anthropic官方推出的命令行AI编程助手。它可以直接在终端中运行,理解你的项目代码库,帮你完成编写代码、重构、调试、搜索等任务。

核心能力:

  • 读取和理解整个项目代码库
  • 直接编辑和创建文件
  • 执行Shell命令
  • 与Git集成(创建commit、PR等)
  • 支持自定义API端点,可以使用第三方LLM网关

什么是火山方舟Coding Plan

火山方舟 是字节跳动推出的大模型服务平台,提供多种模型的统一API接入。其中的 Coding Plan 是方舟平台面向代码生成和编程辅助场景的专项服务,提供适合编程和文本生成的模型(如豆包系列模型)。

注意:方舟Coding Plan的具体功能、模型列表和定价可能随时间变化,请以火山方舟官方文档为准。截至本文编写时,方舟官方文档中关于Coding Plan的公开细节有限,建议登录方舟控制台查看最新的模型和定价信息。

方舟API兼容OpenAI SDK格式,基础调用方式如下:

1
2
3
4
5
6
7
8
9
10
11
from openai import OpenAI

client = OpenAI(
api_key="YOUR_ARK_API_KEY",
base_url="https://ark.volcengine.com/v1"
)

response = client.chat.completions.create(
model="ep-xxxxxxxx", # 方舟推理接入点ID
messages=[{"role": "user", "content": "Hello!"}]
)

整体架构

由于Claude Code要求后端API支持 Anthropic Messages格式/v1/messages),而火山方舟提供的是 OpenAI兼容格式/v1/chat/completions),两者无法直接对接。我们需要一个中间代理层来转换协议。

整体架构如下:

1
2
3
4
5
6
7
8
9
10
Claude Code CLI
│ (Anthropic Messages格式)

LiteLLM Proxy
│ (协议转换: Anthropic → OpenAI)

火山方舟 API
│ (OpenAI兼容格式)

豆包/Coding Plan模型

重要局限

协议转换只能解决API格式兼容问题,但 无法解决模型能力差异

  • Claude Code的系统提示和工具调用格式是为Claude模型设计的,转给豆包等非Claude模型时,工具调用(Tool Use)的成功率和准确度会下降
  • 对于简单的文本生成任务(如写博客文章),影响较小;对于需要精确工具调用的场景(如编辑文件、执行命令),体验可能不如原生Claude模型
  • 如果你拥有Anthropic官方API Key或Claude Pro/Max订阅,建议优先使用原生接入(见文末)

搭建步骤

第一步:获取火山方舟API Key

  1. 注册并登录火山方舟控制台
  2. 在「模型推理」中创建推理接入点(Endpoint),选择你需要的模型
  3. 在「API Key管理」中创建API Key
  4. 记录下你的 Endpoint ID(格式如 ep-20250101xxxxx)和 API Key

第二步:部署LiteLLM代理

LiteLLM 是一个开源的LLM代理服务,支持多种API格式之间的转换。

安全提示:LiteLLM PyPI版本1.82.7和1.82.8曾被植入窃取凭证的恶意代码,请勿安装这两个版本。详见 BerriAI/litellm#24518

安装LiteLLM

1
2
3
pip install 'litellm[proxy]' --upgrade
# 确保版本不是1.82.7或1.82.8
pip show litellm | grep Version

创建配置文件

创建 litellm_config.yaml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
model_list:
- model_name: claude-sonnet-4-6
litellm_params:
model: openai/ep-xxxxxxxx # 替换为你的方舟Endpoint ID
api_base: https://ark.volcengine.com/v1
api_key: os.environ/ARK_API_KEY # 从环境变量读取,不要硬编码
- model_name: claude-haiku-4-5
litellm_params:
model: openai/ep-yyyyyyyy # 替换为你的另一个方舟Endpoint ID
api_base: https://ark.volcengine.com/v1
api_key: os.environ/ARK_API_KEY

general_settings:
master_key: os.environ/LITELLM_MASTER_KEY # LiteLLM代理自身的认证密钥

配置说明:

  • model_name:Claude Code识别的模型名,保持与Claude模型同名即可,LiteLLM会做映射
  • modelopenai/ 前缀 + 你的方舟Endpoint ID,告诉LiteLLM用OpenAI协议调用
  • api_base:方舟的API地址
  • api_key:使用 os.environ/ 前缀从环境变量读取,避免硬编码

启动代理

1
2
3
4
export ARK_API_KEY="你的方舟API Key"
export LITELLM_MASTER_KEY="sk-自定义一个安全密钥"

litellm --config litellm_config.yaml --port 4000

启动后,LiteLLM会在 http://localhost:4000 提供Anthropic Messages格式的API,并将请求转换为OpenAI格式转发给方舟。

第三步:配置Claude Code

方式一:环境变量(推荐用于临时测试)

1
2
3
4
export ANTHROPIC_BASE_URL="http://localhost:4000"
export ANTHROPIC_AUTH_TOKEN="sk-自定义的LiteLLM密钥" # 即上面设置的LITELLM_MASTER_KEY

claude

方式二:配置文件(推荐用于长期使用)

创建或编辑 ~/.claude/settings.json

1
2
3
4
5
6
{
"env": {
"ANTHROPIC_BASE_URL": "http://localhost:4000",
"ANTHROPIC_AUTH_TOKEN": "sk-你的LiteLLM密钥"
}
}

方式三:使用apiKeyHelper(适合动态密钥)

对于需要动态获取密钥的场景(如从密钥管理服务获取临时Token),可以配置一个脚本:

  1. 创建密钥获取脚本 ~/bin/get-llm-key.sh
1
2
3
4
#!/bin/bash
# 从安全的密钥管理服务获取临时密钥
# 示例:从macOS钥匙串读取
security find-generic-password -s "litellm-master-key" -w
1
chmod +x ~/bin/get-llm-key.sh
  1. 在Claude Code设置中配置:
1
2
3
{
"apiKeyHelper": "~/bin/get-llm-key.sh"
}
  1. 可选,设置密钥刷新间隔:
1
export CLAUDE_CODE_API_KEY_HELPER_TTL_MS=3600000  # 每小时刷新

注意apiKeyHelper 的优先级低于 ANTHROPIC_AUTH_TOKENANTHROPIC_API_KEY。如果同时设置了环境变量和apiKeyHelper,环境变量优先生效。

第四步:自定义模型名称

如果LiteLLM配置的 model_name 与Claude Code默认使用的模型名不一致,可以通过环境变量覆盖默认模型选择:

1
2
3
# 在启动Claude Code前设置(仅在model_name与默认值不同时才需要)
export ANTHROPIC_DEFAULT_SONNET_MODEL="claude-sonnet-4-6"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="claude-haiku-4-5"

提示:如果第二步的LiteLLM配置中 model_name 已设置为 claude-sonnet-4-6claude-haiku-4-5(即与上述环境变量值相同),则不需要额外设置这些变量。这两个环境变量仅在你想让Claude Code使用与默认不同的模型名时才需要。

使用Claude Code自动写Hexo博客

配置完成后,就可以用Claude Code来辅助写博客了。

创建新文章

在Hexo项目目录下启动Claude Code:

1
2
cd /path/to/hexo_source_file
claude

然后直接用自然语言描述你要写的博客内容:

1
帮我创建一篇关于Unity Addressables资源管理最佳实践的博客,包括资源分组策略、内存优化和加载性能调优三个方面

Claude Code会:

  1. 使用 hexo new post 创建Markdown文件
  2. 自动填充YAML Front Matter(title、date、tags、categories)
  3. 按照你的描述生成完整的博客内容
  4. 保持与你现有博客风格一致的Markdown格式

修改已有文章

1
帮我在spine优化这篇文章中补充按动画拆分Spine文件的具体代码实现

Claude Code会读取现有文章,理解上下文,在合适的位置插入内容。

一键生成并预览

1
创建一篇关于Lua热更新方案对比的博客,然后启动本地预览服务器让我看看效果

Claude Code会创建文章并执行 hexo server 启动预览。

部署

确认文章内容无误后:

1
帮我执行 hexo deploy 部署博客

防止API Key泄漏

这是使用AI工具时最重要的安全问题。以下是多层防护策略:

1. 环境变量,绝不硬编码

1
2
3
4
5
6
# ✅ 正确:通过环境变量传递
export ARK_API_KEY="你的密钥"
export LITELLM_MASTER_KEY="sk-安全密钥"

# ❌ 错误:写在配置文件中提交到Git
api_key: "AK-xxxxxxxx" # 绝对不要这样做

LiteLLM配置中使用 os.environ/ 前缀来引用环境变量:

1
2
api_key: os.environ/ARK_API_KEY  # ✅ 从环境变量读取
# api_key: AK-xxxxxxxx # ❌ 不要硬编码

2. .gitignore排除敏感文件

确保以下文件在 .gitignore 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 环境变量文件
.env
.env.local
.env.production

# 密钥脚本
bin/get-llm-key.sh

# LiteLLM配置(如果包含密钥)
litellm_config.yaml

# Claude Code项目设置(可能包含API配置)
.claude/settings.json

3. 使用 .env 文件管理本地密钥

创建 .env 文件(确保已被gitignore):

1
2
3
4
5
# .env - 绝对不要提交到Git
ARK_API_KEY=AK-xxxxxxxx
LITELLM_MASTER_KEY=sk-xxxxxxxx
ANTHROPIC_BASE_URL=http://localhost:4000
ANTHROPIC_AUTH_TOKEN=sk-xxxxxxxx

推荐使用 direnv 自动加载,避免手动export:

1
2
3
4
5
6
7
8
9
# 安装direnv
brew install direnv

# 在Shell配置中启用direnv(~/.zshrc添加)
eval "$(direnv hook zsh)"

# 在项目根目录创建 .envrc
echo 'source_env .env' > .envrc
direnv allow

进入项目目录时,direnv会自动加载 .env 中的环境变量。

4. 使用系统密钥管理

macOS用户可以使用钥匙串存储密钥:

1
2
3
4
5
# 存储密钥
security add-generic-password -s "ark-api-key" -a "hexo-blog" -w "AK-xxxxxxxx"

# 读取密钥(用于apiKeyHelper脚本)
security find-generic-password -s "ark-api-key" -w

Linux用户可以使用 passsecret-tool 或云厂商的密钥管理服务。

5. 在CLAUDE.md中声明安全规则

在项目的 CLAUDE.md 中添加安全指令,确保Claude Code不会将密钥写入博客内容:

1
2
3
4
# 安全规则
- 绝不在生成的博客文章中包含任何API Key、Token、密钥等敏感信息
- 如果引用代码示例,将密钥替换为占位符(如 YOUR_API_KEY)
- 不要将 .env 文件的内容输出到对话中

6. Git Hooks自动检查

创建 pre-commit hook 防止意外提交密钥:

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
# .git/hooks/pre-commit

# 检查暂存文件中是否包含疑似API Key的内容
KEY_PATTERN="(AK-[a-zA-Z0-9]{16,}|sk-ant-[a-zA-Z0-9]{20,}|api_key\s*[:=]\s*[\"'][^\"']{10,})"
if git diff --cached -G "$KEY_PATTERN"; then
echo "检测到可能的API Key,请确认后再提交"
echo "如果是误报,使用 git commit --no-verify 跳过检查"
exit 1
fi
1
chmod +x .git/hooks/pre-commit

7. 方舟平台侧的防护

  • 为不同用途创建不同的API Key,限制其权限范围
  • 设置API调用的额度上限,防止密钥泄漏后产生高额费用
  • 定期轮换API Key
  • 使用方舟 GetApiKey 接口获取临时API Key,而非长期有效的密钥

完整工作流示例

下面是一个从零开始的完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 1. 启动LiteLLM代理(新终端窗口)
export ARK_API_KEY="你的方舟API Key"
export LITELLM_MASTER_KEY="sk-your-secure-key"
litellm --config litellm_config.yaml --port 4000

# 2. 配置Claude Code环境
export ANTHROPIC_BASE_URL="http://localhost:4000"
export ANTHROPIC_AUTH_TOKEN="sk-your-secure-key"

# 3. 进入Hexo项目目录
cd /path/to/hexo_source_file

# 4. 启动Claude Code
claude

# 5. 在Claude Code中输入你的需求
# > 帮我写一篇关于xx的博客,包括xxx和xxx几个部分

# 6. 检查生成的文章
# > hexo server 启动预览

# 7. 满意后部署
# > hexo deploy

如果你有Anthropic官方API

如果你有Anthropic官方的API Key(无需使用方舟),配置更简单,不需要LiteLLM代理:

1
2
export ANTHROPIC_API_KEY="sk-ant-xxxxxxxx"
claude

也可以使用Claude Pro/Max订阅直接登录:

1
claude login

使用官方API或订阅可以享受Claude模型的原生工具调用能力,体验明显优于通过代理转接的方案。

总结

步骤 说明
获取方舟API Key 在火山方舟控制台创建
部署LiteLLM代理 转换Anthropic Messages与OpenAI格式
配置Claude Code 设置 ANTHROPIC_BASE_URL 和认证信息
写博客 用自然语言描述需求,Claude Code自动生成
防泄漏 环境变量 + gitignore + pre-commit hook + CLAUDE.md规则

核心原则:密钥永远只通过环境变量或系统密钥管理传递,绝不出现在代码、配置文件或Git仓库中

近期读完了《我的26岁女房客》这本书,这是一部都市情感小说,讲的是男主昭阳——一个颓废的苏州青年,在人生低谷期与四个截然不同的女性之间的情感纠葛,最终不断抉择、成长,走向女主的故事。里面关于男主和四个女主的情感故事覆盖了很多现实生活中恋爱的种类:

  • 简薇:代表校园刻骨铭心的初恋;
  • 乐瑶:人生低迷时期的相互陪伴;
  • 李小允:相亲的邻家对象;
  • 米彩:相互磨合的走到最后的女主;

这部分其实不同的人容易获得不同的感觉,比如你有过校园恋爱的、你有过一起相互扶持的都能映射到不同的女主里面,这里李小允真的很吃亏,毕竟现实中的相亲有感情的其实很少(我个人观点);

其次是我对男主这个人的主观观点,从我个人看书的角度上来说,男主是一个苏州大学毕业、父亲是科长、自身业务能力很好、为人极度自卑又表现为自负的人、说话轻浮但是本身是比较稳重的人、追求”天空之城”。现实生活中我对他的描述是夹在自身层级和自己的女朋友们层级之间挣扎的人;从头到尾我还是觉得他不靠谱,说句实话我要是有女儿,我也没办法接受这样的一个人做女婿,毕竟才华是一个很抽象的东西,但是他烟酒都来、私生活混乱(看起来)、喜欢泡吧;我只能说现实中的男生要先让自己靠谱,才能让别人相信你说的话;

关于女角色们

关于简薇

这个是我感触很深的一个角色,这里也不怕大家笑话,我是一个校园恋爱的受害者,被断崖式分手,很久之后得知别人也是一时冲动,后面也聚过,初恋是去年结婚,有的时候会想为什么会变成这样呢,是哪一步走到了现在的境地呢;我给她的关键词是如果

简薇是对昭阳的影响也是最大的,男主性格里面很大的阴暗面可以说都是断崖式分手带来的ptsd,文中出现的很多次男主狼狈逃离都是来自这里。也是这个初恋让他认知到阶级不一致会带来的严重的后果,内心的自卑经过独处的几年的发酵最终变成一个让人无法忍受的嘴强王者;

小说开头最大的剧情就是简薇回国、假装女友产生误会、答应告白、天空之城崩塌。男主和女主的感情底色发生在天空之城崩塌之后,天空之城重建之后的女主已经变了人。小说里面的她毫无疑问是完全支持男主的,无论是帮助男主还乐瑶的钱、帮助男主获得启动资金、帮助男主解决女主公司的公关危机,支持男主和女主的恋情。这里回到她的主题,如果男主是个男人、如果男主真的有表现得那么豁达、如果在一起的时候适当矫正、如果分手的时候能够说清楚、如果大家都不感动自己,如果真的坚持到底、可能最后的结果都不会变成男主和女主在一起的情况。

但是我还是那句话,这个男主真是一个傻逼东西,我是真没办法和这种男主感同身受,我只是感觉可惜,我想有的时候简薇也会想为什么会变成这样呢,是哪一步走到了现在的境地呢。

乐瑶

乐瑶是人生低迷时期的相互陪伴的人,可是我觉得他是最没有可能走到最后的人。我给他的关键词是错误

乐瑶全身心的喜欢男主,她陪伴男主一起度过艰难的两年,彼此相互扶持、烂在苏州,这个经历就是她最大的问题。男主本身是一个大男子主义的人,应了那句话儿子性格底色是父亲,板爹是一个正直到刻板的人,男主不会一直颓废;一旦男主摆脱颓废,那么乐瑶就会只能从男主身上得到帮助、可怜但却得不到任何的爱情。无论是一起颓废的过去、错误的夜晚、错误的形象最后都指向了令人失望的结果。小说开始的男主只有乐瑶的情况下,双方开着黄腔,彼此在苏州酒吧烂成一团泥。错误的夜晚导致怀孕,男主直到最后都不相信孩子是自己的,在他的心里,乐瑶就是一个很玩的开的人,他的性格底色不会接受这样的人。

乐瑶的优势是和男主很大程度上是同一阶级甚至更低阶级的,男主能得到大男子主义上的满足。其次是他和男主相互舔舐伤口的过去,能够让男主永远放不下自己。但是错误的开始不会得到一个爱惜她的男主,只会伤害自己,很多网上的人觉得带球是王炸,对男主来说是无法拒绝的,但是我很怀疑男主初期对待乐瑶感情生活的质疑,很难相信带球能得到效果。

乐瑶最让我心疼的地方在于,她其实比谁都清楚自己在男主心中的位置。她知道男主放不下简薇,也知道男主和米彩之间的暧昧,但她选择假装不知道,因为她害怕一旦戳破,连留在男主身边的资格都没有。这种”知道但不敢面对”的状态,其实比不知道更痛苦。最后的流产,可以说是她这段感情最残酷的注脚——连最后的牵绊都没有了。

我的认知里面很难说人会一直颓废下去,毕竟现实生活中混民谣圈、说唱圈的如果有机会得到主流的认可,都会毫不在意地丢弃自己小众的粉丝。如果你想和一个人一起长久,最好是能让对方变得更好,也让自己表现得更好。第一面影响很多东西,如果第一面印象不好,相处初期没有一个好的情况,后面的不珍惜都会变成理所应当。如果摆脱了现状,男主是百分百不会接受乐瑶的,他对乐瑶的看法,看似是妹妹,我更多的理解是可怜;

李小允

很多时候我觉得李小允是最接近现实的情况,当然她们感情增长的过程也很玄幻。从我个人读书的感受上来说,如果现实生活中有李小允这样的人,我就十分满足了。但是我能给的关键字是可惜

从小允的恋爱过程上来看,男主条件在三线城市还是很能打的,会唱歌、帅、苏州大学等,这也是我觉得男主因为夹在层级中间导致痛苦的原因。小允和男主是同层的人,经过相亲就坚定的选择了男主,对男主存在一定的强者崇拜,让男主心理上极大满足。相信男主,见过其他几位之后也愿意做男主失败伤心之后的planB。温柔如水且包容,关心男主的日常生活,希望彼此能精神和经济上相互扶持的人。如果是现实,我只能说这简直是完美女人,可惜这是都市爽文小说。

小允身上最可贵的是一种”平凡的力量”。她不会像简薇那样决绝,不会像乐瑶那样卑微,也不会像米彩那样高高在上。她只是踏踏实实地对一个人好,希望两个人能一起把日子过好。借一千块给彼此一个机会那段,与其说是卑微,不如说是她最后的倔强——她不甘心这段感情就这样结束,但她也只能拿出这么多筹码了。

小允最让我感动的是男主为了乐瑶放弃订婚之后,选择找男主借了一千,给了自己和男主再一次机会,可惜男主真不是个人。男主受到高层级的压迫的同时也沉迷其中,如果有任何的机会他可能都会努力往前钻,有的时候我会恶毒的想,男主和很多现实生活中的企业家很像,依赖着女方的帮助起家,同时又要软饭硬吃。

我觉得男主回徐州的那段时间是我向往的生活,体贴的妻子,被赏识的工作,父母的认可。可惜男主来自大城市、可惜身边的女生太过晃眼、可惜这是小说。

米彩

作者主推的女主,虽然我知道这是小说,但是还是觉得太过离谱,现实生活中也很难有映射到米彩身上的情况。我对这个的感受虽然有,但是看书的时候已经感觉到主推这位了(毕竟题名是女房客)。他的关键字我只能说正确

正确的时间,在男主对简薇的天空之城坍塌之后出现,重建了男主的追求。正确的行为,包容并且接受很多不合理的行为。真让人感动的地方有很多,我感动于每次都会以拿衣服回到老房子,给自己和男主一个机会,感动于在最困难的时候没有选择离开而是共同面对。

米彩之所以是”正确”,在于她和男主之间有一种其他三人都无法建立的东西——对等的灵魂碰撞。简薇和男主的感情是青春期的炽烈但脆弱,乐瑶和男主是互相取暖但缺乏尊重,小允和男主是合适但缺乏激情。只有米彩,她既能让男主仰视(阶级和能力),又能在精神层面和男主平等对话(理想和追求)。男主在其他人面前要么自卑要么自负,只有在米彩面前,才能做一个真实的自己。

但正因为太”正确”了,所以我觉得不真实。现实中,一个卓美公司的CEO,年轻、漂亮、有能力,为什么要选择一个颓废的、私生活混乱的、前途未卜的男人?小说给出的理由是”灵魂的共鸣”和”天空之城的理想”,但这个理由放在现实里太过浪漫化了。我能理解作者为什么这样写——毕竟小说需要给读者一个圆满的结局——但这也正是都市爽文的通病:把阶层差距写得好像只是勇气的问题。

写在最后

这本书打动我的不是男主最终选择了谁,而是它真实地呈现了一个普通人面对不同感情时的挣扎。简薇的”如果”、乐瑶的”错误”、小允的”可惜”、米彩的”正确”——这四个关键词,其实也是很多人在现实中面对感情时的四种状态。

我们大多数人都有过简薇式的遗憾:如果当初怎样怎样就好了。也见过乐瑶式的付出:明明不对等却甘愿沉沦。也遇到过小允式的适合:一切都对,就是差了一点命运。也幻想过米彩式的圆满:对的人在对的时间出现。

但现实不是小说,不会有人一直在老房子里等你回来拿衣服。如果你问我从这本书里学到了什么,那就是:不要等”正确”出现才开始珍惜,因为你身边的人可能就是你的”正确”,只是你没有看到。

最后说回男主这个人,我不喜欢他,但我理解他。他的很多选择让人恨铁不成钢,但他的痛苦也是真实的——一个夹在阶层之间的人,想要的够不着,拥有的不甘心。这大概是很多年轻人的写照吧。只希望现实中的我们,能比他做得好一点。

2023年过去了,这是我第一次完整在一个公司度过一年,这一年我身上发生了很多的变化:

  1. 完成了工作身份上的转变,从需求实现的人变化为自我提出需求和完成需求的角色;
  2. 工作氛围的变化,现在对接的工作人更多,且在工友里面找到了一起打羽毛球的人;
  3. 感情生活上确定了无法和现在的对象走向婚姻,变得随和,且不在在意我们之间的三观区别;
  4. 初恋结婚了,想到之前的故事,觉得我可能很难找到和初恋一样爱我的人了,感觉可能很难结婚了;
  5. 找到了和父母相处的方式,支撑家庭的同时尽量对自己好点;

一、工作的变化

2022年底入职现在的公司,2023年在这里度过了一整年,2024年七月份是我工作三周年,工作前半年在做需求方向的事情,很多的时候是接受需求方的要求和修改意见。

1、工作内容上的变化

之前的工作内容主要是需求向的,我个人的交流能力一般,有的时候感觉需求很不合理也没有逻辑,但是由于考虑到交流的复杂性都放弃了坚持,转而去完成需求;下半年转向了优化和改进向的工作内容,我刚转岗的时候组长还开玩笑的说公司要是没钱了第一时间可能就是裁我们。

这里我觉得很庆幸,试用期的时候努力完成需求的同时,多和组长交流一下自己工作上的思考,在公司内部需要招聘优化向的人的时候之前的组长推荐我转岗,之后到了现在的部门。虽然现在面临的情况是可能公司裁员的时候第一时间会想到我们,但是现在的工作内容相对而言比较自主,且组长只有我一个手下,目前和其他人的合作复杂度也没有很高,也有了很多的时间去思考很多大局向的内容。

2、个人成长方向的变化

虽然公司是一个小公司,但是之前的工作内容上更看中的是需求实现的能力,且组长没有很多的时间去考虑你的成长性。更换了工作内容之后由于组长只有我一个下属,我们之间的交流频次多了很多,对成长上的方向也有了很多的具体化的调整。对我的要求从简单的完成需求变成了很多具体化的描述,这里我也感觉到pua和实际的想要培养你的区别;

二、工作氛围

更换工作内容之后工作的氛围和之前的工作出现了很大的不同,最大的感受就是如果你的组长只有你一个组员,那你确实会很爽,毕竟他可能会觉得你不太行但是还是不得不用你,虽然经常性的嫌弃但是也没别的方法🙂。其次是工作方法的问题,由于我的工作内容很少出现和版本相关的,往往是我做的差不多了之后,向项目负责人要求一个比较空闲的版本新增灰度版本,在灰度版本测试我的内容然后合到主干。因此很多时候我的工作内容是技术分享和推广我做的功能,某种程度上我学会了一些表达上的技巧。在这种环境下,组长对我没啥办法的情况下手把手教了我很多东西,比如什么是建立信任。这里推荐一个我很喜欢的访谈内容,还是很部分还是很有意思的,带来了部分不一样的视角。

初次之外今年捡起来了羽毛球,很久没打重新捡起来感觉还挺好,尤其是在北漂的周末没有什么事情干的时候能够避免自身陷入悲观,背上球拍打三个小时,一整个周末都会处在积极的环境中。更让我惊喜的是在公司同事中有很多水平差不多(不超过一级)的同事,大家一起也能更好的分享公司趣闻。

三、感情生活

和现在的女友从校园中走出来一起北漂,今年是工作的第三年,住在一起后仔细思考过要不要将结婚列上日程,明白了对方的想法和我自己的心里的想法,明白对现在的我们来说有一些无法解决的矛盾,她不觉得我是合适的结婚对象,我也不认为她是一个我觉得合适的对象,这里我也分享一下心路历程。

1、感情底色

我们是读研在一起的,两年的学校恋爱,到现在是五年时间。工作之后部分时候我们一开始是同居了一段时间,后面她工作了之后又分开住了一段时间,之后又同居了一段时间,我们某种程度上来说算得上北漂路上彼此的伙伴,彼此抚慰工作上的伤口,但是我和她之间有很多矛盾的地方,

背景

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中的实际占用,避免盲目优化,有针对性地减小微信小游戏的包体大小。

unity使用Addressables进行资源管理很方便但是有一个很值得诟病的问题, 那就是catalog文件过大, 对于一个持续运营的项目来说catalog文件可能超过10M, 导致初始化Addressables时间较长, 峰值内存上涨明显.

本文通过分析catalog文件中主要的内容占用和内存开销, 提出一些可行的(已实践)方案.

一. catalog相关的开销分析

catalog 开销主要来自两个部分:

  1. 初始化Addressables 的时间开销, 使用Addressables需要确保catalog 加载完成, 对于部分网络情况不佳的用户造成卡顿较长的体验;
  2. 加载catalog文件的内存开销, 使用json格式的Addressables加载时会产生两倍于自身大小的内存开销.

加载catalog的时间开销和网络情况密切相关, 实际测试下来一个1Mcatalog文件经过传输和加载到内存, 整体时间超过1s. 主要的开销来源是文件的大小.

内存上的开销主要是加载文本本身和反序列化数据之后的结果上的内存开销.

1. catalog 本身大小开销分析

catalog文件本身是一个json文件, 主要结构如下:

image-20240222202809424

本身实际是ContentCatalogDatajson序列化结果. 其中包含的数据是以下几类数据:

属性 数据含义
m_LocatorId Addressables支持多catalog,所以添加了区分不同catalog的ID
m_InstanceProviderData Instantiate方法使用的Provider的序列化结果
m_SceneProviderData 加载Scene使用的Provider序列化数据
m_ResourceProviderData 加载资源使用的Provider的序列化结果
m_ProviderIds Provider的类名称
m_InternalIds 资源的路径列表, 是AssetPathBase64加密结果
m_KeyDataString Key的二进制序列化结果
m_BucketDataString 哈希桶索引数据,将Key映射到对应的Entry列表,实现Key→Location的哈希表查找
m_EntryDataString 资源的Entry的序列化结果,包含资源的类型,资源的以来等
m_resourceTypes 资源的类型
m_InternalIdPrefixes InternalId列表的前缀优化数据

json文件里面能看到其中比较大的主要是m_KeyDataString m_InternalIds, m_EntryDataString 分别指向了资源的路径, 资源的key和资源的加载信息其中每一段的数据的大小如下:

1
2
3
4
5
6
catalog.json 16.30MB (3.20MB compressed)
43282 internalIds
86296 Keys:5.88MB
86296 Buckets:2.76MB
106176 Entries:3.78MB
731 Extras:802.29KB

2. catalog 加载内存开销分析

catalog加载过程中的内存峰值远大于文件本身的大小,这是因为在反序列化的过程中,同一份数据会同时存在多份拷贝

加载流程

以默认的JSON格式为例,catalog的加载流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
1. TextDataProvider 加载原始JSON文本到内存
└─> File.ReadAllText() 或 UnityWebRequest.downloadHandler.text
内存中存在: 原始JSON字符串 (如16MB)

2. JsonAssetProvider 调用 JsonUtility.FromJson 反序列化
└─> 生成 ContentCatalogData 对象
内存中存在: 原始JSON字符串 + ContentCatalogData对象
ContentCatalogData包含Base64编码的字段:
m_KeyDataString, m_BucketDataString, m_EntryDataString, m_ExtraDataString
这些Base64字符串的大小约等于原始JSON

3. ContentCatalogData.CreateLocator() 构建运行时查找结构
└─> Base64解码为byte[],构建Bucket[]、object[] keys、IResourceLocation[] locations
内存中存在: 原始JSON + ContentCatalogData(Base64字符串) + 运行时结构

4. CleanData() 释放序列化数据
└─> 清空 m_KeyDataString, m_BucketDataString, m_EntryDataString, m_InternalIds
内存中仅保留: 运行时查找结构

内存峰值分析

加载过程中三份数据同时存在于内存

阶段 数据 大小估算
原始文本 JSON字符串 ~16MB
反序列化对象 ContentCatalogData(含Base64字段) ~16MB
运行时结构 ResourceLocationMap(Bucket、Key、Location数组) ~8-12MB
峰值合计 ~40-44MB

也就是说,一个16MB的catalog.json文件,加载过程中的内存峰值约为文件大小的3倍。在CleanData()调用后,序列化数据被释放,运行时只保留查找结构,内存回落到8-12MB左右。

Bundled格式(压缩本地Catalog)

Unity Addressables支持将catalog打包成AssetBundle格式(Compress Local Catalog),开启方式:

  • Inspector: Window > Asset Management > Addressables > Settings > Catalog > Compress Local Catalog
  • 代码: AddressableAssetSettingsDefaultObject.Settings.BundleLocalCatalog = true

开启后的加载流程:

1
2
3
4
1. AssetBundle.LoadFromFileAsync() 加载catalog bundle
2. LoadAllAssetsAsync<TextAsset>() 提取JSON文本
3. AssetBundle.Unload(true) 卸载bundle
4. JsonUtility.FromJson() 反序列化(同JSON流程)

Bundled格式的优势是磁盘空间和下载体积:16MB的JSON经过LZ4压缩后约3.2MB。但运行时内存峰值并不会减少,因为最终还是需要完整的JSON文本进行反序列化。

注意:Bundled格式会增加catalog的加载时间,因为多了一步AssetBundle的加载和卸载。远程更新catalog仍保持JSON格式,不受此设置影响。

二. 可行的catalog优化方案

1.剔除locations

上面分析中发现很多的开销是来在Locations也就是资源的数量, 如果假如到catalog中的资源数量较多, 自然catalog会明显增大. 那么什么是需要添加到catalog中的什么是不需要的呢? 这里项目不一样判断的方法也不一样, 这里设计了一个简单的配置正则项去筛选需要加到Catalog中的Locations用于过滤需要保留的locations. 经过过滤之后能减少2/3的location数量, 能减少大约1/3的大小.

警告:剔除Location可能会导致label丢失, 比如你给一个路径下的资源都标记为lableA,但是所有的资源Location被删除了导致label丢失. 使用这个方案请确保所有的通过Addressables加载的资源确实被保留了.

代码如下

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
63
public class LocationFilterConfig : ScriptableObject
{
protected virtual void Init()
{
}

// 用于子类实现自己的忽略功能
protected virtual bool IsIgnored(ContentCatalogDataEntry entry)
{
return false;
}

// 过滤的接口
public void FilterLocation(List<ContentCatalogDataEntry> locations, ref List<ContentCatalogDataEntry> output)
{
Init();
foreach(ContentCatalogDataEntry location in locations)
{
if(!location.ResourceType.IsAssignableFrom(typeof(IAssetBundleResource)) && IsIgnored(location))
{
continue;
}
output.Add(location);
}
}
}
// 具体实现
public class ResConfigLocationFilterConfig : LocationFilterConfig
{
[SerializeField]
private string[] m_includedAssets;

private Regex[] m_includedAssetsRegex;
private HashSet<string> m_internalIds;

protected override void Init()
{
m_includedAssetsRegex = null;
if (m_includedAssets != null)
{
m_includedAssetsRegex = new Regex[m_includedAssets.Length];
for(int i = 0;i < m_includedAssets.Length;i++)
{
m_includedAssetsRegex[i] = new Regex(m_includedAssets[i]);
}
}
}
// 使用正则项对资源路径进行过滤
protected override bool IsIgnored(ContentCatalogDataEntry entry)
{
if (m_includedAssetsRegex != null)
{
foreach (Regex regex in m_includedAssetsRegex)
{
if(regex.IsMatch(entry.InternalId))
{
return false;
}
}
}
return true;
}
}

2.剔除location 类型

对于同一个资源, 存在多个Entry, 其中不同的key, 不同的资源类型都会生成一个新的数据, 所以会导致同一个资源数据多很多. 这里主要是剔除key和资源类型:

2.1 剔除key

警告:剔除key可能会导致加载资源时无法找到对应的资源而加载失败, 确保你的剔除手段是合理的

剔除key的方法很简单, 在AssetGroup里面勾选是否包含部分信息.其中分别包含的信息是是否需要使用Addresses加载资源, 是否会使用AssetReference加载资源以及是否会通过Label加载资源

image-20240223114135162

2.2 剔除资源类型

对于一个资源来说, 他的资源类型有很多, 在Catalog中会生成不同类型的Entry, 比如TextAsset的资源不仅仅会存在一个TextAssetEntry, 也会产生UnityEngine.Object资源的Entry. 这里也能按照类型剔除部分的资源类型生成的Entry.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
[CreateAssetMenu(fileName = "TypeLocationFilterConfig", menuName = "Addressables/Location Filter/Type")]
public class LocationFilterConfigType : LocationFilterConfig
{
[SerializeField]
private string[] m_types;

protected override bool IsIgnored(ContentCatalogDataEntry entry)
{
string typeName = entry.ResourceType.FullName;
foreach(string type in m_types)
{
if(type == typeName)
{
return true;
}
}
return false;
}
}

3.剔除依赖

警告:可能会导致资源加载失败, 请记得实现检查工具

依赖的数据是catalog中很大的部分, 尤其是对于依赖关系复杂的项目, 依赖的数据可能会比真实数据还多, 这里推荐使用编辑器下的资源依赖去替换使用Assetbundle计算出来的依赖关系, 减少资源的依赖数量

3.1 依赖数据在Catalog中的存储方式

ContentCatalogDataEntry中,每个资源的依赖以Key列表形式存储:

1
2
3
4
5
public class ContentCatalogDataEntry
{
public List<object> Dependencies { get; private set; } // 依赖Key列表
// ...
}

当资源有2个及以上依赖时,Addressables会创建一个”聚合依赖”条目:

  1. 将依赖列表转为HashSet<object>
  2. 计算哈希值:hash = hash * 31 + o.GetHashCode()
  3. 将此哈希值作为新Key加入Key列表
  4. 原始资源的依赖列表被替换为单个哈希Key
  5. 每个被依赖的Bundle的Key列表也会加上这个哈希值

这意味着每增加一个依赖关系,不仅会增加Entry数据,还会增加额外的Key数据和Bucket数据。对于依赖关系复杂的项目,依赖数据可能占总数据的30%-50%。

3.2 使用编辑器依赖替换AssetBundle依赖

构建时GenerateLocationListsTask计算的是展开依赖(Expanded Dependencies)——即所有传递依赖的合集。这会导致很多冗余的依赖引用。

优化思路:在构建完成后,用AssetDatabase.GetDependencies()获取资源的直接依赖,再映射回对应的Bundle,替换原始的展开依赖。

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
/// <summary>
/// 在构建流程中替换Catalog中的依赖数据
/// 在自定义BuildScript的DoBuild中,GenerateCatalog之前调用
/// </summary>
public static void ReplaceDependenciesWithEditorDeps(
List<ContentCatalogDataEntry> locations,
Dictionary<string, string> assetToBundleMap)
{
foreach (var entry in locations)
{
// 仅处理非Bundle类型的资源(即实际的Asset条目)
if (entry.ResourceType == typeof(IAssetBundleResource))
continue;
if (entry.Dependencies == null || entry.Dependencies.Count == 0)
continue;

// 从Entry的Key中找到资源的GUID或路径
string assetGuid = entry.Keys
.OfType<string>()
.FirstOrDefault(k => k.Length == 32); // GUID长度

if (string.IsNullOrEmpty(assetGuid))
continue;

string assetPath = AssetDatabase.GUIDToAssetPath(assetGuid);
if (string.IsNullOrEmpty(assetPath))
continue;

// 使用编辑器获取直接依赖(recursive=false)
string[] deps = AssetDatabase.GetDependencies(assetPath, false);

// 将依赖资源映射回其所在的Bundle
var actualBundleDeps = new HashSet<string>();
foreach (var depPath in deps)
{
// 排除自身
if (depPath == assetPath)
continue;

string depGuid = AssetDatabase.AssetPathToGUID(depPath);
if (assetToBundleMap.TryGetValue(depGuid, out string bundleName))
{
actualBundleDeps.Add(bundleName);
}
}

// 替换原始依赖
entry.Dependencies.Clear();
entry.Dependencies.AddRange(actualBundleDeps);
}
}

其中assetToBundleMap的构建方式:

1
2
3
4
5
6
7
8
9
10
11
// 从构建上下文中获取资源到Bundle的映射
var assetToBundleMap = new Dictionary<string, string>();
foreach (var group in aaContext.assetGroupToBundles)
{
string bundleName = group.Value; // bundle名
foreach (var entry in aaContext.assetEntries)
{
string guid = entry.guid;
assetToBundleMap[guid] = bundleName;
}
}

3.3 接入构建流程

将上述依赖替换逻辑接入自定义构建脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[CreateAssetMenu(fileName = "CustomBuildScript.asset",
menuName = "Addressables/Content Builders/Custom Build Script")]
public class CustomBuildScript : BuildScriptPackedMode
{
protected override TResult DoBuild<TResult>(
AddressablesDataBuilderInput builderInput,
AddressableAssetsBuildContext aaContext)
{
// 在GenerateCatalog之前修改locations
// 1. 应用LocationFilter过滤
FilterLocations(aaContext.locations);

// 2. 替换依赖数据
var assetToBundleMap = BuildAssetToBundleMap(aaContext);
ReplaceDependenciesWithEditorDeps(aaContext.locations, assetToBundleMap);

// 调用原始构建流程
return base.DoBuild<TResult>(builderInput, aaContext);
}
}

在Addressables Settings中将Build Script替换为自定义的CustomBuildScript即可。

3.4 依赖剔除的效果

在一个实际项目中的优化效果:

指标 优化前 优化后 降幅
catalog.json大小 16.3MB 11.2MB 31%
压缩后大小 3.2MB 2.4MB 25%
Location数量 43,282 28,156 35%
Key数量 86,296 54,831 36%
加载内存峰值 ~44MB ~30MB 32%

注意:使用编辑器依赖替换后,必须确保运行时加载Bundle时Unity的AssetBundle依赖机制能正确处理传递依赖。由于AssetBundle.LoadFromFileAsync加载Bundle时会自动处理Bundle间的依赖关系,因此只需要保留直接依赖即可,传递依赖由Unity运行时处理。

三.总结

Addressables是一个广泛使用的资源管理库, 使用默认的方式能确保完全不会出问题, 但是不同项目有不同的实际情况, 在做库开发时适用性应该要大于效率,但是项目实际使用的情况下应当是效率大于适用.