哈哈,没想到吧,竟然还有续集!其实我也没想到。本文介绍我如何为我的安卓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上点击以进行对焦,还有双指缩放来控制相机的缩放等。配合PreviewView
和MlKitAnalyzer
,我们可以完成预览和扫码这个需求。
UI部分还是和前几篇一样使用Compose。最底层使用了AndroidView
来提供不兼容Compose的PreviewView
的对象,随后使用Column
和Row
在屏幕下方绘制按钮,用来控制闪关灯和缩放。在创建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 处获得。