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上全新的應用啓動畫面,還不適配一下?

相關文章
相關標籤/搜索