游戏人生
About Me
  • 你好
  • Math
    • Number
      • Float IEEE754对确定性的影响
      • Pairing Function及其用途
    • Vector and Matrix
      • TRS基础概念
      • LossyScale深入分析
    • Quatenion
      • FromToRotation实现细节
    • Lerp and Curve
      • Slerp球形插值
      • Bezier Curve为什么重要
      • Interpolation和Extrapolation实现细节
  • Programming
    • C#
      • 学习资料
      • C# struct灵魂拷问
      • CIL的世界:call和callvirt
      • .NET装箱拆箱机制
      • .NET垃圾回收机制
    • Go
      • 基础特性
      • 如何正确的判空interface
      • 如何用interface模拟多态
      • 如何定制json序列化
      • 如何安全在循环中删除元素
      • 如何安全关闭channel
      • 如何集成c++库(cgo+swig)
      • 如何性能测试(benchmark, pprof)
    • Lua
      • 基础特性
  • General Game Development
    • Game Engine
      • 学习资料
      • 关于游戏引擎的认知
    • Networking
      • 帧同步
      • 状态同步
      • 物理同步
    • Physics
      • PhysX基本概念
      • PhysX增加Scale支持
      • PhysX场景查询
      • PhysX碰撞检测
      • PhysX刚体动力学
      • PhysX角色控制器
      • PhysX接入项目工程
      • 物理同步
      • 物理破坏
    • Design Pattern
      • 常用设计模式
      • MVP 架构模式
      • ECS 架构模式
  • Unity
    • Runtime
      • Unity拥抱CoreCLR
      • 浅析Mono内存管理
    • UGUI
      • 浅析UGUI渲染机制
      • 浅析UGUI文本优化
      • 介绍若干UGUI实用技巧
    • Resource Management
      • 浅析Unity堆内存的分类和管理方式
      • 深入Unity资源
      • 深入Unity序列化
      • 深入Assetbundle机制
    • Async
      • 深入Unity协程
      • 介绍若干Unity协程实用技巧
      • 异步动作队列
    • Hot Reload
      • Unity+Xlua
      • Xlua Examples学习(一)
      • Xlua Examples学习(二)
    • Editor Extension
    • Performance
      • 浅析Unity Profiler
      • 介绍一个Overdraw分析工具
  • Platform
    • WebGL
  • Real-world Project
    • Souce Engine
    • DOOM3 BFG
Powered by GitBook
On this page
  • 开胃菜:从IEnumerator/IEnumerable 到Yield
  • 主菜:Unity协程的实现
  • 甜点: 带返回值的协程
  1. Unity
  2. Async

深入Unity协程

PreviousAsyncNext介绍若干Unity协程实用技巧

Last updated 2 years ago



开胃菜:从IEnumerator/IEnumerable 到Yield

c#语言中,迭代器这个特性大家不会陌生,最常见的莫过于foreach了。foreach能够对一个实现了IEnumerable接口的对象dataSource进行遍历访问其中的元素。

foreach (var item in dataSource)
{
    Console.WriteLine(item.ToString());
}

foreach的遍历过程可以拆解为:

IEnumerator iterator = dataSource.GetEnumerator(); 
while (iterator.MoveNext()) 
{ 
    Console.WriteLine(iterator.ToString());
}

细心的读者会发现,为什么迭代器要涉及IEnumerable 和 IEnumerator两个接口而不是直接在dataSource中实现MoveNext和Current?

这正是迭代器模式的要点。这个模式将存储数据和遍历数据的职责分离,在c#中对应为IEnumerable 和 IEnumerator两个接口(其实还有两个泛型接口:IEnumerable<T>, IEnumerator<T>,不加 T 的话则默认为IEnumerator<object>)。本文对该模式不做展开讨论。

如何利用IEnumerable 和 IEnumerator,自定义一个支持foreach遍历的类呢?

在c#1.0中,你只能这样做:

public class DataSource : IEnumerable
{
    public IEnumerator GetEnumerator()
    {
        Enumerator enumerator = new Enumerator(0);
        return enumerator;
    }

    public class Enumerator : IEnumerator, IDisposable
    {
        private int state;
        private object current;

        public Enumerator(int state)
        {
            this.state = state;
        }

        public bool MoveNext()
        {
            switch (state)
            {
                case 0:
                    current = "Hello";
                    state = 1;
                    return true;
                case 1:
                    current = "World";
                    state = 2;
                    return true;
                case 2:
                    break;
            }
            return false;
        }

        public void Reset()
        {
            throw new NotSupportedException();
        }

        public object Current
        {
            get { return current; }
        }
        public void Dispose()
        {
        }
    }

}

class Program
{
    static void Main(string[] args)
    {
        DataSource dataSource = new DataSource();
        foreach (string s in dataSource)
        {
            Console.WriteLine(s);
        }
    }
}

可以看到,需要在DataSource类中实现一个Enumerator类,其采用状态机的方式实现MoveNext的逻辑,稍有不慎就会产生差错。

c#2.0后引入了语法糖yield,较完美的解决了迭代器模式的易用性这个问题。同样的功能,只需寥寥几行便可实现。

public class DataSource : IEnumerable
{
    public IEnumerator GetEnumerator()
    {
        yield return "Hello";
        yield return "World";
    }
}

从这个例子中,可以猜想到yield return是如何实现等价效果的:对于一个位于返回值为IEnumerator的函数里的yield,做如下处理:

  1. 静默创建了一个IEnumerator对象

  2. 立刻调用了这个对象的MoveNext()方法,其执行了第一个yield之前的逻辑

  3. 遇到第一个yield时,将Current赋值为yield return后面的值),保存当前状态并挂起。

  4. 下次调用MoveNext()时,从刚才的yield之后的语句开始执行。直到最后一个yield return语句时,MoveNext()返回false。

在实践中,发现yield在返回值为IEnumerable的函数中也能起作用,和IEnumerator似乎是一样的。这是为何?俗话说“好人做到底,送佛送到西”,本文既然说深入剖析,这个疑点自然不能放过。

首先我们知道,yield只是语法糖,那么能不能看到编译器将其展开后的结果呢?笔者将测试代码编译成DLL后,放在ILSpy 2.4.0版中看反编译后的c#,终于发现了使用IEnumerator和IEnumerable的不同。

从上图可以发现,IEnumerable会创建一个线程ID,并且初始状态为 -2(表明 GetEnumerator()还没有被调用)。如果另一个线程在迭代中途调用了GetEnumerator(),则会新建立一个该类对象。这里介绍的是比较简单的情况,当有参数传递时,要去维护线程安全就比较复杂了。总之,用 IEnumerable 是线程安全的。

不过语法糖终究是语法糖,yield的使用是有限制的,比如用于异常处理。

yield return不能用于try-catch中,只能用在try-finally的try中。

主菜:Unity协程的实现

前一节告诉我们:在返回值为IEnumerator/IEnumerable的函数中,yield return [value] 可以被展开,实现迭代器的效果。[value]是本次MoveNext()的返回值Current,可以是object类型。下次调用MoveNext()时,从刚才的yield之后的语句开始执行。

Unity利用这个特性实现了协程。协程本篇就不介绍了,这方面已有不少笔墨,亦超出本篇的讨论范围。继续刚才话题,在Unity的协程中,返回值Current并不能直接被使用者获得,而是内部进行了处理。

对于不同类型的返回值,效果亦不相同。比如yield return null;就是挂起协程,回到主函数逻辑,下一帧从挂起的位置继续。yield return new WaitForSeconds(1f);就是挂起1秒后再继续。这是怎么实现的?先看看反编译后的代码。

  • yield return null 的结果:

  • yield return new WaitForSeconds(1f) 的结果

When you make a call to StartCoroutine(IEnumerator) you are handing the resulting IEnumerator to the underlying unity engine.

StartCoroutine() builds a Coroutine object, runs the first step of the IEnumerator and gets the first yielded value. That will be one of a few things, either "break", some YieldInstruction like "Coroutine", "WaitForSeconds", "WaitForEndOfFrame", "WWW", or something else unity doesn't know about. The Coroutine is stored somewhere for the engine to look at later.

... At various points in the frame, Unity goes through the stored Coroutines and checks the Current value in their IEnumerators.

  • WWW - after Updates happen for all game objects; check the isDone flag. If true, call the IEnumerator's MoveNext() function;

  • WaitForSeconds - after Updates happen for all game objects; check if the time has elapsed, if it has, call MoveNext();

  • null or some unknown value - after Updates happen for all game objects; Call MoveNext()

  • WaitForEndOfFrame - after Render happens for all cameras; Call MoveNext

MoveNext returns false if the last thing yielded was "break" of the end of the function that returned the IEnumerator was reach. If this is the case, unity removes the IEnumerator from the coroutines list.

由于上面已经概括了Unity实现协程的思想,这里稍作补充,源码就不贴了:

  • StartCoroutine创建了Coroutine对象coroutine,该对象保存了yield return展开后的IEnumerator对象指针、MoveNext和Current的函数指针、结束后应当唤醒的协程的指针、指向调用者Monobehaviour的指针等等,并将该对象coroutine保存到该Monobehaviour的活跃协程列表中。然后立即调用了coroutine.Run()。

  • coroutine.Run()首先尝试调用InvokeMoveNext,若发现当前协程执行完成,则会尝试调用应当唤醒的协程,否则才真正执行MoveNext,获得返回值monoWait。

  • 根据返回值monoWait的类型,进行不同的处理。通常是传递不同的参数给CallDelayed函数。对于返回值是Coroutine类型(c#那边用了协程嵌套),会将这个返回值的结束后应唤醒的协程的指针指向当前的coroutine。笔者这里发现了一种不太常见的用法:当返回值为IEnumerator类型(c#那边没有用StartCoroutine去开启嵌套协程,而是直接在yield return 后调用)时,Unity会自动为其创建一个Coroutine对象并初始化,效果同样。

  • CallDelayed函数传入了运行协程对象的方法、qingli协程对象的方法、清理条件等。函数内部创建了一个Callback对象,加到了全局的DelayedCallManager的列表中。游戏主循环会在每一帧调用DelayedCallManager.Update,在满足一定条件时(比如对应的Monobehaviour对象还没被销毁等)调用Callback对象的方法。

甜点: 带返回值的协程

看到这里,似乎意犹未尽。有些读者可能会问,除了知道如何用yield实现自己的可迭代的类,以及Unity利用yield实现协程的原理外,对日常代码有什么立竿见影的作用?这里就介绍一个小技巧:自定义可返回值的协程。

上一节我们知道,Unity内部将yield return的结果进行了处理,但常常我们也想去访问协程的结果,比如玩家发起坐下请求。结果是超时了?还是坐下失败了?如果能用协程实现下面的写法,不仅省去了回调函数定义的冗余,还可以使上下文更连贯。

//期望的用法
SitDownCoroutine cd = new SitDownCoroutine(requestSitDown(seatID));
yield return cd.coroutine;
Debug.Log("result is " + cd.result);  //  'success' or 'fail' or 'timeout'
public class SitDownCoroutine : CustomYieldInstruction
{
    public RetCode result { get; private set; }
    private bool _finished = false;

    public SitDownCoroutine(Action<long> request)
    {
        //将onResponse加入对应的回包监听
    }

    private void onResponse(RetCode retCode)
    {
        result = retCode;
        _finished = true;
    }

    public override bool keepWaiting
    {
        get { return !_finished; }
    }
}

篇幅有限,更多关于Unity协程的实用技巧将收录进下篇。如果本篇没有令读者满足的话,请恕笔者厨艺有限,招待不周。

おそまつ~

yield break可以用于try-catch,但不能用在finally块中。

更多这方面的讨论推荐《C# in depth》作者写的,以及。

看完后觉得信息量不大。Unity到底用了什么魔法?网上给出了自己的猜想(看完源码后发现这位仁兄猜的真准……)

为了实现上述效果,笔者在经历的项目中见过一些复杂的写法,其思想是自己模拟Unity的这套运行协程的机制。不过受限于开发量,往往只能支持简化的功能。介绍了一种相对简单有效的方法,有兴趣可以看看。笔者这里提供相当简练的方法,利用Unity5.3以后提供的 功能。当Unity遇到返回值为CustomYieldInstruction类型时,会检查keepWaiting的值,直到该值为false才会结束协程。

《C#参考》
这篇博客
这篇
有篇博客
这篇帖子
CustomYieldInstruction
深入剖析Unity协程
开胃菜:从IEnumerator/IEnumerable 到Yield
主菜:Unity协程的实现
甜点: 带返回值的协程
IEnumerator和IEnumerable的yield展开结果
yield_null
yield_ws