ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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 线程接起来。整套东西不依赖什么重型框架,编译入口就是一份 Makefile,qmake 生成后直接 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 \ -lavutil

INCLUDEPATH指向 FFmpeg 头文件所在目录,LIBS里的-L指定库文件路径。链接顺序这条血泪经验值得记:avformat依赖avcodec,avcodec依赖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、-std=c++11),后者控制链接选项。交叉编译时,把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 -version

FFmpeg 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 # 运行 ./QtFFmpegDemo

qmake会根据.pro文件里的SOURCES、HEADERS、LIBS生成 Makefile。如果.pro里写的库路径和实际安装位置不一致,make会在链接阶段报错。此时改.pro里的INCLUDEPATH和LIBS,重新qmake再make。不要直接改 Makefile,因为下次qmake会覆盖掉。

4.3 运行参数与流地址配置

Demo 里 RTSP 地址通常写死在代码里,或者通过命令行参数传入。常见做法是在main.cpp里读argv:

int 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依赖avcodec,avcodec依赖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 线程里做了字体渲染,每帧都重新算一遍。从那以后我每次加功能都强制走一遍「单独编译、单独运行、单独测退出」的流程,确认没引入回归再合进去。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进