浅谈Unity协程的实现原理

   概述

        Unity协程就是借助了迭代器模式实现的,所以我们要先了解该模式.

        迭代器(Iterator)模式是一种常见且非常重要的设计模式,它允许我们逐步遍历集合中的元素,而无需关心集合的内部实现细节。在C#中 , List<T> 是一个非常典型的实现迭代器模式的集合类型,理解它如何工作,能让我们更好地掌握 C# 语言中迭代器的工作原理。

        注: “迭代”泛指逐一处理一组数据的过程。这个术语更偏向概念层面,指的是通过某种方式依次访问集合中的每一个元素。C# 中的 foreach 循环就是一种典型的迭代方式,它在内部使用了枚举器。

        广义宽泛的描述整个访问过程。例如,“我们使用迭代访问集合中的每个元素。”

1. 从 List<T> 开始

        List<T> 是 .NET 中最常用的泛型集合类型之一,因为它实现了 IEnumerable<T> 接口,因此可以通过 foreach 循环或显式迭代来遍历集合中的元素。

我们来看一个简单的 List<T> 示例:

List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };



foreach (int number in numbers)

{

    Console.WriteLine(number);

}

        在这个 foreach 循环中,我们逐一遍历了 List<int> 中的每个元素,输出结果是 1 到 5。

2. IEnumerable 和 IEnumerator

        要理解 foreach 背后的工作原理,首先我们需要了解 IEnumerable 和 IEnumerator 接口。List<T> 实现了 IEnumerable<T> 接口,这意味着它是一个可枚举对象,可以生成枚举器来遍历它的元素。

        注:“枚举”更具体,指的是使用特定的机制遍历集合。在 C# 中,枚举指的是通过 IEnumerable 接口和 IEnumerator 对象来访问集合。枚举器(Enumerator)是一个实际用于访问集合的对象。

        当讨论 IEnumerable 和 IEnumerator 时,使用“枚举”来描述通过这些接口进行的集合访问。例如,“实现了 IEnumerable 接口的集合可以被枚举。”下面的内容我们主要使用枚举来描述迭代行为.

2.1 IEnumerable<T> 接口

        IEnumerable<T> 接口只有一个方法 GetEnumerator(),它返回一个 IEnumerator<T> 对象,用于实际枚举集合。

public interface IEnumerable<out T>

{

    IEnumerator<T> GetEnumerator();

}

        使用GetEnumerator方法可以获取一个枚举器,枚举的功能是依靠枚举器实现的.

        当你在 List<int> 上调用 foreach 时,实际上是在背后调用 GetEnumerator() 方法来获取一个枚举器,然后通过枚举器逐步遍历集合。

2.2 IEnumerator<T> 接口

IEnumerator<T> 是负责控制枚举过程的接口,它提供了三件关键的功能:

MoveNext():将枚举器移动到下一个元素,返回一个布尔值,表示是否还有更多的元素。

Current:获取枚举器当前所在位置的元素。

Reset():将枚举器重置到初始位置(一般不推荐手动使用)。

public interface IEnumerator<out T>

{

    bool MoveNext();

    T Current { get; }

    void Reset();

}

        IEnumerator 是 List<T> 的枚举核心。每次调用 MoveNext(),枚举器就会指向下一个元素,而 Current 属性则提供当前元素的访问。

        禅宗有一个指月的典故:想象一下天上有十个月亮,我们的手就是迭代器,我们的初始状态是没指月亮,那么我们是无法访问Current的(此时Current处于未定义状态,访问可能抛异常),调用一下MoveNext,我们就指向了第一个月亮,此时Current就是第一个月亮.接下来多次调用MoveNext,那么每次调用后都会指向下一个月亮,一个关键点到来了:我们指向了第十个月亮,我们再调用MoveNext,那么因为这是最后一个月亮了,MoveNext会返回一个false,告诉我们,指不到下一个月亮了,月亮都指完了,该放下手臂歇一会了,此时我们不能访问Current,Current处于一个未定义状态.

注意:

        泛型版本的接口继承了对应的非泛型版本,这是由于C#的发展导致的,最初C#没有泛型概念,这里不做过多的讨论,参考下表了解它们的关系.

接口名称继承关系返回类型主要方法/属性用途描述
IEnumerableIEnumeratorGetEnumerator()提供一个非泛型的枚举器,遍历集合中的元素
IEnumerable<T>继承自 IEnumerableIEnumerator<T>GetEnumerator()提供一个泛型的枚举器,遍历类型为 T 的集合元素
IEnumeratorobject(通过 CurrentMoveNext()
Reset()
Current
非泛型的枚举器接口,用于遍历集合并访问当前元素
IEnumerator<T>继承自 IEnumeratorIDisposableT(通过 CurrentMoveNext()
Reset()
Current
Dispose()
泛型的枚举器接口,提供类型安全的元素访问和资源释放功能

        我们首先要实现IEnumerable接口,那么就要实现一个类(该类实现了IEnumerable接口),为了实现IEnumerable接口的GetEnumerator方法,我们还要再实现一个类(该类实现了IEnumerator接口).         下面是一个示例

using System;
using System.Collections;
using System.Collections.Generic;

// 手动实现 IEnumerable<int> 和 IEnumerator<int>
public class NumberEnumerable : IEnumerable<int>
{
    public IEnumerator<int> GetEnumerator()
    {
        return new NumberEnumerator();
    }

    // 显式实现非泛型版本的 GetEnumerator,因为继承了对应的非泛型版本的接口
    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

public class NumberEnumerator : IEnumerator<int>
{
    private int _currentState = 0;

    // Current 返回当前的数值
    public int Current
    {
        get
        {
            switch (_currentState)
            {
                case 1:
                    return 1;
                case 2:
                    return 2;
                case 3:
                    return 3;
                default:
                    throw new InvalidOperationException();
            }
        }
    }

    // 显式实现非泛型的 Current 属性,因为继承了对应的非泛型版本的接口
    object IEnumerator.Current => Current;

    // MoveNext 控制状态机的转换
    public bool MoveNext()
    {
        if (_currentState < 3)
        {
            _currentState++;
            return true;
        }
        return false;
    }

    // Reset 将状态机重置为初始状态
    public void Reset()
    {
        _currentState = 0;
    }

    // Dispose 方法,不需要额外清理资源
    public void Dispose()
    {
    }
}

public class Program
{
    public static void Main(string[] args)
    {
        IEnumerable<int> numbers = new NumberEnumerable();
        IEnumerator<int> enumerator = numbers.GetEnumerator();

        while (enumerator.MoveNext())
        {
            Console.WriteLine(enumerator.Current);
        }
    }
}

3. yield return:简化迭代器的实现

        虽然 IEnumerator 提供了强大的枚举功能,但在实现自定义集合的枚举器时,手动编写 MoveNext() 和 Current 是相对繁琐的。C# 提供了 yield return 作为语法糖,简化了迭代器的实现。

        yield return 关键字帮助我们自动实现了 IEnumerable<T> 和 IEnumerator<T> 接口的方法,而不需要手动实现这些接口。

什么是 yield return?

        yield return 是 C# 的一个特殊关键字(或者说是语法糖),当它被执行的时候,方法的执行会被暂停,并将当前值返回给调用者。每次调用 MoveNext() 时,方法会从上次 yield return 暂停的地方继续执行,正是依赖这个机制,Unity可以灵活的暂停/恢复代码的执行,实现了协程的机制.

我们可以通过 yield return 来简化迭代器的实现。来看一个简单的例子:    

IEnumerable<int> GetNumbers()
{
    yield return 1;
    yield return 2;
    yield return 3;
    //没有显式地返回一个 IEnumerable<int> 对象,但实际上,
    //它是通过 yield return 语句隐式返回了一个可枚举对象
}

IEnumerator enumerator = GetNumbers().GetEnumerator();

while (enumerator.MoveNext())
{
    Console.WriteLine(enumerator.Current);
}

        通常情况下,如果没有 yield return,需要手动实现这些接口,比如 IEnumerable<int> 需要实现 GetEnumerator() 方法,而 IEnumerator<int> 需要实现 MoveNext()、Current 和 Reset() 等方法。这会变得很繁琐。

        

        然而,使用 yield return 时,编译器会自动生成一个状态机类,隐式实现了 IEnumerable<T> 和 IEnumerator<T>。这样一来,只需使用 yield return 语句来逐步返回数据,编译器会帮你处理枚举器的创建、状态管理和遍历逻辑。

        当你使用 yield return 时,如果直接返回IEnumerable<T>类型,那么编译器做了以下工作:

        分别生成了一个实现了 IEnumerable<int> 的类和 一个实现了IEnumerator<int> 的类。
当 GetEnumerator() 被调用时,它会返回实现了 IEnumerator<int> 的对象。每次调用 MoveNext() 时,编译器生成的状态机会跟踪迭代器的位置,逐步返回 yield return 中的值。当迭代器结束时,MoveNext() 返回 false,表示遍历完毕。
        因此,yield return 使得编写迭代器变得非常简洁和直观,你不需要关心枚举器的底层实现。yield return 自动实现了接口中的所有细节,简化了迭代器的编写过程。

using System;
using System.Collections.Generic;

public class Program
{
    public static void Main(string[] args)
    {
        var x = Test();
        x.MoveNext();
        Console.WriteLine(x.Current);
    }

    static IEnumerator<int> Test()
    {
        yield return 1;
        yield return 2;
        yield return 3;
    }
}

         当你使用 yield return 时,如果直接返回IEnumerator<T>类型,那么就只会隐式生成实现了IEnumerator<int> 的类.

        通常返回IEnumerable<T>类型会多一些,因为foreach会帮我们调用获取枚举器的方法.

        注意:没有都没有实现Reset方法,使用yield return 不会帮我们实现该方法,调用这个方法会抛出 System.NotSupportedException,但是我们可以重新获取迭代器.

下面再举一个错误例子

using System;
using System.Collections.Generic;

public class Program
{
    public static void Main(string[] args)
    {
        IEnumerable<int> x = Test();
        IEnumerator<int> y = x.GetEnumerator();
        //y.MoveNext(); 
        Console.WriteLine(y.Current);
        //这是一个错误的做法,刚获得枚举器时必须调用MoveNext
        //因为未调用之前Current处于无效状态
        //当然这个例子不会报错,因为是int类型,如果是引用类型(string除外)可能会抛异常
    }

    static IEnumerable<int> Test()
    {
        yield return 1;
        yield return 2;
        yield return 3;
    }
}
 自定义实现

        通过 yield return,我们可以轻松实现自定义集合的迭代器,而不需要手动管理 IEnumerator 的状态。以下是一个简单的自定义集合迭代器的例子:

using System;
using System.Collections.Generic;

public class CustomCollection
{
    private int[] items = { 1, 2, 3, 4, 5 };

    public IEnumerable<int> GetCustomIterator()
    {
        foreach (var item in items)
        {
            yield return item;
        }
    }
}

class Program
{
    static void Main()
    {
        CustomCollection collection = new CustomCollection();
        
        // 使用自定义迭代器逐一遍历元素
        foreach (int item in collection.GetCustomIterator())
        {
            Console.WriteLine(item);
        }
    }
}
枚举器背后的状态机

        当你在方法中使用 yield return 时,C# 编译器会自动将方法转换为一个状态机。这个状态机会保存方法的执行状态(例如局部变量、执行位置等),以便在每次调用 MoveNext() 时能够恢复执行。通过这种方式,C# 可以暂停方法的执行,并在稍后继续执行。

小结

        C# 的迭代器机制通过 IEnumerable 和 IEnumerator 接口实现,提供了一种灵活的方式来逐步遍历集合中的元素。List<T> 是 IEnumerable<T> 的典型实现,它通过迭代器模式,允许我们轻松遍历集合中的元素。

        同时,yield return 提供了一个简洁的语法糖,简化了实现。我们可以通过它来创建自定义的枚举器,而无需手动实现 IEnumerator 的所有细节。

4.Unity 协程和 C# 迭代器

       
        Unity 的协程和 C# 迭代器模式共享了相同的底层机制:状态机(state machine)。当你在 C# 中使用 yield return 时,编译器会自动将你的代码转换为一个状态机,以追踪每次 yield return 的执行位置。Unity 利用这一点来实现协程的延迟执行。

1. C# 编译器的状态机机制
        当你使用 yield return 时,C# 编译器会将方法转换成一个状态机。状态机会保存当前的执行状态和本地变量,使得每次 yield return 后程序可以暂停并在稍后恢复。例如,在迭代器模式中,每次调用 MoveNext() 时,状态机会更新为下一步的执行状态。

2. Unity 协程是如何利用 C# 状态机的
        Unity 的协程在本质上是 C# 的 IEnumerator 对象。Unity 调用 IEnumerator 的 MoveNext() 方法来推进协程的执行,而 yield return 控制着协程的暂停和恢复。

        协程是非阻塞的,这意味着当你在协程中使用 yield return 时,Unity 会在每一帧检查协程的状态并决定是否继续执行或暂停它。例如,yield return new WaitForSeconds(3) 会告诉 Unity "在3秒后恢复协程",而 Unity 会在等待期内暂时中止该协程的执行。

3. 状态机是如何工作的
通过以下两个伪代码来简化解释 Unity 协程和 C# 迭代器的状态机工作原理:

public IEnumerator MyCoroutine()
{
    Debug.Log("Step 1");
    yield return new WaitForSeconds(2);  // Step 2 after 2 seconds
    Debug.Log("Step 2");
}
编译器将这个协程方法转换成类似下面的状态机:


public class MyCoroutineStateMachine : IEnumerator
{
    int state = 0; // 保存当前的执行状态
    float waitTime = 2f; // 记录需要等待的时间
    
    public bool MoveNext()
    {
        switch (state)
        {
            case 0:
                Debug.Log("Step 1");
                state = 1;  // 更新状态为1,意味着等待开始
                return false;  // 暂停协程,直到下次调用 MoveNext()
            
            case 1:
                if (Time.time >= waitTime)  // 检查是否已经等待了2秒
                {
                    Debug.Log("Step 2");
                    state = 2;  // 结束状态
                }
                return false;
        }
        return false;
    }
}
public class MyCoroutineStateMachine : IEnumerator
{
    private int state = 0;   // 跟踪状态机的当前状态
    private float waitTime;  // 记录需要等待的时间
    private float startTime; // 记录当前的游戏时间
    
    // MoveNext 是 Unity 用来推进协程的核心方法
    public bool MoveNext()
    {
        switch (state)
        {
            case 0:
                // Step 1: 执行第一个打印语句
                Debug.Log("Starting Step 1");
                
                // 记录当前的游戏时间,并设置为等待 2 秒
                waitTime = 2f;
                startTime = Time.time;
                
                // 切换到状态 1,表示我们在等待
                state = 1;
                return true;  // 协程继续运行
            
            case 1:
                // Step 2: 检查是否等待了 2 秒
                if (Time.time >= startTime + waitTime)
                {
                    Debug.Log("Starting Step 2");
                    
                    // 切换到状态 2,准备执行下一帧操作
                    state = 2;
                }
                return true;  // 继续等待
            
            case 2:
                // Step 3: 等待一帧
                Debug.Log("Starting Step 3");
                
                // 切换到完成状态
                state = -1;
                return true;  // 表示下一帧继续执行
            
            case -1:
                // 协程完成,返回 false 以停止执行
                return false;
        }

        return false;
    }

    // Reset 是 IEnumerator 接口中的一个方法,在这里我们不实现
    public void Reset() {}

    // Current 是 IEnumerator 接口的一部分,表示当前返回值
    public object Current => null;
}


4. 如何暂停和恢复协程
        在这个状态机中,每次 MoveNext() 方法被调用时,状态机会检查当前状态并决定如何执行下一步。每当 yield return 被调用时,状态机暂停并将控制权交回给调用者(在 Unity 中,控制权交给了主游戏循环)。然后在下一帧或特定条件满足时,Unity 会再次调用 MoveNext(),恢复协程的执行。

例如:

当协程遇到 yield return null 时,它会暂停到下一帧。
遇到 yield return new WaitForSeconds(3) 时,它会暂停执行,等待 3 秒钟后再继续。
遇到 yield return new WWW(url) 时,它会等待网络请求完成后继续。
5. Unity 引擎与协程
        Unity 协程是 Unity 游戏引擎提供的一个框架,用来方便我们控制代码的异步执行和时间控制。C# 提供了基础的迭代器机制,而 Unity 利用这个机制,通过 yield return 和 IEnumerator 实现了协程的功能。实际上,Unity 协程只是 IEnumerator 的一个特殊实现,借助 C# 状态机来实现暂停和恢复。

        这就意味着,当使用 yield 来控制协程时,Unity 在背后管理这个状态机,以确保在游戏主循环的每一帧正确执行和恢复协程。

6. 执行顺序和帧更新
        Unity 的每一帧都会调用所有协程的 MoveNext() 方法,查看它们是否可以继续执行。协程通过 yield return 控制其暂停时间或条件,当条件满足时,MoveNext() 返回 true,协程继续执行;否则,协程暂停并等待下一个满足条件的时刻。

  • 30
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值