iOSアプリへのFluxの導入(2)
具体的な実装の紹介
それでは、freee for チームで利用しているFluxのコンポーネントを紹介します。
とはいっても、実際に必要だったのはDispatcherとActionのみで、そのコードは100行もありません。オブザーバーパターンの土台としてRxSwiftを、Storeへ渡るペイロードの型としてResultをそれぞれ利用しています。
Dispatcher
class Dispatcher: NotificationCenter {
static let shared = Dispatcher()
}
extension Dispatcher {
func rx_notification<T: ActionBase>(_ action: T.Type)
-> Observable<Result<T.Payload, T.ActionError>> {
return rx.notification(action.name).map { n in
let object = n.object as! Result<T.Payload, T.ActionError>
return object
}
}
}
Dispatcherは、NotificationCenterをベースにSingleton Classで、任意のActionのPayloadを値(正確にはResultでラップされた値)とした、RxのObservableを返すメソッドのみ実装されています。本記事ではこれらのライブラリの説明は割愛するので、必要に応じて公式ドキュメント等を参照しながら読み進めてください。
例えば、
Dispatcher.shared.rx_notification(ToDoAction.Fetch.self)
.subscribe(onNext: { result in
})
このように実装すると、取得したToDoのリストが受け取れるイメージです。
Action
protocol ActionBase {
associatedtype Payload
associatedtype ActionError: Error = NSError
}
extension ActionBase {
static var name: Notification.Name {
return Notification.Name(String(reflecting: self))
}
func dispatch(_ result: Result<Payload, ActionError>) {
Dispatcher.shared.post(
name: type(of: self).name,
object: result
)
}
}
protocol Action: ActionBase {
func invoke()
}
protocol AsyncAction: ActionBase {
func invoke() -> Observable<Void>
}
Actionは、ActionBaseプロトコルを継承した、「Action」と「AsyncAction」の、2つのプロトコルを定義しています。
その名の通り、同期的なActionはActionプロトコルを、非同期的なActionはAsyncActionプロトコルに適合し、それぞれのinvokeメソッドの中でペイロードを用意した上で、dispatchメソッドを経由してStoreに渡します。
AsyncActionプロトコルのinvokeメソッドは、非同期に行った処理の完了を、呼び出し元に通知するためにObservableを返すようにしています。
使用例
それでは、ここまでに紹介した最低限のFlux実装をベースに、月並みではありますが、例としてToDoリストの初期値表示(fetch)・追加(add)・削除(delete)が可能なToDoアプリを作ってみましょう。
コードを抜粋しながら、FluxベースのiOSアプリを作る流れを解説します。
この図の赤枠で囲んだ部分(ViewControllerとToDoActionとToDoStore)が、今回実装する部分です。
Action
まずはActionから説明します。
struct ToDoAction {
struct Fetch: AsyncAction {
typealias Payload = [ToDo]
func invoke() -> Observable<Void> {
return fetchDefaultItems().`do`(onNext: { result in
self.dispatch(Result(value: result))
}, onError: { error in
self.dispatch(Result(error: NSError.error(from: error)))
}).map { _ in () }
}
private func fetchDefaultItems() -> Observable<[ToDo]> {
return Observable.just([
ToDo(text: "First item"),
ToDo(text: "Second item"),
ToDo(text: "Third item")
])
}
}
struct Add: Action {
typealias Payload = ToDo
let text: String
func invoke() {
self.dispatch(Result(value: ToDo(text: text)))
}
}
struct Delete: Action {
typealias Payload = Int
let index: Int
func invoke() {
self.dispatch(Result(value: index))
}
}
}
Fetch、Add、Deleteの3つのアクションを実装しました。
Fetch ActionはAsyncActionプロトコルに適合しているため、invokeメソッドでObservableを返しています。こうすることで、
FooAction().invoke()
.concat(BarAction().invoke())
.concat(BazAction.invoke())
.subscribe()
というように、複数のActionを連鎖させることができます。
Store
次にStoreです。
final class ToDoStore {
let todos = Variable<[ToDo]>([])
private(set) var addedIndex = -1
private(set) var deletedIndex = -1
private let disposeBag = DisposeBag()
init() {
let d = Dispatcher.shared
d.rx_notification(ToDoAction.Fetch.self)
.subscribe(onNext: { [weak self] result in
switch result {
case .success(let todos):
self?.resetIndices()
self?.todos.value = todos
default:
break
}
}).disposed(by: disposeBag)
d.rx_notification(ToDoAction.Add.self)
.subscribe(onNext: { [weak self] result in
switch result {
case .success(let todo):
self?.resetIndices()
let current = self?.todos.value ?? []
self?.addedIndex = current.count
self?.todos.value = current + [todo]
default:
break
}
}).disposed(by: disposeBag)
d.rx_notification(ToDoAction.Delete.self)
.subscribe(onNext: { [weak self] result in
switch result {
case .success(let index):
guard let `self` = self else { return }
self.resetIndices()
self.deletedIndex = index
var todos = self.todos.value
todos.remove(at: index)
self.todos.value = todos
default:
break
}
}).disposed(by: disposeBag)
}
private func resetIndices() {
addedIndex = -1
deletedIndex = -1
}
}
ToDoStoreはプレーンなクラスで、イニシャライザでToDoActionを購読し、Actionの種類によって自身のプロパティを更新します。todosプロパティをRxのVariableにしているのには大きく2つ理由があります。
ひとつはActionをトリガーに値が変更された際、todosプロパティを購読しているViewへ自動的にその変更が通知するための仕組みとして利用するためです。もうひとつはViewの更新範囲を最小限に抑えるためですが、これについて詳しくは後述します。
不要なデータがメモリに残らないようにするため、基本的にStoreのライフサイクルは画面のライフサイクルに一致させています。DispatcherをRxベースにしたのも、Actionの購読解除にDisposableの仕組みを使いたかったからです。disposed(by: disposeBag)を呼び出しておけば、Storeが破棄されるときに勝手にActionの購読も解除してくれるので、メモリリークへの懸念が解消されます。
ここで、todosプロパティがVariableであると、実装者がStoreを参照しているViewからもその値を意図的に変更できてしまうということになります。今回は簡略化のため利用していませんが、これを仕様的に制限するには、RxPropertyやSignatureを利用する方法を検討するといいでしょう。
View
次はViewです。
final class ToDoViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
@IBOutlet var tableView: UITableView!
@IBOutlet var textField: UITextField!
private let todoStore = ToDoStore()
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
todoStore.todos.asDriver().drive(onNext: { [unowned self] todos in
if self.todoStore.addedIndex > 0 {
let indexPaths = [IndexPath(row: self.todoStore.addedIndex, section: 0)]
self.tableView.insertRows(at: indexPaths, with: .fade)
self.textField.text = ""
} else if self.todoStore.deletedIndex > 0 {
let indexPaths = [IndexPath(row: self.todoStore.deletedIndex, section: 0)]
self.tableView.deleteRows(at: indexPaths, with: .fade)
} else {
self.tableView.reloadData()
}
}).disposed(by: disposeBag)
_ = ToDoAction.Fetch().invoke().subscribe()
}
@IBAction func tapAdd(_ sender: Any) {
if let text = textField.text, !text.isEmpty {
ToDoAction.Add(text: text).invoke()
}
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return todoStore.todos.value.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell")
cell?.textLabel?.text = todoStore.todos.value[indexPath.row].text
return cell!
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
ToDoAction.Delete(index: indexPath.row).invoke()
}
}
Viewでは、Storeのtodosプロパティの変更のたびにリストを更新しています。ToDoアイテムの追加・削除時には、Storeのプロパティとして公開されたインデックスを参照することで、行単位の更新メソッドを使うことができ、アニメーションを適用したり、再描画の負荷を抑えたりすることができます。
先ほどのStoreのプロパティをVariableにする2つ目の理由の続きになりますが、Webで用いられる「emit changeで変更通知を受け、DOM treeを作り直すことでViewを更新するアプローチ」は、現状Virtual DOMほど洗練された差分更新の仕組みがiOS開発の世界にはないため、パフォーマンスの観点から採用しづらい、というポイントがあります。StoreのプロパティをVariableにすることで、自然な形でemit changeよりも細かな更新を通知することが可能となり、Viewの更新範囲を必要最小限に抑えることができます。
ユニットテスト
最後に、ユニットテストにも少し触れます。以下のコードは、このToDoアプリのビジネスロジックが実装されている、ToDoStoreに対するユニットテストの一部です。
func testFetch() {
let store = ToDoStore()
XCTAssertTrue(store.todos.value.isEmpty)
ToDoAction.Fetch.dispatch(Result(value: [
ToDo(text: "foo"),
ToDo(text: "bar")
]))
XCTAssertEqual(store.todos.value.count, 2)
XCTAssertEqual(store.todos.value.first?.text, "foo")
XCTAssertEqual(store.todos.value.last?.text, "bar")
}
Actionのロジックを通さずStoreに対して直接Actionのペイロードを与え、その前後にAssertionを記述します。Storeの状態はActionによってしか変更されず、ViewはStoreの状態を画面に反映する原則があるため、Storeに対してのシンプルな入出力でアプリ内の重要なロジックをテストすることができます。
なお、ToDoAction.Fetchのstaticなdispatchメソッドを呼び出していますが、これはTest向けに以下のextensionで拡張することで利用可能になります。
extension ActionBase {
static func dispatch(_ result: Result<Payload, ActionError>) {
Dispatcher.shared.post(
name: self.name,
object: result
)
}
}
ToDoの追加・削除のユニットテストについても同じ要領であるため、ここでの詳細説明は割愛します。今回作成したToDoアプリの全コードはGitHubに置いてあるので、動作させながら確認していただくことが可能です。
まとめ
Fluxアーキテクチャを採用後、しばらくfreee for チームの開発を続けてきた上での印象として、一番のメリットはやはりデータの流れがシンプルで分かりやすい、といったところにあります。弊社の他のプロダクトではReactiveCocoaをベースにしたMVVMアーキテクチャが採用されているものもありますが、画面や機能の複雑さに比例して、Streamをうまくつなげて機能を実現させていく難易度も上がる印象がありました。しかし、Fluxでは機能を変更・追加する際にAction、Store、Viewと、似たようなパターンで書くことが多くなり、複雑な画面でも比較的単純に機能を追加していくことができました。複数人で開発する場合、その効果はより大きく感じられます。
一方デメリットは、メリットと引き換えのその冗長さです。たとえちょっとした変更でも、ViewからStoreのメソッドを叩いて状態を変更させることはできません。常にActionを発行し、Storeでそれを受けて状態を変化させ、Viewが変更を外観に反映するという流れを実装する必要があります。また、Virtual DOMのような差分更新の仕組みがないことも、その出自であるWebフロントエンドの世界と比べると親和性は低く感じます。
しかし本記事で解説してきた通り、プラットフォームに合わせてFluxの実装を再考することで、iOSアプリにおいても単方向データフローの恩恵を受けることができます。freee for チームは従業員向けのアプリのため、一般的なコンシューマー向けアプリと比べると、複雑な画面やフォームの実装が求められやすい性質があります。そのようなアプリだと特に、単方向データフローという制約はアプリの機能をスケールさせていく助けになるはずです。
