0. はじめに
この記事は、下記のような方に向けて書いています。
- Reduxの概念をある程度は理解している
- iOSアプリのアーキテクチャとしてRxも組み合わせてイイ感じに採用したいが、実装方法で悩んでいる
また、RxSwiftの使い方については今回の本筋とはずれるので割愛しています。
1. 導入
使用するライブラリは以下の通りです。
- RxSwift - 6.0.0
- ReSwift - 5.1.1
今回はCarthageを使ってライブラリをインストールしていきます。
# Rx
github "ReactiveX/RxSwift"
# Redux
github "ReSwift/ReSwift"
Cartfileを作成したら、下記のコマンドを実行して指定したライブラリをビルド後、Xcodeプロジェクトにビルドしたライブラリを追加します。
今回説明したい本筋とはずれるので、Carthgeによる導入については先人の書いた分かりやすい記事を参照ください(参照)。
carthage update --platform iOS --no-use-binaries
これで準備は完了です。
2. Redux+Rxでアーキテクチャ構築
データフローの確認
まずはこれから実現したいことの大まかな流れを確認しましょう。
- ViewControllerがStoreに対してActionをDispatch
- Storeが「受け取ったAction」と「古いState」をReducerに渡す
- Reducerが新たなStateを発行
- 新たなStateが発行されたことをViewControllerに通知
また、気をつけるべきこととして、非同期通信などのような副作用となるものはMiddlewareやActionCreatorでハンドリングし、純粋関数であるReducerに副作用が侵入しないようにします。
Storeの実装
今回の"キモ"はこのRxStore
です。
RxSwiftの良さを活かして、新しく発行されたStateの更新通知を受け取るためにstateDriver
を公開します。
また、最新のStateを取得するためのプロパティも用意してあげます。
また、ReSwiftではActionをメインスレッドからDispatchすることを推奨しています。
もしメインスレッド以外からDispatchされた際に、他のActionがReducerまたはMiddlewareを処理中の場合、FatalErrorを発生させる排他制御の機能が備わっているためです。
public class RxStore<AnyStateType>: ReSwift.StoreSubscriber where AnyStateType: ReSwift.StateType {
// 最新のStateの発行通知を受け取るためのDriverを公開する
public lazy var stateDriver: Driver<AnyStateType> = {
stateRelay.asDriver()
}()
// 最新のStateを取得するためのプロパティを公開
public var state: AnyStateType { return stateRelay.value }
// 最新のStateをキャッシュする & イベントを流す
private let stateRelay: BehaviorRelay<AnyStateType>
private let store: ReSwift.Store<AnyStateType>
public init(store: ReSwift.Store<AnyStateType>) {
self.store = store
self.stateRelay = BehaviorRelay(value: store.state)
self.store.subscribe(self)
}
deinit {
store.unsubscribe(self)
}
// 新しいStateが発行されたときに呼ばれる
public func newState(state: AnyStateType) {
// stateReray に新しく発行されたStateを反映する
stateRelay.accept(state)
}
public func dispatch(_ action: ReSwift.Action) {
// Action の Dispatch はメインスレッドで行うようにする
guard Thread.isMainThread else {
DispatchQueue.main.async { [weak self] in
self?.store.dispatch(action)
}
return
}
store.dispatch(action)
}
}
Store
はアプリケーションに1つだけ存在するものとしてシングルトンで宣言します。
final public class ApplicationStore {
// 唯一のインスタンス
private static let instance = ApplicationStore()
private let store: RxStore<ApplicationState>
// storeを公開
public static var shared: RxStore<ApplicationState> {
return instance.store
}
// シングルトンにする
private init() {
store = RxStore(store: ReSwift.Store<ApplicationState>(
reducer: appReducer, // Storeで使うReducerを登録
state: nil, // 初期Stateを登録
middleware: [loggingMiddleware] // Middlewareを登録
))
}
}
Stateの実装
今回は、お気に入り機能を想定してFavoriteState
を作っておきます。
Stateの中身は全てReadOnlyなプロパティ(public internal(set)
)として宣言します。
Reduxでは単一方向のデータフローを実現するために以下の制約を設けているからです。
- Stateの内容は変更できない(ReadOnly)
- Reducerのみが新たなStateを発行できる
public struct ApplicationState: ReSwift.StateType {
public internal(set) var favoriteState = FavoriteState() // ReadOnly
}
public struct FavoriteState: ReSwift.StateType {
public internal(set) var isLoading: Bool = false // ReadOnly
public internal(set) var error: Error? // ReadOnly
public internal(set) var favoritedUsers: [favoritedUser] = [] // ReadOnly
}
また、ViewControllerで通知を受け取ることを想定して、必要に応じてstateDriver
をカスタムしておくと便利です。
// State extension
extension RxStore where AnyStateType == ApplicationState {
var favoriteState: Driver<FavoriteState> {
return stateDriver
.map { $0.favoriteState }
.skip(1) // 初期値の通知をスキップ
.distinctUntilChanged()
}
}
Actionの実装
お気に入り機能のために以下のアクションを設けます。
- お気に入りリストの取得
- お気に入りリストへの登録
- お気に入りリストからの削除
Actionはenum
かstruct
で作成できますが、個人的にはenum
の方がReducerでの抜け漏れが防げる & 分かりやすい点が好きなので、今回はenum
で作成します。
また、どのStateのActionか分かりやすくするために、StateのextensionとしてActionを作成します。
extension FavoriteState {
public enum Action: ReSwift.Action {
case requestStart
case requestError(Error)
case get(favoritedUsers: [FavoritedUser]) // お気に入りリストの取得
case add(favoritedUser: FavoritedUser) // お気に入りリストへの登録
case remove(favoritedUser: FavoritedUser) // お気に入りリストからの削除
}
}
Action Creatorの実装
API通信などの副作用に該当する実装は、ReducerではなくActionCreatorで処理をしていきます。
副作用の処理はMiddlewareでも良いですが、API通信など複数箇所で頻繁に利用する可能性のあるものはActionCreatorで実装する方が便利です。
今回はActionCreatorといいつつもAction Dispatcherのような役割です。
final public class FavoriteActionDispatcher {
public static func getFavoriteListRequest(disposeBag: DisposeBag) {
ApplicationStore.shared.dispatch(FavoriteState.Action.requestStart) // StoreにActionをDispatch
// お気に入りリスト取得API
FavoriteAPI.get()
.subscribe(
onSuccess: { favoritedUsers in
let action = FavoriteState.Action.get(favoritedUsers: favoritedUsers)
ApplicationStore.shared.dispatch(action) // StoreにActionをDispatch
},
onError: { error in
let action = FavoriteState.Action.requestError(error)
ApplicationStore.shared.dispatch(action) // StoreにActionをDispatch
}
)
.disposed(by: disposeBag)
}
public static func postFavoriteRequest(userId: Int, disposeBag: DisposeBag) {
ApplicationStore.shared.dispatch(FavoriteState.Action.requestStart) // StoreにActionをDispatch
// お気に入りリスト登録API
FavoriteAPI.post(userId: userId)
.subscribe(
onSuccess: { favoritedUser in
let action = FavoriteState.Action.add(favoritedUser: favoritedUser)
ApplicationStore.shared.dispatch(action) // StoreにActionをDispatch
},
onError: { error in
let action = FavoriteState.Action.requestError(error)
ApplicationStore.shared.dispatch(action) // StoreにActionをDispatch
}
)
.disposed(by: disposeBag)
}
public static func deleteFavoriteRequest(userId: Int, disposeBag: DisposeBag) {
ApplicationStore.shared.dispatch(FavoriteState.Action.requestStart) // StoreにActionをDispatch
// お気に入りリスト削除API
FavoriteAPI.delete(userId: userId)
.subscribe(
onSuccess: { favoritedUser in
let action = FavoriteState.Action.remove(favoritedUser: favoritedUser)
ApplicationStore.shared.dispatch(action) // StoreにActionをDispatch
},
onError: { error in
let action = FavoriteState.Action.requestError(error)
ApplicationStore.shared.dispatch(action) // StoreにActionをDispatch
}
)
.disposed(by: disposeBag)
}
}
Reducerの実装
Reducerでは、古いStateをコピーして新しいStateを作成し、Actionの内容を元に新しいStateに反映して、発行します。
また、AppReducer
をトップレベルにした階層構造になるように作成していきます。
Reducerは純粋関数として機能する(同じ引数を渡したら、常に同じ結果になる)ことが期待されているため、副作用は含めないようにします。
また、構造的に副作用の侵入を防ぐようにすることは難しいため、コードレビューなどで掬い取る必要があります。
// top level Reducer
public func appReducer(action: ReSwift.Action, state: ApplicationState?) -> ApplicationState {
// 古いStateをコピーして新しいStateを作成
var state = state ?? ApplicationState()
// favoriteStateを更新
state.favoriteState = FavoriteState.reducer(action: action, state: state.favoriteState)
return state
}
extension FavoriteState {
public static func reducer(action: ReSwift.Action, state: FavoriteState?) -> FavoriteState {
// 古いStateをコピーして新しいStateを作成
var state = state ?? FavoriteState()
// Actionの種類を絞る
guard let action = action as? FavoriteState.Action else { return state }
switch action {
case .requestStart:
state.isLoading = true
state.error = nil
case .requestError(let error):
state.isLoading = false
state.error = error
case .get(favoritedUsers: let favoritedUsers):
state.isLoading = false
state.favoritedUsers = favoritedUsers
case .add(favoritedUser: let favoritedUser):
state.isLoading = false
state.favoritedUsers.append(favoritedUser)
case .remove(favoritedUser: let favoritedUser):
state.isLoading = false
guard let index = state.favoritedUsers.firstIndex(where: { $0 == favoritedUser }) else { break }
state.favoritedUsers.remove(at: index)
}
return state
}
}
Middlewareの実装
ActionCreatorと同じく、副作用を扱う場合にMiddlewareを使います。
API通信など複数箇所で頻繁に利用する可能性のある副作用以外をMiddlewareで吸収します。
また、Stateが更新された後に副作用を挟みたいという場合は、next(action)
の後に実装することで実現できます。
public let loggingMiddleware: ReSwift.Middleware<ApplicationState> = { dispatch, getState in
return { next in
return { action in
debugPrint("before state: \(String(describing: getState()))")
next(action)
debugPrint("after state: \(String(describing: getState()))")
}
}
}
3. 実際に使ってみる
ReduxをMVVMと組み合わせて実際に使ってみます。
ActionCreatorを使ってStoreにActionをDispatchする
ViewControllerのdisposeBag
をActionCreatorに渡すことで、ViewControllerがdeinit
されたタイミングでAPI通信処理を中断させることができます。
final class FavoriteViewController: UIViewController {
private let disposeBag = DisposeBag()
override func viewDidLoad() {
super.viewDidLoad()
FavoriteActionDispatcher.getFavoriteListRequest(disposeBag: disposeBag)
}
}
ViewControllerでState発行の通知を受け取る
final class FavoriteViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
bindStore()
}
func bindStore() {
ApplicationStore.shared.favoriteState.map { $0.favoritedUsers }
.drive(onNext: { favoritedUsers in
debugPrint(favoritedUsers)
})
.disposed(by: disposeBag)
}
}
まとめ
Redux+RxでのiOSアプリアーキテクチャの構築方法ついてご紹介させていただきました。
少しでもRedux+RxでのiOSアーキテクチャ構築に悩む方々の助けになれれば幸いです。
実装についてはまだまだ足りない箇所もあったりベストプラクティスについての議論が必要な部分もあるかと思うので、ぜひコメントをいただけると嬉しいです。
今回紹介したようなアーキテクチャ構築やiOSアプリに関わらず、今後も色々と発信していく所存です。
長くなってしまいましたが、最後までお読みいただきありがとうございました。