仕事でiOSアプリをVIPERアーキテクチャで1から書く機会があり、せっかくなのでその知見をまとめることにしました。
なぜ採用したか?実際作ってみてどうだったか?まで、赤裸々にまとめたのでアークテクチャ選定の参考になれば幸いです。
VIPERに関してはPotatotipsでも発表した資料もあるのでこちらもご覧ください。
(サンプルアプリは、TwitterクライアントにしたけどGitHubクライアントに書き直すかも)
VIPERとは?
各レイヤーの頭文字の組み合わせで VIPER と呼びます。
最大の特徴は、クリーンアーキテクチャをベースにしたiOSアプリのために作られたアーキテクチャであるということです。Routingの層がデフォルトで存在しています。
各レイヤーの役割と説明
##### View ViewController, ViewなどInteractor
- Presenterのイベントに応じてユースケースごとにデータの取得、加工をする層
- バリューオブジェクト(Entity)を操作するビジネスロジックが含まれる
- Viewからは完全に分離している
Presenter
- Viewに対するプレゼンテーションロジックを持つ層
- ViewとRouter, Interactorの橋渡し的存在
- Viewから受け取ったイベントをもとにInteractorにデータを要求する
- Viewから受け取ったイベントをもとにRouterに画面遷移を依頼する
- Interactorから受け取ったデータをViewに渡す
Entity
- バリューオブジェクト
Router
- 画面遷移を管理する
- 各レイヤーのインスタンスを生成し、画面を描画する(依存性をひとまとめに解決する層はここ)
単一責任の原則をもとに作られたアーキテクチャであり、各レイヤーはInterface(Protocol)にのみ依存している。
なぜVIPERを選んだか?
今までMVC, MVVMでアプリを書いた経験の中で、下記の課題を持っていました。
- ルーティングはセグエやViewControllerに依存せず分離したい
- データの取得・加工だけをする層がないと結果、Viewに関するプレゼンテーションロジックとデータに関するビジネスロジックが混在する(FatViewModelになる)
- 依存性をひとまとめにする層が欲しい
- 人によって実装がバラバラで属人化する傾向が..
VIPERを採用した理由
リリースまでスケジュール的に余裕がないなか、アプリの要件を満たす必要充分なレイヤーがVIPERにあり、しっくりきた。
何よりサンプルを作ってみて、iOS開発していて辛いと感じていた課題を解決できるレイヤーが揃っている点が良くVIPERを採用しました
今までの設計に抱いた課題 | VIPERを採用すると |
---|---|
ルーティングが欲しい | Routerが担保してくれる |
Viewのロジックとデータに関するロジックが混在 | Presenter, Interactorで明確に分離されている |
依存性をひとまとめに解決する層がない | Routerがインスタンスを生成し依存性をまとめる役割を担っている(後述) |
属人化する | レイヤーが明確なため属人化しづらい |
VIPERにして良かった点
依存性をひとまとめにする層があること
Router層は文字通り、Routingに責務を負いますが、各レイヤーの依存性を解決する役割も担保します。
こんな感じで、staticなメソッドを定義してここからある画面に関連するレイヤーの依存性を解決してViewのインスタンスを取得できます。
final class PasswordResetRouter: PasswordResetWireframe {
weak var viewController: UIViewController?
required init(viewController: UIViewController) {
self.viewController = viewController
}
// 依存性の解決
static func assembleModule() -> UIViewController {
let view = StoryboardScene.PasswordReset.initialScene.instantiate()
let router = PasswordResetRouter(viewController: view)
let interactor = PasswordResetInteractor()
let presenter = PasswordResetPresenter(view: view, router: router, interactor: interactor)
view.presenter = presenter
interactor.output = presenter
return view
}
// 普通の画面遷移
func closeView() {
self.viewController?.dismiss(animated: true, completion: nil)
}
}
例えばDelegateパターンでDelegateの受け渡しが必要なケースなど、画面間で値渡しが必要なケースで、画面間の依存関係が一目でわかるのは便利です
static func assembleModule(with modalBaseView: ModalBaseViewDelegate) -> UIViewController {
let view = StoryboardScene.LoginView.initialScene.instantiate()
let router = LoginRouter(viewController: view)
let interactor = LoginInteractor()
let presenter = LoginPresenter(view: view, router: router, interactor: interactor)
view.presenter = presenter
view.modalBaseViewDelegate = modalBaseView
interactor.output = presenter
return view
}
テストが書きやすい
テストに関しては、単体テストのハジメのスライドを参考にテスト用のスタブとスパイに差し替えてテストする手法を取っているのですが、各レイヤーがInterface(Protocol)にのみ依存しているとそれが簡単にできます。
例えばある機能 XXX
がない場合、TableViewのセクションが表示されないかどうかテストするコードは下記です。
Interactorのデバイスにある機能がサポートされているか返すComputedプロパティ
をモックに差し替えて、Viewの構造(TableViewのSectionの数とか)を保持するクラス viewStructure
が意図した状態になっているかテストしています。
class NotAvailableXXXMock: AppSettingsUsecase {
var output: AppSettingsInteractorOutput!
var isAvailableXXX: Bool = false
}
class AppSettingsTests: XCTestCase {
let view = AppSettingsViewController(nibName: nil, bundle: nil)
var presenter: AppSettingsPresentation!
var router: AppSettingsRouter!
func testXXXSectionIsHiddenWhenNotAvailableDevice() {
let mock = NotAvailableXXXMock()
setupPresenter(with: mock)
presenter.viewDidLoad() // ここでViewStructorの初期化処理とか書いてる
let sections = presenter.viewStructure.sections
XCTAssertFalse(sections.contains(.XXX))
}
private func setupPresenter(with mock: AppSettingsUsecase) {
presenter = AppSettingsPresenter(view: view, router: router, interactor: mock)
view.presenter = presenter
}
}
後からチームに入った人がすぐ馴染めた
これは感覚値でしかないのですが、僕がチームを抜けたあとJoinしたメンバーが、1回レクチャーをしただけですぐアプリの実装に取りかかれてPRを送ってくれ「分かりやすい」と言ってくれました。
おそらくVIPERがiOS用に作られたアーキテクチャで、程よいレイヤーで必要なポイントを抑えている点が生きたのだと思います。
書いてみて工夫した点
Embedded Frameworkの単位を工夫した
Embedded Frameworkを採用しましたが、構成については画面に依存するレイヤーと依存しないレイヤーで構成を分けることを意識してシンプルにしました。
具体的には、
画面ごとに必ずセットになるレイヤーView, Presenter, Interactor, Routerはメインのモジュール内に画面ごとにディレクトリを切り、画面に依存しないAPI層, Entity, Utillity, StyleはEmbedded Frameworkとして別モジュールにしました。
こうすることによって後から入ったメンバーがある画面を改修する際、改修の範囲が視覚的にすぐ認識できて良いです。
ボイラープレートは自動化した
レイヤードアーキテクチャを採用するとコード量が増えますが、そこはコードジェネレータのKuri(https://github.com/bannzai/Kuri) を使って解決しました。
テンプレを用意しているので、ある画面を開発するときにコマンド1つで先述した1セットが用意できます。
反省点
Viewにモデルを保持したコードを書いてしまっていた
開発の途中というかテストを書くまで、Viewにmutableなモデルを保持したコードを書いてしまっていました。これをやってしまうと、仮にView内でモデルを差し替えたりした場合、Spy, Stubを使ったテストの結果が担保できなくなるのがNGポイントです。
途中からは先輩のコードを参考にViewの構造を管理するStructを作って、Presenterで保持することで、TableViewなどにmutableなModelを持たなくともViewStructureを参照してViewを構築できるようにしました。
struct AppSettingsViewStructure {
private let interactor: AppSettingsUsecase
private var socialLoginEnabled: Bool
var sections: [Section] {
return [.user, .logout]
}
private(set) var userRows: [UserRow] = {
return socialLoginEnabled
? [.mail, .password, .social]
: [.mail, .password]
}()
enum Section {
case user
case logout
var title: String? {
switch self {
case .user:
return "アカウント設定"
case .logout:
return nil
}
}
}
テストコードがまだ少ない
これはありがちな課題ですが、テストコードがまだ少ないです。実機でモンキーテストすると再現が複雑なケースの一部のみにテストを書いて、必要だと思う箇所にも書けていません。
リリースが落ち着いて、テストで品質を担保するフェーズになったのでテストは増やしたいところ..。実際、テストを書いたところリファクタリングに繋がったこともあったので、リファクタリングがてらやるのが良さそう。
参考リンク
実装前に参考にした記事は下記です。最近は国内でもVIPERの記事が増えてきたと思います。
まとめ
1本アプリを作ってみて、それなりの規模と複雑性のあるアプリを仕事で作るなら、今後もVIPERをベースのアーキテクチャに採用するかなぁといった感じです。
アーキテクチャの選定はケースバイケースですが参考になれば幸いです。