在手机上,用户只有一个明确的指向器官------手指,点哪里就是哪里。到了空间里,设备同时拥有了三双"眼睛":看哪儿(视线)、朝哪儿(头部姿态)、做什么动作(手势)。能力变多,麻烦也跟着来了:三个通道各说各话时,系统到底听谁的?这篇先把三条输入通道的能力边界摊开,再谈怎么让它们协同工作,而不至于互相打架。
三个输入通道各自能干什么
空间交互不是把触摸事件搬到空中那么简单。每个通道采集的物理量不同,能表达的用户意图层级也不同。
视线追踪(Eye Tracking) 提供的是注视点(Gaze Point)与注视时长。它的优势是快------人眼扫过一个目标只需要 100~200ms,比手指移动快一个数量级。视线天然表达"我想看什么",也就是指向意图(Pointing Intent)。但它有个硬伤:眼动包含大量无意识的扫视(Saccade)和微颤(Micro-tremor),设备没法区分"看了一眼"和"盯着看"。所以视线擅长选目标,不擅长下命令。
头部运动(Head Motion) 通过陀螺仪与加速度计采集姿态角(Pitch/Yaw/Roll),表达的是朝向意图(Orientation Intent)------用户把注意力转向哪个方向。它比视线稳定,适合做视角控制、区域切换、粗粒度导航。缺点是慢,转头是个大动作,且连续转动容易引发不适。
手势识别(Gesture Recognition) 从相机或手部追踪传感器拿到手部关键点,识别捏合(Pinch)、抓取(Grab)、滑移(Swipe/Slide)等动作,表达操作意图(Manipulation Intent)------用户想对目标做什么。它最接近传统触摸的操作心智,精确度也最高,代价是需要抬手、有学习成本,长时间悬空操作还会累(Gorilla Arm 问题)。
把这三者放在一张表里,选型时的取舍会清楚很多。
| 通道 | 采集量 | 表达意图 | 响应速度 | 精度 | 主要短板 |
|---|---|---|---|---|---|
| 视线追踪 | 注视点、注视时长 | 指向 | 极快(100~200ms) | 中,易抖动 | 无法区分看与盯,不能独立下命令 |
| 头部运动 | 姿态角、角速度 | 朝向 | 中(300~500ms) | 中 | 动作幅度大,易疲劳,易晕 |
| 手势识别 | 手部关键点、动作类型 | 操作 | 中(含识别延迟) | 高 | 需抬手,长时悬空累,误识别 |
| 2D 触摸(基线) | 触点、压力 | 操作 | 快 | 极高 | 局限于平面,无深度信息 |
一句话概括这张表的用法:视线负责"选谁",头部负责"面向哪",手势负责"对它做什么"。
多通道融合的交互模型
单个通道都不足以独立支撑一次完整操作,融合是必然的。工程上常用的融合方式有三类,复杂度递增。
串行确认:注视选择 + 手势确认
这是最容易落地、也最不容易出错的模型。视线先把光标投到某个目标上完成预选择(Pre-selection) ,再用一个明确的手势完成提交(Commit)。经典的"Gaze + Pinch"就属于这种:盯住卡片,捏合确认。
这样做把"选择"和"确认"两个动作拆给了两个通道,各取所长。视线快,负责找;手势稳,负责定。它同时消解了视线的"迈达斯之触"问题------因为光看不做任何事,必须配合手势才生效。
并行融合:加权投票
系统对同一时刻的三个通道信号做加权,用一个统一的置信度分数决定最终意图。比如视线落在 A、手势指向 B、头部朝向 C 时,根据场景动态调整权重。这在需要高吞吐的专业工具里有价值,但调试成本高,误判时用户很难理解系统为什么这么判断。
状态机编排:分阶段接管
把一次交互拆成若干阶段,每个阶段由一个通道主导,阶段之间用状态迁移衔接。例如"进入空间 → 头部定位区域 → 视线选中对象 → 手势操作 → 视线确认离开"。状态机的好处是任何时刻只有一个通道在"说话",冲突面最小。
下面这张图画的是一次典型的"注视选择 + 手势确认"流程。
#mermaid-svg-s0UVM3bIZpjlb0Ep{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-s0UVM3bIZpjlb0Ep .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-s0UVM3bIZpjlb0Ep .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-s0UVM3bIZpjlb0Ep .error-icon{fill:#552222;}#mermaid-svg-s0UVM3bIZpjlb0Ep .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-s0UVM3bIZpjlb0Ep .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-s0UVM3bIZpjlb0Ep .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-s0UVM3bIZpjlb0Ep .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-s0UVM3bIZpjlb0Ep .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-s0UVM3bIZpjlb0Ep .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-s0UVM3bIZpjlb0Ep .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-s0UVM3bIZpjlb0Ep .marker{fill:#333333;stroke:#333333;}#mermaid-svg-s0UVM3bIZpjlb0Ep .marker.cross{stroke:#333333;}#mermaid-svg-s0UVM3bIZpjlb0Ep svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-s0UVM3bIZpjlb0Ep p{margin:0;}#mermaid-svg-s0UVM3bIZpjlb0Ep .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-s0UVM3bIZpjlb0Ep .cluster-label text{fill:#333;}#mermaid-svg-s0UVM3bIZpjlb0Ep .cluster-label span{color:#333;}#mermaid-svg-s0UVM3bIZpjlb0Ep .cluster-label span p{background-color:transparent;}#mermaid-svg-s0UVM3bIZpjlb0Ep .label text,#mermaid-svg-s0UVM3bIZpjlb0Ep span{fill:#333;color:#333;}#mermaid-svg-s0UVM3bIZpjlb0Ep .node rect,#mermaid-svg-s0UVM3bIZpjlb0Ep .node circle,#mermaid-svg-s0UVM3bIZpjlb0Ep .node ellipse,#mermaid-svg-s0UVM3bIZpjlb0Ep .node polygon,#mermaid-svg-s0UVM3bIZpjlb0Ep .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-s0UVM3bIZpjlb0Ep .rough-node .label text,#mermaid-svg-s0UVM3bIZpjlb0Ep .node .label text,#mermaid-svg-s0UVM3bIZpjlb0Ep .image-shape .label,#mermaid-svg-s0UVM3bIZpjlb0Ep .icon-shape .label{text-anchor:middle;}#mermaid-svg-s0UVM3bIZpjlb0Ep .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-s0UVM3bIZpjlb0Ep .rough-node .label,#mermaid-svg-s0UVM3bIZpjlb0Ep .node .label,#mermaid-svg-s0UVM3bIZpjlb0Ep .image-shape .label,#mermaid-svg-s0UVM3bIZpjlb0Ep .icon-shape .label{text-align:center;}#mermaid-svg-s0UVM3bIZpjlb0Ep .node.clickable{cursor:pointer;}#mermaid-svg-s0UVM3bIZpjlb0Ep .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-s0UVM3bIZpjlb0Ep .arrowheadPath{fill:#333333;}#mermaid-svg-s0UVM3bIZpjlb0Ep .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-s0UVM3bIZpjlb0Ep .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-s0UVM3bIZpjlb0Ep .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s0UVM3bIZpjlb0Ep .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-s0UVM3bIZpjlb0Ep .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s0UVM3bIZpjlb0Ep .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-s0UVM3bIZpjlb0Ep .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-s0UVM3bIZpjlb0Ep .cluster text{fill:#333;}#mermaid-svg-s0UVM3bIZpjlb0Ep .cluster span{color:#333;}#mermaid-svg-s0UVM3bIZpjlb0Ep div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-s0UVM3bIZpjlb0Ep .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-s0UVM3bIZpjlb0Ep rect.text{fill:none;stroke-width:0;}#mermaid-svg-s0UVM3bIZpjlb0Ep .icon-shape,#mermaid-svg-s0UVM3bIZpjlb0Ep .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-s0UVM3bIZpjlb0Ep .icon-shape p,#mermaid-svg-s0UVM3bIZpjlb0Ep .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-s0UVM3bIZpjlb0Ep .icon-shape .label rect,#mermaid-svg-s0UVM3bIZpjlb0Ep .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-s0UVM3bIZpjlb0Ep .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-s0UVM3bIZpjlb0Ep .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-s0UVM3bIZpjlb0Ep :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 否
是
否 且 注视移开
否 且 注视保持
是
是
否
用户看向空间中的目标
注视点是否稳定停留超过阈值
目标进入预选择状态 高亮描边
是否检测到捏合手势
取消预选择 恢复原状态
提交操作 触发选中回调
状态切换 进入对象操作态
头部朝向是否偏离超过容忍角
退出对象操作态 释放焦点
冲突消解的基本原则
多通道同时活跃时,冲突不可避免,靠三条规则压住:
- 显式动作优先于隐式意图。手势是用户主动做出的动作,视线和头部常有无意识成分,冲突时选手势。
- 时间上后发者优先,空间上前景者优先。最近的动作、离相机最近的目标拿优先权。
- 保守拒绝而非冒险执行。置信度不够时不做任何事,比做错事代价小。宁可让用户再来一次。
代码:把三路信号接进统一的交互处理器
下面这段代码演示在 ArkUI 里同时接入手势、传感器姿态和悬停(作为视线/指针的简化代理)三类事件,并用一个统一的 InteractionResolver 做冲突消解。传感器姿态用 @kit.SensorServiceKit 的陀螺仪数据,手势用 PanGesture 与 PanGesture 的捏合替代示例(PinchGesture)。
typescript
import { sensor } from '@kit.SensorServiceKit';
import { BusinessError } from '@kit.BasicServicesKit';
// 三个通道的原始信号,统一收敛成一个意图对象
interface InteractionSignal {
focusTarget: string | null; // 当前注视/悬停目标
gazeDwellMs: number; // 注视停留时长
headYaw: number; // 头部偏航角,单位:度
gestureActive: boolean; // 是否有主动手势进行中
}
@Component
export struct SpatialInteractionHost {
@State focusTarget: string | null = null;
@State dwellMs: number = 0;
@State headYaw: number = 0;
@State gestureActive: boolean = false;
// 注视停留计时器:目标变化时清零,避免"路过"被算作注视
private dwellTimer: number = -1;
private readonly DWELL_THRESHOLD_MS: number = 600;
aboutToAppear(): void {
this.startHeadTracking();
}
aboutToDisappear(): void {
// 订阅与取消订阅必须成对,避免资源泄漏
sensor.off(sensor.SensorId.GYROSCOPE);
if (this.dwellTimer !== -1) {
clearInterval(this.dwellTimer);
}
}
// 头部姿态:用陀螺仪 z 轴角速度近似积分出偏航角,
// 真实项目建议用 ROTATION_VECTOR 或姿态融合算法替代
private startHeadTracking(): void {
try {
sensor.on(sensor.SensorId.GYROSCOPE, (data: sensor.GyroscopeResponse) => {
this.headYaw += data.z * 0.01; // 简易积分,仅作演示
if (Math.abs(this.headYaw) > 60) {
// 头部偏离过大,主动释放焦点,避免用户"看着别处还在操作"
this.cancelFocus();
}
}, { interval: 20000000 }); // 50Hz,姿态控制需要较高频率
} catch (error) {
const e = error as BusinessError;
console.error(`head tracking failed: ${e.code} ${e.message}`);
}
}
private updateFocus(target: string | null): void {
if (target === this.focusTarget) {
return;
}
this.focusTarget = target;
this.dwellMs = 0;
if (this.dwellTimer !== -1) {
clearInterval(this.dwellTimer);
this.dwellTimer = -1;
}
if (target !== null) {
const start = Date.now();
this.dwellTimer = setInterval(() => {
this.dwellMs = Date.now() - start;
}, 50);
}
}
private cancelFocus(): void {
this.updateFocus(null);
this.gestureActive = false;
}
// 冲突消解:手势显式动作优先,之后才看注视停留
private resolveIntent(): string {
if (this.gestureActive) {
return `commit:${this.focusTarget ?? 'none'}`;
}
if (this.focusTarget !== null && this.dwellMs >= this.DWELL_THRESHOLD_MS) {
return `preselect:${this.focusTarget}`;
}
return 'idle';
}
build() {
Column({ space: 16 }) {
Text(`intent = ${this.resolveIntent()}`).fontSize(18)
// 三个可聚焦目标:用 onHover 模拟视线/指针指到目标
ForEach(['card-A', 'card-B', 'card-C'], (id: string) => {
Text(id)
.width(200)
.height(80)
.textAlign(TextAlign.Center)
.borderRadius(12)
.backgroundColor(this.focusTarget === id ? '#4A90D9' : '#E8E8E8')
.onHover((isHover: boolean) => {
// 指针进入即视为注视点落在该目标上
this.updateFocus(isHover ? id : (this.focusTarget === id ? null : this.focusTarget));
})
})
// 手势通道:捏合作为提交动作
Column()
.width(200)
.height(60)
.backgroundColor('#2C2C2C')
.gesture(
PinchGesture({ fingers: 2 })
.onActionStart(() => { this.gestureActive = true; })
.onActionEnd(() => { this.gestureActive = false; })
)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
PinchGesture、GestureEvent 等属于 ArkUI 声明式范式内置能力,在 .ets 文件中可直接使用,无需额外 import。代码里有几个关键点。updateFocus 在目标变化时刻意把 dwellMs 清零------注视计时必须绑定到具体目标,否则用户"一路扫过"会被误判为注视。resolveIntent 里手势判断放在最前面,对应的是"显式动作优先"这条消解规则。头部偏航超过阈值直接 cancelFocus,是在防止用户转开头之后系统还在对旧目标执行操作。
案例:商品详情页的"注视放大 + 手势查看"
一个很实际的场景:用户在一个空间化的商品陈列里浏览,面前有一排悬浮商品卡。纯手势操作的问题是------用户得先用手去"够"卡片,卡片一多,手臂来回划很累。纯视线的问题是------用户扫视浏览时,卡片不断被误触发。
融合方案是这样的:
- 头部:用户转动头部在不同商品分组之间切换。转头到某一分区时,该分区的卡片整体提亮,其余变暗。这是一个粗粒度的"频道切换",用头部最合适。
- 视线 :悬停在某张卡片上超过 500ms,卡片轻微放大并显示价格与评分,但不进入详情。这一步只是信息预览,不产生副作用。
- 手势:确实想进入详情时,做一个"抓取并拉近"的动作,卡片飞入视野中心并展开详情。这就是提交动作。
三个通道各管一段:头部分组、视线预览、手势提交。用户从头到尾只做一次明确的"抓取"手势,其余都在自然行为中完成。实测里这种设计把误触率压得很低,因为唯一能产生状态跳转的通道是手势。
小小总结
把视线当"注意力"而非"控制信号"。它天生有噪声且无意识,任何"看一眼就触发"的设计几乎必然会遭遇迈达斯之触。正确的姿势是让视线做选择和预览,把副作用留给手势。
给每个通道划定职责。视线负责"选谁",头部负责"面向哪",手势负责"对它做什么"。职责交叉越多,冲突消解越难做,也越难向用户解释系统为什么这么响应。
阈值按场景可配置。注视时长、头部转角容忍度、手势置信度都不该是一组固定值。熟练用户和初次使用者、浏览模式和操作模式,合适的阈值都不一样,至少要留出按场景切换的入口。
始终保留"安全出口"。空间交互一旦误触发,用户往往不知道该怎么退回。任何状态下都要有一个明确、低成本的取消路径,并且撤销操作要及时可见。
注意一下下哦
忽略通道之间的时序耦合。注视 + 手势不是"两个独立事件碰巧同时发生",而是有先后顺序的因果链。如果手势发生在注视之前(用户先抬手再找目标),系统应该容忍这个顺序,而不是要求严格同时。
传感器订阅忘记配对取消 。sensor.on 和 sensor.off 必须成对调用,页面销毁时要释放。陀螺仪数据频率高,忘记取消会让后台持续耗电,长时间运行的页面还会累积积分误差。
把三个通道当三个独立功能做。如果视线、头部、手势各写一套互不相干的状态和判定逻辑,界面很快会出现"视线高亮着 A、手势却操作了 B"这类矛盾。融合的前提是有一个统一的状态和意图模型,三个通道都往同一个模型里写。