Flutter 通知モーダルのミューテックス排他制御

## 2024年7月5日追記
焼き直しですがRustCoreを使用したものも書きました！
もっとシンプルになってます！

https://qiita.com/kitaike/items/40a56fcb98b65c9d552d


## 問題発覚

[CBcloud](https://qiita.com/aipacommander/items/60e4e67a5c9307c89c46#cbcloud%E3%81%A8%E3%81%AF)が配送ドライバー向けに提供しているアプリ『ピックゴーパートナー』では、
特別なオファー通知が来るたびに、モーダルボトムシート（以下ではモーダルと略す）を表示する機能があります。

今回ユーザからの報告で、連続して通知が来た場合の2枚目以降のモーダルにおける、GoogleMapの不具合が発覚しました。

以下のGifは、検証のために自身の端末にFCM通知を2回連続で送ったものです。

モーダル描画時に（厳密には描画完了直後に）、[google_maps_flutter](https://pub.dev/packages/google_maps_flutter)の`cameraMove`メソッドを使用してユーザの現在地を基準とした縮尺に更新する処理が、2枚目のモーダルでは正常に動作していません。拡大されず、日本地図全体が表示されてしまっています。

![no_fix2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/512865/c1b0c779-cadb-d1a1-3e4f-11bc13b184a6.gif)

## なんでこうなった？

調査の結果、2枚のモーダルで`GoogleMapController`の`mapId`の重複が起こっていました。※`mapId`は`cameraMove`メソッドの第一引数です

というのも、カメラ移動は画面描画直後に`WidgetsBinding.instance.addPostFrameCallback`の遅延処理で[実行](https://github.com/flutter/flutter/issues/27936#issuecomment-669836928)しており、その遅延のために複数のモーダルで同一の`GoogleMapController`を参照していたからです。

:::note info
Gifをよく見ると、下の画像のように一気に2枚のモーダルが立ち上がっていることがわかります。
:::

<img width="250"  src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/512865/463e005c-7665-aba0-cdd4-a7438e01f95b.png">


## どうすればいい？
ここまで触れてこなかったですが、そもそも後から来た通知が先（＝画面の最前面）に表示される不具合もあるため、同時描画を防ぐ方向で修正します。


複数のダイアログが重ねて表示されないようにする場合、一般的な対応としては`isShownDialog`のようなフラグを使う方法があると思います。

ただ今回のケースでは、2個目の通知を（スキップするわけではなく）最終的に実行する必要があるため、通知をキューに入れてFIFO（＝先入先出）の形式で処理します。

つまり排他制御 [^1]を利用します。

## 排他制御って……なに？

[wikipedia](https://ja.wikipedia.org/wiki/%E6%8E%92%E4%BB%96%E5%88%B6%E5%BE%A1)によると排他制御とは、
>複数のプロセスが利用出来る共有資源に対し、複数のプロセスからの同時アクセスにより競合が発生する場合に、あるプロセスに資源を独占的に利用させている間は、他のプロセスが利用できないようにする事で整合性を保つ処理の事をいう

また今回とりあげる『[ミューテックス](https://wa3.i-3-i.info/word13360.html)』とは狭義の排他制御とも呼ばれるもので、ざっくりいって「順番待ち排他制御」のことです。

排他制御というと、主にバックエンドのトランザクション管理の文脈で出てくる概念ですが、フロントエンドでもAPIの実行制御では話題になると思います。

実際、次の項で紹介する`Mutex`の標準的な使用目的は、クライアント側でのAPI実行順の整合性を保つことにあります。

## 実装する（しない）

排他制御をゼロから実装するには、私のような頭が悪い人間にとっては骨が折れるので、
**頭のいい人が作ったものをお借りします。**

[google_generative_ai](https://pub.dev/packages/google_generative_ai)パッケージの[mutex.dart](https://github.com/google/generative-ai-dart/blob/main/pkgs/google_generative_ai/lib/src/utils/mutex.dart)です。

これについては以前、別の記事で紹介しているので見てみてください。

https://zenn.dev/cbcloud_blog/articles/7f83c5f402d208

手順としては、

まず[mutex.dart](https://github.com/google/generative-ai-dart/blob/main/pkgs/google_generative_ai/lib/src/utils/mutex.dart)をコピペしてプロジェクト（のlib）配下におきます。

:::note warn
パッケージをimportしてもいいですが、AI関連の機能は使わないため該当ファイルだけコピペします。
:::

次にサービスプロバイダーとして定義します。
※状態管理には[Riverpod](https://riverpod.dev/ja/)を使用
また排他制御は競合する処理のグループ別に行う必要があるので、グループのキーをenumで定義し、Providerは`.family`とします。

```dart:mutex_service.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:myapp/utils/mutex.dart';

//排他制御をするグループ単位のProvider
final mutexServiceProvider =
    Provider.family((ref, MutexGroupKey key) => Mutex());

//グループのキー
enum MutexGroupKey { notifyModal }


```
最後に排他制御をしたい処理（今回はモーダル表示）を`try-finally`で囲ってやり、
`try`直前でロックの取得、`finally`でロックの解放をします。

そうすることで、1つ目の通知は（「待ち」状態のタスクがないので）即時実行、2つ目の通知は1つ目の通知のタスクが完了するまで「待ち」状態とすることができます。


```dart:notification.dart
  final _mutexGroupKey = MutexGroupKey.notifyModal;
  final _mutexService = ref.read(mutexServiceProvider(_mutexGroupKey));
  
  //通知が来たときに実行する関数
  Future<void> _handleMessage(RemoteMessage message) async {
    //ロックの取得
    final lock = await _mutexService.acquire();
    try {
      //モーダル表示
      await showBarModalBottomSheet(ModalArguments(message)); 
    } finally {
      //ロックの解放
      lock.release();
    }
  }

```

簡単ですね。シンプルにできました:tada:

今回はモーダル描画に対して使用しましたが、当然のことながらAPIの実行制御でも同じ手順でいけます:ok_hand:


## 修正

以下のGifでは連続で通知が来た場合も、（一気に2枚開くことなく）1つ目のモーダルを閉じてから2つ目のモーダルを開く動きになっており、GoogleMapのカメラ移動の問題も解決していることがわかります。

![fix2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/512865/454a3e26-4487-bcff-5a37-e4b223bb3276.gif)


## 最後に宣伝
CBcloudではモバイルエンジニア募集中です！

https://herp.careers/v1/cbcloud1025/DwSvmDjmzOlP

沖縄では隔月でFlutterコミュニティ活動をしています！
旅行ついでに立ち寄ってみてください:airplane:

https://flutter-okinawa.connpass.com/

[^1]:もちろん、フラグを利用して2回目の実行を拒否することも立派な排他制御ですが、ここでは後述する狭義の排他制御である、ミューテックスのことを指します