[SwiftUI 100天] Bucket List - part1

1,276 阅读8分钟
译自 Bucket List: Introduction
更多内容欢迎关注公众号 「Swift花园」

在这个工程中我们将构建一个可以让用户基于地图建立他们想去的地方的愿望清单的 app 。想去的地方包含地点描述,附近有趣的地方,还可以存储起来之后访问。

为了搞定这个 app ,你需要用到之前学到的技能,包括 form ,sheets ,CodableURLSession,并且我还将教给你新的技能:如何在 SwiftUI app 里嵌入地图,如何安全地存储数据 (只有鉴定的用户才能访问),如何在UserDefaults之外读写数据,等等。

新建工程,用 Single View App 模板,起名 BucketList 。接下来,先介绍我们用到各种技术

译自 Adding conformance to Comparable for custom types

为自定义类型适配 Comparable 协议

如果你曾思考过,你一定知道在我们写 Swift 代码时,编译器为我们做了许多事。举个例子,我们写 4 < 5,期望返回 true —— Swift 语言的开发者 (包括 LLVM,Swift 背后整个编译器的项目组) 完成了大量艰苦的工作,以确定这个计算的结果。

然而,Swift 更棒的地方在于它通过协议和协议扩展把功能扩展方方面面。举个例子,我们知道 4 < 5 是 true ,因为我们懂的怎么比较两个整数并且确定哪个在前,哪个在后。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()
    let firstName: String
    let 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 协议。

但如果我们想要这些 users 按照顺序来显示呢? 我们可能会把代码改成下面这样:

let users = [
    User(firstName: "Arnold", lastName: "Rimmer"),
    User(firstName: "Kristine", lastName: "Kochanski"),
    User(firstName: "David", lastName: "Lister"),
].sorted()

但 Swift 不理解这里的 sorted() 是什么意思,因为它不知道怎么通过 first name ,last name,或者是两者一起,又或者其他东西来给 users 排序。

之前我向你演示过给 sorted() 配置一个闭包,用来排序,我们可以把它用在这里:

let users = [
    User(firstName: "Arnold", lastName: "Rimmer"),
    User(firstName: "Kristine", lastName: "Kochanski"),
    User(firstName: "David", lastName: "Lister"),
].sorted {
    $0.lastName < $1.lastName
}

这样代码就可以编译了,但是这个解决方案不够理想。原因有二。

首先,这是模型的数据。在一个设计良好的应用里,我们不会希望告诉模型它应该如何在 SwiftUI 里表现自己的行为。SwiftUI 呈现我们的视图,例如我们的布局,如果我们把模型相关的代码放在那里,事情会变得令人困惑。

其次,假如我们想在多个地方排序我们的 User 数组呢?你可能需要复制粘贴闭包代码一次或者两次。不过当你发现代码有问题时,你需要更新好多个地方的代码。

Swift 有更好的解决方案。整数数组有简单 sorted() 方法,不需要参数,因为 Swift 知道如何比较两个整数。以编程的术语来说,Int 遵循 Comparable 协议,表明它定义了一个函数,这个函数接收两个整数,当第一个数要排在第二个数之前时返回 true 。

我们也可以让自定义的类型遵循 Comparable,这样一来就能得到一个无参的sorted()方法。一共分两步:

  1. 添加 Comparable 协议到 User 的定义
  2. 添加一个叫 < 的方法,接收两个 user 作为参数,当第一个应当排在第二个之前时返回 true 。

代码如下:

struct User: Identifiable, Comparable {
    let id = UUID()
    let firstName: String
    let lastName: String

    static func < (lhs: User, rhs: User) -> Bool {
        lhs.lastName < rhs.lastName
    }
}

代码不多,但是有不少东西可以拆解。

首先,这个方法名就叫 <,它是 “小于” 操作符。这个方法的工作是确定一个用户是否 “小于” 另一个用户 (排序意义上)。所以我们其实是在已经存在的操作符上添加功能。这个动作叫做 操作符重载 。它可能是一把双刃剑。

其次, lhsrhs 是 “左边” 和 “右边 的编码约定,之所以这么叫是因为 < 操作符左边和右边各有一个操作数。

第三,这个方法返回一个布尔型,也就是说我们需要确定哪个对象应该被排在前面。不存在 “它们是相等的” 的余地 —— 那个由另一个叫 Equatable 的协议来处理。

第四,方法需要标记为 static,也就说这方法是直接基于 User 结构体调用,而不是基于这个结构体的某个实例调用。

最后,我们这里的逻辑相当简单:我们直接其中的一个属性,让 Swift 用< 来确定两个姓的顺序。当然,你可以自己添加更多逻辑,想比较几个属性由你决定,但最终必须要返回 true 或者 false 。

提示: 遵循 Comparable 给我们带来的一个东西,你可能没有留意到,即时它也给了我们访问 > 操作符 —— 大于,它是 < 的反面。基于它可以反转 true 和 false。

一旦我们的 User 结构体遵循了Comparable 协议, 我们就自动获得了无参版本的 sorted()方法,下面的代码就可以编译通过了:

let users = [
    User(firstName: "Arnold", lastName: "Rimmer"),
    User(firstName: "Kristine", lastName: "Kochanski"),
    User(firstName: "David", lastName: "Lister"),
].sorted()

这样就解决了我们之前的问题:我们现在把模型功能独立在结构体本身,不再需要复制粘贴闭包代码。我们在任何地方用这个类的sorted() 方法,而不必担心排序算法发生变化。

译自 Writing data to the documents directory

往文档目录写入数据

前面我们已经学习过如何读写 UserDefaults,它对于用户设置项或者数量较小的 JSON 数据很方便。不过,通常我们不用它来存放数据,尤其是你认为未来很可能会存更多东西的情况下。

在这个 app 中我们将允许用户任意创建数据,也就意味着我们需要一个更好的存储解决方案。 幸运的是,iOS 使得从设备存储上读写数据这件事变得很容易。实际上,所有的 app 都有一个目录,用以存放任何我们想要的文档。这些文件会自动借助 iCloud 备份,如果用户换了新设备,我们的数据也会同其他系统数据一同被恢复 —— 我们甚至不需要考虑这个细节。

有一个事实要注意 —— 所有的 iOS 应用都处于沙盒环境中,也就是说,它们运行在自己的容器中,外部很难猜测目录的文件。由此,我们无法 —— 当然也不应该试图猜测应用安装目录,而应该信赖 Apple 的 API ,借助它们来查找我们的文档目录。

这些动作没有可以优化的空间,所以我几乎总是复制粘贴相同的辅助方法到我的各个工程里。这里我们要做的事情也一样。其中用到了一个新类叫 FileManager,它可以提供给我们当前用户的文档目录。理论上,它返回好几个 URL 路径,但这里我们只关心第一个。

ContentView 中添加下面的代码:

func getDocumentsDirectory() -> URL {
    // find all possible documents directories for this user
    let paths = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)

    // just send back the first one, which ought to be the only one
    return paths[0]
}

这个文档目录是属于 app 的,所以我们任意操作,但由于它属于 app ,所以当 app 被卸载时它也会被自动删除。除了物理设备的限制外,存放数据量并没有其他的限制。不过用户是可以通过设置 app 查看你的 app 占用了多少空间 —— 所以你需要谨慎使用空间。

得到了文档目录,我们可以自由地读写文件了。你之前应该已经见过String(contentsOf:) 方法和 Data(contentsOf:) 方法,它们用于读取数据。但对于写入数据,我们需要用到下面的方法,有三个参数:

  1. 一个要写入的 URL
  2. 是否令写入动作原子化,它指的是 “一次完成”
  3. 我们想采用的字符编码

第一个参数可以通过组合文档目录的 URL 加上一个文件名来得到,例如 myfile.txt 。

第二个参数一般总是设置为 true 。如果设置为 false 的话,当我们在写入大文件的时候,有可能 app 的其他部分正在尝试读取这个文件。虽然不会引起崩溃,但读取的那部分只能读取到部分数据,因为还没写完。原子化写入使得系统每次将文件内容放在一个临时的地方,当写入完成时才重命名为我们的目标文件。也就是说,目标文件要么不存在,要么就是完整的。

第三个参数的存在是因为我们需要在 Objective-C API 的中使用 Swift 的字符串。Objective-C 用的是 UTF-16 编码,而 Swift 原生是采用 UTF-8 编码,所以我们需要转换编码。

把上面所有的知识点翻译成代码实践 —— 我们可以在默认的文本视图模板代码里测试写入一个字符串到文档目录的一个文件中,然后再把字符串读回一个新的字符串。这样就实现了一个完整的读写循环。

把 ContentView 的 body 属性改成下面这样:

Text("Hello World")
    .onTapGesture {
        let str = "Test Message"
        let url = self.getDocumentsDirectory().appendingPathComponent("message.txt")

        do {
            try str.write(to: url, atomically: true, encoding: .utf8)
            let input = try String(contentsOf: url)
            print(input)
        } catch {
            print(error.localizedDescription)
        }
    }

运行 app ,点击文本标签,你应该会在 Xcode 的调试输出区域看到 “Test message” 的消息。

相关文章:

[SwiftUI 100 天] Bucket List - part2

[SwiftUI 100 天] Bucket List - part3

[SwiftUI 100 天] Bucket List - part4


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