Taskを使って、絶対バグる連打・同時タップの苦しみから解放されよう

# 前書き

この記事は、2023のUnityアドカレの12/5の記事です。
今年は、完走賞に挑戦してみたいと思います。Qiita君ぬい欲しい！

https://qiita.com/advent-calendar/2023/present-calendar#qiita_award

# TL;DR;

```csharp
class AsyncButton
{
    public event Func<Task> onClick
    {
        add => m_OnClick.Add(value);
        remove => m_OnClick.RemoveAll(d => d == value);
    }
    readonly List<Func<Task>> m_OnClick = new ();
    
    public async void Press()
    {
        var raycaster = GetCompornentInParent<GraphicRaycaster>();
        raycaster.enabled = false;
        await Task.WhenAll(m_OnClick.Select(d => d()));
        raycaster.enabled = true;
    }
}
```

# はじめに

当然のことですが、ゲームというのは、ユーザーからの入力に対して何らかの応答を繰り返すことで成立しています。しかし、その応答は必ずしもすぐに終わるものではなく、サーバーとの通信や、アニメーションを伴うことも多いでしょう。その処理が終わっていない間に、ユーザーはもう一度ボタンを押してしまうかもしれませんし、あるいはほかのボタンと同時押しをされてしまうかもしれません。

2つの同時・連続入力から、2つの処理が、並行して立ち上がってしまった場合、正しい動作を保証するのは困難です。ここでは、そのような、連打入力や同時入力を防ぐ方法を考えてみましょう。

基本的にはuGUIを例にとっていきますが、入力を受けて、非同期で何か応答をする場合、全般に応用可能です。

```csharp
IEnumerator OpenRanking(string uri)
{
    using UnityWebRequest webRequest = UnityWebRequest.Get(uri);
    yield return webRequest.SendWebRequest();
    OpenDialog(RankingData data); // 同時に一つしか開けないものとする
}

void 
buttonA.onClick += () => StartCoroutine(OpenRanking("https://example.com/world"));
buttonB.onClick += () => StartCoroutine(OpenRanking("https://example.com/japan"));

buttonA.Press();
buttonB.Press();
// 表示されるのは、WorldRanking？JapanRanking？
```

# 処理中フラグ

もっとも愚直に思いつくであろう解決策は、処理中フラグを作ってしまうことです。

```csharp
bool processing = fasle;
IEnumerator OpenRanking(string uri)
{
    if(processing is false) yield break;

    processing = true;
    using UnityWebRequest req = UnityWebRequest.Get(uri);
    yield return req.SendWebRequest();
    OpenDialog(req.downloadHandler.text); // 同時に一つしか開けないものとする
    processing = false;
}
```

これでも機能としては十分です。

しかし、保守の観点ではどうでしょうか？全部のコールバックに、このフラグのチェックと上げ下げを記述しないといけません。もし十字キーなど、常時入力を受け付けているものなども、全部仕込む必要があります。さすがに辛いですよね。

また、ボタンそのものは押せてしまう（コールバックが中止されるだけ）ため、見た目上ボタンは凹んだのに応答はないというギャップができてしまいます。

# バリアを張る

UIの上に透明の板を置くことで、入力をブロックしてしまうという方法もあります。

BarrierImageEnableは、RaycastBlockをOnにしたフルスクリーンより大きいImageで最前面に作っておきます。

```
UIManager
  ├ Canvas (CanvasOrder=0)
  │  ├ ButtonA
  │  └ ButtonB
  ├ Canvas (CanvasOrder=1000)
  └ UIManager
　  └ BarrierImage (≥ FullScreen)
```

```csharp
bool processing = fasle;
IEnumerator OpenRanking(string uri)
{
    UIManager.BarrierImageEnable = true;
    using UnityWebRequest req = UnityWebRequest.Get(uri);
    yield return req.SendWebRequest();
    OpenDialog(req.downloadHandler.text); // 同時に一つしか開けないものとする
    UIManager.BarrierImageEnable = false;
}
```

少しコード量は減りました。単純なフラグに比べ、フラグチェックの分をUISystem側がやってくれるのです。
それでも、BarrierImageEnableを上げ下げするのが厄介ですね。

# うっかり剥がされる/剥がし忘れる問題

今のところ、true/falseの間には3行しかありませんが、機能を追加していくにつれて、長くなっていくかもしれません。サブルーチンとして別メソッドに切り出されるかもしれませんし、サブルーチンは使いまわされるかもしれません。

サブルーチン側に、バリア操作が取り込まれてしまい、気づいたら解除されているなんてことも…逆も然りです。

```csharp
IEnumerator OpenRankingA(string uri)
{
   // 長いのでサブルーチン化した
   yield return Subroutine(url);
}

IEnumerator OpenRankingB(string uri)
{
   UIManager.BarrierImageEnable = true;
   // サブルーチン使いまわそう！
   yield return Subroutine(url);
   
   yield return new WaitForSeconds(1); // ここ、もう剥がされちゃってる
   UIManager.BarrierImageEnable = false;
}

IEnumerator Subroutine(string url)
{
    UIManager.BarrierImageEnable = true;
    using UnityWebRequest req = UnityWebRequest.Get(uri);
    yield return req.SendWebRequest();
    OpenDialog(req.downloadHandler.text); // 同時に一つしか開けないものとする
    UIManager.BarrierImageEnable = false;
}
```

# 処理の完了をTaskに詰め込む

フラグやバリアの上げ下げをアプリケーション側からやるから、危うくなってしまいます。こういうのは、ライブラリ側でやるのがいいんじゃないか、ということが提案したいわけです。

ということで、ボタン側でイベントを呼び出す際に、非同期デリゲートを呼び出すようにします。そして、それの呼び出し前と完了後に、上げ下げを行うわけです。

バリアを貼ってもよいのですが、GraphicRaycasterのON/OFFの方が無駄がないので、こちらを使っています。

```csharp
class AsyncButton
{
    public event Func<Task> onClick
    {
        add => m_OnClick.Add(value);
        remove => m_OnClick.RemoveAll(d => d == value);
    }
    readonly List<Func<Task>> m_OnClick = new ();
    
    public async void Press()
    {
        var raycaster = GetCompornentInParent<GraphicRaycaster>();
        raycaster.enabled = false;
        await Task.WhenAll(m_OnClick.Select(d => d()));
        raycaster.enabled = true;
    }
}
```


実はeventって、Actionだけではなく、Func（戻り値のあるデリゲート）も使えます。ただし、`Func<Task>+Func<Task>`としてしまうと、~~`Task.WhenAny`になってしまうので~~、`Task.WhenAll`になるようにアクセサーで工夫しています。

    追記: `Task.WhenAny`の挙動ではなく、最後に足されたDelegateの戻り値が採用されるようでした
    


あとはonClickに刺すデリゲートでちゃんと非同期をawaitするようにしていけば、ちゃんと処理の前に操作が禁止され、処理が完了したときに操作が再度可能になることが保証できます。

```csharp
asyncButton.onClick += async () => OpenRankingA(URL);

async Task OpenRankingA(string uri)
{
   // サブルーチン使いまわそう！
   await Subroutine(url);
   await PlayCongraturationEffectAnimation();
}

async Task Subroutine(string url)
{
    using UnityWebRequest req = UnityWebRequest.Get(uri);
    await req.SendWebRequest();
    OpenDialog(req.downloadHandler.text); // 同時に一つしか開けないものとする
}
```