こんにちは。日本トレカセンターでモバイルアプリを開発している新宅です。
私たちの Flutter アプリで、手書きの Riverpod Provider 宣言 261 件を @riverpod によるコード生成へ全面移行しました。あわせて、Riverpod 3 で非推奨になった StateNotifier を継承するクラス 130 件も、すべて Notifier へ書き換えています。
進め方の軸にしたのは、少数を先に移して難所を確かめ、互いに衝突しない PR に分けることでした。この記事では、数百件の移行をレビュー可能な単位に分けるまでに、何を調べ、どう判断したかを紹介します。
想定読者
- Riverpod を使った Flutter アプリで、手書き Provider や
StateNotifierが大量に残っている方 - 数百ファイル規模の機械的なリファクタリングを、レビュー可能な単位に切って進めたい方
Riverpod 3 で顕在化した「破棄済み Ref へのアクセス(Ref disposed)」とその対処は、本記事では扱いません。詳しくはRiverpod 3 移行で遭遇した Ref disposed と、その対処で気をつけたいことで解説しています。
移行の見取り図
最初から PR の本数を決めていたわけではありません。依存を確認し、少数を試すたびに、次の作業の分け方が見えてきました。
以下では、この判断に至った背景から順に説明します。
背景
手書き Provider の表記ゆれを、レビューで指摘し続けていた
アプリはすでに Riverpod 3 へ移行済みでした。しかし Provider はすべて手書きで、型引数や autoDispose・family の付け方が人によってばらつき、レビューのたびに指摘する状態でした。
さらに、宣言の約半数は StateNotifierProvider と StateProvider でした。これらは Riverpod 3 で非推奨となり、legacy.dart に移されています。@riverpod が生成するのは Notifier・AsyncNotifier と関数形式の Provider だけです。つまり StateNotifier は Notifier へ書き換えない限り generator に載せられません。
generator を入れるには Flutter の更新が必要だった
計画書を書き始めたところで、より手前の問題が見つかりました。当時の Flutter 3.44(Dart 3.12)のままでは、riverpod のバージョンを維持したまま riverpod_generator を導入できなかったのです。
| 段階 | 制約 |
|---|---|
riverpod_generator 4.0.8 |
analyzer ^13.0.0 を要求 |
analyzer 13 に対応する freezed
|
4.0.0 のみ |
freezed 4.0.0 |
Dart >=3.13.0 を要求 |
| Dart 3.13 を含む Flutter | 3.47.0 以降 |
riverpod_annotation 4.0.6 |
riverpod 本体を完全一致でピン留め |
古い generator を使う回避策もありました。しかし riverpod_annotation が riverpod 本体をピン留めしているため、riverpod 自体のダウングレードが必要になります。完了したばかりの Riverpod 3 移行を巻き戻すことになるので、この案は採りませんでした。
解決策
1. 依存の連鎖を手元で確かめ、判断理由を残す
pub.dev の依存宣言を読むだけで終わらせず、worktree で Flutter 3.47.1 を入れて、次の 3 段階で確かめました。
- SDK だけ更新する
- codegen ツールチェーンを analyzer 13 系へ上げる
- generator を追加する
結果、riverpod を維持したまま依存を解決でき、既存テスト 4,414 件もすべて通りました。この判断は ADR(Architecture Decision Record) に記録しています。ADR は、技術的な判断について、採用した方針とその理由、見送った選択肢を残す文書です。今回は、Flutter 3.47 への更新が generator 導入の前提であることと、ダウングレード案を却下した理由を残しました。
実際に試したことで、机上では見えなかった副作用も見つかりました。
- Dart 3.13 の
dart formatで、1,969 ファイル中 505 ファイルが変わる(フォーマットだけのコミットに分離した) - Flutter のバージョン宣言が
.tool-versions・mise.toml・ワークフロー 16 本などに散らばっており、更新箇所は 29 か所あった
もう 1 つ大きかったのは、SDK だけを上げた中間状態はビルドできないことです。freezed 3 が生成するコードを、Dart 3.13 が受け付けないためです。しかも *.freezed.dart は解析対象から外しているので、flutter analyze は通ってしまいます。テストを実行して初めて落ちるため、SDK 更新と codegen 更新は 1 つの PR にまとめました。
2. 試験移行で「手数」と「難所」を測る
本番の作業より前に、代表的な形の Provider だけを先に移行する「試験移行」を 2 回行いました。全件へ手を付ける前に、1 件あたりの作業量とつまずく箇所を測るためです。
1 回目(素の Provider など 6 ファイル) では、1 件あたりの作業が次の 4 手で済むことが分かりました。
-
part宣言と import を足す - 宣言を関数かクラスへ書き換える
-
keepAliveを明示する - 不要になった import を外す
生成される Provider 名が手書きの名前と一致するので、呼び出し側とテストは 1 行も変えずに済みました。
// before
final articleRepositoryProvider = Provider<ArticleRepository>((ref) {
return ArticleRepositoryImpl(apiClient: ref.read(apiClientProvider));
});
// after
part 'article_repository.g.dart';
@Riverpod(keepAlive: true)
ArticleRepository articleRepository(Ref ref) =>
ArticleRepositoryImpl(apiClient: ref.read(apiClientProvider));
一番大事な発見は、autoDispose の既定値が逆になることでした。手書きの Provider は keepAlive が既定ですが、@riverpod は autoDispose が既定です。実際、素の Provider は 116 件中 115 件が keepAlive でした。一方で StateNotifierProvider は 129 件中 100 件が autoDispose で、傾向が正反対です。思い込みで書き換えると確実に挙動が変わるので、規約では「毎回どちらかを明示的に決める」ことにしました。
2 回目(StateNotifier の代表 4 形態) では、StateNotifier の移行は素の Provider とはまったく別物だと分かりました。
テストの書き換えが必ず発生します。 生成された NotifierProvider の overrideWith は、Notifier のファクトリだけを差し替え、build() はそのまま実行します。依存グラフを避けたいテストでは、build() を override したスタブが必要です。
約半数で、呼び出し側の改名が発生します。 生成名は「クラス名から末尾の Notifier を除き、Provider を付けたもの」です。数えてみると、109 宣言のうち 51 件(47%)の変数名がこの生成名と一致せず、改名が必要でした。また、private クラス 38 件は public へ変えました。
3. 性質の違いでフェーズを分ける
試験移行の結果をもとに、作業を 2 フェーズに分けました。
| フェーズ | 対象 | 件数 | 性質 |
|---|---|---|---|
| 1 | 素の Provider
|
116 | 宣言を書き換えるだけ。呼び出し側・テストはほぼ変わらない |
| 2 |
StateNotifierProvider / StateProvider
|
129 + 16 |
Notifier API への書き換え、テスト改修、改名を伴う |
フェーズ 1 はディレクトリ単位で 3 本の PR にしました。内訳は repository/ が 63 件・provider/ が 46 件・screen/ と main.dart が 7 件です。repository/ の 63 件は「1 ファイル 1 宣言で、本体は単一のコンストラクタ式」という同じ形だったので、1 本にまとめています。keepAlive の付け忘れは、生成物の isAutoDispose が全件 false になっていることで機械的に確認しました。
4. 件数はスクリプトで数える
移行を進めるうちに、文書によって件数が食い違うようになりました。
- 計画時に数えた件数: 246 件
- 試験移行のときに数え直した件数: 251 件
- 計画書に書いた件数: 261 件
- スクリプトで数えた件数: 262 件
機能開発が並行して進んでいるので、Provider は日々増えていきます。
そこで件数の基準は、app/scripts/provider_inventory.dart という小さなスクリプトの出力だけと決めました。対象は lib/ 配下の生成ファイル以外の .dart で、トップレベルの final x = <Kind>... 1 つを 1 件と数えます。改行をまたいで書かれた .autoDispose と .family も連結して判定し、コメントアウトされた行は数えません。
final declarationPattern = RegExp(
r'^final\s+\w+\s*=\s*(' + _providerKinds.join('|') + r')((?:\s*\.\s*\w+)*)\s*[<(]',
multiLine: true,
);
各 PR の本文には、このスクリプトの出力で「移行前 → 移行後」を書きました。たとえば repository/ の PR では、手書きの合計が 262 → 199、@riverpod 宣言が 7 → 70 です。こうしておくと、レビュアーは差分を全部読まなくても、進み具合と範囲を確認できます。
5. ファイルが衝突しない単位に切る
フェーズ 2 の screen/(ViewModel 約 100 件)は、画面のドメイン単位で 8 本に分けて並行で進めることにしました。ただし、そのまま分けると衝突します。改名が必要な Provider は、複数の画面から参照されているからです。主要な 2 本だけを比べても、17 ファイルが重なっていました。
そこで、改名だけを先に 1 本の PR で済ませました。 @riverpod 化はせず、手書きのまま変数名を生成名に揃えます。
どの PR がどのファイルを触るかを、2 画面の例にすると次のようになります。
先行改名 PR の規模は次のとおりです。
| 件数 | |
|---|---|
| 改名した宣言 | 86 |
| 置換箇所 | 2,193 |
| 変更したファイル | 276 |
改名先の名前が既存のシンボルと衝突しないこと、宣言ファイル以外に同名のローカル変数や名前付き引数がないことは、機械的に確認しました。この PR の後は、各移行 PR が自分の宣言ファイルとそのテストしか触らなくなります。8 本の間でファイルの重なりがゼロになることを実際に確かめてから、並行作業に入りました。
記録が残っている PR では、統合ブランチを取り込むときのコンフリクトは生成物のハッシュ 1 行だけでした。
8 本の実装は、コーディングエージェントに 1 本ずつ任せました。エージェントには、試験移行の結果を反映した規約ファイル(書き換えの対応表と注意点の一覧)を参照させています。できた PR には、人がレビュー指摘への対応やテスト修正のコミットを足してからマージしました。
ブランチは「統合ブランチ + 子 PR」の構成にしました。子 PR はすべて統合ブランチへマージし、一つひとつ個別にレビューします。main へは最後に 1 本の統合 PR で入れました。統合 PR は 744 ファイル・約 3 万行の差分になり、レビューツールの上限を超えます。それでも子 PR 22 本が個別にレビュー済みなので、統合 PR では「注意点が残っていないか」と「規約・CI の内容」に絞ってレビューを依頼できました。
6. 「挙動を変えない」範囲を守る
移行 PR の差分には周辺の行も含まれるので、もともとあった問題にレビューで気づくことがよくありました。await の後に mounted を確認していない、BuildContext を Notifier まで引き回している、などです。
方針は一貫させました。移行 PR は「振る舞いを保ったまま形だけ移す」範囲に限定し、既存の問題は別の作業に切り出しました。 await の後のガードの付け忘れは、ディレクトリ全体で棚卸しして別の PR でまとめて直しました。riverpod_lint が既存の設計を指摘した 2 件は、移行時には理由を書いて抑止し、その後の PR で設計を変えて解消しています。
「触ったついでに直す」を許すと、移行 PR に無関係な差分が入り、レビューの負荷が上がります。数百ファイルを動かすときほど、この区別が大切でした。
7. 再発を機械で止める
移行が終わった後に元へ戻らないよう、規約を文章で書くだけでなく、CI で落ちるようにしました。
| CI のチェック | 失敗させる条件 |
|---|---|
make provider_inventory_check |
手書き Provider 宣言・extends StateNotifier・legacy.dart の import / export が 1 件でもあれば失敗 |
make riverpod_lint_check |
riverpod_lint の診断 |
make provider_convention_check |
規約の文書と、そこで例に挙げた実装・レビュー観点のずれ |
件数を数えていたスクリプトに --check を付けるだけで、そのまま CI のチェックとして使えました。さらに barrel.dart から legacy.dart の export を外し、StateNotifierProvider を「うっかり書けない」状態にしています。
riverpod_lint には注意点が 1 つあります。riverpod_lint は analysis_server_plugin 方式で動くので、診断は dart analyze にしか出ず、flutter analyze には出ません。しかも、プラグインの読み込みに失敗しても、dart analyze は「診断 0 件・exit 0」を返します。 これでは「違反がない」のか「チェックが動いていない」のか区別できません。
そこでチェック用のスクリプトは毎回、わざと違反を書いたファイルを lib/ に置きます。その違反が報告されたらチェックは動いていると分かるので、残りの診断を本当の違反として扱います。
AI レビュー(CodeRabbit)の観点も更新しました。当初の観点は、Provider の宣言側まで辿らないと判定できない書き方だったため、実際には指摘されないケースがありました。そこで「await より後の行に ref.read が現れたら」のように、レビュー対象ファイルの構文だけで判定できる条件に書き換えています。移行するとコードの形が変わるので、レビュー観点も一緒に移行する必要がありました。
結果
| 指標 | 移行前 | 移行後 |
|---|---|---|
| 手書き Provider 宣言 | 261 | 0 |
extends StateNotifier クラス |
130 | 0 |
legacy.dart の import / export |
あり | 0 |
@riverpod / @Riverpod( 宣言 |
一部のみ | 284 |
main を取り込んだ後の統合ブランチで、flutter test 4,887 件がすべて通りました。Android エミュレータでの E2E(Maestro)も通過しています。
一方で、破棄済みの Ref にアクセスする問題はリリース後も画面ごとに見つかり、そのつど直しています。この問題への考え方と対処は、冒頭で紹介した記事にまとまっています。
次にやるなら変えること
main への追従をもっとこまめにします。 統合ブランチを長く寝かせたため、最後の追従ではコンフリクトの解消が必要でした。さらに、main 側の修正を生成 Notifier の書き方へ移し替える作業も発生しました。
テストの待機方法を先に揃えます。 固定回数の pump() で autoDispose の破棄完了を待つテストヘルパが各所に残っており、移行 PR のレビューで何度も指摘されました。移行より先に共通化しておけば、指摘への対応が減ったはずです。
おわりに
今回の移行では、少数を先に試したことで、宣言だけで移せるものと、テストや呼び出し側まで変わるものを分けられました。さらに、複数の PR にまたがる改名を先に済ませると、画面ごとの移行を並行で進められました。大きな移行を控えている方は、まず代表的な数件を移し、変更がどのファイルまで広がるかを確かめてみてください。
付録: 移行でつまずいた点
本文で触れた autoDispose の既定値と overrideWith のほかに、移行の途中でつまずいた点をまとめます。どれも規約ファイルの「移行時にはまる点」に記録し、エージェントとレビュアーの共通の観点にしています。
| つまずいた点 | 症状 | 対処 |
|---|---|---|
build() から初回取得を直接呼ぶ |
build() が初期値を返す前に state を書くと落ちる(1 本の PR でテスト 129 件が失敗) |
最初の await より前に state を書く処理は、Future.microtask で後ろに回す |
他 Provider の初期化中に state を書く |
生成 Notifier ではアサーションで落ちる(テスト 55 件が失敗) | 同じく Future.microtask で、初期化の完了後に回す |
依存が変わっても、同じインスタンスで build() が再実行される |
古い世代の非同期結果が state に書き戻される |
build() ごとに進む世代番号を持たせて比べる |
build.yaml の generate_for の対象から外れている |
何も生成されず、離れた場所でコンパイルエラーになる | 対象を lib/**/*.dart と lib/*.dart に広げる |
初回取得を後ろに回す形は、次のとおりです。
@riverpod
class ItemListViewModel extends _$ItemListViewModel {
@override
ItemListState build() {
// fetch() は最初の await の前に state を書くため、ビルド完了後へ回す
unawaited(Future.microtask(fetch));
return const ItemListState();
}
}
もう 1 つ注意したいのは、test/** が flutter analyze の対象外になっていたことです。移行でテストが失敗するようになっても analyze は通ってしまうので、移行のたびに flutter test まで必ず実行することを規約に明記しました。
日本トレカセンターでは、こうした大規模なリファクタリングや開発基盤の改善を、一緒に進める仲間を募集しています。
Discussion
