2. 백그라운드에서 HealthKit 활용하기

문인범·2025년 5월 9일
0

RunMile

목록 보기
3/5
post-thumbnail

Strava 앱은 운동을 완료하면 자동으로 앱에 운동 기록을 등록해 주었다고 노티를 줍니다.

Run Mile 앱도 이렇게 운동을 완료할 때 노티로 사용자에게 알려주면 앱의 노출 빈도를 높일 수 있지 않을까 생각이 들어 구현해보기로 했습니다.

이번 목표는 Background 또는 Suspended 상태에서 운동 데이터 추가를 감지하여 특정 액션을 실행시키기 입니다!

1. enableBackgroundDelivery

해당 기능의 키는 HealthKitHKHealthStore 클래스의 있는 enableBackgroundDelivery(for:frequency:withCompletion:) 메소드에 있습니다.

func enableBackgroundDelivery(
    for type: HKObjectType,
    frequency: HKUpdateFrequency,
    withCompletion completion: @escaping (Bool, (any Error)?) -> Void
)

func enableBackgroundDelivery(
    for type: HKObjectType,
    frequency: HKUpdateFrequency
) async throws

해당 메소드를 실행하게 되면 시스템이 저희 앱을 기억하고 있다가 지정한 특정 타입의 데이터(HKObjectType)에 변경(저장 or 삭제)이 일어나면 앱을 깨워줍니다.

  1. type을 통해 내가 추적할 Health 타입을 선택하고
  2. 얼마만큼 자주 업데이트를 할 것인지 빈도수를 선택합니다.
    1. immediate: 변화가 감지될 때 마다 앱을 깨웁니다.
    2. hourly: 변화가 감지 될 때 최대 한시간에 한번씩 깨웁니다.
    3. daily: 변화가 감지될 때 최대 하루에 한번씩 깨웁니다.
    4. weekly: 변화가 감지될 때 주당 한번씩 깨웁니다.

저는 운동 기록을 감지해야 하기 때문에 WorkoutType을 선택했고 주기는 immediate로 선택했습니다!
데이터 타입 마다 선택할 수 있는 주기의 제한이 있기 때문에 찾아보고 선택하시기 바랍니다.

/// in AppDelegate.swift
try await store.enableBackgroundDelivery(for: .workoutType(), frequency: .immediate)

iOS 15 and watchOS 8 이상의 경우 HealthKit을 백그라운드에서 사용하기 위해선
Signing & Capabilities 에서 HealthKit을 추가하고 HealthKit Background Delivery 를 선택해야 합니다!

2. HKObserverQuery

enableBackgroundDelivery는 해당 부분에 변동이 있을 때 앱을 깨워주는 역할만 합니다.
그래서 앱을 깨운 후 수행할 추가적인 액션을 넣어야 합니다.

일반적으로 건강 데이터가 추가되었을 때 앱을 깨운다는 건 그 데이터를 활용해서 액션을 추가할 것입니다.
저는 추가된 운동 데이터를 기반으로 노티피케이션을 주도록 구현했습니다.
이를 위해서 사용할 수 있는 HKObserverQuery 클래스가 있습니다.
Long-running Query 로 백그라운드에서 특정 데이터의 변동을 관찰하고 발견되었을 때 원하는 액션을 실행시킬 수 있습니다.

init(
    sampleType: HKSampleType,
    predicate: NSPredicate?,
    updateHandler: @escaping (HKObserverQuery, @escaping HKObserverQueryCompletionHandler, (any Error)?) -> Void
)
  • sampleType: 추적할 데이터 타입 선택
  • predicate: 변동되는 데이터에 필터링이 필요할 경우 추가
  • updateHandler: 변동을 파악했을 때 실행할 코드

저 같은 경우 운동이 추가되었을 때 노티피케이션을 제공하도록 구현했습니다.
추후 운동에서 러닝 데이터만 필터링 해 달리기 거리를 노티피케이션에 반영할 예정입니다!

현재 코드는 에러가 일어나지 않으면 query를 실행할 때 한 번 무조건 클로저가 실행되기 때문에 Bool 변수로 분기점을 두어 실행되게 했습니다.

/// in AppDelegate.swift
let query = HKObserverQuery(
    sampleType: .workoutType(),
    predicate: nil
) { query, completionHandler, error in
    if let error = error {
        print(error)
        return
    }
    
    let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false)
    let sampleQuery = HKSampleQuery(queryDescriptors: [.init(sampleType: .workoutType(), predicate: nil)], limit: 1, sortDescriptors: [sort]) { _, samples, error in
        if let error = error {
            print(error)
            return
        }
        
        defer {
            UserDefaults.standard.isFirstLaunch = true
        }
        
        guard let workout = samples?.first as? HKWorkout else {
            return
        }
        
        let workoutId = workout.uuid.uuidString
        let currentId = UserDefaults.standard.recentWorkoutID
        
        if !UserDefaults.standard.isFirstLaunch {
            UserDefaults.standard.recentWorkoutID = workoutId
        } else {
            if workoutId != currentId {
                UNUserNotificationCenter.requestNotification(
                    title: "운동을 완료하셨군요!🔥🔥",
                    body: "신발 마일리지를 등록할 준비가 완료되었습니다. 등록하러 가볼까요?"
                )
                UserDefaults.standard.recentWorkoutID = workoutId
            }
        }
    }
    
    store.execute(sampleQuery)
    
    completionHandler()
}

store.execute(query)

주의점

사용시 주의할 점으로는 반드시 completionHandler를 호출해야 합니다! (백그라운드 과정에서 사용할 때)
completionHandler를 호출함으로써 시스템에게 데이터 업데이트를 통한 진행 과정이 끝났음을 알려주어 오버헤드를 방지하는 기능을 합니다.

또한 기기가 잠겨 있고 암호가 걸려있는 상태에서는 데이터가 암호화되어 있기 때문에 ObserverQuery가 정상적으로 작동하지 않습니다.
하지만 BackgroundDelivery는 정상적으로 작동하기 때문에 잠겨있어 데이터를 불러올 수 없을때만 따로 대응하면 될 듯 합니다!
참고로 Strava에서도 잠겨 있을 경우 ‘락이 걸려있어 업데이트를 하지 못했다. 앱으로 들어와 직접 업데이트 해줘’ 라고 노티가 날라옵니다.

3. 앱 진입점에서 실행

해당 메소드는 앱이 켜질 때 한번만 호출하면 계속 유지가 됩니다.

그러므로 앱이 켜짐을 인식할 수 있는 UIApplicationDelegate나 SwiftUI의 경우 앱의 엔트리 포인트(App 프로토콜을 사용하는 구조체, @main)에서 이니셜라이저를 통해 사용할 수 있습니다.
저는 UIApplicationDelegate를 사용해야 해서 UIApplicationDelegate의 didFinishLaunchingWithOptions 메소드를 사용해 구현을 완료했습니다.

// in SwiftUI

@main
struct Run_MileApp: App {
    @UIApplicationDelegateAdaptor private var appDelegate: AppDelegate
    
    init() {
        // 여기에 작성
        // AppDelegate의 didFinishLaunchingWithOptions에 대응하는 메소드
    }
    
    var body: some Scene {
        WindowGroup {
            MainTabView()
                .tint(.primary1)
        }
    }
}
func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil
) -> Bool {
    
    Task {
        await self.userNotificationAuthorize()         // Notification Auth
        await AppDelegate.setHealthBackgroundTask()    // BackgroundTask 실행
    }
    
    return true
}

4. 결과

이렇게 설정을 완료하고 운동을 한번 해보았습니다.

(TMI 러닝을 하려고 했지만 부상으로 인해 보강운동을 하는 중입니다….ㅜㅜ)
그러면 앱이 이걸 인식해서 노티를 발행해줍니다!
성공~~

Run Mile 레포지토리
https://github.com/mooninbeom/RunMile

profile
월클 개발자를 향한 도전일지

0개의 댓글