You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

本地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同步元数据,云端仍认为旧数据是最新状态。

需要补充的步骤

  1. 恢复前暂停CloudKit自动同步
    在处理存储前,禁用CloudKit的远程变更拉取,避免代理在操作过程中访问已移除的存储:

    // 提前遍历所有存储描述,暂停CloudKit同步
    for description in persistentStoreDescriptions {
        if description.cloudKitContainerOptions != nil {
            description.cloudKitContainerOptions?.shouldFetchRemoteChanges = false
        }
    }
    
  2. 恢复后重置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)")
        }
    }
    
  3. 恢复完成后重新启用同步并加载存储
    在函数末尾,重新启用同步并让容器重新加载存储,确保代理建立新的连接:

    // 函数末尾添加
    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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.23 09:05:53