Today's

길을 나서지 않으면 그 길에서 만날 수 있는 사람을 만날 수 없다

모바일 앱(안드로이드)

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

Billcorea 2025. 8. 2. 15:40
반응형

 

 

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

시계앱에서 bpm 체크 하기 예제

 

이번 글에서는 배드민턴 동호회(콕이랑)앱에 필요한 기능인 워치에서 심박수를 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 전달에 문제가 발생 하지 않는 다는 것임.

이 방식으로 구현하면 경기가 시작될 때만 워치 센서를 켜고, 종료 시 자동으로 중지되므로 배터리 소모를 최소화하면서도 실시간 심박수 모니터링이 가능합니다.

반응형