Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimizations #94

Merged
merged 9 commits into from
Oct 19, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 72 additions & 71 deletions IceCream/Classes/SyncEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import Foundation
import RealmSwift
import CloudKit


#if os(macOS)
import Cocoa
#else
Expand All @@ -18,72 +17,65 @@ import UIKit

/// SyncEngine talks to CloudKit directly.
/// Logically,
/// 1. it takes care of the operations of CKDatabase
/// 1. it takes care of the operations of **CKDatabase**
/// 2. it handles all of the CloudKit config stuffs, such as subscriptions
/// 3. it hands over CKRecordZone stuffs to SyncObject so that it can have an effect on local Realm Database

public final class SyncEngine {

/// Notifications are delivered as long as a reference is held to the returned notification token. You should keep a strong reference to this token on the class registering for updates, as notifications are automatically unregistered when the notification token is deallocated.
/// For more, reference is here: https://realm.io/docs/swift/latest/#notifications
private var notificationToken: NotificationToken?


/// Indicates the private database in default container
private let privateDatabase = CKContainer.default().privateCloudDatabase

private let errorHandler = ErrorHandler()

private let syncObjects: [Syncable]

/// We recommend processing the initialization when app launches
public init(objects: [Syncable]) {
self.syncObjects = objects
for syncObject in syncObjects {
syncObject.pipeToEngine = { [weak self] recordsToStore, recordIDsToDelete in
guard let `self` = self else { return }
`self`.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete)
guard let self = self else { return }
self.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete)
}
}

/// Check iCloud status so that we can go on
CKContainer.default().accountStatus { [weak self] (status, error) in
guard let `self` = self else { return }
guard let self = self else { return }
if status == CKAccountStatus.available {

/// 1. Fetch changes in the Cloud
/// Apple suggests that we should fetch changes in database, *especially* the very first launch.
/// But actually, there **might** be some rare unknown and weird reason that the data is not synced between muilty devices.
/// So I suggests fetch changes in database everytime app launches.
`self`.fetchChangesInDatabase({
print("First sync done!")
})
self.fetchChangesInDatabase()

`self`.resumeLongLivedOperationIfPossible()
self.resumeLongLivedOperationIfPossible()

`self`.createCustomZones()

`self`.startObservingRemoteChanges()

/// 2. Register to local database
DispatchQueue.main.async {
for syncObject in `self`.syncObjects {
self.createCustomZones { [weak self] (error) in
guard let self = self, error == nil else { return }
/// 2. Register to local database
/// We should call `registerLocalDatabase` after custom zones were created, related issue: https://github.com/caiyue1993/IceCream/issues/83
for syncObject in self.syncObjects {
syncObject.registerLocalDatabase()
}
}

self.startObservingRemoteChanges()

#if os(iOS) || os(tvOS)

NotificationCenter.default.addObserver(self, selector: #selector(`self`.cleanUp), name: UIApplication.willTerminateNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(self.cleanUp), name: UIApplication.willTerminateNotification, object: nil)

#elseif os(macOS)

NotificationCenter.default.addObserver(self, selector: #selector(`self`.cleanUp), name: NSApplication.willTerminateNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(self.cleanUp), name: NSApplication.willTerminateNotification, object: nil)

#endif

/// 3. Create the subscription to the CloudKit database
if `self`.subscriptionIsLocallyCached { return }
`self`.createDatabaseSubscription()
if self.subscriptionIsLocallyCached { return }
self.createDatabaseSubscription()

} else {
/// Handle when user account is not available
Expand All @@ -98,18 +90,21 @@ public final class SyncEngine {
let zonesToCreate = syncObjects.filter { !$0.isCustomZoneCreated }.map { CKRecordZone(zoneID: $0.customZoneID) }
let modifyOp = CKModifyRecordZonesOperation(recordZonesToSave: zonesToCreate, recordZoneIDsToDelete: nil)
modifyOp.modifyRecordZonesCompletionBlock = { [weak self](_, _, error) in
guard let `self` = self else { return }
switch `self`.errorHandler.resultType(with: error) {
guard let self = self else { return }
switch self.errorHandler.resultType(with: error) {
case .success:
self.syncObjects.forEach { $0.isCustomZoneCreated = true }
DispatchQueue.main.async {
completion?(nil)
}
case .retry(let timeToWait, _):
`self`.errorHandler.retryOperationIfPossible(retryAfter: timeToWait, block: {
`self`.createCustomZones(completion)
self.errorHandler.retryOperationIfPossible(retryAfter: timeToWait, block: {
self.createCustomZones(completion)
})
default:
return
DispatchQueue.main.async {
completion?(error)
}
}
}

Expand Down Expand Up @@ -164,36 +159,36 @@ extension SyncEngine {
changesOperation.fetchAllChanges = true

changesOperation.changeTokenUpdatedBlock = { [weak self] newToken in
guard let `self` = self else { return }
guard let self = self else { return }
self.databaseChangeToken = newToken
}

/// Cuz we only have one custom zone, so we don't need to store the CKRecordZoneID temporarily
/*
changesOperation.recordZoneWithIDChangedBlock = { [weak self] zoneID in
guard let `self` = self else { return }
`self`.changedRecordZoneID = zoneID
guard let self = self else { return }
self.changedRecordZoneID = zoneID
}
*/
changesOperation.fetchDatabaseChangesCompletionBlock = {
[weak self]
newToken, _, error in
guard let `self` = self else { return }
switch `self`.errorHandler.resultType(with: error) {
guard let self = self else { return }
switch self.errorHandler.resultType(with: error) {
case .success:
`self`.databaseChangeToken = newToken
self.databaseChangeToken = newToken
// Fetch the changes in zone level
`self`.fetchChangesInZones(callback)
self.fetchChangesInZones(callback)
case .retry(let timeToWait, _):
`self`.errorHandler.retryOperationIfPossible(retryAfter: timeToWait, block: {
`self`.fetchChangesInDatabase(callback)
self.errorHandler.retryOperationIfPossible(retryAfter: timeToWait, block: {
self.fetchChangesInDatabase(callback)
})
case .recoverableError(let reason, _):
switch reason {
case .changeTokenExpired:
/// The previousServerChangeToken value is too old and the client must re-sync from scratch
`self`.databaseChangeToken = nil
`self`.fetchChangesInDatabase(callback)
self.databaseChangeToken = nil
self.fetchChangesInDatabase(callback)
default:
return
}
Expand Down Expand Up @@ -223,44 +218,44 @@ extension SyncEngine {
changesOp.fetchAllChanges = true

changesOp.recordZoneChangeTokensUpdatedBlock = { [weak self] zoneId, token, _ in
guard let `self` = self else { return }
guard let syncObject = `self`.syncObjects.first(where: { $0.customZoneID == zoneId }) else { return }
guard let self = self else { return }
guard let syncObject = self.syncObjects.first(where: { $0.customZoneID == zoneId }) else { return }
syncObject.zoneChangesToken = token
}

changesOp.recordChangedBlock = { [weak self] record in
/// The Cloud will return the modified record since the last zoneChangesToken, we need to do local cache here.
/// Handle the record:
guard let `self` = self else { return }
guard let syncObject = `self`.syncObjects.first(where: { $0.recordType == record.recordType }) else { return }
guard let self = self else { return }
guard let syncObject = self.syncObjects.first(where: { $0.recordType == record.recordType }) else { return }
syncObject.add(record: record)
}

changesOp.recordWithIDWasDeletedBlock = { [weak self] recordId, _ in
guard let `self` = self else { return }
guard let syncObject = `self`.syncObjects.first(where: { $0.customZoneID == recordId.zoneID }) else { return }
guard let self = self else { return }
guard let syncObject = self.syncObjects.first(where: { $0.customZoneID == recordId.zoneID }) else { return }
syncObject.delete(recordID: recordId)
}

changesOp.recordZoneFetchCompletionBlock = { [weak self](zoneId ,token, _, _, error) in
guard let `self` = self else { return }
switch `self`.errorHandler.resultType(with: error) {
guard let self = self else { return }
switch self.errorHandler.resultType(with: error) {
case .success:
guard let syncObject = `self`.syncObjects.first(where: { $0.customZoneID == zoneId }) else { return }
guard let syncObject = self.syncObjects.first(where: { $0.customZoneID == zoneId }) else { return }
syncObject.zoneChangesToken = token
callback?()
print("Sync successfully!")
print("Sync successfully: \(zoneId))")
case .retry(let timeToWait, _):
`self`.errorHandler.retryOperationIfPossible(retryAfter: timeToWait, block: {
`self`.fetchChangesInZones(callback)
self.errorHandler.retryOperationIfPossible(retryAfter: timeToWait, block: {
self.fetchChangesInZones(callback)
})
case .recoverableError(let reason, _):
switch reason {
case .changeTokenExpired:
/// The previousServerChangeToken value is too old and the client must re-sync from scratch
guard let syncObject = `self`.syncObjects.first(where: { $0.customZoneID == zoneId }) else { return }
guard let syncObject = self.syncObjects.first(where: { $0.customZoneID == zoneId }) else { return }
syncObject.zoneChangesToken = nil
`self`.fetchChangesInZones(callback)
self.fetchChangesInZones(callback)
default:
return
}
Expand All @@ -273,8 +268,6 @@ extension SyncEngine {
}

fileprivate func createDatabaseSubscription() {
// The direct below is the subscribe way that Apple suggests in CloudKit Best Practices(https://developer.apple.com/videos/play/wwdc2016/231/) , but it doesn't work here in my place.

#if os(iOS) || os(tvOS) || os(macOS)

let subscription = CKDatabaseSubscription(subscriptionID: IceCreamConstant.cloudKitSubscriptionID)
Expand All @@ -296,9 +289,11 @@ extension SyncEngine {
}

fileprivate func startObservingRemoteChanges() {
NotificationCenter.default.addObserver(forName: Notifications.cloudKitDataDidChangeRemotely.name, object: nil, queue: OperationQueue.main, using: { [weak self](_) in
guard let `self` = self else { return }
`self`.fetchChangesInDatabase()
NotificationCenter.default.addObserver(forName: Notifications.cloudKitDataDidChangeRemotely.name, object: nil, queue: nil, using: { [weak self](_) in
guard let self = self else { return }
DispatchQueue.global(qos: .utility).async {
self.fetchChangesInDatabase()
}
})
}
}
Expand Down Expand Up @@ -360,29 +355,29 @@ extension SyncEngine {
[weak self]
(_, _, error) in

guard let `self` = self else { return }
guard let self = self else { return }

switch `self`.errorHandler.resultType(with: error) {
switch self.errorHandler.resultType(with: error) {
case .success:
DispatchQueue.main.async {
completion?(nil)

/// Cause we will get a error when there is very empty in the cloudKit dashboard
/// which often happen when users first launch your app.
/// So, we put the subscription process here when we sure there is a record type in CloudKit.
if `self`.subscriptionIsLocallyCached { return }
`self`.createDatabaseSubscription()
if self.subscriptionIsLocallyCached { return }
self.createDatabaseSubscription()
}
case .retry(let timeToWait, _):
`self`.errorHandler.retryOperationIfPossible(retryAfter: timeToWait) {
`self`.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion)
self.errorHandler.retryOperationIfPossible(retryAfter: timeToWait) {
self.syncRecordsToCloudKit(recordsToStore: recordsToStore, recordIDsToDelete: recordIDsToDelete, completion: completion)
}
case .chunk:
/// CloudKit says maximum number of items in a single request is 400.
/// So I think 300 should be a fine by them.
/// So I think 300 should be fine by them.
let chunkedRecords = recordsToStore.chunkItUp(by: 300)
for chunk in chunkedRecords {
`self`.syncRecordsToCloudKit(recordsToStore: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion)
self.syncRecordsToCloudKit(recordsToStore: chunk, recordIDsToDelete: recordIDsToDelete, completion: completion)
}
default:
return
Expand All @@ -392,10 +387,16 @@ extension SyncEngine {
privateDatabase.add(modifyOpe)
}

// Manually sync data with CloudKit
public func sync() {
/// Fetch data on the CloudKit and merge with local
public func pull() {
fetchChangesInDatabase()
}

/// Push all existing local data to CloudKit
/// You should NOT to call this method too frequently
public func pushAll() {
self.syncObjects.forEach { $0.pushLocalObjectsToCloudKit() }
}
}

public enum Notifications: String, NotificationName {
Expand Down
38 changes: 15 additions & 23 deletions IceCream/Classes/SyncObject.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ public final class SyncObject<T> where T: Object & CKRecordConvertible & CKRecor
/// For more, reference is here: https://realm.io/docs/swift/latest/#notifications
private var notificationToken: NotificationToken?

private let errorHandler = ErrorHandler()

public var pipeToEngine: ((_ recordsToStore: [CKRecord], _ recordIDsToDelete: [CKRecord.ID]) -> ())?

public init() {}
Expand Down Expand Up @@ -81,7 +79,7 @@ extension SyncObject: Syncable {
/// https://realm.io/docs/swift/latest/#objects-with-primary-keys
realm.beginWrite()
realm.add(object, update: true)
if let token = `self`.notificationToken {
if let token = self.notificationToken {
try! realm.commitWrite(withoutNotifying: [token])
} else {
try! realm.commitWrite()
Expand All @@ -99,7 +97,7 @@ extension SyncObject: Syncable {
CreamAsset.deleteCreamAssetFile(with: recordID.recordName)
realm.beginWrite()
realm.delete(object)
if let token = `self`.notificationToken {
if let token = self.notificationToken {
try! realm.commitWrite(withoutNotifying: [token])
} else {
try! realm.commitWrite()
Expand All @@ -112,28 +110,16 @@ extension SyncObject: Syncable {
public func registerLocalDatabase() {
let objects = Cream<T>().realm.objects(T.self)
notificationToken = objects.observe({ [weak self](changes) in
guard let `self` = self else { return }

guard let self = self else { return }
switch changes {
case .initial(let collection):
print("Inited:" + "\(collection)")
case .initial(_):
break
case .update(let collection, let deletions, let insertions, let modifications):
print("collections:" + "\(collection)")
print("deletions:" + "\(deletions)")
print("insertions:" + "\(insertions)")
print("modifications:" + "\(modifications)")

let objectsToStore = (insertions + modifications).filter { $0 < collection.count }.map { collection[$0] }.filter{ !$0.isDeleted }
let objectsToDelete = modifications.filter { $0 < collection.count }.map{ collection[$0] }.filter { $0.isDeleted }

guard objectsToStore.count > 0 || objectsToDelete.count > 0 else { return }

let recordsToStore = objectsToStore.map{ $0.record }
let recordIDsToDelete = objectsToDelete.map{ $0.recordID }

`self`.pipeToEngine?(recordsToStore, recordIDsToDelete)
case .update(let collection, _, let insertions, let modifications):
let recordsToStore = (insertions + modifications).filter { $0 < collection.count }.map { collection[$0] }.filter{ !$0.isDeleted }.map { $0.record }
let recordIDsToDelete = modifications.filter { $0 < collection.count }.map { collection[$0] }.filter { $0.isDeleted }.map { $0.recordID }

guard recordsToStore.count > 0 || recordIDsToDelete.count > 0 else { return }
self.pipeToEngine?(recordsToStore, recordIDsToDelete)
case .error(_):
break
}
Expand All @@ -148,5 +134,11 @@ extension SyncObject: Syncable {
// Error handles here
}
}

public func pushLocalObjectsToCloudKit() {
let recordsToStore: [CKRecord] = Cream<T>().realm.objects(T.self).filter { !$0.isDeleted }.map { $0.record }
pipeToEngine?(recordsToStore, [])
}

}

Loading