0%

PyQt5多线程

开完一坑又一坑,这应该是GUI系列的完结稿了。时至今日,很多方法和函数已经忘记,一部分整理到了仓库,用时查阅;一部分学会了查官方文档。比如布局中的addSpacingaddStretch填充,以及不同空间该如何Qt.Align,需要大量的经验。用过一次就会知道功能,所以,官方文档永远的神。

背景

在之前的制作倒车雷达中,已经说过了多线程的应用背景。那会儿是项目驱动,现在来彻底了结。一个软件中,如果制作一个计数功能。那么主进程进入计数函数,没有进程负责界面的显示,就会导致软件卡死。如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import sys, time
from PyQt5.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QApplication,
QPushButton)


class mainwindow(QMainWindow):
def __init__(self):
super(mainwindow, self).__init__()

layout = QHBoxLayout()
w = QWidget()
w.setLayout(layout)
self.setCentralWidget(w)

btn = QPushButton("点击")
layout.addWidget(btn)
btn.clicked.connect(self.count)

def count(self):
num = 1
while num <= 12000:
time.sleep(0.1)
num += 1


if __name__ == '__main__':
app = QApplication([])
m = mainwindow()
m.show()
sys.exit(app.exec())

需要额外注意的是,在Qt的开发中,一定不能使用time.sleep()这种方法。Qt是框架是基于事件循环的,time.sleep()因为它会阻塞事件的循环,导致窗口冻结,直接跳到sleep后的程序,从而阻止了GUI的重新绘制,并没有中间的过程。所以在Qt中,可以考虑使用多线程来解决这些问题,如:分为显示线程和工作线程,显示事件负责GUI的显示,工作事件负责刷新物体的位置。

QTimer()

这是一个实现多线程的最简单的工具,通常用于周期性检测,比sleep()这种强行停止窗口事件的循环要好上很多。它有常用的两个函数:

  • start(int n),表示n毫秒后,定时器会发出信号。我们只需要把发出的信号绑定到对应函数就可以工作了
  • timeout就是发出的信号,将它绑定到槽函数上
  • stop,停止计时器

我们来写一个最简单却常用的功能。当软件卡顿时,一般会出现转圈圈的图标,表示正在加载。假设就让它转5秒,5秒后通过另一个线程让它消失,不影响主窗口的显示。如果直接time.sleep(5),那么圆圈不会转动的,界面会直接到在这5秒内静止,然后一步跳到5秒后。所以以下程序是错误的:

1
2
3
self.move()    # 开始转动
time.sleep(5) # 转 5 秒
self.stop() # 停止转动

Qt是循环的框架,所以应该考虑用 QTimer 代替 sleep 正确姿势如下,图片文件自己去下载一个吧:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
import sys, time
from PyQt5.QtWidgets import (QApplication, QLabel, QMainWindow, QWidget,
QHBoxLayout)
from PyQt5.QtGui import QMovie
from PyQt5.QtCore import Qt, QTimer


class MainWidget(QWidget):
def __init__(self, parent=None):
super(MainWidget, self).__init__(parent)
self.setAttribute(Qt.WA_StyledBackground, True)
self.setStyleSheet('background-color: white')

self.label = QLabel()
# 居中对齐
self.label.setAlignment(Qt.AlignCenter | Qt.AlignVCenter)
self.loading = QMovie("images/loading.gif")
self.label.setMovie(self.loading)
self.layout = QHBoxLayout()
self.layout.addWidget(self.label)
self.loading_start()
self.setLayout(self.layout)

# 定时器
self.t = QTimer()
self.t.start(5000)
self.t.timeout.connect(self.loading_end)

def loading_end(self):
self.t.stop()
self.loading.stop()
time.sleep(0.2)
self.label.clear()

def loading_start(self):
self.loading.start()
# self.loading.stop()


class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()

W = MainWidget()
self.setCentralWidget(W)


if __name__ == '__main__':
app = QApplication([])
window = MainWindow()
window.show()
sys.exit(app.exec())

效果展示,你看多好,嘿嘿:

QThread()

这个实现多线程就比较强大了,可以完成更为复杂的业务。只需要继承这个类,并重写run()函数就可以了。外部实例化这个类,并调用start()函数,会启动线程;线程启动后,会自动调用实现的 run() 函数。

  • 此外还有started, finised等信号来完成资源的加载与释放;
  • isRunning(), isfinished()来检测线程是否还在执行;
  • 同样,也可以定义自己的信号;
  • 线程执行完毕后,可以调用quit(), exit()来退出线程;
  • 如果不是十分有把握,请不要使用terminate来终止线程,因为它不是线程安全的,会导致资源、锁紊乱。

给个例子,包含以上所有提到的常用方法。主窗口启动一个子线程,线程每隔一秒发送数据给主窗口,主窗口显示数据。发送到一定量后,停止子线程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import sys, time
from PyQt5.QtWidgets import (QMainWindow, QTextEdit, QWidget, QApplication,
QHBoxLayout)
from PyQt5.QtCore import QThread, pyqtSignal


class Worker(QThread):
# 自定义信号
signal_out = pyqtSignal(str)
def __init__(self, working):
super(Worker, self).__init__()
self.working = working
self.num = 0
# 结束信号的触发
self.finished.connect(self.finish)

def finish(self):
# 在这里释放资源
print('finish')

def run(self):
# while 持续发送
while self.working == True:
if self.num != 0 and self.num % 2 == 0:
self.signal_out.emit("stop")
string = "Index " + str(self.num)
self.num += 1
self.signal_out.emit(string)
self.sleep(1)


class MainWindow(QMainWindow):
def __init__(self):
super(MainWindow, self).__init__()
self.text = QTextEdit()
layout = QHBoxLayout()
layout.addWidget(self.text)
w = QWidget()
w.setLayout(layout)
self.setCentralWidget(w)
self.worker = Worker(working=True)
self.start()
self.worker.signal_out.connect(self.display)

def display(self, string):
if string == 'stop':
self.text.clear()
self.worker.working = False
self.worker.quit()
# 强制结束进程的执行,但不推荐
# 线程结束后,被这个线程阻塞的线程都会被唤醒
# self.worker.terminate()
else:
QApplication.processEvents()
self.text.append(string)

def start(self):
# 启动线程
# 自动调用类内的 run 方法
self.worker.start()

if __name__ == "__main__":
q = QApplication([])
m = MainWindow()
m.show()
sys.exit(q.exec())

循环注意

治理需要注意的是,quit()方法会退出当前事件的循环。所以,如果线程处理的事件是死循环时,即使调用quit()是无法退出的。

1
2
3
4
5
6
7
8
9
while True:
string = "Index " + str(self.num)
self.num += 1
self.signal_out.emit(string)
self.sleep(1)
if self.num != 0 and self.num % 2 == 0:
self.signal_out.emit("stop")

self.worker.quit() # 无法退出

但是,一般程序并不需要quit(),因为这个线程根本就不需要事件循环。如以下程序虽然没有出错,但不够优雅:

1
2
3
4
5
6
7
8
9
10
11
# while 持续发送
while self.working == True:
if self.num != 0 and self.num % 2 == 0:
self.signal_out.emit("stop")
string = "Index " + str(self.num)
self.num += 1
self.signal_out.emit(string)
self.sleep(1)
self.worker.working = False
# 自己就会结束,不用退出
self.worker.quit()

wait一般放在startterminate之后,前者是等待线程结束,所以配合下文提到的QApplication.processEvents()使用更加。后者是线程被强行杀死后可能没有立刻死亡,这取决于系统的调度策略,等待相关资源回收完毕。

processEvents()

最终呈现的UI界面,要持续不断地循环刷新,以保证显示流畅、能及时响应用户输入。一般要有一个良好的帧率,比如每秒刷新60帧, 即经常说的FPS 60, 换算一下 1000 ms/ 60 ≈ 16 ms,也就是每隔16毫秒刷新一次。而我们有时候又需要做一些复杂的计算,这些计算的耗时远远超过了16毫秒。

在没有计算完成之前,主线程不会退出计算任务,相当于显示被阻塞,事件循环得不到及时处理,就会发生UI卡住的现象。这种场景下,就可以使用Qt为我们提供的接口,立即处理一次事件循环,来保证UI的流畅。所以,在容易卡顿的地方调用processEvents()函数即可。

MWE

既然都是完结稿了,留个Qt的最小实例在这里吧,以后方便做简单的测试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import sys, time
from PyQt5.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QApplication,
QPushButton)


class mainwindow(QMainWindow):
def __init__(self):
super(mainwindow, self).__init__()

layout = QHBoxLayout()
w = QWidget()
w.setLayout(layout)
self.setCentralWidget(w)

btn = QPushButton("点击")
layout.addWidget(btn)
btn.clicked.connect(self.count)

def count(self):
pass

if __name__ == '__main__':
app = QApplication([])
m = mainwindow()
m.show()
sys.exit(app.exec())

其实,学的越多才发现,我对真正的Qt一无所知。而以后的成长,只能靠阅读文档、阅读一些经验性博客来提升自己了。

参考

  1. qthread官方文档,想要的一切函数都有
  2. processEvents文档
  3. https://zhuanlan.zhihu.com/p/72758194
  4. 让Qt更快的响应,如果不忙,且需要开发软件,我会考虑翻译这篇文章。
感谢上学期间打赏我的朋友们。赛博乞讨:我,秦始皇,打钱。

欢迎订阅我的文章