Единый Источник Достоверных Данных (Ssot) На Mvvm С Помощью Rxswift И Coredata

Часто в мобильном приложении необходимо реализовать следующий функционал:

  1. Выполнить асинхронный запрос
  2. Привязка результата в основном потоке к различным представлениям
  3. При необходимости обновите базу данных на устройстве асинхронно в фоновом потоке.

  4. Если при выполнении этих операций возникают ошибки, то показывать уведомление
  5. Уважайте принцип ССОТ для актуальности данных
  6. Проверьте все это
Решение этой проблемы значительно упрощает архитектурный подход. МВВМ и рамки RxSwift , CoreData .

Описанный ниже подход использует принципы реактивного программирования и не привязан исключительно к RxSwift И CoreData .

А при желании это можно реализовать и с помощью других инструментов.

В качестве примера возьму фрагмент приложения, отображающего данные о продавце.

Контроллер имеет два выхода UILabel для телефона и адреса и один UIButton для вызова этого телефона.

КонтактыViewController .

Я объясню реализацию от модели к представлению.



Модель

Фрагмент автоматически созданного файла SellerContacts+CoreDataProperties из DerivedSources. с атрибутами:
  
  
  
  
  
  
  
  
  
  
  
  
   

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 container.viewContext.rx.entities(fetchRequest: SellerContacts.fetchRequestWithSort()).

materialize(), // 3 updater.sync() ]) }

Именно в этом месте это реализуется ССОТ .

Запрос делается на CoreData , И CoreData обновляется при необходимости.

Все данные получаются ТОЛЬКО из базы данных, и апдейтер.

синхронизация() может генерировать Событие только с ошибкой, но НЕ с данными.

  1. Использование оператора слияния позволяет добиться асинхронного выполнения запроса к базе данных и ее обновления.

  2. Для удобства построения запроса к базе данных используйте RxCoreData
  3. Мы обновляем базу данных
Поскольку используется асинхронный подход к получению и обновлению данных, необходимо использовать наблюдаемый > .

Это необходимо для того, чтобы абонент не получал Error в случае возникновения ошибки при приеме удаленных данных, а лишь показывал эту ошибку и продолжал реагировать на изменения в CoreData .

Подробнее об этом чуть позже.

Обновление базы данных В примере приложения удаленные данные получены из Удаленная конфигурация Firebase .

CoreData обновляется только в том случае, если выборкаИАктивировать() заканчивается статусом .

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 result = self?.

update() ?? Observable.empty() 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 должен быть потокобезопасным.

    А именно получить или записать значения в поле выборка в процессе нужно в последовательной очереди.

  2. Блокировка обновления для последующих вызовов методов
  3. Выполнить запрос на получение удаленных данных
  4. Создайте результат с пустой последовательностью по умолчанию.

  5. Если запрос выполнился с ошибкой, то присвойте результату последовательность с одним элементом Событие с ошибкой
  6. Обновление базы данных
  7. Включить возможность обновления базы и возврата результата


Многопоточность

Чтобы обеспечить многопоточность, мы получаем данные из Core Data viewContext в основном потоке и обновляем данные с помощью метода

func performBackgroundTask(_ block: @escaping (NSManagedObjectContext) -> Void)

из NSPersistentContainer. И не забывайте о

container.viewContext.automaticallyMergesChangesFromParent = true

во время создания контейнера.



/** Update database from remote data */ private func update<T>() -> Observable<Event<T>> { Observable.create { [weak self] observer in self?.

container.performBackgroundTask { [weak self] context in do { .

try self?.

updateSellerContacts(context) .

if context.hasChanges { try context.save() } observer.onCompleted() } catch { context.undo() observer.onNext(Event.error(error)) observer.onCompleted() } } return Disposables.create() }.

subscribeOn(CurrentThreadScheduler.instance) }

ViewModel В этом примере в ViewModel метод просто называется продавецКонтакты() от Репозиторий и результат возвращается.



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

Вьюконтроллер В контроллере нужно привязать результат запроса к полям.

Для этого в просмотрDidLoad() метод называется привязкаКонтакты() :

private func bindContacts() { // 1 viewModel?.

contacts() .

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. Если элемент, содержащий Событие в случае ошибки отображается сообщение об ошибке и возвращается пустая последовательность.

    Подробнее об операторе квартирамаперрор И показать сообщение ниже

  3. Мы используем оператор компактнаяКарта получить контакты из массива
  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. Преобразуем последовательность из Событие.

    Элемент В Элемент

  2. Если Событие содержит ошибку, то мы возвращаем обработчик, преобразованный в пустую последовательность
  3. Если Событие содержит результат, то мы возвращаем последовательность с одним элементом, содержащим этот результат
  4. По умолчанию возвращается пустая последовательность
Такой подход позволяет обрабатывать ошибки выполнения запроса без отправки подписчику события Error Event. А мониторинг изменений в базе данных остается активным.

Оператор .

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() можно использовать не только для отображения уведомлений об ошибках, полезно иметь возможность контролировать, какая последовательность окажется в итоге — пустой или с событием.



Тесты

Все описанное выше не сложно проверить.

Начнем по порядку изложения.

РепозиторийТесты Для теста репозитория используется База данныхUpdaterMock .

Там можно отследить, был ли вызван метод 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. Проверяем, что последовательность содержит только один элемент, метод называется синхронизировать()
  2. Проверяем, что последовательность содержит два элемента.

    Один содержит Событие с ошибкой, другой результат запроса из базы данных, метод вызывается синхронизировать()

База данныхОбновлениеТесты тестсинк()

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. Возвращает пустую последовательность, если данные были обновлены.

ПросмотрМоделиТесты ViewControllerTests тестБиндКонтактс()

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. Проверьте, что внутри контроллер.

    представленныйViewController сообщение об ошибке

  3. Выполните обработчик кнопки ОК и убедитесь, что окно сообщения скрыто.

  4. При пустом результате ошибка не отображается и поля не заполняются.

  5. При успешном запросе ошибка не отображается и поля заполняются.



Тесты для операторов

.

flatMapError() .

showMessage() Используя аналогичный подход к проектированию, мы реализуем асинхронный поиск, обновление и уведомление об ошибках данных, не теряя возможности реагировать на изменение данных, следуя принципу ССОТ .

Теги: #разработка iOS #Swift #rxswift #mvvm

Вместе с данным постом часто просматривают: