モバイルアプリのアーキテクチャを考える

こんにちは、サービス開発部の森川 (@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 というライブラリを使っています。

サンプルの仕様

  • 画面が表示されるとサーバーからデータを取得しリスト表示
  • 末尾までスクロールすると続きのデータをサーバーから取得し表示
  • リクエストエラー時にはアラートを表示

というシンプルなアプリケーションです。

f:id:morishin127:20170518114305g:plain:w320

MVVM

MVVM とは

Model-View-ViewModel の略で、下記のような3つのレイヤに役割を分割します。

f:id:morishin127:20170518132739p:plain:w860

  • View
    • ViewModel の持つ状態を UI に反映する
    • UI イベントの発生を ViewModel に伝える
  • ViewModel
    • 状態を持つ
    • UI イベントに応じたオペレーションを行う
    • 状態が更新されたら View に伝える
  • Model
    • ViewModel からリクエストされたデータをデータソースから取得し整形して返す

実装

MVVM パターンで実装したサンプルコードです。以下で実装の概要を説明します。

github.com

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 に応じて状態を更新します。この繰り返しでアプリケーションが動作します。

f:id:morishin127:20170518114457p:plain

GitHub - facebook/flux: Application Architecture for Building User Interfaces

実装

Flux パターンで実装したサンプルコードです。以下で実装の概要を説明します。

github.com

サンプルでは 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
    }
}

https://github.com/morishin/RxFluxExample/blob/master/RxFluxExample/ModelTableViewController.swift#L25-L67

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)
            }
        }
    }
}

https://github.com/morishin/RxFluxExample/blob/master/RxFluxExample/ModelTableViewController.swift#L69-L110

返り値が 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) {
        // 中略: 状態に応じた描画処理
    }
}

https://github.com/morishin/RxFluxExample/blob/master/RxFluxExample/ModelTableViewController.swift#L112-L197

init で UI イベントを流すストリームを渡して Store を生成します。また viewDidLoadStore.states を購読することで Flux のデータフローを構築します。これで events に UI イベントを流すとそれに応じた Action が実行され、状態が更新されると View の render が呼び出されるようになります。render 関数は状態を受け取って UI の描画を行う関数です。サンプルでは状態に応じたインジケーターやアラートの表示、データによるテーブルビューの更新を行っています。

長所・短所

  • 長所
    • 画面が持ちうる状態と状態遷移が把握しやすい
    • 要素の役割分担がはっきりしている
    • View -> Action Creator -> Store -> View のデータフローが一方向になっていて分かりやすい
    • ステートレスな要素が多いのでユニットテストが書きやすい
  • 短所
    • コード量が膨らむ
    • 状態の一部が書き換わるだけで全ての再描画が走るのでパフォーマンスが良くない

React なんかは Virtual DOM によってパフォーマンスの短所を解決していますね。iOS でも実装の仕方の問題で、状態を一つのストリームにまとめずにいくつかに分割して、それぞれの状態に関係する View のみを更新する関数を接続するといった対処で改善できるかもしれません。

まとめ

例として MVVM と Flux の実装を見てきました。両者とも粒度は違うものの役割を分割することで、従来の M-VC という形よりもどこに何を実装すべきかわかりやすくなっているかと思います。またユニットテストが書きやすくなっているのも魅力です。Flux はアーキテクチャの構成という点の他にも、状態の更新を一箇所に集約し、そこ以外では状態が書き換わらないといった長所もありました。

いかがでしたでしょうか。アーキテクチャの選択はアプリケーションの性質にも依りますし、開発チームの状態にも依ります。それらを考慮しアーキテクチャに求める要件を挙げ、適したアーキテクチャを選択するように心がけましょう。

/* */ @import "/css/theme/report/report.css"; /* */ /* */ body{ background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527163350.png'); background-repeat: repeat-x; background-color:transparent; background-attachment: scroll; background-position: left top;} /* */ body{ border-top: 3px solid orange; color: #3c3c3c; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; line-height: 1.8; font-size: 16px; } a { text-decoration: underline; color: #693e1c; } a:hover { color: #80400e; text-decoration: underline; } .entry-title a{ color: rgb(176, 108, 28); cursor: auto; display: inline; font-family: 'Helvetica Neue', Helvetica, 'ヒラギノ角ゴ Pro W3', 'Hiragino Kaku Gothic Pro', Meiryo, Osaka, 'MS Pゴシック', sans-serif; font-size: 30px; font-weight: bold; height: auto; line-height: 40.5px; text-decoration: underline solid rgb(176, 108, 28); width: auto; line-height: 1.35; } .date a { color: #9b8b6c; font-size: 14px; text-decoration: none; font-weight: normal; } .urllist-title-link { font-size: 14px; } /* Recent Entries */ .recent-entries a{ color: #693e1c; } .recent-entries a:visited { color: #4d2200; text-decoration: none; } .hatena-module-recent-entries li { padding-bottom: 8px; border-bottom-width: 0px; } /*Widget*/ .hatena-module-body li { list-style-type: circle; } .hatena-module-body a{ text-decoration: none; } .hatena-module-body a:hover{ text-decoration: underline; } /* Widget name */ .hatena-module-title, .hatena-module-title a{ color: #b06c1c; margin-top: 20px; margin-bottom: 7px; } /* work frame*/ #container { width: 970px; text-align: center; margin: 0 auto; background: transparent; padding: 0 30px; } #wrapper { float: left; overflow: hidden; width: 660px; } #box2 { width: 240px; float: right; font-size: 14px; word-wrap: break-word; } /*#blog-title-inner{*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-position: left 0px;*/ /*}*/ /*.header-image-only #blog-title-inner {*/ /*background-repeat: no-repeat;*/ /*position: relative;*/ /*height: 200px;*/ /*display: none;*/ /*}*/ /*#blog-title {*/ /*margin-top: 3px;*/ /*height: 125px;*/ /*background-image: url('http://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20140527/20140527172848.png');*/ /*background-repeat: no-repeat;*/ /*background-position: left 0px;*/ /*}*/