Mandelbrot
Mandelbrot 示例演示了如何使用 Qt 进行多线程编程。它展示了如何使用工作线程执行繁重的计算,而不会阻塞主线程的事件循环。
这里的繁重计算是曼德布罗特集,它可能是世界上最著名的分形。如今,虽然XaoS 等复杂的程序可以实时缩放 Mandelbrot 集,但标准的 Mandelbrot 算法对于我们的目的来说已经足够慢了。
在现实生活中,这里描述的方法适用于大量问题,包括同步网络 I/O 和数据库访问,在这些问题中,用户界面必须在某些繁重操作进行时保持响应。阻塞财富客户端示例展示了 TCP 客户端的相同工作原理。
Mandelbrot 应用程序支持使用鼠标或键盘缩放和滚动。为了避免冻结主线程的事件循环(以及应用程序的用户界面),我们将所有的分形计算都放在了一个单独的工作线程中。该线程在完成分形渲染后会发出一个信号。
在工作线程重新计算分形以反映新的缩放因子位置期间,主线程只是简单地缩放之前渲染的像素图,以提供即时反馈。这样做的结果虽然没有工作线程最终提供的效果那么好,但至少能让应用程序的响应速度更快。下面的一系列截图显示了原始图像、缩放图像和重新渲染的图像。
![]() | ![]() | ![]() |
同样,当用户滚动时,前一个像素图会立即滚动,显示像素图边缘以外的未绘制区域,而图像则由工作线程渲染。
![]() | ![]() | ![]() |
应用程序由两个类组成:
RenderThread
是 子类,用于渲染 Mandelbrot 集。QThreadMandelbrotWidget
是一个 子类,用于在屏幕上显示 Mandelbrot 集,并允许用户缩放和滚动。QWidget
如果您还不熟悉 Qt 的线程支持,我们建议您先阅读Qt 的线程支持概述。
渲染线程类定义
我们将从RenderThread
类的定义开始:
class RenderThread : public QThread { Q_OBJECT public: RenderThread(QObject *parent = nullptr); ~RenderThread(); void render(double centerX, double centerY, double scaleFactor, QSize resultSize, double devicePixelRatio); static void setNumPasses(int n) { numPasses = n; } static QString infoKey() { return QStringLiteral("info"); } signals: void renderedImage(const QImage &image, double scaleFactor); protected: void run() override; private: static uint rgbFromWaveLength(double wave); QMutex mutex; QWaitCondition condition; double centerX; double centerY; double scaleFactor; double devicePixelRatio; QSize resultSize; static int numPasses; bool restart = false; bool abort = false; static constexpr int ColormapSize = 512; uint colormap[ColormapSize]; };
该类继承了QThread ,从而获得了在独立线程中运行的能力。除了构造函数和析构函数外,render()
是唯一的公共函数。每当线程完成图像渲染,就会发出renderedImage()
信号。
受保护的run()
函数是从QThread 重新实现的。当线程启动时,它会被自动调用。
在private
部分,我们有一个QMutex 、一个QWaitCondition 和其他一些数据成员。互斥保护其他数据成员。
渲染线程类的实现
RenderThread::RenderThread(QObject *parent) : QThread(parent) { for (int i = 0; i < ColormapSize; ++i) colormap[i] = rgbFromWaveLength(380.0 + (i * 400.0 / ColormapSize)); }
在构造函数中,我们将restart
和abort
变量初始化为false
。这些变量控制着run()
函数的流程。
我们还初始化了colormap
数组,其中包含一系列 RGB 颜色。
RenderThread::~RenderThread() { mutex.lock(); abort = true; condition.wakeOne(); mutex.unlock(); wait(); }
当线程处于活动状态时,析构函数可以在任何时候调用。我们将abort
设置为true
,以告知run()
尽快停止运行。如果线程处于睡眠状态,我们还会调用QWaitCondition::wakeOne() 唤醒它。(我们将在回顾run()
时看到,线程在无事可做时会进入休眠状态)。
这里需要注意的是,run()
是在自己的线程(工作线程)中执行的,而RenderThread
的构造函数和析构函数(以及render()
函数)是由创建工作线程的线程调用的。因此,我们需要一个互斥来保护对abort
和condition
变量的访问,因为run()
随时都可能访问这些变量。
在析构函数的末尾,我们调用QThread::wait() 来等待run()
退出,然后再调用基类的析构函数。
void RenderThread::render(double centerX, double centerY, double scaleFactor, QSize resultSize, double devicePixelRatio) { QMutexLocker locker(&mutex); this->centerX = centerX; this->centerY = centerY; this->scaleFactor = scaleFactor; this->devicePixelRatio = devicePixelRatio; this->resultSize = resultSize; if (!isRunning()) { start(LowPriority); } else { restart = true; condition.wakeOne(); } }
每当需要生成 Mandelbrot 集的新图像时,MandelbrotWidget
都会调用render()
函数。centerX
、centerY
和scaleFactor
参数指定要渲染的分形部分;resultSize
指定生成的QImage 的大小。
函数将参数存储在成员变量中。如果线程尚未运行,函数会启动它;否则,函数会将restart
设置为true
(告诉run()
停止任何未完成的计算,并使用新参数重新开始计算),并唤醒可能处于休眠状态的线程。
void RenderThread::run() { QElapsedTimer timer; forever { mutex.lock(); const double devicePixelRatio = this->devicePixelRatio; const QSize resultSize = this->resultSize * devicePixelRatio; const double requestedScaleFactor = this->scaleFactor; const double scaleFactor = requestedScaleFactor / devicePixelRatio; const double centerX = this->centerX; const double centerY = this->centerY; mutex.unlock();
run()
这是一个相当大的函数,因此我们将它分成几个部分。
函数体是一个无限循环,首先将渲染参数存储在局部变量中。像往常一样,我们使用类的 mutex 来保护对成员变量的访问。将成员变量存储在局部变量中,可以最大限度地减少需要使用互斥保护的代码量。这确保了主线程在需要访问RenderThread
的成员变量时,不会阻塞太久(例如,在render()
中)。
forever
关键字是一个 Qt XML 伪关键字。
const int halfWidth = resultSize.width() / 2; const int halfHeight = resultSize.height() / 2; QImage image(resultSize, QImage::Format_RGB32); image.setDevicePixelRatio(devicePixelRatio); int pass = 0; while (pass < numPasses) { const int MaxIterations = (1 << (2 * pass + 6)) + 32; constexpr int Limit = 4; bool allBlack = true; timer.start(); for (int y = -halfHeight; y < halfHeight; ++y) { if (restart) break; if (abort) return; auto scanLine = reinterpret_cast<uint *>(image.scanLine(y + halfHeight)); const double ay = centerY + (y * scaleFactor); for (int x = -halfWidth; x < halfWidth; ++x) { const double ax = centerX + (x * scaleFactor); double a1 = ax; double b1 = ay; int numIterations = 0; do { ++numIterations; const double a2 = (a1 * a1) - (b1 * b1) + ax; const double b2 = (2 * a1 * b1) + ay; if ((a2 * a2) + (b2 * b2) > Limit) break; ++numIterations; a1 = (a2 * a2) - (b2 * b2) + ax; b1 = (2 * a2 * b2) + ay; if ((a1 * a1) + (b1 * b1) > Limit) break; } while (numIterations < MaxIterations); if (numIterations < MaxIterations) { *scanLine++ = colormap[numIterations % ColormapSize]; allBlack = false; } else { *scanLine++ = qRgb(0, 0, 0); } } } if (allBlack && pass == 0) { pass = 4; } else { if (!restart) { QString message; QTextStream str(&message); str << " Pass " << (pass + 1) << '/' << numPasses << ", max iterations: " << MaxIterations << ", time: "; const auto elapsed = timer.elapsed(); if (elapsed > 2000) str << (elapsed / 1000) << 's'; else str << elapsed << "ms"; image.setText(infoKey(), message); emit renderedImage(image, requestedScaleFactor); } ++pass; } }
接下来是算法的核心。我们并不试图创建完美的曼德尔布罗特集图像,而是进行多次处理,生成越来越精确(计算成本也越来越高)的分形近似值。
我们将设备像素比应用于目标尺寸,从而创建高分辨率像素图(见Drawing High Resolution Versions of Pixmaps and Images )。
如果我们在循环中发现restart
已被render()
设置为true
,我们将立即跳出循环,使控制权迅速返回到外循环(forever
循环)的最顶端,并获取新的渲染参数。同样,如果我们发现abort
已被设置为true
(通过RenderThread
析构函数),我们会立即从函数返回,终止线程。
核心算法超出了本教程的范围。
mutex.lock(); if (!restart) condition.wait(&mutex); restart = false; mutex.unlock(); } }
完成所有迭代后,我们调用QWaitCondition::wait() 使线程休眠,除非restart
是true
。在无事可做的情况下,让工作线程无限循环是没有用的。
uint RenderThread::rgbFromWaveLength(double wave) { double r = 0; double g = 0; double b = 0; if (wave >= 380.0 && wave <= 440.0) { r = -1.0 * (wave - 440.0) / (440.0 - 380.0); b = 1.0; } else if (wave >= 440.0 && wave <= 490.0) { g = (wave - 440.0) / (490.0 - 440.0); b = 1.0; } else if (wave >= 490.0 && wave <= 510.0) { g = 1.0; b = -1.0 * (wave - 510.0) / (510.0 - 490.0); } else if (wave >= 510.0 && wave <= 580.0) { r = (wave - 510.0) / (580.0 - 510.0); g = 1.0; } else if (wave >= 580.0 && wave <= 645.0) { r = 1.0; g = -1.0 * (wave - 645.0) / (645.0 - 580.0); } else if (wave >= 645.0 && wave <= 780.0) { r = 1.0; } double s = 1.0; if (wave > 700.0) s = 0.3 + 0.7 * (780.0 - wave) / (780.0 - 700.0); else if (wave < 420.0) s = 0.3 + 0.7 * (wave - 380.0) / (420.0 - 380.0); r = std::pow(r * s, 0.8); g = std::pow(g * s, 0.8); b = std::pow(b * s, 0.8); return qRgb(int(r * 255), int(g * 255), int(b * 255)); }
rgbFromWaveLength()
函数是一个辅助函数,用于将波长转换为与 32 位QImages 兼容的 RGB 值。在构造函数中调用该函数,可将colormap
数组初始化为令人愉悦的颜色。
MandelbrotWidget 类定义
MandelbrotWidget
类使用RenderThread
在屏幕上绘制 Mandelbrot 集。下面是该类的定义:
class MandelbrotWidget : public QWidget { Q_DECLARE_TR_FUNCTIONS(MandelbrotWidget) public: MandelbrotWidget(QWidget *parent = nullptr); protected: QSize sizeHint() const override { return {1024, 768}; }; void paintEvent(QPaintEvent *event) override; void resizeEvent(QResizeEvent *event) override; void keyPressEvent(QKeyEvent *event) override; #if QT_CONFIG(wheelevent) void wheelEvent(QWheelEvent *event) override; #endif void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; #ifndef QT_NO_GESTURES bool event(QEvent *event) override; #endif private: void updatePixmap(const QImage &image, double scaleFactor); void zoom(double zoomFactor); void scroll(int deltaX, int deltaY); #ifndef QT_NO_GESTURES bool gestureEvent(QGestureEvent *event); #endif RenderThread thread; QPixmap pixmap; QPoint pixmapOffset; QPoint lastDragPos; QString help; QString info; double centerX; double centerY; double pixmapScale; double curScale; };
该部件重新实现了QWidget 中的许多事件处理程序。此外,它还有一个updatePixmap()
插槽,我们将其连接到工作线程的renderedImage()
信号,以便在收到来自线程的新数据时更新显示内容。
在私有变量中,我们有thread
类型的RenderThread
和pixmap
,其中包含最后渲染的图片。
MandelbrotWidget 类的实现
constexpr double DefaultCenterX = -0.637011; constexpr double DefaultCenterY = -0.0395159; constexpr double DefaultScale = 0.00403897; constexpr double ZoomInFactor = 0.8; constexpr double ZoomOutFactor = 1 / ZoomInFactor; constexpr int ScrollStep = 20;
该类的实现从几个常量开始,这些常量我们稍后会用到。
MandelbrotWidget::MandelbrotWidget(QWidget *parent) : QWidget(parent), centerX(DefaultCenterX), centerY(DefaultCenterY), pixmapScale(DefaultScale), curScale(DefaultScale) { help = tr("Zoom with mouse wheel, +/- keys or pinch. Scroll with arrow keys or by dragging."); connect(&thread, &RenderThread::renderedImage, this, &MandelbrotWidget::updatePixmap); setWindowTitle(tr("Mandelbrot")); #if QT_CONFIG(cursor) setCursor(Qt::CrossCursor); #endif }
构造函数中最有趣的部分是QObject::connect() 调用。
虽然它看起来像两个QObject之间的标准信号槽连接,但由于信号是在与接收者所在的线程不同的线程中发出的,因此该连接实际上是一个queued connection 。这些连接是异步的(即非阻塞),这意味着槽将在emit
语句之后的某个时刻被调用。此外,槽将在接收者所在的线程中调用。在这里,信号在工作线程中发出,当控制返回到事件循环时,槽在 GUI 线程中执行。
对于队列连接,Qt 必须存储传递给信号的参数副本,以便稍后将它们传递给槽。Qt 知道如何获取许多 C++ 和 Qt 类型的副本,因此,QImage ,无需进一步操作。如果使用自定义类型,则需要调用模板函数qRegisterMetaType() 才能将该类型用作队列连接中的参数。
void MandelbrotWidget::paintEvent(QPaintEvent * /* event */) { QPainter painter(this); painter.fillRect(rect(), Qt::black); if (pixmap.isNull()) { painter.setPen(Qt::white); painter.drawText(rect(), Qt::AlignCenter|Qt::TextWordWrap, tr("Rendering initial image, please wait...")); return; }
在paintEvent() 中,我们首先将背景填充为黑色。如果还没有要绘制的内容(pixmap
为空),我们会在 widget 上显示一条信息,请用户耐心等待,并立即从函数中返回。
if (qFuzzyCompare(curScale, pixmapScale)) { painter.drawPixmap(pixmapOffset, pixmap); } else { const auto previewPixmap = qFuzzyCompare(pixmap.devicePixelRatio(), qreal(1)) ? pixmap : pixmap.scaled(pixmap.deviceIndependentSize().toSize(), Qt::KeepAspectRatio, Qt::SmoothTransformation); const double scaleFactor = pixmapScale / curScale; const int newWidth = int(previewPixmap.width() * scaleFactor); const int newHeight = int(previewPixmap.height() * scaleFactor); const int newX = pixmapOffset.x() + (previewPixmap.width() - newWidth) / 2; const int newY = pixmapOffset.y() + (previewPixmap.height() - newHeight) / 2; painter.save(); painter.translate(newX, newY); painter.scale(scaleFactor, scaleFactor); const QRectF exposed = painter.transform().inverted().mapRect(rect()) .adjusted(-1, -1, 1, 1); painter.drawPixmap(exposed, previewPixmap, exposed); painter.restore(); }
如果像素图的比例因子正确,我们就直接在 widget 上绘制像素图。
否则,我们将创建一个预览像素图,在计算完成前显示,并相应地平移坐标系。
由于我们将在绘制器上使用变换,并且在这种情况下使用不支持高分辨率像素图的QPainter::drawPixmap() 的重载,因此我们将创建一个设备像素比为 1 的像素图。
通过使用按比例绘制矩阵反向映射 widget 的矩形,我们还能确保只绘制像素图的暴露区域。调用QPainter::save() 和QPainter::restore() 可以确保之后进行的任何绘制都使用标准坐标系。
const QFontMetrics metrics = painter.fontMetrics(); if (!info.isEmpty()){ const int infoWidth = metrics.horizontalAdvance(info); const int infoHeight = (infoWidth/width() + 1) * (metrics.height() + 5); painter.setPen(Qt::NoPen); painter.setBrush(QColor(0, 0, 0, 127)); painter.drawRect((width() - infoWidth) / 2 - 5, 0, infoWidth + 10, infoHeight); painter.setPen(Qt::white); painter.drawText(rect(), Qt::AlignHCenter|Qt::AlignTop|Qt::TextWordWrap, info); } const int helpWidth = metrics.horizontalAdvance(help); const int helpHeight = (helpWidth/width() + 1) * (metrics.height() + 5); painter.setPen(Qt::NoPen); painter.setBrush(QColor(0, 0, 0, 127)); painter.drawRect((width() - helpWidth) / 2 - 5, height()-helpHeight, helpWidth + 10, helpHeight); painter.setPen(Qt::white); painter.drawText(rect(), Qt::AlignHCenter|Qt::AlignBottom|Qt::TextWordWrap, help); }
在绘制事件处理程序结束时,我们会在分形顶部绘制一个文本字符串和一个半透明矩形。
void MandelbrotWidget::resizeEvent(QResizeEvent * /* event */) { thread.render(centerX, centerY, curScale, size(), devicePixelRatio()); }
每当用户调整 widget 的大小时,我们就会调用render()
开始生成新的图像,其参数与centerX
、centerY
和curScale
相同,但使用了新的 widget 大小。
请注意,我们依赖 Qt Widgets 在首次显示时自动调用resizeEvent()
来生成初始图像。
void MandelbrotWidget::keyPressEvent(QKeyEvent *event) { switch (event->key()) { case Qt::Key_Plus: zoom(ZoomInFactor); break; case Qt::Key_Minus: zoom(ZoomOutFactor); break; case Qt::Key_Left: scroll(-ScrollStep, 0); break; case Qt::Key_Right: scroll(+ScrollStep, 0); break; case Qt::Key_Down: scroll(0, -ScrollStep); break; case Qt::Key_Up: scroll(0, +ScrollStep); break; case Qt::Key_Q: close(); break; default: QWidget::keyPressEvent(event); } }
按键事件处理程序为没有鼠标的用户提供了一些键盘绑定。zoom()
和scroll()
函数将在后面介绍。
void MandelbrotWidget::wheelEvent(QWheelEvent *event) { const int numDegrees = event->angleDelta().y() / 8; const double numSteps = numDegrees / double(15); zoom(pow(ZoomInFactor, numSteps)); }
我们重新实现了滚轮事件处理程序,使鼠标滚轮可以控制缩放级别。QWheelEvent::angleDelta() 返回鼠标滚轮移动的角度,单位为八分之一度。对于大多数鼠标来说,一个滚轮步相当于 15 度。我们可以找出鼠标移动的步数,从而确定缩放系数。例如,如果我们在正方向上有两个滚轮步进(即 +30 度),缩放因子就会变成ZoomInFactor
的二次方,即 0.8 * 0.8 = 0.64。
void MandelbrotWidget::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) lastDragPos = event->position().toPoint(); }
如 "小工具和图形视图中的手势 "中所述,缩放功能是通过QGesture 实现的。
#ifndef QT_NO_GESTURES bool MandelbrotWidget::gestureEvent(QGestureEvent *event) { if (auto *pinch = static_cast<QPinchGesture *>(event->gesture(Qt::PinchGesture))) { if (pinch->changeFlags().testFlag(QPinchGesture::ScaleFactorChanged)) zoom(1.0 / pinch->scaleFactor()); return true; } return false; } bool MandelbrotWidget::event(QEvent *event) { if (event->type() == QEvent::Gesture) return gestureEvent(static_cast<QGestureEvent*>(event)); return QWidget::event(event); } #endif
当用户按下鼠标左键时,我们会将鼠标指针的位置存储在lastDragPos
中。
void MandelbrotWidget::mouseMoveEvent(QMouseEvent *event) { if (event->buttons() & Qt::LeftButton) { pixmapOffset += event->position().toPoint() - lastDragPos; lastDragPos = event->position().toPoint(); update(); } }
当用户在按下鼠标左键的同时移动鼠标指针时,我们会调整pixmapOffset
,在移动的位置绘制像素图,并调用QWidget::update() 强制重新绘制。
void MandelbrotWidget::mouseReleaseEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { pixmapOffset += event->position().toPoint() - lastDragPos; lastDragPos = QPoint(); const auto pixmapSize = pixmap.deviceIndependentSize().toSize(); const int deltaX = (width() - pixmapSize.width()) / 2 - pixmapOffset.x(); const int deltaY = (height() - pixmapSize.height()) / 2 - pixmapOffset.y(); scroll(deltaX, deltaY); } }
松开鼠标左键后,我们会更新pixmapOffset
,就像鼠标移动时一样,并将lastDragPos
重置为默认值。然后,我们调用scroll()
渲染新位置的新图像。(调整pixmapOffset
还不够,因为拖动像素图时显示的区域是黑色的)。
void MandelbrotWidget::updatePixmap(const QImage &image, double scaleFactor) { if (!lastDragPos.isNull()) return; info = image.text(RenderThread::infoKey()); pixmap = QPixmap::fromImage(image); pixmapOffset = QPoint(); lastDragPos = QPoint(); pixmapScale = scaleFactor; update(); }
当工作线程完成图像渲染后,会调用updatePixmap()
槽。我们首先要检查拖动是否生效,在这种情况下什么也不做。在正常情况下,我们将图像存储在pixmap
中,并重新初始化其他一些成员。最后,我们调用QWidget::update() 刷新显示屏。
说到这里,你可能会问,为什么我们使用QImage 作为参数,而使用QPixmap 作为数据成员?为什么不使用一种类型呢?原因是QImage 是唯一支持直接像素操作的类,而这正是我们在工作线程中需要的。另一方面,在屏幕上绘制图像之前,必须将其转换为像素图。最好在这里一次性完成转换,而不是在paintEvent()
.
void MandelbrotWidget::zoom(double zoomFactor) { curScale *= zoomFactor; update(); thread.render(centerX, centerY, curScale, size(), devicePixelRatio()); }
在zoom()
中,我们重新计算curScale
。然后,我们调用QWidget::update() 绘制缩放像素图,并要求工作线程渲染与新curScale
值相对应的新图像。
void MandelbrotWidget::scroll(int deltaX, int deltaY) { centerX += deltaX * curScale; centerY += deltaY * curScale; update(); thread.render(centerX, centerY, curScale, size(), devicePixelRatio()); }
scroll()
与 类似,只是受影响的参数是 和 。zoom()
centerX
centerY
main() 函数
应用程序的多线程特性对其main()
函数没有影响,该函数与往常一样简单:
intmain(intargc, char *argv[]) { QApplicationapp(argc,argv); QCommandLineParserparser; parser.setApplicationDescription(u"Qt Mandelbrot Example"_s); parser.addHelpOption(); parser.addVersionOption(); QCommandLineOptionpassesOption(u"通行证"_s,u"通行证数量(1-8)"_s,u "通行证"_s); parser.addOption(passesOption); parser.process(app);if(parser.isSet(passesOption)) {const autopassesStr=parser.value(passesOption);boolok;const intpasses=passesStr.toInt(&ok);if(!ok||passes< 1 ||passes> 8) { qWarning() << "Invalid value:" << passesStr; return-1; } RenderThread::setNumPasses(passes); } MandelbrotWidget widget; widget.grabGesture(Qt::PinchGesture); widget.show();returnapp.exec(); }
© 2025 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.