HealthKit 수영 데이터 Fetch 해오기

728x90

 

취미로 하는 수영을 가지고 앱을 만들어보자 라는 다짐으로 혼자 프로젝트를 시작했습니다.

 

앱 기능 중 피트니스 앱 처럼 수영 데이터를 보여주는 기능을 만들기 위해 HealthKit을 사용하게 됐습니다.

공식문서나 다른 글을 봐도 HealthKit steps 정도만 사용하는 경우가 많아서 이번 기회에 삽질한 내용을 정리합니다.


기본 설정

먼저 프로젝트 생성 후

HealthKit Capability

먼저 App Target으로 이동해서 HealthKit Capability를 추가해줍니다.

두 개의 체크박스는 그냥 데이터를 fetch하는데는 필요가 없기 때문에 그냥 넘어가도 됩니다.

 

그래도 간략하게 정리하자면.. 질병 정보 읽기 권한 체크, 레코드 갱신시 백그라운드에서도 동작하게 허용하는 체크입니다.

info.plist

plist에도 권한 체크를 위한 메시지를 설정해야 합니다!

 

share, read 중 하나만 사용한다고 해도 둘 다 설정해야 제대로 뜨는걸 삽질하다보니 알아냈습니다 ㅠㅠ

이제부터는 구현으로 넘어갑니다.

HealthKit 구현

import HealthKit

final class HealthKitRecordRepositoryImpl: HealthKitRecordRepository {
    private let store: HKHealthStore = HKHealthStore()
    private let read: Set<HKObjectType>

    init(read: Set<HKQuantityType>? = nil) {
        if let read = read {
            self.read = read
        }
        else {
            self.read = Set(
                HealthKitType.allCases.compactMap{HKObjectType.quantityType(forIdentifier: $0.quantityTypeIdentifier)} +
                [HKObjectType.workoutType(), HKSeriesType.workoutRoute()]
            )
        }
    }
...
}

HealthKit에 접근할 수 있는 HKHealthStore, fetch 해올 타입을 저장할 HKObjectType의 집합을 하나 만들어 놓습니다.

여기서 HKObjectType은 특별한 상황이 아니면 사용하는것만 사용할테니 enum으로 정의 해둔것 사용하게 했습니다.

 

추가로 HKObjectType.workoutType(), HKObjectType.workoutRoute()는 각각 운동 세션 오버뷰, 운동 경로를 접근할 수 있는 타입입니다.

HKObjectType.workoutRoute()는 꼭 하지 않아도 됩니다. (추후 OpenWater 운동 데이터 경로를 뽑기위해 넣어둠)

 

권한 요청

func requestAuthorization() async throws -> Bool {
    guard HKHealthStore.isHealthDataAvailable() else { return false }
    try await store.requestAuthorization(toShare: [], read: read)
    // 요청 후 한 번 더 물어볼 필요가 없는지 확인
    return try await hasReadAuthorization()
}

isHealthDataAvailable() 메서드를 사용해 기기가 HealthKit을 사용할 수 있는지 먼저 확인합니다.

그리고 store.requestAuthorization 메서드에 읽어올 타입 집합을 넣고 실행시키면, 실제 앱에서 권한체크를 할 수 있습니다.

 

마지막 return문 에서는 따로 권한체크를 위한 함수를 구현해뒀습니다.

여기까지 Fetch 하기위한 작업이었고 진짜 Fetch를 시작합니다.

 

HealthKit Fetch Quries

 

데이터를 읽어올 때 사용하는 쿼리는 여러 종류가 있습니다.

이전에는 Swift Concurrency를 지원하지 않은것으로 공부했는데, 이번 프로젝트를 진행하며 보니 Swift Concurrency를 지원하는 쿼리들로 업데이트 되었습니다!

 

그래서 여기서는 업데이트 된 쿼리 몇 가지를 비교해보고 사용법을 정리하겠습니다.

 

 

HKSampleQueryDescriptor 단순히 데이터만 가져옴
HKStatisticsQueryDescriptor 합계, 평균, 최댓값 등의 통계를 가져옴 (요약용)
HKStatisticsCollectionQueryDescriptor 일정 단위로 나눠 통계를 시계열 데이터로 가져옴 (차트에 유용)

 

그 외 증분쿼리, 변경 감지용 이벤트 쿼리 등 여러가지가 있지만, 통계용 화면을 만들기 위한 3가지만 알아보겠습니다.

 

HKSampleQuery

func fetchDataByDateRange(type: HealthKitType, start: Date, end: Date) async throws -> [HKQuantitySample] {
    try await hasReadAuthorization()

    let swimType = HKQuantityType(type.quantityTypeIdentifier)
    let rangeOfDate = HKQuery.predicateForSamples(withStart: start, end: end, options:
.strictStartDate)

    let descriptor = HKSampleQueryDescriptor(predicates:[.quantitySample(type: swimType,
predicate: rangeOfDate)], sortDescriptors: [.init(\.startDate, order: .forward)])

    return try await descriptor.result(for: store)
}

수영과 관련된 타입 .distanceSwimming, .swimmingStrokeCount을 fetch 하는 코드입니다.

  • .distanceSwimming은 측정된 기록 시간 범위에 수행한 거리가 반환됩니다.
  • .swimmingStrokeCount는 측정된 기록 시간 범위에 스트로크 카운트와 영법 유형이 반환됩니다.

타입 결정 후 fetch 할 범위를 predicate로 지정합니다.

 

마지막으로 HKSampleQueryDescriptor 에 지정한 타입, 조건을 넣어 fetch를 해주면 됩니다.

데이터를 가져오는 방식은 간단하지만 사용할 데이터를 추출하는게 어려웠습니다..

 

.distanceSwimming 데이터 추출하기

이 부분은 간단했습니다.

여기서 사용할 데이터는 시간 범위와 측정된 수행 거리만 추출하면 됐습니다.

let distanceResult = try await repository.fetchDataByDateRange(type: .distanceSwimming, start: startDate, end: endDate)

let startDate = distanceResult.first?.startDate
let endDate = distanceResult.first?.endDate
let distance = distanceResult.first?.quantity.doubleValue(for: .meter())

간략하게 정리용으로 작성한 코드입니다.

Fetch를 하면 구간 데이터가 배열로 넘어오게 되고 여기서 구간별 거리 및 시간범위를 추출할 수 있습니다.

 

.swimmingStrokeCount 데이터 추출하기

 

여기서 영법을 추출하는 방법에 대해 엄청나게 검색을 했습니다..

도저히 내부 메서드로는 추출할 수 없는것 같아 공식문서 포함 이곳저곳 검색해보니 metadata에 Key 값으로 접근해야 데이터를 받을 수 있다는 것을 알게 되었습니다.

let strokeResult = try await repository.fetchDataByDateRange(type: .swimmingStrokeCount, start: startDate, end: endDate)

let sourceRevision = strokeResult.first?.sourceRevision.source.name ?? "Unknown device"
let startDate = strokeResult.first?.startDate
let endDate = strokeResult.first?.endDate
let strokeStyle = strokeResult.first?.metadata?[HKMetadataKeySwimmingStrokeStyle] as? Int

여기서 혹시 metadata[HKMetadataKeySwimmingStrokeStyle]distanceResult 에서도 뽑을 수 있지 않을까? 했지만, 테스트 해 본 결과 .swimmingStrokeCount에서만 기록되는 데이터 였습니다.

 

HKMetadataKeySwimmingStrokeStyle 는 어떤 값이 들어올까? 라는게 가장 큰 관건이었는데, 값을 하나하나 피트니스앱과 비교하기 귀찮았기에..

검색을 해보니 아래와 같은 데이터를 얻을 수 있었습니다.

/**
 @enum          HKSwimmingStrokeStyle
 @abstract      Represents a style of stroke used during a swimming workout.
 */

@available(iOS 10.0, *)
public enum HKSwimmingStrokeStyle : Int, @unchecked Sendable {
    case unknown = 0
    case mixed = 1
    case freestyle = 2
    case backstroke = 3
    case breaststroke = 4
    case butterfly = 5

    @available(iOS 16.0, *)
    case kickboard = 6

}

Enum이 이미 존재하기 때문에 하나하나 구분할 필요는 없습니다!

AppleWatch SE 기준
kickboard 케이스가 매칭되지 않음! (최신 애플워치가 없어서 다른 테스트는 못해봤습니다 ㅠㅠ)

distanceSwimming 타입에서는 측정 범위와 거리가 존재하지만,
swimStrokeCount 타입에서는 kickboard 사용했을때의 측정데이터가 존재하지 않음
stroke 가 없어서 인 것 같지만, 그래도 이부분 참고

 

이렇게 데이터 추출까지 확인해봤습니다.

 

HKStatisticsQuery

func fetchStatisticsQuery(
    type: HealthKitType,
    unit: HKUnit,
    start: Date,
    end: Date,
    options: HKStatisticsOptions
) async throws -> Double {
    let quantityType = HKQuantityType(type.quantityTypeIdentifier)
    let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: [])
    let descriptor = HKStatisticsQueryDescriptor(
        predicate: .quantitySample(type: quantityType, predicate: predicate),
        options: options
    )
    let stats = try await descriptor.result(for: store)
    // 데이터 추출 후 옵션에 따른 데이터 뽑는 방식
    ... // 아래에서 따로 설명
}
// usage
result.totalActivityBurn = try await repository.fetchStatisticsQuery(type: .activeEnergyBurned, unit: .kilocalorie(), start: start, end: end, options: .cumulativeSum)

result.heartRateMax = try await repository.fetchStatisticsQuery(type: .heartRate, unit: bpmUnit, start: start, end: end, options: .discreteMax)

result.heartRateAvg = try await repository.fetchStatisticsQuery(type: .heartRate, unit: bpmUnit, start: start, end: end, options: .discreteAverage)

Fetch 하는법은 비슷하기때문에 설명은 스킵하고,
HKStatisticsQueryDescriptor에서 반환되는 값을 원하는 방식으로 추출하는 방법을 알아보겠습니다.

 

코드를 보면 options에 따라 데이터 뽑는 법이 다른것을 알 수 있습니다.

switch options {
    case .cumulativeSum: // 누적합계
        return stats?.sumQuantity()?.doubleValue(for: unit) ?? 0
    case .discreteAverage: // 샘플들의 평균값
        return stats?.averageQuantity()?.doubleValue(for: unit) ?? 0
    case .discreteMin: // 최솟값
        return stats?.minimumQuantity()?.doubleValue(for: unit) ?? 0
    case .discreteMax: // 최댓값
        return stats?.maximumQuantity()?.doubleValue(for: unit) ?? 0
    case .mostRecent: // 최근 기록된 값
        return stats?.mostRecentQuantity()?.doubleValue(for: unit) ?? 0
    default:
        return 0
    }

 

모든 옵션이 사용되진 않지만, 알아보기 위해서 전부 올려두었습니다.

합계, 평균값 등에 따라 Fetch 후 사용할 수 있는 내장 메서드가 정의되어 있기때문에 쉽게 사용이 가능합니다.

 

HKUnit 은 어떤 포멧으로 값을 추출할지를 설정할 수 있습니다.

 

HKStatisticsCollectionQuery

func fetchStatisticsCollectionQuery(
    type: HealthKitType,
    start: Date,
    end: Date,
    options: HKStatisticsOptions,
    interval: DateComponents = DateComponents(minute: 1)
) async throws -> HKStatisticsCollection {

    let predicate = HKQuery.predicateForSamples(withStart: start, end: end, options: .strictStartDate)
    let quantityType = HKQuantityType(type.quantityTypeIdentifier)
    let sameplePredicate = HKSamplePredicate.quantitySample(type: quantityType, predicate: predicate)

    let descriptor = HKStatisticsCollectionQueryDescriptor(predicate: sameplePredicate, options: options, anchorDate: start, intervalComponents: interval)
    return try await descriptor.result(for: store)
}

 

마지막으로 차트에서 사용하기 유용한 CollectionQuery 입니다.

다른 Query와 마찬가지로 데이터타입, 가져올 날짜 범위를 설정해주면 됩니다.

 

여기서 다른점은 interval 옵션이 있는것입니다.
interval을 설정하면 특정 범위의 데이터를 interval로 분할하여 가져오게 됩니다.

 

예시를 들면

let interval = DateComponents(second: 60)

이렇게 옵션을 설정하면 데이터가 60초 단위로 통계 데이터가 쌓여 반환됩니다.


위 방법들로 데이터를 활용하면 수영기록에 대한 Overview를 쉽게 만들 수 있습니다!

728x90