RxSwift初心者です。完全自分用の学習備忘録として残します。
開発中、RxSwiftで以下の実装方法に詰まりました。
- ユーザーが誤ってデータをDBへ保存せずに、アプリをキルしようとする
- アプリがバックグラウンドに移行した際にローカルにデータを保存
- アプリがキルされ、再度、アプリを起動する
- ローカルに保存されたデータの有無を確認
- データがある場合、「前回保存されていないデータがあります。データを保存しますか?」という文言で
UIAlert
を表示 - 保存ボタンを押下し、データを保存する非同期処理を実行
- 非同期処理中、仮にエラーが発生するとする
- 画面上に「エラー データの保存に失敗しました。保存しますか?」というエラー
UIAlert
を表示 -
6, 7, 8
を繰り返す
この記事では、3 〜 9
を扱い、1 〜 2
は別の記事**「[RxSwift]詰み① アプリキル直前にデータを保存したい」**で扱います。
3. アプリがキルされ、再度、アプリを起動する
ここまでの流れを簡単におさらいします。
ユーザーがアプリをキルしようとする → Realmでローカルにデータ保存 → アプリがキルされる → アプリを再度起動
4. ローカルに保存されたデータの有無を確認
ローカルに保存されたデータはFirebase Firestore
に保存しなければなりません。
ローカルに保存されたデータを取得し、データがない場合は、後続処理に進まないよう、処理を止めます。
-
ViewModel
import Foundation import RxSwift import RxCocoa class ViewModel { // 更新保留中のデータと保存時に発生するエラーを流す(外部公開) var pendingUpdateData: Driver<(pendingUpdateData: PendingUpdateData?, dataSaveError: MyAppError?)> { return self.pendingUpdateDataRelay.asDriver() } // 更新保留中のデータ保存/削除完了か否か(外部公開) var isPendingUpdateDataHandlingCompleted: Driver<Bool> { return self.isPendingUpdateDataHandlingCompletedRelay.asDriver() } // 更新保留中のデータと保存時に発生するエラーを流す private let pendingUpdateDataRelay = BehaviorRelay<(pendingUpdateData: PendingUpdateData?, dataSaveError: MyAppError?)>(value: (nil, nil)) // 更新保留中のデータの保存/削除完了 private let isPendingUpdateDataHandlingCompletedRelay = BehaviorRelay<Bool>(value: false) // realmServiceはRealmのデータ操作(取得、削除、保存)処理を持つ private let realmService: RealmServiceProtocol private let disposeBag = DisposeBag() init( realmService: RealmServiceProtocol ) { self.realmService = realmService // ローカルに保存した(更新保留中の)データを取得 realmService.fetchPendingUpdateData() .compactMap { $0 } // nil(データがない場合)は処理を止める .subscribe(onNext: { [weak self] pendingUpdateData in // データがある場合は、Relayにデータを流す // Firestoreへの保存処理時に発生したエラーがあれば、dataSaveErrorとして流す(ここではデータ取得処理のみでデータ保存はまだなのでnilを設定) self?.pendingUpdateDataRelay.accept((pendingUpdateData, dataSaveError: nil)) }) .disposed(by: disposeBag) } }
ここでは、細かい処理は飛ばして、Realm
を使用して、データの保存、取得、削除処理が定義されているという認識だけで十分です。
データ取得処理のみ、 func fetchPendingUpdateData() -> Observable<PendingUpdateData?> {}
で、データがあれば、onNext()
でデータを、なければ、nil
を流しています。
-
Realm
import Foundation import Realm import Firebase import RxSwift import RealmSwift protocol RealmServiceProtocol { // 更新保留中のデータ保存 func savePendingUpdateData(pendingUpdateData: PendingUpdateData) // 更新保留中のデータ取得 func fetchPendingUpdateData() -> Observable<PendingUpdateData?> // 更新保留中のデータ削除 func deletePendingUpdateData() -> Void } final class RealmService: RealmServiceProtocol { public static let shared = RealmService() private let realm = try! Realm() private init() {} func savePendingUpdateData(pendingUpdateData: PendingUpdateData) { do { try realm.write { if let userId = FBAuth.currentUserId, let pendingUpdateDataToDelete = self.realm.objects(PendingUpdateData.self).first(where: { $0.userId == userId }) { realm.delete(pendingUpdateDataToDelete) } else { print("更新保留中のデータが存在しません。") } realm.add(pendingUpdateData) } } catch { print("更新保留中のデータ保存失敗: \(error)") } } func fetchPendingUpdateData() -> Observable<PendingUpdateData?> { return Observable.create { [weak self] observer in if let self = self, let userId = FBAuth.currentUserId, let pendingUpdateData = self.realm.objects(PendingUpdateData.self).first(where: { $0.userId == userId }) { observer.onNext(pendingUpdateData) } else { print("更新保留中のデータが存在しません。") observer.onNext(nil) } observer.onCompleted() return Disposables.create() } } func deletePendingUpdateData() -> Void { do { try realm.write { if let userId = FBAuth.currentUserId, let pendingUpdateData = self.realm.objects(PendingUpdateData.self).first(where: { $0.userId == userId }) { realm.delete(pendingUpdateData) } else { print("更新保留中のデータが存在しません。") } } } catch { print("更新保留中のデータ保存失敗 エラー内容: \(error)") } } }
5. データがある場合、「前回保存されていないデータがあります。データを保存しますか?」という文言でUIAlert
を表示
ViewModel
のpendingUpdateDataRelay.accept
で値を流し
// 更新保留中のデータと保存時に発生するエラーを流す(外部公開)
var pendingUpdateData: Driver<(pendingUpdateData: PendingUpdateData?, dataSaveError: MyAppError?)> {
return self.pendingUpdateDataRelay.asDriver()
}
// 更新保留中のデータと保存時に発生するエラーを流す
private let pendingUpdateDataRelay = BehaviorRelay<(pendingUpdateData: PendingUpdateData?, dataSaveError: MyAppError?)>(value: (nil, nil))
// 値を流す
self?.pendingUpdateDataRelay.accept((pendingUpdateData, dataSaveError: nil))
ViewController
側では、viewModel.pendingUpdateData
でアクセスし、流れてくる値を購読します。
-
ViewController
class ViewController: KRProgressHUDEnabled, AlertEnabled { private func bind() { self.viewModel = MapViewModel( realmService: RealmService.shared ) // 更新保留中のデータと保存時に発生するエラー(ない場合はnil)が流れてくる viewModel.pendingUpdateData .filter { $0.pendingUpdateData != nil } // データがなければ処理中断 .map { (($0.pendingUpdateData!, $0.dataSaveError)) } // データを整形 .drive(showSavePendingUpdateDataAlert) // 前回保存していないデータがあることをUIAlertを表示して伝える .disposed(by: disposeBag) // 更新保留中のデータ更新/削除完了後 viewModel.isPendingUpdateDataHandlingCompleted .filter { $0 } .flatMap { [weak self] _ in guard let self = self else { return .empty() } // ここに、更新保留中のデータ更新/削除完了後に行いたい処理があれば記述する ........ ........ } .disposed(by: disposeBag) } } extension ViewController { // 「前回保存されていないデータがあります。データを保存しますか?」という文言でUIAlertを表示する。 // 更新保留中の勉強記録データの保存、保存しない場合は削除を行う private var showSavePendingUpdateDataAlert: Binder<(PendingUpdateData, dataSaveError: MyAppError?)> { return Binder(self) { base, tuple in let (pendingUpdateData, dataSaveError) = tuple let alertActionType = AlertActionType.savePendingUpdateData( dataSaveError: dataSaveError ?? nil, // 初回はdataSaveErrorはないため、nilが指定される onConfirm: { // 保存処理 base.viewModel.handlePendingUpdateData(pendingUpdateData: pendingUpdateData) }, onCancel: { // 削除処理 base.viewModel.handlePendingUpdateData(pendingUpdateData: pendingUpdateData, shouldSave: false) } ) // UIAlert表示 base.rx.showAlert.onNext(alertActionType) } } }
-
MyAppError
import Foundation enum MyAppError: Error { case savePendingUpdateDataFailed(Error) // MARK: - LocalizedError Implementation public var errorDescription: String? { #if DEVELOP // 開発 return debugDescription #else // 本番 return description #endif } // MARK: - Localized Descriptions (日本語版) var description: String { switch self { case .savePendingUpdateDataFailed: return NSLocalizedString("前回の勉強記録の保存に失敗しました。", comment: "勉強記録保存失敗のメッセージ") } } // MARK: - Debug Descriptions (デバッグ用) var debugDescription: String { switch self { case .savePendingUpdateDataFailed(let error): return NSLocalizedString("前回の勉強記録の保存に失敗しました。エラー: \(String(describing: error)).", comment: "勉強記録保存失敗のメッセージ") } } }
UIAlert
の設定情報を定義しています。
-
AlertActionType
import Foundation import UIKit enum AlertActionType { case savePendingUpdateData(dataSaveError: MyAppError? = nil, onConfirm: () -> Void, onCancel: () -> Void) var comfirmActionStyle: UIAlertAction.Style { switch self { default: return .default } } var cancelActionStyle: UIAlertAction.Style { switch self { default: return .cancel } } var title: String { switch self { case .savePendingUpdateData(let dataSaveError, _, _): // データ保存時のエラーの有無に応じて、UIAlertに表示する文言を場合分けする return dataSaveError != nil ? "エラー" : "" } } var message: String { switch self { case .savePendingUpdateData(let dataSaveError, _, _): // データ保存時のエラーの有無に応じて、UIAlertに表示する文言を場合分けする return "\(dataSaveError?.errorDescription ?? "前回の勉強記録が保存されていません。")\n保存しますか?" default: return "" } } var onComfirmTitle: String { switch self { case .savePendingUpdateData: return "保存" default: return "OK" } } var onCancelTitle: String { switch self { case .savePendingUpdateData: return "破棄" default: return "キャンセル" } } // キャンセルアクションの表示/非表示 var shouldShowCancelAction: Bool { switch self { default: return true } } // Confirm と Cancel のハンドラー var handlers: (onConfirm: () -> Void, onCancel: () -> Void) { switch self { case .savePendingUpdateData(_, let onConfirm, let onCancel): return (onConfirm: { _, _ in onConfirm() }, onCancel: { onCancel() }) } } }
AlertActionType
で設定された情報をもとに、 UIAlert
を生成&表示させます。
-
AlertEnabled
import Foundation import UIKit import RxSwift import RxCocoa import Firebase import FirebaseAuth protocol AlertEnabled: UIViewController {} extension Reactive where Base: AlertEnabled { var showAlert: Binder<AlertActionType> { return Binder(self.base, binding: { base, alertAction in let alertController = UIAlertController(title: "", message: "", preferredStyle: .alert) let titleAttributes = [NSAttributedString.Key.font: UIFont(name: "HiraginoSans-W6", size: 18)!, NSAttributedString.Key.foregroundColor: UIColor.black] let titleString = NSAttributedString(string: alertAction.title, attributes: titleAttributes) let messageAttributes = [NSAttributedString.Key.font: UIFont(name: "HiraginoSans-W3", size: 14)!, NSAttributedString.Key.foregroundColor: UIColor.black] let messageString = NSAttributedString(string: alertAction.message, attributes: messageAttributes) alertController.setValue(titleString, forKey: "attributedTitle") alertController.setValue(messageString, forKey: "attributedMessage") // Confirm アクション let confirmAction = UIAlertAction( title: alertAction.onComfirmTitle, style: alertAction.comfirmActionStyle, handler: { _ in alertAction.handlers.onConfirm(nil, nil) }) alertController.addAction(confirmAction) // Cancel アクション if alertAction.shouldShowCancelAction { let cancelAction = UIAlertAction( title: alertAction.onCancelTitle, style: alertAction.cancelActionStyle, handler: { _ in alertAction.handlers.onCancel() }) alertController.addAction(cancelAction) } base.present(alertController, animated: true) }) } }
6. 保存ボタンを押下し、データを保存する非同期処理を実行、7. 非同期処理中、仮にエラーが発生するとする
handlePendingUpdateData
でFirebaseへのデータ保存/削除処理を実行します。
-
ViewModel
extension ViewModel { // 更新保留中の勉強記録データの保存/削除 func handlePendingUpdateData(pendingUpdateData: PendingUpdateData, shouldSave: Bool = true) { if shouldSave { // 保存処理(ここではzipを使用して非同期処理を実行していますが、プロジェクトに合わせて適宜調整してください let combinedObservableResult = Observable.zip( // Firenaseへの保存処理等を行うメソッドをzipの中に入れる // ここで仮にエラーが発生した場合、下のcatchでエラーを捕捉します ) .catch { [weak self] error in // ここで「pendingUpdateDataRelay」にMyAppError型の「savePendingUpdateDataFailed」エラーとともに、 // 再度、更新保留中のデータ「pendingUpdateData」を流す self?.pendingUpdateDataRelay.accept((pendingUpdateData, .savePendingUpdateDataFailed(error))) return .empty() } combinedObservableResult .subscribe(onNext: { [weak self] _, _, _ in self?.realmService.deletePendingUpdateData() self?.isPendingUpdateDataHandlingCompletedRelay.accept(true) }) .disposed(by: disposeBag) } else { // 削除処理 self?.realmService.deletePendingUpdateData() self?.isPendingUpdateDataHandlingCompletedRelay.accept(true) } } }
8. 画面上に「エラー データの保存に失敗しました。保存しますか?」というエラーUIAlert
を表示
handlePendingUpdateData()
でデータ保存処理エラーが発生した場合、catch
内で
self?.pendingUpdateDataRelay.accept((pendingUpdateData, .savePendingUpdateDataFailed(error)))
とすることで、ViewModel
に外部公開用として定義したDriver
に値が流れ、
// 更新保留中のデータと保存時に発生するエラーを流す(外部公開)
var pendingUpdateData: Driver<(pendingUpdateData: PendingUpdateData?, dataSaveError: MyAppError?)> {
return self.pendingUpdateDataRelay.asDriver()
}
// 更新保留中のデータと保存時に発生するエラーを流す
private let pendingUpdateDataRelay = BehaviorRelay<(pendingUpdateData: PendingUpdateData?, dataSaveError: MyAppError?)>(value: (nil, nil))
ViewController
のbind()
内の以下の処理でviewModel.pendingUpdateData
に流れきた値を購読し、再度、UIAlertを表示します。
// 更新保留中のデータと保存時に発生したエラー(ない場合はnil)が流れてくる
viewModel.pendingUpdateData
.filter { $0.pendingUpdateData != nil } // データがなければ処理中断
.map { (($0.pendingUpdateData!, $0.dataSaveError)) } // データを整形
.drive(showSavePendingUpdateDataAlert) // 前回保存していないデータがあることをUIAlertを表示して伝える
.disposed(by: disposeBag)
が、今度は、データ保存処理エラーdataSaveError: MyAppError.savePendingUpdateDataFailed(error)
も一緒に流れてきているため、AlertActionType.savePendingUpdateData()
の引数であるdataSaveError
にnil
は渡されず、MyAppError.savePendingUpdateDataFailed(error)
が渡されます。
そうすることで、AlertActionType
の var message: String {}, var title: String {}
の処理にて、 UIAlert
に表示する文言も変わるようにしています。
extension ViewController {
// 「前回保存されていないデータがあります。データを保存しますか?」という文言でUIAlertを表示するが、
// もし、saveDataErrorがnilでなければ、「エラー データの保存に失敗しました。保存しますか?」と言う文言でUIAlertを表示する
// 更新保留中の勉強記録データの保存、保存しない場合は削除を行う
private var showSavePendingUpdateDataAlert: Binder<(PendingUpdateData, dataSaveError: MyAppError?)> {
return Binder(self) { base, tuple in
let (pendingUpdateData, dataSaveError) = tuple
let alertActionType = AlertActionType.savePendingUpdateData(
dataSaveError: dataSaveError ?? nil, // ここで渡される値によってUIAlertに表示する文言が変わる
. onConfirm: {
// 保存処理
base.viewModel.handlePendingUpdateData(pendingUpdateData: pendingUpdateData)
},
onCancel: {
// 削除処理
base.viewModel.handlePendingUpdateData(pendingUpdateData: pendingUpdateData, shouldSave: false)
}
)
// UIAlert表示
base.rx.showAlert.onNext(alertActionType)
}
}
}
9. 6, 7, 8
を繰り返す
handlePendingUpdateData()
データ保存処理を行い、エラーが発生するたびに、6, 7, 8
の処理の流れを繰り返します。