本文深入讲解 AIUI 应用开发中四个最核心也最容易被忽视的主题: ① 焦点导航模式(Focus Navigation)② 默认行为与拦截(Default Behavior & Interception)③ 可穿戴设备设计规范④ IMU 传感器应用

全部内容以本机 PomodoroTimer 番茄钟工程的真实代码与 aiui-dev skill 文档为依托,可作为直接落地参考。

第一部分 焦点导航模式

1.1 为什么需要「焦点导航」

智能眼镜/可穿戴设备没有鼠标、没有触摸屏,唯一的输入是:

  • 硬件按键(方向键、确认键、返回键)

  • 语音

  • 头部动作(IMU 手势)

因此 UI 必须围绕「当前焦点(Focus)在哪个元素上」来设计:一次只有一个元素处于激活状态,按键/转头负责在元素之间移动焦点,确认键/点头负责「进入或触发」焦点元素。这就是焦点导航

1.2 核心模型:一维焦点索引 + 状态驱动 UI

焦点导航的实现套路非常统一,番茄钟设置页是教科书式范本。核心是:

  1. 一个整数索引 focus 表示当前焦点位置;

  2. 焦点变化时通过 setData 一次性更新所有与焦点相关的 UI 状态(类名、字号、位移、提示文案);

  3. 输入(按键或 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 属性):

回调

说明

onKeyDown(event)

硬件键按下(适合即时反馈)

onKeyUp(event)

硬件键释放(唯一有效的拦截点

onVoiceWakeup(event)

语音唤醒,event.keyword 是唤醒词

2.2 什么是「默认行为」

某些事件不只是通知——页面回调跑完后,宿主仍会执行内置动作,除非页面显式拦截:

按键

宿主默认行为

Backspace

返回上一页 / 退出应用

ArrowUp / ArrowDown

滚动根容器

Enter

激活当前聚焦目标 / 进入导航模式

GlobalHook

设备专属的镜腿触控/快捷键行为

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 拦截规则速记

场景

做法

要在按键释放后做自定义动作

在 onKeyUp 处理

要阻止宿主默认动作

在 onKeyUp 里 event.preventDefault()

只做即时反馈

用 onKeyDown,不调 preventDefault

拦截了但没替代行为

❌ 禁止(会造成无响应)

顶层菜单的返回

通常放行


第三部分 可穿戴设备设计规范

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%

#40FF5E

主元素、激活态、主文本

60%

rgba(64,255,94,0.6)

次级元素、hover、次文本

40%

rgba(64,255,94,0.4)

背景高亮、禁用态、边框弱化

番茄钟的用法示例:

  • 主文本:.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

核心色

--color-primary--color-primary-60--color-primary-40--color-secondary

背景/面板

--color-background--color-surface--color-surface-highlight

文本

--color-text-primary--color-text-secondary

边框

--border-width-thin/default/strong--border-color-default/muted/strong/accent/success/danger/warning

圆角

--radius-sm--radius-md

间距

--spacing-sm/md/lg

组件专属

--card-*--input-*--calendar-*--chart-*--error-state-*

番茄钟工程作为早期实现直接硬编码了 #40FF5E 系列;新代码应优先使用 Token,仅在需要精确控制透明度时用 var(--color-primary-60) 这类变体。

3.4 字体

  • 常用:系统字体 + 回退链 font-family: Arial, sans-serif;

  • 特殊字体:app.json 的 fonts 声明打包字体,family 名在 WXSS 与 Canvas ctx.font 中通用;缺失时回退系统字体。

  • 不要假定所有设备自带同一套系统字体,最终要在真机验证。

3.5 禁止事项(Prohibitions)

  1. ❌ 默认禁用 emoji。UI 文案、标签、状态文本、装饰内容都不得默认使用 emoji,除非产品明确要求。

    • 番茄钟的例外:装饰性番茄图标 🍅 / 咖啡 ☕ 属于产品视觉需求(功能语义),且设置页卡片副标题的 🕙 ⚙ 用于功能图标。判断标准:是「信息」还是「装饰/图标语义」

  2. ❌ 禁止大面积纯色色块。可穿戴屏幕视觉压迫感强;背景保持克制,颜色只用于强调、文本与交互元素。

3.6 设计自检表

  • [ ] 宽度严格 480px,高度 120–380px

  • [ ] 每个页面采用卡片式布局,黑底

  • [ ] 关键元素 2px 边框 + 8–12px 圆角

  • [ ] 使用主题 Token(优先于硬编码)

  • [ ] 主色 #40FF5E,次级用 60%/40% 透明度档

  • [ ] 无大面积纯色块

  • [ ] 文案无默认 emoji(除产品明确要求)


第四部分 IMU 传感器应用

4.1 可用传感器 API

AIUI 提供 Generic Sensor 风格的传感器(全局构造,注册在 globalThis/window):

传感器

构造

主要读数

用途

Accelerometer

new Accelerometer({ frequency })

x/y/z(线性加速度)

检测倾斜、姿态初判

Gyroscope

new Gyroscope({ frequency })

x/y/z(角速度 rad/s)

头部转动积分(核心)

Magnetometer

new Magnetometer({ frequency })

x/y/z(磁场)

朝向辅助

AbsoluteOrientationSensor

new AbsoluteOrientationSensor({ frequency })

quaternion [x,y,z,w]

绝对姿态四元数

统一用法模板:

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 === falsehasReading === false,各读数为 null

  • 首个成功读数后 activated / hasReading 变 true

  • stop() 后 activated 回到 false,但保留最后一次读数

  • timestamp 为读数时间戳(毫秒),是计算采样间隔 dt 的依据。

更多推荐