
简介基于C与Qt框架的视频会议系统项目源码采用经典客户端-服务器架构适合具备C基础、希望系统学习Qt网络编程与音视频处理的开发者。工程代码按客户端和服务器两侧组织覆盖音视频采集、编码解码、流媒体传输、用户认证与权限管理等关键环节能够帮助读者掌握多线程并发、信号槽机制、TCP与UDP通信及数据库操作等综合技术。资源压缩包共2000个文件体积约43.94MB主要包含47个C源码、24个头文件、Qt界面设计文件与工程配置文件以及大量编译过程生成的索引文件、目标文件和发布调试版本程序便于对照工程目录理解构建流程。项目整体以My_meeting为主目录结构清晰已有253人学习下载既适合作为课程设计或毕业设计的完整参考也便于在此基础上进行二次开发与功能扩展。1. 视频会议的 C/S 边界比摄像头和编码器更先决定成败视频会议给人的第一印象是摄像头、编码器和音画同步但真正让项目活下来的往往是架构边界。会议室里每个参会者都要把画面和声音发给别人同时接收别人的流这些流如果全部绕经中心服务器带宽成本会随人数指数膨胀如果完全点对点登录鉴权和地址交换又缺少锚点藏在网络地址转换后面的设备互相找不到。所以标题里的 C/S 模式指的是把登录、房间、成员状态这类信令集中在一台服务端音视频按网络情况走端到端直连或经服务端中转。这套实现的代码按 Qt 6.5 / C17 组织用到 Qt 5.15 时会标记类名差异。适合已经能写 Qt 界面、准备往多媒体方向走一步的工程师也适合评估自研会议室客户端的团队。2. C/S 架构下的信令通道先让客户端可靠地进入同一间房2.1 服务端职责划分信令走 TCP媒体走 UDP很多团队第一版就把服务端设计成转发所有流量最后发现带宽扛不住延迟也压不下来。原因在于信令和媒体对可靠性的要求完全相反信令可以接受几十毫秒延迟但不能丢状态媒体可以容忍丢包但不能容忍排队重传。下面这张职责表是大多数 Qt 视频会议项目的标准拆分平面传输方式Qt 技术栈具体职责信令平面TCP JSONQTcpServer / QTcpSocketQDataStream 分包登录、房间、成员状态、媒体地址交换媒体平面UDP 简化 RTPQUdpSocket音视频包传输不做退避重传控制平面UDP / 复用媒体端口自定义 RTCP 风格报文丢包率、往返时延统计供码率自适应用信令要解决的核心问题是“谁在哪个房间、用什么地址收流”。媒体地址交换由服务端完成客户端登录后拿到令牌加入房间时把本端监听 UDP 的端口上报服务端把房间成员列表连同各自媒体地址一起下发。为什么媒体要单独走 UDPTCP 的粘包、重传和队头阻塞在实时音频里不可接受一个视频关键帧丢包后重传出的大量数据会把后面所有音频包都堵在同一队列里。所以在 C/S 框架内做媒体通道时服务端只转发 UDP 包或者只交换地址让客户端直连不碰 TCP。房间人数多时服务端可以升级成 SFU 角色对媒体做选择性转发那是这个架构的扩展方向不是第一版要考虑的事。2.2 用 QTcpServer 写一个最小信令服务端#include QTcpServer #include QTcpSocket #include QDataStream #include QJsonDocument #include QJsonObject class SignallingServer : public QTcpServer { Q_OBJECT public: explicit SignallingServer(QObject *parent nullptr) : QTcpServer(parent) { connect(this, QTcpServer::newConnection, this, SignallingServer::handleNewConnection); } private slots: void handleNewConnection() { QTcpSocket *socket nextPendingConnection(); connect(socket, QTcpSocket::readyRead, this, [this, socket]() { handleReadyRead(socket); }); connect(socket, QTcpSocket::disconnected, this, [socket]() { socket-deleteLater(); }); } void handleReadyRead(QTcpSocket *socket) { QDataStream in(socket); in.setVersion(QDataStream::Qt_6_5); // 收发端版本必须一致 while (socket-bytesAvailable() 0) { QByteArray payload; in payload; // 前面自带 4 字节长度前缀自动处理粘包 QJsonObject msg QJsonDocument::fromJson(payload).object(); dispatch(socket, msg); } } private: void dispatch(QTcpSocket *socket, const QJsonObject msg); };TCP 是字节流没有消息边界客户端一次 send 的两条 JSON 可能粘在一起一条大消息也可能被拆成三次到达。QDataStream 的 payload之所以能在这里直接用是因为发送端用out payload写入时Qt 会自动在数据前加一个 4 字节的长度字段接收端按同样规则先读长度再读正文。这里必须保证Qt_6_5这个版本号一致否则收发两端对多字节整数的字节序解析会不一致。注意一个反直觉细节bytesAvailable() 0的循环条件不要改成waitForReadyRead()那会阻塞事件循环。信令服务端是单线程事件驱动任何阻塞都会让所有房间的踢人、心跳检查跟着卡住。2.3 客户端状态机与心跳把“已连接”变成“可开会”客户端的状态要比服务端复杂因为连接建立之后还要经过登录和加入房间两步。建议维护三态状态允许操作收到 join_room 响应前Disconnected只能发起连接不发送任何业务包SignedIn查询房间、加入房间不启动音视频采集InRoom上报媒体地址、收发媒体等待房间成员列表下发enum class ClientState { Disconnected, SignedIn, InRoom }; class MeetingClient : public QObject { Q_OBJECT public: void sendPacket(const QJsonObject obj) { QByteArray payload QJsonDocument(obj).toJson(QJsonDocument::Compact); QDataStream out(m_socket); out.setVersion(QDataStream::Qt_6_5); out payload; // 自动写入长度前缀 } private: QTcpSocket m_socket; ClientState m_state ClientState::Disconnected; QTimer m_heartbeatTimer; // 5 秒触发一次 };心跳每 5 秒一个heartbeat包服务端连续两次没收到就断开并向同房间其他成员广播leave_room。不要依赖 TCP 的 disconnected 信号做踢人因为断网与拔网线在 TCP 层要等很久才能暴露。客户端的定时器要挂在事件循环里不能放到子线程的 QTimer 上。2.4 房间管理先别急着建数据库表服务端内部用一个QHashQString, Room维护房间Room 结构里放成员列表和创建时间。为什么不直接上 QSqlDatabase单机会议室原型的并发量通常在一百个房间以内内存哈希表的读写性能和一致性都好过外接数据库等出现多台服务端负载均衡的需求再把房间状态抽成粘性会话或者接入消息队列。为数据库加连接池、事务、序列化的成本大约需要半天但引入后这些代码每天都在分摊维护成本前期完全没必要。消息类型约定用register返回 tokenjoin_room返回房间成员列表leave_room广播通知media_info交换 UDP 地址。所有 JSON 字段采用小写下划线命名和 Qt 源码风格保持一致线上抓包时 grep 也方便。三个边界条件值得提前处理join_room 时上报本端媒体端口服务端自身不参与媒体面踢人时要主动发通知不要等心跳超时。3. 用 Qt Multimedia 拿帧、用 QUdpSocket 送包媒体通道的最小闭环3.1 设备枚举要放在进入会议室之前const QListQCameraDevice cams QMediaDevices::videoInputs(); for (const QCameraDevice cam : cams) { qInfo() camera: cam.description(); const QListQCameraFormat fmts cam.videoFormats(); for (const QCameraFormat fmt : fmts) { if (fmt.resolution().width() 1280 fmt.frameRate() 30) qInfo() fmt.pixelFormat() fmt.resolution() fmt.frameRate(); } }这段代码要在信令登录之前做因为在 Windows 和 macOS 上摄像头权限不是枚举时才弹窗而是首次 start 才请求。直接在加入房间后采集用户拒绝权限会导致进房失败体验很糟糕。参数选择上第一版先锁 720p/30fps不要碰 4K。视频会议的分辨率收益不是线性的带宽需求随像素数线性上涨但人脸识别和共享桌面的清晰度在 1080p 以下就够用了。3.2 QMediaRecorder 的缓冲与低延迟会议之间的冲突Qt 的 QMediaCaptureSession 加 QMediaRecorder 这套组合面向录制场景编码缓冲通常在几百毫秒量级录文件没问题但开会时会有明显的口型对不上。原生 Qt 没有低延迟直播用的编码管线这是设计目标决定的不是配置能解决的。所以常见的做法是分两路演示级实现用 QMediaCaptureSession 加 QVideoSink 拿原始帧逐帧编码后走 QUdpSocket。生产级实现把采集到的帧交给 FFmpeg、GStreamer 或平台硬编 API输出 H.264音频用 Opus。下面给出的最小闭环属于第一种它跑通“采集、发送、接收、显示”的完整链路验证网络模型和信令交互是否合理。编码效率的问题留到 4.2 节讲。3.3 逐帧采集与简化版 RTP 发送class VideoSender : public QObject { Q_OBJECT public: void start(const QCameraDevice device) { m_camera.reset(new QCamera(device)); m_session.setCamera(m_camera.data()); m_sink m_session.videoSink(); connect(m_sink, QVideoSink::videoFrameChanged, this, VideoSender::onFrame); m_camera-start(); } private slots: void onFrame(const QVideoFrame frame) { // map/unmap 必须在同一线程UI 线程回调里做映射是安全的 if (!m_lastSendTimer.isValid() || m_lastSendTimer.elapsed() 33) return; // 限帧率 30避免高帧率设备打爆带宽 QVideoFrame f frame; if (!f.map(QVideoFrame::ReadOnly)) return; QImage img(f.bits(), f.width(), f.height(), f.bytesPerLine(), QImage::Format_RGB32); sendRtp(img); f.unmap(); m_lastSendTimer.restart(); } private: void sendRtp(const QImage img); QScopedPointerQCamera m_camera; QMediaCaptureSession m_session; QVideoSink *m_sink nullptr; QElapsedTimer m_lastSendTimer; };这段代码有两个容易踩的坑。第一map/unmap必须配对而且要在同一线程执行如果 QVideoFrame 来自采集线程就不能把它跨线程抛给网络线程后再 unmap。第二不是每个摄像头回调都值得发30fps 已经覆盖绝大多数会议场景用 QElapsedTimer 直接丢帧是零拷贝的限流方式比硬件编码器做帧率控制简单得多。QImage img(f.bits(), ...)这里有个隐藏假设帧格式是 RGB32。实际摄像头回传的通常是 NV12直接把 NV12 数据按 RGB32 构造图像会花屏。原型阶段可以用frame.toImage()代替它内部会做格式转换但性能一般正式实现要保留 YUV 平面数据直接送硬编。自定义 RTP 头部可以做得非常简单#pragma pack(push, 1) struct RtpHeader { quint8 v_p_x_cc 0x80; // 版本号 2标记位CSRC 计数 0 quint8 m_pt; // marker payload type 7H264 quint16 seq; // 发送序号回绕靠接收端处理 quint32 timestamp; // 90kHz 时钟音画同步用 quint32 ssrc; // 屏幕共享、摄像头、麦克风各自的流标识 }; #pragma pack(pop)RTP 的核心价值是让接收端拿到序号和时间戳。序号用来排序和统计丢包时间戳用来音画同步SSRC 则用来区分同一地址的多路流。一个摄像头、一个麦克风、一个屏幕共享就至少三个 SSRC接收端按 SSRC 分别建队列不要混在一起。3.4 接收端渲染与音频播放关注时钟而不是关注播放接收端绑定 UDP 端口时有一个关键选项m_socket.bind(port, QUdpSocket::ShareAddress | QUdpSocket::ReuseAddressHint);ShareAddress 允许同一台机器上开两个客户端进程接收同一端口的数据这对本机联调非常有用。如果漏掉这个选项第二个客户端会绑定失败你会误以为是协议问题。收到 RTP 包后按 SSRC 放入独立队列根据 seq 做 16 个包的抖动缓冲小于 16 个包直接播放会抖动明显大于 16 个包则端到端延迟过高。音频播放端在 Qt 6.4 以后用 QAudioSinkQt 5.15 对应类是 QAudioOutput。QAudioFormat fmt; fmt.setSampleRate(48000); fmt.setChannelCount(1); fmt.setSampleFormat(QAudioFormat::Int16); QAudioSink sink(fmt); // Qt 6.4Qt 5.15 用 QAudioOutput sink.start(m_audioDevice); // m_audioDevice 是自定义 QIODevice原始 PCM 48kHz 单声道 16bit 的码率是 768kbps这个数字在局域网没问题公网会议必须上 Opus。Qt 没有内置 Opus 编码生产项目一般引第三方编译好的库在最小闭环里先传 PCM把音频编码的替换点留在 QIODevice 的 readData 里后面接编码器时不需要改网络层。4. 客户端界面、线程模型与性能调优画面不能排在调优之后4.1 用 model/view 管理参会者列表不要手动 new QLabel新手做会议界面最容易写成进房时循环 new QLabel 并塞进 QHBoxLayout离开时 deleteWidget。人数少于 8 人看不出问题一旦超过 16 人每个人头的图像帧刷新都会触发整列控件的重算界面卡顿立刻显现。通常我会用 QAbstractListModel 加 QStyledItemDelegate 实现网格墙class MemberModel : public QAbstractListModel { Q_OBJECT public: int rowCount(const QModelIndex parent) const override { return parent.isValid() ? 0 : m_members.size(); } QVariant data(const QModelIndex index, int role) const override { if (!index.isValid()) return {}; const Member m m_members.at(index.row()); if (role Qt::DisplayRole) return m.name; if (role Qt::UserRole) return QVariant::fromValue(m.avatar); return {}; } private: QVectorMember m_members; };delegate 的 paint 里直接用 QPainter::drawImage 画到头像矩形不创建任何 QWidget。这样几百人的房间每帧只触发待更新区域的绘制而不是整行的布局重算。delegate 里不要保存成员指针数据变化时通过 model 的 dataChanged 通知 index 重绘。4.2 分辨率、帧率与码率的三个基准数字参数组合典型码率适用场景640x360 / 25fps0.5 - 1 Mbps弱网、低端 CPU1280x720 / 30fps2 - 4 Mbps桌面会议默认值1920x1080 / 30fps4 - 8 Mbps屏幕共享、课件第一个基准分辨率提升一倍码率未必只翻一倍运动剧烈的画面可能要翻三倍。所以降码率应该先降帧率再降分辨率。第二个基准丢包率超过 2% 时降帧率到 15fps丢包率超过 5% 时降分辨率到 360p。这个判断要从 RTCP 风格的接收端报告拿而不是服务端统计因为服务端不参与媒体面时根本看不到丢包。第三个基准避免每帧做 RGB32 转换NV12 数据直接送硬编CPU 占用可以从 30% 降到 8%。4.3 跨线程传帧的正确姿势采集线程不能直接碰 QLabel这是 Qt 线程模型的红线。正确姿势是子线程只构造 QImage通过信号槽发到 UI 线程UI 线程再更新控件。class FrameProducer : public QObject { Q_OBJECT signals: void frameReady(const QImage img); public slots: void produce() { QImage img capture(); emit frameReady(img); // 传值而非传引用 } };关键是这里按值传递 QImage。QImage 是隐式共享的按引用跨线程传递会导致两个线程同时操作同一份数据。按值传递时 Qt 在信号槽内部做一次引用计数拷贝虽然也有成本但比深拷贝便宜得多。如果帧率 30fps 时拷贝开销明显可以改成环形缓冲加 QSemaphore 手写同步但第一版没必要。5. 打包发布与远程排错在你没有的机器上把会议跑通5.1 windeployqt 之外还要交付什么Qt 程序发布最常见的失败不是代码问题而是运行库缺失。Windows 上用命令部署C:\Qt\6.5.3\msvc2019_64\bin\windeployqt.exe --release --compiler-runtime build\MeetingApp.exewindeployqt 会拷 plugins、QML 模块和 Qt 核心库但注意它不会自动携带 MSVC 运行库和摄像头、音频的后端插件之外的可选组件。发布到客户机器后如果看到qt.qpa.plugin: Could not find the Qt platform plugin windows先检查 exe 同级目录里有没有platforms/qwindows.dll八成是部署时少带了插件目录。还有一类运行时错误指向visual c redistributable处理方式是安装对应版本的 vc_redist或者在部署脚本里把 msvcp 系列 dll 一并拷进运行目录。5.2 崩溃定位先看日志再猜代码现象典型原因定位手段双击后闪退报 Qt 平台插件缺失plugins 目录未发布检查 exe 同级 platforms 目录进房后偶发崩溃QVideoFrame 在 map 期间被释放开 ASAN检查 map/unmap 配对界面假死但声音还在子线程直接操作 QWidget日志里打印调用线程 ID内存随通话时长上涨QImage 缓存未清理观察 RSS 与帧率曲线最后一个问题最容易忽略采集回调里创建 QImage 后如果把它存进全局缓存却不做上限控制几十分钟就会吃光内存。缓存队列的长度要按帧率和最大延迟计算比如 30fps、抖动缓冲 300ms容量上限就是 9 帧超过就丢最老的。5.3 用 QLoggingCategory 给线上会议留一张黑匣子远程排错最怕的是用户一句话“刚才卡了一下”却没有任何现场数据。用 QLoggingCategory 分类记录比 qDebug 打天下可读性强得多Q_LOGGING_CATEGORY(lcMeetNet, meet.network) Q_LOGGING_CATEGORY(lcMeetMedia, meet.media) qCDebug(lcMeetNet) rtp_loss lossRate seq seq; qCDebug(lcMeetMedia) frame_interval_ms lastInterval;发布版本不一定是静默的Qt 支持运行时打开日志类别在启动命令里加-logging:meet.network.debugtrue或者设环境变量 QT_LOGGING_RULES。线上服务端把带时间戳的日志回传本地就可以画出丢包率和帧间隔曲线。会议类项目还有一个常用经验把 RTP 的 seq 写到日志里用户反馈画面卡顿时查同一时间段内 seq 跳变情况就能区分是网络丢包还是本端解码不及。把这两行日志输出到文件回到工位直接看 seq 和丢包区间就行。本文还有配套的精品资源点击获取