解放双手的健身助手:基于 Rokid AR 眼镜的运动计时应用

一个尴尬的下午

上周六下午,我在家做平板支撑。教练说要保持60秒。

我在心里默数:1、2、3...数到40多的时候,脑子开始走神,想着晚上吃什么。回过神来,不确定是数到47还是57了。

偷偷低头瞄一眼手机。

12秒。

心态崩了。

这已经不是第一次了。做俯卧撑的时候,低头看时间脖子疼;做深蹲的时候,扭头看手机重心不稳;组间休息的时候,说好歇60秒,一刷短视频20分钟就过去了。

作为一个程序员,我的第一反应是:能不能用代码解决这个问题?

从问题到方案

冷静分析一下,健身时的核心矛盾是:

双手被占用,眼睛需要看时间,但手机不方便看。

市面上的解决方案:

  • 智能手表:需要抬手,做平板支撑时根本没法看
  • 智能音箱:只能听,看不到进度条
  • 墙上挂钟:得仰着头,颈椎更累

然后我想到了那副吃灰已久的 Rokid AR 眼镜。

眼镜的好处是:戴在头上,平视可见,完全不占用双手。做任何动作的时候,只要眼珠子动一下就能看到计时信息。

问题是:没有现成的健身计时 App。

那就自己写一个。

技术决策

既然用 Rokid 眼镜,自然要用官方的 CXR-M SDK。

这个 SDK 有几个关键点:

  • 支持手机端通过蓝牙控制眼镜
  • 内置「提词器」场景,正好用来显示文字
  • 纯 Android 开发,不需要学新技术栈

开发环境:

  • Android Studio
  • Kotlin
  • MinSDK 28(CXR-M 的硬性要求)

第一步:让项目跑起来

创建项目后,首先配置 Maven 仓库。在 settings.gradle.kts 中:

scss 复制代码
dependencyResolutionManagement {
    repositories {
        maven { url = uri("https://maven.rokid.com/repository/maven-public/") }
        google()
        mavenCentral()
    }
}

然后在 app/build.gradle.kts 中添加依赖:

scss 复制代码
android {
    defaultConfig {
        minSdk = 28
    }
}

dependencies {
    implementation("com.rokid.cxr:client-m:1.0.1-20250812.080117-2")
    implementation("androidx.core:core-ktx:1.12.0")
    implementation("com.google.android.material:material:1.11.0")
}

权限声明,AndroidManifest.xml

ini 复制代码
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />

注意:Android 12(API 31)以上,蓝牙权限需要动态申请。

第二步:连接眼镜

这是整个项目最关键的部分。我花了大半天时间才搞清楚 CXR-M SDK 的连接流程。

SDK 连接机制

CXR-M 的蓝牙连接分两步:

  1. 调用 initBluetooth() 获取连接信息(UUID 和 MAC 地址)
  1. 在回调中拿到信息后,调用 connectBluetooth() 建立真正的连接

很多开发者(包括我)第一次都会漏掉第二步,导致连接失败。

封装 SDK

我把 SDK 封装成单例类 RokidGlassesManager,方便全局调用:

kotlin 复制代码
package com.rokid.fitness.sdk

import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.app.ActivityCompat
import com.rokid.cxr.api.CxrApi
import com.rokid.cxr.callback.BluetoothStatusCallback
import com.rokid.cxr.util.ValueUtil

object RokidGlassesManager {
    private val cxrApi: CxrApi by lazy { CxrApi.getInstance() }
    private var connectionCallback: ConnectionCallback? = null
    interface ConnectionCallback {
        fun onConnecting()
        fun onConnected()
        fun onDisconnected()
        fun onFailed(errorMsg: String)
    }
    interface SendCallback {
        fun onSuccess()
        fun onFailed(errorMsg: String)
    }
    val isConnected: Boolean get() = cxrApi.isBluetoothConnected
    fun setConnectionCallback(callback: ConnectionCallback?) {
        this.connectionCallback = callback
    }
    fun findRokidGlasses(bluetoothAdapter: BluetoothAdapter): BluetoothDevice? {
        if (ActivityCompat.checkSelfPermission(bluetoothAdapter.javaClass, Manifest.permission.BLUETOOTH_CONNECT)
            != PackageManager.PERMISSION_GRANTED
        ) return null
        return bluetoothAdapter.bondedDevices.find {
            it.name?.contains("Rokid", ignoreCase = true) ||
                    it.name?.contains("Glasses", ignoreCase = true)
        } ?: null
    }
    fun connectGlasses(context: Context, device: BluetoothDevice) {
        connectionCallback?.onConnecting()
        cxrApi.initBluetooth(context, device, object : BluetoothStatusCallback() {
            override fun onConnectionInfo(socketUuid: String?, macAddress: String?, rokidAccount: String?, glassesType: Int) {
                if (!socketUuid.isNullOrEmpty() && !macAddress.isNullOrEmpty()) {
                    connectBluetooth(context, socketUuid, macAddress)
                } else {
                    connectionCallback?.onFailed("获取连接信息失败")
                }
            }
            override fun onConnected() {
                connectionCallback?.onConnected()
            }
            override fun onDisconnected() {
                connectionCallback?.onDisconnected()
            }
            override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
                connectionCallback?.onFailed(errorCode?.name ?: "连接失败")
            }
        })
    }
    private fun connectBluetooth(context: Context, socketUuid: String, macAddress: String) {
        cxrApi.connectBluetooth(context, socketUuid, macAddress, object : BluetoothStatusCallback() {
            override fun onConnected() {}
            override fun onDisconnected() { connectionCallback?.onDisconnected() }
            override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
                connectionCallback?.onFailed(errorCode?.name ?: "连接失败")
            }
            override fun onConnectionInfo(socketUuid: String?, macAddress: String?, rokidAccount: String?, glassesType: Int) {}
        })
    }
    fun sendToGlasses(text: String, callback: SendCallback? = null): Boolean {
        if (!isConnected) {
            callback?.onFailed("眼镜未连接")
            return false
        }
        cxrApi.controlScene(ValueUtil.CxrSceneType.WORD_TIPS, true, null)
        val status = cxrApi.sendStream(
            type = ValueUtil.CxrStreamType.WORD_TIPS,
            stream = text.toByteArray(Charsets.UTF_8),
            fileName = "fitness.txt",
            cb = object : com.rokid.cxr.callback.SendStatusCallback() {
                override fun onSendSucceed() { callback?.onSuccess() }
                override fun onSendFailed(errorCode: ValueUtil.CxrSendErrorCode?) {
                    callback?.onFailed(errorCode?.name ?: "发送失败")
                }
            }
        )
        return status == ValueUtil.CxrStatus.REQUEST_SUCCEED
    }
    fun disconnect() {
        cxrApi.deinitBluetooth()
    }
}

查找设备

先从已配对的设备中找 Rokid 眼镜:

kotlin 复制代码
fun findRokidGlasses(bluetoothAdapter: BluetoothAdapter): BluetoothDevice? {
    if (ActivityCompat.checkSelfPermission(
            bluetoothAdapter.javaClass,
            Manifest.permission.BLUETOOTH_CONNECT
        ) != PackageManager.PERMISSION_GRANTED
    ) return null

    return bluetoothAdapter.bondedDevices.find {
        it.name?.contains("Rokid", ignoreCase = true) ||
        it.name?.contains("Glasses", ignoreCase = true)
    }
}

这里有个坑:必须先在系统设置里配对眼镜,App 才能找到设备。

建立连接

kotlin 复制代码
fun connectGlasses(context: Context, device: BluetoothDevice) {
    connectionCallback?.onConnecting()

    cxrApi.initBluetooth(context, device, object : BluetoothStatusCallback() {
        override fun onConnectionInfo(
            socketUuid: String?,
            macAddress: String?,
            rokidAccount: String?,
            glassesType: Int
        ) {
            // 关键:拿到 UUID 和 MAC 后调用 connectBluetooth
            if (!socketUuid.isNullOrEmpty() && !macAddress.isNullOrEmpty()) {
                connectBluetooth(context, socketUuid, macAddress)
            } else {
                connectionCallback?.onFailed("获取连接信息失败")
            }
        }

        override fun onConnected() {
            connectionCallback?.onConnected()
        }

        override fun onDisconnected() {
            connectionCallback?.onDisconnected()
        }

        override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
            connectionCallback?.onFailed(errorCode?.name ?: "连接失败")
        }
    })
}

connectBluetooth() 的实现:

kotlin 复制代码
private fun connectBluetooth(context: Context, socketUuid: String, macAddress: String) {
    cxrApi.connectBluetooth(context, socketUuid, macAddress, object : BluetoothStatusCallback() {
        override fun onConnected() {}
        override fun onDisconnected() { connectionCallback?.onDisconnected() }
        override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
            connectionCallback?.onFailed(errorCode?.name ?: "连接失败")
        }
        override fun onConnectionInfo(
            socketUuid: String?,
            macAddress: String?,
            rokidAccount: String?,
            glassesType: Int
        ) {}
    })
}

当手机和眼镜成功连接后,isConnected 就会返回 true

第三步:发送内容到眼镜

连接成功后,接下来是把文字发送到眼镜显示。

关键方法

kotlin 复制代码
fun sendToGlasses(text: String, callback: SendCallback? = null): Boolean {
    if (!isConnected) {
        callback?.onFailed("眼镜未连接")
        return false
    }

    // 先打开提词器场景
    cxrApi.controlScene(ValueUtil.CxrSceneType.WORD_TIPS, true, null)

    // 发送文字内容
    val status = cxrApi.sendStream(
        type = ValueUtil.CxrStreamType.WORD_TIPS,
        stream = text.toByteArray(Charsets.UTF_8),
        fileName = "fitness.txt",
        cb = object : SendStatusCallback() {
            override fun onSendSucceed() = callback?.onSuccess()
            override fun onSendFailed(errorCode: ValueUtil.CxrSendErrorCode?) {
                callback?.onFailed(errorCode?.name ?: "发送失败")
            }
        }
    )

    return status == ValueUtil.CxrStatus.REQUEST_SUCCEED
}

两个要点:

  1. 发送前必须调用 controlScene() 打开提词器场景
  1. 文字必须用 UTF-8 编码,否则中文会乱码

断开连接

kotlin 复制代码
fun disconnect() {
    cxrApi.deinitBluetooth()
}

第四步:设计数据模型

运动数据用两个类来表示:

运动类型枚举

scss 复制代码
enum class ExerciseType {
    WARM_UP("热身"),
    STRENGTH("力量"),
    CARDIO("有氧"),
    HIIT("HIIT")
}

运动实体类

kotlin 复制代码
data class Exercise(
    val id: Int,
    val name: String,           // 运动名称
    val type: ExerciseType,     // 类型
    val description: String,    // 动作说明
    val duration: Int,          // 持续时间(秒)
    val restTime: Int,          // 组间休息(秒)
    val caloriesPerMin: Int = 0 // 每分钟消耗卡路里
)

预设运动数据

内置 10 种常见运动:

less 复制代码
object ExerciseData {
    val exercises = listOf(
        Exercise(1, "热身", ExerciseType.WARM_UP, "原地踏步,双手叉腰站立", 60, 30),
        Exercise(2, "高抬腿", ExerciseType.WARM_UP, "原地踏步,双手叉腰站立,抬起左腿", 60, 30),
        Exercise(3, "开合跳", ExerciseType.CARDIO, "原地站立,双脚并拢跳跃", 60, 30),
        Exercise(4, "深蹲", ExerciseType.STRENGTH, "原地站立,双脚与肩同宽,慢慢下蹲", 30, 30),
        Exercise(5, "俯卧撑", ExerciseType.STRENGTH, "俯卧姿势,双手撑地", 30, 20),
        Exercise(6, "平板支撑", ExerciseType.CORE, "平板姿势,保持30秒", 60, 30),
        Exercise(7, "登山者", ExerciseType.CARDIO, "原地踏步,模拟登山动作", 60, 30),
        Exercise(8, "跳绳", ExerciseType.CARDIO, "原地跳跃", 100, 30),
        Exercise(9, "波比跳", ExerciseType.CARDIO, "原地跳跃,双脚并拢", 100, 30),
        Exercise(10, "休息", ExerciseType.REST, "原地踏步,放松呼吸", 60, 30)
    )
}

第五步:训练会话管理

WorkoutSession 负责管理一次完整的训练:

kotlin 复制代码
data class WorkoutSession(
    val exercise: Exercise,
    var currentSet: Int = 1,
    var isResting: Boolean = false
) {
    var timer: CountDownTimer? = null
    var startTime: Long = 0
    val elapsed: Long = 0

    var onExerciseListener: ((Exercise) -> Unit)? = null
    var onRestListener: ((Int) -> Unit)? = null
    var onCompleteListener: (() -> Unit)? = null

开始训练

kotlin 复制代码
fun startWorkout() {
    currentSet = 1
    isResting = false
    startTime = System.currentTimeMillis()
    timer = object : CountDownTimer(exercise.duration * 1000L, 1000) {
        override fun onTick(millisUntilFinished: Long) {
            elapsed = millisUntilFinished
            onExerciseListener?.invoke(exercise)
        }
        override fun onFinish() {
            isResting = true
            onRestListener?.invoke(exercise.duration)
            onExerciseListener?.invoke(exercise)
        }
    }.start()
}

组间切换

kotlin 复制代码
fun nextExercise(): Boolean {
    val currentIndex = exercises.indexOf(exercise)
    if (currentIndex < exercises.size - 1) {
        currentSet++
        val nextEx = exercises[currentIndex]
        onExerciseListener?.invoke(nextEx)
        return true
    }
    onComplete()
    onCompleteListener?.invoke()
    return false
}

fun previousExercise(): Boolean {
    val currentIndex = exercises.indexOf(exercise)
    if (currentIndex > 0) {
        currentSet--
        val prevEx = exercises[currentIndex - 1]
        onExerciseListener?.invoke(prevEx)
        return true
    }
    return false
}

生成眼镜显示文本

这是发送到眼镜上的内容格式:

scss 复制代码
fun getDisplayText(): String {
    val ex = exercise ?: return ""
    return buildString {
        appendLine("💪 ${ex.name}")
        if (isResting) {
            appendLine()
            appendLine("────── 休息 ──────")
            appendLine()
            appendLine("下一组: ${ex.name}")
            appendLine()
            appendLine("💧 补充水分")
        } else {
            appendLine()
            appendLine("第 $currentSet/${ex.totalSets} 组")
            appendLine("⏱ ${formatTime(timeLeft / 1000)}")
            appendLine()
            val progressBars = "█".repeat((progress / 10).toInt()) +
                               "░".repeat(10 - (progress / 10).toInt())
            appendLine("$progressBars ${progress.toInt()}%")
        }
    }
}

第六步:主界面实现

MainActivity 是整个应用的控制中心:

kotlin 复制代码
package com.rokid.fitness

import android.Manifest
import android.bluetooth.BluetoothAdapter
import android.content.pm.PackageManager
import android.os.Bundle
import android.os.CountDownTimer
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.rokid.fitness.data.Exercise
import com.rokid.fitness.data.ExerciseData
import com.rokid.fitness.data.WorkoutSession
import com.rokid.fitness.databinding.ActivityMainBinding
import com.rokid.fitness.sdk.RokidGlassesManager

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding
    private var session: WorkoutSession? = null
    private var timer: CountDownTimer? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        setSupportActionBar(binding.toolbar)
        supportActionBar?.title = "健身计时"

        setupExerciseList()
        setupButtons()
        checkPermissions()
        observeConnection()
    }

    private fun setupExerciseList() {
        val adapter = ExerciseAdapter()
        binding.rvExercises.adapter = adapter
        adapter.setOnItemClickListener { exercise ->
            startWorkout(exercise)
        }
    }
    private fun setupButtons() {
        binding.btnConnect.setOnClickListener {
            if (RokidGlassesManager.isConnected) {
                RokidGlassesManager.disconnect()
                updateConnectionStatus()
            } else {
                connectGlasses()
            }
        }
        binding.btnStart.setOnClickListener { startOrStartWorkout() }
        binding.btnPause.setOnClickListener {
            session?.let { it.pause() }
        binding.btnPrev.setOnClickListener { session?.previousExercise() }
        binding.btnNext.setOnClickListener { session?.nextExercise() }
        binding.btnSkip.setOnClickListener { session?.skipExercise() }
    }
    private fun startWorkout(exercise: Exercise) {
        session = WorkoutSession(exercise)
        session?.onExerciseListener = { ex ->
            binding.tvCurrentExercise.text = "💪 ${ex.name}"
            timer?.cancel()
            timer = object : CountDownTimer(exercise.duration * 1000L, 1000) {
                override fun onTick(millisUntilFinished: Long) {
                    updateDisplay()
                }
                override fun onFinish() {
                    session?.onRestFinished()
                    runOnUiThread { updateDisplay() }
                }
            }.start()
            updateDisplay()
        }
        updateConnectionStatus()
    }
    private fun updateDisplay() {
        val s = session ?: return
        val ex = s.exercise ?: return
        binding.apply {
            tvCurrentExercise.text = if (s.isResting) "😤 休息 ${s.exercise.name}" else "💪 ${ex.name}"
            tvSets.text = "第 ${s.currentSet}/${ex.totalSets} 组"
            tvTimer.text = s.getDisplayText()
            btnPrev.text = if (s.currentSet > 1) "上一组" else ""
            btnNext.text = if (s.currentSet < ex.totalSets) "下一组" else ""
            btnPause.text = if (s.isResting) "继续" else "开始"
        }
    }
    private fun connectGlasses() {
        val adapter = BluetoothAdapter.getDefaultAdapter
        if (adapter == null || !adapter.isEnabled) {
            Toast.makeText(this, "请开启蓝牙", Toast.LENGTH_SHORT).show()
            return
        }
        val device = RokidGlassesManager.findRokidGlasses(adapter)
        if (device == null) {
            Toast.makeText(this, "未找到眼镜", Toast.LENGTH_SHORT).show()
            return
        }
        RokidGlassesManager.connectGlasses(this, device)
    }
    private fun observeConnection() {
        RokidGlassesManager.setConnectionCallback(object : RokidGlassesManager.ConnectionCallback {
            override fun onConnecting() {
                runOnUiThread { binding.btnConnect.text = "连接中..." }
            }
            override fun onConnected() {
                runOnUiThread {
                    binding.btnConnect.text = "断开连接"
                    Toast.makeText(this@MainActivity, "眼镜已连接", Toast.LENGTH_SHORT).show()
                    updateDisplay()
                }
            }
            override fun onDisconnected() {
                runOnUiThread {
                    binding.btnConnect.text = "连接眼镜"
                    session = null
                    updateDisplay()
                }
            }
            override fun onFailed(errorMsg: String) {
                runOnUiThread {
                    binding.btnConnect.text = "连接眼镜"
                    Toast.makeText(this@MainActivity, errorMsg, Toast.LENGTH_SHORT).show()
                }
            }
        })
    }
    private fun updateConnectionStatus() {
        if (RokidGlassesManager.isConnected) {
            binding.btnConnect.text = "断开连接"
            if (session != null && RokidGlassesManager.isConnected) {
                val text = session!!.getDisplayText()
                RokidGlassesManager.sendToGlasses(text)
            }
        } else {
            binding.btnConnect.text = "连接眼镜"
        }
    }
    private fun checkPermissions() {
        val permissions = mutableListOf<String>()
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
            permissions.add(Manifest.permission.BLUETOOTH_SCAN)
            permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
        }
        val notGranted = permissions.filter {
            ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
        }
        if (notGranted.isNotEmpty()) {
            ActivityCompat.requestPermissions(this, notGranted.toTypedArray(), 100)
        }
    }
}

初始化运动列表

kotlin 复制代码
private fun setupExerciseList() {
    val adapter = ExerciseAdapter()
    binding.rvExercises.adapter = adapter
    adapter.setOnItemClickListener { exercise ->
        startWorkout(exercise)
    }
}

按钮事件

scss 复制代码
private fun setupButtons() {
    binding.btnConnect.setOnClickListener {
        if (RokidGlassesManager.isConnected) {
            RokidGlassesManager.disconnect()
            updateConnectionStatus()
        } else {
            connectGlasses()
        }
    }
    binding.btnStart.setOnClickListener { startOrStartWorkout() }
    binding.btnPause.setOnClickListener { session?.pause() }
    binding.btnPrev.setOnClickListener { session?.previousExercise() }
    binding.btnNext.setOnClickListener { session?.nextExercise() }
    binding.btnSkip.setOnClickListener { session?.skipExercise() }
}

开始训练

kotlin 复制代码
private fun startWorkout(exercise: Exercise) {
    session = WorkoutSession(exercise)
    session?.onExerciseListener = { ex ->
        binding.tvCurrentExercise.text = "💪 ${ex.name}"
        timer?.cancel()
        timer = object : CountDownTimer(exercise.duration * 1000L, 1000) {
            override fun onTick(millisUntilFinished: Long) {
                updateDisplay()
            }
            override fun onFinish() {
                session?.onRestFinished()
                runOnUiThread { updateDisplay() }
            }
        }.start()
        updateDisplay()
    }
}

连接眼镜

kotlin 复制代码
private fun connectGlasses() {
    val adapter = BluetoothAdapter.getDefaultAdapter
    if (adapter == null || !adapter.isEnabled) {
        Toast.makeText(this, "请开启蓝牙", Toast.LENGTH_SHORT).show()
        return
    }
    val device = RokidGlassesManager.findRokidGlasses(adapter)
    if (device == null) {
        Toast.makeText(this, "未找到眼镜", Toast.LENGTH_SHORT).show()
        return
    }
    RokidGlassesManager.connectGlasses(this, device)
}

监听连接状态

kotlin 复制代码
private fun observeConnection() {
    RokidGlassesManager.setConnectionCallback(object : RokidGlassesManager.ConnectionCallback {
        override fun onConnecting() {
            runOnUiThread { binding.btnConnect.text = "连接中..." }
        }
        override fun onConnected() {
            runOnUiThread {
                binding.btnConnect.text = "断开连接"
                Toast.makeText(this@MainActivity, "眼镜已连接", Toast.LENGTH_SHORT).show()
                updateDisplay()
            }
        }
        override fun onDisconnected() {
            runOnUiThread {
                binding.btnConnect.text = "连接眼镜"
                session = null
                updateDisplay()
            }
        }
        override fun onFailed(errorMsg: String) {
            runOnUiThread {
                binding.btnConnect.text = "连接眼镜"
                Toast.makeText(this@MainActivity, errorMsg, Toast.LENGTH_SHORT).show()
            }
        }
    })
}

同步到眼镜

kotlin 复制代码
private fun updateConnectionStatus() {
    if (RokidGlassesManager.isConnected) {
        binding.btnConnect.text = "断开连接"
        if (session != null) {
            val text = session!!.getDisplayText()
            RokidGlassesManager.sendToGlasses(text)
        }
    } else {
        binding.btnConnect.text = "连接眼镜"
    }
}

权限检查

kotlin 复制代码
private fun checkPermissions() {
    val permissions = mutableListOf<String>()
    if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S) {
        permissions.add(Manifest.permission.BLUETOOTH_SCAN)
        permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
    }
    val notGranted = permissions.filter {
        ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
    }
    if (notGranted.isNotEmpty()) {
        ActivityCompat.requestPermissions(this, notGranted.toTypedArray(), 100)
    }
}

踩坑记录

坑一:连接流程不完整

现象 :调用 initBluetooth() 后,连接状态一直是 "连接中"。

原因 :SDK 的连接分两步,必须在 onConnectionInfo 回调中调用 connectBluetooth()

解决:完整实现连接流程。

坑二:中文乱码

现象:眼镜上显示的中文变成乱码。

原因:发送时没有指定编码。

解决

ini 复制代码
stream = text.toByteArray(Charsets.UTF_8)

坑三:屏幕息屏

现象:训练过程中手机息屏,再唤醒时计时器状态异常。

解决 :在 onCreate() 中保持屏幕常亮:

javascript 复制代码
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

坑四:蓝牙权限

现象:Android 12 以上设备崩溃。

原因:Android 12 新增了蓝牙权限,必须动态申请。

解决:在 Activity 启动时检查并申请权限。

使用流程

  1. 打开 App,选择运动项目
  1. 点击「连接眼镜」,建立蓝牙连接
  1. 点击「开始」,倒计时启动
  1. 做动作时,眼珠转一下就能在眼镜上看到剩余时间
  1. 休息时,眼镜显示休息倒计时
  1. 完成所有组数,训练结束

复盘与思考

做对了什么

  1. 从真实痛点出发:不是为了玩技术而做,而是真的解决自己的问题
  1. 最小可行产品:只做核心功能,不过度设计
  1. 代码结构清晰:SDK 封装、数据模型、UI 分离

可以改进的地方

  1. 运动自定义:目前只能用预设的 10 种运动
  1. 训练历史:没有保存历史记录
  1. 语音控制:说"下一组"自动切换,解放双手
  1. 多设备支持:目前只测试过 Rokid

关于 AR 健身的思考

健身场景非常适合 AR 眼镜:

  • 双手被占用
  • 需要频繁查看信息
  • 环境相对简单(不像户外导航那么复杂)

但目前 AR 眼镜的普及还面临挑战:价格、续航、佩戴舒适度。作为开发者,我们能做的就是提前探索场景,积累经验。

结语

这个项目用了一个周末的时间,大约 800 行代码。功能简单,但确实解决了我的问题。

做平板支撑的时候,不用再纠结"到底做了多久",眼镜上的倒计时会告诉我。做俯卧撑的时候,不用低头看手机,眼珠动一下就行。

技术不需要多复杂,能解决问题的就是好技术。


项目结构

bash 复制代码
FitnessTimer/
├── app/src/main/java/com/rokid/fitness/
│   ├── MainActivity.kt           # 主界面
│   ├── ExerciseAdapter.kt        # 运动列表适配器
│   ├── data/
│   │   ├── Exercise.kt           # 运动数据模型
│   │   └── WorkoutSession.kt     # 训练会话管理
│   └── sdk/
│       └── RokidGlassesManager.kt # SDK 封装

相关资源

相关推荐
Wect4 小时前
LeetCode 17. 电话号码的字母组合:回溯算法入门实战
前端·算法·typescript
ZhengEnCi1 天前
08c. 检索算法与策略-混合检索
后端·python·算法
程序员小崔日记1 天前
大三备战考研 + 找实习:我整理了 20 道必会的时间复杂度题(建议收藏)
算法·408·计算机考研
lizhongxuan1 天前
AI小镇 - 涌现
算法·架构
AI工程架构师1 天前
通常说算力是多少 FLOPS,怎么理解,GPU和CPU为什么差异这么大
算法
祈安_1 天前
Java实现循环队列、栈实现队列、队列实现栈
java·数据结构·算法
归去_来兮2 天前
拉格朗日插值算法原理及简单示例
算法·数据分析·拉格朗日插值
千寻girling2 天前
Python 是用来做 AI 人工智能 的 , 不适合开发 Web 网站 | 《Web框架》
人工智能·后端·算法
颜酱2 天前
一步步实现字符串计算器:从「转整数」到「带括号与优化」
javascript·后端·算法