
上个月接了一个安卓端的物流扫码项目需求听起来特别简单把扫码功能做成一个页面。但需求方越说越细——扫描框要贴合UI稿、扫完不能直接退出、还要支持相册选图识别、同一个画面里多个码要能分别点选。于是我认真比较了一圈开源方案最终在项目里集成了华为HMS Scan Kit实现了完全自定义的扫码能力。这篇文章就是那次完整接入的记录从依赖配置、相机权限、自定义扫描框绘制到结果解析、相册识别、各种坑的排查一次讲清楚。无论你是刚接触安卓扫码开发还是已经在用ZXing想换一个更省心的方案这篇文章都值得花五分钟看完。1. 为什么最终选择用Scan Kit做自定义扫码1.1 默认扫码页与自定义页面的差距华为Scan Kit给开发者的第一印象是“开箱即用”因为官方提供了一个默认扫码页面调用方式也简单ScanUtil.startScan(activity, REQUEST_CODE_SCAN, HmsScan.QRCODE_SCAN_TYPE);然后在onActivityResult里拿结果就行。这套默认页确实能用扫码框、闪光灯、相册入口都齐了但它的问题是界面是固定的。你不能改扫码框的形状不能把顶部标题换成自己的品牌栏也不能在扫码中页里加自己的业务按钮。而实际项目里“能扫出来”远远不够。我那个物流项目业务方要求扫码页顶部放一张配送员头像底部放“手动输入”按钮扫码框要设计成四个圆角的蓝色线段识别成功后还要震动并弹出气泡。这些需求用默认页完全没法做必须把预览层和识别引擎拆开用。Scan Kit在这里做得比较聪明默认页只是它提供的一个快捷入口底层识别能力通过HmsScanAnalyzer暴露开发者可以自己搭布局把相机预览画面放到任意位置再叠加自定义扫描框。识别引擎本身仍然由华为SDK托管不需要自己去处理图像算法。1.2 与ZXing、ZBar、ML Kit等方案的对比选型的时候我也没打算一上来就定华为Scan Kit先把市面上常用方案过了一遍。方案优点缺点ZXing开源成熟、中文资料多定制界面要改到怀疑人生维护已经半停滞ZBar历史久、识别快多年不维护对现代安卓适配差Google ML Kit识别类型多、API友好依赖Google Play服务国内设备识别能力不稳定华为Scan Kit离线识别、格式全、自定义能力强依赖HMS Core非华为设备需要引导安装ZXing大家很熟了玩安卓的基本都用过。它的问题不是不能用而是定制成本高。眼睛、条码管理器、相机配置耦合在一起改一个扫描框线宽要翻半天源码。ZBar就不用多说了社区基本没什么更新遇到Android 13、14的适配问题只能自己硬扛。Google ML Kit我一开始挺想用毕竟接口干净但国内网络环境你是知道的模型和依赖下载偶尔会出幺蛾子而且部分国产ROM对Google Play服务的支持并不理想。华为Scan Kit最打动我的是离线识别和格式全。二维码、EAN系列、UPC码、Code 128、PDF417、DataMatrix这些常用格式全覆盖官方宣称支持20多种。我在真机华为P系列和一台小米上实测普通二维码识别大约是几十毫秒基本没有延迟感。另外它对中文内容、反色码、残缺码都有专门优化这些正是物流单上常见的恶路况。缺点也很明显依赖HMS Core。你可以在启动时判断服务是否存在如果不存在可以拉起安装引导或者降级到ZXing方案。考虑到国内华为/荣耀设备覆盖量很大这个代价可以接受。2. 工程接入与权限申请最容易翻车的环节2.1 Gradle依赖与HMS Core服务检查接入第一步是配置华为Maven仓库。在项目根目录的build.gradle里加上allprojects { repositories { google() maven { url https://developer.huawei.com/repo/ } } }然后在应用模块的build.gradle中引入Scan Kit SDKdependencies { // Scan Kit 基础能力含自定义识别 implementation com.huawei.hms:scan:2.4.0.300 }我用的版本是2.4.0.300现在华为开发者网站有更新版本API大体一致旧项目升级也不需要伤筋动骨。依赖加完很多人以为就能直接跑了结果程序一启动就Crash常见的报错是找不到HMS Core SDK类。原因很直白扫码SDK需要依赖Huawei Mobile ServicesHMS Core运行如果你的设备上没装或者装的是旧版就会初始化失败。所以接入后的第一件事是在进入扫码页前做服务可用性检查int result HuaweiMobileServicesUtil.isHuaweiMobileServicesAvailable(context); if (result ConnectionResult.SUCCESS) { // 可以正常启动扫码 } else { // 提示用户安装或更新HMS Core // 也可以降级到ZXing备用方案 }这一步千万别省。我当时在一台已经刷过机、没有GMS也没有HMS的测试机上跑直接被这个坑卡了半天因为没有检查服务整个页面启动即崩。2.2 相机权限和存储权限的运行时申请自定义扫码需要相机权限这个所有扫码方案都绕不开。Android 6.0以后要动态申请不能再只在Manifest里写uses-permission就完事。在AndroidManifest.xml里声明uses-permission android:nameandroid.permission.CAMERA /然后在进入扫码页前动态申请private void requestCameraPermission() { if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ! PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.CAMERA}, REQUEST_CAMERA_CODE); } else { startScanEngine(); } }这里有个听上去耸人听闻但其实很常见的崩溃如果相机权限没拿到直接调用LensEngine.run()会抛出IllegalStateException页面直接闪退。所以权限回调里也要做好保护Override public void onRequestPermissionsResult(int requestCode, NonNull String[] permissions, NonNull int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); if (requestCode REQUEST_CAMERA_CODE) { if (grantResults.length 0 grantResults[0] PackageManager.PERMISSION_GRANTED) { startScanEngine(); } else { Toast.makeText(this, 需要相机权限才能扫码, Toast.LENGTH_SHORT).show(); } } }至于存储权限我的建议是尽量别碰。如果只是做相册选图识别直接用系统自带的照片选择器Photo Picker不需要申请READ_EXTERNAL_STORAGE或READ_MEDIA_IMAGES既省事又符合现在安卓的隐私设计趋势。后面讲相册识别的时候我再细说。2.3 Theme和Activity配置的必要细节扫码页Activity不需要特殊主题普通App主题就能跑。但有一项必须设置屏幕方向。在Manifest里锁定竖屏activity android:name.ScanActivity android:screenOrientationportrait android:configChangesorientation|screenSize|keyboardHidden /为什么强调这个因为Scan Kit的相机预览方向和扫码框角点坐标默认是建立在竖屏逻辑下的。如果你不锁定方向手机一转屏相机传感器坐标和屏幕坐标之间的映射关系就会错乱。轻则扫码框与画面错位重则识别结果里的角点信息完全对不上。我见过有人为了“适配横屏”折腾了一周后来直接用portrait锁死项目顺利上线。如果你的产品经理要求必须支持横屏扫码建议单独评估方向映射的工程量不要硬扛。3. 自定义扫码页面的核心实现预览、识别与绘制3.1 LensEnginePreview的基本用法自定义扫码页面的核心是LensEnginePreview。它是Scan Kit提供的一个专用View内部封装了TextureView和相机生命周期管理。我们只需要把它放到布局最底层再在上面叠加自己的扫描框View。布局文件大致这样?xml version1.0 encodingutf-8? FrameLayout xmlns:androidhttp://schemas.android.com/apk/res/android android:layout_widthmatch_parent android:layout_heightmatch_parent android:background#000000 com.huawei.hms.mlsdk.common.LensEnginePreview android:idid/preview android:layout_widthmatch_parent android:layout_heightmatch_parent / com.yourpackage.widget.ScanFrameView android:idid/frame_view android:layout_widthmatch_parent android:layout_heightmatch_parent / !-- 顶部业务栏 / 底部按钮 随便加 -- /FrameLayoutLensEnginePreview这个View很关键它会把相机画面按设备屏幕比例做一个缩放居中显示不会像普通SurfaceView那样直接拉伸变形。但前提是父布局需要一个和Activity同尺寸的容器FrameLayout就够。3.2 HmsScanAnalyzer选项配置与扫描类型识别引擎是HmsScanAnalyzer它决定你扫什么码、怎么扫。创建代码如下HmsScanAnalyzerOptions options new HmsScanAnalyzerOptions.Creator() .setHmsScanTypes(HmsScan.QRCODE_SCAN_TYPE, HmsScan.EAN13_SCAN_TYPE) .setMultiMode(true) .setResultType(HmsScan.RESULT_TYPE_ALL) .create(); HmsScanAnalyzer analyzer new HmsScanAnalyzer(options);几个参数我解释一下setHmsScanTypes指定要识别的码制。如果你只需要二维码就只传QRCODE_SCAN_TYPE如果还需要条码就传EAN13_SCAN_TYPE、CODE128_SCAN_TYPE等。识别类型越少单帧处理越快。如果不传默认识别全部类型方便但会增加耗时。setMultiMode(true)开启多码模式。同一帧画面里出现多个二维码时它会都返回给你。如果不开它只随机返回其中一个码的结果。setResultType决定返回的结果字段。用RESULT_TYPE_ALL可以获得原始值、码类型、边框坐标等完整信息。创建完Analyzer下一步创建LensEngine。LensEngine才是真正驱动相机一帧一帧往Analyzer里送数据的引擎LensEngine lensEngine new LensEngine.Factory(context, analyzer) .setLensType(LensEngine.BACK_LENS) .applyDisplayDimension(1280, 720) .applyFPS(25) .enableAutomaticFocus(true) .create();这里重点说下applyDisplayDimension(1280, 720)。这个值代表相机预览分辨率不是越高越好。预览分辨率过高会占用大量CPU带宽识别不会因为分辨率翻倍而变快甚至可能更慢。720p对普通大小的码已经绰绰有余物流面单这种大尺寸条码1080p和720p几乎没有区别。如果你扫的是特别小的码可以调到1920x1080再对比观察。3.3 自定义扫描框View的绘制思路很多人以为自定义扫码就是找个贴图盖在预览上其实最核心的是扫描框View的绘制。我习惯用一个单独的ScanFrameView继承View重写onDraw。扫描框视觉上通常包含两部分半透明遮罩和四角亮线。半透明遮罩的实现思路是把整个屏幕涂一层半透明黑然后“挖”出中间扫码框区域。很多新手会直接往画布上画四个矩形这种画法在刘海屏、圆角屏上容易出现边缘漏色而且还不好维护。更好的方式是用Path加EVEN_ODD填充规则Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); int width getWidth(); int height getHeight(); if (frameRect null) { int frameWidth (int) (width * 0.75f); int frameHeight (int) (height * 0.45f); int left (width - frameWidth) / 2; int top (height - frameHeight) / 2; frameRect new Rect(left, top, left frameWidth, top frameHeight); } // 半透明遮罩 Path maskPath new Path(); maskPath.addRect(0, 0, width, height, Path.Direction.CW); maskPath.addRect(frameRect, Path.Direction.CW); maskPaint.setColor(Color.parseColor(#66000000)); maskPaint.setStyle(Paint.Style.FILL); canvas.drawPath(maskPath, maskPaint); // 四角亮线 drawCorners(canvas); }用EVEN_ODD或Path.Direction.CW加两个矩形的方法能保证中间区域完全透明外面统一变暗。视觉上扫起来非常干净也不会因为屏幕比例不同出现奇怪的白边。四角亮线的绘制就是画四条线段线段端点带圆头。颜色可以用品牌色宽度建议dp转像素不要写死px否则不同分辨率的手机上粗细不一致。扫描框的尺寸设计也讲究二维码建议正方边长为屏幕宽度的0.7倍左右一维条码建议成扁长矩形宽度拉满屏幕的0.85倍高度不用太高。物流项目里我做了两种模式切到“条码模式”时扫描框自动变长体验很好。3.4 生命周期管理start、stop、release三步缺一不可扫码页的生命周期是新手最容易踩坑的地方。LensEngine不是一次性的它对应真实相机资源必须在正确的时机启动和关闭。我的标准模板Override protected void onResume() { super.onResume(); if (hasCameraPermission()) { lensEngine.run(preview); } } Override protected void onPause() { super.onPause(); if (lensEngine ! null) { lensEngine.stop(); } closeTorchIfNeeded(); } Override protected void onDestroy() { super.onDestroy(); if (lensEngine ! null) { lensEngine.release(); } if (analyzer ! null) { analyzer.close(); } }run必须在onResume里做因为只有页面可见时相机才能安全打开。stop必须在onPause里做否则切换到后台时相机还开着其他App扫描就全是黑屏有些系统甚至会直接给你发“相机被占用”的警告。release和close放在onDestroy释放相机和识别引擎资源。另外注意Transactor的onAnalyze回调跑在子线程如果你想在识别成功后更新UI比如把扫码框变成绿色、弹窗提示必须runOnUiThread或者用Handler.post直接操作View会抛CalledFromWrongThreadException。4. 识别结果的后处理从原始数据到业务字段4.1 HmsScan对象里到底装了哪些字段当Analyzer识别到码后会回调Transactor.onAnalyze(ListHmsScan results)。每个HmsScan对象包含以下关键信息String originalValue hmsScan.getOriginalValue(); int scanType hmsScan.getScanType(); int scanTypeForm hmsScan.getScanTypeForm(); ListPoint borderVertices hmsScan.getBorderVertices(); int zoomValue hmsScan.getZoomValue(); int angleValue hmsScan.getAngleValue();getOriginalValue()最常见的就是码内的字符串。比如二维码里存了一个JSON字符串或一个URL。getScanType()返回码制比如HmsScan.QRCODE_SCAN_TYPE、HmsScan.CODE128_SCAN_TYPE。getBorderVertices()返回码的四个顶点坐标。这个坐标是相机预览画面内的坐标可以用来在界面上画出真实码的位置。getZoomValue()条码模式下一些模糊条码需要缩放才能识别这个值表示识别时使用的缩放倍数。getAngleValue()识别方向修正角度。我通常会写一个简单的结果解析类按业务前缀区分private void handleScanResult(HmsScan hmsScan) { String value hmsScan.getOriginalValue(); if (TextUtils.isEmpty(value)) { return; } if (value.startsWith(LGS:)) { // 物流单号 parseLogisticsNo(value); } else if (value.startsWith({)) { // JSON格式业务数据 parseJson(value); } else { // 普通文本/链接 rawResult value; } }扫码不是扫出来就完事后面接的是一大坨业务逻辑。原始值拿到手先别急着弹Toast先判断格式再走业务流程。4.2 同一帧多码识别的场景处理默认情况下Scan Kit一帧画面里如果出现多个码只返回其中一个。在物流场景里一个托盘上可能同时贴着三四张面单用户想扫哪张是用户的事所以我们要开启多码模式。开启方式前面已经说了setMultiMode(true)。开启后一次回调可能返回多个HmsScan对象同时画面里每个码的borderVertices都在。但多码模式带来一个新的问题同一个码会在连续几十帧里反复回调如果不做处理会出现“连续弹窗三十次”的尴尬情况。我的做法是维护一个“已处理码值集合”并用时间戳限制private final SetString handledValues new HashSet(); private long lastHandleTime; private void handleMultiScan(ListHmsScan results) { long now System.currentTimeMillis(); if (now - lastHandleTime 1500) { return; } for (HmsScan scan : results) { String value scan.getOriginalValue(); if (!handledValues.contains(value)) { handledValues.add(value); lastHandleTime now; handleScanResult(scan); break; } } if (handledValues.size() 100) { handledValues.clear(); } }4.3 识别横竖屏与相机方向的坑这部分是真正的进阶内容也是很多扫码方案之间拉开差距的地方。相机传感器的“上”和屏幕的“上”通常不是同一个方向。即使Activity锁定竖屏相机输出的画面仍然是横向的Scan Kit内部做了转换但HmsScan返回的borderVertices坐标是基于相机画面的不一定直接等于屏幕坐标。如果你只是弹出结果不画框那这个坑不明显。但如果你要做“识别到码后在码周围画一个框”这种体验就必须做坐标方向映射。我的经验是不要自己拿三角函数硬算。安卓上可以用Matrix封装旋转和缩放然后把相机坐标点顺序映射到View坐标。比如竖屏情况下通常需要把相机坐标旋转90度再做一个镜像判断前置/后置不同。这个环节没法给一个万能公式因为每台设备的相机方向和预览尺寸组合都可能不同。最简单可靠的方案是先跑一个Demo扫一张纸上的二维码把识别到的borderVertices直接画到屏幕上对比实际位置看偏转多少再写映射。一步到位的人很少调试两三次就通了。5. 相册选图识别的补充方案5.1 用Photo Picker选图再扔给analyzeInBackground除了相机实时扫码业务上还要支持“相册识别”。比如用户收到一个快递单图片可以直接从相册导入识别单号不用再拿摄像头去照屏幕。安卓上现在打开相册的首选方案是ActivityResultContracts.GetContent()它不需要存储权限也能自动适配各版本。private final ActivityResultLauncherString pickImageLauncher registerForActivityResult(new ActivityResultContracts.GetContent(), uri - { if (uri ! null) { decodeAndAnalyze(uri); } }); private void openPhotoPicker() { pickImageLauncher.launch(image/*); }拿到Uri后不能直接BitmapFactory.decodeFile(uri.getPath())因为相册图片的分辨率动辄4000x3000直接解码极容易OOM。先采样压缩private void decodeAndAnalyze(Uri uri) { try { ContentResolver resolver getContentResolver(); BitmapFactory.Options boundsOptions new BitmapFactory.Options(); boundsOptions.inJustDecodeBounds true; InputStream boundsStream resolver.openInputStream(uri); BitmapFactory.decodeStream(boundsStream, null, boundsOptions); if (boundsStream ! null) { boundsStream.close(); } BitmapFactory.Options decodeOptions new BitmapFactory.Options(); decodeOptions.inSampleSize calculateInSampleSize(boundsOptions, 2048, 2048); InputStream decodeStream resolver.openInputStream(uri); Bitmap bitmap BitmapFactory.decodeStream(decodeStream, null, decodeOptions); if (decodeStream ! null) { decodeStream.close(); } analyzeBitmap(bitmap); } catch (IOException e) { e.printStackTrace(); } }calculateInSampleSize是标准写法网上很多种核心逻辑就是让图片长边不超过2048像素。这个尺寸对扫码识别已经足够内存开销也小。压缩完后用HmsScanAnalyzer.analyzeInBackground(bitmap)异步识别private void analyzeBitmap(Bitmap bitmap) { HmsScanAnalyzer analyzer new HmsScanAnalyzer( new HmsScanAnalyzerOptions.Creator() .setHmsScanTypes(HmsScan.ALL_SCAN_TYPE) .setMultiMode(false) .create()); TaskListHmsScan task analyzer.analyzeInBackground(bitmap); task.addOnSuccessListener(scanList - { if (scanList ! null !scanList.isEmpty()) { handleScanResult(scanList.get(0)); } else { Log.d(ScanKit, 图片里没有识别到码); } analyzer.close(); }); task.addOnFailureListener(e - { Log.e(ScanKit, 图片识别失败, e); analyzer.close(); }); }注意无论成功失败都要记得关闭analyzer避免资源泄漏。5.2 大图内存优化与EXIF旋转修正相册识别的大图问题代码已经写过一遍这里再说两个容易被忽略的细节。第一个细节BitmapFactory.Options.inSampleSize必须是2的幂次方比如2、4、8、16。如果你写3框架会向下取整到2不会报错但计算会不精确。我的calculateInSampleSize实现里会把它规范化。第二个细节EXIF方向信息。很多手机拍出来的照片实际像素矩阵是横向的只有靠EXIF里的Orientation标记才能旋转正确。如果你不处理二维码图片显示是正的但底层Bitmap可能是旋转了90度的识别引擎对旋转并不是完全无感尤其是模糊一点的码。处理方式ExifInterface exif new ExifInterface(inputStream); int orientation exif.getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); Matrix matrix new Matrix(); switch (orientation) { case ExifInterface.ORIENTATION_ROTATE_90: matrix.postRotate(90); break; case ExifInterface.ORIENTATION_ROTATE_180: matrix.postRotate(180); break; case ExifInterface.ORIENTATION_ROTATE_270: matrix.postRotate(270); break; default: break; } Bitmap rotatedBitmap Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);注意这里创建了新Bitmap用完后记得recycle()否则内存涨得也很快。对了从analyzeInBackground返回后原始Bitmap和旋转后的Bitmap都不再需要了要在处理完结果后主动回收或者交给GC前先recycle。6. 我在实际项目里踩过的坑和最终建议6.1 异常场景和解决方案一览列一个排查表里面对应的都是在项目里真遇到过的异常现象根本原因解决方案启动扫码页黑屏并闪退相机权限未动态申请或未实现权限回调在onResume前检查权限权限回调后再run在非华为设备上一进页面就崩溃设备没有安装HMS Core或者版本过旧启动前调HuaweiMobileServicesUtil.isHuaweiMobileServicesAvailable识别速度慢预览分辨率设太高/识别类型全开优先用1280x720只配置需要的码制识别到码但界面不刷新onAnalyze跑在子线程用runOnUiThread更新UI扫码框和实际码位置错位相机坐标方向映射没做单独调试borderVertices补Matrix旋转切后台再回来自动扫码失效onPause没有stop相机资源泄漏严格按照onResume/onPause管理LensEngine相册大图OOM直接解码原图先用inJustDecodeBounds采样再解码同一个码识别完又弹窗没有去重维护已处理码值集合加时间戳限制这里面最容易被忽视的是“非华为设备依赖HMS Core”那条。现在的用户手机五花八门有鸿蒙、有MIUI、有ColorOS虽然大部分国产机都能装HMS Core但你不能假设所有设备预置了它。所以启动检查一定要做而且要写好引导弹窗。6.2 体验优化的几个小细节扫码页的体验往往决定了整个App给人的第一感觉我觉得有几个细节值得单独强调。第一个是扫描框动画。很多App的扫码框上下会有一条“激光线”扫描动画实现起来不复杂一个ValueAnimator从扫描框top动到bottom再反向回来即可。动画时长建议1500到2000毫秒太快会干扰用户注意力太慢显得死板。第二个是震动反馈。识别成功时用Vibrator震一下哪怕只有几十毫秒用户也能立刻感知“扫中了”。注意Android 13以后要用VibratorManager获取实例老版本用Vibrator别写错。第三个是手电筒开关。Scan Kit没有直接给手电筒API但可以通过系统摄像头服务控制。用CameraManager.getCameraCharacteristics找闪光灯setTorchMode开关。不过在onPause里记得把手电筒关掉不然切后台摄像头还在发热。private void toggleTorch(boolean enable) { if (Build.VERSION.SDK_INT Build.VERSION_CODES.M) { CameraManager manager (CameraManager) getSystemService(Context.CAMERA_SERVICE); try { String cameraId manager.getCameraIdList()[0]; manager.setTorchMode(cameraId, enable); } catch (Exception e) { e.printStackTrace(); } } }第四个是关于连续扫码。如果你的场景是“连续扫很多单”扫码成功后不要关闭页面而是把结果加到列表里画一个对勾动画然后继续扫描。这时handledValues集合要能自动清空否则第二张同样的单号会被误判为重复。还有一个体验细节扫码页面底部一定会放“手动输入”入口。这个入口在物流场景里不是备选项因为快递面单有时候真的会磨损、污损扫不出来是常事。相册识别、手动输入、相机扫码三件事并行用户才不会有“这个App好蠢”的抱怨。整体用下来华为Scan Kit的自定义能力确实比预期强。官方文档里“自定义扫码页”那一节其实写得很平淡但你真正去用的时候会发现它把相机采集、图像分析、结果回调完全拆开了每一层都能按自己的需求替换。其实“自定义扫码”的难点从来不在识别算法而在画面渲染、生命周期和权限这些工程细节上。把这篇文章里提到的权限、方向、去重、内存优化这几个坑全部绕开你的自定义扫码功能就已经超过市面上很多扫码App的水准了。