年初写了一篇CameraX的使用文章,帮到了一些朋友,也收到了一些建议。正值最近了解到华为ScanKit在扫码场景下的优秀表现,决定集成该方案,并进行一些功能改进。
之前做的Demo略显简陋,本次改进也对UI进行了调整。主要是给顶部操作栏添加了半透明背景,同时给切换按钮添加了半透明边框以提高对比度。另外对拍摄和录制场景的一些配色做了改动。
1. 华为ScanKit是什么
ScanKit可以提供便捷的二维码与条形码扫描、解析、生成能力,帮助您快速构建应用内的扫码功能。
它拥有诸多优势,包括支持多达13种码格式,在反光、污损、畸变、模糊等复杂场景下亦能良好识别,在远距离扫码的情况下能自适应放大码体,还支持多码识别功能等等。
ScanKit给开发者提供了四种集成模式,包括固定扫码界面的Default View Mode,自定义扫码界面的Customized View Mode,以及完全由开发者自定义画面和扫码流程的Bitmap Mode和MultiProcessor Mode。
前两种模式的扫码流程均由ScanKit控制,其内部采用Camera1实现。如果要集成到CameraX上的话,只能选择后两种模式。MultiProcessor Mode适用于多码识别的场景,本次先集成单码识别的Bitmap Mode。
华为ScanKit更加详细的资料可查阅官网:
https://developer.huawei.com/consumer/cn/hms/huawei-scankit
以及易冬大神的完整演示:
https://juejin.cn/post/6967890062423883783
2. 扫码方案的选择
之前的扫码方案采用的是Zxing
,本次集成ScanKit
之后,为了对比学习将Zxing
的使用也进行了保留。在点击扫码按钮之后,底部会弹出扫码方案的选择Fragment,选择之后通过ViewModel将对应的方案告知CameraX的ImageAnalysis。
※ Google的ML Kit是一个更为强大的OCR解决方案,后面也将集成进来
大家可能比较关心ScanKit相较于Zxing的优势,可以参考如下这篇测评文章:
https://developer.huawei.com/consumer/cn/forum/topic/0201248342859390343?fid=18
这篇文章里提到ScanKit在远距离扫码、码体倾斜、模糊扫码等场景下的识别速度和成功率都要优于Zxing。大家也可以使用本文的Demo,分别选择Zxing和ScanKit两个方案,实际对比一下扫码体验。
3. 集成ScanKit
在project的gradle文件里添加ScanKit的仓库地址,app的gradle文件里添加依赖,即可快速集成。※Demo依赖了识别能力更为出色的scanplus依赖包
// build.gradle buildscript { repositories { ... mavenCentral() maven {url 'https://developer.huawei.com/repo/'} } } allprojects { repositories { ... mavenCentral() maven {url 'https://developer.huawei.com/repo/'} } }
// app/build.gradle dependencies { ... // Huawei scan kit implementation 'com.huawei.hms:scanplus:1.3.2.300' }
3.1 ImageProxy转换Bitmap
CameraX图像分析ImageAnalysis
回传的图像实例ImageProxy
是YUV格式的,需要先通过YuvImage
将其转换为Bitmap,之后再调用ScanKit的Bitmap扫码模式。
private fun proxyToBitmap(image: ImageProxy): Bitmap { val planes: Array<ImageProxy.PlaneProxy> = image.planes val yBuffer: ByteBuffer = planes[0].buffer val uBuffer: ByteBuffer = planes[1].buffer val vBuffer: ByteBuffer = planes[2].buffer val ySize: Int = yBuffer.remaining() val uSize: Int = uBuffer.remaining() val vSize: Int = vBuffer.remaining() val nv21 = ByteArray(ySize + uSize + vSize) yBuffer.get(nv21, 0, ySize) vBuffer.get(nv21, ySize, vSize) uBuffer.get(nv21, ySize + vSize, uSize) val yuvImage = YuvImage(nv21, ImageFormat.NV21, image.width, image.height, null) val out = ByteArrayOutputStream() yuvImage.compressToJpeg(Rect(0, 0, yuvImage.width, yuvImage.height), 75, out) val imageBytes = out.toByteArray() val opt = BitmapFactory.Options() opt.inPreferredConfig = Bitmap.Config.ARGB_8888 var bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, opt) return bitmap }
3.2 调用Bitmap扫码模式
创建下ScanKit专用的扫码参数,并将转换得到的Bitmap实例传递给ScanUtil,即可开始识别。返回的识别结果包括内容、坐标、四角位置等信息,被封装到HmsScan对象里。ScanUtil识别完成后实际返回的是HmsScan数组,其第一个元素即为单码的识别结果。HmsScan对象的originalValue属性则是解析出来的内容。
class HuaweiScanAnalysis: RealTimeAnalysis { override fun analyzeContent(imageProxy: ImageProxy, context: Context): AnalysisResult { val bitmap = proxyToBitmap(imageProxy) imageProxy.close() // 创建ScanKit扫码的参数 val options = HmsScanAnalyzerOptions.Creator() .setHmsScanTypes(HmsScan.ALL_SCAN_TYPE) .setPhotoMode(false) .create() // 得到扫码结果 val result = ScanUtil.decodeWithBitmap( context, bitmap, options ) val content = if (result != null && result.isNotEmpty() && result[0].originalValue != null) result[0].originalValue else "" ... // 将扫码结果封装为我们自定义的实例 return AnalysisResult(content, scale, rect) } }
3.3 远距离扫码的自动放大
当远距离扫码或码体过小,ScanKit会计算得到适合的放大倍率,并赋值到HmsScan对象的zoomValue属性里。可以利用该数值及时通知CameraX调整图像采集的倍率,进而提升后续的识别率。实现思路非常简单,使用CameraControl提供的setZoomRatio放大图像预览和分析的倍率即可。为不影响下次的扫码体验,在扫码完成后须将倍率置。
class MyAnalyzer(...): Analyzer { override fun analyze(image: ImageProxy) { viewModel.analysePicture(image).also { if (Constants.DEFAULT_ZOOM_SCALE != it.zoomScale && Constants.MIN_ZOOM_SCALE != it.zoomScale ) { callback.onZoomPreview(it.zoomScale) } else { callback.onAnalyzeResult(it) } } } } fun onAnalyzeGo(view: View?) { if (mAnalyzer == null) { mAnalyzer = MyAnalyzer(viewModel, object : AnalyzeCallback { override fun onZoomPreview(scale: Double) { mCamera.cameraControl.setZoomRatio(scale.toFloat()) } ... }) } }
3.4 成功提示音和震动
为提高用户体验,可以在扫码成功的同时播放预设的提示音或震动反馈,可以利用开源的BeepManager
工具类来实现。
fun onAnalyzeGo(view: View?) { if (mAnalyzer == null) { mAnalyzer = MyAnalyzer(viewModel, object : AnalyzeCallback { override fun onAnalyzeResult(result: AnalysisResult) { synchronized(isAnalyzing) { showQRCodeResult(result.content) ... } } }) } } private fun showQRCodeResult(result: String) { stopAnalysis() beepManager.playBeepSoundAndVibrate() ... }
3.5 绘制码体指示位置
扫码成功的瞬间,微信和支付宝App会在二维码上展示一个圆点,这样的提示设计比较好。HmsScan类的borderRect属性代表码体的矩形框位置,通过计算得到的centerX和centerY可以帮忙获取码体的中心,在该位置可以展示一个指示View。
需要留意的是,竖屏模式下Analyse的图片会有90度的偏差,所以需要额外转换下位置坐标。当然如果Bitmap实例已经做过了90度旋转的处理的话,borderRect数值就不需要额外转换了。有些遗憾的是,坐标计算会有些误差,很难保证每次都将指示位置绘制在中心。
override fun onAnalyzeResult(result: AnalysisResult) { synchronized(isAnalyzing) { showQRCodeResult(result.content) val centerPoint = Utils.convertRectToPoint(result.rect, binding.previewView) showPointView(centerPoint) } } private fun showPointView(point: Point) { val popupWindow = PopupWindow( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ) val imageView = ImageView(this) runOnUiThread { popupWindow.contentView = imageView imageView.setImageResource(R.drawable.ic_point_view) popupWindow.showAsDropDown(binding.previewView, point.x, point.y) binding.previewView.postDelayed({ popupWindow.dismiss() }, 1000) } }
3.6 绘制码体边框
在识别过程或成功的时候也可以展示二维码的边框辅助提示。尽管这样的设计并不十分必要,我们可以试着实现看看。
borderRect属性的原始数值就是框体的宽高,再依据上面的中心位置就可以在上面绘制一个矩形框。事实上除了borderRect,cornerPoints属性可以拿到码体四角的确切位置,也可以作为绘制框体的数据来源。
override fun onAnalyzeResult(result: AnalysisResult) { synchronized(isAnalyzing) { showQRCodeResult(result.content) val centerPoint = Utils.convertRectToPoint(result.rect, binding.previewView) showRectView(centerPoint, result.rect) } } private fun showRectView(point: Point, rect: Rect) { val popupWindow = PopupWindow( rect.height(), rect.width() ) val imageView = ImageView(this) runOnUiThread { popupWindow.contentView = imageView imageView.setImageResource(R.drawable.ic_rect_view) imageView.scaleType = ImageView.ScaleType.FIT_XY try { popupWindow.showAsDropDown(binding.previewView, point.x - (rect.width() / 2), point.y) } catch (e: Exception) {} binding.previewView.postDelayed({ popupWindow.dismiss() }, 1000) } }
※ 不知道拍摄角度的问题还是ScanKit的识别存在误差,框体的绘制位置总有些偏差,官方Demo绘制的框体位置也不准确
4. 必要的手势支持
之前的Demo主要集中在CameraX的API使用上,忽略了支持必要的手势,本次一并加入常用的手势支持。
4.1 双击手势缩放
CameraControl提供的setLinearZoom() API可以将拍摄的视野线性地缩放,比较适合双击或者滑动缩放视图的场景。它接受的参数数值介于0~1之间,具体如下:
0为最小缩放比例,即原始尺寸
1为缩放至最大比例
通过监听双击手势,让拍摄的画面在原始比例0f和0.5F中间比例之间切换。
private fun listenGesture() { binding.previewView.setOnTouchListener { view, event -> ... // Zoom when double click. doubleClickZoom(event) true } } private fun doubleClickZoom(event: MotionEvent) { if (doubleClickDetector == null) { doubleClickDetector = GestureDetector(this@NewCameraXActivity, object : GestureDetector.SimpleOnGestureListener() { override fun onDoubleTap(e: MotionEvent?): Boolean { cameraZoomState.value?.let { val zoomRatio = it.zoomRatio val minRatio = it.minZoomRatio // Ratio parameter from 0f to 1f. if (zoomRatio > minRatio) { mCamera.cameraControl.setLinearZoom(Constants.MIN_ZOOM_SCALE.toFloat()) } else { mCamera.cameraControl.setLinearZoom(Constants.MIDDLE_ZOOM_SCALE.toFloat()) } } return true } }) } doubleClickDetector?.onTouchEvent(event) }
4.2 捏合手势缩放
CameraControl提供的setZoomRatio API在线性缩放的基础之上提供了更为准确的缩放比率,可以实现捏合手势的缩放场景。
private fun listenGesture() { binding.previewView.setOnTouchListener { view, event -> ... // Listen to zoom gesture. scalePreview(event) true } } private fun scalePreview(event: MotionEvent) { if (scaleDetector == null) { scaleDetector = ScaleGestureDetector(this@NewCameraXActivity, object : SimpleOnScaleGestureListener() { override fun onScale(detector: ScaleGestureDetector): Boolean { cameraZoomState.value?.let { val zoomRatio = it.zoomRatio mCamera.cameraControl.setZoomRatio(zoomRatio * detector.scaleFactor) } return true } }) } scaleDetector?.onTouchEvent(event) }
4.3 手动对焦的优化
之前是在Touch(ACITON_DOWN)的时候依据坐标进行手动聚焦,引入缩放手势的支持之后,缩放的过程中会误触对焦操作。改善方法在于将对焦的时机限制在SingleTap
手势,即只有单击操作才会触发对焦。
private fun listenGesture() { binding.previewView.setOnTouchListener { view, event -> ... // Singe tap for focus. singleTapForFocus(event) true } } private fun singleTapForFocus(event: MotionEvent) { if (singleTapDetector == null) { singleTapDetector = GestureDetector(this@NewCameraXActivity, object : GestureDetector.SimpleOnGestureListener() { override fun onSingleTapConfirmed(e: MotionEvent?): Boolean { focusOnPosition(event.x, event.y, true) return super.onSingleTapConfirmed(e) } }) } singleTapDetector?.onTouchEvent(event) }
5. 持续的代码改进
改进前很多逻辑都堆在了Activity里,现将各个UseCase的实现拆分出去,减轻Activity的负担。同时对CameraX使用的一些问题进行了改进。
5.1 防止反复进入的crash
展示相机预览的控件PreviewView尚未添加到视图Tree的时候,如果执行CameraX的绑定操作的话,会发生问题。现象上表现为拍摄画面结束后再次打开的时候会发生Crash。解决思路很简单:监听PreviewView控件的attach时机,在attach成功的回调里才执行CameraX的绑定操作。
override fun onCreate(savedInstanceState: Bundle?) { ... setContentView(binding.root) startCameraWhenAttached() } private fun startCameraWhenAttached() { binding.previewView.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener{ override fun onViewAttachedToWindow(v: View?) { ensureCameraPermission() } }) } private fun ensureCameraPermission() { ... setupCamera(binding.previewView) }
记得在画面不可见的时候结束图像分析的调用,节省内存。
override fun onStop() { super.onStop() mImageAnalysis?.clearAnalyzer() }
5.2 连续点击录屏的crash抑制
快速点击视频录制和停止的情况下偶尔会发生如下的crash。
java.lang.IllegalStateException: Failed to stop the muxer
看了CameraX的源码,录制开始和结束时Audio实例的请求和释放发生了错乱。本此改进加入了录制视频的状态控制,在录制开始的500ms内禁止终止录制,以缓解这种现象。
但在极快的录制和停止的反复操作下,录制的部分文件可能会发生损坏。由于CameraX的视频录制API仍处在实验性阶段,所以耐心等待CameraX的解决吧。
private fun videoRecordingPrepared() { isCameraXHandling = false // Keep disabled status for a while to avoid fast click error with "Muxer stop failed!". binding.capture.postDelayed({ binding.capture.isEnabled = true }, 500) }
5.3 拍摄的镜像反转
CameraX拍摄的照片默认是镜像的,在拍摄前告知CameraX做下镜像反转,做到所见即所得。
private fun takenPictureInternal(isExternal: Boolean) { ... // Mirror image ImageCapture.Metadata().apply { isReversedHorizontal = true } mImageCapture?.takePicture(outputFileOptions, lightExecutor, MyCaptureCallback(picCount, this)) }
5.4 选择指定摄像头
很多设备的前后并不止一个镜头,比如疫情期间非常流行的安全码和体温一体化检测设备。所以有时候镜头切换不能是简单地前后切换,而需要按镜头的ID指定切换。
private fun bindPreview(...) { // Select specified camera. val cameraSelector = CameraSelector.Builder().addCameraFilter(AllCameraFilter()).build() ... } class AllCameraFilter: CameraFilter { override fun filter(cameraInfos: MutableList<CameraInfo>): MutableList<CameraInfo> { val result: MutableList<CameraInfo> = mutableListOf() for (cameraInfo in cameraInfos) { val id = (cameraInfo as CameraInfoInternal).cameraId // Specify the camera id that U need, such as front camera which id is 0. if (CameraSelector.LENS_FACING_FRONT.equals(id)) { result.add(cameraInfo) } } return result } }
实际上CameraX最新版提供了新API(CameraInfo#getCameraSelector()),可返回某镜头对应的选择器实例。
6. 相关API总结
整理一下CameraX使用的主要API,供大家快速查阅。
以及ScanKit的部分API:
API | 作用 |
ScanUtil | Bitmap扫描码模式、压缩Bitmap等功能支持的工具类 |
HmsScanAnalyzerOptions | 指定扫码格式等参数类 |
HmsScan | 扫码结果封装类,包括内容、码体坐标、四角位置等信息 |
结语
华为ScanKit的集成还是非常简单流畅的,在扫码技术选型的时候可以大胆尝试一下。对于识别率或速度担心的朋友可以下载ScanKit和Zxing的官方Apk进行体验和对比。
Scankit官方Sample下载地址:https://developer.huawei.com/consumer/en/doc/development/HMS-Examples/scan-sample-code4
Zxing官方Sample下载地址:https://play.google.com/store/apps/details?id=com.google.zxing.client.android
希望针对CameraX的详细集成和持续的实用改进,能够帮助到大家。
本文DEMO
https://github.com/ellisonchan/JetpackDemo