精通-Python-GUI-编程-全-
精通 Python GUI 编程(全)
原文:
zh.annas-archive.org/md5/0baee48435c6a8dfb31a15ece9441408译者:飞龙
前言
在一个时代,应用程序开发人员几乎总是意味着网络应用程序开发人员的时代,构建桌面 GUI 应用程序似乎有可能变成一种古雅而晦涩的艺术。然而,在每一个讨论编程的论坛、邮件列表和聊天服务中,我发现年轻的 Python 编程人员渴望深入研究 GUI 工具包,以便开始构建任何普通人都可以轻松识别为应用程序的软件。对于这些学习者一直推荐的一个 GUI 库,也可以说是 Python 最令人兴奋和最完整的工具包,就是 PyQt。
尽管 PyQt 很受欢迎,但学习 PyQt 的资源相对较少。那些希望学习它的人必须严重依赖过时的书籍、C++文档、零散的博客或在邮件列表或 Stack Overflow 帖子中找到的代码片段。显然,Python 程序员需要一本现代的 PyQt 教程和参考书,而这本书旨在填补这一需求。
我的第一本书《使用 Tkinter 进行 Python GUI 编程》专注于使用 Tkinter 进行应用程序开发的基础知识,涵盖了诸如界面设计、单元测试、程序架构和打包等核心主题。在这本书中,我希望超越基础知识,不仅教你如何构建数据驱动的业务表单(许多工具包都可以生成,许多其他书籍都可以教你编写),而且探索 PyQt 提供的更令人兴奋和独特的可能性:多媒体、动画、3D 图形、图像处理、网络、多线程等。当然,这本书也不会忽视业务方面的内容,包括数据输入表单、SQL 数据库和图表。
写技术书籍的作者有两种。第一种是绝对的专家,具有不可动摇的权威和对所讨论主题的百科全书式知识,能够凭借深刻的理解力提供完美地满足学习者最迫切需求的解释。
第二种作者是一个普通人,具有对基础知识的合理熟悉度,愿意研究未知的内容,最重要的是,他们有顽强的决心,确保印刷出版的每一种陈述都是完整和正确的真相。这种作者必须准备好在写作过程中停下来,测试解释器或代码编辑器中的声明;花费数小时阅读文档、邮件列表线程、代码注释和 IRC 日志,以追求更正确的理解;当新的事实揭示了他们最初的假设存在错误时,删除和重写大部分工作。
当有人要求我写一本关于 PyQt5 的书时,我不能声称自己是第一种作者(现在也不行);虽然我在工作中和开源世界中开发和维护了几个 PyQt 应用程序,但我对 PyQt 的理解很少超出我自己代码的简单需求。因此,我立志成为第二种类型的作者,致力于勤奋学习和费力地将可用信息的混乱大量筛选和提炼成一篇文章,以指导有抱负的 GUI 程序员掌握 PyQt。
作为五个孩子的自豪父亲,其中一些孩子对编程有着萌芽(如果不是蓬勃)的兴趣,我在过去的六个月里努力写了一本书,如果他们希望学习这些技能,我可以自信和认真地放在他们面前。亲爱的读者,我希望你在这本书中感受到我的父母对你的成长和进步的热情,我们一起攻克这个主题。
这本书是为谁写的
本书适用于希望深入了解 PyQt 应用程序框架并学习如何制作强大 GUI 应用程序的中级 Python 程序员。假设读者了解 Python 语法、特性和习惯用法,如函数、类和常见标准库工具。还假设读者有一个可以舒适地编写和执行 Python 代码的环境。
本书不假设读者具有任何 GUI 开发、其他 GUI 工具包或其他版本的 PyQt 的先前知识。
本书涵盖内容
第一章《PyQt 入门》,向您介绍了 Qt 和 PyQt 库。您将学习如何设置系统以编写 PyQt 应用程序,并介绍 Qt Designer。您还将编写传统的Hello World应用程序,并开发 PyQt 应用程序的基本模板。
第二章《使用 QtWidgets 构建表单》,向您展示了制作 PyQt GUI 的基础知识。您将了解最常见的输入和显示小部件,学会使用布局来排列它们,并学会验证用户输入。您将应用这些技能来开发日历 GUI。
第三章《使用信号和槽处理事件》,专注于 PyQt 的事件处理和对象通信系统。您将学习如何使用此系统使应用程序响应用户输入,以及如何创建自定义信号和槽。您将通过完成日历应用程序来应用这些技能。
第四章《使用 QMainWindow 构建应用程序》,向您介绍了QMainWindow类,它是本书其余部分应用程序的基础。您还将探索 PyQt 的标准对话框类和QSettings模块,用于保存应用程序的配置。
第五章《使用模型视图类创建数据接口》,专注于 Qt 的模型视图类。您将学习模型视图设计原则,探索QtWidgets中的模型视图类,并在开发 CSV 编辑器时练习您的知识。
第六章《美化 Qt 应用程序》,探讨了 PyQt 小部件的样式能力。您将通过自定义字体、图像和图标为 GUI 应用程序增添趣味。您将学会使用样式对象和 Qt 样式表自定义颜色。最后,我们将学习如何对样式属性进行基本动画。
第七章《使用 QtMultimedia 处理音视频》,探索了 Qt 的多媒体功能。您将学习如何在各个平台上无缝播放和录制音频和视频。
第八章《使用 QtNetwork 进行网络通信》,专注于使用QtNetwork库进行简单的网络通信。您将学习如何通过原始套接字进行通信,包括传输控制协议(TCP)和用户数据报协议(UDP),以及学习如何使用 HTTP 传输和接收文件和数据。
第九章《使用 QtSQL 探索 SQL》,向您介绍了 SQL 数据库编程的世界。您将学习 SQL 的基础知识和 SQLite 数据库。然后,您将学习您的 PyQt 应用程序如何使用QtSQL库来使用原始 SQL 命令或 Qt 的 SQL 模型视图类访问数据。
第十章《使用 QTimer 和 QThread 进行多线程》,介绍了多线程和异步编程的世界。您将学习如何使用定时器延迟事件循环中的任务,并学习如何使用QThread将进程推入单独的执行线程。您还将学习如何使用QThreadPool进行高并发编程。
《第十一章》,使用 QTextDocument 创建丰富文本,探索了 Qt 中的丰富文本和文档准备。你将了解 Qt 的丰富文本标记语言,并学习如何使用QTextDocument以编程方式构建文档。你还将学习如何使用 Qt 的打印库,轻松实现跨平台的文档打印。
《第十二章》,使用 Qpainter 创建 2D 图形,深入探讨了 Qt 中的二维图形。你将学习如何加载和编辑图像,创建自定义小部件。你还将了解使用 Qt 图形系统进行绘制和动画,并创建一个街机风格的游戏。
《第十三章》,使用 QtOpenGL 创建 3D 图形,向你介绍了 OpenGL 的 3D 图形。你将学习现代 OpenGL 编程的基础知识,以及如何使用 PyQt 小部件来显示和与 OpenGL 图形进行交互。
《第十四章》,使用 QtCharts 嵌入数据图表,探索了 Qt 内置的图表功能。你将学习如何创建静态和动画图表,以及如何自定义图表的颜色、字体和样式。
《第十五章》,PyQt 树莓派,着重介绍了在树莓派计算机上使用 PyQt。你将学习如何在 Raspbian Linux 上设置 PyQt,以及如何将 PyQt 的强大功能与树莓派的 GPIO 引脚结合起来,创建与真实电路交互的 GUI 应用程序。
《第十六章》,使用 QtWebEngine 进行网页浏览,探讨了 PyQt 的基于 Chromium 的网页浏览器模块。你将在构建自己的多标签网页浏览器时探索这个模块的功能。
《第十七章》,为软件分发做准备,讨论了准备代码进行共享和分发的各种方法。我们将研究最佳的项目布局,使用setuptools为其他 Python 用户打包源代码,以及使用 PyInstaller 构建独立可执行文件。
《附录 A》,问题的答案,包含了每章末尾问题的答案或建议。
《附录 B》,将 Raspbian 9 升级到 Raspbian 10,解释了如何将树莓派设备从 Raspbian 9 升级到 Raspbian 10,供那些在正式发布 Raspbian 10 之前尝试跟随本书的读者参考。
为了充分利用本书
读者应该精通 Python 语言,特别是 Python 3。你应该至少在基本层面上理解如何使用类和面向对象编程。如果你对 C++有一定的了解,可能会有所帮助,因为大部分可用的 Qt 文档都是针对这种语言的。
你应该有一台安装了 Python 3.7 的 Windows、macOS 或 Linux 系统的计算机,并且可以根据需要安装其他软件。你应该有一个你熟悉的代码编辑器和命令行 shell。最后,你应该能够接入互联网。
本书的每一章都包含一个或多个示例应用。尽管这些示例可以下载,但鼓励你跟着操作,手动创建这些应用程序,以便看到应用程序在中间阶段的形成过程。
每一章还包含一系列问题或建议的项目,以巩固你对主题的知识,并提供了一些资源供进一步学习。如果你在解决这些问题和阅读提供的材料时能够运用你的头脑和创造力,你将能够充分利用每一章。
本书中包含的代码是根据开源 MIT 许可发布的,允许您根据自己的需要重复使用代码,前提是您保留了包含的版权声明。鼓励您使用、修改、改进和重新发布这些程序。
下载示例代码文件
您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packt.com/support并注册,以便将文件直接发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
在www.packt.com上登录或注册。
-
选择“支持”选项卡。
-
单击“代码下载和勘误”。
-
在搜索框中输入书名,然后按照屏幕上的说明操作。
文件下载后,请确保使用最新版本的以下工具解压或提取文件夹:
-
WinRAR/7-Zip 适用于 Windows
-
Zipeg/iZip/UnRarX 适用于 Mac
-
7-Zip/PeaZip 适用于 Linux
本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还提供了来自我们丰富书籍和视频目录的其他代码包,可在github.com/PacktPublishing/上找到。快去看看吧!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:www.packtpub.com/sites/default/files/downloads/9781789612905_ColorImages.pdf。
代码实例
访问以下链接查看代码运行的视频:bit.ly/2M3QVrl
使用的约定
本书中使用了许多文本约定。
CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“HTML 文档是按层次结构构建的,最外层的标签通常是<html>。”
代码块设置如下:
<table border=2>
<thead>
<tr bgcolor='grey'><th>System</th><th>Graphics</th><th>Sound</th></tr>
</thead>
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
<table border=2>
<thead>
<tr bgcolor='grey'><th>System</th><th>Graphics</th><th>Sound</th></tr>
</thead>
任何命令行输入或输出都以以下方式编写:
$ python game_lobby.py
Font is Totally Nonexistent Font Family XYZ
Actual font used is Bitstream Vera Sans
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这样的方式出现在文本中。这是一个例子:“从管理面板中选择系统信息。”
警告或重要说明会以这种方式出现。提示和技巧会以这种方式出现。
第一部分:深入了解 PyQt
在本节中,您将探索 PyQt 的核心功能。在本节结束时,您应该能够熟悉 PyQt 应用程序编写中涉及的基本设计工作流程和编码习惯,并且对构建简单的 PyQt 界面有信心。
本节包括以下章节:
-
第一章,PyQt 入门
-
第二章,使用 QtWidgets 构建表单
-
第三章,使用信号和槽处理事件
-
第四章,使用 QMainWindow 构建应用
-
第五章,使用模型-视图类创建数据接口
-
第六章,美化 Qt 应用
第一章:开始使用 PyQt
欢迎,Python 程序员!
Python 是一个用于系统管理、数据分析、Web 服务和命令行程序的优秀语言;很可能您已经在其中至少一个领域发现了 Python 的用处。然而,构建出用户可以轻松识别为程序的 GUI 驱动应用程序确实令人满意,这种技能应该是任何优秀软件开发人员的工具箱中的一部分。在本书中,您将学习如何使用 Python 和 Qt 框架开发令人惊叹的应用程序-从简单的数据输入表单到强大的多媒体工具。
我们将从以下主题开始介绍这些强大的技术:
-
介绍 Qt 和 PyQt
-
创建
Hello Qt-我们的第一个窗口 -
创建 PyQt 应用程序模板
-
介绍 Qt Designer
技术要求
对于本章和本书的大部分内容,您将需要以下内容:
-
一台运行Microsoft Windows,Apple macOS或 64 位GNU/Linux的 PC。
-
Python 3,可从
www.python.org获取。本书中的代码需要 Python 3.7 或更高版本。 -
PyQt 5.12,您可以使用以下命令从 Python 软件包索引中安装:
$ pip install --user PyQt5
-
Linux 用户也可以从其发行版的软件包存储库中安装 PyQt5。
-
Qt Designer 4.9是一款来自
www.qt.io的所见即所得的 GUI 构建工具。有关安装说明,请参阅以下部分。 -
来自
github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter01的示例代码.
查看以下视频以查看代码的运行情况:bit.ly/2M5OUeg
安装 Qt Designer
在 Windows 或 macOS 上,Qt Designer 是 Qt 公司的 Qt Creator IDE 的一部分。这是一个免费的 IDE,您可以用来编码,尽管在撰写本文时,它主要面向 C++,对 Python 的支持还很初级。无论您是否在 Qt Creator 中编写代码,都可以使用 Qt Designer 组件。
您可以从download.qt.io/official_releases/qtcreator/4.9/4.9.0/下载 Qt Creator 的安装程序。
尽管 Qt 公司为 Linux 提供了类似的独立 Qt 安装程序,但大多数 Linux 用户更倾向于使用其发行版存储库中的软件包。一些发行版提供 Qt Designer 作为独立应用程序,而其他发行版则将其包含在其 Qt Creator 软件包中。
此表显示了在几个主要发行版中安装 Qt Designer 的软件包:
| 发行版 | 软件包名称 |
|---|---|
| Ubuntu,Debian,Mint | qttools5-dev-tools |
| Fedora,CentOS,Red Hat,SUSE | qt-creator |
| Arch,Manjaro,Antergos | qt5-tools |
介绍 Qt 和 PyQt
Qt 是一个为 C++设计的跨平台应用程序框架。它有商业和开源许可证(通用公共许可证(GPL)v3 和较宽松的通用公共许可证(LGPL)v3),被广泛应用于开源项目,如 KDE Plasma 和 Oracle VirtualBox,商业软件如 Adobe Photoshop Elements 和 Autodesk Maya,甚至是 LG 和 Panasonic 等公司产品中的嵌入式软件。Qt 目前由 Qt 公司(www.qt.io)拥有和维护。
在本书中,我们将使用 Qt 5.12 的开源版本。如果您使用的是 Windows、macOS 或主要的 Linux 发行版,您不需要显式安装 Qt;当您安装 PyQt5 时,它将自动安装。
Qt 的官方发音是cute,尽管许多人说Q T。
PyQt5
PyQt 是一个允许 Qt 框架在 Python 代码中使用的 Python 库。它是由 Riverbank Computing 在 GPL 许可下开发的,尽管商业许可证可以用于购买想要开发专有应用程序的人。(请注意,这是与 Qt 许可证分开的许可证。)它目前支持 Windows、Linux、UNIX、Android、macOS 和 iOS。
PyQt 的绑定是由一个名为SIP的工具自动生成的,因此,在很大程度上,使用 PyQt 就像在 Python 中使用 Qt 本身一样。换句话说,类、方法和其他对象在用法上都是相同的,除了语言语法。
Qt 公司最近发布了Qt for Python(也称为PySide2),他们自己的 Python Qt5 库,遵循 LGPL 条款。 Qt for Python 在功能上等同于 PyQt5,代码可以在它们之间进行很少的更改。本书将涵盖 PyQt5,但您学到的知识可以轻松应用于 Qt for Python,如果您需要一个 LGPL 库。
使用 Qt 和 PyQt
Qt 不仅仅是一个 GUI 库;它是一个应用程序框架。它包含数十个模块,数千个类。它有用于包装日期、时间、URL 或颜色值等简单数据类型的类。它有 GUI 组件,如按钮、文本输入或对话框。它有用于硬件接口,如相机或移动传感器的接口。它有一个网络库、一个线程库和一个数据库库。如果说什么,Qt 真的是第二个标准库!
Qt 是用 C++编写的,并且围绕 C++程序员的需求进行设计;它与 Python 很好地配合,但 Python 程序员可能会发现它的一些概念起初有些陌生。
例如,Qt 对象通常希望使用包装在 Qt 类中的数据。一个期望颜色值的方法不会接受字符串或 RGB 值的元组;它需要一个QColor对象。一个返回大小的方法不会返回(width, height)元组;它会返回一个QSize对象。PyQt 通过自动在 Qt 对象和 Python 标准库类型之间转换一些常见数据类型(例如字符串、列表、日期和时间)来减轻这种情况;然而,Python 标准库中没有与 Qt 类对应的数百个 Qt 类。
Qt 在很大程度上依赖于称为enums或flags的命名常量来表示选项设置或配置值。例如,如果您想要在最小化、浮动或最大化之间切换窗口的状态,您需要传递一个在QtCore.Qt.WindowState枚举中找到的常量给窗口。
在 Qt 对象上设置或检索值需要使用访问器方法,有时也称为设置器和获取器方法,而不是直接访问属性。
对于 Python 程序员来说,Qt 似乎有一种近乎狂热的执着于定义类和常量,你会花费很多时间在早期搜索文档以定位需要配置对象的项目。不要绝望!您很快就会适应 Qt 的工作方式。
理解 Qt 的文档
Qt 是一个庞大而复杂的库,没有任何印刷书籍能够详细记录其中的大部分内容。因此,学会如何访问和理解在线文档非常重要。对于 Python 程序员来说,这是一个小挑战。
Qt 本身拥有详细和优秀的文档,记录了所有 Qt 模块和类,包括示例代码和关于使用 Qt 进行编码的高级教程。然而,这些文档都是针对 C++开发的;所有示例代码都是 C++,并且没有指示 Python 的方法或解决问题的方法何时有所不同。
PyQt 的文档要少得多。它只涵盖了与 Python 相关的差异,并缺乏全面的类参考、示例代码和教程,这些都是 Qt 文档的亮点。对于任何使用 PyQt 的人来说,这是必读的,但它并不完整。
随着 Qt for Python 的发布,正在努力将 Qt 的 C++文档移植到 Python,网址为doc-snapshots.qt.io/qtforpython/。完成后,这也将成为 PyQt 程序员的宝贵资源。不过,在撰写本文时,这一努力还远未完成;无论如何,PyQt 和 Qt for Python 之间存在细微差异,这可能使这些文档既有帮助又令人困惑。
如果您对 C++语法有一些基本的了解,将 Qt 文档精神翻译成 Python 并不太困难,尽管在许多情况下可能会令人困惑。本书的目标之一是弥合那些对 C++不太熟悉的人的差距。
核心 Qt 模块
在本书的前六章中,我们将主要使用三个 Qt 模块:
-
QtCore包含低级数据包装类、实用函数和非 GUI 核心功能 -
QtGui包含特定于 GUI 的数据包装类和实用程序 -
QtWidgets定义了 GUI 小部件、布局和其他高级 GUI 组件
这三个模块将在我们编写的任何 PyQt 程序中使用。本书后面,我们将探索其他用于图形、网络、Web 渲染、多媒体和其他高级功能的模块。
创建 Hello Qt-我们的第一个窗口
现在您已经了解了 Qt5 和 PyQt5,是时候深入了解并进行一些编码了。确保一切都已安装好,打开您喜爱的 Python 编辑器或 IDE,让我们开始吧!
在您的编辑器中创建一个hello_world.py文件,并输入以下内容:
from PyQt5 import QtWidgets
我们首先导入QtWidgets模块。该模块包含 Qt 中大部分的小部件类,以及一些其他重要的用于 GUI 创建的组件。对于这样一个简单的应用程序,我们不需要QtGui或QtCore。
接下来,我们需要创建一个QApplication对象,如下所示:
app = QtWidgets.QApplication([])
QApplication对象表示我们运行应用程序的状态,必须在创建任何其他 Qt 小部件之前创建。QApplication应该接收一个传递给我们脚本的命令行参数列表,但在这里我们只是传递了一个空列表。
现在,让我们创建我们的第一个小部件:
window = QtWidgets.QWidget(windowTitle='Hello Qt')
在 GUI 工具包术语中,小部件指的是 GUI 的可见组件,如按钮、标签、文本输入或空面板。在 Qt 中,最通用的小部件是QWidget对象,它只是一个空白窗口或面板。在创建此小部件时,我们将其windowTitle设置为'Hello Qt'。windowTitle就是所谓的属性。所有 Qt 对象和小部件都有属性,用于配置小部件的不同方面。在这种情况下,windowTitle是程序窗口的名称,并显示在窗口装饰、任务栏或停靠栏等其他地方,取决于您的操作系统和桌面环境。
与大多数 Python 库不同,Qt 属性和方法使用驼峰命名法而不是蛇形命名法。
用于配置 Qt 对象的属性可以通过将它们作为构造函数参数传递或使用适当的 setter 方法进行设置。通常,这只是set加上属性的名称,所以我们可以这样写:
window = QtWidgets.QWidget()
window.setWindowTitle('Hello Qt')
属性也可以使用 getter 方法进行检索,这只是属性名称:
print(window.windowTitle())
创建小部件后,我们可以通过调用show()使其显示,如下所示:
window.show()
调用show()会自动使window成为自己的顶级窗口。在第二章中,使用 Qt 小部件构建表单,您将看到如何将小部件放置在其他小部件内,但是对于这个程序,我们只需要一个顶级小部件。
最后一行是对app.exec()的调用,如下所示:
app.exec()
app.exec()开始QApplication对象的事件循环。事件循环将一直运行,直到应用程序退出,处理我们与 GUI 的用户交互。请注意,app对象从不引用window,window也不引用app对象。这些对象在后台自动连接;您只需确保在创建任何QWidget对象之前存在一个QApplication对象。
保存hello_world.py文件并从编辑器或命令行运行脚本,就像这样:
python hello_world.py
当您运行此代码时,您应该会看到一个空白窗口,其标题文本为Hello Qt:

这不是一个非常激动人心的应用程序,但它确实展示了任何 PyQt 应用程序的基本工作流程:
-
创建一个
QApplication对象 -
创建我们的主应用程序窗口
-
显示我们的主应用程序窗口
-
调用
QApplication.exec()来启动事件循环
如果您在 Python 的Read-Eval-Print-Loop(REPL)中尝试使用 PyQt,请通过传入一个包含单个空字符串的列表来创建QApplication对象,就像这样:QtWidgets.QApplication(['']);否则,Qt 会崩溃。此外,在 REPL 中不需要调用QApplication.exec(),这要归功于一些特殊的 PyQt 魔法。
创建一个 PyQt 应用程序模板
hello_world.py演示了在屏幕上显示 Qt 窗口的最低限度的代码,但它过于简单,无法作为更复杂应用程序的模型。在本书中,我们将创建许多 PyQt 应用程序,因此为了简化事情,我们将组成一个基本的应用程序模板。未来的章节将参考这个模板,所以确保按照指定的方式创建它。
打开一个名为qt_template.py的新文件,并添加这些导入:
import sys
from PyQt5 import QtWidgets as qtw
from PyQt5 import QtGui as qtg
from PyQt5 import QtCore as qtc
我们将从导入sys开始,这样我们就可以向QApplication传递一个实际的脚本参数列表;然后我们将导入我们的三个主要 Qt 模块。为了节省一些输入,同时避免星号导入,我们将它们别名为缩写名称。我们将在整本书中一贯使用这些别名。
星号导入(也称为通配符导入),例如from PyQt5.QtWidgets import *,在教程中很方便,但在实践中最好避免使用。这样做会使您的命名空间充满了数百个类、函数和常量,其中任何一个您可能会意外地用变量名覆盖。避免星号导入还将帮助您了解哪些模块包含哪些常用类。
接下来,我们将创建一个MainWindow类,如下所示:
class MainWindow(qtw.QWidget):
def __init__(self):
"""MainWindow constructor"""
super().__init__()
# Main UI code goes here
# End main UI code
self.show()
为了创建我们的MainWindow类,我们对QWidget进行子类化,然后重写构造方法。每当我们在未来的章节中使用这个模板时,请在注释行之间开始添加您的代码,除非另有指示。
对 PyQt 类进行子类化是一种构建 GUI 的好方法。它允许我们定制和扩展 Qt 强大的窗口部件类,而无需重新发明轮子。在许多情况下,子类化是利用某些类或完成某些自定义的唯一方法。
我们的构造函数以调用self.show()结束,因此我们的MainWindow将负责显示自己。
始终记得在子类的构造函数中调用super().__init__(),特别是在 Qt 类中。不这样做意味着父类没有得到正确设置,肯定会导致非常令人沮丧的错误。
我们将用主要的代码执行完成我们的模板:
if __name__ == '__main__':
app = qtw.QApplication(sys.argv)
mw = MainWindow()
sys.exit(app.exec())
在这段代码中,我们将创建我们的QApplication对象,制作我们的MainWindow对象,然后调用QApplication.exec()。虽然这并不是严格必要的,但最好的做法是在全局范围内创建QApplication对象(在任何函数或类的外部)。这确保了应用程序退出时所有 Qt 对象都能得到正确关闭和清理。
注意我们将sys.argv传递给QApplication();Qt 有几个默认的命令行参数,可以用于调试或更改样式和主题。如果你传入sys.argv,这些参数将由QApplication构造函数处理。
还要注意,我们在调用sys.exit时调用了app.exec();这是一个小技巧,使得app.exec()的退出代码传递给sys.exit(),这样如果底层的 Qt 实例由于某种原因崩溃,我们就可以向操作系统传递适当的退出代码。
最后,注意我们在这个检查中包装了这个块:
if __name__ == '__main__':
如果你以前从未见过这个,这是一个常见的 Python 习语,意思是:只有在直接调用这个脚本时才运行这段代码。通过将我们的主要执行放在这个块中,我们可以想象将这个文件导入到另一个 Python 脚本中,并能够重用我们的MainWindow类,而不运行这个块中的任何代码。
如果你运行你的模板代码,你应该会看到一个空白的应用程序窗口。在接下来的章节中,我们将用各种小部件和功能来填充这个窗口。
介绍 Qt Designer
在我们结束对 Qt 的介绍之前,让我们看看 Qt 公司提供的一个免费工具,可以帮助我们创建 PyQt 应用程序——Qt Designer。
Qt Designer 是一个用于 Qt 的图形 WYSIWYG GUI 设计师。使用 Qt Designer,你可以将 GUI 组件拖放到应用程序中并配置它们,而无需编写任何代码。虽然它确实是一个可选工具,但你可能会发现它对于原型设计很有用,或者比手工编写大型和复杂的 GUI 更可取。虽然本书中的大部分代码将是手工编写的,但我们将在第二章《使用 Qt 小部件构建表单》和第三章《使用信号和槽处理事件》中介绍在 PyQt 中使用 Qt Designer。
使用 Qt Designer
让我们花点时间熟悉如何启动和使用 Qt Designer:
-
启动 Qt Creator
-
选择文件|新建文件或项目
-
在文件和类下,选择 Qt
-
选择 Qt Designer 表单
-
在选择模板表单下,选择小部件,然后点击下一步
-
给你的表单取一个名字,然后点击下一步
-
点击完成
你应该会看到类似这样的东西:

如果你在 Linux 上将 Qt Designer 作为独立应用程序安装,可以使用designer命令启动它,或者从程序菜单中选择它。你不需要之前的步骤。
花几分钟时间来测试 Qt Designer:
-
从左侧窗格拖动一些小部件到基本小部件上
-
如果你愿意,可以调整小部件的大小,或者选择一个小部件并在右下角的窗格中查看它的属性
-
当你做了几次更改后,选择工具|表单编辑器|预览,或者按Alt + Shift + R,来预览你的 GUI。
在第二章《使用 Qt 小部件构建表单》中,我们将详细介绍如何使用 Qt Designer 构建 GUI 界面;现在,你可以在doc.qt.io/qt-5/qtdesigner-manual.html的手册中找到更多关于 Qt Designer 的信息。
总结
在本章中,你了解了 Qt 应用程序框架和 PyQt 对 Qt 的 Python 绑定。我们编写了一个Hello World应用程序,并创建了一个构建更大的 Qt 应用程序的模板。最后,我们安装并初步了解了 Qt Designer,这个 GUI 编辑器。
在第二章《使用 Qt 小部件构建表单》中,我们将熟悉一些基本的 Qt 小部件,并学习如何调整和排列它们在用户界面中。然后,你将通过代码和 Qt Designer 设计一个日历应用程序来应用这些知识。
问题
尝试这些问题来测试你从本章学到的知识:
-
Qt 是用 C++编写的,这是一种与 Python 非常不同的语言。这两种语言之间有哪些主要区别?在使用 Python 中的 Qt 时,这些区别可能会如何体现?
-
GUI 由小部件组成。在计算机上打开一些 GUI 应用程序,并尝试识别尽可能多的小部件。
-
以下程序崩溃了。找出原因,并修复它以显示一个窗口:
from PyQt5.QtWidgets import *
app = QWidget()
app.show()
QApplication().exec()
-
QWidget类有一个名为statusTip的属性。以下哪些最有可能是该属性的访问方法的名称? -
getStatusTip()和setStatusTip() -
statusTip()和setStatusTip() -
get_statusTip()和change_statusTip() -
QDate是用于封装日历日期的类。你期望在三个主要的 Qt 模块中的哪一个找到它? -
QFont是定义屏幕字体的类。你期望在三个主要的 Qt 模块中的哪一个找到它? -
你能使用 Qt Designer 重新创建
hello_world.py吗?确保设置windowTitle。
进一步阅读
查看以下资源,了解有关 Qt、PyQt 和 Qt Designer 的更多信息:
-
pyqt.sourceforge.net/Docs/PyQt5/上的PyQt 手册是了解 PyQt 独特方面的方便资源 -
doc.qt.io/qt-5/qtmodules.html上的Qt 模块列表提供了 Qt 中可用模块的概述 -
请查看
doc.qt.io/qt-5/qapplication.html#QApplication上的QApplication文档,列出了QApplication对象解析的所有命令行开关 -
doc.qt.io/qt-5/qwidget.html上的QWidget文档显示了QWidget对象中可用的属性和方法 -
doc.qt.io/qt-5/qtdesigner-manual.html上的Qt Designer 手册将帮助您探索 Qt Designer 的全部功能 -
如果你想了解更多关于 C++的信息,请查看 Packt 提供的这些内容
www.packtpub.com/tech/C-plus-plus
第二章:使用 QtWidgets 构建表单
应用程序开发的第一步之一是原型设计应用程序的 GUI。有了各种各样的现成小部件,PyQt 使这变得非常容易。最重要的是,当我们完成后,我们可以直接将我们的原型代码移植到实际应用程序中。
在这一章中,我们将通过以下主题熟悉基本的表单设计:
-
创建基本的 QtWidgets 小部件
-
放置和排列小部件
-
验证小部件
-
构建一个日历应用程序的 GUI
技术要求
要完成本章,您需要从第一章 PyQt 入门中获取所有内容,以及来自github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter02的示例代码。
查看以下视频以查看代码的实际效果:bit.ly/2M2R26r
创建基本的 QtWidgets 小部件
QtWidgets模块包含数十个小部件,有些简单和标准,有些复杂和独特。在本节中,我们将介绍八种最常见的小部件及其基本用法。
在开始本节之前,从第一章 PyQt 入门中复制您的应用程序模板,并将其保存到名为widget_demo.py的文件中。当我们逐个示例进行时,您可以将它们添加到您的MainWindow.__init__()方法中,以查看这些对象的工作方式。
QWidget
QWidget是所有其他小部件的父类,因此它拥有的任何属性和方法也将在任何其他小部件中可用。单独使用时,QWidget对象可以作为其他小部件的容器,填充空白区域,或作为顶层窗口的基类。
创建小部件就像这样简单:
# inside MainWindow.__init__()
subwidget = qtw.QWidget(self)
请注意我们将self作为参数传递。如果我们正在创建一个小部件以放置在或在另一个小部件类中使用,就像我们在这里做的那样,将父小部件的引用作为第一个参数传递是一个好主意。指定父小部件将确保在父小部件被销毁和清理时,子小部件也被销毁,并限制其可见性在父小部件内部。
正如您在第一章中学到的,PyQt 入门,PyQt 也允许我们为任何小部件的属性指定值。
例如,我们可以使用toolTip属性来设置此小部件的工具提示文本(当鼠标悬停在小部件上时将弹出):
subwidget = qtw.QWidget(self, toolTip='This is my widget')
阅读QWidget的 C++文档(位于doc.qt.io/qt-5/qwidget.html)并注意类的属性。请注意,每个属性都有指定的数据类型。在这种情况下,toolTip需要QString。每当需要QString时,我们可以使用常规 Unicode 字符串,因为 PyQt 会为我们进行转换。然而,对于更奇特的数据类型,如QSize或QColor,我们需要创建适当的对象。请注意,这些转换是在后台进行的,因为 Qt 对数据类型并不宽容。
例如,这段代码会导致错误:
subwidget = qtw.QWidget(self, toolTip=b'This is my widget')
这将导致TypeError,因为 PyQt 不会将bytes对象转换为QString。因此,请确保检查小部件属性或方法调用所需的数据类型,并使用兼容的类型。
QWidget 作为顶层窗口
当创建一个没有父级的QWidget并调用它的show()方法时,它就成为了一个顶层窗口。当我们将其用作顶层窗口时,例如我们在MainWindow实例中所做的那样,我们可以设置一些特定于窗口的属性。其中一些显示在下表中:
| 属性 | 参数类型 | 描述 |
|---|---|---|
windowTitle |
字符串 | 窗口的标题。 |
windowIcon |
QIcon |
窗口的图标。 |
modal |
布尔值 | 窗口是否为模态。 |
cursor |
Qt.CursorShape |
当小部件悬停时使用的光标。 |
windowFlags |
Qt.WindowFlags |
操作系统应如何处理窗口(对话框、工具提示、弹出窗口)。 |
cursor的参数类型是枚举的一个例子。枚举只是一系列命名的值,Qt 在属性受限于一组描述性值的任何地方定义枚举。windowFlags的参数是标志的一个例子。标志类似于枚举,不同之处在于它们可以组合(使用管道运算符|),以便传递多个标志。
在这种情况下,枚举和标志都是Qt命名空间的一部分,位于QtCore模块中。因此,例如,要在小部件悬停时将光标设置为箭头光标,您需要找到Qt中引用箭头光标的正确常量,并将小部件的cursor属性设置为该值。要在窗口上设置标志,指示操作系统它是sheet和popup窗口,您需要找到Qt中表示这些窗口标志的常量,用管道组合它们,并将其作为windowFlags的值传递。
创建这样一个QWidget窗口可能是这样的:
window = qtw.QWidget(cursor=qtc.Qt.ArrowCursor)
window.setWindowFlags(qtc.Qt.Sheet|qtc.Qt.Popup)
在本书的其余部分学习配置 Qt 小部件时,我们将遇到更多的标志和枚举。
QLabel
QLabel是一个配置为显示简单文本和图像的QWidget对象。
创建一个看起来像这样的:
label = qtw.QLabel('Hello Widgets!', self)
注意这次指定的父窗口小部件是第二个参数,而第一个参数是标签的文本。
这里显示了一些常用的QLabel属性:
| 属性 | 参数 | 描述 |
|---|---|---|
text |
string | 标签上显示的文本。 |
margin |
整数 | 文本周围的空间(以像素为单位)。 |
indent |
整数 | 文本缩进的空间(以像素为单位)。 |
wordWrap |
布尔值 | 是否换行。 |
textFormat |
Qt.TextFormat |
强制纯文本或富文本,或自动检测。 |
pixmap |
QPixmap |
要显示的图像而不是文本。 |
标签的文本存储在其text属性中,因此可以使用相关的访问器方法来访问或更改,如下所示:
label.setText("Hi There, Widgets!")
print(label.text())
QLabel可以显示纯文本、富文本或图像。Qt 中的富文本使用类似 HTML 的语法;默认情况下,标签将自动检测您的字符串是否包含任何格式标记,并相应地显示适当类型的文本。例如,如果我们想要使我们的标签加粗并在文本周围添加边距,我们可以这样做:
label = qtw.QLabel('<b>Hello Widgets!</b>', self, margin=10)
我们将在第六章 Qt 应用程序样式和第十一章 使用 QTextDocument 创建富文本中学习更多关于使用图像、富文本和字体的知识。
QLineEdit
QLineEdit类是一个单行文本输入小部件,您可能经常在数据输入或登录表单中使用。QLineEdit可以不带参数调用,只带有父窗口小部件,或者将默认字符串值作为第一个参数,如下所示:
line_edit = qtw.QLineEdit('default value', self)
还有许多我们可以传递的属性:
| 属性 | 参数 | 描述 |
|---|---|---|
text |
string | 盒子的内容。 |
readOnly |
布尔值 | 字段是否可编辑。 |
clearButtonEnabled |
布尔值 | 是否添加清除按钮。 |
placeholderText |
string | 字段为空时显示的文本。 |
maxLength |
整数 | 可输入的最大字符数。 |
echoMode |
QLineEdit.EchoMode |
切换文本输入时显示方式(例如用于密码输入)。 |
让我们给我们的行编辑小部件添加一些属性:
line_edit = qtw.QLineEdit(
'default value',
self,
placeholderText='Type here',
clearButtonEnabled=True,
maxLength=20
)
这将用默认文本'默认值'填充小部件。当字段为空或有一个清除字段的小X按钮时,它将显示一个占位符字符串'在此输入'。它还限制了可以输入的字符数为20。
QPushButton 和其他按钮
QPushButton是一个简单的可点击按钮小部件。与QLabel和QLineEdit一样,它可以通过第一个参数调用,该参数指定按钮上的文本,如下所示:
button = qtw.QPushButton("Push Me", self)
我们可以在QPushButton上设置的一些更有用的属性包括以下内容:
| 属性 | 参数 | 描述 |
|---|---|---|
checkable |
布尔值 | 按钮是否在按下时保持开启状态。 |
checked |
布尔值 | 对于checkable按钮,按钮是否被选中。 |
icon |
QIcon |
要显示在按钮上的图标图像。 |
shortcut |
QKeySequence |
一个激活按钮的键盘快捷键。 |
checkable和checked属性允许我们将此按钮用作反映开/关状态的切换按钮,而不仅仅是执行操作的单击按钮。所有这些属性都来自QPushButton类的父类QAbstractButton。这也是其他几个按钮类的父类,列在这里:
| 类 | 描述 |
|---|---|
QCheckBox |
复选框可以是开/关的布尔值,也可以是开/部分开/关的三态值。 |
QRadioButton |
类似复选框,但在具有相同父级的按钮中只能选中一个按钮。 |
QToolButton |
用于工具栏小部件的特殊按钮。 |
尽管每个按钮都有一些独特的特性,但在核心功能方面,这些按钮在我们创建和配置它们的方式上是相同的。
让我们将我们的按钮设置为可选中,默认选中,并给它一个快捷键:
button = qtw.QPushButton(
"Push Me",
self,
checkable=True,
checked=True,
shortcut=qtg.QKeySequence('Ctrl+p')
)
请注意,shortcut选项要求我们传入一个QKeySequence,它是QtGui模块的一部分。这是一个很好的例子,说明属性参数通常需要包装在某种实用类中。QKeySequence封装了一个键组合,这里是Ctrl键(或 macOS 上的command键)和P。
键序列可以指定为字符串,例如前面的示例,也可以使用QtCOre.Qt模块中的枚举值。例如,我们可以将前面的示例写为QKeySequence(qtc.Qt.CTRL + qtc.Qt.Key_P)。
QComboBox
combobox,也称为下拉或选择小部件,是一个在点击时呈现选项列表的小部件,其中必须选择一个选项。QCombobox可以通过将其editable属性设置为True来允许文本输入自定义答案。
让我们创建一个QCombobox对象,如下所示:
combobox = qtw.QComboBox(self)
现在,我们的combobox菜单中没有项目。QCombobox在构造函数中不提供使用选项初始化小部件的方法;相反,我们必须创建小部件,然后使用addItem()或insertItem()方法来填充其菜单选项,如下所示:
combobox.addItem('Lemon', 1)
combobox.addItem('Peach', 'Ohh I like Peaches!')
combobox.addItem('Strawberry', qtw.QWidget)
combobox.insertItem(1, 'Radish', 2)
addItem()方法接受标签和数据值的字符串。正如你所看到的,这个值可以是任何东西——整数,字符串,Python 类。可以使用QCombobox对象的currentData()方法检索当前选定项目的值。通常最好——尽管不是必需的——使所有项目的值都是相同类型的。
addItem()将始终将项目附加到菜单的末尾;要在之前插入它们,使用insertItem()方法。它的工作方式完全相同,只是它接受一个索引(整数值)作为第一个参数。项目将插入到列表中的该索引处。如果我们想节省时间,不需要为我们的项目设置data属性,我们也可以使用addItems()或insertItems()传递一个选项列表。
QComboBox的一些其他重要属性包括以下内容:
| 属性 | 参数 | 描述 |
|---|---|---|
currentData |
(任何) | 当前选定项目的数据对象。 |
currentIndex |
整数 | 当前选定项目的索引。 |
currentText |
string | 当前选定项目的文本。 |
editable |
布尔值 | combobox是否允许文本输入。 |
insertPolicy |
QComboBox.InsertPolicy |
输入的项目应该插入列表中的位置。 |
currentData的数据类型是QVariant,这是 Qt 的一个特殊类,用作任何类型数据的容器。在 C++中更有用,因为它们为多种数据类型可能有用的情况提供了一种绕过静态类型的方法。PyQt 会自动将QVariant对象转换为最合适的 Python 类型,因此我们很少需要直接使用这种类型。
让我们更新我们的combobox,以便我们可以将项目添加到下拉列表的顶部:
combobox = qtw.QComboBox(
self,
editable=True,
insertPolicy=qtw.QComboBox.InsertAtTop
)
现在这个combobox将允许输入任何文本;文本将被添加到列表框的顶部。新项目的data属性将为None,因此这实际上只适用于我们仅使用可见字符串的情况。
QSpinBox
一般来说,旋转框是一个带有箭头按钮的文本输入,旨在旋转一组递增值。QSpinbox专门用于处理整数或离散值(例如下拉框)。
一些有用的QSpinBox属性包括以下内容:
| 属性 | 参数 | 描述 |
|---|---|---|
value |
整数 | 当前旋转框值,作为整数。 |
cleanText |
string | 当前旋转框值,作为字符串(不包括前缀和后缀)。 |
maximum |
整数 | 方框的最大整数值。 |
minimum |
整数 | 方框的最小值。 |
prefix |
string | 要添加到显示值的字符串。 |
suffix |
string | 要附加到显示值的字符串。 |
singleStep |
整数 | 当使用箭头时增加或减少值的数量。 |
wrapping |
布尔值 | 当使用箭头时是否从范围的一端包装到另一端。 |
让我们在脚本中创建一个QSpinBox对象,就像这样:
spinbox = qtw.QSpinBox(
self,
value=12,
maximum=100,
minimum=10,
prefix='$',
suffix=' + Tax',
singleStep=5
)
这个旋转框从值12开始,并允许输入从10到100的整数,以$<value> + Tax的格式显示。请注意,框的非整数部分不可编辑。还要注意,虽然增量和减量箭头移动5,但我们可以输入不是5的倍数的值。
QSpinBox将自动忽略非数字的按键,或者会使值超出可接受范围。如果输入了一个太低的值,当焦点从spinbox移开时,它将被自动更正为有效值;例如,如果您在前面的框中输入了9并单击了它,它将被自动更正为90。
QDoubleSpinBox与QSpinBox相同,但设计用于十进制或浮点数。
要将QSpinBox用于离散文本值而不是整数,您需要对其进行子类化并重写其验证方法。我们将在验证小部件部分中进行。
QDateTimeEdit
旋转框的近亲是QDateTimeEdit,专门用于输入日期时间值。默认情况下,它显示为一个旋转框,允许用户通过每个日期时间值字段进行制表,并使用箭头递增/递减它。该小部件还可以配置为使用日历弹出窗口。
更有用的属性包括以下内容:
| 属性 | 参数 | 描述 |
|---|---|---|
date |
QDate或datetime.date |
日期值。 |
time |
QTime或datetime.time |
时间值。 |
dateTime |
QDateTime或datetime.datetime |
组合的日期时间值。 |
maximumDate,minimumDate |
QDate或datetime.date |
可输入的最大和最小日期。 |
maximumTime,minimumTime |
QTime或datetime.time |
可输入的最大和最小时间。 |
maximumDateTime,minimumDateTime |
QDateTime或datetime.datetime |
可输入的最大和最小日期时间。 |
calendarPopup |
布尔值 | 是否显示日历弹出窗口或像旋转框一样行为。 |
displayFormat |
string | 日期时间应如何格式化。 |
让我们像这样创建我们的日期时间框:
datetimebox = qtw.QDateTimeEdit(
self,
date=qtc.QDate.currentDate(),
time=qtc.QTime(12, 30),
calendarPopup=True,
maximumDate=qtc.QDate(2030, 1, 1),
maximumTime=qtc.QTime(17, 0),
displayFormat='yyyy-MM-dd HH:mm'
)
这个日期时间小部件将使用以下属性创建:
-
当前日期将设置为 12:30
-
当焦点集中时,它将显示日历弹出窗口
-
它将禁止在 2030 年 1 月 1 日之后的日期
-
它将禁止在最大日期后的 17:00(下午 5 点)之后的时间
-
它将以年-月-日小时-分钟的格式显示日期时间
请注意,maximumTime和minimumTime只影响maximumDate和minimumDate的值,分别。因此,即使我们指定了 17:00 的最大时间,只要在 2030 年 1 月 1 日之前,您也可以输入 18:00。相同的概念也适用于最小日期和时间。
日期时间的显示格式是使用包含每个项目的特定替换代码的字符串设置的。这里列出了一些常见的代码:
| 代码 | 意义 |
|---|---|
d |
月份中的日期。 |
M |
月份编号。 |
yy |
两位数年份。 |
yyyy |
四位数年份。 |
h |
小时。 |
m |
分钟。 |
s |
秒。 |
A |
上午/下午,如果使用,小时将切换到 12 小时制。 |
日,月,小时,分钟和秒都默认省略前导零。要获得前导零,只需将字母加倍(例如,dd表示带有前导零的日期)。代码的完整列表可以在doc.qt.io/qt-5/qdatetime.html找到。
请注意,所有时间、日期和日期时间都可以接受来自 Python 标准库的datetime模块以及 Qt 类型的对象。因此,我们的框也可以这样创建:
import datetime
datetimebox = qtw.QDateTimeEdit(
self,
date=datetime.date.today(),
time=datetime.time(12, 30),
calendarPopup=True,
maximumDate=datetime.date(2020, 1, 1),
minimumTime=datetime.time(8, 0),
maximumTime=datetime.time(17, 0),
displayFormat='yyyy-MM-dd HH:mm'
)
你选择使用哪一个取决于个人偏好或情境要求。例如,如果您正在使用其他 Python 模块,datetime标准库对象可能更兼容。如果您只需要为小部件设置默认值,QDateTime可能更方便,因为您可能已经导入了QtCore。
如果您需要更多对日期和时间输入的控制,或者只是想将它们拆分开来,Qt 有QTimeEdit和QDateEdit小部件。它们就像这个小部件一样,只是分别处理时间和日期。
QTextEdit
虽然QLineEdit用于单行字符串,但QTextEdit为我们提供了输入多行文本的能力。QTextEdit不仅仅是一个简单的纯文本输入,它是一个完整的所见即所得编辑器,可以配置为支持富文本和图像。
这里显示了QTextEdit的一些更有用的属性:
| 属性 | 参数 | 描述 |
|---|---|---|
plainText |
字符串 | 框的内容,纯文本格式。 |
html |
字符串 | 框的内容,富文本格式。 |
acceptRichText |
布尔值 | 框是否允许富文本。 |
lineWrapColumnOrWidth |
整数 | 文本将换行的像素或列。 |
lineWrapMode |
QTextEdit.LineWrapMode |
行换行模式使用列还是像素。 |
overwriteMode |
布尔值 | 是否激活覆盖模式;False表示插入模式。 |
placeholderText |
字符串 | 字段为空时显示的文本。 |
readOnly |
布尔值 | 字段是否只读。 |
让我们创建一个文本编辑器,如下所示:
textedit = qtw.QTextEdit(
self,
acceptRichText=False,
lineWrapMode=qtw.QTextEdit.FixedColumnWidth,
lineWrapColumnOrWidth=25,
placeholderText='Enter your text here'
)
这将创建一个纯文本编辑器,每行只允许输入25个字符,当为空时显示短语'在此输入您的文本'。
我们将在第十一章中深入了解QTextEdit和富文本文档,使用 QTextDocument 创建富文本。
放置和排列小部件
到目前为止,我们已经创建了许多小部件,但如果运行程序,您将看不到它们。虽然我们的小部件都属于父窗口,但它们还没有放置在上面。在本节中,我们将学习如何在应用程序窗口中排列我们的小部件,并将它们设置为适当的大小。
布局类
布局对象定义了子小部件在父小部件上的排列方式。Qt 提供了各种布局类,每个类都有适合不同情况的布局策略。
使用布局类的工作流程如下:
-
从适当的布局类创建布局对象
-
使用
setLayout()方法将布局对象分配给父小部件的layout属性 -
使用布局的
addWidget()方法向布局添加小部件
您还可以使用addLayout()方法将布局添加到布局中,以创建更复杂的小部件排列。让我们来看看 Qt 提供的一些基本布局类。
QHBoxLayout 和 QVBoxLayout
QHBoxLayout和QVBoxLayout都是从QBoxLayout派生出来的,这是一个非常基本的布局引擎,它简单地将父对象分成水平或垂直框,并按顺序放置小部件。QHBoxLayout是水平定向的,小部件按添加顺序从左到右放置。QVBoxLayout是垂直定向的,小部件按添加顺序从上到下放置。
让我们在MainWindow小部件上尝试QVBoxLayout:
layout = qtw.QVBoxLayout()
self.setLayout(layout)
一旦布局对象存在,我们可以使用addWidget()方法开始向其中添加小部件:
layout.addWidget(label)
layout.addWidget(line_edit)
如您所见,如果运行程序,小部件将逐行添加。如果我们想要将多个小部件添加到一行中,我们可以像这样在布局中嵌套一个布局:
sublayout = qtw.QHBoxLayout()
layout.addLayout(sublayout)
sublayout.addWidget(button)
sublayout.addWidget(combobox)
在这里,我们在主垂直布局的下一个单元格中添加了一个水平布局,然后在子布局中插入了三个更多的小部件。这三个小部件在主布局的一行中并排显示。大多数应用程序布局可以通过简单地嵌套框布局来完成。
QGridLayout
嵌套框布局涵盖了很多内容,但在某些情况下,您可能希望以统一的行和列排列小部件。这就是QGridLayout派上用场的地方。顾名思义,它允许您以表格结构放置小部件。
像这样创建一个网格布局对象:
grid_layout = qtw.QGridLayout()
layout.addLayout(grid_layout)
向QGridLayout添加小部件类似于QBoxLayout类的方法,但还需要传递坐标:
grid_layout.addWidget(spinbox, 0, 0)
grid_layout.addWidget(datetimebox, 0, 1)
grid_layout.addWidget(textedit, 1, 0, 2, 2)
这是QGridLayout.addWidget()的参数,顺序如下:
-
要添加的小部件
-
行号(垂直坐标),从
0开始 -
列号(水平坐标),从
0开始 -
行跨度,或者小部件将包含的行数(可选)
-
列跨度,或者小部件将包含的列数(可选)
因此,我们的spinbox小部件放置在第0行,第0列,即左上角;我们的datetimebox放置在第0行,第1列,即右上角;我们的textedit放置在第1行,第0列,并且跨越了两行两列。
请记住,网格布局保持所有列的宽度一致,所有行的高度一致。因此,如果您将一个非常宽的小部件放在第2行,第1列,所有行中位于第1列的小部件都会相应地被拉伸。如果希望每个单元格独立拉伸,请改用嵌套框布局。
QFormLayout
在创建数据输入表单时,通常会在标签旁边放置标签。Qt 为这种情况提供了一个方便的两列网格布局,称为QFormLayout。
让我们向我们的 GUI 添加一个表单布局:
form_layout = qtw.QFormLayout()
layout.addLayout(form_layout)
使用addRow()方法可以轻松添加小部件:
form_layout.addRow('Item 1', qtw.QLineEdit(self))
form_layout.addRow('Item 2', qtw.QLineEdit(self))
form_layout.addRow(qtw.QLabel('<b>This is a label-only row</b>'))
这个方便的方法接受一个字符串和一个小部件,并自动为字符串创建QLabel小部件。如果只传递一个小部件(如QLabel),该小部件跨越两列。这对于标题或部分标签非常有用。
QFormLayout不仅仅是对QGridLayout的方便,它还在跨不同平台使用时自动提供成语化的行为。例如,在 Windows 上使用时,标签是左对齐的;在 macOS 上使用时,标签是右对齐的,符合平台的设计指南。此外,当在窄屏幕上查看(如移动设备),布局会自动折叠为单列,标签位于输入框上方。在任何需要两列表单的情况下使用这种布局是非常值得的。
控制小部件大小
如果您按照当前的设置运行我们的演示并将其扩展以填满屏幕,您会注意到主布局的每个单元格都会均匀拉伸以填满屏幕,如下所示:

这并不理想。顶部的标签实际上不需要扩展,并且底部有很多空间被浪费。据推测,如果用户要扩展此窗口,他们会这样做以获得更多的输入小部件空间,就像我们的QTextEdit。我们需要为 GUI 提供一些关于如何调整小部件的大小以及在窗口从其默认大小扩展或收缩时如何调整它们的指导。
在任何工具包中,控制小部件的大小可能会有些令人困惑,但 Qt 的方法可能尤其令人困惑,因此让我们一步一步来。
我们可以简单地使用其setFixedSize()方法为任何小部件设置固定大小,就像这样:
# Fix at 150 pixels wide by 40 pixels high
label.setFixedSize(150, 40)
setFixedSize仅接受像素值,并且设置为固定大小的小部件在任何情况下都不能改变这些像素大小。以这种方式调整小部件的大小的问题在于它没有考虑不同字体、不同文本大小或应用程序窗口的大小或布局发生变化的可能性,这可能导致小部件对其内容太小或过大。我们可以通过设置minimumSize和maximumSize使其稍微灵活一些,就像这样:
# setting minimum and maximum sizes
line_edit.setMinimumSize(150, 15)
line_edit.setMaximumSize(500, 50)
如果您运行此代码并调整窗口大小,您会注意到line_edit在窗口扩展和收缩时具有更大的灵活性。但是,请注意,小部件不会收缩到其minimumSize以下,但即使有空间可用,它也不一定会使用其maximumSize。
因此,这仍然远非理想。与其关心每个小部件消耗多少像素,我们更希望它根据其内容和在界面中的角色合理而灵活地调整大小。Qt 正是使用大小提示和大小策略的概念来实现这一点。
大小提示是小部件的建议大小,并由小部件的sizeHint()方法返回。此大小可能基于各种动态因素;例如,QLabel小部件的sizeHint()值取决于其包含的文本的长度和换行。由于它是一个方法而不是属性,因此为小部件设置自定义sizeHint()需要您对小部件进行子类化并重新实现该方法。幸运的是,这并不是我们经常需要做的事情。
大小策略定义了小部件在调整大小请求时如何响应其大小提示。这是作为小部件的sizePolicy属性设置的。大小策略在QtWidgets.QSizePolicy.Policy枚举中定义,并使用setSizePolicy访问器方法分别为小部件的水平和垂直尺寸设置。可用的策略在此处列出:
| 策略 | 描述 |
|---|---|
| 固定 | 永远不要增长或缩小。 |
| 最小 | 不要小于sizeHint。扩展并不有用。 |
| 最大 | 不要大于sizeHint,如果有必要则缩小。 |
| 首选 | 尝试是sizeHint,但如果有必要则缩小。扩展并不有用。这是默认值。 |
| 扩展 | 尝试是sizeHint,如果有必要则缩小,但尽可能扩展。 |
| 最小扩展 | 不要小于sizeHint,但尽可能扩展。 |
| 忽略 | 完全忘记sizeHint,尽可能占用更多空间。 |
因此,例如,如果我们希望 SpinBox 保持固定宽度,以便旁边的小部件可以扩展,我们将这样做:
spinbox.setSizePolicy(qtw.QSizePolicy.Fixed,qtw.QSizePolicy.Preferred)
或者,如果我们希望我们的textedit小部件尽可能填满屏幕,但永远不要缩小到其sizeHint()值以下,我们应该像这样设置其策略:
textedit.setSizePolicy(
qtw.QSizePolicy.MinimumExpanding,
qtw.QSizePolicy.MinimumExpanding
)
当您有深度嵌套的布局时,调整小部件的大小可能有些不可预测;有时覆盖sizeHint()会很方便。在 Python 中,可以使用 Lambda 函数快速实现这一点,就像这样:
textedit.sizeHint = lambda : qtc.QSize(500, 500)
请注意,sizeHint()必须返回QtCore.QSize对象,而不仅仅是整数元组。
在使用框布局时,控制小部件大小的最后一种方法是在将小部件添加到布局时设置一个stretch因子。拉伸是addWidget()的可选第二个参数,它定义了每个小部件的比较拉伸。
这个例子展示了stretch因子的使用:
stretch_layout = qtw.QHBoxLayout()
layout.addLayout(stretch_layout)
stretch_layout.addWidget(qtw.QLineEdit('Short'), 1)
stretch_layout.addWidget(qtw.QLineEdit('Long'), 2)
stretch只适用于QHBoxLayout和QVBoxLayout类。
在这个例子中,我们添加了一个拉伸因子为1的行编辑,和一个拉伸因子为2的第二个。当你运行这个程序时,你会发现第二个行编辑的长度大约是第一个的两倍。
请记住,拉伸不会覆盖大小提示或大小策略,因此根据这些因素,拉伸比例可能不会完全按照指定的方式进行。
容器小部件
我们已经看到我们可以使用QWidget作为其他小部件的容器。Qt 还为我们提供了一些专门设计用于包含其他小部件的特殊小部件。我们将看看其中的两个:QTabWidget和QGroupBox。
QTabWidget
QTabWidget,有时在其他工具包中被称为笔记本小部件,允许我们通过选项卡选择多个页面。它们非常适用于将复杂的界面分解为更容易用户接受的较小块。
使用QTabWidget的工作流程如下:
-
创建
QTabWidget对象 -
在
QWidget或其他小部件类上构建一个 UI 页面 -
使用
QTabWidget.addTab()方法将页面添加到选项卡小部件
让我们试试吧;首先,创建选项卡小部件:
tab_widget = qtw.QTabWidget()
layout.addWidget(tab_widget)
接下来,让我们将我们在放置和排列小部件部分下构建的grid_layout移动到一个容器小部件下:
container = qtw.QWidget(self)
grid_layout = qtw.QGridLayout()
# comment out this line:
#layout.addLayout(grid_layout)
container.setLayout(grid_layout)
最后,让我们将我们的container小部件添加到一个新的选项卡中:
tab_widget.addTab(container, 'Tab the first')
addTab()的第二个参数是选项卡上将显示的标题文本。可以通过多次调用addTab()来添加更多的选项卡,就像这样:
tab_widget.addTab(subwidget, 'Tab the second')
insertTab()方法也可以用于在末尾以外的其他位置添加新的选项卡。
QTabWidget有一些我们可以自定义的属性,列在这里:
| 属性 | 参数 | 描述 |
|---|---|---|
movable |
布尔值 | 选项卡是否可以重新排序。默认值为False。 |
tabBarAutoHide |
布尔值 | 当只有一个选项卡时,选项卡栏是隐藏还是显示。 |
tabPosition |
QTabWidget.TabPosition |
选项卡出现在小部件的哪一侧。默认值为 North(顶部)。 |
tabShape |
QTabWidget.TabShape |
选项卡的形状。可以是圆角或三角形。 |
tabsClosable |
布尔值 | 是否在选项卡上显示一个关闭按钮。 |
useScrollButtons |
布尔值 | 是否在有许多选项卡时使用滚动按钮或展开。 |
让我们修改我们的QTabWidget,使其在小部件的左侧具有可移动的三角形选项卡:
tab_widget = qtw.QTabWidget(
movable=True,
tabPosition=qtw.QTabWidget.West,
tabShape=qtw.QTabWidget.Triangular
)
QStackedWidget类似于选项卡小部件,只是它不包含用于切换页面的内置机制。如果您想要构建自己的选项卡切换机制,您可能会发现它很有用。
QGroupBox
QGroupBox提供了一个带有标签的面板,并且(取决于平台样式)有边框。它对于在表单上将相关的输入分组在一起非常有用。我们创建QGroupBox的方式与创建QWidget容器的方式相同,只是它可以有一个边框和一个框的标题,例如:
groupbox = qtw.QGroupBox('Buttons')
groupbox.setLayout(qtw.QHBoxLayout())
groupbox.layout().addWidget(qtw.QPushButton('OK'))
groupbox.layout().addWidget(qtw.QPushButton('Cancel'))
layout.addWidget(groupbox)
在这里,我们创建了一个带有Buttons标题的分组框。我们给它一个水平布局,并添加了两个按钮小部件。
请注意,在这个例子中,我们没有像以前那样给布局一个自己的句柄,而是创建了一个匿名的QHBoxLayout,然后使用小部件的layout()访问器方法来检索一个引用,以便添加小部件。在某些情况下,您可能更喜欢这种方法。
分组框相当简单,但它确实有一些有趣的属性:
| 属性 | 参数 | 描述 |
|---|---|---|
title |
字符串 | 标题文本。 |
checkable |
布尔值 | groupbox 是否有一个复选框来启用/禁用它的内容。 |
checked |
布尔值 | 一个可勾选的 groupbox 是否被勾选(启用)。 |
alignment |
QtCore.Qt.Alignment |
标题文本的对齐方式。 |
flat |
布尔值 | 盒子是平的还是有框架。 |
checkable和checked属性非常有用,用于希望用户能够禁用表单的整个部分的情况(例如,如果与运输地址相同,则禁用订单表单的帐单地址部分)。
让我们重新配置我们的groupbox,如下所示:
groupbox = qtw.QGroupBox(
'Buttons',
checkable=True,
checked=True,
alignment=qtc.Qt.AlignHCenter,
flat=True
)
请注意,现在按钮可以通过简单的复选框切换禁用,并且框架的外观不同。
如果您只想要一个有边框的小部件,而没有标签或复选框功能,QFrame类可能是一个更好的选择。
验证小部件
尽管 Qt 提供了各种现成的输入小部件,例如日期和数字,但有时我们可能会发现需要一个具有非常特定约束的小部件。这些输入约束可以使用QValidator类创建。
工作流程如下:
-
通过子类化
QtGui.QValidator创建自定义验证器类 -
用我们的验证逻辑覆盖
validate()方法 -
将我们自定义类的一个实例分配给小部件的
validator属性
一旦分配给可编辑小部件,validate()方法将在用户更新小部件的值时被调用(例如,在QLineEdit中的每次按键),并确定输入是否被接受。
创建 IPv4 输入小部件
为了演示小部件验证,让我们创建一个验证互联网协议版本 4(IPv4)地址的小部件。IPv4 地址必须是 4 个整数,每个整数在0和255之间,并且每个数字之间有一个点。
让我们首先创建我们的验证器类。在MainWindow类之前添加这个类:
class IPv4Validator(qtg.QValidator):
"""Enforce entry of IPv4 Addresses"""
接下来,我们需要重写这个类的validate()方法。validate()接收两个信息:一个包含建议输入的字符串和输入发生的索引。它将返回一个指示输入是可接受、中间还是无效的值。如果输入是可接受或中间的,它将被接受。如果无效,它将被拒绝。
用于指示输入状态的值是QtValidator.Acceptable、QtValidator.Intermediate或QtValidator.Invalid。
在 Qt 文档中,我们被告知验证器类应该只返回状态常量。然而,在 PyQt 中,实际上需要返回一个包含状态、字符串和位置的元组。不幸的是,这似乎没有很好的记录,如果您忘记了这一点,错误就不直观。
让我们开始构建我们的 IPv4 验证逻辑如下:
- 在点字符上拆分字符串:
def validate(self, string, index):
octets = string.split('.')
- 如果有超过
4个段,该值无效:
if len(octets) > 4:
state = qtg.QValidator.Invalid
- 如果任何填充的段不是数字字符串,则该值无效:
elif not all([x.isdigit() for x in octets if x != '']):
state = qtg.QValidator.Invalid
- 如果不是每个填充的段都可以转换为 0 到 255 之间的整数,则该值无效:
elif not all([0 <= int(x) <= 255 for x in octets if x != '']):
state = qtg.QValidator.Invalid
- 如果我们已经进行了这些检查,该值要么是中间的,要么是有效的。如果段少于四个,它是中间的:
elif len(octets) < 4:
state = qtg.QValidator.Intermediate
- 如果有任何空段,该值是中间的:
elif any([x == '' for x in octets]):
state = qtg.QValidator.Intermediate
- 如果值通过了所有这些测试,它是可接受的。我们可以返回我们的元组:
else:
state = qtg.QValidator.Acceptable
return (state, string, index)
要使用此验证器,我们只需要创建一个实例并将其分配给一个小部件:
# set the default text to a valid value
line_edit.setText('0.0.0.0')
line_edit.setValidator(IPv4Validator())
如果您现在运行演示,您会看到行编辑现在限制您输入有效的 IPv4 地址。
使用 QSpinBox 进行离散值
正如您在创建基本 QtWidgets 小部件部分中学到的,QSpinBox可以用于离散的字符串值列表,就像组合框一样。QSpinBox有一个内置的validate()方法,它的工作方式就像QValidator类的方法一样,用于限制小部件的输入。要使旋转框使用离散字符串列表,我们需要对QSpinBox进行子类化,并覆盖validate()和另外两个方法,valueFromText()和textFromValue()。
让我们创建一个自定义的旋转框类,用于从列表中选择项目;在MainWindow类之前,输入以下内容:
class ChoiceSpinBox(qtw.QSpinBox):
"""A spinbox for selecting choices."""
def __init__(self, choices, *args, **kwargs):
self.choices = choices
super().__init__(
*args,
maximum=len(self.choices) - 1,
minimum=0,
**kwargs
)
我们正在对qtw.QSpinBox进行子类化,并覆盖构造函数,以便我们可以传入一个选择列表或元组,将其存储为self.choices。然后我们调用QSpinBox构造函数;请注意,我们设置了maximum和minimum,以便它们不能设置在我们选择的范围之外。我们还传递了任何额外的位置或关键字参数,以便我们可以利用所有其他QSpinBox属性设置。
接下来,让我们重新实现valueFromText(),如下所示:
def valueFromText(self, text):
return self.choices.index(text)
这个方法的目的是能够返回一个整数索引值,给定一个与显示的选择项匹配的字符串。我们只是返回传入的任何字符串的列表索引。
接下来,我们需要重新实现补充方法textFromValue():
def textFromValue(self, value):
try:
return self.choices[value]
except IndexError:
return '!Error!'
这个方法的目的是将整数索引值转换为匹配选择的文本。在这种情况下,我们只是返回给定索引处的字符串。如果以某种方式小部件传递了超出范围的值,我们将返回!Error!作为字符串。由于此方法用于确定在设置特定值时框中显示的内容,如果以某种方式值超出范围,这将清楚地显示错误条件。
最后,我们需要处理validate()。就像我们的QValidator类一样,我们需要创建一个方法,该方法接受建议的输入和编辑索引,并返回一个包含验证状态、字符串值和索引的元组。
我们将像这样编写它:
def validate(self, string, index):
if string in self.choices:
state = qtg.QValidator.Acceptable
elif any([v.startswith(string) for v in self.choices]):
state = qtg.QValidator.Intermediate
else:
state = qtg.QValidator.Invalid
return (state, string, index)
在我们的方法中,如果输入字符串在self.choices中找到,我们将返回Acceptable,如果任何选择项以输入字符串开头(包括空字符串),我们将返回Intermediate,在任何其他情况下我们将返回Invalid。
有了这个类创建,我们可以在我们的MainWindow类中创建一个小部件:
ratingbox = ChoiceSpinBox(
['bad', 'average', 'good', 'awesome'],
self
)
sublayout.addWidget(ratingbox)
QComboBox对象和具有文本选项的QSpinBox对象之间的一个重要区别是,旋转框项目缺少data属性。只能返回文本或索引。最适合用于诸如月份、星期几或其他可转换为整数值的顺序列表。
构建一个日历应用程序 GUI
现在是时候将我们所学到的知识付诸实践,实际构建一个简单的功能性 GUI。我们的目标是构建一个简单的日历应用程序,看起来像这样:

我们的界面还不能正常工作;现在,我们只关注如何创建和布局组件,就像屏幕截图中显示的那样。我们将以两种方式实现这一点:一次只使用代码,第二次使用 Qt Designer。
这两种方法都是有效的,而且都可以正常工作,尽管您会看到,每种方法都有优点和缺点。
在代码中构建 GUI
通过复制第一章中的应用程序模板,创建一个名为calendar_form.py的新文件,PyQt 入门。
然后我们将配置我们的主窗口;在MainWindow构造函数中,从这段代码开始:
self.setWindowTitle("My Calendar App")
self.resize(800, 600)
这段代码将设置我们窗口的标题为适当的内容,并设置窗口的固定大小为 800 x 600。请注意,这只是初始大小,用户可以调整窗体的大小。
创建小部件
现在,让我们创建所有的小部件:
self.calendar = qtw.QCalendarWidget()
self.event_list = qtw.QListWidget()
self.event_title = qtw.QLineEdit()
self.event_category = qtw.QComboBox()
self.event_time = qtw.QTimeEdit(qtc.QTime(8, 0))
self.allday_check = qtw.QCheckBox('All Day')
self.event_detail = qtw.QTextEdit()
self.add_button = qtw.QPushButton('Add/Update')
self.del_button = qtw.QPushButton('Delete')
这些都是我们在 GUI 中将要使用的所有小部件。其中大部分我们已经介绍过了,但有两个新的:QCalendarWidget和QListWidget。
QCalendarWidget正是您所期望的:一个完全交互式的日历,可用于查看和选择日期。虽然它有许多可以配置的属性,但对于我们的需求,默认配置就可以了。我们将使用它来允许用户选择要查看和编辑的日期。
QListWidget用于显示、选择和编辑列表中的项目。我们将使用它来显示保存在特定日期的事件列表。
在我们继续之前,我们需要使用一些项目配置我们的event_category组合框以进行选择。以下是此框的计划:
-
当没有选择时,将其读为“选择类别…”作为占位符
-
包括一个名为
New…的选项,也许允许用户输入新类别。 -
默认情况下包括一些常见类别,例如
工作、会议和医生
为此,请添加以下内容:
# Add event categories
self.event_category.addItems(
['Select category…', 'New…', 'Work',
'Meeting', 'Doctor', 'Family']
)
# disable the first category item
self.event_category.model().item(0).setEnabled(False)
QComboBox实际上没有占位符文本,因此我们在这里使用了一个技巧来模拟它。我们像往常一样使用addItems()方法添加了我们的组合框项目。接下来,我们使用model()方法检索其数据模型,该方法返回一个QStandardItemModel实例。数据模型保存组合框中所有项目的列表。我们可以使用模型的item()方法来访问给定索引(在本例中为0)处的实际数据项,并使用其setEnabled()方法来禁用它。
简而言之,我们通过禁用组合框中的第一个条目来模拟占位符文本。
我们将在第五章中了解更多关于小部件数据模型的知识,使用模型视图类创建数据接口。
构建布局
我们的表单将需要一些嵌套布局才能将所有内容放置到正确的位置。让我们分解我们提议的设计,并确定如何创建此布局:
-
应用程序分为左侧的日历和右侧的表单。这表明主要布局使用
QHBoxLayout。 -
右侧的表单是一个垂直堆叠的组件,表明我们应该使用
QVBoxLayout在右侧排列事物。 -
右下角的事件表单可以大致布局在网格中,因此我们可以在那里使用
QGridLayout。
我们将首先创建主布局,然后添加日历:
main_layout = qtw.QHBoxLayout()
self.setLayout(main_layout)
main_layout.addWidget(self.calendar)
我们希望日历小部件填充布局中的任何额外空间,因此我们将根据需要设置其大小策略:
self.calendar.setSizePolicy(
qtw.QSizePolicy.Expanding,
qtw.QSizePolicy.Expanding
)
现在,在右侧创建垂直布局,并添加标签和事件列表:
right_layout = qtw.QVBoxLayout()
main_layout.addLayout(right_layout)
right_layout.addWidget(qtw.QLabel('Events on Date'))
right_layout.addWidget(self.event_list)
如果有更多的垂直空间,我们希望事件列表填满所有可用的空间。因此,让我们将其大小策略设置如下:
self.event_list.setSizePolicy(
qtw.QSizePolicy.Expanding,
qtw.QSizePolicy.Expanding
)
GUI 的下一部分是事件表单及其标签。我们可以在这里使用另一个标签,但设计建议这些表单字段在此标题下分组在一起,因此QGroupBox更合适。
因此,让我们创建一个带有QGridLayout的组框来容纳我们的事件表单:
event_form = qtw.QGroupBox('Event')
right_layout.addWidget(event_form)
event_form_layout = qtw.QGridLayout()
event_form.setLayout(event_form_layout)
最后,我们需要将剩余的小部件添加到网格布局中:
event_form_layout.addWidget(self.event_title, 1, 1, 1, 3)
event_form_layout.addWidget(self.event_category, 2, 1)
event_form_layout.addWidget(self.event_time, 2, 2,)
event_form_layout.addWidget(self.allday_check, 2, 3)
event_form_layout.addWidget(self.event_detail, 3, 1, 1, 3)
event_form_layout.addWidget(self.add_button, 4, 2)
event_form_layout.addWidget(self.del_button, 4, 3)
我们将网格分为三列,并使用可选的列跨度参数将我们的标题和详细字段跨越所有三列。
现在我们完成了!此时,您可以运行脚本并查看您完成的表单。当然,它目前还没有做任何事情,但这是我们第三章的主题,使用信号和槽处理事件。
在 Qt Designer 中构建 GUI
让我们尝试构建相同的 GUI,但这次我们将使用 Qt Designer 构建它。
第一步
首先,按照第一章中描述的方式启动 Qt Designer,然后基于小部件创建一个新表单,如下所示:

现在,单击小部件,我们将使用右侧的属性面板配置其属性:
-
将对象名称更改为
MainWindow -
在几何下,将宽度更改为
800,高度更改为600 -
将窗口标题更改为
我的日历应用程序
接下来,我们将开始添加小部件。在左侧的小部件框中滚动查找日历小部件,然后将其拖放到主窗口上。选择日历并编辑其属性:
-
将名称更改为
calendar -
将水平和垂直大小策略更改为
扩展
要设置我们的主要布局,右键单击主窗口(不是日历),然后选择布局|水平布局。这将在主窗口小部件中添加一个QHBoxLayout。请注意,直到至少有一个小部件放在主窗口上,您才能这样做,这就是为什么我们首先添加了日历小部件。
构建右侧面板
现在,我们将为表单的右侧添加垂直布局。将一个垂直布局拖到日历小部件的右侧。然后将一个标签小部件拖到垂直布局中。确保标签在层次结构中列为垂直布局的子对象,而不是同级对象:

如果您在将小部件拖放到未展开的布局上遇到问题,您也可以将其拖放到对象检查器面板中的层次结构中。
双击标签上的文本,将其更改为日期上的事件。
接下来,将一个列表小部件拖到垂直布局中,使其出现在标签下面。将其重命名为event_list,并检查其属性,确保其大小策略设置为扩展。
构建事件表单
在小部件框中找到组框,并将其拖到列表小部件下面。双击文本,并将其更改为事件。
将一个行编辑器拖到组框上,确保它显示为组框对象检查器中的子对象。将对象名称更改为event_title。
现在,右键单击组框,选择布局,然后选择在网格中布局。这将在组框中创建一个网格布局。
将一个组合框拖到下一行。将一个时间编辑器拖到其右侧,然后将一个复选框拖到其右侧。将它们分别命名为event_category,event_time和allday_check。双击复选框文本,并将其更改为全天。
要向组合框添加选项,右键单击框并选择编辑项目。这将打开一个对话框,我们可以在其中输入我们的项目,所以点击+按钮添加选择类别…,就像第一个一样,然后新建…,然后一些随机类别(如工作,医生,会议)。
不幸的是,我们无法在 Qt Designer 中禁用第一项。当我们在应用程序中使用我们的表单时,我们将在第三章中讨论如何处理这个问题,使用信号和槽处理事件。
注意,添加这三个小部件会将行编辑器推到右侧。我们需要修复该小部件的列跨度。单击行编辑器,抓住右边缘的手柄,将其向右拖动,直到它扩展到组框的宽度。
现在,抓住一个文本编辑器,将其拖到其他小部件下面。注意它被挤压到第一列,所以就像行编辑一样,将其向右拖动,直到填满整个宽度。将文本编辑器重命名为event_detail。
最后,将两个按钮小部件拖到表单底部。确保将它们拖到第二列和第三列,留下第一列为空。将它们重命名为add_button和del_button,将文本分别更改为添加/更新和删除。
预览表单
将表单保存为calendar_form.ui,然后按下Ctrl + R进行预览。您应该看到一个完全功能的表单,就像原始截图中显示的那样。要实际使用这个文件,我们需要将其转换为 Python 代码并将其导入到实际的脚本中。在我们对表单进行一些额外修改之后,我们将在第三章中进行讨论,使用信号和槽处理事件。
总结
在本章中,我们介绍了 Qt 中一些最受欢迎的小部件类。您学会了如何创建它们,自定义它们,并将它们添加到表单中。我们讨论了各种控制小部件大小的方法,并练习了在 Python 代码和 Qt Designer 所见即所得应用程序中构建简单应用程序表单的方法。
在下一章中,我们将学习如何使这个表单真正做一些事情,同时探索 Qt 的核心通信和事件处理系统。保持你的日历表单方便,因为我们将对它进行更多修改,并从中制作一个功能应用程序。
问题
尝试这些问题来测试你从本章学到的知识:
-
你会如何创建一个全屏、没有窗口框架,并使用沙漏光标的
QWidget? -
你被要求为计算机库存数据库设计一个数据输入表单。为以下字段选择最好的小部件使用:
-
计算机制造商:你公司购买的八个品牌之一
-
处理器速度:CPU 速度,以 GHz 为单位
-
内存量:内存量,以 MB 为单位
-
主机名:计算机的主机名
-
视频制作:视频硬件是 Nvidia、AMD 还是 Intel
-
OEM 许可证:计算机是否使用原始设备制造商(OEM)许可证
-
数据输入表单包括一个需要
XX-999-9999X格式的库存编号字段,其中X是从A到Z的大写字母,不包括O和I,9是从0到9的数字。你能创建一个验证器类来验证这个输入吗? -
看看下面的计算器表单——可能使用了哪些布局来创建它?

-
参考前面的计算器表单,当表单被调整大小时,你会如何使按钮网格占用任何额外的空间?
-
计算器表单中最顶层的小部件是一个
QLCDNumber小部件。你能找到关于这个小部件的 Qt 文档吗?它有哪些独特的属性?你什么时候会使用它? -
从你的模板代码开始,在代码中构建计算器表单。
-
在 Qt Designer 中构建计算器表单。
进一步阅读
查看以下资源,了解本章涉及的主题的更多信息:
-
QWidget属性文档列出了所有QWidget的属性,这些属性被所有子类继承,网址为doc.qt.io/qt-5/qwidget.html#properties -
Qt命名空间文档列出了 Qt 中使用的许多全局枚举,网址为doc.qt.io/qt-5/qt.html#WindowState-enum -
Qt 布局管理教程提供了有关布局和大小调整的详细信息,网址为
doc.qt.io/qt-5/layout.html -
QDateTime文档提供了有关在 Qt 中处理日期和时间的更多信息,网址为doc.qt.io/qt-5/qdatetime.html -
有关
QCalendarWidget的更多信息可以在doc.qt.io/qt-5/qcalendarwidget.html找到。
第三章:使用信号和插槽处理事件
将小部件组合成一个漂亮的表单是设计应用程序的一个很好的第一步,但是为了 GUI 能够发挥作用,它需要连接到实际执行操作的代码。为了在 PyQt 中实现这一点,我们需要了解 Qt 最重要的功能之一,信号和插槽。
在本章中,我们将涵盖以下主题:
-
信号和插槽基础
-
创建自定义信号和插槽
-
自动化我们的日历表单
技术要求
除了第一章中列出的基本要求外,使用 PyQt 入门,您还需要来自第二章使用 QtWidgets 构建全面表单的日历表单代码和 Qt Designer 文件。您可能还希望从我们的 GitHub 存储库github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter03下载示例代码。
查看以下视频,看看代码是如何运行的:bit.ly/2M5OFQo
信号和插槽基础
信号是对象的特殊属性,可以在对应的事件类型中发出。事件可以是用户操作、超时或异步方法调用的完成等。
插槽是可以接收信号并对其做出响应的对象方法。我们连接信号到插槽,以配置应用程序对事件的响应。
所有从QObject继承的类(这包括 Qt 中的大多数类,包括所有QWidget类)都可以发送和接收信号。每个不同的类都有适合该类功能的一组信号和插槽。
例如,QPushButton有一个clicked信号,每当用户点击按钮时就会发出。QWidget类有一个close()插槽,如果它是顶级窗口,就会导致它关闭。我们可以这样连接两者:
self.quitbutton = qtw.QPushButton('Quit')
self.quitbutton.clicked.connect(self.close)
self.layout().addWidget(self.quitbutton)
如果您将此代码复制到我们的应用程序模板中并运行它,您会发现单击“退出”按钮会关闭窗口并结束程序。在 PyQt5 中连接信号到插槽的语法是object1.signalName.connect(object2.slotName)。
您还可以在创建对象时通过将插槽作为关键字参数传递给信号来进行连接。例如,前面的代码可以重写如下:
self.quitbutton = qtw.QPushButton('Quit', clicked=self.close)
self.layout().addWidget(self.quitbutton)
C++和旧版本的 PyQt 使用非常不同的信号和插槽语法,它使用SIGNAL()和SLOT()包装函数。这些在 PyQt5 中不存在,所以如果您在遵循旧教程或非 Python 文档,请记住这一点。
信号还可以携带数据,插槽可以接收。例如,QLineEdit有一个textChanged信号,随信号发送进小部件的文本一起。该行编辑还有一个接受字符串参数的setText()插槽。我们可以这样连接它们:
self.entry1 = qtw.QLineEdit()
self.entry2 = qtw.QLineEdit()
self.layout().addWidget(self.entry1)
self.layout().addWidget(self.entry2)
self.entry1.textChanged.connect(self.entry2.setText)
在这个例子中,我们将entry1的textChanged信号连接到entry2的setText()插槽。这意味着每当entry1中的文本发生变化时,它将用输入的文本信号entry2;entry2将把自己的文本设置为接收到的字符串,导致它镜像entry1中输入的任何内容。
在 PyQt5 中,插槽不必是官方的 Qt 插槽方法;它可以是任何 Python 可调用对象,比如自定义方法或内置函数。例如,让我们将entry2小部件的textChanged连接到老式的print():
self.entry2.textChanged.connect(print)
现在,您会发现对entry2的每次更改都会打印到控制台。textChanged信号基本上每次触发时都会调用print(),并传入信号携带的文本。
信号甚至可以连接到其他信号,例如:
self.entry1.editingFinished.connect(lambda: print('editing finished'))
self.entry2.returnPressed.connect(self.entry1.editingFinished)
我们已经将entry2小部件的returnPressed信号(每当用户在小部件上按下return/Enter时发出)连接到entry1小部件的editingFinished信号,而editingFinished信号又连接到一个打印消息的lambda函数。当你连接一个信号到另一个信号时,事件和数据会从一个信号传递到下一个信号。最终结果是在entry2上触发returnPressed会导致entry1发出editingFinished,然后运行lambda函数。
信号和槽连接的限制
尽管 PyQt 允许我们将信号连接到任何 Python 可调用对象,但有一些规则和限制需要牢记。与 Python 不同,C++是一种静态类型语言,这意味着变量和函数参数必须给定一个类型(string、integer、float或许多其他类型),并且存储在变量中或传递给该函数的任何值必须具有匹配的类型。这被称为类型安全。
原生的 Qt 信号和槽是类型安全的。例如,假设我们尝试将行编辑的textChanged信号连接到按钮的clicked信号,如下所示:
self.entry1.textChanged.connect(self.quitbutton.clicked)
这是行不通的,因为textChanged发出一个字符串,而clicked发出(并且因此期望接收)一个布尔值。如果你运行这个,你会得到这样的错误:
QObject::connect: Incompatible sender/receiver arguments
QLineEdit::textChanged(QString) --> QPushButton::clicked(bool)
Traceback (most recent call last):
File "signal_slots_demo.py", line 57, in <module>
mw = MainWindow()
File "signal_slots_demo.py", line 32, in __init__
self.entry1.textChanged.connect(self.quitbutton.clicked)
TypeError: connect() failed between textChanged(QString) and clicked()
槽可以有多个实现,每个实现都有自己的签名,允许相同的槽接受不同的参数类型。这被称为重载槽。只要我们的信号签名与任何重载的槽匹配,我们就可以建立连接,Qt 会确定我们连接到哪一个。
当连接到一个是 Python 函数的槽时,我们不必担心参数类型,因为 Python 是动态类型的(尽管我们需要确保我们的 Python 代码对传递给它的任何对象都做正确的事情)。然而,与对 Python 函数的任何调用一样,我们确实需要确保传入足够的参数来满足函数签名。
例如,让我们向MainWindow类添加一个方法,如下所示:
def needs_args(self, arg1, arg2, arg3):
pass
这个实例方法需要三个参数(self会自动传递)。让我们尝试将按钮的clicked信号连接到它:
self.badbutton = qtw.QPushButton("Bad")
self.layout().addWidget(self.badbutton)
self.badbutton.clicked.connect(self.needs_args)
这段代码本身并不反对连接,但当你点击按钮时,程序会崩溃并显示以下错误:
TypeError: needs_args() missing 2 required positional arguments: 'arg2' and 'arg3'
Aborted (core dumped)
由于clicked信号只发送一个参数,函数调用是不完整的,会抛出异常。可以通过将arg2和arg3变成关键字参数(添加默认值),或者创建一个以其他方式填充它们的包装函数来解决这个问题。
顺便说一句,槽接收的参数比信号发送的参数少的情况并不是问题。Qt 只是从信号中丢弃额外的数据。
因此,例如,将clicked连接到一个没有参数的方法是没有问题的,如下所示:
# inside __init__()
self.goodbutton = qtw.QPushButton("Good")
self.layout().addWidget(self.goodbutton)
self.goodbutton.clicked.connect(self.no_args)
# ...
def no_args(self):
print('I need no arguments')
创建自定义信号和槽
为按钮点击和文本更改设置回调是信号和槽的常见和非常明显的用法,但这实际上只是开始。在本质上,信号和槽机制可以被看作是应用程序中任何两个对象进行通信的一种方式,同时保持松散耦合。
松散耦合是指保持两个对象彼此需要了解的信息量最少。这是设计大型复杂应用程序时必须保留的重要特性,因为它隔离了代码并防止意外的破坏。相反的是紧密耦合,其中一个对象的代码严重依赖于另一个对象的内部结构。
为了充分利用这一功能,我们需要学习如何创建自己的自定义信号和槽。
使用自定义信号在窗口之间共享数据
假设您有一个弹出表单窗口的程序。当用户完成填写表单并提交时,我们需要将输入的数据传回主应用程序类进行处理。我们可以采用几种方法来解决这个问题;例如,主应用程序可以监视弹出窗口的提交按钮的单击事件,然后在销毁对话框之前从其字段中获取数据。但这种方法要求主窗体了解弹出对话框的所有部件,而且任何对弹出窗口的重构都可能破坏主应用程序窗口中的代码。
让我们尝试使用信号和槽的不同方法。从第一章中打开我们应用程序模板的新副本,PyQt 入门,并开始一个名为FormWindow的新类,就像这样:
class FormWindow(qtw.QWidget):
submitted = qtc.pyqtSignal(str)
在这个类中我们定义的第一件事是一个名为submitted的自定义信号。要定义自定义信号,我们需要调用QtCore.pyqtSignal()函数。pyqtSignal()的参数是我们的信号将携带的数据类型,在这种情况下是str。我们可以在这里使用 Python type对象,或者命名 C++数据类型的字符串(例如'QString')。
现在让我们通过定义__init__()方法来构建表单,如下所示:
def __init__(self):
super().__init__()
self.setLayout(qtw.QVBoxLayout())
self.edit = qtw.QLineEdit()
self.submit = qtw.QPushButton('Submit', clicked=self.onSubmit)
self.layout().addWidget(self.edit)
self.layout().addWidget(self.submit)
在这里,我们定义了一个用于数据输入的QLineEdit和一个用于提交表单的QPushButton。按钮单击信号绑定到一个名为onSubmit的方法,我们将在下面定义:
def onSubmit(self):
self.submitted.emit(self.edit.text())
self.close()
在这个方法中,我们调用submitted信号的emit()方法,传入QLineEdit的内容。这意味着任何连接的槽都将使用从self.edit.text()检索到的字符串进行调用。
发射信号后,我们关闭FormWindow。
在我们的MainWindow构造函数中,让我们构建一个使用它的应用程序:
def __init__(self):
super().__init__()
self.setLayout(qtw.QVBoxLayout())
self.label = qtw.QLabel('Click "change" to change this text.')
self.change = qtw.QPushButton("Change", clicked=self.onChange)
self.layout().addWidget(self.label)
self.layout().addWidget(self.change)
self.show()
在这里,我们创建了一个QLabel和一个QPushButton,并将它们添加到垂直布局中。单击按钮时,按钮调用一个名为onChange()的方法。
onChange()方法看起来像这样:
def onChange(self):
self.formwindow = FormWindow()
self.formwindow.submitted.connect(self.label.setText)
self.formwindow.show()
这个方法创建了一个FormWindow的实例。然后将我们的自定义信号FormWindow.submitted绑定到标签的setText槽;setText接受一个字符串作为参数,而我们的信号发送一个字符串。
如果您运行此应用程序,您会看到当您提交弹出窗口表单时,标签中的文本确实会更改。
这种设计的美妙之处在于FormWindow不需要知道任何关于MainWindow的东西,而MainWindow只需要知道FormWindow有一个submitted信号,该信号发射输入的字符串。只要相同的信号发射相同的数据,我们可以轻松修改任一类的结构和内部,而不会对另一类造成问题。
QtCore还包含一个pyqtSlot()函数,我们可以将其用作装饰器,表示 Python 函数或方法旨在作为槽使用。
例如,我们可以装饰我们的MainWindow.onChange()方法来声明它为一个槽:
@qtc.pyqtSlot()
def onChange(self):
# ...
这纯粹是可选的,因为我们可以使用任何 Python 可调用对象作为槽,尽管这确实给了我们强制类型安全的能力。例如,如果我们希望要求onChange()始终接收一个字符串,我们可以这样装饰它:
@qtc.pyqtSlot(str)
def onChange(self):
# ...
如果您这样做并运行程序,您会看到我们尝试连接clicked信号会失败:
Traceback (most recent call last):
File "form_window.py", line 47, in <module>
mw = MainWindow()
File "form_window.py", line 31, in __init__
self.change = qtw.QPushButton("Change", clicked=self.onChange)
TypeError: decorated slot has no signature compatible with clicked(bool)
除了强制类型安全外,将方法声明为槽还会减少其内存使用量,并提供一点速度上的改进。因此,虽然这完全是可选的,但对于只会被用作槽的方法来说,这可能值得做。
信号和槽的重载
就像 C++信号和槽可以被重载以接受不同的参数签名一样,我们也可以重载我们自定义的 PyQt 信号和槽。例如,假设如果在我们的弹出窗口中输入了一个有效的整数字符串,我们希望将其作为字符串和整数发射出去。
为了做到这一点,我们首先必须重新定义我们的信号:
submitted = qtc.pyqtSignal([str], [int, str])
我们不仅传入单个变量类型,而是传入两个变量类型的列表。每个列表代表一个信号签名的参数列表。因此,我们在这里注册了两个信号:一个只发送字符串,一个发送整数和字符串。
在FormWindow.onSubmit()中,我们现在可以检查行编辑中的文本,并使用适当的签名发送信号:
def onSubmit(self):
if self.edit.text().isdigit():
text = self.edit.text()
self.submitted[int, str].emit(int(text), text)
else:
self.submitted[str].emit(self.edit.text())
self.close()
在这里,我们测试self.edit中的文本,以查看它是否是有效的数字字符串。如果是,我们将其转换为int,并使用整数和文本版本的文本发出submitted信号。选择签名的语法是在信号名称后跟一个包含参数类型列表的方括号。
回到主窗口,我们将定义两种新方法来处理这些信号:
@qtc.pyqtSlot(str)
def onSubmittedStr(self, string):
self.label.setText(string)
@qtc.pyqtSlot(int, str)
def onSubmittedIntStr(self, integer, string):
text = f'The string {string} becomes the number {integer}'
self.label.setText(text)
我们已经创建了两个插槽——一个接受字符串,另一个接受整数和字符串。现在我们可以将FormWindow中的两个信号连接到适当的插槽,如下所示:
def onChange(self):
self.formwindow = FormWindow()
self.formwindow.submitted[str].connect(self.onSubmittedStr)
self.formwindow.submitted[int, str].connect(self.onSubmittedIntStr)
运行脚本,您会发现输入一串数字会打印与字母数字字符串不同的消息。
自动化我们的日历表单
要了解信号和插槽在实际应用程序中的使用方式,让我们拿我们在第二章 使用 QtWidgets 构建表单中构建的日历表单,并将其转换为一个可工作的日历应用程序。为此,我们需要进行以下更改:
-
应用程序需要一种方法来存储我们输入的事件。
-
全天复选框应在选中时禁用时间输入。
-
在日历上选择一天应该用当天的事件填充事件列表。
-
在事件列表中选择一个事件应该用事件的详细信息填充表单。
-
单击“添加/更新”应该更新保存的事件详细信息,如果选择了事件,或者如果没有选择事件,则添加一个新事件。
-
单击删除应该删除所选事件。
-
如果没有选择事件,删除应该被禁用。
-
选择“新建…”作为类别应该打开一个对话框,允许我们输入一个新的类别。如果我们选择输入一个,它应该被选中。
我们将首先使用我们手工编码的表单进行这一过程,然后讨论如何使用 Qt Designer 文件解决同样的问题。
使用我们手工编码的表单
要开始,请将您的calendar_form.py文件从第二章 使用 QtWidgets 构建表单复制到一个名为calendar_app.py的新文件中,并在编辑器中打开它。我们将开始编辑我们的MainWindow类,并将其完善为一个完整的应用程序。
为了处理存储事件,我们将在MainWindow中创建一个dict属性,如下所示:
class MainWindow(qtw.QWidget):
events = {}
我们不打算将数据持久化到磁盘,尽管如果您愿意,您当然可以添加这样的功能。dict中的每个项目将使用date对象作为其键,并包含一个包含该日期上所有事件详细信息的dict对象列表。数据的布局将看起来像这样:
events = {
QDate: {
'title': "String title of event",
'category': "String category of event",
'time': QTime() or None if "all day",
'detail': "String details of event"
}
}
接下来,让我们深入研究表单自动化。最简单的更改是在单击“全天”复选框时禁用时间输入,因为这种自动化只需要处理内置信号和插槽。
在__init__()方法中,我们将添加这段代码:
self.allday_check.toggled.connect(self.event_time.setDisabled)
QCheckBox.toggled信号在复选框切换开或关时发出,并发送一个布尔值,指示复选框是(更改后)未选中(False)还是选中(True)。这与setDisabled很好地连接在一起,它将在True时禁用小部件,在False时启用它。
创建和连接我们的回调方法
我们需要的其余自动化不适用于内置的 Qt 插槽,因此在连接更多信号之前,我们需要创建一些将用于实现插槽的方法。我们将把所有这些方法创建为MainWindow类的方法。
在开始处理回调之前,我们将创建一个实用方法来清除表单,这是几个回调方法将需要的。它看起来像这样:
def clear_form(self):
self.event_title.clear()
self.event_category.setCurrentIndex(0)
self.event_time.setTime(qtc.QTime(8, 0))
self.allday_check.setChecked(False)
self.event_detail.setPlainText('')
基本上,这个方法会遍历我们表单中的字段,并将它们全部设置为默认值。不幸的是,这需要为每个小部件调用不同的方法,所以我们必须把它全部写出来。
现在让我们来看看回调方法。
populate_list()方法
第一个实际的回调方法是populate_list(),它如下所示:
def populate_list(self):
self.event_list.clear()
self.clear_form()
date = self.calendar.selectedDate()
for event in self.events.get(date, []):
time = (
event['time'].toString('hh:mm')
if event['time']
else 'All Day'
)
self.event_list.addItem(f"{time}: {event['title']}")
这将在日历选择更改时调用,并且其工作是使用该天的事件重新填充event_list小部件。它首先清空列表和表单。然后,它使用其selectedDate()方法从日历小部件中检索所选日期。
然后,我们循环遍历所选日期的self.events字典的事件列表,构建一个包含时间和事件标题的字符串,并将其添加到event_list小部件中。请注意,我们的事件时间是一个QTime对象,因此要将其用作字符串,我们需要使用它的toString()方法进行转换。
有关如何将时间值格式化为字符串的详细信息,请参阅doc.qt.io/qt-5/qtime.html中的QTime文档。
为了连接这个方法,在__init__()中,我们添加了这段代码:
self.calendar.selectionChanged.connect(self.populate_list)
selectionChanged信号在日历上选择新日期时发出。它不发送任何数据,因此我们的回调函数不需要任何数据。
populate_form()方法
接下来的回调是populate_form(),当选择事件时将调用它并填充事件详细信息表单。它开始如下:
def populate_form(self):
self.clear_form()
date = self.calendar.selectedDate()
event_number = self.event_list.currentRow()
if event_number == -1:
return
在这里,我们首先清空表单,然后从日历中检索所选日期,并从事件列表中检索所选事件。当没有选择事件时,QListWidget.currentRow()返回值为-1;在这种情况下,我们将只是返回,使表单保持空白。
方法的其余部分如下:
event_data = self.events.get(date)[event_number]
self.event_category.setCurrentText(event_data['category'])
if event_data['time'] is None:
self.allday_check.setChecked(True)
else:
self.event_time.setTime(event_data['time'])
self.event_title.setText(event_data['title'])
self.event_detail.setPlainText(event_data['detail'])
由于列表小部件上显示的项目与events字典中存储的顺序相同,因此我们可以使用所选项目的行号来从所选日期的列表中检索事件。
一旦数据被检索,我们只需要将每个小部件设置为保存的值。
回到__init__()中,我们将连接槽如下:
self.event_list.itemSelectionChanged.connect(
self.populate_form
)
QListWidget在选择新项目时发出itemSelectionChanged。它不发送任何数据,因此我们的回调函数也不需要任何数据。
save_event()方法
save_event()回调将在单击添加/更新按钮时调用。它开始如下:
def save_event(self):
event = {
'category': self.event_category.currentText(),
'time': (
None
if self.allday_check.isChecked()
else self.event_time.time()
),
'title': self.event_title.text(),
'detail': self.event_detail.toPlainText()
}
在这段代码中,我们现在调用访问器方法来从小部件中检索值,并将它们分配给事件字典的适当键。
接下来,我们将检索所选日期的当前事件列表,并确定这是添加还是更新:
date = self.calendar.selectedDate()
event_list = self.events.get(date, [])
event_number = self.event_list.currentRow()
if event_number == -1:
event_list.append(event)
else:
event_list[event_number] = event
请记住,如果没有选择项目,QListWidget.currentRow()会返回-1。在这种情况下,我们希望将新事件追加到列表中。否则,我们将所选事件替换为我们的新事件字典:
event_list.sort(key=lambda x: x['time'] or qtc.QTime(0, 0))
self.events[date] = event_list
self.populate_list()
为了完成这个方法,我们将使用时间值对列表进行排序。请记住,我们对全天事件使用None,因此它们将首先通过在排序中用QTime的 0:00 替换它们来进行排序。
排序后,我们用新排序的列表替换当前日期的事件列表,并用新列表重新填充QListWidget。
我们将通过在__init__()中添加以下代码来连接add_button小部件的clicked事件:
self.add_button.clicked.connect(self.save_event)
delete_event()方法
delete_event方法将在单击删除按钮时调用,它如下所示:
def delete_event(self):
date = self.calendar.selectedDate()
row = self.event_list.currentRow()
del(self.events[date][row])
self.event_list.setCurrentRow(-1)
self.clear_form()
self.populate_list()
再次,我们检索当前日期和当前选择的行,并使用它们来定位我们想要删除的self.events中的事件。在从列表中删除项目后,我们通过将currentRow设置为-1来将列表小部件设置为无选择。然后,我们清空表单并填充列表小部件。
请注意,我们不需要检查当前选择的行是否为-1,因为我们计划在没有选择行时禁用删除按钮。
这个回调很容易连接到__init__()中的del_button:
self.del_button.clicked.connect(self.delete_event)
检查_delete _btn()方法
我们的最后一个回调是最简单的,它看起来像这样:
def check_delete_btn(self):
self.del_button.setDisabled(
self.event_list.currentRow() == -1)
这个方法只是检查当前事件列表小部件中是否没有事件被选中,并相应地启用或禁用删除按钮。
回到__init__(),让我们连接到这个回调:
self.event_list.itemSelectionChanged.connect(
self.check_delete_btn)
self.check_delete_btn()
我们将这个回调连接到itemSelectionChanged信号。请注意,我们已经将该信号连接到另一个插槽。信号可以连接到任意数量的插槽而不会出现问题。我们还直接调用该方法,以便del_button一开始就被禁用。
构建我们的新类别弹出表单
我们应用程序中的最后一个功能是能够向组合框添加新类别。我们需要实现的基本工作流程是:
-
当用户更改事件类别时,检查他们是否选择了“新…”
-
如果是这样,打开一个新窗口中的表单,让他们输入一个类别
-
当表单提交时,发出新类别的名称
-
当发出该信号时,向组合框添加一个新类别并选择它
-
如果用户选择不输入新类别,则将组合框默认为“选择类别…”
让我们从实现我们的弹出表单开始。这将与我们在本章前面讨论过的表单示例一样,它看起来像这样:
class CategoryWindow(qtw.QWidget):
submitted = qtc.pyqtSignal(str)
def __init__(self):
super().__init__(None, modal=True)
self.setLayout(qtw.QVBoxLayout())
self.layout().addWidget(
qtw.QLabel('Please enter a new catgory name:'))
self.category_entry = qtw.QLineEdit()
self.layout().addWidget(self.category_entry)
self.submit_btn = qtw.QPushButton(
'Submit',
clicked=self.onSubmit)
self.layout().addWidget(self.submit_btn)
self.cancel_btn = qtw.QPushButton(
'Cancel',
clicked=self.close
)
self.layout().addWidget(self.cancel_btn)
self.show()
@qtc.pyqtSlot()
def onSubmit(self):
if self.category_entry.text():
self.submitted.emit(self.category_entry.text())
self.close()
这个类与我们的FormWindow类相同,只是增加了一个标签和一个取消按钮。当点击cancel_btn小部件时,将调用窗口的close()方法,导致窗口关闭而不发出任何信号。
回到MainWindow,让我们实现一个方法,向组合框添加一个新类别:
def add_category(self, category):
self.event_category.addItem(category)
self.event_category.setCurrentText(category)
这种方法非常简单;它只是接收一个类别文本,将其添加到组合框的末尾,并将组合框选择设置为新类别。
现在我们需要编写一个方法,每当选择“新…”时,它将创建我们弹出表单的一个实例:
def on_category_change(self, text):
if text == 'New…':
dialog = CategoryWindow()
dialog.submitted.connect(self.add_category)
self.event_category.setCurrentIndex(0)
这种方法接受已更改类别的text值,并检查它是否为“新…”。如果是,我们创建我们的CategoryWindow对象,并将其submitted信号连接到我们的add_category()方法。然后,我们将当前索引设置为0,这是我们的“选择类别…”选项。
现在,当CategoryWindow显示时,用户要么点击取消,窗口将关闭并且组合框将被设置为“选择类别…”,就像on_category_change()留下的那样,要么用户将输入一个类别并点击提交,这样CategoryWindow将发出一个带有新类别的submitted信号。add_category()方法将接收到新类别,将其添加,并将组合框设置为它。
我们的日历应用现在已经完成;启动它并试试吧!
使用 Qt Designer .ui 文件
现在让我们回过头来使用我们在第二章中创建的 Qt Designer 文件,使用 QtWidgets 构建表单。这将需要一种完全不同的方法,但最终产品将是一样的。
要完成本节的工作,您需要第二章中的calendar_form.ui文件,使用 QtWidgets 构建表单,以及第二个.ui文件用于类别窗口。您可以自己练习构建这个表单,也可以使用本章示例代码中包含的表单。如果选择自己构建,请确保将每个对象命名为我们在上一节的代码中所做的那样。
在 Qt Designer 中连接插槽
Qt Designer 对于连接信号和插槽到我们的 GUI 的能力有限。对于 Python 开发人员,它主要只能用于在同一窗口中的小部件之间连接内置的 Qt 信号到内置的 Qt 插槽。连接信号到 Python 可调用对象或自定义的 PyQt 信号实际上是不可能的。
在日历 GUI 中,我们确实有一个原生的 Qt 信号-槽连接示例——allday_check小部件连接到event_time小部件。让我们看看如何在 Qt Designer 中连接这些:
-
在 Qt Designer 中打开
calendar_form.ui文件 -
在屏幕右下角找到 Signal/Slot Editor 面板
-
点击+图标添加一个新的连接
-
在 Sender 下,打开弹出菜单,选择
allday_check -
在 Signal 下,选择 toggled(bool)
-
对于 Receiver,选择
event_time -
最后,对于 Slot,选择 setDisabled(bool)
生成的条目应该是这样的:

如果你正在构建自己的category_window.ui文件,请确保你还将取消按钮的clicked信号连接到类别窗口的closed槽。
将.ui 文件转换为 Python
如果你在文本编辑器中打开你的calendar_form.ui文件,你会看到它既不是 Python 也不是 C++,而是你设计的 GUI 的 XML 表示。PyQt 为我们提供了几种选择,可以在 Python 应用程序中使用.ui文件。
第一种方法是使用 PyQt 附带的pyuic5工具将 XML 转换为 Python。在存放.ui文件的目录中打开命令行窗口,运行以下命令:
$ pyuic5 calendar_form.ui
这将生成一个名为calendar_form.py的文件。如果你在代码编辑器中打开这个文件,你会看到它包含一个Ui_MainWindow类的单个类定义,如下所示:
class Ui_MainWindow(object):
def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(799, 600)
# ... etc
注意这个类既不是QWidget的子类,也不是QObject的子类。这个类本身不会显示我们构建的窗口。相反,这个类将在另一个小部件内部构建我们设计的 GUI,我们必须用代码创建它。
为了做到这一点,我们将这个类导入到另一个脚本中,创建一个QWidget作为容器,并将setupUi()方法与我们的小部件容器作为参数一起调用。
不要试图编辑或添加代码到生成的 Python 文件中。如果你想使用 Qt Designer 更新你的 GUI,当你生成新文件时,你会丢失所有的编辑。把生成的代码当作第三方库来对待。
首先,从第一章,PyQt 入门中复制 PyQt 应用程序模板到存放calendar_form.py的目录,并将其命名为calendar_app.py。
在文件顶部像这样导入Ui_MainWindow类:
from calendar_form import Ui_MainWindow
我们可以以几种方式使用这个类,但最干净的方法是通过将它作为MainWindow的第二个父类进行多重继承。
更新MainWindow类定义如下:
class MainWindow(qtw.QWidget, Ui_MainWindow):
注意我们窗口的基类(第一个父类)仍然是QWidget。这个基类需要与我们最初设计表单时选择的基类匹配(参见第二章,使用 QtWidgets 构建表单)。
现在,在构造函数内部,我们可以调用setupUi,像这样:
def __init__(self):
super().__init__()
self.setupUi(self)
如果你在这一点运行应用程序,你会看到日历 GUI 都在那里,包括我们在allday_check和event_time之间的连接。然后,你可以将其余的连接和修改添加到MainWindow构造函数中,如下所示:
# disable the first category item
self.event_category.model().item(0).setEnabled(False)
# Populate the event list when the calendar is clicked
self.calendar.selectionChanged.connect(self.populate_list)
# Populate the event form when an item is selected
self.event_list.itemSelectionChanged.connect(
self.populate_form)
# Save event when save is hit
self.add_button.clicked.connect(self.save_event)
# connect delete button
self.del_button.clicked.connect(self.delete_event)
# Enable 'delete' only when an event is selected
self.event_list.itemSelectionChanged.connect(
self.check_delete_btn)
self.check_delete_btn()
# check for selection of "new…" for category
self.event_category.currentTextChanged.connect(
self.on_category_change)
这个类的回调方法与我们在代码中定义的方法是相同的。继续把它们复制到MainWindow类中。
使用pyuic5创建的Ui_类的另一种方法是将其实例化为容器小部件的属性。我们将尝试在类别窗口中使用这个方法;在文件顶部添加这个类:
class CategoryWindow(qtw.QWidget):
submitted = qtc.pyqtSignal(str)
def __init__(self):
super().__init__()
self.ui = Ui_CategoryWindow()
self.ui.setupUi(self)
self.show()
在将Ui_CategoryWindow对象创建为CategoryWindow的属性之后,我们调用它的setupUi()方法来在CategoryWindow上构建 GUI。然而,我们所有对小部件的引用现在都在self.ui命名空间下。因此,例如,category_entry不是self.category_entry,而是self.ui.category_entry。虽然这种方法稍微冗长,但如果你正在构建一个特别复杂的类,它可能有助于避免名称冲突。
自动信号和插槽连接
再次查看由pyuic5生成的Ui_类,并注意setupUi中的最后一行代码:
QtCore.QMetaObject.connectSlotsByName(MainWindow)
connectSlotsByName()是一种方法,它将通过将信号与以on_object_name_signal()格式命名的方法进行匹配来自动连接信号和插槽,其中object_name与PyQt对象的objectName属性匹配,signal是其内置信号之一的名称。
例如,在我们的CategoryWindow中,我们希望创建一个回调,当单击submit_btn时运行(如果您制作了自己的.ui文件,请确保您将提交按钮命名为submit_btn)。如果我们将回调命名为on_submit_btn_clicked(),那么这将自动发生。
代码如下:
@qtc.pyqtSlot()
def on_submit_btn_clicked(self):
if self.ui.category_entry.text():
self.submitted.emit(self.ui.category_entry.text())
self.close()
如果我们使名称匹配,我们就不必在任何地方显式调用connect();回调将自动连接。
您也可以在手工编码的 GUI 中使用connectSlotsByName();您只需要显式设置每个小部件的objectName属性,以便该方法有东西与名称匹配。仅仅变量名是行不通的。
在不进行转换的情况下使用.ui 文件
如果您不介意在运行时进行一些转换开销,实际上可以通过使用 PyQt 的uic库(pyuic5基于此库)在程序内部动态转换您的.ui文件,从而避免手动转换这一步。
让我们尝试使用我们的MainWindow GUI。首先将您对Ui_MainWindow的导入注释掉,并导入uic,如下所示:
#from calendar_form import Ui_MainWindow
from PyQt5 import uic
然后,在您的MainWindow类定义之前,调用uic.loadUiType(),如下所示:
MW_Ui, MW_Base = uic.loadUiType('calendar_form.ui')
loadUiType()接受一个.ui文件的路径,并返回一个包含生成的 UI 类和其基于的 Qt 基类(在本例中为QWidget)的元组。
然后,我们可以将这些用作我们的MainWindow类的父类,如下所示:
class MainWindow(MW_Base, MW_Ui):
这种方法的缺点是额外的转换时间,但带来了更简单的构建和更少的文件维护。这是在早期开发阶段采取的一个很好的方法,当时您可能经常在 GUI 设计上进行迭代。
摘要
在本章中,您学习了 Qt 的对象间通信功能,即信号和插槽。您学会了如何使用它们来自动化表单行为,将功能连接到用户事件,并在应用程序的不同窗口之间进行通信。
在下一章中,我们将学习QMainWindow,这是一个简化常见应用程序组件构建的类。您将学会如何快速创建菜单、工具栏和对话框,以及如何保存设置。
问题
尝试这些问题来测试您对本章的了解:
- 查看下表,并确定哪些连接实际上可以进行,哪些会导致错误。您可能需要在文档中查找这些信号和插槽的签名:
| # | 信号 | 插槽 |
|---|---|---|
| 1 | QPushButton.clicked |
QLineEdit.clear |
| 2 | QComboBox.currentIndexChanged |
QListWidget.scrollToItem |
| 3 | QLineEdit.returnPressed |
QCalendarWidget.setGridVisible |
| 4 | QLineEdit.textChanged |
QTextEdit.scrollToAnchor |
-
在信号对象上,
emit()方法在信号被绑定(即连接到插槽)之前是不存在的。重写我们第一个calendar_app.py文件中的CategoryWindow.onSubmit()方法,以防submitted未绑定的可能性。 -
您在 Qt 文档中找到一个对象,该对象的插槽需要
QString作为参数。您能连接发送 Python 的str的自定义信号吗? -
您在 Qt 文档中找到一个对象,该对象的插槽需要
QVariant作为参数。您可以将哪些内置的 Python 类型发送到这个插槽? -
您正在尝试创建一个对话框窗口,该窗口需要时间,并在用户完成编辑值时发出。您正在尝试使用自动插槽连接,但您的代码没有做任何事情。确定缺少了什么:
class TimeForm(qtw.QWidget):
submitted = qtc.pyqtSignal(qtc.QTime)
def __init__(self):
super().__init__()
self.setLayout(qtw.QHBoxLayout())
self.time_inp = qtw.QTimeEdit(self)
self.layout().addWidget(self.time_inp)
def on_time_inp_editingFinished(self):
self.submitted.emit(self.time_inp.time())
self.destroy()
- 你在 Qt Designer 中为一个计算器应用程序创建了一个
.ui文件,现在你试图让它在代码中工作,但是它不起作用。在下面的源代码中你做错了什么?
from calculator_form import Ui_Calculator
class Calculator(qtw.QWidget):
def __init__(self):
self.ui = Ui_Calculator(self)
self.ui.setupGUI(self.ui)
self.show()
- 你正在尝试创建一个新的按钮类,当点击时会发出一个整数值;不幸的是,当你点击按钮时什么也不会发生。看看下面的代码,试着让它工作起来:
class IntegerValueButton(qtw.QPushButton):
clicked = qtc.pyqtSignal(int)
def __init__(self, value, *args, **kwargs):
super().__init__(*args, **kwargs)
self.value = value
self.clicked.connect(
lambda: self.clicked.emit(self.value))
进一步阅读
查看以下资源以获取更多信息:
-
PyQt 关于信号和槽支持的文档可以在这里找到:
pyqt.sourceforge.net/Docs/PyQt5/signals_slots.html -
PyQt 关于使用 Qt Designer 的文档可以在这里找到:
pyqt.sourceforge.net/Docs/PyQt5/designer.html
第四章:使用 QMainWindow 构建应用程序
基本的 Qt 小部件可以在构建简单表单时带我们走很远,但完整的应用程序包括诸如菜单、工具栏、对话框等功能,这些功能可能很繁琐和棘手,从头开始构建。幸运的是,PyQt 为这些标准组件提供了现成的类,使构建应用程序相对轻松。
在本章中,我们将探讨以下主题:
-
QMainWindow类 -
标准对话框
-
使用
QSettings保存设置
技术要求
本章将需要与第一章的设置相同。您可能还希望参考我们在 GitHub 存储库中找到的代码,网址为github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter04。
查看以下视频以查看代码的实际操作:bit.ly/2M5OGnq
QMainWindow 类
到目前为止,我们一直在使用QWidget作为顶级窗口的基类。这对于简单的表单效果很好,但它缺少许多我们可能期望从应用程序的主窗口中得到的功能,比如菜单栏或工具栏。Qt 提供了QMainWindow类来满足这种需求。
从第一章的应用程序模板中复制一份,并进行一个小但至关重要的更改:
class MainWindow(qtw.QMainWindow):
我们不再继承自QWidget,而是继承自QMainWindow。正如您将看到的,这将改变我们编写 GUI 的方式,但也会为我们的主窗口添加许多很好的功能。
为了探索这些新功能,让我们构建一个简单的纯文本编辑器。以下屏幕截图显示了我们完成的编辑器的外观,以及显示QMainWindow类的主要组件的标签:

保存您更新的模板,将其复制到一个名为text_editor.py的新文件中,并在您的代码编辑器中打开新文件。让我们开始吧!
设置中央小部件
QMainWindow分为几个部分,其中最重要的是中央小部件。这是一个代表界面主要业务部分的单个小部件。
我们通过将任何小部件的引用传递给QMainWindow.setCentralWidget()方法来设置这一点,就像这样:
self.textedit = qtw.QTextEdit()
self.setCentralWidget(self.textedit)
只能有一个中央小部件,因此在更复杂的应用程序(例如数据输入应用程序)中,它更可能是一个QWidget对象,您在其中安排了一个更复杂的 GUI;对于我们的简单文本编辑器,一个单独的QTextEdit小部件就足够了。请注意,我们没有在QMainWindow上设置布局;这样做会破坏组件的预设排列。
添加状态栏
状态栏是应用程序窗口底部的一条条纹,用于显示短文本消息和信息小部件。在 Qt 中,状态栏是一个QStatusBar对象,我们可以将其分配给主窗口的statusBar属性。
我们可以像这样创建一个:
status_bar = qtw.QStatusBar()
self.setStatusBar(status_bar)
status_bar.showMessage('Welcome to text_editor.py')
然而,没有必要费这么大的劲;如果没有状态栏,QMainWindow对象的statusBar()方法会自动创建一个新的状态栏,如果有状态栏,则返回现有的状态栏。
因此,我们可以将所有的代码简化为这样:
self.statusBar().showMessage('Welcome to text_editor.py')
showMessage()方法确切地做了它所说的,显示状态栏中给定的字符串。这是状态栏最常见的用法;但是,QStatusBar对象也可以包含其他小部件。
例如,我们可以添加一个小部件来跟踪我们的字符计数:
charcount_label = qtw.QLabel("chars: 0")
self.textedit.textChanged.connect(
lambda: charcount_label.setText(
"chars: " +
str(len(self.textedit.toPlainText()))
)
)
self.statusBar().addPermanentWidget(charcount_label)
每当我们的文本更改时,这个QLabel就会更新输入的字符数。
请注意,我们直接将其添加到状态栏,而不引用布局对象;QStatusBar具有自己的方法来添加或插入小部件,有两种模式:常规和永久。在常规模式下,如果状态栏发送了一个长消息来显示,小部件可能会被覆盖。在永久模式下,它们将保持可见。在这种情况下,我们使用addPermanentWidget()方法以永久模式添加charcount_label,这样它就不会被长文本消息覆盖。
在常规模式下添加小部件的方法是addWidget()和insertWidget();对于永久模式,请使用addPermanentWidget()和insertPermanentWidget()。
创建应用程序菜单
应用程序菜单对于大多数应用程序来说是一个关键功能,它提供了对应用程序所有功能的访问,以分层组织的下拉菜单形式。
我们可以使用QMainWindow.menuBar()方法轻松创建一个。
menubar = self.menuBar()
menuBar()方法返回一个QMenuBar对象,与statusBar()一样,如果存在窗口的现有菜单,此方法将返回该菜单,如果不存在,则会创建一个新的菜单。
默认情况下,菜单是空白的,但是我们可以使用菜单栏的addMenu()方法添加子菜单,如下所示:
file_menu = menubar.addMenu('File')
edit_menu = menubar.addMenu('Edit')
help_menu = menubar.addMenu('Help')
addMenu()返回一个QMenu对象,表示下拉子菜单。传递给该方法的字符串将用于标记主菜单栏中的菜单。
某些平台,如 macOS,不会显示空的子菜单。有关在 macOS 中构建菜单的更多信息,请参阅macOS 上的菜单部分。
要向这些菜单填充项目,我们需要创建一些操作。操作只是QAction类的对象,表示我们的程序可以执行的操作。要有用,QAction对象至少需要一个名称和一个回调;它们还可以为操作定义键盘快捷键和图标。
创建操作的一种方法是调用QMenu对象的addAction()方法,如下所示:
open_action = file_menu.addAction('Open')
save_action = file_menu.addAction('Save')
我们创建了两个名为Open和Save的操作。它们实际上什么都没做,因为我们还没有分配回调方法,但是如果运行应用程序脚本,您会看到文件菜单确实列出了两个项目,Open和Save。
创建实际执行操作的项目,我们可以传入第二个参数,其中包含一个 Python 可调用对象或 Qt 槽:
quit_action = file_menu.addAction('Quit', self.destroy)
edit_menu.addAction('Undo', self.textedit.undo)
对于需要更多控制的情况,可以显式创建QAction对象并将其添加到菜单中,如下所示:
redo_action = qtw.QAction('Redo', self)
redo_action.triggered.connect(self.textedit.redo)
edit_menu.addAction(redo_action)
QAction对象具有triggered信号,必须将其连接到可调用对象或槽,以使操作产生任何效果。当我们使用addAction()方法创建操作时,这将自动处理,但在显式创建QAction对象时,必须手动执行。
虽然在技术上不是必需的,但在显式创建QAction对象时传入父窗口小部件非常重要。如果未这样做,即使将其添加到菜单中,该项目也不会显示。
macOS 上的菜单
QMenuBar默认包装操作系统的本机菜单系统。在 macOS 上,本机菜单系统有一些需要注意的特殊之处:
-
macOS 使用全局菜单,这意味着菜单栏不是应用程序窗口的一部分,而是附加到桌面顶部的栏上。默认情况下,您的主窗口的菜单栏将用作全局菜单。如果您有一个具有多个主窗口的应用程序,并且希望它们都使用相同的菜单栏,请不要使用
QMainWindow.menuBar()来创建菜单栏。而是显式创建一个QMenuBar对象,并使用setMenuBar()方法将其分配给您使用的主窗口对象。 -
macOS 还有许多默认的子菜单和菜单项。要访问这些项目,只需在添加子菜单时使用相同的方法。有关添加子菜单的更多详细信息,请参阅进一步阅读部分中有关 macOS 菜单的更多详细信息。
-
如前所述,macOS 不会在全局菜单上显示空子菜单。
如果您发现这些问题对您的应用程序太具有问题,您可以始终指示 Qt 不使用本机菜单系统,就像这样:
self.menuBar().setNativeMenuBar(False)
这将在应用程序窗口中放置菜单栏,并消除特定于平台的问题。但是,请注意,这种方法会破坏 macOS 软件的典型工作流程,用户可能会感到不适。
有关 macOS 上的 Qt 菜单的更多信息,请访问doc.qt.io/qt-5/macos-issues.html#menu-bar。
添加工具栏
工具栏是一排长按钮,通常用于编辑命令或类似操作。与主菜单不同,工具栏不是分层的,按钮通常只用图标标记。
QMainWindow允许我们使用addToolBar()方法向应用程序添加多个工具栏,就像这样:
toolbar = self.addToolBar('File')
addToolBar()方法创建并返回一个QToolBar对象。传递给该方法的字符串成为工具栏的标题。
我们可以像向QMenu对象添加QAction对象一样添加到QToolBar对象中:
toolbar.addAction(open_action)
toolbar.addAction("Save")
与菜单一样,我们可以添加QAction对象,也可以只添加构建操作所需的信息(标题、回调等)。
运行应用程序;它应该看起来像这样:

请注意,工具栏的标题不会显示在工具栏上。但是,如果右键单击工具栏区域,您将看到一个弹出菜单,其中包含所有工具栏标题,带有复选框,允许您显示或隐藏应用程序的任何工具栏。
默认情况下,工具栏可以从应用程序中拆下并悬浮,或者停靠到应用程序的四个边缘中的任何一个。可以通过将movable和floatable属性设置为False来禁用此功能:
toolbar.setMovable(False)
toolbar.setFloatable(False)
您还可以通过将其allowedAreas属性设置为来自QtCore.Qt.QToolBarAreas枚举的标志组合,限制窗口的哪些边可以停靠该工具栏。
例如,让我们将工具栏限制为仅限于顶部和底部区域:
toolbar.setAllowedAreas(
qtc.Qt.TopToolBarArea |
qtc.Qt.BottomToolBarArea
)
我们的工具栏当前具有带文本标签的按钮,但通常工具栏会有带图标标签的按钮。为了演示它的工作原理,我们需要一些图标。
我们可以从内置样式中提取一些图标,就像这样:
open_icon = self.style().standardIcon(qtw.QStyle.SP_DirOpenIcon)
save_icon = self.style().standardIcon(qtw.QStyle.SP_DriveHDIcon)
现在不要担心这段代码的工作原理;有关样式和图标的完整讨论将在第六章 Qt 应用程序的样式 中进行。现在只需了解open_icon和save_icon是QIcon对象,这是 Qt 处理图标的方式。
这些可以附加到我们的QAction对象,然后可以将它们附加到工具栏,就像这样:
open_action.setIcon(open_icon)
toolbar.addAction(open_action)
如您所见,这看起来好多了:

注意,当您运行此代码时,菜单中的文件 | 打开选项现在也有图标。因为两者都使用open_action对象,我们对该操作对象所做的任何更改都将传递到对象的所有使用中。
图标对象可以作为第一个参数传递给工具栏的addAction方法,就像这样:
toolbar.addAction(
save_icon,
'Save',
lambda: self.statusBar().showMessage('File Saved!')
)
这将在工具栏中添加一个带有图标和一个相当无用的回调的保存操作。请注意,这一次,菜单中的文件 | 保存操作没有图标;尽管我们使用了相同的标签文本,在两个地方分别调用addAction()会导致两个不同且不相关的QAction对象。
最后,就像菜单一样,我们可以显式创建QAction对象,并将它们添加到工具栏中,就像这样:
help_action = qtw.QAction(
self.style().standardIcon(qtw.QStyle.SP_DialogHelpButton),
'Help',
self, # important to pass the parent!
triggered=lambda: self.statusBar().showMessage(
'Sorry, no help yet!'
)
)
toolbar.addAction(help_action)
要在多个操作容器(工具栏、菜单等)之间同步操作,可以显式创建QAction对象,或者保存从addAction()返回的引用,以确保在每种情况下都添加相同的操作对象。
我们可以向应用程序添加任意数量的工具栏,并将它们附加到应用程序的任何一侧。要指定一侧,我们必须使用addToolBar()的另一种形式,就像这样:
toolbar2 = qtw.QToolBar('Edit')
toolbar2.addAction('Copy', self.textedit.copy)
toolbar2.addAction('Cut', self.textedit.cut)
toolbar2.addAction('Paste', self.textedit.paste)
self.addToolBar(qtc.Qt.RightToolBarArea, toolbar2)
要使用这种形式的addToolBar(),我们必须首先创建工具栏,然后将其与QtCore.Qt.ToolBarArea常量一起传递。
添加停靠窗口
停靠窗口类似于工具栏,但它们位于工具栏区域和中央窗口之间,并且能够包含任何类型的小部件。
添加一个停靠窗口就像显式创建一个工具栏一样:
dock = qtw.QDockWidget("Replace")
self.addDockWidget(qtc.Qt.LeftDockWidgetArea, dock)
与工具栏一样,默认情况下,停靠窗口可以关闭,浮动或移动到应用程序的另一侧。要更改停靠窗口是否可以关闭,浮动或移动,我们必须将其features属性设置为QDockWidget.DockWidgetFeatures标志值的组合。
例如,让我们使用户无法关闭我们的停靠窗口,通过添加以下代码:
dock.setFeatures(
qtw.QDockWidget.DockWidgetMovable |
qtw.QDockWidget.DockWidgetFloatable
)
我们已将features设置为DockWidgetMovable和DockWidgetFloatable。由于这里缺少DockWidgetClosable,用户将无法关闭小部件。
停靠窗口设计为容纳使用setWidget()方法设置的单个小部件。与我们主应用程序的centralWidget一样,我们通常会将其设置为包含某种表单或其他 GUI 的QWidget。
让我们构建一个表单放在停靠窗口中,如下所示:
replace_widget = qtw.QWidget()
replace_widget.setLayout(qtw.QVBoxLayout())
dock.setWidget(replace_widget)
self.search_text_inp = qtw.QLineEdit(placeholderText='search')
self.replace_text_inp = qtw.QLineEdit(placeholderText='replace')
search_and_replace_btn = qtw.QPushButton(
"Search and Replace",
clicked=self.search_and_replace
)
replace_widget.layout().addWidget(self.search_text_inp)
replace_widget.layout().addWidget(self.replace_text_inp)
replace_widget.layout().addWidget(search_and_replace_btn)
replace_widget.layout().addStretch()
addStretch()方法可以在布局上调用,以添加一个扩展的QWidget,将其他小部件推在一起。
这是一个相当简单的表单,包含两个QLineEdit小部件和一个按钮。当点击按钮时,它调用主窗口的search_and_replace()方法。让我们快速编写代码:
def search_and_replace(self):
s_text = self.search_text_inp.text()
r_text = self.replace_text_inp.text()
if s_text:
self.textedit.setText(
self.textedit.toPlainText().replace(s_text, r_text)
)
这种方法只是检索两行编辑的内容;然后,如果第一个中有内容,它将在文本编辑的内容中用第二个文本替换所有实例。
此时运行程序,您应该在应用程序的左侧看到我们的停靠窗口,如下所示:

请注意停靠窗口右上角的图标。这允许用户将小部件分离并浮动到应用程序窗口之外。
其他QMainWindow功能
尽管我们已经涵盖了它的主要组件,但QMainWindow提供了许多其他功能和配置选项,您可以在其文档中探索这些选项doc.qt.io/qt-5/qmainwindow.html。我们可能会在未来的章节中涉及其中一些,因为我们将从现在开始广泛使用QMainWindow。
标准对话框
对话框在应用程序中通常是必需的,无论是询问问题,呈现表单还是仅向用户提供一些信息。Qt 提供了各种各样的现成对话框,用于常见情况,以及定义自定义对话框的能力。在本节中,我们将看一些常用的对话框类,并尝试设计自己的对话框。
QMessageBox
QMessageBox是一个简单的对话框,主要用于显示短消息或询问是或否的问题。使用QMessageBox的最简单方法是利用其方便的静态方法,这些方法可以创建并显示一个对话框,而不需要太多麻烦。
六个静态方法如下:
| 功能 | 类型 | 对话框 |
|---|---|---|
about() |
非模态 | 显示应用程序的关于对话框,并提供给定的文本。 |
aboutQt() |
非模态 | 显示 Qt 的关于对话框。 |
critical() |
模态 | 显示带有提供的文本的关键错误消息。 |
information() |
模态 | 显示带有提供的文本的信息消息。 |
warning() |
模态 | 显示带有提供的文本的警告消息。 |
question() |
模态 | 向用户提问。 |
这些对话框之间的主要区别在于默认图标,默认按钮和对话框的模态性。
对话框可以是模态的,也可以是非模态的。模态对话框阻止用户与程序的任何其他部分进行交互,并在显示时阻止程序执行,并且在完成时可以返回一个值。非模态对话框不会阻止执行,但它们也不会返回值。在模态QMessageBox的情况下,返回值是表示按下的按钮的enum常量。
让我们使用about()方法向我们的应用程序添加一个关于消息。首先,我们将创建一个回调来显示对话框:
def showAboutDialog(self):
qtw.QMessageBox.about(
self,
"About text_editor.py",
"This is a text editor written in PyQt5."
)
关于对话框是非模态的,因此它实际上只是一种被动显示信息的方式。参数依次是对话框的父窗口小部件,对话框的窗口标题文本和对话框的主要文本。
回到构造函数,让我们添加一个菜单操作来调用这个方法:
help_menu.addAction('About', self.showAboutDialog)
模态对话框可用于从用户那里检索响应。例如,我们可以警告用户我们的编辑器尚未完成,并查看他们是否真的打算使用它,如下所示:
response = qtw.QMessageBox.question(
self,
'My Text Editor',
'This is beta software, do you want to continue?'
)
if response == qtw.QMessageBox.No:
self.close()
sys.exit()
所有模态对话框都返回与用户按下的按钮相对应的 Qt 常量;默认情况下,question()创建一个带有QMessageBox.Yes和QMessageBox.No按钮值的对话框,因此我们可以测试响应并做出相应的反应。还可以通过传入第四个参数来覆盖呈现的按钮,该参数包含使用管道运算符组合的多个按钮。
例如,我们可以将No更改为Abort,如下所示:
response = qtw.QMessageBox.question(
self,
'My Text Editor',
'This is beta software, do you want to continue?',
qtw.QMessageBox.Yes | qtw.QMessageBox.Abort
)
if response == qtw.QMessageBox.Abort:
self.close()
sys.exit()
如果静态的QMessageBox方法不提供足够的灵活性,还可以显式创建QMessageBox对象,如下所示:
splash_screen = qtw.QMessageBox()
splash_screen.setWindowTitle('My Text Editor')
splash_screen.setText('BETA SOFTWARE WARNING!')
splash_screen.setInformativeText(
'This is very, very beta, '
'are you really sure you want to use it?'
)
splash_screen.setDetailedText(
'This editor was written for pedagogical '
'purposes, and probably is not fit for real work.'
)
splash_screen.setWindowModality(qtc.Qt.WindowModal)
splash_screen.addButton(qtw.QMessageBox.Yes)
splash_screen.addButton(qtw.QMessageBox.Abort)
response = splash_screen.exec()
if response == qtw.QMessageBox.Abort:
self.close()
sys.exit()
正如您所看到的,我们可以在消息框上设置相当多的属性;这些在这里描述:
| 属性 | 描述 |
|---|---|
windowTitle |
对话框任务栏和标题栏中打印的标题。 |
text |
对话框中显示的文本。 |
informativeText |
在text字符串下显示的较长的解释性文本,通常以较小或较轻的字体显示。 |
detailedText |
将隐藏在“显示详细信息”按钮后面并显示在滚动文本框中的文本。用于调试或日志输出。 |
windowModality |
用于设置消息框是模态还是非模态。需要一个QtCore.Qt.WindowModality常量。 |
我们还可以使用addButton()方法向对话框添加任意数量的按钮,然后通过调用其exec()方法显示对话框。如果我们配置对话框为模态,此方法将返回与单击的按钮匹配的常量。
QFileDialog
应用程序通常需要打开或保存文件,用户需要一种简单的方法来浏览和选择这些文件。 Qt 为我们提供了QFileDialog类来满足这种需求。
与QMessageBox一样,QFileDialog类包含几个静态方法,显示适当的模态对话框并返回用户选择的值。
此表显示了静态方法及其预期用途:
| 方法 | 返回 | 描述 |
|---|---|---|
getExistingDirectory |
String | 选择现有目录路径。 |
getExistingDirectoryUrl |
QUrl |
选择现有目录 URL。 |
getOpenFileName |
String | 选择要打开的现有文件名路径。 |
getOpenFileNames |
List | 选择多个现有文件名路径以打开。 |
getOpenFileUrl |
QUrl |
选择现有文件名 URL。 |
getSaveFileName |
String | 选择要保存到的新文件名路径或现有文件名路径。 |
getSaveFileUrl |
QUrl |
选择新的或现有的 URL。 |
在支持的平台上,这些方法的 URL 版本允许选择远程文件和目录。
要了解文件对话框的工作原理,让我们在应用程序中创建打开文件的能力:
def openFile(self):
filename, _ = qtw.QFileDialog.getOpenFileName()
if filename:
try:
with open(filename, 'r') as fh:
self.textedit.setText(fh.read())
except Exception as e:
qtw.QMessageBox.critical(f"Could not load file: {e}")
getOpenFileName()返回一个包含所选文件名和所选文件类型过滤器的元组。如果用户取消对话框,将返回一个空字符串作为文件名,并且我们的方法将退出。如果我们收到一个文件名,我们尝试打开文件并将textedit小部件的内容写入其中。
由于我们不使用方法返回的第二个值,我们将其分配给_(下划线)变量。这是命名不打算使用的变量的标准 Python 约定。
getOpenFileName()有许多用于配置对话框的参数,所有这些参数都是可选的。按顺序,它们如下:
-
父窗口小部件
-
标题,用于窗口标题
-
起始目录,作为路径字符串
-
文件类型过滤器下拉菜单可用的过滤器
-
默认选择的过滤器
-
选项标志
例如,让我们配置我们的文件对话框:
filename, _ = qtw.QFileDialog.getOpenFileName(
self,
"Select a text file to open…",
qtc.QDir.homePath(),
'Text Files (*.txt) ;;Python Files (*.py) ;;All Files (*)',
'Python Files (*.py)',
qtw.QFileDialog.DontUseNativeDialog |
qtw.QFileDialog.DontResolveSymlinks
)
QDir.homePath()是一个返回用户主目录的静态方法。
请注意,过滤器被指定为单个字符串;每个过滤器都是一个描述加上括号内的通配符字符串,并且过滤器之间用双分号分隔。这将导致一个看起来像这样的过滤器下拉菜单:

最后,我们可以使用管道运算符组合一系列选项标志。在这种情况下,我们告诉 Qt 不要使用本机 OS 文件对话框,也不要解析符号链接(这两者都是默认情况下)。有关选项标志的完整列表,请参阅QFileDialog文档doc.qt.io/qt-5/qfiledialog.html#Option-enum。
保存文件对话框的工作方式基本相同,但提供了更适合保存文件的界面。我们可以实现我们的saveFile()方法如下:
def saveFile(self):
filename, _ = qtw.QFileDialog.getSaveFileName(
self,
"Select the file to save to…",
qtc.QDir.homePath(),
'Text Files (*.txt) ;;Python Files (*.py) ;;All Files (*)'
)
if filename:
try:
with open(filename, 'w') as fh:
fh.write(self.textedit.toPlainText())
except Exception as e:
qtw.QMessageBox.critical(f"Could not save file: {e}")
其他QFileDialog便利方法的工作方式相同。与QMessageBox一样,也可以显式创建一个QFileDialog对象,手动配置其属性,然后使用其exec()方法显示它。然而,这很少是必要的,因为内置方法对大多数文件选择情况都是足够的。
在继续之前,不要忘记在MainWindow构造函数中添加调用这些方法的操作:
open_action.triggered.connect(self.openFile)
save_action.triggered.connect(self.saveFile)
QFontDialog
Qt 提供了许多其他方便的选择对话框,类似于QFileDialog;其中一个对话框是QFontDialog,允许用户选择和配置文本字体的各个方面。
与其他对话框类一样,最简单的方法是调用静态方法显示对话框并返回用户的选择,这种情况下是getFont()方法。
让我们在MainWindow类中添加一个回调方法来设置编辑器字体:
def set_font(self):
current = self.textedit.currentFont()
font, accepted = qtw.QFontDialog.getFont(current, self)
if accepted:
self.textedit.setCurrentFont(font)
getFont以当前字体作为参数,这使得它将所选字体设置为当前字体(如果您忽略这一点,对话框将默认为列出的第一个字体)。
它返回一个包含所选字体和一个布尔值的元组,指示用户是否点击了确定。字体作为QFont对象返回,该对象封装了字体系列、样式、大小、效果和字体的书写系统。我们的方法可以将此对象传回到QTextEdit对象的setCurrentFont()槽中,以设置其字体。
与QFileDialog一样,如果操作系统有原生字体对话框,Qt 会尝试使用它;否则,它将使用自己的小部件。您可以通过将DontUseNativeDialog选项传递给options关键字参数来强制使用对话框的 Qt 版本,就像我们在这里做的那样:
font, accepted = qtw.QFontDialog.getFont(
current,
self,
options=(
qtw.QFontDialog.DontUseNativeDialog |
qtw.QFontDialog.MonospacedFonts
)
)
我们还在这里传入了一个选项,以限制对话框为等宽字体。有关可用选项的更多信息,请参阅QFontDialog的 Qt 文档doc.qt.io/qt-5/qfontdialog.html#FontDialogOption-enum。
其他对话框
Qt 包含其他对话框类,用于选择颜色、请求输入值等。所有这些类似于文件和字体对话框,它们都是QDialog类的子类。我们可以自己子类化QDialog来创建自定义对话框。
例如,假设我们想要一个对话框来输入我们的设置。我们可以像这样开始构建它:
class SettingsDialog(qtw.QDialog):
"""Dialog for setting the settings"""
def __init__(self, settings, parent=None):
super().__init__(parent, modal=True)
self.setLayout(qtw.QFormLayout())
self.settings = settings
self.layout().addRow(
qtw.QLabel('<h1>Application Settings</h1>'),
)
self.show_warnings_cb = qtw.QCheckBox(
checked=settings.get('show_warnings')
)
self.layout().addRow("Show Warnings", self.show_warnings_cb)
self.accept_btn = qtw.QPushButton('Ok', clicked=self.accept)
self.cancel_btn = qtw.QPushButton('Cancel', clicked=self.reject)
self.layout().addRow(self.accept_btn, self.cancel_btn)
这段代码与我们在过去章节中使用QWidget创建的弹出框并没有太大的区别。然而,通过使用QDialog,我们可以免费获得一些东西,特别是这些:
-
我们获得了
accept和reject插槽,可以将适当的按钮连接到这些插槽。默认情况下,这些会导致窗口关闭并分别发出accepted或rejected信号。 -
我们还可以使用
exec()方法,该方法返回一个布尔值,指示对话框是被接受还是被拒绝。 -
我们可以通过向
super()构造函数传递适当的值来轻松设置对话框为模态或非模态。
QDialog为我们提供了很多灵活性,可以让我们如何利用用户输入的数据。例如,我们可以使用信号来发射数据,或者重写exec()来返回数据。
在这种情况下,由于我们传入了一个可变的dict对象,我们将重写accept()来修改那个dict对象:
def accept(self):
self.settings['show_warnings'] = self.show_warnings_cb.isChecked()
super().accept()
回到MainWindow类,让我们创建一个属性和方法来使用新的对话框:
class MainWindow(qtw.QMainWindow):
settings = {'show_warnings': True}
def show_settings(self):
settings_dialog = SettingsDialog(self.settings, self)
settings_dialog.exec()
使用QDialog类就像创建对话框类的实例并调用exec()一样简单。在这种情况下,由于我们直接编辑我们的settings dict,所以我们不需要担心连接accepted信号或使用exec()的输出。
使用 QSettings 保存设置
任何合理大小的应用程序都可能积累需要在会话之间存储的设置。保存这些设置通常涉及大量繁琐的文件操作和数据序列化工作,当我们希望跨平台良好地工作时,这种工作变得更加复杂。Qt 的QtCore.QSettings类解救了我们。
QSettings类是一个简单的键值数据存储,会以平台适当的方式自动持久化。例如,在 Windows 上,设置存储在注册表数据库中,而在 Linux 上,它们被放置在~/.config下的纯文本配置文件中。
让我们用QSettings对象替换我们在文本编辑器中创建的设置dict对象。
要创建一个QSettings对象,我们需要传入公司名称和应用程序名称,就像这样:
class MainWindow(qtw.QMainWindow):
settings = qtc.QSettings('Alan D Moore', 'text editor')
这些字符串将确定存储设置的注册表键或文件路径。例如,在 Linux 上,此设置文件将保存在~/.config/Alan D Moore/text editor.conf。在 Windows 上,它将存储在注册表中的HKEY_CURRENT_USER\Alan D Moore\text editor\。
我们可以使用对象的value()方法查询任何设置的值;例如,我们可以根据show_warnings设置使我们的启动警告对话框成为有条件的:
if self.settings.value('show_warnings', False, type=bool):
# Warning dialog code follows...
value()的参数是键字符串、如果未找到键则是默认值,以及type关键字参数,告诉QSettings如何解释保存的值。type参数至关重要;并非所有平台都能以明确的方式充分表示所有数据类型。例如,如果未指定数据类型,则布尔值将作为字符串true和false返回,这两者在 Python 中都是True。
设置键的值使用setValue()方法,就像在SettingsDialog.accept()方法中所示的那样:
self.settings.setValue(
'show_warnings',
self.show_warnings_cb.isChecked()
)
请注意,我们不必做任何事情将这些值存储到磁盘上;它们会被 Qt 事件循环定期自动同步到磁盘上。它们也会在创建QSettings对象的时候自动从磁盘上读取。简单地用QSettings对象替换我们原来的settings dict 就足以让我们获得持久的设置,而无需编写一行文件 I/O 代码!
QSettings 的限制
尽管它们很强大,QSettings对象不能存储任何东西。设置对象中的所有值都存储为QVariant对象,因此只有可以转换为QVariant的对象才能存储。这包括了一个长列表的类型,包括几乎任何 Python 内置类型和QtCore中的大多数数据类。甚至函数引用也可以被存储(尽管不是函数定义)。
不幸的是,如果你尝试存储一个无法正确存储的对象,QSettings.setValue()既不会抛出异常也不会返回错误。它会在控制台打印警告并存储一些可能不会有用的东西,例如:
app = qtw.QApplication([])
s = qtc.QSettings('test')
s.setValue('app', app)
# Prints: QVariant::save: unable to save type 'QObject*' (type id: 39).
一般来说,如果你正在存储清晰表示数据的对象,你不应该遇到问题。
QSettings对象的另一个主要限制是它无法自动识别一些存储对象的数据类型,就像我们在布尔值中看到的那样。因此,在处理任何不是字符串值的东西时,传递type参数是至关重要的。
总结
在本章中,你学习了有助于构建完整应用程序的 PyQt 类。你学习了QMainWindow类,它的菜单、状态栏、工具栏和停靠窗口。你还学习了从QDialog派生的标准对话框和消息框,以及如何使用QSettings存储应用程序设置。
在下一章中,我们将学习 Qt 中的模型-视图类,这将帮助我们分离关注点并创建更健壮的应用程序设计。
问题
尝试这些问题来测试你从本章中学到的知识:
-
你想要使用
QMainWindow与第三章中的calendar_app.py脚本,使用信号和槽处理事件。你会如何进行转换? -
你正在开发一个应用程序,并将子菜单名称添加到菜单栏,但没有填充任何子菜单项。你的同事说在他们测试时,他们的桌面上没有出现任何菜单名称。你的代码看起来是正确的;这里可能出了什么问题?
-
你正在开发一个代码编辑器,并希望为与调试器交互创建一个侧边栏面板。哪个
QMainWindow特性对这个任务最合适? -
以下代码不正确;无论点击什么都会继续进行。为什么它不起作用,你该如何修复它?
answer = qtw.QMessageBox.question(
None, 'Continue?', 'Run this program?')
if not answer:
sys.exit()
- 你正在通过子类化
QDialog来构建一个自定义对话框。你需要将输入到对话框中的信息传回主窗口对象。以下哪种方法将不起作用?
-
- 传入一个可变对象,并使用对话框的
accept()方法来更改其值。
- 传入一个可变对象,并使用对话框的
-
重写对象的
accept()方法,并让它返回输入值的字典。 -
重写对话框的
accepted信号,使其传递输入值的字典。将此信号连接到主窗口类中的回调函数。 -
你正在 Linux 上编写一个名为SuperPhoto的照片编辑器。你已经编写了代码并保存了用户设置,但在
~/.config/中找不到SuperPhoto.conf。查看代码并确定出了什么问题:
settings = qtc.QSettings()
settings.setValue('config_file', 'SuperPhoto.conf')
settings.setValue('default_color', QColor('black'))
settings.sync()
- 你正在从设置对话框保存偏好设置,但由于某种原因,保存的设置回来的时候非常奇怪。这里有什么问题?
settings = qtc.QSettings('My Company', 'SuperPhoto')
settings.setValue('Default Name', dialog.default_name_edit.text)
settings.setValue('Use GPS', dialog.gps_checkbox.isChecked)
settings.setValue('Default Color', dialog.color_picker.color)
进一步阅读
有关更多信息,请参考以下内容:
-
Qt 的
QMainWindow文档可以在doc.qt.io/qt-5/qmainwindow.html找到。 -
使用
QMainWindow的示例可以在github.com/pyqt/examples/tree/master/mainwindows找到。 -
苹果的 macOS 人机界面指南包括如何构建应用程序菜单的指导。这些可以在
developer.apple.com/design/human-interface-guidelines/macos/menus/menu-anatomy/找到。 -
微软提供了有关为 Windows 应用程序设计菜单的指南,网址为
docs.microsoft.com/en-us/windows/desktop/uxguide/cmd-menus。 -
PyQt 提供了一些关于对话框使用的示例,网址为
github.com/pyqt/examples/tree/master/dialogs。 -
QMainWindow也可以用于创建多文档界面(MDIs)。有关如何构建 MDI 应用程序的更多信息,请参见www.pythonstudio.us/pyqt-programming/multiple-document-interface-mdi.html,以及doc.qt.io/qt-5/qtwidgets-mainwindows-mdi-example.html上的示例代码。
第五章:使用模型-视图类创建数据接口
绝大多数应用软件都是用来查看和操作组织好的数据。即使在不是显式数据库应用程序的应用程序中,通常也需要以较小的规模与数据集进行交互,比如用选项填充组合框或显示一系列设置。如果没有某种组织范式,GUI 和一组数据之间的交互很快就会变成一团乱麻的代码噩梦。模型-视图模式就是这样一种范式。
在本章中,我们将学习如何使用 Qt 的模型-视图小部件以及如何在应用程序中优雅地处理数据。我们将涵盖以下主题:
-
理解模型-视图设计
-
PyQt 中的模型和视图
-
构建一个逗号分隔值(CSV)编辑器
技术要求
本章具有与前几章相同的技术要求。您可能还希望从github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter05获取示例代码。
您还需要一个或两个 CSV 文件来使用我们的 CSV 编辑器。这些可以在任何电子表格程序中制作,并且应该以列标题作为第一行创建。
查看以下视频,看看代码是如何运行的:bit.ly/2M66bnv
理解模型-视图设计
模型-视图是一种实现关注点分离的软件应用设计范式。它基于古老的模型-视图-控制器(MVC)模式,但不同之处在于控制器和视图被合并成一个组件。
在模型-视图设计中,模型是保存应用程序数据并包含检索、存储和操作数据逻辑的组件。视图组件向用户呈现数据,并提供输入和操作数据的界面。通过将应用程序的这些组件分离,我们将它们的相互依赖性降到最低,使它们更容易重用或重构。
让我们通过一个简单的例子来说明这个过程。从第四章的应用程序模板开始,使用 QMainWindow 构建应用程序,让我们构建一个简单的文本文件编辑器:
# This code goes in MainWindow.__init__()
form = qtw.QWidget()
self.setCentralWidget(form)
form.setLayout(qtw.QVBoxLayout())
self.filename = qtw.QLineEdit()
self.filecontent = qtw.QTextEdit()
self.savebutton = qtw.QPushButton(
'Save',
clicked=self.save
)
form.layout().addWidget(self.filename)
form.layout().addWidget(self.filecontent)
form.layout().addWidget(self.savebutton)
这是一个简单的表单,包括一个用于文件名的行编辑,一个用于内容的文本编辑和一个调用save()方法的保存按钮。
让我们创建以下save()方法:
def save(self):
filename = self.filename.text()
error = ''
if not filename:
error = 'Filename empty'
elif path.exists(filename):
error = f'Will not overwrite {filename}'
else:
try:
with open(filename, 'w') as fh:
fh.write(self.filecontent.toPlainText())
except Exception as e:
error = f'Cannot write file: {e}'
if error:
qtw.QMessageBox.critical(None, 'Error', error)
这种方法检查是否在行编辑中输入了文件名,确保文件名不存在(这样你就不会在测试这段代码时覆盖重要文件!),然后尝试保存它。如果出现任何错误,该方法将显示一个QMessageBox实例来报告错误。
这个应用程序可以工作,但缺乏清晰的模型和视图分离。将文件写入磁盘的同一个方法也显示错误框并调用输入小部件方法。如果我们要扩展这个应用程序到任何程度,save()方法很快就会变成一个混合了数据处理逻辑和呈现逻辑的迷宫。
让我们用单独的Model和View类重写这个应用程序。
从应用程序模板的干净副本开始,让我们创建我们的Model类:
class Model(qtc.QObject):
error = qtc.pyqtSignal(str)
def save(self, filename, content):
print("save_called")
error = ''
if not filename:
error = 'Filename empty'
elif path.exists(filename):
error = f'Will not overwrite {filename}'
else:
try:
with open(filename, 'w') as fh:
fh.write(content)
except Exception as e:
error = f'Cannot write file: {e}'
if error:
self.error.emit(error)
我们通过子类化QObject来构建我们的模型。模型不应参与显示 GUI,因此不需要基于QWidget类。然而,由于模型将使用信号和槽进行通信,我们使用QObject作为基类。模型实现了我们在前面示例中的save()方法,但有两个变化:
-
首先,它期望用户数据作为参数传入,不知道这些数据来自哪些小部件
-
其次,当遇到错误时,它仅仅发出一个 Qt 信号,而不采取任何特定于 GUI 的操作
接下来,让我们创建我们的View类:
class View(qtw.QWidget):
submitted = qtc.pyqtSignal(str, str)
def __init__(self):
super().__init__()
self.setLayout(qtw.QVBoxLayout())
self.filename = qtw.QLineEdit()
self.filecontent = qtw.QTextEdit()
self.savebutton = qtw.QPushButton(
'Save',
clicked=self.submit
)
self.layout().addWidget(self.filename)
self.layout().addWidget(self.filecontent)
self.layout().addWidget(self.savebutton)
def submit(self):
filename = self.filename.text()
filecontent = self.filecontent.toPlainText()
self.submitted.emit(filename, filecontent)
def show_error(self, error):
qtw.QMessageBox.critical(None, 'Error', error)
这个类包含与之前相同的字段和字段布局定义。然而,这一次,我们的保存按钮不再调用save(),而是连接到一个submit()回调,该回调收集表单数据并使用信号发射它。我们还添加了一个show_error()方法来显示错误。
在我们的MainWindow.__init__()方法中,我们将模型和视图结合在一起:
self.view = View()
self.setCentralWidget(self.view)
self.model = Model()
self.view.submitted.connect(self.model.save)
self.model.error.connect(self.view.show_error)
在这里,我们创建View类的一个实例和Model类,并连接它们的信号和插槽。
在这一点上,我们的代码的模型视图版本的工作方式与我们的原始版本完全相同,但涉及更多的代码。你可能会问,这有什么意义?如果这个应用程序注定永远不会超出它现在的状态,那可能没有意义。然而,应用程序往往会在功能上扩展,并且通常其他应用程序需要重用相同的代码。考虑以下情况:
-
你想提供另一种编辑形式,也许是基于控制台的,或者具有更多的编辑功能
-
你想提供将内容保存到数据库而不是文本文件的选项
-
你正在创建另一个也将文本内容保存到文件的应用程序
在这些情况下,使用模型视图模式意味着我们不必从头开始。例如,在第一种情况下,我们不需要重写任何保存文件的代码;我们只需要创建用户界面代码,发射相同的submitted信号。随着你的代码扩展和你的应用程序变得更加复杂,这种关注点的分离将帮助你保持秩序。
PyQt 中的模型和视图
模型视图模式不仅在设计大型应用程序时有用,而且在包含数据的小部件上也同样有用。从第四章中复制应用程序模板,使用 QMainWindow 构建应用程序,让我们看一个模型视图在小部件级别上是如何工作的简单示例。
在MainWindow类中,创建一个项目列表,并将它们添加到QListWidget和QComboBox对象中:
data = [
'Hamburger', 'Cheeseburger',
'Chicken Nuggets', 'Hot Dog', 'Fish Sandwich'
]
# The list widget
listwidget = qtw.QListWidget()
listwidget.addItems(data)
# The combobox
combobox = qtw.QComboBox()
combobox.addItems(data)
self.layout().addWidget(listwidget)
self.layout().addWidget(combobox)
因为这两个小部件都是用相同的列表初始化的,所以它们都包含相同的项目。现在,让我们使列表小部件的项目可编辑:
for i in range(listwidget.count()):
item = listwidget.item(i)
item.setFlags(item.flags() | qtc.Qt.ItemIsEditable)
通过迭代列表小部件中的项目,并在每个项目上设置Qt.ItemIsEditable标志,小部件变得可编辑,我们可以改变项目的文本。运行应用程序,尝试编辑列表小部件中的项目。即使你改变了列表小部件中的项目,组合框中的项目仍然保持不变。每个小部件都有自己的内部列表模型,它存储了最初传入的项目的副本。在一个列表的副本中改变项目对另一个副本没有影响。
我们如何保持这两个列表同步?我们可以连接一些信号和插槽,或者添加类方法来做到这一点,但 Qt 提供了更好的方法。
QListWidget实际上是另外两个 Qt 类的组合:QListView和QStringListModel。正如名称所示,这些都是模型视图类。我们可以直接使用这些类来构建我们自己的带有离散模型和视图的列表小部件:
model = qtc.QStringListModel(data)
listview = qtw.QListView()
listview.setModel(model)
我们简单地创建我们的模型类,用我们的字符串列表初始化它,然后创建视图类。最后,我们使用视图的setModel()方法连接两者。
QComboBox没有类似的模型视图类,但它仍然在内部是一个模型视图小部件,并且具有使用外部模型的能力。
因此,我们可以使用setModel()将我们的QStringListModel传递给它:
model_combobox = qtw.QComboBox()
model_combobox.setModel(model)
将这些小部件添加到布局中,然后再次运行程序。这一次,你会发现对QListView的编辑立即在组合框中可用,因为你所做的更改被写入了QStringModel对象,这两个小部件都会查询项目数据。
QTableWidget和QTreeWidget也有类似的视图类:QTableView和QTreeView。然而,没有现成的模型类可以与这些视图一起使用。相反,我们必须通过分别继承QAbstractTableModel和QAbstractTreeModel来创建自己的自定义模型类。
在下一节中,我们将通过构建自己的 CSV 编辑器来介绍如何创建和使用自定义模型类。
构建 CSV 编辑器
逗号分隔值(CSV)是一种存储表格数据的纯文本格式。任何电子表格程序都可以导出为 CSV,或者您可以在文本编辑器中手动创建。我们的程序将被设计成可以打开任意的 CSV 文件并在QTableView中显示数据。通常在 CSV 的第一行用于保存列标题,因此我们的应用程序将假定这一点并使该行不可变。
创建表格模型
在开发数据驱动的模型-视图应用程序时,模型通常是最好的起点,因为这里是最复杂的代码。一旦我们把这个后端放在适当的位置,实现前端就相当简单了。
在这种情况下,我们需要设计一个可以读取和写入 CSV 数据的模型。从第四章的应用程序模板中复制应用程序模板,使用 QMainWindow,并在顶部添加 Python csv库的导入。
现在,让我们通过继承QAbstractTableModel来开始构建我们的模型:
class CsvTableModel(qtc.QAbstractTableModel):
"""The model for a CSV table."""
def __init__(self, csv_file):
super().__init__()
self.filename = csv_file
with open(self.filename) as fh:
csvreader = csv.reader(fh)
self._headers = next(csvreader)
self._data = list(csvreader)
我们的模型将以 CSV 文件的名称作为参数,并立即打开文件并将其读入内存(对于大文件来说不是一个很好的策略,但这只是一个示例程序)。我们将假定第一行是标题行,并在将其余行放入模型的_data属性之前使用next()函数检索它。
实现读取功能
为了创建我们的模型的实例以在视图中显示数据,我们需要实现三种方法:
-
rowCount(),必须返回表中的总行数 -
columnCount(),必须返回表中的总列数 -
data()用于从模型请求数据
在这种情况下,rowCount()和columnCount()都很容易:
def rowCount(self, parent):
return len(self._data)
def columnCount(self, parent):
return len(self._headers)
行数只是_data属性的长度,列数可以通过获取_headers属性的长度来获得。这两个函数都需要一个parent参数,但在这种情况下,它没有被使用,因为它是指父节点,只有在分层数据中才适用。
最后一个必需的方法是data(),需要更多解释;data()看起来像这样:
def data(self, index, role):
if role == qtc.Qt.DisplayRole:
return self._data[index.row()][index.column()]
data()的目的是根据index和role参数返回表格中单个单元格的数据。现在,index是QModelIndex类的一个实例,它描述了列表、表格或树结构中单个节点的位置。每个QModelIndex包含以下属性:
-
row号 -
column号 -
parent模型索引
在我们这种表格模型的情况下,我们对row和column属性感兴趣,它们指示我们想要的数据单元的表行和列。如果我们处理分层数据,我们还需要parent属性,它将是父节点的索引。如果这是一个列表,我们只关心row。
role是QtCore.Qt.ItemDataRole枚举中的一个常量。当视图从模型请求数据时,它传递一个role值,以便模型可以返回适合请求上下文的数据或元数据。例如,如果视图使用EditRole角色进行请求,模型应返回适合编辑的数据。如果视图使用DecorationRole角色进行请求,模型应返回适合单元格的图标。
如果没有特定角色的数据需要返回,data()应该返回空。
在这种情况下,我们只对DisplayRole角色感兴趣。要实际返回数据,我们需要获取索引的行和列,然后使用它来从我们的 CSV 数据中提取适当的行和列。
在这一点上,我们有一个最小功能的只读 CSV 模型,但我们可以添加更多内容。
添加标题和排序
能够返回数据只是模型功能的一部分。模型还需要能够提供其他信息,例如列标题的名称或排序数据的适当方法。
要在我们的模型中实现标题数据,我们需要创建一个headerData()方法:
def headerData(self, section, orientation, role):
if (
orientation == qtc.Qt.Horizontal and
role == qtc.Qt.DisplayRole
):
return self._headers[section]
else:
return super().headerData(section, orientation, role)
headerData()根据三个信息——section、orientation和role返回单个标题的数据。
标题可以是垂直的或水平的,由方向参数确定,该参数指定为QtCore.Qt.Horizontal或QtCore.Qt.Vertical常量。
该部分是一个整数,指示列号(对于水平标题)或行号(对于垂直标题)。
如data()方法中的角色参数一样,指示需要返回数据的上下文。
在我们的情况下,我们只对DisplayRole角色显示水平标题。与data()方法不同,父类方法具有一些默认逻辑和返回值,因此在任何其他情况下,我们希望返回super().headerData()的结果。
如果我们想要对数据进行排序,我们需要实现一个sort()方法,它看起来像这样:
def sort(self, column, order):
self.layoutAboutToBeChanged.emit() # needs to be emitted before a sort
self._data.sort(key=lambda x: x[column])
if order == qtc.Qt.DescendingOrder:
self._data.reverse()
self.layoutChanged.emit() # needs to be emitted after a sort
sort()接受一个column号和order,它可以是QtCore.Qt.DescendingOrder或QtCore.Qt.AscendingOrder,该方法的目的是相应地对数据进行排序。在这种情况下,我们使用 Python 的list.sort()方法来就地对数据进行排序,使用column参数来确定每行的哪一列将被返回进行排序。如果请求降序排序,我们将使用reverse()来相应地改变排序顺序。
sort()还必须发出两个信号:
-
在内部进行任何排序之前,必须发出
layoutAboutToBeChanged信号。 -
在排序完成后,必须发出
layoutChanged信号。
这两个信号被视图用来适当地重绘自己,因此重要的是要记得发出它们。
实现写入功能
我们的模型目前是只读的,但因为我们正在实现 CSV 编辑器,我们需要实现写入数据。首先,我们需要重写一些方法以启用对现有数据行的编辑:flags()和setData()。
flags()接受一个QModelIndex值,并为给定索引处的项目返回一组QtCore.Qt.ItemFlag常量。这些标志用于指示项目是否可以被选择、拖放、检查,或者——对我们来说最有趣的是——编辑。
我们的方法如下:
def flags(self, index):
return super().flags(index) | qtc.Qt.ItemIsEditable
在这里,我们将ItemIsEditable标志添加到父类flags()方法返回的标志列表中,指示该项目是可编辑的。如果我们想要实现逻辑,在某些条件下只使某些单元格可编辑,我们可以在这个方法中实现。
例如,如果我们有一个存储在self.readonly_indexes中的只读索引列表,我们可以编写以下方法:
def flags(self, index):
if index not in self.readonly_indexes:
return super().flags(index) | qtc.Qt.ItemIsEditable
else:
return super().flags(index)
然而,对于我们的应用程序,我们希望每个单元格都是可编辑的。
现在模型中的所有项目都标记为可编辑,我们需要告诉我们的模型如何实际编辑它们。这在setData()方法中定义:
def setData(self, index, value, role):
if index.isValid() and role == qtc.Qt.EditRole:
self._data[index.row()][index.column()] = value
self.dataChanged.emit(index, index, [role])
return True
else:
return False
setData()方法接受要设置的项目的索引、要设置的值和项目角色。此方法必须承担设置数据的任务,然后返回一个布尔值,指示数据是否成功更改。只有在索引有效且角色为EditRole时,我们才希望这样做。
如果数据发生变化,setData()也必须发出dataChanged信号。每当项目或一组项目与任何角色相关的更新时,都会发出此信号,因此携带了三个信息:被更改的最左上角的索引,被更改的最右下角的索引,以及每个索引的角色列表。在我们的情况下,我们只改变一个单元格,所以我们可以传递我们的索引作为单元格范围的两端,以及一个包含单个角色的列表。
data()方法还有一个小改变,虽然不是必需的,但会让用户更容易操作。回去编辑该方法如下:
def data(self, index, role):
if role in (qtc.Qt.DisplayRole, qtc.Qt.EditRole):
return self._data[index.row()][index.column()]
当选择表格单元格进行编辑时,将使用EditRole角色调用data()。在这个改变之前,当使用该角色调用data()时,data()会返回None,结果,单元格中的数据将在选择单元格时消失。通过返回EditRole的数据,用户将可以访问现有数据进行编辑。
我们现在已经实现了对现有单元格的编辑,但为了使我们的模型完全可编辑,我们需要实现插入和删除行。我们可以通过重写另外两个方法来实现这一点:insertRows()和removeRows()。
insertRows()方法如下:
def insertRows(self, position, rows, parent):
self.beginInsertRows(
parent or qtc.QModelIndex(),
position,
position + rows - 1
)
for i in range(rows):
default_row = [''] * len(self._headers)
self._data.insert(position, default_row)
self.endInsertRows()
该方法接受插入开始的位置,要插入的行数以及父节点索引(与分层数据一起使用)。
在该方法内部,我们必须在调用beginInsertRows()和endInsertRows()之间放置我们的逻辑。beginInsertRows()方法准备了底层对象进行修改,并需要三个参数:
-
父节点的
ModelIndex对象,对于表格数据来说是一个空的QModelIndex -
行插入将开始的位置
-
行插入将结束的位置
我们可以根据传入方法的起始位置和行数来计算所有这些。一旦我们处理了这个问题,我们就可以生成一些行(以空字符串列表的形式,长度与我们的标题列表相同),并将它们插入到self._data中的适当索引位置。
在插入行后,我们调用endInsertRows(),它不带任何参数。
removeRows()方法非常相似:
def removeRows(self, position, rows, parent):
self.beginRemoveRows(
parent or qtc.QModelIndex(),
position,
position + rows - 1
)
for i in range(rows):
del(self._data[position])
self.endRemoveRows()
再次,我们需要在编辑数据之前调用beginRemoveRows(),在编辑后调用endRemoveRows(),就像我们对插入一样。如果我们想允许编辑列结构,我们可以重写insertColumns()和removeColumns()方法,它们的工作方式与行方法基本相同。现在,我们只会坚持行编辑。
到目前为止,我们的模型是完全可编辑的,但我们将添加一个方法,以便将数据刷新到磁盘,如下所示:
def save_data(self):
with open(self.filename, 'w', encoding='utf-8') as fh:
writer = csv.writer(fh)
writer.writerow(self._headers)
writer.writerows(self._data)
这个方法只是打开我们的文件,并使用 Python 的csv库写入标题和所有数据行。
在视图中使用模型
现在我们的模型已经准备好使用了,让我们充实应用程序的其余部分,以演示如何使用它。
首先,我们需要创建一个QTableView小部件,并将其添加到我们的MainWindow中:
# in MainWindow.__init__()
self.tableview = qtw.QTableView()
self.tableview.setSortingEnabled(True)
self.setCentralWidget(self.tableview)
如您所见,我们不需要做太多工作来使QTableView小部件与模型一起工作。因为我们在模型中实现了sort(),我们将启用排序,但除此之外,它不需要太多配置。
当然,要查看任何数据,我们需要将模型分配给视图;为了创建一个模型,我们需要一个文件。让我们创建一个回调来获取一个:
def select_file(self):
filename, _ = qtw.QFileDialog.getOpenFileName(
self,
'Select a CSV file to open…',
qtc.QDir.homePath(),
'CSV Files (*.csv) ;; All Files (*)'
)
if filename:
self.model = CsvTableModel(filename)
self.tableview.setModel(self.model)
我们的方法使用QFileDialog类来询问用户要打开的 CSV 文件。如果选择了一个文件,它将使用 CSV 文件来创建我们模型类的一个实例。然后使用setModel()访问方法将模型类分配给视图。
回到MainWindow.__init__(),让我们为应用程序创建一个主菜单,并添加一个“打开”操作:
menu = self.menuBar()
file_menu = menu.addMenu('File')
file_menu.addAction('Open', self.select_file)
如果您现在运行脚本,您应该能够通过转到“文件|打开”并选择有效的 CSV 文件来打开文件。您应该能够查看甚至编辑数据,并且如果单击标题单元格,数据应该按列排序。
接下来,让我们添加用户界面组件,以便保存我们的文件。首先,创建一个调用MainWindow方法save_file()的菜单项:
file_menu.addAction('Save', self.save_file)
现在,让我们创建我们的save_file()方法来实际保存文件:
def save_file(self):
if self.model:
self.model.save_data()
要保存文件,我们实际上只需要调用模型的save_data()方法。但是,我们不能直接将菜单项连接到该方法,因为在实际加载文件之前模型不存在。这个包装方法允许我们创建一个没有模型的菜单选项。
我们想要连接的最后一个功能是能够插入和删除行。在电子表格中,能够在所选行的上方或下方插入行通常是有用的。因此,让我们在MainWindow中创建回调来实现这一点:
def insert_above(self):
selected = self.tableview.selectedIndexes()
row = selected[0].row() if selected else 0
self.model.insertRows(row, 1, None)
def insert_below(self):
selected = self.tableview.selectedIndexes()
row = selected[-1].row() if selected else self.model.rowCount(None)
self.model.insertRows(row + 1, 1, None)
在这两种方法中,我们通过调用表视图的selectedIndexes()方法来获取所选单元格的列表。这些列表从左上角的单元格到右下角的单元格排序。因此,对于插入上方,我们检索列表中第一个索引的行(如果列表为空,则为 0)。对于插入下方,我们检索列表中最后一个索引的行(如果列表为空,则为表中的最后一个索引)。最后,在这两种方法中,我们使用模型的insertRows()方法将一行插入到适当的位置。
删除行类似,如下所示:
def remove_rows(self):
selected = self.tableview.selectedIndexes()
if selected:
self.model.removeRows(selected[0].row(), len(selected), None)
这次我们只在有活动选择时才采取行动,并使用模型的removeRows()方法来删除第一个选定的行。
为了使这些回调对用户可用,让我们在MainWindow中添加一个“编辑”菜单:
edit_menu = menu.addMenu('Edit')
edit_menu.addAction('Insert Above', self.insert_above)
edit_menu.addAction('Insert Below', self.insert_below)
edit_menu.addAction('Remove Row(s)', self.remove_rows)
此时,请尝试加载 CSV 文件。您应该能够在表中插入和删除行,编辑字段并保存结果。恭喜,您已经创建了一个 CSV 编辑器!
总结
在本章中,您学习了模型视图编程。您学习了如何在常规小部件中使用模型,以及如何在 Qt 中使用特殊的模型视图类。您创建了一个自定义表模型,并通过利用模型视图类的功能快速构建了一个 CSV 编辑器。
我们将学习更高级的模型视图概念,包括委托和数据映射在第九章中,使用 QtSQL 探索 SQL。
在下一章中,您将学习如何为您的 PyQt 应用程序设置样式。我们将使用图像、动态图标、花哨的字体和颜色来装扮我们的单调表单,并学习控制 Qt GUI 整体外观和感觉的多种方法。
问题
尝试这些问题来测试您从本章中学到的知识:
- 假设我们有一个设计良好的模型视图应用程序,以下代码是模型还是视图的一部分?
def save_as(self):
filename, _ = qtw.QFileDialog(self)
self.data.save_file(filename)
-
您能否至少说出模型不应该做的两件事和视图不应该做的两件事?
-
QAbstractTableModel和QAbstractTreeModel都在名称中有Abstract。在这种情况下,Abstract在这里是什么意思?在 C++中,它的意思是否与 Python 中的意思不同? -
哪种模型类型——列表、表格或树——最适合以下数据集:
-
用户最近的文件
-
Windows 注册表
-
Linux
syslog记录 -
博客文章
-
个人称谓(例如,先生,夫人或博士)
-
分布式版本控制历史
- 为什么以下代码失败了?
class DataModel(QAbstractTreeModel):
def rowCount(self, node):
if node > 2:
return 1
else:
return len(self._data[node])
- 当插入列时,您的表模型工作不正常。您的
insertColumns()方法有什么问题?
def insertColumns(self, col, count, parent):
for row in self._data:
for i in range(count):
row.insert(col, '')
- 当悬停时,您希望您的视图显示项目数据作为工具提示。您将如何实现这一点?
进一步阅读
您可能希望查看以下资源:
-
有关模型视图编程的 Qt 文档在
doc.qt.io/qt-5/model-view-programming.html -
马丁·福勒在
martinfowler.com/eaaDev/uiArchs.html上介绍了模型-视图-控制器(MVC)及相关模式的概述。
第六章:样式化 Qt 应用程序
很容易欣赏到 Qt 默认提供的清晰、本地外观。但对于不那么商业化的应用程序,普通的灰色小部件和标准字体并不总是设置正确的语气。即使是最沉闷的实用程序或数据输入应用程序偶尔也会受益于添加图标或谨慎调整字体以增强可用性。幸运的是,Qt 的灵活性使我们能够自己控制应用程序的外观和感觉。
在本章中,我们将涵盖以下主题:
-
使用字体、图像和图标
-
配置颜色、样式表和样式
-
创建动画
技术要求
在本章中,您将需要第一章中列出的所有要求,PyQt 入门,以及第四章中的 Qt 应用程序模板,使用 QMainWindow 构建应用程序。
此外,您可能需要 PNG、JPEG 或 GIF 图像文件来使用;您可以使用示例代码中包含的这些文件:github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter06。
查看以下视频,了解代码的运行情况:bit.ly/2M5OJj6
使用字体、图像和图标
我们将通过自定义应用程序的字体、显示一些静态图像和包含动态图标来开始样式化我们的 Qt 应用程序。但在此之前,我们需要创建一个图形用户界面(GUI),以便我们可以使用。我们将创建一个游戏大厅对话框,该对话框将用于登录到一个名为Fight Fighter的虚构多人游戏。
要做到这一点,打开应用程序模板的新副本,并将以下 GUI 代码添加到MainWindow.__init__()中:
self.setWindowTitle('Fight Fighter Game Lobby')
cx_form = qtw.QWidget()
self.setCentralWidget(cx_form)
cx_form.setLayout(qtw.QFormLayout())
heading = qtw.QLabel("Fight Fighter!")
cx_form.layout().addRow(heading)
inputs = {
'Server': qtw.QLineEdit(),
'Name': qtw.QLineEdit(),
'Password': qtw.QLineEdit(
echoMode=qtw.QLineEdit.Password),
'Team': qtw.QComboBox(),
'Ready': qtw.QCheckBox('Check when ready')
}
teams = ('Crimson Sharks', 'Shadow Hawks',
'Night Terrors', 'Blue Crew')
inputs['Team'].addItems(teams)
for label, widget in inputs.items():
cx_form.layout().addRow(label, widget)
self.submit = qtw.QPushButton(
'Connect',
clicked=lambda: qtw.QMessageBox.information(
None, 'Connecting', 'Prepare for Battle!'))
self.reset = qtw.QPushButton('Cancel', clicked=self.close)
cx_form.layout().addRow(self.submit, self.reset)
这是相当标准的 Qt GUI 代码,您现在应该对此很熟悉;我们通过将输入放入dict对象中并在循环中将它们添加到布局中,节省了一些代码行,但除此之外,它相对直接。根据您的操作系统和主题设置,对话框框可能看起来像以下截图:

正如您所看到的,这是一个不错的表单,但有点单调。因此,让我们探讨一下是否可以改进样式。
设置字体
我们要解决的第一件事是字体。每个QWidget类都有一个font属性,我们可以在构造函数中设置,也可以使用setFont()访问器来设置。font的值必须是一个QtGui.QFont对象。
以下是您可以创建和使用QFont对象的方法:
heading_font = qtg.QFont('Impact', 32, qtg.QFont.Bold)
heading_font.setStretch(qtg.QFont.ExtraExpanded)
heading.setFont(heading_font)
QFont对象包含描述文本将如何绘制到屏幕上的所有属性。构造函数可以接受以下任何参数:
-
一个表示字体系列的字符串
-
一个浮点数或整数,表示点大小
-
一个
QtGui.QFont.FontWeight常量,指示权重 -
一个布尔值,指示字体是否应该是斜体
字体的其余方面,如stretch属性,可以使用关键字参数或访问器方法进行配置。我们还可以创建一个没有参数的QFont对象,并按照以下方式进行程序化配置:
label_font = qtg.QFont()
label_font.setFamily('Impact')
label_font.setPointSize(14)
label_font.setWeight(qtg.QFont.DemiBold)
label_font.setStyle(qtg.QFont.StyleItalic)
for inp in inputs.values():
cx_form.layout().labelForField(inp).setFont(label_font)
在小部件上设置字体不仅会影响该小部件,还会影响所有子小部件。因此,我们可以通过在cx_form上设置字体而不是在单个小部件上设置字体来为整个表单配置字体。
处理缺失的字体
现在,如果所有平台和操作系统(OSes)都提供了无限数量的同名字体,那么您需要了解的就是QFont。不幸的是,情况并非如此。大多数系统只提供了少数内置字体,并且这些字体中只有少数是跨平台的,甚至是平台的不同版本通用的。因此,Qt 有一个处理缺失字体的回退机制。
例如,假设我们要求 Qt 使用一个不存在的字体系列,如下所示:
button_font = qtg.QFont(
'Totally Nonexistant Font Family XYZ', 15.233)
Qt 不会在此调用时抛出错误,甚至不会注册警告。相反,在未找到请求的字体系列后,它将回退到其defaultFamily属性,该属性利用了操作系统或桌面环境中设置的默认字体。
QFont对象实际上不会告诉我们发生了什么;如果查询它以获取信息,它只会告诉您已配置了什么:
print(f'Font is {button_font.family()}')
# Prints: "Font is Totally Nonexistent Font Family XYZ"
要发现实际使用的字体设置,我们需要将我们的QFont对象传递给QFontInfo对象:
actual_font = qtg.QFontInfo(button_font).family()
print(f'Actual font used is {actual_font}')
如果运行脚本,您会看到,很可能实际上使用的是默认的屏幕字体:
$ python game_lobby.py
Font is Totally Nonexistent Font Family XYZ
Actual font used is Bitstream Vera Sans
虽然这确保了用户不会在窗口中没有任何文本,但如果我们能让 Qt 更好地了解应该使用什么样的字体,那就更好了。
我们可以通过设置字体的styleHint和styleStrategy属性来实现这一点,如下所示:
button_font.setStyleHint(qtg.QFont.Fantasy)
button_font.setStyleStrategy(
qtg.QFont.PreferAntialias |
qtg.QFont.PreferQuality
)
styleHint建议 Qt 回退到的一般类别,在本例中是Fantasy类别。这里的其他选项包括SansSerif、Serif、TypeWriter、Decorative、Monospace和Cursive。这些选项对应的内容取决于操作系统和桌面环境的配置。
styleStrategy属性告诉 Qt 与所选字体的能力相关的更多技术偏好,比如抗锯齿、OpenGL 兼容性,以及大小是精确匹配还是四舍五入到最接近的非缩放大小。策略选项的完整列表可以在doc.qt.io/qt-5/qfont.html#StyleStrategy-enum找到。
设置这些属性后,再次检查字体,看看是否有什么变化:
actual_font = qtg.QFontInfo(button_font)
print(f'Actual font used is {actual_font.family()}'
f' {actual_font.pointSize()}')
self.submit.setFont(button_font)
self.cancel.setFont(button_font)
根据系统的配置,您应该看到与之前不同的结果:
$ python game_lobby.py
Actual font used is Impact 15
在这个系统上,Fantasy被解释为Impact,而PreferQuality策略标志强制最初奇怪的 15.233 点大小成为一个漂亮的15。
此时,根据系统上可用的字体,您的应用程序应该如下所示:

字体也可以与应用程序捆绑在一起;请参阅本章中的使用 Qt 资源文件部分。
添加图像
Qt 提供了许多与应用程序中使用图像相关的类,但是,对于在 GUI 中简单显示图片,最合适的是QPixmap。QPixmap是一个经过优化的显示图像类,可以加载许多常见的图像格式,包括 PNG、BMP、GIF 和 JPEG。
要创建一个,我们只需要将QPixmap传递给图像文件的路径:
logo = qtg.QPixmap('logo.png')
一旦加载,QPixmap对象可以显示在QLabel或QButton对象中,如下所示:
heading.setPixmap(logo)
请注意,标签只能显示字符串或像素图,但不能同时显示两者。
为了优化显示,QPixmap对象只提供了最小的编辑功能;但是,我们可以进行简单的转换,比如缩放:
if logo.width() > 400:
logo = logo.scaledToWidth(
400, qtc.Qt.SmoothTransformation)
在这个例子中,我们使用了像素图的scaledToWidth()方法,使用平滑的转换算法将标志的宽度限制为400像素。
QPixmap对象如此有限的原因是它们实际上存储在显示服务器的内存中。QImage类似,但是它将数据存储在应用程序内存中,因此可以进行更广泛的编辑。我们将在第十二章中更多地探讨这个类,创建使用 QPainter 进行 2D 图形。
QPixmap还提供了一个方便的功能,可以生成简单的彩色矩形,如下所示:
go_pixmap = qtg.QPixmap(qtc.QSize(32, 32))
stop_pixmap = qtg.QPixmap(qtc.QSize(32, 32))
go_pixmap.fill(qtg.QColor('green'))
stop_pixmap.fill(qtg.QColor('red'))
通过在构造函数中指定大小并使用fill()方法,我们可以创建一个简单的彩色矩形像素图。这对于显示颜色样本或用作快速的图像替身非常有用。
使用图标
现在考虑工具栏或程序菜单中的图标。当菜单项被禁用时,您期望图标以某种方式变灰。同样,如果用户使用鼠标指针悬停在按钮或项目上,您可能期望它被突出显示。为了封装这种状态相关的图像显示,Qt 提供了QIcon类。QIcon对象包含一组与小部件状态相映射的像素图。
以下是如何创建一个QIcon对象:
connect_icon = qtg.QIcon()
connect_icon.addPixmap(go_pixmap, qtg.QIcon.Active)
connect_icon.addPixmap(stop_pixmap, qtg.QIcon.Disabled)
创建图标对象后,我们使用它的addPixmap()方法将一个QPixmap对象分配给小部件状态。这些状态包括Normal、Active、Disabled和Selected。
当禁用时,connect_icon图标现在将是一个红色的正方形,或者当启用时将是一个绿色的正方形。让我们将其添加到我们的提交按钮,并添加一些逻辑来切换按钮的状态:
self.submit.setIcon(connect_icon)
self.submit.setDisabled(True)
inputs['Server'].textChanged.connect(
lambda x: self.submit.setDisabled(x == '')
)
如果您在此时运行脚本,您会看到红色的正方形出现在提交按钮上,直到“服务器”字段包含数据为止,此时它会自动切换为绿色。请注意,我们不必告诉图标对象本身切换状态;一旦分配给小部件,它就会跟踪小部件状态的任何更改。
图标可以与QPushButton、QToolButton和QAction对象一起使用;QComboBox、QListView、QTableView和QTreeView项目;以及大多数其他您可能合理期望有图标的地方。
使用 Qt 资源文件
在程序中使用图像文件的一个重要问题是确保程序可以在运行时找到它们。传递给QPixmap构造函数或QIcon构造函数的路径被解释为绝对路径(即,如果它们以驱动器号或路径分隔符开头),或者相对于当前工作目录(您无法控制)。例如,尝试从代码目录之外的某个地方运行您的脚本:
$ cd ..
$ python ch05/game_lobby.py
您会发现您的图像都丢失了!当QPixmap找不到文件时不会抱怨,它只是不显示任何东西。如果没有图像的绝对路径,您只能在脚本从相对路径相关的确切目录运行时找到它们。
不幸的是,指定绝对路径意味着您的程序只能从文件系统上的一个位置工作,这对于您计划将其分发到多个平台是一个重大问题。
PyQt 为我们提供了一个解决这个问题的解决方案,即PyQt 资源文件,我们可以使用PyQt 资源编译器工具创建。基本过程如下:
-
编写一个 XML 格式的Qt 资源集合文件(.qrc),其中包含我们要包括的所有文件的路径
-
运行
pyrcc5工具将这些文件序列化并压缩到包含在 Python 模块中的数据中 -
将生成的 Python 模块导入我们的应用程序脚本
-
现在我们可以使用特殊的语法引用我们的资源
让我们逐步走过这个过程——假设我们有一些队徽,以 PNG 文件的形式,我们想要包含在我们的程序中。我们的第一步是创建resources.qrc文件,它看起来像下面的代码块:
<RCC>
<qresource prefix="teams">
<file>crimson_sharks.png</file>
<file>shadow_hawks.png</file>
<file>night_terrors.png</file>
<file alias="blue_crew.png">blue_crew2.png</file>
</qresource>
</RCC>
我们已经将这个文件放在与脚本中列出的图像文件相同的目录中。请注意,我们添加了一个prefix值为teams。前缀允许您将资源组织成类别。另外,请注意,最后一个文件有一个指定的别名。在我们的程序中,我们可以使用这个别名而不是文件的实际名称来访问这个资源。
现在,在命令行中,我们将运行pyrcc5,如下所示:
$ pyrcc5 -o resources.py resources.qrc
这里的语法是pyrcc5 -o outputFile.py inputFile.qrc。这个命令应该生成一个包含您的资源数据的 Python 文件。如果您花一点时间打开文件并检查它,您会发现它主要只是一个分配给qt_resource_data变量的大型bytes对象。
回到我们的主要脚本中,我们只需要像导入任何其他 Python 文件一样导入这个文件:
import resources
文件不一定要叫做resources.py;实际上,任何名称都可以。你只需要导入它,文件中的代码将确保资源对 Qt 可用。
现在资源文件已导入,我们可以使用资源语法指定像素图路径:
inputs['Team'].setItemIcon(
0, qtg.QIcon(':/teams/crimson_sharks.png'))
inputs['Team'].setItemIcon(
1, qtg.QIcon(':/teams/shadow_hawks.png'))
inputs['Team'].setItemIcon(
2, qtg.QIcon(':/teams/night_terrors.png'))
inputs['Team'].setItemIcon(
3, qtg.QIcon(':/teams/blue_crew.png'))
基本上,语法是:/prefix/file_name_or_alias.extension。
因为我们的数据存储在一个 Python 文件中,我们可以将它放在一个 Python 库中,它将使用 Python 的标准导入解析规则来定位文件。
Qt 资源文件和字体
资源文件不仅限于图像;实际上,它们可以用于包含几乎任何类型的二进制文件,包括字体文件。例如,假设我们想要在程序中包含我们喜欢的字体,以确保它在所有平台上看起来正确。
与图像一样,我们首先在.qrc文件中包含字体文件:
<RCC>
<qresource prefix="teams">
<file>crimson_sharks.png</file>
<file>shadow_hawks.png</file>
<file>night_terrors.png</file>
<file>blue_crew.png</file>
</qresource>
<qresource prefix="fonts">
<file>LiberationSans-Regular.ttf</file>
</qresource>
</RCC>
在这里,我们添加了一个前缀fonts并包含了对LiberationSans-Regular.ttf文件的引用。运行pyrcc5对这个文件进行处理后,字体被捆绑到我们的resources.py文件中。
要在代码中使用这个字体,我们首先要将它添加到字体数据库中,如下所示:
libsans_id = qtg.QFontDatabase.addApplicationFont(
':/fonts/LiberationSans-Regular.ttf')
QFontDatabase.addApplicationFont()将传递的字体文件插入应用程序的字体数据库并返回一个 ID 号。然后我们可以使用该 ID 号来确定字体的系列字符串;这可以传递给QFont,如下所示:
family = qtg.QFontDatabase.applicationFontFamilies(libsans_id)[0]
libsans = qtg.QFont(family)
inputs['Team'].setFont(libsans)
在分发应用程序之前,请确保检查字体的许可证!请记住,并非所有字体都可以自由分发。
我们的表单现在看起来更像游戏了;运行应用程序,它应该看起来类似于以下截图:

配置颜色、样式表和样式
字体和图标改善了我们表单的外观,但现在是时候摆脱那些机构灰色调,用一些颜色来替换它们。在本节中,我们将看一下 Qt 为自定义应用程序颜色提供的三种不同方法:操纵调色板、使用样式表和覆盖应用程序样式。
使用调色板自定义颜色
由QPalette类表示的调色板是一组映射到颜色角色和颜色组的颜色和画笔的集合。
让我们解开这个声明:
-
在这里,color是一个文字颜色值,由
QColor对象表示 -
画笔将特定颜色与样式(如图案、渐变或纹理)结合在一起,由
QBrush类表示 -
颜色角色表示小部件使用颜色的方式,例如前景、背景或边框
-
颜色组指的是小部件的交互状态;它可以是
Normal、Active、Disabled或Inactive
当小部件在屏幕上绘制时,Qt 的绘图系统会查阅调色板,以确定用于渲染小部件的每个部分的颜色和画笔。要自定义这一点,我们可以创建自己的调色板并将其分配给一个小部件。
首先,我们需要获取一个QPalette对象,如下所示:
app = qtw.QApplication.instance()
palette = app.palette()
虽然我们可以直接创建一个QPalette对象,但 Qt 文档建议我们在运行的QApplication实例上调用palette()来检索当前配置样式的调色板的副本。
您可以通过调用QApplication.instance()来随时检索QApplication对象的副本。
现在我们有了调色板,让我们开始覆盖一些规则:
palette.setColor(
qtg.QPalette.Button,
qtg.QColor('#333')
)
palette.setColor(
qtg.QPalette.ButtonText,
qtg.QColor('#3F3')
)
QtGui.QPalette.Button和QtGui.QPalette.ButtonText是颜色角色常量,正如你可能猜到的那样,它们分别代表所有 Qt 按钮类的背景和前景颜色。我们正在用新颜色覆盖它们。
要覆盖特定按钮状态的颜色,我们需要将颜色组常量作为第一个参数传递:
palette.setColor(
qtg.QPalette.Disabled,
qtg.QPalette.ButtonText,
qtg.QColor('#F88')
)
palette.setColor(
qtg.QPalette.Disabled,
qtg.QPalette.Button,
qtg.QColor('#888')
)
在这种情况下,我们正在更改按钮处于Disabled状态时使用的颜色。
要应用这个新的调色板,我们必须将它分配给一个小部件,如下所示:
self.submit.setPalette(palette)
self.cancel.setPalette(palette)
setPalette()将提供的调色板分配给小部件和所有子小部件。因此,我们可以创建一个单独的调色板,并将其分配给我们的QMainWindow类,以将其应用于所有对象,而不是分配给单个小部件。
使用 QBrush 对象
如果我们想要比纯色更花哨的东西,那么我们可以使用QBrush对象。画笔可以填充颜色、图案、渐变或纹理(即基于图像的图案)。
例如,让我们创建一个绘制白色点划填充的画笔:
dotted_brush = qtg.QBrush(
qtg.QColor('white'), qtc.Qt.Dense2Pattern)
Dense2Pattern是 15 种可用图案之一。(你可以参考doc.qt.io/qt-5/qt.html#BrushStyle-enum获取完整列表。)其中大多数是不同程度的点划、交叉点划或交替线条图案。
图案有它们的用途,但基于渐变的画笔可能更适合现代风格。然而,创建一个可能会更复杂,如下面的代码所示:
gradient = qtg.QLinearGradient(0, 0, self.width(), self.height())
gradient.setColorAt(0, qtg.QColor('navy'))
gradient.setColorAt(0.5, qtg.QColor('darkred'))
gradient.setColorAt(1, qtg.QColor('orange'))
gradient_brush = qtg.QBrush(gradient)
要在画笔中使用渐变,我们首先必须创建一个渐变对象。在这里,我们创建了一个QLinearGradient对象,它实现了基本的线性渐变。参数是渐变的起始和结束坐标,我们指定为主窗口的左上角(0, 0)和右下角(宽度,高度)。
Qt 还提供了QRadialGradient和QConicalGradient类,用于提供额外的渐变选项。
创建对象后,我们使用setColorAt()指定颜色停止。第一个参数是 0 到 1 之间的浮点值,指定起始和结束之间的百分比,第二个参数是渐变应该在该点的QColor对象。
创建渐变后,我们将其传递给QBrush构造函数,以创建一个使用我们的渐变进行绘制的画笔。
我们现在可以使用setBrush()方法将我们的画笔应用于调色板,如下所示:
window_palette = app.palette()
window_palette.setBrush(
qtg.QPalette.Window,
gradient_brush
)
window_palette.setBrush(
qtg.QPalette.Active,
qtg.QPalette.WindowText,
dotted_brush
)
self.setPalette(window_palette)
就像QPalette.setColor()一样,我们可以分配我们的画笔,无论是否指定了特定的颜色组。在这种情况下,我们的渐变画笔将用于绘制主窗口,而我们的点画画笔只有在小部件处于活动状态时才会使用(即当前活动窗口)。
使用 Qt 样式表(QSS)自定义外观
对于已经使用过 Web 技术的开发人员来说,使用调色板、画笔和颜色对象来设计应用程序可能会显得啰嗦和不直观。幸运的是,Qt 为您提供了一种称为 QSS 的替代方案,它与 Web 开发中使用的层叠样式表(CSS)非常相似。这是一种简单的方法,可以对我们的小部件进行一些简单的更改。
您可以按照以下方式使用 QSS:
stylesheet = """
QMainWindow {
background-color: black;
}
QWidget {
background-color: transparent;
color: #3F3;
}
QLineEdit, QComboBox, QCheckBox {
font-size: 16pt;
}"""
self.setStyleSheet(stylesheet)
在这里,样式表只是一个包含样式指令的字符串,我们可以将其分配给小部件的styleSheet属性。
这个语法对于任何使用过 CSS 的人来说应该很熟悉,如下所示:
WidgetClass {
property-name: value;
property-name2: value2;
}
如果此时运行程序,你会发现(取决于你的系统主题),它可能看起来像以下的截图:

在这里,界面大部分变成了黑色,除了文本和图像。特别是我们的按钮和复选框与背景几乎无法区分。那么,为什么会发生这种情况呢?
当您向小部件类添加 QSS 样式时,样式更改会传递到所有其子类。由于我们对QWidget进行了样式设置,所有其他QWidget派生类(如QCheckbox和QPushButton)都继承了这种样式。
让我们通过覆盖这些子类的样式来修复这个问题,如下所示:
stylesheet += """
QPushButton {
background-color: #333;
}
QCheckBox::indicator:unchecked {
border: 1px solid silver;
background-color: darkred;
}
QCheckBox::indicator:checked {
border: 1px solid silver;
background-color: #3F3;
}
"""
self.setStyleSheet(stylesheet)
就像 CSS 一样,将样式应用于更具体的类会覆盖更一般的情况。例如,我们的QPushButton背景颜色会覆盖QWidget背景颜色。
请注意在QCheckBox中使用冒号 - QSS 中的双冒号允许我们引用小部件的子元素。在这种情况下,这是QCheckBox类的指示器部分(而不是其标签部分)。我们还可以使用单个冒号来引用小部件状态,就像在这种情况下,我们根据复选框是否选中或未选中来设置不同的样式。
如果您只想将更改限制为特定类,而不是其任何子类,只需在名称后添加一个句点(。),如下所示:
stylesheet += """
.QWidget {
background: url(tile.png);
}
"""
前面的示例还演示了如何在 QSS 中使用图像。就像在 CSS 中一样,我们可以提供一个包装在url()函数中的文件路径。
如果您已经使用pyrcc5序列化了图像,QSS 还接受资源路径。
如果要将样式应用于特定小部件而不是整个小部件类,有两种方法可以实现。
第一种方法是依赖于objectName属性,如下所示:
self.submit.setObjectName('SubmitButton')
stylesheet += """
#SubmitButton:disabled {
background-color: #888;
color: darkred;
}
"""
在我们的样式表中,对象名称前必须加上一个
#符号用于将其标识为对象名称,而不是类。
在单个小部件上设置样式的另一种方法是调用 t
使用小部件的setStyleSheet()方法和一些样式表指令,如下所示:
for inp in ('Server', 'Name', 'Password'):
inp_widget = inputs[inp]
inp_widget.setStyleSheet('background-color: black')
如果我们要直接将样式应用于我们正在调用的小部件,我们不需要指定类名或对象名;我们可以简单地传递属性和值。
经过所有这些更改,我们的应用程序现在看起来更像是一个游戏 GUI:

QSS 的缺点
正如您所看到的,QSS 是一种非常强大的样式方法,对于任何曾经从事 Web 开发的开发人员来说都是可访问的;但是,它确实有一些缺点。
QSS 是对调色板和样式对象的抽象,必须转换为实际系统。这使它们在大型应用程序中变得更慢,这也意味着没有默认样式表可以检索和编辑 - 每次都是从头开始。
正如我们已经看到的,当应用于高级小部件时,QSS 可能会产生不可预测的结果,因为它通过类层次结构继承。
最后,请记住,QSS 是 CSS 2.0 的一个较小子集,带有一些添加或更改 - 它不是 CSS。因此,过渡、动画、flexbox 容器、相对单位和其他现代 CSS 好东西完全不存在。因此,尽管 Web 开发人员可能会发现其基本语法很熟悉,但有限的选项集可能会令人沮丧,其不同的行为也会令人困惑。
使用 QStyle 自定义外观
调色板和样式表可以帮助我们大大定制 Qt 应用程序的外观,对于大多数情况来说,这就是您所需要的。要真正深入了解 Qt 应用程序外观的核心,我们需要了解样式系统。
每个运行的 Qt 应用程序实例都有一个样式,负责告诉图形系统如何绘制每个小部件或 GUI 组件。样式是动态和可插拔的,因此不同的 OS 平台具有不同的样式,用户可以安装自己的 Qt 样式以在 Qt 应用程序中使用。这就是 Qt 应用程序能够在不同的操作系统上具有本机外观的原因。
在第一章中,使用 PyQt 入门,我们学到QApplication在创建时应传递sys.argv的副本,以便它可以处理一些特定于 Qt 的参数。其中一个参数是-style,它允许用户为其 Qt 应用程序设置自定义样式。
例如,让我们使用Windows样式运行第三章中的日历应用程序,使用信号和槽处理事件:
$ python3 calendar_app.py -style Windows
现在尝试使用Fusion样式,如下所示:
$ python3 calendar_app.py -style Fusion
请注意外观上的差异,特别是输入控件。
样式中的大小写很重要;windows不是有效的样式,而Windows是!
常见 OS 平台上可用的样式如下表所示:
| OS | 样式 |
|---|---|
| Windows 10 | windowsvista,Windows和Fusion |
| macOS | macintosh,Windows和Fusion |
| Ubuntu 18.04 | Windows和Fusion |
在许多 Linux 发行版中,可以从软件包存储库中获取其他 Qt 样式。可以通过调用QtWidgets.QStyleFactory.keys()来获取当前安装的样式列表。
样式也可以在应用程序内部设置。为了检索样式类,我们需要使用QStyleFactory类,如下所示:
if __name__ == '__main__':
app = qtw.QApplication(sys.argv)
windows_style = qtw.QStyleFactory.create('Windows')
app.setStyle(windows_style)
QStyleFactory.create()将尝试查找具有给定名称的已安装样式,并返回一个QCommonStyle对象;如果未找到请求的样式,则它将返回None。然后可以使用样式对象来设置我们的QApplication对象的style属性。(None的值将导致其使用默认值。)
如果您计划在应用程序中设置样式,最好在绘制任何小部件之前尽早进行,以避免视觉故障。
自定义 Qt 样式
构建 Qt 样式是一个复杂的过程,需要深入了解 Qt 的小部件和绘图系统,很少有开发人员需要创建一个。但是,我们可能希望覆盖运行样式的某些方面,以完成一些无法通过调色板或样式表的操作来实现的事情。我们可以通过对QtWidgets.QProxyStyle进行子类化来实现这一点。
代理样式是我们可以使用来覆盖实际运行样式的方法的覆盖层。这样,用户选择的实际样式是什么并不重要,我们的代理样式的方法(在实现时)将被使用。
例如,让我们创建一个代理样式,强制所有屏幕文本都是大写的,如下所示:
class StyleOverrides(qtw.QProxyStyle):
def drawItemText(
self, painter, rect,
flags, palette, enabled,
text, textRole
):
"""Force uppercase in all text"""
text = text.upper()
super().drawItemText(
painter, rect, flags,
palette, enabled, text,
textRole
)
drawItemText()是在必须将文本绘制到屏幕时在样式上调用的方法。它接收许多参数,但我们最关心的是要绘制的text参数。我们只是要拦截此文本,并在将所有参数传回super().drawTextItem()之前将其转换为大写。
然后可以将此代理样式应用于我们的QApplication对象,方式与任何其他样式相同:
if __name__ == '__main__':
app = qtw.QApplication(sys.argv)
proxy_style= StyleOverrides()
app.setStyle(proxy_style)
如果此时运行程序,您会看到所有文本现在都是大写。任务完成!
绘制小部件
现在让我们尝试一些更有野心的事情。让我们将所有的QLineEdit输入框更改为绿色的圆角矩形轮廓。那么,我们如何在代理样式中做到这一点呢?
第一步是弄清楚我们要修改的小部件的元素是什么。这些可以在QStyle类的枚举常量中找到,它们分为三个主要类别:
-
PrimitiveElement,其中包括基本的非交互式 GUI 元素,如框架或背景 -
ControlElement,其中包括按钮或选项卡等交互元素 -
ComplexControl,其中包括复杂的交互元素,如组合框和滑块
这些类别中的每个项目都由QStyle的不同方法绘制;在这种情况下,我们想要修改的是PE_FrameLineEdit元素,这是一个原始元素(由PE_前缀表示)。这种类型的元素由QStyle.drawPrimitive()绘制,因此我们需要在代理样式中覆盖该方法。
将此方法添加到StyleOverrides中,如下所示:
def drawPrimitive(
self, element, option, painter, widget
):
"""Outline QLineEdits in Green"""
要控制元素的绘制,我们需要向其painter对象发出命令,如下所示:
self.green_pen = qtg.QPen(qtg.QColor('green'))
self.green_pen.setWidth(4)
if element == qtw.QStyle.PE_FrameLineEdit:
painter.setPen(self.green_pen)
painter.drawRoundedRect(widget.rect(), 10, 10)
else:
super().drawPrimitive(element, option, painter, widget)
绘图对象和绘图将在第十二章中完全介绍,使用 QPainter 创建 2D 图形,但是,现在要理解的是,如果element参数匹配QStyle.PE_FrameLineEdit,则前面的代码将绘制一个绿色的圆角矩形。否则,它将将参数传递给超类的drawPrimitive()方法。
请注意,在绘制矩形后,我们不调用超类方法。如果我们这样做了,那么超类将在我们的绿色矩形上方绘制其样式定义的小部件元素。
正如你在这个例子中看到的,使用QProxyStyle比使用调色板或样式表要复杂得多,但它确实让我们几乎无限地控制我们的小部件的外观。
无论你使用 QSS 还是样式和调色板来重新设计应用程序都没有关系;然而,强烈建议你坚持使用其中一种。否则,你的样式修改可能会相互冲突,并在不同平台和桌面设置上产生不可预测的结果。
创建动画
没有什么比动画的巧妙使用更能为 GUI 增添精致的边缘。在颜色、大小或位置的变化之间平滑地淡入淡出的动态 GUI 元素可以为任何界面增添现代感。
Qt 的动画框架允许我们使用QPropertyAnimation类在我们的小部件上创建简单的动画。在本节中,我们将探讨如何使用这个类来为我们的游戏大厅增添一些动画效果。
因为 Qt 样式表会覆盖另一个基于小部件和调色板的样式,所以你需要注释掉所有这些动画的样式表代码才能正常工作。
基本属性动画
QPropertyAnimation对象用于动画小部件的单个 Qt 属性。该类会自动在两个数值属性值之间创建插值步骤序列,并在一段时间内应用这些变化。
例如,让我们动画我们的标志,让它从左向右滚动。你可以通过添加一个属性动画对象来开始,如下所示:
self.heading_animation = qtc.QPropertyAnimation(
heading, b'maximumSize')
QPropertyAnimation需要两个参数:一个要被动画化的小部件(或其他类型的QObject类),以及一个指示要被动画化的属性的bytes对象(请注意,这是一个bytes对象,而不是一个字符串)。
接下来,我们需要配置我们的动画对象如下:
self.heading_animation.setStartValue(qtc.QSize(10, logo.height()))
self.heading_animation.setEndValue(qtc.QSize(400, logo.height()))
self.heading_animation.setDuration(2000)
至少,我们需要为属性设置一个startValue值和一个endValue值。当然,这些值必须是属性所需的数据类型。我们还可以设置毫秒为单位的duration(默认值为 250)。
配置好后,我们只需要告诉动画开始,如下所示:
self.heading_animation.start()
有一些要求限制了QPropertyAnimation对象的功能:
-
要动画的对象必须是
QObject的子类。这包括所有小部件,但不包括一些 Qt 类,如QPalette。 -
要动画的属性必须是 Qt 属性(不仅仅是 Python 成员变量)。
-
属性必须具有读写访问器方法,只需要一个值。例如,
QWidget.size可以被动画化,但QWidget.width不能,因为没有setWidth()方法。 -
属性值必顺为以下类型之一:
int、float、QLine、QLineF、QPoint、QPointF、QSize、QSizeF、QRect、QRectF或QColor。
不幸的是,对于大多数小部件,这些限制排除了我们可能想要动画的许多方面,特别是颜色。幸运的是,我们可以解决这个问题。
动画颜色
正如你在本章前面学到的,小部件颜色不是小部件的属性,而是调色板的属性。调色板不能被动画化,因为QPalette不是QObject的子类,而且setColor()需要的不仅仅是一个单一的值。
颜色是我们想要动画的东西,为了实现这一点,我们需要对小部件进行子类化,并将其颜色设置为 Qt 属性。
让我们用一个按钮来做到这一点;在脚本的顶部开始一个新的类,如下所示:
class ColorButton(qtw.QPushButton):
def _color(self):
return self.palette().color(qtg.QPalette.ButtonText)
def _setColor(self, qcolor):
palette = self.palette()
palette.setColor(qtg.QPalette.ButtonText, qcolor)
self.setPalette(palette)
在这里,我们有一个QPushButton子类,其中包含用于调色板ButtonText颜色的访问器方法。但是,请注意这些是 Python 方法;为了对此属性进行动画处理,我们需要color成为一个实际的 Qt 属性。为了纠正这一点,我们将使用QtCore.pyqtProperty()函数来包装我们的访问器方法,并在底层 Qt 对象上创建一个属性。
您可以按照以下方式操作:
color = qtc.pyqtProperty(qtg.QColor, _color, _setColor)
我们使用的属性名称将是 Qt 属性的名称。传递的第一个参数是属性所需的数据类型,接下来的两个参数是 getter 和 setter 方法。
pyqtProperty()也可以用作装饰器,如下所示:
@qtc.pyqtProperty(qtg.QColor)
def backgroundColor(self):
return self.palette().color(qtg.QPalette.Button)
@backgroundColor.setter
def backgroundColor(self, qcolor):
palette = self.palette()
palette.setColor(qtg.QPalette.Button, qcolor)
self.setPalette(palette)
请注意,在这种方法中,两个方法必须使用我们打算创建的属性名称相同的名称。
现在我们的属性已经就位,我们需要用ColorButton对象替换我们的常规QPushButton对象:
# Replace these definitions
# at the top of the MainWindow constructor
self.submit = ColorButton(
'Connect',
clicked=lambda: qtw.QMessageBox.information(
None,
'Connecting',
'Prepare for Battle!'))
self.cancel = ColorButton(
'Cancel',
clicked=self.close)
经过这些更改,我们可以如下地对颜色值进行动画处理:
self.text_color_animation = qtc.QPropertyAnimation(
self.submit, b'color')
self.text_color_animation.setStartValue(qtg.QColor('#FFF'))
self.text_color_animation.setEndValue(qtg.QColor('#888'))
self.text_color_animation.setLoopCount(-1)
self.text_color_animation.setEasingCurve(
qtc.QEasingCurve.InOutQuad)
self.text_color_animation.setDuration(2000)
self.text_color_animation.start()
这个方法非常有效。我们还在这里添加了一些额外的配置设置:
-
setLoopCount()将设置动画重新启动的次数。值为-1将使其永远循环。 -
setEasingCurve()改变了值插值的曲线。我们选择了InOutQuad,它减缓了动画开始和结束的速率。
现在,当您运行脚本时,请注意颜色从白色渐变到灰色,然后立即循环回白色。如果我们希望动画从一个值移动到另一个值,然后再平稳地返回,我们可以使用setKeyValue()方法在动画的中间放置一个值:
self.bg_color_animation = qtc.QPropertyAnimation(
self.submit, b'backgroundColor')
self.bg_color_animation.setStartValue(qtg.QColor('#000'))
self.bg_color_animation.setKeyValueAt(0.5, qtg.QColor('darkred'))
self.bg_color_animation.setEndValue(qtg.QColor('#000'))
self.bg_color_animation.setLoopCount(-1)
self.bg_color_animation.setDuration(1500)
在这种情况下,我们的起始值和结束值是相同的,并且我们在动画的中间添加了一个值为 0.5(动画进行到一半时)设置为第二个颜色。这个动画将从黑色渐变到深红色,然后再返回。您可以添加任意多个关键值并创建相当复杂的动画。
使用动画组
随着我们向 GUI 添加越来越多的动画,我们可能会发现有必要将它们组合在一起,以便我们可以将动画作为一个组来控制。这可以使用动画组类QParallelAnimationGroup和QSequentialAnimationGroup来实现。
这两个类都允许我们向组中添加多个动画,并作为一个组开始、停止、暂停和恢复动画。
例如,让我们将按钮动画分组如下:
self.button_animations = qtc.QParallelAnimationGroup()
self.button_animations.addAnimation(self.text_color_animation)
self.button_animations.addAnimation(self.bg_color_animation)
QParallelAnimationGroup在调用其start()方法时会同时播放所有动画。相反,QSequentialAnimationGroup将按添加的顺序依次播放其动画,如下面的代码块所示:
self.all_animations = qtc.QSequentialAnimationGroup()
self.all_animations.addAnimation(self.heading_animation)
self.all_animations.addAnimation(self.button_animations)
self.all_animations.start()
通过像我们在这里所做的那样将动画组添加到其他动画组中,我们可以将复杂的动画安排成一个对象,可以一起启动、停止、暂停和恢复。
注释掉所有其他动画的start()调用并启动脚本。请注意,按钮动画仅在标题动画完成后开始。
我们将在第十二章 使用 QPainter 进行 2D 图形中探索更多QPropertyAnimation的用法。
总结
在本章中,我们学习了如何自定义 PyQt 应用程序的外观和感觉。我们还学习了如何操纵屏幕字体并添加图像。此外,我们还学习了如何以对路径更改具有弹性的方式打包图像和字体资源。我们还探讨了如何使用调色板和样式表改变应用程序的颜色和外观,以及如何覆盖样式方法来实现几乎无限的样式更改。最后,我们探索了使用 Qt 的动画框架进行小部件动画,并学习了如何向我们的类添加自定义 Qt 属性,以便我们可以对其进行动画处理。
在下一章中,我们将使用QtMultimedia库探索多媒体应用程序的世界。您将学习如何使用摄像头拍照和录制视频,如何显示视频内容,以及如何录制和播放音频。
问题
尝试这些问题来测试您从本章学到的知识:
-
您正在准备分发您的文本编辑器应用程序,并希望确保用户无论使用什么平台,都会默认获得等宽字体。您可以使用哪两种方法来实现这一点?
-
尽可能地,尝试使用
QFont模仿以下文本:

-
您能解释一下
QImage,QPixmap和QIcon之间的区别吗? -
您已为应用程序定义了以下
.qrc文件,运行了pyrcc5,并在脚本中导入了资源库。您会如何将此图像加载到QPixmap中?
<RCC>
<qresource prefix="foodItems">
<file alias="pancakes.png">pc_img.45234.png</file>
</qresource>
</RCC>
-
使用
QPalette,如何使用tile.png图像在QWidget对象的背景上铺砌? -
您试图使用 QSS 使删除按钮变成粉色,但没有成功。您的代码有什么问题?
deleteButton = qtw.QPushButton('Delete')
form.layout().addWidget(deleteButton)
form.setStyleSheet(
form.styleSheet() + 'deleteButton{ background-color: #8F8; }'
)
- 哪个样式表字符串将把您的
QLineEdit小部件的背景颜色变成黑色?
stylesheet1 = "QWidget {background-color: black;}"
stylesheet2 = ".QWidget {background-color: black;}"
-
构建一个简单的应用程序,其中包含一个下拉框,允许您将 Qt 样式更改为系统上安装的任何样式。包括一些其他小部件,以便您可以看到它们在不同样式下的外观。
-
您对学习如何为 PyQt 应用程序设置样式感到非常高兴,并希望创建一个
QProxyStyle类,该类将强制 GUI 中的所有像素图像为smile.gif。您会如何做?提示:您需要研究QStyle的一些其他绘图方法,而不是本章讨论的方法。 -
以下动画不起作用;找出它为什么不起作用:
class MyWidget(qtw.QWidget):
def __init__(self):
super().__init__()
animation = qtc.QPropertyAnimation(
self, b'windowOpacity')
animation.setStartValue(0)
animation.setEndValue(1)
animation.setDuration(10000)
animation.start()
进一步阅读
有关更多信息,请参考以下内容:
-
有关字体如何解析的更详细描述可以在
doc.qt.io/qt-5/qfont.html#details的QFont文档中找到 -
这个 C++中的 Qt 样式示例(
doc.qt.io/qt-5/qtwidgets-widgets-styles-example.html)演示了如何创建一个全面的 Qt 代理样式 -
Qt 的动画框架概述在
doc.qt.io/qt-5/animation-overview.html提供了如何使用属性动画以及它们的限制的额外细节
第二部分:使用外部资源
现在您已经了解了构建 PyQt GUI 的基础知识,是时候进入外部世界了。在本节中,您将学习如何将您的 PyQt 应用程序连接到外部资源,如网络和数据库。
本节包括以下章节:
-
第七章,使用 QtMultimedia 处理音频和视频
-
第八章,使用 QtNetwork 进行网络操作
-
第九章,使用 QtSQL 探索 SQL
第七章:使用 QtMultimedia 处理音频-视频
无论是在游戏、通信还是媒体制作应用中,音频和视频内容通常是现代应用的重要组成部分。当使用本机 API 时,即使是最简单的音频-视频(AV)应用程序在支持多个平台时也可能非常复杂。然而,幸运的是,Qt 为我们提供了一个简单的跨平台多媒体 API,即QtMultimedia。使用QtMultimedia,我们可以轻松地处理音频内容、视频内容或摄像头和收音机等设备。
在这一章中,我们将使用QtMultimedia来探讨以下主题:
-
简单的音频播放
-
录制和播放音频
-
录制和播放视频
技术要求
除了第一章中描述的基本 PyQt 设置外,您还需要确保已安装QtMultimedia和PyQt.QtMultimedia库。如果您使用pip安装了 PyQt5,则应该已经安装了。使用发行版软件包管理器的 Linux 用户应检查这些软件包是否已安装。
您可能还想从我们的 GitHub 存储库github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter07下载代码,其中包含示例代码和用于这些示例的音频数据。
如果您想创建自己的音频文件进行处理,您可能需要安装免费的 Audacity 音频编辑器,网址为www.audacityteam.org/。
最后,如果您的计算机没有工作的音频系统、麦克风和网络摄像头,您将无法充分利用本章。如果没有,那么其中一些示例将无法为您工作。
查看以下视频以查看代码的实际操作:bit.ly/2Mjr8vx
简单的音频播放
很多时候,应用程序需要对 GUI 事件做出声音回应,就像在游戏中一样,或者只是为用户操作提供音频反馈。对于这种应用程序,QtMultimedia提供了QSoundEffect类。QSoundEffect仅限于播放未压缩音频,因此它可以使用脉冲编码调制(PCM)、波形数据(WAV)文件,但不能使用 MP3 或 OGG 文件。这样做的好处是它的延迟低,资源利用率非常高,因此虽然它不适用于通用音频播放器,但非常适合快速播放音效。
为了演示QSoundEffect,让我们构建一个电话拨号器。将第四章中的应用程序模板使用 QMainWindow 构建应用程序复制到一个名为phone_dialer.py的新文件中,并在编辑器中打开它。
让我们首先导入QtMultimedia库,如下所示:
from PyQt5 import QtMultimedia as qtmm
导入QtMultimedia将是本章所有示例的必要第一步,我们将一贯使用qtmm作为其别名。
我们还将导入一个包含必要的 WAV 数据的resources库:
import resources
这个resources文件包含一系列双音多频(DTMF)音调。这些是电话拨号时电话生成的音调,我们包括了0到9、*和#。我们已经在示例代码中包含了这个文件;或者,您可以从自己的音频样本创建自己的resources文件(您可以参考第六章中关于如何做到这一点的信息)。
您可以使用免费的 Audacity 音频编辑器生成 DTMF 音调。要这样做,请从 Audacity 的主菜单中选择生成|DTMF。
一旦完成这些,我们将创建一个QPushButton子类,当单击时会播放声音效果,如下所示:
class SoundButton(qtw.QPushButton):
def __init__(self, wav_file, *args, **kwargs):
super().__init__(*args, **kwargs)
self.wav_file = wav_file
self.player = qtmm.QSoundEffect()
self.player.setSource(qtc.QUrl.fromLocalFile(wav_file))
self.clicked.connect(self.player.play)
如您所见,我们修改了构造函数以接受声音文件路径作为参数。这个值被转换为QUrl并通过setSource()方法传递到我们的QSoundEffect对象中。最后,QSoundEffect.play()方法触发声音的播放,因此我们将其连接到按钮的clicked信号。这就是创建我们的SoundButton对象所需的全部内容。
回到MainWindow.__init__()方法,让我们创建一些SoundButton对象并将它们排列在 GUI 中:
dialpad = qtw.QWidget()
self.setCentralWidget(dialpad)
dialpad.setLayout(qtw.QGridLayout())
for i, symbol in enumerate('123456789*0#'):
button = SoundButton(f':/dtmf/{symbol}.wav', symbol)
row = i // 3
column = i % 3
dialpad.layout().addWidget(button, row, column)
我们已经设置了资源文件,以便可以通过dtmf前缀下的符号访问每个 DTMF 音调;例如,':/dtmf/1.wav'指的是 1 的 DTMF 音调。通过这种方式,我们可以遍历一串符号并为每个创建一个SoundButton对象,然后将其添加到三列网格中。
就是这样;运行这个程序并按下按钮。它应该听起来就像拨打电话!
录制和播放音频
QSoundEffect足以处理简单的事件声音,但对于更高级的音频项目,我们需要具备更多功能的东西。理想情况下,我们希望能够加载更多格式,控制播放的各个方面,并录制新的声音。
在这一部分,我们将专注于提供这些功能的两个类:
-
QMediaPlayer类,它类似于一个虚拟媒体播放器设备,可以加载音频或视频内容 -
QAudioRecorder类,用于管理将音频数据录制到磁盘
为了看到这些类的实际效果,我们将构建一个采样音效板。
初始设置
首先,制作一个新的应用程序模板副本,并将其命名为soundboard.py。然后,像上一个项目一样导入QtMultimedia,并布局主界面。
在MainWindow构造函数中,添加以下代码:
rows = 3
columns = 3
soundboard = qtw.QWidget()
soundboard.setLayout(qtw.QGridLayout())
self.setCentralWidget(soundboard)
for c in range(columns):
for r in range(rows):
sw = SoundWidget()
soundboard.layout().addWidget(sw, c, r)
我们在这里所做的只是创建一个空的中央小部件,添加一个网格布局,然后用3行3列的SoundWidget对象填充它。
实现声音播放
我们的SoundWidget类将是一个管理单个声音样本的QWidget对象。完成后,它将允许我们加载或录制音频样本,循环播放或单次播放,并控制其音量和播放位置。
在MainWindow构造函数之前,让我们创建这个类并给它一个布局:
class SoundWidget(qtw.QWidget):
def __init__(self):
super().__init__()
self.setLayout(qtw.QGridLayout())
self.label = qtw.QLabel("No file loaded")
self.layout().addWidget(self.label, 0, 0, 1, 2)
我们添加的第一件事是一个标签,它将显示小部件加载的样本文件的名称。我们需要的下一件事是一个控制播放的按钮。我们不只是一个普通的按钮,让我们运用一些我们的样式技巧来创建一个可以在播放按钮和停止按钮之间切换的自定义按钮。
在SoundWidget类的上方开始一个PlayButton类,如下所示:
class PlayButton(qtw.QPushButton):
play_stylesheet = 'background-color: lightgreen; color: black;'
stop_stylesheet = 'background-color: darkred; color: white;'
def __init__(self):
super().__init__('Play')
self.setFont(qtg.QFont('Sans', 32, qtg.QFont.Bold))
self.setSizePolicy(
qtw.QSizePolicy.Expanding,
qtw.QSizePolicy.Expanding
)
self.setStyleSheet(self.play_stylesheet)
回到SoundWidget类,我们将添加一个PlayButton对象,如下所示:
self.play_button = PlayButton()
self.layout().addWidget(self.play_button, 3, 0, 1, 2)
现在我们有了一个控制按钮,我们需要创建将播放采样的QMediaPlayer对象,如下所示:
self.player = qtmm.QMediaPlayer()
您可以将QMediaPlayer视为硬件媒体播放器(如 CD 或蓝光播放器)的软件等效物。就像硬件媒体播放器有播放、暂停和停止按钮一样,QMediaPlayer对象有play()、stop()和pause()槽来控制媒体的播放。
让我们将我们的双功能PlayButton对象连接到播放器。我们将通过一个名为on_playbutton()的实例方法来实现这一点:
self.play_button.clicked.connect(self.on_playbutton)
SoundWidget.on_playbutton()将如何看起来:
def on_playbutton(self):
if self.player.state() == qtmm.QMediaPlayer.PlayingState:
self.player.stop()
else:
self.player.play()
这种方法检查了播放器对象的state属性,该属性返回一个常量,指示播放器当前是正在播放、已暂停还是已停止。如果播放器当前正在播放,我们就停止它;如果没有,我们就要求它播放。
由于我们的按钮在播放和停止按钮之间切换,让我们更新它的标签和外观。QMediaPlayer在其状态改变时发出stateChanged信号,我们可以将其发送到我们的PlayButton对象,如下所示:
self.player.stateChanged.connect(self.play_button.on_state_changed)
回到PlayButton类,让我们处理该信号,如下所示:
def on_state_changed(self, state):
if state == qtmm.QMediaPlayer.PlayingState:
self.setStyleSheet(self.stop_stylesheet)
self.setText('Stop')
else:
self.setStyleSheet(self.play_stylesheet)
self.setText('Play')
在这里,stateChanged传递了媒体播放器的新状态,我们用它来设置按钮的播放或停止外观。
加载媒体
就像硬件媒体播放器需要加载 CD、DVD 或蓝光光盘才能实际播放任何内容一样,我们的QMediaPlayer在播放任何音频之前也需要加载某种内容。让我们探讨如何从文件中加载声音。
首先在SoundWidget布局中添加一个按钮,如下所示:
self.file_button = qtw.QPushButton(
'Load File', clicked=self.get_file)
self.layout().addWidget(self.file_button, 4, 0)
这个按钮调用get_file()方法,看起来是这样的:
def get_file(self):
fn, _ = qtw.QFileDialog.getOpenFileUrl(
self,
"Select File",
qtc.QDir.homePath(),
"Audio files (*.wav *.flac *.mp3 *.ogg *.aiff);; All files (*)"
)
if fn:
self.set_file(fn)
这个方法简单地调用QFileDialog来检索文件 URL,然后将其传递给另一个方法set_file(),我们将在下面编写。我们已经设置了过滤器来查找五种常见的音频文件类型,但如果你有不同格式的音频,可以随意添加更多——QMediaPlayer在加载方面非常灵活。
请注意,我们正在调用getOpenFileUrl(),它返回一个QUrl对象,而不是文件路径字符串。QMediaPlayer更喜欢使用QUrl对象,因此这将节省我们一个转换步骤。
set_file()方法是我们最终将媒体加载到播放器中的地方:
def set_file(self, url):
content = qtmm.QMediaContent(url)
self.player.setMedia(content)
self.label.setText(url.fileName())
在我们可以将 URL 传递给媒体播放器之前,我们必须将其包装在QMediaContent类中。这为播放器提供了播放内容所需的 API。一旦包装好,我们就可以使用QMediaPlayer.setMedia()来加载它,然后它就准备好播放了。你可以将这个过程想象成将音频数据放入 CD(QMediaContent对象),然后将 CD 加载到 CD 播放器中(使用setMedia())。
作为最后的修饰,我们已经检索了加载文件的文件名,并将其放在标签中。
跟踪播放位置
此时,我们的声音板可以加载和播放样本,但是看到并控制播放位置会很好,特别是对于长样本。QMediaPlayer允许我们通过信号和槽来检索和控制播放位置,所以让我们从我们的 GUI 中来看一下。
首先创建一个QSlider小部件,如下所示:
self.position = qtw.QSlider(
minimum=0, orientation=qtc.Qt.Horizontal)
self.layout().addWidget(self.position, 1, 0, 1, 2)
QSlider是一个我们还没有看过的小部件;它只是一个滑块控件,可以用来输入最小值和最大值之间的整数。
现在连接滑块和播放器,如下所示:
self.player.positionChanged.connect(self.position.setSliderPosition)
self.player.durationChanged.connect(self.position.setMaximum)
self.position.sliderMoved.connect(self.player.setPosition)
QMediaPlayer类以表示从文件开始的毫秒数的整数报告其位置,因此我们可以将positionChanged信号连接到滑块的setSliderPosition()槽。
然而,我们还需要调整滑块的最大位置,使其与样本的持续时间相匹配,否则滑块将不知道值代表的百分比。因此,我们已经将播放器的durationChanged信号(每当新内容加载到播放器时发出)连接到滑块的setMaximum()槽。
最后,我们希望能够使用滑块来控制播放位置,因此我们将sliderMoved信号设置为播放器的setPosition()槽。请注意,我们绝对要使用sliderMoved而不是valueChanged(当用户或事件更改值时,QSlider发出的信号),因为后者会在媒体播放器更改位置时创建一个反馈循环。
这些连接是我们的滑块工作所需的全部。现在你可以运行程序并加载一个长声音;你会看到滑块跟踪播放位置,并且可以在播放之前或期间移动以改变位置。
循环音频
在一次性播放我们的样本很好,但我们也想循环播放它们。在QMediaPlayer对象中循环音频需要稍微不同的方法。我们需要先将QMediaContent对象添加到QMediaPlayList对象中,然后告诉播放列表循环播放。
回到我们的set_file()方法,我们需要对我们的代码进行以下更改:
def set_file(self, url):
self.label.setText(url.fileName())
content = qtmm.QMediaContent(url)
#self.player.setMedia(content)
self.playlist = qtmm.QMediaPlaylist()
self.playlist.addMedia(content)
self.playlist.setCurrentIndex(1)
self.player.setPlaylist(self.playlist)
当然,一个播放列表可以加载多个文件,但在这种情况下,我们只想要一个。我们使用addMedia()方法将QMediaContent对象加载到播放列表中,然后使用setCurrentIndex()方法将播放列表指向该文件。请注意,播放列表不会自动指向任何项目。这意味着如果您跳过最后一步,当您尝试播放播放列表时将不会发生任何事情。
最后,我们使用媒体播放器的setPlaylist()方法添加播放列表。
现在我们的内容在播放列表中,我们将创建一个复选框来切换循环播放的开关:
self.loop_cb = qtw.QCheckBox(
'Loop', stateChanged=self.on_loop_cb)
self.layout().addWidget(self.loop_cb, 2, 0)
正如您所看到的,我们正在将复选框的stateChanged信号连接到一个回调方法;该方法将如下所示:
def on_loop_cb(self, state):
if state == qtc.Qt.Checked:
self.playlist.setPlaybackMode(
qtmm.QMediaPlaylist.CurrentItemInLoop)
else:
self.playlist.setPlaybackMode(
qtmm.QMediaPlaylist.CurrentItemOnce)
QMediaPlaylist类的playbackMode属性与 CD 播放器上的曲目模式按钮非常相似,可以用于在重复、随机或顺序播放之间切换。如下表所示,有五种播放模式:
| 模式 | 描述 |
|---|---|
CurrentItemOnce |
播放当前曲目一次,然后停止。 |
CurrentItemInLoop |
重复播放当前项目。 |
顺序 |
播放所有项目,然后停止。 |
循环 |
播放所有项目,然后重复。 |
随机 |
以随机顺序播放所有项目。 |
在这种方法中,我们根据复选框是否被选中来在CurrentItemOnce和CurrentItemInLoop之间切换。由于我们的播放列表只有一个项目,剩下的模式是没有意义的。
最后,当加载新文件时,我们将清除复选框。因此,请将以下内容添加到set_file()的末尾:
self.loop_cb.setChecked(False)
在这一点上,您应该能够运行程序并循环播放示例。请注意,使用此方法循环音频可能无法保证无缝循环;取决于您的平台和系统功能,循环的迭代之间可能会有一个小间隙。
设置音量
我们的最终播放功能将是音量控制。为了让我们能够控制播放级别,QMediaPlayer有一个接受值从0(静音)到100(最大音量)的volume参数。
我们将简单地添加另一个滑块小部件来控制音量,如下所示:
self.volume = qtw.QSlider(
minimum=0,
maximum=100,
sliderPosition=75,
orientation=qtc.Qt.Horizontal,
sliderMoved=self.player.setVolume
)
self.layout().addWidget(self.volume, 2, 1)
在设置最小和最大值后,我们只需要将sliderMoved连接到媒体播放器的setVolume()槽。就是这样!
为了更平滑地控制音量,Qt 文档建议将滑块的线性刻度转换为对数刻度。我们建议您阅读doc.qt.io/qt-5/qaudio.html#convertVolume,看看您是否可以自己做到这一点。
实现录音
Qt 中的音频录制是通过QAudioRecorder类实现的。就像QMediaPlayer类类似于媒体播放设备一样,QAudioRecorder类类似于媒体录制设备,例如数字音频录音机(或者如果您是作者的一代人,磁带录音机)。录音机使用record()、stop()和pause()方法进行控制,就像媒体播放器对象一样。
让我们向我们的SoundWidget添加一个录音机对象,如下所示:
self.recorder = qtmm.QAudioRecorder()
为了控制录音机,我们将创建另一个双功能按钮类,类似于我们之前创建的播放按钮:
class RecordButton(qtw.QPushButton):
record_stylesheet = 'background-color: black; color: white;'
stop_stylesheet = 'background-color: darkred; color: white;'
def __init__(self):
super().__init__('Record')
def on_state_changed(self, state):
if state == qtmm.QAudioRecorder.RecordingState:
self.setStyleSheet(self.stop_stylesheet)
self.setText('Stop')
else:
self.setStyleSheet(self.record_stylesheet)
self.setText('Record')
就像PlayButton类一样,每当从录音机的stateChanged信号接收到新的state值时,我们就会切换按钮的外观。在这种情况下,我们正在寻找录音机的RecordingState状态。
让我们向我们的小部件添加一个RecordButtoon()方法,如下所示:
self.record_button = RecordButton()
self.recorder.stateChanged.connect(
self.record_button.on_state_changed)
self.layout().addWidget(self.record_button, 4, 1)
self.record_button.clicked.connect(self.on_recordbutton)
我们已经将clicked信号连接到on_recordbutton()方法,该方法将处理音频录制的开始和停止。
这个方法如下:
def on_recordbutton(self):
if self.recorder.state() == qtmm.QMediaRecorder.RecordingState:
self.recorder.stop()
url = self.recorder.actualLocation()
self.set_file(url)
我们将首先检查录音机的状态。如果它当前正在录制,那么我们将通过调用recorder.stop()来停止它,这不仅会停止录制,还会将录制的数据写入磁盘上的音频文件。然后,我们可以通过调用录音机的actualLocation()方法来获取该文件的位置。此方法返回一个QUrl对象,我们可以直接将其传递给self.set_file()以将我们的播放设置为新录制的文件。
确保使用actualLocation()获取文件的位置。可以使用setLocation()配置录制位置,并且此值可以从location()访问器中获取。但是,如果配置的位置无效或不可写,Qt 可能会回退到默认设置。actualLocation()返回文件实际保存的 URL。
如果我们当前没有录制,我们将通过调用recorder.record()来告诉录音机开始录制:
else:
self.recorder.record()
当调用record()时,音频录制器将在后台开始录制音频,并将一直保持录制,直到调用stop()。
在我们可以播放录制的文件之前,我们需要对set_file()进行一次修复。在撰写本文时,QAudioRecorder.actualLocation()方法忽略了向 URL 添加方案值,因此我们需要手动指定这个值:
def set_file(self, url):
if url.scheme() == '':
url.setScheme('file')
content = qtmm.QMediaContent(url)
#...
在QUrl术语中,scheme对象指示 URL 的协议,例如 HTTP、HTTPS 或 FTP。由于我们正在访问本地文件,因此方案应为'file'。
如果QAudioRecorder的默认设置在您的系统上正常工作,则应该能够录制和播放音频。但是,这是一个很大的如果;很可能您需要对音频录制器对象进行一些配置才能使其正常工作。让我们看看如何做到这一点。
检查和配置录音机
即使QAudioRecorder类对您来说运行良好,您可能会想知道是否有一种方法可以控制它记录的音频类型和质量,它从哪里记录音频,以及它将音频文件写入的位置。
为了配置这些内容,我们首先必须知道您的系统支持什么,因为对不同音频录制功能的支持可能取决于硬件、驱动程序或操作系统的能力。QAudioRecorder有一些方法可以提供有关可用功能的信息。
以下脚本将显示有关系统支持的音频功能的信息:
from PyQt5.QtCore import *
from PyQt5.QtMultimedia import *
app = QCoreApplication([])
r = QAudioRecorder()
print('Inputs: ', r.audioInputs())
print('Codecs: ', r.supportedAudioCodecs())
print('Sample Rates: ', r.supportedAudioSampleRates())
print('Containers: ', r.supportedContainers())
您可以在您的系统上运行此脚本并获取受支持的Inputs、Codecs、Sample Rates和container格式的列表。例如,在典型的 Microsoft Windows 系统上,您的结果可能如下所示:
Inputs: ['Microhpone (High Defnition Aud']
Codecs: ['audio/pcm']
Sample Rates: ([8000, 11025, 16000, 22050, 32000,
44100, 48000, 88200, 96000, 192000], False)
Containers: ['audio/x-wav', 'audio/x-raw']
要为QAudioRecorder对象配置输入源,您需要将音频输入的名称传递给setAudioInput()方法,如下所示:
self.recorder.setAudioInput('default:')
输入的实际名称可能在您的系统上有所不同。不幸的是,当您设置无效的音频输入时,QAudioRecorder不会抛出异常或注册错误,它只是简单地无法录制任何音频。因此,如果决定自定义此属性,请务必确保该值首先是有效的。
要更改记录的输出文件,我们需要调用setOutputLocation(),如下所示:
sample_path = qtc.QDir.home().filePath('sample1')
self.recorder.setOutputLocation(
qtc.QUrl.fromLocalFile(sample_path))
请注意,setOutputLocation()需要一个QUrl对象,而不是文件路径。一旦设置,Qt 将尝试使用此位置来录制音频。但是,如前所述,如果此位置不可用,它将恢复到特定于平台的默认值。
容器格式是保存音频数据的文件类型。例如,audio/x-wav是用于 WAV 文件的容器。我们可以使用setContainerFormat()方法在记录对象中设置此值,如下所示:
self.recorder.setContainerFormat('audio/x-wav')
此属性的值应为QAudioRecorder.supportedContainers()返回的字符串。使用无效值将在您尝试录制时导致错误。
设置编解码器、采样率和质量需要一个称为QAudioEncoderSettings对象的新对象。以下示例演示了如何创建和配置settings对象:
settings = qtmm.QAudioEncoderSettings()
settings.setCodec('audio/pcm')
settings.setSampleRate(44100)
settings.setQuality(qtmm.QMultimedia.HighQuality)
self.recorder.setEncodingSettings(settings)
在这种情况下,我们已经将我们的音频配置为使用 PCM 编解码器以44100 Hz 进行高质量编码。
请注意,并非所有编解码器都与所有容器类型兼容。如果选择了两种不兼容的类型,Qt 将在控制台上打印错误并且录制将失败,但不会崩溃或抛出异常。您需要进行适当的研究和测试,以确保您选择了兼容的设置。
根据所选择的编解码器,您可以在QAudioEncoderSettings对象上设置其他设置。您可以在doc.qt.io/qt-5/qaudioencodersettings.html的 Qt 文档中查阅更多信息。
配置音频设置可能非常棘手,特别是因为支持在各个系统之间差异很大。最好在可以的时候让 Qt 使用其默认设置,或者让用户使用从QAudioRecorder的支持检测方法获得的值来配置这些设置。无论您做什么,如果您不能保证运行您的软件的系统将支持它们,请不要硬编码设置或选项。
录制和播放视频
一旦您了解了如何在 Qt 中处理音频,处理视频只是在复杂性方面迈出了一小步。就像处理音频一样,我们将使用一个播放器对象来加载和播放内容,以及一个记录器对象来记录它。但是,对于视频,我们需要添加一些额外的组件来处理内容的可视化并初始化源设备。
为了理解它是如何工作的,我们将构建一个视频日志应用程序。将应用程序模板从第四章 使用 QMainWindow 构建应用程序复制到一个名为captains_log.py的新文件中,然后我们将开始编码。
构建基本 GUI
船长的日志应用程序将允许我们从网络摄像头录制视频到一个预设目录中的时间戳文件,并进行回放。我们的界面将在右侧显示过去日志的列表,在左侧显示预览/回放区域。我们将有一个分页式界面,以便用户可以在回放和录制模式之间切换。
在MainWindow.__init__()中,按照以下方式开始布局基本 GUI:
base_widget = qtw.QWidget()
base_widget.setLayout(qtw.QHBoxLayout())
notebook = qtw.QTabWidget()
base_widget.layout().addWidget(notebook)
self.file_list = qtw.QListWidget()
base_widget.layout().addWidget(self.file_list)
self.setCentralWidget(base_widget)
接下来,我们将添加一个工具栏来容纳传输控件:
toolbar = self.addToolBar("Transport")
record_act = toolbar.addAction('Rec')
stop_act = toolbar.addAction('Stop')
play_act = toolbar.addAction('Play')
pause_act = toolbar.addAction('Pause')
我们希望我们的应用程序只显示日志视频,因此我们需要将我们的记录隔离到一个独特的目录,而不是使用记录的默认位置。使用QtCore.QDir,我们将以跨平台的方式创建和存储一个自定义位置,如下所示:
self.video_dir = qtc.QDir.home()
if not self.video_dir.cd('captains_log'):
qtc.QDir.home().mkdir('captains_log')
self.video_dir.cd('captains_log')
这将在您的主目录下创建captains_log目录(如果不存在),并将self.video_dir对象设置为指向该目录。
我们现在需要一种方法来扫描这个目录以查找视频并填充列表小部件:
def refresh_video_list(self):
self.file_list.clear()
video_files = self.video_dir.entryList(
["*.ogg", "*.avi", "*.mov", "*.mp4", "*.mkv"],
qtc.QDir.Files | qtc.QDir.Readable
)
for fn in sorted(video_files):
self.file_list.addItem(fn)
QDir.entryList()返回我们的video_dir内容的列表。第一个参数是常见视频文件类型的过滤器列表,以便非视频文件不会在我们的日志列表中列出(可以随意添加您的操作系统喜欢的任何格式),第二个是一组标志,将限制返回的条目为可读文件。检索到这些文件后,它们将被排序并添加到列表小部件中。
回到__init__(),让我们调用这个函数来刷新列表:
self.refresh_video_list()
您可能希望在该目录中放入一个或两个视频文件,以确保它们被读取并添加到列表小部件中。
视频播放
我们的老朋友QMediaPlayer可以处理视频播放以及音频。但是,就像蓝光播放器需要连接到电视或监视器来显示它正在播放的内容一样,QMediaPlayer需要连接到一个实际显示视频的小部件。我们需要的小部件是QVideoWidget类,它位于QtMultimediaWidgets模块中。
要使用它,我们需要导入QMultimediaWidgets,如下所示:
from PyQt5 import QtMultimediaWidgets as qtmmw
要将我们的QMediaPlayer()方法连接到QVideoWidget()方法,我们设置播放器的videoOutput属性,如下所示:
self.player = qtmm.QMediaPlayer()
self.video_widget = qtmmw.QVideoWidget()
self.player.setVideoOutput(self.video_widget)
这比连接蓝光播放器要容易,对吧?
现在我们可以将视频小部件添加到我们的 GUI,并将传输连接到我们的播放器:
notebook.addTab(self.video_widget, "Play")
play_act.triggered.connect(self.player.play)
pause_act.triggered.connect(self.player.pause)
stop_act.triggered.connect(self.player.stop)
play_act.triggered.connect(
lambda: notebook.setCurrentWidget(self.video_widget))
最后,我们添加了一个连接,以便在单击播放按钮时切换回播放选项卡。
启用播放的最后一件事是将文件列表中的文件选择连接到加载和播放媒体播放器中的视频。
我们将在一个名为on_file_selected()的回调中执行此操作,如下所示:
def on_file_selected(self, item):
fn = item.text()
url = qtc.QUrl.fromLocalFile(self.video_dir.filePath(fn))
content = qtmm.QMediaContent(url)
self.player.setMedia(content)
self.player.play()
回调函数从file_list接收QListWidgetItem并提取text参数,这应该是文件的名称。我们将其传递给我们的QDir对象的filePath()方法,以获得文件的完整路径,并从中构建一个QUrl对象(请记住,QMediaPlayer使用 URL 而不是文件路径)。最后,我们将内容包装在QMediaContent对象中,将其加载到播放器中,并点击play()。
回到__init__(),让我们将此回调连接到我们的列表小部件:
self.file_list.itemDoubleClicked.connect(
self.on_file_selected)
self.file_list.itemDoubleClicked.connect(
lambda: notebook.setCurrentWidget(self.video_widget))
在这里,我们连接了itemDoubleClicked,它将被点击的项目传递给槽,就像我们的回调所期望的那样。请注意,我们还将该操作连接到一个lambda函数,以切换到视频小部件。这样,如果用户在录制选项卡上双击文件,他们将能够在不手动切换回播放选项卡的情况下观看它。
此时,您的播放器已经可以播放视频。如果您还没有在captains_log目录中放入一些视频文件,请放入一些并查看它们是否可以播放。
视频录制
要录制视频,我们首先需要一个来源。在 Qt 中,此来源必须是QMediaObject的子类,其中可以包括音频来源、媒体播放器、收音机,或者在本程序中将使用的相机。
Qt 5.12 目前不支持 Windows 上的视频录制,只支持 macOS 和 Linux。有关 Windows 上多媒体支持当前状态的更多信息,请参阅doc.qt.io/qt-5/qtmultimedia-windows.html。
在 Qt 中,相机本身表示为QCamera对象。要创建一个可工作的QCamera对象,我们首先需要获取一个QCameraInfo对象。QCameraInfo对象包含有关连接到计算机的物理相机的信息。可以从QtMultimedia.QCameraInfo.availableCameras()方法获取这些对象的列表。
让我们将这些放在一起,形成一个方法,该方法将在您的系统上查找相机并返回一个QCamera对象:
def camera_check(self):
cameras = qtmm.QCameraInfo.availableCameras()
if not cameras:
qtw.QMessageBox.critical(
self,
'No cameras',
'No cameras were found, recording disabled.'
)
else:
return qtmm.QCamera(cameras[0])
如果您的系统连接了一个或多个相机,availableCameras()应该返回一个QCameraInfo对象的列表。如果没有,那么我们将显示一个错误并返回空;如果有,那么我们将信息对象传递给QCamera构造函数,并返回表示相机的对象。
回到__init__(),我们将使用以下函数来获取相机对象:
self.camera = self.camera_check()
if not self.camera:
self.show()
return
如果没有相机,那么此方法中剩余的代码将无法工作,因此我们将只显示窗口并返回。
在使用相机之前,我们需要告诉它我们希望它捕捉什么。相机可以捕捉静态图像或视频内容,这由相机的captureMode属性配置。
在这里,我们将其设置为视频,使用QCamera.CaptureVideo常量:
self.camera.setCaptureMode(qtmm.QCamera.CaptureVideo)
在我们开始录制之前,我们希望能够预览相机捕捉的内容(毕竟,船长需要确保他们的头发看起来很好以供后人纪念)。QtMultimediaWidgets有一个专门用于此目的的特殊小部件,称为QCameraViewfinder。
我们将添加一个并将我们的相机连接到它,如下所示:
self.cvf = qtmmw.QCameraViewfinder()
self.camera.setViewfinder(self.cvf)
notebook.addTab(self.cvf, 'Record')
相机现在已经创建并配置好了,所以我们需要通过调用start()方法来激活它:
self.camera.start()
如果您此时运行程序,您应该在录制选项卡上看到相机捕捉的实时显示。
这个谜题的最后一块是录制器对象。在视频的情况下,我们使用QMediaRecorder类来创建一个视频录制对象。这个类实际上是我们在声音板中使用的QAudioRecorder类的父类,并且工作方式基本相同。
让我们创建我们的录制器对象,如下所示:
self.recorder = qtmm.QMediaRecorder(self.camera)
请注意,我们将摄像头对象传递给构造函数。每当创建QMediaRecorder属性时,必须传递QMediaObject(其中QCamera是子类)。此属性不能以后设置,也不能在没有它的情况下调用构造函数。
就像我们的音频录制器一样,我们可以配置有关我们捕获的视频的各种设置。这是通过创建一个QVideoEncoderSettings类并将其传递给录制器的videoSettings属性来完成的:
settings = self.recorder.videoSettings()
settings.setResolution(640, 480)
settings.setFrameRate(24.0)
settings.setQuality(qtmm.QMultimedia.VeryHighQuality)
self.recorder.setVideoSettings(settings)
重要的是要理解,如果你设置了你的摄像头不支持的配置,那么录制很可能会失败,你可能会在控制台看到错误:
CameraBin warning: "not negotiated"
CameraBin error: "Internal data stream error."
为了确保这不会发生,我们可以查询我们的录制对象,看看支持哪些设置,就像我们对音频设置所做的那样。以下脚本将打印每个检测到的摄像头在您的系统上支持的编解码器、帧速率、分辨率和容器到控制台:
from PyQt5.QtCore import *
from PyQt5.QtMultimedia import *
app = QCoreApplication([])
for camera_info in QCameraInfo.availableCameras():
print('Camera: ', camera_info.deviceName())
camera = QCamera(camera_info)
r = QMediaRecorder(camera)
print('\tAudio Codecs: ', r.supportedAudioCodecs())
print('\tVideo Codecs: ', r.supportedVideoCodecs())
print('\tAudio Sample Rates: ', r.supportedAudioSampleRates())
print('\tFrame Rates: ', r.supportedFrameRates())
print('\tResolutions: ', r.supportedResolutions())
print('\tContainers: ', r.supportedContainers())
print('\n\n')
请记住,在某些系统上,返回的结果可能为空。如果有疑问,最好要么进行实验,要么接受默认设置提供的任何内容。
现在我们的录制器已经准备好了,我们需要连接传输并启用它进行录制。让我们首先编写一个用于录制的回调方法:
def record(self):
# create a filename
datestamp = qtc.QDateTime.currentDateTime().toString()
self.mediafile = qtc.QUrl.fromLocalFile(
self.video_dir.filePath('log - ' + datestamp)
)
self.recorder.setOutputLocation(self.mediafile)
# start recording
self.recorder.record()
这个回调有两个作用——创建并设置要记录的文件名,并开始录制。我们再次使用我们的QDir对象,结合QDateTime类来生成包含按下记录时的日期和时间的文件名。请注意,我们不向文件名添加文件扩展名。这是因为QMediaRecorder将根据其配置为创建的文件类型自动执行此操作。
通过简单调用QMediaRecorder对象上的record()来启动录制。它将在后台记录视频,直到调用stop()插槽。
回到__init__(),让我们通过以下方式完成连接传输控件:
record_act.triggered.connect(self.record)
record_act.triggered.connect(
lambda: notebook.setCurrentWidget(self.cvf)
)
pause_act.triggered.connect(self.recorder.pause)
stop_act.triggered.connect(self.recorder.stop)
stop_act.triggered.connect(self.refresh_video_list)
我们将记录操作连接到我们的回调和一个 lambda 函数,该函数切换到录制选项卡。然后,我们直接将暂停和停止操作连接到录制器的pause()和stop()插槽。最后,当视频停止录制时,我们将希望刷新文件列表以显示新文件,因此我们将stop_act连接到refresh_video_list()回调。
这就是我们需要的一切;擦拭一下你的网络摄像头镜头,启动这个脚本,开始跟踪你的星际日期!
总结
在本章中,我们探索了QtMultimedia和QMultimediaWidgets模块的功能。您学会了如何使用QSoundEffect播放低延迟音效,以及如何使用QMediaPlayer和QAudioRecorder播放和记录各种媒体格式。最后,我们使用QCamera、QMediaPlayer和QMediaRecorder创建了一个视频录制和播放应用程序。
在下一章中,我们将通过探索 Qt 的网络功能来连接到更广泛的世界。我们将使用套接字进行低级网络和使用QNetworkAccessManager进行高级网络。
问题
尝试这些问题来测试你从本章学到的知识:
-
使用
QSoundEffect,你为呼叫中心编写了一个实用程序,允许他们回顾录制的电话呼叫。他们正在转移到一个将音频呼叫存储为 MP3 文件的新电话系统。你需要对你的实用程序进行任何更改吗? -
cool_songs是一个包含你最喜欢的歌曲路径字符串的 Python 列表。要以随机顺序播放这些歌曲,你需要做什么? -
你已经在你的系统上安装了
audio/mpeg编解码器,但以下代码不起作用。找出问题所在:
recorder = qtmm.QAudioRecorder()
recorder.setCodec('audio/mpeg')
recorder.record()
-
在几个不同的 Windows、macOS 和 Linux 系统上运行
audio_test.py和video_test.py。输出有什么不同?有哪些项目在所有系统上都受支持? -
QCamera类的属性包括几个控制对象,允许您管理相机的不同方面。其中之一是QCameraFocus。在 Qt 文档中调查QCameraFocus,网址为doc.qt.io/qt-5/qcamerafocus.html,并编写一个简单的脚本,显示取景器并让您调整数字变焦。 -
您注意到录制到您的船长日志视频日志中的音频相当响亮。您想添加一个控件来调整它;您会如何做?
-
在
captains_log.py中实现一个停靠窗口小部件,允许您控制尽可能多的音频和视频录制方面。您可以包括焦点、变焦、曝光、白平衡、帧速率、分辨率、音频音量、音频质量等内容。
进一步阅读
您可以查阅以下参考资料以获取更多信息:
-
您可以在
doc.qt.io/qt-5/multimediaoverview.html上了解 Qt 多媒体系统及其功能。 -
PyQt 的官方
QtMultimedia和QtMultimediaWidgets示例可以在github.com/pyqt/examples/tree/master/multimedia和github.com/pyqt/examples/tree/master/multimediawidgets找到。它们提供了更多使用 PyQt 进行媒体捕获和播放的示例代码。
第八章:使用 QtNetwork 进行网络连接
人类是社会性动物,越来越多的软件系统也是如此。尽管计算机本身很有用,但与其他计算机连接后,它们的用途要大得多。无论是在小型本地交换机还是全球互联网上,通过网络与其他系统进行交互对于大多数现代软件来说都是至关重要的功能。在本章中,我们将探讨 Qt 提供的网络功能以及如何在 PyQt5 中使用它们。
特别是,我们将涵盖以下主题:
-
使用套接字进行低级网络连接
-
使用
QNetworkAccessManager进行 HTTP 通信
技术要求
与其他章节一样,您需要一个基本的 Python 和 PyQt5 设置,如第一章中所述,并且您将受益于从我们的 GitHub 存储库下载示例代码github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter08。
此外,您将希望至少有另一台装有 Python 的计算机连接到同一局域网。
查看以下视频以查看代码的运行情况:bit.ly/2M5xqid
使用套接字进行低级网络连接
几乎每个现代网络都使用互联网协议套件,也称为TCP/IP,来促进计算机或其他设备之间的连接。TCP/IP 是一组管理网络上原始数据传输的协议。直接在代码中使用 TCP/IP 最常见的方法是使用套接字 API。
套接字是一个类似文件的对象,代表系统的网络连接点。每个套接字都有一个主机地址,网络端口和传输协议。
主机地址,也称为IP 地址,是用于在网络上标识单个网络主机的一组数字。尽管骨干系统依赖 IPv6 协议,但大多数个人计算机仍使用较旧的 IPv4 地址,该地址由点分隔的四个介于0和255之间的数字组成。您可以使用 GUI 工具找到系统的地址,或者通过在命令行终端中键入以下命令之一来找到地址:
| OS | Command |
|---|---|
| Windows | ipconfig |
| macOS | ifconfig |
| Linux | ip address |
端口只是一个从0到65535的数字。虽然您可以使用任何端口号创建套接字,但某些端口号分配给常见服务;这些被称为众所周知的端口。例如,HTTP 服务器通常分配到端口80,SSH 通常在端口22上。在许多操作系统上,需要管理或根权限才能在小于1024的端口上创建套接字。
可以在www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.xhtml找到官方的众所周知的端口列表。
传输协议包括传输控制协议(TCP)和用户数据报协议(UDP)。TCP 是两个系统之间的有状态连接。您可以将其视为电话呼叫 - 建立连接,交换信息,并在某个明确的点断开连接。由于其有状态性,TCP 确保接收所有传输的数据包。另一方面,UDP 是一种无状态协议。将其视为使用对讲机 - 用户传输消息,接收者可能完整或部分接收,且不会建立明确的连接。UDP 相对轻量级,通常用于广播消息,因为它不需要与特定主机建立连接。
QtNetwork模块为我们提供了建立 TCP 和 UDP 套接字连接的类。为了理解它们的工作原理,我们将构建两个聊天系统 - 一个使用 UDP,另一个使用 TCP。
构建聊天 GUI
让我们首先创建一个基本的 GUI 表单,我们可以在聊天应用的两个版本中使用。从第四章的应用程序模板开始,使用 QMainWindow 构建应用程序,然后添加这个类:
class ChatWindow(qtw.QWidget):
submitted = qtc.pyqtSignal(str)
def __init__(self):
super().__init__()
self.setLayout(qtw.QGridLayout())
self.message_view = qtw.QTextEdit(readOnly=True)
self.layout().addWidget(self.message_view, 1, 1, 1, 2)
self.message_entry = qtw.QLineEdit()
self.layout().addWidget(self.message_entry, 2, 1)
self.send_btn = qtw.QPushButton('Send', clicked=self.send)
self.layout().addWidget(self.send_btn, 2, 2)
GUI 很简单,只有一个文本编辑器来显示对话,一个行编辑器来输入消息,以及一个发送按钮。我们还实现了一个信号,每当用户提交新消息时就可以发出。
GUI 还将有两个方法:
def write_message(self, username, message):
self.message_view.append(f'<b>{username}: </b> {message}<br>')
def send(self):
message = self.message_entry.text().strip()
if message:
self.submitted.emit(message)
self.message_entry.clear()
send() 方法由 send_btn 按钮触发,发出包含行编辑中文本的 submitted 信号,以及 write_message() 方法,该方法接收 username 和 message 并使用一些简单的格式将其写入文本编辑器。
在 MainWindow.__init__() 方法中,添加以下代码:
self.cw = ChatWindow()
self.setCentralWidget(self.cw)
最后,在我们可以进行任何网络编码之前,我们需要为 QtNetwork 添加一个 import。像这样将其添加到文件的顶部:
from PyQt5 import QtNetwork as qtn
这段代码将是我们的 UDP 和 TCP 聊天应用程序的基础代码,所以将这个文件保存为 udp_chat.py 的一个副本,另一个副本保存为 tcp_chat.py。我们将通过为表单创建一个后端对象来完成每个应用程序。
构建 UDP 聊天客户端
UDP 最常用于本地网络上的广播应用程序,因此为了演示这一点,我们将使我们的 UDP 聊天成为一个仅限本地网络的广播聊天。这意味着在运行此应用程序副本的本地网络上的任何计算机都将能够查看并参与对话。
我们将首先创建我们的后端类,我们将其称为 UdpChatInterface:
class UdpChatInterface(qtc.QObject):
port = 7777
delimiter = '||'
received = qtc.pyqtSignal(str, str)
error = qtc.pyqtSignal(str)
我们的后端继承自 QObject,以便我们可以使用 Qt 信号,我们定义了两个信号——一个 received 信号,当接收到消息时我们将发出它,一个 error 信号,当发生错误时我们将发出它。我们还定义了一个要使用的端口号和一个 delimiter 字符串。当我们序列化消息进行传输时,delimiter 字符串将用于分隔用户名和消息;因此,当用户 alanm 发送消息 Hello World 时,我们的接口将在网络上发送字符串 alanm||Hello World。
一次只能将一个应用程序绑定到一个端口;如果您已经有一个使用端口 7777 的应用程序,您应该将这个数字更改为 1024 到 65535 之间的其他数字。在 Windows、macOS 和旧版 Linux 系统上,可以使用 netstat 命令来显示正在使用哪些端口。在较新的 Linux 系统上,可以使用 ss 命令。
现在开始一个 __init__() 方法:
def __init__(self, username):
super().__init__()
self.username = username
self.socket = qtn.QUdpSocket()
self.socket.bind(qtn.QHostAddress.Any, self.port)
调用 super() 并存储 username 变量后,我们的首要任务是创建和配置一个 QUdpSocket 对象。在我们可以使用套接字之前,它必须绑定到本地主机地址和端口号。QtNetwork.QHostAddress.Any 表示本地系统上的所有地址,因此我们的套接字将在所有本地接口上监听和发送端口 7777 上的数据。
要使用套接字,我们必须处理它的信号:
self.socket.readyRead.connect(self.process_datagrams)
self.socket.error.connect(self.on_error)
Socket 对象有两个我们感兴趣的信号。第一个是 readyRead,每当套接字接收到数据时就会发出该信号。我们将在一个名为 process_datagrams() 的方法中处理该信号,我们马上就会写这个方法。
error 信号在发生任何错误时发出,我们将在一个名为 on_error() 的实例方法中处理它。
让我们从错误处理程序开始,因为它相对简单:
def on_error(self, socket_error):
error_index = (qtn.QAbstractSocket
.staticMetaObject
.indexOfEnumerator('SocketError'))
error = (qtn.QAbstractSocket
.staticMetaObject
.enumerator(error_index)
.valueToKey(socket_error))
message = f"There was a network error: {error}"
self.error.emit(message)
这种方法在其中有一点 Qt 的魔力。网络错误在QAbstractSocket类(UdpSocket的父类)的SocketError枚举中定义。不幸的是,如果我们只是尝试打印错误,我们会得到常量的整数值。要实际获得有意义的字符串,我们将深入与QAbstractSocket关联的staticMetaObject。我们首先获取包含错误常量的枚举类的索引,然后使用valueToKey()将我们的套接字错误整数转换为其常量名称。这个技巧可以用于任何 Qt 枚举,以检索有意义的名称而不仅仅是它的整数值。
一旦被检索,我们只需将错误格式化为消息并在我们的error信号中发出。
现在让我们来解决process_datagrams():
def process_datagrams(self):
while self.socket.hasPendingDatagrams():
datagram = self.socket.receiveDatagram()
raw_message = bytes(datagram.data()).decode('utf-8')
单个 UDP 传输被称为数据报。当我们的套接字接收到数据报时,它被存储在缓冲区中,并发出readyRead信号。只要该缓冲区有等待的数据报,套接字的hasPendingDatagrams()将返回True。因此,只要有待处理的数据报,我们就会循环调用套接字的receiveDatagram()方法,该方法返回并移除缓冲区中等待的下一个数据报,直到检索到所有数据报为止。
receiveDatagram()返回的数据报对象是QByteArray,相当于 Python 的bytes对象。由于我们的程序传输的是字符串,而不是二进制对象,我们可以将QByteArray直接转换为 Unicode 字符串。这样做的最快方法是首先将其转换为bytes对象,然后使用decode()方法将其转换为 UTF-8 Unicode 文本。
现在我们有了原始字符串,我们需要检查它以确保它来自udp_chat.py的另一个实例,然后将其拆分成username和message组件:
if self.delimiter not in raw_message:
continue
username, message = raw_message.split(self.delimiter, 1)
self.received.emit(username, message)
如果套接字接收到的原始文本不包含我们的delimiter字符串,那么它很可能来自其他程序或损坏的数据包,我们将跳过它。否则,我们将在第一个delimiter的实例处将其拆分为username和message字符串,然后发出这些字符串与received信号。
我们的聊天客户端需要的最后一件事是发送消息的方法,我们将在send_message()方法中实现:
def send_message(self, message):
msg_bytes = (
f'{self.username}{self.delimiter}{message}'
).encode('utf-8')
self.socket.writeDatagram(
qtc.QByteArray(msg_bytes),
qtn.QHostAddress.Broadcast,
self.port
)
这种方法首先通过使用delimiter字符串格式化传递的消息与我们配置的用户名,然后将格式化的字符串编码为bytes对象。
接下来,我们使用writeDatagram()方法将数据报写入我们的套接字对象。这个方法接受一个QByteArray(我们已经将我们的bytes对象转换为它)和一个目标地址和端口。我们的目的地被指定为QHostAddress.Broadcast,这表示我们要使用广播地址,端口当然是我们在类变量中定义的端口。
广播地址是 TCP/IP 网络上的保留地址,当使用时,表示传输应该被所有主机接收。
让我们总结一下我们在这个后端中所做的事情:
-
发送消息时,消息将以用户名为前缀,并作为字节数组广播到网络上的所有主机的端口
7777。 -
当在端口
7777上接收到消息时,它将从字节数组转换为字符串。消息和用户名被拆分并发出信号。 -
发生错误时,错误号将被转换为错误字符串,并与错误信号一起发出。
现在我们只需要将我们的后端连接到前端表单。
连接信号
回到我们的MainWindow构造函数,我们需要通过创建一个UdpChatInterface对象并连接其信号来完成我们的应用程序:
username = qtc.QDir.home().dirName()
self.interface = UdpChatInterface(username)
self.cw.submitted.connect(self.interface.send_message)
self.interface.received.connect(self.cw.write_message)
self.interface.error.connect(
lambda x: qtw.QMessageBox.critical(None, 'Error', x))
在创建界面之前,我们通过获取当前用户的主目录名称来确定username。这有点像黑客,但对我们的目的来说足够好了。
接下来,我们创建我们的接口对象,并将聊天窗口的submitted信号连接到其send_message()槽。
然后,我们将接口的received信号连接到聊天窗口的write_message()方法,将error信号连接到一个 lambda 函数,用于在QMessageBox中显示错误。
一切都连接好了,我们准备好测试了。
测试聊天
要测试这个聊天系统,您需要两台安装了 Python 和 PyQt5 的计算机,运行在同一个局域网上。在继续之前,您可能需要禁用系统的防火墙或打开 UDP 端口7777。
完成后,将udp_chat.py复制到两台计算机上并启动它。在一台计算机上输入一条消息;它应该会显示在两台计算机的聊天窗口中,看起来像这样:

请注意,系统也会接收并对自己的广播消息做出反应,因此我们不需要担心在文本区域中回显自己的消息。
UDP 确实很容易使用,但它有许多限制。例如,UDP 广播通常无法路由到本地网络之外,而且无状态连接的缺失意味着无法知道传输是否已接收或丢失。在构建 TCP 聊天客户端部分,我们将构建一个没有这些问题的聊天 TCP 版本。
构建 TCP 聊天客户端
TCP 是一种有状态的传输协议,这意味着建立并维护连接直到传输完成。TCP 也主要是一对一的主机连接,我们通常使用客户端-服务器设计来实现。我们的 TCP 聊天应用程序将在两个网络主机之间建立直接连接,并包含一个客户端组件,用于连接应用程序的其他实例,以及一个服务器组件,用于处理传入的客户端连接。
在您之前创建的tcp_chat.py文件中,像这样启动一个 TCP 聊天接口类:
class TcpChatInterface(qtc.QObject):
port = 7777
delimiter = '||'
received = qtc.pyqtSignal(str, str)
error = qtc.pyqtSignal(str)
到目前为止,这与 UDP 接口完全相同,除了名称。现在让我们创建构造函数:
def __init__(self, username, recipient):
super().__init__()
self.username = username
self.recipient = recipient
与以前一样,接口对象需要一个username,但我们还添加了一个recipient参数。由于 TCP 需要与另一个主机建立直接连接,我们需要指定要连接的远程主机。
现在我们需要创建服务器组件,用于监听传入的连接:
self.listener = qtn.QTcpServer()
self.listener.listen(qtn.QHostAddress.Any, self.port)
self.listener.acceptError.connect(self.on_error)
self.listener.newConnection.connect(self.on_connection)
self.connections = []
listener是一个QTcpServer对象。QTcpServer使我们的接口能够在给定接口和端口上接收来自 TCP 客户端的传入连接,这里我们将其设置为端口7777上的任何本地接口。
当有传入连接出现错误时,服务器对象会发出一个acceptError信号,我们将其连接到一个on_error()方法。这些是UdpSocket发出的相同类型的错误,因此我们可以从udp_chat.py中复制on_error()方法并以相同的方式处理它们。
每当有新连接进入服务器时,都会发出newConnection信号;我们将在一个名为on_connection()的方法中处理这个信号,它看起来像这样:
def on_connection(self):
connection = self.listener.nextPendingConnection()
connection.readyRead.connect(self.process_datastream)
self.connections.append(connection)
服务器的nextPendingConnection()方法返回一个QTcpSocket对象作为下一个等待连接。像QUdpSocket一样,QTcpSocket在接收数据时会发出readyRead信号。我们将把这个信号连接到一个process_datastream()方法。
最后,我们将在self.connections列表中保存对新连接的引用。
处理数据流
虽然 UDP 套接字使用数据报,但 TCP 套接字使用数据流。顾名思义,数据流涉及数据的流动而不是离散的单元。TCP 传输被发送为一系列网络数据包,这些数据包可能按照正确的顺序到达,也可能不会,接收方需要正确地重新组装接收到的数据。为了使这个过程更容易,我们可以将套接字包装在一个QtCore.QDataStream对象中,它提供了一个从类似文件的源读取和写入数据的通用接口。
让我们像这样开始我们的方法:
def process_datastream(self):
for socket in self.connections:
self.datastream = qtc.QDataStream(socket)
if not socket.bytesAvailable():
continue
我们正在遍历连接的套接字,并将每个传递给QDataStream对象。socket对象有一个bytesAvailable()方法,告诉我们有多少字节的数据排队等待读取。如果这个数字为零,我们将继续到列表中的下一个连接。
如果没有,我们将从数据流中读取:
raw_message = self.datastream.readQString()
if raw_message and self.delimiter in raw_message:
username, message = raw_message.split(self.delimiter, 1)
self.received.emit(username, message)
QDataStream.readQString()尝试从数据流中提取一个字符串并返回它。尽管名称如此,在 PyQt5 中,这个方法实际上返回一个 Python Unicode 字符串,而不是QString。重要的是要理解,这个方法只有在原始数据包中发送了QString时才起作用。如果发送了其他对象(原始字节字符串、整数等),readQString()将返回None。
QDataStream有用于写入和读取各种数据类型的方法。请参阅其文档doc.qt.io/qt-5/qdatastream.html。
一旦我们将传输作为字符串,我们将检查原始消息中的delimiter字符串,并且如果找到,拆分原始消息并发出received信号。
通过 TCP 发送数据
QTcpServer已经处理了消息的接收;现在我们需要实现发送消息。为此,我们首先需要创建一个QTcpSocket对象作为我们的客户端套接字。
让我们将其添加到__init__()的末尾:
self.client_socket = qtn.QTcpSocket()
self.client_socket.error.connect(self.on_error)
我们创建了一个默认的QTcpSocket对象,并将其error信号连接到我们的错误处理方法。请注意,我们不需要绑定此套接字,因为它不会监听。
为了使用客户端套接字,我们将创建一个send_message()方法;就像我们的 UDP 聊天一样,这个方法将首先将消息格式化为原始传输字符串:
def send_message(self, message):
raw_message = f'{self.username}{self.delimiter}{message}'
现在我们需要连接到要通信的远程主机:
socket_state = self.client_socket.state()
if socket_state != qtn.QAbstractSocket.ConnectedState:
self.client_socket.connectToHost(
self.recipient, self.port)
套接字的state属性可以告诉我们套接字是否连接到远程主机。QAbstractSocket.ConnectedState状态表示我们的客户端已连接到服务器。如果没有,我们调用套接字的connectToHost()方法来建立与接收主机的连接。
现在我们可以相当肯定我们已经连接了,让我们发送消息。为了做到这一点,我们再次转向QDataStream对象来处理与我们的 TCP 套接字通信的细节。
首先创建一个附加到客户端套接字的新数据流:
self.datastream = qtc.QDataStream(self.client_socket)
现在我们可以使用writeQString()方法向数据流写入字符串:
self.datastream.writeQString(raw_message)
重要的是要理解,对象只能按照我们发送它们的顺序从数据流中提取。例如,如果我们想要在字符串前面加上它的长度,以便接收方可以检查它是否损坏,我们可以这样做:
self.datastream.writeUInt32(len(raw_message))
self.datastream.writeQString(raw_message)
然后我们的process_datastream()方法需要相应地进行调整:
def process_datastream(self):
#...
message_length = self.datastream.readUInt32()
raw_message = self.datastream.readQString()
在send_message()中我们需要做的最后一件事是本地发出我们的消息,以便本地显示可以显示它。由于这不是广播消息,我们的本地 TCP 服务器不会听到发送出去的消息。
在send_message()的末尾添加这个:
self.received.emit(self.username, message)
让我们总结一下这个后端的操作方式:
-
我们有一个 TCP 服务器组件:
-
TCP 服务器对象在端口
7777上监听来自远程主机的连接 -
当接收到连接时,它将连接存储为套接字,并等待来自该套接字的数据
-
当接收到数据时,它将从套接字中读取数据流,解释并发出
-
我们有一个 TCP 客户端组件:
-
当需要发送消息时,首先对其进行格式化
-
然后检查连接状态,如果需要建立连接
-
一旦确保连接状态,消息将被写入套接字使用数据流
连接我们的后端并进行测试
回到MainWindow.__init__(),我们需要添加相关的代码来创建我们的接口并连接信号:
recipient, _ = qtw.QInputDialog.getText(
None, 'Recipient',
'Specify of the IP or hostname of the remote host.')
if not recipient:
sys.exit()
self.interface = TcpChatInterface(username, recipient)
self.cw.submitted.connect(self.interface.send_message)
self.interface.received.connect(self.cw.write_message)
self.interface.error.connect(
lambda x: qtw.QMessageBox.critical(None, 'Error', x))
由于我们需要一个接收者,我们将使用QInputDialog询问用户。这个对话框类允许您轻松地查询用户的单个值。在这种情况下,我们要求输入另一个系统的 IP 地址或主机名。这个值我们传递给TcpChatInterface构造函数。
代码的其余部分基本上与 UDP 聊天客户端相同。
要测试这个聊天客户端,您需要在同一网络上的另一台计算机上运行一个副本,或者在您自己的网络中可以访问的地址上运行。当您启动客户端时,请指定另一台计算机的 IP 或主机名。一旦两个客户端都在运行,您应该能够互发消息。如果您在第三台计算机上启动客户端,请注意您将看不到消息,因为它们只被发送到单台计算机。
使用QNetworkAccessManager进行 HTTP 通信
超文本传输协议(HTTP)是构建万维网的协议,也可以说是我们这个时代最重要的通信协议。我们当然可以在套接字上实现自己的 HTTP 通信,但 Qt 已经为我们完成了这项工作。QNetworkAccessManager类实现了一个可以传输 HTTP 请求和接收 HTTP 回复的对象。我们可以使用这个类来创建与 Web 服务和 API 通信的应用程序。
简单下载
为了演示QNetworkAccessManager的基本用法,我们将构建一个简单的命令行 HTTP 下载工具。打开一个名为downloader.py的空文件,让我们从一些导入开始:
import sys
from os import path
from PyQt5 import QtNetwork as qtn
from PyQt5 import QtCore as qtc
由于我们这里不需要QtWidgets或QtGui,只需要QtNetwork和QtCore。我们还将使用标准库path模块进行一些基于文件系统的操作。
让我们为我们的下载引擎创建一个QObject子类:
class Downloader(qtc.QObject):
def __init__(self, url):
super().__init__()
self.manager = qtn.QNetworkAccessManager(
finished=self.on_finished)
self.request = qtn.QNetworkRequest(qtc.QUrl(url))
self.manager.get(self.request)
在我们的下载引擎中,我们创建了一个QNetworkAccessManager,并将其finished信号连接到一个名为on_finish()的回调函数。当管理器完成网络事务并准备好处理回复时,它会发出finished信号,并将回复包含在信号中。
接下来,我们创建一个QNetworkRequest对象。QNetworkRequest代表我们发送到远程服务器的 HTTP 请求,并包含我们要发送的所有信息。在这种情况下,我们只需要构造函数中传入的 URL。
最后,我们告诉我们的网络管理器使用get()执行请求。get()方法使用 HTTP GET方法发送我们的请求,通常用于请求下载的信息。管理器将发送这个请求并等待回复。
当回复到来时,它将被发送到我们的on_finished()回调函数:
def on_finished(self, reply):
filename = reply.url().fileName() or 'download'
if path.exists(filename):
print('File already exists, not overwriting.')
sys.exit(1)
with open(filename, 'wb') as fh:
fh.write(reply.readAll())
print(f"{filename} written")
sys.exit(0)
这里的reply对象是一个QNetworkReply实例,其中包含从远程服务器接收的数据和元数据。
我们首先尝试确定一个文件名,我们将用它来保存文件。回复的url属性包含原始请求所发出的 URL,我们可以查询 URL 的fileName属性。有时这是空的,所以我们将退而求其次使用'download'字符串。
接下来,我们将检查文件名是否已经存在于我们的系统上。出于安全考虑,如果存在,我们将退出,这样您就不会在测试这个演示时破坏重要文件。
最后,我们使用它的readAll()方法从回复中提取数据,并将这些数据写入本地文件。请注意,我们以wb模式(写入二进制)打开文件,因为readAll()以QByteAarray对象的形式返回二进制数据。
我们的Downloader类的主要执行代码最后出现:
if __name__ == '__main__':
if len(sys.argv) < 2:
print(f'Usage: {sys.argv[0]} <download url>')
sys.exit(1)
app = qtc.QCoreApplication(sys.argv)
d = Downloader(sys.argv[1])
sys.exit(app.exec_())
在这里,我们只是从命令行中获取第一个参数,并将其传递给我们的Downloader对象。请注意,我们使用的是QCoreApplication而不是QApplication;当您想要创建一个命令行 Qt 应用程序时,可以使用这个类。否则,它与QApplication是一样的。
简而言之,使用QNetworkAccessManager就是这么简单:
-
创建一个
QNetworkAccessManager对象 -
创建一个
QNetworkRequest对象 -
将请求传递给管理器的
get()方法 -
在与管理器的
finished信号连接的回调中处理回复
发布数据和文件
使用GET请求检索数据是相当简单的 HTTP;为了更深入地探索 PyQt5 的 HTTP 通信,我们将构建一个实用程序,允许我们向远程 URL 发送带有任意键值和文件数据的POST请求。例如,这个实用程序可能对测试 Web API 很有用。
构建 GUI
从第四章的 Qt 应用程序模板的副本开始,使用 QMainWindow 构建应用程序,让我们将主要的 GUI 代码添加到MainWindow.__init__()方法中:
widget = qtw.QWidget(minimumWidth=600)
self.setCentralWidget(widget)
widget.setLayout(qtw.QVBoxLayout())
self.url = qtw.QLineEdit()
self.table = qtw.QTableWidget(columnCount=2, rowCount=5)
self.table.horizontalHeader().setSectionResizeMode(
qtw.QHeaderView.Stretch)
self.table.setHorizontalHeaderLabels(['key', 'value'])
self.fname = qtw.QPushButton(
'(No File)', clicked=self.on_file_btn)
submit = qtw.QPushButton('Submit Post', clicked=self.submit)
response = qtw.QTextEdit(readOnly=True)
for w in (self.url, self.table, self.fname, submit, response):
widget.layout().addWidget(w)
这是一个建立在QWidget对象上的简单表单。有一个用于 URL 的输入行,一个用于输入键值对的表格小部件,以及一个用于触发文件对话框并存储所选文件名的按钮。
之后,我们有一个用于发送请求的submit按钮和一个只读文本编辑框,用于显示返回的结果。
fname按钮在单击时调用on_file_btn(),其代码如下:
def on_file_btn(self):
filename, accepted = qtw.QFileDialog.getOpenFileName()
if accepted:
self.fname.setText(filename)
该方法只是调用QFileDialog函数来检索要打开的文件名。为了保持简单,我们采取了略微不正统的方法,将文件名存储为我们的QPushButton文本。
最后的MainWindow方法是submit(),当单击submit按钮时将调用该方法。在编写我们的 Web 后端之后,我们将回到该方法,因为它的操作取决于我们如何定义该后端。
POST 后端
我们的 Web 发布后端将基于QObject,这样我们就可以使用信号和槽。
首先通过子类化QObject并创建一个信号:
class Poster(qtc.QObject):
replyReceived = qtc.pyqtSignal(str)
当我们从服务器接收到我们正在发布的回复时,replyReceived信号将被发出,并携带回复的主体作为字符串。
现在让我们创建构造函数:
def __init__(self):
super().__init__()
self.nam = qtn.QNetworkAccessManager()
self.nam.finished.connect(self.on_reply)
在这里,我们正在创建我们的QNetworkAccessManager对象,并将其finished信号连接到名为on_reply()的本地方法。
on_reply()方法将如下所示:
def on_reply(self, reply):
reply_bytes = reply.readAll()
reply_string = bytes(reply_bytes).decode('utf-8')
self.replyReceived.emit(reply_string)
回想一下,finished信号携带一个QNetworkReply对象。我们可以调用它的readAll()方法来获取回复的主体作为QByteArray。就像我们对原始套接字数据所做的那样,我们首先将其转换为bytes对象,然后使用decode()方法将其转换为 UTF-8 Unicode 数据。最后,我们将使用来自服务器的字符串发出我们的replyReceived信号。
现在我们需要一个方法,实际上会将我们的键值数据和文件发布到 URL。我们将其称为make_request(),并从以下位置开始:
def make_request(self, url, data, filename):
self.request = qtn.QNetworkRequest(url)
与GET请求一样,我们首先从提供的 URL 创建一个QNetworkRequest对象。但与GET请求不同,我们的POST请求携带数据负载。为了携带这个负载,我们需要创建一个特殊的对象,可以与请求一起发送。
HTTP 请求可以以几种方式格式化数据负载,但通过 HTTP 传输文件的最常见方式是使用多部分表单请求。这种请求包含键值数据和字节编码的文件数据,是通过提交包含输入小部件和文件小部件混合的 HTML 表单获得的。
要在 PyQt 中执行这种请求,我们将首先创建一个QtNetwork.QHttpMultiPart对象,如下所示:
self.multipart = qtn.QHttpMultiPart(
qtn.QHttpMultiPart.FormDataType)
有不同类型的多部分 HTTP 消息,我们通过将QtNetwork.QHttpMultiPart.ContentType枚举常量传递给构造函数来定义我们想要的类型。我们在这里使用的是用于一起传输文件和表单数据的FormDataType类型。
HTTP 多部分对象是一个包含QHttpPart对象的容器,每个对象代表我们数据负载的一个组件。我们需要从传入此方法的数据创建这些部分,并将它们添加到我们的多部分对象中。
让我们从我们的键值对开始:
for key, value in (data or {}).items():
http_part = qtn.QHttpPart()
http_part.setHeader(
qtn.QNetworkRequest.ContentDispositionHeader,
f'form-data; name="{key}"'
)
http_part.setBody(value.encode('utf-8'))
self.multipart.append(http_part)
每个 HTTP 部分都有一个标头和一个主体。标头包含有关部分的元数据,包括其Content-Disposition—也就是它包含的内容。对于表单数据,那将是form-data。
因此,对于data字典中的每个键值对,我们正在创建一个单独的QHttpPart对象,将 Content-Disposition 标头设置为form-data,并将name参数设置为键。最后,我们将 HTTP 部分的主体设置为我们的值(编码为字节字符串),并将 HTTP 部分添加到我们的多部分对象中。
要包含我们的文件,我们需要做类似的事情:
if filename:
file_part = qtn.QHttpPart()
file_part.setHeader(
qtn.QNetworkRequest.ContentDispositionHeader,
f'form-data; name="attachment"; filename="{filename}"'
)
filedata = open(filename, 'rb').read()
file_part.setBody(filedata)
self.multipart.append(file_part)
这一次,我们的 Content-Disposition 标头仍然设置为form-data,但也包括一个filename参数,设置为我们文件的名称。HTTP 部分的主体设置为文件的内容。请注意,我们以rb模式打开文件,这意味着它的二进制内容将被读取为bytes对象,而不是将其解释为纯文本。这很重要,因为setBody()期望的是 bytes 而不是 Unicode。
现在我们的多部分对象已经构建好了,我们可以调用QNetworkAccessManager对象的post()方法来发送带有多部分数据的请求:
self.nam.post(self.request, self.multipart)
回到MainWindow.__init__(),让我们创建一个Poster对象来使用:
self.poster = Poster()
self.poster.replyReceived.connect(self.response.setText)
由于replyReceived将回复主体作为字符串发出,我们可以直接将其连接到响应小部件的setText上,以查看服务器的响应。
最后,是时候创建我们的submit()回调了:
def submit(self):
url = qtc.QUrl(self.url.text())
filename = self.fname.text()
if filename == '(No File)':
filename = None
data = {}
for rownum in range(self.table.rowCount()):
key_item = self.table.item(rownum, 0)
key = key_item.text() if key_item else None
if key:
data[key] = self.table.item(rownum, 1).text()
self.poster.make_request(url, data, filename)
请记住,make_request()需要QUrl、键值对的dict和文件名字符串;因此,这个方法只是遍历每个小部件,提取和格式化数据,然后将其传递给make_request()。
测试实用程序
如果您可以访问接受 POST 请求和文件上传的服务器,您可以使用它来测试您的脚本;如果没有,您也可以使用本章示例代码中包含的sample_http_server.py脚本。这个脚本只需要 Python 3 和标准库,它会将您的 POST 请求回显给您。
在控制台窗口中启动服务器脚本,然后在第二个控制台中运行您的poster.py脚本,并执行以下操作:
-
输入 URL 为
http://localhost:8000 -
向表中添加一些任意的键值对
-
选择要上传的文件(可能是一个不太大的文本文件,比如您的 Python 脚本之一)
-
点击提交帖子
您应该在服务器控制台窗口和 GUI 上的响应文本编辑中看到您请求的打印输出。它应该是这样的:

总之,使用QNetworkAccessManager处理POST请求涉及以下步骤:
-
创建
QNetworkAccessManager并将其finished信号连接到将处理QNetworkReply的方法 -
创建指向目标 URL 的
QNetworkRequest -
创建数据有效负载对象,比如
QHttpMultiPart对象 -
将请求和数据有效负载传递给
QNetworkAccessManager对象的post()方法
总结
在本章中,我们探讨了如何将我们的 PyQt 应用程序连接到网络。您学会了如何使用套接字进行低级编程,包括 UDP 广播应用程序和 TCP 客户端-服务器应用程序。您还学会了如何使用QNetworkAccessManager与 HTTP 服务进行交互,从简单的下载到复杂的多部分表单和文件数据上传。
下一章将探讨使用 SQL 数据库存储和检索数据。您将学习如何构建和查询 SQL 数据库,如何使用QtSQL模块将 SQL 命令集成到您的应用程序中,以及如何使用 SQL 模型视图组件快速构建数据驱动的 GUI 应用程序。
问题
尝试这些问题来测试您从本章中学到的知识:
-
您正在设计一个应用程序,该应用程序将向本地网络发出状态消息,您将使用管理员工具进行监视。哪种类型的套接字对象是一个不错的选择?
-
你的 GUI 类有一个名为
self.socket的QTcpSocket对象。你已经将它的readyRead信号连接到以下方法,但它不起作用。发生了什么,你该如何修复它?
def on_ready_read(self):
while self.socket.hasPendingDatagrams():
self.process_data(self.socket.readDatagram())
-
使用
QTcpServer来实现一个简单的服务,监听端口8080,并打印接收到的任何请求。让它用你选择的字节字符串回复客户端。 -
你正在为你的应用程序创建一个下载函数,用于获取一个大数据文件以导入到你的应用程序中。代码不起作用。阅读代码并决定你做错了什么:
def download(self, url):
self.manager = qtn.QNetworkAccessManager(
finished=self.on_finished)
self.request = qtn.QNetworkRequest(qtc.QUrl(url))
reply = self.manager.get(self.request)
with open('datafile.dat', 'wb') as fh:
fh.write(reply.readAll())
- 修改你的
poster.py脚本,以便将键值数据发送为 JSON,而不是 HTTP 表单数据。
进一步阅读
欲了解更多信息,请参考以下内容:
-
有关数据报包结构的更多信息,请参阅
en.wikipedia.org/wiki/Datagram。 -
随着对网络通信中安全和隐私的关注不断增加,了解如何使用 SSL 是很重要的。请参阅
doc.qt.io/qt-5/ssl.html了解使用 SSL 的QtNetwork工具的概述。 -
Mozilla 开发者网络在
developer.mozilla.org/en-US/docs/Web/HTTP上有大量资源,用于理解 HTTP 及其各种标准和协议。
第九章:使用 Qt SQL 探索 SQL
大约 40 年来,使用结构化查询语言(通常称为 SQL)管理的关系数据库一直是存储、检索和分析世界数据的事实标准技术。无论您是创建业务应用程序、游戏、Web 应用程序还是其他应用,如果您的应用处理大量数据,您几乎肯定会使用 SQL。虽然 Python 有许多可用于连接到 SQL 数据库的模块,但 Qt 的QtSql模块为我们提供了强大和方便的类,用于将 SQL 数据集成到 PyQt 应用程序中。
在本章中,您将学习如何构建基于数据库的 PyQt 应用程序,我们将涵盖以下主题:
-
SQL 基础知识
-
使用 Qt 执行 SQL 查询
-
使用模型视图小部件与 SQL
技术要求
除了您自第一章以来一直在使用的基本设置,开始使用 PyQt,您还需要在 GitHub 存储库中找到的示例代码,网址为github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter09。
您可能还会发现拥有SQLite的副本对练习 SQL 示例很有帮助。SQLite 是免费的,可以从sqlite.org/download.html下载。
查看以下视频,了解代码的实际操作:bit.ly/2M5xu1r
SQL 基础知识
在我们深入了解QtSql提供的内容之前,您需要熟悉 SQL 的基础知识。本节将为您快速概述如何在 SQL 数据库中创建、填充、更改和查询数据。如果您已经了解 SQL,您可能希望跳到本章的 PyQt 部分。
SQL 在语法和结构上与 Python 非常不同。它是一种声明式语言,意味着我们描述我们想要的结果,而不是用于获得结果的过程。与 SQL 数据库交互时,我们执行语句。每个语句由一个 SQL命令和一系列子句组成,每个子句进一步描述所需的结果。语句以分号结束。
尽管 SQL 是标准化的,但所有 SQL 数据库实现都提供其自己的对标准语言的修改和扩展。我们将学习 SQL 的 SQLite 方言,它与标准 SQL 相当接近。
与 Python 不同,SQL 通常是不区分大小写的语言;但是,长期以来,将 SQL 关键字写成大写字母是一种惯例。这有助于它们与数据和对象名称区分开。我们将在本书中遵循这个惯例,但对于您的代码来说是可选的。
创建表
SQL 数据库由关系组成,也称为表。表是由行和列组成的二维数据结构。表中的每一行代表我们拥有信息的单个项目,每一列代表我们正在存储的信息类型。
使用CREATE TABLE命令定义表,如下所示:
CREATE TABLE coffees (
id INTEGER PRIMARY KEY,
coffee_brand TEXT NOT NULL,
coffee_name TEXT NOT NULL,
UNIQUE(coffee_brand, coffee_name)
);
CREATE TABLE语句后面跟着表名和列定义列表。在这个例子中,coffees是我们正在创建的表的名称,列定义在括号内。每一列都有一个名称,一个数据类型,以及描述有效值的任意数量的约束。
在这种情况下,我们有三列:
-
id是一个整数列。它被标记为主键,这意味着它将是一个可以用来标识行的唯一值。 -
coffee_brand和coffee_name都是文本列,具有NOT NULL约束,这意味着它们不能有NULL值。
约束也可以在多个列上定义。在字段后添加的UNIQUE约束不是字段,而是一个表级约束,确保每行的coffee _brand和coffee _name的组合对于每行都是唯一的。
NULL是 SQL 中 Python 的None的等价物。它表示信息的缺失。
SQL 数据库至少支持文本、数字、日期、时间和二进制对象数据类型;但不少数据库实现会通过扩展 SQL 来支持额外的数据类型,比如货币或 IP 地址类型。许多数据库还有数字类型的SMALL和BIG变体,允许开发人员微调列使用的存储空间。
尽管简单的二维表很有用,但 SQL 数据库的真正威力在于将多个相关表连接在一起,例如:
CREATE TABLE roasts (
id INTEGER PRIMARY KEY,
description TEXT NOT NULL UNIQUE,
color TEXT NOT NULL UNIQUE
);
CREATE TABLE coffees (
id INTEGER PRIMARY KEY,
coffee_brand TEXT NOT NULL,
coffee_name TEXT NOT NULL,
roast_id INTEGER REFERENCES roasts(id),
UNIQUE(coffee_brand, coffee_name)
);
CREATE TABLE reviews (
id INTEGER PRIMARY KEY,
coffee_id REFERENCES coffees(id),
reviewer TEXT NOT NULL,
review_date DATE NOT NULL DEFAULT CURRENT_DATE,
review TEXT NOT NULL
);
coffees中的roast_id列保存与roasts的主键匹配的值,如REFERENCES约束所示。每个coffees记录不需要在每条咖啡记录中重写烘焙的描述和颜色,而是简单地指向roasts中保存有关该咖啡烘焙信息的行。同样,reviews表包含coffee_id列,它指向一个单独的coffees条目。这些关系称为外键关系,因为该字段引用另一个表的键。
在多个相关表中对数据进行建模可以减少重复,并强制执行数据一致性。想象一下,如果所有三个表中的数据合并成一张咖啡评论表,那么同一款咖啡产品的两条评论可能会指定不同的烘焙程度。这是不可能的,而且在关系型数据表中也不会发生。
插入和更新数据
创建表后,我们可以使用INSERT语句添加新的数据行,语法如下:
INSERT INTO table_name(column1, column2, ...)
VALUES (value1, value2, ...), (value3, value4, ...);
例如,让我们向roasts中插入一些行:
INSERT INTO roasts(description, color) VALUES
('Light', '#FFD99B'),
('Medium', '#947E5A'),
('Dark', '#473C2B'),
('Burnt to a Crisp', '#000000');
在这个例子中,我们为roasts表中的每条新记录提供了description和color值。VALUES子句包含一个元组列表,每个元组代表一行数据。这些元组中的值的数量和数据类型必须与指定的列的数量和数据类型匹配。
请注意,我们没有包括所有的列——id缺失。我们在INSERT语句中不指定的任何字段都将获得默认值,除非我们另有规定,否则默认值为NULL。
在 SQLite 中,INTEGER PRIMARY KEY字段具有特殊行为,其默认值在每次插入时自动递增。因此,此查询产生的id值将为1(Light),2(Medium),3(Dark)和4(Burnt to a Crisp)。
这一点很重要,因为我们需要该键值来插入记录到我们的coffees表中:
INSERT INTO coffees(coffee_brand, coffee_name, roast_id) VALUES
('Dumpy''s Donuts', 'Breakfast Blend', 2),
('Boise''s Better than Average', 'Italian Roast', 3),
('Strawbunks', 'Sumatra', 2),
('Chartreuse Hillock', 'Pumpkin Spice', 1),
('Strawbunks', 'Espresso', 3),
('9 o''clock', 'Original Decaf', 2);
与 Python 不同,SQL 字符串文字必须只使用单引号。双引号字符串被解释为数据库对象的名称,比如表或列。要在字符串中转义单引号,请使用两个单引号,就像我们在前面的查询中所做的那样。
由于我们的外键约束,不可能在coffees中插入包含不存在于roasts中的roast_id的行。例如,这将返回一个错误:
INSERT INTO coffees(coffee_brand, coffee_name, roast_id) VALUES
('Minwell House', 'Instant', 48);
请注意,我们可以在roast_id字段中插入NULL;除非该列被定义为NOT NULL约束,否则NULL是唯一不需要遵守外键约束的值。
更新现有行
要更新表中的现有行,您可以使用UPDATE语句,如下所示:
UPDATE coffees SET roast_id = 4 WHERE id = 2;
SET子句后面是要更改的字段的值分配列表,WHERE子句描述了必须为真的条件,如果要更新特定行。在这种情况下,我们将把id列为2的记录的roast_id列的值更改为4。
SQL 使用单个等号来进行赋值和相等操作。它永远不会使用 Python 使用的双等号。
更新操作也可以影响多条记录,就像这样:
UPDATE coffees SET roast_id = roast_id + 1
WHERE coffee_brand LIKE 'Strawbunks';
在这种情况下,我们通过将Strawbunks咖啡的所有roast_id值增加 1 来增加。每当我们在查询中引用列的值时,该值将是同一行中的列的值。
选择数据
SQL 中最重要的操作可能是SELECT语句,用于检索数据。一个简单的SELECT语句看起来像这样:
SELECT reviewer, review_date
FROM reviews
WHERE review_date > '2019-03-01'
ORDER BY reviewer DESC;
SELECT命令后面跟着一个字段列表,或者跟着*符号,表示所有字段。FROM子句定义了数据的来源;在这种情况下,是reviews表。WHERE子句再次定义了必须为真的条件才能包括行。在这种情况下,我们只包括比 2019 年 3 月 1 日更新的评论,通过比较每行的review_date字段(它是一个DATE类型)和字符串'2019-03-01'(SQLite 将其转换为DATE以进行比较)。最后,ORDER BY子句确定了结果集的排序方式。
表连接
SELECT语句总是返回一个值表。即使你的结果集只有一个值,它也会在一个行和一列的表中,而且没有办法从一个查询中返回多个表。然而,我们可以通过将数据合并成一个表来从多个表中提取数据。
这可以在FROM子句中使用JOIN来实现,例如:
SELECT coffees.coffee_brand,
coffees.coffee_name,
roasts.description AS roast,
COUNT(reviews.id) AS reviews
FROM coffees
JOIN roasts ON coffees.roast_id = roasts.id
LEFT OUTER JOIN reviews ON reviews.coffee_id = coffees.id
GROUP BY coffee_brand, coffee_name, roast
ORDER BY reviews DESC;
在这种情况下,我们的FROM子句包含两个JOIN语句。第一个将coffees与roasts通过匹配coffees中的roast_id字段和roasts中的id字段进行连接。第二个通过匹配reviews表中的coffee_id列和coffees表中的id列进行连接。
连接略有不同:请注意reviews连接是一个LEFT OUTER JOIN。这意味着我们包括了coffees中没有任何匹配reviews记录的行;默认的JOIN是一个INNER连接,意味着只有在两个表中都有匹配记录的行才会显示。
在这个查询中,我们还使用了一个聚合函数,COUNT()。COUNT()函数只是计算匹配的行数。聚合函数要求我们指定一个GROUP BY子句,列出将作为聚合基础的字段。换句话说,对于每个coffee_brand、coffee_name和roast的唯一组合,我们将得到数据库中评论记录的总数。其他标准的聚合函数包括SUM(用于对所有匹配值求和)、MIN(返回所有匹配值的最小值)和MAX(返回所有匹配值的最大值)。不同的数据库实现还包括它们自己的自定义聚合函数。
SQL 子查询
SELECT语句可以通过将其放在括号中嵌入到另一个 SQL 语句中。这被称为子查询。它可以嵌入的确切位置取决于查询预期返回的数据类型:
-
如果语句将返回一个单行单列,它可以嵌入到期望单个值的任何地方
-
如果语句将返回一个单列多行,它可以嵌入到期望值列表的任何地方
-
如果语句将返回多行多列,它可以嵌入到期望值表的任何地方
考虑这个查询:
SELECT coffees.coffee_brand, coffees.coffee_name
FROM coffees
JOIN (
SELECT * FROM roasts WHERE id > (
SELECT id FROM roasts WHERE description = 'Medium'
)) AS dark_roasts
ON coffees.roast_id = dark_roasts.id
WHERE coffees.id IN (
SELECT coffee_id FROM reviews WHERE reviewer = 'Maxwell');
这里有三个子查询。第一个位于FROM子句中:
(SELECT * FROM roasts WHERE id > (
SELECT id FROM roasts WHERE description = 'Medium'
)) AS dark_roasts
因为它以SELECT *开头,我们可以确定它将返回一个数据表(或者没有数据,但这不重要)。因此,它可以在FROM子句中使用,因为这里期望一个表。请注意,我们需要使用AS关键字给子查询一个名称。在FROM子句中使用子查询时,这是必需的。
这个子查询包含了它自己的子查询:
SELECT id FROM roasts WHERE description = 'Medium'
这个查询很可能会给我们一个单一的值,所以我们在期望得到单一值的地方使用它;在这种情况下,作为大于表达式的操作数。如果由于某种原因,这个查询返回了多行,我们的查询将会返回一个错误。
我们最终的子查询在WHERE子句中:
SELECT coffee_id FROM reviews WHERE reviewer = 'Maxwell'
这个表达式保证只返回一列,但可能返回多行。因此,我们将其用作IN关键字的参数,该关键字期望一个值列表。
子查询很强大,但如果我们对数据的假设不正确,有时也会导致减速和错误。
学习更多
我们在这里只是简单地介绍了 SQL 的基础知识,但这应该足够让您开始创建和使用简单的数据库,并涵盖了本章中将要使用的 SQL。在本章末尾的进一步阅读部分中,您将看到如何将 SQL 知识与 PyQt 结合起来创建数据驱动的应用程序。
使用 Qt 执行 SQL 查询
使用不同的 SQL 实现可能会令人沮丧:不仅 SQL 语法有细微差异,而且用于连接它们的 Python 库在它们实现的各种方法上经常不一致。虽然在某些方面,它不如更知名的 Python SQL 库方便,但QtSQL确实为我们提供了一种一致的抽象 API,以一致的方式处理各种数据库产品。正确利用时,它还可以为我们节省大量代码。
为了学习如何在 PyQt 中处理 SQL 数据,我们将为本章SQL 基础中创建的咖啡数据库构建一个图形前端。
可以使用以下命令从示例代码创建完整版本的数据库:
$ sqlite3 coffee.db -init coffee.sql。在前端工作之前,您需要创建这个数据库文件。
构建一个表单
我们的咖啡数据库有三个表:咖啡产品列表、烘焙列表和产品评论表。我们的 GUI 将设计如下:
-
它将有一个咖啡品牌和产品列表
-
当我们双击列表中的项目时,它将打开一个表单,显示关于咖啡的所有信息,以及与该产品相关的所有评论
-
它将允许我们添加新产品和新评论,或编辑任何现有信息
让我们首先从第四章中复制您的基本 PyQt 应用程序模板,使用 QMainWindow 构建应用程序,保存为coffee_list1.py。然后,像这样添加一个QtSQL的导入:
from PyQt5 import QtSql as qts
现在我们要创建一个表单,显示关于我们的咖啡产品的信息。基本表单如下:
class CoffeeForm(qtw.QWidget):
def __init__(self, roasts):
super().__init__()
self.setLayout(qtw.QFormLayout())
self.coffee_brand = qtw.QLineEdit()
self.layout().addRow('Brand: ', self.coffee_brand)
self.coffee_name = qtw.QLineEdit()
self.layout().addRow('Name: ', self.coffee_name)
self.roast = qtw.QComboBox()
self.roast.addItems(roasts)
self.layout().addRow('Roast: ', self.roast)
self.reviews = qtw.QTableWidget(columnCount=3)
self.reviews.horizontalHeader().setSectionResizeMode(
2, qtw.QHeaderView.Stretch)
self.layout().addRow(self.reviews)
这个表单有品牌、名称和咖啡烘焙的字段,以及一个用于显示评论的表格小部件。请注意,构造函数需要roasts,这是一个咖啡烘焙的列表,用于组合框;我们希望从数据库中获取这些,而不是将它们硬编码到表单中,因为新的烘焙可能会被添加到数据库中。
这个表单还需要一种方法来显示咖啡产品。让我们创建一个方法,它将获取咖啡数据并对其进行审查,并用它填充表单:
def show_coffee(self, coffee_data, reviews):
self.coffee_brand.setText(coffee_data.get('coffee_brand'))
self.coffee_name.setText(coffee_data.get('coffee_name'))
self.roast.setCurrentIndex(coffee_data.get('roast_id'))
self.reviews.clear()
self.reviews.setHorizontalHeaderLabels(
['Reviewer', 'Date', 'Review'])
self.reviews.setRowCount(len(reviews))
for i, review in enumerate(reviews):
for j, value in enumerate(review):
self.reviews.setItem(i, j, qtw.QTableWidgetItem(value))
这个方法假设coffee_data是一个包含品牌、名称和烘焙 ID 的dict对象,而reviews是一个包含评论数据的元组列表。它只是遍历这些数据结构,并用数据填充每个字段。
在MainWindow.__init__()中,让我们开始主 GUI:
self.stack = qtw.QStackedWidget()
self.setCentralWidget(self.stack)
我们将使用QStackedWidget在我们的咖啡列表和咖啡表单小部件之间进行切换。请记住,这个小部件类似于QTabWidget,但没有选项卡。
在我们可以构建更多 GUI 之前,我们需要从数据库中获取一些信息。让我们讨论如何使用QtSQL连接到数据库。
连接和进行简单查询
要使用QtSQL与 SQL 数据库,我们首先必须建立连接。这有三个步骤:
-
创建连接对象
-
配置连接对象
-
打开连接
在MainWindow.__init__()中,让我们创建我们的数据库连接:
self.db = qts.QSqlDatabase.addDatabase('QSQLITE')
我们不是直接创建QSqlDatabase对象,而是通过调用静态的addDatabase方法创建一个,其中包含我们将要使用的数据库驱动程序的名称。在这种情况下,我们使用的是 Qt 的 SQLite3 驱动程序。Qt 5.12 内置了九个驱动程序,包括 MySQL(QMYSQL)、PostgreSQL(QPSQL)和 ODBC 连接(包括 Microsoft SQL Server)(QODBC)。完整的列表可以在doc.qt.io/qt-5/qsqldatabase.html#QSqlDatabase-2找到。
一旦我们的数据库对象创建好了,我们需要用任何必需的连接设置来配置它,比如主机、用户、密码和数据库名称。对于 SQLite,我们只需要指定一个文件名,如下所示:
self.db.setDatabaseName('coffee.db')
我们可以配置的一些属性包括以下内容:
-
hostName—数据库服务器的主机名或 IP -
port—数据库服务侦听的网络端口 -
userName—连接的用户名 -
password—用于身份验证的密码 -
connectOptions—附加连接选项的字符串
所有这些都可以使用通常的访问器方法进行配置或查询(例如hostName()和setHostName())。如果你使用的是 SQLite 之外的其他东西,请查阅其文档,看看你需要配置哪些设置。
连接对象配置好之后,我们可以使用open()方法打开连接。这个方法返回一个布尔值,表示连接是否成功。如果失败,我们可以通过检查连接对象的lastError属性来找出失败的原因。
这段代码演示了我们可能会这样做:
if not self.db.open():
error = self.db.lastError().text()
qtw.QMessageBox.critical(
None, 'DB Connection Error',
'Could not open database file: '
f'{error}')
sys.exit(1)
在这里,我们调用self.db.open(),如果失败,我们从lastError中检索错误并在对话框中显示它。lastError()调用返回一个QSqlError对象,其中包含有关错误的数据和元数据;要提取实际的错误文本,我们调用它的text()方法。
获取有关数据库的信息
一旦我们的连接实际连接上了,我们就可以使用它来开始检查数据库。例如,tables()方法列出数据库中的所有表。我们可以使用这个方法来检查所有必需的表是否存在,例如:
required_tables = {'roasts', 'coffees', 'reviews'}
tables = self.db.tables()
missing_tables = required_tables - set(tables)
if missing_tables:
qtw.QMessageBox.critica(
None, 'DB Integrity Error'
'Missing tables, please repair DB: '
f'{missing_tables}')
sys.exit(1)
在这里,我们比较数据库中存在的表和必需表的集合。如果我们发现任何缺失,我们将显示错误并退出。
set对象类似于列表,不同之处在于其中的所有项目都是唯一的,并且它们允许进行一些有用的比较。在这种情况下,我们正在减去集合以找出required_tables中是否有任何不在tables中的项目。
进行简单的查询
与我们的 SQL 数据库交互依赖于QSqlQuery类。这个类表示对 SQL 引擎的请求,可以用来准备、执行和检索有关查询的数据和元数据。
我们可以使用数据库对象的exec()方法向数据库发出 SQL 查询:
query = self.db.exec('SELECT count(*) FROM coffees')
exec()方法从我们的字符串创建一个QSqlQuery对象,执行它,并将其返回给我们。然后我们可以从query对象中检索我们查询的结果:
query.next()
count = query.value(0)
print(f'There are {count} coffees in the database.')
重要的是要对这里发生的事情有一个心理模型,因为这并不是非常直观的。正如你所知,SQL 查询总是返回一张数据表,即使只有一行和一列。QSqlQuery有一个隐式的游标,它将指向数据的一行。最初,这个游标指向无处,但调用next()方法将它移动到下一个可用的数据行,这种情况下是第一行。然后使用value()方法来检索当前选定行中给定列的值(value(0)将检索第一列,value(1)将检索第二列,依此类推)。
所以,这里发生的情况类似于这样:
-
查询被执行并填充了数据。游标指向无处。
-
我们调用
next()将光标指向第一行。 -
我们调用
value(0)来检索行的第一列的值。
要从QSqlQuery对象中检索数据列表或表,我们只需要重复最后两个步骤,直到next()返回False(表示没有下一行要指向)。例如,我们需要一个咖啡烘焙的列表来填充我们的表单,所以让我们检索一下:
query = self.db.exec('SELECT * FROM roasts ORDER BY id')
roasts = []
while query.next():
roasts.append(query.value(1))
在这种情况下,我们要求查询从roasts表中获取所有数据,并按id排序。然后,我们在查询对象上调用next(),直到它返回False;每次,提取第二个字段的值(query.value(1))并将其附加到我们的roasts列表中。
现在我们有了这些数据,我们可以创建我们的CoffeeForm并将其添加到应用程序中:
self.coffee_form = CoffeeForm(roasts)
self.stack.addWidget(self.coffee_form)
除了使用value()检索值之外,我们还可以通过调用record()方法来检索整行。这将返回一个包含当前行数据的QSqlRecord对象(如果没有指向任何行,则返回一个空记录)。我们将在本章后面使用QSqlRecord。
准备好的查询
很多时候,数据需要从应用程序传递到 SQL 查询中。例如,我们需要编写一个方法,通过 ID 号查找单个咖啡,以便我们可以在我们的表单中显示它。
我们可以开始编写该方法,就像这样:
def show_coffee(self, coffee_id):
query = self.db.exec(f'SELECT * FROM coffees WHERE id={coffee_id}')
在这种情况下,我们使用格式化字符串直接将coffee_id的值放入我们的查询中。不要这样做!
使用字符串格式化或连接构建 SQL 查询可能会导致所谓的SQL 注入漏洞,其中传递一个特制的值可能会暴露或破坏数据库中的数据。在这种情况下,我们假设coffee_id将是一个整数,但假设一个恶意用户能够向这个函数发送这样的字符串:
0; DELETE FROM coffees;
我们的字符串格式化将评估这一点,并生成以下 SQL 语句:
SELECT * FROM coffees WHERE id=0; DELETE FROM coffees;
结果将是我们的coffees表中的所有行都将被删除!虽然在这种情况下可能看起来微不足道或荒谬,但 SQL 注入漏洞是许多数据泄露和黑客丑闻背后的原因,这些你在新闻中读到的。在处理重要数据时(还有比咖啡更重要的东西吗?),保持防御是很重要的。
执行此查询并保护数据库免受此类漏洞的正确方法是使用准备好的查询。准备好的查询是一个包含我们可以绑定值的变量的查询。数据库驱动程序将适当地转义我们的值,以便它们不会被意外地解释为 SQL 代码。
这个版本的代码使用了一个准备好的查询:
query1 = qts.QSqlQuery(self.db)
query1.prepare('SELECT * FROM coffees WHERE id=:id')
query1.bindValue(':id', coffee_id)
query1.exec()
在这里,我们明确地创建了一个连接到我们的数据库的空QSqlQuery对象。然后,我们将 SQL 字符串传递给prepare()方法。请注意我们查询中使用的:id字符串;冒号表示这是一个变量。一旦我们有了准备好的查询,我们就可以开始将查询中的变量绑定到我们代码中的变量,使用bindValue()。在这种情况下,我们将:id SQL 变量绑定到我们的coffee_id Python 变量。
一旦我们的查询准备好并且变量被绑定,我们调用它的exec()方法来执行它。
一旦执行,我们可以从查询对象中提取数据,就像以前做过的那样:
query1.next()
coffee = {
'id': query1.value(0),
'coffee_brand': query1.value(1),
'coffee_name': query1.value(2),
'roast_id': query1.value(3)
}
让我们尝试相同的方法来检索咖啡的评论数据:
query2 = qts.QSqlQuery()
query2.prepare('SELECT * FROM reviews WHERE coffee_id=:id')
query2.bindValue(':id', coffee_id)
query2.exec()
reviews = []
while query2.next():
reviews.append((
query2.value('reviewer'),
query2.value('review_date'),
query2.value('review')
))
请注意,这次我们没有将数据库连接对象传递给QSqlQuery构造函数。由于我们只有一个连接,所以不需要将数据库连接对象传递给QSqlQuery;QtSQL将自动在任何需要数据库连接的方法调用中使用我们的默认连接。
还要注意,我们使用列名而不是它们的编号从我们的reviews表中获取值。这同样有效,并且是一个更友好的方法,特别是在有许多列的表中。
我们将通过填充和显示我们的咖啡表单来完成这个方法:
self.coffee_form.show_coffee(coffee, reviews)
self.stack.setCurrentWidget(self.coffee_form)
请注意,准备好的查询只能将值引入查询中。例如,您不能准备这样的查询:
query.prepare('SELECT * from :table ORDER BY :column')
如果您想构建包含可变表或列名称的查询,不幸的是,您将不得不使用字符串格式化。在这种情况下,请注意可能出现 SQL 注入的潜在风险,并采取额外的预防措施,以确保被插入的值是您认为的值。
使用 QSqlQueryModel
手动将数据填充到表小部件中似乎是一项繁琐的工作;如果您回忆起第五章,使用模型视图类创建数据接口,Qt 为我们提供了可以为我们完成繁琐工作的模型视图类。我们可以对QAbstractTableModel进行子类化,并创建一个从 SQL 查询中填充的模型,但幸运的是,QtSql已经以QSqlQueryModel的形式提供了这个功能。
正如其名称所示,QSqlQueryModel是一个使用 SQL 查询作为数据源的表模型。我们将使用它来创建我们的咖啡产品列表,就像这样:
coffees = qts.QSqlQueryModel()
coffees.setQuery(
"SELECT id, coffee_brand, coffee_name AS coffee "
"FROM coffees ORDER BY id")
创建模型后,我们将其query属性设置为 SQL SELECT语句。模型的数据将从此查询返回的表中获取。
与QSqlQuery一样,我们不需要显式传递数据库连接,因为只有一个。如果您有多个活动的数据库连接,您应该将要使用的连接传递给QSqlQueryModel()。
一旦我们有了模型,我们就可以在QTableView中使用它,就像这样:
self.coffee_list = qtw.QTableView()
self.coffee_list.setModel(coffees)
self.stack.addWidget(self.coffee_list)
self.stack.setCurrentWidget(self.coffee_list)
就像我们在第五章中所做的那样,使用模型视图类创建数据接口,我们创建了QTableView并将模型传递给其setModel()方法。然后,我们将表视图添加到堆叠小部件中,并将其设置为当前可见的小部件。
默认情况下,表视图将使用查询的列名作为标题标签。我们可以通过使用模型的setHeaderData()方法来覆盖这一点,就像这样:
coffees.setHeaderData(1, qtc.Qt.Horizontal, 'Brand')
coffees.setHeaderData(2, qtc.Qt.Horizontal, 'Product')
请记住,QSqlQueryModel对象处于只读模式,因此无法将此表视图设置为可编辑,以便更改关于我们咖啡列表的详细信息。我们将在下一节中看看如何使用可编辑的 SQL 模型,在没有 SQL 的情况下使用模型视图小部件。不过,首先让我们完成我们的 GUI。
完成 GUI
现在我们的应用程序既有列表又有表单小部件,让我们在它们之间启用一些导航。首先,创建一个工具栏按钮,用于从咖啡表单切换到列表:
navigation = self.addToolBar("Navigation")
navigation.addAction(
"Back to list",
lambda: self.stack.setCurrentWidget(self.coffee_list))
接下来,我们将配置我们的列表,以便双击项目将显示包含该咖啡记录的咖啡表单。请记住,我们的MainView.show_coffee()方法需要咖啡的id值,但列表小部件的itemDoubleClicked信号携带了点击的模型索引。让我们在MainView上创建一个方法来将一个转换为另一个:
def get_id_for_row(self, index):
index = index.siblingAtColumn(0)
coffee_id = self.coffee_list.model().data(index)
return coffee_id
由于id在模型的列0中,我们使用siblingAtColumn(0)从被点击的任意行中检索列0的索引。然后我们可以通过将该索引传递给model().data()来检索id值。
现在我们有了这个,让我们为itemDoubleClicked信号添加一个连接:
self.coffee_list.doubleClicked.connect(
lambda x: self.show_coffee(self.get_id_for_row(x)))
在这一点上,我们对我们的咖啡数据库有一个简单的只读应用程序。我们当然可以继续使用当前的 SQL 查询方法来管理我们的数据,但 Qt 提供了一种更优雅的方法。我们将在下一节中探讨这种方法。
在没有 SQL 的情况下使用模型视图小部件
在上一节中使用了QSqlQueryModel之后,您可能会想知道这种方法是否可以进一步泛化,直接访问表并避免完全编写 SQL 查询。您可能还想知道我们是否可以避开QSqlQueryModel的只读限制。对于这两个问题的答案都是是,这要归功于QSqlTableModel和QSqlRelationalTableModels。
要了解这些是如何工作的,让我们回到应用程序的起点重新开始:
- 从一个新的模板副本开始,将其命名为
coffee_list2.py。添加QtSql的导入和第一个应用程序中的数据库连接代码。现在让我们开始使用表模型构建。对于简单的情况,我们想要从单个数据库表创建模型,我们可以使用QSqlTableModel:
self.reviews_model = qts.QSqlTableModel()
self.reviews_model.setTable('reviews')
reviews_model现在是reviews表的可读/写表模型。就像我们在第五章中使用 CSV 表模型编辑 CSV 文件一样,我们可以使用这个模型来查看和编辑reviews表。对于需要从连接表中查找值的表,我们可以使用QSqlRelationalTableModel:
self.coffees_model = qts.QSqlRelationalTableModel()
self.coffees_model.setTable('coffees')
- 再一次,我们有一个可以用来查看和编辑 SQL 表中数据的表模型;这次是
coffees表。但是,coffees表有一个引用roasts表的roast_id列。roast_id对应于应用程序用户没有意义,他们更愿意使用烘焙的description列。为了在我们的模型中用roasts.description替换roast_id,我们可以使用setRelation()函数将这两个表连接在一起,就像这样:
self.coffees_model.setRelation(
self.coffees_model.fieldIndex('roast_id'),
qts.QSqlRelation('roasts', 'id', 'description')
)
这个方法接受两个参数。第一个是我们要连接的主表的列号,我们可以使用模型的fieldIndex()方法按名称获取。第二个是QSqlRelation对象,它表示外键关系。它所需的参数是表名(roasts),连接表中的相关列(roasts.id),以及此关系的显示字段(description)。
设置这种关系的结果是,我们的表视图将使用与roasts中的description列相关的值,而不是roast_id值,当我们将coffee_model连接到视图时。
- 在我们可以将模型连接到视图之前,我们需要再走一步:
self.mapper.model().select()
每当我们配置或重新配置QSqlTableModel或QSqlRelationalTableModel时,我们必须调用它的select()方法。这会导致模型生成并运行 SQL 查询,以刷新其数据并使其可用于视图。
- 现在我们的模型准备好了,我们可以在视图中尝试一下:
self.coffee_list = qtw.QTableView()
self.coffee_list.setModel(self.coffees_model)
- 在这一点上运行程序,您应该会得到类似这样的东西:

请注意,由于我们的关系表模型,我们有一个包含烘焙描述的description列,而不是roast_id列。正是我们想要的。
还要注意,在这一点上,您可以查看和编辑咖啡列表中的任何值。QSqlRelationalTableModel默认是可读/写的,我们不需要对视图进行任何调整来使其可编辑。但是,它可能需要一些改进。
代理和数据映射
虽然我们可以编辑列表,但我们还不能添加或删除列表中的项目;在继续进行咖啡表单之前,让我们添加这个功能。
首先创建一些指向MainView方法的工具栏操作:
toolbar = self.addToolBar('Controls')
toolbar.addAction('Delete Coffee(s)', self.delete_coffee)
toolbar.addAction('Add Coffee', self.add_coffee)
现在我们将为这些操作编写MainView方法:
def delete_coffee(self):
selected = self.coffee_list.selectedIndexes()
for index in selected or []:
self.coffees_model.removeRow(index.row())
def add_coffee(self):
self.stack.setCurrentWidget(self.coffee_list)
self.coffees_model.insertRows(
self.coffees_model.rowCount(), 1)
要从模型中删除一行,我们可以调用其removeRow()方法,传入所需的行号。这可以从selectedIndexes属性中获取。要添加一行,我们调用模型的insertRows()方法。这段代码应该很熟悉,来自第五章,使用模型-视图类创建数据接口。
现在,如果您运行程序并尝试添加一行,注意您基本上会得到一个QLineEdit,用于在每个单元格中输入数据。这对于咖啡品牌和产品名称等文本字段来说是可以的,但对于烘焙描述,更合理的是使用一些限制我们使用正确值的东西,比如下拉框。
在 Qt 的模型-视图系统中,决定为数据绘制什么小部件的对象称为代理。代理是视图的属性,通过设置我们自己的代理对象,我们可以控制数据的呈现方式以进行查看或编辑。
在由QSqlRelationalTableModel支持的视图的情况下,我们可以利用一个名为QSqlRelationalDelegate的现成委托,如下所示:
self.coffee_list.setItemDelegate(qts.QSqlRelationalDelegate())
QSqlRelationalDelegate自动为已设置QSqlRelation的任何字段提供组合框。通过这个简单的更改,您应该发现description列现在呈现为一个组合框,其中包含来自roasts表的可用描述值。好多了!
数据映射
现在我们的咖啡列表已经很完善了,是时候处理咖啡表单了,这将允许我们显示和编辑单个产品及其评论的详细信息
让我们从表单的咖啡详情部分的 GUI 代码开始:
class CoffeeForm(qtw.QWidget):
def __init__(self, coffees_model, reviews_model):
super().__init__()
self.setLayout(qtw.QFormLayout())
self.coffee_brand = qtw.QLineEdit()
self.layout().addRow('Brand: ', self.coffee_brand)
self.coffee_name = qtw.QLineEdit()
self.layout().addRow('Name: ', self.coffee_name)
self.roast = qtw.QComboBox()
self.layout().addRow('Roast: ', self.roast)
表单的这一部分是我们在咖啡列表中显示的完全相同的信息,只是现在我们使用一系列不同的小部件来显示单个记录。将我们的coffees表模型连接到视图是直接的,但是我们如何将模型连接到这样的表单呢?一个答案是使用QDataWidgetMapper对象。
QDataWidgetMapper的目的是将模型中的字段映射到表单中的小部件。为了了解它是如何工作的,让我们将一个添加到CoffeeForm中:
self.mapper = qtw.QDataWidgetMapper(self)
self.mapper.setModel(coffees_model)
self.mapper.setItemDelegate(
qts.QSqlRelationalDelegate(self))
映射器位于模型和表单字段之间,将它们之间的列进行转换。为了确保数据从表单小部件正确写入到模型中的关系字段,我们还需要设置适当类型的itemDelegate,在这种情况下是QSqlRelationalDelegate。
现在我们有了映射器,我们需要使用addMapping方法定义字段映射:
self.mapper.addMapping(
self.coffee_brand,
coffees_model.fieldIndex('coffee_brand')
)
self.mapper.addMapping(
self.coffee_name,
coffees_model.fieldIndex('coffee_name')
)
self.mapper.addMapping(
self.roast,
coffees_model.fieldIndex('description')
)
addMapping()方法接受两个参数:一个小部件和一个模型列编号。我们使用模型的fieldIndex()方法通过名称检索这些列编号,但是您也可以在这里直接使用整数。
在我们可以使用我们的组合框之前,我们需要用选项填充它。为此,我们需要从我们的关系模型中检索roasts模型,并将其传递给组合框:
roasts_model = coffees_model.relationModel(
self.coffees_model.fieldIndex('description'))
self.roast.setModel(roasts_model)
self.roast.setModelColumn(1)
relationalModel()方法可用于通过传递字段编号从我们的coffees_model对象中检索单个表模型。请注意,我们通过请求description的字段索引而不是roast_id来检索字段编号。在我们的关系模型中,roast_id已被替换为description。
虽然咖啡列表QTableView可以同时显示所有记录,但是我们的CoffeeForm设计为一次只显示一条记录。因此,QDataWidgetMapper具有当前记录的概念,并且只会使用当前记录的数据填充小部件。
因此,为了在我们的表单中显示数据,我们需要控制映射器指向的记录。QDataWidgetMapper类有五种方法来浏览记录表:
| 方法 | 描述 |
|---|---|
toFirst() |
转到表中的第一条记录。 |
toLast() |
转到表中的最后一条记录。 |
toNext() |
转到表中的下一条记录。 |
toPrevious() |
返回到上一个记录。 |
setCurrentIndex() |
转到特定的行号。 |
由于我们的用户正在选择列表中的任意咖啡进行导航,我们将使用最后一个方法setCurrentIndex()。我们将在我们的show_coffee()方法中使用它,如下所示:
def show_coffee(self, coffee_index):
self.mapper.setCurrentIndex(coffee_index.row())
setCurrentIndex()接受一个与模型中的行号对应的整数值。请注意,这与我们在应用程序的先前版本中使用的咖啡id值不同。在这一点上,我们严格使用模型索引值。
现在我们有了工作中的CoffeeForm,让我们在MainView中创建一个,并将其连接到我们咖啡列表的信号:
self.coffee_form = CoffeeForm(
self.coffees_model,
self.reviews_model
)
self.stack.addWidget(self.coffee_form)
self.coffee_list.doubleClicked.connect(
self.coffee_form.show_coffee)
self.coffee_list.doubleClicked.connect(
lambda: self.stack.setCurrentWidget(self.coffee_form))
由于我们使用索引而不是行号,我们可以直接将我们的doubleClicked信号连接到表单的show_coffee()方法。我们还将它连接到一个 lambda 函数,以将当前小部件更改为表单。
在这里,让我们继续创建一个工具栏操作来返回到列表:
toolbar.addAction("Back to list", self.show_list)
相关的回调看起来是这样的:
def show_list(self):
self.coffee_list.resizeColumnsToContents()
self.coffee_list.resizeRowsToContents()
self.stack.setCurrentWidget(self.coffee_list)
为了适应在CoffeeForm中编辑时可能发生的数据可能的更改,我们将调用resizeColumnsToContents()和resizeRowsToContents()。然后,我们只需将堆栈小部件的当前小部件设置为coffee_list。
过滤数据
在这个应用程序中,我们需要处理的最后一件事是咖啡表单的评论部分:
- 记住,评论模型是
QSqlTableModel,我们将其传递给CoffeeForm构造函数。我们可以很容易地将它绑定到QTableView,就像这样:
self.reviews = qtw.QTableView()
self.layout().addRow(self.reviews)
self.reviews.setModel(reviews_model)
- 这在我们的表单中添加了一个评论表。在继续之前,让我们解决一些视图的外观问题:
self.reviews.hideColumn(0)
self.reviews.hideColumn(1)
self.reviews.horizontalHeader().setSectionResizeMode(
4, qtw.QHeaderView.Stretch)
表格的前两列是id和coffee_id,这两个都是我们不需要为用户显示的实现细节。代码的最后一行导致第四个字段(review)扩展到小部件的右边缘。
如果你运行这个,你会看到我们这里有一个小问题:当我们查看咖啡的记录时,我们不想看到所有的评论在表中。我们只想显示与当前咖啡产品相关的评论。
- 我们可以通过对表模型应用过滤器来实现这一点。在
show_coffee()方法中,我们将添加以下代码:
id_index = coffee_index.siblingAtColumn(0)
self.coffee_id = int(self.coffees_model.data(id_index))
self.reviews.model().setFilter(f'coffee_id = {self.coffee_id}')
self.reviews.model().setSort(3, qtc.Qt.DescendingOrder)
self.reviews.model().select()
self.reviews.resizeRowsToContents()
self.reviews.resizeColumnsToContents()
我们首先从我们的咖啡模型中提取选定的咖啡的id号码。这可能与行号不同,这就是为什么我们要查看所选行的第 0 列的值。我们将它保存为一个实例变量,因为以后可能会用到它。
- 接下来,我们调用评论模型的
setFilter()方法。这个方法接受一个字符串,它会被直接附加到用于从 SQL 表中选择数据的查询的WHERE子句中。同样,setSort()将设置ORDER BY子句。在这种情况下,我们按评论日期排序,最近的排在前面。
不幸的是,setFilter()中没有办法使用绑定变量,所以如果你想插入一个值,你必须使用字符串格式化。正如你所学到的,这会使你容易受到 SQL 注入漏洞的影响,所以在插入数据时要非常小心。在这个例子中,我们将coffee_id转换为int,以确保它不是 SQL 注入代码。
设置了过滤和排序属性后,我们需要调用select()来应用它们。然后,我们可以调整行和列以适应新的内容。现在,表单应该只显示当前选定咖啡的评论。
使用自定义委托
评论表包含一个带有日期的列;虽然我们可以使用常规的QLineEdit编辑日期,但如果我们能使用更合适的QDateEdit小部件会更好。与我们的咖啡列表视图不同,Qt 没有一个现成的委托可以为我们做到这一点。幸运的是,我们可以很容易地创建我们自己的委托:
- 在
CoffeeForm类的上面,让我们定义一个新的委托类:
class DateDelegate(qtw.QStyledItemDelegate):
def createEditor(self, parent, option, proxyModelIndex):
date_inp = qtw.QDateEdit(parent, calendarPopup=True)
return date_inp
委托类继承自QStyledItemDelegate,它的createEditor()方法负责返回将用于编辑数据的小部件。在这种情况下,我们只需要创建QDateEdit并返回它。我们可以根据需要配置小部件;例如,在这里我们启用了日历弹出窗口。
请注意,我们正在传递parent参数——这很关键!如果你不明确传递父小部件,你的委托小部件将弹出在它自己的顶层窗口中。
对于我们在评论表中的目的,这就是我们需要改变的全部内容。在更复杂的场景中,可能需要覆盖一些其他方法:
-
setModelData()方法负责从小部件中提取数据并将其传递给模型。如果需要在模型中更新之前将小部件的原始数据转换或准备好,你可能需要覆盖这个方法。
-
setEditorData()方法负责从模型中检索数据并将其写入小部件。如果模型数据不适合小部件理解,你可能需要重写这个方法。 -
paint()方法将编辑小部件绘制到屏幕上。你可以重写这个方法来构建一个自定义小部件,或者根据数据的不同来改变小部件的外观。如果你重写了这个方法,你可能还需要重写sizeHint()和updateEditorGeometry()来确保为你的自定义小部件提供足够的空间。
- 一旦我们创建了自定义委托类,我们需要告诉我们的表视图使用它:
self.dateDelegate = DateDelegate()
self.reviews.setItemDelegateForColumn(
reviews_model.fieldIndex('review_date'),
self.dateDelegate)
在这种情况下,我们创建了一个DateDelegate的实例,并告诉reviews视图在review_date列上使用它。现在,当你编辑评论日期时,你会得到一个带有日历弹出窗口的QDateEdit。
在表视图中插入自定义行
我们要实现的最后一个功能是在我们的评论表中添加和删除行:
- 我们将从一些按钮开始:
self.new_review = qtw.QPushButton(
'New Review', clicked=self.add_review)
self.delete_review = qtw.QPushButton(
'Delete Review', clicked=self.delete_review)
self.layout().addRow(self.new_review, self.delete_review)
- 删除行的回调足够简单:
def delete_review(self):
for index in self.reviews.selectedIndexes() or []:
self.reviews.model().removeRow(index.row())
self.reviews.model().select()
就像我们在MainView.coffee_list中所做的一样,我们只需遍历所选的索引并按行号删除它们。
- 添加新行会出现一个问题:我们可以添加行,但我们需要确保它们设置为使用当前选定的
coffee_id。为此,我们将使用QSqlRecord对象。这个对象代表了来自QSqlTableModel的单行,并且可以使用模型的record()方法创建。一旦我们有了一个空的record对象,我们就可以用值填充它,并将其写回模型。我们的回调从这里开始:
def add_review(self):
reviews_model = self.reviews.model()
new_row = reviews_model.record()
defaults = {
'coffee_id': self.coffee_id,
'review_date': qtc.QDate.currentDate(),
'reviewer': '',
'review': ''
}
for field, value in defaults.items():
index = reviews_model.fieldIndex(field)
new_row.setValue(index, value)
首先,我们通过调用record()从reviews_model中提取一个空记录。这样做很重要,因为它将被预先填充所有模型的字段。接下来,我们需要设置这些值。默认情况下,所有字段都设置为None(SQL NULL),所以如果我们想要默认值或者我们的字段有NOT NULL约束,我们需要覆盖这个设置。
在这种情况下,我们将coffee_id设置为当前显示的咖啡 ID(我们保存为实例变量,很好对吧?),并将review_date设置为当前日期。我们还将reviewer和review设置为空字符串,因为它们有NOT NULL约束。请注意,我们将id保留为None,因为在字段上插入NULL将导致它使用其默认值(在这种情况下,将是自动递增的整数)。
- 设置好
dict后,我们遍历它并将值写入记录的字段。现在我们需要将这个准备好的记录插入模型:
inserted = reviews_model.insertRecord(-1, new_row)
if not inserted:
error = reviews_model.lastError().text()
print(f"Insert Failed: {error}")
reviews_model.select()
QSqlTableModel.insertRecord()接受插入的索引(-1表示表的末尾)和要插入的记录,并返回一个简单的布尔值,指示插入是否成功。如果失败,我们可以通过调用lastError().text()来查询模型的错误文本。
- 最后,我们在模型上调用
select()。这将用我们插入的记录重新填充视图,并允许我们编辑剩下的字段。
到目前为止,我们的应用程序已经完全功能。花一些时间插入新的记录和评论,编辑记录,并删除它们。
总结
在本章中,你学习了关于 SQL 数据库以及如何在 PyQt 中使用它们。你学习了使用 SQL 创建关系数据库的基础知识,如何使用QSqlDatabase类连接数据库,以及如何在数据库上执行查询。你还学习了如何通过使用QtSql中可用的 SQL 模型视图类来构建优雅的数据库应用程序,而无需编写 SQL。
在下一章中,你将学习如何创建异步应用程序,可以处理缓慢的工作负载而不会锁定你的应用程序。你将学习如何有效地使用QTimer类,以及如何安全地利用QThread。我们还将介绍使用QTheadPool来实现高并发处理。
问题
尝试这些问题来测试你对本章的了解:
-
编写一个 SQL
CREATE语句,用于创建一个用于保存电视节目表的表。确保它有日期、时间、频道和节目名称的字段。还要确保它有主键和约束,以防止无意义的数据(例如同一频道上同时播放两个节目,或者没有时间或日期的节目)。 -
以下 SQL 查询返回语法错误;你能修复吗?
DELETE * FROM my_table IF category_id == 12;
- 以下 SQL 查询不正确;你能修复吗?
INSERT INTO flavors(name) VALUES ('hazelnut', 'vanilla', 'caramel', 'onion');
-
QSqlDatabase的文档可以在doc.qt.io/qt-5/qsqldatabase.html找到。了解如何使用多个数据库连接;例如,对同一数据库创建一个只读连接和一个读写连接。你将如何创建两个连接并对每个连接进行特定查询? -
使用
QSqlQuery,编写代码将dict对象中的数据安全地插入到coffees表中:
data = {'brand': 'generic', 'name': 'cheap coffee',
'roast': 'light'}
# Your code here:
- 你创建了一个
QSqlTableModel对象并将其附加到QTableView。你知道表中有数据,但在视图中没有显示。查看代码并决定问题出在哪里:
flavor_model = qts.QSqlTableModel()
flavor_model.setTable('flavors')
flavor_table = qtw.QTableView()
flavor_table.setModel(flavor_model)
mainform.layout().addWidget(flavor_table)
- 以下是附加到
QLineEdit的textChanged信号的回调函数。解释为什么这不是一个好主意:
def do_search(self, text):
self.sql_table_model.setFilter(f'description={text}')
self.sql_table_model.select()
- 你决定在咖啡列表的“烘焙”组合框中使用颜色而不是名称。你需要做哪些改变来实现这一点?
进一步阅读
查看以下资源以获取更多信息:
-
SQLite 中使用的 SQL 语言指南可以在
sqlite.org/lang.html找到 -
可以在
doc.qt.io/qt-5/qtsql-index.html找到QtSQL模块及其使用的概述
第三部分:揭开高级 Qt 实现
在这最后一节中,您将深入了解 PyQt 提供的更高级功能。您将处理多线程、2D 和 3D 图形、丰富文本文档、打印、数据绘图和网页浏览。您将学习如何在树莓派上使用 PyQt,以及如何在桌面系统上构建和部署代码。通过本节结束时,您将拥有构建美丽 GUI 所需的所有工具和技术。
本节包括以下章节:
-
第十章,使用 QTimer 和 QThread 进行多线程
-
第十一章,使用 QTextDocument 创建丰富的文本
-
第十二章,使用 QPainter 创建 2D 图形
-
第十三章,使用 QtOpenGL 创建 3D 图形
-
第十四章,使用 QtCharts 嵌入数据图
-
第十五章,PyQt 树莓派
-
第十六章,使用 QtWebEngine 进行网页浏览
-
第十七章,为软件分发做准备
第十章:使用 QTimer 和 QThread 进行多线程处理
尽管计算机硬件的功能不断增强,程序仍然经常需要执行需要几秒甚至几分钟才能完成的任务。虽然这种延迟可能是由于程序员无法控制的因素造成的,但它仍然会影响应用程序的性能,使其在后台任务运行时变得无响应。在本章中,我们将学习一些工具,可以帮助我们通过推迟重型操作或将其移出线程来保持应用程序的响应性。我们还将学习如何使用多线程应用程序设计来加快多核系统上的这些操作。
本章分为以下主题:
-
使用
QTimer进行延迟操作 -
使用
QThread进行多线程处理 -
使用
QThreadPool和QRunner实现高并发
技术要求
本章只需要您在整本书中一直在使用的基本 Python 和 PyQt5 设置。您还可以参考github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter10上的示例代码。
查看以下视频以查看代码的运行情况:bit.ly/2M6iSPl
使用 QTimer 进行延迟操作
在程序中能够延迟操作在各种情况下都是有用的。例如,假设我们想要一个无模式的弹出对话框,在定义的秒数后自动关闭,而不是等待用户点击按钮。
我们将从子类化QDialog开始:
class AutoCloseDialog(qtw.QDialog):
def __init__(self, parent, title, message, timeout):
super().__init__(parent)
self.setModal(False)
self.setWindowTitle(title)
self.setLayout(qtw.QVBoxLayout())
self.layout().addWidget(qtw.QLabel(message))
self.timeout = timeout
保存了一个timeout值后,我们现在想要重写对话框的show()方法,以便在指定的秒数后关闭它。
一个天真的方法可能是:
def show(self):
super().show()
from time import sleep
sleep(self.timeout)
self.hide()
Python 的time.sleep()函数将暂停程序执行我们传入的秒数。乍一看,它似乎应该做我们想要的事情,即显示窗口,暂停timeout秒,然后隐藏窗口。
因此,让我们在我们的MainWindow.__init__()方法中添加一些代码来测试它:
self.dialog = AutoCloseDialog(
self,
"Self-destructing message",
"This message will self-destruct in 10 seconds",
10
)
self.dialog.show()
如果运行程序,您会发现事情并不如预期。由于这个对话框是无模式的,它应该出现在我们的主窗口旁边,而不会阻塞任何东西。此外,由于我们在调用sleep()之前调用了show(),它应该在暂停之前显示自己。相反,您很可能得到一个空白和冻结的对话框窗口,它在其存在的整个期间都会暂停整个程序。那么,这里发生了什么?
从第一章 PyQt 入门中记得,Qt 程序有一个事件循环,当我们调用QApplication.exec()时启动。当我们调用show()这样的方法时,它涉及许多幕后操作,如绘制小部件和与窗口管理器通信,这些任务不会立即执行。相反,它们被放置在任务队列中。事件循环逐个处理任务队列中的工作,直到它为空。这个过程是异步的,因此调用QWidget.show()方法不会等待窗口显示后再返回;它只是将显示小部件的任务放在事件队列中并返回。
我们对time.sleep()方法的调用在程序中创建了一个立即阻塞的延迟,直到函数退出为止,这将停止所有其他处理。这包括停止 Qt 事件循环,这意味着所有仍在队列中的绘图操作都不会发生。事实上,直到sleep()完成,没有事件会被处理。这就是为什么小部件没有完全绘制,程序在sleep()执行时为什么没有继续的原因。
为了正确工作,我们需要将hide()调用放在事件循环中,这样我们对AutoCloseDialog.show()的调用可以立即返回,并让事件循环处理隐藏对话框,就像它处理显示对话框一样。但我们不想立即这样做,我们希望在事件队列上延迟执行一段时间。这就是QtCore.QTimer类可以为我们做的事情。
单发定时器
QTimer是一个简单的QObject子类,可以在一定时间后发出timeout信号。
使用QTimer延迟单个操作的最简单方法是使用QTimer.singleShot()静态方法,如下所示:
def show(self):
super().show()
qtc.QTimer.singleShot(self.timeout * 1000, self.hide)
singleShot()接受两个参数:毫秒为单位的间隔和回调函数。在这种情况下,我们在一定数量的self.timeout秒后调用self.hide()方法(我们将乘以 1,000 将其转换为毫秒)。
再次运行此脚本,您现在应该看到您的对话框表现如预期。
重复定时器
在应用程序中,有时我们需要在指定的间隔重复执行某个操作,比如自动保存文档,轮询网络套接字,或者不断地催促用户在应用商店给应用程序评 5 星(好吧,也许不是这个)。
QTimer也可以处理这个问题,您可以从以下代码块中看到:
interval_seconds = 10
self.timer = qtc.QTimer()
self.timer.setInterval(interval_seconds * 1000)
self.interval_dialog = AutoCloseDialog(
self, "It's time again",
f"It has been {interval_seconds} seconds "
"since this dialog was last shown.", 2000)
self.timer.timeout.connect(self.interval_dialog.show)
self.timer.start()
在这个例子中,我们明确创建了一个QTimer对象,而不是使用静态的singleShot()方法。然后,我们使用setInterval()方法配置了以毫秒为单位的超时间隔。当间隔过去时,定时器对象将发出timeout信号。默认情况下,QTimer对象将在达到指定间隔的末尾时重复发出timeout信号。您也可以使用setSingleShot()方法将其转换为单发,尽管一般来说,使用我们在单发定时器部分演示的静态方法更容易。
创建QTimer对象并配置间隔后,我们只需将其timeout信号连接到另一个AutoCloseDialog对象的show()方法,然后通过调用start()方法启动定时器。
我们也可以停止定时器,然后重新启动:
toolbar = self.addToolBar('Tools')
toolbar.addAction('Stop Bugging Me', self.timer.stop)
toolbar.addAction('Start Bugging Me', self.timer.start)
QTimer.stop()方法停止定时器,start()方法将重新开始。值得注意的是这里没有pause()方法;stop()方法将清除任何当前的进度,start()方法将从配置的间隔重新开始。
从定时器获取信息
QTimer有一些方法,我们可以用来提取有关定时器状态的信息。例如,让我们通过以下代码让用户了解事情的进展:
self.timer2 = qtc.QTimer()
self.timer2.setInterval(1000)
self.timer2.timeout.connect(self.update_status)
self.timer2.start()
我们设置了另一个定时器,它将每秒调用self.update_status()。update_status()然后查询信息的第一次如下:
def update_status(self):
if self.timer.isActive():
time_left = (self.timer.remainingTime() // 1000) + 1
self.statusBar().showMessage(
f"Next dialog will be shown in {time_left} seconds.")
else:
self.statusBar().showMessage('Dialogs are off.')
QTimer.isActive()方法告诉我们定时器当前是否正在运行,而remainingTime()告诉我们距离下一个timeout信号还有多少毫秒。
现在运行这个程序,您应该看到关于下一个对话框的状态更新。
定时器的限制
虽然定时器允许我们将操作推迟到事件队列,并可以帮助防止程序中的尴尬暂停,但重要的是要理解连接到timeout信号的函数仍然在主执行线程中执行,并且因此会阻塞主执行线程。
例如,假设我们有一个长时间阻塞的方法,如下所示:
def long_blocking_callback(self):
from time import sleep
self.statusBar().showMessage('Beginning a long blocking function.')
sleep(30)
self.statusBar().showMessage('Ending a long blocking function.')
您可能认为从单发定时器调用此方法将阻止其锁定应用程序。让我们通过将此代码添加到MainView.__init__()来测试这个理论:
qtc.QTimer.singleShot(1, self.long_blocking_callback)
使用1毫秒延迟调用singleShot()是安排一个几乎立即发生的事件的简单方法。那么,它有效吗?
好吧,实际上并不是这样;如果你运行程序,你会发现它会锁定 30 秒。尽管我们推迟了操作,但它仍然是一个长时间的阻塞操作,会在运行时冻结程序。也许我们可以调整延迟值,以确保它被推迟到更合适的时刻(比如在应用程序绘制完毕后或者在启动画面显示后),但迟早,应用程序将不得不冻结并在任务运行时变得无响应。
然而,对于这样的问题有一个解决方案;在下一节使用 QThread 进行多线程处理中,我们将看看如何将这样的繁重阻塞任务推送到另一个线程,以便我们的程序可以继续运行而不会冻结。
使用 QThread 进行多线程处理
等待有时是不可避免的。无论是查询网络、访问文件系统还是运行复杂的计算,有时程序只是需要时间来完成一个过程。然而,在等待的时候,我们的 GUI 没有理由完全变得无响应。具有多个 CPU 核心和线程技术的现代系统允许我们运行并发进程,我们没有理由不利用这一点来制作响应式的 GUI。尽管 Python 有自己的线程库,但 Qt 为我们提供了QThread对象,可以轻松构建多线程应用程序。它还有一个额外的优势,就是集成到 Qt 中,并且与信号和槽兼容。
在本节中,我们将构建一个相对缓慢的文件搜索工具,然后使用QThread来确保 GUI 保持响应。
SlowSearcher 文件搜索引擎
为了有效地讨论线程,我们首先需要一个可以在单独线程上运行的缓慢过程。打开一个新的 Qt 应用程序模板副本,并将其命名为file_searcher.py。
让我们开始实现一个文件搜索引擎:
class SlowSearcher(qtc.QObject):
match_found = qtc.pyqtSignal(str)
directory_changed = qtc.pyqtSignal(str)
finished = qtc.pyqtSignal()
def __init__(self):
super().__init__()
self.term = None
我们将其称为SlowSearcher,因为它将是故意非优化的。它首先定义了一些信号,如下所示:
-
当文件名与搜索项匹配时,将发出
match_found信号,并包含匹配的文件名 -
每当我们开始在一个新目录中搜索时,将发出
directory_changed信号 -
当整个文件系统树已经被搜索时,将发出
finished信号
最后,我们重写__init__()只是为了定义一个名为self.term的实例变量。
接下来,我们将为term创建一个 setter 方法:
def set_term(self, term):
self.term = term
如果你想知道为什么我们要费力实现一个如此简单的 setter 方法,而不是直接设置变量,这个原因很快就会显而易见,当我们讨论QThread的一些限制时,这个原因将很快显现出来。
现在,我们将创建搜索方法,如下所示:
def do_search(self):
root = qtc.QDir.rootPath()
self._search(self.term, root)
self.finished.emit()
这个方法将是我们调用来启动搜索过程的槽。它首先将根目录定位为一个QDir对象,然后调用_search()方法。一旦_search()返回,它就会发出finished信号。
实际的_search()方法如下:
def _search(self, term, path):
self.directory_changed.emit(path)
directory = qtc.QDir(path)
directory.setFilter(directory.filter() |
qtc.QDir.NoDotAndDotDot | qtc.QDir.NoSymLinks)
for entry in directory.entryInfoList():
if term in entry.filePath():
print(entry.filePath())
self.match_found.emit(entry.filePath())
if entry.isDir():
self._search(term, entry.filePath())
_search()是一个递归搜索方法。它首先发出directory_changed信号,表示我们正在一个新目录中搜索,然后为当前路径创建一个QDir对象。接下来,它设置filter属性,以便在查询entryInfoList()方法时,不包括符号链接或.和..快捷方式(这是为了避免搜索中的无限循环)。最后,我们遍历entryInfoList()检索到的目录内容,并为每个匹配的项目发出match_found信号。对于每个找到的目录,我们在其上运行_search()方法。
这样,我们的方法将递归遍历文件系统中的所有目录,寻找与我们的搜索词匹配的内容。这不是最优化的方法,这是故意这样做的。根据您的硬件、平台和驱动器上的文件数量,这个搜索可能需要几秒钟到几分钟的时间才能完成,因此它非常适合查看线程如何帮助必须执行缓慢进程的应用程序。
在多线程术语中,执行实际工作的类被称为Worker类。SlowSearcher是Worker类的一个示例。
一个非线程化的搜索器
为了实现一个搜索应用程序,让我们添加一个用于输入搜索词和显示搜索结果的 GUI 表单。
让我们称它为SearchForm,如下所示:
class SearchForm(qtw.QWidget):
textChanged = qtc.pyqtSignal(str)
returnPressed = qtc.pyqtSignal()
def __init__(self):
super().__init__()
self.setLayout(qtw.QVBoxLayout())
self.search_term_inp = qtw.QLineEdit(
placeholderText='Search Term',
textChanged=self.textChanged,
returnPressed=self.returnPressed)
self.layout().addWidget(self.search_term_inp)
self.results = qtw.QListWidget()
self.layout().addWidget(self.results)
self.returnPressed.connect(self.results.clear)
这个 GUI 只包含一个用于输入搜索词的QLineEdit小部件和一个用于显示结果的QListWidget小部件。我们将QLineEdit小部件的returnPressed和textChanged信号转发到SearchForm对象上的同名信号,以便我们可以更容易地在我们的MainView方法中连接它们。我们还将returnPressed连接到列表小部件的clear槽,以便开始新搜索时清除结果区域。
SearchForm()方法还需要一个方法来添加新项目:
def addResult(self, result):
self.results.addItem(result)
这只是一个方便的方法,这样一来,主应用程序就不必直接操作表单中的小部件。
在我们的MainWindow.__init__()方法中,我们可以创建一个搜索器和表单对象,并将它们连接起来,如下所示:
form = SearchForm()
self.setCentralWidget(form)
self.ss = SlowSearcher()
form.textChanged.connect(self.ss.set_term)
form.returnPressed.connect(self.ss.do_search)
self.ss.match_found.connect(form.addResult)
创建SlowSearcher和SearchForm对象并将表单设置为中央部件后,我们将适当的信号连接在一起,如下所示:
-
表单的
textChanged信号,发出输入的字符串,连接到搜索器的set_term()设置方法。 -
表单的
returnPressed信号连接到搜索器的do_search()方法以触发搜索。 -
搜索器的
match_found信号,携带找到的路径名,连接到表单的addResult()方法。
最后,让我们添加两个MainWindow方法,以便让用户了解搜索的状态:
def on_finished(self):
qtw.QMessageBox.information(self, 'Complete', 'Search complete')
def on_directory_changed(self, path):
self.statusBar().showMessage(f'Searching in: {path}')
第一个将显示一个指示搜索已完成的状态,而第二个将显示一个指示搜索器正在搜索的当前路径的状态。
回到__init__(),这些将连接到搜索器,如下所示:
self.ss.finished.connect(self.on_finished)
self.ss.directory_changed.connect(self.on_directory_changed)
测试我们的非线程化搜索应用程序
我们对这个脚本的期望是,当我们在系统中搜索目录时,我们将在结果区域得到稳定的搜索结果打印输出,同时状态栏中的当前目录也会不断更新。
然而,如果您运行它,您会发现实际发生的并不是这样。相反,一旦搜索开始,GUI 就会冻结。状态栏中什么都没有显示,列表小部件中也没有条目出现,尽管匹配项已经打印到控制台上。只有当搜索最终完成时,结果才会出现,状态才会更新。
为了解决这个问题,我们需要引入线程。
那么,为什么程序会实时打印到控制台,但不会实时更新我们的 GUI 呢?这是因为print()是同步的——它在调用时立即执行,并且直到文本被写入控制台后才返回。然而,我们的 GUI 方法是异步的——它们被排队在 Qt 事件队列中,并且直到主事件循环执行SlowSearcher.search()方法后才会执行。
添加线程
线程是独立的代码执行上下文。默认情况下,我们所有的代码都在一个线程中运行,因此我们将其称为单线程应用程序。使用QtCore.QThread类,我们可以创建新的线程并将代码的部分移动到这些线程中,使其成为多线程应用程序。
您可以使用QThread对象,如下所示:
self.searcher_thread = qtc.QThread()
self.ss.moveToThread(self.searcher_thread)
self.ss.finished.connect(self.searcher_thread.quit)
self.searcher_thread.start()
我们首先创建一个QThread对象,然后使用SlowSearcher.moveToThread()方法将我们的SlowSearcher对象移动到新线程中。moveToThread()是QObject的一个方法,由任何子类QObject的类继承。
接下来,我们将搜索器的finished信号连接到线程的quit槽;这将导致线程在搜索完成时停止执行。由于搜索线程不是我们主要的执行线程的一部分,它必须有一种方法来自行退出,否则在搜索结束后它将继续运行。
最后,我们需要调用搜索线程的start()方法来开始执行代码,并允许我们的主线程与SlowSearcher对象交互。
这段代码需要在创建SlowSearcher对象之后插入,但在连接到它的任何信号或槽之前(我们将在线程提示和注意事项部分讨论原因)。
由于我们在每次搜索后都要退出线程,所以需要在每次开始新搜索时重新启动线程。我们可以通过以下连接来实现这一点:
form.returnPressed.connect(self.searcher_thread.start)
这就是使用线程所需的一切。再次运行脚本,你会看到随着搜索的进行,GUI 会更新。
让我们总结一下这个过程,如下所示:
-
创建
Worker类的实例 -
创建一个
QThread对象 -
使用
Worker类的moveToThread()方法将其移动到新线程 -
连接任何其他信号和槽
-
调用线程的
start()方法
另一种方法
虽然moveToThread()方法是使用QThread的推荐方法,但还有另一种方法可以完全正常地工作,并且在某种程度上简化了我们的代码。这种方法是通过对QThread进行子类化并重写run()方法来创建我们的Worker类,使用我们的工作代码。
例如,创建SlowSearcher的副本,并进行如下修改:
class SlowSearcherThread(qtc.QThread):
# rename "do_search()" to "run()":
def run (self):
root = qtc.QDir.rootPath()
self._search(self.term, root)
self.finished.emit()
# The rest of the class is the same
在这里,我们只改变了三件事:
-
我们已将类重命名为
SlowSearcherThread。 -
我们已将父类更改为
QThread。 -
我们已经将
do_search()重命名为run()。
我们的MainWindow.__init__()方法现在会简单得多:
form = SearchForm()
self.setCentralWidget(form)
self.ss = SlowSearcherThread()
form.textChanged.connect(self.ss.set_term)
form.returnPressed.connect(self.ss.start)
self.ss.match_found.connect(form.addResult)
self.ss.finished.connect(self.on_finished)
self.ss.directory_changed.connect(self.on_directory_changed)
现在,我们只需要将returnPressed连接到SlowSearcher.start()。start()方法创建了新线程,并在新线程中执行对象的run()方法。这意味着,通过重写该方法,我们可以有效地将该代码放在一个新线程中。
始终记得实现run(),但调用start()。不要搞混了,否则你的多线程就无法工作!
虽然这种方法有一些有效的用例,但它可能会在对象数据的线程所有权上产生微妙的问题。即使QThread对象为辅助线程提供了控制接口,但对象本身仍然存在于主线程中。当我们在worker对象上调用moveToThread()时,我们可以确保worker对象完全移动到新线程中。然而,当worker对象是QThread的子类时,QThread的部分必须保留在主线程中,即使执行的代码被移动到新线程中。这可能会导致微妙的错误,因为很难搞清楚worker对象的哪些部分在哪个线程中。
最终,除非你有清晰的理由来对QThread5进行子类化,否则应该使用moveToThread()。
线程的提示和注意事项
之前的示例可能让多线程编程看起来很简单,但那是因为代码经过精心设计,避免了在处理线程时可能出现的一些问题。实际上,在单线程应用程序上进行多线程改造可能会更加困难。
一个常见的问题是worker对象在主线程中被卡住,导致我们失去了多线程的好处。这可能以几种方式发生。
例如,在我们原始的线程脚本(使用moveToThread()的脚本)中,我们必须在连接任何信号之前将工作线程移动到线程中。如果您尝试在信号连接之后移动线程代码,您会发现 GUI 会锁定,就好像您没有使用线程一样。
发生这种情况的原因是我们的工作线程方法是 Python 方法,并且连接到它们会在 Python 中创建一个连接,这个连接必须在主线程中持续存在。解决这个问题的一种方法是使用pyqtSlot()装饰器将工作线程的方法转换为真正的 Qt 槽,如下所示:
@qtc.pyqtSlot(str)
def set_term(self, term):
self.term = term
@qtc.pyqtSlot()
def do_search(self):
root = qtc.QDir.rootPath()
self._search(self.term, root)
self.finished.emit()
一旦您这样做了,顺序就不重要了,因为连接将完全存在于 Qt 对象之间,而不是 Python 对象之间。
您还可以通过在主线程中直接调用worker对象的一个方法来捕获worker对象:
# in MainView__init__():
self.ss.set_term('foo')
self.ss.do_search()
将上述行放在__init__()中将导致 GUI 保持隐藏,直到对foo进行的文件系统搜索完成。有时,这个问题可能会很微妙;例如,以下lambda回调表明我们只是将信号直接连接到槽:
form.returnPressed.connect(lambda: self.ss.do_search())
然而,这种连接会破坏线程,因为lambda函数本身是主线程的一部分,因此对search()的调用将在主线程中执行。
不幸的是,这个限制也意味着您不能将MainWindow方法用作调用工作方法的槽;例如,我们不能在MainWindow中运行以下代码:
def on_return_pressed(self):
self.searcher_thread.start()
self.ss.do_search()
将其作为returnPressed的回调,而不是将信号连接到worker对象的方法,会导致线程失败和 GUI 锁定。
简而言之,最好将与worker对象的交互限制为纯 Qt 信号和槽连接,没有中间函数。
使用 QThreadPool 和 QRunner 进行高并发
QThreads非常适合将单个长时间的进程放入后台,特别是当我们希望使用信号和槽与该进程进行通信时。然而,有时我们需要做的是使用尽可能多的线程并行运行多个计算密集型操作。这可以通过QThread来实现,但更好的选择是在QThreadPool和QRunner中找到。
QRunner代表我们希望工作线程执行的单个可运行任务。与QThread不同,它不是从QObject派生的,也不能使用信号和槽。然而,它非常高效,并且在需要多个线程时使用起来更简单。
QThreadPool对象的工作是管理QRunner对象的队列,当计算资源可用时,启动新线程来执行对象。
为了演示如何使用这个,让我们构建一个文件哈希实用程序。
文件哈希 GUI
我们的文件哈希工具将接受一个源目录、一个目标文件和要使用的线程数。它将使用线程数来计算目录中每个文件的 MD5 哈希值,然后在执行此操作时将信息写入目标文件。
诸如 MD5 之类的哈希函数用于从任意数据计算出唯一的固定长度的二进制值。哈希经常用于确定文件的真实性,因为对文件的任何更改都会导致不同的哈希值。
从第四章中制作一个干净的 Qt 模板的副本,使用 QMainWindow 构建应用程序,将其命名为hasher.py。
然后,我们将从我们的 GUI 表单类开始,如下所示:
class HashForm(qtw.QWidget):
submitted = qtc.pyqtSignal(str, str, int)
def __init__(self):
super().__init__()
self.setLayout(qtw.QFormLayout())
self.source_path = qtw.QPushButton(
'Click to select…', clicked=self.on_source_click)
self.layout().addRow('Source Path', self.source_path)
self.destination_file = qtw.QPushButton(
'Click to select…', clicked=self.on_dest_click)
self.layout().addRow('Destination File', self.destination_file)
self.threads = qtw.QSpinBox(minimum=1, maximum=7, value=2)
self.layout().addRow('Threads', self.threads)
submit = qtw.QPushButton('Go', clicked=self.on_submit)
self.layout().addRow(submit)
这种形式与我们在前几章设计的形式非常相似,有一个submitted信号来发布数据,QPushButton对象来存储选定的文件,一个旋转框来选择线程的数量,以及另一个按钮来提交表单。
文件按钮的回调将如下所示:
def on_source_click(self):
dirname = qtw.QFileDialog.getExistingDirectory()
if dirname:
self.source_path.setText(dirname)
def on_dest_click(self):
filename, _ = qtw.QFileDialog.getSaveFileName()
if filename:
self.destination_file.setText(filename)
在这里,我们使用QFileDialog静态函数(你在第五章中学到的,使用模型视图类创建数据接口)来检索要检查的目录名称和我们将用来保存输出的文件名。
最后,我们的on_submit()回调如下:
def on_submit(self):
self.submitted.emit(
self.source_path.text(),
self.destination_file.text(),
self.threads.value()
)
这个回调只是简单地从我们的小部件中收集数据,并使用submitted信号发布它。
在MainWindow.__init__()中,创建一个表单并将其设置为中央小部件:
form = HashForm()
self.setCentralWidget(form)
这样我们的 GUI 就完成了,现在让我们来构建后端。
哈希运行器
HashRunner类将表示我们要执行的实际任务的单个实例。对于我们需要处理的每个文件,我们将创建一个唯一的HashRunner实例,因此它的构造函数将需要接收输入文件名和输出文件名作为参数。它的任务将是计算输入文件的 MD5 哈希,并将其与输入文件名一起追加到输出文件中。
我们将通过子类化QRunnable来启动它:
class HashRunner(qtc.QRunnable):
file_lock = qtc.QMutex()
我们首先创建一个QMutex对象。在多线程术语中,互斥锁是一个在线程之间共享的可以被锁定或解锁的对象。
你可以将互斥锁看作是单用户洗手间的门的方式;假设 Bob 试图进入洗手间并锁上门。如果 Alice 已经在洗手间里,那么门不会打开,Bob 将不得不耐心地等待,直到 Alice 解锁门并离开洗手间。然后,Bob 才能进入并锁上门。
同样,当一个线程尝试锁定另一个线程已经锁定的互斥锁时,它必须等到第一个线程完成并解锁互斥锁,然后才能获取锁。
在HashRunner中,我们将使用我们的file_lock互斥锁来确保两个线程不会同时尝试写入输出文件。请注意,该对象是在类定义中创建的,因此它将被HashRunner的所有实例共享。
现在,让我们创建__init__()方法:
def __init__(self, infile, outfile):
super().__init__()
self.infile = infile
self.outfile = outfile
self.hasher = qtc.QCryptographicHash(
qtc.QCryptographicHash.Md5)
self.setAutoDelete(True)
该对象将接收输入文件和输出文件的路径,并将它们存储为实例变量。它还创建了一个QtCore.QCryptographicHash的实例。这个对象能够计算数据的各种加密哈希,比如 MD5、SHA-256 或 Keccak-512。这个类支持的哈希的完整列表可以在doc.qt.io/qt-5/qcryptographichash.html找到。
最后,我们将类的autoDelete属性设置为True。QRunnable的这个属性将导致对象在run()方法返回时被删除,节省我们的内存和资源。
运行器执行的实际工作在run()方法中定义:
def run(self):
print(f'hashing {self.infile}')
self.hasher.reset()
with open(self.infile, 'rb') as fh:
self.hasher.addData(fh.read())
hash_string = bytes(self.hasher.result().toHex()).decode('UTF-8')
我们的函数首先通过打印一条消息到控制台并重置QCryptographicHash对象来开始,清除其中可能存在的任何数据。
然后,我们使用addData()方法将文件的二进制内容读入哈希对象中。可以使用result()方法从哈希对象中计算和检索哈希值作为QByteArray对象。然后,我们使用toHex()方法将字节数组转换为十六进制字符串,然后通过bytes对象将其转换为 Python Unicode 字符串。
现在,我们只需要将这个哈希字符串写入输出文件。这就是我们的互斥锁对象发挥作用的地方。
传统上,使用互斥锁的方式如下:
try:
self.file_lock.lock()
with open(self.outfile, 'a', encoding='utf-8') as out:
out.write(f'{self.infile}\t{hash_string}\n')
finally:
self.file_lock.unlock()
我们在try块内调用互斥锁的lock()方法,然后执行我们的文件操作。在finally块内,我们调用unlock方法。之所以在try和finally块内执行这些操作,是为了确保即使file方法出现问题,互斥锁也一定会被释放。
然而,在 Python 中,每当我们有像这样具有初始化和清理代码的操作时,最好使用上下文管理器对象与with关键字结合使用。PyQt 为我们提供了这样的对象:QMutexLocker。
我们可以像下面这样使用这个对象:
with qtc.QMutexLocker(self.file_lock):
with open(self.outfile, 'a', encoding='utf-8') as out:
out.write(f'{self.infile}\t{hash_string}\n')
这种方法更加清晰。通过使用互斥上下文管理器,我们确保with块内的任何操作只由一个线程执行,其他线程将等待直到对象完成。
创建线程池
这个应用程序的最后一部分将是一个HashManager对象。这个对象的工作是接收表单输出,找到要进行哈希处理的文件,然后为每个文件启动一个HashRunner对象。
它将开始像这样:
class HashManager(qtc.QObject):
finished = qtc.pyqtSignal()
def __init__(self):
super().__init__()
self.pool = qtc.QThreadPool.globalInstance()
我们基于QObject类,这样我们就可以定义一个finished信号。当所有的运行者完成他们的任务时,这个信号将被发射。
在构造函数中,我们创建了QThreadPool对象。但是,我们使用globalInstance()静态方法来访问每个 Qt 应用程序中已经存在的全局线程池对象,而不是创建一个新对象。你不必这样做,但对于大多数应用程序来说已经足够了,并且消除了涉及多个线程池的一些复杂性。
这个类的真正工作将在一个我们将称之为do_hashing的方法中发生:
@qtc.pyqtSlot(str, str, int)
def do_hashing(self, source, destination, threads):
self.pool.setMaxThreadCount(threads)
qdir = qtc.QDir(source)
for filename in qdir.entryList(qtc.QDir.Files):
filepath = qdir.absoluteFilePath(filename)
runner = HashRunner(filepath, destination)
self.pool.start(runner)
这个方法被设计为直接连接到HashForm.submitted信号,所以我们将它作为一个槽与匹配的信号。它首先通过将线程池的最大线程数(由maxThreadCount属性定义)设置为函数调用中接收到的数字。一旦设置了这个值,我们可以在线程池中排队任意数量的QRunnable对象,但只有maxThreadCount个线程会同时启动。
接下来,我们将使用QDir对象的entryList()方法来遍历目录中的文件,并为每个文件创建一个HashRunner对象。然后将运行对象传递给线程池的start()方法,将其添加到池的工作队列中。
在这一点上,我们所有的运行者都在单独的执行线程中运行,但是当它们完成时,我们想发射一个信号。不幸的是,QThreadPool中没有内置的信号告诉我们这一点,但waitForDone()方法将继续阻塞,直到所有线程都完成。
因此,将以下代码添加到do_hashing()中:
self.pool.waitForDone()
self.finished.emit()
回到MainWindow.__init__(),让我们创建我们的管理器对象并添加我们的连接:
self.manager = HashManager()
self.manager_thread = qtc.QThread()
self.manager.moveToThread(self.manager_thread)
self.manager_thread.start()
form.submitted.connect(self.manager.do_hashing)
创建了我们的HashManager之后,我们使用moveToThread()将其移动到一个单独的线程中。这是因为我们的do_hashing()方法将阻塞,直到所有的运行者都完成,而我们不希望 GUI 在等待时冻结。如果我们省略了do_hashing()的最后两行,这是不必要的(但我们也永远不会知道何时完成)。
为了获得发生的反馈,让我们添加两个更多的连接:
form.submitted.connect(
lambda x, y, z: self.statusBar().showMessage(
f'Processing files in {x} into {y} with {z} threads.'))
self.manager.finished.connect(
lambda: self.statusBar().showMessage('Finished'))
第一个连接将在表单提交时设置状态,指示即将开始的工作的详细信息;第二个连接将在工作完成时通知我们。
测试脚本
继续启动这个脚本,让我们看看它是如何工作的。将源目录指向一个充满大文件的文件夹,比如 DVD 镜像、存档文件或视频文件。将线程的旋钮保持在默认设置,并点击Go。
从控制台输出中可以看到,文件正在一次处理两个。一旦一个完成,另一个就开始,直到所有文件都被处理完。
再试一次,但这次将线程数增加到四或五。注意到更多的文件正在同时处理。当您调整这个值时,您可能也会注意到有一个收益递减的点,特别是当您接近 CPU 核心数时。这是关于并行化的一个重要教训——有时候,过多会导致性能下降。
线程和 Python GIL
在 Python 中,没有讨论多线程是完整的,而不涉及全局解释器锁(GIL)。GIL 是官方 Python 实现(CPython)中内存管理系统的一部分。本质上,它就像我们在HashRunner类中使用的互斥锁一样——就像HashRunner类必须在写入输出之前获取file_lock互斥锁一样,Python 应用程序中的任何线程在执行任何 Python 代码之前必须获取 GIL。换句话说,一次只有一个线程可以执行 Python 代码。
乍一看,这可能会使 Python 中的多线程看起来是徒劳的;毕竟,如果只有一个线程可以一次执行 Python 代码,那么创建多个线程有什么意义呢?
答案涉及 GIL 要求的两个例外情况:
-
长时间运行的代码可以是 CPU 绑定或 I/O 绑定。CPU 绑定意味着大部分处理时间都用于运行繁重的 CPU 操作,比如加密哈希。I/O 绑定操作是指大部分时间都花在等待输入/输出调用上,比如将大文件写入磁盘或从网络套接字读取数据。当线程进行 I/O 调用并开始等待响应时,它会释放 GIL。因此,如果我们的工作代码大部分是 I/O 绑定的,我们可以从多线程中受益,因为在等待 I/O 操作完成时,其他代码可以运行。
-
如果 CPU 绑定的代码在 Python 之外运行,则会释放 GIL。换句话说,如果我们使用 C 或 C++函数或对象执行 CPU 绑定操作,那么 GIL 会被释放,只有在下一个 Python 操作运行时才重新获取。
这就是为什么我们的HashRunner起作用的原因;它的两个最重的操作如下:
-
从磁盘读取大文件(这是一个 I/O 绑定操作)
-
对文件内容进行哈希处理(这是在
QCryptographicHash对象内部处理的——这是一个在 Python 之外运行的 C++对象)
如果我们要在纯 Python 中实现一个哈希算法,那么我们很可能会发现我们的多线程代码实际上比单线程实现还要慢。
最终,多线程并不是 Python 中加速代码的魔法子弹;必须仔细规划,以避免与 GIL 和我们在“线程提示和注意事项”部分讨论的陷阱有关的问题。然而,经过适当的关怀,它可以帮助我们创建快速响应的程序。
总结
在本章中,您学会了如何在运行缓慢的代码时保持应用程序的响应性。您学会了如何使用QTimer将操作推迟到以后的时间,无论是作为一次性操作还是重复操作。您学会了如何使用QThread将代码推送到另一个线程,既可以使用moveToThread()也可以通过子类化QThread。最后,您学会了如何使用QThreadPool和QRunnable来构建高度并发的数据处理应用程序。
在第十一章中,“使用 QTextDocument 创建丰富的文本”,我们将看看如何在 PyQt 中处理丰富的文本。您将学会如何使用类似 HTML 的标记定义丰富的文本,以及如何使用QDocumentAPI 检查和操作文档。您还将学会如何利用 Qt 的打印支持将文档带入现实世界。
问题
尝试回答这些问题,以测试你从本章学到的知识:
-
创建代码以每 10 秒调用
self.every_ten_seconds()方法。 -
以下代码错误地使用了
QTimer。你能修复它吗?
timer = qtc.QTimer()
timer.setSingleShot(True)
timer.setInterval(1000)
timer.start()
while timer.remainingTime():
sleep(.01)
run_delayed_command()
- 您已经创建了以下单词计数的
Worker类,并希望将其移动到另一个线程以防止大型文档减慢 GUI。但它没有起作用——你需要改变这个类的什么?
class Worker(qtc.QObject):
counted = qtc.pyqtSignal(int)
def __init__(self, parent):
super().__init__(parent)
self.parent = parent
def count_words(self):
content = self.parent.textedit.toPlainText()
self.counted.emit(len(content.split()))
- 以下代码是阻塞的,而不是在单独的线程中运行。为什么会这样?
class Worker(qtc.QThread):
def set_data(data):
self.data = data
def run(self):n
start_complex_calculations(self.data)
class MainWindow(qtw.QMainWindow):
def __init__(self):
super().__init__()
form = qtw.QWidget()
self.setCentralWidget(form)
form.setLayout(qtw.QFormLayout())
worker = Worker()
line_edit = qtw.QLineEdit(textChanged=worker.set_data)
button = qtw.QPushButton('Run', clicked=worker.run)
form.layout().addRow('Data:', line_edit)
form.layout().addRow(button)
self.show()
- 这个
Worker类会正确运行吗?如果不会,为什么?
class Worker(qtc.QRunnable):
finished = qtc.pyqtSignal()
def run(self):
calculate_navigation_vectors(30)
self.finished.emit()
- 以下代码是设计用于处理科学设备输出的大型数据文件的
QRunnable类的run()方法。这些文件包含数百万行以空格分隔的长数字。这段代码可能会受到 Python GIL 的影响吗?您能否减少 GIL 的干扰?
def run(self):
with open(self.file, 'r') as fh:
for row in fh:
numbers = [float(x) for x in row.split()]
if numbers:
mean = sum(numbers) / len(numbers)
numbers.append(mean)
self.queue.put(numbers)
- 以下是您正在编写的多线程 TCP 服务器应用程序中
QRunnable类的run()方法。所有线程共享通过self.datastream访问的服务器套接字实例。然而,这段代码不是线程安全的。您需要做什么来修复它?
def run(self):
message = get_http_response_string()
message_len = len(message)
self.datastream.writeUInt32(message_len)
self.datastream.writeQString(message)
进一步阅读
欲了解更多信息,请参考以下内容:
-
信号量类似于互斥锁,但允许获取任意数量的锁,而不仅仅是单个锁。您可以在
doc.qt.io/qt-5/qsemaphore.html了解更多关于 Qt 实现的QSemaphore类的信息。 -
David Beazley 在 PyCon 2010 的演讲提供了更深入的了解 Python GIL 的运作,可在
www.youtube.com/watch?v=Obt-vMVdM8s上观看。
第十一章:使用 QTextDocument 创建富文本
无论是在文字处理器中起草商业备忘录、写博客文章还是生成报告,世界上大部分的计算都涉及文档的创建。这些应用程序大多需要能够生成不仅仅是普通的字母数字字符串,还需要生成富文本。富文本(与纯文本相对)意味着包括字体、颜色、列表、表格和图像等样式和格式特性的文本。
在本章中,我们将学习 PyQt 如何允许我们通过以下主题处理富文本:
-
使用标记创建富文本
-
使用
QTextDocument操纵富文本 -
打印富文本
技术要求
对于本章,您将需要自第一章以来一直在使用的基本 Python 和 Qt 设置。您可能希望参考可以在github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter11找到的示例代码。
查看以下视频以查看代码的实际效果:bit.ly/2M5P4Cq
使用标记创建富文本
每个支持富文本的应用程序都必须有一些格式来表示内存中的文本,并在将其保存到文件时。有些格式使用自定义二进制代码,例如旧版本 Microsoft Word 使用的.doc和.rtf文件。在其他情况下,使用纯文本标记语言。在标记语言中,称为标签的特殊字符串指示富文本特性的放置。Qt 采用标记方法,并使用超文本标记语言(HTML)第 4 版的子集表示富文本。
Qt 中的富文本标记由QTextDocument对象呈现,因此它只能用于使用QTextDocument存储其内容的小部件。这包括QLabel、QTextEdit和QTextBrowser小部件。在本节中,我们将创建一个演示脚本,以探索这种标记语言的语法和功能。
鉴于 Web 开发的普及和普遍性,您可能已经对 HTML 有所了解;如果您不了解,下一节将作为一个快速介绍。
HTML 基础
HTML 文档由文本内容和标签组成,以指示非纯文本特性。标签只是用尖括号括起来的单词,如下所示:
<sometag>This is some content</sometag>
注意前面示例中的</sometag>代码。这被称为闭合标签,它与开放标签类似,但标签名称前面有一个斜杠(/)。通常只有用于包围(或有能力包围)文本内容的标签才使用闭合标签。
考虑以下示例:
Text can be <b>bold<b> <br>
Text can be <em>emphasized</em> <br>
Text can be <u>underlined</u> <hr>
b、em和u标签需要闭合标签,因为它们包围内容的一部分并指示外观的变化。br和hr标签(换行和水平线,分别)只是指示包含在文档中的非文本项,因此它们没有闭合标签。
如果您想看看这些示例中的任何一个是什么样子,您可以将它们复制到一个文本文件中,然后在您的 Web 浏览器中打开它们。还可以查看示例代码中的html_examples.html文件。
有时,通过嵌套标签创建复杂结构,例如以下列表:
<ol>
<li> Item one</li>
<li> Item two</li>
<li> Item three</li>
</ol>
在这里,ol标签开始一个有序列表(使用顺序数字或字母的列表,而不是项目符号字符)。列表中的每个项目由li(列表项)标签表示。请注意,当嵌套标签使用闭合标签时,标签必须按正确顺序关闭,如下所示:
<b><i>This is right</i></b>
<b><i>This is wrong!</b></i>
前面的错误示例不起作用,因为内部标签(<i>)在外部标签(<b>)之后关闭。
HTML 标签可以有属性,这些属性是用于配置标签的键值对,如下例所示:
<img src="my_image.png" width="100px" height="20px">
前面的标签是一个用于显示图像的img(图像)标签。 其属性是src(指示图像文件路径),width(指示显示图像的宽度)和height(指示显示的高度)。
HTML 属性是以空格分隔的,所以不要在它们之间放逗号。 值可以用单引号或双引号引用,或者如果它们不包含空格或其他令人困惑的字符(例如闭合尖括号)则不引用; 但通常最好用双引号引用它们。 在 Qt HTML 中,大小通常以px(像素)或%(百分比)指定,尽管在现代 Web HTML 中,通常使用其他单位。
样式表语法
现代 HTML 使用层叠样式表(CSS)进行样式设置。 在第六章中,为 Qt 应用程序设置样式,我们讨论了 QSS 时学习了 CSS。 回顾一下,CSS 允许您对标签的外观进行声明,如下所示:
b {
color: red;
font-size: 16pt;
}
前面的 CSS 指令将使粗体标签内的所有内容(在<b>和</b>之间)以红色 16 点字体显示。
某些标签也可以有修饰符,例如:
a:hovered {
color: green;
font-size: 16pt;
}
前面的 CSS 适用于<a>(锚点)标签内容,但仅当鼠标指针悬停在锚点上时。 这样的修饰符也称为伪类。
语义标签与装饰标签
一些 HTML 标签描述了内容应该如何显示。 我们称这些为装饰标签。 例如,<i>标签表示文本应以斜体字打印。 但请注意,斜体字在现代印刷中有许多用途-强调一个词,表示已出版作品的标题,或表示短语来自外语。 为了区分这些用途,HTML 还有语义标签。 例如,<em>表示强调,并且在大多数情况下会导致斜体文本。 但与<i>标签不同,它还指示文本应该以何种方式斜体。 HTML 的旧版本通常侧重于装饰标签,而较新版本则越来越注重语义标签。
Qt 的富文本 HTML 支持一些语义标签,但它们只是等效的装饰标签。
现代 HTML 和 CSS 在网页上使用的内容远不止我们在这里描述的,但我们所涵盖的内容足以理解 Qt 小部件使用的有限子集。 如果您想了解更多,请查看本章末尾的进一步阅读部分中的资源。
结构和标题标签
为了尝试丰富的文本标记,我们将为我们的下一个大型游戏Fight Fighter 2编写广告,并在 QTextBrowser 中查看它。 首先,从第四章中获取应用程序模板,使用 QMainWindow 构建应用程序,并将其命名为qt_richtext_demo.py。
在MainWindow.__init__()中,像这样添加一个QTextBrowser对象作为主窗口小部件:
main = qtw.QTextBrowser()
self.setCentralWidget(main)
with open('fight_fighter2.html', 'r') as fh:
main.insertHtml(fh.read())
QTextBrowser基于QTextEdit,但是只读并预先配置为导航超文本链接。 创建文本浏览器后,我们打开fight_fighter2.html文件,并使用insertHtml()方法将其内容插入浏览器。 现在,我们可以编辑fight_fighter2.html并查看它在 PyQt 中的呈现方式。
在编辑器中打开fight_fighter2.html并从以下代码开始:
<qt>
<body>
<h1>Fight Fighter 2</h1>
<hr>
HTML 文档是按层次结构构建的,最外层的标签通常是<html>。 但是,当将 HTML 传递给基于QTextDocument的小部件时,我们还可以使用<qt>作为最外层的标签,这是一个好主意,因为它提醒我们正在编写 Qt 支持的 HTML 子集,而不是实际的 HTML。
在其中,我们有一个<body>标签。 这个标签也是可选的,但它将使未来的样式更容易。
接下来,我们在<h1>标签内有一个标题。这里的H代表标题,标签<h1>到<h6>表示从最外层到最内层的部分标题。这个标签将以更大更粗的字体呈现,表明它是部分的标题。
在标题之后,我们有一个<hr>标签来添加水平线。默认情况下,<hr>会产生一个单像素厚的黑线,但可以使用样式表进行自定义。
让我们添加以下常规文本内容:
<p>Everything you love about fight-fighter, but better!</p>
<p>标签,或段落标签,表示一块文本。在段落标签中不严格需要包含文本内容,但要理解 HTML 默认不会保留换行。如果你想要通过换行来分隔不同的段落,你需要将它们放在段落标签中。(你也可以插入<br>标签,但是段落标签被认为是更语义化的更干净的方法。)
接下来,添加第一个子标题,如下所示:
<h2>About</h2>
在<h1>下的任何子部分应该是<h2>;在<h2>内的任何子部分应该是<h3>,依此类推。标题标签是语义标签的例子,表示文档层次结构的级别。
永远不要根据它们产生的外观来选择标题级别——例如,不要在<h1>下使用<h4>,只是因为你想要更小的标题文本。使用它们语义化,并使用样式来调整外观(参见字体、颜色、图片和样式部分了解更多信息)。
排版标签
Qt 富文本支持许多标签来改变文本的基本外观,如下所示:
<p>Fight fighter 2 is the <i>amazing</i> sequel to <u>Fight Fighter</u>, an <s>intense</s> ultra-intense multiplayer action game from <b>FightSoft Software, LLC</b>.</p>
在这个例子中,我们使用了以下标签:
| 标签 | 结果 |
|---|---|
<i> |
斜体 |
<b> |
粗体 |
<u> |
下划线 |
<s> |
删除线 |
这些是装饰性标签,它们每个都会改变标签内文本的外观。除了这些标签,还支持一些用于文本大小和位置的较少使用的标签,包括以下内容:
<p>Fight Fighter 2's new Ultra-Action<sup>TM</sup> technology delivers low-latency combat like never before. Best of all, at only $1.99<sub>USD</sub>, you <big>Huge Action</big> for a <small>tiny</small> price.</p>
在前面的例子中,我们可以看到<sup>和<sub>标签,分别提供上标和下标文本,以及<big>和<small>标签,分别提供稍微更大或更小的字体。
超链接
超链接也可以使用<a>(锚点)标签添加到 Qt 富文本中,如下所示:
<p>Download it today from
<a href='http://www.example.com'>Example.com</a>!</p>
超链接的确切行为取决于显示超链接的部件和部件的设置。
QTextBrowser默认会尝试在部件内导航到超链接;但请记住,这些链接只有在它们是资源 URL 或本地文件路径时才会起作用。QTextBrowser缺乏网络堆栈,不能用于浏览互联网。
然而,它可以配置为在外部浏览器中打开 URL;在 Python 脚本中,添加以下代码到MainWindow.__init__():
main.setOpenExternalLinks(True)
这利用QDesktopServices.openUrl()来在桌面的默认浏览器中打开锚点的href值。每当你想要在文档中支持外部超链接时,你应该配置这个设置。
外部超链接也可以在QLabel部件上进行配置,但不能在QTextEdit部件内进行配置。
文档也可以使用超链接来在文档内部导航,如下所示:
<p><a href='#Features'>Read about the features</a></p>
<br><br><br><br><br><br>
<a name='Features'></a>
<h2>Features</h2>
<p>Fight Fighter 2 is so amazing in so many ways:</p>
在这里,我们添加了一个指向#Features(带有井号)的锚点,然后是一些换行来模拟更多的内容。当用户点击链接时,它将滚动浏览器部件到具有name属性(而不是href)为Features的锚点标签(不带井号)。
这个功能对于提供可导航的目录表格非常有用。
列表和表格
列表和表格非常有用,可以以用户能够快速解析的方式呈现有序信息。
列表的一个例子如下:
<ul type=square>
<li>More players at once! Have up to 72 players.</li>
<li>More teams! Play with up to 16 teams!</li>
<li>Easier installation! Simply:<ol>
<li>Copy the executable to your system.</li>
<li>Run it!</li>
</ol></li>
<li>Sound and music! >16 Million colors on some systems!</li>
</ul>
Qt 富文本中的列表可以是有序或无序的。在上面的例子中,我们有一个无序列表(<ul>)。可选的type属性允许您指定应使用什么样的项目符号。在这种情况下,我们选择了square;无序列表的其他选项包括circle和disc。
使用<li>(列表项)标签指定列表中的每个项目。我们还可以在列表项内部嵌套一个列表,以创建一个子列表。在这种情况下,我们添加了一个有序列表,它将使用顺序号来指示新项目。有序列表还接受type属性;有效值为a(小写字母)、A(大写字母)或1(顺序号)。
在最后一个项目中的>是 HTML 实体的一个例子。这些是特殊代码,用于显示 HTML 特殊字符,如尖括号,或非 ASCII 字符,如版权符号。实体以一个和号开始,以一个冒号结束,并包含一个指示要显示的字符的字符串。在这种情况下,gt代表greater than。可以在dev.w3.org/html5/html-author/charref找到官方实体列表,尽管并非所有实体都受QTextDocument支持。
创建 HTML 表格稍微复杂,因为它需要多层嵌套。表标签的层次结构如下:
-
表格本身由
<table>标签定义 -
表的标题部分由
<thead>标签定义 -
表的每一行(标题或数据)由
<tr>(表行)标签定义 -
在每一行中,表格单元格由
<th>(表头)标签或<td>(表数据)标签定义
让我们用以下代码开始一个表格:
<table border=2>
<thead>
<tr bgcolor='grey'>
<th>System</th><th>Graphics</th><th>Sound</th></tr>
</thead>
在上面的例子中,我们从开头的<table>标签开始。border属性指定了表格边框的宽度(以像素为单位);在这种情况下,我们希望有一个两像素的边框。请记住,这个边框围绕每个单元格,不会合并(也就是说,不会与相邻单元格的边框合并),因此实际上,每个单元格之间将有一个四像素的边框。表格边框可以有不同的样式;默认情况下使用ridge样式,因此这个边框将被着色,看起来略微立体。
在<thead>部分,有一行表格,填满了表头单元格。通过设置行的bgcolor属性,我们可以将所有表头单元格的背景颜色更改为灰色。
现在,让我们用以下代码添加一些数据行:
<tr><td>Windows</td><td>DirectX 3D</td><td>24 bit PCM</td></tr>
<tr><td>FreeDOS</td><td>256 color</td><td>8 bit Adlib PCM</td></tr>
<tr><td>Commodore 64</td><td>256 color</td><td>SID audio</td></tr>
<tr><td>TRS80</td>
<td rowspan=2>Monochrome</td>
<td rowspan=2>Beeps</td>
</tr>
<tr><td>Timex Sinclair</td></tr>
<tr>
<td>BBC Micro</td>
<td colspan=2 bgcolor='red'>No support</td>
</tr>
</table>
在上面的例子中,行包含了用于实际表格数据的<td>单元格。请注意,我们可以在单个单元格上使用rowspan和colspan属性,使它们占用额外的行和列,并且bgcolor属性也可以应用于单个单元格。
可以将数据行包装在<tbody>标签中,以使其与<thead>部分区分开,但这实际上在 Qt 富文本 HTML 中没有任何有用的影响。
字体、颜色、图像和样式
可以使用<font>标签设置富文本字体,如下所示:
<h2>Special!</h2>
<p>
<font face='Impact' size=32 color='green'>Buy Now!</font>
and receive <tt>20%</tt> off the regular price plus a
<font face=Impact size=16 color='red'>Free sticker!</font>
</p>
<font>对于那些学习了更现代 HTML 的人可能会感到陌生,因为它在 HTML 5 中已被弃用。但正如您所看到的,它可以用来设置标签中的文本的face、size和color属性。
<tt>(打字机类型)标签是使用等宽字体的简写,对于呈现内联代码、键盘快捷键和终端输出非常有用。
如果您更喜欢使用更现代的 CSS 样式字体配置,可以通过在块级标签(如<div>)上设置style属性来实现:
<div style='font-size: 16pt; font-weight: bold; color: navy;
background-color: orange; padding: 20px;
text-align: center;'>
Don't miss this exciting offer!
</div>
在style属性中,您可以设置任何支持的 CSS 值,以应用于该块。
文档范围的样式
Qt 富文本文档不支持 HTML <style>标签或<link>标签来设置文档范围的样式表。相反,您可以使用QTextDocument对象的setDefaultStyleSheet()方法来设置一个 CSS 样式表,该样式表将应用于所有查看的文档。
回到MainWindow.__init__(),添加以下内容:
main.document().setDefaultStyleSheet(
'body {color: #333; font-size: 14px;} '
'h2 {background: #CCF; color: #443;} '
'h1 {background: #001133; color: white;} '
)
但是,请注意,这必须在 HTML 插入小部件之前添加。defaultStyleSheet方法仅适用于新插入的 HTML。
还要注意,外观的某些方面不是文档的属性,而是小部件的属性。特别是,文档的背景颜色不能通过修改body的样式来设置。
相反,设置小部件的样式表,如下所示:
main.setStyleSheet('background-color: #EEF;')
请记住,小部件的样式表使用 QSS,而文档的样式表使用 CSS。区别是微小的,但在某些情况下可能会起作用。
图片
可以使用<img>标签插入图像,如下所示:
<div>
<img src=logo.png width=400 height=100 />
</div>
src属性应该是 Qt 支持的图像文件的文件或资源路径(有关图像格式支持的更多信息,请参见第六章,Qt 应用程序的样式)。width和height属性可用于强制指定特定大小。
Qt 富文本和 Web HTML 之间的区别
如果您有网页设计或开发经验,您无疑已经注意到 Qt 的富文本标记与现代网页浏览器中使用的 HTML 之间的几个区别。在创建富文本时,重要的是要记住这些区别,所以让我们来看一下主要的区别。
首先,Qt 富文本基于 HTML 4 和 CSS 2.1;正如您所见,它包括一些已弃用的标签,如<font>,并排除了许多更现代的标签,如<section>或<figure>。
此外,Qt 富文本基于这些规范的一个子集,因此它不支持许多标签。例如,没有输入或表单相关的标签,如<select>或<textarea>。
QTextDocument在语法错误和大小写方面也比大多数网页浏览器渲染器更严格。例如,当设置默认样式表时,标签名称的大小写需要与文档中使用的大小写匹配,否则样式将不会应用。此外,未使用块级标签(如<p>、<div>等)包围内容可能会导致不可预测的结果。
简而言之,最好不要将 Qt 富文本标记视为真正的 HTML,而是将其视为一种类似但独立的标记语言。如果您对特定标记或样式指令是否受支持有任何疑问,请参阅doc.qt.io/qt-5/richtext-html-subset.html上的支持参考。
使用 QTextDocument 操作富文本
除了允许我们在标记中指定富文本外,Qt 还为我们提供了一个 API 来编程创建和操作富文本。这个 API 称为Qt Scribe Framework,它是围绕QTextDocument和QTextCursor类构建的。
演示如何使用QTextDocument和QTextCursor类创建文档,我们将构建一个简单的发票生成器应用程序。我们的应用程序将从小部件表单中获取数据,并使用它来编程生成富文本文档。
创建发票应用程序 GUI
获取我们的 PyQt 应用程序模板的最新副本,并将其命名为invoice_maker.py。我们将通过创建 GUI 元素开始我们的应用程序,然后开发实际构建文档的方法。
从一个数据输入表单类开始您的脚本,如下所示:
class InvoiceForm(qtw.QWidget):
submitted = qtc.pyqtSignal(dict)
def __init__(self):
super().__init__()
self.setLayout(qtw.QFormLayout())
self.inputs = dict()
self.inputs['Customer Name'] = qtw.QLineEdit()
self.inputs['Customer Address'] = qtw.QPlainTextEdit()
self.inputs['Invoice Date'] = qtw.QDateEdit(
date=qtc.QDate.currentDate(), calendarPopup=True)
self.inputs['Days until Due'] = qtw.QSpinBox(
minimum=0, maximum=60, value=30)
for label, widget in self.inputs.items():
self.layout().addRow(label, widget)
与我们创建的大多数表单一样,这个类基于QWidget,并通过定义一个submitted信号来携带表单值的字典来开始。在这里,我们还向QFormLayout添加了各种输入,以输入基本的发票数据,如客户名称、客户地址和发票日期。
接下来,我们将添加QTableWidget以输入发票的行项目,如下所示:
self.line_items = qtw.QTableWidget(
rowCount=10, columnCount=3)
self.line_items.setHorizontalHeaderLabels(
['Job', 'Rate', 'Hours'])
self.line_items.horizontalHeader().setSectionResizeMode(
qtw.QHeaderView.Stretch)
self.layout().addRow(self.line_items)
for row in range(self.line_items.rowCount()):
for col in range(self.line_items.columnCount()):
if col > 0:
w = qtw.QSpinBox(minimum=0)
self.line_items.setCellWidget(row, col, w)
该表格小部件的每一行都包含任务的描述、工作的费率和工作的小时数。因为最后两列中的值是数字,所以我们使用表格小部件的setCellWidget()方法来用QSpinBox小部件替换这些单元格中的默认QLineEdit小部件。
最后,我们将使用以下代码添加一个submit按钮:
submit = qtw.QPushButton('Create Invoice', clicked=self.on_submit)
self.layout().addRow(submit)
submit按钮调用一个on_submit()方法,开始如下:
def on_submit(self):
data = {
'c_name': self.inputs['Customer Name'].text(),
'c_addr': self.inputs['Customer Address'].toPlainText(),
'i_date': self.inputs['Invoice Date'].date().toString(),
'i_due': self.inputs['Invoice Date'].date().addDays(
self.inputs['Days until Due'].value()).toString(),
'i_terms': '{} days'.format(
self.inputs['Days until Due'].value())
}
该方法只是简单地提取输入表单中输入的值,进行一些计算,并使用submitted信号发射生成的数据dict。在这里,我们首先通过使用每个小部件的适当方法将表单的每个输入小部件的值放入 Python 字典中。
接下来,我们需要检索行项目的数据,如下所示:
data['line_items'] = list()
for row in range(self.line_items.rowCount()):
if not self.line_items.item(row, 0):
continue
job = self.line_items.item(row, 0).text()
rate = self.line_items.cellWidget(row, 1).value()
hours = self.line_items.cellWidget(row, 2).value()
total = rate * hours
row_data = [job, rate, hours, total]
if any(row_data):
data['line_items'].append(row_data)
对于表格小部件中具有描述的每一行,我们将检索所有数据,通过将费率和工时相乘来计算总成本,并将所有数据附加到我们的data字典中的列表中。
最后,我们将计算一个总成本,并使用以下代码将其附加到:
data['total_due'] = sum(x[3] for x in data['line_items'])
self.submitted.emit(data)
在每一行的成本总和之后,我们将其添加到数据字典中,并使用数据发射我们的submitted信号。
这就是我们的form类,所以让我们在MainWindow中设置主应用程序布局。在MainWindow.__init__()中,添加以下代码:
main = qtw.QWidget()
main.setLayout(qtw.QHBoxLayout())
self.setCentralWidget(main)
form = InvoiceForm()
main.layout().addWidget(form)
self.preview = InvoiceView()
main.layout().addWidget(self.preview)
form.submitted.connect(self.preview.build_invoice)
主小部件被赋予一个水平布局,以包含格式化发票的表单和视图小部件。然后,我们将表单的submitted信号连接到视图对象上将创建的build_invoice()方法。
这是应用程序的主要 GUI 和逻辑;现在我们只需要创建我们的InvoiceView类。
构建 InvoiceView
InvoiceView类是所有繁重工作发生的地方;我们将其基于只读的QTextEdit小部件,并且它将包含一个build_invoice()方法,当使用数据字典调用时,将使用 Qt Scribe 框架构建格式化的发票文档。
让我们从构造函数开始,如下例所示:
class InvoiceView(qtw.QTextEdit):
dpi = 72
doc_width = 8.5 * dpi
doc_height = 11 * dpi
def __init__(self):
super().__init__(readOnly=True)
self.setFixedSize(qtc.QSize(self.doc_width, self.doc_height))
首先,我们为文档的宽度和高度定义了类变量。我们选择这些值是为了给我们一个标准的美国信件大小文档的纵横比,适合于普通计算机显示器的合理尺寸。在构造函数中,我们使用计算出的值来设置小部件的固定大小。这是我们在构造函数中需要做的所有事情,所以现在是时候开始真正的工作了——构建一个文档。
让我们从build_invoice()开始,如下所示:
def build_invoice(self, data):
document = qtg.QTextDocument()
self.setDocument(document)
document.setPageSize(qtc.QSizeF(self.doc_width, self.doc_height))
正如您在前面的示例中所看到的,该方法首先创建一个新的QTextDocument对象,并将其分配给视图的document属性。然后,使用在类定义中计算的文档尺寸设置pageSize属性。请注意,我们基于 QTextEdit 的视图已经有一个我们可以检索的document对象,但我们正在创建一个新的对象,以便该方法每次调用时都会以空文档开始。
使用QTextDocument编辑文档可能会感觉有点不同于我们创建 GUI 表单的方式,通常我们会创建对象,然后配置并将它们放置在布局中。
相反,QTextDocument的工作流更像是一个文字处理器:
-
有一个
cursor始终指向文档中的某个位置 -
有一个活动文本样式、段落样式或另一个块级样式,其设置将应用于输入的任何内容
-
要添加内容,用户首先要定位光标,配置样式,最后创建内容
因此,显然,第一步是获取光标的引用;使用以下代码来实现:
cursor = qtg.QTextCursor(document)
QTextCursor对象是我们用来插入内容的工具,并且它有许多方法可以将不同类型的元素插入文档中。
例如,在这一点上,我们可以开始插入文本内容,如下所示:
cursor.insertText("Invoice, woohoo!")
然而,在我们开始向文档中写入内容之前,我们应该构建一个基本的文档框架来进行工作。为了做到这一点,我们需要了解QTextDocument对象的结构。
QTextDocument 结构
就像 HTML 文档一样,QTextDocument对象是一个分层结构。它由框架、块和片段组成,定义如下:
-
框架由
QTextFrame对象表示,是文档的矩形区域,可以包含任何类型的内容,包括其他框架。在我们的层次结构顶部是根框架,它包含了文档的所有内容。 -
一个块,由
QTextBlock对象表示,是由换行符包围的文本区域,例如段落或列表项。 -
片段,由
QTextFragment对象表示,是块内的连续文本区域,共享相同的文本格式。例如,如果您有一个句子中包含一个粗体字,那么代表三个文本片段:粗体字之前的句子,粗体字,和粗体字之后的句子。 -
其他项目,如表格、列表和图像,都是从这些前面的类中派生出来的。
我们将通过在根框架下插入一组子框架来组织我们的文档,以便我们可以轻松地导航到我们想要处理的文档部分。我们的文档将有以下四个框架:
-
标志框架将包含公司标志和联系信息
-
客户地址框架将保存客户姓名和地址
-
条款框架将保存发票条款和条件的列表
-
行项目框架将保存行项目和总计的表格
让我们创建一些文本框架来概述我们文档的结构。我们将首先保存对根框架的引用,以便在创建子框架后可以轻松返回到它,如下所示:
root = document.rootFrame()
既然我们有了这个,我们可以通过调用以下命令在任何时候为根框架的末尾检索光标位置:
cursor.setPosition(root.lastPosition())
光标的setPosition()方法将我们的光标放在任何给定位置,根框架的lastPosition()方法检索根框架末尾的位置。
现在,让我们定义第一个子框架,如下所示:
logo_frame_fmt = qtg.QTextFrameFormat()
logo_frame_fmt.setBorder(2)
logo_frame_fmt.setPadding(10)
logo_frame = cursor.insertFrame(logo_frame_fmt)
框架必须使用定义其格式的QTextFrameFormat对象创建,因此在我们写框架之前,我们必须定义我们的格式。不幸的是,框架格式的属性不能使用关键字参数设置,因此我们必须使用 setter 方法进行配置。在这个例子中,我们设置了框架周围的两像素边框,以及十像素的填充。
一旦格式对象被创建,我们调用光标的insertFrame()方法来使用我们配置的格式创建一个新框架。
insertFrame()返回创建的QTextFrame对象,并且将我们文档的光标定位在新框架内。由于我们还没有准备好向这个框架添加内容,并且我们不想在其中创建下一个框架,所以我们需要使用以下代码返回到根框架之前创建下一个框架:
cursor.setPosition(root.lastPosition())
cust_addr_frame_fmt = qtg.QTextFrameFormat()
cust_addr_frame_fmt.setWidth(self.doc_width * .3)
cust_addr_frame_fmt.setPosition(qtg.QTextFrameFormat.FloatRight)
cust_addr_frame = cursor.insertFrame(cust_addr_frame_fmt)
在上面的例子中,我们使用框架格式来将此框架的宽度设置为文档宽度的三分之一,并使其浮动到右侧。浮动文档框架意味着它将被推到文档的一侧,其他内容将围绕它流动。
现在,我们将添加术语框架,如下所示:
cursor.setPosition(root.lastPosition())
terms_frame_fmt = qtg.QTextFrameFormat()
terms_frame_fmt.setWidth(self.doc_width * .5)
terms_frame_fmt.setPosition(qtg.QTextFrameFormat.FloatLeft)
terms_frame = cursor.insertFrame(terms_frame_fmt)
这一次,我们将使框架的宽度为文档宽度的一半,并将其浮动到左侧。
理论上,这两个框架应该相邻。实际上,由于QTextDocument类渲染中的一个怪癖,第二个框架的顶部将在第一个框架的顶部下面一行。这对我们的演示来说没问题,但如果您需要实际的列,请改用表格。
最后,让我们添加一个框架来保存我们的行项目表格,如下所示:
cursor.setPosition(root.lastPosition())
line_items_frame_fmt = qtg.QTextFrameFormat()
line_items_frame_fmt.setMargin(25)
line_items_frame = cursor.insertFrame(line_items_frame_fmt)
再次,我们将光标移回到根框架并插入一个新框架。这次,格式将在框架上添加 25 像素的边距。
请注意,如果我们不想对QTextFrameFormat对象进行任何特殊配置,我们就不必这样做,但是必须为每个框架创建一个对象,并且必须在创建新框架之前对它们进行任何配置。请注意,如果您有许多具有相同配置的框架,也可以重用框架格式。
字符格式
就像框架必须使用框架格式创建一样,文本内容必须使用字符格式创建,该格式定义了文本的字体和对齐等属性。在我们开始向框架添加内容之前,我们应该定义一些常见的字符格式,以便在文档的不同部分使用。
这是使用QTextCharFormat类完成的,如下所示:
std_format = qtg.QTextCharFormat()
logo_format = qtg.QTextCharFormat()
logo_format.setFont(
qtg.QFont('Impact', 24, qtg.QFont.DemiBold))
logo_format.setUnderlineStyle(
qtg.QTextCharFormat.SingleUnderline)
logo_format.setVerticalAlignment(
qtg.QTextCharFormat.AlignMiddle)
label_format = qtg.QTextCharFormat()
label_format.setFont(qtg.QFont('Sans', 12, qtg.QFont.Bold))
在前面的示例中,我们创建了以下三种格式:
-
std_format,将用于常规文本。我们不会改变默认设置。 -
logo_format,将用于我们的公司标志。我们正在自定义其字体并添加下划线,以及设置其垂直对齐。 -
label_format,将用于标签;它们将使用 12 号字体并加粗。
请注意,QTextCharFormat允许您直接使用 setter 方法进行许多字体配置,或者甚至可以配置一个QFont对象分配给格式。我们将在文档的其余部分添加文本内容时使用这三种格式。
添加基本内容
现在,让我们使用以下命令向我们的logo_frame添加一些基本内容:
cursor.setPosition(logo_frame.firstPosition())
就像我们调用根框架的lastPosition方法来获取其末尾的位置一样,我们可以调用标志框架的firstPosition()方法来获取框架开头的位置。一旦在那里,我们可以插入内容,比如标志图像,如下所示:
cursor.insertImage('nc_logo.png')
图片可以像这样插入——通过将图像的路径作为字符串传递。然而,这种方法在配置方面提供的内容很少,所以让我们尝试一种稍微复杂的方法:
logo_image_fmt = qtg.QTextImageFormat()
logo_image_fmt.setName('nc_logo.png')
logo_image_fmt.setHeight(48)
cursor.insertImage(logo_image_fmt, qtg.QTextFrameFormat.FloatLeft)
通过使用QTextImageFormat对象,我们可以首先配置图像的各个方面,如其高度和宽度,然后将其添加到枚举常量指定其定位策略。在这种情况下,FloatLeft将导致图像与框架的左侧对齐,并且随后的文本将围绕它。
现在,让我们在块中写入以下文本:
cursor.insertText(' ')
cursor.insertText('Ninja Coders, LLC', logo_format)
cursor.insertBlock()
cursor.insertText('123 N Wizard St, Yonkers, NY 10701', std_format)
使用我们的logo_format,我们已经编写了一个包含公司名称的文本片段,然后插入了一个新块,这样我们就可以在另一行上添加包含地址的另一个片段。请注意,传递字符格式是可选的;如果我们不这样做,片段将以当前活动格式插入,就像在文字处理器中一样。
处理完我们的标志后,现在让我们来处理客户地址块,如下所示:
cursor.setPosition(cust_addr_frame.lastPosition())
文本块可以像框架和字符一样具有格式。让我们使用以下代码创建一个文本块格式,用于我们的客户地址:
address_format = qtg.QTextBlockFormat()
address_format.setAlignment(qtc.Qt.AlignRight)
address_format.setRightMargin(25)
address_format.setLineHeight(
150, qtg.QTextBlockFormat.ProportionalHeight)
文本块格式允许您更改文本段落中更改的设置:边距、行高、缩进和对齐。在这里,我们将文本对齐设置为右对齐,右边距为 25 像素,行高为 1.5 行。在QTextDocument中有多种指定高度的方法,setLineHeight()的第二个参数决定了传入值的解释方式。在这种情况下,我们使用ProportionalHeight模式,它将传入的值解释为行高的百分比。
我们可以将我们的块格式对象传递给任何insertBlock调用,如下所示:
cursor.insertBlock(address_format)
cursor.insertText('Customer:', label_format)
cursor.insertBlock(address_format)
cursor.insertText(data['c_name'], std_format)
cursor.insertBlock(address_format)
cursor.insertText(data['c_addr'])
每次插入一个块,就像开始一个新段落一样。我们的多行地址字符串将被插入为一个段落,但请注意,它仍将被间隔为 1.5 行。
插入列表
我们的发票条款将以无序项目列表的形式呈现。有序和无序列表可以使用光标的insertList()方法插入到QTextDocument中,如下所示:
cursor.setPosition(terms_frame.lastPosition())
cursor.insertText('Terms:', label_format)
cursor.insertList(qtg.QTextListFormat.ListDisc)
insertList()的参数可以是QTextListFormat对象,也可以是QTextListFormat.Style枚举中的常量。在这种情况下,我们使用了后者,指定我们希望使用圆盘样式的项目列表。
列表格式的其他选项包括ListCircle和ListSquare用于无序列表,以及ListDecimal、ListLowerAlpha、ListUpperAlpha、ListUpperRoman和ListLowerRoman用于有序列表。
现在,我们将定义要插入到我们的列表中的一些项目,如下所示:
term_items = (
f'<b>Invoice dated:</b> {data["i_date"]}',
f'<b>Invoice terms:</b> {data["i_terms"]}',
f'<b>Invoice due:</b> {data["i_due"]}',
)
请注意,在上面的示例中,我们使用的是标记,而不是原始字符串。在使用QTextCursor创建文档时,仍然可以使用标记;但是,您需要通过调用insertHtml()而不是insertText()来告诉光标它正在插入 HTML 而不是纯文本,如下例所示:
for i, item in enumerate(term_items):
if i > 0:
cursor.insertBlock()
cursor.insertHtml(item)
在调用insertList()之后,我们的光标位于第一个列表项内,因此现在我们需要调用insertBlock()来到达后续项目(对于第一个项目,我们不需要这样做,因为我们已经处于项目符号中,因此需要进行if i > 0检查)。
与insertText()不同,insertHtml()不接受字符格式对象。您必须依靠您的标记来确定格式。
插入表格
我们要在发票中插入的最后一件事是包含我们的行项目的表格。QTextTable是QTextFrame的子类,就像框架一样,我们需要在创建表格本身之前为其创建格式对象。
我们需要的类是QTextTableFormat类:
table_format = qtg.QTextTableFormat()
table_format.setHeaderRowCount(1)
table_format.setWidth(
qtg.QTextLength(qtg.QTextLength.PercentageLength, 100))
在这里,我们配置了headerRowCount属性,该属性表示第一行是标题行,并且应在每页顶部重复。这相当于在标记中将第一行放在<thead>标记中。
我们还设置了宽度,但是我们没有使用像素值,而是使用了QTextLength对象。这个类的命名有些令人困惑,因为它不是特指文本的长度,而是指您可能在QTextDocument中需要的任何通用长度。QTextLength对象可以是百分比、固定或可变类型;在这种情况下,我们指定了值为100或 100%的PercentageLength。
现在,让我们使用以下代码插入我们的表格:
headings = ('Job', 'Rate', 'Hours', 'Cost')
num_rows = len(data['line_items']) + 1
num_cols = len(headings)
cursor.setPosition(line_items_frame.lastPosition())
table = cursor.insertTable(num_rows, num_cols, table_format)
在将表格插入QTextDocument时,我们不仅需要定义格式,还需要指定行数和列数。为此,我们创建了标题的元组,然后通过计算行项目列表的长度(为标题行添加 1),以及标题元组的长度来计算行数和列数。
然后,我们需要将光标定位在行项目框中并插入我们的表格。就像其他插入方法一样,insertTable()将我们的光标定位在插入的项目内部,即第一行的第一列。
现在,我们可以使用以下代码插入我们的标题行:
for heading in headings:
cursor.insertText(heading, label_format)
cursor.movePosition(qtg.QTextCursor.NextCell)
到目前为止,我们一直通过将确切位置传递给setPosition()来定位光标。QTextCursor对象还具有movePosition()方法,该方法可以接受QTextCursor.MoveOperation枚举中的常量。该枚举定义了表示约两打不同光标移动的常量,例如StartOfLine、PreviousBlock和NextWord。在这种情况下,NextCell移动将我们带到表格中的下一个单元格。
我们可以使用相同的方法来插入我们的数据,如下所示:
for row in data['line_items']:
for col, value in enumerate(row):
text = f'${value}' if col in (1, 3) else f'{value}'
cursor.insertText(text, std_format)
cursor.movePosition(qtg.QTextCursor.NextCell)
在这种情况下,我们正在迭代数据列表中每一行的每一列,并使用insertText()将数据添加到单元格中。如果列号为1或3,即货币值,我们需要在显示中添加货币符号。
我们还需要添加一行来保存发票的总计。要在表格中添加额外的行,我们可以使用以下QTextTable.appendRows()方法:
table.appendRows(1)
为了将光标定位到新行中的特定单元格中,我们可以使用表对象的cellAt()方法来检索一个QTableCell对象,然后使用该对象的lastCursorPosition()方法,该方法返回一个位于单元格末尾的新光标,如下所示:
cursor = table.cellAt(num_rows, 0).lastCursorPosition()
cursor.insertText('Total', label_format)
cursor = table.cellAt(num_rows, 3).lastCursorPosition()
cursor.insertText(f"${data['total_due']}", label_format)
这是我们需要写入发票文档的最后一部分内容,所以让我们继续测试一下。
完成和测试
现在,如果您运行您的应用程序,填写字段,然后点击创建发票,您应该会看到类似以下截图的内容:

看起来不错!当然,如果我们无法打印或导出发票,那么这张发票对我们就没有什么用处。因此,在下一节中,我们将看看如何处理文档的打印。
打印富文本
没有什么能像被要求实现打印机支持那样让程序员心生恐惧。将原始的数字位转化为纸上的墨迹在现实生活中是混乱的,在软件世界中也可能一样混乱。幸运的是,Qt 提供了QtPrintSupport模块,这是一个跨平台的打印系统,可以轻松地将QTextDocument转换为硬拷贝格式,无论我们使用的是哪个操作系统。
更新发票应用程序以支持打印
在我们将文档的尺寸硬编码为 8.5×11 时,美国以外的读者几乎肯定会感到沮丧,但不要担心——我们将进行一些更改,以便根据用户选择的文档尺寸来设置尺寸。
在InvoiceView类中,创建以下新方法set_page_size(),以设置页面大小:
def set_page_size(self, qrect):
self.doc_width = qrect.width()
self.doc_height = qrect.height()
self.setFixedSize(qtc.QSize(self.doc_width, self.doc_height))
self.document().setPageSize(
qtc.QSizeF(self.doc_width, self.doc_height))
该方法将接收一个QRect对象,从中提取宽度和高度值以更新文档的设置、小部件的固定大小和文档的页面大小。
在MainWindow.__init__()中,添加一个工具栏来控制打印,并设置以下操作:
print_tb = self.addToolBar('Printing')
print_tb.addAction('Configure Printer', self.printer_config)
print_tb.addAction('Print Preview', self.print_preview)
print_tb.addAction('Print dialog', self.print_dialog)
print_tb.addAction('Export PDF', self.export_pdf)
当我们设置每个打印过程的各个方面时,我们将实现这些回调。
配置打印机
打印始于一个QtPrintSupport.QPrinter对象,它代表内存中的打印文档。在 PyQt 中打印的基本工作流程如下:
-
创建一个
QPrinter对象 -
使用其方法或打印机配置对话框配置
QPrinter对象 -
将
QTextDocument打印到QPrinter对象 -
将
QPrinter对象传递给操作系统的打印对话框,用户可以使用物理打印机进行打印
在MainWindow.__init__()中,让我们创建我们的QPrinter对象,如下所示:
self.printer = qtps.QPrinter()
self.printer.setOrientation(qtps.QPrinter.Portrait)
self.printer.setPageSize(qtg.QPageSize(qtg.QPageSize.Letter))
打印机创建后,我们可以配置许多属性;在这里,我们只是设置了方向和页面大小(再次设置为美国信纸默认值,但可以随意更改为您喜欢的纸张大小)。
您可以通过QPrinter方法配置打印机设置对话框中的任何内容,但理想情况下,我们宁愿让用户做出这些决定。因此,让我们实现以下printer_config()方法:
def printer_config(self):
dialog = qtps.QPageSetupDialog(self.printer, self)
dialog.exec()
QPageSetupDialog对象是一个QDialog子类,显示了QPrinter对象可用的所有选项。我们将我们的QPrinter对象传递给它,这将导致对话框中所做的任何更改应用于该打印机对象。在 Windows 和 macOS 上,Qt 将默认使用操作系统提供的打印对话框;在其他平台上,将使用一个特定于 Qt 的对话框。
现在用户可以配置纸张大小,我们需要允许InvoiceView在每次更改后重置页面大小。因此,让我们在MainWindow中添加以下方法:
def _update_preview_size(self):
self.preview.set_page_size(
self.printer.pageRect(qtps.QPrinter.Point))
QPrinter.pageRect()方法提取了一个QRect对象,定义了配置的页面大小。由于我们的InvoiceView.set_page_size()方法接受一个QRect,我们只需要将这个对象传递给它。
请注意,我们已经将一个常量传递给pageRect(),表示我们希望以点为单位获取大小。点是英寸的 1/72,因此我们的小部件大小将是物理页面尺寸的 72 倍英寸。如果您想要自己计算以缩放小部件大小,您可以请求以各种单位(包括毫米、Picas、英寸等)获取页面矩形。
不幸的是,QPrinter对象不是QObject的后代,因此我们无法使用信号来确定其参数何时更改。
现在,在printer_config()的末尾添加对self._update_preview_size()的调用,这样每当用户配置页面时都会被调用。您会发现,如果您在打印机配置对话框中更改纸张的大小,您的预览小部件将相应地调整大小。
打印一页
在我们实际打印文档之前,我们必须首先将QTextDocument打印到QPrinter对象中。这是通过将打印机对象传递给文档的print()方法来完成的。
我们将创建以下方法来为我们执行这些操作:
def _print_document(self):
self.preview.document().print(self.printer)
请注意,这实际上并不会导致您的打印设备开始在页面上放墨水-它只是将文档加载到QPrinter对象中。
要实际将其打印到纸张上,需要打印对话框;因此,在MainView中添加以下方法:
def print_dialog(self):
self._print_document()
dialog = qtps.QPrintDialog(self.printer, self)
dialog.exec()
self._update_preview_size()
在这个方法中,我们首先调用我们的内部方法将文档加载到QPrinter对象中,然后将对象传递给QPrintDialog对象,通过调用其exec()方法来执行。这将显示打印对话框,用户可以使用它将文档发送到物理打印机。
如果您不需要打印对话框来阻止程序执行,您可以调用其open()方法。在前面的示例中,我们正在阻止,以便在对话框关闭后执行操作。
对话框关闭后,我们调用_update_preview_size()来获取新的纸张大小并更新我们的小部件和文档。理论上,我们可以将对话框的accepted信号连接到该方法,但实际上,可能会出现一些竞争条件导致失败。
打印预览
没有人喜欢浪费纸张打印不正确的东西,所以我们应该添加一个print_preview函数。QPrintPreviewDialog就是为此目的而存在的,并且与其他打印对话框非常相似,如下所示:
def print_preview(self):
dialog = qtps.QPrintPreviewDialog(self.printer, self)
dialog.paintRequested.connect(self._print_document)
dialog.exec()
self._update_preview_size()
再次,我们只需要将打印机对象传递给对话框的构造函数并调用exec()。我们还需要将对话框的paintRequested信号连接到一个插槽,该插槽将更新QPrinter中的文档,以便对话框可以确保预览是最新的。在这里,我们将其连接到我们的_print_document()方法,该方法正是所需的。
导出为 PDF
在这个无纸化的数字时代,PDF 文件已经取代了许多用途的硬拷贝,因此,添加一个简单的导出到 PDF 功能总是一件好事。QPrinter可以轻松为我们做到这一点。
在MainView中添加以下export_pdf()方法:
def export_pdf(self):
filename, _ = qtw.QFileDialog.getSaveFileName(
self, "Save to PDF", qtc.QDir.homePath(), "PDF Files (*.pdf)")
if filename:
self.printer.setOutputFileName(filename)
self.printer.setOutputFormat(qtps.QPrinter.PdfFormat)
self._print_document()
在这里,我们将首先要求用户提供文件名。如果他们提供了文件名,我们将使用该文件名配置我们的QPrinter对象,将输出格式设置为PdfFormat,然后打印文档。在写入文件时,QTextDocument.print()将负责写入数据并为我们保存文件,因此我们在这里不需要做其他事情。
这涵盖了发票程序的所有打印需求!花些时间测试这个功能,看看它如何与您的打印机配合使用。
总结
在本章中,您掌握了在 PyQt5 中处理富文本文档的方法。您学会了如何使用 Qt 的 HTML 子集在QLabel、QTextEdit和QTextBrowser小部件中添加富文本格式。您通过使用QTextCursor接口编程方式构建了QTextDocument。最后,您学会了如何使用 Qt 的打印支持模块将QTextDocument对象带入现实世界。
在第十二章中,使用 QPainter 创建 2D 图形,你将学习一些二维图形的高级概念。你将学会如何使用QPainter对象来创建图形,构建自定义小部件,并创建动画。
问题
尝试使用这些问题来测试你对本章的了解:
- 以下 HTML 显示的不如你希望的那样。找出尽可能多的错误:
<table>
<thead background=#EFE><th>Job</th><th>Status</th></thead>
<tr><td>Backup</td>
<font text-color='green'>Success!</font></td></tr>
<tr><td>Cleanup<td><font text-style='bold'>Fail!</font></td></tr>
</table>
- 以下 Qt HTML 代码有什么问题?
<p>There is nothing <i>wrong</i> with your television <b>set</p></b>
<table><row><data>french fries</data>
<data>$1.99</data></row></table>
<font family='Tahoma' color='#235499'>Can you feel the <strikethrough>love</strikethrough>code tonight?</font>
<label>Username</label><input type='text' name='username'></input>
<img source='://mypix.png'>My picture</img>
- 这段代码应该实现一个目录。为什么它不能正确工作?
<ul>
<li><a href='Section1'>Section 1</a></li>
<li><a href='Section2'>Section 2</a></li>
</ul>
<div id=Section1>
<p>This is section 1</p>
</div>
<div id=Section2>
<p>This is section 2</p>
</div>
-
使用
QTextCursor,在文档的右侧添加一个侧边栏。解释一下你会如何做到这一点。 -
你正在尝试使用
QTextCursor创建一个文档。它应该有一个顶部和底部的框架;在顶部框架中应该有一个标题,在底部框架中应该有一个无序列表。请纠正以下代码,使其实现这一点:
document = qtg.QTextDocument()
cursor = qtg.QTextCursor(document)
top_frame = cursor.insertFrame(qtg.QTextFrameFormat())
bottom_frame = cursor.insertFrame(qtg.QTextFrameFormat())
cursor.insertText('This is the title')
cursor.movePosition(qtg.QTextCursor.NextBlock)
cursor.insertList(qtg.QTextListFormat())
for item in ('thing 1', 'thing 2', 'thing 3'):
cursor.insertText(item)
- 你正在创建自己的
QPrinter子类以在页面大小更改时添加一个信号。以下代码会起作用吗?
class MyPrinter(qtps.QPrinter):
page_size_changed = qtc.pyqtSignal(qtg.QPageSize)
def setPageSize(self, size):
super().setPageSize(size)
self.page_size_changed.emit(size)
QtPrintSupport包含一个名为QPrinterInfo的类。使用这个类,在你的系统上打印出所有打印机的名称、制造商、型号和默认页面大小的列表。
进一步阅读
有关更多信息,请参考以下链接:
-
Qt 对 Scribe 框架的概述可以在
doc.qt.io/qt-5/richtext.html找到 -
可以使用
QAbstractTextDocumentLayout和QTextLine类来定义高级文档布局;关于如何使用这些类的信息可以在doc.qt.io/qt-5/richtext-layouts.html找到 -
Qt 的打印系统概述可以在
doc.qt.io/qt-5/qtprintsupport-index.html找到
第十二章:使用QPainter创建 2D 图形
我们已经看到 Qt 提供了大量的小部件,具有广泛的样式和自定义功能。然而,有时我们需要直接控制屏幕上的绘制内容;例如,我们可能想要编辑图像,创建一个独特的小部件,或者构建一个交互式动画。在所有这些任务的核心是 Qt 中一个谦卑而勤奋的对象,称为QPainter。
在本章中,我们将在三个部分中探索 Qt 的二维(2D)图形功能:
-
使用
QPainter进行图像编辑 -
使用
QPainter创建自定义小部件 -
使用
QGraphicsScene动画 2D 图形
技术要求
本章需要基本的 Python 和 PyQt5 设置,这是您在整本书中一直在使用的。您可能还希望从github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter12下载示例代码。
您还需要psutil库,可以使用以下命令从 PyPI 安装:
$ pip install --user psutil
最后,有一些图像在手边会很有帮助,您可以用它们作为示例数据。
查看以下视频以查看代码的运行情况:bit.ly/2M5xzlL
使用QPainter进行图像编辑
在 Qt 中,可以使用QPainter对象在QImage对象上绘制图像。在第六章中,Qt 应用程序的样式,您了解了QPixmap对象,它是一个表示图形图像的显示优化对象。QImage对象是一个类似的对象,它针对编辑而不是显示进行了优化。为了演示如何使用QPainter在QImage对象上绘制图像,我们将构建一个经典的表情包生成器应用程序。
生成表情包的图形用户界面
从第四章中创建 Qt 应用程序模板的副本,使用 QMainWindow 构建应用程序,并将其命名为meme_gen.py。我们将首先构建用于表情包生成器的 GUI 表单。
编辑表单
在创建实际表单之前,我们将通过创建一些自定义按钮类稍微简化我们的代码:一个用于设置颜色的ColorButton类,一个用于设置字体的FontButton类,以及一个用于选择图像的ImageFileButton类。
ColorButton类的开始如下:
class ColorButton(qtw.QPushButton):
changed = qtc.pyqtSignal()
def __init__(self, default_color, changed=None):
super().__init__()
self.set_color(qtg.QColor(default_color))
self.clicked.connect(self.on_click)
if changed:
self.changed.connect(changed)
这个按钮继承自QPushButton,但做了一些改动。我们定义了一个changed信号来跟踪按钮值的变化,并添加了一个关键字选项,以便可以像内置信号一样使用关键字连接这个信号。
我们还添加了指定默认颜色的功能,该颜色将传递给set_color方法:
def set_color(self, color):
self._color = color
pixmap = qtg.QPixmap(32, 32)
pixmap.fill(self._color)
self.setIcon(qtg.QIcon(pixmap))
这种方法将传递的颜色值存储在实例变量中,然后生成给定颜色的pixmap对象,用作按钮图标(我们在第六章中看到了这种技术,Qt 应用程序的样式)。
按钮的clicked信号连接到on_click()方法:
def on_click(self):
color = qtw.QColorDialog.getColor(self._color)
if color:
self.set_color(color)
self.changed.emit()
这种方法打开QColorDialog,允许用户选择颜色,并且如果选择了颜色,则设置其颜色并发出changed信号。
FontButton类将与前一个类几乎相同:
class FontButton(qtw.QPushButton):
changed = qtc.pyqtSignal()
def __init__(self, default_family, default_size, changed=None):
super().__init__()
self.set_font(qtg.QFont(default_family, default_size))
self.clicked.connect(self.on_click)
if changed:
self.changed.connect(changed)
def set_font(self, font):
self._font = font
self.setFont(font)
self.setText(f'{font.family()} {font.pointSize()}')
与颜色按钮类似,它定义了一个可以通过关键字连接的changed信号。它采用默认的字体和大小,用于生成存储在按钮的_font属性中的默认QFont对象,使用set_font()方法。
set_font()方法还会更改按钮的字体和文本为所选的字体和大小。
最后,on_click()方法处理按钮点击:
def on_click(self):
font, accepted = qtw.QFontDialog.getFont(self._font)
if accepted:
self.set_font(font)
self.changed.emit()
与颜色按钮类似,我们显示一个QFontDialog对话框,并且如果用户选择了字体,则相应地设置按钮的字体。
最后,ImageFileButton类将与前两个类非常相似:
class ImageFileButton(qtw.QPushButton):
changed = qtc.pyqtSignal()
def __init__(self, changed=None):
super().__init__("Click to select…")
self._filename = None
self.clicked.connect(self.on_click)
if changed:
self.changed.connect(changed)
def on_click(self):
filename, _ = qtw.QFileDialog.getOpenFileName(
None, "Select an image to use",
qtc.QDir.homePath(), "Images (*.png *.xpm *.jpg)")
if filename:
self._filename = filename
self.setText(qtc.QFileInfo(filename).fileName())
self.changed.emit()
唯一的区别是对话框现在是一个getOpenFileName对话框,允许用户选择 PNG、XPM 或 JPEG 文件。
QImage实际上可以处理各种各样的图像文件。您可以在doc.qt.io/qt-5/qimage.html#reading-and-writing-image-files找到这些信息,或者调用QImageReader.supportedImageFormats()。出于简洁起见,我们在这里缩短了列表。
现在这些类已经创建,让我们为编辑表情包属性构建一个表单:
class MemeEditForm(qtw.QWidget):
changed = qtc.pyqtSignal(dict)
def __init__(self):
super().__init__()
self.setLayout(qtw.QFormLayout())
这个表单将与我们在之前章节中创建的表单非常相似,但是,与其在表单提交时使用submitted信号不同,changed信号将在任何表单项更改时触发。这将允许我们实时显示任何更改,而不需要按按钮。
我们的第一个控件将是设置源图像的文件名:
self.image_source = ImageFileButton(changed=self.on_change)
self.layout().addRow('Image file', self.image_source)
我们将把每个小部件的changed信号(或类似的信号)链接到一个名为on_change()的方法上,该方法将收集表单中的数据并发射MemeEditForm的changed信号。
不过,首先让我们添加字段来控制文本本身:
self.top_text = qtw.QPlainTextEdit(textChanged=self.on_change)
self.bottom_text = qtw.QPlainTextEdit(textChanged=self.on_change)
self.layout().addRow("Top Text", self.top_text)
self.layout().addRow("Bottom Text", self.bottom_text)
self.text_color = ColorButton('white', changed=self.on_change)
self.layout().addRow("Text Color", self.text_color)
self.text_font = FontButton('Impact', 32, changed=self.on_change)
self.layout().addRow("Text Font", self.text_font)
我们的表情包将在图像的顶部和底部分别绘制文本,并且我们使用了ColorButton和FontButton类来创建文本颜色和字体的输入。再次,我们将每个小部件的适当changed信号连接到一个on_changed()实例方法。
让我们通过添加控件来绘制文本的背景框来完成表单 GUI:
self.text_bg_color = ColorButton('black', changed=self.on_change)
self.layout().addRow('Text Background', self.text_bg_color)
self.top_bg_height = qtw.QSpinBox(
minimum=0, maximum=32,
valueChanged=self.on_change, suffix=' line(s)')
self.layout().addRow('Top BG height', self.top_bg_height)
self.bottom_bg_height = qtw.QSpinBox(
minimum=0, maximum=32,
valueChanged=self.on_change, suffix=' line(s)')
self.layout().addRow('Bottom BG height', self.bottom_bg_height)
self.bg_padding = qtw.QSpinBox(
minimum=0, maximum=100, value=10,
valueChanged=self.on_change, suffix=' px')
self.layout().addRow('BG Padding', self.bg_padding)
这些字段允许用户在图像太丰富而无法阅读时在文本后面添加不透明的背景。控件允许您更改顶部和底部背景的行数、框的颜色和填充。
这样就处理了表单布局,现在我们来处理on_change()方法:
def get_data(self):
return {
'image_source': self.image_source._filename,
'top_text': self.top_text.toPlainText(),
'bottom_text': self.bottom_text.toPlainText(),
'text_color': self.text_color._color,
'text_font': self.text_font._font,
'bg_color': self.text_bg_color._color,
'top_bg_height': self.top_bg_height.value(),
'bottom_bg_height': self.bottom_bg_height.value(),
'bg_padding': self.bg_padding.value()
}
def on_change(self):
self.changed.emit(self.get_data())
首先,我们定义了一个get_data()方法,该方法从表单的小部件中组装一个值的dict对象并返回它们。如果我们需要显式地从表单中提取数据,而不是依赖信号,这将非常有用。on_change()方法检索这个dict对象并用changed信号发射它。
主 GUI
创建了表单小部件后,现在让我们组装我们的主 GUI。
让我们从MainView.__init__()开始:
self.setWindowTitle('Qt Meme Generator')
self.max_size = qtc.QSize(800, 600)
self.image = qtg.QImage(
self.max_size, qtg.QImage.Format_ARGB32)
self.image.fill(qtg.QColor('black'))
我们将从设置窗口标题开始,然后定义生成的表情包图像的最大尺寸。我们将使用这个尺寸来创建我们的QImage对象。由于在程序启动时我们没有图像文件,所以我们将生成一个最大尺寸的黑色占位图像,使用fill()方法来实现,就像我们用像素图一样。然而,当创建一个空白的QImage对象时,我们需要指定一个图像格式来用于生成的图像。在这种情况下,我们使用 ARGB32 格式,可以用于制作具有透明度的全彩图像。
在创建主 GUI 布局时,我们将使用这个图像:
mainwidget = qtw.QWidget()
self.setCentralWidget(mainwidget)
mainwidget.setLayout(qtw.QHBoxLayout())
self.image_display = qtw.QLabel(pixmap=qtg.QPixmap(self.image))
mainwidget.layout().addWidget(self.image_display)
self.form = MemeTextForm()
mainwidget.layout().addWidget(self.form)
self.form.changed.connect(self.build_image)
这个 GUI 是一个简单的两面板布局,左边是一个QLabel对象,用于显示我们的表情包图像,右边是用于编辑的MemeTextForm()方法。我们将表单的changed信号连接到一个名为build_image()的MainWindow方法,其中包含我们的主要绘图逻辑。请注意,我们不能直接在QLabel对象中显示QImage对象;我们必须先将其转换为QPixmap对象。
使用 QImage 进行绘制
既然我们的 GUI 已经准备好了,现在是时候创建MainView.build_image()了。这个方法将包含所有的图像处理和绘制方法。
我们将从添加以下代码开始:
def build_image(self, data):
if not data.get('image_source'):
self.image.fill(qtg.QColor('black'))
else:
self.image.load(data.get('image_source'))
if not (self.max_size - self.image.size()).isValid():
# isValid returns false if either dimension is negative
self.image = self.image.scaled(
self.max_size, qtc.Qt.KeepAspectRatio)
我们的第一个任务是设置我们的表情包的基本图像。如果在表单数据中没有 image_source 值,那么我们将用黑色填充我们的 QImage 对象,为我们的绘图提供一个空白画布。如果我们有图像来源,那么我们可以通过将其文件路径传递给 QImage.load() 来加载所选图像。如果我们加载的图像大于最大尺寸,我们将希望将其缩小,使其小于最大宽度和高度,同时保持相同的纵横比。
检查图像在任一维度上是否太大的一种快速方法是从最大尺寸中减去它的尺寸。如果宽度或高度大于最大值,则其中一个维度将为负,这使得减法表达式产生的 QSize 对象无效。
QImage.scaled() 方法将返回一个新的 QImage 对象,该对象已经按照提供的 QSize 对象进行了缩放。通过指定 KeepAspectRatio,我们的宽度和高度将分别进行缩放,以使结果大小与原始大小具有相同的纵横比。
现在我们有了我们的图像,我们可以开始在上面绘画。
QPainter 对象
最后,让我们来认识一下 QPainter 类!QPainter 可以被认为是屏幕内部的一个小机器人,我们可以为它提供一个画笔和一个笔,然后发出绘图命令。
让我们创建我们的绘画“机器人”:
painter = qtg.QPainter(self.image)
绘图者的构造函数接收一个它将绘制的对象的引用。要绘制的对象必须是 QPaintDevice 的子类;在这种情况下,我们传递了一个 QImage 对象,它是这样一个类。传递的对象将成为绘图者的画布,在这个画布上,当我们发出绘图命令时,绘图者将进行绘制。
为了了解基本绘画是如何工作的,让我们从顶部和底部的背景块开始。我们首先要弄清楚我们需要绘制的矩形的边界:
font_px = qtg.QFontInfo(data['text_font']).pixelSize()
top_px = (data['top_bg_height'] * font_px) + data['bg_padding']
top_block_rect = qtc.QRect(
0, 0, self.image.width(), top_px)
bottom_px = (
self.image.height() - data['bg_padding']
- (data['bottom_bg_height'] * font_px))
bottom_block_rect = qtc.QRect(
0, bottom_px, self.image.width(), self.image.height())
QPainter 使用的坐标从绘画表面的左上角开始。因此,坐标 (0, 0) 是屏幕的左上角,而 (width, height) 将是屏幕的右下角。
为了计算我们顶部矩形的高度,我们将所需行数乘以我们选择的字体的像素高度(我们从 QFontInfo 中获取),最后加上填充量。我们最终得到一个从原点((0, 0))开始并在框的图像的完整宽度和高度处结束的矩形。这些坐标用于创建一个表示框区域的 QRect 对象。
对于底部的框,我们需要从图像的底部计算;这意味着我们必须首先计算矩形的高度,然后从框的高度中减去它。然后,我们构造一个从左侧开始并延伸到右下角的矩形。
QRect 坐标必须始终从左上到右下定义。
现在我们有了我们的矩形,让我们来绘制它们:
painter.setBrush(qtg.QBrush(data['bg_color']))
painter.drawRect(top_block_rect)
painter.drawRect(bottom_block_rect)
QPainter 有许多用于创建线条、圆圈、多边形和其他形状的绘图函数。在这种情况下,我们使用 drawRect(),它用于绘制矩形。为了定义这个矩形的填充,我们将绘图者的 brush 属性设置为一个 QBrush 对象,该对象设置为我们选择的背景颜色。绘图者的 brush 值决定了它将用什么颜色和图案来填充任何形状。
除了 drawRect(),QPainter 还包含一些其他绘图方法,如下所示:
| 方法 | 用于绘制 |
|---|---|
drawEllipse() |
圆和椭圆 |
drawLine() |
直线 |
drawRoundedRect() |
带有圆角的矩形 |
drawPolygon() |
任何类型的多边形 |
drawPixmap() |
QPixmap 对象 |
drawText() |
文本 |
为了将我们的表情包文本放在图像上,我们需要使用 drawText():
painter.setPen(data['text_color'])
painter.setFont(data['text_font'])
flags = qtc.Qt.AlignHCenter | qtc.Qt.TextWordWrap
painter.drawText(
self.image.rect(), flags | qtc.Qt.AlignTop, data['top_text'])
painter.drawText(
self.image.rect(), flags | qtc.Qt.AlignBottom,
data['bottom_text'])
在绘制文本之前,我们需要给画家一个QPen对象来定义文本颜色,并给一个QFont对象来定义所使用的字体。画家的QPen确定了画家绘制的文本、形状轮廓、线条和点的颜色。
为了控制文本在图像上的绘制位置,我们可以使用drawText()的第一个参数,它是一个QRect对象,用于定义文本的边界框。然而,由于我们不知道我们要处理多少行文本,我们将使用整个图像作为边界框,并使用垂直对齐来确定文本是在顶部还是底部写入。
使用QtCore.Qt.TextFlag和QtCore.Qt.AlignmentFlag枚举的标志值来配置对齐和自动换行等行为。在这种情况下,我们为顶部和底部文本指定了居中对齐和自动换行,然后在drawText()调用中添加了垂直对齐选项。
drawText()的最后一个参数是实际的文本,我们从我们的dict数据中提取出来。
现在我们已经绘制了文本,我们需要做的最后一件事是在图像显示标签中设置图像:
self.image_display.setPixmap(qtg.QPixmap(self.image))
在这一点上,你应该能够启动程序并创建一个图像。试试看吧!
保存我们的图像
创建一个时髦的迷因图像后,我们的用户可能想要保存它,以便他们可以将其上传到他们最喜欢的社交媒体网站。为了实现这一点,让我们回到MainWindow.__init_()并创建一个工具栏:
toolbar = self.addToolBar('File')
toolbar.addAction("Save Image", self.save_image)
当然,你也可以使用菜单选项或其他小部件来做到这一点。无论如何,我们需要定义由此操作调用的save_image()方法:
def save_image(self):
save_file, _ = qtw.QFileDialog.getSaveFileName(
None, "Save your image",
qtc.QDir.homePath(), "PNG Images (*.png)")
if save_file:
self.image.save(save_file, "PNG")
要将QImage文件保存到磁盘,我们需要使用文件路径字符串和第二个字符串定义图像格式调用其save()方法。在这种情况下,我们将使用QFileDialog.getSaveFileName()来检索保存位置,并以PNG格式保存。
如果你运行你的迷因生成器,你应该会发现它看起来像下面的截图:

作为额外的练习,尝试想出一些其他你想在迷因上绘制的东西,并将这个功能添加到代码中。
使用 QPainter 创建自定义小部件
QPainter不仅仅是一个专门用于在图像上绘制的工具;它实际上是为 Qt 中所有小部件绘制所有图形的工作马。换句话说,你在 PyQt 应用程序中看到的每个小部件的每个像素都是由QPainter对象绘制的。我们可以控制QPainter来创建一个纯自定义的小部件。
为了探索这个想法,让我们创建一个 CPU 监视器应用程序。获取 Qt 应用程序模板的最新副本,将其命名为cpu_graph.py,然后我们将开始。
构建一个 GraphWidget
我们的 CPU 监视器将使用区域图显示实时 CPU 活动。图表将通过颜色渐变进行增强,高值将以不同颜色显示,低值将以不同颜色显示。图表一次只显示配置数量的值,随着从右侧添加新值,旧值将滚动到小部件的左侧。
为了实现这一点,我们需要构建一个自定义小部件。我们将其命名为GraphWidget,并开始如下:
class GraphWidget(qtw.QWidget):
"""A widget to display a running graph of information"""
crit_color = qtg.QColor(255, 0, 0) # red
warn_color = qtg.QColor(255, 255, 0) # yellow
good_color = qtg.QColor(0, 255, 0) # green
def __init__(
self, *args, data_width=20,
minimum=0, maximum=100,
warn_val=50, crit_val=75, scale=10,
**kwargs
):
super().__init__(*args, **kwargs)
自定义小部件从一些类属性开始,用于定义good、warning和critical值的颜色。如果你愿意,可以随意更改这些值。
我们的构造函数接受一些关键字参数,如下所示:
-
data_width:这指的是一次将显示多少个值 -
minimum和maximum:要显示的最小和最大值 -
warn_val和crit_val:这些是颜色变化的阈值值 -
Scale:这指的是每个数据点将使用多少像素
我们的下一步是将所有这些值保存为实例属性:
self.minimum = minimum
self.maximum = maximum
self.warn_val = warn_val
self.scale = scale
self.crit_val = crit_val
为了存储我们的值,我们需要类似 Python list的东西,但受限于固定数量的项目。Python 的collections模块为此提供了完美的对象:deque类。
让我们在代码块的顶部导入这个类:
from collections import deque
deque类可以接受一个maxlen参数,这将限制其长度。当新项目附加到deque类时,将其推到其maxlen值之外,旧项目将从列表的开头删除,以使其保持在限制之下。这对于我们的图表非常完美,因为我们只想在图表中同时显示固定数量的数据点。
我们将创建我们的deque类如下:
self.values = deque([self.minimum] * data_width, maxlen=data_width)
self.setFixedWidth(data_width * scale)
deque可以接受一个list作为参数,该参数将用于初始化其数据。在这种情况下,我们使用一个包含最小值的data_width项的list进行初始化,并将deque类的maxlen值设置为data_width。
您可以通过将包含 1 个项目的列表乘以N在 Python 中快速创建N个项目的列表,就像我们在这里所做的那样;例如,[2] * 4将创建一个列表[2, 2, 2, 2]。
我们通过将小部件的固定宽度设置为data_width * scale来完成__init__()方法,这代表了我们想要显示的总像素数。
接下来,我们需要一个方法来向我们的deque类添加一个新值,我们将其称为add_value():
def add_value(self, value):
value = max(value, self.minimum)
value = min(value, self.maximum)
self.values.append(value)
self.update()
该方法首先通过将我们的值限制在最小值和最大值之间,然后将其附加到deque对象上。这还有一个额外的效果,即将deque对象的开头弹出第一项,使其保持在data_width值。
最后,我们调用update(),这是一个QWidget方法,告诉小部件重新绘制自己。我们将在下一步处理这个绘图过程。
绘制小部件
QWidget类,就像QImage一样,是QPaintDevice的子类;因此,我们可以使用QPainter对象直接在小部件上绘制。当小部件收到重新绘制自己的请求时(类似于我们发出update()的方式),它调用其paintEvent()方法。我们可以用我们自己的绘图命令覆盖这个方法,为我们的小部件定义一个自定义外观。
让我们按照以下方式开始该方法:
def paintEvent(self, paint_event):
painter = qtg.QPainter(self)
paintEvent()将被调用一个参数,一个QPaintEvent对象。这个对象包含有关请求重绘的事件的信息 - 最重要的是,需要重绘的区域和矩形。对于复杂的小部件,我们可以使用这些信息来仅重绘请求的部分。对于我们简单的小部件,我们将忽略这些信息,只重绘整个小部件。
我们定义了一个指向小部件本身的画家对象,因此我们向画家发出的任何命令都将在我们的小部件上绘制。让我们首先创建一个背景:
brush = qtg.QBrush(qtg.QColor(48, 48, 48))
painter.setBrush(brush)
painter.drawRect(0, 0, self.width(), self.height())
就像我们在我们的模因生成器中所做的那样,我们正在定义一个画刷,将其给我们的画家,并画一个矩形。
请注意,我们在这里使用了drawRect()的另一种形式,它直接取坐标而不是QRect对象。QPainter对象的许多绘图函数都有取稍微不同类型参数的替代版本,以增加灵活性。
接下来,让我们画一些虚线,显示警告和临界的阈值在哪里。为此,我们需要将原始数据值转换为小部件上的y坐标。由于这将经常发生,让我们创建一个方便的方法来将值转换为y坐标:
def val_to_y(self, value):
data_range = self.maximum - self.minimum
value_fraction = value / data_range
y_offset = round(value_fraction * self.height())
y = self.height() - y_offset
return y
要将值转换为y坐标,我们首先需要确定值代表数据范围的什么比例。然后,我们将该分数乘以小部件的高度,以确定它应该离小部件底部多少像素。然后,因为像素坐标从顶部开始计数向下,我们必须从小部件的高度中减去我们的偏移量,以确定y坐标。
回到paintEvent(),让我们使用这个方法来画一个警告阈值线:
pen = qtg.QPen()
pen.setDashPattern([1, 0])
warn_y = self.val_to_y(self.warn_val)
pen.setColor(self.warn_color)
painter.setPen(pen)
painter.drawLine(0, warn_y, self.width(), warn_y)
由于我们正在绘制一条线,我们需要设置绘图者的pen属性。QPen.setDashPattern()方法允许我们通过向其传递1和0值的列表来为线定义虚线模式,表示绘制或未绘制的像素。在这种情况下,我们的模式将在绘制像素和空像素之间交替。
创建了笔之后,我们使用我们的新转换方法将warn_val值转换为y坐标,并将笔的颜色设置为warn_color。我们将配置好的笔交给我们的绘图者,并指示它在我们计算出的y坐标处横跨小部件的宽度绘制一条线。
同样的方法可以用来绘制我们的临界阈值线:
crit_y = self.val_to_y(self.crit_val)
pen.setColor(self.crit_color)
painter.setPen(pen)
painter.drawLine(0, crit_y, self.width(), crit_y)
我们可以重用我们的QPen对象,但请记住,每当我们对笔或刷子进行更改时,我们都必须重新分配给绘图者。绘图者传递了笔或刷子的副本,因此我们对对象进行的更改在分配给绘图者之后不会隐式传递给使用的笔或刷子。
在第六章中,Qt 应用程序的样式,您学习了如何创建一个渐变对象并将其应用于QBrush对象。在这个应用程序中,我们希望使用渐变来绘制我们的数据值,使得高值在顶部为红色,中等值为黄色,低值为绿色。
让我们定义一个QLinearGradient渐变对象如下:
gradient = qtg.QLinearGradient(
qtc.QPointF(0, self.height()), qtc.QPointF(0, 0))
这个渐变将从小部件的底部(self.height())到顶部(0)进行。这一点很重要要记住,因为在定义颜色停止时,0位置表示渐变的开始(即小部件的底部),1位置将表示渐变的结束(即顶部)。
我们将设置我们的颜色停止如下:
gradient.setColorAt(0, self.good_color)
gradient.setColorAt(
self.warn_val/(self.maximum - self.minimum),
self.warn_color)
gradient.setColorAt(
self.crit_val/(self.maximum - self.minimum),
self.crit_color)
类似于我们计算y坐标的方式,在这里,我们通过将警告和临界值除以最小值和最大值之间的差来确定数据范围表示的警告和临界值的分数。这个分数是setColorAt()需要的第一个参数。
现在我们有了一个渐变,让我们为绘制数据设置我们的绘图者:
brush = qtg.QBrush(gradient)
painter.setBrush(brush)
painter.setPen(qtc.Qt.NoPen)
为了使我们的面积图看起来平滑和连贯,我们不希望图表部分有任何轮廓。为了阻止QPainter勾勒形状,我们将我们的笔设置为一个特殊的常数:QtCore.Qt.NoPen。
为了创建我们的面积图,每个数据点将由一个四边形表示,其中右上角将是当前数据点,左上角将是上一个数据点。宽度将等于我们在构造函数中设置的scale属性。
由于我们将需要每个数据点的上一个值,我们需要从一点开始进行一些簿记:
self.start_value = getattr(self, 'start_value', self.minimum)
last_value = self.start_value
self.start_value = self.values[0]
我们需要做的第一件事是确定一个起始值。由于我们需要在当前值之前有一个值,我们的第一项需要一个开始绘制的地方。我们将创建一个名为start_value的实例变量,它在paintEvent调用之间保持不变,并存储初始值。然后,我们将其赋值给last_value,这是一个本地变量,将用于记住循环的每次迭代的上一个值。最后,我们将起始值更新为deque对象的第一个值,以便下一次调用paintEvent。
现在,让我们开始循环遍历数据并计算每个点的x和y值:
for indx, value in enumerate(self.values):
x = (indx + 1) * self.scale
last_x = indx * self.scale
y = self.val_to_y(value)
last_y = self.val_to_y(last_value)
多边形的两个x坐标将是(1)值的索引乘以比例,和(2)比例乘以值的索引加一。对于y值,我们将当前值和上一个值传递给我们的转换方法。这四个值将使我们能够绘制一个四边形,表示从一个数据点到下一个数据点的变化。
要绘制该形状,我们将使用一个称为QPainterPath的对象。在数字图形中,路径是由单独的线段或形状组合在一起构建的对象。QPainterPath对象允许我们通过在代码中逐个绘制每一边来创建一个独特的形状。
接下来,让我们使用我们计算出的x和y数据开始绘制我们的路径对象:
path = qtg.QPainterPath()
path.moveTo(x, self.height())
path.lineTo(last_x, self.height())
path.lineTo(last_x, last_y)
path.lineTo(x, y)
要绘制路径,我们首先创建一个QPainterPath对象。然后我们使用它的moveTo()方法设置绘制的起始点。然后我们使用lineTo()方法连接路径的四个角,以在点之间绘制一条直线。最后一个连接我们的结束点和起始点是自动完成的。
请注意,此时我们实际上并没有在屏幕上绘制;我们只是在定义一个对象,我们的绘图器可以使用其当前的画笔和笔将其绘制到屏幕上。
让我们绘制这个对象:
painter.drawPath(path)
last_value = value
我们通过绘制路径和更新最后一个值到当前值来完成了这个方法。当然,这条由直线组成的路径相当乏味——我们本可以只使用绘图器的drawPolygon()方法。使用QPainterPath对象的真正威力在于利用它的非线性绘制方法。
例如,如果我们希望我们的图表是平滑和圆润的,而不是锯齿状的,那么我们可以使用立方贝塞尔曲线来绘制最后一条线(即形状的顶部),而不是直线:
#path.lineTo(x, y)
c_x = round(self.scale * .5) + last_x
c1 = (c_x, last_y)
c2 = (c_x, y)
path.cubicTo(*c1, *c2, x, y)
贝塞尔曲线使用两个控制点来定义其曲线。每个控制点都会将线段拉向它自己——第一个控制点拉动线段的前半部分,第二个控制点拉动线段的后半部分:

我们将第一个控制点设置为最后的 y 值,将第二个控制点设置为当前的 y 值——这两个值都是开始和结束 x 值的中间值。这给我们在上升斜坡上一个 S 形曲线,在下降斜坡上一个反 S 形曲线,从而产生更柔和的峰值和谷值。
在应用程序中设置GraphWidget对象后,您可以尝试在曲线和线命令之间切换以查看差异。
使用 GraphWidget
我们的图形小部件已经完成,所以让我们转到MainWindow并使用它。
首先创建您的小部件并将其设置为中央小部件:
self.graph = GraphWidget(self)
self.setCentralWidget(self.graph)
接下来,让我们创建一个方法,该方法将读取当前的 CPU 使用情况并将其发送到GraphWidget。为此,我们需要从psutil库导入cpu_percent函数:
from psutil import cpu_percent
现在我们可以编写我们的图形更新方法如下:
def update_graph(self):
cpu_usage = cpu_percent()
self.graph.add_value(cpu_usage)
cpu_percent()函数返回一个从 0 到 100 的整数,反映了计算机当前的 CPU 利用率。这非常适合直接发送到我们的GraphWidget,其默认范围是 0 到 100。
现在我们只需要定期调用这个方法来更新图形;在MainWindow.__init__()中,添加以下代码:
self.timer = qtc.QTimer()
self.timer.setInterval(1000)
self.timer.timeout.connect(self.update_graph)
self.timer.start()
这只是一个QTimer对象,您在第十章中学到的,使用 QTimer 和 QThread 进行多线程处理,设置为每秒调用一次update_graph()。
如果现在运行应用程序,您应该会得到类似于这样的结果:

注意我们的贝塞尔曲线所创建的平滑峰值。如果切换回直线代码,您将看到这些峰值变得更加尖锐。
如果您的 CPU 太强大,无法提供有趣的活动图,请尝试对update_graph()进行以下更改以更好地测试小部件:
def update_graph(self):
import random
cpu_usage = random.randint(1, 100)
self.graph.add_value(cpu_usage)
这将只输出介于1和100之间的随机值,并且应该产生一些相当混乱的结果。
看到这个 CPU 图表实时动画可能会让您对 Qt 的动画能力产生疑问。在下一节中,我们将学习如何使用QPainter和 Qt 图形视图框架一起创建 Qt 中的 2D 动画。
使用 QGraphicsScene 进行 2D 图形动画
在简单的小部件和图像编辑中,对QPaintDevice对象进行绘制效果很好,但在我们想要绘制大量的 2D 对象,并可能实时地对它们进行动画处理的情况下,我们需要一个更强大的对象。Qt 提供了 Graphics View Framework,这是一个基于项目的模型视图框架,用于组合复杂的 2D 图形和动画。
为了探索这个框架的运作方式,我们将创建一个名为Tankity Tank Tank Tank的游戏。
第一步
这个坦克游戏将是一个两人对战游戏,模拟了你可能在经典的 1980 年代游戏系统上找到的简单动作游戏。一个玩家将在屏幕顶部,一个在底部,两辆坦克将不断从左到右移动,每个玩家都试图用一颗子弹射击对方。
要开始,将您的 Qt 应用程序模板复制到一个名为tankity_tank_tank_tank.py的新文件中。从文件顶部的import语句之后开始,我们将添加一些常量:
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
BORDER_HEIGHT = 100
这些常量将在整个游戏代码中用于计算大小和位置。实际上,我们将立即在MainWindow.__init__()中使用其中的两个:
self.resize(qtc.QSize(SCREEN_WIDTH, SCREEN_HEIGHT))
self.scene = Scene()
view = qtw.QGraphicsView(self.scene)
self.setCentralWidget(view)
这是我们将要添加到MainWindow中的所有代码。在将窗口调整大小为我们的宽度和高度常量之后,我们将创建两个对象,如下:
-
第一个是
Scene对象。这是一个我们将要创建的自定义类,是从QGraphicsScene派生的。QGraphicsScene是这个模型视图框架中的模型,表示包含各种图形项目的 2D 场景。 -
第二个是
QGraphicsView对象,它是框架的视图组件。这个小部件的工作只是渲染场景并将其显示给用户。
我们的Scene对象将包含游戏的大部分代码,所以我们将下一步构建那部分。
创建一个场景
Scene类将是我们游戏的主要舞台,并将管理游戏中涉及的各种对象,如坦克、子弹和墙壁。它还将显示分数并跟踪其他游戏逻辑。
让我们这样开始:
class Scene(qtw.QGraphicsScene):
def __init__(self):
super().__init__()
self.setBackgroundBrush(qtg.QBrush(qtg.QColor('black')))
self.setSceneRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT)
我们在这里做的第一件事是通过设置backgroundBrush属性将我们的场景涂成黑色。这个属性自然地需要一个QBrush对象,它将用来填充场景的背景。我们还设置了sceneRect属性,它描述了场景的大小,设置为我们的宽度和高度常量的QRect对象。
要开始在场景上放置对象,我们可以使用它的许多 add 方法之一:
wall_brush = qtg.QBrush(qtg.QColor('blue'), qtc.Qt.Dense5Pattern)
floor = self.addRect(
qtc.QRectF(0, SCREEN_HEIGHT - BORDER_HEIGHT,
SCREEN_WIDTH, BORDER_HEIGHT),
brush=wall_brush)
ceiling = self.addRect(
qtc.QRectF(0, 0, SCREEN_WIDTH, BORDER_HEIGHT),
brush=wall_brush)
在这里,我们使用addRect()在场景上绘制了两个矩形——一个在底部作为地板,一个在顶部作为天花板。就像QPainter类一样,QGraphicsScene有方法来添加椭圆、像素图、线、多边形、文本和其他这样的项目。然而,与绘图程序不同,QGraphicsScene方法不仅仅是将像素绘制到屏幕上;相反,它们创建了QGraphicsItem类(或其子类)的项目。我们随后可以查询或操作所创建的项目。
例如,我们可以添加一些文本项目来显示我们的分数,如下所示:
self.top_score = 0
self.bottom_score = 0
score_font = qtg.QFont('Sans', 32)
self.top_score_display = self.addText(
str(self.top_score), score_font)
self.top_score_display.setPos(10, 10)
self.bottom_score_display = self.addText(
str(self.bottom_score), score_font)
self.bottom_score_display.setPos(
SCREEN_WIDTH - 60, SCREEN_HEIGHT - 60)
在这里,在创建文本项目之后,我们正在操作它们的属性,并使用setPos()方法设置每个文本项目的位置。
我们还可以更新项目中的文本;例如,让我们创建方法来更新我们的分数:
def top_score_increment(self):
self.top_score += 1
self.top_score_display.setPlainText(str(self.top_score))
def bottom_score_increment(self):
self.bottom_score += 1
self.bottom_score_display.setPlainText(str(self.bottom_score))
如果你把QPainter比作在纸上绘画,那么把QGraphicsItems添加到QGraphicsScene类就相当于在毛毯图上放置毛毡形状。项目在场景上,但它们不是场景的一部分,因此它们可以被改变或移除。
创建坦克
我们的游戏将有两辆坦克,一辆在屏幕顶部,一辆在底部。这些将在Scene对象上绘制,并进行动画处理,以便玩家可以左右移动它们。在第六章中,Qt 应用程序的样式,您学到了可以使用QPropertyAnimation进行动画处理,但是只有被动画处理的属性属于QObject的后代。QGraphicsItem不是QObject的后代,但QGraphicsObject对象将两者结合起来,为我们提供了一个可以进行动画处理的图形项。
因此,我们需要将我们的Tank类构建为QGraphicsObject的子类:
class Tank(qtw.QGraphicsObject):
BOTTOM, TOP = 0, 1
TANK_BM = b'\x18\x18\xFF\xFF\xFF\xFF\xFF\x66'
这个类首先定义了两个常量,TOP和BOTTOM。这将用于表示我们是在屏幕顶部还是底部创建坦克。
TANK_BM是一个包含坦克图形的 8×8 位图数据的bytes对象。我们很快就会看到这是如何工作的。
首先,让我们开始构造函数:
def __init__(self, color, y_pos, side=TOP):
super().__init__()
self.side = side
我们的坦克将被赋予颜色、y坐标和side值,该值将是TOP或BOTTOM。我们将使用这些信息来定位和定向坦克。
接下来,让我们使用我们的bytes字符串为我们的坦克创建一个位图:
self.bitmap = qtg.QBitmap.fromData(
qtc.QSize(8, 8), self.TANK_BM)
QBitmap对象是QPixmap的单色图像的特殊情况。通过将大小和bytes对象传递给fromData()静态方法,我们可以生成一个简单的位图对象,而无需单独的图像文件。
为了理解这是如何工作的,请考虑TANK_BM字符串。因为我们将其解释为 8×8 图形,所以该字符串中的每个字节(8 位)对应于图形的一行。
如果您将每一行转换为二进制数字并将它们按每行一个字节的方式排列,它将如下所示:
00011000
00011000
11111111
11111111
11111111
11111111
11111111
01100110
由 1 创建的形状实质上是该位图将采用的形状。当然,8x8 的图形将非常小,所以我们应该将其放大。此外,这辆坦克显然是指向上的,所以如果我们是顶部的坦克,我们需要将其翻转过来。
我们可以使用QTransform对象来完成这两件事:
transform = qtg.QTransform()
transform.scale(4, 4) # scale to 32x32
if self.side == self.TOP: # We're pointing down
transform.rotate(180)
self.bitmap = self.bitmap.transformed(transform)
QTransform对象表示要在QPixmap或QBitmap上执行的一组变换。创建变换对象后,我们可以设置要应用的各种变换,首先是缩放操作,然后是添加rotate变换(如果坦克在顶部)。QTransform对象可以传递给位图的transformed()方法,该方法返回一个应用了变换的新QBitmap对象。
该位图是单色的,默认情况下是黑色。要以其他颜色绘制,我们将需要一个设置为所需颜色的QPen(而不是刷子!)对象。让我们使用我们的color参数按如下方式创建它:
self.pen = qtg.QPen(qtg.QColor(color))
QGraphicsObject对象的实际外观是通过重写paint()方法确定的。让我们按照以下方式创建它:
def paint(self, painter, option, widget):
painter.setPen(self.pen)
painter.drawPixmap(0, 0, self.bitmap)
paint()的第一个参数是QPainter对象,Qt 已经创建并分配给绘制对象。我们只需要对该绘图程序应用命令,它将根据我们的要求绘制图像。我们将首先将pen属性设置为我们创建的笔,然后使用绘图程序的drawPixmap()方法来绘制我们的位图。
请注意,我们传递给drawPixmap()的坐标不是QGraphicsScene类的坐标,而是QGraphicsObject对象本身的边界矩形内的坐标。因此,我们需要确保我们的对象返回一个适当的边界矩形,以便我们的图像被正确绘制。
为了做到这一点,我们需要重写boundingRect()方法:
def boundingRect(self):
return qtc.QRectF(0, 0, self.bitmap.width(),
self.bitmap.height())
在这种情况下,我们希望我们的boundingRect()方法返回一个与位图大小相同的矩形。
回到Tank.__init__(),让我们定位我们的坦克:
if self.side == self.BOTTOM:
y_pos -= self.bitmap.height()
self.setPos(0, y_pos)
QGraphicsObject.setPos()方法允许您使用像素坐标将对象放置在其分配的QGraphicsScene上的任何位置。由于像素坐标始终从对象的左上角计数,如果对象在屏幕底部,我们需要调整对象的y坐标,使其自身高度升高,以便坦克的底部距离屏幕顶部y_pos像素。
对象的位置始终表示其左上角的位置。
现在我们想要让我们的坦克动起来;每个坦克将在x轴上来回移动,在触碰屏幕边缘时会反弹。
让我们创建一个QPropertyAnimation方法来实现这一点:
self.animation = qtc.QPropertyAnimation(self, b'x')
self.animation.setStartValue(0)
self.animation.setEndValue(SCREEN_WIDTH - self.bitmap.width())
self.animation.setDuration(2000)
QGraphicsObject对象具有定义其在场景上的x和y坐标的x和y属性,因此将对象进行动画处理就像是将我们的属性动画指向这些属性。我们将从0开始动画x,并以屏幕的宽度结束;但是,为了防止我们的坦克离开边缘,我们需要从该值中减去位图的宽度。最后,我们设置两秒的持续时间。
属性动画可以向前或向后运行。因此,要启用左右移动,我们只需要切换动画运行的方向。让我们创建一些方法来做到这一点:
def toggle_direction(self):
if self.animation.direction() == qtc.QPropertyAnimation.Forward:
self.left()
else:
self.right()
def right(self):
self.animation.setDirection(qtc.QPropertyAnimation.Forward)
self.animation.start()
def left(self):
self.animation.setDirection(qtc.QPropertyAnimation.Backward)
self.animation.start()
改变方向只需要设置动画对象的direction属性为Forward或Backward,然后调用start()来应用它。
回到__init__(),让我们使用toggle_direction()方法来创建反弹:
self.animation.finished.connect(self.toggle_direction)
为了使游戏更有趣,我们还应该让我们的坦克从屏幕的两端开始:
if self.side == self.TOP:
self.toggle_direction()
self.animation.start()
设置动画后,通过调用start()来启动它。这处理了坦克的动画;现在是时候装载我们的武器了。
创建子弹
在这个游戏中,每个坦克一次只能在屏幕上有一个子弹。这简化了我们的游戏代码,但也使游戏保持相对具有挑战性。
为了实现这些子弹,我们将创建另一个名为Bullet的QGraphicsObject对象,它被动画化沿着y轴移动。
让我们开始我们的Bullet类如下:
class Bullet(qtw.QGraphicsObject):
hit = qtc.pyqtSignal()
def __init__(self, y_pos, up=True):
super().__init__()
self.up = up
self.y_pos = y_pos
子弹类首先通过定义hit信号来表示它击中了敌方坦克。构造函数接受一个y_pos参数来定义子弹的起始点,并且一个布尔值来指示子弹是向上还是向下移动。这些参数被保存为实例变量。
接下来,让我们按照以下方式定义子弹的外观:
def boundingRect(self):
return qtc.QRectF(0, 0, 10, 10)
def paint(self, painter, options, widget):
painter.setBrush(qtg.QBrush(qtg.QColor('yellow')))
painter.drawRect(0, 0, 10, 10)
我们的子弹将简单地是一个 10×10 的黄色正方形,使用绘图器的drawRect()方法创建。这对于复古游戏来说是合适的,但是为了好玩,让我们把它变得更有趣。为此,我们可以将称为QGraphicsEffect的类应用于QGraphicsObject。QGraphicsEffect类可以实时地对对象应用视觉效果。我们通过创建QGraphicEffect类的子类实例并将其分配给子弹的graphicsEffect属性来实现这一点,如下所示:
blur = qtw.QGraphicsBlurEffect()
blur.setBlurRadius(10)
blur.setBlurHints(
qtw.QGraphicsBlurEffect.AnimationHint)
self.setGraphicsEffect(blur)
添加到Bullet.__init__()的这段代码创建了一个模糊效果并将其应用到我们的QGraphicsObject类。请注意,这是应用在对象级别上的,而不是在绘画级别上,因此它适用于我们绘制的任何像素。我们已将模糊半径调整为 10 像素,并添加了AnimationHint对象,告诉我们正在应用于动画对象的效果,并激活某些性能优化。
说到动画,让我们按照以下方式创建子弹的动画:
self.animation = qtc.QPropertyAnimation(self, b'y')
self.animation.setStartValue(y_pos)
end = 0 if up else SCREEN_HEIGHT
self.animation.setEndValue(end)
self.animation.setDuration(1000)
动画被配置为使子弹从当前的y_pos参数到屏幕的顶部或底部花费一秒的时间,具体取决于子弹是向上还是向下射击。不过我们还没有开始动画,因为我们不希望子弹在射击前就开始移动。
射击将在shoot()方法中发生,如下所示:
def shoot(self, x_pos):
self.animation.stop()
self.setPos(x_pos, self.y_pos)
self.animation.start()
当玩家射出子弹时,我们首先停止任何可能发生的动画。由于一次只允许一颗子弹,快速射击只会导致子弹重新开始(虽然这并不是非常现实,但这样做可以使游戏更具挑战性)。
然后,将子弹重新定位到x坐标并传递到shoot()方法和坦克的y坐标。最后,启动动画。这个想法是,当玩家射击时,我们将传入坦克当前的x坐标,子弹将从那个位置直线飞出。
让我们回到我们的Tank类,并添加一个Bullet对象。在Tank.__init__()中,添加以下代码:
bullet_y = (
y_pos - self.bitmap.height()
if self.side == self.BOTTOM
else y_pos + self.bitmap.height()
)
self.bullet = Bullet(bullet_y, self.side == self.BOTTOM)
为了避免我们的子弹击中自己的坦克,我们希望子弹从底部坦克的正上方或顶部坦克的正下方开始,这是我们在第一条语句中计算出来的。由于我们的坦克不会上下移动,这个位置是一个常数,我们可以将它传递给子弹的构造函数。
为了让坦克射出子弹,我们将在Tank类中创建一个名为shoot()的方法:
def shoot(self):
if not self.bullet.scene():
self.scene().addItem(self.bullet)
self.bullet.shoot(self.x())
我们需要做的第一件事是将子弹添加到场景中(如果尚未添加或已被移除)。我们可以通过检查子弹的scene属性来确定这一点,如果对象不在场景中,则返回None。
然后,通过传入坦克的x坐标来调用子弹的shoot()方法。
碰撞检测
如果子弹击中目标后什么都不发生,那么子弹就没有什么用。为了在子弹击中坦克时发生一些事情,我们需要实现碰撞检测。我们将在Bullet类中实现这一点,要求它在移动时检查是否击中了任何东西。
首先在Bullet中创建一个名为check_colllision()的方法:
def check_collision(self):
colliding_items = self.collidingItems()
if colliding_items:
self.scene().removeItem(self)
for item in colliding_items:
if type(item).__name__ == 'Tank':
self.hit.emit()
QGraphicsObject.collidingItems()返回一个列表,其中包含任何与此项的边界矩形重叠的QGraphicsItem对象。这不仅包括我们的Tank对象,还包括我们在Scene类中创建的floor和ceiling项,甚至是另一个坦克的Bullet对象。如果我们的子弹触碰到这些物品中的任何一个,我们需要将其从场景中移除;为此,我们调用self.scene().removeItem(self)来消除子弹。
然后,我们需要检查我们碰撞的物品中是否有Tank对象。我们只需检查被击中的对象的类型和名称即可。如果我们击中了坦克,我们就会发出hit信号。(我们可以安全地假设它是另一个坦克,因为我们的子弹移动的方式)
每次Bullet对象移动时都需要调用这个方法,因为每次移动都可能导致碰撞。幸运的是,QGraphicsObject方法有一个yChanged信号,每当它的y坐标发生变化时就会发出。
因此,在Bullet.__init__()方法中,我们可以添加一个连接,如下所示:
self.yChanged.connect(self.check_collision)
我们的坦克和子弹对象现在已经准备就绪,所以让我们回到Scene对象来完成我们的游戏。
结束游戏
回到Scene.__init__(),让我们创建我们的两辆坦克:
self.bottom_tank = Tank(
'red', floor.rect().top(), Tank.BOTTOM)
self.addItem(self.bottom_tank)
self.top_tank = Tank(
'green', ceiling.rect().bottom(), Tank.TOP)
self.addItem(self.top_tank)
底部坦克位于地板上方,顶部坦克位于天花板下方。现在我们可以将它们的子弹的hit信号连接到适当的分数增加方法:
self.top_tank.bullet.hit.connect(self.top_score_increment)
self.bottom_tank.bullet.hit.connect(self.bottom_score_increment)
到目前为止,我们的游戏几乎已经完成了:

当然,还有一个非常重要的方面还缺失了——控制!
我们的坦克将由键盘控制;我们将为底部玩家分配箭头键进行移动和回车键进行射击,而顶部玩家将使用A和D进行移动,空格键进行射击。
为了处理按键,我们需要重写Scene对象的keyPressEvent()方法:
def keyPressEvent(self, event):
keymap = {
qtc.Qt.Key_Right: self.bottom_tank.right,
qtc.Qt.Key_Left: self.bottom_tank.left,
qtc.Qt.Key_Return: self.bottom_tank.shoot,
qtc.Qt.Key_A: self.top_tank.left,
qtc.Qt.Key_D: self.top_tank.right,
qtc.Qt.Key_Space: self.top_tank.shoot
}
callback = keymap.get(event.key())
if callback:
callback()
keyPressEvent()在Scene对象聚焦时每当用户按下键盘时被调用。它是唯一的参数,是一个QKeyEvent对象,其key()方法返回QtCore.Qt.Key枚举中的常量,告诉我们按下了什么键。在这个方法中,我们创建了一个dict对象,将某些键常量映射到我们的坦克对象的方法。每当我们接收到一个按键,我们尝试获取一个回调方法,如果成功,我们调用这个方法。
游戏现在已经准备好玩了!找个朋友(最好是你不介意和他共享键盘的人)并开始玩吧。
总结
在本章中,您学习了如何在 PyQt 中使用 2D 图形。我们学习了如何使用QPainter对象编辑图像并创建自定义小部件。然后,您学习了如何使用QGraphicsScene方法与QGraphicsObject类结合使用,创建可以使用自动逻辑或用户输入控制的动画场景。
在下一章中,我们将为我们的图形添加一个额外的维度,探索在 PyQt 中使用 OpenGL 3D 图形。您将学习一些 OpenGL 编程的基础知识,以及如何将其集成到 PyQt 应用程序中。
问题
尝试这些问题来测试你从本章学到的知识:
- 在这个方法中添加代码,以在图片底部用蓝色写下你的名字:
def create_headshot(self, image_file, name):
image = qtg.QImage()
image.load(image_file)
# your code here
# end of your code
return image
-
给定一个名为
painter的QPainter对象,写一行代码在绘图设备的左上角绘制一个 80×80 像素的八边形。您可以参考doc.qt.io/qt-5/qpainter.html#drawPolygon中的文档进行指导。 -
您正在创建一个自定义小部件,但不知道为什么文本显示为黑色。以下是您的
paintEvent()方法;看看你能否找出问题:
def paintEvent(self, event):
black_brush = qtg.QBrush(qtg.QColor('black'))
white_brush = qtg.QBrush(qtg.QColor('white'))
painter = qtg.QPainter()
painter.setBrush(black_brush)
painter.drawRect(0, 0, self.width(), self.height())
painter.setBrush(white_brush)
painter.drawText(0, 0, 'Test Text')
-
深炸迷因是一种使用极端压缩、饱和度和其他处理来使迷因图像故意看起来低质量的迷因风格。在你的迷因生成器中添加一个功能,可以选择使迷因深炸。你可以尝试的一些事情包括减少颜色位深度和调整图像中颜色的色调和饱和度。
-
您想要动画一个圆在屏幕上水平移动。更改以下代码以动画圆:
scene = QGraphicsScene()
scene.setSceneRect(0, 0, 800, 600)
circle = scene.addEllipse(0, 0, 10, 10)
animation = QPropertyAnimation(circle, b'x')
animation.setStartValue(0)
animation.setEndValue(600)
animation.setDuration(5000)
animation.start()
- 以下代码尝试使用渐变刷设置
QPainter对象。找出其中的问题所在:
gradient = qtg.QLinearGradient(
qtc.QPointF(0, 100), qtc.QPointF(0, 0))
gradient.setColorAt(20, qtg.QColor('red'))
gradient.setColorAt(40, qtg.QColor('orange'))
gradient.setColorAt(60, qtg.QColor('green'))
painter = QPainter()
painter.setGradient(gradient)
- 看看你是否可以实现一些对我们创建的游戏的改进:
-
- 脉动子弹
-
坦克被击中时爆炸
-
声音(参见第七章,使用 QtMultimedia 处理音频-视觉,以获取指导)
-
背景动画
-
多个子弹
进一步阅读
有关更多信息,请参阅以下内容:
-
有关
QPainter和 Qt 绘图系统的深入讨论可以在doc.qt.io/qt-5/paintsystem.html找到 -
Qt 图形视图框架的概述可以在
doc.qt.io/qt-5/graphicsview.html找到 -
动画框架的概述可以在
doc.qt.io/qt-5/animation-overview.html找到
第十三章:使用 QtOpenGL 创建 3D 图形
从游戏到数据可视化到工程模拟,3D 图形和动画是许多重要软件应用的核心。几十年来,事实上的应用程序编程接口(API)标准一直是 OpenGL。
用于跨平台 3D 图形的 API 一直是 OpenGL。尽管存在许多 Python 和 C 的 API 实现,Qt 提供了一个直接集成到其小部件中的 API,使我们能够在 GUI 中嵌入交互式的 OpenGL 图形和动画。
在本章中,我们将在以下主题中探讨这些功能:
-
OpenGL 的基础知识
-
使用
QOpenGLWidget嵌入 OpenGL 绘图 -
动画和控制 OpenGL 绘图
技术要求
对于本章,你需要一个基本的 Python 3 和 PyQt5 设置,就像我们在整本书中一直在使用的那样,并且你可能想从github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter13下载示例代码。你还需要确保你的图形硬件和驱动程序支持 OpenGL 2.0 或更高版本,尽管如果你使用的是过去十年内制造的传统台式机或笔记本电脑,这几乎肯定是真的。
查看以下视频,看看代码是如何运行的:bit.ly/2M5xApP
OpenGL 的基础知识
OpenGL 不仅仅是一个库;它是一个与图形硬件交互的 API 的规范。这个规范的实现是由你的图形硬件、该硬件的驱动程序和你选择使用的 OpenGL 软件库共享的。因此,你的基于 OpenGL 的代码的确切行为可能会因其中任何一个因素而略有不同,就像同样的 HTML 代码在不同的网络浏览器中可能会稍有不同地呈现一样。
OpenGL 也是一个有版本的规范,这意味着 OpenGL 的可用功能和推荐用法会随着你所针对的规范版本的不同而改变。随着新功能的引入和旧功能的废弃,最佳实践和建议也在不断发展,因此为 OpenGL 2.x 系统编写的代码可能看起来完全不像为 OpenGL 4.x 编写的代码。
OpenGL 规范由 Khronos Group 管理,这是一个维护多个与图形相关的标准的行业联盟。撰写本文时的最新规范是 4.6,发布于 2019 年 2 月,可以在www.khronos.org/registry/OpenGL/index_gl.php找到。然而,并不总是跟随最新规范是一个好主意。计算机运行给定版本的 OpenGL 代码的能力受到硬件、驱动程序和平台考虑的限制,因此,如果你希望你的代码能够被尽可能广泛的用户运行,最好是针对一个更旧和更成熟的版本。许多常见的嵌入式图形芯片只支持 OpenGL 3.x 或更低版本,一些低端设备,如树莓派(我们将在第十五章,树莓派上的 PyQt中看到)只支持 2.x。
在本章中,我们将限制我们的代码在 OpenGL 2.1,因为它得到了 PyQt 的良好支持,大多数现代计算机应该能够运行它。然而,由于我们将坚持基础知识,我们所学到的一切同样适用于 4.x 版本。
渲染管线和绘图基础知识
将代码和数据转化为屏幕上的像素需要一个多阶段的过程;在 OpenGL 中,这个过程被称为渲染管线。 这个管线中的一些阶段是可编程的,而其他的是固定功能的,意味着它们的行为是由 OpenGL 实现预先确定的,不能被改变。
让我们从头到尾走一遍这个管道的主要阶段:
-
顶点规范:在第一个阶段,绘图的顶点由您的应用程序确定。顶点本质上是 3D 空间中的一个点,可以用来绘制形状。顶点还可以包含关于点的元数据,比如它的颜色。
-
顶点处理:这个可用户定义的阶段以各种方式处理每个顶点,计算每个顶点的最终位置;例如,在这一步中,您可能会旋转或移动顶点规范中定义的基本形状。
-
顶点后处理:这个固定功能阶段对顶点进行一些额外的处理,比如裁剪超出视图空间的部分。
-
基元组装:在这个阶段,顶点被组合成基元。一个基元是一个 2D 形状,比如三角形或矩形,从中可以构建更复杂的 3D 形状。
-
光栅化:这个阶段将基本图元转换为一系列单独的像素点,称为片段,通过在顶点之间进行插值。
-
片段着色:这个用户定义阶段的主要工作是确定每个片段的深度和颜色值。
-
逐样本操作:这个最后阶段对每个片段执行一系列测试,以确定其最终的可见性和颜色。
作为使用 OpenGL 的程序员,我们主要关注这个操作的三个阶段 - 顶点规范、顶点处理和片段着色。对于顶点规范,我们将简单地在 Python 代码中定义一些点来描述 OpenGL 绘制的形状;对于其他两个阶段,我们需要学习如何创建 OpenGL 程序和着色器。
程序和着色器
尽管名字上是着色器,但它与阴影或着色无关;它只是在 GPU 上运行的代码单元的名称。在前一节中,我们谈到了渲染管线的一些阶段是可用户定义的;事实上,其中一些必须被定义,因为大多数 OpenGL 实现不为某些阶段提供默认行为。为了定义这些阶段,我们需要编写一个着色器。
至少,我们需要定义两个着色器:
-
顶点着色器:这个着色器是顶点处理阶段的第一步。它的主要工作是确定每个顶点的空间坐标。
-
片段着色器:这是管线倒数第二个阶段,它唯一的必要工作是确定单个片段的颜色。
当我们有一组着色器组成完整的渲染管线时,这被称为一个程序。
着色器不能用 Python 编写。它们必须用一种叫做GL 着色语言(GLSL)的语言编写,这是 OpenGL 规范的一部分的类似 C 的语言。没有 GLSL 的知识,就不可能创建严肃的 OpenGL 绘图,但幸运的是,写一组足够简单的着色器对于基本示例来说是相当简单的。
一个简单的顶点着色器
我们将组成一个简单的 GLSL 顶点着色器,我们可以用于我们的演示;创建一个名为vertex_shader.glsl的文件,并复制以下代码:
#version 120
我们从一个注释开始,指明我们正在使用的 GLSL 版本。这很重要,因为每个 OpenGL 版本只兼容特定版本的 GLSL,GLSL 编译器将使用这个注释来检查我们是否不匹配这些版本。
可以在www.khronos.org/opengl/wiki/Core_Language_(GLSL)找到 GLSL 和 OpenGL 版本之间的兼容性图表。
接下来,我们需要进行一些变量声明:
attribute highp vec4 vertex;
uniform highp mat4 matrix;
attribute lowp vec4 color_attr;
varying lowp vec4 color;
在类似 C 的语言中,变量声明用于创建变量,定义关于它的各种属性,并在内存中分配空间。我们的每个声明有四个标记;让我们按顺序来看一下这些:
-
第一个标记是
attribute,uniform或varying中的一个。这表明变量将分别用于每个顶点(attribute),每个基本图元(uniform)或每个片段(varying)。因此,我们的第一个变量将对每个顶点都不同,但我们的第二个变量将对同一基本图元中的每个顶点都相同。 -
第二个标记指示变量包含的基本数据类型。在这种情况下,它可以是
highp(高精度数字),mediump(中等精度数字)或lowp(低精度数字)。我们可以在这里使用float或double,但这些别名有助于使我们的代码跨平台。 -
第三个术语定义了这些变量中的每一个是指向向量还是矩阵。你可以将向量看作是 Python 的
list对象,将矩阵看作是一个每个项目都是相同长度的list对象的list对象。末尾的数字表示大小,所以vec4是一个包含四个值的列表,mat4是一个 4x4 值的矩阵。 -
最后一个标记是变量名。这些名称将在整个程序中使用,因此我们可以在管道中更深的着色器中使用它们来访问来自先前着色器的数据。
这些变量可以用来将数据插入程序或将数据传递给程序中的其他着色器。我们将在本章后面看到如何做到这一点,但现在要明白,在我们的着色器中,vertex,matrix和color_attr代表着将从我们的 PyQt 应用程序接收到的数据。
在变量声明之后,我们将创建一个名为main()的函数:
void main(void)
{
gl_Position = matrix * vertex;
color = color_attr;
}
vertex着色器的主要目的是使用vertex的坐标设置一个名为gl_Position的变量。在这种情况下,我们将其设置为传入着色器的vertex值乘以matrix值。正如你将在后面看到的,这种安排将允许我们在空间中操作我们的绘图。
在创建 3D 图形时,矩阵和向量是关键的数学概念。虽然在本章中我们将大部分时间都从这些数学细节中抽象出来,但如果你想深入学习 OpenGL 编程,了解这些概念是个好主意。
我们着色器中的最后一行代码可能看起来有点无意义,但它允许我们在顶点规范阶段为每个顶点指定一个颜色,并将该颜色传递给管道中的其他着色器。着色器中的变量要么是输入变量,要么是输出变量,这意味着它们期望从管道的前一个阶段接收数据,或者将数据传递给下一个阶段。在顶点着色器中,使用attribute或uniform限定符声明变量会将变量隐式标记为输入变量,而使用varying限定符声明变量会将其隐式标记为输出变量。因此,我们将attribute类型的color_attr变量的值复制到varying类型的color变量中,以便将该值传递给管道中更深的着色器;具体来说,我们想将其传递给fragment着色器。
一个简单的片段着色器
我们需要创建的第二个着色器是fragment着色器。请记住,这个着色器的主要工作是确定每个基本图元上每个点(或片段)的颜色。
创建一个名为fragment_shader.glsl的新文件,并添加以下代码:
#version 120
varying lowp vec4 color;
void main(void)
{
gl_FragColor = color;
}
就像我们的vertex着色器一样,我们从一个指定我们要针对的 GLSL 版本的注释开始。然后,我们将声明一个名为color的变量。
因为这是fragment着色器,将变量指定为varying会使其成为输入变量。使用color这个名称,它是我们着色器的输出变量,意味着我们将从该着色器接收它分配的颜色值。
然后在main()中,我们将该颜色分配给内置的gl_FragColor变量。这个着色器的有效作用是告诉 OpenGL 使用vertex着色器传入的颜色值来确定单个片段的颜色。
这是我们可以得到的最简单的fragment着色器。更复杂的fragment着色器,例如在游戏或模拟中找到的着色器,可能实现纹理、光照效果或其他颜色操作;但对于我们的目的,这个着色器应该足够了。
现在我们有了所需的着色器,我们可以创建一个 PyQt 应用程序来使用它们。
使用 QOpenGLWidget 嵌入 OpenGL 绘图
为了了解 OpenGL 如何与 PyQt 一起工作,我们将使用我们的着色器制作一个简单的 OpenGL 图像,通过 PyQt 界面我们将能够控制它。从第四章中创建一个 Qt 应用程序模板的副本,使用 QMainWindow 构建应用程序,并将其命名为wedge_animation.py。将其放在与您的shader文件相同的目录中。
然后,首先在MainWindow.__init__()中添加此代码:
self.resize(800, 600)
main = qtw.QWidget()
self.setCentralWidget(main)
main.setLayout(qtw.QVBoxLayout())
oglw = GlWidget()
main.layout().addWidget(oglw)
此代码创建我们的中央小部件并向其添加一个GlWidget对象。GlWidget类是我们将创建的用于显示我们的 OpenGL 绘图的类。要创建它,我们需要对可以显示 OpenGL 内容的小部件进行子类化。
OpenGLWidget 的第一步
有两个 Qt 类可用于显示 OpenGL 内容:QtWidgets.QOpenGLWidget和QtGui.QOpenGLWindow。在实践中,它们的行为几乎完全相同,但OpenGLWindow提供了稍微更好的性能,如果您不想使用任何其他 Qt 小部件(即,如果您的应用程序只是全屏 OpenGL 内容),可能是更好的选择。在我们的情况下,我们将把我们的 OpenGL 绘图与其他小部件组合在一起,因此我们将使用QOpenGLWidget作为我们的类的基础:
class GlWidget(qtw.QOpenGLWidget):
"""A widget to display our OpenGL drawing"""
要在我们的小部件上创建 OpenGL 内容,我们需要重写两个QOpenGLWidget方法:
-
initializeGL(),它只运行一次来设置我们的 OpenGL 绘图 -
paintGL()在我们的小部件需要绘制自己时(例如,响应update()调用)调用
我们将从initializeGL()开始:
def initializeGL(self):
super().initializeGL()
gl_context = self.context()
version = qtg.QOpenGLVersionProfile()
version.setVersion(2, 1)
self.gl = gl_context.versionFunctions(version)
我们需要做的第一件事是访问我们的 OpenGL API。API 由一组函数、变量和常量组成;在诸如 PyQt 之类的面向对象平台中,我们将创建一个包含这些函数作为方法以及变量和常量作为属性的特殊 OpenGL 函数对象。
为此,我们首先从QOpenGLWidget方法中检索一个 OpenGL上下文。上下文表示我们当前绘制的 OpenGL 表面的接口。从上下文中,我们可以检索包含我们的 API 的对象。
因为我们需要访问特定版本的 API(2.1),我们首先需要创建一个QOpenGLVersionProfile对象,并将其version属性设置为(2, 1)。这可以传递给上下文的versionFunctions()方法,该方法将返回一个QOpenGLFunctions_2_1对象。这是包含我们的 OpenGL 2.1 API 的对象。
Qt 还为其他版本的 OpenGL 定义了 OpenGL 函数对象,但请注意,根据您的平台、硬件以及您获取 Qt 的方式,可能会或可能不会支持特定版本。
我们将functions对象保存为self.gl;我们所有的 API 调用都将在这个对象上进行。
既然我们可以访问 API,让我们开始配置 OpenGL:
self.gl.glEnable(self.gl.GL_DEPTH_TEST)
self.gl.glDepthFunc(self.gl.GL_LESS)
self.gl.glEnable(self.gl.GL_CULL_FACE)
与 Qt 类似,OpenGL 使用定义的常量来表示各种设置和状态。配置 OpenGL 主要是将这些常量传递给各种 API 函数,以切换各种设置。
在这种情况下,我们执行三个设置:
-
将
GL_DEPTH_TEST传递给glEnable()会激活深度测试,这意味着 OpenGL 将尝试弄清楚其绘制的点中哪些在前景中,哪些在背景中。 -
glDepthFunc()设置将确定是否绘制深度测试像素的函数。在这种情况下,GL_LESS常量表示将绘制深度最低的像素(即最接近我们的像素)。通常,这是您想要的设置,也是默认设置。 -
将
GL_CULL_FACE传递给glEnable()会激活面剔除。这意味着 OpenGL 不会绘制观看者实际看不到的物体的侧面。这也是有意义的,因为它节省了本来会被浪费的资源。
这三个优化应该有助于减少我们的动画使用的资源;在大多数情况下,您会想要使用它们。还有许多其他可以启用和配置的选项;有关完整列表,请参见www.khronos.org/registry/OpenGL-Refpages/gl2.1/xhtml/glEnable.xml。请注意,有些选项只适用于使用 OpenGL 的旧固定功能方法。
如果你看到使用glBegin()和glEnd()的 OpenGL 代码,那么它使用的是非常古老的 OpenGL 1.x 固定功能绘图 API。这种方法更容易,但更有限,所以不应该用于现代 OpenGL 编程。
创建一个程序
在实现 OpenGL 绘图的下一步是创建我们的程序。您可能还记得,OpenGL 程序是由一组着色器组成的,形成一个完整的管道。
在 Qt 中,创建程序的过程如下:
-
创建一个
QOpenGLShaderProgram对象 -
将您的着色器代码添加到程序中
-
将代码链接成完整的程序
以下代码将实现这一点:
self.program = qtg.QOpenGLShaderProgram()
self.program.addShaderFromSourceFile(
qtg.QOpenGLShader.Vertex, 'vertex_shader.glsl')
self.program.addShaderFromSourceFile(
qtg.QOpenGLShader.Fragment, 'fragment_shader.glsl')
self.program.link()
着色器可以从文件中添加,就像我们在这里使用addShaderFromSourceFile()做的那样,也可以从字符串中添加,使用addShaderFromSourceCode()。我们在这里使用相对文件路径,但最好的方法是使用 Qt 资源文件(参见第六章中的使用 Qt 资源文件部分,Qt 应用程序的样式)。当文件被添加时,Qt 会编译着色器代码,并将任何编译错误输出到终端。
在生产代码中,您会想要检查addShaderFromSourceFile()的布尔输出,以查看您的着色器是否成功编译,然后再继续。
请注意,addShaderFromSourceFile()的第一个参数指定了我们要添加的着色器的类型。这很重要,因为顶点着色器和片段着色器有非常不同的要求和功能。
一旦所有着色器都加载完毕,我们调用link()将所有编译的代码链接成一个准备执行的程序。
访问我们的变量
我们的着色器程序包含了一些我们需要能够访问并放入值的变量,因此我们需要检索这些变量的句柄。QOpenGLProgram对象有两种方法,attributeLocation()和uniformLocation(),分别用于检索属性和统一变量的句柄(对于varying类型没有这样的函数)。
让我们为我们的vertex着色器变量获取一些句柄:
self.vertex_location = self.program.attributeLocation('vertex')
self.matrix_location = self.program.uniformLocation('matrix')
self.color_location = self.program.attributeLocation('color_attr')
这些方法返回的值实际上只是整数;在内部,OpenGL 只是使用顺序整数来跟踪和引用对象。然而,这对我们来说并不重要。我们可以将其视为对象句柄,并将它们传递到 OpenGL 调用中,以访问这些变量,很快您就会看到。
配置投影矩阵
在 OpenGL 中,投影矩阵定义了我们的 3D 模型如何投影到 2D 屏幕上。这由一个 4x4 的数字矩阵表示,可以用来计算顶点位置。在我们进行任何绘图之前,我们需要定义这个矩阵。
在 Qt 中,我们可以使用QMatrix4x4对象来表示它:
self.view_matrix = qtg.QMatrix4x4()
QMatrix4x4对象非常简单,它是一个按四行四列排列的数字表。然而,它有几种方法,允许我们以这样的方式操纵这些数字,使它们代表 3D 变换,比如我们的投影。
OpenGL 可以使用两种投影方式——正交,意味着所有深度的点都被渲染为相同的,或者透视,意味着视野随着我们远离观察者而扩展。对于逼真的 3D 绘图,您将希望使用透视投影。这种投影由视锥体表示。
视锥体是两个平行平面之间的一个常规几何固体的一部分,它是用来描述视野的有用形状。要理解这一点,把你的手放在头两侧。现在,把它们向前移动,保持它们刚好在你的视野之外。注意,为了做到这一点,你必须向外移动(向左和向右)。再试一次,把你的手放在头上和头下。再一次,你必须垂直向外移动,以使它们远离你的视野。
您刚刚用手做的形状就像一个金字塔,从您的眼睛延伸出来,其顶点被切成与底部平行的形状,换句话说,是一个视锥体。
要创建表示透视视锥体的矩阵,我们可以使用matrix对象的perspective()方法:
self.view_matrix.perspective(
45, # Angle
self.width() / self.height(), # Aspect Ratio
0.1, # Near clipping plane
100.0 # Far clipping plane
)
perspective()方法需要四个参数:
-
从近平面到远平面扩展的角度,以度为单位
-
近平面和远平面的纵横比(相同)
-
近平面向屏幕的深度
-
远平面向屏幕的深度
不用深入复杂的数学,这个矩阵有效地表示了我们相对于绘图的视野。当我们开始绘图时,我们将看到,我们移动对象所需做的就是操纵矩阵。
例如,我们可能应该从我们将要绘制的地方稍微后退一点,这样它就不会发生在视野的最前面。这种移动可以通过translate()方法来实现:
self.view_matrix.translate(0, 0, -5)
translate需要三个参数——x 量、y 量和 z 量。在这里,我们指定了一个 z 平移量为-5,这将使对象深入屏幕。
现在这一切可能看起来有点混乱,但是,一旦我们开始绘制形状,事情就会变得更清晰。
绘制我们的第一个形状
现在我们的 OpenGL 环境已经初始化,我们可以继续进行paintGL()方法。这个方法将包含绘制我们的 3D 对象的所有代码,并且在小部件需要更新时将被调用。
绘画时,我们要做的第一件事是清空画布:
def paintGL(self):
self.gl.glClearColor(0.1, 0, 0.2, 1)
self.gl.glClear(
self.gl.GL_COLOR_BUFFER_BIT | self.gl.GL_DEPTH_BUFFER_BIT)
self.program.bind()
glClearColor()用于用指定的颜色填充绘图的背景。在 OpenGL 中,颜色使用三个或四个值来指定。在三个值的情况下,它们代表红色、绿色和蓝色。第四个值,当使用时,代表颜色的alpha或不透明度。与 Qt 不同,其中 RGB 值是从0到255的整数,OpenGL 颜色值是从0到1的浮点数。我们前面的值描述了深紫蓝色;可以随意尝试其他值。
您应该在每次重绘时使用glClearColor重新绘制背景;如果不这样做,之前的绘画操作仍然可见。如果您进行动画或调整绘图大小,这将是一个问题。
glClear()函数用于清除 GPU 上的各种内存缓冲区,我们希望在重绘之间重置它们。在这种情况下,我们指定了一些常量,导致 OpenGL 清除颜色缓冲区和深度缓冲区。这有助于最大化性能。
最后,我们bind()程序对象。由于 OpenGL 应用程序可以有多个程序,我们调用bind()告诉 OpenGL 我们即将发出的命令适用于这个特定的程序。
现在我们可以绘制我们的形状了。
OpenGL 中的形状是用顶点描述的。您可能还记得,顶点本质上是 3D 空间中的一个点,由X、Y和Z坐标描述,并定义了一个基本图元的一个角或端点。
让我们创建一个顶点列表来描述一个楔形的前面是三角形:
front_vertices = [
qtg.QVector3D(0.0, 1.0, 0.0), # Peak
qtg.QVector3D(-1.0, 0.0, 0.0), # Bottom left
qtg.QVector3D(1.0, 0.0, 0.0) # Bottom right
]
我们的顶点数据不必分组成任何类型的不同对象,但是为了方便和可读性,我们使用QVector3D对象来保存三角形中每个顶点的坐标。
这里使用的数字代表网格上的点,其中(0, 0, 0)是我们 OpenGL 视口的中心在最前面的点。x 轴从屏幕左侧的-1到右侧的1,y 轴从屏幕顶部的1到底部的-1。z 轴有点不同;如果想象视野(我们之前描述的视锥体)作为一个形状从显示器背面扩展出来,负 z 值会推进到视野的更深处。正 z 值会移出屏幕朝着(最终在后面)观察者。因此,通常我们将使用负值或零值的 z 来保持在可见范围内。
默认情况下,OpenGL 将以黑色绘制,但是有一些颜色会更有趣。因此,我们将定义一个包含一些颜色的tuple对象:
face_colors = (
qtg.QColor('red'),
qtg.QColor('orange'),
qtg.QColor('yellow'),
)
我们在这里定义了三种颜色,每个三角形顶点一个。这些是QColor对象,但是请记住 OpenGL 需要颜色作为值在0和1之间的向量。
为了解决这个问题,我们将创建一个小方法将QColor转换为 OpenGL 友好的向量:
def qcolor_to_glvec(self, qcolor):
return qtg.QVector3D(
qcolor.red() / 255,
qcolor.green() / 255,
qcolor.blue() / 255
)
这段代码相当不言自明,它将创建另一个带有转换后的 RGB 值的QVector3D对象。
回到paintGL(),我们可以使用列表推导将我们的颜色转换为可用的东西:
gl_colors = [
self.qcolor_to_glvec(color)
for color in face_colors
]
此时,我们已经定义了一些顶点和颜色数据,但是我们还没有发送任何数据到 OpenGL;这些只是我们 Python 脚本中的数据值。要将这些传递给 OpenGL,我们需要在initializeGL()中获取的那些变量句柄。
我们将传递给我们的着色器的第一个变量是matrix变量。我们将使用我们在initializeGL()中定义的view_matrix对象:
self.program.setUniformValue(
self.matrix_location, self.view_matrix)
setUniformValue()可以用来设置uniform变量的值;我们可以简单地传递uniformLocation()获取的GLSL变量的句柄和我们创建的matrix对象来定义我们的投影和视野。
您还可以使用setAttributeValue()来设置attribute变量的值。例如,如果我们希望所有顶点都是红色,我们可以添加这个:
self.program.setAttributeValue(
self.color_location, gl_colors[0])
但我们不要这样做;如果每个顶点都有自己的颜色会看起来更好。
为此,我们需要创建一些属性数组。属性数组是将传递到属性类型变量中的数据数组。请记住,在 GLSL 中标记为属性的变量将为每个顶点应用一个不同的值。因此,实际上我们告诉 OpenGL,这里有一些数据数组,其中每个项目都应用于一个顶点。
代码看起来像这样:
self.program.enableAttributeArray(self.vertex_location)
self.program.setAttributeArray(
self.vertex_location, front_vertices)
self.program.enableAttributeArray(self.color_location)
self.program.setAttributeArray(self.color_location, gl_colors)
第一步是通过使用要设置数组的变量的句柄调用enableAttributeArray()来启用GLSL变量上的数组。然后,我们使用setAttributeArray()传递数据。这实际上意味着我们的vertex着色器将在front_vertices数组中的每个项目上运行。每次该着色器运行时,它还将从gl_colors列表中获取下一个项目,并将其应用于color_attr变量。
如果您像这样使用多个属性数组,您需要确保数组中有足够的项目来覆盖所有顶点。如果我们只定义了两种颜色,第三个顶点将为color_attr提取垃圾数据,导致未定义的输出。
现在我们已经排队了我们第一个基元的所有数据,让我们使用以下代码进行绘制:
self.gl.glDrawArrays(self.gl.GL_TRIANGLES, 0, 3)
glDrawArrays()将发送我们定义的所有数组到管道中。GL_TRIANGLES参数告诉 OpenGL 它将绘制三角形基元,接下来的两个参数告诉它从数组项0开始绘制三个项。
如果此时运行程序,您应该会看到我们绘制了一个红色和黄色的三角形。不错!现在,让我们让它成为 3D。
创建一个 3D 对象
为了制作一个 3D 对象,我们需要绘制楔形对象的背面和侧面。我们将首先通过列表推导来计算楔形的背面坐标:
back_vertices = [
qtg.QVector3D(x.toVector2D(), -0.5)
for x in front_vertices]
为了创建背面,我们只需要复制每个正面坐标并将 z 轴向后移一点。因此,我们使用QVector3D对象的toVector2D()方法来产生一个只有 x 和 y 轴的新向量,然后将其传递给一个新的QVector3D对象的构造函数,同时指定新的 z 坐标作为第二个参数。
现在,我们将把这组顶点传递给 OpenGL 并进行绘制如下:
self.program.setAttributeArray(
self.vertex_location, reversed(back_vertices))
self.gl.glDrawArrays(self.gl.GL_TRIANGLES, 0, 3)
通过将这些写入vertex_location,我们已经覆盖了已经绘制的正面顶点,并用背面顶点替换了它们。然后,我们对glDrawArrays()进行相同的调用,新的顶点集将被绘制,以及相应的颜色。
您将注意到我们在绘制之前会颠倒顶点的顺序。当 OpenGL 显示一个基元时,它只显示该基元的一面,因为假定该基元是某个 3D 对象的一部分,其内部不需要被绘制。OpenGL 根据基元的点是顺时针还是逆时针绘制来确定应该绘制哪一面的基元。默认情况下,绘制逆时针的基元的近面,因此我们将颠倒背面顶点的顺序,以便绘制顺时针并显示其远面(这将是楔形的外部)。
让我们通过绘制其侧面来完成我们的形状。与前面和后面不同,我们的侧面是矩形,因此每个侧面都需要四个顶点来描述它们。
我们将从我们的另外两个列表中计算出这些顶点:
sides = [(0, 1), (1, 2), (2, 0)]
side_vertices = list()
for index1, index2 in sides:
side_vertices += [
front_vertices[index1],
back_vertices[index1],
back_vertices[index2],
front_vertices[index2]
]
sides列表包含了front_vertices和back_vertices列表的索引,它们定义了每个三角形的侧面。我们遍历这个列表,对于每一个,定义一个包含四个顶点描述楔形一个侧面的列表。
请注意,这四个顶点是按逆时针顺序绘制的,就像正面一样(您可能需要在纸上草图来看清楚)。
我们还将定义一个新的颜色列表,因为现在我们需要更多的颜色:
side_colors = [
qtg.QColor('blue'),
qtg.QColor('purple'),
qtg.QColor('cyan'),
qtg.QColor('magenta'),
]
gl_colors = [
self.qcolor_to_glvec(color)
for color in side_colors
] * 3
我们的侧面顶点列表包含了总共 12 个顶点(每个侧面 4 个),所以我们需要一个包含 12 个颜色的列表来匹配它。我们可以通过只指定 4 种颜色,然后将 Python 的list对象乘以 3 来产生一个重复的列表,总共有 12 个项目。
现在,我们将把这些数组传递给 OpenGL 并进行绘制:
self.program.setAttributeArray(self.color_location, gl_colors)
self.program.setAttributeArray(self.vertex_location, side_vertices)
self.gl.glDrawArrays(self.gl.GL_QUADS, 0, len(side_vertices))
这一次,我们使用GL_QUADS作为第一个参数,而不是GL_TRIANGLES,以指示我们正在绘制四边形。
OpenGL 可以绘制多种不同的基元类型,包括线、点和多边形。大多数情况下,您应该使用三角形,因为这是大多数图形硬件上最快的基元。
现在我们所有的点都绘制完毕,我们来清理一下:
self.program.disableAttributeArray(self.vertex_location)
self.program.disableAttributeArray(self.color_location)
self.program.release()
在我们简单的演示中,这些调用并不是严格必要的,但是在一个更复杂的程序中,它们可能会为您节省一些麻烦。OpenGL 作为一个状态机运行,其中操作的结果取决于系统的当前状态。当我们绑定或启用特定对象时,OpenGL 就会指向该对象,并且某些操作(例如设置数组数据)将自动指向它。当我们完成绘图操作时,我们不希望将 OpenGL 指向我们的对象,因此在完成后释放和禁用对象是一个良好的做法。
如果现在运行应用程序,您应该会看到您惊人的 3D 形状:

哎呀,不太 3D,是吧?实际上,我们已经绘制了一个 3D 形状,但你看不到,因为我们直接在它上面看。在下一节中,我们将创建一些代码来使这个形状动起来,并充分欣赏它的所有维度。
OpenGL 绘图的动画和控制
为了感受我们绘图的 3D 特性,我们将在 GUI 中构建一些控件,允许我们围绕绘图进行旋转和缩放。
我们将从在MainWindow.__init__()中添加一些按钮开始,这些按钮可以用作控件:
btn_layout = qtw.QHBoxLayout()
main.layout().addLayout(btn_layout)
for direction in ('none', 'left', 'right', 'up', 'down'):
button = qtw.QPushButton(
direction,
autoExclusive=True,
checkable=True,
clicked=getattr(oglw, f'spin_{direction}'))
btn_layout.addWidget(button)
zoom_layout = qtw.QHBoxLayout()
main.layout().addLayout(zoom_layout)
zoom_in = qtw.QPushButton('zoom in', clicked=oglw.zoom_in)
zoom_layout.addWidget(zoom_in)
zoom_out = qtw.QPushButton('zoom out', clicked=oglw.zoom_out)
zoom_layout.addWidget(zoom_out)
我们在这里创建了两组按钮;第一组将是一组单选样式的按钮(因此一次只能有一个被按下),它们将选择对象的旋转方向——无(不旋转)、左、右、上或下。每个按钮在激活时都会调用GlWidget对象上的相应方法。
第二组包括一个放大和一个缩小按钮,分别在GlWidget上调用zoom_in()或zoom_out()方法。通过将这些按钮添加到我们的 GUI,让我们跳到GlWidget并实现回调方法。
在 OpenGL 中进行动画
动画我们的楔形纯粹是通过操纵view矩阵并重新绘制我们的图像。我们将在GlWidget.initializeGL()中通过创建一个实例变量来保存旋转值:
self.rotation = [0, 0, 0, 0]
此列表中的第一个值表示旋转角度;其余的值是view矩阵将围绕的点的X、Y和Z坐标。
在paintGL()的末尾,我们可以将这些值传递给matrix对象的rotate()方法:
self.view_matrix.rotate(*self.rotation)
现在,这将不起作用,因为我们的旋转值都是0。要进行旋转,我们将不得不改变self.rotation并触发图像的重绘。
因此,我们的旋转回调看起来像这样:
def spin_none(self):
self.rotation = [0, 0, 0, 0]
def spin_left(self):
self.rotation = [-1, 0, 1, 0]
def spin_right(self):
self.rotation = [1, 0, 1, 0]
def spin_up(self):
self.rotation = [1, 1, 0, 0]
def spin_down(self):
self.rotation = [-1, 1, 0, 0]
每个方法只是改变了我们旋转向量的值。角度向前(1)或向后(1)移动一个度数,围绕一个适当的点产生所需的旋转。
现在,我们只需要通过触发重复的重绘来启动动画。在paintGL()的末尾,添加这一行:
self.update()
update()在event循环中安排了一次重绘,这意味着这个方法会一遍又一遍地被调用。每次,我们的view矩阵都会按照self.rotation中设置的角度进行旋转。
放大和缩小
我们还想要实现缩放。每次点击放大或缩小按钮时,我们希望图像可以稍微靠近或远离一点。
这些回调看起来像这样:
def zoom_in(self):
self.view_matrix.scale(1.1, 1.1, 1.1)
def zoom_out(self):
self.view_matrix.scale(.9, .9, .9)
QMatrix4x4的scale()方法会使矩阵将每个顶点点乘以给定的数量。因此,我们可以使我们的对象缩小或放大,产生它更近或更远的错觉。
我们可以在这里使用translate(),但是在旋转时使用平移可能会导致一些混乱的结果,我们很快就会失去对我们对象的视野。
现在,当您运行应用程序时,您应该能够旋转您的楔形并以其所有的 3D 光辉看到它:

这个演示只是 OpenGL 可以做的开始。虽然本章可能没有使您成为 OpenGL 专家,但希望您能更加自如地深入挖掘本章末尾的资源。
总结
在本章中,您已经了解了如何使用 OpenGL 创建 3D 动画,以及如何将它们集成到您的 PyQt 应用程序中。我们探讨了 OpenGL 的基本原理,如渲染管道、着色器和 GLSL。我们学会了如何使用 Qt 小部件作为 OpenGL 上下文来绘制和动画一个简单的 3D 对象。
在下一章中,我们将学习使用QtCharts模块交互地可视化数据。我们将创建基本的图表和图形,并学习如何使用模型-视图架构构建图表。
问题
尝试这些问题来测试您从本章中学到的知识:
-
OpenGL 渲染管线的哪些步骤是可由用户定义的?为了渲染任何东西,必须定义哪些步骤?您可能需要参考文档
www.khronos.org/opengl/wiki/Rendering_Pipeline_Overview。 -
您正在为一个 OpenGL 2.1 程序编写着色器。以下内容看起来正确吗?
#version 2.1
attribute highp vec4 vertex;
void main (void)
{
gl_Position = vertex;
}
- 以下是“顶点”还是“片段”着色器?你如何判断?
attribute highp vec4 value1;
varying highp vec3 x[4];
void main(void)
{
x[0] = vec3(sin(value1[0] * .4));
x[1] = vec3(cos(value1[1]));
gl_Position = value1;
x[2] = vec3(10 * x[0])
}
- 给定以下“顶点”着色器,您需要编写什么代码来为这两个变量分配简单的值?
attribute highp vec4 coordinates;
uniform highp mat4 matrix1;
void main(void){
gl_Position = matrix1 * coordinates;
}
-
您启用面剔除以节省一些处理能力,但发现绘图中的几个可见基元现在没有渲染。问题可能是什么?
-
以下代码对我们的 OpenGL 图像有什么影响?
matrix = qtg.QMatrix4x4()
matrix.perspective(60, 4/3, 2, 10)
matrix.translate(1, -1, -4)
matrix.rotate(45, 1, 0, 0)
- 尝试使用演示,看看是否可以添加以下功能:
-
- 更有趣的形状(金字塔、立方体等)
-
移动对象的更多控制
-
阴影和光照效果
-
对象中的动画形状变化
进一步阅读
欲了解更多信息,请参考以下内容:
-
现代 OpenGL 编程的完整教程可以在
paroj.github.io/gltut找到。 -
Packt Publications 的Learn OpenGL,网址为
www.packtpub.com/game-development/learn-opengl,是学习 OpenGL 基础知识的良好资源 -
中央康涅狄格州立大学提供了一份关于 3D 图形矩阵数学的免费教程,网址为
chortle.ccsu.edu/VectorLessons/vectorIndex.html。
第十四章:使用 QtCharts 嵌入数据图
世界充满了数据。从服务器日志到财务记录,传感器遥测到人口普查统计数据,程序员们需要筛选和提取意义的原始数据似乎没有尽头。除此之外,没有什么比一个好的图表或图形更有效地将一组原始数据提炼成有意义的信息。虽然 Python 有一些很棒的图表工具,比如matplotlib,PyQt 还提供了自己的QtCharts库,这是一个用于构建图表、图形和其他数据可视化的简单工具包。
在本章中,我们将探讨以下主题中使用QtCharts进行数据可视化:
-
创建一个简单的图表
-
显示实时数据
-
Qt 图表样式
技术要求
除了我们在整本书中一直使用的基本 PyQt 设置之外,您还需要为QtCharts库安装 PyQt 支持。这种支持不是默认的 PyQt 安装的一部分,但可以通过 PyPI 轻松安装,如下所示:
$ pip install --user PyQtChart
您还需要psutil库,可以从 PyPI 安装。我们已经在第十二章中使用过这个库,使用 QPainter 创建 2D 图形,所以如果您已经阅读了那一章,那么您应该已经有了它。如果没有,可以使用以下命令轻松安装:
$ pip install --user psutil
最后,您可能希望从github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter14下载本章的示例代码。
查看以下视频以查看代码的运行情况:bit.ly/2M5y67f
创建一个简单的图表
在第十二章 使用 QPainter 创建 2D 图形中,我们使用 Qt 图形框架和psutil库创建了一个 CPU 活动图。虽然这种构建图表的方法效果很好,但是创建一个缺乏简单美观性的基本图表需要大量的工作。QtChart库也是基于 Qt 图形框架的,但简化了各种功能完备的图表的创建。
为了演示它的工作原理,我们将构建一个更完整的系统监控程序,其中包括几个图表,这些图表是从psutil库提供的数据派生出来的。
设置 GUI
要开始我们的程序,将 Qt 应用程序模板从第四章 使用 QMainWindow 构建应用程序复制到一个名为system_monitor.py的新文件中。
在应用程序的顶部,我们需要导入QtChart库:
from PyQt5 import QtChart as qtch
我们还需要deque类和psutil库,就像我们在第十二章 使用 QPainter 创建 2D 图形中所需要的那样:
from collections import deque
import psutil
我们的程序将包含几个图表,每个图表都在自己的选项卡中。因此,我们将在MainWindow.__init__()中创建一个选项卡小部件来容纳所有的图表:
tabs = qtw.QTabWidget()
self.setCentralWidget(tabs)
现在 GUI 的主要框架已经就位,我们将开始创建我们的图表类并将它们添加到 GUI 中。
构建磁盘使用情况图
我们将创建的第一个图表是一个条形图,用于显示计算机上每个存储分区使用的磁盘空间。每个检测到的分区都将有一个条形表示其使用空间的百分比。
让我们从为图表创建一个类开始:
class DiskUsageChartView(qtch.QChartView):
chart_title = 'Disk Usage by Partition'
def __init__(self):
super().__init__()
该类是从QtChart.QChartView类派生的;这个QGraphicsView的子类是一个可以显示QChart对象的小部件。就像 Qt 图形框架一样,QtChart框架也是基于模型-视图设计的。在这种情况下,QChart对象类似于QGraphicsScene对象,它将附加到QChartView对象以进行显示。
让我们创建我们的QChart对象,如下所示:
chart = qtch.QChart(title=self.chart_title)
self.setChart(chart)
QChart对象接收一个标题,但是,除此之外,不需要太多的配置;请注意,它也没有说它是条形图。与您可能使用过的其他图表库不同,QChart对象不确定我们正在创建什么样的图表。它只是数据图的容器。
实际的图表类型是通过向图表添加一个或多个系列对象来确定的。一个系列代表图表上的单个绘制数据集。QtChart包含许多系列类,所有这些类都是从QAbstractSeries派生的,每个类代表不同类型的图表样式。
其中一些类如下:
| 类 | 图表类型 | 有用于 |
|---|---|---|
QLineSeries |
直线图 | 从连续数据中采样的点 |
QSplineSeries |
线图,但带有曲线 | 从连续数据中采样的点 |
QBarSeries |
条形图 | 按类别比较值 |
QStackedBarSeries |
堆叠条形图 | 按类别比较细分值 |
QPieSeries |
饼图 | 相对百分比 |
QScatterSeries |
散点图 | 点的集合 |
可以在doc.qt.io/qt-5/qtcharts-overview.html找到可用系列类型的完整列表。我们的图表将比较多个分区的磁盘使用百分比,因此在这些选项中使用最合理的系列类型似乎是QBarSeries类。每个分区将是一个类别,并且将与之关联一个单个值(使用百分比)。
让我们创建QBarSeries类,如下:
series = qtch.QBarSeries()
chart.addSeries(series)
创建系列对象后,我们可以使用addSeries()方法将其添加到我们的图表中。从这个方法的名称,您可能会怀疑,我们实际上可以将多个系列添加到图表中,它们不一定都是相同类型的。例如,我们可以在同一个图表中结合条形和线系列。但在我们的情况下,我们只会有一个系列。
要向我们的系列附加数据,我们必须创建一个称为条形集的东西:
bar_set = qtch.QBarSet('Percent Used')
series.append(bar_set)
Qt 条形图旨在显示类别数据,但也允许比较这些类别中的不同数据集。例如,如果您想要比较公司产品在美国各个城市的相对销售成功情况,您可以使用城市作为类别,并为每种产品创建一个条形集。
在我们的情况下,类别将是系统上的分区,我们只有一个数据集要查看每个分区的数据 - 即磁盘使用百分比。
因此,我们将创建一个要附加到我们系列的单个条形集:
bar_set = qtch.QBarSet('Percent Used')
series.append(bar_set)
QBarSet构造函数接受一个参数,表示数据集的标签。这个QBarSet对象是我们要附加实际数据的对象。
因此,让我们继续检索数据:
partitions = []
for part in psutil.disk_partitions():
if 'rw' in part.opts.split(','):
partitions.append(part.device)
usage = psutil.disk_usage(part.mountpoint)
bar_set.append(usage.percent)
这段代码利用了pustil的disk_partitions()函数列出系统上所有可写的分区(我们对只读设备不感兴趣,例如光驱,因为它们的使用是无关紧要的)。对于每个分区,我们使用disk_usage()函数检索有关磁盘使用情况的命名元组信息。这个元组的percent属性包含磁盘使用百分比,因此我们将该值附加到我们的条形集。我们还将分区的设备名称附加到分区列表中。
到目前为止,我们的图表包含一个数据系列,并且可以显示数据的条形。但是,从图表中提取出很多意义将会很困难,因为没有轴来标记数据。为了解决这个问题,我们需要创建一对轴对象来表示x和y轴。
我们将从x轴开始,如下:
x_axis = qtch.QBarCategoryAxis()
x_axis.append(partitions)
chart.setAxisX(x_axis)
series.attachAxis(x_axis)
QtCharts提供了不同类型的轴对象来处理组织数据的不同方法。我们的x轴由类别组成——每个类别代表计算机上找到的一个分区——因此,我们创建了一个QBarCategoryAxis对象来表示x轴。为了定义使用的类别,我们将一个字符串列表传递给append()方法。
重要的是,我们的类别的顺序要与数据附加到条形集的顺序相匹配,因为每个数据点根据其在系列中的位置进行分类。
创建后,轴必须同时附加到图表和系列上;这是因为图表需要了解轴对象,以便能够正确地标记和缩放轴。这是通过将轴对象传递给图表的setAxisX()方法来实现的。系列还需要了解轴对象,以便能够为图表正确地缩放绘图,我们通过将其传递给系列对象的attachAxis()方法来实现。
我们的y轴表示百分比,所以我们需要一个处理0到100之间的值的轴类型。我们将使用QValueAxis对象,如下所示:
y_axis = qtch.QValueAxis()
y_axis.setRange(0, 100)
chart.setAxisY(y_axis)
series.attachAxis(y_axis)
QValueAxis表示显示数字值刻度的轴,并允许我们为值设置适当的范围。创建后,我们可以将其附加到图表和系列上。
此时,我们可以在MainView.__init__()中创建图表视图对象的实例,并将其添加到选项卡小部件中:
disk_usage_view = DiskUsageChartView()
tabs.addTab(disk_usage_view, "Disk Usage")
如果此时运行应用程序,您应该会得到分区使用百分比的显示:

您的显示可能会有所不同,这取决于您的操作系统和驱动器配置。前面的图看起来很不错,但我们可以做一个小小的改进,即在我们的条形上实际放置百分比标签,以便读者可以看到精确的数据值。这可以通过在DiskUsageChartView.__init__()中添加以下行来完成:
series.setLabelsVisible(True)
现在当我们运行程序时,我们会得到带有标签的条形,如下所示:

嗯,看来这位作者需要一个更大的硬盘了!
显示实时数据
现在我们已经看到了创建静态图表有多么容易,让我们来看看创建实时更新图表的过程。基本上,过程是相同的,但是我们需要定期使用新数据更新图表的数据系列。为了演示这一点,让我们制作一个实时 CPU 使用率监视器。
构建 CPU 使用率图表
让我们在一个名为CPUUsageView的新类中启动我们的 CPU 监视器:
class CPUUsageView(qtch.QChartView):
num_data_points = 500
chart_title = "CPU Utilization"
def __init__(self):
super().__init__()
chart = qtch.QChart(title=self.chart_title)
self.setChart(chart)
就像我们在磁盘使用图表中所做的那样,我们基于QChartView创建了这个类,并在构造函数中创建了一个QChart对象。我们还定义了一个标题,并且,就像我们在第十二章中所做的那样,使用 QPainter 创建 2D 图形,配置了一次显示多少个数据点。不过这次我们要显示更多的点,这样我们就可以得到更详细的图表了。
创建图表对象后,下一步是创建系列对象:
self.series = qtch.QSplineSeries(name="Percentage")
chart.addSeries(self.series)
这次,我们使用QSplineSeries对象;我们也可以使用QLineSeries,但是样条版本将使用三次样条曲线连接我们的数据点,使外观更加平滑,这类似于我们在第十二章中使用贝塞尔曲线所实现的效果,使用 QPainter 创建 2D 图形。
接下来,我们需要使用一些默认数据填充系列对象,如下所示:
self.data = deque(
[0] * self.num_data_points, maxlen=self.num_data_points)
self.series.append([
qtc.QPoint(x, y)
for x, y in enumerate(self.data)
])
我们再次创建一个deque对象来存储数据点,并用零填充它。然后,我们通过使用列表推导式从我们的deque对象创建一个QPoint对象的列表,将这些数据附加到我们的系列中。与QBarSeries类不同,数据直接附加到QSplineSeries对象;对于基于线的系列,没有类似于QBarSet类的东西。
现在我们的系列已经设置好了,让我们来处理轴:
x_axis = qtch.QValueAxis()
x_axis.setRange(0, self.num_data_points)
x_axis.setLabelsVisible(False)
y_axis = qtch.QValueAxis()
y_axis.setRange(0, 100)
chart.setAxisX(x_axis, self.series)
chart.setAxisY(y_axis, self.series)
因为我们的数据主要是(x, y)坐标,我们的两个轴都是QValueAxis对象。然而,我们的x轴坐标的值基本上是没有意义的(它只是deque对象中 CPU 使用值的索引),因此我们将通过将轴的labelsVisible属性设置为False来隐藏这些标签。
请注意,这次我们在使用setAxisX()和setAxisY设置图表的x和y轴时,将系列对象与轴一起传递。这样做会自动将轴附加到系列上,并为每个轴节省了额外的方法调用。
由于我们在这里使用曲线,我们应该进行一次外观优化:
self.setRenderHint(qtg.QPainter.Antialiasing)
QChartView对象的renderHint属性可用于激活抗锯齿,这将改善样条曲线的平滑度。
我们的图表的基本框架现在已经完成;现在我们需要一种方法来收集数据并更新系列。
更新图表数据
更新数据的第一步是创建一个调用psutil.cpu_percent()并更新deque对象的方法:
def refresh_stats(self):
usage = psutil.cpu_percent()
self.data.append(usage)
要更新图表,我们只需要更新系列中的数据。有几种方法可以做到这一点;例如,我们可以完全删除图表中的所有数据,并append()新值。
更好的方法是replace()值,如下所示:
new_data = [
qtc.QPoint(x, y)
for x, y in enumerate(self.data)]
self.series.replace(new_data)
首先,我们使用列表推导从我们的deque对象生成一组新的QPoint对象,然后将列表传递给系列对象的replace()方法,该方法交换所有数据。这种方法比清除所有数据并重新填充系列要快一些,尽管任何一种方法都可以。
现在我们有了刷新方法,我们只需要定期调用它;回到__init__(),让我们添加一个定时器:
self.timer = qtc.QTimer(
interval=200, timeout=self.refresh_stats)
self.timer.start()
这个定时器将每 200 毫秒调用refresh_stats(),更新系列,因此也更新了图表。
回到MainView.__init__(),让我们添加 CPU 图表:
cpu_view = CPUUsageView()
tabs.addTab(cpu_view, "CPU Usage")
现在,您可以运行应用程序,单击 CPU 使用率选项卡,查看类似于以下图表的图表:

尝试进行一些 CPU 密集型任务,为图表生成一些有趣的数据。
在图表周围进行平移和缩放
由于我们的刷新方法每秒调用五次,因此该系列中的数据对于这样一个小图表来说相当详细。这样密集的图表可能是用户希望更详细地探索的内容。为了实现这一功能,我们可以利用QChart对象的方法来在图表图像周围进行平移和缩放,并允许用户更好地查看数据。
要为CPUUsageView类配置交互控件,我们可以重写keyPressEvent()方法,就像我们在第十二章中的游戏中所做的那样,使用 QPainter 创建 2D 图形:
def keyPressEvent(self, event):
keymap = {
qtc.Qt.Key_Up: lambda: self.chart().scroll(0, -10),
qtc.Qt.Key_Down: lambda: self.chart().scroll(0, 10),
qtc.Qt.Key_Right: lambda: self.chart().scroll(-10, 0),
qtc.Qt.Key_Left: lambda: self.chart().scroll(10, 0),
qtc.Qt.Key_Greater: self.chart().zoomIn,
qtc.Qt.Key_Less: self.chart().zoomOut,
}
callback = keymap.get(event.key())
if callback:
callback()
这段代码与我们在坦克游戏中使用的代码类似——我们创建一个dict对象来将键码映射到回调函数,然后检查我们的事件对象,看看是否按下了其中一个映射的键。如果是的话,我们就调用callback方法。
我们映射的第一个方法是QChart.scroll()。scroll()接受x和y值,并将图表在图表视图中移动相应的量。在这里,我们将箭头键映射到lambda函数,以适当地滚动图表。
我们映射的其他方法是zoomIn()和zoomOut()。它们确切地执行它们的名称所暗示的操作,分别放大或缩小两倍。如果我们想要自定义缩放的量,那么我们可以交替调用zoom()方法,该方法接受一个表示缩放因子的浮点值。
如果您现在运行此程序,您应该会发现可以使用箭头键移动图表,并使用尖括号放大或缩小(请记住在大多数键盘上按Shift以获得尖括号)。
Qt 图表样式
Qt 图表默认看起来很好,但让我们面对现实吧——在样式方面,没有人想被困在默认设置中。幸运的是,QtCharts 为我们的可视化组件提供了各种各样的样式选项。
为了探索这些选项,我们将构建第三个图表来显示物理和交换内存使用情况,然后根据我们自己的喜好进行样式化。
构建内存图表
我们将像在前面的部分中一样开始这个图表视图对象:
class MemoryChartView(qtch.QChartView):
chart_title = "Memory Usage"
num_data_points = 50
def __init__(self):
super().__init__()
chart = qtch.QChart(title=self.chart_title)
self.setChart(chart)
series = qtch.QStackedBarSeries()
chart.addSeries(series)
self.phys_set = qtch.QBarSet("Physical")
self.swap_set = qtch.QBarSet("Swap")
series.append(self.phys_set)
series.append(self.swap_set)
这个类的开始方式与我们的磁盘使用图表类似——通过子类化QChartView,定义图表,定义系列,然后定义一些条形集。然而,这一次,我们将使用QStackedBarSeries。堆叠条形图与常规条形图类似,只是每个条形集是垂直堆叠而不是并排放置。这种图表对于显示一系列相对百分比很有用,这正是我们要显示的。
在这种情况下,我们将有两个条形集——一个用于物理内存使用,另一个用于交换内存使用,每个都是总内存(物理和交换)的百分比。通过使用堆叠条形图,总内存使用将由条形高度表示,而各个部分将显示该总内存的交换和物理组件。
为了保存我们的数据,我们将再次使用deque对象设置默认数据,并将数据附加到条形集中:
self.data = deque(
[(0, 0)] * self.num_data_points,
maxlen=self.num_data_points)
for phys, swap in self.data:
self.phys_set.append(phys)
self.swap_set.append(swap)
这一次,deque对象中的每个数据点需要有两个值:第一个是物理数据,第二个是交换数据。我们通过使用每个数据点的两元组序列来表示这一点。
下一步,再次是设置我们的轴:
x_axis = qtch.QValueAxis()
x_axis.setRange(0, self.num_data_points)
x_axis.setLabelsVisible(False)
y_axis = qtch.QValueAxis()
y_axis.setRange(0, 100)
chart.setAxisX(x_axis, series)
chart.setAxisY(y_axis, series)
在这里,就像 CPU 使用图表一样,我们的x轴只表示数据的无意义索引号,所以我们只是要隐藏标签。另一方面,我们的y轴表示一个百分比,所以我们将其范围设置为0到100。
现在,我们将创建我们的refresh方法来更新图表数据:
def refresh_stats(self):
phys = psutil.virtual_memory()
swap = psutil.swap_memory()
total_mem = phys.total + swap.total
phys_pct = (phys.used / total_mem) * 100
swap_pct = (swap.used / total_mem) * 100
self.data.append(
(phys_pct, swap_pct))
for x, (phys, swap) in enumerate(self.data):
self.phys_set.replace(x, phys)
self.swap_set.replace(x, swap)
psutil库有两个函数用于检查内存使用情况:virtual_memory()返回有关物理 RAM 的信息;swap_memory()返回有关交换文件使用情况的信息。我们正在应用一些基本算术来找出交换和物理内存使用的总内存百分比,然后将这些数据附加到deque对象中,并通过迭代来替换条形集中的数据。
最后,我们将在__init__()中再次添加我们的定时器来调用刷新方法:
self.timer = qtc.QTimer(
interval=1000, timeout=self.refresh_stats)
self.timer.start()
图表视图类现在应该是完全功能的,所以让我们将其添加到MainWindow类中并进行测试。
为此,在MainWindow.__init__()中添加以下代码:
cpu_time_view = MemoryChartView()
tabs.addTab(cpu_time_view, "Memory Usage")
如果此时运行程序,应该会有一个每秒更新一次的工作内存使用监视器。这很好,但看起来太像默认设置了;所以,让我们稍微调整一下样式。
图表样式
为了给我们的内存图表增添一些个性,让我们回到MemoryChartView.__init__(),开始添加代码来样式化图表的各个元素。
我们可以做的最简单但最有趣的改变之一是激活图表的内置动画:
chart.setAnimationOptions(qtch.QChart.AllAnimations)
QChart对象的animationOptions属性确定图表创建或更新时将运行哪些内置图表动画。选项包括GridAxisAnimations,用于动画绘制轴;SeriesAnimations,用于动画更新系列数据;AllAnimations,我们在这里使用它来激活网格和系列动画;以及NoAnimations,你可能猜到了,用于关闭所有动画(当然,这是默认设置)。
如果你现在运行程序,你会看到网格和轴扫过来,并且每个条形从图表底部平滑地弹出。动画本身是预设的每个系列类型;请注意,我们除了设置缓和曲线和持续时间外,无法对其进行自定义:
chart.setAnimationEasingCurve(
qtc.QEasingCurve(qtc.QEasingCurve.OutBounce))
chart.setAnimationDuration(1000)
在这里,我们将图表的animationEasingCurve属性设置为一个具有out bounce缓和曲线的QtCore.QEasingCurve对象。我们还将动画时间延长到整整一秒。如果你现在运行程序,你会看到动画会反弹并持续时间稍长。
我们还可以通过启用图表的阴影来进行另一个简单的调整,如下所示:
chart.setDropShadowEnabled(True)
将dropShadowEnabled设置为True将导致在图表绘图区域周围显示一个阴影,给它一个微妙的 3D 效果。
通过设置图表的theme属性,我们可以实现外观上的更明显的变化,如下所示:
chart.setTheme(qtch.QChart.ChartThemeBrownSand)
尽管这被称为图表主题,但它主要影响了绘图所使用的颜色。Qt 5.12 附带了八种图表主题,可以在doc.qt.io/qt-5/qchart.html#ChartTheme-enum找到。在这里,我们配置了Brown Sand主题,它将使用土地色调来展示我们的数据绘图。
对于我们的堆叠条形图,这意味着堆栈的每个部分将从主题中获得不同的颜色。
我们可以通过设置图表的背景来进行另一个非常显著的改变。这可以通过将backgroundBrush属性设置为自定义的QBrush对象来实现:
gradient = qtg.QLinearGradient(
chart.plotArea().topLeft(), chart.plotArea().bottomRight())
gradient.setColorAt(0, qtg.QColor("#333"))
gradient.setColorAt(1, qtg.QColor("#660"))
chart.setBackgroundBrush(qtg.QBrush(gradient))
在这种情况下,我们创建了一个线性渐变,并使用它来创建了一个背景的QBrush对象(有关更多讨论,请参阅第六章,Qt 应用程序的样式)。
背景也有一个QPen对象,用于绘制绘图区域的边框:
chart.setBackgroundPen(qtg.QPen(qtg.QColor('black'), 5))
如果你现在运行程序,可能会发现文字有点难以阅读。不幸的是,没有一种简单的方法可以一次更新图表中所有的文字外观 - 我们需要逐个进行。我们可以从图表的标题文字开始,通过设置titleBrush和titleFont属性来实现:
chart.setTitleBrush(
qtg.QBrush(qtc.Qt.white))
chart.setTitleFont(qtg.QFont('Impact', 32, qtg.QFont.Bold))
修复剩下的文字不能通过chart对象完成。为此,我们需要查看如何对图表中的其他对象进行样式设置。
修饰轴
图表轴上使用的标签的字体和颜色必须通过我们的轴对象进行设置:
axis_font = qtg.QFont('Mono', 16)
axis_brush = qtg.QBrush(qtg.QColor('#EEF'))
y_axis.setLabelsFont(axis_font)
y_axis.setLabelsBrush(axis_brush)
在这里,我们使用setLabelsFont()和setLabelsBrush()方法分别设置了y轴的字体和颜色。请注意,我们也可以设置x轴标签的字体和颜色,但由于我们没有显示x标签,所以没有太大意义。
轴对象还可以让我们通过gridLinePen属性来设置网格线的样式:
grid_pen = qtg.QPen(qtg.QColor('silver'))
grid_pen.setDashPattern([1, 1, 1, 0])
x_axis.setGridLinePen(grid_pen)
y_axis.setGridLinePen(grid_pen)
在这里,我们设置了一个虚线银色的QPen对象来绘制x和y轴的网格线。顺便说一句,如果你想改变图表上绘制的网格线数量,可以通过设置轴对象的tickCount属性来实现:
y_axis.setTickCount(11)
默认的刻度数是5,最小值是2。请注意,这个数字包括顶部和底部的线,所以为了让网格线每 10%显示一条,我们将轴设置为11个刻度。
为了帮助用户区分紧密排列的网格线,我们还可以在轴对象上启用阴影:
y_axis.setShadesVisible(True)
y_axis.setShadesColor(qtg.QColor('#884'))
如你所见,如果你运行应用程序,这会导致网格线之间的每个交替区域根据配置的颜色进行着色,而不是使用默认的背景。
修饰图例
在这个图表中我们可能想要修复的最后一件事是图例。这是图表中解释哪种颜色对应哪个条形集的部分。图例由QLegend对象表示,它会随着我们添加条形集或系列对象而自动创建和更新。
我们可以使用legend()访问器方法来检索图表的QLegend对象:
legend = chart.legend()
默认情况下,图例没有背景,只是直接绘制在图表背景上。我们可以改变这一点以提高可读性,如下所示:
legend.setBackgroundVisible(True)
legend.setBrush(
qtg.QBrush(qtg.QColor('white')))
我们首先通过将backgroundVisible设置为True来打开背景,然后通过将brush属性设置为QBrush对象来配置背景的刷子。
文本的颜色和字体也可以进行配置,如下所示:
legend.setFont(qtg.QFont('Courier', 14))
legend.setLabelColor(qtc.Qt.darkRed)
我们可以使用setLabelColor()设置标签颜色,或者使用setLabelBrush()方法更精细地控制刷子。
最后,我们可以配置用于指示颜色的标记的形状:
legend.setMarkerShape(qtch.QLegend.MarkerShapeCircle)
这里的选项包括MarkerShapeCircle,MarkerShapeRectangle和MarkerShapeFromSeries,最后一个选择适合正在绘制的系列的形状(例如,线条或样条图的短线,或散点图的点)。
此时,您的内存图表应该看起来像这样:

不错!现在,尝试使用自己的颜色、刷子、笔和字体值,看看您能创造出什么!
摘要
在本章中,您学会了如何使用QtChart可视化数据。您创建了一个静态表格,一个动画实时表格,以及一个带有自定义颜色和字体的花哨图表。您还学会了如何创建柱状图、堆叠柱状图和样条图。
在下一章中,我们将探讨在树莓派上使用 PyQt 的用法。您将学习如何安装最新版本的 PyQt,以及如何利用树莓派的独特功能将您的 PyQt 应用程序与电路和外部硬件进行接口。
问题
尝试这些问题来测试您对本章的了解:
- 考虑以下数据集的描述。为每个数据集建议一种图表样式:
-
按日期的 Web 服务器点击次数
-
每个销售人员每月的销售数据
-
公司部门过去一年的支持票比例
-
几百株豆类植物的产量与植物高度的图表
- 以下代码中尚未配置哪个图表组件,结果将是什么?
data_list = [
qtc.QPoint(2, 3),
qtc.QPoint(4, 5),
qtc.QPoint(6, 7)]
chart = qtch.QChart()
series = qtch.QLineSeries()
series.append(data_list)
view = qtch.QChartView()
view.setChart(chart)
view.show()
- 以下代码有什么问题?
mainwindow = qtw.QMainWindow()
chart = qtch.QChart()
series = qtch.QPieSeries()
series.append('Half', 50)
series.append('Other Half', 50)
mainwindow.setCentralWidget(chart)
mainwindow.show()
- 您想创建一个柱状图,比较鲍勃和爱丽丝本季度的销售数据。需要添加什么代码?请注意,这里不需要轴:
bob_sales = [2500, 1300, 800]
alice_sales = [1700, 1850, 2010]
chart = qtch.QChart()
series = qtch.QBarSeries()
chart.addSeries(series)
# add code here
# end code
view = qtch.QChartView()
view.setChart(chart)
view.show()
-
给定一个名为
chart的QChart对象,写一些代码,使图表具有黑色背景和蓝色数据绘图。 -
使用您为
内存使用情况图表使用的技术为系统监视器脚本中的另外两个图表设置样式。尝试不同的刷子和笔,看看是否可以找到其他要设置的属性。 -
QPolarChart是QChart的一个子类,允许您构建极坐标图。在 Qt 文档中调查极坐标图的使用,并查看是否可以创建一个适当数据集的极坐标图。 -
psutil.cpu_percent()接受一个可选参数percpu,它将创建一个显示每个 CPU 核使用信息的值列表。更新您的应用程序以使用此选项,并分别在一个图表上显示每个 CPU 核的活动。
进一步阅读
有关更多信息,请参考以下链接:
-
QtCharts概述可以在doc.qt.io/qt-5/qtcharts-index.html找到 -
psutil库的更多文档可以在psutil.readthedocs.io/en/latest/找到 -
加州大学伯克利分校的这篇指南为不同类型的数据选择合适的图表提供了一些指导:
guides.lib.berkeley.edu/data-visualization/type
第十五章:PyQt 树莓派
树莓派是过去十年中最成功和令人兴奋的计算机之一。这款由英国非营利组织于 2012 年推出的微型高级 RISC 机器(ARM)计算机,旨在教育孩子们计算机科学知识,已成为业余爱好者、改装者、开发人员和各类 IT 专业人士的普遍工具。由于 Python 和 PyQt 在其默认操作系统上得到了很好的支持,树莓派也是 PyQt 开发人员的绝佳工具。
在本章中,我们将在以下部分中查看在树莓派上使用 PyQt5 开发:
-
在树莓派上运行 PyQt5
-
使用 PyQt 控制通用输入/输出(GPIO)设备
-
使用 GPIO 设备控制 PyQt
技术要求
为了跟随本章的示例,您需要以下物品:
-
一台树莓派——最好是 3 型 B+或更新的型号
-
树莓派的电源供应、键盘、鼠标、显示器和网络连接
-
安装了 Raspbian 10 或更高版本的微型 SD 卡;您可以参考官方文档
www.raspberrypi.org/documentation/installation/上的说明来安装 Raspbian
在撰写本文时,Raspbian 10 尚未发布,尽管可以将 Raspbian 9 升级到测试版本。如果 Raspbian 10 不可用,您可以参考本书的附录 B,将 Raspbian 9 升级到 Raspbian 10,了解升级的说明。
为了编写基于 GPIO 的项目,您还需要一些电子元件来进行接口。这些零件通常可以在电子入门套件中找到,也可以从当地的电子供应商那里购买。
第一个项目将需要以下物品:
-
一个面包板
-
三个相同的电阻(阻值在 220 到 1000 欧姆之间)
-
一个三色 LED
-
四根母对公跳线
第二个项目将需要以下物品:
-
一个面包板
-
一个 DHT11 或 DHT22 温湿度传感器
-
一个按钮开关
-
一个电阻(值不重要)
-
三根母对公跳线
-
Adafruit DHT 传感器库,可使用以下命令从 PyPI 获取:
$ sudo pip3 install Adafruit_DHT
您可以参考 GitHub 存储库github.com/adafruit/Adafruit_Python_DHT获取更多信息。
您可能还想从github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter15下载示例代码。
查看以下视频以查看代码运行情况:bit.ly/2M5xDSx
在树莓派上运行 PyQt5
树莓派能够运行许多不同的操作系统,因此安装 Python 和 PyQt 完全取决于您选择的操作系统。在本书中,我们将专注于树莓派的官方(也是最常用的)操作系统Raspbian。
Raspbian 基于 Debian GNU/Linux 的稳定版本,目前是 Debian 9(Stretch)。不幸的是,本书中的代码所需的 Python 和 PyQt5 版本对于这个 Debian 版本来说太旧了。如果在阅读本书时,Raspbian 10 尚未发布,请参考附录 B,将 Raspbian 9 升级到 Raspbian 10,了解如何将 Raspbian 9 升级到 Raspbian 10 的说明。
Raspbian 10 预装了 Python 3.7,但我们需要自己安装 PyQt5。请注意,您不能使用pip在树莓派上安装 PyQt5,因为所需的 Qt 二进制文件在 PyPI 上不适用于 ARM 平台(树莓派所基于的平台)。但是,PyQt5 的一个版本可以从 Raspbian 软件存储库中获取。这将不是 PyQt5 的最新版本,而是在 Debian 开发过程中选择的最稳定和兼容发布的版本。对于 Debian/Raspbian 10,这个版本是 PyQt 5.11。
要安装它,首先确保您的设备连接到互联网。然后,打开命令行终端并输入以下命令:
$ sudo apt install python3-pyqt5
高级打包工具(APT)实用程序将下载并安装 PyQt5 及所有必要的依赖项。请注意,此命令仅为 Python 3 安装 PyQt5 的主要模块。某些模块,如QtSQL、QtMultimedia、QtChart和QtWebEngineWidgets,是单独打包的,需要使用额外的命令进行安装:
$ sudo apt install python3-pyqt5.qtsql python3-pyqt5.qtmultimedia python3-pyqt5.qtchart python3-pyqt5.qtwebengine
有许多为 PyQt5 打包的可选库。要获取完整列表,可以使用apt search命令,如下所示:
$ apt search pyqt5
APT 是在 Raspbian、Debian 和许多其他 Linux 发行版上安装、删除和更新软件的主要方式。虽然类似于pip,APT 用于整个操作系统。
在树莓派上编辑 Python
尽管您可以在自己的计算机上编辑 Python 并将其复制到树莓派上执行,但您可能会发现直接在设备上编辑代码更加方便。如果您喜欢的代码编辑器或集成开发环境(IDE)在 Linux 或 ARM 上不可用,不要担心;Raspbian 提供了几种替代方案:
-
Thonny Python IDE 预装了默认的 Raspbian 镜像,并且非常适合本章的示例
-
IDLE,Python 的默认编程环境也是预装的
-
Geany,一个适用于许多语言的通用编程文本编辑器,也是预装的
-
传统的代码编辑器,如Vim和Emacs,以及 Python IDE,如Spyder、Ninja IDE和Eric,可以使用添加/删除软件工具(在程序菜单的首选项下找到)或使用
apt命令从软件包存储库安装
无论您选择哪种应用程序或方法,请确保将文件备份到另一台设备,因为树莓派的 SD 卡存储并不是最稳健的。
在树莓派上运行 PyQt5 应用程序
一旦 Python 和 PyQt5 安装在您的树莓派上,您应该能够运行本书中到目前为止我们编写的任何应用程序。基本上,树莓派是一台运行 GNU/Linux 的计算机,本书中的所有代码都与之兼容。考虑到这一点,您可以简单地将其用作运行 PyQt 应用程序的小型、节能计算机。
然而,树莓派有一些独特的特性,最显著的是其 GPIO 引脚。这些引脚使树莓派能够以一种非常简单和易于访问的方式与外部数字电路进行通信。Raspbian 预装了软件库,允许我们使用 Python 控制这些引脚。
为了充分利用这一特性提供给我们的独特平台,我们将在本章的其余部分中专注于使用 PyQt5 与树莓派的 GPIO 功能结合,创建 GUI 应用程序,以与现实世界的电路进行交互,这只有像树莓派这样的设备才能做到。
使用 PyQt 控制 GPIO 设备
对于我们的第一个项目,我们将学习如何可以从 PyQt 应用程序控制外部电路。您将连接一个多色 LED,并使用QColorDialog来控制其颜色。收集第一个项目中列出的组件,并让我们开始吧。
连接 LED 电路
让我们通过在面包板上连接电路的组件来开始这个项目。关闭树莓派并断开电源,然后将其放在面包板附近。
在连接电路到 GPIO 引脚之前,关闭树莓派并断开电源总是一个好主意。这将减少在连接错误的情况下破坏树莓派的风险,或者如果您意外触摸到组件引脚。
这个电路中的主要组件是三色 LED。尽管它们略有不同,但这个元件的最常见引脚布局如下:

基本上,三色 LED 是将红色 LED、绿色 LED 和蓝色 LED 组合成一个包。它提供单独的输入引脚,以便分别向每种颜色发送电流,并提供一个共同的地引脚。通过向每个引脚输入不同的电压,我们可以混合红色、绿色和蓝色光,从而创建各种各样的颜色,就像我们在应用程序中混合这三种元素来创建 RGB 颜色一样。
将 LED 添加到面包板上,使得每个引脚都在面包板的不同行上。然后,连接其余的组件如下:

如前图所示,我们正在进行以下连接:
-
LED 上的地针直接连接到树莓派左侧第三个外部引脚。
-
LED 上的红色引脚连接到一个电阻,然后连接到右侧的下一个引脚(即引脚 8)
-
LED 上的绿色引脚连接到另一个电阻,然后连接到右侧的下一个空闲引脚(即引脚 10)
-
LED 上的蓝色引脚连接到最后一个电阻,然后连接到 Pi 上右侧的下一个空闲引脚(引脚 12)
重要的是要仔细检查您的电路,并确保您已将电线连接到树莓派上的正确引脚。树莓派上并非所有的 GPIO 引脚都相同;其中一些是可编程的,而其他一些具有硬编码目的。您可以通过在终端中运行pinout命令来查看 Pi 上的引脚列表;您应该看到以下输出:

前面的屏幕截图显示了引脚的布局,就好像您正面对着树莓派,USB 端口朝下。请注意,其中有几个引脚标有GND;这些始终是地引脚,因此您可以将电路的地连接到其中任何一个引脚。其他引脚标有5V或3V3;这些始终是 5 伏或 3.3 伏。其余带有 GPIO 标签的引脚是可编程引脚。您的电线应连接到引脚8(GPIO14)、10(GPIO15)和12(GPIO18)。
仔细检查您的电路连接,然后启动树莓派。是时候开始编码了!
编写驱动程序库
现在我们的电路已连接好,我们需要编写一些代码来控制它。为此,我们将在树莓派上使用GPIO库。从第四章中创建一个 PyQt 应用程序模板的副本,使用 QMainWindow 构建应用程序,并将其命名为three_color_led_gui.py。
我们将从导入GPIO库开始:
from RPi import GPIO
我们首先要做的是创建一个 Python 类,作为我们电路的 API。我们将称之为ThreeColorLed,然后开始如下:
class ThreeColorLed():
"""Represents a three color LED circuit"""
def __init__(self, red, green, blue, pinmode=GPIO.BOARD, freq=50):
GPIO.setmode(pinmode)
我们的__init__()方法接受五个参数:前三个参数是红色、绿色和蓝色 LED 连接的引脚号;第四个参数是用于解释引脚号的引脚模式;第五个参数是频率,我们稍后会讨论。首先,让我们谈谈引脚模式。
如果你查看pinout命令的输出,你会注意到在树莓派上用整数描述引脚有两种方法。第一种是根据板子上的位置,从 1 到 40。第二种是根据它的 GPIO 编号(即在引脚描述中跟在 GPIO 后面的数字)。GPIO库允许你使用任一种数字来指定引脚,但你必须通过向GPIO.setmode()函数传递两个常量中的一个来告诉它你要使用哪种方法。GPIO.BOARD指定你使用位置编号(如 1 到 40),而GPIO.BCM表示你要使用 GPIO 名称。正如你所看到的,我们默认在这里使用BOARD。
每当你编写一个以 GPIO 引脚号作为参数的类时,一定要允许用户指定引脚模式。这些数字本身没有引脚模式的上下文是没有意义的。
接下来,我们的__init__()方法需要设置输出引脚:
self.pins = {
"red": red,
"green": green,
"blue": blue
}
for pin in self.pins.values():
GPIO.setup(pin, GPIO.OUT)
GPIO 引脚可以设置为IN或OUT模式,取决于你是想从引脚状态读取还是向其写入。在这个项目中,我们将从软件发送信息到电路,所以我们需要将所有引脚设置为OUT模式。在将引脚号存储在dict对象中后,我们已经通过使用GPIO.setup()函数迭代它们并将它们设置为适当的模式。
设置好后,我们可以使用GPIO.output()函数告诉单个引脚是高电平还是低电平,如下所示:
# Turn all on and all off
for pin in self.pins.values():
GPIO.output(pin, GPIO.HIGH)
GPIO.output(pin, GPIO.LOW)
这段代码简单地打开每个引脚,然后立即关闭(可能比你看到的更快)。我们可以使用这种方法来设置 LED 为几种简单的颜色;例如,我们可以通过将红色引脚设置为HIGH,其他引脚设置为LOW来使其变为红色,或者通过将蓝色和绿色引脚设置为HIGH,红色引脚设置为LOW来使其变为青色。当然,我们希望产生更多种颜色,但我们不能简单地通过完全打开或关闭引脚来做到这一点。我们需要一种方法来在每个引脚的电压之间平稳地变化,从最小值(0 伏)到最大值(5 伏)。
不幸的是,树莓派无法做到这一点。输出是数字的,而不是模拟的,因此它们只能完全开启或完全关闭。然而,我们可以通过使用一种称为脉宽调制(PWM)的技术来模拟变化的电压。
PWM
在你家里找一个有相对灵敏灯泡的开关(LED 灯泡效果最好)。然后,尝试每秒钟打开和关闭一次。现在越来越快地按开关,直到房间里的灯几乎看起来是恒定的。你会注意到房间里的光似乎比你一直开着灯时要暗,即使灯泡只是完全开启或完全关闭。
PWM 的工作方式相同,只是在树莓派上,我们可以如此快速(当然是无声地)地打开和关闭电压,以至于在打开和关闭之间的切换看起来是无缝的。此外,通过在每个周期中调整引脚打开时间和关闭时间的比例,我们可以模拟在零电压和最大电压之间的变化电压。这个比例被称为占空比。
关于脉宽调制的概念和用法的更多信息可以在en.wikipedia.org/wiki/Pulse-width_modulation找到。
要在我们的引脚上使用 PWM,我们首先要通过在每个引脚上创建一个GPIO.PWM对象来设置它们:
self.pwms = dict([
(name, GPIO.PWM(pin, freq))
for name, pin in self.pins.items()
])
在这种情况下,我们使用列表推导来生成另一个包含每个引脚名称和PWM对象的dict。通过传入引脚号和频率值来创建PWM对象。这个频率将是引脚切换开和关的速率。
一旦我们创建了我们的PWM对象,我们需要启动它们:
for pwm in self.pwms.values():
pwm.start(0)
PWM.start()方法开始引脚的闪烁。传递给start()的参数表示占空比的百分比;这里,0表示引脚将在 0%的时间内打开(基本上是关闭)。值为 100 将使引脚始终完全打开,而介于两者之间的值表示引脚在每个周期内接收的打开时间的量。
设置颜色
现在我们的引脚已经配置为 PWM,我们需要创建一个方法,通过传入红色、绿色和蓝色值,使 LED 显示特定的颜色。大多数软件 RGB 颜色实现(包括QColor)将这些值指定为 8 位整数(0 到 255)。然而,我们的 PWM 值表示占空比,它表示为百分比(0 到 100)。
因此,由于我们需要多次将 0 到 255 范围内的数字转换为 0 到 100 范围内的数字,让我们从一个静态方法开始,该方法将执行这样的转换:
@staticmethod
def convert(val):
val = abs(val)
val = val//2.55
val %= 101
return val
该方法确保我们将获得有效的占空比,而不管输入如何,都使用简单的算术运算:
-
首先,我们使用数字的绝对值来防止传递任何负值。
-
其次,我们将值除以 2.55,以找到它代表的 255 的百分比。
-
最后,我们对数字取 101 的模,这样百分比高于 100 的数字将循环并保持在范围内。
现在,让我们编写我们的set_color()方法,如下所示:
def set_color(self, red, green, blue):
"""Set color using RGB color values of 0-255"""
self.pwms['red'].ChangeDutyCycle(self.convert(red))
self.pwms['green'].ChangeDutyCycle(self.convert(green))
self.pwms['blue'].ChangeDutyCycle(self.convert(blue))
PWM.ChangeDutyCycle()方法接受 0 到 100 的值,并相应地调整引脚的占空比。在这个方法中,我们只是将我们的输入 RGB 值转换为适当的比例,并将它们传递给相应的 PWM 对象。
清理
我们需要添加到我们的类中的最后一个方法是清理方法。树莓派上的 GPIO 引脚可以被视为一个状态机,其中每个引脚都有高状态或低状态(即打开或关闭)。当我们在程序中设置这些引脚时,这些引脚的状态将在程序退出后保持设置。
请注意,如果我们连接了不同的电路到我们的 Pi,这可能会导致问题;在连接电路时,如果在错误的时刻将引脚设置为HIGH,可能会烧坏一些组件。因此,我们希望在退出程序时将所有东西关闭。
这可以使用GPIO.cleanup()函数完成:
def cleanup(self):
GPIO.cleanup()
通过将这个方法添加到我们的 LED 驱动程序类中,我们可以在每次使用后轻松清理 Pi 的状态。
创建 PyQt GUI
现在我们已经处理了 GPIO 方面,让我们创建我们的 PyQt GUI。在MainWindow.__init__()中,添加以下代码:
self.tcl = ThreeColorLed(8, 10, 12)
在这里,我们使用连接到面包板的引脚号创建了一个ThreeColorLed实例。请记住,默认情况下,该类使用BOARD号码,因此这里的正确值是8、10和12。如果要使用BCM号码,请确保在构造函数参数中指定这一点。
现在让我们添加一个颜色选择对话框:
ccd = qtw.QColorDialog()
ccd.setOptions(
qtw.QColorDialog.NoButtons
| qtw.QColorDialog.DontUseNativeDialog)
ccd.currentColorChanged.connect(self.set_color)
self.setCentralWidget(ccd)
通常,我们通过调用QColorDialog.getColor()来调用颜色对话框,但在这种情况下,我们希望将对话框用作小部件。因此,我们直接实例化一个对话框,并设置NoButtons和DontUseNativeDialog选项。通过去掉按钮并使用对话框的 Qt 版本,我们可以防止用户取消或提交对话框。这允许我们将其视为常规小部件并将其分配为主窗口的中央小部件。
我们已经将currentColorChanged信号(每当用户选择颜色时发出)连接到一个名为set_color()的MainWindow方法。我们将在接下来添加这个方法,如下所示:
def set_color(self, color):
self.tcl.set_color(color.red(), color.green(), color.blue())
currentColorChanged信号包括表示所选颜色的QColor对象,因此我们可以简单地使用QColor属性访问器将其分解为红色、绿色和蓝色值,然后将该信息传递给我们的ThreeColorLed对象的set_color()方法。
现在脚本已经完成。您应该能够运行它并点亮 LED-试试看!
请注意,您选择的颜色可能不会完全匹配 LED 的颜色输出,因为不同颜色 LED 的相对亮度不同。但它们应该是相当接近的。
使用 GPIO 设备控制 PyQt
使用 GPIO 引脚从 Python 控制电路非常简单。只需调用GPIO.output()函数,并使用适当的引脚编号和高或低值。然而,现在我们要看相反的情况,即从 GPIO 输入控制或更新 PyQt GUI。
为了演示这一点,我们将构建一个温度和湿度读数。就像以前一样,我们将从连接电路开始。
连接传感器电路
DHT 11 和 DHT 22 传感器都是温度和湿度传感器,可以很容易地与树莓派一起使用。两者都打包为四针元件,但实际上只使用了三根引脚。一些元件套件甚至将 DHT 11/22 安装在一个小 PCB 上,只有三根活动引脚用于输出。
无论哪种情况,如果您正在查看 DHT 的正面(即,格栅一侧),则从左到右的引脚如下:
-
输入电压——5 或 3 伏特
-
传感器输出
-
死引脚(在 4 针配置中)
-
地线
DHT 11 或 DHT 22 对于这个项目都同样适用。11 更小更便宜,但比 22 慢且不太准确。否则,它们在功能上是一样的。
将传感器插入面包板中,使每个引脚都在自己的行中。然后,使用跳线线将其连接到树莓派,如下面的屏幕截图所示:

传感器的电压输入引脚可以连接到任何一个 5V 引脚,地线可以连接到任何一个 GND 引脚。此外,数据引脚可以连接到树莓派上的任何 GPIO 引脚,但在这种情况下,我们将使用引脚 7(再次,按照BOARD编号)。
仔细检查您的连接,确保一切正确,然后打开树莓派的电源,我们将开始编码。
创建传感器接口
要开始我们的传感器接口软件,首先创建另一个 Qt 应用程序模板的副本,并将其命名为temp_humid_display.py。
我们将首先导入必要的库,如下所示:
import Adafruit_DHT
from RPi import GPIO
Adafruit_DHT将封装与 DHT 单元通信所需的所有复杂部分,因此我们只需要使用高级功能来控制和读取设备的数据。
在导入下面,让我们设置一个全局常量:
SENSOR_MODEL = 11
GPIO.setmode(GPIO.BCM)
我们正在设置一个全局常量,指示我们正在使用哪个型号的 DHT;如果您有 DHT 22,则将此值设置为 22。我们还设置了树莓派的引脚模式。但这次,我们将使用BCM模式来指定我们的引脚编号。Adafruit 库只接受BCM编号,因此在我们所有的类中保持一致是有意义的。
现在,让我们开始为 DHT 创建传感器接口类:
class SensorInterface(qtc.QObject):
temperature = qtc.pyqtSignal(float)
humidity = qtc.pyqtSignal(float)
read_time = qtc.pyqtSignal(qtc.QTime)
这一次,我们将基于QObject类来创建我们的类,以便在从传感器读取值时发出信号,并在其自己的线程中运行对象。DHT 单元有点慢,当我们请求读数时可能需要一秒或更长时间来响应。因此,我们希望在单独的执行线程中运行其接口。正如您可能记得的来自第十章 使用 QTimer 和 QThread 进行多线程处理,当我们可以使用信号和插槽与对象交互时,这很容易实现。
现在,让我们添加__init__()方法,如下所示:
def __init__(self, pin, sensor_model, fahrenheit=False):
super().__init__()
self.pin = pin
self.model = sensor_model
self.fahrenheit = fahrenheit
构造函数将接受三个参数:连接到数据线的引脚,型号(11 或 22),以及一个布尔值,指示我们是否要使用华氏或摄氏温标。我们暂时将所有这些参数保存到实例变量中。
现在我们想要创建一个方法来告诉传感器进行读数:
@qtc.pyqtSlot()
def take_reading(self):
h, t = Adafruit_DHT.read_retry(self.model, self.pin)
if self.fahrenheit:
t = ((9/5) * t) + 32
self.temperature.emit(t)
self.humidity.emit(h)
self.read_time.emit(qtc.QTime.currentTime())
正如您所看到的,Adafruit_DHT库消除了读取传感器的所有复杂性。我们只需使用传感器的型号和引脚号调用read_entry(),它就会返回一个包含湿度和温度值的元组。温度以摄氏度返回,因此对于美国用户,如果对象配置为这样做,我们将进行计算将其转换为华氏度。然后,我们发出三个信号——分别是温度、湿度和当前时间。
请注意,我们使用pyqtSlot装饰器包装了这个函数。再次回想一下第十章中的内容,使用 QTimer 和 QThread 进行多线程处理,这将消除将这个类移动到自己的线程中的一些复杂性。
这解决了我们的传感器驱动程序类,现在让我们构建 GUI。
显示读数
在本书的这一部分,创建一个 PyQt GUI 来显示一些数字应该是轻而易举的。为了增加趣味性并创建时尚的外观,我们将使用一个我们还没有讨论过的小部件——QLCDNumber。
首先,在MainWindow.__init__()中创建一个基本小部件,如下所示:
widget = qtw.QWidget()
widget.setLayout(qtw.QFormLayout())
self.setCentralWidget(widget)
现在,让我们应用一些我们在第六章中学到的样式技巧,Qt 应用程序样式:
p = widget.palette()
p.setColor(qtg.QPalette.WindowText, qtg.QColor('cyan'))
p.setColor(qtg.QPalette.Window, qtg.QColor('navy'))
p.setColor(qtg.QPalette.Button, qtg.QColor('#335'))
p.setColor(qtg.QPalette.ButtonText, qtg.QColor('cyan'))
self.setPalette(p)
在这里,我们为这个小部件及其子级创建了一个自定义的QPalette对象,给它一个类似于蓝色背光 LCD 屏幕的颜色方案。
接下来,让我们创建用于显示我们的读数的小部件:
tempview = qtw.QLCDNumber()
humview = qtw.QLCDNumber()
tempview.setSegmentStyle(qtw.QLCDNumber.Flat)
humview.setSegmentStyle(qtw.QLCDNumber.Flat)
widget.layout().addRow('Temperature', tempview)
widget.layout().addRow('Humidity', humview)
QLCDNumber小部件是用于显示数字的小部件。它类似于一个八段数码管显示,例如您可能在仪表板或数字时钟上找到的。它的segmentStyle属性在几种不同的视觉样式之间切换;在这种情况下,我们使用Flat,它用前景颜色填充了段。
现在布局已经配置好了,让我们创建一个传感器对象:
self.sensor = SensorInterface(4, SENSOR_MODEL, True)
self.sensor_thread = qtc.QThread()
self.sensor.moveToThread(self.sensor_thread)
self.sensor_thread.start()
在这里,我们创建了一个连接到 GPIO4 引脚(即 7 号引脚)的传感器,传入我们之前定义的SENSOR_MODEL常量,并将华氏度设置为True(如果您喜欢摄氏度,可以随时将其设置为False)。之后,我们创建了一个QThread对象,并将SensorInterface对象移动到其中。
接下来,让我们连接我们的信号和插槽,如下所示:
self.sensor.temperature.connect(tempview.display)
self.sensor.humidity.connect(humview.display)
self.sensor.read_time.connect(self.show_time)
QLCDNumber.display()插槽可以连接到发出数字的任何信号,因此我们直接连接我们的温度和湿度信号。然而,发送到read_time信号的QTime对象将需要一些解析,因此我们将其连接到一个名为show_time()的MainWindow方法。
该方法看起来像以下代码块:
def show_time(self, qtime):
self.statusBar().showMessage(
f'Read at {qtime.toString("HH:mm:ss")}')
这个方法将利用MainWindow对象方便的statusBar()方法,在状态区域显示最后一次温度读数的时间。
因此,这解决了我们的 GUI 输出显示;现在我们需要一种方法来触发传感器定期进行读数。我们可以采取的一种方法是创建一个定时器来定期执行它:
self.timer = qtc.QTimer(interval=(60000))
self.timer.timeout.connect(self.sensor.take_reading)
self.timer.start()
在这种情况下,这个定时器将每分钟调用sensor.take_reading(),确保我们的读数定期更新。
我们还可以在界面中添加QPushButton,以便用户可以随时获取新的读数:
readbutton = qtw.QPushButton('Read Now')
widget.layout().addRow(readbutton)
readbutton.clicked.connect(self.sensor.take_reading)
这相当简单,因为我们只需要将按钮的clicked信号连接到传感器的take_reading插槽。但是硬件控制呢?我们如何实现外部触发温度读数?我们将在下一节中探讨这个问题。
添加硬件按钮
从传感器读取值可能是有用的,但更有用的是能够响应电路中发生的事件并作出相应的行动。为了演示这个过程,我们将在电路中添加一个硬件按钮,并监视它的状态,以便我们可以在按下按钮时进行温度和湿度读数。
扩展电路
首先,关闭树莓派的电源,让我们向电路中添加一些组件,如下图所示:

在这里,我们基本上添加了一个按钮和一个电阻。按钮需要连接到树莓派上的引脚 8 的一侧,而电阻连接到地面的另一侧。为了保持布线整洁,我们还利用了面包板侧面的公共地和公共电压导轨,尽管这是可选的(如果您愿意,您可以直接将东西连接到树莓派上的适当 GND 和 5V 引脚)。
在入门套件中经常找到的按钮有四个连接器,每侧两个开关。确保您的连接在按钮被按下之前不连接。如果您发现即使没有按下按钮,它们也总是连接在一起,那么您可能需要将按钮在电路中旋转 90 度。
在这个电路中,按钮在被按下时将简单地将我们的 GPIO 引脚连接到地面,这将允许我们检测按钮按下。当我们编写软件时,我们将更详细地了解它是如何工作的。
实现按钮驱动程序
在脚本的顶部开始一个新的类,作为我们按钮的驱动程序:
class HWButton(qtc.QObject):
button_press = qtc.pyqtSignal()
再次,我们使用QObject,以便我们可以发出 Qt 信号,当我们检测到按钮被按下时,我们将这样做。
现在,让我们编写构造函数,如下所示:
def __init__(self, pin):
super().__init__()
self.pin = pin
GPIO.setup(pin, GPIO.IN, pull_up_down=GPIO.PUD_UP)
在调用super().__init__()之后,我们的__init__()方法的第一件事是通过将GPIO.IN常量传递给setup()函数来将我们的按钮的 GPIO 引脚配置为输入引脚。
我们在这里传递的pull_up_down值非常重要。由于我们连接电路的方式,当按钮被按下时,引脚将连接到地面。但是当按钮没有被按下时会发生什么?嗯,在这种情况下,它处于浮动状态,其中输入将是不可预测的。为了在按钮没有被按下时保持引脚处于可预测的状态,pull_up_down参数将导致在没有其他连接时将其拉到HIGH或LOW。在我们的情况下,我们希望它被拉到HIGH,因为我们的按钮将把它拉到LOW;传递GPIO.PUD_UP常量将实现这一点。
这也可以以相反的方式工作;例如,我们可以将按钮的另一侧连接到 5V,然后在setup()函数中将pull_up_down设置为GPIO.PUD_DOWN。
现在,我们需要弄清楚如何检测按钮何时被按下,以便我们可以发出信号。
这项任务的一个简单方法是轮询。轮询简单地意味着我们将定期检查按钮,并在上次检查时发生变化时发出信号。
为此,我们首先需要创建一个实例变量来保存按钮的上一个已知状态:
self.pressed = GPIO.input(self.pin) == GPIO.LOW
我们可以通过调用GPIO.input()函数并传递引脚号来检查按钮的当前状态。此函数将返回HIGH或LOW,指示引脚是否为 5V 或地面。如果引脚为LOW,那么意味着按钮被按下。我们将将结果保存到self.pressed。
接下来,我们将编写一个方法来检查按钮状态的变化:
def check(self):
pressed = GPIO.input(self.pin) == GPIO.LOW
if pressed != self.pressed:
if pressed:
self.button_press.emit()
self.pressed = pressed
这个检查方法将采取以下步骤:
-
首先,它将比较
input()的输出与LOW常量,以查看按钮是否被按下 -
然后,我们比较按钮的当前状态与保存的状态,以查看按钮的状态是否发生了变化
-
如果有,我们需要检查状态的变化是按下还是释放
-
如果是按下(
pressed为True),那么我们发出信号 -
无论哪种情况,我们都会使用新状态更新
self.pressed
现在,剩下的就是定期调用这个方法来轮询变化;在__init__()中,我们可以使用定时器来做到这一点,如下所示:
self.timer = qtc.QTimer(interval=50, timeout=self.check)
self.timer.start()
在这里,我们创建了一个定时器,每 50 毫秒超时一次,当这样做时调用self.check()。这应该足够频繁,以至于可以捕捉到人类可以执行的最快的按钮按下。
轮询效果很好,但使用GPIO库的add_event_detect()函数有一种更干净的方法来做到这一点:
# Comment out timer code
#self.timer = qtc.QTimer(interval=50, timeout=self.check)
#self.timer.start()
GPIO.add_event_detect(
self.pin,
GPIO.RISING,
callback=self.on_event_detect)
add_event_detect()函数将在另一个线程中开始监视引脚,以侦听RISING事件或FALLING事件,并在检测到此类事件时调用配置的callback方法。
在这种情况下,我们只需调用以下实例方法:
def on_event_detect(self, *args):
self.button_press.emit()
我们可以直接将我们的emit()方法作为回调传递,但是add_event_detect()将使用引脚号调用回调函数作为参数,而emit()将不接受。
使用add_event_detect()的缺点是它引入了另一个线程,使用 Python 的threading库,这可能会导致与 PyQt 事件循环的微妙问题。轮询是一个完全可行的替代方案,可以避免这种复杂性。
这两种方法都适用于我们的简单脚本,所以让我们回到MainWindow.__init__()来为我们的按钮添加支持:
self.hwbutton = HWButton(8)
self.hwbutton.button_press.connect(self.sensor.take_reading)
我们所需要做的就是创建一个HWButton类的实例,使用正确的引脚号,并将其button_press信号连接到传感器的take_reading()插槽。
现在,如果您在树莓派上启动所有内容,当您按下按钮时,您应该能够看到更新。
总结
树莓派是一项令人兴奋的技术,不仅因为其小巧、低成本和低资源使用率,而且因为它使得将编程世界与真实电路的连接变得简单和易于访问,这是以前没有的。在本章中,您学会了如何配置树莓派来运行 PyQt 应用程序。您还学会了如何使用 PyQt 和 Python 控制电路,以及电路如何控制软件中的操作。
在下一章中,我们将使用QtWebEngineWidgets将全球网络引入我们的 PyQt 应用程序,这是一个完整的基于 Chromium 的浏览器,内置在 Qt Widget 中。我们将构建一个功能齐全的浏览器,并了解网络引擎库的各个方面。
问题
尝试回答以下问题,以测试您从本章中获得的知识:
-
您刚刚购买了一个预装了 Raspbian 的树莓派来运行您的 PyQt5 应用程序。当您尝试运行您的应用程序时,您遇到了一个错误,试图导入
QtNetworkAuth,这是您的应用程序所依赖的。问题可能是什么? -
您已经为传统扫描仪设备编写了一个 PyQt 前端。您的代码通过一个名为
scanutil.exe的专有驱动程序实用程序与扫描仪通信。它目前正在运行在 Windows 10 PC 上,但您的雇主希望通过将其移动到树莓派来节省成本。这是一个好主意吗? -
您已经获得了一个新的传感器,并希望尝试将其与树莓派一起使用。它有三个连接,标有 Vcc、GND 和 Data。您将如何将其连接到树莓派?您还需要更多信息吗?
-
您正在点亮连接到最左边的第四个 GPIO 引脚的 LED。这段代码有什么问题?
GPIO.setmode(GPIO.BCM)
GPIO.setup(8, GPIO.OUT)
GPIO.output(8, 1)
- 您正在调暗连接到 GPIO 引脚 12 的 LED。以下代码有效吗?
GPIO.setmode(GPIO.BOARD)
GPIO.setup(12, GPIO.OUT)
GPIO.output(12, 0.5)
- 您有一个运动传感器,当检测到运动时,数据引脚会变为
HIGH。它连接到引脚8。以下是您的驱动代码:
class MotionSensor(qtc.QObject):
detection = qtc.pyqtSignal()
def __init__(self):
super().__init__()
GPIO.setmode(GPIO.BOARD)
GPIO.setup(8, GPIO.IN)
self.state = GPIO.input(8)
def check(self):
state = GPIO.input(8)
if state and state != self.state:
detection.emit()
self.state = state
您的主窗口类创建了一个MotionSensor对象,并将其detection信号连接到回调方法。然而,没有检测到任何东西。缺少了什么?
- 以创造性的方式将本章中的两个电路结合起来;例如,您可以创建一个根据湿度和温度变化颜色的灯。
进一步阅读
有关更多信息,请参阅以下内容:
-
有关树莓派的
GPIO库的更多文档可以在sourceforge.net/p/raspberry-gpio-python/wiki/Home/找到 -
Packt 提供了许多详细介绍树莓派的书籍;您可以在
www.packtpub.com/books/content/raspberry-pi找到更多信息。
第十六章:使用 QtWebEngine 进行 Web 浏览
在第八章中,使用 QtNetwork 进行网络操作,您学习了如何使用套接字和 HTTP 与网络系统进行交互。然而,现代网络远不止于网络协议;它是建立在 HTML、JavaScript 和 CSS 组合之上的编程平台,有效地使用它需要一个完整的 Web 浏览器。幸运的是,Qt 为我们提供了QtWebEngineWidgets库,为我们的应用程序提供了一个完整的 Web 浏览器小部件。
在本章中,我们将学习如何在以下部分中使用 Qt 访问 Web:
-
使用
QWebEngineView构建基本浏览器 -
高级
QtWebEngine用法
技术要求
除了本书中使用的基本 PyQt5 设置之外,您还需要确保已从 PyPI 安装了PyQtWebEngine软件包。您可以使用以下命令执行此操作:
$ pip install --user PyQtWebEngine
您可能还想要本章的示例代码,可以从github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter16获取。
查看以下视频,了解代码的运行情况:bit.ly/2M5xFtD
使用QWebEngineView构建基本浏览器
从QtWebEngineWidgets中使用的主要类是QWebEngineView类;这个类在QWidget对象中提供了一个几乎完整的基于 Chromium 的浏览器。Chromium 是支持许多 Google Chrome、最新版本的 Microsoft Edge 和许多其他浏览器的开源项目。
Qt 还有一个基于Webkit渲染引擎的已弃用的QtWebKit模块,用于 Safari、Opera 和一些旧版浏览器。QtWebKit和QtWebEngineWidgets之间的 API 和渲染行为存在一些显着差异,后者更适合新项目。
在本节中,我们将看到使用QtWebEngineWidgets构建一个简单的 Web 浏览器,将 Web 内容包含在 Qt 应用程序中是多么容易。
使用 QWebEngineView 小部件
我们需要从第四章中复制我们的 Qt 应用程序模板,使用 QMainWindow 构建应用程序,并将其命名为simple_browser.py;我们将开发一个带有选项卡和历史记录显示的基本浏览器。
我们首先导入QtWebEngineWidgets库,如下所示:
from PyQt5 import QtWebEngineWidgets as qtwe
请注意,还有一个QtWebEngine模块,但它是用于与Qt 建模语言(QML)声明性框架一起使用的,而不是本书涵盖的 Qt 小部件框架。QtWebEngineWidgets包含基于小部件的浏览器。
在我们的MainWindow类构造函数中,我们将通过定义导航工具栏来启动 GUI:
navigation = self.addToolBar('Navigation')
style = self.style()
self.back = navigation.addAction('Back')
self.back.setIcon(style.standardIcon(style.SP_ArrowBack))
self.forward = navigation.addAction('Forward')
self.forward.setIcon(style.standardIcon(style.SP_ArrowForward))
self.reload = navigation.addAction('Reload')
self.reload.setIcon(style.standardIcon(style.SP_BrowserReload))
self.stop = navigation.addAction('Stop')
self.stop.setIcon(style.standardIcon(style.SP_BrowserStop))
self.urlbar = qtw.QLineEdit()
navigation.addWidget(self.urlbar)
self.go = navigation.addAction('Go')
self.go.setIcon(style.standardIcon(style.SP_DialogOkButton))
在这里,我们为标准浏览器操作定义了工具栏按钮,以及用于 URL 栏的QLineEdit对象。我们还从默认样式中提取了这些操作的图标,就像我们在第四章的添加工具栏部分中所做的那样,使用 QMainWindow 构建应用程序。
现在我们将创建一个QWebEngineView对象:
webview = qtwe.QWebEngineView()
self.setCentralWidget(webview)
QWebEngineView对象是一个(大多数情况下,正如您将看到的那样)功能齐全且交互式的 Web 小部件,能够检索和呈现 HTML、CSS、JavaScript、图像和其他标准 Web 内容。
要在视图中加载 URL,我们将QUrl传递给其load()方法:
webview.load(qtc.QUrl('http://www.alandmoore.com'))
这将提示 Web 视图下载并呈现页面,就像普通的 Web 浏览器一样。
当然,尽管该网站很好,我们希望能够浏览其他网站,因此我们将添加以下连接:
self.go.triggered.connect(lambda: webview.load(
qtc.QUrl(self.urlbar.text())))
在这里,我们将我们的go操作连接到一个lambda函数,该函数检索 URL 栏的文本,将其包装在QUrl对象中,并将其发送到 Web 视图。如果此时运行脚本,您应该能够在栏中输入 URL,点击 Go,然后像任何其他浏览器一样浏览 Web。
QWebView具有所有常见浏览器导航操作的插槽,我们可以将其连接到我们的导航栏:
self.back.triggered.connect(webview.back)
self.forward.triggered.connect(webview.forward)
self.reload.triggered.connect(webview.reload)
self.stop.triggered.connect(webview.stop)
通过连接这些信号,我们的脚本已经在成为一个完全功能的网络浏览体验的路上。但是,我们目前仅限于单个浏览器窗口;我们想要选项卡,因此让我们在以下部分实现它。
允许多个窗口和选项卡
在MainWindow.__init__()中,删除或注释掉刚刚添加的 Web 视图代码(返回到创建QWebEngineView对象)。我们将将该功能移动到一个方法中,以便我们可以在选项卡界面中创建多个 Web 视图。我们将按照以下方式进行:
- 首先,我们将用
QTabWidget对象替换我们的QWebEngineView对象作为我们的中央小部件:
self.tabs = qtw.QTabWidget(
tabsClosable=True, movable=True)
self.tabs.tabCloseRequested.connect(self.tabs.removeTab)
self.new = qtw.QPushButton('New')
self.tabs.setCornerWidget(self.new)
self.setCentralWidget(self.tabs)
此选项卡小部件将具有可移动和可关闭的选项卡,并在左上角有一个新按钮用于添加新选项卡。
- 要添加一个带有 Web 视图的新选项卡,我们将创建一个
add_tab()方法:
def add_tab(self, *args):
webview = qtwe.QWebEngineView()
tab_index = self.tabs.addTab(webview, 'New Tab')
该方法首先创建一个 Web 视图小部件,并将其添加到选项卡小部件的新选项卡中。
- 现在我们有了我们的 Web 视图对象,我们需要连接一些信号:
webview.urlChanged.connect(
lambda x: self.tabs.setTabText(tab_index, x.toString()))
webview.urlChanged.connect(
lambda x: self.urlbar.setText(x.toString()))
QWebEngineView对象的urlChanged信号在将新 URL 加载到视图中时发出,并将新 URL 作为QUrl对象发送。我们将此信号连接到一个lambda函数,该函数将选项卡标题文本设置为 URL,以及另一个函数,该函数设置 URL 栏的内容。这将使 URL 栏与用户在网页中使用超链接导航时与浏览器保持同步,而不是直接使用 URL 栏。
- 然后,我们可以使用其
setHtml()方法向我们的 Web 视图对象添加默认内容:
webview.setHtml(
'<h1>Blank Tab</h1><p>It is a blank tab!</p>',
qtc.QUrl('about:blank'))
这将使浏览器窗口的内容成为我们提供给它的任何 HTML 字符串。如果我们还传递一个QUrl对象,它将被用作当前 URL(例如发布到urlChanged信号)。
- 为了启用导航,我们需要将我们的工具栏操作连接到浏览器小部件。由于我们的浏览器有一个全局工具栏,我们不能直接将这些连接到 Web 视图小部件。我们需要将它们连接到将信号传递到当前活动 Web 视图的插槽的方法。首先创建回调方法如下:
def on_back(self):
self.tabs.currentWidget().back()
def on_forward(self):
self.tabs.currentWidget().forward()
def on_reload(self):
self.tabs.currentWidget().reload()
def on_stop(self):
self.tabs.currentWidget().stop()
def on_go(self):
self.tabs.currentWidget().load(
qtc.QUrl(self.urlbar.text()))
这些方法本质上与单窗格浏览器使用的方法相同,但有一个关键变化——它们使用选项卡窗口小部件的currentWidget()方法来检索当前可见选项卡的QWebEngineView对象,然后在该 Web 视图上调用导航方法。
- 在
__init__()中连接以下方法:
self.back.triggered.connect(self.on_back)
self.forward.triggered.connect(self.on_forward)
self.reload.triggered.connect(self.on_reload)
self.stop.triggered.connect(self.on_stop)
self.go.triggered.connect(self.on_go)
self.urlbar.returnPressed.connect(self.on_go)
self.new.clicked.connect(self.add_tab)
为了方便和键盘友好性,我们还将 URL 栏的returnPressed信号连接到on_go()方法。我们还将我们的新按钮连接到add_tab()方法。
现在尝试浏览器,您应该能够添加多个选项卡并在每个选项卡中独立浏览。
为弹出窗口添加选项卡
目前,我们的脚本存在问题,即如果您Ctrl +单击超链接,或打开配置为打开新窗口的链接,将不会发生任何事情。默认情况下,QWebEngineView无法打开新标签页或窗口。为了启用此功能,我们必须使用一个函数覆盖其createWindow()方法,该函数创建并返回一个新的QWebEngineView对象。
我们可以通过更新我们的add_tab()方法来轻松实现这一点:
webview.createWindow = self.add_tab
return webview
我们不会对QWebEngineView进行子类化以覆盖该方法,而是将我们的MainWindow.add_tab()方法分配给其createWindow()方法。然后,我们只需要确保在方法结束时返回创建的 Web 视图对象。
请注意,我们不需要在createWindow()方法中加载 URL;我们只需要适当地创建视图并将其添加到 GUI 中。Qt 将负责在我们返回的 Web 视图对象中执行浏览所需的操作。
现在,当您尝试浏览器时,您应该发现*Ctrl * +单击会打开一个带有请求链接的新选项卡。
高级 QtWebEngine 用法
虽然我们已经实现了一个基本的、可用的浏览器,但它还有很多不足之处。在本节中,我们将通过修复用户体验中的一些痛点和实现有用的工具,如历史和文本搜索,来探索QtWebEngineWidgets的一些更高级的功能。
共享配置文件
虽然我们可以在浏览器中查看多个选项卡,但它们在与经过身份验证的网站一起工作时存在一个小问题。访问任何您拥有登录帐户的网站;登录,然后*Ctrl *+单击站点内的链接以在新选项卡中打开它。您会发现您在新选项卡中没有经过身份验证。对于使用多个窗口或选项卡来实现其用户界面的网站来说,这可能是一个真正的问题。我们希望身份验证和其他会话数据是整个浏览器范围的,所以让我们来解决这个问题。
会话信息存储在一个由QWebEngineProfile对象表示的配置文件中。这个对象是为每个QWebEngineWidget对象自动生成的,但我们可以用自己的对象来覆盖它。
首先在MainWindow.__init__()中创建一个:
self.profile = qtwe.QWebEngineProfile()
当我们在add_tab()中创建新的 web 视图时,我们需要将这个配置文件对象与每个新的 web 视图关联起来。然而,配置文件实际上并不是 web 视图的属性;它们是 web 页面对象的属性。页面由QWebEnginePage对象表示,可以被视为 web 视图的模型。每个 web 视图都会生成自己的page对象,它充当了浏览引擎的接口。
为了覆盖 web 视图的配置文件,我们需要创建一个page对象,覆盖它的配置文件,然后用我们的新页面覆盖 web 视图的页面,就像这样:
page = qtwe.QWebEnginePage(self.profile)
webview.setPage(page)
配置文件必须作为参数传递给QWebEnginePage构造函数,因为没有访问函数可以在之后设置它。一旦我们有了一个使用我们的配置文件的新的QWebEnginePage对象,我们就可以调用QWebEngineView.setPage()将其分配给我们的 web 视图。
现在当您测试浏览器时,您的身份验证状态应该在所有选项卡中保持不变。
查看历史记录
每个QWebEngineView对象都管理着自己的浏览历史,我们可以访问它来允许用户查看和导航已访问的 URL。
为了构建这个功能,让我们创建一个界面,显示当前选项卡的历史记录,并允许用户点击历史记录项进行导航:
- 首先在
MainView.__init__()中创建一个历史记录的停靠窗口小部件:
history_dock = qtw.QDockWidget('History')
self.addDockWidget(qtc.Qt.RightDockWidgetArea, history_dock)
self.history_list = qtw.QListWidget()
history_dock.setWidget(self.history_list)
历史记录停靠窗口只包含一个QListWidget对象,它将显示当前选定选项卡的历史记录。
- 由于我们需要在用户切换选项卡时刷新这个列表,将选项卡小部件的
currentChanged信号连接到一个可以执行此操作的回调函数:
self.tabs.currentChanged.connect(self.update_history)
update_history()方法如下:
def update_history(self, *args):
self.history_list.clear()
webview = self.tabs.currentWidget()
if webview:
history = webview.history()
for history_item in reversed(history.items()):
list_item = qtw.QListWidgetItem()
list_item.setData(
qtc.Qt.DisplayRole, history_item.url())
self.history_list.addItem(list_item)
首先,我们清除列表小部件并检索当前活动选项卡的 web 视图。如果 web 视图存在(如果所有选项卡都关闭了,它可能不存在),我们使用history()方法检索 web 视图的历史记录。
这个历史记录是一个QWebEngineHistory对象;这个对象是 web 页面对象的属性,用来跟踪浏览历史。当在 web 视图上调用back()和forward()槽时,会查询这个对象,找到正确的 URL 进行加载。历史对象的items()方法返回一个QWebEngineHistoryItem对象的列表,详细描述了 web 视图对象的整个浏览历史。
我们的update_history方法遍历这个列表,并为历史中的每个项目添加一个新的QListWidgetItem对象。请注意,我们使用列表小部件项的setData()方法,而不是setText(),因为它允许我们直接存储QUrl对象,而不必将其转换为字符串(QListWidget将自动将 URL 转换为字符串进行显示,使用 URL 的toString()方法)。
- 除了在切换选项卡时调用此方法之外,我们还需要在 web 视图导航到新页面时调用它,以便在用户浏览时保持历史记录的最新状态。为了实现这一点,在
add_tab()方法中为每个新生成的 web 视图添加一个连接:
webview.urlChanged.connect(self.update_history)
- 为了完成我们的历史功能,我们希望能够双击历史中的项目并在当前打开的标签中导航到其 URL。我们将首先创建一个
MainWindow方法来进行导航:
def navigate_history(self, item):
qurl = item.data(qtc.Qt.DisplayRole)
if self.tabs.currentWidget():
self.tabs.currentWidget().load(qurl)
我们将使用QListWidget中的itemDoubleClicked信号来触发此方法,该方法将QListItemWidget对象传递给其回调。我们只需通过调用其data()访问器方法从列表项中检索 URL,然后将 URL 传递给当前可见的 web 视图。
- 现在,回到
__init__(),我们将连接信号到回调如下:
self.history_list.itemDoubleClicked.connect(
self.navigate_history)
这完成了我们的历史功能;启动浏览器,您会发现可以使用停靠中的历史列表查看和导航。
Web 设置
QtWebEngine浏览器,就像它所基于的 Chromium 浏览器一样,提供了一个非常可定制的网络体验;我们可以编辑许多设置来实现各种安全、功能或外观的更改。
为此,我们需要访问以下默认的settings对象:
settings = qtwe.QWebEngineSettings.defaultSettings()
defaultSettings()静态方法返回的QWebEngineSettings对象是一个全局对象,由程序中所有的 web 视图引用。我们不必(也不能)在更改后将其显式分配给 web 视图。一旦我们检索到它,我们可以以各种方式配置它,我们的设置将被所有我们创建的 web 视图所尊重。
例如,让我们稍微改变字体:
# The web needs more drama:
settings.setFontFamily(
qtwe.QWebEngineSettings.SansSerifFont, 'Impact')
在这种情况下,我们将所有无衬线字体的默认字体系列设置为Impact。除了设置字体系列,我们还可以设置默认的fontSize对象和defaultTextEncoding对象。
settings对象还具有许多属性,这些属性是布尔开关,我们可以切换;例如:
settings.setAttribute(
qtwe.QWebEngineSettings.PluginsEnabled, True)
在这个例子中,我们启用了 Pepper API 插件的使用,例如 Chrome 的 Flash 实现。我们可以切换 29 个属性,以下是其中的一些示例:
| 属性 | 默认 | 描述 |
|---|---|---|
JavascriptEnabled |
True |
允许运行 JavaScript 代码。 |
JavascriptCanOpenWindows |
True |
允许 JavaScript 打开新的弹出窗口。 |
| 全屏支持已启用 | 假 | 允许浏览器全屏显示。 |
AllowRunningInsecureContent |
False |
允许在 HTTPS 页面上运行 HTTP 内容。 |
PlaybackRequiresUserGesture |
False |
在用户与页面交互之前不要播放媒体。 |
要更改单个 web 视图的设置,请使用page().settings()访问其QWebEnginSettings对象。
构建文本搜索功能
到目前为止,我们已经在我们的 web 视图小部件中加载和显示了内容,但实际内容并没有做太多事情。我们通过QtWebEngine获得的强大功能之一是能够通过将我们自己的 JavaScript 代码注入到这些页面中来操纵网页的内容。为了看看这是如何工作的,我们将使用以下说明来开发一个文本搜索功能,该功能将突出显示搜索词的所有实例:
- 我们将首先在
MainWindow.__init__()中添加 GUI 组件:
find_dock = qtw.QDockWidget('Search')
self.addDockWidget(qtc.Qt.BottomDockWidgetArea, find_dock)
self.find_text = qtw.QLineEdit()
find_dock.setWidget(self.find_text)
self.find_text.textChanged.connect(self.text_search)
搜索小部件只是一个嵌入在停靠窗口中的QLineEdit对象。我们已经将textChanged信号连接到一个回调函数,该函数将执行搜索。
- 为了实现搜索功能,我们需要编写一些 JavaScript 代码,以便为我们定位和突出显示搜索词的所有实例。我们可以将此代码添加为字符串,但为了清晰起见,让我们将其写在一个单独的文件中;打开一个名为
finder.js的文件,并添加以下代码:
function highlight_selection(){
let tag = document.createElement('found');
tag.style.backgroundColor = 'lightgreen';
window.getSelection().getRangeAt(0).surroundContents(tag);}
function highlight_term(term){
let found_tags = document.getElementsByTagName("found");
while (found_tags.length > 0){
found_tags[0].outerHTML = found_tags[0].innerHTML;}
while (window.find(term)){highlight_selection();}
while (window.find(term, false, true)){highlight_selection();}}
这本书不是一本 JavaScript 文本,所以我们不会深入讨论这段代码的工作原理,只是总结一下正在发生的事情:
-
highlight_term()函数接受一个字符串作为搜索词。它首先清理任何 HTML<found>标签;这不是一个真正的标签——这是我们为了这个功能而发明的,这样它就不会与任何真正的标签冲突。
-
然后该函数通过文档向前和向后搜索搜索词的实例。
-
当它找到一个时,它会用背景颜色设置为浅绿色的
<found>标签包裹它。 -
回到
MainWindow.__init__(),我们将读取这个文件并将其保存为一个实例变量:
with open('finder.js', 'r') as fh:
self.finder_js = fh.read()
- 现在,让我们在
MainWindow下实现我们的搜索回调方法:
def text_search(self, term):
term = term.replace('"', '')
page = self.tabs.currentWidget().page()
page.runJavaScript(self.finder_js)
js = f'highlight_term("{term}");'
page.runJavaScript(js)
在我们当前的网页视图中运行 JavaScript 代码,我们需要获取它的QWebEnginePage对象的引用。然后我们可以调用页面的runJavaScript()方法。这个方法简单地接受一个包含 JavaScript 代码的字符串,并在网页上执行它。
- 在这种情况下,我们首先运行我们的
finder.js文件的内容来设置函数,然后我们调用highlight_term()函数并插入搜索词。作为一个快速而粗糙的安全措施,我们还从搜索词中剥离了所有双引号;因此,它不能用于注入任意的 JavaScript。如果你现在运行应用程序,你应该能够在页面上搜索字符串,就像这样:

这个方法效果还不错,但是每次更新搜索词时重新定义这些函数并不是很有效,是吗?如果我们只定义这些函数一次,然后在我们导航到的任何页面上都可以访问它们,那就太好了。
- 这可以使用
QWebEnginePage对象的scripts属性来完成。这个属性存储了一个QWebEngineScript对象的集合,其中包含了每次加载新页面时要运行的 JavaScript 片段。通过将我们的脚本添加到这个集合中,我们可以确保我们的函数定义仅在每次页面加载时运行,而不是每次我们尝试搜索时都运行。为了使这个工作,我们将从MainWindow.__init__()开始,定义一个QWebEngineScript对象:
self.finder_script = qtwe.QWebEngineScript()
self.finder_script.setSourceCode(self.finder_js)
- 集合中的每个脚本都在 256 个worlds中的一个中运行,这些 worlds 是隔离的 JavaScript 上下文。为了在后续调用中访问我们的函数,我们需要确保我们的
script对象通过设置它的worldId属性在主 world 中执行:
self.finder_script.setWorldId(qtwe.QWebEngineScript.MainWorld)
QWebEngineScript.MainWorld是一个常量,指向主 JavaScript 执行上下文。如果我们没有设置这个,我们的脚本会运行,但函数会在它们自己的 world 中运行,并且在网页上下文中不可用于搜索。
- 现在我们有了我们的
script对象,我们需要将它添加到网页对象中。这应该在MainWindow.add_tab()中完成,当我们创建我们的page对象时:
page.scripts().insert(self.finder_script)
- 最后,我们可以缩短
text_search()方法:
def text_search(self, term):
page = self.tabs.currentWidget().page()
js = f'highlight_term("{term}");'
page.runJavaScript(js)
除了运行脚本,我们还可以从脚本中检索数据并将其发送到我们的 Python 代码中的回调方法。
例如,我们可以对我们的 JavaScript 进行以下更改,以从我们的函数中返回匹配项的数量:
function highlight_term(term){
//cleanup
let found_tags = document.getElementsByTagName("found");
while (found_tags.length > 0){
found_tags[0].outerHTML = found_tags[0].innerHTML;}
let matches = 0
//search forward and backward
while (window.find(term)){
highlight_selection();
matches++;
}
while (window.find(term, false, true)){
highlight_selection();
matches++;
}
return matches;
}
这个值不是从runJavaScript()返回的,因为 JavaScript 代码是异步执行的。
要访问返回值,我们需要将一个 Python 可调用的引用作为runJavaScript()的第二个参数传递;Qt 将调用该方法,并传递被调用代码的返回值:
def text_search(self, term):
term = term.replace('"', '')
page = self.tabs.currentWidget().page()
js = f'highlight_term("{term}");'
page.runJavaScript(js, self.match_count)
在这里,我们将 JavaScript 调用的输出传递给一个名为match_count()的方法,它看起来像下面的代码片段:
def match_count(self, count):
if count:
self.statusBar().showMessage(f'{count} matches ')
else:
self.statusBar().clearMessage()
在这种情况下,如果找到任何匹配项,我们将显示一个状态栏消息。再次尝试浏览器,你会看到消息应该成功传达。
总结
在本章中,我们探讨了QtWebEngineWidgets为我们提供的可能性。您实现了一个简单的浏览器,然后学习了如何利用浏览历史、配置文件共享、多个选项卡和常见设置等功能。您还学会了如何向网页注入任意 JavaScript 并检索这些调用的结果。
在下一章中,您将学习如何准备您的代码以进行共享、分发和部署。我们将讨论如何正确地构建项目目录结构,如何使用官方工具分发 Python 代码,以及如何使用 PyInstaller 为各种平台创建独立的可执行文件。
问题
尝试这些问题来测试您从本章中学到的知识:
- 以下代码给出了一个属性错误;出了什么问题?
from PyQt5 import QtWebEngine as qtwe
w = qtwe.QWebEngineView()
- 以下代码应该将
UrlBar类与QWebEngineView连接起来,以便在按下return/Enter键时加载输入的 URL。但是它不起作用;出了什么问题?
class UrlBar(qtw.QLineEdit):
url_request = qtc.pyqtSignal(str)
def __init__(self):
super().__init__()
self.returnPressed.connect(self.request)
def request(self):
self.url_request.emit(self.text())
mywebview = qtwe.QWebEngineView()
myurlbar = UrlBar()
myurlbar.url_request(mywebview.load)
- 以下代码的结果是什么?
class WebView(qtwe.QWebEngineView):
def createWindow(self, _):
return self
-
查看
doc.qt.io/qt-5/qwebengineview.html中的QWebEngineView文档。您将如何在浏览器中实现缩放功能? -
正如其名称所示,
QWebEngineView代表了模型-视图架构中的视图部分。在这个设计中,哪个类代表了模型? -
给定一个名为
webview的QWebEngineView对象,编写代码来确定webview上是否启用了 JavaScript。 -
您在我们的浏览器示例中看到
runJavaScript()可以将整数值传递给回调函数。编写一个简单的演示脚本来测试可以返回哪些其他类型的 JavaScript 对象,以及它们在 Python 代码中的表现方式。
进一步阅读
有关更多信息,请参考以下内容:
-
QuteBrowser是一个使用
QtWebEngineWidgets用 Python 编写的开源网络浏览器。您可以在github.com/qutebrowser/qutebrowser找到其源代码。 -
ADMBrowser是一个基于
QtWebEngineWidgets的浏览器,由本书的作者创建,并可用于信息亭系统。您可以在github.com/alandmoore/admbrowser找到它。 -
QtWebChannel是一个功能,允许您的 PyQt 应用程序与 Web 内容之间进行更强大的通信。您可以在doc.qt.io/qt-5/qtwebchannel-index.html开始探索这一高级功能。
第十七章:准备软件进行分发
到目前为止,在这本书中,我们主要关注的是编写一个可工作的代码。我们的项目都是单个脚本,最多有几个支持数据文件。然而,完成一个项目并不仅仅是编写代码;我们还需要我们的项目能够轻松分发,这样我们就可以与其他人分享(或出售)它们。
在本章中,我们将探讨为分享和分发准备我们的代码的方法。
我们将涵盖以下主题:
-
项目结构
-
使用
setuptools进行分发 -
使用 PyInstaller 编译
技术要求
在本章中,您将需要我们在整本书中使用的基本 Python 和 PyQt 设置。您还需要使用以下命令从 PyPI 获取setuptools、wheel和pyinstaller库:
$ pip install --user setuptools wheel pyinstaller
Windows 用户将需要从www.7-zip.org/安装 7-Zip 程序,以便他们可以使用tar.gz文件,所有平台的用户都应该从upx.github.io/安装 UPX 实用程序。
最后,您将希望从存储库中获取示例代码github.com/PacktPublishing/Mastering-GUI-Programming-with-Python/tree/master/Chapter17。
查看以下视频,看看代码是如何运行的:bit.ly/2M5xH4J
项目结构
到目前为止,在这本书中,我们一直将每个示例项目中的所有 Python 代码放入单个文件中。然而,现实世界的 Python 项目受益于更好的组织。虽然没有关于如何构建 Python 项目的官方标准,但我们可以应用一些约定和一般概念来构建我们的项目结构,这不仅可以保持组织,还可以鼓励其他人贡献我们的代码。
为了看到这是如何工作的,我们将在 PyQt 中创建一个简单的井字棋游戏,然后花费本章的其余部分来准备分发。
井字棋
我们的井字棋游戏由三个类组成:
-
管理游戏逻辑的引擎类
-
提供游戏状态视图和进行游戏的方法的棋盘类
-
将其他两个类合并到 GUI 中的主窗口类
打开第四章中的应用程序模板的新副本,使用 QMainWindow 构建应用程序,并将其命名为ttt-qt.py。现在让我们创建这些类。
引擎类
我们的游戏引擎对象的主要责任是跟踪游戏并检查是否有赢家或游戏是否为平局。玩家将简单地由'X'和'O'字符串表示,棋盘将被建模为九个项目的列表,这些项目将是玩家或None。
它开始如下:
class TicTacToeEngine(qtc.QObject):
winning_sets = [
{0, 1, 2}, {3, 4, 5}, {6, 7, 8},
{0, 3, 6}, {1, 4, 7}, {2, 5, 8},
{0, 4, 8}, {2, 4, 6}
]
players = ('X', 'O')
game_won = qtc.pyqtSignal(str)
game_draw = qtc.pyqtSignal()
def __init__(self):
super().__init__()
self.board = [None] * 9
self.current_player = self.players[0]
winning_sets列表包含set对象,其中包含构成胜利的每个棋盘索引的组合。我们将使用该列表来检查玩家是否获胜。我们还定义了信号,当游戏获胜或平局时发出(即,所有方块都填满了,没有人获胜)。构造函数填充了棋盘列表,并将当前玩家设置为X。
我们将需要一个方法来在每轮之后更新当前玩家,看起来是这样的:
def next_player(self):
self.current_player = self.players[
not self.players.index(self.current_player)]
接下来,我们将添加一个标记方块的方法:
def mark_square(self, square):
if any([
not isinstance(square, int),
not (0 <= square < len(self.board)),
self.board[square] is not None
]):
return False
self.board[square] = self.current_player
self.next_player()
return True
此方法首先检查给定方块是否应该被标记的任何原因,如果有原因则返回False;否则,我们标记方块,切换到下一个玩家,并返回True。
这个类中的最后一个方法将检查棋盘的状态,看看是否有赢家或平局:
def check_board(self):
for player in self.players:
plays = {
index for index, value in enumerate(self.board)
if value == player
}
for win in self.winning_sets:
if not win - plays: # player has a winning combo
self.game_won.emit(player)
return
if None not in self.board:
self.game_draw.emit()
该方法使用一些集合操作来检查每个玩家当前标记的方块是否与获胜组合列表匹配。如果找到任何匹配项,将发出game_won信号并返回。如果还没有人赢,我们还要检查是否有任何未标记的方块;如果没有,游戏就是平局。如果这两种情况都不成立,我们什么也不做。
棋盘类
对于棋盘 GUI,我们将使用一个QGraphicsScene对象,就像我们在第十二章中为坦克游戏所做的那样,使用 QPainter 创建 2D 图形。
我们将从一些类变量开始:
class TTTBoard(qtw.QGraphicsScene):
square_rects = (
qtc.QRectF(5, 5, 190, 190),
qtc.QRectF(205, 5, 190, 190),
qtc.QRectF(405, 5, 190, 190),
qtc.QRectF(5, 205, 190, 190),
qtc.QRectF(205, 205, 190, 190),
qtc.QRectF(405, 205, 190, 190),
qtc.QRectF(5, 405, 190, 190),
qtc.QRectF(205, 405, 190, 190),
qtc.QRectF(405, 405, 190, 190)
)
square_clicked = qtc.pyqtSignal(int)
square_rects元组为棋盘上的九个方块定义了一个QRectF对象,并且每当点击一个方块时会发出一个square_clicked信号;随附的整数将指示点击了哪个方块(0-8)。
以下是=__init__()方法:
def __init__(self):
super().__init__()
self.setSceneRect(0, 0, 600, 600)
self.setBackgroundBrush(qtg.QBrush(qtc.Qt.cyan))
for square in self.square_rects:
self.addRect(square, brush=qtg.QBrush(qtc.Qt.white))
self.mark_pngs = {
'X': qtg.QPixmap('X.png'),
'O': qtg.QPixmap('O.png')
}
self.marks = []
该方法设置了场景大小并绘制了青色背景,然后在square_rects中绘制了每个方块。然后,我们加载了用于标记方块的'X'和'O'图像的QPixmap对象,并创建了一个空列表来跟踪我们标记的QGraphicsSceneItem对象。
接下来,我们将添加一个方法来绘制棋盘的当前状态:
def set_board(self, marks):
for i, square in enumerate(marks):
if square in self.mark_pngs:
mark = self.addPixmap(self.mark_pngs[square])
mark.setPos(self.square_rects[i].topLeft())
self.marks.append(mark)
该方法将接受我们棋盘上的标记列表,并在每个方块中绘制适当的像素项,跟踪创建的QGraphicsSceneItems对象。
现在我们需要一个方法来清空棋盘:
def clear_board(self):
for mark in self.marks:
self.removeItem(mark)
该方法只是遍历保存的像素项并将它们全部删除。
我们需要做的最后一件事是处理鼠标点击:
def mousePressEvent(self, mouse_event):
position = mouse_event.buttonDownScenePos(qtc.Qt.LeftButton)
for square, qrect in enumerate(self.square_rects):
if qrect.contains(position):
self.square_clicked.emit(square)
break
mousePressEvent()方法由QGraphicsScene在用户进行鼠标点击时调用。它包括一个QMouseEvent对象,其中包含有关事件的详细信息,包括鼠标点击的位置。我们可以检查此点击是否在我们的square_rects对象中的任何一个内部,如果是,我们将发出square_clicked信号并退出该方法。
主窗口类
在MainWindow.__init__()中,我们将首先创建一个棋盘和一个QGraphicsView对象来显示它:
self.board = TTTBoard()
self.board_view = qtw.QGraphicsView()
self.board_view.setScene(self.board)
self.setCentralWidget(self.board_view)
现在我们需要创建一个游戏引擎的实例并连接它的信号。为了让我们能够一遍又一遍地开始游戏,我们将为此创建一个单独的方法:
def start_game(self):
self.board.clear_board()
self.game = TicTacToeEngine()
self.game.game_won.connect(self.game_won)
self.game.game_draw.connect(self.game_draw)
该方法清空了棋盘,然后创建了游戏引擎对象的一个实例,将引擎的信号连接到MainWindow方法以处理两种游戏结束的情况。
回到__init__(),我们将调用这个方法来自动设置第一局游戏:
self.start_game()
接下来,我们需要启用玩家输入。我们需要一个方法,该方法将尝试在引擎中标记方块,然后在标记成功时检查棋盘是否获胜或平局:
def try_mark(self, square):
if self.game.mark_square(square):
self.board.set_board(self.game.board)
self.game.check_board()
该方法可以连接到棋盘的square_clicked信号;在__init__()中,添加以下代码:
self.board.square_clicked.connect(self.try_mark)
最后,我们需要处理两种游戏结束的情况:
def game_won(self, player):
"""Display the winner and start a new game"""
qtw.QMessageBox.information(
None, 'Game Won', f'Player {player} Won!')
self.start_game()
def game_draw(self):
"""Display the lack of a winner and start a new game"""
qtw.QMessageBox.information(
None, 'Game Over', 'Game Over. Nobody Won...')
self.start_game()
在这两种情况下,我们只会在QMessageBox中显示适当的消息,然后重新开始游戏。
这完成了我们的游戏。花点时间运行游戏,并确保您了解它在正常工作时的响应(也许找个朋友和您一起玩几局;如果您的朋友很年轻或者不太聪明,这会有所帮助)。
现在我们有了一个可用的游戏,是时候准备将其分发了。我们首先要做的是以一种使我们更容易维护和扩展的方式构建我们的项目,以及让其他 Python 程序员合作。
模块式结构
作为程序员,我们倾向于将应用程序和库视为两个非常不同的东西,但实际上,结构良好的应用程序与库并没有太大的不同。库只是一组现成的类和函数。我们的应用程序主要也只是类定义;它只是碰巧在最后有几行代码,使其能够作为应用程序运行。当我们以这种方式看待事物时,将我们的应用程序结构化为 Python 库模块是很有道理的。为了做到这一点,我们将把我们的单个 Python 文件转换为一个包含多个文件的目录,每个文件包含一个单独的代码单元。
第一步是考虑我们项目的名称;现在,那个名称是ttt-qt.py。当你开始着手一个项目时,想出一个快速简短的名称是很常见的,但这不一定是你要坚持的名称。在这种情况下,我们的名称相当神秘,由于连字符而不能作为 Python 模块名称。相反,让我们称之为qtictactoe,这是一个更明确的名称,避免了连字符。
首先,创建一个名为QTicTacToe的新目录;这将是我们的项目根目录。项目根目录是所有项目文件都将放置在其中的目录。
在该目录下,我们将创建一个名为qtictactoe的第二个目录;这将是我们的模块目录,其中将包含大部分我们的源代码。
模块的结构
为了开始我们的模块,我们将首先添加我们三个类的代码。我们将把每个类放在一个单独的文件中;这并不是严格必要的,但这将帮助我们保持代码解耦,并使得更容易找到我们想要编辑的类。
因此,在qtictactoe下,创建三个文件:
-
engine.py将保存我们的游戏引擎类。复制TicTacToeEngine的定义以及它所使用的必要的PyQt5导入语句。在这种情况下,你只需要QtCore。 -
board.py将保存TTTBoard类。也复制那段代码以及完整的PyQt5导入语句。 -
最后,
mainwindow.py将保存MainWindow类。复制该类的代码以及PyQt5导入。
mainwindow.py还需要从其他文件中获取TicTacToeEngine和TTTBoard类的访问权限。为了提供这种访问权限,我们需要使用相对导入。相对导入是一种从同一模块中导入子模块的方法。
在mainwindow.py的顶部添加这行:
from .engine import TicTacToeEngine
from .board import TTTBoard
在导入中的点表示这是一个相对导入,并且特指当前容器模块(在本例中是qtictactoe)。通过使用这样的相对导入,我们可以确保我们从自己的项目中导入这些模块,而不是从用户系统上的其他 Python 库中导入。
我们需要添加到我们模块的下一个代码是使其实际运行的代码。这通常是我们放在if __name__ == '__main__'块下的代码。
在模块中,我们将把它放在一个名为__main__.py的文件中:
import sys
from PyQt5.QtWidgets import QApplication
from .mainwindow import MainWindow
def main():
app = QApplication(sys.argv)
mainwindow = MainWindow()
sys.exit(app.exec())
if __name__ == '__main__':
main()
__main__.py文件在 Python 模块中有着特殊的用途。每当我们使用-m开关运行我们的模块时,它就会被执行,就像这样:
$ python3 -m qtictactoe
实质上,__main__.py是 Python 脚本中if __name__ == '__main__':块的模块等价物。
请注意,我们已经将我们的三行主要代码放在一个名为main()的函数中。当我们讨论setuptools的使用时,这样做的原因将变得明显。
我们需要在模块内创建的最后一个文件是一个名为__init__.py的空文件。Python 模块的__init__.py文件类似于 Python 类的__init__()方法。每当导入模块时,它都会被执行,并且其命名空间中的任何内容都被视为模块的根命名空间。但在这种情况下,我们将它留空。这可能看起来毫无意义,但如果没有这个文件,我们将要使用的许多工具将不会将这个 Python 文件夹识别为一个实际的模块。
此时,您的目录结构应该是这样的:
QTicTacToe/
├── qtictactoe
├── board.py
├── engine.py
├── __init__.py
├── __main__.py
└── mainwindow.py
现在,我们可以使用python3 -m qtictactoe来执行我们的程序,但对大多数用户来说,这并不是非常直观。让我们通过创建一个明显的文件来帮助一下执行应用程序。
在项目根目录下(模块外部),创建一个名为run.py的文件:
from qtictactoe.__main__ import main
main()
这个文件的唯一目的是从我们的模块中加载main()函数并执行它。现在,您可以执行python run.py,您会发现它可以正常启动。但是,有一个问题——当您点击一个方块时,什么也不会发生。那是因为我们的图像文件丢失了。我们需要处理这些问题。
非 Python 文件
在 PyQt 程序中,处理诸如我们的X和O图像之类的文件的最佳方法是使用pyrcc5工具生成一个资源文件,然后像任何其他 Python 文件一样将其添加到您的模块中(我们在第六章中学习了这个)。然而,在这种情况下,我们将保留我们的图像作为 PNG 文件,以便我们可以探索处理非 Python 文件的选项。
关于这些文件应该放在项目目录的何处,目前还没有达成一致的意见,但是由于这些图像是TTTBoard类的一个必需组件,将它们放在我们的模块内是有意义的。为了组织起见,将它们放在一个名为images的目录中。
现在,您的目录结构应该是这样的:
QTicTacToe/
├── qtictactoe
│ ├── board.py
│ ├── engine.py
│ ├── images
│ │ ├── O.png
│ │ └── X.png
│ ├── __init__.py
│ ├── __main__.py
│ └── mainwindow.py
└── run.py
我们编写TTTBoard的方式是,您可以看到每个图像都是使用相对文件路径加载的。在 Python 中,相对路径始终相对于当前工作目录,也就是用户启动脚本的目录。不幸的是,这是一个相当脆弱的设计,因为我们无法控制这个目录。我们也不能硬编码绝对文件路径,因为我们不知道我们的应用程序可能存储在用户系统的何处(请参阅我们在第六章中对这个问题的讨论,Styling Qt Applications,Using Qt Resource files部分)。
在 PyQt 应用程序中解决这个问题的理想方式是使用 Qt 资源文件;然而,我们将尝试一种不同的方法,只是为了说明在这种情况下如何解决这个问题。
为了解决这个问题,我们需要修改TTTBoard加载图像的方式,使其相对于我们模块的位置,而不是用户的当前工作目录。这将需要我们使用 Python 标准库中的os.path模块,因此在board.py的顶部添加这个:
from os import path
现在,在__init__()中,我们将修改加载图像的行:
directory = path.dirname(__file__)
self.mark_pngs = {
'X': qtg.QPixmap(path.join(directory, 'images', 'X.png')),
'O': qtg.QPixmap(path.join(directory, 'images', 'O.png'))
}
__file__变量是一个内置变量,它始终包含当前文件(在本例中是board.py)的绝对路径。使用path.dirname,我们可以找到包含此文件的目录。然后,我们可以使用path.join来组装一个路径,以便在同一目录下的名为images的文件夹中查找文件。
如果您现在运行程序,您应该会发现它完美地运行,就像以前一样。不过,我们还没有完成。
文档和元数据
工作和组织良好的代码是我们项目的一个很好的开始;但是,如果您希望其他人使用或贡献到您的项目,您需要解决一些他们可能会遇到的问题。例如,他们需要知道如何安装程序,它的先决条件是什么,或者使用或分发的法律条款是什么。
为了回答这些问题,我们将包括一系列标准文件和目录:LICENSE文件,README文件,docs目录和requirements.txt文件。
许可文件
当您分享代码时,非常重要的是明确说明其他人可以或不可以对该代码做什么。在大多数国家,创建作品的人自动成为该作品的版权持有人;这意味着您对您的作品的复制行为行使控制。如果您希望其他人为您创建的作品做出贡献或使用它们,您需要授予他们一个许可证。
管理您项目的许可证通常以项目根目录中的一个名为LICENSE的纯文本文件提供。在我们的示例代码中,我们已经包含了这样一个文件,其中包含了MIT 许可证的副本。MIT 许可证是一种宽松的开源许可证,基本上允许任何人对代码做任何事情,只要他们保留我们的版权声明。它还声明我们对因某人使用我们的代码而发生的任何可怕事件不负责。
这个文件有时被称为COPYING,也可能有一个名为txt的文件扩展名。
您当然可以在许可证中加入任何条件;但是,对于 PyQt 应用程序,您需要确保您的许可证与 PyQt 的通用公共许可证(GPL)GNU 和 Qt 的较宽松的通用公共许可证(LGPL)GNU 的条款兼容。如果您打算发布商业或限制性许可的 PyQt 软件,请记住来自第一章,PyQt 入门,您需要从 Qt 公司和 Riverbank Computing 购买商业许可证。
对于开源项目,Python 社区强烈建议您坚持使用 MIT、BSD、GPL 或 LGPL 等知名许可证。可以在开放源代码倡议组织的网站opensource.org/licenses上找到已知的开源许可证列表。您还可以参考choosealicense.com,这是一个提供有关选择最符合您意图的许可证的指导的网站。
README 文件
README文件是软件分发中最古老的传统之一。追溯到 20 世纪 70 年代中期,这个纯文本文件通常旨在在用户安装或运行软件之前向程序的用户传达最基本的一组指令和信息。
虽然没有关于README文件应包含什么的标准,但用户希望找到某些内容;其中一些包括以下内容:
-
软件的名称和主页
-
软件的作者(带有联系信息)
-
软件的简短描述
-
基本使用说明,包括任何命令行开关或参数
-
报告错误或为项目做出贡献的说明
-
已知错误的列表
-
诸如特定平台问题或说明之类的注释
无论您在文件中包含什么,您都应该力求简洁和有组织。为了方便一些组织,许多现代软件项目在编写README文件时使用标记语言;这使我们可以使用诸如标题、项目列表甚至表格等元素。
在 Python 项目中,首选的标记语言是重新结构化文本(RST)。这种语言是docutils项目的一部分,为 Python 提供文档实用程序。
当我们创建qtictactoe的README.rst文件时,我们将简要介绍 RST。从一个标题开始:
============
QTicTacToe
============
顶部行周围的等号表示它是一个标题;在这种情况下,我们只是使用了我们项目的名称。
接下来,我们将为项目的基本信息创建几个部分;我们通过简单地用符号划线下一行文本来指示部分标题,就像这样:
Authors
=======
By Alan D Moore - https://www.alandmoore.com
About
=====
This is the classic game of **tic-tac-toe**, also known as noughts and crosses. Battle your opponent in a desperate race to get three in a line.
用于下划线部分标题的符号必须是以下之一:
= - ` : ' " ~ ^ _ * + # < >
我们使用它们的顺序并不重要,因为 RST 解释器会假定第一个使用的符号作为表示顶级标题的下划线,下一个类型的符号是第二级标题,依此类推。在这种情况下,我们首先使用等号,所以无论我们在整个文档中使用它,它都会指示一个一级标题。
注意单词tac-tac-toe周围的双星号,这表示粗体文本。RST 还可以表示下划线、斜体和类似的排版样式。
例如,我们可以使用反引号来指示等宽代码文本:
Usage
=====
Simply run `python qtictactoe.py` from within the project folder.
- Players take turns clicking the mouse on the playing field to mark squares.
- When one player gets 3 in a row, they win.
- If the board is filled with nobody getting in a row, the game is a draw.
这个例子还展示了一个项目列表:每行前面都加了一个破折号和空格。我们也可以使用+或*符号,并通过缩进创建子项目。
让我们用一些关于贡献的信息和一些注释来完成我们的README.rst文件:
Contributing
============
Submit bugs and patches to the
`public git repository <http://git.example.com/qtictactoe>`_.
Notes
=====
A strange game. The only winning move is not to play.
*—Joshua the AI, WarGames*
Contributing部分显示如何创建超链接:将超链接文本放在反引号内,URL 放在尖括号内,并在关闭反引号后添加下划线。Notes部分演示了块引用,只需将该行缩进四个空格即可。
虽然我们的文件作为文本是完全可读的,但是许多流行的代码共享网站会将 RST 和其他标记语言转换为 HTML。例如,在 GitHub 上,这个文件将在浏览器中显示如下:

这个简单的README.rst文件对于我们的小应用已经足够了;随着应用的增长,它将需要进一步扩展以记录添加的功能、贡献者、社区政策等。这就是为什么我们更喜欢使用 RST 这样的纯文本格式,也是为什么我们将其作为项目仓库的一部分;它应该随着代码一起更新。
RST 语法的快速参考可以在docutils.sourceforge.net/docs/user/rst/quickref.html找到。
文档目录
虽然这个README文件对于QTicTacToe已经足够了,但是一个更复杂的程序或库可能需要更健壮的文档。放置这样的文档的标准位置是在docs目录中。这个目录应该直接位于我们的项目根目录下,并且可以包含任何类型的额外文档,包括以下内容:
-
示例配置文件
-
用户手册
-
API 文档
-
数据库图表
由于我们的程序不需要这些东西,所以我们不需要在这个项目中添加docs目录。
requirements.txt文件
Python 程序通常需要标准库之外的包才能运行,用户需要知道安装什么才能让你的项目运行。你可以(而且可能应该)将这些信息放在README文件中,但你也应该将它放在requirements.txt中。
requirements.txt的格式是每行一个库,如下所示:
PyQt5
PyQt5-sip
这个文件中的库名称应该与 PyPI 中使用的名称相匹配,因为这个文件可以被pip用来安装项目所需的所有库,如下所示:
$ pip install --user -r requirements.txt
我们实际上不需要指定PyQt5-sip,因为它是PyQt5的依赖项,会自动安装。我们在这里添加它是为了展示如何指定多个库。
如果需要特定版本的库,也可以使用版本说明符进行说明:
PyQt5 >= 5.12
PyQt5-sip == 4.19.4
在这种情况下,我们指定了PyQt5版本5.12或更高,并且只有PyQt5-sip的4.19.4版本。
关于requirements.txt文件的更多信息可以在pip.readthedocs.io/en/1.1/requirements.html找到。
其他文件
这些是项目文档和元数据的基本要素,但在某些情况下,你可能会发现一些额外的文件有用:
-
TODO.txt:需要处理的错误或缺失功能的简要列表 -
CHANGELOG.txt:主要项目变更和发布历史的日志 -
tests:包含模块单元测试的目录 -
scripts:包含对你的模块有用但不是其一部分的 Python 或 shell 脚本的目录 -
Makefile:一些项目受益于脚本化的构建过程,对此,像make这样的实用工具可能会有所帮助;其他选择包括 CMake、SCons 或 Waf
不过,此时你的项目已经准备好上传到你喜欢的源代码共享站点。在下一节中,我们将看看如何为 PyPI 做好准备。
使用 setuptools 进行分发
在本书的许多部分,你已经使用pip安装了 Python 包。你可能知道pip会从 PyPI 下载这些包,并将它们安装到你的系统、Python 虚拟环境或用户环境中。你可能不知道的是,用于创建和安装这些包的工具称为setuptools,如果我们想要为 PyPI 或个人使用制作自己的包,它就可以随时为我们提供。
尽管setuptools是官方推荐的用于创建 Python 包的工具,但它并不是标准库的一部分。但是,如果你在安装过程中选择包括pip,它通常会包含在大多数操作系统的默认发行版中。如果由于某种原因你没有安装setuptools,请参阅setuptools.readthedocs.io/en/latest/上的文档,了解如何在你的平台上安装它。
使用setuptools的主要任务是编写一个setup.py脚本。在本节中,我们将学习如何编写和使用我们的setup.py脚本来生成可分发的包。
编写 setuptools 配置
setup.py的主要目的是使用关键字参数调用setuptools.setup()函数,这将定义我们项目的元数据以及我们的项目应该如何打包和安装。
因此,我们将首先导入该函数:
from setuptools import setup
setup(
# Arguments here
)
setup.py中的剩余代码将作为setup()的关键字参数。让我们来看看这些参数的不同类别。
基本元数据参数
最简单的参数涉及项目的基本元数据:
name='QTicTacToe',
version='1.0',
author='Alan D Moore',
author_email='alandmoore@example.com',
description='The classic game of noughts and crosses',
url="http://qtictactoe.example.com",
license='MIT',
在这里,我们已经描述了包名称、版本、简短描述、项目 URL 和许可证,以及作者的姓名和电子邮件。这些信息将被写入包元数据,并被 PyPI 等网站使用,以构建项目的个人资料页面。
例如,看一下 PyQt5 的 PyPI 页面:

在页面的左侧,你会看到一个指向项目主页的链接,作者(带有超链接的电子邮件地址)和许可证。在顶部,你会看到项目名称和版本,以及项目的简短描述。所有这些数据都可以从项目的setup.py脚本中提取出来。
如果你计划向 PyPI 提交一个包,请参阅www.python.org/dev/peps/pep-0440/上的 PEP 440,了解你的版本号应该如何指定。
你在这个页面的主体中看到的长文本来自long_description参数。我们可以直接将一个长字符串放入这个参数,但既然我们已经有了一个很好的README.rst文件,为什么不在这里使用呢?由于setup.py是一个 Python 脚本,我们可以直接读取文件的内容,就像这样:
long_description=open('README.rst', 'r').read(),
在这里使用 RST 的一个优点是,PyPI(以及许多其他代码共享站点)将自动将你的标记渲染成格式良好的 HTML。
如果我们希望使我们的项目更容易搜索,我们可以包含一串空格分隔的关键字:
keywords='game multiplayer example pyqt5',
在这种情况下,搜索 PyPI 中的“multiplayer pyqt5”的人应该能找到我们的项目。
最后,你可以包含一个与项目相关的 URL 字典:
project_urls={
'Author Website': 'https://www.alandmoore.com',
'Publisher Website': 'https://packtpub.com',
'Source Code': 'https://git.example.com/qtictactoe'
},
格式为{'label': 'URL'};你可能会在这里包括项目的 bug 跟踪器、文档站点、Wiki 页面或源代码库,特别是如果其中任何一个与主页 URL 不同的话。
包和依赖关系
除了建立基本元数据外,setup()还需要有关需要包含的实际代码或需要在系统上存在的环境的信息,以便执行此包。
这里我们需要处理的第一个关键字是packages,它定义了我们项目中需要包含的模块:
packages=['qtictactoe', 'qtictactoe.images'],
请注意,我们需要明确包括qtictactoe模块和qtictactoe.images模块;即使images目录位于qtictactoe下,也不会自动包含它。
如果我们有很多子模块,并且不想明确列出它们,setuptools也提供了自动解决方案:
from setuptools import setup, find_package
setup(
#...
packages=find_packages(),
)
如果要使用find_packages,请确保每个子模块都有一个__init__.py文件,以便setuputils可以将其识别为模块。在这种情况下,您需要在images文件夹中添加一个__init__.py文件,否则它将被忽略。
这两种方法都有优点和缺点;手动方法更费力,但find_packages有时可能在某些情况下无法识别库。
我们还需要指定此项目运行所需的外部库,例如PyQt5。可以使用install_requires关键字来完成:
install_requires=['PyQt5'],
这个关键字接受一个包名列表,这些包必须被安装才能安装程序。当使用pip安装程序时,它将使用此列表自动安装所有依赖包。您应该在此列表中包括任何不属于标准库的内容。
就像requirements.txt文件一样,我们甚至可以明确指定每个依赖项所需的版本号:
install_requires=['PyQt5 >= 5.12'],
在这种情况下,pip将确保安装大于或等于 5.12 的 PyQt5 版本。如果未指定版本,pip将安装 PyPI 提供的最新版本。
在某些情况下,我们可能还需要指定特定版本的 Python;例如,我们的项目使用 f-strings,这是 Python 3.6 或更高版本才有的功能。我们可以使用python_requires关键字来指定:
python_requires='>=3.6',
我们还可以为可选功能指定依赖项;例如,如果我们为qtictactoe添加了一个可选的网络游戏功能,需要requests库,我们可以这样指定:
extras_require={
"NetworkPlay": ["requests"]
}
extras_require关键字接受一个特性名称(可以是任何您想要的内容)到包名称列表的映射。这些模块在安装您的包时不会自动安装,但其他模块可以依赖于这些子特性。例如,另一个模块可以指定对我们项目的NetworkPlay额外关键字的依赖,如下所示:
install_requires=['QTicTacToe[NetworkPlay]'],
这将触发一系列依赖关系,导致安装requests库。
非 Python 文件
默认情况下,setuptools将打包在我们项目中找到的 Python 文件,其他文件类型将被忽略。然而,在几乎任何项目中,都会有一些非 Python 文件需要包含在我们的分发包中。这些文件通常分为两类:一类是 Python 模块的一部分,比如我们的 PNG 文件,另一类是不是,比如README文件。
要包含不是 Python 包的文件,我们需要创建一个名为MANIFEST.in的文件。此文件包含项目根目录下文件路径的include指令。例如,如果我们想要包含我们的文档文件,我们的文件应该如下所示:
include README.rst
include LICENSE
include requirements.txt
include docs/*
格式很简单:单词include后跟文件名、路径或匹配一组文件的模式。所有路径都是相对于项目根目录的。
要包含 Python 包的文件,我们有两种选择。
一种方法是将它们包含在MANIFEST.in文件中,然后在setup.py中将include_package_data设置为True:
include_package_data=True,
包含非 Python 文件的另一种方法是在setup.py中使用package_data关键字参数:
package_data={
'qtictactoe.images': ['*.png'],
'': ['*.txt', '*.rst']
},
这个参数接受一个dict对象,其中每个条目都是一个模块路径和一个匹配包含的文件的模式列表。在这种情况下,我们希望包括在qtictactoe.images模块中找到的所有 PNG 文件,以及包中任何位置的 TXT 或 RST 文件。请记住,这个参数只适用于模块目录中的文件(即qtictactoe下的文件)。如果我们想要包括诸如README.rst或run.py之类的文件,那些应该放在MANIFEST.in文件中。
您可以使用任一方法来包含文件,但您不能在同一个项目中同时使用两种方法;如果启用了include_package_data,则将忽略package_data指令。
可执行文件
我们倾向于将 PyPI 视为安装 Python 库的工具;事实上,它也很适合安装应用程序,并且许多 Python 应用程序都可以从中获取。即使你正在创建一个库,你的库很可能会随附可执行的实用程序,比如 PyQt5 附带的pyrcc5和pyuic5实用程序。
为了满足这些需求,setuputils 为我们提供了一种指定特定函数或方法作为控制台脚本的方法;当安装包时,它将创建一个简单的可执行文件,在从命令行执行时将调用该函数或方法。
这是使用entry_points关键字指定的:
entry_points={
'console_scripts': [
'qtictactoe = qtictactoe.__main__:main'
]
}
entry_points字典还有其他用途,但我们最关心的是'console_scripts'键。这个键指向一个字符串列表,指定我们想要设置为命令行脚本的函数。这些字符串的格式如下:
'command_name = module.submodule:function'
您可以添加尽可能多的控制台脚本;它们只需要指向包中可以直接运行的函数或方法。请注意,您必须在这里指定一个实际的可调用对象;您不能只是指向一个要运行的 Python 文件。这就是为什么我们将所有执行代码放在__main__.py中的main()函数下的原因。
setuptools包含许多其他指令,用于处理不太常见的情况;有关完整列表,请参阅setuptools.readthedocs.io/en/latest/setuptools.html。
源码分发
现在setup.py已经准备就绪,我们可以使用它来实际创建我们的软件包分发。软件包分发有两种基本类型:源码和构建。在本节中,我们将讨论如何使用源码分发。
源码分发是我们构建项目所需的所有源代码和额外文件的捆绑包。它包括setup.py文件,并且对于以跨平台方式分发您的项目非常有用。
创建源码分发
要构建源码分发,打开项目根目录中的命令提示符,并输入以下命令:
$ python3 setup.py sdist
这将创建一些目录和许多文件:
-
ProjectName.egg-info目录(在我们的情况下是QTicTacToe.egg-info目录)将包含从我们的setup.py参数生成的几个元数据文件。 -
dist目录将包含包含我们分发的tar.gz存档文件。我们的文件名为QTicTacToe-1.0.tar.gz。
花几分钟时间来探索QTicTacToe.egg-info的内容;您会看到我们在setup()中指定的所有信息以某种形式存在。这个目录也包含在源码分发中。
此外,花点时间打开tar.gz文件,看看它包含了什么;你会看到我们在MANIFEST.in中指定的所有文件,以及qtictactoe模块和来自QTicTacToe.egg-info的所有文件。基本上,这是我们项目目录的完整副本。
Linux 和 macOS 原生支持tar.gz存档;在 Windows 上,您可以使用免费的 7-Zip 实用程序。有关 7-Zip 的信息,请参阅技术要求部分。
安装源码分发
源分发可以使用pip进行安装;为了在一个干净的环境中看到这是如何工作的,我们将在 Python 的虚拟环境中安装我们的库。虚拟环境是创建一个隔离的 Python 堆栈的一种方式,您可以在其中独立于系统 Python 安装添加或删除库。
在控制台窗口中,创建一个新目录,然后将其设置为虚拟环境:
$ mkdir test_env
$ virtualenv -p python3 test_env
virtualenv命令将必要的文件复制到给定目录,以便可以运行 Python,以及一些激活和停用环境的脚本。
要开始使用您的新环境,请运行此命令:
# On Linux and Mac
$ source test_env/bin/activate
# On Windows
$ test_env\Scripts\activate
根据您的平台,您的命令行提示可能会更改以指示您处于虚拟环境中。现在当您运行python或 Python 相关工具,如pip时,它们将在虚拟环境中执行所有操作,而不是在您的系统 Python 中执行。
让我们安装我们的源分发包:
$ pip install QTicTacToe/dist/QTicTacToe-1.0.tar.gz
此命令将导致pip提取我们的源分发并在项目根目录内执行python setup.py install。install指令将下载任何依赖项,构建一个入口点可执行文件,并将代码复制到存储 Python 库的目录中(在我们的虚拟环境的情况下,那将是test_env/lib/python3.7/site-packages/)。请注意,PyQt5的一个新副本被下载;您的虚拟环境中除了 Python 和标准库之外没有安装任何依赖项,因此我们在install_requires中列出的任何依赖项都必须重新安装。
在pip完成后,您应该能够运行qtictactoe命令并成功启动应用程序。该命令存储在test_env/bin中,以防您的操作系统不会自动将虚拟环境目录附加到您的PATH。
要从虚拟环境中删除包,可以运行以下命令:
$ pip uninstall QTicTacToe
这应该清理源代码和所有生成的文件。
构建分发
源分发对开发人员至关重要,但它们通常包含许多对最终用户不必要的元素,例如单元测试或示例代码。除此之外,如果项目包含编译代码(例如用 C 编写的 Python 扩展),那么该代码在目标上使用之前将需要编译。为了解决这个问题,setuptools提供了各种构建分发类型。构建分发提供了一组准备好的文件,只需要将其复制到适当的目录中即可使用。
在本节中,我们将讨论如何使用构建分发。
构建分发的类型
创建构建分发的第一步是确定我们想要的构建分发类型。setuptools库提供了一些不同的构建分发类型,我们可以安装其他库以添加更多选项。
内置类型如下:
-
二进制分发:这是一个
tar.gz文件,就像源分发一样,但与源分发不同,它包含预编译的代码(例如qtictactoe可执行文件),并省略了某些类型的文件(例如测试)。构建分发的内容需要被提取和复制到适当的位置才能运行。 -
Windows 安装程序:这与二进制分发类似,只是它是一个在 Windows 上启动安装向导的可执行文件。向导仅用于将文件复制到适当的位置以供执行或库使用。
-
RPM 软件包管理器(RPM)安装程序:再次,这与二进制分发类似,只是它将代码打包在一个 RPM 文件中。RPM 文件被用于几个 Linux 发行版的软件包管理工具(如 Red Hat、CentOS、Suse、Fedora 等)。
虽然您可能会发现这些分发类型在某些情况下很有用,但它们在 2019 年都有点过时;今天分发 Python 的标准方式是使用wheel 分发。这些是您在 PyPI 上找到的二进制分发包。
让我们来看看如何创建和安装 wheel 包。
创建 wheel 分发
要创建一个 wheel 分发,您首先需要确保从 PyPI 安装了wheel库(请参阅技术要求部分)。之后,setuptools将有一个额外的bdist_wheel选项。
您可以使用以下方法创建您的wheel文件:
$ python3 setup.py bdist_wheel
就像以前一样,这个命令将创建QTicTacToe.egg-info目录,并用包含您项目元数据的文件填充它。它还创建一个build目录,在那里编译文件被分阶段地压缩成wheel文件。
在dist下,我们会找到我们完成的wheel文件。在我们的情况下,它被称为QTicTacToe-1.0-py3-none-any.whl。文件名的格式如下:
-
项目名称(
QTicTacToe)。 -
版本(1.0)。
-
支持的 Python 版本,无论是 2、3 还是
universal(py3)。 -
ABI标签,它表示我们的项目依赖的 Python 二进制接口的特定版本(none)。如果我们已经编译了代码,这将被使用。 -
平台(操作系统和 CPU 架构)。我们的是
any,因为我们没有包含任何特定平台的二进制文件。
二进制分发有三种类型:
-
通用类型只有 Python,并且与 Python 2 或 3 兼容
-
纯 Python类型只有 Python,但与 Python 2 或 Python 3 兼容
-
平台类型包括只在特定平台上运行的已编译代码
正如分发名称所反映的那样,我们的包是纯 Python 类型,因为它不包含已编译的代码,只支持 Python 3。PyQt5 是一个平台包类型的例子,因为它包含为特定平台编译的 Qt 库。
回想一下第十五章,树莓派上的 PyQt,我们无法在树莓派上从 PyPI 安装 PyQt,因为 Linux ARM 平台上没有wheel文件。由于 PyQt5 是一个平台包类型,它只能安装在已生成此wheel文件的平台上。
安装构建的分发
与源分发一样,我们可以使用pip安装我们的 wheel 文件:
$ pip install qtictactoe/dist/QTicTacToe-1.0-py3-none-any.whl
如果您在一个新的虚拟环境中尝试这个,您应该会发现,PyQt5 再次从 PyPI 下载并安装,并且您之后可以使用qtictactoe命令。对于像QTicTacToe这样的程序,对最终用户来说并没有太大的区别,但对于一个包含需要编译的二进制文件的库(如 PyQt5)来说,这使得设置变得相当不那么麻烦。
当然,即使wheel文件也需要目标系统安装了 Python 和pip,并且可以访问互联网和 PyPI。这对许多用户或计算环境来说仍然是一个很大的要求。在下一节中,我们将探讨一个工具,它将允许我们从我们的 Python 项目创建一个独立的可执行文件,而无需任何先决条件。
使用 PyInstaller 编译
成功编写他们的第一个应用程序后,许多 Python 程序员最常见的问题是如何将这段代码制作成可执行文件?不幸的是,对于这个问题并没有一个单一的官方答案。多年来,许多项目已经启动来解决这个任务(例如 Py2Exe、cx_Freeze、Nuitka 和 PyInstaller 等),它们在支持程度、使用简单性和结果一致性方面各有不同。在这些特性方面,目前最好的选择是PyInstaller。
PyInstaller 概述
Python 是一种解释语言;与 C 或 C++编译成机器代码不同,您的 Python 代码(或称为字节码的优化版本)每次运行时都会被 Python 解释器读取和执行。这使得 Python 具有一些使其非常易于使用的特性,但也使得它难以编译成机器代码以提供传统的独立可执行文件。
PyInstaller 通过将您的脚本与 Python 解释器以及运行所需的任何库或二进制文件打包在一起来解决这个问题。这些东西被捆绑在一起,形成一个目录或一个单一文件,以提供一个可分发的应用程序,可以复制到任何系统并执行,即使该系统没有 Python。
要查看这是如何工作的,请确保您已经从 PyPI 安装了 PyInstaller(请参阅技术要求部分),然后让我们为QTicTacToe创建一个可执行文件。
请注意,PyInstaller 创建的应用程序包是特定于平台的,只能在与编译平台兼容的操作系统和 CPU 架构上运行。例如,如果您在 64 位 Linux 上构建 PyInstaller 可执行文件,则它将无法在 32 位 Linux 或 64 位 Windows 上运行。
基本的命令行用法
理论上,使用 PyInstaller 就像打开命令提示符并输入这个命令一样简单:
$ pyinstaller my_python_script.py
实际上,让我们尝试一下,使用第四章中的qt_template.py文件,使用 QMainWindow 构建应用程序;将其复制到一个空目录,并在该目录中运行pyinstaller qt_template.py。
您将在控制台上获得大量输出,并发现生成了几个目录和文件:
-
build和__pycache__目录主要包含在构建过程中生成的中间文件。这些文件在调试过程中可能有所帮助,但它们不是最终产品的一部分。 -
dist目录包含我们的可分发输出。 -
qt_template.spec文件保存了 PyInstaller 生成的配置数据。
默认情况下,PyInstaller 会生成一个包含可执行文件以及运行所需的所有库和数据文件的目录。如果要运行可执行文件,整个目录必须复制到另一台计算机上。
进入这个目录,寻找一个名为qt_template的可执行文件。如果运行它,您应该会看到一个空白的QMainWindow对象弹出。
如果您更喜欢只有一个文件,PyInstaller 可以将这个目录压缩成一个单独的可执行文件,当运行时,它会将自身提取到临时位置并运行主可执行文件。
这可以通过--onefile参数来实现;删除dist和build的内容,然后运行这个命令:
$ pyinstaller --onefile qt_template.py
现在,在dist下,您只会找到一个单一的qt_template可执行文件。再次运行它,您将看到我们的空白QMainWindow。请记住,虽然这种方法更整洁,但它会增加启动时间(因为应用程序需要被提取),并且如果您的应用程序打开本地文件,可能会产生一些复杂性,我们将在下面看到。
如果对代码、环境或构建规范进行了重大更改,最好删除build和dist目录,可能还有.spec文件。
在我们尝试打包QTicTacToe之前,让我们深入了解一下.spec文件。
.spec 文件
.spec文件是一个 Python 语法的config文件,包含了关于我们构建的所有元数据。您可以将其视为 PyInstaller 对setup.py文件的回答。然而,与setup.py不同,.spec文件是自动生成的。这是在我们运行pyinstaller时发生的,使用了从我们的脚本和通过命令行开关传递的数据的组合。我们也可以只生成.spec文件(而不开始构建)使用pyi-makespec命令。
生成后,可以编辑.spec文件,然后将其传递回pyinstaller,以重新构建分发,而无需每次都指定命令行开关:
$ pyinstaller qt_template.spec
要查看我们可能在这个文件中编辑的内容,再次运行pyi-makespec qt_template.py,然后在编辑器中打开qt_template.spec。在文件内部,您将发现正在创建四种对象:Analysis、PYZ、EXE和COLLECT。
Analysis构造函数接收有关我们的脚本、数据文件和库的信息。它使用这些信息来分析项目的依赖关系,并生成五个指向应包含在分发中的文件的路径表。这五个表是:
-
scripts:作为入口点的 Python 文件,将被转换为可执行文件 -
pure:脚本所需的纯 Python 模块 -
binaries:脚本所需的二进制库 -
datas:非 Python 数据文件,如文本文件或图像 -
zipfiles:任何压缩的 Python.egg文件
在我们的文件中,Analysis部分看起来像这样:
a = Analysis(['qt_template.py'],
pathex=['/home/alanm/temp/qt_template'],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False)
您会看到 Python 脚本的名称、路径和许多空关键字参数。这些参数大多对应于输出表,并用于手动补充分析结果,以弥补 PyInstaller 未能检测到的内容,包括以下内容:
-
binaries对应于binaries表。 -
datas对应于datas表。 -
hiddenimports对应于pure表。 -
excludes允许我们排除可能已自动包含但实际上并不需要的模块。 -
hookspath和runtime_hooks允许您手动指定 PyInstaller hooks;hooks 允许您覆盖分析的某些方面。它们通常用于处理棘手的依赖关系。
接下来创建的对象是PYZ对象:
pyz = PYZ(a.pure, a.zipped_data,
cipher=block_cipher)
PYZ 对象表示在分析阶段检测到的所有纯 Python 脚本的压缩存档。我们项目中的所有纯 Python 脚本将被编译为字节码(.pyc)文件并打包到这个存档中。
注意Analysis和PYZ中都有cipher参数;这个参数可以使用 AES256 加密进一步混淆我们的 Python 字节码。虽然它不能完全阻止代码的解密和反编译,但如果您计划商业分发,它可以成为好奇心的有用威慑。要使用此选项,请在创建文件时使用--key参数指定一个加密字符串,如下所示:
$ pyi-makespec --key=n0H4CK1ngPLZ qt_template.py
在PYZ部分之后,生成了一个EXE()对象:
exe = EXE(pyz,
a.scripts,
[],
exclude_binaries=True,
name='qt_template',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
console=True )
EXE 对象表示可执行文件。这里的位置参数表示我们要捆绑到可执行文件中的所有文件表。目前,这只是压缩的 Python 库和主要脚本;如果我们指定了--onefile选项,其他表(binaries、zipfiles和datas)也会包含在这里。
EXE的关键字参数允许我们控制可执行文件的各个方面:
-
name是可执行文件的文件名 -
debug切换可执行文件的调试输出 -
upx切换是否使用UPX压缩可执行文件 -
console切换在 Windows 和 macOS 中以控制台或 GUI 模式运行程序;在 Linux 中,它没有效果
UPX 是一个可用于多个平台的免费可执行文件打包工具,网址为upx.github.io/。如果您已安装它,启用此参数可以使您的可执行文件更小。
该过程的最后阶段是生成一个COLLECT对象:
coll = COLLECT(exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=True,
name='qt_template')
这个对象将所有必要的文件收集到最终的分发目录中。它只在单目录模式下运行,其位置参数包括要包含在目录中的组件。我们还可以覆盖文件夹的其他一些方面,比如是否在二进制文件上使用 UPX 以及输出目录的名称。
现在我们对 PyInstaller 的工作原理有了更多的了解,让我们来打包 QTicTacToe。
为 PyInstaller 准备 QTicTacToe
PyInstaller 在处理单个脚本时非常简单,但是在处理我们的模块式项目安排时该如何工作呢?我们不能将 PyInstaller 指向我们的模块,因为它会返回一个错误;它需要指向一个作为入口点的 Python 脚本,比如我们的run.py文件。
这似乎有效:
$ pyinstaller run.py
然而,生成的分发和可执行文件现在被称为run,这并不太好。您可能会想要将run.py更改为qtictactoe.py;事实上,一些关于 Python 打包的教程建议这种安排(即,将run脚本与主模块具有相同的名称)。
然而,如果您尝试这样做,您可能会发现出现以下错误:
Traceback (most recent call last):
File "qtictactoe/__init__.py", line 3, in <module>
from .mainwindow import MainWindow
ModuleNotFoundError: No module named '__main__.mainwindow'; '__main__' is not a package
[3516] Failed to execute script qtictactoe
因为 Python 模块可以是.py文件或目录,PyInstaller 无法确定哪一个构成了qtictactoe模块,因此两者具有相同的名称将失败。
正确的方法是在创建我们的.spec文件或运行pyinstaller时使用--name开关:
$ pyinstaller --name qtictactoe run.py
# or, to just create the spec file:
# pyi-makespec --name qtictactoe run.py
这将创建qtictactoe.spec并将EXE和COLLECT的name参数设置为qtictactoe,如下所示:
exe = EXE(pyz,
#...
name='qtictactoe',
#...
coll = COLLECT(exe,
#...
name='qtictactoe')
当然,这也可以通过手动编辑.spec文件来完成。
处理非 Python 文件
我们的程序运行了,但我们又回到了'X'和'O'图像不显示的旧问题。这里有两个问题:首先,我们的 PNG 文件没有包含在分发中,其次,即使它们包含在分发中,程序也无法找到它们。
要解决第一个问题,我们必须告诉 PyInstaller 在构建的Analysis阶段将我们的文件包含在datas表中。我们可以在命令行中这样做:
# On Linux and macOS:
$ pyinstaller --name qtictactoe --add-data qtictactoe/images:images run.py
# On Windows:
$ pyinstaller --name qtictactoe --add-data qtictactoe\images;images run.py
--add-data参数接受一个源路径和一个目标路径,两者之间用冒号(在 macOS 和 Linux 上)或分号(在 Windows 上)分隔。源路径是相对于我们正在运行pyinstaller的项目根目录(在本例中为QTicTacToe)的,目标路径是相对于分发根文件夹的。
如果我们不想使用长而复杂的命令行,我们还可以更新qtictactoe.spec文件的Analysis部分:
a = Analysis(['run.py'],
#...
datas=[('qtictactoe/images', 'images')],
在这里,源路径和目标路径只是datas列表中的一个元组。源值也可以是一个模式,例如qtictactoe/images/*.png。如果您使用这些更改运行pyinstaller qtictactoe.spec,您应该会在dist/qtictactoe中找到一个images目录,其中包含我们的 PNG 文件。
这解决了图像的第一个问题,但我们仍然需要解决第二个问题。在使用 setuptools 进行分发部分,我们通过使用__file__内置变量解决了定位 PNG 文件的问题。但是,当您从 PyInstaller 可执行文件运行时,__file__的值不是可执行文件的路径;它实际上是一个临时目录的路径,可执行文件在其中解压缩字节码。此目录的位置也会根据我们是处于单文件模式还是单目录模式而改变。为了解决这个问题,我们需要更新我们的代码以检测程序是否已制作成可执行文件,并且如果是,则使用不同的方法来定位文件。
当我们运行 PyInstaller 可执行文件时,PyInstaller 会向sys模块添加两个属性来帮助我们:
-
sys.frozen属性,其值为True -
sys._MEIPASS属性,存储可执行目录的路径
因此,我们可以将我们的代码在board.py中更新为以下内容:
if getattr(sys, 'frozen', False):
directory = sys._MEIPASS
else: # Not frozen
directory = path.dirname(__file__)
self.mark_pngs = {
'X': qtg.QPixmap(path.join(directory, 'images', 'X.png')),
'O': qtg.QPixmap(path.join(directory, 'images', 'O.png'))
}
现在,在从冻结的 PyInstaller 环境中执行时,我们的代码将能够正确地定位文件。重新运行pyinstaller qtictactoe.spec,您应该会发现X和O图形正确显示。万岁!
如前所述,在 PyQt5 应用程序中更好的解决方案是使用第六章中讨论的 Qt 资源文件,Styling Qt Applications。对于非 PyQt 程序,setuptools库有一个名为pkg_resources的工具可能会有所帮助。
进一步调试
如果您的构建继续出现问题,有几种方法可以获取更多关于正在进行的情况的信息。
首先,确保您的代码作为 Python 脚本正确运行。如果在任何模块文件中存在语法错误或其他代码问题,分发将在没有它们的情况下构建。这些遗漏既不会中止构建,也不会在命令行输出中提到。
确认后,检查构建目录以获取 PyInstaller 正在执行的详细信息。在build/projectname/下,您应该看到一些文件,可以帮助您进行调试,包括这些:
-
warn-projectname.txt:这个文件包含Analysis过程输出的警告。其中一些是无意义的(通常只是无法在您的平台上找到特定于平台的库),但如果库有错误或无法找到,这些问题将在这里记录。 -
.toc文件:这些文件包含构建过程各阶段创建的目录表;例如,Analysis-00.toc显示了Analysis()中找到的目录。您可以检查这些文件,看看项目的依赖项是否被错误地识别或从错误的位置提取。 -
base_library.zip:此存档应包含您的应用程序使用的所有纯 Python 模块的 Python 字节码文件。您可以检查这个文件,看看是否有任何遗漏。
如果您需要更详细的输出,可以使用--log-level开关来增加输出的详细程度到warn-projectname.txt。设置为DEBUG将提供更多细节:
$ pyinstaller --log-level DEBUG my_project.py
更多调试提示可以在pyinstaller.readthedocs.io/en/latest/when-things-go-wrong.html找到。
总结
在本章中,您学会了如何与他人分享您的项目。您学会了使您的项目目录具有最佳布局,以便您可以与其他 Python 编码人员和 Python 工具进行协作。您学会了如何使用setuptools为诸如 PyPI 之类的站点制作可分发的 Python 软件包。最后,您学会了如何使用 PyInstaller 将您的代码转换为可执行文件。
恭喜!您已经完成了这本书。到目前为止,您应该对使用 Python 和 PyQt5 从头开始开发引人入胜的 GUI 应用程序的能力感到自信。从基本的输入表单到高级的网络、数据库和多媒体应用程序,您现在有了创建和分发惊人程序的工具。即使我们涵盖了所有的主题,PyQt 中仍有更多的发现。继续学习,创造伟大的事物!
问题
尝试回答这些问题,以测试您从本章中学到的知识:
-
您已经在一个名为
Scan & Print Tool-box.py的文件中编写了一个 PyQt 应用程序。您想将其转换为模块化组织形式;您应该做出什么改变? -
您的 PyQt5 数据库应用程序有一组包含应用程序使用的查询的
.sql文件。当您的应用程序是与.sql文件在同一目录中的单个脚本时,它可以正常工作,但是现在您已将其转换为模块化组织形式后,无法找到查询。您应该怎么做? -
在将新应用程序上传到代码共享站点之前,您正在编写一个详细的
README.rst文件来记录您的新应用程序。分别应使用哪些字符来下划线标记您的一级、二级和三级标题? -
您正在为您的项目创建一个
setup.py脚本,以便您可以将其上传到 PyPI。您想要包括项目的常见问题解答页面的 URL。您该如何实现这一点? -
您在
setup.py文件中指定了include_package_data=True,但由于某种原因,docs文件夹没有包含在您的分发包中。出了什么问题? -
您运行了
pyinstaller fight_fighter3.py来将您的新游戏打包为可执行文件。然而出了些问题;您在哪里可以找到构建过程的日志? -
尽管名称如此,PyInstaller 实际上不能生成安装程序或包来安装您的应用程序。请为您选择的平台研究一些选项。
进一步阅读
有关更多信息,请参阅以下内容:
-
有关
ReStructuredText标记的教程可以在docutils.sourceforge.net/docs/user/rst/quickstart.html找到。 -
关于设计、构建、文档化和打包 Python GUI 应用程序的更多信息可以在作者的第一本书《Python GUI 编程与 Tkinter》中找到,该书可在 Packt Publications 上获得。
-
如果您有兴趣将软件包发布到 PyPI,请参阅
blog.jetbrains.com/pycharm/2017/05/how-to-publish-your-package-on-pypi/了解发布过程的教程。 -
解决在非 PyQt 代码中包含图像的问题的更好方法是
setuptools提供的pkg_resources工具。您可以在setuptools.readthedocs.io/en/latest/pkg_resources.html上了解更多信息。 -
PyInstaller 的高级用法在 PyInstaller 手册中有详细说明,可在
pyinstaller.readthedocs.io/en/stable/找到。
第十八章:问题的答案
第一章
- Qt 是用 C++编写的,这种语言与 Python 非常不同。这两种语言之间有哪些主要区别?在我们使用 Python 中的 Qt 时,这些区别可能会如何体现?
C++语言的差异以多种方式影响 PyQt,例如:
-
- 它的静态类型和类型安全的函数意味着在某些情况下,PyQt 对可以调用的函数和可以传递的变量相当严格。
-
C++中缺乏内置数据类型意味着 Qt 提供了丰富的数据类型选择,其中许多我们必须在 Python 中使用,因为类型安全。
-
在 C++中常见但在 Python 中很少见的
enum类型在 Qt 中普遍存在。
- GUI 由小部件组成。在计算机上打开一些 GUI 应用程序,尝试识别尽可能多的小部件。
一些例子可能包括以下内容:
-
- 按钮
-
复选框
-
单选按钮
-
标签
-
文本编辑
-
滑块
-
图像区域
-
组合框
- 假设以下程序崩溃。找出原因,并修复它以显示一个窗口:
from PyQt5.QtWidgets import *
app = QWidget()
app.show()
QApplication().exec()
代码应该如下所示:
from PyQt5.QtWidgets import *
app = QApplication([])
window = QWidget()
window.show()
app.exe()
记住在任何QWidget对象之前必须存在一个QApplication()对象,并且它必须用列表作为参数创建。
-
QWidget类有一个名为statusTip的属性。以下哪些最有可能是该属性的访问方法的名称: -
getStatusTip()和setStatusTip()
-
statusTip()和setStatusTip() -
get_statusTip()和change_statusTip()
答案b是正确的。在大多数情况下,property的访问器是property()和setProperty()。
QDate是用于包装日历日期的类。你期望在三个主要的 Qt 模块中的哪一个找到它?
QDate在QtCore中。QtCore保存了与 GUI 不一定相关的数据类型类。
QFont是定义屏幕字体的类。你期望在三个主要的 Qt 模块中的哪一个找到它?
QFont在QtGui中。字体与 GUI 相关,但不是小部件或布局,所以你期望它在QtGui中。
- 你能使用 Qt Designer 重新创建
hello_world.py吗?确保设置windowTitle。
基于QWidget创建一个新项目。然后选择主窗口小部件,并在属性窗格中设置windowTitle。
第二章
- 你如何创建一个全屏的
QWidget,没有窗口框架,并使用沙漏光标?
代码看起来像这样:
widget = QWidget(cursor=qtc.Qt.WaitCursor)
widget.setWindowState(qtc.Qt.WindowFullScreen)
widget.setWindowFlags(qtc.Qt.FramelessWindowHint)
- 假设你被要求为计算机库存数据库设计一个数据输入表单。为以下字段选择最佳的小部件:
-
- 计算机制造:公司购买的八个品牌之一
-
处理器速度:CPU 速度(GHz)
-
内存量:RAM 的数量,以 MB 为单位
-
主机名:计算机的主机名
-
视频制造:视频硬件是 Nvidia、AMD 还是 Intel
-
OEM 许可:计算机是否使用 OEM 许可
这个表格列出了一些可能的答案:
| 字段 | 小部件 | 解释 |
|---|---|---|
| 计算机制造 | QComboBox |
用于在许多值列表中进行选择,组合框是理想的选择 |
| 处理器速度 | QDoubleSpinBox |
十进制值的最佳选择 |
| 内存量 | QSpinBox |
整数值的最佳选择 |
| 主机名 | QLineEdit |
主机名只是一个单行文本字符串 |
| 视频制造 | QComboBox,QRadioButton |
组合框可以工作,但只有三个选择,单选按钮也是一个选项 |
| OEM 许可 | QCheckBox |
QCheckBox是布尔值的一个很好的选择 |
- 数据输入表单包括一个需要
XX-999-9999X格式的库存编号字段,其中X是从A到Z的大写字母,不包括O和I,9是从0到9的数字。你能创建一个验证器类来验证这个输入吗?
查看示例代码中的inventory_validator.py。
- 查看以下计算器表单:

可能使用了哪些布局来创建它?
很可能是一个带有嵌套QGridLayout布局的QVBoxLayout,用于按钮区域,或者是一个使用列跨度的单个QGridLayout布局的前两行。
- 参考前面的计算器表单,当表单被调整大小时,你如何使按钮网格占据任何额外的空间?
在每个小部件上设置sizePolicy属性为QtWidgets.QSizePolicy.Expanding,垂直和水平都是。
- 计算器表单中最顶部的小部件是一个
QLCDNumber小部件。你能找到关于这个小部件的 Qt 文档吗?它有哪些独特的属性?什么时候会用到它?
QLCDNumber的文档在doc.qt.io/qt-5/qlcdnumber.html。它的独特属性是digitCount、intValue、mode、segmentStyle、smallDecimalPoint和value。它适用于显示任何类型的数字,包括八进制、十六进制和二进制。
- 从你的模板代码开始,在代码中构建计算器表单。
在示例代码中查看calculator_form.py。
- 在 Qt Designer 中构建计算器表单。
在示例代码中查看calculator_form.ui。
第三章
- 查看下表,并确定哪些连接实际上可以被建立,哪些会导致错误。你可能需要在文档中查找这些信号和槽的签名:
| # | 信号 | 槽 |
|---|---|---|
| 1 | QPushButton.clicked |
QLineEdit.clear |
| 2 | QComboBox.currentIndexChanged |
QListWidget.scrollToItem |
| 3 | QLineEdit.returnPressed |
QCalendarWidget.setGridVisible |
| 4 | QLineEdit.textChanged |
QTextEdit.scrollToAnchor |
答案如下:
-
- 可以,因为
clicked的布尔参数可以被clear忽略
- 可以,因为
-
不行,因为
currentIndexChanged发送的是int,但scrollToItem期望一个项目和一个滚动提示 -
不行,因为
returnPressed不发送任何参数,而setGridVisible期望一个参数 -
可以,因为
textChanged发送一个字符串,而scrollToAnchor接受它 -
在信号对象上,
emit()方法直到信号被绑定(即连接到槽)之前都不存在。重新编写我们第一个calendar_app.py文件中的CategoryWindow.onSubmit()方法,以防submitted未被绑定的可能性。
我们需要捕获AttributeError,像这样:
def onSubmit(self):
if self.category_entry.text():
try:
self.submitted.emit(self.category_entry.text())
except AttributeError:
pass
self.close()
- 你在 Qt 文档中找到一个对象,它的槽需要
QString作为参数。你能连接你自定义的信号,发送一个 Pythonstr对象吗?
可以,因为 PyQt 会自动在QString和 Python str对象之间转换。
- 你在 Qt 文档中找到一个对象,它的槽需要
QVariant作为参数。你可以发送哪些内置的 Python 类型到这个槽?
任何一个都可以发送。QVariant是一个通用对象容器,可以容纳任何其他类型的对象。
- 你正在尝试创建一个对话框窗口,它需要时间,并在用户完成编辑数值时发出信号。你试图使用自动槽连接,但你的代码没有做任何事情。确定以下代码缺少什么:
class TimeForm(qtw.QWidget):
submitted = qtc.pyqtSignal(qtc.QTime)
def __init__(self):
super().__init__()
self.setLayout(qtw.QHBoxLayout())
self.time_inp = qtw.QTimeEdit(self)
self.layout().addWidget(self.time_inp)
def on_time_inp_editingFinished(self):
self.submitted.emit(self.time_inp.time())
self.destroy()
首先,你忘记调用connectSlotsByName()。另外,你没有设置self.time_inp的对象名称。你的代码应该像这样:
class TimeForm(qtw.QWidget):
submitted = qtc.pyqtSignal(qtc.QTime)
def __init__(self):
super().__init__()
self.setLayout(qtw.QHBoxLayout())
self.time_inp = qtw.QTimeEdit(
self, objectName='time_inp')
self.layout().addWidget(self.time_inp)
qtc.QMetaObject.connectSlotsByName(self)
def on_time_inp_editingFinished(self):
self.submitted.emit(self.time_inp.time())
self.destroy()
- 你在 Qt Designer 中为一个计算器应用程序创建了一个
.ui文件,并尝试在代码中让它工作,但是没有成功。你做错了什么?查看以下源代码:
from calculator_form import Ui_Calculator
class Calculator(qtw.QWidget):
def __init__(self):
self.ui = Ui_Calculator(self)
self.ui.setupGUI(self.ui)
self.show()
这里有四个问题:
-
- 首先,你忘记调用
super().__init__()
- 首先,你忘记调用
-
其次,你将
self传递给Ui_Calculator,它不需要任何参数 -
第三,你调用了
self.ui.setupGUI();应该是self.ui.setupUi() -
最后,你将
self.ui传递给setupUi();你应该传递一个对包含小部件的引用,即self
- 你正在尝试创建一个新的按钮类,当点击按钮时会发出一个整数值;不幸的是,当你点击按钮时什么也不会发生。查看以下代码并尝试让它工作:
class IntegerValueButton(qtw.QPushButton):
clicked = qtc.pyqtSignal(int)
def __init__(self, value, *args, **kwargs):
super().__init__(*args, **kwargs)
self.value = value
self.clicked.connect(
lambda: self.clicked.emit(self.value))
答案是将__init__()的最后一行更改为以下内容:
super().clicked.connect(
lambda: self.clicked.emit(self.value))
因为我们用自己的信号覆盖了内置的clicked属性,self.clicked不再指向按钮被点击时发出的信号。我们必须调用super().clicked来获得对父类clicked信号的引用。
第四章
- 你想要使用
calendar_app.py脚本中的QMainWindow,来自第三章,使用信号和槽处理事件。你会如何进行转换?
最简单的方法是以下:
-
- 将
MainWindow重命名为类似CalendarForm的东西
- 将
-
基于
QMainWindow创建一个新的MainWindow类 -
在
MainWindow内创建一个CalendarForm的实例,并将其设置为中央小部件
- 你正在开发一个应用程序,并已将子菜单名称添加到菜单栏,但尚未填充任何子菜单。你的同事说在他测试时他的桌面上没有出现任何菜单名称。你的代码看起来是正确的;这里可能出了什么问题?
你的同事正在使用一个默认不显示空菜单文件夹的平台(如 macOS)。
- 你正在开发一个代码编辑器,并希望创建一个侧边栏面板与调试器进行交互。哪个
QMainWindow特性对这个任务最合适?
QDockWidget是最合适的,因为它允许你将任何类型的小部件构建到可停靠窗口中。工具栏不是一个好选择,因为它主要设计用于按钮。
- 以下代码无法正常工作;无论点击什么都会继续。为什么它不起作用,你如何修复它?
answer = qtw.QMessageBox.question(
None, 'Continue?', 'Run this program?')
if not answer:
sys.exit()
QMessageBox.question()不返回布尔值;它返回与点击的按钮类型匹配的常量。匹配No按钮的常量的实际整数值是65536,在 Python 中评估为True。代码应该如下所示:
answer = qtw.QMessageBox.question(
None, 'Continue?', 'Run this program?')
if answer == qtw.QMessageBox.No:
sys.exit()
- 你正在通过子类化
QDialog来构建一个自定义对话框。你需要将对话框中输入的信息传递回主窗口对象。以下哪种方法不起作用?
-
- 传入一个可变对象,并使用对话框的
accept()方法来改变它的值。
- 传入一个可变对象,并使用对话框的
- 覆盖对象的
accept()方法,并使其返回输入值的dict。
-
- 覆盖对话框的
accepted信号,使其传递输入值的dict。将此信号连接到主窗口类中的回调。
- 覆盖对话框的
答案a和c都可以。答案b不行,因为accept的返回值在调用exec()时对话框没有返回。exec()只返回一个布尔值,指示对话框是被接受还是被拒绝。
- 你正在 Linux 上开发一个名为 SuperPhoto 的照片编辑器。你已经编写了代码并保存了用户设置,但是在
~/.config/中找不到SuperPhoto.conf。查看代码并确定出了什么问题:
settings = qtc.QSettings()
settings.setValue('config_file', 'SuperPhoto.conf')
settings.setValue('default_color', QColor('black'))
settings.sync()
QSettings使用的配置文件(或在 Windows 上的注册表键)由传递给构造函数的公司名称和应用程序名称确定。代码应该如下所示:
settings = qtc.QSettings('My Company', 'SuperPhoto')
settings.setValue('default_color', QColor('black'))
另外,注意sync()不需要显式调用。它会被 Qt 事件循环自动调用。
- 你正在从设置对话框保存偏好设置,但出于某种原因,保存的设置返回的结果非常奇怪。这里有什么问题?看看以下代码:
settings = qtc.QSettings('My Company', 'SuperPhoto')
settings.setValue('Default Name', dialog.default_name_edit.text)
settings.setValue('Use GPS', dialog.gps_checkbox.isChecked)
settings.setValue('Default Color', dialog.color_picker.color)
问题在于你实际上没有调用小部件的访问函数。因此,settings存储了访问函数的引用。在下一次程序启动时,这些引用是无意义的,因为新的对象被创建在新的内存位置。请注意,如果你保存函数引用,settings不会抱怨。
第五章
- 假设我们有一个设计良好的模型-视图应用程序,以下代码是模型还是视图的一部分?
def save_as(self):
filename, _ = qtw.QFileDialog(self)
self.data.save_file(filename)
这是视图代码,因为它创建了一个 GUI 元素(文件对话框),并似乎回调到可能是一个模型的东西(self.data)。
- 您能否至少列举两件模型绝对不应该做的事情,以及视图绝对不应该做的两件事情?
模型绝对不应该做的事情的例子包括创建或直接更改 GUI 元素,为演示格式化数据,或关闭应用程序。视图绝对不应该做的事情的例子包括将数据保存到磁盘,对存储的数据执行转换(如排序或算术),或从模型以外的任何地方读取数据。
QAbstractTableModel和QAbstractTreeModel都在名称中带有abstract。在这种情况下,abstract是什么意思?在 C++中,它的含义与 Python 中的含义不同吗?
在任何编程语言中,抽象类是指不打算实例化为对象的类;它们只应该被子类化,并覆盖所需的方法。在 Python 中,这是暗示的,但不是强制的;在 C++中,标记为abstract的类将无法实例化。
- 以下哪种模型类型——列表、表格或树——最适合以下数据集?
-
- 用户的最近文件
-
Windows 注册表
-
Linux
syslog记录 -
博客文章
-
个人称谓(例如,先生,夫人或博士)
-
分布式版本控制历史
虽然有争议,但最有可能的答案如下:
-
- 列表
-
树
-
表
-
表
-
列表
-
树
-
为什么以下代码失败了?
class DataModel(QAbstractTreeModel):
def rowCount(self, node):
if node > 2:
return 1
else:
return len(self._data[node])
rowCount()的参数是指向父节点的QModelIndex对象。它不能与整数进行比较(if node > 2)。
- 当插入列时,您的表模型工作不正常。您的
insertColumns()方法有什么问题?
def insertColumns(self, col, count, parent):
for row in self._data:
for i in range(count):
row.insert(col, '')
在修改数据之前,您忽略了调用self.beginInsertColumns(),并在完成后调用self.endInsertColumns()。
- 当鼠标悬停时,您希望您的视图显示项目数据作为工具提示。您将如何实现这一点?
您需要在模型的data()方法中处理QtCore.Qt.TooltipRole。代码示例如下:
def data(self, index, role):
if role in (
qtc.Qt.DisplayRole,
qtc.Qt.EditRole,
qtc.Qt.ToolTipRole
):
return self._data[index.row()][index.column()]
第六章
- 您正在准备分发您的文本编辑器应用程序,并希望确保用户无论使用什么平台,都会默认获得等宽字体。您可以使用哪两种方法来实现这一点?
第一种方法是将默认字体的styleHint设置为QtGui.QFont.Monospace。第二种方法是找到一个适当许可的等宽字体,将其捆绑到 Qt 资源文件中,并将字体设置为您捆绑的字体。
- 尽可能地,尝试使用
QFont模仿以下文本:

代码如下:
font = qtg.QFont('Times', 32, qtg.QFont.Bold)
font.setUnderline(True)
font.setOverline(True)
font.setCapitalization(qtg.QFont.SmallCaps)
- 您能解释
QImage,QPixmap和QIcon之间的区别吗?
QPixmap和QImage都代表单个图像,但QPixmap经过优化用于显示,而QImage经过优化用于内存中的图像处理。QIcon不是单个图像,而是一组可以绑定到小部件或操作状态的图像。
- 您已经为您的应用程序定义了以下
.qrc文件,运行了pyrcc5,并在脚本中导入了资源库。如何将这个图像加载到QPixmap中?
<RCC>
<qresource prefix="foodItems">
<file alias="pancakes.png">pc_img.45234.png</file>
</qresource>
</RCC>
代码应该如下所示:
pancakes_pxm = qtg.QPixmap(":/foodItems/pancakes.png")
- 使用
QPalette,如何使用tile.png图像铺设QWidget对象的背景?
代码应该如下所示:
widget = qtw.QWidget()
palette = widget.palette()
tile_brush = qtg.QBrush(
qtg.QColor('black'),
qtg.QPixmap('tile.png')
)
palette.setBrush(qtg.QPalette.Window, tile_brush)
widget.setPalette(palette)
- 您试图使用 QSS 使删除按钮变成粉色,但没有成功。您的代码有什么问题?
deleteButton = qtw.QPushButton('Delete')
form.layout().addWidget(deleteButton)
form.setStyleSheet(
form.styleSheet() + 'deleteButton{ background-color: #8F8; }'
)
您的代码有两个问题。首先,您的deleteButton没有分配objectName。QSS 对您的 Python 变量名称一无所知;它只知道 Qt 对象名称。其次,您的样式表没有使用#符号前缀对象名称。更正后的代码应该如下所示:
deleteButton = qtw.QPushButton('Delete')
deleteButton.setObjectName('deleteButton')
form.layout().addWidget(deleteButton)
form.setStyleSheet(
form.styleSheet() +
'#deleteButton{ background-color: #8F8; }'
)
- 哪种样式表字符串将把您的
QLineEdit小部件的背景颜色变成黑色?
stylesheet1 = "QWidget {background-color: black;}"
stylesheet2 = ".QWidget {background-color: black;}"
stylesheet1将把任何QWidget子类的背景变成黑色,包括QLineEdit。stylesheet2只会把实际QWidget对象的背景变成黑色;子类将保持不受影响。
- 使用下拉框构建一个简单的应用程序,允许您将 Qt 样式更改为系统上安装的任何样式。包括一些其他小部件,以便您可以看到它们在不同样式下的外观。
在本章的示例代码中查看question_8_answer.py。
- 您对学习如何为 PyQt 应用程序设置样式感到非常高兴,并希望创建一个
QProxyStyle类,该类将强制 GUI 中的所有像素图像为smile.gif。您会如何做?提示:您需要研究一些QStyle的绘图方法,而不是本章讨论的方法。
该类如下所示:
class SmileyStyley(qtw.QProxyStyle):
def drawItemPixmap(
self, painter, rectangle, alignment, pixmap):
smile = qtg.QPixmap('smile.gif')
super().drawItemPixmap(
painter, rectangle, alignment, smile)
- 以下动画不起作用;找出为什么不起作用:
class MyWidget(qtw.QWidget):
def __init__(self):
super().__init__()
animation = qtc.QPropertyAnimation(
self, b'windowOpacity')
animation.setStartValue(0)
animation.setEndValue(1)
animation.setDuration(10000)
animation.start()
简短的答案是animation应该是self.animation。动画没有父对象,当它们被添加到布局时,它们不会像小部件一样被重新父化。因此,当构造函数退出时,animation就会超出范围并被销毁。故事的寓意是,保存您的动画作为实例变量。
第七章
- 使用
QSoundEffect,您为呼叫中心编写了一个实用程序,允许他们回顾录制的电话呼叫。他们正在转移到一个新的电话系统,该系统将电话呼叫存储为 MP3 文件。您需要对您的实用程序进行任何更改吗?
是的。您需要使用QMediaPlayer而不是QSoundEffect,或者编写一个解码 MP3 到 WAV 的层,因为QSoundEffect无法播放压缩音频。
cool_songs是一个 Python 列表,其中包含您最喜欢的歌曲的路径字符串。要以随机顺序播放这些歌曲,您需要做什么?
您需要将路径转换为QUrl对象,将它们添加到QMediaPlaylist,将playbackMode设置为Random,然后将其传递给QMediaPlayer。代码如下:
playlist = qtmm.QMediaPlaylist()
for song in cool_songs:
url = qtc.QUrl.fromLocalFile(song)
content = qtmm.QMediaContent(url)
playlist.addMedia(content)
playlist.setPlaybackMode(qtmm.QMediaPlaylist.Random)
player = qtmm.QMediaPlayer()
player.setPlaylist(playlist)
player.play()
- 您已在系统上安装了
audio/mpeg编解码器,但以下代码不起作用。找出其中的问题:
recorder = qtmm.QAudioRecorder()
recorder.setCodec('audio/mpeg')
recorder.record()
QAudioRecorder没有setCodec方法。录制中使用的编解码器设置在QAudioEncoderSettings对象上设置。代码应该如下所示:
recorder = qtmm.QAudioRecorder()
settings = qtmm.QAudioEncoderSettings()
settings.setCodec('audio/mpeg')
recorder.setEncodingSettings(settings)
recorder.record()
- 在几个不同的 Windows、macOS 和 Linux 系统上运行
audio_test.py和video_test.py。输出有什么不同?有哪些项目在所有系统上都受支持?
答案将取决于您选择的系统。
QCamera类的属性包括几个控制对象,允许您管理相机的不同方面。其中之一是QCameraFocus。在 Qt 文档中查看QCameraFocus,并编写一个简单的脚本,显示取景器并让您调整数字变焦。
在包含的代码示例中查看question_5_example_code.py。
- 您已经注意到录制到您的船长日志视频日志中的音频相当响亮。您想添加一个控件来调整它;您会如何做?
QMediaRecorder有一个volume()插槽,就像QAudioRecorder一样。您需要创建一个QSlider(或任何其他控件小部件),并将其valueChanged或sliderMoved信号连接到录制器的volume()插槽。
- 在
captains_log.py中实现一个停靠窗口小部件,允许您控制尽可能多的音频和视频录制方面。您可以包括焦点、缩放、曝光、白平衡、帧速率、分辨率、音频音量、音频质量等内容。
这里就靠你自己了!
第八章
- 您正在设计一个应用程序,该应用程序将向本地网络发出状态消息,您将使用管理员工具进行监控。哪种类型的套接字对象是一个不错的选择?
在这里最好使用QUdpSocket,因为它允许广播数据包,并且状态数据包不需要 TCP 的开销。
- 您的 GUI 类有一个名为
self.socket的QTcpSocket对象。您已经将其readyRead信号连接到以下方法,但它没有起作用。发生了什么,您该如何修复它?
def on_ready_read(self):
while self.socket.hasPendingDatagrams():
self.process_data(self.socket.readDatagram())
QTcpSocket没有hasPendingDatagrams()或readDatagram()方法。TCP 套接字使用数据流而不是数据包。这个方法需要重写以使用QDataStream对象提取数据。
- 使用
QTcpServer实现一个简单的服务,监听端口8080并打印接收到的任何请求。让它用您选择的字节字符串回复客户端。
在示例代码中查看question_3_tcp_server.py。通过运行脚本并将 Web 浏览器指向localhost:8080来进行测试。
- 您正在为应用程序创建一个下载函数,以便检索一个大型数据文件以导入到您的应用程序中。代码不起作用。阅读代码并决定您做错了什么:
def download(self, url):
self.manager = qtn.QNetworkAccessManager(
finished=self.on_finished)
self.request = qtn.QNetworkRequest(qtc.QUrl(url))
reply = self.manager.get(self.request)
with open('datafile.dat', 'wb') as fh:
fh.write(reply.readAll())
您试图同步使用QNetworkAccessManager.get(),但它是设计用于异步使用的。您需要连接一个回调到网络访问管理器的finished信号,而不是从get()中检索回复对象,它携带完成的回复。
- 修改您的
poster.py脚本,以便将键值数据发送为 JSON,而不是 HTTP 表单数据。
在示例代码中查看question_5_json_poster.py文件。
第九章
- 编写一个 SQL
CREATE语句,用于构建一个表来保存电视节目表。确保它具有日期、时间、频道和节目名称的字段。还要确保它具有主键和约束,以防止无意义的数据(例如在同一频道上同时播放两个节目,或者一个节目没有时间或日期)。
一个示例可能如下所示:
CREATE TABLE tv_schedule AS (
id INTEGER PRIMARY KEY,
channel TEXT NOT NULL,
date DATE NOT NULL,
time TIME NOT NULL,
program TEXT NOT NULL,
UNIQUE(channel, date, time)
)
- 以下 SQL 查询返回语法错误;您能修复它吗?
DELETE * FROM my_table IF category_id == 12;
这里有几个问题:
-
DELETE不接受字段列表,因此必须删除*。
-
IF是错误的关键字。它应该使用WHERE。 -
==不是 SQL 运算符。与 Python 不同,SQL 使用单个=进行赋值和比较操作。
生成的 SQL 应该如下所示:
DELETE FROM my_table WHERE category_id = 12;
- 以下 SQL 查询不正确;您能修复它吗?
INSERT INTO flavors(name) VALUES ('hazelnut', 'vanilla', 'caramel', 'onion');
VALUES子句中的每组括号表示一行。由于我们只插入一列,每行应该只有一个值。因此,我们的语句应该如下所示:
INSERT INTO flavors(name) VALUES ('hazelnut'), ('vanilla'), ('caramel'), ('onion');
QSqlDatabase的文档可以在doc.qt.io/qt-5/qsqldatabase.html找到。详细了解如何使用多个数据库连接,例如对同一数据库进行只读和读写连接。您将如何创建两个连接并对每个连接进行特定的查询?
关键是多次使用唯一连接名称调用addDatabase();一个示例如下:
db1 = qts.QSqlDatabase.addDatabase('QSQLITE', 'XYZ read-only')
db1.setUserName('readonlyuser')
# etc...
db1.open()
db2 = qts.QSqlDatabase.addDatabase('QSQLITE', 'XYZ read-write')
db2.setUserName('readwriteuser')
# etc...
db2.open()
# Keep the database reference for querying:
query = qts.QSqlQuery('SELECT * FROM my_table', db1)
# Or retrieve it using its name:
db = qts.QSqlDatabase.database('XYZ read-write')
db.exec('INSERT INTO my_table VALUES (1, 2, 3)')
- 使用
QSqlQuery,编写代码将dict对象中的数据安全地插入coffees表中:
data = {'brand': 'generic', 'name': 'cheap coffee', 'roast':
'light'}
# Your code here:
为了安全起见,我们将使用QSqlQuery的prepare()方法:
data = {'brand': 'generic', 'name': 'cheap coffee', 'roast':
'Light'}
query = QSqlQuery()
query.prepare(
'INSERT INTO coffees(coffee_brand, coffee_name, roast_id) '
'VALUES (:brand, :name,
'(SELECT id FROM roasts WHERE description == :roast))'
)
query.bindValue(':brand', data['brand'])
query.bindValue(':name', data['name'])
query.bindValue(':roast', data['roast'])
query.exec()
- 您已经创建了一个
QSqlTableModel对象,并将其附加到QTableView。您知道表中有数据,但在视图中没有显示。查看代码并决定问题出在哪里:
flavor_model = qts.QSqlTableModel()
flavor_model.setTable('flavors')
flavor_table = qtw.QTableView()
flavor_table.setModel(flavor_model)
mainform.layout().addWidget(flavor_table)
您没有在模型上调用select()。在这样做之前,它将是空的。
- 以下是附加到
QLineEdit的textChanged信号的回调。解释为什么这不是一个好主意:
def do_search(self, text):
self.sql_table_model.setFilter(f'description={text}')
self.sql_table_model.select()
问题在于您正在接受任意用户输入并将其传递给表模型的filter()字符串。这个字符串被直接附加到表模型的内部 SQL 查询中,从而使您的数据库容易受到 SQL 注入。为了使其安全,您需要采取措施来清理text或切换 SQL 表模型以使用prepare()来创建一个准备好的语句。
- 您决定在您的咖啡列表的烘焙组合框中使用颜色而不是名称。为了实现这一点,您需要做出哪些改变?
您需要更改roast_id上设置的QSqlRelation所使用的显示字段为color。然后,您需要为coffee_list创建一个自定义委托,用于创建颜色图标(参见第六章,Qt 应用程序的样式)并在组合框中使用它们而不是文本标签。
第十章
- 创建代码以每十秒调用
self.every_ten_seconds()方法。
假设我们在一个类的__init__()方法中,它看起来像这样:
self.timer = qtc.QTimer()
self.timer.setInterval(10000)
self.timer.timeout.connect(self.every_ten_seconds)
- 以下代码错误地使用了
QTimer。你能修复它吗?
timer = qtc.QTimer()
timer.setSingleShot(True)
timer.setInterval(1000)
timer.start()
while timer.remainingTime():
sleep(.01)
run_delayed_command()
QTimer与while循环同步使用。这会创建阻塞代码。可以异步完成相同的操作,如下所示:
qtc.QTimer.singleShot(1000, run_delayed_command)
- 您创建了以下计算单词数的工作类,并希望将其移动到另一个线程以防止大型文档减慢 GUI。但是,它没有工作;您需要对这个类做出哪些改变?
class Worker(qtc.QObject):
counted = qtc.pyqtSignal(int)
def __init__(self, parent):
super().__init__(parent)
self.parent = parent
def count_words(self):
content = self.parent.textedit.toPlainText()
self.counted.emit(len(content.split()))
该类依赖于通过共同的父级访问小部件,因为Worker类必须由包含小部件的 GUI 类作为父级。您需要更改此类,使以下内容适用:
-
- 它没有父小部件。
-
它以其他方式访问内容,比如通过一个槽。
- 以下代码是阻塞的,而不是在单独的线程中运行。为什么会这样?
class Worker(qtc.QThread):
def set_data(data):
self.data = data
def run(self):n
start_complex_calculations(self.data)
class MainWindow(qtw.QMainWindow):
def __init__(self):
super().__init__()
form = qtw.QWidget()
self.setCentralWidget(form)
form.setLayout(qtw.QFormLayout())
worker = Worker()
line_edit = qtw.QLineEdit(textChanged=worker.set_data)
button = qtw.QPushButton('Run', clicked=worker.run)
form.layout().addRow('Data:', line_edit)
form.layout().addRow(button)
self.show()
按钮回调指向Worker.run()。它应该指向QThread对象的start()方法。
- 这个工作类会正确运行吗?如果不会,为什么?
class Worker(qtc.QRunnable):
finished = qtc.pyqtSignal()
def run(self):
calculate_navigation_vectors(30)
self.finished.emit()
不,QRunnable对象不能发出信号,因为它们不是从QObject继承的,也没有事件循环。在这种情况下,最好使用QThread。
- 以下代码是一个
QRunnable类的run()方法,用于处理来自科学设备的大型数据文件输出。文件由数百万行空格分隔的数字组成。这段代码可能会被 Python GIL 减慢吗?您能使 GIL 干扰的可能性更小吗?
def run(self):
with open(self.file, 'r') as fh:
for row in fh:
numbers = [float(x) for x in row.split()]
if numbers:
mean = sum(numbers) / len(numbers)
numbers.append(mean)
self.queue.put(numbers)
读取文件是一个 I/O 绑定的操作,不需要获取 GIL。但是,进行数学计算和类型转换是一个 CPU 绑定的任务,需要获取 GIL。这可以通过在非 Python 数学库(如 NumPy)中进行计算来减轻。
- 以下是你正在编写的多线程 TCP 服务器应用程序中
QRunnable中的run()方法。所有线程共享通过self.datastream访问的服务器套接字实例。但是,这段代码不是线程安全的。你需要做什么来修复它?
def run(self):
message = get_http_response_string()
message_len = len(message)
self.datastream.writeUInt32(message_len)
self.datastream.writeQString(message)
由于您不希望两个线程同时写入数据流,您将希望使用QMutex来确保只有一个线程可以访问。在定义了一个名为qmutex的共享互斥对象之后,代码将如下所示:
def run(self):
message = get_http_response_string()
message_len = len(message)
with qtc.QMutexLocker(self.qmutex):
self.datastream.writeUInt32(message_len)
self.datastream.writeQString(message)
第十一章
- 以下 HTML 显示不像您想要的那样。找出尽可能多的错误:
<table>
<thead background=#EFE><th>Job</th><th>Status</th></thead>
<tr><td>Backup</td><font text-color='green'>Success!</font></td></tr>
<tr><td>Cleanup<td><font text-style='bold'>Fail!</font></td></tr>
</table>
这里有几个错误:
-
<thead>部分缺少围绕单元格的<tr>标签。
-
在下一行中,第二个单元格缺少开放的
<td>标签。 -
另外,没有
text-color属性。它只是color。 -
在下一行中,第一个单元格缺少闭合的
</td>标签。 -
还有没有
text-style属性。文本应该只是用<b>标签包装起来。
- 以下 Qt HTML 片段有什么问题?
<p>There is nothing <i>wrong</i> with your television <b>set</p></b>
<table><row><data>french fries</data>
<data>$1.99</data></row></table>
<font family='Tahoma' color='#235499'>Can you feel the <strikethrough>love</strikethrough>code tonight?</font>
<label>Username</label><input type='text' name='username'></input>
<img source='://mypix.png'>My picture</img>
问题如下:
-
- 最后两个闭合标签被切换了。嵌套标签必须在外部标签之前关闭。
-
没有
<row>或<data>这样的标签。正确的标签应该分别是<tr>和<td>。 -
有两个问题——
<font>没有family属性,应该是face;另外,没有<strikethrough>标签,应该是<s>。 -
Qt 不支持
<label>或<input>标签。此外,<input>不使用闭合标签。 -
<img>没有source属性;它应该是src。它也没有使用闭合标签,也不能包含文本内容。 -
这段代码应该实现一个目录。为什么它不能正常工作?
<ul>
<li><a href='Section1'>Section 1</a></li>
<li><a href='Section2'>Section 2</a></li>
</ul>
<div id=Section1>
<p>This is section 1</p>
</div>
<div id=Section2>
<p>This is section 2</p>
</div>
这不是文档锚点的工作方式。正确的代码如下:
<ul>
<li><a href='#Section1'>Section 1</a></li>
<li><a href='#Section2'>Section 2</a></li>
</ul>
<a name='Section1'></a>
<div id=Section1>
<p>This is section 1</p>
</div>
<a name='Section2'></a>
<div id=Section2>
<p>This is section 2</p>
</div>
请注意href前面的井号(#),表示这是一个内部锚点,以及上面的<a>标签,其中包含一个包含部分名称的name属性(不包括井号!)。
- 使用
QTextCursor,您需要在文档的右侧添加一个侧边栏。解释一下您将如何做到这一点。
这样做的步骤如下:
-
- 创建一个
QTextFrameFormat对象
- 创建一个
-
将框架格式的
position属性配置为右浮动 -
将文本光标定位在根框中
-
在光标上调用
insertFrame(),并将框架对象作为第一个参数 -
使用光标插入方法插入侧边栏内容
-
您正在尝试使用
QTextCursor创建一个文档。它应该有一个顶部和底部框架;在顶部框架中,应该有一个标题,在底部框架中,应该有一个无序列表。请更正此代码,使其实现这一点:
document = qtg.QTextDocument()
cursor = qtg.QTextCursor(document)
top_frame = cursor.insertFrame(qtg.QTextFrameFormat())
bottom_frame = cursor.insertFrame(qtg.QTextFrameFormat())
cursor.insertText('This is the title')
cursor.movePosition(qtg.QTextCursor.NextBlock)
cursor.insertList(qtg.QTextListFormat())
for item in ('thing 1', 'thing 2', 'thing 3'):
cursor.insertText(item)
这段代码的主要问题在于它未能正确移动光标,因此内容没有被创建在正确的位置。以下是更正后的代码:
document = qtg.QTextDocument()
cursor = qtg.QTextCursor(document)
top_frame = cursor.insertFrame(qtg.QTextFrameFormat())
cursor.setPosition(document.rootFrame().lastPosition())
bottom_frame = cursor.insertFrame(qtg.QTextFrameFormat())
cursor.setPosition(top_frame.lastPosition())
cursor.insertText('This is the title')
# This won't get us to the next frame:
#cursor.movePosition(qtg.QTextCursor.NextBlock)
cursor.setPosition(bottom_frame.lastPosition())
cursor.insertList(qtg.QTextListFormat())
for i, item in enumerate(('thing 1', 'thing 2', 'thing 3')):
# don't forget to add a block for each item after the first:
if i > 0:
cursor.insertBlock()
cursor.insertText(item)
- 您正在创建自己的
QPrinter子类以在页面大小更改时添加信号。以下代码会起作用吗?
class MyPrinter(qtps.QPrinter):
page_size_changed = qtc.pyqtSignal(qtg.QPageSize)
def setPageSize(self, size):
super().setPageSize(size)
self.page_size_changed.emit(size)
不幸的是,不会。因为QPrinter不是从QObject派生的,所以它不能有信号。您将会收到这样的错误:
TypeError: MyPrinter cannot be converted to PyQt5.QtCore.QObject in this context
QtPrintSupport包含一个名为QPrinterInfo的类。使用这个类,在您的系统上打印出所有打印机的名称、制造商和型号以及默认页面大小的列表。
代码如下:
for printer in qtps.QPrinterInfo.availablePrinters():
print(
printer.printerName(),
printer.makeAndModel(),
printer.defaultPageSize())
第十二章
- 在这个方法中添加代码,以在图片底部用蓝色写下您的名字:
def create_headshot(self, image_file, name):
image = qtg.QImage()
image.load(image_file)
# your code here
# end of your code
return image
您的代码将需要创建QPainter和QPen,然后写入图像:
def create_headshot(self, image_file, name):
image = qtg.QImage()
image.load(image_file)
# your code here
painter = qtg.QPainter(image)
pen = qtg.QPen(qtg.QColor('blue'))
painter.setPen(pen)
painter.drawText(image.rect(), qtc.Qt.AlignBottom, name)
# end of your code
return image
- 给定一个名为
painter的QPainter对象,写一行代码在绘图设备的左上角绘制一个 80×80 像素的八边形。参考doc.qt.io/qt-5/qpainter.html#drawPolygon中的文档。
有几种方法可以创建和绘制多边形,但最简单的方法是将一系列QPoint对象传递给drawPolygon():
painter.drawPolygon(
qtc.QPoint(0, 20), qtc.QPoint(20, 0),
qtc.QPoint(60, 0), qtc.QPoint(80, 20),
qtc.QPoint(80, 60), qtc.QPoint(60, 80),
qtc.QPoint(20, 80), qtc.QPoint(0, 60)
)
当然,您也可以使用QPainterPath对象。
- 您正在创建一个自定义小部件,但不知道为什么文本显示为黑色。以下是您的
paintEvent()方法;看看您能否找出问题所在:
def paintEvent(self, event):
black_brush = qtg.QBrush(qtg.QColor('black'))
white_brush = qtg.QBrush(qtg.QColor('white'))
painter = qtg.QPainter()
painter.setBrush(black_brush)
painter.drawRect(0, 0, self.width(), self.height())
painter.setBrush(white_brush)
painter.drawText(0, 0, 'Test Text')
问题在于您设置了brush,但文本是用pen绘制的。默认的笔是黑色。要解决这个问题,创建一个设置为白色的pen,并在绘制文本之前将其传递给painter.setPen()。
- 油炸模因是一种使用极端压缩、饱和度和其他处理方式的模因风格,使模因图像看起来故意低质量。向您的模因生成器添加一个功能,可选择使模因油炸。您可以尝试的一些方法包括减少颜色位深度和调整图像中颜色的色调和饱和度。
在这里要有创意,但是可以参考附带源代码中的question_4_example_code.py文件。
- 您想要对一个圆进行水平移动的动画。在以下代码中,您需要改变什么才能使圆形动起来?
scene = QGraphicsScene()
scene.setSceneRect(0, 0, 800, 600)
circle = scene.addEllipse(0, 0, 10, 10)
animation = QPropertyAnimation(circle, b'x')
animation.setStartValue(0)
animation.setEndValue(600)
animation.setDuration(5000)
animation.start()
您的circle对象不能像现在这样进行动画处理,因为它是一个QGraphicsItem。要使用QPropertyAnimation对对象的属性进行动画处理,它必须是QObject的后代。您需要将您的圆构建为QGraphicsObject的子类;然后,您可以对其进行动画处理。
- 以下代码有什么问题,它试图使用渐变刷设置
QPainter?
gradient = qtg.QLinearGradient(
qtc.QPointF(0, 100), qtc.QPointF(0, 0))
gradient.setColorAt(20, qtg.QColor('red'))
gradient.setColorAt(40, qtg.QColor('orange'))
gradient.setColorAt(60, qtg.QColor('green'))
painter = QPainter()
painter.setGradient(gradient)
这里有两个问题:
-
setColorAt的第一个参数不是像素位置,而是一个表示为浮点数的百分比,介于0和1之间。
-
没有
QPainter.setGradient()方法。渐变必须传递到QPainter构造函数中。 -
看看你是否可以实现以下游戏改进:
-
- 脉动子弹
-
击中坦克时爆炸
-
声音(参见第七章,使用 QtMultimedia 处理音频-视觉,在这里寻求帮助)
-
背景动画
-
多个子弹
你自己来吧。玩得开心!
第十三章
- OpenGL 渲染管线的哪些步骤是可用户定义的?为了渲染任何东西,必须定义哪些步骤?你可能需要参考
www.khronos.org/opengl/wiki/Rendering_Pipeline_Overview上的文档。
顶点处理和片段着色器步骤是可用户定义的。至少,你必须创建一个顶点着色器和一个片段着色器。可选步骤包括几何着色器和镶嵌步骤,这些步骤是顶点处理的一部分。
- 你正在为一个 OpenGL 2.1 程序编写着色器。以下看起来正确吗?
#version 2.1
attribute highp vec4 vertex;
void main (void)
{
gl_Position = vertex;
}
你的版本字符串是错误的。它应该是#version 120,因为它指定了 GLSL 的版本,而不是 OpenGL 的版本。版本也被指定为一个没有句号的三位数。
- 以下是顶点着色器还是片段着色器?你如何判断?
attribute highp vec4 value1;
varying highp vec3 x[4];
void main(void)
{
x[0] = vec3(sin(value1[0] * .4));
x[1] = vec3(cos(value1[1]));
gl_Position = value1;
x[2] = vec3(10 * x[0])
}
这是一个顶点着色器;有一些线索:
-
- 它有一个属性变量,它分配给
gl_Position。
- 它有一个属性变量,它分配给
-
它有一个可变变量,它正在分配值。
- 给定以下顶点着色器,你需要写什么代码来为这两个变量分配简单的值?
attribute highp vec4 coordinates;
uniform highp mat4 matrix1;
void main(void){
gl_Position = matrix1 * coordinates;
}
假设你的QOpenGLShaderProgram对象保存为self.program,需要以下代码:
c_handle = self.program.attributeLocation('coordinates')
m_handle = self.program.uniformLocation('matrix1')
self.program.setAttributeValue(c_handle, coordinate_value)
self.program.setUniformValue(m_handle, matrix)
- 你启用面剔除以节省一些处理能力,但发现你的绘图中的几何体没有渲染。可能出了什么问题?
顶点被以错误的顺序绘制。记住,逆时针绘制一个基元会导致远处的面被剔除;顺时针绘制会导致近处的面被剔除。
- 以下代码对我们的 OpenGL 图像做了什么?
matrix = qtg.QMatrix4x4()
matrix.perspective(60, 4/3, 2, 10)
matrix.translate(1, -1, -4)
matrix.rotate(45, 1, 0, 0)
单独来看,什么也没有。这段代码只是创建一个 4x4 矩阵,并对其进行一些变换操作。然而,如果我们将其传递到一个应用其值到顶点的着色器中,它将创建一个透视投影,将我们的对象移动到空间中,并旋转图像。实际的matrix对象只不过是一组数字的矩阵。
- 尝试演示,并看看你是否可以添加以下功能中的任何一个:
-
- 一个更有趣的形状(金字塔、立方体等)
-
移动对象的更多控件
-
阴影和光效果
-
在对象中动画形状的变化
你自己来吧!
第十四章
- 考虑以下数据集的描述。你会为每个建议哪种图表样式?
-
- 按日期的 Web 服务器点击次数
-
每个销售人员每月的销售数据
-
去年各公司部门支持票的百分比
-
豆类植物的产量与植物的高度的图表,几百个植物
答案是主观的,但作者建议以下内容:
-
- 线图或样条线图,因为它可以说明交通趋势
-
条形图或堆叠图,因为这样可以让你比较销售人员的销售情况
-
饼图,因为它代表一组百分比加起来等于 100
-
散点图,因为你想展示大量数据的一般趋势
-
以下代码中哪个图表组件尚未配置,结果会是什么?
data_list = [
qtc.QPoint(2, 3),
qtc.QPoint(4, 5),
qtc.QPoint(6, 7)]
chart = qtch.QChart()
series = qtch.QLineSeries()
series.append(data_list)
view = qtch.QChartView()
view.setChart(chart)
view.show()
轴尚未配置。此图表可以显示,但轴上将没有参考标记,并且可能无法直观地进行缩放。
- 以下代码有什么问题?
mainwindow = qtw.QMainWindow()
chart = qtch.QChart()
series = qtch.QPieSeries()
series.append('Half', 50)
series.append('Other Half', 50)
mainwindow.setCentralWidget(chart)
mainwindow.show()
QChart不是一个小部件,不能添加到布局或设置为中央小部件。它必须附加到QChartView。
- 你想创建一个比较 Bob 和 Alice 季度销售额的柱状图。需要添加什么代码?(注意这里不需要轴。)
bob_sales = [2500, 1300, 800]
alice_sales = [1700, 1850, 2010]
chart = qtch.QChart()
series = qtch.QBarSeries()
chart.addSeries(series)
# add code here
# end code
view = qtch.QChartView()
view.setChart(chart)
view.show()
我们需要为 Bob 和 Alice 创建柱状图,并将它们附加到系列中:
bob_set = qtch.QBarSet('Bob')
alice_set = qtch.QBarSet('Alice')
bob_set.append(bob_sales)
alice_set.append(alice_sales)
series.append(bob_set)
series.append(alice_set)
- 给定一个名为
chart的QChart对象,编写代码使图表具有黑色背景和蓝色数据图。
为此,设置backgroundBrush和theme属性:
chart.setBackgroundBrush(
qtg.QBrush(qtc.Qt.black))
chart.setTheme(qtch.QChart.ChartThemeBlueIcy)
- 使用你在上一个图表中使用的技术来为系统监视器脚本中的另外两个图表设置样式。尝试不同的画刷和笔,看看是否可以找到其他需要设置的属性。
你现在是自己一个人了!
QPolarChart是QChart的一个子类,允许你构建极坐标图。查阅 Qt 文档中关于极坐标图的使用,并看看你是否可以创建一个适当数据集的极坐标图。
你现在是自己一个人了!
psutil.cpu_percent()接受一个可选参数percpu,它将创建一个显示每个 CPU 核心使用信息的值列表。更新你的应用程序以使用这个选项,并分别在一个图表上显示每个 CPU 核心的活动。
你现在还是自己一个人;不过别担心,你可以做到的!
第十五章
- 你刚刚购买了一个预装了 Raspbian 的树莓派来运行你的 PyQt5 应用程序。当你尝试运行你的应用程序时,你会遇到一个错误,试图导入
QtNetworkAuth,而你的应用程序依赖于它。可能的问题是什么?
可能你的 Raspbian 安装版本是 9。版本 9 具有 Qt 5.7,其中没有QtNetworkAuth模块。你需要升级到更新的 Raspbian 版本。
- 你为一个传统扫描仪设备编写了一个 PyQt 前端。你的代码通过一个名为
scanutil.exe的专有驱动程序实用程序与扫描仪通信。它目前在 Windows 10 PC 上运行,但你的雇主希望通过将其移植到树莓派来节省成本。这是一个好主意吗?
不幸的是,不是这样。如果你的应用程序依赖于专有的 Windows x86 二进制文件,那么该程序将无法在树莓派上运行。要切换到树莓派,你需要一个为 ARM 平台编译的二进制文件,可以在树莓派支持的操作系统之一上运行(此外,该操作系统需要能够运行 Python 和 Qt)。
- 你已经获得了一个新的传感器,并想要用树莓派试验它。它有三个连接,标有 Vcc、GND 和 Data。你将如何将其连接到树莓派?你还需要更多的信息吗?
你真的需要更多的信息,但这里有足够的信息让你开始:
-
- Vcc是输入电压的缩写。你将不得不将其连接到树莓派上的 5V 或 3V3 引脚。你需要查阅制造商的文档,以确定哪种连接方式可行。
-
GND意味着地线,你可以将其连接到树莓派上的任何地线引脚。
-
Data可能是你想要连接到可编程 GPIO 引脚之一的连接。很可能你需要某种库来使其工作,所以你应该向制造商咨询。
- 你试图点亮连接到树莓派左侧第四个 GPIO 引脚的 LED。这段代码有什么问题?
GPIO.setmode(GPIO.BCM)
GPIO.setup(8, GPIO.OUT)
GPIO.output(8, 1)
GPIO 引脚模式设置为BCM,这意味着你使用的引脚号错误。将模式设置为BOARD,或者使用正确的 BCM 引脚号(14)。
- 你试图调暗连接到 GPIO 引脚
12的 LED。这段代码有效吗?
GPIO.setmode(GPIO.BOARD)
GPIO.setup(12, GPIO.OUT)
GPIO.output(12, 0.5)
这段代码不起作用,因为引脚只能是开或关。要模拟半电压,你需要使用脉冲宽度调制,就像下面的例子中所示:
GPIO.setmode(GPIO.BOARD)
GPIO.setup(12, GPIO.OUT)
pwm = GPIO.PWM(12, 60)
pwm.start(0)
pwm.ChangeDutyCycle(50)
- 你有一个带有数据引脚的运动传感器,当检测到运动时会变为
HIGH。它连接到引脚8。以下是你的驱动代码:
class MotionSensor(qtc.QObject):
detection = qtc.pyqtSignal()
def __init__(self):
super().__init__()
GPIO.setmode(GPIO.BOARD)
GPIO.setup(8, GPIO.IN)
self.state = GPIO.input(8)
def check(self):
state = GPIO.input(8)
if state and state != self.state:
detection.emit()
self.state = state
你的主窗口类创建了一个MotionSensor对象,并将其detection信号连接到一个回调方法。然而,没有检测到任何东西。缺少了什么?
您没有调用MotionSensor.check()。您应该通过添加一个调用check()的QTimer对象来实现轮询。
- 以创造性的方式结合本章中的两个电路;例如,您可以创建一个根据湿度和温度改变颜色的灯。
这里就靠你自己了!
第十六章
- 以下代码给出了一个属性错误;怎么了?
from PyQt5 import QtWebEngine as qtwe
w = qtwe.QWebEngineView()
您想要导入QtWebEngineWidgets,而不是QtWebEngine。后者用于与 Qt 的 QML 前端一起使用。
- 以下代码应该将
UrlBar类与QWebEngineView连接起来,以便在按下返回/Enter键时加载输入的 URL。但是它不起作用;怎么了?
class UrlBar(qtw.QLineEdit):
url_request = qtc.pyqtSignal(str)
def __init__(self):
super().__init__()
self.returnPressed.connect(self.request)
def request(self):
self.url_request.emit(self.text())
mywebview = qtwe.QWebEngineView()
myurlbar = UrlBar()
myurlbar.url_request(mywebview.load)
QWebEngineView.load()需要一个QUrl对象,而不是一个字符串。url_request信号将栏的文本作为字符串直接发送到load()。它应该首先将其包装在QUrl对象中。
- 以下代码的结果是什么?
class WebView(qtwe.QWebEngineView):
def createWindow(self, _):
return self
每当浏览器操作请求创建新的选项卡或窗口时,都会调用QWebEngineView.createWindow(),并且预计返回一个QWebEngineView对象,该对象将用于新窗口或选项卡。通过返回self,这个子类强制任何尝试创建新窗口的链接或调用只是在同一个窗口中导航。
- 查看
doc.qt.io/qt-5/qwebengineview.html上的QWebEngineView文档。您将如何在浏览器中实现缩放功能?
首先,您需要在MainWindow上实现回调函数,以设置当前 Web 视图的zoomFactor属性:
def zoom_in(self):
webview = self.tabs.currentWidget()
webview.setZoomFactor(webview.zoomFactor() * 1.1)
def zoom_out(self):
webview = self.tabs.currentWidget()
webview.setZoomFactor(webview.zoomFactor() * .9)
然后,在MainWindow.__init__()中,您只需要创建控件来调用这些方法:
navigation.addAction('Zoom In', self.zoom_in)
navigation.addAction('Zoom Out', self.zoom_out)
- 顾名思义,
QWebEngineView表示模型-视图架构中的视图部分。在这个设计中,哪个类代表模型?
QWebEnginePage似乎是这里最清晰的候选者,因为它存储和控制 Web 内容的呈现。
- 给定名为
webview的QWebEngineView,编写代码来确定webview上是否启用了 JavaScript。
代码必须查询视图的QWebEngineSettings对象,就像这样:
webview.settings().testAttribute(
qtwe.QWebEngineSettings.JavascriptEnabled)
- 您在我们的浏览器示例中看到
runJavaScript()可以将整数值传递给回调函数。编写一个简单的演示脚本来测试可以返回哪些其他类型的 JavaScript 对象,以及它们在 Python 代码中的显示方式。
在示例代码中查看chapter_7_return_value_test.py。
第十七章
- 您已经在名为
Scan & Print Tool-box.py的文件中编写了一个 PyQt 应用程序。您想将其转换为模块样式的组织;您应该做出什么改变?
脚本的名称应该更改,因为空格、和符号和破折号不是 Python 模块名称中使用的有效字符。例如,您可以将模块名称更改为scan_and_print_toolbox。
- 您的 PyQt5 数据库应用程序有一组包含应用程序使用的查询的
.sql文件。当您的应用程序是与.sql文件在同一个目录中的单个脚本时,它可以工作,但是现在您已经将其转换为模块样式的组织,就无法找到查询。你应该怎么办?
最好的做法是将您的.sql文件放入 Qt 资源文件中,并将其作为 Python 模块的一部分。如果无法使用 Qt 资源文件,您将需要使用path模块和内置的file变量将相对路径转换为绝对路径
- 在将新应用程序上传到代码共享站点之前,您正在编写一个详细的
README.rst文件来记录您的新应用程序。分别应该使用哪些字符来标记您的一级、二级和三级标题?
实际上并不重要,只要使用可接受字符列表中的字符即可:
= - ` : ' " ~ ^ _ * + # < >
RST 解释器应该考虑遇到的第一个标题字符表示一级;第二个表示二级;第三个表示三级。
- 您正在为您的项目创建一个
setup.py脚本,以便您可以将其上传到 PyPI。您想要包括项目的 FAQ 页面的 URL。您该如何实现这一点?
您需要向project_urls字典中添加一个key: value对,就像这样:
setup(
project_urls={
'Project FAQ': 'https://example.com/faq',
}
)
- 您在
setup.py文件中指定了include_package_data=True,但由于某种原因,docs文件夹没有包含在您的分发包中。出了什么问题?
include_package_data只影响包(模块)内的数据文件。如果您想要包括模块外的文件,您需要使用MANIFEST.in文件。
- 您运行了
pyinstaller fight_fighter3.py来将您的新游戏打包为可执行文件。不过出了些问题;您可以在哪里找到构建过程的日志?
首先,您需要查看build/fight_fighter3/warn-fight_fighter3.txt。您可能需要通过使用--log-level DEBUG参数调用 PyInstaller 来增加调试输出。
- 尽管名字是这样,但 PyInstaller 实际上不能生成安装程序或包来安装您的应用程序。研究一些适合您平台的选项。
您需要自己解决这个问题,尽管一个流行的选项是Nullsoft Scriptable Install System(NSIS)。
第十九章:将 Raspbian 9 升级到 Raspbian 10
在第十五章中,PyQt Raspberry Pi,需要 Raspbian 10,这样您就可以拥有足够新的 Python 和 PyQt5 版本。在出版时,Raspbian 的当前版本是 9 版,预计 2019 年中至晚期将推出 10 版。但是,您可以升级到 Raspbian 10 的测试版本,这将正确地满足本书的目的。
要做到这一点,请按照以下步骤进行:
- 首先,通过检查
/etc/issue的内容来验证您是否正在使用 Raspbian 9。它应该如下所示:
$ Rasbpian GNU/Linux 9 \n \l
- 打开命令提示符,并使用
sudo编辑/etc/apt/sources.list:
$ sudo -e /etc/apt/sources.list
- 将每个
stretch实例更改为buster。例如,第一行应该如下所示:
deb http://raspbian.raspbrrypi.org/raspbian/
buster main contrib non-free rpi
-
运行
sudo apt update命令,确保没有任何错误。 -
现在运行
sudo apt upgrade命令。此命令可能需要很长时间才能完成,因为它需要下载系统上每个软件包的更新副本并安装它。下载阶段结束后,还会有一些问题需要回答。一般来说,对这些问题采用默认答案。 -
最后,重新启动您的 Raspberry Pi。要清理旧的软件包,请运行以下命令:
$ sudo apt autoremove
就是这样;现在您应该正在运行 Raspbian 10。如果遇到困难,请咨询Raspbian 社区。


浙公网安备 33010602011771号