Quantcast
Channel: Swift Advent Calendarの記事 - Qiita
Viewing all articles
Browse latest Browse all 25

RxSwiftとReSwiftで実装するMVVM+Reduxアーキテクチャ

$
0
0

現在開発中の個人アプリにRxSwiftとReSwiftを使ったMVVM+Reduxアーキテクチャを採用しています。
「MVVM+Reduxアーキテクチャ」といっても、特に新しいアーキテクチャを考えたわけではなく、MVVMとReduxを組み合わせてアプリを実装しているということです。

MVVMとReduxは解決しようとしている課題が異なるため、併用することが可能です。
本記事では、なぜMVVM+Reduxアーキテクチャを採用したのか、どのようにMVVM+Reduxアーキテクチャを実装しているのかについてご紹介します。

なぜMVVM+Reduxアーキテクチャを採用したのか

MVVMで開発して感じていた課題

会社では数人の開発者でiOSアプリの開発を行っており、アーキテクチャにはMVVMを採用しています。
規模としては小さいアプリなのですが、MVVMで1年ほど開発を行ってきて、ずっと感じていた課題があります。
それは、MVVMには複数画面間での状態の共有方法状態変化の通知方法についてのルールがないことです。

いくつか具体的な例で考えてみましょう。

  1. 「商品一覧」を表示するタブと、「お気に入り商品一覧」を表示するタブがあるとします。
    このとき、「商品一覧」画面でお気に入りに追加した商品を、「お気に入り商品一覧」画面に即時反映させるにはどうしたらよいでしょうか?

  2. 「商品一覧」画面から「商品詳細」画面への遷移を実装することを考えてみます。
    「商品詳細」画面を表示するためには、商品のIDや名前、画像のURLといったパラメータが必要です。
    このとき、これらのパラメータは「商品詳細」画面のViewControllerに渡すべきでしょうか?ViewModelに渡すべきでしょうか?

  3. 商品検索機能の実装について考えてみます。
    「商品一覧」画面から「検索条件選択」画面に遷移し、検索条件を選択したら「商品一覧」画面に戻って商品を検索するとします。
    このとき、選択した検索条件をどのようにして「商品一覧」画面に渡せばよいでしょうか?

いずれの課題にも解決方法はあります。

  1. このケースでは、2つの画面が共通で参照するクラスを作成して、「商品一覧」画面で商品をお気に入りに追加したことを「お気に入り商品一覧」画面に通知させれば良いでしょう。
    NotificationCenterを使ってもいいかもしれません。

  2. このケースでは、画面遷移の実装方法にも依りますがViewControllerに渡すのが一般的でしょう。
    商品IDも状態の一部であり、ViewControllerがそれをプロパティとして保持しているのは理想的ではないと考えるのであれば、ViewModelに渡すのでもいいと思います。

  3. このケースでは、Delegateパターンがよく使われていると思います。
    また、「商品一覧」画面用のViewModelを「検索条件選択」画面でも共有して、選択された検索条件をそのViewModelにセットするという方法もあります。

しかしながら、MVVMアーキテクチャではこうした複数の画面をまたいだ状態の扱い方に関しては定義がされていません1
開発者それぞれが個々の画面を開発しているときは、Viewの状態はViewModelに持たせる、ViewとViewModelはデータバインディングで状態を双方向に通知するといった、一般的に知られるMVVMのルールをもとに開発していればよかったのですが、複数画面をまたいだ機能の開発となると参照できるルールがないため、実装方法は開発者それぞれに依存してしまうという状態が起きてしまいます。
実装方法を決めれば良いじゃないかと言われたらそれまでなのですが、iOSアプリ開発経験の少ないチームでスタートしたためプロジェクト開始当初からこうした事態を想定することができませんでした。

複数画面間での状態の共有方法、状態変化の通知方法についてもルールがほしい!ということで注目したのがReduxアーキテクチャです。

MVVMにReduxを加えることで課題を解決

状態の扱い方が開発者に依存してしまうということは、将来別の開発者がそのコードに変更を加える際に、どこで状態の受け渡しや変更が起きているかを把握しきれず、思わぬバグを生んでしまう可能性があるということです。
Reduxは状態の扱い方に関してルールを設け、アプリケーションの状態がどのように変化し、やり取りされるのかを予測可能にすることを目的としたアーキテクチャであり、まさに私が抱えていた課題を解決してくれるものだと思いました。

Reduxやその思想のもととなっているFluxについては、私は以下の記事や書籍で勉強をしました。
本記事ではReduxやFluxそのものの解説はしないので、詳しくはそちらをご参照ください。

MVVMとReduxはどちらもアプリケーションアーキテクチャパターンに分類されるものですが、PDS(Presentation-Domain-Separation)をその主目的とするMVVMと、アプリケーション全体の状態の扱い方に注目しているReduxとでは、解決しようとしている課題が異なるため併用が可能だと思っています。

本記事の後半では、私がMVVM+Reduxアーキテクチャをどのように実装しているかについてご紹介します。
Reduxを採用することによって状態の扱い方が統一され、読みやすく変更しやすいコードになることを感じていただければと思います。

RxSwiftとReSwiftを用いたMVVM+Reduxアーキテクチャの実装例

現在絶賛開発中の個人アプリは読書メモを取るためのアプリで、RxSwiftとReSwiftを使ってMVVM+Reduxアーキテクチャを実装しています。
ここからはそのアプリのコード(一部説明用に省略したり簡易化しています)を例に、MVVM+Reduxアーキテクチャの実装方法についてご紹介していきます。

なお、RxSwiftとReSwiftについてはすでに理解している前提で説明していきます。
ReSwiftを使ったReduxの実装部分は、さきほど挙げた以下の記事と書籍を参考にしています。

一覧画面から詳細画面への遷移

最初の例として、一覧画面から詳細画面へ遷移するコードを紹介します。
一つの本に対する読書メモをPost(メモを投稿するイメージなので)と表現しています。

State

Stateは基本的に画面単位で分割し、structをネストして構成しています。
トップレベルのStateであるAppStateは、「読書メモ一覧」画面のStateであるPostListStateを保持しています。

AppState.swift
struct AppState: StateType {
    var postListState = PostListState()
}

PostListStateは、画面に表示する読書メモデータ(posts)を保持しています。
また、下層のStateとして「読書メモ詳細」画面のStateであるPostStateを保持しています。

PostListState.swift
// MARK: State
struct PostListState: StateType {
    var posts = [Post]()
    var postState = PostState()
}

// MARK: Action
extension PostListState {
    enum Action: ReSwift.Action {
        case updatePosts(posts: [Post])
    }
}

// MARK: Reducer
extension PostListState {
    static func reducer(action: ReSwift.Action, state: PostListState?) -> PostListState {
        var state = state ?? PostListState()

        if let action = action as? Action {
            switch action {
            case let .updatePosts(posts):
                state.posts = posts
            }
        }

        state.postState = PostState.reducer(action: action, state: state.postState)

        return state
    }
}

ViewModel

続いてViewModelです。
ViewModelはViewControllerからの入力イベントを受け取り、何らかの処理をしたあとその結果をStateに反映させる役割を担います。
「読書メモ一覧」画面用のViewModelであるPostListViewModelは、以下の2つのことを行っています:

(1)一覧画面の表示イベント(viewWillAppear)を受け取り、APIから読書メモデータを取得し、それをupdatePostsActionを通じて一覧画面のState(PostListState)に反映
(2) 一覧画面上での行選択イベント(itemSelected)を受け取り、その行に対応する読書メモデータをupdatePostActionを通じて「読書メモ詳細」画面のState(PostState)に反映

ViewModelはまた、Stateの更新通知を受け取るStoreSubscriber役も担っています(3)
ViewControllerをStoreSubscriberにすることもできますが、受け取ったStateをもとに何らかのロジックを適用したり、ビューでの表示用に加工したりといった処理はViewModelの責務なのでこのようにしています。

更新通知を受け取ったら、受け取った値をViewControllerに流します。
UIにバインドするためDriverやSignalとして公開するようにしています(4)

PostListViewModel.swift
class PostListViewModel {
    // MARK: Injected properties
    private let store: Store<AppState>
    private let api: API

    // MARK: Input streams
    let viewWillAppear = PublishRelay<Void>()
    let viewWillDisappear = PublishRelay<Void>()
    let itemSelected = PublishRelay<Int>()

    // MARK: Output streams
    private let postsStream = BehaviorRelay<[Post]>(value: [])
    private let errorsStream = PublishRelay<Error>()

    // MARK: Private properties
    private let disposeBag = DisposeBag()

    init(store: Store<AppState>, api: API) {
        self.store = store
        self.api = api

        viewWillAppear
            .subscribe(onNext: { [unowned self] in
                self.store.subscribe(self) { subcription in
                    subcription.select { state in state.postListState }
                }
            })
            .disposed(by: disposeBag)

        // (1)
        viewWillAppear
            .subscribe(onNext: { [unowned self] in
                self.fetchPosts()
            })
            .disposed(by: disposeBag)

        viewWillDisappear
            .subscribe(onNext: { [unowned self] in
                self.store.unsubscribe(self)
            })
            .disposed(by: disposeBag)

        // (2)
        itemSelected
            .withLatestFrom(postsStream) { (index, posts) in posts[index] }
            .subscribe(onNext: { [unowned self] post in
                self.store.dispatch(PostState.Action.updatePost(post: post))
            })
            .disposed(by: disposeBag)
    }

    private func fetchPosts() {
        api.fetchPosts()
            .do(onError: { [unowned self] in self.errorsStream.accept($0) })
            .subscribe(onNext: { [unowned self] in
                self.store.dispatch(PostListState.Action.updatePosts(posts: $0))
            })
            .disposed(by: disposeBag)
    }
}

// MARK: StoreSubscriber
// (3)
extension PostListViewModel: StoreSubscriber {
    typealias StoreSubscriberStateType = PostListState

    func newState(state: PostListState) {
        postsStream.accept(state.posts)
    }
}

// MARK: Output
// (4)
extension PostListViewModel {
    var posts: Driver<[Post]> {
        return postsStream.asDriver()
    }

    var errors: Signal<Error> {
        return errorsStream.asSignal()
    }
}

ViewController

最後にViewControllerを見ていきます。
ViewControllerの役割は非常にシンプルで、ViewModelから受け取った値を使ってビューの描画を行うことと、イベントをViewModelに入力することです。
ViewControllerには極力ロジックを持たせず、ViewModelとのデータバインディングの設定のみを行うようにしています。

PostListViewController
class PostListViewController: UIViewController {
    @IBOutlet weak var tableView: UITableView!

    // MARK: Injected properties
    var viewModel: PostListViewModelProtocol!

    // MARK: Private properties
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        bind()
    }

    func bind() {
        rx.viewWillAppear
            .bind(to: viewModel.viewWillAppear)
            .disposed(by: disposeBag)

        rx.viewWillDisappear
            .bind(to: viewModel.viewWillDisappear)
            .disposed(by: disposeBag)

        tableView.rx.itemSelected
            .map { $0.row }
            .bind(to: viewModel.itemSelected)
            .disposed(by: disposeBag)

        tableView.rx.itemSelected
            .subscribe(onNext: { [unowned self] in
                self.tableView.deselectRow(at: $0, animated: false)
                // DIコンテナからPostViewControllerインスタンスを取得
                let vc = self.resolve(PostViewController.self)!
                self.present(vc, animated: true)
            })
            .disposed(by: disposeBag)

        viewModel.posts
            .drive(tableView.rx.items(
                cellIdentifier: R.nib.postListCell.name,
                cellType: PostListCell.self)
            ) { (_, element, cell) in
                cell.configure(post: element)
            }
            .disposed(by: disposeBag)
    }
}

画面遷移時の状態の受け渡しについて

一通りコードを見てきましたが、改めて一覧画面から詳細画面へ遷移する部分に着目して説明します。

画面遷移のトリガーとなるのは、一覧画面での行の選択イベントです。
選択された行の番号をViewModelに入力します。

PostListViewController
tableView.rx.itemSelected
    .map { $0.row }
    .bind(to: viewModel.itemSelected)
    .disposed(by: disposeBag)

ViewModelは行選択イベントを受け取ると、updatePostActionをDispatchして、「読書メモ詳細」画面用のStateであるPostStateを更新します。

PostListViewModel.swift
itemSelected
    .withLatestFrom(postsStream) { (index, posts) in posts[index] }
    .subscribe(onNext: { [unowned self] post in
        self.store.dispatch(PostState.Action.updatePost(post: post))
    })
    .disposed(by: disposeBag)

ViewControllerでは行選択イベント発火時に詳細画面を表示するという設定も行っていました。
ここで注目すべきは、詳細画面のViewControllerやViewModelに対して何もパラメータを渡していないことです。

PostListViewController
tableView.rx.itemSelected
    .subscribe(onNext: { [unowned self] in
        self.tableView.deselectRow(at: $0, animated: false)
        // DIコンテナからPostViewControllerインスタンスを取得
        let vc = self.resolve(PostViewController.self)!
        self.present(vc, animated: true)
    })
    .disposed(by: disposeBag)

本記事の前半で、詳細画面への遷移時にパラメータをどのように渡すべきかのルールがないという課題を挙げましたが、Reduxの導入によってこれが解決されたのです。
つまり、画面遷移時に次の画面のViewControllerやViewModelにパラメータを渡す必要がなくなったということです。
なぜなら、遷移前に詳細画面のStateにデータを渡しておくことによって、詳細画面のViewControllerやViewModelはデータをStoreから受け取ることができるからです。

データの受け渡しは必ずReduxのフローを経由して行うというルールがあることで、開発者ごとに実装がばらつくことがなくなり、やり取りされているデータの把握が容易になるので、将来コードに変更を加える際に思わぬバグを仕込んでしまう可能性を減らすことができます。

フィルタ条件選択画面で選択したフィルタを一覧画面に適用する

今度は少し複雑な例を取り上げます。

本アプリには、読書をしているときに疑問に感じたことをメモしておく機能があります。
さらに、その疑問にはカテゴリーやスターをつけることができ、それらでフィルタをすることができます。

フィルタを選択して適用する流れは以下のとおりです。

  • 一覧画面でフィルタボタンをタップし、フィルタ選択画面を表示する
  • フィルタ種別選択画面で種別(ここでは「スター」)を選択する
  • フィルタ(ここでは「スター付き」)を選択する
  • ×ボタンをタップしてフィルタを適用する

今回は3つの画面が登場します。
さらに、現在選択されているフィルタ、フィルタの一覧、選択されたフィルタなど、扱うStateもやや複雑です。
MVVM+Reduxアーキテクチャでどのように実装できるでしょうか?

State

「疑問一覧」画面のStateであるQuestionListStateは、疑問データ一覧(questions)を保持しています。
また、下層のStateとして「フィルタ種別選択」画面のStateであるQuestionFilterListStateを保持しています。

QuestionListState.swift
// MARK: State
struct QuestionListState {
    var questions = [Question]()
    var questionFilterListState = QuestionFilterListState()
}

// MARK: Action
extension QuestionListState {
    enum Action: ReSwift.Action {
        case updateQuestions([Question])
    }
}

// MARK: Reducer
extension QuestionListState {
    static func reducer(action: ReSwift.Action, state: QuestionListState?) -> QuestionListState {
        var state = state ?? QuestionListState()

        if let action = action as? Action {
            switch action {
            case let .updateQuestions(questions):
                state.questions = questions
            }
        }

        state.questionFilterListState = QuestionFilterListState.reducer(action: action, state: state.questionFilterListState)

        return state
    }
}

「フィルタ種別選択」画面のStateであるQuestionFilterListStateは、現在選択されているフィルタ(categoryFilterIdisStarredFilterId)を保持しています。
また、下層のStateとして「フィルタ選択」画面のStateであるSelectQuestionFilterStateを保持しています。

QuestionFilterListState.swift
// MARK: State
struct QuestionFilterListState {
    var categoryFilterId = QuestionFilter.defaultCategoryId
    var isStarredFilterId = QuestionFilter.defaultIsStarredId
    var selectQuestionFilterState = SelectQuestionFilterState()
}

// MARK: Action
extension QuestionFilterListState {
    enum Action: ReSwift.Action {
        case selectCategoryFilter(Int)
        case selectIsStarredFilter(Int)
    }
}

// MARK: Reducer
extension QuestionFilterListState {
    static func reducer(action: ReSwift.Action, state: QuestionFilterListState?) -> QuestionFilterListState {
        var state = state ?? QuestionFilterListState()

        if let action = action as? Action {
            switch action {
            case let .selectCategoryFilter(id):
                state.categoryFilterId = id
            case let .selectIsStarredFilter(id):
                state.isStarredFilterId = id
            }
        }

        state.selectQuestionFilterState = SelectQuestionFilterState.reducer(action: action, state: state.selectQuestionFilterState)

        return state
    }
}

「フィルタ選択」画面のStateであるSelectQuestionFilterStateは、フィルタ種別(filterType)、フィルタ一覧(filters)、現在選択されているフィルタ(selected)を保持しています。

SelectQuestionFilterState.swift
// MARK: State
struct SelectQuestionFilterState {
    var filterType: FilterType?
    var filters = [String]()
    var selected: Int?

    enum FilterType {
        case category
        case star
    }
}

// MARK: Action
extension SelectQuestionFilterState {
    enum Action: ReSwift.Action {
        case selectFilterType(type: FilterType, filters: [String], selected: Int?)
    }
}

// MARK: Reducer
extension SelectQuestionFilterState {
    static func reducer(action: ReSwift.Action, state: SelectQuestionFilterState?) -> SelectQuestionFilterState {
        var state = state ?? SelectQuestionFilterState()

        if let action = action as? Action {
            switch action {
            case let .selectFilterType(type, filters, selected):
                state.filterType = type
                state.filters = filters
                state.selected = selected
            }
        }

        return state
    }
}

ViewModel

ViewModelの基本的な形は、画面ごとに大きく変わることはありません。
ViewModelの役割、実装の構成については、一覧画面から詳細画面への遷移の項で説明した通りです。

ここでは先に3つのViewModelの実装を列挙して、状態変化の流れについては後述することとします。

QuestionListViewModel.swift
class QuestionListViewModel {
    // MARK: Injected properties
    private let store: Store<AppState>
    private let api: API

    // MARK: Input streams
    let viewWillAppear = PublishRelay<Void>()
    let viewWillDisappear = PublishRelay<Void>()

    // MARK: Output streams
    private let questionsStream = BehaviorRelay<[Question]>(value: [])
    private let errorsStream = PublishRelay<Error>()

    // MARK: Private properties
    private let disposeBag = DisposeBag()
    private let categoryFilterStream = BehaviorRelay<Int>(value: nil)
    private let isStarredFilterStream = BehaviorRelay<Int>(value: false)

    init(store: Store<AppState>, api: API) {
        self.store = store
        self.firestoreApi = firestoreApi

        viewWillAppear
            .subscribe(onNext: { [unowned self] in
                self.store.subscribe(self) { subcription in
                    subcription.select { state in state.postListState.postState.questionListState }
                }
            })
            .disposed(by: disposeBag)

        let queryParams = Observable.combineLatest(
            categoryFilterStream,
            isStarredFilterStream)

        viewWillAppear
            .withLatestFrom(queryParams)
            .subscribe(onNext: { [unowned self] (category, isStarred) in
                self.fetchQuestions(category: category, isStarred: isStarred)
            })
            .disposed(by: disposeBag)

        viewWillDisappear
            .subscribe(onNext: { [unowned self] in
                self.store.unsubscribe(self)
            })
            .disposed(by: disposeBag)
    }

    private func fetchQuestions(category: Int, isStarred: Int) {
        api.fetchQuestions(category: category, isStarred: isStarred)
            .do(onError: { [unowned self] in self.errorsStream.accept($0) })
            .subscribe(onNext: { [unowned self] in
                self.store.dispatch(QuestionListState.Action.updateQuestions($0))
            })
    }
}

// MARK: StoreSubscriber
extension QuestionListViewModel: StoreSubscriber {
    typealias StoreSubscriberStateType = QuestionListState

    func newState(state: QuestionListState) {
        questionsStream.accept(state.questions)
        categoryFilterStream.accept(state.questionFilterListState.categoryFilterId)
        isStarredFilterStream.accept(questionFilterListState.isStarredFilterId)
    }
}

// MARK: Output
extension QuestionListViewModel {
    var questions: Driver<[Question]> {
        return questionsStream.asDriver()
    }

    var errors: Signal<Error> {
        return errorsStream.asSignal()
    }
}
QuestionFilterListViewModel.swift
class QuestionFilterListViewModel {
    // MARK: Injected properties
    private let store: Store<AppState>

    // MARK: Input streams
    let viewDidLoad = PublishRelay<Void>()
    let itemSelected = PublishRelay<Int>()

    // MARK: Output streams
    private let filtersStream = BehaviorRelay<[Filter]>(value: [])
    private let categoryIdStream = BehaviorRelay<Int>(value: QuestionFilter.defaultCategoryId)
    private let isStarredIdStream = BehaviorRelay<Int>(value: QuestionFilter.defaultIsStarredId)
    private let errorsStream = PublishRelay<Error>()

    // MARK: Private properties
    private let disposeBag = DisposeBag()

    init(store: Store<AppState>) {
        self.store = store

        viewDidLoad
            .subscribe(onNext: { [unowned self] in
                self.store.subscribe(self) { subcription in
                    subcription
                        .select { state in state.postListState.postState.questionListState.questionFilterListState }
                }
            })
            .disposed(by: disposeBag)

        let filterIds = Observable.combineLatest(categoryIdStream, isStarredIdStream)

        itemSelected
            .withLatestFrom(filterIds) { (index, ids) in (index, ids.0, ids.1) }
            .subscribe(onNext: { [unowned self] (index, category, isStarred) in
                if index == 0 {
                    self.store.dispatch(
                        SelectQuestionFilterState.Action.selectFilterType(
                            title: QuestionFilter.categoryFilterLabel,
                            type: .category,
                            filters: QuestionFilter.categoryFilters,
                            selected: category))
                } else {
                    self.store.dispatch(
                        SelectQuestionFilterState.Action.selectFilterType(
                            title: QuestionFilter.isStarredFilterLabel,
                            type: .star,
                            filters: QuestionFilter.isStarredFilters,
                            selected: isStarred))
                }
            })
            .disposed(by: disposeBag)
    }

    deinit {
        store.unsubscribe(self)
    }
}

// MARK: StoreSubscriber
extension QuestionFilterListViewModel: StoreSubscriber {
    typealias StoreSubscriberStateType = QuestionFilterListState

    func newState(state: QuestionFilterListState) {
        categoryIdStream.accept(state.categoryFilterId)
        isStarredIdStream.accept(state.isStarredFilterId)

        let categoryFilter = QuestionFilter.findCategoryFilterById(state.categoryFilterId)
        let starFilter = QuestionFilter.findIsStarredFilterById(state.isStarredFilterId)

        filtersStream.accept([
            (label: QuestionFilter.categoryFilterLabel, filter: categoryFilter),
            (label: QuestionFilter.isStarredFilterLabel, filter: starFilter)
        ])
    }
}

// MARK: Output
extension QuestionFilterListViewModel {
    var filters: Driver<[Filter]> {
        return filtersStream.asDriver()
    }

    var errors: Signal<Error> {
        return errorsStream.asSignal()
    }
}
SelectQuestionFilterViewModel.swift
class SelectQuestionFilterViewModel {
    // MARK: Injected properties
    private let store: Store<AppState>

    // MARK: Input streams
    let viewDidLoad = PublishRelay<Void>()
    let itemSelected = PublishRelay<Int>()

    // MARK: Private properties
    private let disposeBag = DisposeBag()
    private let filterTypeStream = BehaviorRelay<SelectQuestionFilterState.FilterType?>(value: nil)
    private let itemsStream = BehaviorRelay<[String]>(value: [])
    private let checkedIndexStream = BehaviorRelay<Int?>(value: nil)

    init(store: Store<AppState>) {
        self.store = store

        viewDidLoad
            .subscribe(onNext: { [unowned self] in
                self.store.subscribe(self) { subcription in
                    subcription.select { state in state.postListState.postState.questionListState.questionFilterListState.selectQuestionFilterState }
                }
            })
            .disposed(by: disposeBag)

        itemSelected
            .withLatestFrom(filterTypeStream) { (index: $0, filterType: $1) }
            .subscribe(onNext: { [unowned self] in
                switch $0.filterType! {
                case .category:
                    self.store.dispatch(QuestionFilterListState.Action.selectCategoryFilter($0.index))
                case .star:
                    self.store.dispatch(QuestionFilterListState.Action.selectIsStarredFilter($0.index))
                }
            })
            .disposed(by: disposeBag)
    }

    deinit {
        store.unsubscribe(self)
    }
}

// MARK: StoreSubscriber
extension SelectQuestionFilterViewModel: StoreSubscriber {
    typealias StoreSubscriberStateType = SelectQuestionFilterState

    func newState(state: SelectQuestionFilterState) {
        filterTypeStream.accept(state.filterType)
        itemsStream.accept(state.filters)
        checkedIndexStream.accept(state.selected)
    }
}

// MARK: Output
extension SelectQuestionFilterViewModel {
    var items: Driver<[String]> {
        return itemsStream.asDriver()
    }

    var checkedIndex: Driver<Int?> {
        return checkedIndexStream.asDriver()
    }
}

ViewController

ViewControllerは前述の通りViewModelから受け取った値のビューへの描画とViewModelへのイベントの入力だけを担います。
状態の扱いに関してViewControllerは何も関知しないため、コードの紹介は省略します。

フィルタ機能の状態変化の流れについて

ここからは、フィルタが選択され一覧画面に適用されるまでの、状態変化の流れに着目して見ていきます。

まず、「フィルタ種別選択」画面にて、「スター」フィルタが選択されたとします。
するとQuestionFilterListViewModelは、selectFilterTypeActionをDispatchして、「フィルタ選択」画面のStateであるSelectQuestionFilterStateを更新します(1)
ここではActionにフィルタ種別、フィルタ一覧、現在選択されているフィルタを渡しています。

コードは省略しますが、ViewControllerではフィルタ種別の選択と同時に「フィルタ選択」画面への遷移が行われています。
前述したとおり、ViewControllerは画面遷移に際して次の画面のViewControllerにパラメータを渡す必要はありません。
状態の受け渡しはViewModelとStoreで完結しており、ViewControllerはどのような状態がやり取りされているのか意識しなくて良いのです。

QuestionFilterListViewModel.swift
itemSelected
    .withLatestFrom(filterIds) { (index, ids) in (index, ids.0, ids.1) }
    .subscribe(onNext: { [unowned self] (index, category, isStarred) in
        if index == 0 {
            self.store.dispatch(
                SelectQuestionFilterState.Action.selectFilterType(
                    type: .category,
                    filters: QuestionFilter.categoryFilters,
                    selected: category))
        } else {
            // (1)
            self.store.dispatch(
                SelectQuestionFilterState.Action.selectFilterType(
                    type: .star,
                    filters: QuestionFilter.isStarredFilters,
                    selected: isStarred))
        }
    })
    .disposed(by: disposeBag)

続いて、「フィルタ選択」画面にてフィルタが選択される際の流れについて見ていきます。
ここでは「スター付き」というフィルタが選択されたとします。
するとSelectQuestionFilterViewModelは、selectIsStarredFilterActionをDipatchして、「フィルタ種別」画面のStateであるQuestionFilterListStateを更新します(2)
ここではActionに選択された行の番号、つまりフィルタIDを渡しています。

SelectQuestionFilterViewModel.swift
itemSelected
    .withLatestFrom(filterTypeStream) { (index: $0, filterType: $1) }
    .subscribe(onNext: { [unowned self] in
        switch $0.filterType! {
        case .category:
            self.store.dispatch(QuestionFilterListState.Action.selectCategoryFilter($0.index))
        case .star:
            // (2)
            self.store.dispatch(QuestionFilterListState.Action.selectIsStarredFilter($0.index))
        }
    })
    .disposed(by: disposeBag)

フィルタが選択されると、「フィルタ種別選択」画面に戻ります。
ここで×ボタンをタップする「フィルタ種別選択」画面が閉じられ、「疑問一覧」画面が表示されます。
すると、QuestionListViewModelviewWillAppearイベントが入力されます。

viewWillAppearイベントをトリガーに、APIから疑問一覧データを取得します。
QuestionFilterListStateが保持するフィルタは先程選択したフィルタに更新されているので(3)、APIから「スター付き」の疑問一覧データが取得されることになります(4)

QuestionViewModel.swift
let queryParams = Observable.combineLatest(
            categoryFilterStream,
            isStarredFilterStream)

viewWillAppear
    .withLatestFrom(queryParams)
    .subscribe(onNext: { [unowned self] (category, isStarred) in
        // (4)
        self.fetchQuestions(category: category, isStarred: isStarred)
    })
    .disposed(by: disposeBag)

...

func newState(state: QuestionListState) {
    questionsStream.accept(state.questions)
    // (3)
    categoryFilterStream.accept(state.questionFilterListState.categoryFilterId)
    isStarredFilterStream.accept(state.questionFilterListState.isStarredFilterId)
}

上記の流れを図で表すとこんな感じです。
Reduxによって状態変化の流れは常に一方向であり、複雑に入り組むことはありません。

仕事で開発しているアプリではReduxなしでこのようなフィルタ機能を実装していましたが、そのときはQuestionListViewModelに相当するViewModelをフィルタ選択画面に渡していき、選択されたフィルタをそのViewModelにセットするというやり方をしていました。
これだとフィルタ選択画面では2つのViewModelが存在するような形になってしまい、他の画面との統一性がなくなってしまいました。

今回紹介した実装方法だと、画面が持つ機能によってViewControllerやViewModelの実装が大きく変わることがありません。
複雑な状態のやり取りが起きる画面であっても、状態変化が起きる場所は決まっているので、コードが非常に読みやすいです。

まとめ

MVVM+Reduxアーキテクチャにチャレンジした背景と、MVVM+Reduxアーキテクチャをどのように実装しているかについてご紹介しました。

実装方法については正直まだまだ試行錯誤の段階です。
今は単純に画面ごとに分割しているだけのStateツリーの構成の仕方にも改善の余地はあるだろうし、ActionCreator、Middlewareといった、まだ使用していないReduxのコンポーネントもあるので、より良いMVVM+Reduxアーキテクチャの実装方法があるはずです。

今後もMVVM+Reduxアーキテクチャについて色々と発信していく所存です。
非常に長くなってしまいましたが、最後までお読みいただきありがとうございました。


  1. ネット上の様々なMVVMの記事を呼んだ上での私個人の理解です。 


Viewing all articles
Browse latest Browse all 25

Latest Images

Trending Articles