일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | |||||
3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | 29 | 30 |
- 네이티브
- 앱 성능 개선
- 360도 뷰어
- 360도 이미지 뷰어
- Skeleton UI
- react
- 360도 이미지
- requirenativecomponent
- 리액트 네이티브
- react-native-fast-image
- 리엑트 네이티브
- 스켈레톤 통합
- 스켈레톤 UI
- native
- 스플래시스크린
- 파노라마 뷰
- React Native
- launch screen
- panorama view
- Android
- privacyinfo.plist
- 라이브러리 없이
- 3b52.1
- 리액트
- launchscreen
- ios
- React-Native
- Native Module
- boilerplate 제거
- Privacy manifest
- Today
- Total
Neoself의 기술 블로그
애플워치로 측정한 심박수 iOS 앱으로 전송하기 본문
0. 프로젝트에 WatchOS 타겟 추가
우선 심박수를 측정하는 애플워치에서 동작할 WatchOS 앱과 심박수 데이터를 전달받고 처리하는 iOS 앱이 있어야 합니다.
만일 iOS 앱만 생성되어있다면, 같은 프로젝트에서 WatchOS에 대한 새 타겟을 추가해줘야 합니다.
1. Xcode 상단 메뉴 File > New > Target을 클릭
2. WatchOS 탭에 있는 App 선택
3. Product Name을 설정 & "Watch App for Existing iOS App"을 선택 후 기존 iOS Target 선택
WatchOS Target이 동일 프로젝트 내부에 생성되었다면, 생성된 WatchOS 타겟에 대해 Bundle Identifier를 확인합니다.
watchOS 타겟 생성 시, 사진처럼 {iOS의 번들 ID}.watchkitapp 형식으로 Bundle Identifier가 자동 생성되는데요. 만약 뒤에 .watchkitapp 이 없을 경우, 붙여줍니다.
그 다음, WatchOS 타겟의 Info.plist를 수정해줍니다. 이때 iOS 타겟의 Info.plist랑 헷갈리지 말것!
Privacy - Health Share Usage Description : 사용자의 심박수를 측정하기 위해 사용자에게 보여줄 문구
Privacy - Health Update Usage Description : 사용자의 심박수 데이터를 공유하는 것에 대한 설명 문구
WatchKit Companion App Bundle Identifier : WatchKit 앱과 연동되는 iOS 앱의 번들 ID
*항목 이름은 Xcode 13버전 이후 Target > Info 탭에서의 명칭입니다!
마지막으로, WatchOS Targets > Signing & Capabilities 탭에서 HealthKit을 추가해줍니다.
1. WatchOS 구성 코드
WatchOS - iOS 타겟 간의 환경 구성을 다 완료하였으면, 이제 본격적으로 심박수 측정 및 수집을 위한 코드를 작성합니다.
먼저 WatchOS 내부, 심박수 측정 및 iOS로의 전달을 담당하는 ViewModel 전체 코드입니다.
1.1 ViewModel
import Foundation
import HealthKit
import Combine
import WatchConnectivity
class HeartRateMonitorViewModel: NSObject, ObservableObject {
@Published var currentHeartRate: Double = 0
@Published var isMonitoring: Bool = false
private let healthStore = HKHealthStore()
private var workoutSession: HKWorkoutSession?
private var anchoredObjectQuery: HKAnchoredObjectQuery?
private var timer: Timer?
private let heartRateType = HKObjectType.quantityType(forIdentifier: .heartRate)!
override init() {
super.init()
setupWatchConnectivity()
}
private func setupWatchConnectivity() {
if WCSession.isSupported() {
let session = WCSession.default
session.delegate = self
session.activate()
}
}
func startMonitoring() {
requestAuthorization { success in
guard success else { return }
self.startWorkoutSession()
self.setupAnchoredObjectQuery()
self.startTimer()
DispatchQueue.main.async {
self.isMonitoring = true
}
}
}
func stopMonitoring() {
stopWorkoutSession()
stopAnchoredObjectQuery()
stopTimer()
DispatchQueue.main.async {
self.isMonitoring = false
}
}
private func requestAuthorization(completion: @escaping (Bool) -> Void) {
let typesToShare: Set = [HKObjectType.workoutType()]
let typesToRead: Set = [heartRateType]
healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) { success, error in
if let error = error {
print("HealthKit authorization failed: \(error.localizedDescription)")
}
completion(success)
}
}
// 심박수 측정 빈도수를 높히고자 운동 세션 시작
private func startWorkoutSession() {
let configuration = HKWorkoutConfiguration()
configuration.activityType = .other
configuration.locationType = .outdoor
do {
workoutSession = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
healthStore.start(workoutSession!)
} catch {
print("Failed to start workout session: \(error)")
}
}
private func stopWorkoutSession() {
guard let workoutSession = workoutSession else { return }
healthStore.end(workoutSession)
self.workoutSession = nil
}
private func setupAnchoredObjectQuery() {
// 실시간으로 새로운 데이터 바로 받기 위해 하기 유형 사용
let query = HKAnchoredObjectQuery(type: heartRateType, predicate: nil, anchor: nil, limit: HKObjectQueryNoLimit) { [weak self] query, samples, deletedObjects, anchor, error in
self?.processHeartRateSamples(samples)
}
query.updateHandler = { [weak self] query, samples, deletedObjects, anchor, error in
self?.processHeartRateSamples(samples)
}
anchoredObjectQuery = query
healthStore.execute(query)
}
private func stopAnchoredObjectQuery() {
if let query = anchoredObjectQuery {
healthStore.stop(query)
anchoredObjectQuery = nil
}
}
// MARK: HKAnchoredObjectQuery와는 별개로, 타이머 그리고, HKSampleQuery를 사용해 정기적으로 최신 심박수 데이터를 가져옴. 예비 데이터 추출기.
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
self?.fetchLatestHeartRate()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
// MARK: HKAnchoredObjectQuery 초기화 및 새로운 샘플이 HealthStore로부터 전송올때, 실행됨.
// 심박수 정보 currentHeartRate에 저장하고, iOS에 전달
private func processHeartRateSamples(_ samples: [HKSample]?) {
guard let samples = samples as? [HKQuantitySample] else { return }
for sample in samples {
let heartRate = sample.quantity.doubleValue(for: HKUnit(from: "count/min"))
DispatchQueue.main.async {
self.currentHeartRate = heartRate
}
sendMessageToIOS(["heartRate":heartRate,
"dataType":"HKAnchoredObjectQuery",
"lastUpdated":Date().description
])
}
}
private func fetchLatestHeartRate() {
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
let query = HKSampleQuery(sampleType: heartRateType, predicate: nil, limit: 1, sortDescriptors: [sortDescriptor]) { [weak self] query, samples, error in
guard let sample = samples?.first as? HKQuantitySample else { return }
let heartRate = sample.quantity.doubleValue(for: HKUnit(from: "count/min"))
DispatchQueue.main.async {
self?.currentHeartRate = heartRate
}
self?.sendMessageToIOS(["heartRate":heartRate,
"dataType":"HKSampleQuery",
"lastUpdated":Date().description
])
}
healthStore.execute(query)
}
private func sendMessageToIOS(_ message: [String:Any]) {
guard WCSession.default.isReachable else { return }
WCSession.default.sendMessage(message, replyHandler: nil) { error in
print("Failed to send heart rate: \(error.localizedDescription)")
}
}
}
extension HeartRateMonitorViewModel: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("WCSession activation failed: \(error.localizedDescription)")
}
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
print("Received message from iOS app: \(message)")
}
}
0. 주요 프로퍼티
currentHeartRate: UI에 보이기 위한 현재 심박수 @Published 변수입니다.
isMonitoring: 심박수 측정중인지 여부를 UI에 보이기 위한 @Publisehd 변수입니다.
healthStore: HealthKit 데이터에 접근하기 위한 객체입니다.
workoutSession: 운동세션을 관리합니다.
anchoredObjectQuery: 실시간 심박수 데이터를 쿼리하는 데에 사용됩니다.
timer: 정기적으로 최대 심박수를 서버로 전송하기 위해 사용되는 타이머 객체입니다.
heartRateType: Query 설정과 초기 사용자 권한 요청을 위해 참조하는 데이터 타입입니다.(접근하고자 하는 샘플값은 심박수이기 때문에, 숫자값)
1. 권한 요청 및 운동 세션 설정 및 시작
// 0. View로부터 가장 먼저 진입하는 함수. 권한 요청 후 심박수 모니터링을 시작합니다.
func startMonitoring() {
requestAuthorization { success in
guard success else { return }
self.startWorkoutSession() // 운동 세션 시작
self.setupAnchoredObjectQuery() // 쿼리 설정
self.startTimer() // 정기적인 데이터 수집을 위한 타이머 시작
DispatchQueue.main.async {
self.isMonitoring = true
}
}
}
// 1. 권한 요청
private func requestAuthorization(completion: @escaping (Bool) -> Void) {
let typesToShare: Set = [HKObjectType.workoutType()]
let typesToRead: Set = [heartRateType]
healthStore.requestAuthorization(toShare: typesToShare, read: typesToRead) { success, error in
if let error = error {
print("HealthKit authorization failed: \(error.localizedDescription)")
}
completion(success)
}
}
// 2. 권한 요청 성공 시 호출되며,
// 보다 자주 심박수 데이터를 수집하기 위해 운동 세션을 설정하고 시작합니다.
private func startWorkoutSession() {
let configuration = HKWorkoutConfiguration()
configuration.activityType = .other // 수영, 사이클과 같은 운동 종류 선택 로직
configuration.locationType = .outdoor // 위치 설정
do {
workoutSession = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
healthStore.start(workoutSession!)
} catch {
print("Failed to start workout session: \(error)")
}
}
// MARK: HKAnchoredObjectQuery와는 별개로, 타이머 그리고, HKSampleQuery를 사용해 정기적으로 최신 심박수 데이터를 가져옴. 예비 데이터 추출기.
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
self?.fetchLatestHeartRate()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
View로부터 startMonitoring 함수를 호출하면, 먼저 healthStore.requestAuthorization()를 통해 Watch앱에서 사용자에게 심박수 측정 권한을 요청하는 창을 띄웁니다. 여기서 권한 요청을 수락할 경우, 심박수 측정을 위한 운동 세션을 설정하고, 시작합니다.
운동세션을 시작할 경우, HKAnchoredObjectQuery를 통해 수집하는 데이터의 측정 빈도수가 높아집니다.
2.실시간 심박수 측정 및 iOS 앱으로 전송.
// HealthStore로부터 사용자 건강정보 접근을 위한 쿼리 설정
private func setupAnchoredObjectQuery() {
// 실시간으로 새로운 데이터 바로 받기 위해 하기 유형 사용
let query = HKAnchoredObjectQuery(type: heartRateType, predicate: nil, anchor: nil, limit: HKObjectQueryNoLimit) { [weak self] query, samples, deletedObjects, anchor, error in
self?.processHeartRateSamples(samples) // 초기 데이터 설정 위해 함수 재사용
}
// 쿼리로부터 결과를 받을 때, processHeartRateSamples()함수를 실행하는 결과 핸들러 생성
query.updateHandler = { [weak self] query, samples, deletedObjects, anchor, error in
self?.processHeartRateSamples(samples)
}
// 쿼리 생성 및 뷰모델 속성에 할당, 쿼리 처음 실행 시 실행되는 resultsHandler에 updateHandler 함수 할당
query = HKAnchoredObjectQuery(type: heartRateType, predicate: devicePredicate, anchor: nil, limit: HKObjectQueryNoLimit, resultsHandler: updateHandler)
anchoredObjectQuery = query // 추후 메모리 할당 해제 과정에서 healthStore 실행여부를 파악하는 조건으로 활용
healthStore.execute(query) // healthStore 실행
}
// MARK: HKAnchoredObjectQuery 초기화 및 새로운 샘플이 HealthStore로부터 전송올때, 실행됨.
// 심박수 정보 currentHeartRate에 저장하고, iOS에 전달
private func processHeartRateSamples(_ samples: [HKSample]?) {
guard let samples = samples as? [HKQuantitySample] else { return } // 샘플데이터 존재여부 검증
for sample in samples {
let heartRate = sample.quantity.doubleValue(for: HKUnit(from: "count/min"))
DispatchQueue.main.async {
self.currentHeartRate = heartRate // currentHeartRate에 수신한 샘플데이터 저장
}
// IOS로 전달
sendMessageToIOS(["heartRate":heartRate,
"dataType":"HKAnchoredObjectQuery",
"lastUpdated":Date().description
])
}
}
운동 세션을 시작한 후에는, 심박수 데이터 접근을 위한 쿼리를 설정해준 후 실행하여 HealthStore로부터 샘플 데이터를 받게 되면, process()를 통해 UI를 업데이트 한후, iOS 앱으로 데이터를 전달하는 sendHeartRateToiOSApp()함수를 실행시킵니다.
여기서 쿼리라는 개념이 생소하실 텐데요. 이를 이해하기 위해선 WatchOS로부터 사용자의 건강정보를 측정하는 매커니즘을 이해할 필요가 있습니다.
Apple은 보안을 위해, 암호화된 저장소에 사용자의 모든 건강 데이터를 중앙 집중식으로 저장하고 관리합니다. 여기서 암호화된 저장소는 HKHealthStore이죠. 이와 같은 맥락으로 앱은 HealthStore에 직접 접근할 수 없고 오직 쿼리를 통해 데이터를 요청한 후, 권한이 있는 데이터만 반환받아 접근할 수 있습니다. 쿼리를 통한 데이터 접근은 보안관련 뿐만 아니라, 원하는 유형이나 기간에 대한 데이터를 일부만 가져올 수도 있기 때문에, 효율성과 유연성에도 이점이 있습니다.
이처럼 다양한 시나리오를 지원하고자 총 9가지의 쿼리 유형들이 존재하는데, 그 중 자주 사용되는 유형은 아래 5개입니다.
HKSampleQuery : 특정 유형의 건강 데이터 샘플을 한 번에 검색
- 사용사례 : 특정 기간의 걸음 수 데이터 조회, 최근 심박수 기록 가져오기
- 특징: 일회성 쿼리, 결과를 정렬하고 제한할 수 있음
HKObserverQuery : 특정 유형의 데이터 변경 사항을 지속적으로 관찰
- 사용사례 : 새로운 운동 세션 시작/종료 감지, 실시간 걸음 수 변화 모니터링
- 특징 : 백그라운드에서 동작 가능, 변경 사항 발생 시 알림
HKAnchoredObjectQuery : 마지막 쿼리 이후의 새로운 또는 업데이트된 데이터 검색
- 사용사례 : 실시간 심박수 모니터링, 새로 추가된 운동 데이터 동기화
- 특징: 증분 업데이트 제공, 연속적인 데이터 흐름 처리에 적합
HKStatisticsQuery : 특정 유형의 데이터에 대한 통계 정보 계산
- 사용사례 : 일일 평균 걸음 수 계산, 특정 기간의 최대/최소 심박수 찾기
- 특징: 합계, 평균, 최소값, 최대값 등의 통계 제공
HKStatisticsCollectionQuery : 시간 간격별로 그룹화된 통계 정보 계산
- 사용사례 : 주간 또는 월간 운동 요약 생성, 시간대별 심박수 추세 분석
- 특징 : 여러 시간 간격에 대한 통계를 한 번에 계산, 차트나 그래프 생성에 적합
저의 경우, 실시간으로 심박수를 화면에 띄우는 것을 구현하기 위해 HKAnchoredObjectQuery를 선택하였습니다.
HealthKit에서의 데이터 접근방식에 대해 더 궁금하신 분은 아래 블로그 글을 확인하시면 도움이 될 것 같습니다.
3. 정기적 (확정적) 심박수 측정 및 iOS로 전송
// MARK: HKAnchoredObjectQuery와는 별개로, 타이머 그리고, HKSampleQuery를 사용해 정기적으로 최신 심박수 데이터를 가져옴. 예비 데이터 추출기.
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.fetchLatestHeartRate()
}
}
private func stopTimer() {
timer?.invalidate()
timer = nil
}
private func fetchLatestHeartRate() {
let sortDescriptor = NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)
let query = HKSampleQuery(sampleType: heartRateType, predicate: nil, limit: 1, sortDescriptors: [sortDescriptor]) { [weak self] query, samples, error in
guard let sample = samples?.first as? HKQuantitySample else { return }
let heartRate = sample.quantity.doubleValue(for: HKUnit(from: "count/min"))
DispatchQueue.main.async {
self?.currentHeartRate = heartRate
}
self?.sendMessageToIOS(["heartRate":heartRate,
"dataType":"HKSampleQuery",
"lastUpdated":Date().description
])
}
healthStore.execute(query)
}
HKAnchoredObjectQuery를 사용하여 HealthStore로부터 샘플데이터를 가져오게 될경우, 새로운 데이터가 생길때마다 실시간에 가깝게 WatchOS에서 대응할 수 있다는 장점이 있습니다. 하지만, 데이터가 3초에서 길게는 18초까지 불규칙적인 간격으로 데이터가 추가되었기 때문에, 매 분마다 일정하게 최대 심박수를 계산하여 서버로 전송하는 것이 가능할지 확신하기 어려웠습니다. 60초 동안 아무런 데이터를 HKAnchoredObjectQuery로부터 수신받지 못하게 될경우, 0 값을 서버로 전송하게 되면서 심박수를 통한 타임라인에 무의미한 데이터가 들어갈 수도 있기 때문입니다. 따라서 초기화 시에 30초마다 최신 심박수를 강제로 가져와 currentHeartRate를 갱신시켜주는 Timer를 시작하는 로직을 추가하여, 위와 같은 잠재적인 문제를 해결하고자 하였습니다.
즉, HKAnchoredObjectQuery와 Timer를 조합한 HKSampleQuery로 투 트랙을 형성해, 목표하였던 데이터의 실시간 갱신로직과 데이터 일관성을 모두 챙겼습니다.
4. WCSession을 통한 iOS앱으로 심박수 데이터 전송
...
private func sendMessageToIOS(_ message: [String:Any]) {
guard WCSession.default.isReachable else { return }
WCSession.default.sendMessage(message, replyHandler: nil) { error in
print("Failed to send heart rate: \(error.localizedDescription)")
}
}
}
extension HeartRateMonitorViewModel: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
if let error = error {
print("WCSession activation failed: \(error.localizedDescription)")
}
}
}
그 이후에는, WCSession을 통해 연결상태 확인 후, 인자값을 iOS로 전송하는 sendMessageToIOS 함수를 활용하여 iOS앱으로의 실시간 심박수 전달 로직을 구현하였으며, WCSession 활성화를 위해 WCSessionDelegate를 extension 형태로 정의한 후, 활성화 간 에러대응도 진행하였습니다.
1.2 View
심박수 측정 로직을 다루는 뷰모델 작성이 완료되면, 이제 뷰에서 뷰모델을 연결해 심박수를 실시간으로 확인해볼 수 있습니다.
import SwiftUI
struct ContentView: View {
@StateObject private var viewModel = HeartRateMonitorViewModel()
var body: some View {
VStack {
Text("Current Heart Rate")
.font(.headline)
Text("\(Int(viewModel.currentHeartRate)) BPM")
.font(.largeTitle)
.fontWeight(.bold)
}
.onAppear {
viewModel.startMonitoring()
}
}
}
2. iOS 구성 코드
2.1 ViewModel
그 다음 심박수를 WCSession을 통해 WatchOS로부터 전달받고, 60초를 주기로 갱신되는 최대심박수를 계산하는 뷰모델 코드를 아래와 같이 구현해줍니다.
import Foundation
import WatchConnectivity
class HeartRateViewModel: NSObject, ObservableObject {
@Published var currentHeartRate: Double = 0
@Published var maxHeartRate: Double = 0
private var timer: Timer?
private var wcSession: WCSession?
override init() {
super.init()
setupWatchConnectivity()
}
private func setupWatchConnectivity() {
if WCSession.isSupported() {
wcSession = WCSession.default
wcSession?.delegate = self
wcSession?.activate()
}
}
func updateConnectionStatus() {
guard let session = wcSession else {
connectionStatus = "Watch Connectivity Not Supported"
return
}
if session.isPaired {
if session.isWatchAppInstalled {
connectionStatus = session.isReachable ? "Connected" : "Watch App Installed but Not Reachable"
} else {
connectionStatus = "Watch Paired but App Not Installed"
}
} else {
connectionStatus = "Watch Not Paired"
}
}
private func startMaxHeartRateTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 60, repeats: true) { [weak self] _ in
// 서버로 최대 심박수 전송하는 로직 위치
self?.maxHeartRate = 0 // 다음 분의 최대심박수 계산을 위해 0으로 초기화
}
}
}
extension HeartRateViewModel: WCSessionDelegate {
func session(_ session: WCSession, activationDidCompleteWith activationState: WCSessionActivationState, error: Error?) {
DispatchQueue.main.async {
if let error = error {
print("WCSession activation failed with error: \(error.localizedDescription)")
self.connectionStatus = "Activation Failed"
} else {
print("WCSession activated with state: \(activationState.rawValue)")
self.updateConnectionStatus()
self.startMaxHeartRateTimer()
}
}
}
func sessionDidBecomeInactive(_ session: WCSession) {
DispatchQueue.main.async {
self.connectionStatus = "Session Inactive"
}
}
func sessionDidDeactivate(_ session: WCSession) {
WCSession.default.activate()
}
func session(_ session: WCSession, didReceiveMessage message: [String : Any]) {
DispatchQueue.main.async {
if let heartRate = message["heartRate"] as? Double {
self.currentHeartRate = heartRate
self.maxHeartRate = max(self.maxHeartRate, heartRate)
}
}
}
}
2.2 View
import SwiftUI
struct HeartRateView: View {
@StateObject private var viewModel = HeartRateViewModel()
var body: some View {
VStack(spacing: 20) {
Text("Heart Rate Monitor")
.font(.largeTitle)
Text("\(Int(viewModel.currentHeartRate)) BPM")
.font(.system(size: 60, weight: .bold, design: .rounded))
.foregroundColor(.red)
Text("Max: \(Int(viewModel.maxHeartRate)) BPM")
.font(.system(size: 60, weight: .bold, design: .rounded))
.foregroundColor(.red)
}
.padding()
.onAppear {
viewModel.updateConnectionStatus()
}
}
}
#Preview {
HeartRateView()
}
이후 Watch로부터 전달받은 심박수를 표시하는 SwiftUI 뷰를 위와 같이 구성하면, 심박수 측정 및 iOS앱에서의 확인이 가능해집니다.
감사합니다.
'개발지식 정리 > Swift' 카테고리의 다른 글
PhaseAnimator, Keyframes으로 고급 애니메이션 구현하기 (6) | 2024.10.16 |
---|---|
SwiftUI 애니메이션 정리(Animatable, Animation, Transaction) (0) | 2024.10.15 |
SwiftUI에서 애플, 구글 로그인 구현하기 (with Node.js) (1) | 2024.10.09 |
React Native와 비교해보는 Swift,SwiftUI (5) | 2024.10.08 |
Keychain과 UserDefaults를 활용한 사용자 정보 관리 (3) | 2024.10.07 |