第27天 项目 4 第二部分
今天,我们将结合 SwiftUI 和 Core ML 来构建项目,代码量少得惊人 —— 我相信你会印象深刻。
除了所有的 SwiftUI 实用功能之外,我希望你能从这个项目中获得的,是对更广阔的应用开发世界的一点初步了解。Core ML 只是苹果强大框架中的一个,还有十几个其他框架等着你准备好去探索:ARKit、Core Graphics、Core Image、MapKit、WebKit 等等。
我知道你可能会想 “哇,我们已经开始接触机器学习了吗?” 毕竟,这只是 100 天课程中的第 27 天。但是,正如安德烈・纪德所说:“除非你有勇气远离海岸,否则你无法发现新的海洋。”
今天你需要学习三个主题,并且要动手在实际应用中实现 Stepper、DatePicker、DateFormatter 等组件。
- 构建基本布局
- 将 SwiftUI 与 Core ML 连接
- 优化用户界面
构建基本布局
作者:Paul Hudson 2023 年 10 月 18 日
这个应用将允许用户通过日期选择器和两个步进器输入信息,这些信息结合起来可以告诉我们用户想什么时候醒来、通常需要多少睡眠以及喝多少咖啡。
首先,请添加三个属性来存储这些控件所需的信息:
@State private var wakeUp = Date.now
@State private var sleepAmount = 8.0
@State private var coffeeAmount = 1在我们的 body 中,我们将把三组组件包装在 VStack 和 NavigationStack 中,先从起床时间开始。将默认的 “Hello World” 文本视图替换为以下内容:
NavigationStack {
VStack {
Text("你想什么时候醒来?")
.font(.headline)
DatePicker("请输入时间", selection: $wakeUp, displayedComponents: .hourAndMinute)
.labelsHidden()
// 后续内容待添加
}
}我们选择.hourAndMinute 配置是因为我们只关心用户想醒来的时间,而不关心日期。通过 labelsHidden () 修饰符,我们不会为选择器显示第二个标签 —— 上面的文本已经足够了。
接下来,我们将添加一个步进器,让用户选择大致需要的睡眠时间。通过设置 in 范围为 4...12,步长为 0.25,我们可以确保用户输入合理的值,同时结合 formatted () 方法,让显示的数字是 “8” 而不是 “8.000000”。
在 “// 后续内容待添加” 注释的位置添加以下代码:
Text("期望的睡眠时间")
.font(.headline)
Stepper("\(sleepAmount.formatted()) 小时", value: $sleepAmount, in: 4...12, step: 0.25)最后,我们将添加最后一个步进器和标签,用于处理用户的咖啡饮用量。这次我们使用 1 到 20 的范围 —— 因为每天 20 杯咖啡对任何人来说应该都够了吧?
在 VStack 中,将以下内容添加到之前的视图下方:
Text("每日咖啡摄入量")
.font(.headline)
Stepper("\(coffeeAmount) 杯", value: $coffeeAmount, in: 1...20)我们需要的最后一个元素是一个按钮,让用户计算应该睡觉的最佳时间。我们可以在 VStack 的末尾添加一个简单的按钮,但我认为直接在导航栏中添加按钮效果更好。
首先,我们需要一个按钮调用的方法,添加一个空的 calculateBedtime () 方法,如下所示:
func calculateBedtime() {
}现在,我们需要使用 toolbar () 修饰符在导航栏中添加按钮。同时,我们也可以使用 navigationTitle () 在顶部添加一些文本。
因此,将这些修饰符添加到 VStack 中:
.navigationTitle("优质睡眠")
.toolbar {
Button("计算", action: calculateBedtime)
}提示: 在英语等从左到右书写的语言中,我们的按钮会自动放在右上角;而在从右到左书写的语言中,会自动移到另一侧。
目前 calculateBedtime () 是空的,所以这个按钮还不会有任何作用,但至少我们的用户界面暂时足够用了。
将 SwiftUI 与 Core ML 连接
作者:Paul Hudson 2023 年 10 月 18 日
就像 SwiftUI 让用户界面开发变得简单一样,Core ML 也让机器学习变得简单。有多简单呢?一旦你有了一个训练好的模型,只需两行代码就能得到预测结果 —— 你只需要传入作为输入的数值,然后读取返回的结果即可。
在我们的案例中,我们已经使用 Xcode 的 Create ML 应用制作了一个 Core ML 模型,现在我们就要使用它。你应该已经将它保存在桌面上了,请现在将它拖到 Xcode 的项目导航器中。当 Xcode 提示 “是否需要复制项目” 时,请确保勾选该复选框。
当你将.mlmodel 文件添加到 Xcode 中时,它会自动创建一个同名的 Swift 类。你看不到这个类,也不需要看到 —— 它是在构建过程中自动生成的。但是,这意味着如果你的模型文件命名奇怪,自动生成的类名也会很奇怪。
无论你的模型文件原来叫什么名字,请将它重命名为 “SleepCalculator.mlmodel”,这样自动生成的类就会叫做 SleepCalculator。
我们怎么确定类名就是这个呢?只需选中模型文件,Xcode 就会显示更多信息。你会看到它知道模型的作者、生成的 Swift 类的名称,以及输入(及其类型)和输出(及其类型)的列表 —— 这些都编码在模型文件中,这也是为什么模型文件(相对来说)会比较大。
很快我们就要开始填充 calculateBedtime () 方法的内容了,但在此之前,我们需要导入 CoreML,因为我们要使用 SwiftUI 之外的功能。
因此,滚动到 ContentView.swift 的顶部,在导入 SwiftUI 的代码行之前添加以下内容:
import CoreML提示: 严格来说,不一定需要将 CoreML 放在 SwiftUI 前面,但将导入语句按字母顺序排列,以后检查起来会更方便。
好了,现在我们可以着手处理 calculateBedtime () 方法了。首先,我们需要创建一个 SleepCalculator 类的实例,如下所示:
do {
let config = MLModelConfiguration()
let model = try SleepCalculator(configuration: config)
// 后续代码待添加
} catch {
// 出现错误!
}这个模型实例负责读取我们所有的数据,并输出预测结果。配置(config)的存在是为了在你需要启用一些相当冷门的选项时使用 —— 也许全职从事机器学习工作的人会需要这些选项,但说实话,我猜 1000 个人中可能只有 1 个人会实际使用它们。
我希望你关注 do/catch 块,因为使用 Core ML 可能会在两个地方抛出错误:如上所示的加载模型时,以及请求预测结果时。老实说,我这辈子还从没遇到过预测失败的情况,但谨慎一点总没错!
不管怎样,我们是使用包含以下字段的 CSV 文件训练的模型:
- “wake”:用户想醒来的时间。以从午夜开始的秒数表示,例如早上 8 点就是 8 小时乘以 60 再乘以 60,得到 28800 秒。
- “estimatedSleep”:用户大致需要的睡眠时间,以 4 到 12 之间的数值存储,增量为 0.25。
- “coffee”:用户每天大致喝的咖啡杯数。
因此,要从模型中获得预测结果,我们需要填入这些数值。
我们已经有了其中两个数值,因为我们的 sleepAmount 和 coffeeAmount 属性基本符合要求 —— 我们只需要将 coffeeAmount 从整数转换为 Double 类型,这样 Swift 就能正常处理了。
但是计算起床时间需要更多思考,因为我们的 wakeUp 属性是 Date 类型,而不是表示秒数的 Double 类型。幸运的是,Swift 的 DateComponents 类型可以帮我们解决这个问题:它将表示日期所需的所有部分存储为单独的值,这意味着我们可以读取小时和分钟部分,忽略其他部分。然后,我们只需要将分钟乘以 60(转换为秒),将小时乘以 60 再乘以 60(也转换为秒)即可。
我们可以通过一个非常特定的方法调用从 Date 中获取 DateComponents 实例:Calendar.current.dateComponents ()。然后,我们可以请求小时和分钟组件,并传入我们的起床时间。返回的 DateComponents 实例包含所有组件的属性 —— 年、月、日、时区等等 —— 但大多数属性不会被设置。我们请求的小时和分钟组件会被设置,但它们是可选类型,所以我们需要小心地解包。
因此,在 calculateBedtime () 方法中 “// 后续代码待添加” 注释的位置添加以下代码:
let components = Calendar.current.dateComponents([.hour, .minute], from: wakeUp)
let hour = (components.hour ?? 0) * 60 * 60
let minute = (components.minute ?? 0) * 60这段代码在无法读取小时或分钟时使用 0 作为默认值,但实际上这种情况几乎不可能发生,所以 hour 和 minute 最终会被设置为对应的秒数。
下一步是将我们的数值输入 Core ML,然后查看输出结果。这可以通过模型的 prediction () 方法实现,该方法需要传入进行预测所需的起床时间、预计睡眠时间和咖啡饮用量数值,且这些数值都必须是 Double 类型。我们刚刚计算出的 hour 和 minute 是秒数,所以在传入之前需要将它们相加。
请在之前代码的下方添加以下内容:
let prediction = try model.prediction(wake: Double(hour + minute), estimatedSleep: sleepAmount, coffee: Double(coffeeAmount))
// 后续代码待添加完成这一步后,prediction 就包含了用户实际需要的睡眠时间。这个数值很可能不是我们训练模型时使用的数据集中的一部分,而是由 Core ML 算法动态计算得出的。
但是,这个数值对用户来说并不直观 —— 它会是某个以秒为单位的数字。我们需要将它转换为用户应该睡觉的时间,这意味着我们需要用用户需要醒来的时间减去这个秒数。
多亏了苹果强大的 API,这只需一行代码 —— 你可以直接从一个 Date 中减去以秒为单位的数值,然后得到一个新的 Date!因此,在预测结果之后添加以下代码行:
let sleepTime = wakeUp - prediction.actualSleep现在我们确切知道用户应该什么时候睡觉了。目前,我们最后的任务是将这个时间展示给用户。我们将使用警报(alert)来展示,因为你已经学过如何使用警报,正好可以练习一下。
首先,添加三个属性,分别用于确定警报的标题、消息以及是否显示警报:
@State private var alertTitle = ""
@State private var alertMessage = ""
@State private var showingAlert = false我们可以立即在 calculateBedtime () 方法中使用这些数值。如果计算出现错误 —— 比如读取预测结果时抛出错误 —— 我们可以将 “// 出现错误” 注释替换为以下代码,设置一个有用的错误消息:
alertTitle = "错误"
alertMessage = "抱歉,计算你的睡觉时间时出现了问题。"无论预测是否成功,我们都应该显示警报。警报中可能包含预测结果,也可能包含错误消息,但无论哪种情况,显示警报都是有帮助的。因此,在 calculateBedtime () 方法的末尾,也就是 catch 块之后,添加以下代码:
showingAlert = true如果预测成功,我们会创建一个名为 sleepTime 的常量,其中包含用户需要睡觉的时间。但这是一个 Date 类型,而不是格式整洁的字符串,所以我们需要通过 formatted () 方法处理它,确保它易于阅读,然后将其赋值给 alertMessage。
因此,在 calculateBedtime () 方法中,在设置 sleepTime 常量的代码之后,添加以下最后几行代码:
alertTitle = "你理想的睡觉时间是……"
alertMessage = sleepTime.formatted(date: .omitted, time: .shortened)为了完成应用的这个阶段,我们只需要添加一个 alert () 修饰符,当 showingAlert 变为 true 时,显示 alertTitle 和 alertMessage。
请将这个修饰符添加到我们的 VStack 中:
.alert(alertTitle, isPresented: $showingAlert) {
Button("确定") { }
} message: {
Text(alertMessage)
}现在运行应用 —— 它能正常工作了!虽然看起来还不够 “精美”,但功能是完整的。
优化用户界面
作者:Paul Hudson 2023 年 10 月 18 日
虽然我们的应用现在可以正常工作,但它还不是一个适合在 App Store 上发布的产品 —— 它至少存在一个重大的可用性问题,而且设计…… 嗯,我们只能说 “不够标准”。
先来看可用性问题,因为你可能还没意识到这个问题。当你使用 Date.now 时,它会自动设置为当前的日期和时间。因此,当我们用一个新的日期创建 wakeUp 属性时,默认的起床时间就是当前的时间。
虽然这个应用需要能够处理各种时间 —— 比如我们不想把夜班工作者排除在外 —— 但我认为,将默认起床时间设置在早上 6 点到 8 点之间,对大多数用户来说会更实用。
为了解决这个问题,我们将在 ContentView 结构体中添加一个计算属性,该属性包含一个表示当天早上 7 点的 Date 值。实现这一点非常简单:我们只需创建自己的 DateComponents,然后使用 Calendar.current.date (from:) 将这些组件转换为完整的日期。
因此,现在向 ContentView 添加以下属性:
var defaultWakeTime: Date {
var components = DateComponents()
components.hour = 7
components.minute = 0
return Calendar.current.date(from: components) ?? .now
}现在,我们可以使用这个属性作为 wakeUp 的默认值,替代原来的 Date.now:
@State private var wakeUp = defaultWakeTime如果你尝试编译这段代码,会发现编译失败,原因是我们在一个属性内部访问了另一个属性 ——Swift 不知道属性的创建顺序,所以不允许这样做。
解决这个问题很简单:我们可以将 defaultWakeTime 设为静态变量(static variable),这意味着它属于 ContentView 结构体本身,而不是该结构体的某个实例。这反过来意味着我们可以在任何需要的时候读取 defaultWakeTime,因为它不依赖于其他任何属性的存在。
因此,将属性定义修改为以下内容:
static var defaultWakeTime: Date {这样就解决了可用性问题,因为大多数用户会发现默认的起床时间与他们想要选择的时间比较接近。
至于样式优化,这需要更多工作。一个简单的改进是将 VStack 换成 Form。找到以下代码:
NavigationStack {
VStack {并将其替换为:
NavigationStack {
Form {这会立即让用户界面看起来更好 —— 我们得到了一个分区清晰的输入表格,而不是一些控件在空白区域居中显示。
表单中还有一个令人困扰的问题:表单中的每个视图都被视为列表中的一行,但实际上所有文本视图都属于同一个逻辑表单部分。
我们可以使用 Section 视图,将文本视图作为标题 —— 你将在挑战环节中尝试这种方式。不过现在,我们将每对文本视图和控件用 VStack 包裹起来,这样它们就会被视为单独的一行。
现在,用 VStack 包裹每一对视图,对齐方式设置为.leading,间距设置为 0。例如,对于以下两个视图:
Text("期望的睡眠时间")
.font(.headline)
Stepper("\(sleepAmount.formatted()) 小时", value: $sleepAmount, in: 4...12, step: 0.25)用 VStack 包裹后如下所示:
VStack(alignment: .leading, spacing: 0) {
Text("期望的睡眠时间")
.font(.headline)
Stepper("\(sleepAmount.formatted()) 小时", value: $sleepAmount, in: 4...12, step: 0.25)
}这样好多了!
我们要做的最后一个修改虽然很小,但效果却很棒。再看一下这段代码,它显示用户喝了多少杯咖啡:
Stepper("\(coffeeAmount) 杯(s)", value: $coffeeAmount, in: 1...20)写 “杯 (s)” 虽然可以用,但有点敷衍。理想情况下,我们应该显示 “1 杯”,而 “2 杯”、“3 杯” 等等 —— 也就是正确的复数形式。
我们可以用三元运算符来修正这个问题,如下所示:
Stepper(coffeeAmount == 1 ? "1杯" : "\(coffeeAmount)杯(s)", value: $coffeeAmount, in: 1...20)但 SwiftUI 有一个更好的解决方案:它可以帮我们处理复数形式!将代码修改为以下内容:
Stepper("^[\(coffeeAmount) cup](inflect: true)", value: $coffeeAmount, in: 1...20)我知道这个语法看起来有点奇怪,但它实际上是一种特殊形式的 Markdown(一种常见的文本格式)。这个语法告诉 SwiftUI,“cup” 这个词需要根据 coffeeAmount 变量的值进行屈折变化(inflect),在这个案例中,这意味着它会自动根据数量从 “cup” 变为 “cups”。
现在最后一次运行应用吧,它已经完成了 —— 做得好!