Qt訊號槽與事件迴圈學習筆記

2023-10-12 09:01:13

事件與事件迴圈

在Qt中,事件event)被封裝為QEvent類/子類物件,用來表示應用內部或外部發生的各種事情。事件可以被任何QObject子類的物件接收並處理。

根據事件的建立方式和排程方式,Qt中事件可分為三類,分別是:

  • 自發事件(Spontaneous event)由視窗系統(window system)建立,隨後加入事件佇列,等待主事件迴圈處理(首先轉換為QEvents範例,再分發給對應的QObjects範例)。
  • 推播事件(Posted event)由Qt程式建立,並加入Qt的事件佇列,等待事件迴圈分發。
  • 傳送事件(Sent event)由Qt程式建立,直接傳遞給目標物件。

Qt中的事件迴圈event loop)是通過一個佇列來回圈處理事件的機制。當佇列中有事件時,事件迴圈會處理事件;如果佇列中沒有事件,則事件迴圈會阻塞等待。

在Qt程式中,每個執行緒都可以有自己的事件迴圈(每個執行緒只能擁有一個活動的事件迴圈,並對應一個事件佇列),事件迴圈在呼叫.exec()時進行啟動。主執行緒/GUI執行緒對應的事件迴圈,被稱為主事件迴圈(main event loop)。一般來說,在main()函數的末尾會呼叫QApplication::exec()函數,從而啟動並進入Qt的主事件迴圈(更準確地說,Qt的主事件迴圈在主執行緒中呼叫QCoreApplication::exec()時啟動),直到exit()函數被呼叫。

從概念上來說,事件迴圈可以理解為一些while迴圈:

while (!exit_was_called) {
        while (!posted_event_queue_is_empty) {
            process_next_posted_event();
        }
        while (!spontaneous_event_queue_is_empty) {
            process_next_spontaneous_event();
        }
        while (!posted_event_queue_is_empty) {
            process_next_posted_event();
        }
    }

首先,事件迴圈處理Posted事件,直到佇列為空。然後,處理Spontaneous事件(首先轉換為QEvents物件,然後傳送給QObjects範例)。最後,處理在Spontaneous事件處理過程中生成的Posted事件。事件迴圈不會對Sent事件進行處理,Sent事件會直接傳遞給目標物件。

事件迴圈首先會將事件傳遞給物件樹頂部的QObject範例,然後從根節點開始逐級向下傳遞事件,直到找到能夠處理該事件的接收者物件。這個過程被稱為事件傳遞(Event Propagation)。事件的處理通過呼叫QObject範例的event()函數來完成的。

在使用事件迴圈機制時,exec()用於開啟事件迴圈,exit()或quit()用於退出事件迴圈,值得注意的是,的quit()或exit()函數並不會立即退出事件迴圈,而是等待控制權返回事件迴圈後,才會真正退出事件迴圈,並返回exec()呼叫處,這裡有點費解,之後會通過一個例子說明。

更多關於Qt中事件系統的細節,除了參考Qt原始碼和官方檔案The Event SystemQEvent Class,還推薦其他兩篇文章Another Look at EventsQt原始碼閱讀-事件迴圈

這裡出現一個問題:次執行緒中建立的事件迴圈是否會處理Spontaneous事件?以下是GPT的回答,正確性未驗證僅供參考,歡迎各位大佬指點。
GPT:在其他執行緒中建立的事件迴圈通常不會處理自發事件,除非明確地要求。如果需要在其他執行緒中處理自發事件,您需要自行建立和管理事件迴圈,並顯式地設定相應的機制來觸發事件的處理。也就是說,如果直接呼叫QThread::exec()函數開啟事件迴圈,是不會處理Spontaneous事件的。

訊號槽機制

訊號槽signal-slot)和事件處理是兩種不同的機制,都可以用於實現程式中不同物件之間的同步或非同步通訊。它們可以在Qt應用程式中一起使用,但它們在實現方式和應用範圍上有一些區別。

訊號槽機制:

  • 訊號槽機制是Qt框架的獨有特性,用於實現物件之間的鬆散耦合通訊。
  • 傳送訊號並不需要一個特定的接收物件,訊號可以被多個槽函數接收,類似於「廣播」。
  • 訊號槽機制允許物件在特定事件或狀態變化時發射訊號,通知其他物件執行相關操作。

事件和事件處理:

  • 事件和事件處理是一種通用的事件驅動程式設計正規化,廣泛應用於多種程式設計環境和框架,不僅限於Qt。
  • 事件傳送和處理需要明確指定一個接收者物件,每個事件必須有一個確定的接收者,類似於「單播」。
  • 事件處理通常是通過重寫物件的事件處理常式來實現,響應不同型別的事件,如使用者輸入事件、系統事件等。

Qt中訊號槽的使用非常簡單,使用connect將兩個物件(必須是QObject的子類)的訊號和槽連線起來即可。值得注意的是,connect函數的第5個引數為列舉型別Qt::ConnectionType,用於指定連線型別。以下是列舉型別Qt::ConnectionType的值:

  1. Qt::AutoConnection(預設值):Qt會根據訊號和槽的所線上程來自動選擇連線型別。如果訊號和槽在同一執行緒,將採用 Qt::DirectConnection,否則採用 Qt::QueuedConnection。

  2. Qt::DirectConnection:訊號被髮射時,槽會直接在訊號發射的執行緒上呼叫,不涉及事件佇列。這通常在同一執行緒內的連線中使用,且是同步的。

  3. Qt::QueuedConnection:訊號被髮射時,槽會被放入接收者物件的事件佇列中,等待事件迴圈處理。這用於在不同執行緒之間建立連線,因此是非同步的。這裡說明,訊號槽機制的佇列連線實現依賴事件迴圈機制。

  4. Qt::BlockingQueuedConnection:類似於 Qt::QueuedConnection,但不返回到訊號發射者,阻塞直到槽函數完成執行。

  5. Qt::UniqueConnection:如果已經存在一個具有相同引數的連線,將不會建立新連線,而是返回一個無效的連線。

通常,直接使用預設值Qt::AutoConnection,即可滿足大多數情況的需求,因為它會根據上下文自動選擇合適的連線型別。特殊情況下,也可以手動指定連線型別,比如,指定同一個執行緒中的兩個物件間為佇列連線,或指定不同兩個執行緒中的兩個物件為直接連線。

下面用一個例子,詳細解釋以上所有特性。

#include <QDebug>
#include <QCoreApplication>
#include <QTimer>
#include <QThread>


class Foo : public QObject {
Q_OBJECT

public:
    Foo(QObject *parent = nullptr) : QObject(parent) {}


private:
    void doStuff() {
        qDebug() << QThread::currentThreadId() << ": Emit signal one";
        emit signal1();

        qDebug() << QThread::currentThreadId() << ": Emit signal finished";
        emit finished();

        qDebug() << QThread::currentThreadId() << ": Emit signal two";
        emit signal2();
    }

signals:

    void signal1();

    void finished();

    void signal2();

public slots:

    void slot1() {
        qDebug() << QThread::currentThreadId() << ": Execute slot one";
    }

    void slot2() {
        qDebug() << QThread::currentThreadId() << ": Execute slot two";
    }

    void start() {
        doStuff();

        qDebug() << QThread::currentThreadId() << ": Bye!";

    }
};

#include "main.moc"

int main(int argc, char **argv) {
    qDebug() << "main thread id:" << QThread::currentThreadId();
    QCoreApplication app(argc, argv);

    Foo foo;
    Foo foo2;
    QThread *foo2thread = new QThread(&app);
    foo2.moveToThread(foo2thread);
    foo2thread->start();

    QObject::connect(&foo, &Foo::signal1, &foo, &Foo::slot1);
    QObject::connect(&foo, &Foo::signal1, &foo2, &Foo::slot1);

    QObject::connect(&foo, &Foo::finished, &app, &QCoreApplication::quit);
    QObject::connect(&foo, &Foo::finished, foo2thread, &QThread::quit);

    QObject::connect(&foo, &Foo::signal2, &foo, &Foo::slot2); // Qt::DirectConnection
    QObject::connect(&foo, &Foo::signal2, &foo2, &Foo::slot2); // Qt::QueuedConnection

    QTimer::singleShot(0, &foo, &Foo::start);
    return app.exec();
}

以下是執行結果:

main thread id: 0x165c
0x165c : Emit signal one
0x165c : Execute slot one
0x165c : Emit signal finished
0x5578 : Execute slot one
0x165c : Emit signal two
0x165c : Execute slot two
0x165c : Bye!

在這段程式碼中,

第1步:建立了兩個Foo的範例foo和foo2,並將foo2移動到另一個執行緒foo2thread中。

第2步:將foo的兩個訊號分別連線到foo2兩個槽函數。此外,還將foo的finished()訊號,連線到app和foo2thread的quit函數上,以便在發出finished訊號時,通知主事件迴圈和foo2thread執行緒的事件迴圈退出。

第3步:將單次定時器連線到foo的start() 函數,準備進入主事件迴圈。

第4步:啟動並進入主事件迴圈。

當exec()函數被呼叫時,事件迴圈開始。發生的第一個事件是計時器在0毫秒後發出超時訊號。訊號timeout()連線到foo物件的start()槽函數。在輪詢任何其他事件之前,start()槽函數將被執行完成。這導致了該doStuff()方法發出signal1(). 連線到該訊號的槽slot1()將立即被執行。一旦控制返回到doStuff(),它就會發出第二個訊號finished()。該訊號連線到應用程式app和foo2thread執行緒的quit函數上,這是否意味著應用程式將立即退出?

答案是否定的。如前所述,QCoreApplication::quit()槽實際上呼叫QCoreApplication::exit(0),而分析後者的原始碼可以發現,其只是將事件迴圈的退出標誌設為true。在控制權返回到主事件迴圈之前,實際的退出不會發生。

因此,在發出訊號finished()之後,程式會繼續執行doStuff(),發出訊號signal2(),這裡注意,由於foo的signal2和slot2之間是直接連線,因此在發射signal2的同時,foo的slot2便阻塞執行了,而signal2和foo2的slot2之間是佇列連線,執行緒foo2thread的控制權已經回到了事件迴圈處,並已經退出事件迴圈。因此,foo2的slot2不會執行。

隨後,返回start()。在start()退出之前,列印「Bye!」。

最後,回到主事件迴圈。

由於這時主事件迴圈退出標誌設定為true,便會返回主函數中exec的呼叫處,隨之程式結束。

如果手動指定QObject::connect(&foo, &Foo::signal2, &foo, &Foo::slot2)的連線型別為Qt::QueuedConnection,最後得到的結果會有所不同,感興趣的讀者可以自己試一試。