- قم بعمل طلب غير متزامن
- ربط النتيجة في الموضوع الرئيسي بآراء مختلفة
- إذا لزم الأمر ، قم بتحديث قاعدة البيانات على الجهاز بشكل غير متزامن في مؤشر ترابط الخلفية
- إذا حدثت أخطاء أثناء إجراء هذه العمليات ، فقم بإظهار إشعار
- الامتثال لمبدأ SSOT لمدى ملاءمة البيانات
- اختبرها كلها
يتم تبسيط حل هذه المشكلة بشكل كبير من خلال النهج المعماري لـ MVVM وإطار عمل RxSwift و CoreData .
يستخدم النهج الموضح أدناه مبادئ البرمجة التفاعلية ولا يرتبط حصريًا بـ RxSwift و CoreData . وإذا رغبت في ذلك ، يمكن تنفيذها باستخدام أدوات أخرى.
كمثال ، سآخذ مقتطفًا من تطبيق يعرض بيانات البائع. تحتوي وحدة التحكم على منفذي UILabel لرقم الهاتف والعنوان وزر UIB واحد للاتصال برقم الهاتف هذا. ContactsViewController .
اسمحوا لي أن أشرح التنفيذ من نموذج إلى آخر.
نموذج
جزء من الملف الذي تم إنشاؤه تلقائيًا 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 () إنشاء حدث به خطأ فقط ، ولكن ليس مع البيانات.
- يتيح لنا استخدام عامل الدمج تحقيق التنفيذ غير المتزامن للاستعلام إلى قاعدة البيانات وتحديثها.
- لتسهيل إنشاء استعلام لقاعدة البيانات ، يتم استخدام RxCoreData
- تحديث قاعدة البيانات
لان يتم استخدام نهج غير متزامن لتلقي البيانات وتحديثها ، يجب عليك استخدام الملاحظة <حدث <... >> . يعد هذا ضروريًا حتى لا يتلقى المشترك خطأ عند تلقي البيانات عن بُعد ، ولكنه يظهر هذا الخطأ فقط ويستمر في الاستجابة للتغييرات في 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
}
}
- , . , sync(). fetchLimiter . , fetchInProcess .
- 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)
}
- ننفذ طلبًا لجهات الاتصال في سلسلة الرسائل الخلفية ، وبالنتيجة الناتجة نعمل بشكل رئيسي
- إذا وصل عنصر يحتوي على حدث مصحوبًا بخطأ ، فستظهر رسالة خطأ ويعاد تسلسل فارغ. أكثر على flatMapError و showMessage المشغل أدناه
- استخدام عامل CompactMap للحصول على جهات اتصال من مجموعة
- ضبط البيانات على المنافذ
عامل التشغيل .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()
}
}
}
- تحويل تسلسل من Event.Element إلى Element
- إذا احتوى الحدث على خطأ ، فإننا نعيد المعالج محوّلًا إلى تسلسل فارغ
- إذا احتوى الحدث على نتيجة ، فقم بإرجاع تسلسل يحتوي على عنصر واحد يحتوي على هذه النتيجة.
- يتم إرجاع التسلسل الفارغ افتراضيًا
يسمح لك هذا الأسلوب بمعالجة أخطاء تنفيذ الاستعلام دون إرسال حدث خطأ إلى المشترك. ولا تزال مراقبة التغيير في قاعدة البيانات نشطة.
Operator .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 () ليس فقط لإظهار إشعارات الخطأ ، بل من المفيد أن تكون قادرًا على ضبط ما إذا كان التسلسل فارغًا أو مع حدث.
الاختبارات
كل ما هو موصوف أعلاه ليس من الصعب اختباره. لنبدأ بترتيب العرض.
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)
}
- نتحقق من أن التسلسل يحتوي على عنصر واحد فقط ، تسمى طريقة sync ()
- نتحقق من أن التسلسل يحتوي على عنصرين. أحدهما يحتوي على حدث به خطأ ، والآخر نتيجة استعلام من قاعدة البيانات ، تسمى طريقة 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)
}
- يتم إرجاع تسلسل فارغ إذا كان التحديث قيد التقدم
- يتم إرجاع تسلسل فارغ إذا لم يتم استلام أي بيانات
- تم إرجاع حدث مع وجود خطأ
- يتم إرجاع تسلسل فارغ إذا تم تحديث البيانات
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)
}
- إظهار رسالة الخطأ
- تحقق من أن controller.presentedViewController به رسالة خطأ
- قم بتشغيل معالج للزر Ok وتأكد من إخفاء مربع الرسالة
- لنتيجة فارغة ، لا يوجد خطأ معروض ولا يتم ملء أي حقول
- لطلب ناجح ، لم يتم عرض أي خطأ وتم ملء الحقول
اختبارات المشغل
.flatMapError ()
.showMessage ()
باستخدام نهج تصميم مماثل ، نقوم بتنفيذ استرجاع البيانات غير المتزامن وتحديث البيانات وإعلام الخطأ دون فقدان القدرة على الاستجابة لتغييرات البيانات ، باتباع مبدأ SSOT .