AIUI入门使用流程(二)
本文深入讲解 AIUI 应用开发中四个最核心也最容易被忽视的主题: ① 焦点导航模式(Focus Navigation)、② 默认行为与拦截(Default Behavior & Interception)、③ 可穿戴设备设计规范、④ IMU 传感器应用。
全部内容以本机 PomodoroTimer 番茄钟工程的真实代码与 aiui-dev skill 文档为依托,可作为直接落地参考。
第一部分 焦点导航模式
1.1 为什么需要「焦点导航」
智能眼镜/可穿戴设备没有鼠标、没有触摸屏,唯一的输入是:
-
硬件按键(方向键、确认键、返回键)
-
语音
-
头部动作(IMU 手势)
因此 UI 必须围绕「当前焦点(Focus)在哪个元素上」来设计:一次只有一个元素处于激活状态,按键/转头负责在元素之间移动焦点,确认键/点头负责「进入或触发」焦点元素。这就是焦点导航。
1.2 核心模型:一维焦点索引 + 状态驱动 UI
焦点导航的实现套路非常统一,番茄钟设置页是教科书式范本。核心是:
-
用一个整数索引
focus表示当前焦点位置; -
焦点变化时通过
setData一次性更新所有与焦点相关的 UI 状态(类名、字号、位移、提示文案); -
输入(按键或 IMU)只负责「把 focus +1 / -1 / 触发」,不直接改 UI。
以设置页 10 张卡片为例,data 里维护焦点及其派生状态:
data: {
settingFocus: 2, // ← 焦点索引(唯一事实来源)trackOffset: '-40px', // 卡片轨道位移(让焦点卡片居中)cardCls0: 'card', // 每张卡片的 class('card active' 表示选中)cardCls1: 'card',
cardLabelSz0: '13px', // 每张卡片标签字号(选中变大)cardSub0: '25分', // 每张卡片的副标题(当前值)// ... cardCls2..9, cardLabelSz1..9 ...
}
1.3 焦点切换函数(唯一的写入口)
所有焦点移动最终都汇入同一个函数 setSettingFocus(i),它负责完整地、原子地刷新 UI:
setSettingFocus(i) {
var hints = [
'单击选择专注时长', '单击选择休息时长', '单击确认重新开始计时',
'单击切换语音提醒开关', '单击切换专注防分心模式',
'单击进入灵敏度与显示效果设置', '单击切换头部手势控制开关',
'单击进入颈椎保护与久坐提醒设置', '单击切换白噪音开关',
'单击切换边角小倒计时开关'
];
var offset = -i * 80 + 120; // 卡片轨道位移:让选中卡片居中
this.setData({
settingFocus: i,
trackOffset: offset + 'px',
hintText: hints[i], // 底部提示文案随焦点变化
cardCls0: i === 0 ? 'card active' : 'card', // 选中卡片高亮
// ... cardCls1..9 同理 ...
cardLabelSz0: i === 0 ? '16px' : '13px', // 选中卡片字号放大
cardSubSz0: i === 0 ? '14px' : '11px',
// ... 其余卡片同理 ...
});
}
关键设计:
cardCls*/cardLabelSz*这类每项一个字段的做法,是因为 AIUI 不支持嵌套ink:for。若用ink:for渲染数组,无法在每一项上动态绑 class,所以项目用「10 个显式字段 + 10 个显式ink:if」硬编码。这是平台限制下的务实折中。
1.4 输入归一化:detectAction()
不同设备的按键编码可能不同,先把 event.code 归一化为语义动作,后续逻辑只认动作不认具体键:
function detectAction(arg) {
if (!arg || typeof arg !== 'object') return 'unknown';
var code = String(arg.code || '').toLowerCase();
if (code === 'up' || code === 'arrowup') return 'prev'; // 左/上 → 上一个if (code === 'down' || code === 'arrowdown') return 'next'; // 右/下 → 下一个if (code === 'enter' || code === 'center' || code === 'ok') return 'confirm'; // 确认if (code === 'backspace' || code === 'escape' || code === 'back') return 'back'; // 返回return 'other';
}
语义动作只有四个:prev(上一个)、next(下一个)、confirm(确认)、back(返回)。
1.5 按键驱动:onKeyUp 统一分发
按键处理的样板是:弹窗状态优先于主菜单,逐个判断当前可见的「层级」,再分发动作:
onKeyUp(event) {
var action = detectAction(event);
// ===== 第 1 层:确认弹窗 =====if (this.data.confirmModalVisible) {
if (action === 'back') { event.preventDefault(); return this.closeConfirmModal(); }
if (action === 'prev' || action === 'next') {
event.preventDefault();
this.setConfirmModalFocus(this.data.confirmModalFocus === 0 ? 1 : 0); // 两个按钮互切
return;
}
if (action === 'confirm') {
event.preventDefault();
return this.data.confirmModalFocus === 0 ? this.closeConfirmModal() : this.confirmEnableFocus();
}
return; // 其他动作被弹窗吞掉,不落到下层
}
// ===== 第 2 层:时间选择弹窗 =====if (this.data.timePickerVisible) {
if (action === 'back') { event.preventDefault(); return this.closeTimePicker(); }
if (action === 'confirm'){ event.preventDefault(); return this.confirmTimePicker(); }
if (action === 'prev') { event.preventDefault(); this.setTimePickerFocus((this.data.timePickerFocus + this.data.timePickerCount - 1) % this.data.timePickerCount); return; }
if (action === 'next') { event.preventDefault(); this.setTimePickerFocus((this.data.timePickerFocus + 1) % this.data.timePickerCount); return; }
return;
}
// ===== 第 3 层:专注设置弹窗 =====if (this.data.modalVisible) {
if (action === 'back') { event.preventDefault(); return this.closeModal(); }
if (action === 'confirm'){ event.preventDefault(); return this.stepSettingModal(); }
if (action === 'prev') { event.preventDefault(); this.setModalFocus((this.data.modalFocus + MODAL_COUNT - 1) % MODAL_COUNT); return; }
if (action === 'next') { event.preventDefault(); this.setModalFocus((this.data.modalFocus + 1) % MODAL_COUNT); return; }
return;
}
// ===== 第 4 层:颈椎保护弹窗 =====if (this.data.neckModalVisible) { /* 与上面同构 */ return; }
// ===== 最底层:主菜单 =====if (action === 'back') { return; } // 不拦截,交给宿主返回上一页if (action === 'confirm') { event.preventDefault(); this.stepSetting(); return; }
if (action === 'prev') {
event.preventDefault();
this.setSettingFocus(this.data.settingFocus > 0 ? this.data.settingFocus - 1 : CARD_COUNT - 1);
return;
}
if (action === 'next') {
event.preventDefault();
this.setSettingFocus(this.data.settingFocus < CARD_COUNT - 1 ? this.data.settingFocus + 1 : 0);
return;
}
}
要点提炼:
-
弹窗优先:一旦有弹窗打开,按键全部被弹窗层消费(
return吞掉),不会穿透到下层菜单。 -
焦点边界回绕:
prev到 0 时回到末尾,next到末尾时回到 0(% CARD_COUNT取模)。 -
confirm 触发「进入」:调用
stepSetting()根据当前焦点进入不同子界面/执行动作。 -
back 不拦截:在主菜单层,返回键直接交还宿主(保持「返回上一页」的直觉)。
1.6 stepSetting() —— 焦点到动作的分发
确认键按下后,根据焦点索引分发到不同行为:开关切换、打开子弹窗、跳转等。
stepSetting() {
var f = this.data.settingFocus;
if (f === 0) { this.openTimePicker('work'); } // 打开时间选择else if (f === 1) { this.openTimePicker('rest'); }
else if (f === 2) { /* 重新计时:写标记 + 返回首页 */
this.doSave();
var sets = loadSettings(); sets._resetTimer = true; saveSettings(sets);
goBack();
}
else if (f === 3) { /* 语音提醒开关 */
var v3 = !this.data.voiceOn;
this.setData({ voiceOn: v3, cardSub3: v3 ? '开' : '关' });
this.quickSave();
}
else if (f === 4) { /* 专注模式:当前关→弹确认框,当前开→直接关 */
if (!this.data.focusOn) { this.setData({ confirmModalVisible: true, confirmModalFocus: 0 }); }
else { this.setData({ focusOn: false, cardSub4: '关' }); this.quickSave(); }
}
else if (f === 5) { this.setData({ modalVisible: true, modalFocus: 0, modalCls0: 'modal-card active', modalCls1: 'modal-card', modalTrackOffset: '0px' }); }
else if (f === 6) { /* 头部手势开关 */ }
else if (f === 7) { /* 颈椎保护弹窗 */ }
else if (f === 8) { /* 白噪音开关 */ }
else if (f === 9) { /* 边角倒计时开关 */ }
}
1.7 焦点导航与 IMU 手势复用同一套函数
这是番茄钟最重要的架构决策:硬件按键和头部手势共享同一组焦点函数。转头调用 setSettingFocus(),点头调用 stepSetting() —— 与按键逻辑完全一致(详见第四部分 4.5 节)。
// 转头 → 等效于按 'prev'/'next'if (shakeYaw > YAW_STEP) {
shakeYaw = 0; YAW_COOLDOWN = 30;
self.setSettingFocus((self.data.settingFocus + 1) % CARD_COUNT); // ← 同一个函数
}
// 点头 → 等效于按 'confirm'if (shakePitch > NOD_THRESHOLD) {
shakePitch = 0; NOD_COOLDOWN = 10;
self.stepSetting(); // ← 同一个函数
}
好处:一次实现,两种输入方式天然一致;新增第三种输入(如语音)也只需把指令映射到同一组函数。
1.8 焦点状态的 UI 呈现
模板中用 {{ }} 绑定动态 class 和样式,选中卡片放大、加边框、居中显示:
<view class="{{ cardCls0 }}"><text class="card-label" style="font-size: {{ cardLabelSz0 }};">{{ cardLabel0 }}</text><text class="card-sub" style="font-size: {{ cardSubSz0 }};">{{ cardSub0 }}</text></view><!-- ... 卡片 1 ~ 9 同理 ... -->
.card { display: flex; flex-direction: column; align-items: center; justify-content: center;
width: 70px; height: 60px; border: 2px solid rgba(64,255,94,0.6);
border-radius: 10px; flex-shrink: 0; }
.card.active { width: 100px; height: 90px; border-color: #40FF5E; border-width: 3px; } /* 选中放大+高亮 */.card-label { color: rgba(64,255,94,0.6); font-weight: bold; }
.card.active .card-label { color: #40FF5E; }
顶部还有一列「圆点指示器」,用条件表达式给当前焦点打上 .active:
<view class="indicator"><text class="dot{{ settingFocus === 0 ? ' active' : '' }}">●</text><text class="dot{{ settingFocus === 1 ? ' active' : '' }}">●</text><!-- ... --></view>
1.9 焦点导航清单(自检表)
-
[ ] 有且仅有一个
focus索引字段作为唯一事实来源 -
[ ] 所有焦点移动收敛到一个
setXxxFocus(i)函数,原子更新全部 UI 状态 -
[ ] 输入(按键/IMU/语音)只改 focus,不直接改 UI
-
[ ] 弹窗打开时按键被弹窗层消费,不穿透
-
[ ] 焦点在边界处回绕(取模)
-
[ ]
back在顶层菜单不拦截;在弹窗内拦截并关闭弹窗 -
[ ] 每个焦点位置对应一条 hint 提示文案(可穿戴屏幕小,引导很重要)
第二部分 默认行为与拦截
2.1 事件模型回顾
AIUI 页面有三类页面级事件(定义在导出的页面对象上,不是 WXML 属性):
|
回调 |
说明 |
|---|---|
|
|
硬件键按下(适合即时反馈) |
|
|
硬件键释放(唯一有效的拦截点) |
|
|
语音唤醒, |
2.2 什么是「默认行为」
某些事件不只是通知——页面回调跑完后,宿主仍会执行内置动作,除非页面显式拦截:
|
按键 |
宿主默认行为 |
|---|---|
|
|
返回上一页 / 退出应用 |
|
|
滚动根容器 |
|
|
激活当前聚焦目标 / 进入导航模式 |
|
|
设备专属的镜腿触控/快捷键行为 |
2.3 拦截的正确姿势
关键规则:默认行为绑定在按键释放阶段,因此 event.preventDefault() 只有放在 onKeyUp 里才有效;在 onKeyDown 里调用不会阻止默认行为。
export default {
data: { dialogVisible: true, status: 'idle' },
// 按下:做即时反馈(改状态),但别指望能拦截onKeyDown(event) {
if (event.code === 'Enter') {
this.setData({ status: 'enter pressed' });
}
},
// 释放:这里才是拦截点onKeyUp(event) {
if (event.code === 'Backspace' && this.data.dialogVisible) {
event.preventDefault(); // ← 关键:拦截默认返回
this.setData({
dialogVisible: false,
status: 'dialog closed instead of navigating back'
});
}
}
};
2.4 何时该拦截 / 何时不该拦截
应该拦截(页面提供了替代行为):
-
页面自己管理返回栈 / 弹窗关闭(如设置页里按 Backspace 先关弹窗而不是直接返回)
-
用硬件键做自定义焦点移动或快捷键
-
需要在验证/确认完成前阻止导航
不应该拦截(会造成「卡死感」):
-
拦截了默认行为却不更新 UI、不执行替代动作 → 页面显得无响应
-
在
onKeyDown里试图拦截默认行为 → 无效 -
顶层菜单的
back→ 保持「返回上一页」直觉(番茄钟设置页明确注释:// 不拦截,让主机默认返回行为导航到 index)
2.5 番茄钟中的拦截实例
① 主页面(index) —— 用 Backspace 主动退出应用前先展示「告别屏」:
onKeyDown(arg) {
var action = detectAction(arg);
// prev/next 在页面间导航if (action === 'prev') { clearHide(); wx.navigateTo({ url: '/pages/stats/stats' }); return; }
if (action === 'next') { clearHide(); wx.navigateTo({ url: '/pages/settings/settings' }); return; }
// ...
}
onKeyUp(arg) {
var action = detectAction(arg);
if (action === 'back') {
event.preventDefault(); // 拦截默认退出
this.showByeScreen(); // 展示告别屏 + 3 秒后真正退出
return;
}
}
② 设置页(settings) —— 弹窗优先拦截,主菜单放行:
onKeyUp(event) {
var action = detectAction(event);
if (this.data.confirmModalVisible) {
if (action === 'back') { event.preventDefault(); return this.closeConfirmModal(); }
// ...
return; // 吞掉,不穿透到主菜单
}
// ...if (action === 'back') { return; } // 主菜单:不拦截,交给宿主
}
2.6 语音唤醒事件
onVoiceWakeup(event) {
if (event.keyword === 'leqi') {
this.setData({ status: 'voice wakeup received' });
}
}
注意:某些宿主对语音唤醒也可能有默认行为,是否支持拦截取决于宿主实现。
2.7 拦截规则速记
|
场景 |
做法 |
|---|---|
|
要在按键释放后做自定义动作 |
在 |
|
要阻止宿主默认动作 |
在 |
|
只做即时反馈 |
用 |
|
拦截了但没替代行为 |
❌ 禁止(会造成无响应) |
|
顶层菜单的返回 |
通常放行 |
第三部分 可穿戴设备设计规范
3.1 画布与布局尺寸
|
项 |
值 |
说明 |
|---|---|---|
|
宽度 |
严格 480px |
应用宽度固定,不可变更 |
|
高度 |
120–380px 推荐 |
避免过高的页面导致频繁滚动 |
|
布局风格 |
卡片式(Card Style) |
空间环境中边界清晰、视觉聚焦 |
|
背景色 |
黑色 |
默认背景 |
|
边框 |
2px |
卡片与关键交互元素的默认边框宽 |
|
圆角 |
12px |
卡片、按钮、图片的推荐圆角 |
番茄钟全程遵守:设置页卡片 border: 2px solid rgba(64,255,94,0.6) + border-radius: 10px;弹窗 border: 2px solid #40FF5E + border-radius: 12px。
3.2 颜色与绿色主题
主品牌色 #40FF5E(绿),按透明度分三档:
|
档位 |
值 |
用途 |
|---|---|---|
|
100% |
|
主元素、激活态、主文本 |
|
60% |
|
次级元素、hover、次文本 |
|
40% |
|
背景高亮、禁用态、边框弱化 |
番茄钟的用法示例:
-
主文本:
.time { color: #40FF5E; } -
次级文本:
.stats-text { color: rgba(64,255,94,0.5); } -
弱化提示:
.hint { color: rgba(64,255,94,0.4); } -
选中卡片边框:
border-color: #40FF5E; border-width: 3px;
3.3 优先使用主题 Token
设计规范围绕内置语义化 Token 展开,优先 var(--token-name) 而非硬编码颜色,便于主题统一与后续换肤。常用 Token:
|
类别 |
Token |
|---|---|
|
核心色 |
|
|
背景/面板 |
|
|
文本 |
|
|
边框 |
|
|
圆角 |
|
|
间距 |
|
|
组件专属 |
|
番茄钟工程作为早期实现直接硬编码了
#40FF5E系列;新代码应优先使用 Token,仅在需要精确控制透明度时用var(--color-primary-60)这类变体。
3.4 字体
-
常用:系统字体 + 回退链
font-family: Arial, sans-serif;。 -
特殊字体:
app.json的fonts声明打包字体,family 名在 WXSS 与 Canvasctx.font中通用;缺失时回退系统字体。 -
不要假定所有设备自带同一套系统字体,最终要在真机验证。
3.5 禁止事项(Prohibitions)
-
❌ 默认禁用 emoji。UI 文案、标签、状态文本、装饰内容都不得默认使用 emoji,除非产品明确要求。
-
番茄钟的例外:装饰性番茄图标 🍅 / 咖啡 ☕ 属于产品视觉需求(功能语义),且设置页卡片副标题的 🕙 ⚙ 用于功能图标。判断标准:是「信息」还是「装饰/图标语义」。
-
-
❌ 禁止大面积纯色色块。可穿戴屏幕视觉压迫感强;背景保持克制,颜色只用于强调、文本与交互元素。
3.6 设计自检表
-
[ ] 宽度严格 480px,高度 120–380px
-
[ ] 每个页面采用卡片式布局,黑底
-
[ ] 关键元素 2px 边框 + 8–12px 圆角
-
[ ] 使用主题 Token(优先于硬编码)
-
[ ] 主色
#40FF5E,次级用 60%/40% 透明度档 -
[ ] 无大面积纯色块
-
[ ] 文案无默认 emoji(除产品明确要求)
第四部分 IMU 传感器应用
4.1 可用传感器 API
AIUI 提供 Generic Sensor 风格的传感器(全局构造,注册在 globalThis/window):
|
传感器 |
构造 |
主要读数 |
用途 |
|---|---|---|---|
|
|
|
|
检测倾斜、姿态初判 |
|
|
|
|
头部转动积分(核心) |
|
|
|
|
朝向辅助 |
|
|
|
|
绝对姿态四元数 |
统一用法模板:
var sensor = new Gyroscope({ frequency: 10 }); // frequency 是尽力而为的采样率提示
sensor.addEventListener('reading', function () {
var x = sensor.x, y = sensor.y, z = sensor.z; // 从实例上读最新读数// ... 处理 ...
});
sensor.addEventListener('error', function (e) {
console.warn('sensor error', e); // e.error / e.message
});
sensor.start(); // 开始采样// sensor.stop() // 停止(保留最后一次读数)
行为要点:
-
初始
activated === false、hasReading === false,各读数为null。 -
首个成功读数后
activated/hasReading变true。 -
stop()后activated回到false,但保留最后一次读数。 -
timestamp为读数时间戳(毫秒),是计算采样间隔dt的依据。
更多推荐


所有评论(0)