🎄

await using を Unity の自作ダイアログ表示に利用してみる

2021/12/12に公開

はじめに

この記事は C# Advent Calendar 2021 の 12日目 の投稿です。
11日目は @tera1707 さんの「[WPF] 簡単なインベーダー的ゲームを作ってみる」でした。

本稿では、await using を用いたダイアログ表示の方法を紹介します。
Blazor Component とかを例にしても良かったんですが、筆者の主戦場である Unity を題材にさせていただきます。

なお、掲載しているサンプルコードは全て名前空間を省略しておりますが、実際のプロダクトでは名前空間の利用を強く推奨いたします。

非同期ストリームについて

非同期ストリームとは、ものすごく雑に言えば「foreach を非同期にできる機能」みたいな感じです。
詳細な定義や仕様は Microsoft の公式ドキュメントや、岩永さんのサイト ++C++ での解説 をご覧いただくと理解できるかと思います。

非同期ストリーム自体は C# 8.0 の言語機能として採用されており、C# 8.0 は2019年7月にリリースされているので「何を今更…。」と思われるかも知れませんが、Unity だと Unity 2020.2 から利用可能になっているため、Unity 村の住民的には比較的新しい機能だったりしますw
(UniTaskAsyncEnumerable を用いれば、Unity 2020.1 以前のバージョンでも非同期ストリーム的なコトはできますが、本稿では触れません。)

await usingIAsyncDisposable について

本稿で取り扱う await using は、IAsyncDisposable を実装したインスタンスの破棄処理を非同期に行うための構文です。
(厳密に言うと、パターンベースなので IAsyncDisposable を実装せずとも Awaitable なナニカを返す DisposeAsync() メソッドがあれば OK です。)

例えば次のようなクラスがあったとします。

Foo.cs
class Foo : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        // (snip)
    }
}

このとき、次のようなコードを書くと、await using のブロックを抜ける所で Foo.DisposeAsync() が await されます。

async ValueTask FooAsync()
{
    var foo = new Foo();
    await using (foo)
    {
        // (snip)
    }
}

await using するインスタンスにアクセス可能なスコープを抜けた時点で await されるので、次のような書き方も可能です。

async ValueTask FooAsync()
{
    await using var foo = new Foo();
    // (snip)
}

詳しい仕様や使い方は公式ドキュメントに説明を譲るとして、本稿では「Unity でダイアログ的な UI を表示する」というユースケースを題材に、IAsyncDisposableawait using の使い方を紹介します。

await using を使ったダイアログの実装例

要件としては以下のようなものを想定します。

  • ダイアログ Open 時に 1.0sec のアニメーション(フェードイン)を再生
  • ユーザが「OK」ボタンを押下するまで待つ
  • ダイアログ Close 時に 0.5sec のアニメーション(フェードアウト)を再生

デモはこんな感じ。

呼び出し側

Startup.cs
using Cysharp.Threading.Tasks;
using UnityEngine;

public class Startup : MonoBehaviour
{
    [SerializeField] private Dialog dialogPrefab;
    [SerializeField] private Transform parentTransform;

    // UI.Button の onClick の Handler として設定する
    // UnityEvent の仕様として戻り値が設定できないため async void にしている
    public async void OpenDialog()
    {
        await using (var dialog = Instantiate(dialogPrefab, parentTransform))
        {
            var cancellationToken = this.GetCancellationTokenOnDestroy();
            await dialog.InitializeAsync(cancellationToken);
            await dialog.OnClickButtonToCloseAsync(cancellationToken);
        }
        // 後続処理がないため、以下のような書き方も可能
        // await using var dialog = await Instantiate(dialogPrefab, parentTransform);
        // await dialog.InitializeAsync(cancellationToken);
        // await dialog.OnClickButtonToCloseAsync(cancellationToken);
    }
}

ダイアログ側

Dialog.cs
using System;
using System.Threading;
using System.Threading.Tasks;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;

// IAsyncDisposable は実装不要だが、型の宣言を見れば await using を期待しているコトを明示できるので実装している
public class Dialog : MonoBehaviour, IAsyncDisposable
{
    private const double AnimationDurationOpen = 1.0;
    private const double AnimationDurationClose = 0.5;

    private static readonly int AnimatorTriggerOpen = Animator.StringToHash("Open");
    private static readonly int AnimatorTriggerClose = Animator.StringToHash("Close");

    [SerializeField] private Animator animator;
    [SerializeField] private Button buttonToClose;

    // Button を public property として公開して、外側から Button.OnClickAsync() を呼ばせる手もある
    public UniTask OnClickButtonToCloseAsync(CancellationToken cancellationToken = default) =>
        buttonToClose.OnClickAsync(cancellationToken);

    public async UniTask InitializeAsync(CancellationToken cancellationToken = default)
    {
        animator.SetTrigger(AnimatorTriggerOpen);
        // Animator Behaviour とかを使ってステート変化を監視するとかもアリ
        await UniTask.Delay(TimeSpan.FromSeconds(AnimationDurationOpen), cancellationToken: cancellationToken);
    }

    public async ValueTask DisposeAsync()
    {
        animator.SetTrigger(AnimatorTriggerClose);
        // Animator Behaviour とかを使ってステート変化を監視するとかもアリ
        await UniTask.Delay(TimeSpan.FromSeconds(AnimationDurationClose));
        // お掃除
        Destroy(gameObject);
    }
}

ポイントとしては次のような点が挙げられます。

  • ダイアログを閉じるためのボタン処理や、閉じたあとのお掃除はダイアログ側の責務として実装する
  • ダイアログの閉じ待ちは public メソッドとして公開して、呼び出し側で待てるようにする

多少丁寧に書いたのでコードが長くなっていますが、要点は一番最後の DisposeAsync() です。
こうすることで、await using を用いている呼び出し側で、ダイアログのフェードアウトと GameObject の破棄が終わるまで待ってくれます。

One more thing

極力 Unity の API を用いるようにしていますが、例えば以下のような interface, class を作り、 Instantiate()InitializeAsync() を一発で実行出来るようにしても良いかもしれません。

IAsyncInitializable.cs
using System.Threading;
using Cysharp.Threading.Tasks;

public interface IAsyncInitializable
{
    UniTask InitializeAsync(CancellationToken cancellationToken = default);
}
PrefabUtility.cs
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;

public static class PrefabUtility
{
    public static async UniTask<T> InstantiateAsync<T>(T prefab, Transform parent, CancellationToken cancellationToken = default) where T : Component
    {
        var instance = Object.Instantiate(prefab, parent);
        if (instance is IAsyncInitializable asyncInitializable)
        {
            await asyncInitializable.InitializeAsync(cancellationToken);
        }
        return instance;
    }
}

こうすると、呼び出し側のコードを以下のように短く出来ます。

Startup.cs
public async void OpenDialog()
{
    await using (var dialog = await PrefabUtility.InstantiateAsync(dialogPrefab, parentTransform))
    {
        var cancellationToken = this.GetCancellationTokenOnDestroy();
        await dialog.OnClickButtonToCloseAsync(cancellationToken);
    }
}

注意点

本稿で紹介したテクニックは Unity 2020.2 以降で利用可能になったものではありますが、少なくとも Unity 2021.1 までは選択可能な API Compatibility Level が .NET Standard 2.0 までとなっているため、標準ライブラリに System.IAsyncDisposable が含まれておらず await using 構文を利用するとコンパイルエラーが発生してしまいます。
この辺りの問題については別途記事を書いておりますので、良ければご参照ください。
Unity 2021.2 は .NET Standard 2.1 を選択可能になっているため、上記記事のワークアラウンドを用いずとも await using を利用出来ることを確認しております。

おわりに

C# というよりは Unity 成分が濃いめな記事になってしまいましたが、一応非同期ストリームの一部機能を紹介しているというコトでご容赦いただければ幸いです。

本記事に掲載したコードを含むサンプルプロジェクトはコチラのリポジトリで公開しております。

というわけで、C# Advent Calendar 2021 12日目の記事でした。
13日目は @soi さんの「Material Design In XAML Toolkitについて」です。

Discussion