有兴趣的话,可以看看GitHub Page上的原文
Preface
前些日子因为一点小意外,需要在一两天时间从零开始弄一个web service上云,因为部分逻辑已经先用C#写好了,平常也天天在用C#,没想太多就用上了 ASP.NET core,没想到意外的很香。
除了.NET Core很香之外,这两天的时间写了写MVC的Web service,意外地发现和写游戏前端截然不同的写法,在写web service的时候,C#的async功能可以说是用个不停。
从以前就久闻UniRx差分出来的UniTask的大名,却迟迟没有机会与他相见,想说趁这个机会来碰一碰吧,碰巧,最近下班玩的一个插件,刚好使用Coroutine作为接口,趁这个机会,来试试UniTask可以怎么让程式撰写变得有所不同。
Sync vs Async
印象好像从大一的计概?还是后来的组合语言或计组之类的课程,都常常提到同步和非同步的差别。
不太确定课本精准的定义,不过Synchronize(sync, 同步)大致上是指在程式执行过程中,必须等前一个讯号执行完成,才继续进行下一个指令,而Asynchronize(async, 非同步)则是反过来,这个讯号并不一定要等到执行到了尽头,才开始下一个指令的运行。
在一般写程式的时候,大部分的程式码都是逐行、同步进行的(虽然流水线、指令级同步等东西存在,但逻辑上还是逐行在跑),然而,可想而知,有许多的指令会造成执行上的瓶颈,例如:IO, 网路相关的动作,相对于程式码都是缓慢的,以同步方式执行,就必须要在这里等到天荒地老,CPU直接等到睡着,可想而知这不是个好点子。
Callback
此时,就需要用到callback function这种做法。
传进一个delegate (或是function pointer,如果你热爱C语言的话),等到事件结束后,再继续执行这个完成后的function,当然可以将IO得到的资讯作为参数之类的。
许多library都是类似底下这种形式呼叫:
void DoSomethingCool()
{
DoSomethingNeedToWait(ioStuff =>
{
DoSomethingAfterHugeIO(ioStuff);
});
}
void DoSomethingNeedToWait(System.Action<IOStuff> callback)
{
var IOStuff = SomethingHugeIO();
callback(IOStuff);
}
void DoSomethingNeedToWait(System.Action callback)
{
SomethingHugeIO();
callback();
}
复制代码
扣掉这样IO其实还是同步的吐槽,这样的作法已经非常酷,但想像到底下的状况
当IO结束之后,必须送到某个伺服器等待回应,程式码就会开始出现怪味:
void DoSomethingCool()
{
DoSomethingNeedToWait(ioStuff =>
{
DoSomethingNeedACoolServer(ioStuff, res =>
{
DoTheRealCoolThings(res);
});
});
}
void DoSomethingNeedToWait(System.Action<IOStuff> callback)
{
var IOStuff = SomethingHugeIO();
callback(IOStuff);
}
void DoSomethingNeedACoolServer(IOStuff coolData, System.Action<Response> onResponsed)
{
var response = SomethingWaitServer();
onResponsed(response);
}
复制代码
当然,扣掉request好像完全不需要handle error的吐槽,我们可以看到DoSomethingCool的主函式,已经开始出现波动拳的力量。
这对于一个加班N小时候看到这段程式码的工程师来说,很有可能就是压垮他的最后一片稻草了。
想想一般的工程师,回到家之后没有女仆龙可以陪伴,我们真的不需要互相伤害,制造出这种callback hell,幸好,Unity里面早有一个常见方式可以克服这件事,那就是Coroutine。
Coroutine
Coroutine使用C#的迭代器模式,利用一个返回迭代器的Function来进行序列执行,并且在每一次Update后,做一次tick触发。
原本的程式码,可以改写成这种形式:
IOStuff _ioStuff;
Response _response;
void Start()
{
StartCoroutine(DoSomethingCool());
}
IEnumerator DoSomethingCool()
{
yield return DoSomethingNeedToWait();
yield return DoSomethingNeedACoolServer(_ioStuff);
DoTheRealCoolThings(_response);
}
IEnumerator DoSomethingNeedToWait()
{
yield return SomethingHugeIO(out _ioStuff);
}
IEnumerator DoSomethingNeedACoolServer(IOStuff coolData)
{
yield return SomethingWaitServer(out _response);
}
复制代码
显然可以感觉到,比波动拳安全许多,yield return后的事情,只会在一个frame进行一次,
如果还没完成,会等到下一次tick时再次检查,这样可以回避掉波动拳,并且让半夜看到这段程式码的工程师感到舒畅许多,明显可以一眼看出在等什么以及资料流的走向。
然而,Coroutine必须绑定monobehaviour进行,以及每一次Update时unity都需要费心来关切他,而且try-catch区段在yield语法下不可用,或许我们不需要那么多心思在制作这样的串列上,而是有其他替代方法。
UniTask
UniTask是利用C#的async/await语言机制整合进unity元件的一个解决方法,
可以用雷同C# Task的方式来进行unity元件的操作,获得一个更优雅的call chain,并且不需要担心allocation问题(至少readme上是写no allocation)。
(async在语言层面上应该是类似C++的std::this_thread::yield,将这个thread的优先权交出,但C#的async会不会真的交出优先权我不晓得)
我想这边开始就不用上面提到的那些假举例,而是用我最近实际遇到的使用情境来说明。
前些日子在特价的时候,我买了MoreMountain的Feel这个插件,他可以使用预先做好的元件,做出许多很酷的效果,包含Cinemachine的一些元件互动,或是Post Effect的动态等。
可以做出像这样的打击效果:
顺带一提,再加入效果前的样子是这样的:
可以说是相当方便的插件,端详他的程式码后,发现他实作一连串演出的呼叫MMFeedbacks是使用coroutine呼叫的,倘若我们想要在这一连串演出结束过后,再衔接什么演出,就必须遇到前面提到的Coroutine问题。
MMFeedback的呼叫介面如下:
public virtual void PlayFeedbacks()
{
StartCoroutine(PlayFeedbacksInternal(this.transform.position, FeedbacksIntensity));
}
复制代码
其实他有提供几个Event可以直接对接,但如果我们想和其他coroutine,或是tweening演出一起写成一个function,使用event的撰写就会变得冗长且难以维护。
用Event的方式来注册的话,可以写成如下:
private void HitSomething(Collider[] hits)
{
m_HitPos = GetRecent(3);
OnHit?.Invoke();
FeedbackHandler.Events.OnComplete.AddListener(() =>
{
TriggerAfterFeedback(hits);
});
FeedbackHandler.PlayFeedbacks();
}
复制代码
这段程式码有几个问题,第一个是Event里面的匿名function,执行时间其实在PlayFeedbacks底下,这导致了程式码的顺序与执行顺序的不同,降低了一部分的可读性。
再者,这段程式码其实没有写到RemoveListener的部分,如果每次呼叫都AddListener一次,会造成显著的memory leak,当然我们也可以将event的注册拉到物件初始化的时候,但这样会将逻辑更进一步的分离,可读性再次下降。
最后,就是许多演出的串列如果在同一个function实作,最终会变成上面所说的波动拳问题,要将这个做法写得漂亮,需要耗费许多苦心。
还好,这个插件还提供第二个方案,也就是前面提到Unity对于callback hell的一个解法,也就是Coroutine。
MMFeedback对于Coroutine的接口如下:
public virtual IEnumerator PlayFeedbacksCoroutine(Vector3 position3,...)
{
return PlayFeedbacksInternal(position, feedbacksIntensity, forceRevert);
}
复制代码
可以看到,这个接口直接回传了一个迭代器,我们可以简单的利用这个IEnumrator改写成如下:
private void HitSomething(Collider[] hits)
{
StartCoroutine(DoHitSomething(hits));
}
private IEnumerator DoHitSomething(Collider[] hits)
{
m_HitPos = GetRecent(3);
OnHit?.Invoke();
yield return FeedbackHandler.PlayFeedbacksCoroutine(this.transform.position);
TriggerAfterFeedback(hits);
}
复制代码
这样就可以用Coroutine的方式,解决掉event可能产生的一些问题,
但这样就会产生一些coroutine的对应消耗,以及handle coroutine结束与否的问题,而前面提到的UniTask,可以用更优雅的方式做到。
我们可以先为MMFeedbacks添加一个接口function如下:
public virtual async UniTask PlayFeedbacksAsync()
{
await PlayFeedbacksInternal(this.transform.position, FeedbacksIntensity);
}
复制代码
UniTask会时做一个awaiter,将coroutine的执行完成与否这件事封装到UniTask自己的internal enumerator之中,这样我们呼叫时,就可以简单地写成这样:
private async UniTask OnHitSomething(Collider[] hits)
{
m_HitPos = GetRecent(3);
OnHit?.Invoke();
await FeedbackHandler.PlayFeedbacksAsync();
TriggerAfterFeedback(hits);
}
复制代码
这样整个演出就可以简单的写成一个async function,其中的calling chain也会变得优雅许多,甚至如果有多个演出同时进行的时候,可以写成下面的形式:
private async void DoTonsOfScreenPlay()
{
List<UniTask> screenPlays = new List<UniTask>();
screenPlays.Add(OnHitSomething());
screenPlays.Add(OnHitSomethingCool());
screenPlays.Add(OnHitSomethingCute());
screenPlays.Add(OnHitSomethingAhoy());
screenPlays.Add(LoadNextPartyAddressables());
await UniTask.WhenAll(screenPlays);
// After all screenplay end
await SceneManager.LoadSceneAsync("Next Party");
}
复制代码
这样我们可以在播出许多演出的同时,偷偷地在背后读取Assets,直到一切都准备就绪了,马上开始进行下一个场景的切换,达成一些无缝切换的效果。
顺带一提,转场的概念可以去看我最敬爱的blog writer,羽毛的热门文章:重新载入&场景转换,肯定会获益良多。
Conclusion
UniTask是个非常酷的插件,可以将许多演出与callback的可怕义大利面程式码,转换成一眼就能看出结果的程式码,同个作者的UniRx也是非常酷的插件,有兴趣的可以去看看这个作者的repo们。
延伸阅读
UniTask v2 — Zero Allocation async/await for Unity, with Asynchronous LINQ
【Unite 2017 Tokyo】「黒骑士と白の魔王」にみるC#で统一したサーバー/クライアント开発と现実的なUniRx使いこなし术