
最近我在做一套运行在 OpenHarmony 设备上的行业终端应用业务上必须内置二维码扫描能力而且后续要快速铺到好几款不同形态的硬件上去。刚接这个任务时我的第一反应是“这不就是一个扫码页吗”把 Android 上现成的扫码封装挪过来把相机预览调通剩下就是解码而已。等真正把工程跑起来才发现Flutter for OpenHarmony 这条链路里环境适配、相机访问、解码引擎集成、真机方向矫正每一环都有自己的脾气直接套老经验会踩到不少隐蔽的坑。这篇文章就把我从选型到落地一版可用 App 的过程拆开讲为什么最终选定 Flutter 来承载 OpenHarmony 扫码场景相机预览和逐帧数据是怎么从硬件一路送到解码器的ZXing C 如何作为本地解码核心集成进来以及真机调试里我踩过的方向、刘海屏、内存抖动这几个典型的坑。你可以把它当成一份可复用的实现笔记也可以当作排查手册遇到同类问题直接翻对应章节对号入座。1. 选型复盘为什么 Flutter 能撑起 OpenHarmony 扫码 App1.1 需求场景和“看似顺利”的技术预判先说清楚项目边界。我手头的 OpenHarmony 终端不是手机而是带扫码座、工业手持机、平板坞这类形态系统版本分散在 OpenHarmony 3.2 到 4.0 之间。业务方要的扫码能力除了“对准二维码就出结果”之外还要求快速集成到现有 Flutter 业务代码中因为主流程已经跑在 Flutter 上了。这决定了第一原则扫码功能不是独立 App而是嵌入主工程的模块UI 要跟随业务主题切换不能做一个与世隔绝的原生页面。很多人会问OpenHarmony 不是自带扫码能力吗确实有系统中有一部分设备出厂带扫码应用但那是系统级、绑定特定硬件的第三方 App 很难直接复用。另一个自然的想法是“Android APK 直接装上去”毕竟 OpenHarmony 有兼容 Android 应用的能力但实际一测就会发现相机硬件访问走的是 OpenHarmony CameraKit又不是 Android Camera2灰度逻辑、设备定制设置完全对不上。所以扫码的核心链路——取景、帧数据处理、解码——必须自己做。1.2 Flutter for OpenHarmony 到底适配到什么程度选择 Flutter for OpenHarmony不是因为它成熟而是因为它最贴合我们的技术栈。OpenHarmony SIG 社区维护着 flutter_flutter 分支基础 Widget 渲染、Platform Channel、插件机制在多数场景下是可用的。但你必须清楚它的边界它并不是 Flutter 全量能力都搬到 OpenHarmony 上了文档里写着支持的组件和插件需要逐一验证不能想当然。我实测下来的感觉是Flutter 应用层代码基本能做到“一套代码跑多端”特别是 UI 部分平移成本很低。但凡是碰到底层硬件能力的插件比如相机、传感器、振动器现成的 Flutter 插件大多是安卓/iOS 实现OpenHarmony 上没有对应的原生实现需要自己写平台通道。这也正是这个项目的关键工作量所在不是 Flutter UI而是把相机流和本地解码能力以平台通道的方式注入 Flutter 引擎。1.3 扫码链路的关键环节拆解我把完整的扫码链路拆成四个环节后续的选型和排错都围绕这几个环节来做相机权限与会话建立申请 CAMERA 权限创建 CameraManager、CameraInput、CaptureSession打开摄像头。预览显示把相机画面送进 UI 层让用户看到取景画面。帧数据获取从相机会话中拿到逐帧 YUV 数据送到解码器。二维码解码对 YUV 数据进行格式归一化交给 ZXing C 解码返回结果给 Dart。四个环节里预览显示可以由原生 Surface 处理也可以转成 Flutter Texture 渲染帧数据获取是整个项目里最容易出问题的一环因为 OpenHarmony 的相机帧回调方式和 Android 不一样解码相对独立反而是最好迁移的。2. 环境组合与工程骨架版本对齐才是第一生产力2.1 SDK 版本组合推荐这个项目里最磨人的不是写代码而是环境版本对齐。Flutter for OpenHarmony 的版本分支、OpenHarmony SDK 版本、DevEco Studio 版本、Dart 版本四者必须匹配。我所用的组合是这样组件版本选择Flutter SDKflutter_flutter 的 OpenHarmony 支持分支建议拉取官方 SIG 仓库 develop 分支OpenHarmony SDK4.0 Release 以上包含 API 10 能力DevEco Studio4.0 及以上版本用于编译 OpenHarmony HAP 包Dart随 Flutter SDK 自带无需单独安装这个组合不是随便选的。SDK 版本过低很多 Camera API 还没有稳定的 imageReceiver 能力版本过高部分老的第三方原生库又可能编译失败。如果后续要上 OpenHarmony NEXT 或者 API 12 以上设备记得先查一下对应 Flutter 分支是否同步支持别盲目升级。2.2 从 flutter create 开始建工程工程创建上Flutter for OpenHarmony 的命令行参数和标准 Flutter 差别不大但要注意 platform 参数flutter create --platformsohos qr_scanner_app cd qr_scanner_app如果项目里已经有 Android/iOS 目录也没关系--platformsohos会在工程里额外生成.ohos原生目录。随后用 DevEco Studio 打开这个.ohos目录首次打开会做一次 Gradle 与 SDK 依赖同步这时候最容易出现两种问题SDK 路径识别不到需要在 DevEco 里手动配置 OpenHarmony SDK 路径并把local.properties中的sdk.dir指向 SDK 目录。Flutter 引擎库没拉下来部分 Flutter 的 OpenHarmony 适配版本需要预先编译引擎如果工程里提示找不到 libflutter.so去 flutter_flutter 仓库查看预编译产物下载说明把引擎库放到对应目录。这两种问题都是环境级的卡住的主要原因不是代码而是分支和版本号没对上。建议做好镜像备份千万不要用“最新的 Flutter 稳定版”直接跑 OpenHarmony 工程大概率会报一堆接口签名不兼容。2.3 原生侧与 Dart 侧的目录约定工程结构上我用了最常见的混合模式Dart 侧管业务逻辑和 UI.ohos/entry/src/main管理原生能力。原生目录里又会拆出.ets、.cpp、.so几个部分ArkTS 层负责摄像头会话管理、权限申请、组件生命周期。C 层通过 NAPI 封装 ZXing C提供解码能力。原生动态库libzxing.so和系统自带的相机相关库。在开始写码前我先把这条调用链画清楚Dart 发起扫码 Config → 平台通道通知 ArkTS → ArkTS 打开相机并回调 Dart → Dart 侧拿到每一帧 YUV 数据 → 传给 C 解码器 → 解码结果通过 EventChannel 推送回 UI。链路看起来长但每一跳都简单越简单的路径越容易定位问题。3. 相机链路改造从取景到拿到一帧可解码的 YUV 数据3.1 摄像头权限声明与运行时动态申请OpenHarmony 的相机权限不允许只靠静态声明。配置里需要添加uses-permission ohos:nameohos.permission.CAMERA /同时必须在运行时动态申请。动态申请的标准路径是通过 UIAbilityContextimport common from ohos.app.ability.common; import abilityAccessCtrl from ohos.abilityAccessCtrl; let context getContext(this) as common.UIAbilityContext; let atManager abilityAccessCtrl.createAtManager(); await atManager.requestPermissionsFromUser(context, [ohos.permission.CAMERA]);值得注意的一点OpenHarmony 对权限授予结果的回调是异步的不要在调用完requestPermissionsFromUser后就立刻打开相机而是要在用户点击“允许”之后再去创建相机会话。有几次我们拿到“已授权”就用结果摄像头初始化仍失败就是因为没有等待 UI 层的授权确认。这个时序问题在真机上比在模拟器上更容易复现。3.2 CameraKit 打开预览的流程相机预览我采用的是“原生输出 Flutter 嵌套”的方案而不是把相机画面转成 Flutter 纹理再渲染。原因很直接Flutter 纹理渲染会增加一帧 GPU 复制和纹理上传的开销在低端工业设备上会造成取景画面卡顿而 Edge to Edge 的 XComponent 嵌入方案画面直接绘制在独立的原生 Surface 上流畅度更有保障。核心流程分五步// 1. 获取相机管理器和后置摄像头 let cameraManager camera.getCameraManager(context); let cameras cameraManager.getSupportedCameras(); let device cameras.find((camera: camera.CameraDevice) camera.cameraPosition camera.CameraPosition.CAMERA_POSITION_BACK); // 2. 创建并打开输入 let cameraInput cameraManager.createCameraInput(device); await cameraInput.open(); // 3. 创建预览输出并绑定到 XComponent Surface let previewProfile: camera.Profile { format: camera.CameraFormat.CAMERA_FORMAT_YUV_420_SP, size: { width: 1280, height: 720 } }; let previewOutput cameraManager.createPreviewOutput(previewProfile, previewSurfaceId); // 4. 创建会话配入输入和输出 let session cameraManager.createCaptureSession(); session.beginConfig(); session.addInput(cameraInput); session.addOutput(previewOutput); await session.commitConfig(); await session.start();这里的previewSurfaceId从哪里来如果使用 XComponent它会在加载完成时回调onSurfaceCreated给你一个 surfaceId如果走 ImageReceiver则用imageReceiver.getReceivingSurfaceId()。我在项目里同时用了这两种方式预览走 XComponent帧数据走 ImageReceiver两者都挂到同一个 CaptureSession 上互不干扰。3.3 如何稳定拿到逐帧数据帧数据获取是本项目最容易踩坑的一环。OpenHarmony 的取帧方式和 Android 的ImageReader有点类似但 API 命名和回调时机不一样。我用的方案是创建ImageReceiver监听每一帧到达事件import image from ohos.multimedia.image; let receiver image.createImageReceiver(1280, 720, image.ImageFormat.YUV_420_SP, 8); receiver.on(imageArrival, () { receiver.readLatestImage((err, img) { let buffer img.getComponent(image.ComponentType.YUV_Y); let yBuffer buffer.byteBuffer; // 这里把 YUV 数据打包成 ByteArray通过平台通道发给 Dart / C }); }); let videoProfile: camera.Profile { format: camera.CameraFormat.CAMERA_FORMAT_YUV_420_SP, size: { width: 1280, height: 720 } }; let videoOutput cameraManager.createVideoOutput(videoProfile, receiver.getReceivingSurfaceId()); session.addOutput(videoOutput);这里有个“容量 8”的参数代表缓存 8 帧。它很关键如果值设得太小比如 2帧回调会经常阻塞解码跟不上会出现画面撕裂如果设得太大比如 30内存压力会明显上升低端设备上的 GC 会加剧。8 这个数是我在目标设备上试出来的平衡点建议你们也针对各自设备微调。3.4 帧数据格式与“先转再接”的优化相机出来的原始格式大多是 YUV_420_SP也就是俗称的 NV21/NV12。ZXing C 本身不直接吃 YUV它吃的是灰度亮度数组也就是一张 LuminancePlane。因此我们要从 YUV 数据里提取 Y 平面这一步本身开销不大但如果在 Dart 侧做一次 Uint8List 复制再传回 C就会多一次内存拷贝。我的做法是在 ArkTS 侧拿到 YUV 数据的 ByteBuffer 后不转 Dart直接通过 NAPI 接口传给 C 解码器。也就是把“帧数据归属权”留在原生层尽量减少跨语言边界的拷贝次数。整个数据流是这样的原生侧捕获 YUV 帧 → NAPI 把 ByteBuffer 地址传给 C → C 中直接按 Y 平面偏移读取灰度 → 交给 ZXing 解码 → 把结果字符串传回 ArkTS → ArkTS 再通过 Promise/Callback 通知 Dart。如果你非要走 Platform Channel 传大数据建议把帧压缩成灰度后只传灰度数组不要传全尺寸 YUV否则每帧几千字节的跨边界拷贝会让解码吞吐量直线下降。4. 解码核心ZXing C 的本地集成与平台通道设计4.1 为什么不用现成 Android 扫码库直接套做扫码功能第一个想到的往往是 ZXing 安卓库或者 ML Kit。但它们的开箱集成方案并不能直接平移到 OpenHarmony原因有三ZXing 安卓版的CameraConfigurationManager、PlanarYUVLuminanceSource依赖安卓相机框架OpenHarmony 上不通用。部分扫码 SDK 虽然可以嵌入 APK但内部假设的设备传感器方向、屏幕旋转逻辑在 OpenHarmony 设备上完全不适用。一些扫码库会集成摄像头打开逻辑和 OpenHarmony CameraKit 冲突一旦冲突很难剥离干净。所以更稳妥的思路是解码引擎用 ZXing C它跨平台、体积小、解码能力强相机和图像采集留在 OpenHarmony 原生侧完全绕开安卓兼容层。4.2 zxing-cpp 在 OpenHarmony 里的移植方案zxing-cpp 的官方仓库提供了 CMake 构建脚本我们可以直接将它編译成 OpenHarmony 可用的动态库。流程大致如下git clone https://github.com/zxing-cpp/zxing-cpp.git cd zxing-cpp mkdir build cd build cmake .. -DCMAKE_TOOLCHAIN_FILE/path/to/ohos-sdk/native/build/cmake/ohos.toolchain.cmake \ -DCMAKE_BUILD_TYPERelease \ -DBUILD_SHARED_LIBSON \ -DBUILD_TESTINGOFF cmake --build . --config Release编译出来的libzxing.so和必要的头文件放进工程原生目录里。这里有个细节OpenHarmony 的 native 工程使用 CMake 作为默认构建工具记得在CMakeLists.txt里把 zxing 的 include 路径和 so 文件路径链接进 targetadd_library(libzxing SHARED IMPORTED) set_target_properties(libzxing PROPERTIES IMPORTED_LOCATION ${CMAKE_CURRENT_SOURCE_DIR}/third_party/zxing/lib/${OHOS_ARCH}/libzxing.so) target_include_directories(qr_scanner PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/third_party/zxing/include) target_link_libraries(qr_scanner PRIVATE libzxing)需要特别注意架构匹配OpenHarmony 设备大多以 ARM64 为主但工业设备上偶尔也有 ARM32 系统所以动态库最好把arm64-v8a和armeabi-v7a两种 ABI 都编出来避免换一台设备就抓瞎。4.3 MethodChannel 接口设计与解码逻辑平台通道的名字我定义为qr_scanner_channel负责扫码启动和停止另外用了一个 EventChannel 专门回传解码结果避免结果和命令响应混在一起。Dart 侧核心代码class QrScanner { static const MethodChannel _channel MethodChannel(qr_scanner_channel); static Futurebool startScan() async { return await _channel.invokeMethod(startScan); } static Futurebool stopScan() async { return await _channel.invokeMethod(stopScan); } }ArkTS 侧收到startScan后创建相机会话同时注册帧回调。每次回调里调 NAPI 解码import zxing from libzxing.so; receiver.on(imageArrival, () { receiver.readLatestImage((err, img) { let yComponent img.getComponent(image.ComponentType.YUV_Y); let uvComponent img.getComponent(image.ComponentType.YUV_UV); let result zxing.decodeYuv( yComponent.byteBuffer, uvComponent.byteBuffer, img.size.width, img.size.height ); if (result ! ) { emitter.emit(qrResult, result); } img.release(); }); });C 侧的解码函数核心逻辑std::string decodeYuv(const uint8_t* yBuffer, const uint8_t* uvBuffer, int width, int height) { // ZXing 的 RGBLuminanceSource 需要灰度数据直接把 Y 平面传入 zxing::LuminanceSource* source new zxing::RGBLuminanceSource(width, height, yBuffer, width * sizeof(uint8_t), 0); zxing::DecodeHints hints; hints.setFormats(zxing::BarcodeFormat::QRCode); hints.setTryHarder(true); zxing::Result result zxing::ReadBarcode(source, hints); return result.text(); }解码返回字符串后ArkTS 侧拿结果回调 Dart。要注意如果解码器的 result 是空的说明这一帧没有识别到二维码不要频繁上报事件给 Dart我加了一层“连续 10 帧無结果才回调一次空结果”的节流处理避免 UI 线程被事件轰炸。5. 真机踩坑记录方向、刘海屏与内存抖动5.1 相机的方向矫正不能靠猜这个坑几乎是所有 OpenHarmony 扫码项目绕不过去的。OpenHarmony 设备的传感器方向和屏幕方向并不总是一致尤其工业设备形态多样有的摄像头装在上边框有的在侧边有的在背面。如果你把相机预览的数据直接交给解码器解码器默认认为图像是按屏幕视角竖立摆放的但实际传感器拿到的画面可能是横过来的。最正确的做法是查询当前设备的自然方向和相机传感器的安装角度通过 CameraKit 的回调获取sensorOrientation再结合当前屏幕方向计算旋转角度。在代码里我是这样处理的function calculateRotation(sensorOrientation: number, displayRotation: number, isFrontCamera: boolean): number { let rotation (sensorOrientation - displayRotation 360) % 360; if (isFrontCamera) { rotation (360 - rotation) % 360; // 前置需要镜像 } return rotation; }这个计算结果最终会传给 C 解码器。在 zxing-cpp 中旋转可以通过构造RotatedLuminanceSource来实现也可以直接把图像内存按旋转后的坐标系读取。建议在解码前先对 Y 平面数据做一次旋转不然取景框里明明显示二维码解码器却只返回空结果排查思路会绕很远。5.2 扫码框与预览画面不对齐问题第二个真机上很明显的问题扫码参考线取景框位置和实际识别的区域对不准。用户看到扫码框把二维码框住了但结果就是扫不出来把二维码放到屏幕中间偏上位置反而能出结果。原因在于预览画面被 XComponent 直接拉伸到了全屏宽高而相机的预览输出分辨率比例和屏幕比例通常不同。系统默认会把 4:3 的预览拉伸到接近 16:9 的屏幕上导致画面中的物体被纵向拉长实际坐标偏移。解决思路是进行“裁剪适配”在创建预览 Profile 时选择一个和目标屏幕宽高比接近的分辨率然后在 UI 层用Clip或者Stack OverflowBox把多余部分裁掉保证预览比例不被拉伸。我最终的方案是让原生侧动态计算裁剪区域把预览画面的中心和扫码框中心对齐扫码框内看到的区域与解码器实际扫描的区域保证一致。5.3 解码内存抖动与回调风暴低配设备上二维码解码最大的敌人不是 CPU 算力不够而是每帧分配新对象导致的内存抖动。第一次联调时我的帧回调里每帧都new一个ByteArray跑 10 分钟应用内存就涨了快 200MB最后被系统杀掉。后来我改成预分配缓存池创建一组 1280×720 大小的 ByteBuffer 队列帧到达时循环写入解码器读完再回收彻底避免每帧创建对象。另一个坑是回调风暴。ImageReceiver 的imageArrival回调频率可能高达每秒 30 次如果每次都唤醒 Dart 侧去刷新 UI页面会明显卡顿。我设了一个“解码窗口”概念连续 30 帧中只挑 1 帧做解码其他帧只更新预览画面不触发解码逻辑。这样解码频率降至 1~2 次每秒足够应对常见的扫码速度需求又能显著降低 CPU 占用。不过要注意如果二维码离摄像头很远帧率低会导致漏帧所以这个窗口值应该做成可配置项而不是写死。6. 一版可上线的效果性能实测与剩余优化空间6.1 真机性能数据我用一台 OpenHarmony 4.0 的 8 核工业平板做测试分辨率选择 1280×720预览帧率约 30FPS解码频率每秒 1~2 次得到的核心数据如下指标实测值首次启动到扫码页可预览约 1.2 秒预热后单次扫码耗时平均 180ms近距离快速扫码成功率约 97%弱光场景扫码成功率约 82%连续扫码 30 分钟内存增量稳定在 30MB 以内平均 CPU 占用约 23%这个成绩谈不上惊艳但在业务场景里已经可用。如果对扫码速度要求更高可以尝试把输入分辨率降到 640×480解码耗时会进一步降低但远端小码率的二维码可能就识别不到了需要根据实际业务权衡。6.2 还能往哪个方向继续优化一版能用不代表没有继续优化的空间。我在收尾阶段梳理了三个明确可做的方向自动变焦策略。目前我把对焦模式设为连续对焦但部分工业设备对近距对焦支持不好。可以结合设备能力和扫码框位置把对焦区域设置为屏幕中间区域能明显提近距扫码成功率。解码器的 TryHarder 开关。ZXing 的 TryHarder 会尝试更复杂的识别路径但会拉高耗时。在光线充足且二维码比较大的场景下可以动态关闭 TryHarder 以换取更快的响应。多码识别能力。ZXing C 支持返回多个二维码结果但当前项目里我只取第一个结果。如果后续业务需要“一次扫到多个码后让用户选择”解码接口和 Dart 回调协议都需要提前设计成数组结构。扫码功能看似是一个“调库就行”的小模块但真正落在 OpenHarmony 这种非主流生态上时从相机链路到解码集成再到真机适配每一环都需要平台级的知识兜底。拿我自己来说如果第二次再做同类项目我会先把设备形态、摄像头安装方向和屏幕比例这几项信息拿到手再动手写相机代码能少走很多弯路。