17
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

こんにちは!筋肉です!

私は普段の筋トレにおいてレスト時間を心拍数で管理しています。
具体的にいうと各セット中に上昇した心拍数が、休憩中に一定値(125bpm)まで下がったら次のセットに入るやり方です。

image.png

このやり方は体感結構良く、休み過ぎてトレーニング時間が長くなりすぎたり、追い込み過ぎて倒れたりすることなくトレーニングを続けられています。(細かいトレーニング理論は技術記事なので割愛します。)

ただこのやり方でレストを管理できる丁度よいアプリが無かったので、この度は自分で作成してみようと思いました。流行ってない理由としては明確で、恐らくトレーニングギアは手首につける場合が多く、ウェアラブルデバイスは邪魔になりやすいため だと思いますが、気合で両方つけることは可能です。

Android Studioでの開発は初ですが、こっちも気合でなんとかします。

実現したいこと

WearOS側 (ウェアラブルデバイス)

①トレーニング間のレスト中に心拍数の減少を検知し、次のセットに入るようにアラートを出す。
②スマホアプリ側でデータの解析ができるように、必要なデータをスマホに送信する。
(心拍データ自体はHealth Connectにあるため、その分析に必要な時刻を送信する)

image.png

スマホアプリ側

  • 送られてきたデータをもとに心拍データを分析して、セットごとのデータに対するビューを提供する。
  • 上記データをトレーニングメニューごとにラベルづけし、メニューごとの統計データを提供する。

⇩こんな分析を行って
image.png

⇩そしてこんなデータを出したいです。

  • 各セットの振り返り
セット トレーニング時間 (s) レスト時間 (s)
1 155 190
2 182 205
3 200 255
4 210 280
5 225 -
合計 トレーニング時間 レスト時間
31:42 16:12 15:30

そしてそして、トレーニング種目ごとにデータをラベルづけして、⇩みたいに見れるようにしたいです。

  • (例)XX月or週-スクワット
セット数計 トレーニング時間計 レスト時間計 平均セット(s) 平均レスト(s)
42 4:16:12 4:15:30 180 200

ここまでやれば、筋肥大に必要なトレーニングボリュームの確保がデータが確認できるようになると思います。
それから普段のセット時間やレスト時間を把握しておけば、トレ中・トレ後の「なんかおかしかったな~」って感覚を定量的に測れるようになるかもしれません。


ここまでが野望です。ぜひ実現させましょう。 ということで wearOSの開発に移ります。

WearOS実装

前提

  • 実機は初代の「pixel watch」
  • 開発環境は「Android Studio」
  • 開発言語は「kotlin」

(以降はライブラリの組み合わせや基礎知識ばかりなのでだいぶ省略していきます。)

作成ファイル構成

kotlin+java配下にパッケージを作り、その3ファイル作成しています。
image.png

  • MainActivity.kt
    SensorManager を使って心拍センサーを監視し、トレーニング状態の遷移と通知を制御。
  • HeartRateMonitorUI.kt
    Jetpack Compose で UI を構築。
  • HeartRateViewModel.kt
    心拍数と通知メッセージを mutableStateOf で管理し、UIと状態を連携。

こんなもんで動くものができるのがいいですね~

トレーニング状態の遷移と通知

Sensorの取得

Sensorの取得部分(onCreate)
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

    viewModel = HeartRateViewModel()

    setContent {
        HeartRateMonitorUI(viewModel)
    }

    sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
    heartRateSensor = sensorManager.getDefaultSensor(Sensor.TYPE_HEART_RATE)
    heartRateSensor?.let {
        sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_FASTEST)
    }
}
  • MainActivity.kt内で心拍センサーを取得して、その後onSensorChanged内で、センサーの値変化を元に通知を送る仕組みです。
  • window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)はアプリ実行時に画面がオフにならないように入れています。
  • sensorManager.registerListener(this, it, SensorManager.SENSOR_DELAY_FASTEST)について、SENSOR_DELAY_FASTESTじゃないとリアルタイムに通知するのは難しそうでした。

心拍数の通知とUIを動かす部分

単純に「心拍数がある数値になったら」を条件にすると、心拍数の上昇中にも反応してしまい、余計な通知が発生してしまいます。これを避けるために、「HIGH状態」「LOW状態」の概念を導入しました。

また一瞬だけ逆の動きをした心拍数で通知が来るのも嫌なので、遷移条件に継続時間も加えています。

それからトレーニング開始心拍数だけを通知すると、準備や重量の付替を行えないので、開始より先に次の「セットの準備せよ」の心拍数も導入しました。

状態 心拍数条件 継続時間 遷移先 通知
LOW 心拍数が 130以上 6秒間 HIGH なし
HIGH 心拍数が 130未満 6秒間 HIGH Ready?(セット準備)
HIGH 心拍数が 125未満 6秒間 LOW Let’s get!(開始合図)
心拍数の通知とUIの変化(onSensorChanged)
companion object {
        private const val HEART_RATE_THRESHOLD_START = 130      // トレーニング開始ライン
        private const val HEART_RATE_THRESHOLD_READY = 130      // 「Ready?」通知ライン
        private const val HEART_RATE_THRESHOLD_REST = 125        // 「Let’s get!」通知ライン
        private const val STABLE_DURATION_MS = 6000L            // 安定してこの時間以下なら状態遷移
    }

    private var aboveStartTime: Long? = null
    private var belowStartTime: Long? = null
    private var belowReadyTime: Long? = null
    private var notifiedReady = false
    private var notifiedStart = false
    private var currentState = TrainingState.LOW

override fun onSensorChanged(event: SensorEvent?) {
        val heartRate = event?.values?.get(0)?.toInt() ?: return
        val now = System.currentTimeMillis()

        viewModel.updateHeartRate(heartRate.toString())

        when (currentState) {
            TrainingState.LOW -> {
                if (heartRate >= HEART_RATE_THRESHOLD_START) {
                    if (aboveStartTime == null) {
                        aboveStartTime = now
                    } else if (now - aboveStartTime!! >= STABLE_DURATION_MS) {
                        currentState = TrainingState.HIGH
                        resetTimersAndFlags()
                    }
                } else {
                    aboveStartTime = null
                }
            }

            TrainingState.HIGH -> {
                if (heartRate < HEART_RATE_THRESHOLD_READY) {
                    if (belowStartTime == null) {
                        belowStartTime = now
                    } else if (now - belowStartTime!! >= STABLE_DURATION_MS && !notifiedReady) {
                        viewModel.triggerAlert("Ready?")
                        notifiedReady = true
                        belowReadyTime = now
                    }
                } else {
                    belowStartTime = null
                    belowReadyTime = null
                    notifiedReady = false
                    notifiedStart = false
                }

                if (heartRate < HEART_RATE_THRESHOLD_REST && notifiedReady) {
                    if (belowReadyTime == null) {
                        belowReadyTime = now
                    } else if (now - belowReadyTime!! >= STABLE_DURATION_MS && !notifiedStart) {
                        viewModel.triggerAlert("Let’s get!")
                        currentState = TrainingState.LOW
                        notifiedStart = true
                    }
                } else {
                    belowReadyTime = null
                }
            }
        }
    }

    private fun resetTimersAndFlags() {
        aboveStartTime = null
        belowStartTime = null
        belowReadyTime = null
        notifiedReady = false
        notifiedStart = false
    }

動けばヨシで書いちゃってます~

UI

ほぼほぼJetpack Composeで成り立ちました。
UI部分
@Composable
fun HeartRateMonitorUI(viewModel: HeartRateViewModel) {
    val context = LocalContext.current
    val heartRate = viewModel.heartRate.value
    val alertMessage = viewModel.alertMessage.value

    val heartRateInt = heartRate.toIntOrNull() ?: 0
    val heartColor = when {
        heartRateInt >= 130 -> Color.Red
        heartRateInt >= 120 -> Color.Yellow
        else -> Color.White
    }

    val isRunning = remember { mutableStateOf(false) }
    val elapsedTime = remember { mutableStateOf(0L) }

    LaunchedEffect(isRunning.value) {
        while (isRunning.value) {
            delay(1000L)
            elapsedTime.value += 1000L
        }
    }

    val formattedTime = remember(elapsedTime.value) {
        val seconds = (elapsedTime.value / 1000) % 60
        val minutes = (elapsedTime.value / (1000 * 60)) % 60
        val hours = (elapsedTime.value / (1000 * 60 * 60))
        String.format("%02d:%02d:%02d", hours, minutes, seconds)
    }

    Surface(
        modifier = Modifier.fillMaxSize(),
        color = Color(0xFF101010)
    ) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(24.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text(text = "$heartRate bpm", fontSize = 20.sp, color = heartColor)

            Spacer(modifier = Modifier.height(8.dp))

            HorizontalDivider(thickness = 1.dp, color = Color.Gray)

            Spacer(modifier = Modifier.height(16.dp))

            Text(
                text = formattedTime,
                style = MaterialTheme.typography.headlineMedium,
                color = Color.White
            )

            Spacer(modifier = Modifier.height(16.dp))

            Row {
                IconButton(onClick = { isRunning.value = !isRunning.value }) {
                    Icon(
                        imageVector = if (isRunning.value) Icons.Filled.Pause else Icons.Filled.PlayArrow,
                        contentDescription = null,
                        tint = Color.White,
                        modifier = Modifier.size(48.dp)
                    )
                }

                Spacer(modifier = Modifier.width(8.dp))

                IconButton(onClick = {
                    isRunning.value = false
                    elapsedTime.value = 0L
                }) {
                    Icon(
                        imageVector = Icons.Filled.Replay,
                        contentDescription = "reset",
                        tint = Color.White,
                        modifier = Modifier.size(48.dp)
                    )
                }
            }

            if (alertMessage != null) {
                HeartRateAlertDialog(message = alertMessage) {
                    viewModel.clearAlert()
                }
            }
        }
    }
}

@SuppressLint("ServiceCast")
@Composable
fun HeartRateAlertDialog(message: String, onDismiss: () -> Unit) {
    val context = LocalContext.current

    // バイブレーション効果
    LaunchedEffect(message) {
        val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            vibrator.vibrate(VibrationEffect.createOneShot(500, VibrationEffect.DEFAULT_AMPLITUDE))
        } else {
            @Suppress("DEPRECATION")
            vibrator.vibrate(500)
        }
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.Black),
        contentAlignment = Alignment.Center
    ) {
        AlertDialog(
            onDismissRequest = onDismiss,
            confirmButton = {
                Column(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(bottom = 8.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Button(
                        onClick = onDismiss,
                        colors = ButtonDefaults.buttonColors(containerColor = Color.White),
                        modifier = Modifier.height(32.dp)
                    ) {
                        Text("OK", fontSize = 12.sp, color = Color.Black)
                    }
                }
            },
            text = {
                Text(
                    text = message,
                    color = Color.White,
                    fontSize = 15.sp,
                    lineHeight = 20.sp,
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 8.dp, vertical = 4.dp),
                    textAlign = TextAlign.Center
                )
            },
            containerColor = Color.Black,
            tonalElevation = 0.dp,
            modifier = Modifier
                .fillMaxSize()
                .padding(horizontal = 12.dp, vertical = 24.dp),
            shape = RoundedCornerShape(0.dp)
        )
    }
}

watchで行いたいことは「通知と時刻の送信」だけなので、基本的には開始・終了の1ボタンで足りるのですが、それだと味気が無さすぎるので、このUIにしています。

image.png

  • 上部:心拍数のモニター
    心拍数に応じて色を変えるようにしてますが、トレーニング中に見る余裕はないのであまり関係ないです。

  • 中部:ストップウォッチ
    ジムで器具の独占時間が長すぎることは良くないので、トレーニング中はどれだけ占有しているかを把握するために採用しました。

  • 下部:ボタン
    開始ボタンは、開始すると終了ボタンに代わるようしています。その後はリセットボタンを押して、次のメニューに移る想定です。
    image.png

工夫した点

心拍数の通知については、スマホ側に通知として残すのも面倒くさいので、Jetpack ComposeのAlertDialogを採用しています。watch側だけに通知が来るようになって良かったのですが、AlertDialogの形が四角だったせいで、⇩のように画面になってしました。

image.png

これだとダサすぎるので、Jetpack ComposeのBoxで背景を真っ黒にすることでわからなくしました。
image.png

この辺りはもっといいやり方があって然るべきだと思っているので、次回同じことをするときにはちゃんと考えたいです。

UIのAlertDialog用のテストコード
@Composable
fun TestAlertDialogScreen() {
    val showDialog = remember { mutableStateOf(true) }

    if (showDialog.value) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(Color.Black), // 画面全体を黒に
            contentAlignment = Alignment.Center
        ) {
            AlertDialog(
                ~省略~

                // 背景と要素を黒に
                containerColor = Color.Black, 
                tonalElevation = 0.dp, 
                modifier = Modifier
                    .fillMaxSize()
                    .padding(horizontal = 12.dp, vertical = 24.dp),
                shape = RoundedCornerShape(0.dp)
            )
        }
    }
}

ViewModel

いるかな?
HeartRateViewModel.kt
<h2>viewModel</h2>
package com.example.heartratemonitor

import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel

class HeartRateViewModel : ViewModel() {
    
    val heartRate = mutableStateOf("--")
    val alertMessage = mutableStateOf<String?>(null)

    fun updateHeartRate(value: String) {
        heartRate.value = value
    }

    fun triggerAlert(message: String) {
        alertMessage.value = message
    }

    fun clearAlert() {
        alertMessage.value = null
    }
}

感想

Android Studioでの開発は初でしたが、コードエディタ、エミュレータ、デバッガー、ビルドツールなどが一通り揃っていて、初心者でも開発が行いやすかったです。

特にエミュレータでWearOSが煩わしい設定もなく簡単に起動でき、テスト実行も一瞬!という点が嬉しかったです。

あとGoogleが提供しているライブラリが豊富で、やりたいことがほぼ実現できたのもすごいなと思いました。画面が小さいのもありますが、Jetpack Composeなどフロントのパーツが充実していて、誰でも綺麗に作りやすい仕組みがあるなとも思いました。


この調子でスマホアプリ側も作成したいです。

17
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?