Qt+FFmpeg实现RTSP视频流播放器:从拉流解码到画面渲染

发布时间:2026/9/8 2:21:18
Qt+FFmpeg实现RTSP视频流播放器:从拉流解码到画面渲染 简介基于Qt与FFmpeg实现RTSP视频流播放器的完整工程资源面向具备C和Qt基础、希望快速构建多路视频流播放应用的开发者。资源解决三路RTSP流同时解码播放、画面同步控制及实时截图等核心问题涵盖RTSP协议接入、FFmpeg编解码调用、Qt多媒体界面搭建等关键知识点可直接用于安防监控、视频会议等场景的二次开发。压缩包共123个文件以h头文件与cpp源文件为主辅以ui界面文件、pro工程配置、dll动态链接库、a静态库及qss样式资源包体大小11.03MB工程结构清晰便于编译与扩展。已有5520人学习下载适合需要掌握QtFFmpeg多媒体开发流程并参考完整项目实现的开发者。 Qt做RTSP视频流播放器安防监控、工业视觉、车载终端、远程教学这些场景里真的是刚需我这些年接过的项目里几乎每一次都会碰到“把网络摄像头的RTSP流在Qt界面里显示出来”这种需求。不管是大厂的平台客户端还是自己私下做的工具软件核心链路都一样拉流、解码、渲染。这篇文章就把整个流程从方案选型到FFmpeg API调用再到线程模型和常见坑位全部讲透适合有Qt基础、第一次接触视频流开发的朋友照着做。1. 项目定位与方案选型为什么我选了 FFmpeg 而不是 QtMultimedia1.1 这类项目的典型需求先搞清楚一件事RTSP视频流播放器不是普通播放器。普通播放器放本地文件播放器自己掌握节奏用户等一等、缓冲一下都没关系。但RTSP流绝大多数来自于摄像头实时画面它有几个特点一是数据永远不结束只要不断流就不会像MP4一样播完二是对延迟敏感监控场景里如果画面比真实时间晚了三五秒那基本没法用三是在弱网或者局域网拥堵时网络包会丢、会乱序播放器要能扛住这种抖动。我接触到的实际需求大概有这么几类园区安防对接几十个网络摄像头做多画面预览、录像回放、报警联动。工业视觉产线相机实时画面要在一个Qt上位机软件里展示同时做图像分析。车载终端把车内或者周边的摄像头画面实时显示在触摸屏上还要叠加车辆状态信息。教育直播把教室的摄像头流推到平台客户端用Qt播放。不管哪一种落到工程上无非就是三件事能从RTSP地址拿到流、能把H.264/H.265数据解码成一帧一帧的图像、能高效地画到界面上还不出问题。1.2 三种技术路线横向对比这里我直接给结论别指望一套方案从头打到尾要按项目条件选。技术路线优点缺点适合场景Qt Multimedia 的 QMediaPlayer集成简单几行代码就能播底层后端不稳定控制能力弱帧级操作基本做不了H.265支持看运气快速原型、明确只跑本地的轻量演示FFmpeg 自研封装解码能力强H.264/H.265全覆盖延迟和缓冲全在自己手里需要自己处理线程、内存、格式转换工程量明显更大生产级项目、工业设备、长期维护的产品VLC-Qt / libvlc复用VLC生态拿来就用VLC引擎改动空间小定制化需求很难做商用授权也要注意普通桌面播放器不需要深度定制我做正式项目基本都用FFmpeg。原因很简单Qt Multimedia在不同平台上的多媒体后端差异很大而FFmpeg是纯C库跨平台表现一致另外我需要精确控制解码缓存和重连策略Qt Multimedia的黑盒设计给不了我这些东西。1.3 选型结论所以这篇博文的实现技术栈定为界面框架Qt 5.15.2 MSVC2019 64位Qt 6也可以主体思路不变解码引擎FFmpeg 4.4及以上版本构建方式CMake或qmake均可我用CMake下面所有的代码和思路都围绕这个组合展开。如果你用的是海康、大华或其它厂家的摄像头RTSP取流地址格式一般是这样的rtsp://用户名:密码IP地址:端口/路径比如某品牌常见的格式是rtsp://admin:admin123192.168.1.64:554/Streaming/Channels/101没有真实摄像头的话也可以用公开测试流来做开发我常用来调试的是一个公开的MP4测试流rtsp://wowzaec2demo.streamlock.net/vod/mp4:BigBuckBunny_115k.mp4这个流在开发阶段验证拉流、解码逻辑很够用了。2. 环境准备与前置知识先把依赖问题解决掉2.1 Qt 与 FFmpeg 的环境配置先把环境跑通。Qt安装本身不细说了注意一点如果你用MSVC编译器务必将Qt和编译器的位数保持一致。FFmpeg这一步很多人会栽跟头。网上有编译好的FFmpeg开发包Windows下建议下载shared版本里面包含include头文件、lib库文件和bin下的DLL。配置步骤很固定下载FFmpeg windows版本解压到某个纯英文目录比如D:\libs\ffmpeg我强烈建议避免中文路径否则MinGW/MSVC链接时有一堆预料之外的坑。在项目pro文件里添加头文件和库路径INCLUDEPATH D:/libs/ffmpeg/include LIBS -LD:/libs/ffmpeg/lib \ -lavformat \ -lavcodec \ -lavutil \ -lswscale \ -lswresample如果用的是CMake则写法类似target_include_directories(app PRIVATE D:/libs/ffmpeg/include) target_link_directories(app PRIVATE D:/libs/ffmpeg/lib) target_link_libraries(app avformat avcodec avutil swscale swresample)运行时把FFmpeg的bin目录里的DLL拷贝到exe同级目录或者把bin目录加入系统PATH。一定要注意FFmpeg版本之间API有差异比如解码从avcodec_decode_video2换成了avcodec_send_packet/avcodec_receive_frame本文的代码基于新版API如果你用老版本请自行调整。我开发时踩过一次这个坑一开始抄老代码怎么都编译不过。2.2 RTSP 协议基础与测试流地址RTSP本身不是一个传输数据的协议它更像是一个“控制协议”负责协商播放、暂停、停止这些命令。真正的视频数据挂在RTP上传输RTP又可以选择跑在UDP或者TCP上面。拉流时内核发生的事可以简单理解为FFmpeg通过RTSP协议和服务器对话拿到媒体参数之后另起通道接收RTP数据包把H.264帧从RTP包中恢复出来然后交给解码器。这里面有一个关键选择传输协议用UDP还是TCP。UDP延迟低但丢包会导致花屏或马赛克局域网里可以接受。TCP因为要重传延迟稍高但稳定很多是监控场景的首选。我自己的做法是默认TCP同时在代码里提供一个可配置项。TCP在公网、跨网段环境下能省掉很多麻烦代价只是轻微的延迟上升。FFmpeg里设置传输方式是通过AVDictionary传参AVDictionary* opts nullptr; av_dict_set(opts, rtsp_transport, tcp, 0);如果在avformat_open_input之后没有做这个设置FFmpeg默认行为可能会去尝试UDP跨网段时UDP很容易把画面卡出马赛克。3. 播放器核心代码实现拉流、解码、显示一条龙3.1 解码线程与关键数据结构播放器能不能流畅关键在“不要在UI线程里做任何耗时工作”。最简单合理的架构是两类线程分工清楚主线程负责界面响应、按钮点击、画面绘制。解码线程负责av_read_frame读包、avcodec_send_packet解码、sws_scale像素格式转换。下面是一个精简的解码线程类头文件// DecoderThread.h #ifndef DECODERTHREAD_H #define DECODERTHREAD_H #include QThread #include QImage #include atomic extern C { #include libavformat/avformat.h #include libavcodec/avcodec.h #include libswscale/swscale.h #include libavutil/imgutils.h } class DecoderThread : public QThread { Q_OBJECT public: explicit DecoderThread(QObject *parent nullptr); ~DecoderThread() override; void openStream(const QString url); void stopStream(); signals: void frameReady(const QImage frame); void errorOccurred(const QString message); void videoInfo(int width, int height); protected: void run() override; private: std::atomicbool m_stop{false}; QString m_url; }; #endif // DECODERTHREAD_H解码线程内部的状态量可以有很多但最核心的就两个是否停止、流的URL。3.2 FFmpeg 拉流与解码循环现在把这部分写完整。run()函数是整个播放器的发动机它的骨架分四步打开输入、找音视频流、初始化解码器、循环读帧。void DecoderThread::run() { avformat_network_init(); AVFormatContext *fmtCtx nullptr; AVDictionary *options nullptr; // 指定RTSP走TCP延迟稳定丢包少 av_dict_set(options, rtsp_transport, tcp, 0); // 设置连接超时单位是微秒这里设成3秒 av_dict_set(options, stimeout, 3000000, 0); int ret avformat_open_input(fmtCtx, m_url.toStdString().c_str(), nullptr, options); if (ret 0) { emit errorOccurred(QString(无法打开流: %1).arg(m_url)); return; } ret avformat_find_stream_info(fmtCtx, nullptr); if (ret 0) { emit errorOccurred(无法获取流信息); avformat_close_input(fmtCtx); return; } // 找到视频流索引 int videoStreamIndex av_find_best_stream(fmtCtx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); if (videoStreamIndex 0) { emit errorOccurred(未找到视频流); avformat_close_input(fmtCtx); return; } AVCodecParameters *codecParams fmtCtx-streams[videoStreamIndex]-codecpar; const AVCodec *codec avcodec_find_decoder(codecParams-codec_id); if (!codec) { emit errorOccurred(不支持的解码器); avformat_close_input(fmtCtx); return; } AVCodecContext *codecCtx avcodec_alloc_context3(codec); avcodec_parameters_to_context(codecCtx, codecParams); ret avcodec_open2(codecCtx, codec, nullptr); if (ret 0) { emit errorOccurred(解码器初始化失败); avcodec_free_context(codecCtx); avformat_close_input(fmtCtx); return; } emit videoInfo(codecCtx-width, codecCtx-height); decodeLoop(fmtCtx, codecCtx, videoStreamIndex); avcodec_free_context(codecCtx); avformat_close_input(fmtCtx); avformat_network_deinit(); }解码循环是真正高频执行的部分。每次av_read_frame拿到一个封装好的包如果它属于视频流就送进解码器然后从解码器里取出的每一帧从YUV转成RGB再包成QImage发出去。void DecoderThread::decodeLoop(AVFormatContext *fmtCtx, AVCodecContext *codecCtx, int videoStreamIndex) { AVPacket packet; av_init_packet(packet); int width codecCtx-width; int height codecCtx-height; AVFrame *frame av_frame_alloc(); if (!frame) return; // 创建一个图像转换上下文把解码后的YUV数据转成RGB24 SwsContext *swsCtx sws_getContext( width, height, codecCtx-pix_fmt, width, height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); int outLinesize[4]; uint8_t *outBuffer[4]; av_image_alloc(outBuffer, outLinesize, width, height, AV_PIX_FMT_RGB24, 1); while (!m_stop.load()) { int ret av_read_frame(fmtCtx, packet); if (ret 0) { break; // 读不到数据线程结束 } if (packet.stream_index videoStreamIndex) { ret avcodec_send_packet(codecCtx, packet); if (ret 0) { while (avcodec_receive_frame(codecCtx, frame) 0) { sws_scale(swsCtx, (const uint8_t *const *)frame-data, frame-linesize, 0, height, outBuffer, outLinesize); QImage qImage(outBuffer[0], width, height, outLinesize[0], QImage::Format_RGB24); // 这里必须深拷贝因为outBuffer在下一轮会被覆盖 emit frameReady(qImage.copy()); } } } av_packet_unref(packet); } av_freep(outBuffer); sws_freeContext(swsCtx); av_frame_free(frame); }这段代码里最关键的一行是emit frameReady(qImage.copy())。一开始我没深拷贝直接emit frameReady(qImage)结果画面隔两秒闪一次还时不时出现花屏块。原因很简单信号发射出去之后槽函数不一定马上执行等槽函数执行时outBuffer里的数据已经被下一帧覆盖了。3.3 在 UI 层完成渲染解码线程把一帧一帧的QImage发出来主线程只需要负责贴图。这里我个人推荐不要用QLabel而是用一个自定义QWidget重写paintEvent方法用QPainter画图性能更高还方便后续叠加OSD文字和画框。先看最直观的QLabel方案connect(m_decoderThread, DecoderThread::frameReady, this, [this](const QImage frame) { QPixmap pixmap QPixmap::fromImage(frame); ui-videoLabel-setPixmap(pixmap.scaled(ui-videoLabel-size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); });这个方案胜在简单但每次都做一次 QImage 到 QPixmap 的转换还要缩放CPU占用偏高。我后来在正式项目里换成了自定义控件VideoWidget核心思路是保存最新一帧在paintEvent里直接画class VideoWidget : public QWidget { Q_OBJECT public: void updateFrame(const QImage frame) { m_frame frame; update(); // 触发立即重绘 } protected: void paintEvent(QPaintEvent *event) override { QPainter painter(this); if (m_frame.isNull()) { painter.fillRect(rect(), Qt::black); return; } QImage scaled m_frame.scaled(size(), Qt::KeepAspectRatio, Qt::FastTransformation); int x (width() - scaled.width()) / 2; int y (height() - scaled.height()) / 2; painter.drawImage(QPoint(x, y), scaled); } private: QImage m_frame; };然后在主窗口里连接信号connect(m_decoderThread, DecoderThread::frameReady, m_videoWidget, VideoWidget::updateFrame);实测下来这种方式比QLabel方案流畅不少尤其是分辨率到1080P以上时差距很明显。4. 播放控制与功能扩展不能只满足于“能看”4.1 播放、暂停、停止与自动重连播放器最基础的控制逻辑是“开始播放”和“停止播放”。void MainWindow::onPlayButtonClicked() { if (!m_decoderThread-isRunning()) { m_decoderThread-openStream(ui-urlEdit-text().trimmed()); m_decoderThread-start(); } } void MainWindow::onStopButtonClicked() { m_decoderThread-stopStream(); m_decoderThread-quit(); m_decoderThread-wait(1000); }这里有个很隐蔽的问题stopStream()里如果把std::atomic置为true然后线程正在av_read_frame里阻塞等待数据这个函数在流断开或者网络卡死时会导致线程退出不了。所以我在打开流时设置了stimeout让av_read_frame最多阻塞3秒就返回错误这样线程才有机会走完退出流程。至于“暂停”对RTSP实时流来说真正的暂停并不存在摄像头不会因为你点了个暂停就不发数据。常见的做法是停止刷新画面但后台解码线程继续丢帧或者干脆拔掉播放句柄下次继续显示最新帧。m_paused !m_paused; m_videoWidget-setUpdatesEnabled(!m_paused);这样最省事而且暂停状态下如果继续收流内存和CPU都一直在烧。如果项目对性能敏感我会在暂停时直接停掉解码线程。自动重连逻辑我建议放在解码线程的run尾部// 在run最后判断是不是非主动停止 if (!m_stop.load()) { emit errorOccurred(连接断开3秒后重连...); msleep(3000); if (!m_stop.load()) { run(); // 重新开始整个打开流程 } }注意这里不能用递归方式无限重连会爆栈。我在实际项目里会改成while循环控制重连次数上限最多重试5次避免在摄像头断网期间客户端不断发起无效连接。4.2 截图、录像与 OSD 叠加画面能稳定显示后老板们一定会提新需求截图、录像、画框、叠加文字。截图最容易。因为解码线程已经把每一帧发到主线程了VideoWidget里保留的m_frame就是当前画面void MainWindow::onSnapshotButtonClicked() { QImage img m_videoWidget-currentFrame(); QString filename QDateTime::currentDateTime().toString(yyyyMMdd_hhmmss) .png; img.save(filename); }录像则复杂一些因为需要把RGB格式转回YUV或者H.264编码再封装成MP4。这需要引入FFmpeg编码器和muxer本质上就是新建一个AVFormatContext把收到的每一帧写入av_interleaved_write_frame。考虑到代码量我没有在这篇文章里展开不妨先说结论如果你只是想录一段本地视频用FFmpeg命令行轮子就够了但如果你想在播放器内嵌“一键录像”就必须自己在工程里再创建一个编码线程并把解码出来的帧送到编码器。OSD叠加则简单很多回到前面VideoWidget的paintEvent里在绘制完图像后追加painter.setPen(QPen(Qt::white)); painter.setFont(QFont(Microsoft YaHei, 12)); painter.drawText(12, 24, QString(通道1 %1).arg(QTime::currentTime().toString(hh:mm:ss)));热词里提到的“Qt时域图转换频域图”其实也是一个道理你在QPainter里画完一张波形图底层都是QImage如果你拿到的是RTSP解码出来的帧同样可以使用painter在帧上画线、画点、叠加曲线。把RTSP帧和QPainter结合就是很多安防软件做热力图、轨迹叠加的技术基础。5. 常见问题排查实录我踩过的那些坑5.1 播放延迟越来越高表现画面刚开始正常播放几分钟后延迟达到十几秒。原因分析解码线程不断读帧、送显示但主线程的重绘速度跟不上导致队列堆积。有些方案里还会手动加一个QQueue缓存帧但缓存越大延迟越高。解决办法不要随手queue缓存帧宁可丢帧也不要攒帧。解码线程在av_read_frame循环里每次usleep(1)让出一点CPU避免包一多就疯狂占用。在解码循环里加一个逻辑如果当前未显示的帧数超过N帧直接丢弃最老的帧。5.2 画面花屏或绿屏表现画面出现大块马赛克、绿色条纹几秒后自动恢复。原因分析RTSP走UDP传输时丢包太严重H.264的参考帧关键帧I帧丢了后面的P帧解码出来全是乱的。解决办法把rtsp_transport从udp改成tcp这是我做得最多的修复。如果必须走UDP可以在解码器里设置AV_CODEC_FLAG2_SHOW_ALL但这只能缓解不能根治。5.3 程序崩溃与内存问题表现播放正常但关闭窗口时崩溃或者动不动报访问越界。原因分析线程生命周期没管理好。最常见的是主窗口析构时解码线程还在运行还在发信号结果信号指向的接收对象已经被销毁。解决办法在析构函数里先m_decoderThread-stopStream()再wait()确保线程完全退出后再释放相关资源。使用Qt的connect时给连接设置Qt::QueuedConnection并在接收者析构时自动断开。FFmpeg的AVFrame、AVPacket一定要成对释放av_packet_unref不能忘否则会内存泄漏。我用valgrind跑过一次没释放packet的播放器跑一小时能涨好几百MB内存。5.4 运行时提示找不到 Qt platform plugin这个问题其实和播放器本身无关但会被很多人撞上。报错信息大概长这样This application failed to start because no Qt platform plugin could be initialized.原因很简单exe文件旁边缺少Qt的平台插件目录比如platforms文件夹或者你的程序没有使用windeployqt工具部署依赖。解决办法windeployqt.exe 你的可执行文件.exe它会自动把Qt相关的DLL和插件目录拷贝到exe旁。如果你还依赖FFmpeg的DLL记得手动把FFmpeg的bin目录下的DLL也一起复制过去。6. 打包发布与个人心得6.1 依赖库打包注意事项发布给客户的时候程序文件夹里大概要有这几类文件项目exeQt相关的DLL比如Qt5Core.dll、Qt5Gui.dll、Qt5Widgets.dllplatforms/qwindows.dllstyles、imageformats等Qt插件目录FFmpeg的DLLavcodec、avformat、avutil、swscale、swresample等如果有录音录像功能可能还需要openh264或者其它编码器DLL打包前我习惯先检查一遍文件大小FFmpeg那几个DLL加起来就有几十MB再加上Qt最终安装包体积往往超过100MB。如果客户对体积敏感可以考虑用UPX压缩或者换更精简的FFmpeg编译选项不过这是后话。6.2 一些实践经验总结做RTSP播放器这么多年有几点体会越到后面越觉得重要。第一RTSP绝对不等于简单的网络摄像头不同的设备厂家对RTSP实现细节的兼容性千差万别。海康、大华、宇视、华为同一套代码在不同品牌上表现可能完全不同。做产品一定要把设备兼容性测试纳入开发流程至少必备一个测试矩阵不同分辨率、不同编码、不同传输协议。第二延迟这个指标要看整体链路不能只盯着FFmpeg。摄像头端的编码延迟、网络传输延迟、解码器缓冲、Qt渲染延迟每一环都可能有坑。我见过一个项目开发组花了两个星期调FFmpeg参数延迟还是在两秒以上最后发现是交换机开启了巨型帧和某种流控策略导致视频流在交换机里被缓存了好几秒。第三别把所有功能都塞在解码线程里。视频流播放器的性能瓶颈往往不在解码本身而在像素格式转换和界面绘制。尤其在高分辨率、高帧率场景下sws_scale和QImage的拷贝要考虑用硬件加速比如OpenGL纹理上传或者直接解码到特定格式。Qt里可以使用QOpenGLWidget配合glTexImage2D来显示YUV数据性能比软件缩放强得多。最后再分享一个小技巧调试RTSP播放器时别上来就用真实摄像头。先用VLC或者ffplay验证一下这个RTSP地址是否可用排除摄像头端的问题再去查自己的代码。FFmpeg自带的ffplay是一个非常高效的验证工具ffplay -rtsp_transport tcp rtsp://你的地址如果ffplay都放不出来基本可以断定是摄像头配置或网络问题不要在自己写的播放器上浪费时间。把这个习惯养成之后你会发现排错效率翻了一倍。本文还有配套的精品资源点击获取