async/await,disposableを使って素直で読みやすいコードを書く
async/await、聞いたことはあるけどなんだか難しそう。と思って使ってなかったりしませんか?
これに対する自分の答えは、「難しい使い方で使うと難しい。」
逆に言えば、簡単な使いみちで使う分にはめっちゃ簡単なんですよ。
なので早速今から簡単な使い方を紹介していきます。
どこで使えるか
ズバリ、「状態の制御」。
ゲームを作る上では必須ですが、状態を扱うサンプルコードって意外と少ないんですよね。
状態というのは例えば、「タイトル画面」「インゲーム画面」「リザルト画面」みたいな画面だとか、
RPGなら「マップを歩いている」「村人と会話している」など。
後は「ロード中」なんかも状態と言えますね。
このスライドにおける素直で読みやすいコードとは
色んな思想があるとは思いますが、このスライドにおいて自分が考えている素直で読みやすいコードとは、
「同じ抽象レベルの状態の制御が同じ場所で行われている」こと。
同じ抽象レベルとは、画面であれば「タイトル画面」「インゲーム画面」「リザルト画面」、
キャラクターであれば「歩いている」「攻撃している」などが同じ抽象レベルと言えます。
同じ抽象レベルの状態の制御が同じ場所で行われていると、状態の流れが上から下に素直に読めるようになります。
ゲームじゃなければごく当たり前のことですが、ゲームではフレーム単位で処理を行わないといけないので意外と難しいんですよね。
async/await
「async/await」一体何者なのか?
ゲームプログラミングという文脈において重要なのは「フレームをまたげる」ということです。
(詳しいC#の言語仕様なんかは気になった時に調べてください。)
これを使うことでフレームという単位から逃れることが出来ます。
なので、状態制御においてUpdateメソッドのような「毎フレーム呼ばれる何か」という概念はもう忘れましょう。
このコードはMonoBehaviourのUpdateメソッドのドキュメントに載っていたサンプルから一部抜粋したものです。
updateというメンバ変数を使って、deltaTimeを足していって1を超えたらログを出す、「1秒に1回なにかする」コードです。
private float update;
void Update()
{
update += Time.deltaTime;
if (update > 1.0f)
{
update = 0.0f;
Debug.Log("HP回復");
}
}
今回提案したいコードはこちら。
whileで繰り返して、1秒待ってログを出す。
メンバ変数もなく1つのメソッド内で完結しています。
async UniTaskVoid Start()
{
while (true)
{
await UniTask.Delay(TimeSpan.FromSeconds(1));
Debug.Log("HP回復");
}
}
例えば、カジュアルゲームの画面遷移
具体的な例を見ていきます。
簡単なカジュアルゲームを想像してみてください。
最初にタイトル画面が表示されて、スタートしたらインゲーム画面を表示する。
インゲームでゲームオーバーになったらリザルト画面を表示する。
そして、それを繰り返す。
その制御がこのコードのように、言葉の通りただ順番に書けたら嬉しくないですか?
仕様をそのままコードに落としたかのような、素直なコードを言えるのではないでしょうか。
while (true)
{
var タイトル画面 = new タイトル画面();
await タイトル画面.スタートするまで待つ();
タイトル画面.消す();
var インゲーム画面 = new インゲーム画面();
var スコア = await インゲーム画面.ゲームオーバーまで待つ();
インゲーム画面.消す();
var リザルト画面 = new リザルト画面(スコア);
await リザルト画面.ボタンが押されるまで待つ();
リザルト画面.消す();
}
こうなってると何が嬉しいかというとパッと考えただけでも「デバッグが簡単になる」「仕様変更に強い」という2点が考えられます。
順番に見ていきましょう。
例えば「リザルト画面でスコアが3桁のときだけ表示がバグる」と報告が来たとしましょう。
このコードを初めて見た人でも「リザルト画面から開始するにはこうすればいいんだ」というのがひと目見るだけで分かりますよね。
while (true)
{
/*
var タイトル画面 = new タイトル画面();
await タイトル画面.スタートするまで待つ();
タイトル画面.消す();
var インゲーム画面 = new インゲーム画面();
var スコア = await インゲーム画面.ゲームオーバーまで待つ();
インゲーム画面.消す();
*/
var スコア = new スコア(100);
var リザルト画面 = new リザルト画面(スコア);
await リザルト画面.ボタンが押されるまで待つ();
リザルト画面.消す();
}
次に「ゲームオーバーになった時、インゲーム画面を背景に表示したままリザルト画面を手前に出したい」という仕様変更が入ったとします。
その時も、インゲーム画面を消す処理を、言葉の通り、素直に移動させただけでうまく動くことでしょう。
...
var インゲーム画面 = new インゲーム画面();
var スコア = await インゲーム画面.待つ();
// インゲーム画面.消す();
var リザルト画面 = new リザルト画面(スコア);
await リザルト画面.ボタンが押されるまで待つ();
インゲーム画面.消す();
リザルト画面.消す();
}
例えば、キャラクターの状態制御
もちろん、もっと小さい「キャラクターの状態制御」なんかにも使えます。
移動ボタンが押されたら一歩歩いて、攻撃ボタンが押されたら攻撃する。そして、この2つの状態は同時には発生しない。という制御、よくあると思います。
これも以下のように、Inputを取ってきてawaitで処理をする。
これだけで実現出来てしまいます。
while (true)
{
var input = await Input.Get();
if (input is Move move) await Move(move.Direction);
if (input is Attack) await Attack();
}
disposable
async/awaitと合わせて紹介したいのが、こちらのdisposable。
これ、本来はリソースの開放に使われているものです。
例えばよく見るのはファイルを開いた後、使い終わったらそれを開放する時など。
実態としては、IDisposableというinterfaceがあるだけです。
それぞれのclassで開放処理のメソッドが違うとややこしいので、Disposeというメソッドに後片付けは書きましょう。というルール付けをするためのものになっています。
ですがこれ、始まりがあって終わりがあるものなら何にでも使えるんですよ。
具体的な使い方はこうです。
基本的にコンストラクタに始まりの処理を書いて、Disposeメソッドに終わりの処理を書きます。
class Hoge : IDisposable
{
public Hoge()
{
Debug.Log("始まりの処理");
}
public void Dispose()
{
Debug.Log("終わりの処理");
}
}
さらに便利なのが、これをusingで囲ってやると、usingを抜ける時に勝手にDisposeメソッドが呼ばれるようになります。
これにより、Disposeメソッドを呼び忘れることを防げます。
using (var hoge = new Hoge())
{
Debug.Log("Hogeを使った処理");
} // ここでhoge.Disposable()が呼ばれる。
これが、「一時的な状態」の表現に使えます。
イベントシーン
ゲームに置いて「一時的な状態」とはどういうものか。
例えば「会話イベント」や「ムービー再生中」のような「イベント中」という状態ありますよね。
その時は、移動ボタンを押しても移動できなくしたり、メニューボタンを押してもメニューを開けなくしたり、みたいなことがしたいです。
これは、以下のようなコードで実現出来ます。
イベントシーンメソッド内でボタンを無効にする処理を行い、そこから返したclassのDisposeメソッドで再度有効にする処理を行えば良いのです。
using (イベントシーン())
{
await 会話イベント();
await ムービー();
}
BGMの変更
メニュー画面を開いているときだけ一時的にBGMを変更して、閉じたら元に戻す。なんてこともよくありますよね。
今まで紹介したやり方で書くならこうなります。
using (new BgmChanger("メニュー画面のBGM"))
{
await メニュー画面の処理();
}
このような方法もあります。
AddToを使うことで、GameObjectの寿命にdisposableを紐付けることが出来ます。
こうすると、menuGameObjectが破棄されたら自動的にDisposeされる、ということになります。
var bgm = new BgmChanger("メニュー画面のBGM");
bgm.AddTo(menuGameObject);
ロード中
具体的にどういうコードになるかを詳しく見てみましょう。
例えば、ロード中だけ画面上に「ロード中」と書いたGameObjectのActiveをtrueにする処理を考えてみます。
using (Loading())
{
await スプライトのロード();
await モデルのロード();
}
このLoadingメソッド内はどのように書けるでしょうか?
IDisposable Loading()
{
loadingGameObject.SetActive(true);
return Disposable.Create(() =>
{
loadingGameObject.SetActive(false);
});
}
Loadingメソッドが呼ばれたら、対象の loadingGameObject
のActiveをtrueにする。
そして、返すものはIDisposableなんですけど、UniRxにあるDisposable.Createを使うことで、
わざわざIDisposableなclassを自分で作らなくても後片付けの処理を書くことが出来ます。
こうすることで、
Loadingメソッドが呼ばれて、その内部でloadingGameObjectのActiveがtrueになる。
スプライトのロードが走り、モデルのロードが走り、usingを抜ける時にDisposeが呼ばれて、
loadingGameObjectのActiveがfalseに戻る。
という一連の流れが実装できます。
using
ところでさっきから、
using (Loading())
{
await 時間がかかる処理();
}
のようにusingで囲ってるけれど、
var loading = Loading();
await 時間がかかる処理();
loading.Dispose();
と、自分でDisposeを呼んでやっても同じだよね?と思った方。
usingには嬉しいポイントがあるんです。
例えばこの処理、「キャッシュがあったら時間がかかる処理をスキップする」という処理が後から入った場合、
キャッシュがあったらreturnする。と書いてしまうと
var loading = Loading();
if (キャッシュがあった) return; // Dispose忘れ!
await 時間がかかる処理();
loading.Dispose();
Disposeが呼ばれないケースが出来てしまいます。
そんなとき、usingを使えば
using (Loading())
{
if (キャッシュにあった) return;
await 時間がかかる処理();
}
途中でreturnや例外処理によりusingのスコープを抜けても必ずDisposeが呼ばれます。
なので、基本的にusingとセットで使うことでDisposeの呼び忘れを防ぐことが出来ます。
GameObjectに寿命を紐付けたい、というときにはAddToを使いましょう。
まとめ
- 同じ抽象レベルの状態の制御は同じ場所に書く。
- 状態の制御はUpdateに依存せずasync/awaitで書く。
- 一時的な状態はdisposableで表現する。
これが自分の考える、そして提案したい素直で読みやすいコードということになります。
発表に入り切らなかったおまけ
コルーチンと何が違うの?
- 複数Taskを並列で走らせるような制御がしやすい
- キャンセルの制御がまとも
複数Taskを並列で走らせる制御の例
「アイテムを取ってから10秒間」「ステージクリアするまで」のどちらかを満たしたら、無敵の終了処理(Dispose)が呼ばれる処理。
ステージクリアを例外的な処理と見なすならCancellationToken経由もあり。
using (無敵())
{
await UniTask.WhenAny(
UniTask.Delay(TimeSpan.FromSeconds(10)),
StageClear
);
}
じゃあUpdateはいつ使うの?
GameObjectがそれぞれ状態を持っていて、それらが相互に作用するような場所はUpdateで管理するほうが楽だと思います。
一言でいうと、FPSとか含めた広義の意味でのアクションゲームを作る時。
ECS(EntityComponentSystem)が得意なジャンルとも言えますね。
なので、今日の話はECSとも共存します。
どちらがいい、とかじゃなく、適材適所というわけです。
余談ですが、Unityが公式で作っているECS、あれは “実行速度を求めたECS” なんですよね。
ECSは実行速度はもちろん、”依存を整理して分かりやすくゲーム開発を行うための設計” という側面もあるので、その話もまたどこかでしたいですね。
付録
IDisposableについて抑えておきたいこと
CompositeDisposableで複数のDisposableをまとめれる。
他、便利なやつ
- SerialDisposable
- RefCountDisposable
- CancellationDisposable
IDIsposableを返してることにそもそも気づかない、という事故を防止するには?
ReSharperやRiderを使っているならMustUseReturnValueAttributeをつければオッケー。
返しているDisposableを無視していたら警告が出る。
[MustUseReturnValue]
private IDisposable ReturnDisposable()
{
return Disposable.Empty;
}