Qt之保持GUI响应

简述

Qter们经常遇到由于耗时操作造成GUI阻塞的问题。其实,这个问题并不难克服,可以采用许多不同的方式,下面我会列举一些可选范围,根据使用情况进行处理。

  • 简述
  • 执行耗时操作
  • 手动事件处理
  • 使用一个工作线程
  • 等待本地事件循环
  • 逐步地解决问题
  • 并行编程
  • 总结
  • 更多参考

执行耗时操作

我们需要做的第一件事就是确定能够解决问题的范围。上述问题可能会以两种形式出现。

  1. 当一个程序执行计算密集型的任务,为了获得一系列按顺序操作的最终结果。这样任务的一个例子是:计算一个快速傅立叶变换。
  2. 当一个程序触发一些行为(例如:网络下载),等待它完成之后,继续算法的下一个步骤。使用Qt时,这个问题很容易避免,因为大部分框架执行的异步任务完成以后都会发出相应的信号,可以把它连接到槽函数上延续算法。

在计算期间(忽略任何信号槽使用)所有事件处理会停止。其结果是:GUI未刷新、用户输入不处理、网络活动停止 - 应用程序看起来被阻塞了。实际上,不相关的部分耗时任务阻塞了,多长时间属于“耗时操作”? - 任何一切干扰用户和程序交互的都算。一秒比较长,所有超过两秒以上的绝对太长了。

这节,我们的目标是保证功能,同时防止用户界面被阻塞而惹恼用户。要做到这一点,来看看解决方案。有两种方式可以达到执行计算的最终目标:

  1. 在主线程计算(单线程方式)
  2. 单独的线程(多线程方式)

后者广为人知,但它有时被滥用。因为有时一个线程处理完全可以。与流行观点相反,线程通常会减缓你的应用程序而不是加速。所以,除非你确信程序可以在多线程中受益(无论对效率还是简单而言),尽量避免产生新的线程,因为你可以。

手动事件处理

最基本的解决方案是明确要求Qt在计算的某些时刻处理等待事件。要做到这一点,必须定期调用QCoreApplication::processEvents()。

下面的例子显示如何做到这一点:

for (int i = 3; i <= sqrt(x) && isPrime; i += 2) {
    label->setText(tr("Checking %1...").arg(i));
    if (x % i == 0)
        isPrime = false;
    QCoreApplication::processEvents();
    if (!pushButton->isChecked()) {
        label->setText(tr("Aborted"));
        return;
    }
}

这种方法有明显的缺点。例如:假设你想要并行调用两个类似这样的循环,其中一个将会阻止另一个直到第一个完成(所以你不能分配计算能力到不同的任务)。这也会使应用程序的事件延迟反应。此外,代码很难阅读和分析,因此这种方法只适合单线程中处理的简短的操作,如:启动画面和短期监控操作。

使用一个工作线程

另一个避免阻塞主事件循环的解决方案:在一个单独的线程中处理耗时操作。如果任务由第三方库以阻塞方式执行,这就显得非常有用。这种情况下,可能不能中断它让GUI处理等待事件。

在单独的线程中执行操作的一种方法是使用QThread。可以子类化QThread并实现run()函数,或调用QThread::exec()启动线程的事件循环,当然,也可以两者兼有。然后可以用信号槽的方式与主线程通信。切记:必须确保使用Qt::QueuedConnection类型连接,否则线程可能会失去稳定性,导致程序crash。

在Qt参考文档和在线资料里有很多使用线程的示例,所以这里就不自己实现了,主要专注于其它有趣的方面。

等待本地事件循环

接下来的解决方案,我想描述处理等待直到异步任务完成。这里,我会告诉你如何阻塞流动,直到没有阻塞事件处理的网络操作完成,基本上是这样:

task.start();
while (!task.isFinished())
    QCoreApplication::processEvents();

这就是所谓的忙等待 - 不断地检查状态,直到不满足条件。大多数情况下,这是个坏主意 - 往往会吃掉所有的CPU并且具有手动事件处理的所有缺点。

幸运的是,Qt有一个类可以帮助我们完成任务 - QEventLoop:与应用程序和模态对话框使用exec()一样。这个类的每个实例都会连接到主事件调度机制,当exec()函数被调用时,就开始处理事件,直到使用quit()让其退出。

我们可以利用该机制把异步操作转换成同步操作,使用信号和槽 - 可以开启一个本地事件循环,当接收到特定对象的特定信号时,让它退出:

QNetworkAccessManager manager;
QEventLoop loop;
QTimer timer;

timer.setSingleShot(true);
connect(&timer, SIGNAL(timeout()), &loop, SLOT(quit()));
connect(&manager, SIGNAL(finished(QNetworkReply*)), &loop, SLOT(quit()));
QNetworkReply *reply = manager.get(QNetworkRequest(QUrl("http://www.qtcentre.org")));

timer.start(5000); // 5s超时
loop.exec();

if (timer.isActive()){
    // 下载完成
    timer.stop();
} else {
    // 超时
}

我们使用了QNetworkAccessManager来获取远程URL,因为它是异步的,我们创建一个本地事件循环等待finished()信号。此外,还创建了一个超时为5s的定时器,万一出错将终止事件循环。调用get()和start()发送请求并启动定时器,进入事件循环。在下载完成或5s超时时间到了之后(这取决谁先完成),exec()就会返回。我们通过检测定时器是否处于激活状态,来判断究竟是谁先完成的,然后就可以处理结果或告诉用户下载失败了。

这里,应该再说明两件事:

  1. 类似的方法在libqxt项目(http://www.libqxt.org)中QxtSignalWaiter类的组成部分。
  2. 对于某些操作,Qt提供了一个“等待”方法(例如:QIODevice::waitForBytesWritten()),作用或多或少与上面的代码相同,但没有运行事件循环。然而,“等待”的解决方案将阻塞GUI,因为它们不运行自己的事件循环。

逐步地解决问题

如果能把问题分成子问题,然后有一种不阻塞GUI的很好的方案。简而言之,你可以在较短步骤内执行任务不会阻塞耗时处理的事件。当发现在指定的任务上已经花了一些时间时,保存其状态并返回到事件循环。在完成事件后,需要有一种方法来通知Qt继续你的任务。

比较幸运,有两种方式。

  • QTimer::singleShot()
  • QMetaObject::invokeMethod()

方式一:使用一个单次触发定时器(时间间隔为0)。这个特殊的值会导致Qt发出timeout()信号,定时器的事件循环将会变为空闲。如果连接这个信号至一个槽函数,当应用程序不忙着做其它事情(类似于屏幕保护的工作机制)时,会得到调用函数的机制。这里我们看一个后台查找素数的例子:

class FindPrimes : public QObject
{
    Q_OBJECT
public:
    FindPrimes(QObject *parent = 0) : QObject(){}
public slots:
    void start(qlonglong _max);
private slots:
    void calculate();
signals:
    void prime(qlonglong);
    void finished();
private:
    qlonglong cand, max, curr;
    double sqrt;
    void next(){ cand+=2; curr = 3; sqrt = ::sqrt(cand);}
};

void FindPrimes::start(qlonglong _max)
{
    emit prime(1); emit prime(2); emit prime(3);
    max = _max; cand = 3; curr = 3;
    next();
    QTimer::singleShot(0, this, SLOT(calculate()));
}

void FindPrimes::calculate()
{
    QTime t;
    t.start();
    while (t.elapsed() < 150) {
        if (cand > max) {
            emit finished();        // end
            return;
        }
        if (curr > sqrt) {
            emit prime(cand);       // prime
            next();
        } else if (cand % curr == 0)
            next();                 // not prime
        else
            curr += 2;              // check next divisor
    }
    QTimer::singleShot(0, this, SLOT(calculate()));
}

FindPrimes类使用两种特性 - 保持其当前计算状态(cand、curr变量),以便它可以继续计算中断的地方,并且可以监控(通过使用QTime::elapsed())当前任务步骤执行了多长时间。如果时间超过预定量,就返回到事件循环。但这样做之前,它启动一个会再次调用该方法的单次定时器(你可能把这种方式称之为“延迟复发”)。

方式二:这种方法可以调用任何对象中的任何槽函数。需要说的一件事是,这在我们的情况下工作,我们需要确保使用Qt::QueuedConnection连接类型,使槽函数以异步方式(默认情况下,在一个单线程中调用槽函数是同步的)调用。因此,我们可能会以下列取代计时器调用:

QMetaObject::invokeMethod(this, "calculate", Qt::QueuedConnection);

使用这种方式比定时器好的地方在于,它可以传递参数给槽函数(例如:给它传递当前的计算状态),除此以外,这两种方法是等效的。

并行编程

最后,还有这种情况 - 需要为一组数据执行类似的操作。例如:为一个目录的图片创建缩略图,来看一下最简单的实现:

QList<QImage> images = loadImages(directory);
QList<QImage> thumbnails;
foreach (const QImage &image, images) {
    thumbnails << image.scaled(QSize(300,300), Qt::KeepAspectRatio, Qt::SmoothTransformation);
    QCoreApplication::sendPostedEvents();
}

这种方法的缺点是:创建一个单一的缩略图可能会花很长时间,那时候GUI将会阻塞,更好的方法是在一个单独的线程中执行:

QList<QImage> images = loadImages(directory);
ThumbThread *thread = new ThumbThread;
connect(thread, SIGNAL(finished(QList<QImage>)), this, SLOT(showThumbnails(QList<QImage>)));
thread->start(images);

这个解决方案非常好,但没有考虑到计算机系统发展的方向。越来越快的处理单元配备多个慢单元(多核或多处理器系统),它们一起提供更多的计算周期伴随着低功耗和散热。不幸的是,上述算法只使用了一个线程,因此在单个处理单元上执行,导致在多核系统上执行比单核的慢(因为多核系统中一个核比单核系统中一个核慢)。

为了克服这一缺点,我们必须进入并行编程的世界 - 将工作划分为尽可能多的线程来处理可用的单元,这些由QThreadPool和Qt Concurrent提供。

第一种可能的做法是使用所谓的runnables-simple类,它的实例可以被一个线程执行。Qt通过QRunnable类来实现runnables。你可以基于QRunnable提供的接口实现属于自己的runnable,并且使用Qt提供的另一个实体执行它。指的是线程池 - 一个可以产生大量线程执行任意工作的对象。如果作业数超过可用的线程数,作业将会排队,当线程可用时作业将会执行。

回到示例,实现runnable,使用线程池创建一个图像的缩略图。

class ThumbRunnable : public QRunnable {
public:
    ThumbRunnable(...)  : QRunnable(), ... {}
    void run(){ m_result = m_image.scaled(...); }
    const QImage &result() const{ return m_result; }
};

QList<ThumbRunnable *> runnables;
foreach(const QImage &image, images){
    ThumbRunnable *r = new ThumbRunnable(image, ...);
    r->setAutoDelete(false);
    QThreadPool::globalInstance()->start(r);
    runnables << r;
}

基本上,需要做的就是通过QRunnable类实现run()函数,它和子类化QThread相同,唯一的区别是:作业是不依赖于它创建的一个线程,因此可以通过任何现有的任何线程调用。创建ThumbRunnable的一个实例后,我们要确保在作业执行完成以后,它不会被线程池删除。这样做是因为我们想获取对象的结果。最后,我们要求线程池对作业排队利用每个应用程序的全局线程池变量,并添加runnable到列表中以供将来参考。

然后,我们需要定期检查每个runnable,看看它的结果是否可用的,比较无聊、麻烦。幸好,当你需要获取结果时,有一个更好的方案。Qt Concurrent引入了一系列示例可以执行SIMD(单指令多数据)操作。下面我们来看看其中的一个,最简单的一种是处理容器中的每个元素,把结果保存到另一个容器中。

typedef QFutureWatcher<QImage> ImageWatcher;
QImage makeThumb(const QString &img)
{
    return QImage(img).scaled(QSize(300,300), ...);
}

QStringList images = imageEntries(directory);
ImageWatcher *watcher = new ImageWatcher(this);
connect(watcher, SIGNAL(progressValueChanged(int)), progressBar, SLOT(setValue(int)));
QFuture<QImage> result = QtConcurrent::mapped(images, makeThumb);
watcher->setFuture(result);

很简单,不是吗?只需要几行代码,观察者就会通知我们QFuture对象持有的SIMD程序的状态。它甚至会让我们取消、暂停和恢复程序。这里,我们使用了一个调用最简单可能的变量 - 使用一个独立函数。真实的情况下,会用一些更复杂的东西而不仅仅是一个简单的函数。Qt Concurrent允许使用函数、类函数和函数对象,第三方解决方案可让通过使用绑定函数参数进一步扩大可能性。

总结

上面,已经展示了基于Qt耗时操作类型和复杂度问题的整体解决方案。这些只是基础知识,可以依赖它们 - 例如:使用本地事件循环创建自己的“模式”对象,使用并行编程快速处理数据、或者使用线程池处理通用作业运行。对于简单情况而言,有办法手动请求应用程序来处理挂起事件,将复杂的任务划分成更小的子任务可能是正确的方向。

再也不要让你的GUI阻塞了!

更多参考

Keeping the GUI Responsive

时间: 2024-10-31 13:53:59

Qt之保持GUI响应的相关文章

ROS:使用Qt Creator创建GUI程序(二)

开发环境: Ubuntu14.04 ROS indigo version Qt Creator 3.0.1 based on Qt 5.2.1 步骤如下:(前提:成功完成ROS:使用Qt Creator创建GUI程序(一)中所有步骤) ①关闭所有终端 ②打开新的终端,输入下述命令: $ qtcreator ③重复"ROS:使用Qt Creator创建GUI程序(一)"中启动qtcreator后的操作, 进入到qt界面,点击绿色三角形符号运行,会出现以下问题: ④打开工程所在文件夹,发现

使用 PySide2 开发 Maya 插件系列一:QT Designer 设计GUI, pyside-uic 把 .ui 文件转为 .py 文件

使用 PySide2 开发 Maya 插件系列一:QT Designer 设计GUI, pyside-uic 把 .ui 文件转为 .py 文件 前期准备: 安装 python:https://www.python.org/downloads/ 安装 PySide2:安装 python 后,在安装目录下有 /script 文件夹,里面有 pip.exe ,cmd执行:pip install PySide,pip install PySide2(注意: python2.x 对应 PySide,py

Qt多线程和GUI界面假死

QThread的常见特性: run()是线程的入口,就像main()对于应用程序的作用.QThread中对run()的默认实现调用了exec(),从而创建一个QEventLoop对象,由其处理该线程事件队列(每一个线程都有一个属于自己的事件队列)中的事件.简单用代码描述如下: 1 int QThread::exec() 2 { 3 //... 4 QEventLoop eventLoop; 5 int returnCode = eventLoop.exec(); 6 //... 7 return

在QT上进行GUI开发实战

1.目录 用ODBC的方式excel文件(xls)从输入数据 GUI界面的入门设计 QT内QFile的用法 Qt内中文乱码的解决 2.用ODBC的方式excel文件(xls)从输入数据 主要的网站参考:http://blog.csdn.net/u010780613/article/details/51151448 主要的代码: QString str; str = QString::fromLocal8Bit("请选择Excel文件"); QString filePath = QFil

ROS:使用Qt Creator创建GUI程序(一)

开发环境: Ubuntu14.04 ROS indigo version Qt Creator 3.0.1 based on Qt 5.2.1 步骤如下:(按照下面命令一步步来,亲测可行) (一)安装qt-ros相关的包打开终端,输入下面的命令: $ sudo apt-get install ros-indigo-qt-create $ sudo apt-get install ros-indigo-qt-build (二)创建一个Qt ROS GUI 模板程序打开终端,输入下面的命令: $ m

【QT】C++ GUI Qt4 学习笔记4

感觉这本书的顺序设计的太不合理了,出现的最多的一句话就是后面会讲.按照使用的顺序讲不行吗?搞得代码都运行不了. 我决定先直接跳到73页,子类化QTableWidgetItem这一节.因为前面功能的实现都依赖于这一部分. 预备知识: C++关键字 mutable: mutalbe的中文意思是“可变的,易变的”,跟constant(既C++中的const)是反义词. 在C++中,mutable也是为了突破const的限制而设置的.被mutable修饰的变量,将永远处于可变的状态,即使在一个const

【QT】C++ GUI Qt4 学习笔记1

Find对话框实现 平台 Qt5.3.2 MinGW4.8.2 注意创建时用QDialog finddialog.h #ifndef FINDDIALOG_H #define FINDDIALOG_H #include <QDialog> #include <QLabel> #include <QCheckBox> #include <QLineEdit> #include <QPushButton> #include <QLayout&g

【QT】C++ GUI Qt4 学习笔记2

Go To Cell 利用QT Desinger做好界面后加入的代码有 gotocelldialog.h #ifndef GOTOCELLDIALOG_H #define GOTOCELLDIALOG_H #include <QDialog> #include "ui_gotocelldialog.h" class GoToCellDialog : public QDialog, public Ui::GoToCellDialog { Q_OBJECT public: Go

【QT】C++ GUI Qt4 学习笔记5

折腾了好几天,终于把这本书的第三章和第四章给看了个大概. 里面的函数调用关系可谓是复杂. 整理了一部分的函数关系如下: cell关系清理 data(role) 返回应该显示的值 或者对齐方式 或者公式 ->value() 单元格的值,如果是旧的就重新获得 值给cachedValue并返回 对输入的以‘开头和 = 开头的做特殊处理 ->formula() 获得单元格的Edit role ->data(Edit role) ->evalExpression() 计算表达式的值 setF