下面这个就是我建议你这个 **多图表后台渲染库**的完整模型。 核心目标是: ```text 元数据变化才计算 后台只算最新状态 主线程只贴图 多个图统一调度 不堆旧任务 不显示半成品 不因为 paintEvent 无意义重算 ``` --- # 1. 总体管线 每个图都是这个流程: ```text 主线程修改元数据 ↓ meta_version++ ↓ 调度器定时扫描 ↓ 判断这个图是否需要提交 CPU 任务 ↓ 生成元数据快照 Render_Input ↓ 扔进 CPU 线程池 ↓ CPU 线程生成 Pixel_Frame ↓ 发布到三缓冲 ready ↓ 主线程 update 合并 ↓ paintEvent 消费 ready,贴 front ``` 也就是: ```text Meta -> Render_Input -> CPU Render -> Pixel_Frame -> paintEvent drawImage ``` 每种具体图只需要实现: ```text 元数据 -> 像素图 ``` 线程控制、版本控制、三缓冲、刷新合并都放到通用框架里。 --- # 2. 每个图不要自己开线程 一堆图不能每个图一个线程、一个 timer。 应该是: ```text 一个 Render_Scheduler 一个 CPU Thread_Pool 多个 Chart_Node ``` 结构类似: ```text Render_Scheduler Chart_Node 1 Chart_Node 2 Chart_Node 3 ... Thread_Pool ``` 调度器负责: ```text 扫描哪些图需要重算 限制每轮提交数量 限制单图提交频率 跳过不可见图 合并 update 区域 控制 CPU 线程池压力 ``` 具体图只负责: ```text 准备元数据 执行 CPU 绘制算法 提供 dirty rect / 绘制区域 ``` --- # 3. 每个图的版本号 每个图建议维护这些版本: ```cpp struct Chart_Versions { std::uint64_t meta = 0; std::uint64_t submitted = 0; std::uint64_t running = 0; std::uint64_t rendered = 0; std::uint64_t requested_paint = 0; std::uint64_t painted = 0; }; ``` 语义: ```text meta 主线程最新元数据版本。 submitted 已经提交给 CPU 线程池的版本。 running 当前 CPU 正在计算的版本。 rendered 后台已经生成像素图的版本。 requested_paint 已经请求主线程刷新的版本。 painted paintEvent 已经贴出去的版本。 ``` 这里不要用 `OK`、`imageReady` 这种 bool。 bool 只能表示: ```text 有 / 没有 ``` 版本号能表示: ```text 是哪一版 新不新 有没有提交 有没有生成 有没有请求 update 有没有真正 paint ``` --- # 4. 元数据层:双缓冲或者快照 元数据是用户操作和数据输入修改的东西,比如: ```text 坐标范围 颜色表 缩放 图尺寸 数据源 采样参数 显示开关 曲线样式 ``` 主线程修改元数据: ```cpp void Chart_Node::mark_meta_changed() { ++versions.meta; } ``` 调度器提交任务时生成快照: ```cpp Render_Input Chart_Node::make_render_input() { Render_Input input; input.meta = edit_meta; input.version = versions.meta; return input; } ``` 如果元数据很小,直接拷贝快照。 如果元数据很大,用双缓冲或者共享不可变快照。 这一层一般不需要三缓冲。 原因是元数据中间版本可以丢,只要后台下一轮拿最新版本即可。 --- # 5. 任务层:single-flight 每个图同一时间最多一个后台任务。 不要这样: ```text version 100 提交 version 101 提交 version 102 提交 version 103 提交 ``` 这样会造成旧任务堵新任务。 应该这样: ```text 一个图最多一个任务在飞 任务运行期间 meta 变了,只记录版本变化 任务完成后调度器下一轮再提交最新版本 ``` 任务状态: ```cpp enum class Render_Job_State { Idle, Queued, Running }; ``` 提交条件: ```cpp bool Chart_Node::need_submit(std::uint64_t now_ms) const { if (!visible) { return false; } if (!valid_size) { return false; } if (job_state != Render_Job_State::Idle) { return false; } if (versions.meta == versions.submitted) { return false; } if (now_ms - last_submit_time < min_render_interval) { return false; } return true; } ``` 核心原则: ```text 版本没变,不算。 图不可见,不算。 尺寸无效,不算。 已经有任务在跑,不算。 刚算过,间隔太短,不算。 ``` --- # 6. 像素结果层:三缓冲 像素层建议三缓冲: ```cpp struct Pixel_Frame { std::uint64_t version = 0; QImage image; }; struct Pixel_Buffers { Pixel_Frame front; Pixel_Frame ready; Pixel_Frame working; }; ``` 三张图语义: ```text front 主线程 paintEvent 当前稳定显示的图。 ready 后台已经生成好,等待主线程消费的图。 working 后台线程正在写入的图。 ``` 三缓冲解决的是: ```text 主线程读 front 后台写 working ready 作为交接区 ``` 它不是为了替代版本号,而是为了控制图像内存所有权。 --- # 7. 后台任务完成后的发布规则 后台线程生成完 `working` 后,不直接碰 `front`。 只发布到 `ready`。 如果 `ready` 还没被主线程消费,新 `working` 又生成完了: ```text 旧 ready 丢掉 新 working 覆盖 ready front 继续稳定显示 ``` 图表库不需要按帧播放旧图,应该追最新状态。 发布逻辑: ```cpp void Chart_Node::publish_working_frame(Pixel_Frame frame) { bool need_update = false; { std::scoped_lock lock(pixel_lock); if (frame.version <= pixels.ready.version) { return; } pixels.working = std::move(frame); pixels.ready.swap(pixels.working); versions.rendered = pixels.ready.version; if (versions.rendered != versions.requested_paint) { versions.requested_paint = versions.rendered; need_update = true; } job_state = Render_Job_State::Idle; } if (need_update) { scheduler->request_update(this); } } ``` 状态变化示例: ```text 发布前: front = 100 ready = 101 working = 102 发布后: front = 100 ready = 102 working = 101 ``` 旧 `ready = 101` 被丢掉,变成新的空闲 working 槽,后面会被覆盖。 --- # 8. 已经开始生成的 working 要不要停 不要停。 规则是: ```text 已经开始生成的 working:继续生成完。 还没开始的新任务:由调度器根据版本、间隔、状态决定是否提交。 ``` 原因: ```text CPU 任务中途取消复杂。 生成完的新版本可以覆盖旧 ready。 single-flight 已经保证不会无限堆任务。 ``` 所以正确策略是: ```text working 已经开始:继续。 ready 没消费:不等。 新 working 完成:覆盖旧 ready。 下一轮是否继续算:由调度器判断。 ``` --- # 9. 主线程 paintEvent 消费规则 主线程 `paintEvent()` 前先尝试消费 `ready`: ```cpp void Chart_Node::prepare_paint() { std::scoped_lock lock(pixel_lock); if (pixels.ready.version <= pixels.front.version) { return; } pixels.front.swap(pixels.ready); versions.painted = pixels.front.version; } ``` 然后只贴 `front`: ```cpp void Chart_Node::paint(QPainter& painter) { prepare_paint(); painter.drawImage(rect, pixels.front.image); } ``` `paintEvent()` 不做: ```text 元数据计算 颜色映射 衰减计算 坐标采样 大数组遍历 后台等待 ``` 它只做: ```text ready -> front drawImage(front) ``` 如果没有新图,也照样画旧 `front`。 因为 Qt 可能因为窗口遮挡、缩放、系统刷新等原因主动触发 `paintEvent()`,不能假设每次 paint 都有新数据。 --- # 10. update 合并 后台发布新图以后,不要每个图都直接 `update()` 整个窗口。 应该由调度器合并刷新区域: ```text 图 A 有新图 图 B 有新图 图 C 有新图 ↓ 合并 dirty rect ↓ 主线程 update(total_dirty_rect) ``` 可以加一个 pending 标志,避免重复投递刷新请求: ```cpp void Render_Scheduler::request_update(Chart_Node* chart) { dirty_region += chart->rect(); if (paint_request_pending.exchange(true)) { return; } QMetaObject::invokeMethod(widget, [this] { paint_request_pending.store(false); QWidget* w = widget; QRegion region = std::move(dirty_region); dirty_region = QRegion(); w->update(region); }, Qt::QueuedConnection); } ``` 这样多个图连续完成渲染,也只触发一次合并后的主线程刷新。 --- # 11. 调度器 tick 流程 调度器定时扫描所有图: ```cpp void Render_Scheduler::tick() { std::uint64_t now_ms = current_time_ms(); int submitted_count = 0; for (Chart_Node* chart : charts) { if (submitted_count >= max_submit_per_tick) { break; } if (!chart->need_submit(now_ms)) { continue; } chart->submit_render(thread_pool, now_ms); ++submitted_count; } } ``` 提交任务: ```cpp void Chart_Node::submit_render(Thread_Pool& pool, std::uint64_t now_ms) { Render_Input input; { std::scoped_lock lock(meta_lock); input = make_render_input(); versions.submitted = input.version; versions.running = input.version; job_state = Render_Job_State::Queued; last_submit_time = now_ms; } pool.enqueue([this, input = std::move(input)]() mutable { run_render_task(std::move(input)); }); } ``` CPU 任务: ```cpp void Chart_Node::run_render_task(Render_Input input) { { std::scoped_lock lock(job_lock); job_state = Render_Job_State::Running; } Pixel_Frame frame; frame.version = input.version; frame.image = render_image(input); publish_working_frame(std::move(frame)); } ``` --- # 12. 过期任务结果怎么处理 假设: ```text 提交 version 100 CPU 正在算 用户已经改到 version 110 version 100 算完 ``` 推荐默认策略: ```text 允许发布 version 100 但任务完成后,如果 meta 还是新于 submitted,下一轮马上提交最新 version 110 ``` 这样用户不会长时间看不到任何结果。 但是不排队算: ```text 101、102、103、104... ``` 只算当前正在跑的 100,然后下一轮追最新的 110。 也就是: ```text 旧结果可以短暂显示 旧任务不排队 新任务追最新 ``` 如果某些图对实时一致性要求特别高,也可以配置成: ```text 任务完成时发现 version < meta_version,直接丢弃结果 ``` 但默认不建议这么激进,否则用户拖动时可能一直看不到更新。 --- # 13. 最终状态机 每个图的任务状态: ```text Idle ↓ submit Queued ↓ thread starts Running ↓ publish Idle ``` 版本流向: ```text meta_version ↓ submit submitted_version ↓ run running_version ↓ publish rendered_version ↓ request update requested_paint_version ↓ paintEvent painted_version ``` 像素缓冲流向: ```text CPU writes working ↓ publish working <-> ready ↓ paintEvent ready <-> front ↓ drawImage(front) ``` 如果 ready 没消费,新 working 又来了: ```text working <-> ready 旧 ready 丢掉 front 不动 ``` 如果 meta 没变: ```text 不提交任务 不生成图 不 request update ``` 如果没有新 ready: ```text paintEvent 只画旧 front ``` --- # 14. 最终设计原则 整个模型可以定成这几条: ```text 1. 每个图有自己的元数据版本。 2. 每个图同一时间最多一个 CPU 渲染任务。 3. 调度器统一扫描所有图,不让每个图自建线程和 timer。 4. 元数据层用快照或者双缓冲。 5. 像素结果层用三缓冲。 6. 三缓冲只管图像内存所有权,不替代版本号。 7. ready 只保存最新待显示图,不做帧队列。 8. 旧 ready 没被消费时,新 working 直接覆盖它。 9. 已经开始的 working 继续算完。 10. 是否继续提交新任务由版本号、single-flight、限速、可见性共同决定。 11. 主线程 paintEvent 只贴 front,不做重计算。 12. update 由调度器合并,不让每个图疯狂 update。 ``` 一句话总结: ```text 元数据版本号决定要不要算; single-flight 决定不要堆任务; CPU 线程池负责把 Meta 算成 Pixel; 三缓冲负责 Pixel 的安全交接; 调度器负责多图公平和 update 合并; paintEvent 只负责贴最新 front。 ``` 这个模型适合你的场景: **一堆图、多线程后台渲染、高频数据变化、主线程只做轻量显示**。