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 决定。