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

Android Kotlin中Bitmap转换时的内存泄漏问题及解决

Android内存泄漏修复:ImageProxy转Bitmap在Jetpack Compose中的高效显示

问题背景

通过Profiler检测到本地内存随时间高速增长,定位到ImageProxy转Bitmap过程中Bitmap未正确销毁,同时bitmap?.asImageBitmap()转换后的ImageBitmap也可能存在未及时释放的问题,最终需要在Jetpack Compose的Canvas上显示相机帧。

原转换代码

/**
 * Utils functions for bitmap conversions.
 */
object BitmapUtils {
    /**
     * Converts NV21 format byte buffer to bitmap.
     */
    fun getBitmap(data: ByteBuffer, metadata: FrameMetadata, isFlip: Boolean): Bitmap? {
        data.rewind()
        val imageInBuffer = ByteArray(data.limit())
        data[imageInBuffer, 0, imageInBuffer.size]
        try {
            val image = YuvImage(
                imageInBuffer, ImageFormat.NV21, metadata.width, metadata.height, null
            )
            val stream = ByteArrayOutputStream()
            image.compressToJpeg(Rect(0, 0, metadata.width, metadata.height), 80, stream)
            val bmp = BitmapFactory.decodeByteArray(stream.toByteArray(), 0, stream.size())
            stream.close()
            return rotateBitmap(bmp, metadata.rotation, flipX = isFlip, flipY = false)
        } catch (e: Exception) {
            Log.e("VisionProcessorBase", "Error: " + e.message)
        }
        return null
    }

    /**
     * Converts a YUV_420_888 image from CameraX API to a bitmap.
     */
    @ExperimentalGetImage
    fun getBitmap(image: ImageProxy, isFlip: Boolean): Bitmap? {
        val frameMetadata: FrameMetadata = FrameMetadata.Builder()
            .setWidth(image.width)
            .setHeight(image.height)
            .setRotation(image.imageInfo.rotationDegrees)
            .build()
        val nv21Buffer = yuv420ThreePlanesToNV21(
            image.image!!.planes, image.width, image.height
        )
        return getBitmap(nv21Buffer, frameMetadata, isFlip)
    }

    /**
     * Rotates a bitmap if it is converted from a bytebuffer.
     */
    private fun rotateBitmap(
        bitmap: Bitmap,
        rotationDegrees: Int,
        flipX: Boolean,
        flipY: Boolean
    ): Bitmap {
        val matrix = Matrix()

        // Rotate the image back to straight.
        matrix.postRotate(rotationDegrees.toFloat())

        // Mirror the image along the X or Y axis.
        matrix.postScale(if (flipX) -1.0f else 1.0f, if (flipY) -1.0f else 1.0f)
        val rotatedBitmap =
            Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)

        // Recycle the old bitmap if it has changed.
        if (rotatedBitmap != bitmap) {
            bitmap.recycle()
        }
        return rotatedBitmap
    }

    /**
     * Converts YUV_420_888 to NV21 bytebuffer.
     *
     *
     * The NV21 format consists of a single byte array containing the Y, U and V values. For an
     * image of size S, the first S positions of the array contain all the Y values. The remaining
     * positions contain interleaved V and U values. U and V are subSampled by a factor of 2 in both
     * dimensions, so there are S/4 U values and S/4 V values. In summary, the NV21 array will contain
     * S Y values followed by S/4 VU values: YYYYYYYYYYYYYY(...)YVUVUVUVU(...)VU
     *
     *
     * YUV_420_888 is a generic format that can describe any YUV image where U and V are subSampled
     * by a factor of 2 in both dimensions. [Image.getPlanes returns an array with the Y, U and
     * V planes. The Y plane is guaranteed not to be interleaved, so we can just copy its values into
     * the first part of the NV21 array. The U and V planes may already have the representation in the
     * NV21 format. This happens if the planes share the same buffer, the V buffer is one position
     * before the U buffer and the planes have a pixelStride of 2. If this is case, we can just copy
     * them to the NV21 array.
     */
    private fun yuv420ThreePlanesToNV21(
        yuv420888planes: Array<Plane>, width: Int, height: Int
    ): ByteBuffer {
        val imageSize = width * height
        val out = ByteArray(imageSize + 2 * (imageSize / 4))
        if (areUVPlanesNV21(yuv420888planes, width, height)) {
            // Copy the Y values.
            yuv420888planes[0].buffer[out, 0, imageSize]
            val uBuffer = yuv420888planes[1].buffer
            val vBuffer = yuv420888planes[2].buffer
            // Get the first V value from the V buffer, since the U buffer does not contain it.
            vBuffer[out, imageSize, 1]
            // Copy the first U value and the remaining VU values from the U buffer.
            uBuffer[out, imageSize + 1, 2 * imageSize / 4 - 1]
        } else {
            // Fallback to copying the UV values one by one, which is slower but also works.
            // Unpack Y.
            unpackPlane(
                yuv420888planes[0], width, height, out, 0, 1
            )
            // Unpack U.
            unpackPlane(
                yuv420888planes[1], width, height, out, imageSize + 1, 2
            )
            // Unpack V.
            unpackPlane(
                yuv420888planes[2], width, height, out, imageSize, 2
            )
        }
        return ByteBuffer.wrap(out)
    }

    /**
     * Checks if the UV plane buffers of a YUV_420_888 image are in the NV21 format.
     */
    private fun areUVPlanesNV21(planes: Array<Plane>, width: Int, height: Int): Boolean {
        val imageSize = width * height
        val uBuffer = planes[1].buffer
        val vBuffer = planes[2].buffer

        // Backup buffer properties.
        val vBufferPosition = vBuffer.position()
        val uBufferLimit = uBuffer.limit()

        // Advance the V buffer by 1 byte, since the U buffer will not contain the first V value.
        vBuffer.position(vBufferPosition + 1)
        // Chop off the last byte of the U buffer, since the V buffer will not contain the last U value.
        uBuffer.limit(uBufferLimit - 1)

        // Check that the buffers are equal and have the expected number of elements.
        val areNV21 =
            vBuffer.remaining() == 2 * imageSize / 4 - 2 && vBuffer.compareTo(uBuffer) == 0

        // Restore buffers to their initial state.
        vBuffer.position(vBufferPosition)
        uBuffer.limit(uBufferLimit)
        return areNV21
    }

    /**
     * Unpack an image plane into a byte array.
     *
     *
     * The input plane data will be copied in 'out', starting at 'offset' and every pixel will be
     * spaced by 'pixelStride'. Note that there is no row padding on the output.
     */
    private fun unpackPlane(
        plane: Plane, width: Int, height: Int, out: ByteArray, offset: Int, pixelStride: Int
    ) {
        val buffer = plane.buffer
        buffer.rewind()

        // Compute the size of the current plane.
        // We assume that it has the aspect ratio as the original image.
        val numRow = (buffer.limit() + plane.rowStride - 1) / plane.rowStride
        if (numRow == 0) {
            return
        }
        val scaleFactor = height / numRow
        val numCol = width / scaleFactor

        // Extract the data in the output buffer.
        var outputPos = offset
        var rowStart = 0
        for (row in 0 until numRow) {
            var inputPos = rowStart
            for (col in 0 until numCol) {
                out[outputPos] = buffer[inputPos]
                outputPos += pixelStride
                inputPos += plane.pixelStride
            }
            rowStart += plane.rowStride
        }
    }
}

解决方案

1. 修复核心内存泄漏点

关键问题:ImageProxy未关闭

CameraX的ImageProxy必须手动调用close(),否则底层硬件图像缓冲区不会被回收,这是内存泄漏的核心原因之一。修改getBitmap(image: ImageProxy)方法,使用use块自动关闭:

@ExperimentalGetImage
fun getBitmap(image: ImageProxy, isFlip: Boolean): Bitmap? {
    return image.use { img -> // use块自动关闭ImageProxy
        val frameMetadata = FrameMetadata.Builder()
            .setWidth(img.width)
            .setHeight(img.height)
            .setRotation(img.imageInfo.rotationDegrees)
            .build()
        val nv21Buffer = yuv420ThreePlanesToNV21(
            img.image!!.planes, img.width, img.height
        )
        getBitmap(nv21Buffer, frameMetadata, isFlip)
    }
}

优化Bitmap转换流程(避免JPEG压缩的内存浪费)

原代码中将YUV转成JPEG再解码为Bitmap,不仅损失画质,还生成大量中间字节数组,加剧内存压力。替换为直接将NV21数据转换为ARGB Bitmap:

fun getBitmap(data: ByteBuffer, metadata: FrameMetadata, isFlip: Boolean): Bitmap? {
    data.rewind()
    val imageInBuffer = ByteArray(data.limit())
    data[imageInBuffer, 0, imageInBuffer.size]

    return try {
        val bitmap = Bitmap.createBitmap(metadata.width, metadata.height, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)
        val paint = Paint()

        // 使用YuvImage直接绘制到Bitmap,跳过JPEG压缩
        val yuvImage = YuvImage(
            imageInBuffer, ImageFormat.NV21, metadata.width, metadata.height, null
        )
        yuvImage.draw(canvas, Rect(0, 0, metadata.width, metadata.height), paint)

        rotateBitmap(bitmap, metadata.rotation, flipX = isFlip, flipY = false)
    } catch (e: Exception) {
        Log.e("BitmapUtils", "Conversion failed: ${e.message}")
        null
    }
}

2. 正确处理ImageBitmap的生命周期

在Compose中,asImageBitmap()生成的ImageBitmap需要手动调用dispose()释放内存,尤其在频繁更新帧的场景下:

// 在Compose状态中持有ImageBitmap
var cameraImage by remember { mutableStateOf<ImageBitmap?>(null) }

// 当新Bitmap到来时,先释放旧的ImageBitmap
fun updateCameraImage(newBitmap: Bitmap) {
    cameraImage?.dispose() // 释放旧资源
    cameraImage = newBitmap.asImageBitmap()
    newBitmap.recycle() // 转换完成后回收原Bitmap
}

// 在Canvas中绘制
Canvas(modifier = Modifier.fillMaxSize()) {
    cameraImage?.let { img ->
        drawImage(img, size = Size(width, height))
    }
}

// 在组件销毁时释放资源
DisposableEffect(Unit) {
    onDispose {
        cameraImage?.dispose()
    }
}

3. 更高效的Compose显示方案:跳过Bitmap转换

直接将ImageProxy的YUV数据转换为Compose的ImageBitmap,避免中间Bitmap对象:

@ExperimentalGetImage
fun ImageProxy.toImageBitmap(isFlip: Boolean): ImageBitmap? {
    return use { img ->
        val width = img.width
        val height = img.height
        val rotation = img.imageInfo.rotationDegrees

        // 直接将YUV_420_888转换为ImageBitmap
        val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
        val canvas = Canvas(bitmap)
        val yuvImage = YuvImage(
            yuv420ThreePlanesToNV21(img.image!!.planes, width, height).array(),
            ImageFormat.NV21, width, height, null
        )
        yuvImage.draw(canvas, Rect(0, 0, width, height), Paint())

        val rotatedBitmap = rotateBitmap(bitmap, rotation, isFlip, false)
        val imageBitmap = rotatedBitmap.asImageBitmap()
        
        // 回收中间Bitmap
        bitmap.recycle()
        rotatedBitmap.takeIf { it != bitmap }?.recycle()
        
        imageBitmap
    }
}

// 使用方式
fun analyzeImage(imageProxy: ImageProxy) {
    val imageBitmap = imageProxy.toImageBitmap(isFlip = true)
    // 更新Compose状态
    updateCameraImage(imageBitmap)
}

额外优化建议

  • 使用Bitmap.Config.RGB_565替代ARGB_8888,减少一半内存占用(如果不需要透明通道)。
  • 对相机帧进行降采样,缩小Bitmap尺寸,降低内存压力。
  • 使用rememberUpdatedState或LaunchedEffect管理帧更新的协程,避免频繁创建对象。

内容的提问来源于stack exchange,提问作者Al amin Kawsar

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.11 06:02:01