简介:这份资源是一套面向Android开发者的二维码扫描实战Demo,聚焦支付、分享链接、电子票务等常见扫码场景,适合希望快速掌握ZXing集成与相机预览控制的初中级开发者。压缩包共85个文件,约1.59MB,包含20个java源码、35个class、4个xml布局、3个jar依赖及apk安装包等,源码、资源与可运行产物齐备,便于对照阅读与直接调试。Demo核心围绕CaptureActivity展开,涵盖相机初始化、预览回调、图像解码等关键环节,并支持自定义扫描框大小与调整扫描速度,通过修改布局尺寸和相机参数即可适配不同界面与性能需求。代码注释详尽、结构简洁,降低了二次开发门槛。目前已有522人学习下载,可作为Android扫码功能落地的实用参考。
1. 从一次扫码翻车说起:这个 Android 二维码扫描 Demo 到底值不值得拆
去年帮一个做仓储盘点的团队看问题,他们的 Android 扫码页面在暗光货架间十次有三次识别不出来,换了两套开源库还是玄学——有的机型秒开,有的机型预览直接黑屏。后来我把一个流传比较广的二维码扫描 Demo 拆开重写了一遍相机预览和解析线程,问题才收敛。这类 Demo 的价值不在于“能扫出结果”,而在于它把相机权限申请、预览尺寸选择、对焦策略、解析线程调度这几件脏活都摆在了明面上,你能看到每一处参数是怎么定的。
这份 Android 二维码扫描 Demo 就是这样一个东西:一个可以直接跑起来的扫码工程,核心是相机预览加图像解析,适合两类人——刚接触 Android 相机开发、想找一个能跑通的起点的新手,以及被各种机型兼容性折腾过、想回头把底层参数理清楚的熟手。它解决的不是“有没有扫码功能”,而是“为什么同一套代码在不同设备上表现差这么多”。下面我按拆解顺序讲,从工程结构到参数调优,再到我踩过的坑。
2. 拆开工程看结构:相机预览与解析线程是怎么串起来的
2.1 三个核心模块的职责划分
拿到一个扫码 Demo,先别急着改 UI,先看它的模块边界。这类工程通常分三块:相机控制层、图像解析层、结果回调层。相机控制层负责打开摄像头、配置预览参数、把预览帧投递出去;图像解析层拿到帧数据后做灰度化和二值化,再交给解码器;结果回调层把解析出的字符串回传给业务页面。
我一般会先找预览回调那个入口,因为它是整条链路的起点。常见做法是在Camera.PreviewCallback里拿到byte[]数据,但这里有个关键选择:是在回调线程里直接解析,还是丢到独立线程。Demo 里如果直接在回调里解析,帧率一高就会阻塞预览,表现为画面卡顿甚至掉帧。合格的实现会把数据丢进一个单线程队列,解析和预览解耦。
// 相机预览回调,只做数据搬运,不在这里解析 @Override public void onPreviewFrame(byte[] data, Camera camera) { if (data == null) return; // 复用缓冲区,避免每帧 new 数组造成 GC 抖动 if (mFrameBuffer == null) { mFrameBuffer = new byte[data.length]; } System.arraycopy(data, 0, mFrameBuffer, 0, data.length); // 投递到解析线程,立即返回,不阻塞预览 mDecodeHandler.obtainMessage(MSG_DECODE, mFrameBuffer).sendToTarget(); }这段代码的逻辑很直白:预览回调只负责拷贝数据,真正的解析交给mDecodeHandler所在的线程。参数上要注意mFrameBuffer的复用——如果每帧都new byte[],在低端机上 GC 会频繁触发,画面就会一顿一顿的。MSG_DECODE是自定义的消息标识,解析线程收到后从消息池里取数据。这里有个边界:拷贝本身也有开销,如果预览分辨率是 1920x1080,每帧拷贝接近 2MB,所以有些实现会用双缓冲或直接传递引用,但传递引用要小心相机驱动复用同一块内存导致的数据竞争。
2.2 预览尺寸与解析区域的取舍
预览尺寸不是越大越好。我见过一个 Demo 默认用 1920x1080 预览,结果在中端机上帧率掉到 15fps 以下,扫码反而更慢。原因是解析一帧大图的耗时远大于小图,而二维码识别并不需要全分辨率。常见做法是把预览尺寸定在 1280x720 或 960x540,然后在预览帧里裁出中间一块区域做解析。
裁剪区域的选择也有讲究。如果业务场景是用户把二维码对准屏幕中央,那就只解析中间 60% 的区域,四周的像素直接跳过。这样单帧解析耗时能降一半以上。参数上,裁剪的起始坐标和宽高要根据预览尺寸动态算,不能写死,否则换个分辨率就偏了。
// 根据预览尺寸计算中间解析区域,避免全图解析 private Rect computeDecodeRect(int previewWidth, int previewHeight) { // 取中间 60% 宽度和高度,比例可按业务调整 int cropWidth = (int) (previewWidth * 0.6f); int cropHeight = (int) (previewHeight * 0.6f); int left = (previewWidth - cropWidth) / 2; int top = (previewHeight - cropHeight) / 2; return new Rect(left, top, left + cropWidth, top + cropHeight); }0.6f这个比例是我试出来的经验值:再小会漏掉边缘的二维码,再大解析耗时上去了收益不明显。Rect的四个参数分别是左、上、右、下,注意右和下是坐标值不是宽高,这是新手容易写错的地方。如果业务要求支持远距离扫码,这个比例要往上调,同时预览尺寸也得跟着提,两者是联动的。
2.3 对焦策略:连续对焦还是触发对焦
对焦是扫码体验的分水岭。Demo 里如果只调了FOCUS_MODE_CONTINUOUS_PICTURE就完事,在暗光或近距离场景下大概率对不上。连续对焦适合画面里有明显纹理的场景,但二维码本身是大块黑白区域,对比度特征不明显,连续对焦容易来回拉风箱。
我的做法是混合策略:默认开连续对焦保证画面基本清晰,同时每隔一段时间或检测到解析失败时,主动触发一次FOCUS_MODE_AUTO单次对焦。触发时机很关键,太频繁会打断预览,太稀疏又救不回来。一般设 1.5 到 2 秒触发一次,或者在连续 N 帧解析失败后触发。
// 解析失败累计到阈值后触发一次单次对焦 private void onDecodeFailed() { mFailCount++; if (mFailCount >= FAIL_THRESHOLD) { mFailCount = 0; Camera.Parameters params = mCamera.getParameters(); // 切到单次对焦,对焦完成后再切回连续对焦 if (params.getSupportedFocusModes() .contains(Camera.Parameters.FOCUS_MODE_AUTO)) { params.setFocusMode(Camera.Parameters.FOCUS_MODE_AUTO); mCamera.setParameters(params); mCamera.autoFocus((success, camera) -> { // 对焦回调里恢复连续对焦,避免卡在单次模式 resumeContinuousFocus(); }); } } }FAIL_THRESHOLD我一般设 5 到 8 帧,对应大概半秒的失败。autoFocus的回调里必须恢复连续对焦,否则相机就停在单次模式不动了,这是血泪教训——有次忘了恢复,用户移动手机后画面一直是糊的。另外getSupportedFocusModes的判断不能省,部分低端设备不支持FOCUS_MODE_AUTO,直接设会抛异常。
3. 把 Demo 跑起来:权限、依赖与最小可运行配置
3.1 相机权限与运行时申请
Android 6.0 之后相机权限必须运行时申请,Demo 里如果只在清单文件声明,在真机上会直接黑屏。清单里需要CAMERA权限,如果还要保存扫码结果到相册,再加存储权限。运行时申请的时机放在页面启动时,不要等到用户点了扫码按钮才申请,那样体验很割裂。
<!-- AndroidManifest.xml 中声明,运行时仍需动态申请 --> <uses-permission android:name="android.permission.CAMERA" /> <uses-feature android:name="android.hardware.camera" /> <uses-feature android:name="android.hardware.camera.autofocus" />uses-feature这两行容易被忽略。它们的作用是告诉应用市场这个应用需要相机硬件,同时android:required默认为 true,意味着没有相机的设备装不上。如果业务允许无相机设备安装,要把required设成 false,代码里再做降级处理。
// 运行时申请,回调里根据授权结果决定是否初始化相机 if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, REQ_CAMERA); } else { initCamera(); }REQ_CAMERA是请求码,在onRequestPermissionsResult里要判断用户是拒绝还是勾了“不再询问”。勾了不再询问的情况,下次申请系统会直接回调拒绝,这时候要引导用户去设置页手动开,否则用户会以为应用坏了。
3.2 依赖引入与解码库选择
这类 Demo 的解码核心通常依赖一个二维码解析库。常见做法是引入一个轻量的解码库,而不是自己从零实现 Reed-Solomon 纠错。依赖引入时注意版本对齐,尤其是和 AndroidX 的兼容。如果 Demo 用的是较老的 support 库,迁移到 AndroidX 时会有包名冲突,需要全局替换。
// build.gradle 中引入解码库,版本按工程实际对齐 dependencies { implementation 'com.google.zxing:core:3.5.0' // 相机预览若用 CameraX,另加对应依赖 implementation 'androidx.camera:camera-core:1.3.0' implementation 'androidx.camera:camera-camera2:1.3.0' }zxing:core是纯 Java 的解码核心,不依赖 Android 框架,所以可以放在任意线程解析。camera-core和camera-camera2是 CameraX 的依赖,如果 Demo 用的是老的android.hardware.CameraAPI,这两行就不需要。选 CameraX 还是老 API 是个分水岭:CameraX 封装了机型差异,代码量少,但在一些定制 ROM 上行为不可控;老 API 控制力强,但兼容性代码要自己写。我一般新项目用 CameraX,维护老项目就沿用老 API。
3.3 最小可运行验证步骤
跑通 Demo 不要一上来就改业务逻辑,先验证相机预览是否正常。步骤是:装到真机、打开页面、看预览画面有没有出来、画面是否清晰、旋转屏幕后预览方向对不对。这四步过了,再验证扫码。
验证扫码时准备一张对比度高的二维码,距离 15 到 25 厘米,光线均匀。如果扫不出来,先看解析线程有没有收到帧数据,再看解码器返回的是什么。常见情况是帧数据格式不对——相机默认输出 NV21,解码库可能期望 YUV420 或灰度图,格式转换漏了就会一直解不出来。
提示:模拟器上相机预览通常是虚拟画面,扫码功能没法真实验证,必须用真机。
4. 避坑与排查:五个让我返工过的具体问题
4.1 预览画面拉伸变形
现象是预览画面里的人脸变扁或变长。原因是预览尺寸和 SurfaceView 的宽高比不一致,相机把画面硬塞进了控件。解决方法是根据预览尺寸算出控件应有的宽高比,用FrameLayout包裹后动态设置LayoutParams,或者用TextureView配合变换矩阵做裁剪。我一般选后者,因为TextureView支持变换,能精确控制显示区域。
4.2 部分机型预览黑屏
现象是权限给了、相机也打开了,但预览区域全黑。原因通常是相机实例被占用没释放,或者预览尺寸选了一个设备不支持的。解决方法是打开相机前先release旧实例,选预览尺寸时从getSupportedPreviewSizes里挑一个和控件比例最接近的,而不是硬编码。另外页面onPause时必须释放相机,否则切回来就黑屏。
4.3 扫码结果重复回调
现象是扫一次码,结果回调触发了三四次。原因是解析线程在连续多帧里都解出了同一个码,每帧都回调。解决方法是在回调层加去重,记录上一次的结果和时间戳,短时间内相同结果直接丢弃。我一般设 1.5 秒的去重窗口,同时解析成功后立即暂停解析线程,等页面处理完再恢复。
4.4 暗光下识别率骤降
现象是白天正常,晚上或仓库暗处扫不出来。原因是相机自动曝光把画面压暗了,二维码黑白对比度不够。解决方法是开启相机的低光增强模式(如果支持),或者手动提高曝光补偿。另一个思路是解析前做一次直方图均衡化,把对比度拉回来,但这会增加单帧耗时,要权衡。
4.5 解析线程内存泄漏
现象是页面退出后内存没释放,反复进出几次就 OOM。原因是解析线程持有 Activity 引用,或者消息队列里的帧数据没清空。解决方法是用静态内部类加弱引用持有上下文,页面销毁时清空消息队列并中断线程。这个坑很隐蔽,因为单次进出看不出来,要反复操作才暴露。
5. 进阶调优:把识别率从 80% 拉到 95% 的几个参数
5.1 二值化阈值动态调整
解码前的二值化阈值如果写死,在不同光照下表现差异很大。我的做法是根据当前帧的灰度直方图动态算阈值,常见的是大津法(Otsu),它能自动找到一个让前景背景类间方差最大的阈值。实现上先对裁剪区域做灰度统计,算出阈值后再二值化,比固定阈值稳得多。
// 用大津法计算动态阈值,替代固定阈值 private int computeOtsuThreshold(byte[] gray, int length) { int[] histogram = new int[256]; for (int i = 0; i < length; i++) { histogram[gray[i] & 0xFF]++; } int total = length; float sum = 0; for (int i = 0; i < 256; i++) sum += i * histogram[i]; float sumB = 0, maxVar = 0; int wB = 0, threshold = 0; for (int t = 0; t < 256; t++) { wB += histogram[t]; if (wB == 0) continue; int wF = total - wB; if (wF == 0) break; sumB += (float) t * histogram[t]; float mB = sumB / wB; float mF = (sum - sumB) / wF; float varBetween = (float) wB * wF * (mB - mF) * (mB - mF); if (varBetween > maxVar) { maxVar = varBetween; threshold = t; } } return threshold; }histogram统计每个灰度级的像素数,sum是总灰度。循环里wB和wF是前景背景的像素数,mB和mF是两类均值,varBetween是类间方差。取方差最大的t作为阈值。这个算法单帧耗时在毫秒级,中端机扛得住。注意gray[i] & 0xFF这个操作,Java 的 byte 是有符号的,不转成无符号会得到负数索引。
5.2 多帧结果投票
单帧解码有随机性,偶尔会解出错误结果。提升准确率的一个技巧是连续解出多帧后做投票,比如连续三帧结果一致才认为有效。这能过滤掉偶发的误码,代价是响应慢了一点点。参数上投票帧数设 2 到 3 比较平衡,设多了用户会觉得扫得慢。
| 参数 | 建议值 | 说明 |
|---|---|---|
| 预览尺寸 | 1280x720 | 兼顾帧率和清晰度 |
| 解析区域比例 | 0.6 | 中间区域,可按场景调 |
| 对焦失败阈值 | 5 帧 | 触发单次对焦 |
| 去重窗口 | 1.5 秒 | 防止重复回调 |
| 投票帧数 | 2 到 3 | 平衡准确率和速度 |
这张表是我在不同项目里反复调出来的起点值,不是万能公式。比如远距离扫码要把预览尺寸提到 1920x1080,解析区域比例也要放大;而近距离高频扫码可以把投票帧数降到 1,换更快的响应。
5.3 一个我坚持了很久的习惯
从那以后我每次接手扫码相关的需求,都强制走一遍这个流程:先在目标机型上跑一遍原始 Demo 记录基线数据,再改一个参数测一次,绝不一次性改三四个参数。因为扫码体验是多个参数耦合的结果,一起改出了问题根本不知道是哪个引起的。这个习惯帮我省了很多返工时间。希望帮到你。
本文还有配套的精品资源,点击获取