5.3 更新你的基于文稿的应用

原文链接: https://developer.apple.com/documentation/swiftui/updating-your-document-based-app

5.3 更新你的基于文稿的应用

把一个已有应用迁移到使用 URL 的方式读写文稿,并结合 Swift 并发。

概述

如果你已有一个基于文稿的应用,可以采用 Document 协议,从而获得直接 URL 访问、与 Swift 并发集成以及现代观察机制等好处。Document 协议把读和写分离到各自的专用类型中,让你对文件 I/O 有更多控制,并能对复杂的文稿格式实现部分读取和写入。

在早于 iOS 27、iPadOS 27、macOS 27 和 visionOS 27 的版本中,你通过遵循 FileDocument 或 ReferenceFileDocument 来创建文稿类型。在这些以及更新的版本中,你可以依据应用的实际需要,遵循 Document 协议,或者分别遵循 ReadableDocument 与 WritableDocument 协议。尽管 FileDocument 和 ReferenceFileDocument 仍然可用,但它们已不再支持用于新的文稿类型。

下表列出这三个协议之间的差异,帮助你选择合适的迁移路径:

FileDocumentReferenceFileDocumentDocument
类型值类型(struct)引用类型(class)引用类型(class)
读取init(configuration:)init(configuration:)reader(configuration:) + apply(snapshot:previous:)
写入fileWrapper(configuration:)fileWrapper(snapshot:configuration:)writer(configuration:) + snapshot(contentType:)
读写执行方式同步同步async/sending,并有显式的 actor 边界
文件访问仅 FileWrapper仅 FileWrapperURL 或 FileWrapper
撤销自动(值语义)手动(UndoManager)手动(UndoManager)
观察机制不适用(值类型)ObservableObject@Observable

更新你的应用

依据应用使用的是哪种已弃用协议,选择相应的标签页并按照清单更新应用:

FileDocument

  1. 从结构体改为 @Observable 类。 遵循 FileDocument 的文稿类型通常是值类型。Document 协议要求引用类型。请把你的结构体替换为用 @Observable 标注的 final class。
  2. 把读取逻辑分离到 DocumentReader 中。 读取时使用 FileWrapperDocumentReader,并提供一个把 FileWrapper 转换成快照值的闭包。
  3. 实现 apply(snapshot:previous:)。 当读取器送来新的快照时,用这个方法更新文稿的属性。
  4. 把写入逻辑分离到 DocumentWriter 中。 写入时使用 FileWrapperDocumentWriter,并提供一个把快照转换成 FileWrapper 的闭包。
  5. 实现 snapshot(contentType:)。 添加这个方法,在主 actor 上捕获文稿的当前状态。把它标记为 async throws,并返回 sending 值。
  6. 添加撤销注册。 使用 FileDocument 时,SwiftUI 通过值语义和 Binding 自动管理撤销。使用 Document 协议时,你需要用 UndoManager 自己注册撤销操作。内容视图环境中取得的撤销管理器已经与该文稿连接。
  7. 更新你的 DocumentGroup 构造器。 把 DocumentGroup(newDocument:) 替换为基于闭包、接收 URLDocumentConfiguration 和 DocumentCreationContext 的构造器。
  8. 更新你的内容视图。 把 @Binding var document: MyDocument 替换为对可观察类的直接引用,并用 @Bindable 创建绑定。

ReferenceFileDocument

  1. 把文稿标注为 @Observable。 ReferenceFileDocument 协议早于 Observation 框架。请添加 @Observable 宏,并移除任何 ObservableObject 遵循和 @Published 属性包装器。
  2. 把读取逻辑分离到 DocumentReader 中。 读取时使用 FileWrapperDocumentReader,并提供一个把 FileWrapper 转换成快照值的闭包。
  3. 实现 apply(snapshot:previous:)。 当读取器送来新的快照时,用这个方法更新文稿的属性。
  4. 把写入逻辑分离到 DocumentWriter 中。 写入时使用 FileWrapperDocumentWriter,并提供一个把快照转换成 FileWrapper 的闭包。
  5. 实现 snapshot(contentType:)。 更新该方法,为返回类型加上 async throws 和 sending,以便安全跨越并发边界传递。
  6. 更新你的 DocumentGroup 构造器。 把基于类型的构造器替换为基于闭包、接收 URLDocumentConfiguration 和 DocumentCreationContext 的构造器。
  7. 检查撤销注册。 撤销注册在概念上是一样的,但要确认这些改动之后你的撤销操作仍然正常工作。

如果你现有的基于文稿的应用使用 FileDocument 或 ReferenceFileDocument,下表展示这些概念如何映射到 Document 协议:

FileDocument

迁移前迁移后
FileDocumentDocument
struct(值类型)@Observable final class(引用类型)
init(configuration:)独立的 DocumentReader
fileWrapper(configuration:)独立的 DocumentWriter
隐式快照(值语义)显式的 snapshot(contentType:) 方法
通过 Binding 自动撤销用 UndoManager 手动注册撤销
DocumentGroup(newDocument:)DocumentGroup 构造器

ReferenceFileDocument

迁移前迁移后
ReferenceFileDocumentDocument
init(configuration:)独立的 DocumentReader
fileWrapper(snapshot:configuration:)独立的 DocumentWriter
仅 FileWrapperFileWrapper 以及通过 DocumentReader 的 URL 访问
ReferenceFileDocument 上的单一 Snapshot 类型DocumentWriter 和 DocumentReader 上各自的 Snapshot 类型

下面的例子展示了一个完整的文本文稿在从 FileDocument 迁移前后的样子:

迁移前

 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
struct TextDocument: FileDocument {
    static let readableContentTypes: [UTType] = [.plainText]

    var text: String

    init(configuration: ReadConfiguration) throws {
        guard let data = configuration.file.regularFileContents,
              let string = String(data: data, encoding: .utf8)
        else {
            throw CocoaError(.fileReadCorruptFile)
        }
        text = string
    }

    func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
        let data = Data(text.utf8)
        return FileWrapper(regularFileWithContents: data)
    }
}

struct TextDocumentApp: App {
    var body: some Scene {
        DocumentGroup(newDocument: TextDocument()) { file in
            TextEditor(text: file.$document.text)
        }
    }
}

迁移后

 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
@Observable
final class TextDocument: Document {
    static let readableContentTypes: [UTType] = [.plainText]

    var text: String

    init(text: String = "") {
        self.text = text
    }

    func reader(configuration: sending ReadConfiguration) -> sending FileWrapperDocumentReader<String> {
        FileWrapperDocumentReader(configuration) { fileWrapper in
            guard let data = fileWrapper.regularFileContents else {
                throw CocoaError(.fileReadCorruptFile)
            }
            return String(decoding: data, as: UTF8.self)
        }
    }

    func writer(configuration: sending WriteConfiguration) -> sending FileWrapperDocumentWriter<String> {
        FileWrapperDocumentWriter(configuration) { snapshot in
            FileWrapper(
                regularFileWithContents: Data(snapshot.utf8)
            )
        }
    }

    @MainActor
    func snapshot(contentType: UTType) async throws -> sending String {
        text
    }

    @MainActor
    func apply(snapshot: sending String, previous: sending String?) async throws {
        text = snapshot
    }
}

struct TextDocumentApp: App {
    var body: some Scene {
        DocumentGroup { document in
            TextDocumentView(document: document)
        } makeDocument: { _, _ in
            TextDocument()
        }
    }
}

struct TextDocumentView: View {
    @Bindable var document: TextDocument
    @Environment(\.undoManager) private var undoManager

    var body: some View {
        TextEditor(text: $document.text)
            .onChange(of: document.text) { oldValue, _ in
                undoManager?.registerUndo(
                    withTarget: document
                ) { document in
                    document.text = oldValue
                }
            }
    }
}

关键的结构性变化是从值语义转向引用语义。使用 FileDocument 时,SwiftUI 通过指向结构体的 Binding 跟踪变更,并自动管理撤销。使用 Document 时,你用 @Observable 跟踪变更并显式注册撤销操作——但换来的是异步 I/O、基于 URL 的文件访问,以及状态捕获与序列化之间的清晰分离。

下面的例子展示了一个完整的文本文稿在从 ReferenceFileDocument 迁移前后的样子:

迁移前

 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
final class OldTextDocument: ReferenceFileDocument {
    typealias Snapshot = String

    static let readableContentTypes = [UTType.utf8PlainText]

    @Published var text: String

    init() {
        text = ""
    }

    required init(configuration: ReadConfiguration) throws {
        if let data = configuration.file.regularFileContents {
            text = String(data: data, encoding: .utf8) ?? ""
        } else {
            text = ""
        }
    }

    func snapshot(contentType: UTType) throws -> String {
        text
    }

    func fileWrapper(
        snapshot: String, configuration: WriteConfiguration
    ) throws -> FileWrapper {
        let data = snapshot.data(using: .utf8) ?? Data()
        return FileWrapper(regularFileWithContents: data)
    }
}

迁移后

 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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
@Observable
final class TextDocument: Document {
    static let readableContentTypes = [UTType.utf8PlainText]

    var text: String

    init() {
        text = ""
    }

    func reader(configuration: sending ReadConfiguration) -> sending FileWrapperDocumentReader<String> {
        FileWrapperDocumentReader(configuration) { fileWrapper in
            if let data = fileWrapper.regularFileContents,
               let text = String(data: data, encoding: .utf8) {
                return text
            }
            return ""
        }
    }

    @MainActor
    func apply(snapshot: sending String, previous: sending String?) async throws {
        text = snapshot
    }

    func writer(configuration: sending WriteConfiguration) -> sending FileWrapperDocumentWriter<String> {
        FileWrapperDocumentWriter(configuration) { snapshot, previous in
            let data = snapshot.data(using: .utf8) ?? Data()
            return FileWrapper(regularFileWithContents: data)
        }
    }

    @MainActor
    func snapshot(contentType: UTType) async throws -> sending String {
        text
    }
}

struct TextDocumentView: View {
    @Bindable var document: TextDocument
    @Environment(\.undoManager) private var undoManager

    var body: some View {
        TextEditor(text: $document.text)
            .onChange(of: document.text) { oldValue, _ in
                undoManager?.registerUndo(
                    withTarget: document
                ) { document in
                    document.text = oldValue
                }
            }
    }
}

迁移后的文稿清晰地分离了各项职责,既支持用于高级场景的 URL 访问,也采用了 Observation 框架,并与 Swift 并发集成。