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.cpp
  • mainwindow.h

2.2 Player 编排层

职责:

  • 管理 DemuxerAudioDecoderVideoDecoder 三个核心对象。
  • 建立线程、信号槽和状态机(Stopped/Playing/Paused)。
  • 统一处理时钟同步、seek 编排、音量/倍速等控制。

对应文件:

  • Player.cpp
  • player.h

2.3 Demuxer 解封装层

职责:

  • 打开输入源(本地文件或 RTSP)。
  • 循环 av_read_frame,按流类型分发到音视频包队列。
  • 执行 seek,并在 seek 后清队列、flush、重置时钟。

对应文件:

  • demuxer.cpp
  • demuxer.h

2.4 Decoder 解码层(基类 + 音频/视频派生)

职责:

  • 基类封装通用 decode loop:取包 -> send/receive -> 回调派发。
  • 音频解码做重采样,输出 PCM 给 QAudioOutput
  • 视频解码做像素格式转换,输出 QImage 给 UI。

对应文件:

  • Decoder.cpp / Decoder.h
  • audiodecoder.cpp / audiodecoder.h
  • videodecoder.cpp / videodecoder.h

图解:整体架构图

DP-Player Overall Architecture

图注:从 UI 到 Player 编排层,再到 Demuxer 与音视频解码器,最后分别进入音频输出与视频渲染;AVSynchronizer 负责时钟收敛。

3. 线程模型

当前是“三工作线程 + UI 主线程”模型:

  • Demux Thread:读包与 seek。
  • Audio Decode Thread:音频解码与重采样。
  • Video Decode Thread:视频解码与渲染前处理。
  • UI Thread:界面交互与绘制。

启动流程由 Player::play() 统一编排:

  1. moveToThread 绑定对象到对应线程。
  2. QThread::started 连接到各自 start 槽函数。
  3. 启动三个线程。

停止流程由 Player::stop() 收口:

  1. 设置停止状态。
  2. 通知 demuxer/audio/video 停止。
  3. quit() + wait() 等待线程退出。

这套结构的核心价值是“谁创建,谁编排,谁回收”,避免跨层乱停线程。

4. 数据流主链路

4.1 Open 阶段

Player::open() 完成以下工作:

  • demuxer.open(path) 打开媒体并解析流信息。
  • AVFormatContext 和 stream index 注入给两个 decoder。
  • 初始化音频输出格式(采样率/声道/采样格式)。
  • 建立关键信号:视频帧、音频时钟、视频时钟、暂停联动。

4.2 Play 阶段

数据流可以简化为:

1
2
3
4
av_read_frame -> audio/video packet queue
-> AudioDecoder / VideoDecoder
-> PCM / QImage
-> QAudioOutput / VideoWidget

其中 Demuxer 只管“分发包”,解码器只管“消化包并产出帧”,职责非常清晰。

4.3 关键代码:线程启动与数据分发

Player::play() 里把三个核心模块绑定到各自线程并启动:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void Player::play()
{
if (m_state == Playing) return; // [1] 状态防重入,避免重复启动线程

m_demuxer->moveToThread(&m_demuxThread); // [2] 解封装对象绑定到解封装线程
connect(&m_demuxThread, &QThread::started, m_demuxer.get(), &Demuxer::startDemuxing);

m_audioDecoder->moveToThread(&m_audioDecodeThread); // [3] 音频解码对象绑定到音频线程
connect(&m_audioDecodeThread, &QThread::started, m_audioDecoder.get(), &AudioDecoder::start);

m_videoDecoder->moveToThread(&m_videoDecodeThread); // [4] 视频解码对象绑定到视频线程
connect(&m_videoDecodeThread, &QThread::started, m_videoDecoder.get(), &VideoDecoder::start);

m_demuxThread.start(); // [5] 启动后通过 started 信号进入各模块主循环
m_audioDecodeThread.start(); // [6]
m_videoDecodeThread.start(); // [7]
}

代码阅读提示:

  • [1] 防止重复调用 play() 带来的重复 connect/重复线程启动问题。
  • [2][3][4] 是线程亲和性设置,保证对象槽函数在目标线程执行。
  • [5][6][7] 启动顺序明确后,问题定位更容易(卡在哪条链路一眼可见)。

Demuxer::demuxLoop() 里按流类型把包分发到音视频队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int ret = av_read_frame(m_formatCtx, packet);
if (ret == AVERROR_EOF) {
break; // [1] 文件读完,退出解封装循环
}

if (packet->stream_index == m_audioStreamIndex && m_audioPacketQueue) {
AVPacket* newPacket = av_packet_alloc();
av_packet_ref(newPacket, packet); // [2] 引用拷贝,避免复用 packet 导致数据失效
m_audioPacketQueue->enqueue(newPacket); // [3] 进入音频包队列
} else if (packet->stream_index == m_videoStreamIndex && m_videoPacketQueue) {
AVPacket* newPacket = av_packet_alloc();
av_packet_ref(newPacket, packet); // [4] 引用拷贝
m_videoPacketQueue->enqueue(newPacket); // [5] 进入视频包队列
}

代码阅读提示:

  • [2][4] 是关键,若不做 av_packet_ref,后续 av_packet_unref(packet) 会把数据释放掉。
  • [3][5] 把“读包”与“解码”解耦,解码线程按各自节奏消费。

5. 同步机制在架构中的位置

项目采用“音频主时钟”策略:

  • 音频解码输出时上报 audioPtsUpdated
  • 视频解码在每帧计算 framePts - audioClock 决定等待或丢帧。
  • 同步状态收敛到 AVSynchronizer 单例。

这样同步逻辑不会散落在 UI 和 Demuxer,便于独立优化。

对应接线代码在 Player::open()

1
2
3
4
5
6
7
8
9
connect(m_audioDecoder.get(), &AudioDecoder::audioPtsUpdated, this, [this](double pts) {
if (!m_isSeeking.load()) { // [1] seek 期间不回写旧时钟,避免进度条跳动
AVSynchronizer::instance().updateAudioClock(pts); // [2] 音频主时钟更新
emit positionChanged(pts); // [3] UI 进度同步
}
});

connect(m_videoDecoder.get(), &VideoDecoder::videoPtsUpdated,
this, [](double pts) { AVSynchronizer::instance().updateVideoClock(pts); }); // [4] 视频时钟更新

代码阅读提示:

  • [1] 是 seek 体验的关键保护,避免“拖到新位置后又被旧位置拉回”。
  • [2][4] 让同步器持有双时钟,便于后续切换同步策略(AudioMaster/VideoMaster)。

6. seek 在架构中的协作分工

seek 不是单点函数,而是跨模块事务:

  1. Player 标记 m_isSeeking = true
  2. Demuxer 收到目标时间,进入 seek 分支。
  3. seekRequested,Decoder 切换到 seek 态,避免消费旧包。
  4. Demuxer av_seek_frame 成功后清空队列并 avcodec_flush_buffers
  5. 重置同步时钟,发 seekCompleted
  6. Player 和 Decoder 解除 seek 态,恢复消费。

这一套是“先冻结、再跳转、后解冻”的标准事务流程。

7. 当前架构的优点

  1. 分层清晰:UI 不碰 FFmpeg,核心逻辑集中在 Player/Demuxer/Decoder。
  2. 线程边界清晰:每个角色在固定线程运行。
  3. 扩展友好:倍速、滤镜、硬解都能在 Decoder 层独立演进。
  4. 支持本地与 RTSP 输入,输入侧和解码侧解耦。

8. 可继续优化的方向

  1. 连接管理:play() 多次调用时,注意避免重复 connect。
  2. seek 单位统一:接口命名与时间单位(ms/us)建议强约束。
  3. 时钟接口统一:positionChanged 的单位建议固定为毫秒并保持全链路一致。
  4. stop 后对象重建策略:当前主窗口有重建 Player 逻辑,可进一步收敛生命周期。
  5. 渲染路径:可把 QPainter 路径升级到稳定的 OpenGL/YUV 纹理渲染。

9. 小结

这个播放器的架构思路可以概括为一句话:

  • Player 负责编排。
  • Demuxer 负责分发。
  • Decoder 负责计算。
  • UI 负责展示。

把这四件事彻底拆开,后续做同步优化、seek 提速、硬解接入都会顺很多。


系列下一篇