一、解码器内存占用分析
做视频硬解码,内存问题永远是绕不开的坎。我刚开始接触MediaCodec时,总觉得它是个黑盒子,内存占用完全不可控。后来踩了不少坑,才慢慢摸清楚它的内存模型。
说白了,解码器的内存占用主要分三块:
- 输入缓冲区:存放压缩数据,就是H.264/H.265码流
- 输出缓冲区:存放解码后的YUV/RGBA帧
- 解码器内部状态:各种参考帧、运动矢量等
其中输出缓冲区是最吃内存的。举个例子,1080p的YUV420帧,一帧就要占用1920×1080×1.5≈3MB。如果解码器内部维护了8-16个输出缓冲区,光这部分就要24-48MB。
关键数据:不同分辨率下,单帧YUV420的内存占用
| 分辨率 | 单帧大小 | 16帧缓冲池 |
|---|---|---|
| 720p (1280×720) | ~1.4MB | ~22MB |
| 1080p (1920×1080) | ~3MB | ~48MB |
| 4K (3840×2160) | ~12MB | ~192MB |
我在项目中遇到过,某款低端机解码4K视频时直接闪退。查了半天,发现是解码器申请了16个输出缓冲区,每个12MB,光这就192MB了。加上其他内存开销,直接触发了系统杀进程。
二、Buffer池化:别再频繁申请释放了
很多新手写解码器,喜欢每帧都new一个ByteBuffer,用完就释放。你想想看,解码器每秒输出30帧,每帧3MB,频繁GC的代价有多大?
我建议的做法是:预分配一个Buffer池。
// 伪代码示例:Buffer池化
class DecoderBufferPool {
private val pool = LinkedList<ByteBuffer>()
private val bufferSize: Int
init {
// 预分配8个缓冲区
for (i in 0 until 8) {
pool.add(ByteBuffer.allocateDirect(bufferSize))
}
}
fun acquire(): ByteBuffer {
return pool.poll() ?: ByteBuffer.allocateDirect(bufferSize)
}
fun release(buffer: ByteBuffer) {
buffer.clear()
pool.offer(buffer)
}
}
这样做的好处很明显:
- 减少GC压力,避免卡顿
- 内存分配次数从每秒30次降到几乎为0
- 内存碎片更少,系统更稳定
小技巧:Buffer池的大小不是越多越好。我个人习惯设置为解码器输出缓冲区数量的1.5倍。比如解码器有8个输出缓冲区,池子就设12个。多了浪费,少了不够用。
三、纹理内存管理:SurfaceView vs TextureView
说到纹理内存,就不得不提SurfaceView和TextureView的选择。很多人觉得TextureView更灵活,就无脑用。但你知道吗?TextureView内部维护了一个独立的GL纹理,这本身就要占用显存。
我曾经在一个项目中,用TextureView显示4K视频,结果发现显存占用比SurfaceView多了将近100MB。为什么?因为TextureView需要把解码器的输出帧先拷贝到自己的纹理上,再渲染到屏幕。而SurfaceView直接共享了解码器的输出缓冲区,省去了这份拷贝。
注意:如果你不需要对视频帧做二次处理(比如加滤镜、旋转),请优先使用SurfaceView。它更省内存,性能也更好。
那什么时候用TextureView?
- 需要对视频帧做动画、缩放、旋转
- 需要与其他View叠加显示
- 需要截取视频帧做后续处理
嗯,这里要注意:即使用了TextureView,也要控制纹理的尺寸。不要直接使用解码器输出的原始分辨率,可以适当缩小。比如4K视频显示在1080p的屏幕上,纹理尺寸设为1080p就够了,没必要用4K纹理。
四、OOM预防:别等到崩溃才后悔
OOM(Out Of Memory)是Android开发者的噩梦。在视频解码场景下,OOM尤其容易发生。我总结了几条经验:
- 监控内存水位:在解码循环中定期检查可用内存,低于阈值时主动降级
- 限制解码器数量:不要同时创建多个解码器实例,每个解码器都是内存大户
- 及时释放资源:MediaCodec的release()一定要在finally块中调用
- 使用软硬解码切换:当硬解码内存不足时,可以回退到软解码
// 内存监控示例
fun checkMemorySafety(): Boolean {
val runtime = Runtime.getRuntime()
val usedMem = runtime.totalMemory() - runtime.freeMemory()
val maxMem = runtime.maxMemory()
val threshold = (maxMem * 0.8).toLong()
if (usedMem > threshold) {
Log.w("Decoder", "内存使用超过80%,准备降级")
return false
}
return true
}
避坑指南:我曾经在某个项目里,因为忘记释放解码器,导致连续播放20个视频后OOM。查了三天才发现是MediaCodec的release()没调用。从那以后,我所有解码器都加上了try-finally保护。
还有一个容易被忽略的点:Bitmap的内存管理。如果你从解码器输出帧创建Bitmap,记得用BitmapFactory.Options的inBitmap复用内存。否则每帧都创建新Bitmap,OOM只是时间问题。
最后说一句,内存优化没有银弹。不同设备、不同分辨率、不同编码格式,表现都可能不一样。我的习惯是:先分析,再优化,最后验证。用Profiler跑一遍,看看内存峰值在哪,再针对性地做池化、降级、复用。别一上来就堆代码,那样反而容易出问题。