HealthKit集成SwiftUI实战:HRV、SpO2、睡眠与运动数据#
我在开始开发MeteoHealth——一款读取心率、心率变异性(HRV)、血氧饱和度、睡眠阶段和运动记录的健康状态追踪应用——的时候,曾经以为HealthKit不过是"又一个读取数据的框架"。事实并非如此。HealthKit是一个拥有自己权限模型、单位体系、样本类型和后台同步规则的数据库,而它最容易出错的地方,恰恰是用户绝不会原谅的地方:他们私密的健康数据。
这篇文章讲的是在SwiftUI应用中集成HealthKit的一套可行方法——从授权流程一直到iOS 18新增的State of Mind API。文中的每一段代码,都是我在MeteoHealth中实际使用的模式,只是去掉了UI和业务逻辑的细节。
授权:HKHealthStore与数据类型#
HealthKit不会一次性授予"整个健康数据的访问权限"——应用需要针对具体类型发起请求:数量型数据(心率、HRV、SpO2)用HKQuantityType,分类型数据(睡眠阶段)用HKCategoryType,运动记录和State of Mind则各有专门的类型。
有一点值得尽早理解清楚:HealthKit从不会告诉你用户是否真的拒绝了某个类型的读取权限。requestAuthorization确认的只是授权弹窗被展示过,而不是访问权限被授予。因此代码必须在有数据和没有数据两种情况下都能正确工作。
以下是我在几乎所有健康类框架中都会写的管理类骨架:
import HealthKit
final class HealthKitManager {
static let shared = HealthKitManager()
private let healthStore = HKHealthStore()
// Types MeteoHealth reads: heart rate, HRV, SpO2, respiratory rate,
// sleep stages and workouts.
private let readTypes: Set<HKObjectType> = [
HKQuantityType(.heartRate),
HKQuantityType(.heartRateVariabilitySDNN),
HKQuantityType(.oxygenSaturation),
HKQuantityType(.respiratoryRate),
HKCategoryType(.sleepAnalysis),
HKObjectType.workoutType(),
HKSampleType.stateOfMindType()
]
// Types MeteoHealth writes: water, caffeine, heart rate from the
// camera-based PPG measurement, and State of Mind entries.
private let writeTypes: Set<HKSampleType> = [
HKQuantityType(.dietaryWater),
HKQuantityType(.dietaryCaffeine),
HKQuantityType(.heartRate),
HKSampleType.stateOfMindType()
]
func requestAuthorization() async throws {
guard HKHealthStore.isHealthDataAvailable() else {
throw HealthKitError.notAvailable
}
try await healthStore.requestAuthorization(toShare: writeTypes, read: readTypes)
}
}
enum HealthKitError: Error {
case notAvailable
}请留意这里更现代的类型写法:HKQuantityType(.heartRate)取代了旧式的HKQuantityType.quantityType(forIdentifier: .heartRate)!。那个强制解包多年来一直是HealthKit崩溃的常见来源,而新的初始化方法彻底消除了这个问题。
下面是MeteoHealth实际使用的数据类型:
| 数据 | HealthKit类型 | 应用中的用途 |
|---|---|---|
| 心率 | HKQuantityType(.heartRate) | 实时图表、摄像头测量 |
| HRV(SDNN) | HKQuantityType(.heartRateVariabilitySDNN) | 恢复指数 |
| 血氧SpO2 | HKQuantityType(.oxygenSaturation) | Apple Watch夜间读数 |
| 睡眠 | HKCategoryType(.sleepAnalysis) | REM/Core/Deep阶段 |
| 运动 | HKObjectType.workoutType() | 活动动态 |
| 情绪 | HKSampleType.stateOfMindType() | 每日心情记录 |
用异步描述符读取HRV与SpO2#
随着Swift Concurrency的引入,HealthKit获得了基于描述符的API——HKStatisticsQueryDescriptor和HKSampleQueryDescriptor——用一个简单的await取代了嵌套的completion handler。对MeteoHealth来说,这意味着从200行的加载屏幕代码,缩减到20行。
extension HealthKitManager {
/// Average HRV (SDNN, ms) for the last 24 hours.
func averageHRV(daysBack: Int = 1) async throws -> Double? {
let hrvType = HKQuantityType(.heartRateVariabilitySDNN)
let start = Calendar.current.date(byAdding: .day, value: -daysBack, to: Date())!
let predicate = HKQuery.predicateForSamples(withStart: start, end: Date())
let descriptor = HKStatisticsQueryDescriptor(
predicate: HKSamplePredicate.quantitySample(type: hrvType, predicate: predicate),
options: .discreteAverage
)
let statistics = try await descriptor.result(for: healthStore)
let unit = HKUnit.secondUnit(with: .milli)
return statistics?.averageQuantity()?.doubleValue(for: unit)
}
/// Latest blood oxygen saturation (SpO2) reading, as a percentage.
func latestSpO2() async throws -> Double? {
let spo2Type = HKQuantityType(.oxygenSaturation)
let descriptor = HKSampleQueryDescriptor(
predicates: [.quantitySample(type: spo2Type)],
sortDescriptors: [SortDescriptor(\.endDate, order: .reverse)],
limit: 1
)
let samples = try await descriptor.result(for: healthStore)
return samples.first?.quantity.doubleValue(for: .percent())
}
}关于SpO2有一点实际经验值得分享:Apple Watch Series 6及更新机型主要在夜间进行间歇式测量,数据可能在手表与手机同步后延迟数小时才到达。如果应用刚安装完SpO2显示"空白",这是正常现象,并不是授权流程出了问题。
睡眠阶段与运动记录#
HKCategoryType(.sleepAnalysis)返回的样本值是一个原始Int,需要转换为HKCategoryValueSleepAnalysis。从watchOS 9 / iOS 16开始,Apple Watch报告的不再只是"睡着/清醒",而是真正的睡眠阶段——REM、Core、Deep:
extension HealthKitManager {
struct SleepStage {
let stage: HKCategoryValueSleepAnalysis
let start: Date
let end: Date
}
/// Sleep stages for the last night, sorted chronologically.
func lastNightSleepStages() async throws -> [SleepStage] {
let sleepType = HKCategoryType(.sleepAnalysis)
let start = Calendar.current.date(byAdding: .hour, value: -18, to: Date())!
let predicate = HKQuery.predicateForSamples(withStart: start, end: Date())
let descriptor = HKSampleQueryDescriptor(
predicates: [.categorySample(type: sleepType, predicate: predicate)],
sortDescriptors: [SortDescriptor(\.startDate, order: .forward)]
)
let samples = try await descriptor.result(for: healthStore)
return samples.compactMap { sample in
guard let value = HKCategoryValueSleepAnalysis(rawValue: sample.value) else {
return nil
}
return SleepStage(stage: value, start: sample.startDate, end: sample.endDate)
}
}
}HKCategoryValueSleepAnalysis | 含义 | MeteoHealth中的用途 |
|---|---|---|
.asleepREM | 快速眼动睡眠 | REM占总睡眠的比例 |
.asleepCore | 浅睡眠 | 基础睡眠时长 |
.asleepDeep | 深睡眠 | 恢复质量指标 |
.awake | 夜间清醒 | 统计夜间中断次数 |
.inBed | 在床上但未必入睡 | iOS 18起与真实睡眠区分开 |
运动记录采用相同的描述符模式读取,只需在HKSampleQueryDescriptor上使用.workout()谓词——代码结构与SpO2示例完全一致,只是样本类型不同。
后台数据传递:无需打开应用也能更新#
如果应用需要对新数据做出反应——比如刷新心率小组件,或者每天早上重新计算恢复指数——仅在打开屏幕时读取数据是不够的。HealthKit可以通过enableBackgroundDelivery和HKObserverQuery来"唤醒"应用。
extension HealthKitManager {
/// Subscribes to background updates for heart rate and enables
/// hourly wake-ups even when MeteoHealth is not in the foreground.
func enableBackgroundDelivery() async throws {
let heartRateType = HKQuantityType(.heartRate)
try await healthStore.enableBackgroundDelivery(
for: heartRateType,
frequency: .hourly
)
let query = HKObserverQuery(sampleType: heartRateType, predicate: nil) { _, completionHandler, error in
defer { completionHandler() }
guard error == nil else { return }
Task {
try? await self.syncLatestHeartRate()
}
}
healthStore.execute(query)
}
private func syncLatestHeartRate() async throws {
// Fetch and cache the newest sample, refresh widgets, etc.
}
}这里有一个坑,是我自己在MeteoHealth开发早期踩过的:HKObserverQuery中的completionHandler()必须每次都调用,包括出错的时候,否则系统会逐渐停止向这个观察者投递更新——而且控制台不会有任何提示信息。上面代码中的defer不是风格上的讲究,而是专门针对这个问题的保险。
第二点:enableBackgroundDelivery需要在每次应用启动时重新设置,而不是只在首次授权时设置一次——订阅状态并不保证能在重装应用或iOS系统更新后依然保留。
State of Mind API:全新的健康状态数据层#
iOS 18为HealthKit新增了一个情绪状态API——HKStateOfMind,也就是"健康"应用中"心理状态"板块背后的机制。对MeteoHealth来说,这恰好是应用原本就有的心情记录功能的一个自然延伸:不再需要独立、孤立的心情数据存储,这些数据现在可以直接写入HealthKit,成为用户整体健康画像的一部分,并且在用户许可的情况下,也能被其他应用访问。
extension HealthKitManager {
/// Saves a user-reported mood entry as a State of Mind sample.
func logStateOfMind(valence: Double, labels: [HKStateOfMind.Label]) async throws {
let sample = HKStateOfMind(
date: Date(),
kind: .momentaryEmotion,
valence: valence,
labels: labels,
associations: [.currentEvents]
)
try await healthStore.save(sample)
}
}valence是一个从-1(非常不愉快)到+1(非常愉快)的量表,labels是具体的标签,比如.calm、.stressed、.grateful,associations则描述了情境(工作、家庭、健康等)。有一个重要限制:HKStateOfMind的读取权限是受限的。即使获得了授权,应用也无法以用户自己在系统"健康"应用中看到的那种精细程度,去读取用户通过系统应用记录的原始条目——苹果有意限制第三方应用访问这一敏感数据类别。
MeteoHealth上线后我真正学到的东西#
和其他一些苹果框架相比,HealthKit的集成工作本身出奇地稳定:系统版本更新很少大幅改变API的外观,一旦数据流设计得当,往往可以长期沿用下去。但也有一些坑,只有真正把应用发布到商店之后才会显现出来。以下几条经验,光看文档是不容易发现的:
- 不要把
authorizationStatus当作数据存在的证明。 它反映的只是是否发起过请求,而不是用户是否真的授予了访问权限——出于隐私考虑,HealthKit故意隐藏了这个细节。判断数据是否存在的唯一可靠方法,就是实际尝试读取一次。 - 单位本身就是一个bug来源。 HRV返回的单位是秒(
HKUnit.secondUnit(with: .milli)),而不是直觉上以为的"已经是毫秒"。把单位弄混很容易,而且结果会悄悄出错,不会抛出任何异常。 - 摄像头测心率不是HealthKit的功能,而是自研算法。 MeteoHealth的摄像头脉搏测量基于对摄像头视频的PPG分析,测量结果之后再作为普通的
HKQuantitySample单独写入HealthKit。HealthKit本身完全不涉及摄像头。 - 一定要用真实的Apple Watch数据测试。 模拟器可以插入虚假样本,但真实的数据碎片化模式——比如SpO2每15分钟一次的间歇读数、HRV一天只记录几次——只有在配对了真实手表的真机上才能观察到。
HealthKit是苹果为数不多的、前期架构上的严谨会带来数倍回报的框架之一:数据类型、授权流程和后台数据传递在各个系统版本之间几乎不变,而处理私密健康数据出错的代价,从来不只是技术上的,更是信誉上的。


