반응형
Wear OS 워치에서 심박수 측정 → 30초마다 폰으로 전송하기

이번 글에서는 배드민턴 동호회(콕이랑)앱에 필요한 기능인 워치에서 심박수를 30초마다 측정하여 폰 앱으로 전달하는 방법을 정리했습니다. 경기가 시작될 때만 심박수를 측정하고, 경기 종료 시 센서를 중단하는 구조를 설계했습니다.
📌 전체 구조
워치앱 (심박수 측정) → DataClient → 폰 앱 (수신 + UI 표시)
↑
MessageClient로 START / STOP 명령
1️⃣ Gradle 의존성 추가
📱 폰 앱 (app/build.gradle)
dependencies {
implementation "com.google.android.gms:play-services-wearable:18.2.0"
}
⌚ 워치 앱
dependencies {
implementation "com.google.android.gms:play-services-wearable:18.2.0"
}
2️⃣ 워치 앱 Foreground Service
경기 중 백그라운드에서 심박수를 안정적으로 측정하려면 Foreground Service를 사용합니다. 아래 코드는 심박수를 30초마다 폰 앱으로 전송합니다.
AndroidManifest.xml
<service
android:name=".HeartRateService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="health" />
HeartRateService.kt
@AndroidEntryPoint
class HeartRateService @Inject constructor() : Service(), SensorEventListener {
companion object {
private val _heartRateFlow = MutableStateFlow(0f)
val heartRateFlow: StateFlow = _heartRateFlow
}
private lateinit var sensorManager: SensorManager
private var heartRateSensor: Sensor? = null
private var latestHeartRate: Float = 0f
private val handler = Handler(Looper.getMainLooper())
override fun onCreate() {
super.onCreate()
sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
heartRateSensor = sensorManager.getDefaultSensor(Sensor.TYPE_HEART_RATE)
startForegroundService()
startHeartRateMeasurement()
}
override fun onDestroy() {
super.onDestroy()
stopHeartRateMeasurement()
}
private fun startForegroundService() {
val channelId = "heart_rate_channel"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId, "Heart Rate Monitoring", NotificationManager.IMPORTANCE_LOW
)
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this, channelId)
.setContentTitle("심박수 측정 중")
.setContentText("경기 동안 심박수를 측정합니다.")
.setSmallIcon(R.drawable.ic_heart)
.setOngoing(true)
.build()
startForeground(1, notification)
}
private fun startHeartRateMeasurement() {
heartRateSensor?.let {
sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)
scheduleHeartRateSending()
}
}
private fun stopHeartRateMeasurement() {
sensorManager.unregisterListener(this)
handler.removeCallbacksAndMessages(null)
stopForeground(true)
stopSelf()
}
override fun onSensorChanged(event: SensorEvent?) {
if (event?.sensor?.type == Sensor.TYPE_HEART_RATE) {
latestHeartRate = event.values[0]
_heartRateFlow.value = latestHeartRate
updateNotification(latestHeartRate)
}
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
private fun scheduleHeartRateSending() {
val runnable = object : Runnable {
override fun run() {
sendHeartRateToPhone(latestHeartRate)
handler.postDelayed(this, 30_000)
}
}
handler.post(runnable)
}
private fun sendHeartRateToPhone(heartRate: Float) {
val dataClient = Wearable.getDataClient(this)
val putDataReq = PutDataMapRequest.create("/heart_rate").apply {
dataMap.putFloat("bpm", heartRate)
dataMap.putLong("timestamp", System.currentTimeMillis())
}.asPutDataRequest().setUrgent()
dataClient.putDataItem(putDataReq)
}
private fun updateNotification(heartRate: Float) {
val notification = NotificationCompat.Builder(this, "heart_rate_channel")
.setContentTitle("심박수 측정 중")
.setContentText("현재 심박수: $heartRate bpm")
.setSmallIcon(R.drawable.ic_heart)
.setOngoing(true)
.build()
(getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager)
.notify(1, notification)
}
override fun onBind(intent: Intent?): IBinder? = null
}
3️⃣ 워치 앱 MainActivity (START/STOP 명령 수신)
class MainActivity : ComponentActivity() {
private val messageListener = MessageClient.OnMessageReceivedListener { messageEvent ->
if (messageEvent.path == "/match_command") {
val command = String(messageEvent.data)
when (command) {
"START" -> ContextCompat.startForegroundService(
this, Intent(this, HeartRateService::class.java)
)
"STOP" -> stopService(Intent(this, HeartRateService::class.java))
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Wearable.getMessageClient(this).addListener(messageListener)
setContent { HeartRateScreen() }
}
override fun onDestroy() {
super.onDestroy()
Wearable.getMessageClient(this).removeListener(messageListener)
}
}
4️⃣ 폰 앱에서 START/STOP 명령 보내기
fun sendMatchCommand(context: Context, command: String) {
val messageClient = Wearable.getMessageClient(context)
val path = "/match_command"
Wearable.getNodeClient(context).connectedNodes.addOnSuccessListener { nodes ->
nodes.forEach { node ->
messageClient.sendMessage(node.id, path, command.toByteArray())
}
}
}
5️⃣ 폰 앱에서 데이터 수신 (ViewModel)
@HiltViewModel
class HeartRateViewModel @Inject constructor(
@ApplicationContext private val context: Context
) : ViewModel(), DataClient.OnDataChangedListener {
private val _heartRate = MutableStateFlow(0f)
val heartRate: StateFlow = _heartRate
init { Wearable.getDataClient(context).addListener(this) }
override fun onDataChanged(dataEvents: DataEventBuffer) {
for (event in dataEvents) {
if (event.type == DataEvent.TYPE_CHANGED &&
event.dataItem.uri.path == "/heart_rate") {
val bpm = DataMapItem.fromDataItem(event.dataItem).dataMap.getFloat("bpm")
_heartRate.value = bpm
}
}
}
override fun onCleared() {
super.onCleared()
Wearable.getDataClient(context).removeListener(this)
}
}
UI (Compose)
@Composable
fun HeartRateScreen(viewModel: HeartRateViewModel = hiltViewModel()) {
val bpm by viewModel.heartRate.collectAsState()
Text(
text = if (bpm > 0) "${bpm.toInt()} bpm" else "측정 대기 중...",
fontSize = 32.sp,
fontWeight = FontWeight.Bold
)
}
✅ 정리
- 폰 ↔ 워치 1:1 연결
- MessageClient로 START / STOP 제어
- Foreground Service로 경기 중 안정적 심박수 측정
- 30초마다 DataClient로 bpm 전송
- 폰 앱 ViewModel에서 실시간 수신 → Compose UI 반영
- AI와 코딩을 하면서 이 AI 가 놓친 부분은 applicationId 는 phone 과 wear 가 동일하여야 message 전달에 문제가 발생 하지 않는 다는 것임.
이 방식으로 구현하면 경기가 시작될 때만 워치 센서를 켜고, 종료 시 자동으로 중지되므로 배터리 소모를 최소화하면서도 실시간 심박수 모니터링이 가능합니다.
반응형
'모바일 앱(안드로이드)' 카테고리의 다른 글
| Wear OS Tail 기능 구현: 심박수 실시간 모니터링과 백그라운드 측정 (1) | 2025.08.09 |
|---|---|
| AI 가 안드로이드 개발자에게 미치는 영향 ... (ft Google IO 2025) (3) | 2025.08.03 |
| Kotlin으로 복식 경기 Round-Robin 매칭 구성하기 (3) | 2025.07.29 |
| Nearby Connections API에서 기기 이름이 다르게 나오는 이유 (2) | 2025.07.25 |
| Google Nearby Connections API 완전 정복 가이드 (feat Claude.ai) (6) | 2025.07.23 |