HealthKit×SwiftUI実践ガイド:HRV・SpO2・睡眠・運動の統合#
MeteoHealth——心拍数、心拍変動(HRV)、血中酸素飽和度、睡眠ステージ、ワークアウトを読み取るウェルビーイングトラッカー——を作り始めたとき、HealthKitは「ただのデータ読み取りフレームワーク」くらいに思っていました。実際は違います。HealthKitは独自の権限モデル、単位系、サンプルタイプ、バックグラウンド同期ルールを持つデータベースであり、ユーザーが絶対に許してくれない領域——プライベートな健康データ——でこそミスをしやすいのです。
この記事では、SwiftUIアプリにHealthKitを統合する実践的なアプローチを、認可からiOS 18で追加されたState of Mind APIまで一通り扱います。掲載するコードは、UIやビジネスロジックの詳細を除いた、MeteoHealthで実際に使っているパターンそのものです。
認可: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)!に代わるものです。このforce-unwrapは長年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はHKStatisticsQueryDescriptorやHKSampleQueryDescriptorといったディスクリプタベースのAPIを獲得し、ネストしたcompletion handlerをシンプルな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())
}
}SpO2に関する実践的な注意点があります。Apple Watch Series 6以降は主に夜間に断続的に計測しており、データは腕時計とiPhoneが同期してから数時間遅れて届くことがあります。アプリをインストールした直後に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は読み取りアクセスが制限された型です。許可を得ていても、システムのヘルスケアアプリを通じてユーザーが記録した生のエントリを、ユーザー自身が見るのと同じ粒度で読み取ることはできません。Appleはこのセンシティブなデータカテゴリへのサードパーティアクセスを意図的に制限しています。
MeteoHealthを本番リリースして学んだこと#
HealthKitの統合作業そのものは、他のAppleフレームワークに比べて驚くほど安定しています。年々のOSアップデートでAPIの見た目が大きく変わることは少なく、一度きちんと設計したデータフローは長く使い回せます。とはいえ、実際にストアに公開してからでないと見えてこない落とし穴も確実に存在します。以下は、ドキュメントだけでは気づきにくい教訓をいくつか挙げたものです。
authorizationStatusをデータ存在の証拠として扱わないこと。 これはリクエストが行われたかどうかを反映するだけで、ユーザーが実際にアクセスを許可したかは分かりません——HealthKitはプライバシー上の理由でその情報を意図的に隠しています。データがあるかどうかを確認する唯一確実な方法は、実際に読み取ってみることです。- 単位はそれ自体がバグの温床になります。 HRVは秒単位(
HKUnit.secondUnit(with: .milli))で返ってきますが、直感的に「もうミリ秒」と思い込みがちです。単位を取り違えるのは簡単で、しかも例外を投げずに静かに間違った結果を返します。 - カメラによる脈拍計測はHealthKitの機能ではなく、独自アルゴリズムです。 MeteoHealthのカメラ脈拍計測はカメラ映像のPPG解析に基づいており、その結果をあとから通常の
HKQuantitySampleとして個別にHealthKitへ書き込んでいます。HealthKit自体はカメラに一切関与しません。 - 実際のApple Watchデータでテストすること。 シミュレータでは偽のサンプルを挿入できますが、SpO2が15分単位で断続的に届く、HRVが1日に数回しか記録されないといった現実の断片化パターンは、実機と実際の腕時計を組み合わせて初めて見えてきます。
HealthKitは、最初の段階でのアーキテクチャ上の丁寧さが何倍にもなって返ってくる、数少ないAppleフレームワークの一つです。データ型、認可、バックグラウンド配信はリリースをまたいでもほとんど変わりませんが、プライベートな健康データを扱う上でのミスの代償は、単なる技術的なものではなく評判に関わるものです。


