示例仓库: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)
系统 APIHarmonyOS 7.0.0 / API 26(compatibleSdkVersion: 26.0.0)
React Native0.84.1
RN 鸿蒙框架@react-native-oh/react-native-harmony 0.84.5(RNOH)
Node.js≥ 22.11.0
实测设备华为(nova 14)
碰一碰 APIShare 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 传输环节由本机回环替代。

回环演示动图:监听 → 触碰 → 3秒倒计时 → 接收

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) —— 交给系统传输

三条硬约束决定了封装的骨架:

  1. 3 秒铁律。官方约定触碰回调后超过 3 秒调 share() 可能失败,对端会一直等待。所以原生层在回调里记录时间戳,JS 层 hook 里做倒计时兜底;超时或无内容时调 target.reject(NO_CONTENT_ERROR) 明确拒绝,让对端立刻收到失败反馈而不是干等转圈。
  2. windowId 决定监听归属。harmonyShare.on('knockShare', registry, cb) 的 registry 携带 windowId——系统只把触碰回调派发给持有焦点的窗口。手机单窗口下感知不强,但 PC/2in1 与平板自由多窗口下,用户点了别的窗口,原 windowId 的监听就收不到回调。这也意味着 off 必须传与 on 同一个 registry 对象,否则注销不生效。
  3. 沙箱文件必须转 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 };
}

这段代码有三个容易忽略的防御点:

  1. mounted 引用贯穿所有异步回调:碰一碰的事件可能在页面卸载后才到达,send 的 Promise 也可能在卸载后 resolve。所有 setState 前都检查 mounted.current,否则就是内存泄漏警告刷屏。
  2. pendingItems 用 ref 而非 state:knockDetected 事件处理器注册一次、闭包捕获的是首次渲染的 state。用 ref 才能保证"触碰瞬间"读到的是最新的待发内容。反过来的坑我们真踩过:业务侧切换分享内容后不重新 start(items),触碰时发出的还是旧内容(详见踩坑章节)。
  3. 卸载时注销监听: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配置即可使用。

参考与致谢

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐