Run Mile 개발 일지 첫번째
첫번째로 구현한 기능은 운동 데이터 불러오기 입니다!
사용자의 운동 데이터를 불러오기 위해서는 HealthKit 프레임워크를 사용하면 가져올 수 있습니다.
하지만 해당 정보는 개인정보인 만큼 불러오기 위해서는 다양한 스텝을 밟아야 합니다.
다른 기능 권한 부여(카메라, gps, 마이크 등)일 때와 똑같이 Info 파일을 수정해야 합니다.
프로젝트 → 타겟 → Info에서 추가하면 됩니다.
Value 값에 권한 허가 요청 시 나올 메시지를 입력하면 됩니다!
해당 Info를 추가하지 않으면 권한 요청을 진행하지 못하고 에러가 발생하니 꼭 추가해야 합니다.
Privacy - Health Share Usage Description
건강 데이터 읽기(read) 요청 메시지Privacy - Health Update Usage Description
건강 데이터 생성(Share) 요청 메시지
🚨 주의점!
value값의 길이(String 길이)가 12 이하일 경우 채워 넣었음 에도 에러가 발생할 수 있으니 12자 이상은 채울 수 있도록 하면 좋습니다!!
이렇게 짧으면 채워 넣어도 에러가 납니다!! 제가 당했어요!!
Location, 이미지, 마이크 등 개인 정보에 접근하려고 하면 권한을 받아야 하는 것처럼 건강 데이터 또한 개인정보이기 때문에 사용하기 전에 권한을 받아야 합니다.
제가 선택한 방식은 권한 요청이 주어졌는지 확인 → 요청 경우에 따른 권한 허가 요청 순으로 진행했습니다.
그 이유는 앱에서 한번 권한을 Request 하고 난 뒤에는 다시 Request를 할 수 없기 때문입니다.
그래서 대다수의 앱이 사용자가 권한을 허가하지 않은 경우 재요청하지 않고 세팅으로 유도하여 직접 허가를 할 수 있도록 유도합니다.
let store = HKHealthStore()
// CompletionHandler 방식
store.getRequestStatusForAuthorization(
toShare typesToShare: Set<HKSampleType>,
read typesToRead: Set<HKObjectType>,
completion: @escaping (HKAuthorizationRequestStatus, (any Error)?) -> Void
)
// Swift Concurrency 방식
store.statusForAuthorizationRequest(
toShare typesToShare: Set<HKSampleType>,
read typesToRead: Set<HKObjectType>
) async throws -> HKAuthorizationRequestStatus
getRequestStatusForAuthorization()
결과 타입의 HKAuthorizationRequestStatus
unknownshouldRequestunnecessary저는 요청되지 않은 경우에만 권한 요청을 주도록 구현하기 위해 .shouldRequest 케이스 일 때만 진행되도록 사용했습니다.
HKObjectType 종류
HKQuantityTypeHKCategoryTypeHKWorkoutTypeHKCorrelationType요청을 진행하기 전 해당 기기가 HealthKit을 사용이 가능한지 확인하는 절차를 추가했습니다.
macOS와 특정 버전의 iPadOS의 경우 HealthKit을 사용할 수 없기 때문에 확인 후 진행하면 좋습니다!
HKHealthStore.isHealthDataAvailable()
그 후 권한을 요청합니다.
let store = HKHealthStore()
store.requestAuthorization(
toShare typesToShare: Set<HKSampleType>,
read typesToRead: Set<HKObjectType>
) async throws
requestAuthorization(toShare:read:)
toShare은 쓰기(건강 데이터 생성)를 위한 타입을 지정합니다.
read는 읽기(기기에 저장된 데이터 불러오기)를 위한 타입을 지정합니다.
저는 쓰기는 사용하지 않고 운동 기록 불러오기만 하기 때문에 read에 workoutType을 추가했습니다.
/// Health 데이터 사용 권한을 요청합니다.
private func requestAuthorization() async throws {
if HKHealthStore.isHealthDataAvailable() {
try await store.requestAuthorization(
toShare: Set(),
read: Set([.workoutType()])
)
} else {
throw HealthError.notAvailableDevice
}
}
이제 권한을 부여받았으면 데이터를 불러오기만 하면 됩니다.
다만 건강 데이터의 종류가 엄청 많은 만큼 그 중 필요한 데이터만 들고와야 합니다.
이를 위해 사용하는 class중 제너럴한 것이 HKSampleQuery입니다.
저는 건강 데이터 중 러닝 데이터를 들고오겠습니다.
HKSampleQuery를 생성해 원하는 샘플 타입, predicate, limit 등등을 지정합니다.
결과 값의 개수 제한 없이 들고오기 위해서는 HKObjectQueryNoLimit를 사용하면 됩니다.
init(
sampleType: HKSampleType, // read를 원하는 데이터 타입
predicate: NSPredicate?, // 결과를 필터링 하기 위한 조건
limit: Int, // 결과 값 개수 제한
sortDescriptors: [NSSortDescriptor]?, // 결과 값 정렬 조건
resultsHandler: @escaping (HKSampleQuery, [HKSample]?, (any Error)?) -> Void // 결과 값 CompletionHandler
)
init(sampleType:predicate:limit:sortDescriptors:resultsHandler:)
이렇게 만든 쿼리를 HKHealthStore 에서 실행하면 됩니다.
HKHealthStore().excute(query)
결과 값의 타입은 HKSample 입니다.
도큐먼트의 설명에서도 볼 수 있듯이 Abstract Class이므로 제대로 사용하기 위해서는 구체화된 클래스로 캐스팅 하는 과정을 거쳐야 합니다.
HKSample의 하위 클래스들
HKQuantitySampleHKCategorySampleHKCorrelationHKWorkoutHKClinicalRecordHKDocumentSampleHKSeriesSampleHKElectrocardiogramHKHeartbeatSeriesSampleHKStateOfMind저의 경우 운동 데이터를 가져왔기 때문에 HKWorkout으로 타입 캐스팅을 한 후 사용했습니다!
public func fetchWorkoutData() async throws -> [RunningData] {
let predicate = HKQuery.predicateForWorkouts(with: .running)
let runningData = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKSample], any Error>) in
let query = HKSampleQuery(
sampleType: .workoutType(),
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [.init(key: HKSampleSortIdentifierStartDate, ascending: false)]) { query, samples, error in
if let _ = error {
continuation.resume(with: .failure(HealthError.failedToLoadWorkoutData))
}
guard let samples = samples else {
continuation.resume(with: .failure(HealthError.failedToLoadWorkoutData))
return
}
continuation.resume(with: .success(samples))
}
store.execute(query)
}
guard let result = runningData as? [HKWorkout] else { throw HealthError.failedToLoadWorkoutData }
return convertToRunningData(result)
}
HealthKit에서 Sample 데이터를 추출해 사용을 하려고 보면 CompletionHandler 기반의 비동기 코드로 구성이 되어 있음을 알 수 있습니다.
기존 GCD/CompletionHandler 방식은 비동기 작업마다 스레드를 생성하고 쓰레드 간 컨텍스트 스위칭이 빈번하게 발생해 시스템 오버헤드가 크다고 합니다.
이에 반해 Swift Concurrency는 CPU 코어 수 만큼만 쓰레드를 유지하고 작업상태를 저장해 필요할 때만 재개하여 불필요한 쓰레드 생성과 컨텍스트 스위칭 비용을 줄입니다.
고로 성능 향상!
일단 Continuation에 대해 간단하게 알아보겠습니다!
Updating an App to Use Swift Concurrency
Swift Concurrency에서 비동기 함수의 실행을 일시정지(suspend)한 시점의 상태를 추적하고 저장하는 객체입니다.
즉 함수가 await에서 멈췄을 때 그 지점의 실행 정보를 보관해 두었다가, 나중에 다시 이어서 실행할 수 있게 해줍니다.
CheckedContinuationUnsafe Continuation대충 이런 친구인데 우린 이 Continuation을 직접 사용해 기존 CompletionHandler를 통합할 수 있습니다!
위에서 봤던 Checked와 Unsafe 둘 다 사용할 수 있습니다.
하지만 UnsafeContinuation는 거의 사용할 일이 없으므로 CheckedContinuation을 사용했습니다.
completionHandler가 에러를 던지는 경우에 따라 선택해서 사용하면 됩니다.
withCheckedContinuation(isolation:function:_:)
withCheckedThrowingContinuation(isolation:function:_:)
let runningData = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKSample], any Error>) in
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: limit,
sortDescriptors: sortDescriptors) { query, samples, error in
if let _ = error {
continuation.resume(with: .failure(HealthError.failedToLoadWorkoutData))
}
guard let samples = samples else {
continuation.resume(with: .failure(HealthError.failedToLoadWorkoutData))
return
}
continuation.resume(with: .success(samples))
}
self.execute(query)
}
이런 식으로 CompletionHandler 바깥으로 withCheckedThrowingContinuation을 감싸주어 사용하면 됩니다.
그리고 CompletionHandler 클로져 안에서 상황에 맞게 continuation.resume를 호출해주면 됩니다.
주의할 점은 반드시 continuation.resume은 한번만 호출해야 합니다!
여러번 호출을 할 경우 에러가 발생할 수 있습니다.
확장성과 보다 깔끔한 코드를 위해 저는 extension에 넣어 사용했습니다.
/// 호출부
public func fetchWorkoutData() async throws -> [RunningData] {
let predicate = HKQuery.predicateForWorkouts(with: .running)
let descriptor = [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)]
let result: [HKWorkout] = try await store.fetchData(
sampleType: .workoutType(),
predicate: predicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: descriptor
)
return convertToRunningData(result)
}
/// 구현부
extension HKHealthStore {
public func fetchData<T: HKSample>(
sampleType: HKSampleType,
predicate: NSPredicate? = nil,
limit: Int,
sortDescriptors: [NSSortDescriptor]? = nil
) async throws -> [T] {
let predicate = HKQuery.predicateForWorkouts(with: .running)
let data = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKSample], any Error>) in
let query = HKSampleQuery(
sampleType: sampleType,
predicate: predicate,
limit: limit,
sortDescriptors: sortDescriptors) { query, samples, error in
if let _ = error {
continuation.resume(with: .failure(HealthError.failedToLoadWorkoutData))
}
guard let samples = samples else {
continuation.resume(with: .failure(HealthError.failedToLoadWorkoutData))
return
}
continuation.resume(with: .success(samples))
}
self.execute(query)
}
guard let result = data as? [T] else { throw HealthError.failedToLoadWorkoutData }
return result
}
}
레포지토리