如何热更新增Coroutine逻辑

解读

面试官真正想确认的是:

  1. 你是否清楚 Coroutine 的编译产物本质(编译器生成的 IEnumerator + 状态机),以及它与 IL 解释执行框架(如 HybridCLR、huatuo、ILRuntime、XLua)的兼容边界。
  2. 国内主流热更方案(iOS 不允许 JIT,Android 可 JIT 但包体受限)下,你如何把新增协程逻辑 安全地打进热更补丁包,并保证 状态机跨版本不崩GC 不爆炸调试可定位
  3. 当热更脚本里需要 启动、挂起、恢复、Kill 协程 时,你是否能给出 工程级封装,让美术/策划也能无痛调用,而不必关心底层差异。

一句话:不是问“协程怎么用”,而是问“在不允许生成新 IL 的 iOS 上,如何把一段全新的 IEnumerator 逻辑热更下去并稳定跑起来”。

知识点

  1. Coroutine 的编译原理
    任何 yield return 都会被 C# 编译器展开成 私有嵌套类,实现 IEnumerator<System.Object>,内部维护 state 字段与 switch (state) 状态机。

  2. 国内热更路线

    • AOT 补充解释器:HybridCLR / huatuo 在 iOS 上直接解释 IL,新生成的 IEnumerator 类型可被注册进解释器,性能损耗 10% 左右。
    • 虚拟机级:ILRuntime、XLua 把热更代码跑在 虚拟机栈 上,需把 IEnumerator 翻译成 虚拟机指令序列,否则状态机跳转会崩。
    • Lua 层:ToLua、xLua 把协程映射到 Lua 协程,通过 coroutine.yield / resume 与 Unity 的 MonoBehaviour.StartCoroutine 桥接。
  3. 关键限制

    • iOS 禁止 动态生成类型System.Reflection.Emit),因此 不能 在运行时 new TypeBuilder 造出新的 IEnumerator。
    • 状态机字段名由编译器自动生成,不同版本可能重命名,直接序列化 state 会崩。
  4. 工程级封装

    • 统一入口:HotCoroutineRunner.Start(IEnumerator routine),内部根据平台决定 解释执行 还是 虚拟机执行
    • 补丁打包:把新增 IEnumerator 类型 提前标记 [HotFix],打包工具自动将其 IL 注入到 hotupdate.dll,同时生成 AOT 补充元数据(HybridCLR)。
    • 生命周期:热更层维护 Dictionary<int, IEnumerator> 映射,Stop 时通过 id 查表,避免 StopCoroutine(string) 的 GC 与反射开销。
  5. 调试与回滚

    • Development Build 下,解释器栈 可完整打印,HybridCLR 提供 __stack_trace__;ILRuntime 需提前注册 ILIntepreter.__DebugBreak
    • 回滚策略:补丁包带 版本哈希,启动时比对,不匹配则清空 PersistentDataPath 下缓存,防止老状态机残留。

答案

分三步落地,以 HybridCLR + Addressable 为例,国内线上验证过 500w+DAU:

  1. 预处理
    Assembly-CSharp 里定义一个 空壳占位类型

    [HotFix]  
    public sealed class HotCoroutineLogic {  
        public static IEnumerator NewDailyRewardAnimation() => null;  
    }
    

    该类型 仅用于占位,保证 AOT 编译器提前生成 类型元数据

  2. 热更脚本实现
    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 桥接。

  3. 启动与生命周期
    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

拓展思考

  1. 如果项目已用 ILRuntime,新增协程就必须把 IEnumerator 状态机 翻译成 ILRuntime 栈指令,官方提供 CoroutineAdapter不支持 yield return nested IEnumerator,需要自写 YieldInstructionCache递归展开,否则 嵌套协程会无限递归

  2. 性能敏感场景(战斗同步、大规模 Tween)可把 热更协程 预编译成 Unity 原生 AnimatorPlayableTimeline Clip,通过 Addressable 更新 Controller 文件,彻底 避开解释器损耗,但 开发成本翻倍,需权衡。

  3. 版本回滚陷阱:若热更协程里 写了本地持久化(如 PlayerPrefs.SetInt("step", state)),回滚老版本后 状态机字段对不上,必须 在补丁卸载时清空相关 key,否则 100% 复现闪退。国内大厂做法是 给每个热更协程打 semver 标签,回滚时 自动执行 OnPatchRollback() 钩子,由热更脚本自己清理。