iOSエンジニアのつぶやき

毎朝8:30に iOS 関連の技術について1つぶやいています。まれに釣りについてつぶやく可能性があります。

はじめての ReactorKit【実践編】

前回の概要編に続き、今回は実際に ReactorKit を使ったサンプル実装をしていきたいと思います。

yamato8010.hatenablog.com

作るアプリ

今回は Google Books API を使用したアプリを想定して実装していきたいと思います。仕様は下記の通りです。 ※ 基本的には、ReactorKit 周りの実装が中心なので細かい API 処理などの実装部分などは省いていきます。

  • 画面が開いたら、API からデータを取得して TableView に反映する
  • refreshButton がタップされたら、データを更新する
  • API からデータを取得している最中は activityIndicator を表示し、取得が完了したら非表示にする

作業開始👨‍💻

まずは View のロジックを担う Reactor にそれぞれのイベントとデータを定義していきます。

import ReactorKit
import RxSwift
import RxCocoa // 後々 concat() 関数も使いたいのでインポート

class BookListViewReactor: Reactor {
    enum Action {
        case load
        case refresh
    }

    // Mutation を定義しない場合は、Action が Mutation として扱われる
    enum Mutation {
        case setBooks([ServerBook])
        case setLoading(Bool)
    }

    struct State {
        var books: [ServerBook]
        var isLoading: Bool
    }

    var initialState: BookListViewReactor.State = State(books: [], isLoading: false)
}

ActionStateinitialState あたりは必須 Reactor プロトコルの定義で必須で、Mutation に関しては、Action をもとに実行される処理の具体的な結果の値を定義します。また、Mutation の定義は必須ではなく、定義が無い場合には ActionMutation として扱われます。

次に Action をもとに処理を実行して Mutation を返すための mutate(action:) と、その受け取った Mutation をもとに新しい State を返す reduce(state:, mutation:) 関数を定義していきます。

    // BookListViewReactor

    func mutate(action: Action) -> Observable<Mutation> {
        switch action {
        case .load:
            return Observable.concat([
                Observable.just(Mutation.setLoading(true)),
                // API から本の情報一覧を取得
                BookService.sharedInstance.getAllBooks().flatMap { item in Observable.just(Mutation.setBooks(item.items))},
                Observable.just(Mutation.setLoading(false))
            ])
        case .refresh:
            return Observable.concat([
                Observable.just(Mutation.setLoading(true)),
                BookService.sharedInstance.getAllBooks().flatMap { item in Observable.just(Mutation.setBooks(item.items))},
                Observable.just(Mutation.setLoading(false))
            ])
        }
    }

    func reduce(state: State, mutation: Mutation) -> State {
        switch mutation {
        case .setLoading(let isLoading):
            var newState = state
            newState.isLoading = isLoading
            return newState
        case .setBooks(let books):
            var newState = state
            newState.books = books
            return newState
        }
    }

mutate(action:) の中では、主に Observable.cancat()Mutaiont を返しています。今回 Action として定義した load のように1つのイベントで loading の更新API からデータを取得 など複数の処理を行う必要があるので、RxCocoa が提供している concat() 関数で Observable を直列に実行して、順次 Mutatation を返しています。また、reduce(state:, mutation:) では受け取った Mutation と現在の State をもとに新しい State を発行して返しています。

これで、Reactor 側の実装は完了したので、View の実装をしていきます。

まずは、使用する View(ViewController) を ReactorKit が提供している View プロトコルに準拠させます。また、今回は、Storyboard を使用して View を作成していくので、StoryboardView というプロトコルに準拠させます。これによって、ViewController の childViews が初期化されたタイミングで bind(reactor:) が呼ばれるようになります。

import ReactorKit
import RxSwift

class BookListViewController: UIViewController, StoryboardView {
    @IBOutlet weak var refreshButton: UIButton!
    @IBOutlet weak var activityIndicator: UIActivityIndicatorView!
    @IBOutlet weak var tableView: UITableView!
    // 🚨 本来はより、Testable にするために Reactor の注入はクラス内では行いませんが、今回はサンプル実装のためこのままでいきます
    var reactor: BookListViewReactor? = BookListViewReactor()
    
    var disposeBag: DisposeBag = DisposeBag()

    override func viewDidLoad() {
        super.viewDidLoad()
        tableView.register(UINib(nibName: "BookTableViewCell", bundle: nil), forCellReuseIdentifier: "BookTableViewCell")
    }

    func bind(reactor: BookListViewReactor) {
        Observable.just(Void())
            .map { Reactor.Action.load }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)

        refreshButton.rx.tap
            .map { Reactor.Action.refresh }
            .bind(to: reactor.action)
            .disposed(by: disposeBag)

        // State binding.
        reactor.state
            .map { $0.isLoading }
            .distinctUntilChanged() // 値に変更があった場合にのみイベントを流す
            .map { !$0 }
            .bind(to: activityIndicator.rx.isHidden)
            .disposed(by: disposeBag)
        
        reactor.state
            .map { $0.books }
            .bind(to: tableView.rx.items(cellIdentifier: "BookTableViewCell", cellType: BookTableViewCell.self)) { index, book, cell in
                cell.set(book: book)
            }
            .disposed(by: disposeBag)
    }

}

基本的には、disposeBagbind(reactor:) の定義が必須になります。今回はサンプル実装なので、reactor への反映をクラス内で行っていますが、本来はより Testable にするために、切り離す必要があります(初回表示の View の場合は、AppDelegate 内で reactor の反映を行うなど)。また、bind(reactor:) が呼ばれるのは、reactor への反映が完了していて、かつ viewDidLoad の後に呼び出されます。ですので、初回時に行う処理などを bind(reactor:)rx.methodInvoked(#selector(viewDidLoad)) のように Observe したいところですが、これは呼ばれないので初回時の処理は Observable.just(Void()) で定義します(参考のIssue-comment)。

こんな感じで、ReactorKit を使ってシンプルにアプリを作成することができました🎉 また他の場面で使用することがあったらまた記事を書きたいと思います。

参考

その他の記事

yamato8010.hatenablog.com

yamato8010.hatenablog.com

yamato8010.hatenablog.com