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

N 人看过

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的動態等。

可以做出像這樣的打擊效果:
Juicy

順帶一提,再加入效果前的樣子是這樣的:

NoJuicy

可以說是相當方便的插件,端詳他的程式碼後,發現他實作一連串演出的呼叫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 Repo

UniTask v2 — Zero Allocation async/await for Unity, with Asynchronous LINQ

【Unite 2017 Tokyo】「黒騎士と白の魔王」にみるC#で統一したサーバー/クライアント開発と現実的なUniRx使いこなし術

UniRx Repo

UniRx DoTween Integration