今天将直接讨论SwiftUI中的文档架构,可以让我们将EmojiArt作品保存到设备上,每个作品主题都将生成一个文档保存。SwiftUI有一个非常强大的机制来处理这些事情。
1、APP Protocol
在我们的程序入口文件处(EmojiArtApp.swift)可以看到以@main包装后结构体:
@main
struct EmojiArtApp: App {
//和视图一样,它有一个var body返回的是some Scene
var body: some Scene {
//使用一个WindowGroup作为应用程序呈现的视图层次结构的容器。
WindowGroup {
//此视图内容的层次结构作为应用程序从该组创建的每个窗口的模板
EmojiArtDocumentView(document: docment)
}
}
}
这是一个APP协议,整个程序中只会有一个这样符合APP协议的结构。var body里存放的是Scene场景,要理解APP协议我们必须要了解这些场景(some Scene)是什么
2、some Scene 场景
场景是要在UI中显示的顶级视图的容器。我们可以创建自己的场景,这些场景又具有自己的var body,但这样的情况不多,也不太可能这样操作。一般是在需要监听名为scenePhase的@Environment变量时才这样做。
如果要构建场景,需要使用下面2种方式:
a.WindowGroup{ return aTopLevelView } 通过Mac创建“新窗口”,在iPad上拆分屏幕,在iPhone上无效,因为手机屏幕关系只有一个Scene(将屏幕填满)。
WindowGroup可以呈现一组结构相同的窗口的场景(非文档)。SwiftUI负责处理特定于平台的行为。例如,在macOS和ipad等支持该功能的平台上,用户可以同时打开多个群组窗口。在macOS中,用户可以将打开的窗口聚集在一个选项卡界面中。同样在macOS中,窗口组自动提供标准窗口管理命令。
从组中创建的每个窗口都维护独立的状态。例如,对于从组中创建的每个新窗口,系统为场景的视图层次结构实例化的任何State或StateObject变量分配新的存储空间。
你通常使用窗口组作为非基于文档的应用程序的主界面。对于基于文档的应用程序,使用DocumentGroup代替。
b.DocumentGroup(newDocument: ){ config in ... return aTopLevelView } 可以新建一个文档。
DocumentGroup根据参数不同支持打开、创建和保存文档的场景。使用DocumentGroup场景告诉SwiftUI,当你使用app协议声明应用程序时,你的应用程序可以打开哪些类型的文档。
通过传入文档模型和能够显示文档类型的视图来初始化文档组场景。提供给DocumentGroup的文档类型必须符合FileDocument或ReferenceFileDocument。SwiftUI使用该模型为应用程序添加文档支持。在macOS中,这包括基于文档的菜单支持,包括打开多个文档的能力。在iOS中,这包括一个文档浏览器,可以导航到存储在文件系统中的文档,并支持多窗口:
@main
struct EmojiArtApp: App {
//我们的ViewModel将不再使用@StateObject
var body: some Scene {
//28:22 newDocument可以使用小闭包为场景创建视图
DocumentGroup(newDocument: { EmojiArtDocument() }]) { config in //config替换了原来使用@StateObject包装的属性
EmojiArtDocumentView(document: config.document)//我们新建的每个TopView都使用了自己的ViewModel
}
//我们的ViewModel必须符合ReferenceFileDocument协议才能正常工作(将文档存到硬盘上)
//符合这个协议必须要实现Undo(撤销)的功能,如果暂时不想实现则传入config.$document(多一个$)
//直接使用DocumentGroup内容保存Model。
//当新建文档时将使用Binding传递给Model
}
}
在视图里定义跟踪Undo需要使用环境变量(40:23):
@Environment(\.undoManager ) var undoManager //从环境变量获取撤销
c.DocumentGroup(viewing: ){ config in ... return aTopLevelView } 只用于查看文档(只读)
如果你的应用程序只需要显示而不需要修改特定的文档类型,你可以使用文件查看器文档组场景。您提供文档的文件类型,以及显示您提供的文档类型的视图:
@main
struct EmojiArtViewerApp: App {
var body: some Scene {
DocumentGroup(viewing: EmojiArtDocument.self) { config in//只有一个参数可以使用$0替换
EmojiArtDisplayOnlyView(emojiArtModel: config.document)//甚至不需要ViewMode(32:30)
}
}
}
你的应用程序可以通过添加额外的文档组场景来支持多种文档类型:
@main
struct MyApp: App {
var body: some Scene {
//新建文档
DocumentGroup(newDocument: TextFile()) { group in
ContentView(document: group.$document)
}
//查看文档
DocumentGroup(viewing: MyImageFormatDocument.self) { group in
MyImageFormatViewer(image: group.document)
}
}
}
3、FileDocument 文档系统(33:22)
一种文档模型定义,用于将文档与文件内容进行序列化。与FileDocument的一致性要求值语义和线程安全。序列化和反序列化在后台线程上进行。
//根据给定的configuration创建文件文档。
init(configuration: Self.ReadConfiguration) throws{
if let data = configuration.file.regularFileContents{
//从该文档中的内容初始化data
}else{
throw CocoaError(.fileReadCorruptFile)//抛出错误
}
}
FileDocument只有一个init,为它提供Self.ReadConfiguration的东西从中读取文档的文件内容。FileDocument还有一个函数:
func fileWrapper(configuration: Self.WriteConfiguration) throws -> FileWrapper//configuration是当前文档内容的配置。
将文档序列化为指定配置的文件内容。返回要序列化文档的内容。该返回值可以是新创建的FileWrapper,也可以是配置中提供的更新的文件包装器。
configuration要求提供此文件系统以某种方式编码的内容。这个fileWrapper通常只是一个普通的文件,只是存储了Data。但也可以存储复杂的Data。
4、ReferenceFileDocument 协议(33:20)
用于将引用类型(如class)文档与文件内容进行序列化。对ReferenceFileDocument的一致性应该是线程安全的,反序列化和序列化将在后台线程上完成。
我们通常与ViewModels一起使用,它继承了ObservableObjects。所以只有ViewModel(引用类型)可以是ReferenceFileDocument不能是值类型(Model)。它与FileDocument唯一的区别是ReferenceFileDocument协议将用快照来完成存储:
func snapshot(contentType: UTType) throws -> Snapshot{
return //我的Model (可能)转换成其他类型,比如Data
}
这个文档系统会在它想要自动保存时询问你:“请创建您自己的快照”。它甚至会在主线程之外询问,以防它以某种方式需要大量资源来创建快照。
你可以将快照转换成任何想要的类型,通常我们会做成一个Data,最终我们会把Data写入到文件系统中,所以我们应该以Data的方式保存。
参数UTType这种内容类型是一个统一类型标识符。UTType是一种表示要加载、发送或接收的数据类型的结构。UTType 结构描述数据的类型信息有一个唯一的标识符,并提供了分别使用 preferredFilenameExtension 和 preferredMIMEType 查找首选文件扩展名或 MIME 类型的方法。系统包括许多常见类型的静态声明,您可以通过标识符、文件扩展名或 MIME 类型查找这些声明。
UTType 结构可以提供与类型相关的附加信息。 附加信息的示例包括面向用户的本地化描述、标识有关该类型的技术文档的参考 URL 或该类型的版本号。 您还可以通过它们的一致性来查找类型,以获得与您的用例相关的类型或类型列表。
我们应该在应用程序的 Info.plist 中定义您自己的类型。 在后面的演示中我们将定义自己的UTType。使用的identifier通常是我们的反向DNS表示法来定义唯一标识符,其核心作用是我们的扩展名关联应用程序操作,当我们点击自己定义的UTType文件时,将使用我们自己开发的程序打开它。
extension UTType{
static let emojiart = UTType(exportedAs:"唯一标识符.emojiart")//告诉系统本程序可以打开.emojiart文件
}
//下面放到ViewModel里
static let readableContentTypes = [UTType.emojiart] //读取.emojiart文件
static let writeableContentTypes = [UTType.emojiart]//写入.emojiart文件
和FileDocument一样ReferenceFileDocument也拥有一个fileWrapper函数:
//snapshot包含需要保存的状态的文档的快照。configuration 当前文档内容的配置。
func snapshot(snapshot: Snapshot, configuration: Self.WriteConfiguration) throws -> FileWrapper{
FileWrapper(regularFileWithContents: /* 将快照转为Data类型 */)
}
返回要序列化文档内容的目标。该值可以是新创建的FileWrapper,也可以是配置中提供的更新的FileWrapper。
5、@SceneStorage 场景存储包装器
用于在每个场景的基础上持久的存储比较简单的数据(使用RawRepresentable可以解决复杂结构的数据存储问题),当你需要在场景中自动恢复某个值时,可使用@SceneStorage对属性进行包装。它的工作方式与State非常相似主要也就是替换掉State,让我们恢复场景时使用场景保存的值,与State不同之处在于它的初始值如果之前保存过则由系统恢复,并且该值与同一场景中的其他 SceneStorage 变量共享。
系统帮你管理SceneStorage的保存和恢复。但无法使用支持 SceneStorage 的基础数据,因此您必须通过SceneStorage属性包装器访问它。系统不保证何时以及多长时间持久化数据(老设备由于内存限制可能被kill的情况)。
每个场景都有自己的SceneStorage,因此some Scene之间不共享数据。
我在中 SceneStorage 应该存储轻量级的数据,不应存储较大的数据,例如Model数据,否则可能会导致性能下降(占用内存多的先被kill)。
如果场景被显式销毁(切换器快照在 iPadOS 上销毁或窗口在 macOS 上关闭),SceneStorage数据也会被销毁。SceneStorage的生命周期与场景绑定,场景不存在了SceneStorage也就被销毁了,所以这只是场景临时的数据保存方案,基于APP重要的数据不能存(使用下面的AppStorage方案)。
使用@SceneStorage替换到我们项目里的@State:
a.EmojiArtDocumentView.swift里几个需要场景持久化的变量:
@SceneStorage("EmojiArtDocumentView.steadyStateZoomScale")//原来是@State
private var steadyStateZoomScale: CGFloat = 1 //在场景里持久化这个值(扩展文件支持CGFloat)
@SceneStorage("EmojiArtDocumentView.steadyStatePanOffset")
private var steadyStatePanOffset = CGSize.zero //平移参数值(扩展CGSize支持)
extension CGSize: RawRepresentable { }//扩展CGSize符合RawRepresentable协议以支持SceneStorage
extension CGFloat: RawRepresentable { }//这2个都基于 extension RawRepresentable where Self: Codable扩展
b.让场景默认被选择的表情选择器持久,修改PaletteChooser.swift:
@SceneStorage("PaletteChooser.chosenPaletteIndex")
private var chosenPaletteIndex = 0//如果用户修改则下次打开时恢复用户设置
6、AppStorage 简单版的UserDefaults
@AppStorage属性包装器本质上是读写存在于UserDefaults中的值,并使该UserDefaults更改时的UI重建。其底层是UserDefaults(持久化存储方式),@AppStorage是UserDefaults的API,可以让我们的编码更简洁易读,AppStorage是应用程序范围的存储,所以场景都可读取。
7、@ScaledMetric 按比例缩放(24:48)
这个包装器和用户体验有关系,可缩放数值的动态属性。通过ScaledMetric包装的属性后,在用户的系统设置里调整了字体尺寸后,根据比例会缩放被包装的值。一般用于对字体的调节(老年或者视力障碍人士使用)。
@ScaledMetric var defaultEmojiFontSize: CGFloat = 40//表情跟随系统字体缩放
1、为APP设置图片(10:01):
这个比较简单,找到合适的图片放入Assets.xcassets里即可,如果是有全套的图片目录可以拖拽进入后点击我们的项目根文件(EmojiArt.xcodeproj)找到 App icons source选择我们自己定义的目录即可。
2、程序与扩展名文件关联(43:30):
点击项目录根目录:EmojiArt 在顶上切换到info选项:
这里的Identifier需要与我们程序里扩展UTType里的保持一致,否则将提示一个紫色,未注册的扩展名。
3、清理ViewModel代码:
在前面的课程里我们自己定义了自动保存、自动恢复的代码,因为文档自带了保存体系,所以我们将清理这些代码(scheduleAutosave相关联的Autosave、save、autosaveTimer等):
//增加ReferenceFileDocument协议,因为它符合ObservableObject,所以只写这一个就OK
class EmojiArtDocument:ReferenceFileDocument{
@Published private(set) var emojiArt: EmojiArtModel {
didSet {
if emojiArt.background != oldValue.background {
fetchBackgroundImageDataIfNecessary()
}
}
}
/* 这里与保存相关的代码全删除了 */
init() {
/* 这里与恢复相关的代码全删除了 */
emojiArt = EmojiArtModel()
}
var emojis:[EmojiArtModel.Emoji]{ emojiArt.emojis }
var background:EmojiArtModel.Background { emojiArt.background }
//.............. 下面代码保持不变 ............//
}
4、修改程序为文档模式(48:30)
我们通过上面的理论学习将WindowGroup改为DocumentGroup:
@main
struct EmojiArtApp: App {
@StateObject private var paletteStore = PaletteStore(named: "Default")//共用一个表情选择器
var body: some Scene {
DocumentGroup(newDocument: { EmojiArtDocument() }){ config in
EmojiArtDocumentView(document: config.document)//我们将实现Undo所以未使用$版本
.environmentObject(paletteStore)
}
}
}
原来的@StateObject被移除了,我们的文档新建时不能共享使用这个objiet,所以每次我们都会新的初始化一个EmojiArtDocument绑定到config上去。现在在我们的每个场景中将拥有不同的文档,现在我们需要符合eferenceFileDocument才不会报错。
5、使ViewModel符合新协议(49:55):
我们在第3项增加了ReferenceFileDocument协议,我们将在这里编写代码以符合这个协议。
上面我们清理了自动保存的相关代码,现在我们增加代码以符合这个协议:
import SwiftUI
import Combine
import UniformTypeIdentifiers //引用框架
//扩展UTType注册我们的扩展名文件关联
extension UTType {
static let emojiart = UTType(exportedAs: "com.55mx.emojiart")//需要和info里的Identifier保持一致
}
class EmojiArtDocument: ReferenceFileDocument{
static var readableContentTypes = [UTType.emojiart]//向系统注册文档能够打开的类型。
static var writeableContentTypes = [UTType.emojiart]//向系统注册文档保存的类型。
//通过读取给定ReadConfiguration的内容来初始化self(打开文档时将调用这个init)。
required init(configuration: ReadConfiguration) throws {
//尝试读取文件文件并恢复到文档
if let data = configuration.file.regularFileContents {
emojiArt = try EmojiArtModel(json: data)//将读取的文件以data方式传参
fetchBackgroundImageDataIfNecessary()//抓取远程图片
} else {
//抛出错误
throw CocoaError(.fileReadCorruptFile)
}
}
//创建文档当前状态的快照,当用户可以编辑self时,该快照将用于序列化。
func snapshot(contentType: UTType) throws -> Data {
try emojiArt.json()
//在保存ReferenceFileDocument时,对文档的编辑将被阻塞,直到可以创建带有任何可变引用副本的快照。
//一旦创建了快照,文档就变成可编辑的,与使用write(snapshot:to:contentType:)序列化的快照并行。
}
//将快照序列化为指定类型的文件内容。
//要序列化文档内容的目标。该值可以是新创建的FileWrapper,也可以是配置中提供的更新的FileWrapper。
func fileWrapper(snapshot: Data, configuration: WriteConfiguration) throws -> FileWrapper {
FileWrapper(regularFileWithContents: snapshot)//文件系统中节点(文件、目录或符号链接)的表示形式。
}
/* 原来的代码不变,直接到init() ... */
}
FileWrapper类提供对文件系统节点的属性和内容的访问。文件系统节点是一个文件、目录或符号链接。这个类的实例称为文件包装器。
文件包装器将文件系统节点表示为一个对象,该对象可以显示为图像(可能在适当的位置进行编辑)、保存到文件系统或传输到另一个应用程序。
有三种类型的文件包装器:
a.regular -file文件包装器:表示一个常规文件。
b.目录文件包装器:表示一个目录。
c.符号链接文件包装器:表示符号链接。
文件包装器有以下属性:
a.文件名。文件包装器所代表的文件系统节点的名称。
b.文件系统属性。有关属性字典内容的信息,请参阅FileManager。
c.常规文件内容。仅适用于普通文件的文件包装器。
d.文件包装。仅适用于目录文件包装器。
e.目标节点。仅适用于符号链接文件包装器。
6、增加一个Undo以让系统识别自动保存(1:01:20)
我们在ViewModel的最下面增加代码:
// MARK: - Undo
//可撤销的执行
private func undoablyPerform(operation: String, with undoManager: UndoManager? = nil, doit: () -> Void) {
let oldEmojiArt = emojiArt //备份将要更改的内容
doit() //可撤销闭包(参数传入代码)
//注册Undo
undoManager?.registerUndo(withTarget: self) { myself in
//如果有撤销动作,我们就可以通过undoablyPerform重做
myself.undoablyPerform(operation: operation, with: undoManager) {
myself.emojiArt = oldEmojiArt //如果撤销就恢复备份数据
}
}
undoManager?.setActionName(operation)//设置操作名字
}
7、在所有的操作中应用Undo函数:
//设置背影,增加UndoManager?参数
func setBackground(_ background: EmojiArtModel.Background,undoManager:UndoManager?) {
undoablyPerform(operation: "设置背景图片", with: undoManager){
emojiArt.background = background
}
}
//增加表情,增加UndoManager?参数
func addEmoji(_ emoji: String, at location: (x: Int, y: Int), size: CGFloat,undoManager:UndoManager?) {
undoablyPerform(operation: "增加表情(emoji)", with: undoManager){
emojiArt.addEmoji(emoji, at: location, size: Int(size))//使用Model里的addEmoji
}
}
//移动表情,增加UndoManager?参数
func moveEmoji(_ emoji: EmojiArtModel.Emoji, by offset: CGSize,undoManager:UndoManager?) {
//通过索引找到表情
if let index = emojiArt.emojis.index(matching: emoji) {
undoablyPerform(operation: "移动表情(emoji)", with: undoManager){
emojiArt.emojis[index].x += Int(offset.width)//修改移动后的x值
emojiArt.emojis[index].y += Int(offset.height)//修改移动后的y值
}
}
}
//缩放表情,增加UndoManager?参数
func scaleEmoji(_ emoji: EmojiArtModel.Emoji, by scale: CGFloat,undoManager:UndoManager?) {
if let index = emojiArt.emojis.index(matching: emoji) {
undoablyPerform(operation: "缩放表情(emoji)", with: undoManager){
emojiArt.emojis[index].size = Int((CGFloat(emojiArt.emojis[index].size) * scale).rounded(.toNearestOrAwayFromZero))
}
}
}
上面增加UndoManager?参数将在View调用时被传入。
8、在视图里传入 undoManager
首先我们需要通过环境变量获取到undoManager
@Environment(.undoManager) var undoManager//从环境变量里获取undoManager
然后在调用操作的地方传入这个参数:
//设置背景图片来自URL
document.setBackground(.url(url.imageURL), undoManager: undoManager)
//设置背景图片来自data
document.setBackground(.imageData(data), undoManager: undoManager)
//增加表情
document.addEmoji(
String(emoji),
at: convertToEmojiCoordinates(location, in: geometry),
size: defaultEmojiFontSize / zoomScale, undoManager: undoManager //向画布拖入后自动缩放
)
9、让我们的文档可以文件里直接打开
我们需要在Info.plist里增加一个选项:Supports Document Browser -> Boolean: YES
现在我们在IOS的文件管理里可以点击通过我们的应用程序打开这个文档了。并且会生成一个程序名称一样的目录文件。
10、在视图里增加撤销、重做按钮:
在主视图的底部增加下面的代码:
.toolbar {
UndoButton(
undo: undoManager?.optionalUndoMenuItemTitle,
redo: undoManager?.optionalRedoMenuItemTitle
)
}
这个UndoButton的源代码来源来视图的扩展:
struct UndoButton: View {
let undo: String?
let redo: String?
@Environment(.undoManager) var undoManager
var body: some View {
let canUndo = undoManager?.canUndo ?? false//从环境获取是否可以撤销
let canRedo = undoManager?.canRedo ?? false//从环境获取是否可以重做
//当可以重做/撤销时显示按钮与菜单
if canUndo || canRedo {
Button {
if canUndo {
undoManager?.undo()//调用环境的撤销
} else {
undoManager?.redo()//调用环境的重做
}
} label: {
if canUndo {
Image(systemName: "arrow.uturn.backward.circle")
} else {
Image(systemName: "arrow.uturn.forward.circle")
}
}
//在Mac OS里将显示在菜单上
.contextMenu {
if canUndo {
Button {
undoManager?.undo()
} label: {
Label(undo ?? "撤销", systemImage: "arrow.uturn.backward")
}
}
if canRedo {
Button {
undoManager?.redo()
} label: {
Label(redo ?? "重做", systemImage: "arrow.uturn.forward")
}
}
}
}
}
}
extension UndoManager {
var optionalUndoMenuItemTitle: String? {
canUndo ? undoMenuItemTitle : nil//撤销菜单命令的完整标题,例如“撤销粘贴”。
}
var optionalRedoMenuItemTitle: String? {
canRedo ? redoMenuItemTitle : nil//重做菜单命令的完整标题,例如“重做粘贴”。
}
}
本课内容相对比较复杂,涉及到了文档的概念,需要多看几次视图,多读源代码才能很好的理解。
除非注明,网络人的文章均为原创,转载请以链接形式标明本文地址:https://www.55mx.com/post/160
《CS193p2021学习笔记第十四课:Document Architecture 文档体系结构》的网友评论(0)