店舗一覧画面と店舗検索画面からなる以下のようなアプリがあります。
店舗検索画面は正確には「店舗検索条件選択画面」であり、ここで指定した検索条件が店舗検索画面に渡され、店舗検索画面側で店舗を検索して一覧表示するというデータの流れになっています。
アーキテクチャはMVVM(RxSwift)を採用していて、以下のように4つのクラスで構成されているとします。
ここで問題になるのが、店舗検索画面でユーザが選択した検索条件を、どのように店舗一覧画面側に渡すかです。
※以下では説明に必要な部分的なコードのみを掲載します。完全なサンプルコードはGithubに上げてあり、URLは記事の最後に記載しています。
店舗検索画面側のイベントを店舗一覧画面側が監視する
先にスマートじゃないやり方を紹介します。
ShopSearchViewControllerが発行する検索条件選択イベントを、ShopListViewModelにバインドしてしまう方法です。
検索条件選択イベントは、PublishSubject
で実装します。
ユーザがセルをタップしたら、そのセルが持つ検索条件文字列をストリームに発行します。
class ShopSearchViewController: BaseViewController {
private let searchQueryStream = PublishSubject<String>()
var searchQuery: Driver<String> {
return searchQueryStream.asDriver(onErrorJustReturn: "")
}
...
tableView.rx.itemSelected.asDriver()
.do(onNext: { [unowned self] in self.tableView.deselectRow(at: $0, animated: false)} )
.withLatestFrom(vm.genres) { ($0, $1) }
.map { (indexPath, genres) in genres[indexPath.row] }
.drive(onNext: { [unowned self] genre in
self.searchQueryStream.onNext(genre) // 検索条件選択イベントを発行
self.dismiss(animated: true)
})
.disposed(by: disposeBag)
}
ShopSearchViewControllerとShopListViewModelのバインディング設定はShopListViewControllerで行います。
以下は店舗一覧の右上にある検索アイコンをタップしたときの動作の宣言です。
アイコンをタップすると、店舗検索画面が表示されると同時に、データバインディングの設定がされます。
class ShopListViewController: BaseViewController {
var vm: ShopListViewModel!
...
searchButton.rx.tap.asDriver()
.drive(onNext: { [unowned self] _ in
let vc = ViewControllerBuilder.shared.makeShopSearvhViewController()
self.present(vc, animated: true) // 店舗検索画面の表示
vc.searchQuery.drive(self.vm.searchQueryObserver).disposed(by: vc.disposeBag) // バインディング
})
.disposed(by: disposeBag)
}
以上のコードにより、ユーザが検索条件のセルをタップすると新しい検索条件で店舗が検索され、店舗一覧画面が更新されるようになります。
この実装の課題は、データの流れが複雑になってしまうことです。
今回の例はシンプルなので流れを追うことは難しくありませんが、例えばShopListViewControllerから複数の画面への遷移があり、それぞれがShopListViewModelとバインドされてしまうようになると、データの流れを追うのが大変になります。
Fluxパターンを使った実装
Fluxパターンを取り入れてみようと思ったきっかけは、WEB+DB PRESS Vol.106でFluxパターンの実装例が詳しく紹介されていたことでした。
以下で紹介する実装は、そこで紹介されていたものを参考にしています。
Fluxパターンを実装すると、データの流れは以下のように変わります。
検索条件選択イベントがShopSearchViewModelに入力されると、 ShopSearchActionCreator -> (Dispather) -> ShopSearchStore という流れで検索条件アクションが伝播されていきます。
ShopListViewModelはShopSearchStoreの検索条件ストリームをサブスクライブしていて、イベントが発行されると店舗一覧を更新します。
ViewController、ViewModelの実装を見ていきます。
まずShopSearchViewControllerですが、ShopListViewModelへ検索条件を通知するためのプロパティがなくなり、代わりにShopSearchViewModelとのバインド設定が入ります。
class ShopSearchViewController: BaseViewController {
...
tableView.rx.itemSelected.asDriver()
.do(onNext: { [unowned self] in self.tableView.deselectRow(at: $0, animated: false)} )
.withLatestFrom(vm.genres) { ($0, $1) }
.map { (indexPath, genres) in genres[indexPath.row] }
.drive(onNext: { [unowned self] genre in
self.vm.querySelected.onNext(genre) // 変更
self.dismiss(animated: true)
})
.disposed(by: disposeBag)
}
続いて、ShopSearchViewModelにShopSearchActionCreatorを持たせます。
検索条件が選択されたら、ShopSearchActionCreator.selectQuery(query:)を呼ぶようにします。
このメソッドを呼び出すと、Dispatcherを経由してShopSearchStoreに検索条件が伝播されます。
class ShopSearchViewModel {
var querySelected: AnyObserver<String> {
return querySelectedStream.asObserver()
}
private let disposeBag = DisposeBag()
private let actionCreator: ShopSearchActionCreator
private let querySelectedStream = PublishSubject<String>()
init(actionCreator: ShopSearchActionCreator) {
self.actionCreator = actionCreator
querySelectedStream.subscribe(onNext: { [unowned self] query in
self.actionCreator.selectQuery(query: query)
})
.disposed(by: disposeBag)
}
}
さらに、ShopListViewModelにShopSearchStoreを持たせます。
ShopSearchStore.queryをサブスクライブし、検索条件が選択されたら店舗リストを更新するようにします。
class ShopListViewModel {
var shops: Driver<[String]> {
return shopsStream.asDriver()
}
var query: Driver<String> {
return store.query.asDriver(onErrorJustReturn: "")
}
private let store: ShopSearchStore
private let shopsStream = BehaviorRelay<[String]>(value: [])
private let disposeBag = DisposeBag()
init(store: ShopSearchStore) {
self.store = store
store.query
.map { Shop.list[$0]! }
.bind(to: shopsStream)
.disposed(by: disposeBag)
}
}
以前ShopListViewControllerで設定していたShopListViewModelとShopSearchViewControllerとのバインディングは不要になります。
単にShopSearchViewControllerへの画面遷移コードだけが残りました。
class ShopListViewController: BaseViewController {
...
searchButton.rx.tap.asDriver()
.drive(onNext: { [unowned self] _ in
let vc = ViewControllerBuilder.shared.makeShopSearvhViewController()
self.present(vc, animated: true)
})
.disposed(by: disposeBag)
}
まとめ
Fluxパターンを使ってビュー間でデータの受け渡しをする方法について説明しました。
既存のコードにFluxパターンを適用して気づいたのは、既存のコードへの変更が少なくて済むことです。
また、作成するファイルの数は増えますが、Fluxパターンという実装方式の共通化によって各ファイルの役割が明確化され、なんでもViewModelにやらせるということがなくなり、コードの読みやすさも向上します。
FluxパターンはなんとなくReactとかで使うものという認識でいましたが、iOSアプリ開発でも大変便利に使えるものだということがわかりました。
Reference
サンプルリポジトリ
- スマートじゃないやり方: https://github.com/takehilo/RxPassingDataBackward
- Fluxパターン: https://github.com/takehilo/FluxPatternExample
Fluxパターンの参考書籍
- WEB+DB PRESS Vol.106: https://gihyo.jp/magazine/wdpress/archive/2018/vol106