75142913在线留言
CS193p2021学习笔记第十四课:Document Architecture 文档体系结构_IOS开发_网络人

CS193p2021学习笔记第十四课:Document Architecture 文档体系结构

Kwok 发表于:2021-09-09 20:09:30 点击:66 评论: 0

今天将直接讨论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支持)
@SceneStorage只能存储简单的数据,默认情况下CGSize与CGFloat是不支持的,所以在扩展文件里我们有这样代码(20:18):
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选项:

CS193p2021学习笔记第十四课DocumentArchitecture文档体系结构

这里的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
0
感谢打赏!

《CS193p2021学习笔记第十四课:Document Architecture 文档体系结构》的网友评论(0)

本站推荐阅读

热门点击文章