Table of Contents
Algorithm
Review
学习 SwiftUI container view 的能力并构建一个好的模型关于子视图如何被它们的 container 管理。使用新的 API 构建你自己自定义的 container,创建修改器来自定义 container 内容,并给你的 container 一些额外的润滑处理来使得你的应用更出色
下面会用一个卡拉 OK 的应用例子举例
// Data-driven DisplayBoard
@State private var songs: [Song] = [
Song("Scrolling in the Deep")
Song("Born to Build & Run")
Song("Some Body Like View")
]
var body: some View {
DisplayBoard(songs) { song in
Text(song.title)
}
}
// DisplayBoard implementation
var data: Data
@ViewBuilder var content: (Data.Element) -> Content
var body: some View {
DisplayBoardCardLayout {
ForEach(data) { item in
CardView {
content(item)
}
}
}
.background { BoardBackgroundView() }
}
创建一个自定义的 DisplayBoard 视图,并用自定义的 DisplayBoardCardLayout 用随机位置显示歌曲简介
// List composition
List {
Text("Scrolling in the Deep")
Text("Born to Build & Run")
Text("Some Body Like View")
ForEach(songsFromSam) { song in
Text(song.title)
}
}
把静态内容和动态内容组合在 List 中
这样我们可改进 DisplayBoard 实现
// DisplayBoard implementation
@ViewBuilder var content: Content
var body: some View {
DisplayBoardCardLayout {
ForEach(subviewOf: content) { subview in
CardView {
subview
}
}
}
.background { BoardBackgroundView() }
}
这样 DisplayBoard 使用组合处理
// DisplayBoard composition
DisplayBoard {
Song("Scrolling in the Deep")
Song("Born to Build & Run")
Song("Some Body Like View")
ForEach(songsFromSam) { song in
Text(song.title)
}
}
这里 subviewOf 对应的是 resolved subviews,即 ViewBuilder 里定义的 subviews 解析后的 subviews
使用新 API Group(subviewsOf:) 来判断 resolved subviews 总数并进行区别处理
// DisplayBoard implementation
@ViewBuilder var content: Content
var body: some View {
DisplayBoardCardLayout {
Group(subviewsOf: content) { subviews in
ForEach(subviews) { subview in
CardView (
scale: subviews.count > 15 ? .small : .normal
} {
subview
}
}
}
}
.background { BoardBackgroundView() }
}
自定义 container 默认不支持段头,需要自定义实现,并使用 ForEach(sectionOf:) 新 API
// Implementation DisplayBoardCard sections
@ViewBuilder var content: Content
var body: some View {
HStack(spacing: 80) {
ForEach(sectionOf: content) { section in
VStack(spacing: 20) {
if !section.header.isEmpty {
DisplayBoardCardSectionHeaderCard { section.header }
}
DisplayBoardCardSectionContent {
content
}
.background { BoardSectionBackgroundView() }
}
}
}
.background { BoardBackgroundView() }
}
struct DisplayBoardCardSectionContent<Content: View>: View {
@ViewBuilder var content: Content
// ...
}
上述代码会解析 ViewBuilder 代码里的 section 并进行显示处理
Container values
Container values 的概念跟 Environment 和 Preference 相似,它们只能被 container 持有,表示 container 特有选项。下面是用 container value 在卡片中显示一个花叉效果
首先,我们会在 ContainerValues 类型上创建一个扩展,这是一种新的类型,使用 @Entry 新 API,其提供一个方便的语法来添加新值到 SwiftUI 键存储类型,包含环境值、聚焦值等等。其次,使用 containerValue() 新 API 声明一个自定义视图修改器方便设置我的属性
// Custom container values
extension ContainerValues {
@Entry var isDisplayBoardCardRejected: Bool = false
}
extension View {
func displayBoardCardRejected(_ isRejected: Bool) -> some View {
containerValue(\.isDisplayBoardCardRejected, isRejected)
}
}
// Implementating DisplayBoard customization
struct DisplayBoardSectionContent<Content: View>: View {
@ViewBuilder var content: Content
var body: some View {
DisplayBoardCardLayout {
Group(subviewsOf: content) { subviews in
ForEach(subviews) { subview in
let values = subview.containerValues
CardView (
scale: subviews.count > 15 ? .small : .normal,
isRejected: values.isDisplayBoardCardRejected
} {
subview
}
}
}
}
.background { BoardBackgroundView() }
}
}
这样我们就能处理视图
// DisplayBoard customization
DisplayBoard {
Section("Matt's Favorites") {
Song("Scrolling in the Deep")
.displayBoardCardRejected(true)
Song("Born to Build & Run")
Song("Some Body Like View")
}
Section("Sam's Favorites") {
ForEach(songsFromSam) { song in
Text(song.title)
.displayBoardCardRejected(song.samHasDibs)
}
}
Section("Sommer's Favorites") {
ForEach(songsFromSommer) { song in
Text(song.title)
}
}
.displayBoardCardRejected(true)
}
Tips
Create a custom data store with SwiftData
只是通过用 JSONStoreConfiguration 替换 ModelConfiguration,下面例子中的 ModelContainer 现在知道使用一个不同的 store type
// Use your own custom data store
import SwiftUI
import SwiftData
struct TripsApp: App {
var container: ModelContainer = {
do {
let configuration = JSONStoreConfiguration(schema: Schema([Trip.self]), url: fileURL)
return try ModelContainer(for: Trip.self, configurations: configuration)
} catch {
// ...
}
} ()
var body: some Scene {
WindowGroup {
ContentView()
}
.modelContainer(container)
}
}
在本视频中我将引入一个 store 并说明它如何跟 ModelContext 和 ModelContainer 交互,然后,我将探讨它们用新的 DataStore 协议如何构建。最后,我将覆盖说明一个自定义 DataStore 实现的基本要点,使用一个 JSON 文件作为持久化存储的例子
store 负责获取和保存支持持久化模型的所有数据。为探讨在 SwiftData 中自定义 data store 如何工作,我们用 SampleTrips 样例 app 做一个说明。一个典型的 app 由三个重要部分组成。SwiftUI 提供用户接口,典型的一个视图,比如一个列表或标签显示 ModelContext 中一个模型的数据。ModelContext 使用 ModelContainer 中的一个 store 读写数据。在本视频中,我将聚焦于 store 角色
SampleTrips 使用一个 ModelContext 来强化视图显示旅行信息。ModelContext 为视图实例化每个旅行的持久化模型。每个旅行有一个对应的持久化唯一标识符唯一确定模型。ModelContext 追踪改变这样它们可在需要时保存到 store
例如如果我决定取消我的洛杉矶旅行,并添加一个新的到东京的旅行,这些改变被 ModelContext 追踪,当新的东京模型插入到 ModelContext,它被一个唯一的临时持久化唯一标识符唯一确定。当 ModelContext 保存后,它告诉 store 删除洛杉矶的旅行并插入新的东京旅行。store 然后赋予东京模型一个永久的持久化唯一标识符,并映射它到它之前的临时唯一标识符,其被重新映射。store 然后用更新后的东京旅行的持久化唯一标识符响应 ModelContext
在 ModelContext 完成更新它的状态后,UI 更新视图呈现旅行信息
持久化改变只是一个 ModelContext 和 store 一起支持持久化模型如何一起工作的例子。它们通过一系列请求通讯并负责定义操作比如获取或保存。store 的角色是提供模型值如何持久化的实现。这种通信杠杆化一个可发送、代码化的模型表示称为 DataStoreSnapshot
在 SampleTrips app 中,视图使用 持久化模型与 ModelContext 通讯。然而,当 ModelContext 需要通信 store 时,它创建一个快照持有模型的当前状态。快照是该时刻可发送的,可代码化的模型值容器。像持久化模型一样,每个用一个持久化唯一标识符确定。store 然后消费这些快照并应用值到它的存储中。且反转也是可以的。当数据被 ModelContext 从 store 中读取,store 创建一系列快照对齐 context 寻求的持久化模型。ModelContext 然后对每个快照创建持久化模型用于视图、查询或其他工作中
store 在 SwiftData 中扮演一个关键性的角色允许 ModelContext 读写模型数据到任意存储格式。让我们通过新的 DataStore 协议来使它成为可能
store 有三个关键的部分:Configuration、Communication 和实现。Configuration 描述 store,快照用来与 modelContext 通讯模型值,store 实现使得 ModelContainer 可管理。这些部分服从三个不同的协议:DataStoreConfiguration、DataStoreSnapshot 和 DataStore。在 SwiftData 中提供的缺省实现是:ModelConfiguration、DefaultSnapshot 和 DefaultStore。DefaultStore 支持 SwiftData 所有特性比如迁移、历史跟踪和 CloudKit 同步。且它封装平台最佳的性能和扩展实践,使得它是持久化模型的最佳缺省选择
DataStore 协议定义所有 SwiftData 需要的 store 功能用于 ModelContext,包括保存、获取和缓存。附加协议定义可选 data store 特性,比如新的历史协议描述所有到 store 的改变。ModelContext 使用 DataStore 协议中的请求和响应与 store 通讯。例如,当从 store 获取数据时,ModelContext 发送给 store 一个 DataStoreFetchRequest,其包含 FetchDescriptor 描述 store 将要提取的数据。一旦 store 提取模型值,它对每个模型创建一个快照,并在一个 DataStoreFetchResult 中返回,然后 ModelContext 对每个快照创建一个持久化模型。相似的进程发生在模型在 ModelContext 中改变时且保存被调用。ModelContext 创建一个 DataStoreSaveChangesRequest 包含所有修改的模型的快照,并发送请求到 store。然后,store 应用快照到它的存储并创建一个 DataStoreSaveChangesResult 返回给 ModelContext。在这个结果中,store 对任意新插入的模型提供一个重新映射唯一标识符的映射。这告诉 ModelContext 对插入的旅行更新持久化唯一标识符。最后,ModelContext 处理 store 的保存结果并更新它的状态,对插入的旅行赋予新的永久性持久化唯一标识符
我将实现一个 store,使用一个 JSON 文件来持久化 SampleTrips app 中的模型。注意,这个 store 是一个可存档 store,意味着整个文件在读写时会加载。另外,我将使用 Foundation 库中提供的 JSON 编码器并存储数据为以快照组的形式到文件中
创建一个 store 的第一步是定义 configuration 和 store type,服从 DataStoreConfiguration 和 DataStore 协议。
// Reference using associated types
class JSONStoreConfiguration: DataStoreConfiguration {
typealias Store = JSONStore
}
class JSONStore: DataStore {
typealias Configuration = JSONStoreConfiguration
typealias Snapshot = DefaultSnapshot
func fetch<T>(_ request: DataStoreFetchRequest<T>) throws -> DataStoreFetchResult<T, DefaultSnapshot> where T: PersistentModel {
// I use these in ModelContext instead, because this data set is small
if request.descriptor.predicate != nil {
throw DataStoreError.preferInMemoryFilter
} else if request.descriptor.sortBy.count > 0 {
throw DataStoreError.preferInMemorySort
}
let decoder = JSONDecoder()
let data = try Data(contentsOf: configuration.fileURL)
let trips = try decoder.decode([DefaultSnapshot].self, from: data)
return DataStoreFetchResult(descriptor: request.descriptor,
fetchedSnapshots: trips)
}
func save(_ request: DataStoreSaveChangesRequest<DefaultSnapshot>) throws -> DataStoreSaveChangesResult<DefaultSnapshot> {
var snapshotsByIdentifier = [PersistentIdentifier: DefaultSnapshot]()
try self.read().forEach { snapshotsByIdentifier[$0.persistentIdentifier] = $0 }
var remappedIdentifiers = [Persistentidentifier: Persistentidentifier]()
for snapshot in request.inserted {
let entityName = snapshot.persistentIdentifier.entityName
let permanentIdentifier = try PersistentIdentifier.identifier(for: identifier,
entityName: entityName,
primaryKey: UUID())
let snapshotCopy = snapshot.copy(persistentIdentifier: permanentIdentifier)
remappedIdentifiers[snapshot.persistentIdentifier] = permanentIdentifier
snapshotsByIdentifier[permanentIdentifier] = snapshotCopy
}
// ...
for snapshot in request.updated {
snapshotsByIdentifier[snapshot.persistentIdentifier] = snapshot
}
for snapshot in request.deleted {
snapshotsByIdentifier[snapshot.persistentIdentifier] = nil
}
// ...
let snapshots = snapshotsByIdentifier.values.map({ $0 })
let encoder = JSONEncoder()
let jsonData = try encoder.encode(snapshots)
try jsonData.write(to: configuration.fileURL)
return DataStoreSaveChangesResult(for: self.identifier,
remappedIdentifiers: remappedIdentifiers)
}
}
Share
Swift 学习小结
最近主要看了 SwiftUI 和 SwiftData 相关资料,并写了一个小应用。使用体验是用了 SwiftUI 和 SwiftData 后,代码量确实可以大大减少,SwiftUI 在数据变化后会自动刷新界面,SwiftData 对数据增删修改自动保存。这样你只要定义好界面要显示的数据模型,界面如何显示数据模型就行了,大大简化了相关代码,并定义好想要保持的数据,这些数据就会自动保存,且提供持久化保存,确保下一次应用启动时能加载保存的数据
SwiftUI 和 SwiftData 的使用非常简单,基本上看了官方几个介绍就能大致了解,再动手实验实验基本就能掌握个大概。提供的功能能满足大部分常见的需求,但也有一些特殊情况时需要寻找替代方案,比如目前发现的问题:
- SwiftData 数据的过滤条件要比较简单,不支持复杂的操作,比如过滤语句中不支持 for 循环
- SwiftUI ViewBuilder 代码块中如果内嵌了多重闭包,有些闭包内部捕获外部的一些变量值会有一些问题
- SwiftData 数据模型定义后使用并有了数据,再修改数据模型会导致出错,比较麻烦,我目前的处理时先把之前的数据先完全删除,再修改数据模型添加新的数据
- Picker 控件非选中项不能显示图片
尽管遇到一些特殊问题会比较头疼,要花费一些时间想替代方案,但总体上来说使用体验非常好,代码量会比不使用时减少很多,特别是不用的话要写一些相似的数据、界面关联操作粘合代码,这样就比较无趣。SwiftUI 和 SwiftData 的使用还是大大提高了效率,代码也看得顺眼很多,更加直观,强烈推荐在 iOS 平台使用 SwiftUI 和 SwiftData