AR 眼镜进厨房:手油腻也能看菜谱的春节小应用
春节期间做年夜饭,最头疼的就是一边炒菜一边看手机上的菜谱——手上沾满油渍,手机屏幕也弄得油腻腻的。这个问题让我想到:能不能把菜谱做到 AR 眼镜上?
恰好看到 Rokid 开发者社区的征文活动,于是花了一周时间,基于 CXR-M SDK 开发了一款「菜谱步骤助手」。这篇文章记录了开发过程中的技术选型、架构设计和踩坑经验。
技术选型:为什么是 CXR-M SDK?
Rokid SDK 体系
Rokid 为开发者提供了几套 SDK,针对不同的开发场景:
| SDK | 目标平台 | 适用场景 |
|---|---|---|
| CXR-M SDK | Android/iOS 手机端 | 手机控制眼镜,数据协同 |
| CXR-S SDK | YodaOS-Sprite 眼镜端 | 眼镜端独立应用开发 |
| AR Studio SDK | Unity/UE | 3D AR 场景开发 |
我的需求很明确:手机端选择菜谱,眼镜端显示步骤。这正好是 CXR-M SDK 的职责范围——它负责手机与眼镜之间的通信协同。
提词器场景的发现
翻阅 CXR-M SDK 文档时,我发现了一个预置的「提词器场景」(WORD_TIPS)。原本这是给演讲者看稿子用的,但换个思路,把菜谱步骤当"稿子"发过去,不就是我要的功能吗?
这个发现让开发难度大大降低——不需要自己开发眼镜端应用,直接复用官方的提词器功能就行。
SDK 的局限性
需要说明的是,CXR-M SDK 目前只支持 Android 平台,iOS 还在计划中。如果你是 iOS 开发者,可能要等等了。不过对于这次的项目,Android 已经够用。
系统架构
整体架构比较简单,核心是手机端与眼镜端的通信:
通信层面,CXR-M SDK 采用蓝牙 + WiFi P2P 双通道架构。蓝牙负责控制指令和小数据传输,WiFi P2P 用于大文件传输。对于我们这个应用,蓝牙通道就够用了。
开发环境配置
项目基础配置
创建 Android 项目时,需要注意 minSdk 至少设为 28(Android 9.0),这是 CXR-M SDK 的硬性要求。一开始我设的 21,编译直接报错。
在 settings.gradle.kts 中添加 Rokid 的 Maven 仓库:
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
// Rokid Maven 仓库
maven { url = uri("https://maven.rokid.com/repository/maven-public/") }
google()
mavenCentral()
}
}
然后在 app/build.gradle.kts 中添加依赖:
android {
defaultConfig {
minSdk = 28 // CXR-M SDK 硬性要求
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a")
}
}
}
dependencies {
// CXR-M SDK(建议使用最新正式版本)
implementation("com.rokid.cxr:client-m:1.0.9")
// SDK 依赖的第三方库(版本需与 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("com.google.android.material:material:1.11.0")
}
权限配置
这一块是个坑,需要申请的权限比较多:
<!-- 蓝牙相关 -->
<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.ACCESS_FINE_LOCATION" />
<!-- 网络 -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
几点说明:
- 定位权限:虽然我们不用定位功能,但 Android 系统规定蓝牙扫描必须申请定位权限。
neverForLocation标志可以告诉系统这不是用于定位追踪。
- 动态权限申请:Android 12(API 31)以上,蓝牙权限必须在运行时动态申请,光在 Manifest 里声明没用。这一点我一开始没注意,Debug 版能跑,Release 版就崩了。
核心功能实现
蓝牙连接管理
CXR-M SDK 的连接流程是:扫描设备 → 初始化蓝牙 → 获取连接信息 → 建立连接。我把它封装成了一个单例类:
import com.rokid.cxr.api.CxrApi
import com.rokid.cxr.callback.BluetoothStatusCallback
import com.rokid.cxr.util.ValueUtil
object RokidGlassesManager {
private val cxrApi = CxrApi.getInstance()
private var connectionCallback: ConnectionCallback? = null
/**
* 连接眼镜设备
*/
fun connectGlasses(context: Context, device: BluetoothDevice) {
cxrApi.initBluetooth(context, device, object : BluetoothStatusCallback {
// 连接信息回调,拿到 socketUuid 和 macAddress
override fun onConnectionInfo(
socketUuid: String?,
macAddress: String?,
rokidAccount: String?,
glassesType: Int
) {
if (socketUuid != null && macAddress != null) {
// 用拿到的信息建立正式连接
connectBluetooth(context, socketUuid, macAddress)
}
}
override fun onConnected() {
connectionCallback?.onConnected()
}
override fun onDisconnected() {
connectionCallback?.onDisconnected()
}
override fun onFailed(errorCode: ValueUtil.CxrBluetoothErrorCode?) {
val errorMsg = when (errorCode) {
ValueUtil.CxrBluetoothErrorCode.PARAM_INVALID -> "参数无效"
ValueUtil.CxrBluetoothErrorCode.BLE_CONNECT_FAILED -> "BLE连接失败"
ValueUtil.CxrBluetoothErrorCode.SOCKET_CONNECT_FAILED -> "Socket连接失败"
else -> "未知错误"
}
connectionCallback?.onFailed(errorMsg)
}
})
}
/**
* 建立 SDK 蓝牙连接
*/
private fun connectBluetooth(context: Context, socketUuid: String, macAddress: String) {
cxrApi.connectBluetooth(context, socketUuid, macAddress, object : BluetoothStatusCallback {
override fun onConnected() {
connectionCallback?.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
) {
// 连接信息已在 initBluetooth 阶段获取,此处可忽略
}
})
}
val isConnected: Boolean
get() = cxrApi.isBluetoothConnected
/**
* 断开连接,释放资源
*/
fun disconnect() {
cxrApi.deinitBluetooth()
}
}
这里有个设计细节:initBluetooth 之后不会直接连上,而是在 onConnectionInfo 回调里拿到 UUID 和 MAC 地址,再调用 connectBluetooth 才能建立真正的连接。我理解这跟蓝牙协议的握手机制有关。
查找设备时,可以从已配对的设备里筛选。Rokid 眼镜的设备名一般包含 "Rokid" 或 "Glasses":
private fun findRokidGlasses(adapter: BluetoothAdapter): BluetoothDevice? {
return adapter.bondedDevices.find { device ->
device.name?.contains("Rokid", ignoreCase = true) == true ||
device.name?.contains("Glasses", ignoreCase = true) == true
}
}
步骤数据发送
连接成功后,发送步骤数据到眼镜。先打开提词器场景,再发送文本:
import com.rokid.cxr.callback.SendStatusCallback
/**
* 发送步骤到眼镜端
*/
fun sendStepToGlasses(stepData: StepData, callback: SendCallback?): Boolean {
if (!isConnected) {
callback?.onFailed("眼镜未连接")
return false
}
// 1. 打开提词器场景
val sceneResult = cxrApi.controlScene(
sceneType = ValueUtil.CxrSceneType.WORD_TIPS,
openOrClose = true,
otherParams = null
)
if (sceneResult != ValueUtil.CxrStatus.REQUEST_SUCCEED) {
callback?.onFailed("打开场景失败")
return false
}
// 2. 发送步骤文本
val text = stepData.toDisplayText()
return cxrApi.sendStream(
type = ValueUtil.CxrStreamType.WORD_TIPS,
stream = text.toByteArray(Charsets.UTF_8),
fileName = "step_${stepData.stepNumber}.txt",
cb = object : SendStatusCallback {
override fun onSendSucceed() {
callback?.onSuccess()
}
override fun onSendFailed(errorCode: ValueUtil.CxrSendErrorCode?) {
callback?.onFailed("发送失败: ${errorCode?.name}")
}
}
) == ValueUtil.CxrStatus.REQUEST_SUCCEED
}
这里有个坑:一开始我直接调用 sendStream,眼镜端什么反应都没有。排查了半天才发现,必须先调用 controlScene 打开场景。这个逻辑文档里写了,但不够醒目,容易漏掉。
TTS 语音播报
除了文字显示,SDK 还支持 TTS 语音播报,这在做菜时特别有用——不用一直盯着眼镜看:
/**
* 发送 TTS 语音反馈到眼镜端(由眼镜播放语音)
*/
fun sendTtsFeedback(text: String): Boolean {
if (!isConnected) return false
val result = cxrApi.sendTtsContent(text)
if (result == ValueUtil.CxrStatus.REQUEST_SUCCEED) {
// 通知眼镜 TTS 播放结束(建议调用,确保播放完整)
cxrApi.notifyTtsAudioFinished()
}
return result == ValueUtil.CxrStatus.REQUEST_SUCCEED
}
使用时可以这样:
// 切换步骤时播报
glassesManager.sendTtsFeedback("步骤2,炒糖色")
// 倒计时结束时播报
glassesManager.sendTtsFeedback("时间到了")
注意 notifyTtsAudioFinished() 这个调用,不加的话 TTS 可能播放不完整。
数据模型设计
菜谱结构
我定义了两个核心数据类:
data class Recipe(
val id: Int,
val name: String,
val description: String,
val category: String, // 分类:年夜饭、家常菜等
val totalTime: Int, // 总时长(分钟)
val steps: List<RecipeStep> // 步骤列表
)
data class RecipeStep(
val stepNumber: Int, // 步骤序号
val title: String, // 步骤标题
val content: String, // 步骤详情
val duration: Int, // 这一步的时长(分钟)
val tips: String? = null // 小技巧(可选)
)
发送到眼镜的格式
眼镜端需要格式化的文本,我加了一个转换方法:
data class GlassStepData(
val recipeName: String,
val currentStep: Int,
val totalSteps: Int,
val stepTitle: String,
val stepContent: String,
val duration: Int,
val tips: String?
) {
fun toDisplayText(): String = buildString {
appendLine("【$recipeName】")
appendLine("步骤 $currentStep/$totalSteps:$stepTitle")
appendLine()
appendLine(stepContent)
appendLine()
if (duration > 0) {
appendLine("⏱ 预计时间:${duration}分钟")
}
if (!tips.isNullOrBlank()) {
appendLine("💡 $tips")
}
}
}
菜谱数据来源
一开始我想找个免费的菜谱 API,但调研下来要么收费,要么不稳定。考虑到春节做菜来来回回就那几道,干脆硬编码了 20 道家常菜,包括:
- 年夜饭经典:红烧肉、清蒸鲈鱼、糖醋排骨、四喜丸子、白切鸡
- 家常菜:宫保鸡丁、麻婆豆腐、鱼香肉丝、番茄炒蛋
- 汤品:玉米排骨汤、番茄牛腩汤
- 春节特色:饺子、春卷、汤圆
- 凉菜:拍黄瓜、凉拌木耳
数据放在 RecipeRepository 单例对象里,方便扩展。以后如果要接入网络数据源,改这个类就行。
倒计时功能
做菜最怕忘记时间,尤其是炒糖色这种需要精确控制的步骤。我用 Android 原生的 CountDownTimer 实现:
class StepCountDown(
private val textView: TextView,
private val onFinished: () -> Unit
) : CountDownTimer(durationMillis, 1000) {
override fun onTick(millisUntilFinished: Long) {
val minutes = (millisUntilFinished / 1000 / 60).toInt()
val seconds = ((millisUntilFinished / 1000) % 60).toInt()
textView.text = "%02d:%02d".format(minutes, seconds)
}
override fun onFinish() {
textView.text = "00:00"
onFinished()
}
}
倒计时结束时,除了界面提示,还会通过眼镜 TTS 播报,这样用户不用看设备也能知道:
override fun onFinish() {
textView.text = "00:00"
Toast.makeText(context, "时间到!", Toast.LENGTH_LONG).show()
glassesManager.sendTtsFeedback("${currentStep.title}完成")
}
界面实现
主界面:菜谱列表
主界面用 RecyclerView 展示菜谱卡片,支持分类筛选。卡片上显示菜名、时长、难度等基本信息。
连接眼镜的状态放在顶部,用一个小圆点指示:绿色表示已连接,灰色表示未连接。点击「连接眼镜」按钮会自动从已配对设备中查找 Rokid 眼镜。
详情界面:步骤浏览
详情界面是核心交互页面,包含几个区域:
- 菜谱信息:名称、描述、总时长、难度
- 眼镜状态:连接状态 + 同步按钮
- 步骤内容:当前步骤的标题、详情、小贴士
- 倒计时器:大字体显示 + 控制按钮
- 翻页按钮:上一步 / 下一步
有个交互细节:切换步骤时自动同步到眼镜,不用每次都手动点「同步」。这样操作更流畅。
开发中遇到的问题
记录几个有代表性的问题,供后来者参考:
1. minSdk 版本冲突
CXR-M SDK 要求 minSdk 至少 28,而我的项目一开始设的是 21。编译时报错倒不难排查,麻烦的是一些 API 需要调整。比如 ActivityResultLauncher 在低版本上行为不一致,花了不少时间适配。
2. 蓝牙权限问题
Android 12 对蓝牙权限做了调整,新增了 BLUETOOTH_SCAN 和 BLUETOOTH_CONNECT。最坑的是这些权限必须运行时动态申请,静态声明无效。这个问题在模拟器上不会暴露,真机测试才发现。
3. 中文编码问题
发送中文到眼镜时显示乱码。排查后发现是编码问题,加上 Charsets.UTF_8 就好了:
stream = text.toByteArray(Charsets.UTF_8)
4. TTS 播放中断
偶尔出现 TTS 播放到一半停止的情况。查阅文档后发现需要调用 notifyTtsAudioFinished() 通知 SDK 播放结束。这个细节文档里提得不够明显。
5. 场景未打开导致数据不显示
这是最让人头疼的一个。sendStream 调用成功,但眼镜端没有显示。最后发现是提词器场景没打开——必须先 controlScene(WORD_TIPS, true),再 sendStream。
功能清单与演示
最终实现的功能:
| 功能 | 说明 |
|---|---|
| 菜谱浏览 | 20道春节家常菜,分类展示 |
| 步骤详情 | 分步显示,含时间和小贴士 |
| 眼镜同步 | 实时发送到 Rokid 眼镜 |
| 倒计时 | 每步计时,支持暂停/重置 |
| TTS 播报 | 步骤切换和时间提醒语音提示 |
使用流程:
- 打开 App,连接眼镜(首次需要配对)
- 选择要做的菜
- 步骤自动同步到眼镜,开始烹饪
- 点击「下一步」切换步骤
- 需要计时的步骤启动倒计时
眼镜端显示效果示例:
【红烧肉】
步骤 1/6:准备食材
五花肉500g切块,生姜切片,葱切段,
准备好八角、桂皮、冰糖
⏱ 预计时间:10分钟
💡 选肥瘦相间的五花肉口感最佳
不足与改进方向
这个应用还很不完善,列几个后续可以改进的点:
功能层面:
- 语音控制:说「下一步」自动翻页,彻底解放双手
- 菜谱扩展:支持用户自定义添加,或接入网络数据源
- 多设备支持:家人可以同步查看烹饪进度
技术层面:
- 断线重连:蓝牙连接有时会不稳定
- 离线缓存:无网络时也能使用
- iOS 支持:等 SDK 更新
内容层面:
- 菜谱不够丰富,目前只有 20 道
- 没有视频教程,光看文字有时候不太明白
关于 AR 眼镜应用的一些思考
做完这个项目,我对 AR 眼镜的应用场景有了一些思考。
厨房是一个不错的场景:双手被占用,需要频繁查看信息,环境相对简单。眼镜能很好地解决「手脏不能碰手机」的问题。
但现实是,AR 眼镜的普及还面临不少挑战:
- 价格门槛:一副眼镜两三千,普通家庭不会专门为了做菜买一个
- 续航问题:做一顿饭一两个小时,眼镜不一定撑得住
- 佩戴舒适度:长时间戴着鼻子会累
不过,技术总是在进步的。当年智能手机刚出来时,也有人说太贵、没用。现在呢?人手一部。AR 眼镜也许会走同样的路。
作为开发者,我们能做的就是提前探索,积累经验。等技术成熟时,我们已经准备好了。
结语
这次开发体验还不错。CXR-M SDK 整体比较好用,文档基本够用,就是有些细节不够清晰,需要自己踩坑。
如果你也有 Rokid 眼镜,欢迎试试这个应用。有任何问题或建议,可以在评论区交流。
最后,祝大家春节快乐,做菜顺利!
相关资源:
- Rokid AR 开发者平台 - 官方 SDK 下载和文档
- Rokid 开发者论坛 - 技术交流和问题解答
- CXR-M SDK Maven 仓库 - SDK 依赖