最近新しいアプリの設計をしていて、そのときに考えたり試したりしたことのまとめです。
経緯
以前開発していたアプリでは MVVM + RxSwift で開発していました。このアーキテクチャ自体はとてもよいものでしたが Model をどう扱うかで悩むことが多かったです。なぜスッキリとした Model を書けないのかと考えた結果、Modelに複数の責務があるからではと気づきました。
上の図は以前開発していたアプリの設計を図にしたものです。一応 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 を取り入れた設計
Action
Action は非同期処理ができますが、状態を変更しません。状態を変更しないかわりに、Mutationをコミットします。
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 側に実装しました。
import Foundation
public enum TodoMutation: Mutation {
case addTodo(Todo)
case addTodos([Todo])
}
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 を作ってやる必要があります。
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 がコミットされることでしか発生しないので、こちらはシンプルにテストできました。
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 周りのシステムがそれぞれ異なるため、自分たちのプラットフォームを踏まえて再考する必要はあると思いますが、同じ領域にいる身としては彼らの問題へのアプローチ手法は今後もウォッチしていきたいなと思っています。