HarmonyOS 手表端翻腕触发交互实现:基于陀螺仪的两段式手势识别

HarmonyOS 手表端翻腕触发交互实现:基于陀螺仪的两段式手势识别

背景

手表端交互空间很小,很多手机端常见操作并不适合直接搬到表盘上。例如学习答题场景里,原本可以通过按钮、三击空白、双指手势触发某个快捷动作,但在手表上,用户更自然的操作可能是轻微翻腕。

本文记录一种通用实现:使用 HarmonyOS 的陀螺仪传感器识别"翻腕回来"的动作,并触发业务逻辑。这里以"斩"动作为例,实际也可以替换成提交、跳过、收藏、暂停等任意业务方法。

核心目标:

  • 只在目标页面或组件显示时监听传感器。
  • 第一下翻腕只进入待触发状态,不立即触发。
  • 手腕回来的那一下才触发。
  • 加防抖和冷却,避免连续误触发。
  • 离开页面、打开遮挡层时及时取消监听。

权限配置

module.json5 中声明陀螺仪权限:

json5 复制代码
{
  "module": {
    "requestPermissions": [
      {
        "name": "ohos.permission.GYROSCOPE",
        "reason": "$string:EntryAbility_label",
        "usedScene": {
          "abilities": ["EntryAbility"],
          "when": "inuse"
        }
      }
    ]
  }
}

如果后续要融合加速度计判断姿态,也可以同时声明:

json5 复制代码
{
  "name": "ohos.permission.ACCELEROMETER",
  "reason": "$string:EntryAbility_label",
  "usedScene": {
    "abilities": ["EntryAbility"],
    "when": "inuse"
  }
}

本文实现只依赖 GYROSCOPE

识别思路

直接使用"角速度大于阈值就触发"会有一个问题:手腕向下甩的第一段也会触发。实际体验上,我们希望用户"翻腕回来"的那一下才触发动作。

所以采用两段式识别:

  1. 检测到第一段明显翻腕:记录主轴、方向和时间,只进入 armed 状态。
  2. 在指定时间窗口内,检测到同一主轴的反方向回摆。
  3. 回摆的瞬时角速度和累计角度都达标后,触发业务方法。
  4. 触发后进入冷却期,避免一次动作重复触发。

关键参数

ts 复制代码
const WRIST_GYRO_INTERVAL_NS: number = 30000000; // 30ms 采样
const WRIST_RETURN_WINDOW_MS: number = 900; // 第一下翻腕后等待回摆的窗口
const WRIST_TRIGGER_COOLDOWN_MS: number = 2000; // 成功触发后的冷却时间
const WRIST_MIN_ANGULAR_SPEED: number = 4.2; // rad/s,瞬时角速度阈值
const WRIST_MIN_RETURN_ANGLE: number = 0.75; // rad,回摆累计角度阈值

参数含义:

  • WRIST_RETURN_WINDOW_MS:第一段翻腕后多久内必须回来。
  • WRIST_TRIGGER_COOLDOWN_MS:触发成功后多久内不再触发。
  • WRIST_MIN_ANGULAR_SPEED:单次采样的主轴角速度门槛。
  • WRIST_MIN_RETURN_ANGLE:回摆阶段累计旋转角度门槛。

核心代码

下面代码是通用版,可以放在 ArkUI 组件中。业务触发点是 onWristReturnGesture(),替换成自己的业务方法即可。

ts 复制代码
import { sensor } from '@kit.SensorServiceKit';

const WRIST_GYRO_INTERVAL_NS: number = 30000000;
const WRIST_RETURN_WINDOW_MS: number = 900;
const WRIST_TRIGGER_COOLDOWN_MS: number = 2000;
const WRIST_MIN_ANGULAR_SPEED: number = 4.2;
const WRIST_MIN_RETURN_ANGLE: number = 0.75;

interface GyroscopeSample {
  x: number;
  y: number;
  z: number;
}

interface DominantMotion {
  axis: number; // 0=x, 1=y, 2=z
  absSpeed: number;
  direction: number; // 1 或 -1
}

@Component
struct WristGestureDemo {
  private gyroActive: boolean = false;
  private armedAt: number = 0;
  private lastSampleAt: number = 0;
  private returnAccumulatedAngle: number = 0;
  private armedAxis: number = -1;
  private armedDirection: number = 0;
  private lastTriggerAt: number = 0;

  private onGyroscopeChange = (data: GyroscopeSample): void => {
    this.handleGyroscopeSample(data);
  };

  aboutToAppear(): void {
    this.startGyroscope();
  }

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

  private startGyroscope(): void {
    if (this.gyroActive) {
      return;
    }
    this.resetWristWindow();
    try {
      sensor.on(sensor.SensorId.GYROSCOPE, this.onGyroscopeChange, {
        interval: WRIST_GYRO_INTERVAL_NS
      });
      this.gyroActive = true;
    } catch (e) {
      this.gyroActive = false;
      console.warn('start gyroscope failed: ' + JSON.stringify(e));
    }
  }

  private stopGyroscope(): void {
    if (!this.gyroActive) {
      return;
    }
    try {
      sensor.off(sensor.SensorId.GYROSCOPE, this.onGyroscopeChange);
    } catch (e) {
      try {
        sensor.off(sensor.SensorId.GYROSCOPE);
      } catch (ignore) {
      }
    }
    this.gyroActive = false;
    this.resetWristWindow();
  }

  private resetWristWindow(now: number = Date.now()): void {
    this.armedAt = 0;
    this.lastSampleAt = now;
    this.returnAccumulatedAngle = 0;
    this.armedAxis = -1;
    this.armedDirection = 0;
  }

  private getDominantMotion(data: GyroscopeSample): DominantMotion {
    let axis: number = 0;
    let signedSpeed: number = data.x;
    let absSpeed: number = Math.abs(data.x);
    const absY: number = Math.abs(data.y);
    const absZ: number = Math.abs(data.z);

    if (absY > absSpeed) {
      axis = 1;
      signedSpeed = data.y;
      absSpeed = absY;
    }
    if (absZ > absSpeed) {
      axis = 2;
      signedSpeed = data.z;
      absSpeed = absZ;
    }

    return {
      axis: axis,
      absSpeed: absSpeed,
      direction: signedSpeed >= 0 ? 1 : -1
    };
  }

  private armReturnGesture(now: number, motion: DominantMotion): void {
    this.armedAt = now;
    this.lastSampleAt = now;
    this.returnAccumulatedAngle = 0;
    this.armedAxis = motion.axis;
    this.armedDirection = motion.direction;
  }

  private handleGyroscopeSample(data: GyroscopeSample): void {
    const now: number = Date.now();

    if (now - this.lastTriggerAt < WRIST_TRIGGER_COOLDOWN_MS) {
      this.resetWristWindow(now);
      return;
    }

    const motion: DominantMotion = this.getDominantMotion(data);

    if (motion.absSpeed < WRIST_MIN_ANGULAR_SPEED) {
      if (this.armedAt > 0 && now - this.armedAt > WRIST_RETURN_WINDOW_MS) {
        this.resetWristWindow(now);
      }
      return;
    }

    if (this.armedAt <= 0 || now - this.armedAt > WRIST_RETURN_WINDOW_MS) {
      this.armReturnGesture(now, motion);
      return;
    }

    if (motion.axis !== this.armedAxis) {
      this.armReturnGesture(now, motion);
      return;
    }

    if (motion.direction === this.armedDirection) {
      this.returnAccumulatedAngle = 0;
      this.lastSampleAt = now;
      return;
    }

    const dtSec: number = Math.max(0.01, (now - this.lastSampleAt) / 1000);
    this.lastSampleAt = now;
    this.returnAccumulatedAngle += motion.absSpeed * dtSec;

    this.tryTriggerWristReturn(now, motion.absSpeed);
  }

  private tryTriggerWristReturn(now: number, maxAngularSpeed: number): void {
    if (maxAngularSpeed < WRIST_MIN_ANGULAR_SPEED ||
      this.returnAccumulatedAngle < WRIST_MIN_RETURN_ANGLE) {
      return;
    }
    this.lastTriggerAt = now;
    this.resetWristWindow(now);
    this.onWristReturnGesture();
  }

  private onWristReturnGesture(): void {
    // 在这里写自己的业务逻辑,例如:
    // submitAnswer();
    // skipCurrentItem();
    // performSlash();
    console.info('wrist return gesture triggered');
  }

  build() {
    Column() {
      Text('Wrist gesture demo')
        .fontSize(14);
    }
    .width('100%')
    .height('100%');
  }
}

接入业务时的保护条件

真实业务里,触发前通常还需要判断状态。以答题场景为例:

ts 复制代码
private onWristReturnGesture(): void {
  if (this.isLocked) {
    return;
  }
  if (this.hasTappedOption) {
    return;
  }
  if (this.isOverlayShowing) {
    return;
  }
  this.performSlash();
}

也可以把这些条件放到 handleGyroscopeSample() 的开头:

ts 复制代码
if (this.isLocked || this.hasTappedOption || this.isOverlayShowing) {
  this.resetWristWindow();
  return;
}

这样能避免用户已经点过选项、页面被弹层遮挡、动画正在播放时再次触发。

防抖设计

这套实现有几层防抖:

  • 生命周期防抖:组件出现时订阅,组件消失时退订。
  • 回摆防抖:第一段动作只 armed,不触发。
  • 方向防抖:只有同一主轴的反方向回摆才触发。
  • 时间防抖:回摆必须在 900ms 内发生。
  • 冷却防抖:触发后 2000ms 内不再触发。
  • 阈值防抖:瞬时角速度和累计角度必须同时达标。

调参建议

不同设备的陀螺仪灵敏度不同,建议真机调参。

如果不够灵敏:

  • WRIST_MIN_ANGULAR_SPEED4.2 降到 3.5
  • WRIST_MIN_RETURN_ANGLE0.75 降到 0.55
  • WRIST_RETURN_WINDOW_MS900 提到 1100

如果容易误触发:

  • WRIST_MIN_ANGULAR_SPEED 提到 4.8
  • WRIST_MIN_RETURN_ANGLE 提到 0.95
  • WRIST_TRIGGER_COOLDOWN_MS 提到 2400

测试清单

建议至少验证:

  1. 单独向下甩手腕不触发。
  2. 向下后回来的那一下触发。
  3. 普通抬手不触发。
  4. 转表冠不触发。
  5. 轻微晃动不触发。
  6. 成功触发后 2 秒内不会重复触发。
  7. 离开页面后不再监听传感器。
  8. 弹窗或遮挡层显示时不触发业务动作。

小结

手表端动作识别不要只看"速度大不大",还要看动作结构。对于"翻腕回来才触发"这种交互,两段式识别会比单阈值判断稳定得多:第一段负责蓄势,第二段反向回摆才真正触发,再配合冷却和生命周期管理,就能得到比较稳的体验。

相关推荐
小北的AI科技分享1 小时前
手机AI应用如何改变我们的日常交互方式
交互·应用·隐私
Swift社区1 小时前
AI + 鸿蒙游戏:下一代交互革命
人工智能·游戏·harmonyos
2601_961194021 小时前
27考研刘晓燕资源
linux·sql·ubuntu·华为·pdf·.net
ZC跨境爬虫9 小时前
跟着 MDN 学CSS day_41:显式轨道、隐式网格与区域命名放置
前端·javascript·css·ui·交互
AI品信智慧数智人12 小时前
打破大屏局限!山东品信智慧科技数字人交互系统,实现可视化实时数据联动✨
科技·交互
yuegu77712 小时前
HarmonyOS应用<节气通>开发第2篇:首页开发(上)——Tabs架构与骨架搭建
华为·harmonyos
程序猿追16 小时前
HarmonyOS——模拟器上写个扫雷的夜晚
华为·harmonyos
G_dou_16 小时前
Flutter三方库适配OpenHarmony【bmi_calculator】BMI 计算器项目完整实战
flutter·harmonyos
大雷神17 小时前
第41篇|补光与水印:效果选项如何参与最终照片记录
harmonyos