Files
scuc-qt-course/docs/superpowers/specs/2026-07-08-day4-mandelbrot-demo-design.md
T
张宗平 6773f9b29e 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>
2026-07-09 09:01:29 +08:00

180 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 均零警告)。