HarmonyOS 7视线追踪交互设计:凝视点选与注视触发

举报
Jack20 发表于 2026/10/02 11:56:11 2026/10/02
【摘要】 用手指点屏幕,"点"和"选"是同一个动作,用户不会怀疑自己到底点了没有。换成视线就麻烦了:人眼每分钟要做上万次无意识的扫视,如果每看一眼就触发一次,界面会疯掉。这就是视线交互要解决的核心矛盾——如何从连绵不断的眼动里,分辨出哪一次才是"我想点它"。这篇讲凝视点选(Gaze Selection)的原理、注视触发(Dwell)的参数设计,以及怎么把抖动和误触压下去。 凝视点选与迈达斯之触视线交互...

用手指点屏幕,"点"和"选"是同一个动作,用户不会怀疑自己到底点了没有。换成视线就麻烦了:人眼每分钟要做上万次无意识的扫视,如果每看一眼就触发一次,界面会疯掉。这就是视线交互要解决的核心矛盾——如何从连绵不断的眼动里,分辨出哪一次才是"我想点它"。这篇讲凝视点选(Gaze Selection)的原理、注视触发(Dwell)的参数设计,以及怎么把抖动和误触压下去。

凝视点选与迈达斯之触

视线交互最早、也最直接的实现是凝视点选:系统持续计算注视点坐标,落在哪个可交互目标上,就把哪个目标当作当前选择。

这个方案会遇到一个著名的难题,叫迈达斯之触(Midas Touch)。古希腊神话里迈达斯王碰什么什么变金子,最后连食物都吃不了。视线交互也是这个困境:眼睛看到哪里,哪里就被"碰"了。用户只是习惯性地瞟一眼时间,结果打开了日历;想看清楚某个按钮的字,结果把它按了。

迈达斯之触的根子在于:注视(Fixation)和意图(Intent)不等价。人看东西分两类动作:

  • 扫视(Saccade):快速跳动,视线从 A 跳到 B,持续 20~40ms,是无意识的。
  • 注视(Fixation):视线在某点附近停留,持续 100ms 以上,通常对应认知加工。

但即便是一次 300ms 的注视,也可能只是"我在读它",不等于"我要激活它"。所以纯凝视点选几乎不可用。工程上必须引入一个额外的、明确的信号来区分"看"和"选"。

常见的四种解法:

方案 触发信号 优点 缺点
纯凝视 注视达阈值即触发 无需其他设备 迈达斯之触严重,基本不可用
注视 + 停留(Dwell) 注视持续超过 N 毫秒 仅靠眼动即可完成 等待感强,误触仍在
注视 + 手势确认 注视选目标,手势提交 误触率极低,心智清晰 需手势设备,多一步操作
注视 + 眨眼 识别特定眨眼模式 单手可完成 眨眼误识别率高,疲劳

实际产品里,"注视 + 停留"和"注视 + 手势"用得最多,前者适合无手的浏览场景,后者适合精确操作。

注视触发的时长与反馈

如果真的要用 Dwell(停留触发),时长就是最关键的参数。设短了误触,设长了让人等。

业界摸索出的经验区间在 400ms 到 1000ms 之间:

  • 低于 400ms,正常阅读过程中的停驻就会被判为触发,误触率飙升。
  • 高于 1000ms,用户会产生明显的"卡顿感",尤其连续操作时很难受。
  • 600ms 左右是较常用的折中值,适合大多数点击类操作。
  • 对于有破坏性的操作(删除、支付),可以拉长到 800~1000ms,并且加二次确认。

比时长更重要的,是反馈。Dwell 最大的体验问题是等待期间界面毫无动静,用户不知道系统在数数。好的做法是给一个与计时同步的视觉反馈,让"等待"变成"进度":

  • 环形进度:目标周围画一个圈,随时间填充,填满即触发。
  • 径向放大:目标随停留逐渐放大,到达阈值时"弹"一下确认。
  • 颜色渐深:背景色随停留加深。

反馈要让用户能预判"还差多少"。用户一旦看到圈快满了,就会下意识多看半秒——这一下恰好把误触和漏触都降低了。

还有几个细节:

  • 计时必须在注视离开目标的瞬间清零,不能跨目标累积。
  • 阈值可以动态:对重要目标用较长阈值,对高频操作用较短阈值。
  • 提供全局 Dwell 开关,让不喜欢停留触发的用户关掉,改用显式确认。

抖动与误触的抑制

眼动追踪的原始数据带有明显噪声,直接拿来算注视点,光标会像喝了咖啡一样抖。抑制分两层。

数据层:滤波与去噪

最常用的是滑动平均或指数平滑(EMA)。视线信号的抖动频率比真实注视转移频率高,低通滤波能压掉大部分。但滤波有代价——它引入延迟,而视线交互对延迟极其敏感(超过 100ms 用户就会觉得"眼睛和光标不同步")。所以要平衡:平滑窗口不能太大。

一个更聪明的做法是基于速度的过滤。眼动的速度分布是双峰的:扫视时速度极高(几百 deg/s),注视时极低(几 deg/s)。设定一个速度阈值,只有速度低于阈值时才算"可能是注视",高速段直接标记为扫视并跳过。

逻辑层:注视聚类与目标吸附

原始注视点是连续坐标,需要聚类成"一次注视事件"。常用算法是 I-DT(Dispersion-Threshold Identification):在时间窗内,如果注视点的离散度(最大最小坐标差)小于某个阈值,就判定为一次注视。

聚类出注视后,再把它吸附到最近的交互目标上。吸附有一个磁吸半径(Magnet Radius),超出半径就不算落在目标上。这样即使原始坐标略有偏差,只要落在目标附近就能正确命中。

flowchart LR
    A["原始注视点流 60~120Hz"] --> B{"瞬时速度是否低于阈值"}
    B -- "否 扫视" --> C["丢弃 不参与选择"]
    B -- "是 可能注视" --> D["I-DT 聚类 时间窗离散度判断"]
    D --> E{"离散度是否小于阈值"}
    E -- "否" --> C
    E -- "是" --> F["生成一次注视事件"]
    F --> G["吸附到最近目标 磁吸半径判定"]
    G --> H{"是否命中目标"}
    H -- "否" --> C
    H -- "是" --> I["启动 Dwell 计时 显示环形反馈"]

误触防线:三重保险

数据滤波和聚类之后,再加三道防线:

  1. 目标尺寸下限。视线精度有限(通常 1~2 度视角误差),小目标不要用纯视线选。可交互目标在视锥里至少要占 3 度以上。
  2. 二次确认。破坏性操作必须二次确认,Dwell 一次是"选中",再 Dwell 一次或加手势才是"执行"。
  3. 可撤销。触发后提供明确、及时、低成本的撤销入口,且撤销窗口不能太短。

代码:注视焦点状态机与 Dwell 反馈

下面用 ArkUI 的状态管理与动画,实现一个"注视焦点 + 停留进度"的可视化组件。onHover 在这里作为注视点进入/离开目标的代理事件(真实项目接入眼动 SDK 后替换数据源即可),Dwell 进度用 @State 驱动环形动画。

@Component
export struct GazeTarget {
  @Prop targetId: string;
  @Prop label: string;
  @State isFocusing: boolean = false;   // 注视是否落在本目标
  @State dwellProgress: number = 0;     // 停留进度 0.0~1.0
  @State confirmed: boolean = false;    // 是否已触发

  // 触发前参数:停留阈值越长,误触越少,等待感越强
  private readonly DWELL_MS: number = 600;
  private dwellTimer: number = -1;
  private startTs: number = 0;
  onConfirm?: (id: string) => void;

  // 注视进入:启动计时;离开:立即清零,防止跨目标累积
  private handleFocusChange(focusing: boolean): void {
    if (focusing === this.isFocusing) {
      return;
    }
    this.isFocusing = focusing;
    if (focusing) {
      this.startDwell();
    } else {
      this.stopDwell();
    }
  }

  private startDwell(): void {
    this.stopDwell();
    this.confirmed = false;
    this.startTs = Date.now();
    this.dwellTimer = setInterval(() => {
      const elapsed = Date.now() - this.startTs;
      this.dwellProgress = Math.min(elapsed / this.DWELL_MS, 1);
      if (this.dwellProgress >= 1) {
        this.stopDwell();
        this.confirmed = true;
        this.onConfirm?.(this.targetId); // 触发确认回调
      }
    }, 16); // 约 60fps,保证进度条顺滑
  }

  private stopDwell(): void {
    if (this.dwellTimer !== -1) {
      clearInterval(this.dwellTimer);
      this.dwellTimer = -1;
    }
    this.dwellProgress = 0;
  }

  aboutToDisappear(): void {
    this.stopDwell();
  }

  build() {
    Stack() {
      // 目标卡片:注视时高亮并轻微放大
      Text(this.label)
        .width(180)
        .height(100)
        .textAlign(TextAlign.Center)
        .borderRadius(16)
        .backgroundColor(this.confirmed ? '#2E9E5B'
          : (this.isFocusing ? '#4A90D9' : '#E8E8E8'))
        .scale(this.isFocusing ? { x: 1.05, y: 1.05 } : { x: 1, y: 1 })
        .onHover((isHover: boolean) => {
          this.handleFocusChange(isHover); // 真实场景替换为注视点命中判定
        })

      // 环形停留进度:只在注视且未确认时显示
      if (this.isFocusing && !this.confirmed) {
        Progress({ value: this.dwellProgress * 100, total: 100, type: ProgressType.Ring })
          .width(120)
          .height(120)
          .color('#FFFFFF')
          .style({ strokeWidth: 6 })
          .hitTestBehavior(HitTestMode.None) // 不拦截事件,避免影响定位
      }
    }
  }
}

这段代码有两个设计点。一是 stopDwell 在每次注视离开时把 dwellProgress 归零,杜绝了"扫过多个目标累计凑满进度"这类误触。二是进度环 hitTestBehavior 设为 None,反馈层不能干扰命中测试,否则动画元素自己会把注视点"吃掉"。真实接入眼动 SDK 时,onHover 换成注视点坐标与目标包围盒的相交判断即可,其余逻辑不变。

配合一个完整的"注视 + 确认"场景,宿主组件负责把注视命中和手势确认拼起来:

@Entry
@Component
struct GazeDemo {
  @State confirmedId: string = '';

  build() {
    Column({ space: 20 }) {
      Text(this.confirmedId === '' ? '注视目标并保持 0.6 秒' : `已选中:${this.confirmedId}`)
        .fontSize(18)

      Row({ space: 16 }) {
        GazeTarget({ targetId: 'A', label: '商品 A', onConfirm: (id: string) => {
          this.confirmedId = id;
        }})
        GazeTarget({ targetId: 'B', label: '商品 B', onConfirm: (id: string) => {
          this.confirmedId = id;
        }})
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

案例:博物馆导览里的"凝视展品看详情"

一个文旅展馆的空间应用,面前陈列着若干 3D 展品模型,每件展品旁边悬浮一块信息牌。用户戴着头显或者站在大屏前浏览。

如果每件展品都用"看一眼就展开详情",用户会崩溃——浏览时眼睛到处扫,详情面板会不停跳出来又收回去。这里的做法是分开两件事:

  • 凝视预览:注视展品超过 400ms,展品缓慢旋转一圈,信息牌显示一行简短的年代与名称。这一步不打开详情,只是"我注意到了"。
  • 注视确认:用户确实想看详情时,注视信息牌上的"展开"按钮停留 600ms。环形进度填满,详情面板从展品后方滑出。

为什么给不同的目标不同阈值?因为展品本体很大,误触无所谓;而"展开"按钮一旦误触,会打断浏览节奏,值得让用户多等 200ms 换更低的误触率。这种按目标重要程度分配阈值的做法,在视线交互里很常见。

细节上还加了两个保护:详情面板打开后,注视停留在面板外超过 1 秒才自动收起(避免用户看别处一眼面板就没了);面板收起有 200ms 的淡出过渡,防止频繁闪烁。

总结一下下

先决定"看"和"选"分不分家。如果场景允许手势,优先做注视+手势,把副作用交给显式动作;只有在免手场景下才考虑 Dwell,并且接受它必然存在的误触残余。

Dwell 阈值按目标重要性分层。不要全局一个值。高频、低风险的操作可以短,低频、高风险的操作要长。

反馈是 Dwell 的一部分,不是装饰。没有进度的等待对用户就是卡顿,环形进度、径向放大这类反馈直接决定了 Dwell 能不能用。

滤波延迟和抖动要一起调。平滑窗口开大压抖动,但同时增加延迟;视线交互对延迟的敏感度比想象中高,找到平衡点需要配合真机实测,纸面参数不可靠。

一定要留开关。眼动追踪的个体差异很大,戴眼镜、眼睛疲劳、光线变化都会影响精度。给用户一个"关闭视线触发,改用手势"的入口,比强行优化所有边缘情况划算。

容易出问题的地方O

计时跨目标累积。注视在 A 停了 300ms,移到 B 又停 300ms,如果计时不清零,B 会在 600ms 时被误触发。这是最常见的实现 bug,务必在目标切换时归零。

反馈层拦截了命中测试。动画、进度环、粒子效果这些叠加在目标上的图层,如果没有设置 hitTestBehavior(HitTestMode.None),会把原本该落到目标上的交互"截胡",表现出来就是"看着明明点中了却没反应"。

忘记处理数据缺失。用户眨眼、追踪丢失(比如手遮住摄像头)时,数据流会中断。如果不做超时和重连处理,Dwell 计时会用旧数据继续跑,导致莫名其妙的触发。数据中断超过 100ms 就该清除状态。

用线性插值处理注视点。注视点从 A 平滑移到 B 的过程中,路径上会经过中间的目标,如果对插值结果做命中判定,会连续触发一串目标。要么只在"注视事件"级别判定,要么对插值过程做屏蔽。

忽略可访问性。不是所有人的眼动特征都标准,也不是所有设备都有高质量眼动追踪。视线交互必须有一条不依赖它的降级路径,否则就是把一部分用户挡在门外。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。