Flutterで発生するGlobalKey重複問題の原因と解決策



## はじめに

アプリエンジニアになり、先輩から渡してもらうオンボーディング用のアプリ設計資料を読み込んでいました。
そこで気になる記述がありました。

> 「ref.invalidateが呼び出されると、このProviderの中身はリセットされて、新しいGoRouterインスタンスが作られる。
> その時、古いインスタンスと新しいインスタンスとでrootNavigatorKeyが重複する。」

```dart
# keyは再生成されない
final rootNavigatorKey = GlobalKey<NavigatorState>();

# このProvidarがinvalidateによって再生成される
@riverpod
GoRouter router(RouterRef ref) => GoRouter(
   navigatorKey: rootNavigatorKey,
   routes: <RouteBase>[
      ...$appRoutes
   ]
)
```

GlobalKeyとはウィジェットツリー内で特定のウィジェットとその状態を識別するための鍵。
同じGlobalKeyを持ったウィジェットが、ウィジェットツリー内に２つ以上同時に存在するとアサートエラーが発生します。※1
エミュレータの画面が真っ赤になるアレです。キーがどちらのウィジェットの状態にアクセスしたらいいかわからなくなるからです。

でも、invalidateされたときにnavigatorKeyを保管しているProviderはkeyごと破棄されるよね？
破棄してから再度Providerが読み込まれた時に、rootNavigatorKeyを再生成するはず。なんで重複するんだ？
何回読み返してみてもわからない😭
Flutterのライフサイクルに無知だったので、これを機に学んでみることにしました。

## Flutterの内部構造を理解する

まず、Flutterの基本的な構造から理解していきましょう。FlutterはUIを構成する際に以下のパーツに分かれ、それぞれが連携しています。

- **Widget**: レイアウトの設計図（例：Text('Hello')）。常にイミュータブルで、UIはこれを使うと定義する存在です。
- **Element**: Widget の「ツリー状に存在する場所」や「状態（State）」を管理します。例えば Text ウィジェットの中身の情報や、状態管理されているStatefulWidgetのStateはここに紐づきます。Widgetツリーと呼ばれているものは実質的にElementツリーであり、Elementは担当するウィジェットがWidgetツリーのどこに存在するかを管理しています。
- **RenderObject**: Elementに基づいて、実際に画面上に表示を行います。サイズやレイアウトの計算、描画を担当します。

## 問題の発生メカニズム

実際のアプリケーションでは、以下のような流れで問題が発生します：

```dart
class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    # ここで変更をリッスン
    final router = ref.watch(routerProvider);
    return MaterialApp.router(
      routerConfig: router,
    );
  }
}
```

1. ログイン状態が変わった時にrouterProviderを破棄してMyAppのwatchに変更を通知します：
```dart
ref.invalidate(routerProvider);
```
これにより、routerProviderが破棄される(dispose)。
次回のref.watch(routerProvider)呼び出し時に再生成が行われます。

2. MyAppのbuildが再実行されます。
この時にFlutterの内部処理として以下が行われます
- 古いElementと新しいElementを比較して差分だけを反映します
- ランタイムタイプやキーを比較してウィジェットが持つElementを差分だけ変更します
上記差分比較時にGlobalKeyが重複していると判定されアサートエラーが発生します

## 結論

問題の本質は以下の2点にあります：

1. ref.watchを使っている場合、ユーザーのログイン状態などの変化でproviderが再評価されるタイミングで、新しいウィジェットが作成されるが、古いインスタンスがまだツリー状に残っていることがある。

2. 両方のウィジェットが同じGlobalKeyを使い、ツリー上に同じキーを持つウィジェットが同時に2つ存在してしまい、アサートエラーが発生します。

### 重要な気づき

この問題は`ref.invalidate()`を明示的に呼び出した時に発生します。ただし、以下のような場合にも同様の問題が発生する可能性があります：

1. `routerProvider`内で`ref.watch`や`ref.listen`を使用して他の値の変更を監視している場合
2. その監視している値が変更されると、`routerProvider`が自動的に再評価され、新しい`GoRouter`インスタンスが生成されます
3. この時、古いインスタンスがまだツリー上に残っていると、`GlobalKey`の重複が発生します


## 参考資料

- [Flutterの内部処理についての解説動画](https://www.youtube.com/watch?v=996ZgFRENMs)
- [Flutter公式ドキュメント - GlobalKey](https://api.flutter.dev/flutter/widgets/GlobalKey-class.html)
- [Riverpod公式ドキュメント](https://riverpod.dev/docs/getting_started)

※1. ドキュメントから以下の通り：
> 同じGlobalKeyを持つ2つのウィジェットを同時にツリーに含めることはできません。
> そうしようとすると、実行時にアサートが発生します。 

## 補足：GoRouterとRiverpodの組み合わせについて

この記事では主にFlutterのウィジェットライフサイクルとGlobalKeyの重複問題に焦点を当てていますが、実はGoRouterとRiverpodの組み合わせは結構面倒な問題を抱えています。

正直なところ、GoRouterとRiverpodは相性が良くないと感じています。
特にGlobalKeyの扱いに関しては、以下の2つの選択肢しかないのが現状です。

1. GlobalKeyを使わない設計にする
2. GlobalKeyをRiverpodのProvider内に含めてしまう
- 新しいProviderが作られるたびにGlobalKeyも再生成されてしまい、ウィジェットの特定どころではなくなる
3. GlobalKeyとRouterを別々にRiverpodで管理する
- Providerで管理することが適切かわからないです。(でもキーの役割やドキュメントの推奨事項には合格している。)