利用 HealthKit 体能训练区间提供健身洞察
Deliver workout insights with HealthKit workout zones
2026年6月10日
一句话判断
这场 Session 最值得关注的一件事是:HealthKit 终于把“计算和存储心率/功率区间”这个脏活累活彻底接管了,健身 App 开发者再也不用自己手写区间统计算法和状态机了。
这场 Session 讲了什么
以前做健身 App,想给用户看个“心率区间分布图”,你得自己搞一套公式(比如 Karvonen 公式),自己监听心率样本流,自己处理传感器断连时的插值,还得自己把“在 Zone 3 呆了多久”存下来。现在 iOS 27 和 watchOS 27 的 HealthKit 直接内置了 Workout Zones(体能训练区间)支持。
你只需要调用 zoneGroupsByType 就能直接拿到系统算好的区间配置和每个区间的停留时长。如果是实时训练,实现 HKLiveWorkoutDelegate 的新方法 didUpdateWorkoutZone,系统会在用户跨区时直接推回调给你。这对所有做运动记录、骑行、跑步的 App 影响巨大,直接砍掉了成百上千行的区间计算和状态管理代码。
值得深挖的点
系统级接管与自定义区间的博弈
Apple 这次把区间计算收归系统,最大的好处是数据标准化。以前 A App 算出来的 Zone 3 和 B App 算出来的 Zone 3 可能完全不同,因为大家用的公式和静息心率取值不一样。现在 HealthKit 默认读取用户在“健康”App 里设置的 Preferred zones(偏好区间),这些区间基于用户的年龄和静息心率自动计算,且跨设备同步。
Apple 也没把路堵死。对于有自己独家算法的专业 App(比如基于乳酸阈值心率算区间的 App),可以通过 HKWorkoutZoneConfiguration 传入自定义的边界值(zoneBoundaries)。这里有个细节:自定义配置是 scoped to individual workouts(仅作用于单次训练)的,不会持久化到 HealthKit 里覆盖用户的系统偏好。这个 Trade-off 做得很聪明:既保证了大众用户数据的一致性,又给硬核玩家留了后门。坑在于,如果你用了自定义区间,一定要在 beginCollection 之前调用 setCustomZoneConfiguration,否则系统会用默认偏好去算,你的专业算法就白写了。
实时区间回调:从“轮询样本”到“状态机驱动”
在旧方案里,想实现“用户进入 Zone 4 时震动提醒”,你得监听 HKAnchoredObjectQuery 拿实时心率,然后自己写个滑动窗口或者低通滤波器平滑数据,再判断是否越界。这不仅费电,而且延迟高。
新的 didUpdateWorkoutZone 方法直接把这个过程变成了状态机驱动。系统底层已经做好了数据平滑和区间判定,只有当状态真正发生跃迁(比如从 Zone 3 跳到 Zone 4)时,才会触发回调。回调里直接带着 currentZoneDuration(当前区间累计时长)和 zoneGroup(整体数据)。这意味着你可以直接把 UI 绑定到这个回调上,做实时高亮或者 Taptic Engine 震动,代码量从几百行缩减到十几行。唯一要注意的是,这个回调依赖于底层样本的摄入频率,如果 Apple Watch 传感器被遮挡导致心率样本断流,回调也会停滞,UI 上最好加个“信号丢失”的兜底状态。
代码片段
1. 读取已完成训练的区间数据
场景:训练结束后,在 Summary 页面渲染心率区间柱状图。
// 从已完成的 HKWorkout 中读取心率区间数据
if let heartRateZoneGroup = workout.zoneGroupsByType?[HKQuantityType(.heartRate)] {
// 获取区间总数和每个区间的停留时长
let zoneDurations = heartRateZoneGroup.zoneDurations.map { $0.duration }
// 渲染 UI,比如传给 SwiftUI 的 Chart
updateChart(zoneCount: heartRateZoneGroup.configuration.zones.count,
durations: zoneDurations)
}
坑:zoneGroupsByType 是个字典,如果用户没戴手表或者传感器全程没数据,这个字典里是没有 .heartRate 这个 key 的,直接 force unwrap 会 crash,一定要用 if let。
2. 处理实时区间变更
场景:训练进行中,当用户心率突破阈值进入新区域时,触发手表震动和 UI 高亮。
func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder,
didUpdateWorkoutZone zoneUpdate: HKLiveWorkoutZoneUpdate) {
// 确保有区间数据
guard let zoneGroup = zoneUpdate.zoneGroup else { return }
// 获取当前所在的区间索引
if let currentIndex = zoneUpdate.currentZoneDuration?.zone.index {
// 更新 UI 高亮当前区间
highlightActiveZone(index: currentIndex)
// 如果是从低区间进入高区间(比如 Zone 3 -> Zone 4),触发震动
if let previousIndex = zoneUpdate.previousZone?.index, currentIndex > previousIndex {
triggerHapticFeedback()
}
}
}
坑:previousZone 在训练刚开始、第一次进入某个区间时可能是 nil,做前后对比判断时一定要先解包,别直接拿 previousIndex 去比大小。
3. 为单次训练设置自定义区间
场景:你的 App 有自己的一套基于 FTP(功能阈值功率)或乳酸阈值的算法,需要覆盖系统的默认区间。
// 定义你的心率区间边界值(比如 120, 140, 160, 180 bpm)
let bpmUnit = HKUnit.count().unitDivided(by: HKUnit.minute())
let boundaries = [120.0, 140.0, 160.0, 180.0].map {
HKQuantity(unit: bpmUnit, doubleValue: $0)
}
let heartRateType = HKQuantityType(.heartRate)
// 创建自定义区间配置
let customConfig = try HKWorkoutZoneConfiguration(
quantityType: heartRateType,
zoneBoundaries: boundaries
)
// 必须在 beginCollection 之前设置!
try await builder.setCustomZoneConfiguration(customConfig, for: heartRateType)
// 开始收集数据
try await builder.beginCollection(at: Date())
坑:zoneBoundaries 数组里的值必须是严格递增的,且不能包含最大/最小值(系统会自动把 0 到第一个边界作为 Zone 1,最后一个边界到无穷大作为最高 Zone)。传乱序数组会直接抛异常。
最佳实践
对于已有项目,如果你之前是自己算区间的,建议加个 Feature Flag 灰度切换到 HealthKit 原生区间。切换时,对比一下系统算出来的 zoneDurations 和你自己算的误差。如果误差在 5% 以内,直接干掉你的旧代码;如果误差大,检查是不是你的静息心率取值逻辑和系统不一致。
对于新项目,无脑使用 zoneGroupsByType 和 didUpdateWorkoutZone。别再去监听 HKQuantityTypeIdentifierHeartRate 自己写定时器累加了。把省下来的精力花在优化 UI 动画和训练后的洞察分析(Insights)上。
实战中最容易踩的坑是权限问题。读取区间数据需要申请 HKWorkoutType 和 HKQuantityType(.heartRate) 的读取权限。很多开发者只申请了 Workout 权限,结果 zoneGroupsByType 拿出来的字典永远是空的,排查半天以为是 API 的 Bug。
还有什么值得关注
- 骑行功率区间(Cycling Power Zones)原生支持:和心率区间共用一套 API 结构,只要把
HKQuantityType换成.cyclingPower,骑行 App 开发者可以直接白嫖这套逻辑。 - 偏好区间跨设备同步:用户在 iPhone 健康 App 里手动微调的区间阈值,会通过 iCloud 同步到 Apple Watch,App 不需要自己做配置下发。
- 区间来源溯源:
HKWorkoutZoneConfiguration里的source枚举可以告诉你这个区间是系统自动算的、用户手改的、还是你的 App 自定义的,方便在 UI 上给用户做差异化提示。