跨端 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)
系统 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配置即可使用。

参考与致谢

相关推荐

RN 鸿蒙安全三方库落地:react-native-digital-shield,密钥不出 TEE,AES-GCM/RSA-JWT 跨端身份打通

本文基于 RNOH 0.84 与 HarmonyOS API 26,自研数字盾库 react-native-digital-shield,把 ArkTS 专属的 HUKS、asset 能力经TurboModule 桥接给 JS:提供 AES-256-GCM 加解密与密钥托管、敏感资产存储、RSA-2048 签发校验 RS256 JWT,并以 JWK 公钥和 HS256 共享密钥实现跨端身份互认,密钥明文始终不出 TEE。文中还复盘 TurboModule 双侧注册、GCM 需拆分 16 字节

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 15

Flutter 修复鸿蒙 WebView Cookie 同名键丢失:离线回读与开发助手复核实录

修复了 Flutter 三方库 webview_cookie_manager 鸿蒙端因同名键误判导致的 Cookie 名称丢失问题,确保业务键如 path、domain 等在回读时正确保留。修复后,模拟器与真机测试均实现四组数据完整恢复,值中多等号也保持不变。补丁已提交至 CPF-Flutter 官方仓库,提升插件在鸿蒙平台的可靠性。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 25

Flutter 鸿蒙启动图撤销后编译失败:用 DevEco Code 复核三方库修复

本文针对 Flutter 鸿蒙启动图撤销时导致编译失败的问题,修复了 flutter_native_splash_ohos 库在移除启动图时误删仍被引用资源的缺陷。通过保留1×1透明图片并透传自定义模块路径,确保资源引用完整性,使工程可正常构建。修复已提交至官方仓库,支持幂等操作与多场景验证,提升开发体验。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 38

Flutter 接入鸿蒙 ArkWeb 预初始化:帮助页首开时机与主线程成本实测

为解决Flutter鸿蒙版webview_flutter无法在无视图状态下提前初始化引擎的问题,我在其原生实现中新增可选静态入口initializeWebEngine()。实测表明,在2秒预热窗口下,页面首屏渲染(FCP)耗时从中位数1791ms降至1019ms,显著提升首次打开体验;而在零延迟场景下,总路径耗时略有上升,需权衡预热时机与用户等待成本。该补丁已提交至CPF-Flutter官方仓库,支持社区协作优化。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 33

Flutter 优化鸿蒙 printing_ohos 多页 PDF 光栅化:原生资源释放与缓冲复用

针对鸿蒙端 printing_ohos 插件存在的页码映射错误与高内存占用问题,本文提出并实现三项优化:修正页码索引映射、引入原生资源分层释放机制、复用像素缓冲区。修复后,第三页显示正确,内存增长显著降低——在模拟器上PSS增量中位数下降65.8%,真机测试下降64.4%。完整补丁已提交至官方仓库,支持社区协作与问题反馈。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 29

React Native 鸿蒙权限审计实战:让每一项权限都有人认领

本文介绍一款纯 JS 编写的 React Native 鸿蒙权限审计工具,解决权限声明与使用对账难、最小权限集计算矛盾、审核必填字段缺失三大痛点。通过 module.json5 声明、使用台账与源码扫描三路输入对账,自动识别未使用权限、补全 reason/usedScene、生成可粘贴的最小清单,并支持 CI 卡控与风险分评估。工具内核独立,离线运行,兼顾准确性与安全性,助力应用高效通过审核。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 12

Cordova 鸿蒙 WebView 安全实战:能改的运行时拦,改不了的构建期审

本文通过 cordova-plugin-harmony-webguard 插件,解决 Cordova/HarmonyOS 应用中导航安全、引擎不可修改配置、JS 门面静默降级三大难题。基于运行时策略闸门(onPageStart + JS 预检)与构建期审计 CLI(扫描 MainPage.ets、config.xml 等),实现协议拦截、白名单校验、高危配置阻断,支持 CI 失败退出,将安全基线可视化、可测化,真正落地“能改的运行时拦,改不了的构建期审”。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 24

Flutter 鸿蒙供应链审计实战:让高危依赖卡在 CI 门口

本文介绍了一个基于纯 Dart CLI 与 Flutter Dashboard 的依赖审计工具,解决鸿蒙工程中三套锁文件(pubspec.lock、oh-package-lock.json5、package-lock.json)统一读取、离线快照与 OSV 在线情报选择、以及高危依赖通过退出码卡在 CI 的问题。工具以锁文件为事实依据,支持离线快照(4 条真实 GHSA)与在线查询,输出 SBOM(CycloneDX 1.5)、Markdown/JSON 报告,并通过退出码实现 CI 阻断,性能秒级

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 113

React Native 鸿蒙端侧 OCR 实战:TurboModule 与纯 TS 引擎的逐字契约

本文以鸿蒙真机上的 React Native 0.84 工程为例,详解三大难题:通过 TurboModule 接入 HarmonyOS Core Vision Kit 实现端侧离线 OCR;规避 Metro 预置头导致的 require 不存在问题;实现 npm 包与 HAR 双形态交付。项目采用 codegen 生成 ArkTS 接口,结合纯 TS 回退引擎,确保跨引擎逐字符一致,并通过 autolinking 支持无缝集成。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 55

Flutter 鸿蒙端侧 OCR 实战:让文字识别留在设备上

本文通过一个运行在鸿蒙真机上的 Flutter 插件工程,系统解决了三大核心问题:如何统一内存、文件、相册三类输入的识别链路;如何实现与 KMP 版本的 JSON 契约逐字节对齐;以及在系统不提供置信度时如何诚实处理。基于 HarmonyOS Core Vision Kit,插件实现端侧离线 OCR,全程无网络、无权限、无密钥,支持真实小票与截图识别,性能稳定(312~507ms),并通过 35 项单元测试与真机集成验证。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 93

React Native 鸿蒙实战:给应用接入小艺语音指令,跑通 Skill 声明到操作闭环

本文介绍如何在 React Native 鸿蒙(RNOH)工程中实现小艺技能闭环,通过开源库 react-native-ohos-ai-agent 封装技能声明、语音指令接收、端侧执行与结果回传全流程。针对三方应用权限限制,精准定位可操作边界,实现“被唤起→执行→反馈”链路贯通。结合五张真机截图验证:技能注册、指令匹配、意图上报、智能体调起及对话层弹出等关键节点均成功,完整打通语音交互闭环,助力应用无需入口即可被用户“叫”到。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 168

Flutter 鸿蒙实现精准碰一碰分享:tap_share_ohos 三方库打通真机传文件与传卡片

本文针对Flutter在鸿蒙系统上“碰一碰”分享功能缺失的痛点,基于DevEco Studio 26.0、HarmonyOS 7.0 API 26与Flutter 3.41.9实测环境,提出并实现开源库tap_share_ohos。该库封装NFC、Share Kit、权限管理及分布式能力,通过Dart门面层统一接口,利用MethodChannel与BasicMessageChannel对接原生ArkTS逻辑,实现文件与卡片的碰一碰分享。真机测试显示,可无缝调起系统分享面板,支持华为分享接力

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 159

Flutter 鸿蒙实战:用三方库接入小艺语音指令与智能体,跑通 Skill 声明到操作闭环

本文基于 flutter_ohos_ai_agent 三方库,实现 Flutter 应用一键调起鸿蒙小艺智能体。针对跨端框架对接鸿蒙 AI 能力的碎片化、环境差异等痛点,通过插件封装系统 Kit 能力,提供统一 Dart API。实测显示,模拟器与真机均支持文本/语音指令闭环,真机可完整调起小艺面板并回传事件。核心原理为:利用 ArkUI 承载页桥接 FunctionComponent,通过 MethodChannel 与 EventChannel 实现 Flutter 与原生能力双向通信,完成从语音识别到

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 146

RNOH 原生内存泄漏定位与治理:借助 LeakyGuard 实现图片解码降采样、LRU 字节缓存、资源生命周期管控,长列表图片页面峰值内存优化

本文面向 RNOH 0.84 长列表图片场景,剖析滑动数分钟后进程被系统杀掉的根因:原图解码尺寸失控、缓存按条数限流、PixelMap未显式 release。基于自研LeakyGuar,TurboModule 库,给出 desiredSize 真降采样、字节预算 LRU 缓存(逐出必 release)、资源登记与快照 diff、卸载即释放 Hook 的闭环方案,实测单图内存降 98.6%,24 图峰值由 1098.6MB 降至15.8MB。并总结双侧注册、ArkTS 语法差异、vp 转像

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 231

Flutter 鸿蒙空间音频实战:让音乐播放器的声音“长出方位感“

本文通过一个真实运行的 Flutter 工程,系统解析了在鸿蒙系统上实现空间音频的三大核心问题:基于双耳时间差(ITD)、声级差(ILD)和频谱整形的方位感算法;耳机切换与音频焦点管理;以及播放器与音效的集成。项目使用自研插件 spatial_audio,结合原生 HarmonyOS 接口,实现了跨设备的空间音频体验,支持普通耳机,无需硬件依赖,并提供实时参数反馈与系统状态监听。附带完整代码仓库与实测效果展示,为 Flutter on HarmonyOS 的音频开发提供了可复用的技术方案。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 282

Flutter 鸿蒙 HiTrace 请求归因:图片压缩等待时,主线程在做什么

为定位鸿蒙平台图片压缩性能瓶颈,我在 flutter_native_image 插件中新增可选的 HiTrace 阶段追踪功能,支持按请求开启解码、缩放、编码与资源释放阶段的耗时分析。通过对比模拟器与真机(华为畅享 100 Pro Max)数据,精准识别出耗时主要集中在解码与编码阶段,显著提升诊断效率。相关代码已提交至官方仓库,助力开发者快速定位性能问题。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 1万+

Flutter 适配鸿蒙折叠屏铰链角度按需订阅:支架调节页用完即停与重入实测

本文介绍对 Flutter 三方库 dual_screen 的优化:针对支架调节页等短时观察场景,保留原有缓存入口的同时,新增按需订阅机制。通过按需启停监听,实现页面退出后停止原生回调,避免无效数据更新。实测显示,无消费者时原生回调从3条降至0条,重进页面则等待新事件,更符合临时工具页的使用逻辑。完整代码已开源,支持鸿蒙折叠屏角度动态感知,适用于需精准控制生命周期的折叠屏应用开发。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 1万+

Flutter 适配鸿蒙手表屏幕形状:让计时卡片按圆屏和方屏布局

本文基于 wear_plus 1.2.4 版本,为鸿蒙系统补全屏幕形状查询功能,使 WatchShape 能正确返回 round 等形状信息。通过在原 Dart 接口不变的前提下,新增鸿蒙平台处理器,实现与 Android 一致的异步查询逻辑。对照实验验证:适配后页面可从“查询未接入”变为“圆屏已确认”,日志成功接收系统回执,布局行为保持一致。完整代码开源至 oh-flutter/wear_plus,支持 Flutter 鸿蒙穿戴设备开发。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 1万+

Flutter 优化鸿蒙剪贴板大图复制卡顿:从主线程同步阻塞到异步文件描述符直解

针对鸿蒙系统下 flutter_image_clipboard 插件复制高清图片时阻塞主线程的问题,本文提出三项优化:异步文件描述符直解、异步剪贴板写入及原生资源逆序释放。实测显示,模拟器事件循环最大间隔从300.32ms降至54.36ms(降幅81.9%),真机上更从133.20ms降至11.20ms(降幅91.59%),显著提升页面响应性。完整代码已开源,支持直接使用与社区交流。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 1万+

Flutter 三方库 petalmaps_ohos 的 OpenHarmony 适配实战(0 → 1 全记录)

本文详细记录了从零构建 Flutter 三方库 petalmaps_ohos 并适配 OpenHarmony/HarmonyOS 平台的全过程,涵盖项目初始化、原生实现(基于 ArkTS 和 MethodCallHandler)、插件注册、功能验证及升级至 HarmonyOS SDK 26 的关键步骤。重点解析了为何需实现 AbilityAware 获取 UIAbilityContext 以确保地图调起成功,并通过分层架构、枚举封装与兼容性设计,实现功能完整、易用且低维护成本的“调起系”地图插件,

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 59

Flutter 三方库 holding 的 OpenHarmony 适配实战:从 0 到 1 开发一个鸿蒙感知插件

本文详述了从零实现开源 Flutter 插件 holding 并适配 OpenHarmony/HarmonyOS 的全过程。基于鸿蒙专属 MultimodalAwarenessKit,实现握持手与操作手状态感知,覆盖事件流订阅、状态查询及 API 26 新增接口,确保功能一致、性能高效、接口稳定。通过 Dart 层封装、ArkTS 原生桥接、真机验证与单元测试,完成插件工程化落地,并发布至 pub.dev,为同类系统能力插件的鸿蒙化提供可复用的技术路径。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 28

Flutter 三方库 gesturesshare 从 0 到 1:鸿蒙隔空传送分享插件的开发、升级与发布实战

本文详述了 Flutter for OpenHarmony 三方库 gesturesshare 从零开发到发布全过程:基于 HarmonyOS 隔空传送能力,实现跨设备图片与 App Linking 一键分享。涵盖双端代码设计、API 26 升级、真机验证及 Pub 发布流程,提供 Dart/ArkTS 对照与踩坑复盘。项目填补 Flutter 生态空白,推动鸿蒙多设备交互能力普及,助力开发者快速接入系统级功能。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 28

Qt 鸿蒙沉浸光感实战:让玻璃的每个像素自己发光

本文通过一个 QML 模块与 Qt for OpenHarmony 构建实践,完整解答了三个核心问题:如何用 81 行 GLSL 实现无图片依赖的动态玻璃材质(含光核漂移、视差、SDF 圆角与颗粒);如何在 Qt Quick 中通过 C++ 控制器实现弹层锚定与翻转;以及如何将 Qt 应用交叉编译并以 HAR 形式部署至鸿蒙系统。项目基于 Qt 5.12.12,利用 ShaderEffect 实现纯渲染级视觉效果,兼顾性能与跨平台兼容性,提供可复用的开源方案。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 22

开源鸿蒙平台 KMP_CMP 三方库「端侧语音转文字」适配全流程

本项目实现KMP/CMP在OpenHarmony鸿蒙平台的语音转文字(TTS)完整链路适配,通过Kotlin/Native共享模型、C ABI/N-API桥接与ArkTS交互,解决权限、音频格式、生命周期及跨平台契约等难题。支持中文、英文、日文多语言识别,提供统一结果模型与JSON序列化,实测于华为Mate 80 Pro Max模拟器,验证了JVM测试、Native动态库、HAP签名部署及真机识别全流程。项目结构清晰,分六阶段推进,确保代码复用与平台无关性,为KMP在鸿蒙生态落地提供可复用的技术范式。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 30

开源鸿蒙平台 KMP_CMP 三方库「端侧OCR」新特性适配

本项目实现Kotlin Multiplatform(KMP)在OpenHarmony平台的端侧OCR适配,基于HarmonyOS 7.0 SDK与Compose Multiplatform 1.9.2,成功打通JVM、Kotlin/Native与ArkTS间的数据交互。通过C ABI/N-API桥接层,实现KMP模型与Core Vision Kit识别结果的跨语言共享,支持中、英、日、韩四语识别。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 13

开源鸿蒙平台 KMP/CMP 三方库「智感握姿」新特性

本项目实现鸿蒙KMP/CMP平台下智感握姿能力的跨端适配,基于Kotlin Multiplatform构建共享状态模型,通过C ABI与ArkTS桥接,支持左手、右手、双手等五种握持状态的稳定传递。项目打通ohosArm64目标、Native动态库构建与DevEco Studio集成链路,提供统一API与真机验证示例,确保状态模型、事件序列与生命周期在多端一致。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 19

Electron 鸿蒙沉浸光感实战:把玻璃交给操作系统去发光

本文通过一个 Electron 库与 HarmonyOS 三方库,系统解决三大核心问题:跨平台材质路径对齐(macOS vibrancy、Windows acrylic、鸿蒙 backgroundBlurStyle)、菜单子窗口精准定位(坐标转换与工作区适配)、以及无脚本菜单文档的安全事件回传(自定义 URL scheme 与焦点管理)。基于 CPF-Electron 鸿蒙化分支,实现桌面级玻璃效果——材质由操作系统合成器采样,网页仅渲染薄色层与光斑,确保视觉一致性与性能优化。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 21

Flutter 修复 flutter_secure_storage 鸿蒙入口路由与前缀隔离:让多账号登录凭证在手机上独立读写与清理

本文针对 flutter_secure_storage 在鸿蒙平台的适配问题,修复了主包缺失 TargetPlatform.ohos 路由导致的 unsupported_platform 错误,并统一鸿蒙子包多账号前缀的读写与清理范围,解决键值覆盖、前缀错用及批量操作匹配过宽等问题。修复后,业务层可继续使用统一接口实现跨账号安全存储,保障数据隔离性。完整代码已开源至 abaoge/flutter_secure_storage。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 1万+

Cordova 鸿蒙沉浸光感实战:让弹层玻璃“喝到“背景的光

本文通过一个 HarmonyOS 模拟器上的 Cordova 项目,详解了在 WebView 中实现“玻璃拟态”效果的三大核心问题:利用 backdrop-filter 实现背景实时模糊与提亮,构建四层结构(采样、渐变底、光场、噪点)模拟真实玻璃质感;通过锚点定位与动态布局实现弹层贴边生长;并集成焦点管理、系统返回键及 inert 属性,确保交互礼仪完整。插件为纯 JS 无原生依赖,基于 ArkWeb 内核,兼顾性能与真实背景采样,适用于菜单、浮层等场景。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 28

CMP 鸿蒙透明玻璃实战:让悬浮导航栏真正透出光场

本文通过一个KMP/CMP工程,详解如何在鸿蒙上用Kotlin实现沉浸光感玻璃材质的逐像素渲染。核心解决三大问题:透明感源于环境光场与带alpha的柔光混合、玻璃“透光”依赖alpha保留的屏幕混合算法、跨平台一致性通过软件光栅器与原生桥接实现。项目以纯代码生成全屏光场与悬浮玻璃组件,无图片资源,支持动态切换调色板,实测模拟器下亚秒级重绘,完整复现官方“沉浸光感”视觉效果。仓库地址:https://atomgit.com/lqjmac/spatial-glass-cmp。

白天写代码,晚上追AI大模型,偶尔搞点八卦 开源动态|技术干货|实习机会|生态活动 我负责分享,你负责变强 28
上一篇: React Native 鸿蒙实战:给应用接入小艺语音指令,跑通 Skill 声明到操作闭环
下一篇: Flutter 鸿蒙端侧 OCR 实战:让文字识别留在设备上
AtomGit 代码君
AtomGit 代码君 企业官方账号 企业官方账号
博客等级 码龄6年 2万+粉丝 856原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值