Flutter 三方库 qr_mobile_vision 的 OpenHarmony 适配实战
本文记录了将开源 Flutter 三方库
qr_mobile_vision适配到 OpenHarmony / HarmonyOS 平台的完整过程,
包含适配思路、代码改动对照、关键决策和踩坑复盘。这次适配与常见的「API 平移」型适配有本质区别:OpenHarmony 公共 SDK 里根本没有条码解码能力。
相机能拿到画面,但没有任何 API 能告诉你画面里是什么码。这个缺口贯穿了整篇适配,也是本文最有价值的部分。
一、背景
1.1 三方库简介
qr_mobile_vision 是一个 Flutter 社区使用较广的相机扫码插件,提供以下能力:
- 二维码与一维/二维条码识别 —— 支持 14 种码制(QR、Code128、Code39、Code93、Codabar、EAN-8/13、ITF、UPC-A/E、PDF417、Aztec、DataMatrix)
- 相机预览渲染 —— 通过
QrCamera组件把取景画面渲染到 Flutter 纹理上,并自动处理旋转、缩放、裁剪 - 前后摄像头切换 ——
CameraDirection.BACK/CameraDirection.FRONT - 闪光灯控制 —— 手电筒模式开关
- 生命周期感知 —— 应用切后台自动释放相机,回前台自动恢复扫描
该三方库最初支持 Android、iOS 两个平台:
- Android 侧使用 Google MLKit Barcode Scanning API(需依赖
com.google.mlkit:barcode-scanning) - iOS 侧使用 Apple Vision / AVFoundation
本次任务将其适配到 OpenHarmony / HarmonyOS 平台。
项目地址:https://atomgit.com/oh-flutter/qr_mobile_vision
发布版本:6.0.3-ohos-1.0.0-beta.1
1.2 适配目标
| 维度 | 要求 |
|---|---|
| 功能一致性 | 相机预览、扫码识别、前后摄切换、闪光灯四项能力对齐 Android/iOS;支持库内声明的全部 14 种码制 |
| Dart 层零改动 | 通道名 qr_mobile_vision、方法名 start/stop/toggleFlash/heartbeat、回调 qrRead 及参数/返回结构完全不变 |
| 性能 | 逐帧解码不得导致内存暴涨或掉帧崩溃;预览渲染走 Flutter 纹理,不引入 PlatformView 开销 |
| 工程规范 | 补齐双语鸿蒙 README(兼容性/权限/接口/踩坑)、module.json5 权限声明、oh-package.json5 依赖声明 |
1.3 先说清本次适配的特殊性
在动手之前,有必要先说明这次适配难在哪里,因为它决定了后面的所有技术决策。
Android 与 iOS 之所以能把「相机取帧 → 识别码值」写得那么简单,是因为系统或生态提供了现成的解码器:
| 平台 | 取帧 | 解码 |
|---|---|---|
| Android | Camera2 / SurfaceTexture | MLKit Barcode Scanning(Google 提供) |
| iOS | AVFoundation | Vision / CIDetector(Apple 提供) |
| OpenHarmony | CameraKit ✅ 可用 | ❌ 无任何对应能力 |
我在 OpenHarmony SDK 26.0.0 里做了一次穷尽式检索,结论是解码能力确实缺失:
| 检索项 | 结果 |
|---|---|
@kit.ScanKit(华为扫码服务) | ❌ 不存在(属 HMS 商业能力,开源 SDK 不含) |
scanBarcode / detectBarcode / customScan | ❌ 全 SDK 无此符号 |
@ohos.scan.d.ets | ⚠️ 存在,但属 打印 扫描框架(SystemCapability.Print.PrintFramework),与条码无关 |
CameraKit MetadataObjectType.BAR_CODE_DETECTION | ⚠️ 枚举存在(API 26 新增) |
CameraKit MetadataBarcodeObject | ❌ 空接口 —— interface MetadataBarcodeObject extends MetadataObject {},取不到码值 |
也就是说:CameraKit 能告诉你「这里有一个条码」,但不会告诉你「这个条码是什么」。
这构成了本次适配的核心命题:
相机取帧可以复用系统能力,但解码器必须自己带。
二、适配路线图
整个适配分为 5 个阶段:
第 1 阶段:项目初始化 ── 生成 ohos/ 平台骨架
第 2 阶段:原生实现 ── ArkTS 实现相机取帧 + 自带解码器
第 3 阶段:三方库注册 ── pubspec.yaml 声明 ohos 平台
第 4 阶段:示例工程 ── example/ohos/ 宿主工程 + 构建验证
第 5 阶段:真机验证 ── 相机链路 + 解码链路双重验证
其中 第 2 阶段是绝对重心,可再分成四条子线:
第 2 阶段(原生实现)
├─ 2.1 整体架构对比 判断插件类型、确定整体形态
├─ 2.2 通道与权限 与 Dart 侧建立契约、申请相机权限
├─ 2.3 取帧与预览 ImageReceiver + registerSurfaceTexture
└─ 2.4 解码器接入 zxing 集成 + 降采样 + 灰度转换
三、逐步适配过程
第 1 阶段:项目初始化
使用 Flutter 命令行生成 OHOS 平台骨架:
flutter create . --template=plugin --platforms=ohos
该命令自动生成 ohos/ 目录,以及示例工程的 example/ohos/。
目录结构:
ohos/
├── index.ets # 模块入口,导出插件类
├── oh-package.json5 # 包配置(声明 OHOS 侧依赖)
├── build-profile.json5 # 构建配置
└── src/main/
├── module.json5 # HAR 模块配置(含权限声明)
├── ets/components/plugin/
│ └── QrMobileVisionPlugin.ets # 原生插件实现(核心,594 行)
└── resources/base/element/
└── string.json # 权限申请理由等字符串资源
关键配置文件:
index.ets(入口导出文件)
import QrMobileVisionPlugin from './src/main/ets/components/plugin/QrMobileVisionPlugin';
export default QrMobileVisionPlugin;
oh-package.json5(包配置 —— 注意这里引入了本文最关键的外部依赖)
{
"name": "qr_mobile_vision",
"version": "1.0.0",
"description": "Flutter plugin for reading QR codes and barcodes on HarmonyOS.",
"main": "index.ets",
"license": "Apache-2.0",
"dependencies": {
"@ohos/zxing": "^2.1.2" // ← 解码器:OHOS 无系统扫码能力,必须自带
}
}
注 1:
@ohos/flutter_ohos由 Flutter 引擎在构建时自动链接,无需在dependencies中显式声明。注 2:
@ohos/zxing是本次适配的关键决策。它是 ZXing(业界最成熟的条码解码库)的 OpenHarmony 移植版,可从 ohpm 中心仓获取。没有它,这个插件在鸿蒙上无法工作。
module.json5(HAR 模块配置 + 权限声明)
{
"module": {
"name": "qr_mobile_vision",
"type": "har",
"deviceTypes": ["default", "tablet"],
"requestPermissions": [
{
"name": "ohos.permission.CAMERA",
"reason": "$string:camera_reason", // ← 必须是资源引用,不能写纯文本
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
配套的 resources/base/element/string.json:
{
"string": [
{
"name": "camera_reason",
"value": "用于扫描二维码与条形码"
}
]
}
踩坑预警:
reason字段必须是$string:xxx形式的资源引用。如果直接写中文纯文本,schema 校验会失败。这一点在第八章会再次提及。
第 2 阶段:原生实现(核心)
这是适配的核心工作。Android 侧用 1038 行 Java 完成的事,在 OHOS 侧需要用 ArkTS 重新实现,
而且要多做一件 Android 不需要做的事 —— 自带解码器。
2.1 整体架构对比
首先判断插件类型。qr_mobile_vision 属于 方法调用型:
- Dart 侧主动调用原生(
QrMobileVision.start()→ 原生start) - 原生识别到码后通过
channel.invokeMethod('qrRead', ...)反向通知 Dart
它不是事件流型(没有 EventChannel),但有一个值得注意的特点:原生会主动回调 Dart。
这在 ArkTS 侧需要持有 MethodChannel 引用,用于反向调用。
架构对照:
Android (Java) OHOS (ArkTS)
────────────────────── ──────────────────────
class QrMobileVisionPlugin export default class QrMobileVisionPlugin
implements FlutterPlugin, implements FlutterPlugin,
MethodCallHandler, MethodCallHandler,
ActivityAware AbilityAware
┌─ 通道 ─────────────┐ ┌─ 通道 ─────────────┐
│ MethodChannel │ │ MethodChannel │
│ "qr_mobile_vision"│ │ "qr_mobile_vision"│ ← 契约不变
└────────────────────┘ └────────────────────┘
┌─ 取帧 ─────────────┐ ┌─ 取帧 ─────────────┐
│ Camera2 + │ │ CameraKit + │
│ SurfaceTexture │ │ ImageReceiver │
└────────────────────┘ └────────────────────┘
┌─ 预览渲染 ─────────┐ ┌─ 预览渲染 ─────────┐
│ TextureRegistry │ │ registerSurface │
│ .createSurface │ │ Texture(receiver) │
│ Texture() │ │ → Flutter Texture │
└────────────────────┘ └────────────────────────┘
┌─ 解码 ─────────────┐ ┌─ 解码 ─────────────┐
│ MLKit │ │ @ohos/zxing │ ← 必须自带!
│ BarcodeScanning │ │ MultiFormatReader │
└────────────────────┘ └────────────────────┘
核心差异一览:
| 维度 | Android | OHOS |
|---|---|---|
| 插件接口 | FlutterPlugin + MethodCallHandler + ActivityAware | FlutterPlugin + MethodCallHandler + AbilityAware |
| 取帧 | Camera2 + SurfaceTexture | CameraKit + image.ImageReceiver |
| 预览渲染 | TextureRegistry.createSurfaceTexture() | FlutterRenderer.registerSurfaceTexture(receiver) |
| 解码 | MLKit(系统生态提供) | @ohos/zxing(自行引入) |
| 权限申请 | ActivityCompat.requestPermissions() | abilityAccessCtrl.requestPermissionsFromUser() |
| 线程模型 | 独立 HandlerThread | 主线程 + 异步 Promise |
2.2 通道注册与权限申请
通道注册:
| 平台 | 代码 |
|---|---|
| Android | new MethodChannel(binding.getBinaryMessenger(), "qr_mobile_vision") |
| OHOS | new MethodChannel(binding.getBinaryMessenger(), "qr_mobile_vision") |
onAttachedToEngine(binding: FlutterPluginBinding): void {
this.binding = binding;
this.channel = new MethodChannel(binding.getBinaryMessenger(), CHANNEL_NAME);
this.channel.setMethodCallHandler(this);
// 纹理注册表从这里拿到,用于注册相机预览
this.textureRegistry = binding.getTextureRegistry();
}
差异:OHOS 的
FlutterPluginBinding直接提供getBinaryMessenger()和getTextureRegistry(),
比 Android 需要先拿FlutterEngine再层层取更简洁。
权限申请对照:
| 平台 | 代码 |
|---|---|
| Android | ActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.CAMERA}, REQ) |
| OHOS | abilityAccessCtrl.createAtManager().requestPermissionsFromUser(context, ['ohos.permission.CAMERA']) |
private requestCameraPermission(): Promise<boolean> {
const context = this.ability;
if (context == null) {
return Promise.reject(new Error('Ability is null, cannot request permission'));
}
const atManager = abilityAccessCtrl.createAtManager();
return atManager.requestPermissionsFromUser(context, [CAMERA_PERMISSION])
.then((data) => {
const granted: boolean = data.authResults.length > 0 && data.authResults[0] === 0;
return granted;
});
}
关键差异 —— 权限模型不同:
Android 只需在
AndroidManifest.xml声明 + 运行时申请即可。HarmonyOS 则要求引用方(应用)也要声明。插件在
module.json5里声明了ohos.permission.CAMERA,
但如果应用的module.json5没有同样声明(且带reason),权限申请会静默返回失败 ——
表现为「申请返回已授权,但相机仍打不开」。这个坑在第八章会展开。
2.3 取帧与预览渲染
这是 OHOS 侧最需要重新设计的部分。Android 的 SurfaceTexture 与 OHOS 的 ImageReceiver 虽然职责相近,
但设计取向不同:
- Android
SurfaceTexture:面向 GPU 渲染,可直接setPreviewTexture()让相机把画面画进来 - OHOS
ImageReceiver:面向 CPU 取帧,通过readNextImage()拿到Image对象
而 Flutter 的纹理注册接口恰好接受 ImageReceiver:
// OHOS 引擎的 TextureRegistry 接口
export interface TextureRegistry {
registerSurfaceTexture(receiver: image.ImageReceiver): SurfaceTextureEntry;
// ...
}
于是形成一条很顺的链路:同一个 ImageReceiver,既作为相机预览的输出 Surface,又注册给 Flutter 作为纹理来源。
一份数据,两处使用,不需要额外拷贝。
完整实现(openCamera 核心段):
private openCamera(targetWidth: number, targetHeight: number,
cameraDirection: number, result: MethodResult): void {
const context = this.ability;
// 1) 创建帧接收器 —— 既是相机预览的输出,又是 Flutter 纹理的输入
// 注意:此处只接受 JPEG 入参形式,传 YCBCR_422_SP 会抛 401 Invalid type(见踩坑复盘)
let receiver: image.ImageReceiver;
try {
receiver = image.createImageReceiver(targetWidth, targetHeight,
image.ImageFormat.JPEG, 8);
} catch (err) {
result.error('QRREADER_ERROR', `createImageReceiver failed`, null);
return;
}
this.imageReceiver = receiver;
receiver.getReceivingSurfaceId().then((surfaceId: string) => {
// 2) 注册为 Flutter 纹理,拿到 Dart 侧 Texture(textureId) 所需 ID
const entry: SurfaceTextureEntry = this.textureRegistry.registerSurfaceTexture(receiver);
this.textureEntry = entry;
this.textureId = entry.getTextureId();
// 3) 相机:选设备 → 选预览档位 → 开会话
this.cameraManager = camera.getCameraManager(context);
const cameras = this.cameraManager.getSupportedCameras();
const cameraDevice = this.pickCamera(cameras, cameraDirection);
const capability = this.cameraManager.getSupportedOutputCapability(
cameraDevice, camera.SceneMode.NORMAL_PHOTO);
const previewProfile = this.pickPreviewProfile(
capability.previewProfiles, targetWidth, targetHeight);
this.surfaceWidth = previewProfile.size.width;
this.surfaceHeight = previewProfile.size.height;
this.surfaceOrientation = 90;
const input = this.cameraManager.createCameraInput(cameraDevice);
this.cameraInput = input;
input.open()
// 用同一份 surfaceId 建预览输出 —— 数据流向 ImageReceiver
.then(() => {
const previewOutput = this.cameraManager!.createPreviewOutput(previewProfile, surfaceId);
this.previewOutput = previewOutput;
const session = this.cameraManager!.createCaptureSession();
this.captureSession = session;
session.beginConfig();
session.addInput(input);
session.addOutput(previewOutput);
return session.commitConfig();
})
.then(() => this.captureSession!.start())
.then(() => {
this.running = true;
this.startFrameLoop(); // 启动解码循环
const response = new Map<string, Object>();
response.set('surfaceWidth', this.surfaceWidth);
response.set('surfaceHeight', this.surfaceHeight);
response.set('surfaceOrientation', this.surfaceOrientation);
response.set('textureId', this.textureId); // ← Dart 用它渲染 Texture
result.success(response);
});
});
}
预览分辨率选择策略(兼顾识别率与开销):
private pickPreviewProfile(profiles: camera.Profile[],
targetWidth: number, targetHeight: number): camera.Profile | null {
if (profiles == null || profiles.length === 0) return null;
// 优先选不小于目标分辨率里最接近的一档
let best: camera.Profile | null = null;
let bestArea: number = Number.MAX_VALUE;
for (const p of profiles) {
const area = p.size.width * p.size.height;
if (p.size.width >= targetWidth && p.size.height >= targetHeight && area < bestArea) {
bestArea = area;
best = p;
}
}
if (best != null) return best;
// 都小于目标则取最大的
let largest = profiles[0];
for (const p of profiles) {
if (p.size.width * p.size.height > largest.size.width * largest.size.height) largest = p;
}
return largest;
}
实测结果:Dart 侧请求
976×1950,设备实际选中的是2560×1440。
这也是后面必须做「解码降采样」的直接原因 —— 相机给的画面比我们需要的大得多。
Dart 侧拿到 textureId 后即可渲染:
// lib/src/preview.dart(上游原有代码,未做改动)
ClipRect(
child: FittedBox(
fit: fit,
child: RotatedBox(
quarterTurns: rotationCompensation,
child: SizedBox(
width: frameWidth,
height: frameHeight,
child: Texture(textureId: textureId!), // ← 用的是 OHOS 侧注册的纹理
),
),
),
)
这一步的成果:Dart 侧的预览渲染代码一行未改,OHOS 侧通过 registerSurfaceTexture 无缝接入了 Flutter 的纹理体系。
2.4 解码器接入(本次适配的真正核心)
如前所述,OHOS 没有解码能力,必须自带。选型上只有一个现实选择:
| 方案 | 优点 | 缺点 |
|---|---|---|
@ohos/zxing ✅ | ZXing 移植版,成熟稳定;纯 ArkTS 实现,无需 native 编译;支持全部 14 种码制;ohpm 直接安装 | 逐帧 CPU 解码有开销,需自行控帧 |
| 自研解码(C++ 编译 zxing-cpp) | 性能更优 | 需引入 NDK 构建链路,工程复杂度陡增,工作量与收益不成正比 |
| 调起系统扫码页 | 最省事 | 无法满足 QrCamera 内嵌预览的产品形态,且需跳转应用,体验割裂 |
决策:采用 @ohos/zxing。
集成方式(configureReader):
import {
MultiFormatReader, BarcodeFormat, DecodeHintType,
RGBLuminanceSource, BinaryBitmap, HybridBinarizer,
} from '@ohos/zxing';
private configureReader(formatStrings: string[]): void {
const hints = new Map<number, Object>();
const formats: BarcodeFormat[] = [];
// 把 Dart 传来的枚举名映射为 zxing 格式(键名必须与 Dart 发送的完全一致,见踩坑复盘)
const seen = new Set<number>();
formatStrings.forEach((name: string) => {
const mapped = FORMAT_MAP.get(name);
if (mapped != null) {
mapped.forEach((f: BarcodeFormat) => {
if (!seen.has(f)) { seen.add(f); formats.push(f); }
});
}
});
if (formats.length === 0) formats.push(BarcodeFormat.QR_CODE);
hints.set(DecodeHintType.POSSIBLE_FORMATS, formats);
hints.set(DecodeHintType.TRY_HARDER, true);
const reader = new MultiFormatReader();
reader.setHints(hints);
this.reader = reader;
}
取帧 → 解码的帧循环:
private startFrameLoop(): void {
const receiver = this.imageReceiver;
if (receiver == null) return;
// imageArrival 是「有新帧到达」通知。
// 注意:运行时传入的对象并非总是错误对象,不可据此提前 return(见踩坑复盘)
const callback = (err: BusinessError): void => {
if (err != null && err.code != null) {
hilog.warn(DOMAIN, TAG, `imageArrival reported: ${err.code}`);
}
this.decodeLatestFrame();
};
this.imageReceiverCallback = callback;
receiver.on('imageArrival', callback);
}
private async processImage(img: image.Image): Promise<void> {
try {
// 取帧数据(关于为什么用 JPEG 分量、而数据其实是 YUV,见踩坑复盘)
const comp: image.Component = await img.getComponent(image.ComponentType.JPEG);
if (comp == null) return;
const buffer: ArrayBuffer = comp.byteBuffer;
const width: number = img.size.width;
const height: number = img.size.height;
const text = this.decodeY(buffer, width, height);
if (text != null && text.length > 0) {
this.reportResult(text);
}
} finally {
img.release(); // 必须释放,否则帧池耗尽
}
}
关键优化:降采样 + 灰度直取
相机给的是 2560×1440 的 YUV420 数据,直接全分辨率送 zxing 会直接把进程吃崩(实测崩溃)。
因此做了两个处理:
- 利用 YUV 特性直取 Y 平面 —— Y 平面本身就是灰度值,与 Android 侧
PlanarYUVLuminanceSource的
做法完全一致,不需要先把 YUV 转成 RGB,省掉一次完整图像转换 - 整数倍抽稀到约 640 宽 —— 识别率足够,内存降一个量级
private decodeY(yuv: ArrayBuffer, width: number, height: number): string | null {
const reader = this.reader;
if (reader == null) return null;
try {
// 抽稀:2560 → factor=4 → 640 宽
const factor: number = Math.max(1, Math.ceil(width / DECODE_MAX_WIDTH)); // DECODE_MAX_WIDTH = 640
const dstWidth: number = Math.floor(width / factor);
const dstHeight: number = Math.floor(height / factor);
// Y 平面即灰度:取前 width*height 字节
const src: Uint8Array = new Uint8Array(yuv, 0, width * height);
const gray: Uint8ClampedArray = new Uint8ClampedArray(dstWidth * dstHeight);
for (let y = 0; y < dstHeight; y++) {
const srcRow = y * factor * width;
const dstRow = y * dstWidth;
for (let x = 0; x < dstWidth; x++) {
gray[dstRow + x] = src[srcRow + x * factor];
}
}
return this.decodeGray(gray, dstWidth, dstHeight);
} catch (err) {
// 未识别到码属正常情况(NotFoundException),静默忽略
return null;
}
}
private decodeGray(gray: Uint8ClampedArray, w: number, h: number): string | null {
const reader = this.reader;
if (reader == null) return null;
try {
const source = new RGBLuminanceSource(gray, w, h);
const bitmap = new BinaryBitmap(new HybridBinarizer(source));
const result = reader.decode(bitmap);
return result != null ? result.getText() : null;
} catch (err) {
return null; // 未识别到码不是错误
}
}
结果上报(反向调用 Dart):
private reportResult(text: string): void {
if (this.channel == null) return;
// 按「内容变化」去重:同一个码不重复上报,但扫到新码时仍会回调
if (text === this.lastReported) return;
this.lastReported = text;
hilog.info(DOMAIN, TAG, `qrRead: ${text}`);
this.channel.invokeMethod('qrRead', text); // ← 反向通知 Dart
}
这里有一个容易写错的地方:很多实现会图省事写成「识别到一次就锁定」,即
if (this.reported) return; this.reported = true;。
那样会导致永远扫不到第二个码 —— 用户扫完一个码,再扫另一个就没反应了。
正确做法是按内容去重(如上),这在第八章会作为踩坑展开。
第 3 阶段:三方库注册
在 pubspec.yaml 中添加 OHOS 平台注册:
flutter:
plugin:
platforms:
android:
package: com.github.rmtmckenzie.qr_mobile_vision
pluginClass: QrMobileVisionPlugin
ios:
pluginClass: QrMobileVisionPlugin
ohos: # ← 新增
pluginClass: QrMobileVisionPlugin # ← 对应 index.ets 默认导出
Flutter 的 OHOS 引擎在构建时会读取 pubspec.yaml 中的 ohos 配置,
自动加载 ohos/index.ets 中导出的插件类。
修改量统计:
pubspec.yaml仅新增 2 行。Dart 层、Android 层、iOS 层代码零改动。
第 4 阶段:示例工程创建与构建验证
在 example/ 目录下生成 OHOS 宿主工程(注意:在 example 目录执行,而非插件根目录):
cd example
flutter create . --platforms=ohos
该命令自动生成 example/ohos/,包含签名配置、SDK 版本、测试模块与 Flutter 运行时资源:
example/ohos/
├── AppScope/app.json5 # 应用配置(bundleName 等)
├── build-profile.json5 # 项目构建配置(signingConfigs、SDK 版本)
├── hvigor/hvigor-config.json5 # 构建工具配置
├── entry/
│ ├── build-profile.json5
│ ├── src/main/
│ │ ├── module.json5 # entry 模块配置
│ │ ├── ets/
│ │ │ ├── entryability/EntryAbility.ets # Ability 生命周期
│ │ │ ├── pages/Index.ets # Flutter 容器页
│ │ │ └── plugins/GeneratedPluginRegistrant.ets # 自动注册插件
│ │ └── resources/rawfile/flutter_assets/ # Flutter 运行时资源
│ └── src/ohosTest/ # 测试目录
说明:
GeneratedPluginRegistrant.ets由 Flutter 工具依据pubspec.yaml的ohos配置自动生成,
会import QrMobileVisionPlugin from 'qr_mobile_vision'并注册到引擎,无需手写。
构建验证:
cd example
flutter build hap --debug --target-platform ohos-arm64
成功产出:
✓ Built build/ohos/hap/entry-default-signed.hap
签名提示:首次构建会提示「请通过 DevEco Studio 配置调试签名」。
需在example/ohos/build-profile.json5的signingConfigs中填入调试证书,
或通过 DevEco Studio 的File → Project Structure → Signing Configs自动生成。
第 5 阶段:真机验证(双重验证)
本阶段的验证策略值得单独说明,详见第六章。

四、完整代码对照
4.1 Android vs OHOS 完整实现对照
| 维度 | Android (Java) | OHOS (ArkTS) |
|---|---|---|
| 语言 | Java / Kotlin | ArkTS (TypeScript 语法) |
| 插件接口 | FlutterPlugin, MethodCallHandler, ActivityAware | FlutterPlugin, MethodCallHandler, AbilityAware |
| 取帧组件 | Camera2 + SurfaceTexture | CameraKit + image.ImageReceiver |
| 纹理创建 | TextureRegistry.createSurfaceTexture() | FlutterRenderer.registerSurfaceTexture(receiver) |
| 解码能力 | MLKit BarcodeScanning.getClient() | @ohos/zxing MultiFormatReader |
| 解码输入 | Frame(MLKit 封装) | Y 平面灰度 + 整数倍抽稀 |
| 权限申请 | ActivityCompat.requestPermissions() | abilityAccessCtrl.requestPermissionsFromUser() |
| 权限声明 | AndroidManifest.xml(仅插件) | 插件 + 应用侧都要声明 |
| 生命周期 | onAttachedToActivity | onAttachedToAbility |
| 线程 | HandlerThread + Handler | 主线程 + Promise 异步链 |
| 日志 | Log.d(TAG, ...) | hilog.info(DOMAIN, TAG, ...) |
| 代码规模 | 1038 行 | 594 行 |
有趣的对比:OHOS 侧用 594 行完成了 Android 侧 1038 行的工作(约 57%)。
这主要得益于两点:ArkTS 的异步 Promise 链写法更紧凑;MLKit 在 Android 侧封装了大量
相机与解码胶水代码,而 OHOS 侧用ImageReceiver一个对象就同时解决了取帧与纹理两件事。
4.2 关键 ArkTS 语法差异
| Java 语法 | ArkTS 语法 | 备注 |
|---|---|---|
import io.flutter.plugin.common.MethodChannel; | import { MethodChannel } from '@ohos/flutter_ohos'; | ArkTS 使用模块化导入,从统一入口取 |
new HashMap<String, Object>() | new Map<string, Object>() | 泛型语法不同 |
map.put(k, v) | map.set(k, v) | Map 方法名不同 |
obj != null | obj != null / 可选链 ?. | 空值判断语义一致 |
try { } catch (Exception e) { } | try { } catch (err) { const e = err as BusinessError; } | ArkTS 需显式类型断言 |
Xxx.this | (err as BusinessError).code | 错误对象需断言后取 code / message |
String.format("...%s", v) | `...${v}` | 模板字符串 |
implements A, B, C | implements A, B, C | 一致 |
interface 匿名实现 | export default class ... implements | ArkTS 要求显式类声明 |
for (String s : list) | for (const s of list) | 遍历语法不同 |
4.3 一处值得注意的「同名不同物」
image.ComponentType 这个枚举在 OHOS 侧有个反直觉的点:
enum ComponentType {
YUV_Y = 1,
YUV_U = 2,
YUV_V = 3,
JPEG = 4, // ← 名字叫 JPEG,但在我们场景下它承载的是 YUV 数据
}
本次适配中,createImageReceiver 必须传 ImageFormat.JPEG(否则报 401),
但相机预览流实际投递的是 YUV420 数据(帧长 = 宽 × 高 × 1.5,已实测确认)。
因此代码里出现了一个看起来矛盾实则正确的组合:
// 创建时要求 JPEG 形式
image.createImageReceiver(w, h, image.ImageFormat.JPEG, 8)
// 读取时用 JPEG 分量名,但内容其实是 YUV
img.getComponent(image.ComponentType.JPEG)
这是第八章「踩坑复盘」的第一条,也是本次适配最费时的一个点。
五、关键决策说明
决策 1:解码器自带(引入 @ohos/zxing),而非依赖系统能力
OpenHarmony 公共 SDK 没有条码解码能力(第一章已列出检索证据)。这不是「暂时没找到」,
而是「确实不存在」—— 华为的扫码服务属 HMS 商业能力,不含在开源 SDK 中。
因此只有两条路:调起系统扫码页(破坏 QrCamera 的内嵌预览形态),或自带解码器。
选择 @ohos/zxing:纯 ArkTS 实现、无需 NDK 构建链路、ohpm 直接安装、覆盖全部 14 种码制。
维护策略:oh-package.json5 中声明 "@ohos/zxing": "^2.1.2",跟随上游更新。
若后续 OHOS 官方开放扫码 API,可替换解码层而不影响其它任何代码(解码已被 configureReader / decodeGray 两个函数收敛)。
决策 2:保持通道名与方法签名完全不变
Dart 侧 MethodChannel('qr_mobile_vision') 已固定,OHOS 原生侧必须使用完全相同的通道名。
通道名是 Dart 与原生之间的通信契约,一旦不一致,插件会静默失效(不报错,只是收不到调用)。
维护策略:通道名抽为常量 CHANNEL_NAME,方法名在 onMethodCall 中集中分发。
决策 3:Dart 层零改动
Dart API 通过 MethodChannel.invokeMethod('start', {...}) 调用,方法签名和返回值结构保持不变:
// 上游 Dart 代码(未改动)
final details = (await methodChannel.invokeMapMethod<String, dynamic>('start', {
'targetWidth': width,
'targetHeight': height,
'heartbeatTimeout': 0,
'cameraDirection': (cameraDirection == CameraDirection.FRONT ? 0 : 1),
'formats': formatStrings,
}))!;
int? textureId = details["textureId"];
num? orientation = details["surfaceOrientation"];
num surfaceHeight = details["surfaceHeight"];
num surfaceWidth = details["surfaceWidth"];
OHOS 侧返回同样的四个字段即可,Dart 层一行不改。
维护策略:任何新增能力都应通过新增方法实现,不改动既有方法的参数与返回结构。
决策 4:预览走 Flutter 纹理,而非 PlatformView
OHOS 侧有两条渲染路径可选:
| 方案 | 优点 | 缺点 |
|---|---|---|
Flutter 纹理(registerSurfaceTexture) ✅ | 与上游 Dart 的 Texture(textureId:) 天然契合;无 PlatformView 合成开销;跨平台渲染路径一致 | 需要 ImageReceiver 的 surfaceId |
PlatformView(registerViewFactory) | 可直接嵌入原生 View | 需额外实现 PlatformViewFactory/PlatformView;与上游 Dart 的 Texture 用法不匹配,必然要改 Dart 代码 |
决策依据很直接:上游 Dart 侧已经用 Texture(textureId: textureId!) 渲染预览(见 lib/src/preview.dart)。
选 PlatformView 就必须回头改 Dart 层,违背「Dart 零改动」目标。而 OHOS 引擎的
TextureRegistry.registerSurfaceTexture(receiver) 恰好接受 ImageReceiver,
与本插件取帧所需的 ImageReceiver 是同一个对象 —— 一份数据两用,零额外拷贝。
维护策略:纹理生命周期由 releaseResources() 统一管理(unregisterTexture)。
决策 5:解码前抽稀到约 640 宽
相机给出的是 2560×1440。全分辨率逐帧送 zxing 会因内存分配把进程吃崩(实测崩溃,见踩坑复盘)。
| 方案 | 优点 | 缺点 |
|---|---|---|
| 整数倍抽稀到 ~640 宽 ✅ | 内存降一个量级;采样是整数倍,实现简单无插值开销;640 宽对二维码识别率足够 | 远距离小码识别率可能下降 |
| 全分辨率解码 | 识别率最高 | 实测进程崩溃 |
改用 desiredSize 让系统缩 | 由系统实现,可能更优 | 需先解码 JPEG/YUV 再缩放,多一次完整图像转换 |
选择抽稀而非系统缩放,是因为可以跳过「YUV → RGB → 缩 → 再转灰度」的完整转换,
直接从 Y 平面按步长采样,路径最短。
维护策略:常量 DECODE_MAX_WIDTH = 640 可调;若需提升小码识别率,可调大该值(需同步观察内存)。
决策 6:识别结果按内容去重,而非一次性锁定
// ✅ 正确做法:内容变化才上报
if (text === this.lastReported) return;
this.lastReported = text;
this.channel.invokeMethod('qrRead', text);
若写成一次性锁定(if (reported) return; reported = true;),扫完第一个码后再扫第二个将毫无反应。
上游 Android 实现是「每次识别都回调」,OHOS 侧取「内容变化」作为折中:
既避免同一码在连续帧中高频重复上报,又保证扫新码时仍能触发。
维护策略:lastReported 在每次 start 时重置为空串。
决策 7:尺寸向上对齐为偶数
Dart 侧传入的尺寸可能为奇数(实测 975),而 YUV 要求宽高为偶数:
const targetWidth: number = rawWidth % 2 === 0 ? rawWidth : rawWidth + 1; // 975 → 976
维护策略:在 handleStart 入口统一对齐,后续流程不再关心奇偶。
六、测试与验证
6.1 测试环境
| 项目 | 版本 |
|---|---|
| Flutter | 3.47.5-ohos-1.0.0(channel oh-3.47-dev) |
| Dart | 3.13.3 |
| DevTools | 2.60.0 |
| HarmonyOS SDK | 6.0.0(26) |
| IDE | DevEco Studio 26.0.0 |
| hvigor / ohpm | 26.0.0.630 |
| Node | v26.0.0 |
| 设备型号 | ALN-AL00(HUAWEI,硬件版本 HL1CMSM) |
| 设备 ROM | OpenHarmony-7.0.0.105(API 26) |
| 设备架构 | arm64-v8a |
| 设备类型 | 真机(const.build.characteristics = default,非模拟器) |
版本获取方式:
| 版本项 | 获取方式 |
|---|---|
| Flutter / Dart | flutter --version |
| HarmonyOS SDK | 读取 example/ohos/build-profile.json5 的 compatibleSdkVersion / targetSdkVersion |
| IDE | /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" /Applications/DevEco-Studio.app/Contents/Info.plist |
| 设备 ROM | hdc shell param get const.ohos.fullname |
| 设备 API | hdc shell param get const.ohos.apiversion |
| 设备架构 | hdc shell param get const.product.cpu.abilist |
| 设备型号 | hdc shell param get const.product.model |
一个容易混淆的点:
const.ohos.fullname返回OpenHarmony-7.0.0.105,
而const.product.software.version返回ALN-AL00 7.0.0.107(SP8C00E105R4P3)。
前者是 OpenHarmony 基线版本,后者是华为商用固件版本号。文档中一般引用前者。
6.2 验证策略:为什么用了「双重验证」
本次验证面临一个现实约束:扫码这类依赖相机取景的功能,验证结果受物理环境影响很大。
如果只跑一次「对着二维码扫」,那么「扫不到」这个结果有两种可能:
① 代码有问题;② 摄像头没对准。无法区分。
因此采用了分层验证策略:
| 层级 | 验证内容 | 能证明什么 | 能否自动化 |
|---|---|---|---|
| L1 构建验证 | flutter build hap 产出 HAP,ArkTS 编译 0 错误 | 代码语法/类型/接口正确 | ✅ 可自动化 |
| L2 相机链路 | 相机启动、纹理注册、持续取帧 | 相机与预览渲染路径打通 | ✅ 可自动化 |
| L3 解码链路 | 用已知内容的二维码走解码路径,校验返回值 | 解码器与格式映射正确 | ✅ 可自动化 |
| L4 实拍扫码 | 相机拍摄屏幕上的已知二维码 | 端到端(含光学路径) | ❌ 需人工对准 |
L3 是本次验证的关键设计:它绕开了物理取景的不确定性,用确定性的输入验证解码逻辑。
6.3 L1~L2:构建与相机链路
$ cd example
$ flutter build hap --debug --target-platform ohos-arm64
✓ Built build/ohos/hap/entry-default-signed.hap
$ hdc install -r entry-default-signed.hap
install bundle successfully.
$ hdc shell aa start -a EntryAbility -b com.example.my
start ability successfully.
真机日志(hdc shell hilog | grep QrMobileVisionPlugin):
camera permission granted=true
imageReceiver created
started: textureId=1 2560x1440
结论:权限申请成功、帧接收器创建成功、Flutter 纹理注册成功(textureId=1)、
相机选中 2560×1440 预览档位并成功启动。
6.4 L3:解码链路确定性验证(关键)
为了在不受取景影响的情况下验证解码,设计了两阶段自检:
自检 1:直接解码内置二维码
将一张内容为 FLUTTER-QR-3.47.5-OHOS-VERIFY 的二维码打包进 HAP 的 rawfile,
启动时解码并校验:
selfTest: rawfile bytes=1243
selfTest RESULT: FLUTTER-QR-3.47.5-OHOS-VERIFY ← 与预期内容一致 ✓
自检 2:构造与相机同规格的 YUV420 帧,走完整相机解码路径
自检 1 只验证了「zxing 能解码」,还没验证「相机路径的处理逻辑正确」。
于是进一步构造与相机真实输出同规格(2560×1440 YUV420)的帧 —— 把二维码按原始比例居中
放入 Y 平面,UV 置中性 128 —— 然后调用与相机完全相同的解码函数:
selfTest CAMERA-PATH: FLUTTER-QR-3.47.5-OHOS-VERIFY ← 同样正确 ✓
这个自检第一次失败了,原因是我最初把正方形二维码直接拉伸成 16:9 铺满画面,
码被横向拉长 1.78 倍导致无法识别。改为按比例居中放置后通过。
这也说明自检本身的价值:它同时验证了「解码逻辑」和「自检构造逻辑」。
这一层的意义:证明「YUV 帧 → 抽稀到 640 宽 → 取 Y 平面灰度 → zxing 解码 → 返回文本」
这条链路逻辑上是正确的,排除了解码实现的问题。
6.5 L4:实拍扫码
在相机实际对准二维码时,真机日志确凿记录了解码成功并回传 Dart:
frame: bytes=5533696 2560x1440 ratio=1.50B/px
qrRead: <解码内容>
其中 ratio=1.50B/px 正是 YUV420 的特征值(4:2:0 每像素 1.5 字节),
印证了「入参写 JPEG、实际是 YUV」这一判断(见第八章)。
6.6 验证要点清单
| # | 验证项 | 结果 |
|---|---|---|
| 1 | 权限申请 | ✅ camera permission granted=true |
| 2 | 帧接收器创建 | ✅ imageReceiver created |
| 3 | Flutter 纹理注册 | ✅ textureId 有效 |
| 4 | 相机启动与预览档位选择 | ✅ 2560x1440 |
| 5 | 持续取帧 | ✅ 帧长 5533696 = 2560×1440×1.5(YUV420) |
| 6 | 14 种码制正确装载 | ✅ decoder formats: [0,1,2,3,4,5,6,7,8,10,11,14,15](13 项,去重后) |
| 7 | 解码正确性(直接) | ✅ 返回预期内容 |
| 8 | 解码正确性(相机路径) | ✅ 返回预期内容 |
| 9 | 解码结果回传 Dart | ✅ qrRead: <内容> |
| 10 | 构建验证 | ✅ ✓ Built entry-default-signed.hap,ArkTS 错误 0 |
| 11 | 前后摄切换 | ✅ 日志可见 dir=1(后)与 dir=0(前)切换 |
| 12 | 插件自动注册 | ✅ Adding plugin: QrMobileVisionPlugin |
关于第 6 项:这一项在适配过程中曾经是不通过的 —— 格式映射表的键名全部写成小写驼峰,
而 Dart 实际发送的是大写枚举名,导致所有映射失败、静默回退为仅支持 QR_CODE。
详见第八章踩坑复盘第 5 条。
七、运行效果
适配完成后,在 OpenHarmony 真机上运行示例工程:
cd example
flutter run -d 192.168.1.5:35095
取景画面通过 Flutter 纹理正常渲染(Texture(textureId: ...)),
识别到码后回调更新界面文字:
QrCamera(
qrCodeCallback: (code) {
setState(() => qr = code); // 界面显示 QRCODE: <识别内容>
},
...
)
示例界面包含:
- 取景区:实时相机画面(Flutter 纹理渲染)
- 方向开关:Back / Front 切换前后摄像头
- 右上角闪光灯按钮:
QrCamera.toggleFlash() - 底部结果文字:
QRCODE: <识别内容> - 右下角 on/off 悬浮按钮:启停相机
截图:受验证环境限制(需人工将设备摄像头对准屏幕上的二维码),
本文以真机日志作为功能验证的客观证据:
started: textureId=1 2560x1440→frame: bytes=5533696→qrRead: <内容>。
这组日志完整覆盖了「相机启动 → 取帧 → 解码 → 回传」全链路。
八、遗留问题与改进方向
8.1 踩坑复盘(本次适配最有价值的部分)
以下 6 个坑全部来自真机调试,均有明确日志证据,不是推测。
| # | 踩坑点 | 现象 / 报错 | 根因与解法 |
|---|---|---|---|
| 1 | createImageReceiver 报 401 Invalid type | 构造帧接收器直接抛异常,相机根本起不来 | 根因:该 ROM 上 createImageReceiver 只接受 ImageFormat.JPEG 形式的入参,传 ImageFormat.YCBCR_422_SP 必失败。解法:改用 image.ImageFormat.JPEG。注意:这是入参形式的要求,不代表数据真是 JPEG —— 见坑 2。 |
| 2 | 入参写 JPEG,数据实为 YUV420 | 按 JPEG 解码报 Decode failed. e.g.,Decode image header failed. | 根因:帧长实测 5533696 = 2560×1440×1.5,这是 YUV420 的精确特征(每像素 1.5 字节),根本不是 JPEG。解法:不要按 JPEG 解码,直接取 Y 平面作灰度送 zxing(Y 平面本身即灰度,等价于 Android 侧 PlanarYUVLuminanceSource)。教训:入参的「格式要求」与「实际数据格式」可能不一致,必须用帧长等客观数据验证。 |
| 3 | 全分辨率解码导致进程崩溃 | DfxFaultLogger 堆栈指向 decodeY;进程被系统回收 | 根因:2560×1440 逐帧交给 zxing,每帧都在分配数 MB 的中间缓冲,内存迅速爆掉。 解法:按整数倍抽稀到约 640 宽后再解码,内存降一个量级。 |
| 4 | imageArrival 回调提前 return 导致永不取帧 | 相机启动成功,但一帧都取不到,无任何解码日志 | 根因:误把 imageArrival 回调的参数当作「错误对象」,写成 if (err != null) return;。而运行时传入的是一个非错误的占位对象,于是每次回调都直接返回。解法: imageArrival 是「有新帧到达」通知,一律尝试读取;仅在 err.code != null 时记一条 warn 日志。教训:回调签名里有 AsyncCallback 不代表参数一定是错误。 |
| 5 | 格式映射键名与 Dart 不匹配,非 QR 码制静默失效 | 无任何报错,但用户指定 CODE_128 等码制时扫不出来 | 根因:Dart 用 format.toString().split('.')[1] 生成字符串,即枚举名原样(ALL_FORMATS、CODE_128、EAN_13,全大写含下划线)。而映射表键名写成了小写驼峰(all、code128、ean13),没有一个能匹配。更隐蔽的是:默认 [ALL_FORMATS] 匹配失败后代码会回退到 QR_CODE,导致二维码能扫、其它码制静默失效,问题被完美掩盖。解法:键名改为与 Dart 完全一致的大写枚举名; ALL_FORMATS 展开为全部码制;未知名称改为记日志而非静默丢弃。教训:跨语言传字符串时,必须核对生成方的实际输出,不能凭直觉写映射表。 |
| 6 | 上报一次性锁定,扫不到第二个码 | 扫完第一个码后,再扫其它码毫无反应 | 根因:图省事写成 if (reported) return; reported = true;,一旦上报过就永久锁定。解法:改为按内容去重( if (text === lastReported) return;),既防止同码高频重复上报,又保证扫新码时可再次触发。教训:上游 Android 实现是「每次识别都回调」,移植时不要自作聪明加锁定。 |
额外记录一个自检自身的坑:
| 踩坑点 | 现象 | 根因与解法 |
|---|---|---|
| 自检构造的 YUV 帧解码失败 | selfTest CAMERA-PATH: DECODE_FAILED | 根因:把 1110×1110 的正方形二维码直接拉伸铺满 2560×1440(16:9),横向拉长 1.78 倍,码变形无法识别。 解法:按原始比例居中放置,两侧留白(并保留静默区)。 |
8.2 关于「格式要求」与「实际数据」不一致的深入思考
坑 1 与坑 2 组合起来,是本次适配最反直觉的部分,值得单独说明。
现象是:创建时要求 JPEG,拿到的却不是 JPEG。
这个设计在 OpenHarmony 的 API 文档中有说明:
ImageReceiver的参数「does not actually affect the received images.
The configuration of image properties should be done on the sending side (the producer),
such as when creating a camera preview stream.」
也就是说:createImageReceiver 的 format 参数只是「能力声明」,
实际投递什么格式由生产者(这里是相机预览流)决定。
这带来一个实用的调试方法:用帧长反推实际格式。
| 帧长 / 像素数 | 推断格式 |
|---|---|
| 1.0 B/px | 纯灰度(Y 平面单独投递) |
| 1.5 B/px | YUV420(4:2:0,1 + 0.25 + 0.25) |
| 2.0 B/px | YUV422(4:2:2) |
| 3.0 B/px | RGB888 |
| 4.0 B/px | RGBA8888 |
本次实测 5533696 / (2560 × 1440) = 1.5,精确等于 YUV420,由此确认了数据真实格式。
这个方法的普适性:在任何平台上排查「拿到的图像数据格式不对」问题时,
先算字节/像素比,比猜要快得多。
8.3 已知问题
-
闪光灯在部分设备上不支持 —— 该设备在预览模式下开启闪光灯报
7400102 Operation not allowed: Feature or mode unsupported。插件已做静默降级(不抛异常),但 UI 无法感知是否真的打开了闪光灯。 -
远距离小码识别率 —— 解码前抽稀到约 640 宽,对于远距离的小尺寸二维码,抽稀后可能丢失细节。可通过调大
DECODE_MAX_WIDTH改善,但需权衡内存。 -
未支持一帧多码 —— 当前每次只回报一个识别结果,未实现同框多码同时返回。
-
sdkInt字段语义 —— 该字段源自 Android(系统 SDK 版本)。鸿蒙侧返回 API 版本,非 Android 平台统一为-1,语义上略勉强。 -
heartbeat()为兼容占位 —— Android 侧用于维持扫码会话;鸿蒙侧扫码是持续读取模式,无需心跳维持,该接口保留仅为兼容同一套 Dart 代码。 -
实拍扫码的人工验证环节 —— 本次由真机日志确认了解码与回传成功,但「多种机型 × 多种码制」的实拍矩阵验证仍建议由社区开发者补充。
8.4 未来优化
- 解码性能 —— 可引入帧间隔控制(如每 N 帧解码一次)或运动检测,降低空闲时的 CPU 占用。
- 多码识别 —— 可基于 zxing 的
GenericMultipleBarcodeReader扩展一帧多码能力。 - 识别区域裁剪 —— 支持指定 ROI(取景框区域),只解码框内区域,可显著提升性能与准确率。
- 码制自动降级 —— 当前
ALL_FORMATS会装载全部 13 种码制,解码开销较大。可考虑「先试 QR,未命中再试全量」的分级策略。 - 等待官方扫码能力 —— 若 OHOS 后续开放系统级扫码 API,可替换解码层(已收敛在
configureReader/decodeGray两个函数中)。
九、总结
9.1 核心路径:三步走
将一个 Flutter 三方库适配到 OHOS 平台,核心路径可以概括为 三步走:
1. 找对应 ── 找到 OHOS 对每个 Android 原生 API 的等价实现
2. 保契约 ── 确保方法通道名、方法名、返回值结构完全一致
3. 补缺口 ── 对于 OHOS 不提供的 API,用合理方案弥补
本次适配中,这三步的难度分布极不均匀,很值得记录:
| 步骤 | 本次情况 | 难度 |
|---|---|---|
| 找对应 | 取帧、纹理、权限都有清晰对应物;但解码能力完全没有对应物 | 🟡 中等(有缺口) |
| 保契约 | 通道名、方法名、返回结构照搬即可,pubspec.yaml 仅改 2 行 | 🟢 简单 |
| 补缺口 | 引入 @ohos/zxing 自行实现完整解码链路 —— 本次真正的重心 | 🔴 困难 |
结论:「补缺口」才是这次适配的实质工作。
Android 侧直接把图片交给 MLKit 就完事了,而 OHOS 侧需要自己完成
「取 YUV 帧 → 转灰度 → 抽稀 → 解码 → 上报」整条链路,还要处理内存与格式的坑。
9.2 适配成果
| 指标 | 数值 |
|---|---|
新增 ohos/ 实现 | 594 行 ArkTS |
| 对应 Android 实现 | 1038 行 Java(约 57%) |
pubspec.yaml 修改 | 2 行 |
| Dart 层改动 | 0 行 |
| Android / iOS 代码改动 | 0 行 |
| 新增文件 | 44 个(含 example/ohos/ 宿主工程) |
| 修复的真实缺陷 | 6 个(均由真机日志定位) |
Dart 层和其他平台的代码完全不受影响 —— 这正是 Flutter 跨平台三方库生态的魅力所在:
新增一个平台,其他平台一行不动。
9.3 一句话经验
适配的难点往往不在「翻译 API」,而在「系统没有这个能力」。
遇到这种情况,先做穷尽式检索确认能力确实缺失(而不是自己没找到),
再评估「自带实现 / 调系统页 / 降级」三条路,然后果断引入所需依赖。本次的
@ohos/zxing就是这个判断的产物。它也提醒我们:
一个插件的适配难度,很大程度上取决于目标平台有没有现成的对应能力。
9.4 给后来者的建议
如果你是第一次做 OHOS 插件适配,本次经验中最值得直接借鉴的是:
- 先确认目标平台的「能力清单」 —— 像本文第一章那样做一次穷尽检索,明确哪些能力有、哪些没有。这决定了整个适配的工作量。
- 入口处收敛格式差异 —— 尺寸奇偶、类型转换等平台差异,在
handleStart入口统一处理,后续流程就不用关心了。 - 用「帧长/像素比」验证数据格式 —— 比对着文档猜要快得多(8.2 节)。
- 回调参数不要想当然 ——
imageArrival的参数不一定是错误对象(坑 4)。 - 跨语言字符串映射必须核对生成方输出 —— 格式映射表这个坑极隐蔽,因为默认值恰好掩盖了它(坑 5)。
- 移植时不要「自作聪明」 —— 上游怎么做的就怎么做,加了「优化」反而可能引入 bug(坑 6)。
参考文档
- qr_mobile_vision 官方仓库(GitHub)
- qr_mobile_vision 鸿蒙适配仓库(AtomGit)
- Flutter OH 适配框架 flutter_flutter
- @ohos/zxing(ohpm 中心仓)
- HarmonyOS CameraKit 开发指南
- HarmonyOS ImageKit(ImageReceiver)API
开源协议
本项目遵循 MIT License,与上游 qr_mobile_vision 保持一致。
本文基于
qr_mobile_vision6.0.3 →6.0.3-ohos-1.0.0-beta.1的实际适配过程撰写。
所有踩坑记录均来自真机调试(ALN-AL00 / OpenHarmony-7.0.0.105 / API 26 / arm64-v8a),
附带日志证据,非推测。如果你的设备或 ROM 表现不同(例如
createImageReceiver接受YCBCR_422_SP),
欢迎在仓库提 Issue 反馈,帮助生态覆盖更多机型。
更多推荐


所有评论(0)