12
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ビュー間でのデータの受け渡しの複雑さをFluxパターンで解消

Last updated at Posted at 2018-09-25

店舗一覧画面と店舗検索画面からなる以下のようなアプリがあります。

shop-search.gif

店舗検索画面は正確には「店舗検索条件選択画面」であり、ここで指定した検索条件が店舗検索画面に渡され、店舗検索画面側で店舗を検索して一覧表示するというデータの流れになっています。

アーキテクチャはMVVM(RxSwift)を採用していて、以下のように4つのクラスで構成されているとします。
ここで問題になるのが、店舗検索画面でユーザが選択した検索条件を、どのように店舗一覧画面側に渡すかです。

※以下では説明に必要な部分的なコードのみを掲載します。完全なサンプルコードはGithubに上げてあり、URLは記事の最後に記載しています。

店舗検索画面側のイベントを店舗一覧画面側が監視する

先にスマートじゃないやり方を紹介します。
ShopSearchViewControllerが発行する検索条件選択イベントを、ShopListViewModelにバインドしてしまう方法です。

検索条件選択イベントは、PublishSubjectで実装します。
ユーザがセルをタップしたら、そのセルが持つ検索条件文字列をストリームに発行します。

ShopSearchViewController.swift
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で行います。
以下は店舗一覧の右上にある検索アイコンをタップしたときの動作の宣言です。
アイコンをタップすると、店舗検索画面が表示されると同時に、データバインディングの設定がされます。

ShopListViewController.swift
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とのバインド設定が入ります。

ShopSearchViewController.swift
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に検索条件が伝播されます。

ShopSearchViewModel.swift
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をサブスクライブし、検索条件が選択されたら店舗リストを更新するようにします。

ShopListViewModel.swift
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への画面遷移コードだけが残りました。

ShopListViewController.swift
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

サンプルリポジトリ

Fluxパターンの参考書籍

12
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?