Build better document-based apps
System & Services 进阶 20m

构建更好的文档型应用

Build better document-based apps

2023年6月5日

在 Apple 官方观看视频

一句话判断

如果你的 iPad 应用涉及文档浏览、查看或编辑,iOS 17 新引入的 UIDocumentViewController 能让你的代码量锐减——它自动处理导航栏、分享、拖拽、撤销重做、重命名等一揽子事情,而且用 SwiftUI 的 DocumentGroup 甚至不需要额外代码。

这场 Session 讲了什么

Michael Ochs 系统性地介绍了 iOS 17 中针对文档型应用的两大改进:UIKit 中的 UIDocumentViewController 和 SwiftUI 中 DocumentGroup 的全面增强。

文档型应用分三类:浏览器型(如 Files)、查看器型(如 Quick Look)、编辑器型(如 Pages)。这次更新主要面向后两类,但部分内容也适用于浏览器型。

Session 的核心是 UIDocumentViewController 这个新的抽象基类。它和 UIDocument 配合工作,能自动配置导航栏,内置分享、拖拽文档、撤销重做、自动重命名等功能。更重要的是,它采用模块化设计——系统提供合理的默认行为,每个行为都可以单独定制。

在 SwiftUI 侧,DocumentGroup 已经自动支持了所有这些新功能,零额外代码。

值得深挖的点

UIDocument 的加载与保存机制:UIDocument 是抽象基类,需要为每种文件类型创建子类。它背后由 URL 驱动,支持文件系统路径,也支持数据库和自定义 URL scheme。加载和保存操作都是异步的,UIDocument 内部通过锁和队列保证线程安全。实现子类时需要关注两个职责:一是加载/保存逻辑,二是内容访问接口。

两种保存策略的选择:简单场景重写 loadFromContents:ofType:contentsForType: 就够了,content 是 Data 或 FileWrapper 类型。如果需要完全控制——比如文档存在数据库里,或者有特殊的读写需求——可以重写 saveToURL:forSaveOperation:readFromURL:,获得对 URL 的完整访问权。

documentDidOpen 与 viewDidLoad 的时序问题:UIDocumentViewController 不保证 documentDidOpenviewDidLoad 的调用顺序。Session 给出的最佳实践是把视图配置逻辑抽成独立方法,在两个回调中都检查条件后调用——这是一个容易踩的坑。

代码片段

// 创建 UIDocument 子类 - 以 Markdown 编辑器为例
class MarkdownDocument: UIDocument {
    var text: String = ""
    
    // 加载文档内容
    override func loadFromContents(_ contents: Any, ofType typeName: String?) throws {
        guard let data = contents as? Data else { return }
        text = String(data: data, encoding: .utf8) ?? ""
    }
    
    // 保存文档内容
    override func contents(forType typeName: String) throws -> Any {
        return text.data(using: .utf8) ?? Data()
    }
}

// 每次内容变更时通知文档需要保存
func textViewDidChange(_ textView: UITextView) {
    document.text = textView.text
    document.updateChangeCount(.done) // 标记为需要保存
}
// UIDocumentViewController 子类实现
class MarkdownEditorViewController: UIDocumentViewController {
    
    // 文档打开后的回调
    override func documentDidOpen() {
        configureViews()
    }
    
    // 视图加载完成
    override func viewDidLoad() {
        super.viewDidLoad()
        configureViews()
    }
    
    // 安全的视图配置方法
    private func configureViews() {
        guard isViewLoaded, let doc = document as? MarkdownDocument else { return }
        textView.text = doc.text
    }
    
    // 导航栏自定义
    override func navigationItemDidUpdate() {
        navigationItem.rightBarButtonItems = [undoRedoItemGroup.barButton]
    }
}

最佳实践

  • 务必调用 updateChangeCount:每次修改文档属性后都要调用,否则 UIDocument 不知道需要保存,自动保存机制不会触发。
  • 视图配置要做好防御性编程:不要假设 documentDidOpenviewDidLoad 的先后顺序,两个入口都要做条件检查。
  • 利用 undoRedoItemGroup:把系统提供的撤销重做按钮组放进导航栏,UIDocumentViewController 会自动管理其显示/隐藏和启用/禁用状态——前提是给文档分配了 undoManager。
  • 从 SiriKit Intent 迁移到 App Intents:Xcode 提供了一键转换功能,但转换后要确保参数名和类型与原来的 Intent Definition 完全一致,否则已配置的 Widget 会失效。
  • 无浏览器时的兜底方案:如果 UIDocumentViewController 是根视图控制器且没有浏览器层,它会自动在导航栏放置文档按钮,打开文档选择器。需要在 Info.plist 中声明 UIDocumentClass key。

还有什么值得关注

  • Session 提到 UIDocumentViewController 在没有关联文档时会自动显示空状态(empty state),这个行为和 “What’s new in UIKit” 中提到的空状态配置有关联。
  • 对于已经在用 UIDocument 的老项目,迁移到 UIDocumentViewController 不需要重写所有代码,只需让现有的视图控制器继承它并实现两个回调方法。
  • SwiftUI 的 DocumentGroup 已经内置了所有这些功能,新项目优先考虑 SwiftUI 方案。
  • UIDocument 的线程安全设计值得学习——它通过内部锁和队列机制协调多线程访问,开发者不需要自己处理并发问题。
WWDC 2023