本文是《React Native x HarmonyOS NEXT 创新能力接入方案》系列第 8 篇。
前面几篇我们已经完成了 TTS、OCR、端侧 AI、小艺智能体、碰一碰分享等能力的封装。本文继续探索 HarmonyOS 的交互感知能力:在 React Native 页面中接入智感握姿,让页面根据用户左右手握持状态自动调整悬浮按钮位置。

源码:https://atomgit.com/huqi/RNHarmonySkuAssistant

本篇我们要做一个 React Native 商品图片编辑页

页面中有一个悬浮操作按钮,用于打开 OCR、抠图、TTS 等快捷功能。默认情况下,按钮固定在页面右下角。

接入 HarmonyOS 智感握姿能力后,应用可以根据用户当前握持状态,自动调整按钮位置:

右手握持 → 悬浮按钮靠右显示
左手握持 → 悬浮按钮靠左显示
未知状态 → 使用默认位置

最终效果不是单纯调用一个原生方法,而是实现一个 原生事件主动通知 React Native 页面更新 UI 的交互链路:

HarmonyOS 智感握姿能力
    ↓
ArkTS 原生模块监听握持状态
    ↓
RNOH EventEmitter 向 RN 发送事件
    ↓
React Native 页面接收事件
    ↓
自动调整悬浮按钮位置

2. 业务场景设计:商品图编辑页的单手操作优化

本系列的统一 Demo 是:

RN Harmony SKU Assistant

本文把智感握姿能力放到 商品图编辑页 中。

页面场景如下:

用户正在查看或编辑商品图
    ↓
页面右下角有一个悬浮快捷按钮
    ↓
按钮可以打开 OCR、抠图、TTS、分享等工具
    ↓
如果用户左手握持手机,按钮自动移动到左下角
    ↓
如果用户右手握持手机,按钮自动移动到右下角

商品图编辑、拍照识别、SKU 信息查看等页面,往往需要用户单手操作。大屏手机上,如果按钮固定在右下角,左手使用时会很难点到。

智感握姿可以让这个体验更自然。


3. 技术方案总览

本文采用三层结构:

React Native 页面层
    ├── 展示商品图
    ├── 展示悬浮按钮
    ├── 监听握姿事件
    └── 根据 handType 更新按钮位置

RNOH 桥接层
    ├── NativeSmartSensingModule
    ├── startListening()
    ├── stopListening()
    └── onHandPoseChanged 事件

HarmonyOS ArkTS 原生层
    ├── 初始化智感握姿监听
    ├── 接收左右手握持状态
    ├── 统一转换成业务字段
    └── 发送事件给 RN

事件结构设计如下:

export type HandPose = 'left' | 'right' | 'unknown';

export interface HandPoseEvent {
  handPose: HandPose;
  confidence?: number;
  timestamp: number;
}

4. 运行截图

Screenshot_20260625005129856

Screenshot_20260625005139007

Screenshot_20260625005146320

Screenshot_20260625005155868


5. 定义 RN 侧原生模块接口

新建文件:

src/native/NativeSmartSensingModule.ts

示例代码:

import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

export type HandPose = 'left' | 'right' | 'unknown';

export type HandPoseEvent = {
  handPose: HandPose;
  confidence?: number;
  timestamp: number;
};

export interface Spec extends TurboModule {
  startListening(): Promise<boolean>;
  stopListening(): Promise<boolean>;
  getCurrentHandPose(): Promise<HandPoseEvent>;
  mockLeftHand(): Promise<HandPoseEvent>;
  mockRightHand(): Promise<HandPoseEvent>;
  mockUnknownHand(): Promise<HandPoseEvent>;
  addListener(eventName: string): void;
  removeListeners(count: number): void;
}

export default TurboModuleRegistry.get<Spec>('SmartSensingModule');

这里特意保留了 3 个 方法:

mockLeftHand()
mockRightHand()
mockUnknownHand()

先把 RN 页面变化 + TurboModule 调用链路 跑通。


6. RN 页面实现:悬浮按钮根据握姿自动移动

新建页面:

src/pages/SmartSensingPage.tsx

示例代码:

import React, { useEffect, useMemo, useState } from 'react';
import {
  NativeEventEmitter,
  Pressable,
  SafeAreaView,
  StyleSheet,
  Text,
  View,
} from 'react-native';

import SmartSensingModule, {
  HandPose,
  HandPoseEvent,
} from '../native/NativeSmartSensingModule';

type Props = { onBack: () => void };
type FloatingSide = 'left' | 'right';

export const getFloatingSide = (handPose: HandPose): FloatingSide =>
  handPose === 'left' ? 'left' : 'right';

export default function SmartSensingPage({ onBack }: Props) {
  const [handPose, setHandPose] = useState<HandPose>('unknown');
  const [lastEvent, setLastEvent] = useState<HandPoseEvent | null>(null);
  const [isListening, setIsListening] = useState(false);
  const [errorMessage, setErrorMessage] = useState('');

  const applyEvent = (event: HandPoseEvent) => {
    setHandPose(event.handPose);
    setLastEvent(event);
  };

  useEffect(() => {
    if (!SmartSensingModule) {
      setErrorMessage('SmartSensingModule 未注册');
      return;
    }

    const nativeModule = SmartSensingModule;
    const emitter = new NativeEventEmitter(nativeModule);
    const subscription = emitter.addListener('onHandPoseChanged', applyEvent);
    nativeModule.startListening()
      .then(setIsListening)
      .catch(error => setErrorMessage(String(error)));

    return () => {
      subscription.remove();
      nativeModule.stopListening().catch(() => undefined);
    };
  }, []);

  const floatingStyle = useMemo(
    () => getFloatingSide(handPose) === 'left' ? styles.floatLeft : styles.floatRight,
    [handPose],
  );

  const runMock = async (pose: HandPose) => {
    if (!SmartSensingModule) {
      setErrorMessage('SmartSensingModule 未注册');
      return;
    }
    try {
      const event = pose === 'left'
        ? await SmartSensingModule.mockLeftHand()
        : pose === 'right'
          ? await SmartSensingModule.mockRightHand()
          : await SmartSensingModule.mockUnknownHand();
      applyEvent(event);
    } catch (error) {
      setErrorMessage(String(error));
    }
  };

  const toggleListening = async () => {
    if (!SmartSensingModule) {
      return;
    }
    try {
      const next = isListening
        ? !await SmartSensingModule.stopListening()
        : await SmartSensingModule.startListening();
      setIsListening(next);
    } catch (error) {
      setErrorMessage(String(error));
    }
  };

  return (
    <SafeAreaView style={styles.safeArea}>
      <View style={styles.container}>
        <Pressable style={styles.backButton} onPress={onBack}>
          <Text style={styles.backText}>← 返回</Text>
        </Pressable>
        <Text style={styles.title}>智感握姿交互</Text>
        <Text style={styles.subtitle}>真实握持手状态驱动悬浮工具按钮左右换边;下方按钮仍可用于调试回退链路。</Text>

        <View style={styles.preview}>
          <Text style={styles.previewTitle}>商品图片预览</Text>
          <Text testID="current-hand-pose" style={styles.previewText}>当前握姿:{handPose}</Text>
          <Text style={styles.previewText}>监听状态:{isListening ? '监听中' : '已停止'}</Text>
          {lastEvent && <Text style={styles.previewText}>置信度:{lastEvent.confidence ?? '-'}</Text>}
          {!!errorMessage && <Text style={styles.errorText}>{errorMessage}</Text>}
          <Pressable testID="floating-tool-button" style={[styles.floatingButton, floatingStyle]}>
            <Text style={styles.floatingButtonText}>工具</Text>
          </Pressable>
        </View>

        <Pressable style={styles.listenButton} onPress={toggleListening}>
          <Text style={styles.listenButtonText}>{isListening ? '停止监听' : '开始监听'}</Text>
        </Pressable>
        <View style={styles.mockRow}>
          <Pressable testID="mock-left-button" style={styles.mockButton} onPress={() => runMock('left')}>
            <Text style={styles.mockButtonText}>模拟左手</Text>
          </Pressable>
          <Pressable style={styles.mockButton} onPress={() => runMock('unknown')}>
            <Text style={styles.mockButtonText}>未知</Text>
          </Pressable>
          <Pressable testID="mock-right-button" style={styles.mockButton} onPress={() => runMock('right')}>
            <Text style={styles.mockButtonText}>模拟右手</Text>
          </Pressable>
        </View>
      </View>
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  safeArea: { flex: 1, backgroundColor: '#F7F8FA' },
  container: { flex: 1, padding: 20, paddingTop: 12 },
  backButton: { alignSelf: 'flex-start', paddingVertical: 8 },
  backText: { color: '#0A59F7', fontWeight: '700' },
  title: { marginTop: 4, fontSize: 26, fontWeight: '700', color: '#111827' },
  subtitle: { marginTop: 8, fontSize: 15, color: '#6B7280' },
  preview: { position: 'relative', height: 330, marginTop: 24, padding: 20, borderRadius: 16, backgroundColor: '#E5E7EB' },
  previewTitle: { fontSize: 20, fontWeight: '700', color: '#374151' },
  previewText: { marginTop: 8, fontSize: 14, color: '#6B7280' },
  errorText: { marginTop: 8, fontSize: 13, color: '#B91C1C' },
  floatingButton: { position: 'absolute', bottom: 20, width: 64, height: 64, borderRadius: 32, alignItems: 'center', justifyContent: 'center', backgroundColor: '#0A59F7' },
  floatLeft: { left: 20 },
  floatRight: { right: 20 },
  floatingButtonText: { color: '#FFFFFF', fontWeight: '700' },
  listenButton: { marginTop: 18, paddingVertical: 13, borderRadius: 10, alignItems: 'center', backgroundColor: '#111827' },
  listenButtonText: { color: '#FFFFFF', fontWeight: '700' },
  mockRow: { flexDirection: 'row', gap: 10, marginTop: 12 },
  mockButton: { flex: 1, paddingVertical: 12, borderRadius: 10, alignItems: 'center', backgroundColor: '#FFFFFF' },
  mockButtonText: { color: '#0A59F7', fontWeight: '700' },
});


7. RN 事件监听说明

这里核心代码是:

const eventEmitter = new NativeEventEmitter(
  NativeModules.SmartSensingModule,
);

const subscription = eventEmitter.addListener(
  'onHandPoseChanged',
  (event: HandPoseEvent) => {
    setHandPose(event.handPose);
    setLastEvent(event);
  },
);

这说明原生侧需要向 RN 发送一个事件:

onHandPoseChanged

事件数据格式为:

{
  "handPose": "left",
  "confidence": 0.92,
  "timestamp": 1760000000000
}

RN 页面只关心业务字段,不应该直接依赖 HarmonyOS 原始返回值。

因此建议在 ArkTS 层做一次统一转换。


8. ArkTS 原生模块实现

新建文件:

harmony/entry/src/main/ets/turbomodule/SmartSensingModule.ets

示例结构如下:

import { BusinessError } from '@kit.BasicServicesKit';
import { motion } from '@kit.MultimodalAwarenessKit';
import { UITurboModule, UITurboModuleContext } from '@rnoh/react-native-openharmony';

type HandPose = 'left' | 'right' | 'unknown';

interface HandPoseEvent {
  handPose: HandPose;
  confidence?: number;
  timestamp: number;
}

export class SmartSensingModule extends UITurboModule {
  static readonly NAME = 'SmartSensingModule';

  private currentHandPose: HandPose = 'unknown';
  private isListening: boolean = false;
  private listenerCount: number = 0;
  private lastConfidence: number = 0;
  private readonly holdingHandCallback = (status: motion.HoldingHandStatus): void => {
    let nextHandPose: HandPose = 'unknown';
    let nextConfidence = 0;

    if (status === motion.HoldingHandStatus.LEFT_HAND_HELD) {
      nextHandPose = 'left';
      nextConfidence = 1;
    } else if (status === motion.HoldingHandStatus.RIGHT_HAND_HELD) {
      nextHandPose = 'right';
      nextConfidence = 1;
    }

    this.updateHandPose(nextHandPose, nextConfidence);
  };

  constructor(ctx: UITurboModuleContext) {
    super(ctx);
  }

  async startListening(): Promise<boolean> {
    if (this.isListening) {
      return true;
    }

    if (!canIUse('SystemCapability.MultimodalAwareness.Motion')) {
      throw Error('当前系统不支持动作感知能力。');
    }

    try {
      motion.on('holdingHandChanged', this.holdingHandCallback);
    } catch (err) {
      const error = err as BusinessError;
      if (error.code === 801) {
        throw Error('当前机型未开启或不支持智感握姿能力。');
      }
      if (error.code === 201) {
        throw Error('系统拒绝访问握持手状态,请检查 DETECT_GESTURE 权限声明。');
      }
      throw Error(`订阅握持手状态失败:${error.code}`);
    }

    this.isListening = true;
    return true;
  }

  async stopListening(): Promise<boolean> {
    if (this.isListening) {
      try {
        motion.off('holdingHandChanged', this.holdingHandCallback);
      } catch (err) {
        const error = err as BusinessError;
        if (error.code !== 801) {
          throw Error(`取消握持手订阅失败:${error.code}`);
        }
      }
    }

    this.isListening = false;
    return true;
  }

  async getCurrentHandPose(): Promise<HandPoseEvent> {
    return this.buildEvent(this.currentHandPose, this.lastConfidence);
  }

  async mockLeftHand(): Promise<HandPoseEvent> {
    return this.updateHandPose('left', 0.95);
  }

  async mockRightHand(): Promise<HandPoseEvent> {
    return this.updateHandPose('right', 0.96);
  }

  async mockUnknownHand(): Promise<HandPoseEvent> {
    return this.updateHandPose('unknown', 0);
  }

  addListener(_eventName: string): void {
    this.listenerCount += 1;
  }

  removeListeners(count: number): void {
    this.listenerCount = Math.max(0, this.listenerCount - count);
  }

  private updateHandPose(handPose: HandPose, confidence: number): HandPoseEvent {
    this.currentHandPose = handPose;
    this.lastConfidence = confidence;
    const event = this.buildEvent(handPose, confidence);
    if (this.isListening && this.listenerCount > 0) {
      this.ctx.rnInstance.emitDeviceEvent('onHandPoseChanged', event);
    }
    return event;
  }

  private buildEvent(handPose: HandPose, confidence: number): HandPoseEvent {
    return { handPose, confidence, timestamp: Date.now() };
  }

  __onDestroy__(): void {
    this.isListening = false;
    this.listenerCount = 0;
    super.__onDestroy__();
  }
}

注意:

this.ctx.rnInstance.emitDeviceEvent('onHandPoseChanged', event);

这行代码在不同 RNOH 版本中可能存在 API 差异。实际项目中请以当前 RNOH 模板或官方示例中的事件发送方式为准。

本文重点关注的是设计模式:

原生感知状态
    ↓
转换成统一业务事件
    ↓
发送给 RN
    ↓
RN 更新页面

9. 注册 SmartSensingModule(RNOH 0.84.1 实装)

本工程的自定义 TurboModule 采用 ArkTS 实现 + C++ JSI 元数据 双层注册。第一层在 harmony/entry/src/main/ets/GeneratedPackage.ets 注册:

import { SmartSensingModule } from './turbomodule/SmartSensingModule';

return new Map([
  [SmartSensingModule.NAME, (ctx) => new SmartSensingModule(ctx)],
]);

第二层由 SmartSensingModule.cpp 声明异步方法和 EventEmitter 协议方法:

methodMap_ = {
    ARK_ASYNC_METHOD_METADATA(startListening, 0),
    ARK_ASYNC_METHOD_METADATA(stopListening, 0),
    ARK_ASYNC_METHOD_METADATA(getCurrentHandPose, 0),
    ARK_ASYNC_METHOD_METADATA(mockLeftHand, 0),
    ARK_ASYNC_METHOD_METADATA(mockRightHand, 0),
    ARK_ASYNC_METHOD_METADATA(mockUnknownHand, 0),
    ARK_SCHEDULE_METHOD_METADATA(addListener, 1),
    ARK_SCHEDULE_METHOD_METADATA(removeListeners, 1),
};

SmartSensingPackage.h 创建 C++ TurboModule,并在 PackageProvider.cpp 注册;对应 .cpp 必须加入 CMakeLists.txt。RN spec、C++ name、ArkTS NAME 和事件订阅模块名均为 SmartSensingModule


10. 错误码设计

建议不要只返回字符串错误,而是定义统一错误码:

export enum SmartSensingErrorCode {
  NOT_SUPPORTED = 'SMART_SENSING_NOT_SUPPORTED',
  PERMISSION_DENIED = 'SMART_SENSING_PERMISSION_DENIED',
  START_FAILED = 'SMART_SENSING_START_FAILED',
  STOP_FAILED = 'SMART_SENSING_STOP_FAILED',
  UNKNOWN = 'SMART_SENSING_UNKNOWN',
}

RN 页面可以根据错误码做提示:

try {
  await SmartSensingModule.startListening();
} catch (error) {
  Alert.alert('当前设备暂不支持智感握姿');
}

建议在真实项目中区分:

设备不支持
权限未开启
能力初始化失败
监听已开启
监听已停止
未知异常


11. 常见问题

11.1 RN 页面收不到事件

优先检查:

1. NativeEventEmitter 使用的模块名是否正确
2. 原生侧事件名是否为 onHandPoseChanged
3. 模块是否已注册到 RNOH
4. 原生 emit 方法是否符合当前 RNOH 版本
5. Metro 是否刷新了最新 JS 代码

11.2 点击 Mock 方法有返回,但 UI 不更新

检查:

1. setHandPose 是否被调用
2. handPose 字段是否为 left / right / unknown
3. floatingButtonStyle 是否依赖 handPose
4. StyleSheet 中 left 和 right 是否互斥

尤其注意:

return styles.floatLeft;

和:

return styles.floatRight;

不要同时给按钮设置 leftright


11.3 真机不支持智感握姿

这种情况很正常。

不是所有设备、系统版本、场景都支持该能力。

建议文章里明确提示:

如果当前设备不支持智感握姿,请先使用 Mock 方式验证 RN/RNOH 通信链路。
真实能力需要以具体设备、系统版本和官方开放能力为准。

11.4 原生监听需要在页面销毁时释放

RN 页面卸载时需要调用:

subscription.remove();
SmartSensingModule.stopListening();

原生侧也要释放系统监听,避免内存泄漏或重复回调。


12. 后续可复用封装建议

建议不要把智感握姿写死在一个页面里,而是封装成 Hook:

src/hooks/useHandPose.ts

示例:

import { useEffect, useState } from 'react';
import { NativeEventEmitter, NativeModules } from 'react-native';
import SmartSensingModule, {
  HandPose,
  HandPoseEvent,
} from '../native/NativeSmartSensingModule';

const eventEmitter = new NativeEventEmitter(
  NativeModules.SmartSensingModule,
);

export function useHandPose() {
  const [handPose, setHandPose] = useState<HandPose>('unknown');
  const [lastEvent, setLastEvent] = useState<HandPoseEvent | null>(null);

  useEffect(() => {
    SmartSensingModule.startListening().catch(() => undefined);

    const subscription = eventEmitter.addListener(
      'onHandPoseChanged',
      (event: HandPoseEvent) => {
        setHandPose(event.handPose);
        setLastEvent(event);
      },
    );

    return () => {
      subscription.remove();
      SmartSensingModule.stopListening().catch(() => undefined);
    };
  }, []);

  return {
    handPose,
    lastEvent,
  };
}

页面中直接使用:

const { handPose } = useHandPose();

这样未来可以复用到:

商品图编辑页
拍照识别页
OCR 扫描页
表单填写页
大屏工具页

13. 本篇小结

仓库验证结果(2026-06-24)

npm test -- --runInBand
npm run lint
npx tsc --noEmit
cd harmony && /Applications/DevEco-Studio.app/Contents/tools/hvigor/bin/hvigorw \
  --mode module -p module=entry@default -p product=default assembleHap --no-daemon

自动化测试 4/4 通过,HAP 编译、打包、签名和真机安装成功。nova 16 真机验证了 unknown → left → right 状态变化、置信度返回和悬浮按钮换边;ArkTS 模块实现监听状态、listener 计数、事件发送和销毁清理。真实传感事件、支持型号、权限与系统回调仍需按官方开放 API 继续验证。

本文完成了 React Native 接入 HarmonyOS 智感握姿能力的基础设计。

我们重点实现了:

ArkTS 原生模块
    ↓
监听 握姿状态
    ↓
通过 RNOH 发送事件
    ↓
RN 页面接收事件
    ↓
悬浮按钮自动左右切换

这样我们也完成了挑战,为后续 RNOH 的开发打下了基础:

让 React Native 页面不只是主动调用鸿蒙原生能力,还能接收鸿蒙系统侧主动推送的交互感知事件。

这类模式后续也可以用于:

设备状态变化
网络状态变化
多设备连接状态
系统事件通知
传感器状态变化
跨设备流转状态

14. 下一篇预告

下一篇我们可能会继续写系统级协同能力:

RNOH x HarmonyOS 跨设备流转:手机到平板继续编辑商品资料

下一篇会实现:

RN 商品编辑页
    ↓
整理当前商品草稿数据
    ↓
调用 ArkTS 原生流转模块
    ↓
触发 HarmonyOS 跨设备流转
    ↓
另一台设备恢复商品编辑页面

重点会讲:

跨设备传递什么数据
RN 页面状态如何序列化
ArkTS 原生流转能力如何封装
接收端如何恢复页面
没有多设备时如何用 Mock 方式演示
Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐