LoginSignup
5
7

More than 3 years have passed since last update.

Redux+Rxで構築するiOSアプリアーキテクチャ

Last updated at Posted at 2020-11-09

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でアーキテクチャ構築

データフローの確認

まずはこれから実現したいことの大まかな流れを確認しましょう。

  1. ViewControllerがStoreに対してActionをDispatch
  2. Storeが「受け取ったAction」と「古いState」をReducerに渡す
  3. Reducerが新たなStateを発行
  4. 新たなStateが発行されたことをViewControllerに通知

また、気をつけるべきこととして、非同期通信などのような副作用となるものはMiddlewareやActionCreatorでハンドリングし、純粋関数であるReducerに副作用が侵入しないようにします。

redux-dataflow.jpeg

Storeの実装

今回の"キモ"はこのRxStoreです。
RxSwiftの良さを活かして、新しく発行されたStateの更新通知を受け取るためにstateDriverを公開します。
また、最新のStateを取得するためのプロパティも用意してあげます。

また、ReSwiftではActionをメインスレッドからDispatchすることを推奨しています。
もしメインスレッド以外からDispatchされた際に、他のActionがReducerまたはMiddlewareを処理中の場合、FatalErrorを発生させる排他制御の機能が備わっているためです。

RxStore.swift
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つだけ存在するものとしてシングルトンで宣言します。

ApplicationStore.swift
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を発行できる
ApplicationState.swift
public struct ApplicationState: ReSwift.StateType {
    public internal(set) var favoriteState = FavoriteState() // ReadOnly
}
FavoriteState.swift
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はenumstructで作成できますが、個人的にはenumの方がReducerでの抜け漏れが防げる & 分かりやすい点が好きなので、今回はenumで作成します。
また、どのStateのActionか分かりやすくするために、StateのextensionとしてActionを作成します。

FavoriteActions.swift
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のような役割です。

FavoriteActionDispatcher.swift
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は純粋関数として機能する(同じ引数を渡したら、常に同じ結果になる)ことが期待されているため、副作用は含めないようにします。
また、構造的に副作用の侵入を防ぐようにすることは難しいため、コードレビューなどで掬い取る必要があります。

AppReducer.swift
// 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
}
FavoriteStateReducer.swift
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)の後に実装することで実現できます。

loggingMiddleware.swift
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通信処理を中断させることができます。

FavoriteViewController.swift
final class FavoriteViewController: UIViewController {
    private let disposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()

        FavoriteActionDispatcher.getFavoriteListRequest(disposeBag: disposeBag)
    }
}

ViewControllerでState発行の通知を受け取る

FavoriteViewController.swift
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アプリに関わらず、今後も色々と発信していく所存です。
長くなってしまいましたが、最後までお読みいただきありがとうございました。

参考

5
7
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
5
7