Appearance
从协程开始
协程是什么?
标准答案
Unity 协程是:一段可以执行到一半暂停,过一段时间或下一帧再继续执行的逻辑。
它最常用来写延迟、分帧、流程控制,比如:
等 1 秒后播放特效。 一帧加载一部分资源。 按步骤执行新手引导。 等待动画播完再进入下一步。
底层原理
Unity 协程的本质不是线程,而是:
IEnumerator 状态机 + Unity 协程调度器你写的 yield return 方法,C# 编译器会把它变成一个状态机对象。 Unity 调用 StartCoroutine 后,会保存这个 IEnumerator,然后在合适的时候反复调用它的 MoveNext()。
执行到 yield return xxx 时,协程暂停,把等待条件交给 Unity。 等条件满足后,Unity 再继续调用 MoveNext(),从上次暂停的位置往下执行。
协程不是线程
这是面试最重要的一句:
协程默认仍然运行在 Unity 主线程上,不是多线程。
所以协程不会自动把耗时计算放到后台,也不能解决 CPU 密集任务卡顿。 如果你在协程里写一个很大的 for 循环,并且中间没有 yield,它一样会卡主线程。
代码例子
c
using System.Collections; // 引入 IEnumerator,用来写 Unity 协程
using UnityEngine; // 引入 MonoBehaviour、WaitForSeconds 等 Unity 类型
public class CoroutineDemo : MonoBehaviour // 定义一个 Unity 脚本类
{ // 类开始
private void Start() // Start 会在脚本第一次启用后调用
{ // Start 方法开始
StartCoroutine(PlayFlow()); // 启动协程,让 Unity 接管这个 IEnumerator
} // Start 方法结束
private IEnumerator PlayFlow() // 定义一个协程方法,返回 IEnumerator
{ // 协程方法开始
Debug.Log("第一步:开始流程"); // 输出第一步日志
yield return null; // 暂停当前协程,通常下一帧继续执行
Debug.Log("第二步:下一帧继续"); // 下一帧恢复后输出日志
yield return new WaitForSeconds(1f); // 暂停 1 秒,受 Time.timeScale 影响
Debug.Log("第三步:1 秒后继续"); // 等待结束后继续执行
yield break; // 主动结束协程
} // 协程方法结束
} // 类结束常见 yield 含义
yield return null:暂停到下一帧继续。 yield return new WaitForSeconds(1f):等待游戏时间 1 秒,受 Time.timeScale 影响。 yield return new WaitForSecondsRealtime(1f):等待真实时间 1 秒,不受暂停影响。 yield break:结束协程。
适合和不适合
适合:延迟执行、分帧处理、流程脚本、等待动画或异步操作。 不适合:大量 CPU 计算、阻塞 IO、真正并行任务。
面试一句话
我会这样答:协程是 Unity 主线程上的分帧状态机,靠 yield 暂停,靠 Unity 调度器在下一帧或等待条件满足后恢复执行;它不是线程,所以不能用它来做真正的并行计算。
它是不是线程?
标准答案
不是,Unity 协程不是线程。
协程默认仍然运行在 Unity 主线程 上。它只是把一段逻辑拆成多段,在不同帧继续执行,看起来像“同时做很多事”,但本质上不是并行。
底层原理
协程本质是:
IEnumerator 状态机 + yield 暂停点 + Unity 调度器当执行到:
yield return null;协程会暂停,把执行权交还给 Unity。下一帧 Unity 再调用它的 MoveNext(),从上次暂停的位置继续执行。
所以协程不是“开了一个后台线程”,而是“主线程上分帧执行”。
为什么会误会
因为你可以同时启动多个协程:
协程 A 等 1 秒
协程 B 等 2 秒
协程 C 下一帧继续它们看起来像一起运行,但实际上 Unity 每帧按调度规则推进它们。它们不是多个 CPU 线程并行跑。
重要区别
协程:
运行在主线程。 可以安全访问大多数 Unity API。 适合等待、延迟、流程控制、分帧处理。 不能自动利用多核 CPU。
线程:
是真正的并行执行流。 可以在后台做计算、IO、解析数据。 不能直接操作大多数 Unity API。 通常要把结果切回主线程再应用。
代码例子
c
using System.Collections; // 引入 IEnumerator,用来写协程
using UnityEngine; // 引入 Unity 的 MonoBehaviour 和 Debug
public class CoroutineThreadDemo : MonoBehaviour // 定义一个 Unity 脚本类
{ // 类开始
private void Start() // Start 在脚本启用后调用
{ // Start 方法开始
StartCoroutine(TestCoroutine()); // 启动协程,但不会创建新线程
} // Start 方法结束
private IEnumerator TestCoroutine() // 定义一个协程方法
{ // 协程方法开始
Debug.Log("协程开始,仍然在主线程"); // 输出日志,说明协程在主线程执行
yield return null; // 暂停到下一帧继续执行
Debug.Log("下一帧继续,还是主线程"); // 下一帧恢复后继续执行
} // 协程方法结束
} // 类结束面试加分回答
如果面试官问“协程能不能解决卡顿”,我会说:
不能直接解决。 如果卡顿来自一个很长的计算任务,协程只有在你主动把任务切成多段并频繁 yield 时,才能把压力分摊到多帧。 如果是真正要并行计算,要用 Thread、Task 或 Unity Job System,然后把结果回到主线程应用。
yield return null 做什么?
标准答案
yield return null 的意思是:
当前协程先暂停,本帧不再继续往下执行,通常等到下一帧再从这一行后面继续执行。
它不是 sleep,不会阻塞主线程,也不会开新线程。
底层原理
协程本质是 IEnumerator 状态机。 执行到 yield return null 时,本次 MoveNext() 返回,Unity 记录当前协程状态。 下一帧 Unity 再次调度这个协程,继续调用 MoveNext(),从 yield return null 后面的代码继续执行。
代码例子
c
using System.Collections; // 引入 IEnumerator,用来定义协程
using UnityEngine; // 引入 MonoBehaviour 和 Debug
public class YieldNullDemo : MonoBehaviour // 定义一个 Unity 脚本类
{ // 类开始
private void Start() // Start 在脚本第一次启用后调用
{ // Start 方法开始
StartCoroutine(Test()); // 启动协程
} // Start 方法结束
private IEnumerator Test() // 定义一个协程方法
{ // 协程方法开始
Debug.Log("第 1 帧:执行到 yield 前"); // 当前帧先执行这一行
yield return null; // 当前协程暂停,通常下一帧再继续
Debug.Log("第 2 帧:从 yield 后继续"); // 下一帧恢复后执行这一行
} // 协程方法结束
} // 类结束常见用途
最常见是分帧处理:
比如你要生成 1000 个对象,如果一帧全生成可能卡顿,就可以每生成一批 yield return null,把压力分散到多帧。
和 WaitForSeconds 区别
yield return null:通常下一帧继续。 yield return new WaitForSeconds(1f):等待游戏时间 1 秒后继续,受 Time.timeScale 影响。 yield return new WaitForSecondsRealtime(1f):等待真实时间 1 秒后继续,不受 Time.timeScale 影响。
面试加分点
yield return null 只是把任务拆到下一帧,它不会减少总计算量。 如果你在两次 yield 之间写了很重的计算,仍然会卡主线程。 所以它适合“分帧切片”,不适合真正的后台并行计算。
WaitForSeconds 受 timeScale 影响吗?
标准答案
受影响。
WaitForSeconds 等的是缩放后的游戏时间 scaled time,所以它会受 Time.timeScale 影响。
比如:
Time.timeScale = 1:WaitForSeconds(2f) 大约真实 2 秒后继续。 Time.timeScale = 0.5:游戏时间变慢,WaitForSeconds(2f) 大约真实 4 秒后继续。 Time.timeScale = 0:游戏时间暂停,WaitForSeconds 通常不会继续倒计时,直到 timeScale 恢复。
和 WaitForSecondsRealtime 区别
WaitForSeconds:受 Time.timeScale 影响,适合技能前摇、攻击间隔、游戏内倒计时。 WaitForSecondsRealtime:不受 Time.timeScale 影响,适合暂停菜单 UI 倒计时、网络超时、真实时间等待。
代码例子
c
using System.Collections; // 引入 IEnumerator,用来写协程
using UnityEngine; // 引入 Unity 的 MonoBehaviour、Time、WaitForSeconds
public class WaitTimeDemo : MonoBehaviour // 定义一个 Unity 脚本类
{ // 类开始
private void Start() // Start 在脚本启用后调用
{ // Start 方法开始
StartCoroutine(TestWait()); // 启动测试协程
} // Start 方法结束
private IEnumerator TestWait() // 定义一个协程方法
{ // 协程方法开始
Time.timeScale = 0.5f; // 把游戏时间调成 0.5 倍速
Debug.Log("开始等待 WaitForSeconds"); // 输出开始等待日志
yield return new WaitForSeconds(2f); // 等待 2 秒游戏时间,真实时间大约需要 4 秒
Debug.Log("WaitForSeconds 结束"); // 等待结束后输出日志
yield return new WaitForSecondsRealtime(2f); // 等待 2 秒真实时间,不受 timeScale 影响
Debug.Log("WaitForSecondsRealtime 结束"); // 真实时间等待结束后输出日志
Time.timeScale = 1f; // 恢复正常游戏速度
} // 协程方法结束
} // 类结束面试加分点
WaitForSeconds 到时间后,也不是在任意毫秒级立刻恢复,而是等 Unity 下一次调度协程时继续,所以可能晚到下一帧。 如果做暂停菜单动画、Loading 文案、真实倒计时,要用 WaitForSecondsRealtime 或 Time.unscaledDeltaTime。
物体禁用后协程如何?
标准答案
要分两种情况:
gameObject.SetActive(false):会停止这个 GameObject 上的协程。 this.enabled = false:只是禁用这个脚本组件,Update 等回调停了,但已经启动的协程通常还会继续执行。
这是 Unity 面试里很容易混的点。
重点结论
禁用整个 GameObject:
SetActive(false) -> 协程停止
SetActive(true) -> 不会自动从原来的 yield 位置恢复只禁用 MonoBehaviour 组件:
enabled = false -> Update 不再执行
但已经 StartCoroutine 的协程通常还会继续跑代码例子
c
using System.Collections; // 引入 IEnumerator,用来写协程
using UnityEngine; // 引入 Unity 的 MonoBehaviour 和 Debug
public class CoroutineDisableDemo : MonoBehaviour // 定义一个 Unity 脚本类
{ // 类开始
private Coroutine _runningCoroutine; // 保存协程句柄,方便手动停止
private void OnEnable() // 物体或组件启用时调用
{ // OnEnable 方法开始
_runningCoroutine = StartCoroutine(PrintLoop()); // 启动协程,并保存句柄
} // OnEnable 方法结束
private void OnDisable() // 物体或组件禁用时调用
{ // OnDisable 方法开始
if (_runningCoroutine != null) // 判断协程句柄是否存在
{ // if 开始
StopCoroutine(_runningCoroutine); // 主动停止当前组件启动的协程
_runningCoroutine = null; // 清空句柄,避免重复停止
} // if 结束
} // OnDisable 方法结束
private IEnumerator PrintLoop() // 定义循环打印协程
{ // 协程方法开始
while (true) // 一直循环
{ // while 开始
Debug.Log("Coroutine Running"); // 输出协程正在运行
yield return null; // 暂停到下一帧继续
} // while 结束
} // 协程方法结束
} // 类结束底层理解
协程是由启动它的 MonoBehaviour 管理的。 当 GameObject 失活时,这个对象上的协程会被 Unity 停掉。 但 enabled = false 主要影响的是 Update、FixedUpdate、LateUpdate 这类生命周期回调,不等于自动停止已经启动的协程。
Unity 工程坑点
对象池里经常 SetActive(false) 回收对象,如果这个对象身上有攻击延迟、特效延迟、AI 协程,它们会被停掉。下次取出来时要重新初始化并重新启动。
UI 窗口也是一样,关闭窗口如果是 SetActive(false),窗口里的动画协程、倒计时协程可能直接停掉。再次打开时不要指望它自动接着上次跑。
面试一句话
我会这样答:禁用 GameObject 会停止它上面的协程,恢复激活后不会自动续跑;禁用脚本组件 enabled=false 通常不会停止已启动协程,所以工程里最好在 OnDisable 里主动管理协程生命周期。
协程异常如何处理?
标准答案
协程里的异常如果不处理,Unity 通常会在控制台打印异常,然后停止当前这个协程。 它不会像普通同步函数那样被 StartCoroutine 外面的 try/catch 稳定捕获,因为协程不是一次执行完,而是 Unity 后续不断调用 MoveNext()。
底层原理
StartCoroutine() 只是把 IEnumerator 注册给 Unity。 真正执行发生在后续每一帧或等待条件满足后:
c
Unity 调度 -> MoveNext() -> 执行业务代码 -> 遇到异常如果 MoveNext() 里抛异常,当前协程通常就中断,后面的 yield 和逻辑不会继续。
常见坑
不能简单这样写:
c
try { yield return xxx; } catch { }因为 C# 迭代器里,yield return 不能直接放在带 catch 的 try 块中。工程里一般用两种方式:
局部捕获容易失败的业务代码。 写一个 SafeCoroutine 包装器,统一捕获 MoveNext() 的异常。
安全协程包装器示例
c
using System; // 引入 Exception 类型
using System.Collections; // 引入 IEnumerator 类型
using UnityEngine; // 引入 MonoBehaviour 和 Debug
public class SafeCoroutineRunner : MonoBehaviour // 定义一个安全协程运行器
{ // 类开始
public Coroutine StartSafeCoroutine(IEnumerator routine, string coroutineName) // 启动一个带异常保护的协程
{ // 方法开始
return StartCoroutine(RunSafe(routine, coroutineName)); // 用包装器接管原始 IEnumerator
} // 方法结束
private IEnumerator RunSafe(IEnumerator routine, string coroutineName) // 真正执行安全包装逻辑
{ // 方法开始
while (true) // 持续推进原始协程
{ // while 开始
object current = null; // 保存当前协程 yield 出来的等待对象
bool hasNext = false; // 记录原始协程是否还有下一步
Exception error = null; // 保存 MoveNext 中捕获到的异常
try // 尝试推进原始协程
{ // try 开始
hasNext = routine.MoveNext(); // 手动调用 MoveNext,异常会在这里抛出
if (hasNext) // 如果原始协程还没有结束
{ // if 开始
current = routine.Current; // 取出本次 yield 返回的等待对象
} // if 结束
} // try 结束
catch (Exception e) // 捕获协程执行中的异常
{ // catch 开始
error = e; // 保存异常,避免在 catch 内直接 yield
} // catch 结束
if (error != null) // 如果协程执行过程中出现异常
{ // if 开始
Debug.LogError($"Coroutine failed: {coroutineName}"); // 输出协程名字,方便定位是哪条流程失败
Debug.LogException(error); // 输出完整异常堆栈
yield break; // 结束安全包装协程,避免错误流程继续跑
} // if 结束
if (!hasNext) // 如果原始协程已经自然执行完
{ // if 开始
yield break; // 结束包装协程
} // if 结束
yield return current; // 把原始协程的等待对象交还给 Unity 调度
} // while 结束
} // 方法结束
} // 类结束工程建议
关键流程,比如资源加载、登录流程、战斗结算、UI 打开流程,最好不要让异常直接把协程打断。 捕获异常后至少要做三件事:打印日志、上报信息、清理状态,比如关闭 Loading、解锁按钮、归还对象池对象。
协程和 async/await 怎么选?
标准答案
协程和 async/await 的选择核心是:协程适合 Unity 帧驱动逻辑,async/await 适合等待异步结果。
在 Unity 项目里,我一般这样选:
- 和帧、时间、动画、场景对象生命周期有关:优先用协程。 例如:等待 1 秒、下一帧执行、分帧创建对象、技能前摇后摇、Loading 进度条。
- 和网络、文件、SDK、数据库、Task 结果有关:优先用
async/await。 例如:登录请求、下载配置、读写本地文件、等待多个异步任务完成。 - 真正耗 CPU 的计算:协程和
async/await本身都不是“性能魔法”。 协程只是分帧,不是多线程;async/await如果没有Task.Run或底层异步 IO,也不等于开线程。大量寻路、解压、烘焙数据这类任务,要么分帧,要么放 Job System / 线程池处理,最后回主线程改 Unity 对象。
底层原理
协程本质是 C# 的 IEnumerator 状态机,Unity 每帧帮你调用一次 MoveNext()。所以它很适合“等下一帧”“等几秒”“等某个 Unity 异步操作完成”。
async/await 本质也是编译器生成的状态机。遇到 await 时,当前方法先返回,等任务完成后再继续执行。它更适合表达“等一个结果回来后继续往下走”。
但要注意:Unity 大多数 API 只能在主线程调用。所以即使你用了 async/await,后台线程里也不能直接改 Transform、GameObject、UI。
代码例子
c
using System.Collections; // 引入 IEnumerator 和协程相关类型。
using System.Threading; // 引入 CancellationTokenSource 和 CancellationToken。
using System.Threading.Tasks; // 引入 Task 和 async/await。
using UnityEngine; // 引入 Unity 常用 API。
public class AsyncCoroutineChoiceDemo : MonoBehaviour // 定义一个挂在 GameObject 上的示例脚本。
{ // 类开始。
private CancellationTokenSource _cts; // 保存取消令牌,用于对象销毁时取消异步任务。
private void Start() // Unity 在第一帧 Update 前调用 Start。
{ // 方法开始。
StartCoroutine(PlaySkillTimeline()); // 技能时间轴这种按帧逻辑,适合用协程。
_cts = new CancellationTokenSource(); // 创建取消源,方便后续取消 async 任务。
_ = LoadPlayerDataAsync(_cts.Token); // 网络或文件加载这种等待结果的任务,适合用 async。
} // 方法结束。
private IEnumerator PlaySkillTimeline() // 定义一个技能释放时间轴协程。
{ // 协程开始。
Debug.Log("播放前摇动画"); // 表现逻辑可以直接操作 Unity API。
yield return new WaitForSeconds(0.3f); // 等待 0.3 秒,适合协程表达。
Debug.Log("进入命中帧,开启攻击判定"); // 到达命中帧后执行判定逻辑。
yield return null; // 等待下一帧,让判定只持续一帧。
Debug.Log("关闭攻击判定,进入后摇"); // 关闭攻击盒或判定窗口。
} // 协程结束。
private async Task LoadPlayerDataAsync(CancellationToken token) // 定义一个异步加载玩家数据的方法。
{ // 方法开始。
await Task.Delay(1000, token); // 模拟等待网络或文件返回结果。
Debug.Log("数据加载完成,回到主线程后刷新 UI"); // await 后通常回到 Unity 主线程上下文。
} // 方法结束。
private void OnDestroy() // 对象销毁时 Unity 会调用 OnDestroy。
{ // 方法开始。
_cts?.Cancel(); // 取消还没完成的异步任务,避免对象销毁后继续回调。
_cts?.Dispose(); // 释放 CancellationTokenSource 内部资源。
} // 方法结束。
} // 类结束。面试加分点
协程不是线程,它不会让 CPU 密集任务自动变快;async/await 也不是一定开线程,它只是更适合组织异步流程。
我会这样总结:表现层、帧驱动、生命周期强相关用协程;网络、文件、SDK、可取消、可组合的异步任务用 async/await;重计算要分帧或放后台,最后回主线程应用结果。
协程能做大量计算吗?
标准答案
协程能写大量计算,但不适合在协程里“一口气算完”。因为 Unity 协程不是线程,它仍然运行在主线程。如果你在协程里写一个很大的 for 循环,并且中间没有 yield,那这一帧主线程会一直被占用,游戏就会卡住。
更准确地说:
协程适合做分帧计算,不适合替代多线程做真正的并行计算。
底层原理
协程本质是一个 IEnumerator 状态机。Unity 每帧调用它的 MoveNext()。
关键点是:只有执行到 yield return xxx 时,协程才会暂停,把控制权还给 Unity。
所以这段会卡:
c
for (int i = 0; i < 1000000; i++) // 一帧内执行大量循环。
{ // 循环开始。
Calculate(i); // 这里一直占用主线程。
} // 循环结束。
yield return null; // 太晚了,前面已经把这一帧卡住了。正确思路是:算一部分,yield 一下,下一帧继续算。
c
using System.Collections; // 引入 IEnumerator,用来写协程。
using System.Collections.Generic; // 引入 List<T>,用来保存待处理数据。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Mathf。
public class BatchComputeDemo : MonoBehaviour // 定义一个分帧计算示例脚本。
{ // 类开始。
[SerializeField] private int batchSize = 200; // 每帧最多处理 200 个,避免单帧过长。
private IEnumerator ProcessInBatches(List<int> data) // 定义一个分批处理数据的协程。
{ // 协程开始。
int index = 0; // 记录当前处理到第几个元素。
while (index < data.Count) // 只要还有数据没处理,就继续循环。
{ // 外层循环开始。
int end = Mathf.Min(index + batchSize, data.Count); // 计算本帧最多处理到哪里。
while (index < end) // 本帧只处理 batchSize 数量的数据。
{ // 内层循环开始。
DoHeavyCalculate(data[index]); // 处理当前数据。
index++; // 移动到下一个数据。
} // 内层循环结束。
yield return null; // 本帧处理完一批,把控制权还给 Unity。
} // 外层循环结束。
Debug.Log("分帧计算完成"); // 所有数据处理完后输出日志。
} // 协程结束。
private void DoHeavyCalculate(int value) // 定义一个模拟计算的方法。
{ // 方法开始。
int result = value * value; // 模拟一次计算。
_ = result; // 避免示例变量未使用。
} // 方法结束。
} // 类结束。Unity 工程实践
如果是创建大量怪物、初始化 UI Item、扫描地图格子、预处理少量数据,可以用协程分帧做。
如果是大量寻路、压缩解压、复杂排序、批量数学计算,更推荐用 Task、线程池、Job System 或 Burst。后台线程只能处理纯数据,最后要回到主线程再改 Transform、GameObject、UI。
面试加分点
我会重点说一句:协程解决的是“把任务拆到多帧执行”,不是“把任务放到另一个线程执行”。优化目标是控制单帧耗时,比如 60 FPS 下每帧预算大约是 16.67ms,超过太多就会掉帧。
协程如何取消?
标准答案
Unity 协程取消主要有 4 种方式:
- 保存
Coroutine句柄,然后StopCoroutine(handle) - 协程内部用 bool 标记,自己
yield break退出 StopAllCoroutines()停止当前脚本启动的所有协程- 对象生命周期自动停止,比如
Destroy或GameObject.SetActive(false)
面试里最好这样说:取消协程本质是让 Unity 不再继续调用这个 IEnumerator 的 MoveNext(),但工程上还要处理状态清理、重复取消、事件注销、特效关闭、异步请求兜底。
底层原理
协程不是线程,它是一个 IEnumerator 状态机。Unity 每帧推进一次:
MoveNext() -> 遇到 yield -> 暂停 -> 下一帧继续StopCoroutine 做的事情,就是停止 Unity 对这个协程的继续调度。它不会自动帮你回滚业务状态,比如攻击盒已经打开、特效已经播放、事件已经注册,这些要你自己清理。
推荐写法
c
using System.Collections; // 引入 IEnumerator,用来编写协程。
using UnityEngine; // 引入 UnityEngine,使用 MonoBehaviour 和 Coroutine。
public class CoroutineCancelDemo : MonoBehaviour // 定义一个协程取消示例脚本。
{ // 类开始。
private Coroutine _attackCoroutine; // 保存正在运行的攻击协程句柄。
private bool _requestCancel; // 保存是否请求协程自然退出的标记。
public void StartAttack() // 开始一次攻击流程。
{ // 方法开始。
CancelAttackImmediate(); // 先取消旧攻击,避免重复启动多个攻击协程。
_requestCancel = false; // 重置取消标记,保证新协程可以正常执行。
_attackCoroutine = StartCoroutine(AttackFlow()); // 启动协程并保存句柄。
} // 方法结束。
public void RequestCancelAttack() // 请求协程自己走到安全点后退出。
{ // 方法开始。
_requestCancel = true; // 设置取消标记,让协程内部检查后 yield break。
} // 方法结束。
public void CancelAttackImmediate() // 立即停止当前攻击协程。
{ // 方法开始。
if (_attackCoroutine == null) // 如果没有正在运行的协程。
{ // 判断开始。
return; // 直接返回,保证重复取消是安全的。
} // 判断结束。
StopCoroutine(_attackCoroutine); // 停止 Unity 对这个协程的继续调度。
_attackCoroutine = null; // 句柄置空,避免重复 Stop 同一个协程。
CleanupAttackState(); // 手动清理攻击状态,比如关闭攻击盒和特效。
} // 方法结束。
private IEnumerator AttackFlow() // 定义攻击流程协程。
{ // 协程开始。
Debug.Log("播放攻击前摇"); // 模拟播放前摇动画。
yield return new WaitForSeconds(0.3f); // 等待前摇时间。
if (_requestCancel) // 检查是否请求取消。
{ // 判断开始。
CleanupAttackState(); // 自然退出前先清理状态。
_attackCoroutine = null; // 清空协程句柄。
yield break; // 结束当前协程。
} // 判断结束。
Debug.Log("开启攻击判定"); // 模拟打开攻击碰撞盒。
yield return null; // 等待一帧,表示命中帧持续一帧。
Debug.Log("关闭攻击判定"); // 模拟关闭攻击碰撞盒。
CleanupAttackState(); // 正常结束时清理状态。
_attackCoroutine = null; // 正常结束后清空句柄。
} // 协程结束。
private void CleanupAttackState() // 清理攻击相关状态。
{ // 方法开始。
Debug.Log("关闭特效、攻击盒、事件监听"); // 这里放真正的状态回滚逻辑。
} // 方法结束。
private void OnDisable() // 脚本或对象被禁用时调用。
{ // 方法开始。
CancelAttackImmediate(); // 禁用时主动取消协程,避免残留状态。
} // 方法结束。
} // 类结束。常见坑
StopCoroutine(MyCoroutine()) 很容易错,因为这通常会创建一个新的 IEnumerator,不是正在运行的那个。更推荐保存 Coroutine 句柄:
c
Coroutine co = StartCoroutine(MyCoroutine());
StopCoroutine(co);StopAllCoroutines() 只会停止当前 MonoBehaviour 启动的协程,不会停止别的脚本里的协程。
enabled = false 不会自动停止已经启动的协程;但 GameObject.SetActive(false) 和 Destroy(gameObject) 会让相关协程停止。
还有一个面试加分点:停止协程不一定等于停止底层异步操作。比如你在协程里等 UnityWebRequest、Addressables、资源加载,StopCoroutine 只是没人继续等结果了,请求本身可能还在跑,需要额外 Abort、Release 或做回调保护。
你项目里哪里用了协程?
标准答案
我项目里主要在 4 类地方用了协程:场景异步加载、技能表现时间轴、UI 过渡流程、分帧初始化。
我不会把核心战斗状态、伤害结算、资源引用计数这些强逻辑放进协程里。协程更适合处理“等待”和“节奏”,比如等下一帧、等几秒、等异步加载进度、分批创建对象。
项目里具体怎么用
比如切换战斗场景时,我会用协程串起完整流程:
点击进入副本 -> 打开 Loading -> 异步加载场景 -> 每帧刷新进度条 -> 预加载关键资源 -> 允许场景激活 -> 关闭 Loading。
还有技能表现里,比如前摇 0.3 秒、命中帧开启攻击盒、下一帧关闭攻击盒、后摇结束清理特效,这种“按时间推进的表现流程”也适合用协程。但真正的命中结果、伤害计算,我会交给战斗逻辑层确认。
代码例子
c
using System.Collections; // 引入 IEnumerator,用来编写协程。
using UnityEngine; // 引入 Unity 常用 API。
using UnityEngine.SceneManagement; // 引入场景加载 API。
using UnityEngine.UI; // 引入 UI 组件,例如 Slider。
public class BattleSceneLoader : MonoBehaviour // 定义一个战斗场景加载器。
{ // 类开始。
[SerializeField] private Slider progressSlider; // 在 Inspector 里绑定 Loading 进度条。
private Coroutine _loadCoroutine; // 保存加载协程句柄,方便取消或防重复。
public void EnterBattle(string sceneName) // 外部调用这个方法进入战斗场景。
{ // 方法开始。
if (_loadCoroutine != null) // 如果已经有加载协程在跑。
{ // 判断开始。
return; // 直接返回,避免重复点击导致重复加载。
} // 判断结束。
_loadCoroutine = StartCoroutine(LoadBattleScene(sceneName)); // 启动加载协程并保存句柄。
} // 方法结束。
private IEnumerator LoadBattleScene(string sceneName) // 定义异步加载场景的协程。
{ // 协程开始。
progressSlider.value = 0f; // 初始化进度条为 0。
AsyncOperation op = SceneManager.LoadSceneAsync(sceneName); // 开始异步加载目标场景。
op.allowSceneActivation = false; // 先不允许场景自动激活,给预加载和过渡动画留时间。
while (op.progress < 0.9f) // Unity 场景加载进度通常会停在 0.9 等待激活。
{ // 循环开始。
progressSlider.value = op.progress / 0.9f * 0.8f; // 把场景加载阶段映射到 80% 进度。
yield return null; // 等待下一帧,继续刷新 UI。
} // 循环结束。
yield return StartCoroutine(PreloadBattleResources()); // 继续预加载战斗关键资源。
progressSlider.value = 1f; // 资源准备完成后显示 100%。
op.allowSceneActivation = true; // 允许 Unity 激活新场景。
_loadCoroutine = null; // 加载结束后清空协程句柄。
} // 协程结束。
private IEnumerator PreloadBattleResources() // 定义预加载战斗资源的协程。
{ // 协程开始。
yield return null; // 示例里等待一帧,真实项目中这里会加载角色、特效、音效等资源。
} // 协程结束。
} // 类结束。面试加分点
我会补一句:协程不是线程,它只是让流程按帧暂停和恢复。所以我用协程做流程编排,但不会用它做大量 CPU 计算。大量怪物初始化、UI Item 创建这类任务,我会协程分帧;真正重计算会考虑 Job System、线程或提前预处理。
常见坑
协程要保存句柄,关闭界面、切场景、角色死亡时要能取消。否则容易出现对象销毁后协程继续跑、攻击盒没关、Loading 回调残留这些问题。