RxSwiftへ苦手意識がある方向けの RxSwift + MVVM でiOSサンプルコード書きました
はじめに
業務を一緒にやっている方に僕の書き方で苦手意識を払拭できたという嬉しいお言葉を頂いたので、今回サンプルコードを用意して本記事を書こうと思いました。
RxSwiftの最初の苦手意識が払拭でき、かなり助かりました!名前の付け方とかPublishSubjectの使い方含めてこれは復習すれば、様々な形に応用できるようになりそうです😊
— fumiyasac@iOSアプリ開発「UI 実装であると嬉しいレシピブック」著者 (@fumiyasac) April 29, 2019
※ 僕自身、今まで様々なプロジェクトに関わりながら、どれがいいかなーという感じで手探りで進めてきたので、もっとこうした方がいいよという意見がありましたら、コメントください!
サンプルアプリ
GitHub Api を使ってレポジトリを検索し、詳細をWebViewで表示するというシンプルなものです。
サンプルコード
解説
前置き知識
RxSwift を使うことで、イベントドリブンなコードを簡潔に書くことができます。 GoFのデザインパターンのObserverパターンを調べると理解を進めてくれるかもしれません。
主な使用ライブラリ
主な使用ライブラリは以下です。
RxSwiftCommunity/Action は見慣れない方も多いかもしれませんが、 RxSwift を使ってAPIコールするときに便利ライブラリです。 IN と OUT を整理して、 Loading や Error などの状態管理も可能です。
コードを追う
APIのベース部分は一般的な実装だと思うので割愛します。わからない場合は、聞いていただければお答えします。
今回は一覧画面についてしか書きません。
コードについての説明は、コード内にコメントを書いて処理の内容を説明していきます。
手抜きですみません 😅
一覧画面
この画面は検索結果をUITableViewに表示するだけですが、
UIパーツ
、データ
、アクション
、状態
を書き出してみます。
UIパーツ
- UITableView
- UITableViewCell
- UIActivityIndicatorView
データ (表示する)
アクション
- 初回データ取得
- スクロール時に追加データ取得
- セル選択
状態
UIパーツ
は、ViewControllerに配置し、
データ
、アクション
、状態
は ViewModelに配置します。
ViewModelでは、ViewController が扱いやすいようにViewModel内で加工して、適切な Observable に変換して公開します。
ソースコード解説
ViewModel
ここからは一旦、ViewModel 内での処理を見ていきます。
ViewModel では、 inputs
, outputs
というprotocol (インターフェイス) を切っています。
ViewModelに対するインプットとアウトプットが混ざってしまいバグになることがあるので、それを回避するためだったり、 可読性をあげるという目的もあります。
Inputs
は ViewControllerなどから ViewModel に入ってくるイベントなどを記述します。Outputs
は ViewModel から ViewController などに出て行くイベントを記述します。(ViewControllerなどから参照されるイベント)
protocol ListViewModelInputs { var fetchTrigger: PublishSubject<Void> { get } var reachedBottomTrigger: PublishSubject<Void> { get } } protocol ListViewModelOutputs { var gitHubRepositories: Observable<[GitHubRepository]> { get } var navigationBarTitle: Observable<String> { get } var isLoading: Observable<Bool> { get } var error: Observable<NSError> { get } } protocol ListViewModelType { var inputs: ListViewModelInputs { get } var outputs: ListViewModelOutputs { get } } final class ListViewModel: ListViewModelType, ListViewModelInputs, ListViewModelOutputs { var inputs: ListViewModelInputs { return self } var outputs: ListViewModelOutputs { return self } ...
一つ一つを見て行くと、 先ほど挙げた データ
、アクション
、状態
を記述していることに気づいて欲しいです。
// アクション // 初回データ取得 var fetchTrigger: PublishSubject<Void> { get } // スクロール時に追加データ取得 var reachedBottomTrigger: PublishSubject<Void> { get } // セル選択 は UI側で完結するので、記述なし
// データ (表示する) // GitHub 検索結果のレポジトリリスト (UITableView用) var gitHubRepositories: Observable<[GitHubRepository]> { get } // GitHub 検索文字列 (navigationBar title 用) var navigationBarTitle: Observable<String> { get } // 状態 // API Loading var isLoading: Observable<Bool> { get } // API エラー var error: Observable<NSError> { get }
イベント発火・通知するためのものには Subject
か AnyObserver
の Observer
を使用し、それを 購読してイベントを処理します。
Subject
クラス | 説明 |
---|---|
PublishSubject | 一切キャッシュしないSubject |
BehaviorSubject | 直近の値を1つだけキャッシュするSubjectで、初期値を与えることができる。 |
ReplaySubject | 指定した数または全てをキャッシュするSubjectで、初期値は与えられない。 |
状態、データには、基本的に Observable
を使用し、イベントや変更された値などを通知します。
View上では任意のObservableをUIコンポーネントやViewModelのpropertyに対してバインドするだけで、状態変更のロジックなどが実装されていないシンプルな実装を実現できます。
また命名については
イベント発火・通知するためのものには hogeHogeTrigger (...Trigger)、
状態、データは それ自体を表す名前をつけるようにしています。
この命名の仕方でも、RxSwift のコード理解を進めるようです。
実際に実装内容をコードベースで見てみます。
コード内にコメントしています。
final class ListViewModel: ListViewModelType, ListViewModelInputs, ListViewModelOutputs { var inputs: ListViewModelInputs { return self } var outputs: ListViewModelOutputs { return self } // MARK: - Inputs // 初回データ取得イベントトリガー let fetchTrigger = PublishSubject<Void>() // スクロール時に追加データ取得イベントトリガー let reachedBottomTrigger = PublishSubject<Void>() // API pagination 用 private let page = BehaviorRelay<Int>(value: 1) // MARK: - Outputs // GitHub 検索文字列 (navigationBar title 用) を通知する let navigationBarTitle: Observable<String> // GitHub 検索結果のレポジトリリスト (UITableView用) を通知する let gitHubRepositories: Observable<[GitHubRepository]> // Loading 状態を通知する let isLoading: Observable<Bool> // エラー 状態を通知する let error: Observable<NSError> // Action ライブラリの記述 (INとOUTを記述する) private let searchAction: Action<Int, [GitHubRepository]> private let disposeBag = DisposeBag() init(language: String) { // ナビゲーションバー用の文字列を生成し、Observableにして代入する self.navigationBarTitle = Observable.just("\(language) Repositories") self.searchAction = Action { page in // in に入ってきたときに以下が実行される return Session.shared.rx.response(GitHubApi.SearchRequest(language: language, page: page)) } // ViewModel内でGitHubレポジトリデータを保持する let response = BehaviorRelay<[GitHubRepository]>(value: []) // GitHubレポジトリデータを外部に公開するように Observable に変換して代入する self.gitHubRepositories = response.asObservable() // Actionライブラリが Loading 状態を持っているので、その状態を外部に公開するために公開用の変数に代入する self.isLoading = searchAction.executing.startWith(false) // Actionライブラリが エラー 状態を持っているので、その状態を外部に公開するために公開用の変数に代入する self.error = searchAction.errors.map { _ in NSError(domain: "Network Error", code: 0, userInfo: nil) } // Actionライブラリが API レスポンスを持っているので、それを購読する searchAction.elements .withLatestFrom(response) { ($0, $1) } // 前回のレスポンス情報と合わせたいので、ストリームに取り込む .map { $0.1 + $0.0 } // 実際にここで、前回のレスポンスと今回取得したレスポンスを合成する .bind(to: response) // response にバインドし、値の変更を通知する .disposed(by: disposeBag) // API レスポンスを購読し、次回のAPIリクエストするときのために、 page番号をインクリメントする searchAction.elements .withLatestFrom(page) .map { $0 + 1 } .bind(to: page) .disposed(by: disposeBag) // 初回データ取得イベントトリガーを購読し、 APIリクエストする fetchTrigger .withLatestFrom(page) .bind(to: searchAction.inputs) .disposed(by: disposeBag) // スクロール時に追加データ取得イベントトリガーを購読し、 APIリクエストする reachedBottomTrigger .withLatestFrom(isLoading) // API通信中はリクエストを送らないために、Loading フラグをストリームに取り込む .filter { !$0 } // 取り込んだ通信中フラグでフィルターをかける。フラグを判定し、trueの場合は次へ行き、 false の場合は イベント通知はここで終了する .withLatestFrom(page) .filter { $0 < 5 } // APIの使用上リクエスト制限があるので、 page番号でフィルターをかける .bind(to: searchAction.inputs) .disposed(by: disposeBag) } }
ViewController
今度は ViewController の方をみていきます。
ViewControllerは ユーザーアクションをViewModelに通知する、ViewModelの状態、データ、イベントを反映するだけにしたほうがいいです。
つまり、ViewModelと繋ぐだけの実装にしたほうがシンプルで、テストもしやすくなり、いいと思います。
あとは、 viewModel の型を ViewModel の inputs
, outputs
のみを公開している ListViewModelType にするのが肝ですかね。
final class ListViewController: UIViewController { // ViewController を 生成する時は ViewModel を引数に入れています。 ViewModelを外から差し込めるようにしておけば、Unit テストを実施することもできます。 static func make(with viewModel: ListViewModel) -> ListViewController { let view = ListViewController.instantiate() view.viewModel = viewModel return view } @IBOutlet private weak var tableView: UITableView! @IBOutlet private weak var indicatorView: UIActivityIndicatorView! // 必ずインターフェイスを公開するようにする private var viewModel: ListViewModelType! private let disposeBag = DisposeBag() override func viewDidLoad() { super.viewDidLoad() // GitHub 検索文字列 (navigationBar title 用)をナビゲーションバータイトルにバインド viewModel.outputs.navigationBarTitle .observeOn(MainScheduler.instance) .bind(to: navigationItem.rx.title) .disposed(by: disposeBag) // GitHub 検索結果のレポジトリリスト (UITableView用) を UITableViewにバインド viewModel.outputs.gitHubRepositories .observeOn(MainScheduler.instance) .bind(to: tableView.rx.items) { tableView, row, githubRepository in // セル生成 let cell = UITableViewCell(style: .subtitle, reuseIdentifier: "subtitle") cell.textLabel?.text = "\(githubRepository.fullName)" cell.detailTextLabel?.textColor = UIColor.lightGray cell.detailTextLabel?.text = "\(githubRepository.description)" return cell } .disposed(by: disposeBag) // セル選択時のイベントを購読 (選択時のROWに応じたGitHubRepositoryが通知される) tableView.rx.modelSelected(GitHubRepository.self) .observeOn(MainScheduler.instance) .subscribe(onNext: { [weak self] in // 詳細画面に遷移する let vc = DetailViewController.make(with: DetailViewModel(repository: $0)) self?.navigationController?.pushViewController(vc, animated: true) }) .disposed(by: disposeBag) // Loading 状態 を UIActivityIndicatorView にバインド viewModel.outputs.isLoading .observeOn(MainScheduler.instance) .bind(to: indicatorView.rx.isAnimating) .disposed(by: disposeBag) // Loading 状態 を 購読し、 TableView の contentInset を調整する viewModel.outputs.isLoading .observeOn(MainScheduler.instance) .subscribe(onNext: { [weak self] in self?.tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: $0 ? 50 : 0, right: 0) }) .disposed(by: disposeBag) // エラー 状態 を 購読し、 エラーが通知された場合は アラートを表示する viewModel.outputs.error .observeOn(MainScheduler.instance) .subscribe(onNext: { [weak self] in let ac = UIAlertController(title: "Error \($0)", message: nil, preferredStyle: .alert) ac.addAction(UIAlertAction(title: "OK", style: .default, handler: nil)) self?.present(ac, animated: true) }) .disposed(by: disposeBag) // スクロールして、下に到達した時に流れるイベントを購読して、ViewModelに通知する tableView.rx.reachedBottom.asObservable() .bind(to: viewModel.inputs.reachedBottomTrigger) .disposed(by: disposeBag) // 初回のデータ取得を、ViewModelに通知する viewModel.inputs.fetchTrigger.onNext(()) } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) tableView.indexPathsForSelectedRows?.forEach { [weak self] in self?.tableView.deselectRow(at: $0, animated: true) } } } extension ListViewController: StoryboardInstantiable {}
reachedBottomに関しては、Reactive Extension を書いてある
extension Reactive where Base: UIScrollView { var reachedBottom: ControlEvent<Void> { let observable = contentOffset .flatMap { [weak base] contentOffset -> Observable<Void> in guard let scrollView = base else { return Observable.empty() } let visibleHeight = scrollView.frame.height - scrollView.contentInset.top - scrollView.contentInset.bottom let y = contentOffset.y + scrollView.contentInset.top let threshold = max(0.0, scrollView.contentSize.height - visibleHeight) return y > threshold ? Observable.just(()) : Observable.empty() } return ControlEvent(events: observable) } }
任意の画面から一覧画面に遷移する
ViewController.swift
let vc = ListViewController.make(with: ListViewModel(language: "RxSwift")) navigationController?.pushViewController(vc, animated: true)
さいごに
僕自身、100%この書き方や、実装方法がいいのかわかりませんが、色々なプロジェクトに関わってきた結果、今の所、これが良さそうという感じです。。
もっとこうしたほうがいいよというコメントがある方はお待ちしています🙇♂️
サンプルコード