27
17

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.

ReactorKit(Flux + Reactive Programming)を学ぶ2 基礎編

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 実践編

ReactorKitは、リアクティブで単方向ストリームのアーキテクチャを構築するためのフレームワークです。
前回の入門編ではReactorKitの概要と最低限の実装について解説しました。今回はもう少し実践的なサンプルを例にReactorKitの使い方を解説します。

更新履歴

2018-03-28

  • v0.4.5からv1.1.0に内容を更新
    • 解説部分への変更はなし。一部テキストを修正。

2017-06-10

  • 初稿

この記事で学べること

  • ReactorKit/Examples/GitHubSearchの解説
  • Actionが重複して発行されることについて
  • 1つのActionで複数のMutationが発生することについて
  • Actionのキャンセル処理

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

サンプルアプリGitHubSearchを解説

ReactorKitのExamplesにあるGitHubSearchを元にReactorKitのより実践的な使い方を解説します。

アプリの機能は、UISearchBarに入力した文字列からGitHub内のリポジトリを検索し、該当するリポジトリを表示します。スクロールするとさらに一致するリポジトリ(次ページ)が表示できます。

76671e92-385a-11e7-972f-5005160eb690.png

ReactorKit/Examples/GitHubSearch/README.md

Stateの定義

ViewやReactorで必要な値を定義します。
実際はfileprivateなものもありますが、これはサンプルなので特にアクセスコントロールはつけられていません。

GitHubSearchViewReactor
struct State {
  var query: String? // 検索クエリ
  var repos: [String] = [] // 検索結果のリポジトリ名
  var nextPage: Int? // 次のページ番号
  var isLoadingNextPage: Bool = false // 次のページへのローディング中か
}

let initialState = State() // Stateの初期値

Actionの定義とバインディング

Viewから行うべきアクションは、

  • 文字列からリポジトリを検索
  • 検索結果の次のページのリポジトリを検索

の2つで、これをReactorに定義してあります。

GitHubSearchViewReactor
enum Action {
  case updateQuery(String?)
  case loadNextPage
}

Viewのbind(reactor:)で各ActionとReactorとのバインディングです。
searchBarのテキスト入力をきっかけにupdateQueryアクションを、リポジトリの検索はUITableViewの最下部付近かでloadNextPageアクションが発行されるように実装します。

GitHubSearchViewController
func bind(reactor: GitHubSearchViewReactor) {
    // Action
    searchBar.rx.text
      .throttle(0.3, scheduler: MainScheduler.instance) // テキスト入力が止まってから0.3秒後にイベントを発行
      .map { Reactor.Action.updateQuery($0) } // Actionを生成
      .bind(to: reactor.action) // Actionをbind
      .disposed(by: disposeBag)

    tableView.rx.contentOffset
      .filter { [weak self] offset in // UITableViewの最下部付近か
        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 } // Actionを生成
      .bind(to: reactor.action) // Actionをbind
      .disposed(by: disposeBag)

ここでのポイントは、View側ではActionが重複して発行されることを気にしていないことです。例えばloadNextPageは文字入力がない状態でもスクロールすればActionは発行されます。発行されたActionに対してAPIリクエストが実際に行われるのか、最終的にstateは更新されるかなどを決定するのはReactorの責務で、View側はActionの発行にだけに注力すればいいのです。
そのほかに注意すべきところは、Reactorのstateの状態を元にActionの発行を判断することは避けるべきです。それを行う時点で単方向ストリームではなくなってしまいます。あくまでもActionの発行は、View側の状態によって判断すべきことです。

GitHub APIの実装

GitHub APIを使ってリポジトリ検索を行います。
GitHub APIに関してはReactorKitの範囲外なので、細かい解説は割愛させていただきます。ここではクエリを元にリポジトリを検索し、その結果が取得できるということだけに注目すれば大丈夫です。なお、エラー時は空の結果を返すようになっています。

GitHubSearchViewReactor
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.flatMap { $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.")
      }
    })
    .catchErrorJustReturn(emptyResult)
}

Mutationの定義

Actionからの変化内容であるMutationを定義します。

GitHubSearchViewReactor
enum Mutation {
  case setQuery(String?) // State.queryの更新
  case setRepos([String], nextPage: Int?) // State.reposの更新
  case appendRepos([String], nextPage: Int?) // State.reposに次のページの検索結果を追加します
  case setLoadingNextPage(Bool) // 次のページを検索中かのフラグ
}

mutate(action:)の実装

ActionからMutationの生成をmutate(action:)に実装します。

GitHubSearchViewReactor
func mutate(action: Action) -> Observable<Mutation> {
  switch action {
  case let .updateQuery(query):
    return Observable.concat([
      // 1) State.queryを更新する
      Observable.just(Mutation.setQuery(query)),
      
      // 2) GitHub APIでリポジトリを検索し、State.reposを更新する
      self.search(query: query, page: 1)
        // リクエスト中にupdateQueryアクションが発行されたらdisposeする
        .takeUntil(self.action.filter(isUpdateQueryAction))
        .map { Mutation.setRepos($0, nextPage: $1) },
    ])    
  case .loadNextPage:
    guard !self.currentState.isLoadingNextPage else { return Observable.empty() } // リクエストの重複を防ぐ
    guard let page = self.currentState.nextPage else { return Observable.empty() } // nextPageがない場合はリクエストしない
    return Observable.concat([
      // 1) State.isLoadingNextPageを更新する
      Observable.just(Mutation.setLoadingNextPage(true)),
      
      // 2) GitHub APIで次のページを検索し、State.reposに結果を追加する
      self.search(query: self.currentState.query, page: page)
        .takeUntil(self.action.filter(isUpdateQueryAction))
        .map { Mutation.appendRepos($0, nextPage: $1) },

      // 3) State.isLoadingNextPageを更新する
      Observable.just(Mutation.setLoadingNextPage(false)),
    ])
  }
}

// Actionが`updateQuery`かどうかを判定する関数
private func isUpdateQueryAction(_ action: Action) -> Bool {
  if case .updateQuery = action {
    return true
  } else {
    return false
  }
}

ポイントは、 1つのActionで複数のMutationが発生しうる という点です。

  • Action.updateQueryMutation.setQueryMutation.setRepos
  • Action.loadNextPageMutation.setLoadingNextPageMutation.appendRepos

複数のMutationは、concatで各Observableの完了を待って順番に実行するようにしています。

アクションをキャンセルする(重複したリクエストを防ぐ)

GitHub APIのような非同期のリクエストを行う場合に、重複してリクエストが発生するのは避けたいところです。
サンプルではAction.updateQueryAction.loadNextPageでそれぞれ別の方法で、リクエストをキャンセルする方法が実装されています。

takeUntilで防ぐ

takeUntilは指定したObservableが何かイベントを発行すると終了(dispose)することができます。

.takeUntil(self.action.filter(isUpdateQueryAction))

Reactor.actionActionSubjectというReactorKitで独自に実装されているSubjectです。ActionSubjectは、.nextのみ発行されるSubjectなので、takeUntil によりonErrorが発行されるケースを考慮する必要がありません。
つまり、APIリクエスト中に新たにAction.updateQueryが発行されたら(違う文字列での検索が始まったら)リクエストをキャンセルするという挙動になります。

フラグで防ぐ

単純にStateにフラグを持たせ、それで重複リクエストを防ぎます。

guard !self.currentState.isLoadingNextPage else { return Observable.empty() }
guard let page = self.currentState.nextPage else { return Observable.empty() }

Action.loadNextPageは、APIリクエストの前後でState.isLoadingNextPageのフラグをtrue → falseと変更させているので、これでAction.loadNextPageが重複して実行されることを防いでいます。
また、nextPageを確認することで、まだ検索していなかったり、APIの結果で次のページがない状態を判定して、リクエストをしないようにしています。

加えて、.takeUntilも使用し、Action.updateQuery中にAction.updateQueryが発行されたら、Action.updateQueryをキャンセルするようにもしています。

reduce(state:mutation:)の実装

Mutationから新しいStateを生成します。
ここでは単純にMutationが持っている値でStateの各プロパティを更新するだけです。

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

StateをUIに反映

Viewのbind(reactor:)でStateとUIのバインドを行いUIが更新されるようにします。

GitHubSearchViewController
func bind(reactor: GitHubSearchViewReactor) {
  /* 省略 */
  
  // State.reposを元にUITableViewの表示を更新する
  reactor.state.map { $0.repos }
    .bind(to: tableView.rx.items(cellIdentifier: "cell")) { indexPath, repo, cell in
      cell.textLabel?.text = repo
    }
    .disposed(by: disposeBag)

  // UITableViewCellを選択したときの挙動
  tableView.rx.itemSelected
    .subscribe(onNext: { [weak self, weak reactor] indexPath in
      guard let `self` = self else { return }
      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.present(viewController, animated: true, completion: nil)
    })
    .disposed(by: disposeBag)
  }

GitHubSearchを少し改良

せっかくState.isLoadingNextPageがあるので、これを利用して簡易的なリクエスト中の表示を追加してみます。

UINavigationItem.promptを使います。RxCocoaにはpromptのObserverが実装されていないので追加します。

extension Reactive where Base: UINavigationItem {
  public var prompt: UIBindingObserver<Base, String?> {
    return UIBindingObserver(UIElement: self.base) { navigationItem, text in
        navigationItem.prompt = text
    }
  }
}

次にUIとのバインドです。

func bind(reactor: GitHubSearchViewReactor) {
  /* 省略 */
  
  // promptを更新
  reactor.state.map { $0.isLoadingNextPage }
    .distinctUntilChanged() // 変化のないイベントをスキップ
    .map { $0 ? "NextPage..." : nil }
    .bind(to: navigationItem.rx.prompt)
    .disposed(by: disposeBag)
}

これで次のページをリクエストするときにpromptに"NextPage..."と表示されるようになります。

このようにStateにフラグを用意しておけば、Viewではそのフラグの状態に対する変化をUIに反映させて、Reactorでは、適切なタイミングでフラグを切り替える(GitHubSearchではAPIリクエストの前後で切り替えてる)ことに注力すれば良いということになります。

GitHub APIへのリクエストはReactorの責務か

サンプルではGitHub APIへのリクエストがGitHubSearchViewReactorに実装されていますが、果たしてこれはReactorに実装すべきなのでしょうか?

答えは、 Serviceレイヤーで行うべき です。前回と同様にこのサンプルでもわかりやすさを優先してビジネスロジックをReactor内に書いてあるだけだと思われます。

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

Serviceレイヤーについては、次回解説したいと思います。

まとめ

前回と比べて今回は非同期のリクエストをまじえた少し実践的な内容になりました。
アーキテクチャとして意識すべきところはそれぞれの責務です。View, Reactorそれぞれ何に責任を持って実装すべきかに注力します。

  • Viewはbind(reactor:)でユーザインプットとActionのバインディングを行う
  • Viewはbind(reactor:)でStateとUIのバインディングを行う
  • Reactorはmutate(action:)でActionからMutationを生成する
  • Reactorはreduce(state:mutation:)でMutationから新しいState生成する

次回はRxToDoを例にReactorKitのServiceレイヤーとtransform系のメソッドについて解説したいと思います。
ReactorKit(Flux + Reactive Programming)を学ぶ3 実践編


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

27
17
3

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
27
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?