本地Core Data备份/恢复与iCloud冲突:Stores Changed问题排查
Core Data CloudKit 恢复数据后被iCloud覆盖的解决方法
问题背景
我为Core Data定义了如下备份/恢复扩展方法:
import CoreData import FirebaseCrashlytics import Foundation extension NSPersistentCloudKitContainer { enum CopyPersistentStoreErrors: Error { case invalidDestination(String) case destinationError(String) case destinationNotRemoved(String) case copyStoreError(String) case invalidSource(String) } func restorePersistentStore(from backupURL: URL) throws { guard backupURL.isFileURL else { throw CopyPersistentStoreErrors.invalidSource("Backup URL must be a file URL") } var isDirectory: ObjCBool = false if FileManager.default.fileExists(atPath: backupURL.path, isDirectory: &isDirectory) { if !isDirectory.boolValue { throw CopyPersistentStoreErrors.invalidSource("Source URL must be a directory") } } else { throw CopyPersistentStoreErrors.invalidSource("Source URL must exist") } for persistentStoreDescription in persistentStoreDescriptions { guard let loadedStoreURL = persistentStoreDescription.url else { continue } let backupStoreURL = backupURL.appendingPathComponent(loadedStoreURL.lastPathComponent) guard FileManager.default.fileExists(atPath: backupStoreURL.path) else { throw CopyPersistentStoreErrors.invalidSource("Missing backup store for \(backupStoreURL)") } do { var storeOptions = persistentStoreDescription.options storeOptions.updateValue(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) let configurationName = persistentStoreDescription.configuration let storeType = persistentStoreDescription.type // Replace the current store with the backup copy. This has a side effect of removing the current store from the Core Data stack. // When restoring, it's necessary to use the current persistent store coordinator. try persistentStoreCoordinator.replacePersistentStore(at: loadedStoreURL, destinationOptions: storeOptions, withPersistentStoreFrom: backupStoreURL, sourceOptions: storeOptions, ofType: storeType) // Add the persistent store at the same location we've been using, because it was removed in the previous step. try persistentStoreCoordinator.addPersistentStore(ofType: storeType, configurationName: configurationName, at: loadedStoreURL, options: storeOptions) } catch { Crashlytics.crashlytics().record(error: error) throw CopyPersistentStoreErrors.copyStoreError("Could not restore: \(error.localizedDescription)") } } } func copyPersistentStores(to destinationURL: URL, overwriting: Bool = false) throws { guard destinationURL.isFileURL else { throw CopyPersistentStoreErrors.invalidDestination("Destination URL must be a file URL") } var isDirectory: ObjCBool = false if !overwriting, FileManager.default.fileExists(atPath: destinationURL.path, isDirectory: &isDirectory) { if !isDirectory.boolValue { throw CopyPersistentStoreErrors.invalidDestination("Destination URL must be a directory") } } if overwriting, FileManager.default.fileExists(atPath: destinationURL.path) { do { try FileManager.default.removeItem(at: destinationURL) } catch { Crashlytics.crashlytics().record(error: error) throw CopyPersistentStoreErrors.destinationNotRemoved("Can't overwrite destination at \(destinationURL)") } } // Create the destination directory do { try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true, attributes: nil) } catch { Crashlytics.crashlytics().record(error: error) throw CopyPersistentStoreErrors.destinationError("Could not create destination directory at \(destinationURL)") } for persistentStoreDescription in persistentStoreDescriptions { guard let storeURL = persistentStoreDescription.url else { continue } guard persistentStoreDescription.type != NSInMemoryStoreType else { continue } let destinationStoreURL = destinationURL.appendingPathComponent(storeURL.lastPathComponent) if !overwriting, FileManager.default.fileExists(atPath: destinationStoreURL.path) { // If the destination exists, the replacePersistentStore call will update it in place. That's fine unless we're not overwriting. throw CopyPersistentStoreErrors.destinationError("Destination already exists at \(destinationStoreURL)") } do { // Replace an existing backup, if any, with a new one with the same options and type. This doesn't affect the current Core Data stack. // The function name says "replace", but it works if there's nothing at the destination yet. In that case it creates a new persistent store. // Note that for backup, it doesn't matter if the persistent store coordinator is the one currently in use or a different one. It could be a class function, for this use. try persistentStoreCoordinator.replacePersistentStore(at: destinationStoreURL, destinationOptions: persistentStoreDescription.options, withPersistentStoreFrom: storeURL, sourceOptions: persistentStoreDescription.options, type: .sqlite) } catch { Crashlytics.crashlytics().record(error: error) throw CopyPersistentStoreErrors.copyStoreError("\(error.localizedDescription)") } } } }
该功能在本地运行正常,但结合CloudKit使用时,控制台出现如下错误:
CoreData: error: CoreData+CloudKit: -[NSCloudKitMirroringDelegate tearDown:]_block_invoke(808): <NSCloudKitMirroringDelegate: 0x302f9c960>: Told to tear down with reason: Stores Changed
后续还出现一系列关联错误:
error: CoreData+CloudKit: -[NSCloudKitMirroringDelegate recoverFromError:](2302): <NSCloudKitMirroringDelegate: 0x302f9c960> - Attempting recovery from error: Error Domain=NSCocoaErrorDomain Code=134404 "(null)" UserInfo={NSDetailedErrors=( "Error Domain=NSCocoaErrorDomain Code=134407 \"Request 'ECA8B22F-4F97-4D7E-B6B9-D61CC5F2C4D9' was cancelled because the store was removed from the coordinator.\" CoreData: error: CoreData+CloudKit: -[NSCloudKitMirroringDelegate resetAfterError:andKeepContainer:](605): <NSCloudKitMirroringDelegate: 0x302f9c960> - resetting internal state after error: Error Domain=NSCocoaErrorDomain Code=134404 "(null)" UserInfo={NSDetailedErrors=( CoreData: CloudKit: CoreData+CloudKit: -[NSCloudKitMirroringDelegate _scheduleAutomatedImportWithLabel:activity:voucher:completionHandler:]_block_invoke(3633): <NSCloudKitMirroringDelegate: 0x302f9c960> - Finished automatic import - SandboxImport - with result: <NSCloudKitMirroringResult: 0x301cb1650> storeIdentifier: 2A4E461C-6CBD-4EB1-8C3B-C8487E191315 success: 0 madeChanges: 1 error: Error Domain=NSCocoaErrorDomain Code=134410 "(null)" UserInfo={NSUnderlyingError=0x301eef420 {Error Domain=NSCocoaErrorDomain Code=134404 "(null)" UserInfo={NSDetailedErrors=( CoreData: CloudKit: CoreData+CloudKit: -[NSCloudKitMirroringDelegate checkAndExecuteNextRequest](3535): <NSCloudKitMirroringDelegate: 0x302f9c960>: Checking for pending requests. CoreData: error: CoreData+CloudKit: -[NSCloudKitMirroringDelegate resetAfterError:andKeepContainer:](605): <NSCloudKitMirroringDelegate: 0x302f9c960> - resetting internal state after error: Error Domain=NSCocoaErrorDomain Code=134404 "(null)" UserInfo={NSDetailedErrors=( CoreData: CloudKit: CoreData+CloudKit: -[NSCloudKitMirroringDelegate _scheduleAutomatedImportWithLabel:activity:voucher:completionHandler:]_block_invoke(3633): <NSCloudKitMirroringDelegate: 0x302f9c960> - Finished automatic import - SandboxImport - with result: <NSCloudKitMirroringResult: 0x301cb1650> storeIdentifier: 2A4E461C-6CBD-4EB1-8C3B-C8487E191315 success: 0 madeChanges: 1 error: Error Domain=NSCocoaErrorDomain Code=134410 "(null)" UserInfo={NSUnderlyingError=0x301eef420 {Error Domain=NSCocoaErrorDomain Code=134404 "(null)" UserInfo={NSDetailedErrors=( CoreData: CloudKit: CoreData+CloudKit: -[NSCloudKitMirroringDelegate checkAndExecuteNextRequest](3535): <NSCloudKitMirroringDelegate: 0x302f9c960>: Checking for pending requests.
重启应用后,恢复的数据会被iCloud中的数据覆盖,请问我的restorePersistentStore函数缺少什么步骤,才能让iCloud使用新恢复的持久化存储数据?
解决方案
核心问题
使用replacePersistentStore和addPersistentStore操作时,CloudKit的镜像代理会因为存储被移除/添加触发Stores Changed销毁逻辑,导致同步状态混乱。重启后iCloud用云端数据覆盖本地,是因为恢复后的存储未重置CloudKit同步元数据,云端仍认为旧数据是最新状态。
需要补充的步骤
恢复前暂停CloudKit自动同步
在处理存储前,禁用CloudKit的远程变更拉取,避免代理在操作过程中访问已移除的存储:// 提前遍历所有存储描述,暂停CloudKit同步 for description in persistentStoreDescriptions { if description.cloudKitContainerOptions != nil { description.cloudKitContainerOptions?.shouldFetchRemoteChanges = false } }恢复后重置CloudKit同步元数据
添加存储后,重置CloudKit的同步元数据,让iCloud将恢复后的本地数据作为基准:// 在addPersistentStore调用后添加 if let store = persistentStoreCoordinator.persistentStore(for: loadedStoreURL), persistentStoreDescription.cloudKitContainerOptions != nil { do { try persistentStoreCoordinator.setMetadata([ NSCloudKitMirroringDelegateOptionResetMetadata: true ], for: store) } catch { Crashlytics.crashlytics().record(error: error) throw CopyPersistentStoreErrors.copyStoreError("Failed to reset CloudKit metadata: \(error.localizedDescription)") } }恢复完成后重新启用同步并加载存储
在函数末尾,重新启用同步并让容器重新加载存储,确保代理建立新的连接:// 函数末尾添加 for description in persistentStoreDescriptions { if description.cloudKitContainerOptions != nil { description.cloudKitContainerOptions?.shouldFetchRemoteChanges = true } } self.loadPersistentStores(completionHandler: { (_, error) in if let error = error { Crashlytics.crashlytics().record(error: error) } })
修改后的完整restorePersistentStore函数
func restorePersistentStore(from backupURL: URL) throws { guard backupURL.isFileURL else { throw CopyPersistentStoreErrors.invalidSource("Backup URL must be a file URL") } var isDirectory: ObjCBool = false if FileManager.default.fileExists(atPath: backupURL.path, isDirectory: &isDirectory) { if !isDirectory.boolValue { throw CopyPersistentStoreErrors.invalidSource("Source URL must be a directory") } } else { throw CopyPersistentStoreErrors.invalidSource("Source URL must exist") } // 提前暂停所有CloudKit存储的同步 for description in persistentStoreDescriptions { if description.cloudKitContainerOptions != nil { description.cloudKitContainerOptions?.shouldFetchRemoteChanges = false } } for persistentStoreDescription in persistentStoreDescriptions { guard let loadedStoreURL = persistentStoreDescription.url else { continue } let backupStoreURL = backupURL.appendingPathComponent(loadedStoreURL.lastPathComponent) guard FileManager.default.fileExists(atPath: backupStoreURL.path) else { throw CopyPersistentStoreErrors.invalidSource("Missing backup store for \(backupStoreURL)") } do { var storeOptions = persistentStoreDescription.options storeOptions.updateValue(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) let configurationName = persistentStoreDescription.configuration let storeType = persistentStoreDescription.type try persistentStoreCoordinator.replacePersistentStore(at: loadedStoreURL, destinationOptions: storeOptions, withPersistentStoreFrom: backupStoreURL, sourceOptions: storeOptions, ofType: storeType) try persistentStoreCoordinator.addPersistentStore(ofType: storeType, configurationName: configurationName, at: loadedStoreURL, options: storeOptions) // 重置CloudKit同步元数据 if let store = persistentStoreCoordinator.persistentStore(for: loadedStoreURL), persistentStoreDescription.cloudKitContainerOptions != nil { try persistentStoreCoordinator.setMetadata([ NSCloudKitMirroringDelegateOptionResetMetadata: true ], for: store) } } catch { Crashlytics.crashlytics().record(error: error) throw CopyPersistentStoreErrors.copyStoreError("Could not restore: \(error.localizedDescription)") } } // 重新启用同步并加载存储 for description in persistentStoreDescriptions { if description.cloudKitContainerOptions != nil { description.cloudKitContainerOptions?.shouldFetchRemoteChanges = true } } self.loadPersistentStores(completionHandler: { (_, error) in if let error = error { Crashlytics.crashlytics().record(error: error) } }) }
内容的提问来源于stack exchange,提问作者Petr Smejkal
相关产品推荐
相关产品推荐

