Часто в мобильном приложении необходимо реализовать следующий функционал:
- Выполнить асинхронный запрос
- Привязка результата в основном потоке к различным представлениям
- При необходимости обновите базу данных на устройстве асинхронно в фоновом потоке.
- Если при выполнении этих операций возникают ошибки, то показывать уведомление
- Уважайте принцип ССОТ для актуальности данных
- Проверьте все это
Описанный ниже подход использует принципы реактивного программирования и не привязан исключительно к RxSwift И CoreData .
А при желании это можно реализовать и с помощью других инструментов.
В качестве примера возьму фрагмент приложения, отображающего данные о продавце.
Контроллер имеет два выхода UILabel для телефона и адреса и один UIButton для вызова этого телефона.
Я объясню реализацию от модели к представлению.
Модель
Фрагмент автоматически созданного файла 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 обновляется при необходимости.
Все данные получаются ТОЛЬКО из базы данных, и апдейтер.
синхронизация() может генерировать Событие только с ошибкой, но НЕ с данными.
- Использование оператора слияния позволяет добиться асинхронного выполнения запроса к базе данных и ее обновления.
- Для удобства построения запроса к базе данных используйте RxCoreData
- Мы обновляем базу данных
Это необходимо для того, чтобы абонент не получал 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
}
}
- Мы возвращаем пустую последовательность, если данные уже получены.
Например, другой метод из репозитория уже вызывал sync().
fetchLimiter должен быть потокобезопасным.
А именно получить или записать значения в поле выборка в процессе нужно в последовательной очереди.
- Блокировка обновления для последующих вызовов методов
- Выполнить запрос на получение удаленных данных
- Создайте результат с пустой последовательностью по умолчанию.
- Если запрос выполнился с ошибкой, то присвойте результату последовательность с одним элементом Событие с ошибкой
- Обновление базы данных
- Включить возможность обновления базы и возврата результата
Многопоточность
Чтобы обеспечить многопоточность, мы получаем данные из 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)
}
- Выполняем запрос на контакт. Мы явно указываем основной поток для работы с результатом, ведь данные будут настроены на просмотр
- Если элемент, содержащий Событие в случае ошибки отображается сообщение об ошибке и возвращается пустая последовательность.
Подробнее об операторе квартирамаперрор И показать сообщение ниже
- Мы используем оператор компактнаяКарта получить контакты из массива
- Установка данных в торговых точках
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()
}
}
}
- Преобразуем последовательность из Событие.
Элемент В Элемент
- Если Событие содержит ошибку, то мы возвращаем обработчик, преобразованный в пустую последовательность
- Если Событие содержит результат, то мы возвращаем последовательность с одним элементом, содержащим этот результат
- По умолчанию возвращается пустая последовательность
Оператор .
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() }
}
- Используя RxAlert создается окно с сообщением и одной кнопкой
- Результат преобразуется в Пустота
- Если событие необходимо после показа сообщения, то мы возвращаем результат. В противном случае мы сначала преобразуем ее в пустую последовательность, а затем возвращаем ее.
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)
}
- Проверяем, что последовательность содержит только один элемент, метод называется синхронизировать()
- Проверяем, что последовательность содержит два элемента.
Один содержит Событие с ошибкой, другой результат запроса из базы данных, метод вызывается синхронизировать()
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)
}
- Пустая последовательность возвращается, если обновление выполняется.
- Возвращает пустую последовательность, если данные не получены
- Возврат Событие с ошибкой
- Возвращает пустую последовательность, если данные были обновлены.
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)
}
- Показать сообщение об ошибке
- Проверьте, что внутри контроллер.
представленныйViewController сообщение об ошибке
- Выполните обработчик кнопки ОК и убедитесь, что окно сообщения скрыто.
- При пустом результате ошибка не отображается и поля не заполняются.
- При успешном запросе ошибка не отображается и поля заполняются.
Тесты для операторов
.flatMapError()
.showMessage()
Используя аналогичный подход к проектированию, мы реализуем асинхронный поиск, обновление и уведомление об ошибках данных, не теряя возможности реагировать на изменение данных, следуя принципу ССОТ .Теги: #разработка iOS #Swift #rxswift #mvvm
-
Плохой Хороший Слободин
19 Oct, 24 -
Обучение Слепому Подписанию
19 Oct, 24 -
Opera Лидирует На 3Dnews
19 Oct, 24 -
Нужны Ли Миру Технические Сертификаты?
19 Oct, 24