简介:一套 FFmpeg + Qt 开发的视频播放器完整工程,源自《从零开始学习音视频编程技术》系列的 Bug 修复最终完善版,面向音视频编程初学者与播放器模块化设计开发者。基于 Qt 5.6.2(VS2013)、FFmpeg 2.5.2 与 SDL 2.04 构建,压缩包共 250 个文件,总大小 15.33MB,其中 165 个 h 头文件、14 个 cpp 源文件构成播放器核心逻辑,11 个 lib 与 9 个 dll 提供 FFmpeg/SDL 依赖,3 个 ui 文件对应界面布局,另有 pro/pri 工程配置便于编译。目前已有 554 人学习。该版本重点修复 SDL 打开失败后视频不播放的 Bug,支持播放无音频流的视频文件与纯音频文件,并把底层播放器与 Qt 界面拆为两模块,底层纯 C++ 编写,方便跨平台复用。资源内还包含界面图标、GitHub 源码地址,可帮助读者梳理播放流程、理解音视频同步思路并借鉴其排错方式。 功能写齐全以后,我本来以为播放器就算完工了。结果一测,崩溃、花屏、音画不同步、关闭时卡死,问题一个接一个。这篇文章就是把我修复这些BUG的完整过程整理出来,每一步都记录了排查思路和最终解决方案,给正在用FFmpeg和Qt做播放器的朋友当个参考。
整个项目可以概括为:基于FFmpeg做音视频解码,用Qt搭建播放界面和交互逻辑,通过SDL或QPainter完成画面渲染。开发环境是Windows 10 + FFmpeg 5.x + Qt 5.15.2,编译器用的MSVC2019 64位。网上搜到的很多零散问题和报错,比如qt崩溃、平台插件初始化失败、打包后无法运行等,其实都能在这次修复中找到对应的根因。
1. 功能全跑通后,我为什么还要专门做一轮BUG修复
播放器的基础功能很简单:打开文件、播放、暂停、拖动进度条、停止、音量调节、倍速播放。第一版代码流程上也跑得通,视频能出画面,声音能出声,但只停留在“能跑”的阶段。
第一个问题出现在我连续切换文件的时候。打开第二个视频,程序直接崩溃。重启再试,第一个视频播放到一半,拖动进度条,又崩了。更离谱的是,关闭播放器的时候,窗口有时候要卡好几秒才消失,有时候直接弹出一个“已停止工作”的对话框。这种体验别说给别人用了,自己都忍不了。
我还测试了异常场景:播放过程中拔出音频设备、网络流断开、打开一个损坏的视频文件、连续快速拖动进度条。这些场景在第一版里几乎没有一个能正常处理。有的直接崩溃,有的界面卡住不动,有的声音还在播但画面已经冻结了。
这说明一个问题:功能完成度和工程质量是两回事。我重新梳理了播放器整个架构,把问题分成了三类:线程安全问题、资源生命周期问题、边界状态处理问题。
| 问题类别 | 典型案例 | 直接后果 |
|---|---|---|
| 线程竞争 | 解码线程与UI线程同时操作同一个变量 | 崩溃、数据错乱 |
| 资源生命周期 | QImage引用已释放的AVFrame内存 | 花屏、随机崩溃 |
| 边界状态 | 播放结束/seek/切换文件/设备拔出 | 卡死、无响应 |
2. 三类崩溃的完整排查链路:从偶发闪退到反复复现
2.1 崩溃一:解码线程与UI线程访问了同一块内存
第一版的设计是:解码线程通过信号把QImage发给主线程,主线程负责显示。问题就出在数据传递的环节。我在解码线程里直接使用QImage的构造方式创建图像数据,然后把QImage当作参数通过信号发送出去。看起来没有共享内存,但实际上这个构造过程会引用FFmpeg解码出来的AVFrame中的数据,数据并没有被真正复制。
这就是崩溃的根源。解码线程解码完一个帧就调用av_frame_unref释放内存,但UI线程还在用这块内存绘制画面。释放和使用的时序完全取决于线程调度,所以这个崩溃是偶发的,有时播放几分钟才出现,有时几秒就崩。
排查思路是这样的:我先把播放器置为Debug编译模式,在崩溃时查看调用堆栈。发现崩溃位置在QImage绘制相关函数,而上一个调用是主线程的paintEvent。这就奇怪了,解码线程没有直接调用绘制,那一定是通过某种间接方式访问了已释放内存。
进一步在解码线程发送图像的代码处加断点,发现我传递给信号的QImage对象使用的数据指针,确实指向AVFrame内部的data[0]。然后我又在av_frame_unref处加断点,确认了释放顺序。真相大白。
修复方案很简单,就一句话:将AVFrame的数据拷贝到QImage自己的缓冲区中,而不是引用它。代码如下:
// 正确做法:深拷贝像素数据 QImage createImageFromFrame(AVFrame* frame, int width, int height) { QImage image(width, height, QImage::Format_RGB32); // 注意:必须逐行拷贝,不能直接 memcpy 整个数据区 for (int y = 0; y < height; ++y) { memcpy(image.scanLine(y), frame->data[0] + y * frame->linesize[0], width * 4); } return image; }为什么必须逐行拷贝而不是一次性memcpy?因为FFmpeg的linesize可能不等于width * 4。出于内存对齐的考虑,FFmpeg每行数据的实际长度往往比图像宽度多出几个字节。如果按整块拷贝,你会得到一个斜的、撕裂的图像。这个细节我一开始没注意,整个画面是歪的,排查了半天。
2.2 崩溃二:关闭播放器时线程还在跑,资源被提前释放
第二个崩溃场景发生在关闭窗口的瞬间。我重新打开文件或者退出程序时,程序频繁报错,错误位置在avcodec_close或者avformat_close_input附近。
问题出在关闭顺序上。主线程收到用户关闭窗口的消息,直接就释放了解码器上下文。但此时解码线程可能还阻塞在av_read_frame这个函数中,等待读取数据。解码器上下文被释放后,这个阻塞中的函数返回了错误码,尝试访问已经释放的内存,自然就崩溃了。
这个问题的排查过程比较曲折。因为崩溃不是每次都能复现,有时快速点击关闭按钮才会触发。我先在窗口关闭事件里加日志,确认了主线程执行到avformat_close_input之后,解码线程的日志还在输出,说明解码线程根本还没停下来。
修复的关键是设计一套完整的停止流程,核心原则是:先通知解码线程退出,等它真正退出之后,主线程再释放任何FFmpeg资源。我用了QThread的请求中断机制:
void PlayerWidget::stop() { // 1. 设置标志位,通知解码线程退出循环 m_stopFlag = true; // 2. 调用 av_read_frame 的中断回调,让阻塞中的读取立即返回 if (m_formatCtx) { avformat_close_input(&m_formatCtx); } // 3. 等待解码线程完全停止 m_decodeThread->quit(); m_decodeThread->wait(); // 4. 只有这里才能安全释放解码器上下文 avcodec_free_context(&m_codecCtx); }这里有一个非常关键的技巧:av_read_frame是阻塞调用,如果线程正卡在读取数据上,即使设置了退出标志位,它也感知不到。需要注册一个中断回调函数,才能让FFmpeg立刻从阻塞中返回。
static int decodeInterruptCallback(void* ctx) { return *((bool*)ctx) ? 1 : 0; } // 注册方式 m_formatCtx->interrupt_callback.callback = decodeInterruptCallback; m_formatCtx->interrupt_callback.opaque = &m_stopFlag;一旦退出标志位被置真,中断回调返回1,FFmpeg的所有阻塞调用都会立即返回错误,线程就可以正常退出。这个机制是FFmpeg专门提供来处理用户取消操作的,不利用起来,停止功能就很难做得优雅。
2.3 崩溃三:拖动进度条时解码状态没有重置
第三个崩溃是拖动进度条时出现的。操作步骤是:视频播放到一半,快速多次拖动进度条,然后程序崩溃。
排查过程使用了Qt的崩溃日志和Debug输出。崩溃点指向avcodec_send_packet。这是因为seek之后,上一个解码上下文还保留着之前的数据状态,包括解码延迟帧和内部缓存。直接往这个状态里塞新数据,就会出错。
修好的做法是在seek成功之后,调用avcodec_flush_buffers来清理解码器的内部状态。
void VideoPlayer::seek(int64_t targetMs) { // 1. 换算成FFmpeg的时间基 int64_t targetPts = av_rescale_q( targetMs * AV_TIME_BASE / 1000, AV_TIME_BASE_Q, m_formatCtx->streams[m_videoStreamIndex]->time_base ); // 2. seek avformat_seek_file( m_formatCtx, m_videoStreamIndex, INT64_MIN, targetPts, INT64_MAX, 0 ); // 3. 清理解码器缓冲 avcodec_flush_buffers(m_codecCtx); }还有个细节是seek之后要立即清空之前累积的待显示帧队列。我原来有一个QQueue保存解码出来的帧,目的是让播放更流畅。但seek之后这个队列里的帧全是旧位置的数据,如果不清空,它们会被解码线程取出来显示,进度条位置和新画面就对不上。关于这一点我加了两行代码就解决掉:
m_frameQueue.clear(); m_lastPts = 0; // 同时重置时间戳计算3. 播放逻辑修正:音画同步、Seek与文件切换的边界处理
3.1 音画同步的校准:从“偏早”到“同步”
第一版没有做音画同步,视频自己播自己的,音频自己播自己的。结果就是画面比声音快大概200到400毫秒,说话嘴型和声音对不上,看着非常难受。
音画同步的核心思路是:以音频时钟为准,视频帧要根据当前音频时钟决定是早显示还是晚显示。具体实现是记录每一帧的pts(显示时间戳),和当前音频播放时间做比较,如果视频帧比音频早,就不急着显示;如果比音频晚,就要马上显示甚至丢掉一些帧。
void VideoPlayer::syncVideo(AVFrame* frame, double& delay) { double framePts = frame->pts * timeBase; double currentAudioTime = m_audioClock->getTime(); // 帧的目标显示时间与当前音频时间的差值 double diff = framePts - currentAudioTime; if (diff > 0.01) { // 视频帧比音频早,延迟显示 delay = diff; } else if (diff < -0.01) { // 视频帧比音频晚,说明积压了,需要加速追赶 delay = 0; } }这套逻辑说起来简单,但实际调起来还是有坑。比如视频帧率是25fps,每帧间隔40毫秒,音频时间片的查询频率不够高,会导致画面一顿一顿的。后面我把音频时钟的更新粒度控制在10毫秒以内,画面才真正顺滑起来。
3.2 Seek之后的关键处理:时间戳和缓冲队列
seek这件事前面提到要avcodec_flush_buffers,但还漏了一个大坑:如果不重新获取并更新时长信息,播放进度、总时长和视频时间基都会错乱。
我专门写了一个resetState方法,在seek之后统一定位并初始化这些数据:
void VideoPlayer::resetStateAfterSeek() { // 清空显示帧队列 while (!m_frameQueue.isEmpty()) { AVRational frameRate = m_formatCtx->streams[m_videoStreamIndex]->avg_frame_rate; // 注意:有些视频的时间基不是固定的,需要重新计算 } m_frameQueue.clear(); }在seek过程中如果用户再点暂停,状态就更复杂了。暂停状态下seek,视频帧的pts基准会错乱,导致画面冻结在异常位置。我的处理方式是:seek前强制停止解码线程,seek完成后再重新启动,避免线程在状态重置过程中竞争数据。
3.3 文件切换与播放结束:不崩溃、不白屏、不残留
连续播放多个文件是常见的需求。每一个文件都有一组独立的解码器上下文、视频流索引、音频流索引、时长等信息。切换文件时如果上一个文件的资源没清理干净,新的解码器上下文创建就会失败。
我的建议是写一个干净的函数,专门负责释放所有动态资源。以下是我最终用的releaseAllResources片段:
void VideoPlayer::releaseAllResources() { if (m_packet) { av_packet_free(&m_packet); m_packet = nullptr; } if (m_frame) { av_frame_free(&m_frame); m_frame = nullptr; } if (m_codecCtx) { avcodec_free_context(&m_codecCtx); m_codecCtx = nullptr; } if (m_formatCtx) { avformat_close_input(&m_formatCtx); m_formatCtx = nullptr; } }播放结束时,不能直接停止线程,而是要等最后一帧显示完成,再发出播放结束的信号,让界面去更新状态。第一版我在解码线程里直接调用emit播放结束信号,结果界面在播放结束后仍然残留最后一帧,进度条也停在99%,非常难看。
正确做法是:解码线程循环检测到文件结束时,发送带时间戳的信号,主线程收到后再统一更新UI状态,同时清空播放进度条的剩余显示。
4. 打包发布期的坑:朋友电脑上打不开和平台插件之谜
4.1 现象:我的机器上好好的,同事的机器上双击没反应
功能修完之后,我用Qt自带的windeployqt工具打包发布,觉得应该没什么问题了。结果拷到一台没有装Qt开发环境的电脑上,双击exe,弹出一个错误:
This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.
这个报错在热搜词里出现了很多次,核心原因是Qt的plugins目录没有被正确拷贝。Qt应用程序依赖“platform plugin”,最常见的就是qwindows.dll。windeployqt默认会拷贝这个插件,但如果你的项目里使用了自定义的QPA插件,或者插件的目录组织不对,就会找不到。
检查方法是,在exe同级目录下必须有以下结构:
release/ ├── myplayer.exe ├── platforms/ │ └── qwindows.dll ├── styles/ │ ├── qmodernwindowsstyle.dll │ └── ... └── Qt5Core.dll, Qt5Gui.dll, Qt5Widgets.dll ...windeployqt虽然一般能自动生成这个目录,但如果你把exe单独拷出来,忘了拷整个文件夹,一样会报这个错。
4.2 自定义插件和Release/Debug不一致的坑
另一个困扰我很久的问题是:Debug版本的播放器在装有Qt环境的机器上正常运行,但用Release版编译后在开发机上也会报platform plugin错误。根因是Qt的插件目录和编译模式不匹配,插件只存在Debug目录,没有执行Release拷贝。
这里有一个很小的细节,但很实用:一定要在Release模式下构建项目,然后在Release目录下运行windeployqt。如果你不小心在Debug模式下执行了windeployqt,生成的依赖也有可能是Debug版本的Qt库,拷到干净环境还是会失败。我后来是专门写了一个批处理来保证打包:
set PATH=C:\Qt\5.15.2\msvc2019_64\bin;%PATH% cd /d E:\build-myplayer-Desktop_Qt_5_15_2_MSVC2019_64bit-Release\release windeployqt myplayer.exe --release --no-translations值得注意的还有FFmpeg运行库,FFmpeg的dll文件需要和exe放在一起,包括avcodec、avformat、avutil、swscale、swresample五个核心库。如果你用了FFmpeg的静态库,那就不用管这一步。没有正确加载FFmpeg库的表现为:程序可以启动,但点击打开文件后没有任何反应,控制台也不报错,这时候检查一下FFmpeg dll是否完整。
4.3 中文字符串和路径问题
我最初的版本里,文件路径直接用QString传给FFmpeg的avformat_open_input。在中文目录下打开视频直接返回错误。后来发现FFmpeg的API接受的路径默认是UTF-8编码,而Windows下的QString默认是UTF-16,直接转换会乱码。
解决方案是用QFile::encodeName,或者直接用QString::toUtf8,把路径转换成UTF-8再传给FFmpeg。如果你也用到了文件名中含中文的情况,这个坑一定要提前处理。顺带说一下,如果用sprintf拼命令行调用ffmpeg命令处理文件,比如“ffmpeg m3u8转mp4”,也要注意命令行参数在Windows控制台下的编码问题,推荐用QProcess启动ffmpeg命令并传参,而不是在system命令里拼字符串。
5. 完善版新增的小功能与性能打磨
5.1 进度条拖动与播放状态同步
修复完崩溃问题之后,我开始处理体验上的小毛病。第一版的进度条,拖动的时候画面不跟着跳,松开鼠标后才能跳转,体验很生硬。原因是QSlider的sliderMoved信号在拖动过程中一直触发,但我只处理了sliderReleased信号。
改进方式:在sliderPressed时暂停进度条刷新,在sliderReleased时执行seek。拖动过程中可以实时显示目标时间,松开后再真正seek。
connect(ui->progressSlider, &QSlider::sliderPressed, this, [this]() { m_isSliderPressed = true; }); connect(ui->progressSlider, &QSlider::sliderReleased, this, [this]() { m_isSliderPressed = false; seekToPosition(ui->progressSlider->value()); }); connect(ui->progressSlider, &QSlider::valueChanged, this, [this](int value) { if (!m_isSliderPressed) { // 播放过程中自动更新进度条 ui->timeLabel->setText(formatTime(value)); } });这个改动看似简单,但解决了拖动过程中反复触发seek导致画面跳跃、CPU飙升的痛点。核心原则是:拖动时不响应,释放时才恢复。
5.2 渲染性能优化:避免无意义的QImage拷贝
我在修复第一类崩溃时用了深拷贝,但深拷贝本身是有代价的。如果每一帧都做整幅图像的内存拷贝再显示,在高分辨率视频下性能会明显下降,CPU占用升高,掉帧也随之而来。
最终方案是采用双缓冲策略:解码线程解码后把RGB数据写入一块固定的缓冲区,UI线程在paintEvent里只做一次从缓冲区到QImage的转换和绘制。只有当缓冲区被写满后,才用信号通知主线程刷新界面。这样把图像的拷贝次数从每帧一次降到了每帧一次,降不下去,但可以避免不必要的临时QImage对象创建。
实测下来,1080p视频在普通笔记本上的CPU占用从30%降到了18%左右。注意,如果你的解码线程和UI线程之间用QueuedConnection传递对象,一定要确保对象可以安全跨线程传递,即注册自定义元类型或使用全局共享缓冲区。
5.3 从播放器到波形可视化:QCustomPlot的扩展思路
播放器主流程稳定之后,我开始给播放器加一些分析功能。热搜词里有人提到“qt时域图转换为频域图,使用qcustomplot显示”,其实就是把解码出来的音频PCM数据通过FFT变换成频谱,再在界面上画出来。
我选了QCustomPlot来做这个显示。思路是:从解码器拿到音频样本后,缓存一小段数据,通过kissfft做FFT变换,得到幅度谱。然后把这些幅度值映射为柱状图或曲线,实时刷新。QCustomPlot的graph可以很方便地绘制这种实时曲线。
不过要提醒的是,别把FFT和UI刷新也放到解码线程里。FFT计算本身很快,几百个点的FFT不到一毫秒,但UI刷新不能那么频繁。合理的做法是:在解码线程中只做FFT计算,把结果缓存到一个环形缓冲区;UI线程用固定帧率(比如30fps)的定时器去读取并刷新图表。这样既能保证波形实时性,又不会卡住解码流程。
我实现之后,整个播放器的流畅程度和频谱刷新频率都在可接受范围。QCustomPlot的replot函数效率挺高,但如果你要画非常密集的点,建议设置raster模式。
5.4 倍速播放的音频处理
倍速播放也是播放器必不可少的功能。FFmpeg在解码层面对倍速没有直接支持,需要自己控制播放节奏。视频部分可以通过减少帧间隔来实现,音频部分就比较麻烦了,直接抽帧会导致音调升高或声音断裂。
稳妥的方案是采用soundtouch库做变速不变调处理。当然,如果没有这个需求,简单倍速也是可行的方案:把音频的播放时钟加速,同时丢帧音频帧之间的播放时间。但一定要注意,在UI线程上使用QTimer来处理倍速,频繁切换倍速时,计时误差会导致音画同步越来越偏。
所以我最终的处理方式还是在解码线程里处理,根据倍速系数动态调整帧之间的显示等待时间:
double delay = baseFrameDuration / m_speedFactor;倍速切换后,同步逻辑稍作迁移,不再额外触发大范围重建。
6. 我在这一轮修复中最值得记住的三条工程经验
6.1 线程问题的排查顺序:先看数据归属
排查崩溃时,我最大的教训是:排查线程问题不要先想算法,要先想数据是谁的。谁拥有这块内存,谁负责释放,谁负责访问。如果一份数据需要被多个线程读,就必须加锁;如果一份数据是一个线程创建、另一个线程销毁,那就是设计问题。
在播放器这个场景里,最合理的数据归属模型是:解码线程拥有AVFormatContext、AVCodecContext、AVFrame、AVPacket;UI线程拥有QImage的绘制和显示。两者之间传递的数据,要么是深拷贝的QImage,要么是共享的引用计数对象。不要把FFmpeg的裸指针传出去。
6.2 逐步验证比一口气改完更高效
我第一版修改bug的时候,经常一次性把线程逻辑、同步逻辑、UI刷新全改了,结果出现了更多的问题。后面改成每次只改一个变量,改完跑一个完整播放周期验证,再改下一个。效率反而高了很多。
比如修复崩溃问题,我先只改QImage深拷贝,跑十分钟视频确认不崩了,再处理关闭时的线程停止问题。顺序从最底层的数据安全,再到逻辑完善,再到界面交互,层层递进。每改一层,都重新测试所有功能场景,避免上一轮的修复被下一轮改动破坏。
6.3 日志是排查问题的最好工具,别舍不得写
在Windows上,我把日志写到本地文件里,每段都带时间戳和线程ID。排查线程竞争问题时,时间戳加线程ID能非常直观地看出执行顺序,比断点好用得多。发布版本我保留了qDebug日志,只是增加了一个日志分级系统。
你可以在关键路径上写这样的日志:
qDebug() << "[DecodeThread]" << QThread::currentThreadId() << "send frame, pts=" << frame->pts; qDebug() << "[MainThread]" << QThread::currentThreadId() << "display frame, pts=" << pts;对比两行日志的时间戳和线程ID,你一眼就能看出哪个线程访问了哪个资源,顺序是怎样的。
这一轮修改下来,播放器基本达到了稳定可用的水平。现在连续循环播放几部电影,反复拖动进度条,快速切换文件,都没有再出现崩溃或卡死。希望这篇实战记录对正在做同类项目的你有帮助,特别是那几个隐藏比较深的线程与内存问题,越早用对模型,后面就越省心。
本文还有配套的精品资源,点击获取