Qt+FFmpeg+RTSP桌面播放器实战:从拉流解码到渲染的完整链路

发布时间:2026/10/21 22:33:22
Qt+FFmpeg+RTSP桌面播放器实战:从拉流解码到渲染的完整链路
简介这是一份面向C与Qt开发者的多媒体编程学习资源聚焦在Qt框架中集成FFmpeg库实现RTSP实时流播放。项目通过封装FFmpeg的avformat_open_input、av_read_frame等接口完成流解析与解码再由Qt构建界面呈现视频画面适合想入门音视频开发、理解RTSP协议控制流程的中级开发者参考。压缩包共16个文件约14KB以5个cpp源文件、4个头文件、3个ui界面文件为主另含Makefile、pro工程文件与README说明覆盖主程序入口、FFmpeg封装类、登录与主窗口界面等模块结构紧凑便于通读。目前已有671人学习下载。通过研读可掌握Qt事件循环与多线程下流媒体的顺畅播放思路、FFmpeg解码调用链以及Makefile自动化构建流程是衔接图形界面与网络流处理的实用练手项目。1. Qt-FFmpeg-RTSP-Demo一个把拉流、解码、渲染串成一条线的桌面播放器如果你手头正好有一路 RTSP 摄像头流想用 Qt 做个能实时预览的小工具又不想一上来就啃 FFmpeg 那一堆 AVCodec、AVFormat 的 API那这个 Demo 值得先跑一遍。它干的事很具体用 FFmpeg 负责拉流和解码用 Qt 负责窗口、事件循环和画面显示中间靠信号槽把解码线程和 UI 线程接起来。整套东西不依赖什么重型框架编译入口就是一份 Makefileqmake 生成后直接 make 就能出可执行文件。适合两类人一类是想快速验证「我这条 RTSP 流到底能不能在桌面端稳定播」的开发者另一类是想拿一个能跑的最小骨架往上加录像、抓帧、AI 推理的二次开发。它不追求播放器该有的完整功能追求的是链路清晰、每一环都能单独替换。2. 项目结构与技术选型为什么是 Qt FFmpeg Makefile 这套组合2.1 目录骨架与各文件职责这类 Demo 的目录通常不会太深常见做法是分成源码、头文件、构建脚本三块保持一眼能看全。下面是我按这个项目场景整理出的典型结构实际拿到源码后可以对照着看Qt-FFmpeg-RTSP-Demo/ ├── main.cpp # 程序入口创建 QApplication 和主窗口 ├── mainwindow.h # 主窗口类声明持有解码线程和显示控件 ├── mainwindow.cpp # 主窗口实现负责按钮事件和图像刷新 ├── videodecoder.h # 解码线程类声明封装 FFmpeg 上下文 ├── videodecoder.cpp # 解码线程实现拉流、解码、发信号 ├── QtFFmpegDemo.pro # qmake 工程文件声明依赖库 └── Makefile # 构建入口qmake 生成或手写main.cpp只做一件事起 Qt 事件循环把MainWindow推上去。真正的重活在VideoDecoder里它继承QThread在run()中完成avformat_open_input到avcodec_send_packet的整条链路。MainWindow不碰 FFmpeg 的任何结构体只接收解码线程发来的QImage转成QPixmap贴到QLabel上。这种切分的好处是哪天你想把显示层从 QLabel 换成 QOpenGLWidget解码部分一行不用动。.pro文件里最关键的是链接选项。FFmpeg 的库分 avformat、avcodec、avutil、swscale 几个顺序不能乱否则会出现符号找不到的玄学问题QT core gui widgets TARGET QtFFmpegDemo TEMPLATE app SOURCES main.cpp mainwindow.cpp videodecoder.cpp HEADERS mainwindow.h videodecoder.h # FFmpeg 头文件路径按实际安装位置改 INCLUDEPATH /usr/local/include # 链接顺序上层库在前底层库在后 LIBS -L/usr/local/lib \ -lavformat \ -lavcodec \ -lswscale \ -lavutilINCLUDEPATH指向 FFmpeg 头文件所在目录LIBS里的-L指定库文件路径。链接顺序这条血泪经验值得记avformat依赖avcodecavcodec依赖avutil所以avformat必须写在最前面avutil放最后。顺序反了链接器会报一堆 undefined reference但错误信息不会直接告诉你顺序问题容易翻车。2.2 为什么用 QThread 而不是 QtConcurrent解码是个持续性的阻塞操作av_read_frame在流没数据时会卡住avcodec_send_packet也可能因为缓冲区满而等待。如果放在 UI 线程里跑界面直接假死。常见做法是继承QThread并重写run()把整个解码循环塞进去。相比QtConcurrent::run继承 QThread 的好处是你能在类里直接持有 FFmpeg 的上下文成员变量不用把一堆指针传来传去生命周期也好控制——线程对象析构时run()已经退出再统一释放AVFormatContext和AVCodecContext。信号槽的跨线程连接默认是Qt::AutoConnection当发送者和接收者不在同一线程时自动走队列连接QImage会被拷贝一份投递到 UI 线程的事件循环里。这里有个细节QImage的深拷贝发生在信号发射时所以解码线程里那块用于sws_scale的缓冲区可以复用不用每帧 new 一块内存。2.3 Makefile 在其中的角色qmake 会根据.pro生成一份 Makefile里面定义了all、clean、distclean等目标。日常开发中直接make就行但如果你要集成到 CI 或者交叉编译环境可能需要手写或改这份 Makefile。关键变量是CXXFLAGS和LFLAGS前者控制编译选项比如-O2、-stdc11后者控制链接选项。交叉编译时把CC、CXX换成目标平台的工具链前缀LIBS里的路径换成目标平台的 FFmpeg 库路径其余不用动。提示如果make时报cannot find -lavformat先确认 FFmpeg 是否装了开发包libavformat-dev之类再确认-L路径下确实有libavformat.so或.a文件。3. 核心模块拆解从 av_read_frame 到 QLabel 的完整链路3.1 解码线程的初始化与资源申请解码线程的run()函数是整个项目的心脏。它按顺序做四件事打开输入、找视频流、打开解码器、循环读帧。每一步都有对应的失败分支漏掉任何一个都会导致后续操作在空指针上崩溃。void VideoDecoder::run() { // 1. 打开 RTSP 输入强制走 TCP避免 UDP 丢包导致花屏 AVDictionary *opts nullptr; av_dict_set(opts, rtsp_transport, tcp, 0); av_dict_set(opts, stimeout, 5000000, 0); // 5秒超时单位微秒 int ret avformat_open_input(formatCtx, url.toUtf8().constData(), nullptr, opts); av_dict_free(opts); if (ret 0) { emit errorOccurred(打开输入失败); return; } // 2. 探测流信息拿到视频流的索引 ret avformat_find_stream_info(formatCtx, nullptr); if (ret 0) { emit errorOccurred(找不到流信息); return; } videoStreamIndex -1; for (unsigned i 0; i formatCtx-nb_streams; i) { if (formatCtx-streams[i]-codecpar-codec_type AVMEDIA_TYPE_VIDEO) { videoStreamIndex i; break; } } if (videoStreamIndex -1) { emit errorOccurred(没有视频流); return; } // 3. 根据流参数打开解码器 AVCodecParameters *codecPar formatCtx-streams[videoStreamIndex]-codecpar; const AVCodec *codec avcodec_find_decoder(codecPar-codec_id); if (!codec) { emit errorOccurred(找不到解码器); return; } codecCtx avcodec_alloc_context3(codec); avcodec_parameters_to_context(codecCtx, codecPar); ret avcodec_open2(codecCtx, codec, nullptr); if (ret 0) { emit errorOccurred(打开解码器失败); return; } // 4. 进入读帧循环 decodeLoop(); }av_dict_set里设的rtsp_transport为tcp是 RTSP 拉流的常见做法。UDP 模式下丢包会导致解码器收到不完整的 NAL 单元画面出现绿块或撕裂TCP 虽然延迟略高但稳定得多。stimeout设 5 秒防止流断开后av_read_frame永久阻塞线程退不出来。avcodec_parameters_to_context把流里的编码参数分辨率、像素格式、码率复制到解码器上下文省去手动逐项赋值的麻烦。3.2 读帧、解码、格式转换三步走decodeLoop()里是一个while循环每次从流里取一个 packet送进解码器再取出一帧原始图像。原始图像通常是 YUV420P 格式Qt 的QImage不直接支持需要用sws_scale转成 RGB。void VideoDecoder::decodeLoop() { AVPacket *packet av_packet_alloc(); AVFrame *frame av_frame_alloc(); AVFrame *rgbFrame av_frame_alloc(); // 准备格式转换上下文目标格式 RGB32 SwsContext *swsCtx nullptr; uint8_t *rgbBuffer nullptr; while (!stopRequested) { int ret av_read_frame(formatCtx, packet); if (ret 0) { // 流结束或出错退出循环 break; } if (packet-stream_index ! videoStreamIndex) { av_packet_unref(packet); continue; } ret avcodec_send_packet(codecCtx, packet); av_packet_unref(packet); if (ret 0) { continue; } while (avcodec_receive_frame(codecCtx, frame) 0) { // 首次解码后才知道实际分辨率此时初始化转换上下文 if (!swsCtx) { swsCtx sws_getContext( frame-width, frame-height, (AVPixelFormat)frame-format, frame-width, frame-height, AV_PIX_FMT_RGB32, SWS_BILINEAR, nullptr, nullptr, nullptr); rgbBuffer new uint8_t[av_image_get_buffer_size( AV_PIX_FMT_RGB32, frame-width, frame-height, 1)]; av_image_fill_arrays(rgbFrame-data, rgbFrame-linesize, rgbBuffer, AV_PIX_FMT_RGB32, frame-width, frame-height, 1); } // YUV 转 RGB sws_scale(swsCtx, frame-data, frame-linesize, 0, frame-height, rgbFrame-data, rgbFrame-linesize); // 包装成 QImage注意拷贝一份数据因为 rgbBuffer 会被复用 QImage image(rgbFrame-data[0], frame-width, frame-height, rgbFrame-linesize[0], QImage::Format_RGB32); emit frameReady(image.copy()); av_frame_unref(frame); } } // 清理资源 if (swsCtx) sws_freeContext(swsCtx); delete[] rgbBuffer; av_frame_free(frame); av_frame_free(rgbFrame); av_packet_free(packet); }av_read_frame返回的 packet 可能属于音频流所以要先判断stream_index不是视频流就av_packet_unref释放掉。avcodec_send_packet和avcodec_receive_frame是 FFmpeg 新版的解码接口一个 packet 可能解出多帧所以内层用while循环反复取直到返回AVERROR(EAGAIN)或AVERROR_EOF。sws_getContext的初始化放在第一次收到 frame 之后因为只有解码后才知道实际分辨率和像素格式。SWS_BILINEAR是双线性插值缩放质量够用且速度不慢。QImage构造时传入的rgbFrame-data[0]是外部缓冲区QImage不会接管所有权所以必须.copy()一份再发信号否则下一帧sws_scale会覆盖同一块内存UI 线程拿到的就是脏数据。3.3 主窗口如何接收并显示图像MainWindow的职责很轻连信号、贴图、处理窗口关闭。解码线程的frameReady信号连到一个槽函数槽函数里把QImage转成QPixmap按QLabel大小缩放后设置上去。MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { label new QLabel(this); label-setAlignment(Qt::AlignCenter); label-setMinimumSize(640, 480); setCentralWidget(label); decoder new VideoDecoder(this); connect(decoder, VideoDecoder::frameReady, this, MainWindow::onFrameReady); connect(decoder, VideoDecoder::errorOccurred, this, MainWindow::onError); decoder-start(); // 启动解码线程 } void MainWindow::onFrameReady(const QImage image) { // 按 QLabel 当前尺寸等比缩放保持宽高比 QPixmap pix QPixmap::fromImage(image).scaled( label-size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); label-setPixmap(pix); }scaled的Qt::KeepAspectRatio保证画面不变形Qt::SmoothTransformation在缩放时做平滑处理代价是每帧多花一点 CPU。如果追求低延迟可以换成Qt::FastTransformation画面会有点锯齿但刷新更快。decoder-start()调用后run()在新线程里执行主线程继续跑 Qt 事件循环两边通过信号槽通信互不阻塞。注意onFrameReady里不要做耗时操作否则 UI 线程会被拖慢表现为画面卡顿但解码线程还在正常发信号。缩放和贴图本身很快但如果加了录像或 AI 推理要另开线程处理。4. 编译与运行从 qmake 到看到第一帧画面4.1 环境准备与依赖安装在 Linux 下FFmpeg 开发包和 Qt 开发包是必须的。以常见的包管理方式为例# 安装 FFmpeg 开发库 sudo apt install libavformat-dev libavcodec-dev libavutil-dev libswscale-dev # 安装 Qt5 开发环境 sudo apt install qtbase5-dev qt5-qmake # 验证 FFmpeg 版本确认 API 兼容性 ffmpeg -versionFFmpeg 4.x 和 5.x 在解码 API 上有差异avcodec_send_packet这套接口从 3.1 开始引入4.x 之后基本稳定。如果系统自带的是 3.x 以下版本avcodec_parameters_to_context可能不存在需要改用avcodec_copy_context。确认版本能省掉不少编译错误。4.2 qmake 生成 Makefile 并编译进入源码目录后标准流程是# 生成 Makefile qmake QtFFmpegDemo.pro # 编译-j4 表示四核并行按 CPU 核心数调整 make -j4 # 运行 ./QtFFmpegDemoqmake会根据.pro文件里的SOURCES、HEADERS、LIBS生成 Makefile。如果.pro里写的库路径和实际安装位置不一致make会在链接阶段报错。此时改.pro里的INCLUDEPATH和LIBS重新qmake再make。不要直接改 Makefile因为下次qmake会覆盖掉。4.3 运行参数与流地址配置Demo 里 RTSP 地址通常写死在代码里或者通过命令行参数传入。常见做法是在main.cpp里读argvint main(int argc, char *argv[]) { QApplication app(argc, argv); QString url rtsp://127.0.0.1:8554/live; // 默认地址 if (argc 1) { url QString::fromUtf8(argv[1]); // 命令行传入覆盖 } MainWindow w; w.setStreamUrl(url); w.show(); return app.exec(); }运行时./QtFFmpegDemo rtsp://your-camera-ip:554/stream就能指定流地址。如果地址不对或流不可达avformat_open_input会在stimeout设定的 5 秒后返回失败界面弹出错误提示不会一直卡住。4.4 验证画面是否正常第一帧出来之前可以先用ffplay验证流本身是否可用ffplay -rtsp_transport tcp rtsp://your-camera-ip:554/stream如果ffplay能播但 Demo 播不了问题多半在代码里的参数或链接库版本。如果ffplay也播不了先排查网络和摄像头配置别在代码里绕。画面出来后重点看三件事颜色是否正常YUV 转 RGB 的公式对不对、有没有周期性卡顿解码线程和 UI 线程的节奏是否匹配、长时间运行内存是否持续增长av_frame_unref和av_packet_unref有没有漏调。5. 避坑与常见问题那些编译和运行时的翻车现场5.1 链接报错 undefined reference toav_xxx现象make到最后一步报一堆undefined reference to avformat_open_input之类的错误。原因LIBS里的库顺序不对或者根本没链接对应的库。FFmpeg 的库有依赖关系avformat依赖avcodecavcodec依赖avutil链接器从左到右解析被依赖的库必须写在右边。解决把LIBS改成-lavformat -lavcodec -lswscale -lavutil这个顺序avutil永远放最后。如果还报错确认-L路径下确实有这些.so文件。5.2 画面花屏或绿块现象画面能出来但大面积绿色块或者上半部分正常下半部分撕裂。原因RTSP 默认走 UDP丢包导致解码器收到不完整的帧。或者sws_scale的目标缓冲区大小不够linesize对不齐。解决在avformat_open_input前设rtsp_transport为tcp。检查av_image_get_buffer_size的返回值是否足够容纳一帧 RGB32 数据rgbBuffer的分配大小不能小于这个值。5.3 运行一段时间后崩溃现象跑几分钟到几十分钟不等程序突然 segfault。原因AVFrame或AVPacket没有及时unref内存耗尽或者QImage构造时没有.copy()UI 线程访问了已被解码线程复用的缓冲区。解决确保每次avcodec_receive_frame之后调用av_frame_unref每次av_read_frame之后调用av_packet_unref。emit frameReady之前对QImage做深拷贝。5.4 窗口关闭后线程不退出现象点关闭按钮窗口消失了但进程还在后台跑ps能看到。原因run()里的while循环没有退出条件stopRequested标志没被置位或者av_read_frame阻塞在stimeout之前。解决在MainWindow的closeEvent里调用decoder-stop()把stopRequested设为true然后decoder-wait()等线程真正退出。stimeout设了 5 秒最坏情况下等 5 秒线程也能出来。5.5 编译时报AVCodecContext成员访问错误现象代码里写codecCtx-width或codecCtx-pix_fmt编译报错说没有这个成员。原因FFmpeg 5.x 之后把AVCodecContext的部分成员移到了AVCodecParameters里直接访问codecCtx-width不再合法。解决从formatCtx-streams[videoStreamIndex]-codecpar里读width、height、format或者用avcodec_parameters_to_context同步之后再访问。如果必须兼容旧版用条件编译区分版本号。6. 二次开发改造从能播到好用几个我踩过的坑拿到这个 Demo 之后最自然的想法是加功能。我试过几个方向有的顺利有的翻车这里说几个关键点。第一个改造是加录像。思路很简单在decodeLoop里收到 frame 之后除了发信号给 UI再写一份到文件。但直接写原始 YUV 文件太大常见做法是用avformat_alloc_output_context2建一个输出上下文把 frame 编码成 H.264 再写。这里有个坑输入流的解码器和输出流的编码器是两套上下文不能混用。我一开始图省事想把codecCtx直接拿去编码结果avcodec_send_frame返回AVERROR(EINVAL)折腾了半天才反应过来。正确做法是单独avcodec_find_encoder(AV_CODEC_ID_H264)新建一个AVCodecContext设好码率、帧率、GOP 之后再开。第二个改造是抓帧保存图片。这个简单在onFrameReady里加个按钮触发把当前QImage用save()存成 PNG 或 JPG。但要注意onFrameReady在 UI 线程执行存图片是磁盘 IO大分辨率下会卡一下。我一般会另起一个QtConcurrent::run把图片丢到线程池里写UI 线程立刻返回。第三个改造是加 AI 推理。比如接一个人形检测模型每 N 帧跑一次。这里的关键是别在解码线程里跑推理否则解码节奏被拖慢画面卡成幻灯片。我的做法是解码线程照常发QImage主窗口收到后丢进一个容量为 1 的队列另一个推理线程从队列取最新帧旧帧直接丢弃。这样推理慢不影响播放只是检测结果有延迟。队列用QMutex保护取帧时用tryLock避免阻塞。第四个改造是支持多路流。一个窗口播一路多个窗口就是多个VideoDecoder实例。但 FFmpeg 的全局初始化avformat_network_init()只需要调一次放在main里就行。每个解码器实例持有自己的AVFormatContext互不干扰。CPU 占用会随路数线性增长四路 1080p 解码在中端 CPU 上差不多吃满再往上要考虑硬解。硬解是另一个话题。这个 Demo 用的是软解avcodec_find_decoder找的是 CPU 解码器。如果要上硬解得用avcodec_find_decoder_by_name(h264_cuvid)之类并且把解码后的 GPU 帧映射回 CPU 内存才能给sws_scale用中间多一次拷贝。我试过一版延迟没降多少代码复杂度翻倍后来还是退回软解。除非路数很多或者分辨率到 4K否则软解够用。最后一个建议改任何东西之前先把原始 Demo 跑通确认画面正常、退出干净。然后每加一个功能就提交一次出问题能回退。我吃过亏一口气加了录像、抓帧、OSD 叠加结果画面卡顿排查了一整天才发现是 OSD 叠加那步在 UI 线程里做了字体渲染每帧都重新算一遍。从那以后我每次加功能都强制走一遍「单独编译、单独运行、单独测退出」的流程确认没引入回归再合进去。希望帮到你。本文还有配套的精品资源点击获取