AR 眼镜进厨房:手油腻也能看菜谱的春节小应用

0 阅读8分钟

AR 眼镜进厨房:手油腻也能看菜谱的春节小应用

春节期间做年夜饭,最头疼的就是一边炒菜一边看手机上的菜谱——手上沾满油渍,手机屏幕也弄得油腻腻的。这个问题让我想到:能不能把菜谱做到 AR 眼镜上?

恰好看到 Rokid 开发者社区的征文活动,于是花了一周时间,基于 CXR-M SDK 开发了一款「菜谱步骤助手」。这篇文章记录了开发过程中的技术选型、架构设计和踩坑经验。

技术选型:为什么是 CXR-M SDK?

Rokid SDK 体系

Rokid 为开发者提供了几套 SDK,针对不同的开发场景:

SDK目标平台适用场景
CXR-M SDKAndroid/iOS 手机端手机控制眼镜,数据协同
CXR-S SDKYodaOS-Sprite 眼镜端眼镜端独立应用开发
AR Studio SDKUnity/UE3D 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" />

几点说明:

  1. 定位权限:虽然我们不用定位功能,但 Android 系统规定蓝牙扫描必须申请定位权限。neverForLocation 标志可以告诉系统这不是用于定位追踪。
  1. 动态权限申请: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. 菜谱信息:名称、描述、总时长、难度
  1. 眼镜状态:连接状态 + 同步按钮
  1. 步骤内容:当前步骤的标题、详情、小贴士
  1. 倒计时器:大字体显示 + 控制按钮
  1. 翻页按钮:上一步 / 下一步

有个交互细节:切换步骤时自动同步到眼镜,不用每次都手动点「同步」。这样操作更流畅。

开发中遇到的问题

记录几个有代表性的问题,供后来者参考:

1. minSdk 版本冲突

CXR-M SDK 要求 minSdk 至少 28,而我的项目一开始设的是 21。编译时报错倒不难排查,麻烦的是一些 API 需要调整。比如 ActivityResultLauncher 在低版本上行为不一致,花了不少时间适配。

2. 蓝牙权限问题

Android 12 对蓝牙权限做了调整,新增了 BLUETOOTH_SCANBLUETOOTH_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 播报步骤切换和时间提醒语音提示

使用流程:

  1. 打开 App,连接眼镜(首次需要配对)
  1. 选择要做的菜
  1. 步骤自动同步到眼镜,开始烹饪
  1. 点击「下一步」切换步骤
  1. 需要计时的步骤启动倒计时

眼镜端显示效果示例:

【红烧肉】
步骤 1/6:准备食材

五花肉500g切块,生姜切片,葱切段,
准备好八角、桂皮、冰糖

⏱ 预计时间:10分钟

💡 选肥瘦相间的五花肉口感最佳

不足与改进方向

这个应用还很不完善,列几个后续可以改进的点:

功能层面

  • 语音控制:说「下一步」自动翻页,彻底解放双手
  • 菜谱扩展:支持用户自定义添加,或接入网络数据源
  • 多设备支持:家人可以同步查看烹饪进度

技术层面

  • 断线重连:蓝牙连接有时会不稳定
  • 离线缓存:无网络时也能使用
  • iOS 支持:等 SDK 更新

内容层面

  • 菜谱不够丰富,目前只有 20 道
  • 没有视频教程,光看文字有时候不太明白

关于 AR 眼镜应用的一些思考

做完这个项目,我对 AR 眼镜的应用场景有了一些思考。

厨房是一个不错的场景:双手被占用,需要频繁查看信息,环境相对简单。眼镜能很好地解决「手脏不能碰手机」的问题。

但现实是,AR 眼镜的普及还面临不少挑战:

  1. 价格门槛:一副眼镜两三千,普通家庭不会专门为了做菜买一个
  1. 续航问题:做一顿饭一两个小时,眼镜不一定撑得住
  1. 佩戴舒适度:长时间戴着鼻子会累

不过,技术总是在进步的。当年智能手机刚出来时,也有人说太贵、没用。现在呢?人手一部。AR 眼镜也许会走同样的路。

作为开发者,我们能做的就是提前探索,积累经验。等技术成熟时,我们已经准备好了。

结语

这次开发体验还不错。CXR-M SDK 整体比较好用,文档基本够用,就是有些细节不够清晰,需要自己踩坑。

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

最后,祝大家春节快乐,做菜顺利!

相关资源