こんにちは、サービス開発部の森川 (@morishin127) です。主にクックパッドの iOS アプリの開発に携わっています。
日々アプリを開発する中で、近頃は最適なアーキテクチャとは何かを考えながら色々な形を試行錯誤しています。世の中で採用されているモバイルアプリのアーキテクチャには様々なものがあります。MVC, MVP, MVVM, VIPER, Clean Architecture などなど。開発している、あるいは開発しようとしているアプリケーションでどういったアーキテクチャを選択するかというのは難しい問題です。選択するためにはアーキテクチャに求める要件を定義する必要があります。この記事では私がアーキテクチャに求める要件と、それらをある程度満たすと考えた MVVM と Flux という2つのアーキテクチャで実装したサンプルを見つつその長所・短所について考えてみようと思います。
アーキテクチャに求める要件
アプリケーションの性質や開発チームの状況によって適したアーキテクチャが異なるため、モバイルのアプリのアーキテクチャには様々なアイデアがあります。クックパッドの iOS アプリはおよそ十数人程のメンバーで同時に開発しており、ある人の書いた実装を別の人が修正あるいは機能追加することも日常的に行われています。こういった開発体制の中で私がアーキテクチャに満たしてほしいと思う要件は下記のようなものです。
レイヤ毎の役割分担が明確
- 実装者によって実装の仕方がバラバラになるのを防ぐため
アプリケーションが持ちうる状態と状態遷移を人間が把握しやすい
- 想定外の状態はバグを生むため
レイヤ間が疎結合でありにユニットテストが書きやすい
- テストがあると安全に変更を加えられるため
Apple の UIKit には UIViewController というクラスがあり、素直に実装すると Model と ViewController の二層構造になります。この構造が悪いという話ではないのですが、機能が増え実装が複雑になっていくにつれ、上記の要件を満たすのが困難になってきます。
これらの要件を満しつつスケールしやすいアーキテクチャを考える中で、いわゆる MVVM というパターンと Flux というパターンに倣って簡単な画面を実装してみました。実際に作っているアプリケーションでは Model 層よりも Controller (あるいは ViewController) 層が肥大化することが多かったため、状態をどのように管理し UI に反映するかという View 寄りのロジックに工夫のあるこの2つを選択しました。それぞれの実装を見ながら長所・短所を考えていきます。
サンプルの実装には RxSwift というライブラリを使っています。
サンプルの仕様
- 画面が表示されるとサーバーからデータを取得しリスト表示
- 末尾までスクロールすると続きのデータをサーバーから取得し表示
- リクエストエラー時にはアラートを表示
というシンプルなアプリケーションです。
MVVM
MVVM とは
Model-View-ViewModel の略で、下記のような3つのレイヤに役割を分割します。
- View
- ViewModel の持つ状態を UI に反映する
- UI イベントの発生を ViewModel に伝える
- ViewModel
- 状態を持つ
- UI イベントに応じたオペレーションを行う
- 状態が更新されたら View に伝える
- Model
- ViewModel からリクエストされたデータをデータソースから取得し整形して返す
実装
MVVM パターンで実装したサンプルコードです。以下で実装の概要を説明します。
ViewModel のコードの骨子はこのようになっています。
class ViewModel { var models: Observable<[Model]> = Observable.empty() init(inputs: (refreshTrigger: Observable<Void>, loadMoreTrigger: Observable<Void>)) { // UI イベントのストリームをマージして一つのストリームにする let requestTrigger: Observable<TriggerType> = Observable .merge( inputs.refreshTrigger.map { .refresh }, inputs.loadMoreTrigger.map { .loadMore } ) // UI イベントの種類に応じて API リクエストを行いレスポンスから models のストリームを生成する models = requestTrigger .flatMapFirst { [weak self] triggerType -> Observable<ModelRequest.Response> in // 中略: API リクエスト } .startWith([]) .shareReplay(1) } }
https://github.com/morishin/RxMVVMExample/blob/master/RxMVVMExample/ViewModel.swift
init
では View からの UI イベントが流れてくるストリームを受け取り、アプリケーションの状態を表すストリーム models
を生成します。View は UI イベントの発生をストリームに流して ViewModel に伝え、また ViewModel の models
を購読し変更があれば UI に反映します。
View (ViewController) からは下記のように ViewModel を生成し、 models
を購読します。
override func viewDidLoad() { super.viewDidLoad() // 一度目の viewWillAppear 時にイベントが流れる let refreshTrigger = rx.sentMessage(#selector(viewWillAppear)) .take(1) .map { _ in } // 最後のセルが表示される時にイベントが流れる let loadMoreTrigger = tableView.rx.willDisplayCell .filter { [weak self] (cell, indexPath) -> Bool in guard let strongSelf = self else { return false } let isLastCell = indexPath.row == strongSelf.tableView.numberOfRows(inSection: indexPath.section) - 1 return isLastCell } .map { _ in } // UI イベントのストリームを渡して ViewModel を生成 let viewModel = ViewModel(inputs: (refreshTrigger: refreshTrigger, loadMoreTrigger: loadMoreTrigger)) // ViewModel の状態を購読 viewModel.models .bind(to: tableView.rx.items(cellIdentifier: String(describing: UITableViewCell.self))) { (row, model, cell) in cell.textLabel?.text = model.name } .disposed(by: disposeBag) }
https://github.com/morishin/RxMVVMExample/blob/master/RxMVVMExample/ModelTableViewController.swift
サンプルでは models
の他に networkStates
という状態を持ち、リクエスト中であるか、あるいはエラーが発生しているかという状態を View に伝え、インジケーターやアラートの表示を行っています。詳しくはリポジトリを御覧ください。
長所・短所
- 長所
- ViewController に集中しがちなロジックを ViewModel に切り出すことができ、役割分担がはっきりする
- ロジックを ViewModel に切り分けたことによりユニットテストが書きやすくなる
- レイヤ間のデータフローが分かりやすい
- 短所
- UI イベントに応じた各種アクションと状態の管理を ViewModel が担うので大きくなりがち
ViewModel が肥大化する場合は VIPER の Presenter と Interactor のような役割のレイヤを用意してそれらに機能を分割するのもよいかもしれません。
次に同じ仕様のアプリケーションを Flux パターンで実装したものを示します。
Flux
Flux とは
Facebook が提唱しているアーキテクチャで、同名の JavaScript フレームワークがあります。
Flux には下記のような4つの要素があります。
- Store
- アプリケーションで用いるデータ・状態を保持する
- View
- Store の持つ状態を UI に反映する
- Action
- UI イベントに起因して作られる Store への要求
- Dispatcher
- Action を Store へ送る
これらの要素で下図のようなデータフローを構築します。View は Store の持つ状態を購読し、変更があると UI に反映します。ユーザーによるタップやスクロールといった UI イベントが起きると、Action Creator がイベントに応じた Action (API リクエスト等) を発行し Dispatcher へ渡します。Dispatcher は送られた Action を Store へ伝え、Store は Action に応じて状態を更新します。この繰り返しでアプリケーションが動作します。
GitHub - facebook/flux: Application Architecture for Building User Interfaces
実装
Flux パターンで実装したサンプルコードです。以下で実装の概要を説明します。
サンプルでは Dispatcher は定義せず、Action Creator から直接 Store へデータを流しています。
Store の定義は下記のようになっています。
class Store { static let initialState = State( models: [], nextPage: .nextPage(State.initialPage), networkState: .nothing ) var states: Observable<State> = .empty() var currentState: State { return try! stateCache.value() } private let stateCache: BehaviorSubject<State> = BehaviorSubject(value: Store.initialState) init(inputs: Observable<View.Event>) { states = inputs .flatMap { event in ActionCreator.action(for: event, store: self) } .scan(Store.initialState, accumulator: Store.reduce) .multicast(stateCache) .refCount() } static func reduce(state: State, action: Action) -> State { var nextState = state switch action { case let .refreshed(models, nextPage): nextState.models = models nextState.nextPage = nextPage nextState.networkState = .nothing case let .loadedMore(models, nextPage): nextState.models += models nextState.nextPage = nextPage nextState.networkState = .nothing case .requested: nextState.networkState = .requesting case let .errorOccured(error): nextState.networkState = .error(error) } return nextState } }
init
で View からの UI イベントが流れてくるストリームを受け取ります。ActionCreator.action
関数によって UI イベントを Action のストリームに変換し、Store.reduce
でそれを状態遷移のストリーム stetes
にしています。そしてこの states
を View から購読して UI に反映することで Flux のデータフローが完成します。
Store.reduce
は現在の状態と Action を取り、次の状態を返す関数です。全ての状態遷移はここに集約されます。
UI イベントを Action に変換する ActionCreator.action
関数の定義は下記のようになっています。
struct ActionCreator { static func action(for event: View.Event, store: Store) -> Observable<Action> { let currentState = store.currentState switch event { case .firstViewWillAppear: if case .requesting = currentState.networkState { return Observable.just(.requested) } else { let request = ModelRequest(page: State.initialPage) let response: Single<ModelRequest.Response> = MockClient.response(to: request) return response.asObservable() .map { response -> Action in return .refreshed(models: response.models, nextPage: response.nextPage) } .catchError { error -> Observable<Action> in return .just(.errorOccured(error: error)) } .startWith(.requested) } case .reachedBottom: switch currentState.nextPage { case .reachedLast: return .empty() case let .nextPage(nextPage): if case .requesting = currentState.networkState { return .just(.requested) } let request = ModelRequest(page: nextPage) let response: Single<ModelRequest.Response> = MockClient.response(to: request) return response.asObservable() .map { response -> Action in return .loadedMore(models: response.models, nextPage: response.nextPage) } .catchError { error -> Observable<Action> in return .just(.errorOccured(error: error)) } .startWith(.requested) } } } }
返り値が Action
でなく Observable<Action>
になっているのは非同期処理の完了後に Action を送出したい場合があるためです。例えば API リクエストを行う場合、リクエスト開始時には .requested
という Action を Store へ送り View はインジケーターを表示し、非同期にレスポンスを取得し完了時に .refreshed
という Action を Store へ送り View はインジケーターを非表示にし取得したデータを画面に描画します。
最後に View の実装です。
typealias View = ModelTableViewController class ModelTableViewController: UIViewController, UITableViewDataSource { fileprivate enum Event { case firstViewWillAppear case reachedBottom } private let store: Store private let events = PublishSubject<Event>() private let tableView = UITableView() private let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray) private let disposeBag = DisposeBag() private var models: [Model] = [] init() { // UI イベントを流すストリームを渡して Store を生成 store = Store(inputs: events) super.init(nibName: nil, bundle: nil) } override func viewDidLoad() { super.viewDidLoad() // Store の状態遷移のストリームを購読し、状態に変更があれば render を呼ぶ store.states .observeOn(MainScheduler.instance) .subscribe(onNext: self.render) .disposed(by: disposeBag) view.addSubview(tableView) tableView.frame = view.bounds tableView.dataSource = self tableView.register(UITableViewCell.self, forCellReuseIdentifier: String(describing: UITableViewCell.self)) view.addSubview(activityIndicator) activityIndicator.center = view.center // 一度目の viewWillAppear 時に .firstViewWillAppear イベントを送出 rx.sentMessage(#selector(viewWillAppear)) .take(1) .subscribe(onNext: { [weak self] _ in self?.events.onNext(.firstViewWillAppear) }) .disposed(by: disposeBag) // 末尾までスクロールされたら .reachedBottom イベントを送出 tableView.rx.willDisplayCell .subscribe(onNext: { [weak self] (cell, indexPath) in guard let strongSelf = self else { return } if indexPath.row == strongSelf.tableView.numberOfRows(inSection: indexPath.section) - 1 { strongSelf.events.onNext(.reachedBottom) } }) .disposed(by: disposeBag) } private func render(state: State) { // 中略: 状態に応じた描画処理 } }
init
で UI イベントを流すストリームを渡して Store を生成します。また viewDidLoad
で Store.states
を購読することで Flux のデータフローを構築します。これで events
に UI イベントを流すとそれに応じた Action が実行され、状態が更新されると View の render
が呼び出されるようになります。render
関数は状態を受け取って UI の描画を行う関数です。サンプルでは状態に応じたインジケーターやアラートの表示、データによるテーブルビューの更新を行っています。
長所・短所
- 長所
- 画面が持ちうる状態と状態遷移が把握しやすい
- 要素の役割分担がはっきりしている
- View -> Action Creator -> Store -> View のデータフローが一方向になっていて分かりやすい
- ステートレスな要素が多いのでユニットテストが書きやすい
- 短所
- コード量が膨らむ
- 状態の一部が書き換わるだけで全ての再描画が走るのでパフォーマンスが良くない
React なんかは Virtual DOM によってパフォーマンスの短所を解決していますね。iOS でも実装の仕方の問題で、状態を一つのストリームにまとめずにいくつかに分割して、それぞれの状態に関係する View のみを更新する関数を接続するといった対処で改善できるかもしれません。
まとめ
例として MVVM と Flux の実装を見てきました。両者とも粒度は違うものの役割を分割することで、従来の M-VC という形よりもどこに何を実装すべきかわかりやすくなっているかと思います。またユニットテストが書きやすくなっているのも魅力です。Flux はアーキテクチャの構成という点の他にも、状態の更新を一箇所に集約し、そこ以外では状態が書き換わらないといった長所もありました。
いかがでしたでしょうか。アーキテクチャの選択はアプリケーションの性質にも依りますし、開発チームの状態にも依ります。それらを考慮しアーキテクチャに求める要件を挙げ、適したアーキテクチャを選択するように心がけましょう。