通过IceCream让Realm与CloudKit同步小纸条

·

2 min read

在项目中启用 CloudKit

苹果官方有相应的文档,这里只是自己记录一下,不过Xcode确实也微调了一些界面。首先需要在项目的Signing&Capabilities中点+按钮,为项目加入iCloud功能

image.png

然后在iCloud中勾选上CloudKit,并点Containers下的+按钮,输入你的Cloud Container名称,苹果会在你的名称前加上iCloud前缀:

image.png

多个app可以使用同一个Containers,一但创建你就不能删除或重命名了。一但你增加了iCloud,Xcode也会自动给你加上Push Notifications。我们希望同步更新的通知不显示而是静默通知,哪么我们还需要增加背景通知的功能。再点+按钮,为项目增加Background Modes,勾选其中的Remote notifications项目:

image.png

修改Realm Model

在需要同步的Realm对象声明中加入一个属性isDelete,如下

@Persisted var isDeleted = false

然后,再为该对象加入CKRecordConvertible和CKRecordRecoverable的扩展。以下是我项目中的一段代码:

import RealmSwift
import IceCream

public class Chapter: Object, ObjectKeyIdentifiable {
    @Persisted(primaryKey: true) public var name: String
    @Persisted public var isSelect = true
    @Persisted public var isDeleted = false
    @Persisted public var topics = RealmSwift.List<Topic>()
}

extension Chapter: CKRecordConvertible{}
extension Chapter: CKRecordRecoverable{}

isDelete这个属性是用于跟踪变更的,当你决定删除一个记录时,只需要将isDelete设置为true,IceCream就会通过Realm的钩子得到这个变更,然后通过iCloud去删除云端数据,删除完成后会将你的本地数据也删除。

扩展两个protocol其实就是为了方便与CKRecord进行同步。

SyncEngine管理

建立AppDelegate

我在所有的地方都看到的示例为使用AppDelegate来启动SyncEngine,按我看下来的主要原因是RemoteNotification,看起来Scene协议里对于远程通知还没有给出好的解决方法,如果回头有了解决方法我应该会更新这份文档。有关AppDelegate的声明,主要是这样的一个框架:

import UIKit
import IceCream
import CloudKit

public class AppDelegate: NSObject, UIApplicationDelegate {
    var syncEngine: SyncEngine?

    public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        return true
    }

    // Enable CloudKit / IceCream Syncronization
    public func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {

        if let dict = userInfo as? [String: NSObject], let notification = CKNotification(fromRemoteNotificationDictionary: dict), let subscriptionID = notification.subscriptionID, IceCreamSubscription.allIDs.contains(subscriptionID) {
            NotificationCenter.default.post(name: Notifications.cloudKitDataDidChangeRemotely.name, object: nil, userInfo: userInfo)
            completionHandler(.newData)
        }
    }
}

它主要有两个方法,一个是application启动时的调用函数,一个是接到远程Notification时的回调函数。第二个函数已经完成,它就是把远程Notifcation变为一个IceCream的本地同步处理。

初始化Realm

我们主要说一下第一个回调中需要做的事。首先,你应该先调用并初始化Realm,你可以在这里请求你的Realm的Model,也可以直接在这里初始化,当然推荐调用自己的Model的Realm初始化。内容应该类似这样:

    let config = Realm.Configuration(
        schemaVersion: 9,
        migrationBlock: { migration, oldSchemaVersion in
            if oldSchemaVersion < 9{
                let types = [Picture.className(),Word.className()]
                for type in types {
                    migration.enumerateObjects(ofType: type) { oldObject, newObject in
                        if let newObject = newObject{
                            migration.delete(newObject)
                        }
                    }
                }
            }
        }
    )
    Realm.Configuration.defaultConfiguration = config
    localRealm = try Realm()

注意,如果你不将Realm初始化完成,后面实例化SyncEngine会直接core dump的。

初始化SyncEngine

接下来,就是初始化SyncEngine:

        syncEngine = SyncEngine(objects: [
            SyncObject(type: Chapter.self, uListElementType: Topic.self),
            SyncObject(type: Topic.self, uListElementType: Picture.self),
            SyncObject(type: Picture.self, uListElementType: Word.self),
            SyncObject(type: Word.self)
        ])

IceCream非常友好的支持了List的关系,每一个SyncObject都是一个需要同步到iCloud上的Zone,也对应着本地Realm数据库中的一个Realm Object。

建立获取完成回调(option)

我的应用中有一个同步来源优先选择的问题,所以需要知道是不是已经从iCloud上同步完成,所以我选建立了一个UserDefaults用于记录是否有从iCloud同步完成过一次的设置(参见Swift中使用UserDefaults小纸条)。

syncEngine.pull(completionHandler: { error in
    let defaults = UserDefaults.standard
    defaults.setIsCloudSynced(true)
})

将AppDelegate挂到App启动中

最后我们需要将AppDelegate放在App的声明里:

import SwiftUI

@main
struct LearnEnglishHelperAppApp: App {
    @StateObject var vm = LearnEnglishHelperViewModel()
    @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(vm)
        }
    }
}

代码的变化注意

Realm Model

Realm Object必须要有primaryKey声明,而且primaryKey必须是ASCII的String。我之前primaryKey直接使用了Realm提供的ObjectID,上来就出问题了。后来改用了单词的name,结果被我自己的épée fencing成攻击败,老老实实的写了一个SHA Hash函数生成主键。

isDeleted使用

除了只是在Realm Model里加入isDeleted的声明。所有的删除也不应该是直接删除记录,而是设置isDeleted=true。另外,在所有的查询里应该加上where{!$0.isDeleted}这样的查询条件。

Did you find this article valuable?

Support 老房东 by becoming a sponsor. Any amount is appreciated!