第37天 项目 7 第二部分
今天你将使用@Observable、sheet()、Codable、UserDefaults等工具构建一个完整的应用程序。我知道这看起来看起来看起来很多,但希望你能试着思考思考这些工具在后台所做的工作:
@Observable会监听类的变化,并刷新刷新所有受影响的视图。sheet()会监听我们指定的条件,并自动显示或隐藏视图。Codable几乎不需要我们编写代码,就能将Swift对象转换为JSON格式,也能将JSON格式转换回Swift对象。UserDefaults可以读取和写入数据,让我们能即时保存设置等信息。
没错,我们需要编写代码来整合这些工具,但大量冗余代码已被移除,最终留下的代码非常简洁。正如法国作家兼诗人安托万·德·圣-埃克苏佩里所说:“完美并非在无可添加之时达成,而是在无可删减之刻实现。”
今天你需要完成五个主题的学习,在这些主题中,你将实践所学的@Observable、sheet()、onDelete()等知识。
- 构建可删除内容的列表
- 在SwiftUI中处理可识别的项目
- 与新视图共享被观察对象
- 使用UserDefaults实现更改的持久化
- 最终优化
又一个应用程序构建完成了,同时也在实际场景中运用了更多技术——做得好!
构建可删除内容的列表
作者:Paul Hudson 2023年10月29日
在这个项目中,我们需要一个能显示支出信息的列表,之前我们会使用@State数组来存储对象实现这一功能。但这次我们将采用不同的方法:创建一个Expenses类,并通过@State将其关联到我们的列表。
这听起来可能有点复杂化,但实际上会让事情变得更简单,因为我们可以让Expenses类无缝地加载和保存自身数据——你很快就会发现,这一过程几乎是无感知的。
首先,我们需要确定“支出”包含哪些信息——我们希望它存储什么内容?在这个案例中,它将包含三部分信息:项目名称、支出类型(商务或个人)以及金额(类型为Double)。
之后我们会对其进行扩展,但目前我们可以用一个ExpenseItem结构体来表示所有这些信息。你可以将这个结构体放在一个名为ExpenseItem.swift的新Swift文件中,当然也不是必须这样做——只要不把它放在ContentView结构体内部,放在ContentView.swift文件中也可以。
无论你将其放在哪里,都可以使用以下代码:
struct ExpenseItem {
let name: String
let type: String
let amount: Double
}现在我们已经有了表示单个支出项目的结构体,下一步就是创建一个对象来存储这些支出项目的数组。这个对象需要使用@Observable宏,这样SwiftUI才能对其进行监听。
和ExpenseItem结构体一样,这个类一开始会比较简单,之后之后会进行扩展,现在先添加这个新类:
@Observable
class Expenses {
var items = [ExpenseItem]()
}至此,主视图所需的所有数据都已准备就绪:我们有一个表示单个支出项目的结构体,还有一个用于存储所有支出项目数组的类。
现在让我们在SwiftUI视图中实际运用这些数据,这样我们就能在屏幕上看到数据了。我们的视图大部分内容将是一个List,用于显示支出项目,但由于我们希望用户能删除不再需要的项目,所以不能简单地使用List——我们需要在列表内部使用ForEach,这样才能使用onDelete()修饰符。
首先,我们需要在视图中添加一个@State属性,用于创建Expenses类的实例:
@State private var expenses = Expenses()记住,这里使用@State是为了保持对象的存活,而真正让SwiftUI能够监听对象变化的是@Observable宏。
其次,我们可以将Expenses对象与NavigationStack、List和ForEach结合使用,创建基本布局:
NavigationStack {
List {
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
}
.navigationTitle("iExpense")
}这段代码告诉ForEach通过支出项目的名称来唯一标识每个项目,然后在列表行中显示项目名称。
在完成这个简单布局之前,我们还需要添加两件事:用于测试的添加新项目功能,以及通过滑动删除项目的功能。
很快我们会让用户自己添加项目,但在继续之前,确保列表能正常工作是很重要的。所以,我们要添加一个工具栏按钮,用于添加示例ExpenseItem实例,现在就将这个修饰符添加到List上:
.toolbar {
Button("Add Expense", systemImage: "plus") {
let expense = ExpenseItem(name: "Test", type: "Personal", amount: 5)
expenses.items.append(expense)
}
}这样我们的应用程序就有了交互功能:现在启动应用,反复点击+按钮,就能添加多个测试用的支出项目。
既然我们能添加支出项目,也应该能删除它们。这就需要添加一个方法,该方法能够删除列表中指定索引集(IndexSet)的项目,然后直接对expenses数组执行删除操作:
func removeItems(at offsets: IndexSet) {
expenses.items.remove(atOffsets: offsets)
}要将这个方法与SwiftUI关联起来,我们需要给ForEach添加onDelete()修饰符,如下所示:
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
.onDelete(perform: removeItems)现在运行应用程序,点击几次+按钮,然后滑动删除列表行。
不过要记住:当我们写id: \.name时,意味着我们通过项目名称来唯一标识每个支出项目,但在当前情况下这并不成立——我们有多个名称相同的项目,而且也无法保证所有支出项目的名称都是唯一的。
通常情况下,这样的代码可能还能正常运行,但有时会导致项目中出现奇怪的、异常的动画效果,所以接下来我们来看看更好的解决方案。
在SwiftUI中处理可识别的项目
作者:Paul Hudson 2024年4月11日
在SwiftUI中创建静态视图时——比如硬编码一个VStack,然后是TextField,再然后是Button等等——SwiftUI能清楚地知道我们有哪些视图,并且能够控制这些视图、为其添加动画效果等。但当我们使用List或ForEach创建动态视图时,SwiftUI需要知道如何唯一地标识每个项目,否则它将难以比较视图层级结构,无法判断哪些内容发生了变化。
在我们当前的代码中,有这样一段:
ForEach(expenses.items, id: \.name) { item in
Text(item.name)
}
.onDelete(perform: removeItems)用通俗的话来说,这段代码的意思是:“为支出项目数组中的每个项目创建一个新行,通过项目名称来唯一标识每个项目,在行中显示项目名称,删除项目时调用removeItems()方法。”
之后,我们还有这样一段代码:
Button("Add Expense", systemImage: "plus") {
let expense = ExpenseItem(name: "Test", type: "Personal", amount: 5)
expenses.items.append(expense)
}每次点击这个按钮,都会向列表中添加一个测试用的支出项目,这样我们就能确保添加和删除功能正常工作。
你能发现问题所在吗?
每次创建示例支出项目时,我们都使用名称“Test”,但我们之前已经告诉SwiftUI,可以将支出项目的名称用作唯一标识符。所以,当代码运行且我们删除一个项目时,SwiftUI会先查看删除前的数组——“Test”、“Test”、“Test”、“Test”——然后再查看删除后的数组——“Test”、“Test”、“Test”——它无法轻易判断哪些内容发生了变化。确实有内容发生了变化,因为有一个项目消失了,但SwiftUI无法确定具体是哪一个。
在这种情况下,我们还算幸运,因为List能清楚地知道我们正在滑动哪一行,但在很多其他情况下,这种额外信息是不存在的,我们的应用程序就会开始出现异常行为。
这是我们的逻辑错误:代码本身没有问题,在运行时也不会崩溃,但我们使用了错误的逻辑来达到目标——我们告诉SwiftUI某个东西可以作为唯一标识符,但实际上它并不唯一。
要解决这个问题,我们需要重新考虑ExpenseItem结构体。目前它有三个属性:name、type和amount。单靠名称在实际情况中可能是唯一的,但也很可能不是——只要用户两次输入“午餐”,我们就会遇到问题。或许我们可以尝试将名称、类型和金额组合成一个新的计算属性,但这也只是延缓问题的发生,它仍然不是真正唯一的。
明智的解决方案是给ExpenseItem添加一个真正唯一的属性,比如一个手动分配的ID编号。这种方法可行,但这意味着我们需要跟踪上一个使用的编号,以避免重复。
实际上,还有一个更简单的解决方案,那就是UUID——它是“通用唯一标识符”(Universally Unique Identifier)的缩写,如果这还不算唯一,那我真不知道什么才算了。
UUID是一串长长的十六进制字符串,例如:08B15DB4-2F02-4AB8-A965-67A9C90D8A44。它的格式是8位、4位、4位、4位,然后是12位,其中唯一的要求是第三组的第一个数字必须是4。如果去掉这个固定的4,就剩下31位数字,每位数字可以是16个值中的一个——即使我们每秒生成1个UUID,持续生成10亿年,生成重复UUID的概率也微乎其微。
现在,我们可以像这样更新ExpenseItem,为其添加UUID属性:
struct ExpenseItem {
let id: UUID
let name: String
let type: String
let amount: Double
}这样是可行的。但这也意味着我们需要手动生成UUID,并且在保存和加载其他数据时也要一并处理UUID。所以,在这个案例中,我们会让Swift自动为我们生成UUID,代码如下:
struct ExpenseItem {
let id = UUID()
let name: String
let type: String
let amount: Double
}现在我们不需要担心支出项目的id值了——Swift会确保它们始终是唯一的。
有了这个属性后,我们就可以修复ForEach了,代码如下:
ForEach(expenses.items, id: \.id) { item in
Text(item.name)
}现在运行应用程序,你会发现问题已经解决了:SwiftUI现在能清楚地知道哪个支出项目被删除了,并且能正确地执行动画。
不过,这一步我们还没有完成。接下来,我希望你修改ExpenseItem,让它遵循一个名为Identifiable的新协议,代码如下:
struct ExpenseItem: Identifiable {
let id = UUID()
let name: String
let type: String
let amount: Double
}我们所做的只是在协议遵循列表中添加了Identifiable,没有其他操作。这是Swift内置的协议之一,表示“该类型可以被唯一标识”。它只有一个要求,即必须有一个名为id的属性,该属性存储唯一标识符。我们刚刚已经添加了这个属性,所以不需要再做额外的工作——我们的类型已经可以很好地遵循Identifiable协议了。
现在,你可能会疑惑我们为什么要添加这个协议,因为之前的代码已经能正常工作了。其实,因为我们的支出项目现在保证是可唯一标识的,所以我们不再需要告诉ForEach使用哪个属性作为标识符——它知道会有一个id属性,并且这个属性是唯一的,因为这正是Identifiable协议的作用。
因此,由于这个修改,我们可以再次修改ForEach,代码如下:
ForEach(expenses.items) { item in
Text(item.name)
}这样就简洁多了!
与新视图共享被观察对象
作者:Paul Hudson 2024年5月16日
使用@Observable的类可以在多个SwiftUI视图中使用,当类的属性发生变化时,所有使用该类的视图都会更新。SwiftUI在这方面非常智能:它只会更新那些实际使用了发生变化的属性的视图。
在这个应用程序中,我们将专门设计一个视图来添加新的支出项目。当用户准备好添加时,我们会将新项目添加到Expenses类中,这将自动促使原始视图刷新数据,从而显示新添加的支出项目。
要创建一个新的SwiftUI视图,你可以按下Cmd+N组合键,或者前往“文件”菜单,选择“新建”>“文件”。无论选择哪种方式,都应在“用户界面”类别下选择“SwiftUI视图”,然后将文件命名为AddView.swift。Xcode会询问你保存文件的位置,请确保在“iExpense”旁边看到文件夹图标,然后点击“创建”,Xcode就会显示这个新视图,供你编辑。
和我们其他的视图一样,AddView的初始版本会比较简单,之后我们会对其进行扩展。这意味着我们要添加用于输入支出名称和金额的文本字段,以及用于选择支出类型的选择器,所有这些都将包含在表单(Form)和导航栈(NavigationStack)中。
这些对你来说应该都是熟悉的内容了,现在让我们开始编写代码:
struct AddView: View {
@State private var name = ""
@State private var type = "Personal"
@State private var amount = 0.0
let types = ["Business", "Personal"]
var body: some View {
NavigationStack {
Form {
TextField("Name", text: $name)
Picker("Type", selection: $type) {
ForEach(types, id: \.self) {
Text($0)
}
}
TextField("Amount", value: $amount, format: .currency(code: "USD"))
.keyboardType(.decimalPad)
}
.navigationTitle("Add new expense")
}
}
}需要说明的是,这里的货币类型始终使用美元(USD)——在本项目的挑战任务中,你需要让它更智能一些,能支持更多货币类型。
我们稍后会回到这段代码的其他部分,但首先让我们在ContentView中添加一些代码,以便在点击+按钮时显示AddView。
要将AddView作为新视图呈现,我们需要对ContentView进行三项修改。首先,我们需要一些状态来跟踪AddView是否正在显示,现在就添加这个属性:
@State private var showingAddExpense = false其次,我们需要告诉SwiftUI使用这个布尔值作为显示工作表(弹出窗口,sheet)的条件。这可以通过给视图层级结构中的某个视图添加sheet()修饰符来实现。你可以给List添加这个修饰符,给NavigationStack添加也同样可以。无论选择哪种方式,现在都在ContentView的某个视图上添加以下代码作为修饰符:
.sheet(isPresented: $showingAddExpense) {
// 在这里显示AddView
}第三步是在工作表中添加内容。通常,这里会直接是你想要显示的视图类型的实例,如下所示:
.sheet(isPresented: $showingAddExpense) {
AddView()
}但是,在这里我们需要更多的操作。你知道,我们已经在内容视图(ContentView)中有了expenses属性,而在AddView中,我们将编写代码来添加支出项目。我们不希望在AddView中创建Expenses类的第二个实例,而是希望它共享来自ContentView的现有实例。
所以,我们要做的是给AddView添加一个属性,用于存储Expenses对象。请在AddView中添加以下属性:
var expenses: Expenses现在我们可以将现有的Expenses对象从一个视图传递到另一个视图了——两个视图将共享同一个对象,并且都会监听该对象的变化。将ContentView中的sheet()修饰符修改为以下代码:
.sheet(isPresented: $showingAddExpense) {
AddView(expenses: expenses)
}这一步我们还没有完全完成,原因有两个:一是代码无法编译,二是即使能编译,按钮也无法触发工作表的显示。
编译失败是因为当我们创建新的SwiftUI视图时,Xcode还会添加一些预览代码,以便我们在编写代码时能查看视图的设计效果。如果你在AddView.swift文件的底部找到这段预览代码,会发现它试图创建一个AddView实例,但没有为expenses属性提供值。
现在这种方式是不允许的,但我们可以传入一个虚拟值来解决,代码如下:
#Preview {
AddView(expenses: Expenses())
}第二个问题是,我们实际上没有任何代码来显示工作表,因为目前ContentView中的+按钮只是添加测试用的支出项目。幸运的是,修复这个问题很简单——只需将现有的按钮操作替换为切换showingAddExpense布尔值的代码,如下所示:
Button("Add Expense", systemImage: "plus") {
showingAddExpense = true
}现在运行应用程序,整个工作表功能应该能正常工作了——你从ContentView开始,点击+按钮调出AddView,在其中输入各种信息,然后可以通过滑动来关闭AddView。
使用UserDefaults实现更改的持久化
作者:Paul Hudson 2024年4月11日
目前,我们应用程序的用户界面已经可以正常使用了:你已经看到我们能够添加和删除项目,现在还有一个用于创建新支出项目的工作表视图。但是,这个应用程序还远未完善:在AddView中输入的任何数据都会被完全忽略,即使没有被忽略,在应用程序下次启动时,这些数据也不会被保存。
我们将按顺序解决这些问题,首先处理如何实际利用AddView中的数据。我们已经有了存储表单中输入值的属性,之前也添加了一个属性来存储从ContentView传递过来的Expenses对象。
我们需要将这两部分结合起来:添加一个按钮,当点击该按钮时,根据我们的属性创建一个ExpenseItem,并将其添加到expenses的项目数组中。
在AddView中,在navigationTitle()下方添加以下修饰符:
.toolbar {
Button("Save") {
let item = ExpenseItem(name: name, type: type, amount: amount)
expenses.items.append(item)
}
}虽然我们还有更多工作要做,但我建议你现在运行应用程序,因为它已经逐渐成型了——你现在可以调出添加视图,输入一些详细信息,点击“保存”,然后滑动关闭添加视图,就能在列表中看到新添加的项目了。这意味着我们的数据同步工作非常完美:两个SwiftUI视图都从同一个支出项目列表中读取数据。
现在尝试再次启动应用程序,你会立即遇到第二个问题:你添加的任何数据都不会被存储,这意味着每次重新启动应用程序时,所有内容都会清零。
显然,这是非常糟糕的用户体验,但得益于我们将Expenses设计为一个独立的类,修复这个问题其实并不难。
我们将利用四项重要技术来帮助我们以简洁的方式保存和加载数据:
Codable协议:它能帮助我们将所有现有的支出项目归档,以便存储。UserDefaults:它能让我们保存和加载归档后的数据。Expenses类的自定义初始化器:这样当我们创建该类的实例时,就能从UserDefaults中加载任何已保存的数据。Expenses类中items属性的didSet属性观察器:这样每当添加或删除项目时,我们就能将更改写入存储。
让我们先处理数据的写入。在Expenses类中,我们已经有了这个属性:
var items = [ExpenseItem]()这是我们存储所有已创建的支出项目结构体的地方,我们也将在这里添加属性观察器,以便在数据发生变化时将其写入存储。
这总共需要四个步骤:创建一个JSONEncoder实例,用于将数据转换为JSON格式;请求该编码器尝试对items数组进行编码;然后将编码后的数据通过键“Items”写入UserDefaults。
将items属性修改为以下代码:
var items = [ExpenseItem]() {
didSet {
if let encoded = try? JSONEncoder().encode(items) {
UserDefaults.standard.set(encoded, forKey: "Items")
}
}
}提示: 使用JSONEncoder().encode()意味着“创建一个编码器并使用它对某个内容进行编码”,这是一步完成的操作,无需先创建编码器再后续使用。
如果你正在跟随操作,会发现这段代码实际上无法编译。如果观察得更仔细,你会发现我之前说这个过程需要四个步骤,但上面只列出了三个。
问题在于,encode()方法只能对遵循Codable协议的对象进行归档。记住,遵循Codable协议会让编译器为我们生成处理归档和反归档对象的代码,如果我们不添加这个协议遵循,代码就无法编译。
好在我们只需给ExpenseItem添加Codable协议遵循即可,无需其他操作,代码如下:
struct ExpenseItem: Identifiable, Codable {
let id = UUID()
let name: String
let type: String
let amount: Double
}Swift已经为ExpenseItem的UUID、String和Double属性提供了Codable协议遵循,因此只要我们要求,编译器就能自动让ExpenseItem遵循Codable协议。
但是,你会看到一个警告,提示id属性不会被解码,因为我们将其设为常量并给了一个默认值。实际上,这正是我们想要的行为,但Swift出于好心会给出警告,因为它担心你可能原本计划从JSON中解码这个值。要消除这个警告,只需将该属性改为变量,代码如下:
var id = UUID()完成这个修改后,我们就编写好了所有确保用户添加项目时数据能被保存的代码。但这本身还不够:数据可能被保存了,但在应用程序重新启动时不会被加载。
要解决这个问题,我们需要实现一个自定义初始化器。这个初始化器将:
- 尝试从
UserDefaults中读取键“Items”对应的数据。 - 创建一个
JSONDecoder实例,它是JSONEncoder的对应工具,能将JSON数据转换回Swift对象。 - 请求解码器将从
UserDefaults中获取的数据转换为ExpenseItem对象数组。 - 如果转换成功,将结果数组赋值给
items并退出初始化器。 - 如果转换失败,则将
items设为空数组。
现在在Expenses类中添加这个初始化器:
init() {
if let savedItems = UserDefaults.standard.data(forKey: "Items") {
if let decodedItems = try? JSONDecoder().decode([ExpenseItem].self, from: savedItems) {
items = decodedItems
return
}
}
items = []
}这段代码中有两个关键部分:data(forKey: "Items")行,它尝试将“Items”键对应的内容读取为Data对象;以及try? JSONDecoder().decode([ExpenseItem].self, from: savedItems)行,它负责将Data对象反归档为ExpenseItem对象数组。
初次看到[ExpenseItem].self时,人们常常会困惑——这里的.self是什么意思?其实,如果我们只使用[ExpenseItem],Swift会不清楚我们的意图——我们是想复制这个类吗?是打算引用某个静态属性或方法吗?还是可能想创建这个类的实例?为了避免混淆——为了表明我们指的是类型本身(即“类型对象”)——我们在其后加上.self。
现在我们已经同时实现了数据的加载和保存功能,你应该可以使用这个应用程序了。不过它还没有完全完成——让我们进行一些最终优化!
最终优化
作者:Paul Hudson 2023年10月29日
如果你尝试使用这个应用程序,很快就会发现它有两个问题:
- 添加支出项目后,无法看到该项目的任何详细信息。
- 添加支出项目后,
AddView不会关闭,仍然停留在屏幕上。
在完成本项目之前,让我们修复这些问题,让整个应用程序感觉更完善。
首先,关闭AddView可以通过在合适的时机调用环境中的dismiss()方法来实现。这个方法由视图的环境控制,并且与我们工作表的isPresented参数相关联——我们将这个布尔值设为true来显示AddView,而当我们调用dismiss()时,环境会将这个布尔值重新设为false,从而隐藏AddView。
首先在AddView中添加以下属性:
@Environment(\.dismiss) var dismiss你会注意到我们没有为这个属性指定类型——Swift可以通过@Environment属性包装器推断出类型。
接下来,我们需要在希望视图关闭时调用dismiss()。这会使ContentView中的showingAddExpense布尔值变回false,并隐藏AddView。我们已经在AddView中有一个“保存”按钮,它会创建一个新的支出项目并将其添加到现有的支出项目数组中,所以在这行代码的正下方添加以下代码:
dismiss()这就解决了第一个问题,剩下的第二个问题是:我们只显示了每个支出项目的名称,没有显示其他信息。这是因为我们列表的ForEach代码非常简单:
ForEach(expenses.items) { item in
Text(item.name)
}我们将用嵌套的栈视图来替换这段代码,以确保所有信息在屏幕上显示良好。内部将使用一个VStack来显示支出项目的名称和类型,外部则使用一个HStack,将VStack放在左侧,中间用一个间隔器(Spacer),右侧显示支出金额。这种布局在iOS中很常见:左侧显示标题和副标题,右侧显示更多信息。
将ContentView中现有的ForEach替换为以下代码:
ForEach(expenses.items) { item in
HStack {
VStack(alignment: .leading) {
Text(item.name)
.font(.headline)
Text(item.type)
}
Spacer()
Text(item.amount, format: .currency(code: "USD"))
}
}现在最后一次运行程序并尝试使用——我们的项目完成了!