
协程这东西刚接触Unity那会儿我踩过的坑能写满一整块白板。最开始我以为它就是个延时执行的工具后来发现它还能做异步加载、分帧处理、动画序列再后来读了一些底层实现的分析才意识到它本质上是一套基于迭代器的调度机制。而CustomYieldInstruction更是很多人听过但没用过的类——它其实是协程体系里一个非常关键的扩展点理解了它你才算真正摸到了Unity协程的门槛。这篇内容我会从协程的底层原理讲起把迭代器、状态机、调度器这三层关系拆开揉碎然后重点分析CustomYieldInstruction的设计意图和实战用法。适合已经用过协程但对其原理一知半解的开发者也适合想自己写自定义等待逻辑的中级选手。读完你至少能搞清楚三件事协程为什么能暂停和恢复、yield return背后到底发生了什么、以及怎么用CustomYieldInstruction写出比WaitUntil更优雅的等待条件。1. 协程到底是个什么东西1.1 从一次踩坑说起很早之前我写过一个需求点击按钮后物体先移动到A点停顿0.5秒再移动到B点最后播放一个特效。当时的我图省事直接在Update里写了一堆状态变量和计时器代码大概长这样private int step 0; private float timer 0f; void Update() { timer Time.deltaTime; switch (step) { case 0: transform.position Vector3.Lerp(startPos, posA, timer / moveTime); if (timer moveTime) { step 1; timer 0f; } break; case 1: if (timer 0.5f) { step 2; timer 0f; } break; // ... 后面还有一堆 } }这段代码能跑但问题是一旦步骤增加到七八个switch分支就会膨胀得没法看而且每个步骤的计时逻辑散落在各处改一个需求要动好几个地方。后来同事看了一眼说你用协程啊。改成协程之后同样的逻辑变成了这样IEnumerator Sequence() { yield return MoveTo(posA, moveTime); yield return new WaitForSeconds(0.5f); yield return MoveTo(posB, moveTime); PlayEffect(); }代码量直接砍掉一大半而且逻辑是线性的从上往下读就是执行顺序。这就是协程最直观的价值把跨帧的异步逻辑写成同步的线性代码。但用起来爽和知道为什么爽是两回事。很多人用了几年协程被问到协程的暂停是怎么实现的就答不上来。下面我把这层窗户纸捅破。1.2 迭代器协程的语法基础协程能暂停靠的是C#的迭代器Iterator机制。你写的那个返回IEnumerator的方法编译器在背后帮你生成了一个状态机类。这个类实现了IEnumerator接口核心是三个成员Current、MoveNext()、Reset()。MoveNext()是灵魂。每次调用它状态机就从上一次yield return的位置继续往下执行直到遇到下一个yield return或者方法结束。Current则保存了当前yield return出来的那个对象。你可以把迭代器想象成一本书MoveNext()就是翻到下一页Current就是当前页的内容。Unity的协程调度器做的事情就是决定什么时候去翻这一页。这里有个关键点很多人不知道迭代器方法在你调用它的那一刻并不会执行任何代码。比如你写var e MyCoroutine();此时方法体一行都没跑。只有当你第一次调用e.MoveNext()时代码才会执行到第一个yield return。这个特性是理解协程调度的前提。1.3 yield return的返回值决定了什么yield return后面跟的东西不是随便写的。它决定了协程什么时候恢复。Unity内置了几种常见的等待指令指令恢复时机典型用途null下一帧Update之后分帧执行让出一帧WaitForEndOfFrame帧渲染完成后截图、读像素WaitForFixedUpdate下一次物理更新后物理相关逻辑WaitForSeconds指定秒数后受timeScale影响定时等待WaitForSecondsRealtime指定秒数后不受timeScale影响UI动画、暂停菜单WaitUntil条件为true时等待某个状态WaitWhile条件为false时等待某个状态结束Coroutine另一个协程结束后协程嵌套AsyncOperation异步操作完成后场景加载、资源加载CustomYieldInstruction由子类自定义自定义等待逻辑这张表建议你存下来。我见过太多人只会用WaitForSeconds遇到等某个条件满足再继续的需求就不知道怎么写了其实WaitUntil一行就能搞定。1.4 调度器谁在推动协程前进Unity的协程调度发生在MonoBehaviour的生命周期里。当你调用StartCoroutine()时Unity会把这个IEnumerator包装成一个Coroutine对象注册到当前MonoBehaviour所属的调度队列中。每一帧Unity的协程调度器会遍历所有活跃的协程对每一个调用MoveNext()然后检查Current返回的对象判断这个协程是否应该在本帧继续。判断逻辑大致是这样的如果Current是null下一帧继续如果是WaitForSeconds检查内部计时器是否到期如果是WaitUntil调用它的keepWaiting属性如果返回false就继续如果是CustomYieldInstruction同样检查keepWaiting如果是Coroutine检查目标协程是否完成如果是AsyncOperation检查isDone注意最后两条WaitUntil、WaitWhile、CustomYieldInstruction、Coroutine、AsyncOperation这些本质上都是通过一个统一的接口来告诉调度器我还需不需要等。这个接口就是IEnumerator的Current返回对象所需要满足的约定。而CustomYieldInstruction正是把这个约定显式地抽象成了一个抽象类让你可以自定义等待条件。2. CustomYieldInstruction的设计意图2.1 它解决了什么问题假设你要写一个等待条件等某个动画播放到第3帧、等某个网络请求返回、等某个UI面板完全展开。用WaitUntil当然可以yield return new WaitUntil(() animator.GetCurrentAnimatorStateInfo(0).normalizedTime 0.5f);但WaitUntil有个问题它每帧都会执行一次你传进去的lambda。如果这个lambda里有复杂的计算或者频繁的GC分配性能就会受影响。而且WaitUntil的语义是条件为true时恢复如果你想要更复杂的逻辑——比如条件满足后还要再等两帧——就得嵌套或者额外写状态。CustomYieldInstruction给了你一个更底层的入口你继承它重写keepWaiting属性返回true表示继续等返回false表示恢复协程。调度器每帧会读一次这个属性就这么简单。它的定义非常精简public abstract class CustomYieldInstruction : IEnumerator { public abstract bool keepWaiting { get; } public object Current { get { return null; } } public bool MoveNext() { return keepWaiting; } public void Reset() { } }看到没它实现了IEnumeratorMoveNext()直接返回keepWaiting。也就是说调度器调用MoveNext()时实际上就是在问你还需要等吗。这个设计非常巧妙——它把是否继续等待这个决策权完全交给了你。2.2 和WaitUntil的本质区别很多人觉得CustomYieldInstruction和WaitUntil差不多其实差别在于控制粒度和状态保持。WaitUntil是一个无状态的等待器它只关心你给的条件函数当前返回什么。条件函数本身不能保存跨帧的状态除非你用闭包捕获外部变量但那很别扭。CustomYieldInstruction是一个有状态的等待器。你可以在子类里定义字段记录已经等了多少帧、条件满足了几次、是否已经触发过某个事件等等。这让它能表达更复杂的等待逻辑。举个例子我要等一个条件连续满足3帧才恢复。用WaitUntil很难优雅地写用CustomYieldInstruction就很自然public class WaitForStableCondition : CustomYieldInstruction { private Funcbool condition; private int requiredFrames; private int stableCount; public WaitForStableCondition(Funcbool condition, int requiredFrames 3) { this.condition condition; this.requiredFrames requiredFrames; this.stableCount 0; } public override bool keepWaiting { get { if (condition()) { stableCount; return stableCount requiredFrames; } stableCount 0; return true; } } }这个等待器会等条件连续满足3帧才放行能有效过滤掉条件抖动的情况。这种逻辑用WaitUntil写起来就很别扭。2.3 什么时候该用它我的经验是以下几种场景优先考虑CustomYieldInstruction等待条件需要跨帧保持状态比如计数、计时、状态机等待逻辑需要复用封装成一个可配置的类需要组合多个条件比如A满足且B满足或A满足后等2秒需要在等待过程中做副作用比如每帧更新一个进度条如果只是简单的等一个bool变trueWaitUntil就够了没必要上CustomYieldInstruction。工具要用在合适的地方过度设计也是坑。3. 手写一个CustomYieldInstruction实战3.1 需求等待一个带超时的条件我们来做一个实际中很常见的需求等待某个条件满足但如果超过指定时间还没满足就放弃等待并执行超时逻辑。这个需求用WaitUntil是做不到的因为它没有超时概念。用CustomYieldInstruction就很自然。先看完整实现using System; using UnityEngine; public class WaitForConditionOrTimeout : CustomYieldInstruction { private Funcbool condition; private float timeout; private float startTime; private bool timedOut; public bool TimedOut timedOut; public WaitForConditionOrTimeout(Funcbool condition, float timeout) { this.condition condition; this.timeout timeout; this.startTime Time.realtimeSinceStartup; this.timedOut false; } public override bool keepWaiting { get { if (condition()) { timedOut false; return false; } if (Time.realtimeSinceStartup - startTime timeout) { timedOut true; return false; } return true; } } }用法IEnumerator LoadAndWait() { var waiter new WaitForConditionOrTimeout(() SomeManager.IsReady, 5f); yield return waiter; if (waiter.TimedOut) { Debug.LogWarning(等待超时走降级逻辑); FallbackLoad(); } else { ProceedNormally(); } }这里有几个设计细节值得说第一用Time.realtimeSinceStartup而不是Time.time。因为Time.time受timeScale影响如果游戏暂停了Time.time不增长超时永远不会触发。而超时逻辑通常希望是真实时间所以用realtimeSinceStartup更合适。当然如果你的需求是游戏内时间超时那就该用Time.time看场景。第二timedOut字段在条件满足时被重置为false。这是为了防止一种边界情况如果条件在超时判断之前刚好满足timedOut可能残留上一次的值。虽然在这个简单场景里不太会出现但养成习惯总是好的。第三keepWaiting里先判断条件再判断超时。顺序很重要。如果先判断超时那么当条件刚好在超时那一刻满足时可能会被误判为超时。先判断条件能保证条件满足优先于超时。3.2 需求升级带进度回调的等待再做一个更实用的等待一个异步操作完成同时每帧回调进度。这在加载资源、下载数据时很常见。public class WaitForProgress : CustomYieldInstruction { private Funcbool isDone; private Funcfloat getProgress; private Actionfloat onProgress; private float lastReported; public WaitForProgress(Funcbool isDone, Funcfloat getProgress, Actionfloat onProgress null) { this.isDone isDone; this.getProgress getProgress; this.onProgress onProgress; this.lastReported -1f; } public override bool keepWaiting { get { if (isDone()) { onProgress?.Invoke(1f); return false; } float p getProgress(); if (Mathf.Abs(p - lastReported) 0.01f) { lastReported p; onProgress?.Invoke(p); } return true; } } }用法IEnumerator LoadLevel() { var op SceneManager.LoadSceneAsync(GameScene); op.allowSceneActivation false; yield return new WaitForProgress( () op.progress 0.9f, () op.progress / 0.9f, p progressBar.value p ); op.allowSceneActivation true; }这里有个细节onProgress只在进度变化超过0.01时才回调。为什么要做这个节流因为如果每帧都回调UI更新频率可能过高而且进度值在小数点后很多位变化时视觉上根本看不出来。节流能减少不必要的UI刷新。提示CustomYieldInstruction的keepWaiting每帧都会被调用所以里面的逻辑要尽量轻量。避免在里面做复杂的计算、字符串拼接、或者频繁的GC分配。如果确实需要重逻辑考虑加缓存或者降低检查频率。3.3 需求再升级组合等待器有时候你需要等多个条件同时满足。可以写一个组合器public class WaitForAll : CustomYieldInstruction { private CustomYieldInstruction[] waiters; public WaitForAll(params CustomYieldInstruction[] waiters) { this.waiters waiters; } public override bool keepWaiting { get { foreach (var w in waiters) { if (w.keepWaiting) return true; } return false; } } }用法yield return new WaitForAll( new WaitForConditionOrTimeout(() player.IsLoaded, 3f), new WaitForConditionOrTimeout(() ui.IsReady, 3f) );这个组合器会等所有子等待器都完成才放行。注意这里有个坑keepWaiting被调用时会依次调用每个子等待器的keepWaiting这意味着子等待器的状态会被推进。如果某个子等待器已经完成了它的keepWaiting返回false但每次调用仍然会执行它的getter逻辑。所以子等待器的keepWaiting实现要保证幂等——多次调用结果一致不能有副作用累积。我上面写的WaitForConditionOrTimeout就满足这个要求因为它的状态变化只依赖于时间和条件多次读取不会改变结果。但如果你写的等待器在keepWaiting里做了计数1这种操作放进组合器里就会出问题。4. 协程调度中的那些坑4.1 协程不是线程这是最基础但也最容易被误解的一点。协程运行在主线程上它只是把一段逻辑拆成多帧执行并没有真正的并行。所以协程里不能做耗时计算否则照样卡帧协程里访问Unity API是安全的因为主线程协程里不能用Thread.Sleep那会阻塞整个主线程我见过有人在协程里写了个大循环做数据处理然后疑惑为什么游戏卡了。协程不会帮你把计算变快它只是帮你把计算分散到多帧。如果单帧的计算量本身就大该卡还是卡。4.2 yield return null不等于等一帧严格来说yield return null是等到下一帧的Update阶段之后恢复。它的恢复时机和WaitForEndOfFrame不同。如果你在协程里做和渲染相关的事情比如截图用yield return null可能会拿到不完整的画面必须用WaitForEndOfFrame。另外yield return null在Update和LateUpdate之间的具体时机不同Unity版本可能有细微差异。如果你的逻辑对执行顺序敏感最好实测确认。4.3 协程的生命周期绑定StartCoroutine是在MonoBehaviour上调用的协程的生命周期和这个MonoBehaviour绑定。当MonoBehaviour被禁用enabled false时协程不会自动停止但当它被销毁Destroy时协程会停止。这里有个常见的坑如果你在一个会被频繁销毁的对象上启动协程协程可能在执行到一半时突然中断导致一些清理逻辑没跑。解决办法是在OnDisable或OnDestroy里手动StopAllCoroutines并确保关键清理逻辑不依赖协程的完整执行。4.4 CustomYieldInstruction的异常处理keepWaiting里如果抛异常协程会中断但异常可能不会以你期望的方式冒泡。建议在keepWaiting里做好防御性判断比如检查委托是否为null、检查对象是否已被销毁。public override bool keepWaiting { get { if (condition null) return false; try { return !condition(); } catch (Exception e) { Debug.LogError($等待条件异常: {e.Message}); return false; } } }4.5 常见问题速查表问题现象可能原因排查方向协程不执行对象未激活或组件未启用检查gameObject.activeInHierarchy和enabled协程执行到一半停了对象被销毁检查是否有Destroy调用WaitForSeconds不准确受timeScale影响改用WaitForSecondsRealtimeCustomYieldInstruction不恢复keepWaiting一直返回true打印条件值检查逻辑协程嵌套后外层不恢复内层协程被中断检查内层协程的宿主对象协程里报空引用对象在等待期间被销毁等待后加null检查5. 一些进阶玩法和经验5.1 用CustomYieldInstruction做状态机协程天然适合做状态机。你可以把每个状态写成一个协程用CustomYieldInstruction来控制状态切换条件。比如一个简单的AI行为IEnumerator AIBehavior() { while (true) { yield return new WaitUntil(() CanSeePlayer); yield return ChasePlayer(); yield return new WaitForConditionOrTimeout(() !CanSeePlayer, 3f); yield return Patrol(); } }这种写法比传统的switch(state)清晰得多而且每个状态的逻辑可以独立测试。5.2 避免在keepWaiting里分配内存keepWaiting每帧调用如果里面有new、字符串拼接、LINQ查询就会产生持续的GC压力。我见过一个项目因为在一个自定义等待器里每帧new一个数组导致GC频繁触发帧率不稳。改成缓存数组后问题消失。5.3 协程的替代方案虽然协程很好用但也不是万能的。C#的async/await配合Task在某些场景下更合适尤其是需要真正异步IO的时候。Unity从2018开始对async/await的支持也越来越好。我的建议是帧同步逻辑、动画序列、简单的延时用协程网络请求、文件IO、复杂异步流程考虑async/await需要取消、超时、组合的复杂异步async/awaitCancellationToken更成熟但协程的优势在于它和Unity的生命周期深度集成写起来直观调试也方便。两者不是替代关系而是互补。5.4 调试协程的小技巧协程的调用栈在调试时不太直观因为它是分帧执行的。我的做法是在关键节点加日志用统一的标签前缀方便过滤Debug.Log($[Coroutine:{GetType().Name}] 开始等待); yield return new WaitForConditionOrTimeout(...); Debug.Log($[Coroutine:{GetType().Name}] 等待结束);另外Unity的Profiler里可以看到协程的CPU占用如果某个协程占用异常高多半是keepWaiting里的逻辑太重了。5.5 一个容易忽略的细节yield return的执行时机yield return后面的表达式是在恢复时求值的而不是在暂停时。这意味着如果你写yield return new WaitForSeconds(GetDelayTime());GetDelayTime()会在协程恢复的那一刻被调用而不是在yield return执行的那一刻。大多数情况下这没区别但如果GetDelayTime()依赖某个会变化的状态就可能出问题。稳妥的做法是先算好再yieldfloat delay GetDelayTime(); yield return new WaitForSeconds(delay);这个细节我在实际项目里踩过一次坑一个等待时间依赖玩家等级结果玩家在等待期间升级了等待时间就变了。虽然听起来是正确的行为但和设计预期不符。5.6 CustomYieldInstruction的复用与池化如果你的等待器会被频繁创建比如每帧都有新的等待可以考虑池化。但大多数场景下等待器的创建频率并不高池化带来的复杂度可能得不偿失。我的建议是先用Profiler确认GC压力再决定是否优化。如果确实需要优化一个简单的做法是把等待器设计成可重置的public class ReusableWaiter : CustomYieldInstruction { private Funcbool condition; private bool waiting; public void Setup(Funcbool condition) { this.condition condition; this.waiting true; } public override bool keepWaiting { get { if (!waiting) return false; if (condition()) { waiting false; return false; } return true; } } }但要注意池化后的对象如果被多个协程同时引用会出问题。所以池化只适合同一时间只有一个协程使用的场景。5.7 关于keepWaiting的调用频率keepWaiting每帧被调用一次这个每帧具体是哪个阶段根据我的实测它是在协程调度阶段被调用的大致在Update之后、LateUpdate之前。如果你的等待条件依赖于LateUpdate里更新的状态可能会有一帧的延迟。这种细节在大多数场景下无所谓但在对时序敏感的逻辑里要注意。我在实际项目里总结下来协程和CustomYieldInstruction这套机制核心价值在于它把异步这件事用同步的写法表达出来了。你不需要管理状态机、不需要手动写计时器、不需要担心回调地狱。而CustomYieldInstruction则是这套机制留给开发者的扩展口让你能在不破坏协程语义的前提下定义自己的等待规则。理解了这两层你在Unity里处理时序问题的能力会上一个台阶。