带有RxSwift和CoreData的MVVM上的单一真相(SSOT)

通常,以下功能需要在移动应用程序中实现:



  1. 发出异步请求
  2. 将主线程中的结果绑定到不同的视图
  3. 如有必要,在后台线程中异步更新设备上的数据库
  4. 如果执行这些操作时发生错误,则显示通知
  5. 符合SSOT原则以实现数据相关性
  6. 全部测试


MVVM 的体系结构方法以及RxSwiftCoreData框架大大简化了解决此问题步骤



下述方法使用反应式编程原理,并不专门与RxSwiftCoreData绑定并且,如果需要,可以使用其他工具来实现。



例如,我将显示显示卖方数据的应用程序的摘要。控制器具有两个用于电话号码和地址的UILabel插座,以及一个用于呼叫该电话号码的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 Remote Config检索远程数据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

在控制器中,您需要将查询结果绑定到字段。为此,viewDidLoad()中调用bindContacts()方法



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. 如果包含事件的元素到达时出现错误,则会显示错误消息并返回空序列。下面有关flatMapErrorshowMessage运算符的更多信息
  3. 使用compactMap运算符从数组获取联系人
  4. 将数据设置到插座


运算符.flatMapError()

要将序列的结果从Event转换为它包含的元素或显示错误,请使用运算符:



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. 如果Event包含结果,则返回一个包含一个包含该结果的元素的序列。
  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. 结果转换为虚空
  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. 我们检查序列中是否只包含一个元素,调用了sync()方法
  2. 我们检查序列是否包含两个元素。一个包含有错误事件另一个包含从数据库中查询的结果,调用sync()方法


DatabaseUpdater测试



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. 运行确定按钮的处理程序,并确保该消息框是隐藏的
  4. 对于空结果,不显示任何错误,并且不填充任何字段
  5. 对于成功的请求,不会显示任何错误,并且必须填写字段


操作员测试



.flatMapError().

showMessage()



使用类似的设计方法,我们遵循SSOT原理,实现了异步数据获取,更新和错误通知,而不会失去响应数据更改的能力



All Articles