Skip to content

第37天 项目 7 第二部分

今天你将使用@Observablesheet()CodableUserDefaults等工具构建一个完整的应用程序。我知道这看起来看起来看起来很多,但希望你能试着思考思考这些工具在后台所做的工作:

  • @Observable会监听类的变化,并刷新刷新所有受影响的视图。
  • sheet()会监听我们指定的条件,并自动显示或隐藏视图。
  • Codable几乎不需要我们编写代码,就能将Swift对象转换为JSON格式,也能将JSON格式转换回Swift对象。
  • UserDefaults可以读取和写入数据,让我们能即时保存设置等信息。

没错,我们需要编写代码来整合这些工具,但大量冗余代码已被移除,最终留下的代码非常简洁。正如法国作家兼诗人安托万·德·圣-埃克苏佩里所说:“完美并非在无可添加之时达成,而是在无可删减之刻实现。”

今天你需要完成五个主题的学习,在这些主题中,你将实践所学的@Observablesheet()onDelete()等知识。

  • 构建可删除内容的列表
  • 在SwiftUI中处理可识别的项目
  • 与新视图共享被观察对象
  • 使用UserDefaults实现更改的持久化
  • 最终优化

又一个应用程序构建完成了,同时也在实际场景中运用了更多技术——做得好!

构建可删除内容的列表

作者:Paul Hudson 2023年10月29日

在这个项目中,我们需要一个能显示支出信息的列表,之前我们会使用@State数组来存储对象实现这一功能。但这次我们将采用不同的方法:创建一个Expenses类,并通过@State将其关联到我们的列表。

这听起来可能有点复杂化,但实际上会让事情变得更简单,因为我们可以让Expenses类无缝地加载和保存自身数据——你很快就会发现,这一过程几乎是无感知的。

首先,我们需要确定“支出”包含哪些信息——我们希望它存储什么内容?在这个案例中,它将包含三部分信息:项目名称、支出类型(商务或个人)以及金额(类型为Double)。

之后我们会对其进行扩展,但目前我们可以用一个ExpenseItem结构体来表示所有这些信息。你可以将这个结构体放在一个名为ExpenseItem.swift的新Swift文件中,当然也不是必须这样做——只要不把它放在ContentView结构体内部,放在ContentView.swift文件中也可以。

无论你将其放在哪里,都可以使用以下代码:

swift
struct ExpenseItem {
    let name: String
    let type: String
    let amount: Double
}

现在我们已经有了表示单个支出项目的结构体,下一步就是创建一个对象来存储这些支出项目的数组。这个对象需要使用@Observable宏,这样SwiftUI才能对其进行监听。

ExpenseItem结构体一样,这个类一开始会比较简单,之后之后会进行扩展,现在先添加这个新类:

swift
@Observable
class Expenses {
    var items = [ExpenseItem]()
}

至此,主视图所需的所有数据都已准备就绪:我们有一个表示单个支出项目的结构体,还有一个用于存储所有支出项目数组的类。

现在让我们在SwiftUI视图中实际运用这些数据,这样我们就能在屏幕上看到数据了。我们的视图大部分内容将是一个List,用于显示支出项目,但由于我们希望用户能删除不再需要的项目,所以不能简单地使用List——我们需要在列表内部使用ForEach,这样才能使用onDelete()修饰符。

首先,我们需要在视图中添加一个@State属性,用于创建Expenses类的实例:

swift
@State private var expenses = Expenses()

记住,这里使用@State是为了保持对象的存活,而真正让SwiftUI能够监听对象变化的是@Observable宏。

其次,我们可以将Expenses对象与NavigationStackListForEach结合使用,创建基本布局:

swift
NavigationStack {
    List {
        ForEach(expenses.items, id: \.name) { item in
            Text(item.name)
        }
    }
    .navigationTitle("iExpense")
}

这段代码告诉ForEach通过支出项目的名称来唯一标识每个项目,然后在列表行中显示项目名称。

在完成这个简单布局之前,我们还需要添加两件事:用于测试的添加新项目功能,以及通过滑动删除项目的功能。

很快我们会让用户自己添加项目,但在继续之前,确保列表能正常工作是很重要的。所以,我们要添加一个工具栏按钮,用于添加示例ExpenseItem实例,现在就将这个修饰符添加到List上:

swift
.toolbar {
    Button("Add Expense", systemImage: "plus") {
        let expense = ExpenseItem(name: "Test", type: "Personal", amount: 5)
        expenses.items.append(expense)
    }
}

这样我们的应用程序就有了交互功能:现在启动应用,反复点击+按钮,就能添加多个测试用的支出项目。

既然我们能添加支出项目,也应该能删除它们。这就需要添加一个方法,该方法能够删除列表中指定索引集(IndexSet)的项目,然后直接对expenses数组执行删除操作:

swift
func removeItems(at offsets: IndexSet) {
    expenses.items.remove(atOffsets: offsets)
}

要将这个方法与SwiftUI关联起来,我们需要给ForEach添加onDelete()修饰符,如下所示:

swift
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能清楚地知道我们有哪些视图,并且能够控制这些视图、为其添加动画效果等。但当我们使用ListForEach创建动态视图时,SwiftUI需要知道如何唯一地标识每个项目,否则它将难以比较视图层级结构,无法判断哪些内容发生了变化。

在我们当前的代码中,有这样一段:

swift
ForEach(expenses.items, id: \.name) { item in
    Text(item.name)
}
.onDelete(perform: removeItems)

用通俗的话来说,这段代码的意思是:“为支出项目数组中的每个项目创建一个新行,通过项目名称来唯一标识每个项目,在行中显示项目名称,删除项目时调用removeItems()方法。”

之后,我们还有这样一段代码:

swift
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结构体。目前它有三个属性:nametypeamount。单靠名称在实际情况中可能是唯一的,但也很可能不是——只要用户两次输入“午餐”,我们就会遇到问题。或许我们可以尝试将名称、类型和金额组合成一个新的计算属性,但这也只是延缓问题的发生,它仍然不是真正唯一的。

明智的解决方案是给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属性:

swift
struct ExpenseItem {
    let id: UUID
    let name: String
    let type: String
    let amount: Double
}

这样是可行的。但这也意味着我们需要手动生成UUID,并且在保存和加载其他数据时也要一并处理UUID。所以,在这个案例中,我们会让Swift自动为我们生成UUID,代码如下:

swift
struct ExpenseItem {
    let id = UUID()
    let name: String
    let type: String
    let amount: Double
}

现在我们不需要担心支出项目的id值了——Swift会确保它们始终是唯一的。

有了这个属性后,我们就可以修复ForEach了,代码如下:

swift
ForEach(expenses.items, id: \.id) { item in
    Text(item.name)
}

现在运行应用程序,你会发现问题已经解决了:SwiftUI现在能清楚地知道哪个支出项目被删除了,并且能正确地执行动画。

不过,这一步我们还没有完成。接下来,我希望你修改ExpenseItem,让它遵循一个名为Identifiable的新协议,代码如下:

swift
struct ExpenseItem: Identifiable {
    let id = UUID()
    let name: String
    let type: String
    let amount: Double
}

我们所做的只是在协议遵循列表中添加了Identifiable,没有其他操作。这是Swift内置的协议之一,表示“该类型可以被唯一标识”。它只有一个要求,即必须有一个名为id的属性,该属性存储唯一标识符。我们刚刚已经添加了这个属性,所以不需要再做额外的工作——我们的类型已经可以很好地遵循Identifiable协议了。

现在,你可能会疑惑我们为什么要添加这个协议,因为之前的代码已经能正常工作了。其实,因为我们的支出项目现在保证是可唯一标识的,所以我们不再需要告诉ForEach使用哪个属性作为标识符——它知道会有一个id属性,并且这个属性是唯一的,因为这正是Identifiable协议的作用。

因此,由于这个修改,我们可以再次修改ForEach,代码如下:

swift
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)中。

这些对你来说应该都是熟悉的内容了,现在让我们开始编写代码:

swift
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是否正在显示,现在就添加这个属性:

swift
@State private var showingAddExpense = false

其次,我们需要告诉SwiftUI使用这个布尔值作为显示工作表(弹出窗口,sheet)的条件。这可以通过给视图层级结构中的某个视图添加sheet()修饰符来实现。你可以给List添加这个修饰符,给NavigationStack添加也同样可以。无论选择哪种方式,现在都在ContentView的某个视图上添加以下代码作为修饰符:

swift
.sheet(isPresented: $showingAddExpense) {
    // 在这里显示AddView
}

第三步是在工作表中添加内容。通常,这里会直接是你想要显示的视图类型的实例,如下所示:

swift
.sheet(isPresented: $showingAddExpense) {
    AddView()
}

但是,在这里我们需要更多的操作。你知道,我们已经在内容视图(ContentView)中有了expenses属性,而在AddView中,我们将编写代码来添加支出项目。我们不希望在AddView中创建Expenses类的第二个实例,而是希望它共享来自ContentView的现有实例。

所以,我们要做的是给AddView添加一个属性,用于存储Expenses对象。请在AddView中添加以下属性:

swift
var expenses: Expenses

现在我们可以将现有的Expenses对象从一个视图传递到另一个视图了——两个视图将共享同一个对象,并且都会监听该对象的变化。将ContentView中的sheet()修饰符修改为以下代码:

swift
.sheet(isPresented: $showingAddExpense) {
    AddView(expenses: expenses)
}

这一步我们还没有完全完成,原因有两个:一是代码无法编译,二是即使能编译,按钮也无法触发工作表的显示。

编译失败是因为当我们创建新的SwiftUI视图时,Xcode还会添加一些预览代码,以便我们在编写代码时能查看视图的设计效果。如果你在AddView.swift文件的底部找到这段预览代码,会发现它试图创建一个AddView实例,但没有为expenses属性提供值。

现在这种方式是不允许的,但我们可以传入一个虚拟值来解决,代码如下:

swift
#Preview {
    AddView(expenses: Expenses())
}

第二个问题是,我们实际上没有任何代码来显示工作表,因为目前ContentView中的+按钮只是添加测试用的支出项目。幸运的是,修复这个问题很简单——只需将现有的按钮操作替换为切换showingAddExpense布尔值的代码,如下所示:

swift
Button("Add Expense", systemImage: "plus") {
    showingAddExpense = true
}

现在运行应用程序,整个工作表功能应该能正常工作了——你从ContentView开始,点击+按钮调出AddView,在其中输入各种信息,然后可以通过滑动来关闭AddView

使用UserDefaults实现更改的持久化

作者:Paul Hudson 2024年4月11日

目前,我们应用程序的用户界面已经可以正常使用了:你已经看到我们能够添加和删除项目,现在还有一个用于创建新支出项目的工作表视图。但是,这个应用程序还远未完善:在AddView中输入的任何数据都会被完全忽略,即使没有被忽略,在应用程序下次启动时,这些数据也不会被保存。

我们将按顺序解决这些问题,首先处理如何实际利用AddView中的数据。我们已经有了存储表单中输入值的属性,之前也添加了一个属性来存储从ContentView传递过来的Expenses对象。

我们需要将这两部分结合起来:添加一个按钮,当点击该按钮时,根据我们的属性创建一个ExpenseItem,并将其添加到expenses的项目数组中。

AddView中,在navigationTitle()下方添加以下修饰符:

swift
.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类中,我们已经有了这个属性:

swift
var items = [ExpenseItem]()

这是我们存储所有已创建的支出项目结构体的地方,我们也将在这里添加属性观察器,以便在数据发生变化时将其写入存储。

这总共需要四个步骤:创建一个JSONEncoder实例,用于将数据转换为JSON格式;请求该编码器尝试对items数组进行编码;然后将编码后的数据通过键“Items”写入UserDefaults

items属性修改为以下代码:

swift
var items = [ExpenseItem]() {
    didSet {
        if let encoded = try? JSONEncoder().encode(items) {
            UserDefaults.standard.set(encoded, forKey: "Items")
        }
    }
}

提示: 使用JSONEncoder().encode()意味着“创建一个编码器并使用它对某个内容进行编码”,这是一步完成的操作,无需先创建编码器再后续使用。

如果你正在跟随操作,会发现这段代码实际上无法编译。如果观察得更仔细,你会发现我之前说这个过程需要四个步骤,但上面只列出了三个。

问题在于,encode()方法只能对遵循Codable协议的对象进行归档。记住,遵循Codable协议会让编译器为我们生成处理归档和反归档对象的代码,如果我们不添加这个协议遵循,代码就无法编译。

好在我们只需给ExpenseItem添加Codable协议遵循即可,无需其他操作,代码如下:

swift
struct ExpenseItem: Identifiable, Codable {
    let id = UUID()
    let name: String
    let type: String
    let amount: Double
}

Swift已经为ExpenseItemUUIDStringDouble属性提供了Codable协议遵循,因此只要我们要求,编译器就能自动让ExpenseItem遵循Codable协议。

但是,你会看到一个警告,提示id属性不会被解码,因为我们将其设为常量并给了一个默认值。实际上,这正是我们想要的行为,但Swift出于好心会给出警告,因为它担心你可能原本计划从JSON中解码这个值。要消除这个警告,只需将该属性改为变量,代码如下:

swift
var id = UUID()

完成这个修改后,我们就编写好了所有确保用户添加项目时数据能被保存的代码。但这本身还不够:数据可能被保存了,但在应用程序重新启动时不会被加载。

要解决这个问题,我们需要实现一个自定义初始化器。这个初始化器将:

  1. 尝试从UserDefaults中读取键“Items”对应的数据。
  2. 创建一个JSONDecoder实例,它是JSONEncoder的对应工具,能将JSON数据转换回Swift对象。
  3. 请求解码器将从UserDefaults中获取的数据转换为ExpenseItem对象数组。
  4. 如果转换成功,将结果数组赋值给items并退出初始化器。
  5. 如果转换失败,则将items设为空数组。

现在在Expenses类中添加这个初始化器:

swift
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日

如果你尝试使用这个应用程序,很快就会发现它有两个问题:

  1. 添加支出项目后,无法看到该项目的任何详细信息。
  2. 添加支出项目后,AddView不会关闭,仍然停留在屏幕上。

在完成本项目之前,让我们修复这些问题,让整个应用程序感觉更完善。

首先,关闭AddView可以通过在合适的时机调用环境中的dismiss()方法来实现。这个方法由视图的环境控制,并且与我们工作表的isPresented参数相关联——我们将这个布尔值设为true来显示AddView,而当我们调用dismiss()时,环境会将这个布尔值重新设为false,从而隐藏AddView

首先在AddView中添加以下属性:

swift
@Environment(\.dismiss) var dismiss

你会注意到我们没有为这个属性指定类型——Swift可以通过@Environment属性包装器推断出类型。

接下来,我们需要在希望视图关闭时调用dismiss()。这会使ContentView中的showingAddExpense布尔值变回false,并隐藏AddView。我们已经在AddView中有一个“保存”按钮,它会创建一个新的支出项目并将其添加到现有的支出项目数组中,所以在这行代码的正下方添加以下代码:

swift
dismiss()

这就解决了第一个问题,剩下的第二个问题是:我们只显示了每个支出项目的名称,没有显示其他信息。这是因为我们列表的ForEach代码非常简单:

swift
ForEach(expenses.items) { item in
    Text(item.name)
}

我们将用嵌套的栈视图来替换这段代码,以确保所有信息在屏幕上显示良好。内部将使用一个VStack来显示支出项目的名称和类型,外部则使用一个HStack,将VStack放在左侧,中间用一个间隔器(Spacer),右侧显示支出金额。这种布局在iOS中很常见:左侧显示标题和副标题,右侧显示更多信息。

ContentView中现有的ForEach替换为以下代码:

swift
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"))
    }
}

现在最后一次运行程序并尝试使用——我们的项目完成了!

本站使用 VitePress 制作