简介:Qt 配合 FFmpeg 实现跨平台视频播放器是不少客户端开发者的常见需求。这份资源面向已有 Qt 基础、想在项目中接入 FFmpeg 以兼容更多音视频格式的开发人员,通过一个可运行的播放器工程演示了完整集成思路:从 FFmpeg 库的编译链接,到继承 QAbstractMediaService 自定义解码服务,再到界面层播放、暂停、快进、进度跳转、音量控制与异常反馈的实现。压缩包共 270 个文件、34.26MB,以 180 个头文件、22 个 lib 与 18 个 dll 动态库、16 个 a/def 静态导入库为主体,构成 FFmpeg 链接与调用基础;4 个 cpp 与 1 个 ui 文件对应播放器主逻辑和界面设计,qrc 资源、ico 图标、mp4 示例视频及 pro 工程文件让读者可以直接用 Qt Creator 打开工程对照学习。这份资料还覆盖了 avcodec_find_decoder、avcodec_open2、AVFormatContext 打开文件、AVStream 获取流、YUV/RGB 渲染到 QVideoWidget 等关键调用点,并给出元数据显示与错误处理框架,方便二次开发。目前已有 1070 人学习,适合希望深度掌握 Qt 与 FFmpeg 媒体管道、并快速落地到实际项目的读者。 我最早接触 Qt 和 FFmpeg,是想给项目里加一个简单又好用的视频播放模块。Qt 自带的 QMediaPlayer 说实话够用,但碰到一些非常规格式、或者想对解码后的帧做实时处理时,就会明显感觉使不上劲。后来花了几个晚上用 FFmpeg 的库直接对接 Qt,把解码、显示、音频输出、同步这些环节一个一个打通,总算是完完整整跑起来了。这篇文章就把这个过程中最有参考价值的东西整理出来,包含架构选型、工程配置、代码骨架、同步思路和踩坑记录,给正准备用 Qt + FFmpeg 做播放器的朋友一份可以少走弯路的参考。
1. 播放器方案选型与整体架构设计
我之前也纠结过一个问题:既然 Qt 已经提供了 QMediaPlayer,为什么还要绕一圈去调 FFmpeg?这个问题的答案,直接决定了整个项目的技术路线。
1.1 QMediaPlayer 与 FFmpeg 方案的边界
QMediaPlayer 的优势是接入简单,几行代码就能拖动一个窗口播放本地视频。但它有一个绕不开的短板:解码能力受限于 Qt Multimedia 后端的实现。在 Windows 上依赖系统自带的 DirectShow 或 WMF,在 Linux 上依赖 GStreamer,一旦遇到系统没安装对应解码器或格式较冷门的文件,就直接罢工。而且 QMediaPlayer 不给你访问原始帧数据的机会,想做滤镜、截图、逐帧分析、定制渲染,基本没门。
FFmpeg 是一个纯粹的编解码库,自带 libavcodec、libavformat、libswscale、libswresample 等模块,支持的封装格式和编码格式覆盖面非常广。它不管界面的事,把解码出来的原始数据交给你,剩下怎么显示、怎么播放、怎么同步,完全由自己掌控。这既是优势也是成本:界面、线程、音频输出、同步逻辑全都要自己搭。
1.2 整体模块划分
我最终的播放器架构分成了四个核心模块:
- 解封装与解码模块:基于 FFmpeg 的 libavformat 读取容器格式,通过 libavcodec 解码出原始视频帧和音频采样。
- 视频显示模块:把解码出的 YUV 帧转换为 RGB 图像,交给 Qt 的 QLabel 或 QOpenGLWidget 渲染。
- 音频输出模块:把 PCM 数据喂给 Qt 的 QAudioSink,实现出声。
- 同步控制模块:以音频时钟为参考,控制视频帧的显示节奏,实现音画同步。
这几个模块放在独立的解码线程中,避免阻塞 UI 线程。UI 线程只负责接收 QImage 并显示,音频数据则通过队列传递给音频输出接口。这样拆分的最大好处是职责清晰,后续想加暂停、定位、截图功能时,只需要在对应模块上扩展。
提示:如果对实时性要求不高,只是想在 Qt 里放一个能用的视频窗口,QMediaPlayer 确实更省事。但如果你想深入理解播放器原理,或者对格式兼容性、帧处理有更高要求,FFmpeg 方案是非常值得投入时间的选择。
2. 开发环境准备与工程构建
在动手写代码之前,先把环境搭好。这部分看起来琐碎,但很多新手会在这里卡住,特别是链接库那一堆依赖关系。
2.1 版本选型与下载
我使用的是 Qt 5.15.2 和 FFmpeg 4.4(后来也试过 FFmpeg 6.x,接口基本兼容)。Qt 5.15.2 是 LTS 版本,稳定,资料多,适合开发桌面应用。FFmpeg 4.4 也是 LTS 分支,API 变动相对小,网上能查到的资料大部分都适用于这个版本。
如果是在 Windows 上开发,FFmpeg 官方不直接提供 Windows 编译版本,一般去 gyan.dev 或 BtbN 的 GitHub Releases 下载已编译好的 shared 版本。shared 版本里面包含 dll、lib 和头文件,适合做二次开发。
Qt 的话,官方安装包在国内下载可能会慢一些,可以走 Qt 的国内镜像站,比如清华 TUNA 的镜像源。装的时候注意勾选 Qt 5.15.2 对应的 MSVC 编译器版本,别只装 Qt 库却忘了装编译器工具链。
2.2 CMake 工程配置
整个项目用 CMake 构建,比 qmake 更灵活,也更符合现代 C++ 项目的习惯。核心配置是这样的:
cmake_minimum_required(VERSION 3.16) project(QtFfmpegPlayer) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) find_package(Qt5 COMPONENTS Widgets Multimedia REQUIRED) set(FFMPEG_ROOT "D:/libs/ffmpeg-4.4") set(FFMPEG_INCLUDE_DIR "${FFMPEG_ROOT}/include") set(FFMPEG_LIB_DIR "${FFMPEG_ROOT}/lib") add_executable(QtFfmpegPlayer main.cpp MainWindow.ui PlayerCore.h PlayerCore.cpp ) target_include_directories(QtFfmpegPlayer PRIVATE ${FFMPEG_INCLUDE_DIR}) target_link_directories(QtFfmpegPlayer PRIVATE ${FFMPEG_LIB_DIR}) target_link_libraries(QtFfmpegPlayer PRIVATE Qt5::Widgets Qt5::Multimedia avformat avcodec avutil swscale swresample )有几个点需要特别强调。
第一,FFmpeg 的头文件和库目录一定要正确指定,否则编译时会报找不到头文件。
第二,链接顺序有讲究。在 MSVC 环境下,avformat 依赖 avcodec 和 avutil,avcodec 依赖 avutil,swscale 和 swresample 也依赖 avutil,所以通常把 avutil 放在最后,让链接器能正确解析符号依赖。如果你在链接时遇到一堆 unresolved external symbol,多半就是顺序问题。
第三,Debug 和 Release 版本的库不能混用。Qt 的 debug 库后缀是 d,FFmpeg 的库如果只有 release 版本,那整个项目也统一用 release 构建,否则会碰到运行时崩溃或解码异常的问题。
2.3 动态库运行时配置
编译通过只是第一步。程序运行时,由于 FFmpeg 是动态链接的,必须让系统能找到对应的 dll 文件。两个做法:
直接把 FFmpeg 的 bin 目录下的所有 dll 复制到 exe 所在目录。这是最简单也最粗暴的方法,发布时也用得上。
在 Qt Creator 里,把 FFmpeg 的 bin 目录路径加入到 PATH 环境变量或者构建环境的 PATH 设置中,方便开发调试。
网上经常看到有人运行时提示找不到 avformat-*.dll,或者代码能编译但一运行就崩溃,一部分原因就是 dll 没跟着跑起来。另有一个高频报错是 Windows 下提示 no qt platform plugin could be initialized,这个多半是 Qt 的 platforms 插件目录没有随程序正确发布导致的,后面在常见问题里我会再详细说。
注意:FFmpeg 在 Windows 下的 Debug 构建版本并不好找,多数预编译包只提供 release 版本。如果项目必须用 Debug 模式调试,建议在 CMake 里做一次判断,Debug 模式也链接 release 库,并确保全局使用同样的运行时库设置,以免出现运行时崩溃。
3. FFmpeg 解封装与解码流程实现
环境没问题之后,就开始写核心逻辑了。解封装和解码是播放器的前半段,就是把视频文件中压缩的数据还原成原始的图像和声音。
3.1 打开媒体文件与查找流信息
第一步是打开文件,拿到格式上下文,并从容器中探测出视频流、音频流和对应的解码器。
extern "C" { #include <libavformat/avformat.h> #include <libavcodec/avcodec.h> #include <libswscale/swscale.h> #include <libswresample/swresample.h> #include <libavutil/imgutils.h> } bool PlayerCore::openFile(const QString& filePath) { // 1. 打开输入文件 if (avformat_open_input(&m_formatCtx, filePath.toStdString().c_str(), nullptr, nullptr) != 0) { return false; } // 2. 读取媒体信息,找到视频流和音频流 if (avformat_find_stream_info(m_formatCtx, nullptr) < 0) { return false; } // 3. 查找最佳视频流 m_videoStreamIndex = av_find_best_stream(m_formatCtx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); m_audioStreamIndex = av_find_best_stream(m_formatCtx, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); if (m_videoStreamIndex < 0) { // 既有视频也有音频,才算是完整的播放器需求 return false; } m_videoStream = m_formatCtx->streams[m_videoStreamIndex]; // 4. 打开视频解码器 const AVCodec* videoDecoder = avcodec_find_decoder(m_videoStream->codecpar->codec_id); m_videoCodecCtx = avcodec_alloc_context3(videoDecoder); avcodec_parameters_to_context(m_videoCodecCtx, m_videoStream->codecpar); if (avcodec_open2(m_videoCodecCtx, videoDecoder, nullptr) < 0) { return false; } // 5. 音频解码器按同样步骤打开 // ... return true; }av_find_best_stream 这个函数很好用,它会自动选择最合适的流,省去自己遍历 streams 数组去判断编码类型的麻烦。注意旧版本的 FFmpeg 用的是 codec,新版本用 codecpar,如果你看的老博客用的是旧的赋值方式,编译时会报字段不存在的错误。
3.2 读取数据包与解码
打开解码器之后,就进入主循环:循环读取压缩数据包,把包交给解码器,解码器输出原始帧,然后再把帧做后续处理。
AVPacket* packet = av_packet_alloc(); AVFrame* frame = av_frame_alloc(); while (av_read_frame(m_formatCtx, packet) >= 0) { if (packet->stream_index == m_videoStreamIndex) { // 发送给视频解码器 if (avcodec_send_packet(m_videoCodecCtx, packet) == 0) { while (avcodec_receive_frame(m_videoCodecCtx, frame) == 0) { // 拿到一个解码后的视频帧 processVideoFrame(frame); } } } else if (m_audioStreamIndex >= 0 && packet->stream_index == m_audioStreamIndex) { // 音频包走同样的流程,解码后交给音频输出 if (avcodec_send_packet(m_audioCodecCtx, packet) == 0) { while (avcodec_receive_frame(m_audioCodecCtx, frame) == 0) { processAudioFrame(frame); } } } av_packet_unref(packet); }这里有一个关键的编码方式设计:FFmpeg 新一代的 API 是 send/receive 模式,即发送一个压缩包进去,解码器可能输出 0 个、1 个或多个原始帧。必须用 while 循环把解码器内部缓冲的所有帧都取出来,不能只取一次。
3.3 视频转格式:YUV 到 RGB
FFmpeg 解码出来的视频帧一般是 YUV420P 格式,但 Qt 的 QImage 显示最方便的是 RGB32 格式。这个转换用 FFmpeg 自带的 swscale 模块完成,效率很高。
void PlayerCore::processVideoFrame(AVFrame* frame) { // 第一次处理时初始化格式转换上下文 if (m_swsCtx == nullptr) { m_swsCtx = sws_getContext( frame->width, frame->height, AV_PIX_FMT_YUV420P, frame->width, frame->height, AV_PIX_FMT_RGB32, SWS_BILINEAR, nullptr, nullptr, nullptr); m_rgbBuffer.resize(av_image_get_buffer_size(AV_PIX_FMT_RGB32, frame->width, frame->height, 1)); } uint8_t* rgbData[1] = { m_rgbBuffer.data() }; int rgbLineSize[1] = { frame->width * 4 }; sws_scale(m_swsCtx, frame->data, frame->linesize, 0, frame->height, rgbData, rgbLineSize); QImage image(rgbData[0], frame->width, frame->height, rgbLineSize[0], QImage::Format_RGB32); // 通过信号把图片发给 UI 线程 emit frameReady(image.copy()); }QImage 默认是浅拷贝,只保存像素指针,不复制数据,所以这里必须调用 copy() 做一次深拷贝,否则传给 UI 线程后原始缓冲区被复用,图像内容就乱了。这是我最早写播放器时踩过的一个坑,图像花屏闪烁,找了好久才发现是这个原因。
4. 音频输出与音视频同步
视频图像能一帧一帧出来之后,接下来要解决两个问题:声音怎么放出来,以及画面和声音怎么对上。
4.1 Qt 的音频输出接口实现
我用了 Qt Multimedia 里的 QAudioOutput 来播放 PCM 数据。这个类会把 QByteArray 或 QIODevice 里的数据写入音频设备,底层是跨平台的,省去自己调 Windows/ALSA/CoreAudio 的麻烦。
初始化代码:
// 假设声道数是 2,采样率 44100,采用 16 位有符号整数 QAudioFormat fmt; fmt.setSampleRate(44100); fmt.setChannelCount(2); fmt.setSampleFormat(QAudioFormat::UInt8); // 注意这里 m_audioOutput = new QAudioOutput(fmt); m_audioIO = m_audioOutput->start();编码细节需要说明一下:不同平台和不同 FFmpeg 解码出的音频格式并不一样,很多音频文件解码出来是 AV_SAMPLE_FMT_FLTP,即浮点 planar 格式,而 QAudioOutput 最通用的是有符号 16 位交错格式。因此你需要用 swresample 做一次音频重采样,把解码出的 AVFrame 转成目标格式后再塞给音频输出。如果不做转换,轻则无声,重则杂音剧烈。
音频重采样示例:
// 初始化转换器 SwrContext* swr = swr_alloc_set_opts(nullptr, outChannelLayout, AV_SAMPLE_FMT_S16, outSampleRate, inChannelLayout, (AVSampleFormat)frame->format, (int)frame->sample_rate, 0, nullptr); swr_init(swr); // 转换数据 uint8_t* outBuffer = nullptr; int outSamples = swr_get_out_samples(swr, frame->nb_samples); av_samples_alloc(&outBuffer, nullptr, outChannels, outSamples, AV_SAMPLE_FMT_S16, 0); int converted = swr_convert(swr, &outBuffer, outSamples, (const uint8_t**)frame->extended_data, frame->nb_samples); // 然后 m_audioIO->write((const char*)outBuffer, converted * outChannels * 2);4.2 音视频同步的常用策略
播放器最难的部分不是解码,而是同步。我采用的是最经典、也最实用的方案:以音频时钟为主时钟,视频向音频对齐。具体思路是这样的:
- 维护一个全局的音频时钟,即当前正在播放的音频已经播到了第几秒。
- 视频帧在显示之前,比较自己的 PTS(展示时间戳)和音频时钟的差值。
- 如果视频领先音频超过一定阈值,就等待,比如休眠几十毫秒再判断。
- 如果视频落后音频,就放弃显示这一帧,直接跳过,让画面快点追上声音。
计算视频帧 PTS 秒数的方式:
double pts = frame->pts * av_q2d(m_videoStream->time_base);音频时钟的获取,我用的方案是:记录音频输出启动时的 QAudioOutput 的 processedUSecs 减去已写入的数据累计时间。更简单一点,可以用一个计数器累加每次写入音频设备的样本数,再除以采样率得到当前播放位置。当然启动和暂停的时候这个时钟会复杂一点,但基本的播放下,累加写入样本数的方式已经够用。
有了时钟后,视频显示逻辑可以简化成伪代码:
double audioClock = getAudioClock(); double videoPts = getVideoPts(frame); if (videoPts > audioClock) { // 画面快了,等一下 double delayMs = (videoPts - audioClock) * 1000; QThread::msleep(qMin((int)delayMs, 100)); // 最多睡100ms,避免卡死UI } else if (audioClock - videoPts > 0.1) { // 画面落后太多,跳过这帧 av_frame_unref(frame); continue; }同步算法的具体阈值可以根据实际体验调整。阈值太小会觉得卡顿,太大会觉得不同步。我最后用的是声音领先的画面超前 30ms 内不丢帧,画面落后 50ms 内强制显示的策略,试了几轮视频下来体感正常。
4.3 暂停与跳转的同步处理
如果停了播放,又原地开始,音频和视频的时间基准就不一致了。最简单的处理方式是:暂停时记住当前的音频时钟偏移量,恢复时将偏移量加到所有帧的 PTS 比较逻辑上。跳转时则调用 av_seek_frame 或者 avformat_seek_file 进行关键帧定位,跳转后清空解码器内部缓冲,同时重置音频时钟。
跳转一次之后,如果直接读到非关键帧,解码器无法立即输出完整画面,这是正常现象。FFmpeg 在 seek 后会自动往关键帧跳,不过视频可能会有几十毫秒到几百毫秒的黑屏或花屏过渡,这符合正常的播放器行为。
5. 工程构建中的高频报错与排查记录
最后把这几个月的实践中碰到最多的坑总结一下。这些报错在论坛和问答平台里几乎天天能看到,很多人卡住很久,我把原因和解决办法直接列出来。
5.1 高频问题排查表
| 报错现象 | 可能原因 | 解决办法 |
|---|---|---|
| 运行时提示 no qt platform plugin could be initialized | 程序目录缺少 platforms/qwindows.dll | 用 windeployqt 部署插件目录,或在开发环境检查 PATH 是否包含 Qt 的 plugins 目录 |
| 编译时报 avformat.h 找不到 | FFmpeg 头文件路径未指定 | 检查 CMake 的 include_directories 路径,确认是 include 目录,而不是 bin 目录 |
| 链接报 unresolved external symbol | 库链接顺序不对,或库版本与编译器不匹配 | FFmpeg 库放在依赖库之前;确认库用的编译器架构和当前一致 |
| 程序运行后立刻崩溃 | FFmpeg 是 release 库但工程是 debug 模式 | 统一用 release 模式编译,或换用带 debug 符号的库 |
| 有图像没声音 | 音频格式未转换,或采样率不匹配 | 检查 QAudioFormat 参数,用 swresample 把 PCM 转成 S16 交错格式 |
| 画面播放速度过快 | 缺少同步逻辑,帧直接全速显示 | 引入音频时钟,显示前判断 PTS 差值,等待或丢帧 |
| 视频出现花屏、闪烁 | QImage 浅拷贝导致缓冲区被覆盖 | 信号发出前调用 QImage::copy() 深拷贝 |
| 视频结束后 UI 卡死 | 解码循环和 UI 线程直接交互 | 保证解码、读取、处理都在子线程,只通过信号和 UI 通信 |
5.2 发布部署的注意事项
程序开发完要发布到其他机器时,需要把 Qt 的运行库和 FFmpeg 的 dll 打包完整。Qt 官方提供的 windeployqt 工具可以自动分析 exe 依赖并拷贝对应的 Qt 插件和运行库,但 FFmpeg 的 dll 必须手动一起复制进去。我用 windeployqt 部署完,又手动把 FFmpeg 的 bin 目录下所有 dll 放到 exe 同一目录下,测试了几台没有 Qt 环境的新电脑,都能正常运行。
有次我在项目里还遇到过一个比较隐蔽的问题:在 Windows 上 FFmpeg 如果打开了硬件解码(比如 cuda、dxva2),发布时需要额外带上对应依赖,而且目标机器还得有对应显卡驱动,否则会运行报错。我没有在通用版本里启用硬件解码,只在特定需求的版本中做了条件编译。
5.3 调试技巧与性能优化
开发过程中有一些很有效的排查手段:
用 av_err2str 函数把 FFmpeg 返回的错误码转换成字符串错误信息,可以快速判断是文件打开失败、流未找到,还是解码器内出现异常。
视频转 RGB 时,SWS_BILINEAR 是通用选择,如果需要更高性能,可以试试 SWS_FAST_BILINEAR,质量差一点点但速度快不少。如果做实时预览或处理高分辨率视频,建议用 QOpenGLWidget 配合着色器直接做 YUV 到 RGB 的转换,比 sws_scale 再交给 QImage 要快得多。
解码线程是纯计算密集型任务,在 Qt 的信号槽中传递 QImage 虽然方便,但频繁的拷贝会影响性能。我后续优化时把视频帧队列改为使用无锁队列直接传递 QImage,把 UI 刷新通过定时器定时从队列取图,这样能显著减少 UI 线程的压力。
从整体体验来看,Qt + FFmpeg 这个组合除了开发初期搭建环境需要一点耐心之外,后面基本是一马平川,非常自由,几乎所有音视频层的需求都能自己掌控。你完全可以基于这套架构继续扩展,比如加字幕、做帧截图、加滤镜、支持网络流播放,这也是我当初选择 FFmpeg 而不是其他方案的原因所在。
本文还有配套的精品资源,点击获取