如何热更新增Coroutine逻辑
解读
面试官真正想确认的是:
- 你是否清楚 Coroutine 的编译产物本质(编译器生成的 IEnumerator + 状态机),以及它与 IL 解释执行框架(如 HybridCLR、huatuo、ILRuntime、XLua)的兼容边界。
- 在 国内主流热更方案(iOS 不允许 JIT,Android 可 JIT 但包体受限)下,你如何把新增协程逻辑 安全地打进热更补丁包,并保证 状态机跨版本不崩、GC 不爆炸、调试可定位。
- 当热更脚本里需要 启动、挂起、恢复、Kill 协程 时,你是否能给出 工程级封装,让美术/策划也能无痛调用,而不必关心底层差异。
一句话:不是问“协程怎么用”,而是问“在不允许生成新 IL 的 iOS 上,如何把一段全新的 IEnumerator 逻辑热更下去并稳定跑起来”。
知识点
-
Coroutine 的编译原理
任何yield return都会被 C# 编译器展开成 私有嵌套类,实现IEnumerator<System.Object>,内部维护state字段与switch (state)状态机。 -
国内热更路线
- AOT 补充解释器:HybridCLR / huatuo 在 iOS 上直接解释 IL,新生成的 IEnumerator 类型可被注册进解释器,性能损耗 10% 左右。
- 虚拟机级:ILRuntime、XLua 把热更代码跑在 虚拟机栈 上,需把 IEnumerator 翻译成 虚拟机指令序列,否则状态机跳转会崩。
- Lua 层:ToLua、xLua 把协程映射到 Lua 协程,通过
coroutine.yield / resume与 Unity 的MonoBehaviour.StartCoroutine桥接。
-
关键限制
- iOS 禁止 动态生成类型(
System.Reflection.Emit),因此 不能 在运行时new TypeBuilder造出新的 IEnumerator。 - 状态机字段名由编译器自动生成,不同版本可能重命名,直接序列化
state会崩。
- iOS 禁止 动态生成类型(
-
工程级封装
- 统一入口:
HotCoroutineRunner.Start(IEnumerator routine),内部根据平台决定 解释执行 还是 虚拟机执行。 - 补丁打包:把新增 IEnumerator 类型 提前标记
[HotFix],打包工具自动将其 IL 注入到hotupdate.dll,同时生成 AOT 补充元数据(HybridCLR)。 - 生命周期:热更层维护
Dictionary<int, IEnumerator>映射,Stop 时通过 id 查表,避免StopCoroutine(string)的 GC 与反射开销。
- 统一入口:
-
调试与回滚
- 在
Development Build下,解释器栈 可完整打印,HybridCLR 提供__stack_trace__;ILRuntime 需提前注册ILIntepreter.__DebugBreak。 - 回滚策略:补丁包带 版本哈希,启动时比对,不匹配则清空
PersistentDataPath下缓存,防止老状态机残留。
- 在
答案
分三步落地,以 HybridCLR + Addressable 为例,国内线上验证过 500w+DAU:
-
预处理
在Assembly-CSharp里定义一个 空壳占位类型:[HotFix] public sealed class HotCoroutineLogic { public static IEnumerator NewDailyRewardAnimation() => null; }该类型 仅用于占位,保证 AOT 编译器提前生成 类型元数据。
-
热更脚本实现
在HotUpdate.dll里 真正实现协程:public sealed class HotCoroutineLogic { public static IEnumerator NewDailyRewardAnimation(Transform root) { for (int i = 0; i < 5; ++i) { var node = Addressables.InstantiateAsync("RewardCoin", root).WaitForCompletion(); node.transform.localScale = Vector3.zero; yield return node.transform.DOScale(1, 0.3f).WaitForCompletion(); yield return new WaitForSeconds(0.1f); } } }注意:不能 使用
yield return null以外的 自定义 yield 指令,除非你在热更层也注册了对应的IEnumerator桥接。 -
启动与生命周期
在MonoBehaviour中提供 统一入口:public class HotCoroutineLauncher : MonoBehaviour { private static readonly Dictionary<int, IEnumerator> s_Running = new(); private static int s_ID; public static int StartHotCoroutine(IEnumerator routine) { int id = ++s_ID; s_Running[id] = routine; Instance.StartCoroutine(CoWrap(id)); return id; } private static IEnumerator CoWrap(int id) { var routine = s_Running[id]; while (routine.MoveNext()) { yield return routine.Current; } s_Running.Remove(id); } public static void StopHotCoroutine(int id) { if (s_Running.TryGetValue(id, out var routine)) { // 主动释放 Addressable 资源 if (routine is IDisposable disposable) disposable.Dispose(); s_Running.Remove(id); } } }这样 热更层 只需调用:
int token = HotCoroutineLauncher.StartHotCoroutine(HotCoroutineLogic.NewDailyRewardAnimation(transform));即可把 全新协程逻辑 在 iOS 上 无 JIT 地跑起来,并且 随时可 Kill,内存可追踪,补丁包大小 <200KB。
拓展思考
-
如果项目已用 ILRuntime,新增协程就必须把 IEnumerator 状态机 翻译成 ILRuntime 栈指令,官方提供
CoroutineAdapter但 不支持yield return nested IEnumerator,需要自写YieldInstructionCache做 递归展开,否则 嵌套协程会无限递归。 -
性能敏感场景(战斗同步、大规模 Tween)可把 热更协程 预编译成 Unity 原生 AnimatorPlayable 或 Timeline Clip,通过 Addressable 更新 Controller 文件,彻底 避开解释器损耗,但 开发成本翻倍,需权衡。
-
版本回滚陷阱:若热更协程里 写了本地持久化(如
PlayerPrefs.SetInt("step", state)),回滚老版本后 状态机字段对不上,必须 在补丁卸载时清空相关 key,否则 100% 复现闪退。国内大厂做法是 给每个热更协程打semver标签,回滚时 自动执行OnPatchRollback()钩子,由热更脚本自己清理。