HealthKit en SwiftUI: guía práctica de HRV, SpO2, sueño y entrenamientos#
Cuando empecé a construir MeteoHealth — una app de bienestar que lee frecuencia cardíaca, variabilidad del ritmo cardíaco, saturación de oxígeno, fases del sueño y entrenamientos — HealthKit me parecía "solo otro framework para leer datos". No lo es. HealthKit es una base de datos con su propio modelo de permisos, sistema de unidades, tipos de muestra y reglas de sincronización en segundo plano, y es fácil equivocarse justo donde los usuarios no perdonan errores: sus datos de salud privados.
Este artículo recorre un enfoque probado para integrar HealthKit en una app SwiftUI — desde la autorización hasta la API State of Mind introducida en iOS 18. Cada fragmento de código aquí es un patrón real que uso en MeteoHealth, sin los detalles de UI ni de lógica de negocio.
Autorización: HKHealthStore y tipos de datos#
HealthKit no concede "acceso a toda la salud" de una vez — la app solicita tipos específicos: HKQuantityType para valores cuantitativos (frecuencia cardíaca, HRV, SpO2), HKCategoryType para datos categóricos (fases del sueño), además de tipos dedicados para entrenamientos y State of Mind.
Un matiz que conviene interiorizar pronto: HealthKit nunca dice si el usuario realmente denegó el acceso de lectura a un tipo concreto. requestAuthorization solo confirma que se mostró la hoja de permisos — no que el acceso fue concedido. Tu código debe comportarse correctamente exista o no ese dato.
Este es el esqueleto del manager con el que empiezo casi cualquier framework de salud:
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
}Fíjate en la sintaxis moderna de tipos: HKQuantityType(.heartRate) en lugar del antiguo HKQuantityType.quantityType(forIdentifier: .heartRate)!. Ese force-unwrap fue durante años una fuente constante de crashes en HealthKit — el nuevo inicializador lo elimina por completo.
Los tipos con los que realmente trabaja MeteoHealth:
| Dato | Tipo de HealthKit | Uso en la app |
|---|---|---|
| Frecuencia cardíaca | HKQuantityType(.heartRate) | gráfico en vivo, medición por cámara |
| HRV (SDNN) | HKQuantityType(.heartRateVariabilitySDNN) | índice de recuperación |
| SpO2 | HKQuantityType(.oxygenSaturation) | lecturas nocturnas del Apple Watch |
| Sueño | HKCategoryType(.sleepAnalysis) | fases REM/Core/Deep |
| Entrenamientos | HKObjectType.workoutType() | feed de actividad |
| Estado de ánimo | HKSampleType.stateOfMindType() | reflexión diaria |
Leer HRV y SpO2 con descriptores async#
Con Swift Concurrency, HealthKit incorporó APIs basadas en descriptores — HKStatisticsQueryDescriptor y HKSampleQueryDescriptor — que sustituyen los completion handlers anidados por un simple await. Para MeteoHealth es la diferencia entre una pantalla de carga de 200 líneas y una de 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())
}
}Una nota práctica sobre el SpO2: el Apple Watch Series 6 en adelante lo mide de forma episódica, sobre todo por la noche, y las lecturas pueden llegar horas después de que el reloj se sincronice con el teléfono. Si el SpO2 aparece "vacío" justo después de instalar la app, es lo esperado — no un fallo de autorización.
Fases del sueño y entrenamientos#
HKCategoryType(.sleepAnalysis) devuelve muestras cuyo valor es un Int que hay que convertir en HKCategoryValueSleepAnalysis. Desde watchOS 9 / iOS 16, el Apple Watch reporta no solo "dormido/despierto", sino fases reales — 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 | Significado | Uso en MeteoHealth |
|---|---|---|
.asleepREM | Sueño REM | proporción de REM en el sueño total |
.asleepCore | Sueño ligero | duración base del sueño |
.asleepDeep | Sueño profundo | indicador de calidad de recuperación |
.awake | Despertar durante la noche | conteo de interrupciones nocturnas |
.inBed | En la cama, no necesariamente dormido | distinto del sueño real desde iOS 18 |
Los entrenamientos se leen con el mismo patrón de descriptor, usando un predicado .workout() en HKSampleQueryDescriptor — la forma del código es idéntica al ejemplo de SpO2, solo cambia el tipo de muestra.
Background delivery: actualizaciones sin abrir la app#
Si tu app necesita reaccionar a datos nuevos — por ejemplo, actualizar un widget de frecuencia cardíaca o recalcular un índice de recuperación cada mañana — no basta con leer datos solo cuando se abre una pantalla. HealthKit puede "despertar" tu app mediante enableBackgroundDelivery y 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.
}
}Aquí hay una trampa en la que caí yo mismo al principio del desarrollo de MeteoHealth: completionHandler() en HKObserverQuery debe llamarse siempre, incluso ante errores, o el sistema deja de entregar actualizaciones a ese observador con el tiempo — sin ningún mensaje visible en la consola. El defer de arriba no es un detalle de estilo; es un seguro contra ese bug exacto.
Segundo punto: enableBackgroundDelivery hay que restablecerlo en cada lanzamiento de la app, no solo una vez en la primera autorización — el estado de la suscripción no está garantizado tras reinstalaciones y actualizaciones de iOS.
State of Mind API: una nueva capa de datos de bienestar#
iOS 18 añadió a HealthKit una API de estado emocional — HKStateOfMind, la misma que hay detrás de la sección "Estado de ánimo" de la app Salud. Para MeteoHealth resultó ser una extensión natural del registro de ánimo que la app ya hacía: en lugar de un almacén de ánimo separado y aislado, esos datos ahora pueden escribirse directamente en HealthKit, donde pasan a formar parte del cuadro general de salud del usuario y, con su permiso, quedan disponibles para otras apps.
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 es una escala de -1 (muy desagradable) a +1 (muy agradable), labels son etiquetas concretas como .calm, .stressed, .grateful, y associations describe el contexto (trabajo, familia, salud). Restricción importante: HKStateOfMind tiene acceso de lectura limitado. Incluso con permiso concedido, una app no puede leer las entradas registradas por el usuario a través de la app Salud del sistema con la misma fidelidad con que las ve el propio usuario — Apple restringe deliberadamente el acceso de terceros a esta categoría de datos tan sensible.
Lo que realmente me enseñó llevar MeteoHealth a producción#
Algunas lecciones que no son obvias solo con la documentación:
- No trates
authorizationStatuscomo prueba de que hay datos. Solo refleja si se hizo una solicitud, no si el usuario concedió realmente el acceso — HealthKit oculta ese detalle a propósito por privacidad. La única forma fiable de saber si hay datos es intentar leerlos. - Las unidades son una fuente de bugs por sí solas. El HRV llega en segundos (
HKUnit.secondUnit(with: .milli)), no intuitivamente ya en "milisegundos". Confundir la unidad es fácil, y el resultado falla en silencio, sin lanzar ninguna excepción. - El pulso por cámara no es una función de HealthKit, es un algoritmo propio. La medición de pulso por cámara en MeteoHealth se basa en análisis PPG del vídeo de la cámara, y el resultado se escribe después en HealthKit por separado como un
HKQuantitySamplenormal. HealthKit en sí nunca toca la cámara. - Prueba con datos reales de Apple Watch. El simulador permite insertar muestras falsas, pero los patrones reales de fragmentación — SpO2 en ráfagas de 15 minutos, HRV unas pocas veces al día — solo se ven en un dispositivo real emparejado con un reloj real.
HealthKit es uno de los pocos frameworks de Apple donde la disciplina arquitectónica desde el principio se paga muchas veces: los tipos de datos, la autorización y el background delivery apenas cambian entre versiones, mientras que el coste de un error con datos de salud privados es reputacional, no solo técnico.


