淺談Qt事件子系統——以可拖動的通用Widget為例子
這一篇文章是一個通過實現可拖動的通用Widget為引子簡單介紹一下我們的事件對象子系統的事情
代碼和所有的文檔
1:Qt側的API介紹和說明
? 這個是每一個小項目的慣例,我會介紹大部分Qt程序中使用到的細節,比如說,本項目當中就是eventFilter和事件處理隊列的Qt編程技術。這個也是我們編程Qt的一個重點。
? 本項目打算介紹的是——Qt的事件處理機制,以及對象事件監聽機制。如果您很熟悉了,可以考慮直接跳過本篇。
所以,Qt的事件處理機制
? 我喜歡寫一個東西的時候,直接說明我要寫什么。很簡單。
- Qt是如何實現事件處理的?技術的要點有哪些?
- 我們作為開發人員,重點關心的接口有哪些?
- 如何監聽,甚至是攔截其他對象的事件處理呢【這個是本項目的實現要點】
Qt是如何實現事件處理的
? 毫無疑問,事件驅動處理是GUI的一個命根子,我們的GUI接受事件,展示對應的變化;同時我們的用戶跟GUI交互,將用戶的意圖傳遞給我們的后臺。這就是GUI的一個最大的要點。
所以,我們關心事件驅動的對象有哪些呢?
? 我們的事件隊列的處理主要依賴一個重要的概念,叫“事件循環”(Event Loop)。事件循環是一個持續運行的循環,它不斷檢測、分發并處理各種事件,包括用戶輸入(如鍵盤、鼠標事件)、系統消息以及自定義事件。主要過程大致如下:
- 事件產生:當用戶操作或系統狀態變化時,Qt會生成一個對應的事件對象(QEvent的子類實例)。
- 事件隊列:事件對象被放入事件隊列中等待處理。
- 事件分發:事件循環依次從隊列中取出事件,分發給相應的對象處理。
- 事件響應:目標對象在其事件處理函數中對事件作出響應,更新界面或執行其他邏輯。
? 我們分析事件,也是主要抓手這四個部分進行學習。
? 我們事件隊列處理的開始,在QApplication::exec上,調用這個,我們的全應用程序的事件隊列就開始工作了。下面,我們來看看一些API函數:
? 對于框架層次,你需要知道這員工的一些函數:
QCoreApplication::notify
Qt的事件分發機制主要依賴于QCoreApplication類中的notify()
方法。每當一個事件需要傳遞給某個QObject對象時,都會經過該方法。其主要職責是:
- 統一調度:集中管理所有事件的發送和轉發。
- 異常處理:對事件處理過程中可能出現的異常進行捕獲和處理,保證整個事件循環的穩定性。
- 事件過濾:在正式分發事件前,提供預處理的機會(見下文的事件過濾機制)。
? 我們一般不會跑去重寫notify(至少筆者沒見過特殊到要重寫notify的)
事件隊列與異步處理
Qt支持將事件異步投遞到目標對象中,通過QCoreApplication::postEvent()
方法將事件放入事件隊列,等待事件循環處理。這種方式使得事件發送和處理解耦,避免在調用過程中產生阻塞,提升了系統響應能力。
與之對應的同步事件發送方式為QCoreApplication::sendEvent()
,該方法直接調用目標對象的事件處理函數,在調用者線程中立刻執行。這種方式適用于對時序和結果有嚴格要求的情況,但需注意同步調用可能會引發遞歸調用或死鎖問題。
事件循環(Event Loop)
每個Qt應用程序通常都有一個主事件循環,通過調用QCoreApplication::exec()
啟動。事件循環在不斷地檢測、分發和處理事件的同時,也會處理定時器、信號等異步任務。
- 阻塞與非阻塞:事件循環既能阻塞等待事件,也能在無事件時進入休眠狀態,保證資源利用率。
- 嵌套事件循環:在某些對話框或模態窗口中,Qt會啟動嵌套事件循環,保證界面依然響應用戶操作。需要注意的是,嵌套循環可能會帶來事件處理順序和狀態管理方面的復雜性。
討論事件的類型
? 事件事件,啥事件呢?這就是事件的類型。Qt中的所有事件都以QEvent為基類,其派生類涵蓋了豐富的事件類型,如:
- 用戶輸入事件:QMouseEvent、QKeyEvent、QWheelEvent等。
- 窗口系統事件:QResizeEvent、QCloseEvent等。
- 自定義事件:開發人員可以繼承QEvent,定義屬于自己的事件類型,實現特定業務邏輯的事件傳遞。
? 這些事件呢,就在我們后面的開發接口上埋下了伏筆,所以,讓我們馬上進入第二個部分
開發人員關心的關鍵接口
? 我們的一個大頭中的大頭,是QObject的一個重要的函數,或者說,QT元對象系統的一個重要的特化于事件處理的核心,就是我們的一個虛函數event(QEvent *event)
,這是所有事件最終處理的入口函數。每個QObject子類都可以重寫這個函數,根據事件類型作出不同的響應。
? 我們需要注意的是——event()函數并不直接處理事件,而是將這些事件對象按照它們不同的類型,**分發給不同的事件處理器(event handler)。**重寫一個event事件,我們往往可能是要特化一部分操作。當然,往往我們的功能是——需要在原先擁有事件處理的基礎上,進一步擴展通用事件處理的能力,比如說要做薄記,比如說統一的處理,這個時候重寫event就是一個很明智的選擇了!
? 例如,在自定義控件中,可以重寫event()
函數,對特定事件(如鼠標點擊、鍵盤輸入)進行處理,從而實現自定義行為。當然!這只是一個例子,實際上沒人這樣寫!我們會有專門的函數來處理,這是我們下面會提到的議題!
bool MyWidget::event(QEvent *event) {if (event->type() == QEvent::MouseButtonPress) {my_process_of_mouseEvent(event);return true;}// 調用基類的事件處理,保證其他事件正常分發return QWidget::event(event);
}
? 你需要注意的是——請看,這里函數返回的是一個Bool值,這個bool值的含義是什么呢?答案是——當你返回了true的時候,就說明你的事件已經處理結束,Qt 將會檢查這個函數的返回值,如果是true,說明這個事件已經被處理完成,會轉而取事件隊列的下一個進行預取,如果返回的是false,那么會繼續把這個事件傳遞給其他的組件讓他們接著處理
專用事件處理函數
為了簡化事件處理,Qt為常見的事件提供了專用的虛函數,舉個例子看看:
- mousePressEvent(QMouseEvent *event):處理鼠標按下事件。
- keyPressEvent(QKeyEvent *event):處理鍵盤按下事件。
- resizeEvent(QResizeEvent *event):處理窗口尺寸變化事件。
這些函數通常在對應的控件類中重寫,目的是對特定事件進行精細控制。需要注意的是,如果同時重寫了event()函數和專用事件函數,則通常應保證事件在其中一個函數中得到完整處理,避免重復調用。這些在源碼中的表先就是:判斷事件的Type,然后依據事件的類型轉發給對應的回調函數,就是這樣簡單!
事件發送接口
QCoreApplication::sendEvent()
同步事件發送接口sendEvent()
直接調用目標對象的事件處理函數,并返回處理結果。這種方式適用于需要立即獲得事件處理結果的情況。但由于它是在當前線程中執行的,因此要注意防止在事件處理過程中產生阻塞或遞歸調用。
QCoreApplication::postEvent()
異步事件投遞接口postEvent()
將事件放入目標對象所在線程的事件隊列中,由事件循環在合適的時機進行分發。常見的應用場景包括跨線程通信、延遲處理等。由于postEvent()并不會立即調用事件處理函數,開發人員在設計邏輯時應考慮事件延時帶來的影響。
自定義事件
在許多場景中,內置的事件類型無法滿足特定需求,開發者可以通過繼承QEvent來定義自定義事件。常見步驟如下:
- 定義新的事件類型(通常選用Qt::User 類型及之后的值)。
- 創建自定義事件類,包含特定數據和處理邏輯。
- 通過postEvent()或sendEvent()將自定義事件投遞到目標對象中。
- 在目標對象的event()函數中進行識別和處理。
這種方式提供了極大的擴展性,使得復雜的應用邏輯可以通過事件機制進行模塊化解耦。
事件過濾器
Qt還提供了事件過濾器機制,使得開發人員可以在事件傳遞前攔截、監控或修改事件。關鍵接口是QObject的installEventFilter(QObject *filterObj)
和eventFilter(QObject *watched, QEvent *event)
函數。通過在某個對象上安裝事件過濾器,過濾器對象可以提前捕獲并處理目標對象的事件。
例如,在全局日志記錄、調試或臨時修改事件響應邏輯時,事件過濾器是一種非常有效的手段。下列代碼展示了如何為一個窗口安裝事件過濾器:
// 在構造函數中安裝過濾器
myWidget->installEventFilter(this);// 重寫eventFilter函數
bool MyClass::eventFilter(QObject *watched, QEvent *event) {if (watched == myWidget && event->type() == QEvent::KeyPress) {// 對鍵盤事件進行特殊處理qDebug() << "捕獲到鍵盤事件";return true; // 返回true表示事件已經被處理,不再傳遞}// 調用基類實現,確保其他事件可以正常傳遞return QObject::eventFilter(watched, event);
}
通過上述接口,開發者可以在不改動原有對象代碼的前提下,實現對事件的監聽和攔截。
如何監聽和攔截其他對象的事件(本次文檔的重點)
在實際開發中,經常需要對已有控件或對象的事件進行監聽、修改甚至攔截。Qt提供了非常方便的事件過濾機制,使得這一需求得以高效實現。
? 事件過濾器的核心在于:每個QObject對象都有一個內部列表,用于存儲安裝到該對象上的過濾器。當事件到達目標對象前,系統會先依次調用每個過濾器對象的eventFilter()
方法。
- 如果某個過濾器返回
true
,表示該事件已經被處理,后續的過濾器和目標對象本身將不再接收到此事件。 - 如果所有過濾器都返回
false
,事件則繼續傳遞給目標對象進行正常處理。
這種機制使得開發人員可以在不侵入原對象邏輯的情況下,對事件進行預處理,甚至阻斷事件傳遞。
安裝和使用事件過濾器
要實現對其他對象事件的監聽和攔截,主要步驟如下:
- 編寫過濾器類
通常通過繼承QObject并重寫eventFilter()
方法,編寫自定義過濾器類。在該方法中,根據watched參數判斷當前捕獲的事件屬于哪個對象,并根據事件類型進行處理。 - 安裝過濾器
在需要監控的對象上調用installEventFilter()
方法,將自定義過濾器對象注冊到該對象上。一個對象可以安裝多個過濾器,調用順序與安裝順序有關。 - 事件攔截與傳遞控制
在eventFilter()中,當檢測到感興趣的事件后,可以選擇返回true(表示事件已處理,不繼續傳遞),也可以返回false(讓目標對象繼續處理)。
例如,假設我們需要攔截某個QLineEdit控件中的鼠標事件,可以這樣實現:
class MyEventFilter : public QObject {Q_OBJECT
protected:bool eventFilter(QObject *watched, QEvent *event) override {if (watched->inherits("QLineEdit")) {if (event->type() == QEvent::MouseButtonDblClick) {// 攔截雙擊事件qDebug() << "QLineEdit雙擊事件被攔截";return true; // 阻止事件繼續傳遞}}// 其他情況繼續傳遞事件return QObject::eventFilter(watched, event);}
};
在程序初始化時,為目標對象安裝過濾器:
QLineEdit *edit = new QLineEdit(this);
MyEventFilter *filter = new MyEventFilter();
edit->installEventFilter(filter);
這樣,當用戶對該QLineEdit進行雙擊操作時,MyEventFilter將捕獲并攔截該事件,而QLineEdit本身不會收到雙擊事件。
動態監聽與跨對象事件監控
有時,我們不僅需要攔截單個對象的事件,還需要在全局范圍內對多個對象進行統一監控。例如,在大型應用中調試或記錄日志時,可以為整個應用安裝一個全局事件過濾器。通常的做法是將過濾器安裝在QCoreApplication對象上,這樣所有事件都會先經過該過濾器的檢測。
class GlobalEventFilter : public QObject {Q_OBJECT
protected:bool eventFilter(QObject *watched, QEvent *event) override {// 可以對所有對象和事件進行日志記錄或特定處理qDebug() << "全局過濾器捕獲到事件:" << event->type() << "來自對象:" << watched;// 根據需求選擇是否攔截或繼續傳遞return QObject::eventFilter(watched, event);}
};// 在main()函數中安裝全局過濾器
int main(int argc, char *argv[]) {QApplication app(argc, argv);GlobalEventFilter *globalFilter = new GlobalEventFilter();app.installEventFilter(globalFilter);// 后續創建的所有對象的事件均會經過globalFilter的檢測// …return app.exec();
}
這種全局過濾器的使用,尤其適用于調試階段,對復雜交互過程中的事件進行全面記錄和分析,或在某些特殊情況下統一攔截某類事件。
注意事項與最佳實踐
? 當然這里說一些重點的事情。在使用事件過濾器時,還需要注意以下幾點:
- 性能問題:全局事件過濾器會處理所有事件,因此在實現中要避免執行過于耗時的操作,防止影響界面響應。
- 返回值控制:返回
true
表示事件被完全攔截,可能導致目標對象無法得到響應;返回false
則允許事件繼續傳遞。開發人員需要仔細判斷實際需求。 - 層次關系:如果一個對象安裝了多個事件過濾器,事件會按照安裝順序依次經過各過濾器,過濾器之間可能存在相互影響,因此在設計時要考慮好先后次序。
- 安全釋放:當過濾器對象不再需要時,必須及時調用
removeEventFilter()
方法,或者在對象銷毀時自動移除,避免懸掛指針問題。
本項目的實現的重要文檔思路
? 注意,這個文檔可能不會跟我們的源碼有一定保證的同步,只是提供一種參考!
如何讓Widgets跟隨鼠標移動呢
? 一種辦法,是讓我們創建一個SubWidget,這個SubWidget負責一對一的維護一個目標控件。比如說一個按鈕,或者是任何一個其他的控件,當我們的的目標事件傳遞到這個控件的時候,會優先的投射到我們的這個widgets上來。通過調用控件的 installEventFilter()
方法,將當前對象(this)作為過濾器安裝到 holding_widget
上。安裝事件過濾器后,該控件產生的所有事件都會首先傳遞到當前對象的 eventFilter()
方法中進行預處理。如果在 eventFilter()
中返回了 true
,那么該事件就不會繼續傳遞到控件自身的事件處理函數中;如果返回 false
,則事件會繼續傳遞。
? 這樣,我們就可以寫自己的一個eventFilter來控制目標widget的行為。而不需要重載我們的對象添加一個Movable或者是其他任何的屬性,這樣看就會非常的方便。
? 下面我們要做的就是準備處理我們的move行為
bool CCMovableWidget::eventFilter(QObject *watched, QEvent *event) {if (!holding_widget || watched != holding_widget) {return false;}QMouseEvent *mouseEvent = dynamic_cast<QMouseEvent *>(event);if (!mouseEvent) {return false;}// here we handle the mouse events// this will promise the future extensionsswitch (event->type()) {case QEvent::MouseButtonPress:handling_mousePressEvent(mouseEvent);break;case QEvent::MouseButtonRelease:handling_mouseReleaseEvent(mouseEvent);break;case QEvent::MouseMove:handling_mouseMoveEvent(mouseEvent);break;default:break;}// back the default behaviorreturn QObject::eventFilter(watched, event);
}
? 這是筆者的處理方式,依次對這個事件的MouseButtonPress,MouseButtonRelease和MouseMove進行了傳遞。這也就意味著這里它的事件就傳遞進來了進行了處理,當然處理結束后,我們還希望讓它做進一步的處理,所以我們讓他進一步維護其默認的實現。不要更改控件原來的行為。
剩下的內容
? 剩下的內容就沒什么新鮮的了,這里就讓AI幫我代勞吧!
// widget is pressed by the mouse, so this means we shell start our moving
void CCMovableWidget::handling_mousePressEvent(QMouseEvent *event) {qDebug() << "Mouse pressed";if (!holding_widget)return; // no widget to hold, reject processif (accept_buttons.size() > 0 && !accept_buttons.contains(event->button()))return; // the button is not acceptable, reject processwidget_state.lastPoint = event->pos(); // memorize the last pointwidget_state.pressed = true;
}
handling_mousePressEvent(QMouseEvent *event)
是用戶按下鼠標時觸發的事件處理函數,是整個拖動行為的起點。當鼠標點擊到控件上時,首先通過日志輸出來表明事件已經被捕獲。接著,程序判斷 holding_widget
是否存在,如果為空,則說明當前沒有設置任何需要被移動的目標控件,因此直接返回,放棄此次操作。隨后,如果開發者為這個類設定了一個特定可接受的鼠標按鈕列表 accept_buttons
,而當前觸發事件的按鈕不在該列表中,也同樣視為無效事件,拒絕處理。只有當這些條件都滿足后,事件才被視為有效操作。此時程序記錄當前鼠標點擊的位置,保存在 widget_state.lastPoint
中,用于后續計算移動偏移量,并將 widget_state.pressed
標志設為 true
,表明控件已被點擊按住,準備進行拖動。
void CCMovableWidget::handling_mouseReleaseEvent(QMouseEvent *event) {qDebug() << "Mouse released";if (!holding_widget)return; // no widget to hold, reject processwidget_state.pressed = false;
}
handling_mouseReleaseEvent(QMouseEvent *event)
則是用戶釋放鼠標按鈕時調用的函數,它的作用相對簡單。同樣以日志開始,表示捕獲了釋放事件。隨后依舊先檢查是否存在 holding_widget
,如果當前并未綁定任何控件,則此次釋放事件無需處理。若控件存在,則將 widget_state.pressed
設為 false
,這一行為本質上是標記當前已結束拖動操作,后續的鼠標移動將不再引起控件的位置變化。
void CCMovableWidget::handling_mouseMoveEvent(QMouseEvent *event) {qDebug() << "Mouse moved";if (!holding_widget)return; // no widget to hold, reject processif (!widget_state.pressed)return; // the widget is not pressed, reject process// calculate the offsetint offsetX = event->pos().x() - widget_state.lastPoint.x();int offsetY = event->pos().y() - widget_state.lastPoint.y();// calculate the new positionint x = holding_widget->x() + offsetX;int y = holding_widget->y() + offsetY;// check if the widget should be in the parentif (widget_state.inParent) {QWidget *w = dynamic_cast<QWidget *>(holding_widget->parent());if (w && (sizeIsOutlier(QPoint(x, y), w) || positionIsOutlier(QPoint(x, y)))) {return;}// move the widgetholding_widget->move(x, y);}
}
handling_mouseMoveEvent(QMouseEvent *event)
是核心函數,它在用戶拖動鼠標時不斷被調用,從而持續地更新控件位置,完成“隨鼠標移動”的視覺效果。函數首先打印出“鼠標移動”的日志,確認事件的發生。緊接著,它做出兩個防御性檢查。第一,是否存在 holding_widget
,否則自然不該響應移動。第二,判斷是否存在 widget_state.pressed
為真的狀態,這是防止控件在未被按住的情況下跟隨鼠標移動,確保只有在“鼠標按下后并且未釋放”的情形下才進入后續邏輯。接下來,程序通過當前位置與上次記錄的鼠標按下點 lastPoint
計算出一個偏移量 offsetX
與 offsetY
,這是拖動過程中控件應該移動的距離。然后,根據當前控件的原始位置加上偏移量,計算出控件新的坐標 x
和 y
。
但并非所有位置更新都是合理的,因此函數中還加入了一道邏輯判斷,即如果當前設置了 widget_state.inParent
為真(意味著控件應保持在其父組件內),就需要判斷新位置是否越界。這里調用了 sizeIsOutlier(QPoint(x, y), w)
與 positionIsOutlier(QPoint(x, y))
兩個函數,前者大概是判斷控件在給定位置上是否尺寸越界,后者則可能是判斷位置是否超出允許的邊界。這一檢查使得控件不能被拖出其父容器或顯示區域之外。如果這兩個函數判定位置無效,則不執行移動操作,函數直接返回。
最后,如果所有條件都滿足,程序調用 holding_widget->move(x, y)
將控件平滑地移動到新位置上。這一行為便是“拖動”體驗的實現者,控件就隨著鼠標游走而流暢移動。