[Swift]ReactorKitの基本概要
経緯
初めて触れて苦戦しながらも結構理解出来たので、
その理解までの工程の備忘録のため。
あくまで記載内容は公式の内容の意訳と自分なりの解釈ですのでご承知ください。
ReactorKitとは
アーキテクチャ名は、The Reactive Architectureで、
TCA(The Composable Architecture)と似た構造という印象。
2017年に公開されたFluxとRxSwiftを組み合わせて構築するアーキテクチャです。
とにかくRxSwiftは必須なのでそこの学習コストが高いのがネックです。
ReactorKitの基本構造
公式に掲載されているこの構造です。
1つ1つ役割を見ていきましょう。
Action
Actionはユーザ操作のイベントを設定します。
ユーザ操作についてなのでVCでActionを発行する形になります。
enum Action {
case reload // 画面更新
case tapButton // ボタンタップ
}
Reactor
1番ここが肝だと思います。
reactor
は、view
の状態を管理するUIに依存しない層です。
reactor
の最も重要な役割は、制御フローをview
から切り離すことです。
すべてのview
は対応するreactor
を持ち、
すべてのロジックをそのreactor
に委譲します。
reactor
はview
に依存しないので、簡単にテストすることができます。
この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間の橋渡しをする役割を持っています。
enum Mutation {
case isLoading(Bool) // 画面更新のフラグ
case setText(String) // テキストセット
}
mutate
ReactorがActionから処理を行い、ObservableでMutationに変換する。
reduce
mutateで返された Mutationから新しいStateを生成する。
ReactorKitを使うにあたって必要なプロトコル
使用するプロトコルは大きく2つあります。
個人的にですがこのプロトコルの中身を把握しておくことで少し理解が深まりました。
以下の公式のGitHubディレクトリにソースがありますので一読してみてください。
Reactorプロトコル
reactor
を定義するには、reactor
プロトコルに準拠します。
このプロトコルでは、以下3つの型を定義する必要があります。
- Action
- Mutation
- State
また、initialStateという名前のプロパティも必要です。
Action
はユーザーとの対話を表し、
State
はview
の状態を表します。
Mutation
はActionとStateの間の橋渡しをします。
reactor
は、mutate()
とreduce()
という2つのステップで、Action
をState
に変換します。
mutate()
はAction
を受け取り、Observable<Mutation>を生成します。
非同期操作やAPI呼び出しなどのあらゆる副作用は、このメソッドで実行されます。
reduce()
は、前のState
とMutation
から新しいState
を生成します。
このメソッドは純粋な関数で、新しいState
を同期的に返すだけです。
この関数の中で副作用を実行してはいけません。
transform()
については自分がまだあまり使用したことがなく、
まだ理解も薄いので今後追記していきます。
Viewプロトコル
View
はデータを表示します。
ViewControllerとCellは1つのView
として扱われます。
View
は、ユーザーの入力をアクションストリームにバインドし、
View
の状態を各UIコンポーネントにbindします。
View
層には、ビジネスロジックはありません。
View
は、Action
とState
をどのように対応付けるかを定義するだけです。
View
を定義するには、既存のクラスでViewというプロトコルを定義するだけです。
そうすると、クラスは自動的にreactor
プロパティを持つようになります。
このプロパティは通常、View
の外部に設定されます。
reactor
プロパティが変更されると、bind(reactor:)
が呼び出されます。
Action
とState
のバインディングを定義するには、このメソッドで実装します。
ストーリーボードを使ってViewControllerを初期化する場合は、
StoryboardViewプロトコルを使用します。
Viewプロトコルと全て同じですが、
唯一の違いは、StoryboardViewはViewがロードされた(viewDidLoad)後に、
バインディングを実行することです。
実装例(サンプルアプリ)
実装例のサンプルアプリとして、
公式のサンプルからよくあるGitHubのリポジトリを取得するアプリを例とします。
// 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)
}
}
// 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を設定します。
struct State {
var query: String? // SearcBarで入力したAPI通信に付与するquery
var repos: [String] = [] // TableViewに表示するリポジトリ
var nextPage: Int? // TableViewのセルの位置からローディングするページ
var isLoadingNextPage: Bool = false // ローディングフラグ
Actionの設定
Viewから受けるイベントをActionとして設定します。
enum Action {
case updateQuery(String?) // SearcBarで入力するAction
case loadNextPage // TableViewのスクロール位置からローディング必要判定Action
}
Mutationの設定
Actionを受けて、実際のState変更内容を行う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()を返すのもよく使うかもしれません。
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になります。
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:)
が呼び出されます。
このメソッドは内部で自動で呼び出されるもので、直接呼び出してはいけません。
// 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:)
が呼び出されます
今回のアプリではセル内のtextLabel
のtext
をstate
のrepos
から、
bindして表示させています。
// 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