Sync files to the cloud with FileProvider on macOS
System & Services 高级 26m

在 macOS 上使用 FileProvider 同步文件到云端

Sync files to the cloud with FileProvider on macOS

2021年6月10日

在 Apple 官方观看视频

一句话判断

如果你在做 macOS 云存储客户端,FileProvider 框架是你和 Finder 深度集成的唯一正确路径——别再自己写 Finder Sync Extension 了,那是个维护噩梦。

这场 Session 讲了什么

Session 介绍了 macOS Monterey 中 FileProvider 框架的重大更新。FileProvider 是 Apple 为云存储服务商设计的系统级框架,让你的云存储服务可以在 Finder、Open/Save 对话框、Spotlight 搜索中无缝呈现,就像 iCloud Drive 一样。

今年的更新重点包括:改进的遥测(Telemetry)API 让你可以监控同步状态、新的 NSFileProviderDomain 管理能力、以及 NSFileProviderReplicatedExtension——一个全新的扩展子类,支持更细粒度的同步控制。Session 还详细讲解了”按需下载”(On-demand Download)的实现:文件在 Finder 中显示但内容还在云端,用户双击时才触发下载。这个机制对用户体验至关重要——想象一下用户的 100GB 云盘里有 10 万个文件,你不可能在启动时全部下载。

值得深挖的点

Replicated Extension vs Non-Replicated Extension 的选择

FileProvider 提供两种扩展类型:NSFileProviderExtension(Non-Replicated,文件只存在于云端)和 NSFileProviderReplicatedExtension(Replicated,文件可以同时在本地和云端存在)。大多数云存储服务应该选择 Replicated 模式,因为它支持离线访问、本地缓存和增量同步。Non-Replicated 模式适合纯云端的文件浏览器。Replicated Extension 的实现更复杂,需要管理本地镜像(local mirror)和云端状态的双向同步。

NSFileProviderUserInteractions 的错误处理

同步过程中不可避免地会遇到冲突——用户在两台设备上同时修改了同一个文件。NSFileProviderUserInteractions 允许你定义用户可见的冲突解决界面。你可以通过 NSFileProviderUserInteractionsactionIdentifier 注册自定义的处理动作,Finder 会在冲突发生时展示你的解决方案(比如”保留云端版本”、“保留本地版本”或”保留两者”)。

代码片段

// 注册 FileProvider 域(Domain)
func setupProviderDomain() {
    let domain = NSFileProviderDomain(
        identifier: NSFileProviderDomainIdentifier("com.example.cloud"),
        displayName: "My Cloud Storage",
        pathRelativeToDocumentStorage: "MyCloud"
    )
    
    // 向系统注册域
    NSFileProviderManager.add(domain) { error in
        if let error = error {
            print("注册域失败: \(error)")
        } else {
            print("域注册成功,Finder 中将出现 My Cloud Storage")
        }
    }
}
// 实现 NSFileProviderReplicatedExtension 的核心方法
class CloudFileProvider: NSFileProviderReplicatedExtension {
    
    // 提供目录内容列表
    func enumerator(for containerItemIdentifier: NSFileProviderItemIdentifier) throws -> NSFileProviderEnumerator {
        return CloudEnumerator(containerItemIdentifier: containerItemIdentifier)
    }
    
    // 创建文件或目录
    func createItem(basedOn itemTemplate: NSFileProviderItem,
                    fields: NSFileProviderItemFields,
                    contents url: URL?,
                    options: NSFileProviderCreateItemOptions = [],
                    completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void) {
        
        Task {
            do {
                // 在云端创建文件
                let remoteItem = try await CloudAPI.createItem(
                    name: itemTemplate.filename,
                    parentID: itemTemplate.parentItemIdentifier.rawValue,
                    localURL: url
                )
                
                // 返回创建后的文件元数据
                let providerItem = CloudFileItem(remoteItem: remoteItem)
                completionHandler(providerItem, [], false, nil)
            } catch {
                completionHandler(nil, [], false, error)
            }
        }
    }
    
    // 获取文件内容(按需下载)
    func fetchContents(for itemIdentifier: NSFileProviderItemIdentifier,
                       version requestedVersion: NSFileProviderItemVersion?,
                       completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void) {
        
        Task {
            do {
                // 从云端下载文件到临时 URL
                let (localURL, metadata) = try await CloudAPI.downloadFile(
                    id: itemIdentifier.rawValue,
                    version: requestedVersion
                )
                completionHandler(localURL, metadata, nil)
            } catch {
                completionHandler(nil, nil, error)
            }
        }
    }
}
// 枚举器:列出目录内容
class CloudEnumerator: NSObject, NSFileProviderEnumerator {
    
    private let containerID: NSFileProviderItemIdentifier
    
    init(containerItemIdentifier: NSFileProviderItemIdentifier) {
        self.containerID = containerItemIdentifier
    }
    
    func enumerateItems(for observer: NSFileProviderEnumerationObserver, 
                        startingAt anchor: NSFileProviderSyncAnchor) {
        Task {
            do {
                // 从云端获取目录列表
                let items = try await CloudAPI.listDirectory(
                    id: containerID == .rootContainer ? "root" : containerID.rawValue
                )
                
                let providerItems = items.map { CloudFileItem(remoteItem: $0) }
                observer.didEnumerate(providerItems)
                observer.finishEnumerating(upTo: NSFileProviderSyncAnchor(data: Data()))
            } catch {
                observer.finishEnumerating(error: error)
            }
        }
    }
    
    // 监控变更:当云端文件发生变化时通知系统
    func startProvidingItem(at url: URL, completionHandler: @escaping (Error?) -> Void) {
        // 确保本地文件已下载
        // 如果文件已在本地缓存,直接返回
        completionHandler(nil)
    }
}

最佳实践

  • 初始同步时只拉取文件元数据(名称、大小、修改日期),不要下载文件内容。用户双击或通过 Quick Look 查看时才触发下载。
  • 同步冲突的默认策略应该是”保留最新修改的版本”,并允许用户通过 Finder 的冲突解决界面手动选择。
  • NSFileProviderManagersignalEnumerator(for:) 方法是你告诉 Finder”这个目录的内容变了,请刷新”的通道——任何云端变更后都要调用它。

还有什么值得关注

  • FileProvider 的 thumbnail 支持可以让 Finder 显示云端文件的缩略图,不需要下载完整文件。
  • Spotlight 通过 FileProvider 自动索引云端文件名——用户可以在 Spotlight 中搜索到未下载的云端文件。
  • macOS Monterey 的 NSFileProviderDomain 支持多个域,适合云存储服务中的”个人空间”和”团队空间”分离。
WWDC 2021