
简介一套 FFmpeg Qt 开发的视频播放器完整工程源自《从零开始学习音视频编程技术》系列的 Bug 修复最终完善版面向音视频编程初学者与播放器模块化设计开发者。基于 Qt 5.6.2VS2013、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;一旦退出标志位被置真中断回调返回1FFmpeg的所有阻塞调用都会立即返回错误线程就可以正常退出。这个机制是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 PATHC:\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、AVPacketUI线程拥有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你一眼就能看出哪个线程访问了哪个资源顺序是怎样的。这一轮修改下来播放器基本达到了稳定可用的水平。现在连续循环播放几部电影反复拖动进度条快速切换文件都没有再出现崩溃或卡死。希望这篇实战记录对正在做同类项目的你有帮助特别是那几个隐藏比较深的线程与内存问题越早用对模型后面就越省心。本文还有配套的精品资源点击获取