Hacking with iOS: SwiftUI Edition - BetterRest 项目

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()是空的,所以这还不起作用,但至少我们的用户界面目前已经足够好了。

Hacking with iOS: SwiftUI Edition - BetterRest 项目_第1张图片
当前基本UI布局

使用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类的名称,加上输入及其类型的列表,以及输出加上类型——这些都是在模型文件中编码的,这就是为什么它(相对而言!)太大了。


Hacking with iOS: SwiftUI Edition - BetterRest 项目_第2张图片
SleepCalculator.mlmodel

让我们开始实现calculateBedtime()。首先,我们需要创建SleepCalculator类的一个实例,如下所示:

let model = SleepCalculator()

这就是读取我们所有数据并输出预测的东西。我们使用包含以下字段的CSV文件来训练模型:

  • “wake”:当用户想要睡醒的时间。这表示为从午夜开始的秒数,因此上午8点将是8小时乘以60乘以60,得出28800。
  • “estimatedSleep”:大致是用户想要的睡眠时间,以四分之一增量存储为4到12之间的值。
  • “ coffee”:用户每天大概喝多少杯咖啡。

所以,为了从我们的模型中得到一个预测,我们需要填写这些值。

我们已经有了其中的两个,因为我们的sleepAmountcoffeeAmount属性基本上已经足够好了——我们只需要将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,但实际情况是,这种情况永远不会发生,因此hourminute将被会设置正确的值。

下一步是将我们的值输入到 Core ML 中,看看结果如何。如果Core ML遇到某种问题,这可能会失败,因此我们需要使用docatch。老实说,我想我这辈子从来没有遇到一个预测失败过,但安全是没有坏处的!

因此,我们将创建一个do/catch块,在其中使用模型的prediction()方法。这需要做一个预测所需的睡醒时间、估计睡眠时间和咖啡量值,所有这些都是以Double值提供的。我们只是用秒来计算hourminute,所以在发送之前我们会把它们加在一起。

请立即将此代码添加到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时显示alertTitlealertMessage

请将此修饰符添加到我们的VStack中:

.alert(isPresented: $showingAlert) {
    Alert(title: Text(alertTitle), message: Text(alertMessage), dismissButton: .default(Text("OK")))
}

现在继续运行应用程序——它工作了!虽然可能看起来不太好,但很管用。

嗯嗯嗯~,我需要早点睡了


Hacking with iOS: SwiftUI Edition - BetterRest 项目_第3张图片
预测结果

优化用户界面

虽然我们的应用程序现在可以运行,但它并不是你想在应用商店中发布的东西——它至少有一个主要的可用性问题,而且设计是……嗯……让我们说“不合格”。

让我们先来看看可用性问题,因为这可能是你没有想到的。创建新的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时,它就丢失了,因为DatePickerForm中使用时有不同的样式,但是我们可以通过使用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 项目_第4张图片
BetterRest - 运行结果

译自 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 项目——挑战

赏我一个赞吧~~~

你可能感兴趣的:(Hacking with iOS: SwiftUI Edition - BetterRest 项目)