[SwiftUI 100天] BetterRest · part4

译自 Connecting SwiftUI to Core ML

更多内容,欢迎关注公众号 「Swift花园」

收藏是白嫖,点赞才是真爱

连接 SwiftUI 和 CoreML

每当你添加一个 .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 文件训练模型时包含下面这些字段:

  • “wake”: 用户想要什么时候起床。这个字段是以午夜之后的秒数来表示的,所以早上 8 点就是 8 小时乘以 60 再乘以 60 ,也就是 28800 。
  • “estimatedSleep”: 用户想要的睡觉时长,4 到 12 小时之间,每 15 分钟为步长。
  • “coffee”: 用户每天喝的咖啡杯数。

因此,为了用模型预测,我们需要提供上面这些值。

我们已经有两个了, 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 —— 虽然外观还不怎么样,不过工作正常。

整理 UI

虽然 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及计算机编程的相关文章,以及优秀国外文章翻译,欢迎关注~

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章