
Strava 앱은 운동을 완료하면 자동으로 앱에 운동 기록을 등록해 주었다고 노티를 줍니다.
Run Mile 앱도 이렇게 운동을 완료할 때 노티로 사용자에게 알려주면 앱의 노출 빈도를 높일 수 있지 않을까 생각이 들어 구현해보기로 했습니다.
이번 목표는 Background 또는 Suspended 상태에서 운동 데이터 추가를 감지하여 특정 액션을 실행시키기 입니다!
해당 기능의 키는 HealthKit의 HKHealthStore 클래스의 있는 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 삭제)이 일어나면 앱을 깨워줍니다.
immediate: 변화가 감지될 때 마다 앱을 깨웁니다.hourly: 변화가 감지 될 때 최대 한시간에 한번씩 깨웁니다.daily: 변화가 감지될 때 최대 하루에 한번씩 깨웁니다.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 를 선택해야 합니다!
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에서도 잠겨 있을 경우 ‘락이 걸려있어 업데이트를 하지 못했다. 앱으로 들어와 직접 업데이트 해줘’ 라고 노티가 날라옵니다.
해당 메소드는 앱이 켜질 때 한번만 호출하면 계속 유지가 됩니다.
그러므로 앱이 켜짐을 인식할 수 있는 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
}
이렇게 설정을 완료하고 운동을 한번 해보았습니다.

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

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