🎬 第八章 FFplay+SDL 2开发音视频流媒体播放器
📄 一句话概括

Qt 移植有两条路:把 SDL 的窗口塞进 QWidget,或者干脆不用 SDL 自己画。 真正的难点不在渲染,在「跨线程那一跳」——默认走队列,队列就有延迟,而延迟多大是可以算出来的。

📝 本节和前面几节的关系

08.03 的 MFC 版解决的是「窗口归谁」, Qt 版同样要解决它(QWindow::fromWinId 对 SDL_CreateWindowFrom)。 但 Qt 多了一层东西:信号槽。 它让「子线程算、主线程画」变得非常顺手,顺手到你容易忘记它在排队。 这一节的重点就是把这个「排队」量出来。

一、两条路:塞进去,还是自己画

先选路,再写代码。 三种做法在本课里都出现过,只是分散在不同章节:

做法 关键 API 适合
塞进去 QWindow::fromWinId + SDL_CreateWindowFrom 想少改 ffplay 的代码
自己画 QImage → QPixmap → QLabel 只要一个简单的播放窗口
上 OpenGL QOpenGLWidget + 纹理 要性能、要自己上特效

第一条路最省事,也最容易出怪问题:SDL 和 Qt 各自以为自己管着那块窗口, 谁先建、谁先销毁、谁负责重绘,都得你自己定规矩。

第二条路最稳,代价是每帧多一次转换:QImage 要 Format_RGB32, 而解码出来的是 YUV,这一次转换在本课第五章量过,是逐像素的活。

第三条路性能最好,但工作量大: 你得自己写 YUV 纹理上传、自己管着色器——本质上就是把 ffplay 里 SDL 那一层重写一遍。

选路的判据其实只有一条:你是要「复用 ffplay 的渲染」,还是「复用 ffplay 的解码与同步」。 前者走第一条,后者走第二条或第三条。

二、信号槽的连接方式

Qt 的信号槽有四种连接方式,但真正要记的只有一条规则:

连接方式 投递发生在 额外延迟
Direct 发送方线程里当场执行 0
Queued 塞进接收方事件队列 ≤ 一个事件循环周期
BlockingQueued 同上,但发送方等它做完 ≤ 两个
Auto 同线程 Direct,跨线程 Queued 看情况

Auto 是默认值,也是唯一需要记住的那个:它按「收发双方在不在同一条线程」来选。 所以同一份代码,在主线程调是同步的、在子线程调是异步的——这种「行为随调用者而变」的设计,是 Qt 里最容易误判的地方。

由此推出一条实用规矩:在子线程里 emit 一个信号去刷新界面,是安全的(自动变成 Queued, 槽函数回到主线程执行); 在子线程里直接调 label->setPixmap(),是崩的(那不是信号槽,没人帮你排队)。

这条规矩跟 08.03 里 MFC 的「窗口有线程亲和性」是同一件事, 只是 Qt 用信号槽把它包装得更顺手了。 顺手不等于免费——下一段就量它的价格。

三、队列深度不等于延迟

先做一个「不成立」的实验:生产和消费都按 60 帧每秒,队列深度分别设成 1、2、8。

结果是三种深度几乎一模一样,都是零点几毫秒。 这个结论跟「缓冲越大越迟钝」的常识正好相反——但道理很简单:两边同速,队列里永远是空的, 空队列当然没有延迟。

再做第二个:消费速度降一半(30 帧每秒),生产还是 60 帧。 这次数字一目了然: 深度 2 是九十多毫秒,深度 4 是一百五十多毫秒,深度 8 是两百六十多毫秒——几乎是一条直线。

两次实验合起来才是完整的结论:

情况 队列深度的作用
消费跟得上 深度几乎不影响延迟
消费跟不上 深度每加一帧,延迟涨一帧半左右

所以「把队列开大一点就不卡了」这句话,要看你在治什么病:治抖动有效(偶尔慢一下不立刻卡), 治「解码根本跟不上」无效(只会让它更晚崩)。这两种病长得很像,药是反的。

四、节拍:QTimer 也不是准的

Qt 里最容易写错的一行是 timer.start(16)。 它不保证 16 毫秒一次,QTimer 的精度取决于事件循环的忙闲和定时器类型。

定时器类型 精度 代价
Qt::CoarseTimer 较粗,允许提前或延后 省电
Qt::PreciseTimer 尽量精确到毫秒 更费电
Qt::VeryCoarseTimer 精确到秒 适合定时任务

但即使设成 PreciseTimer,也不该用它打节拍。 原因跟 08.02 里 SDL_Delay 那个坑一样:「隔一会儿再来一次」这种计时方式,误差会累积。

正确做法是同一套:记住下一帧应该在的绝对时间,每次算差值。 Qt 里用 QElapsedTimer 取时间,算出来的差值再决定「睡一会儿还是立刻画」。

这条经验是跨框架通用的:SDL 的 SDL_Delay、MFC 的 SetTimer、Qt 的 QTimer, 三者的共同点是「都只能睡个大概」。能对绝对时间的,永远是你自己记的那个 next。

五、2026 现状:Qt 6

书上用的是 Qt 5 加 VS 2015 的组合,现在这个组合装起来都得折腾半天: VS 2015 的 Qt 插件早就不维护了。

东西 书上 现在
Qt 版本 5.x 6.x
构建方式 .pro + qmake CMake 为主
多媒体 QMediaPlayer(依赖平台后端) 同上,但后端换了 FFmpeg
OpenGL 控件 QOpenGLWidget QRhiWidget(Qt 6.7 起)

有一处变化值得单说:Qt 6 的 QMediaPlayer 后端换成了 FFmpeg, 也就是说「用 Qt 内置的多媒体模块」这条路,底下跑的就是本课讲的东西。 知道这一点,第四章讲的 QMediaPlayer 和这一章讲的 ffplay,就能接上了。

QOpenGLWidget 的地位也在变:Qt 6.7 起官方推 QRhiWidget, 用统一渲染接口屏蔽后端差异。书上那一套还能用,但新项目建议从 QRhiWidget 起。

六、三条路怎么选:一张收尾的表

把这一节和上一节的判断合起来,就是一张选型表:

你的情况 选哪条 主要风险
已有 ffplay 代码,只想换界面 塞进去(fromWinId) 窗口归属与销毁顺序
界面为主,视频只是其中一块 自己画(QImage) 每帧一次格式转换
要 4K、要特效、要低延迟 OpenGL 纹理 工作量大,要自己管同步

没有一条是「最好」的,只有「跟你手上的代码最省事」的那条。

最后提醒一句:三条路共用同一套「解码 + 队列 + 同步」的逻辑, 那部分才是播放器的本体,也是第九章要一行行读的东西。 换壳换不掉它。

七、动手 Demo:Qt 移植——跨线程那一跳,延迟到底有几帧

Qt 版的渲染代码跟 MFC 版几乎一样,多出来的只有「子线程怎么把帧交给主线程」这一跳。 这一跳在信号槽里是隐形的,所以先用一个队列把它显形,再量它。 量两个场景:两边同速、以及消费只有一半。

def run(depth, slow=1.0):
    q = queue.Queue(maxsize=depth)
    def producer():
        for i in range(90):
            q.put((i, time.perf_counter()))
            sleep_until(next_tick())
    threading.Thread(target=producer).start()
    while True:
        item = q.get()
        if item is None: break
        lat = time.perf_counter() - item[1]   # 端到端延迟
        sleep_until(next_tick() * slow)

实跑输出:

--------------------------------------------------------------------
Qt 移植——跨线程那一跳,延迟到底有几帧
--------------------------------------------------------------------
**Qt 移植 ffplay 的难点跟 MFC 不一样**:**MFC 难在「窗口怎么给」,Qt 难在「线程怎么连」。**
**因为 Qt 的信号槽默认跨线程走队列,一排队就有延迟。**

**先真跑一遍**:一条线程按 60 帧/秒产帧,另一条线程按同样的节拍取走,中间隔一个队列,
队列最大长度分别是 1、2、8。

  队列最大长度     平均延迟      中位延迟      最大延迟
  ----------------------------------------------------------
  1 帧             0.21 毫秒     0.10 毫秒     1.30 毫秒
  2 帧             0.13 毫秒     0.08 毫秒     1.50 毫秒
  8 帧             0.15 毫秒     0.10 毫秒     1.04 毫秒

**三种深度几乎一模一样,这个结果跟常识相反,得照实说**:**队列长度本身不产生延迟**。
**生产和消费速度相同,队列里永远是空的**,
**最长的那一格只是「偶尔排队一下」,所以三种深度测出来都是零点几毫秒。**

**那「缓冲越大越迟钝」这句话什么时候成立?把消费速度降一半再看**——消费 30 帧/秒,
生产还是 60 帧/秒。

  队列最大长度     平均延迟     中位延迟     最大延迟
  -------------------------------------------------------
  2 帧             96 毫秒      100 毫秒     105 毫秒
  4 帧             156 毫秒     166 毫秒     168 毫秒
  8 帧             268 毫秒     300 毫秒     301 毫秒

**这次数字一目了然:队列越长,延迟越高**,**而且几乎是一条直线。**
**因为消费跟不上,队列只能一路积压到满**——**每多一格就多积压一帧半**,
**所以 8 帧的队列在这里是 268 毫秒,而不是同速时的 133 毫秒。**
**这才是「缓冲越大越流畅、但越迟钝」的量化版本**:
**它成立的前提是「消费比生产慢」,不是「队列设得长」。**
**Qt 的三种连接方式对应三种不同的「这一跳」**,算一下就清楚了:

  连接方式     投递发生在                 额外延迟
  ---------------------------------------------------------------------
  Direct       发送方线程里当场执行       0 毫秒
  Queued       塞进接收方事件队列         ≤ 一个帧间隔(16.7 毫秒)
  Blocking     同上,但发送方等它做完     ≤ 两个帧间隔

**`AutoConnection` 的规则只有一句**:**收发双方在同一条线程就是 Direct,不在就是 Queued。**
**所以「在子线程里 `emit` 一个信号去刷新界面」是安全的**(自动变 Queued),
**而「在子线程里直接调控件的 `setPixmap`」是崩的**(那不是信号槽,没人帮你排队)。

**最后把「解码这条线程有多忙」量出来**(320×240、40 帧):

  单帧解码耗时:**中位 0.14 毫秒,最慢 0.43 毫秒**
  60 帧/秒的预算是 **16.67 毫秒**,单帧只占 **0.87%**

**这个占比说明「解码这条线程根本不忙」**——**余量大到不需要缓冲,队列开成 1 帧也不会卡。**
**但这只代表「320×240、本机、硬解以外的软解」这一种情况。**

**真实播放里「占用」远不止解码这一项**:
**后面还有 `sws_scale` 的格式转换、还有网络与磁盘的抖动。**
**ffplay 把视频包队列默认开到 15 帧,不是给「解码慢」留的余量,是给「网络抖」留的余量。**
**分清这两种缓冲,才知道改哪个参数有用。**

第一张表要看的是「三个数几乎一样」这件事,而不是某一个具体值。 它证明的是:队列深度本身不产生延迟。 我们平时说的「缓冲越大越迟钝」,其实说的是「缓冲里攒了几帧」,不是「缓冲能装几帧」。

第二张表斜率接近「深度 × 一帧半」,为什么是一帧半而不是一帧:因为生产比消费快一倍, 每过一帧,队列就多积压半帧到一帧——多出来的那部分,是「速度差」而不是「帧间隔」。

这两张表放一起,就把一句模糊的经验变成了可算的东西:延迟 ≈ 队列里积压的帧数 × 帧间隔。 而「积压多少」由生产与消费的速度差决定,跟你把 maxsize 设成多大没关系——maxsize 只是「最多允许积压多少」。

最后一段量的是「解码这条线程有多忙」,结论是「远远不忙」: 单帧只占 60 帧预算的百分之零点几。 所以在这台机器、这个分辨率上,队列开成 1 帧也不会卡。

但这个结论千万不能外推:换成 1080p,解码耗时按像素涨几十倍, 再叠上网络抖动,余量很快就没了。 ffplay 把视频包队列开到 15 帧,防的是网络,不是解码。

这也是本课最后一次强调「测得出来的」和「推不出来的」要分开写: 这台机器的数字只对这台机器成立,但它揭示的规律——延迟等于积压帧数乘帧间隔——是通用的。

30 秒实验

  1. 把消费者改成「每帧多睡 5 毫秒」,看延迟的斜率怎么变。
  2. 算一下:60 帧每秒、队列积压 6 帧,端到端延迟是多少毫秒?
  3. 判断一下:AutoConnection 在两条件都在主线程时,是同步还是异步?

常见误区

  1. 以为队列开大就能治卡顿。 治抖动有效,治「根本解不动」无效,只会让它更晚崩。
  2. 以为 QTimer 准。 默认是 CoarseTimer,允许提前或延后; 而且「隔一会儿再来一次」的误差会累积。
  3. 在子线程里直接调 setPixmap。 那不是信号槽,没人帮你排队,崩得还很随机。
  4. 以为塞进去(fromWinId)就万事大吉。 SDL 和 Qt 都以为自己管着窗口, 创建与销毁的顺序必须自己定。
自测三问(点开对答案) 1. `AutoConnection` 什么时候变成队列投递? —— 收发双方不在同一条线程时。同线程是 Direct,当场执行。 2. 队列深度从 2 加到 8,延迟一定变大吗? —— 不一定。消费跟得上时队列是空的,深度不影响延迟;只有消费跟不上时,深度才变成积压上限。 3. Qt 6 的 `QMediaPlayer` 底层用什么? —— FFmpeg。所以第四章讲的那个播放器和这一章讲的 ffplay,底下跑的是同一套东西。

第八章到此为止。四节走完一条线:认识 ffplay、控制台跑通、MFC 换窗口、Qt 换线程。第九章把壳全部剥掉,回到 ffplay.c 本身——一行行看它的队列、时钟和同步,那才是播放器真正的难点。

相关笔记