用 VS Code 搞 Qt6:訊號、槽,以及QObject
Qt 裡面的訊號(Signal)和槽(Slot)雖然看著像事件,但它實際上是用來在兩個對象之間進行通訊的。既然是通訊,就會有發送者和接收者。
1、訊號是發送者,觸發時通過特有的關鍵字「emit」來發出訊號。
2、槽是訊號的接收者,它實則是一個方法(函數 )成員,當收到訊號後會被調用。
為了讓C++類能夠使用訊號和槽機制,必須從 QObject 類派生。QObject 類是 Qt 對象的公共基類。它的第一個作用是讓 Qt 對象之形成一株「對象樹」。當某個 Qt 對象發生析構時,它的子級對象都會發生析構。比如,窗口中包含兩個按鈕,當窗口類析構時,裡面的兩個按鈕也會跟著發生析構。所以,在 Qt 的窗口應用程式裡面,一般不用手動去 delete 指針類型的對象。位於對象樹上的各個對象會自動清理。
QObject 類的另一個關鍵作用是實現訊號和槽的功能。
1、從 QObject 類派生的類,在類內部要使用 Q_OBJECT 宏。
2、跟在 signals 關鍵字後面的函數被視為訊號。這個關鍵字實際上是 Q_SIGNALS 宏,是 Qt 項目專用的,並不是 C++ 的標準關鍵字。
3、跟在 slots 或 public slots 後面的成員函數(方法)被認為是槽,當接收到訊號時會自動調用。
訊號和槽之間相互不認識,需要找個「媒婆」讓它們走到一起。因此,在發出訊號前要調用 QObject :: connect 方法在訊號與槽之間建立連接。
老周不喜歡說得太複雜,上面的介紹應該算比較簡潔了,接下來咱們來個示例,就好理解了。
這裡老周定義了兩個類:DemoObject 類裡面包含了一個 QStack<int> 對象,是個棧集合,這個應該都懂,後進先出。兩個公共方法,AddOne 用來向 Stack 對象壓入元素,TakeOne 方法從 Stack 對象中彈出一個元素。不過,彈出的元素不是經 TakeOne 方法返回,而是發出 GetItem 訊號,用這個訊號將彈出的元素髮送給接收者(槽在 TestRecver 類中)。第二個類是 TestRecver,對,上面 DemoObject 類發出的 GetItem 訊號可以在 TestRecver 類中接收,槽函數是 setItem。
#include <iostream> #include <qobject.h> #include <qstack.h> class DemoObject : public QObject { // 這個是宏 Q_OBJECT private: QStack<int> _inner; public: void AddOne(int val) { _inner.push(val); } void TakeOne() { if(_inner.empty()){ return; } int x = _inner.pop(); // 發出訊號 emit GetItem(x); } // 訊號 signals: void GetItem(int n); }; class TestRecver : public QObject { // 記得用這個宏 Q_OBJECT // 槽 public slots: void setItem(int n) { std::cout << "取出項:" << n << std::endl; } };
在 main 函數中,先創建 DemoObject 實例,用 AddOne 方法壓入三個元素。然後創建 TestRecver 實例,用 connect 方法建立訊號和槽的連接。
int main(int argc, char **argv) { DemoObject a; a.AddOne(50); a.AddOne(74); a.AddOne(80); TestRecver r; // 訊號與槽連接 QObject::connect(&a, &DemoObject::GetItem, &r, &TestRecver::setItem); // 下面這三行會發送GetItem訊號 a.TakeOne(); a.TakeOne(); a.TakeOne(); return 0; }
下面是 CMakeLists.txt 文件:
cmake_minimum_required(VERSION 3.0.0) project(myapp LANGUAGES CXX) find_package(Qt6 REQUIRED COMPONENTS Core) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) add_executable(myapp main.cpp) target_link_libraries(myapp PRIVATE Qt6::Core)
注意,這裡一定要把 CMAKE_AUTOMOC 選項設置為 ON,1,或者 YES。因為我們用到了 Q_OBJECT 宏,它需要 MOC 生成一些特定C++程式碼和元數據。這個示例只用到 QtCore 模組的類,所以 find_package 和 target_link_libraries 中只要引入這個就行。
當你興奮異常地編譯和運行本程式時,會發生錯誤:
這個錯誤是因為 MOC 生成的程式碼最終要用回到我們的程式中的,但程式碼文件沒有包含這些程式碼。所以你看上面已經提示你了,解決方法是包含 main.moc。這個文件名和你定義 DemoObject 類的程式碼文件名相同。我剛剛的程式碼文件是 main.cpp,所以它生成的程式碼文件就是 main.moc。
不過,#include 指令一定要寫在 DemoObject 和 TestRecver 類的定義之後,這樣才能正確放入生成的程式碼。# include 放在文件頭部仍然會報錯的,此時,DemoObject 和 TestRecver 類還沒有定義,無法將 main.moc 中的源程式碼插入到 main.cpp 中(會找不到類)。
#include <iostream> #include <qobject.h> #include <qstack.h> class DemoObject : public QObject { // 這個是宏 Q_OBJECT …… }; class TestRecver : public QObject { // 記得用這個宏 Q_OBJECT …… }; #include "main.moc" int main(int argc, char **argv) { …… return 0; }
要是你覺得這樣麻煩,最省事的做法是把類的定義寫在頭文件中,實現程式碼寫在cpp文件中。MOC 默認會處理頭文件,所以不會報錯。
之後再編譯運行,就不會報錯了。
如果用的是 Windows 系統,cmd 默認編碼是 GBK,不是 UTF-8,VS Code 的程式碼默認是 UTF8 的,控制台可能會列印出來亂碼。這裡老周不建議改程式碼文件的編碼,因為說不定你還要把這程式碼放到 Linux 系統中編譯的。在 cmd 中用 CHCP 命令改一下控制台的編碼,再運行程式就行了。
chcp 65001
其實,訊號和槽的函數簽名可以不一致。下面我們再來做一例。這個例子咱們用到 QWidget 類的 windowTitleChanged 訊號。當窗口標題欄中的文本發生改變時會發出這個訊號。它的簽名如下:
void windowTitleChanged(const QString &title);
這個訊號有一個 title 參數,表示修改的窗口標題文本(指新的標題)。而咱們這個例子中用於和它連接的槽函數是無參數的。
private slots: // 這個是槽 void onTitleChanged();
儘管簽名不一致,但可以用。
在這個例子中,只要滑鼠點一下窗口區域,就會修改窗口標題——顯示滑鼠指針在窗口中的坐標。窗口標題被修改,就會發出 windowTitleChanged 訊號,然後,onTitleChanged 也會被調用。
接下來是實現步驟:
1、準備 CMakeLists.txt 文件。
cmake_minimum_required(VERSION 3.0.0) project(demo VERSION 0.1.0) find_package(Qt6 REQUIRED COMPONENTS Core Gui Widgets) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) file(GLOB SRC_LIST ./*.h ./*.cpp) add_executable(demo WIN32 ${SRC_LIST}) target_link_libraries(demo PRIVATE Qt6::Core Qt6::Gui Qt6::Widgets)
這裡老周就偷懶一下。add_executable(demo ….) 是添加頭文件和源碼文件的。老周嫌麻煩,加一個文件又要改一次,於是就用 file 命令搜索項目根目錄下的所有頭文件和 C++ 程式碼文件。然後把這些搜到的文件添加到變數 SRC_LIST 中。在 add_executable 命令中引用 SRC_LIST 變數,就可以自動添加文件了。
2、定義一個自定義窗口類,從 QWidget 類派生。
/* 頭文件 */ #include <QWidget> #include <QMessageBox> #include <QMouseEvent> #include <QString> #include <QApplication> class MyWindow : public QWidget { Q_OBJECT public: MyWindow(QWidget* parent = nullptr); private slots: // 這個是槽 void onTitleChanged(); protected: void mousePressEvent(QMouseEvent *event) override; };
/* 實現程式碼 */ #include "MyWindow.h" /****************************************************************/ MyWindow::MyWindow(QWidget *parent) : QWidget::QWidget(parent) { // 窗口大小 resize(300, 275); connect(this, &MyWindow::windowTitleChanged, this, &MyWindow::onTitleChanged); } void MyWindow::onTitleChanged() { QMessageBox::information(this, "Test", "看,窗口標題變了。", QMessageBox::Ok); } void MyWindow::mousePressEvent(QMouseEvent *event) { auto pt = event->pos(); QString s = QString("滑鼠指針位置:%1, %2") .arg(pt.x()) .arg(pt.y()); setWindowTitle(s); QWidget::mousePressEvent(event); } /*****************************************************************/
重寫了 mousePressEvent 方法,當滑鼠按鈕按下時觸發,先通過事件參數的 pos 函數得到滑鼠坐標,再用 setWindowTitle 方法修改窗口標題。隨即 windowTitleChanged 訊號發出,在槽函數 onTitleChanged 中只是用 QMessgeBox 類彈出了一個提示框。運行結果如下圖所示。
一個訊號可以連接多個槽,一個槽可以與多個訊號建立連接。這外交能力是真的強,來者不拒。下面咱們做一個 SaySomething 訊號連接三個槽的實驗。
#include <QObject> class SomeObj : public QObject { Q_OBJECT public: SomeObj(QObject *parent = nullptr); void SpeakOut(); // 用這個方法發訊號 signals: void SaySomething(); }; class SlotsObj : public QObject { Q_OBJECT public slots: // 來幾個cao void slot1(); void slot2(); void slot3(); };
以上是頭文件。SomeObj 類負責發出訊號,SlotsObj 類負責接收訊號,它有三個 cao:slot1、slot2、slot3。
下面是 SomObj 類的實現程式碼。
SomeObj::SomeObj(QObject *parent) : QObject::QObject(parent) { // 無事干 } void SomeObj::SpeakOut() { emit SaySomething(); }
emit 關鍵字(Qt 特有)發出 SaySomething 訊號。
下面是 SlotsObj 類的實現程式碼。
#include "app.h" #include <iostream> using namespace std; void SlotsObj::slot1() { cout << "第一個cao觸發了" << endl; } void SlotsObj::slot2() { cout << "第二個cao觸發了" << endl; } void SlotsObj::slot3() { cout << "第三個cao觸發了" << endl; }
來,咱們試一試,分別實例化 SomeObj 和 SlotsObj 類,然後讓 SaySomething 訊號依次與 slot1、slot2、slot3 建立連接。這是典型的「一號戰三槽」。
int main(int argc, char** argv) { // 分別實例化 SomeObj sender; SlotsObj recver; // 建立連接 QObject::connect(&sender, &SomeObj::SaySomething, &recver, &SlotsObj::slot1); QObject::connect(&sender, &SomeObj::SaySomething, &recver, &SlotsObj::slot2); QObject::connect(&sender, &SomeObj::SaySomething, &recver, &SlotsObj::slot3); // 發訊號 sender.SpeakOut(); return 0; }
結果表明:訊號一旦發出,三個 slot 都調用了。如下圖:
好了,今天的故事就講到這兒了,欲知後事如何,且待下回分解。