简介:这是一份基于 Qt 5.8(32 位)与 FFmpeg 5.1 开发的流媒体播放器工程源码,面向具备一定 Qt 与音视频基础、希望动手实践实时流播放的开发者。工程支持以 TCP 或 UDP 方式拉取 RTSP 实时流(如监控摄像头画面),音频通过 QAudioOutput 输出,视频经 FFmpeg 解码、YUV 转 RGB 后在 QOpenGLWidget 中渲染,并集成截图与录像功能,可用于学习音视频同步、解码渲染与本地录制等完整链路。压缩包共 263 个文件,约 43.23MB,包含 13 个 cpp 与 144 个 h 源文件、45 个 dll 动态库、8 个 lib 库文件、16 个 qm 翻译文件及 obj、exe、mp4 等编译与演示产物,另有 pro、pri 工程配置,结构完整可直接编译调试。目前已有 1999 人学习下载,适合作为 Qt 音视频入门到进阶的参考工程,帮助读者理解线程分工、解码渲染与录制模块的组织方式。
1. 从一次“播放器翻车”说起:这个 Qt + FFmpeg 项目到底能干什么
去年帮朋友做一个桌面端的媒体工具,需求很朴素:能打开本地视频、能拖进度条、能截图、能录一段。我一开始想偷懒,直接上系统自带的播放组件,结果格式兼容性一地鸡毛,换个封装就黑屏。后来老老实实用 FFmpeg 做解码,用 Qt 做界面和音频输出,才算把坑填平。这份资源就是一个把这条路走通的完整工程:Qt 负责窗口、控件、事件循环,FFmpeg 负责解封装和解码,QAudioOutput 负责把 PCM 推给声卡,播放、暂停、截图、录像这些操作都落在同一套时间轴和状态机上。它适合两类人:一类是想跑通一个能用的本地播放器、顺手加点自己功能的新手;另一类是想看 Qt 与 FFmpeg 怎么在工程层面配合、而不是停留在示例代码层面的熟手。下面我按“能干什么 → 怎么跑 → 怎么改”的顺序,把这个工程拆开讲。
2. 工程结构与技术选型:Qt 界面、FFmpeg 解码、QAudioOutput 出声
2.1 为什么是这三件套,而不是别的组合
这个项目的技术栈很明确:Qt(Widgets 或 QML 二选一,工程里通常是 Widgets 为主)、FFmpeg 的 libavformat / libavcodec / libswscale / libswresample 四个库、以及 Qt Multimedia 里的 QAudioOutput。选型逻辑其实不复杂:FFmpeg 负责“把任何格式变成原始帧和 PCM”,Qt 负责“把原始帧画到控件上、把 PCM 喂给声卡”,中间用 swscale 做像素格式转换(YUV420P → RGB32)、用 swresample 做音频重采样(各种采样率/声道布局 → 声卡能吃的格式)。这套组合的好处是解码能力和界面能力解耦,坏处是音视频同步、线程模型、资源释放全得自己管,这也是后面避坑章节要重点说的部分。
常见做法是把解码放在独立线程里跑,主线程只做 UI 刷新和事件分发。因为 FFmpeg 的 av_read_frame 和 avcodec_send_packet/receive_frame 都是阻塞式的,放在主线程里一读大文件界面就卡成 PPT。工程里一般会有一个 DecodeThread 或者用 QThread + moveToThread 的 Worker 对象,通过信号槽把解码好的帧和音频数据抛回主线程。
2.2 目录结构长什么样
这类工程的目录通常不会太深,核心就几个文件夹。下面是我按常见组织方式还原的结构,具体文件名可能略有差异,但职责划分基本一致:
player/ ├── main.cpp // 入口,注册元类型、启动主窗口 ├── player.pro // qmake 工程文件,链接 FFmpeg 与 Qt Multimedia ├── ui/ │ ├── mainwindow.h/.cpp // 主窗口:菜单、进度条、按钮、视频显示区 │ └── videowidget.h/.cpp // 继承 QWidget,重写 paintEvent 画 RGB 帧 ├── core/ │ ├── decoder.h/.cpp // 封装 avformat/avcodec,产出视频帧与音频 PCM │ ├── audiothread.h/.cpp // QAudioOutput + QIODevice,消费 PCM │ └── syncclock.h/.cpp // 音视频同步用的时钟 └── utils/ ├── screenshot.h/.cpp // 截图:当前帧转 QImage 存盘 └── recorder.h/.cpp // 录像:编码或直接存原始流这个划分的关键点是:decoder 不碰 UI,audiothread 不碰解码,mainwindow 只做编排。很多新手把解码、绘制、音频全塞进 MainWindow,跑小文件没事,一上 1080p 就露馅。把职责切开之后,截图和录像这类功能才有地方挂——截图本质是“拿当前显示的那一帧 QImage 存盘”,录像本质是“在解码输出链路上多接一个消费者”。
2.3 工程文件里必须写对的两处
.pro文件里最容易翻车的是 FFmpeg 的头文件和库路径。Windows 下通常是:
# player.pro 片段:FFmpeg 链接配置(Windows MinGW 示例) INCLUDEPATH += $$PWD/ffmpeg/include LIBS += -L$$PWD/ffmpeg/lib \ -lavformat -lavcodec -lswscale -lswresample -lavutil # Qt Multimedia 用于 QAudioOutput QT += multimedia multimediawidgets逻辑说明:INCLUDEPATH指向 FFmpeg 的 include 目录,LIBS里-L指定库目录,后面按依赖顺序列库。注意-lavutil要放最后,因为它被其他库依赖。参数上,如果你用的是 MSVC 而不是 MinGW,库名不带lib前缀的写法要相应调整,且要保证 FFmpeg 的编译器和 Qt 的编译器一致(32/64 位、运行库版本都要对齐),否则链接期会报一堆 undefined reference,这是血泪经验里最常见的一条。
3. 核心链路拆解:一个视频文件从打开到出声的完整路径
3.1 打开文件与流信息探测
一切从 avformat_open_input 开始。这一步做的是解封装:识别容器格式、读出有几条流、每条流的编码参数。代码骨架大概是这样:
// decoder.cpp:打开输入并定位音视频流 bool Decoder::open(const QString &path) { if (avformat_open_input(&fmtCtx, path.toUtf8().constData(), nullptr, nullptr) < 0) return false; // 打开失败:路径或格式问题 if (avformat_find_stream_info(fmtCtx, nullptr) < 0) return false; // 探测流信息失败 videoIndex = av_find_best_stream(fmtCtx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); audioIndex = av_find_best_stream(fmtCtx, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); if (videoIndex >= 0) { AVCodecParameters *par = fmtCtx->streams[videoIndex]->codecpar; const AVCodec *codec = avcodec_find_decoder(par->codec_id); videoCtx = avcodec_alloc_context3(codec); avcodec_parameters_to_context(videoCtx, par); avcodec_open2(videoCtx, codec, nullptr); } // 音频流同理:audioCtx = ...,avcodec_open2 return true; }逻辑说明:avformat_open_input只负责打开容器,真正的编码参数要等avformat_find_stream_info探测完才可靠。av_find_best_stream帮你在多音轨/多字幕的容器里挑出默认流,比自己遍历 streams 稳妥。参数上,codecpar是流的编码参数副本,必须通过avcodec_parameters_to_context灌进解码上下文,不能直接把stream->codec拿来用(那是老 API,新版本已废弃)。这一步失败时优先看返回值:负数对应 AVERROR 宏,AVERROR_INVALIDDATA多半是文件损坏,AVERROR_STREAM_NOT_FOUND说明容器里没有你要的流类型。
3.2 解码循环与帧格式转换
解码是“送 packet、收 frame”的循环。视频帧出来是 YUV 系列格式,要转成 RGB 才能用 QImage 显示;音频帧出来是各种采样格式,要转成声卡支持的 S16 或 F32。视频侧的核心代码:
// decoder.cpp:读包 → 解码 → 转 RGB while (av_read_frame(fmtCtx, pkt) >= 0) { if (pkt->stream_index == videoIndex) { avcodec_send_packet(videoCtx, pkt); while (avcodec_receive_frame(videoCtx, frame) == 0) { // YUV → RGB32,输出到 rgbBuf sws_scale(swsCtx, frame->data, frame->linesize, 0, videoCtx->height, rgbFrame->data, rgbFrame->linesize); emit frameReady(QImage(rgbBuf, width, height, QImage::Format_RGB32)); } } av_packet_unref(pkt); // 每包必须 unref,否则内存持续上涨 }逻辑说明:avcodec_send_packet和avcodec_receive_frame是新版解码 API,一个 packet 可能产出 0 到多帧,所以 receive 要放在内层循环里直到返回AVERROR(EAGAIN)或AVERROR_EOF。sws_scale的linesize参数不能想当然填 width,必须用frame->linesize[0],因为解码器输出可能有行对齐填充,填错会花屏。av_packet_unref是必须的,漏掉就是内存泄漏,跑几分钟内存飙到几个 G,这是排查章节里会再提的经典问题。
音频侧同理,只是转换用swr_convert,把任意采样率/声道布局统一成 QAudioOutput 的格式(通常是 44100Hz、双声道、S16)。转换器的初始化要放在打开文件之后、第一次解码之前,参数从audioCtx里取sample_rate和ch_layout。
3.3 QAudioOutput 出声与音视频同步
QAudioOutput 的用法是“给格式、拿 QIODevice、往里写 PCM”。工程里一般会包一个自定义 QIODevice,把解码线程产出的 PCM 塞进环形缓冲区,QAudioOutput 从缓冲区里拉数据:
// audiothread.cpp:初始化音频输出并写入 PCM QAudioFormat fmt; fmt.setSampleRate(44100); fmt.setChannelCount(2); fmt.setSampleFormat(QAudioFormat::Int16); audioOut = new QAudioOutput(fmt, this); audioDevice = audioOut->start(); // 返回内部 QIODevice,往里 write 即可 // 解码线程拿到 PCM 后 qint64 written = audioDevice->write(pcmData, pcmBytes); if (written < pcmBytes) { // 缓冲区满,剩余数据要缓存,不能丢,否则声音断续 }逻辑说明:QAudioOutput::start()返回的 QIODevice 是只写设备,write返回实际写入字节数,小于请求值时说明内部缓冲满了,剩下的必须自己缓存等下次再写,直接丢弃会听到“咔咔”的爆音。参数上,setSampleFormat要和 swr_convert 的输出格式严格一致,Int16 对应AV_SAMPLE_FMT_S16,Float 对应AV_SAMPLE_FMT_FLT,对不上就是噪音。
音视频同步是这个工程里最“玄学”的部分。常见做法是以音频时钟为主时钟:音频是连续播放的,人耳对声音断续极敏感,视频帧则按“当前音频播放到哪个时间点”来决定是显示、丢弃还是等待。工程里一般有一个 SyncClock,音频线程每写一批 PCM 就更新时钟,视频线程拿到帧的 pts 后和时钟比较,差值超过阈值就丢帧或延时。这块没有银弹,阈值调大了画面抖,调小了频繁丢帧,我一般先用 40ms 左右起步再微调。
4. 截图与录像:在解码链路上挂两个消费者
4.1 截图:拿当前帧转 QImage 存盘
截图看着简单,坑在“拿哪一帧”。正确做法是保存最近一次成功显示的那帧 QImage,而不是在点击瞬间去解码器里抢帧——后者会和播放线程抢锁,轻则卡顿重则崩溃。工程里通常维护一个QImage lastFrame,在frameReady槽里更新,截图时直接lastFrame.save(path)。如果要带时间戳命名,用QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss")拼文件名即可。注意 QImage 的深拷贝问题:如果 lastFrame 是从 rgbBuf 构造的,要确保 rgbBuf 生命周期覆盖到 save 完成,稳妥做法是构造时直接QImage(...).copy()。
4.2 录像:多接一个消费者而不是重开一条解码链
录像的常见误区是“再开一个 FFmpeg 编码器从头解码一遍”,那样 CPU 直接翻倍。合理做法是在解码输出处多挂一个消费者:视频帧到了,除了送显示,还送编码器(或直接按原始流写文件);音频 PCM 同理。如果只是“录一段原始流”,甚至可以不编码,直接把av_read_frame读到的 packet 按时间范围写进输出容器,速度快、质量无损,缺点是输出格式受限于源封装。要转码成 MP4 就得走avcodec_send_frame+av_interleaved_write_frame那一套,工程里一般会封一个 Recorder 类,start/stop 控制开关,内部维护输出上下文和编码器上下文。参数上,录像的起始时间用当前播放位置(音频时钟)而不是系统时间,否则录出来的片段和画面会对不上。
5. 避坑与排查:这几处翻车我见过太多次
5.1 现象:界面卡死、进度条不动
原因:解码循环跑在主线程,av_read_frame阻塞时事件循环被占死。解决:把解码放进 QThread 或 moveToThread 的 Worker,UI 只通过信号槽收帧;同时注意别在槽里做重活,paintEvent里只做 drawImage。
5.2 现象:跑几分钟内存暴涨
原因:av_packet_unref或av_frame_unref漏调,或者 sws/swr 上下文没释放。解决:每个 packet 用完必 unref,frame 在 receive 循环里每轮 unref,关闭文件时按“反初始化顺序”释放 swr → sws → codecCtx → fmtCtx,顺序错了会崩。
5.3 现象:有画面没声音,或声音是噪音
原因:QAudioFormat 和 swr_convert 输出格式不一致,或声道布局没设对。解决:打印audioOut->format()和 swr 输出参数逐项比对,采样率、声道数、采样格式三项必须完全一致;噪音多半是 Int16 和 Float 搞混了。
5.4 现象:链接期一堆 undefined reference
原因:FFmpeg 库的编译器/位数和 Qt 不一致,或库顺序写错。解决:确认 MinGW/MSVC 版本匹配、32/64 位一致,-lavutil放最后;Windows 下还要把 FFmpeg 的 dll 拷到可执行文件同目录,否则运行期直接起不来。
5.5 现象:拖动进度条后花屏或音画不同步
原因:seek 之后没有 flush 解码器缓冲,旧帧混进新位置。解决:av_seek_frame之后调avcodec_flush_buffers,同时清空音频环形缓冲和同步时钟,再重新起播。
6. 二次开发怎么改:从“能播”到“好用”的几条改造路径
拿到这个工程之后,最值得先动的是三处。第一处是播放列表和拖拽打开:MainWindow 里加一个 QListWidget,把dragEnterEvent/dropEvent接上,文件路径入列表,双击切换Decoder::open,这块改动小、收益直观。第二处是倍速播放:音频侧改 QAudioOutput 的采样率不现实,正确做法是在 swr_convert 之后按倍速重采样(1.5 倍速就是把输出采样率按比例调整再喂给固定采样率的声卡),视频侧则按倍速调整同步时钟的推进速度,两处要一起改,只改一边必然音画分离。第三处是录像格式选择:把 Recorder 的输出封装做成可配置,MP4 适合分享但需要转码,MKV 容错好、支持直接写原始流,给用户一个下拉框选就行。
验证方法我一般用“三件套”:一个 4K 高码率文件测性能和内存、一个多音轨 MKV 测流选择、一个损坏文件测异常分支不崩。改完同步逻辑后,专门找一段有连续对白的片子,听有没有爆音、看口型对不对得上,这比看任何日志都直接。
有个习惯我保持了挺久:每次动解码或同步相关代码之前,先把当前能跑的版本打个 tag,改完用上面三件套过一遍,对不上就回滚。因为音视频这类问题排查成本极高,一个 seek 后的花屏可能查一整天,留好后悔药比什么都强。希望这份拆解能帮你少走点弯路,把这个播放器改成自己顺手的样子。
本文还有配套的精品资源,点击获取