Unity下异步流程开发经验
1. 总起
1.1 游戏中的异步流程
游戏是一种通过动态艺术表现来使人愉悦的特殊软件。帧频限制下,长时间的工作若不异步执行,就将带来体验上卡顿。
对于网络游戏来说,异步协议交互无所不在。手游弱网络条件也为协议流程处理带来了新的复杂度。
美术表现的异步流程也是十分常见的。谁让游戏是一门艺术呢。
1.2 异步流程的顺序依赖
两个过程的顺序依赖是一种常见的流程耦合。在非异步执行的世界里,调用栈是用于描述顺序依赖过程的语法模型。但当顺序依赖进入异步流程中,实际代码通常并不那么优雅和易维护。
1.3 此文的目的
Unity引擎的开发者看到了异步流程对游戏开发的影响,以C#迭代器语法为基础,实现了对异步流程支持——协程Coroutine。在研究了其内部机制,并经历了两个项目的积累后,觉得有必要将其中的收获和实际技巧共享出来。如果大家平时在使用Unity协程上有困惑或是也有好的实践,希望能借此文多多交流。
2. 实现异步过程多种方式对比
2.1 异步过程四种不同的写法
了解事物总是从简单的实例入手的,通过一个简单的例子对比下异步过程的多种写法的优劣:
场景:收到后台通知,有人进入当前房间,需要下载完成这个角色的资源之后,显示这个角色。(为了讨论简单,省略了下载容错,角色在异步过程中离开房间的情况。)以下是几种不同的写法:
2.2 变量的作用域和可见域问题
前两种方式都不同程度的将异步流程上下文保存在成员变量中(如mEnteringRoleData)。为了避免其它流程不慎改写此变量,不得不加上一个较难理解的前缀“Entering”。但这种非语法保证的手段只是考验下后续代码修改者(也可能是一星期后的自己)的节操,实践中异步流程变量被滥用或被修改的故事还是比较常见的。
从设计原理上来说,问题出在“变量的作用域和可见域不一致”。而对这种一致性的坚持实际就是封装或面对对象的精髓之一。复杂系统的开发都得感谢这样的设计思想。
比较而言,后两种方式流程变量的可见域就没有暴露出去。妈妈再也不用担心我的变量被人改写了~
2.3 传统写法的局限性
其次,这种写法假设不会同时执行两个此异步流程。一旦流程执行过程中,RoleEnterRoom被再次调用,前一次的流程就被中断。这种属于“写代码时默认带入的限制性”,如果限制与实际不符,或是实际情况发生变化,那就等着debug吧。(想象下类似问题埋藏在复杂的时间相关逻辑的中,而且代码恰好是离职员工写的,呵呵。)
2.4 语法闭包与协程
第3种写法使用了C#的匿名代理特性,通过语法闭包将流程变量的作用域扩充到异步回调的时候;第4种写法通过启动一个协程,两者都避免了上面的两个问题。3和4两种写法的差异在当前这个例子中并不明显。而在需要多个异步流程按顺序执行时,协程的写法更利于逻辑组织。后文会用框架状态切换过程来更好的解释这个特点。
3. 事件等待及其实际应用
3.1 事件和协程的关系
事件和协程的关系应该是不一般的,如果他俩同一天公开女友的话我一定不会感到奇怪。异步过程说白了就是在等待什么,那么事件作为一种通知手段就再正常不过了。而协程自然地描述了异步流程,而事件为协程提供了一个与外部交流的手段。可以说两者肩并肩创造了一个对异步过程的描述模型。这个模型可以被广泛的应用在与协议交互流程中,也适合描述特定领域(如AI行为逻辑)。考虑到异步与协作广泛存在于世界的各个角落,两者的结合——一种可以作为异步编程范式的思想,究竟能创造出怎样的未来?画面太美不敢想象。
有等待便有超时,超时时间应该是此模型一个不可缺少的因素。看看实际代码是怎样的:
这样写,异步流程相关的上下文,也都封装在一个协程中了。清晰明了,容易维护。
3.2 弱网络下的超时处理
代码中省略了等待回包超时时的处理流程。这个流程稍复杂一些,但也极好地体现了协程的优点。原理上大致介绍下:
封装了一个方法,定义为“可靠的发送消息”。调用方需传入要发的包,要等待回包的消息id,以及消息成功收到的回调。此方法内部启动一个携程来发送消息并等待,如果发生超时,即认为与服务器之间的通讯不畅,提示玩家,并尝试重新连接服务器,连接上就发登陆请求,并等待登陆相应,之后再重发之前的请求消息。并再次等待回包。并且可以控制重试的次数。
这整个是一个很复杂的异步流程,其中有“等待回包”,“重连服务器”,“等待登陆回包”,“等待用户选择重试”这四个异步过程。同时还要避免重连协程多次并发执行,以及处理协程执行过程中切到后台时的中断逻辑,另外还要在协程过程中增加蒙板保护,以避免玩家操作产生额外的复杂度。
这么复杂的一个异步流程。如果用传统方式来实现,维护代码会比较困难。而有了协程和事件,可以将这里的复杂度以最可读的形式封装在内部,让业务逻辑能够简单而安心地调用此接口:
至于刚刚提到的并发保护,以及异步过程的蒙板实施细节,下文均会讲到。
4. 框架的状态异步切换
最早4种异步流程的写法中。匿名代理回调与协程两种方法之间并没有效果上的差异。而这里讲到的场景,只有用协程的语法工具来实现。
手游的流程结构,通常是基于界面、场景的切换。由于手游的系统性能存在明显的瓶颈。框架层面设计了当前状态的概念,处在当前状态下的系统出于激活态,相关的资源是加载到内存的,更新逻辑也只有在激活态时才执行。
另一方面,手游的特点往往是同一个核心玩法拥有多个包装。系统的重用度是一个考验。降低系统耦合是框架一个重要的设计目标。
最早就提到,流程顺序依赖是一种流程上的耦合。举个简单的例子,角色的创建和放置是在场景加载完毕后才能执行的。这样,在状态切换过程中,需要先激活场景加载系统,然后执行角色的表现系统。非异步的情况下,传统的框架通常都支持在一个配置文件中定义系统的触发顺序。以便在保证流程顺序的前提下解耦。而对异步流程传统框架则往往束手无策。协程在此处又派上了用场:
没错,协程中yield return 另一个协程时,将等待子协程执行完成后继续执行父协程。跟函数的调用栈十分相似。(注意,只是StartCoroutine而不作为当前Value被yield return的话,新协程和旧协程就是独立的。)
异步流程依赖关系因此而解耦。
5. 协程的原理,知其然且知其所以然。
5.1 C#语言相关
首先讲C#语言的机制,当一个函数的返回类型为IEnumerator,并且内部有yield表达式时。编译器在编译过程中,会将函数中内容生成一个状态机的类型(也可以说是迭代器)。将各个yield return划分为各个状态,并在每次MoveNext调用时从上一个yield return处继续执行,直到下一个yield return。若碰到yield break或协程结束,MoveNext就返回null。
流程中的变量实际就是状态机的成员变量。而yield return的东西,就是IEnumerator当前的Value。
也就是说,对协程函数的调用,本质上就是创建了一个状态机(或迭代器)的实例并初始化。
说白了,这部分并没有复杂的机制,只是编译器做了一个翻译,在语法上方便了使用者。X相比Lua的协程实现机制会创建一个新的调用栈来说,C#这个机制是相当轻量级的。
(另,关于这部分的细节,在我之前的文章中有提到,可参考
http://km.oa.com/group/16957/articles/show/119518)
5.2 Unity相关
理解了以上,就不难接下来Unity引擎提供的机制,理解其原理之后我们会发现自己实现也并非难事。
Unity下调用协程需要使用StartCoroutine方法。他的传入参数是一个IEnumerator(前文所说的状态机,或迭代器)。而StartCoroutine实际就是在GameObject中注册了这个IEnumerator。注册时会立刻调用一次IEnumerator对象的MoveNext一次,并在每次Update的时候调一次MoveNext,直到协程结束。
至于协程中yield return协程的情况。实际上就是在调用MoveNext之后,获得IEnumerator当前的Value。如果Value是一个UnityEngine.Coroutine对象(StartCoroutine的返回值),那么会将子Coroutine入栈,在子Coroutine结束后出栈了才继续执行父Coroutine。(入栈,出栈只是便于解释模型,实际的实现可能只是记录一个父子关系链。)这样便保证了嵌套协程的执行顺序。
理解了协程的实现原理,我们再来比较下最初的方式3和方式4的性能差异。
协程的方式会把局部变量全部存在状态机的成员变量里,例子中包括了loadReqList/resourceSys/roleData。(如果3者都是引用类型,存储的就是3个引用。)而3的方式中,语法闭包里仅有roleData的一个引用。内存分配上,协程是要略微多出一些。
其次,协程会在每帧执行一次MoveNext,检查下资源是否已经下载完成。而回调的方式并不会有。
要看到,这种级别的代价基本不会成为使用协程的阻碍。协程提供了描述异步流程的自然的语法,这是顺应编程方法论发展的整体趋势的。
另一方面,使用协程的时候也要在意这样的消耗。避免写出这种代码:
而应该在一个协程中处理多个数据。
另外,由于Unity使用的Mono版本还在2.6, Mono的GC在2.8之前,性能都是不太好的。容易引起卡顿,需要注意。(据说Unity不升级Mono的原因是Mono要求很高的授权费,而Unity只好研究Mono的替代方案。知道更详细八卦的同学请补充~)
6. 协程其它技巧和注意点
6.1 单例协程
有些异步过程,可能涉及对资源、对象的独占访问,或是出于逻辑原因并不期望同时触发执行多个异步实例。比如之前提到的框架的状态切换,如果在从StateA切换到StateB的过程中,又有逻辑希望切换到StateC。我们要保证固定的切换顺序,就需要实现一个切换队列:
还有一种轻量写法可以达到类似目的:
6.2 互斥量
runing这个变量起到的作用,有点像多线程编程当中的互斥量。
需要注意的,如果协程中间有yield break。就需要在退出前设置runing为false。如果退出比较多,写起来也比较容易出错。这时候就可以利用C#的using语法解决
从using块退出时,被using的对象会调用Dispose来释放资源。不仅支持异常形式的退出,也支持在迭代函数中跨异步过程使用。只需要注意,如果协程被StopCoroutine函数终止,是不会走到Dispose的,还是需要手动设置下runing=false。
这个技巧不仅可以用在协程互斥量上。之前提到的异步过程的蒙板,也可以用这个技巧。比如切状态的过程中不希望玩家进行其它操作:
7. 注意点
7.1 协程的生命期
由于协程在StartCoroutine时被注册到的GameObject上,他的生命期受限于GameObject的生命期,并受GameObject是否active的影响。这就要求我们尽可能将重要流程(不希望被打断的流程)放到一直存在的GameObject中,例如每个系统独占的GameObject上。又例如UI表现相关的协程,可以注册在UI的GameObject上,则随UI对象的删除而终止。
7.2 异步过程中依赖数据发生变更
在异步过程执行过程中,他所依赖的环境可能发生改变。
例如下载Icon完成的时候,需要设置在UI上,但UI已经随着界面的切换删除了。如果不注意判断直接访问,就会产生异常。
在异步过程当中,可能发生改变的除了全局变量,类的成员变量外,还包括协程中或语法闭包中的引用变量。在协程执行过程中,变更相关的数据是需要特别注意,并尽量避免的。如果数据确实要变,一方面可以在协程中加入失效判断,当数据变化时终止执行。另一方面也可以考虑在协程开始时,拷贝此对象,以确保不被外部修改。
这就是异步流程与生俱来的复杂度。解决复杂问题,依赖程序员的能力是一方面。而语法的进步,让程序员以更贴近自然语言的思维习惯来分解复杂问题。则是处理复杂问题,并推动编程技术不断向前发展的动力。面向对象,反射/元数据编程,F#的管道语法,应该还能找到不少鲜活的例子。个人认为,C#以及Unity提供的这样一套语言技巧能够很好处理游戏开发领域的异步流程的复杂度问题,谨借此文分享。