ReactorKitを学ぶシリーズ
- ReactorKit(Flux + Reactive Programming)を学ぶ1 入門編
- ReactorKit(Flux + Reactive Programming)を学ぶ2 基礎編 ← 今ここ
- ReactorKit(Flux + Reactive Programming)を学ぶ3 実践編
ReactorKitは、リアクティブで単方向ストリームのアーキテクチャを構築するためのフレームワークです。
前回の入門編ではReactorKitの概要と最低限の実装について解説しました。今回はもう少し実践的なサンプルを例にReactorKitの使い方を解説します。
更新履歴
2018-03-28
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
なものもありますが、これはサンプルなので特にアクセスコントロールはつけられていません。
struct State {
var query: String? // 検索クエリ
var repos: [String] = [] // 検索結果のリポジトリ名
var nextPage: Int? // 次のページ番号
var isLoadingNextPage: Bool = false // 次のページへのローディング中か
}
let initialState = State() // Stateの初期値
Actionの定義とバインディング
Viewから行うべきアクションは、
- 文字列からリポジトリを検索
- 検索結果の次のページのリポジトリを検索
の2つで、これをReactorに定義してあります。
enum Action {
case updateQuery(String?)
case loadNextPage
}
Viewのbind(reactor:)
で各ActionとReactorとのバインディングです。
searchBarのテキスト入力をきっかけにupdateQuery
アクションを、リポジトリの検索はUITableViewの最下部付近かでloadNextPage
アクションが発行されるように実装します。
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の範囲外なので、細かい解説は割愛させていただきます。ここではクエリを元にリポジトリを検索し、その結果が取得できるということだけに注目すれば大丈夫です。なお、エラー時は空の結果を返すようになっています。
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を定義します。
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:)
に実装します。
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.updateQuery
はMutation.setQuery
とMutation.setRepos
-
Action.loadNextPage
はMutation.setLoadingNextPage
とMutation.appendRepos
複数のMutationは、concatで各Observableの完了を待って順番に実行するようにしています。
アクションをキャンセルする(重複したリクエストを防ぐ)
GitHub APIのような非同期のリクエストを行う場合に、重複してリクエストが発生するのは避けたいところです。
サンプルではAction.updateQuery
とAction.loadNextPage
でそれぞれ別の方法で、リクエストをキャンセルする方法が実装されています。
takeUntilで防ぐ
takeUntilは指定したObservableが何かイベントを発行すると終了(dispose)することができます。
.takeUntil(self.action.filter(isUpdateQueryAction))
Reactor.actionはActionSubjectという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が更新されるようにします。
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から引用