Edited at

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

More than 1 year has passed since last update.


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内のリポジトリを検索し、該当するリポジトリを表示します。スクロールするとさらに一致するリポジトリ(次ページ)が表示できます。

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内に書いてあるだけだと思われます。

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から引用