[RxSwift]詰み② UIAlertを活用してデータ保存の失敗時に繰り返しリトライさせたい

RxSwift初心者です。完全自分用の学習備忘録として残します。

開発中、RxSwiftで以下の実装方法に詰まりました。

1. ユーザーが誤ってデータをDBへ保存せずに、アプリをキルしようとする
2. アプリがバックグラウンドに移行した際にローカルにデータを保存 
3. アプリがキルされ、再度、アプリを起動する
4. ローカルに保存されたデータの有無を確認
5. データがある場合、「前回保存されていないデータがあります。データを保存しますか？」という文言で`UIAlert`を表示
6. 保存ボタンを押下し、データを保存する非同期処理を実行
7. 非同期処理中、仮にエラーが発生するとする
8. 画面上に「エラー　データの保存に失敗しました。保存しますか？」というエラー`UIAlert`を表示
9. `6, 7, 8` を繰り返す

この記事では、`3 〜 9` を扱い、`1 〜 2`は別の記事**「[RxSwift]詰み① アプリキル直前にデータを保存したい」**で扱います。

### 3. アプリがキルされ、再度、アプリを起動する

ここまでの流れを簡単におさらいします。

`ユーザーがアプリをキルしようとする → Realmでローカルにデータ保存 → アプリがキルされる → アプリを再度起動`

### 4. ローカルに保存されたデータの有無を確認

ローカルに保存されたデータは`Firebase Firestore`に保存しなければなりません。

ローカルに保存されたデータを取得し、データがない場合は、後続処理に進まないよう、処理を止めます。

- ViewModel
    
    ```swift
    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
    
    ```swift
    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` で値を流し

```swift
    // 更新保留中のデータと保存時に発生するエラーを流す（外部公開）
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
    
    ```swift
    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
    
    ```swift
    
    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
    
    ```swift
    
    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
    
    ```swift
    
    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
    
    ```swift
    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`内で

```swift
self?.pendingUpdateDataRelay.accept((pendingUpdateData, .savePendingUpdateDataFailed(error)))
```

とすることで、`ViewModel` に外部公開用として定義した`Driver` に値が流れ、

```swift
 // 更新保留中のデータと保存時に発生するエラーを流す（外部公開）
    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を表示します。

```swift
  // 更新保留中のデータと保存時に発生したエラー（ない場合は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`に表示する文言も変わるようにしています。

```swift
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`の処理の流れを繰り返します。