MENU

【代码札记】从零开始的安卓应用开发 - P4

May 2, 2023 • 瞎折腾

哈哈,没想到吧,竟然还有续集!其实我也没想到。本文介绍我如何为我的安卓APP追加扫码功能。

背景

本来上一篇就完结了,APP也写完了,目前也没有需要追加的功能了。但是在实际使用中发现,使用Google的条码扫描器,经常会出现Google Play Service自废武功的情况。为了解决这个情况我真是新买了一台三星S23+,但情况还是没有改善。

这部分的故事是这样的。我原本使用的是红米K40 Pro,使用了MIUI EU版本的系统,这个是一群票友基于中国版MIUI固件,缝合了谷歌全家桶,并删减了一些臃肿的小米框架得到的第三方ROM。在此基础上我还进行了Root,并安装了一些Magisk模块,例如存储空间隔离等。总之是一个非常客制化的一个环境。

在进行长期测试时我发现Google的条码扫描服务经常在一段时间之后变得不可用。具体表现就是抛出com.google.mlkit.common.MlKitException: Failed to scan code这样的异常。经过一番搜索,我发现清空Google Play Service的数据能够缓解这个问题——因为扫描服务是按需下载的,清空数据之后要重新下载扫描服务,当然就可以恢复正常。但是过一段时间(一天)又会抛出异常,还得再清空,就很烦。

于是我开始搜索详细原因。在Google官方的一个Issue tracker上我看到有一个人说关于Google Play Service版本的问题,我查了一下,我这个好像不是最新版,于是就想办法升级。但是升级过程中我发现Play商店总是失败——并不是那么明显的失败,而是点了升级按钮之后,短暂读条,然后立刻又变回去了。我尝试过单独下载最新版的Google Play Service的APK,手动安装,但是系统提示我签名不一样。我寻思,这不对啊,都是谷歌的APK,怎么会签名不对呢?于是我又回到Google Play商店开始点升级按钮。

按照以往的经验,有时候Play商店会犯傻,因为在国内需要使用代理访问,所以Play商店会误判无网络,从而导致安装失败。于是我开始不停的点升级按钮,然后到了某一刻,开始读条之后没有退出,我以为是Play商店想明白了。过了一段时间(指看了一会儿YouTube),我也没有检查商店页面有没有升级好(默认情况下在Play商店是搜不到Google Play Service的),就直接回到我的APP调用了扫码服务。

结果这一下手机就卡死了,因为我用的是全面屏手势导航,所以界面也不响应我的手势。我想,那重启一下应该就没问题了吧。结果这一个电源键按下去,它就再也不启动了。具体表现是就是开始会过logo动画,过完动画之后就卡死在这里了,不进入桌面。但是指纹传感器会有震动反馈。然而屏幕不响应触控操作,重启之后需要键盘才能解锁。理论上我可以尝试插入一个usb键盘,但我觉得希望不大。所以就直接刷机了。四清了四五次,这手机才重新见到桌面。

关于这个过程,我猜测有如下几种可能:

  • 票友缝合的谷歌框架有问题,因为刷机之后我查询Google Play Service的安装时间,它反馈是2009年,相比之下,三星系统的安装时间显示的是2023年;与此同时,我从网络上下载的APK提示签名不符,这其中肯定是有修改
  • 系统在Root之后影响了Play商店的更新,导致更新时把系统搞坏了
  • 我安装的Magisk模块干扰了Play商店的更新,但具体是什么模块不清楚

总之我决定不再使用票友制作的ROM了。一方面是小米的系统太臃肿了,基于中国版ROM,但又缺乏中国版ROM的特性(例如公交卡、钱包等),有Google框架,但没有认证级别(原版MIUI据说有,但是很明显,经过EU修改之后就没了)。总之就是不如原生的体验。所以左思右想,换了三星S23+,关于这个设备,在我使用一段时间之后将会写文章和大家分享感受。

API设计

总之,既然没有办法绕开这个Bug,就只能自己实现扫码功能了。在开始之前,我们先看看谷歌这个库的API设计:

val scanner = GmsBarcodeScanning.getClient(
    this, GmsBarcodeScannerOptions.Builder()
        .setBarcodeFormats(
            Barcode.FORMAT_DATA_MATRIX,
            Barcode.FORMAT_CODE_128
        ).build()
)
scanner.startScan()
    .addOnSuccessListener { barcode ->
        // use the barcode
    }
    .addOnCanceledListener { showToast("Scan canceled") }
    .addOnFailureListener { e -> showToast("Scan failed: ${e.message}") }

首先需要创建一个Client,这个Client在创建的时机上并没有要求,你可以随用随创建,也可以在一开始创建,创建完保存在ViewModel里供以后调用。创建完Client之后就可以发起扫码请求了。发起请求后需要添加三个回调。成功的回调将会在扫码成功后调用,参数是扫出来的二维码。取消则是用户通过返回按钮退出了扫码页面,而失败则很明显,是捕获异常用的。

很明显,Google应该是用了什么高深的生命周期之类的东西。对于一般的拉起Activity并获取返回数据的话,需要使用registerForActivityResult并配合ActivityResultContracts.StartActivityForResult()来使用。同时安卓对于注册回调的时机也有要求,并不能做到像Google那样随用随创建。所以没办法只简单封装registerForActivityResult来进行调用。

UI

总之,在想明白API怎么设计之前,不如先从简单一些的UI下手。扫码界面的UI非常简单:

  • 最底层是相机预览
  • 上层是两个按钮,一个控制闪光灯,一个控制缩放

而谷歌官方推荐使用CameraX这个库配合ML Kit使用,可以避免许多样板代码。

CameraX主要提供两种控制方式,一种是CameraController,一种是CameraProvider。前者旨在使用少量代码就能提供完整的相机功能;而后者旨在提供较多的控制选项。很明显,前者更适合扫码——你总不会在预期让用户在扫码的时候手动调整对焦和曝光吧?CameraController提供了一些自动控制的功能,例如在Preview上点击以进行对焦,还有双指缩放来控制相机的缩放等。配合PreviewViewMlKitAnalyzer,我们可以完成预览和扫码这个需求。

UI部分还是和前几篇一样使用Compose。最底层使用了AndroidView来提供不兼容Compose的PreviewView的对象,随后使用ColumnRow在屏幕下方绘制按钮,用来控制闪关灯和缩放。在创建UI之前,我们需要先处理好相机的部分:

        // setup camera controller
        val cameraController = LifecycleCameraController(this)
        cameraController.bindToLifecycle(this)
        cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
        cameraController.isTapToFocusEnabled = true
        cameraController.isPinchToZoomEnabled = true

        // setup camera preview
        val previewView = PreviewView(this)
        previewView.controller = cameraController
        previewView.scaleType = PreviewView.ScaleType.FILL_CENTER

这里我们需要在Compose外面创建好View对象,因为这些对象并不需要每次都重新创建。首先是CameraController,这里允许点击对焦和双指缩放,并要求使用后置摄像头进行扫码。然后创建一个PreviewView来展示相机的预览。这里的缩放默认为FILL_CENTER,也就是让相机画面填满屏幕并居中。这样一来相机画面的周围部分就会被裁剪,如果希望显示完整,那么可以之后通过按钮切换。这里我们刚刚设置好了预览的部分,接下来说说图像分析,也就是扫码的部分:

        // setup the scanner
        cameraController.setImageAnalysisAnalyzer(
            ContextCompat.getMainExecutor(this),
            MlKitAnalyzer(
                listOf(barcodeScanner),
                COORDINATE_SYSTEM_VIEW_REFERENCED,
                ContextCompat.getMainExecutor(this)
            ) { result ->
                val centerX = previewView.width / 2
                val centerY = previewView.height / 2
                // select the center one
                val list = result.getValue(barcodeScanner)?.sortedBy {
                    val dx = centerX - (it.boundingBox?.centerX() ?: 0)
                    val dy = centerY - (it.boundingBox?.centerY() ?: 0)
                    dx * dx + dy * dy
                } ?: emptyList()
                val barcode = list.firstNotNullOfOrNull { it.rawValue }
                if (barcode != null) {
                    setResult(RESULT_OK, Intent().apply { putExtra("barcode", barcode) })
                    finish()
                }
            }
        )

这里使用了MlKitAnalyzer来提供CameraX需要的图像分析器对象。如果你看ML Kit条码扫描部分的文档,你会发现Google并没有告诉你这种使用方法,而是手动通过传递参数来实现的。而MlKitAnalyzer可以省略这部分,自动从CameraX获取需要的信息,并且它能保证你获取到的数据和Preview的坐标是相对应的。

与Google的条码扫描器不同,ML Kit会返回一组条码,但我的应用只需要一个条码。因此我这里采用的策略是选择与屏幕中心距离最近的一个。由于条码的坐标和Preview的坐标是相对应的,因此中心坐标就可以通过previewView的宽和高计算得出,进而实现选择。如果最终选择出来的条码不为空,那么获取它的字符串值传递给调用者就好了。

解决了页面逻辑,下面简单说说渲染:

    AndroidView(
        factory = { previewView }, modifier = Modifier.fillMaxSize(),
    )
    var torchStatus by remember { mutableStateOf(false) }
    var fillStatus by remember { mutableStateOf(true) }

    Column(
        verticalArrangement = Arrangement.Bottom,
        horizontalAlignment = Alignment.CenterHorizontally,
        modifier = Modifier.fillMaxSize()
    ) {
        Row(
            verticalAlignment = Alignment.CenterVertically,
            horizontalArrangement = Arrangement.SpaceEvenly,
            modifier = Modifier.fillMaxWidth()
        ) {
            if (torchStatus) {
                IconButton(onClick = {
                    cameraController.enableTorch(false)
                    torchStatus = false
                }) {
                    Icon(
                        imageVector = Icons.Default.FlashOn,
                        contentDescription = "turn off torch"
                    )
                }
            } else {
                IconButton(onClick = {
                    cameraController.enableTorch(true)
                    torchStatus = true
                }) {
                    Icon(
                        imageVector = Icons.Default.FlashOff,
                        contentDescription = "turn on torch"
                    )
                }
            }
            if (fillStatus) {
                IconButton(onClick = {
                    previewView.scaleType = PreviewView.ScaleType.FIT_CENTER
                    fillStatus = false
                }) {
                    Icon(
                        imageVector = Icons.Default.ZoomInMap,
                        contentDescription = "switch to fill"
                    )
                }
            } else {
                IconButton(onClick = {
                    previewView.scaleType = PreviewView.ScaleType.FILL_CENTER
                    fillStatus = true
                }) {
                    Icon(
                        imageVector = Icons.Default.ZoomOutMap,
                        contentDescription = "switch to fit"
                    )
                }
            }
        }
    }

这里关于预览就不多说了,主要说说按钮。按钮部分,每个按钮都有两种状态。对于闪光灯来说,图标总是表示闪光灯当前的状态,并在点击后切换到相反状态。有一些APP则是表示闪光灯在按下之后的状态,并在点击后切换到这个状态。我觉得这个就看个人吧,我更喜欢展示当前状态一些。另一个是控制预览缩放。默认情况下是填充屏幕,而此时的图标是缩小,点击后就会变成适应屏幕,也就是通过缩放将画面呈现在屏幕中;而在适应屏幕时,图标变成放大,表示通过缩放让预览充满屏幕。

调用方式

因为我在安卓方面的经验不够丰富,了解的也不多。作为个人项目来说,我的主要目标是能用。所以我设计了一种比较简单的交互方式。

因为要处理权限问题,所以我之前引入了一个VazanActivity,通过子类调用super.onCreate来帮助子类请求权限。因此对于registerForActivityResult的调用也可以放在这里。但问题在于,这个调用注册的回调是固定的,没办法在运行时修改。不过这也好办,搞一个队列就是了:

    private val scannerCallbackQueue = ConcurrentLinkedQueue<Pair<(String) -> Unit, () -> Unit>>()

    private val scannerLauncher =
        registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
            val (onSuccess, onFailed) = scannerCallbackQueue.poll() ?: error("Missing callback")
            val barcode = it.data?.getStringExtra("barcode")
            if (it.resultCode == RESULT_OK && barcode != null)
                onSuccess(barcode)
            else onFailed()
        }

这里我采用了简化的设计,回调一共就两种,一种是成功,一种是取消或者失败,总之前者表示有结果,后者表示没有结果。调用时我们先把回调放入队列,然后拉起Activity进行扫码。扫码的Activity退出之后就会执行到我们注册的回调。如果返回值为成功,并且有条码结果,则调用成功的回调;如果没有,则调用失败的回调。

    protected fun scanBarcode(onSuccess: (String) -> Unit, onFailed: () -> Unit) =
        synchronized(scannerCallbackQueue) {
            scannerCallbackQueue.offer(onSuccess to onFailed)
            scannerLauncher.launch(intent(ScannerActivity::class))
        }

这里我对扫码操作的调用加了锁。严格来说不加锁也没问题,无论扫码的Activity怎么乱序启动,最终处理回调的时候保证先进先出就可以了。如果更严谨一点的话,可以使用Object的await和notifyAll来确保队列中等待处理的回调至多只有一个。很遗憾,我没有那么严谨,我觉得加个锁就差不多了。

至于迁移,也很好办。由于不需要创建扫码Client的,因此可以直接删掉这部分代码,对于扫码的调用,可以直接换成scanBarcode。在回调方面,成功的回调几乎不变,而失败的回调作为取消处理,不考虑异常捕捉的回调。

至此,我觉得这一系列应该真的完结了吧。

-全文完-


知识共享许可协议
【代码札记】从零开始的安卓应用开发 - P4天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code