6773f9b29e
三段式对比直击 Day4 slides「主线程耗时=冻结」痛点:主线程渲染冻结 / 后台单线程不卡但慢 / 后台多线程不卡且快。 - QThreadPool + QRunnable 分水平条带并行,排队信号槽回传,epoch 版本号防过期覆盖 - wheelEvent 缩放(鼠标为锚)+ mouseEvent 拖拽平移 + paintEvent 画 QImage - 子目录自带独立 CMakeLists.txt,可单独构建/共享(双模式约定同 lineedit_eye_toggle) - 配套 README(概念/走读/双构建/5 踩坑点/练习)+ 设计规格 - 已验证:独立工程 offscreen 运行 exit=0;主工程构建 exit=0(w64devkit g++16 + mingw73 Qt5.14.2) 注:主工程 examples/CMakeLists.txt 的接入(mandelbrot + lineedit_eye_toggle + qdialog + themes 原子接入)与顶层 CMakeLists 本地调试改动未一并提交,待整体确认后单独提交。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
110 lines
7.1 KiB
Markdown
110 lines
7.1 KiB
Markdown
# Day4 Mandelbrot 分形渲染器 —— 事件 / 绘图 / 多线程 综合演示
|
||
|
||
> 对应 Day4 课程主线:**事件机制(事件 vs 信号、事件拦截)+ QPainter 绘图 + 多线程防 UI 阻塞**。
|
||
> 仓库 `p03/` 的 wiki 抓取内容里没有把这三者串起来的综合示例,本示例是手工补写的教学
|
||
> 补充,**不属于** `tools/gen_part3.py` 的生成产物。
|
||
|
||
## 一句话
|
||
|
||
滚轮缩放 / 拖拽平移探索 Mandelbrot 分形;分形渲染是 CPU 密集任务,用 `QThreadPool` +
|
||
`QRunnable` 分水平条带并行计算,主线程 `paintEvent` 只负责画图——**界面永不卡**。
|
||
再配一个「主线程渲染」按钮,**故意**在 GUI 线程同步算一帧,直观复现 Day4 slides 反复强调的
|
||
「主线程做耗时操作 = 事件循环停摆 = 整个界面冻结」。
|
||
|
||
## 三段式对比(本示例的核心教学价值)
|
||
|
||
| 操作 | 谁在算 | 事件循环 | 速度 | 你看到 |
|
||
|---|---|---|---|---|
|
||
| 「主线程渲染」按钮 | GUI 线程同步算 | **被占用→冻结** | 慢 | 按钮无响应、窗口拖不动、`paintEvent` 排队 |
|
||
| 「单线程」模式 | 线程池(1 线程)后台 | 空闲 | 慢 | 界面流畅、可继续缩放 |
|
||
| 「多线程」模式 | 线程池(N 核)后台 | 空闲 | **快 ~N 倍** | 界面流畅、渲染耗时骤降 |
|
||
|
||
三段对比让两个结论同时成立:**耗时任务必须离开主线程**(否则冻结);**多线程进一步压榨多核**(加速比)。
|
||
|
||
## 概念映射
|
||
|
||
| Day4 知识点 | 本示例实现 |
|
||
|---|---|
|
||
| QPainter 绘图(框架驱动,只能在 `paintEvent` 里画) | `MandelbrotWidget::paintEvent` 把累积 `QImage` 一次性 `drawImage` |
|
||
| 事件处理(重写 `xxxEvent`) | `wheelEvent`(缩放)、`mousePress/Move/ReleaseEvent`(平移)、`resizeEvent` |
|
||
| 多线程防 UI 阻塞 | `QThreadPool::globalInstance()` + `QRunnable` 分条带并行;排队信号槽回传结果 |
|
||
|
||
## 代码走读
|
||
|
||
1. **`mandelbrot_strip_task.h/.cpp`** —— 纯计算单元(可独立理解)
|
||
- `MandelViewport`:复平面视口参数;`MandelPalette`:迭代次数→RGB 调色板。
|
||
- `renderStrip(vp, pal, y0, y1)`:**纯函数**,渲染视口内 `[y0,y1)` 行像素。多线程 task 与
|
||
「主线程渲染」按钮**共用**它——保证两路径算同一份分形,差异只在是否阻塞/是否并行。
|
||
- `MandelbrotStripTask`:`public QObject, public QRunnable` 多继承,`run()` 算完一条带
|
||
`emit stripReady(epoch, i, QImage)`。**任务持 palette/viewport 的值副本**,自包含、不引用
|
||
主线程对象(避免悬垂)。
|
||
2. **`mandelbrot_widget.h/.cpp`** —— 交互与渲染调度
|
||
- `scheduleRender()`:`++epoch_` → 切 `2×核数` 条带 → 每带 `new MandelbrotStripTask` 丢池。
|
||
- `onStripReady()`:校验 `epoch == epoch_`(过期丢弃)→ 把条带画到累积 `pixmap_` → `update()`。
|
||
- `onRenderOnMainThread()`:在 GUI 线程同步循环算整帧,**不丢池** → 事件循环停摆 → 冻结。
|
||
- `onToggleThreadMode()`:`setMaxThreadCount(1 或 idealThreadCount())`,复用同一套分块逻辑。
|
||
3. **`main.cpp`** —— offscreen 平台下 200ms 自动退出(仓库自动化验证约定)。
|
||
|
||
## 关键机制:为什么界面不卡 / 为什么结果不会错乱
|
||
|
||
- **不卡**:耗时计算在 `QRunnable::run()`(子线程)里,主线程事件循环始终空闲,能继续响应
|
||
鼠标键盘。`stripReady` 信号经**排队连接**自动调度回主线程执行 `onStripReady`——不需要手动加锁。
|
||
- **不错乱**:连续缩放会产生多轮渲染。每轮 `++epoch_`;旧任务的 `stripReady` 回来时
|
||
`epoch != epoch_` 直接丢弃。这**不是主动取消**(旧任务仍在跑、算完即丢),但保证了画面正确——
|
||
**「多线程结果回收要防竞态」**本身就是 Day4 该讲的点。
|
||
|
||
## 构建与运行
|
||
|
||
### 方式 A:作为主仓库的一部分(仓库标准)
|
||
|
||
```bash
|
||
# Linux(隔离 Qt 5.14.2)
|
||
cmake -S . -B build -G Ninja -DBUILD_QT_PART=ON \
|
||
-DCMAKE_PREFIX_PATH=$PWD/.qt514/5.14.2/gcc_64 -DCMAKE_BUILD_TYPE=Debug
|
||
cmake --build build --target qt_mandelbrot_renderer
|
||
tools/run_qt.sh qt_mandelbrot_renderer # 有屏交互
|
||
QT_QPA_PLATFORM=offscreen tools/run_qt.sh qt_mandelbrot_renderer # 自动退出码 0
|
||
```
|
||
|
||
### 方式 B:作为独立工程(可单独打包共享)
|
||
|
||
```bash
|
||
cmake -S docs/teaching/examples/mandelbrot_renderer -B build_mandel -G Ninja \
|
||
-DCMAKE_PREFIX_PATH=/path/to/qt/5.14.2/<kit> -DCMAKE_BUILD_TYPE=Debug
|
||
cmake --build build_mandel
|
||
./build_mandel/qt_mandelbrot_renderer
|
||
```
|
||
> 红线:无论哪种方式,Qt 必须是 5.14.x(本工程 CMakeLists 内置版本断言)。
|
||
> Linux 装隔离 Qt 见 `tools/install_qt_host.sh`;Windows 用官方 Qt 5.14.2 MinGW 安装包。
|
||
|
||
## 踩坑点(代码里复现或规避)
|
||
|
||
1. **在 `paintEvent` 之外对【窗口 widget】构造 `QPainter` = 未定义行为**。本例严格区分:
|
||
对窗口 `this` 画只在 `paintEvent`;对离屏 `QImage pixmap_` 画(`onStripReady` / `onRenderOnMainThread`)
|
||
是合法的——`QImage` 是 `QPaintDevice`,不受此限制。初学者常把两者混淆。
|
||
2. **`QRunnable` 没有 `Q_OBJECT`、没有信号**。要让任务发信号,须**多继承 `QObject`**(本例做法),
|
||
或在 `run()` 里用 `QMetaObject::invokeMethod(..., Qt::QueuedConnection)` 回调。继承 `QThread`
|
||
重写 `run()` 是另一条路但更易踩坑(见 `qthread_worker` 示例的对照说明)。
|
||
3. **主线程渲染卡死 vs 后台流畅的本质**:不是「多线程更快」那么简单,而是**事件循环是否被
|
||
占用**。按钮槽函数同步耗时 = 槽返回前事件循环不跑 = 所有事件(含重绘)排队 = 冻结。
|
||
4. **过期渲染结果覆盖新画面**:连续缩放时旧任务可能在新一轮之后才返回,必须用版本号
|
||
(本例 `epoch_`)过滤,否则画面会闪回旧帧。
|
||
5. **重写 `wheelEvent`/`mouseMoveEvent` 时是否调用基类**:本例需要继续向父传播的调用了
|
||
`QWidget::xxxEvent(e)`,自处理完毕不需要传播的(如 `wheelEvent` 缩放)则不调——这是
|
||
`accept()/ignore()` 的实践体现。
|
||
|
||
## 练习方向(供任务卡引用)
|
||
|
||
- 把 `setMaxThreadCount` 从 1 逐步调到核数,画一张「线程数—渲染耗时」曲线,验证加速比的天花板。
|
||
- 给「主线程渲染」按钮加一个 `QApplication::processEvents()` 看似「不卡」的伪解法,观察它为什么
|
||
不可靠(仍是单线程、仍可能丢事件)——理解为什么必须用真线程。
|
||
- 把渲染中途的旧任务**真正取消**(`QFuture`/`cancel` 标志位),对比当前「算完丢弃」的版本,
|
||
讨论 CPU 浪费与实现复杂度的权衡。
|
||
- 给分形加平滑着色(escape time + log 归一化),消除色带。
|
||
|
||
## 参考
|
||
|
||
- Qt 官方同名示例 *Mandelbrot Example*(Qt Widgets / Painting)——本例改用「QThreadPool 分块 +
|
||
worker-object 思路」以贴合 Day4 多线程教学,官方版用 `RenderThread` 继承 `QThread`,可对照阅读。
|
||
- 配套概念示例:`qthread_worker/`(worker-object + moveToThread 最小版)。
|