团队第一次在鸿蒙上做扫码需求时,我图省事直接用了系统扫码组件,前后不到半天就把功能跑通了。可等到产品经理把新版设计稿甩过来,我就知道完了:扫码框位置要逐像素对齐、识别到不同码要触发不同业务跳转、页面切后台再回来不能黑屏、还得支持相册选图识别。这些需求堆在一起,系统组件根本顶不住,只能从相机预览开始,自己写一个自定义扫一扫页面。这篇文章就把我在鸿蒙上实现自定义扫一扫页面的完整思路、关键代码和踩坑记录整理出来,适合已经能写基础鸿蒙应用、但还没碰过相机和扫码链路的开发者。
1. 先搞清楚系统扫码组件缺什么:自定义扫一扫的动机
1.1 产品提的几个需求,系统组件答不上来
鸿蒙自带的扫码能力确实简单,直接调起系统扫码页,用户扫完把原始值返回来,对“只需要扫个码拿到内容”的场景来说接入成本几乎为零。但产品需求一旦深入,系统组件就只能尴尬退场:
- 扫码框位置、颜色、动画全部写死,产品要的是设计稿里的圆角扫码框和自定义激光线,系统组件给不了;
- 识别结果需要区分“首次扫码、连续扫码、重复码”并携带扩展参数,系统组件只给一个字符串,扩展性太差;
- 页面要做埋点统计,比如扫码成功数、失败原因、单次识别耗时,系统组件内部是个黑盒,数据根本拿不到;
- 页面切后台再回前台需要恢复相机预览,系统组件偶尔会出现黑屏或者重新拉起页面的情况,很影响体验。
我实际遇到最要命的是第四条。用户扫完码之后跳转去填写信息,切到微信回个消息再切回来,发现扫码页黑屏了,只能杀掉重进。这类问题在系统组件层面几乎没法修,因为你控制不了它的预览状态和生命周期。所以结论很明确:要么在系统组件外面打补丁,要么直接下沉到相机层,自己控制预览和识别。想做得稳定,就得选后者。
1.2 技术选型:三条路里面我们选了哪条
鸿蒙上实现自定义扫码,无非三条路:
| 方案 | 定制能力 | 集成成本 | 识别能力 | 适用场景 |
|---|---|---|---|---|
| 系统扫码组件 | 低 | 最低 | 强 | 只扫个码,不做深度定制 |
| Camera Kit 自建预览 + Scan Kit 做识别 | 高 | 中等 | 强 | 自定义扫码页的主流选择 |
| Camera Kit 自建预览 + zxing/ML Kit 移植 | 高 | 高 | 中等 | 需要完全脱离系统识别能力 |
我们最终选了第二种。Scan Kit 毕竟是系统级能力,对畸变、模糊、暗光下的处理比自研算法稳定;多码识别、二维码、条形码都支持,省去了一大堆图像处理工作量。Camera Kit 负责相机预览,Scan Kit 负责把帧数据变成结果,两者拆开之后,UI 层完全可以自绘。这套组合的定制空间接近无限。
如果团队里有人坚持移植 zxing,我的建议是先掂量一下工作量。zxing 在鸿蒙上要么用 C++ 做 NAPI 封装,要么用 ArkTS 重写核心解码逻辑,光是把 Android 那套依赖改造完就够喝一壶的。除非 Scan Kit 满足不了业务,否则别走这条路。
1.3 页面整体架构:数据怎么流转
自定义扫码页的核心链路可以拆成四段:UI 层、相机层、识别层、业务层。
UI 层就是 ArkUI 的页面,里面是一个 Stack:底层放 XComponent 承载相机预览,上层叠加遮罩、扫码框、激光线、手电筒按钮和相册入口。相机层由 Camera Kit 管理,负责初始化会话、绑定预览surface、输出视频帧。识别层用 ImageReceiver 接收相机帧,转成 PixelMap 后交给 Scan Kit 解码,拿到结果再回调给业务层。
数据流是单向的:预览画面实时刷新,但只有识别层触达后又需要UI更新时,才会通过状态变量通知UI层。我见过不少同学把识别结果直接塞进全局对象里,页面切走再回来会出现旧结果残留,这个问题下文踩坑部分会细说。架构上保持单向数据流,后面排查问题会轻松很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 相机预览接入:权限、生命周期和画幅适配
2.1 相机权限申请,别只在配置文件里写一下
鸿蒙应用要使用相机,必须在 module.json5 里声明权限,这是第一步:
json复制{
"requestPermissions": [
{
"name": "ohos.permission.CAMERA",
"reason": "$string:camera_permission_reason",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
reason 是向用户解释为什么需要相机权限的文案,不能省略。usedScene 里的 when 字段,前台使用场景写成 inuse,如果应用某些版本需要在后台继续识别,才需要仔细评估时机。大多数扫码场景都是前台使用,inuse 就够。
动态授权也要做。很多开发者以为写了配置就能直接调相机,结果在真机上直接崩溃。正确做法是在进入扫码页前,通过 abilityAccessCtrl 动态申请:
typescript复制import { abilityAccessCtrl, Permissions, common } from '@kit.AbilityKit';
async function requestCameraPermission(context: common.UIAbilityContext): Promise<boolean> {
const atManager = abilityAccessCtrl.createAtManager();
const result = await atManager.requestPermissionsFromUser(context, ['ohos.permission.CAMERA']);
const granted = result.authResults[0] === 0;
return granted;
}
权限被拒绝之后,不能只弹一个 toast 就完事。用户第一次拒绝可能只是手滑,正确的做法是引导他们去设置页打开相机权限。我一般会在页面里加一个“相机权限未开启,去设置开启”的按钮,点击后调用 appManager 跳转应用详情设置页。权限申请失败直接黑屏,是新手最容易遇到的问题。
2.2 Camera Kit 会话初始化:surfaceId 是绕不开的坎
相机预览的第一步,是拿到一个可以渲染画面的 surface。在 ArkUI 里,最直接的方案是 XComponent,类型用 surface,然后通过 controller 获取 surfaceId:
typescript复制XComponent({
id: 'scanPreview',
type: 'surface',
controller: this.xComponentController
})
.onLoad(() => {
const surfaceId = this.xComponentController.getXComponentSurfaceId();
this.initCamera(surfaceId);
})
注意,initCamera 一定要在 onLoad 之后调用,因为 surfaceId 在组件加载完成之前是无效的。我在第一版就犯过这个错,把初始化写在 aboutToAppear 里,结果相机 session 一直创建失败,日志又不太明显,最后断点才找到问题。
Camera Kit 初始化大致分四步:拿到相机管理器和相机设备、创建输入、创建输出、创建会话并启动。代码骨架如下:
typescript复制import { camera } from '@kit.CameraKit';
async initCamera(surfaceId: string) {
const cameraManager = camera.getCameraManager(this.context);
const cameras = cameraManager.getSupportedCameras();
const backCamera = cameras.find(cameraDevice =>
cameraDevice.cameraPosition === camera.CameraPosition.CAMERA_POSITION_BACK
);
const cameraInput = cameraManager.createCameraInput(backCamera);
await cameraInput.open();
const outputCapability = cameraManager.getSupportedOutputCapability(backCamera);
const previewProfile = outputCapability.previewProfiles.find(profile =>
profile.size.height === 1080
);
const previewOutput = cameraManager.createPreviewOutput(previewProfile, surfaceId);
const session = cameraManager.createSession(camera.SceneMode.NORMAL_PHOTO) as camera.PhotoSession;
session.beginConfig();
session.addInput(cameraInput);
session.addOutput(previewOutput);
await session.commitConfig();
await session.start();
this.cameraSession = session;
this.previewOutput = previewOutput;
}
这里有个重要的点:一定要从 getSupportedOutputCapability 返回的 previewProfiles 里选一个预览尺寸,不要自己随便拼一个分辨率。不同设备支持的预览尺寸不一样,硬塞一个不支持的 Profile,会话提交大概率失败。
2.3 预览画幅方向与尺寸适配
相机传感器默认输出方向和屏幕方向不一定一致。竖屏 App 如果直接铺满 XComponent,大概率看到的是横向画面,或者画面被拉伸变形。
解决思路分两步。第一步,选预览 Profile 时尽量挑选接近屏幕宽高比的尺寸,比如屏幕是 1080 x 2400,优先选 1080 x 2400 或者 720 x 1600 这类竖构图比例。第二步,如果实际画面仍然旋转了 90 度,可以在会话层设置预览旋转。部分 API 版本支持:
typescript复制if (session.setPreviewRotation) {
await session.setPreviewRotation(camera.PreviewRotation.ROTATION_90);
}
不同 API 版本上方法名可能略有差异,以你当前版本的 SDK 为准。有些版本还可以通过 XComponent 或者外部容器做 rotate 变换,但那是治标不治本,后期整个预览区域都跟着转,手势和绘制都会乱,不如在相机层解决。
尺寸适配还有一个隐藏问题:XComponent 的宽高和预览输出比例不一致时,画面会被拉伸或裁切。我的建议是让 XComponent 保持和预览画面比例一致,再通过布局居中。如果必须让扫码框区域固定,那么扫码框的位置要按实际预览画面的裁切区域重新计算,不能简单用页面的百分比。这就是很多新手反馈“扫码框对准了但识别不到”的原因——相机实际看到的区域和你 UI 上圈出来的区域根本不是一个坐标系。
2.4 生命周期与资源释放
相机是硬件资源,生命周期处理不好,轻则黑屏,重则崩溃。核心原则是:页面不可见时释放会话,页面重新可见时重新初始化。
typescript复制aboutToDisappear() {
this.releaseCamera();
}
onPageHide() {
this.releaseCamera();
}
onPageShow() {
if (this.shouldStartCamera && this.surfaceId) {
this.initCamera(this.surfaceId);
}
}
releaseCamera 里要依次停止会话、移除输出、关闭输入:
typescript复制async releaseCamera() {
if (this.cameraSession) {
await this.cameraSession.stop();
this.cameraSession = null;
}
if (this.cameraInput) {
await this.cameraInput.close();
this.cameraInput = null;
}
}
这里还有一个很隐蔽的坑:onPageShow 可能在 XComponent 的 onLoad 之前触发,比如页面第一次进入时,先走 onPageShow 再走 onLoad。如果直接在这种时序下初始化,surfaceId 还没准备好。我常用一个标记位记录是否已经拿到 surfaceId,拿到之后再真正初始化;从后台回到前台时,如果 surfaceId 还在,且会话已经被释放,就重新初始化。
3. 识别链路:图像帧如何变成扫码结果
3.1 用 ImageReceiver 拿帧
预览画面只是给用户看的,真正用于识别的是另一路输出。常见做法是创建 ImageReceiver,设置一个比较低的分辨率,把相机帧导流给识别模块。
typescript复制import { image } from '@kit.ImageKit';
const receiver = image.createImageReceiver({
size: { width: 960, height: 720 },
format: image.ImageFormat.JPEG,
capacity: 3
});
然后通过 receiver.getReceivingSurfaceId() 创建一路输出,加进相机会话:
typescript复制const photoOutput = cameraManager.createPhotoOutput(receiver.getReceivingSurfaceId());
session.beginConfig();
session.addInput(cameraInput);
session.addOutput(previewOutput);
session.addOutput(photoOutput);
await session.commitConfig();
await session.start();
注意 capacity 字段。相机帧是持续产生的,如果消费者处理不过来,帧会堆积,导致延迟越来越大。设置得太大,内存压力会上去;太小,相机输出管线容易阻塞。我实测 capacity = 3 对 960 x 720 的 JPEG 帧比较均衡,既能保证识别不丢帧,内存也不会涨太离谱。
从 receiver 拿帧再转 PixelMap 的流程:
typescript复制async function frameToPixelMap(receiver: image.ImageReceiver): Promise<image.PixelMap> {
const img = await receiver.readNextImage();
const component = await img.getComponent(image.ComponentType.JPEG);
const buffer = component.byteBuffer;
const imageSource = image.createImageSource(buffer);
const pixelMap = await imageSource.createPixelMap();
await img.release();
return pixelMap;
}
这里要强调:readNextImage 是一次性的,每拿到一帧,就相当于从队列里取走一个元素。如果某次处理异常没有释放 image,下一帧就会卡住。一定要在 finally 里确保 release。我踩过一次,连续扫描几十次之后页面卡死,就是这里漏了释放。
3.2 Scan Kit 的接入与参数配置
Scan Kit 的识别能力很强,接口设计也比较简洁。先创建扫码服务:
typescript复制import { scanBarcode, scanCore } from '@kit.ScanKit';
const options: scanBarcode.ScanOptions = {
scanTypes: [scanCore.ScanType.QR_CODE, scanCore.ScanType.ALL],
enableMultiMode: false,
enableErrorCorrection: true
};
const scanService = await scanBarcode.createScanService(options);
识别时直接调用:
typescript复制const results = await scanService.decode(pixelMap);
if (results && results.length > 0) {
const text = results[0].originalValue;
// 返回码值,进入业务处理
}
scanTypes 按需设置,如果业务只需要二维码,就不要把所有类型都打开。ScanType.ALL 会同时识别条形码、二维码、部分文字码,识别速度会有所下降。enableErrorCorrection 建议打开,尤其是处理屏幕上的码、反光码和模糊码场景,打开后识别率明显提升。
如果项目用的是老的 Scan Kit 接口,也可以直接用 scanBarcode.decode(pixelMap, options),返回结果结构类似。两个接口在不同 API 版本上并存过,用之前先查一下当前 SDK 推荐哪个,避免 deprecation 警告。
3.3 识别的帧率、防抖和多码处理
识别不能每帧都跑。相机帧率通常是 30fps,如果每帧都去做一次 decode,CPU 会被吃满,而且识别结果毫无意义——同一张码连续识别 30 次,只会触发 30 次业务回调。
我的做法是加一个识别节流。用一个定时器,每 500ms 从 receiver 读一帧并交给 Scan Kit,识别成功后立刻停掉定时器,进入结果处理流程;处理结束后再恢复识别。
typescript复制startScanLoop() {
this.scanTimer = setInterval(async () => {
if (this.isRecognizing || this.hasResult) return;
this.isRecognizing = true;
try {
const pixelMap = await frameToPixelMap(this.receiver);
const results = await this.scanService.decode(pixelMap);
if (results && results.length > 0) {
this.hasResult = true;
this.handleResult(results[0].originalValue);
}
} finally {
this.isRecognizing = false;
}
}, 500);
}
关于多码识别,enableMultiMode 打开后,decode 会返回一帧里的多个码。但这个功能要用对场景。如果业务只需要扫一个码,就不要开多码模式,一方面性能有损耗,另一方面用户扫码时画面里同时出现两个码,系统会根据位置、清晰度打分,返回结果可能不是用户想要的。只有业务确实需要一次扫多个码的场景,比如盘点、批量录入,才开启这个模式。
4. 自绘扫码界面:遮罩、激光线、手电筒和相册识别
4.1 Stack 层级与遮罩抠洞
自定义页面的 UI 核心是一个 Stack,底层是 XComponent,上层是自绘组件。这里有一个 ArkUI 开发容易忽略的点:XComponent 不是普通组件,它的渲染层级和普通组件不同,一定要把它放在 Stack 底层,别的 UI 组件盖在上面,否则会出现预览画面盖住按钮的情况。
遮罩效果的常规实现是用 Canvas 绘制:先在整个画布上填充半透明黑色,再在扫码框位置用 clearRect 挖出一个透明区域,最后在挖洞边缘画上边角线,视觉上就形成了扫码框。
typescript复制Canvas(this.maskCtx)
.width('100%')
.height('100%')
.onReady(() => {
this.drawScanMask();
})
typescript复制drawScanMask() {
const ctx = this.maskCtx;
ctx.clearRect(0, 0, this.width, this.height);
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fillRect(0, 0, this.width, this.height);
ctx.clearRect(this.boxLeft, this.boxTop, this.boxWidth, this.boxHeight);
// 画四角
ctx.strokeStyle = '#00E5FF';
ctx.lineWidth = 4;
ctx.strokeRect(this.boxLeft, this.boxTop, this.boxWidth, this.boxHeight);
}
扫码框的位置建议按比例计算,不要写死。不同设备屏幕宽高比差异很大,写死之后低端机上扫码框会跑偏。
4.2 激光扫描动画:从属性动画换到 Canvas 的原因
很多团队做激光线时第一反应是用 ArkUI 的 translate/rotate 属性动画,让一条 View 上下移动。我也这么试过,但问题很明显:动画在重复执行时会有顿挫感,而且组件层级的刷新开销比较大,扫码页同时还在跑相机预览,整页帧率容易掉。
后来我改成完全用 Canvas 绘制激光线,每帧只更新一个 y 坐标,配合 requestAnimationFrame 驱动重绘:
typescript复制private startScanAnimation() {
const tick = () => {
if (this.pageDestroyed) return;
this.scanY += 2;
if (this.scanY > this.boxBottom - this.lineHeight) {
this.scanY = this.boxTop;
}
this.drawScanMask();
this.maskCtx.fillStyle = '#00FF88';
this.maskCtx.fillRect(this.boxLeft, this.scanY, this.boxWidth, this.lineHeight);
this.frameId = requestAnimationFrame(tick);
};
this.frameId = requestAnimationFrame(tick);
}
注意页面销毁时一定要 cancelAnimationFrame,否则会一直空转,尤其页面反复进出时会造成无谓的 CPU 消耗。
4.3 手电筒开关的实现与容错
手电筒的本质是控制相机闪光灯,属于 torch 模式,不是拍照闪光。Camera Kit 提供了接口:
typescript复制async toggleTorch() {
if (!this.cameraSession) return;
const supported = this.cameraSession.isFlashSupported();
if (!supported) {
// 弹提示或直接隐藏按钮
return;
}
this.torchOn = !this.torchOn;
await this.cameraSession.setTorchMode(this.torchOn ? camera.TorchMode.ON : camera.TorchMode.OFF);
}
关键点是 isFlashSupported() 的判断。不是所有设备都支持手电筒,尤其部分前置摄像头和中低端设备,闪光灯能力是缺失的。检查到不支持时,最好直接隐藏手电筒按钮,让用户根本看不到,而不是点完之后弹 toast 说“不支持”,体验完全不同。
另外手电筒状态和相机生命周期联动:页面离开时释放相机,手电筒会自动熄灭;再次进入时需要恢复上一次的状态,还是需要重置为关闭,要和产品确认。我一般选择重置为关闭,因为用户重新进扫码页时不太希望手电筒突然亮着。
4.4 相册选图识别:别忽略照片旋转
相册入口用 PhotoViewPicker 实现:
typescript复制import { photoAccessHelper } from '@kit.MediaLibraryKit';
const picker = new photoAccessHelper.PhotoViewPicker();
const result = await picker.select({
MIMEType: photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE,
maxSelectNumber: 1
});
const uri = result.photoUris[0];
拿到 URI 之后,通过文件接口读入字节流,创建 ImageSource 再转 PixelMap,最后送进 Scan Kit 识别:
typescript复制import { fileIo as fs } from '@kit.CoreFileKit';
const file = fs.openSync(uri, fs.OpenMode.READ_ONLY);
const stat = fs.statSync(file.fd);
const buffer = new ArrayBuffer(stat.size);
fs.readSync(file.fd, buffer);
fs.closeSync(file);
const imageSource = image.createImageSource(buffer);
const pixelMap = await imageSource.createPixelMap();
const results = await this.scanService.decode(pixelMap);
这里有个经常踩的坑:相册里的照片带着 EXIF 旋转信息,屏幕扫码时相机输出的帧已经被方向修正,但相册选出的原图不一定。如果你的 PixelMap 创建后方向不对,扫码会失败或者识别率极低。部分系统版本在 createPixelMap 时会自动处理 EXIF,但万一遇到不正常的结果,可以从 EXIF 里读取 orientation 手动旋转。不要等测试报 bug 才发现这个问题,拿到相册图直接确认一次方向,最省事。
5. 踩坑记录:几个让排查到深夜的问题
5.1 预览黑屏:都是日志看不出来的问题
预览黑屏是扫码页开发出现频率最高的故障,而且日志往往表现得很正常:Camera Kit 初始化成功,会话启动成功,没有异常抛出,但画面就是黑的。
我排查过几次后总结出三个常见原因。
第一个是 surfaceId 没拿对。检查 XComponent 的 onLoad 和相机会话初始化的时序,确认 surfaceId 不是空的,这是在 XComponent 未完成布局时初始化导致的典型现象。
第二个是 XComponent 被遮挡。ArkUI 中 XComponent 在某些版本里如果是同层渲染,层级关系比较特殊,如果 UI 层直接盖了一个不透明的组件在它上面,预览就完全被挡住。检查一下 Stack 中的上层组件背景色是不是半透明,有时一个默认白色背景就让画面“黑”了。
第三个是会话启动顺序。部分设备要求先 beginConfig 添加输入输出,再 commitConfig,最后 start。如果 start 命令下发太快,硬件还没准备好,表面上是黑屏,实际上预览根本没有流出来。可以尝试在 commitConfig 和 start 之间加一个几十毫秒的间隔测试,如果有效,就说明是时序问题,需要根据设备情况做异步重试。
5.2 识别结果重复回调
扫码成功之后不做防抖,结果会以每 500ms 一次的速度疯狂触发。尤其是二维码贴在卡上,用户一直把卡放在镜头前,回调会连续触发很多次,导致页面重复跳转。
解决办法就是在 handleResult 里加锁,通常用一个 hasResult 状态位,识别成功就置 true,并停止扫码循环;业务处理完且用户重新进入扫码页时,再重置为 false。
typescript复制handleResult(text: string) {
if (this.hasResult) return;
this.hasResult = true;
this.stopScanLoop();
// 业务跳转或弹窗
}
注意重置的时机一定不能放在 aboutToAppear 里。如果用户从扫码页跳转到结果页再返回,页面可能不会重新触发 aboutToAppear,需要结合 onPageShow 或者返回回调来重置状态。我建议直接在跳转前把状态传到业务页,业务页返回时统一重置。
5.3 图片方向与预览方向混乱
这种问题最典型的场景:在竖屏页面扫码,二维码横着放,识别率极低,偶尔识别出来速度也很慢。原因就是相机输出帧没有经过方向纠正,Scan Kit 拿到的是旋转 90 度或 180 度后的图像。
处理方向问题要分两层。预览方向处理在前面用 setPreviewRotation 解决;识别帧的方向则需要在拿到 PixelMap 后确认是否需要旋转。Scan Kit 内部对旋转的容忍度是有限的,角度偏差太大时很难识别。
如果相机 API 没提供直接的 preview rotation 设置,或者设置后识别帧方向仍然不对,可以考虑用 PixelMap 的 rotate 接口处理:
typescript复制pixelMap.rotate(90);
但要注意,旋转会引入额外耗时。更优的做法是挑一个和屏幕同方向的预览 Profile,并且尽量统一预览和识别帧的来源尺寸,这样大多数设备上方向就是对的。我最后在项目里做了一个设备方向补偿表,针对不同机型的旋转角度做适配,基本解决方向问题。
5.4 内存持续上涨
扫码页跑久了,内存曲线一直往上走,大概率是帧对象和 PixelMap 没有释放。我经历过一次页面驻留几个小时,内存从 200MB 涨到 700MB 的案例。
排查方向有三个:
readNextImage拿到的image对象必须调用 release,这个前面提过;createPixelMap创建的 PixelMap 在识别完后也要释放;- 相机会话本身在页面销毁时要停止并释放。
另外要注意 ImageReceiver 的 capacity。如果把 capacity 设成 10,100 个像素帧排队,内存自然下不去。合理设置 capacity,配合释放逻辑,扫码页长时间驻留的内存曲线应该是平的。
5.5 一些真正有效的排查顺序
扫码页问题牵扯相机、图像编解码、扫描识别、UI 绘制多条链路,问题出现时很容易不知道从哪下手。我自己习惯按这个顺序排查:
- 先确认相机预览是否正常。预览有问题,所有后续都是空谈。
- 再确认 ImageReceiver 能否拿到帧。在扫码循环里打日志,看是否持续有帧进来。
- 然后确认 PixelMap 内容是否正确。可以把某帧保存下来,人工看一眼是不是旋转过、是否模糊。
- 最后才是 Scan Kit 的识别参数。不要一上来就怀疑 Scan Kit 识别能力差,大多数问题都出在前面几步。
这套顺序看起来简单,但真能省下很多不必要的调试时间。我还见过同事在 Scan Kit 配置上反复调参,最后发现 ImageReceiver 拿到的帧是花的,全白忙活。先把数据链路用日志走通,再做业务联调,是最稳妥的节奏。踩过几次坑之后,我现在做扫码相关需求都会先搭一个最小闭环:相机预览 + 帧回调 + 日志输出,确认底层没问题,再开始画 UI 和调业务,效率提升非常明显。
