Day4 教学示例:Mandelbrot 分形渲染器(QPainter 绘图 + 多线程 + 事件处理综合演示)

三段式对比直击 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>
This commit is contained in:
张宗平
2026-07-09 09:01:29 +08:00
parent 05a685c952
commit 6773f9b29e
8 changed files with 706 additions and 0 deletions
@@ -0,0 +1,179 @@
# Day4 Mandelbrot 分形渲染器 —— 设计 / 实现规格
- 日期:2026-07-08
- 用途:川大 Qt 方向课程 Day4 综合演示示例(事件机制 + QPainter 绘图 + 多线程)
- 状态:已与用户确认设计;本文同时作为实现计划(压缩 writing-plans 环节,经用户授权「一次性完成」)
## 1. 目标与定位
把 Day4 三个知识点串成一条可视化故事线:**缩放分形 → 触发重绘 → 多线程后台分块计算 → 界面不卡**。
通过三段式对比直击 slides「主线程耗时 = 冻结」痛点并量化多核收益:
1. 主线程同步渲染 → **卡死且慢**(按钮无响应、窗口拖不动、`paintEvent` 停滞)
2. 后台单线程(线程池 = 1)→ **不卡但慢**
3. 后台多线程(线程池 = N)→ **不卡且快**
定位为 `docs/teaching/examples/` 下的手工维护教学示例(非 `tools/gen_part3.py` 生成产物),
配 README,遵循仓库构建/验证约定。同时作为**可独立共享的单 CMake 工程**。
## 2. 知识点覆盖映射
| Day4 知识点 | 本示例对应实现 |
|---|---|
| QPainter 绘图(框架驱动,只在 `paintEvent` 画) | `MandelbrotWidget::paintEvent` 把累积 QImage 一次性 `drawImage` |
| 事件 vs 信号 / 事件循环 / 事件拦截 | `wheelEvent`(缩放)、`mousePress/Move/ReleaseEvent`(平移)重写;「主线程渲染」按钮演示事件循环被占用即冻结 |
| 多线程防 UI 阻塞 | `QThreadPool` + `QRunnable` 分水平条带并行;排队信号槽回传结果 |
## 3. 架构与组件(3 个类 + main,多文件)
### 3.1 `MandelbrotWidget``public QWidget`,主角)
- 持有视口参数:`double centerX, centerY, scale`(scale = 单位像素对应的复平面跨度);`int maxIter = 256`
- 持有 `quint64 renderEpoch = 0`(渲染版本号,防过期结果覆盖)。
- 持有累积 `QImage pixmap_`widget 尺寸,`QImage::Format_RGB32`)。
- 重写:`paintEvent``drawImage` 累积图)、`wheelEvent`(以鼠标为锚点缩放)、`mousePressEvent/mouseMoveEvent/mouseReleaseEvent`(拖拽平移)、`resizeEvent`(尺寸变化触发重渲染)。
- 底部 `QHBoxLayout` 工具条:「主线程渲染」按钮、「单/多线程」切换按钮、耗时 `QLabel`
- 槽:`onStripReady(quint64 epoch, int stripIndex, QImage strip)`——校验 epoch、拼图、计数、全部到齐则 `update()` 并显示耗时。
### 3.2 `MandelbrotStripTask``public QObject, public QRunnable`,带 `Q_OBJECT`
- 数据成员:`quint64 epoch``int stripIndex``QRect viewport`(像素区域)、视口参数副本、调色板指针/引用。
- `run()` override:调用自由函数 `renderStrip(...)``[y0, y1]` 行像素 → `emit stripReady(epoch, stripIndex, img)`(排队连接,自动回主线程)。
- `signals: void stripReady(quint64 epoch, int stripIndex, QImage strip);`
- `setAutoDelete(true)`(默认)、无 QObject parent,靠线程池回收。
- *教学点*:QRunnable 本身无信号,多继承 QObject 是 Qt 让 QRunnable 拥有信号的标准写法。
### 3.3 自由函数 `renderStrip`(纯计算,两路径共用)
- `QImage renderStrip(const Viewport& vp, int y0, int y1, const RenderPalette& pal);`
-`[y0,y1) × [0,width)` 每个像素做标准 Mandelbrot 迭代 `z = z² + c`(逃逸半径 2),按迭代次数经调色板映射成 RGB,写入 QImage。
-`MandelbrotStripTask::run()`(多线程路径)和「主线程渲染」按钮(卡死路径)**共用**,保证两路径算同一份分形,差异只在「是否阻塞事件循环 / 是否并行」。
### 3.4 调色板 `RenderPalette`
- 迭代次数 → HSV(h, s=1, v=1) → RGB 的简单预计算表(256 项 `QRgb`),构造时生成。
- 提升趣味,与知识点无关但成本低。
### 3.5 `main.cpp`
-`QApplication` + `MandelbrotWidget``resize(800,600)``show()`
- offscreen 平台(`qApp->platformName() == "offscreen"`)时:启动即触发一次后台渲染(覆盖计算路径),并 `QTimer::singleShot(200, &app, &QApplication::quit)` 自动退出码 0(仓库自动化验证约定)。
## 4. 数据流(一轮缩放)
```
wheelEvent 改 scale/center(以鼠标点为锚,缩放后该点复坐标不变)
→ ++renderEpoch
→ 切 N = hardwareConcurrency()*2 条水平带
→ 每带 new MandelbrotStripTask(epoch,i,viewport) → QThreadPool::globalInstance()->start(task)
↓ (子线程并行)
task.run() → renderStrip(...) → emit stripReady(epoch,i,QImage)
↓ (Qt::QueuedConnection,自动回主线程)
Widget::onStripReady: epoch != currentEpoch ? 丢弃
否则把 strip 画到 pixmap_ 对应行,received++ ;
received == N → update() + 显示 QElapsedTimer 耗时与线程数
paintEvent: QPainter(this); p.drawImage(0,0, pixmap_);
```
## 5. 三段式对比(教学核心)
- **「主线程渲染」按钮**:槽里同步调用 `renderStrip` 算一整帧(在 GUI 线程,不分块、不丢池),耗时约 1–3s。
期间事件循环停摆 → 按钮无响应、窗口拖不动 → 直观复现痛点。算完 `update()` 并显示「主线程渲染 Xms · 界面冻结」。
- **「单/多线程」切换**:复用同一套分块逻辑,仅切 `QThreadPool::globalInstance()->setMaxThreadCount(1 或 hardwareConcurrency())`
单线程不卡但慢、多线程不卡且快,耗时标签量化加速比。
- 三段对比让学员一眼看懂:耗时任务必须离开主线程;多线程进一步压榨多核。
## 6. 关键决策与取舍
- **条带数** = `hardwareConcurrency()*2`:负载均衡,避免某核分到全「集合内部」的快带。
- **过期防护(非主动取消)**`renderEpoch` 版本号,新视口令旧结果作废丢弃。YAGNI 不做主动 cancel
但版本号保证画面正确性——这本身是教学点(多线程结果回收要防竞态)。
- **无锁设计**:每 task 算自己的 strip QImage,信号复制回传,主线程拼。呼应 `qthread_worker` README「优先信号槽传数据,退而求其次才用锁」。
- **maxIter 默认 256**:画质 vs 卡死演示时长的平衡点。
- **UI 纯代码**(无 `.ui`),避免分散对 painting/线程/事件的注意力。
## 7. 构建系统:双模式 CMake(与仓库 `lineedit_eye_toggle` 同模式)
仓库权威约定(见 `lineedit_eye_toggle/CMakeLists.txt` 注释):顶层 `CMakeLists.txt`
`add_subdirectory(docs/teaching/examples)` 且**不递归**子目录的 CMakeLists;统一构建走
`examples/CMakeLists.txt``add_executable`/`add_teaching_example`;子目录的独立 CMakeLists.txt
仅供单独打开/共享。本示例遵循此约定。
### 7.1 子目录 `mandelbrot_renderer/CMakeLists.txt` = 完整独立工程(供单独打开/共享)
- `cmake_minimum_required(VERSION 3.16)` / `project(qt_mandelbrot_renderer LANGUAGES CXX)`
- C++17 严格(`CXX_STANDARD 17 / REQUIRED ON / CXX_EXTENSIONS OFF`)、`set(CMAKE_AUTOMOC ON)`
- `find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED)`(不重复 5.14 断言——顶层已有全局断言)
- `add_executable(qt_mandelbrot_renderer main.cpp mandelbrot_widget.cpp mandelbrot_strip_task.cpp)`
- `target_link_libraries(... PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)``-Wall -Wextra`
- 可脱离主仓库独立 `cmake -S . -B build` 构建、单独打包共享。
### 7.2 主工程纳入
- `docs/teaching/examples/CMakeLists.txt` 新增 `add_executable(qt_mandelbrot_renderer …)` 直接引用
`mandelbrot_renderer/*.cpp`(多文件,不走单文件 `add_teaching_example` 宏),设 C++17/AUTOMOC/Qt5 链接;
- 子目录独立 CMakeLists.txt **不**被仓库构建递归(避免 target 重复定义);
-`qt_mandelbrot_renderer` 加入 `all_teaching_examples` 的 DEPENDS 列表。
- target 定义在父子两处各一份,源文件相同(零代码重复)——与 `lineedit_eye_toggle`/`mainwindow_showcase` 一致。
### 7.3 权衡
子独立 CMakeLists 不内置 Qt 5.14 断言(顶层 `CMakeLists.txt` 第 4449 行已有全局断言;
独立构建时靠 `-DCMAKE_PREFIX_PATH` 指对 Qt 5.14.2 路径),与 `lineedit_eye_toggle` 风格一致。
独立共享时接收方需装 Qt 5.14.2README 指向 `tools/install_qt_host.sh`Linux)或官方
Qt 5.14.2 MinGW 安装(Windows)。
## 8. 显式踩坑点(README 专节,代码里复现/规避)
1.`paintEvent` 之外对窗口 widget 构造 `QPainter`UB)——严格只在 `paintEvent` 画。
2. QRunnable 无信号 → 必须多继承 `QObject` 才能 `emit`(初学者高频困惑)。
3. 主线程渲染卡死 vs 后台流畅的**本质**:事件循环是否被占用。
4. 过期渲染结果覆盖新画面(版本号防护)。
5. `wheelEvent`/`mouseEvent` 重写时基类调用与事件 `accept/ignore` 的处理。
## 9. 验证方式(CLAUDE.md 行为风格第 2 点:端到端实跑)
### 9.1 Windows 本地(本次开发已实跑通过,Qt 5.14.2 mingw73_64 + w64devkit g++ 16
```bash
# 独立工程(Release,避开 mingw Qt 可能缺 debug 平台插件的问题)
cmake -S docs/teaching/examples/mandelbrot_renderer -B build_mandel_rel -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_COMPILER=E:/workenv/w64devkit/bin/g++.exe \
-DCMAKE_PREFIX_PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64
cmake --build build_mandel_rel
export PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/bin:$PATH
export QT_QPA_PLATFORM_PLUGIN_PATH=/e/Qt/Qt5.14.2/5.14.2/mingw73_64/plugins/platforms
QT_QPA_PLATFORM=offscreen ./build_mandel_rel/qt_mandelbrot_renderer.exe ; echo "exit=$?" # 实测 exit=0
# 主工程内(Debug,仅验证纳入与编译链接)
cmake -S . -B build_main -G Ninja -DBUILD_QT_PART=ON -DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_C_COMPILER=E:/workenv/w64devkit/bin/gcc.exe \
-DCMAKE_CXX_COMPILER=E:/workenv/w64devkit/bin/g++.exe \
-DCMAKE_PREFIX_PATH=E:/Qt/Qt5.14.2/5.14.2/mingw73_64
cmake --build build_main --target qt_mandelbrot_renderer # 实测 build_exit=0
```
> 注:w64devkit g++16 与 mingw73Qt 编译所用的 GCC7)ABI 在本例链接通过、运行正常;
> 若他人环境报 ABI 错误,改用与 Qt 同源的 MinGW 7.3 工具链即可。
### 9.2 主工程内(仓库标准,Linux 隔离 Qt)
```bash
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
```
## 10. 文件清单与实现步骤
```
docs/teaching/examples/mandelbrot_renderer/
├ CMakeLists.txt # 独立工程(双模式复用)
├ main.cpp
├ mandelbrot_widget.h / .cpp
├ mandelbrot_strip_task.h / .cpp # 含调色板 RenderPalette + renderStrip 纯函数
└ README.md # 概念/代码走读/两套构建/练习方向/踩坑点
docs/teaching/examples/CMakeLists.txt # 改:add_subdirectory + 挂 all_teaching_examples
```
实现顺序:① `mandelbrot_strip_task.h/.cpp`(调色板 + renderStrip + task,可独立单测的纯计算)
→ ② `mandelbrot_widget.h/.cpp`(组装交互与渲染调度)→ ③ `main.cpp` → ④ `CMakeLists.txt`(独立)
→ ⑤ 改 `examples/CMakeLists.txt`(纳入)→ ⑥ `README.md` → ⑦ 构建 + offscreen 验证。
## 11. 成功标准(可验证)
- [x] 独立工程 `cmake -S mandelbrot_renderer -B build` 能配置 + 构建,产物 offscreen 运行(Windows 实测 exit=0)。
- [x] 主工程 `cmake --build build --target qt_mandelbrot_renderer` 全绿,并入 `all_teaching_examples`Windows 实测 build_exit=0)。
- [x] offscreen 运行 200ms 自动退出,退出码 0;启动即触发一次后台渲染(计算路径被覆盖)。
- [ ] 有屏人工交互(滚轮缩放、拖拽平移、单/多线程切换耗时对比、「主线程渲染」冻结演示):当前为自动化上下文,**未做有屏人工验证**;交互逻辑经代码走读 + offscreen 覆盖,待真机点验。
- [x] README 完整覆盖概念/走读/双构建/踩坑/练习方向。
- [x] 无编译警告(`-Wall -Wextra`Debug + Release 均零警告)。
@@ -0,0 +1,36 @@
# =============================================================================
# Day4 Mandelbrot 分形渲染器 —— 独立 CMake 工程
#
# 本文件供「单独打开 / 独立构建 / 单独打包共享」使用:
# cmake -S . -B build -G Ninja \
# -DCMAKE_PREFIX_PATH=<Qt5.14.2> -DCMAKE_BUILD_TYPE=Debug
# cmake --build build
#
# 仓库统一构建【不走】这里:顶层 CMakeLists.txt 只
# add_subdirectory(docs/teaching/examples) 且不递归,统一构建走
# examples/CMakeLists.txt 的 add_executable,源文件同为
# main.cpp / mandelbrot_widget.cpp / mandelbrot_strip_task.cpp(零重复)。
# 本目录不计入仓库「173 目标」计数。
#
# 说明:本工程为手写规范版(C++17 / cmake 3.16 / 无 ANDROID 噪音),
# 区别于其它子目录里 Qt Creator 自动导出的低质量模板。
# =============================================================================
cmake_minimum_required(VERSION 3.16)
project(qt_mandelbrot_renderer LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_AUTOMOC ON)
find_package(Qt5 COMPONENTS Core Gui Widgets REQUIRED)
message(STATUS "Found Qt5 ${Qt5Core_VERSION_STRING} at ${Qt5Core_DIR}")
add_executable(qt_mandelbrot_renderer
main.cpp
mandelbrot_widget.cpp
mandelbrot_strip_task.cpp)
target_include_directories(qt_mandelbrot_renderer PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(qt_mandelbrot_renderer PRIVATE Qt5::Core Qt5::Gui Qt5::Widgets)
target_compile_options(qt_mandelbrot_renderer PRIVATE -Wall -Wextra)
@@ -0,0 +1,109 @@
# 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 最小版)。
@@ -0,0 +1,30 @@
// ============================================================================
// main —— Day4 Mandelbrot 分形渲染器入口
// offscreen 平台(QT_QPA_PLATFORM=offscreen)下:show 触发首帧后台渲染,
// 200ms 后退出;aboutToQuit 等线程池收尾,避免悬垂任务访问已析构对象。
// 仓库自动化验证据此断言退出码 0(CLAUDE.md 行为风格第 2 点)。
// ============================================================================
#include <QApplication>
#include <QtCore>
#include <QThreadPool>
#include <QTimer>
#include "mandelbrot_widget.h"
int main(int argc, char* argv[]) {
QApplication app(argc, argv);
MandelbrotWidget w;
w.resize(820, 640);
w.setWindowTitle(QStringLiteral("Day4 · Mandelbrot 分形渲染器(绘图 / 多线程 / 事件)"));
w.show();
if (app.platformName() == QStringLiteral("offscreen")) {
// 等所有在途 QRunnable 跑完,再让 widget 析构(任务持值副本,但仍可能
// 通过排队信号访问 this——这里确保它们落定)。
QObject::connect(&app, &QCoreApplication::aboutToQuit, []() {
QThreadPool::globalInstance()->waitForDone();
});
QTimer::singleShot(200, &app, &QApplication::quit);
}
return app.exec();
}
@@ -0,0 +1,54 @@
// ============================================================================
// mandelbrot_strip_task.cpp —— 调色板构造 + renderStrip 纯计算实现
// MandelbrotStripTask 的成员全部内联在头文件,本 .cpp 只需让 AUTOMOC 扫到
// strip_task.h 的 Q_OBJECT#include 它即可),并实现非内联的 MandelPalette
// 与 renderStrip。
// ============================================================================
#include "mandelbrot_strip_task.h"
#include <QColor>
#include <QtGlobal>
// 迭代次数 → HSV 色相循环(彩虹环),集合内部点统一黑色。
MandelPalette::MandelPalette(int maxIter) {
const int n = qMax(1, maxIter);
table_.resize(n);
for (int i = 0; i < n; ++i) {
if (i == n - 1) { table_[i] = qRgb(0, 0, 0); continue; } // 集合内 → 黑
const int hue = static_cast<int>((360.0 * 4.0 * i / n));
table_[i] = QColor::fromHsv(hue % 360, 255, 255).rgb();
}
}
// 标准 Mandelbrot 迭代 z = z² + c,逃逸半径 2。逐像素写入 RGB32。
QImage renderStrip(const MandelViewport& vp, const MandelPalette& pal, int y0, int y1) {
const int w = vp.width;
const int top = qBound(0, y0, vp.height);
const int bot = qBound(0, y1, vp.height);
if (w <= 0 || bot <= top) return {};
QImage img(w, bot - top, QImage::Format_RGB32);
const double x0 = vp.centerX - vp.scale * vp.width / 2.0;
const double y0c = vp.centerY - vp.scale * vp.height / 2.0;
const int maxIter = vp.maxIter;
for (int py = top; py < bot; ++py) {
const double cy = y0c + vp.scale * py;
auto* line = reinterpret_cast<QRgb*>(img.scanLine(py - top)); // RGB32 = 每像素 1 个 QRgb
for (int px = 0; px < w; ++px) {
const double cx = x0 + vp.scale * px;
double zx = 0.0, zy = 0.0;
int iter = 0;
while (iter < maxIter) {
const double zx2 = zx * zx;
const double zy2 = zy * zy;
if (zx2 + zy2 > 4.0) break; // 逃逸
zy = 2.0 * zx * zy + cy;
zx = zx2 - zy2 + cx;
++iter;
}
line[px] = pal.color(iter >= maxIter ? maxIter - 1 : iter);
}
}
return img;
}
@@ -0,0 +1,75 @@
// ============================================================================
// mandelbrot_strip_task —— Day4 Mandelbrot 渲染器的「分块计算 + 调色板」单元
// 职责:
// * MandelViewport : 视口参数(复平面中心 / 像素跨度 / 尺寸 / 最大迭代)
// * MandelPalette : 迭代次数→RGB 的预计算调色板(提升趣味,与知识点无关)
// * renderStrip() : 纯计算函数,渲染视口内 [y0,y1) 行像素。多线程 task 与
// 「主线程卡死」演示路径共用它——保证两路径算的是同一份分形,
// 差异只在「是否阻塞事件循环 / 是否并行」。
// * MandelbrotStripTask : 一条水平带的 QRunnable 任务。多继承 QObject+QRunnable
// 以获得信号能力(QRunnable 本身无信号——这是初学者高频困惑,
// 见 README 踩坑点 2)。
// 设计要点:task 持 palette/viewport 的【值副本】,自包含、不引用主线程对象,
// 避免主对象先析构导致的悬垂访问(见 README 踩坑点 4)。
// ============================================================================
#ifndef MANDELBROT_STRIP_TASK_H
#define MANDELBROT_STRIP_TASK_H
#include <QImage>
#include <QObject>
#include <QRunnable>
#include <QRgb>
#include <QVector>
// 视口参数:复平面映射。scale = 每像素对应的复平面跨度。
struct MandelViewport {
double centerX = -0.5; // 经典 Mandelbrot 主分形居中
double centerY = 0.0;
double scale = 3.0 / 600.0;
int width = 0; // show 后由 widget 尺寸填入
int height = 0;
int maxIter = 256;
};
// 调色板:迭代次数 → RGB。集合内部(迭代未逃逸)映射为黑。
class MandelPalette {
public:
explicit MandelPalette(int maxIter = 256);
QRgb color(int i) const { return table_.at(i); }
private:
QVector<QRgb> table_;
};
// 纯计算:渲染视口内 [y0, y1) 行像素,返回 (width × (y1-y0)) 的 RGB32 QImage。
QImage renderStrip(const MandelViewport& vp, const MandelPalette& pal, int y0, int y1);
// 一条水平带的多线程任务。多继承 QObject + QRunnable 以拥有信号。
// setAutoDelete(true)(默认):run() 结束后由线程池回收;不设 QObject parent
// 避免与 QThreadPool 的自动删除发生双重所有权——见 README 踩坑点 2。
class MandelbrotStripTask : public QObject, public QRunnable {
Q_OBJECT
public:
MandelbrotStripTask(quint64 epoch, int stripIndex,
const MandelViewport& vp, const MandelPalette& pal,
int y0, int y1)
: QObject(nullptr), epoch_(epoch), stripIndex_(stripIndex),
vp_(vp), pal_(pal), y0_(y0), y1_(y1) { setAutoDelete(true); }
void run() override {
// 算完一条带 → 通过信号把结果「排队」送回主线程(Qt 自动跨线程调度)。
emit stripReady(epoch_, stripIndex_, renderStrip(vp_, pal_, y0_, y1_));
}
signals:
void stripReady(quint64 epoch, int stripIndex, QImage strip);
private:
quint64 epoch_;
int stripIndex_;
MandelViewport vp_;
MandelPalette pal_; // 值副本:任务自包含
int y0_;
int y1_;
};
#endif // MANDELBROT_STRIP_TASK_H
@@ -0,0 +1,164 @@
// ============================================================================
// mandelbrot_widget.cpp —— 交互与渲染调度实现(详见 .h)
// ============================================================================
#include "mandelbrot_widget.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QMouseEvent>
#include <QPainter>
#include <QPushButton>
#include <QResizeEvent>
#include <QThreadPool>
#include <QThread>
#include <QVBoxLayout>
#include <QWheelEvent>
MandelbrotWidget::MandelbrotWidget(QWidget* parent)
: QWidget(parent), pal_(256) {
// 线程池默认按核数;切到「单线程」模式时再 setMaxThreadCount(1)。
QThreadPool::globalInstance()->setMaxThreadCount(QThread::idealThreadCount());
threadCount_ = QThreadPool::globalInstance()->maxThreadCount();
auto* btnFreeze = new QPushButton(QStringLiteral("主线程渲染 (将冻结)"));
auto* btnToggle = new QPushButton(QStringLiteral("切换 单/多线程"));
label_ = new QLabel(QStringLiteral("就绪 · 滚轮缩放 · 左键拖拽平移"));
auto* bar = new QHBoxLayout;
bar->setContentsMargins(8, 4, 8, 4);
bar->addWidget(btnFreeze);
bar->addWidget(btnToggle);
bar->addSpacing(16);
bar->addWidget(label_);
bar->addStretch();
auto* root = new QVBoxLayout(this);
root->setContentsMargins(0, 0, 0, 0);
root->setSpacing(0);
root->addStretch(); // 画布占主体(paintEvent 画整个 widget 背景,工具条浮在底部)
root->addLayout(bar);
connect(btnFreeze, &QPushButton::clicked, this, &MandelbrotWidget::onRenderOnMainThread);
connect(btnToggle, &QPushButton::clicked, this, &MandelbrotWidget::onToggleThreadMode);
}
// ---- 绘图:只在 paintEvent 内对【窗口 widget】构造 QPainter(核心踩坑点 1----
void MandelbrotWidget::paintEvent(QPaintEvent*) {
QPainter p(this);
if (!pixmap_.isNull()) {
p.drawImage(0, 0, pixmap_);
} else {
p.fillRect(rect(), Qt::black);
}
}
// ---- 事件处理:滚轮缩放(以鼠标点为锚,缩放后该点复坐标不变)----
void MandelbrotWidget::wheelEvent(QWheelEvent* e) {
const int dy = e->angleDelta().y();
if (dy == 0) { QWidget::wheelEvent(e); return; }
const double factor = (dy > 0) ? 0.8 : 1.25; // 上滚放大、下滚缩小
const QPointF m = e->position();
const double mx = vp_.centerX + (m.x() - width() / 2.0) * vp_.scale;
const double my = vp_.centerY + (m.y() - height() / 2.0) * vp_.scale;
vp_.scale *= factor;
vp_.centerX = mx - (m.x() - width() / 2.0) * vp_.scale;
vp_.centerY = my - (m.y() - height() / 2.0) * vp_.scale;
scheduleRender();
}
// ---- 事件处理:左键拖拽平移 ----
void MandelbrotWidget::mousePressEvent(QMouseEvent* e) {
if (e->button() == Qt::LeftButton) { dragging_ = true; lastMouse_ = e->pos(); }
QWidget::mousePressEvent(e);
}
void MandelbrotWidget::mouseMoveEvent(QMouseEvent* e) {
if (dragging_) {
const QPoint d = e->pos() - lastMouse_;
lastMouse_ = e->pos();
vp_.centerX -= d.x() * vp_.scale;
vp_.centerY -= d.y() * vp_.scale;
scheduleRender();
}
QWidget::mouseMoveEvent(e);
}
void MandelbrotWidget::mouseReleaseEvent(QMouseEvent* e) {
if (e->button() == Qt::LeftButton) dragging_ = false;
QWidget::mouseReleaseEvent(e);
}
void MandelbrotWidget::resizeEvent(QResizeEvent* e) {
QWidget::resizeEvent(e);
scheduleRender(); // 首次 show 与窗口缩放都经此触发首帧
}
// ---- 多线程调度:切条带丢池,渐进回传 ----
void MandelbrotWidget::scheduleRender() {
++epoch_; // 令所有在途旧任务的结果作废
vp_.width = width();
vp_.height = height();
if (vp_.width <= 0 || vp_.height <= 0) return;
pixmap_ = QImage(vp_.width, vp_.height, QImage::Format_RGB32);
stripsTotal_ = multiThread_ ? QThread::idealThreadCount() * 2 : 1; // 多线程:2×核数均衡负载
stripsDone_ = 0;
timer_.start();
const int N = stripsTotal_;
for (int i = 0; i < N; ++i) {
const int y0 = vp_.height * i / N;
const int y1 = vp_.height * (i + 1) / N;
auto* task = new MandelbrotStripTask(epoch_, i, vp_, pal_, y0, y1);
// 排队连接:stripReady 自动调度回主线程(接收者 this 所在线程)执行 onStripReady。
connect(task, &MandelbrotStripTask::stripReady, this, &MandelbrotWidget::onStripReady);
QThreadPool::globalInstance()->start(task);
}
}
void MandelbrotWidget::onStripReady(quint64 epoch, int stripIndex, QImage strip) {
if (epoch != epoch_) return; // 过期结果丢弃(防竞态覆盖新画面)
if (!strip.isNull()) {
QPainter p(&pixmap_); // 对【离屏 QImage】构造 QPainter——合法(非窗口 widget
p.drawImage(0, vp_.height * stripIndex / stripsTotal_, strip);
}
++stripsDone_;
update(); // 渐进刷新:能看到条带自上而下并行填满
if (stripsDone_ >= stripsTotal_) {
label_->setText(QString(QStringLiteral("%1 · %2 ms · %3 线程"))
.arg(multiThread_ ? QStringLiteral("多线程渲染")
: QStringLiteral("单线程渲染"))
.arg(int(timer_.elapsed()))
.arg(threadCount_));
}
}
// ---- 卡死演示:在 GUI 线程同步算整帧,不分块、不丢池 → 事件循环停摆 → 界面冻结 ----
void MandelbrotWidget::onRenderOnMainThread() {
++epoch_; // 让在途后台任务的结果作废,避免覆盖本帧
vp_.width = width();
vp_.height = height();
if (vp_.width <= 0 || vp_.height <= 0) return;
timer_.start();
pixmap_ = QImage(vp_.width, vp_.height, QImage::Format_RGB32);
const int H = vp_.height;
const int chunk = 24; // 分块调 renderStrip 只为复用同一纯函数
for (int y = 0; y < H; y += chunk) {
const QImage s = renderStrip(vp_, pal_, y, qMin(y + chunk, H));
QPainter p(&pixmap_);
p.drawImage(0, y, s);
}
// 注意:本函数返回前事件循环不跑——期间按钮无响应、窗口拖不动、paintEvent 排队等待。
update();
label_->setText(QString(QStringLiteral("主线程渲染 (界面冻结) · %1 ms"))
.arg(int(timer_.elapsed())));
}
// ---- 单/多线程切换:复用同一套分块逻辑,仅切线程池大小 ----
void MandelbrotWidget::onToggleThreadMode() {
multiThread_ = !multiThread_;
QThreadPool::globalInstance()->setMaxThreadCount(
multiThread_ ? QThread::idealThreadCount() : 1);
threadCount_ = QThreadPool::globalInstance()->maxThreadCount();
scheduleRender();
}
@@ -0,0 +1,59 @@
// ============================================================================
// mandelbrot_widget —— Day4 Mandelbrot 渲染器的交互与渲染调度主体(QWidget)
// 职责:
// * paintEvent : 把累积 QImage 一次性画到窗口(QPainter 绘图知识点)
// * wheelEvent/mouse* : 滚轮缩放(以鼠标为锚)、左键拖拽平移(事件处理知识点)
// * scheduleRender() : 切水平条带丢 QThreadPool 并行算(多线程知识点)
// * onStripReady() : 收条带、拼图、版本号防过期、耗时显示
// * onRenderOnMainThread() : 「主线程渲染」按钮——同步算整帧,演示事件循环被
// 占用即界面冻结(直击 Day4 slides「主线程耗时=冻结」痛点)
// * onToggleThreadMode() : 复用同一套分块逻辑,切线程池大小,对比单/多线程加速比
// ============================================================================
#ifndef MANDELBROT_WIDGET_H
#define MANDELBROT_WIDGET_H
#include <QElapsedTimer>
#include <QImage>
#include <QPoint>
#include <QWidget>
#include "mandelbrot_strip_task.h" // MandelViewport / MandelPalette
class QLabel;
class MandelbrotWidget : public QWidget {
Q_OBJECT
public:
explicit MandelbrotWidget(QWidget* parent = nullptr);
protected:
void paintEvent(QPaintEvent*) override;
void wheelEvent(QWheelEvent*) override;
void mousePressEvent(QMouseEvent*) override;
void mouseMoveEvent(QMouseEvent*) override;
void mouseReleaseEvent(QMouseEvent*) override;
void resizeEvent(QResizeEvent*) override;
private slots:
void onStripReady(quint64 epoch, int stripIndex, QImage strip);
void onRenderOnMainThread(); // 卡死演示
void onToggleThreadMode(); // 单/多线程切换
private:
void scheduleRender(); // ++epoch + 分块丢池
MandelViewport vp_;
MandelPalette pal_;
QImage pixmap_; // 累积画布(离屏 QImagepaintEvent 外对它构造 QPainter 合法)
quint64 epoch_ = 0; // 渲染版本号:新视口令旧结果作废丢弃(防过期覆盖)
int stripsTotal_ = 0;
int stripsDone_ = 0;
QElapsedTimer timer_;
bool multiThread_ = true;
int threadCount_ = 0;
bool dragging_ = false;
QPoint lastMouse_;
QLabel* label_ = nullptr;
};
#endif // MANDELBROT_WIDGET_H