RNOH x HarmonyOS 智感握姿:原生事件驱动 RN UI 自适应
本文是《React Native x HarmonyOS NEXT 创新能力接入方案》系列第 8 篇。
前面几篇我们已经完成了 TTS、OCR、端侧 AI、小艺智能体、碰一碰分享等能力的封装。本文继续探索 HarmonyOS 的交互感知能力:在 React Native 页面中接入智感握姿,让页面根据用户左右手握持状态自动调整悬浮按钮位置。
本篇我们要做一个 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. 运行截图




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;
不要同时给按钮设置 left 和 right。
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 方式演示
更多推荐

所有评论(0)