0.前言
在PySide6中,有且僅有一個來處理GUI顯示的線程,如果我們一些業(yè)務(wù)需要大量運(yùn)算,使得運(yùn)算占用GUI線程時間太久,這樣會導(dǎo)致主窗口不能響應(yīng)用戶操作,導(dǎo)致應(yīng)用程序看起來像假死了一樣,這時候我們需要使用一個新的線程來運(yùn)算,這樣GUI線程就不會被大量運(yùn)算占用,下面來介紹和學(xué)習(xí)QThread。
1.一個簡單的處理方法
大量運(yùn)算的場景也許不是很常見,但是占用時間的場景一定很常見,比如等待網(wǎng)絡(luò),比如大量數(shù)據(jù)填充至QListWidget中,下面首先放一個模擬場景,這種代碼就會產(chǎn)生GUI假死(凍結(jié))問題。
import sys
from PySide6.QtCore import *
from PySide6.QtGui import *
from PySide6.QtWidgets import *
class MyWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("QThread學(xué)習(xí)")
self.resize(800, 600)
self.setup_ui()
self.setup_connect()
def setup_ui(self):
self.mylistwidget = QListWidget(self)
self.mylistwidget.resize(500, 500)
self.mylistwidget.move(20, 20)
self.additem_button = QPushButton(self)
self.additem_button.resize(150, 30)
self.additem_button.setText("填充QListWidget")
self.additem_button.move(530, 20)
def setup_connect(self):
self.additem_button.clicked.connect(self.additem)
def additem(self):
for i in range(5000000):
item = QListWidgetItem()
item.setText(f"第{i}項Item")
item.setIcon(QPixmap())
self.mylistwidget.addItem(item)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MyWindow()
window.show()
app.exec()
代碼可以直接運(yùn)行,運(yùn)行后點擊按鈕,我們會發(fā)現(xiàn)GUI界面立刻卡死了。
其中additem函數(shù)產(chǎn)生了大量的運(yùn)算,導(dǎo)致GUI界面會假死,其實這種場景我們未必需要QThread出場,有個簡單的方法來處理這種情況。那就是使用QApplication.processEvents()函數(shù),將這個函數(shù)插入在循環(huán)的合適位置,來告訴Qt這時候該處理GUI產(chǎn)生的事件(比如用戶點擊、鼠標(biāo)懸浮),這樣GUI會在每次循環(huán)的時候來處理用戶對GUI的操作,從而達(dá)到不會卡死主界面的效果。
def additem(self):
for i in range(5000000):
item = QListWidgetItem()
item.setText(f"第{i}項Item")
item.setIcon(QPixmap())
self.mylistwidget.addItem(item)
QApplication.processEvents()
這個函數(shù)很好理解,因為GUI是事件驅(qū)動的,在合適的時機(jī)讓GUI去處理事件,自然就不會在運(yùn)算的時候卡死界面,但是QApplication.processEvents()也不是萬能的,這個僅限每次循環(huán)用時都比較短的時候才好用,假如我們每次循環(huán)需要1秒鐘呢?如果更長呢?使用sleep()函數(shù)來模擬更長時間的單次循環(huán)。
...............
from time import sleep
...............
...............
def additem(self):
for i in range(5000000):
item = QListWidgetItem()
item.setText(f"第{i}項Item")
item.setIcon(QPixmap())
self.mylistwidget.addItem(item)
QApplication.processEvents()
sleep(0.3)
...............
程序每次循環(huán)僅僅只用了0.3S,但是這時候我們看主界面,感覺會非常的卡,因為即使使用了QApplication.processEvents()函數(shù),也需要0.3S才能處理一次來自GUI的事件,這時候就需要QThread了,把運(yùn)算單獨放在一個線程里面,讓GUI正常響應(yīng)事件。
2.QThread簡介
首先上官方文檔!
遇事不決先看文檔,當(dāng)然一上來看文檔估計也看不太明白,尤其是官方文檔對于例子的代碼也不是很全,而且英語如果不好的話學(xué)起來會很困難。
因為是簡易教程,更高級的用法就不學(xué)習(xí)了,這里主要講,如何創(chuàng)建一個新的線程用于運(yùn)算,而且在主線程(GUI線程)和線程里傳遞數(shù)據(jù)。
2.1 線程之間數(shù)據(jù)的傳遞
首先確定一點,線程之間的數(shù)據(jù)傳遞是靠信號傳遞的,把主線程的控件產(chǎn)生的信號鏈接到線程的函數(shù)上就可以,同時信號是可以傳遞參數(shù)的,通過信號傳遞參數(shù)來傳遞我們自己的數(shù)據(jù),反過來也可以同樣讓主線程接收來自線程的信號同時傳遞參數(shù),并且對數(shù)據(jù)進(jìn)行處理。
2.2 使用QThread
想正確的使用QThread,并不建議像網(wǎng)絡(luò)上常見的教程那樣直接使用一個類來繼承QThread類,并且重寫它的方法,這種使用方法已經(jīng)被Qt的作者嚴(yán)肅批評過了,雖然這么用起來似乎也沒什么問題,但是果然我們還是按照官方的要求來比較好。
首先我們需要把運(yùn)算函數(shù)單獨封裝成一個類,這個類要繼承QObject,并且自定義一個新的信號用于接受來自主線程的參數(shù)。
先刪除MyWindow類里的additem函數(shù),同時新建一個類,同時寫好運(yùn)算函數(shù)和定義信號。
注意,信號的聲明一定要做成類變量,不能放在其他地方。
這里我們讓線程來返回range出來的結(jié)果,并且每循環(huán)一次就返回一次結(jié)果。
...............
class WorkThread(QObject):
range_requested = Signal(int) # 括號里是傳出的參數(shù)的類型
def __init__(self):
super().__init__()
def range_proc(self, args): # args即為從主線程接收的參數(shù)
print(args)
for i in range(5000):
self.range_requested.emit(i) # 發(fā)射信號
sleep(0.5)
...............
這樣,我們的線程類就包裝好了,接下來在主線程中把線程類實例化起來,并且通過線程運(yùn)行,終于QThread要出場了。刪除setup_connect()函數(shù)。
...............
class MyWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("QThread學(xué)習(xí)")
self.resize(800, 600)
self.setup_ui()
self.setup_thread()
...............
def setup_thread(self):
self.thread1 = QThread(self) # 創(chuàng)建一個線程
self.range_thread = WorkThread() # 實例化線程類
self.range_thread.moveToThread(self.thread1) # 將類移動到線程中運(yùn)行
# 線程數(shù)據(jù)傳回信號,用add_item函數(shù)處理
self.range_thread.range_requested.connect(self.add_item)
self.additem_button.clicked.connect(self.start_thread)
self.additem_button.clicked.connect(self.range_thread.range_proc) # 連接到線程類的函數(shù)
def start_thread(self):
self.thread1.start()
def add_item(self, requested_number): # 線程傳回參數(shù)
text = f"第{requested_number}項————Item"
item = QListWidgetItem()
item.setIcon(QPixmap())
item.setText(text)
self.mylistwidget.addItem(item)
...............
先點擊運(yùn)行,讓我們跑一下代碼看看效果,可以看出QListWidget中緩慢填充數(shù)據(jù),并且沒有卡死GUI界面。
2.3 代碼詳解
可能比較熟悉Qt的人已經(jīng)看明白了,但是還是打算講解一下代碼功能和實際運(yùn)行中遇到的坑。
self.additem_button.clicked.connect(self.range_thread.range_proc)
把線程類的函數(shù)連接到主線程中按鈕的點擊信號上,這樣點下按鈕時,就會在線程中運(yùn)行該函數(shù)。同時,會將該信號參數(shù)傳遞給該函數(shù),這時候就能解釋為什么點擊按鈕之后,會打印出false了,因為clicked信號本身就會傳遞一個參數(shù)出去,這個參數(shù)被range_thread.range_proc接收了,這里不打算詳細(xì)說clicked傳遞的是什么參數(shù),但是他傳遞出去的值就是false,有興趣的讀者可以自行查看官方文檔。
self.range_thread.range_requested.connect(self.add_item)
range_requested在線程類WorkThread里面被定義成了一個信號Signal(int),在我們實例化線程類WorkThread以后,需要在主線程中把這個信號連接到一個主線程函數(shù)中,用于處理線程傳回來的數(shù)據(jù),同時Signal(int)確定了信號傳遞的數(shù)據(jù)類型是int。
self.range_requested.emit(i)
該語句表示發(fā)射信號,并且攜帶參數(shù)i,根據(jù)前面的語句詳解,可以得知,這個信號由主線程中的add_item函數(shù)處理,同時參數(shù)會傳給該函數(shù)。該函數(shù)在定義的時候要攜帶參數(shù),例如:def add_item(self, requested_number):,實際上requested_number得到的數(shù)據(jù)就是i傳遞來的。
2.4 Q&A
Q:信號可以隨便傳遞任何類型的參數(shù)嗎?
A:當(dāng)然不是,但是常見的類型都可以傳遞。
Q:主線程的信號一定要連接線程類的函數(shù)嗎?可以用其他主線程的函數(shù)中轉(zhuǎn)嗎?
A:一定要,不可以。主線程的信號不能連接到主線程的函數(shù),即使這個函數(shù)僅有一行運(yùn)行線程類函數(shù)的語句。這樣做的話線程類的函數(shù)不會運(yùn)行在線程中。
Q:在之前的QThread例子中,如果把range改成5000000,并且不sleep(0.5),即使用了QThread,也會卡死主界面?
A:QApplication.processEvents()和QThread要靈活運(yùn)用,要分辨清楚究竟是GUI的刷新導(dǎo)致事件無法響應(yīng),還是大量運(yùn)算導(dǎo)致事件無法響應(yīng),從而選擇對應(yīng)的方法。
Q:如何從主線程向線程傳遞參數(shù)和數(shù)據(jù)?
A:很簡單,將線程傳遞到主線程的方法反過來就可以,在主線程中定義一個信號,然后在合適的時機(jī)發(fā)射信號并攜帶參數(shù),從而讓線程接收信號和數(shù)據(jù)。
3.示例代碼
import sys
from time import sleep
from PySide6.QtCore import *
from PySide6.QtGui import *
from PySide6.QtWidgets import *
class MyWindow(QMainWindow):
range_number = Signal(int)
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("QThread學(xué)習(xí)")
self.resize(800, 600)
self.setup_ui()
self.setup_thread()
def setup_ui(self):
self.mylistwidget = QListWidget(self)
self.mylistwidget.resize(500, 500)
self.mylistwidget.move(20, 20)
self.additem_button = QPushButton(self)
self.additem_button.resize(150, 30)
self.additem_button.setText("填充QListWidget")
self.additem_button.move(530, 20)
def setup_thread(self):
self.thread1 = QThread(self) # 創(chuàng)建一個線程
self.range_thread = WorkThread() # 實例化線程類
self.range_thread.moveToThread(self.thread1) # 將類移動到線程中運(yùn)行
# 線程數(shù)據(jù)傳回信號,用add_item函數(shù)處理
self.range_thread.range_requested.connect(self.add_item)
self.additem_button.clicked.connect(self.start_thread)
self.range_number.connect(self.range_thread.range_proc)
# self.additem_button.clicked.connect(self.range_thread.range_proc) # 連接到線程類的函數(shù)
def start_thread(self):
self.thread1.start()
range_number = 30
self.range_number.emit(range_number) # 發(fā)射信號讓線程接收需要range多少
def add_item(self, requested_number): # 線程傳回參數(shù)
text = f"第{requested_number}項————Item"
item = QListWidgetItem()
item.setIcon(QPixmap())
item.setText(text)
self.mylistwidget.addItem(item)
class WorkThread(QObject):
range_requested = Signal(int) # 括號里是傳出的參數(shù)的類型
def __init__(self):
super().__init__()
def range_proc(self, number): # number即為從主線程接收的參數(shù)
print(number)
for i in range(number):
self.range_requested.emit(i) # 發(fā)射信號
sleep(0.5)
if __name__ == "__main__":
app = QApplication(sys.argv)
window = MyWindow()
window.show()
app.exec()
該代碼range參數(shù)從主線程獲取,線程運(yùn)算range后傳回主線程,主線程負(fù)責(zé)處理運(yùn)算后的數(shù)據(jù)結(jié)果。