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。
识别思路
直接使用"角速度大于阈值就触发"会有一个问题:手腕向下甩的第一段也会触发。实际体验上,我们希望用户"翻腕回来"的那一下才触发动作。
所以采用两段式识别:
- 检测到第一段明显翻腕:记录主轴、方向和时间,只进入 armed 状态。
- 在指定时间窗口内,检测到同一主轴的反方向回摆。
- 回摆的瞬时角速度和累计角度都达标后,触发业务方法。
- 触发后进入冷却期,避免一次动作重复触发。
关键参数
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_SPEED从4.2降到3.5。WRIST_MIN_RETURN_ANGLE从0.75降到0.55。WRIST_RETURN_WINDOW_MS从900提到1100。
如果容易误触发:
WRIST_MIN_ANGULAR_SPEED提到4.8。WRIST_MIN_RETURN_ANGLE提到0.95。WRIST_TRIGGER_COOLDOWN_MS提到2400。
测试清单
建议至少验证:
- 单独向下甩手腕不触发。
- 向下后回来的那一下触发。
- 普通抬手不触发。
- 转表冠不触发。
- 轻微晃动不触发。
- 成功触发后 2 秒内不会重复触发。
- 离开页面后不再监听传感器。
- 弹窗或遮挡层显示时不触发业务动作。
小结
手表端动作识别不要只看"速度大不大",还要看动作结构。对于"翻腕回来才触发"这种交互,两段式识别会比单阈值判断稳定得多:第一段负责蓄势,第二段反向回摆才真正触发,再配合冷却和生命周期管理,就能得到比较稳的体验。