Qt之保持GUI响应
简述
Qter们经常遇到由于耗时操作造成GUI阻塞的问题。其实,这个问题并不难克服,可以采用许多不同的方式,下面我会列举一些可选范围,根据使用情况进行处理。
执行耗时操作
我们需要做的第一件事就是确定能够解决问题的范围。上述问题可能会以两种形式出现。
当一个程序执行计算密集型的任务,为了获得一系列按顺序操作的最终结果。这样任务的一个例子是:计算一个快速傅立叶变换。
当一个程序触发一些行为(例如:网络下载),等待它完成之后,继续算法的下一个步骤。使用Qt时,这个问题很容易避免,因为大部分框架执行的异步任务完成以后都会发出相应的信号,可以把它连接到槽函数上延续算法。
在计算期间(忽略任何信号槽使用)所有事件处理会停止。其结果是:GUI未刷新、用户输入不处理、网络活动停止 - 应用程序看起来被阻塞了。实际上,不相关的部分耗时任务阻塞了,多长时间属于“耗时操作”? - 任何一切干扰用户和程序交互的都算。一秒比较长,所有超过两秒以上的绝对太长了。
这节,我们的目标是保证功能,同时防止用户界面被阻塞而惹恼用户。要做到这一点,来看看解决方案。有两种方式可以达到执行计算的最终目标:
- 在主线程计算(单线程方式)
- 单独的线程(多线程方式)
后者广为人知,但它有时被滥用。因为有时一个线程处理完全可以。与流行观点相反,线程通常会减缓你的应用程序而不是加速。所以,除非你确信程序可以在多线程中受益(无论对效率还是简单而言),尽量避免产生新的线程,因为你可以。
手动事件处理
最基本的解决方案是明确要求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()就会返回。我们通过检测定时器是否处于激活状态,来判断究竟是谁先完成的,然后就可以处理结果或告诉用户下载失败了。
这里,应该再说明两件事:
类似的方法在libqxt项目(http://www.libqxt.org)中QxtSignalWaiter类的组成部分。
对于某些操作,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<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阻塞了!