如何中途取消NSBatchInsertRequest并回滚已保存数据?
问题
我的应用支持用户导入多达10000个音频文件。AudioFile实体的唯一性由名称和文件扩展名共同决定,我已在xcdatamodeld文件中添加了name,fileExtension约束。
导入过程中,用户可通过取消按钮或从应用切换器关闭应用终止导入,但NSBatchInsertRequest会直接写入磁盘、绕过内存上下文的常规变更跟踪,请问如何回滚已保存的更改或删除已存入磁盘的托管对象?
当前导入代码
func createNewBackgroundContext() -> NSManagedObjectContext { let taskContext = container.newBackgroundContext() taskContext.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy return taskContext } func importFiles(from urls: [URL]) async { let importContext = DataController.shared.createNewBackgroundContext() var objects: [[String: Any]] = [] for url in urls { if url.startAccessingSecurityScopedResource() { let metadata = getMetadata(url: url) let audioFileName = url.deletingPathExtension().lastPathComponent let fileExtension = url.pathExtension let artist = metadata?.value(forKey: "artist") as? String ?? "Unknown Artist" let album = metadata?.value(forKey: "album") as? String ?? "Unknown Album" let genre = metadata?.value(forKey: "genre") as? String ?? "Unknown Genre" let composer = metadata?.value(forKey: "composer") as? String ?? "Unknown Composer" let year = metadata?.value(forKey: "year") as? String ?? "Unknown Release Year" let importDate = Date() let artwork = audioFileName + ".\(fileExtension)" let trackNumber = metadata?.value(forKey: "trackNumber") as? Int ?? -1 let properties: [String: Any] = [ "name": audioFileName, "artist": artist, "album": album, "genre": genre, "year": year, "composer": composer, "importDate": importDate, "trackNumber": trackNumber, "artwork": artwork, "fileExtension": fileExtension, "favourite": false ] objects.append(properties) // 保存文件到磁盘 save(url: url, with: audioFileName + ".\(fileExtension)") } url.stopAccessingSecurityScopedResource() } await importContext.perform { let batchInsertRequest = NSBatchInsertRequest(entity: AudioFile.entity(), objects: objects) if let fetchResult = try? importContext.execute(batchInsertRequest), let batchInsertResult = fetchResult as? NSBatchInsertResult, let success = batchInsertResult.result as? Bool, success { return } } }
解决方案
由于NSBatchInsertRequest是直接写入持久化存储,无法通过上下文rollback()直接回滚,需要从导入流程设计和事后清理两个维度解决:
一、提前规避:分批次导入+事务绑定
把大批次导入拆分为若干小批次,每批次绑定独立的标识和事务,用户终止时只需清理未完成或指定批次的内容:
- 将
urls拆分为多个子数组(比如每500个文件一批) - 每批次使用独立的
NSManagedObjectContext,并给本次导入的所有AudioFile添加唯一的batchId属性(需新增到xcdatamodeld) - 每批次完成后,记录该批次的状态;用户取消时,终止当前批次并删除对应批次的
AudioFile和磁盘文件
核心调整示例:
// 新增ImportBatch实体(可选),用于记录批次状态;或直接用batchId标识 func importFiles(from urls: [URL]) async { let batchSize = 500 let batches = stride(from: 0, to: urls.count, by: batchSize).map { Array(urls[$0..<min($0+batchSize, urls.count)]) } // 线程安全的取消标志 let isCancelled = AtomicBool(initialValue: false) // 绑定取消按钮动作:isCancelled.value = true for batch in batches { guard !isCancelled.value else { break } let importContext = DataController.shared.createNewBackgroundContext() var batchObjects: [[String: Any]] = [] var savedFilePaths: [String] = [] let currentBatchId = UUID().uuidString for url in batch { guard !isCancelled.value else { break } if url.startAccessingSecurityScopedResource() { // ... 原有解析metadata逻辑 ... // 给每个对象添加batchId var propertiesWithBatch = properties propertiesWithBatch["batchId"] = currentBatchId batchObjects.append(propertiesWithBatch) // 保存文件并记录路径 let savedPath = save(url: url, with: audioFileName + ".\(fileExtension)") savedFilePaths.append(savedPath) } url.stopAccessingSecurityScopedResource() } guard !isCancelled.value else { // 用户取消,删除当前批次已保存的文件 savedFilePaths.forEach { try? FileManager.default.removeItem(atPath: $0) } break } await importContext.perform { guard !isCancelled.value else { return } do { let batchInsertRequest = NSBatchInsertRequest(entity: AudioFile.entity(), objects: batchObjects) try importContext.execute(batchInsertRequest) try importContext.save() } catch { // 插入失败,回滚磁盘文件 savedFilePaths.forEach { try? FileManager.default.removeItem(atPath: $0) } } } } // 全局取消:删除本次导入所有批次的记录和文件 if isCancelled.value { let cleanupContext = DataController.shared.createNewBackgroundContext() await cleanupContext.perform { // 批量删除带有batchId的AudioFile let fetchRequest: NSFetchRequest<NSFetchRequestResult> = AudioFile.fetchRequest() fetchRequest.predicate = NSPredicate(format: "batchId != nil") let batchDelete = NSBatchDeleteRequest(fetchRequest: fetchRequest) try? cleanupContext.execute(batchDelete) try? cleanupContext.save() } // 删除所有本次导入的磁盘文件(可通过batchId关联路径,或遍历临时目录) // ... 对应删除逻辑 ... } }
二、事后清理:基于导入会话ID批量删除
如果无法修改导入流程,可通过新增标识实现事后清理:
- 给
AudioFile实体新增importSessionId属性(UUID类型) - 每次导入开始时生成唯一的
importSessionId,所有本次导入的对象都绑定该ID - 用户取消或应用意外终止后,查询所有带有该
importSessionId的AudioFile,用NSBatchDeleteRequest批量删除 - 同时遍历磁盘上对应本次导入的音频文件,批量删除
三、处理应用意外终止
- 导入开始时,将
importSessionId和已处理的文件路径存入UserDefaults或临时文件 - 应用启动时检查是否存在未完成的导入会话:
- 根据
importSessionId批量删除对应的AudioFile - 根据记录的路径删除磁盘文件
- 清理临时记录
- 根据
关键注意事项
NSBatchInsertRequest执行后直接提交到存储,无法回滚,只能依赖批量删除实现“回滚”效果- 磁盘文件保存和Core Data插入要保持原子性:可先将文件保存到临时目录,批次插入成功后再移动到正式目录;插入失败则删除临时文件
- 取消标志必须线程安全(比如用
AtomicBool),避免多线程竞争导致逻辑错误
内容的提问来源于stack exchange,提问作者User95797654974
相关产品推荐
相关产品推荐

