2018/11/10 追記
かなり時間が経ってしまいましたが、当時の理解が甘かったところがあり、CleanArchitectureというよりも、レイヤごとに分離させたという方が適切な表現であると気がつきました。
では何をもってCleanArchitectureなのか?というのは、こちらの記事が参考になりました。
https://qiita.com/gki/items/f601afbfada85fd8624e
——
概要
自社サービスのアプリのアーキテクチャにMVVMを採用していたのですが、
- ViewModelやModelが肥大化
- Mの部分が上手く実装できておらず、データ層の取り回しやビジネスロジックまわりに不便を感じてきた
などの課題があったので、CleanArchitectureを参考に「データレイヤを厚めにする+ビジネスロジックの分離」を行ってみましたという内容です。
各クラスの名前はちょっと変えてしまっていますが、部分的な考え方としてはCleanArchitectureに寄せていると思います。
現在のアプリの規模(中規模くらい)では必要無さそうなところは削ぎ落としています。(TranslaterやDomain層のModelなど)
部分的に移行が進んでおり、現在上手くいっているように見えているので投稿してみました。
※CleanArchitectureについては、主にこちらを参考にしました。
http://qiita.com/koutalou/items/07a4f9cf51a2d13e4cdc
全体像
図上の各クラスについて
DataManager
基本はEntity単位で作成します。Userなら UserDataManager
など。
DataStoreのインスタンスを保持して利用し、データの取得や保存などの取り回しを行います。
必要に応じてLocalDataStoreのキャッシュを返す処理を行ったりします。
また、疎結合にするためprotocolを定義してそれに準拠させます。
RemoteDataStore
主にAPIにアクセスしてEntityを返すクラスです。
API以外の例が浮かびませんが、一応Entityとデータソース単位で作成します。 UserAPIDataStore
など。
LocalDataStore
ローカルのストレージにアクセスしてEntityを返すクラスです。
こちらも必要に応じてEntityとデータソース単位で作成する想定です。(通常は1つになることが多いと思います)
Realmへのアクセスなら UserRealmDataStore
、SQLiteへのアクセスなら UserSQLiteDataStore
など。
ストレージに依存するロジックはここに書きます。(例えばRealmへのアクセスロジックやデータ追加時の専用のクラスへの変換など)
Usecase
Usecaseは機能仕様を表すクラスで、単体テストを意識して書きます。画面単位で作成し、DataManagerを保持します。
主にData層からの情報をその画面の機能仕様に合わせて操作します。
例えば「商品情報を新着順で表示する」という仕様の画面のUsecaseであれば、商品の公開日順にソートするといったロジックが含まれますし、「フォームの入力チェック」を行うのであれば、バリデーションのロジックが含まれます。
この作りではEntityに依存していますが、必要に応じて複数のEntityに対してUsecaseを使い回す必要がある場合は、Entityを共通のDTOに変換してUsecaseとEntityの依存を切ってしまう方が良いと思います。(特殊なケース)
ViewModel
ViewModelはUsecaseを保持し、主に画面仕様を表します。
例えば usecase.orderStatus()
が .shipped
であれば 出荷済み
というテキストを自身の持つ statusText
にセットします。
(場合によってはbindすることもあります。)
チェックボックスの選択状態など、Viewの状態も保持します。
原則、画面仕様だけが変更になっても手を入れる必要があるのはViewModelまでになる想定です。
また、Data層のクラスには依存しないようにします。(Entityも含めてViewModelは知らない状態。)
View
ViewはViewModelを保持します。ViewModelが保持する変数をbindし、基本その内容に従うだけになるようにします。そうすることでViewModelのテストで間接的にView側もカバーできるようになります。(View側で余計なロジックを挟まずにただbindだけするようにしておくことで、表示内容やあるべき状態については担保できるようになる。ただしbindのミスやレイアウト、Viewのライフサイクル関連のバグなどについてはカバーできない。)
例えば 手配中
出荷済み
配送済み
のステータスを表示する statusLabel
があるとしたら viewModel.statusText
を statusLabel
にbindします。
(上に書いたように、ViewModelはUsecaseの内容から statusText
に適切な文字列をセットします)
Router
図にはありませんが、画面の遷移を担当するクラスとして切り出す想定です。
(規模によっては必要になりそうです。)
Module
図にはありませんが、重い機能や全画面から利用される機能の一部はModuleという形で切り出す想定です。
実感したメリット
単体テストが書きやすい
(今までModel部分をちゃんと書けていない箇所があったからというのもあるのですが…)
ビジネスロジックを画面単位でUsecaseに分離したことや、単体テストの対象は原則Usecaseの全てというわかりやすいルール化ができたこともあり、テストを意識して書きやすくなった感じがします。
※追記
別のプロジェクトではViewModelもテスト対象にしました。
画面ごとの機能を把握しやすい
画面ごとに必要なビジネスロジックがUsecaseにまとまっているので、画面の機能仕様はUsecaseを見れば把握できるようになっています。特にUsecaseはコメントをしっかり書くようにしていけば、後から入った人も比較的理解しやすいのではないかと思います。
(今は、Usecaseは特にコメントを重点的に書くというルールにしています。)
Data層の差し替えがしやすい
例えば、DIコンテナを使えば簡単にスタブとして作成したDataManagerと差し替えができます。
その他では、なかなか無いですがLocalへの保存方法を変更する時にはDataManagerを差し替えるだけで対応可能になります。
(ちなみにSwiftの単体テストではDIを使った差し替えではなくテストケースに合わせたManual Mockingを行うべきだと思います。)
コードが追いやすい
これは元々の作りにもよりますが、どこに置くべきかわからないコードが散ってしまっていたこともありまして、その時よりはクラスごとの責任がハッキリしてコードが追いやすくなりました。(その代わりクラスも増えました。)
改修などで手を入れるときにもイメージがしやすくなったと思います。
デメリット
ファイル数が増える
元の作り次第ですが、恐らく増えてしまいます。
要検討
画面をまたぐ共通のビジネスロジックをどこに配置するか
SwiftだとProtocolExtensionにするか、DataManagerに置いてしまうか、別クラスにするかなどその時によりそうです。
最後に
今の所デメリットも少なく良い方向に向かったと実感できているので、次に新規で開発する際にも候補の一つとして考えようと思っています。
最適な設計はアプリのフェーズや規模感によっても変わってくると思いますが、何をどこに置くかについての大方針だけでも定めるのは大切だとあらためて思いました。
まだまだ書ききれていない細かいことはあるのですが、基本はこんな感じです。