我们团队最近接到一个有点特殊的任务:把现有的 React Native 客户端平滑迁移到 OpenHarmony 平台。App 里有一个很常见的功能——从相册选图和调用相机拍照,原本在 Android 和 iOS 两端一直用的是 react-native-image-picker。到了鸿蒙这边,本以为要自己从头写一个桥接模块,后来发现社区里已经有现成的解决方案:@react-native-ohos/react-native-image-picker。这篇文章就围绕着这个三方库,把我从零开始集成、配置、调试,到最终跑通全流程的完整经验记录下来,包括踩过的坑、查过的源码、验证过的边界场景,希望给同样在做 RN 鸿蒙化的朋友提供一份能直接用得上的参考。
简单交代一下项目背景,我们是一个偏工具类的跨平台应用,React Native 版本用的是 0.72 系列,鸿蒙那边的目标机型覆盖手机和平板。选图需求不复杂但是链路长,要覆盖系统相册、相机拍摄、裁剪压缩、权限被拒后的用户引导这些场景。如果这些事情都要通过原生代码在 OpenHarmony 上重写一遍,工作量不小,而且后续维护成本很高。正因为如此,从一开始我就倾向于优先调研社区的三方库方案,而不是直接自研。事实证明,这个决策在前期帮我们省下了大量时间,但"省时间"不等于"无脑用",库本身仍有不少需要适配和处理的地方,这些才是本文的重点。
1. @react-native-ohos/react-native-image-picker 解决的是什么问题
在 OpenHarmony 生态刚起步的时候,React Native 开发者面临的最大痛点不是写页面,而是"设备能力调用"这一层。相册、相机、文件存储这些高频能力,在 Android 上有现成的原生模块,在 iOS 上也有成熟的 JS 封装,但鸿蒙这边经常是 "JS 侧能跑起来,一碰原生能力就歇菜" 的状态。
这个三方库的本质,就是在 OpenHarmony 的 Native 层实现了一套对 RN 运行时暴露的 TurboModule 模块,把相册读取、相机调用、图片裁剪、临时缓存这些系统能力封装成了统一的 JavaScript 接口。对我们业务开发来说,它的意义可以用一句话概括:在鸿蒙设备上,调用相册和相机的代码写法,可以最大程度地向 Android/iOS 现有代码靠拢。
它的具体能力覆盖了这几块:
- 相册多选:支持单选和多选,可以按数量上限做限制。
- 相机拍照:直接拉起系统相机,拍完回调返回图片路径。
- 图片裁剪:支持宽高比、指定宽高裁剪。
- 基础压缩:可以指定图片质量、最大宽度和最大高度,这在控制上传体积时很有用。
- 存储类型控制:支持返回原始文件路径或者可复制的临时缓存文件。
我在调研的时候把官方仓库的文档、示例工程、Issues 都翻了一遍,整体感受是这个库的成熟度在同类型鸿蒙三方库中属于偏高的。核心 API 风格沿用了 react-native-image-picker 的经典设计,也就是说,如果你们团队之前有 Android/iOS 上使用 image-picker 的经验,迁移到鸿蒙时的学习成本几乎为零。
需要提醒的是,这个库依赖的 React Native 版本不是随便配的。它内部走的 TurboModule 链路,要求 RN 版本和 OpenHarmony 侧的 RN 运行时版本做严格对齐。目前我测试下来,@react-native-ohos/react-native-image-picker 的 0.4.x 系列配合 RN 0.72.5 是稳定组合,如果你用的 RN 版本更高或更低,需要先查一下对应库版本的 peerDependencies 声明,避免装上之后一启动就报 "TurboModuleRegistry could not find module" 这类错误。
2. 环境准备与版本匹配:最容易忽略的前置条件
2.1 基础环境清单
开始集成之前,先把基础环境铺好。以下是我这次实际使用的环境组合,可以作为参考基线:
| 组件 | 版本 |
|---|---|
| React Native | 0.72.5 |
| React Native OHOS 运行时 | 0.72.5-0.0.x 系列 |
| @react-native-ohos/react-native-image-picker | 0.4.2 |
| OpenHarmony SDK | 10+ |
| DevEco Studio | 4.0 及以上 |
| Node.js | 18 或 20 |
这里面最容易踩坑的是 React Native OHOS 运行时。OpenHarmony 上的 RN 并不是直接用官方 Facebook 的 React Native 包,而是由 OpenHarmony 团队维护的适配版本,它在原生侧替换了部分平台实现。如果你之前习惯了在 Android/iOS 工程里手动改 build.gradle 或者 Podfile,到鸿蒙这边要换成在 DevEco Studio 工程里配置依赖。
2.2 确认工程基础结构
一个标准的 React Native + OpenHarmony 工程,目录结构大概是这样的:
project-root/ ├── entry/ │ ├── src/ │ │ └── main/ │ │ ├── ets/ │ │ ├── resources/ │ │ └── module.json5 │ └── oh-package.json5 ├── node_modules/ ├── package.json └── build-profile.json5所有鸿蒙原生相关的配置都在 entry 这个模块下,和三方库接入直接相关的就是oh-package.json5和module.json5两个文件。
2.3 安装三方库到 RN 工程
这一步和普通的 npm 包安装没有区别,直接在项目根目录执行:
npm install @react-native-ohos/react-native-image-picker安装完成后,需要确认它是否真的被 link 进了鸿蒙工程。在老版本 RN 时代我们习惯了react-native link自动关联,但 OpenHarmony 工程不会自动做这一步。我当时的做法是检查node_modules/@react-native-ohos/react-native-image-picker目录下是否存在harmony子目录,如果存在,说明这个包携带了鸿蒙原生工程代码,接下来需要手动配置工程依赖。
2.4 在 oh-package.json5 中声明依赖
打开entry/oh-package.json5,在 dependencies 区域新增:
{ "dependencies": { "@react-native-ohos/react-native-image-picker": "file:../../node_modules/@react-native-ohos/react-native-image-picker/harmony" } }这里写file:前缀的意思是直接以本地路径引用的方式把包关联进来,这在鸿蒙工程里很常见,省去了私有 npm 仓库的配置成本。需要注意相对路径是否正确,如果工程不是标准的entry在根目录下两层的位置,需要按实际情况调整。
这一步做完后,在 DevEco Studio 里执行 sync,确认没有任何报错。如果出现hvigor相关的依赖解析错误,多半是路径不对,或者 node_modules 还没安装完整。
3. 权限声明与动态申请:鸿蒙隐私模型的特殊之处
3.1 静态权限声明
相册和相机属于敏感权限,在鸿蒙上必须在module.json5里先声明。我们要做的修改在entry/src/main/module.json5的requestPermissions字段:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.READ_MEDIA", "reason": "需要读取相册中的图片用于用户选择头像和上传图片", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } }, { "name": "ohos.permission.CAMERA", "reason": "需要调用相机拍摄图片用于直接上传", "usedScene": { "abilities": ["EntryAbility"], "when": "inuse" } } ] } }这里有一个鸿蒙平台的特殊之处:READ_MEDIA权限在 Android 13 以后已经细化为READ_MEDIA_IMAGES和READ_MEDIA_VIDEO,但鸿蒙这边目前主要依赖单一READ_MEDIA权限。如果你的应用还需要读取视频,记得把视频相关的场景也要在 reason 里写清楚。
3.2 动态权限申请的必要性
静态声明只是第一步,运行时还需要动态申请。这一点和 Android 6.0+ 的动态权限模型是一致的。不过,@react-native-ohos/react-native-image-picker这个库在设计上并不会自动帮你弹权限框,而是直接把权限请求抛给系统,如果用户还没授权,系统会返回一个带有错误码的拒绝结果。
所以我在业务层封装了一个权限预检函数,在真正拉起相册之前先主动申请权限:
import { abilityAccessCtrl, common } from '@kit.AbilityKit'; async function ensurePermission(permission: string): Promise<boolean> { const context = getContext(this) as common.UIAbilityContext; const atManager = abilityAccessCtrl.createAtManager(); try { const result = await atManager.requestPermissionsFromUser(context, [permission]); const grantStatus = result.authResults[0]; return grantStatus === 0; // 0 表示授权成功 } catch (e) { return false; } }这里有个细节需要注意:getContext(this)在高版本的 RN 鸿蒙运行时中可能拿不到 UIAbilityContext,因为 RN 的页面上下文和原生 UIAbility context 不是同一个对象。我最后的方案是在原生侧写了一个简单的 PermissionHelper 模块通过 TurboModule 暴露给 JS,或者在 MainAbility 的onWindowStageCreate里提前申请。总之,不要完全依赖 JS 层去拿原生 Context,否则容易出现"编译没错,跑到真机上才崩"的情况。
3.3 权限被拒后的用户体验设计
权限被拒这件事,每个平台的行为差别很大。鸿蒙系统有一个特点:用户连续拒绝两次之后,系统的权限弹窗就不会再自动弹出,此时只能引导用户去设置页手动开启。我在实际测试中发现,第一次拒绝后立刻再次调用requestPermissionsFromUser还能弹窗,但如果用户选了"拒绝且不再询问",后续调用就直接返回拒绝结果,没有任何 UI 反馈。
针对这种情况,我给业务层加了一个状态位:如果连续两次返回权限拒绝错误,就跳转系统设置页,让用户手动打开权限。跳转代码大概是这样:
import { common } from '@kit.AbilityKit'; function openSystemSettings() { const context = getContext(this) as common.UIAbilityContext; context.startAbility({ action: 'action.settings.app.info', parameters: { bundleName: context.abilityInfo.bundleName, }, }); }这段代码在测试机上验证过是能正常跳转到本应用的设置页的,不过不同的系统版本在action名称上可能略有差异,如果后续系统升级出现跳转失败,可以再查一下最新的 Ability 操作常量。
4. 核心 API 使用实战:从相册选择到相机拍照的完整链路
4.1 库的导入方式
这个库的导出结构和原版 react-native-image-picker 保持了一致,只导入一个默认对象即可:
import { ImagePicker } from '@react-native-ohos/react-native-image-picker';我注意到在 0.4.x 系列中,部分版本导出的是命名导出ImagePicker,而早期版本是launchImageLibrary这种独立函数。如果你在编译阶段遇到undefined is not a function,大概率是版本之间的导出 API 变化导致的,建议先看一眼node_modules里包的index.d.ts声明文件。
4.2 打开相册多选
以下是我在业务中实际使用的相册多选代码:
async function pickImages(maxCount: number): Promise<ImagePickerResponse[]> { const options = { selectionLimit: maxCount, includeBase64: false, includeExtra: false, mediaType: 'photo' as const, quality: 0.8, maxWidth: 1440, maxHeight: 1440, }; try { const result = await ImagePicker.launchImageLibrary(options); if (result.didCancel) { return []; } if (result.errorCode) { throw new Error(`picker error: ${result.errorMessage}`); } return result.assets ?? []; } catch (e) { // 统一错误处理 return []; } }这里重点解释一下参数选择背后的理由:
selectionLimit:多选上限,在鸿蒙的实现中传入0表示不限数量,但部分版本对0的支持并不好,我测试时发现传0会导致相册页面直接崩溃。所以业务侧如果确实需要不限数量,建议先给一个上限,比如 9 张,再在 UI 上提示用户。maxWidth和maxHeight:这个库的压缩逻辑是在原生侧做的,如果传了这两个值,图片会被等比缩放,不会出现拉伸变形。我测试过原图 4000x3000 的照片,设置 1440 上限后输出图片大小从 8MB 左右降到了 2MB 以内,对上传链路很友好。includeBase64:这个选项在鸿蒙上虽然存在,但我在实测中发现如果选择大图并且同时开 base64,内存会飙得很高,尤其是低端设备会有 OOM 风险。所以除非必须,默认保持false。
4.3 拉起相机拍照
相机拍摄的调用方式与相册非常相似:
async function capturePhoto(): Promise<ImagePickerResponse | null> { const options = { mediaType: 'photo', quality: 0.9, saveToPhotos: true, }; try { const result = await ImagePicker.launchCamera(options); if (result.didCancel) { return null; } if (result.errorCode) { throw new Error(`camera error: ${result.errorMessage}`); } return result.assets?.[0] ?? null; } catch (e) { return null; } }saveToPhotos这个参数值得单独说一下。在 Android 平台上,launchCamera拍摄的照片默认保存到系统相册;但在鸿蒙上,这个库的默认行为是只返回临时缓存目录下的图片文件,不写入系统相册。也就是说,如果业务不需要在系统相册里留下拍摄的照片,saveToPhotos保持false可以减少一次文件复制操作,速度更快。
我实际测试后还发现一个现象:部分鸿蒙机型上,相机返回的图片是有 EXIF 旋转信息的,比如竖拍的照片显示方向不对。这个库在内部会读取 EXIF 并做旋转校正,但偶尔也有漏网之鱼。稳妥的做法是在展示图片时,用鸿蒙的 Image 组件自动处理 EXIF,或者在原生侧再做一次统一纠正。
4.4 裁剪与临时文件处理
如果业务需要头像裁剪,库提供了openPicker方法,支持 iOS 那种系统级裁剪面板:
const result = await ImagePicker.openPicker({ mediaType: 'photo', cropping: true, cropperCircleOverlay: true, width: 512, height: 512, });cropperCircleOverlay为true时,裁剪框是圆形的,适合做头像场景;如果希望自由矩形裁剪,把它设成false同时配合width/height指定固定输出尺寸。
关于裁剪后的临时文件,我建议拿到uri之后立刻进行文件拷贝或者上传,不要长时间依赖临时路径。因为鸿蒙系统的文件清理机制会在内存紧张时把这些临时文件清掉。更稳妥的做法是拿到uri后再拷贝到应用沙箱的 files 目录下,确保后续上传或者预览不受影响。
5. 从黑屏到出图:两段典型踩坑排查过程
5.1 问题一:相册打开后白屏/黑屏,无任何报错
我第一个遇到的严重问题是:打开相册选择器后,页面是黑的,过几秒自动返回,控制台没有任何 JS 报错。这个问题一度让我以为是权限配置没生效,反复检查了module.json5、鸿蒙应用权限设置、甚至卸载重装,都没有效果。
后来我换了个思路,把 DevEco Studio 的 native 日志打开,跑了几个线程,发现进程里大量出现MediaDataSource相关的异常。这才意识到问题出在相册数据源初始化失败。
翻了这个库的源码之后发现,它在原生侧通过PhotoAccessHelper读取相册,这一步需要READ_MEDIA权限被授予。虽然我的module.json5已经声明了权限,但如果应用进程启动后到调用相册之间没有做过任何权限请求,PhotoAccessHelper拿到的就是无权限状态,它会抛出异常,同时在上层表现为黑屏。
排查到这一步,解决方案就清晰了:在调用launchImageLibrary之前,先在原生侧调用requestPermissionsFromUser主动申请权限,等授权返回后再进入相册。我后来把权限申请的逻辑放到了进入相册页的前一步,问题立刻解决。
这个坑的根因听起来简单,但很容易忽略,因为 Android 平台在很多版本上只要静态声明了权限,拉起系统相册时系统会自动弹出授权框。鸿蒙这边没有这个隐式授权逻辑,必须显式请求一次才算真正拥有权限。
5.2 问题二:相机拍照返回后整个页面退出
第二个坑更隐蔽。相机拍照可以正常拉起,拍完点确认后,App 并没有回到 RN 页面,而是直接杀掉了整个任务栈,回到了桌面。
这个问题刚开始让我以为是launchCamera内部崩溃导致的,但崩溃日志里没有任何异常信息。后来我把注意力放到了 Activity 的启动模式上,因为相机拍照从系统相机回到应用,本质上涉及一个Activity的重新调度。
查阅了鸿蒙 RN 框架的实现后发现,RN 的页面在鸿蒙上是通过Ability的WindowStage来承载的。如果Ability的launchType被设置成了singleton,并且相机返回时启动的是一个新实例,新旧实例交替过程中容易出现焦点丢失,表现就是 "页面直接没了"。
我的排查结论是:只要把module.json5里 Ability 的launchType改成standard,相机返回后页面就能正常恢复。但是这里有一个矛盾点:standard模式下每次启动都会创建一个新实例,对某些业务场景来说资源开销更大。最终我选择的做法是保持singleton,但是在相机返回事件里手动恢复焦点。不过这个方案稳定性一般,如果你也是第一次集成,直接跟随官方示例工程默认配置(就是最近更新后默认的standard)反而是最省心的。
关于这个现象,社区里也偶有讨论,不同机器上的表现不完全一致。如果你在集成过程中遇到类似问题,可以先检查一下自己的launchType是不是被改过,优先尝试改回standard复测一次,大概率能解决。
6. 与 Android/iOS 平台的行为差异对照表
这部分我整理了一些集成测试时观察到的平台差异,方便你在做多端适配时有心理预期:
| 行为点 | Android | iOS | OpenHarmony |
|---|---|---|---|
| 权限弹窗时机 | 静态声明后部分版本自动弹 | 首次调用自动弹 | 必须显式调用权限请求 |
| 返回图片的最大尺寸 | 受 maxWidth/maxHeight 控制 | 受 maxWidth/maxHeight 控制 | 受 maxWidth/maxHeight 控制,但大图内存占用更高 |
| saveToPhotos 默认行为 | 部分机型保存到相册 | 保存到相册 | 默认不保存到相册 |
| 多选返回值排序 | 按选择顺序 | 按选择顺序 | 部分版本按相册时间倒序,需要业务层排序 |
| 裁剪框样式 | 矩形/圆形可配 | 矩形/圆形可配 | 矩形/圆形可配,但圆形裁剪预览偶发锯齿 |
| 临时文件有效期 | 较长 | 较长 | 可能被系统随时清理,必须及时拷贝 |
| 相机返回时冷启动问题 | 少见 | 少见 | 取决于 Ability launchType,需特别关注 |
这张表不是想说鸿蒙版有多差,而是要提醒:做跨端功能时,不能只测安卓和 iOS 通过就认为万事大吉。图片选择这类看起来很小的功能,落到系统实现层面时,权限模型、任务栈、文件管理机制都可能影响最终的体验。提前知道差异点,才能在排期和测试用例上做好准备。
7. 我在集成过程中沉淀的几条实操建议
集成这个库整体上属于"低成本、高收益"的事情,但也绕不开"原生层问题需要原生思路去排"这个原则。最后分享几条我在反复踩坑和排障后总结下来的实操建议,它们比具体 API 调用更重要:
第一,权限申请逻辑不要放在useEffect里无脑执行。有些前端同学习惯在页面挂载后就调权限,这在 Android 上也许没问题,但在鸿蒙上会碰到一个问题:如果页面还没完全就绪,权限弹窗和页面的焦点竞争会导致弹窗显示异常,甚至请求回调丢失。我更推荐把权限申请和相册/相机的拉起按钮绑定在一起,用户明确点了"我要选图"再弹授权框,体验上也更自然。
第二,拿到图片 URI 之后,立刻决定是上传、预览还是拷贝。如果选择预览,建议用原生组件而不是把整个 base64 塞进Image组件里,否则低端机器会明显卡顿。如果选择上传,也要考虑 URI 对应的文件是否在沙箱内,跨沙箱路径在某些鸿蒙版本上存在访问限制。
第三,升级库版本时,先跑一次完整的选图-裁剪-上传链路,再切后台复测一次。我发现这个库在不同小版本之间对后台恢复场景的处理存在变化,特别是相机返回后页面栈恢复的问题,只测前台流程很难暴露。切后台再回来,加上系统内存压力模拟,能帮你更早发现问题。
第四,不要忽略system相册和picker相册的差异。在鸿蒙上,部分机型还区分了用户主动选择的相册(Picker)和全局媒体库(Media Library),这个库内部主要走的是系统相册选择器,所以它的 UI 是系统原生的,无法自定义。如果你的设计稿对选择器有很强的品牌要求,需要考虑自研一套选择界面,那就不是这个库能覆盖的范围了。
最后说一句,鸿蒙生态的三方库还在快速迭代中,今天写下的这些经验和坑,可能两三个月后版本一升级就过时了。但排查问题的思路是可复用的:先看权限、再看任务栈、再看文件生命周期,最后回到源码。顺着这个思路,不管将来库怎么变,遇到问题你都能比自己最开始想象的更快定位到根因。希望这篇文章能帮你少走一些我已经走过的弯路,让你的 React Native + OpenHarmony 项目顺利跑起来。