在正在写的应用中,在原有的Core Data数据持久化的基础上,希望能加入iCloud数据同步的能力,经过查阅各个资料,最后形成一个可用的方案,做一个记录。

前提

  1. 项目已经能支持CoreData的持久化能力
  2. 在Xcode的项目的设置中,在capabilities中添加了iCloud相关的配置
  3. 如果对本文存在疑问,可以先看下相关文章 - searching-for-toggle-icloud-sync-with-userdefaults

设置页面 - iCloud同步开关

这里的实现方案参照了Writing a UserDefaults editor with SwiftUI and property wrappers

PropertyWrapper

在这个属性包装器中定义了配置的UserDefaults,对应的key,已经默认值。

@propertyWrapper
struct SimpleUserDefault<T> {
    let userDefaults: UserDefaults
    let key: String
    let defaultValue: T
    
    init(userDefaults: UserDefaults = UserDefaults.standard,
         key: String,
         defaultValue: T) {
        self.userDefaults = userDefaults
        self.key = key
        self.defaultValue = defaultValue
    }
    
    var wrappedValue: T {
        get {
            guard let data = userDefaults.object(forKey: key) as? T else {
                return self.defaultValue
            }
            
            return data
        }
        
        set {
            userDefaults.setValue(newValue, forKey: key)
        }
    }
}

Extensiong for Binding

给Binding提供构造keyPath和object作为参数的初始化方法,在后面的组件中构造Binding需要。

extension Binding{
    init<RootType>(
        keyPath: ReferenceWritableKeyPath<RootType, Value>,
        object: RootType) {
        self.init(
            get: { object[keyPath: keyPath] },
            set: { object[keyPath: keyPath] = $0 })
    }
}

View

页面主要有两个部分:Toggle结合UserDefaults组件、Config页面

UserDefaultsConfigToggleItemView

对于Toggle和UserDefaults结合使用,我最开始的方式是,在页面初始化时,获取到UserDefaults中的值并赋值到字段A上,在使用Toggle时,将字段A的引用给Toggle中的isOn字段,这样可以通过Toggle修改isOn的值。但是发现,每次触发Toggle的点击动作时,只能修改字段A的值,无法再修改A的同时,触发其他的动作(ex.修改持久化容器,并拉取数据)。
以下的方案,通过包装器的模式,解决了修改字段值的同时,进行动作的触发的问题。即可以在点击Toggle时,修改字段A的值,同时触发修改持久化容器的能力。

// UserDefaults和Toggle结合的组件
struct UserDefaultsConfigToggleItemView: View {
    @ObservedObject var defaultsConfig = UserDefaultsConfig.shared
    let path: ReferenceWritableKeyPath<UserDefaultsConfig, Bool>
    let name: String
    
    var body: some View {
        HStack{
            Toggle(isOn: Binding(
                    keyPath: self.path,
                    object: self.defaultsConfig
            ), label: {
                Text(name)
            })
            Spacer()
        }
    }
}

Config View

在设置页面中,


// 设置页面
struct ConfigView: View {
    
    @Environment(\.presentationMode) var presentationMode
    
    /// 属性对象 - 单例
    @ObservedObject var defaultsConfig = UserDefaultsConfig.shared
    
    var body: some View {
        VStack{
            Form{
                Section(header: Text("存储")) {
			// 使用👆的组件
                    UserDefaultsConfigToggleItemView(
                        path: \.iCloudSyncEnabled,
                        name: "开启iCloud同步")
                }//:Section icloud
            }//:FORM
        }
        .navigationTitle("设置")
        .navigationBarTitleDisplayMode(.inline)
        .navigationBarItems(leading: HStack{self.hideButton}, trailing: HStack{})
        .embedInNavigationView()
    }
    
    private var hideButton:some View {
        return AnyView(
            Button(action: {self.presentationMode.wrappedValue.dismiss()}, label: {
                Image(systemName: "xmark")
            }))
    }
}

ViewModel

class UserDefaultsConfig: ObservableObject{

	// 单例
    static let shared = UserDefaultsConfig()
    
	// 监听对象更新
    let objectWillChange = PassthroughSubject<Void, Never>()
    
	//👆的属性包装类
    @SimpleUserDefault(
        key: "com.leozhou.tomemo.is-icloud-sync-enabled",
        defaultValue: false)
    var iCloudSyncEnabled: Bool {
        willSet {
            //MARK: TODO check icloud acction
            objectWillChange.send()
        }
        didSet {
		// 切换持久化容器
            do {
                try PersistenceController.shared.updateContainer()
            } catch let error as NSError {
		// 如果更新失败了,需要将iCloud同步的配置切回到原来的值
                iCloudSyncEnabled.toggle()
            }
        }
    }
}

效果

设置页面效果

持久化设置

持久化设置的实现参照了stackoverflow - CoreData+CloudKit | On/off iCloud sync toggle

Persistence

struct PersistenceController {
    
// 延迟加载Persistence容器
    lazy var container: NSPersistentContainer = {
        return setupContainer()
    }()

    static var shared = PersistenceController()
    
    init(inMemory: Bool = false) {
        if inMemory {
		// 主要用于preview时初始化,但本案例中暂时删除了preview相关代码
            container.persistentStoreDescriptions.first!.url = URL(fileURLWithPath: "/dev/null")
        }
    }
    
	// 设置容器
    private func setupContainer() -> NSPersistentContainer{
        let container: NSPersistentContainer?
           
        if UserDefaultsConfig.shared.iCloudSyncEnabled {
		// 开启了iCloud同步,需要使用NSPersistentCloudKitContainer,继承了NSPersistentContainer
            print("create CloudKit Container")
            container = NSPersistentCloudKitContainer(name: "ToMemo")
        } else {
		// 关闭了iCloud同步,使用NSPersistentContainer
            print("create Persisitent Container")
            container = NSPersistentContainer(name: "ToMemo")
        }
        container!.viewContext.automaticallyMergesChangesFromParent = true
        container!.viewContext.mergePolicy = NSMergeByPropertyStoreTrumpMergePolicy
        
        let description = container!.persistentStoreDescriptions.first
        if UserDefaultsConfig.shared.iCloudSyncEnabled {
            
        } else {
            
            // This allows a 'non-iCloud' sycning container to keep track of changes if a user changes their mind
            // and turns it on.
            description?.setOption(true as NSNumber,
                                   forKey: NSPersistentHistoryTrackingKey)
//            container?.persistentStoreDescriptions.forEach{ $0.cloudKitContainerOptions = nil}
            container?.persistentStoreDescriptions = [description!]
        }
        description?.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey)

        container!.loadPersistentStores(completionHandler: { (storeDescription, error) in
            if let error = error as NSError? {
                // Handle the errors
                fatalError("Unresolved error \(error), \(error.userInfo)")
            }
        })
        // As NSPersistentCloudKitContainer is a subclass of NSPersistentContainer, you can return either.
        return container!
    }
    
	// 切换容器
    mutating func updateContainer() throws {
	// 保存当前改动
        try self.saveContext()
	// 设置容器
        setupContainer()
        
        // 更新完容器后,拉取数据
        CategoryRepository.shared.getAllCategory()
        NoteRepository.shared.getAllNotes()
    }
    
	// 保存容器中的改动
    mutating func saveContext() throws{
        try container.viewContext.save()
    }
   
}

结尾

按照以上的代码使用 UserDefaults + Toggle + iCloudKit,已经能够实现在设置中通过Toggle控制持久化容器的切换,并且数据能正常保存。
虽然解决了iCloud同步与否的切换问题,但是还有部分问题还未解决:

  1. iCloud同步的异常
    1. 用户未登录AppleID
    2. 用户为开启iCloud
    3. 应用使用iCloud的权力
  2. 网络检查问题
  3. iCloud异常友好展示问题