适用平台:Rokid AIUI(YodaOS-Sprite)AR 眼镜 阅读对象:入门级开发者 范围说明:只讲"焦点高亮 / 选中态切换"这一条链路,不涉及语音交互和页面跳转
一、首页的焦点坏了
事情是这样的。
我们开发的AIUI-《健康操》应用首页并排摆着两张入口卡片,一张"颈椎训练",一张"视力舒缓"。当前焦点在哪张上,哪张就亮一点,另一张暗下去;触控板上下滑,亮度跟着焦点走。这是最基础的效果,也是用户最直接的感受。
开发过程中测试发现,在PC模拟机和真机上:"按钮焦点亮度不替换。"意思是------不管怎么滑,上面那张永远亮着,下面那张一直暗着。
我第一反应是自己的锅。把页面文件翻出来一看,git diff 是空的,这个文件从仓库建好那天起就没动过。又怕真机跑的是旧代码,把设备上那个 HeathyCare.aix 解包,取出里面的 pages/index/index.ink,跟本地这份比了一遍:字节数一样,内容也一样,11813 字节,分毫不差。
日志也干净。按键事件的日志、焦点切换的日志,一条不少,全在正常打印。
这就有点邪门了------代码没动,日志不报错,偏偏PC模拟机和真机上效果不对。

后来查明白了:问题不在我们写的代码里,在眼镜上那个负责画界面的运行时。它在某个版本升级之后,把一条本来好好的渲染指令给丢了。
二、先搞懂:焦点是怎么"亮"起来的
想查一个问题,得先知道这条链路上有哪几环。焦点切换这件事,说白了就三步:
text
数据变了 → setData 通知一声 → 运行时按样式把画面重画一遍
拆开说:
运行时。 AIUI 里画界面这件事,由 @yodaos-pkg/ink 这个包来做。它把我们写的 .ink 页面(模板、样式、数据)画成屏幕上的画面。要注意的是,它不是我们写的 JS,而是一份提前编译好的引擎------所以它换个版本,行为就可能跟着变。这次的问题就出在这儿。
data / setData。 data 是页面自己的状态表;setData 相当于按一下刷新键,把改过的字段递给运行时,让它重画。有一点要记牢:日志里看到 setData 被调用了,不代表画面一定按你想的那么画。这两者之间,隔着运行时。
样式。 决定每个元素长什么样。改明暗,本质就是改样式。
再往下就是我这次栽跟头的地方------改样式其实有两条完全不同的路,很多人(包括我)平时不太区分:
一条是 class 。元素上写 class="card card-focused",样式表里提前给 .card-focused 写好一套样式;切换焦点,就是给一个元素加上这个类、把另一个元素身上的这个类去掉。这是"加标签、撕标签"。
另一条是 inline style 。直接写 style="opacity: 0.5",相当于在元素上贴张便签写个数;要改,就改便签上的数字。这是"改数值"。
这两条路在引擎内部是分开走的。
我们原来的写法,是把焦点的表达全压在 class 这条路上:.card-focused 一加,边框、标题、图标的透明度就从 0.4 跳到 0.8,亮一倍;一去掉,回到 0.4。切换焦点就是给 A 去类、给 B 加类,亮度自然互换。逻辑上一点毛病没有,问题在于------它依赖的那条路断了。
顺便补一句:那两张图标是纯色的 #40ff5e,明暗完全不看图标内容,全靠这层 CSS 透明度撑着。记住"亮是暗的两倍"这个数就行,后面用像素去量的时候,靠的就是它。
三、动手前,先分清是哪一种"不切换"
"焦点不对"这四个字太笼统了。实际会遇到的情况有好几种,处理方式完全不同,先花一分钟归类,能省掉大把无用功。
一进页面就不对:该亮的不亮,或者亮反了。这基本是自己代码的问题,初始 data 写错了,跟引擎没什么关系。
进来是对的、一切换就出问题:比如两张都变亮、焦点转不过去。这次我们碰到的就是这个。
按了没反应:亮度看着正常,但滑了没动静,或者单击确认没反应。这多半是按键事件根本没进来,跟样式没关系。
PC 模拟机和真机上不对:同一份代码,换个引擎版本现象就变了。这是环境或版本差异。
怎么快速分辨后两种?看画面表现------如果是"该暗的暗不下来""亮加上去了去不掉",那是第二种;再拿同一份代码换个环境试------如果换个引擎版本现象就变,那是第四种。这两类经常凑在一起出现。像我们这次,表面上是第二种(加成功、去失败),根子上是第四种(引擎版本换掉之后才这样)。
四、我实际是怎么查出来的
把我当时的顺序捋一遍,基本就是这么走下来的。
先做的事,是往代码里埋日志。把一次焦点切换拆成几段,每段打一条带统一前缀的日志:初始化时的焦点状态、按下的键、焦点索引的前后值、这次 setData 改了什么。模板我放在附录 B,可以直接抄。
埋好之后点一下 ArrowDown,控制台打出来两行:
text
[健康操-首页] 按键事件: code=ArrowDown
[健康操-首页] 焦点下移到卡片1
这两行说明事件层是通的:onKeyUp 执行了,setData 也调了,焦点索引也更新了。那问题只能出在后半段------运行时把这些变更画到屏幕上的时候。这一步的价值就在这儿,它把"事件层"和"渲染层"一刀切开,方向一下就明朗了。
接下来是自证清白。既然怀疑渲染,那先得排除自己的代码。git diff 是空的,之前已经看过;真机包也解包比过了,逐字节一致。源码这条线,可以放下了。
然后是排除"老版本不支持这个能力"。我去翻了官方的 wxss 文档,opacity 和 rgba() 本来就是支持的一等能力,官方 samples 里就有专门讲 opacity、讲 colors 的例子。所以"透明度是新版本才支持的"这个方向也站不住。
剩下就只剩一条路了:换引擎版本试。这一步靠 aix preview。它妙就妙在用真的 ink 引擎,把 app 直接画到 canvas 上,跟真机是同一条渲染路径。我用 esm.sh 把引擎按版本 pin 住------https://esm.sh/@yodaos-pkg/ink@版本号 这样写------给每个待测版本各生成一份预览页,本地起个静态服务,用 Playwright 打开,点预览面板上的 ArrowDown,再从 canvas 上采像素。
肉眼说"好像都亮"是不算数的,得量出来。用 canvas 的 getImageData 读指定区域,取绿色通道的最大值:卡片亮的时候透明度是 0.8,峰值绿是 204;暗的时候 0.4,峰值 102。另外还有个 153,那是副标题和底部文案的 0.6,别拿它当依据。采样的时候按"卡片带"取峰值,不要按屏幕半区取------半区会被那堆 0.6 的文字污染。
以我们这张 480×352 的画布为例,采样带是这么切的:
| y 区间 | 峰值绿 | 这里有什么 |
|---|---|---|
| 24--96 | 204 | 标题"健康操"和颈卡标题 |
| 96--144 | 153 | 副标题"职场健康伴侣" |
| 140--290 | 204 | 颈卡 |
| 288--312 | 0 | 卡片之间的空隙 |
| 310--460 | 102 | 眼卡 |
| 504--624 | 153 | 底部文案 |
先看初始状态,六个版本全试一遍:0.1.0、0.12.3、0.13.0、0.14.0、0.16.1、0.17.1,初始全是"颈卡 204、眼卡 102"。这说明初始这个亮度差根本不是 bug,它就是焦点高亮本来该有的样子。这一步排掉的是一个更大的猜测------透明度这个功能整体没坏。
再看切换。点一下 ArrowDown 重新量:
| 引擎版本 | 初始(颈/眼) | 点 ArrowDown 后(颈/眼) | 焦点有没有互换 |
|---|---|---|---|
| 0.12.3 | 204 / 102 | 102 / 204 | 互换了,正常 |
| 0.13.0 | 204 / 102 | 204 / 204 | 两张都亮,坏了 |
| 0.14.0 | 204 / 102 | 204 / 204 | 坏了 |
| 0.16.1 | 204 / 102 | 204 / 204 | 坏了 |
| 0.17.1 | 204 / 102 | 204 / 204 | 坏了 |
结论就很清楚了:引擎从 0.13.0 往后,给眼卡"加上" card-focused 是生效的(102 变成 204),但把颈卡上的 card-focused"去掉"不生效(还是 204)。所以两张卡都停在亮态。最后一次正常是 0.12.3,第一次出问题是 0.13.0。
最后再最小验证一下。我写了个极简页面,左边一个元素用 class 切、右边一个元素用 style 数值切,分别试。结果还是那句老话:class 加得上去、去不掉;style 数值替换一切正常。到这儿,根因就钉死了。
五、根因,以及怎么修
根因。 @yodaos-pkg/ink 从 v0.13.0 开始有个渲染回退:用 setData 改 class 字符串的时候,往视图上"加"class 能正常重画,从视图上"移除"class 样式就不再重算。而焦点高亮恰恰靠"一张加、一张去",这个回退就把焦点转移整个废掉了。
引擎是提前编译好的 WASM,源码看不到,没法直接从代码里取证。但"同一份 app、同一套预览、只换引擎版本"就能复现差异,这就足够定性了。
先说一个我试过、但没用的修法。 我当时想,那把 class 字符串换成独立布尔加三元拼接,不就行了?
html
<view class="card card-neck{{neckFocused ? ' card-focused' : ''}}" ...>
试了,还是颈卡 204、眼卡 204。没用。因为不管写成字符串互换还是三元拼接,切的还是 class,走的还是"加/去"这条路。
真正管用的修法,是干脆不走 class 了:改用 inline style 的数字表达明暗。子元素统一固定成亮态(0.8),卡片根节点只留一个 opacity 数字随焦点变------聚焦 1.0,未聚焦 0.5。style 的值是"数值替换",走的是另一条渲染路径,不受 class 删除那个回退影响。
模板:
html
<view class="card card-neck" style="opacity: {{neckFocused ? 1 : 0.5}}" bindtap="goNeckTraining">
...
</view>
<view class="card card-eye" style="opacity: {{eyeFocused ? 1 : 0.5}}" bindtap="goEyeTraining">
...
</view>
JS 里,真正变动的就一句 setData:
javascript
data: { neckFocused: true, eyeFocused: false },
onShow() {
// 每次页面显示都把焦点重置回第一张,免得返回后焦点错乱
this._focusedIndex = 0;
this.setData({ neckFocused: true, eyeFocused: false });
},
onKeyUp(event) {
var code = event.code || '';
switch (code) {
case 'ArrowUp':
if (this._focusedIndex > 0) {
var upIdx = this._focusedIndex - 1;
this._focusedIndex = upIdx;
this.setData({ neckFocused: upIdx === 0, eyeFocused: upIdx === 1 });
}
break;
case 'ArrowDown':
if (this._focusedIndex < CARD_COUNT - 1) {
var downIdx = this._focusedIndex + 1;
this._focusedIndex = downIdx;
this.setData({ neckFocused: downIdx === 0, eyeFocused: downIdx === 1 });
}
break;
case 'Enter':
// 确认键照旧按 _focusedIndex 走
if (this._focusedIndex === 0) { this.goNeckTraining(); }
else { this.goEyeTraining(); }
break;
}
}
CSS 那边,就是把子元素全固定成亮态,把原来所有 .card-focused 的规则删掉:
css
.card { border: 2px solid rgba(64, 255, 94, 0.8); }
.icon-svg { opacity: 0.8; }
.card-title { color: rgba(64, 255, 94, 0.8); }
.card-desc { color: rgba(64, 255, 94, 0.8); }
/* :active 的单击瞬态保留,不要动 */
为什么效果一样?聚焦时是 1.0 × 0.8 = 0.8,未聚焦时是 0.5 × 0.8 = 0.4,原来那个"亮是暗的两倍"一点没变。
改完在 0.17.1 上验了一遍:初始颈卡 204、眼卡 102;点 ArrowDown 变成颈卡 102、眼卡 204;点 ArrowUp 又回到原样;控制台零报错。
如果暂时不想动代码,还有个备选:把引擎版本锁在 0.13.0 以前,比如用 aix runtime select 固定到 0.12.3,也能躲开这个坑。我们最后选了改写法,因为不想被版本绑住。

顺带说两个也常被误当成"焦点问题"的原因。
一个是按键事件写错了地方。AIUI 的按键分 onKeyDown 和 onKeyUp 两个回调,单击确认(Enter)只在 onKeyUp 里触发,这是框架定死的,跟浏览器的习惯不一样。你要是全写在 onKeyDown 里,就会"滑动能用、单击没反应"。另外取按键码要用 event.code,event.key 和 event.keyCode 在 AIUI 里都拿不到值。
另一个是改了没部署。AIUI 是"本地改 → 上传开发者工作室 → 设备拉取",本地改完设备不会自动跟着变,得重新打包上传、设备端再拉一次。好多人改完发现真机没变化,就以为修了个寂寞,其实是旧包还在跑。
六、以后怎么避免再踩
写代码的时候,焦点、选中这类状态,我现在的习惯是优先用 inline style 的数字去表达,别把它全拴在 class 增删这一条路上------多留一条路,真出问题的时候才有退路。另外 data 里只放真正要在模板里绑定的字段,像焦点索引这种纯内部状态,用 this._xxx 私有属性就行,不然运行时会嫌你没绑定、一直报警告。按键统一放 onKeyUp、统一用 event.code,这些都是顺手的事。
测试的时候,凡是动过焦点或者渲染的改动,别只看 PC 预览,一定上真机过一遍:初始对不对、切换能不能互换、返回后焦点有没有错乱。还有个好习惯是把手上那台设备的引擎版本号记下来,升级之前把焦点相关的用例跑一遍------我们这次就是靠"同一份包换引擎版本对比"才揪出来的,平时顺手做一次,成本很低。
排查的时候,日志按"初始化 / 按键 / 状态变更 / 数据下发 / 渲染校验"分段写,前缀统一。详细日志加个开关(平时关着,要用再开),报错的日志别关。一旦看到"数据都下发对了、画面却不动",就马上把注意力从"按键处理"转到"渲染引擎"上,别在事件那层瞎转。
最后,把踩过的坑记下来。我们项目之前吃过一次亏:新人上来没看经验文档就动手改,结果跟之前的修复撞了车,白折腾一轮。文档这东西平时看着没用,真出事的时候能省一整天。
七、最后
这次"灯不灭",回头看其实就是三句老话:把链路拆开一段一段查,别整体瞎猜;让日志和数据说话,别凭感觉;改动以真机为准,PC 预览不算数。
最坑的是这回的情况------代码没动、日志没报错,画面就是不对。根子藏在你平时看不见的运行时里,它不崩、不报错,只是安安静静地画错。这种问题,光盯着自己的代码是想不出来的。
要是你现在正被焦点问题缠着,可以照这个路子来一遍:先归类是哪一种不切换;再翻日志,把事件层和渲染层分开;然后对一下源码和真机包是不是一回事,有条件就换个引擎版本比一比;最后写个最小页面复现、再上真机确认。多数问题其实走不到第五步,前三步就差不多了。
附录 A:排查速查表
| 你看到的 | 先查什么 | 多半是 | 怎么弄 |
|---|---|---|---|
| 一进页面该亮的没亮 / 亮反了 | 初始 data 的值 | 初始态写错了 | 改初始 data |
| 进来正常,一切换两张都亮 / 该暗的暗不下来 | 切换走的是 class 还是 style | 引擎"移除 class"回退 | 改用 inline style 数值 |
| 滑了没反应 / 单击确认没反应 | 日志里 [焦点-2.按键] 有没有打印 |
按键事件没进来 | 处理挪到 onKeyUp,用 event.code |
| PC 正常、真机不对 | 本地包和真机包是不是一回事 | 版本差异 / 没部署 | 逐字节比包、重新部署、换版本对比 |
| 换个引擎版本现象就变 | 先把版本号记下来 | 引擎版本回退 | 改写法绕开,或锁到 0.13.0 以前 |
| 改用 class 三元绑定还是不行 | 切换是不是还在切 class | 修错了方向(伪修复) | 改走 style 数值这条路 |
附录 B:焦点链路日志模板
javascript
// 1) 初始化:页面打开/显示时,先把焦点状态记下来
console.log(LOG_TAG + ' [焦点-1.初始化] 焦点=卡片' + this._focusedIndex
+ ' 颈亮=' + this.data.neckFocused + ' 眼亮=' + this.data.eyeFocused);
// 2) 按键:每次按键先记 event.code ------ 这条是"事件层"的哨兵
var code = event.code || '';
console.log(LOG_TAG + ' [焦点-2.按键] code=' + code);
// 3) 状态变更:以 ArrowDown 为例,记下切换前后的索引
var newIdx = this._focusedIndex + 1;
console.log(LOG_TAG + ' [焦点-3.状态变更] 焦点 ' + this._focusedIndex + ' -> ' + newIdx);
this._focusedIndex = newIdx;
// 4) 数据下发:记下这次到底 setData 了什么
this.setData({ neckFocused: newIdx === 0, eyeFocused: newIdx === 1 });
console.log(LOG_TAG + ' [焦点-4.数据下发] neckFocused=' + (newIdx === 0)
+ ' eyeFocused=' + (newIdx === 1));
// 5) 渲染校验:运行时不提供"样式有没有重画"的日志,
// 只能靠肉眼或画布采样确认:是不是一明一暗、并且随焦点互换
// 采样判据:绿通道峰值 204=亮(0.8),102=暗(0.4),153=干扰带(0.6)
怎么读这些日志,三句话:
[焦点-2]一条都没有,事件层没通,去看按键处理。[焦点-2]有、[焦点-3]/[焦点-4]也有,画面却不动,渲染层有问题,重点怀疑 class 增删。- 三段都有、画面也变了,那就是好了。
附录 C:像素采样判别法
用真 ink 引擎在浏览器里渲染,再从画布上采像素,就能不靠肉眼判断焦点有没有正确互换:
text
1. 给每个待测版本生成一份预览 HTML,inkSdkUrl 指向 esm.sh 对应版本
https://esm.sh/@yodaos-pkg/ink@<版本号>
2. 本地起个静态服务,用 Playwright 打开
3. 点预览面板上的 ArrowDown
4. 用 canvas 的 getImageData 采样:
颈卡带 x=中线 y∈[140,290]
眼卡带 x=中线 y∈[310,460]
5. 取每条带的"峰值绿":
正确互换 → 颈 102、眼 204
异常 → 颈 204、眼 204
两个要点,别踩:
- 按"卡片带"取峰值,别按屏幕半区取。半区会被副标题、底部文案那堆
rgba(,0.6)(绿 153)污染,量出来不准。 - 这几个数记熟:204 是亮(0.8),102 是暗(0.4),153 是干扰带(0.6)。