轮播的代码只有三件套:定时器、索引、重绘。 卡顿从来不在这三件套里,而在「下一张还没准备好」——解法是提前一张。
上一节讲怎么画一张,这一节讲怎么每隔几秒换一张。 看起来是界面问题,其实是调度问题:换的那一瞬间,必须有一张已经准备好的图。 这个「提前准备」的思路,第七章播放器的帧队列用的完全是同一套。
一、三件套:定时器、索引、重绘
轮播的全部逻辑就这么几行:
QTimer *t = new QTimer(this);
connect(t, &QTimer::timeout, this, &Carousel::next);
t->start(3000); // 三秒一张
void Carousel::next() {
idx = (idx + 1) % paths.size();
update(); // 只标记,不立刻画
}
三行代码里有两个容易忽略的点:
t->start(3000) 是「至少三秒」,不是「正好三秒」。界面线程忙的时候,定时器会晚到。
update() 只是打个标记,真正绘制发生在下一轮事件循环。连着调十次 update() 只会画一次。
定时器的精度分三档:Qt::PreciseTimer(毫秒级)、Qt::CoarseTimer(默认,可能差 5%)、
Qt::VeryCoarseTimer(差到秒级)。轮播用默认的就够,用精确档反而费电。
update() 与 repaint() 的区别要记住:repaint() 是「立刻同步画」,
在 paintEvent 里调它会递归、死循环。绝大多数情况都该用 update()。
二、卡顿的三个来源
轮播卡一下,原因基本只有三类:
| 来源 | 表现 | 解法 |
|---|---|---|
| 图还没解码 | 切的那一刻停半秒 | 提前加载下一张 |
| 现场做缩放 | 每张都顿一下 | 预缩放到窗口尺寸并缓存 |
paintEvent 里干重活 |
整个窗口一起卡 | 绘制里只做 drawImage |
Demo 量的就是第一类:五张 1080p,每张「转格式 + 缩到 1280×720」平均 14.34 毫秒。
用这个数去对照三档间隔: 500 毫秒的间隔,留给准备的时间是 486 毫秒——绰绰有余。3000 毫秒更不用说。 所以「三秒一张」的轮播,压根不需要预加载。
但把间隔改成 50 毫秒就完全不同了:14.34 ÷ 16.67 = 86%, 一轮事件循环里八成多的时间在准备下一张,界面基本没有响应余地。
这就是「够不够」的正确算法:不是看绝对时间,是看「准备时间 ÷ 间隔」这个比例。 超过一半就该考虑提前准备了。
三、提前一张:双缓冲
做法很朴素:准备两个槽位。一个叫 front,正在显示;一个叫 back,正在准备。 定时器到点时只做一件事:交换两个指针。
交换是 O(1),跟图多大无关。 所以「切换」这件事的耗时变成了零, 剩下的只是「准备」什么时候做——而准备可以摊到前面那一整个间隔里。
这个做法有个通用名字:双缓冲。 它跟显示器防撕裂、跟播放器的帧队列,是同一个思路。 用一份额外的内存,换掉「现做现用」的等待。
多线程版要小心三件事:
准备完的状态用原子标志或者信号回传,别用 while(!ready){} 死等。
准备线程不能直接 update(),要发信号让界面线程去刷新。
准备超时也要切,不然一张坏图能把整个轮播卡死。
第三条最容易被忽略:网络图片加载失败、文件被占用、磁盘休眠, 任何一种都可能让「准备」永远不完成。 界面代码里必须有一条「超时就先用占位图」的退路。
四、动手 Demo:图片轮播,卡顿都出在「下一张还没准备好」
这段把五张 1080p 的图真做一次「转格式并缩到 1280×720」,量平均耗时,再拿它去对照三档切换间隔。 那张「够不够」的表就是全部结论:间隔远大于准备时间,临时解码也来得及。
cost = []
for i in range(5):
img = np.zeros((1080, 1920, 3), dtype=np.uint8)
f = av.VideoFrame.from_ndarray(img, format="rgb24")
t0 = time.perf_counter()
f.reformat(width=1280, height=720, format="rgb24")
cost.append((time.perf_counter() - t0) * 1e3)
avg = sum(cost) / len(cost) # 14.34 毫秒
for gap in (500, 1000, 3000):
print(gap, "%.0f" % (gap - avg),
"够" if gap - avg > 0 else "不够")
实跑输出:
--------------------------------------------------------------------
图片轮播,卡顿都出在「下一张还没准备好」
--------------------------------------------------------------------
**轮播的实现只有一句:定时器到点,换索引、重绘。**
**卡顿的原因也只有一句话:换到下一张时它还没解码。**
五张 1920×1080 的图,**每张「转格式并缩到 1280×720」平均 14.34 毫秒**。
切换间隔 留给准备的时间 够不够 观感
----------------------------------------------------
500 毫秒 486 毫秒 够 流畅
1000 毫秒 986 毫秒 够 流畅
3000 毫秒 2986 毫秒 够 流畅
**三行结论**:
**间隔远大于准备时间 → 临时解码也来得及。**
**间隔接近准备时间 → 每次切都卡一下。**
**间隔小于准备时间 → 永远追不上,越切越慢。**
**所以轮播的标准做法是「提前一张」**:显示第 N 张的时候,后台已经在准备第 N+1 张。
**这样切换只是「换一个已经准备好的对象」,耗时接近零。**
**这个做法有个名字:双缓冲。**
**它跟显示器防撕裂、跟播放器的帧队列是同一个思路。**
用一份额外的内存,换掉「现做现用」的等待。
14.34 毫秒这个数,放在三秒的间隔里完全不值一提——它只占 0.5%。 所以「三秒一张」的轮播,写完就能用,不需要任何优化。
那为什么真实项目里还是卡? 因为真实项目里准备时间不是常数。 图在机械盘上、还被别的程序占着 → 几十毫秒。 图是 4K 的、还要缩到 720p → 五倍以上。 第一次加载还要建解码器,那一下往往就是几百毫秒。
所以「够不够」要按最坏情况算,不是按平均值。Demo 里那张表全写「够」, 是因为它只测了稳态、只测了内存里的数据。
这也是「双缓冲」真正的价值:它不缩短准备时间, 它把准备时间从「切换那一刻」挪到了「切换之前」。 只要平均准备时间小于间隔,就不卡。
30 秒实验
- 把间隔改成 100 毫秒,用 14.34 毫秒算一下:CPU 有百分之多少在准备图片?
- 算一下:十张 1080p 的
Format_RGB32全预加载到内存要多少 MB? - 判断一下:轮播该在
paintEvent里缩放,还是提前缩好存起来?
常见误区
- 在
paintEvent里load()文件。 每次重绘都读盘加解码,窗口被遮挡一下再露出来就卡一次。 加载与绘制必须分开。 - 用
QTimer::singleShot递归实现轮播。 误差会一圈圈累积,而且没有一个统一的句柄能停掉它。 用一个常驻的QTimer。 - 在准备线程里直接调
update()。 非界面线程不能触发绘制,要发信号让界面线程去刷新。 - 把一百张图全部预加载。 一张 1080p 的
Format_RGB32是 8 MB,一百张就是 800 MB, 手机直接被系统杀掉。只提前一张到三张。
自测三问(点开对答案)
1. `update()` 与 `repaint()` 的区别? —— `update()` 只是标记「需要重绘」,多次调用会合并,真正绘制在下一轮事件循环。 `repaint()` 立刻同步重绘,在 `paintEvent` 里调它会递归。 2. 双缓冲解决的到底是什么? —— 解决「准备」和「显示」抢同一份数据。切换变成指针交换,耗时与图的大小无关; 准备被摊到整个间隔里。 3. 三秒的间隔,为什么还要考虑提前加载? —— 因为准备时间不是常数:冷盘、大图、首次建解码器都可能到几百毫秒。提前一张是给最坏情况留余量。第三章到此为止。三节讲的是同一件事的三个层次: 消息怎么送(信号槽)、像素怎么画(QImage)、什么时候换(定时器)。第四章换个层次——不解码也不画图,直接把整个播放流程交给 Qt 的多媒体模块。
- ☐ (本章完,下一节 04.01 Qt的Multimedia多媒体框架简介)