译自 Connecting SwiftUI to Core ML
更多内容,欢迎关注公众号 「Swift花园」
收藏是白嫖,点赞才是真爱
每当你添加一个 .mlmodel 文件到 Xcode 的时候,它会自动地创建一个同名的 Swift 类,但是你看不到这个类,也不需要看到 —— 它是编译过程自动生成的。不过呢,这也意味着,如果你给模型文件起了一个古怪的名字,那么那个自动生成的类名字也会一样古怪。
在我们的案例,我们的文件叫 “BetterRest 1.mlmodel” ,因此 Xcode 会生成一个叫 BetterRest_1 的 Swift 类。不管你的模型叫什么名字,现在请把它重命名为 “SleepCalculator.mlmodel” ,这样自动生成的类就会叫 SleepCalculator。
我们怎么确定这一点呢?你可以选中这个文件,Xcode 会显示更多信息。你会看到它知道模型的作者,描述,以及生成的 Swift 类的名字,还正如 SwiftUI 让 UI 开发变得更简单一样,Core ML 让机器学习变得更简单了。有多简单呢?这么说吧,一旦你完成了模型训练,只需要两行代码你就能做预测 —— 你只需要传入数值作为输出,然后读取结果就行了。
在我们的案例中,我们已经利用 Xcode 的 Create ML app 创建了一个 Core ML 的模型。你可能已经把它保存到你的桌面了。现在把这个模型文件拖到 Xcode 的项目导航器里 —— 放到 Info.plist 下方就OK 。
有一组输入的特征项的列表,对应的类型,以及输出的类型 —— 这些都在模型文件中做了编码。
接下来我们编写 calculateBedtime()方法。首先,我们需要用到一个 SleepCalculator 类的实例,像这样:
let model = SleepCalculator()复制代码
这个东西负责读取我们所有的数据,然后输出预测。我们之前用 CSV 文件训练模型时包含下面这些字段:
因此,为了用模型预测,我们需要提供上面这些值。
我们已经有两个了, sleepAmount 和 coffeeAmount 属性够用 —— 我们只需要把 coffeeAmount 从整型转换成 Double 让编译器满意。
但是搞明白起床时间需要费点事,因为我们的 wakeUp 属性Date而不是代表秒数的Double。万幸,Swift 的 DateComponents 类型就是为此而生的:它把表示一个日期的所有部分单独存储,也就是说,我们可以只读取小时和分钟组件,忽略剩下的。然后我们只需要把分钟乘以 60 (得到秒数),把小时乘以 60 再乘以 60 (也是为了得到秒数)。
我们可以从 Date 中拿到 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 代替,不过实际上这不可能发生。
下一步是把特征值提供给 Core ML 然后看会输出什么。如果 Core ML 遭遇问题,这个过程可能会失败,所以我们需要使用 do 和 catch。老实说,我自己没遇过预测失败的情况,不过保险无害!
我们将创建一个 do/catch 块,在块里面调用模型的prediction() 方法,它需要起床时间,估计的睡觉时长,喝咖啡杯数,所以的参数都以 Double 值形式提供。我们只需要把小时和分钟换成秒数,相加然后传入。
把下面的代码添加到 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 算法动态计算得到的。
但是,这个数值对用户来说还不是很有用 —— 它是一个秒数。我们需要把它转换成上床睡觉的时间。因此我们得用期望起床时间减去这个秒数。
归功于 Apple 强大的 API ,这也是一行代码的事。你可以直接从 一个 Date类型里减去一个秒数值,然后你会拿到一个新的Date! 在预测之后添加这行代码:
let sleepTime = wakeUp - prediction.actualSleep复制代码
现在我们知道用户需要上床睡觉的准确时间了。我们最后的挑战是把这个时间展示给用户。我么将通过一个 alert 来实现。
先添加三个属性来确定 alert 的标题,消息以及是否展示:
@State private var alertTitle = "" @State private var alertMessage = "" @State private var showingAlert = false复制代码
在 calculateBedtime() 中可以用上这些属性。如果你的计算出错了 —— 预测抛出错误 —— 我们可以把// something went wrong 注释换成下面的代码:
alertTitle = "错误" alertMessage = "抱歉,计算就寝时间时出错了"复制代码
不管预测是否成功,我们都应该展示 alert ,它可能包含预测结果或者是错误消息,但都是有用的。所以在 calculateBedtime()的 catch 块之后,添加这行代码:
showingAlert = true复制代码
接下来是挑战的部分:如果预测成功,我们创建一个包含用户需要上床睡觉的时间的常量,叫 sleepTime。但它是一个 Date ,并不是一个格式化的字符串没所以我们需要用 Swift 的DateFormatter来改善它。
DateFormatter 可以通过它的 dateStyle and timeStyle属性以各种样式格式化日期。在我们的案例中,我们只想要一个时间字符串,以便放进 alertMessage。
把下面这些代码放在 calculateBedtime()的最后,即设置 sleepTime 常量之后:
let formatter = DateFormatter() formatter.timeStyle = .short alertMessage = formatter.string(from: sleepTime) alertTitle = "你的理想就寝时间是…"复制代码
最后,我们需要添加一个 alert() modifier ,在showingAlert 变为 true 时展示 alert 。
给 VStack添加下面的 modifier :
.alert(isPresented: $showingAlert) { Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK"))) }复制代码
运行 app —— 虽然外观还不怎么样,不过工作正常。
虽然 app 已经工作了,不过它还不是那种你想上架到 App Store 的东西 —— 它至少有一个可用性上的问题,设计上也不够标准。
首先来看一下可用性问题,因为你之前可能没有遇到过这种问题。当你创建一个新的 Date
实例时,它会被自动设置到当前日期和时间。因此,当我们用新建的 date 创建 wakeUp
属性时,默认的起床时间就被设置成当前时间了,不管它是什么时间。
虽然这个 app 需要能够处理各种时间 —— 我们并不希望排除上夜班的人 —— 但我认为起床时间默认在早上 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() }复制代码
于是我们就可以用它来作为 wakeUp
的默认值:
@State private var wakeUp = defaultWakeTime复制代码
如果你尝试编译上面的代码,编译将会失败。原因在于我们正在一个属性中访问另一个属性 —— Swift 并不知道属性创建的顺序,因此它不允许这种操作。
修复方案很简单,我们只要让 defaultWakeTime
称为一个静态变量,意味着它属于 ContentView
结构体本身而不是任何一个结构体实例。这样我们就可以任何时候读取 defaultWakeTime
,因为它并不依赖任何其他属性的存在。
把属性定义改成这样:
static var defaultWakeTime: Date {复制代码
上面的修改就解决了可用性问题,因为大部分用户会发现默认起床时间和他们想要选择的时间很接近。
至于我们的外观样式,需要做的努力更多。一个简单的改动是,切换到 Form
而不是VStack
,找到这里:
NavigationView { VStack {复制代码
替换成:
NavigationView { Form {复制代码
这个简单的动作立刻就能改善 UI —— 我们得到了一个清晰分段的表单,而不是各种控件居中在白色空间。
当我们切换到Form 时, DatePicker
的样式跟原来的不同。如果你更喜欢原来的样式,可以通过 .datePickerStyle(WheelDatePickerStyle())
modifier 用回原来的样式。
修改代码让 DatePicker 用回原来的样式:
DatePicker("请输入一个时间", selection: $wakeUp, displayedComponents: .hourAndMinute) .labelsHidden() .datePickerStyle(WheelDatePickerStyle())复制代码
提示:滚盘样式的只在 iOS 和 watchOS 上可用,所以如果你打算写的 SwiftUI 代码是给 macOS 或者 tvOS 用的话,要避免用上面的样式。
表单里还有一个恼人的地方:表单里每个视图都被当做一行来对待,这样文本视图就和相同逻辑的视图分开了。
我们可以用 Section
视图,其中用文本视图作为标题 —— 你可以自己动手试试。不过,这里可以直接利用 VStack
把成对的文本视图和输入控件组合到一起。
三组控件都用 VStack 包起来,并且用 .leading
对齐,0 作为间距:比如,把下面这两个视图:
Text("要求的睡觉时长") .font(.headline) Stepper(value: $sleepAmount, in: 4...12, step: 0.25) { Text("\(sleepAmount, specifier: "%g") hours") }复制代码
包进 VStack
,像这样:
VStack(alignment: .leading, spacing: 0) { Text("要求的睡眠时长") .font(.headline) Stepper(value: $sleepAmount, in: 4...12, step: 0.25) { Text("\(sleepAmount, specifier: "%g") hours") } }复制代码
再次运行 app ,这就完工了。干得漂亮!
我的公众号 这里有Swift及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~