[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
结构体再深入思考。它现在有三个属性: name
, type
和 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的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!
- 2022-10-05Swift语法学习--基于协议进行网络请求
- 2022-08-17Apple开发_Swift语言地标注释
- 2022-07-24Swift 初见
- 2022-05-22SwiftUI App 支持多语种 All In One
- 2022-05-10SwiftUI 组件参数简写 All In One
- 2022-04-14SwiftUI 学习笔记
- 2022-02-23Swift 文件夹和文件操作
- 2022-02-17Swift中使用KVO
- 2022-02-08Swift 汇编 String array
- 2022-01-30SwiftUI3.0页面反向传值