HealthKit в SwiftUI: HRV, SpO₂, сон и тренировки в 2026 — практическое руководство#
Когда я начинал строить MeteoHealth — трекер самочувствия, который читает пульс, вариабельность сердечного ритма, сатурацию кислорода, фазы сна и тренировки, — HealthKit казался просто «ещё одним фреймворком для чтения данных». На практике это не так. HealthKit — это база данных с собственной моделью прав доступа, единицами измерения, типами сэмплов и правилами фоновой синхронизации, и ошибиться в ней легко ровно там, где пользователь этого не простит: в приватных данных о здоровье.
В этой статье — рабочий подход к интеграции HealthKit в SwiftUI-приложение: от авторизации до нового State of Mind API, добавленного в iOS 18. Все примеры кода — реальные паттерны, которые я использую в MeteoHealth (за вычетом деталей UI и бизнес-логики).
Авторизация: HKHealthStore и типы данных#
HealthKit не даёт доступ «ко всему здоровью сразу» — приложение запрашивает конкретные типы данных: HKQuantityType для количественных величин (пульс, HRV, SpO₂), HKCategoryType для категориальных (фазы сна), и отдельные типы для тренировок и State of Mind.
Важный нюанс, который стоит понять сразу: HealthKit никогда не сообщает, было ли пользователем отклонено чтение конкретного типа. requestAuthorization подтверждает только факт показа диалога — а не то, что доступ дан. Поэтому код должен одинаково корректно работать и с данными, и без них.
Вот структура менеджера, с которой я начинаю почти любой health-фреймворк:
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)!. Force-unwrap в описании HealthKit был источником крашей годами — новый API избавляет от него полностью.
Типы, с которыми реально работает MeteoHealth:
| Данные | HealthKit-тип | Назначение в приложении |
|---|---|---|
| Пульс | HKQuantityType(.heartRate) | live-график, измерение камерой |
| HRV (SDNN) | HKQuantityType(.heartRateVariabilitySDNN) | индекс восстановления |
| SpO₂ | HKQuantityType(.oxygenSaturation) | ночные показатели с Apple Watch |
| Сон | HKCategoryType(.sleepAnalysis) | фазы REM/Core/Deep |
| Тренировки | HKObjectType.workoutType() | лента активности |
| Настроение | HKSampleType.stateOfMindType() | ежедневная рефлексия |
HRV и SpO₂: чтение через async-дескрипторы#
С приходом Swift Concurrency HealthKit получил дескрипторные API — HKStatisticsQueryDescriptor и HKSampleQueryDescriptor — которые заменяют вложенные completion-хендлеры на прямой await. Для 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())
}
}Практический момент по SpO₂: Apple Watch Series 6+ и новее измеряет её эпизодически, в основном ночью, и данные могут прийти с задержкой в несколько часов после синхронизации часов с телефоном. Если в UI показан «пустой» SpO₂ сразу после установки приложения — это норма, а не баг авторизации.
Фазы сна и тренировки#
HKCategoryType(.sleepAnalysis) возвращает сэмплы, значение которых — это rawValue, требующий преобразования в 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() — структура кода идентична примеру со SpO₂, меняется только тип сэмпла.
Background delivery: обновления без открытия приложения#
Если приложению нужно реагировать на новые данные — например, обновлять виджет пульса или пересчитывать индекс восстановления по утрам, — недостаточно читать данные только при открытии экрана. 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: completionHandler() в HKObserverQuery обязателен вызвать всегда, включая ошибки, иначе система со временем перестаёт присылать обновления этому наблюдателю вообще — без явного сообщения об этом в консоли. defer в примере выше — не стилистическая деталь, а страховка от этой проблемы.
Второй момент: enableBackgroundDelivery нужно переустанавливать при каждом запуске приложения, а не один раз при первой авторизации — состояние подписки не гарантированно переживает переустановки и обновления iOS.
State of Mind API: новый пласт данных о самочувствии#
iOS 18 добавил в HealthKit API для эмоционального состояния — HKStateOfMind, тот самый, что стоит за разделом «Состояние» в приложении Здоровье. Для MeteoHealth это оказалось естественным продолжением уже существующей записи State of Mind внутри приложения: вместо собственного изолированного хранилища настроения данные можно писать напрямую в 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 — тип с ограниченным чтением. Даже получив разрешение, приложение не может прочитать «сырые» записи, сделанные пользователем через системное приложение Здоровье, с той же детализацией, с какой видит их сам пользователь — Apple намеренно ограничивает доступ третьих сторон к этой чувствительной категории данных.
Что я вынес из продакшена MeteoHealth#
Несколько уроков, которые не находятся с первой попытки в документации:
- Не проверяйте
authorizationStatusкак признак «данные есть». Он отражает только факт запроса, а не факт наличия данных или согласия пользователя — HealthKit нарочно скрывает эту информацию по соображениям приватности. Единственный надёжный способ узнать, есть ли данные, — попробовать их прочитать. - Единицы измерения — отдельный источник багов. HRV возвращается в секундах (
HKUnit.secondUnit(with: .milli)), а не в «попугаях миллисекунд» интуитивно — перепутать unit легко, а результат будет тихо неверным, без исключения. - Камера-пульс — это не HealthKit-функция, а собственный алгоритм. Измерение пульса камерой в MeteoHealth построено на PPG-анализе видео с камеры, а результат уже отдельно записывается в HealthKit как обычный
HKQuantitySample— сам HealthKit камеру не трогает. - Тестируйте на реальных данных Apple Watch. Симулятор позволяет вставлять фейковые сэмплы, но реальные паттерны фрагментации (SpO₂ по 15 минут, HRV раз в несколько часов) видны только на живом устройстве с настоящими часами.
HealthKit — один из немногих Apple-фреймворков, где архитектурная аккуратность на старте окупается кратно: типы данных, авторизация и background delivery почти не меняются между релизами, а вот цена ошибки в приватных данных о здоровье — репутационная, а не просто техническая.


