🎬 第三章 Qt信号槽机制及图片轮播
📄 一句话概括

轮播的代码只有三件套:定时器、索引、重绘。 卡顿从来不在这三件套里,而在「下一张还没准备好」——解法是提前一张。

📝 本节和前面几节的关系

上一节讲怎么画一张,这一节讲怎么每隔几秒换一张。 看起来是界面问题,其实是调度问题:换的那一瞬间,必须有一张已经准备好的图。 这个「提前准备」的思路,第七章播放器的帧队列用的完全是同一套。

一、三件套:定时器、索引、重绘

轮播的全部逻辑就这么几行:

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 秒实验

  1. 把间隔改成 100 毫秒,用 14.34 毫秒算一下:CPU 有百分之多少在准备图片?
  2. 算一下:十张 1080p 的 Format_RGB32 全预加载到内存要多少 MB?
  3. 判断一下:轮播该在 paintEvent 里缩放,还是提前缩好存起来?

常见误区

  1. 在 paintEvent 里 load() 文件。 每次重绘都读盘加解码,窗口被遮挡一下再露出来就卡一次。 加载与绘制必须分开。
  2. 用 QTimer::singleShot 递归实现轮播。 误差会一圈圈累积,而且没有一个统一的句柄能停掉它。 用一个常驻的 QTimer。
  3. 在准备线程里直接调 update()。 非界面线程不能触发绘制,要发信号让界面线程去刷新。
  4. 把一百张图全部预加载。 一张 1080p 的 Format_RGB32 是 8 MB,一百张就是 800 MB, 手机直接被系统杀掉。只提前一张到三张。
自测三问(点开对答案) 1. `update()` 与 `repaint()` 的区别? —— `update()` 只是标记「需要重绘」,多次调用会合并,真正绘制在下一轮事件循环。 `repaint()` 立刻同步重绘,在 `paintEvent` 里调它会递归。 2. 双缓冲解决的到底是什么? —— 解决「准备」和「显示」抢同一份数据。切换变成指针交换,耗时与图的大小无关; 准备被摊到整个间隔里。 3. 三秒的间隔,为什么还要考虑提前加载? —— 因为准备时间不是常数:冷盘、大图、首次建解码器都可能到几百毫秒。提前一张是给最坏情况留余量。

第三章到此为止。三节讲的是同一件事的三个层次: 消息怎么送(信号槽)、像素怎么画(QImage)、什么时候换(定时器)。第四章换个层次——不解码也不画图,直接把整个播放流程交给 Qt 的多媒体模块。

相关笔记