LoginSignup
109
68

More than 3 years have passed since last update.

ReactorKit(Flux + Reactive Programming)を学ぶ1 入門編

Last updated at Posted at 2017-06-09

6aa05998-26da-11e7-9b85-e48bec938a6e.png

ReactorKitを学ぶシリーズ

  1. ReactorKit(Flux + Reactive Programming)を学ぶ1 入門編 ← 今ここ
  2. ReactorKit(Flux + Reactive Programming)を学ぶ2 基礎編
  3. ReactorKit(Flux + Reactive Programming)を学ぶ3 実践編

更新履歴

2018-03-28

2017-06-10

  • 初稿

この記事で学べること

※ この記事は、ReactorKit v1.1.0を元に書いています。

ReactorKitとは

ReactorKitは、リアクティブで単方向ストリームのアーキテクチャを構築するためのフレームワークです。

アーキテクチャ名は、The Reactive Architectureです。ReactorKitで構築できるアーキテクチャがThe Reactive Architectureということなのですが、便宜上、ReactorKit == The Reactive Architectureということで説明します。

2017/1/7に公開された新しめのアーキテクチャ & フレームワークです。詳しくは後述しますが、FluxReactive Programming(RxSwift)を組み合わせて構築するアーキテクチャで、新しい概念や技術で1から作られたものではありませんので、現段階でも実用に耐えられる作りとなっています。実例として現在App Storeで公開されているの Dribbbleアプリは、ReactorKitを使用しており、実装はGitHubに公開されています。

ReactorKitは、2018/3/28の時点でGitHubのStarは951です。
開発者の方は、@devxoulさんで、様々なOSSを開発されている方です。

何を解決したいのか

個人的にですが、各アーキテクチャには次のような問題点を感じています。

詳しくは、iOSアプリのアーキテクチャについて考えるをご覧ください。

ReactorKitは、これらの問題を 良いバランスで解決 できそうだと感じています。

一つだけ強調しておきたいことは、この記事は 各アーキテクチャの優劣について話しているわけではありません。 どのアーキテクチャを選択するかは、各プロジェクトの要件や規模によりますし、実装者の好みもあると思います。結局は何か処理してそれを画面に表示するということは変わらず、それをどういう枠組みで整理するかということですしね。

前提知識

ReactorKitは、RxSwiftに依存しています。単方向ストリームの構築部分もそうなのですが、使う側が実装するロジック部分もRxSwiftが必須になっています。

RxSwiftはとても学習コストが高いのですが、学ぶ価値は大いにあります。
RxSwiftについては次の資料が参考になると思います。

ReactorKitの特徴

ReactorKitの特徴は、ReactorKitがアーキテクチャの枠組みを用意してくれるので、ReactorKitのルールを守り実装を行えばアーキテクチャを導入できるという点です。アーキテクチャの枠組みが用意され、何をどこに実装するかが明確なのでアーキテクチャを正しく構築するための実装上の迷いが軽減されます。

メインの実装は、ReactorプロトコルViewプロトコルの2つで、非常に軽量なフレームワークです。

v0.6.0で、StoryboardからUIViewControllerを生成しているView用に使用するStoryboardViewプロトコルが追加されました。

ReactorKitの実装

ReactorKitは、FluxReactive Programming(RxSwift)を組み合わせた、アクティブで単方向のストリームを実現しています。

a91c1688-2321-11e7-8f04-bf91031a09dd.png

Actionとは

  • Actionはユーザの操作を表します。
  • ViewがActionを発行します。
Actionの例
enum Action {
  case refresh // 更新
  case toggleEditing // 編集モードを切り替える
  case toggleTaskDone(IndexPath) // タスクの完了を切り替える
  case deleteTask(IndexPath) // タスクを削除する
  case moveTask(IndexPath, IndexPath) // タスクを移動する
}

Stateとは

  • StateはViewの現在の状態を表し、具体的な値を保持します。
  • ViewはStateの状態からUIを更新します。
Stateの例
struct State {
  var isEditing: Bool // 編集モードか
  var sections: [TaskListSection] // タスク(モデルデータ)の配列
}

Reactorとは

  • Reactorは、Viewの状態を管理するUIに依存しないレイヤー(層)です。そのためReactorではUIKitをimportしてはいけません
  • 最も重要な役割は、Viewの制御フローを分離することで、すべてのViewには対応するReactorがあり、すべてのビジネスロジックをReactorに移譲します。
  • ReactorはUIに依存しないため簡単にテスト可能です。
  • Reactorを実装するためのReactorプロトコルが定義されています。

Viewとは

  • Viewはデータを表示するところです。
  • テーブルビューを例にすると、UITableViewControllerやUITableViewCellはViewにあたります。
  • Viewはユーザのインプット(テキスト入力やボタンをタップしたなど)でAction発行し、Reactorの単方向ストリームにバインドします。
  • ViewはStateとUIコンポーネントをバインドし、UIを更新します。
  • Viewを実装するためのViewプロトコルが定義されています。

単方向ストリームとは

  • 単方向ストリームとは、View → Action → State → Viewの流れを指します。
    1. ViewはユーザのインプットをきっかけにActionを発行する
    2. ReactorはActionを受けてStateを更新する
    3. ViewはStateとUIをバインドし、Stateの変化に応じてUIを更新する

Reactorの処理

Reactorは、Viewから発行されたActionから最終的にはStateを更新するのですが、その中間の状態としてMutationがあります。

2de21a28-23e2-11e7-8a41-d33d199dd951.png

Mutationとは

  • MutationはStateの更新内容を表します。
  • MutationはReactor内部でのみ使用されます。
Mutationの例
enum Mutation {
  case toggleEditing // 編集モードを切り替える
  case setSections([TaskListSection]) // タスクをセットする
  case insertSectionItem(IndexPath, TaskListSection.Item) // タスクを挿入する
  case updateSectionItem(IndexPath, TaskListSection.Item) // タスクを更新する
  case deleteSectionItem(IndexPath) // タスクを削除する
  case moveSectionItem(IndexPath, IndexPath) // タスクを移動する
}

Reactor内のデータの流れ

  • Reactor内では、Action → Mutation → Stateとデータの変更を行います。
  • Reactorには各状態を変更するメソッドが用意されています。
    • ActionをMutationに変更するmutate(action:)メソッド
    • MutationからStateを生成するreduce(state:mutation:)メソッド

Serviceとは

ReactorKitにはビジネスロジックを行うためのServiceレイヤーが設けられています。Serviceレイヤーは必須ではないのと、今回はまずはシンプルなReactorKitの実装解説を目指しているので、Serviceレイヤーについてはまたの機会に解説を行います。

ReactorKitの実装

ReactorKitの実装は、ReactorプロトコルとViewプロトコルに準拠し、Stateストリームを構築することです。

Reactorプロトコルへの準拠

Reactorプロトコルは次の通りです。

  • 新たにStateストリームという用語が出ていますが、前述の単方向ストリーム == Stateストリームになります。各コメントは私の意訳+補足を追加しています。気になる方は原文をご覧ください。
Reactorプロトコル
public typealias _Reactor = Reactor
public protocol Reactor: class, AssociatedObjectStore {
  associatedtype Action
  associatedtype Mutation = Action
  associatedtype State

  /// Viewからのアクション。ユーザが発行するActionをこのSubjectにバインドします。
  /// ActionSubjectはReactorKitが実装しているSubjectで`.next`のみ発行します。
  var action: ActionSubject<Action> { get }

  /// Stateの初期値です。
  var initialState: State { get }

  /// 現在のStateです。ストリームによって変更されます。
  var currentState: State { get }

  /// Stateストリームです。このObservableでStateの変更をObserveできます。
  var state: Observable<State> { get }

  /// Actionのグローバルな変更を行うことができます。このメソッドは他のObservablesとの結合に使用できます。
  /// このメソッドはActionが発行された直後、`mutate(action:)`の直前に呼び出されます。
  func transform(action: Observable<Action>) -> Observable<Action>

  /// ActionからMutationを確定させます。ここでは非同期タスクなど副作用を伴う処理を実行するのに適しています。
  /// このメソッドはActionが発行されるたびに呼び出されます。
  func mutate(action: Action) -> Observable<Mutation>

  /// Mutationのグローバルな変更を行うことができます。このメソッドは他のObservablesとの変換または結合に使用できます。
  /// このメソッドは`mutate(action:)`の直後、`reduce(state:)`の直前に呼び出されます。
  func transform(mutation: Observable<Mutation>) -> Observable<Mutation>

  /// 前回のStateとMutationから新しいStateを生成します。ここでは副作用を起こすべきではありません。
  /// このメソッドはMutationが発行されるたびに呼び出されます。
  func reduce(state: State, mutation: Mutation) -> State

  /// Stateのグローバルな変更を行うことができます。このメソッドでロギングなどの副作用を実行することもできます。
  /// このメソッドは`reduce(state:mutation:)`の直後、新しいStateがcurrentStateに適用される直前に呼び出されます。
  func transform(state: Observable<State>) -> Observable<State>
}

最低限実装すべきものは次の通りです。

定義 説明 最適な型
Action Viewが行うアクション 列挙型
Mutation Actionに対する変更内容 列挙型
State Viewの状態(具体的な値を保持) 構造体
initialState Stateの初期値 State型
メソッド 説明 ルール
mutate(action:) ActionからMutationを生成する 非同期処理などの副作用を伴う処理が可能
reduce(state:) MutationからStateを生成する 同期処理のみを行い副作用を伴う処理は行わない
  • transform系は少し特殊な処理を受け持ち、複数のReactor間に影響する処理の対応に使用します。これは今回の例では使わないのでまたの機会に解説します。

Viewプロトコルへの準拠

Viewプロトコルは次の通りです。

Viewプロトコル
public typealias _View = View
public protocol View: class, AssociatedObjectStore {
  associatedtype Reactor: _Reactor

  /// RxSwiftでおなじみのDisposeBagです。
  /// 注意点として`reactor`が割り当てられるたびに新しく生成されます。
  var disposeBag: DisposeBag { get set }

  /// Viewが使用するReactorです。
  /// このプロパティに新しいReactorが割り当てられると、`bind(reactor:)`が呼び出されます。
  var reactor: Reactor? { get set }

  /// ViewとReactor間の各バインディングを記述するところです。
  /// `reactor`プロパティが割り当てられると呼び出されます。
  /// 注意点として、このメソッドは内部で自動で呼び出されるもので、直接呼び出してはいけません。
  func bind(reactor: Reactor)
}
  • disposeBagを定義し、ViewとReactor間の各バインディングをbind(reactor:)に実装します。
  • bind(reactor:)は新しいReactorが割り当てられると内部で呼び出されるため、直接呼び出してはいけません

StoryboardViewプロトコルについて

StoryboardViewプロトコル
public protocol StoryboardView: View, _ObjCStoryboardView {
}
  • StoryboardViewプロトコルはViewプロトコルを継承しているだけで、追加の実装はありません。
  • Viewプロトコルとの違いは内部で呼び出されている bind(reactor:) の呼び出しタイミングです。 bind(reactor:) は、reactorがセットされたタイミングで内部的に呼び出されるのですが、StoryboardからUIViewControllerを生成すると、initの段階ではStoryboard上で追加してあるsubViewはまだ生成されていないため、もしも bind(reactor:) でそれらのsubViewにアクセスするとランタイムエラーが発生します。そのためViewプロトコルしかないときは、subViewの生成タイミングを考慮してreactorのセットを遅らせたりする必要があり非常に煩雑だったのですが、StorybarodViewプロトコルでは bind(reactor:) の呼び出しタイミングが ViewDidLoad() 以降のタイミングになるように調整され、reactorのセットタイミングの問題が解消されています。

サンプルアプリCounterを解説

実装はサンプルを見るのが一番わかりやすいので、ReactorKitのExamplesにあるCounterを例に各コードを解説していきます。

30179038-0ba51cdc-93d9-11e7-95e4-9fb3000c6c3f.png

ReactorKit/Examples/Counter/README.md

中央にカウントを表示し、 - で-1、 + で+1とカウントを増減することができるアプリです。カウントの更新中を表すUIActivityIndicatorViewもカウントの下にあります。

CounterViewController
// Viewプロトコルに準拠すると`self.reactor`プロパティが利用可能になります。
// CounterViewControllerはStoryboardから生成されますのでStoryboardViewプロトコルに準拠しています。
final class CounterViewController: UIViewController, StoryboardView {
  @IBOutlet var decreaseButton: UIButton!
  @IBOutlet var increaseButton: UIButton!
  @IBOutlet var valueLabel: UILabel!
  @IBOutlet var activityIndicatorView: UIActivityIndicatorView!
  var disposeBag = DisposeBag()

  func bind(reactor: CounterViewReactor) {
    // Action
    increaseButton.rx.tap               // タップイベント
      .map { Reactor.Action.increase }  // タップイベントをAction.increaseに変換
      .bind(to: reactor.action)         // Actionをreactor.actionにバインド
      .disposed(by: disposeBag)

    decreaseButton.rx.tap               // タップイベント
      .map { Reactor.Action.decrease }  // タップイベントをAction.decreaseに変換
      .bind(to: reactor.action)         // Actionをreactor.actionにバインド
      .disposed(by: disposeBag)

    // State
    reactor.state.map { $0.value }      // カウントの値
      .distinctUntilChanged()           // カウントの値に変化があるか
      .map { "\($0)" }                  // カウントの値を文字列に変換
      .bind(to: valueLabel.rx.text)     // カウントの値の文字列をUILabelのtextにバインド
      .disposed(by: disposeBag)

    reactor.state.map { $0.isLoading }  // ロード中か
      .distinctUntilChanged()           // 値に変化があるか
      .bind(to: activityIndicatorView.rx.isAnimating) // ロード中かをUIActivityIndicatorViewのアニメーションにバインド
      .disposed(by: disposeBag)
  }
}
CounterViewReactor
final class CounterViewReactor: Reactor {
  // Viewが行うアクション
  enum Action {
    case increase
    case decrease
  }

  // Actionに対する変更内容
  enum Mutation {
    case increaseValue
    case decreaseValue
    case setLoading(Bool)
  }

  // Viewの状態(具体的な値を保持)
  struct State {
    var value: Int
    var isLoading: Bool
  }

  let initialState: State

  init() {
    self.initialState = State(
      value: 0, // カウントする値の初期値は0
      isLoading: false
    )
  }

  // ActionからMutationを生成する
  func mutate(action: Action) -> Observable<Mutation> {
    switch action {
    case .increase:
      return Observable.concat([
        Observable.just(Mutation.setLoading(true)),
        Observable.just(Mutation.increaseValue).delay(0.5, scheduler: MainScheduler.instance),
        Observable.just(Mutation.setLoading(false)),
      ])

    case .decrease:
      return Observable.concat([
        Observable.just(Mutation.setLoading(true)),
        Observable.just(Mutation.decreaseValue).delay(0.5, scheduler: MainScheduler.instance),
        Observable.just(Mutation.setLoading(false)),
      ])
    }
  }  

  // MutationからStateを更新する
  func reduce(state: State, mutation: Mutation) -> State {
    var state = state
    switch mutation {
    case .increaseValue:
      state.value += 1

    case .decreaseValue:
      state.value -= 1

    case let .setLoading(isLoading):
      state.isLoading = isLoading
    }
    return state
  }
}

実装の流れ

  • 必ずしもこの流れで実装するわけではないのですが、Counterを例に実装の流れを解説します。

1. Stateの定義

  • Viewの状態(具体的な値を保持)を表すためのStateを定義します。カウントするための値を保持する value: Int とロード中を表す var isLoading: Bool を定義します。
State
struct State {
  var value: Int
  var isLoading: Bool
}

2. Viewが行いたいAction(ロジック)を定義

  • Viewから行いたいことはカウントの増減です。
Action
enum Action {
  case increase // 値の増加
  case decrease // 値の減少
}

3. Actionに対するMutation(変更内容)の定義

Actionから具体的な変更内容を定義します。

※ このサンプルの要件的には、Mutationの定義は省略することができます。詳しくは後述のCounterをリファクタリングを参照してください。 (※ v1.1.0のサンプルではMutationの内容が変更され省略不可になりました)

Mutation
enum Mutation {
  case increaseValue    // 値の増加
  case decreaseValue    // 値の減少
  case setLoading(Bool) // ロード中か
}

4. ActionからMutationを生成するロジックを実装

発行されたActionをMutationに変更します。ポイントは複数の Observable.concat() を使って1つのActionから複数のMutationを生成していることです。Mutation.increaseValueはdelayを使って仮想的に非同期更新しているようなイメージを持っていただくと以下の流れがわかると思います。

  1. Mutation.setLoading(true)で値の更新前にロード状態をtrueにする
  2. Mutation.increaseValueで値を更新する
  3. Mutation.setLoading(false)で値の更新が終わったにロード状態をfalseにする

[補足] Observable.concat() は複数の Observable を完了するまで待って順次 subscribe します。

mutate
// ActionからMutationを生成する
func mutate(action: Action) -> Observable<Mutation> {
  switch action {
  case .increase:
    return Observable.concat([
      Observable.just(Mutation.setLoading(true)),
      Observable.just(Mutation.increaseValue).delay(0.5, scheduler: MainScheduler.instance),
      Observable.just(Mutation.setLoading(false)),
    ])

  case .decrease:
    return Observable.concat([
      Observable.just(Mutation.setLoading(true)),
      Observable.just(Mutation.decreaseValue).delay(0.5, scheduler: MainScheduler.instance),
      Observable.just(Mutation.setLoading(false)),
    ])
  }
}

5. MutationからStateの内容を更新するロジックを実装

発行されたMutationを元にStateのvalueを増減させます。

reduce
// MutationからStateを更新する
func reduce(state: State, mutation: Mutation) -> State {
  var state = state
  switch mutation {
  case .increaseValue:
    state.value += 1

  case .decreaseValue:
    state.value -= 1

  case let .setLoading(isLoading):
    state.isLoading = isLoading
  }
  return state
}

6. Stateの初期値を定義

中央に表示するカウントの初期値を定義します。

let initialState: State

init() {
  self.initialState = State(
    value: 0, // カウントする値の初期値は0
    isLoading: false
  )
}

7. ViewでActionの発行を実装

increaseButton/decreaseButtonのタップイベントをそれぞれのActionに変換してreactor.actionにバインドします。

bind(reactor
increaseButton.rx.tap               // タップイベント
  .map { Reactor.Action.increase }  // タップイベントをAction.increaseに変換
  .bind(to: reactor.action)         // Actionをreactor.actionにバインド
  .disposed(by: disposeBag)

decreaseButton.rx.tap               // タップイベント
  .map { Reactor.Action.decrease }  // タップイベントをAction.decreaseに変換
  .bind(to: reactor.action)         // Actionをreactor.actionにバインド
  .disposed(by: disposeBag) 

8. Stateの変更から具体的な各Viewを更新

  • reactor.state.valueをvalueLabel.rx.textにバインドして、中央の値を表示を更新する。
  • reactor.state.isLoadingをactivityIndicatorView.rx.isAnimatingにバインドして、ロード中がわかるようにUIActivityIndicatorViewを更新する。
bind(reactor
reactor.state.map { $0.value }     // カウントの値
  .distinctUntilChanged()          // カウントの値に変化があるか
  .map { "\($0)" }                 // カウントの値を文字列に変換
  .bind(to: valueLabel.rx.text)    // カウントの値の文字列をUILabelのtextにバインド
  .disposed(by: disposeBag)

reactor.state.map { $0.isLoading } // ロード中か
  .distinctUntilChanged()          // 値に変化があるか
  .bind(to: activityIndicatorView.rx.isAnimating) // ロード中かをUIActivityIndicatorViewのアニメーションにバインド
  .disposed(by: disposeBag)

Counterをリファクタリング (v1.1.0のサンプルでは不可)

[注意] v1.1.0のサンプルではActionとMutationが同定義ではないため以下のリファクタリングはできなくなりました。以下はv0.4.5の時に有効だった内容です。

Counterの実装を見てMutationって意味ないんじゃないのかなって思った方もいると思います。
実はその通りで、このサンプルの機能的にはMutationは必要ないです。おそらくこのサンプルの目的が、シンプルな例でReactorKitの実装を紹介するということなので意図して設けられているだけだと思われます。

Mutationは、

associatedtype Mutation = Action

となっているので、Mutationで特に行うべきロジックがない場合はActionのみを定義することが許容されています(Mutationを定義しなければActionと同じ定義になります)。

CounterからMutationを取り除くと、次のようにリファクタリングできます。

CounterViewReactor
final class CounterViewReactor: Reactor {
  // Viewが行うアクション
  enum Action {
    case increase
    case decrease
  }

  // Viewの状態(具体的な値を保持)
  struct State {
    var value: Int
  }

  let initialState = State(value: 0) // カウントする値の初期値は0

  // Mutation(Actionと同定義)からStateを更新する
  func reduce(state: State, mutation: Mutation) -> State {
    switch mutation {
    case .increase:
      return State(value: state.value + 1)
    case .decrease:
      return State(value: state.value - 1)
    }
  }
}

まとめ

まずはReactorKitの概要ということでシンプルなCounterを例に取り上げました。Viewに必要な数値のカウントアップ/ダウン機能(ビジネスロジック)と現在のカウント数の保持をViewから切り離し、それらをReactorに移譲することができました。正直この規模だとまだメリットは感じられないと思いますが、アプリがより複雑化したときにReactorKitが活きてきます。

個人的には今一番好きなアーキテクチャがReactorKitです。

  • アーキテクチャを構築するためのフレームワークなので、正しいアーキテクチャを構築するための学習コストが抑えられる
  • 単方向ストリームがすでに構築されているので、MVVMでいうとViewとViewModelとの双方向バインディングの複雑さを軽減できる
  • 各責務が明瞭
  • 他人のコードを読んでも把握が楽
  • 最少は1つ以上のViewに対して1つのReactorを設けるだけなので、一部分だけの導入も可能。1つのアプリが単一のアーキテクチャに縛られる必要はないです😉

何よりも設計思想が好きですね。アーキテクチャの学習/導入/運用コストを大きく上回るだけのメリットを感じています。

次回はさらにReactorKitを掘り下げて見たいと思います。
ReactorKit(Flux + Reactive Programming)を学ぶ2 基礎編


※ 本文中の画像はReactorKitから引用

109
68
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
109
68