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 子类。
迁移三步走
- 把内容视图控制器的基类改成 UIDocumentViewController
- 把现有代码移到新的回调方法(
documentDidOpen()、navigationItemDidUpdate()) - 删除不再需要的代码(重命名委托、导航项配置、文档属性更新等)
详细内容
加载文档
(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 时自动配置
核心启发
-
把现有文档应用迁移到 UIDocumentViewController
- 做什么:把内容视图控制器的基类从 UIViewController 改成 UIDocumentViewController
- 为什么值得做:自动获得分享、拖拽、撤销重做、重命名等功能,删除大量 boilerplate 代码
- 怎么开始:改基类 → 移代码到新回调 → 删除不再需要的导航项配置和重命名委托
-
给文档模型加上自动保存
- 做什么:在文档属性的 didSet 中调用
updateChangeCount(.done) - 为什么值得做:UIDocument 自动处理保存时机,用户不用手动点保存
- 怎么开始:找到所有修改文档内容的地方,确保每次变更都调用了
updateChangeCount
- 做什么:在文档属性的 didSet 中调用
-
利用 undoRedoItemGroup 提供标准撤销重做
- 做什么:把
undoRedoItemGroup放进导航栏,给文档分配 undoManager - 为什么值得做:系统管理按钮的显示/隐藏和启用/禁用,零代码获得标准体验
- 怎么开始:创建 UndoManager 实例赋给 document.undoManager,把 undoRedoItemGroup 加到 toolbar items
- 做什么:把
-
实现一个 Markdown 编辑器
- 做什么:用 UIDocument + UIDocumentViewController 做一个支持 iPad 桌面级体验的 Markdown 编辑器
- 为什么值得做:获得自动保存、分享、重命名、拖拽、空状态等全套功能,专注编辑器本身
- 怎么开始:创建 MarkdownDocument 子类处理文本加载/保存,用 UIDocumentViewController 子类展示编辑界面
-
给无浏览器的单文档应用加文档选择器
- 做什么:让单文档应用也能从文件系统打开文档
- 为什么值得做:UIDocumentViewController 作为根视图控制器时自动显示文档按钮
- 怎么开始:在 Info.plist 中声明
UIDocumentClasskey,指向你的 UIDocument 子类
关联 Session
- What’s new in UIKit — UIKit 新特性,包含 UIDocumentViewController 的概览
- Meet desktop-class iPad — 桌面级 iPad 应用的设计理念
- Build a desktop-class iPad app — 构建桌面级 iPad 应用的实践指南
评论
GitHub Issues · utterances