第68天 项目 14 第一部分
好消息是,今天将是你近期最轻松的一天。这并不意味着我们要学习无关紧要的内容,只是我们今天要介绍的新技术,绝对能让你从Core Image的学习中得到一个愉快的调剂。
我能说的只有:好好好珍惜这段时光!明天我们就要回到较难的内容了。考虑到你已经完成了这100天学习计划的三分之二以上,想必这一点不会让你感到意外。
继续加油!正如文斯·隆巴迪(Vince Lombardi)所说:“只有在字典里,‘成功’才会出现在‘努力’之前。”
今天你需要学习四个主题,从中你将了解如何实现Comparable协议、查找用户的文档目录等内容。
- 愿望清单(Bucket List):简介
- 为自定义类型添加
Comparable协议遵循 - 将数据写入文档目录
- 使用枚举切换视图状态
愿望清单(Bucket List):简介
作者:Paul Hudson 2023年12月23日
在这个项目中,我们将制作一个应用,用户可以在地图上创建一个私人列表,添加自己将来想去的地方,为这些地方添加描述,查找附近有趣的地点,并将所有内容保存到iOS存储中,以便日后查看。
要实现所有这些功能,我们需要用到一些你已经学过的技能,比如表单(forms)、工作表(sheets)、Codable协议和URLSession,同时还会学习一些新技能:如何在SwiftUI应用中嵌入地图、如何安全存储私人数据(确保只有已验证用户才能访问)、如何在UserDefaults之外加载和保存数据等。
所以,有很多知识要学,还有一个很棒的应用要制作!好了,我们开始学习相关技术:使用App模板创建一个新的iOS项目,并将其命名为Bucket List。接下来我们就进入技术学习环节……
为自定义类型添加Comparable协议遵循
作者:Paul Hudson 2023年12月23日
仔细想想,我们在编写Swift代码时,其实想当然地认为很多功能是“理所当然”的。例如,当我们写下4 < 5时,我们期望它返回true——Swift的开发者(以及支撑Swift的大型编译器项目LLVM的开发者)已经完成了判断这个计算是否为真的所有复杂工作,所以我们无需为此费心。
但Swift真正出色的地方在于,它能通过协议(protocols)和协议扩展(protocol extensions),将功能扩展到各个领域。比如,我们知道4 < 5为真,是因为我们能够比较两个整数,并判断第一个整数应该在第二个整数之前还是之后。Swift将这种功能扩展到了整数数组:我们可以比较数组中的所有整数,判断每个整数应该在其他整数之前还是之后。然后Swift会根据这个结果对数组进行排序。
因此,在Swift中,我们期望下面这样的代码能“正常工作”:
struct ContentView: View {
let values = [1, 5, 3, 6, 2, 9].sorted()
var body: some View {
List(values, id: \.self) {
Text(String($0))
}
}
}我们不需要告诉sorted()方法该如何工作,因为它理解整数数组的排序规则。
现在来看下面这个结构体:
struct User: Identifiable {
let id = UUID()
var firstName: String
var lastName: String
}我们可以创建一个包含这些用户的数组,并在List中使用它们,如下所示:
struct ContentView: View {
let users = [
User(firstName: "Arnold", lastName: "Rimmer"),
User(firstName: "Kristine", lastName: "Kochanski"),
User(firstName: "David", lastName: "Lister"),
]
var body: some View {
List(users) { user in
Text("\(user.lastName), \(user.firstName)")
}
}
}这段代码能正常运行,因为我们让User结构体遵循了Identifiable协议。
但如果我们想让这些用户按排序后的顺序显示呢?如果我们将代码修改成下面这样,它就无法正常工作了:
let users = [
User(firstName: "Arnold", lastName: "Rimmer"),
User(firstName: "Kristine", lastName: "Kochanski"),
User(firstName: "David", lastName: "Lister"),
].sorted()Swift不知道这里的sorted()方法意味着什么,因为它不清楚应该按名字(firstName)排序、按姓氏(lastName)排序、同时按两者排序,还是按其他规则排序。
解决这个问题的一种方法是,给sorted()方法传入一个闭包,自行实现排序逻辑。闭包会接收数组中的两个对象(如果使用简写名称,就是$0和$1),如果第一个对象应该排在第二个对象之前,我们就返回true,代码如下:
let users = [
User(firstName: "Arnold", lastName: "Rimmer"),
User(firstName: "Kristine", lastName: "Kochanski"),
User(firstName: "David", lastName: "Lister"),
].sorted {
$0.lastName < $1.lastName
}这种方法确实有效,但它并不是一个理想的解决方案,原因有两点。
首先,这是“模型”数据相关的操作,也就是说,它会影响我们使用User结构体的方式。这个结构体及其属性是我们的数据模型,在一个完善的应用中,我们并不希望在SwiftUI代码中定义数据模型的行为。SwiftUI代表的是我们的“视图”(view),也就是布局, 如果我们在视图代码中混入模型相关代码,会导致逻辑混乱。
其次,如果我们需要在多个地方对User数组进行排序,会出现什么问题呢?你可能会复制粘贴这个闭包一两次,但很快就会发现这会给自己带来麻烦:如果之后你想修改排序逻辑(比如当姓氏相同时,再按名字排序),就需要在所有代码中查找并更新所有相关的闭包。
Swift有一个更好的解决方案。整数数组之所以能直接使用无参数的sorted()方法,是因为Swift理解如何比较两个整数。从代码层面来说,Int类型遵循了Comparable协议,该协议要求定义一个函数,接收两个整数,并返回true表示第一个整数应该排在第二个整数之前。
我们也可以让自己定义的类型遵循Comparable协议,这样它们也能获得无参数的sorted()方法。这需要两个步骤:
- 在
User的定义中添加Comparable协议遵循声明。 - 添加一个名为
<的方法,接收两个用户对象,如果第一个用户应该排在第二个用户之前,就返回true。
代码实现如下:
struct User: Identifiable, Comparable {
let id = UUID()
var firstName: String
var lastName: String
static func <(lhs: User, rhs: User) -> Bool {
lhs.lastName < rhs.lastName
}
}这段代码看起来不多,但有很多需要深入理解的地方。
首先,没错,这个方法的名字就是<(小于运算符)。这个方法的作用是判断一个用户是否“小于”另一个用户(从排序的角度来说),所以我们其实是在为一个已有的运算符添加新功能。这被称为“运算符重载”(operator overloading),它既可能带来便利,也可能带来麻烦。
其次,lhs和rhs是编码中的常用缩写,分别代表“左手边”(left-hand side)和“右手边”(right-hand side)。使用这两个缩写是因为<运算符的左右两侧各有一个操作数。
第三,这个方法必须返回一个布尔值(Boolean),也就是说,我们必须明确判断一个对象是否应该排在另一个对象之前。这里不允许出现“两个对象相同”的情况——这种情况由另一个名为Equatable的协议处理。
第四,这个方法必须标记为static,这意味着它是直接通过User结构体调用的,而不是通过User结构体的某个实例调用的。
最后,我们这里的逻辑非常简单:将比较操作委托给结构体的某个属性,让Swift对两个用户的姓氏字符串执行<比较。你可以根据需要添加任意多的逻辑,比较任意多个属性,但最终都必须返回true或false。
提示: 这段代码中有一个隐藏的特性——遵循Comparable协议后,我们还能使用>运算符(大于运算符)。>运算符是<运算符的相反操作,所以Swift会通过<运算符的结果反转布尔值,自动为我们实现>运算符的功能。
现在,我们的User结构体已经遵循了Comparable协议,因此自动获得了无参数的sorted()方法,这意味着下面这样的代码现在可以正常工作了:
let users = [
User(firstName: "Arnold", lastName: "Rimmer"),
User(firstName: "Kristine", lastName: "Kochanski"),
User(firstName: "David", lastName: "Lister"),
].sorted()这就解决了我们之前遇到的问题:现在我们将模型相关的功能封装在了结构体内部,不再需要复制粘贴代码——我们可以在任何地方使用sorted()方法,而且可以放心,只要我们修改了排序算法,所有使用该方法的代码都会自动适配。
将数据写入文档目录
作者:Paul Hudson 2023年12月23日
之前我们学习过如何使用UserDefaults读取和写入数据,这种方式非常适合存储用户设置或少量JSON数据;我们还学习过SwiftData,当需要处理对象之间的关系,或者需要更复杂的排序和筛选功能时,SwiftData是一个很好的选择。
在这个应用中,我们将介绍一种介于两者之间的方式:直接将数据写入文件。这并不是因为我不喜欢SwiftData,事实上,我认为SwiftData在这里也很适用。选择这种方式,是为了向你展示iOS开发中所有可能的实现方案——有很多你未来会接触到的应用,正是使用这种方式来保存数据的,了解它的工作原理对你很有帮助。
话虽如此,在这里使用UserDefaults绝对是个坏主意,因为用户在这个应用中创建的数据量是没有限制的。UserDefaults更适合存储简单的设置之类的数据。
幸运的是,iOS让从设备存储中读取和写入数据变得非常简单,而且实际上,所有应用都有一个专门的目录,用于存储各种文档。这个目录中的文件会自动与iCloud备份同步,因此如果用户更换了新设备,我们的数据会和其他系统数据一起被恢复——我们甚至不需要为此做任何额外操作。
不过这里有一个小陷阱(事情往往如此):所有iOS应用都运行在“沙盒”(sandbox)中,这意味着每个应用都在自己的容器中运行,容器的目录名是难以猜测的。因此,我们不能(也不应该尝试)猜测应用的安装目录,而是需要依赖一个特殊的URL,这个URL指向应用的文档目录:
print(URL.documentsDirectory)这个文档目录完全由我们支配,而且因为它属于当前应用,所以当应用被删除时,这个目录也会被自动删除。除了设备的物理存储限制外,我们在这个目录中存储的数据量没有限制,但要记住,用户可以通过“设置”应用查看你的应用占用了多少存储空间——所以请尊重用户的存储资源!
有了可用的目录后,我们就可以自由地在其中读取和写入文件了。你已经学过使用String(contentsOf:)和Data(contentsOf:)来“读取”数据,而要写入数据,我们需要使用write(to:)方法。这个方法接收两个参数:
- 要写入的目标URL。
- 保存数据时使用的额外选项。
第一个参数可以通过将文档目录的URL与文件名(例如myfile.txt)组合来创建。
至于第二个参数,我建议传入一个包含两个值的数组:.atomic和.completeFileProtection。它们的作用完全不同,但都很重要:
- 请求“原子性保存”(atomic saving)意味着整个文件应该一次性写入。如果不设置这个选项,当我们尝试写入一个较大的文件时,应用的其他部分可能会在文件尚未完全写入时尝试读取它。这虽然不会导致崩溃,但确实会导致只能读取到部分数据(因为另一部分数据还没写完)。“原子性”写入会让系统先将完整的文件写入一个临时文件名(不是我们指定的目标文件名),当写入完成后,再将临时文件重命名为我们指定的目标文件名。这样一来,要么能看到完整的文件,要么什么都看不到(不会出现部分文件的情况)。
- 请求“完全文件保护”(complete file protection)意味着iOS会自动(且透明地)对文件进行加密,并且只允许我们的应用在设备解锁时读取该文件。iOS在保护用户数据安全方面做得很好,但多一层安全保障总是好的!
为了将这些代码付诸实践,我们来修改模板中的默认代码:向文档目录中的一个文件写入一个测试字符串,然后将其读回并存储到一个新字符串中,最后打印出来——完成一次完整的数据读写循环。
将ContentView的body属性修改为以下内容:
Button("Read and Write") {
let data = Data("Test Message".utf8)
let url = URL.documentsDirectory.appending(path: "message.txt")
do {
try data.write(to: url, options: [.atomic, .completeFileProtection])
let input = try String(contentsOf: url)
print(input)
} catch {
print(error.localizedDescription)
}
}运行代码后,点击按钮,你应该能在Xcode的调试输出区域看到“Test Message”被打印出来。
在继续之前,给你一个小挑战:在第8个项目中,我们学习过如何为Bundle创建一个泛型扩展,以便查找、加载和解码应用资源包中的任何Codable数据。你能为文档目录编写类似的扩展吗?或许可以将其作为FileManager的扩展。
使用枚举切换视图状态
作者:Paul Hudson 2023年12月23日
你已经了解过如何使用常规的Swift条件语句来展示不同类型的视图,例如:
if Bool.random() {
Rectangle()
} else {
Circle()
}提示: 当需要返回不同类型的视图时,请确保你要么在body属性内部编写代码,要么使用@ViewBuilder或Group这样的工具。
条件视图尤其适用于需要展示多种不同状态的场景。如果规划得当,我们可以让视图代码保持简洁,并且易于维护——这是训练自己从SwiftUI架构角度思考问题的好方法。
实现这个方案需要两个步骤。第一步是定义一个枚举,用来表示你想要展示的各种视图状态。例如,你可以定义一个嵌套枚举:
enum LoadingState {
case loading, success, failed
}第二步是为这些状态分别创建对应的视图。这里我只用简单的文本视图(Text)来演示,但实际上这些视图可以包含任何内容:
struct LoadingView: View {
var body: some View {
Text("Loading...")
}
}
struct SuccessView: View {
var body: some View {
Text("Success!")
}
}
struct FailedView: View {
var body: some View {
Text("Failed.")
}
}这些视图可以嵌套在其他结构体中,也可以不嵌套——这主要取决于你是否计划在其他地方使用它们,以及应用的规模大小。
完成这两个步骤后,我们就可以将ContentView用作一个简单的“包装器”:它负责跟踪应用的当前状态,并展示对应的子视图。这意味着我们需要给它添加一个属性,用来存储当前的LoadingState值:
@State private var loadingState = LoadingState.loading然后在它的body属性中编写代码,根据枚举值展示正确的视图,如下所示:
if loadingState == .loading {
LoadingView()
} else if loadingState == .success {
SuccessView()
} else if loadingState == .failed {
FailedView()
}你也可以使用switch语句来实现,代码如下:
switch loadingState {
case .loading:
LoadingView()
case .success:
SuccessView()
case .failed:
FailedView()
}提示: 对枚举使用switch语句有一个优势——Swift会检查我们是否覆盖了所有的枚举情况。这意味着如果将来你添加了新的枚举情况,Swift会提示你进行相应的处理。
使用这种方式,即使视图中添加了越来越多的代码,ContentView也不会变得臃肿失控。事实上,ContentView甚至不需要知道“加载中”“成功”和“失败”这些状态具体长什么样。