跨端 RN 鸿蒙碰一碰分享库实战:基于 Share Kit 封装 react-native-ohos-knock-share,实现卡片、文件一碰传
示例仓库:https://atomgit.com/dijun_520/react-native-ohos-knock-share
社区引导:框架源码与版本发布见 https://atomgit.com/CPF-RN/ohos_react_native/tree/0.84-main
CPF-RN 鸿蒙社区:https://atomgit.com/CPF-rn
环境版本
| 项目 | 版本 |
|---|---|
| 开发工具 | DevEco Studio 26.0(内置 SDK:DevEco Studio/sdk) |
| 系统 API | HarmonyOS 7.0.0 / API 26(compatibleSdkVersion: 26.0.0) |
| React Native | 0.84.1 |
| RN 鸿蒙框架 | @react-native-oh/react-native-harmony 0.84.5(RNOH) |
| Node.js | ≥ 22.11.0 |
| 实测设备 | 华为(nova 14) |
| 碰一碰 API | Share Kit harmonyShare(API 20+ 对三方开放)、systemShare、fileUri(@kit.ShareKit / @kit.CoreFileKit) |
一、真实痛点:为什么碰一碰值得封装成 RN 三方库
先说场景。我们维护一个跨端协作类应用,高频需求是把"当前正在看的东西"递给身边的人:手机递给同事一份合同 PDF、把评审链接递给参会的人、把设计稿递给领导。传统做法有三条路,每条都不好走:
第一条:自建传输通道。 蓝牙要申请蓝牙权限、配对流程繁琐;局域网直传要双方在同一网段、写 socket 协议、处理断线重连;Wi-Fi P2P 在鸿蒙上对三方应用的开放程度有限。为了"递个文件"维护一套传输层,投入产出比极差。
第二条:系统分享面板。 调起系统分享面板让用户手动选接收人和方式——多了一次选择操作,在"面对面快速递文件"的场景里反而比扫码/搜索更慢。而且分享面板无法感知"对方就是眼前这台设备"。
第三条:等官方 ArkTS 方案,RN 业务干等。 碰一碰分享(华为分享 / Share Kit 的 knockShare 通道)是华为把这些底层传输全部收编后的系统级能力:NFC 或星闪感应触发,传输走系统通道,应用不申请任何权限、不实现任何传输协议,只负责两件事——触碰后 3 秒内"给内容"、接收侧"解析内容"。但官方文档与社区示例清一色是 ArkTS 原生写法,React Native 开发者找不到可用的封装,业务侧要么放弃这个特性,要么自己摸一遍 TurboModule 的坑。
第三个痛点才是我们动手的原因。RN 鸿蒙(RNOH)适配层已经把 TurboModule、事件桥、AutoLinking 打磨得比较成熟,缺的只是 Share Kit 这块能力的"最后一公里"。这一公里里有不少细节坑:3 秒发送窗口怎么兜底、windowId 与窗口焦点的关系、沙箱文件必须经 fileUri.getUriFromPath() 转换、接收 Want 的冷启动与热启动两条路径、模拟器无 NFC 时怎么演示验证……这些坑踩一遍要几天,封装成库之后业务接入只剩十几行代码。
另外一个容易被忽略的点:多端形态。鸿蒙 PC(2in1)从 API 26 起支持精准碰一碰,手机、平板、PC 三种形态下监听注册方式相同,但 PC 的自由多窗口引入了"窗口焦点"变量,Web 浏览器与非鸿蒙桌面则完全没有 NFC 硬件。一个业务要跑全场景,就需要库在内部把"原生碰一碰 / PC 适配 / Web 降级"的路由做掉。
二、先看效果:
2.1 单设备回环演示(动图,约 14 秒)
示例 App 启动后自动执行一次"回环演示":注册监听 → 模拟对端触碰 → 3 秒倒计时 → 发送 → 接收解析,一条龙跑完。碰一碰的触发依赖 NFC/星闪硬件,模拟器无法真实触碰,因此库内置了 simulateKnock():knockDetected 事件、3 秒窗口、SharedData 构造、接收解析全部走真实管线,仅 NFC 传输环节由本机回环替代。

2.2 状态机三态(真机实拍)
启动即探测,能力卡片逐项上报——syscap、设备形态、API 版本、沙箱直达接收,全部 ✓:

触碰发生后徽标转橙色"已触碰 · 3 秒内发送",倒计时实时收缩;倒计时归零前完成发送(下图徽标已回到绿色"监听中",最近接收区出现完整 JSON):

2.3 三种分享内容的接收端数据(模拟器实拍)
分享内容支持三种类型切换,接收端 getReceivedData() 返回的结构逐字段对照:
链接卡片(HYPERLINK + 标题 + 描述 + 缩略图,对应系统"沉浸式大卡"模板):

文件(rawfile 先落沙箱,再经 fileUri 转换为系统可传输 URI):

图片卡片(纯图布局模板,uri 与 thumbnailUri 双字段):

2.4 真机关键日志
事件日志完整记录了「页面挂载 → 监听注册(harmonyShare.on knockShare)→ 能力探测 → 模拟触碰 → knockDetected → 3 秒窗口内自动发送 → 接收成功」全链路,每条带时间戳,可直接对照后文原理图:

发送链路的完整时序与接收链路的多端适配矩阵如下:


结论:能力探测、监听注册、回环收发在真机与模拟器全通过;真实 NFC 触碰需要双端真机(或手机触碰 PC 的 NFC 触控区域),属硬件边界,不是代码问题。
实测数据汇总(均为本文实测,非推测值):
| 验证项 | 真机 TLR-AL00(API 26) |
|---|---|
| 能力探测(syscap/形态/API/沙箱接收) | 全 ✓(phone / 26) |
监听注册 harmonyShare.on knockShare | 成功,windowId 正常获取 |
| 回环演示(卡片/文件/图片三种内容) | 接收端分别解析出 general.hyperlink / general.file / general.image |
| 3 秒倒计时窗口 | knockDetected → 自动发送,徽标倒计时正常 |
| 真实 NFC 触碰 | 需双端真机背部轻触 |
三、原理:把 Share Kit 碰一碰拆成两条链路
碰一碰能力在华为官方文档中归属 Share Kit(应用接续与分享),官方资料见 https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/share-knock-share。本节按"发送"与"接收"两条链路拆解,下文代码分析会一一对应。
3.1 发送链路:knockShare 回调 → 3 秒 → share()
startListening()
→ window.getLastWindow(ctx) 取当前窗口 windowId
→ harmonyShare.on('knockShare', { windowId }, callback)
→ 对端轻触(NFC/星闪感应),系统回调 callback(SharableTarget)
→ 原生立即 emit 'knockDetected' 事件给 JS
→ JS 在 3 秒内调 send(items)
→ 构造 systemShare.SharedData(多条 SharedRecord)
→ sharableTarget.share(data) —— 交给系统传输
三条硬约束决定了封装的骨架:
- 3 秒铁律。官方约定触碰回调后超过 3 秒调
share()可能失败,对端会一直等待。所以原生层在回调里记录时间戳,JS 层 hook 里做倒计时兜底;超时或无内容时调target.reject(NO_CONTENT_ERROR)明确拒绝,让对端立刻收到失败反馈而不是干等转圈。 - windowId 决定监听归属。
harmonyShare.on('knockShare', registry, cb)的 registry 携带 windowId——系统只把触碰回调派发给持有焦点的窗口。手机单窗口下感知不强,但 PC/2in1 与平板自由多窗口下,用户点了别的窗口,原 windowId 的监听就收不到回调。这也意味着off必须传与on同一个 registry 对象,否则注销不生效。 - 沙箱文件必须转 URI。
SharedRecord.uri要的是系统能识别的file://URI,直接传沙箱路径字符串系统读不到。所以分享前统一走fileUri.getUriFromPath(path);而分享的文件又要先落在应用沙箱里(rawfile 内置素材先拷贝到filesDir),这一步由copyRawfileToSandbox封装。
3.2 接收链路:App Linking 调起 → getSharedData → 沙箱
对端收到的如果是链接(HYPERLINK),系统走 App Linking:https 链接直达接收方应用(module.json5 的 skills 声明 entity.system.browsable + 域名规则),Want 里携带分享数据。接收侧要同时处理两条路径:冷启动(应用未运行,onCreate(want))与热启动(应用在后台,onNewWant(want)),两条路径都调 systemShare.getSharedData(want) 解析出 SharedData,逐条取出 SharedRecord,写入 AppStorage 中转给 JS 层,JS 调 getReceivedData() 取走(消费即清空)。
如果是文件类内容,接收方走的是"沙箱直达接收"(dataReceive 通道,手机 API 12+ / 平板 API 23+ / PC API 20+),文件直接落应用沙箱,路径同样出现在解析结果里。
权限方面有个容易误解的点:碰一碰链路本身不需要声明任何分享相关权限,前提是双端开启华为分享服务(系统默认开启;用户手动关闭时轻碰会收到系统提示,应用侧没有公开的查询接口,官方文档明确说明了这一边界)。宿主工程通常只需要 ohos.permission.INTERNET(RNOH 运行与调试需要)。
3.3 卡片模板由字段决定
系统按 SharedRecord 的字段自动选卡片模板:只有 thumbnailUri → 纯图片布局;title + description + thumbnailUri(宽高比小于 1:1)→ 沉浸式大卡;HYPERLINK 无缩略图 → 纯文本布局。业务不需要选模板,只需要控制字段——这也是库把字段全部透传、不加工的原因。
3.4 多端路由与回环演示
库的平台路由在 JS 层一次收敛:鸿蒙环境(Platform.OS === 'harmony')走 TurboModule,PC/2in1 与手机同一套 API;浏览器与非鸿蒙桌面走 Web 降级适配层(Web Share API → 剪贴板),业务侧零分支。
回环演示是这套封装里最取巧的设计:simulateKnock() 在 JS 层直接本地分发 knockDetected 事件(不走原生事件桥),同时置位回环标记,下一次 send() 被路由到原生 sendLoopback——它与 send() 共用同一条 SharedData 构造管线(沙箱落盘 + fileUri 转换),但不要求存在 SharableTarget,构造结果写回接收通路(AppStorage → getReceivedData)。于是单设备、无 NFC 硬件也能演示"触碰 → 3 秒 → 发送 → 接收"的全流程,只有 NFC 传输这个物理环节被替代。这既是演示能力,也是开发期的自动化验证手段。
四、核心代码分析
代码全部在仓库 https://atomgit.com/CPF-rn/react-native-ohos-knock-share,目录结构:
├── src/
│ ├── index.ts # 公共 API:KnockShare 对象 + 事件订阅 + 平台路由
│ ├── NativeKnockShare.ts # TurboModule codegen spec
│ ├── hooks/useKnockShare.ts # React hook 状态机封装
│ └── adapters/web.ts # Web / 桌面降级实现(Web Share API + 剪贴板)
├── harmony/
│ └── knock_share/ # ArkTS HAR 源码模块(RNOH AutoLinking 自动链接)
│ └── src/main/ets/
│ ├── KnockSharePackage.ets # Package 注册入口
│ ├── KnockShareModule.ets # Share Kit 碰一碰实现
│ └── KnockShareReceiver.ets # 接收端助手(Want 解析 → AppStorage)
└── example/ # 演示工程(壳工程搭建见 example/SHELL_SETUP.md)
4.1 TurboModule Spec:先定契约,再写实现
RN 0.84 的 TurboModule 用 codegen 生成 JS↔ArkTS 胶水,Spec 文件既是类型契约也是胶水的生成来源。这里的关键设计:把"能力快照、分享条目、接收记录、事件负载"四个数据结构全部定义在 Spec 里,JS 与 ArkTS 两侧共用同一套形状,避免手写映射时字段对不上。
// src/NativeKnockShare.ts —— TurboModule codegen spec(完整文件)
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
/**
* 碰一碰能力探测快照。
* 与 ArkTS 侧 canIUse / deviceInfo / window.getWindowProperties() 对应。
*/
export interface KnockCapability {
/** 系统能力 SystemCapability.Collaboration.HarmonyShare 是否可用(碰一碰前提) */
syscapSupported: boolean;
/** 设备形态:phone / tablet / 2in1(PC) */
deviceType: string;
/** 系统 API 版本(PC 精准碰一碰要求 API 26+,沙箱接收 PC API 20+ / 平板 API 23+) */
apiVersion: number;
/** 是否支持一碰传文件/卡片 */
knockShareAvailable: boolean;
/** 是否支持沙箱直达接收(dataReceive:PC API 20+ / Tablet API 23+ / 手机 API 12+) */
dataReceiveAvailable: boolean;
/** 华为分享服务是否可用(用户关闭时碰一碰只弹系统提示) */
shareServiceReady: boolean;
}
/** 分享条目 —— 一次碰一碰可携带多条记录(多文件 / 文件+卡片) */
export interface KnockShareItem {
/**
* 标准化数据类型(UTD)字符串:
* 'general.file' | 'general.image' | 'general.video' | 'general.audio'
* | 'general.hyperlink' | 'general.plain-text'
*/
utd: string;
/** 本地文件路径(沙箱路径,原生侧经 fileUri.getUriFromPath 转 URI) */
uri?: string;
/** 链接(HYPERLINK)或文本(PLAIN_TEXT)内容 */
content?: string;
/** 卡片标题(沉浸式大卡布局需要) */
title?: string;
/** 卡片描述(沉浸式大卡布局需要) */
description?: string;
/** 预览图路径(沙箱路径;纯图片布局只传 uri+thumbnailUri 即可) */
thumbnailUri?: string;
}
/** 接收端解析结果(对端碰一碰传来的内容,或跨端调起 want 中携带的分享数据) */
export interface KnockReceivedRecord {
utd: string;
/** 文件类:已拷入本应用沙箱的本地路径 */
uri?: string;
/** 链接/文本类内容 */
content?: string;
title?: string;
description?: string;
thumbnailUri?: string;
}
/** 原生侧转发的事件负载 */
export interface KnockShareEventPayload {
/** knockDetected:对端已触碰、需要在 3 秒内 send();listenState:监听注册状态;error */
kind: 'knockDetected' | 'listenState' | 'error';
/** kind = 'knockDetected' 时:本次触碰时间戳(原生收到回调的时刻) */
knockTimestamp?: number;
/** kind = 'listenState' 时:true=已注册 / false=已注销 */
listening?: boolean;
/** kind = 'error' 时的错误信息 */
message?: string;
}
export interface Spec extends TurboModule {
/** 能力探测:syscap + 设备形态 + API 版本 + 华为分享服务状态,一次拿全 */
checkCapability(): Promise<KnockCapability>;
/** 注册碰一碰监听;windowId 由原生侧解析,JS 无需传 */
startListening(): Promise<void>;
/** 注销碰一碰监听(页面离开/退后台必须调用) */
stopListening(): Promise<void>;
/** 在收到 knockDetected 后 3 秒内调用:构造 SharedData 并 sharableTarget.share() */
send(items: KnockShareItem[]): Promise<void>;
/** 无内容时明确拒绝,避免对端一直转圈(target.reject(NO_CONTENT_ERROR)) */
rejectNoContent(): Promise<void>;
/** 模拟一次对端触碰(回环演示,单设备验证收发全链路) */
simulateKnock(): Promise<void>;
/** 读取最近一次接收到的分享数据(消费即取走) */
getReceivedData(): Promise<KnockReceivedRecord[]>;
/** 把 rawfile 内置文件复制到应用沙箱并返回路径 */
copyRawfileToSandbox(name: string): Promise<string>;
/** 回环演示发送端点:与 send() 共用 SharedData 构造管线,不要求 SharableTarget */
sendLoopback(items: KnockShareItem[]): Promise<void>;
/* NativeEventEmitter 约定接口(ArkTS 侧通过 emitDeviceEvent 转发) */
addListener(eventName: string): Promise<void>;
removeListeners(count: number): Promise<void>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('KnockShare');
Spec 里的 sendLoopback 是回环演示的原生端点。这里有个后文踩坑章节会展开的教训:Spec 改了必须重跑 codegen-harmony,否则 JS 侧 TurboModule 上根本"不存在"新方法,调用会静默走错分支——我们就是被 sendLoopback 缺失坑过一次,JS 端看起来一切正常,实际每次都回退到 send 并报"没有待分享目标"。
4.2 ArkTS 实现:3 秒窗口与 windowId 是两条生命线
核心实现在 KnockShareModule.ets,继承 RNOH 的 UITurboModule(需要 UIAbility 上下文取窗口信息)。先看监听注册与注销——这是碰一碰的"开关",两个细节决定了它能不能稳定工作:
// harmony/knock_share/src/main/ets/KnockShareModule.ets(节选:监听注册/注销)
import { UITurboModule } from '@rnoh/react-native-openharmony/ts';
import { harmonyShare, systemShare } from '@kit.ShareKit';
import { fileUri, fileIo } from '@kit.CoreFileKit';
import { deviceInfo, BusinessError } from '@kit.BasicServicesKit';
import { window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
/** 官方铁律:收到碰一碰回调后,超过 3 秒 share() 可能失败 */
const KNOCK_SEND_WINDOW_MS = 3000;
export class KnockShareModule extends UITurboModule {
/** 当前注册用的 registry(off 必须传同一 windowId) */
private registry: harmonyShare.SendCapabilityRegistry | null = null;
/** 最近一次触碰的 SharableTarget(3 秒发送窗口内有效) */
private pendingTarget: harmonyShare.SharableTarget | null = null;
private pendingTimestamp: number = 0;
private listening: boolean = false;
private loopback: boolean = false;
async startListening(): Promise<void> {
const ctx = this.ctx.uiAbilityContext;
// 注册依赖 windowId:PC/2in1、平板自由窗口下系统只回调持有焦点的窗口
const win = await window.getLastWindow(ctx as common.UIAbilityContext);
const windowId = win.getWindowProperties().id;
console.info(`[KnockShare] startListening windowId=${windowId}`);
const registry: harmonyShare.SendCapabilityRegistry = { windowId: windowId };
this.registry = registry;
// 事件名固定 'knockShare';回调内禁止阻塞,立即转发给 JS
harmonyShare.on('knockShare', registry, (target: harmonyShare.SharableTarget) => {
this.onKnockTarget(target);
});
this.listening = true;
this.emit({ kind: 'listenState', listening: true });
}
async stopListening(): Promise<void> {
if (!this.listening || this.registry === null) {
return;
}
try {
// off 与 on 必须传同一个 registry(同一 windowId),否则注销不生效
harmonyShare.off('knockShare', this.registry);
console.info('[KnockShare] stopListening');
} catch (e) {
console.error(`[KnockShare] stopListening failed: ${JSON.stringify(e)}`);
}
this.listening = false;
this.registry = null;
this.pendingTarget = null;
this.emit({ kind: 'listenState', listening: false });
}
/** 碰一碰回调:记录 target + 时间戳,转发 JS(JS 需在 3 秒内 send) */
private onKnockTarget(target: harmonyShare.SharableTarget): void {
this.pendingTarget = target;
this.pendingTimestamp = Date.now();
console.info(`[KnockShare] knock detected at ${this.pendingTimestamp}`);
this.emit({ kind: 'knockDetected', knockTimestamp: this.pendingTimestamp });
}
}
逐条讲透:
- 为什么
startListening每次都重新取 windowId:PC/2in1 和平板的自由窗口模式下,窗口可能被关闭重建,缓存的旧 windowId 会让注册落到一个已销毁的窗口上。每次注册都通过window.getLastWindow拿最新前台窗口,注册一定跟着"当前可见的 UI"走。代价是 JS 层重复 start 会重复on(叠加注册、触碰回调多次触发)——所以 hook 层做了"重注册前先注销"的兜底,见 4.3。 - 为什么回调里只做记录和转发:
onKnockTarget在系统触碰回调线程上执行,官方约定回调内禁止阻塞。我们只做三件事:持有SharableTarget、记时间戳、emit 给 JS。真正"分享什么内容"的决策权交给 JS 业务层——这是库与业务的合理分工。 pendingTarget的生命周期就是 3 秒:send 成功、失败、超时、无内容四个出口都会把它清空,绝不过期持有。对端设备的等待状态由系统管理,应用侧持有过期 target 没有任何意义,还会造成"陈旧触碰"误发送。
再看 send()——碰一碰的"出口",3 秒校验、SharedData 构造、fileUri 转换、无内容拒绝全部在这里收敛:
// harmony/knock_share/src/main/ets/KnockShareModule.ets(节选:发送与拒绝)
async send(items: KnockShareItem[]): Promise<void> {
const target = this.pendingTarget;
const isLoopback = target === null && this.loopback;
if (target === null && !isLoopback) {
throw new Error('没有待分享目标:请先 startListening 并等待对端碰一碰');
}
if (Date.now() - this.pendingTimestamp > KNOCK_SEND_WINDOW_MS) {
// 3 秒铁律:超时分享可能失败,直接报错并清空目标
this.pendingTarget = null;
this.loopback = false;
throw new Error('超出碰一碰 3 秒发送窗口,本次分享已失效');
}
if (items === null || items.length === 0) {
// 不拒绝的话,对端会一直转圈
this.loopback = false;
if (target !== null) {
await this.rejectNoContent();
}
return;
}
const first = items[0];
const firstRecord: systemShare.SharedRecord = {
utd: first.utd,
};
if (first.content !== undefined) {
firstRecord.content = first.content;
}
if (first.uri !== undefined) {
// 沙箱路径必须经 fileUri.getUriFromPath 转换,直接传路径字符串系统读不到
firstRecord.uri = fileUri.getUriFromPath(first.uri);
}
if (first.title !== undefined) {
firstRecord.title = first.title;
}
if (first.description !== undefined) {
firstRecord.description = first.description;
}
if (first.thumbnailUri !== undefined) {
firstRecord.thumbnailUri = fileUri.getUriFromPath(first.thumbnailUri);
}
const data: systemShare.SharedData = new systemShare.SharedData(firstRecord);
for (let i = 1; i < items.length; i++) {
const item = items[i];
const record: systemShare.SharedRecord = { utd: item.utd };
if (item.content !== undefined) {
record.content = item.content;
}
if (item.uri !== undefined) {
record.uri = fileUri.getUriFromPath(item.uri);
}
if (item.title !== undefined) {
record.title = item.title;
}
if (item.description !== undefined) {
record.description = item.description;
}
if (item.thumbnailUri !== undefined) {
record.thumbnailUri = fileUri.getUriFromPath(item.thumbnailUri);
}
data.addRecord(record);
}
try {
if (target !== null) {
// 必须在触碰回调后 3 秒内调用;分享结果经系统通知告知用户
target.share(data);
console.info('[KnockShare] share() sent');
} else {
// 回环演示:真实构造出的分享数据写回本机接收通路(AppStorage → getReceivedData)
const records: KnockReceivedRecord[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i];
const record: KnockReceivedRecord = { utd: item.utd };
if (item.uri !== undefined) {
record.uri = fileUri.getUriFromPath(item.uri);
}
if (item.content !== undefined) {
record.content = item.content;
}
if (item.title !== undefined) {
record.title = item.title;
}
if (item.description !== undefined) {
record.description = item.description;
}
if (item.thumbnailUri !== undefined) {
record.thumbnailUri = fileUri.getUriFromPath(item.thumbnailUri);
}
records.push(record);
}
AppStorage.setOrCreate(RECEIVED_DATA_KEY, records);
console.info(`[KnockShare] loopback delivered ${records.length} record(s)`);
}
} catch (e) {
const err = e as BusinessError;
console.error(`[KnockShare] share failed code=${err.code} ${err.message}`);
throw new Error(`share failed code=${err.code} ${err.message}`);
} finally {
this.pendingTarget = null;
this.loopback = false;
}
}
async rejectNoContent(): Promise<void> {
const target = this.pendingTarget;
this.pendingTarget = null;
if (target === null) {
return;
}
try {
target.reject(harmonyShare.SharableErrorCode.NO_CONTENT_ERROR);
} catch (e) {
console.error(`[KnockShare] reject failed: ${JSON.stringify(e)}`);
}
}
几个值得展开的点:
- 错误用
BusinessError取 code:ArkTS 侧系统 API 抛的是BusinessError(带 code + message),直接String(e)会得到[object Object]——必须在原生层把 code 取出来拼进 message,JS 层才有可诊断的信息。 rejectNoContent是体验兜底:业务层拿到 knockDetected 却没有内容可发(比如页面正在加载)时,必须显式拒绝。对端的系统 UI 是"等待对端响应"的转圈状态,不拒绝它会一直等。- 回环分支复用同一条构造管线:
sendLoopback不另起炉灶,除"无 target"外,SharedRecord 的字段处理与真实share()完全一致,构造结果写 AppStorage。这样回环演示验证的就是真实发送要走的代码路径,而不是一条假链路。
接收侧是独立的 KnockShareReceiver,设计为静态工具类,供宿主壳工程的 EntryAbility 调用:
// harmony/knock_share/src/main/ets/KnockShareReceiver.ets(完整文件)
import { systemShare } from '@kit.ShareKit';
import { Want } from '@kit.AbilityKit';
import { RECEIVED_DATA_KEY } from './KnockShareModule';
/** 接收端解析结果(与 TurboModule 的 KnockReceivedRecord 一致) */
interface KnockReceivedRecord {
utd: string;
uri?: string;
content?: string;
title?: string;
description?: string;
thumbnailUri?: string;
}
/**
* 碰一碰接收端助手:供宿主壳工程的 EntryAbility 调用。
* 冷启动走 onCreate,热启动走 onNewWant,两条路径都调 handleWant。
*/
export class KnockShareReceiver {
/** 解析 Want 中的分享数据并写入 AppStorage。任何失败都不抛出(接收端静默降级)。 */
static async handleWant(want: Want): Promise<void> {
if (want === null || want === undefined) {
return;
}
try {
// SDK 26 中 getSharedData 返回 Promise<SharedData>
const sharedData = await systemShare.getSharedData(want);
if (sharedData === undefined || sharedData === null) {
// 非分享类 Want(普通路由/通知跳转),不处理
return;
}
const records: KnockReceivedRecord[] = [];
const raw = sharedData.getRecords();
for (let i = 0; i < raw.length; i++) {
const r = raw[i];
const record: KnockReceivedRecord = { utd: r.utd };
if (r.uri !== undefined) {
record.uri = r.uri;
}
if (r.content !== undefined) {
record.content = r.content;
}
if (r.title !== undefined) {
record.title = r.title;
}
if (r.description !== undefined) {
record.description = r.description;
}
if (r.thumbnailUri !== undefined) {
record.thumbnailUri = r.thumbnailUri;
}
records.push(record);
}
if (records.length > 0) {
AppStorage.setOrCreate(RECEIVED_DATA_KEY, records);
console.info(`[KnockShareReceiver] stored ${records.length} received record(s)`);
}
} catch (e) {
console.error(`[KnockShareReceiver] handleWant failed: ${JSON.stringify(e)}`);
}
}
}
两个设计决策:一是 handleWant 静默降级——接收端解析失败不能让应用崩溃,最坏情况就是这次分享收不到;二是 getSharedData 的签名陷阱——早期 SDK 它是同步返回,API 26 是 Promise<SharedData>,网上不少旧示例按同步写法在 API 26 上拿到的永远是 undefined,这也是我们能力探测里把 API 版本上报出来的原因之一。
4.3 Hook 状态机:把生命周期约束翻译成 React 语言
碰一碰的 API 约束天然适合用状态机表达:探测 → 监听 → 触碰(3 秒倒计时)→ 发送 → 回到监听。useKnockShare 把"进页面注册、离页面注销、触碰自动发送、无内容自动拒绝"这些规则全部内建:
// src/hooks/useKnockShare.ts(完整文件)
import { useCallback, useEffect, useRef, useState } from 'react';
import KnockShare, {
type KnockCapability,
type KnockShareEventPayload,
type KnockShareItem,
} from '../index';
/** 碰一碰状态机:探测能力 → 注册监听 → 触碰回调 → 3 秒内发送 */
export type KnockPhase =
| 'idle' // 尚未完成能力探测
| 'listening' // 能力可用,监听已注册,等待对端触碰
| 'knocked' // 对端已触碰,进入 3 秒发送窗口
| 'unsupported' // 设备/系统不支持碰一碰(Web 降级后端也归入此类)
| 'error'; // 探测失败
export interface UseKnockShareResult {
capability: KnockCapability | null;
phase: KnockPhase;
loading: boolean;
error: string | null;
/** knockDetected 触发的时刻(send 倒计时起点) */
knockTimestamp: number | null;
/** 一键流程:探测能力 → 注册监听。传 items 则触碰后自动 3 秒内 send */
start: (items?: KnockShareItem[] | null) => Promise<void>;
/** 注销监听(离开页面时 React unmount 会自动调用) */
stop: () => Promise<void>;
/** 手动发送(自定义时机,同样需在触碰后 3 秒内) */
send: (items: KnockShareItem[]) => Promise<void>;
}
export function useKnockShare(): UseKnockShareResult {
const [capability, setCapability] = useState<KnockCapability | null>(null);
const [phase, setPhase] = useState<KnockPhase>('idle');
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [knockTimestamp, setKnockTimestamp] = useState<number | null>(null);
const pendingItems = useRef<KnockShareItem[] | null>(null);
const mounted = useRef(true);
const listening = useRef(false);
const stop = useCallback(async () => {
if (!listening.current) {
return;
}
listening.current = false;
try {
await KnockShare.stopListening();
} catch (e) {
console.warn('[useKnockShare] stopListening failed', e);
}
}, []);
const start = useCallback(async (items?: KnockShareItem[] | null) => {
pendingItems.current = items ?? null;
setLoading(true);
try {
// 重复 start(切换分享内容等场景)必须先注销旧监听,
// 否则 harmonyShare.on 会叠加注册、触碰回调触发多次
if (listening.current) {
listening.current = false;
try {
await KnockShare.stopListening();
} catch {
// 注销失败不阻塞重新注册
}
}
const cap = await KnockShare.checkCapability();
if (!mounted.current) {
return;
}
setCapability(cap);
if (cap.knockShareAvailable) {
await KnockShare.startListening();
listening.current = true;
setPhase('listening');
setError(null);
} else {
setPhase('unsupported');
setError(cap.syscapSupported ? null : '设备不支持碰一碰能力');
}
} catch (e) {
if (mounted.current) {
setPhase('error');
setError(e instanceof Error ? e.message : JSON.stringify(e));
}
} finally {
if (mounted.current) {
setLoading(false);
}
}
}, []);
const send = useCallback(async (items: KnockShareItem[]) => {
await KnockShare.send(items);
}, []);
useEffect(() => {
mounted.current = true;
// 事件桥接:knockDetected 后自动走发送/拒绝(3 秒窗口内)
const offKnock = KnockShare.on('knockDetected', (payload: KnockShareEventPayload) => {
if (!mounted.current) {
return;
}
setKnockTimestamp(payload.knockTimestamp ?? Date.now());
setPhase('knocked');
const items = pendingItems.current;
if (items && items.length > 0) {
KnockShare.send(items)
.then(() => {
// 发送完成即回到监听态(一次触碰消费一次分享),徽标不再停留在倒计时
if (mounted.current) {
setKnockTimestamp(null);
setPhase('listening');
}
})
.catch(e => {
const msg =
e instanceof Error ? e.message : typeof e === 'object' ? JSON.stringify(e) : String(e);
console.warn('[useKnockShare] send failed', msg);
if (mounted.current) {
setError(msg);
setPhase('listening');
}
});
} else {
// 无内容:明确拒绝,避免对端一直转圈
KnockShare.rejectNoContent().catch(() => {});
setPhase('listening');
}
});
const offError = KnockShare.on('error', (payload: KnockShareEventPayload) => {
if (mounted.current) {
setError(payload.message ?? '未知错误');
}
});
return () => {
mounted.current = false;
offKnock();
offError();
// 页面卸载即注销监听:避免与其它应用的碰一碰监听冲突
if (listening.current) {
listening.current = false;
KnockShare.stopListening().catch(() => {});
}
};
}, []);
return { capability, phase, loading, error, knockTimestamp, start, stop, send };
}
这段代码有三个容易忽略的防御点:
mounted引用贯穿所有异步回调:碰一碰的事件可能在页面卸载后才到达,send 的 Promise 也可能在卸载后 resolve。所有 setState 前都检查mounted.current,否则就是内存泄漏警告刷屏。pendingItems用 ref 而非 state:knockDetected 事件处理器注册一次、闭包捕获的是首次渲染的 state。用 ref 才能保证"触碰瞬间"读到的是最新的待发内容。反过来的坑我们真踩过:业务侧切换分享内容后不重新start(items),触碰时发出的还是旧内容(详见踩坑章节)。- 卸载时注销监听:
harmonyShare.on是应用级注册,页面销毁不注销的话,碰一碰回调会打到一个已销毁的 RN 页面上,还会和其它页面的监听互相干扰。
4.4 平台路由与回环演示:JS 层收敛所有分支
src/index.ts 是业务唯一 import 的入口。它做的事情:isHarmony() 判定平台(RNOH 上 Platform.OS 为 'harmony',上游类型未收录需要断言);鸿蒙走 TurboModule、其它平台走 Web 降级适配层;KnockUTD 常量表避免业务侧硬编码 UTD 字符串;KnockShare.on 把原生事件桥(NativeEventEmitter)与 JS 本地事件总线(回环演示)两路事件汇入同一套订阅者。这套路由的取舍逻辑值得说一下:我们没有把平台判定下放到 ArkTS 侧,因为"当前环境有没有 NFC 硬件、是不是浏览器"本质上是一个 JS 侧更容易回答的问题(Platform.OS 与 navigator 探测都是零成本同步操作),而 ArkTS 侧的回答需要异步的 deviceInfo 查询;同步判定让 KnockShare 对象可以在模块加载期就确定实现类,业务侧任何方法调用都不需要先"等平台探测"。完整代码见仓库 src/index.ts(约 200 行),这里贴出与回环演示直接相关的两个函数:
// src/index.ts(节选:回环路由,函数体完整;完整文件见仓库)
/** 回环演示标记:simulateKnock 置位后,下一次 send() 路由到原生 sendLoopback */
let loopbackPending = false;
export const KnockShare = {
send(items: KnockShareItem[]): Promise<void> {
if (isHarmony() && loopbackPending) {
loopbackPending = false;
const impl = pickImplementation() as UnifiedImpl & {
sendLoopback?: (items: KnockShareItem[]) => Promise<void>;
};
if (typeof impl.sendLoopback === 'function') {
return impl.sendLoopback(items);
}
// 诊断信息直接进错误链路:sendLoopback 在 JS 侧不可见(codegen 胶水过期等)
return Promise.reject(
new Error('sendLoopback 方法不可用:JS 侧 TurboModule 未见该方法(请重新执行 codegen-harmony)'),
);
}
return (pickImplementation() as UnifiedImpl).send(items);
},
/**
* 模拟一次对端触碰(单设备/无 NFC 环境演示收发全链路):
* knockDetected 在 JS 本地分发(不依赖 native emit 桥),hook 照常进入
* 3 秒窗口并自动 send();鸿蒙原生后端 send 被路由到 sendLoopback;
* Web 降级后端则真实走 Web Share API / 剪贴板发出。
*/
simulateKnock(): Promise<void> {
if (isHarmony()) {
loopbackPending = true;
jsEmit({ kind: 'knockDetected', knockTimestamp: Date.now() });
return Promise.resolve();
}
return webAdapter!.simulateKnock();
},
};
注意 simulateKnock 在鸿蒙端不走原生事件桥,直接在 JS 层本地分发事件。原因是我们实测发现部分 RNOH 版本 emitDeviceEvent 对快速连发的事件不够可靠,回环演示又是纯本地行为,没必要过桥——真实碰一碰事件仍然走原生桥,两路都汇入 KnockShare.on 的订阅者。
4.5 宿主接入三件套:注册、权限、接收回调
业务侧使用只需十几行(完整示例见仓库 example/src/App.tsx):
// 业务组件示例(完整可运行,依赖仅 react-native-ohos-knock-share)
import React, { useEffect } from 'react';
import { Text } from 'react-native';
import KnockShare, { KnockUTD, useKnockShare, type KnockShareItem } from 'react-native-ohos-knock-share';
const DEEP_LINK = 'https://knockshare.example.com/share/demo-2026';
export function KnockShareScreen() {
const { capability, phase, knockTimestamp, start } = useKnockShare();
useEffect(() => {
// 组装分享内容:文件类先落沙箱拿真实路径
const prepare = async (): Promise<KnockShareItem[]> => {
const thumb = await KnockShare.copyRawfileToSandbox('knock_demo.jpg');
return [
{
utd: KnockUTD.HYPERLINK,
content: DEEP_LINK,
title: '设计稿 v3',
description: '碰一碰直达评审页',
thumbnailUri: thumb,
},
];
};
// 进入页面即注册监听;触碰后 3 秒内由 hook 自动发送
prepare()
.then(items => start(items))
.catch(e => console.warn('准备分享内容失败', JSON.stringify(e)));
// 离开页面由 hook 自动注销监听
}, [start]);
// 接收对端分享:应用回到前台时取走(消费即清空)
useEffect(() => {
KnockShare.getReceivedData()
.then(records => {
if (records.length > 0) {
console.info('收到分享数据', JSON.stringify(records));
}
})
.catch(() => {});
}, []);
const remaining = knockTimestamp
? Math.max(0, 3 - Math.floor((Date.now() - knockTimestamp) / 1000))
: 0;
return <Text>碰一碰状态:{phase}(3 秒窗口剩余 {remaining}s)</Text>;
}
ArkTS 侧三件套,全部是完整可拷贝的配置。
第一件:Package 注册(RNOH 0.84 通过 harmony 目录 AutoLinking,Package 在 RNPackagesFactory 里追加):
import { KnockSharePackage } from 'react-native-ohos-knock-share/harmony/knock_share';
// 工程的 RNPackagesFactory.getPackages() 数组追加:
new KnockSharePackage()
第二件:接收回调(壳工程 EntryAbility 完整文件,冷启动与热启动两条路径都要接):
// example/harmony/entry/src/main/ets/entryability/EntryAbility.ets(完整文件)
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { RNAbility } from '@rnoh/react-native-openharmony';
import { KnockShareReceiver } from 'react-native-ohos-knock-share';
export default class EntryAbility extends RNAbility {
getPagePath(): string {
return 'pages/Index';
}
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 冷启动:系统经 App Linking 拉起时 Want 携带分享数据
KnockShareReceiver.handleWant(want);
super.onCreate(want, launchParam);
}
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 热启动:应用已在后台,对端碰一碰再次调起
KnockShareReceiver.handleWant(want);
super.onNewWant(want, launchParam);
}
}
第三件:module.json5 权限与 App Linking 声明(碰一碰本身零权限,只有 INTERNET 与 skills 域名规则):
// example/harmony/entry/src/main/module.json5(完整文件)
{
"module": {
"name": "entry",
"type": "entry",
"description": "KnockShare example entry module",
"mainElement": "EntryAbility",
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"deviceTypes": [
"phone",
"tablet",
"2in1"
],
"requestPermissions": [
{
// RNOH 运行与 Metro 热加载需要;碰一碰本身无需额外权限
"name": "ohos.permission.INTERNET"
}
],
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"description": "main ability",
"exported": true,
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
"skills": [
{
"entities": ["entity.system.home"],
"actions": ["action.system.home"]
},
{
// App Linking 跨端调起:对端碰一碰分享的 https 链接直达本应用
// 域名校验由 AGC 的 assetlinks.json 承担(API 26 schema 已移除 domainVerify 字段)
"entities": ["entity.system.browsable"],
"actions": ["ohos.want.action.viewData"],
"uris": [
{
"scheme": "https",
"host": "knockshare.example.com",
"pathStartWith": "/share/"
}
]
}
]
}
]
}
}
五、踩坑与边界条件
以下每一条都是实测踩到、并且已经修复或找到 workaround 的,按杀伤力排序。
坑 1:Spec 加了新方法必须重跑 codegen,否则"静默走错"。 TurboModule 的 JS 侧方法集来自 codegen-harmony 生成的胶水代码。我们在 Spec 里加了 sendLoopback 后只改了 ArkTS 实现,没有重新 codegen——JS 侧 TurboModule 上根本不存在这个方法,typeof impl.sendLoopback === 'function' 判断为 false,静默回退到 send,然后报"没有待分享目标"。表象完全误导排查方向(一度以为监听没生效)。结论:改 Spec 必跑 npx react-native codegen-harmony --ets-output-path ./harmony/entry/src/main/ets/generated,并把"JS 侧方法可见性"作为错误信息输出(现在库里 send 路由失败会明确提示重跑 codegen)。
坑 2:切换分享内容后发出的还是旧内容。 监听注册绑定的是 start(items) 时刻的 items 引用(hook 内 pendingItems ref)。业务侧切换单选按钮只改了 UI state,没有重新 start(newItems),触碰后 hook 自动发送的是上一次注册的旧 items——我们实测选了"文件"、发出去的却是链接卡片。修复:示例 App 的发送入口改为"每次都按当前选中内容重建并重新 start";同时 hook 层在重复 start 前先 stopListening,否则 harmonyShare.on 会叠加注册,触碰回调触发多次。
坑 3:发送完成后状态机停在"已触碰 0s"。 一次触碰消费一次分享,send resolve 后如果不把 phase 拉回 listening 并清空 knockTimestamp,UI 徽标就永远卡在倒计时归零的橙色状态(我们第一版真机截图里就能看到这个 bug)。这不是功能问题,但会让用户误以为分享失败。
坑 4:真机拒装 unsigned HAP。 模拟器接受未签名 HAP,真机报 9568320: no signature file。解决办法:用 DevEco 自动签名(材料在 ~/.ohos/config/,p12 密码由 IDE 加密存储,本机 hvigor 可解密),debug profile 绑定 bundleName 与设备 UDID——包名必须与 profile 一致,换新设备要在 DevEco 里重新自动签名。
坑 5:DevEco 26 构建链的三连坑。 hvigor 6.26.8 要求 modelVersion: "26.0.0" 在 hvigor-config.json5 与根 oh-package.json5 两处同时存在且一致,缺一处报 109104038;API 26 的 module.json5 schema 移除了 domainVerify 字段,照旧文档写会直接 schema 校验失败(域校验移交 AGC 的 assetlinks.json);startWindowIcon/startWindowBackground 资源缺失会构建失败。
坑 6:example/node_modules 是拷贝不是软链。 示例工程用 npm install --install-links=true 安装本地库时,库会被完整拷贝进 example/node_modules——改了库源码必须同步这份副本再打 bundle,否则模拟器上跑的永远是旧版,改了半天"没生效"就是它。
坑 7:错误对象直接 String(e) 得到 [object Object]。 ArkTS 抛到 JS 侧的错误多是普通对象,统一用 JSON.stringify(e) 序列化(原生层已在 BusinessError 里拼好 code)。
六、总结:
结论:鸿蒙碰一碰是"投入小、体验好"的近场分享方案——应用零权限、零传输协议,全部复杂度收敛在"3 秒内给内容"和"接收解析"两个点;把它封装成 RN 三方库后,业务接入成本从"几天踩坑"降到"十几行代码 + 三件套配置"。全场景(手机/平板/PC/Web)在库内路由收敛,业务侧零分支。
这篇是基于鸿蒙 Share Kit 碰一碰能力,封装 RN 鸿蒙三方库
react-native-ohos-knock-share的实战文章,核心是给 RN 鸿蒙业务提供卡片、文件一碰传能力。
传统近场分享方案(自建传输、系统分享面板、手写ArkTS原生代码)都有明显短板,而鸿蒙碰一碰依靠NFC/星闪触发、系统通道传输,应用不用申请传输相关权限、不用实现传输协议,只需要在触碰后的3秒窗口期内提供分享内容、接收侧解析数据,但官方只有ArkTS示例,RN鸿蒙生态缺少现成封装,于是开发了这个TurboModule库。
库内部拆分发送、接收两条链路:发送端监听窗口的碰一碰回调,3秒时限做强校验,沙箱文件必须转为系统识别的fileUri;接收端同时处理冷启动、热启动两种Want调起路径,解析分享数据。同时内置回环模拟能力,不用NFC真机、模拟器也能完整跑通收发流程,Web和非鸿蒙平台做降级适配。
整体采用TurboModule Codegen规范定义JS与ArkTS契约,搭配React Hook封装状态机,自动管理监听注册、页面卸载注销、超时兜底、无内容拒绝等逻辑,业务接入仅十几行代码,配合EntryAbility回调、App Linking配置即可使用。
参考与致谢
- HarmonyOS Share Kit 碰一碰官方文档:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/share-knock-share
- 本文配套仓库:https://atomgit.com/dijun_520/react-native-ohos-knock-share
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)