【Flutter】数万行のリファクタリングをしている話
やること
対象はFlutterでできたMisskeyのサードパーティアプリ Miriaさんです。
OSSなので全部の修正がわかります。こちらがプルリクエスト。マージするのは自分ですが。自動生成コードを抜きにしてもDartのソースは6万行程度あり、このかなりの範囲で影響します。
今回やったことを特にMisskeyのサードパーティクライアントだからという話ではなく、一般にFlutterのアプリをリファクタリングする話として整理してみます。
対象 | 前 | やること |
---|---|---|
Lint | 標準 | めっちゃ強く |
状態管理 | Riverpod & StatefulWidget | Riverpod(_generator) & flutter_hooks |
非同期処理の状態管理 | StatefulWidget | AsyncNotifierProvider |
Flutter自体のバージョン管理 | 最新版を使う | fvmで固定 |
エラーハンドリング | FutureにExtensionを生やす | それ用のStateNotifierを作る |
Material | Material 2 | Material 3 with Adaptive |
ダイアログやmodalBottomSheet | showDialogやshowModalBottomSheetを使う | AutoRouterでやる |
Lintを強くする
Flutterの標準のプロジェクトでは、flutter_lints/flutter.yaml
が使用されます。ここに、freezedなどの自動生成のファイルを除く設定だけを入れていました。
しかしながら、これがawait
のつけ忘れ(discard_futures, unawaited_futuresで改善)やソースコードの読みにくさ(require_trailing_commasで改善)に繋がってしまっていたため、
Lintを強く設定し、さらに後述の目的でriverpod_lintも導入しました。(なおすのがたいへん〜〜〜)
これ、あとからやるのはものすごく大変なので、もしも今からFlutterの新規のプロジェクトを作ろうとしている方がいれば、最初から入れられるものは入れてあげてください
ソースコードの修正量の多い大半の理由は実はprefer_double_quotesとrequire_trailing_commasです。あと後述するriverpodを自動生成にしたため。
状態管理
まず、全体としてすでにあるRiverpodのProviderたちを@riverpod
や@Riverpod(keepAlive: true)
などを指定しながら、Providerを自動生成するようにしました。
さらに、didChangeDependencies
で状態の初期化を行うためにさらにFuture
で囲って…のようななどまどろっこしかったところや、TextEditingControllerのdispose
忘れなどを防ぐ目的、そしてそもそもRiverpodはローカルな状態管理に向いていない(し、そうすべきではない)というところから、flutter_hooksを導入したり、AsyncNotifierProviderを活用するようにしました。
たとえばノート検索画面では「ノートの検索内容」「チャンネル・ユーザー指定」などがローカルな状態として存在しました。こちらがリファクタ前で、
こちらがリファクタ後です。
こうしてみると、欠点もあります。(ウィジェット間の引数で渡さないといけないものが増える、 .valueがNullableで解決しにくいところなど)
しかしながら、initStateやdidChangeDependenciesといったStatefulWidgetのライフサイクルから離れてかなり分かりやすくなったかなと思います。
また、AsyncNotifierProviderの活用は、たとえばこちらは絵文字の一覧を取得するページですが、ウワーって感じです。
が
になりました。そのウィジェットで必要な非同期処理をdidChangeDependenciesで走らせ、ローディング中かどうかをフラグで持っていて、エラーのとき用の変数も用意して……といったことをしていたのが、@riverpodで宣言したFutureをwatchし、switch文で簡単に状態の切り分けが可能になりました。これめちゃくちゃいいですね。
ただ、かなりたくさん存在しているChangeNotifierは、この方法ではnotifyListeners()を呼んでもリッスンされないです。これ変えるのは大変なので見送ることにしました。
現状ではInheritedWidgetを使用している箇所もあったりしてややこしいのですが、これらもScoped Provider
に置き換えて行こうと思っています。
エラーハンドリング
APIの、例えばPOST処理にはエラーハンドリングが必要です。が、かなり雑な作りになっていました。
FutureにExtensionを生やして、ここでキャッチしてエラーハンドリングを行っていました(一部の画面のみ、かなり漏れあり)
BuildContextが必要で呼び出し元でくっつけないといけないなど、かなりイケてない作りだったのですが、ここは本流ことMisskeyの実装を参考にしました。 FlutterとVue.jsで異なるフレームワークとはいえ、アプローチは参考にできます。
まず、ダイアログの情報を管理するStateNotifier
を作り、Completer
を保持します。
そして、このCompleter
を画面側で叩けば、ダイアログのどれを押したかの情報がStateNotifierに伝わり、さらにこのCompleterをawaitしている箇所でも受け取ることができます。
これは汎用的なダイアログの、Contextになるべく依存しない安全な実装です。これであれば、ユーザーのダイアログの操作を待ってどちらが押されたかで処理を変える…といったケースでも、BuildContextから離れて実装することができます。
ただし、メッセージやボタンの名称はString Function(BuildContext)
にしています。このアプリでは多言語対応の手段としてflutterの通常の国際化の手法を使っており、これがBuildContextがないとアクセスできないためです。固定の文字列でいいなら単にStringの配列とかでもいいです。
さて共通的なエラーハンドリングもこれで行うことができます。
先ほどAsyncNotifierProviderやAsyncValueを活用する方針を立てたので、同様にAsyncValueを返すメソッドを作り、その中でエラーの場合はなんなりとダイアログを表示するようにします。この形であれば、このNotifierのguardメソッドで囲ったFutureのエラーハンドリングを簡単に行いつつ、状態管理も行うことができるようになります。
Material 2から3へ移行、Adaptive
そもそもダイアログの表示方法については、上記の手段へ移行するので、ここで一緒にAdaptiveにしてしまいます。それ以外の画面は…鋭意実装中です。
まだまだリファクタしたい箇所はたくさんあるので、地道にやっていくしかないですねえ。
fvmの導入
上げると地雷になるバージョンがそのまま正式に上がってくることが直接のきっかけでしたが。
単純にOSSでそれぞれレポジトリをクローンしてくれる方の環境も必ずしも一致しない、などということもあり、fvmを導入してFlutter自体のバージョンも固定することにしました。
ダイアログやModalBottomSheetの改善
ダイアログやModalBottomSheetは普通にshowDialogやshowModalBottomSheetを使用していましたが、
/// ダイアログ
class AutoDialogRoute<T> extends CustomRoute {
AutoDialogRoute({
required PageInfo<T> page,
}) : super(
transitionsBuilder: TransitionsBuilders.fadeIn,
durationInMilliseconds: 200,
fullscreenDialog: false,
customRouteBuilder: (context, widget, page) =>
DialogRoute<PageInfo<T>>(
context: context,
builder: (context) => widget,
settings: page,
),
page: page,
);
}
/// モーダルボトムシート
class AutoModalRouteSheet<T> extends CustomRoute {
AutoModalRouteSheet({
required PageInfo<T> page,
}) : super(
page: page,
transitionsBuilder: TransitionsBuilders.slideBottom,
durationInMilliseconds: 200,
customRouteBuilder: (context, widget, page) => ModalBottomSheetRoute(
builder: (context) => widget,
isScrollControlled: false,
settings: page,
),
);
}
このようなCustomRouteを定義してauto_routeで管理することにしました。こうすれば返す型を付随情報に加えたり、共通化を測ったり、単純にダイアログやBottomModalSheetを表示する箇所のコード量が減ります。
その他
今回のリファクタにあたって、じつはMiriaのフォークであるAriaからかなり影響を受けました。AriaがなかったらたぶんAsyncNotifierProviderを広範にわたって導入しようとは思わなかったり、flutter_hooksは今でも敬遠していたかもしれません。感謝です。
ところでですね、今後Dartではマクロが実装されますし、Riverpodは近いうちに3.0になります。
マクロがあればdart run build_runner build --delete-conflicting-outputs
を毎回しなくてもよくなり、.freezed.dartや.g.dartの地獄から抜け出したり、レポジトリのサイズや修正時の差分も小さくなるので、ものすごく期待しています。
また、Miriaでは今の時点で既にRiverpodは3.0-dev.3を使用していて、switch文でのAsyncValueの分岐は既にいろんな箇所で活用しようとしているところです。ところがRiverpod 3.0最新プレビューによると「Side-effectのサポート強化」が謳われており、これは今回のリファクタリングに大いに影響するところです(POSTのAPI呼び出しもローディング表示、エラーハンドリングや二重タップ防止などの目的でAsyncValueをたくさん使っていこうとしていた矢先です…)
変わったときはまた変わったときにやればいいやの気持ちでマクロやRiverpod 3.0を待っています。
Discussion