【UniTask】UniTask非同期処理入門

UniTask
使用しているツールのバージョン
  • Unity: 2022.3.35f1
  • UniTask: 2.5.4

UniTaskと非同期処理

UniTaskとは

UniTaskは、Unityにおいて非同期処理をうまく扱うためのライブラリです。MITライセンスで配布されているオープンソースのライブラリであり、ライセンスが要求する条件に従っていれば、商用利用の有無に関わらず無料で利用することができます。

会話シーンの実装には非同期処理が多用され、そしてScenarioFlowは非同期処理を扱うためにUniTaskを使用します。そのため、ScenarioFlowを活用するためにはUniTaskの習得が必須となります。

非同期処理とは

同期処理は、実行完了を待たずに次の処理が実行される処理のことです。

通常、プログラムは1つずつ順番に実行され、これは同期処理と呼ばれます。同期処理の場合、ある処理を実行すると、その処理が完了するまで次の処理は実行されません。これに対して、ある同期処理を実行すると、その実行の完了は待たずに次の処理の実行が開始されます。

非同期処理を使用する、最もわかりやすい例はロード画面です。ゲームにおいて何かしらのデータをロードするのはよくある処理ですが、その際に、データのロードを同期的に行った場合、ロードが完了するまで他の処理は実行できず、画面がフリーズしたような状態になってしまいます。

そこでロードを非同期的に行うと、ロード処理の実行中に、その完了を待たずに別の処理を実行できるので、例えばロードの進捗バーを表示してユーザーの待ち時間に対するストレスを軽減することができます。

非同期処理は主に、「時間のかかる処理」に適用されると考えて問題ありません。

なぜUniTaskを使うのか

Unityにおいて非同期処理を扱う方法としては、UniTaskのほかにもコルーチンを使う方法や、C#標準のTaskを使う方法などがあります。

その中でUniTaskを使用するのは、UniTaskは標準のTaskをUnity向けに最適化したものであるためパフォーマンスが高いこと、ゲーム開発のために有用な機能を多数そろえていることの2点が主な理由です。

UniTaskのインポート

この後、実際にコードを書いてUniTaskの使い方を学びますが、まずはライブラリをプロジェクトにインポートしてUniTaskを使えるようにしましょう。

UniTaskを導入するもっとも簡単な方法は、.unitypackageファイルをプロジェクトにインポートすることです。下のリンクからGitHubの配布ページにアクセスすると、Releaseにおいてある.unitypackageファイルが入手できます。

GitHub - Cysharp/UniTask: Provides an efficient allocation free async/await integration for Unity.
Provides an efficient allocation free async/await integration for Unity. - Cysharp/UniTask

返り値のないUniTask

遅延付きメッセージ表示プログラム

UniTaskを使った最初の非同期プログラミングとして、秒数とメッセージを指定し、その秒数が経過した後にメッセージをコンソールに表示する非同期メソッドを作成してみます。

次の手順に従い、サンプルコードを実行しましょう。

  • ヒエラルキーウィンドウに新しい空オブジェクトを配置
  • 以下のC#スクリプトを作成し、オブジェクトにアタッチ
  • 実行
遅延付きメッセージ表示プログラム
using Cysharp.Threading.Tasks;
using System;
using UnityEngine;

public class SampleManager : MonoBehaviour
{
    private void Start()
    {
        Debug.Log("Program started...");
        LogDelayedMessageAsync(3.0f, "Hello, UniTask1!").Forget();
        LogDelayedMessageAsync(3.0f, "Hello, UniTask2!").Forget();
        LogDelayedMessageAsync(3.0f, "Hello, UniTask3!").Forget();
    }

    private async UniTask LogDelayedMessageAsync(float waitTime, string message)
    {
        await UniTask.Delay(TimeSpan.FromSeconds(waitTime));
        Debug.Log(message);
    }
}
C#

上記の手順に従うと、実行開始から3秒後に3つのメッセージがコンソールへ出力されます。

遅延付きメッセージ表示プログラム 実行結果

指定する時間を変えると、メッセージが表示されるタイミングが変化することも確認しておきましょう。

遅延付きメッセージ表示プログラム2
private void Start()
{
    Debug.Log("Program started...");
    LogDelayedMessageAsync(3.0f, "Hello, UniTask1!").Forget();
    LogDelayedMessageAsync(2.0f, "Hello, UniTask2!").Forget();
    LogDelayedMessageAsync(1.0f, "Hello, UniTask3!").Forget();
}
C#
遅延付きメッセージ表示プログラム 実行結果その2

非同期処理は、その実行の完了を待たずに次の処理を開始することができます。

1つ目の例では3つのメッセージが同時に表示されており、2つ目の例では3つのメッセージがプログラム順とは逆順で表示されています。これは、後続のプログラムが前のプログラムの完了を待たずに実行されていることを示しています。

非同期メソッドのメソッド名

内部で非同期処理を行う非同期メソッドの名前について、その末尾に”Async”を付けて明示的に非同期メソッドであることを示すことは、よくある慣習です。

UniTask構造体

UniTaskにおいて非同期メソッドを実装するときは、基本的にはその返り値の型をUniTaskとします。UniTaskはC#標準のTaskをUnity向けに最適化したもので、Cysharp.Threading.Tasks名前空間で定義されています。

非同期メソッド、つまり非同期処理は、その完了を待たずに次の処理を開始できることが特徴ですが、UniTaskをメソッドの返り値の型とすることで、その非同期メソッドの完了を待機することもできます。そして、返り値の型がUniTaskであるような非同期メソッドのことを、ここでは単にUniTaskと呼びます。

ここまでで、UniTaskといったときに、文脈によって以下の三つの意味に分かれることに注意してください。

  • ライブラリとしてのUniTask
  • 返り値の型としてのUniTask
  • 非同期処理の一種としてのUniTask

サンプルコードで使用されているUniTaskは、LogDelayedMessageAsyncUniTask.Delayの2つです。後者はライブラリのUniTaskによって提供されている、指定した時間だけの遅延を挿入するUniTaskです。前述の通り、UniTaskは完了を待機することも、待機せずに次の処理を実行することもできますが、サンプルプログラムは、前者の呼び出しに関しては完了を待機せず、後者の呼び出しに関しては完了を待機するようにしています。

UniTaskと非同期メソッドは等価ではない

UniTaskは非同期メソッドの一種であるといえますが、非同期メソッドならば必ずUniTaskというわけではありません。例えば、UniTaskのもととなっているTask型を返り値とするメソッドも非同期メソッドになり、void型が返り値の型であるメソッドも、非同期メソッドになりえます。

ちなみに、void型の非同期メソッドはTask型やUniTask型のそれとは異なり、その完了を待機することができません。

UniTaskの完了を待機しない

UniTaskを呼び出す際、その完了を待機しない場合は呼び出すUniTaskの後ろにForgetを付ける必要があります。これをつけないと、警告が出されます。

Forgetを付けない場合は警告が出る

Forgetの代わりに、アンダースコア(_)に対してUniTaskを代入しても良いです。

Forgetの代わりにアンダースコアを使う
private void Start()
{
    Debug.Log("Program started...");
    _ = LogDelayedMessageAsync(3.0f, "Hello, UniTask1!");
    LogDelayedMessageAsync(2.0f, "Hello, UniTask2!").Forget();
    LogDelayedMessageAsync(1.0f, "Hello, UniTask3!").Forget();
}
C#

UniTaskの完了を待機する

UniTaskの完了を待機するには、awaitを使用します。

LogDelayedMessageAsyncの中では、UniTask.Delayの次にDebug.Log が呼び出されていますが、UniTask.Delayawait付きで呼び出しているため、その非同期処理が完了するまで、つまり指定の秒数が経過するまでDebug.Logは呼び出されません。

また、あるメソッドの中でawaitを用いて何かしらの非同期処理の完了を待機する場合、そのメソッドにはasyncを付ける必要があります。

試しに、LogDelayedMessageAsyncの完了を待機してみましょう。サンプルコードのStartを以下のように変更します。

LogDelayedMessageAsyncメソッドの完了を待機する
private async void Start()
{
    Debug.Log("Program started...");
    await LogDelayedMessageAsync(3.0f, "Hello, UniTask1!");
    await LogDelayedMessageAsync(2.0f, "Hello, UniTask2!");
    await LogDelayedMessageAsync(1.0f, "Hello, UniTask3!");
}
C#

awaitを内部で使用しているため、Startにはasyncが付与されています。

実行結果は1つ目の例2つ目の例とは異なり、メッセージがプログラム順の通りに表示されるようになります。これは、各LogDelayedMessageAsync の呼び出しについて、その完了がawait によって待機されるようになったからです。

UniTaskの完了を待機した場合の実行結果
await可能なオブジェクト

UniTaskの完了をawaitで待機できるのは、UniTask型に対して、Awaiterと呼ばれるオブジェクトを返すためのGetAwaiterメソッドが実装されているからです。AwaiterGetAwaiterはC#の機能であるため、UniTaskオブジェクト以外でも、このメソッドを実装するオブジェクトはawaitによってその完了を待つことができます。

UniTaskは様々なオブジェクトに対してのAwaiterおよびGetAwaiterを提供しているため、UniTaskオブジェクト以外にも、いろいろなオブジェクトをawaitすることが可能です。例えば、アニメーションをスクリプトベースで作成するためのライブラリであるDOTweenとの連携は非常に強力です。

変数を介したawait

UniTaskは、一度変数に格納してから、その完了を待機することもできます。以下のコードを試してみましょう。

変数を介して完了を待機する
private async void Start()
{
    Debug.Log("Program started...");
    var task1 = LogDelayedMessageAsync(3.0f, "Hello, UniTask1!");
    var task2 = LogDelayedMessageAsync(2.0f, "Hello, UniTask2!");
    var task3 = LogDelayedMessageAsync(1.0f, "Hello, UniTask3!");
    await task1;
    await task2;
    await task3;
}
C#

この例ではawaitで完了を待機しているのにもかかわらず、 前セクションの例とは異なり、メッセージの表示はプログラム順でなくなってしまいました。

これは、変数にUniTaskを代入した時点でその実行が開始されてしまっているからです。基本的に、UniTaskの実行が開始されるのはそれをawaitした時点ではなく、メソッドを呼び出した時点です。awaitの役割は、あくまでawaitされているUniTaskが完了するまで待機させることだけです。

メソッドの呼び出しと同時にその完了を待機したい場合は直接メソッド呼び出しをawaitし、メソッド呼び出しとその完了を待機するタイミングをずらしたい場合は、変数を介してawaitするとよいでしょう。

awaitは1度まで

1つのUniTaskは、基本的に1度までしかawaitできません。以下のようなコードはエラーとなります。どうしても同一のUniTaskを複数回awaitする必要がある場合は、Preserveという特別なメソッドを使用する必要があります。

1つのUniTaskを2度awaitする
var task1 = LogDelayedMessageAsync(3.0f, "Hello, UniTask1!");
await task1;
await task1;
C#
await時点で初めて実行する

UniTask.Deferを使用すると、UniTaskがawaitされるまで、その実行タイミングを遅らせることができます。以下のコードは、前セクションの例と同様の結果になります。ちなみに、複数回awaitしたい場合はUniTask.Lazyというメソッドを使用します。

Deferを使用してawait時点で実行を開始する
private async void Start()
{
    Debug.Log("Program started...");
    var task1 = UniTask.Defer(() => LogDelayedMessageAsync(3.0f, "Hello, UniTask1!"));
    var task2 = UniTask.Defer(() => LogDelayedMessageAsync(2.0f, "Hello, UniTask2!"));
    var task3 = UniTask.Defer(() => LogDelayedMessageAsync(1.0f, "Hello, UniTask3!"));
    await task1;
    await task2;
    await task3;
}
C#

UniTaskVoid

あるUniTaskについて、そのUniTaskがawaitされることがない場合、より軽量なUniTaskVoidを使用することができます。例えば、次のコードは正常に動作し、 初めの例と同じ結果になります。返り値の型をUniTaskVoidとした場合、呼び出し側でawaitすることはできないことに注意してください。

UniTaskVoidで返り値の型を置き換える
using Cysharp.Threading.Tasks;
using System;
using UnityEngine;

public class SampleManager : MonoBehaviour
{
    private void Start()
    {
        Debug.Log("Program started...");
        LogDelayedMessageAsync(3.0f, "Hello, UniTask1!").Forget();
        LogDelayedMessageAsync(3.0f, "Hello, UniTask2!").Forget();
        LogDelayedMessageAsync(3.0f, "Hello, UniTask3!").Forget();
    }

    private async UniTaskVoid LogDelayedMessageAsync(float waitTime, string message)
    {
        await UniTask.Delay(TimeSpan.FromSeconds(waitTime));
        Debug.Log(message);
    }
}
C#

Startメソッド内で何かしらの非同期処理をawaitする場合、返り値の型はUniTaskVoidとすることができます。例えば、以下のコードは、返り値をUniTaskとした場合と同じ結果になります。

Startメソッドの返り値の型をUniTaskVoidで置き換える
private async UniTaskVoid Start()
{
    Debug.Log("Program started...");
    await LogDelayedMessageAsync(3.0f, "Hello, UniTask1!");
    await LogDelayedMessageAsync(2.0f, "Hello, UniTask2!");
    await LogDelayedMessageAsync(1.0f, "Hello, UniTask3!");
}
C#

UniTaskVoidは、voidのUniTask版であるといえます。

返り値のあるUniTask

遅延付き加算器の実装

前章では、指定した秒数だけ遅延をさせてメッセージを表示させるUniTaskを実装しました。このUniTaskは、呼び出しとその完了の待機ができるだけで、何も値を返しません。

この章では、返り値のあるUniTaskを扱います。

例として、遅延付きの加算メソッドを実装します。以下のスクリプトを作成し、実行してみましょう。

遅延付き加算器プログラム
using Cysharp.Threading.Tasks;
using System;
using UnityEngine;

public class SampleManager : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        Debug.Log("Program started...");
        var result = await AddNumbersAsync(1, 2);
        Debug.Log($"Result: {result}");
    }

    private async UniTask<int> AddNumbersAsync(int n1, int n2)
    {
        await UniTask.Delay(TimeSpan.FromSeconds(2.0f));
        return n1 + n2;
    }
}
C#
遅延付き加算プログラムの実行結果

AddNumbersAsyncの完了には数秒かかり、その後はパラメーターとして渡した二つの整数が足された結果が取得できていることがわかります。

UniTask<T>ジェネリック構造体

返り値のあるUniTaskを扱うには、メソッドの返り値の型をUniTask<T>とします。Tには、返り値の型を指定します。

asyncawaitの扱いは、返り値のないUniTaskと同様です。メソッド内でawaitを使用する場合、そのメソッドにはasync を付ける必要があります。

返り値は、呼び出したUniTaskにawaitを付けて変数に代入することで受け取ることができます。

返り値のないUniTaskの扱いを理解できていれば、返り値のあるUniTaskの扱いはそう難しくはありません。UniTask<T>は実行結果を受け取れるというだけで、処理が実行されるタイミングや、awaitの回数に関する制約などは同じです。

変数を介したawait

返り値のないUniTask同様、返り値のあるUniTaskも変数を介してその完了を待機することが可能です。次のサンプルコードは、先ほどの例 と同じ結果になります。

変数を介した完了の待機
private async UniTaskVoid Start()
{
    Debug.Log("Program started...");
    var task = AddNumbersAsync(1, 2);
    var result = await task;
    Debug.Log($"Result: {result}");
}
C#
返り値のあるUniTaskの開始タイミングを遅らせる

AddNumberAsyncは、taskに代入した時点で処理が開始されています。awaitされるまで処理の開始を遅らせたい場合は、ジェネリック版のUniTask.DeferであるUniTask.Defer<T>を使用します。(UniTask.Lazy<T>も使えます。)

演習

名前付きタイマー

整数で秒数を指定し、その分だけカウントを行うタイマーを作りましょう。1秒ごとに、残り秒数とタイマー名をコンソールに表示させてください。

以下のテンプレートを使用します。

名前付きタイマープログラムのテンプレート
using Cysharp.Threading.Tasks;
using System;
using UnityEngine;

public class SampleManager : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        Debug.Log("Program started...");
        var timerA = StartTimerAsync("Timer A", 3);
        var timerB = StartTimerAsync("Timer B", 5);
        await timerA;
        await timerB;
        Debug.Log("Program finished!");
    }

    private async UniTask StartTimerAsync(string name, int seconds)
    {

    }
}
C#
名前付きタイマープログラムの実行例

n入力遅延付き加算器

任意の数の小数を引数にとり、それらをすべて足した結果を返す加算器を作りましょう。ただし、計算には引数として与えた小数1つごとに1秒の遅延がかかるものとします。

以下のテンプレートを使用します。

n入力遅延付き加算器プログラムのテンプレート
using Cysharp.Threading.Tasks;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

public class SampleManager : MonoBehaviour
{
    private async UniTaskVoid Start()
    {
        Debug.Log("Program started...");
        var result1 = await SumAsync(new float[] { 1.6f, 5.4f, 3.0f });
        Debug.Log($"Result1: {result1}");
        var result2 = await SumAsync(new float[] { 2.2f, -5.3f, 2.5f, -7.9f, -6.5f });
        Debug.Log($"Result2: {result2}");
        Debug.Log("Program finished!");
    }

    private async UniTask<float> SumAsync(IEnumerable<float> numbers)
    {

    }
}
C#
n入力遅延付き加算器プログラムの実行例

解答例

名前付きタイマー
名前付きタイマーの解答例
private async UniTask StartTimerAsync(string name, int seconds)
{
    Debug.Log($"{name} started...");
    while (seconds > 0)
    {
        Debug.Log($"{name}: {seconds} sec.");
        seconds--;
        await UniTask.Delay(TimeSpan.FromSeconds(1.0f));
    }
    Debug.Log($"{name} finished!");
}
C#
n入力遅延付き加算器
n入力遅延付き加算器の解答例
private async UniTask<float> SumAsync(IEnumerable<float> numbers)
{
    await UniTask.Delay(TimeSpan.FromSeconds(1.0f * numbers.Count()));
    return numbers.Sum();
}
C#

コメント