WWDC Quick Look 💓 By SwiftGGTeam
Sync files to the cloud with FileProvider on macOS

Sync files to the cloud with FileProvider on macOS

观看原视频

Highlight

Apple 在 macOS Monterey 为 FileProvider 加入基于 APFS dataless 文件的云同步能力,让云存储服务可以用用户空间 extension 接入 Finder、按需下载文件、同步本地和远程变更,并替代依赖 FUSE 或 KAUTH 的内核扩展方案。

核心内容

云盘 App 在 macOS 上最难做的一件事,是让云端文件像本地文件一样工作。

用户希望在 Finder 侧边栏看到自己的云盘。目录要能打开。文件要能被 cat、TextEdit、Photoshop 这类普通进程读取。磁盘空间不足时,系统还要能安全地回收本地副本。

过去,很多云存储服务会拦截文件系统调用。常见方案是 FUSE 或 KAUTH。它们靠近内核,能做到按需下载,但代价很高:安全边界复杂,调试困难,macOS 也在弃用内核扩展。

Apple 在这场 session 里给出的替代方案,是 macOS 上的 FileProvider 框架(File Provider framework)。开发者实现一个 App Extension。系统负责把它接入 Finder 和文件系统。

这个 extension 只处理三类事情:上传、下载、告诉系统远端发生了什么变化。系统负责本地文件变化检测、Finder 状态显示、安全保存、磁盘空间回收,以及文件协调。

从空目录开始

用户登录云盘账号后,App 创建一个 domain(域)。domain 代表用户可以访问的一棵云端文件树。系统把它放进 Finder 侧边栏,并在文件系统里创建根目录。

这个根目录一开始没有真实文件内容。它是 APFS 提供的 dataless directory(无数据目录)。用户可以进入目录,看到文件名,但内容还没有下载到本机。

当进程读取 dataless file(无数据文件)时,内核暂停这次读取。系统调用 extension 的 fetchContents。extension 下载文件,交回一个本地 URL。系统把内容填进原来的文件,再恢复被暂停的读取。

远程变化由 extension 主动通知

云端有人新建、删除或修改文件时,本机不会自动知道。服务端可以向 Mac 发 push notification。App 收到通知后,告诉系统 .workingSet 有变化。

系统随后调用 working set 的 enumerator。extension 通过 syncAnchor(同步锚点)只返回上次之后的变化。系统再异步更新用户可见的文件。

这里的关键是 APFS compare-and-swap。系统更新本地文件时,会避免覆盖尚未上传的本地修改。它还会配合 file coordination(文件协调)和 advisory locking(建议锁),减少与其他 App 的冲突。

本地变化由系统归并后交给 extension

用户在本地保存文件时,系统检测变化,并调用 modifyItemcreateItemdeleteItem。extension 不需要处理每一个底层写入事件。

系统会把低层事件归并成适合同步的操作。例如,许多 App 使用 safe save(安全保存):先写临时文件,再替换原文件。系统会识别这种模式,并把 item identifier 映射到新的 file ID。

如果文件内容变了,系统会把变更后文件的 clone 交给 extension。这样,即使用户继续编辑,extension 也能上传一个一致的版本。

磁盘空间管理由系统兜底

本地文件上传完成后,系统知道它可以重新下载。磁盘空间紧张时,系统可以自动驱逐这类文件,把它们重新变成 dataless 文件。

这个过程不会调用 extension。系统会选择最少数量、最近最少使用的文件,为录制视频或下载系统更新腾出空间。

开发者仍然可以通过 FileProvider 的其他方法主动触发或阻止 eviction(驱逐),但紧急磁盘回收由系统处理。

详细内容

1. 创建 domain,把云盘放进 Finder

12:31)FileProvider 的入口是 domain。演讲中说,domain 通常对应云服务上的一次登录会话。它有唯一 identifier。创建后通过 manager 添加,Finder 侧边栏就会出现入口。

import FileProvider

func addFruitBasketDomain() {
    let domain = NSFileProviderDomain(
        identifier: NSFileProviderDomainIdentifier("fruitbasket"),
        displayName: "FruitBasket"
    )

    NSFileProviderManager.add(domain) { error in
        if let error = error {
            NSLog("Failed to add FileProvider domain: \(error)")
            return
        }

        NSLog("FileProvider domain added")
    }
}

关键点:

  • import FileProvider 引入 FileProvider 框架。
  • NSFileProviderDomain 表示一棵可同步的文件树。
  • identifier 是 domain 的唯一标识,通常和用户登录会话绑定。
  • displayName 是 Finder 侧边栏显示给用户看的名字。
  • NSFileProviderManager.add 把 domain 注册给系统。
  • completion handler 用来接收注册失败的错误。

如果用户退出账号,可以移除 domain。演讲提到,这在开发测试阶段也有用,因为你会反复清理旧的 Finder 入口。

import FileProvider

func removeFruitBasketDomain(_ domain: NSFileProviderDomain) {
    NSFileProviderManager.remove(domain) { error in
        if let error = error {
            NSLog("Failed to remove FileProvider domain: \(error)")
            return
        }

        NSLog("FileProvider domain removed")
    }
}

关键点:

  • 参数 domain 是要移除的 FileProvider domain。
  • NSFileProviderManager.remove 告诉系统删除这个入口。
  • 用户登出时调用它,可以让 Finder 不再显示该云盘。
  • 开发时调用它,可以清掉测试 domain。

2. 枚举目录,让 dataless 文件先出现在本地

04:26)用户打开目录时,内核检测到 readdir 调用并暂停。系统调用 extension 的 enumerator。extension 从服务端取元数据,然后把若干 item 返回给系统。

演讲强调,目录枚举是分页的。你可以先返回一部分 item。系统会从上一次停下的位置继续请求。

import FileProvider

final class FruitEnumerator: NSObject, NSFileProviderEnumerator {
    func enumerateItems(
        for observer: NSFileProviderEnumerationObserver,
        startingAt page: NSFileProviderPage
    ) {
        let items: [NSFileProviderItem] = loadMetadataPage(startingAt: page)
        let nextPage: NSFileProviderPage? = nextPageAfter(page)

        observer.didEnumerate(items)
        observer.finishEnumerating(upTo: nextPage)
    }
}

关键点:

  • FruitEnumerator 实现 NSFileProviderEnumerator
  • enumerateItems 在系统需要目录内容时调用。
  • observer 是 extension 回复系统的对象。
  • page 表示这次枚举从哪一页开始。
  • loadMetadataPage 代表从服务端读取文件名、大小、版本等元数据。
  • observer.didEnumerate(items) 把本页 item 交给系统。
  • observer.finishEnumerating(upTo:) 结束本页,并告诉系统是否还有下一页。

这些 item 会变成 Finder 里可见的目录项。文件内容还没下载,所以它们仍然是 dataless 文件。

3. 读取 dataless 文件时下载内容

03:36)当进程读取 dataless 文件,系统调用 fetchContents。extension 下载文件内容到磁盘上的某个位置,然后用 completion handler 把 URL 交给系统。

import FileProvider

final class FruitProvider: NSObject, NSFileProviderReplicatedExtension {
    func fetchContents(
        for itemIdentifier: NSFileProviderItemIdentifier,
        version requestedVersion: NSFileProviderItemVersion?,
        request: NSFileProviderRequest,
        completionHandler: @escaping (URL?, NSFileProviderItem?, Error?) -> Void
    ) -> Progress {
        let progress = Progress(totalUnitCount: 100)

        downloadFile(for: itemIdentifier, version: requestedVersion) { localURL, item, error in
            if let error = error {
                completionHandler(nil, nil, error)
                return
            }

            completionHandler(localURL, item, nil)
        }

        return progress
    }
}

关键点:

  • FruitProvider 实现 NSFileProviderReplicatedExtension
  • itemIdentifier 是系统要求下载的文件。
  • requestedVersionnil 时,文档说明下载最新版本。
  • request 携带这次系统请求的信息。
  • completionHandler 的第一个参数是下载后的本地文件 URL。
  • completionHandler 的第二个参数是文件的最终 item 状态。
  • 返回 Progress,系统可以在 Finder 里显示下载进度。

演示里,讲者用 Terminal 执行 cat 读取一个 dataless 文件。Xcode 断点停在 fetchContents 时,cat 也被阻塞。Finder 里的云图标变成进度指示器。completion handler 调用后,系统把下载好的内容替换到用户可见文件里,cat 继续执行。

11:58)同一个文件第二次执行 cat 时,不再触发断点。文件已经是普通本地文件,读取不会再调用 extension。

4. 用 working set 同步远程变化

05:27)远程变化的入口是 .workingSet。服务端有变化时,App 通知系统。系统随后向 working set enumerator 请求自上次 syncAnchor 之后的变化。

import FileProvider

func notifyRemoteChanges() {
    NSFileProviderManager.default.signalEnumerator(for: .workingSet) { error in
        if let error = error {
            NSLog("Failed to signal working set: \(error)")
            return
        }

        NSLog("Working set signaled")
    }
}

关键点:

  • .workingSet 是演讲中提到的特殊 enumerator。
  • signalEnumerator(for:) 提醒系统:指定位置有内容变化。
  • completion handler 只表示 signal 操作完成,不表示系统已经拉取完变化。
  • 远程 push notification 到达 Mac 后,可以调用这个方法。

working set enumerator 需要实现当前同步锚点,以及从某个锚点开始的变化枚举。

import FileProvider

final class WorkingSetEnumerator: NSObject, NSFileProviderEnumerator {
    func currentSyncAnchor(
        completionHandler: @escaping (NSFileProviderSyncAnchor?) -> Void
    ) {
        completionHandler(loadCurrentServerChangeToken())
    }

    func enumerateChanges(
        for observer: NSFileProviderChangeObserver,
        from syncAnchor: NSFileProviderSyncAnchor
    ) {
        let changes = loadServerChanges(after: syncAnchor)

        observer.didUpdate(changes.updatedItems)
        observer.didDeleteItems(withIdentifiers: changes.deletedItemIdentifiers)
        observer.finishEnumeratingChanges(upTo: changes.newSyncAnchor, moreComing: false)
    }
}

关键点:

  • currentSyncAnchor 返回当前服务端变化游标。
  • syncAnchor 由 extension 定义,系统只负责保存上次枚举到的位置。
  • enumerateChanges 返回指定锚点之后的变化。
  • observer.didUpdate 把新增或修改的 item 返回给系统。
  • observer.didDeleteItems 把删除的 item identifier 返回给系统。
  • finishEnumeratingChanges 交回新的锚点,供系统下次使用。

演讲里说,系统随后会异步更新用户可见文件,并用 APFS compare-and-swap 避免丢失本地变化。

5. 上传本地创建、修改和删除

06:50)本地修改走相反方向。系统检测本地 item 变化,然后调用 extension 的 modifyItem。参数里包含精确的 changed fields(变化字段)。

14:37)演讲建议最后实现 sync up,并展示了 create flow。系统把新 item、关注字段和内容 URL 交给 extension。extension 上传到服务器,再用 completion handler 返回远端确认后的 item。

import FileProvider

final class UploadingProvider: NSObject, NSFileProviderReplicatedExtension {
    func createItem(
        basedOn itemTemplate: NSFileProviderItem,
        fields: NSFileProviderItemFields,
        contents url: URL?,
        options: NSFileProviderCreateItemOptions,
        request: NSFileProviderRequest,
        completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void
    ) -> Progress {
        let progress = Progress(totalUnitCount: 100)

        uploadNewItem(itemTemplate, changedFields: fields, contents: url) { remoteItem, remainingFields, error in
            if let error = error {
                completionHandler(nil, fields, false, error)
                return
            }

            completionHandler(remoteItem, remainingFields, false, nil)
        }

        return progress
    }
}

关键点:

  • itemTemplate 是系统要求创建的 item。
  • fields 描述哪些字段需要处理,例如内容或扩展属性。
  • url 是文件内容位置;文件夹和符号链接可能没有内容。
  • uploadNewItem 代表把本地新 item 上传到服务端。
  • remoteItem 是服务端接受后的最终 item 状态。
  • completion handler 确认系统已经把这次变更交给 extension。

本地修改同理。系统调用 modifyItem,并在内容变化时提供一个文件 clone,让上传过程拿到稳定版本。

import FileProvider

final class ModifyingProvider: NSObject, NSFileProviderReplicatedExtension {
    func modifyItem(
        _ item: NSFileProviderItem,
        baseVersion version: NSFileProviderItemVersion,
        changedFields: NSFileProviderItemFields,
        contents newContents: URL?,
        options: NSFileProviderModifyItemOptions,
        request: NSFileProviderRequest,
        completionHandler: @escaping (NSFileProviderItem?, NSFileProviderItemFields, Bool, Error?) -> Void
    ) -> Progress {
        let progress = Progress(totalUnitCount: 100)

        uploadModifiedItem(item, baseVersion: version, changedFields: changedFields, contents: newContents) { finalItem, remainingFields, error in
            if let error = error {
                completionHandler(nil, changedFields, false, error)
                return
            }

            completionHandler(finalItem, remainingFields, false, nil)
        }

        return progress
    }
}

关键点:

  • item 是本地修改后的 item。
  • version 是这次修改基于的版本。
  • changedFields 告诉 extension 哪些字段发生了变化。
  • newContents 是系统提供的稳定内容 URL。
  • finalItem 是服务端处理完成后的最终状态。
  • 如果服务端检测到冲突,可以通过最终 item 状态让本地文件反映云端真相。

6. Finder 集成:图标装饰、菜单和预检提醒

15:59)FileProvider 还提供可选的系统集成点。

图标装饰可以给 Finder item 加 badge、给文件夹做 emboss,或者显示共享状态。开发者通过 App 中声明的 UTType 提供自定义 artwork。

<key>NSExtensionFileProviderDecorations</key>
<array>
    <dict>
        <key>Identifier</key>
        <string>com.example.fruitbasket.shared</string>
        <key>BadgeImageType</key>
        <string>com.example.fruitbasket.shared-badge</string>
        <key>Category</key>
        <string>Badge</string>
    </dict>
</array>

关键点:

  • NSExtensionFileProviderDecorations 在 extension 的 Info.plist 中声明装饰。
  • Identifier 是装饰的标识。
  • BadgeImageType 指向 App 中声明的 UTType artwork。
  • Category 表示这类装饰用于 badge。

上下文菜单动作让用户在 Finder 右键菜单里执行自定义操作。演讲提到,动作有带 UI 和不带 UI 两种,适用文件由 Info.plist 中的 NSPredicate 决定。

<key>NSExtensionFileProviderActions</key>
<array>
    <dict>
        <key>Identifier</key>
        <string>com.example.fruitbasket.share</string>
        <key>ActivationRule</key>
        <string>TRUEPREDICATE</string>
    </dict>
</array>

关键点:

  • NSExtensionFileProviderActions 声明 Finder 上下文菜单动作。
  • Identifier 区分不同动作。
  • ActivationRule 用 predicate 控制哪些文件显示这个动作。
  • 适合实现“共享链接”“查看云端版本”“在网页中打开”这类操作。

预检提醒用于在用户执行可能有副作用的操作前弹出警告。演讲说明,提醒 UI 和触发条件也通过 Info.plist 配置。

核心启发

  1. 做什么:给团队云盘做原生 Finder 入口。 为什么值得做:FileProvider domain 可以把每个登录账号映射成 Finder 侧边栏入口,用户不用进入你的 App 才能找文件。 怎么开始:先创建 NSFileProviderDomain,用 NSFileProviderManager.add 注册;再实现目录 enumerator,让根目录可以列出服务端文件夹。

  2. 做什么:做一个“只在打开时下载”的素材库。 为什么值得做:APFS dataless 文件让 Finder 先显示完整目录结构,内容读取时才触发 fetchContents,可以节省本地磁盘。 怎么开始:让 enumerator 返回远端素材元数据;在 fetchContents(for:version:request:completionHandler:) 中下载原图或视频到本地 URL。

  3. 做什么:给设计文件加 Finder 共享状态 badge。 为什么值得做:图标装饰可以直接在 Finder 中显示文件共享状态,用户不用打开网页查看权限。 怎么开始:在 Info.plist 声明 FileProvider decorations,并用 item metadata 决定哪些文件应用共享 badge。

  4. 做什么:实现 Finder 右键“复制共享链接”。 为什么值得做:上下文菜单动作可以把云服务的高频操作放到文件旁边,减少用户在 Finder 和网页之间切换。 怎么开始:在 Info.plist 声明 FileProvider action,用 predicate 限定普通文件或文件夹,再在 action handler 中请求服务端生成链接。

  5. 做什么:给大文件上传做安全的本地编辑同步。 为什么值得做:系统在内容变化时提供文件 clone,extension 可以上传稳定版本,不会被用户后续编辑打断。 怎么开始:实现 modifyItem(_:baseVersion:changedFields:contents:options:request:completionHandler:),只在 changedFields 包含内容变化时读取 contents URL 并上传。

关联 Session

评论

GitHub Issues · utterances