前往
大廳
主題

[Unity] UniTask:利用async/await優雅的撰寫callback

HaoX@ミリシタ | 2021-09-10 07:00:02 | 巴幣 2304 | 人氣 1440

更多內容以及整理可以看:部落格文章

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都是類似底下這種形式呼叫:
扣掉這樣IO其實還是同步的吐槽,這樣的作法已經非常酷,但想像到底下的狀況
當IO結束之後,必須送到某個伺服器等待回應,程式碼就會開始出現怪味:

當然,扣掉request好像完全不需要handle error的吐槽,我們可以看到DoSomethingCool的主函式,已經開始出現波動拳的力量。
這對於一個加班N小時候看到這段程式碼的工程師來說,很有可能就是壓垮他的最後一片稻草了。
想想一般的工程師,回到家之後沒有女僕龍可以陪伴,我們真的不需要互相傷害,製造出這種callback hell,幸好,Unity裡面早有一個常見方式可以克服這件事,那就是Coroutine。

Coroutine

Coroutine使用C#的迭代器模式,利用一個返回迭代器的Function來進行序列執行,並且在每一次Update後,做一次tick觸發。
原本的程式碼,可以改寫成這種形式:

顯然可以感覺到,比波動拳安全許多,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的呼叫介面如下:
其實他有提供幾個Event可以直接對接,但如果我們想和其他coroutine,或是tweening演出一起寫成一個function,使用event的撰寫就會變得冗長且難以維護。

用Event的方式來註冊的話,可以寫成如下:

這段程式碼有幾個問題,第一個是Event裡面的匿名function,執行時間其實在PlayFeedbacks底下,這導致了程式碼的順序與執行順序的不同,降低了一部分的可讀性。

再者,這段程式碼其實沒有寫到RemoveListener的部分,如果每次呼叫都AddListener一次,會造成顯著的memory leak,當然我們也可以將event的註冊拉到物件初始化的時候,但這樣會將邏輯更進一步的分離,可讀性再次下降。

最後,就是許多演出的串列如果在同一個function實作,最終會變成上面所說的波動拳問題,要將這個做法寫得漂亮,需要耗費許多苦心。

還好,這個插件還提供第二個方案,也就是前面提到Unity對於callback hell的一個解法,也就是Coroutine。

MMFeedback對於Coroutine的接口如下:

可以看到,這個接口直接回傳了一個迭代器,我們可以簡單的利用這個IEnumrator改寫成如下:

這樣就可以用Coroutine的方式,解決掉event可能產生的一些問題,但這樣就會產生一些coroutine的對應消耗,以及handle coroutine結束與否的問題,而前面提到的UniTask,可以用更優雅的方式做到。

我們可以先為MMFeedbacks添加一個接口function如下:

UniTask會時做一個awaiter,將coroutine的執行完成與否這件事封裝到UniTask自己的internal enumerator之中,這樣我們呼叫時,就可以簡單地寫成這樣:


這樣整個演出就可以簡單的寫成一個async function,其中的calling chain也會變得優雅許多,甚至如果有多個演出同時進行的時候,可以寫成下面的形式:

這樣我們可以在播出許多演出的同時,偷偷地在背後讀取Assets,直到一切都準備就緒了,馬上開始進行下一個場景的切換,達成一些無縫切換的效果。
順帶一提,轉場的概念可以去看我最敬愛的blog writer,羽毛的熱門文章:重新載入&場景轉換,肯定會獲益良多。

Conclusion

UniTask是個非常酷的插件,可以將許多演出與callback的可怕義大利麵程式碼,轉換成一眼就能看出結果的程式碼,同個作者的UniRx也是非常酷的插件,有興趣的可以去看看這個作者的repo們。

延伸閱讀

創作回應

虛鹿
怕,感覺好難
2021-11-20 05:48:44
HaoX@ミリシタ
還好拉
2021-11-20 18:00:29

更多創作