做鸿蒙扫一扫,很多人第一反应是:调系统相机,扫完拿到结果,结束。可真把业务接入进去,你会发现这条路基本走不通。业务方要的是扫码框嵌在自己的页面里、识别结果实时回调、左上角放返回键、底部放相册入口和手电筒开关,这种需求下,只能自己动手实现一个鸿蒙自定义扫一扫页面。这篇文章我会用 ArkTS + ArkUI,从相机预览、区域识别、相册解码到问题排查,完整走一遍实现流程,顺便把那些文档里不会写的坑也一起填上。
这个方案适合谁?一是鸿蒙应用开发中需要接入扫码能力的工程,尤其是扫码页需要定制 UI 的项目;二是从 uni-app、微信小程序或者其他平台转鸿蒙、想搞懂“鸿蒙相机到底怎么玩”的开发者;三是准备鸿蒙面试、被问到“扫码页怎么实现”这类场景题的候选人。别小看一个扫码页,它把权限申请、生命周期管理、相机调用、坐标系换算、多线程处理、组件通信全串起来了,搞懂它,等于把鸿蒙开发的中高频知识点过了一遍。
1. 为什么不用现成扫码,偏要自己做
1.1 现成方案的两难
鸿蒙早期的版本并没有内置一个“随便调一下就能用”的原生扫码组件,大家用的方案基本分成两类:一类是华为提供的 Scan Kit 扫码服务,另一类是社区移植的开源解码库。
Scan Kit 的识别能力确实很强,官方文档也提供了 SystemScan、SimpleScan 这类快速接入接口,扫个码、拿个结果基本不需要动脑子。但问题也很明显:SystemScan 出来的是系统固定页面,扫码框样式、标题栏、返回按钮、底部按钮全都动不了。你要是业务方突然说“扫码框改成圆角、加个品牌 logo、底部加个推广位”,SystemScan 基本就废了。就算用 CustomizedScan 模式,页面的自定义程度也有限,而且依赖 HMS Core 扫码服务的可用性,在某些设备或特殊 ROM 环境下,还需要处理服务不可用的兜底逻辑,工程量并不小。
另一类走开源解码库路线,比如鸿蒙社区里有人移植的 ZXing 版本。这类库的好处是代码可控、宿主依赖少,但坑也更隐蔽:有的移植版本只做了图片解码,不支持相机帧实时解码;有的连相机预览都要自己接,等于让你从零搭一套扫码链路。更麻烦的是,很多移植版无人维护,遇到编译问题只能自己改源码。说实话,如果你有足够的时间和技术储备,后者折腾明白了收益很高;如果只是急着上线,前者更稳妥。两条路都不存在"完美省事"的选项。
1.2 自定义扫一扫到底“自定义”了什么
先把概念捋清楚:你在扫码页里看到的那个取景框,并不是系统相机界面的一部分,它只是你页面上画出来的一个 View。相机一直在后台采集画面,你只是拿一块画布去展示它,再在画布上叠加 UI 元素。所以“自定义扫一扫”的核心,其实是自己掌握这四块东西:
第一块是扫码页面布局。返回键放哪、标题显示什么、相册入口在什么位置、手电筒开关怎么排,全部由你的 ArkUI 代码决定,这是自定义页面的基本盘。
第二块是识别区域。不是每一帧画面都需要参与识别,通常只取扫码框框住的那块区域去解码。这既是业务语义需要——用户默认“框哪里扫哪里”——也是性能需要,识别区域越小,解码耗时越短。
第三块是识别策略。识别成功后要不要震动、要不要响一声提示音、多久允许扫下一个条码、同一个二维码能不能连扫,这些都靠你自己控制,而不是框架替你做决定。
第四块是结果回传。扫码页是用 Navigation 路由压栈进来的,识别成功后怎么把结果带回上一页,是直接调用回调函数还是通过路由参数返回,需要提前设计好。
搞清楚这些,你就明白为什么“自定义扫一扫”不是个可有可无的伪需求,而是很多真实业务场景里的硬性要求。它本质上是一个“可复用的扫码组件”,而不是一个“调完就完的 API”。
1.3 实现路线选择:Scan Kit 还是开源方案
结合我现在实际项目的经验,选型建议看两个维度:UI 定制深度和团队技术储备。
如果业务页面对扫码 UI 基本没有要求,能用默认样式就行,团队节奏又紧,选 Scan Kit 的 CustomizedScan 模式最合适。你已经能自定义扫码页背景、扫码框颜色和部分控件,识别和解码完全托管,省掉一大堆相机适配工作。但也有前提:目标设备上 HMS Core 可用,某些卸载了 HMS 或者限制后台服务的设备会出幺蛾子。
如果团队有精力维护一个自研扫码组件,或者项目对扫码 UI 自由度的要求实在太高,那就走底层方案:自己调相机预览,自己接解码库。解码库优先考虑 ZXing 的鸿蒙适配版,或者 Java 侧用 ZXing 核心包通过 NAPI 封装到鸿蒙侧。不要想着自己写解码算法,没有个把月磨不出来,直接用成熟库。
我自己的偏好是:做技术验证、写 Demo、或者做长期维护的基础组件库,走自研路线,因为可控性最强,也不会有外部服务突然挂掉的风险;做短期交付项目,走 Scan Kit。两者不冲突,工程里甚至可以做个抽象层,底层实现可切换。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 权限声明与动态申请
在鸿蒙里用相机,第一步不是写代码,是配权限。module.json5 里必须声明相机权限,否则运行时会直接报错甚至闪退。
在 module.json5 的 module 节点下添加:
json复制{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.CAMERA",
"reason": "用于扫描二维码和条形码",
"usedScene": {
"abilities": [
"MainAbility"
]
}
}
]
}
}
reason 字段是权限用途描述,应用市场审核时会看,别乱填。usedScene 声明了权限在哪个 Ability 里使用,尽量写准确。
光声明还不够。相机权限在鸿蒙里属于 user_grant 类型,也就是需要运行时动态申请。用户在设置页关闭授权后,应用不能直接调用相机 API,必须先请求授权。
动态申请的核心代码:
typescript复制import abilityAccessCtrl, { Permissions } from '@ohos.abilityAccessCtrl';
import common from '@ohos.app.ability.common';
async function requestCameraPermission(context: common.UIAbilityContext): Promise<boolean> {
let atManager = abilityAccessCtrl.createAtManager();
let permissions: Array<Permissions> = ['ohos.permission.CAMERA'];
let result = await atManager.requestPermissionsFromUser(context, permissions);
let grantStatus = result.authResults[0];
return grantStatus === 0; // 0 表示授予
}
这里有个经验:不要在页面刚创建、UI 还没渲染完的时候立刻弹权限框,用户体验很差,也容易触发系统“过度申请”的审核提醒。我一般在用户点击“开始扫码”或者进入扫码页的 onPageShow 里再申请。如果用户拒绝授权,页面要引导用户去设置页打开权限,而不是直接白屏。
2.2 生命周期管理:相机资源不是随手开关的事
相机是个独占性很强的硬件资源。你打开相机之后不放,别的应用就用不了;你页面退出了但相机没释放,应用就可能黑屏或者崩溃。我见过不少新手在开发调试时,扫码页进去一次出来一次,第二次进去就黑屏,基本都是生命周期没处理好。
鸿蒙扫码页常用的生命周期节点有这么几个:
第一个是 onPageShow。页面每次显示时检查权限,权限通过后初始化相机管理器、创建预览流并启动。注意 onPageShow 在页面首次进入和从其他页面返回时都会触发,得做好重复初始化的判断,避免相机被初始化两次。
第二个是 onPageHide。页面被覆盖、跳转到其他页面或者退到后台时,要释放相机资源,否则相机一直被占着。这里尤其注意:如果你调用了一个系统相册选择图片,扫码页陷入 onPageHide,选完图回来 onPageShow 会再次触发,相机要能重新拉起来。
第三个是 aboutToDisappear。页面彻底销毁时,一定要释放所有相机相关资源,包括关闭预览输出、释放相机输入、释放相机管理器,避免内存泄漏。
我习惯把相机初始化和释放封装成一个类,比如叫 CameraController,在 onPageShow 里调 start(),onPageHide 里调 stop(),aboutToDisappear 里调 destroy()。这样页面代码干净,也不容易漏掉某个生命周期。
2.3 坐标系换算:扫码框和识别区域的对齐
这是自定义扫码页最核心、最容易被忽视的技术点,也是很多扫码框“看着在中间、扫出来却是歪的”的根因。
先想一个问题:相机传感器输出一帧画面,比如 1920x1080,这帧画面上每个像素都有固定坐标。你的扫码框画在 UI 上,比如宽 250vp、高 250vp,位于页面中间。当你想“只识别扫码框内的内容”时,必须把 UI 上的扫码框坐标换算成相机图像帧里的像素区域,然后再把这块区域交给解码器。换算公式不复杂:
code复制scaleX = imageWidth / componentWidth
scaleY = imageHeight / componentHeight
region.X = scanFrame.X * scaleX
region.Y = scanFrame.Y * scaleY
region.Width = scanFrame.Width * scaleX
region.Height = scanFrame.Height * scaleY
componentWidth 和 componentHeight 是相机预览组件 XComponent 的实际宽高,scanFrame 是扫码框在 XComponent 上的坐标。如果预览组件全屏展示、扫码框居中,那扫二维码可能问题不大,因为二维码一般不会紧贴着框边缘,有点偏差也能扫上。但一旦扫码框被业务方改到靠左、靠右、或者需要识别多个区域,坐标算不准,识别率立刻崩。
真正的坑还在后头:相机传感器的图像方向和 UI 的坐标方向并不是天然一致的。手机竖屏拿在手里,传感器输出的图像可能是横向的,需要对画面做旋转。如果你直接把 UI 坐标用比例算完就传给解码器,切到竖屏的时候,扫码框对应的图像区域会旋转 90 度甚至更多,结果就是框住了 A 区域,实际识别的是 B 区域。
解决办法有两个方向。一是把所有坐标换算建立在“旋转后的图像”上:把相机帧先旋转到和 UI 一致的方向再裁剪,这样逻辑最直观但性能开销大。二是用 Camera API 提供的方向信息把扫码框坐标映射到传感器坐标系,性能好但代码复杂。我的建议是:第一版先做方案一,把整帧数据旋转后裁剪识别区域,功能稳定后再考虑优化性能,毕竟扫码功能正确性排在第一位。
实操里还有个辅助手段:在页面上做一个调试开关,把当前识别区域的预览画出来,用半透明色块标出实际识别范围,对比扫码框位置,一眼就能看出偏了多少。这个开关上线前记得关掉,别闹出测试版扫码页里出现个五颜六色方块的笑话。
2.4 相机参数与性能配置的经验值
扫码场景的相机参数和拍照不同,不能直接抄拍照的配置。给你几个我实测下来比较稳的经验值:
预览分辨率选 1920x1080 或者 1280x720 就好,不要选最大分辨率。扫码识别并不需要 4K 级别的细节,分辨率越大,每帧处理耗时越长,识别越慢。对焦模式用连续自动对焦,也就是 FOCUS_MODE_CONTINUOUS_AUTO,这样手机靠近条码时画面能自动对焦清晰,不会出现“怎么扫都扫不出来”的尴尬。
处理解码时,绝对不要在 UI 主线程里做图像转换和解码操作。一帧 1920x1080 的图像转灰度图就要几毫秒,解码时间长的时候能到上百毫秒,主线程一卡,扫码页直接掉帧,体验全毁。解码任务要放到 TaskPool 或者 Worker 里执行,解码完成后切回主线程更新 UI,这个没得商量。
还有一点经验:识别区域不要设成整个预览画面,最好只取扫码框附近略微扩大一点的区域。这能显著减少解码耗时,也能降低误识别率。比如扫码框是 250x250,识别区域可以设为从扫码框左上角向外扩 20vp、宽高各加 40vp,相当于给扫码框加了点余量,用户稍微没对准也能识别出来。
3. 实操过程与核心环节实现
3.1 页面结构:先搭一个能看的扫码 UI
自定义扫码页面的核心视觉就三块:顶部返回栏、中间扫码框、底部操作区。用 ArkUI 的 Stack 做层叠布局,把相机预览放最底层,扫码框等 UI 元素覆盖在上面。
基础页面结构长这样:
typescript复制@Entry
@Component
struct ScanPage {
@State scanFrame: ScanFrame = { x: 60, y: 260, width: 250, height: 250 }
private xComponentController: XComponentController = new XComponentController()
private cameraController: CameraController = new CameraController()
build() {
Stack({ alignContent: Alignment.TopStart }) {
// 相机预览
XComponent({
id: 'cameraPreview',
type: 'surface',
controller: this.xComponentController
})
.width('100%')
.height('100%')
.onLoad(() => {
this.cameraController.init(this.xComponentController)
})
// 扫码引导线
Stack() {
Column()
.width(1)
.height(this.scanFrame.height)
.backgroundColor('#00FF00')
}
.position({ x: this.scanFrame.x + this.scanFrame.width / 2, y: this.scanFrame.y })
// 遮罩
ScanMask({ scanFrame: this.scanFrame })
// 顶部栏
Row() {
Text('←')
.fontSize(24)
.onClick(() => {
this.cameraController.release()
router.back()
})
Text('扫一扫')
.fontSize(18)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.height(48)
.padding({ left: 16, right: 16 })
.backgroundColor(Color.Transparent)
// 底部操作区
Row() {
Text('相册')
.onClick(() => this.chooseFromAlbum())
Text('手电筒')
.onClick(() => this.toggleTorch())
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)
.position({ y: '80%' })
}
.width('100%')
.height('100%')
.backgroundColor(Color.Black)
}
}
XComponent 的 type 一定要是 surface,这是 SDK 层面要求的一种承载相机预览画面的视图类型。别用 Texture,相机预览的接法不一样。onLoad 是 XComponent 加载完成的回调,只有在这个回调里才能拿到 surfaceId,才能把 surfaceId 传给相机 API 创建预览输出。
ScanFrame 是我自定义的一个数据结构,用来存扫码框的位置和尺寸。为什么要单独定义这个结构?因为布局要读它的值,坐标换算也要读它的值,识别区域的设置还要读它的值,一处定义,多处复用,避免 UI 上显示一个尺寸、识别区域却是另一个尺寸的低级错误。
3.2 相机预览接入:从 CameraManager 到 PreviewOutput
相机初始化的核心逻辑分四步:创建 CameraManager,拿到后置摄像头,创建预览输出,把 XComponent 的 surfaceId 传进去启动预览。
核心代码:
typescript复制import camera from '@ohos.multimedia.camera';
import { BusinessError } from '@ohos.base';
export class CameraController {
private cameraManager: camera.CameraManager | null = null
private cameraDevice: camera.CameraDevice | null = null
private previewOutput: camera.PreviewOutput | null = null
private cameraInput: camera.CameraInput | null = null
async init(xComponentController: XComponentController) {
let context = getContext(this) as common.UIAbilityContext
this.cameraManager = camera.getCameraManager(context)
// 1. 获取后置摄像头
let cameras = this.cameraManager.getSupportedCameras()
for (let i = 0; i < cameras.length; i++) {
if (cameras[i].cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK) {
this.cameraDevice = cameras[i]
break
}
}
if (!this.cameraDevice) {
return
}
// 2. 创建相机输入
this.cameraInput = this.cameraManager.createCameraInput(this.cameraDevice)
await this.cameraInput.open()
// 3. 创建预览输出
let capability = this.cameraManager.getSupportedOutputCapability(
this.cameraDevice,
camera.SceneMode.NORMAL_VIDEO
)
// 选一个合适的预览尺寸
let previewProfile = capability.previewProfiles.find((profile) => {
return profile.size.width === 1920 && profile.size.height === 1080
})
this.previewOutput = this.cameraManager.createPreviewOutput(previewProfile, surfaceId)
// 4. 配置会话并启动
let session = this.cameraManager.createSession(camera.SceneMode.NORMAL_VIDEO) as camera.VideoSession
session.beginConfig()
session.addInput(this.cameraInput)
session.addOutput(this.previewOutput)
await session.commitConfig()
await session.start()
}
async release() {
await this.previewOutput?.release()
await this.cameraInput?.close()
this.cameraManager = null
}
}
几个细节我单独说。
SceneMode 选 NORMAL_VIDEO 还是 NORMAL_PHOTO,会影响你能拿到的 previewProfiles 列表。扫码场景一般用 NORMAL_VIDEO 更合适,因为它更偏向视频流的连续采集,相机参数也更符合扫码需求。优先找 1920x1080,有些设备不提供这个档位,那再退而求其次选 1280x720。有一个问题是 capability 里可能同时存在多个 1920x1080 的 profile,它们的 size 相同但 frameRateRange 不同,建议选帧率上限高一点的那档,扫码识别更跟手。
还有一个隐藏坑:XComponent 的 surfaceId 必须在创建 PreviewOutput 之前拿到。XComponent 的 onLoad 回调一般发生得比相机初始化早,但异步顺序不可控,所以最好在 onLoad 里把 surfaceId 存下来,再调用 CameraController 的 init 方法。如果你发现偶尔扫码页黑屏,大概率是 onLoad 的回调和相机初始化的时序打架了。
3.3 识别流程与结果回传:把画面转成解码器能认的样子
相机预览跑起来之后,识别逻辑就在每一帧画面上跑。鸿蒙相机 API 本身不直接给你 YUV 数据,但 ImageReceiver 可以在相机会话里作为一个图像输出,不断产出图像,再从图像里拿 PixelMap 做识别。
如果不想引入额外的相机图像接收流程,也可以用 PhotoOutput 的 capture 方法拍一张当前画面去识别,但这种方式是“拍照式扫码”,体验不够连续,扫码时需要用户手动对准且等待拍照结果,不建议做主流程。
标准做法是创建一个 ImageReceiver,把它的 surfaceId 作为 ImageReceiver 输出添加到相机会话中,这样每一帧都能回调到你这里。拿到 PixelMap 之后,裁剪出扫码框对应的区域,再交给解码库处理。
解码库的选择上,我推荐先用 ZXing 的鸿蒙适配版。裁剪和缩放可以自己在 ArkTS 侧做:
typescript复制import image from '@ohos.multimedia.image';
async function cropPixelMap(pixelMap: image.PixelMap, region: ScanImageRegion): Promise<image.PixelMap> {
let cropRect: image.ImageRegion = {
x: region.x,
y: region.y,
size: {
width: region.width,
height: region.height
}
}
return await pixelMap.cropSync(cropRect)
}
需要注意的是,PixelMap 裁剪的坐标系是图像像素坐标系,不是 UI 坐标系,所以这里传进来的 x、y、width、height 一定是通过前面换算得到的图像坐标,而不是直接用 UI 的 vp 值。
裁剪完成后,把 PixelMap 转成解码库需要的格式。ZXing 的核心解码输入是亮度灰度数据,如果直接传 RGB 位图,库内部会再做一次转换,性能损耗不小。理想路径是直接拿到 YUV 数据丢给 ZXing,鸿蒙 ImageReceiver 拿到的就是 YUV 格式的 nativeWindow,再用解码库处理会快很多。具体 YUV 转码的代码和图像格式版本强相关,这里不展开,但记住一个原则:尽量别把图像转成 RGB 再转灰度,能省一次就省一次。
识别成功后的结果回传,我建议用回调函数,这样扫码页组件可以被复用到更多场景。扫码页定义一个回调属性:
typescript复制export interface ScanCallback {
onSuccess(result: string): void
onError(error: string): void
}
页面在识别到条码后调用 onSuccess 并带上结果,同时触发一次震动反馈,然后通过 Navigation 的返回逻辑把结果传给上一个页面。用 router.back 的话,可以通过全局数据结构或者静态变量传递结果,但这种方式在组件化程度高的项目里不太优雅。
3.4 相册入口与手电筒:两个绕不开的延伸功能
相册识别是扫码页的标配功能。用户在光线差、二维码在屏幕上的场景没法直接扫,只能从相册里选图识别。
鸿蒙里选相册图片最简单的方式是用 PhotoViewPicker:
typescript复制import photoAccessHelper from '@ohos.file.photoAccessHelper';
async function pickImageFromAlbum(): Promise<image.PixelMap | null> {
let picker = new photoAccessHelper.PhotoViewPicker()
let result = await picker.select({
MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE,
maxSelectNumber: 1
})
if (result.photoUris.length === 0) {
return null
}
let uri = result.photoUris[0]
let file = await fileIo.open(uri, fileIo.OpenMode.READ_ONLY)
let imageSource = image.createImageSource(file.fd)
let pixelMap = await imageSource.createPixelMap()
return pixelMap
}
拿到 PixelMap 后直接丢给解码器,识别逻辑和相机帧识别完全复用。我做过测试,相册图识别的成功率通常比相机实时识别更高,因为图片质量更稳定。
手电筒的实现相对简单,鸿蒙相机 API 里可以控制 Torch 模式:
typescript复制import camera from '@ohos.multimedia.camera';
async function toggleTorch(cameraManager: camera.CameraManager): Promise<boolean> {
let isTorchOn = cameraManager.isTorchSupported()
if (!isTorchOn) {
return false
}
let isTorchActive = cameraManager.isTorchModeSupported(camera.TorchMode.TORCH_MODE_ON)
if (isTorchActive) {
await cameraManager.setTorchMode(camera.TorchMode.TORCH_MODE_ON)
} else {
await cameraManager.setTorchMode(camera.TorchMode.TORCH_MODE_OFF)
}
return true
}
注意,扫码页在相机未初始化的时候,手电筒按钮要点亮基本是不可能的,所以按钮的可用状态要跟着相机初始化状态走。页面退后台的时候要主动关闭手电筒,否则会出现“离开扫码页但闪光灯还亮着”的诡异现象,我在正式环境里被用户投诉过这个问题。
3.5 代码分层与复用:把扫码做成一个真组件
扫码页一旦做完,最好能沉淀成可复用的基础组件,别让它只活在业务页面里。我的做法是这样的:
底层是 CameraController,封装相机初始化、释放、手电筒控制、对焦模式设置,不感知业务 UI。中间是 ScanDecoder,负责把 PixelMap 裁剪、缩放、转灰度,再调解码库,通过回调抛结果。上层是 ScanPage 组件,组合使用 CameraController 和 ScanDecoder,同时负责布局和交互。业务页面只需要调用这个组件,传入一个 onScanResult 回调,扫码结果自动回流。
这一个分层的好处是:如果哪天你决定从 ZXing 换成 Scan Kit,只需要替换中间层的实现,页面代码一行不动。如果将来要支持多码同时识别、定向识别某个码制,也只需要扩展解码层。
4. 常见问题与排查技巧实录
4.1 高频问题速查表
先把我在开发和维护中遇到的高频问题整理成速查表,遇到问题直接对着查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 扫码页黑屏 | 相机权限未授予;相机初始化时序错乱 | 在 onPageShow 里申请权限并校验;确认 XComponent onLoad 后再初始化相机 |
| 画面拉伸、变形 | 预览尺寸和 XComponent 宽高比不一致 | 选择匹配的预览分辨率,或在 XComponent 外层用 aspectRatio 控制显示比例 |
| 扫码框和实际识别区域偏移 | 坐标换算未考虑旋转;换算基准错误 | 用调试色块画出当前识别区域,与扫码框对比校准 |
| 识别速度很慢 | 识别区域过大;解码在主线程执行;图像格式转换损耗大 | 缩小识别区域;用 TaskPool 解码;直接用 YUV 灰度数据 |
| 二维码在框内却扫不出来 | 对焦没对上;识别区域过小 | 开启连续自动对焦;识别区域比扫码框外扩 20vp |
| 手电筒开关失败 | 相机未初始化;设备不支持 Torch | 初始化相机后再操作手电筒;先检查 isTorchSupported |
| 从相册选图回来相机黑屏 | onPageHide 释放了相机,onPageShow 没有重新初始化 | 生命周期里做好配对处理,回到页面时重建预览流 |
| 同一个码连续触发 | 缺少防抖逻辑 | 识别成功后加 1.5 秒冷却时间,阻断重复回调 |
| 模拟器上相机不可用 | 模拟器对相机支持有限 | 扫码功能用真机调试,模拟器只看 UI 布局 |
4.2 一次真实的扫码框偏移排查
讲一次我自己的排错经历,印象特别深。
当时扫码页在测试机上表现正常,可一到某款机型上,用户反馈“把二维码放在框里扫不出来,往下移一点反而能扫上”。第一反应是识别区域和扫码框没对齐。我打开调试开关,把当前识别区域用半透明色块画出来,果然,识别区域在扫码框的右上方,差了大概一个身位。
当时第一版代码用的是最简单的比例换算,没有做方向处理。在这款机型上,相机传感器输出的图像方向和 UI 方向不一致,传感器是横向的,UI 是竖向的,换算时忽略了旋转,所以识别区域要么往下偏、要么往右偏。
排错过程中我把预览图的原始方向和扫码框位置打日志对比,发现这款机型的传感器旋转角度是 90 度,其他测试机是 0 度或 180 度,验证了方向问题。修复方式是在换算坐标前先获取 CameraDevice 的 orientation 信息,按照旋转角度做坐标变换。修复后在多台机器上验证,扫码框对齐都正常了。
这个过程中我的体会是:扫码框偏移这个 bug 很有迷惑性,因为二维码本身容忍一定偏移,轻微的偏移不会立刻暴露,一旦某个机型的旋转角度不同,问题才被放大。遇到这种问题,别猜,先画识别区域,再打印传感器方向,定位会快得多。
4.3 兼容性与性能调优建议
不同机型的相机能力差异很大。有的设备支持 4K 预览但处理性能差,有的设备 1080p 预览就已经是上限。我建议预览分辨率做成可配置项,默认 1080p,低端机上自动降级到 720p。判断依据可以直接读 CPU 核心数或者设备型号白名单,也可以用运行时性能采样来动态调整,但第一版没必要想得太复杂,固定档位够用。
模拟器的问题也值得提醒。DevEco Studio 自带的模拟器对相机支持非常有限,有的模拟器根本没有虚拟摄像头,有的虽然有但画面是固定的测试图案。在模拟器上你可以验证 UI 布局、权限弹窗逻辑,但扫码识别必须上真机测。
低端机上的性能调优,我最常用三板斧:识别区域缩到最小可接受范围,解码前把图像缩放到解码器期望的最适尺寸,图像格式尽量直接用灰度数据而不是 RGB。这三步做完,识别耗时可压缩 50% 以上。
还有一个容易忽略的点:扫码页的网络请求。很多扫码业务拿到结果后要立刻查库或者发请求,如果这个请求很重,页面会卡在结果页不动。我建议扫码成功先行震动并显示结果,再异步发起网络请求,这样体感上会“快”不少。
最后补充一个优化小技巧:二维码识别成功后不要立刻关闭扫码页,先做一个“识别成功”的视觉反馈,比如闪一下绿框或者震动,停顿 300 到 500 毫秒再返回结果。这半年我调过不少扫码体验问题,用户对“有没有扫到”这件事极其敏感,反馈动画比结果文字更让他们安心。
如果你打算把这个扫码页长期维护下去,建议把 ScanDecoder 的接口设计成可选自定义解码器。默认用 ZXing,后续如果 Scan Kit 免费版额度不够或者识别率有瓶颈,你可以随时替换实现,而不必动页面代码。这个设计花不了多少时间,但会帮你省掉后面无数重构的麻烦。
