
현재 진행하고 있는 프로젝트에서 실시간으로 심박수를 측정하는 기능을 구현해야 했습니다.
검색해 본 결과 적용할 수 있는 방법은 아래와 같았습니다.
헬스 커넥트 API는 별도로 워치 앱을 구현하지 않아도 되지만,
결국 로컬 DB에 저장된 값을 조회하는 것이므로 실시간성이 부족하다고 생각했습니다.
또 직접 센서에 접근하는 방식은 위 방법과 다르게 실시간성을 보장할 수 있지만,
데이터 처리 방식이나 배터리 효율성 등을 생각했을 때 좋은 방법은 아니었습니다.
따라서 저는 Health Service를 활용하기로 했습니다.
Health ServiceHealth Service는 Wear OS 3 이상부터 지원하는 API로,
사용자의 활동이나 운동, 건강과 관련된 데이터를 처리할 수 있도록 도와줍니다.

또 제가 개발하는 앱에서는 사용자의 심박수를 측정하는 데에 있어서
실시간성과 더불어 지속성을 보장해야 했기 때문에 최선의 선택지가 될 것이라고 생각했습니다.
이러한 Health Service에서도 심박수를 측정하는 방법은 크게 3가지가 있습니다.
MeasureClient : 실시간 측정PassiveMonitoringClient : 백그라운드 측정ExerciseClient : 운동 세션을 통한 실시간 및 백그라운드 측정우선 이 글에서는 MeasureClient를 통해 실시간으로 심박수를 측정하는 방법에 대해 알아보겠습니다.
MeasureClient를 통한 심박수 측정 구현우선 build.gradle.kts에 다음과 같이 의존성을 추가합니다.
dependencies {
// Compose
val composeBom = platform("androidx.compose:compose-bom:2024.05.00")
implementation(composeBom)
implementation("androidx.activity:activity-compose:1.10.1")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.7.0")
implementation("androidx.compose.ui:ui-tooling")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
// Wear OS
implementation("androidx.wear:wear:1.3.0")
implementation("androidx.wear.compose:compose-material:1.4.1")
implementation("androidx.wear.compose:compose-ui-tooling:1.4.1")
// Concurrent
implementation("com.google.guava:guava:33.3.1-android")
implementation("androidx.concurrent:concurrent-futures-ktx:1.3.0")
// Health Service
implementation("androidx.health:health-services-client:1.0.0")
// Google Mobile Service
implementation("com.google.android.gms:play-services-wearable:19.0.0")
}
워치 앱 UI의 구현을 컴포즈로 하기 위해 컴포즈와 관련된 의존성을 추가했고,
Wear OS와 관련된 컴포즈 의존성도 추가해야 합니다.
또한 헬스 서비스 API는 비동기 처리를 위해 내부적으로 ListenableFuture를 사용하는데,
이를 코루틴과 연동하여 안정적으로 처리하려면 guava와 concurrent-futures-ktx를 추가해야 합니다.
그리고 가장 중요한 헬스 서비스와 데이터 전송을 위한 GMS까지 추가하면 끝입니다.
<uses-permission
android:name="android.permission.BODY_SENSORS"
android:maxSdkVersion="35" />
<uses-permission android:name="android.permission.health.READ_HEART_RATE" />
AndroidManifest.xml에는 위와 같은 권한을 추가합니다.
참고로 Android 36부터는 BODY_SENSORS 권한이 READ_HEART_RATE로 대체되었기 때문에,
버전 호환성을 고려하는 경우에는 위처럼 선언하면 되겠습니다.
MeasureClient 인터페이스는 아래와 같이 선언되어 있습니다.
public interface MeasureClient {
public fun registerMeasureCallback(
dataType: DeltaDataType<*, *>,
callback: MeasureCallback
)
public fun registerMeasureCallback(
dataType: DeltaDataType<*, *>,
executor: Executor,
callback: MeasureCallback
)
public fun unregisterMeasureCallbackAsync(
dataType: DeltaDataType<*, *>,
callback: MeasureCallback
): ListenableFuture<Void>
public fun getCapabilitiesAsync(): ListenableFuture<MeasureCapabilities>
}
공식 문서에 서술되어 있듯이, MeasureClient는 콜백을 등록하여 데이터를 수신합니다.
즉 심박수를 측정하려면 먼저 MeasureCallback을 등록해야 한다는 것을 알 수 있습니다.
MeasureCallback은 아래와 같습니다.
public interface MeasureCallback {
public fun onRegistered() {}
public fun onRegistrationFailed(throwable: Throwable) {}
public fun onAvailabilityChanged(dataType: DeltaDataType<*, *>, availability: Availability)
public fun onDataReceived(data: DataPointContainer)
}
onRegistered : 콜백 등록에 성공한 경우 호출onRegistrationFailed : 콜백 등록에 실패한 경우 호출 (일반적으로 권한이 거부된 상태거나 디바이스가 특정 DataType을 지원하지 않는 경우)onAvailabilityChanged : 매개변수로 전달되는 DataType의 유효성이 변경된 경우 호출onDataReceived : 새로운 데이터가 들어온 경우 호출DataType은 API를 통해 얻을 수 있는 여러 데이터를 포함합니다.
(이동 거리, 칼로리, 심박수, 위치, 속도 등..)
참고로 MeasureClient의 구현체는 ServiceBackedMeasureClient인데,
궁금해서 이것 저것 찾아봤는데 저수준의 코드가 대부분이라 이해하는걸 포기했습니다.
사실 몰라도 심박수 측정 구현에는 전혀 문제가 없습니다..
MeasureClient는 HealthServicesClient라는 클래스의 멤버로 존재합니다.
@Module
@InstallIn(SingletonComponent::class)
object HealthClientModule {
@Provides
@Singleton
fun provideHealthClient(
@ApplicationContext context: Context,
): HealthServicesClient = HealthServices.getClient(context)
}
우선 HealthServicesClient를 제공하는 Hilt 모듈을 선언했습니다.
class HeartRateLocalDataSourceImpl @Inject constructor(
healthClient: HealthServicesClient,
) : HeartRateLocalDataSource {
private val measureClient = healthClient.measureClient
}
그럼 이런 식으로 MeasureClient를 사용할 수 있습니다.
MeasureCallback을 구현하여 콜백을 등록하기 이전에 디바이스의 상태를 확인해야 합니다.
override suspend fun hasHeartRateCapability(): Boolean {
val capabilities = measureClient.getCapabilitiesAsync().await()
return (DataType.HEART_RATE_BPM in capabilities.supportedDataTypesMeasure)
}
이렇게 디바이스의 심박수 데이터 지원 여부를 반환합니다.
public suspend fun <T> ListenableFuture<T>.await(): T {
try {
if (isDone) return getUninterruptibly(this)
} catch (e: ExecutionException) {
throw e.nonNullCause()
}
return suspendCancellableCoroutine { cont: CancellableContinuation<T> ->
addListener(ToContinuation(this, cont), DirectExecutor.INSTANCE)
cont.invokeOnCancellation { cancel(false) }
}
}
참고로 await() 메소드는 다트나 코틀린의 그것과 유사합니다.
이걸 사용하지 않으면 ListenableFuture에 대한 처리를 해주어야 합니다.
이제 콜백을 등록하면 되는데, MeasureCallback은 onAvailabilityChanged와 onDataReceived의 구현을 강제하고 있습니다.
그런데 onAvailabilityChanged는 특정 데이터 타입의 유효성을 반환하고, onDataReceived는 실제 심박수 데이터를 반환하기 때문에 두 메소드에서 얻을 수 있는 데이터의 성질이 다르다고 할 수 있습니다.
따라서 두 데이터를 하나의 타입으로 처리하기 위해 sealed interface를 사용했습니다.
sealed interface MeasureResult {
data class Availability(
val isAvailable: Boolean,
) : MeasureResult
data class HeartRate(
val value: Int,
) : MeasureResult {
init {
require(value > 0) { HEART_RATE_ERROR }
}
companion object {
private const val HEART_RATE_ERROR = "심박수는 자연수여야 합니다."
}
}
}
그리고 다음과 같이 MeasureCallback을 구현하고 등록했습니다.
override fun getMeasureResult(): Flow<MeasureResult> =
callbackFlow {
val callback =
object : MeasureCallback {
override fun onAvailabilityChanged(
dataType: DeltaDataType<*, *>,
availability: Availability,
) {
if (dataType == DataType.HEART_RATE_BPM && availability is DataTypeAvailability) {
val isAvailable = (availability == DataTypeAvailability.AVAILABLE)
trySendBlocking(MeasureResult.Availability(isAvailable))
}
}
override fun onDataReceived(data: DataPointContainer) {
data.getData(DataType.HEART_RATE_BPM).lastOrNull()?.let { sample ->
trySendBlocking(MeasureResult.HeartRate(sample.value))
}
}
}
measureClient.registerMeasureCallback(DataType.HEART_RATE_BPM, callback)
awaitClose {
runBlocking {
measureClient.unregisterMeasureCallback(DataType.HEART_RATE_BPM, callback)
}
}
}
이제 외부에서 해당 Flow를 구독하면 콜백이 등록되어 심박수 측정이 시작되고,
Flow가 종료될 때 등록되었던 콜백을 해제하고 심박수 측정을 중단합니다.
class HeartRateRepositoryImpl @Inject constructor(
private val localDataSource: HeartRateLocalDataSource,
private val remoteDataSource: HeartRateRemoteDataSource,
) : HeartRateRepository {
override suspend fun hasHeartRateCapability(): Result<Boolean> =
runCatching { localDataSource.hasHeartRateCapability() }
override fun getHeartRate(): Flow<MeasureResult> =
localDataSource.getMeasureResult().onEach { measureResult ->
if (measureResult is MeasureResult.HeartRate) {
remoteDataSource.sendHeartRate(measureResult.value)
}
}
Repository는 위와 같이 구현하였습니다.
저의 경우에는 측정한 심박수를 서버로 전송하기 위해 우선 DataLayer API를 이용하여 워치와 연결된 디바이스로 전송하는 기능까지 구현하였는데, 이 부분은 추후 다뤄보도록 하겠습니다.
이제 ViewModel을 비롯한 UI를 구현하면 심박수를 측정하고 측정한 심박수를 확인할 수 있습니다.
@Stable
@HiltViewModel
class MainViewModel @Inject constructor(
private val repository: HeartRateRepository,
) : ViewModel() {
private val _uiState: MutableStateFlow<UiState> = MutableStateFlow(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState.asStateFlow()
fun checkSupported() {
viewModelScope.launch {
repository
.hasHeartRateCapability()
.onSuccess { isSupported ->
if (isSupported) {
fetchMeasureResult()
return@launch
}
updateState { UiState.WatchDisconnect }
}.onFailure {
Log.i("Widyu", "Error", it)
updateState { UiState.WatchDisconnect }
}
}
}
private fun fetchMeasureResult() {
repository
.getHeartRate()
.catch {
Log.i("Widyu", "Error", it)
updateState { UiState.WatchDisconnect }
}.onEach { measureResult ->
when (measureResult) {
is MeasureResult.Availability -> {
if (measureResult.isAvailable.not()) {
updateState { UiState.WatchDisconnect }
}
}
is MeasureResult.HeartRate -> {
updateState { UiState.Stable(measureResult.value) } }
}
}
}.launchIn(viewModelScope)
}
먼저 디바이스의 지원 여부를 확인하고, 지원하는 경우 측정을 시작하도록 했습니다.
참고로 권한이 허용되지 않은 상태에서 접근하려고 하면 오류가 발생합니다.
val permissionLauncher =
rememberPermissionLauncher(
activity = activity,
onGranted = {
viewModel.checkSupported()
context.startForegroundService(HeartRateForegroundService.newIntent(context))
},
onRequireRuntimePermission = {},
onRequireBackgroundPermission = { openAppSettings(context) },
)
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
permissionLauncher.launch(requiredPermissions)
}
따라서 저는 권한이 모두 허용되었을 때 측정을 시작하게끔 처리했습니다.
또 심박수 측정에 필요한 권한은 런타임 권한이므로,
공식 문서에서 권장하는 가이드라인에 따라 권한 처리를 하는 것을 추천드립니다.
(위 코드는 아직 처리되어 있지 않습니다)

그럼 이렇게 심박수를 측정할 수 있게 됩니다.
저는 에뮬레이터가 아닌 실제 디바이스(갤럭시 워치4)로 개발하고 있었습니다.
근데 앱을 실행하고 있을 때 다른 앱에서 실시간 심박수 측정을 시작하게 되면
두 앱 모두 심박수 측정이 중단되는 것을 확인했습니다. (삼성 헬스로 테스트)
별다른 로그가 출력되지는 않았는데 이런 문제가 발생하는 것을 보니
센서의 용량이 제한적이거나 혹은 시스템에서 중단하는 것 같았습니다.
ExerciseClient로 심박수 측정을 하게 될 경우,
다른 앱에서 운동 세션을 진행하고 있는지 확인할 수 있기 때문에
아마 이 문제를 해결할 수 있지 않을까 싶긴 합니다.
개발 초기에는 앱이 포그라운드 상태에 있을 때는 MeasureClient,
백그라운드 상태에 있을 때는 PassiveMonitoringClient를 사용하고자 했습니다.
근데 워치 화면이 꺼지지 않는 이상 무슨 짓을 해도 MeasureClient가 살아 있을 뿐더러,
백그라운드 상태에서 PassiveMonitoringClient가 제대로 작동하지 않았습니다.
PassiveMonitoringClient는 센서를 사용하는 방식이 아닌,
MeasureClient가 일정 수준 이상 데이터를 수집하면 조회하는 방식을 사용합니다.
때문에 PassiveMonitoringClient로 실시간성을 보장할 수도 없고,
MeasureClient는 화면이 꺼지면 심박수 측정이 시스템에 의해 중단되기 때문에 예상했던 것과는 달리 지속성을 보장할 수 없었습니다.
이 문제를 해결하려고 MeasureClient와 ForegroundService를 함께 사용해보았습니다.
그런데도 워치 화면이 꺼지면 심박수 측정이 되질 않았습니다..
아무리 서비스를 이용해 MeasureClient로 측정하려고 해도,
위에 서술한 것처럼 시스템에서 제한하는 것 같았습니다.
그 이후 여러 가지 방법을 계속 시도했었습니다.
PowerManager를 통해 강제로 CPU가 일을 하게 해보거나, AmbientLifecycleObserver를 적용시켜 보는 등...
하지만 결론은 화면이 꺼져 있을 때는 MeasureClient를 사용할 수 없다는 것이었습니다.
고로 앱 생명 주기에 상관없이 실시간으로 심박수 측정을 해야 한다면
MeasureClient가 아닌 ExerciseClient를 통해 운동 세션을 시작해야만 합니다.
ExerciseClient를 활용한 방법은 다음 글에서 다뤄보도록 하겠습니다.