【Rokid】用Rokid AR眼镜做菜:解放双手的厨房助手开发实战

简介: 本文介绍如何使用Rokid CXR-M SDK的自定义页面功能,开发一款厨房菜谱助手。通过JSON构建界面布局,结合数据类与Gson序列化,实现步骤显示、计时提醒与火候图标等交互功能,解决做菜时手脏不便操作手机的问题。项目涵盖界面设计、图片处理、语音控制与性能优化,为AR眼镜在生活场景中的应用提供实战参考。(239字)

写在前面

周末在家做菜,手机放在旁边看菜谱。切到一半想确认下一步是先放葱还是先放蒜,手上全是油,根本不敢碰手机。用纸巾擦手再操作,等擦完手锅都快糊了。试过把手机架起来语音控制,但油烟一大根本听不清我说啥。

后来想到能不能把菜谱直接显示在眼镜里?做菜时抬头就能看,手再脏也不影响。翻了下Rokid SDK文档,发现有个"自定义页面场景"功能,可以用JSON自己设计界面布局。理论上可以实现这个需求。

这篇文章记录下怎么用Rokid CXR-M SDK的自定义页面功能做一个厨房助手,重点讲代码实现和开发过程中的思考。

一、为什么选自定义页面而不是其他场景

SDK提供了好几种场景:AI助手、翻译、提词器,还有自定义页面。做菜这个需求用哪个?

刚开始我想用提词器场景,把菜谱当演讲稿推上去。写了几行代码发现不对劲:

// 最初的错误想法 - 用提词器
fun showRecipe(steps: List<String>) {
    val content = steps.joinToString("\n\n")
    CxrApi.getInstance().sendStream(
        ValueUtil.CxrStreamType.WORD_TIPS,
        content.toByteArray(),
        "recipe.txt",
        callback
    )
}

问题来了:

  1. 提词器是纯文本流,没法显示计时器、没法加图标
  2. 排版完全没法控制,想把当前步骤高亮显示做不到
  3. 交互很弱,只能上下滚动,想标记"已完成"这种操作没办法

后来看到自定义页面场景,可以用JSON定义布局,支持LinearLayoutRelativeLayoutTextViewImageView。这就是Android原生那套,自由度高多了。

决定用自定义页面做,虽然代码量会大一些,但能完全控制界面。

整体架构:

img

手机端负责所有业务逻辑,SDK只是通信桥梁,眼镜端专注渲染。这种分工很清晰。

二、JSON布局的理解和实践

官方文档怎么说

打开Rokid官方文档,找到"自定义页面场景"章节,里面详细介绍了JSON布局的使用方法:

img

SDK文档说自定义页面用JSON描述界面,支持两种布局:

  • LinearLayout:线性布局,子元素垂直或水平排列
  • RelativeLayout:相对布局,子元素可以相对定位

支持两种控件:

  • TextView:显示文字
  • ImageView:显示图片(注意只显示绿色通道)

官方文档中的JSON布局参数表:

img

从表格可以看到,LinearLayout和RelativeLayout支持的参数非常丰富,基本和Android原生的一样。这让我很熟悉,不用重新学习。

看了官方示例,是一个标题+图标+文字的简单界面:

{
  "type": "LinearLayout",
  "props": {
    "layout_width": "match_parent",
    "layout_height": "match_parent",
    "orientation": "vertical",
    "gravity": "center_horizontal",
    "paddingTop": "140dp",
    "backgroundColor": "#FF000000"
  },
  "children": [
    {
      "type": "TextView",
      "props": {
        "id": "tv_title",
        "text": "Init Text",
        "textSize": "16sp",
        "textColor": "#FF00FF00"
      }
    }
  ]
}

这个例子挺简单的,但实际做菜场景要复杂得多。我需要:

  • 显示当前步骤(要大,要醒目)
  • 显示步骤编号(1/8这种)
  • 显示计时器(倒计时)
  • 显示小图标(提示火候、时间等)

第一版界面设计

我画了个草图,想做成这样:

img

开始写JSON,第一版写成这样:

fun buildRecipeLayout(stepNum: Int, totalSteps: Int, stepText: String, timeLeft: String): String {
    return """
    {
      "type": "LinearLayout",
      "props": {
        "layout_width": "match_parent",
        "layout_height": "match_parent",
        "orientation": "vertical",
        "gravity": "center_horizontal",
        "paddingTop": "100dp",
        "backgroundColor": "#FF000000"
      },
      "children": [
        {
          "type": "TextView",
          "props": {
            "id": "tv_step_number",
            "layout_width": "wrap_content",
            "layout_height": "wrap_content",
            "text": "步骤 $stepNum/$totalSteps",
            "textSize": "12sp",
            "textColor": "#FF888888",
            "marginBottom": "20dp"
          }
        },
        {
          "type": "LinearLayout",
          "props": {
            "layout_width": "match_parent",
            "layout_height": "wrap_content",
            "orientation": "horizontal",
            "gravity": "center"
          },
          "children": [
            {
              "type": "ImageView",
              "props": {
                "id": "iv_icon",
                "layout_width": "40dp",
                "layout_height": "40dp",
                "name": "fire_icon",
                "marginEnd": "10dp"
              }
            },
            {
              "type": "TextView",
              "props": {
                "id": "tv_step_text",
                "layout_width": "wrap_content",
                "layout_height": "wrap_content",
                "text": "$stepText",
                "textSize": "20sp",
                "textColor": "#FF00FF00",
                "textStyle": "bold"
              }
            }
          ]
        },
        {
          "type": "TextView",
          "props": {
            "id": "tv_timer",
            "layout_width": "wrap_content",
            "layout_height": "wrap_content",
            "text": "⏱ $timeLeft",
            "textSize": "16sp",
            "textColor": "#FF00FF00",
            "marginTop": "30dp"
          }
        }
      ]
    }
    """.trimIndent()
}

写完一看,不对。这是字符串拼接,JSON格式很容易出错。特殊字符没转义、缩进不对、引号配对错误,调试起来要命。

改用数据类构建JSON

决定用Kotlin数据类表示JSON结构,然后用Gson序列化。这样类型安全,不容易出错:

// JSON布局数据结构
sealed class ViewNode {
    abstract val type: String
    abstract val props: Map<String, Any>
}

data class LinearLayoutNode(
    override val type: String = "LinearLayout",
    override val props: Map<String, Any>,
    val children: List<ViewNode> = emptyList()
) : ViewNode()

data class TextViewNode(
    override val type: String = "TextView",
    override val props: Map<String, Any>
) : ViewNode()

data class ImageViewNode(
    override val type: String = "ImageView",
    override val props: Map<String, Any>
) : ViewNode()

// 构建器类
class RecipeLayoutBuilder {

    fun buildStepLayout(
        stepNum: Int,
        totalSteps: Int,
        stepText: String,
        timeLeft: String,
        iconName: String
    ): String {
        val layout = LinearLayoutNode(
            props = mapOf(
                "layout_width" to "match_parent",
                "layout_height" to "match_parent",
                "orientation" to "vertical",
                "gravity" to "center_horizontal",
                "paddingTop" to "100dp",
                "paddingStart" to "40dp",
                "paddingEnd" to "40dp",
                "backgroundColor" to "#FF000000"
            ),
            children = listOf(
                // 步骤编号
                TextViewNode(
                    props = mapOf(
                        "id" to "tv_step_number",
                        "layout_width" to "wrap_content",
                        "layout_height" to "wrap_content",
                        "text" to "步骤 $stepNum/$totalSteps",
                        "textSize" to "12sp",
                        "textColor" to "#FF666666",
                        "marginBottom" to "24dp"
                    )
                ),

                // 步骤内容(图标+文字)
                LinearLayoutNode(
                    props = mapOf(
                        "layout_width" to "match_parent",
                        "layout_height" to "wrap_content",
                        "orientation" to "horizontal",
                        "gravity" to "center",
                        "marginBottom" to "30dp"
                    ),
                    children = listOf(
                        ImageViewNode(
                            props = mapOf(
                                "id" to "iv_step_icon",
                                "layout_width" to "36dp",
                                "layout_height" to "36dp",
                                "name" to iconName,
                                "marginEnd" to "12dp"
                            )
                        ),
                        TextViewNode(
                            props = mapOf(
                                "id" to "tv_step_text",
                                "layout_width" to "0dp",
                                "layout_height" to "wrap_content",
                                "layout_weight" to "1",
                                "text" to stepText,
                                "textSize" to "18sp",
                                "textColor" to "#FF00FF00",
                                "textStyle" to "bold"
                            )
                        )
                    )
                ),

                // 计时器
                TextViewNode(
                    props = mapOf(
                        "id" to "tv_timer",
                        "layout_width" to "wrap_content",
                        "layout_height" to "wrap_content",
                        "text" to "⏱ $timeLeft",
                        "textSize" to "16sp",
                        "textColor" to "#FF00FF00"
                    )
                )
            )
        )

        return Gson().toJson(layout)
    }
}

这样写代码量大了,但是:

  1. 类型安全:IDE能检查字段类型,不会把"40dp"写成40
  2. 容易维护:修改布局不用数括号配对
  3. 可复用:抽成函数后可以组合复杂布局

编译通过后,还需要考虑布局的显示效果。

可能遇到的布局问题

问题1:文字太长换行显示不全

"中火翻炒至肉变色,加入料酒去腥"这种长文本,一行显示不下。虽然TextView应该支持自动换行,但如果宽度设置不当,可能会出现文字被截断的情况。

解决方案是正确设置TextView的宽度:

TextViewNode(
    props = mapOf(
        "id" to "tv_step_text",
        "layout_width" to "match_parent",  // 改成match_parent而不是wrap_content
        "layout_height" to "wrap_content",
        "text" to stepText,
        "textSize" to "18sp",
        "textColor" to "#FF00FF00",
        "gravity" to "start"  // 左对齐,不然居中会很奇怪
    )
)

但这样改了之后,外层的LinearLayout(横向排列图标和文字)就有问题了。图标+文字的总宽度超了。

最后用layout_weight解决:

// 图标固定宽度
ImageViewNode(
    props = mapOf(
        "layout_width" to "36dp",
        "layout_height" to "36dp",
        // ...
    )
),
// 文字占剩余空间
TextViewNode(
    props = mapOf(
        "layout_width" to "0dp",     // 配合weight使用
        "layout_weight" to "1",      // 占满剩余空间
        "layout_height" to "wrap_content",
        // ...
    )
)

这个坑卡了我半小时。Android布局的layout_weight机制,在JSON里用起来和写XML还是有点区别,要自己试。

画个图理解下布局层次:

LinearLayout (vertical, 黑色背景, padding: 100dp/80dp/30dp)
│
├─ TextView (步骤编号 "3/8")
│   └─ 12sp, 灰色, marginBottom: 24dp
│
├─ LinearLayout (horizontal, 居中)
│   ├─ ImageView (火候图标)
│   │   └─ 36dp×36dp, marginEnd: 12dp
│   │
│   └─ TextView (步骤文字)
│       └─ 18sp, 粗体, 绿色, weight: 1
│
└─ TextView (计时器 "⏱ 02:45")
    └─ 16sp, 绿色, marginTop: 30dp

这个层次结构转成JSON后,眼镜端会按照Android布局规则渲染出来。关键是要理解每个控件的尺寸和定位关系。

问题2:眼镜屏幕安全区域

界面布局设计时,需要考虑眼镜屏幕的"安全显示区域"。眼镜屏幕并非整块都能显示内容,边缘部分可能会被遮挡。

根据屏幕规格推测,安全区域的参考值为:

  • 顶部留100dp
  • 底部留80dp
  • 左右各留30dp
LinearLayoutNode(
    props = mapOf(
        // ...
        "paddingTop" to "100dp",    // 顶部安全区
        "paddingBottom" to "80dp",  // 底部安全区
        "paddingStart" to "30dp",   // 左侧安全区
        "paddingEnd" to "30dp"      // 右侧安全区
    )
)

这个数值基于常见AR眼镜的显示规格推导。不同型号可能有差异,实际使用时可能需要微调。

JSON序列化的坑

用Gson序列化时遇到个问题。Kotlin的mapOf()生成的Map,key是String没问题,但value可能是String、Int、Boolean等不同类型。Gson序列化时会自动推断类型,但有时候会出错。

比如这个:

"textSize" to "18sp"  // 字符串
"layout_weight" to 1  // 数字

Gson序列化出来是:

{
  "textSize": "18sp",
  "layout_weight": 1.0   // 变成浮点数了
}

眼镜端解析时layout_weight: 1.0可以用,但看着别扭。更严重的是有时候会把整数序列化成科学计数法(虽然概率很小)。

最后我统一用字符串:

"layout_weight" to "1"  // 全用字符串,让眼镜端自己解析

这样保险一点。

三、图片资源的处理

图标准备

做菜界面需要几个图标:

  • 🔥 火候图标(小火、中火、大火)
  • ⏱ 计时器图标
  • ✓ 完成图标
  • ⚠ 注意图标

官方文档说图片有限制:

  1. 分辨率不超过128×128px
  2. 数量最好不超过10张
  3. 只显示绿色通道

第三点是关键。普通PNG图片有RGBA四个通道,眼镜只用绿色通道。也就是说图片要做成这样:

  • R通道:0
  • G通道:255(显示的部分)
  • B通道:0
  • A通道:255(不透明)

图片处理流程图:

img

这个流程最关键的是灰度值计算,使用标准的RGB到灰度转换公式,保证转换后的图片在眼镜上清晰可见。

处理完的图片肉眼看是绿色的,但在眼镜上显示正常(因为眼镜屏幕本身是绿色的)。

Base64编码

图片要转成Base64发给眼镜。我写了个工具函数:

object IconUtils {

    fun loadIconAsBase64(context: Context, @DrawableRes resId: Int): String {
        val bitmap = BitmapFactory.decodeResource(context.resources, resId)

        // 检查尺寸
        if (bitmap.width > 128 || bitmap.height > 128) {
            Log.w(TAG, "图标尺寸超过128px: ${bitmap.width}x${bitmap.height}")
            // 缩放
            val scaled = Bitmap.createScaledBitmap(bitmap, 128, 128, true)
            return bitmapToBase64(scaled)
        }

        return bitmapToBase64(bitmap)
    }

    private fun bitmapToBase64(bitmap: Bitmap): String {
        val byteArrayOutputStream = ByteArrayOutputStream()
        bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
        val byteArray = byteArrayOutputStream.toByteArray()
        return Base64.encodeToString(byteArray, Base64.NO_WRAP)
    }

    // 批量加载所有图标
    fun loadAllIcons(context: Context): List<IconInfo> {
        return listOf(
            IconInfo("fire_low", loadIconAsBase64(context, R.drawable.icon_fire_low)),
            IconInfo("fire_mid", loadIconAsBase64(context, R.drawable.icon_fire_mid)),
            IconInfo("fire_high", loadIconAsBase64(context, R.drawable.icon_fire_high)),
            IconInfo("timer", loadIconAsBase64(context, R.drawable.icon_timer)),
            IconInfo("check", loadIconAsBase64(context, R.drawable.icon_check)),
            IconInfo("warning", loadIconAsBase64(context, R.drawable.icon_warning))
        )
    }
}

注意Base64.NO_WRAP这个参数,不加的话会自动插入换行符,传给SDK会出错。

上传图标到眼镜

打开自定义页面之前,要先上传图标:

class CookingAssistant(private val context: Context) {

    private var iconsUploaded = false

    fun start() {
        if (!iconsUploaded) {
            uploadIcons()
        }
    }

    private fun uploadIcons() {
        val icons = IconUtils.loadAllIcons(context)

        val status = CxrApi.getInstance().sendCustomViewIcons(icons)

        when (status) {
            ValueUtil.CxrStatus.REQUEST_SUCCEED -> {
                Log.d(TAG, "图标上传成功")
                iconsUploaded = true
                // 上传成功后打开界面
                openRecipeView()
            }
            ValueUtil.CxrStatus.REQUEST_WAITING -> {
                Log.w(TAG, "图标上传中,请勿重复请求")
            }
            ValueUtil.CxrStatus.REQUEST_FAILED -> {
                Log.e(TAG, "图标上传失败")
                showError("图标加载失败,请重试")
            }
            else -> {
                Log.e(TAG, "未知状态: $status")
            }
        }
    }
}

需要注意的是,图标上传是异步的。调用sendCustomViewIcons()后不是立即完成,需要等待上传完成。SDK通过CustomViewListener.onIconsSent()回调通知上传完成。

解决方案是监听这个回调:

private val customViewListener = object : CustomViewListener {
    override fun onIconsSent() {
        Log.d(TAG, "图标上传完成")
        iconsUploaded = true
        // 现在可以打开界面了
        openRecipeView()
    }

    override fun onOpened() {
        Log.d(TAG, "自定义页面已打开")
    }

    override fun onOpenFailed(errorCode: Int) {
        Log.e(TAG, "页面打开失败: $errorCode")
    }

    override fun onUpdated() {
        Log.d(TAG, "页面更新完成")
    }

    override fun onClosed() {
        Log.d(TAG, "页面已关闭")
    }
}

fun init() {
    CxrApi.getInstance().setCustomViewListener(customViewListener)
}

这样就能保证图标上传完才打开界面,不会出现图标显示不出来的问题。

四、菜谱数据模型设计

做菜助手的核心是菜谱数据。我设计了这样的数据结构:

// 单个步骤
data class CookingStep(
    val stepNumber: Int,
    val description: String,
    val duration: Int = 0,        // 秒数,0表示无需计时
    val temperature: Temperature = Temperature.MEDIUM,
    val tips: String = "",
    val ingredients: List<String> = emptyList()
)

enum class Temperature(val iconName: String) {
    LOW("fire_low"),
    MEDIUM("fire_mid"),
    HIGH("fire_high"),
    NONE("")  // 不需要火候提示的步骤(如备菜)
}

// 完整菜谱
data class Recipe(
    val id: String,
    val name: String,
    val cuisine: String,          // 菜系
    val difficulty: Int,          // 难度 1-5
    val prepTime: Int,            // 准备时间(分钟)
    val cookTime: Int,            // 烹饪时间(分钟)
    val servings: Int,            // 份数
    val ingredients: List<Ingredient>,
    val steps: List<CookingStep>
)

data class Ingredient(
    val name: String,
    val amount: String,
    val unit: String,
    val isOptional: Boolean = false
)

然后准备几个示例菜谱。我选了"番茄炒蛋",因为步骤简单,适合测试:

object RecipeData {

    val tomatoScrambledEggs = Recipe(
        id = "recipe_001",
        name = "番茄炒蛋",
        cuisine = "家常菜",
        difficulty = 1,
        prepTime = 5,
        cookTime = 10,
        servings = 2,
        ingredients = listOf(
            Ingredient("鸡蛋", "3", "个"),
            Ingredient("番茄", "2", "个"),
            Ingredient("盐", "1", "小勺"),
            Ingredient("糖", "1", "小勺"),
            Ingredient("葱花", "适量", "", isOptional = true)
        ),
        steps = listOf(
            CookingStep(
                stepNumber = 1,
                description = "番茄洗净切块,鸡蛋打散加少许盐",
                duration = 0,
                temperature = Temperature.NONE
            ),
            CookingStep(
                stepNumber = 2,
                description = "热锅冷油,倒入蛋液",
                duration = 0,
                temperature = Temperature.MEDIUM
            ),
            CookingStep(
                stepNumber = 3,
                description = "中火炒至蛋液凝固,盛出备用",
                duration = 45,
                temperature = Temperature.MEDIUM
            ),
            CookingStep(
                stepNumber = 4,
                description = "锅中留底油,放入番茄块",
                duration = 0,
                temperature = Temperature.MEDIUM
            ),
            CookingStep(
                stepNumber = 5,
                description = "大火翻炒至番茄出汁",
                duration = 120,
                temperature = Temperature.HIGH,
                tips = "番茄要炒出汁才好吃"
            ),
            CookingStep(
                stepNumber = 6,
                description = "加入炒好的鸡蛋,加盐和糖调味",
                duration = 0,
                temperature = Temperature.MEDIUM
            ),
            CookingStep(
                stepNumber = 7,
                description = "翻炒均匀后出锅,撒上葱花",
                duration = 30,
                temperature = Temperature.MEDIUM
            )
        )
    )
}

数据结构设计得比较细,因为后面要根据这些信息动态生成界面。比如:

  • duration > 0才显示计时器
  • temperature != NONE才显示火候图标
  • tips不为空才显示提示文字

五、核心逻辑实现

整体数据流程

先看整体的数据流向,从用户点击"开始做菜"到完成:

img

这个流程图展示了三个关键路径:

  1. 初始化路径:从开始到第一个步骤显示
  2. 计时器更新路径:每秒更新倒计时(高频操作)
  3. 步骤切换路径:切换到下一个步骤或完成(低频操作)

主控制器

整个应用的核心是CookingController,管理菜谱流程:

class CookingController(
    private val context: Context,
    private val recipe: Recipe
) {

    private var currentStepIndex = 0
    private var timer: CountDownTimer? = null
    private val layoutBuilder = RecipeLayoutBuilder()

    // 当前步骤
    private val currentStep: CookingStep
        get() = recipe.steps[currentStepIndex]

    // 是否最后一步
    private val isLastStep: Boolean
        get() = currentStepIndex >= recipe.steps.size - 1

    fun start() {
        showCurrentStep()
    }

    private fun showCurrentStep() {
        val step = currentStep

        // 构建界面JSON
        val layoutJson = layoutBuilder.buildStepLayout(
            stepNum = currentStepIndex + 1,
            totalSteps = recipe.steps.size,
            stepText = step.description,
            timeLeft = formatTime(step.duration),
            iconName = step.temperature.iconName
        )

        // 打开或更新界面
        val status = if (currentStepIndex == 0) {
            CxrApi.getInstance().openCustomView(layoutJson)
        } else {
            CxrApi.getInstance().updateCustomView(layoutJson)
        }

        if (status == ValueUtil.CxrStatus.REQUEST_SUCCEED) {
            // 如果步骤有计时,启动倒计时
            if (step.duration > 0) {
                startTimer(step.duration)
            }
        }
    }

    private fun startTimer(seconds: Int) {
        timer?.cancel()

        timer = object : CountDownTimer(seconds * 1000L, 1000) {
            override fun onTick(millisUntilFinished: Long) {
                val secondsLeft = (millisUntilFinished / 1000).toInt()
                updateTimerDisplay(secondsLeft)
            }

            override fun onFinish() {
                updateTimerDisplay(0)
                // 时间到,震动提醒
                vibrate()
            }
        }.start()
    }

    private fun updateTimerDisplay(secondsLeft: Int) {
        val updateJson = """
        [
          {
            "action": "update",
            "id": "tv_timer",
            "props": {
              "text": "⏱ ${formatTime(secondsLeft)}"
            }
          }
        ]
        """.trimIndent()

        CxrApi.getInstance().updateCustomView(updateJson)
    }

    private fun formatTime(seconds: Int): String {
        val mins = seconds / 60
        val secs = seconds % 60
        return String.format("%02d:%02d", mins, secs)
    }

    fun nextStep() {
        if (isLastStep) {
            finish()
            return
        }

        currentStepIndex++
        timer?.cancel()
        showCurrentStep()
    }

    fun previousStep() {
        if (currentStepIndex == 0) return

        currentStepIndex--
        timer?.cancel()
        showCurrentStep()
    }

    private fun finish() {
        timer?.cancel()

        // 显示完成界面
        val finishJson = layoutBuilder.buildFinishLayout(recipe.name)
        CxrApi.getInstance().updateCustomView(finishJson)

        // 3秒后关闭
        Handler(Looper.getMainLooper()).postDelayed({
            CxrApi.getInstance().closeCustomView()
        }, 3000)
    }

    private fun vibrate() {
        val vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            vibrator.vibrate(
                VibrationEffect.createWaveform(
                    longArrayOf(0, 200, 100, 200),
                    -1
                )
            )
        } else {
            vibrator.vibrate(500)
        }
    }

    fun cleanup() {
        timer?.cancel()
        CxrApi.getInstance().closeCustomView()
    }
}

这个类的核心思路:

  1. 维护当前步骤索引currentStepIndex
  2. 根据当前步骤生成JSON布局
  3. 第一次用openCustomView(),后续用updateCustomView()
  4. 如果步骤有时长,启动CountDownTimer倒计时
  5. 每秒更新一次计时器显示

更新机制的优化

最初我每次切换步骤都重新打开界面:

// 错误做法
fun showCurrentStep() {
    CxrApi.getInstance().closeCustomView()
    Thread.sleep(100)  // 等关闭完成
    CxrApi.getInstance().openCustomView(layoutJson)
}

这样有问题:

  1. 界面会闪烁(关闭→打开)
  2. 要等关闭完成,体验很卡
  3. 图标要重新上传

改成用updateCustomView()平滑更新:

// 正确做法
fun showCurrentStep() {
    val layoutJson = buildLayout(...)

    val status = if (currentStepIndex == 0) {
        // 第一步才打开
        CxrApi.getInstance().openCustomView(layoutJson)
    } else {
        // 后续步骤用update
        CxrApi.getInstance().updateCustomView(layoutJson)
    }
}

updateCustomView()也有坑。

Update的两种方式

SDK文档说更新有两种方式:

方式1:完整JSON 传一个完整的布局JSON,完全替换当前界面。

方式2:部分更新JSON 只更新指定id的控件。格式是:

[
  {
    "action": "update",
    "id": "tv_timer",
    "props": {
      "text": "⏱ 01:30"
    }
  }
]

方式1虽然简单,但可能存在性能问题:每次传完整JSON,眼镜需要重新解析整个布局,可能会出现界面卡顿。

改成方式2,只更新计时器:

private fun updateTimerDisplay(secondsLeft: Int) {
    // 构建部分更新JSON
    val updates = listOf(
        UpdateAction(
            action = "update",
            id = "tv_timer",
            props = mapOf("text" to "⏱ ${formatTime(secondsLeft)}")
        )
    )

    val updateJson = Gson().toJson(updates)
    CxrApi.getInstance().updateCustomView(updateJson)
}

data class UpdateAction(
    val action: String,
    val id: String,
    val props: Map<String, Any>
)

这样每秒更新计时器非常流畅,没有卡顿。

但切换步骤时还是要更新多个控件(步骤编号、步骤文字、图标、计时器),用部分更新就很麻烦,要写一堆update对象。

最后的策略:

  • 计时器更新:用部分更新(高频)
  • 切换步骤:用完整JSON(低频)
private fun showCurrentStep() {
    // 切换步骤用完整JSON
    val fullLayoutJson = layoutBuilder.buildStepLayout(...)
    CxrApi.getInstance().updateCustomView(fullLayoutJson)
}

private fun updateTimerDisplay(secondsLeft: Int) {
    // 计时器用部分更新
    val partialUpdateJson = buildTimerUpdate(secondsLeft)
    CxrApi.getInstance().updateCustomView(partialUpdateJson)
}

这样平衡了性能和代码复杂度。

六、手机端交互控制

做菜时眼镜上只能看,不能操作。要切换步骤得用手机控制。

简单的按钮控制

最简单的方案是手机上放几个按钮:

class CookingActivity : AppCompatActivity() {

    private lateinit var controller: CookingController
    private lateinit var binding: ActivityCookingBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityCookingBinding.inflate(layoutInflater)
        setContentView(binding.root)

        // 初始化控制器
        controller = CookingController(this, RecipeData.tomatoScrambledEggs)
        controller.start()

        // 按钮事件
        binding.btnPrevious.setOnClickListener {
            controller.previousStep()
        }

        binding.btnNext.setOnClickListener {
            controller.nextStep()
        }

        binding.btnPauseTimer.setOnClickListener {
            controller.pauseTimer()
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        controller.cleanup()
    }
}

但做菜时手可能是脏的,频繁碰手机也不方便。

语音控制

理想的方案是语音控制:"下一步"、"上一步"、"暂停"。

可以用Android的SpeechRecognizer实现:

class VoiceCommandListener(
    private val context: Context,
    private val onCommand: (Command) -> Unit
) {

    enum class Command {
        NEXT, PREVIOUS, PAUSE, RESUME, REPEAT
    }

    private val speechRecognizer = SpeechRecognizer.createSpeechRecognizer(context)
    private val recognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
        putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
        putExtra(RecognizerIntent.EXTRA_LANGUAGE, "zh-CN")
        putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
    }

    init {
        speechRecognizer.setRecognitionListener(object : RecognitionListener {
            override fun onResults(results: Bundle?) {
                val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
                matches?.firstOrNull()?.let { text ->
                    parseCommand(text)?.let { command ->
                        onCommand(command)
                    }
                }
            }

            override fun onError(error: Int) {
                Log.e(TAG, "语音识别错误: $error")
                // 错误后重新开始监听
                Handler(Looper.getMainLooper()).postDelayed({
                    startListening()
                }, 1000)
            }

            // 其他回调省略...
        })
    }

    fun startListening() {
        speechRecognizer.startListening(recognizerIntent)
    }

    fun stopListening() {
        speechRecognizer.stopListening()
    }

    private fun parseCommand(text: String): Command? {
        return when {
            text.contains("下一步") || text.contains("下一个") -> Command.NEXT
            text.contains("上一步") || text.contains("上一个") -> Command.PREVIOUS
            text.contains("暂停") -> Command.PAUSE
            text.contains("继续") || text.contains("恢复") -> Command.RESUME
            text.contains("重复") || text.contains("再说一遍") -> Command.REPEAT
            else -> null
        }
    }

    fun release() {
        speechRecognizer.destroy()
    }
}

考虑到厨房环境通常噪音较大(油烟机、炒菜声),语音识别的准确率可能不高。而且Android的SpeechRecognizer每次识别完要重新start,体验可能不够流畅。

最后我还是用按钮控制为主,语音作为辅助。

音量键控制

还有个方案:用音量键控制。手机放在旁边,音量上键"下一步",下键"上一步",不用看屏幕也能操作。

override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
    return when (keyCode) {
        KeyEvent.KEYCODE_VOLUME_UP -> {
            controller.nextStep()
            true  // 消费事件,不调整音量
        }
        KeyEvent.KEYCODE_VOLUME_DOWN -> {
            controller.previousStep()
            true
        }
        else -> super.onKeyDown(keyCode, event)
    }
}

这个方案理论上很实用,手脏的时候可以用胳膊肘按音量键。

七、潜在问题与优化方案

厨房环境适配考虑

在开发过程中,需要考虑厨房环境的特殊性,以下是可能遇到的问题和对应的设计方案:

设计优势:

  1. 解放双手:手脏了也能看步骤,不用擦手碰手机
  2. 计时提醒:自动倒计时,不用盯着锅
  3. 视觉提示:火候图标(小火/中火/大火)一目了然

需要考虑的问题:

问题1:油烟环境适配

厨房环境油烟较大,可能会影响眼镜镜片的清晰度。

这个问题主要依赖硬件层面解决,软件上无法处理。建议使用场景:

  • 备菜阶段(无油烟)
  • 抽油烟机开启的情况下
  • 非爆炒类菜品

问题2:光线对比度优化

考虑到厨房灯光通常较亮,绿色文字的对比度可能不够。设计时做了以下优化:

// 增大字号,提高可读性
"textSize" to "24sp"  // 比默认18sp更大

// 使用浅绿色,在亮光下对比度更好
"textColor" to "#FFAAFFAA"  // 而不是纯绿#FF00FF00

问题3:操作流程优化

考虑到炒菜节奏较快,部分步骤是连续动作(如"倒入蛋液"→"炒至凝固"),手动切换可能来不及。

设计了"自动下一步"功能:计时器结束后自动跳转,减少手动操作:

override fun onFinish() {
    updateTimerDisplay(0)
    vibrate()

    // 计时结束,3秒后自动下一步
    Handler(Looper.getMainLooper()).postDelayed({
        if (!isLastStep) {
            nextStep()
        }
    }, 3000)
}

这个改进后体验好很多,大部分步骤可以自动流转。

性能优化

优化1:减少更新频率

计时器每秒更新一次,但实际上秒数变化对做菜影响不大。改成每5秒更新一次:

timer = object : CountDownTimer(seconds * 1000L, 5000) {  // 改成5秒
    override fun onTick(millisUntilFinished: Long) {
        val secondsLeft = (millisUntilFinished / 1000).toInt()
        updateTimerDisplay(secondsLeft)
    }
    // ...
}

这样减少了80%的更新次数,眼镜端压力小很多。

优化2:JSON缓存方案(已弃用)

理论上可以缓存不变的JSON部分来提升性能:

class RecipeLayoutBuilder {

    private val layoutCache = mutableMapOf<String, String>()

    fun buildStepLayout(...): String {
        val cacheKey = "$stepNum-$stepText-$iconName"

        return layoutCache.getOrPut(cacheKey) {
            // 生成JSON
            val layout = LinearLayoutNode(...)
            Gson().toJson(layout)
        }
    }
}

但实际分析发现,因为每个步骤的文本都不一样,基本没有重复命中的情况。这个优化意义不大,反而增加了代码复杂度,最终放弃。

优化3:预加载下一步

考虑到切换步骤时可能有短暂延迟(JSON序列化需要时间),可以设计预加载机制:当前步骤显示时,后台就把下一步的JSON生成好:

private var nextStepLayoutJson: String? = null

private fun showCurrentStep() {
    val layoutJson = buildCurrentStepLayout()
    CxrApi.getInstance().updateCustomView(layoutJson)

    // 预加载下一步
    preloadNextStep()
}

private fun preloadNextStep() {
    if (isLastStep) return

    GlobalScope.launch(Dispatchers.Default) {
        val nextStep = recipe.steps[currentStepIndex + 1]
        nextStepLayoutJson = layoutBuilder.buildStepLayout(
            stepNum = currentStepIndex + 2,
            totalSteps = recipe.steps.size,
            stepText = nextStep.description,
            timeLeft = formatTime(nextStep.duration),
            iconName = nextStep.temperature.iconName
        )
    }
}

fun nextStep() {
    if (isLastStep) return

    currentStepIndex++

    // 使用预加载的JSON
    val layoutJson = nextStepLayoutJson ?: buildCurrentStepLayout()
    CxrApi.getInstance().updateCustomView(layoutJson)

    preloadNextStep()
}

这个优化理论上可以消除切换步骤时的延迟感。

八、代码复盘和总结

需要注意的问题

  1. JSON格式错误难调试 使用字符串拼接JSON时,格式错误SDK通常不会报错,界面只是显示不出来。建议使用数据类+Gson,至少编译时能发现类型错误。
  2. 图片通道限制 根据官方文档,图片只显示绿色通道。如果传普通PNG,眼镜上可能显示一片黑。必须按照文档要求转换为绿色通道格式。
  3. 更新机制差异 updateCustomView()既能传完整JSON也能传部分更新,但格式完全不同。混用会导致更新失败,需要严格区分使用场景。
  4. 生命周期管理 timerCustomViewListener需要在Activity销毁时清理,否则会导致内存泄漏。这是Android开发的基本原则。
  5. 安全区域适配 眼镜屏幕边缘部分不可见,需要根据屏幕规格设置合理的padding值。不同型号可能有差异。

实用的代码模式

模式1:数据类构建JSON

不要用字符串拼接,用数据类+Gson:

data class LinearLayoutNode(...)
data class TextViewNode(...)

val layout = LinearLayoutNode(props = mapOf(...), children = listOf(...))
val json = Gson().toJson(layout)

模式2:部分更新

高频更新(如计时器)用部分更新:

val updates = listOf(
    UpdateAction("update", "tv_timer", mapOf("text" to "01:30"))
)
Gson().toJson(updates)

模式3:监听器管理

统一管理SDK的监听器,在onDestroy()清理:

override fun onDestroy() {
    super.onDestroy()
    CxrApi.getInstance().setCustomViewListener(null)
    controller.cleanup()
}

总结一下关键点:

  1. 自定义页面用JSON描述,建议用数据类而不是字符串拼接
  2. 图片要处理成绿色通道,Base64编码后上传
  3. 更新机制有完整更新和部分更新,根据频率选择
  4. 生命周期管理很重要,记得清理资源
  5. 安全区域要自己试,文档没有明确数值

代码为主,实战为辅。做项目最重要的是解决实际问题,而不是堆砌功能。做菜助手这个场景看似简单,但要做好用,细节很多。希望这篇文章对想用Rokid SDK做自定义界面的开发者有帮助。

目录
相关文章
|
4天前
|
搜索推荐 编译器 Linux
一个可用于企业开发及通用跨平台的Makefile文件
一款适用于企业级开发的通用跨平台Makefile,支持C/C++混合编译、多目标输出(可执行文件、静态/动态库)、Release/Debug版本管理。配置简洁,仅需修改带`MF_CONFIGURE_`前缀的变量,支持脚本化配置与子Makefile管理,具备完善日志、错误提示和跨平台兼容性,附详细文档与示例,便于学习与集成。
293 116
|
19天前
|
域名解析 人工智能
【实操攻略】手把手教学,免费领取.CN域名
即日起至2025年12月31日,购买万小智AI建站或云·企业官网,每单可免费领1个.CN域名首年!跟我了解领取攻略吧~
|
6天前
|
数据采集 人工智能 自然语言处理
Meta SAM3开源:让图像分割,听懂你的话
Meta发布并开源SAM 3,首个支持文本或视觉提示的统一图像视频分割模型,可精准分割“红色条纹伞”等开放词汇概念,覆盖400万独特概念,性能达人类水平75%–80%,推动视觉分割新突破。
439 43
Meta SAM3开源:让图像分割,听懂你的话
|
13天前
|
安全 Java Android开发
深度解析 Android 崩溃捕获原理及从崩溃到归因的闭环实践
崩溃堆栈全是 a.b.c?Native 错误查不到行号?本文详解 Android 崩溃采集全链路原理,教你如何把“天书”变“说明书”。RUM SDK 已支持一键接入。
678 221
|
1天前
|
Windows
dll错误修复 ,可指定下载dll,regsvr32等
dll错误修复 ,可指定下载dll,regsvr32等
132 95
|
11天前
|
人工智能 移动开发 自然语言处理
2025最新HTML静态网页制作工具推荐:10款免费在线生成器小白也能5分钟上手
晓猛团队精选2025年10款真正免费、无需编程的在线HTML建站工具,涵盖AI生成、拖拽编辑、设计稿转代码等多种类型,均支持浏览器直接使用、快速出图与文件导出,特别适合零基础用户快速搭建个人网站、落地页或企业官网。
1673 158
|
存储 人工智能 监控
从代码生成到自主决策:打造一个Coding驱动的“自我编程”Agent
本文介绍了一种基于LLM的“自我编程”Agent系统,通过代码驱动实现复杂逻辑。该Agent以Python为执行引擎,结合Py4j实现Java与Python交互,支持多工具调用、记忆分层与上下文工程,具备感知、认知、表达、自我评估等能力模块,目标是打造可进化的“1.5线”智能助手。
921 61