
触摸屏上的手势是实的——手指确实碰到了玻璃坐标、压力、速度都是确定的数值。空中手势是虚的传感器看到一只手在动系统得猜这动作是什么意思还得猜得足够准准到用户敢把删除操作交给它。这篇讲空中手势的语义怎么设计、误识别怎么兜底以及它和 2D 触摸手势到底差在哪。空中手势的语义映射先明确一组基础动作。空间里能稳定识别、用户也容易理解的手势其实不多常见的有这几类点指Point / Air Tap食指前伸或轻点作用等价于单击是最轻量的选择动作。捏合Pinch拇指与食指合拢作用等价于确认、拾取或缩放。抓取Grab整只手呈抓握状用于拿起一个对象并跟随移动。松手Release从抓握张开用于放下对象触发落位。滑移Swipe / Slide手掌沿某方向平移用于翻页、切换、推动。挥动Wave左右摆动常用于呼出/收起系统级面板。双手缩放Two-hand Scale两手距离变化用于整体缩放与旋转。语义映射的核心原则是动作与语义要有物理隐喻。抓取对应拿起松手对应放下两手拉开对应放大——用户不需要学习凭直觉就能猜对。反过来如果一个手势的语义需要教程才能记住那它在真实场景里基本不会被用起来。每一类手势的语义设计记得留出方向维度。同样是滑移向左和向右可以是下一页和上一页向上和向下可以是滚动和拉出。但在空间里要小心滑移方向要和内容的布局方向一致不然用户会感觉推错了方向。手势物理隐喻典型语义触发代价适合的操作点指指向选择、高亮最低预览、轻选择捏合捏住确认、拾取、缩放低提交操作、进入详情抓取 移动拿起拖拽、重排中对象位置调整松手放下落位、提交低完成拖拽滑移推拉翻页、切换中列表浏览、面板切换挥动挥手呼出/收起中系统级面板双手缩放拉伸整体缩放、旋转高场景级视角调整置信度与容错让系统学会拿不准就不做手势识别模型输出的不是一个确定的类别而是每个候选手势的置信度Confidence通常是一个 0~1 的概率。设计容错本质是围绕这个置信度做文章。阈值与迟滞最简单的是单阈值置信度超过 0.8 才触发。但它有个毛病——用户的手停在临界状态时置信度在 0.79 和 0.81 之间反复横跳界面就会疯狂闪烁。解决办法是迟滞Hysteresis也叫双阈值。进入要 0.85退出只要 0.65。两个阈值之间是保持区一旦进入就不再反复直到跌出下限才退出。这和按钮的消抖是同一个思路。时间维度确认窗口手势是时间上的过程不是瞬时事件。可以在时间上加两道约束最短持续时间动作至少保持 N 毫秒才算数过滤掉手抖造成的瞬时误识别。稳定窗口在某个时间窗内识别结果要保持一致避免单帧噪声造成误判。二次确认与撤销破坏性操作必须二次确认。这里的二次可以换一个通道比如第一次捏合是选中第二次捏合或加一个语音确认才执行删除且中间界面要有明显的待确认状态。撤销同样重要。空间交互没有物理触感用户做完动作后往往不确定自己做了什么。撤销入口要及时操作后立刻出现、低成本不需要复杂手势、可见界面上有明确提示。手势识别的容错链路可以画成这样否是否是跌落退出阈值稳定否是传感器采集手部关键点识别模型输出候选手势与置信度置信度是否高于进入阈值丢弃 不产生任何界面变化持续时间是否超过最小窗口进入待确认状态 高亮反馈手势是否稳定 在保持区内判定为误识别 回滚状态是否为破坏性操作直接执行 显示结果要求二次确认 换通道或再确认执行 并提供撤销入口空中手势与 2D 触摸手势的差异很多人以为把TapGesture换成空中点指就完事了实际上两者在工程上有本质区别。事件是连续的不是离散的。触摸事件有明确的down和up起止清晰。空中手势没有接触这个动作全靠识别模型划定时序边界。所以空中手势多了识别延迟这一项开销——系统必须先看到一段完整动作才能判断它是什么。没有触点只有姿态。触摸有精确坐标空中手势只有关节角度和手部位置。精度差一个量级注定不能做像素级精细操作。误识别是常态不是异常。触摸手势误触是边缘情况空中手势误识别是家常便饭设计基线就得假设随时可能认错。缺乏触觉反馈。触摸有震动和按压的物理反馈空中手势没有。用户只能靠视觉和声音确认反馈设计要更主动。维度2D 触摸手势空中手势事件边界明确down/up需模型划定时序输入精度像素级关节角度级较粗识别延迟低即时有需完整动作误识别率低相对高属常态触觉反馈有震动、压力无依赖视觉/听觉用户疲劳低长时悬空会累最后一行是容易被低估的。空中手势需要手臂悬空连续操作几分钟就会酸。所以空中手势应尽量用在低频、粗粒度的操作上高频操作还是留给触摸或者视线。代码手势识别与容错处理ArkUI 的手势框架天然支持这类场景。下面用PanGesture做滑移带方向判定与阈值用PinchGesture做缩放并在回调里演示迟滞、最短持续时间和撤销的实现思路。EntryComponentstruct GestureInteractionDemo{Statescale:number1.0;StateoffsetX:number0;StateoffsetY:number0;StategestureState:stringidle;// idle | pending | activeStatetoast:string;// 迟滞阈值进入难、退出易避免临界状态反复触发privatereadonlyENTER_THRESHOLD:number0.85;privatereadonlyEXIT_THRESHOLD:number0.65;privatereadonlyMIN_HOLD_MS:number120;// 最短持续时间过滤手抖privategestureStartTs:number0;privatelastScale:number1.0;privatelastOffsetX:number0;privatelastOffsetY:number0;// 破坏性操作前的待确认状态StatependingDelete:string;build(){Column({space:16}){Text(state${this.gestureState}scale${this.scale.toFixed(2)}).fontSize(16)// 可操作对象同时绑定滑动平移与捏合缩放Column().width(160).height(160).borderRadius(20).backgroundColor(#4A90D9).translate({x:this.offsetX,y:this.offsetY}).scale({x:this.scale,y:this.scale}).gesture(// 多个手势必须用 GestureGroup 显式绑定对同一组件连续调用多次 .gesture()// 时后面的会覆盖前面的只有最后一个生效。Parallel 表示两者可同时响应。GestureGroup(GestureMode.Parallel,// 滑移手势用于拖拽对象direction 限制只在水平方向响应PanGesture({direction:PanDirection.Horizontal,distance:8}).onActionStart((){this.gestureStartTsDate.now();this.lastOffsetXthis.offsetX;this.gestureStatepending;}).onActionUpdate((event:GestureEvent){// 最短持续时间未到不进入活跃态避免误识别if(Date.now()-this.gestureStartTsthis.MIN_HOLD_MS){return;}this.gestureStateactive;this.offsetXthis.lastOffsetXevent.offsetX;}).onActionEnd((){this.gestureStateidle;}).onActionCancel((){// 手势被打断被其他手势竞争或系统回收时回滚防止状态卡死this.offsetXthis.lastOffsetX;this.gestureStateidle;}),// 捏合手势用于缩放PinchGesture({fingers:2,distance:5}).onActionStart((){this.lastScalethis.scale;}).onActionUpdate((event:GestureEvent){// 缩放限制在合理区间防止用户拉到极端值constnextthis.lastScale*event.scale;this.scaleMath.min(3.0,Math.max(0.5,next));})))// 破坏性操作删除按钮二次确认后才执行Button(this.pendingDeletecard?再次点击确认删除:删除对象).backgroundColor(this.pendingDeletecard?#D9534F:#8A8A8A).onClick((){if(this.pendingDeletecard){this.toast已删除可撤销;this.pendingDelete;}else{this.pendingDeletecard;// 第一次点击进入待确认this.toast请再次确认;}})if(this.toast!){Text(this.toast).fontSize(14).fontColor(#666666)}}.width(100%).height(100%).justifyContent(FlexAlign.Center)}}代码里的几个动作对应了前面讲的容错策略PanGesture的distance: 8是识别阈值滑动位移超过 8vp 才认压掉微小手抖——这就是进入阈值在位移维度的体现。MIN_HOLD_MS的最短持续时间判断是时间维度的过滤未达时长不进入活跃态。onActionCancel里回滚offsetX处理的是手势竞争被打断的情况防止拖到一半卡住。删除按钮的pendingDelete状态就是破坏性操作的二次确认第一次点击只切状态第二次才真正执行。PanGesture和PinchGesture通过GestureGroup(GestureMode.Parallel)并行绑定在同一个组件上。这里有个容易踩的坑不要对同一组件连续调用两次.gesture()——后面的调用会覆盖前面的表面上两个手势都写上了实际只剩最后一个生效。需要多个手势时一律用GestureGroup显式声明并行或串行关系或者拆到不同命中区域。案例空间相册的抓取—移动—落位一个空间化的相册应用照片以卡片形式悬浮在用户面前。用户想把某张照片从最近分组移到收藏分组。这个操作如果用触摸就是长按拖动换成空中手势设计要重做一遍。方案是抓取—移动—落位三段式抓取手掌呈抓握状对准一张照片置信度超过 0.85 且保持 150ms 后照片轻微上浮并跟随手部移动同时源分组出现一个虚线空位。移动照片跟随手部水平移动经过其他分组时该分组整体提亮表示可以放这里。落位手张开松手照片落入当前高亮的分组分组做一次收拢动画。若在手张开时没有任何分组被高亮照片飞回原位并提示未放入任何分组而不是随便找个位置丢下。这里的关键容错有两点。一是跟随有阻尼照片不会严格贴着手走而是带一点延迟和柔化这样手部追踪的抖动不会让照片疯狂晃视觉上也更像有重量的实物。二是落位有明确判定松手时如果没有合法目标一律回退不做猜测性放置。空间交互里猜比不响应更伤体验因为用户无法理解系统为什么把东西放到了那里。撤销也做了落位后相册顶部出现已移入收藏的提示条持续 5 秒点它即可放回。5 秒足够用户反应过来又不至于长期占位。总结一下下手势数量宁少勿多。识别能支持几十种手势不代表用户记得住。常用手势控制在 7 个以内超出部分要么合并语义要么让用户自定义。每个手势都要有视觉预览。用户做手势的过程中界面要实时反映系统现在认为你在做什么比如抓取时对象上浮、滑移时内容跟手。没有预览用户不知道自己有没有做对。容错的重心在不做而不是纠正。系统认不准的时候最好的选择是保持现状等用户重新做一次。自动纠正用户的意图十次里有八次是错的。给手势留退路。任何手势产生的状态变化都要能撤销且撤销入口要一直可见到操作确认。空间里没有 CtrlZ 的肌肉记忆得靠界面提醒。容易出问题的地方哦忽视手势竞争。同一区域绑定多个手势用户做一个动作可能同时满足两个的触发条件。要用GestureGroup明确声明串行或并行关系不要依赖默认行为默认行为在版本迭代里可能变。onActionCancel没写回滚。手势被系统接管或被打断时只触发 cancel 而不触发 end如果只在 end 里提交状态就会出现操作做了一半没结束的脏状态。cancel 回调必须把中间态清理干净。把识别置信度当固定值。不同光照、不同手速、不同用户识别置信度的分布都不一样。写死的 0.8 在某些用户身上会频繁误触在另一些用户身上又怎么都触发不了。至少要提供灵敏度调节理想情况下做自适应校准。长时间不释放传感器和相机。手部追踪依赖相机或专用传感器页面不可见时要及时暂停否则既耗电又涉及隐私问题。追随时也要限制最大时长用户手放下半天系统还在等容易出意外。手势反馈只做视觉。没有触觉纯视觉反馈在快速连续操作时会漏看。震动Vibrator和短促音效是低成本的补充一次咔哒就能让用户确认动作生效这比一闪而过的动画可靠。