目錄

(放個目錄方便預覽。這個目錄是從博客複製過來的,點擊會跳轉到博客)

  • 簡介
  • 事件與事件循環
    • Hello World
    • 循環處理
    • 類比事件循環的概念
  • 不同操作系統的事件循環
    • Windows
    • Linux X11窗口
    • MacOS Cocoa Application
  • Qt的事件循環
    • QEventLoop類
    • QCoreApplication 主事件循環
  • Qt的事件分發和事件處理
    • 重載事件
    • QEvent
    • 事件過濾器
  • 事件循環的運用
    • processEvents不阻塞
    • QEventLoop模擬同步調用

簡介

本文是《Qt實用技能》系列文章的第三篇,濤哥在這裡討論事件循環相關的知識點。

一些常見的問題,諸如:

為什麼Qt程序的main函數都有一個QApplication?

執行比較耗時的任務時,怎樣能不卡界面?

同步、非同步、阻塞、非阻塞到底是怎麼回事?

等等,都可以在本文中找到答案。

註:文章主要發布在濤哥的博客 和 濤哥的知乎專欄-Qt進階之路

事件與事件循環

Hello World

從Hello World說起吧

#include <stdio.h>
int main(int argc, char *argv[])
{
printf("Hello World");
return 0;
}

這是一段大家都很熟悉的命令行程序,運行起來會在終端輸出」Hello World」,之後程序就退出了。

循環處理

我們稍微加點需求: 程序能夠一直運行,每次用戶輸入一些信息並按下回車時,列印出用戶的輸入。直到輸入的內容為「quit」時才退出。

按照這個需求,代碼實現如下:

#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[])
{
char input[1024]; //假設輸入長度不超過1024
const char quitStr[] = "quit";
bool quit = false;
while (false == quit) {
scanf_s("%s", input, sizeof input);
printf("user input: %s
", input);
if (0 == memcmp(input, quitStr, sizeof quitStr)) {
quit = true;
}
}
return 0;
}

我們使用了一個while循環。在這個循環體內,不停地處理用戶的輸入。當輸入的內容為」quit」時,循環終止條件被設置為true,循環將終止。

類比事件循環的概念

在上面這個例子中,「用戶輸入並按下回車」這件事情,我們可以稱作一個「事件」或者「用戶輸入事件」,不停的去處理「事件」的這段代碼,

我們可以稱作「事件循環」, 也可以叫做」消息循環」,是一回事。

一般對於帶UI窗口的程序來說,「事件」是由操作系統或程序框架在不同的時刻發出的。

當用戶按下滑鼠、敲下鍵盤,或者是窗口需要重新繪製的時候,計時器觸發的時候,都會發出一個相應的事件。

我們把「事件循環」的代碼 提煉/抽象 如下:

function loop() {
initialize();
bool shouldQuit = false;
while(false == shouldQuit)
{
var message = get_next_message();
process_message(message);
if (message == QUIT)
{
shouldQuit = true;
}
}
}

在事件循環中, 不停地去獲取下一個事件,然後做出處理。直到quit事件發生,循環結束。

有「取事件」的過程,那麼自然有「存儲事件」的地方,要麼是操作系統存儲,要麼是軟體框架存儲。

存儲事件的地方,我們稱作 「事件隊列」 Event Queue

處理事件,我們也稱作 「事件分發」 Event Dispatch

不同操作系統的事件循環

Windows

先來看一個Windows系統的事件循環示例(win32 API):

MSG msg = { 0 };
bool done = false;
bool result = false;
while (!done)
{
if (PeekMessage(&msg, 0, 0, 0, PM_REMOVE))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
if (msg.message == WM_QUIT)
{
done = true;
}
}

思路和前面介紹的一致

Linux X11窗口

有些linux系統使用X11窗口系統,看看其窗口事件循環

Atom wmDeleteMessage = XInternAtom(mDisplay, "WM_DELETE_WINDOW", False);
XSetWMProtocols(display, window, &wmDeleteMessage, 1);

XEvent event;
bool running = true;

while (running)
{
XNextEvent(display, &event);

switch (event.type)
{
case Expose:
printf("Expose
");
break;

case ClientMessage:
if (event.xclient.data.l[0] == wmDeleteMessage)
running = false;
break;

default:
break;
}
}

思路也是和前面一致的

MacOS Cocoa Application

在Cocoa Application中, 有一種獲取事件的機制,叫做runloop(一個NSRunLoop對象,它允許進程接收窗口服務的各種事件)

一般的Cocoa Application運行流程是,從runloop的事件隊列中獲取一個事件(NSEvent)

派發事件(NSEvent)到合適的對象(Object)

事件被處理完成後,再取下一個事件(NSEvent),直到應用退出.

思路也是和前面一致的。

Qt的事件循環

Qt作為一個跨平臺的UI框架,其事件循環實現原理, 就是把不同平臺的事件循環進行了封裝,並提供統一的抽象介面。

和Qt做了類似工作的,還有glfw、SDL等等很多開源庫。

QEventLoop類

QEventLoop即Qt中的事件循環類,主要介面如下:

int exec(QEventLoop::ProcessEventsFlags flags = AllEvents)
void exit(int returnCode = 0)
bool isRunning() const
bool processEvents(QEventLoop::ProcessEventsFlags flags = AllEvents)
void processEvents(QEventLoop::ProcessEventsFlags flags, int maxTime)
void wakeUp()

其中exec是啟動事件循環,調用exec以後,調用exec的函數就會被「阻塞」,直到EventLoop裡面的while循環結束。

這裡畫個簡單的示意圖:

exit是退出事件循環(將EventLoop中的退出標識設為true)

processEvents是及時處理隊列中的事件(這個很有用,後面還會講)。

這裡有個問題,exec阻塞了當前函數,還怎麼退出EventLoop呢?

答案是:在派發事件後,某個事件處理的函數中,達到事件退出條件時,調用exit函數,將EventLoop中的退出標識設為true。

這樣的程序運行流程,我們叫做 「事件驅動」式的程序。

QCoreApplication 主事件循環

一般的Qt程序,main函數中都有一個QCoreApplication/QGuiApplication/QApplication,並在末尾調用 exec。

int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
//或者QGuiApplication, 或者 QApplication
...
...
return app.exec();
}

Application類中,除去啟動參數、版本等相關東西後,關鍵就是維護了一個QEventLoop,Application的exec就是QEventLoop的exec。

不過Application中的這個EventLoop,我們稱作「主事件循環」Main EventLoop。

所有的事件分發、事件處理都從這裡開始。

Application還提供了sendEvent和poseEvent兩個函數,分別用來發送事件。

sendEvent發出的事件會立即被處理,也就是「同步」執行。

postEvent發送的事件會被加入事件隊列,在下一輪事件循環時才處理,也就是「非同步」執行。

還有一個特殊的sendPostedEvents,是將已經加入隊列中的準備非同步執行的事件立即同步執行。

Qt的事件分發和事件處理

以QWidget為例來說明。

QWidget是Widget框架中,大部分UI組件的基類。QWidget類擁有一些名字為xxxEvent的虛函數,比如:

virtual void keyPressEvent(QKeyEvent *event)
virtual void keyReleaseEvent(QKeyEvent *event)

keyPressEvent就表示按鍵按下時的處理,keyReleaseEvent表示按鍵鬆開時的處理。

主事件循環中(註冊過QWidget類之後),事件分發會在按鍵按下時調用QWidget的keyPressEvent函數,按鍵鬆開時調用QWidget的keyReleaseEvent函數。

重載事件

有了上面的事件處理機制,我們就可以在自己的QWidget子類中,通過重載keyPressEvent、keyReleaseEvent等等事件處理函數,做一些自定義的事件處理。

QEvent

每一個事件處理函數,都是帶有參數的,這個參數是QEvent的子類,攜帶了各種事件的參數。比如

按鍵事件 void keyPressEvent(QKeyEvent *event) 中的QKeyEvent, 就包括了按下的按鍵值key、 count等等。

事件過濾器

Qt還提供了事件過濾機制,在事件分發之前先過濾一部分事件。

用法如下:

class KeyPressEater : public QObject
{
Q_OBJECT
...

protected:
bool eventFilter(QObject *obj, QEvent *event) override;
};

bool KeyPressEater::eventFilter(QObject *obj, QEvent *event)
{
if (event->type() == QEvent::KeyPress) {
QKeyEvent *keyEvent = static_cast<QKeyEvent *>(event);
qDebug("Ate key press %d", keyEvent->key());
return true;
} else {
// standard event processing
return QObject::eventFilter(obj, event);
}
}

。。。

monitoredObj->installEventFilter(filterObj);

自定義一個QObject子類,重載eventFilter函數。之後在要過濾的QObject對象上,調用installEventFilter函數以安裝過濾器上去。

過濾器函數的返回值為bool,true表示這個事件被過濾掉了,不用再往下分發了。false表示沒有過濾。

事件循環的運用

processEvents不阻塞UI

我們的UI界面,要持續不斷地刷新(對於QWidget就是觸發paintEvent事件),以保證顯示流暢、能及時響應用戶輸入。

一般要有一個良好的幀率,比如每秒刷新60幀, 即經常說的FPS 60, 換算一下 1000 ms/ 60 ≈ 16 ms,也就是每隔16毫秒刷新一次。

而我們有時候又需要做一些複雜的計算,這些計算的耗時遠遠超過了16毫秒。

在沒有計算完成之前,函數不會退出(相當於阻塞),事件循環得不到及時處理,就會發生UI卡住的現象。

這種場景下,就可以使用Qt為我們提供的介面,立即處理一次事件循環,來保證UI的流暢

(先不討論多線程的情況,後續有多線程專題文章,而且你們玩的不一定比濤哥6)

//耗時操作
someWork1()
//適當的位置,插入一個processEvents,保證事件循環被處理
QCoreApplication::processEvents();

//耗時操作
someWork2()

QEventLoop模擬同步調用

經常會有這種場景: 「觸發 」了某項操作,必須等該操作完成後才能進行「 下一步 」

比如:軟體的登錄界面,向伺服器發起登錄請求後,必須等收到伺服器返回的登錄數據,才知道登錄結果並決定下一步如何執行。

這種場景,如果設計成非同步調用,直接用Qt的信號/槽即可,如果要設計成同步調用,就可以使用本地QEventLoop

這裡寫段偽代碼示例一下:

bool login(const QString &userName, const QString &passwdHash, const QString &slat)
{
//聲明本地EventLoop
QEventLoop loop;
bool result = false;
//先連接好信號
connect(&network, &Network::result, [&](bool r, const QString &info){
result = r;
qDebug() << info;
//槽中退出事件循環
loop.quit();
});
//發起登錄請求
sendLoginRequest(userName, passwdHash, slat);
//啟動事件循環。阻塞當前函數調用,但是事件循環還能運行。
//這裡不會再往下運行,直到前面的槽中,調用loop.quit之後,才會繼續往下走
loop.exec();
//返回result。loop退出之前,result中的值已經被更新了。
return result;
}

推薦閱讀:

相關文章