Files
scuc-qt-course/docs/superpowers/specs/2026-07-08-day4-mandelbrot-demo-design.md
T
张宗平 993aa30a1c Mandelbrot 教学示例增强:主线程渲染循环至 ≥1.5s,让界面冻结肉眼可感
实测单帧 820×640@256 单线程仅约 70ms,远低于人眼可感阈值,直接算一帧看不出
「冻结」。故「主线程渲染」按钮改为自适应循环渲染直到累计 ≥ 1500ms(实测 ~23 帧 /
1506ms),上限 100 轮防止极慢机器卡死过久。冻结的本质是事件循环被占用,循环只是
模拟任意一个占着 GUI 线程不返回的耗时操作。

- mandelbrot_widget.cpp:onRenderOnMainThread 改 do-while 循环,标签加「· N 轮」
- README.md:补冻结时长说明与课堂演示技巧
- 设计文档同步:三段式对比章节更新实测数据与冻结本质解释
- 新增 docs/teaching/MULTITHREADING.md:多线程教学要点汇总

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:50:46 +08:00

182 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. 三段式对比(教学核心)
- **「主线程渲染」按钮**:槽里在 GUI 线程同步调用 `renderStrip` 算整帧(不分块、不丢池)。
实测单帧 820×640@256 仅约 70ms,不足以肉眼可感,故**自适应循环渲染到累计 ≥ 1500ms**(实测 ~23 帧 / 1506ms)。
期间事件循环停摆 → 按钮无响应、窗口拖不动 → 直观复现痛点。算完 `update()` 并显示「主线程渲染 Xms · N 轮」。
冻结本质是事件循环被占用,循环只是模拟一个持续 ~1.5s 的耗时操作。
- **「单/多线程」切换**:复用同一套分块逻辑,仅切 `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 均零警告)。