Skip to content

从协程开始

协程是什么?

csharp-unity-coroutine-concept

标准答案

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 调度器在下一帧或等待条件满足后恢复执行;它不是线程,所以不能用它来做真正的并行计算。

它是不是线程?

csharp-unity-coroutine-not-thread

标准答案

不是,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 时,才能把压力分摊到多帧。 如果是真正要并行计算,要用 ThreadTask 或 Unity Job System,然后把结果回到主线程应用。

yield return null 做什么?

csharp-unity-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 之间写了很重的计算,仍然会卡主线程。 所以它适合“分帧切片”,不适合真正的后台并行计算。

WaitForSecondstimeScale 影响吗?

csharp-unity-waitforseconds-timescale

标准答案

受影响。

WaitForSeconds 等的是缩放后的游戏时间 scaled time,所以它会受 Time.timeScale 影响。

比如:

Time.timeScale = 1WaitForSeconds(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 文案、真实倒计时,要用 WaitForSecondsRealtimeTime.unscaledDeltaTime

物体禁用后协程如何?

csharp-unity-coroutine-disable-behavior

标准答案

要分两种情况:

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 主要影响的是 UpdateFixedUpdateLateUpdate 这类生命周期回调,不等于自动停止已经启动的协程。

Unity 工程坑点

对象池里经常 SetActive(false) 回收对象,如果这个对象身上有攻击延迟、特效延迟、AI 协程,它们会被停掉。下次取出来时要重新初始化并重新启动。

UI 窗口也是一样,关闭窗口如果是 SetActive(false),窗口里的动画协程、倒计时协程可能直接停掉。再次打开时不要指望它自动接着上次跑。

面试一句话

我会这样答:禁用 GameObject 会停止它上面的协程,恢复激活后不会自动续跑;禁用脚本组件 enabled=false 通常不会停止已启动协程,所以工程里最好在 OnDisable 里主动管理协程生命周期。

协程异常如何处理?

csharp-unity-coroutine-exception-handling

标准答案

协程里的异常如果不处理,Unity 通常会在控制台打印异常,然后停止当前这个协程。 它不会像普通同步函数那样被 StartCoroutine 外面的 try/catch 稳定捕获,因为协程不是一次执行完,而是 Unity 后续不断调用 MoveNext()

底层原理

StartCoroutine() 只是把 IEnumerator 注册给 Unity。 真正执行发生在后续每一帧或等待条件满足后:

c
Unity 调度 -> MoveNext() -> 执行业务代码 -> 遇到异常

如果 MoveNext() 里抛异常,当前协程通常就中断,后面的 yield 和逻辑不会继续。

常见坑

不能简单这样写:

c
try { yield return xxx; } catch { }

因为 C# 迭代器里,yield return 不能直接放在带 catchtry 块中。工程里一般用两种方式:

局部捕获容易失败的业务代码。 写一个 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 怎么选?

csharp-unity-coroutine-vs-async-await-choice

标准答案

协程和 async/await 的选择核心是:协程适合 Unity 帧驱动逻辑,async/await 适合等待异步结果。

在 Unity 项目里,我一般这样选:

  1. 和帧、时间、动画、场景对象生命周期有关:优先用协程。 例如:等待 1 秒、下一帧执行、分帧创建对象、技能前摇后摇、Loading 进度条。
  2. 和网络、文件、SDK、数据库、Task 结果有关:优先用 async/await。 例如:登录请求、下载配置、读写本地文件、等待多个异步任务完成。
  3. 真正耗 CPU 的计算:协程和 async/await 本身都不是“性能魔法”。 协程只是分帧,不是多线程;async/await 如果没有 Task.Run 或底层异步 IO,也不等于开线程。大量寻路、解压、烘焙数据这类任务,要么分帧,要么放 Job System / 线程池处理,最后回主线程改 Unity 对象。

底层原理

协程本质是 C# 的 IEnumerator 状态机,Unity 每帧帮你调用一次 MoveNext()。所以它很适合“等下一帧”“等几秒”“等某个 Unity 异步操作完成”。

async/await 本质也是编译器生成的状态机。遇到 await 时,当前方法先返回,等任务完成后再继续执行。它更适合表达“等一个结果回来后继续往下走”。

但要注意:Unity 大多数 API 只能在主线程调用。所以即使你用了 async/await,后台线程里也不能直接改 TransformGameObjectUI

代码例子

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;重计算要分帧或放后台,最后回主线程应用结果。

协程能做大量计算吗?

csharp-unity-coroutine-heavy-computation

标准答案

协程能写大量计算,但不适合在协程里“一口气算完”。因为 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。后台线程只能处理纯数据,最后要回到主线程再改 TransformGameObjectUI

面试加分点

我会重点说一句:协程解决的是“把任务拆到多帧执行”,不是“把任务放到另一个线程执行”。优化目标是控制单帧耗时,比如 60 FPS 下每帧预算大约是 16.67ms,超过太多就会掉帧。

协程如何取消?

csharp-unity-coroutine-cancel

标准答案

Unity 协程取消主要有 4 种方式:

  1. 保存 Coroutine 句柄,然后 StopCoroutine(handle)
  2. 协程内部用 bool 标记,自己 yield break 退出
  3. StopAllCoroutines() 停止当前脚本启动的所有协程
  4. 对象生命周期自动停止,比如 DestroyGameObject.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 只是没人继续等结果了,请求本身可能还在跑,需要额外 AbortRelease 或做回调保护。

你项目里哪里用了协程?

csharp-unity-project-coroutine-usage

标准答案

我项目里主要在 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 回调残留这些问题。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0