简介:一份面向 Android 初学者的 MediaPlayer 播放器示例工程,帮助理解视频与音频播放的核心流程:从初始化、数据源设置、异步准备到播放、暂停、停止与资源释放,同时覆盖播放状态监听、进度跳转、音量控制等知识要点。压缩包为 RAR 格式,共 56 个文件,约 10.57MB,包含 Java 源码、APK 安装包、XML 布局与配置、class 编译文件及 PNG 演示图等,打开工程即可对照学习,也适合直接运行验证效果。资源共有 327 人浏览学习,作者按 Android 工程目录整理,src、res、assets、libs 分层清晰,便于检索各类文件;其中 mp3 素材与 jar 库能辅助理解本地媒体源和第三方依赖的使用方式。对准备快速上手 MediaPlayer 的入门开发者来说,这份示例包比零散文档更直观,可结合源码注释和配套布局文件掌握从创建播放器到处理异常、释放资源的完整链路。
1. MediaPlayer:当系统播放器撑不住时,你自己写一个到底要拆多少东西
很多人觉得做一个 MediaPlayer 不过是调系统组件:传个路径,等回调,结束。但真正做过播放器相关开发的人都会遇到一个场景:视频要倍速、要逐帧暂停、要无缝连播、要从内存流播放。结果系统播放器要么不支持,要么黑匣子一样根本没法定位问题,翻车了就只能猜。这时候“自己实现一个 MediaPlayer”就不再是炫技,而是需求倒逼。标题里的 MediaPlayer 如果落到工程上,本质是解决一件事:把媒体文件从字节流变成一帧帧能渲染的图像和一段段连续的音频,并且保证两者的时间关系是对的。这个方向适合两类人:一类是想在客户端里深度定制的应用开发者,另一类是刚接触音视频、被各种封装格式绕晕的入门服务端或客户端工程师。
做这个事,你不需要从零写一个解码器,也不必重复发明解封装轮子。常见做法是站在 FFmpeg 的肩膀上,用它的 API 接管协议解析、解封装、解码,自己只负责三件事:读数据、喂数据、把解码结果按时钟送出去。真正难的也不是 API 本身,而是时序问题:音频和视频谁当主时钟?网络流卡了怎么补?seek 之后怎么快速出来画面?这篇文章就按我做过的一个跨平台播放器 Demo 来拆,从架构设计讲到具体接口调用,再讲那些让我反复踩坑的边界问题。内容不依赖某个特定的发行版或特定编译选项,只讲方案本身,你可以照着思路在自己的工程里复现。
2. 架构先行:一个能落地的 MediaPlayer 至少有三层和一条时钟线
2.1 播放器不只是一个解码器:从字节到画面的完整链路
如果只盯着“解码”两个字,很容易把 MediaPlayer 理解成调用avcodec_send_packet和avcodec_receive_frame的循环。但一个工程化的播放器要处理的远不止这些。媒体文件最外层是容器格式,比如 MP4、MKV、TS,里面封装着视频流和音频流,视频流又经过 H.264、HEVC 等编码压缩。播放器首先要把容器拆开,拿到编码后的数据包,然后送进解码器得到原始帧,最后把原始帧交给渲染器。这一条链路里每一环都可能出问题:容器解析失败、码流不完整、解码器不支持、渲染时序错乱。
我一般把播放器拆成三层。最底层是协议与解封装层,负责从文件、HTTP、内存缓冲区拿到数据并剥离容器头,输出编码包。中间是解码层,负责把编码包变成 PCM 音频数据和 YUV/RGB 视频帧。最上层是渲染控制层,负责把解码后的数据送到声卡和屏幕,同时维护播放、暂停、seek、倍速这些状态。这三层各管各的事,但共享一条贯通的消息链路和一条时钟线。分层清晰之后,排查问题就不用从头查到尾。
2.2 为什么必须是独立线程:阻塞模型是播放器卡顿的第一根源
有一个常见误用是把读包、解码、渲染全放在 UI 线程里跑,结果视频一卡,界面也跟着无响应。正确的做法是每个耗时环节独占一个线程,再用队列做衔接。读包线程只做av_read_frame,拿到的 packet 按流类型分发给音频、视频两个包队列;解码线程从队列里取包,解码出一帧就放入帧队列;渲染线程按时钟取帧显示。线程之间是生产者与消费者的关系,队列长度就是缓冲区。
线程数不用太多,三到四个就够:一个读包线程,一个音频解码线程(音频渲染通常由声卡回调驱动,可以和解码合并),一个视频解码线程,一个渲染控制线程。队列的设计要注意一个原则:视频包队列可以长一点,音频包队列要短。因为音频是连续播放的,缓冲太多会导致暂停、seek 之后反应迟钝;而视频队列短了容易在画面复杂时饿死。队列长度通常按时间而不是按包数来衡量,视频保持 3 到 5 秒的缓冲,音频保持 500 毫秒到 1 秒就够。这么做的好处是 seek 之后只需要清空这两个队列,不用动解码器上下文,恢复速度会快很多。
2.3 时钟是播放器的第二骨架:三种主时钟选型
播放器里最容易被忽略又最致命的是时钟。音频有音频的播放速度,视频有视频的帧率,而人的耳朵对音频卡顿比眼睛对画面卡顿敏感得多,所以几乎所有的播放器都会把音频时钟作为主时钟。视频帧在渲染前要和音频时钟比较,算出自己该提前还是该等待。
有三种常见的主时钟选型。第一种是音频时钟,即当前正在播放的音频帧的 PTS,加上已经播放的时长。这是默认方案,稳定性最好。第二种是外部时钟,即用av_gettime_relative()拿到的系统单调时间。适合纯视频文件、无声视频或直播场景。第三种是系统时钟加缓冲修正,适合需要精确同步多个播放器的场景。自己做本地文件播放时,直接用音频时钟就好,不要搞复杂的动态切换,那是直播和音视频会议才需要的能力,对本地播放器而言纯属增加翻车概率。
2.4 架构图之外必须定的数据格式:解码后到底用什么交给渲染
解码后的视频帧可能是 YUV420P、NV12,也可能是 RGBA,取决于解封装后的 pix_fmt 和硬解码器类型。在跨平台方案里,我倾向于在解码后面加一个统一的帧格式转换层:一律转成目标渲染器最吃的格式。比如用 SDL 渲染时,YUV 纹理比 RGB 纹理性能更好,所以在视频解码线程里直接把帧转成 YUV420P,再用 SDL 的纹理上传。音频则统一转成 S16 交错格式或者浮点格式,方便送进音频设备。这个转换层会多耗一点 CPU,但换来的是渲染层不用关心上游是什么编码格式,整体更可控。
3. FFmpeg 核心流程落地:打开媒体、读包、解码的最小闭环
3.1 从文件名到 AVFormatContext:打开媒体文件的第一步
FFmpeg 的接口虽然多,但播放器的核心路径是固定的。第一步是打开输入并探测格式。代码上看起来也就几十行,但每一步都对后面的行为有影响。
AVFormatContext *fmt_ctx = NULL; int ret = avformat_open_input(&fmt_ctx, filename, NULL, NULL); if (ret < 0) { // 文件不存在、协议不支持、格式无法识别都会走到这里 return ret; } ret = avformat_find_stream_info(fmt_ctx, NULL); if (ret < 0) { avformat_close_input(&fmt_ctx); return ret; } // 找到视频流索引和音频流索引 for (unsigned int i = 0; i < fmt_ctx->nb_streams; i++) { AVStream *st = fmt_ctx->streams[i]; AVMediaType type = st->codecpar->codec_type; if (type == AVMEDIA_TYPE_VIDEO && video_stream_idx < 0) { video_stream_idx = i; } else if (type == AVMEDIA_TYPE_AUDIO && audio_stream_idx < 0) { audio_stream_idx = i; } }这段代码的逻辑说明:avformat_open_input会依据文件内容而非后缀来探测容器格式,所以后缀名写错也能打开,但读取到的封面流、字幕流会被识别成额外流;avformat_find_stream_info内部会读取一部分数据包来解析流参数,这一步对网络流来说会阻塞直到拿到足够信息,所以一般在打开网络流时建议限制probesize和analyzeduration,默认值对本地文件没问题,对直播流会造成明显起播延迟。视频流索引通常取第一个视频流,但遇到包含多视角或多音轨的文件时,要按需选择,不能硬编码 0。
3.2 打开解码器:codecpar 是唯一的参数来源
拿到流的索引之后,下一步是根据codecpar找解码器并打开。这里有一个非常重要的细节:不要用codec_id直接猜测解码器,要通过codecpar->codec_id查找,然后打开。硬解还是软解也在这个阶段决定。
AVCodec *decoder = avcodec_find_decoder(fmt_ctx->streams[stream_idx]->codecpar->codec_id); if (!decoder) { // 当前 FFmpeg 编译未包含该解码器 return AVERROR_DECODER_NOT_FOUND; } AVCodecContext *dec_ctx = avcodec_alloc_context3(decoder); avcodec_parameters_to_context(dec_ctx, fmt_ctx->streams[stream_idx]->codecpar); // 多线程解码:自动决定线程数,解码速度会明显提升 dec_ctx->thread_count = 0; dec_ctx->thread_type = FF_THREAD_FRAME; int ret = avcodec_open2(dec_ctx, decoder, NULL); if (ret < 0) { avcodec_free_context(&dec_ctx); return ret; }thread_count设为 0 表示让 FFmpeg 自己判断;对视频解码建议用FF_THREAD_FRAME按帧划分线程,对音频则不需要。硬解的接入方式是给avcodec_open2传一个包含AV_HWDEVICE_TYPE_VIDEO_TOOLBOX或AV_HWDEVICE_TYPE_VAAPI之类的字典参数,但硬解成功后拿到的hw_frames_ctx和普通帧的存储格式不同,转格式时要多一层av_hwframe_transfer_data。我做过某平台上的硬解接入,一个最容易犯的错是打开硬解失败后没有回退到软解,导致整个播放器直接不可用。健壮的做法是先用软解跑通全流程,再把硬解作为可选优化。
3.3 读包循环:一个 while 循环里的三件事
打开完成后,播放器就进入主循环。读包线程不断从fmt_ctx里拿 packet,根据packet->stream_index分发给对应的解码线程。这里隐藏着一个关键原则:av_read_frame返回的 packet 是引用计数的,必须及时av_packet_unref,否则大量读取会造成内存暴涨。我见过某开发者把 packet 分发给两个队列后没有 unref,播放 10 分钟内存占用就到了好几个 GB。
AVPacket *pkt = av_packet_alloc(); while (is_playing) { int ret = av_read_frame(fmt_ctx, pkt); if (ret == AVERROR_EOF) { // 文件读完了,发送空包冲刷解码器 flush_decoders(); break; } else if (ret < 0) { // 网络中断、文件损坏 handle_read_error(ret); break; } if (pkt->stream_index == video_stream_idx) { video_pkt_queue_push(pkt); } else if (pkt->stream_index == audio_stream_idx) { audio_pkt_queue_push(pkt); } av_packet_unref(pkt); } av_packet_free(&pkt);需要注意的是:视频包队列和音频包队列的 push 操作里,一般保存的是pkt的一个拷贝(即av_packet_ref新包),所以循环末尾的av_packet_unref是安全的。当 seek 发生时,读包线程不能立即退出循环,而是要清空队列,再调用av_seek_frame,让下一轮av_read_frame从 seek 后的位置继续读取。为了减少 seek 带来的黑屏时间,视频解码线程要能在收到 seek 信号时立刻丢弃解码器里的缓存帧,做法是调用avcodec_flush_buffers,但清空时要注意解码线程此时可能还在avcodec_receive_frame的阻塞等待中,所以很多实现里用条件变量来做线程间的暂停与恢复。
3.4 解码循环:send 和 receive 的配合
解码线程的核心是avcodec_send_packet和avcodec_receive_frame的循环。这两个接口是 FFmpeg 3.1 之后的标准模式,替代了旧的avcodec_decode_video2。它们的关系不是一次 send 就一定能拿到一次 receive,而是 send 一包,receive 可能拿到 0 帧或多帧,取决于编码类型和 B 帧结构。
AVFrame *frame = av_frame_alloc(); while (!abort) { AVPacket *pkt = queue_pop(); int ret = avcodec_send_packet(dec_ctx, pkt); if (ret == AVERROR(EAGAIN)) { // 解码器内部已经满了,需要先取帧 while (avcodec_receive_frame(dec_ctx, frame) == 0) { deliver_frame_to_renderer(frame); av_frame_unref(frame); } // 重新放回队列尾部 queue_push_front(pkt); } else if (ret < 0) { handle_decode_error(ret); } while (avcodec_receive_frame(dec_ctx, frame) == 0) { deliver_frame_to_renderer(frame); av_frame_unref(frame); } av_packet_unref(pkt); } av_frame_free(&frame);实际项目中很少直接遇到EAGAIN分支,因为解码器的内部缓冲有一套自动管理机制,但把这个分支写出来能让代码在低延迟模式下更健壮。deliver_frame_to_renderer里,视频帧要做一次av_frame_clone再入队,因为 frame 对象会被下一轮 receive 覆盖。音频帧同理,但如果做音频重采样,则要在这一层把原始格式统一转成目标格式。需要注意视频帧的best_effort_timestamp字段不是一定能拿到,部分封装格式会返回AV_NOPTS_VALUE,所以渲染层拿到的 PTS 要有一个兜底方案,否则音画同步逻辑会直接崩溃。
4. 音画同步:PTS 换算、主时钟推进和三个必调参数
4.1 PTS 不是直接能用的时间:先弄清 time_base
解码得到的帧里有pts,但它是一个整数,真正的展示时间要乘以time_base。每个AVStream都有自己的time_base,来自容器封装。例如 MP4 的time_base通常是1/15360,而解码后的帧的pts要以AVStream的time_base换算成秒。
double frame_time = frame->pts * av_q2d(stream->time_base);这里有一个常见误解:av_q2d返回的是一个 double,但time_base本身的值由容器决定,不能假设所有文件都是1/1000。在换算时钟时,我习惯把整个播放器的时间总线统一成微秒(int64_t),换算公式是pts * time_base.num * 1000000 / time_base.den。这样无论音频还是视频,进入比较函数前都已经变成了同样的单位,省掉了浮点精度问题。
还有一个坑是AVStream里的start_time。部分 MP4 文件的开头有编辑列表(edit list),会导致第一条视频帧的 PTS 不等于 0。如果直接把首帧 PTS 当作 0,音画同步差几百毫秒是常有的事。处理方式是记录第一个视频帧和第一个音频帧的 PTS 作为基准,后面所有帧和基准做差,得到相对时间。
4.2 音频时钟:声卡回调和播放位置之间的距离
音频时钟的计算不能只依赖已送入声卡的数据量,因为声卡有一个内部缓冲,你送入的数据可能还没真正发声。正确做法是:以当前解码出的音频帧的 PTS 为基准,加上音频设备已播放的采样数除以采样率,估算出当前真实播放位置。在 SDL 的实现里,回调机制天然提供了这一信息:回调里要求填充的数据量对应了声卡想要消费的位置,因此可以精确同步。
我一般把音频时钟推进的逻辑抽成一个函数:每次从音频包队列取出一个帧解码后,更新audio_clock为这个帧的 PTS;在写音频设备时,根据回调里报告的已写字节数和未播放字节数,用audio_clock - 未播放时长作为当前播放时间。这个算法不完美,但足够稳,本地文件播放的精度的误差通常能控制在 20 毫秒以内。
4.3 视频渲染:早了就等,晚就丢,关键帧优先
视频帧的渲染逻辑是一个三选一判断:当前帧的显示时间比音频时钟早,说明需要等待;比音频时钟晚超过阈值,说明视频掉队了,需要丢掉这一帧;在阈值之内,直接渲染。阈值的选择是个技术活,太大会感觉音画不同步,太小会因渲染抖动而频繁丢帧。
我常用的阈值是AV_SYNC_THRESHOLD = 0.04秒,也就是说误差在 40 毫秒内不做修正。帧率 25fps 的视频,单帧间隔是 40 毫秒,这个阈值正好可以容忍一帧的误差。如果丢帧策略太激进,视觉上会出现画面跳跃;如果太保守,音频会一直等视频,整体节奏变慢。还有一个容易被忽略的细节:当视频帧延迟过大时,不要直接丢当前帧,而是连续丢帧直到下一帧的延迟在阈值以内,这个循环要限制次数,避免陷入死循环。
4.4 倍速播放:重采样和 PTS 步进的关系
倍速播放的本质是改变音频时钟的推进速度。音频端通常用重采样配合播放速度调整:0.5 倍速时把双声道缓冲拉长,2 倍速时丢弃中间采样。FFmpeg 的swr_set_compensation可以动态调整重采样比率,但它只适合小范围变速,超过 1.5 倍或低于 0.5 倍就会产生明显失真。视频端更简单,帧率不变,只把等待时间按倍速缩短或拉长即可。
要让倍速平滑,核心是音频主时钟的步进必须和重采样严格一致。我做过一个实现:先把原始音频数据按固定周期重采样到目标倍速,再送入声卡,这样音频时钟直接等于声卡消费位置除以倍速系数。这样一个倍速切换的过程里,音画误差不会像某些播放器那样积累到好几秒。如果不想自己写重采样,最常见的捷径是用 SDL2 内部自带的音频设备,配合SDL_AudioStreamPut做速率变化,但灵活性比直接操作 FFmpeg 的swr_ctx要差一点。
5. 避坑手册:本地播放里最常见的五个翻车点与排查思路
5.1 起播后画面黑屏但声音正常:视频首帧不是关键帧
现象:文件播放开始后几秒内只有声音没有画面,甚至首帧出来后是一块花屏,然后恢复正常。
原因:视频流的编码里存在关键帧间隔,如果 seek 或起播位置落在非关键帧上,视频解码器要等到下一个关键帧才能完整解码出一帧图像。本地文件起播时通常会从头开始,但网络流或 seek 场景下会频繁复现。
排查思路:打印视频流的关键帧间隔,用av_seek_frame时指定AVSEEK_FLAG_BACKWARD,让视频流跳到目标位置之前最近的关键帧。再配合解码器从关键帧开始解码,起播画面就能正常出现。如果仍然黑屏,检查dec_ctx->skip_frame是否设置错误,或者flags2里是否误开了AV_CODEC_FLAG2_SHOW_ALL。
5.2 seek 后画面颜色不对或马赛克:解码器缓存未清
现象:视频文件播放中拖进度条,新画面出现时颜色发绿、色块严重,过一段时间自己恢复。
原因:seek 到新的关键帧后,解码器内部的参考帧还是旧的数据,新帧在没有刷新 reference frame 的情况下被错误参考了。更严重的情况是解码线程还在读取旧 packet 时就被塞入了新 packet,导致流错位。
排查思路:seek 后立刻调用avcodec_flush_buffers(dec_ctx),同时清空音频和视频的包队列与帧队列。flush 之后第一条视频帧必须是关键帧,否则重复上一条。常见做法是先av_seek_frame到目标位置之前的关键帧,再 flush,再继续读包。注意 flush 和解码线程之间有竞态:必须在暂停解码线程后再 flush,否则avcodec_send_packet会在 flush 过程中拿到AVERROR_EOF,让播放器进入错误状态。
5.3 播放一段时间后画面和声音越来越不同步:时钟漂移
现象:起播时音画同步正常,播放十几分钟后画面明显落后,或者音频越来越拖沓。
原因:视频时钟和音频时钟各自独立推进,没有定期重新对齐。视频端如果只是简单地按固定帧间隔等待,没有对照音频时钟做动态调整,漂移在所难免。另一个常见原因是音频时钟没有算入声卡内部的缓冲延迟,导致主时钟越来越快。
排查思路:在每次视频渲染前重新读取一次音频时钟,不要用缓存的值;每次渲染后统计视频帧晚到或早到的平均误差,如果超过 50 毫秒,把视频等待逻辑改成动态步进。对于本地文件,还可以把视频帧的duration纳入考虑:下一帧显示时间 = 上一帧时间 + duration,而不是直接用 PTS 差值,这能消除容器时间戳不均匀造成的积压。
5.4 播放本地文件正常,播放某类 MKV 卡顿:音频流的压缩格式太老
现象:普通 MP4 播放流畅,换成一个音频轨是 AC3 的老 MKV 后,播放器声音一顿一顿,CPU 飙升。
原因:AC3 解码要么靠 FFmpeg 软件解码,要么靠硬件解码器。软件解码 AC3 虽然不重,但如果壳体为播放器配置的音频设备不支持 5.1 声道,且你的重采样逻辑没有做降混,解码后的多声道数据会让音频输出层做大量的转换,CPU 占用自然上升。
排查思路:channel_layout要显式设置目标布局,比如统一转成立体声。用swr_ctx重采样时,out_channel_layout设为AV_CH_LAYOUT_STEREO。如果音频时钟仍然不稳,把音频缓冲的长度减小,声卡回调里每次多取一些数据,减少回调频率。对于 AC3 这类不相容的音频流,另一个可行的方案是直接把音频解码线程的环形缓冲增大到 2048 采样点,换取更稳定的写入节奏。
5.5 文件末尾播放结束后进程退出崩溃:post flush 的 EOF 处理不到位
现象:视频从头播到尾,播放器自动停止,但日志里出现avcodec_receive_frame: AVERROR_EOF,接着有野指针崩溃。
原因:播放器在 EOS 时给解码器发送了一个空包来冲刷缓存,但发送空包后avcodec_receive_frame会返回AVERROR_EOF,返回之后必须停止取帧。如果还在继续循环,取到的frame可能是空指针,或者内部已经释放,对av_frame_unref传入空帧会直接崩溃。
排查思路:每次avcodec_send_packet之后,检查返回值如果是AVERROR_EOF,设置一个eof_received标志,所有后续avcodec_receive_frame的结果如果是AVERROR_EOF,直接让解码线程退出,不要再做任何 frame 操作。双线程场景下,还要等所有帧都被渲染完毕后再销毁解码线程,否则队列里残留的帧会被释放,渲染线程拿到野指针。崩溃现场基本都是frame->data[0]为空的断言,提前做空判断当然可以,但治本还是严格按 EOF 状态机退出。
6. 进阶实践:可观测性设计和断点续播的最小方案
播放器做出来能播,只算完成了三分之一。一个真正能交付的 MediaPlayer 需要可观测性:出了问题你能通过日志和状态量快速判断是读包慢了、解码慢了,还是渲染慢了。我一般在播放器内部维护一个PlayerStats结构体,里面记录最近 10 秒的读包平均耗时、解码平均耗时、渲染丢帧数、缓冲队列长度。这些值每 500 毫秒刷新一次,输出到统一的日志通道。排查卡顿问题时,只看这几项就能把问题范围缩小到某一层。
针对错误上报,所有 FFmpeg 调用返回的错误码统一转换成可读字符串,同时在开头记录发生函数名。不要只存ret的整数值,因为AVERROR_*的宏在 Release 版本的日志里会显示成负数,没人记得住 -1094995529 是哪一类错误。定义错误码转换函数只需几行,但能省下大量二次排查时间。
断点续播是另一个值得做的进阶功能。实现方式并不复杂:播放器暂停时,记录当前视频帧 PTS、音频时钟、播放进度百分比,以及文件 URL,写入本地 JSON 文件。下次启动时,读取进度并 seek 到目标位置前一个关键帧。有个细节是 seek 的位置不要超过文件总时长减去 5 秒,否则最后一段视频没有足够数据缓冲,起播就卡顿。断点续播配合上面说的关键帧回退逻辑,能让用户在任何位置恢复播放时都得到几乎无缝的体验。
还有一个常见但容易做糟的优化点:内存播放。对于某些不想落盘的媒体数据,可以直接用avformat_open_input的AVIOInterruptCB加上自定义的回调函数,从一个已加载的uint8_t* buffer读取数据。实现时重点不是读,而是avio的 seek 回调里要支持随机访问,不能只实现read不实现seek。很多视频文件需要avformat_find_stream_info反复读前面几个字节,没有正确的 seek 回调,探测阶段就会失败。
整个 MediaPlayer 做完,回头看最深刻的教训是:不要相信 FFmpeg 返回的每一个字段都是有效值,不要假设播放器只会走到你写过的分支。当初我改写音频时钟那段逻辑时,因为少判断了一次pts == AV_NOPTS_VALUE,结果在某个切音频轨的场景里,整个播放直接进入一万毫秒的等待,画面冻住,翻车翻了整整半天。从那之后我养成了一个习惯:每个取到的 PTS 先打日志校验范围,对不合理的一律丢弃。这个习惯帮我后来排查网络流时间戳跳变时省了非常多时间。希望这篇内容能让你在动手做 MediaPlayer 的时候,少走几段我走过的弯路。
本文还有配套的精品资源,点击获取