本文记录了将开源 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 之所以能把「相机取帧 → 识别码值」写得那么简单,是因为系统或生态提供了现成的解码器:

平台取帧解码
AndroidCamera2 / SurfaceTextureMLKit Barcode Scanning(Google 提供)
iOSAVFoundationVision / CIDetector(Apple 提供)
OpenHarmonyCameraKit ✅ 可用❌ 无任何对应能力

我在 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  │
   └────────────────────┘                        └────────────────────┘

核心差异一览:

维度AndroidOHOS
插件接口FlutterPlugin + MethodCallHandler + ActivityAwareFlutterPlugin + MethodCallHandler + AbilityAware
取帧Camera2 + SurfaceTextureCameraKit + image.ImageReceiver
预览渲染TextureRegistry.createSurfaceTexture()FlutterRenderer.registerSurfaceTexture(receiver)
解码MLKit(系统生态提供)@ohos/zxing(自行引入)
权限申请ActivityCompat.requestPermissions()abilityAccessCtrl.requestPermissionsFromUser()
线程模型独立 HandlerThread主线程 + 异步 Promise
2.2 通道注册与权限申请

通道注册:

平台代码
Androidnew MethodChannel(binding.getBinaryMessenger(), "qr_mobile_vision")
OHOSnew 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 再层层取更简洁。

权限申请对照:

平台代码
AndroidActivityCompat.requestPermissions(activity, new String[]{Manifest.permission.CAMERA}, REQ)
OHOSabilityAccessCtrl.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 会直接把进程吃崩(实测崩溃)。
因此做了两个处理:

  1. 利用 YUV 特性直取 Y 平面 —— Y 平面本身就是灰度值,与 Android 侧 PlanarYUVLuminanceSource 的
    做法完全一致,不需要先把 YUV 转成 RGB,省掉一次完整图像转换
  2. 整数倍抽稀到约 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 / KotlinArkTS (TypeScript 语法)
插件接口FlutterPlugin, MethodCallHandler, ActivityAwareFlutterPlugin, MethodCallHandler, AbilityAware
取帧组件Camera2 + SurfaceTextureCameraKit + image.ImageReceiver
纹理创建TextureRegistry.createSurfaceTexture()FlutterRenderer.registerSurfaceTexture(receiver)
解码能力MLKit BarcodeScanning.getClient()@ohos/zxing MultiFormatReader
解码输入Frame(MLKit 封装)Y 平面灰度 + 整数倍抽稀
权限申请ActivityCompat.requestPermissions()abilityAccessCtrl.requestPermissionsFromUser()
权限声明AndroidManifest.xml(仅插件)插件 + 应用侧都要声明
生命周期onAttachedToActivityonAttachedToAbility
线程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 != nullobj != 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, Cimplements A, B, C一致
interface 匿名实现export default class ... implementsArkTS 要求显式类声明
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 测试环境

项目版本
Flutter3.47.5-ohos-1.0.0(channel oh-3.47-dev)
Dart3.13.3
DevTools2.60.0
HarmonyOS SDK6.0.0(26)
IDEDevEco Studio 26.0.0
hvigor / ohpm26.0.0.630
Nodev26.0.0
设备型号ALN-AL00(HUAWEI,硬件版本 HL1CMSM)
设备 ROMOpenHarmony-7.0.0.105(API 26)
设备架构arm64-v8a
设备类型真机(const.build.characteristics = default,非模拟器)

版本获取方式:

版本项获取方式
Flutter / Dartflutter --version
HarmonyOS SDK读取 example/ohos/build-profile.json5 的 compatibleSdkVersion / targetSdkVersion
IDE/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" /Applications/DevEco-Studio.app/Contents/Info.plist
设备 ROMhdc shell param get const.ohos.fullname
设备 APIhdc 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
3Flutter 纹理注册✅ textureId 有效
4相机启动与预览档位选择✅ 2560x1440
5持续取帧✅ 帧长 5533696 = 2560×1440×1.5(YUV420)
614 种码制正确装载✅ 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 个坑全部来自真机调试,均有明确日志证据,不是推测。

#踩坑点现象 / 报错根因与解法
1createImageReceiver 报 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 宽后再解码,内存降一个量级。
4imageArrival 回调提前 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/pxYUV420(4:2:0,1 + 0.25 + 0.25)
2.0 B/pxYUV422(4:2:2)
3.0 B/pxRGB888
4.0 B/pxRGBA8888

本次实测 5533696 / (2560 × 1440) = 1.5,精确等于 YUV420,由此确认了数据真实格式。

这个方法的普适性:在任何平台上排查「拿到的图像数据格式不对」问题时,
先算字节/像素比,比猜要快得多。

8.3 已知问题

  1. 闪光灯在部分设备上不支持 —— 该设备在预览模式下开启闪光灯报 7400102 Operation not allowed: Feature or mode unsupported。插件已做静默降级(不抛异常),但 UI 无法感知是否真的打开了闪光灯。

  2. 远距离小码识别率 —— 解码前抽稀到约 640 宽,对于远距离的小尺寸二维码,抽稀后可能丢失细节。可通过调大 DECODE_MAX_WIDTH 改善,但需权衡内存。

  3. 未支持一帧多码 —— 当前每次只回报一个识别结果,未实现同框多码同时返回。

  4. sdkInt 字段语义 —— 该字段源自 Android(系统 SDK 版本)。鸿蒙侧返回 API 版本,非 Android 平台统一为 -1,语义上略勉强。

  5. heartbeat() 为兼容占位 —— Android 侧用于维持扫码会话;鸿蒙侧扫码是持续读取模式,无需心跳维持,该接口保留仅为兼容同一套 Dart 代码。

  6. 实拍扫码的人工验证环节 —— 本次由真机日志确认了解码与回传成功,但「多种机型 × 多种码制」的实拍矩阵验证仍建议由社区开发者补充。

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 插件适配,本次经验中最值得直接借鉴的是:

  1. 先确认目标平台的「能力清单」 —— 像本文第一章那样做一次穷尽检索,明确哪些能力有、哪些没有。这决定了整个适配的工作量。
  2. 入口处收敛格式差异 —— 尺寸奇偶、类型转换等平台差异,在 handleStart 入口统一处理,后续流程就不用关心了。
  3. 用「帧长/像素比」验证数据格式 —— 比对着文档猜要快得多(8.2 节)。
  4. 回调参数不要想当然 —— imageArrival 的参数不一定是错误对象(坑 4)。
  5. 跨语言字符串映射必须核对生成方输出 —— 格式映射表这个坑极隐蔽,因为默认值恰好掩盖了它(坑 5)。
  6. 移植时不要「自作聪明」 —— 上游怎么做的就怎么做,加了「优化」反而可能引入 bug(坑 6)。

参考文档


开源协议

本项目遵循 MIT License,与上游 qr_mobile_vision 保持一致。


本文基于 qr_mobile_vision 6.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 反馈,帮助生态覆盖更多机型。

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐