現在開発中の個人アプリにRxSwiftとReSwiftを使ったMVVM+Reduxアーキテクチャを採用しています。
「MVVM+Reduxアーキテクチャ」といっても、特に新しいアーキテクチャを考えたわけではなく、MVVMとReduxを組み合わせてアプリを実装しているということです。
MVVMとReduxは解決しようとしている課題が異なるため、併用することが可能です。
本記事では、なぜMVVM+Reduxアーキテクチャを採用したのか、どのようにMVVM+Reduxアーキテクチャを実装しているのかについてご紹介します。
なぜMVVM+Reduxアーキテクチャを採用したのか
MVVMで開発して感じていた課題
会社では数人の開発者でiOSアプリの開発を行っており、アーキテクチャにはMVVMを採用しています。
規模としては小さいアプリなのですが、MVVMで1年ほど開発を行ってきて、ずっと感じていた課題があります。
それは、MVVMには複数画面間での状態の共有方法、状態変化の通知方法についてのルールがないことです。
いくつか具体的な例で考えてみましょう。
-
「商品一覧」を表示するタブと、「お気に入り商品一覧」を表示するタブがあるとします。
このとき、「商品一覧」画面でお気に入りに追加した商品を、「お気に入り商品一覧」画面に即時反映させるにはどうしたらよいでしょうか? -
「商品一覧」画面から「商品詳細」画面への遷移を実装することを考えてみます。
「商品詳細」画面を表示するためには、商品のIDや名前、画像のURLといったパラメータが必要です。
このとき、これらのパラメータは「商品詳細」画面のViewControllerに渡すべきでしょうか?ViewModelに渡すべきでしょうか? -
商品検索機能の実装について考えてみます。
「商品一覧」画面から「検索条件選択」画面に遷移し、検索条件を選択したら「商品一覧」画面に戻って商品を検索するとします。
このとき、選択した検索条件をどのようにして「商品一覧」画面に渡せばよいでしょうか?
いずれの課題にも解決方法はあります。
-
このケースでは、2つの画面が共通で参照するクラスを作成して、「商品一覧」画面で商品をお気に入りに追加したことを「お気に入り商品一覧」画面に通知させれば良いでしょう。
NotificationCenterを使ってもいいかもしれません。 -
このケースでは、画面遷移の実装方法にも依りますがViewControllerに渡すのが一般的でしょう。
商品IDも状態の一部であり、ViewControllerがそれをプロパティとして保持しているのは理想的ではないと考えるのであれば、ViewModelに渡すのでもいいと思います。 -
このケースでは、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
を保持しています。
struct AppState: StateType {
var postListState = PostListState()
}
PostListState
は、画面に表示する読書メモデータ(posts
)を保持しています。
また、下層のStateとして「読書メモ詳細」画面のStateであるPostState
を保持しています。
// 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から読書メモデータを取得し、それをupdatePosts
Actionを通じて一覧画面のState(PostListState
)に反映
(2) 一覧画面上での行選択イベント(itemSelected
)を受け取り、その行に対応する読書メモデータをupdatePost
Actionを通じて「読書メモ詳細」画面のState(PostState
)に反映
ViewModelはまた、Stateの更新通知を受け取るStoreSubscriber役も担っています**(3)**。
ViewControllerをStoreSubscriberにすることもできますが、受け取ったStateをもとに何らかのロジックを適用したり、ビューでの表示用に加工したりといった処理はViewModelの責務なのでこのようにしています。
更新通知を受け取ったら、受け取った値をViewControllerに流します。
UIにバインドするためDriverやSignalとして公開するようにしています**(4)**。
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とのデータバインディングの設定のみを行うようにしています。
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に入力します。
tableView.rx.itemSelected
.map { $0.row }
.bind(to: viewModel.itemSelected)
.disposed(by: disposeBag)
ViewModelは行選択イベントを受け取ると、updatePost
ActionをDispatchして、「読書メモ詳細」画面用のStateであるPostState
を更新します。
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に対して何もパラメータを渡していないことです。
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
を保持しています。
// 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
は、現在選択されているフィルタ(categoryFilterId
とisStarredFilterId
)を保持しています。
また、下層のStateとして「フィルタ選択」画面のStateであるSelectQuestionFilterState
を保持しています。
// 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
)を保持しています。
// 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の実装を列挙して、状態変化の流れについては後述することとします。
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()
}
}
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()
}
}
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
は、selectFilterType
ActionをDispatchして、「フィルタ選択」画面のStateであるSelectQuestionFilterState
を更新します**(1)**。
ここではActionにフィルタ種別、フィルタ一覧、現在選択されているフィルタを渡しています。
コードは省略しますが、ViewControllerではフィルタ種別の選択と同時に「フィルタ選択」画面への遷移が行われています。
前述したとおり、ViewControllerは画面遷移に際して次の画面のViewControllerにパラメータを渡す必要はありません。
状態の受け渡しはViewModelとStoreで完結しており、ViewControllerはどのような状態がやり取りされているのか意識しなくて良いのです。
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
は、selectIsStarredFilter
ActionをDipatchして、「フィルタ種別」画面のStateであるQuestionFilterListState
を更新します**(2)**。
ここではActionに選択された行の番号、つまりフィルタIDを渡しています。
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)
フィルタが選択されると、「フィルタ種別選択」画面に戻ります。
ここで×ボタンをタップする「フィルタ種別選択」画面が閉じられ、「疑問一覧」画面が表示されます。
すると、QuestionListViewModel
にviewWillAppear
イベントが入力されます。
viewWillAppear
イベントをトリガーに、APIから疑問一覧データを取得します。
QuestionFilterListState
が保持するフィルタは先程選択したフィルタに更新されているので**(3)、APIから「スター付き」の疑問一覧データが取得されることになります(4)**。
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アーキテクチャについて色々と発信していく所存です。
非常に長くなってしまいましたが、最後までお読みいただきありがとうございました。
-
ネット上の様々なMVVMの記事を呼んだ上での私個人の理解です。 ↩