この記事は、Classi Advent Calendar 2019 の15日目の記事です。
こんにちは、ClassiのiOSアプリエンジニアの@yoko-yanです。
Classiは、サーバーサイド、特にRubyエンジニアが多くて、サーバーサイドの記事が多いと思うんですが、アプリも負けずに書いていきたいと思っているので、よろしくお願い致します。
最近は社内でRxSwift勉強会を開催するなど、社内のRxSwiftの啓蒙活動を行っています。
そういったこともあり、せっかくなので、RxSwiftで試行錯誤して作った部分を公開できる範囲で記事にしてClassiのアプリも、しっかり作っているぞ的なイメージを持たせられればと思っています。
UX(ユーザーエクスペリエンス)を考慮した詳細画面とは
さて、現在開発に携わっているアプリの中では、いろいろな工夫を施しているんですが、その中でも、UXの体験向上として、工夫した箇所があるんですが、一覧画面と詳細画面という構成の中で、詳細の前後のページにアクセスしやすくしたいので、詳細画面で左右にスワイプすることで、次の詳細データや前のの詳細データを表示する体験があります。
これは、Gmailアプリが実装しているような、各メールの詳細ビューで左右にスワイプすることで、次の前のメールを表示する方法をイメージしてもらえれば、分かりやすいかと思います。
記事の内容について
前述したものを実現すべく、RxSwfitを使って、どんな風に実装したかを、記事にしたいと思います。
今回、実際に動かせるソースをGithubに公開できるとよかったんですが、時間の都合で用意できなかったので
別途、公開できるタイミングがあれば、公開したいなと思うので、今回は、ご了承ください。
具体的な実装の解説
アプリのよくある実装として、一覧画面では、一覧データ取得のAPIを叩いて、あらかじめ決められた数(例えば20件など)を取得して一覧画面を作成していると思うので、Gmailのように一覧画面から詳細画面に遷移し、そこでスワイプで前後のデータに遷移できるように実装するには
1. スワイプで遷移した際のデータの切れ目で、ページングのAPIリクエストを実行
2. 追加で取得したデータを一覧ページとスワイプ画面で同期を取る
という実装が必要になります。
これを、RxSwiftで実装してみます。
まず、スワイプでのページングを実装するにあたり、UICollectionViewだったり、UIPageViewControllerだったりで、いろいろ実装方法はあるかと思いますが、今回は、UIPageViewControllerで実装しました。
その理由は、すでに詳細画面をUIViewControllerとして実装していたので、それをそのまま使いたく、UIPageViewControllerなら、それを呼び出すだけでよかったのと、スワイプのいらない詳細画面は、直接、詳細画面のUIViewControllerを呼び出すことが出来るようにしたかったからです。
概要
めちゃくちゃ雑ですが、ざっくり言うと、こんな感じで、ストリームを共有しています。
PageViewControllerについて
まず、PageViewControllerをどのように実装しているのかを見ていきます。
struct Item: Decodable {
let id: String
let name: String
}
このアプリでは、上記のようなStructを扱うとして、以下のように実装しています。
import UIKit
import RxSwift
import RxCocoa
class PageViewController: UIPageViewController {
private let paginationRequestTrigger: PublishRelay<Void>
private var items: [Item] = []
let disposeBag = DisposeBag()
init(itemsStream: Observable<[Item]>, paginationRequestTrigger: PublishRelay<Void>, selectedItem: Any) {
self.paginationRequestTrigger = paginationRequestTrigger
super.init(transitionStyle: .scroll, navigationOrientation: .horizontal, options: nil)
dataSource = self
delegate = self
let binder = Binder(self) { (vc, items: [Item]) in
if vc.items.isEmpty {
guard let index = items.firstIndex(where: { $0.id == selectedItem.id }) else { return }
vc.items = items
vc.setupPageViewController(initialIndex: index)
} else if !items.isEmpty {
vc.items = items
}
}
itemsStream
.filter { !$0.isEmpty }
.bind(to: binder)
.disposed(by: disposeBag)
}
required init?(coder aDecoder: NSCoder) {
fatalError()
}
}
private extension PageViewController {
func setupPageViewController(initialIndex: Int) {
setViewControllers([createDetailPageViewController(of: initialIndex) ?? UIViewController()], direction: .forward, animated: true, completion: nil)
}
func createDetailPageViewController(of index: Int) -> UIViewController? {
guard items.indices.contains(index) else { return nil } // index out of range
let item = items[index]
let vc = DetailViewController()
vc.item = item
vc.view.tag = index
return vc
}
}
extension PageViewController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
let currentIndex = viewController.view.tag
return createDetailPageViewController(of: currentIndex - 1)
}
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
let currentIndex = viewController.view.tag
return createDetailPageViewController(of: currentIndex + 1)
}
}
extension PageViewController: UIPageViewControllerDelegate {
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
guard let nextIndex = pendingViewControllers.first?.view.tag else { return }
guard items.count - 1 == nextIndex else { return }
paginationRequestTrigger.accept(())
}
}
このPageViewControllerは、一覧画面で、セル(アイテム)をクリックした際に、インスタンス化されて呼び出されます。
このPageViewControllerでやってるRxSwiftを活用した実装のポイントとしては
- (1) 一覧取得APIを実行しているストリームを購読し、ListViewController(一覧画面)とデータを同期する
- (2) スワイプ時に表示するデータがない場合、ストリームを実行し、追加取得する
の2つです。
(1) 一覧取得APIを実行しているストリームを購読し、ListViewController(一覧画面)とデータを同期する
インスタンス生成時に一覧画面から、一覧取得APIを実行しているストリームと、そのトリガーと、選択されたデータを渡しています。
init(itemsStream: Observable<[Item]>, paginationRequestTrigger: PublishRelay<Void>, selectedItem: Any) {
引数 | 説明 |
---|---|
itemsStream | 一覧取得APIを実行し、 itemの配列が流れるストリーム |
paginationRequestTrigger | itemsStreamを現在のページ指定を元に追加データを取得するトリガー |
selectedItem | 一覧画面で選択したセルのデータ |
以下の箇所で、ストリームに値が流れてきた際に、必要に応じてDetaiViewControllerを再セットします。
let binder = Binder(self) { (vc, items: [Item]) in
if vc.items.isEmpty {
guard let index = items.firstIndex(where: { $0.id == selectedItem.id }) else { return }
vc.items = items
vc.setupPageViewController(initialIndex: index)
} else if !items.isEmpty {
vc.items = items
}
}
itemsStream
.filter { !$0.isEmpty }
.bind(to: binder)
.disposed(by: disposeBag)
(2) スワイプ時に表示するデータがない場合、ストリームを実行し、追加取得する
スワイプで次々にデータを表示する際、DetailViewControllerを生成して表示していますが、元になるデータは、インスタンス生成時に、initで渡されたデータなので、スワイプを続けているとデータの切れ目に辿りつきます。
そのタイミングで、一覧取得APIを実行して、新たにデータを追加取得したいのですが、ここで取得したデータは、一覧でも反映して(同期を取りたい)、一覧画面に戻った時に、同じストリーム実行せずに済ませたいので、initで渡されたトリガーを元に、一覧画面側のストリームを実行し、 (1)の箇所で、結果を反映させます。
extension PageViewController: UIPageViewControllerDelegate {
func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) {
guard let nextIndex = pendingViewControllers.first?.view.tag else { return }
guard items.count - 1 == nextIndex else { return }
paginationRequestTrigger.accept(())
}
}
このように実装することで、一覧画面と同じストリームや同じパラメータセットのAPIを、わざわざ実行せずに済むようにしました。
ListViewControllerについて
一覧画面を表示するListViewControllerについて、実装を見ていきます。
[補足]
余談ですが、このアプリの実装は、MVVM+RxSwiftで実装しているので、この記事でも、それで実装しているコードで記述します。
また、MVVM+RxSwiftで実装するにあたっては、以下の書籍を参考にさせて頂いています。
RxSwift研究読本3 ViewModel設計パターン入門
この書籍の中のsergdort氏のViewModelパターンを採用しています。
ListViewControllerは、以下のように実装しています。
final class ListViewController: UIViewController {
private let disposeBag = DisposeBag()
private let viewModel = ListViewModel()
private let dataSource = DataSource()
@IBOutlet private weak var tableView: UITableView!
private let reloadRequestTrigger = PublishRelay<Void>()
private let paginationRequestTrigger = PublishRelay<Void>()
private let changeDataTrigger = PublishRelay<Item>()
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
bind()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
}
// MARK: - Private
private func bind() {
let input = ListViewModel.Input(
viewWillAppearStream: rx.sentMessage(#selector(viewWillAppear(_:))).map { _ in },
viewDidReachBottom: tableView.rx.reachedBottom.asObservable(),
reloadRequestTrigger: reloadRequestTrigger.asObservable(),
paginationRequestTrigger: paginationRequestTrigger.asObservable(),
changeDataTrigger: changeDataTrigger.asObservable()
)
let output = viewModel.transform(input: input)
output.itemsStream
.bind(to: tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
do {
let binder = Binder(self) { (vc, error: Error) in
// error
}
output.error
.bind(to: binder)
.disposed(by: disposeBag)
}
do {
let binder = Binder(self) { (vc, item: Item) in
let detailVC = PageViewController(itemsStream: output.itemsStream,
paginationRequestTrigger: vc.paginationRequestTrigger,
selectedItem: item)
vc.navigationController?.pushViewController(detailVC, animated: true)
}
tableView.rx
.modelSelected(Item.self)
.bind(to: binder)
.disposed(by: disposeBag)
}
output.loading
.bind(onNext: { $0 ? LoadingIndicator.show() : LoadingIndicator.dismiss() })
.disposed(by: disposeBag)
}
}
private class DataSource: NSObject, UITableViewDataSource {
typealias Element = [Item]
private var items: [Item] = []
func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return items.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let item = items[indexPath.row]
let cell = UITableViewCell(style: .default, reuseIdentifier: "cell")
cell.textLabel?.text = item.name
return cell
}
}
extension DataSource: RxTableViewDataSourceType {
func tableView(_ tableView: UITableView, observedEvent: RxSwift.Event<Element>) {
Binder(self) { dataSource, elements in
dataSource.items = elements
tableView.reloadData()
}
.on(observedEvent)
}
}
extension DataSource: SectionedViewDataSourceType {
func model(at indexPath: IndexPath) throws -> Any {
return items[indexPath.row]
}
}
ViewModelの処理の概要は、後述します。
このListViewControllerでやってることの概要としては
- (1) UIイベントに依存せず、任意で発火させたいトリガーを宣言する
- (2) 一覧取得APIを実行の責務は、ViewModelに持たせるため、各トリガーをViewModelへ渡す
- (3) ViewModelで流れたストリームの結果を処理
- (4) tableViewの各イベントをストリームで処理
(1) UIイベントに依存せず、任意で発火させたいトリガーを宣言する
private let reloadRequestTrigger = PublishRelay<Void>()
private let paginationRequestTrigger = PublishRelay<Void>()
private let changeDataTrigger = PublishRelay<Item>()
ボタンクリックなど、UIイベントをトリガーとする場合は、RxCocoaで、イベントをObservable化すればいいですが、そうでなく任意に発火したい場合は、トリガー用のObservableを用意します。
各トリガーの説明は以下になります。
変数 | 説明 | 発火場所 |
---|---|---|
reloadRequestTrigger | 現在のページ番号を考慮しない初回のAPIリクエストを実行するトリガー | Pull to Refresh やエラー時のリトライなど |
paginationRequestTrigger | 現在のページ指定を元に追加データを取得するトリガー | スクロールイベントなどに起因せず、任意に追加読み込みをしたい箇所 |
changeDataTrigger | 特定のデータ (Item)だけを入れ替えるトリガー | 編集画面から戻ってきた際に、該当するデータだけ反映したい場合など |
(2) 一覧取得APIを実行の責務は、ViewModelに持たせるため、各トリガーをViewModelへ渡す
各トリガーごとに、異なった処理が必要ですが、それぞのロジックは、ViewController側ではなくViewModelで処理を行います。
これは、ViewModelをテスタブルにして、ViewController側は何も気にせずに処理を実行させたいという意図があります。
現在、アドバイザーとしていろいろ教えて頂いているスーパーなiOSエンジニアの方が、**『ViewControllerはアホな方がいい』**と言ってました。
この格言、めっちゃ気に入りましたw
ViewModelへバインドしている箇所です。
let input = ListViewModel.Input(
viewWillAppearStream: rx.sentMessage(#selector(viewWillAppear(_:))).map { _ in },
viewDidReachBottom: tableView.rx.reachedBottom.asObservable(),
reloadRequestTrigger: reloadRequestTrigger.asObservable(),
paginationRequestTrigger: paginationRequestTrigger.asObservable(),
changeDataTrigger: changeDataTrigger.asObservable()
)
ここでのviewWillAppearStreamについては、その名前の通り、UIViewControllerのviewWillAppearに反応してフックしているトリガーです。
ここでのreachedBottomについては、UIScrollViewを拡張して、RxCocoaでスクロールのボトムを検知しているものです。
(3) ViewModelで流れたストリームの結果を処理
ViewModelで組んだストリームの結果を、Viewに反映するためのロジックです。
let output = viewModel.transform(input: input)
output.itemsStream
.bind(to: tableView.rx.items(dataSource: dataSource))
.disposed(by: disposeBag)
do {
let binder = Binder(self) { (vc, error: Error) in
// error
}
output.error
.bind(to: binder)
.disposed(by: disposeBag)
}
リクエストが成功した際のストリームをtableViewのdataSourceへバインドしているのと
リクエストが失敗した際のストリームをエラー処理する為の、Binderへバインドしています。
(4) tableViewの各イベントをストリームで処理
これについては、特に目新しいことをやっていることもないので、割愛させて頂きます。
ListViewModelについて
前述させて頂いたように、@yimajoさんの書籍を元に、sergdort氏のViewModelパターンを採用しています。
こちらに関しては、@yimajoさんの書籍を参照頂くか、以下のサイトを参照ください。
ViewModel in RxSwift world - Serg Dort - Medium
このViewModelの責務としては、ItemをAPI経由で取得し、ストリームとしてViewControllerへ返すだけです。
が!、今回の実装の肝となる部分なので、少々細かく説明します。
struct ItemResponse: Decodable {
let items: Observable<[Item]>
public let pages: Pages
}
public struct Pages: Codable {
public let currentPage: Int
public let nextPage: Int?
}
APIから返ってくるレスポンスが上記のようだと仮定して、ListViewModelの処理は以下のようになります。
import Foundation
import RxSwift
import RxCocoa
import APIKit
struct ListViewModel {
private let disposeBag = DisposeBag()
struct Input {
let viewWillAppearStream: Observable<Void>
let viewDidReachBottom: Observable<Void>
let reloadRequestTrigger: Observable<Void>
let paginationRequestTrigger: Observable<Void>
let changeItemTrigger: Observable<Item>
}
struct Output {
let loading: Observable<Bool>
let itemsStream: Observable<[Item]>
let error: Observable<Error>
}
func transform(input: Input) -> Output {
let session = Session.shared
let nextPageNumber = PublishRelay<Int?>()
let startRequestTrigger = input.viewWillAppearStream.take(1).map { 1 } // 1 is a first page number
let itemsRequestTrigger = input.reloadRequestTrigger.map { 1 } // 1 is a first page number
let paginationRequestTrigger = PublishRelay.merge(
input.viewDidReachBottom.asObservable()
.throttle(.seconds(1), latest: false, scheduler: MainScheduler.instance),
input.paginationRequestTrigger.asObservable()
.throttle(.seconds(1), latest: false, scheduler: MainScheduler.instance)
)
.withLatestFrom(nextPageNumber)
.compactMap { $0 }
let load = PublishRelay.merge(startRequestTrigger, paginationRequestTrigger, itemsRequestTrigger).share()
let sequence = load
.flatMapLatest { nextPage -> Observable<Event<ItemResponse>> in
return session.rx
.send(ItemsRequest(page: nextPage))
.asObservable()
.materialize()
}
.share()
let elements = sequence.compactMap { $0.event.element }.share()
elements
.map { $0.pages.nextPage }
.bind(to: nextPageNumber)
.disposed(by: disposeBag)
let elementsWithPagination = elements
.scan([]) { $1.pages.currentPage == 1 ? $1.items : $0 + $1.items }
.startWith([])
.share(replay: 1)
let elementsWithChanged = input.changeItemTrigger
.withLatestFrom(elementsWithPagination) { ($0, $1) }
.map { arg -> [Item] in
let item = arg.0
var items = arg.1
if let index = items.firstIndex(where: {$0.id == item.id}) {
items[index] = item
}
return items
}
let itemsStream = Observable
.merge(elementsWithPagination, input.reloadRequestTrigger.map { [] }, elementsWithChanged)
.share(replay: 1)
let loading = PublishRelay.merge(load.map { _ in true }, sequence.map { _ in false })
return Output(
loading: loading,
itemsStream: itemsStream,
error: sequence.compactMap { $0.event.error }
)
}
}
このListViewModelでやってることの概要としては
- (1) 各トリガーに流すデータをページ番号で統一
- (2) 各トリガーをマージして、トリガーが発火したらストリームが実行されるようにする
- (3) APIを実行して、ページ番号を購読
- (4) APIの実行結果を、ストリームの中で保持している前回の結果にマージ
- (5) 部分的に変更があった場合、ストリームの中で保持している結果を元に差し替える
- (6) APIリクエストが必要なトリガーと、APIリクエストが不要となるトリガーをマージして、ストリームを構成する
(1) 各トリガーに流すデータをページ番号で統一
最終的に、ストリームに流れるデータは、ページ番号を起因にして、変化するので、発火する際に流す値は、ページ番号を長す
viewWillAppearやreloadRequest発火時のリクエストについては、ViewControllerからページ番号を渡す必要がないので、ViewModelの中でストリームを直接、書き換えます。
let nextPageNumber = PublishRelay<Int?>()
let startRequestTrigger = input.viewWillAppearStream.take(1).map { 1 } // 1 is a first page number
let itemsRequestTrigger = input.reloadRequestTrigger.map { 1 } // 1 is a first page number
追加読み込みによるAPIリクエストは、一覧をスクロールして最下部に到達した場合、任意に読み込み発生させたい(PageViewController内)の両方のパターンがあるので、その二つのトリガーをマージします。
let paginationRequestTrigger = PublishRelay.merge(
input.viewDidReachBottom.asObservable()
.throttle(.seconds(1), latest: false, scheduler: MainScheduler.instance),
input.paginationRequestTrigger.asObservable()
.throttle(.seconds(1), latest: false, scheduler: MainScheduler.instance)
)
.withLatestFrom(nextPageNumber)
.compactMap { $0 }
連打防止の為、throttleで無駄なストリームが流れないように制御しています。
(2) 各トリガーをマージして、トリガーが発火したらストリームが実行されるようにする
ここでは、3パターンあるトリガーのうち、どれかが発火されたらストリームが流れるようにマージします。
let load = PublishRelay.merge(startRequestTrigger, paginationRequestTrigger, itemsRequestTrigger).share()
(3) APIを実行して、ページ番号を購読
一覧取得のAPIリクエストを実行しています。
let sequence = load
.flatMapLatest { nextPage -> Observable<Event<ItemResponse>> in
return session.rx
.send(ItemsRequest(page: nextPage))
.asObservable()
.materialize()
}
.share()
ここでは、ItemsRequestの中身については、触れません。
単に、APIKitをラッピングしているのみです。
RxSwiftのmaterializeで、APIの成功のストリームと失敗のストリームに分けて、成功のストリームが流れたときに、ページ番号を購読しています。
let elements = sequence.compactMap { $0.event.element }.share()
elements
.map { $0.pages.nextPage }
.bind(to: nextPageNumber)
.disposed(by: disposeBag)
(4) APIの実行結果を、ストリームの中で保持している前回の結果にマージ
(成功の)ストリームの中で、保持しているデータに前回の結果が含まれていれば、それに新しい結果を追加して、それをストリームに流すようにします。
let elementsWithPagination = elements
.scan([]) { $1.pages.currentPage == 1 ? $1.items : $0 + $1.items }
.startWith([])
.share(replay: 1)
これは、APIの結果が、2ページ目、3ページ目で、リクエストごとに、前ページの結果を含んでいない場合のAPIの仕様の場合で、ストリームの外から前回の結果を差し込まずに、時系列で保持している前回の値を効率よく使う為のロジックです。
最初、ストリームの外から差し込んじゃえというようなロジックを書いてたんですが、スーパーエンジニアにダメ出しされました。
今考えると当たり前ですが。汗
(5) 部分的に変更があった場合、ストリームの中で保持している結果を元に差し替える
これも (6) と同じく、既にストリームの中に流れている値 (elementsWithPagination) を元に、部分的にデータを差し替えるロジックです。
編集を行った際などに、APIリクエストを伴わなずに、差し替えたいなと思った場合、上流のストリームから流してしまうと、かなり複雑なストリームになってしまうので、API実行後のストリームに対し、差し替えを行っています。
let elementsWithChanged = input.changeItemTrigger
.withLatestFrom(elementsWithPagination) { ($0, $1) }
.map { arg -> [Item] in
let item = arg.0
var items = arg.1
if let index = items.firstIndex(where: {$0.id == item.id}) {
items[index] = item
}
return items
}
(6) APIリクエストが必要なトリガーと不要となるトリガーをマージして、ストリームを構成する
(5) で説明したようにAPI実行後のストリームの値に変化をつけて、その結果をViewControllerへ流したいので、本流のストリームとマージして、両方をトリガーとして、流れるようにしています。
let itemsStream = Observable
.merge(elementsWithPagination, input.reloadRequestTrigger.map { [] }, elementsWithChanged)
.share(replay: 1)
ViewController側でこういった処理を行おうとすると、かなり助長なコードになるのが予想できるので、途中で話した**『ViewControllerはアホな方がいい』**というのは、非常に納得できるのではないでしょうか。
(補足) ローディングの表示について
ここまで触れてこなかったのですが、ViewController側で、くるくるのローディング表示を行うに当たって、ストリームが流れているかどうかで判断させることで、ViewControllerは、そのBool値を追いかけるだけでよくなります。
let loading = PublishRelay.merge(load.map { _ in true }, sequence.map { _ in false })
上流のトリガーが発火したタイミングでtrue、ストリームが完了したタイミングでfalseを流すことで、ローディング表示のオンオフが容易になります。
DetailViewControllerについて
今回の説明では、解説は不要なので割愛させて頂きます。
最終的なストリームのイメージ
最終的に組んだストリームのイメージは、こんな感じになります。
厳密には、ちょっと違うかもしれませんが、Itemsの取得に関するストリームは、全てListViewModelの中のストリームを通って、取得している感じです。
まとめ
実は、RxSwiftやリアクティブプログラミングは、Classiに入社する前は、興味はあれど、触ったことがなく、入社してからキャッチアップさせてもらったのですが、さすが教育分野の会社だけあって、社内メンバーの学習意欲の高さや、学習欲に対する理解度は高いです。
オブジェクト指向プログラミングを長くやっていたので、リアクティブプログラミングに切り替えるのは、かなり苦労しましたが、今ではかなり理解度もあがり、オブジェクト指向プログラミングに戻って、助長なプログラムを書いてしまうのが、怖いくらいになりました。
ただ、まだまだリアクティブプログラミングの奥は深く、ただでさえ、正解がないプログラミングの世界で、さらに正解がぼやけてしまう感じで、例えて言うならば、RPGゲームで、どの武器を使ってボスと戦かおうかの選択肢の中に、魔法や属性が加わって、武器の選択だけでは、戦えないといった感じです。
むしろ、リアクティブプログラミングという方法を知ってしまった今は、バル○やパルプン○といった呪文を覚えてしまった感覚にもなり、全てを破壊したり、混乱に陥れる可能性もあるヤバイスキルを身につけてしまった感もあります。
ただ、Appleも、Combineフレームワークを発表したように、時代は、リアクティブプログラミングなど、時系列を考慮したプログラミングを組めるようにならないと、取り残されていくなと感じました。
今回の実装も、複雑な処理は、ほとんど、ViewModelの中にコンパクトに収められていて、RxSwiftを導入していなければ、色々煩雑なロジックを書く必要があったなと、RxSwiftを採用したメリットをかなり感じています。
ここまで来るには、いろんな壁にぶち当たって、その都度、社内のiOSエンジニア(@sussan0416さん)と、現在ClassiのiOSアプリのテクニカルアドバイザーとしてアドバイスを頂いているスーパーエンジニア(@susieyyさん)のご協力もあって、実装できましたので、リスペクトを兼ねて、解説する記事を書くことにしました。
おかげで、自分自身もかなり、RxSwiftやリアクティブプログラミングの理解が深まり、こういった記事が書けるようになったので、本当に感謝しています。
Classiには、こんなアプリエンジニアがいるんだというようなイメージアップに繋がればいいなと思っていますので、是非、今後ともよろしくお願いいたします。