跳到主要内容
极客日志极客日志面向AI+效率的开发者社区
首页博客GitHub 精选镜像工具UI配色美学隐私政策关于联系
搜索内容 / 工具 / 仓库 / 镜像...⌘K搜索
注册
博客列表
KotlinAI大前端java

基于 Rokid AR 眼镜的 Android 喝水提醒应用开发

!AR 眼镜界面 一、从一次体检说起 二、为什么是 AR 眼镜? 三、技术选型:CXR-M SDK vs 灵珠平台 四、项目架构设计 五、从配置开始:Gradle 和权限 5.1 添加 SDK 依赖 5.2 权限配置 六、数据层实现 6.1 数据模型 6.2 数据仓库 七、SDK 封装层 7.1 发送提醒到眼镜 7.2 TTS 语音播报 八、前台服务:定时提醒 九、主界面实现 十、踩坑记录 10.…

清心发布于 2026/4/6更新于 2026/5/2077K 浏览
基于 Rokid AR 眼镜的 Android 喝水提醒应用开发

AR 眼镜界面

  • 一、从一次体检说起
  • [二、为什么是 AR 眼镜?](#二、为什么是 AR 眼镜?)
  • [三、技术选型:CXR-M SDK vs 灵珠平台](#三、技术选型:CXR-M SDK vs 灵珠平台)
  • 四、项目架构设计
  • [五、从配置开始:Gradle 和权限](#五、从配置开始:Gradle 和权限)
    • 5.1 添加 SDK 依赖
    • 5.2 权限配置
  • 六、数据层实现
    • 6.1 数据模型
    • 6.2 数据仓库
  • 七、SDK 封装层
    • 7.1 发送提醒到眼镜
    • 7.2 TTS 语音播报
  • 八、前台服务:定时提醒
  • 九、主界面实现
  • 十、踩坑记录
    • 10.1 坑 1:蓝牙权限动态申请
    • 10.2 坑 2:提词器场景不显示
    • 10.3 坑 3:中文乱码
    • 10.4 坑 4:TTS 播放不完整
  • 十一、最终效果
  • 十二、一些思考
    • 12.1 AR 眼镜适合什么样的应用?
    • 12.2 这个项目的局限性
    • 12.3 后续改进方向
  • 十四、结语

一、从一次体检说起

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

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

买了各种喝水提醒 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),正好可以用来显示喝水提醒。

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


四、项目架构设计

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

项目架构

核心组件:

  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")
    // AndroidX
    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"/>
<!-- 通知权限(Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

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

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


六、数据层实现

6.1 数据模型

先定义三个核心数据类:

数据模型

// WaterRecord.kt
/**
 * 单次喝水记录
 */
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, // 日期 yyyy-MM-dd
    val totalMl: Int, // 总饮水量
    val targetMl: Int, // 目标饮水量
    val cupCount: Int // 喝水次数
) {
    // 完成率(0.0 ~ 1.0+)
    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, // 每日目标(默认 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 // 是否启用语音播报
) {
    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 完全够用,而且代码更简单。

数据仓库

// WaterRepository.kt
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
        )
    }

    // 获取历史统计(最近 N 天)
    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 虽然不复杂,但有一些细节需要注意。我把它封装成了一个单例类:

SDK 封装

// RokidGlassesManager.kt
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
    }

    /**
     * 查找已配对的 Rokid 眼镜设备
     */
    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
            // 眼镜设备名通常包含 "Rokid" 或 "Glasses"
            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
                ) {
                    // 关键:获取到 UUID 和 MAC 后,调用 connectBluetooth 建立真正连接
                    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
                ) {
                    // 这个回调在 connectBluetooth 阶段可以忽略
                }
            }
        )
    }

    // ... 通信相关方法
}

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

  1. 调用 initBluetooth() 获取连接信息(UUID 和 MAC 地址)
  2. 在 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), // 重要:必须指定 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 语音播报
/**
 * 发送 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) {
        // 重要:通知 SDK TTS 播放完成
        cxrApi.notifyTtsAudioFinished()
        return true
    }
    return false
}

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


八、前台服务:定时提醒

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

// ReminderService.kt
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()
        // 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 * 1000L
        if (now - lastReminderTime < intervalMs) return
        // 5. 发送提醒
        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 来访问视图,配合协程处理数据加载:

// MainActivity.kt
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 风格的卡片式设计,主要分三个区域:

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

十、踩坑记录

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 播放不完整

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

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

解决方案:

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

十一、最终效果

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

功能清单:

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

眼镜端显示效果:

眼镜端效果


十二、一些思考

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 眼镜做应用的开发者。


项目源码:DrinkingWaterReminder/

相关资源:

  • CXR-M SDK 官方文档
  • Rokid 开发者论坛
  • Android 前台服务文档

目录

  1. 一、从一次体检说起
  2. 二、为什么是 AR 眼镜?
  3. 三、技术选型:CXR-M SDK vs 灵珠平台
  4. 四、项目架构设计
  5. 五、从配置开始:Gradle 和权限
  6. 5.1 添加 SDK 依赖
  7. 5.2 权限配置
  8. 六、数据层实现
  9. 6.1 数据模型
  10. 6.2 数据仓库
  11. 七、SDK 封装层
  12. 7.1 发送提醒到眼镜
  13. 7.2 TTS 语音播报
  14. 八、前台服务:定时提醒
  15. 九、主界面实现
  16. 十、踩坑记录
  17. 10.1 坑 1:蓝牙权限动态申请
  18. 10.2 坑 2:提词器场景不显示
  19. 10.3 坑 3:中文乱码
  20. 10.4 坑 4:TTS 播放不完整
  21. 十一、最终效果
  22. 十二、一些思考
  23. 12.1 AR 眼镜适合什么样的应用?
  24. 12.2 这个项目的局限性
  25. 12.3 后续改进方向
  26. 十四、结语
  • 💰 8折买阿里云服务器限时8折了解详情
  • Magick API 一键接入全球大模型注册送1000万token查看
  • 🤖 一键搭建Deepseek满血版了解详情
  • 一键打造专属AI 智能体了解详情
极客日志微信公众号二维码

微信扫一扫,关注极客日志

微信公众号「极客日志V2」,在微信中扫描左侧二维码关注。展示文案:极客日志V2 zeeklog

更多推荐文章

查看全部
  • 算法实战:位运算求解两数之和、唯一数字及缺失数字
  • Web 服务与 I/O 模型详解及 Nginx 实战
  • Python 自动化办公与数据采集实战指南
  • Python Numpy 库常见用法入门教程
  • Chaterm:开源 AI 智能终端与 SSH 客户端工具解析
  • 使用 ONNX 加载头部姿态评估模型并集成到 LLM Agent
  • 双指针实战:移动零与复写零算法解析
  • 如何理性看待 AIGC 人工智能技术的发展与影响
  • 程序员适合考取的职业资格证书指南
  • LLaMA-Factory 命令行工具 llamafactory-cli 核心指令实战
  • HTML 网页结构搭建:从语义化标签到整站规划
  • Linux 环境下 Git 核心原理与基础使用
  • 大疆 MSDK 无人机视觉引导自适应降落方案
  • Windows 使用 Codex 显示“正在思考”的代理配置与脚本方案
  • 基于 FastGPT 与 MCP 协议构建工具增强型 AI Agent
  • Llama-Factory 支持 Flash Attention 吗?训练加速配置详解
  • Docker 部署 AI 量化分析平台:波浪理论实战指南
  • Webnovel Writer:基于 Claude Code 的长篇网文 AI 创作系统
  • C++ 构造函数为何不能是虚函数?调用虚函数有何风险?
  • AI 网络技术演进对路由协议的重塑分析

相关免费在线工具

  • RSA密钥对生成器

    生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online

  • Keycode 信息

    查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online

  • Escape 与 Native 编解码

    JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online

  • Mermaid 预览与可视化编辑

    基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online

  • JavaScript / HTML 格式化

    使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online

  • JavaScript 压缩与混淆

    Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online