这个项目我最近刚做完一轮完整的落地,从Flutter工程搭建到OpenHarmony真机调试,再到把“批量扫描”这个核心功能磨到能稳定交付,中间踩了不少文档里看不到的坑。如果你的目标也是“在OpenHarmony设备上用Flutter快速做一个扫码工具”,或者你正在纠结“批量扫”到底和普通扫有什么区别、怎么实现才不卡不漏,那这篇实战记录应该能帮你省下不少时间。
先说一下背景:Flutter社区官方已经提供了OpenHarmony的适配分支,Flutter应用可以跑在开源鸿蒙系统上,UI层几乎不用改,但涉及相机、解码这类系统能力时,需要走平台通道或者Native侧的适配库。我这个项目要解决的实际需求是仓库物料盘点场景下的批量扫码——用户拿着OpenHarmony平板,对着整箱整托的货品连续扫码,系统自动去重、实时列表刷新、震动反馈,扫完一键导出记录。这比传统“一次只扫一个”的扫码工具有一个本质差异:你不能要求用户每次都对准一个码后停顿等待,而是要在连续扫掠动作里精准捕获每一个码,并且不重复、不漏扫。
下面我把这个项目从思路到实现,完整拆开讲,所有核心代码逻辑、参数设定、踩坑记录,都在后面了。
1. 整体设计与思路拆解
1.1 为什么要在OpenHarmony上用Flutter写扫码App
OpenHarmony不是安卓,虽然它有兼容层,但直接用安卓方案适配相机、权限、硬件编解码,往往会遇到系统服务和驱动差异,尤其是相机流的格式、帧回调参数、HAL层行为,和安卓不完全一致。用Flutter的好处,恰好在“UI层跨端”和“逻辑层可复用”——Dart层写的扫描状态机、去重策略、数据模型,以后如果要把业务迁回安卓或iOS,基本原封不动带走。
我当时评估过三个方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 纯Native(ArkTS)开发 | 系统API直接调,相机适配最“正统” | 开发周期长,UI动态性弱,以后多端复用难 |
| Flutter + 官方自带camera插件适配版 | 集成简单,UI灵活 | 帧回调能力有限,批量扫需要逐帧处理时自由度不够 |
| Flutter + Platform Channel自封装相机链路 | 对相机帧流完全可控,解码可以下沉到Native | 需要自己处理生命周期、线程、内存,工作量大一些 |
最终我选了第三种,核心原因是批量扫描对“帧”的控制权要求太高——我需要主动控制抽帧频率、分辨率、对焦策略,甚至还需要在Native侧做一帧YUV到灰度图的转换,才能保证性能和安全。用现成插件,封装路径基本是黑盒,遇到性能问题很难定位是相机层、通道层还是解码层的问题。
1.2 批量扫描与普通扫码的区别
单次扫码的逻辑很简单:预览画面 → 识别到码 → 解码成功 → 返回结果 → 结束。但批量扫码的第一原则是“不能让同一个码在短时间内重复上报”,同时又不能把不同的码漏掉。听起来简单,真正实现时,你会发现以下四个问题接踵而至:
- 重复扫描:一个码在画面里停留超过两秒,解码器可能每帧都成功,如果不上报去重,列表会瞬间塞满同一条记录。
- 连扫抖动:用户扫完一个码,下个动作还没对准第二个码,画面里短暂出现同一个码的残影或反光,容易被误判为新码。
- 漏扫:用户快速扫掠时,码在画面中的持续时间很短,如果解码策略太保守(比如每10帧才处理一次),很容易直接错过。
- 性能瓶颈:连续解码是非常吃CPU的操作,如果每一帧都全分辨率解码,手机发烫不说,App直接卡到无法继续扫。
所以我在架构设计里,把“批量扫描”拆成了三个子问题:捕获帧的节流、解码结果的去重、UI反馈的低延迟。后面实操部分,我会仔细说这三个子问题各自的实现细节。
1.3 技术选型:相机、解码与平台通道
整个扫描链路有三个关键组件:
- 相机流获取:通过OpenHarmony的CameraKit获取预览帧,回调到Native层,再把帧数据(YUV或RGBA)通过FFI或JSI式的通道传给Dart侧做后续处理。这里我没有用C++直接解码,而是把数据传到Dart侧,再用解码库,这样逻辑层统一,以后多端迁移更省事。实测下来,只要控制好分辨率,性能完全够用。
- 解码器:QR扫码解码我选了ZBar的OpenHarmony适配版本,因为有现成的C/C++交叉编译产物,通过FFI接入Dart比较干净。二维码是QR Code时识别率很高,如果是DataMatrix或者别的码制,可以再换ZXing-ng或是OpenCV的QRCodeDetector,灵活度足够。ZBar需要处理灰度图,所以我会在Native侧先做一次YUV到灰度图的转换,只传灰度数据给Dart侧,这样通道层带宽压力小很多。
- 批量扫描状态机:这是Dart层的核心模块,负责控制“什么时候解码”“什么时候上报结果”“怎么去重”。我用了一个简单的有限状态机来管理空闲、扫描中、已识别三种状态,再配合时间窗口去重表和UI事件流,保证性能稳定。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 相机预览帧的获取链路
OpenHarmony的相机API和安卓Camera2不完全一样,但概念上相通。你需要先拿到相机Manager,然后再申请相机权限、打开相机设备、创建预览流。这个环节最需要注意的是帧格式和分辨率,因为它直接决定后面的解码效率和画面延迟。
我在项目里把预览分辨率固定在1280×720,而不是直接用摄像头最大分辨率。原因很简单:二维码信息密度有限,720p足以覆盖正常距离下的扫码精度;分辨率再高,解码耗时成倍增加,但识别率提升非常有限。同时,我明确申请了YUV420格式的帧回调,这样后续处理灰度图时,可以直接取Y平面做计算,而不用先做全套YUV到RGB的转换——这个“只取Y通道”的小技巧,让单帧耗时至少降低40%。
帧回调触发频率在部分设备上可以到30fps甚至更高,但批量扫码场景根本不需要那么高的帧率。我做了抽帧策略:维护一个简单的间隔计数器,每两帧才触发一次解码运算,实际上每秒处理约12~15帧,完全能覆盖人手扫掠的最快速度,CPU占用率也明显降低。这个策略很像视频播放里的“丢帧”,很多刚上手的朋友一听到丢帧就担心漏扫,实际操作后会发现扫码场景下,两帧一处理完全够用。
2.2 解码器接入与性能平衡
解码器接入的关键点有三个:输入灰度图、设置合适的解码参数、选择合理的运行线程。
- 灰度图来源:前一节说过只取Y通道。在Native侧,我通过FFI暴露了一个函数,Dart侧把一块ByteBuffer传进去,Native直接读取Y平面数据,返回给Dart侧时就不需要额外拷贝了。这个细节看似微小,但在批量扫描连续跑10分钟时,省掉的内存拷贝和GC压力非常可观。
- 解码参数:ZBar里比较影响体验的一个参数是
setSensitivity。这个值调得太高,会出现大量误识别——“明明画面上没有二维码,它也报出来一个码”;调得太低,又会导致小尺寸或远距离的码扫不出来。我实测下来,针对720p输入,灵敏度设置在60~70之间比较平衡,你可以根据实际测试结果微调。 - 运行线程:解码是一个CPU密集操作,绝对不能放在UI线程,也不能每帧都在Dart的Isolate里频繁创建销毁线程。我的做法是在Native侧维护一个单线程的Worker,Dart侧把灰度数据投递过去,解码完成后通过回调返回。这样解码的过程在同一个线程内串行执行,避免并发解码导致的结果错乱,同时也不阻塞UI。
解码耗时在720p灰度图上一般能稳定在20~50ms。这意味着即使每秒处理12帧,CPU负载也不会爆满。
2.3 批量扫描的去重与节流策略
这是整个项目中最容易被低估的部分。如果去重策略设计得不好,用户扫一次货品,列表里可能瞬间出现3~4条相同记录,整个“批量”体验就崩了。
我的去重方案如下:
dart复制class BatchScanController {
// 记录二维码内容 -> 最近一次识别时间
final Map<String, DateTime> _recentResults = {};
// 去重时间窗口
static const Duration _dedupeWindow = Duration(milliseconds: 2000);
// 回调:把识别结果抛给UI层
void Function(ScanResult result)? onResult;
bool _tryToReport(String code) {
final now = DateTime.now();
final lastTime = _recentResults[code];
if (lastTime == null) {
_recentResults[code] = now;
onResult?.call(ScanResult(code: code, time: now));
return true;
}
if (now.difference(lastTime) > _dedupeWindow) {
_recentResults[code] = now;
onResult?.call(ScanResult(code: code, time: now));
return true;
}
return false;
}
}
这段代码的核心思路非常简单:在一个2秒的时间窗口内,相同内容的二维码只会上报一次。为什么是2秒?因为我实测过,用户在一次扫掠动作中,同一个码从进入画面到完全离开,平均落在1~2秒之间。如果窗口太短(比如500ms),码在画面里停留1.5秒,就可能重复上报;如果窗口太长(比如5秒),用户扫完一轮后想补扫同一个码,系统会一直拦截,反而造成“扫不上”的错觉。
节流策略则体现在“连续扫码的间隔控制”上。我要求每次成功上报一个结果后,至少在300ms内不处理新的解码结果,为什么是300ms?这是为了给用户一个自然的“动作切换时间”——一次扫码成功后抬手换姿势,中间会有短暂留白,如果这个留白期间还拼命解码,容易把上一个码的残影又识别出来。300ms既不影响下一个码的捕获,又能有效过滤掉动作间隙的干扰。这个参数也是可以调的,如果你的设备性能较差,可以适当放宽到500ms。
2.4 界面反馈与交互体验
批量扫的UI反馈,直接影响用户会不会觉得“这工具管用”。我做了三件事:
- 震动反馈:每次成功识别并上报新码,立即触发一次短震动。用Flutter的HapticFeedback.mediumImpact,手感比systemSound更明显,又没有重击那么突兀。
- 实时列表:扫描结果以倒序插入列表顶部,每插入一条新记录,列表自动滚到第一行。用户全程不需要碰屏幕,就能清楚看到扫进来的每一笔数据。
- 重复码弱提示:如果是2秒窗口内重复扫到同一个码,界面不做明显弹窗,但在对应列表行上做一个轻微的闪烁动画,让用户知道“哦这个我扫过了”,又不会打断连续扫掠的节奏。
还有一个小细节:必须允许用户手动删除某条误扫记录。批量扫模式下,偶尔会发生用户快速掠过时误触及旁边一个码的极端情况,如果后台无法删除,用户只能全部清空重来,体验很糟糕。所以我在列表项上加了左滑删除手势,同时保留“删除后10秒内如果再次扫到同码,直接允许上报”的逻辑,避免删掉后想补扫又被去重表拦截。
3. 实操过程与核心环节实现
3.1 搭建OpenHarmony上的Flutter工程
Flutter跑在OpenHarmony上,工程结构和标准Flutter工程有细微差别,但整体流程是:先把Flutter SDK切换到OpenHarmony分支,然后通过DevEco Studio打开生成的工程。这里有一个新手容易走弯路的地方——你用flutter create生成工程后,不是直接把Android目录替换成OpenHarmony工程,而是需要用ohos目录来承载OpenHarmony侧代码,和安卓的android目录、iOS的ios目录类似。
我的工程目录简略如下:
text复制my_scan_app/
├── ohos/ # OpenHarmony原生工程
│ ├── entry/src/main/ets # ArkTS侧代码或C++封装层
│ └── entry/src/main/cpp # Native C++代码(ZBar封装、YUV转灰度)
├── lib/
│ ├── main.dart
│ ├── scanner/
│ │ ├── batch_scanner.dart # 批量扫描核心
│ │ └── zbar_bridge.dart # FFI桥接
│ └── ui/
│ └── scan_page.dart # 扫码页面
└── pubspec.yaml
需要强调一点:OpenHarmony侧的C++代码,是通过FFI直接暴露给Dart的,并没有走PlatformChannel的JSON消息机制。原因前面说了,扫码帧数据量大、频率高,用JSON序列化的方式完全扛不住。FFI传ByteBuffer才是正解。
3.2 相机模块的完整生命周期
相机生命周期如果管理不当,轻则页面退出后相机不释放、下次进入黑屏,重则直接崩溃。我整理了一个最小可用的生命周期模板:
dart复制class CameraController {
bool _isInitialized = false;
Future<void> initialize() async {
// 1. 申请相机权限
final hasPermission = await requestCameraPermission();
if (!hasPermission) return;
// 2. 通过FFI打开相机
await cameraNative.open(1280, 720, frameCallback);
_isInitialized = true;
}
Future<void> dispose() async {
// 3. 释放相机
await cameraNative.close();
_isInitialized = false;
}
}
注意帧回调:Native侧把每一帧通过Dart Port回传,Dart侧收到后交给批量扫描模块处理。这里必须用Port而不是MethodChannel的invokeMethod,因为MethodChannel的设计初衷是低频调用,高频流转时会带来明显的序列化开销和线程切换成本。Port传ByteBuffer,Dart侧可以直接拿到内存区域,几乎零拷贝。
页面生命周期这里,我是在Widget的dispose里调用dispose方法,同时在App进入后台时(AppLifecycleListener)自动关闭相机,回到前台时重新初始化。不然用户切到微信回个消息再切回来,相机大概率黑屏。
3.3 批量扫描核心流程代码实现
批量扫描核心逻辑,我把它封装成一个独立的BatchScanner类,内部包含相机回调、抽帧、解码、去重、上报五个步骤。下面是一段简化后的核心循环:
dart复制class BatchScanner {
int _frameCounter = 0;
void onFrameReceived(Uint8List grayData) {
// 1. 抽帧:每两帧处理一次
_frameCounter++;
if (_frameCounter % 2 != 0) return;
// 2. 判断节流:距上次上报未满300ms不处理
if (DateTime.now().difference(_lastReportTime) < const Duration(milliseconds: 300)) {
return;
}
// 3. 调用ZBar解码
final code = zbarBridge.decodeGray(grayData, width, height);
if (code == null || code.isEmpty) return;
// 4. 去重并上报
final isNew = _tryToReport(code);
if (isNew) {
_lastReportTime = DateTime.now();
}
}
}
这个循环本身并不复杂,但你要注意每个环节的“时间成本”。我把实际跑出来的耗时整理了一下:
| 环节 | 平均耗时 | 优化手段 |
|---|---|---|
| 抽帧判断 | <0.1ms | 纯Dart内存操作 |
| 灰度图数据传递 | 约1ms | Native侧直接取Y平面,通过FFI零拷贝传参 |
| ZBar解码 | 20~40ms | 720p灰度图,灵敏度60 |
| 去重与上报 | <0.5ms | 哈希表查询+时间戳比较 |
也就是说,单帧总耗时约25~45ms,处理频率12fps时,CPU占用相对平稳。如果你需要进一步提高帧率,那就需要把分辨率降到640×480,或者把抽帧间隔放宽到每三帧一处理,根据自己的真机测试结果灵活取舍。
3.4 真机性能调优与验证
开发完成后,一定要做真机压力测试,不能只在模拟器上点点就算了。我专门设计了一套操作路径:把10个不同的二维码贴在A4纸上,模拟一整箱货品,然后手持设备从上到下快速扫掠,连续扫5轮。这个测试能暴露大量只在连续使用场景下才会出现的问题:
- 第1轮主要看“首扫延迟”——从App打开到第一个码上列表,耗时是否在两秒以内。实测下来,如果是720p+两帧一处理,首扫延迟约0.8秒,可以接受;如果抽帧间隔放太宽,首扫延迟会拖到1.5秒以上,用户会觉得“卡”。
- 第5轮主要看“稳定性”——连续扫5轮后,相机是否仍正常运行,列表是否越积越卡。这里最大的坑是内存泄漏。如果每一帧的ByteBuffer没有正确释放,连续扫10分钟,内存占用会以肉眼可见的速度上涨,最后直接OOM。一定要在Native侧确保每帧数据用完即释放。
- 还要看“多码同框”——如果一张A4纸上两张码靠得比较近,设备同一时间把两个码都拍进去了,系统每帧解码只会返回其中一个码,但另一张码也会在接下来一两帧内被捕获到,只要去重窗口逻辑正确,两个码最终都会上报。这里不要指望一帧解码出所有码,ZBar默认单帧只报一个,一定要靠“多帧轮流捕获”的方式实现多码不漏。
4. 常见问题与排查技巧实录
4.1 相机预览黑屏,问题出在哪儿
相机黑屏是OpenHarmony上Flutter扫码最常见的翻车点,而且现象一致、原因却五花八门。我梳理了三个最高频的原因:
- 权限申请时机不对:相机权限必须在初始化相机之前完成申请,而且不能在Dart层申请完就直接调用Native初始化,要等系统回调确认授权后,再真正打开相机。我一开始就是先调用初始化再申请权限,结果设备直接就黑屏,界面无任何错误提示。
- Surface未就绪:OpenHarmony的预览流需要绑定一个XComponent或者Surface,Flutter里如果用PlatformView渲染预览画面,如果Surface还没有创建完成就急着启动相机,预览就会是黑的。解决办法是监听Surface生命周期,就绪后再openCamera。
- 重复打开相机:页面退出时没有正确关闭,再次进入时OpenHarmony相机服务还没释放完,第二次打开就会失败。通常表现为“第一次进入是好的,退出再进入就黑屏”。解决方法是,在关闭时轮询等待相机状态变为已释放,确认释放后再允许新的初始化流程。
4.2 识别率低,扫不出来,怎么调
如果你的App偶尔能扫出来,但速度和成功率都不稳定,优先排查以下几项:
- 灵敏度太低。ZBar的setSensitivity如果低于50,二维码稍微偏一点、反光一下,就会识别失败。先调到70试一试,如果误识别增多(屏幕上没有码但频繁触发),再调回60。
- 分辨率太低。如果预览分辨率是480p以下,二维码距离稍微远一点,码的模块占不到足够的像素,解码算法会因为细节不足而失败。建议至少720p起步。
- 对焦模式。OpenHarmony相机API的对焦模式一定要设置为连续自动对焦(CAF)。如果默认是固定对焦或手动对焦,移动设备时画面会虚,解码率直线下降。这个细节我在文档里翻了很久才找到正确配置,算是个隐性坑。
- 补光。仓库这类环境光线不均,如果有暗角或反光,就尽量在页面里内置一个手电筒开关,让用户自己根据环境调节。别觉得它土,现实里这个开关能救回30%的识别率。
4.3 连扫卡顿和漏扫,如何平衡
卡顿和漏扫本质上是同一个性能预算问题的两个极端。卡顿意味着单帧处理太慢了,漏扫意味着抽帧策略太保守了。
我的建议是先做性能摸底:用日志打点统计每一帧从回调到解码返回的总耗时,观察峰值和平均值。如果平均值低于50ms,但UI还是卡顿,那瓶颈可能在Dart侧的内存GC或者列表重建上,此时要去优化列表的Item构建逻辑,而不是继续压榨解码线程。如果平均值已经超过80ms,就要降分辨率或者放宽抽帧策略。
漏扫的另一个隐藏原因是对焦滞后。用户快速扫掠时,画面里的码往往是从模糊到清楚再到出画,如果对焦还没跟上,码已经糊了,自然扫不到。这不完全是代码能解决的,但你可以做一个小优化:在识别失败时,临时触发一次对焦锁定再释放(类似“Tap to Focus”逻辑),理论上可以加快下一次对焦的收敛速度。实测对部分设备有效,对某些算法保守的设备作用不大,权当一个可选项。
4.4 平台通道通信异常
使用FFI时,最常见的问题是数据指针生命周期。Dart侧通过FFI把灰度图的内存指针传给Native,并指定长度。如果在Native解码的过程中,Dart侧因为GC把这块内存回收了,就会导致非法访问,App直接崩溃。
解决办法有两个,二选一即可:
- 在Dart侧把灰度图数据锁定为固定内存(比如用Dart的ExternalBuffer或Malloc分配,手动释放),保证解码期间不会被GC移动或回收。
- 在Native侧复制一份灰度图数据进来再解码,这样即使Dart侧回收了原缓冲区,Native内部用的也是自己的副本。缺点是每次复制多一份内存,好在720p灰度图才不到1MB,复制成本可以接受。
我自己选了第一种方案,因为复制在高帧率下还是有明显的CPU开销。但如果你对FFI内存管理不熟,先用第二种方案确保不崩,再慢慢优化也完全没问题。
最后再分享一个经验:批量扫码App的成败,七成在算法调优,三成在细节体验。算法层要多花时间做真机压力测试,尤其是连续扫掠时的手感、不同光线下识别率变化、不同距离的码的捕获速度——这些参数没有绝对标准,必须根据目标设备和使用场景去调。我这一版最终能稳定交付,主要就是在2秒去重窗口和300ms节流间隔上反复试出来的结果。你如果也想做类似功能,建议先拿抽样设备跑通全流程,再按实际场景微调参数。
