こんにちは!筋肉です!
私は普段の筋トレにおいてレスト時間を心拍数で管理しています。
具体的にいうと各セット中に上昇した心拍数が、休憩中に一定値(125bpm)まで下がったら次のセットに入るやり方です。
このやり方は体感結構良く、休み過ぎてトレーニング時間が長くなりすぎたり、追い込み過ぎて倒れたりすることなくトレーニングを続けられています。(細かいトレーニング理論は技術記事なので割愛します。)
ただこのやり方でレストを管理できる丁度よいアプリが無かったので、この度は自分で作成してみようと思いました。流行ってない理由としては明確で、恐らくトレーニングギアは手首につける場合が多く、ウェアラブルデバイスは邪魔になりやすいため だと思いますが、気合で両方つけることは可能です。
Android Studioでの開発は初ですが、こっちも気合でなんとかします。
WearOS側 (ウェアラブルデバイス)
①トレーニング間のレスト中に心拍数の減少を検知し、次のセットに入るようにアラートを出す。
②スマホアプリ側でデータの解析ができるように、必要なデータをスマホに送信する。
(心拍データ自体はHealth Connectにあるため、その分析に必要な時刻を送信する)
スマホアプリ側
- 送られてきたデータをもとに心拍データを分析して、セットごとのデータに対するビューを提供する。
- 上記データをトレーニングメニューごとにラベルづけし、メニューごとの統計データを提供する。
⇩そしてこんなデータを出したいです。
- 各セットの振り返り
セット | トレーニング時間 (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の開発に移ります。
前提
- 実機は初代の「pixel watch」
- 開発環境は「Android Studio」
- 開発言語は「kotlin」
(以降はライブラリの組み合わせや基礎知識ばかりなのでだいぶ省略していきます。)
作成ファイル構成
kotlin+java配下にパッケージを作り、その3ファイル作成しています。
-
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にしています。
-
上部:心拍数のモニター
心拍数に応じて色を変えるようにしてますが、トレーニング中に見る余裕はないのであまり関係ないです。 -
中部:ストップウォッチ
ジムで器具の独占時間が長すぎることは良くないので、トレーニング中はどれだけ占有しているかを把握するために採用しました。 -
下部:ボタン
開始ボタンは、開始すると終了ボタンに代わるようしています。その後はリセットボタンを押して、次のメニューに移る想定です。
工夫した点
心拍数の通知については、スマホ側に通知として残すのも面倒くさいので、Jetpack ComposeのAlertDialogを採用しています。watch側だけに通知が来るようになって良かったのですが、AlertDialogの形が四角だったせいで、⇩のように画面になってしました。
これだとダサすぎるので、Jetpack ComposeのBoxで背景を真っ黒にすることでわからなくしました。
この辺りはもっといいやり方があって然るべきだと思っているので、次回同じことをするときにはちゃんと考えたいです。
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>viewModelh2>
package com.example.heartratemonitor
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
class HeartRateViewModel : ViewModel() {
val heartRate = mutableStateOf("--")
val alertMessage = mutableStateOfString?>(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などフロントのパーツが充実していて、誰でも綺麗に作りやすい仕組みがあるなとも思いました。
この調子でスマホアプリ側も作成したいです。
Views: 0