第26天 项目 4 第一部分
今天我们要开启一个新项目,在学习更多 SwiftUI 技能的同时,还会涉足编程领域中一个极具吸引力的方向:机器学习。
瑞典籍牛津大学教授尼克・博斯特罗姆(Nick Bostrom)曾说过:“机器智能将是人类需要完成的最后一项发明。” 这句话是否正确呢?诚然,如果我们能让计算机具备足够出色的 “思考” 能力,人类或许就无需再亲力亲为进行思考;但另一方面,也有人认为,实际上大多数人本身就没进行过太多深度思考。
不过,我相信你会惊讶地发现,入门机器学习其实非常简单,而且它与 SwiftUI 的契合度也非常高。
另外想提一点,到目前为止,你应该已经逐渐适应本课程的节奏了:学习知识点、开发应用、完成技术项目、巩固所学内容 —— 这个过程会重复多次。
但我想提醒你,无论何时,如果你感到疲惫,或者生活中出现其他干扰因素,不妨稍作休息!一两天后再回到代码旁,你会更放松,也更有精力投入学习。就像我一开始说的,这是一场马拉松,而非短跑。如果压力过大,学习效果只会大打折扣。
今天你需要学习五个主题,还会接触到 Stepper、DatePicker、DateFormatter 等更多内容。
- BetterRest:项目介绍
- 使用 Stepper 输入数字
- 使用 DatePicker 选择日期和时间
- 日期相关操作
- 使用 Create ML 训练模型
学完这些主题后,一定要在网上分享你的学习进度 —— 你已经迈出了理解机器学习的第一步!
BetterRest:项目介绍
作者:Paul Hudson 2023 年 1 月 31 日
这个 SwiftUI 项目同样是一款基于表单的应用,它会让用户输入信息,然后将这些信息转换为弹窗展示。听起来可能有些乏味 —— 毕竟你之前已经做过类似的功能了,对吧?
话虽如此,但多练习总归是好的。不过,这个项目设计得相对简单,真正的目的是为了向你介绍 iOS 开发中一项真正强大的功能:机器学习(ML)。
所有 iPhone 都内置了一项名为 Core ML 的技术,借助它,我们可以编写代码,让计算机根据之前接触过的数据,对新数据做出预测。我们会先准备一些原始数据,将其作为训练数据提供给 Mac,然后利用训练结果开发一款应用。这款应用能够基于新数据做出准确估算,而且所有操作都在设备本地完成,能充分保障用户隐私。
我们要开发的这款应用名为 BetterRest,旨在帮助咖啡爱好者获得良好的睡眠质量。它会向用户提出三个问题:
- 希望几点起床?
- 大致希望睡几个小时?
- 每天喝喝几杯咖啡?
获取这三个数值后,我们会将其输入 Core ML,得到建议的入睡时间。仔细想想就会发现,可能的答案组合多达数十亿种 —— 所有可能的起床时间、睡眠时长与咖啡饮用量相互搭配,组合数量极为庞大。
而机器学习就能解决这个问题:通过一种名为 “回归分析” 的技术,我们可以让计算机推导出一个能够表征所有数据的算法。之后,计算机就能将这个算法应用到从未接触过的新数据上,从而得出准确结果。
你需要为这个项目下载一些文件,可以从 GitHub 上获取:https://github.com/twostraws/HackingWithSwift —— 记得在文件的 SwiftUI 目录下查找。
下载完成后,打开 Xcode,创建一个新的 App 项目,命名为 BetterRest。和之前一样,我们先概述开发这款应用所需的各项技术,现在就开始吧……
使用 Stepper 输入数字
作者:Paul Hudson 2023 年 10 月 17 日
SwiftUI 提供了两种让用户输入数字的方式,本次我们要使用的是 Stepper:它由 “-” 和 “+” 两个按钮组成,用户点击按钮就能精确选择数字。另一种方式是 Slider(滑块),我们之后会用到 —— 它同样可以让用户在一定范围内选择数值,但精度相对较低。
Stepper 的灵活性很高,支持绑定各种数值类型,无论是 Int 还是 Double 都能适配,而且会自动调整。例如,我们可以先定义一个属性:
@State private var sleepAmount = 8.0然后将这个属性与 Stepper 绑定,让 Stepper 显示当前数值,代码如下:
Stepper("\(sleepAmount) 小时", value: $sleepAmount)运行这段代码后,你会看到显示内容为 “8.000000 小时”。点击 “-” 按钮,数值会向下减少到 7、6、5,甚至可以变成负数;点击 “+” 按钮,数值则会向上增加到 9、10、11 等。
默认情况下,Stepper 的数值范围仅受其绑定的变量类型限制。在这个例子中,我们使用的是 Double 类型,这意味着 Stepper 的最大值会非常大。
作为两个孩子的父亲,我非常清楚睡眠的珍贵,但即便如此,也没人能睡那么久。好在 Stepper 支持通过 in 参数限定可接受的数值范围,示例如下:
Stepper("\(sleepAmount) 小时", value: $sleepAmount, in: 4...12)添加这个参数后,Stepper 的初始值仍为 8,但用户只能在 4 到 12 之间(包含首尾值)调整数值,无法超出这个范围。这样一来,我们就能控制睡眠时长的选择范围,避免用户选择 24 小时这样不切实际的数值,同时也能排除像 -1 小时这样不可能的情况。
Stepper 还有第四个实用参数 ——step(步长),用于指定每次点击 “-” 或 “+” 按钮时数值的变化幅度。步长同样可以是任意数值类型,但必须与绑定变量的类型一致。例如,如果绑定的是整数类型,就不能将步长设为 Double 类型。
在这个场景中,我们可以设定用户的睡眠时长选择范围为 4 到 12 小时,且以 15 分钟为步长进行调整,代码如下:
Stepper("\(sleepAmount) 小时", value: $sleepAmount, in: 4...12, step: 0.25)这样的设计已经比较实用了:既有合理的数值范围,又有合适的步长,用户还能清晰地看到自己当前选择的数值。
不过,在继续往下学习之前,我们先优化一下显示的文本:目前显示的是 “8.000000”,虽然精确,但显得有些冗余。要解决这个问题,只需让 Swift 对 Double 类型数值进行格式化,使用 formatted() 方法即可:
Stepper("\(sleepAmount.formatted()) 小时", value: $sleepAmount, in: 4...12, step: 0.25)完美!
使用 DatePicker 选择日期和时间
作者:Paul Hudson 2023 年 10 月 17 日
SwiftUI 提供了一个专门用于选择日期的控件 ——DatePicker,它可以与一个日期类型的属性绑定。没错,Swift 中有专门处理日期的类型,毫不意外,它就叫 Date。
因此,要使用 DatePicker,首先需要定义一个 @State 属性,示例如下:
@State private var wakeUp = Date.now然后将这个属性与 DatePicker 绑定,代码如下:
DatePicker("请选择日期", selection: $wakeUp)在模拟器中运行这段代码,感受一下它的外观和交互。你会看到一个可点击的选项,用于调整日和时间,左侧还会显示 “请选择日期” 这个标签。
或许你会觉得这个标签不太美观,于是尝试将代码改成这样:
DatePicker("", selection: $wakeUp)但这样做会带来两个问题:一是 DatePicker 仍然会为标签预留空间,即便标签内容为空;二是对于开启了屏幕阅读器(我们更熟悉的名称是 VoiceOver)的用户来说,他们将无法知晓这个 DatePicker 的用途。
更好的解决方案是使用 labelsHidden() 修饰符,代码如下:
DatePicker("请选择日期", selection: $wakeUp)
.labelsHidden()这样既能保留原始标签,方便屏幕阅读器通过 VoiceOver 为用户提供说明,又能让标签在界面上隐藏 ——DatePicker 不会再因为一段空文本而被挤到一边。
DatePicker 提供了几个配置选项,用于控制其功能。首先,可以通过 displayedComponents 参数指定用户能看到的日期 / 时间组件:
- 如果不指定该参数,用户会看到日、时、分。
- 如果设置为
.date,用户会看到月、日、年。 - 如果设置为
.hourAndMinute,用户只会看到时和分。
因此,我们可以这样来让用户选择精确的时间:
DatePicker("请选择时间", selection: $wakeUp, displayedComponents: .hourAndMinute)最后,DatePicker 还有一个 in 参数,其用法与 Stepper 中的 in 参数完全相同:我们可以为它指定一个日期范围,DatePicker 会确保用户无法选择超出该范围的日期。
我们已经接触范围概念有一段时间了,想必你对 1...5 或 0..<10 这类写法已经很熟悉。实际上,Swift 的范围也支持 Date 类型。例如:
func exampleDates() {
// 创建一个 Date 实例,代表当前时间往后推一天(86400 是一天的秒数)
let tomorrow = Date.now.addingTimeInterval(86400)
// 用当前时间和明天的这个时间创建一个范围
let range = Date.now...tomorrow
}这一特性在 DatePicker 中非常实用,但还有更便捷的用法:Swift 支持 “单侧范围”—— 即只指定范围的起始端或结束端,另一端由 Swift 自动推断。
例如,我们可以创建这样一个 DatePicker:
DatePicker("请选择日期", selection: $wakeUp, in: Date.now...)这样配置后,用户只能选择当前时间之后的日期,无法选择过去的日期 —— 可以将这个范围理解为 “从当前日期开始,到任意未来日期”。
日期相关操作
作者:Paul Hudson 2023 年 10 月 17 日
让用户通过 DatePicker 控件输入日期很简单,只需将 @State 修饰的 Date 类型属性与 DatePicker 绑定即可。但在这之后,处理日期就会变得复杂起来。
要知道,处理日期其实非常困难,比你想象的要难得多,甚至比我这个接触日期处理多年的人想象的还要难。
先看一个看似简单的例子:
let now = Date.now
let tomorrow = Date.now.addingTimeInterval(86400)
let range = now...tomorrow这段代码创建了一个从当前时间到明天同一时间的范围(86400 是一天的秒数)。
这看起来似乎很简单,但真的每天都有 86400 秒吗?如果答案是肯定的,那很多人可能就要失业了!想想夏令时:有时时钟会拨快一小时(导致当天只有 23 小时),有时会拨慢一小时(导致当天有 25 小时)。除此之外,还有闰秒 —— 为了调整地球自转速度变慢带来的时间偏差,会在特定时刻为时钟增加一秒。
如果你觉得这已经够复杂了,不妨在 Mac 的终端中运行 cal 命令。这个命令会打印当前月份的简易日历,显示一周中的每一天。再尝试运行 cal 9 1752,查看 1752 年 9 月的日历 —— 你会发现有整整 12 天不见了,这是因为当时历法从儒略历切换到了公历。
我讲这些并不是为了让你对日期处理望而却步 —— 毕竟在程序开发中,日期处理是无法回避的。相反,我想让你明白,对于任何重要的日期处理场景(即代码中涉及的日期操作会影响核心功能),我们都应该依赖 Apple 提供的框架来进行计算和格式化。
在我们正在开发的这个项目中,会从三个方面使用日期:
- 设置合理的 “起床时间” 默认值。
- 获取用户希望起床的小时和分钟。
- 以清晰的格式显示建议的入睡时间。
理论上,我们可以手动实现所有这些功能,但这会涉及夏令时、闰秒、公历转换等一系列复杂问题。
更好的做法是让 iOS 帮我们处理这些复杂工作:这样不仅能减少我们的代码量,还能确保无论用户处于哪个地区,日期处理结果都是准确的。
下面我们分别解决这三个问题,首先从设置合理的起床时间默认值开始。
正如你所见,Swift 提供了 Date 类型来处理日期,它包含了年、月、日、时、分、秒、时区等多种信息。但在这个场景中,我们不需要关注这么多细节 —— 我们只想设置 “无论今天是哪一天,默认起床时间都是早上 8 点”。
Swift 中有一个专门用于处理这种需求的类型 ——DateComponents,它允许我们读取或设置日期中的特定部分,而无需操作整个日期。
因此,如果我们想创建一个代表 “今天早上 8 点” 的日期,可以编写如下代码:
var components = DateComponents()
components.hour = 8
components.minute = 0
let date = Calendar.current.date(from: components)由于日期验证过程中可能出现问题,date(from:) 方法返回的是一个可选类型的日期。因此,最好使用空合运算符(nil coalescing)来处理这种情况,代码如下:“如果转换失败,就返回当前日期”。
let date = Calendar.current.date(from: components) ?? .now第二个问题是如何获取用户希望起床的小时和分钟。要知道,DatePicker 绑定的 Date 类型包含了大量信息,所以我们需要从中提取出仅包含小时和分钟的部分。
同样,DateComponents 可以帮我们解决这个问题:我们可以让 iOS 从指定日期中提取特定的组件,然后读取这些组件的值。不过有一点需要注意,由于 DateComponents 的工作机制,我们 “请求的组件” 和 “实际获取的组件” 之间存在一定差异:即便我们明确请求提取小时和分钟,得到的 DateComponents 实例中,所有属性仍然是可选类型。虽然我们知道小时和分钟这两个属性一定有值(因为是我们主动请求提取的),但还是需要对可选类型进行解包,或者提供默认值。
因此,我们可以编写如下代码:
let components = Calendar.current.dateComponents([.hour, .minute], from: someDate)
let hour = components.hour ?? 0
let minute = components.minute ?? 0第三个问题是如何格式化日期和时间,这里有两种实现方式。
第一种方式是使用我们之前已经熟练掌握的 format 参数,通过它可以指定要显示的日期 / 时间部分。
例如,如果我们只想显示某个日期的时间,可以编写如下代码:
Text(Date.now, format: .dateTime.hour().minute())如果想显示日、月、年,可以这样写:
Text(Date.now, format: .dateTime.day().month().year())你可能会好奇,这种方式如何适配不同的日期格式?比如,在英国,日期格式通常是 “日 / 月 / 年”,但在其他一些国家,格式可能是 “月 / 日 / 年”。其实,我们无需担心这个问题:当我们编写 day().month().year() 时,只是 “请求” 显示这些数据,而不是 “指定” 数据的排列顺序。iOS 会根据用户的偏好自动对这些数据进行格式化。
另一种方式是直接调用日期的 formatted() 方法,并传入配置选项,指定日期和时间的显示格式,示例如下:
Text(Date.now.formatted(date: .long, time: .shortened))总而言之,日期处理确实很复杂,但 Apple 提供了大量工具来降低其难度。只要学会合理使用这些工具,你编写的代码不仅会更简洁,质量也会更高!
使用 Create ML 训练模型
作者:Paul Hudson 2023 年 10 月 17 日
借助 Apple 的两个框架 ——Core ML 和 Create ML,在设备本地实现机器学习变得异常简单。其中,Core ML 用于开发支持机器学习的应用,而 Create ML 则搭配专门的 Create ML 应用,通过拖拽操作就能创建自定义的机器学习模型。得益于这些工具,现在任何人都能轻松地在自己的应用中集成机器学习功能。
Core ML 支持多种训练任务,例如图像识别、声音识别甚至动作识别。但在本次项目中,我们要关注的是 “表格回归”(tabular regression)。这是一个机器学习领域中常见的专业术语,但其本质很简单:我们可以将大量类似电子表格的数据输入 Create ML,让它分析这些数据中各个数值之间的关系。
机器学习通常分为两个步骤:训练模型和使用模型进行预测。训练过程是让计算机分析所有输入数据,找出数据中各个数值之间的关系。对于大型数据集,训练过程可能需要很长时间 —— 轻则几小时,重则更久。而预测过程则在设备本地进行:我们将训练好的模型植入应用,它就能根据之前的训练结果,对新数据做出估算。
现在我们就开始训练模型:请在你的 Mac 上打开 Create ML 应用。如果你不知道该应用的位置,可以通过 Xcode 启动它 —— 点击 Xcode 菜单栏,选择 “Open Developer Tool”(打开开发者工具)>“Create ML”。
Create ML 应用启动后,首先会提示你创建新项目或打开已有项目 —— 请点击 “New Document”(新建文档)开始。你会看到有很多模板可供选择,请选择 “Tabular Regression”(表格回归),然后点击 “Next”(下一步)。项目名称请输入 “BetterRest”,接着点击 “Next”,选择桌面作为保存位置,最后点击 “Create”(创建)。
初次使用 Create ML 时,你可能会觉得界面上的选项比较复杂,不用担心 —— 只要跟着我的步骤操作,很快就能上手。
第一步是为 Create ML 提供训练数据。这些原始数据将作为计算机分析的依据,在本项目中,数据包含四个数值:用户希望的起床时间、用户认为自己需要的睡眠时长、用户每天喝的咖啡杯数,以及用户实际需要的睡眠时长。
我已经为你准备了这些数据,存储在 BetterRest.csv 文件中,该文件位于本项目的资源文件中。这是一个逗号分隔值(CSV)格式的数据集,Create ML 可以直接处理它。我们的首要任务就是导入这个文件。
在 Create ML 界面中,找到 “Data”(数据)部分,点击 “Training Data”(训练数据)下方的 “Select…”(选择…)按钮。再次点击 “Select…” 后,会弹出文件选择窗口,请选择 BetterRest.csv 文件。
重要提示: 此 CSV 文件仅包含用于本项目演示的样本数据,不得用于实际的健康相关工作。
接下来,需要确定 “目标值”(target)和 “特征值”(features)。目标值是我们希望计算机学会预测的数值;特征值则是计算机在预测目标值时需要分析的数值。例如,如果我们将 “用户认为自己需要的睡眠时长” 和 “用户实际需要的睡眠时长” 设为特征值,就可以训练计算机预测用户每天喝的咖啡杯数。
在本项目中,请将 “actualSleep”(实际睡眠时长)设为目标值,这意味着我们希望计算机学会预测用户实际需要的睡眠时长。然后点击 “Choose Features”(选择特征值)按钮,勾选所有三个选项:wake(起床时间)、estimatedSleep(预估睡眠时长)和 coffee(咖啡杯数)—— 我们希望计算机综合这三个因素来生成预测结果。
在 “Choose Features” 按钮下方,有一个用于选择算法的下拉按钮,包含五个选项:Automatic(自动)、Random Forest(随机森林)、Boosted Tree(提升树)、Decision Tree(决策树)和 Linear Regression(线性回归)。每种算法分析数据的方式都不同,但好在有 “Automatic” 选项,它会尝试自动选择最合适的算法。虽然这个选项并非总能选出最优算法,而且会大幅限制可选算法的范围,但对于本项目来说,它已经足够好用了。
准备就绪后,点击窗口标题栏中的 “Train”(训练)按钮。由于我们使用的数据集规模较小,几秒钟后训练就会完成,界面上会出现一个大大的对勾,表示训练成功。
要查看训练结果,请选择 “Evaluation”(评估)标签页,然后选择 “Validation”(验证)选项,查看相关的结果指标。我们重点关注的指标是 “Root Mean Squared Error”(均方根误差),通常这个值会在 170 左右。这意味着,该模型预测的睡眠时长与实际睡眠时长的平均误差仅为 170 秒(约 3 分钟)。
提示: Create ML 会同时提供 “训练” 和 “验证” 相关的统计数据,这两项数据都很重要。当我们让 Create ML 使用数据进行训练时,它会自动将数据分成两部分:一部分用于训练机器学习模型,另一部分则预留出来用于验证。之后,Create ML 会使用验证数据来检验模型的效果:根据输入的特征值进行预测,然后将预测结果与数据集中的实际目标值进行对比,计算误差。
更值得一提的是,切换到 “Output”(输出)标签页,你会发现生成的模型文件大小仅为 545 字节左右。Create ML 将原本 180KB 大小的数据,压缩到了仅 545 字节 —— 几乎可以忽略不计。
虽然 545 字节听起来非常小,但需要说明的是,其中几乎所有字节都属于元数据:包括作者名称、所有字段的名称(wake、estimatedSleep、coffee、actualSleep)等。
而真正用于存储核心数据(即根据三个特征值预测所需睡眠时长的规则)的空间,还不到 100 字节。之所以能做到这一点,是因为 Create ML 并不关心数据的具体数值,只关注数值之间的关系。在训练过程中,计算机会进行数十亿次 CPU 运算,尝试为每个特征值分配不同的权重,找到能让预测结果最接近实际目标值的组合。一旦确定了最优算法,Create ML 就会将这个算法存储起来。
模型训练完成后,请点击 “Get”(获取)按钮,将模型导出到桌面,以便我们在代码中使用它。
提示: 如果你想重新训练模型(例如尝试使用不同的算法),可以右键点击左侧窗口中的模型源文件,然后选择 “Duplicate”(复制)。