2
Help us understand the problem. What are the problem?

posted at

updated at

Organization

UniTask勉強会〜実用例を見ながら〜

UniTask勉強会〜実用例を見ながら〜

by KyoheiOkawa
1 / 40

はじめに

みなさんコールバックはお好きですか?
私はコールバック自体は悪だとは思いませんが、それを多用するのはよくないと思っています。
コールバック地獄がなくなることを切に願っています。

今回解説していくUniTaskを活用するとコールバックを少なくでき、さらにコードの可読性も上げることができます。
また、他の記事で解説しようと思っているUniRxも活用すればほとんどコールバックを使わなくて良くなります。

UniTaskを使い始めて1年ちょっとが経つので、布教するために記事を書こうと思いました。
まだまだ理解していないことがたくさんあるかとは思いますが、これまでによく使ってきた処理をまとめて書いていきます。


UniTaskとは

C#の言語仕様であるasync/awaitはそのままではUnityのゲームロジックなどで使用することができません。

UniTaskではasync/awaitの使用を可能にし、さらにUnity上で使いやすいようにユーティリティ関数が準備されています。

またパフォーマンスもC#デフォルトのTaskを使用するより良いです。


まず非同期とは

非同期には以下の2種類があります。
・別スレッドで実行するもの
・遅延実行するもの

こちらの二つをしっかり意識することが、非同期処理を理解する上で大切だと思っています。

別スレッドで実行するものとしては、
・通信処理
・IO処理
・重い計算や処理

遅延実行するものとしては
・演出のロジック
・別スレッド処理の終了を待つ判定と処理

で使い分けることができると考えています。


UniTaskが必要な理由

デフォルトのC#だと特に遅延実行する処理がUnityでサポートされていません。
GameObjectの処理はメインスレッドで実行する必要があるため、別スレッドでは制御できないです。
UniTaskを使用することで、async/awaitでコルーチンを書くことが可能になります。
コルーチンでも書けるのに、いちいちasync/awaitで書く必要があるのかとツッコミが入ると思いますので、比較用に書いてみます。


コルーチンの場合

using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;

public class CroutineSample : MonoBehaviour {
    void Start() {
        StartCoroutine(Coroutine(request => {
            Debug.Log(request.downloadHandler.text);
        }));
    }

    IEnumerator Coroutine(Action<UnityWebRequest> endCallBack) {
        var request = UnityWebRequest.Get("https://www.google.com/");
        yield return request.SendWebRequest();
        endCallBack?.Invoke(request);
    }
}

UniTaskの場合

using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;

public class UniTaskSample : MonoBehaviour {
    async void Start() {
        var request = UnityWebRequest.Get("https://www.google.com/");
        await request.SendWebRequest();
        Debug.Log(request.downloadHandler.text);
    }
}

例外処理は書いていませんが、UniTaskで書いた方がシンプルで読みやすいです。
今回は一つのRequestしかないですが、こちらが複数になったりするとコルーチンの場合はコードが複雑になってしまうと思います。

遅延処理のコルーチンだけではなく、別スレッドでの処理においても、C#標準のTaskよりUniTaskを使う方がパフォーマンスに優れています。


UniTaskの基本

私がよく使うものをまとめてみました。


async/await

こちらがUniTaskを使い始めるためのベースになるので絶対抑えておく必要があります。
以下の3パターンがよく使う書き方です。


処理を止めない場合

    void Start() {
        SendWebRequest();
        // 通信完了を待たずに以下の処理に続く
        Debug.Log("Do something");
    }

    async void SendWebRequest() {
        var request = UnityWebRequest.Get("https://www.google.com/");
        await request.SendWebRequest();
        // 以下は通信が終わった後に処理される
    }

処理を止める場合

    async void Start() {
        await SendWebRequest();
        // 通信完了を待ってから以下の処理が呼ばれる
        Debug.Log("Do something");
    }

    async UniTask SendWebRequest() {
        var request = UnityWebRequest.Get("https://www.google.com/");
        await request.SendWebRequest();
        // 以下は通信が終わった後に処理される
    }

戻り値を受け取りたい場合

    async void Start() {
        var result = await SendWebRequest();
        // 通信の結果を受け取ってから以下の処理に続く
        Debug.Log($"Result:{result}");
    }

    async UniTask<string> SendWebRequest() {
        var request = UnityWebRequest.Get("https://www.google.com/");
        await request.SendWebRequest();
        return request.downloadHandler.text;
    }

キャンセル処理

見落としがちでしっかり対応しないと痛い目に遭うのがキャンセル処理周りです。
これまでのサンプルコードはわかりやすくするために書いていませんでしたが、
通信中にGameObjectが破棄された場合、通信完了後await後の処理が実行されてしまいます。
大丈夫なケースもありますが、NullReferenceExceptionが発生してしまいます。


CancellationTokenSource

こちらがDestroy時に通信処理を中断する基本的な書き方になります。

    private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource();

    async void Start() {
        var result = await SendWebRequest(_cancellationTokenSource.Token);
        Debug.Log($"Result:{result}");
    }

    async UniTask<string> SendWebRequest(CancellationToken ct = default) {
        var request = UnityWebRequest.Get("https://www.google.com/");
        await request.SendWebRequest().ToUniTask(cancellationToken: ct);
        return request.downloadHandler.text;
    }

    private void OnDestroy() {
        _cancellationTokenSource?.Cancel();
    }

非同期処理をする関数の引数にCancellationTokenを含める癖をつけたほうが良いです。
今回は非同期関数が一つですが、非同期関数の中で非同期関数が呼ばれていく場合、後から全部の引数にCancellationTokenを含めようとすると範囲が広くなってしまいます。
こちらが引数にあることで、キャンセル処理を伝搬させていくことができます。

非同期処理が完了する前にCancellationTokenSourceがキャンセルされた場合、
上記の書き方だとOperationCanceledExceptionが発生します。
例外が発生した場合、それ以降の処理は行われなくなるので、処理を止めるために例外を利用しているのかなと私は思っています。
OperationCanceledExceptionが出て問題ないケースと問題あるケースはあると思いますが、try-catchで対応するか、後述のForgetを使うことでログに例外が出てこなくなります。


GetCancellationTokenOnDestroy

実は上の書き方をしなくても一発で同じ処理をかけるように、UniTask側で準備されています。

    async void Start() {
        var result = await SendWebRequest(this.GetCancellationTokenOnDestroy());
        Debug.Log($"Result:{result}");
    }

    async UniTask<string> SendWebRequest(CancellationToken ct = default) {
        var request = UnityWebRequest.Get("https://www.google.com/");
        await request.SendWebRequest().ToUniTask(cancellationToken: ct);
        return request.downloadHandler.text;
    }

オブジェクトが破棄される以外に、キャンセルをしたい時があるので上では例としてCancellationTokenSourceの使い方をあげました。


LinkedTokenSource

自分が破棄された時と他のGameObjectが持っているCancellationTokenSourceがキャンセルされた時のいずれかにキャンセルしたいとなったとき等に使えるのがLinkedTokenSourceです。
書き方は以下になっています。

    async void Start() {
        var cancellationTokenSource = new CancellationTokenSource();
        var linkedTokenSource =
            CancellationTokenSource.CreateLinkedTokenSource(this.GetCancellationTokenOnDestroy(), cancellationTokenSource.Token);
        var result = await SendWebRequest(linkedTokenSource.Token);
        Debug.Log($"Result:{result}");
    }

Forget

最初に紹介した処理を止めない方法(awaitしない)でも動作はするのですが、処理がキャンセルされた場合にOperationCanceledExceptionが発生します。
Forgetを使用するとOperationCanceledExceptionが発生しなくなります。
NullReferenceExceptionなど他の例外が関数内で発生した時は飲み込まれず発生します。

    void Start() {
        SendWebRequest(this.GetCancellationTokenOnDestroy()).Forget();
        // Do something
    }

    async UniTask SendWebRequest(CancellationToken ct = default) {
        var request = UnityWebRequest.Get("https://www.google.com/");
        await request.SendWebRequest().ToUniTask(cancellationToken: ct);
        Debug.Log($"Result:{request.downloadHandler.text}");
    }

待ち処理


遅延(Delay)


指定時間待つ(Delay)

    async UniTask SendWebRequest(CancellationToken ct = default) {
        // 1秒待つ
        await UniTask.Delay(TimeSpan.FromSeconds(1f), cancellationToken: ct);
        var request = UnityWebRequest.Get("https://www.google.com/");
        await request.SendWebRequest().ToUniTask(cancellationToken: ct);
        Debug.Log($"Result:{request.downloadHandler.text}");
    }

他に渡せる引数は以下のようなものがあります

パラメータ 説明
bool ignoreTimeScale Time.timeScaleを無視するかどうか
PlayerLoopTiming delayType あまり利用したことはありませんが、ゲーム内の時間か実際の時間かを指定できるようです
DelayType delayTiming UpdateやFixedUpdateなどのタイミングを指定できます

※delayTypeとdelayTimingは同時には渡せません


指定フレーム待つ(DelayFrame)

    async UniTask SendWebRequest(CancellationToken ct = default) {
        // 1フレーム待つ
        await UniTask.DelayFrame(1, cancellationToken: ct);
        var request = UnityWebRequest.Get("https://www.google.com/");
        await request.SendWebRequest().ToUniTask(cancellationToken: ct);
        Debug.Log($"Result:{request.downloadHandler.text}");
    }

他に渡せる引数はdelayTimingのみです。


タスクの終了を待つ(When)


WhenAll

    async void Start() {
        var task1 = SendWebRequest(this.GetCancellationTokenOnDestroy());
        var task2 = SendWebRequest(this.GetCancellationTokenOnDestroy());
        await UniTask.WhenAll(task1, task2);
        // Do something
    }

task1とtask2を同時に実行して、二つのtaskが終了するまで待ちます。
※可変長引数になっているので何個でも指定できます。


WhenAny

    async void Start() {
        var task1 = SendWebRequest(this.GetCancellationTokenOnDestroy());
        var task2 = SendWebRequest(this.GetCancellationTokenOnDestroy());
        await UniTask.WhenAny(task1, task2);
        // Do something
    }

task1とtask2を同時に実行して、一つでもtaskが終了したら抜けます。


条件を満たすまで待つ(Wait)


WaitUntil

条件が満たされるまで待つことができます。
以下のコードですと、60フレーム後にisFinishがtrueになるので実際は60フレーム待つという処理になります。

    async void Start() {
        bool isFinish = false;
        Observable.TimerFrame(60).Subscribe(_ => isFinish = true).AddTo(this);
        await UniTask.WaitUntil(() => isFinish, cancellationToken: this.GetCancellationTokenOnDestroy());
        // DoSomething
    }

私の使い所ですが、非同期が対応されていない箇所で待つ必要がある場合に利用します。
例えばダイアログを閉じたというイベントがコールバックでしか取れない場合、
コールバックでフラグを変更してそのフラグでawaitをかける形で使います。

    async void Start() {
        bool isClose = false;
        Action closeCallback = () => { isClose = true; };
        OpenDialog(closeCallback);
        await UniTask.WaitUntil(() => isClose, cancellationToken: this.GetCancellationTokenOnDestroy());
        // DoSomething
    }

このように書くことでコールバックのネストを深くせず、手続き的に処理をかけるので可読性が上がると私は感じています。

WaitUntilの他にWaitWhileが用意されていますが、こちらは条件が反転するだけです。
私はほとんどWaitUntilを使用しますが、処理を見て一番意味がわかる方を選ぶのが良いと思います。


1更新待つ(Yield)

    async UniTask SendWebRequest(CancellationToken ct = default) {
        await UniTask.Yield(PlayerLoopTiming.Update, ct);
        var request = UnityWebRequest.Get("https://www.google.com/");
        await request.SendWebRequest().ToUniTask(cancellationToken: ct);
        Debug.Log($"Result:{request.downloadHandler.text}");
    }

コルーチンでいうyield return nullと同じような感じで、
指定されたPlayerLoopTimingのタイミングで1更新遅らせます。
PlayerLoopTiming.Updateだと1フレーム待つのと同じですね。


余談ですが、過去にループ処理を以下で書いていたことがありました。

            while (true)
            {
                await UniTask.DelayFrame(1, cancellationToken: cancellationTokenSource.Token);
                await UniTask.WaitUntil(() => IsUpdate, cancellationToken: cancellationTokenSource.Token);
                // Update処理
            }

1フレーム待って、IsUpdateがfalseにはUpdateを止める処理ですね。
IsUpdateがtrueの時は1フレームしか待たないように見えますが、2フレームくらい待ちが発生していました。

なので、しっかり1フレーム待ちたい場合は以下のように書いたほうがよさそうです。

           while (true)
            {
                await UniTask.Yield(PlayerLoopTiming.Update, cancellationTokenSource.Token);
                while (!IsUpdate) {
                    await UniTask.Yield(PlayerLoopTiming.Update, cancellationTokenSource.Token);
                }
                // Update処理
            }

Trigger系

種類が多く、MonoBehaviourのメッセージの数だけあります。
Awake,Start,Update,Destroy,OnCollicionXXXなどが呼ばれるまでawaitすることが可能です。

    async void Start() {
        Collision collision = await this.GetAsyncCollisionEnterTrigger().OnCollisionEnterAsync();
    }

UniTaskの利用例4選


通信

これまで通信処理でサンプルを書いてきたので、実感は湧いてきていると思います。
通信を待つのにコールバックを使用する必要がなく、通信の結果も待った後に使えるのでかなり便利です。
また、メインスレッドを止めずに通信処理が行われるため通信中のアニメーションなどを止めずに通信を行うことができます。


リソースの読み込み

Resourcesだと以下のように書けて、他にAssetBundleのダウンロード+読み込み処理も同じように書くことも可能です。

    async void Start() {
        var go = await Resources.LoadAsync<GameObject>("Test");
        Instantiate(go);
    }

通信処理と同じく、メインスレッドを止めずに処理することができるため読み込み中のアニメーションを止めずに処理を行うことができます。
画像などを読み込んでからUI.Imageなどにセットする場合、
気をつけないと画像がセットされる前の仮状態の表示がされてしまうのでこの辺りの処理は気を付けないといけません。


アニメーション

アニメーションの制御にUniTaskはかなり役立ちます。
今回はDOTWeenを例に挙げて説明します。
UniTaskにDOTweenの対応も入っており、awaitすることができます。
こちらの機能を使うためにはUNITASK_DOTWEEN_SUPPORTを定義する必要があり、
Project Settings/Player/OtherSettings/ScriptiongDefineSymbolsに設定します。
Screen Shot 2021-11-26 at 14.25.35.png

DOTweenではアニメーションの終了通知をOnCompleteで受け取れますが、
一つのアニメーションが終わってから次の処理をしてその処理が終わったらアニメーションさせようと思うと、
コールバック地獄が発生します。
UniTaskの機能を利用することで以下のようにシンプルに書くことが可能になります。

    async void Start() {
        await transform.DOMoveX(1f, 0.5f).WithCancellation(this.GetCancellationTokenOnDestroy());
        // 何かしらの演出
        await transform.DOMoveX(0f, 0.5f).WithCancellation(this.GetCancellationTokenOnDestroy());
    }

またWhenAllと組み合わせて使用することで、並列にアニメーションを実行できたり、

    async void Start() {
        var anim1 = transform.DOMoveX(1f, 0.5f).WithCancellation(this.GetCancellationTokenOnDestroy());
        var anim2 = transform.DOScale(0f, 0.5f).WithCancellation(this.GetCancellationTokenOnDestroy());
        await UniTask.WhenAll(anim1, anim2);
        // Do something
    }

シーケンスを作成し、それを完了するまで待つこともできます。

    async void Start() {
        Sequence seq = DOTween.Sequence();
        seq.Append(transform.DOMoveX(1f, 0.5f));
        seq.AppendCallback(() => { Debug.Log("Test"); });
        seq.Append(transform.DOMoveX(0f, 0.5f));
        seq.Join(transform.DOScale(0f, 0.5f));
        await seq.WithCancellation(this.GetCancellationTokenOnDestroy());
        // Do something
    }

UI・演出制御

タップ、フリック、クリックなど、操作に関する処理にもUniTaskを活用することができます。
ダブルクリックやフリックなどの少し複雑な操作を待つ処理はUniRxと併用することで効果を発揮します。

私が以前書いた記事で、タップしてから指を話すまでの時間が長い場合はタップ判定をしない処理があります。

上記の記事で書いた判定をUniTaskと使用する場合は以下のように書けます。

    async void Start() {
        var mouseDown = Observable.EveryUpdate().Where(_ => Input.GetMouseButtonDown(0)).Select(_ => Time.realtimeSinceStartup);
        var mouseUp = Observable.EveryUpdate().Where(_ => Input.GetMouseButtonUp(0)).Select(_ => Time.realtimeSinceStartup);
        var tapObservable = mouseDown.Merge(mouseUp).Buffer(2).Where(ls => ls[1] - ls[0] < 0.2f);
        // タップされるまで待つ
        await tapObservable.GetAwaiter(this.GetCancellationTokenOnDestroy());
        // タップされた後の処理
    }

操作によって表示や演出が順番に更新されていく実装とかなり相性が良いと個人的に感じています。

上記だと複雑に見えてしまいますが、単にマウスボタンが押された時だと以下のように書けます。

    async void Start() {
        // ボタン押されるまでされるまで待つ
        await UniTask.WaitUntil(() => Input.GetMouseButtonDown(0));
        // 後の処理
    }

その他

UniTaskTracker

Window/UniTask Tracker
Screen Shot 2021-12-01 at 11.42.23.png
UniTaskに付属しているEditorToolです。
キャンセル処理を正しく行わないとTaskが残り続けることがあるので、その確認などに使えます。


RuntimeUnitTestToolkit

UniTaskの作者さんが作成したツールで、TestRunnerを実機でも実行できるようにしたツールです。
TestRunnerの詳細な説明は省きますが、TestRunnerではコルーチンのテストは可能ですがasync/awaitを使用したテストは書くことができません。
こちらのツールにasync/awaitを使用したテスト可能にする機能があるのでこちらだけ紹介します。

サンプルテストコードです。

using System;
using System.Collections;
using Cysharp.Threading.Tasks;
using NUnit.Framework;
using UnityEngine;
using UnityEngine.TestTools;

public class UniTaskTest {
    [UnityTest]
    public IEnumerator Test() => UniTask.ToCoroutine(async () =>
    {
        Debug.Log("テスト開始");
        int i = 0;
        Debug.Log("1秒待つ");
        await UniTask.Delay(TimeSpan.FromSeconds(1f));
        Debug.Log("1秒経過");
        Assert.AreEqual(0, i);
    });
}

こちらを記述するとTestRunnerに表示され、

Screen Shot 2021-12-01 at 12.05.16.png

実行もできました!

Screen Shot 2021-12-01 at 12.05.43.png


最後に

本記事を読んで、UniTaskを使う理由・良さを感じていただければ幸いです。
コールバック地獄を無くし、メンテナンスしやすいみんなが幸せになれるコードが増えていくようにUniTask布教を一緒に進めていきましょう!

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
2
Help us understand the problem. What are the problem?