Unity 编辑器在脚本编译完成后会触发"域重载"(Domain Reload):整个托管脚本域重启、所有静态字段被清零、运行中的 EditorCoroutine 被中止、HTTP 监听 socket 被回收。对运行时游戏代码这没影响------Play Mode 之外的脚本对象本来就不持续------但对 Editor 工具是一场每隔几分钟就发生的"地震"。
本文记录 Unity 域重载会清掉什么,以及一个长期运行的 Editor 工具(以 Funplay Unity MCP 为例)如何用三层应对让状态在重载后无缝续命。
1. 域重载触发时机
四种典型触发:
- C# 脚本被修改 + 保存------最常见,几乎每次开发都会遇到
AssetDatabase.Refresh()检测到代码变更------MCP 工具或 Editor 脚本主动调用时- 菜单
Assets → Reimport All------清理项目时 - 进入 Play Mode 时(如果项目设置 Domain Reload 选项打开)
无论哪种触发,效果一致:托管侧(CLR)的所有状态被清空,原生侧(Unity Editor 进程本体)保留。
2. 域重载会清掉什么
把重载当成一次"进程内重启"是合理近似:
域重载前
重载发生
域重载后
静态字段
运行中 task / coroutine
HTTP server / socket
反射 / 类型缓存
InitializeOnLoad 已执行
静态字段 = 默认值
task 中止 / 抛 ThreadAbort
socket 已关闭
首次访问触发重新构建
InitializeOnLoad 重新执行
对长期运行的 Editor 工具,最痛的几条:
- HTTP 服务器(如 MCP server)的监听 socket 被关闭,端口可能短时间被占用
- 正在执行的工具调用 ------例如一个
execute_code跑到一半,后半段消失 - 配置缓存 / 类型扫描结果------TypeCache 之类的查询要重新做
- 客户端连接状态------AI 客户端发出的请求若卡在重载窗口,会超时或拿到 socket reset
3. 三类典型应对
业界对域重载的处理方式归为三类:
| 策略 | 适用 | 代价 |
|---|---|---|
| 重载后重建(默认) | 无状态服务,重启代价低 | 启动时间长则影响响应性 |
| 序列化保留 | 有需要跨重载传递的关键状态 | 状态结构必须可序列化 |
| 取消并重启 | 可接受丢弃中断中的工作 | 客户端需要主动重试 |
Funplay Unity MCP 三类都用------HTTP server 是策略 1,正在执行的工具调用状态是策略 2,无法续传的工具走策略 3。
4. 静态状态:[InitializeOnLoad] 与 DI 容器
[InitializeOnLoad] 标记的类型在每次域重载完成后被自动加载,是静态状态重建的标准入口。Funplay Unity MCP 的 RootScopeServices 走这条路:
csharp
[InitializeOnLoad]
internal static class RootScopeServices
{
private static IContainer _container;
static RootScopeServices()
{
_container = BuildContainer();
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeReload;
EditorApplication.quitting += OnQuit;
// 域重载后自动恢复中断的工具调用
EditorApplication.delayCall += () => CheckForInterruptedExecution();
}
private static IContainer BuildContainer()
{
var c = new Container();
c.RegisterSingleton<IEditorThreadHelper, EditorThreadHelper>();
c.RegisterSingleton<MCPServerService>();
c.RegisterSingleton<FunplayMcpSettings>(LoadSettings);
return c;
}
private static void OnBeforeReload()
{
_container?.Dispose();
_container = null;
}
}
每次重载后构造函数重跑,DI 容器从头构建。beforeAssemblyReload 事件是关键------重载发生前主动 Dispose 容器,确保 HTTP 监听 socket 在静态字段被清空之前已经显式关闭,避免端口泄漏。
5. 中断态:SessionState 与 DomainReloadHandler
SessionState 是 Unity 提供的 Editor 会话级 KV 存储,重载后保留、Editor 关闭后清空。Funplay 用它持久化"重载发生时正在执行的工具调用"信息:
csharp
internal static class DomainReloadHandler
{
private const string KEY_INTERRUPTED = "Funplay.MCP.Interrupted";
public static void RecordInterruption(string toolName, string requestId, string reason)
{
var record = new InterruptionRecord
{
ToolName = toolName,
RequestId = requestId,
Reason = reason,
TimestampUtc = DateTime.UtcNow.ToString("o")
};
SessionState.SetString(KEY_INTERRUPTED, JsonUtility.ToJson(record));
}
public static InterruptionRecord ConsumePending()
{
var raw = SessionState.GetString(KEY_INTERRUPTED, null);
if (string.IsNullOrEmpty(raw)) return null;
SessionState.EraseString(KEY_INTERRUPTED);
return JsonUtility.FromJson<InterruptionRecord>(raw);
}
}
beforeAssemblyReload 事件触发时,正在跑的工具调用主动调用 RecordInterruption 把状态扔进 SessionState;重载完成后 RootScopeServices 静态构造函数里调用 ConsumePending,把这条记录变成 MCP 客户端可见的"上次调用被中断"摘要。
客户端可通过 get_reload_recovery_status 工具查询:
json
{
"success": true,
"data": {
"wasInterrupted": true,
"toolName": "execute_code",
"reason": "AssemblyReload triggered during execution",
"timestamp": "2026-05-07T08:42:13Z"
}
}
知道发生了什么之后,客户端可以决定是直接重试、还是先确认副作用。这种透明性比"工具调用静默丢失"或"工具调用同步等待重载"都更合理。
6. 服务重启:EditorApplication.delayCall 模式
域重载完成后立即重启 HTTP server 是个陷阱------AssemblyReloadEvents.afterAssemblyReload 事件在主线程触发,但此时 Unity 内部仍在收尾,立即调用某些 Editor API 会失败。
正确的方式是把重启推迟到 EditorApplication.delayCall:
csharp
private static void OnSettingsChanged(FunplayMcpSettings _)
{
// 重启服务必须走主线程的 delayCall,不能直接在事件回调里执行
EditorApplication.delayCall += () =>
{
var server = _container.Resolve<MCPServerService>();
server.Restart();
};
}
delayCall 在 Unity 的下一次 Editor 主循环空闲时触发,此时所有 Editor 内部状态已就绪。同样的模式也用于"端口配置变更后重启"------任何会触发 socket 重新绑定的操作都要走这条通道。
特别提醒:绝不在后台线程触发服务重启 。delayCall 不仅是延迟,更是把执行强制放回主线程。Funplay Unity MCP 在 MCPServerService 里专门有一段注释说明这条规则------历史上有过一次 bug 是设置变更监听跑在 ThreadPool 线程上直接调 socket bind,导致 Editor 偶发崩溃。
7. 反射 / 类型扫描的重建
域重载后所有反射缓存失效。Funplay 的 ToolRegistry 在每次重载后通过 [InitializeOnLoad] 触发的 ScanAssemblies 重新扫描所有 [ToolProvider] 标注的类型------并使用 TypeCache.GetTypesDerivedFrom<>() 加速:
csharp
[InitializeOnLoad]
internal static class ToolRegistry
{
private static Dictionary<string, ToolDefinition> _tools;
static ToolRegistry()
{
_tools = ScanAssemblies();
}
public static void InvalidateCache()
{
// 提供给外部插件主动失效缓存的入口
_tools = ScanAssemblies();
}
private static Dictionary<string, ToolDefinition> ScanAssemblies()
{
var result = new Dictionary<string, ToolDefinition>();
foreach (var t in TypeCache.GetTypesWithAttribute<ToolProviderAttribute>())
{
foreach (var m in t.GetMethods(BindingFlags.Public | BindingFlags.Static))
{
var name = ToSnakeCase(m.Name);
if (BlockedTools.Contains(name)) continue;
result[name] = BuildDefinition(m);
}
}
return result;
}
}
TypeCache 是 Unity 提供的 O(1) 类型查询缓存,专门为这种"每次重载后重建类型集合"的场景设计。相比传统的 AppDomain.CurrentDomain.GetAssemblies() 遍历,TypeCache 命中速度是数量级的差异。
8. 客户端视角的"无缝续命"
把上述三层组合起来,AI 客户端发出 MCP 请求时遇到域重载的体感是:
Unity Editor MCP Server AI 客户端 Unity Editor MCP Server AI 客户端 socket 中断;客户端收到错误 tools/call execute_code 调度到主线程 触发域重载(脚本编译) tools/call get_reload_recovery_status { wasInterrupted: true, toolName: execute_code, reason } tools/call execute_code(重试) 正常返回
整个过程对客户端是显式的------它能从 get_reload_recovery_status 拿到结构化的中断摘要,决定下一步是重试、回滚、还是询问用户。比"请求静默失败"或"客户端无限等待"都更可控。
9. 设计原则总结
让 Editor 工具在域重载下续命的几条共通设计原则:
| 原则 | 实现 |
|---|---|
| 容器层重建,不持久化 | [InitializeOnLoad] 静态构造 + beforeAssemblyReload Dispose |
| 业务态显式持久化 | 用 SessionState 序列化关键中断态 |
| 服务重启走主线程 | EditorApplication.delayCall 而非后台线程 |
| 类型扫描走 TypeCache | 避免每次重载手动遍历所有 Assembly |
| 中断对客户端透明 | 提供专门的 recovery 查询工具 |
这些原则不止 MCP server 适用------任何长期运行、需要响应外部请求的 Editor 工具(场景同步插件、远程调试桥、自动化测试 runner)都会面临同样的重载挑战。
10. 写在最后
Unity 域重载在底层逻辑上无法避免------Mono / IL2CPP 的运行时模型决定了脚本变更后必须重新加载托管程序集。Editor 工具能做的不是阻止它,而是让它从"用户感知到的崩溃"变成"用户感知不到的边界事件"。
Funplay Unity MCP 的完整实现可在 FunplayAI/funplay-unity-mcp 查看。Editor/State/DomainReloadHandler.cs 与 Editor/DI/RootScopeServices.cs 是本文示例的源头。MIT 协议,欢迎 issue 与讨论。