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

如何中途取消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或临时文件
  • 应用启动时检查是否存在未完成的导入会话:
    1. 根据importSessionId批量删除对应的AudioFile
    2. 根据记录的路径删除磁盘文件
    3. 清理临时记录

关键注意事项

  • NSBatchInsertRequest执行后直接提交到存储,无法回滚,只能依赖批量删除实现“回滚”效果
  • 磁盘文件保存和Core Data插入要保持原子性:可先将文件保存到临时目录,批次插入成功后再移动到正式目录;插入失败则删除临时文件
  • 取消标志必须线程安全(比如用AtomicBool),避免多线程竞争导致逻辑错误

内容的提问来源于stack exchange,提问作者User95797654974

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.23 02:19:57