UniTask:利用async/await優雅的撰寫callback
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使いこなし術