简介:一套面向 Android 开发者的二维码与条形码扫描功能实践资料,基于 ZXing 开源库,完整覆盖依赖接入、扫描界面定制、相机权限处理与扫描结果回调等关键环节,适合需要快速集成扫码能力或做二次开发的移动应用工程师。压缩包共 1874 个文件,整体约 18.42MB,以 Java 源码、XML 布局、Gradle 工程配置、Jar 依赖及 dex/class 编译产物为主,同时包含可直接运行体验的 APK 和使用说明 docx。目前已有 2535 人学习下载。附带的 MyApplication 示例工程与说明文档可对照学习,能从零跑通 ZXing 集成流程,理解自定义扫描界面、闪光灯控制、扫描区域指示及常见异常处理,兼具教学与工程参考价值。
1. 扫码功能选型:为什么是 ZXing core 而不是整套 SDK
做盘点类的模拟项目X时,我需要在一个安卓端应用里同时扫二维码和商品条码,扫码框还得嵌到自研 UI 里,不能用一个固定样式的页面。考察了一圈现成的扫码 SDK,要么包体太大,要么扫码框样式写死,要么对一维码的支持很敷衍,最后我决定只用 ZXing 的 core 库,把相机预览、对焦、解码循环全部自己写。ZXing 是业内最老牌的开源解码库,离线可用,同时覆盖二维码和 CODE_128、EAN-13 等一维码,适合想把扫码功能做轻、做可控的安卓开发者。
这套方案不依赖任何扫码页面资源,几百行代码就能落地,所有 Camera 参数都裸露在外面,遇到问题可以直接查相机栈,而不是在黑匣子里猜。真机调试时最棘手的其实是三件事:预览画面、扫码框、解码区域三者经常对不上;小票条码和反色码识别率惨不忍睹;部分机型在弱光下对焦拉风箱。这些我会在后面的章节按踩坑记录展开。如果你正在评估扫码方案,或者手上有个扫码模块想重构,这篇笔记应该能省你几天时间。
2. 库的接入与解码参数:只用 core,不碰全家桶
2.1 为什么不用 zxing-android-embedded
zxing-android-embedded 是社区封装好的完整扫码模块,里面带了相机管理、扫码 Activity、布局文件和提示音,接入后确实能快速跑起来。但我在一维码场景里吃过亏:嵌入式库把相机封装得太深,我想对 Camera 做一点“魔改”——比如设置缩放、切换连续对焦、开辅助补光、处理反色码——要么走反射,要么升级一次库就把代码打回原样。
ZXing core 则是纯 Java 的解码算法库,不包含任何 Android UI 依赖。相机的打开、预览回调、解码线程、扫码框绘制全由自己掌控。代价是多写几百行胶水代码,换来的是每个参数都可调、每个异常都可见。如果你是那种喜欢把问题定位到具体一行的开发者,core 方案明显比全家桶舒服。
2.2 添加依赖、权限与工程配置
在模块的 build.gradle 里加一行依赖即可,核心库本身非常轻:
dependencies { implementation 'com.google.zxing:core:3.5.3' }然后 AndroidManifest 里声明相机和振动权限:
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.VIBRATE" /> <uses-feature android:name="android.hardware.camera" android:required="false" />这里提三个容易忽略的点。第一,core 是纯 Java 库,理论上任何 Android 版本都能用,但注意不要和项目里其他 SDK 传递依赖进来的旧版 zxing 冲突,我现在统一锁定 3.5.3。第二,VIBRATE 权限是给“扫码成功震动反馈”用的,如果你不想要震动可以不声明。第三,Android 6.0 以后 CAMERA 属于运行时权限,在进入扫码页之前要单独请求,不能在 Manifest 里写完就当没事。
2.3 解码器配置:核心类与 Hint 参数
ZXing 的解码入口是 MultiFormatReader,它可以根据你设置的 Hint 组合,在多种码制之间自动选择和尝试。我的初始化代码一般长这样:
MultiFormatReader reader = new MultiFormatReader(); Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class); hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); hints.put(DecodeHintType.POSSIBLE_FORMATS, Arrays.asList( BarcodeFormat.QR_CODE, BarcodeFormat.CODE_128, BarcodeFormat.CODE_39, BarcodeFormat.EAN_13, BarcodeFormat.UPC_A, BarcodeFormat.UPC_E)); hints.put(DecodeHintType.CHARACTER_SET, "UTF-8"); reader.setHints(hints);这段配置里,TRY_HARDER 会牺牲一点点速度,换取对小角度倾斜、模糊码的识别提升,扫码场景里我一般都会开。POSSIBLE_FORMATS 用来收紧格式范围,只保留你业务里真正需要的码制,否则识别时会在每种格式上做尝试,速度慢而且容易出现误识别。CHARACTER_SET 对含中文的二维码内容很关键,不设 UTF-8 时可能拿到乱码。
解码管线上的核心类大概就四个,弄清楚它们的分工,后面调参才有方向。
| 类 | 职责 |
|---|---|
| PlanarYUVLuminanceSource | 把相机 NV21 帧包装成可裁剪的亮度矩阵 |
| HybridBinarizer | 局部阈值二值化,抗光照不均 |
| GlobalHistogramBinarizer | 全局直方图二值化,细线条场景下有时更稳 |
| MultiFormatReader | 多格式解码器,按 Hint 尝试并返回 Result |
二值化这个环节值得一提:HybridBinarizer 在暗光、渐变光场景下明显更稳,默认用它没问题。但如果你发现某些细线条的一维码在强光下反复失败,可以试试 GlobalHistogramBinarizer,它按全局亮度分布切分,对高对比度条码偶尔有奇效。两套二值化器切换只需要换一行构造参数,遇到玄学识别问题时值得作为一个排查项。
3. 相机帧到二维码结果:从预览到出码的完整实现
3.1 预览回调与相机生命周期管理
相机这块最核心的教训是:一切操作必须等 Surface 真正创建之后再执行。我见过太多黑屏问题,本质都是在 surfaceCreated 之前就调用了 setPreviewDisplay 或者 startPreview。正确骨架如下:
public class CameraPreview implements SurfaceHolder.Callback { private Camera camera; private PreviewCallback previewCallback; @Override public void surfaceCreated(SurfaceHolder holder) { openCameraSafely(); if (camera != null) { camera.setDisplayOrientation(getPreviewDegree()); try { camera.setPreviewDisplay(holder); } catch (IOException e) { releaseCamera(); } } } @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { if (camera == null) return; // 先停掉旧预览,再重新配置参数,否则部分机型会出现首帧黑屏 camera.stopPreview(); configureCameraParams(width, height); camera.setPreviewCallbackWithBuffer(previewCallback); camera.addCallbackBuffer(createPreviewBuffer(width, height)); camera.startPreview(); } }每次配置参数之前先 stopPreview,这是血泪经验。有些设备不停止预览直接 setParameters,要么不生效,要么出现画面撕裂。createPreviewBuffer 分配的是 YUV420 数据缓冲区,size 用width * height * 3 / 2计算,NV21 格式是这个大小。buffer 放给相机之后,相机每帧都会往里写数据,写完通过回调还给应用,具体复用逻辑放到最后一章讲。
3.2 后台解码线程与帧率控制
解码不能放在主线程,也不能放在相机回调线程里,否则预览会卡顿甚至 ANR。我习惯用一个 HandlerThread 串行处理预览帧,收到帧数据后直接 post 到解码线程:
final class DecodeHandler extends Handler { private final MultiFormatReader reader; private final DecodeListener listener; @Override public void handleMessage(Message msg) { byte[] data = (byte[]) msg.obj; int width = msg.arg1; int height = msg.arg2; PlanarYUVLuminanceSource source = new PlanarYUVLuminanceSource( data, width, height, 0, 0, width, height, false); Result result = null; try { result = reader.decodeWithState(new BinaryBitmap( new HybridBinarizer(source))); } catch (Exception ignored) { // 没识别到是常态,不能在这里抛异常 } finally { reader.reset(); } if (result != null) { listener.onDecoded(result); } } }这段逻辑里有两个细节值得讲。第一,decodeWithState 在每帧识别结束后要调用 reset(),否则内部状态会残留到下一帧,导致连续出码或误码。第二,每次 try 里必须把 Exception 全部吞掉,因为绝大多数帧本来就是扫不出来的,抛异常只会拖慢线程。帧率控制上,我一般会加一个“解码中”标记:如果上一帧还没解完,新帧就直接丢弃,宁可少解几帧也不让解码线程积压。
3.3 结果回调与扫码框的坐标换算
解码成功后,Result 里带的是相机原始帧坐标系下的 ResultPoint 数组,二维码有三个定位点,一维码通常有两个端点。但屏幕上扫码框是 View 坐标系,两者不能直接画等号。我的做法是先做一个通用的坐标映射:
PointF mapToScreen(ResultPoint p, int frameW, int frameH, int viewW, int viewH, int rotation) { // 旋转 90/270 度时,宽高互换后再缩放 float scaleX = viewW / (rotation % 180 == 0 ? (float) frameW : frameH); float scaleY = viewH / (rotation % 180 == 0 ? (float) frameH : frameW); return new PointF(p.getX() * scaleX, p.getY() * scaleY); }这就是扫码框动画能和真实条码贴合的底层逻辑。如果你不做这一步换算,最典型的表现是:扫码框中心对准条码扫不出来,把条码挪到框外反而能扫出。后面避坑章节会再展开一次。你可以在解码成功回调里把这四个点传给自定义 View,让四个角标画到对应位置,视觉效果就会像“框住了条码”。
4. 条形码扫描专项:一维码为什么更难,参数怎么调
4.1 一维码和二维码的识别差异
二维码的定位图案和容错机制,决定了它哪怕有 30% 面积被遮挡、或者倾斜一定角度,依然能恢复出来。一维码完全不一样,它的信息全部编码在水平方向的条和空的宽度序列里,一旦某一根细条模糊、断裂、或者反光过曝,整个解码就直接失败。
| 维度 | 二维码 | 一维码 |
|---|---|---|
| 信息维度 | 二维矩阵 | 一维条带 |
| 容错能力 | 有分级容错 | 基本无容错 |
| 对清晰度要求 | 整体锐利即可 | 条宽像素必须足够 |
| 常见格式 | QR_CODE | CODE_128 / EAN-13 |
| 误识别风险 | 低 | 高,需限制格式 |
实践中一个关键经验是:一维码在画面中的宽度占比直接决定成功率。当条码占画面宽度小于三分之一时,识别率会掉到很难看的程度;占三分之二以上时成功率才比较稳定。所以一维码扫码页会主动拉近画面,不是心理作用,是为了让条宽在传感器上多占几个像素。
4.2 解码格式限制与调参代码
同时扫二维码和一维码时,不能直接把所有格式都丢给 MultiFormatReader,否则二维码抢在一维码前面消耗大量解码时间。我通常根据页面类型切换 hints:
Map<DecodeHintType, Object> productHints = new EnumMap<>(DecodeHintType.class); productHints.put(DecodeHintType.POSSIBLE_FORMATS, Arrays.asList( BarcodeFormat.CODE_128, BarcodeFormat.CODE_39, BarcodeFormat.CODE_93, BarcodeFormat.EAN_13, BarcodeFormat.EAN_8, BarcodeFormat.UPC_A, BarcodeFormat.UPC_E)); productHints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE); productHints.put(DecodeHintType.ALLOWED_LENGTHS, new int[]{6, 8, 13, 14}); reader.setHints(productHints);ALLOWED_LENGTHS 是个双刃剑。EAN-13 固定 13 位、UPC-A 固定 12 位,限制长度能大幅降低误识别,比如画面里的随机条纹不会被误认为条码。但 CODE_128 是变长的,千万别给它设长度,否则超过你预设长度的合法码全会被拒掉。我踩过一次把 CODE_128 限制成长度 10,结果扫码枪打出来的 14 位序列号死活扫不出,排查了半天才意识到是 ALLOWED_LENGTHS 误杀。
4.3 对焦模式选择与主动拉近
一维码对清晰度极其敏感,连续对焦是首选,前置条件判断不能省:
Camera.Parameters params = camera.getParameters(); List<String> modes = params.getSupportedFocusModes(); if (modes.contains(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE)) { params.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE); } else if (modes.contains(Camera.Parameters.FOCUS_MODE_AUTO)) { params.setFocusMode(Camera.Parameters.FOCUS_MODE_AUTO); camera.autoFocus(null); }部分设备只有默认无对焦模式,此时一维码基本残废,只能在 UI 上提示用户拉远拉近找位置。缩放是另一个重要的辅助手段。每次进入扫码页,我习惯把画面主动放大一点,但不能拉满:
Camera.Parameters params = camera.getParameters(); if (params.isZoomSupported()) { int maxZoom = params.getMaxZoom(); params.setZoom((int) (maxZoom * 0.35f)); camera.setParameters(params); }放大 35% 到 40% 是我反复调出来的经验值:太小对细条没有实质帮助,太大容易让条码超出画面或者近距离对焦失败。请记住一个原则:放大是补救手段,不是万灵药。扫码页引导用户把条码放正、占满扫码框三分之二,才是根本解法。
5. 扫码开发里的常见问题排查:五处翻车记录与解决办法
5.1 黑底白码的反色码扫不出来
现象:白底黑字的二维码秒出,黑底白字的反色码一帧都扫不出,同一台设备同样的距离。
原因:ZXing 的二值化默认按“白纸黑字”的极性去找条码边缘,黑底白字会把亮度关系完全颠倒,HybridBinarizer 处理后条码区域变成一片黑或一片白,边缘信息全丢。
解决:把亮度分量反转后再走一遍解码。NV21 帧里 Y 分量在前,UV 交错在后面,反色时只反 Y,不要动 UV 色度分量:
// 只反转亮度分量,色度保持原样 int yLength = width * height; for (int i = 0; i < yLength; i++) { data[i] = (byte) (255 - (data[i] & 0xFF)); }我一般是在原帧解码失败后,再拿反色帧尝试一次。要注意这个操作必须在线程里做,因为它是 O(n) 的循环,主线程跑会掉帧。现在很多会员码、代金券故意做黑底或深色底,如果不处理反色场景,上线后一定会收到“扫不出来”的反馈。
5.2 相机黑屏或预览卡在第一帧
现象:扫码页打开是黑屏,退出重进偶尔恢复;甚至有些机型第一次进能正常预览,第二次进就黑。
原因:最常见的两个。一是 Surface 还没创建完成就调用了 setPreviewDisplay,二是前一个页面没有释放 Camera 实例,新页面 Camera.open() 时拿到的是 null 或直接抛异常。部分机型对 Camera 释放的时机特别敏感,onPause 里没释放,onResume 里就再也打不开。
解决:严格执行 surfaceCreated 后再 open 相机的顺序;所有 open 操作包 try-catch,失败时允许用户点屏幕重试;onPause 里无条件 releaseCamera(),并把 camera 引用置空,避免下一次进入时拿到脏对象。我后来把这个流程封装成了一个状态机,黑屏问题基本绝迹。
5.3 小票条码识别率低
现象:超市小票、快递面单上的条码,要对半天才能扫出,经常超时自动退出。
原因:小票打印机的分辨率本来就不高,条宽很窄,再加上纸面卷曲、反光、手指遮挡,一维码只要有一小段模糊整张就废。这类场景和屏幕上渲染出来的高清二维码完全不是一个难度级别。
解决:三条措施叠加。第一,放大画面,让条码占预览宽度三分之二以上。第二,缩小 ROI 解码区域,把解码范围限制到扫码框内部,减少周围纹理对二值化的干扰。第三,关闭连续对焦,改用自动对焦并监听对焦完成后再启动解码,避免在合焦瞬间之前一直用模糊帧做无效尝试。如果你发现条码离镜头很近时反复对不上焦,可以额外尝试把对焦模式切到 MACRO,部分设备在 10 厘米内确实需要微距模式。
5.4 ZXing 类冲突和版本冲突
现象:编译报重复类,或者运行时 NoSuchMethodError,崩溃栈指向 zxing 包名。
原因:项目里某个依赖 SDK 自己带了一份很老版本的 zxing,和你显式依赖的 3.5.3 里的类结构不一样。两个版本的包名完全一致,Gradle 去重时只能留一个,留了旧的就会出现 NoSuchMethodError。
解决:先定位是谁带来的传递依赖,然后显式排除:
implementation('com.google.zxing:core:3.5.3') { // 排查后确定是别的 SDK 带来的旧版 zxing,整包排除 }更彻底一点的做法,是全局排除所有传递依赖里的 zxing,只保留自己声明的那份:
configurations.all { exclude group: 'com.google.zxing' }这个操作要放在顶层 build.gradle 里,加之前先确认没有其他模块直接依赖了旧版。这个坑最大的麻烦在于它不是必现的,换一个依赖版本可能就冒出来,建议在集成扫码功能时就把排除规则写好。
5.5 扫码框显示与解码区域错位
现象:扫码框对准条码中心,怎么都扫不出;把条码放到框外反而秒出,扫码动画指示的那条线完全失真。
原因:相机原始帧是横向传感器数据,预览显示经过旋转、缩放后投射到屏幕;扫码框是屏幕 View 坐标系。如果直接用扫码框的 View 坐标去裁剪 YUV 数据,必然错位。我最早犯的错就是直接在data上按 View 的 left/top 去切,结果扫到的区域完全不是用户看到的位置。
解决:做坐标映射。核心是算出两个坐标系之间的缩放比,旋转 90 度时宽和高的映射要互换:
float scaleX = cameraFrameWidth / (float) previewView.getWidth(); float scaleY = cameraFrameHeight / (float) previewView.getHeight(); int frameLeft = (int) (viewRect.left * scaleX); int frameTop = (int) (viewRect.top * scaleY); int frameRight = (int) (viewRect.right * scaleX); int frameBottom = (int) (viewRect.bottom * scaleY);如果你的预览方向是 90 度或 270 度,还需要把 scaleX 和 scaleY 互换(见第三章的 mapToScreen),这里不再重复。排查这个问题的诀窍是:在解码线程里把要裁剪的区域写到一个日志里,再用截图工具对比实际画面,一两轮就能看出偏移规律。
6. 把识别率再冲一冲:帧缓冲复用与 ROI 裁剪技巧
6.1 用 setPreviewCallbackWithBuffer 减少 GC 和帧抖动
默认的 setPreviewCallback 每帧回调都会新建一个 byte[],在 30fps 下意味着每秒要产生 30 个大数组,内存抖动比你想的严重得多。我的做法是交给相机一块固定缓冲区,让它循环填、循环用:
@Override public void onPreviewFrame(byte[] data, Camera camera) { // 带标记:上一帧还没解完就跳过,避免积压 if (isDecoding.compareAndSet(false, true)) { decodeHandler.post(() -> { decodeAndNotify(data); isDecoding.set(false); }); } // 复用的是同一块 buffer,不会新增内存 camera.addCallbackBuffer(data); }配合解码线程的“正在解码”标记,这套组合让我把扫码页的掉帧率压到了很低的水平。初始化时只需要在 surfaceChanged 里分配一次:
camera.setPreviewCallbackWithBuffer(previewCallback); camera.addCallbackBuffer(new byte[width * height * 3 / 2]);注意旋转屏幕或者切换摄像头后预览尺寸会变,缓冲区必须重建,否则会出现花屏。
6.2 在 YUV 层对扫码框 ROI 做裁剪
裁剪可以减少解码像素量,同时避开扫码框外的高频干扰。但 NV21 裁剪有个容易踩的细节:UV 分量是 2x2 像素共享一个采样点,所以裁剪的 left 和 top 必须是偶数,否则颜色会发生偏移。一个可靠的裁剪函数大致这样:
private byte[] cropNv21(byte[] src, int srcW, int srcH, int left, int top, int cropW, int cropH) { byte[] dst = new byte[cropW * cropH * 3 / 2]; // Y 分量逐行拷贝 for (int row = 0; row < cropH; row++) { System.arraycopy(src, (top + row) * srcW + left, dst, row * cropW, cropW); } // UV 分量行,每两行 Y 对应一行 UV int srcUvOffset = srcW * srcH; int dstUvOffset = cropW * cropH; for (int row = 0; row < cropH / 2; row++) { System.arraycopy(src, srcUvOffset + (top / 2 + row) * srcW + left, dst, dstUvOffset + row * cropW, cropW); } return dst; }调用时把裁剪后的 byte[] 传给 PlanarYUVLuminanceSource,解码器只处理扫码框内的区域。这块做对后,有两处明显收益:识别成功率高了一截,低端机器上的解码时延也降了不少。从那以后,我每次做扫码功能都强制自己走一遍流程:先测黑底反色码,再测小票条码,确认 Buffer 复用和 ROI 裁剪都在位。ZXing 的解码算法本身很稳,真正的幺蛾子全在相机参数和坐标系换算上,希望这些经验能帮到你。
本文还有配套的精品资源,点击获取