ReactNativeForOpenHarmony-纯ArkTS-TurboModule双侧注册-鸿蒙PC文件权限实战
示例仓库:https://atomgit.com/dijun_520/react-native-ohos-file-perm
社区引导:框架源码与版本发布见 https://atomgit.com/CPF-RN/ohos_react_native/tree/0.84-main
CPF-RN 鸿蒙社区:https://atomgit.com/CPF-rn
0. 环境与实测设备
| 项目 | 版本 / 型号 |
|---|---|
| DevEco Studio | 26.0.0 (26.x) |
| HarmonyOS SDK / API | 26.0.0 / API 26 |
| React Native / RNOH | 0.84.1(@rnoh/react-native-openharmony 0.84.1,C-API 架构) |
| Node.js | v22.11+ |
| 实测设备 | HarmonyOS 7 PC(MateBook Pro) |
1. 白屏三连:这个问题是怎么找上我的
起因很朴素:要给鸿蒙 PC 上的 React Native 应用做文件能力——选文件、另存为、拖拽导入、导出报表,都是桌面应用的日常。我原以为把手机上那套文件逻辑搬过来改改就行,真机一跑才发现,PC 和手机在文件体系上的差异是结构性的,手机上跑通的东西到 PC 上会全面失效。
最直观的变化是授权模型。手机上申请一个媒体权限,基本就能长期访问相册;PC 上系统倡导的是让用户通过 DocumentViewPicker 逐次授权,应用拿到的是一个 file:// URI,而且它的访问权是临时的——本次会话内有效,用户随时可以撤销,U 盘一拔即刻失效。应用要是把这个 URI 存进数据库想"下次接着用",真正读取那天就会撞上权限失效。
第二个变化是窗口成了权限的边界。PC 支持多窗口和分屏,A 窗口通过选择器拿到的文件 URI,拿到 B 窗口直接用是不合规的。应用得自己维护"哪个 URI 在哪个窗口被授权"的登记表——这个表后来成了整个 Demo 的数据结构核心。
第三,可写范围收紧了。桌面、文档这类用户敏感目录不允许静默写入;导出要么走"另存为"弹窗由用户自选位置,要么默认写进应用自己的导出区,再给一个显式的导出入口。
第四,外部输入面变宽了。鼠标拖拽是 PC 上最自然的文件交互,但拖来的 URI 可能指向可移动介质、可能类型不符、可能权限早就过期了,每一条都得校验。
那我们得出一些结论就出来了:文件能力必须由原生层成体系地封装,从选择器、另存为、拖拽校验、沙箱读写,到外接存储、批量导入、跨窗口隔离、WebView 范围、敏感目录拦截、权限回收。
【原因分析】:RN 侧没有现成的三方库覆盖这套东西,只能自己写 TurboModule,因为文件选择器、窗口管理这些全是 ArkTS 系统 Kit 的 API,C++ 里调不到,逻辑只能落在 ArkTS。
【第一次报错问题点】:跑起来,应用白屏,日志里是这三行:
E RNOH_JS: Exception in HostObject::get for prop 'FilePerm':
Couldn't find Turbo Module on the ArkTs side, name: 'FilePerm'
E RNOH_JS: Suggestions: Have you linked a package that provides this turbo module on the CPP side?
E RNOH_JS: Invariant Violation: "FilePermDemo" has not been registered.
ArkTS 侧的 Package 明明注册了,日志里也能看到 TurboModuleFactory.cpp:54> Creating Turbo Module: FilePerm,构建产物字节码里确实有 FilePermPackage 的符号。
RNOH 的 TurboModule 按实现语言分两类:C++ TurboModule,逻辑在 C++ 里,Codegen 生成元数据直接在 C++ 层分发;纯 ArkTS TurboModule,逻辑在 ArkTS 里。而纯 ArkTS TurboModule 在 RNOH 0.84 的 C-API 架构下,必须同时满足 ArkTS 侧和 C++ 侧的注册,任何一侧缺失,都以同一个报错暴露。这正是"照抄文档却跑不通"的根源:文档往往只写了 ArkTS 侧。
2. 效果:11 个场景,全部真机跑出来的
按老规矩,先给效果再讲原理。项目运行是一个 11 页签的 PC 窗口应用,下面这组数据全部来自 hilog 时间戳和这次回归的记录,原始日志在仓库里可以直接复核:
| 指标 | 数值 |
|---|---|
| 场景回归结果 | 11/11 通过(真机 HarmonyOS 7 PC) |
| TurboModule 创建 → RN 应用注册完成 | 11个完成 |
| 故障状态下异常抛出点 | bundle 求值阶段 |
| 模块名要求逐字符一致的位置 | 3 处(JS get / ArkTS 工厂 key / C++ delegate) |
| C++ 桥接层代码量 | 1 个 delegate + 1 张方法元数据表,约 60 行PackageProvider.cpp |
接下来,我们可以先来看一下结果,以下分为按11个场景过一遍,每个场景给真机截图和实测日志。
示例应用启动后是,所有调用的原生返回值以 JSON 回显在底部日志面板——这正是第 1 条要求的"先给效果":

场景一:文件选择器(临时授权)。 点击"单选一个文档",拉起系统"选择文件"弹窗,注意弹窗内的官方提示"应用仅可访问您选定的文件或文件夹"——这就是鸿蒙授权模型的用户可见面:

在弹窗中选中 report.txt 后,界面进入"已选 (1/1)"状态,"打开"按钮点亮:

点"打开"后,JS 侧拿到的是 URI 而非路径,且附带了明确的能力说明:

日志关键行(绿色为 ok:true):
[17:32:20] pickDocuments → {"ok":true,"uris":["file://docs/storage/Users/currentUser/report.txt"],"note":"临时授权:URI 仅本次/短时可用"}
场景二:另存为(用户决定落盘位置)。 点击"另存为 report.txt",拉起系统"选择路径"弹窗,文件名已按建议名预填:

因为目标位置已存在同名文件,系统弹出覆盖确认——用户始终拥有最终决定权,应用没有任何绕过弹窗直写的路径:

点"替换"后保存成功,返回用户选定的 URI:

场景三:沙箱读写(零权限)。 "写入沙箱"与"读取沙箱"两个按钮验证应用自身沙箱内 filesDir 的读写——这类操作不需要任何权限声明,返回的是真实落盘路径:


日志:
[17:31:37] sandboxWrite → {"ok":true,"uri":"/data/storage/el2/base/haps/entry/files/data/demo.txt"}
[17:34:20] sandboxRead → {"ok":true,"content":"私有缓存/数据库/临时文件仅放沙箱"}
场景四:导出到应用导出区。 exportFile 把内容写进沙箱内 files/export/ 目录并自动净化文件名,不静默写任何共享目录;业务侧需要分享时再走系统分享:

场景五:敏感目录拦截。 对 /system/etc/hosts 做访问前检查,命中系统目录前缀直接拒绝——这类调用哪怕系统不拦,应用层也应该先拦,避免把风险路径透传给底层:

场景六:权限回收重校验。 revalidateGrants 对所有已登记 URI 逐一 statSync 探活,失效项自动从登记表清除并返回清单。当前无失效项时返回空列表,但机制本身是 U 盘场景的生命线——把文件放到 U 盘上授权后拔盘,再点这个按钮,revoked 数组就会出现对应 URI(第 4 节边界条件里有详细说明):

系统侧日志同样可以印证调用链路(hdc shell hilog 过滤 filePicker 相关,完整日志见仓库 article/images/13_hilog_evidence.txt):
11-08 17:32:59.319 I A0F666/filePicker/FileMgr: SourceManager:sourceBusiness [syncAndCleanSources]
update existing source: com.rnoh.awesomeproject, DB appName: FilePermDemo
11-08 17:32:59.320 I A0F666/filePicker/FileMgr: SourceDbManager:sourceBusiness updateAppNameAndIcon start,
bundleName: com.rnoh.awesomeproject, appName: FilePermDemo
两轮完整真机运行的全量日志对照(同一应用连续多轮调用,时间戳从 17:31 到 18:33):

效果说完,下面进入核心:这套东西在 RNOH 里是怎么接起来的。
3. RNOH 是怎么把一个模块"找"出来的
要理解为什么双侧注册缺一不可,得先看清 RNOH 0.84 C++ 侧 TurboModuleFactory::create 的完整流程。以下分析基于 ohpm 安装的 @rnoh/react-native-openharmony@0.84.1 包内源码(src/main/cpp/RNOH/TurboModuleFactory.cpp):
当 JS 侧执行 TurboModuleRegistry.get('FilePerm') 时,C++ 的 TurboModuleFactory 按三步走。
第一步,问 ArkTS 侧"这个模块跑在哪个线程"。源码里的 findArkTSTurboModuleThread 方法先检查 worker 线程特性开关(没启用就跳过),然后通过 napi 桥调 ArkTS 侧 TurboModuleProvider.hasModule(name)。
注意这里查的是主线程的 TurboModuleProvider——它由应用 ArkTS 侧
createRNPackages()返回的所有 Package 装配而成。Provider 里没登记FilePerm这个名字的话,hasModule返回 false,C++ 就会认为"这个模块没有 ArkTS 实例引用"。
第二步,拿到 ArkTS 实例引用。第一步命中后,C++ 再调一次 ArkTS 的 getModule(name),拿到真正的 ArkTS 模块对象,包成 napi 引用塞进 Context。这个引用是后续所有 JS 到 ArkTS 方法调用的桥梁——JS 调 pickDocuments 时,C++ 就是拿着它去调 ArkTS 对象上的同名方法。
第三步,遍历 C++ 侧的 TurboModuleFactoryDelegate。这是最容易漏的一环。C++ 侧有一个 delegate 列表(m_delegates),每个 delegate 都有机会"认领"一个模块名:某个 delegate 的 createTurboModule(ctx, name) 返回非空时,RNOH 检查返回值是不是 ArkTSTurboModule 类型——如果是,但 Context 里没有第一步拿到的 ArkTS 实例引用,就直接抛出 Couldn't find Turbo Module on the ArkTs side 这个 Fatal 错误。
三步串起来,故障的形成机制就清楚了:ArkTS 侧的 Provider 里有 FilePerm(所以第三步的 delegate 能把模块包装成 ArkTSTurboModule,dynamic_pointer_cast 成功),但 C++ 侧没有任何 Package 声明过"我认识 FilePerm 这个名字"(所以 Context 里的实例引用始终为空)。ArkTS 说"我有",C++ 说"我不认识",两边对不上,RNOH 宁可抛 Fatal 也不返回一个调不通的空壳模块——因为那会让 JS 拿到一个调用任何方法都无响应的对象,问题会被推迟到更难排查的业务现场。
报错文案里那句建议语也值得说一句。“Have you linked a package that provides this turbo module on the CPP side?”——第一反应往往是"我只写了 ArkTS,哪来的 CPP 侧?“其实这句话是准确意义上的提示:对纯 ArkTS TurboModule,C++ 侧需要的不是业务实现,而是一个几行代码的桥接 delegate,唯一职责是"认领名字、把 ArkTS 实例包装成 ArkTSTurboModule”。业务逻辑仍然百分之百在 ArkTS。
相关细节:RNOH 官方核心包注册 Networking、AppState 这些模块用的就是同一套机制——ArkTS 侧实现继承 UITurboModule 或 AnyThreadTurboModule,C++ 侧由 Codegen 自动生成的 Package 提供 delegate。官方模块"开箱即用",是因为 codegen 替你完成了 C++ 侧注册;手写模块没人替你生成,这一环必须自己补上。这也是为什么社区把这一步叫"最隐蔽的坑"——构建期零报错,所有错误延迟到运行期首屏。
放到整个项目中,一次"点击按钮 → 原生执行 → 日志回显"的完整链路是四层接力:
JS 层(React)
App.tsx ── 11 个场景页面,按钮回调构造 () => Promise<string>
useFilePerm.ts ── 统一 call/parse/log,异常带栈回显
NativeFilePerm.ts ── TurboModuleRegistry.get('FilePerm')
│
▼
C++ 桥接层(RNOH 运行时)
TurboModuleFactory ── findArkTSTurboModuleThread(查线程)
FilePermTurboModuleFactoryDelegate ── 认领名字(本节的三步流程)
ArkTSTurboModule ── 持有 ArkTS 实例引用,转发 JS 调用
│
▼
ArkTS 原生层(entry/src/main/ets/fileperm/)
FilePermPackage ── getUITurboModuleFactoryByNameMap
FilePermTurboModule ── 11 场景业务实现 + grantedByWindow 授权登记表
│
▼
系统层(HarmonyOS API 26)
DocumentViewPicker / fileIo(fs) / BusinessError / 沙箱路径
C++ 桥接层的三个必答题值得记在脑子里:线程(模块跑主线程还是 JS 线程,对应 UITurboModule 和 AnyThreadTurboModule)、名字(delegate 认不认识这个名字)、方法表(C++ 元数据里有没有这个方法——元数据表缺失时 JS 拿到的模块"看起来存在、什么方法都没有",调用即抛 undefined is not a function,而且堆栈指向业务调用行而不是桥接层)。
4. RNOH主要的逻辑参考 - 核心代码分析:双侧注册与授权生命周期:
整个能力由三部分组成:JS 侧规格声明、ArkTS 侧业务实现、C++ 侧桥接 delegate。下面按数据流依次拆。
4.1 JS 侧:TurboModule 规格声明
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
// 统一返回结构:原生侧固定返回 JSON 字符串,此接口供 JS 侧解析后的类型提示
export interface PickResult {
ok: boolean;
uris?: string[];
error?: string;
reason?: string;
note?: string;
}
export interface FilePermSpec extends TurboModule {
/** 场景1 文件选择器(临时授权) */
pickDocuments(multiple: boolean, suffixes: string[]): Promise<string>;
/** 场景2 另存为 */
saveFileAs(suggestedName: string, content: string): Promise<string>;
/** 场景3 拖拽 URI 校验 */
validateDroppedUri(uri: string, suffixes: string[]): Promise<string>;
/** 场景4 导出/下载 */
exportFile(fileName: string, content: string): Promise<string>;
/** 场景5 沙箱读写 */
sandboxWrite(relativePath: string, content: string): Promise<string>;
sandboxRead(relativePath: string): Promise<string>;
/** 场景7 批量导入(文件夹顶层) */
pickFolderTopLevel(): Promise<string>;
/** 场景8 跨窗口 URI 隔离 */
checkUriForWindow(uri: string, windowId: string): Promise<string>;
registerGrantForWindow(uri: string, windowId: string): Promise<string>;
/** 场景9 WebView 本地文件范围 */
resolveForWebView(uri: string, windowId: string): Promise<string>;
/** 场景10 敏感目录拦截 */
sensitiveCheck(path: string): Promise<string>;
/** 场景11 权限回收重校验 */
revalidateGrants(): Promise<string>;
/** 场景6 外接存储探测 */
probeExternal(uri: string): Promise<string>;
}
export default TurboModuleRegistry.get<FilePermSpec>('FilePerm');
其二,模块名 'FilePerm' 这个字符串出现在三个地方:JS 的 get、ArkTS 工厂 map 的 key、C++ delegate 的判断。
4.2 ArkTS 侧:业务实现与 Package 注册
业务类 FilePermTurboModule 继承 UITurboModule。选 UITurboModule 而不是 AnyThreadTurboModule 是有理由的:选择器弹窗、保存弹窗这类 UI 交互必须在主线程触发,而且这个 Demo 的文件 IO 量级不构成性能瓶颈。将来要做大文件批量处理,切到 AnyThreadTurboModule 并启用 worker 就行。
Package 注册就是一张"名字到构造函数"的映射表:
import { RNPackage, UITurboModule, UITurboModuleContext } from '@rnoh/react-native-openharmony/ts';
import { FilePermTurboModule } from './FilePermTurboModule';
type TurboModuleFactoryFn = (ctx: UITurboModuleContext) => UITurboModule | null;
export class FilePermPackage extends RNPackage {
// 懒加载 factory map:只有 JS 侧真正请求时才实例化模块
getUITurboModuleFactoryByNameMap(): Map<string, TurboModuleFactoryFn> {
const map = new Map<string, TurboModuleFactoryFn>();
map.set('FilePerm', (ctx: UITurboModuleContext): UITurboModule => {
return new FilePermTurboModule(ctx);
});
return map;
}
}
ArkTS 严格模式在这里留了两个细节。返回值类型必须与基类 RNPackage 声明的 Map<string, TurboModuleFactory<UITurboModule, UITurboModuleContext>> 结构兼容,所以我用本地别名 TurboModuleFactoryFn 把完整的函数签名展开写出来,而不是引用一个语义相同的自定义泛型——结构化类型比对在边界情况下会误判,在 AnyThread 版本上实际踩过:工厂 map 的返回类型写法与基类不兼容时,编译期只给警告,运行期 hasModule 查询失败,另外 map 的 value 返回类型写 UITurboModule 而不是 null,让类型系统保证工厂永远产出实例。
业务实现里最能体现"PC 文件权限"特点的是两段。第一段是选择器场景——只拿 URI、只做内存登记,绝不做任何持久化:
/** 场景 1:系统文件选择器(临时授权,仅拿到 URI) */
async pickDocuments(multiple: boolean, suffixes: Array<string>): Promise<string> {
try {
const pickerView = new picker.DocumentViewPicker();
const options = new picker.DocumentSelectOptions();
options.maxSelectNumber = multiple ? 99 : 1; // 单选/多选由 JS 侧意图决定
if (suffixes.length > 0) {
options.fileSuffixFilters = suffixes; // 后缀白名单,从源头限制可选类型
}
const uris: Array<string> = await pickerView.select(options);
// 仅登记到当前窗口上下文;不做任何持久化 → 满足"禁止持久持有访问权限"
const note = '临时授权:URI 仅本次/短时可用';
return `{"ok":true,"uris":${JSON.stringify(uris)},"note":"${note}"}`;
} catch (err) {
const e = err as BusinessError;
return `{"ok":false,"error":"${e.code}:${e.message}"}`; // 用户取消也走统一错误结构
}
}
第二段是权限回收重校验,整个模块里最"安全工程"的方法。内存登记表 grantedByWindow(Map<windowId, Set<uri>>)是唯一的事实来源,重校验时逐条探测、失效即删、随删随报:
/** 场景 11:权限回收——重校验所有登记 URI,清空失效项 */
async revalidateGrants(): Promise<string> {
const revoked: Array<string> = [];
const alive: Array<string> = [];
grantedByWindow.forEach((set, win) => {
set.forEach(u => {
try {
fs.statSync(u); // 探测:URI 是否仍可访问
alive.push(u);
} catch (err) {
revoked.push(u); // 授权被撤销 / 介质拔出 / 会话过期
set.delete(u); // 立刻清出登记表,杜绝"带着失效凭证继续读写"
}
});
if (set.size === 0) {
grantedByWindow.delete(win); // 空窗口登记也一并清理
}
});
const note = '已清空失效 URI 登记,请提示用户重新选择';
return `{"ok":true,"revoked":${JSON.stringify(revoked)},"alive":${JSON.stringify(alive)},"note":"${note}"}`;
}
跨窗口隔离(场景 8)就建立在这张登记表上:checkUriForWindow(uri, windowId) 只查目标窗口自己的 Set,查不到就返回明确的拒绝原因——第 2 节截图里那句"A 窗口权限不可直接给 B 窗口"就是它返回的,隔离是从数据结构层面保证的,不靠约定。WebView 范围控制(场景 9)做两层校验:先确认 URI 在本窗口已登记,再做路径穿越检测(拒绝包含 /../ 片段的变体 URI),两关都过才允许交给 WebView 加载。
4.3 C++ 侧:桥接 delegate 与方法元数据表
这是解决报错的关键增补,完整文件六十来行,与仓库 harmony/entry/src/main/cpp/PackageProvider.cpp 逐行一致:
#include "RNOH/PackageProvider.h"
#include "RNOH/ArkTSTurboModule.h"
#include "RNOH/TurboModuleFactory.h"
using namespace rnoh;
namespace {
/**
* FilePerm 纯 ArkTS TurboModule 的 C++ 侧包装:
* - methodMap_ 声明每个方法的元数据(名称 + 参数个数),RNOH 据此在 JSI 层
* 暴露属性;缺了它,JS 侧拿到的模块对象上没有任何方法(undefined is not a function)。
* - 返回 Promise 的方法用 ARK_ASYNC_METHOD_METADATA。
*/
class FilePermArkTSTurboModule : public ArkTSTurboModule {
public:
FilePermArkTSTurboModule(const ArkTSTurboModule::Context ctx, const std::string name)
: ArkTSTurboModule(ctx, name) {
methodMap_ = {
ARK_ASYNC_METHOD_METADATA(pickDocuments, 2),
ARK_ASYNC_METHOD_METADATA(saveFileAs, 2),
ARK_ASYNC_METHOD_METADATA(validateDroppedUri, 2),
ARK_ASYNC_METHOD_METADATA(exportFile, 2),
ARK_ASYNC_METHOD_METADATA(sandboxWrite, 2),
ARK_ASYNC_METHOD_METADATA(sandboxRead, 1),
ARK_ASYNC_METHOD_METADATA(pickFolderTopLevel, 0),
ARK_ASYNC_METHOD_METADATA(checkUriForWindow, 2),
ARK_ASYNC_METHOD_METADATA(registerGrantForWindow, 2),
ARK_ASYNC_METHOD_METADATA(resolveForWebView, 2),
ARK_ASYNC_METHOD_METADATA(sensitiveCheck, 1),
ARK_ASYNC_METHOD_METADATA(revalidateGrants, 0),
ARK_ASYNC_METHOD_METADATA(probeExternal, 1),
};
}
};
/**
* C++ 侧桥接 delegate:认领 "FilePerm" 模块名,
* 把 ArkTS TurboModuleProvider 创建的实例包装为 ArkTSTurboModule 交给 JS。
*/
class FilePermTurboModuleFactoryDelegate : public TurboModuleFactoryDelegate {
public:
SharedTurboModule createTurboModule(Context ctx, const std::string& name) const override {
if (name == "FilePerm") { // 认领模块名,必须与 JS/ArkTS 两侧逐字符一致
return std::make_shared<FilePermArkTSTurboModule>(ctx, name); // 建立 napi 引用桥
}
return nullptr; // 不认识的名字交给下一个 delegate
}
};
class FilePermPackage : public rnoh::Package {
using Super = rnoh::Package;
public:
using Super::Super;
std::unique_ptr<TurboModuleFactoryDelegate> createTurboModuleFactoryDelegate() override {
return std::make_unique<FilePermTurboModuleFactoryDelegate>();
}
};
} // namespace
std::vector<std::shared_ptr<Package>> PackageProvider::getPackages(Package::Context ctx) {
auto packages = std::vector<std::shared_ptr<Package>>{};
packages.push_back(std::make_shared<FilePermPackage>(ctx));
return packages;
}
第一个问题:JS 怎么"看见"模块上的方法?
答案是 methodMap_ 这张方法元数据表。ArkTSTurboModule 本质是一个 JSI HostObject:JS 侧第一次访问 FilePerm.pickDocuments 这个属性时,JSI 会调用 HostObject 的 get(),get() 拿属性名去 methodMap_ 里查——查到了,就返回一个能转发到 ArkTS 的函数;查不到,JS 拿到的是 undefined,调用时抛 undefined is not a function,而且堆栈指向业务调用行,不指向桥接层。所以方法表必须逐个登记模块的全部方法名和参数个数:ARK_ASYNC_METHOD_METADATA(方法名, 参数个数) 声明的是异步方法(返回 Promise),参数个数用于调用前校验,对不上会直接报参数错误,而不是把 undefined 悄悄传进 ArkTS。
每新增一个原生方法,ArkTS 实现、C++ 方法表、JS 规格三处要同步改,漏任何一处都在运行期才暴露。
第二个问题:ArkTS 实例怎么被 JS 调到?
答案是 delegate 认领名字。FilePermTurboModuleFactoryDelegate::createTurboModule 被遍历调用时,拿到的 ctx 里已经带着第 3 节三步流程的前两步准备好的 ArkTS 实例引用——FilePermArkTSTurboModule(ctx, name) 这个构造把引用挂到模块对象上,之后 JS 的每一次方法调用都通过它转发到 ArkTS 对象。
为什么继承 ArkTSTurboModule 写子类、而不是直接 make_shared<ArkTSTurboModule>?因为方法表跟模块走:每个模块有自己的方法集合,把表收进子类构造函数,delegate 的 createTurboModule 才能保持"一行认领"的干净逻辑。
第三个问题:RNOH 怎么知道有这个 Package?
答案是 getPackages 挂接。FilePermPackage::createTurboModuleFactoryDelegate() 是 Package 与工厂的挂钩点,RNOH 初始化时收集所有 Package 的 delegate 组成 m_delegates 列表。最后 getPackages 把 Package 交给 C++ PackageProvider 体系,与 ArkTS 侧 RNPackagesFactory 的注册相互独立、各自生效。
下面这张截图是方法表缺失时的故障形态——当时 delegate 认领了名字,但还没写 methodMap_,应用能跑、界面正常,11 个场景按钮全部变成红色异常日志:

日志面板每一条都是
undefined is not a function,堆栈指向 bundle 内业务调用行。它和"双侧注册失败直接白屏"是两种完全不同的故障形态,前者更隐蔽。
一句话总结这套分工:ArkTS 侧回答"模块是什么、怎么干活",C++ 侧回答"我认识这个名字,以及它有哪些方法"。理解了这一点,Couldn't find Turbo Module on the ArkTs side 和 undefined is not a function 都不再神秘——前者是两侧"认识"状态不一致的哨兵,后者是方法表登记不全的哨兵。
4.4 权限:最小化申请,每一条都有理由
把工程 module.json5 的 requestPermissions 摊开看,只有三条:
// harmony/entry/src/main/module.json5(节选)
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" },
{
"name": "ohos.permission.READ_WRITE_DOCUMENTS_DIRECTORY",
"reason": "$string:perm_documents_reason", // 用户可见的用途说明
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
},
{
"name": "ohos.permission.READ_WRITE_DOWNLOAD_DIRECTORY",
"reason": "$string:perm_download_reason",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
]
INTERNET 是调试期依赖,dev 模式下 JS bundle 和热更新都走 Metro(localhost:8081),release 包改用内置 bundle 之后可以考虑去掉。两个目录权限是导出场景的兜底——本文的设计是"默认写应用导出区、落用户目录一律走另存为弹窗",这两个权限只覆盖"另存为弹窗返回用户目录 URI 之后的写入动作",并且都用 reason 加 usedScene 声明了用途和触发时机。
静态声明之外还有一步:这两个目录权限在鸿蒙上是 user_grant 类型,module.json5 声明只是"报备",真正生效还要在运行时向用户发起授权弹窗。工程里放在 RN 容器 Index.ets 的 aboutToAppear 中完成,PC 上是鼠标加键盘操作的弹窗交互:
// harmony/entry/src/main/ets/pages/Index.ets(节选,完整文件见仓库)
// 运行时动态申请文件目录读写权限(鸿蒙 PC 上鼠标键盘操作弹窗授权)
private requestFilePermissions(): void {
const permissions: Array<Permissions> = [
'ohos.permission.READ_WRITE_DOCUMENTS_DIRECTORY',
'ohos.permission.READ_WRITE_DOWNLOAD_DIRECTORY'
];
try {
const atManager = abilityAccessCtrl.createAtManager();
atManager.requestPermissionsFromUser(this.rnohCoreContext!.uiAbilityContext, permissions)
.then((result) => {
// 授权结果落日志,真机上逐项核对授予状态
this.logger.info("Index", `权限申请结果 authResults=${JSON.stringify(result.authResults)}`);
})
.catch((err: Error) => {
this.logger.error("Index", `权限申请失败: ${err.message}`);
});
} catch (err) {
this.logger.error("Index", `权限申请异常: ${JSON.stringify(err)}`);
}
}
两个容易忽略的细节。其一,requestPermissionsFromUser 必须持有 uiAbilityContext——在 RN 容器里要从 RNOHCoreContext 取,不能用 getContext() 的默认上下文,否则弹窗拉不起来。其二,申请动作放在 aboutToAppear 里同步发起、不 await:权限弹窗和 RN 首帧渲染并行,不阻塞启动;授权结果只落日志供核对,被拒绝时文件能力按"未授权"分支降级返回结构化错误,而不是崩掉。
文件选择器走
DocumentViewPicker,权限本体在系统手里,应用拿到的是带临时凭证的 URI;沙箱读写操作的是应用自己的el2/base目录,天然可读写;拖拽与批量导入的授权上下文由系统在交互时授予。这套方案的取向就是:权限是兜底,picker 是主路径——凡是需要静态权限才能完成的文件访问,在鸿蒙 PC 上几乎都存在更合规的 picker 替代路径。后续要加"网络盘"或"分布式文件"场景时,按需最小化新增权限,把reason写成用户能看懂的中文,别为了图省事整包带上。
5. 我踩过的坑
开发过程中我养成了一个习惯:每踩一个坑就立刻把报错原文、排查动作和最终解法记进仓库的证据目录,这份清单就是从里面直接整理出来的:
5.1 API 组(RNOH / 鸿蒙 / ArkTS)
坑 1|只注册 ArkTS 侧,TurboModule 依然找不到:
E RNOH_JS: Couldn't find Turbo Module on the ArkTs side, name: 'FilePerm'
E RNOH_JS: Invariant Violation: "FilePermDemo" has not been registered.
根因:只注册了 ArkTS 侧 Package,缺 C++ 桥接 delegate,0.84 下纯 ArkTS TurboModule 必须双侧注册,ArkTS 写得再对,C++ 不认识名字就到不了。
坑 2|白屏的另一种来源:Metro 缓存了旧工程名。应用启动后白屏,日志却有 Running "FilePermDemo",紧接着:
E RNOH_JS: { [Invariant Violation: "FilePermDemo" has not been registered. This can happen if:
* Metro (the local dev server) is run from the wrong folder...
排查方法:直接下载 Metro 正在服务的 bundle 检查——curl http://localhost:8081/index.bundle?platform=harmony,grep 发现整个 bundle 里 FilePermDemo 出现 0 次,注册的还是旧工程名 AwesomeProject。
根因是 Metro 的 transform 缓存里存着改名前的产物,杀掉 Metro、
npx react-native start --reset-cache重启即恢复。
坑 3|SelectMode 枚举名对不上 SDK:
Property 'SelectMode' does not exist on type 'typeof picker'
按直觉写了 picker.SelectMode.FOLDER_MODE,翻 API 26 的 @ohos.file.picker.d.ts 才发现枚举类型名是 DocumentSelectMode,成员是 FOLDER。
鸿蒙 Kit API 的枚举名和成员名在不同 API 版本之间变动频繁,动手前先 grep 本机 SDK 的 d.ts(
{SDK目录}/ets/api/@ohos.file.picker.d.ts),别凭记忆或旧文章写代码。
坑 4|ArkTS 对正则字面量的解析比 TS 严格得多。路径穿越检测里写了 /\\/\\.+|\\/\\.\\./g 这样的正则字面量,TS 里完全合法,ArkTS 编译器报了一串 Invalid character 和 Expected 2 arguments, but got 4——字符类里的 ?、"、<、| 被当成词法层面的非法字符,文件名净化用的 /[\\/:*?"<>|]/g 同样中招。
修复方式:路径穿越检测改成循环调用
String.replace('/../', '/')直到结果稳定,文件名净化改成逐字符白名单过滤。
坑 5|Record<string, ...> 的类型逃逸,报 arkts-no-any-unknown。用 Record<string, string | boolean> 收集校验结论直接被拒——ArkTS 禁止隐式 any/unknown,Record 的动态索引访问被视为不可控。
修复方式:彻底不用
Record拼返回值,直接拼 JSON 字符串模板,面向 ArkTS 写代码,把它当成一门更严格的独立语言。
5.2 bundleName 与调试签名 Profile 绑定
坑 6|bundleName 与调试签名 Profile 绑定:
The bundleName in app.json5 does not match the bundleName in the generated SigningConfigs
改包名后 SignHap 阶段报错。根因是 DevEco 自动生成的调试 Profile(.p7b)和 bundleName 绑定,换包名等于换了应用身份。
在 File > Project Structure > Signing Configs 里为新包名重新生成。频繁新建 demo 的团队,建议固定一个"调试专用 bundleName + Profile"复用。
坑 7|命令行打包报 spawn java ENOENT:
hvigor ERROR: Failed :entry:default@PackageHap... spawn java ENOENT
C++ 编译、ArkTS 编译全过,最后 PackageHap 阶段炸——HAP 签名工具需要 java,而命令行环境没有。解法:把 DevEco 自带 JBR({安装目录}/jbr/bin)加入 PATH 或设 JAVA_HOME。这个坑的特征是"IDE 里能构建、命令行不能",容易误判为工程配置问题。顺带一提,如果 PATH 里明明有 java 还报这个错,杀掉命令行里含 hvigor 的旧 worker 进程再试——hvigor 会复用旧环境里起的进程。
坑 8|跨工程复制 harmony 目录后 CMake 报错。把 .cxx 缓存目录一起带过来时,CMake 拿着旧工程的绝对路径配置报错,清理 .cxx 和 build/default/intermediates/cmake 重新构建即恢复。教训:跨工程复制 harmony 目录永远不要带 .cxx。
5.3 JS 运行时组(Hermes / RN 侧)
坑 9|C++ 方法表漏登记,模块"看得见、调不动":
E RNOH_JS: 异常: undefined is not a function | stack: at anonymous (bundle.harmony.js:1028:40)
应用不白屏、页签正常、11 个按钮全红——这是比坑 1 更隐蔽的故障形态。根因:delegate 认领了模块名,但 methodMap_ 里漏登记了方法(初版只登记了前几个),JSI 的 HostObject get() 查不到方法名,JS 拿到 undefined。排查靠 useFilePerm 钩子的异常栈回显定位到"每个方法都炸",才反推到方法表。
方法表是第三个"必须逐字符一致"的地方——方法名与 ArkTS 实现一致、参数个数与签名一致,新增方法时三处同步。
坑 10|Hermes 下 toLocaleTimeString 不可用,日志时间戳直接崩:
E RNOH_JS: TypeError: d.toLocaleTimeString is not a function
RNOH 0.84 的 Hermes 引擎在默认配置下没有完整的 Intl 能力,locale 相关的 toLocaleTimeString、toLocaleDateString 都不可靠。日志时间戳手动拼 HH:mm:ss(pad 两位补零),见 js/useFilePerm.ts 的 push 方法。
RN 上JS 内置方法在 Hermes 最小化配置下未必存在,UI 层工具函数要先在真机过一遍。
坑 11|picker.save() 用户取消不抛错,返回空数组:
E RNOH_JS: 异常: Parameter error (401)
初版把"取消"当异常路径处理,等系统抛错——实际用户点取消时 save() 返回空数组,代码继续走 result[0] 取到 undefined,fs.openSync(undefined, ...) 抛 401 参数错误,显式判断 result.length === 0,返回 {"ok":false,"error":"用户取消保存"}(4.2 节代码已含)。
系统 API 的"取消"语义要么抛错、要么空返回,两个分支都要接住,并映射成业务语义。
坑 12|fs.statSync 对目录同样成功,“存在"不等于"是文件”。
拖拽校验把一个目录 URI 判成了有效文件(ok:true),后续按文件读取才失败。根因:statSync 只回答"路径可访问",目录同样返回成功,区分类型要靠 isFile(),校验链固定为 statSync → isFile() → 后缀白名单 → 可移动介质标记 四道独立闸门(4.2 节 validateDroppedUri 的实现顺序)。
授权校验的每一步都是独立闸门,合并判断会放过"目录授权"的口子。
两个一句话小坑:Hermes 的 JSON.stringify 会静默丢弃值为 undefined 的字段,日志面板上"字段消失"先查这里,返回结构只允许 string/boolean/number/数组;DevEco 首次无线 hdc 连接偶发 file recv 半途截断,重发一次即可,别急着怀疑工程。
6. 总结:
本文围绕 RNOH 0.84 C-API 架构下 React Native 鸿蒙 PC 端文件权限难题,落地了一套纯 ArkTS TurboModule 方案,核心是依靠JS、ArkTS、C++ 双侧注册机制打通 JS 业务层与鸿蒙系统文件套件,解决 RN 原有文件库适配鸿蒙 PC 时的底层矛盾。
鸿蒙 PC 采用临时 URI 授权模型,授权不持久、会随 U 盘拔出、介质删除、超时自动失效,叠加 PC 独有的拖拽、多窗口、WebView 本地文件加载等场景,传统基于固定路径读写的 RN 文件方案完全无法适用。该方案把文件权限生命周期完整封装:通过 DocumentViewPicker 获取临时 URI 凭证、按窗口隔离登记授权、访问前校验可用性、提供主动探活回收接口,沙箱读写、另存导出、敏感路径拦截等能力一并收敛,同时遵循鸿蒙安全规范,不持久存储授权凭证。
文件权限方案检查清单:
- 选择器 URI 仅内存登记、禁止落盘持久化,授权表按窗口 ID 隔离;
- 所有 URI 使用前过
fs.statSync探测,失效必须走"清理登记、用户提示"路径,不允许未捕获异常; - 导出默认写应用导出区,落用户目录一律经"另存为"弹窗,桌面/文档/系统目录做前缀拦截;
- 批量导入只读所选文件夹顶层、不递归,并处理"目录已授权但不可枚举"的降级(场景 7 实测就有这个分支);
- 拖拽与外接存储的 URI 做"可移动介质"标记,介质离线时界面降级提示。
工程上明确了双侧注册的铁律:三处模块名严格一致、C++ 侧方法元数据参数数量与 ArkTS 实现对齐,这套注册链路也是该版本最容易引发白屏报错的坑点;架构上采用统一 JSON 字符串返回、ok 标记的错误契约,所有原生异常、用户取消、授权失效都走 resolve 返回,避免 JS 未捕获异常造成页面崩溃,JS 层再做解析兜底,大幅降低业务侧异常处理成本。
FilePermDemo/
├── App.tsx # 11 场景页面 + 运行日志面板
├── js/NativeFilePerm.ts # TS 规格:TurboModuleRegistry.get('FilePerm')
├── js/useFilePerm.ts # 调用钩子(异常带栈回显)
├── harmony/entry/src/main/ets/
│ ├── fileperm/FilePermTurboModule.ets # ArkTS 业务实现(13 方法 + 授权登记表)
│ ├── fileperm/FilePermPackage.ets # getUITurboModuleFactoryByNameMap 注册
│ ├── fileperm/index.ets
│ ├── RNPackagesFactory.ets # createRNPackages → FilePermPackage
│ └── pages/Index.ets # RNApp 配置(C-API,appKey: FilePermDemo)
├── harmony/entry/src/main/cpp/
│ └── PackageProvider.cpp # FilePermPackage(C++) + delegate + 方法表
方案已经在 HarmonyOS 7 PC 真机完成多场景验证,区分了沙箱免权、用户弹窗授权两类路径,最小化权限声明;同时梳理出 Hermes 兼容、Metro 打包、符号链接、URI 异常捕获等真机调试高频踩坑点,并给出边界场景处理策略。最终封装成可复用的三方库,让 RN 业务代码最小改动就能适配鸿蒙 PC 文件安全模型,也为其他开发者开发纯 ArkTS TurboModule 提供了一套可直接复刻的落地范式。
更多推荐




所有评论(0)