342 lines
6.2 KiB
Markdown
342 lines
6.2 KiB
Markdown
我的建议是:
|
||
|
||
```text
|
||
不要靠主线程固定定时器轮询 update。
|
||
后台生成完新图后,提交一个刷新任务到 Qt 主事件循环。
|
||
如果你要限帧,再用单次 QTimer 做合并/节流。
|
||
```
|
||
|
||
也就是主线程刷新应该是 **事件驱动**,不是一直定时扫。
|
||
|
||
---
|
||
|
||
# 1. 不推荐固定主线程 timer 轮询 update
|
||
|
||
比如这种方式:
|
||
|
||
```text
|
||
主线程每 16ms 扫描所有图
|
||
如果 rendered_version > painted_version
|
||
update()
|
||
```
|
||
|
||
它能工作,但问题是:
|
||
|
||
```text
|
||
没有新图时也一直醒
|
||
图很多时每次都要扫一遍
|
||
后台刚生成完图,最多要等一个 timer 周期
|
||
主线程刷新逻辑和后台完成事件脱节
|
||
```
|
||
|
||
如果你有很多图,这种轮询会变成一种隐性开销。
|
||
|
||
尤其是图不可见、数据静止时,timer 仍然在跑,这不优雅。
|
||
|
||
---
|
||
|
||
# 2. 更推荐:后台发布 ready 后投递主线程刷新任务
|
||
|
||
后台线程生成完像素图:
|
||
|
||
```text
|
||
CPU 线程生成 working
|
||
↓
|
||
发布到 ready
|
||
↓
|
||
发现 rendered_version > requested_paint_version
|
||
↓
|
||
投递一个 Qt 主线程任务
|
||
↓
|
||
主线程里调用 update(region)
|
||
```
|
||
|
||
注意:后台线程不要直接调用 QWidget 的 `update()`。Qt GUI 对象应该在 GUI 线程使用。
|
||
|
||
应该用:
|
||
|
||
```cpp
|
||
QMetaObject::invokeMethod(widget, [this] {
|
||
widget->update(dirty_region);
|
||
}, Qt::QueuedConnection);
|
||
```
|
||
|
||
或者用 signal/slot,连接方式是 `Qt::QueuedConnection`。
|
||
|
||
---
|
||
|
||
# 3. 但是要加 pending,避免疯狂投递
|
||
|
||
后台很多图同时完成时,不能每个图都投递一个主线程任务。
|
||
|
||
应该有一个全局 pending 标志:
|
||
|
||
```cpp
|
||
std::atomic_bool update_pending = false;
|
||
```
|
||
|
||
请求刷新:
|
||
|
||
```cpp
|
||
void Render_Scheduler::request_update(Chart_Node* chart) {
|
||
{
|
||
std::scoped_lock lock(dirty_lock);
|
||
dirty_region += chart->rect();
|
||
}
|
||
if (update_pending.exchange(true, std::memory_order_acq_rel)) {
|
||
return;
|
||
}
|
||
QMetaObject::invokeMethod(widget, [this] {
|
||
flush_update();
|
||
}, Qt::QueuedConnection);
|
||
}
|
||
```
|
||
|
||
主线程执行:
|
||
|
||
```cpp
|
||
void Render_Scheduler::flush_update() {
|
||
QRegion region;
|
||
{
|
||
std::scoped_lock lock(dirty_lock);
|
||
region = dirty_region;
|
||
dirty_region = QRegion();
|
||
}
|
||
update_pending.store(false, std::memory_order_release);
|
||
if (!region.isEmpty()) {
|
||
widget->update(region);
|
||
}
|
||
}
|
||
```
|
||
|
||
这样效果是:
|
||
|
||
```text
|
||
后台完成 100 个图
|
||
↓
|
||
只投递 1 个主线程刷新任务
|
||
↓
|
||
主线程合并 dirty region
|
||
↓
|
||
调用一次 update(region)
|
||
```
|
||
|
||
这个比固定 timer 更合适。
|
||
|
||
---
|
||
|
||
# 4. Qt 的 update 本身也会合并
|
||
|
||
`update()` 不是马上 paint,它只是请求 Qt 稍后重绘。
|
||
|
||
所以:
|
||
|
||
```cpp
|
||
widget->update();
|
||
widget->update();
|
||
widget->update();
|
||
```
|
||
|
||
通常不会立刻触发三次 `paintEvent()`,Qt 会合并。
|
||
|
||
但是你自己这层仍然要合并,因为:
|
||
|
||
```text
|
||
Qt 会合并 paint event
|
||
但你仍然可能投递了大量 QueuedConnection 任务
|
||
```
|
||
|
||
所以你自己的 `update_pending` 是为了减少主线程事件队列压力。
|
||
|
||
---
|
||
|
||
# 5. timer 应该用在哪里
|
||
|
||
timer 不是完全不用,而是用途要分清。
|
||
|
||
## 计算调度 timer:可以有
|
||
|
||
比如每 16ms / 33ms 扫描哪些图需要提交 CPU 任务:
|
||
|
||
```text
|
||
Render_Scheduler::tick()
|
||
扫描 meta_version
|
||
提交必要 CPU 任务
|
||
```
|
||
|
||
这个 timer 管的是:
|
||
|
||
```text
|
||
要不要开始后台计算
|
||
```
|
||
|
||
不是管:
|
||
|
||
```text
|
||
要不要 update
|
||
```
|
||
|
||
也就是说:
|
||
|
||
```text
|
||
计算调度可以定时
|
||
UI 刷新最好事件驱动
|
||
```
|
||
|
||
## 刷新节流 timer:可以用 single-shot
|
||
|
||
如果你想限制 UI 最多 60 FPS,可以不用立刻 `invokeMethod -> update()`,而是主线程启动一个单次 timer:
|
||
|
||
```text
|
||
后台 ready
|
||
↓
|
||
投递主线程任务
|
||
↓
|
||
主线程发现 flush_timer 没启动
|
||
↓
|
||
启动 singleShot(16ms)
|
||
↓
|
||
timer 到了再 update(region)
|
||
```
|
||
|
||
这样是 **节流 timer**,不是一直轮询 timer。
|
||
|
||
---
|
||
|
||
# 6. 最推荐的刷新模型
|
||
|
||
我建议你最终用这个:
|
||
|
||
```text
|
||
后台完成图像
|
||
↓
|
||
publish ready
|
||
↓
|
||
scheduler->request_update(chart)
|
||
↓
|
||
合并 dirty region
|
||
↓
|
||
如果没有 pending flush
|
||
invokeMethod 投递主线程 flush
|
||
↓
|
||
主线程 flush_update()
|
||
↓
|
||
widget->update(region)
|
||
↓
|
||
Qt 稍后调用 paintEvent
|
||
↓
|
||
paintEvent 中 ready -> front
|
||
↓
|
||
drawImage(front)
|
||
```
|
||
|
||
这个模型里:
|
||
|
||
```text
|
||
没有新图,不投递刷新。
|
||
多个图完成,合并刷新。
|
||
主线程不轮询。
|
||
paintEvent 不计算。
|
||
```
|
||
|
||
---
|
||
|
||
# 7. 如果要限帧,用这个变体
|
||
|
||
```cpp
|
||
void Render_Scheduler::request_update(Chart_Node* chart) {
|
||
{
|
||
std::scoped_lock lock(dirty_lock);
|
||
dirty_region += chart->rect();
|
||
}
|
||
QMetaObject::invokeMethod(widget, [this] {
|
||
if (flush_timer.isActive()) {
|
||
return;
|
||
}
|
||
flush_timer.start(16);
|
||
}, Qt::QueuedConnection);
|
||
}
|
||
```
|
||
|
||
timer timeout:
|
||
|
||
```cpp
|
||
void Render_Scheduler::flush_update() {
|
||
QRegion region;
|
||
{
|
||
std::scoped_lock lock(dirty_lock);
|
||
region = dirty_region;
|
||
dirty_region = QRegion();
|
||
}
|
||
if (!region.isEmpty()) {
|
||
widget->update(region);
|
||
}
|
||
}
|
||
```
|
||
|
||
这个语义是:
|
||
|
||
```text
|
||
后台可以随时完成图
|
||
但 UI 最多约 60Hz 请求 update
|
||
```
|
||
|
||
如果你想 30 FPS:
|
||
|
||
```text
|
||
flush_timer.start(33)
|
||
```
|
||
|
||
如果你想尽快刷新:
|
||
|
||
```text
|
||
invokeMethod 之后直接 update(region)
|
||
```
|
||
|
||
---
|
||
|
||
# 8. 最终结论
|
||
|
||
你的主线程不需要启动一个固定 timer 去不停判断:
|
||
|
||
```text
|
||
rendered_version 是否变化
|
||
```
|
||
|
||
更合理的是:
|
||
|
||
```text
|
||
后台生成完新 ready 后,投递一个刷新任务到 Qt 主事件循环。
|
||
```
|
||
|
||
然后根据需求加节流:
|
||
|
||
```text
|
||
低延迟:QueuedConnection -> update(region)
|
||
限帧:QueuedConnection -> singleShot timer -> update(region)
|
||
```
|
||
|
||
所以最终分工是:
|
||
|
||
```text
|
||
后台线程:
|
||
生成图
|
||
发布 ready
|
||
请求调度器刷新
|
||
|
||
调度器:
|
||
合并 dirty region
|
||
合并 queued update 请求
|
||
可选限帧
|
||
|
||
主线程:
|
||
执行 update(region)
|
||
在 paintEvent 中消费 ready 并贴 front
|
||
```
|
||
|
||
一句话:
|
||
|
||
```text
|
||
计算可以用定时器调度,刷新不要用定时器轮询;刷新应该由后台完成事件驱动,再由主线程事件循环合并执行。
|
||
```
|