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

基于NSMetadataQuery的iCloud文档下载通知可扩展方案咨询

iCloud Document 批量下载实现方案

问题1:观察者的正确移除逻辑

  • 调用NotificationCenter.default.addObserver(forName:object:queue:using:)时会返回不透明的观察者令牌,必须用数组强持有三个通知对应的令牌对象,移除时调用NotificationCenter.default.removeObserver(令牌对象)即可,不要使用模糊的removeObserver(self)方式避免误删其他观察者。
  • 移除时机分两种场景:
    • 如果是全局单例持有查询实例(适配动态更新的文件列表场景),不需要在单个/批量文件下载完成后移除观察者,仅在用户关闭iCloud同步功能、应用销毁前等完全不需要监听iCloud状态的节点统一移除即可。
    • 如果是临时创建的查询实例,必须在调用query.stop()的前一刻,先移除所有持有的观察者令牌,避免block循环引用导致的内存泄漏。
  • 下载失败不需要移除观察者:可给失败文件维护重试计数,单文件最多重试3次,超限后将文件移出待下载列表即可,观察者持续保留用于处理其他待下载文件的状态变更。
  • 不需要额外寻找特殊回调时机:观察者移除操作和查询停止操作放在同一个串行队列的同一个代码块中执行即可,保证时序正确。

问题2:万级文件场景的高扩展实现

绝对不要为每个文件创建独立的NSMetadataQuery实例,这种实现会导致系统iCloud服务进程压力过大,触发限流甚至崩溃。最优方案是全局仅维护1个NSMetadataQuery实例:

  • 查询谓词使用IN操作符匹配整个待下载文件名集合:NSPredicate(format: "%K IN %@", NSMetadataItemFSNameKey, pendingFileNames),单查询即可监控所有目标文件,仅需注册一次通知,文件量级增长不会带来额外的通知注册开销。
  • 待下载文件列表动态更新时,不需要重启查询:先调用query.disableUpdates(),更新谓词为新的文件名集合,再调用query.enableUpdates()即可,系统会自动做增量结果同步,不会触发全量磁盘扫描。
  • 不要每次通知触发都全量遍历query.results:从NSMetadataQueryDidUpdate通知的userInfo中读取NSMetadataQueryUpdateAddedItemsKey、NSMetadataQueryUpdateChangedItemsKey、NSMetadataQueryUpdateRemovedItemsKey三个数组,仅处理发生变更的文件项,万级文件下可减少99%的无效遍历开销。
  • 维护下载并发控制:不要一次性对所有文件调用startDownloadingUbiquitousItem,系统iCloud下载服务本身有并发上限,自行维护一个下载队列,限制同时下载的文件数在3-5个,避免触发系统限流导致整体下载卡住。
  • 本地维护三个集合做状态缓存:待下载集合、下载中集合、已完成/失败超限集合,每次处理变更项时先判断集合归属,跳过不需要处理的文件。

问题3:后台线程执行避免UI卡顿

现有代码卡顿的核心原因是将query.operationQueue设置为主队列,所有查询回调、文件处理逻辑都占用主线程资源,修改方式如下:

  • 单独创建一个串行队列分配给查询实例:初始化一个maxConcurrentOperationCount = 1、服务质量为.utility的OperationQueue,赋值给query.operationQueue,所有和NSMetadataQuery相关的操作(启动、停止、开关更新、读取结果、文件处理逻辑)都放在这个队列执行,完全不占用主线程资源。
  • 所有对query的操作必须保证在这个专属串行队列上执行,避免线程安全问题。
  • 仅当需要更新UI(比如刷新下载进度、弹出下载完成提示)时,才手动切换到主队列执行对应UI逻辑。
  • 文件IO操作(比如下载完成后移动文件、校验文件哈希)也放在该串行队列或专门的IO队列执行,不要放到主线程。

可直接复用的核心实现代码

class iCloudDocumentManager {
    static let shared = iCloudDocumentManager()
    
    private var query: NSMetadataQuery!
    // 持有观察者令牌,用于后续移除
    private var observerTokens: [NSObjectProtocol] = []
    // 专属后台串行队列,所有查询操作跑在这
    private let queryQueue: OperationQueue = {
        let queue = OperationQueue()
        queue.maxConcurrentOperationCount = 1
        queue.qualityOfService = .utility
        return queue
    }()
    // 状态缓存
    private var pendingFileNames: Set<String> = []
    private var downloadingFileNames: Set<String> = []
    private var completedFileNames: Set<String> = []
    private var failedRetryCount: [String: Int] = [:]
    // 最大并发下载数
    private let maxConcurrentDownload = 4
    
    private init() {
        setupQuery()
        setupObservers()
        // 队列里启动查询
        queryQueue.addOperation { [weak self] in
            self?.query.start()
            self?.query.enableUpdates()
        }
    }
    
    // 对外暴露:动态更新待下载文件列表
    func updatePendingFileNames(_ newFileNames: Set<String>) {
        queryQueue.addOperation { [weak self] in
            guard let self = self else { return }
            // 过滤已经完成的文件
            let validNewNames = newFileNames.subtracting(self.completedFileNames)
            self.pendingFileNames = validNewNames
            // 更新查询谓词,不需要重启查询
            self.query.disableUpdates()
            self.query.predicate = NSPredicate(format: "%K IN %@", NSMetadataItemFSNameKey, validNewNames)
            self.query.enableUpdates()
            // 触发一次下载调度
            self.scheduleDownloads()
        }
    }
    
    // 对外暴露:销毁时调用,清理资源
    func tearDown() {
        queryQueue.addOperation { [weak self] in
            guard let self = self else { return }
            // 先移除观察者
            self.observerTokens.forEach { NotificationCenter.default.removeObserver($0) }
            self.observerTokens.removeAll()
            // 再停查询
            self.query.disableUpdates()
            self.query.stop()
        }
    }
    
    private func setupQuery() {
        query = NSMetadataQuery()
        query.operationQueue = queryQueue
        query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
        // 初始化为空,后续通过updatePendingFileNames更新
        query.predicate = NSPredicate(value: false)
    }
    
    private func setupObservers() {
        let startGatherToken = NotificationCenter.default.addObserver(
            forName: NSNotification.Name.NSMetadataQueryDidStartGathering,
            object: query,
            queue: queryQueue
        ) { [weak self] _ in
            self?.handleQueryUpdate()
        }
        
        let progressToken = NotificationCenter.default.addObserver(
            forName: NSNotification.Name.NSMetadataQueryGatheringProgress,
            object: query,
            queue: queryQueue
        ) { [weak self] _ in
            self?.handleQueryUpdate()
        }
        
        let updateToken = NotificationCenter.default.addObserver(
            forName: NSNotification.Name.NSMetadataQueryDidUpdate,
            object: query,
            queue: queryQueue
        ) { [weak self] notification in
            // 仅处理变更项,不全量遍历
            guard let userInfo = notification.userInfo else {
                self?.handleQueryUpdate()
                return
            }
            let changedItems = (userInfo[NSMetadataQueryUpdateChangedItemsKey] as? [NSMetadataItem]) ?? []
            let addedItems = (userInfo[NSMetadataQueryUpdateAddedItemsKey] as? [NSMetadataItem]) ?? []
            let allChangedItems = changedItems + addedItems
            self?.processMetadataItems(allChangedItems)
            self?.scheduleDownloads()
        }
        
        observerTokens = [startGatherToken, progressToken, updateToken]
    }
    
    private func handleQueryUpdate() {
        // 首次收集全量结果
        guard let items = query.results as? [NSMetadataItem] else { return }
        processMetadataItems(items)
        scheduleDownloads()
    }
    
    private func processMetadataItems(_ items: [NSMetadataItem]) {
        for item in items {
            guard let fileName = item.value(forAttribute: NSMetadataItemFSNameKey) as? String,
                  pendingFileNames.contains(fileName) || downloadingFileNames.contains(fileName),
                  let fileURL = item.value(forAttribute: NSMetadataItemURLKey) as? URL else {
                continue
            }
            
            // 处理下载完成
            if let downloadStatus = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String,
               downloadStatus == NSMetadataUbiquitousItemDownloadingStatusCurrent {
                downloadingFileNames.remove(fileName)
                completedFileNames.insert(fileName)
                failedRetryCount.removeValue(forKey: fileName)
                // 切主线程回调UI
                DispatchQueue.main.async {
                    // 这里写下载完成的UI逻辑,比如通知页面刷新
                    print("下载完成:\(fileName)")
                }
                continue
            }
            
            // 处理下载错误
            if let error = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingErrorKey) as? NSError {
                let retryCount = failedRetryCount[fileName] ?? 0
                if retryCount >= 3 {
                    // 重试超限,移出待下载列表
                    pendingFileNames.remove(fileName)
                    downloadingFileNames.remove(fileName)
                    DispatchQueue.main.async {
                        // 这里写下载失败的UI提示
                        print("下载失败,超过最大重试次数:\(fileName), error: \(error.localizedDescription)")
                    }
                } else {
                    failedRetryCount[fileName] = retryCount + 1
                    // 放回待下载队列等下次调度
                    downloadingFileNames.remove(fileName)
                    pendingFileNames.insert(fileName)
                }
                continue
            }
            
            // 处理下载进度
            if let progress = item.value(forAttribute: NSMetadataUbiquitousItemPercentDownloadedKey) as? Double {
                DispatchQueue.main.async {
                    // 这里更新进度条UI
                    print("文件\(fileName)下载进度:\(progress)%")
                }
            }
        }
    }
    
    private func scheduleDownloads() {
        // 计算剩余可调度的下载槽位
        let availableSlots = maxConcurrentDownload - downloadingFileNames.count
        guard availableSlots > 0 else { return }
        // 取前N个待下载的文件开始下载
        let filesToStart = pendingFileNames.prefix(availableSlots)
        for fileName in filesToStart {
            // 找到对应的metadataItem
            guard let item = query.results.first(where: {
                ($0 as? NSMetadataItem)?.value(forAttribute: NSMetadataItemFSNameKey) as? String == fileName
            }) as? NSMetadataItem,
            let fileURL = item.value(forAttribute: NSMetadataItemURLKey) as? URL else {
                continue
            }
            do {
                try FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
                pendingFileNames.remove(fileName)
                downloadingFileNames.insert(fileName)
            } catch {
                let retryCount = failedRetryCount[fileName] ?? 0
                failedRetryCount[fileName] = retryCount + 1
            }
        }
    }
}

内容的提问来源于stack exchange,提问作者Cheok Yan Cheng

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.30 05:27:20