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
相关产品推荐
相关产品推荐

