RxSwiftとCoreDataを使用したMVVMの単一ソースオブトゥルース(SSOT)

多くの場合、次の機能をモバイルアプリケーションに実装する必要があります。



  1. 非同期リクエストを作成します
  2. メインスレッドの結果を別のビューにバインドします
  3. 必要に応じて、デバイス上のデータベースをバックグラウンドスレッドで非同期に更新します
  4. これらの操作の実行中にエラーが発生した場合は、通知を表示してください
  5. データの関連性に関するSSOTの原則に準拠する
  6. すべてテストする


この問題の解決は、MVVMRxSwiftおよびCoreDataフレームワークのアーキテクチャアプローチによって大幅に簡素化されます。



以下で説明するアプローチは、リアクティブプログラミングの原則を使用しており、RxSwiftおよびCoreDataだけに関連付けられているわけではありませんまた、必要に応じて、他のツールを使用して実装できます。



例として、販売者データを表示するアプリケーションのスニペットを取り上げます。コントローラには、電話番号とアドレス用の2つのUILabelアウトレットと、この電話番号を呼び出すための1つのUIButtonがあります。ContactsViewController



モデルからビューへの実装について説明します。



モデル





属性を持つ DerivedSourcesからの自動生成ファイルSellerContacts + CoreDataPropertiesのフラグメント



extension SellerContacts {

    @nonobjc public class func fetchRequest() -> NSFetchRequest<SellerContacts> {
        return NSFetchRequest<SellerContacts>(entityName: "SellerContacts")
    }

    @NSManaged public var address: String?
    @NSManaged public var order: Int16
    @NSManaged public var phone: String?

}


リポジトリ



売り手データを提供する方法:



func sellerContacts() -> Observable<Event<[SellerContacts]>> {
        // 1
        Observable.merge([
            // 2
            context.rx.entities(fetchRequest: SellerContacts.fetchRequestWithSort()).materialize(),
            // 3
            updater.sync()
        ])
    }


ここにSSOTが実装されています。要求がに行われCoreData、そしてCoreDataをされ、必要に応じて更新します。すべてのデータはデータベースからのみ受信され、updater.sync()はエラーのあるイベントのみを生成できますが、データのイベントは生成できません。



  1. マージ演算子を使用すると、データベースへのクエリの非同期実行とその更新を実現できます。
  2. データベースへのクエリを作成するのに便利なように、RxCoreDataが使用されます
  3. データベースの更新


なぜなら データの受信と更新の非同期アプローチが使用されている場合は、Observable <Event <... >>を使用する必要がありますこれは、サブスクライバーがリモートデータの受信中にエラーを受信したときにエラーを受信せず、このエラーのみを表示し、CoreDataの変更に応答し続けるために必要です。これについては後で詳しく説明します。



DatabaseUpdater

サンプルアプリケーションでは、リモートデータはFirebase RemoteConfigから取得されますCoreDataは、fetchAndActivate().successFetchedFromRemoteステータスで終了した場合にのみ更新されます



ただし、時間など、他の更新制限を使用できます。

データベースを更新するためのSync()メソッド:



func sync<T>() -> Observable<Event<T>> {
        // 1
        // Check can fetch
        if fetchLimiter.fetchInProcess {
            return Observable.empty()
        }
        // 2
        // Block fetch for other requests
        fetchLimiter.fetchInProcess = true
        // 3
        // Fetch & activate remote config
        return remoteConfig.rx.fetchAndActivate().flatMap { [weak self] status, error -> Observable<Event<T>> in
            // 4
            // Default result
            var result = Observable<Event<T>>.empty()
            // Update database only when config wethed from remote
            switch status {
            // 5
            case .error:
                let error = error ?? AppError.unknown
                print("Remote config fetch error: \(error.localizedDescription)")
                // Set error to result
                result = Observable.just(Event.error(error))
            // 6
            case .successFetchedFromRemote:
                print("Remote config fetched data from remote")
                // Update database from remote config
                try self?.update()
            case .successUsingPreFetchedData:
                print("Remote config using prefetched data")
            @unknown default:
                print("Remote config unknown status")
            }
            // 7
            // Unblock fetch for other requests
            self?.fetchLimiter.fetchInProcess = false
            return result
        }
    }


  1. , . , sync(). fetchLimiter . , fetchInProcess .
  2. Event


ViewModel

この例では、 ViewModelリポジトリからsellerContacts()メソッドを呼び出すだけで、結果を返します。



func contacts() -> Observable<Event<[SellerContacts]>> {
        repository.sellerContacts()
    }


ViewController

コントローラーで、クエリ結果をフィールドにバインドする必要があります。これを行うには、 bindContacts()メソッドがviewDidLoad()で呼び出されます



private func bindContacts() {
        // 1
        viewModel?.contacts()
            .subscribeOn(SerialDispatchQueueScheduler.init(qos: .userInteractive))
            .observeOn(MainScheduler.instance)
             // 2
            .flatMapError { [weak self] in
                self?.rx.showMessage($0.localizedDescription) ?? Observable.empty()
            }
             // 3
            .compactMap { $0.first }
             // 4
            .subscribe(onNext: { [weak self] in
                self?.phone.text = $0.phone
                self?.address.text = $0.address
            }).disposed(by: disposeBag)
    }


  1. バックグラウンドスレッドで連絡先のリクエストを実行し、その結果、メインで作業します
  2. イベントを含む要素がエラーで到着した場合、エラーメッセージが表示され、空のシーケンスが返されます。以下のflatMapErrorおよびshowMessage演算子の詳細
  3. CompactMap演算子使用して配列から連絡先を取得する
  4. アウトレットへのデータの設定


演算子.flatMapError()

シーケンスの結果をイベントから含まれる要素に変換する、またはエラーを表示するには、次の演算子を使用します。



func flatMapError<T>(_ handler: ((_ error: Error) -> Observable<T>)? = nil) -> Observable<Element.Element> {
        // 1
        flatMap { element -> Observable<Element.Element> in
            switch element.event {
            // 2
            case .error(let error):
                return handler?(error).flatMap { _ in Observable<Element.Element>.empty() } ?? Observable.empty()
            // 3
            case .next(let element):
                return Observable.just(element)
            // 4
            default:
                return Observable.empty()
            }
        }
    }


  1. シーケンスをEvent.ElementからElementに変換します
  2. イベントにエラーが含まれている場合は、空のシーケンスに変換されたハンドラーを返します
  3. イベントに結果が含まれている場合は、この結果を含む1つの要素を持つシーケンスを返します。
  4. デフォルトでは空のシーケンスが返されます


このアプローチにより、サブスクライバーにエラーイベントを送信せずにクエリ実行エラーを処理できます。また、データベースの変更の監視はアクティブなままです。



演算子.showMessage()

ユーザーにメッセージを表示するには、次の演算子を使用します。



public func showMessage(_ text: String, withEvent: Bool = false) -> Observable<Void> {
        // 1
        let _alert = alert(title: nil,
              message: text,
              actions: [AlertAction(title: "OK", style: .default)]
        // 2
        ).map { _ in () }
        // 3
        return withEvent ? _alert : _alert.flatMap { Observable.empty() }
    }


  1. RxAlertのウィンドウがメッセージと、単一のボタンを使用して作成されました
  2. 結果はVoidに変換されます
  3. メッセージを表示した後にイベントが必要な場合は、結果を返します。それ以外の場合は、最初にそれを空のシーケンスに変換してから、


なぜなら .showMessage()は、エラー通知を表示するためだけでなく、シーケンスが空であるかイベントがあるかを調整できると便利です。



テスト



上記のすべてをテストすることは難しくありません。プレゼンテーションの順番から始めましょう。



RepositoryTests DatabaseUpdaterMockは

、リポジトリをテストするために使用されますここで、sync()メソッドが呼び出されたかどうかを追跡し、その実行結果を設定することができます。



func testSellerContacts() throws {
        // 1
        // Success
        // Check sequence contains only one element
        XCTAssertThrowsError(try repository.sellerContacts().take(2).toBlocking(timeout: 1).toArray())
        updater.isSync = false
        // Check that element
        var result = try repository.sellerContacts().toBlocking().first()?.element
        XCTAssertTrue(updater.isSync)
        XCTAssertEqual(result?.count, sellerContacts.count)

        // 2
        // Sync error
        updater.isSync = false
        updater.error = AppError.unknown
        let resultArray = try repository.sellerContacts().take(2).toBlocking().toArray()
        XCTAssertTrue(resultArray.contains { $0.error?.localizedDescription == AppError.unknown.localizedDescription })
        XCTAssertTrue(updater.isSync)
        result = resultArray.first { $0.error == nil }?.element
        XCTAssertEqual(result?.count, sellerContacts.count)
    }


  1. シーケンスに要素が1つだけ含まれていることを確認し、sync()メソッドを呼び出します
  2. シーケンスに2つの要素が含まれていることを確認します。1つにはエラーのあるイベント含まれ、もう1つにはデータベースからのクエリの結果が含まれ、sync()メソッドが呼び出されます。


DatabaseUpdaterTests



testSync()
func testSync() throws {
        let remoteConfig = RemoteConfigMock()
        let fetchLimiter = FetchLimiter(serialQueue: DispatchQueue(label: "test"))
        let databaseUpdater = DatabaseUpdaterImpl(remoteConfig: remoteConfig, decoder: JSONDecoderMock(), context: context, fetchLimiter: fetchLimiter)
        // 1
        // Not update. Fetch in process
        fetchLimiter.fetchInProcess = true
        XCTAssertFalse(remoteConfig.isFetchAndActivate)
        XCTAssertFalse(remoteConfig.isSubscript)
        
        expectation(forNotification: .NSManagedObjectContextDidSave, object: context)
            .isInverted = true
    
        var sync: Observable<Event<Void>> = databaseUpdater.sync()
        XCTAssertNil(try sync.toBlocking().first())
        XCTAssertFalse(remoteConfig.isFetchAndActivate)
        XCTAssertFalse(remoteConfig.isSubscript)
        XCTAssertTrue(fetchLimiter.fetchInProcess)
        
        waitForExpectations(timeout: 1)
        // 2
        // Not update. successUsingPreFetchedData
        fetchLimiter.fetchInProcess = false
        
        expectation(forNotification: .NSManagedObjectContextDidSave, object: context)
            .isInverted = true
        
        sync = databaseUpdater.sync()
        var result: Event<Void>?
        sync.subscribe(onNext: { result = $0 }).disposed(by: disposeBag)
        XCTAssertTrue(fetchLimiter.fetchInProcess)
        remoteConfig.completionHandler?(RemoteConfigFetchAndActivateStatus.successUsingPreFetchedData, nil)
        
        waitForExpectations(timeout: 1)
        XCTAssertNil(result)
        XCTAssertTrue(remoteConfig.isFetchAndActivate)
        XCTAssertFalse(remoteConfig.isSubscript)
        XCTAssertFalse(fetchLimiter.fetchInProcess)
        // 3
        // Not update. Error
        fetchLimiter.fetchInProcess = false
        remoteConfig.isFetchAndActivate = false
        
        expectation(forNotification: .NSManagedObjectContextDidSave, object: context)
            .isInverted = true
        sync = databaseUpdater.sync()
        sync.subscribe(onNext: { result = $0 }).disposed(by: disposeBag)
        XCTAssertTrue(fetchLimiter.fetchInProcess)
        remoteConfig.completionHandler?(RemoteConfigFetchAndActivateStatus.error, AppError.unknown)
        
        waitForExpectations(timeout: 1)
        
        XCTAssertEqual(result?.error?.localizedDescription, AppError.unknown.localizedDescription)
        XCTAssertTrue(remoteConfig.isFetchAndActivate)
        XCTAssertFalse(remoteConfig.isSubscript)
        XCTAssertFalse(fetchLimiter.fetchInProcess)
        // 4
        // Update
        fetchLimiter.fetchInProcess = false
        remoteConfig.isFetchAndActivate = false
        result = nil
        
        expectation(forNotification: .NSManagedObjectContextDidSave, object: context)
        
        sync = databaseUpdater.sync()
        sync.subscribe(onNext: { result = $0 }).disposed(by: disposeBag)
        XCTAssertTrue(fetchLimiter.fetchInProcess)
        remoteConfig.completionHandler?(RemoteConfigFetchAndActivateStatus.successFetchedFromRemote, nil)
        
        waitForExpectations(timeout: 1)
        
        XCTAssertNil(result)
        XCTAssertTrue(remoteConfig.isFetchAndActivate)
        XCTAssertTrue(remoteConfig.isSubscript)
        XCTAssertFalse(fetchLimiter.fetchInProcess)
    }




  1. 更新が進行中の場合、空のシーケンスが返されます
  2. データが受信されない場合、空のシーケンスが返されます
  3. イベントがエラーで返されます
  4. データが更新されている場合は、空のシーケンスが返されます


ViewModelTests



ViewControllerTests



testBindContacts()
func testBindContacts() {
        // 1
        // Error. Show message
        XCTAssertNotEqual(controller.phone.text, contacts.phone)
        XCTAssertNotEqual(controller.address.text, contacts.address)
        viewModel.contactsResult.accept(Event.error(AppError.unknown))
        
        expectation(description: "wait 1 second").isInverted = true
        waitForExpectations(timeout: 1)
        // 2
        XCTAssertNotNil(controller.presentedViewController)
        let alertController = controller.presentedViewController as! UIAlertController
        XCTAssertEqual(alertController.actions.count, 1)
        XCTAssertEqual(alertController.actions.first?.style, .default)
        XCTAssertEqual(alertController.actions.first?.title, "OK")
        XCTAssertNotEqual(controller.phone.text, contacts.phone)
        XCTAssertNotEqual(controller.address.text, contacts.address)
        // 3
        // Trigger action OK
        let action = alertController.actions.first!
        typealias AlertHandler = @convention(block) (UIAlertAction) -> Void
        let block = action.value(forKey: "handler")
        let blockPtr = UnsafeRawPointer(Unmanaged<AnyObject>.passUnretained(block as AnyObject).toOpaque())
        let handler = unsafeBitCast(blockPtr, to: AlertHandler.self)
        handler(action)
        
        expectation(description: "wait 1 second").isInverted = true
        waitForExpectations(timeout: 1)
        // 4
        XCTAssertNil(controller.presentedViewController)
        XCTAssertNotEqual(controller.phone.text, contacts.phone)
        XCTAssertNotEqual(controller.address.text, contacts.address)
        // 5
        // Empty array of contats
        viewModel.contactsResult.accept(Event.next([]))
        
        expectation(description: "wait 1 second").isInverted = true
        waitForExpectations(timeout: 1)
        
        XCTAssertNil(controller.presentedViewController)
        XCTAssertNotEqual(controller.phone.text, contacts.phone)
        XCTAssertNotEqual(controller.address.text, contacts.address)
        // 6
        // Success
        viewModel.contactsResult.accept(Event.next([contacts]))
        
        expectation(description: "wait 1 second").isInverted = true
        waitForExpectations(timeout: 1)
        
        XCTAssertNil(controller.presentedViewController)
        XCTAssertEqual(controller.phone.text, contacts.phone)
        XCTAssertEqual(controller.address.text, contacts.address)
    }




  1. エラーメッセージを表示する
  2. controller.presentedViewControllerにエラーメッセージあることを確認します
  3. [OK]ボタンのハンドラーを実行し、メッセージボックスが非表示になっていることを確認します
  4. 空の結果の場合、エラーは表示されず、フィールドは入力されません
  5. リクエストが成功した場合、エラーは表示されず、フィールドに入力されます


オペレーターテスト



.flatMapError()。

showMessage()



同様の設計アプローチを使用して、SSOTの原則に従って、データ変更に応答する機能を失うことなく、非同期データ取得、データ更新、およびエラー通知を実装します



All Articles