附录A.3. 实践 - 冒泡及轻者上浮

本书同名免费MOOC《Python编程基础及应用》在哔哩哔哩(B站)热播,作者带着你学。


附录A.3. 实践 - 冒泡及轻者上浮

Python自带有标准GUI-图形用户界面工具包Tkinter。但Tkinter的功能相对比较简单,界面也不够漂亮,对于规模大一点的GUI应用略显不足。在当前的Python生态圈,如果读者需要一种功能较齐全、能满足大多数项目实践需要的GUI工具包,作者认为,PyQt5是当前最好的选择。

本章向读者介绍基于PyQt5的图形应用程序的框架及开发过程,以及分时操作系统的消息循环机制,还有多线程程序设计的基本概念和方法。冒泡排序不是重点。

A.3.1 开发环境准备

A.3.1.1 Qt

Qt - https://www.qt.io/是久负盛名的跨平台C++ GUI开发包及集成开发环境。它独特的信号-槽机制屏蔽了不同操作系统间的差异,使得用C++语言书写的应用程序可以在不同的操作系统(Windows, Linux如Ubuntu)下运行。由于它本身就是C++语言书写的,所以运行速度相对较快。经过多年的发展,Qt已经发展得比较成熟了,即便开发Android App,也可以在Qt上用C++完成。

PyQt则是对Qt的Python封装,它是由英国的RiverBank公司- https://riverbankcomputing.com开发的。另外还有一款名为Eric的IDE软件,它把Python, PyQt集成得非常好。作者曾在树莓派卡片电脑上直接用Eric开发基于PyQt的应用程序,该卡片电脑运行一种Linux的发布版本 - Raspbian。

需要注意的是,Qt主要执行LGPL授权协议而PyQt执行 GPL授权协议。如果读者试图在一个私有代码的商业软件中应用上述组件,可能需要付费。

A.3.1.2 PyQt安装

进入Windows命令行或者Linux的终端,通过pip工具安装pyqt5以及pyqt5-tools两个包。安装需要联网,并持续好几分钟,因为被安装的包是从网络软件仓库中实时下载的。

1
2
pip install pyqt5
pip install pyqt5-tools

A.3.1.3 Visual Studio Code配置

Qt/PyQt中包括一系列的工具,其中:

工具名称 用途 可执行文件/模块名称
Qt Designer 用即见即所得的方式设计图形界面,成果表现为扩展名为ui的文件。 designer
UI Compiler 将上述ui文件“编译”成Python程序。该Python程序帮助构建ui文件所描述的图形界面。 pyuic5
Qt Linguist 语言学家,可以便捷的实现软件的国际化,即生成软件的法语、英语、日语或者其它语种版本。工作模式大致可以描述成:先用pylupdate5扫描源代码中全部可翻译的字符串,然后用linguist翻译相应的字符串至目标语言,接下来用lrelease工具发布。软件运行时,加载法语版本的语言学家文件,软件界面就是法语,加载日语版本的语言学家文件,软件界面就是日语。 linguist,
pylupdate5,
lrelease
Resource Compiler 资源编译器。UI文件设计过程中可能需要使用到各种图片,这些图片以资源文件的形式组织,扩展名为qrc;资源编译器负责将 qrc格式的资源文件编译成py文件,其中,图片被转换成bytes-字节流。 pyrcc5

为了使用这些Qt工具,我们需要在Visual Studio Code中安装下述扩展或者其它类似功能的扩展并对扩展进行配置。Visual Studio Code上的扩展安装方法请回顾第一章相关内容。

安装完成后,上述扩展还需要进行配置才能使用,该扩展在Visual Studio Code中的配置及基本使用方法请参考下述链接:https://codelearn.club/2019/06/pyqtconfig/

A.3.2 简单PyQt图形应用

A.3.2.1 创建PyQt应用

在计算机里创建一个名为BubbleSort的文件夹,比如d:\pylearn\BubbleSort。在Visual Source Code中打开这个文件夹,然后创建一个名为SimpleQtApp.py的程序文件,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#SimpleQtApp.py
import sys
from PyQt5 import QtWidgets,QtCore

app = QtWidgets.QApplication(sys.argv)
wdMain = QtWidgets.QWidget()
wdMain.setGeometry(200,200,800,600)
wdMain.setWindowTitle("GUI, Let's embrace the world!")

btnExit = QtWidgets.QPushButton('EXIT',wdMain)
btnExit.resize(200,80)
btnExit.move(300,300)
btnExit.clicked.connect(QtCore.QCoreApplication.quit)

wdMain.show()
r = app.exec_()
print("message loop ended.")
exit(r)

执行,得到第一个Qt图形应用的运行界面,用鼠标点一下EXIT按钮,程序运行结束。

1541736278967

A.3.2.2 分时系统与消息循环

上面这个SimpleQtApp.py行数并不多,但要彻底理解它的工作原理却并不容易。我们得从操作系统说起。现代操作系统都是分时系统,你的计算机同时在做很多事情:浏览器、Word、音乐播放器… 读者如果打开Windows任务管理器,可以看到数十至数百个进程 - process在“同时”运行。而一个进程 ,可能又是由多个线程 - thread组成的。比如,当你的浏览器进程试图从网站上下载一个大文件时,它可能会创建一个单独的线程来下载文件,而原有的主线程则随时待命,及时处理你的命令:输入网址,点击超链接等等。

  • 线程竞争性地使用CPU资源

操作系统管理着CPU,将CPU的时间切割成非常小的时间片。它按照效率与公平兼顾的原则将时间片分配给线程,线程获得时间片后,将执行相应的运算或其它操作。时间片用完后, 操作系统会收回CPU,将时间片分给其它线程;上述时间片的分配和回收对于应用程序而言是透明的,也就是应用程序根本不知道也无法预测或者控制时间片的获得与丧失。当一个线程被被剥夺时间片时,操作系统会保存好执行现场,包括CPU内各个寄存器的值,然后线程就挂起 - suspended。当这个被挂起的线程重新获得时间片时,操作系统会先恢复执行现场,然后通过跳转指令恢复线程的执行。由于时间片的轮转速度非常快,所以,使用者一般感觉不到应用程序的这种间断执行,似乎应用程序拥有一个“专享”的CPU。

计算机通常只有一个键盘、一个鼠标。所以我们不能认为键盘和鼠标是属于WORD的,还是浏览器的。这些应用程序在共同使用这些外部设备,计算机的系统软件-操作系统在管理这些外设资源,包括输入设备如键盘鼠标,也包括输出设备,如显示器/显卡等。

  • 操作系统负责管理键盘鼠标并将键盘鼠标的输入分发给对应的进程

假设桌面上同时有两个窗口,如下图。这里操作者如果敲下一个键,比如c,那么首先获悉这个事件的,肯定是操作系统,因为操作系统监视着键盘输入。问题是,当操作系统获悉这个事件后,将这个事件分发给哪个应用程序呢? 是前面的浏览器还是后面的文字编辑器呢? 显然,操作系统遵循谁有焦点(focus),就分发给谁的规则。事实上,所有的应用程序,它的窗口大小、窗口位置等信息都是向操作系统登记备案的,依据这些信息,操作系统决定信息的去向。为了更好的分发这些消息,操作系统会为每一个进程创建消息队列,凡是有发往这个进程的消息,操作系统就把这个消息放在对应的队列里。而应用程序的进程,则不断地从队列获取消息并处理,作出恰当的反应。应用程序不断读取消息队列并处理消息的机制称为消息循环

1562813697276

​ 总结,在分时操作系统下,一个图形应用程序的执行框架可以大致用下图刻画。

1562813401063

可以看到,图形应用程序启动后,在完成初始化,向操作系统注册,显示主窗口等任务后,即进入一个消息循环:周而复始的从操作系统的消息队列中获取分发给自己的消息并进行处理。比如,用户按了某个按钮,应用程序在收到这个消息后将执行对应的处理函数,以响应用户的要求。如果用户按下的是主窗口的关闭按钮(即窗口右上角的X),应用程序在收到这个消息后通常会退出消息循环,执行结束。

A.3.2.3 示例解读

现在可以尝试解释本节的PyQt图形应用的代码了。

1
from PyQt5 import QtWidgets,QtCore

PyQt的包,我们主要用到三个,分别是:

包/模块名 说明
QtWidgets 包括一系列GUI部件,比如QMindow、QDialog等。
QtGui 包括窗口集成、事件处理、2D绘图、字体和文本等GUI元素。
QtCore 包括时间、文件及目录处理、数据类型、数据流、进程/线程等功能,属于非GUI的核心模块。

除此之外,还有一些模块:QtNetwork用于网络通信;QtSql用于关系数据库访问;QtWebKit则支持内置的网络浏览器;QtMultimedia则用于支持多媒体。

1
2
3
4
5
app = QtWidgets.QApplication(sys.argv)
...
r = app.exec_()
print("message loop ended.")
exit(r)

所有的Qt图形应用程序都需要创建一个QtWidgets.QApplication对象,这个对象将负责进程的消息循环和分发。sys.argv是程序启动时的命令行参数。app.exec_()函数的实质就是应用程序的消息循环,它周而复始地从操作系统消息队列中获取用户消息/指令,然后把这些消息/指令按照Qt特有的信号-槽( signal - slot)机制分发给对应的处理函数进行处理,并做作适当响应。通常,当应用程序的主窗口被关闭后,app.exec_()函数将退出消息循环,并返回一个值表明应用的执行结果,这个值通常表明程序在执行过程中有没有出错。

读者可以再运行一次这个简单的只有一个按钮的图形应用。请注意,只有当你点击EXIT按钮,主窗口关闭后,上述print(“message loop ended.”)消息才会输出到控制台。这证明,app.exec_()函数真的是在进行消息”死”循环,它将程序“卡”在这里。

app.exec_()执行结束后,exit()函数退出Python解释器,参数r被返回给操作系统表明程序运行结果。

1
2
3
4
5
6
7
8
9
10
wdMain = QtWidgets.QWidget()
wdMain.setGeometry(200,200,800,600)
wdMain.setWindowTitle("GUI, Let's embrace the world!")

btnExit = QtWidgets.QPushButton('EXIT',wdMain)
btnExit.resize(200,80)
btnExit.move(300,300)
btnExit.clicked.connect(QtCore.QCoreApplication.quit)

wdMain.show()

中间这段代码先是创建了一个QtWidgets.QWidget对象作为应用程序的主窗口。Widget这个词大致就是窗口(Window)的另一种写法。setGeometry()方法显然设置了这个窗口在桌面上的呈现位置(左上角坐标)和像素单位长宽尺寸。setWindowTitle()则设置了窗口的标题。大多数情况下,Qt的类名、函数都具有良好自解释特性,看到名字大概就能猜出其功能。

接下来,btnExit是一个QtWidgets.QPushButton对象,就是一个按钮控件。QtWidgets.QPushButton(‘EXIT’,wdMain)的第一个参数表示这个按钮的标题,而参数wdMain表明了这个按钮的父控件,也就是按钮的拥有者是wdMain主窗口。resize()函数设定了按钮的尺寸,move()函数将按钮移动到窗口内部坐标(300,300)的位置。请注意,在GUI应用中,坐标系通常是top-left坐标系,以窗口的左上角为原点,向右x为正,向下y为正。

btnExit.clicked.connect(QtCore.QCoreApplication.quit)这一行最为关键。clicked为btnExit对象的属性,它是一个信号(signal),而QtCore.QCoreApplication.quit可以认为是一个特殊的回调函数,称之为槽(slot),它的功能大致是结束应用程序的运行。clicked.connect()函数则将信号clicked与槽关联起来,结果就是:当主窗口wdMain内的btnExit按钮被点击时,操作系统监控到鼠标的动作,然后将这一事件打包成一个消息,放至该应用程序的消息队列;app.exec_()内部的消息循环得到这一消息后,在内部将其处理成btnExit的clicked信号,根据信号-槽的关联,QtCore.QCoreApplication.quit槽方法被执行,跳出消息循环,程序结束。

上述代码只是”徒手”创建了wdMain窗口,而wdMain.show()函数的执行才真正将其显示出来。接下来就是app.exec_()的消息循环。

A.3.3 世界主要工业国GDP排名

接下来,我们要编写一个图形应用程序,使用冒泡排序来对世界主要工业国的GDP进行排名,并演示冒泡排序的执行过程。在本书配套的网站上,你可以下载到本实践的全部代码和数据。在完成本章第一节的环境准备工作后,你应该可以打开并运行该实例。请对照代码阅读本章后续内容。 该实例的运行结果大致如下图。

1562918296315

A.3.4 数据及基础结构

A.3.4.1 数据文件

名为BubbleSort的项目目录内有一个名为countries.ini的文件,其中包括了2017年世界前15位的工业国家的GDP数据,下表列出了该文件的前几行。可见,基本数据包括国家名称,GDP值(以十亿(billion)美元为单位),以及这个国家的国旗图片文件名称。

1
2
3
4
5
6
7
8
[Countries]
countries.size = 15
countries[0].sName = United States
countries[0].fGdp = 19555.874
countries[0].sLogoFile = us.gif
countries[1].sName = China
countries[1].fGdp = 13173.585
countries[1].sLogoFile = china.gif

A.3.4.2 数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#Country.py
from enum import Enum

class CompareState(Enum):
prev = 0 #the item in comparation as prev item
next = 1 #the item in comparation as next item
idle = 2 #the item is not in comparation
fixed = 3 #the item's position have been settled by sort algorithm

class Country:
def __init__(self,name,gdp,logofile):
self.sName = name
self.fGdp = gdp
self.sLogoFile = logofile
self.compareState = CompareState.idle

在Country.py文件中,我们定义了新类Country。通过其构造函数,初始化了四个属性,依次是国家名称-sName,GDP值-fGdp,国旗文件-sLogoFile以及比较状态-compareState。

其中,比较状态是个枚举型,其值将用于界面展示(详见后)。idle表示当前这个国家对象不参与最近的冒泡比较;prev表示对象作为左元素参与冒泡比较;next则表示对象将作为右元素参与冒泡比较;fixed表明对象在序列中的位置已经确定,不会再移动了,将来也不会再参与冒泡比较。

A.3.4.3 数据读入与组织

在MainWidget.py中,MainWidget的构造函数将调用initCountries()成员函数。该函数通过configparser包从”countries.ini”文件中读出数据,形成15个Country对象并置于self.countries列表当中。这些方法我们曾在本书的前述章节中讨论过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#MainWidget.py
def initCountries(self):
self.countries = []

import configparser
data = configparser.ConfigParser()
data.read("countries.ini")
data = data["Countries"]
iSize = int(data.get("countries.size",0))
for i in range(iSize):
name = data.get("countries[{}].sName".format(i),"ERROR").strip()
gdp = float(data.get("countries[{}].fGdp".format(i),"0"))
logofile = data.get("countries[{}].sLogoFile".format(i),"ERROR").strip()
self.countries.append(Country(name,gdp,logofile))

A.3.5 界面设计

在15.2节,我们“徒手”用代码创建了简单图形应用的主窗口,这样做没有问题。但更多情况下,图形界面的设计借助于那些所见即所得的设计工具将更加高效。Qt Designer就是这样一个设计工具。

A.3.5.1 基本操作

在Visual Studio Code的项目目录中找到MainWidget.ui, 鼠标左键单击,打开该文件,可以看到该文件事实上是XML格式的文本文件。

1562819537393

在MainWidget.ui的编辑框中任意位置,右击鼠标,弹出菜单中选择Edit Form(Qt Deisnger UI File),可以在Qt Designer中查看/编辑MainWidget.ui。受限于印刷分辨率,下图可能看不清,请读者在计算机中实际操作以查看细节。Qt Designer中的工具栏是可以调整和拖曳的,因此,读者在自己的计算机上看到的界面布局可能不完全与下图相同。读者可通过View菜单显示/隐藏工具栏。

1541924631196

这个界面的最左端,是Widget Box工具栏。这里包括了众多GUI组件,读者大致可以从名称和缩略图中猜出这些组件的功能:其中,Layouts属于布局类组件,它负责组织其中的下层GUI元素之间的相对位置关系;Buttons是各种操作按钮;Items Views以及Items Widgets是列表、树、及表格组件;Containers是容器类界面元素,它可以容纳下层组件;Input Widgets内的组件可以支持用户输入;Display Widgets内的组件负责在界面上显示信息。如果设计需要用到什么组件,左键单击该组件,按住不放,拖到中间界面内即可。

右上方的Object Inspector展示了当前窗口的树形结构。在Qt图形应用中,界面组件之间是存在父子从属关系的。比如,我们看到,名为pbStart的QPushButton按钮属于verticalLayout,而verticalLayout又从属于centralWidget,centralWidget又属于MainWidget,MainWidget的类型为QMainWindow。QMainWindow是Qt中的主窗口类,通常的Qt图形应用程序都应包括一个这样的主窗口。

当操作者选定一个界面组件时,可在右端中间的Property Editor中设置该界面组件的属性。当作者选中pbStart(START按钮)后,Property Editor如下图(部分属性被作者收起来了)。从该图中,我们可以看出QPushButton的继承关系:QObject -> QWidget -> QAbstractButton->QPushButton。QPushButton的每一个父类型都会带来一些属性,其中QObject带来了objectName,这是这个组件的对象名称。当你的代码操作或者引用这个组件时,就需要使用到这个名称。建议读者现在就展开pbStart的全部属性,从上到下看一遍,猜猜它们的用途。在本书中,组件的很多属性限于篇幅,无法一一介绍,读者需要自行查看和修改,通过观察变化来理解它们。

1541925810620

对于组件的名称取名,作者本人执行如下的铁律:当组件需要在代码中引用时,必须在UI设计阶段按照命令规则命名;如果组件不被代码引用,比如就是界面上的一个不需要修改显示内容的标签(Label),允许不改名,使用UI Designer为其取的默认名。读者也可以研究一下作者在本项目中的命名,以 pbStart为例,pb为PushButton的首字母小写,Start表明其用途。

Qt Designer的右下端为Resource Browser - 资源浏览器。下图中展示了本项目中使用的各按钮的配用LOGO图片。

1562918790927

A.3.5.2 布局

现代计算机的形式多种多样,笔记本、台式机、手机、iPad,每种终端的屏幕比例,分辨率千差万别。所以,现代界面设计,都要考虑应用界面在不同终端上的显示兼容。实现显示兼容的一个重要的工具就是布局。Qt里有4种布局组件:Vertical Layout把其下层界面组件按行布局,每行一个组件;Horizontal Layout把其下层组件按列布局,每列一个组件;Grid Layout把其下层组件按表格布局,每个单元一个组件,当然,也同时允许组件跨多行或者多列;Form Layout称为表单布局,其表现形式跟Grid Layout有些相似。在实践中,读者可以通过不种布局之间的相互嵌套才实现复杂界面布局。

本例中的MainWidget.ui使用了下述布局结构:

1541928347502

可以看到,START-开始,SHUFFLE-打乱,STOP-停止,ABOUT-关于,EXIT-退出这5个QPushButton处于同一个verticalLayout(类型为QVBoxLayout)中,它们从上至下等间距显示。

还可以注意到上述结构中有一个verticalLayoutCountries,它是一个垂直布局。这是全部布局中唯一一个取了正式名称的,这是因为,作者要在代码中引用它,并把参与排序的全部国家信息放置在该布局中展示。

点击verticalLayout,可以在Property Editor中修改布局属性:

1541928720427

从上到下,分别是布局的名称,左上右下的边距,布局间隔-即布局内各界面组件的相对距离等信息。读者可以尝试修改这些信息,同时观察界面变化。 其它布局的属性信息大同小异。

A.3.5.3 组件位置及尺寸

一个组件在最终界面中的展示位置及尺寸受多方因素的制约。下图显示了STOP按钮的属性。当组件不位于任何布局容器中时,geometry中可以设置其Top-Left坐标系左上角坐标X,Y以及长宽。当组件位于布局容器中时,geometry不可用。

多数情况下,我们都会把组件放置在布局容器中。sizePolicy-尺寸策略将参与决定组件的最终尺寸。下图中,STOP按钮的Horizontal Policy为Expanding,这意味着,按钮将试图横向填满布局。STOP按钮的Vertical Policy则为Fixed,这意味着按钮的高度将为固定值,此处取了minimumSize中的Height,即120。后面的minimum Size和maximum Size则设置了组件的最大和最小尺寸。如果读者希望组件的大小是固定值,可以尝试把minimum和maximum Size设为相等的固定值。

反过来,布局中组件的minimum Size也可能影响布局的宽度,比如,界面中的tbTitle的minimum Size的Width为700,这就使得其归属容器verticalLayout_3被撑到700以上宽度。

1541929078711

A.3.5.4 使用图片资源

在项目目录下有个子目录名为img,其中放置了本项目中各按钮使用的LOGO-图标文件。以STOP按钮为例,其对图标文件的引用信息如下:

1541930250361

text属性设置了按钮上的显示文字;iconSize设置图标的长宽;在Normal On那里点有省略号的小按钮,可以打开如下界面:

1562918604976

通过这个资源选择器,可以选择按钮使用的图标。如前图所示,操作者甚至可以为按钮的不同状态指定不同的图标。点上图中的铅笔按钮可以进行资源编辑:

1562918652509

如图所示,作者创建了一个名为Images.qrc的资源文件,其中包括了6个图标文件,它们都在img子目录下。这些工具上有很多功能按钮,读者把鼠标焦点移上去即可看到提示文字。读者也可以在Visual Studio Code的项目目录中看到文件Images.qrc。

A.3.6 UI转Python

Qt Designer的设计成果是扩展名为.ui的文件,比如本项目中的MainWidget.ui。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#MainWidget.ui
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWidget</class>
<widget class="QMainWindow" name="MainWidget">
...
<widget class="QPushButton" name="pbStop">
<property name="font">
<font>
<family>Cambria</family>
<pointsize>16</pointsize>
</font>
</property>
<property name="text">
<string> STOP </string>
</property>
<property name="icon">
</property>
</widget>
...

作者随意从文件中摘取了数行,可以看到,我们在Qt Designer里所作的全部设计成果,都保存在这个XML文件里了,它描述了一个窗口是如何构成的。

如果在程序中需要使用它,我们还需要把UI文件“编译”成Python程序。在MainWidget.ui的编辑框中,右击鼠标,弹出菜单中选择Compile Form(Qt Designer UI File) into Qt for Python File。

1562947355115

在Visual Studio Code的下方控制台中自动执行了下述命令:

1
pyuic5 -d -o ./"Ui_MainWidget.py" "d:\pylearn\C15_BubbleSort\MainWidget.ui"

pyuic5可执行程序将MainWidget.ui翻译转换成Ui_MainWidget.py,该文件出现在项目目录中。接下来,看看Ui_MainWidget.py的内容:

1
2
3
4
5
6
7
8
9
10
11
12
#Ui_MainWidget.py
from PyQt5 import QtCore, QtGui, QtWidgets

class Ui_MainWidget(object):
def setupUi(self, MainWidget):
MainWidget.setObjectName("MainWidget")
MainWidget.resize(1077, 932)
...
self.tbTitle = QtWidgets.QToolButton(self.centralwidget)
self.tbTitle.setEnabled(True)
...
import Images_rc

可以看到,Ui_MainWidget.py定义了一个新类,名为Ui_MainWidget,从object类型继承。这个类型定义了一个函数setupUi(),这个函数负责用代码创建一个MainWidget及其全部下层组件,然后把组件的全部属性都设置好。显然,setupUi()函数的内容是以UI文件为依据的。

由于Ui_MainWidget.py是由UI Compiler编译出来的,所以我们最好不要修改其代码。本章的后续部分,我们会定义一个MainWidget类,从Ui_MainWidget继承。我们将在MainWidget类中添加相关功能代码,避免对Ui_MainWidget.py的修改。

还应注意,上述Ui_MainWidget.py的最后一行,导入了Images_rc模块,这个模块应该是由Images.qrc资源文件编译而得。这是必要的,因为MainWidget中的按钮要使用资源文件中的图标内容。

A.3.7 资源编译

直接在Visual Studio Code中单击Images.qrc文件,可以看到,它不过是一个列出了相关资源路径的XML文件。

1
2
3
4
5
6
7
8
#Images.qrc
<RCC>
<qresource prefix="Images">
<file>/images/copy.png</file>
...
<file>/images/start.png</file>
</qresource>
</RCC>

同样,在Images.qrc的编辑窗口中右击鼠标,弹出菜单中选择Compile Resource File into Qt for Python File。

1562820018363

可以看到,下述命令在Visual Studio Code的下方控制台中被执行,Images_rc.py文件被生成出来。

1
pyrcc5 -o ./"Images_rc.py" "d:\pylearn\C15_BubbleSort\Images.qrc"

打开Images_rc.py,可以看到,资源文件被转换成了bytes字节流,以便后续利用。

1
2
3
4
5
6
7
8
9
#Images_rc.py
from PyQt5 import QtCore

qt_resource_data = b"\
\x00\x00\x22\xb9\
\x89\
\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\
\x00\x02\x00\x00\x00\x02\x00\x08\x06\x00\x00\x00\xf4\x78\xd4\xfa\
...

A.3.8 BubbleSort.py

BubbleSort.py是程序的启动文件。可以看到,它首先创建了一个app对象, 然后创建了MainWidget主窗口(见后节)mw,并执行mw.show()将其显示出来。最后,app.exec_()函数开始消息循环。该应用程序将不断地查询操作系统消息队列,等待操作者的下一步指示。

1
2
3
4
5
6
7
8
9
10
11
#BubbleSort.py
import sys
from PyQt5 import QtWidgets
import MainWidget

app = QtWidgets.QApplication(sys.argv)

mw = MainWidget.MainWidget()
mw.show()

exit(app.exec_())

A.3.9 MainWidget.py

这是我们新建的程序文件,它是应用程序的主窗口。可以看到,MainWidget有两个父类,分别是QMainWindow和Ui_MainWidget。QMainWindow是Qt里表示主窗口的父类。Ui_MainWidget是我们使用UI Compiler从MainWidget.ui“编译”生成的,MainWidget从这个父类里继承了setupUi()函数。通过这个函数,MainWidget对象创建了正确的组件结构,比如,创建了一个名为pbStart的QPushButton对象。pbStart同时也是MainWidget对象的属性。在MainWidget.py中,一般地,使用self.pbStart就可以引用这个按钮对象。

1
2
3
4
5
6
7
8
#MainWidget.py
from PyQt5 import QtWidgets,QtGui,QtCore
import Ui_MainWidget

class MainWidget(QtWidgets.QMainWindow,Ui_MainWidget.Ui_MainWidget):
def __init__(self,parent=None):
super(MainWidget,self).__init__(parent)
self.setupUi(self)

A.3.9.1 国家列表组件

1
2
3
4
5
6
7
8
9
10
11
#MainWidget.py
def createPanelCountries(self):
for i in range(len(self.countries)):
panel = QtWidgets.QToolButton(self.centralwidget)
panel.setFixedHeight(52)
panel.setSizePolicy(QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed))
panel.setFont(QtGui.QFont("Cambria",16))
panel.setIconSize(QtCore.QSize(64,48))
panel.setToolButtonStyle(QtCore.Qt.ToolButtonTextBesideIcon)
self.verticalLayoutCountries.addWidget(panel)
self.panelCountries.append(panel)

如前所述,国家及其GDP数据存储在self.countries列表中。上述createPanelCountries()成员函数则创建相同数量的QToolButton-工具按钮,并存放在self.panelCountries列表当中。这些QToolButton用来显示对应国家的国旗、国名和GDP。

self.verticalLayoutCountries.addWidget(panel)将创建好的工具按钮加入verticalLayoutCountries布局容器,国家与国家将竖向排列,一行一个。从上到下,上述代码还设置了QToolButton的固定高度、SizePolicy-尺寸策略、字体、图标大小、以及工具按钮风格 - 图标在左,文字在旁。请注意,QToolButton的横向尺寸策略为Expanding,这意味着,代表国家的工具按钮将横向扩展,尽可能填满verticalLayoutCountries的横向空间。

需要说明的是,作者在这里用QToolButton组件来显示国家信息完全是因为方便。在运行的界面中,虽然操作者可以点击界面中代表国家的QToolButton,但不会有任何反应。

A.3.9.2 国家列表显示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#MainWidget.py
def displayCountries(self):
if len(self.panelCountries) == 0:
self.createPanelCountries()
assert len(self.panelCountries) == len(self.countries)

for x, y in zip(self.panelCountries,self.countries):
x.setText(" " * 10 + "${:<30,.2f}{}".format(y.fGdp,y.sName))
x.setIcon(QtGui.QIcon("img{}{}".format(os.sep,y.sLogoFile)))
if y.compareState == CompareState.prev:
x.setStyleSheet("background-color: rgb(255, 255, 0);")
elif y.compareState == CompareState.next:
x.setStyleSheet("background-color: rgb(255, 0, 0);")
elif y.compareState == CompareState.fixed:
x.setStyleSheet("background-color: rgb(0, 255, 0);")
else:
x.setStyleSheet("")

在MainWidget的构造函数中,调用了displayCountries()成员函数来完成国家列表的显示。该函数首先检查国家列表组件是否已创建,如果没有,则调用self.createPanelCountries()创建国家列表组件。

接下来,zip()函数将self.panelCountries和self.countries进行了序列缝合,然后再用for循环遍历。在循环体内部,y代表一个Country对象,x则是对应的QToolButton。根据y中的国家名称、GDP,我们设置了对应QToolButton的显示文本-setText();根据y中的国旗图片文件名,我们设置了QToolButton的图标-setIcon();最后,根据y中的compareState枚举值,我们还设置了QToolButton的样式-setStyleSheet(),改变其背景色,以表示对应的国家在当前排序进行程中状态:两两比较的左、右元素,已排序到位或是暂不参与比较。

A.3.9.3 按钮可用性

按下”START”按钮,冒泡排序将开始。而在排序开始后,”START”按钮如果还可以点,则会引起不少逻辑问题。为了避免诸如此类的问题,MainWidget.py中定义了下述函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#MainWidget.py
def setToRunningState(self):
self.pbStart.setEnabled(False)
self.pbStop.setEnabled(True)
self.pbShuffle.setEnabled(False)
self.pbAbout.setEnabled(False)
self.pbExit.setEnabled(False)

def setToIdleState(self):
self.pbStart.setEnabled(True)
self.pbStop.setEnabled(False)
self.pbShuffle.setEnabled(True)
self.pbAbout.setEnabled(True)
self.pbExit.setEnabled(True)

排序开始时,将执行setToRunningState()函数设置到运行态,将“STOP”按钮置为可用,而将其它按钮全部禁用(Disabled)。排序结束后,设置为空闲态的setToIdleState()函数将被执行,此时,除了”STOP”之外的按钮都将被设置成可用(Enabled)。

A.3.9.4 打乱顺序

1
2
3
4
5
6
#MainWidget.py
def on_pbShuffle_released(self):
random.shuffle(self.countries)
for x in self.countries:
x.compareState = CompareState.idle
self.displayCountries()

从”countries.ini”读取的原始数据是有序的,排序前需要先打乱。按下”SHUFFLE”按钮,上述成员函数将会被执行(背后的机制请见本章后续部分)。这个函数使用random模块把self.countries列表内的元素随机打乱。然后,把所有Country都设为“暂不参与比较的空闲状态”,再执行displayCountries()刷新界面上的国家列表。

A.3.10 信号与槽

不同的操作系统在消息循环机制方面存在差异。为了做到跨平台,Qt定义了一套消息分发处理机制,称为信号(signal)和槽(slot)。

信号大概就是消息的同义词,当我们按下某个按钮、或者在主窗口处于活动状态时按下某个键盘键,都将触发一个至多个信号。app.exec_()函数内部的消息循环将处理并分发这些信号至对应的信号处理程序,也就是槽。

在PyQt里,槽通常是个函数。我们可以将信号和槽连接(connect)起来,即信号被触发后,槽函数将在主线程中被调用执行。一个信号可以与一个或者多个槽相连接,也可以一个都不连。

在本实践中,我们使用了两种方法将信号及槽关联起来。其中,一种就是前节中的on_pbShuffle_released()函数,这个成员函数的函数名有点奇怪,以on开头,然后是pbShuffle按钮对象,最后是released。这种成员函数的命名形式其实是PyQt的一种约定:当pbShuffle按钮被鼠标点击触发其released信号后,执行on_pbShuffle_released()函数。PyQt会扫描你的代码并将符合这种名字约定的信号和槽自动连接起来

另一种方法我们已经在15.2节中见过了。btnExit.clicked.connect(QtCore.QCoreApplication.quit)将btnExit按钮的clicked信号与QtCore.QCoreApplication.quit槽函数连接起来。

A.3.11 主线程

app._exec()中的消息循环在程序进程的主线程中运行,主线程主要负责处理这样一些任务:

主线程的使命
- 执行消息循环,消息的解释和封装,信号的分发;调用执行同信号连接的槽函数;
- 界面内容的显示与刷新。

上述信息告诉我们两件事。第一,不要在主线程中处理耗费时间过长的任务。因为如果主线程花费时间片去处理耗时工作-比如向邮件服务器提交一个邮件发送申请,那么在这些耗时的任务未完成之前,主线程是没有时间通过消息循环去接收响应你的指令的。此时,应用程序就有“卡”住的现象:它会不理你,并且界面也不再刷新。

第二,不要在非主线程的其它线程中处理与界面有关的事情,比如修改一个按钮的图标之类。因为,这些都是主线程的任务。当主线程和其它线程竞争性的“同时”使用一些资源时,可能会引起麻烦。

A.3.12 排序线程

实际上,15个国家的GDP冒泡排序花不了多少时间,不属于“耗时”工作的范畴。但由于我们需要演示冒泡排序的执行过程,这意味着,冒泡排序每执行一步, 都需要刷新一下国家列表的显示状态,休息一会儿再继续。这种事情,线程最在行。SortRunner.py定义了本实践中的排序线程。

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
#SortRunner.py
from PyQt5 import QtCore
from Country import CompareState
import time

class SortRunner(QtCore.QThread):
updateInformer = QtCore.pyqtSignal()
def __init__(self, countries, parent):
super().__init__(parent)
self.countries = countries

def run(self):
for x in self.countries:
x.compareState = CompareState.idle
self.updateInformer.emit()
time.sleep(0.2)
for i in range(len(self.countries)-1,0,-1):
for j in range(0,i):
self.countries[j].compareState = CompareState.prev
self.countries[j+1].compareState = CompareState.next
self.updateInformer.emit()
time.sleep(0.5)
if self.countries[j].fGdp < self.countries[j+1].fGdp:
self.countries[j],self.countries[j+1] = \
self.countries[j+1],self.countries[j]
self.updateInformer.emit()
time.sleep(1.0)
self.countries[j].compareState = CompareState.idle
self.countries[j+1].compareState = CompareState.idle
self.countries[i].compareState = CompareState.fixed

self.countries[0].compareState = CompareState.fixed
self.updateInformer.emit()
return

SoftRunner类是QtCore.QThread的子类,QThread是Qt定义的线程祖先类。构造函数接受一个countries列表并将该列表存于self.countries属性。当然,通过super().__init__()执行QThread的构造函数是必不可少的。

run()成员函数是这个线程的实际执行体,里面就是在对self.countries列表进行冒泡排序。注意这里排序比较的不是Country对象本身,而是比较Country的fGdp属性:self.countries[j].fGdp < self.countries[j+1].fGdp。在排序的进行过程中,还有几件事情值得注意:

  • 排序算法会修改Country的compareState枚举值,以便MainWidget显示国家列表时标识排序进度。

  • 排序算法会时不时执行self.updateInformer.emit(),这里的updateInformer是一个信号对象,通过QtCore.pyqtSignal()函数创建。其emit()函数将触发这个信号。当这个信号被触发后,与之连接的MainWidget.py当中的槽函数handlerUpdateInformer将执行并刷新国家列表显示。updateInformer信号与handlerUpdateInformer槽函数的连接在后节中叙述。请读者注意这里的updateInfomer信号对象是一个类属性

    1
    2
    3
    #MainWidget.py
    def handlerUpdateInformer(self):
    self.displayCountries()
  • 排序算法在触发了updateInformer信号后会执行time.sleep()函数,参数单位为秒。time.sleep(0.5)将会主动向操作系统让出时间片,将告知操作系统,0.5秒之内我不再需要时间片,我要休息一会儿。时间到后,重新获得时间片的线程将继续排序过程。如果读者想把排序演示过程进行得慢一点,可以把time.sleep()中的时间延迟改长一点。

A.3.13 线程间的协调

回到主线程中运行的MainWidget.py,”START”按钮点击后,下述槽函数将被执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
#MainWidget.py
def on_pbStart_released(self):
if self.sortRunner != None:
if not self.sortRunner.isFinished():
self.sortRunner.terminate()
assert self.sortRunner.wait(2000)

self.setToRunningState()

self.sortRunner = SortRunner(self.countries,self)
self.sortRunner.updateInformer.connect(self.handlerUpdateInformer)
self.sortRunner.finished.connect(self.setToIdleState)
self.sortRunner.start()

出于稳妥的考虑,代码首先检查了上一次排序操作的线程是否已执行完-isFinished(),如果没有执行完,终止-terminate(),然后等待直到其事实上终止-wait()。如果等待超出2000ms,断言失败。

接下来,将界面设置至运行态,除”STOP”之外的按钮都不再可用。然后,创建了SortRunner线程对象并将self.countries列表数据传递给它。注意,由于“名字绑定”的关系,SortRunner内如果修改了countries内的元素,MainWidget的self.countries也会改变(两者事实上是同一个列表)。

self.sortRunner.updateInformer.connect(self.handlerUpdateInformer)将sortRunner的updateInfomer信号连接至handlerUpdateInformer槽函数。这意味着,当sortRunner线程触发该信号后,该信号将会进入app.exec_()内的消息循环,主线程收到这个消息/信号后将调用对应的槽函数。对于操作系统而言,主线程和排序线程各自独立地领取时间片,排序线程只管触发信号,至于信号的槽函数何时被主线程执行,就不得而知了。但一般的,只要CPU不是过于繁忙,都会很快执行。

self.sortRunner.finished.connect(self.setToIdleState)这一行将sortRunner的finished信号连接到setToIdleState槽函数。finished信号是从QThread类继承过来的,它表明线程的run()函数已执完并返回。线程结束即排序结束,setToIdleState将界面设置成空闲态,”START”等按钮重新变得可用。

另外,pbStop也有对应的槽函数:

1
2
3
4
#MainWidget.py
def on_pbStop_released(self):
assert self.sortRunner != None
self.sortRunner.terminate()

terminate()要求终止sortRunner线程的执行。当它真正被终止时,其finished信号将被触发,MainWidget.py内的setToIdleState槽函数将被主线程执行,界面恢复至空闲状态。

到这里,我们终于说完了程序的主体部分,Enjoy it!

A.3.14 关于对话框

为了向读者展示如何打开一个对话框(dialog),实例中还加入了一个“ABOUT”按钮功能。这很重要,因为,绝大多数应用程序都有一个主窗口及多个对话框子窗口。

1562918438439

About.ui是用Qt Designer设计的,编译之后有了 Ui_About.py。然后继承之,得About.py中的About类,请注意,About的父类不再是QMainWindow或者QWidget,而是QDiaglog - Qt中的对话框祖先类。

1
2
3
4
5
6
7
8
9
10
11
#About.py
from PyQt5 import QtWidgets
from Ui_About import Ui_About

class About(QtWidgets.QDialog,Ui_About):
def __init__(self,parent):
QtWidgets.QDialog.__init__(self,parent)
self.setupUi(self)

def on_pbClose_released(self):
self.close()

​ 在MainWidget.py里:

1
2
3
4
#MainWidget.py
def on_pbAbout_released(self):
dlg = About.About(self)
dlg.exec()

dlg.exec()使得对话框以模式对话框(modal dialog)形式执行,这意味着,在这个对话框结束之前,你无法再操作背后的主窗口。非模式对话框的执行函数为dlg.show()。

A.3.15 有坑请注意

1
2
3
4
#MainWidget.py
def on_pbAbout_released(self):
dlg = About.About(self)
dlg.exec()

如上图,在本实践中,我们一直在响应QPushButton的released信号而不是clicked信号。在传统习惯中,clicked表示按钮被鼠标点击一次。至少在PyQt里,事实不是这样,我们尝试一下把上述代码改成响应clicked信号:

1
2
def on_pbAbout_clicked(self):
print("About button clicked.")

运行后,点一次About按钮,可以注意如下控制台输出,看起来,一次鼠标点击导致了两次clicked信号的发射。据推测可能是Qt内部把鼠标按下与鼠标弹起分别当成一个clicked事件处理。部分初学者不明就里,响应clicked信号进行事件处理,遇到了很多无法解释的软件BUG。作者提醒读者记住这一点,PyQt图形应用中,对于按钮,标准方法是响应released信号。

1
2
About button clicked.
About button clicked.

A.3.16 小结

这一章的实例功能并不复杂,但信息量很大。Qt是个非常复杂,功能强大的跨平台GUI软件开发工具包。本实例只是起到把读者领进门的作用。如果读者真的希望用PyQt做点复杂的图形应用,还需要消化很多的资料,本章节远远不够。

另外,本实例中,SortRunner只管排序并向主线程发出界面刷新请求;而MainWidget.py中的代码只管展示和协调工作,不参与排序细节。MainWidget.py中的self.countries是国家列表的数据,self.panelCountries是国家列表显示的按钮列表,两者相互独立,仅在displayCountries()函数中同时使用两个列表:数据和展示是分开的。

这些细节处理试图降低程序不同部分之间的耦合度, 以达到所谓松散耦合的目的。而松散耦合的程序,容易理解和维护。

本书同名免费MOOC《Python编程基础及应用》在哔哩哔哩(B站)热播,点击加入,作者带着你学。


练习

15-1 本实践中,SortRunner线程需要使用countries列表,MainWidget.py中的主线程也在使用同一个countries列表进行数据展示。事实上,两个线程在竞争性地使用该资源,有时,这样做会带来后果。我们希望有一种机制,可以确保同一时间,仅有一个线程使用该资源-countries列表,如果另一个线程需要用,则稍等等,等另一个线程使用完后再用。请查询资料,解决该问题。

提示:关键词mutex, 互斥锁。

15-2 试着修改本实践,增加一个进度条,显示排序进展到总进程的百分之多少。

15-3 本实践中,为了实现排序进程的演示并保证界面的及时响应,我们启用了排序线程。就本实践而言,使用定时器可以达到类似目的。请修改程序,改用定时器实现相同功能。提示:资料查询关键词QTimer, PyQt5。

15-4 在实践中,人们极少自行编写排序代码进行排序,而是尽可能地借助于列表的sort函数实现排序。就本实践MainWidget.py中的self.countries列表,请借助于列表的sort()函数,将该列表按GDP升序排序。

提示:key参数可以指向某个自定义函数或者lambda函数,该函数接受一个Country对象参数,返回该对象的GDP值。