程序员的自我修养:用 AR 眼镜管理健康

程序员的自我修养:用 AR 眼镜管理健康
在这里插入图片描述

欢迎文末添加好友交流,共同进步!

“ 俺はモンキー・D・ルフィ。海贼王になる男だ!”

在这里插入图片描述


本文应用基于Rokid灵珠智能体/CXR SDK开发,开发指南请点击

一、从一次体检说起

去年年底公司组织体检,拿到报告的时候我愣住了——尿酸偏高、肾结石早期征兆、血液粘稠度异常。医生问了我几个问题,最后归结为一句话:“你是不是经常一坐就是一整天,基本不怎么喝水?”

被说中了。作为一名程序员,写代码的时候经常进入"心流"状态,一坐就是三四个小时,等反应过来的时候,嗓子已经干得冒烟了。

买了各种喝水提醒 App,用过一段时间后都卸载了。原因很简单:

手机震动提醒的时候,我正在敲代码,下意识就把通知划掉了。

划掉之后呢?继续写代码。等想起来喝水这件事,已经是两小时后了。

这个场景让我开始思考:如果提醒不是出现在手机上,而是出现在我的视野里呢?

恰好看到 Rokid 开发者社区的征文活动,手里正好有一副 Rokid AR 眼镜。于是决定花一周时间,开发一款「喝水提醒助手」。


二、为什么是 AR 眼镜?

在做技术选型之前,我先分析了一下现有方案的痛点:

手机通知的问题:

  • 需要解锁才能看到详情
  • 容易被其他应用干扰(刷着刷着就忘了)
  • 写代码的时候手机放在一边,不一定会注意到

智能水杯的问题:

  • 贵,动辄两三百
  • 需要充电
  • 只能记录从这个杯子喝的水,换个杯子就失效了

Apple Watch / 手环的问题:

  • 提醒方式是震动,容易被忽略
  • 屏幕太小,信息量有限

而 AR 眼镜有几个独特优势:

  1. 视野可见:只要戴着眼镜,提醒就在视野里
  2. 不中断工作:不用低头看手机,不用解锁屏幕
  3. 信息丰富:可以显示详细的喝水数据
  4. 双重提醒:文字 + 语音(TTS),不容易错过

当然,AR 眼镜也有局限性——不是每个人都有,戴着不舒服,续航有限。但如果你恰好有一副,那这个方案值得一试。


三、技术选型:CXR-M SDK vs 灵珠平台

Rokid 提供了两套开发方案:

方案适用场景学习成本
灵珠 AI 平台对话式应用,需要 AI 生成内容低,可视化配置
CXR-M SDK纯代码开发,完全自定义中,需要 Android 开发经验

我的需求很明确:

  • 本地管理喝水数据
  • 定时提醒 + 手动记录
  • 发送文字到眼镜显示
  • 支持 TTS 语音播报

这些功能用 CXR-M SDK 完全能实现,而且不需要依赖云端服务。更重要的是,CXR-M SDK 内置了「提词器场景」(WORD_TIPS),正好可以用来显示喝水提醒。

所谓「提词器场景」,原本是给演讲者看稿子用的。但换个思路,把喝水提醒当"稿子"发过去,不就是我要的功能吗?


四、项目架构设计

在动手写代码之前,我先规划了整体架构:

img

核心组件:

  1. WaterRepository:数据仓库,用 SharedPreferences 存储数据
  2. ReminderService:前台服务,负责定时提醒
  3. 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// CXR-M SDK 要求最低 Android 9.0 targetSdk =34 versionCode =1 versionName ="1.0.0"} compileOptions { sourceCompatibility = JavaVersion.VERSION_17 } kotlinOptions { jvmTarget ="17"}} dependencies {// CXR-M SDK(核心依赖)implementation("com.rokid.cxr:client-m:1.0.1-20250812.080117-2")// SDK 需要的第三方库implementation("com.squareup.okhttp3:okhttp:4.9.3")implementation("com.google.code.gson:gson:2.10.1")// AndroidXimplementation("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 的硬性要求。

img

5.2 权限配置

AndroidManifest.xml 中需要声明以下权限:

<!-- 蓝牙权限 --><uses-permissionandroid:name="android.permission.BLUETOOTH"/><uses-permissionandroid:name="android.permission.BLUETOOTH_ADMIN"/><uses-permissionandroid:name="android.permission.BLUETOOTH_CONNECT"/><uses-permissionandroid:name="android.permission.BLUETOOTH_SCAN"android:usesPermissionFlags="neverForLocation"/><!-- 前台服务权限 --><uses-permissionandroid:name="android.permission.FOREGROUND_SERVICE"/><uses-permissionandroid:name="android.permission.FOREGROUND_SERVICE_HEALTH"/><!-- 通知权限(Android 13+) --><uses-permissionandroid:name="android.permission.POST_NOTIFICATIONS"/>

重点说一下 BLUETOOTH_SCANneverForLocation 标志。Android 系统规定蓝牙扫描需要定位权限,但这个标志可以告诉系统"我不是用来定位的"。这样用户在授权的时候,不会看到"此应用需要获取您的位置信息"这种吓人的提示。

另外,Android 12+ 和 Android 13+ 的蓝牙权限、通知权限都需要运行时动态申请,光在 Manifest 里声明是没用的。

六、数据层实现

6.1 数据模型

先定义三个核心数据类:

img
// WaterRecord.kt/** * 单次喝水记录 */dataclassWaterRecord(val id: Long = System.currentTimeMillis(),// 用时间戳作为唯一标识val timestamp: Long = System.currentTimeMillis(),val amountMl: Int,// 喝水量(毫升)val note: String?=null// 备注(可选)){fungetFormattedTime(): String {val sdf = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault())return sdf.format(java.util.Date(timestamp))}}/** * 每日喝水统计 */dataclassDailyStats(val date: String,// 日期 yyyy-MM-ddval totalMl: Int,// 总饮水量val targetMl: Int,// 目标饮水量val cupCount: Int // 喝水次数){// 完成率(0.0 ~ 1.0+)val completionRate: Float get()=if(targetMl >0) totalMl.toFloat()/ targetMl else0f// 是否达标val isGoalMet: Boolean get()= totalMl >= targetMl }/** * 提醒设置 */dataclassReminderSettings(val isEnabled: Boolean =true,// 是否启用提醒val targetMl: Int =2000,// 每日目标(默认2000ml)val cupSizeMl: Int =250,// 每杯容量(默认250ml)val intervalMinutes: Int =60,// 提醒间隔(默认60分钟)val startTime: String ="08:00",// 开始时间val endTime: String ="22:00",// 结束时间val glassesEnabled: Boolean =false,// 是否启用眼镜提醒val ttsEnabled: Boolean =true// 是否启用语音播报){companionobject{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 完全够用,而且代码更简单。

img
// WaterRepository.ktclass WaterRepository privateconstructor(context: Context){privateval prefs: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)privateval gson =Gson()companionobject{privateconstval PREFS_NAME ="water_reminder_prefs"privateconstval KEY_RECORDS ="water_records"privateconstval KEY_SETTINGS ="reminder_settings"@Volatileprivatevar instance: WaterRepository?=nullfungetInstance(context: Context): WaterRepository {return instance ?:synchronized(this){ instance ?:WaterRepository(context.applicationContext).also{ instance = it }}}}// 添加喝水记录funaddRecord(record: WaterRecord){val records =getAllRecords().toMutableList() records.add(0, record)// 新记录插入到开头saveRecords(records)}// 获取今日记录fungetTodayRecords(): List<WaterRecord>{val today =getTodayDateString()returngetAllRecords().filter{ record ->getDateString(record.timestamp)== today }}// 获取今日统计fungetTodayStats(): DailyStats {val todayRecords =getTodayRecords()val settings =getSettings()returnDailyStats( date =getTodayDateString(), totalMl = todayRecords.sumOf{ it.amountMl }, targetMl = settings.targetMl, cupCount = todayRecords.size )}// 获取历史统计(最近N天)fungetHistoryStats(days: Int =7): List<DailyStats>{val settings =getSettings()val records =getAllRecords()val result = mutableListOf<DailyStats>()val calendar = java.util.Calendar.getInstance()for(i in0 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()}// 获取/保存设置fungetSettings(): ReminderSettings {val json = prefs.getString(KEY_SETTINGS,null)?:return ReminderSettings.DEFAULT returntry{ gson.fromJson(json, ReminderSettings::class.java)}catch(e: Exception){ ReminderSettings.DEFAULT }}funsaveSettings(settings: ReminderSettings){ prefs.edit().putString(KEY_SETTINGS, gson.toJson(settings)).apply()}// ... 其他辅助方法}

这里用单例模式来管理 Repository 实例,避免重复创建。@Volatile + synchronized 是为了保证线程安全。


七、SDK 封装层

这是整个项目的核心。CXR-M SDK 的 API 虽然不复杂,但有一些细节需要注意。我把它封装成了一个单例类:

img
// RokidGlassesManager.ktobject RokidGlassesManager {privateconstval TAG ="RokidGlassesManager"privateval cxrApi: CxrApi by lazy { CxrApi.getInstance()}privatevar connectionCallback: ConnectionCallback?=null// ========== 回调接口 ==========interface ConnectionCallback {funonConnecting()funonConnected()funonDisconnected()funonFailed(errorMsg: String)}interface SendCallback {funonSuccess()funonFailed(errorMsg: String)}// ========== 连接相关 ==========val isConnected: Boolean get()= cxrApi.isBluetoothConnected funsetConnectionCallback(callback: ConnectionCallback?){this.connectionCallback = callback }/** * 查找已配对的 Rokid 眼镜设备 */funfindRokidGlasses(bluetoothAdapter: BluetoothAdapter): BluetoothDevice?{if(ActivityCompat.checkSelfPermission( bluetoothAdapter.javaClass, Manifest.permission.BLUETOOTH_CONNECT )!= PackageManager.PERMISSION_GRANTED ){returnnull}val bondedDevices = bluetoothAdapter.bondedDevices for(device in bondedDevices){val deviceName = device.name ?:continue// 眼镜设备名通常包含 "Rokid" 或 "Glasses"if(deviceName.contains("Rokid", ignoreCase =true)|| deviceName.contains("Glasses", ignoreCase =true)){ Log.d(TAG,"找到 Rokid 眼镜: $deviceName")return device }}returnnull}/** * 连接眼镜 */funconnectGlasses(context: Context, device: BluetoothDevice){ Log.d(TAG,"开始连接眼镜: ${device.name}") connectionCallback?.onConnecting() cxrApi.initBluetooth( context = context, device = device, callback =object:BluetoothStatusCallback(){overridefunonConnectionInfo( socketUuid: String?, macAddress: String?, rokidAccount: String?, glassesType: Int ){// 关键:获取到 UUID 和 MAC 后,调用 connectBluetooth 建立真正连接 Log.d(TAG,"获取连接信息: UUID=$socketUuid, MAC=$macAddress")if(!socketUuid.isNullOrEmpty()&&!macAddress.isNullOrEmpty()){connectBluetooth(context, socketUuid, macAddress)}else{ connectionCallback?.onFailed("获取连接信息失败")}}overridefunonConnected(){ Log.d(TAG,"眼镜连接成功") connectionCallback?.onConnected()}overridefunonDisconnected(){ Log.w(TAG,"眼镜连接断开") connectionCallback?.onDisconnected()}overridefunonFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?){val errorMsg =getBluetoothErrorMessage(errorCode) Log.e(TAG,"眼镜连接失败: $errorMsg") connectionCallback?.onFailed(errorMsg)}})}privatefunconnectBluetooth(context: Context, socketUuid: String, macAddress: String){ cxrApi.connectBluetooth( context = context, socketUuid = socketUuid, macAddress = macAddress, callback =object:BluetoothStatusCallback(){overridefunonConnected(){ Log.d(TAG,"蓝牙连接确认成功")}overridefunonDisconnected(){ connectionCallback?.onDisconnected()}overridefunonFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?){ connectionCallback?.onFailed(getBluetoothErrorMessage(errorCode))}overridefunonConnectionInfo( socketUuid: String?, macAddress: String?, rokidAccount: String?, glassesType: Int ){// 这个回调在 connectBluetooth 阶段可以忽略}})}// ... 通信相关方法}

这里有一个关键细节:连接眼镜分两步:

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

我一开始以为调用 initBluetooth() 就能连上,结果等了半天没反应。后来看文档才发现需要两步。

7.1 发送提醒到眼镜

/** * 发送喝水提醒到眼镜 */funsendWaterReminder(text: String, callback: SendCallback?=null): Boolean {if(!isConnected){ callback?.onFailed("眼镜未连接")returnfalse}// 关键:必须先打开提词器场景! 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),// 重要:必须指定 UTF-8 编码 fileName ="water_reminder.txt", cb =object:SendStatusCallback(){overridefunonSendSucceed(){ Log.d(TAG,"喝水提醒发送成功") callback?.onSuccess()}overridefunonSendFailed(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 语音播报

/** * 发送 TTS 语音播报 */funsendTts(text: String): Boolean {if(!isConnected){returnfalse} Log.d(TAG,"发送 TTS: $text")val status = cxrApi.sendTtsContent(text)if(status == ValueUtil.CxrStatus.REQUEST_SUCCEED){// 重要:通知 SDK TTS 播放完成 cxrApi.notifyTtsAudioFinished()returntrue}returnfalse}

TTS 播放后要调用 notifyTtsAudioFinished(),否则可能出现播放不完整的情况。


八、前台服务:定时提醒

Android 8.0+ 对后台服务限制很严格,普通后台服务很快就会被系统杀掉。所以必须使用前台服务,显示一个常驻通知。

// ReminderService.ktclass ReminderService :Service(){companionobject{privateconstval TAG ="ReminderService"privateconstval CHANNEL_ID ="water_reminder_channel"privateconstval NOTIFICATION_ID =1001constval ACTION_START ="com.rokid.waterreminder.ACTION_START"constval ACTION_STOP ="com.rokid.waterreminder.ACTION_STOP"funstart(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)}}funstop(context: Context){val intent =Intent(context, ReminderService::class.java).apply{ action = ACTION_STOP } context.startService(intent)}}privateval serviceScope =CoroutineScope(Dispatchers.Default +SupervisorJob())privatevar reminderJob: Job?=nullprivatelateinitvar repository: WaterRepository privatevar lastReminderTime: Long =0overridefunonCreate(){super.onCreate() repository = WaterRepository.getInstance(applicationContext)createNotificationChannel() Log.d(TAG,"服务创建")}overridefunonStartCommand(intent: Intent?, flags: Int, startId: Int): Int {when(intent?.action){ ACTION_START ->startReminder() ACTION_STOP ->stopSelf()}return START_STICKY // 服务被杀后自动重启}privatefunstartReminder(){// 启动前台服务(必须显示通知)startForeground(NOTIFICATION_ID,createNotification())// 开始定时检查 reminderJob = serviceScope.launch{while(isActive){checkAndRemind()delay(60*1000L)// 每分钟检查一次}} Log.d(TAG,"提醒服务已启动")}privatefuncheckAndRemind(){val settings = repository.getSettings()// 1. 检查是否启用if(!settings.isEnabled)return// 2. 检查是否在提醒时间范围内if(!isInReminderTime(settings.startTime, settings.endTime))return// 3. 检查今日是否已达标val todayStats = repository.getTodayStats()if(todayStats.isGoalMet)return// 4. 检查距离上次提醒是否超过间隔val now = System.currentTimeMillis()val intervalMs = settings.intervalMinutes *60*1000Lif(now - lastReminderTime < intervalMs)return// 5. 发送提醒sendReminder(settings, todayStats) lastReminderTime = now }privatefunsendReminder(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 来访问视图,配合协程处理数据加载:

// MainActivity.ktclass MainActivity :AppCompatActivity(){companionobject{privateconstval TAG ="MainActivity"privateconstval REQUEST_PERMISSIONS =1001}privatelateinitvar binding: ActivityMainBinding privatelateinitvar repository: WaterRepository privatevar settings = ReminderSettings.DEFAULT overridefunonCreate(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)}}privatefunsetupViews(){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()}}privatefunupdateTodayStats(){ 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))}}}}privatefunaddWaterRecord(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)}}privatefunobserveConnection(){ RokidGlassesManager.setConnectionCallback(object: RokidGlassesManager.ConnectionCallback{overridefunonConnecting(){ runOnUiThread { binding.btnConnectGlasses.text ="连接中..."}}overridefunonConnected(){ runOnUiThread {updateConnectionStatus() Toast.makeText(this@MainActivity,"眼镜已连接", Toast.LENGTH_SHORT).show()}}overridefunonDisconnected(){ runOnUiThread {updateConnectionStatus()}}overridefunonFailed(errorMsg: String){ runOnUiThread {updateConnectionStatus() Toast.makeText(this@MainActivity,"连接失败: $errorMsg", Toast.LENGTH_SHORT).show()}}})}// ... 其他方法}

界面布局用了 Material Design 风格的卡片式设计,主要分三个区域:

  1. 今日饮水统计卡片(进度条 + 数据)
  2. 记录喝水按钮区
  3. 眼镜连接区

十、踩坑记录

10.1 坑 1:蓝牙权限动态申请

Android 12 (API 31) 新增了 BLUETOOTH_SCANBLUETOOTH_CONNECT 权限,而且必须运行时申请

我一开始只在 Manifest 里声明了权限,Debug 版能跑,Release 版直接崩溃。排查了半天才发现是权限问题。

解决方案

privatefuncheckPermissions(){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 播放不完整

有时候 TTS 只播了一半就停了。

原因:没有调用 notifyTtsAudioFinished() 通知 SDK。

解决方案

val status = cxrApi.sendTtsContent(text)if(status == ValueUtil.CxrStatus.REQUEST_SUCCEED){ cxrApi.notifyTtsAudioFinished()// 必须调用}

十一、最终效果

经过一周的开发和调试,应用终于能正常运行了。

功能清单

功能说明
喝水记录一键记录 + 自定义数量
目标追踪进度条实时显示
定时提醒前台服务保活
眼镜同步提词器场景显示
TTS 播报语音提醒
历史统计近 7 天数据

眼镜端显示效果

img

十二、一些思考

12.1 AR 眼镜适合什么样的应用?

做这个项目的过程中,我一直在思考这个问题。

AR 眼镜的优势是信息即视性——不需要主动去看,信息就在视野里。但这同时是劣势:屏幕小、分辨率有限、长时间佩戴不舒服。

所以,AR 眼镜最适合的场景是:

  1. 需要频繁查看的信息(比如喝水提醒)
  2. 双手被占用的情况(比如做菜看菜谱)
  3. 需要隐蔽查看的信息(比如演讲提词)

不适合的场景:

  1. 需要大量阅读的内容
  2. 需要复杂交互的操作
  3. 长时间使用的应用

12.2 这个项目的局限性

  1. 用户群体有限:只有 Rokid 眼镜用户能用
  2. 需要一直戴着眼镜:不戴的时候提醒就收不到
  3. 数据孤岛:没有和健康平台打通

12.3 后续改进方向

如果继续完善这个项目,我会考虑:

  • 接入小米运动/华为健康的开放 API
  • 添加小组件,不用打开 App 也能看到进度
  • 支持多人账号(家庭成员共用)
  • 加入喝水知识科普

十四、结语

这个项目虽然简单,但确实解决了我的实际问题。现在每天都能完成 2000ml 的饮水目标,上次复查指标也有所改善。

开发过程中踩了不少坑,但也让我对 CXR-M SDK 有了更深入的理解。希望这篇文章能帮到其他想用 Rokid 眼镜做应用的开发者。

如果你也有 Rokid 眼镜,欢迎试试这个应用,有问题可以在评论区交流。


项目源码DrinkingWaterReminder/

相关资源

Read more

无人机巡检新选择:YOLOv12镜像高效部署方案

无人机巡检新选择:YOLOv12镜像高效部署方案 在电力线路巡检中,一架无人机每分钟飞越3公里,需实时识别绝缘子破损、金具锈蚀、树障侵入等十余类缺陷;在光伏电站运维场景里,热成像与可见光双模图像流持续涌入,系统必须在200毫秒内完成多目标定位与分类——这些严苛需求,正倒逼目标检测技术从“可用”迈向“可靠即用”。 就在2025年初,YOLOv12官版镜像正式发布。这不是又一次参数微调的版本更新,而是一次面向边缘智能场景的架构重构:它首次将注意力机制深度融入YOLO实时检测范式,在保持毫秒级推理速度的同时,显著提升小目标与遮挡目标的识别鲁棒性。更重要的是,官方预构建镜像让这套前沿模型真正走出实验室,成为一线工程师可即刻部署的生产工具。 1. 为什么无人机巡检需要YOLOv12? 1.1 传统方案的三大瓶颈 过去两年,我们为南方某电网公司部署了三套不同架构的巡检AI系统,发现共性痛点始终围绕三个维度: * 小目标漏检严重:绝缘子串长度仅占图像高度3%–5%,YOLOv5/v8在未精细调参时漏检率超27%; * 边缘设备吞吐不足:Jetson Orin NX实测YOLOv8s

小米 “养龙虾”:手机 Agent 落地,智能家居十年困局被撬开

小米 “养龙虾”:手机 Agent 落地,智能家居十年困局被撬开

3月6日,小米正式推出国内首个手机端类 OpenClaw Agent 应用 ——Xiaomi miclaw,开启小范围邀请封测。这款被行业与网友戏称为小米 “开养龙虾” 的新品,绝非大模型浪潮下又一款语音助手的常规升级,而是基于自研 MiMo 大模型、具备系统级权限、全场景上下文理解能力的端侧智能体。 作为深耕智能家居领域的行业媒体,《智哪儿》始终认为:智能家居行业过去十年的迭代,始终没能跳出 “被动执行” 的底层困局。而 miclaw 的落地,不止是小米在端侧 AI 赛道的关键落子,更是为整个智能家居行业的底层逻辑重构,提供了可落地的参考范本。需要清醒认知的是,目前该产品仍处于小范围封测阶段,复杂场景执行成功率、端侧功耗表现、第三方生态适配进度等核心体验,仍有待大规模用户实测验证。本文将结合具象场景、量化数据与多维度视角,客观拆解 miclaw 的突破价值、现实挑战,以及它对智能家居行业的长期影响。 01 复盘行业困局:智能家居十年 始终困在 “被动执行”

深入解析 π₀ 与 π₀.5:Physical Intelligence 的机器人基础模型演进

本文详细对比分析 Physical Intelligence 公司发布的两代视觉-语言-动作(VLA)模型:π₀ 和 π₀.5,从设计目标、模型架构、训练方法、数据策略等多个维度进行深入解读。 1. 引言 机器人领域正在经历一场由基础模型驱动的革命。正如大语言模型(LLM)改变了自然语言处理领域,视觉-语言-动作模型(Vision-Language-Action, VLA) 正在改变机器人学习的范式。 Physical Intelligence 公司先后发布了两代 VLA 模型: * π₀(2024年10月):首个通用机器人策略 * π₀.5(2025年4月):具备开放世界泛化能力的 VLA 本文将深入分析这两个模型的核心差异,帮助读者理解 VLA 技术的演进方向。 2. π₀:首个通用机器人策略 2.1 设计目标 π₀ 的核心目标是实现 灵巧操作(

Coze(扣子)全解析:100个落地用途+发布使用指南,小白也能玩转低代码AI智能体

Coze(扣子)全解析:100个落地用途+发布使用指南,小白也能玩转低代码AI智能体

摘要:Coze(扣子)作为字节跳动推出的低代码AI智能体平台,凭借零代码/低代码拖拽式操作、丰富的插件生态和多平台发布能力,成为小白和职场人高效落地AI应用的首选工具。本文全面汇总Coze可实现的100个实用场景,覆盖个人、学习、办公、运营等7大领域,同时详细拆解其生成形态、发布流程和使用方法,帮你快速上手,把AI能力转化为实际生产力,无需专业开发经验也能轻松搭建专属AI应用。 前言 在AI普及的当下,很多人想借助AI提升效率、解决实际问题,但苦于没有编程基础,无法开发专属AI工具。而Coze(扣子)的出现,彻底打破了这一壁垒——它是字节跳动自主研发的低代码AI智能体平台,无需复杂编码,通过拖拽组件、配置插件、编写简单提示词,就能快速搭建聊天Bot、工作流、知识库等AI应用,并且支持多渠道发布,让你的AI工具随时随地可用。 本文将分为两大核心部分:第一部分汇总Coze可落地的100个实用场景,帮你打开思路,找到适配自己需求的用法;第二部分详细讲解Coze生成的应用形态、发布流程和使用技巧,让你搭建完成后快速落地使用,真正实现“零代码上手,高效用AI”。 第一部分:Coze