2017 字
10 分钟
[Unity] 基于迭代器的协程底层原理详解

Unity 是单线程设计的游戏引擎, 所有对于 Unity 的调用都应该在主线程执行. 倘若我们要实现另外再执行一个任务, 该怎么做呢? 答案就是协程.

协程本质上是基于 C# yield 迭代器的, 使用 yield 语法生成的返回迭代器的方法, 其内部的逻辑执行, 是 “懒” 的, 只有在调用 MoveNext 的时候, 才会继续执行下一步逻辑.


Unity 生命周期#

我们知道, Unity 在运行的时候, 本质上是有一个主循环, 不断的调用所有游戏对象的各个事件函数, 诸如 Update, LateUpdate, FixedUpdate, 以及在这个主循环中, 进行游戏主逻辑的更新. 其中协程的处理也是在这里完成的.

Unity 在每一个游戏对象中都维护一个协程的列表, 该对象启动一个协程的时候, 该协程的迭代器就会被放置到 “正在执行的协程” 列表中. Unity 每一帧都会对他们进行判断, 是否应该调用 MoveNext 方法.

又因为迭代器有 “懒执行” 的特性, 所以就能够实现, 等待某些操作结束, 然后执行下一段逻辑.

关于迭代器懒执行, 参考: [C#] 基于 yield 语句的迭代器逻辑懒执行


仿写协程#

光是口述, 肯定是无法讲明白协程原理的, 下面将使用代码简单实现一个协程.

我们游戏引擎将有以下文件:

  • GameEngine : 游戏引擎, 存储所有的游戏对象
  • GameObject : 表示一个游戏对象, 将会存储其正在运行的协程
  • GameObjectStates : 表示一个游戏对象的状态, 例如它是否已经启动, 是否被销毁
  • Coroutine : 表示一个正在运行的协程
  • WaitForSeconds : 表示一个要等待的对象, 它将使协程暂停执行指定秒数
  • Program : 游戏引擎的主循环逻辑

以及用户的逻辑:

  • MyGameObject : 用户自定义的游戏对象

首先创建一个 GameEngine 类, 它将容纳当前创建好的所有游戏对象.

public class GameEngine
{
    // 私有构造函数, 使外部无法直接被调用
    private GameEngine()
    { }

    // 单例模式
    public static GameEngine Current { get; } = new();

    // 所有的游戏对象
    internal List<GameObject> _allGameObjects = new();

    // 通过 ReadOnlyList 向外暴露所有游戏对象
    public IReadOnlyList<GameObject> AllGameObjects => _allGameObjects;
    public int FrameNumber { get; internal set; }
}

创建一个 WaitForSeconds 类, 它和 Unity 中的 WaitForSeconds 类一样, 用于在写成中通过 yield 返回实现等待指定时间.

public class WaitForSeconds
{
    public WaitForSeconds(float seconds)
    {
        Seconds = seconds;
    }

    public float Seconds { get; }
}

接下来, 创建一个 Coroutine 类, 它表示一个正在运行的协程, 构造时, 传入协程要执行的逻辑, 也就是一个 IEnumerator. 其中, 包含一个 “当前的等待对象” 以及 “当前等待对象相关联的某些参数数据”. 它的 Update 方法会在游戏主循环中不断被调用.

using System.Collections;

public class Coroutine
{
    public Coroutine(IEnumerator enumerator)
    {
        Enumerator = enumerator;
    }

    public IEnumerator Enumerator { get; }

    // 当前等待对象
    object? currentWaitable;

    // 与当前等待对象相关联的参数信息
    object? currentWaitableParameter;

    public bool IsCompleted { get; set; }

    internal void Update()
    {
        // 如果当前协程已经结束, 就不再进行任何操作
        if (IsCompleted)
            return;

        // 如果当前没有要等待的对象
        if (currentWaitable == null)
        {
            // 执行迭代器的 "MoveNext"
            if (!Enumerator.MoveNext())
            {
                // 如果迭代器返回了 false, 也就是迭代器没有下一个数据了
                // 则表示当前协程已经运行结束, 做上标记, 然后返回
                IsCompleted = true;
                return;
            }

            // 如果当前等待对象是 "等待指定秒"
            if (Enumerator.Current is WaitForSeconds waitForSeconds)
            {
                // 保存当前等待对象
                currentWaitable = waitForSeconds;

                // 将当前时间作为参数存起来
                currentWaitableParameter = DateTime.Now;
            }
            else if (Enumerator.Current is Coroutine coroutine)
            {
                // 如果当前等待对象是另一个协程
                // 保存当前等待对象
                currentWaitable = coroutine;
            }
        }
        else   // 否则, 也就是当当前等待对象不为空时
        {
            // 如果当前等待对象是 "等待指定秒"
            if (currentWaitable is WaitForSeconds waitForSeconds)
            {
                DateTime startTime = (DateTime)currentWaitableParameter!;
                
                // 判断是否等待结束
                if ((DateTime.Now - startTime).TotalSeconds >= waitForSeconds.Seconds)
                {
                    // 如果等待结束, 那么就将当前等待对象置空
                    // 这样下一次被调用 Update 时, 就会通过调用迭代器 MoveNext
                    // 执行协程的下一段逻辑, 并且获取下一个等待对象
                    currentWaitable = null;
                }
            }
            else if (currentWaitable is Coroutine coroutine)
            {
                // 如果等待对象是协程, 并且对应协程已经执行完毕
                if (coroutine.IsCompleted)
                {
                    // 将当前等待对象置空
                    currentWaitable = null;
                }
            }
        }
    }
}

编写一个 GameObjectStates 来表示一个游戏对象的状态, 例如是否启动了, 是否被销毁了什么的.

internal class GameObjectStates
{
    // 对应游戏对象
    public GameObject Target { get; }

    // 是否已经启动
    public bool Started { get; set; }

    // 是否已经被销毁
    public bool Destroyed { get; set; }

    public GameObjectStates(GameObject target)
    {
        Target = target;
    }
}

下面, 编写一个 GameObject, 因为协程是运行在游戏对象中的, 所以游戏对象会有一个容器来承载当前游戏对象正在运行的协程. 当然, 它也有 StartUpdate 两个虚方法, 会被游戏的主逻辑调用.

using System.Collections;

public class GameObject
{
    // 当前游戏对象的状态
    internal GameObjectStates States { get; }

    // 所有正在运行的协程
    List<Coroutine> coroutines = new();

    // 即将开始运行的协程
    List<Coroutine> coroutinesToAdd = new();

    // 将要被删除的协程
    List<Coroutine> coroutinesToRemove = new();

    public GameObject()
    {
        // 初始化状态
        States = new(this);

        // 将当前游戏对象添加到游戏引擎
        GameEngine.Current._allGameObjects.Add(this);
    }

    // 由游戏引擎调用的 Start 和 Update
    public virtual void Start() { }
    public virtual void Update() { }

    // 由游戏引擎调用的, 更新所有协程的逻辑
    internal void UpdateCoroutines()
    {
        // 将需要添加的所有协程添加到当前正在运行的协程中
        foreach (var coroutine in coroutinesToAdd)
        {
            coroutines.Add(coroutine);
        }

        coroutinesToAdd.Clear();

        // 更新当前所有协程
        foreach (var coroutine in coroutines)
        {
            coroutine.Update();

            // 如果当前协程已经执行完毕, 则将其添加到 "删除列表" 中
            if (coroutine.IsCompleted)
            {
                coroutinesToRemove.Add(coroutine);
            }
        }

        // 将准备删除的所有协程从当前运行的协程列表中删除
        foreach (var coroutine in coroutinesToRemove)
        {
            coroutines.Remove(coroutine);
        }

        coroutinesToRemove.Clear();
    }

    // 开启一个协程
    public Coroutine StartCoroutine(IEnumerator enumerator)
    {
        Coroutine coroutine = new(enumerator);
        coroutinesToAdd.Add(coroutine);

        return coroutine;
    }

    // 停止一个协程
    public void StopCoroutine(Coroutine coroutine)
    {
        coroutinesToRemove.Add(coroutine);
    }

    // 停止一个协程
    public void StopCoroutine(IEnumerator enumerator)
    {
        int index = coroutines.FindIndex(c => c.Enumerator == enumerator);
        if (index != -1)
            coroutinesToRemove.Add(coroutines[index]);
    }

    // 销毁当前游戏对象
    public void DestroySelf()
    {
        States.Destroyed = true;
    }
}

自定义一个游戏对象 MyGameObject, 它在 Start 时启动一个协程.

using System.Collections;

class MyGameObject : GameObject 
{
    public override void Start()
    {
        base.Start();
        StartCoroutine(MyCoroutineLogic());
    }


    IEnumerator MyCoroutineLogic()
    {
        System.Console.WriteLine("Logic out");
        yield return StartCoroutine(MyCoroutineLogicInner());
        yield return new WaitForSeconds(3);
        System.Console.WriteLine("Logic out end");
    }

    IEnumerator MyCoroutineLogicInner() 
    {
        for (int i = 0; i < 5; i++)
        {
            yield return new WaitForSeconds(1);
            Console.WriteLine($"Coroutine inner {i}");
        }
    }
}

程序主逻辑, 创建自定义的游戏对象, 并执行主循环:

// 创建自定义的游戏对象
new MyGameObject();

// 要被销毁的游戏对象
List<GameObject> objectsToDestroy = new();

while (true)
{
    // 对所有游戏对象执行 Start
    foreach (var obj in GameEngine.Current.AllGameObjects)
    {
        if (!obj.States.Started)
        {
            obj.Start();
            obj.States.Started = true;
        }
    }

    // 调用所有游戏对象的 Update
    foreach (var obj in GameEngine.Current.AllGameObjects)
    {
        if (obj.States.Destroyed)
            continue;
            
        obj.Update();
    }

    // 更新所有游戏对象的协程
    foreach (var obj in GameEngine.Current.AllGameObjects)
    {
        if (obj.States.Destroyed)
            continue;

        obj.UpdateCoroutines();
    }

    // 将需要被销毁的游戏对象存起来
    objectsToDestroy.Clear();
    foreach (var obj in GameEngine.Current.AllGameObjects)
    {
        if (obj.States.Destroyed)
            objectsToDestroy.Add(obj);
    }

    // 从游戏引擎中移出游戏对象
    foreach (var obj in objectsToDestroy)
        GameEngine.Current._allGameObjects.Remove(obj);
}

执行结果:

Logic out
Coroutine inner 0
Coroutine inner 1
Coroutine inner 2
Coroutine inner 3
Coroutine inner 4
Logic out end

总结#

综上所述, 可以了解到, Unity 协程的本质无非就是在合适的实际执行迭代器的 MoveNext 方法. 对当前正在等待的对象进行条件判断, 如果满足条件, 则 MoveNext, 否则就不执行.

[Unity] 基于迭代器的协程底层原理详解
https://slimenull.com/posts/20231213153716/
作者
SlimeNull
发布于
2023-12-13
许可协议
CC BY-NC-SA 4.0