结合 Qt 信号槽机制的 Python 自定义线程类
1. Qt for Python
1.1 PySide 与 PyQt 的滑稽故事
自 Qt 5.12 LTS 开始,就已全面支持 Qt for Python,Qt 官方给它的命名为 PySide
,目前最新版本为 PySide2
。但其实,早在 PySide 发行之前,就已经可以实现使用 Python 语言编写 Qt 程序了,它就是 PyQt
,目前最新版本为 PyQt5
。
这里有一段有关 PySide 和 PyQt 的滑稽故事:
由于先前 PySide 项目不是很完善,并且缺乏文档,所以存在感并不是很高,网上有关使用 Python 编写 Qt 的资料几乎都是 PyQt 占大多数。但后来在官方的强力推动下,现在的 PySide2,已经基本趋于成熟稳定,且文档齐全(官方文档:https://doc.qt.io/qtforpython/)。
1.2 PySide2 与 PyQt5 的细微差异
由于 PySide2 与 PyQt 高度兼容,对于开发者来说,除了在头部引包时将 “PyQt5” 改成 “PySide2” 外,其他的代码基本无需修改。就目前我使用的经验来看,主要发现了以下的这些不同之处,当然,我肯定知道它们之间的差异不仅仅只是这些,但我目前还尚未发现。
PyQt5 的自定义信号类名为 pyqtSignal,而 PySide2 的类名为 Signal。
PyQt5 的槽函数装饰器名称为 pyqtSlot,而 PySide2 的装饰器名称为 Slot。
在 PyQt5中,自定义信号的如果要传参,则必须传入指定类型的参数,不能传入 None 值,这一点是非常让人头疼的,而在 PySide2 上这一点就做得非常人性化。
在 QWebEnginePage 控件中的 runJavaScript 方法,PyQt5 可以传入回调函数来获取或处理执行 JavaScript 代码后的结果,而 PySide2 中却没有这个功能,也就是说,使用 PySide2,你是完全没办法得到 JavaScript 执行结果的。
1.3 使用 PySide2 进行开发
首先需要下载 PySide2 模块:
pip install pyside2
针对开发 PySide2 项目,一般我喜欢在 main.py 中创建 Qt 应用程序(有且仅有一个 QApplication 对象)并实例化一个主窗口,而其他的所有工作,都将在主窗口中调用执行。如果是自定义的控件,我喜欢把它放进一个 widgets 文件夹里,而其他的与图形界面无关的 python 程序,我喜欢把它们都放在一个 library 文件夹里。
下面给出的是我自己使用的一套开发 PySide2 的编码习惯。
这是主界面类:main_window.py
# 导入可能用到的标准库模块
import <lib>
# 导入 PySide2 库模块
from PySide2.QtWidgets import QWidget
from PySide2.QtCore import Slot
# 导入其他自定义的模块包
import <custom_modules>
from <custom_modules> import <custom_class_or_function>
class MainWindow(QWidget):
""" MainWindow 继承 QWidget 类 """
def __init__(self):
QWidget.__init__(self)
# 设置类属性
pass
# 设置窗口属性
pass
# 创建窗口内部控件,并设置样式
self._create_components()
self._set_styles()
def _create_components(self):
""" 创建窗口内部的子控件 """
pass
def _set_styles(self):
""" 设置窗口及其子控件的样式 """
pass
@Slot()
def _slot_xxx_www(self):
""" 槽函数:连接至xxx控件的www信号 """
pass
def _other_protected_function(self):
""" 其他的 protected 访问权限的方法 """
pass
def other_public_function(self):
""" 其他的 public 访问权限的方法 """
pass
在上面的这个示例代码中,我喜欢把所有创建子控件的操作都放进 _create_components
这个方法里;然后把所有设置样式的操作都放进 _set_styles
这个方法里;而对于槽函数,我喜欢使用 _slot_xxx_www
这样的命名方式,其中 xxx 为信号发送者对象,www 为信号名称。
下面是 main.py 入口文件的代码逻辑:
import sys
from PySide2.QtWidgets import QApplication
from main_windows import MainWindow
if __name__ == "__main__":
app = QApplication(sys.argv) # 创建 Qt 应用程序实例
main_win = MainWindow() # 创建主窗口实例
main_win.show() # 显示主窗口
sys.exit(app.exec_()) # 进入循环监听事件
2. PySide2 的信号槽处理机制
PySide2 的信号槽连接方式有两种,一种是 Qt 经典风格,另一种是 PySide2 独特的新风格,后者在 Python 中使用非常方便。
2.1 控件的信号连接
对于控件的某些事件所发送的信号,例如按钮的 clicked 信号,则可以直接通过 connect 方法连接到槽函数中。下面给出对应的示例代码:
from PySide2.QtWidgets import QPushButton
from PySide2.QtCore import Slot
@Slot()
def slot_function():
"""
定义槽函数:处理按钮被单击时的事件
其中 @Slot() 为槽函数的装饰器,其实也可以不写装饰器,但为了便于区分普通的函数,还是建议加上装饰器。
"""
pass
# 实例化一个 Qt 控件(如按钮)
btn = QPushButton()
# 直接将控件的某个信号(如按钮的 clicked 信号)使用 connect 方法连接到槽函数
btn.clicked.connect(slot_function)
2.2 自定义信号与带参数信号
对于自定义的信号,需要引用 Signal 类,并在类属性中定义信号(注意只能在类属性中定义);而对于带参数的信号处理,则只需在定义信号(Signal 类)的时候写上参数数据类型,然后在发射信号的时候(emit 方法)传入对应的参数,同时在槽函数中接收对应的参数。下面是我自己探索出的具体的实现案例。
from PySide2.QtWidgets import QApplication
from PySide2.QtCore import Qt, QObject, Signal, Slot
class CustomClass(QObject):
""" 这是一个自定义的类,在这个类里面要使用 Qt 的信号,因此必须继承 QObject 类 """
# 定义一个信号,注意信号的定义只能写在类属性中,不能作为对象属性定义在构造方法中。
custom_signal1 = Signal()
# 定义一个带参数的信号,此时只需声明参数的类型即可
custom_signal2 = Signal(int, str)
def __init__(self):
QObject.__init__(self)
def send_signal(self):
""" 调用该方法时向外部发送信号 """
self.custom_signal1.emit() # 使用 emit() 方法发射信号
self.custom_signal2.emit(1, "hello") # 带参数的信号则需一起发送参数
@Slot()
def slot_deal_signal1():
"""
定义槽函数,用于处理 custom_signal1 信号
"""
print("get custom signal 1.")
@Slot(int, str)
def slot_deal_signal2(num: int, text: str):
"""
定义槽函数:用于处理 custom_signal2 信号
注意此时装饰器以及函数的参数列表应该与信号中定义的参数列表一致
"""
print("get custom signal 2.")
print("the first arg is:", num)
print("the second arg is:", text)
# 实例化这个类
cs = CustomClass()
# 连接信号与槽
cs.custom_signal1.connect(slot_deal_signal1)
cs.custom_signal2.connect(slot_deal_signal2)
# 发射信号
cs.send_signal()
在上面这个例子中,当调用 cs.send_signal( ) 方法时,会依次触发 custom_signal1 信号和 custom_signal2 信号,由于 custom_signal1 信号连接至 slot_deal_signal1 槽、custom_signal2 信号连接至 slot_deal_signal2 槽,因此当程序运行时,最终输出结果应该如下:
get custom signal 1.
git custom signal 2.
the first arg is: 1
the second arg is: hello
3. Python3 的线程处理模块
首先说说为什么 Qt 程序中离不开多线程处理。在图形界面程序中,往往是点击某个按钮后要处理特定的功能逻辑,有些处理很快,所以感觉不到什么,但有些需要长时间处理的,甚至可能进入死循环处理的,这就会导致界面卡顿,一直等待事件处理完毕,这将会造成非常不友好的用户体验。因此使用多线程处理的话,就可以把一些需要长时间处理的程序丢给子线程去处理,而主界面程序不受影响。
3.1 _thread 和 threading
Python3 通过两个标准库 _thread
和 threading
提供对线程的支持。_thread 提供了低级别的、原始的线程以及一个简单的锁,它相比于 threading 模块的功能还是比较有限的。threading 模块除了包含 _thread 模块中的所有方法外,还提供的其他方法:threading.currentThread()、threading.enumerate() 和 threading.activeCount() 等。
3.2 threading.Thread
threading 线程模块提供了 Thread 类来处理线程,Thread 类提供了以下方法:
-
run(): 用以表示线程活动的方法。
-
start(): 启动线程活动。
-
is_alive(): 返回线程是否活动的。
-
getName(): 返回线程名。
-
setName(): 设置线程名。
3.3 实现一个自定义的线程类
下面使用 threading.Thread 派生出的新类,来实现一个自定义的子线程,该子线程将每隔一秒钟向屏幕打印一次当前时间。
import time
from threading import Thread
class PrintTimeThread(Thread):
""" 自定义线程类:用于打印当前时间 """
def __init__(self):
Thread.__init__(self)
def run(self):
""" 重写 run 方法:实现子线程处理逻辑 """
while True:
print(time.asctime()) # 打印时间
time.sleep(1) # 休眠1秒
if __name__ == "__main__":
# 实例化子线程对象
my_thread = PrintTimeThread()
# 启动子线程
my_thread.start()
# 为了作对比,主线程中每隔三秒打印一次 “hello”
while True:
print("hello")
time.sleep(3)
在这个例子中,子线程每隔1秒打印一次当前时间,而主线程每隔3秒打印一次 “hello”,子线程与主线程之间所处理的工作互不相干。但如果想要实现由主线程来打印当前时间,并且主线程中 while 内的代码不变,照样每隔三秒打印 hello,那么这就遇到问题了:子线程该如何向主线程传递数据并且不中断子线程的继续运行呢?主线程又如何接受并处理子线程发过来的数据并且也不影响主线程的正常运行呢?
可能玩过单片机的人应该想到了,对,可以使用类似 “中断” 的方法来实现,子线程中每隔一秒向主线程发送中断请求,主线程则去处理中断,处理完后继续主线程的动作。那么在上层应用中,如何实现这一过程呢?很庆幸的是,Qt 信号与槽的机制正好符合 “中断” 的思想。所以,我就想到了可以结合 Qt 的信号槽机制来实现这一过程。
4. 基于信号槽机制的自定义线程类
首先回顾一下上面讲到的自定义信号以及带参数信号的实现。使用自定义信号需要引入 PySide2.QtCore
中的 Signal
类,并且还需要继承 PySide2.QtCore
中的 QObject
类,定义信号的代码需写在类属性中,发射信号使用 emit( ) 方法。
接下来结合上面的程序,加入 Qt 信号的思想,重新编写实现逻辑:
import time
from threading import Thread
from PySide2.QtCore import QObject, Signal, Slot
class GetTimeThread(Thread, QObject):
"""
自定义线程类:用于打印当前时间
注意这里使用 Python 多继承的概念,同时继承了 Thread 类和 QObjet 类
"""
# 定义信号:每隔一秒钟触发信号,并将当前时间信息发射出去
# 注意这里使用了带参数的信号,待发射的时间信息为 str 类型
clock_signal = Signal(str)
def __init__(self):
# 父类初始化
Thread.__init__(self)
QObject.__init__(self)
def run(self):
""" 重写 run 方法:实现子线程处理逻辑 """
while True:
# print(time.asctime()) # 现在不用在子线程中打印时间了,所以注释掉
self.clock_signal.emit(time.asctime()) # 直接将时间信息发射出去
time.sleep(1) # 休眠1秒
@Slot(str)
def slot_print_time(current_time: str):
""" 定义槽函数:接收子线程传送过来的时间信息,并打印出来
:param current_time: 由子线程发射过来的时间信息
"""
print(current_time)
if __name__ == "__main__":
# 实例化子线程对象
my_thread = PrintTimeThread()
# 连接子线程的信号到主线程的槽函数中
my_thread.clock_signal.connect(slot_print_time)
# 启动子线程
my_thread.start()
# 为了作对比,主线程中每隔三秒打印一次 “hello”
while True:
print("hello")
time.sleep(3)
这个程序与上面程序不同的是,子线程类中多了信号的定义以及信号的发射;而在主线程中定义了一个用于打印时间信息的槽函数,并在实例化子线程后,直接将子线程的信号连接到主线程的槽函数中。当程序运行时,子线程每隔1秒钟向主线程发射信号,信号中携带当前时间信息,主线程接收到信号后,立即中断当前 while 中的工作,转而执行 slot_print_time 槽函数,执行完这个函数后继续回到 while 中刚刚停下来的地方继续执行。
5. 总结
信号与槽的机制是 Qt 的核心思想,也是 Qt 中最值得为人称赞的地方。同时,由于 Python 语言出色的简单易用、第三方模块丰富、代码高效简洁、跨平台兼容性好等特征,并且使用 threading.Thread 处理多线程问题也相比其他语言而言简单方便,目前使用 Qt for Python 的案例也越来越多,并且未来也将会是一个趋势。结合 Qt 信号槽机制与 threading.Thread 多线程处理类,可以将多线程处理的程序的实现变得更加简单高效。