1. 项目背景与方案选型
1.1 为什么需要自定义扫一扫页面
做过鸿蒙应用开发的同学应该都有体会:系统自带的扫码能力虽然能用,但一到实际项目里,十有八九要自定义扫一扫页面。原因很简单,扫码功能从来不是一个孤立的技术点,它往往要融入业务的整体交互逻辑里——界面上要有品牌色的扫码框、要有“从相册选择二维码”的入口、要有手电筒开关、要有扫码结果的自定义处理弹窗,甚至要支持连续扫码、扫码枪模式这些特殊场景。默认扫码界面在视觉上不可控,交互上也无法深度定制,所以“自定义扫一扫页面”几乎是每个扫码相关鸿蒙项目的刚需。
这篇文章我会完整讲一遍我最近在鸿蒙项目里做自定义扫码页的整个方案落地过程,从最基础的权限申请、XComponent相机预览,到扫码引擎的接入、识别性能调优,再到自定义扫码框、相册识别、手电筒这些交互细节的实现,最后把踩过的坑和排查思路整理出来。适合刚接触鸿蒙开发、准备做扫码功能,以及已经做过但想优化扫码体验的开发者参考。
有人可能会问:鸿蒙不是有现成的扫码API吗?为什么还要自己搭相机预览?这里需要先明确一个概念——鸿蒙的扫码能力可以分成两类:一类是系统提供的完整扫码组件,集成快但界面和交互几乎锁死;另一类是自己通过相机框架拿预览流,配合解码引擎实现扫码。自定义扫一扫页面走的是后者,虽然代码量多了不少,但换来的是完全可控的UI和交互,同时扫码逻辑也能和业务深度绑定,比如扫码成功后直接拉起后续流程、把识别结果回传到指定页面等等。
1.2 方案对比与最终选型
在动手之前,我先梳理了市面上几种主流方案,简单排了个对比表:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 系统扫码组件直接调用 | 集成快,稳定性高 | 界面不可定制,交互受限 | 原型验证、内部工具 |
| 自定义相机预览 + 系统扫码服务 | 界面自由,识别稳定 | 需要自己做相机管理,部分能力受系统限制 | 大多数业务扫码需求 |
| 自定义相机预览 + 移植解码库(zxing等) | 完全控制,可深度调优 | 需要编译和适配,工作量大 | 需要特殊格式、特殊交互的场景 |
我最终选了第三套方案:XComponent绑定相机预览,通过ImageReceiver获取实时帧,帧数据传给解码引擎做识别。解码引擎用的是zxing的鸿蒙移植版,很多开源库已经打包成了har包,接起来并不算麻烦。
为什么不用系统扫码服务?因为自定义扫一扫页面最核心的需求是“页面完全可控”,系统扫码服务虽然能识别,但很多时候它的UI层、回调方式和项目现有的页面栈管理有冲突。举个例子,我们当时需要扫码成功后不退出扫码页,而是直接在页面上弹出自定义的业务确认弹窗,系统扫码服务在这类场景下处理起来就很别扭。自己做相机预览,本质上是把扫码能力的控制权全部拿回来,后续不管怎么改交互都不受底层限制。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 前置准备:权限与环境搭建
2.1 相机权限申请
到了具体的实现阶段,第一步就是处理相机权限。这个步骤看起来简单,却是整个项目里最容易埋坑的位置之一,特别是刚接触鸿蒙权限体系的开发者,很容易在动态申请和配置文件之间搞混。
在鸿蒙里,相机权限需要在module.json5中声明:
json复制{
"requestPermissions": [
{
"name": "ohos.permission.CAMERA",
"reason": "用于扫码时预览取景",
"usedScene": {
"abilities": [
"EntryAbility"
],
"when": "inuse"
}
}
]
}
reason字段在应用上架时是必填的,建议写清楚用途,否则审核阶段会被打回。usedScene里的when建议用inuse,只在页面使用期间申请相机权限,这样对用户更友好,隐私合规审查也更容易通过。
运行时动态申请的代码也要写完整。我一般封装一个权限工具类,避免每个页面重复写。关键代码大致是这样的:
typescript复制import abilityAccessCtrl from '@ohos.abilityAccessCtrl';
import { BusinessError } from '@ohos.base';
import common from '@ohos.app.ability.common';
export async function requestCameraPermission(context: common.UIAbilityContext): Promise<boolean> {
const atManager = abilityAccessCtrl.createAtManager();
try {
let result = await atManager.requestPermissionsFromUser(context, ['ohos.permission.CAMERA']);
let grantStatus = result.authResults[0];
return grantStatus === 0; // 0 表示授权成功
} catch (err) {
let error = err as BusinessError;
console.error(`requestCameraPermission error: ${error.code}, ${error.message}`);
return false;
}
}
这里有个容易忽略的细节:requestPermissionsFromUser是异步方法,必须在UIAbility的context下调用,如果你在非UIAbility环境里调,会直接报错。我在早期开发时就踩过这个坑——在某个工具类里直接传入了applicationContext,结果授权弹窗一直不出现。正确做法是页面或Ability里通过getContext(this)拿到UIAbilityContext再传进去。
申请之后还要处理用户拒绝的情况。开发阶段可能感受不强,但线上环境用户拒绝相机权限的概率其实不低。比较好的做法是:拒绝后不要直接退出页面,而是显示一个“相机权限未开启”的引导界面,提供跳转设置页的按钮,同时保留返回上一页的入口。用户在设置页打开权限回到应用后,页面要能自动恢复相机预览,这一点通过onPageShow生命周期里重新初始化相机就能实现。
2.2 XComponent的创建与配置
扫一扫页面的相机预览区,我用的载体是XComponent。XComponent在鸿蒙里承担的是原生纹理渲染和surface绑定职责,相机预览数据可以直接渲染到这个组件上,这是实现自定义扫码页的基础。
在ArkTS页面里创建XComponent有两种方式,一种是声明式写法,另一种是动态创建。推荐直接用声明式,代码简洁,生命周期也好管理:
typescript复制XComponent({
id: 'cameraPreview',
type: 'surface',
libraryname: ''
})
.onLoad((context) => {
// surfaceId 获取成功,可以开始初始化相机
let surfaceId = context.surfaceId;
initCamera(surfaceId);
})
.width('100%')
.height('100%')
这里有几个关键点:
一是type字段要传'surface',表示XComponent承载的是相机surface数据。另一种type是'texture',也能用于相机预览,但surface方式在性能和兼容性上更稳,我生产环境选的是surface。
二是onLoad回调触发时机。XComponent加载完成后才会回调onLoad,并且只有在这个回调里才能拿到有效的surfaceId。有些同学习惯在aboutToAppear里就去初始化相机,此时surface还没准备好,自然会报错。正确的时序是:先等XComponent onLoad,再初始化相机。
三是surfaceId是字符串类型,这个值要传给相机框架的PreviewOutput,告诉相机“你把数据渲染到这个surface上”。这里务必注意类型转换,有些版本接口接收的是string,有的接收的是number,建议在初始化前做一次显式转换,避免编译通过但运行时崩溃的情况。
XComponent的尺寸设置也值得提一下。扫码页的相机会全屏铺满,但XComponent内部surface的宽高要和相机输出分辨率的宽高比匹配,否则会出现画面拉伸或者裁剪。我在项目里直接用'100%'铺满屏幕,然后通过内容布局把扫码框叠在上面,视觉上扫码区域居中,画面比例实际会随设备不同有细微差别。如果你有强迫症,可以通过aspectRatio设置比例,或者动态计算surface尺寸去匹配相机输出的比例。
3. 核心实现:相机预览与帧获取
3.1 初始化相机并绑定XComponent
相机初始化这部分是整个扫一扫页面的技术核心,也是代码量最集中的地方。我把整个过程拆成几步来说,每一步都标注上容易出错的地方。
先引入必须的模块:
typescript复制import { camera } from '@kit.CameraKit';
import { image } from '@kit.ImageKit';
然后编写初始化相机的方法。这里需要特别说明,鸿蒙的相机API版本演进比较快,最新版本已经统一到了@kit.CameraKit这个kit包下,老版本用的@ohos.multimedia.camera虽然还能用,但考虑到新项目的维护性,建议直接用kit包。
初始化方法的关键流程:
typescript复制private async initCamera(surfaceId: string) {
try {
let cameraManager = camera.getCameraManager(this.getContext(this));
// 获取可用相机设备,优先选后置摄像头
let cameraDevices = cameraManager.getSupportedCameras();
let backCamera = cameraDevices.find((device: camera.CameraDevice) => {
return device.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK;
});
if (!backCamera) {
this.showToast('未检测到后置摄像头');
return;
}
// 创建相机输入
let cameraInput = cameraManager.createCameraInput(backCamera);
await cameraInput.open();
// 获取相机输出能力,创建预览输出
let capability = cameraManager.getSupportedOutputCapability(backCamera);
let previewProfile = capability.previewProfiles[0];
this.previewOutput = cameraManager.createPreviewOutput(previewProfile, surfaceId);
// 创建会话
this.cameraSession = cameraManager.createSession(camera.SceneMode.NORMAL_PHOTO);
this.cameraSession.beginConfig();
this.cameraSession.addInput(cameraInput);
this.cameraSession.addOutput(this.previewOutput);
await this.cameraSession.commitConfig();
await this.cameraSession.start();
// 开启连续自动对焦
this.cameraSession.setFocusMode(camera.FocusMode.FOCUS_MODE_CONTINUOUS_AUTO);
this.cameraSession.setFlashMode(camera.FlashMode.FLASH_MODE_OFF);
} catch (err) {
console.error(`initCamera error: ${JSON.stringify(err)}`);
}
}
这一段代码里面有一个特别容易被忽略的问题:previewProfiles数组取第一个profile,不一定是最合适的。不同设备的previewProfiles列表顺序可能不同,有些设备第一个profile分辨率很高,导致surface渲染卡顿。我实测下来的处理办法是遍历previewProfiles,挑一个宽度在1080附近的profile,既保证画面清晰度,又不会让解码帧处理压力过大。
另外,createSession的场景枚举不同API版本可能不一样,有些版本用camera.SceneMode.NORMAL_PHOTO,有些旧版直接用camera.CameraSession。如果你编译报错说找不到SceneMode,检查一下SDK版本和引用路径,大概率是kit包版本不对。
还有一点,如果你只创建了PreviewOutput而没有创建ImageReceiver去拿帧,那扫一扫页面只能看到画面,根本没有数据给解码引擎分析。所以下一步非常关键:注册ImageReceiver并接收实时帧。
3.2 实时帧获取与处理
实时帧获取的原理是:创建ImageReceiver,让它同时监听相机输出,相机的每一帧数据除了渲染到预览surface之外,也会回调到ImageReceiver的‘imageArrival’事件里。我们在回调里拿到image对象,转成像素数据后交给解码引擎。
创建ImageReceiver的代码长这样:
typescript复制private initImageReceiver() {
let receiver = image.createImageReceiver({
width: 1080,
height: 1920,
capCount: 4
});
this.imageReceiver = receiver;
receiver.on('imageArrival', () => {
receiver.readLatestImage((err, img) => {
if (err || !img) {
return;
}
this.processImage(img);
img.release();
});
});
}
如果按上面的流程写完,你会发现一个致命问题:ImageReceiver创建了,但它并没有和相机会话关联起来,根本不收帧。正确做法是在initCamera里创建会话后,把ImageReceiver的surfaceId作为另一个输出加进会话。
具体说,createImageReceiver之后,需要获取receiver.getReceivingSurfaceId(),然后在cameraSession的beginConfig和commitConfig之间,把这个surfaceId对应的ImageReceiver输出添加到会话中:
typescript复制let receiverSurfaceId = await this.imageReceiver.getReceivingSurfaceId();
this.imageReceiverOutput = cameraManager.createPreviewOutput(
capability.previewProfiles[0], // 这里可以复用预览的profile,也可以单独配置
receiverSurfaceId
);
this.cameraSession.addOutput(this.imageReceiverOutput);
这样相机的帧数据才会同时流向预览surface和ImageReceiver。
在processImage里,我做的事情是把image对象转成pixelMap,然后从pixelMap里读取RGBA数据,转成解码库需要的格式:
typescript复制private async processImage(img: image.Image) {
let pixelMap = await img.getComponent(image.ComponentType.JPEG);
// 或者调用 image.createPixelMap 转成 PixelMap
let buffer = pixelMap.byteBuffer;
// 转成 ArrayBuffer 传递给解码引擎
decodeFromBuffer(buffer, pixelMap.size.width, pixelMap.size.height);
}
这里有个性能问题:每次帧回调都做完整的分辨率读取和解码,开销非常大,尤其低端机上很可能导致预览卡顿甚至掉帧。后面第4节我会详细讲优化策略,这里先有个预期就好。
另外一定要记得:image读完要release,否则内存会持续上涨,几分钟后直接OOM。这个问题我第一版上线后就遇到过,用户使用扫码页面超过3分钟就闪退,排查半天发现是image.release()被遗漏了。
4. 扫码引擎接入与识别优化
4.1 解码库的移植与接入
扫码引擎的选型,我直接用了zxing的鸿蒙适配版本。鸿蒙生态里已经有不少开发者把zxing核心库用ArkTS或C++重写打包成了har,搜索关键字“zxing harmony”就能找到。
我用har包的方式接入,省去了自己编译C++的麻烦。接入步骤分三步:
第一步,在oh-package.json5里添加依赖:
json复制{
"dependencies": {
"@ohos/zxing": "^1.0.0"
}
}
第二步,在扫一扫页面里引入:
typescript复制import { MultiFormatReader, DecodeHintType, RGBLuminanceSource, BinaryBitmap, HybridBinarizer } from '@ohos/zxing';
第三步,封装解码方法:
typescript复制private decode(buffer: ArrayBuffer, width: number, height: number): string | null {
try {
let hints = new Map<DecodeHintType, Object>();
hints.set(DecodeHintType.POSSIBLE_FORMATS, [
BarcodeFormat.QR_CODE,
BarcodeFormat.CODE_128,
BarcodeFormat.EAN_13,
BarcodeFormat.EAN_8
]);
hints.set(DecodeHintType.TRY_HARDER, true);
let luminanceSource = new RGBLuminanceSource(buffer, width, height);
let bitmap = new BinaryBitmap(new HybridBinarizer(luminanceSource));
let reader = new MultiFormatReader();
reader.setHints(hints);
let result = reader.decode(bitmap);
return result.getText();
} catch (err) {
return null;
}
}
POSSIBLE_FORMATS这个hint要按业务需求配。如果只需要识别二维码,就只传QR_CODE,识别速度能快不少。因为格式集越少,解码器遍历的算法分支越少。我们当时业务上还需要扫一维码,所以保留了CODE_128、EAN_13、EAN_8这几个常见格式。
RGBLuminanceSource的buffer参数,必须是RGBA8888格式的数据,并且是连续的字节数组。前面从pixelMap里读出来的数据要确保格式是RGBA_8888,否则颜色通道错乱会导致识别率骤降。
这里有一个和旧版Android开发经验明显不同的地方:鸿蒙的pixelMap默认可能不是RGBA格式,我在集成时踩过一次。解决方法是创建pixelMap时显式指定PixelMapFormat.RGBA_8888,或者字节转换时手动调整通道顺序。
4.2 识别性能调优
扫码页最影响体验的就是识别速度。用户拿着二维码对准摄像头,半秒钟没反应就会觉得卡。我在做过几轮优化后,总结出了三个最有效的性能调优手段。
第一是帧率控制。imageArrival回调理论上是跟着相机帧率走的,可能是30fps甚至更高。如果每一帧都解码,低端机上预览都要卡没了。我的策略是做“定时取样”,每200毫秒最多解码一帧,代码思路是这样:
typescript复制private lastDecodeTime: number = 0;
private readonly DECODE_INTERVAL = 200;
private onFrameArrival(image: image.Image) {
let now = Date.now();
if (now - this.lastDecodeTime < this.DECODE_INTERVAL) {
return;
}
this.lastDecodeTime = now;
// 处理解码
}
千万别小看这个节流,它能把CPU占用直接降一个量级。而且实测下来,200毫秒的间隔不会漏掉正常扫码场景,因为用户拿着二维码对准相机时,画面在短时间内是相对稳定的。
第二是降采样。1080x1920的帧全量解码,内存和CPU都扛不住。我一般把输入解码的图像压缩到600x800左右再进入解码流程。有一个容易踩的坑是:降采样后宽高比变了,或者像素格式变了,解码库可能报错。稳妥做法是用Image的scale属性或者pixelMap的scale接口处理。
第三是设置解码区域。如果扫码框只占屏幕中间一块矩形区域,理论上解码时只需要分析这一块区域,但zxing接口本身不直接支持“只解码局部区域”,因为二维码可能跨出扫码框。我的做法是在解码前通过裁剪逻辑只裁剪扫码框对应区域的图像,这样既加快解码,也在一定程度上避免误扫到框外其他二维码。当然,裁剪区域要和界面上的扫码框位置保持一致,这个需要通过像素坐标换算,用组件在屏幕上的实际位置去裁剪。
经过这三轮优化后,识别响应时间基本能稳定在300毫秒以内,中低端设备也能流畅运行。
5. 自定义扫码界面与交互
5.1 界面布局与扫码框绘制
作为自定义扫一扫页面,界面布局是重头戏。我用的布局结构是Stack容器,一层放XComponent相机预览,一层放扫码框和操作按钮,层级关系简单清晰:
typescript复制Stack({ alignContent: Alignment.TopStart }) {
// 第一层:相机预览
XComponent({ id: 'cameraPreview', type: 'surface', libraryname: '' })
.onLoad((context) => { this.initCamera(context.surfaceId); })
.width('100%')
.height('100%')
// 第二层:扫一扫UI覆盖层
Column() {
// 顶部标题栏
Text('扫一扫')
.fontSize(18)
.fontColor(Color.White)
.margin({ top: 16 })
// 扫码区域
Stack() {
// 半透明遮罩 + 扫码框
Column()
.width(250)
.height(250)
.border({ width: 2, color: '#00C853' })
// 四角装饰线
}
.margin({ top: 80 })
// 底部操作区
Row() {
// 相册按钮
// 手电筒按钮
}
}
}
扫码框的样式,我是用border画边框加上四角装饰实现的。这里有个体验细节:扫码框四角做得比边框粗一点、亮一点,视觉引导效果会好很多。我用的是Row嵌套四个小矩形来模拟四角,每根线条宽度3px,长度20px,颜色用高亮的绿色。直接摆border虽然省事,但在深色背景上辨识度不够。
还有遮罩层。为了让用户视线聚焦到扫码框,通常要盖一层半透明黑色蒙层,中间扫码区域镂空。鸿蒙里实现镂空效果有几种办法,我用的是Canvas绘制,先画一个铺满屏幕的半透明黑色矩形,然后通过globalCompositeOperation的destination-out挖掉中间区域。当然也有更简单的做法——整体盖一层半透明黑色,再在扫码框位置放一个Normal的Column盖住,不过这样扫码框区域的画面会看起来比周围亮,实际效果和镂空类似,实现成本低很多,适合赶工期时用。
手电筒按钮的交互,需要调用相机闪光灯开关:
typescript复制private toggleFlash() {
if (!this.cameraSession) return;
let flashMode = this.isFlashOn ? camera.FlashMode.FLASH_MODE_OFF : camera.FlashMode.FLASH_MODE_ON;
this.cameraSession.setFlashMode(flashMode);
this.isFlashOn = !this.isFlashOn;
}
这里需要注意,setFlashMode是异步方法,建议await一下,并且在设置成功后根据结果去刷新按钮状态,避免出现UI状态和实际闪光灯状态不一致的问题。
5.2 相册识别与手电筒功能
“从相册选二维码”是自定义扫码页几乎必备的补充能力,因为有些二维码在纸质介质上磨损严重,或者显示在其他屏幕上时相机对焦困难,从相册选图能兜底。
相册选图我用的是PhotoAccessHelper,核心流程分三步:
第一步,拉起系统相册选择器:
typescript复制import { photoAccessHelper } from '@kit.MediaLibraryKit';
async function selectImageFromAlbum(context: common.UIAbilityContext): Promise<string | null> {
let phAccessHelper = photoAccessHelper.getPhotoAccessHelper(context);
let photoSelectOptions = new photoAccessHelper.PhotoSelectOptions();
photoSelectOptions.MIMEType = photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE;
photoSelectOptions.maxSelectNumber = 1;
let photoSelectResult = await phAccessHelper.select(photoSelectOptions);
let uri = photoSelectResult.photoUris[0];
return uri;
}
第二步,通过uri读取图片并转成pixelMap:
typescript复制let file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
let imageSource = image.createImageSource(file.fd);
let pixelMap = await imageSource.createPixelMap({
desiredPixelFormat: image.PixelMapFormat.RGBA_8888
});
第三步,把pixelMap转成RGBA字节数组喂给解码库。这里有一个兼容性细节:相册图片可能是超大分辨率(比如一张4800万像素的照片),直接全量解码会非常吃内存甚至OOM。建议先读取图片的宽高,再按比例缩放到最大边不超过1000像素,然后再去解码。
我封装了一个压缩读取pixelMap的方法:
typescript复制let imageInfo = await imageSource.getImageInfo();
let scale = Math.min(1, 1000 / Math.max(imageInfo.size.width, imageInfo.size.height));
let pixelMap = await imageSource.createPixelMap({
desiredPixelFormat: image.PixelMapFormat.RGBA_8888,
desiredSize: {
width: Math.floor(imageInfo.size.width * scale),
height: Math.floor(imageInfo.size.height * scale)
}
});
一个容易忽略的问题是,系统相册返回的uri可能是content://类型,直接传给fs.openSync会失败。我在第一版实现时就遇到这个报错,排查半天发现是uri scheme的问题。解决办法是先把uri通过photoAccessHelper的PhotoAsset转换成文件fd,或者尝试解析content uri的实际路径。网上有各种解析方法,但不同系统版本行为不太一样,稳妥做法是直接用photoAccessHelper提供的接口去获取资源。
手电筒功能除了在扫码页打开相机预览时用,还有一个特殊场景:有些用户会从相册选图识别,选完图返回扫码页时,如果相机预览被系统回收了,手电筒按钮应该自动置灰,否则用户点一下会发现没反应。我处理的方式是监听页面onShow,检查当前isCameraActive状态,如果相机未初始化,手电筒按钮disable并降低透明度。
6. 常见问题与排障实录
6.1 黑屏问题与生命周期管理
自定义扫码页第一大坑就是黑屏。相机初始化了、XComponent也绑定了,但预览区域一片黑。我在项目里遇到过几种情况,按出现频率排一下:
一是onLoad回调还没触发就初始化相机,或者surfaceId拿到的是无效值。排查方法是打印surfaceId,确认它非空且XComponent的onLoad确实在初始化之前触发了。
二是相机设备和会话生命周期没管理好。比如页面onDisappear时没有停止相机,再次onAppear时又开一个新的会话,新旧会话冲突导致预览黑屏。我的做法是写一个releaseCamera方法,在onDisappear里调用,把会话停止、输入关闭、输出释放,然后在下一次onAppear里重新初始化。如果你不想频繁开关相机,可以考虑复用相机会话,但要注意重新绑定surface,两者一定要配套。
三是XComponent尺寸为0。有些场景下XComponent在布局计算完成之前就被onLoad了,surface宽高是0,画面自然是黑的。症状是页面加载后黑屏几秒,然后突然恢复。我遇到这个问题后直接把初始化时机往后挪了半拍,用setTimeout 50毫秒延迟初始化,虽然有点土,但实测很稳。
6.2 识别率低与识别慢的排查思路
识别率低通常有几种原因:
第一种是图像模糊。相机对焦没开启,或者光线不足。解决方法是开启连续自动对焦,并且设置对焦模式为FOCUS_MODE_CONTINUOUS_AUTO。另外可以检测环境亮度,过暗时提示用户打开手电筒。
第二种是图像分辨率太高,扫码框区域在降采样后变得过小。你想想,如果一张照片缩到200x200,里面的二维码可能只占30x30像素,解码当然失败。解决方法是控制降采样的上限,尤其保证扫码框对应区域的像素宽度不低于200像素。我之前在扫码框区域是250x250dp的设备上,把解码分辨率压到400x600,结果严重区域在缩放后不到100像素宽,识别率很难看。后来改成以扫码框区域实际像素数为基准,动态计算缩放比例,问题就解决了。
第三种是数据格式不对。zxing的RGBLuminanceSource要求按RGB顺序排列的像素字节数组,如果鸿蒙pixelMap输出的是RGBA顺序,两者差异会导致图像颜色通道错位。表现是识别率极低,偶尔能识别出内容但不稳定。排查思路是单独写一个测试页,加载一张确定能识别的二维码图片,把pixelMap的数据dump出来,用Python脚本还原成图片检查颜色是否正常。这个方法很笨,但排查起来特别快。
识别慢的问题,除了第4节讲到的帧率控制和降采样外,还有一个思路是识别到结果后进行防抖。用户扫码成功后会继续拿着手机,导致后续帧持续识别成功,重复触发回调。我通常加一个识别冷却期,扫码成功后2秒内不做重复识别,等用户把手机移开再恢复。
6.3 权限、设备兼容与代码细节的注意事项
最后整理一些琐碎但影响很大的点。
一是相机权限在部分平板上可能没有物理后置摄像头。设备如果没有cameraPosition为BACK的摄像头,getSupportedCameras返回的数组里找不到后置设备。代码里一定要做兜底判断,如果没有后置就用前置,前置也没有就直接提示“当前设备不支持扫码”。
二是预览方向问题。鸿蒙设备默认相机预览可能是横屏方向的,扫码页竖屏显示时画面是倒的或旋转90度的。这个需要通过设置图像旋转角度解决。通常在创建PreviewOutput之前,可以查询cameraDevice的sensorOrientation,通过session.setRotation或者对XComponent做旋转矩阵来修正。这块有点绕,不同设备的sensorOrientation不一样,建议真机测试时多测几台设备。
三是内存泄露。ImageReceiver的imageArrival回调里如果处理时间过长,会导致图像堆积,内存迅速上涨。我的处理方式是用capCount控制缓冲数量,尽量用readLatestImage而不是readNextImage,确保每次只读最新一帧,丢弃旧帧。另外,在页面销毁时务必调用imageReceiver.release()和cameraSession.release(),否则相机资源一直被占着,下一次进入页面会初始失败。
四是har包版本兼容问题。鸿蒙SDK从API 11到API 12,再到后续版本,相机API和扫码库都有不少变化。如果你用的第三方扫码har包是基于老的API编译的,在新SDK工程里可能会编译不过。遇到这种问题先看错误日志,优先找适配当前API版本的包,而不是强行改代码适配旧包。
五是Page路由问题。扫码页如果是从一个页面跳转过来的,建议用Navigation或router.pushUrl进入,并在返回时处理好相机资源的释放。如果用户扫码成功后直接finish页面,相机资源会在releaseCamera里被回收,没问题。但如果你使用Navigation的栈管理,页面不是立即销毁,这时候要特别小心onHidden事件,离开扫码页但页面还在栈里驻留时,也要先释放相机,避免后台挂着一路相机消耗电量。
我再说一个比较隐蔽的问题:有些扫码har包在识别到结果后会创建全局的DL(DecoderLoop)线程池,如果每次打开扫码页都new一个解码器对象而不销毁,线程池会越攒越多。我的做法是把解码器做成单例,只在应用启动时创建一次,扫码页打开时复用,这样既省了CPU还避免了线程泄漏。
总体来说,自定义扫一扫页面在鸿蒙上的实现思路并不复杂,核心就是XComponent绑定相机预览、ImageReceiver拿帧、解码引擎识别这三板斧。真正考验人的是那些细节:权限流程、生命周期管理、性能调优、设备兼容、内存释放。你照着文章里的步骤走一遍,大概率能跑通一个基础版本。要是遇到和我不一样的坑,别急,先看日志,再拆变量,很多时候问题都出在“以为自己传对了其实传错了”的接口参数上。做扫码功能,耐心比技术重要。
