在手机上,用户只有一个明确的指向器官——手指,点哪里就是哪里。到了空间里,设备同时拥有了三双"眼睛":看哪儿(视线)、朝哪儿(头部姿态)、做什么动作(手势)。能力变多,麻烦也跟着来了:三个通道各说各话时,系统到底听谁的?这篇先把三条输入通道的能力边界摊开,再谈怎么让它们协同工作,而不至于互相打架。
三个输入通道各自能干什么
空间交互不是把触摸事件搬到空中那么简单。每个通道采集的物理量不同,能表达的用户意图层级也不同。
视线追踪(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 时,根据场景动态调整权重。这在需要高吞吐的专业工具里有价值,但调试成本高,误判时用户很难理解系统为什么这么判断。
状态机编排:分阶段接管
把一次交互拆成若干阶段,每个阶段由一个通道主导,阶段之间用状态迁移衔接。例如"进入空间 → 头部定位区域 → 视线选中对象 → 手势操作 → 视线确认离开"。状态机的好处是任何时刻只有一个通道在"说话",冲突面最小。
下面这张图画的是一次典型的"注视选择 + 手势确认"流程。
冲突消解的基本原则
多通道同时活跃时,冲突不可避免,靠三条规则压住:
- 显式动作优先于隐式意图。手势是用户主动做出的动作,视线和头部常有无意识成分,冲突时选手势。
- 时间上后发者优先,空间上前景者优先。最近的动作、离相机最近的目标拿优先权。
- 保守拒绝而非冒险执行。置信度不够时不做任何事,比做错事代价小。宁可让用户再来一次。
代码:把三路信号接进统一的交互处理器
下面这段代码演示在 ArkUI 里同时接入手势、传感器姿态和悬停(作为视线/指针的简化代理)三类事件,并用一个统一的InteractionResolver做冲突消解。传感器姿态用@kit.SensorServiceKit的陀螺仪数据,手势用PanGesture与PanGesture的捏合替代示例(PinchGesture)。
import{sensor}from'@kit.SensorServiceKit';import{BusinessError}from'@kit.BasicServicesKit';// 三个通道的原始信号,统一收敛成一个意图对象interfaceInteractionSignal{focusTarget:string|null;// 当前注视/悬停目标gazeDwellMs:number;// 注视停留时长headYaw:number;// 头部偏航角,单位:度gestureActive:boolean;// 是否有主动手势进行中}@Componentexportstruct SpatialInteractionHost{@StatefocusTarget:string|null=null;@StatedwellMs:number=0;@StateheadYaw:number=0;@StategestureActive:boolean=false;// 注视停留计时器:目标变化时清零,避免“路过”被算作注视privatedwellTimer:number=-1;privatereadonlyDWELL_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 或姿态融合算法替代privatestartHeadTracking():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){conste=errorasBusinessError;console.error(`head tracking failed:${e.code}${e.message}`);}}privateupdateFocus(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){conststart=Date.now();this.dwellTimer=setInterval(()=>{this.dwellMs=Date.now()-start;},50);}}privatecancelFocus():void{this.updateFocus(null);this.gestureActive=false;}// 冲突消解:手势显式动作优先,之后才看注视停留privateresolveIntent():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"这类矛盾。融合的前提是有一个统一的状态和意图模型,三个通道都往同一个模型里写。