Skip to content

第53天 项目 11 第一部分

今天,我们要启动另一个新项目,从这里开始,内容会变得更加深入,因为你将学习一项重要的新Swift技能、一项重要的新SwiftUI技能,以及一项重要的应用开发技能——在我们开发这个项目的过程中,这些这些技能都会派上用场。

你将学习的应用开发技能涉及Apple的一个框架:SwiftData。它负责管理数据库中的对象,包括读取、写入、筛选、排序等操作,在iOS、macOS等平台的应用开发中至关重要。之前我们直接将数据写入UserDefaults,但那只是帮助你学习的临时方案,而SwiftData才是真正的实用工具,无数应用都在使用它。

加拿大软件开发者罗布·派克(Go编程语言的创建者、Unix开发团队成员、UTF-8的联合创建者,同时也是出版作家)曾这样评价数据:

“数据至上。如果你选择了合适的数据结构并合理组织,算法往往会不言自明。在编程中,核心是数据结构,而非算法。”

这句话通常被简化为“用智能对象编写简单代码”,而你会发现,当对象由SwiftData支持时,其“智能程度”几乎达到了顶峰!

今天你需要学习四个主题,从中你将了解@Binding、TextEditor、SwiftData等内容。

  • Bookworm:项目介绍
  • 用@Binding创建自定义组件
  • 用TextEditor接收多行文本输入
  • SwiftData与SwiftUI入门

Bookworm:项目介绍

作者:Paul Hudson 2023年11月17日

在这个项目中,我们将开发一个应用,用于记录你读过的书籍以及你对这些书的评价。该项目的主题与第10个项目类似:先运用你已掌握的所有技能,再添加一些额外的新技能,让你的能力更上一层楼。

这次你将接触到SwiftData,它是Apple用于操作数据库的框架。本项目将作为SwiftData的入门介绍,后续我们会深入探讨更多细节。

同时,我们还将构建第一个自定义用户界面组件——一个星级评分组件,用户可以通过点击为每本书打分。这意味着我们要向你介绍另一个属性包装器@Binding——相信我,你最终会完全理解它的作用。

和往常一样,我们首先会梳理完成这个项目所需的所有新技术,所以请创建一个新的iOS应用,命名为Bookworm,使用App模板。

重要提示:我知道你可能会忍不住,但请务必不要触碰存储选项,即使你看到其中有SwiftData选项也不要选择。 因为它会在项目中添加大量无用的代码,为了跟上后续的学习,你还得删除这些代码。

用@Binding创建自定义组件

作者:Paul Hudson 2024年4月11日

你已经了解到,SwiftUI的@State属性包装器可以让我们处理本地值类型,而@Bindable则能让我们与可观察类内部的属性创建绑定。不过,还有第三种选择,它的名称可能会让人有些困惑——@Binding。它可以让一个视图的@State属性与另一个视图共享,这样两个视图就指向同一个整数、字符串、布尔值等数据。

想一想:当我们创建切换开关(Toggle)时,会传入一个可以修改的布尔属性,就像这样:

swift
@State private var rememberMe = false

var body: some View {
    Toggle("记住我", isOn: $rememberMe)
}

那么,切换开关需要在用户交互时修改我们的布尔值,它是如何记住自己应该修改哪个值的呢?

这就需要@Binding发挥作用了:它允许我们在一个视图中存储一个可变值,而这个值实际上指向其他地方的某个值。以Toggle为例,开关会修改自身对布尔值的本地绑定,但在幕后,这实际上是在操作我们视图中的@State属性——它们读取和写入的是同一个布尔值。

起初,你可能会对@Bindable和@Binding的区别感到非常困惑,但慢慢你就会理解。

需要明确的是,@Bindable用于访问使用@Observable宏的共享类:我们在一个视图中用@State创建该类的实例,这样在该视图中就有了可用的绑定;而当我们要与其他视图共享这个类的实例时,就会使用@Bindable,这样SwiftUI也能在其他视图中创建绑定。

另一方面,@Binding则用于处理简单的值类型数据,而非独立的类。例如,你有一个存储布尔值、双精度浮点数、字符串数组等数据的@State属性,并且想要传递这个属性。这类数据不使用@Observable宏,所以我们不能用@Bindable。这时,我们就会使用@Binding,这样就能在多个地方共享这个布尔值或整数了。

这种特性使得@Bindable在创建自定义用户界面组件时极为重要。从本质上来说,用户界面组件和其他所有SwiftUI视图一样,但@Binding是它们的独特之处:虽然这些组件可能有自己的本地@State属性,但它们还会暴露@Binding属性,以便直接与其他视图交互。

为了演示这一点,我们来看一段代码,用它创建一个按下后能保持按下状态的自定义按钮。这个基础实现所用的知识都是你已经学过的:一个带内边距的按钮、用于背景的线性渐变、胶囊形的裁剪形状等——现在就把这段代码添加到ContentView.swift中:

swift
struct PushButton: View {
    let title: String
    @State var isOn: Bool

    var onColors = [Color.red, Color.yellow]
    var offColors = [Color(white: 0.6), Color(white: 0.4)]

    var body: some View {
        Button(title) {
            isOn.toggle()
        }
        .padding()
        .background(LinearGradient(colors: isOn ? onColors : offColors, startPoint: .top, endPoint: .bottom))
        .foregroundStyle(.white)
        .clipShape(.capsule)
        .shadow(radius: isOn ? 0 : 5)
    }
}

这段代码中唯一稍显特别的地方是,我用属性来存储两种渐变颜色,这样创建按钮的代码就可以自定义这些颜色。

现在,我们可以在主用户界面中创建这样一个按钮,代码如下:

swift
struct ContentView: View {
    @State private var rememberMe = false

    var body: some View {
        VStack {
            PushButton(title: "记住我", isOn: rememberMe)
            Text(rememberMe ? "开启" : "关闭")
        }
    }
}

按钮下方有一个文本视图,用于跟踪按钮的状态——运行代码,看看效果如何。

你会发现,点击按钮确实会改变按钮的外观,但文本视图并没有反映出这种变化,它始终显示“关闭”。显然,有什么东西发生了变化(因为按钮按下时外观会改变),但这种变化并没有传递到ContentView中。

问题在于,我们定义的是单向数据流:ContentView有一个rememberMe布尔值,用它来创建PushButton——按钮的初始值由ContentView提供。但是,一旦按钮创建完成,它就会接管这个值的控制权:按钮内部会将isOn属性在true和false之间切换,但不会把这种变化传递回ContentView。

这就出现了问题,因为现在有两个数据来源:ContentView存储一个值,PushButton存储另一个值。幸运的是,@Binding可以解决这个问题:它能在PushButton和使用它的视图之间建立双向连接,这样当一个值发生变化时,另一个值也会随之变化。

要切换到@Binding,我们只需做两处修改。首先,在PushButton中,将isOn属性修改为:

swift
@Binding var isOn: Bool

其次,在ContentView中,修改创建按钮的代码:

swift
PushButton(title: "记住我", isOn: $rememberMe)

这里在rememberMe前面加了一个美元符号($)——我们传递的是绑定本身,而不是其中的布尔值。

现在再次运行代码,你会发现一切都能正常工作了:切换按钮时,文本视图也会正确更新。

这就是@Binding的强大之处:在按钮看来,它只是在切换一个布尔值——它完全不知道还有其他视图在监听这个布尔值并根据变化做出反应。

用TextEditor接收多行文本输入

作者:Paul Hudson 2023年11月17日

我们已经多次使用过SwiftUI的TextField视图,当用户需要输入短文本时,它非常好用。但是,对于较长的文本,你可能会想要改用TextEditor视图:它同样需要与一个文本字符串建立双向绑定,但它有一个额外的优势——支持多行文本输入,更适合为用户提供较大的输入空间。

由于TextEditor几乎没有特殊的配置选项,所以使用它实际上比使用TextField更简单:你无法调整它的样式或添加占位文本,只需将它与一个字符串绑定即可。不过,你需要注意确保它不会超出安全区域,否则输入会很不方便;可以将它嵌入到NavigationStack、Form等容器中。

例如,我们可以结合TextEditor和@AppStorage创建一个最简单的笔记应用,代码如下:

swift
struct ContentView: View {
    @AppStorage("notes") private var notes = ""

    var body: some View {
        NavigationStack {
            TextEditor(text: $notes)
                .navigationTitle("笔记")
                .padding()
        }
    }
}

提示:@AppStorage并非为存储敏感信息设计,所以切勿用它存储任何私密内容。

不过,我之前说“可能会想要改用TextEditor”而非“应该改用TextEditor”,是有原因的:SwiftUI还提供了第三种选择,在某些情况下效果更好。

创建TextField时,我们可以选择性地指定它可以沿某个轴扩展。这意味着文本框最初是一个普通的单行文本框,但当用户输入时,它可以像iMessage的文本框一样自动扩展。

代码如下:

swift
struct ContentView: View {
    @AppStorage("notes") private var notes = ""

    var body: some View {
        NavigationStack {
            TextField("输入文本", text: $notes, axis: .vertical)
                .textFieldStyle(.roundedBorder)
                .navigationTitle("笔记")
                .padding()
        }
    }
}

你可以尝试一下,看看效果如何。

这两种方法你在不同场景下都会用到。虽然我喜欢TextField自动扩展的特性,但有时候,向用户展示一个较大的文本输入区域,让他们一开始就知道可以在这里输入大量内容,也是很有帮助的。

提示:当视图处于Form内部时,SwiftUI通常会改变其外观,所以一定要分别在Form内部和外部尝试这两种视图,看看它们的差异。

SwiftData与SwiftUI入门

作者:Paul Hudson 2024年5月29日

SwiftUI是一个强大的现代框架,可用于在Apple的所有平台上构建出色的应用;而SwiftData也是一个强大的现代框架,用于存储、查询和筛选数据。要是它们能完美配合,岂不是很棒?

事实上,它们不仅能出色地协同工作,而且所需的代码量少到让你难以置信——只需几分钟,你就能创建出很棒的功能。

首先,基础知识:SwiftData是一个对象图和持久化框架,简单来说,它允许我们定义对象及其属性,然后从持久化存储中读取和写入这些对象。

表面上看,这似乎和使用Codable与UserDefaults类似,但SwiftData要先进得多:它能够对数据进行排序和筛选,并且可以处理更大量的数据——实际上,它能存储的数据量没有限制。更棒的是,当你确实需要依赖它的高级功能时,SwiftData还提供了各种强大功能:iCloud同步、数据懒加载、撤销与重做等等。

在本项目中,我们只会用到SwiftData的一小部分功能,但后续会逐步扩展——目前我只想让你先对它有个初步了解。

之前创建Xcode项目时,我让你不要启用SwiftData支持,因为虽然它能帮你省去一些繁琐的设置代码,但同时也会添加大量无用的示例代码,最后你还得删除这些代码。

所以,接下来你要学习如何手动设置SwiftData。这个过程分为三步,首先我们要定义应用中需要使用的数据。

之前,我们定义数据的方式是创建一个名为Student.swift(或其他类似名称)的Swift文件,然后在文件中编写如下代码:

swift
@Observable
class Student {
    var id: UUID
    var name: String

    init(id: UUID, name: String) {
        self.id = id
        self.name = name
    }
}

我们只需做两处微小的修改,就能将这个类转换为SwiftData对象——即可以存储在数据库中、与iCloud同步、可搜索、可排序等的对象。

首先,需要在文件顶部添加另一个导入语句:

swift
import SwiftData

这行代码告诉Swift,我们要引入SwiftData的所有功能。

然后,将原来的代码:

swift
@Observable
class Student {

修改为:

swift
@Model
class Student {

……就这样。只需这样修改,就能为SwiftData提供加载和保存Student对象所需的全部信息。现在,SwiftData还可以查询、删除这些对象,以及将它们与其他对象关联等。

这个类被称为SwiftData模型(model):它定义了我们想要在应用中使用的某种数据。在幕后,@Model基于与@Observable相同的观察系统构建,这意味着它能与SwiftUI很好地配合使用。

定义好要使用的数据后,就可以进行SwiftData设置的第二步:编写少量Swift代码来加载这个模型。这段代码会告诉SwiftData在iPhone上准备好存储区域,用于读取和写入Student对象。

这项工作最好在App结构体中完成。每个项目都有一个这样的结构体,包括我们之前制作的所有项目,它相当于整个应用的启动入口。

由于这个项目名为Bookworm,所以我们的App结构体位于BookwormApp.swift文件中。该文件的代码应该如下所示:

swift
import SwiftUI

@main
struct BookwormApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

可以看到,这段代码与我们常见的视图代码有些相似:仍然需要导入SwiftUI(import SwiftUI),仍然使用结构体创建自定义类型,而且ContentView也在其中。其余部分是新内容,我们主要关注两点:

  1. @main行告诉Swift,这是应用的启动入口。从内部机制来说,当用户从iOS主屏幕启动应用时,正是这段代码启动了整个程序。
  2. WindowGroup部分告诉SwiftUI,我们的应用可以在多个窗口中显示。这在iPhone上作用不大,但在iPad和macOS上就非常重要了。

我们需要在这里告诉SwiftData为我们设置好所有存储,同样只需做两处微小的修改。

首先,在import SwiftUI旁边添加import SwiftData。我个人喜欢按字母顺序排列导入语句,但这并不是必须的。

其次,为WindowGroup添加一个修饰符,让SwiftData在应用的所有地方都可用:

swift
.modelContainer(for: Student.self)

模型容器(model container)是SwiftData对其数据存储区域的称呼。应用第一次运行时,SwiftData会创建底层的数据库文件;后续运行时,它会加载之前创建的数据库。

到目前为止,你已经了解了如何使用@Model创建数据模型,以及如何使用modelContainer()修饰符创建模型容器。第三步是模型上下文(model context),它实际上是数据的“实时”版本——当你加载对象并修改它们时,这些修改只存在于内存中,直到被保存。因此,模型上下文的作用是让我们在内存中操作所有数据,这比频繁地从磁盘读写数据要快得多。

每个SwiftData应用都需要一个模型上下文来工作,而我们其实已经创建了一个——使用modelContainer()修饰符时,它会自动创建。SwiftData会自动为我们创建一个模型上下文,称为主上下文(main context),并将其存储在SwiftUI的环境中。

至此,SwiftData的所有配置都已完成,接下来就到了有趣的部分:读取数据和写入数据。

从SwiftData中检索信息需要使用查询(query)——我们描述想要获取的数据、排序方式以及是否需要筛选,SwiftData就会返回所有匹配的数据。我们需要确保这个查询能实时更新,这样当学生对象被创建或删除时,用户界面也能保持同步。

SwiftUI为此提供了解决方案——没错,又是一个属性包装器。这次是@Query,只需在文件中添加import SwiftData,就可以使用它。

所以,在ContentView.swift文件的顶部添加SwiftData的导入语句,然后在ContentView结构体中添加以下属性:

swift
@Query var students: [Student]

这看起来就像一个普通的Student数组,但只需在前面加上@Query,就能让SwiftData从模型容器中加载学生对象——它会自动找到存储在环境中的主上下文,并通过该上下文查询容器。我们没有指定要加载哪些学生对象,也没有指定排序方式,所以会加载所有学生对象。

之后,我们就可以像使用普通Swift数组一样使用students了——将以下代码添加到视图的body中:

swift
NavigationStack {
    List(students) { student in
        Text(student.name)
    }
    .navigationTitle("教室")
}

你可以运行代码看看效果,但实际上意义不大——列表会是空的,因为我们还没有添加任何数据,数据库中没有内容。要解决这个问题,我们可以在列表下方添加一个按钮,每次点击就添加一个随机的学生对象,但首先需要添加一个新属性来访问之前创建的模型上下文。

现在在ContentView中添加以下属性:

swift
@Environment(\.modelContext) var modelContext

添加好这个属性后,下一步就是创建一个按钮,生成随机的学生对象并将其保存到模型上下文中。为了让学生对象的名称更容易区分,我们可以创建firstNames和lastNames两个数组,然后使用randomElement()从每个数组中随机选择一个元素,组合成学生的姓名。

首先,为List添加以下工具栏:

swift
.toolbar {
    Button("添加") {
        let firstNames = ["金妮", "哈利", "赫敏", "卢娜", "罗恩"]
        let lastNames = ["格兰杰", "洛夫古德", "波特", "韦斯莱"]

        let chosenFirstName = firstNames.randomElement()!
        let chosenLastName = lastNames.randomElement()!

        // 后续代码将在此处添加
    }
}

注意:肯定会有人抱怨我在这里对randomElement()的调用使用了强制解包,但这些数组是我们手动创建的,里面肯定有元素——强制解包一定会成功。如果你实在不喜欢强制解包,可以用空合运算符设置一个默认值。

接下来是关键部分:创建一个Student对象。将“// 后续代码将在此处添加”注释替换为以下代码:

swift
let student = Student(id: UUID(), name: "\(chosenFirstName) \(chosenLastName)")

最后,我们需要让模型上下文添加这个学生对象,这样它才会被保存。在按钮的操作闭包中添加最后一行代码:

swift
modelContext.insert(student)

现在,你终于可以运行应用并尝试使用了——点击“添加”按钮几次,生成一些随机的学生对象,你应该会看到它们出现在列表中。更棒的是,重新启动应用后,这些学生对象仍然会存在,因为SwiftData已经自动保存了它们。

你可能会觉得,学了这么多内容,得到的结果却很简单,但实际上你已经了解了模型、模型容器和模型上下文是什么,并且掌握了如何插入和查询数据。在本项目的后续部分以及未来的学习中,我们会进一步探讨SwiftData,但目前你已经取得了不小的进步。

以上就是本项目概述的最后一部分,请准备好重置项目,开始真正的开发工作。重置操作包括重置ContentView.swift、BookwormApp.swift文件,并删除Student.swift文件。

本站使用 VitePress 制作