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

### 在项目中启用 CloudKit

苹果官方有[相应的文档](https://developer.apple.com/documentation/cloudkit/enabling_cloudkit_in_your_app#//apple_ref/doc/uid/TP40014987-CH2)，这里只是自己记录一下，不过Xcode确实也微调了一些界面。首先需要在项目的Signing&Capabilities中点+按钮，为项目加入iCloud功能

![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1652662372675/pbHzli_a6.png align="left")

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

![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1652662881900/cXAaSUrIa.png align="left")

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

![image.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1652663428004/ofzpEQUXv.png align="left")

### 修改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小纸条](https://blog.hdcola.one/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}这样的查询条件。



