原文:HealthKit Tutorial with Swift: Workouts
作者:Ted Bendixson
译者:kmyhy更新说明:本教程由 Ted Bendixson 升级至 Swift 4、Xcode 9 及 iOS 11。原教程作者是Ernesto García。
欢迎回到我们的 HealthKit 教程系列!
在本系列第一部分,你学习了基本的 Healthkit 的使用:读取和写入数据。
在最后的第二部分教程中,你将学习如何使用一种更复杂的数据类型:workout。
本项目继续从前面的 HealthKit 教程开始。如果你没有完成这个项目,你可以从这里下载它。
准备好迎接 HealthKit workout 了吗?:]
开始
在日常生活中,一次锻炼或练习(workout)表示一件非常简单的事情。它是一个时间周期,通过一系列动作消耗你的体力。
大部分 workout 拥有一个或多个下列属性:
- 活动类型(跑步、骑车、Prancercise 等等)
- 距离
- 开始时间、结束时间
- 消耗的热量
HealthKit 以同样的方式理解 workout。一个 workout 对象包含了这些信息,就像一个样本的集合。某个 workout 可能还包括心率、距离和活动类型。
继续上一教程的话题,你将记录一种特殊的 workout: Prancercise。
开始项目已经包括了一个 view controller,用于记录你的 Prancercise 练习。你可以打开 Workouts 页面并点击 + 按钮。
视图中包含了一个按钮,用于开始 Prancercise 练习。如果你点击这个按钮,app 会开始记录你的 Prancercise 课程,显示开始时间和时长。
当你再次点击这颗按钮,当前 Prancercise 课程结束。你可以点击 Done 按钮来记录这次练习,或者点击 New Prancercise 按钮开始新的练习课(注意这会删除原来的记录)。
保存 workout
现在,app 在你点击 Done 按钮想保存 workout 时啥也不会做。你将解决这个问题。
首先,介绍一点背景知识。打开 Workout.swift 看看。你会看到一个名为 PrancerciseWorkout 的结构:
struct PrancerciseWorkout {
var start: Date
var end: Date
init(start: Date,end: Date) {
self.start = start
self.end = end
}
var duration: TimeInterval {
return end.timeIntervalSince(start)
}
var totalEnergyBurned: Double {
let prancerciseCaloriesPerHour: Double = 450
let hours: Double = duration/3600
let totalCalories = prancerciseCaloriesPerHour*hours
return totalCalories
}
}
PrancerciseWorkout 是一个模型类,app 用它来保存和练习相关的数据。它在你每次做完 Prancercise 课程并点击 Done 按钮时创建。
每个 PrancerciseWorkout 对象记录了:
- 开始时间和结束事件
- 时长
- 消耗的卡路里
当 workout 被保存时,这些值被传递给 HealthKit。
注意:假定采用比较激烈的步速、踝绑带负重为中等,挥拳时伴以大声的音乐伴奏。在这种情况下每小时消耗 450 卡路里。尊巴(一种来自南美的健身舞蹈),吃掉它!
理解完 PrancerciseWorkout 对象中的内容,让我们来保存它。
打开 WorkoutDataStore.swift 找到 save(prancerciseWorkout:completion:) 方法。你将用它保存 PrancerciseWorkout 到 HealthKit。
//1. Setup the Calorie Quantity for total energy burned
let calorieQuantity = HKQuantity(unit: HKUnit.kilocalorie(),doubleValue: prancerciseWorkout.totalEnergyBurned)
//2. Build the workout using data from your Prancercise workout
let workout = HKWorkout(activityType: .other,start: prancerciseWorkout.start,end: prancerciseWorkout.end,duration: prancerciseWorkout.duration,totalEnergyBurned: calorieQuantity,totalDistance: nil,device: HKDevice.local(),Metadata: nil)
//3. Save your workout to HealthKit
let healthStore = HKHealthStore()
healthStore.save(workout) { (success,error) in
completion(success,error)
}
你可能想到上一篇 HealthKit 教程中的 HKQuantitiy。你曾经用它来读写用户的身高、体重和 BMI。
HealthKit 用类似的方式处理 HKWorkout。这里,你可以看到 HKWorkout 中保存了开始时间、结束时间、时长和消耗的总热量。所有这些属性都来自于参数中传入的 PrancerciseWorkout 实体。
将一个 Prancercise Workout 的活动类型划到 Other 是合适的,但只要你愿意,你可以从任意支持的活动类型中选择一个。
你可能会注意到你可以告诉 HealthKit 这个练习是由哪种设备来记录的。在后面查询数据的时候还会用到它。
其余代码都很直观。就和你在上一篇 HealthKit 教程中所做的一样,你用 HKHealthStore 来保存 workout。当保存完成后,completion 回调块调用。
查询 workout
现在已经能保存 workout 了,但还需要有一种方法从 HealthKit 加载 workout。在 WorkoutDataStore 中新加一个方法。
将下列方法粘贴到 WorkoutDataSource 的 save(…) 方法后面:
class func loadPrancerciseWorkouts(completion: @escaping (([HKWorkout]?,Error?) -> Swift.Void)){
//1. Get all workouts with the "Other" activity type.
let workoutPredicate = HKQuery.predicateForWorkouts(with: .other)
//2. Get all workouts that only came from this app.
let sourcePredicate = HKQuery.predicateForObjects(from: HKSource.default())
//3. Combine the predicates into a single predicate.
let compound = NSCompoundPredicate(andPredicateWithSubpredicates: [workoutPredicate,sourcePredicate])
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierEndDate,ascending: true)
let query = HKSampleQuery(sampleType: HKObjectType.workoutType(),predicate: compound,limit: 0,sortDescriptors: [sortDescriptor]) { (query,samples,error) in
DispatchQueue.main.async {
//4. Cast the samples as HKWorkout
guard let samples = samples as? [HKWorkout],error == nil else {
completion(nil,error)
return
}
completion(samples,nil)
}
}
HKHealthStore().execute(query)
}
如果你学过上一篇 HealthKit 教程,这些代码有许多都是熟悉的。NSPredicate 用于决定要查询的HealthKit 数据的类型,sort descriptor 用于告诉 HealthKit 如何对返回的样本进行排序。
- HKQuery.predicateForWorkouts(with:) 是一个特殊的方法,用于返回一个针对特定运动类型 workout 的谓词。这里,你将加载私有运动类型是 Other(所有的 Prancercise 练习的运动类型都使用 Other) 的 workout。
- HKSource 用于表示提供这些 workout 数据给 HealthKit 的 app。当使用 HKSource.default() 时,其实就是值“这个 app”。sourcePredicate 告诉所有的 workout 的源 app 是哪里,也就是这个 app。
- 你的 Core Data 经验告诉你这些代码很熟悉。它提供了一种方法将 1 个或多个过滤器聚合成一个。最终结果是一个查询,抓取所有运动类型为 Other,源 app 是 PrancerciseTracker 的 workout。
- 在完成回调中,样本被解包成为一个 HKWorkout 对象数组。因为 HKSampleQuery 默认返回一个 HKSample 数组,你必须将它们转换成 HKWorkout,才能获得那些要用的属性,比如开始时间、结束时间、时长和消耗热量。
将 Workout 加载到 UI
刚刚写的是从 HealthKit 加载 workout 的方法。现在来将这些 workout 渲染到一个 table view 上。幸好,我已经为你做好了所有的准备工作!
打开 WorkoutsTableViewController.swift 看一眼。你会看到几样东西。
- 有一个可空的数组 workouts 用于保存 workout。它会被 loadPrancerciseWorkouts(completion:) 方法填充。
- 有一个 reloadWorkout() 方法。它在 view 开始显示时调用。每当你切换到这个屏幕,这些 workout 都会被重新加载。
要渲染数据到 UI 上,你需要加载 workout 并绑定 table view 的数据源。
将下列代码粘贴进 WorkoutsTableViewController.swift 的 reloadWorkouts() 方法。
WorkoutDataStore.loadPrancerciseWorkouts { (workouts,error) in
self.workouts = workouts
self.tableView.reloadData()
}
这段代码用你刚才编写的方法加载 workouts。一旦 workout 加载成功,会被赋给 WorkoutsTableViewController 的 workouts 属性。然后 reload UITableView。
你可能会注意仍然没有将 workouts 数据放到 table view 上。我们需要实现 table view 的数据源。
在 reloadWorkouts() 方法下面粘贴这些代码:
//MARK: UITableView DataSource
override func numberOfSections(in tableView: UITableView) -> Int {
return 1
}
override func tableView(_ tableView: UITableView,numberOfRowsInSection section: Int) -> Int {
guard let workouts = workouts else {
return 0
}
return workouts.count
}
这会告诉 table view 它的 section 数为 1,行数就等于从 HealthKit 中抓取的 workout 的数目。此外,如果没有抓到任何数据,table view 不显示任何行。
注意:你可能曾经看过这些方法的前面有时候也没有带 override 关键字。这里使用 override 关键字的原因是 WorkoutsTableViewController 是一个 UITableViewController 的子类。
UITableViewController 已经实现了所有的 UITableViewDataSource 方法。要自定义这些行为,你必须在默认实现的前面添加 override。
现在来说明单元格将如何显示。在两个数据源方法后面粘贴这个方法:
override func tableView(_ tableView: UITableView,cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let workouts = workouts else {
fatalError("CellForRowAtIndexPath should never get called if there are no workouts")
}
//1. Get a cell to display the workout in.
let cell = tableView.dequeueReusableCell(withIdentifier: prancerciseWorkoutCellID,for: indexPath)
//2. Get the workout corresponding to this row.
let workout = workouts[indexPath.row]
//3. Show the workout's start date in the label.
cell.textLabel?.text = dateFormatter.string(from: workout.startDate)
//4. Show the Calorie burn in the lower label.
if let caloriesBurned = workout.totalEnergyBurned?.doubleValue(for: HKUnit.kilocalorie()) {
let formattedCalories = String(format: "CaloriesBurned: %.2f",caloriesBurned)
cell.detailTextLabel?.text = formattedCalories
} else {
cell.detailTextLabel?.text = nil
}
return cell
}
OK! 这就是重点了。你从 table view 中 dequeue 了一个 cell,然后用对应的 workout 数据来渲染它。
这些代码大部分都在上一篇 HealthKit 教程中见过。唯一新鲜的东西就是对消耗的卡路里进行单位转换。
如果一个 workout 的 total energy burned 属性不为空,它会被 Kilocalorie 转换成一个 double。字符串会被格式化并显示在 cell 的 detailTextLabel。
Build & run。进入 Prancercise Workouts,点击 + 按钮,记录一个短暂的练习课程,点击 Done,然后看一下 table view。
这只是一次很短的练习,但伙计,我觉得浑身都在燃烧。这个新的锻炼方式会是 Crossfit 健身体系的竞争对手。
为 Workout 添加样本
到目前为止,我们都假设一个 Prancercise 练习是由单独的练习课构成。但如果你和我一样,你会发现 Prancercise 太累人了,你应该把它分成几个更短的步骤。
通过样本,你可以在同一个练习中记录多个练习时间。这是一种允许 HealthKit 更加细粒度观察你在练习课中做了些什么的办法。
你可以为 workout 添加所有类型的样本。如果你愿意,你可以添加距离、燃烧的卡路里、心跳等等。
因为 Prancercise 是一门舞蹈课程,这个 HealthKit 教程会把重心放在燃烧的卡路里样本上。
修改模型
这是对 Prancercise 的 workout 进行了一次全新的思考,因此我们需要修改模型。
替代使用单一的 PrancerciseWorkout 模型,应当用一个 workout interval 来表示一个短暂的过程。这样,单个的 PrancerciseWorkout 会变成一个包含了开始结束事件的 workout interval 的包装或容器。
打开 WorkOut.swift。将 PrancerciseWorkout 重新命名为 PrancerciseWorkoutInterval:
struct PrancerciseWorkoutInterval {
var start: Date
var end: Date
var duration: TimeInterval {
return end.timeIntervalSince(start)
}
var totalEnergyBurned: Double {
let prancerciseCaloriesPerHour: Double = 450
let hours: Double = duration/3600
let totalCalories = prancerciseCaloriesPerHour*hours
return totalCalories
}
}
你会看到什么都没有改变。曾经是一个完成的练习,现在变成了其中的一小段。
在 PrancerciseWorkoutInterval 结构下方粘贴代码:
PrancerciseWorkoutInterval:
struct PrancerciseWorkout {
var start: Date
var end: Date
var intervals: [PrancerciseWorkoutInterval]
init(with intervals: [PrancerciseWorkoutInterval]) {
self.start = intervals.first!.start
self.end = intervals.last!.end
self.intervals = intervals
}
}
现在,一个完整的 PrancerciseWorkout 由一个 PrancerciseWorkoutInterval 数组组成。练习的开始时间是数组中第一个对象的开始时间,结束时间是最后一个对象的截止时间。
这是一种很好表达方式,表示一个 workout 由 interval 组成,但却丢失了 duration 和 totalEneryBurned 信息。在你定义这些之前代码无法编译。
这个时候就要用函数式编程了。你可以用 reduce 方法来加总 PrancerciseWorkoutInterval 的 duration 和 totalEnergyBurned 属性。
在 PrancerciseWorkout 的 init(with:) 构造器后粘贴下列计算属性:
var totalEnergyBurned: Double {
return intervals.reduce(0,{ (result,interval) -> Double in return result+interval.totalEnergyBurned }) } var duration: TimeInterval { return intervals.reduce(0,interval) -> TimeInterval in return result+interval.duration }) }
Reduce 函数有一个开始值参数(这里是 0),以及一个带 result 参数的闭包,result 表示上一次计算的结果。这个闭包会在数组的每个对象上调用。
要计算出 totalEnergyBurned,reduce 函数的起始值指定为 0,然后将 0 和数组中第一个对象的 totalEnergyBurned 相加。然后将结果和数组中后面的值累加,以此类推。当它到达数组末尾,你会拿到整个 workout 的总的消耗的热量。
Reduce 是一个很好用的函数,用于灵活地加总数组数据。如果你想细致了解函数式编程及其优点,请参考这篇文章。
Workout 课程
模型的修改就快完了。打开 WorkoutSession.swift 看看。
WorkoutSession 用于保存和当前正在记录的 PrancerciseWorkout 相关的数据。因为你在 PrancerWorkout 中增加了一个 workout interval 的概念,WorkoutSession 需要在你开始、结束 Prancercise 课的时候添加新的 intervals 属性。
在 WorkoutSession 类中,找到 state 变量声明:
var state: WorkoutSessionState = .notStarted
添加一个新的 PrancerciseWorkoutInterval 数组:
var intervals = [PrancerciseWorkoutInterval]()
当你结束 Prancercise 课时,一个新的 interval 会添加到这个数组。让我们来新加一个函数完成这个。
在 WorkoutSession 的 clear() 方法下面添加这个方法:
private func addNewInterval() {
let interval = PrancerciseWorkoutInterval(start: startDate,end: endDate)
intervals.append(interval)
}
这个方法用 workout session 的开始结束事件创建了一个 PrancerciseWorkoutInterval。注意开始结束事件在一个 Pracercise Session 开始和结束时会被重新赋值。
你现在有添加 PrancerciseWorkoutInterval 的方法了,你只需要使用它就行了。
将 WorkoutSession 的 end() 方法替换为:
func end() { endDate = Date() addNewInterval() state = .finished }
你会看到,当设置完 session 的结束时间之后,会添加一个新的 interval 到 intervals 数组中。
注意,当 workout session 需要清空的时候,也要清空这个数组。
intervals.removeAll()
removeAll() 就是 remove all :]。
还有一个地方要改。completeWorkout 属性需要用 intervals 去创建一个新的 PrancerciseWorkout 对象。
将 completeWorkout 变量替换成:
var completeWorkout: PrancerciseWorkout? {
get {
guard state == .finished,intervals.count > 0 else {
return nil
}
return PrancerciseWorkout(with: intervals)
}
}
就这样了。因为这个属性是可空的,你只想在 WorkoutSession 结束并且至少记录有一个 interval 时才返回一个完整的 PrancerciseWorkout 对象。
如果一切无误,你的 WorkoutSessoin 类应该是这个样子:
class WorkoutSession {
private (set) var startDate: Date!
private (set) var endDate: Date!
var state: WorkoutSessionState = .notStarted
var intervals = [PrancerciseWorkoutInterval]()
func start() {
startDate = Date()
state = .active
}
func end() {
endDate = Date()
addNewInterval()
state = .finished
}
func clear() {
startDate = nil
endDate = nil
state = .notStarted
intervals.removeAll()
}
private func addNewInterval() {
let interval = PrancerciseWorkoutInterval(start: startDate,end: endDate)
intervals.append(interval)
}
var completeWorkout: PrancerciseWorkout? {
get {
guard state == .finished,intervals.count > 0 else {
return nil
}
return PrancerciseWorkout(with: intervals)
}
}
}
你会看到当 stop() 方法调用时,会用当前时间和停止时间创建一个 PrancerciseWorkoutInterval 并添加到数组中。当用户点击 Done 按钮保存 workout 时,这个代码会用这些记录下的 interval 生成一个完整的 PrancerciseWorkout 实体。
CreateWorkoutViewController 中的代码无需改变。按钮的 action 会调用同一个 start()、end() 和 clear() 方法。唯一区别是不再使用单个的 interval,而是由 WorkoutSession 生成和保存多个 interval。
在保存 workout 时添加样本
如果现在 build & run,它只会保存 PracerciseWorkout 到 Healthkit。而不会包含任何样本数据。你需要一个将 PrancerciseWorkoutInterval 对象转换成样本。
打开 WorkoutDataStore.swift,在 save(prancerciseWorkout:completion:) 方法后添加新方法:
private class func samples(for workout: PrancerciseWorkout) -> [HKSample] {
var samples = [HKSample]()
//1. Verify that the energy quantity type is still available to HealthKit.
guard let energyQuantityType = HKSampleType
.quantityType(forIdentifier:HKQuantityTypeIdentifier
.activeEnergyBurned) else {
fatalError("*** Energy Burned Type Not Available ***")
}
//2. Create a sample for each PrancerciseWorkoutInterval
for interval in workout.intervals {
let calorieQuantity = HKQuantity(unit: HKUnit.kilocalorie(),doubleValue: interval.totalEnergyBurned)
let sample = HKQuantitySample(type: energyQuantityType,quantity: calorieQuantity,start: interval.start,end: interval.end)
samples.append(sample)
}
return samples
}
你之前已经看过这些代码了!它和上一篇 HealthKit 教程中提交 bmi 样本是一样的。只不过是在循环中为每个这个 PrancerciseWorkout 关联的 PrancerciseWorkoutInterval 创建了一个样本。
现在你需要调整 save(prancerciseWorkout:completion:)方法,将这些样本和 workout 关联。
在声明部分找到这行:
let healthStore = HKHealthStore()
将之后的代码替换成:
let samples = self.samples(for: prancerciseWorkout)
healthStore.save(workout) { (success,error) in
guard error == nil else {
completion(false,error)
return
}
healthStore.add(samples,to: workout,completion: { (samples,error) in
guard error == nil else {
completion(false,error)
return
}
completion(true,nil)
})
}
这段代码用你的 PrancerciseWorkout 生成了一个样本数组。然后和之前一样将 workout 保存到 HealthKit。如果保存成功,添加这些样本。
在健康 app 中查看样本
Build & run。点击 Prancercise Workouts。然后点 + 按钮记录一次新的 PrancercisesWorkout。保存几个 PrancerciseSession 然后点击 Done 将它们以当个 PrancerciseWorkout 的形式保存到 HealthKit。
在 Prancercise Tracker 的 UI 上看不到任何改变,但相信我,用你的健康 app 可以加载出这些数据。
打开健康 app。点击 Activity。你会看到今天你的练习课的所有分解单元。
我记录了几个很短的 PrancerciseSession,因此 Activity 告诉我曾经锻炼了 1 分钟。那很好。我们已经制定了一定强度的 Prancercise 健身计划,因此每天都应该保持足够的身体运动。
点击 Workouts。下一个界面会列出你一天中的 workout 的构成。对你来说,你想看到所有这些数据是来自哪里的。
点击 Show All Data。这会在另一个界面显示今天所有的 workout 以及它们的源 app。
好。RW 的 logo 很清楚地说明这个 workout 来自于 Prancercise Tracker。
点击一个 workout 查看详情,滚动到 Workout Samples 节,点击 total active energy 单元格。
这里,你会看到和这个你刚才记录的 workout 关联的 active energy 样本列表。
点击某个样本,你会看到你的 PrancerciseSession 的开始结束时间。
很好。你已经编写了一个 app,不仅记录了一次 workout,也记录了这次 workout 中包含的interval。
接下来做什么?
希望这篇教程能让你了解基本的 HealthKit 知识以及如何在 app 中使用它们。要想学习更过 HealthKit,请阅读这些相关资源:
- HealthKit Framework Reference。
- WWDC 2014 会议视频: App Store Review Guidelines 中和 HealthKit 相关的内容。你必须确保你的 app 遵守这个指南。
看完这些文档和视频,你应该深入到 HealthKit 中更高级的主题并改进这个 app。例如,你可以添加新类型的样本或 workout,用 HKStatisticQuery 计算统计数字,或者用 HKObserverQuery 观察这些存储信息的改变。
希望你喜欢这篇 HealthKit 教程,有任何问题或看法,请在论坛中留言!