本篇目的:了解如何連接 QT 的 Signal & Slot,讓 UI 元件與 Python 腳本內的物件產生互動
我們在先前的文章中個別提到了建構 UI 與 Python class 的方式。而 GUI 應用程式的運行邏輯是事件驅動程式設計(Event-Driven Programming),需要使用者點擊 UI 中的元件才能觸發對應 function。因此我們需要一個可以連接 UI 元件與對應 function 的方式。
傳統的 callback 連接方式會有高度耦合、無法自由斷開/產生連結等問題。在今天的文章中,我會介紹 QT 的最大優勢 ”Signal(訊號)與 Slot(槽)”的使用方式,讓 GUI 能真實的執行腳本,別再”無魂有體親像稻草人”。
傳統上 UI 元件與 function 連接時,會使用 callback (回呼) 方式。以下面的 Python 的 tkinter code 為例,bark 被當作引數傳入 tk.Button 中,並且使用者按下按鈕後才會呼叫 bark 並印出”汪汪”。
這樣的連接方式有以下幾個缺點
一個按鈕只能對一個功能
無法直接傳入引數給 function
# 省略 import tkinter 與視窗建立
def bark():
print('汪汪')
# bark function 被當作引數傳入 button 中
btn = tk.Button(root,
text='讓狗狗叫',
font=('Microsoft JhengHei',20),
command=bark
)
注意:被當作引數的 function 不可以加上括號,若 function 加上括號會直接執行,就像一隻隨便吠叫的壞狗狗
為了解決 callback 方式的靈活性不佳問題,QT 創立了 ”Signal & Slot” 機制。這個機制的最大特點是 UI 元件與 function 連接方式是透過連接指令,實作方式為 物件.行為.connect (function) 。物件執行某些行為時會發射訊號 (signal),而與其 connect 的物件(slot)接收到訊號後便會執行動作。
在 Python 裡任何東西都是物件,包括當作引數傳入 callback 的沒有括號之 function 也是一個物件
在 day 2 中有提到 PySide6 較 tkinter 靈活,且較容易讓我們達成模組化程式設計,這個原因就是 QT 的 Signal & Slot。Signal & Slot 連接示意圖如下(來源),由這示意圖可以看到 Signal & Slot 的幾個特色:
一個 signal 可以對應多個 slot;當然,一個 slot 也可以對應多個 signal
無論對應 slot 是甚麼、或根本沒有對應 slot,都不影響 signal 發射
connect 可以依需求連接或斷開
一個物件可以同時有 slot 與 signal
從上面的特色可以知道 QT 的連結方式屬於低耦合 (low coupling),其中的物件腳色為只管執行後發射 signal、或是等著對應 signal 來才執行的 slot。這樣的連接方式會讓我們自然的將程式專案分成為負責建構 UI 的腳本、負責運算的腳本、以及負責 connect 的腳本,這就是前面提到的模組化程式設計。

Qt 元件有許多內建訊號,在執行特定動作時便會發射對應訊號,使用這些訊號的方式與讀取物件的 property 相同,為 oblect.對應訊號名稱 。若以 QPushButton 為例,它就有 clicked() 、 pressed() 等訊號,另外像是 QLineEdit 這種沒有明顯按鍵的物件,也有 textChanged()、textEdited() 等訊號可用。Qt 元件訊號的指令如下:
button = QPushButton() # 創立按鍵
line = QLineEdit() # 創立輸入框
# QPushButton 常用訊號
button.clicked # clicked: 按按鍵並放開
button.pressed # pressed: 長按按鍵
# QLineEdit 常用訊號
line.textChanged # textChanged: 當文字變化時發射訊號
line.textEdited # textEdited: 當QLineEdit處於編輯狀態時發射訊號
Qt 內建元件已經將訊號發射時機處理好了,因此不必設定
訊號.emit()
自訂的 class 當然不會有內建訊號可用,因此我們需要在 class 中設定訊號 。可以發射訊號的 class 有以下幾個特點:
需要繼承 QObject,這樣才能在 Qt 體系中發射訊號
訊號是 class 的類別屬性,不可以放在 __init__() 裡面
若要設定該 class 的訊號,在 class 中設定 sig = Signal() 即可。實務上可以依照用途,設定不同的訊號。另外若希望訊號可以帶有引數,需要在設定時先指定引數型別,如下面的 pop_up_sig 。
# 需要先 import QObject, Signal
from PySide6.QtCore import QObject, Signal
class YieldCalculator(QObject): # 繼承 QObject
# 注意 signal 不能放在 __init()__ 內
# signal 一定是類別屬性 (Class Attribute)
finish_sig = Signal() # 完成訊號
pop_up_sig = Signal(str, str) # 彈出式視窗訊號,內含"標題、訊息"
訊號設定好以後,即可在 class 內的 method 中設定發射訊號。以上面兩個訊號為例,pop_up_sig 設定在需要通知使用者訊息時發送、而 finish_sig 設定在正常完成時發送。另外可以看到 pop_up_sig 裡面帶有引數,因此彈出視窗會因情境顯示不同的內容。
def run_analyzer(self, wafer_id:str):
try:
# 這裡是運算邏輯......
except Exception as e:
# 發生錯誤時顯示異常訊息
self.pop_up_sig.emit('發生錯誤', f'{e}')
else:
# 正常結束時顯示恭喜訊息
self.pop_up_sig.emit('已完成', '已完成計算,感謝使用')
self.finish_sig.emit()
在 PySide6 的官方文件中(連結),可以確認該物件對應的訊號。

另外 Qt 中的元件有明確的繼承關係,如下面的 QPushButton 關係圖。也因此 QPushButton 可以使用 QAbstractButton、QWidget 的signal。
在上一段提到”自訂 class 若想發射訊號,需要繼承 QObject ”,我們也可以在官方文件中看到所有會發射訊號的 QT 元件皆繼承了 QObject 。這是因為 QObject 中有一個 metaObject() ,它是建立 ”Signal & Slot” 機制的重要物件 (連結)。

官方文件中的
QPushButton是沒有 signal 的,要到QAbstractButton才有 signal 可以確認
Slot 就是在收到 signal 後,要被觸發的 function。在 "QT 的 Signal & Slot 特點" 中有提到 Signal & Slot 便是為了解決 UI 模組與計算模組過度耦合的產物,因此通常做為 slot 的 function 都與 UI 元件狀態的更動有關 (弱耦合)。
Slot 的設定與一般 function 完全相同,但 slot function 可以加上一個 @Slot() 裝飾器。加上 @Slot() 的好處如下
標示更明確:可以清楚知道這個 function 是一個 slot
執行速度較快:QT 的底層是 C++,若 slot 有加上裝飾器,可以在腳本執行時就預先註冊至 C++ 底層物件,增加執行速度
Slot 的設定範例如下
# 接收引數的signal後,彈出視窗的 function
# @slot()裡面可以標示對應引數
@Slot(str, str)
def pop_up_message(self, title:str, content:str):
QMessageBox.information(self, title, content)
# 接收signal後,更新UI物件狀態的 function
# 不須任何引數
@Slot()
def update_label_wfinish_msg(self):
self.UI.label.setText('已完成計算')
無論是 QT 原生物件的訊號、或是我們自己設定的訊號,連接方式都是 物件.訊號.connect(slot) 。 connect() 內的 function 一樣不可以加上括號。
# 按鈕接到對應 slot
button.clicked.connect(update_label_wfinish_msg)
# 自訂訊號接到對應 slot
# 下面的 finish_sig 就是訊號名稱,與 clicked 相同
cal_obj = YieldCalculator()
cal_obj.finish_sig.connect(update_label_wfinish_msg)
當 signal 帶有引數時,若引數的參數、型別與 slot 設定一致即可直接連接。當訊號發射後,引數會自動帶入到 function 中。
cal_obj = YieldCalculator()
# pop_up_sig.emit(title, msg) 發射的兩個字串
# 會自動帶入 pop_up_message
cal_obj.pop_up_sig.connect(self.pop_up_message)
當 function 被當作引數傳入 connect 時,無法直接為 function 設定引數 (function 加了括號會直接觸發)。因此若我們需要為 function 設定 signal 外的引數,必須使用 lambda 修改 function。使用 lambda 可以預先設定 function 的引數但不觸發 function ,當訊號發射時才傳入引數並觸發 function。
# 訊號發射時,才傳入 wafer ID 並且觸發 function
wafer_id = 'a123456779'
button.clicked.connect(lambda :cal_obj.run_analyzer(wafer_id))
不可以寫成
lambda wafer_id: function(wafer_id)。因為clicked是包含 bool 的訊號,這樣寫的話wafer_id會被clicked的布林值覆蓋掉
接下來要進到本專欄的第四部分 ”使用者輸入資料的管理”,內容包含使用者輸入介面的設定、內容驗證、以及儲存輸入資料的方法。下一篇會說明如何讓程式擁有持久的記憶。