BetterRest 介绍
这个SwiftUI项目是另一个基于表单的应用程序,它将要求用户输入信息并将这些信息转换成一个警报,这听起来可能很无聊——你已经这样做过了,对吧?
嗯,是的,但是练习从来都不是坏事。然而,我们有一个相当简单的项目的原因是,我想向您介绍iOS开发的一个真正强大的特性:机器学习(ML)。
所有的iphone都内置了一种称为Core ML的技术,它允许我们编写代码,根据之前看到的数据对新数据进行预测。我们将从一些原始数据开始,将其作为训练数据提供给我们的Mac,然后使用结果构建一个能够对新数据做出准确估计的应用程序——所有这些操作都在设备上,并且完全保护用户的隐私。
我们开发的实际应用程序名为BetterRest,它旨在通过问三个问题帮助喝咖啡的人睡个好觉:
1、他们想什么时候醒来?
2、他们大概要睡几个小时?
3、他们每天喝多少杯咖啡?
一旦我们有了这三个值,我们将把它们输入Core ML,得到一个结果,告诉我们它们应该什么时候睡觉。如果你仔细想想,可能会有数十亿个答案——所有不同的唤醒时间乘以所有的睡眠时间,再乘以所有的咖啡量。
这就是机器学习的用武之地:使用一种叫做回归分析的技术,我们可以要求计算机提出一种能够表示我们所有数据的算法。这反过来又允许它将该算法应用于以前从未见过的新数据,并获得准确的结果。
你需要为这个项目下载一些文件,你可以从GitHub下载:
https://github.com/twostraws/HackingWithSwift
PS:不想下载整个项目的小伙伴看这里
1. 打开终端
2. cd 到你想保存的文件夹 比如 cd Desktop/
3. 输入
svn checkout https://github.com/twostraws/HackingWithSwift/trunk/SwiftUI/project4
4. 回车等待
还有对应的文件地址:
svn checkout https://github.com/twostraws/HackingWithSwift/trunk/SwiftUI/project4-files
一旦你有了这些,继续在Xcode中创建一个新的Single View App 模板工程,命名为BetterRest。和之前一样,我们将从构建应用程序所需的各种技术的概述开始,所以让我们进入它…
- 使用 Stepper 输入数字
- 使用 DatePicker 选择日期和时间
- 使用日期
- 用Create ML训练模型
建立基本布局
这个应用程序将允许用户输入一个日期选择器和两个步进器,这两个组合将告诉我们他们什么时候想醒来,他们通常喜欢多少睡眠,喝多少咖啡。
因此,请首先添加三个属性,让我们存储这些控件的信息:
@State private var wakeUp = Date()
@State private var sleepAmount = 8.0
@State private var coffeeAmount = 1
在我们的body
里,我们将放置三组组件,包装在一个 VStack
和一个NavigationView
中,所以让我们从唤醒时间开始。将默认的“Hello World”文本视图替换为:
NavigationView {
VStack {
Text("When do you want to wake up?")
.font(.headline)
DatePicker("Please enter a time", selection: $wakeUp, displayedComponents: .hourAndMinute)
.labelsHidden()
// more to come
}
}
因为我们在一个VStack
中,它将把日期选择器渲染为iOS上的一个滚轮,这在这里看起来很好。我们要求使用.hourAndMinute
配置,因为我们关心的是某人想醒来的时间,而不是一天,并且使用labelsHidden()
修饰符,我们不需要为选择器设置第二个标签——上面的一个已经足够了。
接下来我们将添加一个步进器,让用户大致选择他们想要多少睡眠。通过给这个值4…12
的范围和0.25
的步长,我们可以确定它们会输入合理的值,但是我们可以把它与%g
字符串插值说明符结合起来,这样我们就可以看到像“8”而不是“8.000000”这样的数字。
添加此代码以代替//more to come
注释:
Text("Desired amount of sleep")
.font(.headline)
Stepper(value: $sleepAmount, in: 4...12, step: 0.25) {
Text("\(sleepAmount, specifier: "%g") hours")
}
最后,我们将添加最后一个步进器和标签来处理他们喝了多少咖啡。这次我们将使用1到20的范围(因为一天20杯咖啡对任何人来说肯定足够了?),但我们还将在步进器内加上两个标签,以便更好地处理复数形式(英文才要,我们可以直接使用:杯)。如果用户将咖啡量设置为1,我们将显示“1 cup”,否则我们将使用该数值加上“cups”。
将这些添加到VStack
中,位于先前视图的下面:
Text("Daily coffee intake")
.font(.headline)
Stepper(value: $coffeeAmount, in: 1...20) {
if coffeeAmount == 1 {
Text("1 cup")
} else {
Text("\(coffeeAmount) cups")
}
}
最后我们需要的是一个按钮,让用户计算他们应该睡觉的最佳时间。我们可以在VStack
的末尾使用一个简单的按钮来实现这一点,但是为了使这个项目更加有趣,我想尝试一些新的东西:我们将直接在导航栏中添加一个按钮。
首先,我们需要一个方法来让按钮调用,因此添加一个空的calculateBedtime()
方法,如下所示:
func calculateBedtime() {
}
现在我们需要使用navigationBarItems()
修饰符向导航视图添加一个"Trailing "按钮。“Trailing”在英语等从左到右的语言中是“on the right”的意思,您可以在这里提供任何视图——例如,如果您需要几个按钮,可以使用HStack
。在这里,我们还可以使用navigationBarTitle()
在顶部放置一些文本。
因此,将这些修改器添加到VStack
:
.navigationBarTitle("BetterRest")
.navigationBarItems(trailing:
// our button here
)
在我们的例子中,我们想用一个“Calculate”按钮来替换这个注释。之前我解释过按钮有两种形式:
Button("Hello") {
print("Button was tapped")
}
Button(action: {
print("Button was tapped")
}) {
Text("Hello")
}
如果我们愿意,我们可以在这里使用第一个选项:
Button("Calculate") {
self.calculateBedtime()
}
这个代码可以正常工作,但我希望你重新考虑。这段代码创建了一个新的闭包,闭包的唯一工作就是调用一个方法。闭包在很大程度上只是没有名字的函数——我们直接把它们赋给某个东西,而不是把它们作为一个单独的实体。
所以,我们正在创建一个只调用另一个函数的函数。如果我们能完全跳过中间那一层,对每个人都不是更好吗?
好吧,我们可以。按钮关心的是它的动作是某种函数,它不接受任何参数,也不返回任何信息——它不关心这是一个方法还是一个闭包,只要它们都遵循这些规则。
因此,我们实际上可以将calculatebdtime
直接绑定到按钮的操作,如下所示:
Button(action: calculateBedtime) {
Text("Calculate")
}
现在,当人们看到上面的代码,他们经常认为我犯了一个错误。他们想写的是:
Button(action: calculateBedtime()) {
Text("Calculate")
}
然而,这段代码不起作用,实际上意味着完全不同的东西。如果我们在calculateBedtime
后面加上括号,它的意思是“调用calculateBedtime()
,当点击按钮时,它会发送回正确的函数来使用。”因此,Swift要求calculateBedtime()
返回一个闭包来运行。
通过编写calculateBedtime
而不是calculateBedtime()
,我们告诉Swift在点击按钮时直接运行该方法,而且不会返回任何应该运行的内容。
Swift确实模糊了函数、方法、闭包,甚至运算符(+
、-
,等等)之间的界限,这就是为什么我们可以如此互换地使用它们。
所以,整个修饰符应该如下所示:
.navigationBarItems(trailing:
Button(action: calculateBedtime) {
Text("Calculate")
}
)
因为calculateBedtTime()
是空的,所以这还不起作用,但至少我们的用户界面目前已经足够好了。
使用Core ML模型预测结果
与SwiftUI简化用户界面开发一样,Core ML简化了机器学习。有多容易?好吧,一旦你有了一个训练有素的模型,你就可以在两行代码中得到预测——你只需要发送应该用作输入的值,然后阅读返回的内容。
在我们的例子中,我们已经使用Xcode的Create ML应用创建了一个Core ML模型( 用Create ML训练模型),所以我们将使用它。你应该把它保存在桌面上,所以现在请将它拖到Xcode中的项目导航器中,就放在Info.plist下面。
将.mlmodel
文件添加到Xcode时,它将自动创建同名的Swift类。你看不到这个类,也不需要——它是作为构建过程的一部分自动生成的。但是,这确实意味着如果模型文件的命名很奇怪,那么自动生成的类名也会命名很奇怪。
在这个例子中,我有一个名为“BetterRest 1.mlmodel”的文件,这意味着Xcode将生成一个名为BetterRest_1
的Swift类。无论模型文件的名称是什么,请将其重命名为“SleepCalculator.mlmodel”,从而使自动生成的类成为SleepCalculator
。
我们怎么能确定?好吧,只要选择模型文件本身,Xcode就会显示更多信息。您将看到它知道我们的作者和描述,所创建的Swift类的名称,加上输入及其类型的列表,以及输出加上类型——这些都是在模型文件中编码的,这就是为什么它(相对而言!)太大了。
让我们开始实现calculateBedtime()
。首先,我们需要创建SleepCalculator
类的一个实例,如下所示:
let model = SleepCalculator()
这就是读取我们所有数据并输出预测的东西。我们使用包含以下字段的CSV文件来训练模型:
- “wake”:当用户想要睡醒的时间。这表示为从午夜开始的秒数,因此上午8点将是8小时乘以60乘以60,得出28800。
- “estimatedSleep”:大致是用户想要的睡眠时间,以四分之一增量存储为4到12之间的值。
- “ coffee”:用户每天大概喝多少杯咖啡。
所以,为了从我们的模型中得到一个预测,我们需要填写这些值。
我们已经有了其中的两个,因为我们的sleepAmount
和coffeeAmount
属性基本上已经足够好了——我们只需要将coffeeAmount
从整数转换为Double
,这样Swift就很高兴了(模型中的coffee是Double
类型)。
但是计算睡醒时间需要更多的思考,因为我们的wakeUp
属性是一个日期,而不是表示秒数的Double
。有帮助的是,这就是Swift的DateComponents
类型的作用:它将表示日期所需的所有部分存储为单独的值,这意味着我们可以读取小时和分钟部分,而忽略其余部分。然后我们需要做的就是将分钟乘以60(得到秒而不是分钟),将小时乘以60再乘以60(得到秒而不是小时)。
我们可以使用非常特定的方法调用Calendar.current.DateComponents()
从Date
获取DateComponents
实例。然后我们可以请求小时和分钟部分,并传入睡醒日期。返回的DateComponents
实例具有其所有部分的属性(年、月、日、时区等),但大多数不会设置。我们要求的那些——小时和分钟——将被设置,但将是可选的,所以我们需要小心解包。
所以,将它直接放在calculateBedtTime()
的第一行下面:
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 中,看看结果如何。如果Core ML遇到某种问题,这可能会失败,因此我们需要使用do
和catch
。老实说,我想我这辈子从来没有遇到一个预测失败过,但安全是没有坏处的!
因此,我们将创建一个do/catch
块,在其中使用模型的prediction()
方法。这需要做一个预测所需的睡醒时间、估计睡眠时间和咖啡量值,所有这些都是以Double
值提供的。我们只是用秒来计算hour
和minute
,所以在发送之前我们会把它们加在一起。
请立即将此代码添加到calculateBedtime()
中:
do {
let prediction = try model.prediction(wake: Double(hour + minute), estimatedSleep: sleepAmount, coffee: Double(coffeeAmount))
// more code here
} catch {
// something went wrong!
}
有了这个,prediction
现在包含了他们实际需要多少睡眠。这几乎肯定不是我们模型看到的训练数据的一部分,而是由Core ML算法动态计算的。
然而,这对用户来说并不是一个有用的值——它将是以秒为单位的一些数字。我们想要的是把它转换成他们应该睡觉的时间,这意味着我们需要从他们需要醒来的时间中减去这个值(以秒为单位)。
多亏了苹果强大的API,这只是一行代码——你可以直接从一个日期中减去一个以秒为单位的值,然后你会得到一个新的日期!所以,在预测之后添加这行代码:
let sleepTime = wakeUp - prediction.actualSleep
现在我们知道他们什么时候该睡觉了。至少现在,我们的最后一个挑战是向用户展示这个结果。我们将在使用Alert
实现此操作,因为您已经学习了如何进行此操作,并且可以使用此实践。
因此,首先添加三个属性来确定警报的标题和消息,以及它是否显示:
@State private var alertTitle = ""
@State private var alertMessage = ""
@State private var showingAlert = false
我们可以立即在calculateBedtime()
中使用这些值。如果计算出错(如果读取预测时抛出错误),我们可以用设置有用错误消息的代码替换//something-went-wrong
注释:
alertTitle = "Error"
alertMessage = "Sorry, there was a problem calculating your bedtime."
不管预测是否有效,我们都应该显示警报。它可能包含他们预测的结果,也可能包含错误消息,但它仍然有用。因此,将其放在catch
块之后calculatebdtime()
的末尾:
showingAlert = true
现在来看看更具挑战性的部分:如果预测有效,我们会创建一个称为sleepTime
的常数,其中包含他们需要睡觉的时间。但这是一个Date
,而不是一个格式整洁的字符串,所以我们需要使用Swift的DateFormatter
来使它看起来更好。
所以,将最后几行代码直接放入calculateBedtime()
中,在这里我们设置了sleepTime
常量:
let formatter = DateFormatter()
formatter.timeStyle = .short
alertMessage = formatter.string(from: sleepTime)
alertTitle = "Your ideal bedtime is…"
要结束应用程序的这个阶段,我们只需要添加一个alert()
修饰符,在showinglert
变为true时显示alertTitle
和alertMessage
。
请将此修饰符添加到我们的VStack
中:
.alert(isPresented: $showingAlert) {
Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK")))
}
现在继续运行应用程序——它工作了!虽然可能看起来不太好,但很管用。
嗯嗯嗯~,我需要早点睡了
优化用户界面
虽然我们的应用程序现在可以运行,但它并不是你想在应用商店中发布的东西——它至少有一个主要的可用性问题,而且设计是……嗯……让我们说“不合格”。
让我们先来看看可用性问题,因为这可能是你没有想到的。创建新的Date
实例时,它会自动设置为当前日期和时间。因此,当我们用新日期创建wakeUp
属性时,默认的weakUp
时间将是现在的时间。
尽管应用程序需要能够处理任何类型的时间,例如,我们不想排除上夜班的人,但我认为可以肯定的是,对于绝大多数用户来说,早上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) ?? Date()
}
现在我们可以用它代替Date()
作为wakeUp
的默认值:
@State private var wakeUp = defaultWakeTime
如果您尝试编译该代码,您将看到它失败了,原因是我们正在从一个属性内部访问另一个属性——Swift不知道属性的创建顺序,因此这是不允许的。
这里的修复方法很简单:我们可以将defaultWakeTime
设置为静态变量,这意味着它属于ContentView
结构体本身,而不是该结构体的单个实例。这反过来意味着可以随时读取defaultWakeTime
,因为它不依赖于任何其他属性的存在。
因此,将属性定义更改为:
static var defaultWakeTime: Date {
这解决了我们的可用性问题,因为大多数用户会发现默认的唤醒时间接近他们想要选择的时间。
这解决了我们的可用性问题,因为大多数用户会发现默认的唤醒时间接近他们想要选择的时间。
至于我们的UI,这需要更多的努力。一个简单的更改是切换到Form
而不是VStack
。所以,找到这个:
NavigationView {
VStack {
改成:
NavigationView {
Form {
这立刻使UI看起来更好——我们得到了一个清晰的输入分段表,而不是一些以空白为中心的控件。
如果你愿意的话,你可以通过特别要求使用滚轮选择器来恢复旧的风格。当我们移动到Form
时,它就丢失了,因为DatePicker
在Form
中使用时有不同的样式,但是我们可以通过使用modifier.datePickerStyle(WheelDatePickerStyle())
将其设置为原来的样子。
因此,请将日期选择器代码修改为:
DatePicker("Please enter a time", selection: $wakeUp, displayedComponents: .hourAndMinute)
.labelsHidden()
.datePickerStyle(WheelDatePickerStyle())
提示:滚轮选择器只在iOS和watchOS上可用,所以如果你计划为macOS或tvOS编写SwiftUI代码,你应该避免使用它。
在我们的表单中仍然有一个烦恼:表单中的每个视图都被视为列表中的一行,而实际上所有文本视图都是同一逻辑表单部分的一部分。
我们可以在这里使用Section
视图,将文本视图作为标题——您将在挑战中体验这一点。在这里,我们将用一个VStack
包装每对文本视图和控件,以便将它们分别看作一行。
现在,用.leading
表示对齐,0表示间距,将每行用VStack
包装起来。例如,您可以采用以下两种视图:
Text("Desired amount of sleep")
.font(.headline)
Stepper(value: $sleepAmount, in: 4...12, step: 0.25) {
Text("\(sleepAmount, specifier: "%g") hours")
}
然后用这样一个VStack
包起来:
VStack(alignment: .leading, spacing: 0) {
Text("Desired amount of sleep")
.font(.headline)
Stepper(value: $sleepAmount, in: 4...12, step: 0.25) {
Text("\(sleepAmount, specifier: "%g") hours")
}
}
现在最后一次运行这个应用程序,因为它已经完成了——God Job!
译自 Hacking with iOS: SwiftUI Edition - BetterRest
BetterRest: Introduction
Building a basic layout
Connecting SwiftUI to Core ML
Cleaning up the user interface
Previous: 里程碑(一)之Challenge: 剪刀石头布 | Hacking with iOS: SwiftUI Edition | Next: BetterRest 项目——挑战 |
---|
赏我一个赞吧~~~