WWDC Quick Look 💓 By SwiftGGTeam
Build better document-based apps

Build better document-based apps

观看原视频

Highlight

iPadOS 17 引入 UIDocumentViewController 作为文档应用的内容视图控制器基类,自动提供分享、拖拽、撤销重做、自动重命名等功能,让开发者从文档管理 boilerplate 中解放出来,专注应用本身的特色功能。

核心内容

文档应用的三层分类

文档应用分三类:浏览器型(如 Files)、查看器型(如 Quick Look)、编辑器型(如 Pages)。这次更新主要面向查看器和编辑器,但浏览器也能受益。

UIDocument:文档模型的核心

UIDocument 是每个文档应用的根基。它是一个抽象基类,需要为每种支持的文件类型创建子类。所有 UIDocument 都基于 URL——可以是磁盘文件,也可以是自定义 URL scheme 的数据库。

加载和保存是异步的,UIDocument 内部处理了线程安全和文件协调。开发者只需要关心两件事:加载/保存的实现,以及文档内容的访问接口。

对于简单的文件型文档,重写两个便捷方法就够了:load(fromContents:ofType:) 在打开时调用,contents(forType:) 在保存时调用。如果需要完全控制(比如存到数据库),可以重写 save(to:for:completionHandler:)read(from:)

内容访问与自动保存

给 UIDocument 子类添加属性来暴露文档内容。每次属性变化时调用 updateChangeCount(.done),UIDocument 就知道文档需要保存,并在合适的时机自动触发保存。

UIDocumentViewController:自动化的文档展示

这是 iPadOS 17 的新基类,和 UIDocument 配合工作。它自动处理:

  • 导航栏标题和标题菜单
  • 文档分享
  • 拖拽文档
  • 撤销重做快捷键
  • 自动重命名

采用模块化设计,系统提供合理的默认行为,每个行为都可以单独定制。

关键回调方法

documentDidOpen() 在文档打开后调用,用来配置视图显示文档内容。navigationItemDidUpdate() 在导航项更新后调用,用来添加自定义导航按钮。

这两个方法的调用时机没有保证,所以视图配置要同时从 documentDidOpen()viewDidLoad() 调用,并检查 isViewLoaded 和文档状态。

撤销重做

把系统提供的 undoRedoItemGroup 放进导航栏,UIDocumentViewController 会自动管理它的显示/隐藏和按钮的启用/禁用。前提是给文档分配了 undoManager

自动重命名

iPadOS 17 中 UIDocument 遵循 UINavigationItemRenameDelegate,自动处理重命名时的文件操作。用 UIDocumentViewController 时自动配置,不用时手动设置 navigationItem.renameDelegate = document

无浏览器时的兜底

如果 UIDocumentViewController 是根视图控制器且没有浏览器层,它会在导航栏自动放置文档按钮,点击打开文档选择器。需要在 Info.plist 中声明 UIDocumentClass key,指向对应的 UIDocument 子类。

迁移三步走

  1. 把内容视图控制器的基类改成 UIDocumentViewController
  2. 把现有代码移到新的回调方法(documentDidOpen()navigationItemDidUpdate()
  3. 删除不再需要的代码(重命名委托、导航项配置、文档属性更新等)

详细内容

加载文档

03:54

override func load(fromContents contents: Any, ofType typeName: String?) throws {
    // Load your document from contents
    guard let data = contents as? Data,
          let text = String(data: data, encoding: .utf8) else {
        throw DocumentError.readError
    }
    self.text = text
}

关键点:

  • load(fromContents:ofType:) 在文档打开时调用
  • contents 对普通文件是 Data,对包文件是 FileWrapper
  • 解析失败时抛出错误,UIDocument 会处理错误状态

保存文档

04:08

override func contents(forType typeName: String) throws -> Any {
    // Encode your document with an instance of NSData or NSFileWrapper
    guard let data = self.text?.data(using: .utf8) else {
        throw DocumentError.writeError
    }
    return data
}

关键点:

  • contents(forType:) 在保存时调用
  • 返回 Data 或 FileWrapper
  • UIDocument 自动处理异步保存和文件协调

手动保存和加载

04:34

override func save(to url: URL,
                   for saveOperation: UIDocument.SaveOperation,
                   completionHandler: ((Bool) -> Void)? = nil) {
    self.performAsynchronousFileAccess {
        // Set up file coordination and write file to URL
   }
}

override func read(from url: URL) throws {
    // Set up file coordination and read file from URL
}

关键点:

  • 需要完全控制读写时使用这对方法
  • save(to:for:completionHandler:) 是异步的
  • read(from:) 是同步的,需要在返回前完成读取
  • performAsynchronousFileAccess 确保文件访问的线程安全

标记文档需要保存

05:08

class Document: UIDocument {
    var text: String? {
        didSet {
            if oldValue != nil && oldValue != text {
                self.updateChangeCount(.done)
            }
        }
    }
}

关键点:

  • updateChangeCount(.done) 告诉 UIDocument 内容已变更
  • 首次加载时 oldValue 为 nil,避免误标记
  • UIDocument 会自动在合适时机保存

配置文档视图

06:30

override func documentDidOpen() {
    configureViewForCurrentDocument()
}

override func viewDidLoad() {
    super.viewDidLoad()
    configureViewForCurrentDocument()
}

func configureViewForCurrentDocument() {
    guard let document = markdownDocument,
          !document.documentState.contains(.closed)
            && isViewLoaded else { return }
    // Configure views for document
}

关键点:

  • documentDidOpen()viewDidLoad() 都要调用配置方法
  • 检查 isViewLoaded 和文档状态,确保两者都就绪
  • 防御性编程,不假设两个回调的先后顺序

更新导航项

07:17

override func navigationItemDidUpdate() {
    // Customize navigation item
}

关键点:

  • UIDocumentViewController 更新导航项后调用
  • 在这里添加自定义按钮或修改导航栏
  • 系统会尽量保留你的修改

手动打开文档

08:01

documentController.openDocument { success in
    if success {
        self.present(documentController, animated: true)
    }
}

关键点:

  • 需要从外部访问文档时手动调用
  • 自动处理 documentDidOpen 等回调
  • 完成后在 completion handler 中 present

重命名委托

09:20

navigationItem.renameDelegate = document

关键点:

  • 不用 UIDocumentViewController 时手动设置
  • UIDocument 自动处理重命名的文件操作
  • 用 UIDocumentViewController 时自动配置

核心启发

  1. 把现有文档应用迁移到 UIDocumentViewController

    • 做什么:把内容视图控制器的基类从 UIViewController 改成 UIDocumentViewController
    • 为什么值得做:自动获得分享、拖拽、撤销重做、重命名等功能,删除大量 boilerplate 代码
    • 怎么开始:改基类 → 移代码到新回调 → 删除不再需要的导航项配置和重命名委托
  2. 给文档模型加上自动保存

    • 做什么:在文档属性的 didSet 中调用 updateChangeCount(.done)
    • 为什么值得做:UIDocument 自动处理保存时机,用户不用手动点保存
    • 怎么开始:找到所有修改文档内容的地方,确保每次变更都调用了 updateChangeCount
  3. 利用 undoRedoItemGroup 提供标准撤销重做

    • 做什么:把 undoRedoItemGroup 放进导航栏,给文档分配 undoManager
    • 为什么值得做:系统管理按钮的显示/隐藏和启用/禁用,零代码获得标准体验
    • 怎么开始:创建 UndoManager 实例赋给 document.undoManager,把 undoRedoItemGroup 加到 toolbar items
  4. 实现一个 Markdown 编辑器

    • 做什么:用 UIDocument + UIDocumentViewController 做一个支持 iPad 桌面级体验的 Markdown 编辑器
    • 为什么值得做:获得自动保存、分享、重命名、拖拽、空状态等全套功能,专注编辑器本身
    • 怎么开始:创建 MarkdownDocument 子类处理文本加载/保存,用 UIDocumentViewController 子类展示编辑界面
  5. 给无浏览器的单文档应用加文档选择器

    • 做什么:让单文档应用也能从文件系统打开文档
    • 为什么值得做:UIDocumentViewController 作为根视图控制器时自动显示文档按钮
    • 怎么开始:在 Info.plist 中声明 UIDocumentClass key,指向你的 UIDocument 子类

关联 Session

评论

GitHub Issues · utterances