You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Android Jetpack Compose中ZXing二维码扫描框绘制问题求助

Jetpack Compose二维码扫描器边框问题修复指南

问题描述

我用Android Jetpack Compose开发二维码扫描器,扫描读取功能正常,但给识别到的二维码绘制红色边框时遇到两个问题:

  • 红色边框高度正常,但宽度过窄
  • 移动手机时边框无法锁定二维码,出现偏移

仅角度完美时边框才与二维码对齐,且尺寸不符

原代码实现

CameraPreview可组合函数

@Composable
fun CameraPreview() {
    val context = LocalContext.current
    val scanner = MultiFormatReader().apply {
        val hints: MutableMap<DecodeHintType, Any> = EnumMap(DecodeHintType::class.java)
        hints[DecodeHintType.POSSIBLE_FORMATS] = listOf(BarcodeFormat.QR_CODE)
        setHints(hints)
    }

    val qrCodeBounds = remember { mutableStateOf<Rect?>(null) }

    BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
        val screenWidth = maxWidth
        val scanBoxSize = screenWidth * 0.6f // 调整扫描区域大小

        AndroidView(
            factory = { ctx ->
                val previewView = PreviewView(ctx)
                val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
                cameraProviderFuture.addListener({
                    val cameraProvider = cameraProviderFuture.get()
                    val preview = Preview.Builder().build().also {
                        it.setSurfaceProvider(previewView.surfaceProvider)
                    }
                    val imageAnalysis = ImageAnalysis.Builder()
                        .build()
                        .also {
                            it.setAnalyzer(ContextCompat.getMainExecutor(ctx)) { imageProxy ->
                                val result = scanQRCode(imageProxy, scanner)
                                imageProxy.close()
                                if (result != null) {
                                    println("QR Code found: ${result.text}")
                                    println("Image Proxy Width: ${imageProxy.width}, Height: ${imageProxy.height}")
                                    qrCodeBounds.value = getBoundingBox(result.resultPoints, imageProxy.width, imageProxy.height, previewView.width, previewView.height)
                                } else {
                                    qrCodeBounds.value = null
                                }
                            }
                        }

                    try {
                        cameraProvider.unbindAll()
                        cameraProvider.bindToLifecycle(
                            context as ComponentActivity,
                            CameraSelector.DEFAULT_BACK_CAMERA,
                            preview,
                            imageAnalysis
                        )
                    } catch (exc: Exception) {
                        // 异常处理
                    }
                }, ContextCompat.getMainExecutor(ctx))
                previewView
            },
            modifier = Modifier.fillMaxSize()
        )

        Box(modifier = Modifier
            .matchParentSize()
            .background(Color.Black.copy(alpha = 0.8f))
        )

        // 带镂空效果的扫描区域框
        Box(
            modifier = Modifier
                .size(scanBoxSize)
                .align(Alignment.Center)
                .drawBehind {
                    drawRoundRect(
                        color = Color.Transparent,
                        topLeft = Offset(0f, 0f),
                        size = Size(size.width, size.height),
                        cornerRadius = CornerRadius(x = 12.dp.toPx(), y = 12.dp.toPx()),
                        blendMode = BlendMode.Clear
                    )
                }
                .border(2.dp, MaterialTheme.colorScheme.onPrimary, RoundedCornerShape(12.dp))
        )

        qrCodeBounds.value?.let { bounds ->
            Canvas(modifier = Modifier.fillMaxSize()) {
                drawRect(
                    color = Color.Red,
                    topLeft = Offset(bounds.left.toFloat(), bounds.top.toFloat()),
                    size = Size(bounds.width().toFloat(), bounds.height().toFloat()),
                    style = Stroke(width = 3.dp.toPx())
                )
            }
        }
    }
}

getBoundingBox函数

private fun getBoundingBox(resultPoints: Array<ResultPoint>?, imageWidth: Int, imageHeight: Int, previewWidth: Int, previewHeight: Int): Rect? {
    if (resultPoints == null || resultPoints.size != 4) {
        return null
    }

    // 计算缩放比例
    val scaleX = previewWidth.toFloat() / imageWidth
    val scaleY = previewHeight.toFloat() / imageHeight

    // 应用缩放比例到坐标
    val left = (resultPoints[0].x * scaleX).toInt()
    val top = (resultPoints[0].y * scaleY).toInt()
    val right = (resultPoints[2].x * scaleX).toInt()
    val bottom = (resultPoints[2].y * scaleY).toInt()

    return Rect(left, top, right, bottom)
}

scanQRCode函数

private fun scanQRCode(imageProxy: ImageProxy, scanner: MultiFormatReader): Result? {
    val data = imageProxy.planes[0].buffer.let { buffer ->
        val data = ByteArray(buffer.capacity())
        buffer.get(data)
        buffer.clear()
        data
    }
    val source = PlanarYUVLuminanceSource(
        data,
        imageProxy.width,
        imageProxy.height,
        0,
        0,
        imageProxy.width,
        imageProxy.height,
        false
    )
    val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
    return try {
        scanner.decodeWithState(binaryBitmap)
    } catch (e: Exception) {
        null // 未识别到二维码
    }
}

问题原因分析

  1. 边框宽度过窄:固定取ResultPoints的第0和第2个点计算边界,无法准确获取二维码真实边界(ResultPoints顺序不固定);未考虑相机旋转角度,导致宽高缩放比例计算错误。
  2. 边框偏移:相机输出图像方向与屏幕显示方向不一致,坐标转换时未做旋转适配;PreviewView缩放类型未明确,导致预览与分析图像适配逻辑不统一。

修复方案

1. 修改scanQRCode函数,处理图像旋转

根据相机旋转角度调整图像方向,确保识别坐标与预览界面匹配:

private fun scanQRCode(imageProxy: ImageProxy, scanner: MultiFormatReader): Result? {
    val rotationDegrees = imageProxy.imageInfo.rotationDegrees
    val data = imageProxy.planes[0].buffer.let { buffer ->
        val data = ByteArray(buffer.capacity())
        buffer.get(data)
        buffer.clear()
        data
    }

    // 根据旋转角度调整亮度源方向
    val source = if (rotationDegrees == 90 || rotationDegrees == 270) {
        PlanarYUVLuminanceSource(
            data,
            imageProxy.width,
            imageProxy.height,
            0,
            0,
            imageProxy.width,
            imageProxy.height,
            false
        ).rotateCounterClockwise()
    } else {
        PlanarYUVLuminanceSource(
            data,
            imageProxy.width,
            imageProxy.height,
            0,
            0,
            imageProxy.width,
            imageProxy.height,
            false
        )
    }

    val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
    return try {
        scanner.decodeWithState(binaryBitmap)
    } catch (e: Exception) {
        null
    }
}

2. 重写getBoundingBox函数,准确计算边界

遍历所有ResultPoint获取真实坐标范围,结合旋转角度和预览尺寸计算正确的缩放与偏移:

private fun getBoundingBox(
    resultPoints: Array<ResultPoint>?,
    imageWidth: Int,
    imageHeight: Int,
    previewWidth: Int,
    previewHeight: Int,
    rotationDegrees: Int
): Rect? {
    if (resultPoints.isNullOrEmpty()) return null

    // 遍历所有点,找到最小/最大坐标
    var minX = Float.MAX_VALUE
    var maxX = Float.MIN_VALUE
    var minY = Float.MAX_VALUE
    var maxY = Float.MIN_VALUE

    resultPoints.forEach { point ->
        minX = min(minX, point.x)
        maxX = max(maxX, point.x)
        minY = min(minY, point.y)
        maxY = max(maxY, point.y)
    }

    // 根据旋转角度调整图像宽高
    val (adjustedImageWidth, adjustedImageHeight) = if (rotationDegrees == 90 || rotationDegrees == 270) {
        imageHeight to imageWidth
    } else {
        imageWidth to imageHeight
    }

    // 计算适配比例(以FILL_CENTER缩放类型为准)
    val scaleX = previewWidth.toFloat() / adjustedImageWidth
    val scaleY = previewHeight.toFloat() / adjustedImageHeight
    val scale = min(scaleX, scaleY)

    // 计算居中显示的偏移量
    val offsetX = (previewWidth - adjustedImageWidth * scale) / 2
    val offsetY = (previewHeight - adjustedImageHeight * scale) / 2

    // 转换坐标并添加偏移
    val left = (minX * scale + offsetX).toInt()
    val top = (minY * scale + offsetY).toInt()
    val right = (maxX * scale + offsetX).toInt()
    val bottom = (maxY * scale + offsetY).toInt()

    return Rect(left, top, right, bottom)
}

3. 修改CameraPreview函数,完善预览配置

明确设置PreviewView缩放类型,并传递旋转角度给getBoundingBox:

@Composable
fun CameraPreview() {
    val context = LocalContext.current
    val scanner = remember {
        MultiFormatReader().apply {
            val hints: MutableMap<DecodeHintType, Any> = EnumMap(DecodeHintType::class.java)
            hints[DecodeHintType.POSSIBLE_FORMATS] = listOf(BarcodeFormat.QR_CODE)
            setHints(hints)
        }
    }

    val qrCodeBounds = remember { mutableStateOf<Rect?>(null) }

    BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
        val screenWidth = maxWidth
        val scanBoxSize = screenWidth * 0.6f

        AndroidView(
            factory = { ctx ->
                val previewView = PreviewView(ctx).apply {
                    // 明确设置缩放类型,确保预览与分析图像适配一致
                    scaleType = PreviewView.ScaleType.FILL_CENTER
                }
                val cameraProviderFuture = ProcessCameraProvider.getInstance(ctx)
                cameraProviderFuture.addListener({
                    val cameraProvider = cameraProviderFuture.get()
                    val preview = Preview.Builder().build().also {
                        it.setSurfaceProvider(previewView.surfaceProvider)
                    }
                    val imageAnalysis = ImageAnalysis.Builder()
                        .build()
                        .also {
                            it.setAnalyzer(ContextCompat.getMainExecutor(ctx)) { imageProxy ->
                                val rotationDegrees = imageProxy.imageInfo.rotationDegrees
                                val result = scanQRCode(imageProxy, scanner)
                                imageProxy.close()
                                if (result != null) {
                                    println("QR Code found: ${result.text}")
                                    qrCodeBounds.value = getBoundingBox(
                                        result.resultPoints,
                                        imageProxy.width,
                                        imageProxy.height,
                                        previewView.width,
                                        previewView.height,
                                        rotationDegrees
                                    )
                                } else {
                                    qrCodeBounds.value = null
                                }
                            }
                        }

                    try {
                        cameraProvider.unbindAll()
                        cameraProvider.bindToLifecycle(
                            context as ComponentActivity,
                            CameraSelector.DEFAULT_BACK_CAMERA,
                            preview,
                            imageAnalysis
                        )
                    } catch (exc: Exception) {
                        exc.printStackTrace()
                    }
                }, ContextCompat.getMainExecutor(ctx))
                previewView
            },
            modifier = Modifier.fillMaxSize()
        )

        // 其余UI代码保持不变
        Box(modifier = Modifier
            .matchParentSize()
            .background(Color.Black.copy(alpha = 0.8f))
        )

        Box(
            modifier = Modifier
                .size(scanBoxSize)
                .align(Alignment.Center)
                .drawBehind {
                    drawRoundRect(
                        color = Color.Transparent,
                        topLeft = Offset(0f, 0f),
                        size = Size(size.width, size.height),
                        cornerRadius = CornerRadius(x = 12.dp.toPx(), y = 12.dp.toPx()),
                        blendMode = BlendMode.Clear
                    )
                }
                .border(2.dp, MaterialTheme.colorScheme.onPrimary, RoundedCornerShape(12.dp))
        )

        qrCodeBounds.value?.let { bounds ->
            Canvas(modifier = Modifier.fillMaxSize()) {
                drawRect(
                    color = Color.Red,
                    topLeft = Offset(bounds.left.toFloat(), bounds.top.toFloat()),
                    size = Size(bounds.width().toFloat(), bounds.height().toFloat()),
                    style = Stroke(width = 3.dp.toPx())
                )
            }
        }
    }
}

额外说明

  • 确保使用最新版本的ZXing库,避免旋转方法缺失问题
  • 若rotateCounterClockwise()报错,可手动实现亮度源旋转逻辑
  • 测试时覆盖不同屏幕尺寸和方向的设备,确保边框适配一致

内容的提问来源于stack exchange,提问作者Hasnain Ali

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.02 06:14:57