让 Qt 耗时计算可取消,并保持界面响应

让 Qt 耗时计算可取消,并保持界面响应

原文:The Qt Company 与文档贡献者,Mandelbrot。中文翻译与技术整理:未完纪。2026年10月5日读取页面标识 Qt 6.12.0,与研究记录的 Qt 6.12 一致。本篇覆盖完整后台渲染与界面交互流程;代码仅静态核对,未安装、构建或运行 Qt。

Mandelbrot 示例把分形计算放到工作线程,让主线程继续处理鼠标、键盘和绘制事件。这个思路也适用于同步网络I/O、数据库访问等耗时操作:计算在后台进行,界面及时响应输入,新的结果完成后再投递回来。不过,本文还会明确指出当前官方示例在共享标志和退出等待上的同步风险,不能只因它是官方示例就把所有片段当成已验证的通用线程模板。

Qt主线程提交渲染参数并预览旧图,工作线程计算和通过排队信号返回结果;新请求触发重算,关闭时设置退出标志、唤醒并等待线程结束
生命周期示意图为本文重绘,不是运行截图,也不代表分形性能实测。

先反馈操作,再等待精确结果

Mandelbrot集合是著名的分形。虽然已有XaoS等程序能实时缩放,传统算法仍足以在本教程中形成可观察的耗时计算。应用允许鼠标或键盘缩放和滚动;工作线程重算时,主线程先缩放上一张图,让操作立即得到反馈。预览不如重新计算的结果精确,但用户不必面对冻结的窗口。

移动视图也一样:先平移旧图,边缘暂时露出尚未绘制的区域,再由后台新图补齐。原文用“原图—缩放预览—重算结果”和“原位置—平移露白—重算结果”两组截图解释过程;本译稿用自绘流程图说明,不伪造原程序运行截图。

程序由两个类组成:RenderThread继承QThread,负责计算;MandelbrotWidget继承QWidget,处理输入并显示结果。不熟悉线程模型时,可先阅读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];
};

render()接收中心坐标、缩放比例、结果尺寸和设备像素比。完成一轮有效结果时发出renderedImage(image, scaleFactor)。run()是重写的工作入口,由QThread启动后在新线程执行。QMutex保护共享参数,QWaitCondition让线程空闲时休眠。restart表示当前结果已被新请求取代,abort表示应结束线程。numPasses是遍数配置,colormap用于把迭代次数转成颜色。

线程对象的归属与run()执行位置要分清:RenderThread的构造、析构和由界面调用的render()运行在创建者线程,通常就是GUI线程;只有run()在工作线程。直接调用thread.render()不会自动把这个函数搬到后台。

构造颜色表,析构时等待线程退出

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。构造函数根据380到约780纳米的波长范围准备512个颜色值,计算过程中只读取这张表。

RenderThread::~RenderThread()
{
    mutex.lock();
    abort = true;
    condition.wakeOne();
    mutex.unlock();

    wait();
}

原文的析构步骤是:持锁把abort设为true,唤醒可能正在等待的线程,解锁,再调用wait(),等run()返回之后才销毁QThread基类。wait()必须放在解锁后,否则工作线程若也需要这把锁就无法完成退出。不要让拥有线程资源的对象在工作仍访问其成员时直接销毁。

这个流程表达了协作式退出的意图,而不是强制终止。退出速度取决于计算循环多久检查一次标志;对于阻塞I/O,也要让I/O能超时或被取消,仅设置一个布尔值不能中断任意阻塞调用。当前示例的具体同步缺口会在后文单独说明。

接收请求:首次启动,后续重算

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()。QMutexLocker先锁住成员,把参数作为一组更新;若线程尚未运行,以LowPriority启动。若线程已经运行,则设置restart并wakeOne()。工作线程可能正在计算,也可能在等待,两种状态都能收到“有新任务”的意图。

这不是为每次拖动创建一个新线程,也不是把每个旧请求无限排队。共享成员保存最新视图参数,新请求使旧计算尽快放弃,之后按新参数重新开始。

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();

外层forever是Qt的无限循环写法。每轮开始持锁复制参数,随后解锁。计算使用局部快照,不长时间持有mutex,因此主线程能迅速更新下一次请求。结果图像的实际像素尺寸乘以devicePixelRatio,计算用的每像素比例除以它,而信号中传回requestedScaleFactor保留界面逻辑尺度。

复制参数能缩短锁定范围,但它只解决这些参数的一致读取;共享取消标志是否安全,还要看其每个读写位置,不能凭这一处加锁就认定整类没有数据竞争。

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;
            }
        }

图像使用QImage::Format_RGB32,并记录设备像素比。算法分多遍计算,MaxIterations随pass增加,先给出粗略结果,再逐步增加精度和计算量。如果第一遍全黑,例子将pass跳到4,让后续尝试更深的迭代。该算法是示例载荷,本文不把它的数值效果或性能推广成保证。

每条扫描线开始检查restart与abort:发现新请求就跳出当前行循环,发现退出就直接从run()返回。原代码的restart路径会继续走余下遍数的控制逻辑,再回到外层读取新参数;“取消”是主动检查后的中止,并不是任意指令位置的立即打断。每一遍完成且没有restart时,记录遍数、最大迭代和耗时,将信息写入image的文本字段,然后发出renderedImage。

QElapsedTimer提供示例的耗时显示:超过2000毫秒时按秒显示,否则按毫秒显示。这些代码不构成本轮性能测量。原文将分形算法本身的数学推导排除在教程范围之外,本文也保持这一边界。

没有任务时等待条件变量

mutex.lock();
        if (!restart)
            condition.wait(&mutex);
        restart = false;
        mutex.unlock();
    }
}

一轮完成后重新取得mutex。若没有重算请求,原文调用condition.wait(&mutex),等待时释放互斥锁,唤醒后重新取得它;之后清除restart并解锁。这样空闲线程不会不断空转。等待必须与受保护的状态判断结合,不能把wakeOne()当作可积累的任务队列。

当前示例的同步风险与修订建议

静态审核发现:本次同时核对了Qt 6.12分支的renderthread.h和renderthread.cpp。restart、abort确实声明为普通bool,render()/析构持锁写入,但计算循环在不持同一锁的情况下读取。按C++并发规则,这些共享非原子读写存在数据竞争风险;“写入时加锁”本身不能使未加锁读取安全。

还有一个退出窗口:如果析构在工作线程进入wait之前设置abort并发出唤醒,工作线程随后只检查!restart就等待,先前唤醒可能丢失,析构中的wait()就可能一直等下去。条件变量不会保存一个供未来消费的唤醒。原文的说明体现了正确生命周期目标,但这些具体片段需要修订后再用于自己的程序。

一个较小的修订方向是把两个跨线程标志改成原子变量,同时把退出条件纳入等待谓词。普通参数仍用原有mutex成组保护,析构仍在mutex保护下设置退出并唤醒。下列是编辑建议,与上方原文代码分开列出;它不是已编译的补丁,也没有经过竞态检测或关闭压力测试。

#include <atomic>

// 替换原来的两个 bool 成员;其余渲染参数继续受 mutex 保护。
std::atomic_bool restart{false};
std::atomic_bool abort{false};

// 在 run() 的计算检查点使用原子读取:
if (abort.load())
    return;
if (restart.load())
    break;

// 替换 run() 尾部的等待块:
mutex.lock();
while (!restart.load() && !abort.load())
    condition.wait(&mutex);
if (abort.load()) {
    mutex.unlock();
    return;
}
restart.store(false);
mutex.unlock();

这段修订改变了原文的同步方式及等待条件,不应与原文bool声明混用。它也不保证投递出去的结果永远是最新请求:检查restart到emit之间仍可能来了新任务,已经排队的旧结果也不会自动从GUI事件队列消失。需要严格拒绝过时结果时,应为请求和结果携带代次编号,并在GUI端核对当前代次;这属于进一步设计,本文没有伪造一个已验证实现。

波长到颜色的辅助函数

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位图像可用的qRgb值。它只在构造时用于初始化colormap,不承担线程调度或取消逻辑。

MandelbrotWidget保存显示状态

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;
};

窗口重写绘制、尺寸变化、键盘、滚轮、鼠标与手势事件。RenderThread是窗口的数据成员;pixmap保存最后一张图,pixmapOffset控制预览平移,lastDragPos记录拖动位置。centerX/centerY和curScale代表当前请求,而pixmapScale记录已得到图像的尺度;两者不同,正好表示当前需要显示缩放预览。

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
}

构造函数设置初始中心与比例、帮助文字、标题和可用时的十字光标。connect()看起来是普通信号槽连接,但发射发生在工作线程,接收者MandelbrotWidget属于GUI线程,因此默认自动连接在这里按排队连接处理。

排队连接异步投递参数,emit不会等待槽执行。GUI事件循环恢复处理事件后,在接收者线程调用updatePixmap()。Qt需要为队列保存参数副本;QImage是已知可复制类型,不需要额外注册。若换成自定义类型,应按Qt元类型要求注册,例如调用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()先把背景填黑。如果还没有图像,就显示“正在绘制初始图像,请稍候”的消息并返回。它不在绘制事件里等待工作线程,因此窗口仍能处理其他事件。

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();
    }

curScale与pixmapScale相同,直接按pixmapOffset绘制。否则,把旧图按pixmapScale/curScale缩放,并让它围绕原来的中心调整位置。这里用到的QPainter::drawPixmap重载与坐标变换组合不直接按原高DPI方式工作,因此预览先转为设备像素比1的图像尺寸。

程序通过绘制变换的逆矩阵把窗口可见矩形映射回图像坐标,只绘制暴露区域,再向外扩一点,避免缩放边界缝隙。save()/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);

}

最后在分形上叠加半透明黑色矩形和白色信息文字。上方显示当前计算信息,下方显示缩放和平移帮助。文字尺寸来自QFontMetrics。

尺寸、键盘、滚轮和捏合手势

void MandelbrotWidget::resizeEvent(QResizeEvent * /* event */)
{
    thread.render(centerX, centerY, curScale, size(), devicePixelRatio());
}

窗口变大或变小时,以相同中心和尺度重新请求新尺寸图像。初次显示窗口也会触发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);
    }
}

加号和减号缩放,方向键以固定步长平移,Q键关闭窗口;其他键交给QWidget默认处理。通过键盘也能完成与鼠标相同的操作。

void MandelbrotWidget::wheelEvent(QWheelEvent *event)
{
    const int numDegrees = event->angleDelta().y() / 8;
    const double numSteps = numDegrees / double(15);
    zoom(pow(ZoomInFactor, numSteps));
}

angleDelta().y()返回八分之一度为单位的滚轮角度。代码先除以8转为度,再除以15得到常见鼠标的刻度数,使用ZoomInFactor的该次幂得到缩放比例。例如向正方向两格,比例为0.8×0.8=0.64。

#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

捏合手势通过QGesture实现:从手势事件中取QPinchGesture,只有ScaleFactorChanged时才缩放,并采用其比例倒数。event()把Gesture类型转发到gestureEvent(),其他事件继续交给基类。编译条件QT_NO_GESTURES和QT_CONFIG(wheelevent)保留了不具备对应功能时的边界。

拖动时只移动旧图,松开后重算

void MandelbrotWidget::mousePressEvent(QMouseEvent *event)
{
    if (event->button() == Qt ::LeftButton)
        lastDragPos = event->position().toPoint();
}
void MandelbrotWidget::mouseMoveEvent(QMouseEvent *event)
{
    if (event->buttons() & Qt ::LeftButton) {
        pixmapOffset += event->position().toPoint() - lastDragPos;
        lastDragPos = event->position().toPoint();
        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,再调用update()请求重绘。这里没有每移动一个像素就同步等待分形计算。

松开左键后,先补上最后一段位移,再清空拖动标志。根据窗口尺寸、图像的设备无关尺寸和偏移算出需要移动的逻辑量,交给scroll()更新中心并请求新图。只移动旧图不够,因为它无法补齐拖动后露出的边缘。

在GUI线程接收新图

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()发现拖动尚未结束时直接返回,避免新的图突然替换正在拖动的预览。正常情况下读取图像附带的信息,转换为QPixmap,清空偏移与拖动状态,记录这张图的scaleFactor,然后update()。

工作线程使用QImage,便于按像素直接操作;GUI端保存QPixmap,适合后续显示。转换集中在接收槽里做一次,不在每次paintEvent()重复进行。这里按原例的显示路径解释两类对象分工,不把“图像必须先变成QPixmap才可绘制”当作Qt所有绘图API的普遍限制。

void MandelbrotWidget::zoom(double zoomFactor)
{
    curScale *= zoomFactor;
    update();
    thread.render(centerX, centerY, curScale, size(), devicePixelRatio());
}
void MandelbrotWidget::scroll(int deltaX, int deltaY)
{
    centerX += deltaX * curScale;
    centerY += deltaY * curScale;
    update();
    thread.render(centerX, centerY, curScale, size(), devicePixelRatio());
}

zoom()改变curScale,先update()显示缩放后的旧图,再调用render()请求新结果。scroll()则把像素位移乘以curScale,加到centerX/centerY,随后同样先重绘再请求后台计算。界面反馈与精确结果因此各走自己的节奏。

main()仍然保持普通GUI结构

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    QCommandLineParser parser;
    parser.setApplicationDescription(u"Qt Mandelbrot Example"_s);
    parser.addHelpOption();
    parser.addVersionOption();
    QCommandLineOption passesOption(u"passes"_s, u"Number of passes (1-8)"_s, u"passes"_s);
    parser.addOption(passesOption);
    parser.process(app);

    if (parser.isSet(passesOption)) {
        const auto passesStr = parser.value(passesOption);
        bool ok;
        const int passes = 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();
    return app.exec();
}

创建QApplication后,命令行解析器提供帮助、版本和–passes参数;指定passes时必须能转换为1到8之间的整数,否则输出警告并以-1返回。默认遍数在renderthread.cpp中为8。这个静态配置在创建窗口、启动工作之前设好;若要运行中更改,也必须考虑同步,而不能把setNumPasses()当成线程安全的动态设置接口。

最后建立窗口,注册捏合手势,显示并进入app.exec()。线程机制被封装在类中,没有把main()变成手工轮询循环。窗口销毁时会连带析构RenderThread,因此前述“设置退出—唤醒—wait”的完整收尾至关重要。

来源、许可与修改记录

原文完整项目链接:Qt 6.12 Mandelbrot示例。本文保留定义、构造/析构、render、run、条件等待、颜色函数、窗口事件、排队结果和main的全部正文技术流程。原文截图被原创解释图替代;没有生成假的运行画面。

文档原版权:© 2026 The Qt Company Ltd.,文档贡献者保留各自版权。原文文档采用GNU Free Documentation License 1.3;本译文按该许可保留署名、来源和修改记录,未新增不变章节、封面文本或商标背书。随稿附GNU FDL 1.3完整文本。Qt及相关标识是The Qt Company及相应权利人的商标。

代码许可单独处理:核对的renderthread.h/cpp文件头为Copyright (C) 2021 The Qt Company Ltd.,SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause。本稿所引示例代码保留该归属,并按BSD-3-Clause条件提供,随稿附BSD三条款文本。本文同步建议属于另行标出的编辑修改,不冒充上游原代码。中文译写与配图依据另行授权制作。

修改记录:2026年10月5日,未完纪完成中文翻译、合并说明、自绘生命周期图,以及对共享bool、条件等待窗口、过时结果投递和静态遍数配置的审核提示。没有执行来源代码,没有构建、竞态检测、压力测试或性能测试;不能据本文宣称示例或修订已通过测试、没有漏洞。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容