最近新しいアプリの設計をしていて、そのときに考えたり試したりしたことのまとめです。

経緯

以前開発していたアプリでは MVVM + RxSwift で開発していました。このアーキテクチャ自体はとてもよいものでしたが Model をどう扱うかで悩むことが多かったです。なぜスッキリとした Model を書けないのかと考えた結果、Modelに複数の責務があるからではと気づきました。

2017_1st_mvvm.png

上の図は以前開発していたアプリの設計を図にしたものです。一応 Model 層内に DataStore と APIClient を作り、責務を分けていました。この時点で Model にはAPIを叩いてデータを取得するという責務と取得したデータの管理をするという責務の2つがあることはわかっていたためこういう形になりましたが、ただ分割するだけではうまくいかないこともありました。一番頭を悩ませた問題は ViewModel へビジネスロジックが漏れてくることでストリームが複雑になり、あとから読みづらいコードが生まれてしまうことでした。

新しく作るアプリでは同じことで悩みたくなかったため Model の再設計を行いました。そのときに設計の参考として Web フロントエンドでよく使われている Flux について調査し、簡単なスパイクコードをいくつか書いて試してみた結果、一番よさそうな形になったものが Vuex を取り入れた設計でした。

Vuex について

僕は Web フロントエンドのコードを普段あまり書かない人なので、ここに関しては 公式のドキュメント や他の記事を参考にしてください。僕の認識としては「Vue.js 向けの Flux 実装」という感じです。

参考にした点

Vuex を参考にしたと言っても Swift でそのまま Vuex を実装したわけではありません。UIKit と Vue.js の Component ではいろいろと事情が異なるため、あくまで一部の発想を借りて iOS 向けに極力シンプルにできるよう設計しました。特に Action と Mutation の区別は通信処理とデータ管理の責務を分けるのにかなり有効でした。一方で単一ステートツリーや Getter は iOS では相性が悪かったり必要なさそうに感じたので最初の段階では取り入れていません。今後やっていく中で何かうまくいかないことが発生したら、改めて検討するかもしれないです。

Vuex を取り入れた設計

architecture_vuex.png

Action

Action は非同期処理ができますが、状態を変更しません。状態を変更しないかわりに、Mutationをコミットします

TodoAction.swift
import Foundation
import RxSwift

public struct TodoAction {

    public static func fetch<StoreType: Store>(store: StoreType) -> Completable where StoreType.MutationType == TodoMutation {
        return Completable.create { observer in
            _ = API.send(request)
                .subscribe(
                    onSuccess: { todos in
                        store.commit(mutation: .addTodos(todos))
                        observer(.completed)
                    },
                    onError: { error in
                        observer(.error(error))
                    }
                )

            return Disposables.create()
        }
    }

}

エラーハンドリングを考慮して Completable を返す実装にしています。直接 Store を引数に取らずにジェネリクスにした上で関連する Mutation を指定しているのはテストを考慮してのことです。少し表記が複雑になっていますが、もう少しよいやり方があるかもしれません。

Mutation

Mutation は Action と異なり、同期的状態を変更します。今回は Mutation を enum として定義し、具体的な状態変更処理は Store 側に実装しました。

TodoMutation.swift
import Foundation

public enum TodoMutation: Mutation {   
    case addTodo(Todo)
    case addTodos([Todo])
}
TodoStore.swift
import Foundation
import RxSwift

public class TodoStore: Store {

    public typealias MutationType = TodoMutation

    public let todoList = Variable<[Todo]>([])

    public func commit(mutation: TodoMutation) {
        switch mutation {
        case .addTodo(let todo):
            todoList.value.append(todo)
        case .addTodos(let todos):
            todoList.value.append(contentsOf: todos)
        }
    }

}

テスト

Action も Mutation もそれぞれの性質がはっきりしているので、何をテストすべきかが明確になる点がいいなと思いました。

Action

Action は Store に対して Mutation をコミットする形にしたので Store の Spy を作ってやる必要があります。

TodoActionTests.swift
import RxBlocking
import XCTest
@testable import Domain

class TodoActionTests: XCTestCase {

    class TodoStoreSpy: Store {

        typealias MutationType = TodoMutation

        private(set) var commitLog: [TodoMutation] = []

        func commit(mutation: TodoMutation) {
            commitLog.append(mutation)
        }

    }

    func testFetch() {
        let storeSpy = TodoStoreSpy()
        try? _ = TodoAction.fetch(store: storeSpy).toBlocking().last()
        XCTAssertTrue(storeSpy.commitLog.first!.isAddTodos)
    }

}

Mutation が 値持ちの enum のため、そのままだと XCTAssert での比較ができない・・・。判定用のメソッドを持たせたけど、テストのためのメソッドになってしまっていてなんだかなぁという感じ。

Store

状態変更は Mutation がコミットされることでしか発生しないので、こちらはシンプルにテストできました。

TodoStoreTests.swift
import RxSwift
import XCTest
@testable import Domain

class TodoStoreTests: XCTestCase {

    var store: TodoStore!

    override func setUp() {
        super.setUp()
        store = TodoStore()
    }

    func testAddTodo() {
        let todo = Todo(text: "test", done: false)
        store.commit(mutation: .addTodo(todo))

        let todoList = store.todoList.value
        XCTAssertEqual(todoList.count, 1)
    }

    func testAddTodos() {
        let todos = [Todo(text: "test1", done: false), Todo(text: "test2", done: false)]
        store.commit(mutation: .addTodos(todos))

        let todoList = store.todoList.value
        XCTAssertEqual(todoList.count, 2)
    }

}

Redux はどうなの?

Redux も検討してみましたが、非同期処理の扱いに対して複数のアプローチがあり、どれがベストプラクティスなのかいまいちよくわからなかったため、Vuex のほうがモバイルアプリには向いているように感じました。ReSwift も試してみましたが、あくまでそれに乗っかれば Redux できるという感じのライブラリで、結局同じ部分で悩む感じになりました。もちろん、ここに関してはアプリのビジネスロジック次第でもあるので、どういうアプリを作るかによっては Vuex よりも Redux のほうが合っていることもあると思います。

まとめ

この記事に載せた実装はあくまで一例で、もしかしたらもっといいやり方があるかもしれません。ですが、非同期な通信処理と同期的な状態変更を分離することで、テストしやすいスッキリとした設計ができたのではないかなと思います。現在開発しているアプリは少人数で開発しているので、大きいアーキテクチャを採用してがちがちにやるよりは、このくらいのサイズ感の設計がちょうどよいかなと思います。

また、今回 Vuex まで目が向いた理由としては社内で Vue.js が流行っているというのもありますが、MVVM や Reactive Extensions が元々 C# で発祥しそれがモバイルアプリに流れてきたように、同じ GUI アプリケーションという枠では Android のみならず Web フロントエンドや Windows アプリケーションの設計手法は大いに役に立つと思ったからです。UI 周りのシステムがそれぞれ異なるため、自分たちのプラットフォームを踏まえて再考する必要はあると思いますが、同じ領域にいる身としては彼らの問題へのアプローチ手法は今後もウォッチしていきたいなと思っています。