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

12 KiB
Raw Blame History

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 MandelbrotWidgetpublic QWidget,主角)

  • 持有视口参数:double centerX, centerY, scale(scale = 单位像素对应的复平面跨度);int maxIter = 256
  • 持有 quint64 renderEpoch = 0(渲染版本号,防过期结果覆盖)。
  • 持有累积 QImage pixmap_widget 尺寸,QImage::Format_RGB32)。
  • 重写:paintEventdrawImage 累积图)、wheelEvent(以鼠标为锚点缩放)、mousePressEvent/mouseMoveEvent/mouseReleaseEvent(拖拽平移)、resizeEvent(尺寸变化触发重渲染)。
  • 底部 QHBoxLayout 工具条:「主线程渲染」按钮、「单/多线程」切换按钮、耗时 QLabel
  • 槽:onStripReady(quint64 epoch, int stripIndex, QImage strip)——校验 epoch、拼图、计数、全部到齐则 update() 并显示耗时。

3.2 MandelbrotStripTaskpublic QObject, public QRunnable,带 Q_OBJECT

  • 数据成员:quint64 epochint stripIndexQRect 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 + MandelbrotWidgetresize(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.txtadd_subdirectory(docs/teaching/examples)不递归子目录的 CMakeLists;统一构建走 examples/CMakeLists.txtadd_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 第 44–49 行已有全局断言; 独立构建时靠 -DCMAKE_PREFIX_PATH 指对 Qt 5.14.2 路径),与 lineedit_eye_toggle 风格一致。 独立共享时接收方需装 Qt 5.14.2README 指向 tools/install_qt_host.shLinux)或官方 Qt 5.14.2 MinGW 安装(Windows)。

8. 显式踩坑点(README 专节,代码里复现/规避)

  1. paintEvent 之外对窗口 widget 构造 QPainterUB)——严格只在 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

# 独立工程(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)

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. 成功标准(可验证)

  • 独立工程 cmake -S mandelbrot_renderer -B build 能配置 + 构建,产物 offscreen 运行(Windows 实测 exit=0)。
  • 主工程 cmake --build build --target qt_mandelbrot_renderer 全绿,并入 all_teaching_examplesWindows 实测 build_exit=0)。
  • offscreen 运行 200ms 自动退出,退出码 0;启动即触发一次后台渲染(计算路径被覆盖)。
  • 有屏人工交互(滚轮缩放、拖拽平移、单/多线程切换耗时对比、「主线程渲染」冻结演示):当前为自动化上下文,未做有屏人工验证;交互逻辑经代码走读 + offscreen 覆盖,待真机点验。
  • README 完整覆盖概念/走读/双构建/踩坑/练习方向。
  • 无编译警告(-Wall -WextraDebug + Release 均零警告)。