8. 内存与带宽优化:减少内存拷贝、使用ImageReader高效获取帧、降低DDR带宽
内存和带宽,说白了就是相机系统的「血管」和「血液」。
我见过太多项目,算法跑得飞快,但一上相机预览,整机功耗直接飙到 2W+。查到最后,问题往往出在内存拷贝上——数据在 CPU、GPU、DSP 之间来回倒腾,DDR 带宽被吃光,CPU 空转等内存。嗯,今天我们就来聊聊怎么把这些「无效搬运」砍掉。
核心思路: 数据流路径上,每减少一次内存拷贝,就能省下约 0.5~1.5 mW 的 DDR 功耗(取决于分辨率)。别小看这点,叠加起来就是几百毫瓦的差距。
8.1 减少内存拷贝:从源头掐断「无效搬运」
我个人习惯,拿到一个相机 HAL 或 Framework 层的代码,第一件事就是 grep 搜索 memcpy 和 copyFrom。为什么?因为很多性能问题都是「拷贝太多」引起的。
8.1.1 使用共享内存(SharedMemory / ION)
Android 从 8.0 开始推荐使用 SharedMemory 替代传统的 Binder 传输大块数据。Binder 传输超过 1MB 的数据时,内核会做隐式拷贝,而 SharedMemory 本质上是 mmap,零拷贝。
// 推荐:使用 SharedMemory 传递帧数据
val sharedMemory = SharedMemory.create("frame_data", bufferSize)
val mappedBuffer = sharedMemory.map(READ_WRITE)
// ... 填充数据 ...
sharedMemory.sendTo(remoteReceiver)
// 不推荐:直接通过 Binder 传 byte[]
// val data = ByteArray(bufferSize)
// parcel.writeByteArray(data) // 这里会触发 memcpy
我在项目中遇到过,某厂商的 Camera HAL 用 Binder 传 1080p YUV 帧,每帧 3MB,30fps 下每秒 90MB 的拷贝量。换成 SharedMemory 后,CPU 占用直接降了 12%。
8.1.2 使用 Surface 传递而非 ByteBuffer
如果你在 App 层做相机开发,尽量用 Surface 或 ImageReader 获取帧,而不是 Camera.open() 配合 onPreviewFrame 回调。后者每次回调都会产生一次 Java Heap 到 Native Heap 的拷贝。
避坑指南: 我曾经接手过一个项目,App 层用 onPreviewFrame 获取 4K 帧做人脸检测,每帧 8MB,30fps 下每秒 240MB 的拷贝量。换成 ImageReader + GPU 直接处理,功耗从 3.2W 降到 2.1W。
8.2 使用 ImageReader 高效获取帧
ImageReader 是 Android 官方推荐的帧获取方式。它底层使用 SurfaceTexture 或 Surface 的 BufferQueue 机制,数据直接从 Camera HAL 流向消费者,中间不经过 App 进程的 Java Heap。
8.2.1 正确配置 ImageReader
// 推荐配置
val imageReader = ImageReader.newInstance(
width, height,
ImageFormat.YUV_420_888, // 使用 YUV 而非 JPEG,避免编解码开销
2, // maxImages = 2,不要设太大
HardwareBuffer.USAGE_GPU_SAMPLER_IMAGE // 允许 GPU 直接访问
)
// 设置回调时注意:不要在回调里做耗时操作
imageReader.setOnImageAvailableListener({ reader ->
val image = reader.acquireLatestImage() // 用 acquireLatestImage 而非 acquireNextImage
if (image != null) {
// 处理 image
image.close() // 必须及时关闭,否则 buffer 泄漏
}
}, backgroundHandler)
为什么 maxImages 设 2 就够了?设大了反而会增加内存占用和 DDR 带宽。你想想看,Camera HAL 每帧都要往 BufferQueue 里塞数据,队列越长,数据在内存里停留的时间就越久,功耗自然就上去了。
8.2.2 避免 Image 到 Bitmap 的转换
很多开发者习惯把 Image 转成 Bitmap 再处理。但 Bitmap 在 Java Heap 上,Image 在 Native Heap 上,转换一次就是一次完整的 memcpy。如果你要做图像处理,直接用 Image.getPlanes() 获取 YUV 数据,或者传给 GPU 做纹理。
注意: ImageReader 的 acquireLatestImage() 会丢弃旧帧,只返回最新的一帧。如果你的算法需要每一帧(比如录像),请用 acquireNextImage(),但记得控制帧率,别让队列堆积。
8.3 降低 DDR 带宽:从「搬运工」变成「精算师」
DDR 带宽是功耗的「隐形杀手」。我见过一个案例,某手机拍 4K 视频时,DDR 带宽跑到 12GB/s,整机功耗 4.5W。优化后降到 6GB/s,功耗 2.8W。怎么做到的?说白了就是减少不必要的数据搬运。
8.3.1 使用更小的分辨率做预览
预览分辨率不一定要和拍照分辨率一样。很多 App 预览用 1080p,拍照用 4K,但 Camera HAL 内部还是以 4K 跑全链路。我建议预览用 720p 或 1080p,拍照时再切到 4K。这样预览阶段的 DDR 带宽能省 60% 以上。
| 预览分辨率 | 每帧数据量 (YUV420) | 30fps 带宽需求 | 相对 4K 节省 |
|---|---|---|---|
| 4K (3840x2160) | ~12 MB | ~360 MB/s | 基准 |
| 1080p (1920x1080) | ~3 MB | ~90 MB/s | 节省 75% |
| 720p (1280x720) | ~1.4 MB | ~42 MB/s | 节省 88% |
8.3.2 利用硬件缩放和裁剪
不要用 CPU 做缩放和裁剪。Camera HAL 和 ISP 通常内置了硬件缩放模块,几乎不耗电。你只需要在配置 CaptureRequest 时设置 SCALER_CROP_REGION 即可。
// 使用 ISP 硬件缩放,零 CPU 开销
val cropRegion = Rect(0, 0, 1920, 1080) // 输出 1080p
captureRequestBuilder.set(CaptureRequest.SCALER_CROP_REGION, cropRegion)
我曾经见过一个团队,在 App 层用 Bitmap.createScaledBitmap 做缩放,每次缩放都要把数据从 GPU 拉到 CPU,缩放完再传回 GPU。嗯,这简直是「用卡车运一包薯片」——完全没必要。
8.3.3 使用更少的数据格式
YUV420 是相机最常用的格式,但如果你不需要颜色信息(比如做深度检测、人脸检测),可以用 ImageFormat.NV21 或直接取 Y 平面。Y 平面只有 1/3 的数据量,带宽直接降 66%。
小技巧: 如果算法只需要灰度图,直接从 YUV 的 Y 平面取数据,不要转成 RGB 再取灰度。我做过测试,Y 平面直接处理比 RGB 转换后处理,功耗低 40%。
8.4 知识体系总览
下面这张图总结了内存与带宽优化的核心路径。你可以把它当作一个检查清单,每次做相机功耗优化时,对照着看一遍。
8.5 实战检查清单
最后,我整理了一份检查清单。每次做相机功耗优化时,对着过一遍,基本不会漏掉关键点。
- 数据流路径:是否用了 SharedMemory / Surface / ImageReader?有没有 Binder 传大数据的「雷」?
- 拷贝次数:从 HAL 到 App,数据经过了几次 memcpy?能不能减少到 1 次甚至 0 次?
- ImageReader 配置:maxImages 是否合理?acquireLatestImage 还是 acquireNextImage?close 是否及时?
- 分辨率与格式:预览分辨率是否过高?能否用 Y 平面替代完整 YUV?
- 硬件加速:缩放、裁剪、旋转是否用了 ISP/GPU 硬件?有没有用 CPU 做这些操作?
- 带宽监控:用
dumpsys meminfo或perfetto查看 DDR 带宽,确认优化效果。
一句话总结: 内存优化的本质是「能不搬就不搬,非要搬就少搬,搬的时候走高速」。你想想看,数据在内存里每多待一毫秒,DDR 就多耗一毫秒的电。砍掉无效拷贝,就是最直接的降功耗手段。
公众号:蓝海资料掘金营,微信deep3321