前言

PyQt5可以说是Python中比较重要,也比较热门的GUI模块,依托Qt的实力而具有丰富的积累。就我目前的了解来看,和Python自带的GUI模块Tkinter相比,PyQt的重要优势是附带的图形界面工具Designer可以帮助开发者高效建立图形界面,以及更丰富的部件和信号发送-处理机制。综合起来,使用体验和Tkinter相比是要好很多的。

当然,网上关于PyQt的教程已经很多了,我暂时还是在短暂地接触上手的阶段,没有原创的经验。这里摘录一些比较基础、常用的使用示例,我个人遇到的一些问题,以及对应的解决方案。因为来源太多,很难一一记录,这里声明,除有特别说明的以外,本文的示例代码和解决方案都并非原创。

基础使用和代码示例

这里简单介绍PyQt及其GUI工具的安装和使用。

基础使用

首先是安装,使用pip install PyQt5安装即可。(PyQt模块体积略大)

安装完成后,在Python安装目录/Scripts/目录下的designer.exe即为自带的pyqt-tools图形界面工具。开启时似乎会伴有Shell界面,但是在之后关闭也没有影响。界面如图。

Designer界面

选择Mainwindow或其他默认窗口后,Designer会创建.ui文件以记录图形界面。可以看到,通过Designer,可以非常方便地完成添加、修改部件尺寸,修改部件的其他属性等操作。这确保了GUI编程的效率。自然,有人更喜欢直接使用代码编写GUI界面,网上也有不少教程是这么做的。我个人比较赞同一个观点,就是要把GUI和逻辑分离

保存后,需要使用PyQt自带的工具/scripts/pyuic.exe将转成Python代码,如:

1
pyuic5 -o output.py MainScreen.ui

可以看到,output.py将MainScreen封装成了一个重写的类。要调用主窗口类,并将主窗口实例化时,需要另写一个main.py提供入口。以下是常见的形式:

1
2
3
4
5
6
7
8
9
10
11
12
import sys
import MainScreen
from PyQt5 import QtCore, QtGui
from PyQt5.QtWidgets import QMainWindow, QApplication

app = QApplication(sys.argv)
MainWindow = QMainWindow()
MainUI = MainScreen.Ui_MainWindow()
MainUI.setupUi(MainWindow)

MainWindow.show()
sys.exit(app.exec())

对于上述代码,有必要仔细区分MainWindowMainUI。对组件进行操作时,一般调用MainUI;而对窗口进行操作时,一般调用MainWindow。人为清除窗口时,有MainWindow.destroy()MainWindow.hide()两种方法。经测试,只调用destroy()时,运行不会自动停止;而所有窗口都调用了hide()方法时,运行就会中止。因为没有研究过官方文档,所以这一点还有待讨论。

部件交互

这里总结部件交互的基本方式稍作总结。与Tkinter的回调形式不同,PyQt(Qt)采用的是信号-槽的信息传递方式,其途径是Sender-SignalReceiver-Slot,即信号源部件发出特定信号(可以传递参数),而对信号接受部件的槽(如方法)。这为GUI部件之间的信息传递提供了便利。

一些常用部件有固定的常规信号,如Button。可以通过以下代码将按钮点击信号绑定到方法:

1
MainUI.Button1.clicked.connect(func)

要实现一些部件的非常规交互方式,其中一种方式是重写该部件的事件和信号发送的相关方法。如对QPlainTextEdit部件的重写:

1
2
3
4
5
6
7
from PyQt5 import QtCore, QtGui, QtWidgets

class NewTextEdit(QtWidgets.QPlainTextEdit):
clicked = QtCore.pyqtSignal() #定义clicked信号
def mouseReleaseEvent(self, QMouseEvent):
if QMouseEvent.button() == QtCore.Qt.MidButton:
self.clicked.emit() #发送clicked信号

可以看到,这段代码通过QtCore.pyqtSignal()定义了一个待发送的信号,并确定了QPlainTextEdit对鼠标中键点击的响应,相当于修改了事件的过滤器。其中的mouseReleaseEventQtCore.Qt.MidButton都可以根据需要进行修改,这里只是一个简单的例子。

部分的信号-槽可以在Designer中添加。无奈的是,上述方式似乎不能通过Designer实现,每次修改GUI并重新生成后都不得不重新补上重写的类。不知道是否有更好的解决方案。

而RadioButton和CheckButton有更简洁的交互方式,可以直接调用它们的属性,如RadioButton1.isChecked()CheckButton1.isChecked()

对于从文本框获取文本的需求,因为PyQt的类似部件很多,不同文本框部件的获取文本的方法名又不尽相同,还是要参考Qt文档。常用的部件如TextEdit是GetText(),而纯文本常用的PlainTextEdit采用的是toPlainText()。修改文本也是如此,如PlainTextEdit采用的是setPlainText(),而其他的部件,包括Lable,多采用setText()

其他常用功能

这里罗列一些常用的基本功能。

设置图标

程序图标设置通过QIcon和窗体的方法实现,如:

1
2
3
icon = QtGui.QIcon()
icon.addPixmap(QtGui.QPixmap('Icon.ico'), QtGui.QIcon.Normal, QtGui.QIcon.Off)
MainWindow.setWindowIcon(icon)

最后两个参数是图标的模式,我没有测试过具体作用。

基本对话框

PyQt提供了几种基本的对话框,即information、warning、fatal、question、about。其具体使用如:

1
result = QMessageBox.information(MainWindow, "OK", "Configuration file loaded!", QMessageBox.Ok, QMessageBox.Ok)

这是一种调用默认弹窗静态方法的用法。其中information指明类型,各参数依次是父对象、窗口标题、正文内容、对话框使用的默认按钮和默认焦点所在的按钮。可以使用多个按钮,用“|”分割。这里用户的选择作为返回值赋给result,类型是QMessageBox.Ok等。下面是一个about窗口的用例:

1
QMessageBox.about(MainWindow, 'About', ShowText)

这里的about窗口只能使用默认的确认键。另外,如果设置了程序图标,那么该窗口也会在左侧显示放大的图标。

这些默认弹窗基本可以满足一般需求,但是如果要求更复杂的交互,就需要另外设计了。Designer提供了相应的模板。

使用样式

PyQt支持各类HTML标签和CSS样式。也就是说,可以在文本中使用HTML标签和style来调整文本。如:

1
2
3
4
style = 'font-family: Calibri; font-style: italic; font-size: 12px; font-weight: bold; color: red'
ShowText = '<h1>About</h1>'
ShowText += '<p style="{}">Banzai!!!<br></br></p>'.format(style)
QMessageBox.about(MainWindow, 'About', ShowText)

另外惊喜的是,经测试,img标签同样是可用的,所以也可以用这种方式插入图片。

注意事项

方便归方便,在上手的过程中,我还是遇到了各种意外的情况,这里稍作总结。

窗体显示

PyQt窗体貌似默认以低分辨率的模式运行,而这会给布局效果带来灾难性的影响。可以使用下面这行代码解决这一问题:

1
2
QtCore.QCoreApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling)
app = QApplication(sys.argv)

值得注意的是,这里的顺序似乎是很关键的。另外,对于字体无法显示完全,也有一些其他的解决方案,如:

1
2
3
4
font = QtGui.QFont("Arial")
pointsize = font.pointSize()
font.setPixelSize(int(pointsize / 1.25)) # 1.25是经验值
app.setFont(font)

这是通过使用便于自适应的Arial字体,并按比例调节字体大小来解决字体显示问题的。这里只是以主窗体为例,其他的部件同样可以重新设置字体。

部件布局

PyQt的部件一定要使用自适应的布局!这是我经过两天的痛苦摸索之后得出的结论。事实证明,PyQt的窗口在不同机器上的运行结果可能完全不同,而其中可能的原因是对绝对大小的定义的差异,以及对窗口伸展性定义的差异。下面是一个惨不忍睹的窗口效果的例子。(图源:Toyomu的机器)

惨不忍睹的窗口效果(图源:Toyomu的机器)

而PyQt提供四种基本布局方式,即水平并列、垂直并列、表格布局、表单布局,其具体模式都如字面意思,可以结合需要使用。在Designer中,这些布局方式都很容易设置。另外,要注意对整个窗体,即Designer中默认的centralwidget选用布局,以便部件随窗体大小变化而调整。

可以在右键菜单中选取

可以看到,通过布局的参数调节,可以设置边距、比例等,达到预期的效果。

参数调节

使用Pyinstaller打包

在简单的程序中,使用Pyinstaller打包并不会带来问题。然而,当程序依赖于资源文件,如图片时,选择用Pyinstaller打包成单文件的exe就会带来问题。首先是,必须将资源文件一并打包。参考网上的其他教程可知,可以通过一般的命令使用Pyinstaller后,修改生成的.spec文件中的设置,添加资源文件夹,如:

1
datas = [('res', 'res')]

其中,元组中的第一个参数是资源文件夹的当前路径,第二个参数指定的是打包后释放文件时的所在路径。修改完成后,通过.spec文件打包即可:

1
pyinstaller -F -w example.spec

另一方面,通过一般方式,如相对路径引用、os模块的绝对路径获取,都会得到单文件程序本体所在的路径。然而,Pyinstaller打包的单文件程序的实际操作是,将包中各部分文件释放到以_MEIPASS开头的临时目录中,包括资源文件夹,对本体路径的引用是无效的。所幸,Pyinstaller打包的单文件程序中,会在sys加入sys._MEIPASS这一属性,以便获取实际路径,即临时文件的所在目录。如果要兼顾调试和实际使用,可以定义一个函数:

1
2
3
4
5
6
7
def GetAbsolutePath(filename):
if getattr(sys, 'frozen', False): # 检查环境
dirname = sys._MEIPASS # 这一行可能会报错,但实际上并没有问题
else:
dirname = os.path.dirname(__file__)

return os.path.join(dirname, filename)

这样,就可以保证引用正确了。