在OpenHarmony上用Flutter做扫码应用,听起来是把两套生态拼在一起,实际上最折腾的根本不是二维码算法本身,而是那个看起来最简单的东西——相机预览。我前前后后调了两个星期,踩遍了黑屏、拉伸、内存暴涨、通道时序对不上这些坑,最后总结出来的经验是:只要把“预览帧是怎么从OpenHarmony摄像头一路流到Flutter UI树上”这件事吃透了,剩下的扫码解码、业务跳转全都是水到渠成的事。这篇就专门说清楚二维码预览这块的实现细节,从方案选型到双端通信,再到解码帧处理,全部按实操顺序捋一遍。
这篇内容适合两类人:一是已经有Flutter基础、想把自己的扫码应用迁到OpenHarmony设备上的开发者;二是对OpenHarmony相机能力不太熟、想了解跨端SDK要怎么跟原生相机API打交道的初学者。整体思路不会太依赖某个具体版本,但涉及代码的部分我会明确说明是基于API 10/11的常见写法,你拿到自己的工程里大概率能直接复用。
1. 整体思路与方案选型
1.1 为什么在OpenHarmony上选择Flutter
聊这个话题之前,我估计很多人心里都有个疑问:OpenHarmony不是有自己的ArkTS和ArkUI吗,为什么要绕一圈用Flutter?这个选择在刚立项的时候确实被团队挑战过,但实际做下来,我觉得理由还是比较充分的。
首先是代码复用。市面上成熟的扫码模块,不管是自研的、基于ZXing改的,还是买的三方SDK,大多数都有Flutter/Dart版本或Android原生版本。如果走ArkTS原生开发,等于整套逻辑要重写一遍,识别算法、业务编排、状态管理全部从零开始。而Flutter这边,Dart生态里的二维码工具链虽然不如Java生态那么全,但够用;再加上以前在Android、iOS上沉淀的UI代码可以直接搬,迁移动力一下就上去了。
其次是渲染一致性。OpenHarmony的ArkUI组件模型跟Flutter的声明式Widget模型完全是两套思路,如果你有现成的Flutter页面,强行用ArkUI重画一遍,视觉细节必定有偏差。而Flutter在OpenHarmony上有社区维护的适配引擎,同一套Widget树在不同系统上的渲染效果几乎一致,对“一套代码多端跑”这种需求非常友好。
不过我得泼一盆冷水:这个组合目前还不是那种开箱即用的体验。Flutter官方支持的平台列表里没有OpenHarmony,你需要用三方维护的flutter_flutter引擎分支,或者手动把Flutter Engine编译成OpenHarmony版本。渲染后端也不是Flutter 3.10之后主推的Impeller,而是Skia的OHOS适配,性能调优的时候要考虑到这一点。
1.2 二维码预览的完整链路
做扫码App,外界讨论最多的是“识别速度快不快”,但识别只是末端一环。真正决定用户体验的是预览链路,整个流程我把它拆成六段:
摄像头硬件采集原始图像,这一层是OpenHarmony多媒体框架的活儿,你不能绕开Camera Kit。拿到图像帧之后,要以流畅的速率把帧送进Flutter渲染管线,形成用户看到的“预览画面”。同时,同一帧数据要去转换成二维码解码器能吃的灰度数据,这叫帧数据处理。解码库对灰度图做定位、校正、解码,返回原始字符串。识别结果要从原生层回到Dart层,再驱动业务逻辑,比如跳转、复制、提示音。最后,整个生命周期还要跟着页面走,切换后台、退出页面时该停的停、该释放的释放。
这六段里有三处是跨语言的边界:原生相机帧到Flutter纹理、解码结果到Dart侧、生命周期控制指令下发。每一处都要靠通道通信来搭桥,而这恰恰是网上教程最少、坑最深的地方。
1.3 预览方案AB对比
我在立项时对比过两种预览方案,这里直接放一张对比表,大家选型的时候可以少走弯路:
| 对比项 | 方案A:Texture共享 | 方案B:原生层直接渲染 |
|---|---|---|
| 画面归属 | Flutter纹理合成,UI可随意叠加 | 原生Surface独立渲染,Flutter放占位图 |
| 跨端通信复杂度 | 中等,需要维护TextureId生命周期 | 低,帧不经过Flutter |
| 叠加UI(扫码框、灯光按钮) | 方便,跟普通Widget一样 | 麻烦,需要额外用Overlay或子Surface |
| 帧数据获取 | 原生回调里能拿到同一份数据 | 原生层内部处理,业务跨端要多走一步 |
| 多设备适配 | 画面随Flutter布局走,不用额外适配 | 尺寸、旋转要自己在原生层处理 |
实际体验下来,我强烈推荐方案A。虽然它要求你必须搞懂Flutter的textureId怎么注册、怎么销毁,但一旦跑通,后面的UI交互就全是Dart侧的事了。方案B看起来简单,但“扫码框要对准预览画面”这种需求会把你折磨到怀疑人生——原生Surface和Flutter布局是两个坐标系,对不齐。
2. 工程搭建与环境准备
2.1 DevEco Studio与SDK版本选择
先说结论:开发OpenHarmony应用请认准DevEco Studio,目前主流的稳定版本是DevEco Studio NEXT系列,对应的OpenHarmony SDK建议API 10起步,API 11更好。如果你手上的设备是OrangePi 5 Pro这类开发板,预装的系统多半是API 10或API 11,跟着设备版本走就行,别贪新。
这里有个很容易踩的坑:Flutter适配OpenHarmony引擎的版本和OpenHarmony SDK版本是绑定的,不是越新越好。适配仓的README里会明确写支持哪些API Level,比如某个flutter_flutter分支只验证过API 10,你非要用API 11编译,可能跑起来倒是能跑,但相机的某些接口行为会有微妙的差异。所以我建议先把设备系统版本确认好,再反过来选Flutter引擎版本。
开发环境上,Windows、macOS、Linux都能开发,但真机调试最好准备一台OpenHarmony设备,不要只用模拟器。相机硬件在模拟器上的行为非常不真实,Texture的格式、内存的分配策略都跟真机不同,很多预览黑屏的问题在模拟器上根本复现不出来。
2.2 Flutter工程集成到OpenHarmony
这一步比传统Flutter Android工程要繁琐,因为你要把Flutter Module嵌进一个hvigor工程(OpenHarmony的构建体系),而不是直接用Gradle。
我的做法是先把Flutter工程作为纯Dart代码库管理,里面写好业务页面和平台通道协议,然后单独建一个OpenHarmony宿主工程,用hvigor依赖方式把Flutter模块引进去。具体在工程层面要做三件事:
- 用flutter_flutter仓库的引擎产物替换掉Flutter SDK里默认的engine,构建出适配OpenHarmony的libflutter_engine.so和相关的头文件。
- 在OpenHarmony工程的oh-package.json5里声明flutter module依赖,路径指向本地Flutter工程的构建产物目录。
- 在宿主工程里初始化Flutter Engine,通过FlutterViewController或类似容器把Flutter页面加载到Stage模型里。
这个过程很容易在“engine的.so没打进hap包”上翻车。一个比较稳的排查方法是,安装到设备后跑一段测试代码,检查系统是否能加载libflutter_engine.so。如果加载失败,优先检查so文件的ABI类型,OpenHarmony只认OHOS的so,你从Android安装包里扒一个过来是绝对跑不起来的。
2.3 权限声明与运行时申请
二维码扫描必备权限就是相机权限,在OpenHarmony里面它的名字叫ohos.permission.CAMERA。需要在工程的module.json5的requestPermissions数组里声明:
{ "name": "ohos.permission.CAMERA", "reason": "用于二维码扫描时采集图像", "usedScene": { "abilities": ["MainAbility"], "when": "inuse" } }这里的reason字段不能随便写,OpenHarmony的校验逻辑会对权限用途做审查。如果你后面要过XTS认证,权限声明这块尤其严格,reason和实际调用场景对不上,认证材料直接被打回。
声明之后还要在代码里动态申请。OpenHarmony的权限申请是原生侧的Promise能力,你在Dart侧通过MethodChannel发起申请,原生收到后用permissionManager.requestPermissionsFromUser弹授权框,然后把结果通过回调传回Dart。这个过程必须封装成Future,因为用户从“看到弹窗”到“点击允许”之间是有时间延迟的,不能用同步等待。
3. 双端通信与相机预览核心实现
3.1 MethodChannel与EventChannel的分工
在Flutter和OpenHarmony之间通信,最基础的就是PlatformChannel。我一开始犯的错误是试图用一种通道搞定所有事,结果代码乱七八糟。后来清理成两条清晰的通道,整个架构就顺了。
MethodChannel用来处理一次性的请求-响应指令,比如打开相机、关闭相机、切换前后摄、查询设备能力。它们都是调用方发起、接收方执行、然后返回结果,天生适合Method模式。
EventChannel用来处理连续的数据流。相机预览帧是一秒几十张的持续数据,解码结果也可能连续出现,这类数据应该走EventChannel。Dart侧用StreamBuilder去接,就能非常自然地处理“每来一帧就往某个Widget里推”的场景。
有一段代码我建议专门封装成一个工具类,叫它ChannelRepository,里面管理两条通道的创建和销毁。注意通道name一定要在两端写死且完全一致,比如都用“scan/camera”和“scan/frame”,别用默认值,不然多页面共用时会出现事件发给错误接收方的问题。
3.2 OpenHarmony原生侧相机流程
原生侧的相机调用,核心是@ohos.multimedia.camera这个Kit。流程是固定的四步:创建CameraManager、获取相机设备列表、创建预览输出、启动会话。
创建CameraManager需要getCameraManager(context),这一步要传Ability上下文。获取设备列表用getSupportedCameras(),返回的数组里包含前摄、后摄设备对象,每个对象有CameraPosition字段,用来区分前后。创建预览输出时,要传一个CameraProfile,里面包含宽高和帧率,比如1920x1080@30fps。
然后是最关键的一步:把SurfaceId传给原生预览输出。这里的SurfaceId是从Flutter侧注册的Texture里获取的窗口句柄。原生侧拿到预览帧后会通过一个on('frameAvailable')之类的回调通知上层,但你真正要拿帧数据,还需要在创建预览输出时做配置,让CameraKit输出YUV帧到一块可读buffer里。这个过程跟Android上用Camera2配置ImageReader有点类似,只是API面目全非。
我贴一段简化版的原生初始化流程,方便大家理解这个顺序:
import camera from '@ohos.multimedia.camera'; async initCamera(surfaceId: string) { this.cameraManager = camera.getCameraManager(this.context); const devices = await this.cameraManager.getSupportedCameras(); const backCamera = devices.find(d => d.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK); const profile = { format: camera.CameraFormat.CAMERA_FORMAT_YUV_420_SP, size: { width: 1920, height: 1080 } }; this.previewOutput = await this.cameraManager.createPreviewOutput(profile, surfaceId); this.cameraInput = this.cameraManager.createCameraInput(backCamera); await this.cameraInput.open(); const session = await this.cameraManager.createSession(camera.SceneMode.NORMAL_PHOTO); session.beginConfig(); session.addInput(this.cameraInput); session.addOutput(this.previewOutput); await session.commitConfig(); await session.start(); }这里有个非常重要的细节:surfaceId不是普通的字符串,它需要从Flutter侧的纹理注册接口里拿到。我的做法是先让Dart侧注册一个Texture,拿到纹理ID,再把纹理ID传给原生,原生通过内部方法解析出SurfaceId,然后才去创建预览输出。顺序反了,得到的SurfaceId是无效的,预览必然是黑屏。
3.3 Flutter侧Texture与预览UI
Dart侧要做的就是两件事:注册纹理、放一个Texture Widget。核心代码如下:
class CameraPreviewWidget extends StatefulWidget { @override _CameraPreviewWidgetState createState() => _CameraPreviewWidgetState(); } class _CameraPreviewWidgetState extends State<CameraPreviewWidget> { int? _textureId; StreamSubscription? _frameSub; @override void initState() { super.initState(); _textureId = _registerTexture(); _channelRepository.methodChannel.invokeMethod('startCamera', {'textureId': _textureId}); _frameSub = _channelRepository.frameStream.listen(_onFrame); } int? _registerTexture() { // 调用原生侧提供的方法,注册一个动态纹理并返回ID return _channelRepository.methodChannel.invokeMethod('registerTexture'); } @override void dispose() { _frameSub?.cancel(); _channelRepository.methodChannel.invokeMethod('stopCamera'); _channelRepository.methodChannel.invokeMethod('unregisterTexture', {'textureId': _textureId}); super.dispose(); } @override Widget build(BuildContext context) { return _textureId == null ? Container(color: Colors.black) : Texture(textureId: _textureId!); } }这一段是预览UI的骨架,实际你可以加扫描框、加闪光灯按钮、加Loading遮罩,都是普通Widget叠加,非常顺。唯一要记得的是dispose里一定要先停相机再注销纹理,顺序反了会出现“相机还在出帧、纹理已经被销毁”的崩溃,这类崩溃在log里表现为native层的use-after-free,排查成本特别高。
4. 二维码识别与结果回传实现
4.1 帧数据转换:从YUV到灰度
摄像头输出的一般是YUV_420_SP格式,也就是俗称的NV21。二维码解码只需要亮度分量,所以颜色分量可以完全不看,直接取Y通道转成灰度矩阵,这是最快的路径。
在原生侧,预览帧回调给你的是一整块ByteBuffer,第0字节开始是Y分量,长度等于图像宽乘高。NV21的Y分量排布是:Y0、Y1、Y2、Y3...按行从左到右、从上到下;然后紧跟着是VU交替的色度数据,这部分解码二维码时可以直接跳过。
灰度转换的伪逻辑非常简单:
// 假设width=1920, height=1080, buffer是NV21数据 std::vector<uint8_t> gray(buffer.begin(), buffer.begin() + width * height);这行代码的含义是:把Y分量的那一段拷贝出来作为解码输入。听起来很蠢但非常实用,因为解码库只要灰度图。千万不要做完整的YUV到RGBA到灰度的转换,那是白白浪费CPU。
但这里有个方向问题不得不处理。相机传感器水平和屏幕方向不一致时,Y分量排布会跟着旋转,直接喂给解码器会导致识别率极低。常规处理是拿到帧的rotation值,在拷贝灰度数据时做一次旋转校正,或者调整解码库的期望方向参数。
4.2 解码库的选型与交叉编译
二维码解码库的选择,我调研下来主要有三条路。
第一条是纯Dart方案,比如qrcode_reader、qr_code_tools这类库,优点是接入简单、跨端一致,缺点是性能对比原生C库有差距,在低端设备上高帧率场景可能吃紧。
第二条是ZXing的官方或第三方实现。ZXing是有Java版本的,但OpenHarmony跑的是ArkTS,Java不能直接用。好在ZXing的核心算法也有C++移植版,你可以把C++源码交叉编译成OHOS的.so,再用FFI调用。
第三条是ZBar,它本身就是C库,交叉编译也很成熟。ZBar对二维码的识别速度和鲁棒性都不错,缺点是对“图像中存在多个二维码”的场景支持没ZXing好。
我的建议是:如果Rx像素比较小(比如低分辨率摄像头),纯Dart方案完全够用;如果你要在1080p帧上实时扫,建议上ZXing C++版。交叉编译的时候要注意toolchain一定得用OpenHarmony提供的OHOS clang,不能用Android NDK或者宿主机的GCC,否则编译出来的.so在设备上直接加载失败。
4.3 解码结果回传与业务联动
解码成功之后,结果要通过EventChannel回传到Dart侧。这里我强烈建议做一个“结果节流”,不要每识别到一帧就往UI推一次。二维码识别成功时,同一画面会触发数十次同一结果,如果不加节流,页面跳转会重复执行,用户会看到页面被疯狂刷新。
一个简单的节流策略是:记录上一次成功识别的内容和时间,如果在2秒内识别到相同内容,就丢弃本次结果。放在Dart侧做还是原生侧做都行,我放在Dart侧,因为业务规则变化频繁,改Dart代码发布迭代更快。
处理结果的典型流程是:接受到结果字符串,先校验格式,比如是否以特定协议头开头;然后通过Provider更新全局状态,触发页面层的监听回调;页面根据结果类型做跳转,或弹出确认框。
这里提一下热词里很多人问的Flutter Provider用法。在扫码场景里,我把识别结果放在一个ScanResultModel里,用Provider的ChangeNotifier监听;扫码页在识别成功后调用model.setResult(content),然后主页用context.watch ()去响应变化并跳转。这样解耦得很干净,扫码页只管出结果,业务逻辑全在外部处理。
5. 常见问题与踩坑实录
5.1 预览黑屏与方向错乱
预览黑屏的原因多到能写一本书,我把最常见的几个原因和排查方法整理成一个速查表:
| 现象 | 常见原因 | 排查手段 |
|---|---|---|
| 全黑无任何画面 | 权限未授权,摄像头没启动 | 检查原生侧是否收到相机启动指令、是否有camera_error回调 |
| 全黑但画面上有系统UI | SurfaceId无效或已过期 | 确认Texture注册成功后再传给原生,并在CameraManager里注册错误监听 |
| 画面出现但旋转90度 | 传感器方向未校正 | 获取设备方向角,在解码前旋转灰度数据 |
| 画面拉伸变形 | 预览分辨率与Texture宽高比例不匹配 | 根据Texture尺寸动态选择CameraProfile的宽高比 |
我最常犯的错误是权限异步回调还没完成,就把camera start指令发出去了。原生侧OpenHarmony的API是Promise的,如果你不在then回调里下发后续指令,容易形成竞态条件。解决方法是把“申请权限”和“启动相机”封装成一个串行Promise链,绕不开,必须写对。
方向错乱还有一个隐蔽原因:Flutter页面写的是竖屏,但OpenHarmony的Stage模型默认能力是可以旋转的。如果没在module.json5里锁定orientation为竖屏,用户稍微倾斜设备,整个预览画面就跟着转了,解码器就乱了。
5.2 内存上涨与线程安全
预览帧回调频率一般在30fps上下,一帧YUV_420_SP在1080p下大约3MB。如果你在回调里不做处理或者处理完不释放,内存会以肉眼可见的速度飙升。有次我调试时发现内存从200MB一路涨到1GB,一度以为设备中毒了,后来定位是回调线程里我直接把ByteBuffer存到了一个Vector里,导致整个队列持续积累。
正确做法是:在帧回调里先拷贝出Y分量灰度数据(大约2MB),把拷贝结果以值的方式扔进一个固定容量的线程池队列,回调线程立即返回。队列消费线程负责调用解码器。同时灰度buffer可以复用,用环形缓冲池减少频繁分配。这个优化做下来,内存占用平稳了很多,CPU占用也降下来了。
线程安全上还有个重点:EventChannel发送事件时,一定要在主线程上发。原生相机回调线程是独立的线程池,直接拿它往Flutter侧推送数据有时会出现异常。我的兼容写法是回调线程里先post到主线程再走EventChannel,虽然绕了一步,但稳定很多。
5.3 构建与运行时崩溃处理
写Flutter for OpenHarmony时,很多人会碰到一个经典构建报错,类似“you are applying flutter's main gradle plugin imperatively using the apply method”。这个报错看着是Flutter的,实际是工程混编时构建插件版本不匹配。OpenHarmony工程用的是hvigor,不是Gradle,所以你在网上搜Flutter的Gradle修复方案是没用的。正确做法是检查oh-package.json5里引入的flutter模块构建产物是否跟hvigor版本兼容,通常升级Flutter适配仓到对应hvigor版本即可。
运行时还有个高频崩溃,log里会出现类似“e/flutter: [error:flutter/runtime/dart_vm_initializer.cc(41)] unhandled exception”的内容。这个前缀信息看起来吓人,但多半只是Dart侧未捕获异常。我们遇到最多的是MethodChannel调用了一个原生侧还没注册的方法,比如在initState时立刻调用startCamera,但原生侧Engine刚初始化完,通道还没准备就绪。解决办法是加一个“通道就绪”的握手信号,原生侧Engine初始化完成后向Dart侧发一个ready事件,Dart侧收到后再发启动指令。
另外提一句XTS认证相关的坑:如果你做的扫码应用要上架OpenHarmony应用市场,XTS兼容性测试会检查很多细节,包括权限的动态申请方式、相机库的调用是否在后台非法访问、应用退出时资源是否全部释放。认证过程中有项检查叫“相机权限在前台才可使用”,如果你的扫码逻辑允许在后台任务里继续扫一段帧,就会直接fail。所以无论功能上需不需要,生命周期上都要严格做到“页面可见才预览,页面隐藏就停相机”。
最后聊一点实战体会
做完这个项目,我最大的感受是:在OpenHarmony上做Flutter扫码,真正花时间的不是UI,也不是二维码算法,而是把“相机预览帧”这条管道从原生侧一路通到Dart侧的过程。这条管道每一段都有它自己的坑,SurfaceId的注册时序、通道的事件线程、帧缓冲区的生命周期管理,任何一环松了,整个链路就会以各种诡异的方式挂掉。如果你正准备开始做,我的建议是先抛开业务,只做“黑屏上出现实时画面”这个小目标;这个目标达成了,后面加识别、加业务逻辑都是顺势而为。另外分享一个小技巧:调试阶段在原生侧打一条日志,记录每一帧回调的系统时间戳,在Dart侧也打一条收到数据的时间戳,对比两个时间戳就能快速定位是传输延迟还是解码耗时,这个习惯帮我省了很多盲猜的时间。