208
134

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.

FOLIOAdvent Calendar 2017

Day 5

Redux+Rxを活用したiOSアプリアーキテクチャ

Last updated at Posted at 2017-12-05

この記事はFOLIO Advent Calendar5日目の記事です。

前日は @odakyoka さんの1ヶ月で1万人のフォロワーを増やしたデザインマーケティング戦略議事録でした。

株式会社FOLIOというテーマ投資型オンライン証券サービスを提供している会社で、iOSエンジニアをしています、 @susieyy です。

絶賛開発中のアプリのアーキテクチャにReduxを採用しました。iOSアプリでどのようにReduxを活用しているのかをご紹介いたします。

Reduxとは

iOSエンジニアには、Reduxに馴染みのない方も多いのではと思うので、まずはReduxの解説から。

Reduxは3つの原則(Redux Three Principles)を遵守するよう設計されています。

  • Single source of truth
  • State is read-only
  • Mutations are written as pure functions

5.jpeg

アプリ全体で1つの状態を保持するのは、iOSだとデータをすべてSingletonに保持するイメージです。画面毎にデータのスコープを区切ることが多いので、あまりないのではないでしょうか。

6.jpeg

状態はイミュータブル(不変)とします。Swiftのletstructとも相性がよさそうです。

7.jpeg

既存の状態を変更せずに、既存状態と、新しい状態への指示及びインプットデータ(Action)を用いて、変更ロジック(Reducer)を経て新しい状態を作成します。よって、アプリが参照する状態(インスタンス)はイミュータブルですが、Action⇒Reducerを実行するして、新しい状態(インスタンス)を参照することで、アプリからの状態参照においては非破壊・変更不可を実現しています。

単方向のデータフロー / Unidirectional Data Flow

9.jpeg

上述の3原則を踏まえ、処理の流れ(状態の変化の作用)は上記図のように、必ず1方向に定まるように準拠します。

MVC、MVVM、やレイヤードアーキテクチャ(e.g. クリーンアーキテクチャ)では、各レイヤー間のやりとりは双方向となりますが、Reduxでは単方向に制限することで、処理の流れ(プログラムの記述)をシンプルにし、作用を把握しやすく、動作時の内部挙動をわかりやすくします。また、この単一の処理の流れは、マルチスレッドなアプリにおいても、処理を単一スレッドで逐次実行とすることで、マルチスレッドにおける複雑性も排除しています。

POINTをまとめると以下のようになります

予測可能な形でコードを宣言的に構造化すること

  • 予測可能
    • 状態を一元的に管理
    • 状態変化はシーケンシャル
    • 副作用との分離
  • 宣言的
    • 状態変化の起因が明示的 ( ActionをDispatch )
    • 状態変化は純粋関数

Reduxをなにで実装するか

iOS開発でReduxを活用にあたり、先駆者の方が実装くださったいくつかのオープンソースライブラリが既に存在します。

  • 本家Reduxのステップ数は318と非常に少ない
  • 実装を参考に自分でポーティングすることも可能な分量
  • ライブラリもいくつか実装されているので検討してみる

Redux系ライブラリ

Flux系ライブラリ

検討結果

独自実装も可能そうな規模ですが、今回は以下の観点からReSwiftを採用しました

  • ReSwiftが一番有名
  • 関数やクロージャーを中心に設計されており、実装がとても綺麗
  • リリース頻度もほどよく、ISSUEも活発

ReSwiftとは

ReSwiftの実装と利用例を以下の観点で見てみます。

  • State
  • Action
  • Reducer
  • Store
  • Dispatch
  • ActionCreator

State

状態(データ)を表現するStateです。

  • Structの木構造
  • StructReSwift.StateTypeプロトコルに準拠する
  • 慣例的に一番上層の状態を表すStructAppStateとする

17.jpeg

ReSwiftの実装

ReSwift.StateTypeプロトコルは準拠すべき制約は何もありません。ReSwiftではReSwift.StateTypeに準拠したインスタンスをStateとして扱うための目印として利用します。

protocol StateType { }

e.g. 利用例

シンプルなTwitterアプリを想定しています。以下ようにStructが木構造になっています。慣例的にAppState配下のStateはアプリの各画面毎のStateを担うことが多いです。ReSwift.StateTypeに準拠しない状態(データ)も保持できます。ただし、後述するReducerはReSwift.StateType単位で処理を行うので、どの粒度の状態までReSwift.StateTypeに準拠するか判断が必要です。

struct AppState: ReSwift.StateType {
    var timelineState = TimelineState()
    var userProfileState = UserProfileState()
}

struct TimelineState: ReSwift.StateType {
  var tweets: [Tweet]
  var response: [Tweet]
}

struct UserProfileState: ReSwift.StateType {
  var profile: [Profile]
  var profileImage: URL
  var response: [Tweet]
}

Action

状態(データ)の変更を指示するために表現するActionです。

  • ReSwift.Actionプロトコルに準拠していれば、Structでも、Enumでもよい
  • 処理を有しないただのデータ
  • Reducerでどういう処理をしたいかの種類とそのインプットデータになる

21.jpeg

ReSwiftの実装

Stateと同様に、ReSwift.Actionプロトコルは準拠すべき制約は何もありません。ReSwiftではReSwift.Actioneに準拠したインスタンスをAactionとして扱うための目印として利用します。

protocol Action { }

e.g. 利用例

ここでは、ActionをStructではなく、Enumで表現しました。ReSwiftではActionの種別を、Structの場合はその型の違いにより、Enumの場合は、Enumのその型とcaseにより、区別します。区別とはどの指示かを表します。指示に付随するインプットデータはStructではプロパティが、EnumではAssociated Valueが担います。

extension TimelineState {
  enum Action: ReSwift.Action {
    case requestSuccess(response: [Tweet])
    case requestState(fetching: Bool)
    case requestError(error: Error)
  }
}

Reducer

状態(データ)の変更を指示するためのActionを受けて、Stateを変更するReducerです。Actionの指示の種類(型)により、実行されるReducerが決定します。木構造のStateの各State毎にReducerが紐づくようになります。

Reducerは、現在のStateと、変更指示であるActionを引数にとり、新しいStateを返す関数です。またReducerは純粋関数であることが求められるます。純粋関数とは、関数の処理中に副作用なく任意の引数に対して、常に一意な値を返す関数のことをいいます。

ActionはすべてのRedercerに渡されます(Broadcast)。各々のReducerは渡されたActionは自身が処理すべきActionなのかを判断(型マッチ)の上、必要であれば、Actionの指示に従い紐いたStateを変更します。

  • Reduxでは状態変更できるのはReducerだけという制約
  • Reducer(state, action) => state を満たす状態を持たないふつうの関数(純粋関数)でなければならない

25.jpeg

ReSwiftの実装

typealiasでクロージャーを定義しているだけなところが美しいですね。

typealias Reducer<ReducerStateType> =
    (action: Action, state: ReducerStateType?) -> ReducerStateType

e.g. 利用例

シンプルなTwitterアプリのタイムライン画面用のStateにおけるRedcuerです。

  • もし自身に紐づくStateがまだインスタンスが作成されていない場合は生成
  • Actionが自身のReducerで処理すべきかEnumの型を判断
  • 処理すべきActionの場合はEnumのパターンマッチを活用して、漏れなく処理を記述
  • 各Enumのケース毎にStateを変更するロジックを記述
  • 最後に変更後のStateを返す
extension TimelineState {
    public static func reducer(action: ReSwift.Action, state: TimelineState?) -> TimelineState {
        var state = state ?? TimelineState()

        guard let action = action as? TimelineState.Action else { return state }
        switch action {
        case let .requestState(fetching):
            state.fetching = fetching
            state.error = nil
        case let .requestSuccess(response):
            state.fetching = false
            state.response = response
            state.dataSourceElements = DataSourceElements(response.map({ DiffableWrap($0) }))
        case let .requestError(error):
            state.fetching = false
            state.error = error
        }
        return state
    }
}

Reducer / Initialization

  • 慣例的に一番上層の状態を表すStructappReduceとする
  • appReduceを起点に、下位のReducerActionをブロードキャストする
func appReduce(action: ReSwift.Action, state: AppState?) -> AppState {
    var state = state ?? AppState()
    state.timelineState = TimelineState.reducer(
                                        action: action,
                                        state: state.timelineState)

    state.userProfile = UserProfileState.reducer(
                                        action: action,
                                        state: state.userProfile)
    return state
}

var appStore = ReSwift.Store<AppState>(
                              reducer: appReduce,
                              state: nil,
                              middleware: [])

Store

状態(データ)を保持するのStoreです。基本的にアプリ内で1つのStoreとなります。Storeはシングルトンでインスタンもアプリ内で一意です。

Reducerが新しい状態を作成したら、既存の状態と新しい状態を入れ替えます。入れ替えによりイミュータブルである状態は、既存の状態を非破壊で扱えるようになります。

また、ActionをReducerに伝えるための、Dispatch、Stateの状態が新しく作られたことをViewに伝えるSubscribeの機能も有しています。

  • StateReducerを保持するシングルトン
  • Actionのディスパッチメソッドを有する
  • Stateのサブスクライブメソッドを有する

31.jpeg

ReSwiftの実装

open class Store<State: ReSwift.StateType>: ReSwift.StoreType {
    var state: State! { get }
    private var reducer: Reducer<State>

    open func dispatch(_ action: Action) {
        ...
    }

    open func subscribe<S: StoreSubscriber>(_ subscriber: S) {
        ...
    }

    open func unsubscribe(_ subscriber: AnyStoreSubscriber) {
        ...
    }
    ...
}

Dispatch

ViewからActionをReducerに伝えたい場合に、StoreのDispatchメソッドを利用します。

  • View側でActionのインスタンスを作成し、StoreDispatchメソッドをコールする

34.jpeg

e.g. 利用例

Actionのインスタンを生成し、シングルトンであるappStoreのdispatchメソッドにアクションのインスタンを渡します。

appStoreはシングルトンですが、必要であればDIなどで、参照をVCにインジェクションするようにして参照を局所化してもよいでしょう。dispatch可能なViewが限定されます。

let action = TimelineState.Action.requestState(fetching: true)
appStore.dispatch(action)

ActionCreator

Dispatch可能な関数でActionを返します。

  • ActionCreator関数内ではStateにアクセス可能で、Stateを加工してActionを作成する用途に利用できる
  • ActionCreator関数内ではStoreにアクセス可能で、Dispatchメソッドをコールすることもできる

37.jpeg

ReSwift Rx活用編

ReSwiftをRxSwiftと共に活用することで、ReSwiftの課題点を解決できます。以下2点の観点から、どのような課題を、どのようにアプローチしているのか見てみましょう。

  • 副作用(非同期通信)
  • ViewDataBinding

副作用(非同期通信)/ Asynchronous Operations

ReSwiftのREADMEによる非同期通信の例です。ActionCreator内で副作用(非同期通信)を行っています。以下のような課題点があるように感じます。

  • ActionCreator関数が純粋関数ではなくなる
  • 関数内に副作用(非同期通信)があるとテストがしにくい
  • 美しくない(個人の感想です)
  • とはいえ、Reducerは純粋関数で副作用を許容しないので、Reducerにも記述できない
func fetchGitHubRepositories(state: State, store: Store<State>) -> Action? {
    guard case let .LoggedIn(configuration) = state.authenticationState.loggedInState  else { return nil }

    Octokit(configuration).repositories { response in
        dispatch_async(dispatch_get_main_queue()) {
            store.dispatch(SetRepostories(repositories: response))
        }
    }
    return nil
}

Redux(JS)では副作用(非同期処理)をどのように扱っているか

Redux(JS)ではMiddlewareで副作用(非同期処理)を扱います。下の図のActionがDispatchされてから、Reducerで処理が実施されるまでの間に、MiddlewareというActionに作用するロジック層を設けることができます。

44.jpeg

ReSwiftのMiddleware

ReSwiftでは、Middlewareは以下のように定義されます。ちょっと複雑なtypealiasですね。

public typealias DispatchFunction = (Action) -> Void
public typealias Middleware<State> = (
                                      @escaping DispatchFunction,
                                      @escaping () -> State?
          ) -> (@escaping DispatchFunction) -> DispatchFunction

e.g. 利用例

一番シンプルな利用例である、DispatchされたActionをログに出力する例です。基本的に現在のStateとDispatchされたActionが引数として渡されて、その値を活用しつつ、必要であれば、Actionを変更したり、ログ出力などの副作用を行います。Middlewareも関すですが、純粋関数でなくてもよいです。

let loggingMiddleware: ReSwift.Middleware<AppState> = { dispatch, getState in
    return { next in
        return { action in
            logger.info("🔥 [Action] \(action)")
            return next(action)
        }
    }

var appStore = Store<AppState>(
      reducer: appReduce,
      state: nil,
      middleware: [loggingMiddleware]
    )

Redux(JS)の非同期用Middlewareたち

Redux(JS)で副作用(非同期処理)を扱うためのライブラリがいくつかありますが、以下が有名でしょうか。

  • redux-thunk
  • redux-sage
  • redux-promise

Redux thunkのソースコード(全量)

ここでは、redux-thunkについてのソースコードを見てみます。以下がライブラリのソースコード全量ですが、大変コンパクトです。行っていることはActionはデータ型だけではなくて関数型も許容し、Actionが関数型の場合は、関数を実行した上、その戻り値はActionとして次のMiddlewareに渡しています。

ここで実行する関数を副作用(非同期通信)を許容する(純粋関数ではない)ことで、ActionCreatorとReducerは純粋関数ですが、副作用(非同期通信)の処理をMiddlewareにのみ局所化することができます。

function createThunkMiddleware(extraArgument) {
  return ({ dispatch, getState }) => next => action => {
    if (typeof action === 'function') {
      return action(dispatch, getState, extraArgument);
    }

    return next(action);
  };
}

const thunk = createThunkMiddleware();
thunk.withExtraArgument = createThunkMiddleware;

export default thunk;

ふむふむ🤔

RxSwift活用して非同期処理をMiddlewareに押し込む

redux-thunkを参考に、SingleActionというRxSwiftのSingleを保持するActionを定義し、その型をMiddlewareで検知した場合は、subscribeすることでRxSwiftを活用した非同期処理をMiddlewareで行うことを実現しています。

struct SingleAction: ReSwift.Action {
    public let single: Single<ReSwift.Action>
    public let disposeBag: DisposeBag
}

let rxThunkMiddleware: ReSwift.Middleware<AppState> = { dispatch, getState in
    return { next in
        return { action in
            if let action = action as? SingleAction {
                action.single
                    .observeOn(MainScheduler.instance)
                    .subscribe(onSuccess: { next($0) })
                    .disposed(by: action.disposeBag)
            } else {
                return next(action)
            }
        }
    }
}

e.g. 利用例

ActionCreatorでは、RxSwiftを利用して通信を宣言的に記述します。ポイントは、mapとcatchErrorで通信結果を成功時も、失敗時も共にActionに変換しています。これにより、Middlewareでsubscribeされた通信結果はActionとなり、Reducerへと伝えられます。

func requstAsyncCreator(maxID: String) -> Store<AppState>.ActionCreator {
    return { (state: AppState, store: Store<AppState>) in
        if state.timelineState.fetching { return nil }

        let single = TwitterManager.shared.timeline(maxID: maxID)
                    .map {
                        return Timeline.Action.requestSuccess(response: $0)
                    }
                    .catchError {
                        let action = Timeline.Action.requestError(error: $0)
                        return Single<ReSwift.Action>.just(action)
                    }
        return SingleAction(single: single, disposeBag: state.timelineState.requestDisposeBag)
    }
}

Testability

Reduxの世界はイミュータブル、純粋関数、副作用のみで構築されています。テストがしにくい副作用部分を局所化すること、関数は純粋関数であること、データはイミュータブルであることでテストを容易にしています。

  • イミュータブルは不変
    • Action(インプットパラメーター)、State(データ)
  • 純粋関数はテストが容易
    • 任意のインプットに対してアウトプットが一意に決まる
    • ActionCreatorReducer
  • 副作用はテストが複雑
    • Middlewareの部分はテスト時にスタブに差し替え

テスト容易性のために、ビジネスロジックには、依存性のあるコンポーネントらの参照をDI(Dependency Injection)でテスト時に差し替え可能とする方法がよく用いられますが、このアーキテクチャでは下図のようにテスト時に副作用部分であるMiddlewareのみStubに変更することで、副作用との依存性の切り離しを可能としています。またDI不要の設計となっています。💪

57.jpeg

ViewDataBinding

ReSwift#subscribe

override func viewWillAppear(animated: Bool) {
    super.viewWillAppear(animated)
    store.subscribe(self) { subcription in
        subcription.select { state in state.repositories }
    }
}

override func viewWillDisappear(animated: Bool) {
    super.viewWillDisappear(animated)
    store.unsubscribe(self)
}

func newState(state: Response<[Repository]>?) {
    if case let .Success(repositories) = state {
        dataSource?.array = repositories
        tableView.reloadData()
    }
}

ReSwift.StoreをRxSwift.Variableに変換

let rxReduxStore = RxReduxStore<AppState>(store: appStore)

public class RxReduxStore<AppStateType>: StoreSubscriber where AppStateType: StateType {
    public lazy var stateObservable: Observable<AppStateType> = {
        return self.stateVariable.asObservable().observeOn(MainScheduler.instance)
                                                .shareReplayLatestWhileConnected()
    }()
    public var state: AppStateType { return stateVariable.value }
    private let stateVariable: Variable<AppStateType>
    private let store: Store<AppStateType>

    public init(store: Store<AppStateType>) {
        self.store = store
        self.stateVariable = Variable(store.state)
        self.store.subscribe(self)
    }

    deinit {
        self.store.unsubscribe(self)
    }

    public func newState(state: AppStateType) {
        self.stateVariable.value = state
    }

    public func dispatch(_ action: Action) {
        store.dispatch(action)
    }

    public func dispatch(_ actionCreatorProvider: @escaping (AppStateType, ReSwift.Store<AppStateType>) -> Action?) {
        store.dispatch(actionCreatorProvider)
    }
}

e.g. 利用例

class TimeLineViewController: UIViewController {
    fileprivate let disposeBag = DisposeBag()
    fileprivate let rxReduxStore: RxReduxStore<AppState>

    init(_ rxReduxStore: RxReduxStore<AppState>) {
        self.rxReduxStore = rxReduxStore
        super.init(nibName: nil, bundle: nil)
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        rxReduxStore.stateObservable
            .map { $0.timelineState.dataSourceElements }
            .bind(to: adapter.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)

        rxReduxStore.stateObservable
            .map { $0.timelineState.fetching }
            .distinctUntilChanged()
            .bind(to: loadingView.rx.fetching)
            .disposed(by: disposeBag)
    }
}

Viewの差分更新

66.jpeg

ReactではVirtual DOM

68.jpeg

Viewコンポーネントで差分更新が可能

69.jpeg

iOSでは??

IGListKit

71.jpeg

72.jpeg

73.jpeg

74.jpeg

  • UICollectionViewをベースにできている
  • データはいろんな型の要素を含む1次元配列
  • データの要素の型でコンポーネントを分岐する
  • データの要素は比較的可能なためにIGListKitのプロトコルに準拠する(Equitableみたいなもの)
  • ObjCなところが悲しい😢
    • プロトコル準拠のためにNSObjectを継承が必要
final class TimeLineViewController: UIViewController {
    fileprivate let dataSource = DataSource()
    fileprivate lazy var adapter: ListAdapter = ListAdapter(updater: ListAdapterUpdater(), viewController: self)
    fileprivate let collectionView = UICollectionView(frame: .zero, collectionViewLayout: UICollectionViewFlowLayout())

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(collectionView)
        adapter.collectionView = collectionView
        adapter.rx
            .setDataSource(dataSource)
            .disposed(by: disposeBag)

        rxReduxStore.stateObservable
            .map { $0.timelineState.dataSourceElements }
            .bind(to: adapter.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)
    }
}

extension TimeLineViewController {
    fileprivate final class DataSource: AdapterDataSource {
        override func listAdapter(_ listAdapter: ListAdapter, sectionControllerFor object: Any) -> ListSectionController {
            switch object {
            case let o as DiffableWrap<Tweet>: return TweetsSectionController(o)
            case let o as DiffableWrap<[RecommendUser]>: return RecommendUsersSectionController(o)
            }
        }
    }
}

Conclusion

iOSでReduxアーキテクチャ良いよ👍

208
134
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
208
134

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?