Skip to content

第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中,我们期望下面这样的代码能“正常工作”:

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()方法该如何工作,因为它理解整数数组的排序规则。

现在来看下面这个结构体:

swift
struct User: Identifiable {
    let id = UUID()
    var firstName: String
    var lastName: String
}

我们可以创建一个包含这些用户的数组,并在List中使用它们,如下所示:

swift
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协议。

但如果我们想让这些用户按排序后的顺序显示呢?如果我们将代码修改成下面这样,它就无法正常工作了:

swift
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,代码如下:

swift
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()方法。这需要两个步骤:

  1. User的定义中添加Comparable协议遵循声明。
  2. 添加一个名为<的方法,接收两个用户对象,如果第一个用户应该排在第二个用户之前,就返回true

代码实现如下:

swift
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),它既可能带来便利,也可能带来麻烦。

其次,lhsrhs是编码中的常用缩写,分别代表“左手边”(left-hand side)和“右手边”(right-hand side)。使用这两个缩写是因为<运算符的左右两侧各有一个操作数。

第三,这个方法必须返回一个布尔值(Boolean),也就是说,我们必须明确判断一个对象是否应该排在另一个对象之前。这里不允许出现“两个对象相同”的情况——这种情况由另一个名为Equatable的协议处理。

第四,这个方法必须标记为static,这意味着它是直接通过User结构体调用的,而不是通过User结构体的某个实例调用的。

最后,我们这里的逻辑非常简单:将比较操作委托给结构体的某个属性,让Swift对两个用户的姓氏字符串执行<比较。你可以根据需要添加任意多的逻辑,比较任意多个属性,但最终都必须返回truefalse

提示: 这段代码中有一个隐藏的特性——遵循Comparable协议后,我们还能使用>运算符(大于运算符)。>运算符是<运算符的相反操作,所以Swift会通过<运算符的结果反转布尔值,自动为我们实现>运算符的功能。

现在,我们的User结构体已经遵循了Comparable协议,因此自动获得了无参数的sorted()方法,这意味着下面这样的代码现在可以正常工作了:

swift
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指向应用的文档目录:

swift
print(URL.documentsDirectory)

这个文档目录完全由我们支配,而且因为它属于当前应用,所以当应用被删除时,这个目录也会被自动删除。除了设备的物理存储限制外,我们在这个目录中存储的数据量没有限制,但要记住,用户可以通过“设置”应用查看你的应用占用了多少存储空间——所以请尊重用户的存储资源!

有了可用的目录后,我们就可以自由地在其中读取和写入文件了。你已经学过使用String(contentsOf:)Data(contentsOf:)来“读取”数据,而要写入数据,我们需要使用write(to:)方法。这个方法接收两个参数:

  1. 要写入的目标URL。
  2. 保存数据时使用的额外选项。

第一个参数可以通过将文档目录的URL与文件名(例如myfile.txt)组合来创建。

至于第二个参数,我建议传入一个包含两个值的数组:.atomic.completeFileProtection。它们的作用完全不同,但都很重要:

  1. 请求“原子性保存”(atomic saving)意味着整个文件应该一次性写入。如果不设置这个选项,当我们尝试写入一个较大的文件时,应用的其他部分可能会在文件尚未完全写入时尝试读取它。这虽然不会导致崩溃,但确实会导致只能读取到部分数据(因为另一部分数据还没写完)。“原子性”写入会让系统先将完整的文件写入一个临时文件名(不是我们指定的目标文件名),当写入完成后,再将临时文件重命名为我们指定的目标文件名。这样一来,要么能看到完整的文件,要么什么都看不到(不会出现部分文件的情况)。
  2. 请求“完全文件保护”(complete file protection)意味着iOS会自动(且透明地)对文件进行加密,并且只允许我们的应用在设备解锁时读取该文件。iOS在保护用户数据安全方面做得很好,但多一层安全保障总是好的!

为了将这些代码付诸实践,我们来修改模板中的默认代码:向文档目录中的一个文件写入一个测试字符串,然后将其读回并存储到一个新字符串中,最后打印出来——完成一次完整的数据读写循环。

ContentViewbody属性修改为以下内容:

swift
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条件语句来展示不同类型的视图,例如:

swift
if Bool.random() {
    Rectangle()
} else {
    Circle()
}

提示: 当需要返回不同类型的视图时,请确保你要么在body属性内部编写代码,要么使用@ViewBuilderGroup这样的工具。

条件视图尤其适用于需要展示多种不同状态的场景。如果规划得当,我们可以让视图代码保持简洁,并且易于维护——这是训练自己从SwiftUI架构角度思考问题的好方法。

实现这个方案需要两个步骤。第一步是定义一个枚举,用来表示你想要展示的各种视图状态。例如,你可以定义一个嵌套枚举:

swift
enum LoadingState {
    case loading, success, failed
}

第二步是为这些状态分别创建对应的视图。这里我只用简单的文本视图(Text)来演示,但实际上这些视图可以包含任何内容:

swift
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值:

swift
@State private var loadingState = LoadingState.loading

然后在它的body属性中编写代码,根据枚举值展示正确的视图,如下所示:

swift
if loadingState == .loading {
    LoadingView()
} else if loadingState == .success {
    SuccessView()
} else if loadingState == .failed {
    FailedView()
}

你也可以使用switch语句来实现,代码如下:

swift
switch loadingState {
case .loading:
    LoadingView()
case .success:
    SuccessView()
case .failed:
    FailedView()
}

提示: 对枚举使用switch语句有一个优势——Swift会检查我们是否覆盖了所有的枚举情况。这意味着如果将来你添加了新的枚举情况,Swift会提示你进行相应的处理。

使用这种方式,即使视图中添加了越来越多的代码,ContentView也不会变得臃肿失控。事实上,ContentView甚至不需要知道“加载中”“成功”和“失败”这些状态具体长什么样。

本站使用 VitePress 制作