Closed4

Flutter公式アーキテクチャを読んでみる

oke331oke331

アーキテクチャの概要

レイヤードアーキテクチャを採用してUIレイヤーとDataレイヤーの関心の分離をちゃんとしようという事が書かれている。
(必要に応じてLogicレイヤーも作っても良いよねという感じ)

また、UIレイヤーの分離の考え方としてMVVMを採用しており、各層を下記のように分離している。

  • UIレイヤーをView/ViewModelに分離
  • DataレイヤーをRepository/Serviceに分離

下記の図がわかりやすい。

こちらに詳しく記載がある。

また、いくつか聞いたことのある単語の利点も解説してあるのでピックアップする。

シングルソースオブトゥルース (Single Source of Truth)

アプリ内の各データ型は単一の真実のソース(SSOT)を持ち、そのソースのみがデータを変更できる。
具体的には、DataレイヤーのリポジトリクラスがSSOTとして機能し、各データ型ごとに1つのリポジトリクラスを設ける。
これにより、バグの減少とコードの単純化が可能。

単方向データフロー (Unidirectional Data Flow)

状態がデータ層からロジック層を経てUI層に流れ、ユーザーのイベントが逆方向に流れる設計パターン。
下記の順序で、ユーザー操作が上から下に流れてユーザーに伝わる感じで、単方向になっている。

  1. ユーザー操作(例: ボタンクリック)がイベントを発生させる。
  2. ロジック層のメソッドが呼び出される。
  3. データ層がデータを更新し、新しいデータをロジック層に提供する。
  4. ロジック層が新しい状態を保存し、UI層に送信する。
  5. UI層が新しい状態を表示する。

これによりコードが理解しやすくなり、エラーが発生しにくく、不正なデータや予期しないデータの作成を防ぐことができる。

ディレクトリについて

アーキテクチャの解説ではあまり触れられていなかったように思うが、
ディレクトリとしてはレイヤーごとに設けられ、
そこから機能ごとに分離している形になっている。
また、UI層については各機能からview_models/widgetsのディレクトリに分けられている。

lib/
├── data/
│   ├── repositories/
│   └── services/
├── domain/
│   ├── models/
│   └── use_cases/
├── ui/
│   ├── activities/
│   └── auth/
│       ├── login/
│       └── logout/
│         ├── view_models/
│         └── widgets/
└── main.dart

個人的に好きなのは、ui層が大枠の機能ごと(例: auth 認証)にディレクトリが切られ、その中でlogin/logoutという処理ごとに分かれていて関連するViewやViewModelがまとまっており、
UIレイヤーの実装をしている時にあっちこっちに飛ばなくて良い&見やすいことが良いなぁと思う。

また、各レイヤーごとでちゃんとディレクトリが分かれていることで、どこに何があるか非常に理解しやすい。

oke331oke331

ViewModel、良くない?という話。

少し前にViewModel使わなくて良くないみたいな話がちらほら(旧)Twitter上で出ていましたが、
今回の実装を見てみて、ViewModelでも良くないかな?となったのでそれをまとめます。
(その時の争点と異なる部分があるかもしれませんが、自分が感じていたことを中心に書きます)

ViewModelがなぜよくなかったか

自分はViewModelを使って組んだことがあったのですが、下記のようなことがありました。

  1. 画面ごとで表示されているデータに差異が出る
  2. ViewModel肥大化問題
  3. 同じロジックが複数箇所にある

(まだある気がしますが一旦、、)

1. 画面ごとで表示されているデータに差異が出る

これを一番懸念していたのですが、どうやら画面遷移ごとで再取得が走っているようでした。

コードをたどると、恐らくgo_routerが画面遷移ごとで再構築してくれており、ViewModelのロード処理が呼ばれているのではと思います。
(最近go_router使ってなかったので間違ってたら教えてください…)

↓該当コード辺り

https://github.com/flutter/samples/blob/6921283923face1085dacc8a9b5c138a63fa42ef/compass_app/app/lib/routing/router.dart#L47-L55

これにより、ある画面で更新処理をして、ある画面には反映されないみたいなことが起きずに済みそうですね…!
ただ、画面遷移ごとに取得処理が走ってしまうので、適切にRepositoryでキャッシュするなどは必要そうです。

2. ViewModel肥大化問題

ViewModelを使用していた時に良くなかったのが、ViewModelの肥大化です。

画面とViewModelは1対1みたいに思っていましたが、下記の記述がありました。

On the other hand, logging out of an app is generally not done on a dedicated screen. The ability to log out is generally presented to the user as a button in a menu, a user account screen, or any number of different locations. It's often presented in multiple locations. In that scenario, you may have a LogoutViewModel and a LogoutView which only contains a single button that can be dropped into other widgets.

アプリからのログアウトは専用画面で行われることはほとんどありません。ログアウトの機能は通常、メニュー、ユーザーアカウント画面、またはその他のさまざまな場所にあるボタンとしてユーザーに提供されます。多くの場合、複数の場所に表示されます。このようなシナリオでは、LogoutViewModelとLogoutViewを作成し、ボタン1つだけを含むViewを作成することで、他のウィジェットに埋め込むことが可能です。

要するに、ログアウトボタンのようにいくつかの画面で使用されるようなものは、機能ごとでViewModelを作成してOKということかと思います。

↓ログアウトボタンにViewModelを渡している

https://github.com/flutter/samples/blob/6921283923face1085dacc8a9b5c138a63fa42ef/compass_app/app/lib/ui/auth/logout/widgets/logout_button.dart#L11-L21

例えばHome画面はいろいろな機能で長くなりますが、
それを機能ごとでViewModelを設けて適切にビューを分けておくことで、
単一の機能で構成される読みやすいViewModelの作成ができるのではと思います。

以前、以下の記事を書いたのですが、「機能ごと」と「画面ごと」のViewModelがあるのではという悩みも、このアーキテクチャで解消されているような気もします。
https://zenn.dev/oke331/articles/3db9041111ddff

3. 同じロジックが複数箇所にある

これは、上記のように複数箇所で使用するコンポーネントは予め切り出しておくことや、
UseCase層を使用することでかなり解消できそうですね…!!

↓UseCase層(これしかUseCaseが設けられてなかった)

https://github.com/flutter/samples/blob/6921283923face1085dacc8a9b5c138a63fa42ef/compass_app/app/lib/domain/use_cases/booking/booking_create_use_case.dart#L20-L27

ただ、UseCase作成に関しては懸念点があり、
公式の解説を読んだ感じ全部をUseCaseにするというより「必要なものだけUseCase作成するのおすすめだよ」みたいなことかと思ったのですが(場合によるが)、個人的にはUseCase作成してあるかがわからず車輪の再発明祭りが起きないかは心配です…。(かといって全部設けるのも…)
何か「UseCase作成してあるものはそれを忘れずに使うみたいな仕組み」を思いついている方がいたら教えていただきたいです🙇‍♂️

個人的に迷っていた点も解消

ViewModelにBuildContextを渡しているコードをちらほら見てきたのですが、
公式サンプルではBuildContextはViewModelに渡していないようです。

そのため、ViewModelにonTap〇〇みたいな名称のメソッドが設けられることはなく、
画面側にonTapの処理を書いて、その過程でCommandパターンで設けたメソッドを呼び出す形になっており、そこから得た情報を元にローディング状態にしたりエラーのSnackBarを出したりしています。

↓エラーのSnackBar表示箇所

https://github.com/flutter/samples/blob/6921283923face1085dacc8a9b5c138a63fa42ef/compass_app/app/lib/ui/booking/widgets/booking_screen.dart#L103-L112

個人的にこのコマンドパターンがかなり好みで、
isLoading/isErrorなどをいちいち設けていたりした時もありましたが、
Commandパターンを使用するとそれを書かずにスマートな感じでそれぞれの処理のステートを管理できそうです。

↓Commandパターンで状態を監視している箇所

https://github.com/flutter/samples/blob/6921283923face1085dacc8a9b5c138a63fa42ef/compass_app/app/lib/ui/booking/widgets/booking_screen.dart#L61-L95

その他の良かった点

Resultの使用

時々エラーハンドリングを忘れたりして「おっと」となる時があるのですが、Result型でRepository全体を管理しているのでエラーハンドリング忘れがなさそうで良いですね。

↓抽象リポジトリでResult型で返している

https://github.com/flutter/samples/blob/6921283923face1085dacc8a9b5c138a63fa42ef/compass_app/app/lib/data/repositories/activity/activity_repository.dart#L9-L12

oke331oke331

感想

こういった公式からのサンプルはとてもありがたい…!!
どういう考え方で設計を行うべきかの指針を公式から設けられることで、
なにかの判断に迷う際に「公式はこう言ってるよ」と話すことでエンジニア間で認識の統一ができそうにも思う。

あとは、レイヤーごとでパッケージを分けたりしても良いかな?と思ったりはした。
このように定義していても実装上は依存関係ぐちゃぐちゃにできてしまうので、ちゃんとできない仕組み作りが大切に思う。

僕はかなり良いサンプルだなぁと思っているのですが、
もし「このサンプルだとこういった点が懸念されるな」ということがあったらぜひ教えてください!

このスクラップは1日前にクローズされました