5.2 处理高级文稿场景

原文链接: https://developer.apple.com/documentation/swiftui/handling-advanced-document-scenarios

5.2 处理高级文稿场景

扩展你的基于文稿的应用,支持自定义文件格式、按需文件访问和进度报告。

概述

在创建出一个可用的基于文稿的应用之后,你可以扩展它来处理超出基本读写范围的场景。用自定义的 UTType 来支持系统默认不识别的文件格式。用 makeFileCoordinator() 在常规读写生命周期之外访问文件,例如按需打开包中的某个子文件。添加进度报告,以便在长时间操作期间给人们反馈。

注意:如果你刚接触 SwiftUI 中基于文稿的应用,请先从创建基于文稿的应用入手。

声明自定义文件格式

系统知道常见的内置格式及其标识符,例如 utf8PlainText、jpeg 和 markdown。对于你自己的文件格式,请声明一个自定义 UTType,让「访达」、文稿浏览器和 Spotlight 都能识别它们。

每个自定义类型都需要一个基础类型,用来告诉系统它是什么样的文件。单文件文稿请使用 public.data 或遵循它的类型作为基础类型;对于系统以目录形式存储但呈现为单个文件的文稿,请使用 com.apple.package。

在应用目标的信息属性列表中的 UTExportedTypeDeclarations 下声明该内容类型,让「访达」、文稿浏览器和 Spotlight 都能识别它。把 UTTypeConformsTo 设为匹配的基础类型,并添加 UTTypeTagSpecification,把该类型映射到你的文件扩展名。下面的例子声明了一个 notebook 包;对于扁平文件格式,把 com.apple.package 换成 public.data 即可,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
<key>UTExportedTypeDeclarations</key>
<array>
    <dict>
        <key>UTTypeIdentifier</key>
        <string>com.example.notebook</string>
        <key>UTTypeConformsTo</key>
        <array>
            <string>com.apple.package</string>
        </array>
        <key>UTTypeTagSpecification</key>
        <dict>
            <key>public.filename-extension</key>
            <array>
                <string>examplenotebook</string>
            </array>
        </dict>
    </dict>
</array>

为了方便,请在代码中镜像这份声明,如下所示:

1
2
3
extension UTType {
    static let notebook = UTType(exportedAs: "com.example.notebook")
}

然后在文稿的 readableContentTypes 和 writableContentTypes 中引用你的类型,如下所示:

1
2
static let readableContentTypes: [UTType] = [.notebook]
static let writableContentTypes: [UTType] = [.notebook, .utf8PlainText]

关于为私有格式声明统一类型标识符的更多内容,参见为应用定义文件与数据类型。

注意:每台 Mac 都会注册系统提供的标准内容类型,但每台电脑能识别的较冷门类型的集合取决于它所安装的软件。例如,如果没有已安装的应用声明某些媒体类型,这台电脑就可能无法识别它们。同样,没有安装 Xcode 的电脑不会识别 com.apple.xcode.resultbundle,也就是 Xcode 结果包的标识符。

在读写之外访问文件

SwiftUI 会为 read 和 write 调用自动协调文件访问。要在其他时机访问文件 URL——例如读取包中某个特定子文件时——请使用配置中的文件协调器,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
let coordinator = document.configuration.makeFileCoordinator()
coordinator.coordinate(
    readingItemAt: packageURL.appending(path: "metadata.json"),
                       options: []) { url in
    do {
        let data = try Data(contentsOf: url)
        let metadata = try JSONDecoder().decode(NotebookMetadata.self, from: data)
        // 处理元数据。
    } catch {
        // 处理错误。
    }
}

重要:在 read 和 write 之外访问磁盘时,请始终使用 makeFileCoordinator()。直接访问文件 URL 有损坏数据的风险,因为包括 iCloud 在内的其他进程随时可能更改它。

报告进度

包让你可以增量读写;不必在每次改动时都加载或存储整个文稿,你可以只读取需要的文件、只写入发生变化的文件。对于由多个文件组成的 notebook 文稿、把数据存放在独立文件中的自定义图像格式,或者内嵌媒体的项目,这可能是快速自动存储与缓慢自动存储之间的差别。

DocumentReader 和 DocumentWriter 分别通过它们的 read 和 write 方法接收一个 Subprogress 参数。请通过该参数报告进度,这样 SwiftUI 就能在长时间操作期间显示合适的界面。

从 Subprogress 创建 ProgressReporter 时,要指定总单位数。然后在工作完成时调用 complete(count:),如下所示:

1
2
3
4
5
6
7
8
9
@concurrent 
func read(from source: URL, progress: consuming Subprogress) async throws -> sending ImageSnapshot {
    let progressManager = progress.start(totalCount: 2)
    let data = try Data(contentsOf: source)
    progressManager.complete(count: 1)
    let image = try decodeImage(from: data)
    progressManager.complete(count: 1)
    return ImageSnapshot(image: image)
}

对于包文稿,你可以把每个文件视为等量的一份工作,如下所示。这种做法能在写入器逐个处理文件时给出细粒度的反馈。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@concurrent
func write(
    snapshot: sending NotebookSnapshot, to destination: URL,
    previous: sending NotebookSnapshot?, progress: consuming Subprogress
) async throws {
    let changedPages = snapshot.pages.filter { (identifier, content) in
        previous?.pages[identifier] != content
    }

    // 元数据占一个单位,每个发生变化的页面占一个单位。
    let totalUnits = 1 + changedPages.count
    let progressManager = progress.start(totalCount: totalUnits)

    // 写入元数据。
    let metadataURL = destination.appending(path: "metadata.json")
    let metadataData = try JSONEncoder().encode(snapshot.metadata)
    try metadataData.write(to: metadataURL, options: .atomic)
    progressManager.complete(count: 1)

    // 逐个写入每个发生变化的页面。
    let fileManager = FileManager.default
    let pagesDirectory = destination.appending(path: "pages")

    // 如果 pages 子目录还不存在,就创建它。
    try? fileManager.createDirectory(
        at: pagesDirectory, withIntermediateDirectories: true
    )

    for (identifier, content) in changedPages {
        let pageURL = pagesDirectory.appending(
            path: "\(identifier.uuidString).txt"
        )
        let data = Data(content.utf8)
        try data.write(to: pageURL, options: .atomic)
        progressManager.complete(count: 1)
    }
}

如果你使用 FileHandle 或 OutputStream 分块写入文件——例如写入一个大型媒体文件——请在每写完一块后报告进度,而不是只在最后报告一次。每次调用 complete(count:) 都会把进度条向前推进,因此请把这些调用分散到整个写入过程中。

下面的例子把大型媒体文件分块写入磁盘。把总单位数设为文件的字节大小,并在每写完一块后调用报告器,让进度条平滑更新。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@concurrent
func write(
    snapshot: sending MediaSnapshot, to destination: URL,
    previous: sending MediaSnapshot?, progress: consuming Subprogress
) async throws {
    let payload = snapshot.payload
    let totalBytes = payload.count
    let progressManager = progress.start(totalCount: totalBytes)

    try Data().write(to: destination)
    let fileHandle = try FileHandle(forWritingTo: destination)
    defer { try? fileHandle.close() }

    // 目标是在整个写入过程中大约报告 100 次进度;并加以限制,
    // 让块足够大以摊薄系统调用开销,又足够小以便在极小负载上
    // 也能让进度条持续前进。
    let targetUpdateCount = 100
    let minimumChunkSize = 64 * 1024        //  64 KB
    let maximumChunkSize = 4 * 1024 * 1024  //   4 MB
    let chunkSize = min(
        maximumChunkSize,
        max(minimumChunkSize, totalBytes / targetUpdateCount)
    )

    var offset = 0
    while offset < totalBytes {
        let end = min(offset + chunkSize, totalBytes)
        let chunk = payload[offset..<end]
        try fileHandle.write(contentsOf: chunk)
        progressManager.complete(count: end - offset)
        offset = end
    }
}

块大小会随文件大小自适应,因此大文件不会产生不必要的系统开销。如果你改为流式写入 OutputStream,同样的模式依然适用:打开流,按计算出的块大小写入数据,用已写入的字节数调用 complete(count:),并在完成时调用 ProgresManager.complete(count:)。

注意:即使你报告了进度,是否显示进度视图仍由 SwiftUI 决定。