HealthKit API 완전 정복 — Workout 트래킹 관점에서 본 API 비교
iOS 러닝 앱을 만들면서 HealthKit API 이름이 너무 비슷해 길을 잃었다. HKWorkoutSession, HKWorkoutBuilder, HKLiveWorkoutBuilder, HKAnchoredObjectQuery, HKSampleQuery... 한 번 정리해두지 않으면 다음에 또 헤맬 것 같아 메모로 남겼다.
HealthKit이 OS의 데이터 창고라는 큰 그림
HealthKit은 iOS에 내장된 건강·운동 데이터의 중앙 저장소다. 앱은 데이터를 직접 들고 있지 않고, OS에 위임해 두고 권한을 받아 읽고 쓰는 구조다. Watch, AirPods, iPhone에서 측정된 값이 한 곳에 모이고, 그 위에 서드파티 앱이 구독하는 모양으로 동작한다.
HealthKit API는 크게 세 갈래로 갈린다. 세션 관리, 기록 작성, 데이터 조회. 이 분류만 머리에 넣어도 80%는 정리됐다.
데이터 타입부터 잡고 가야 헷갈리지 않는다
API보다 먼저 타입 체계를 잡아두지 않으면 코드를 봐도 무엇이 무엇인지 분간이 안 됐다. HealthKit의 데이터 타입은 HKObject를 루트로 한 계층 구조를 갖는다.
HKObjectType은 데이터의 "종류"를 정의하는 타입이고(.heartRate, .distanceWalkingRunning 같은 식별자가 여기 붙는다), HKQuantity는 값과 단위를 묶은 표현이다. 단위는 HKUnit.count().unitDivided(by: .minute()) 같은 식으로 조합해서 만든다.
워크아웃에서 자주 만지게 되는 수치 타입
HKQuantityTypeIdentifier는 어떤 수치를 다룰지를 지정하는 식별자다. 러닝 트래킹에서는 다음 정도가 자주 등장했다.
extension HKQuantityTypeIdentifier {
static let heartRate // 심박수 (count/min)
static let distanceWalkingRunning // 걷기/달리기 거리 (meter)
static let distanceCycling // 자전거 거리 (meter)
static let distanceSwimming // 수영 거리 (meter)
static let activeEnergyBurned // 활동 칼로리 (kcal)
static let basalEnergyBurned // 기초 대사 칼로리 (kcal)
static let stepCount // 걸음 수 (count)
static let flightsClimbed // 오른 층수 (count)
static let vo2Max // 최대산소섭취량 (ml/kg/min)
static let runningPower // 러닝 파워 (Watt, iOS 16+)
static let runningSpeed // 러닝 속도 (m/s, iOS 16+)
static let runningStrideLength // 보폭 (meter, iOS 16+)
static let runningGroundContactTime // 접지 시간 (ms, iOS 16+)
static let runningVerticalOscillation // 수직 진동 (cm, iOS 16+)
}단위 조합은 처음 봤을 때 가장 헷갈렸다. HR은 단순한 count가 아니라 분당 카운트(count/minute)고, 페이스는 미터당 분(min/m)을 다시 킬로 단위로 묶는 식이라 표기가 길어졌다.
HKUnit.count().unitDivided(by: .minute())
HKUnit.meter()
HKUnit.meterUnit(with: .kilo)
HKUnit.kilocalorie()
HKUnit.meter().unitDivided(by: .second())
HKUnit.minute().unitDivided(by: .meter().unitMultiplied(by: .kilo))수치 샘플 하나는 HKQuantitySample로 표현된다. 어떤 종류인지(quantityType), 값과 단위(quantity), 측정 구간(startDate/endDate), 어느 기기에서 측정됐고 어느 앱이 기록했는지까지 함께 묶인다.
class HKQuantitySample: HKSample {
let quantityType: HKQuantityType
let quantity: HKQuantity
let startDate: Date
let endDate: Date
let device: HKDevice?
let sourceRevision: HKSourceRevision
let metadata: [String: Any]?
}심박수 145 BPM 샘플을 직접 만들어 보면 이렇게 된다.
let hrType = HKQuantityType.quantityType(forIdentifier: .heartRate)!
let bpmUnit = HKUnit.count().unitDivided(by: .minute())
let quantity = HKQuantity(unit: bpmUnit, doubleValue: 145.0)
let sample = HKQuantitySample(
type: hrType,
quantity: quantity,
start: Date(),
end: Date()
)HKWorkout과 빌더 설정
HKWorkout은 운동 기록 자체를 가리키는 타입이다. iOS 17부터 HKWorkout을 직접 생성자로 만들던 방식이 deprecated 되어, 이제는 HKWorkoutBuilder를 거쳐서만 만들어야 했다.
class HKWorkout: HKSample {
let workoutActivityType: HKWorkoutActivityType
let duration: TimeInterval
let totalDistance: HKQuantity? // (deprecated iOS 17+)
let totalEnergyBurned: HKQuantity? // (deprecated iOS 17+)
let workoutEvents: [HKWorkoutEvent]?
let workoutActivities: [HKWorkoutActivity] // iOS 17+ 멀티스포츠
}빌더에 무엇을 만들지 알려주는 설정은 HKWorkoutConfiguration으로 넘긴다.
let config = HKWorkoutConfiguration()
config.activityType = .running
config.locationType = .outdoor
config.swimmingLocationType = .pool
config.lapLength = HKQuantity(unit: .meter(), doubleValue: 50)운동 중 발생하는 일시정지·재개·랩은 별개의 이벤트로 빌더에 더한다.
let pauseEvent = HKWorkoutEvent(
type: .pause,
dateInterval: DateInterval(start: pausedAt, duration: 0),
metadata: nil
)권한 상태 enum의 함정
권한 상태를 표현하는 HKAuthorizationStatus는 세 가지뿐이다.
enum HKAuthorizationStatus {
case notDetermined
case sharingDenied
case sharingAuthorized
}여기서 한 번 크게 헤맸다. 이 값이 정확하게 알려주는 것은 쓰기 권한뿐이고, 읽기 권한의 진짜 상태는 프라이버시 보호 때문에 알 수 없도록 막혀 있었다. 사용자가 읽기를 거부해도 .sharingAuthorized로 보이는 식이다.
워크아웃 API 3종을 한 번에 비교
세션 관리와 기록 작성 쪽 API가 가장 이름이 비슷해서 헷갈렸다. 셋을 한 번에 늘어놓고 차이를 짚었다.
HKWorkoutSession은 Watch에서만 "지금 운동 중"을 OS에 선언한다
HKWorkoutSession은 watchOS 전용이다. "지금 운동 중"이라고 OS에 선언하면 Watch 화면 빨간 테두리, 백그라운드 권한, 센서 sampling rate가 자동으로 부여됐다. iOS 타겟에서는 import 자체가 안 됐다.
#if os(watchOS)
let session = try HKWorkoutSession(
healthStore: store,
configuration: config
)
session.delegate = self
session.startActivity(with: Date())
session.pause()
session.resume()
session.end()
#endif내부 속성은 헬스 스토어, 설정, 현재 상태, 델리게이트로 단순하다. iOS 17부터는 Watch에서 만든 세션을 iPhone에 미러링하는 API도 추가됐다.
class HKWorkoutSession {
let healthStore: HKHealthStore
let workoutConfiguration: HKWorkoutConfiguration
let state: HKWorkoutSessionState
weak var delegate: HKWorkoutSessionDelegate?
func startMirroringToCompanionDevice(completion:)
}HKLiveWorkoutBuilder는 Session의 짝꿍이라 자동 수집을 해준다
HKLiveWorkoutBuilder는 Session과 짝으로 동작했다. Watch 센서가 거리·심박수·칼로리를 알아서 모아 빌더에 채워주는 게 핵심이다. iPhone에서는 쓸 수 없다.
#if os(watchOS)
let builder = session.associatedWorkoutBuilder()
builder.dataSource = HKLiveWorkoutDataSource(healthStore: store, workoutConfiguration: config)
builder.delegate = self
builder.beginCollection(withStart: Date()) { _, _ in }
let hrStats = builder.statistics(for: HKQuantityType.quantityType(forIdentifier: .heartRate)!)
let currentHR = hrStats?.mostRecentQuantity()?.doubleValue(for: bpmUnit)
builder.endCollection(withEnd: Date()) { _, _ in
builder.finishWorkout { workout, error in
// HKWorkout 저장 완료
}
}
#endifHKWorkoutBuilder와 어떻게 다른지가 가장 자주 묻게 되는 지점이라 표로 정리했다.
| 기능 | HKWorkoutBuilder | HKLiveWorkoutBuilder |
|---|---|---|
| 데이터 자동 수집 | 없음 (앱이 add) | 있음 (센서가 자동) |
| 실시간 통계 | 없음 | statistics(for:) 가능 |
| iPhone 사용 | 가능 | 불가 |
HKWorkoutBuilder는 iPhone에서 워크아웃을 저장할 때 쓰는 표준 경로다
HKWorkoutBuilder는 iOS와 watchOS 모두에서 동작한다. 빈 운동 기록을 만들고 샘플을 직접 add해서 마감하면 HKWorkout이 저장된다. 자동 수집은 해주지 않는다는 점이 LiveWorkoutBuilder와의 결정적인 차이다.
let builder = HKWorkoutBuilder(
healthStore: store,
configuration: config,
device: .local()
)
builder.beginCollection(withStart: startDate) { success, error in }
let distanceSample = HKQuantitySample(
type: HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!,
quantity: HKQuantity(unit: .meter(), doubleValue: 1000),
start: segmentStart,
end: segmentEnd
)
builder.add([distanceSample]) { success, error in }
builder.addWorkoutEvents([pauseEvent]) { success, error in }
builder.addMetadata([HKMetadataKeyIndoorWorkout: false]) { success, error in }
builder.endCollection(withEnd: endDate) { success, error in
builder.finishWorkout { workout, error in
// HKWorkout 저장됨
}
}GPS 경로는 별도의 빌더가 따로 있었다. HKWorkoutRouteBuilder로 위치를 누적해 두었다가, 운동이 끝난 뒤에 finishRoute(with:)로 워크아웃에 연결하는 식이다.
let routeBuilder = HKWorkoutRouteBuilder(healthStore: store, device: .local())
routeBuilder.insertRouteData([clLocation1, clLocation2]) { success, error in }
builder.finishWorkout { workout, error in
guard let workout = workout else { return }
routeBuilder.finishRoute(with: workout, metadata: nil) { route, error in
// HKWorkoutRoute 저장 + workout과 연결
}
}조회 API 5종은 "한 번 vs 지속"으로 나눠 봤다
조회 쪽은 다섯 개라 더 헷갈렸는데, "한 번 vs 지속", "샘플 자체 vs 집계"로 갈라 보니 정리가 됐다.
| 쿼리 | 한 번 / 지속 | 데이터 형태 | 페이로드 | 백그라운드 | 사용 사례 |
|---|---|---|---|---|---|
| HKSampleQuery | 한 번 | 샘플 배열 | 큼 | 불가 | "오늘 HR 다 가져와" |
| HKAnchoredObjectQuery | 지속 | 새 샘플만 | 작음 | 가능 | "새 HR 들어오면 알려줘" |
| HKObserverQuery | 지속 | "변했다" 알림만 | 0 | 가능 | 푸시 트리거 |
| HKStatisticsQuery | 한 번 | 합/평균 한 개 | 매우 작음 | 불가 | "이번 주 총 거리" |
| HKStatisticsCollectionQuery | 한 번/지속 | 시간 윈도우별 집계 | 중간 | 옵션 | 차트(시간별/일별) |
HKSampleQuery는 1회성이라 백그라운드 갱신이 없다
HKSampleQuery는 호출 한 번에 결과 한 묶음이 오고 끝났다. 백그라운드에서 다시 발화하지 않는다.
let query = HKSampleQuery(
sampleType: hrType,
predicate: HKQuery.predicateForSamples(withStart: startDate, end: endDate),
limit: HKObjectQueryNoLimit,
sortDescriptors: [NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)]
) { _, samples, error in
let hrSamples = samples as? [HKQuantitySample] ?? []
}
store.execute(query)HKAnchoredObjectQuery가 iPhone에서 "실시간"이라고 부를 수 있는 거의 유일한 옵션이었다
HKAnchoredObjectQuery는 updateHandler를 함께 설정하면, 첫 호출 후로는 새 샘플만 푸시로 받게 된다. anchor는 "어디까지 봤는지"를 표시하는 북마크(HKQueryAnchor 타입)다. 앱이 재시작되더라도 anchor만 저장해뒀다가 다시 넘기면 중복 없이 이어 받을 수 있었다.
let query = HKAnchoredObjectQuery(
type: hrType,
predicate: HKQuery.predicateForSamples(withStart: sessionStart, end: nil),
anchor: nil,
limit: HKObjectQueryNoLimit
) { _, samples, deletedObjects, newAnchor, error in
// 첫 호출: 기존 샘플 전부
}
query.updateHandler = { _, samples, deletedObjects, newAnchor, error in
guard let newSamples = samples as? [HKQuantitySample] else { return }
let latest = newSamples.max(by: { $0.endDate < $1.endDate })
}
store.execute(query)
store.stop(query)anchor 자체는 HKQueryAnchor 객체라서 직렬화해 저장해야 했다. NSKeyedArchiver로 묶어 UserDefaults에 넣는 패턴을 썼다.
let anchorData = try NSKeyedArchiver.archivedData(
withRootObject: anchor,
requiringSecureCoding: true
)
UserDefaults.standard.set(anchorData, forKey: "hr-anchor")
let data = UserDefaults.standard.data(forKey: "hr-anchor")!
let anchor = try NSKeyedUnarchiver.unarchivedObject(
ofClass: HKQueryAnchor.self,
from: data
)HKObserverQuery는 데이터를 못 본다
HKObserverQuery는 "뭔가 추가됐다"는 신호만 전달하고, 내용은 알려주지 않았다. 결국 콜백 안에서 또 다른 쿼리(보통 HKSampleQuery나 HKAnchoredObjectQuery)를 실행하는 식으로 묶어 썼다.
let query = HKObserverQuery(
sampleType: hrType,
predicate: nil
) { _, completionHandler, error in
completionHandler()
}
store.execute(query)
store.enableBackgroundDelivery(
for: hrType,
frequency: .immediate
) { success, error in }쓰임새는 "변화 자체"만 중요할 때, 예를 들어 HealthKit 변경을 서버 동기화 트리거로 쓸 때다. 데이터까지 같이 받고 싶다면 AnchoredObjectQuery가 거의 항상 더 나았다.
HKStatisticsQuery는 합/평균 한 개
집계값이 한 개만 필요할 때는 HKStatisticsQuery가 가장 가벼웠다.
let query = HKStatisticsQuery(
quantityType: distanceType,
quantitySamplePredicate: predicate,
options: [.cumulativeSum]
) { _, statistics, error in
let totalDistance = statistics?.sumQuantity()?.doubleValue(for: .meter())
}
store.execute(query)옵션은 데이터의 성격에 맞춰 갈라 썼다.
| 옵션 | 의미 | 어울리는 데이터 |
|---|---|---|
.cumulativeSum | 합계 | 거리, 칼로리, 걸음 수 (누적형) |
.discreteAverage | 평균 | HR, 속도 (시점형) |
.discreteMin / .discreteMax | 최소/최대 | HR, VO2Max |
.mostRecent | 가장 최근 값 | HR, 체중 |
HKStatisticsCollectionQuery는 차트용 집계
차트처럼 시간 윈도우별 집계가 필요하면 HKStatisticsCollectionQuery를 썼다. 시작점(anchorDate)과 간격(intervalComponents)을 정하면 그 단위로 묶어 줬다.
let anchorDate = Calendar.current.startOfDay(for: Date())
let interval = DateComponents(hour: 1)
let query = HKStatisticsCollectionQuery(
quantityType: hrType,
quantitySamplePredicate: predicate,
options: .discreteAverage,
anchorDate: anchorDate,
intervalComponents: interval
)
query.initialResultsHandler = { _, collection, error in
collection?.enumerateStatistics(from: startDate, to: endDate) { stats, _ in
let avgHR = stats.averageQuantity()?.doubleValue(for: bpmUnit)
}
}
query.statisticsUpdateHandler = { _, _, collection, error in /* ... */ }
store.execute(query)권한 요청은 onboarding에서 미리 받아두는 게 안전했다
권한 요청 자체는 한 줄이지만, 콜백의 success가 "사용자가 시트를 닫았는가"라는 의미라서 처음에는 허용 여부로 착각했다.
let typesToRead: Set<HKObjectType> = [
HKQuantityType.quantityType(forIdentifier: .heartRate)!,
HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!,
HKObjectType.workoutType()
]
let typesToWrite: Set<HKSampleType> = [
HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning)!,
HKObjectType.workoutType()
]
store.requestAuthorization(toShare: typesToWrite, read: typesToRead) { success, error in
// success는 "시트를 닫았는가"이지 "허용했는가"가 아니었다
}쓰기 권한 상태는 authorizationStatus(for:)로 정확히 확인할 수 있었지만, 읽기 권한은 프라이버시 보호 때문에 거부됐어도 .sharingAuthorized로 보였다. 진짜로 확인하려면 쿼리를 한 번 날려보고 빈 결과가 오는지를 봐야 했다.
let writeStatus = store.authorizationStatus(for: distanceType)
let readStatus = store.authorizationStatus(for: hrType)
// 실제로 거부됐어도 .sharingAuthorized로 보임Info.plist에 사용 목적 문자열을 빠뜨리면 앱이 크래시 났다. 백그라운드 전달을 쓸 거면 entitlement도 같이 필요했다.
<key>NSHealthShareUsageDescription</key>
<string>운동 데이터 분석을 위해 필요합니다.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>운동 기록을 건강 앱에 저장합니다.</string><key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.background-delivery</key>
<true/>플랫폼 호환성에서 갈리는 지점
iOS만 지원할 거면 어떤 API는 아예 못 쓴다는 점이 컸다. 매트릭스로 정리해 둔 게 도움이 됐다.
| API | iOS | watchOS | macOS | visionOS |
|---|---|---|---|---|
| HKHealthStore | O (8+) | O (2+) | O (13+) | O |
| HKWorkoutSession | X | O (2+) | X | X |
| HKLiveWorkoutBuilder | X | O (5+) | X | X |
| HKWorkoutBuilder | O (17+) | O (5+) | X | O |
| HKWorkoutRouteBuilder | O (11+) | O (5+) | X | O |
| HKSampleQuery | O (8+) | O (2+) | O (13+) | O |
| HKAnchoredObjectQuery | O (8+) | O (2+) | O (13+) | O |
| HKObserverQuery | O (8+) | O (2+) | O (13+) | O |
| HKStatisticsQuery | O (8+) | O (2+) | O (13+) | O |
| Mirrored Workout Session | O (17+) | O (10+) | X | X |
| Running form metrics | O (16+) | O (9+) | X | O |
iPhone 단독으로 워크아웃을 트래킹해야 한다면 사용 가능한 조합과 불가능한 조합이 명확하게 갈렸다.
| 구분 | 항목 |
|---|---|
| 가능 | HKWorkoutBuilder (수동 add), HKWorkoutRouteBuilder (GPS), HKAnchoredObjectQuery (HR 실시간 구독), HKObserverQuery + background delivery |
| 불가 | HKWorkoutSession (Watch 전용), HKLiveWorkoutBuilder (Watch 전용), 자동 거리/페이스/칼로리 계산 (앱이 직접 구현) |
시나리오별로 무엇을 골랐는지
원문에 시나리오별 선택 가이드가 있어 그대로 보존했다. 각 상황에서 어떤 API를 짝지어 썼는지가 핵심이다.
운동 중 실시간 심박수 표시는 iPhone에서는 HKAnchoredObjectQuery + updateHandler로 해결했다. 권한은 .heartRate read, 백그라운드 처리는 워크아웃 백그라운드 권한이나 observerQuery를 같이 묶었다. 데이터 출처는 Watch, AirPods Pro 2, 외부 BT 센서가 될 수 있다. Watch 앱에서라면 HKWorkoutSession + HKLiveWorkoutBuilder.statistics(for: .heartRate) 조합이 정답이었다. 자동 수집된 statistics에서 mostRecentQuantity로 현재 BPM을 꺼내면 됐다.
운동 기록을 건강 앱에 저장할 때는 iPhone에서 HKWorkoutBuilder.beginCollection → 거리·칼로리·HR을 add → addWorkoutEvents로 pause/resume/lap을 더하고 → endCollection → finishWorkout → HKWorkoutRouteBuilder.finishRoute로 GPS 경로를 연결하는 게 표준 흐름이었다.
주간 누적 거리·칼로리는 HKStatisticsQuery(options: .cumulativeSum)에 지난 7일 predicate를 묶었고, 시간대별 HR 차트는 HKStatisticsCollectionQuery에 .discreteAverage와 1분 intervalComponents를 줬다. 앱 백그라운드 상태에서 새 워크아웃을 감지해 서버 동기화를 트리거할 때는 HKObserverQuery(sampleType: .workoutType()) + enableBackgroundDelivery(.immediate)로 트리거를 잡고, 콜백 안에서 HKAnchoredObjectQuery로 새 workout을 조회했다.
이 선택지를 한눈에 보려고 의사결정 트리를 따로 그려뒀다.
직접 부딪힌 함정들
머리로만 정리해두고 끝낼 수도 있지만, 실제로 코드를 짜다 보면 같은 데서 또 막혔다. 짚어둔 것들을 모았다.
가장 처음 무너진 가정은 "HealthKit이 다 알아서 해주겠지"였다. iPhone에서 HKWorkoutBuilder는 거리·페이스·auto-pause를 계산해주지 않았다. CLLocationManager로 GPS 좌표를 받아 거리를 계산하고, CMPedometer로 걸음 수를 받고, auto-pause 로직은 직접 짠 뒤 결과만 builder에 add 했다. OS가 대신 측정해주는 건 Watch에서 LiveWorkoutBuilder를 쓸 때뿐이었다.
권한 거부 후 침묵도 한 번 크게 당했다. 위에서 짚은 대로 authorizationStatus로 read 권한 거부를 잡을 수 없으니, 실제로 쿼리를 날려 결과가 비어있는지를 보고 UI를 안내하도록 바꿨다.
let query = HKSampleQuery(...) { _, samples, _ in
if (samples ?? []).isEmpty {
// 데이터가 없거나 권한 거부일 수 있음 → UI 안내
}
}HKAnchoredObjectQuery의 anchor를 저장하지 않으면, 앱 재시작 때마다 이미 처리한 샘플을 다시 받았다. 항상 UserDefaults나 파일에 anchor를 저장해 두는 습관이 필요했다.
단위 혼동은 컴파일러가 잡아주지 않아서 런타임에서 터졌다. HR을 .count로 꺼내려고 했다가 크래시가 났고, 올바르게는 count/minute 조합으로 꺼내야 했다.
quantity.doubleValue(for: .count()) // 잘못된 단위
quantity.doubleValue(for: HKUnit.count().unitDivided(by: .minute())) // 올바른 단위쿼리 콜백은 백그라운드 스레드에서 발화했다. UI 갱신은 메인 스레드로 옮기지 않으면 조용히 깨졌다.
DispatchQueue.main.async {
self.label.text = "\(bpm) bpm"
}HKObserverQuery로 백그라운드 알림을 받으려면 entitlement, enableBackgroundDelivery 호출, 한 번이라도 앱이 실행된 상태가 모두 필요했다. 사용자가 앱을 강제 종료하면 다시 안 왔다.
베스트 프랙티스로 굳힌 것들
- 권한은 운동 시작 직전이 아니라 onboarding에서 미리 요청한다. 거부되면 UX가 막힌다.
- 쿼리는 명시적으로 stop 한다. 안 그러면 백그라운드 누수가 났다.
AnchoredObjectQuery가ObserverQuery + SampleQuery조합보다 거의 항상 나았다. 한 번에 처리된다.HKWorkoutBuilder는 동시에 하나만 연다. 여러 빌더를 동시에 열면 꼬였다.HKWorkoutRouteBuilder의finishRoute는 워크아웃 종료 후에만 호출했다. workout 인스턴스가 필요하기 때문이다.
한 줄 요약과 표준 아키텍처
마지막으로 머리에 박아둘 한 줄 요약을 남겼다.
HKWorkoutSession + HKLiveWorkoutBuilder는 Watch에서만 가능한 진짜 라이브 워크아웃이다. iPhone에서는 import도 안 된다.HKWorkoutBuilder는 iPhone에서 워크아웃을 저장하는 표준 방법이다. 데이터는 앱이 직접 넣어야 한다.HKAnchoredObjectQuery는 iPhone에서 "실시간"이라 부를 수 있는 거의 유일한 옵션이다.HKObserverQuery는 알림 트리거 전용이다. 데이터를 보려면 다른 쿼리가 추가로 필요하다.HKStatisticsQuery / Collection은 차트와 합계 전용이라 워크아웃 트래킹 흐름과는 별개다.
iPhone-only 워크아웃 앱이라면 결국 이 흐름으로 굳었다.
다음에 더 파볼 자료
- WWDC23: Build a workout app for Apple Watch — Mirrored workouts
- WWDC22: Meet Health features in WidgetKit
- HealthKit Developer Documentation
- WWDC22: What's new in HealthKit — Running form metrics
정리해 둔 내용은 iOS 17 기준이라, iOS 18에서 추가된 HKActivityMoveModeStaticHandler, HKWorkoutEffortRelationshipQuery 같은 신규 API는 별도로 또 정리해야 한다.