跳到主要内容基于 Rokid AR 眼镜的 Android 喝水提醒应用开发 | 极客日志Kotlin大前端java
基于 Rokid AR 眼镜的 Android 喝水提醒应用开发
一款基于 Rokid AR 眼镜和 Android 开发的喝水提醒应用。针对程序员久坐少动、容易忘记喝水的健康问题,作者利用 AR 眼镜视野可见、不中断工作的特性,设计了双模提醒方案。文章详细阐述了技术选型(CXR-M SDK)、项目架构(Kotlin + Coroutines + Room/SharedPreferences)、权限配置、数据层实现及 SDK 封装过程。重点分享了蓝牙连接、提词器场景调用、UTF-8 编码及 TTS 播放等实际开发中的踩坑记录与解决方案,为同类 AR 应用开发提供参考。
监控大屏7 浏览 基于 Rokid AR 眼镜的 Android 喝水提醒应用开发

一、从一次体检说起
去年年底公司组织体检,拿到报告的时候我愣住了——尿酸偏高、肾结石早期征兆、血液粘稠度异常。医生问了我几个问题,最后归结为一句话:'你是不是经常一坐就是一整天,基本不怎么喝水?'
被说中了。作为一名程序员,写代码的时候经常进入"心流"状态,一坐就是三四个小时,等反应过来的时候,嗓子已经干得冒烟了。
买了各种喝水提醒 App,用过一段时间后都卸载了。原因很简单:
手机震动提醒的时候,我正在敲代码,下意识就把通知划掉了。
划掉之后呢?继续写代码。等想起来喝水这件事,已经是两小时后了。
这个场景让我开始思考:如果提醒不是出现在手机上,而是出现在我的视野里呢?
恰好看到 Rokid 开发者社区的征文活动,手里正好有一副 Rokid AR 眼镜。于是决定花一周时间,开发一款「喝水提醒助手」。
二、为什么是 AR 眼镜?
在做技术选型之前,我先分析了一下现有方案的痛点:
手机通知的问题:
- 需要解锁才能看到详情
- 容易被其他应用干扰(刷着刷着就忘了)
- 写代码的时候手机放在一边,不一定会注意到
智能水杯的问题:
- 贵,动辄两三百
- 需要充电
- 只能记录从这个杯子喝的水,换个杯子就失效了
Apple Watch / 手环的问题:
而 AR 眼镜有几个独特优势:
- 视野可见:只要戴着眼镜,提醒就在视野里
- 不中断工作:不用低头看手机,不用解锁屏幕
- 信息丰富:可以显示详细的喝水数据
- 双重提醒:文字 + 语音(TTS),不容易错过
当然,AR 眼镜也有局限性——不是每个人都有,戴着不舒服,续航有限。但如果你恰好有一副,那这个方案值得一试。
三、技术选型:CXR-M SDK vs 灵珠平台
Rokid 提供了两套开发方案:
| 方案 | 适用场景 | 学习成本 |
|---|
| 灵珠 AI 平台 | 对话式应用,需要 AI 生成内容 | 低,可视化配置 |
| CXR-M SDK | 纯代码开发,完全自定义 | 中,需要 Android 开发经验 |
我的需求很明确:
- 本地管理喝水数据
- 定时提醒 + 手动记录
- 发送文字到眼镜显示
- 支持 TTS 语音播报
这些功能用 CXR-M SDK 完全能实现,而且不需要依赖云端服务。更重要的是,CXR-M SDK 内置了「提词器场景」(WORD_TIPS),正好可以用来显示喝水提醒。
所谓「提词器场景」,原本是给演讲者看稿子用的。但换个思路,把喝水提醒当"稿子"发过去,不就是我要的功能吗?
四、项目架构设计
在动手写代码之前,我先规划了整体架构:

微信扫一扫,关注极客日志
微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- WaterRepository:数据仓库,用 SharedPreferences 存储数据
- ReminderService:前台服务,负责定时提醒
- RokidGlassesManager:SDK 封装,处理眼镜连接和通信
五、从配置开始:Gradle 和权限
5.1 添加 SDK 依赖
首先是 app/build.gradle.kts:
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.rokid.waterreminder"
compileSdk = 34
defaultConfig {
applicationId = "com.rokid.waterreminder"
minSdk = 28
targetSdk = 34
versionCode = 1
versionName = "1.0.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation("com.rokid.cxr:client-m:1.0.1-20250812.080117-2")
implementation("com.squareup.okhttp3:okhttp:4.9.3")
implementation("com.google.code.gson:gson:2.10.1")
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.7.0")
}
这里有个坑:minSdk 必须设为 28 以上,否则编译会报错。我一开始设的是 21,折腾了半天才发现是 SDK 的硬性要求。
5.2 权限配置
AndroidManifest.xml 中需要声明以下权限:
<uses-permission android:name="android.permission.BLUETOOTH"/>
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
重点说一下 BLUETOOTH_SCAN 的 neverForLocation 标志。Android 系统规定蓝牙扫描需要定位权限,但这个标志可以告诉系统"我不是用来定位的"。这样用户在授权的时候,不会看到"此应用需要获取您的位置信息"这种吓人的提示。
另外,Android 12+ 和 Android 13+ 的蓝牙权限、通知权限都需要运行时动态申请,光在 Manifest 里声明是没用的。
六、数据层实现
6.1 数据模型
data class WaterRecord(
val id: Long = System.currentTimeMillis(),
val timestamp: Long = System.currentTimeMillis(),
val amountMl: Int,
val note: String? = null
) {
fun getFormattedTime(): String {
val sdf = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault())
return sdf.format(java.util.Date(timestamp))
}
}
data class DailyStats(
val date: String,
val totalMl: Int,
val targetMl: Int,
val cupCount: Int
) {
val completionRate: Float
get() = if (targetMl > 0) totalMl.toFloat() / targetMl else 0f
val isGoalMet: Boolean
get() = totalMl >= targetMl
}
data class ReminderSettings(
val isEnabled: Boolean = true,
val targetMl: Int = 2000,
val cupSizeMl: Int = 250,
val intervalMinutes: Int = 60,
val startTime: String = "08:00",
val endTime: String = "22:00",
val glassesEnabled: Boolean = false,
val ttsEnabled: Boolean = true
) {
companion object {
val DEFAULT = ReminderSettings()
}
}
这里用 data class 是因为 Kotlin 会自动生成 equals()、hashCode()、copy() 等方法,非常方便。copy() 方法在修改设置时特别好用:
val newSettings = settings.copy(targetMl = 3000)
val newSettings = settings.copy(
targetMl = 3000,
intervalMinutes = 45
)
6.2 数据仓库
数据存储用的是 SharedPreferences + Gson。为什么不 SQLite/Room?因为这个应用的数据量很小,一天最多几十条记录,用 SharedPreferences 完全够用,而且代码更简单。

class WaterRepository private constructor(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val gson = Gson()
companion object {
private const val PREFS_NAME = "water_reminder_prefs"
private const val KEY_RECORDS = "water_records"
private const val KEY_SETTINGS = "reminder_settings"
@Volatile
private var instance: WaterRepository? = null
fun getInstance(context: Context): WaterRepository {
return instance ?: synchronized(this) {
instance ?: WaterRepository(context.applicationContext).also {
instance = it
}
}
}
}
fun addRecord(record: WaterRecord) {
val records = getAllRecords().toMutableList()
records.add(0, record)
saveRecords(records)
}
fun getTodayRecords(): List<WaterRecord> {
val today = getTodayDateString()
return getAllRecords().filter { record ->
getDateString(record.timestamp) == today
}
}
fun getTodayStats(): DailyStats {
val todayRecords = getTodayRecords()
val settings = getSettings()
return DailyStats(
date = getTodayDateString(),
totalMl = todayRecords.sumOf { it.amountMl },
targetMl = settings.targetMl,
cupCount = todayRecords.size
)
}
fun getHistoryStats(days: Int = 7): List<DailyStats> {
val settings = getSettings()
val records = getAllRecords()
val result = mutableListOf<DailyStats>()
val calendar = java.util.Calendar.getInstance()
for (i in 0 until days) {
val date = java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(calendar.time)
val dayRecords = records.filter { getDateString(it.timestamp) == date }
result.add(DailyStats(
date = date,
totalMl = dayRecords.sumOf { it.amountMl },
targetMl = settings.targetMl,
cupCount = dayRecords.size
))
calendar.add(java.util.Calendar.DAY_OF_MONTH, -1)
}
return result.reversed()
}
fun getSettings(): ReminderSettings {
val json = prefs.getString(KEY_SETTINGS, null) ?: return ReminderSettings.DEFAULT
return try {
gson.fromJson(json, ReminderSettings::class.java)
} catch (e: Exception) {
ReminderSettings.DEFAULT
}
}
fun saveSettings(settings: ReminderSettings) {
prefs.edit().putString(KEY_SETTINGS, gson.toJson(settings)).apply()
}
}
这里用单例模式来管理 Repository 实例,避免重复创建。@Volatile + synchronized 是为了保证线程安全。
七、SDK 封装层
这是整个项目的核心。CXR-M SDK 的 API 虽然不复杂,但有一些细节需要注意。我把它封装成了一个单例类:

object RokidGlassesManager {
private const val TAG = "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
}
val bondedDevices = bluetoothAdapter.bondedDevices
for (device in bondedDevices) {
val deviceName = device.name ?: continue
if (deviceName.contains("Rokid", ignoreCase = true) || deviceName.contains("Glasses", ignoreCase = true)) {
Log.d(TAG, "找到 Rokid 眼镜:$deviceName")
return device
}
}
return null
}
fun connectGlasses(context: Context, device: BluetoothDevice) {
Log.d(TAG, "开始连接眼镜:${device.name}")
connectionCallback?.onConnecting()
cxrApi.initBluetooth(
context = context,
device = device,
callback = object : BluetoothStatusCallback() {
override fun onConnectionInfo(
socketUuid: String?,
macAddress: String?,
rokidAccount: String?,
glassesType: Int
) {
Log.d(TAG, "获取连接信息:UUID=$socketUuid, MAC=$macAddress")
if (!socketUuid.isNullOrEmpty() && !macAddress.isNullOrEmpty()) {
connectBluetooth(context, socketUuid, macAddress)
} else {
connectionCallback?.onFailed("获取连接信息失败")
}
}
override fun onConnected() {
Log.d(TAG, "眼镜连接成功")
connectionCallback?.onConnected()
}
override fun onDisconnected() {
Log.w(TAG, "眼镜连接断开")
connectionCallback?.onDisconnected()
}
override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
val errorMsg = getBluetoothErrorMessage(errorCode)
Log.e(TAG, "眼镜连接失败:$errorMsg")
connectionCallback?.onFailed(errorMsg)
}
}
)
}
private fun connectBluetooth(context: Context, socketUuid: String, macAddress: String) {
cxrApi.connectBluetooth(
context = context,
socketUuid = socketUuid,
macAddress = macAddress,
callback = object : BluetoothStatusCallback() {
override fun onConnected() {
Log.d(TAG, "蓝牙连接确认成功")
}
override fun onDisconnected() {
connectionCallback?.onDisconnected()
}
override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
connectionCallback?.onFailed(getBluetoothErrorMessage(errorCode))
}
override fun onConnectionInfo(
socketUuid: String?,
macAddress: String?,
rokidAccount: String?,
glassesType: Int
) {
}
}
)
}
}
- 调用
initBluetooth() 获取连接信息(UUID 和 MAC 地址)
- 在
onConnectionInfo 回调中拿到信息后,调用 connectBluetooth() 建立真正的连接
我一开始以为调用 initBluetooth() 就能连上,结果等了半天没反应。后来看文档才发现需要两步。
7.1 发送提醒到眼镜
fun sendWaterReminder(text: String, callback: SendCallback? = null): Boolean {
if (!isConnected) {
callback?.onFailed("眼镜未连接")
return false
}
cxrApi.controlScene(
sceneType = ValueUtil.CxrSceneType.WORD_TIPS,
openOrClose = true,
otherParams = null
)
Log.d(TAG, "发送喝水提醒,长度:${text.length}")
val status = cxrApi.sendStream(
type = ValueUtil.CxrStreamType.WORD_TIPS,
stream = text.toByteArray(Charsets.UTF_8),
fileName = "water_reminder.txt",
cb = object : SendStatusCallback() {
override fun onSendSucceed() {
Log.d(TAG, "喝水提醒发送成功")
callback?.onSuccess()
}
override fun onSendFailed(errorCode: ValueUtil.CxrSendErrorCode?) {
val errorMsg = getSendErrorMessage(errorCode)
Log.e(TAG, "喝水提醒发送失败:$errorMsg")
callback?.onFailed(errorMsg)
}
}
)
return status == ValueUtil.CxrStatus.REQUEST_SUCCEED
}
又一个坑:发送数据之前,必须先调用 controlScene() 打开提词器场景。我一开始直接调用 sendStream(),眼镜端什么反应都没有。折腾了半天才发现这个问题。
另外,必须指定 UTF-8 编码,否则中文会变成乱码:
stream = text.toByteArray()
stream = text.toByteArray(Charsets.UTF_8)
7.2 TTS 语音播报
fun sendTts(text: String): Boolean {
if (!isConnected) return false
Log.d(TAG, "发送 TTS: $text")
val status = cxrApi.sendTtsContent(text)
if (status == ValueUtil.CxrStatus.REQUEST_SUCCEED) {
cxrApi.notifyTtsAudioFinished()
return true
}
return false
}
TTS 播放后要调用 notifyTtsAudioFinished(),否则可能出现播放不完整的情况。
八、前台服务:定时提醒
Android 8.0+ 对后台服务限制很严格,普通后台服务很快就会被系统杀掉。所以必须使用前台服务,显示一个常驻通知。
class ReminderService : Service() {
companion object {
private const val TAG = "ReminderService"
private const val CHANNEL_ID = "water_reminder_channel"
private const val NOTIFICATION_ID = 1001
const val ACTION_START = "com.rokid.waterreminder.ACTION_START"
const val ACTION_STOP = "com.rokid.waterreminder.ACTION_STOP"
fun start(context: Context) {
val intent = Intent(context, ReminderService::class.java).apply {
action = ACTION_START
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.startForegroundService(intent)
} else {
context.startService(intent)
}
}
fun stop(context: Context) {
val intent = Intent(context, ReminderService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
}
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var reminderJob: Job? = null
private lateinit var repository: WaterRepository
private var lastReminderTime: Long = 0
override fun onCreate() {
super.onCreate()
repository = WaterRepository.getInstance(applicationContext)
createNotificationChannel()
Log.d(TAG, "服务创建")
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> startReminder()
ACTION_STOP -> stopSelf()
}
return START_STICKY
}
private fun startReminder() {
startForeground(NOTIFICATION_ID, createNotification())
reminderJob = serviceScope.launch {
while (isActive) {
checkAndRemind()
delay(60 * 1000L)
}
}
Log.d(TAG, "提醒服务已启动")
}
private fun checkAndRemind() {
val settings = repository.getSettings()
if (!settings.isEnabled) return
if (!isInReminderTime(settings.startTime, settings.endTime)) return
val todayStats = repository.getTodayStats()
if (todayStats.isGoalMet) return
val now = System.currentTimeMillis()
val intervalMs = settings.intervalMinutes * 60 * 1000L
if (now - lastReminderTime < intervalMs) return
sendReminder(settings, todayStats)
lastReminderTime = now
}
private fun sendReminder(settings: ReminderSettings, stats: DailyStats) {
val remainingMl = settings.targetMl - stats.totalMl
val remainingCups = (remainingMl + settings.cupSizeMl - 1) / settings.cupSizeMl
val tips = listOf("该喝水啦", "记得补充水分", "喝水时间到", "为健康喝杯水吧")
val message = "${tips.random()}\n还需 $remainingCups 杯 (${remainingMl}ml)"
showNotification(message)
if (settings.glassesEnabled && RokidGlassesManager.isConnected) {
RokidGlassesManager.sendWaterReminder(message)
}
if (settings.ttsEnabled && RokidGlassesManager.isConnected) {
RokidGlassesManager.sendTts("该喝水了,今日还需喝${remainingCups}杯水")
}
Log.d(TAG, "发送提醒:$message")
}
}
这里用 Kotlin 协程来处理定时任务,比传统的 Handler + Runnable 更简洁。
START_STICKY:服务被杀后自动重启
- 前台通知:让系统知道这个服务是"用户关心的"
九、主界面实现
主界面使用 ViewBinding 来访问视图,配合协程处理数据加载:
class MainActivity : AppCompatActivity() {
companion object {
private const val TAG = "MainActivity"
private const val REQUEST_PERMISSIONS = 1001
}
private lateinit var binding: ActivityMainBinding
private lateinit var repository: WaterRepository
private var settings = ReminderSettings.DEFAULT
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
repository = WaterRepository.getInstance(this)
settings = repository.getSettings()
setupViews()
checkPermissions()
observeConnection()
if (settings.isEnabled) {
ReminderService.start(this)
}
}
private fun setupViews() {
updateTodayStats()
binding.btnDrink.setOnClickListener {
addWaterRecord(settings.cupSizeMl)
}
binding.btnCustomDrink.setOnClickListener {
showCustomDrinkDialog()
}
binding.btnConnectGlasses.setOnClickListener {
if (RokidGlassesManager.isConnected) {
RokidGlassesManager.disconnect()
} else {
connectGlasses()
}
}
binding.btnTestReminder.setOnClickListener {
sendTestReminder()
}
}
private fun updateTodayStats() {
lifecycleScope.launch {
val stats = repository.getTodayStats()
binding.apply {
tvTotalMl.text = "${stats.totalMl} ml"
tvCupCount.text = "${stats.cupCount} 杯"
tvTargetMl.text = "/ ${stats.targetMl} ml"
progressWater.progress = (stats.completionRate * 100).toInt().coerceAtMost(100)
if (stats.isGoalMet) {
tvStatus.text = "今日目标已达成 🎉"
tvStatus.setTextColor(getColor(R.color.success))
} else {
val remaining = stats.targetMl - stats.totalMl
tvStatus.text = "还需 ${remaining} ml"
tvStatus.setTextColor(getColor(R.color.text_secondary))
}
}
}
}
private fun addWaterRecord(amountMl: Int) {
val record = WaterRecord(amountMl = amountMl)
repository.addRecord(record)
updateTodayStats()
binding.root.performHapticFeedback(android.view.HapticFeedbackConstants.CONFIRM)
Toast.makeText(this, "记录成功:+${amountMl}ml", Toast.LENGTH_SHORT).show()
if (RokidGlassesManager.isConnected && settings.glassesEnabled) {
val stats = repository.getTodayStats()
val message = "喝水 +${amountMl}ml\n今日:${stats.totalMl}/${stats.targetMl}ml"
RokidGlassesManager.sendWaterReminder(message)
}
}
private fun observeConnection() {
RokidGlassesManager.setConnectionCallback(object : RokidGlassesManager.ConnectionCallback {
override fun onConnecting() {
runOnUiThread {
binding.btnConnectGlasses.text = "连接中..."
}
}
override fun onConnected() {
runOnUiThread {
updateConnectionStatus()
Toast.makeText(this@MainActivity, "眼镜已连接", Toast.LENGTH_SHORT).show()
}
}
override fun onDisconnected() {
runOnUiThread {
updateConnectionStatus()
}
}
override fun onFailed(errorMsg: String) {
runOnUiThread {
updateConnectionStatus()
Toast.makeText(this@MainActivity, "连接失败:$errorMsg", Toast.LENGTH_SHORT).show()
}
}
})
}
}
界面布局用了 Material Design 风格的卡片式设计,主要分三个区域:
- 今日饮水统计卡片(进度条 + 数据)
- 记录喝水按钮区
- 眼镜连接区
十、踩坑记录
10.1 坑 1:蓝牙权限动态申请
Android 12 (API 31) 新增了 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT 权限,而且必须运行时申请。
我一开始只在 Manifest 里声明了权限,Debug 版能跑,Release 版直接崩溃。排查了半天才发现是权限问题。
private fun checkPermissions() {
val permissions = mutableListOf<String>()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
permissions.add(Manifest.permission.BLUETOOTH_SCAN)
permissions.add(Manifest.permission.BLUETOOTH_CONNECT)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
permissions.add(Manifest.permission.POST_NOTIFICATIONS)
}
val notGranted = permissions.filter {
ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
}
if (notGranted.isNotEmpty()) {
ActivityCompat.requestPermissions(this, notGranted.toTypedArray(), REQUEST_PERMISSIONS)
}
}
10.2 坑 2:提词器场景不显示
sendStream() 调用成功,返回值也是 REQUEST_SUCCEED,但眼镜端什么都没显示。
原因:没有先调用 controlScene() 打开场景。
cxrApi.controlScene(ValueUtil.CxrSceneType.WORD_TIPS, true, null)
cxrApi.sendStream(ValueUtil.CxrStreamType.WORD_TIPS, data, fileName, callback)
10.3 坑 3:中文乱码
stream = text.toByteArray(Charsets.UTF_8)
10.4 坑 4:TTS 播放不完整
原因:没有调用 notifyTtsAudioFinished() 通知 SDK。
val status = cxrApi.sendTtsContent(text)
if (status == ValueUtil.CxrStatus.REQUEST_SUCCEED) {
cxrApi.notifyTtsAudioFinished()
}
十一、最终效果
| 功能 | 说明 |
|---|
| 喝水记录 | 一键记录 + 自定义数量 |
| 目标追踪 | 进度条实时显示 |
| 定时提醒 | 前台服务保活 |
| 眼镜同步 | 提词器场景显示 |
| TTS 播报 | 语音提醒 |
| 历史统计 | 近 7 天数据 |
十二、一些思考
12.1 AR 眼镜适合什么样的应用?
AR 眼镜的优势是信息即视性——不需要主动去看,信息就在视野里。但这同时是劣势:屏幕小、分辨率有限、长时间佩戴不舒服。
- 需要频繁查看的信息(比如喝水提醒)
- 双手被占用的情况(比如做菜看菜谱)
- 需要隐蔽查看的信息(比如演讲提词)
- 需要大量阅读的内容
- 需要复杂交互的操作
- 长时间使用的应用
12.2 这个项目的局限性
- 用户群体有限:只有 Rokid 眼镜用户能用
- 需要一直戴着眼镜:不戴的时候提醒就收不到
- 数据孤岛:没有和健康平台打通
12.3 后续改进方向
- 接入小米运动/华为健康的开放 API
- 添加小组件,不用打开 App 也能看到进度
- 支持多人账号(家庭成员共用)
- 加入喝水知识科普
十三、结语
这个项目虽然简单,但确实解决了我的实际问题。现在每天都能完成 2000ml 的饮水目标,上次复查指标也有所改善。
开发过程中踩了不少坑,但也让我对 CXR-M SDK 有了更深入的理解。希望这篇文章能帮到其他想用 Rokid 眼镜做应用的开发者。