ReactorKitを学ぶシリーズ
- ReactorKit(Flux + Reactive Programming)を学ぶ1 入門編
- ReactorKit(Flux + Reactive Programming)を学ぶ2 基礎編
- ReactorKit(Flux + Reactive Programming)を学ぶ3 実践編 ← 今ここ
ReactorKitは、リアクティブで単方向ストリームのアーキテクチャを構築するためのフレームワークです。
前回までで、ReactorKitの基本的な実装方法について解説しました。今回はさらに実践的なReactorKitの実装方法について解説していきます。
この記事で学べること
- Serviceレイヤについて
-
transform()
関数を使用して、異なるReactor間に影響する変更への対応
※ この記事は、ReactorKit v1.1.0を元に書いています。
サンプルアプリ: RxTodo
ReactorKitで実装されているRxTodoを例に解説して行きます。RxTodoは名前の通りTodoアプリで、タスクの追加、編集、並び替え、削除といった一連の機能が備わっています。
※ RxTodoはSwift 3, ReactorKit v0.4.5で実装されていますが、本質的なロジックとしてはv1.1.0と変わりはありません。
※ ユニットテストだけXcode 9.xだとコンパイルできなかったのですが…コードの確認はできますし マイグレーションしてPRが結構しんどそうだったので どうしてもコンパイルしたい場合にはXcode 8.3.xを利用してください。
Serviceレイヤ
ReactorKitにはServiceレイヤが設けられています。ただし、ViewやReactorと違いプロトコルが用意されているわけではなく、アーキテクチャとしてServiceレイヤを設けましょうという設計上の話になります。
RxTodoには3つのServiceが実装されています。
Service | 役割 |
---|---|
UserDefaultsService | UserDefaultsに対して値の保存、取得を行う |
TaskService | タスクの作成、更新、削除、並び替え、完了マークの表示/非表示 |
AlertService | アラートの表示 |
Serviceレイヤは実際のビジネスロジックを受け持つレイヤで、Reactorレイヤはイベントストリームを管理するViewレイヤとServiceレイヤの中間層となります。具体的なビジネスロジックをServiceレイヤに委譲することでServiceのユニットテストも行いやすくなります。
Serviceの使用例
すべてServiceを細かく解説するのはReactorKit解説の本筋からずれてしまうので、代表してAlertServiceを例に解説していきます。
AlertServiceを使用しているのは、TaskEditViewReactorで、タスクを編集中にUINavigationBarのCancelボタンを押した時に、編集中のデータを破棄していいかの確認アラートが表示されます。
func mutate(action: Action) -> Observable<Mutation> {
switch action {
case let .updateTaskTitle(taskTitle):
return .just(.updateTaskTitle(taskTitle))
case .submit:
// 省略
case .cancel:
if !self.currentState.shouldConfirmCancel {
return .just(.dismiss) // no need to confirm
}
let alertActions: [TaskEditViewCancelAlertAction] = [.leave, .stay]
return self.provider.alertService
.show(
title: "Really?",
message: "All changes will be lost",
preferredStyle: .alert,
actions: alertActions
)
.flatMap { alertAction -> Observable<Mutation> in
switch alertAction {
case .leave:
return .just(.dismiss)
case .stay:
return .empty()
}
}
}
}
アラートのLeaveを選択した場合には、TaskEditViewReactor.Mutation.dismissを発行し、Stayを選択した場合にはObservable.empty()
を発行させています。
※ AlertServiceには、UIAlertControllerから選択肢をonNextしてその後、enumで判定するために抽象化された実装がされています。この実装は通常のRxでも有用だと思います。
※ AlertService内でUIAlertControllerをpresentするためにNavigator.presentというコードがありますが、これはURLNavigatorというReactorKitの作者が作った別のOSSです。URLNavigatorも個人的には好きでよく使っています。
疑問: AlertServiceはありなのか
ReactorはViewの状態を管理するUIに依存しないレイヤでUIKitをimportしてはいけないのですが、そのReactor内でUI操作を伴うAlertServiceを使用するのはどうなのか、という疑問が生じます。そこで考えるのは、そもそもなぜReactorはUIに依存させないようにするのかです。これはReactorKitの設計目標の1つである、ビジネスロジックとビューを分離することでテスト可能にする というのが関連します。
RxTodoではAlertServiceを含むTaskEditViewReactorもテストが可能となっており設計目標を満たしています。さて、それがどのようにしてそれを実現しているかを順を追って見ていきましょう。
1. Serviceレイヤへのアクセス
各ServiceへのアクセスはServiceProviderTypeプロトコルに準拠した、なんらかの具象クラス経由でアクセスするように設計されています。
protocol ServiceProviderType: class {
var userDefaultsService: UserDefaultsServiceType { get }
var alertService: AlertServiceType { get }
var taskService: TaskServiceType { get }
}
2. アプリとユニットテストのそれぞれのServiceProvider
ServiceProviderTypeに準拠した具象クラスとして、RxTodoではServiceProvider、RxTodoTestsではMockServiceProviderが実装されています。
final class ServiceProvider: ServiceProviderType {
lazy var userDefaultsService: UserDefaultsServiceType = UserDefaultsService(provider: self)
lazy var alertService: AlertServiceType = AlertService(provider: self)
lazy var taskService: TaskServiceType = TaskService(provider: self)
}
final class MockServiceProvider: ServiceProviderType {
lazy var userDefaultsService: UserDefaultsServiceType = MockUserDefaultsService()
lazy var alertService: AlertServiceType = MockAlertService(provider: self)
lazy var taskService: TaskServiceType = TaskService(provider: self)
}
MockServiceProviderでは、新たにMockUserDefaultsServiceとMockAlertServiceが実装されています。
MockUserDefaultsService
値を保持する方法にUserDefaultsを使用せず、Dictionaryを使用しています。
final class MockUserDefaultsService: UserDefaultsServiceType {
var store = [String: Any]()
func value<T>(forKey key: UserDefaultsKey<T>) -> T? {
return self.store[key.key] as? T
}
func set<T>(value: T?, forKey key: UserDefaultsKey<T>) {
if let value = value {
self.store[key.key] = value
} else {
self.store.removeValue(forKey: key.key)
}
}
}
MockAlertService
UIAlertControllerを使用せず、 var selectAction: AlertActionType
を Observable.just()
するようになっており、UIを伴う操作ではなくなっています。
final class MockAlertService: BaseService, AlertServiceType, Then {
var selectAction: AlertActionType?
func show<Action: AlertActionType>(
title: String?,
message: String?,
preferredStyle: UIAlertControllerStyle,
actions: [Action]
) -> Observable<Action> {
guard let selectAction = self.selectAction as? Action else { return .empty() }
return .just(selectAction)
}
}
3. View, Reactor, Serviceを疎結合にする
ServiceProviderの生成はAppDelegateで行われています。
let serviceProvider = ServiceProvider()
let reactor = TaskListViewReactor(provider: serviceProvider)
let viewController = TaskListViewController(reactor: reactor)
MockServiceProviderの生成はTaskEditViewReactorTestsでテスト毎に以下のように行われています。
let provider = MockServiceProvider()
let reactor = test.retain(TaskEditViewReactor(provider: provider, mode: .new))
※ test.retain()
の部分はRxExpectのコードです。
ポイントは以下です。
- Reactorが依存するServiceProviderはReactorの外部で生成してReactorに渡す
- Viewが依存するReactorはViewの外部で生成してViewに渡す
各依存コードを外部からセットし、オブジェクト間を疎結合にしています。疎結合にすることで、ユニットテスト用のServiceなどを容易に差し替えることが可能になっています。
疑問: それでもServiceレイヤでUI操作が伴われる処理が行われるのは設計としてどうなのか
わかります。僕もこれは悩みました。ただ結局のところViewとReactorでそれぞれ何に注力するかのバランスの問題ではないのかなと思います。
- ViewはActionを発行し、Stateの変更に対してViewを更新することに注力する
- Reactorは発行されたActionから様々なビジネスロジックを処理し、Stateを更新することに注力する
この中で、アラートで編集を破棄するかどうかをユーザが選択するというのは、UI操作を伴いますがStateを更新を行うための一連のロジックに近い処理という判断がされて設計されているのではと感じました
もしもViewでアラートを表示する場合には、アラートを表示して選択された選択肢によってActionを発行するかどうかを決めることになると思いますが、次のような問題が発生します。
1. Stateの状態を考慮してActionを発行することになってしまう
アラートで編集を破棄するかどうかは、TaskEditViewReactor.State.shouldConfirmCancelで管理されており、タスクのタイトルが編集されていた場合にのみアラートで破棄するかの確認が行われます。これをView側で確認する場合にはStateの状態によってActionの発行を決定するしかなく、単方向ストリームではなくなってしまい複雑さが増してしまいます。
2. 複数のアラートが存在する
今回のように単純な確認だったらView側での実装も簡単ですが、例えばアラートで確認 → API通信 → 更にアラートで確認 → API通信 → Stateの更新のような場合には、アラートの進行・選択状況に応じたStateの値を設けて、その値の状態変化に応じてView側はActionの発行することになり実装が複雑になってしまいます(そもそもそういう複雑な設計になること自体を避ける設計をしようという話ではありますが…)。
上記を総合的に考えて、
- ViewはReactorに対してActionを発行 (Stateの状態は考慮しない)
- Reactorは発行されたActionをなんやかんや色々なことをしてStateを更新させる or されないかもしれない
- Stateが更新されたらViewがその変更を反映
という一連の流れから、アラートの表示は簡単な確認のためのUI操作ですしReactorのなんやかんや色々なことに含んでも問題ないという判断で設計されているのかなと思いました。ただ、これが行き過ぎるとReactor、Serviceの複雑化・肥大化の原因にもなってしまうのでバランスが非常に難しいところです。
異なるReactor間に影響する変更への対応
ReactorKitはViewに対して1つのReactorが存在し、Reactor内にViewの状態を管理するStateが存在します。そして、 Action → Mutation → State の単方向ストリーム(Flux)が構築されています。基本はそれぞれのReactorが独立して存在するため、複数のViewが参照できるグローバルなStateという概念が存在しません。
※ Fluxと並べて話題に上がることが多いReduxですが、ReduxはStateを管理するStoreがシングルトンになっています。
しかし、実際のアプリでは異なるReactor間に影響するStateの変化を発生させたいというケースが多く存在します。例えばRxTodoの場合だと、タスクの編集画面でタスクのタイトルを変更した場合には、タスクリスト画面側でもリアクティブにそのタイトル変更を反映させたいところです。
そこでReactorKitでは異なるReactor間に影響する変更に対応できるように、いくつかのtransform関数が用意されています。
transform関数
transform関数はReactorに3種類用意されています。
public protocol Reactor: class, AssociatedObjectStore {
/// Actionストリームを変換することができます。この関数は他のObservableと組み合わせることに使用できます。
/// Stateストリームが作成される前に1度だけ呼び出されます。
func transform(action: Observable<Action>) -> Observable<Action>
/// Mutationストリームを変換することができます。この関数は他のObservableを変形または組み合わせることに使用できます。
/// Stateストリームが作成される前に1度だけ呼び出されます。
func transform(mutation: Observable<Mutation>) -> Observable<Mutation>
/// Stateストリームを変換することができます。この関数はロギングなどの副作用を伴う処理に使用することができます。
/// Stateストリームが作成された後に1度だけ呼び出されます。
func transform(state: Observable<State>) -> Observable<State>
}
それぞれのストリームが流れるタイミングは、 mutate(action:)
, transform(state:)
の前後に挟まる形です。
transformはSearviceレイヤなどのが持つ Variable
, PublishSubject
などから発行されたグローバルなイベントストリームに対して、ローカルのStateストリームに変更を加えることを想定して用意されています。
RxTodoでの実例
RxTodoではタスクのリスト画面のTaskListViewReactorにtransform(mutation:)が実装されています。
func transform(mutation: Observable<Mutation>) -> Observable<Mutation> {
let taskEventMutation = self.provider.taskService.event
.flatMap { [weak self] taskEvent -> Observable<Mutation> in
self?.mutate(taskEvent: taskEvent) ?? .empty()
}
return Observable.of(mutation, taskEventMutation).merge()
}
self.provider.taskService.eventはTaskEventを発行するPublicSubjectです。
final class TaskService: BaseService, TaskServiceType {
let event = PublishSubject<TaskEvent>()
}
eventはTaskService内で各種操作が行われた場合にevent.onNext()されます。
func create(title: String, memo: String?) -> Observable<Task> {
return self.fetchTasks()
.flatMap { [weak self] tasks -> Observable<Task> in
guard let `self` = self else { return .empty() }
let newTask = Task(title: title, memo: memo)
return self.saveTasks(tasks + [newTask]).map { newTask }
}
.do(onNext: { task in
self.event.onNext(.create(task))
})
}
eventから発行されたTaskEventからローカルのTaskListViewReactor.Mutationを生成するためのmutate(taskEvent: TaskEvent) -> Observableが実装されており、これをtransform(mutation:)内で使用しています。
private func mutate(taskEvent: TaskEvent) -> Observable<Mutation> {
let state = self.currentState
switch taskEvent {
case let .create(task):
let indexPath = IndexPath(item: 0, section: 0)
let reactor = TaskCellReactor(task: task)
return .just(.insertSectionItem(indexPath, reactor))
case let .update(task):
guard let indexPath = self.indexPath(forTaskID: task.id, from: state) else { return .empty() }
let reactor = TaskCellReactor(task: task)
return .just(.updateSectionItem(indexPath, reactor))
case let .delete(id):
guard let indexPath = self.indexPath(forTaskID: id, from: state) else { return .empty() }
return .just(.deleteSectionItem(indexPath))
case let .move(id, index):
guard let sourceIndexPath = self.indexPath(forTaskID: id, from: state) else { return .empty() }
let destinationIndexPath = IndexPath(item: index, section: 0)
return .just(.moveSectionItem(sourceIndexPath, destinationIndexPath))
case let .markAsDone(id):
guard let indexPath = self.indexPath(forTaskID: id, from: state) else { return .empty() }
var task = state.sections[indexPath].currentState
task.isDone = true
let reactor = TaskCellReactor(task: task)
return .just(.updateSectionItem(indexPath, reactor))
case let .markAsUndone(id):
guard let indexPath = self.indexPath(forTaskID: id, from: state) else { return .empty() }
var task = state.sections[indexPath].currentState
task.isDone = false
let reactor = TaskCellReactor(task: task)
return .just(.updateSectionItem(indexPath, reactor))
}
}
Viewから発行された本来のActionストリームはどうなっているのでしょうか。TaskListViewReactorではTaskEventが発生するActionはTaskServiceのタスク操作を実行して、Mutaionストリームにはemptyを発行させています。つまり、TaskEventはTaskService経由でしかMutationが発生されないように設計されています。
case let .deleteTask(indexPath):
let task = self.currentState.sections[indexPath].currentState
return self.provider.taskService.delete(taskID: task.id).flatMap { _ in Observable.empty() }
一連のストリームの流れをまとめると次の通りです。このようにTaskEventをTaskService経由でのみ処理することで、TaskEditViewReactorで発生したTaskEventに対してもTaskListViewReactor.Stateの更新が可能となります。
他の解決方法
他の解決方法で、TaskListViewController.viewDidLoad()でTaskListViewReactor.Action.refreshを行なっているのをTaskListViewController.viewWillAppear()
に変更し、タスクリスト画面を表示するタイミングで更新をかけるという方法も考えられます。
もちろんそれでも最新のデータを表示するという要件を満たしてはいますが、単方向ストリームの設計方針から若干逸脱してしまうのかなとも思います。
ただ、実際にtransformを利用してみると、
- グローバルイベントの設計
- グローバルイベントからReactor内で使用するローカルのAction/Mutation/Stateの変化への変換
- 変換したイベントのmerge処理
など、割と設計難易度が上がります。そう考えると案外シンプルに画面の表示タイミングで明示的に最新のデータを反映させるという方法は悪くないなと思えるのが悩ましいところです。
まとめ
Serviceレイヤ、transformを使用することでより実践的なReactorKitの設計が可能になります。
今個人的に抱えている課題として、
- transformの設計が難しく、煩雑になりがち
- 永続化したデータをtransformしたいというケースが多い
があるのですが、これは代替としてRealmの通知と組み合わせることで解決できるんじゃないのかなと模索しています。
transformの設計が難しい問題は、Fluxの理解を深めると解決するのかなと思い調べ中です。
今回までの内容でReactorKitについては網羅して解説できたと思います。次回は応用編でReactorKitのよくある実装パターンか、Realmとの組み合わせ方など具体的な問題解決方法について解説したいと思います。
→ まだ書いていない。