[SwiftUI 100 天] iExpense - part3

2020/3/22 23:03:20

本文主要是介绍[SwiftUI 100 天] iExpense - part3,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

译自 Archiving Swift objects with Codable
更多内容,欢迎关注公众号 「Swift花园」
喜欢文章?不如来个 🔺💛➕三连?关注专栏,关注我 🚀🚀🚀

用 Codable 打包 Swift 对象

UserDefaults 对于存储像整数和布尔值这样的简单的数据非常好用,但是对于复杂数据 —— 比如自定义的 Swift 类型,我们需要稍微多做一些工作。

下面是一个简单的 User 数据结构:

struct User {
    var firstName: String
    var lastName: String
}复制代码

它有两个字符串,它们并不特别,整数,布尔型和浮点数也一样。即便是数组和字典,也都是很容易理解的类型。

处理像这样的类型,Swift 给了我们一个很奇妙的协议,叫Codable:它是一个专门用来打包和解包数据的协议,作为 “把对象转换成普通文本以及转换回来” 的高级说法。

在未来的项目中,我们还会多次深入 Codable ,不过眼前的需求很简单:我们想要打包一个自定义类型以便放进 UserDefaults,然后从UserDefaults拿出来时再解包回该自定义类型。

当我们处理只包含简单属性的类型时 —— 简单属性指的是字符串,整型,布尔型和字符串数组等 —— 我们唯一要做的事情是让类型支持打包和解包,通过遵循 Codable 协议实现:

struct User: Codable {
    var firstName: String
    var lastName: String
}复制代码

Swift 会按需自动生成一些可以打包和解包 User 实例的代码,而我们要做的是告诉 Swift 什么时候打包和解包,以及如何处理相关数据。

打包过程是通过一个叫 JSONEncoder 的类型驱动,它的工作是接收遵循Codable 协议的对象,返回对象 JavaScript Object Notation (JSON) 形式 —— 虽然这个命名暗示是给 JavaScript 使用的,但实践中因为这种数据格式的便捷性,它已经被使用广泛。

Codable 协议并不要求我们必须使用 JSON ,实际上它对其他格式也是支持的,但 JSON 目前是最常用的。在这个案例中,我们其实不关心数据是什么类型的,因为数据只是被简单地存进 UserDefaults

为了把 user 数据转换成 JSON 数据,我们需要在JSONEncoder实例上调用一个 encode 方法。这个方法可能抛出错误,因此需要用 try 或者 try? 恰当地处理那些错误。举个例子,假如我们用拥有一个 User 类型的属性,像下面这样:

@State private var user = User(firstName: "Taylor", lastName: "Swift")复制代码

我们创建一个按钮,在点击时打包这个属性的值并且存入UserDefaults ,像这样:

Button("Save User") {
    let encoder = JSONEncoder()

    if let data = try? encoder.encode(self.user) {
        UserDefaults.standard.set(data, forKey: "UserData")
    }
}复制代码

上面的 data 常量是一个新的数据类型,叫 Data。这个类型被设计来存储你可以想到的任意类型的数据,比如字符串,图像,zip 压缩包,等等。不过,我们这里只关心它是一种可以直接写入UserDefaults的类型。

当我们做反向的操作时 —— 也就是我们有 JSON 数据,希望转换成 Swift Codable 类型时 —— 我们需要换成 JSONDecoder ,但过程几乎一样。

这个项目用到的技术点介绍到这里,接下来可以尽管把项目恢复到初始状态,我们要开始实际编码了。

译自 Building a list we can delete from

构建一个可删除条目的列表

在这个项目中我们想要构建一个显示费用项的列表,而之前我们已经用@State 的对象数组实现这一点了。不过这一次我们要用一种不同的方法:我们将创建一个Expenses 类,然后通过@ObservedObject关联到列表。

看起来我们把事情变复杂了,但实际上是更简单了。因为我们会让 Expenses 类无缝地自行加载和保存 —— 你将看到,这个过程是无感的。

首先,我们需要确定花销项怎么表示,以及我们想把它们存在哪里。在这个案例中具体有三样东西:花销项的名字,它是属于商务的还是个人的,以及具体费用。

之后我么还会扩展更多内容,但是现在只需要一个 ExpenseItem 结构体来表示就行了。你可以把下面的代码放进一个新的 Swift 文件,取名 ExpenseItem.swift ,但你也可以不这么做 —— 可以直接放进 ContentView.swift ,只要不放进 ContentView 结构体里面。

代码如下:

struct ExpenseItem {
    let name: String
    let type: String
    let amount: Int
}复制代码

有了代表花销项的数据结构,下一步是创建一个可以存储这些花销项数组的类型,它必须遵循 ObservableObject 协议,并且我们会利用 @Published来确保发生在 items 数组上的改动对外发布。

ExpenseItem 结构体一样,这个类型一开始也简单实现,添加下面这个的新类:

class Expenses: ObservableObject {
    @Published var items = [ExpenseItem]()
}复制代码

这样就完成了主视图需要的全部数据:我们有表示单个花销项的结构体,有存储所有花销项数组的类。

接下来把它们应用到 SwiftUI 视图,以便我们在屏幕上看到数据。视图上最主要的部分会是一个展示花销项的 List ,但是由于我们想要用户能够删除他们不再需要的列表项,我们不能用最简单的 List —— 我们需要在 List 内部用到一个 ForEach ,以便访问onDelete() modifier 。

首先,在视图中添加 @ObservedObject 属性,创建一个 Expenses 类的属性:

@ObservedObject var expenses = Expenses()复制代码

记得吧,使用 @ObservedObject 是让 SwiftUI 监测对象的变化,当 @Published 属性改变时视图会刷新 body 。

然后,我们用一个 NavigationView,一个 List,一个ForEach以及 Expenses 实例构建出基本布局:

NavigationView {
    List {
        ForEach(expenses.items, id: \.name) { item in
            Text(item.name)
        }
    }
    .navigationBarTitle("iExpense")
}复制代码

上面的代码告诉 ForEach 以名称来唯一识别每个 expense 项,然后把名称作为文本行呈现在列表中。

我们还要加两样东西到这个布局中:可以新增花销项以便测试的功能,以及用轻扫手势删除花销项的功能。

稍后我们会让用户自行添加花销项,不过在那之前得先确保列表正确地工作。所以我们要加一个在导航栏右侧的按钮,点击按钮时先生成一个样例的 ExpenseItem ,并加入花销项列表 —— 把下面的代码添加到 List

.navigationBarItems(trailing:
    Button(action: {
        let expense = ExpenseItem(name: "Test", type: "Personal", amount: 5)
        self.expenses.items.append(expense)
    }) {
        Image(systemName: "plus")
    }
)复制代码

这个操作一下让我们的 app 鲜活起来。你可以启动 app ,重复点击 + 按钮,添加多个测试用的花销项。

实现了添加花销项的代码,我们还需要实现删除它们的代码,这意味着要加一个能够接收 IndexSet类型作为参数以删除列表项的方法:

func removeItems(at offsets: IndexSet) {
    expenses.items.remove(atOffsets: offsets)
}复制代码

添加 onDelete() modifier 到 ForEach,连接删除操作和 SwiftUI :

ForEach(expenses.items, id: \.name) { item in
    Text(item.name)
}
.onDelete(perform: removeItems)复制代码

再次运行 app ,尝试轻扫列表项删除花销项。

现在认真观察发生的事情。你注意到了什么?添加花销项工作正常,但是删除操作有点奇怪,当你滑出列表第一项的删除按钮并且点击下去时,这一项并没有被删除,而是滑回它原来的地方,但是列表最后一项却被移除了!

究竟发生了什么?原来,是因为我们对 SwiftUI “说谎” 了,而这个谎言导致了问题的出现...

译自 Working with Identifiable items in SwiftUI

处理 SwiftUI 的 Identifiable Item

当我们在 SwiftUI 中创建静态视图的时候 —— 比如硬编码一个VStack,然后 TextField,再然后Button—— SwiftUI 知道这些视图是什么类型,并且能够控制它们,采用动画等等。 但是当我们用 List 或者 ForEach 创建动态视图时,SwiftUI 需要知道它该如何唯一地识别其中的每一项,否则它就无法比较视图体系,从中得知什么东西发生了变化。

我们的代码是这样的:

ForEach(expenses.items, id: \.name) { item in
    Text(item.name)
}
.onDelete(perform: removeItems)复制代码

换成自然语言表述 “为每个花销项创建一行 (即列表的的一项) ,并且以它们的名称标识它们,在行里显示这一项的名称,调用removeItems() 方法可以删除这一项”。

然后,我们还有下面的代码:

Button(action: {
    let expense = ExpenseItem(name: "Test", type: "Personal", amount: 5)
    self.expenses.items.append(expense)
}) {
    Image(systemName: "plus")
}复制代码

你看出问题所在了吗?

每次我们创建测试用的花销项时,我们用的名字都是 “Test” ,而我们也告诉 SwiftUI 它需要用花费项的名称作为唯一标识符。因此,当我们运行代码,删除某一项时,SwiftUI 先查看之前的数组 —— “Test”,“Test”,“Test”,“Test” —— 然后再查看之后的数组 ——afterwards “Test”,“Test”,“Test”。然后它实际上说不上来是哪里发生变化了。的确发生了某些事,因为少了一项,但 SwiftUI 无法确定究竟是少了哪一项。因此,它选择了最简单的选项,把表里最后一项干掉。

这是一个错误的逻辑。我们的代码是可以工作的,并不会引起运行时崩溃,我们的错误在于我们告诉 SwiftUI 某样东西可以用作唯一标识,而实际上它根本不是唯一的。

为了修正这个问题,我们需要对 ExpenseItem 结构体再深入思考。它现在有三个属性: nametype amount。名字自身在实践中可能是唯一的,但也有可能不是 —— 比如用户很有可能输入两次 “午餐” 。也许我们会尝试把名字,类型和数量结合到一个新的计算属性,但即使这样做也只是拖延了必然会发生的事情:它仍然不是唯一的。

更聪明的做法是添加某个一定唯一的属性给 ExpenseItem ,比如我们手工赋值一个 ID 数字。这样做当然可行,但也意味着我们需要跟踪赋值的数字以确保它们不会重复。

实际上有一种最简单的解决方案,它叫 UUID—— ”通用唯一标识符“ 的缩写。如果连这个都不唯一,那我想不到什么能做到唯一。

UUID 是一些十六进制的字符串:形如 08B15DB4-2F02-4AB8-A965-67A9C90D8A44 这样。因此它是 8 位数字,4 位数字,4 位数字,4 位数字,然后 12 位数字。其中唯一的要求是第三块以 4 开头。如果我们去掉这个固定的 4 ,会得到 31 位数字,每一位可能有 16 种值—— 如果我们每秒生成 1 个 UUID ,持续 10 亿年,才刚刚有微小的可能遭遇重复的情况。

好的,让我们更新 ExpenseItem ,添加一个UUID 属性:

struct ExpenseItem {
    let id: UUID
    let name: String
    let type: String
    let amount: Int
}复制代码

这样就可以了,但这样写意味着我们需要手工生成 UUID ,然后和其他数据一起加载和保存 UUID 。 所以,在实例中我们可以让 Swift 自动生成一个UUID

struct ExpenseItem {
    let id = UUID()
    let name: String
    let type: String
    let amount: Int
}复制代码

现在我们可以不用关心花费项的 id值 —— Swift 会确保它们总是唯一的。

上面的代码到位后我们可以在 ForEach中修正代码,像这样:

ForEach(expenses.items, id: \.id) { item in
    Text(item.name)
}复制代码

再次运行 app ,你会看到问题已经被修复:Swift 现在可以正确地发现哪个花销项被删除,并且正确地动画呈现删除的过程。

不过到这我们还不算完成。我想让你修改ExpenseItem ,让它遵循一个新的协议,叫 Identifiable,像这样:

struct ExpenseItem: Identifiable {
    let id = UUID()
    let name: String
    let type: String
    let amount: Int
}复制代码

我们所做的只是把 Identifiable 添加到遵循协议的清单里。这是一个 Swift 内置的协议,意思是 “这个类型可以被唯一标识。” 这个协议只有一个要求,也就是必须有一个叫 id 的属性,包含一个唯一标识符。我们刚刚已经添加过了,所以不必再做额外的工作。

现在你可能会好奇为什么我们要加这个协议,鉴于我们的代码在这之前已经可以工作了。是这样的,这么做可以保证我们的花销项是可通过 id 唯一标识的,因此就不用再告诉 ForEach哪个属性要用作标识符。它知道类型里一定有一个 id 属性,而且是唯一的,因为这正是Identifiable 协议的要点。

基于上面的修改,我们可以减少 ForEach 的代码,像这样:

ForEach(expenses.items) { item in
    Text(item.name)
}复制代码

好多了!

我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~




这篇关于[SwiftUI 100 天] iExpense - part3的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程