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 秒实验
- 把消费者改成「每帧多睡 5 毫秒」,看延迟的斜率怎么变。
- 算一下:60 帧每秒、队列积压 6 帧,端到端延迟是多少毫秒?
- 判断一下:
AutoConnection在两条件都在主线程时,是同步还是异步?
常见误区
- 以为队列开大就能治卡顿。 治抖动有效,治「根本解不动」无效,只会让它更晚崩。
- 以为
QTimer准。 默认是CoarseTimer,允许提前或延后; 而且「隔一会儿再来一次」的误差会累积。 - 在子线程里直接调
setPixmap。 那不是信号槽,没人帮你排队,崩得还很随机。 - 以为塞进去(
fromWinId)就万事大吉。 SDL 和 Qt 都以为自己管着窗口, 创建与销毁的顺序必须自己定。
自测三问(点开对答案)
1. `AutoConnection` 什么时候变成队列投递? —— 收发双方不在同一条线程时。同线程是 Direct,当场执行。 2. 队列深度从 2 加到 8,延迟一定变大吗? —— 不一定。消费跟得上时队列是空的,深度不影响延迟;只有消费跟不上时,深度才变成积压上限。 3. Qt 6 的 `QMediaPlayer` 底层用什么? —— FFmpeg。所以第四章讲的那个播放器和这一章讲的 ffplay,底下跑的是同一套东西。第八章到此为止。四节走完一条线:认识 ffplay、控制台跑通、MFC 换窗口、Qt 换线程。第九章把壳全部剥掉,回到 ffplay.c 本身——一行行看它的队列、时钟和同步,那才是播放器真正的难点。
- ☐ (本章完,下一节 09.01 FFplay播放器概述)