曼德尔布罗特
曼德尔布罗特示例演示了使用 Qt 进行多线程编程。它展示了如何使用工作线程来执行繁重的计算,同时不阻塞主线程的事件循环。

此处的计算密集型任务是曼德尔布罗集,这可能是世界上最著名的分形。如今,虽然像XaoS 这样的复杂程序能够提供曼德尔布罗集的实时缩放功能,但标准曼德尔布罗算法的速度恰好满足我们的需求。
在实际应用中,本文描述的方法适用于大量问题,包括同步网络I/O和数据库访问——在这些场景中,当某些耗时操作正在进行时,用户界面必须保持响应。Blocking Fortune Client示例展示了该原理在TCP客户端中的应用。
曼德尔布罗特应用程序支持通过鼠标或键盘进行缩放和滚动。为了避免主线程的事件循环(以及随之而来的应用程序用户界面)冻结,我们将所有分形计算都放在了一个单独的工作线程中。当该线程完成分形渲染后,会发出一个信号。
在工作线程重新计算分形以反映新的缩放因子位置期间,主线程仅对先前渲染的位图进行缩放,以提供即时反馈。 虽然效果不如工作线程最终渲染的结果理想,但至少能提高应用程序的响应速度。下面的截图序列展示了原始图像、缩放后的图像以及重新渲染的图像。
![]() | ![]() | ![]() |
同样地,当用户滚动时,会立即滚动之前的像素图,显示像素图边缘之外未绘制的区域,同时由工作线程渲染图像。
![]() | ![]() | ![]() |
该应用程序由两个类组成:
如果您还不熟悉 Qt 的线程支持,建议您先阅读《Qt 中的多线程》概述。
RenderThread 类定义
我们先从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::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();
}
}每当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() 这是一个相当大的函数,因此我们将它拆分为几个部分。
函数体是一个无限循环,首先将渲染参数存储在局部变量中。与往常一样,我们使用类的互斥锁来保护对成员变量的访问。 将成员变量存储在局部变量中,使我们能够最大限度地减少需要由互斥锁保护的代码量。这确保了主线程在需要访问RenderThread 的成员变量时(例如在render() 中),绝不会被阻塞太久。
forever 关键字是 Qt 的伪关键字。
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 已被设置为true (由render() 设置),则立即退出循环,以便控制流快速返回外层循环(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位QImage兼容的RGB值。该函数在构造函数中被调用,用于使用赏心悦目的颜色初始化colormap 数组。
MandelbrotWidget 类定义
MandelbrotWidget 类使用RenderThread 在屏幕上绘制曼德尔布罗集。以下是该类的定义:
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() 信号连接起来,以便在从线程接收新数据时更新显示。
在私有变量中,我们有类型为RenderThread 的thread 以及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 为空),则在小部件上显示一条消息,请用户稍候,并立即从函数中返回。
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);
QRectF exposed = painter.transform().inverted().mapRect(rect());
exposed = exposed.adjusted(-1, -1, 1, 1);
painter.drawPixmap(exposed, previewPixmap, exposed);
painter.restore();
}如果位图具有正确的缩放因子,则直接将位图绘制到小部件上。
否则,我们会创建一个预览位图,在计算完成前显示该位图,并相应地平移坐标系。
由于我们将在绘图器上进行变换,并且使用的是QPainter::drawPixmap()的重载版本(该版本不支持高分辨率位图),因此我们创建了一个设备像素比为1的位图。
通过使用缩放后的绘制矩阵对控件的矩形进行反向映射,我们还可以确保只绘制位图的可见区域。对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());
}每当用户调整小部件的大小时,我们会调用render() 来开始生成新图像,其centerX 、centerY 和curScale 参数保持不变,但会采用新的小部件尺寸。
请注意,我们依赖于 Qt 在小部件首次显示时自动调用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 是唯一支持直接像素操作的类,而我们在工作线程中需要这种操作。另一方面,图像在屏幕上绘制之前,必须先转换为pixmap。最好在这里一次性完成转换,而不是在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[])
{
QApplication app(argc,argv);
QCommandLineParser parser;
parser.setApplicationDescription(u"Qt 曼德尔布罗特示例"_s);
parser.addHelpOption();
parser.addVersionOption();
QCommandLineOption passesOption(u"passes"_s,u"迭代次数 (1-8)"_s,u"passes"_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();
}© 2026 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.





