CameraX + 华为ScanKit:二维码扫描的终极解决方案

本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!前端

年初写了一篇CameraX的使用文章,帮到了一些朋友,也收到了一些建议。正值最近了解到华为ScanKit在扫码场景下的优秀表现,决定集成该方案,并进行一些功能改进。java

以前作的Demo略显简陋,本次改进也对UI进行了调整。主要是给顶部操做栏添加了半透明背景,同时给切换按钮添加了半透明边框以提升对比度。另外对拍摄和录制场景的一些配色作了改动。android

12-widget

1. 华为ScanKit是什么

ScanKit能够提供便捷的二维码与条形码扫描、解析、生成能力,帮助您快速构建应用内的扫码功能。git

它拥有诸多优点,包括支持多达13种码格式,在反光、污损、畸变、模糊等复杂场景下亦能良好识别,在远距离扫码的状况下能自适应放大码体,还支持多码识别功能等等。github

ScanKit给开发者提供了四种集成模式,包括固定扫码界面的Default View Mode,自定义扫码界面的Customized View Mode,以及彻底由开发者自定义画面和扫码流程的Bitmap ModeMultiProcessor Mode后端

前两种模式的扫码流程均由ScanKit控制,其内部采用Camera1实现。若是要集成到CameraX上的话,只能选择后两种模式。MultiProcessor Mode适用于多码识别的场景,本次先集成单码识别的Bitmap Mode数组

华为ScanKit更加详细的资料可查阅官网:安全

developer.huawei.com/consumer/cn…微信

以及易冬大神的完整演示:markdown

juejin.cn/post/696789…

2. 扫码方案的选择

以前的扫码方案采用的是Zxing,本次集成ScanKit以后,为了对比学习将Zxing也进行了保留。在点击扫码按钮以后,底部会弹出扫码方案的选择Fragment,选择以后经过ViewModel将对应的方案告知CameraX的ImageAnalysis。

12-widget

※ Google ML Kit是一个更为强大的OCR解决方案,后面也将集成进来

你们可能比较关心ScanKit相较于Zxing的优点,能够参考以下这篇测评文章: developer.huawei.com/consumer/cn…

这篇文章里提到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)
    }
}
复制代码
12-widget

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())
            }
            ...
        })
    }
}
复制代码
12-widget

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)
    }
}
复制代码
12-widget

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)
    }
}
复制代码
12-widget

※ 不知道拍摄角度的问题仍是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)
}
复制代码
12-widget

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)
}
复制代码
12-widget

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)
}
复制代码
12-widget

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,供你们快速查阅。

管理相机实例的接口或实现 做用
CameraController 获取和管理相机实例的接口
LifecycleCameraController 经过LifecycleOwner实现生命周期管理Camera实例的接口
ProcessCameraProvider LifecycleOwner的实现类,用以单例模式管理Camera实例
访问镜头功能和属性的API 做用
Camera 提供镜头操做的主要接口
CameraControl 用以执行镜头缩放、聚焦等操做的接口,经过Camera接口获取实例
CameraInfo 用以获取镜头参数的IF,好比缩放比率、是否有闪光灯等,其实例一样由Camera接口提供
CameraConfig 用以获取Camera使用配置信息的接口,也经过Camera接口获取实例
CameraSelector 过滤并匹配对应镜头的类,在CameraController执行的时候传入实例以初始化对应的镜头
场景UseCase类的实现类 做用
Preview 预览场景
ImageAnalysis 图像分析
ImageCapture 图像拍摄
VideoCapture 视频录制
相机效果的扩展类 做用
PreviewExtender 展现预览扩展效果,实现类有美颜的BeautyPreviewExtender、夜拍的NightPreviewExtender等
ImageCaptureExtender 展现拍摄扩展效果,一样有美颜等效果的实现类

以及ScanKit的部分API:

API 做用
ScanUtil Bitmap扫描码模式、压缩Bitmap等功能支持的工具类
HmsScanAnalyzerOptions 指定扫码格式等参数类
HmsScan 扫码结果封装类,包括内容、码体坐标、四角位置等信息

结语

华为ScanKit的集成仍是很是简单流畅的,在扫码技术选型的时候能够大胆尝试一下。对于识别率或速度担忧的朋友能够下载ScanKitZxing的官方Apk进行体验和对比。

Scankit官方Sample下载地址:

developer.huawei.com/consumer/en…

Zxing官方Sample下载地址:

play.google.com/store/apps/…

但愿针对CameraX的扫码集成和实用的改进,对你们有所帮助。

本文DEMO

github.com/ellisonchan…

参考资料

华为官方文档

完美替代ZXing,统一扫码服务

Android CameraX使用入门

推荐阅读

为何推荐使用Jetpack CameraX?

从Preference组件的更迭看Jetpack的前世此生

Android 12上面目一新的小组件:美观、便捷和实用

Android 12上全新的应用启动画面,还不适配一下?

相关文章
相关标签/搜索