0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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

    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を表示

ViewModelpendingUpdateDataRelay.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))
  

ViewControllerbind() 内の以下の処理で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() の引数であるdataSaveErrornil は渡されず、MyAppError.savePendingUpdateDataFailed(error) が渡されます。

そうすることで、AlertActionTypevar 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の処理の流れを繰り返します。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?