Clean Architectureとその派生系による最適なiOSアーキテクチャ選定の考察
はじめに
おばんです、給料日を過ぎたらいつでも給料日前、田中です。
この記事はSwift Advent Calendar 2016, 18日目の記事になります。当日より遅れての投稿になってしまいすみません!!
もくじ
- 対象読者
- この記事はなに?
- パターン紹介(クリーンアーキテクチャでの実装)
- パターン紹介(クリーンアーキテクチャ + Wireframe(VIPER Routing)での実装)
- パターン紹介(クリーンアーキテクチャ - DIでの実装)
- まとめ
- 参考・関連
対象読者
- これからiOSでクリーンアーキテクチャを実装しようと思っている方
- iOSのクリーンアーキテクチャを少しでも書いてる人
- 「もうやだクリーンアーキテクチャの実装重すぎだよおおおぉぉぉぉ」と思っている方
この記事はなに?
クリーンアーキテクチャは昨今の複雑なアプリケーション開発において非常に有効な設計ではありますが、同時に開発コストを要する点や包含する要素が多いために理解が難しい点などがデメリットとして挙げられます。この記事では私がクリーンアーキテクチャでアプリを実装した経験をもとにして、クリーンアーキテクチャのメリット/デメリットをプロジェクトの規模や複雑さに応じて要素を活かし、状況に合わせて使える形にできないかと試した以下の3つのパターンを紹介していきます。
- クリーンアーキテクチャでの実装
- クリーンアーキテクチャ + Wireframe(VIPER Routing)での実装
- クリーンアーキテクチャ - DIでの実装
私がクリーンアーキテクチャでアプリを実装する際には@koutalouさんが公開している以下の二つの資料をもとに実装をしていますので、これら二つも合わせてお読みください。
パターン紹介
クリーンアーキテクチャでの実装
まずデフォルトの形のクリーンアーキテクチャでの実装を紹介します。
// Twitterのリストを表示するためのViewControllerを起点としたBuilder // DIによって構築されている struct ListBuilder { func build() -> UIViewController { let listVC = UIStoryboard(name: "Main", bundle: nil).instantiateViewController(withIdentifier: "ListViewController") as! ListViewController let listRepository = ListRepositoryImpl() let listUseCase = ListUseCaseImpl(repository: listRepository) let presenter = ListPresenterImpl( listUseCase: listUseCase, viewOutput: listVC ) listVC.inject(presenter: presenter) return listVC as UIViewController } }
// Presenterを抽象化するためのインターフェース // DIを行ってテストする際などに、このprotocolに準拠したテスト用クラスに置き換える protocol ListPresenter { func getLists() }
// Twitterのリストを表示するためのPresenter class ListPresenterImpl: ListPresenter { private var listUseCase: ListUseCase! private weak var listVCOutput: ListVCOutput! init(listUseCase: ListUseCase, viewOutput: ListVCOutput) { self.listUseCase = listUseCase self.listVCOutput = viewOutput } func getLists() { listUseCase.getLists { [weak self] result in switch result { case .failure(let error): self?.listVCOutput.showEmptyState() case .success(let lists): self?.listVCOutput.showLists(lists: lists) } } } }
BuilderというApplication層の中の役割によってDIを行いつつ、各役割の間にはインターフェースを作成してそのやり取りを行うという作り。ここではBuilderの他にPresenterを例として載せています。ListPresenterImplがListVCOutput protocolに準拠させたListViewControllerを弱参照で保持することにより、ビジネスロジック(UseCase)から返ってきた結果に応じて、ViewControllerに描画ロジックを書くことなく描画を行うことができるという作りになっています。これによりViewControllerの肥大化を防ぐこととなります。
このようなクリーンアーキテクチャには以下のようなメリットとデメリットがあります。
メリット
- ViewControllerの肥大化を防げる
- 層と役割が明確に分かれているので、チームメンバーの経験に依ることなく設計を統一できる
- 各役割の独立性が高いため、テスタビリティが高い
特にチームメンバーの経験に依る設計思想のばらつきを少なくすることができる点は非常に優れています。例えばMV○系のアーキテクチャの"M"にあたるModelという線引きはあまりにも漠然としていると私は思っています。経験値の高いメンバーが考えるModelと新卒ぺーぺーのまさに自分のようなエンジニアが考えるModelとで、先を見通した抽象化やグルーピングの仕方に差が出るのは当然のことです。そこを埋めてくれる機能を果たすことはチーム開発においてとても良いことです。
デメリット
- 理解が難しい
- ファイル数・コード量が増える
開発コストが高いことがクリーンアーキテクチャのボトルネックです。先述したPresenterが一例にあるように、Presenter(インターフェース)-PresenterImpl(実装)、UseCase-UseCaseImpl、Repository-RepositoryImpl、......、と、ただ役割を分割するだけでも多くなるファイル数・コード量にそれぞれインターフェースを切り出して書く必要が出てきます。
利用すべき状況
- 長期にわたる開発・メンテナンスが必要になるとき
- 複雑な機能を有するアプリを開発するとき
- テストを重視した開発のとき
長期というのは、クリーンアーキテクチャを案件に導入してみたことのある人々の間でだいたい1年以上と考えてもらうと差し支えないかなという話がされています。期間のみならずどれだけ複雑な機能がアプリに含まれているかなどを考慮に入れて、もう少し短い期間ではあるけれど導入してみようという判断でも良いと思います。
利用すべきでない状況
- 短納期の開発のとき
- メンテナンスする予定のない開発のとき
- 作りがシンプルなアプリの開発のとき
短納期での開発やそもそもテストをしっかりとは回さないような場合にはクリーンアーキテクチャは向いていません。
クリーンアーキテクチャ + Wireframe(VIPER Routing)での実装
Wireframe(VIPERにおけるRouting)という役割をApplication層に追加したクリーンアーキテクチャの派生系です。
UIViewControllerのpresentなどの画面遷移の設計上、「ViewControllerがViewControllerのことを知っている(=依存している)」状況が発生しがちです。この部分を疎結合にし、ViewController同士がそれぞれを知ることなく画面遷移を行えるようにする役割がWireframeになります。
// UIViewControllerに採用させるProtocol // このプロトコルに準拠したViewControllerに対して、Wireframeが遷移先のViewControllerを生成して渡すためのインターフェース protocol Transitioner: class { func transition(to viewController: UIViewController, animated: Bool, completionHandler: (() -> ())?) }
// Wireframeを抽象化するためのインターフェース protocol TimelineWireframe { func transitionToPostTweetViewController(withTweetId tweetId: String?, screenName: String?) }
// Wireframeの実装 // transitionerに対して遷移先のViewControllerを生成して渡す struct TimelineWireframeImpl: TimelineWireframe { private weak var transitioner: Transitioner! init(transitioner: Transitioner) { self.transitioner = transitioner } func transitionToPostTweetViewController(withTweetId tweetId: String?, screenName: String?) { let postTweetVC = PostTweetBuilder().build(withTweetId: tweetId, screenName: screenName) transitioner.transition(to: postTweetVC, animated: true, completionHandler: nil) } }
// Transitionerを実装するViewController // Wireframeから渡されたViewControllerや、animatedなどの設定値をそのままpresentメソッドに渡すのみ extension TimelineViewController: Transitioner { func transition(to viewController: UIViewController, animated: Bool, completionHandler completion: (() -> ())?) { present(viewController, animated: animated, completion: completion) } }
メリット
- ViewController同士を疎結合にすることができる
デメリット
- 役割が増えるので、少し実装コストが高まる
利用すべき状況
元々のクリーンアーキテクチャを採用する余裕がある状況であればこのパターンは採用しても良いと、個人的には思っています。
利用すべきでない状況
元々のクリーンアーキテクチャ同様にテストを行う前提で、長期にわたる開発が行われる場合でない場合には利用すべきではないパターンになります。
クリーンアーキテクチャ - DIでの実装
DIを考慮しない、テスタビリティを度外視した実装。このパターンでは各役割ごとの独立性を切り捨てます。具体的には各役割間でのインターフェース(protocol)を捨てた実装のことを指し、その箇所はクロージャを用いて実装します。
// クリーンアーキテクチャの層のより内側にある役割を、インターフェースとして持つのでなく、実装のあるものをそのまま保持する class TimelinePresenter { private let timelineUseCase = TimelineUseCase() private weak var timelineVCOutput: TimelineVCOutput! init(viewOutput: TimelineVCOutput) { self.timelineVCOutput = viewOutput } func getTimeline(maxId: String?, list: List?) { timelineUseCase.getTimeline(maxId: maxId, list: list) { [weak self] result in self?.timelineVCOutput.endRefreshing() switch result { case .failure(let error): self?.timelineVCOutput.showEmptyState() case .success(let timeline): if let _ = maxId { self?.timelineVCOutput.showAdditionalTimeline(timeline: timeline) } else { self?.timelineVCOutput.showTimeline(timeline: timeline) } } } } }
「それってもはやクリーンアーキテクチャではないのではないか?」と思われるかもしれませんが、おそらくその通りです。クリーンアーキテクチャにおけるテスタビリティを意識するような要素はありません。ただし私はこれが悪いことばかりであるとも考えていません。このパターンがクリーンアーキテクチャのもつ高いテスタビリティ以外のメリットを未だ持っているからです。
メリット
- ViewControllerの肥大化を防げる
- チームメンバーの設計認識を統一できる
- 依存の単一方向性が守られている
デメリット
- 疎結合になっていない
- テストできない
利用すべき状況
「テストを回したりするような余裕はないが、複雑な仕様のアプリを作らなければいけないとき」に利用すると良いと考えています。クリーンアーキテクチャの「チームメンバーの設計認識を統一できる」メリットを享受でき、「依存の単一方向性が守られている」ことによりシンプルな作りにできているからです。ここで言うシンプルという言葉を補足すると、データと処理の流れが追いやすい状況を指しています。これはソースコードの可読性を高めてくれます。
利用すべきでない状況
このパターンの冒頭で述べたように、各役割の依存性が高くなっておりテストはとてもやりづらいです。そのためテストを行う必要のある状況では向いていません。
まとめ
クリーンアーキテクチャとその派生系となるものを試してみたという紹介をしました。
- テストが必要で1年以上の長期開発・メンテナンスが必要になるアプリ開発の場合 -> クリーンアーキテクチャパターン
- テストが必要で1年以上の長期開発・メンテナンスが必要になるアプリ開発の場合で、もう少し余裕がある場合 -> クリーンアーキテクチャ + Wireframeパターン
- テストを回す余裕はないが、複雑な仕様のアプリでメンバーの設計に対する認識を統一したい場合 -> クリーンアーキテクチャ - DIパターン
という三つのパターンがありました。それぞれに向き不向きや検討不足の部分もあるかと思いますが、今後の開発の参考になれば幸いです。
父からの言葉
「こんなに多くの役割に分けて、インターフェースやテストを考慮しなければならないほどにiOSアプリ開発も複雑な要求をされるようになったんだなあ」と人が話しているのを聞きました。インターフェースを考慮して、ここまで細かな設計をして〜という流れは、そういえば業務系のシステム開発では昔から取り入れられていたものだったという父の話を思い出しました。
私の父もエンジニアで業務系システムを書く人間なのですが、私がプログラミングを始めた頃にこんな言葉をもらいました。
「インターフェース設計がソフトウェア開発の肝だ」
当時は学び始めて間もなく、プログラミングどころか「ぷよぐやみんぐ」程度にしか理解のなかった自分にはその言葉の意味は全く理解できていませんでしたが、今年に入ってOSSライブラリを作ってみたり、こうやってクリーンアーキテクチャに触れてみるようになってその言葉の重さを痛感しました。
いやぁ、インターフェースまじ大事。今後もより理解を深めていこうと思います。