【Flutter】「Flutterアプリにおける、過不足ない設計の考察」の個人的まとめ
テックリードがmonoさんの記事を読んで言いたいことが全部言語化されていたと、おっしゃっていたのでメモを取りながら読んでみることにする
この記事では、「一般的なモバイルアプリ」の設計全般について、特に気をつける必要があることとあまり気にしなくて良いと思うことを書いている。
この記事における「一般的なモバイルアプリ」の定義は以下の通り。
- コード量: 数万~十数万行
- 実装者: 一桁人
- スマホ向けのクライアントアプリコード
Flutterアプリにおいてまず念頭に置くべきこと
- SSOT原則に従った状態管理
- 状態の流れを単方向データフローで組む
次に念頭に置くべきこと(遵守必須ではないが守るほうが良い)
- イミュータブルプログラミングの徹底
- Unit/Widgetテストが可能に
- 単一責任の原則を意識
SSOT原則に従った状態管理
SSOTとは何?
- 信頼できる唯一の情報源の略
- すべてのデータが1か所でのみ作成、あるいは編集されるように、情報モデルと関連するデータスキーマとを構造化する方法(ref:wiki)
SSOTに従った状態管理にすると何が嬉しい?
- プライマリのデータが更新された場合、どこかで重複したり失われたりすることなく、システム全体に伝播される
- 例: 以下のような要件を満たしたい時、特別な工夫なく容易かつ確実に実現できる
1. 記事一覧画面で、自分のlike表示がされている(未like)
2. 詳細画面に遷移後、likeするとその詳細画面でlike済みに変わる
3. 一覧画面に戻ると、その記事がlike済みになっている
状態の流れを単方向データフローで組む
単方向データフローとは何?
- 単方向データフロー(UDF)は、状態が下方に流れ、イベントが上方に流れる設計パターン(ref: Compose UI を設計する)
状態の流れを単方向データフローで組むと何が嬉しい?
- SSOT原則に従った状態管理との組み合わせによって、データがあちこち行ったり来たりすることなく、基本的に大元の SSOT から適宜加工されながら流れてくるということで、追いやすくなる
補足: CQRSとは何?
- コマンドクエリ責務分離の略
- コマンド: 副作用を与えるだけで戻り値がvoidのコマンド
- 例: 特定の記事のお気に入り状態を更新する
- クエリ: 副作用を与えずデータを得るだけのクエリ
- 例: お気に入りにしている記事の取得
- コマンド: 副作用を与えるだけで戻り値がvoidのコマンド
イミュータブルプログラミングの徹底
イミュータブルとは何?
- 作成後にその状態を変えることのできない性質のこと
- 反対に作成後でも状態が変えることができる性質をミュータブルと呼ぶ
イミュータブルプログラミングを徹底すると何が嬉しい?
- 複数の変数が同じインスタンスへの参照を共有できないため、意図しない副作用に起因する不具合の心配をしなくてよくなる(ref: Flutter/Dartにおけるimmutableの実践的な扱い方)
Unit/Widget Testが可能なこと
- Unit/Widgetテストが可能であることは、以下に示すような依存がうまいこと切り離されていることの証左になる(カナリアみたい)
- ネイティブAPI
- Unit/WidgetテストではネイティブAPIが使えないため、これに依存しているとテストが書けない
- ネットワーク通信
- これが切り離されてないとモックが書きづらい
- ローカルDBアクセス
- オンメモリで動かせるもの以外は、永続化処理がネイティブ依存のため、テストが書けない
- 随時変動する環境情報(例: 現在時刻)
- 特定の時刻の場合の挙動に関するテストが書きづらい
- ネイティブAPI
単一責任の原則を意識
単一責任の原則とは何?
-
プログラミングに関する原則であり、モジュール、クラスまたは関数は、単一の機能について責任を持ち、その機能をカプセル化するべきであるという原則(ref: Wikipedia)
-
それぞれの関数・クラス・メソッドが以下を満たすように組めれば及第点
- それぞれの責務を表す端的な名前が付けられている
- 実際の処理内容が付けられた名前に従っており余計なことをしていない
- コード量が多すぎない
- 高凝集(特定の責務が散らばらずに1箇所・あるいは近い場所に閉じていること)
- 疎結合(単一の機能としてうまく切り離されていること)
単一責任の原則を意識すると何が嬉しい?
- 特定の依存だけを差し替えやすくなるため、テストコードが書きやすくメンテナンスしやすい
ディレクトリ構成
- 関連性のあるものを寄せて、分けるのは機能ごとというのが基本的な考え方
monoさんが当該記事中で紹介していたディレクトリ構成
レイヤーファーストではなくフィーチャファースト推し
見かけ駆動パッケージングではなく、責務に従ったパッケージング
個人的に金言だと思ったので、そのまま引用
「どのように組むがの良いかはケースバイケースですが、とりあえず「最近流行ってるから」「本に書いてあったから」とかではなく、それをプロジェクトコードに取り入れた場合を想像したり試してみたりした上で、具体的なメリットがデメリットを上回る手応えを得てから採用するのが重要だと思っています。」
Riverpodの各Providerの主要な利用箇所と流れ
以下の画像をMermaidで作図してみる
アプリ全体でのデータフロー
- アプリ全体として大きな単方向データフローの流れ(A・B)があり、要件に応じてCが混ざることがある
- 「各データストア」部分が大元の SSOT に相当し、CQRS観点では、Aがクエリで、Bがコマンドに相当する
- A: 各データストアから得た値をリアクティブなキャッシュとして流す
- B: UIからの入力を元にデータストアを更新するメソッドを持つクラスあるいはクロージャを返すProvider
- C: 局所的な単方向データフロー(編集画面で初期値・ユーザー入力値をそこに閉じて状態管理必要な場合など)
StateNotifierの使い所
- StateNotifierは本当に必要なところで最小限の利用にとどめるのが良い
- 理由: StateNotifierだと、SSOTから得られる値が流れる以外に、任意のタイミングで state = で値をセットできる余地があるため、周辺コードをよく確認しないとデータの流れを正確に読み解けなくなるため
- StateNotifierを多用していると、単方向データフローを徹底できてないサインかもしれない
- 理由: StateNotifierは編集画面やその他ユーザー入力値の一時保持など必要な場合の利用が適しているが、多くのアプリでは出番が多くないはずなので、不要な箇所でStateNotifierを使用している可能性があるため。
- StateNotifierを最新データの再取得するために使用するのは代替手段が存在するため、その理由で使う必然性が無い
- Firestore使っている場合は監視によるStreamProviderベースで組めばリアルタイム更新される
- Web API利用などでFutureProviderで組んでいる場合はautoDisposeにしたり(リスナーがゼロになると次回watch時に取得し直しされる)、任意のタイミングで ref.invalidte を読んで再取得を促せる
感想
モバイルアプリのアーキテクチャを考えていく上で、とりあえずMVVM+Repositoryで勧めていこうとmonoさんの記事を読むまで考えていたが、基本を抑えた上でどのアーキテクチャパターンを採用するかを検討しようと思った。
重要な要素であるSSOTや単方向データフローについては、記事中のサンプルコードを読みながら理解することができよかった。
また、自分の書くコードはStateNotifierを何でもかんでも使っていることに気づき、FutureProviderやProviderで置き換えられないか検討してみるきっかけになった。