Unity Asynchronous and Coroutine


在Unity中,异步编程主要应用于长时间运行的操作或I/O操作,例如加载场景、资源(如纹理、音频文件)、进行网络请求或其他非阻塞操作。Unity提供了几种常见的方式来实现异步操作,通常通过协程和异步编程API(如async/await)来实现

Asynchronous

Unity 从 2017 版本开始支持 async/await 异步编程方式,它是 C# 的一部分,适用于处理 耗时的异步操作,如网络请求、文件操作等。通过 async 标记方法,并在需要等待的地方使用 await,可以简化代码并使其更加可读

示例:异步加载资源(UnityWebRequest)
假设你要从网络上下载文件,可以使用async/await来实现非阻塞的异步操作:

using UnityEngine;
using UnityEngine.Networking;
using System.Threading.Task;

public class AsyncExample : MonoBehaviour
{
    async void Start()
    {
        string url = "https://example.com/resource";
        string result = await DownloadDataAsync(url);
        Debug.Log("下载完成:" + result);
    }

    // 异步下载数据
    private async Task<string> DownloadDataAsync(string url)
    {
        using (UnityWebRequest webRequest = UnityWebRequest.Get(url))
        {
            // 发送请求并等待结果
            await webRequest.SendWebRequest();

            if (webRequest.result == UnityWebRequest.Result.Success)
                return webRequest.downloadHandler.text; // 返回下载的文本内容
            else return "错误:" + webRequest.error;
        }
    }
}

在这个例子中,DownloadDataAsync使用async/await来处理异步操作。awatiwebRequest.SendWebRequest()会等待请求完成,避免阻塞主线程

异步操作的常见用途:

  • 网络请求:例如从服务器获取数据或上传数据
  • 文件操作:读取/写入大文件时避免主线程阻塞
  • 资源加载:异步加载资源(比如大型的纹理、音频文件等)

异步编程优缺点:

  • 优点:

    • 代码更简洁、易于理解
    • 支持现代C#异步模式,错误处理更加方便
    • 完全非阻塞主线程,不会影响UI和游戏的流畅性
  • 缺点:

    • 对于资源加载(如场景加载)等操作,仍然需要通过Unity自带的API来实现
    • 不适用于每一类异步操作,尤其是涉及到Unity特有的对象和接口时

Coroutine

Unity Coroutine是一种允许在多帧中分布执行代码的机制,它通常用于处理一些需要在多个帧之间等待的任务,比如延时操作、动画播放、资源加载等
协程本质上是通过一种特殊的方式执行代码,它可以在执行过程中“暂停”并在后续的帧继续执行

协程是通过StartCoroutine()来启动的。协程通常返回一个IEnumerator类型的方法

using UnityEngine;
using System.Collections;

public class CoroutineExample : MonoBehaviour
{
    void Start() => StartCoroutine(MyCoroutine());

    IEnumerator MyCoroutine()
    {
        //在这里执行某些操作
        Debug.Log("协程开始");

        // Wait 2 seconds
        yield return new WaitForSeconds(2);

        // 等待结束后继续执行
        Debug.Log("2秒后继续执行");

        // 继续执行其他操作
        yield return null; // 等待下一帧
        Debug.Log("协程执行完毕");
    }
}

在这个例子中,MyCoroutine协程将在开始时打印“协程开始”,然后等待2秒后打印“2秒后继续执行”,最后在下一帧打印“协程执行完毕”

协程可以通过yield return暂停执行,直到某个条件满足。常见的暂停类型有:

  • WaitForSeconds:等待指定的时间
  • WaitForEndOfFrame:等待当前帧渲染结束后继续执行
  • WaitForFixedUpdate:等待下一次物理更新
  • null:等到下一帧执行

协程并不是自动停止的,你需要显示地停止它
使用StopCoroutine()方法可以停止某个协程,或者通过StopAllCoroutine()停止当前对象的所有协程

StartCoroutine(MyCoroutine());
StopCoroutine(MyCoroutine());

协程通常返回一个IEnumerator,但也可以有不同的返回类型,比如WaitForSeconds或其他等待条件类型

协程的优缺点

优点:

  • 简洁性:相比于传统的Update或者使用定时器的方式,协程让代码更简洁、更易于理解
  • 灵活性:可以处理复杂的等待逻辑,比如按帧延迟、动态等待、分步执行等
  • 性能优化:协程可以有效避免不必要的多次计算或事件处理,提升游戏性能

缺点:

  • 容易受到Unity引擎主线程调度的影响
  • 错误处理不如async/await简单

注意

  • 协程是在主线程中执行的,所以它们会被游戏的主循环驱动,而不能跨线程操作数据
  • 协程一旦启动,默认会在对象生命周期内有效,如果对象被销毁,协程会自动停止
  • 如果需要频繁控制协程的暂停或停止,可能需要考虑使用更复杂的状态机或事件系统来更好的管理它们

进阶应用

协程不仅仅仅限于等待固定时间,也可以与其他逻辑结合实现复杂的功能

等待某个条件满足后继续执行

IEnumerator WaitForCondition()
{
    while (!someCondition) yield return null;

    Debug.Log("条件满足,继续执行");
}

动态修改等待时间

IEnumerator DynamicWait(float time)
{
    yield return new WaitForSeconds(time);
    Debug.Log("等待结束");
}

实现动画或缓动 可以用协程来实现逐个改变的某个值,例如实现一个平滑的动画过渡

IEnumerator LerpPosition(Vector3 targetPosition, float duration)
{
    Vector3 startPosition = transform.position;
    float timeElapsed = 0f;

    while (timeElapsed < duration)
    {
        transform.position = Vector3.Lerp(startPosition, targetPosition, timeElapsed / duration);
        timeElapsed += Time.deltaTime;
        yield return null;
    }

    transform.position = targetPosition;
}

协程的底层机制

在Unity中,协程的执行是通过IEnumerator类型的函数来定义的,协程的调用、暂停、恢复都与Unity的主线程紧密结合
协程不是传统意义上的线程,而是通过Unity引擎内部的协程调度系统来管理的

通过StartCoroutine()方法启动,这个方法接收一个IEnumerator类型的函数,或者是一个字符串(表示方法名)

StartCoroutine(MyCoroutine());

StartCoroutine("MyCoroutine");

当调用StartCoroutine()时,Unity会为该协程分配一个任务,并把它加入到协程调度队列中
之后Unity的主循环会负责在每一帧执行协程的代码

协程本质上时被Unity的引擎框架所调度的,协程代码并不会一次性执行完,而是会按需执行
调度流程
1.挂起状态:当协程执行到yield return语句时,Unity会暂停协程的执行,并把协程的执行状态保存下来(即当前的执行位置和上下文)
2.等待状态:协程会等待指定的时间、条件、或事件。在等待期间,协程的执行被挂起
3.恢复执行:当协程等待的条件满足,Unity会再次将协程的执行任务加入到下一帧的调度队列中,并从挂起点继续执行

协程调度的底层实现机制

大致底层实现:

  • 每个协程有一个状态机,包含当前的执行位置、等待的条件等信息
  • Unity会管理所有的协程的队列,在每帧中,根据协程的状态和等待条件,决定哪些协程应该继续执行,哪些需要暂停或恢复
  • Unity引擎通过MonoBehaviour类的Update()函数来调度协程,保证协程的状态更新和执行是与游戏主线程同步的
  • 每次协程的恢复操作本质上是在下一帧的Update()LateUpdate()中继续执行。协程的执行是由Unity内部的协程管理系统控制的

协程的性能与限制

  • 协程过多会影响性能:如果你创建了大量的协程,并且每个协程的执行时间都比较长,可能会导致性能下降。建议根据时间需求合理使用协程
  • 协程不能跨线程:协程只能在主线程上允许,它们并不会生成新的线程,因此不能在协程中执行线程相关的任务
  • 协程与对象生命周期:协程与对象的生命周期紧密关联,当独享被销毁时,所有挂载该对象上的协程都会自动停止

Unity中协程的状态是如何被保存的

  1. Coroutine对象 协程的状态是通过Coroutine对象来管理的,每个协程在运行时都会创建一个Coroutine对象,Unity会使用这个对象来跟踪协程执行状态

  2. IEnumerator状态机 协程通常返回一个IEnumerator对象,这实际上就是一个状态机的实现。在协程函数中,你可以通过yield return语句控制协程的执行。当协程遇到yield语句时,Unity会保存当前的执行上下文(如执行位置、局部变量等),并在下一帧继续从这个位置开始执行

  3. 内存和堆栈 在协程执行过程中,Unity会使用内存中的堆栈来保存函数调用的上下文,每次协程被挂起时,它的局部变量、执行位置等信息会被保存在堆栈中,当协程恢复时,这些信息会被取出,协程继续执行

  4. 协程调度器 Unity会管理一个协程调度器,它负责跟踪所有活动的协程,并在每一帧更新它们。协程调度器会检查每个协程的状态,如果协程已经完成,它就会被销毁

  5. 状态保存与恢复 Unity通过以下方式来保存和恢复协程状态:

  • yield条件:yield return语句时协程的暂停点,Unity会记录当前的暂停点(如等待的时间、是否等待某个事件等)
  • 协程生命周期:协程的生命周期和GameObject、MonoBehaviour的生命周期有关,只有当对象被销毁或协程被停止时,协程才会完全退出
  • 暂停/恢复机制:Unity的协程机制基于状态机模型,每次yield返回后,Unity会根据当前的yield值来决定合适恢复协程的执行

结束和清理

当协程结束时,Unity会清理相关的资源并移除协程。若协程被手动停止,Unity会在下一帧停止协程的执行,并释放相关资源

IEnumerator接口

IEnumerator是C#中的一个接口,它广泛用于实现迭代器模式,通过IEnumerator,我们可以控制集合的遍历、生成懒加载的数据、以及其他的一些需要“暂停”和“恢复”的操作,比如Unity中的协程

IEnumerator接口概述

IEnumerator是C#中用来实现迭代器模式的接口,它定义了两种方法和一个属性:

  • MoveNext():移动到集合中的下一个元素,返回truefalse表示是否还有更多元素可供迭代
  • Current:返回当前元素
  • Reset():将迭代器重置到初始状态(不常用,部分实现会抛出异常)
public interface IEnumerator
{
    bool MoveNect();
    object Current { get; }
    void Reset();
}

IEnumerator在迭代器中的使用

IEnumerator主要用于构建“迭代器”,用于按顺序遍历集合中的元素。一个典型的实现例子是一个自定义集合类,它提供了一个迭代器来遍历集合中的元素:

public class MyCollection : IEnumerable
{
    private int[] numbers = {1, 2, 3, 4, 5};
    
    public IEnumerator GetEnumerator() => return new MyEnumerator(numbers);

    private class MyEnumerator : IEnumerator
    {
        private int[] _numbers;
        private int _index = -1;

        public MyEnumerator(int[] numbers) => _numbers = numbers;

        public bool MoveNext()
        {
            _index++;
            return _index < _numbers.Length;
        }

        public object Current => _numbers[_index];

        public void Reset() => _index = -1;
    }
}

在这个例子中,MyCollection实现了IEnumerable接口,这样它就可以被foreach遍历
GetEnumerator方法返回一个IEnumerator实例,负责控制迭代过程

yield returnIEnumerator

在C#中,yield return是实现IEnumerator接口的一种简化方式,当你使用yield return时,编译器会自动生成一个迭代器类,并且每次yield作为一个暂停点来保存当前的状态
示例:使用yield return返回值

public class MyCollection
{
    public IEnumerable<int> GetNumbers()
    {
        yield return 1;
        yield return 2;
        yield return 3;
    }
}

GetNumbers返回了一个IEnumerable<int>类型的对象,这意味着可以通过foreach来遍历这个集合。
每次迭代时,yield return会暂停方法的执行,并将当前值返回给调用者,这个过程在每次调用MoveNext时自动恢复

IEnumerator与Unity协程

Unity中的协程是基于IEnumerator实现的,它是延迟执行的核心。协程的执行通过yield return来暂停,Unity引擎管理协程的调度和恢复

yield return与状态机

在使用yield return时,C#编译器会将方法转化为状态机,每次遇到yield return时,编译器会保存当前方法的执行状态(局部变量、执行信息等),并返回一个IEnumerator迭代器,这个迭代器控制着何时继续执行代码,何时返回控制权

状态机生成示例 假设有协程:

IEnumerator ExampleCoroutine()
{
    Debug.Log("Step 1");
    yield return new WairForSeconds(1f);
    Debug.Log("Step 2");
}

编译器会将这个代码转化成类似以下的状态机:

private class ExampleCoroutineStateMachine : IEnumerator
{
    private int _state = 0;
    private bool _isPaused = false;

    public bool MoveNext()
    {
        switch (_state)
        {
            case 0:
                Debug.Log("Step 1");
                _state = 1;
                return true; // 暂停,等待外部恢复
            case 1:
                if (_isPaused)
                {
                    _isPaused = false; // 模拟等待1秒
                    Debug.Log("Step 2");
                    return false; // 完成
                }
                return true; // 继续执行
            default:
                return false; // 协程结束
        }
    }
    public object Current => null;
}

每次协程执行时,状态机会检查当前状态,并决定是否继续执行或暂停

状态保存与恢复:IEnumerator和Unity协程

当Unity执行协程时,每次遇到yield return,它会将协程的执行状态(包括局部变量、执行栈、当前指令位置等)保存到内存中。
Unity引擎管理一个协程调度器,这个调度器负责在每一帧检查协程的状态,并恢复或继续执行这些协程
yield表达式在背后通过IEnumerator实现,并通过Unity的调度器继续执行

自定义IEumerator

可以自定义自己的IEnumerator来控制更复杂的行为

public class WaitForCondition : CustomYieldInstruction
{
    private Func<bool> _condition;

    public WaitForCondition(Func<bool> condition) => _condition = condition;

    public override bool keepWaiting => !_condition(); // 直到条件满足才结束
}