🍎

[Swift]ReactorKitの基本概要

2022/02/23に公開

経緯

初めて触れて苦戦しながらも結構理解出来たので、
その理解までの工程の備忘録のため。
あくまで記載内容は公式の内容の意訳と自分なりの解釈ですのでご承知ください。

ReactorKitとは

アーキテクチャ名は、The Reactive Architectureで、
TCA(The Composable Architecture)と似た構造という印象。
2017年に公開されたFluxとRxSwiftを組み合わせて構築するアーキテクチャです。
とにかくRxSwiftは必須なのでそこの学習コストが高いのがネックです。
https://github.com/ReactorKit/ReactorKit

ReactorKitの基本構造

公式に掲載されているこの構造です。

1つ1つ役割を見ていきましょう。

Action

Actionはユーザ操作のイベントを設定します。
ユーザ操作についてなのでVCでActionを発行する形になります。

Acttion記載例
enum Action {
  case reload // 画面更新
  case tapButton // ボタンタップ
}

Reactor

1番ここが肝だと思います。
reactorは、viewの状態を管理するUIに依存しない層です。
reactorの最も重要な役割は、制御フローをviewから切り離すことです。
すべてのviewは対応するreactorを持ち、
すべてのロジックをそのreactorに委譲します。
reactorviewに依存しないので、簡単にテストすることができます。
このreactorの処理もまたいくつか役割が細分化されていますので、
そこの詳細は後述します。

State

Viewの状態を表した値を保持します。
ViewはStateの状態からのみUIを更新するので、
State以外の値でのUI更新は基本NGです。

View

現在のStateを描画し、ユーザ操作をActionとして発行し、
Reactorに渡す役割と、
StateとUI部品をバインドして、UI更新を行う役割があります。
そしてStoryboardを使用する場合は、
StoryboardViewというプロトコルが用意されているのでそれを継承します。

Reactorの基本構造


Reactorはどのような役割かと言うと、
上記図のようにViewから発行されたActionでStateを更新します。
State更新の間にMutationというのが存在します。

Mutation

ActionとState間の橋渡しをする役割を持っています。

Mutation記載例
enum Mutation {
  case isLoading(Bool) // 画面更新のフラグ
  case setText(String) // テキストセット
}

mutate

ReactorがActionから処理を行い、ObservableでMutationに変換する。

reduce

mutateで返された Mutationから新しいStateを生成する。

ReactorKitを使うにあたって必要なプロトコル

使用するプロトコルは大きく2つあります。
個人的にですがこのプロトコルの中身を把握しておくことで少し理解が深まりました。
以下の公式のGitHubディレクトリにソースがありますので一読してみてください。
https://github.com/ReactorKit/ReactorKit/tree/1.1.0/Sources/ReactorKit

Reactorプロトコル

reactorを定義するには、reactorプロトコルに準拠します。
このプロトコルでは、以下3つの型を定義する必要があります。

  • Action
  • Mutation
  • State

また、initialStateという名前のプロパティも必要です。

Actionはユーザーとの対話を表し、
Stateviewの状態を表します。
MutationはActionとStateの間の橋渡しをします。
reactorは、mutate()reduce()という2つのステップで、ActionStateに変換します。

mutate()Actionを受け取り、Observable<Mutation>を生成します。
非同期操作やAPI呼び出しなどのあらゆる副作用は、このメソッドで実行されます。

reduce()は、前のStateMutationから新しいStateを生成します。
このメソッドは純粋な関数で、新しいStateを同期的に返すだけです。
この関数の中で副作用を実行してはいけません。

transform()については自分がまだあまり使用したことがなく、
まだ理解も薄いので今後追記していきます。

Viewプロトコル

Viewはデータを表示します。
ViewControllerとCellは1つのViewとして扱われます。
Viewは、ユーザーの入力をアクションストリームにバインドし、
Viewの状態を各UIコンポーネントにbindします。
View層には、ビジネスロジックはありません。
Viewは、ActionStateをどのように対応付けるかを定義するだけです。
Viewを定義するには、既存のクラスでViewというプロトコルを定義するだけです。
そうすると、クラスは自動的にreactorプロパティを持つようになります。
このプロパティは通常、Viewの外部に設定されます。

reactorプロパティが変更されると、bind(reactor:)が呼び出されます。
ActionStateのバインディングを定義するには、このメソッドで実装します。

ストーリーボードを使ってViewControllerを初期化する場合は、
StoryboardViewプロトコルを使用します。
Viewプロトコルと全て同じですが、
唯一の違いは、StoryboardViewはViewがロードされた(viewDidLoad)後に、
バインディングを実行することです。

実装例(サンプルアプリ)

実装例のサンプルアプリとして、
公式のサンプルからよくあるGitHubのリポジトリを取得するアプリを例とします。
https://github.com/ReactorKit/ReactorKit/tree/master/Examples/GitHubSearch#github-search

GitHubSearchViewController
//  Created by Suyeol Jeon on 12/05/2017.
//  Copyright © 2017 Suyeol Jeon. All rights reserved.

import SafariServices
import UIKit
import ReactorKit
import RxCocoa
import RxSwift

class GitHubSearchViewController: UIViewController, StoryboardView {
  @IBOutlet var tableView: UITableView!
  let searchController = UISearchController(searchResultsController: nil)

  var disposeBag = DisposeBag()

  override func viewDidLoad() {
    super.viewDidLoad()
    tableView.scrollIndicatorInsets.top = tableView.contentInset.top
    searchController.dimsBackgroundDuringPresentation = false
    navigationItem.searchController = searchController
  }

  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    UIView.setAnimationsEnabled(false)
    searchController.isActive = true
    searchController.isActive = false
    UIView.setAnimationsEnabled(true)
  }

  func bind(reactor: GitHubSearchViewReactor) {
    // Action
    searchController.searchBar.rx.text
      .throttle(.milliseconds(300), scheduler: MainScheduler.instance)
      .map { Reactor.Action.updateQuery($0) }
      .bind(to: reactor.action)
      .disposed(by: disposeBag)

    tableView.rx.contentOffset
      .filter { [weak self] offset in
        guard let `self` = self else { return false }
        guard self.tableView.frame.height > 0 else { return false }
        return offset.y + self.tableView.frame.height >= self.tableView.contentSize.height - 100
      }
      .map { _ in Reactor.Action.loadNextPage }
      .bind(to: reactor.action)
      .disposed(by: disposeBag)

    // State
    reactor.state.map { $0.repos }
      .bind(to: tableView.rx.items(cellIdentifier: "cell")) { indexPath, repo, cell in
        cell.textLabel?.text = repo
      }
      .disposed(by: disposeBag)

    // View
    tableView.rx.itemSelected
      .subscribe(onNext: { [weak self, weak reactor] indexPath in
        guard let `self` = self else { return }
        self.view.endEditing(true)
        self.tableView.deselectRow(at: indexPath, animated: false)
        guard let repo = reactor?.currentState.repos[indexPath.row] else { return }
        guard let url = URL(string: "https://github.com/\(repo)") else { return }
        let viewController = SFSafariViewController(url: url)
        self.searchController.present(viewController, animated: true, completion: nil)
      })
      .disposed(by: disposeBag)
  }
}
GitHubSearchViewReactor
//  Created by Suyeol Jeon on 13/05/2017.
//  Copyright © 2017 Suyeol Jeon. All rights reserved.

import ReactorKit
import RxCocoa
import RxSwift

final class GitHubSearchViewReactor: Reactor {
  enum Action {
    case updateQuery(String?)
    case loadNextPage
  }

  enum Mutation {
    case setQuery(String?)
    case setRepos([String], nextPage: Int?)
    case appendRepos([String], nextPage: Int?)
    case setLoadingNextPage(Bool)
  }

  struct State {
    var query: String?
    var repos: [String] = []
    var nextPage: Int?
    var isLoadingNextPage: Bool = false
  }

  let initialState = State()

  func mutate(action: Action) -> Observable<Mutation> {
    switch action {
    case let .updateQuery(query):
      return Observable.concat([
        // 1) set current state's query (.setQuery)
        Observable.just(Mutation.setQuery(query)),

        // 2) call API and set repos (.setRepos)
        self.search(query: query, page: 1)
          // cancel previous request when the new `.updateQuery` action is fired
          .take(until: self.action.filter(Action.isUpdateQueryAction))
          .map { Mutation.setRepos($0, nextPage: $1) },
      ])

    case .loadNextPage:
      guard !self.currentState.isLoadingNextPage else { return Observable.empty() } // prevent from multiple requests
      guard let page = self.currentState.nextPage else { return Observable.empty() }
      return Observable.concat([
        // 1) set loading status to true
        Observable.just(Mutation.setLoadingNextPage(true)),

        // 2) call API and append repos
        self.search(query: self.currentState.query, page: page)
          .take(until: self.action.filter(Action.isUpdateQueryAction))
          .map { Mutation.appendRepos($0, nextPage: $1) },

        // 3) set loading status to false
        Observable.just(Mutation.setLoadingNextPage(false)),
      ])
    }
  }

  func reduce(state: State, mutation: Mutation) -> State {
    switch mutation {
    case let .setQuery(query):
      var newState = state
      newState.query = query
      return newState

    case let .setRepos(repos, nextPage):
      var newState = state
      newState.repos = repos
      newState.nextPage = nextPage
      return newState

    case let .appendRepos(repos, nextPage):
      var newState = state
      newState.repos.append(contentsOf: repos)
      newState.nextPage = nextPage
      return newState

    case let .setLoadingNextPage(isLoadingNextPage):
      var newState = state
      newState.isLoadingNextPage = isLoadingNextPage
      return newState
    }
  }

  private func url(for query: String?, page: Int) -> URL? {
    guard let query = query, !query.isEmpty else { return nil }
    return URL(string: "https://api.github.com/search/repositories?q=\(query)&page=\(page)")
  }

  private func search(query: String?, page: Int) -> Observable<(repos: [String], nextPage: Int?)> {
    let emptyResult: ([String], Int?) = ([], nil)
    guard let url = self.url(for: query, page: page) else { return .just(emptyResult) }
    return URLSession.shared.rx.json(url: url)
      .map { json -> ([String], Int?) in
        guard let dict = json as? [String: Any] else { return emptyResult }
        guard let items = dict["items"] as? [[String: Any]] else { return emptyResult }
        let repos = items.compactMap { $0["full_name"] as? String }
        let nextPage = repos.isEmpty ? nil : page + 1
        return (repos, nextPage)
      }
      .do(onError: { error in
        if case let .some(.httpRequestFailed(response, _)) = error as? RxCocoaURLError, response.statusCode == 403 {
          print("⚠️ GitHub API rate limit exceeded. Wait for 60 seconds and try again.")
        }
      })
      .catchAndReturn(emptyResult)
  }
}

extension GitHubSearchViewReactor.Action {
  static func isUpdateQueryAction(_ action: GitHubSearchViewReactor.Action) -> Bool {
    if case .updateQuery = action {
      return true
    } else {
      return false
    }
  }
}

これらのソースを以下で細かく解説していきます。

実装例(処理の流れ)

Stateの設定

Viewの状態管理をするためのStateを設定します。

State
 struct State {
    var query: String?                                    // SearcBarで入力したAPI通信に付与するquery
    var repos: [String] = []                        // TableViewに表示するリポジトリ
    var nextPage: Int?                                    // TableViewのセルの位置からローディングするページ
    var isLoadingNextPage: Bool = false // ローディングフラグ

Actionの設定

Viewから受けるイベントをActionとして設定します。

Action
 enum Action {
    case updateQuery(String?) // SearcBarで入力するAction
    case loadNextPage // TableViewのスクロール位置からローディング必要判定Action
  }

Mutationの設定

Actionを受けて、実際のState変更内容を行うMutationを設定します。

Mutation
 enum Mutation {
    case setQuery(String?) // API通信に付与するqueryのセット
    case setRepos([String], nextPage: Int?) // 表示するリポジトリのセット
    case appendRepos([String], nextPage: Int?) // リポジトリの追加
    case setLoadingNextPage(Bool) // ローディングフラグの変更
  }

mutate内の処理

ActionからMutationに変換するロジックをここで記載します。
公式のコメントそのままで分かりやすいかと思います。
よく見かけるのは、.concatや.mergeなどで、
複数のストリーム(Mutation)を合成する処理はよく見られます。
あとはguardで.empty()を返すのもよく使うかもしれません。

mutate
 func mutate(action: Action) -> Observable<Mutation> {
    switch action {
    case let .updateQuery(query):
      return Observable.concat([
        // 1) set current state's query (.setQuery)
        Observable.just(Mutation.setQuery(query)),

        // 2) call API and set repos (.setRepos)
        self.search(query: query, page: 1)
          // cancel previous request when the new `.updateQuery` action is fired
          .take(until: self.action.filter(Action.isUpdateQueryAction))
          .map { Mutation.setRepos($0, nextPage: $1) },
      ])

    case .loadNextPage:
      guard !self.currentState.isLoadingNextPage else { return Observable.empty() } // prevent from multiple requests
      guard let page = self.currentState.nextPage else { return Observable.empty() }
      return Observable.concat([
        // 1) set loading status to true
        Observable.just(Mutation.setLoadingNextPage(true)),

        // 2) call API and append repos
        self.search(query: self.currentState.query, page: page)
          .take(until: self.action.filter(Action.isUpdateQueryAction))
          .map { Mutation.appendRepos($0, nextPage: $1) },

        // 3) set loading status to false
        Observable.just(Mutation.setLoadingNextPage(false)),
      ])
    }
  }

reduceでの処理

Mutationを元にStateの値を更新させるメソッドがreduceになります。

reduce
 func reduce(state: State, mutation: Mutation) -> State {
    switch mutation {
    case let .setQuery(query):
      var newState = state
      newState.query = query
      return newState

    case let .setRepos(repos, nextPage):
      var newState = state
      newState.repos = repos
      newState.nextPage = nextPage
      return newState

    case let .appendRepos(repos, nextPage):
      var newState = state
      newState.repos.append(contentsOf: repos)
      newState.nextPage = nextPage
      return newState

    case let .setLoadingNextPage(isLoadingNextPage):
      var newState = state
      newState.isLoadingNextPage = isLoadingNextPage
      return newState
    }
  }

ViewからのイベントをActionにbind

reactorプロパティが変更されると、bind(reactor:)が呼び出されます。
このメソッドは内部で自動で呼び出されるもので、直接呼び出してはいけません。

GitHubSearchViewController > bind(reactor
 // Action
    searchController.searchBar.rx.text
      .throttle(.milliseconds(300), scheduler: MainScheduler.instance)
      .map { Reactor.Action.updateQuery($0) }
      .bind(to: reactor.action)
      .disposed(by: disposeBag)

    tableView.rx.contentOffset
      .filter { [weak self] offset in
        guard let `self` = self else { return false }
        guard self.tableView.frame.height > 0 else { return false }
        return offset.y + self.tableView.frame.height >= self.tableView.contentSize.height - 100
      }
      .map { _ in Reactor.Action.loadNextPage }
      .bind(to: reactor.action)
      .disposed(by: disposeBag)

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

reactorプロパティが変更されると、bind(reactor:)が呼び出されます
今回のアプリではセル内のtextLabeltextstatereposから、
bindして表示させています。

GitHubSearchViewController > bind(reactor
 // State
    reactor.state.map { $0.repos }
      .bind(to: tableView.rx.items(cellIdentifier: "cell")) { indexPath, repo, cell in
        cell.textLabel?.text = repo
      }
      .disposed(by: disposeBag)

個人的ReactorKitの感想

責務の明確化という点では素晴らしいと思いました。
ただ個人的にiOS開発でのアーキテクチャは、

  • MVC
  • MVP
  • MVVM

この辺りしか実務経験が無かったので、
他でよく見かけるVIPERやTCAなどと比較が出来ないです。
あくまでそれらの初心者の自分という観点からであれば、
ReactorKitの思想や設計は分かりやすく実装しやすい印象でした。
今後ももっと深いところまで学習していきたいです。

Discussion