写在前面
周末在家做菜,手机放在旁边看菜谱。切到一半想确认下一步是先放葱还是先放蒜,手上全是油,根本不敢碰手机。用纸巾擦手再操作,等擦完手锅都快糊了。试过把手机架起来语音控制,但油烟一大根本听不清我说啥。
后来想到能不能把菜谱直接显示在眼镜里?做菜时抬头就能看,手再脏也不影响。翻了下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
)
}
问题来了:
- 提词器是纯文本流,没法显示计时器、没法加图标
- 排版完全没法控制,想把当前步骤高亮显示做不到
- 交互很弱,只能上下滚动,想标记"已完成"这种操作没办法
后来看到自定义页面场景,可以用JSON定义布局,支持LinearLayout、RelativeLayout、TextView、ImageView。这就是Android原生那套,自由度高多了。
决定用自定义页面做,虽然代码量会大一些,但能完全控制界面。
整体架构:

手机端负责所有业务逻辑,SDK只是通信桥梁,眼镜端专注渲染。这种分工很清晰。
二、JSON布局的理解和实践
官方文档怎么说
打开Rokid官方文档,找到"自定义页面场景"章节,里面详细介绍了JSON布局的使用方法:

SDK文档说自定义页面用JSON描述界面,支持两种布局:
LinearLayout:线性布局,子元素垂直或水平排列RelativeLayout:相对布局,子元素可以相对定位
支持两种控件:
TextView:显示文字ImageView:显示图片(注意只显示绿色通道)
官方文档中的JSON布局参数表:

从表格可以看到,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这种)
- 显示计时器(倒计时)
- 显示小图标(提示火候、时间等)
第一版界面设计
我画了个草图,想做成这样:

开始写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)
}
}
这样写代码量大了,但是:
- 类型安全:IDE能检查字段类型,不会把"40dp"写成40
- 容易维护:修改布局不用数括号配对
- 可复用:抽成函数后可以组合复杂布局
编译通过后,还需要考虑布局的显示效果。
可能遇到的布局问题
问题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" // 全用字符串,让眼镜端自己解析
这样保险一点。
三、图片资源的处理
图标准备
做菜界面需要几个图标:
- 🔥 火候图标(小火、中火、大火)
- ⏱ 计时器图标
- ✓ 完成图标
- ⚠ 注意图标
官方文档说图片有限制:
- 分辨率不超过128×128px
- 数量最好不超过10张
- 只显示绿色通道
第三点是关键。普通PNG图片有RGBA四个通道,眼镜只用绿色通道。也就是说图片要做成这样:
- R通道:0
- G通道:255(显示的部分)
- B通道:0
- A通道:255(不透明)
图片处理流程图:

这个流程最关键的是灰度值计算,使用标准的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不为空才显示提示文字
五、核心逻辑实现
整体数据流程
先看整体的数据流向,从用户点击"开始做菜"到完成:

这个流程图展示了三个关键路径:
- 初始化路径:从开始到第一个步骤显示
- 计时器更新路径:每秒更新倒计时(高频操作)
- 步骤切换路径:切换到下一个步骤或完成(低频操作)
主控制器
整个应用的核心是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()
}
}
这个类的核心思路:
- 维护当前步骤索引
currentStepIndex - 根据当前步骤生成JSON布局
- 第一次用
openCustomView(),后续用updateCustomView() - 如果步骤有时长,启动
CountDownTimer倒计时 - 每秒更新一次计时器显示
更新机制的优化
最初我每次切换步骤都重新打开界面:
// 错误做法
fun showCurrentStep() {
CxrApi.getInstance().closeCustomView()
Thread.sleep(100) // 等关闭完成
CxrApi.getInstance().openCustomView(layoutJson)
}
这样有问题:
- 界面会闪烁(关闭→打开)
- 要等关闭完成,体验很卡
- 图标要重新上传
改成用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:光线对比度优化
考虑到厨房灯光通常较亮,绿色文字的对比度可能不够。设计时做了以下优化:
// 增大字号,提高可读性
"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()
}
这个优化理论上可以消除切换步骤时的延迟感。
八、代码复盘和总结
需要注意的问题
- JSON格式错误难调试 使用字符串拼接JSON时,格式错误SDK通常不会报错,界面只是显示不出来。建议使用数据类+Gson,至少编译时能发现类型错误。
- 图片通道限制 根据官方文档,图片只显示绿色通道。如果传普通PNG,眼镜上可能显示一片黑。必须按照文档要求转换为绿色通道格式。
- 更新机制差异
updateCustomView()既能传完整JSON也能传部分更新,但格式完全不同。混用会导致更新失败,需要严格区分使用场景。 - 生命周期管理
timer和CustomViewListener需要在Activity销毁时清理,否则会导致内存泄漏。这是Android开发的基本原则。 - 安全区域适配 眼镜屏幕边缘部分不可见,需要根据屏幕规格设置合理的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()
}
总结一下关键点:
- 自定义页面用JSON描述,建议用数据类而不是字符串拼接
- 图片要处理成绿色通道,Base64编码后上传
- 更新机制有完整更新和部分更新,根据频率选择
- 生命周期管理很重要,记得清理资源
- 安全区域要自己试,文档没有明确数值
代码为主,实战为辅。做项目最重要的是解决实际问题,而不是堆砌功能。做菜助手这个场景看似简单,但要做好用,细节很多。希望这篇文章对想用Rokid SDK做自定义界面的开发者有帮助。