zoukankan      html  css  js  c++  java
  • 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<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阻塞了!

    更多参考

  • 相关阅读:
    【必备】史上最全的浏览器 CSS & JS Hack 手册(转)
    iScroll4.2.5中的无法滑动或点击的解决方案(转)
    无障碍网页设计(WCAG2.0)
    大小端
    TCP报文结构
    A*
    shell中uniq与sort -u 两种去重的对别
    login流程
    类模板成员函数
    game 角色相关记录
  • 原文地址:https://www.cnblogs.com/new0801/p/6146576.html
Copyright © 2011-2022 走看看