技术博客

脚本热重载:编辑器里按保存后哪些状态丢了

第 89 / 149 篇 · 2025-06-05 · godot · GDScriptC#编辑器源码分析

开发时最爽的时刻是按 Ctrl+S 后游戏里的行为立刻变了。但"变了"三个字底下藏着一组不一致的规则:有的状态被保留,有的悄悄重置,有的干脆没生效。把 Godot 两条脚本路线的热重载机制拆开看,规则就能背下来而不是每次现踩。

GDScript:替换编译产物,不动活着的实例

GDScript 的热重载分两个世界。

编辑器场景里,保存脚本触发重编译后,场景会被重新加载(reload),所有节点重建,新逻辑立即生效。代价是你场景里的运行时状态归零——这是编辑器域的行为,不算热重载的坑,算工作流特性。

运行中的游戏(编辑器内嵌运行的场景树)里,规则完全不同:重编译产生一个新的 GDScript 对象(脚本资源本身被替换),但已经存在的 ScriptInstance 不换。一个正在跑的节点还挂着旧字节码编译出来的实例,改动对它不生效。新 instantiate 的节点才拿新脚本。实测验证方式很简单:运行中改一个 _process 里的值,看旧节点输出不变,新开一个同场景节点输出已变。

静态变量跟着脚本对象走。脚本对象被替换成新编译产物,静态变量重新走一遍初始化表达式——运行中的静态状态在热重载后重置。如果静态变量被当缓存用(static var _cache := {}),热重载一次缓存清空一次,这个语义有时恰好是你想要的(强制刷新),有时是要命的(计数器归零)。

还有一个隐蔽的坑:signal 连接。连接里存的 Callable 指向"旧脚本对象上的方法",脚本对象换了之后,编辑器会把场景里能追踪到的连接重连到新对象上,但运行中通过代码动态 connect 的连接不做迁移。表现就是:热重载后有的回调生效有的不生效,取决于 connect 发生在场景序列化里还是运行时代码里。

C#:collectible ALC,重载整个程序集域

4.2 起的 C# 热重载换了一条路,前面 .NET 宿主那篇埋过伏笔:脚本程序集加载在一个可卸载的 AssemblyLoadContext 里,热重载 = 用新 ALC 加载重新编译的程序集,然后把旧 ALC 标为卸载。

这条路的优势是彻底:旧程序集连同其中所有类型进入卸载队列,新实例全部来自新程序集,不存在"旧实例挂着旧字节码"的半新半旧状态。被卸载的旧类型实例在下次 GC 时回收,内存不留尾巴。

代价同样是彻底:

  • 所有静态状态丢失,比 GDScript 更干净——GDScript 只是静态变量重置,C# 是静态类、单例模式、缓存字典全部重来。
  • 旧类型的存活实例不能跨 ALC 存活。引擎持有的对象引用是句柄不是类型实例,所以场景树能撑过去;但你自己放进静态字段、事件订阅里的东西,引用的是旧类型,随旧 ALC 一起失效。事件订阅是重灾区:SomeEvent += Handler 里的 Handler 指向旧程序集的方法,重载后事件还连着,调到的却是鬼魂方法。
  • 4.2 起这个功能默认开着但带 experimental 标记,4.x 中期版本里它的崩溃案例大多出在"旧类型实例被外部缓存(第三方序列化库、DI 容器)强引用",导致 ALC 卸载失败或类型撕裂。遇到诡异崩溃先怀疑这个。

对比表

状态 GDScript 热重载 C# 热重载
运行中实例的旧行为 保留旧实例,改动不生效 实例由句柄引用,行为更新
静态变量 重置为初始化表达式值 全部丢失(整个 ALC 回收)
场景序列化里的 signal 连接 编辑器重连到新脚本对象 随对象重建重连
运行时代码 connect 的连接 不迁移,指向旧脚本对象 事件订阅指向鬼魂方法
序列化缓存的脚本类型引用 按 UID 重新解析,安全 外部缓存的旧类型实例失效

工程上的稳定姿势

无论哪条路线,有两条规则能把坑封死。第一,把"不能丢"的状态放在节点上而不是静态变量里——节点的属性走场景序列化,热重载后仍在;静态变量一律视为可丢弃缓存。第二,运行时代码建立的连接,在 _exit_tree 里配对 disconnect,别依赖重载帮你清理。我自己的工程里给所有动态 connect 包了一层带 owner 的辅助函数,_exit_tree 统一断开,热重载后再没出现过"有的回调活着有的死了"的灵异现象。

热重载后状态的保留与丢失 GDScript:替换脚本对象 运行中实例(保留旧字节码) 旧 ScriptInstance × 改动不生效 静态变量(重置) × 回到初始化值 场景序列化里的 connect ✓ 编辑器重连 运行时 connect × 指向旧脚本对象 节点自身属性 ✓ 走序列化保留 C#:卸载旧 ALC 运行中实例 ✓ 句柄引用,行为更新 静态字段 / 单例 × 随旧 ALC 回收 事件订阅 × 指向鬼魂方法 外部库缓存的旧类型 × ALC 卸载失败 引擎句柄引用的对象 ✓ 场景树撑过去 新实例来源 新 ALC 的程序集 统一姿势:状态放节点属性上,动态 connect 在 _exit_tree 里配对断开