在鸿蒙(HarmonyOS)上做 React Native 开发,经常会遇到第三方 npm 包兼容性不确定的问题。以剪贴板为例,@react-native-clipboard/clipboard 在 Android/iOS 上开箱即用,但在鸿蒙上能否正常工作完全取决于该库是否适配了 OpenHarmony。为了避免这种不确定性,最稳妥的办法是直接基于 RNOH(React Native OpenHarmony)的 TurboModule 机制,自己封装一个剪贴板写入模块。本文记录的就是通过 TurboModule 调用 @ohos.pasteboard 实现免权限写入系统剪贴板的完整过程。
读写分离:只做写入,不做读取
先说说设计取舍。鸿蒙的 @ohos.pasteboard 在 API 12 之后,对读取剪贴板内容引入了强制的用户授权要求,对应权限为 ohos.permission.READ_PASTEBOARD,属于 user_grant 类型。也就是说,App 想读取剪贴板内容,必须先弹出系统授权对话框,等待用户点击允许。
问题在于,用户面对“某某应用想要读取剪贴板内容”这类弹窗时,第一反应往往是拒绝,他们并不知道这个弹窗和当前操作有什么关系。尤其是在电商场景里,用户可能只是想粘贴一个商品编码,结果先被授权弹窗打断,体验非常割裂。因此,本文的实现最终砍掉了读取功能,只保留写入。
写入(setText)是免权限的,App 主动往系统剪贴板写数据不需要任何授权。读取(getText)则需要申请 READ_PASTEBOARD 权限并处理授权弹窗,这里直接移除了。如果产品确实需要粘贴功能,可以参照官方文档把 getText 加回来,但要做好弹窗授权率不高的心理准备。
鸿蒙剪贴板 API 核心调用链
只做写入时,调用链非常短,核心就三步:
- // 1. 构造剪贴板数据
- pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
- // 2. 获取系统剪贴板实例
- pasteboard.getSystemPasteboard();
- // 3. 写入数据(异步)
- systemPasteboard.setData(pasteData);
复制代码
不需要 getData(),也不需要 hasData(),更不需要在 module.json5 里声明任何权限。
TurboModule 完整实现
1. ArkTS 核心实现
核心逻辑放在 ArkTS 侧,通过 UITurboModule 基类暴露给 JS 层。这里只实现了 setText 一个方法,内部构造 PasteData 并调用系统剪贴板写入:
- // turbomodule/ClipboardModule.ets
- import { pasteboard, BusinessError } from '@kit.BasicServicesKit';
- import { UITurboModule, UITurboModuleContext } from '@rnoh/react-native-openharmony';
- const TAG = '[ClipboardModule]';
- export class ClipboardModule extends UITurboModule {
- static readonly NAME = 'ClipboardModule';
- constructor(ctx: UITurboModuleContext) {
- super(ctx);
- console.info(`${TAG} initialized`);
- }
- async setText(text: string): Promise<boolean> {
- try {
- const pasteData = pasteboard.createData(pasteboard.MIMETYPE_TEXT_PLAIN, text);
- const systemPasteboard = pasteboard.getSystemPasteboard();
- await systemPasteboard.setData(pasteData);
- return true;
- } catch (err) {
- const error = err as BusinessError;
- console.error(`${TAG} setText: code=${error.code}, msg=${error.message}`);
- return false;
- }
- }
- }
复制代码
去掉 getText 之后,整个写入逻辑只有不到 10 行,剩下的主要是类型声明和错误处理。
2. JS 侧 TypeScript 声明
JS 侧需要声明对应的 TurboModule 接口,并注册到 TurboModuleRegistry 中:
- // src/native/NativeClipboardModule.ts
- import type { TurboModule } from 'react-native';
- import { TurboModuleRegistry } from 'react-native';
- export interface Spec extends TurboModule {
- setText(text: string): Promise<boolean>;
- }
- export default TurboModuleRegistry.getEnforcing<Spec>('ClipboardModule');
复制代码
接口中只有一个方法 setText(text),返回 Promise<boolean>,true 表示写入成功,false 表示写入失败。
3. C++ 桥接层
C++ 侧继承 ArkTSTurboModule,并在构造函数中注册方法元数据。参数个数必须和 JS 侧一致,setText 有一个参数,所以元数据中写 1:
- // turbomodule/ClipboardModule.h
- #pragma once
- #include "RNOH/ArkTSTurboModule.h"
- namespace rnoh {
- class JSI_EXPORT ClipboardModule : public ArkTSTurboModule {
- public:
- ClipboardModule(const ArkTSTurboModule::Context ctx, const std::string name);
- };
- } // namespace rnoh
复制代码- // turbomodule/ClipboardModule.cpp
- #include "ClipboardModule.h"
- namespace rnoh {
- ClipboardModule::ClipboardModule(const ArkTSTurboModule::Context ctx, const std::string name)
- : ArkTSTurboModule(ctx, name) {
- methodMap_ = {
- ARK_ASYNC_METHOD_METADATA(setText, 1),
- };
- }
- } // namespace rnoh
复制代码
这里有一个隐蔽的坑:如果 C++ 侧注册了方法但 ArkTS 侧没有实现,运行时不会报错,但 JS 调用时会拿到 undefined。当初调试 getText 方法时,就是因为 C++ 侧注册了、ArkTS 侧没实现,排查了很久才定位到两侧方法没对齐。因此在删除某个方法时,一定要检查 C++ 的 methodMap_ 和 ArkTS 侧是否有残留。
4. Package 注册
和其他 TurboModule 一样,ClipboardModule 也需要通过 Package 机制暴露给 RNOH 运行时:
- // ClipboardPackage.h
- #pragma once
- #include "RNOH/Package.h"
- #include "turbomodule/ClipboardModule.h"
- namespace rnoh {
- class ClipboardPackageTurboModuleFactoryDelegate : public TurboModuleFactoryDelegate {
- public:
- SharedTurboModule createTurboModule(Context ctx, const std::string &name) const override {
- if (name == "ClipboardModule") {
- return std::make_shared<ClipboardModule>(ctx, name);
- }
- return nullptr;
- }
- };
- class ClipboardPackage : public Package {
- public:
- ClipboardPackage(Package::Context ctx) : Package(ctx) {}
- std::unique_ptr<TurboModuleFactoryDelegate> createTurboModuleFactoryDelegate() override {
- return std::make_unique<ClipboardPackageTurboModuleFactoryDelegate>();
- }
- };
- } // namespace rnoh
复制代码
此外还需要完成 4 处常规注册:
- PackageProvider.cpp:include ClipboardPackage.h 并 push_back 到 packages 列表;
- CMakeLists.txt:在源码列表中加入 ./turbomodule/ClipboardModule.cpp;
- GeneratedPackage.ets:import ClipboardModule 并注册到模块 map;
- App.tsx:完成 import、DemoKey、路由和 DemoCard 配置。
全部注册完成后,不需要在 module.json5 中新增任何权限声明。
踩坑记录
整套流程走下来,有四个值得记录的坑,算是给后来者排雷:
第一,createData 的 MIME 类型参数。第一个参数应当使用官方常量 pasteboard.MIMETYPE_TEXT_PLAIN,而不是手写字符串 'text/plain'。虽然手写字符串也能工作,但使用官方常量可以让编译器在拼写错误时给出提示,更安全。
第二,setData 是异步操作。systemPasteboard.setData() 返回的是 Promise<void>,需要用 await 等待完成。虽然写入通常只有毫秒级,但不 await 的话,JS 侧可能在写入完成前就继续执行后续逻辑。对于“复制到剪贴板”这种操作,不 await 问题通常也不大,用户感知不到先后差异,但为了严谨,还是建议按异步处理。
第三,C++ 参数个数必须与 JS 侧严格一致。ARK_ASYNC_METHOD_METADATA(setText, 1) 中的 1 对应 JS 接口 setText(text: string) 的一个参数。修改方法签名时,两边的元数据都要同步更新。
第四,权限文案清理。如果之前为了读取功能在 string.json 中配置过 read_pasteboard_reason 之类的授权文案,在移除读取功能后,记得同步删除对应的字符串资源,保持工程整洁。
JS 侧调用体验
JS 侧调用非常简单,直接 import 声明的模块,然后 await 即可:
- import Clipboard from '../native/NativeClipboardModule';
- const ok = await Clipboard.setText('你好,鸿蒙!');
- if (ok) {
- console.log('复制成功');
- } else {
- console.log('复制失败');
- }
复制代码
与其他需要用户交互的模块不同,剪贴板写入是纯代码行为。ImagePicker 需要用户主动选图,Scan 需要用户主动扫码,而 Clipboard.setText 不需要任何用户交互,也不会弹窗。只需在某个操作(比如长按复制按钮)的响应中直接调用,数据就会静默写入系统剪贴板,用户全程无感知。这也正是免权限 API 带来的体验优势。
权限总结与版本说明
最后把权限相关结论梳理一遍:
- 写入(setText):免权限,直接调用 pasteboard.createData + getSystemPasteboard + setData 即可;
- 读取(getText):需要 ohos.permission.READ_PASTEBOARD,API 12 起为 user_grant 强授权,需要弹窗;
- 写入和读取是两套权限体系,不要混淆;
- 移除读取功能后,注意清理 C++ methodMap_、ArkTS 实现和 string.json 中的残留。
本文实现基于 React Native 0.84 + RNOH 0.84.1,验证设备为 HarmonyOS 6.0。不同版本之间的 API 行为可能存在差异,以实际测试结果为准。所有代码示例均来自项目真实实现,已在鸿蒙设备上验证通过。 |