我一直在构建一个工具,用于在 Play 模式下已经运行的游戏应用 C# 修改,这样可以保留在编译之间设置的状态,而不是在重新编译时丢失它。剪辑是一次 Play 会话:已经运行的协程将其 spawn 延迟从 2 秒改为 0.1 秒,而播放计时器永不重置。这篇帖子主要讨论其内部工作原理以及我遇到的问题,因为其中一些让我惊讶到了。

基本循环

文件监视器看到保存文件,并确定该文件所属的程序集(对全新文件,则通过最近的 .asmdef 判断)。只编译该程序集,使用 Unity 自带的 Roslyn 编译器。新程序集加载在正在运行的程序集旁边,并将每个已更改方法的入口点重定向到其新版本。在编辑器中使用 Harmony 完成此操作。

https://preview.redd.it/szd5fx9jlmyh1.png?width=1600&format=png&au&s=4463605d2798c36875c5532f8a8dd804c5e05779

出错 #1:协程通过序号记住它们的位置

https://preview.redd.it/1f2jejsmlmyh1.png?width=1600&format=png&au&s=e8b906bf8197f85560480dbfcd702a920240b89b

编译器将协程转换为一个类,其 MoveNext() 根据一个整数进行切换:当前停留在哪个 yield。将 2f 改为 0.1f 仍保留 yield 位置,因此运行的协程可以接收新体,并在下一个循环中使用 0.1f(这是顶部剪辑的内容)。添加或删除 yield return 会导致序号变化,使得在新体上的运行协程会在错误的位置恢复。它会在旧代码中结束,而保存后开始的所有内容则使用新代码。

出错 #2:async 方法导致编辑器崩溃

我的第一版尝试在挂起的 await 中修补 async 方法的状态机,这会导致编辑器硬崩溃,因为 async builder 绑定到精确的状态机类型。现在方法本身被重定向:新调用运行新体,而已经在飞行的调用则在旧体上完成。

出错 #3:每种类型都有两份副本

https://preview.redd.it/pmyoc0tolmyh1.png?width=1600&format=png&au&s=f40f054062cdeed51a45f7991904b3455206845d

重新加载的程序集会为你的类型和静态字段创建自己的副本。若无额外工作,重新加载代码中的 GetComponent<Player>() 找不到任何内容,单例读取到 null。重新加载代码必须回到运行中的游戏的原始类型和静态字段。

出错 #4:Burst

Burst 编译的作业是原生代码,无法交换,因此编辑其中一个会在整个会话期间关闭 Burst,使得作业以受管理的 C# 方式运行(控制台显示如此)。

一个令人惊喜的发现:新 MonoBehaviour

我原本预期需要自行调用 Update 和 Start,因为该类只存在于重新加载的程序集中。结果发现,Unity 在将其添加为组件后会原生调用它们。

以及 Android

Harmony 无法补丁 ARM 代码,因此在 Android 开发构建中,在方法入口处的一个小原生钩子负责重定向。同一份保存文件在不重新构建的情况下即可到达手机。

它在 Asset Store 上名为 2N Lightning Reload,供你尝试(Windows 编辑器,Unity 2021.3 至 6.5):https://assetstore.unity.com/packages/tools/utilities/2n-lightning-reload-edit-c-in-play-mode-without-compiling-402544

但我更感兴趣的是,是否有其他人也走上了这条路。是否有更清洁的方式来处理 yield 序号变化,而不是让运行的协程在旧体上结束?