RxSwiftへ苦手意識がある方向けの RxSwift + MVVM でiOSサンプルコード書きました

はじめに

業務を一緒にやっている方に僕の書き方で苦手意識を払拭できたという嬉しいお言葉を頂いたので、今回サンプルコードを用意して本記事を書こうと思いました。

※ 僕自身、今まで様々なプロジェクトに関わりながら、どれがいいかなーという感じで手探りで進めてきたので、もっとこうした方がいいよという意見がありましたら、コメントください!

サンプルアプリ

GitHub Api を使ってレポジトリを検索し、詳細をWebViewで表示するというシンプルなものです。

サンプルコード

github.com

解説

前置き知識

RxSwift を使うことで、イベントドリブンなコードを簡潔に書くことができます。 GoFデザインパターンのObserverパターンを調べると理解を進めてくれるかもしれません。

qiita.com

主な使用ライブラリ

主な使用ライブラリは以下です。

RxSwiftCommunity/Action は見慣れない方も多いかもしれませんが、 RxSwift を使ってAPIコールするときに便利ライブラリです。 IN と OUT を整理して、 Loading や Error などの状態管理も可能です。

コードを追う

APIのベース部分は一般的な実装だと思うので割愛します。わからない場合は、聞いていただければお答えします。

今回は一覧画面についてしか書きません。
コードについての説明は、コード内にコメントを書いて処理の内容を説明していきます。
手抜きですみません 😅

一覧画面

この画面は検索結果をUITableViewに表示するだけですが、
UIパーツデータアクション状態を書き出してみます。

UIパーツ

  • UITableView
    • UITableViewCell
  • UIActivityIndicatorView

データ (表示する)

  • GitHub 検索結果のレポジトリリスト (UITableView用)
  • GitHub 検索文字列 (navigationBar title 用)

アクション

  • 初回データ取得
  • スクロール時に追加データ取得
  • セル選択

状態

UIパーツ は、ViewControllerに配置し、
データアクション状態 は ViewModelに配置します。
ViewModelでは、ViewController が扱いやすいようにViewModel内で加工して、適切な Observable に変換して公開します。

ソースコード解説

ViewModel

ListViewModel.swift

ここからは一旦、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 }

イベント発火・通知するためのものには SubjectAnyObserverObserver を使用し、それを 購読してイベントを処理します。

Subject

クラス 説明
PublishSubject 一切キャッシュしないSubject
BehaviorSubject 直近の値を1つだけキャッシュするSubjectで、初期値を与えることができる。
ReplaySubject 指定した数または全てをキャッシュするSubjectで、初期値は与えられない。

qiita.com

状態、データには、基本的に 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

ListViewController.swift

今度は 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%この書き方や、実装方法がいいのかわかりませんが、色々なプロジェクトに関わってきた結果、今の所、これが良さそうという感じです。。
もっとこうしたほうがいいよというコメントがある方はお待ちしています🙇‍♂️

サンプルコード

github.com