Qt + FFmpeg 播放器整体架构实战:从模块拆分到线程协作
Qt + FFmpeg 播放器整体架构实战:从模块拆分到线程协作
系列导航
1. 为什么先做架构再写功能
播放器最容易“先跑起来再说”,最后常见问题是:
open/play/seek/stop互相影响,状态乱。- 音视频解码线程无法稳定退出。
- 出现卡顿后很难定位是解封装、解码还是渲染瓶颈。
这个项目的做法是把链路拆成 4 层:UI -> Player 编排层 -> Demuxer -> Audio/Video Decoder,优先解决职责边界。
2. 模块划分
2.1 UI 层(MainWindow + VideoWidget)
职责:
- 接收用户操作(打开、播放、暂停、拖动进度条、调音量)。
- 渲染视频帧(当前使用
QPainter路径,OpenGL 版本保留)。 - 只通过
Player对外接口交互,不直接碰 FFmpeg 细节。
对应文件:
mainwindow.cppmainwindow.h
2.2 Player 编排层
职责:
- 管理
Demuxer、AudioDecoder、VideoDecoder三个核心对象。 - 建立线程、信号槽和状态机(Stopped/Playing/Paused)。
- 统一处理时钟同步、seek 编排、音量/倍速等控制。
对应文件:
Player.cppplayer.h
2.3 Demuxer 解封装层
职责:
- 打开输入源(本地文件或 RTSP)。
- 循环
av_read_frame,按流类型分发到音视频包队列。 - 执行 seek,并在 seek 后清队列、flush、重置时钟。
对应文件:
demuxer.cppdemuxer.h
2.4 Decoder 解码层(基类 + 音频/视频派生)
职责:
- 基类封装通用 decode loop:取包 -> send/receive -> 回调派发。
- 音频解码做重采样,输出 PCM 给
QAudioOutput。 - 视频解码做像素格式转换,输出
QImage给 UI。
对应文件:
Decoder.cpp/Decoder.haudiodecoder.cpp/audiodecoder.hvideodecoder.cpp/videodecoder.h
图解:整体架构图

图注:从 UI 到 Player 编排层,再到 Demuxer 与音视频解码器,最后分别进入音频输出与视频渲染;AVSynchronizer 负责时钟收敛。
3. 线程模型
当前是“三工作线程 + UI 主线程”模型:
Demux Thread:读包与 seek。Audio Decode Thread:音频解码与重采样。Video Decode Thread:视频解码与渲染前处理。UI Thread:界面交互与绘制。
启动流程由 Player::play() 统一编排:
moveToThread绑定对象到对应线程。QThread::started连接到各自start槽函数。- 启动三个线程。
停止流程由 Player::stop() 收口:
- 设置停止状态。
- 通知
demuxer/audio/video停止。 quit()+wait()等待线程退出。
这套结构的核心价值是“谁创建,谁编排,谁回收”,避免跨层乱停线程。
4. 数据流主链路
4.1 Open 阶段
Player::open() 完成以下工作:
demuxer.open(path)打开媒体并解析流信息。- 把
AVFormatContext和 stream index 注入给两个 decoder。 - 初始化音频输出格式(采样率/声道/采样格式)。
- 建立关键信号:视频帧、音频时钟、视频时钟、暂停联动。
4.2 Play 阶段
数据流可以简化为:
1 | av_read_frame -> audio/video packet queue |
其中 Demuxer 只管“分发包”,解码器只管“消化包并产出帧”,职责非常清晰。
4.3 关键代码:线程启动与数据分发
Player::play() 里把三个核心模块绑定到各自线程并启动:
1 | void Player::play() |
代码阅读提示:
[1]防止重复调用play()带来的重复 connect/重复线程启动问题。[2][3][4]是线程亲和性设置,保证对象槽函数在目标线程执行。[5][6][7]启动顺序明确后,问题定位更容易(卡在哪条链路一眼可见)。
Demuxer::demuxLoop() 里按流类型把包分发到音视频队列:
1 | int ret = av_read_frame(m_formatCtx, packet); |
代码阅读提示:
[2][4]是关键,若不做av_packet_ref,后续av_packet_unref(packet)会把数据释放掉。[3][5]把“读包”与“解码”解耦,解码线程按各自节奏消费。
5. 同步机制在架构中的位置
项目采用“音频主时钟”策略:
- 音频解码输出时上报
audioPtsUpdated。 - 视频解码在每帧计算
framePts - audioClock决定等待或丢帧。 - 同步状态收敛到
AVSynchronizer单例。
这样同步逻辑不会散落在 UI 和 Demuxer,便于独立优化。
对应接线代码在 Player::open():
1 | connect(m_audioDecoder.get(), &AudioDecoder::audioPtsUpdated, this, [this](double pts) { |
代码阅读提示:
[1]是 seek 体验的关键保护,避免“拖到新位置后又被旧位置拉回”。[2][4]让同步器持有双时钟,便于后续切换同步策略(AudioMaster/VideoMaster)。
6. seek 在架构中的协作分工
seek 不是单点函数,而是跨模块事务:
- Player 标记
m_isSeeking = true。 - Demuxer 收到目标时间,进入 seek 分支。
- 发
seekRequested,Decoder 切换到 seek 态,避免消费旧包。 - Demuxer
av_seek_frame成功后清空队列并avcodec_flush_buffers。 - 重置同步时钟,发
seekCompleted。 - Player 和 Decoder 解除 seek 态,恢复消费。
这一套是“先冻结、再跳转、后解冻”的标准事务流程。
7. 当前架构的优点
- 分层清晰:UI 不碰 FFmpeg,核心逻辑集中在 Player/Demuxer/Decoder。
- 线程边界清晰:每个角色在固定线程运行。
- 扩展友好:倍速、滤镜、硬解都能在 Decoder 层独立演进。
- 支持本地与 RTSP 输入,输入侧和解码侧解耦。
8. 可继续优化的方向
- 连接管理:
play()多次调用时,注意避免重复 connect。 - seek 单位统一:接口命名与时间单位(ms/us)建议强约束。
- 时钟接口统一:
positionChanged的单位建议固定为毫秒并保持全链路一致。 - stop 后对象重建策略:当前主窗口有重建 Player 逻辑,可进一步收敛生命周期。
- 渲染路径:可把
QPainter路径升级到稳定的 OpenGL/YUV 纹理渲染。
9. 小结
这个播放器的架构思路可以概括为一句话:
Player负责编排。Demuxer负责分发。Decoder负责计算。UI负责展示。
把这四件事彻底拆开,后续做同步优化、seek 提速、硬解接入都会顺很多。
系列下一篇
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 David's Blog!
评论