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();        // 结束
            return;
        }
        if (curr > sqrt) {
            emit prime(cand);       // 素数
            next();
        } else if (cand % curr == 0)
            next();                 // 非素数
        else
            curr += 2;              // 查找下一个素数
    }
    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 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 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 result = QtConcurrent::mapped(images, makeThumb);
watcher->setFuture(result);

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

总结

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

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

更多参考

  • Keeping the GUI Responsive

你可能感兴趣的:(Qt之保持GUI响应)