【Unity】超便利! UniTask を使おう!
こんばんは! 今日は内部的なソースコードの改善をしており、せっかくなのでそのことに関連した技術記事を書こうと思います。
今回のお題は UniTask という非同期ライブラリ です!
インストール
まぁなにはともあれインストールしましょう。 リリース一覧から最新の .unitypackage ファイルを探してダウンロードし、いつものようにインストールしてください。
そもそも非同期って?
非同期自体はありふれた概念なんですが、知らない方も多いと思うので、軽く説明します。
たとえば「ローディング画面」を考えます。ローディング画面ですることは主にふたつ。
- 画面にプログレスバーを表示する。
- バックグラウンドで、画像や音楽を読み込むなどの処理をする。
ということです。
重要なのは、このふたつを同時に実行しなければならない、ということです。
多くのプログラミング言語は、一度にひとつの処理しかできません。ですから、それなりに高度なプログラミングをする必要があります。
こういったものを実装する方法としては、いくつか種類があるのですが、
- 「画像の1/N枚目を読み込む→プログレスバー更新処理→画像の2/N枚目を読み込む→プログレスバー更新処理→……→画像のN/N枚目を読み込む→完了処理」というように交互に進めるなど、工夫して実装する
- スレッド と呼ばれる並列・並行処理の機構を使う
- Unity なら、コルーチン(StartCoroutine)を使う
などの方法があります。
非同期は、技術的にはもっと詳細な内容はありますが、基本的にはこういった方法のひとつ、という捉え方をしておけばよいでしょう。
一番簡単な UniTask の使い方
一番簡単な、と言いつつ結構複雑なんですが(
とりあえず、以下のコードを見てください。このコードを実行すると、 Start → Test A/1 → Test B/1 → Test A/2 → Test B/2 → Test A/3 → Test B/3 → End と、 0.5 秒ごとに表示されます。
using System;
using UnityEngine;
using Cysharp.Threading.Tasks;
public class TestScript : MonoBehaviour {
async void Start() {
Debug.Log("Start");
await UniTask.Delay(TimeSpan.FromSeconds(0.5f));
await UniTask.WhenAll(TestA(), TestB());
await UniTask.Delay(TimeSpan.FromSeconds(0.5f));
Debug.Log("End");
}
async UniTask TestA() {
Debug.Log("Test A/1");
await UniTask.Delay(TimeSpan.FromSeconds(1.0f));
Debug.Log("Test A/2");
await UniTask.Delay(TimeSpan.FromSeconds(1.0f));
Debug.Log("Test A/3");
}
async UniTask TestB() {
await UniTask.Delay(TimeSpan.FromSeconds(0.5f));
Debug.Log("Test B/1");
await UniTask.Delay(TimeSpan.FromSeconds(1.0f));
Debug.Log("Test B/2");
await UniTask.Delay(TimeSpan.FromSeconds(1.0f));
Debug.Log("Test B/3");
}
}
今回使った機能の説明!
上のコードで、以下の5つの要素を使っています。順に説明します。
- async void
- async UniTask
- await
- UniTask.Delay
- UniTask.WhenAll
async void
これは少し特殊な機能で、 Start, Awake, Update 等の Unity のイベントを非同期化するために使います(※)。
(※正確にはもっと汎用的な機能ですが、説明がややこしいので、この記事ではこう覚えてください。基本的に、非同期関数を定義する場合は後述の async UniTask を使ってください。 async void についてもっと詳しく知りたければ、多くの人が記事を書いてくださっているので、読まれるとよいでしょう)
async UniTask
非同期関数を定義する構文です。戻り値がない場合は async UniTask 、ある場合は async UniTask<int> のように使います。
async UniTask F() {}
async UniTask<int> G() { return 0; }
await
非同期関数の実行の終了を待つために使います。
たとえば、
await UniTask.WhenAll(TestA(), TestB());
というのは、 UniTask.WhenAll(TestA(), TestB())
の実行の終了を待つ、という意味です。
await TestA();
await TestB();
とすれば、 TestA の実行がすべて終わってから TestB を実行するという意味になり、実行順は Start → Test A/1 → Test A/2 → Test A/3 → Test B/1 → Test B/2 → Test B/3 → End となります。
TestA() と TestB() が同時に実行されてほしいのに、上記のように書いてしまうというのは本当にありがちなミスですので、気をつけましょう。 TestA() と TestB() を同時に実行するには、 WhenAll を使う必要がある、と覚えて帰ってくださいね。
await を書かなくても動くが…
なお、 await が「待つ」動作をするので、書かなければ待たないことになります。即ち以下のように書くと WhenAll を使った場合と 似た 動作になりますが…
TestA();
TestB();
これはあまりよくありません。実行順としては Start → Test A/1 → Test B/1 → End → Test A/2 → Test B/2 → Test A/3 → Test B/3 となり、 End が先に実行されてしまいます。
じゃあ End がなかったらいいの? ……というと、いいっちゃいいと思うのですが、個人的にはあまりお勧めしません。少なくとも、このコードの意味をよく理解してから書いたほうがよいでしょう。
非同期関数の呼び出し時には、原則として await をつける、と覚えたほうがいいと思います。
Delay
指定の時間後に終了する非同期関数を生成します。 TimeSpan.FromSeconds(秒数) とセットで覚えてください。
// 0.5 秒待つ
await UniTask.Delay(TimeSpan.FromSeconds(0.5f));
Delay 自体が待ってくれるわけではありません。あくまで「待つ」処理をするのは await のほうであり、 Delay 自体は指定の時間後に終了する非同期関数であるというだけです。
ですので
UniTask.Delay(TimeSpan.FromSeconds(0.5f));
と、 await を書かないと待ってくれませんので、注意しましょう。
また、このことを利用して、
await UniTask.WhenAll(F(), TimeSpan.FromSeconds(0.5f));
というように、 F の実行をしつつ、最低でも 0.5 秒は待つ、という処理をするというテクニックもあります。
WhenAll
WhenAll は、ふたつの非同期関数を同時に実行し、その両方が終了するのを待つ関数です。
非同期処理をする上で非常に大事な関数です! 頻出します。
WhenAll で各関数の戻り値を使いたい場合には
var (x, y) = await UniTask.WhenAll(F(), G());
と書きます(※両方とも UniTask<int>
のように戻り値を持つ必要があります)。
~他にも色々~
実践的には、もっといろいろ機能が使いたい場面も多いと思います。
UniTask.WaitUntil
WaitUntil は極めて実用的な関数で UniTask 非対応な様々なアセットを UniTask 化するときに頻出します。
たとえば 宴 というアセットには IsWaitBootLoading という、エンジン自体の初期化が終了したかどうかを判定できるフラグがあるのですが、これを UniTask で待つ場合には以下のように書くことができます。
await UniTask.WaitUntil(() => !advEngine.IsWaitBootLoading);
ToCoroutine()
実際には、 Unity のコルーチンと相互運用したい場合があるというのが現実であり、そういう場合に UniTask をコルーチン化することができます。
IEnumerator.ToUniTask
逆に Unity のコルーチンを UniTask 化したいという場合には、 ToUniTask を使います。
UniTask.Yield
これは少し難しいですが、 Unity のイベントにあわせて実行したい場合に便利です。既存のコルーチンを UniTask で書き直すときに最も汎用的なメソッドです。
たとえば以下のようなコルーチンを考えます。
IEnumerator F(Func<bool> g) {
while (g()) {
yield return null;
}
}
すると、これは下記のように書き直すことができます。
async UniTask F(Func<bool> g) {
while (g()) {
await UniTask.Yield(PlayerLoopTiming.Update);
}
}
コルーチンの yield return null
というのは、次のフレーム(Update の実行)まで処理を中断するという意味です。つまり while の各ループが 1 フレームに 1 回実行されることになります。
await UniTask.Yield(PlayerLoopTiming.Update) も同様に、次のフレームまで待つ処理になります。さらに UniTask では、 PlayerLoopTiming という関数で色々なタイミングを指定することもできます。
これを利用すれば、 Unity の Update を使わなくても毎フレーム更新する処理というのが書けます。
async UniTask F() {
while (true) {
// Update 相当の処理をする
await UniTask.Yield(PlayerLoopTiming.Update);
}
}
おわりに
ひとまず、以上です!
いつもは視覚的にわかりやすい進捗記事を書いているのですが、ここ数日は内部的な改善に尽くした関係上、こんな記事になりました。こんな記事も需要ありますかね?
ゲーム本体の改善としては、この記事にあるように今までコルーチンを使っていた部分を UniTask 化した他、前回の記事のように AssetBundle を暗号化したり、最近では この記事 を参考に、宴のアセットを StreamingAssets から読み込むようにしたりしていました。
こういった内部的な改善は、どうしてもわかりにくいので出せるものがなくなってしまい、気が滅入りますね……。とはいえ、改善というならなにか技術記事は書けるだろう、という思いもあり、今回思い切って書いてみた次第です。
この手の記事も今後更新していきたいと思います!