29、常见问题与调试:黑屏/花屏问题、音画不同步、内存泄漏排查
做播放器开发,说白了就是跟各种"幺蛾子"打交道。我自己刚入行那会儿,最怕的就是测试突然跑过来说:"哎,你这个画面怎么黑了?"或者"声音跟嘴型对不上啊!"。
这些问题看起来吓人,其实背后都有套路。今天我就把这三类最头疼的问题——黑屏花屏、音画不同步、内存泄漏,掰开了揉碎了讲清楚。
一、黑屏与花屏问题
黑屏和花屏,是播放器开发里最常见的"面子问题"。用户第一眼看到的就是画面,画面挂了,体验直接归零。
1.1 黑屏的常见原因
黑屏分两种:一种是"一直黑",另一种是"播着播着突然黑了"。原因完全不同。
- Surface 未就绪:最常见的原因。Surface 还没创建好,你就往上面扔数据了。我遇到过好几次,都是因为 Activity 的
onCreate里直接初始化播放器,但 SurfaceView 的surfaceCreated回调还没触发。 - 解码器初始化失败:比如 H.265 的视频,但设备不支持硬解。这时候解码器返回
ERROR_CANNOT_CONFIGURE,但你没处理这个错误,画面自然就黑了。 - 渲染线程崩溃:渲染线程悄无声息地挂了,主线程还不知道。这种情况最坑,因为日志里可能只有一行
EGL error。
排查思路:先确认 Surface 是否有效。在 onSurfaceTextureAvailable 或 surfaceCreated 里打个日志,看看回调到底有没有触发。然后检查解码器的状态码,别吞掉异常。
1.2 花屏的常见原因
花屏比黑屏更让人抓狂。画面出来了,但全是马赛克或者绿块。
- 关键帧丢失:H.264/H.265 的视频,如果 I 帧丢了,后面的 P 帧和 B 帧根本解不出来。我在项目里遇到过网络切片刚好切在 I 帧中间的情况,画面直接花掉。
- 分辨率不匹配:解码器输出的分辨率,跟 Surface 的尺寸对不上。比如解码出来是 1920x1080,但 Surface 只有 1280x720,硬要往上怼,不花才怪。
- 数据包乱序:尤其是 RTSP 或自定义协议,数据包到达顺序乱了,解码器拿到的是"错位"的数据。
我的经验:遇到花屏,先看日志里有没有 OMX_ErrorStreamCorrupt 或者 BUFFER_FLAG_CODEC_CONFIG 相关的警告。另外,可以试试把解码器从硬解切到软解,如果软解不花,那基本就是硬件解码器的问题。
二、音画不同步
音画不同步,说白了就是"嘴型对不上"。用户可能说不清哪里不对,但就是觉得"怪怪的"。
2.1 根本原因
音频和视频是两个独立的解码链路,各自有各自的时钟。如果这两个时钟没对齐,就会不同步。
- 时间戳不准:有些视频文件本身的时间戳就是乱的。比如 PTS(显示时间戳)跳变、重复或者缺失。
- 缓冲策略差异:音频缓冲通常很小(几十毫秒),视频缓冲可能很大(几百毫秒)。音频跑得快,视频跟不上,声音就比画面快。
- 解码延迟抖动:硬解有时候会突然卡一下,导致视频帧延迟到达。音频可不管这些,它按自己的节奏走。
2.2 解决方案
我个人的习惯是,用音频时钟做主时钟。因为人对声音的延迟更敏感。
// 伪代码:基于音频时钟的视频同步
long audioClock = audioTrack.getPlaybackHeadPosition() * 1000 / sampleRate;
long videoPts = videoFrame.getPts();
long diff = videoPts - audioClock;
if (diff > 50) {
// 视频快了,丢帧
skipFrame();
} else if (diff < -50) {
// 视频慢了,等待
Thread.sleep(Math.abs(diff));
}
注意:getPlaybackHeadPosition 在某些设备上可能不准。我遇到过小米某款手机,这个值会跳变。建议加一个滑动平均滤波。
另外,还有一个容易被忽略的点——音频采样率。如果实际采样率和声明的不一致,音频时钟算出来就是错的。我曾经被这个坑过,折腾了两天才发现是音频文件 metadata 写错了。
三、内存泄漏排查
内存泄漏不像黑屏那么明显,但它是"慢性毒药"。用户用着用着,App 就 OOM 了。
3.1 播放器中的常见泄漏点
| 泄漏类型 | 原因 | 排查方法 |
|---|---|---|
| MediaCodec 泄漏 | 没有调用 stop() 和 release() |
用 dumpsys media.codec 查看活跃实例 |
| Surface 泄漏 | Surface 被引用,无法释放 | MAT 或 LeakCanary 分析引用链 |
| 回调泄漏 | 匿名内部类持有 Activity 引用 | 检查匿名类是否被静态变量持有 |
| ByteBuffer 泄漏 | 解码输出缓冲区没有及时回收 | 观察 native heap 增长 |
3.2 我的排查套路
嗯,这里我分享一下我自己的三板斧:
- 先确认是不是真的泄漏:打开 Android Studio 的 Profiler,反复进出播放页面,看内存曲线是不是只涨不降。如果每次进入都涨 10MB 且不回落,那基本就是泄漏了。
- 用 LeakCanary 定位:这个工具太香了。它会直接告诉你"XXX 对象被 YYY 持有了,无法释放"。我遇到过一个 case,是一个
Handler的匿名内部类持有了 Activity,导致整个播放器页面无法回收。 - 检查 MediaCodec 的生命周期:在
onPause和onDestroy里,确保调用了mediaCodec.stop()和mediaCodec.release()。可以用try-finally包裹,确保异常时也能释放。
避坑指南:我曾经在 MediaCodec.Callback 里直接传入了 Activity 的引用,结果 Activity 销毁了,回调还在跑。后来改成用 WeakReference 才解决。记住,任何跨线程的回调,都要小心生命周期。
3.3 工具推荐
- LeakCanary:自动检测泄漏,适合开发阶段。
- Android Profiler:看实时内存和 CPU 占用。
- dumpsys meminfo:命令行查看进程内存详情。
- MAT (Memory Analyzer Tool):分析 heap dump,找泄漏根节点。
最后说一句,调试这东西,说白了就是"大胆假设,小心求证"。遇到问题别慌,先确认能不能稳定复现,然后一步步缩小范围。我见过太多人一上来就改代码,结果越改越乱。
好了,这一章的内容就到这里。记住,黑屏查 Surface,花屏查关键帧,不同步查时钟,泄漏查引用。把这四句话记住,能省你不少时间。