【Flutter】アプリを分割する3つのレイヤーと依存関係
前回の記事 では、今仕事で開発中のアプリのアーキテクチャを クリーンアーキテクチャ本 の教えを頼りに頑張って考えた話を書きました。
前回の記事では主に レイヤーを分割して依存関係を整理することの意義 について書きましたので、この記事ではそれをさらに深掘りし、具体的にそれぞれのレイヤーがどのような役割を担当し、なぜそれをレイヤーとして独立させる必要があると考えたかを説明していきます。
「クリーンアーキテクチャを適用する」とは
このアプリの具体的な話に入る前に、この記事での「クリーンアーキテクチャを適用する」という言葉のイメージをちゃんと書いておこうと思います。
個人的な理解ですが、「クリーンアーキテクチャを適用する」という言葉が表す内容は、あの有名な同心円上の 4 つのレイヤーを忠実に再現することではありません。
クリーンアーキテクチャ本では、例の図の直後に
図 22-1 の円は、概要を示したものである。したがって、この 4 つ以外にも必要なものはあるだろう。この 4 つ以外は認めないというルールはない。
と書かれており、またその図に至る文脈も、ヘクサゴナルアーキテクチャや DCI アーキテクチャ、BCE などで紹介されている従来のアーキテクチャを「単一の実行可能なアイデアに統合」すると、だいたい例の4つの領域に分けられると説明されているのみです。
むしろ、その後に 「依存性のルールは常に適用される」 と強調されていることからも、ここで伝えたいのは 依存性のルールが重要である こと、またその 依存の方向が制御の流れと対立する場合の対処法 といったことであると受け取れます。
つまり、「クリーンアーキテクチャを適用する」とは、ソフトウェアを例の 4 つのレイヤーに分割してその通りの名前をつけることではなく、 レイヤーを適切に分割し、レイヤー間の依存性のルールを定め、それを実現するコーディングをすること だと理解できます。
このことを前提に[1]、ここから続くレイヤーの分割方法や依存関係に着目して読んでいただければと思います。
3 つ +α のレイヤー
前回の記事でも触れましたが、このアプリでは全体を 3 つ +α のレイヤーに分割しています。
それぞれのレイヤーを簡単に説明します。
View
ユーザーに見せる UI を構築します。
よりシステム的な役割として説明すると、「入出力」を担当する のがこのレイヤーです。ユーザーがキーボードで入力した文字列やタップなどによって開始する処理を後続のレイヤーに渡し、逆に後続のレイヤーから受けとったデータをデザインに従って出力するのがこのレイヤーであるといえます。
注目すべきは、Flutter フレームワークに依存するのはこのレイヤーだけである、という点です。フレームワークに依存するレイヤーを制限することによって生まれるメリットについては後述します。
BusinessLogic
アプリがユーザーに提供したい価値と、それを実現するための手順を表現するのがこのレイヤーです。
「こんなデータの場合はこの機能はこんな動作をする」という、いわゆる 「仕様」を実現するためのロジック(完全にデザイン的な仕様は除く)は基本的にここに実装します。
依存関係に着目すると、BusinessLogic レイヤーは常に「依存される」側であり、BusinessLogic レイヤーから外に向かう矢印がない(+α である Data レイヤーは無視してください)点がポイントです。
Repository
Repository レイヤーにはデータへの具体的なアクセス処理を実装します。
Flutter フレームワークに依存するのが View レイヤーだけであるのと同じように、Firestore など特定のデータベースに依存してよいのはこの Repository レイヤーのみです。たとえば DocumentReference
のような cloud_firestore
由来のオブジェクトはこのレイヤーから外に出してはいけません。
クリーンアーキテクチャ的な観点では、 BusinessLogic と Repository の関係がまさに「制御の流れ」と「依存の方向性」を逆転させている例 となります。これがどのようなメリットを生むのかについては後述します。
Data
Data レイヤーは少し特殊で、レイヤー共通で利用するデータクラスを単に列挙したものです。
当然それらのデータクラスはデータを保持するのみで、メソッドを持つことはできません。[2]
クリーンアーキテクチャ本に忠実に従う場合、このようなレイヤーをまたぐデータクラスはそれぞれ矢印の内側(つまりこの場合は BusinessLogic)に定義し、レイヤーの境界ごとに専用の型を用意するのが、各レイヤーを完全に独立してバージョン管理したい場合などを考えると望ましいようです。
とはいえ今回はビルドすると単一のバイナリになる「アプリ」であることを踏まえ、さすがにレイヤーをまたぐたびに別のオブジェクトに詰め替えるのはやりすぎと判断し、全体が一律で利用できる Data レイヤーとして設計しました。[3]
備考
レイヤーを分割すること自体について
もし UI とデータ構造がイコールの関係になっており、 ユーザーが入力した内容がそのままデータベースに保存される 、 データベースの内容がそのまま画面に表示される だけで事足りるアプリであれば、このようにレイヤーを分割したり依存関係を整理したりするのは「やりすぎ」です。
riverpod
などで管理する状態オブジェクトがデータアクセスを担当するオブジェクトを通してデータを出し入れし、それの結果を Widget が .watch()
するだけでだいたい問題ないでしょう。
ただし、今回のアプリはその間にさまざまな事情が加わっていることは前回の記事で説明した通りです。このような分割は「必要なことなんだ」という前提でこの先を読んでいただければと思います。[4]
命名について
各レイヤーの名称については、「これが最適」というよりは「きっとみんなこの単語ならイメージしやすいよね?」という考えて便宜的につけたものになります。
"BusinessLogic" はもしかしたら "Entity" でも良いような気もしますし、"Repository" も他のアーキテクチャパターンで使われている定義と差異があるような気もします。とはいえ今回の考え方をピッタリと表す独自の単語を考えて定着することに力を使うくらいなら、「このプロジェクトではこの単語はこのレイヤーのことを指します」とざっくり指し示す記号になってくれればいいかな、と考えこのような命名をしています。
各レイヤーの詳細とその役割
登場するレイヤーを一覧したところで、各レイヤーの詳しい役割とそのような設計にするメリットについて説明していきます。この記事では、「制御の流れ」に従って View -> BusinessLogic -> Repository の順番で説明します。
View
View は「入出力」を担当します。
View レイヤーを詳しく図にすると以下のようになります。
Widget へのユーザーの入力や操作を Controller
に伝え、Controller
は適切な BusinessLogic クラスの適切なメソッドを呼び出し、結果を State
に反映させ、それをトリガーに Widget がリビルドされて UI という形で出力される、という Flutter にはよくある流れかと思います。
ここでの注意点としては、 Controller
の役割が具体的な何か処理を行うことではなく、適切な BusinessLogic を判断して呼び出すのみ であるというところでしょうか。
前回の記事で書いた通り、今回のプロジェクトでは「見た目だけ変えた別アプリ」を出すことも念頭に開発を進めています。そうなったらこの View レイヤーのみを作り直し、 BusinessLogic 以下のレイヤーはそのまま使い回せることを狙っているため、View レイヤーに具体的な処理があればあるほどアプリ間でのコードの重複が発生してしまう、というわけです。それを防ぐのが View レイヤーと BusinessLogic レイヤーは明確に分離した理由のひとつです。
Flutter への依存
先述の通り Flutter に依存するのはこのレイヤーのみのため、Widget のビルドをはじめとする Flutter に依存した処理はすべてこのレイヤーに閉じ込めるのが実装のポイントです。
「Flutter に依存した処理」には状態管理も含まれます。そのため、状態管理を主な役割とする provider
や flutter_riverpod
といったパッケージの利用も View レイヤーのみで行います。
Controller や State といった役割は概念上のもので、これを実現するためのパッケージ等は明確に決めてはいません。場合によっては StatefulWidget の利用も選択肢に入ることは以前の記事に書いた通りですが、StatefulWidget では力不足な場面は多々あるため、実際は大半の Controller で状態管理パッケージを利用しています。
状態管理パッケージの選定
現状では、状態管理パッケージは provider
パッケージを使っています。
なぜ riverpod
やその他の状態管理パッケージではなく provider
なのかというと、単純に「従来のアプリが provider
を使っていたから」です。
これは単なる前例主義的な発想ではなく、ただでさえアーキテクチャをガラっと考え直して(しかも私の独断で)その考え方を理解して開発しなければならないのに、そこにさらに riverpod
も覚えなければならない状況だと、私を含め プロジェクトメンバーの学習コストが上がりすぎてしまう と判断したためです。
ガラッと変えるところは変える、「以前と同じ」でよいところはそのままにする、の考えで優先度を考えた結果、「 provider
から riverpod
に移行する」のは相対的に優先度が下がると判断しての provider
続投となっています。
とはいえ provider
に対する不満と riverpod
が便利であるという知見はそれぞれ溜まってきているのが正直なところではあるので、早いうちにこの移行は済ませてしまいたいと思っています。その場合、 修正範囲はこの View レイヤーのみで(理論上は)完結する のがこのアーキテクチャにしたメリットのひとつと期待しています。
入力を BusinessLogic へ渡す方法
Controller から BusinessLogic へ渡すのはあくまで「ユーザーが入力した内容」そのものです。それがどのような意味を持つのか、どのように扱うのか、といった情報は極力 Controller を含む View レイヤーには入りこまないようにしたいところです。
たとえば、「タイトルと本文を入力して記事を投稿する」という機能があった場合、Controller 側のコードは以下のようになります。
final ArticleLogic articleLogic = // 何らかの方法でオブジェクト生成
Future<void> submit({required String title, required String body}) async {
await articleLogic.post(title, body);
}
この時、たとえ「記事」をあらわす Article
クラスが用意されていたとしても、それを Controller 側で生成して BusinessLogic に渡すようなことはしません。
final ArticleLogic articleLogic = // 何らかの方法でオブジェクト生成
Future<void> submit({required String title, required String body}) async {
// このやり方は望ましくない
await articleLogic.post(
Article(title: title, body: body),
);
}
なぜかというと、Article
オブジェクトの生成にも何か「仕様」に基づいたロジックが入る可能性があるからです。
たとえば「その記事の読了までの時間を body
の長さから計算する」という仕様があった場合、そのロジックを Article
オブジェクト生成時に処理しなければなりませんが、そのような「仕様」を実現するコードは役割的に BusinessLogic 側で行いたいところです。
final ArticleLogic articleLogic = // 何らかの方法でオブジェクト生成
Future<void> submit({required String title, required String body}) async {
await articleLogic.post(
Article(
title: title,
body: body,
estimateTime: // 読了までの時間を body の長さから計算する仕様,
),
);
}
では、ということで「Article
オブジェクト生成のタイミングでは estimateTime
を含めなくてもよい」という作りにしようとすると Article
クラスの estimateTime
は nullable にせざるを得ず、Article
オブジェクトを扱う場面で常に null の考慮をしたり !
による強制的な変換が必要になってしまいます。これでは Article
オブジェクトを使う側が どのような場合に estimateTime
が null
になるのか を意識しなければならず、コーディング時に思考を停止させる要因になり得ます。
同じことが「その記事の ID となる文字列」にも言えるでしょう。場合によっては実際にデータベースに保存してみるまでその記事の ID が振られないことも考えられます。じゃあその場合にどうするのか、という仕様も View 側で判断せずに BusinessLogic にお任せするのがこのアーキテクチャの考え方です。
そのほかにも、「長すぎる(短すぎる)場合はエラーとして受け付けない」ような異常系の仕様も BusinessLogic で処理したいところです。
以上のことから、Controller が BusinessLogic 値を渡す際はユーザーが入力したそのままの形であることが望ましい と考えています。
View レイヤーについてまとめ
以上が View レイヤーに対する基本的な考え方です。さらに細かいところでは、
- Controller と Widget が 1対1 である必要はない。
- 特定の画面専用の Controller と考えるのではなく、特定の機能に紐づける形で Controller を分割する。
- Controller と BusinessLogic が 1対1 である必要もあまりない。
- UI 上扱いやすい分割方法を考える。
など Controller の分割方法についても考えがあったりしますが、ここでは長くなる(かつあまり確固たる方針があるわけではない)ため割愛します。
いずれにしても、View が行うのは UI の構築とそのためのデータの取得、またユーザーの操作を処理する適切な BusinessLogic を選択すること とまとめられるかと思います。
View レイヤーはもっとも変更が多く、かつ別アプリの開発にあたっては「全とっかえ」が発生するレイヤーです。そのため、このレイヤーは 「見た目上の都合」を吸収することに特化し、「どのような見た目であっても変わらない処理」は極力 BusinessLogic にお任せする のが基本的な考え方となります。
BusinessLogic
BusinessLogic ではそのアプリがユーザーに提供する価値を実現します。
ユーザーがソフトウェアを利用するということは、そのソフトウェアに期待する動作(特にデータの処理に関するもの)が必ずあるはずです。
たとえばストップウォッチアプリには「正確に経過時間が測定できること」を期待し、勤怠管理システムには「出退勤の記録」と「目的に応じた記録の一覧」といった機能を期待するでしょう。
これらの機能は、アプリの見た目がどうであろうとユーザーが期待する動作は変わりません。経過時間をデジタル表示する場合もアナログ表示する場合もあるでしょうし、モバイルアプリではなく CUI アプリケーションとして、たとえば dart stopwatch.dart start
のようなコマンドベースでストップウォッチを操作する場合もあるでしょう。きっと。
いずれの方法でユーザーに機能を提供する場合でも使いまわせる 「正確に経過時間を測定する」という機能 を、このレイヤーで実現するわけです。
アプリ内で処理しなければならないこと
とはいえ、多くのアプリでこの部分をバックエンドが担当することが多いのではないでしょうか。
アプリはユーザーの入力をそのまま API を叩く形でバックエンドに送信し、細かい処理はバックエンドで行って結果のみをレスポンスとして返却します。
そのため、前回の記事の反応を見た感じでも「アプリにこのレイヤー必要?」という意見がいくつかあったように思います。実際このレイヤーを明確に分離する必要が無いアプリも多いであろうことは私も同意です。[5]
しかし今回のアプリでは
- アプリ内で完結するサービス上大事な機能がいくつか存在する
- 仕様上、データを
Stream
でリアルタイムに変更を取得する Firestore の機能を多用したいため、Function を通してのデータ取得が難しい。
といった事情があり、このレイヤーを明確に用意しています。
依存性の逆転
処理を行う上でデータベースへのアクセスを必要とする場合、Repository レイヤーを通して行います。
しかし何も考えずにそれを行ってしまうと、BusinessLogic が Repository に依存する 状態となってしまい、依存の方向が設計と異なってしまいます。
// Repository レイヤーへの依存!!
import 'package:myapp/repository/firestore_article_repository.dart';
final FirestoreArticleRepository repository = // 何らかの方法でオブジェクト生成
Future<void> post({required String title, required String body}) async {
final article = Article(
// 何らかの仕様に従って Article オブジェクトを生成
);
await repository.save(article);
}
詳細は後述しますが、今回のプロジェクトでは Repository レイヤーも View レイヤーと同じように交換可能であることが求められます。
たとえば「こっちのアプリではデータの保存先をオンプレの MySQL にしたから API 経由でやりとりしてね」となった場合、BusinessLogic レイヤーの ArticleLogic
が Repository レイヤーの FirestoreArticleRepository
を import している状態だと、Repository レイヤーをごっそり交換した際にそれに合わせて BusinessLogic レイヤーのコードも全部書き換えなければならなくなってしまいます。
// Repository レイヤーの全とっかえに伴う import の書き換え
- import 'package:myapp/repository/firestore_article_repository.dart';
+ import 'package:myapp/repository/http_article_repository.dart';
final HttpArticleRepository repository = ...;
クラス名だけでなくメソッドの定義や使い方まで変わってしまった場合は目も当てられません。
そこで、「依存性の逆転」のテクニックを利用して依存関係を望ましい方向に調整 します。
方法は以下の通りです。
- Repository レイヤーで実装すべきインターフェースを BusinessLogic レイヤーに 定義する
- BusinessLogic クラスはそのインターフェースにのみ依存する
- インターフェースを継承した具象クラスを Repository レイヤーに実装する。
- Repository レイヤーに実装した具象クラスは、何らかの DI の仕組みを使って BusinessLogic クラスに渡す。
このあたりを図にすると以下のような形です。
BusinessLogic レイヤーと Repository レイヤーの間の矢印を見ると、青色の「処理の流れ」は BusinessLogic -> Repository となっているものの、赤色の 「依存の方向」は逆向きの BusinessLogic <- Repository になっている のが見てとれるのではないかと思います。
このことをソースコードでも確認しましょう。
まずはインターフェースです。インターフェースの記述方法は言語によってまちまちですが、Dart を使う今回は abstract class
を利用します。
abstract class ArticleRepository {
Future<void> save(Article article);
}
ポイントは BusinessLogic レイヤーの中に定義することです。Repository レイヤーではありません。ArticleLogic
クラスがいくらインターフェースで扱ったとしても、インターフェース自体が Repository レイヤーに定義されていたら「Repository レイヤーに依存する」形になってしまうためです。
次にこれを使う ArticleLogic
クラスです。
import 'package:myapp/businesslogic/interface/article_repository.dart';
class ArticleLogic {
ArticleLogic(this.repository);
// インターフェースで扱って具象クラスはコンストラクタで受け取る
final ArticleRepository repository;
Future<void> post({required String title, required String body}) async {
final article = Article( ... );
await repository.save(article);
}
}
見ての通り、import 文に /repository
は出てきません。同じ BusinessLogic レイヤー内のクラスにのみ依存しているのがコードからも読み取れます。
最後に Repository です。
import 'package:myapp/businesslogic/interface/article_repository.dart';
class FirestoreArticleRepository extends ArticleRepository {
Future<void> save(Article article) async {
// 何か Firestore へ保存する処理
}
}
ArticleRepository
を継承するため、import 文には BusinessLogic レイヤーへの依存が記述されています。これも図の通りですね。
これで、処理の流れと依存関係がアーキテクチャの図の通りになりました。最後にここで定義した FirestoreArticleRepository
のオブジェクトを生成して ArticleLogic
のコンストラクタに渡す DI の仕組みについてですが、これについてはアーキテクチャの話から少しそれるため別で記事にまとめられたらと思います。
BusinessLogic レイヤーについてのまとめ
BusinessLogic レイヤーでは、アプリがユーザーに価値を提供するための「機能」そのものを実装します。それぞれの機能には詳細な仕様や要件が存在するのが通常だと思いますので、その要件を可能な限りこのレイヤーにまとめて実装し、それによって ガワを入れ替えた別アプリでも使いまわせる ことを狙っています。
処理を進める中でデータアクセスするために Repository レイヤーを利用する場合は、そのまま呼び出して Repository レイヤーへの依存を作ってしまうのではなく、インターフェースをうまく利用して依存の方向を逆転し、Repository レイヤーが別アプリで総取り替えになったとしても BusinessLogic レイヤーのコードは一切書き換えなくてよい設計 を実現しようとしています。
Repository
最後は Repository レイヤーです。
Repository レイヤーはデータを保存するデータベースとの具体的なやりとりを記述します。
役割としてはデータベースから取得したデータを BusinessLogic レイヤーに返却したり、逆に BusinessLogic レイヤーから受け取ったデータをデータベースに保存したりするわけですが、この際に発生する「データベース特有の事情」を吸収するのが Repository レイヤーの仕事です。
データベース特有の事情
たとえば、「下書き状態の記事」を表すデータのデータベース上の表現方法はいくつかのパターンが考えられます。
- 「下書きフラグ」として bool 型で表す場合
isDraft: true
- 「状態」として文字列で表す場合
status: "draft"
アプリ内では enum Status { published, draft }
として保持していたとしても(もしくは逆に bool isDraft
のフラグで保持していたとしても)、その形式をそのままデータベースに保存すれば良いかというと、それは状況次第です。
データベースが扱える型の制約、利用状況を集計したいなど同じデータを別用途で使うための工夫、過去に定義してしまって変えられないデータ構造など、データベース固有の事情というのは往々にして発生します。この事情を BusinessLogic レイヤーや View レイヤーに影響させないのが Repository レイヤーの仕事 と言えます。
Future<void> save(Article article) async {
// Firestore に保存するために Map に変換(実際は freezed を使ってます)
final documentFields = <String, dynamic> {
'title': article.title,
'body': article.body,
'isDraft': article.status == Status.draft, // データベース上はフラグで扱うため、true / false を判断
};
// documentFields を Firestore に保存する処理
}
今回のプロジェクトでは、後々の機能追加や派生アプリのことを考慮するとファーストリリース時の仕様にデータ構造を合わせるわけにはいかない場面が多々あり、ビジネスサイドから新しい構想が上がるたびにデータ構造が見直される事情がありました。
そのたびにアプリ全体に影響を出していては実装が進まないため、このように Repository 層でそのような「データベースの事情」を吸収する、という作戦です。
ダミーデータを生成する具象クラス
依存の方向を逆転するために用意したインターフェースですが、同じ仕組みを活用して「ダミーデータを返却するクラス」の実装も容易になりました。
これは Firestore に実際にアクセスするのではなく、ソースコードにベタ書きしたりメモリ上に一時的にデータを保管したり引き出したりすることで BusinessLogic レイヤーとのデータのやりとりを行うクラスです。
import 'package:myapp/businesslogic/interface/article_repository.dart';
class InMemoryArticleRepository extends ArticleRepository {
final _data = <Article>[];
Future<void> save(Article article) async {
_data.add(article); // リストに追加するだけ
}
Future<List<Article>> fetchAll() async {
return [..._data]; // リストのコピーを返却するだけ
}
}
先述の通り、バックエンドは先々の構想に従ってデータ構造(Firestore の場合はセキュリティルールなど)が頻繁に変化します。そこで発生する少しの変化によってアプリ全体がエラーで動かなくなり、その変化に追従する対応を優先して BusinessLogic レイヤーや View レイヤーの実装をストップしなければならないとなると開発効率が下がってしまいます。
そこで、バックエンドの事情を一切考慮することなく「とりあえずのダミーデータ」を使って他のレイヤーを実装するための仕組みがこのダミークラスです。これらは実行時の --dart-define
ひとつで切り替えられるようにしてあります。
同じ発想で「GPS から取得する位置情報」も実際の端末に搭載された GPS を利用するクラスと適当な固定値を返却するダミークラスで切り替えられるようにしています。これは机上でも外の移動をシミュレートするのに役立ちます。
その際、「定期的に通知される位置情報」は Stream<Coordinate>
型で返却するようにインターフェースを定義し、具象クラスでは StreamController
を使って Stream
自体を生成するようなことをしています。
abstract class LocationRepository {
Stream<Coordinate> locationStream();
}
class GpsLocationRepository extends LocationRepository{
Stream<Coordinate> locationStream() {
late final StreamController<Coordinate> controller;
void startLocation() {
// GPS を取得するパッケージを呼び出して位置情報を定期的に取得
// 位置情報が取得できたら controller.add() を呼び出す
}
void stopLocation() {
// GPS の取得を停止する処理
}
final controller = StreamController<Coordinate>(
onStart: startLocation;
onCancel: () {
stopLocation();
controller.close();
}
);
}
}
class FakeLocationRepository extends LocationRepository{
Stream<Coordinate> locationStream() {
late final StreamController<Coordinate> controller;
late final Timer timer;
void startLocation() {
var currentLocation = Coordinate(latitude: 123.45, longitude: 45.678),
timer = Timer.periodic(const Duration(seconds: 3), (_) {
currentLocation = Coordinate(
latitude: currentLocation.latitude + 0.1, // 適当に動かす
longitude: currentLocation.longitude + 0.1, // 適当に動かす
),
controller.add(currentLocation); // 通知する
});
}
void stopLocation() {
timer.cancel();
}
final controller = StreamController<Coordinate>(
onStart: startLocation;
onCancel: () {
stopLocation();
controller.close();
}
);
}
}
蛇足ですが、同様の発想で SharedPreferences
へのデータ保存も端末内に実際に保存するモードとメモリ上の Map<String, dynamic>
型のフィールドで管理するだけのモードを切り替え可能にしています。(主に「アプリ再インストールして SharedPreferences が消えちゃったケース」をエミュレートしやすくするため)
Repository についてのまとめ
Repository レイヤー自体の役割をひとことで表すと「データベースの事情を吸収する」になりますが、インターフェースを介して交換可能である性質を活かしてダミー的なクラスに簡単に切り替えて実行するという仕組みも実現しています。
いろいろな未来を考慮しながらの開発ではデータベース自信の事情もあれこれ発生しがちなため、ここを切り離す設計はとても役に立っていると感じています。
まとめ
以上、各レイヤーの役割と、なぜそれらの依存関係を整理して切り離しやすくしているのかについて説明してみました。
重ね重ねですが、この 3 つのレイヤーに分けてそれぞれに役割を持たせた理由は「クリーンアーキテクチャ本にそう書いてあったから」ではありません。クリーンアーキテクチャ本から理解できた「レイヤーの分割」と「依存関係のルール」を意識しつつ目の前の要件や状況とにらめっこしながら考えた結果出来上がった のがこの設計です。
そのため、この記事の内容を他のプロジェクトに「そのまま適用する」してもあまり意味はなく、みなさんの目の前のプロジェクトに目を向けた時にみなさん自信が最適な設計を考えるためのひとつの材料としてこの記事が役に立てたら嬉しいです。
また、この設計自体もまだいろいろと改良の余地があり、開発していると「あれ、これどこのレイヤーに書こう?」「この機能だと BusinessLogic レイヤーがただの土管だなあ、、」「なんかめっちゃ回りくどいことしてる気がする、、!」などいろいろと悩みどころが出てくるのも事実です。そのあたりは引き続き頭を悩ませながら最適な形を模索していければと思います。
-
少なくとも私はこのアプリのアーキテクチャを考える上でこのことを一番意識していました。 ↩︎
-
getter は内容により審議です。 ↩︎
-
が、同じものを表すオブジェクトでもレイヤーによって欲しいフィールドや必須なフィールドが違ったりする場合がちょこちょこあり、この記事を書いている時点では若干この作戦がミスだったような気がして悩んでいます。設計って難しい、、 ↩︎
-
適切なアーキテクチャはアプリの仕様のみならず、ビジネスの方向性やプロジェクトの状況によっても変化するもので、「Flutter アプリならコレ」というオススメのアーキテクチャ 1 つを常に適用すれば良いわけではないことは繰り返し主張していきたいところです。 ↩︎
-
というか私の経験上だけで言うと不要なアプリの方が多いです。 ↩︎
Discussion
すみません、前回記事の方でも質問したものです🙏🏻今回の記事も大変わかりやすかったのですが、一点だけ不明なところがありました。
https://user-images.githubusercontent.com/20849526/194087064-d12d2cab-4def-4854-bd47-8c98b22c5741.png
この形だと、ビジネスロジックからview を呼び出す所がやはり BL → view の依存関係となり、やはり依存関係逆転が必要になってくる感じがしますがそうなのでしょうか?
見落としていましたら申し訳ありません🙏🏻
こちらもコメントありがとうございます!
あ、たしかにこの図だと BusinessLogic -> View で呼び出しているように見えますね、、!
正確には、 View から BusinessLogic を呼び出した結果が戻り値として戻ってくるというデータの流れを書いただけですので、依存関係ができているわけではありません。
という説明で伝わりますでしょうか?
あ、伝わります伝わります!!シングルリクエストなら await で返してもらって、ストリームリクエストならStreamController とかを BL から返してもらって、View の中で StreamProvider かなんかでラップする感じですね。(すみません、Flutter 初心者なので細かいところ間違って言ってるかもしれません)
よかったです!イメージ合ってると思います!
Stream
も実はこのアプリで多用しているので、そのあたりの詳細も別途書ければと思います。このアプリでの細かい実装内容はそこでまた見てみていただければ。