精通-QT5-全-
精通 QT5(全)
原文:
zh.annas-archive.org/md5/29dd401727a875d0c02294502aa92a0b译者:飞龙
前言
C++ 是一种强大的语言。与 Qt 结合,您手中就有一个跨平台框架,它将性能和易用性结合起来。Qt 是一个庞大的框架,在许多领域提供工具(GUI、线程、网络等)。自其诞生以来 25 年后,Qt 仍然随着每个版本的发布而不断发展和成长。
本书旨在教你如何利用新的 C++14 添加功能(lambda 表达式、智能指针、枚举类等)从 Qt 中榨取最佳性能。这两种技术结合在一起为您带来一个安全且强大的开发工具箱。全书,我们试图强调一个干净的架构,让您能够在复杂的环境中创建和维护应用程序。
每一章都基于一个示例项目,这是所有讨论的基础。以下是关于本书将展示内容的预览:
-
揭示 qmake 的秘密
-
深入研究模型/视图架构,并学习如何使用此模式构建复杂的应用程序
-
研究移动中的 QML 和 Qt Quick 应用程序
-
使用 QML 和 JavaScript 开发 Qt 3D 组件
-
展示如何使用 Qt 开发插件和 SDK
-
涵盖 Qt 提供的多线程技术
-
使用套接字构建 IPC 机制
-
使用 XML、JSON 和二进制格式序列化数据
我们将涵盖所有这些以及更多内容。
注意,如果您想获得一些开发糖果并查看一些可能使您的开发更加愉快的代码片段,您可以在任何时候查看第十四章,Qt 小贴士和技巧。
最重要的是,享受编写 Qt 应用程序的过程!
本书涵盖的内容
第一章,让你的 Qt 脚步湿透,介绍了 Qt 的基础知识,并通过一个待办事项应用程序刷新你的记忆。本章涵盖了 Qt 项目结构、如何使用设计师、信号和槽机制的基本原理,并介绍了 C++14 的新特性。
第二章,发现 QMake 秘密,深入 Qt 编译系统的心脏:qmake。本章将帮助您了解它是如何工作的,如何使用它,以及您如何通过设计一个系统监控应用程序来使用平台特定的代码来结构化 Qt 应用程序。
第三章,划分你的项目和统治你的代码,分析了 Qt 的模型/视图架构以及如何通过开发一个包含应用程序核心逻辑的自定义库来组织项目。项目示例是一个持久性画廊应用程序。
第四章,征服桌面 UI,通过一个依赖于上一章完成的库的 Qt 小部件应用程序,研究模型/视图架构的 UI 视角。
第五章, 统治移动 UI,通过添加移动版本(Android 和 iOS)的画廊应用程序的缺失部分,增加了内容;本章使用 QML、Qt Quick 控件和 QML / C++交互来介绍这一点。
第六章,即使 Qt 也值得一块树莓派,继续沿着 Qt Quick 应用的道路,以 Qt 3D 视角前行。本章介绍了如何为树莓派构建一个 3D 蛇形游戏。
第七章, 无需头痛的第三方库,介绍了如何在 Qt 项目中集成第三方库。OpenCV 将与一个图像过滤器应用程序集成,该应用程序还提供了一个自定义 QDesigner 插件。
第八章, 动画,它活着,活着!,通过添加动画和分发自定义 SDK 的能力来扩展图像过滤器应用程序,以便其他开发者可以添加他们自己的过滤器。
第九章, 用多线程保持你的理智,通过构建一个多线程的 Mandelbrot 分形绘制应用程序来调查 Qt 提供的多线程功能。
第十章, 需要 IPC?让你的小弟们去工作,通过将计算移动到其他进程并使用套接字管理通信来扩展 Mandelbrot 分形应用程序。
第十一章, 与序列化一起玩乐,在一个可以录制和加载声音循环的鼓机应用程序中,涵盖了多种序列化格式(JSON、XML 和二进制)。
第十二章, 用 QTest 通过(或不通过),向鼓机应用程序添加测试,并研究如何使用 Qt Test 框架进行单元测试、基准测试和 GUI 事件模拟。
第十三章, 打包并准备部署,提供了如何打包应用程序在所有桌面操作系统(Windows、Linux 和 Mac OS)和移动平台(Android 和 iOS)上的见解。
第十四章, Qt 帽技巧与窍门,汇集了一些开发 Qt 时愉快使用的技巧和窍门。它展示了如何在 Qt Creator 中管理会话,有用的 Qt Creator 键盘快捷键,如何自定义日志记录,将其保存到磁盘,以及更多。
你需要这本书的内容
本书中的所有代码都可以使用 Qt 5.7 在 Qt Creator 中编译和运行。您可以从您喜欢的操作系统:Windows、Linux 或 Mac OS 中进行操作。
关于针对移动设备的章节,无论是 Android 还是 iOS 设备都可以使用,但这不是强制性的(模拟器/仿真器就足够了)。
第六章, 即使 Qt 也值得一块树莓派,提供在树莓派上运行的应用程序构建。虽然如果我们能用真实的树莓派来做会更有趣,但完成本章内容并不需要真的有一个树莓派。
这本书面向的对象
这本书将吸引那些希望构建基于 GUI 应用程序的开发者和程序员。你应该熟练掌握 C++和面向对象范式。Qt 知识推荐,但不是必需的。
惯例
在这本书中,你会发现许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名如下所示:“使用项目.pro文件执行qmake命令。”
代码块设置如下:
void MemoryWidget::updateSeries()
{
double memoryUsed = SysInfo::instance().memoryUsed();
mSeries->append(mPointPositionX++, memoryUsed);
if (mSeries->count() > CHART_X_RANGE_COUNT) {
QChart* chart = chartView().chart();
chart->scroll(chart->plotArea().width()
/ CHART_X_RANGE_MAX, 0);
mSeries->remove(0);
}
}
当我们希望将你的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
windows {
SOURCES += SysInfoWindowsImpl.cpp
HEADERS += SysInfoWindowsImpl.h
debug {
SOURCES += DebugClass.cpp
HEADERS += DebugClass.h
}
}
任何命令行输入或输出都应如下所示:
/path/to/qt/installation/5.7/gcc_64/bin/qmake -makefile -o Makefile /path/to/sysinfoproject/ch02-sysinfo.pro
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“在 Qt Creator 中,当你点击构建按钮时,会调用 qmake。”
注意
警告或重要提示会出现在这样的框中。
小贴士
技巧和窍门如下所示。
读者反馈
我们欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出真正能让你受益的标题。要发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍的标题。如果你在某个主题上有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在你已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助你从购买中获得最大收益。
下载示例代码
你可以从www.packtpub.com的账户下载这本书的示例代码文件。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。
你可以按照以下步骤下载代码文件:
-
使用你的电子邮件地址和密码登录或注册我们的网站。
-
将鼠标指针悬停在顶部的支持标签上。
-
点击代码下载与勘误。
-
在搜索框中输入书籍名称。
-
选择您想要下载代码文件的书籍。
-
从下拉菜单中选择您购买此书的来源。
-
点击代码下载。
文件下载完成后,请确保您使用最新版本解压缩或提取文件夹:
-
WinRAR / 7-Zip for Windows
-
Zipeg / iZip / UnRarX for Mac
-
7-Zip / PeaZip for Linux
书籍的代码包也托管在 GitHub 上,网址为 github.com/PacktPublishing/masteringqt5。我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 找到。查看它们吧!
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告,我们将不胜感激。这样做可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误清单部分。
要查看之前提交的错误清单,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误清单部分。
盗版
互联网上对版权材料的盗版是一个持续存在的问题,在所有媒体中都是如此。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 copyright@packtpub.com 联系我们,并提供疑似盗版材料的链接。
我们感谢您的帮助,保护我们的作者和提供有价值内容的能力。
询问
如果您在这本书的任何方面遇到问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。
第一章。让你的 Qt 脚步湿透
如果你熟悉 C++ 但从未接触过 Qt,或者如果你已经制作了一些中级 Qt 应用程序,那么本章将确保你在学习以下章节的高级概念之前,Qt 的基础是安全的。
我们将教你如何使用 Qt Creator 创建一个简单的待办事项应用程序。此应用程序将显示一个任务列表,你可以创建/更新/删除任务。我们将涵盖 Qt Creator 和 Qt Designer 界面、信号/槽机制的介绍、创建具有自定义信号/槽的自定义小部件以及将其集成到你的应用程序中。
你将使用新的 C++14 语义实现一个待办事项应用程序:lambda 表达式、auto 变量和 for 循环。这些概念将逐一深入解释,并在整本书中应用。
本章结束时,你将能够使用 Qt 小部件和新的 C++ 语义创建一个具有灵活 UI 的桌面应用程序。
在本章中,我们将涵盖以下主题:
-
Qt 项目基本结构
-
Qt Designer 界面
-
UI 基础
-
信号和槽
-
自定义
QWidget -
C++14 lambda、auto、for each
创建项目
第一件事是启动 Qt Creator。
在 Qt Creator 中,你可以通过 文件 | 新建文件或项目 | 应用程序 | Qt 小部件应用程序 | 选择 来创建一个新的 Qt 项目。
然后向导将引导你完成四个步骤:
-
位置:你必须选择一个项目名称和位置。
-
工具包:你的项目目标平台(桌面、Android 等)。
-
详情:生成类的基类信息和名称。
-
摘要:允许你将你的新项目配置为子项目,并自动将其添加到版本控制系统。
即使可以保留所有默认值,也请至少设置一个有用的项目名称,例如 "todo" 或 "TodoApp"。如果你想要将其命名为 "Untitled" 或 "Hello world",我们也不会责怪你。
完成后,Qt Creator 将生成几个文件,你可以在 项目 层级视图中看到它们:

.pro 文件是 Qt 的配置项目文件。由于 Qt 添加了特定的文件格式和 C++ 关键字,因此会执行一个中间构建步骤,解析所有文件以生成最终文件。这个过程由 qmake(Qt SDK 中的一个可执行文件)完成。它还将为你的项目生成最终的 Makefiles。
一个基本的 .pro 文件通常包含:
-
使用的 Qt 模块(
core、gui等) -
目标名称(
todo、todo.exe等) -
项目模板(
app、lib等) -
源文件、头文件和表单
Qt 和 C++14 提供了一些很棒的功能。本书将在所有项目中展示它们。对于 GCC 和 CLANG 编译器,你必须将 CONFIG += c++14 添加到 .pro 文件中,以在 Qt 项目上启用 C++14,如下面的代码所示:
QT += core gui
CONFIG += c++14
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
TARGET = todo
TEMPLATE = app
SOURCES += main.cpp \
MainWindow.cpp
HEADERS += MainWindow.h \
FORMS += MainWindow.ui \
MainWindow.h 和 MainWindow.cpp 文件是 MainWindow 类的头文件/源文件。这些文件包含向导生成的默认 GUI。
MainWindow.ui文件是你的 XML 格式的 UI 设计文件。它可以用 Qt Designer 更容易地编辑。这个工具是一个WYSIWYG(所见即所得)编辑器,它可以帮助你添加和调整你的图形组件(小部件)。
这是main.cpp文件,其中包含其著名的函数:
#include "MainWindow.h"
#include <QApplication>
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
MainWindow w;
w.show();
return a.exec();
}
如同往常,main.cpp文件包含程序的入口点。默认情况下,它将执行两个操作:
-
实例化和显示你的主窗口
-
实例化一个
QApplication并执行阻塞的主事件循环
这是 Qt Creator 的底部左侧工具栏:

使用它以调试模式构建和启动你的todo应用程序:
-
确认项目处于调试构建模式。
-
使用锤子按钮来构建你的项目。
-
使用带有小蓝色虫的绿色播放按钮开始调试。
你会发现一个奇妙且空荡荡的窗口。在解释如何构建这个MainWindow之后,我们将纠正这个问题:

一个空的MainWindow截图
提示
Qt 技巧
-
按Ctrl + B(Windows/Linux)或Command + B(Mac)来构建你的项目
-
按F5(Windows/Linux)或Command + R(Mac)以调试模式运行你的应用程序
MainWindow 结构
这个生成的类是 Qt 框架使用的完美且简单的例子;我们将一起剖析它。如前所述,MainWindow.ui文件描述了你的 UI 设计,而MainWindow.h/MainWindow.cpp是你可以用代码操作 UI 的 C++对象。
看一下头文件MainWindow.h很重要。我们的MainWindow对象继承自 Qt 的QMainWindow类:
#include <QMainWindow>
namespace Ui {
class MainWindow;
}
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = 0);
~MainWindow();
private:
Ui::MainWindow *ui;
};
由于我们的类继承自QMainWindow类,我们在头文件顶部添加了相应的包含。第二部分是Ui::MainWindow的前向声明,因为我们只声明了一个指针。
Q_OBJECT对于非 Qt 开发者来说可能看起来有点奇怪。这个宏允许类定义自己的信号/槽,以及更广泛的 Qt 元对象系统。这些特性将在本章的后面部分介绍。
这个类定义了一个公共构造函数和析构函数。后者相当常见。但构造函数接受一个参数 parent。这个参数是一个默认为null的QWidget指针。
QWidget是一个 UI 组件。它可以是一个标签、一个文本框、一个按钮等等。如果你在窗口、布局和其他 UI 小部件之间定义了父子关系,你的应用程序的内存管理将会更容易。实际上,在这种情况下,删除父对象就足够了,因为它的析构函数将负责删除其子对象,然后子对象再删除其子对象,依此类推。
我们的 MainWindow 类从 Qt 框架扩展了 QMainWindow。我们在私有字段中有一个 ui 成员变量。其类型是 Ui::MainWindow 的指针,该指针在由 Qt 生成的 ui_MainWindow.h 文件中定义。它是 UI 设计文件 MainWindow.ui 的 C++ 转写。ui 成员变量将允许您从 C++ 中与 UI 组件(QLabel、QPushButton 等)交互,如图所示:

小贴士
C++ 小贴士
如果您的类只使用指针或引用来表示类类型,您可以通过使用前向声明来避免包含头文件。这将大大减少编译时间。
现在头部部分已经完成,我们可以谈谈 MainWindow.cpp 源文件。
在以下代码片段中,第一个包含是我们类的头文件。第二个包含是生成类 Ui::MainWindow 所需的包含。这个包含是必需的,因为我们只在头文件中使用前向声明:
#include "MainWindow.h"
#include "ui_MainWindow.h"
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow)
{
ui->setupUi(this);
在许多情况下,Qt 使用初始化列表生成一段很好的代码。parent 参数用于调用超类构造函数 QMainWindow。我们的私有成员变量 ui 也被初始化了。
现在ui已经初始化,我们必须调用setupUi函数来初始化MainWindow.ui设计文件中使用的所有组件:
由于我们在构造函数中初始化了一个指针,它必须在析构函数中清理:
MainWindow::~MainWindow()
{
delete ui;
}
Qt Designer
Qt Designer 是开发 Qt 应用程序的主要工具。这个所见即所得编辑器将帮助您轻松设计您的 GUI。如果您在MainWindow.ui文件之间切换到编辑模式或设计模式,您将看到真实的 XML 内容和设计器:

设计器显示几个部分:
-
表单编辑器:这是表单的可视表示(目前为空)
-
组件箱:这包含可以与您的表单一起使用的所有组件
-
对象检查器:这以分层树的形式显示您的表单
-
属性编辑器:这列出了所选组件的属性
-
动作编辑器/信号与槽编辑器:这处理对象之间的连接
是时候装饰这个空白的窗口了!让我们从表单上的“显示组件”部分拖放一个标签组件。您可以从属性编辑器中更改名称和文本属性。
由于我们正在制作一个 todo 应用程序,我们建议以下属性:
-
objectName:statusLabel -
text:状态:0 待办/0 完成
这个标签将稍后显示 todo 任务的数量和已完成任务的数量。好的,保存,构建,并启动您的应用程序。现在您应该在窗口中看到您的新标签。
您现在可以使用以下属性添加一个按钮:
-
objectName:addTaskButton -
text:添加任务
你应该得到一个接近以下结果:

小贴士
Qt 小贴士
您可以通过双击它直接在表单上编辑组件的文本属性!
信号和槽
Qt 框架通过三个概念:信号、槽和连接,提供了一个灵活的消息交换机制:
-
signal是对象发送的消息 -
slot是当这个信号被触发时将被调用的函数 -
connect函数指定哪个signal连接到哪个slot
Qt 已经为其类提供了信号和槽,你可以在应用程序中使用它们。例如,QPushButton 有一个 clicked() 信号,当用户点击按钮时将被触发。QApplication 类有一个 quit() 槽函数,可以在你想终止应用程序时调用。
这就是为什么你会喜欢 Qt 信号和槽:
-
槽仍然是一个普通函数,所以你可以自己调用它
-
单个信号可以连接到不同的槽
-
单个槽可以被不同的链接信号调用
-
可以在来自不同对象的信号和槽之间,甚至在位于不同线程中的对象之间建立连接!
请记住,为了能够将 signal 连接到 slot,它们的函数签名必须匹配。参数的数量、顺序和类型必须相同。注意,信号和槽从不返回值。
这是 Qt 连接的语法:
connect(sender, &Sender::signalName,
receiver, &Receiver::slotName);
我们可以使用此出色机制进行的第一个测试是将现有的 signal 与现有的 slot 连接起来。我们将把这个连接调用添加到 MainWindow 构造函数中:
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow)
{
ui->setupUi(this);
connect(ui->addTaskButton, &QPushButton::clicked,
QApplication::instance(), &QApplication::quit);
}
让我们分析一下如何完成连接:
-
sender:这是将发送信号的对象。在我们的例子中,它是从 UI 设计器添加的名为addTaskButton的QPushButton。 -
&Sender::signalName:这是成员信号函数的指针。在这里,我们希望在点击信号被触发时执行某些操作。 -
receiver:这是将接收并处理信号的对象。在我们的情况下,它是main.cpp中创建的QApplication对象。 -
&Receiver::slotName:这是指向接收者之一的成员槽函数的指针。在这个例子中,我们使用内置的quit()槽从Qapplication,这将退出应用程序。
你现在可以编译并运行这个简短的示例。如果你点击 MainWindow 的 addTaskButton,应用程序将被终止。
小贴士
Qt 小贴士
你可以将一个信号连接到另一个信号。当第一个信号被触发时,第二个信号将被发出。
现在你已经知道了如何将信号连接到现有的槽,让我们看看如何在 MainWindow 类中声明和实现一个自定义的 addTask() 槽。当用户点击 ui->addTaskButton 时,这个槽将被调用。
这是更新后的 MainWindow.h:
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = 0);
~MainWindow();
public slots:
void addTask();
private:
Ui::MainWindow *ui;
};
Qt 使用特定的 slot 关键字来识别槽。由于槽是一个函数,你可以根据需要调整其可见性(public、protected 或 private)。
在 MainWindow.cpp 文件中添加此槽实现:
void MainWindow::addTask()
{
qDebug() << "User clicked on the button!";
}
Qt 提供了一种使用 QDebug 类高效显示调试信息的方法。获取 QDebug 对象的一个简单方法是调用 qDebug() 函数。然后,您可以使用流运算符发送您的调试信息。
更新文件顶部如下:
#include <QDebug>
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow)
{
ui->setupUi(this);
connect(ui->addTaskButton, &QPushButton::clicked,
this, &MainWindow::addTask);
}
由于我们现在在槽中使用 qDebug(),我们必须包含 <QDebug>。更新的连接现在调用我们的自定义槽而不是退出应用程序。
构建并运行应用程序。如果您点击按钮,您将在 Qt Creator 的 应用程序输出 选项卡中看到您的调试信息。
自定义 QWidget
现在,我们必须创建一个 Task 类来保存我们的数据(任务名称和完成状态)。这个类将有一个与 MainWindow 分离的表单文件。Qt Creator 提供了一个自动工具来生成基类和相关表单。
点击 文件 | 新建文件或项目 | Qt | Qt Designer 表单类。这里有几个表单模板;您将认出主窗口,这是我们在启动 todo 应用程序项目时 Qt Creator 为我们创建的。选择 小部件 并将类命名为 Task,然后点击 下一步。以下是 Qt Creator 将执行的操作的摘要:
-
创建一个
Task.h文件和一个Task.cpp文件。 -
创建相关的
Task.ui并进行管道连接,将其连接到Task.h。 -
将这三个新文件添加到
todo.pro中,以便它们可以被编译。
完成,然后,Task 类就准备好了,可以填充。我们将首先跳转到 Task.ui。首先,拖放一个 Check Box(将 checkbox 放入 objectName)和一个 Push Button (objectName = removeButton):

我的对齐看起来很棒,让我们把它发给客户吧!
除非您有像素级的眼睛,否则您的小部件对齐得不是很好。您需要指示小部件应该如何布局以及当窗口几何形状改变时它们应该如何反应(例如,当用户调整窗口大小时)。为此,Qt 提供了几个默认布局类:
-
垂直布局: 在这种布局中,小部件垂直堆叠 -
水平布局: 在这种布局中,小部件水平堆叠 -
网格布局: 在这种布局中,小部件被排列在一个可以细分到更小单元格的网格中 -
表单布局: 在这种布局中,小部件排列得像网页表单,一个标签和一个输入
每个布局都会尝试将所有小部件约束在相等表面上。它将根据每个小部件的约束更改小部件的形状或添加额外的边距。一个复选框不会被拉伸,但一个按钮会被拉伸。
在我们的 Task 对象中,我们希望这些是水平堆叠的。在 表单编辑器 选项卡中,右键单击窗口并选择 布局 | 水平布局。每次您在此布局中添加新小部件时,它都将水平排列。
现在在 checkbox 对象之后添加一行 Push Button (objectName = editButton)。
表单编辑器窗口提供了你 UI 渲染的逼真预览。如果你现在拉伸窗口,你可以观察到每个部件将如何对此事件做出反应。当水平调整大小时,你可以注意到按钮被拉伸了。这看起来很糟糕。我们需要一些“提示”布局,这些按钮不应该被拉伸。进入Spacer部件。在部件框中取Horizontal Spacer,并将其放在checkbox对象之后:

空间部件是一个特殊的部件,它试图将相邻的部件(水平或垂直)推到一边,迫使它们尽可能少地占用空间。editButton和removeButton对象现在只占用它们的文本空间,当窗口大小调整时,它们将被推到窗口的边缘。
你可以在表单中添加任何类型的子布局(垂直、水平、网格、表单),并通过组合部件、空间和布局创建一个看起来复杂的应用程序。这些工具旨在设计一个能够正确响应不同窗口几何形状的桌面应用程序。
设计器部分已完成,因此我们可以切换到Task源代码。由于我们创建了一个 Qt Designer 表单类,Task与其 UI 紧密相关。我们将利用这一点将模型存储在单个位置。当我们创建一个Task对象时,它必须有一个名称:
#ifndef TASK_H
#define TASK_H
#include <QWidget>
#include <QString>
namespace Ui {
class Task;
}
class Task : public QWidget
{
Q_OBJECT
public:
explicit Task(const QString& name, QWidget *parent = 0);
~Task();
void setName(const QString& name);
QString name() const;
bool isCompleted() const;
private:
Ui::Task *ui;
};
#endif // TASK_H
构造函数指定了一个名称,正如你所见,没有私有字段存储对象的任何状态。所有这些都将通过表单部分来完成。我们还添加了一些 getter 和 setter,它们将与表单交互。最好将模型与 UI 完全分离,但我们的示例足够简单,可以合并它们。此外,Task的实现细节对外界是隐藏的,并且可以在以后进行重构。以下是Task.cpp文件的内容:
#include "Task.h"
#include "ui_Task.h"
Task::Task(const QString& name, QWidget *parent) :
QWidget(parent),
ui(new Ui::Task)
{
ui->setupUi(this);
setName(name);
}
Task::~Task()
{
delete ui;
}
void Task::setName(const QString& name)
{
ui->checkbox->setText(name);
}
QString Task::name() const
{
return ui->checkbox->text();
}
bool Task::isCompleted() const
{
return ui->checkbox->isChecked();
}
实现很简单;我们在ui->checkbox中存储信息,name()和isCompleted()getter 从ui->checkbox中获取数据。
添加任务
我们现在将重新排列MainWindow的布局,以便能够显示我们的待办任务。目前,我们没有可以显示任务的部件。打开MainWindow.ui文件并编辑它以获得以下结果:

如果我们详细说明内容,我们有:
-
一个包含
toolbarLayout文件和tasksLayout文件的centralWidget的垂直布局。 -
一个垂直空间将这些布局推到顶部,迫使它们占用尽可能小的空间。
-
我们去掉了
menuBar、mainToolBar和statusBar。Qt Creator 会自动创建它们,我们只是不需要它们来完成我们的目的。你可以从它们的名字中猜测它们的用途。
不要忘记通过在对象检查器窗口中选择MainWindow并编辑Qwidget | windowTitle属性,将MainWindow的标题重命名为Todo。你的应用程序值得有一个合适的名字。
小贴士
Qt 小贴士
在设计模式中按Shift + F4切换到表单编辑器和源代码。
现在,MainWindow UI 已经准备好欢迎任务,让我们切换到代码部分。应用程序必须跟踪新任务。在MainWindow.h文件中添加以下内容:
#include <QVector>
#include "Task.h"
class MainWindow : public QMainWindow
{
// MAINWINDOW_H
public slots:
void addTask();
private:
Ui::MainWindow *ui;
QVector<Task*> mTasks;
};
QVector是 Qt 容器类,提供动态数组,相当于std::vector。作为一个一般规则,STL 容器更可定制,但可能缺少一些与 Qt 容器相比的功能。如果你使用 C++11 智能指针,你应该优先使用std容器,但我们会稍后讨论这一点。
在 Qt 的QVector文档中,你可能会遇到以下说法:“对于大多数用途, QList 是正确的类来使用”。在 Qt 社区中对此有争议:
-
你是否经常需要在数组的开始或中间插入比指针更大的对象?请使用
QList类。 -
需要连续内存分配?更少的 CPU 和内存开销?请使用
QVector类。
已经添加的槽addTask()现在将在我们想要将新的Task对象添加到mTasks函数时被调用。
每次点击addTaskButton时,让我们填充我们的QVector任务。首先,在MainWindow.cpp文件中连接clicked()信号:
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow),
mTasks()
{
ui->setupUi(this);
connect(ui->addTaskButton, &QPushButton::clicked,
this, &MainWindow::addTask);
};
小贴士
C++小贴士
作为最佳实践,尽量始终在初始化列表中初始化成员变量,并尊重变量声明的顺序。这样你的代码将运行得更快,并且可以避免不必要的变量复制。请查看标准 C++文档isocpp.org/wiki/faq/ctors#init-lists。
addTask()函数的主体应该如下所示:
void MainWindow::addTask()
{
qDebug() << "Adding new task";
Task* task = new Task("Untitled task");
mTasks.append(task);
ui->tasksLayout->addWidget(task);
}
我们创建了一个新的任务并将其添加到我们的mTask向量中。因为Task是一个QWidget,所以我们直接将其添加到tasksLayout中。这里需要注意的是,我们从未管理过这个新任务的内存。delete task指令在哪里?这是我们在本章早期开始探讨的 Qt 框架的关键特性;QObject类会自动处理对象销毁。
在我们的案例中,ui->tasksLayout->addWidget(task)调用有一个有趣的副作用;任务的所有权被转移到了tasksLayout。在Task构造函数中定义的QObject*父类现在是tasksLayout,当tasksLayout通过递归遍历其子项并调用它们的析构函数来释放自己的内存时,将调用Task的析构函数。
这将在这一精确时刻发生:
MainWindow::~MainWindow()
{
delete ui;
}
当MainWindow被释放(记住,它是在main.cpp文件中分配的栈变量),它将调用delete ui,这反过来又会使整个QObject层次结构崩溃。这个特性有一些有趣的后果。首先,如果你在应用程序中使用QObject父子模型,你将需要管理的内存将少得多。其次,它可能与一些新的 C++11 语义冲突,特别是智能指针。我们将在后面的章节中讨论这一点。
使用QDialog
我们应该得到比未命名的任务更好的东西。用户在创建时需要定义其名称。最简单的方法是显示一个对话框,用户可以在其中输入任务名称。幸运的是,Qt 为我们提供了一个非常可配置的对话框,它完美地适用于addTask():
#include <QInputDialog>
...
void MainWindow::addTask()
{
bool ok;
QString name = QInputDialog::getText(this,
tr("Add task"),
tr("Task name"),
QLineEdit::Normal,
tr("Untitled task"), &ok);
if (ok && !name.isEmpty()) {
qDebug() << "Adding new task";
Task* task = new Task(name);
mTasks.append(task);
ui->tasksLayout->addWidget(task);
}
}
QinputDialog::getText函数是一个静态阻塞函数,用于显示对话框。当用户验证/取消对话框时,代码继续执行。如果我们运行应用程序并尝试添加一个新任务,我们会看到以下内容:

QInputDialog::getText的签名如下:
QString QinputDialog::getText(
QWidget* parent,
const QString& title,
const QString& label,
QLineEdit::EchoMode mode = QLineEdit::Normal,
const QString& text = QString(),
bool* ok = 0, ...)
让我们分解一下:
-
parent: 这是QinputDialog附加到的父小部件(MainWindow)。这是QObject类父子模型的另一个实例。 -
title: 这是窗口标题中显示的标题。在我们的示例中,我们使用tr("Add task"),这是 Qt 处理代码中的 i18n 的方式。我们将在稍后看到如何为给定的字符串提供多个翻译。 -
label: 这是在输入文本字段上方显示的标签。 -
mode: 这是指输入字段如何渲染(密码模式将隐藏文本)。 -
ok: 这是一个指向变量的指针,如果用户按下OK,则将其设置为 true;如果用户按下Cancel,则将其设置为 false。 -
QString: 返回的QString是用户输入的内容。
对于我们的示例,还有一些可选参数我们可以安全忽略。
分配代码责任
太好了,用户现在可以在创建任务时指定任务名称。如果他输入名称时出错怎么办?下一个合乎逻辑的步骤是在创建任务后重命名它。我们将采取稍微不同的方法。我们希望我们的Task尽可能自主。如果我们将其附加到另一个组件(而不是MainWindow),这个重命名功能必须继续工作。因此,这个责任必须交给Task类:
// In Task.h
public slots:
void rename();
// In Task.cpp
#include <QInputDialog>
Task::Task(const QString& name, QWidget *parent) :
QWidget(parent),
ui(new Ui::Task)
{
ui->setupUi(this);
setName(name);
connect(ui->editButton, &QPushButton::clicked, this, &Task::rename);
}
...
void Task::rename()
{
bool ok;
QString value = QInputDialog::getText(this, tr("Edit task"),
tr("Task name"),
QLineEdit::Normal,
this->name(), &ok);
if (ok && !value.isEmpty()) {
setName(value);
}
}
我们添加了一个公共槽rename()来将其连接到信号。rename()函数的主体重用了我们之前用QInputDialog覆盖的内容。唯一的区别是QInputDialog的默认值,它是当前任务名称。当调用setName(value)时,UI 会立即刷新为新值;无需同步或更新,Qt 主循环会完成其工作。
好处在于Task::rename()是完全自主的。在MainWindow中没有进行任何修改,因此我们实际上在Task和父QWidget之间有零耦合。
使用 lambda 发出自定义信号
删除任务的操作实现起来很简单,但我们在过程中会学习一些新概念。Task 必须通知其所有者和父类(MainWindow),removeTaskButton(QPushButton)已被点击。我们将在 Task.h 文件中定义一个自定义信号 removed 来实现这一点:
class Task : public QWidget
{
...
public slots:
void rename();
signals:
void removed(Task* task);
...
};
就像我们对槽函数所做的那样,我们必须在我们的头文件中添加 Qt 关键字信号。由于 signal 只用于通知另一个类,因此不需要 public 关键字(它甚至会导致编译错误)。signal 简单地是一个发送给接收者(连接的 slot)的通知;它意味着 removed(Task* task) 函数没有函数体。我们添加了 task 参数,以便接收者知道哪个任务请求被删除。下一步是在 removeButton 点击时发出 removed 信号。这是在 Task.cpp 文件中完成的:
Task::Task(const QString& name, QWidget *parent) :
QWidget(parent),
ui(new Ui::Task)
{
ui->setupUi(this);
...
connect(ui->removeButton, &QPushButton::clicked, [this] {
emit removed(this);
});
}
这段代码摘录展示了 C++11 的一个非常有趣的功能:lambda 表达式。在我们的例子中,lambda 表达式是以下部分:
[this] {
emit removed(this);
});
我们在这里所做的就是将点击信号连接到一个匿名内联函数,一个 lambda。Qt 允许通过将一个信号连接到另一个信号(如果它们的签名匹配)来传递信号。这里不是这种情况;clicked 信号没有参数,而 removed 信号需要一个 Task*。lambda 避免了在 Task 中声明冗长的 slot。Qt 5 接受 lambda 而不是槽函数在 connect 中,并且两种语法都可以使用。
我们的 lambda 执行一行代码:emit removed(this)。Emit 是一个 Qt 宏,它将立即触发连接的 slot 并传递我们作为参数传递的内容。正如我们之前所说的,removed(Task* this) 没有函数体,它的目的是通知已注册的槽函数一个事件。
lambda 是 C++ 的一个很好的补充。它们提供了一种非常实用的方法来定义代码中的短函数。技术上讲,lambda 是一个能够捕获其作用域内变量的闭包的构造。完整的语法如下:
[ capture-list ] ( params ) -> ret { body }
让我们研究这个语句的每一部分:
-
capture-list:这定义了在lambda范围内可见的变量。 -
params:这是可以传递到lambda范围的函数参数类型列表。在我们的例子中没有参数,我们可能会写成[this] () { ... },但 C++11 允许我们完全省略括号。 -
ret:这是lambda函数的返回类型。就像params一样,如果返回类型是void,则可以省略此参数。 -
body:这显然是你的代码主体,其中你可以访问你的capture-list和params,并且必须返回一个类型为ret的变量。
在我们的例子中,我们捕获了 this 指针以便能够:
-
参考一下
removed()函数,它是Task类的一部分。如果我们没有捕获this,编译器会报错:error: 'this' was not captured for this lambda function emit removed(this);。 -
将
this传递给removed信号;调用者需要知道哪个任务触发了removed。
capture-list 依赖于标准的 C++ 语义:通过复制或引用捕获变量。假设我们想要打印构造函数参数 name 的日志,并在我们的 lambda 中通过引用捕获它:
connect(ui->removeButton, &QPushButton::clicked, [this, &name] {
qDebug() << "Trying to remove" << name;
this->emit removed(this);
});
这段代码可以正常编译。不幸的是,当我们尝试移除一个 Task 时,运行时会因为一个耀眼的段错误而崩溃。发生了什么?正如我们所说,我们的 lambda 是一个匿名函数,它将在 clicked() 信号被发射时执行。我们捕获了 name 引用,但这个引用可能是 -,一旦我们离开 Task 构造函数(更准确地说,从调用者作用域),这个引用就变得无效了。然后 qDebug() 函数将尝试显示一个不可达的代码并崩溃。
你真的需要小心你捕获的内容以及你的 lambda 将被执行的上下文。在这个例子中,可以通过通过复制捕获 name 来修正段错误:
connect(ui->removeButton, &QPushButton::clicked, [this, name] {
qDebug() << "Trying to remove" << name;
this->emit removed(this);
});
小贴士
C++ 小贴士
-
你可以使用
=和&语法捕获在定义 lambda 的函数中可到达的所有变量。 -
this变量是捕获列表的一个特例。你不能通过引用[&this]来捕获它,如果你处于这种情况,编译器会警告你:[=, this]。不要这样做。小猫会死的。
我们的 lambda 直接作为参数传递给 connect 函数。换句话说,这个 lambda 是一个变量。这有很多后果:我们可以调用它、赋值给它,并返回它。为了说明一个“完全形成”的 lambda,我们可以定义一个返回任务名称格式化版本的 lambda。这个代码片段的唯一目的是调查 lambda 函数的机制。不要将以下代码包含在你的 todo 应用程序中,你的同事可能会称你为“函数狂热者”:
connect(ui->removeButton, &QPushButton::clicked, [this, name] {
qDebug() << "Trying to remove" <<
[] (const QString& taskName) -> QString {
return "-------- " + taskName.toUpper();
}(name);
this->emit removed(this);
});
在这里我们做了一件巧妙的事情。我们调用了 qDebug();在这个调用中我们定义了一个立即执行的 lambda。让我们分析一下:
-
[]: 我们没有进行捕获。这个lambda不依赖于封装的函数。 -
(const QString& taskName): 当这个 lambda 被调用时,它将期望一个QString来工作。 -
-> QString: 返回的 lambda 表达式的值将是一个QString。 -
return "------- " + taskName.toUpper(): 我们 lambda 的主体。如您所见,使用 Qt,字符串操作变得容易得多。 -
(name): 现在到了关键点。现在lambda函数已经定义,我们可以通过传递name参数来调用它。在一条指令中,我们定义并调用它。QDebug()函数将简单地打印结果。
如果我们能将这个 lambda 赋值给一个变量并多次调用它,这个 lambda 的真正好处就会显现出来。C++ 是静态类型的,因此我们必须提供 lambda 变量的类型。在语言规范中,lambda 类型不能显式定义。我们将很快看到如何使用 C++11 来实现它。现在,让我们完成我们的删除功能。
任务现在发出 removed() 信号。这个信号必须由 MainWindow 消费:
// in MainWindow.h
public slots:
void addTask();
void removeTask(Task* task);
// In MainWindow.cpp
void MainWindow::addTask()
{
...
if (ok && !name.isEmpty()) {
qDebug() << "Adding new task";
Task* task = new Task(name);
connect(task, &Task::removed,
this, &MainWindow::removeTask);
...
}
}
void MainWindow::removeTask(Task* task)
{
mTasks.removeOne(task);
ui->tasksLayout->removeWidget(task);
task->setParent(0);
delete task;
}
MainWindow::removeTask() 函数必须匹配信号签名。当任务创建时建立连接。有趣的部分在于 MainWindow::removeTask() 的实现。
任务首先从 mTasks 向量中删除。然后从 tasksLayout 中删除。在这里,tasksLayout 释放了对 task 的所有权(即,tasksLayout 停止成为 task 类的父类)。
到目前为止一切顺利。接下来的两行很有趣。所有权转移并没有完全释放 task 类的所有权。如果我们注释掉这些行,removeTask() 将会看起来像这样:
void MainWindow::removeTask(Task* task)
{
mTasks.removeOne(task);
ui->tasksLayout->removeWidget(task);
// task->setParent(0);
// delete task;
}
如果你在 Task 析构函数中添加日志消息并执行程序,这个日志消息将会显示。然而,Qt 文档告诉我们,在 Qlayout::removeWidget 部分:小部件的所有权保持不变,就像它被添加时一样。
实际上发生的情况是,task 类的父类变成了 centralWidget,tasksLayout 类的父类。我们希望 Qt 忘记关于 task 的所有信息,这就是为什么我们调用 task->setParent(0)。然后我们可以安全地删除它并结束。
使用 auto 类型和一个基于范围的 for 循环简化
完成我们任务系统的 CRUD 功能的最终一步是实现已完成任务功能。我们将实现以下功能:
-
点击复选框以标记任务为已完成
-
删除任务名称
-
更新
MainWindow中的状态标签
复选框点击处理遵循与 removed 相同的模式:
// In Task.h
signals:
void removed(Task* task);
void statusChanged(Task* task);
private slots:
void checked(bool checked);
// in Task.cpp
Task::Task(const QString& name, QWidget *parent) :
QWidget(parent),
ui(new Ui::Task)
{
...
connect(ui->checkbox, &QCheckBox::toggled,
this, &Task::checked);
}
...
void Task::checked(bool checked)
{
QFont font(ui->checkbox->font());
font.setStrikeOut(checked);
ui->checkbox->setFont(font);
emit statusChanged(this);
}
我们定义一个槽 checked(bool checked),它将被连接到 checkbox::toggled 信号。在我们的 slot checked() 中,根据 bool checked 值划掉 checkbox 文本。这是使用 QFont 类完成的。我们创建一个从 checkbox->font() 的副本字体,修改它,并将其重新分配给 ui->checkbox。如果原始的 font 是加粗的,具有特殊大小,其外观将保证保持不变。
提示
在 Qt Designer 中玩转字体对象。选择 Task.ui 文件中的 checkbox 并转到 属性编辑器 | QWidget | 字体。
最后一条指令通知 MainWindow 任务的状态已更改。信号名称是 statusChanged,而不是 checkboxChecked,以隐藏任务的实现细节。在 MainWindow.h 文件中添加以下代码:
// In MainWindow.h
public:
void updateStatus();
public slots:
void addTask();
void removeTask(Task* task);
void taskStatusChanged(Task* task);
// In MainWindow.cpp
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow),
mTasks()
{
...
updateStatus();
}
}
void MainWindow::addTask()
{
...
if (ok && !name.isEmpty()) {
...
connect(task, &Task::removed, this,
&MainWindow::removeTask);
connect(task, &Task::statusChanged, this,
&MainWindow::taskStatusChanged);
mTasks.append(task);
ui->tasksLayout->addWidget(task);
updateStatus();
}
}
void MainWindow::removeTask(Task* task)
{
...
delete task;
updateStatus();
}
void MainWindow::taskStatusChanged(Task* /*task*/)
{
updateStatus();
}
void MainWindow::updateStatus()
{
int completedCount = 0;
for(auto t : mTasks) {
if (t->isCompleted()) {
completedCount++;
}
}
int todoCount = mTasks.size() - completedCount;
ui->statusLabel->setText(
QString("Status: %1 todo / %2 completed")
.arg(todoCount)
.arg(completedCount));
}
我们定义了一个槽 taskStatusChanged,当任务创建时与之连接。这个 slot 的单一指令是调用 updateStatus()。这个函数遍历任务并更新 statusLabel。updateStatus() 函数在任务创建和删除时被调用。
在 updateStatus() 中,我们遇到了更多新的 C++11 语义:
for(auto t : mTasks) {
...
}
for 关键字让我们可以遍历基于范围的容器。因为 QVector 是一个可迭代的容器,所以我们可以在这里使用它。范围声明(auto t)是每次迭代将被分配的类型和变量名。范围表达式(mTasks)简单地是执行过程将进行的容器。Qt 为 C++ 早期版本提供了一个针对 for 循环(即 foreach)的定制实现;你不再需要它了。
auto 关键字是另一个伟大的新语义。编译器根据初始化器自动推断变量类型。它为诸如这样的神秘迭代器减轻了许多痛苦:
std::vector::const_iterator iterator = mTasks.toStdVector()
.stdTasks.begin();
// how many neurones did you save?
auto autoIter = stdTasks.begin();
自 C++14 以来,auto 甚至可以用于函数返回类型。这是一个了不起的工具,但请谨慎使用。如果你使用 auto,类型应该从签名名称/变量名中明显可见。
小贴士
auto 关键字可以与 const 和引用结合使用。你可以编写这样的 for 循环:for (const auto & t : mTasks) { ... }。
记得我们之前提到的半面包 lambda 吗?有了所有这些特性,我们可以写出:
auto prettyName = [] (const QString& taskName) -> QString {
return "-------- " + taskName.toUpper();
};
connect(ui->removeButton, &QPushButton::clicked,
[this, name, prettyName] {
qDebug() << "Trying to remove" << prettyName(name);
this->emit removed(this);
});
现在真是一件美丽的事情。将 auto 与 lambda 结合使用,可以编写出非常易读的代码,并开启了一个充满可能性的世界。
最后要学习的是 QString API。我们在 updateStatus() 中使用了它:
ui->statusLabel->setText(
QString("Status: %1 todo / %2 completed")
.arg(todoCount)
.arg(completedCount));
Qt 背后的开发者们投入了大量工作,使得在 C++ 中进行字符串操作变得可行。这是一个完美的例子,我们用更现代、更健壮的 API 替换了经典的 C sprintf。参数仅基于位置,无需指定类型(更不易出错),并且 arg(...) 函数接受所有类型的参数。
小贴士
花些时间浏览一下 doc.qt.io/qt-5/qstring.html 上的 QString 文档。它展示了你可以用这个类做多少事情,你也会发现自己使用 std string 或 cstring 的例子越来越少。
摘要
在本章中,我们从零开始创建了一个桌面 Qt 应用程序。Qt 以其信号/槽机制而闻名,你必须对这种范式有信心。我们还介绍了一些重要的 C++14 特性,这些特性将贯穿整本书。
现在是时候发现一些 qmake 的秘密了,以及当你构建 Qt 项目时实际上会发生什么。在下一章中,我们还将讨论如何创建和组织一个应用程序,其中包含必须在 Windows、Mac OS 和 Linux 上运行的与平台相关的代码。
第二章。探索 QMake 秘密
本章讨论了创建依赖特定平台代码的跨平台应用程序的问题。我们将看到 qmake 对项目编译的影响。
你将学习如何创建一个系统监控应用程序,该程序可以从 Windows、Linux 和 Mac 中检索平均 CPU 负载和内存使用情况。对于这类依赖于操作系统的应用程序,架构是保持应用程序可靠和可维护的关键。
在本章结束时,您将能够创建和组织一个使用特定平台代码并显示 Qt 图表小部件的跨平台应用程序。此外,qmake 将不再是一个谜。
本章涵盖了以下主题:
-
特定平台代码组织
-
设计模式、策略和单例
-
抽象类和纯虚函数
-
Qt 图表
-
qmake 工具
设计跨平台项目
我们想要显示一些可视化仪表和图表小部件,因此创建一个新的 Qt 小部件应用程序,名为 ch02-sysinfo。正如在 第一章 中所讨论的,“初识 Qt”,Qt Creator 将为我们生成一些文件:main.cpp、MainWindow.h、MainWindow.cpp 和 MainWindow.ui。
在深入 C++ 代码之前,我们必须考虑软件的架构。本项目将处理多个桌面平台。得益于 C++ 和 Qt 的结合,大部分源代码将适用于所有目标。然而,为了从操作系统(操作系统)中检索 CPU 和内存使用情况,我们将使用一些特定平台的代码。
为了成功完成这项任务,我们将使用两种设计模式:
-
策略模式:这是一个描述功能(例如,检索 CPU 使用情况)的接口,具体行为(在 Windows/Mac OS/Linux 上检索 CPU 使用情况)将在实现此接口的子类中执行。
-
单例模式:此模式确保给定类只有一个实例。此实例将通过一个唯一的访问点轻松访问。
如您在以下图中所见,类 SysInfo 是我们与策略模式的接口,也是一个单例。策略模式的具体行为在 SysInfoWindowsImpl、SysInfoMacImpl 和 SysInfoLinuxImpl 类中执行,这些类是 SysInfo 的子类:

UI 部分只知道并使用 SysInfo 类。特定平台的实现类由 SysInfo 类实例化,调用者不需要了解 SysInfo 子类的任何信息。由于 SysInfo 类是单例,对所有小部件的访问将更加容易。
让我们从创建SysInfo类开始。在 Qt Creator 中,你可以通过在层次视图中的项目名称上右键单击的上下文菜单创建一个新的 C++类。然后点击添加新项选项,或者从菜单中,转到文件 | 新建文件或项目 | 文件和类。然后执行以下步骤:
-
前往C++类 | 选择。
-
将类名字段设置为
SysInfo。由于这个类没有从其他类继承,我们不需要使用基类字段。 -
点击下一步,然后完成以生成一个空的 C++类。
我们将通过添加三个纯虚函数来指定我们的接口:init()、cpuLoadAverage()和memoryUsed():
// In SysInfo.h
class SysInfo
{
public:
SysInfo();
virtual ~SysInfo();
virtual void init() = 0;
virtual double cpuLoadAverage() = 0;
virtual double memoryUsed() = 0;
};
// In SysInfo.cpp
#include "SysInfo.h"
SysInfo::SysInfo()
{
}
SysInfo::~SysInfo()
{
}
这些函数各自有特定的角色:
-
init():这个函数允许派生类根据操作系统平台执行任何初始化过程。 -
cpuLoadAverage():这个函数调用一些特定于操作系统的代码来获取平均 CPU 负载,并以百分比的形式返回。 -
memoryUsed():这个函数调用一些特定于操作系统的代码来获取内存使用情况,并以百分比的形式返回。
virtual关键字表示该函数可以在派生类中被覆盖。= 0语法表示这个函数是纯虚的,必须在任何具体的派生类中覆盖。此外,这使得SysInfo成为一个抽象类,不能被实例化。
我们还添加了一个空的虚析构函数。这个析构函数必须是虚的,以确保从基类指针删除派生类的实例时,将调用派生类析构函数,而不仅仅是基类析构函数。
现在,我们的SysInfo类是一个抽象类,并准备好被派生,我们将描述三种实现:Windows、Mac OS 和 Linux。如果你不想使用其他两个,你也可以只执行一个实现。我们不会对此做出任何评判。在添加实现后,SysInfo类将被转换成一个单例。
添加 Windows 实现
记得本章开头提到的 UML 图吗?SysInfoWindowsImpl类是SysInfo类派生出的一个类。这个类的主要目的是封装获取 CPU 和内存使用情况的 Windows 特定代码。
是时候创建SysInfoWindowsImpl类了。为了做到这一点,你需要执行以下步骤:
-
在层次视图中的
ch02-sysinfo项目名称上右键单击。 -
点击添加新项 | C++类 | 选择。
-
将类名字段设置为
SysInfoWindowsImpl。 -
将基类字段设置为
,并在 SysInfo类下编写。 -
点击下一步然后完成以生成一个空的 C++类。
这些生成的文件是一个好的起点,但我们必须调整它们:
#include "SysInfo.h"
class SysInfoWindowsImpl : public SysInfo
{
public:
SysInfoWindowsImpl();
void init() override;
double cpuLoadAverage() override;
double memoryUsed() override;
};
首件事是向我们的父类SysInfo添加include指令。现在你可以覆盖基类中定义的虚函数。
小贴士
Qt 小贴士
将您的光标放在派生类名称(在 class 关键字之后)上,然后按 Alt + Enter(Windows / Linux)或 Command + Enter(Mac)来自动插入基类的虚拟函数。
override 关键字来自 C++11。它确保函数在基类中被声明为虚拟的。如果标记为 override 的函数签名与任何父类的 virtual 函数不匹配,将显示编译时错误。
在 Windows 上检索当前使用的内存很容易。我们将从 SysInfoWindowsImpl.cpp 文件中的这个功能开始:
#include "SysInfoWindowsImpl.h"
#include <windows.h>
SysInfoWindowsImpl::SysInfoWindowsImpl() :
SysInfo(),
{
}
double SysInfoWindowsImpl::memoryUsed()
{
MEMORYSTATUSEX memoryStatus;
memoryStatus.dwLength = sizeof(MEMORYSTATUSEX);
GlobalMemoryStatusEx(&memoryStatus);
qulonglong memoryPhysicalUsed =
memoryStatus.ullTotalPhys - memoryStatus.ullAvailPhys;
return (double)memoryPhysicalUsed /
(double)memoryStatus.ullTotalPhys * 100.0;
}
不要忘记包含 windows.h 文件,这样我们就可以使用 Windows API!实际上,这个函数检索总物理内存和可用物理内存。简单的减法给出了使用的内存量。根据基类 SysInfo 的要求,此实现将返回值作为 double 类型;例如,Windows 操作系统上使用 23% 的内存值为 23.0。
检索总使用的内存是一个好的开始,但我们不能就此停止。我们的类还必须检索 CPU 负载。Windows API 有时可能会很混乱。为了使我们的代码更易于阅读,我们将创建两个私有辅助函数。更新您的 SysInfoWindowsImpl.h 文件以匹配以下片段:
#include <QtGlobal>
#include <QVector>
#include "SysInfo.h"
typedef struct _FILETIME FILETIME;
class SysInfoWindowsImpl : public SysInfo
{
public:
SysInfoWindowsImpl();
void init() override;
double cpuLoadAverage() override;
double memoryUsed() override;
private:
QVector<qulonglong> cpuRawData();
qulonglong convertFileTime(const FILETIME& filetime) const;
private:
QVector<qulonglong> mCpuLoadLastValues;
};
让我们分析这些变化:
-
cpuRawData()是一个将执行 Windows API 调用来检索系统计时信息并以通用格式返回值的函数。我们将检索并返回三个值:系统在空闲、内核和用户模式中花费的时间量。 -
convertFileTime()函数是我们的第二个辅助函数。它将 Windows 的FILETIME结构语法转换为qulonglong类型。qulonglong类型是 Qt 的unsigned long long int。Qt 保证在所有平台上都是 64 位的。您也可以使用 typedefquint64。 -
mCpuLoadLastValues是一个变量,它将存储在某一时刻的系统计时(空闲、内核和用户)。 -
不要忘记包含
<QtGlobal>标签以使用qulonglong类型,以及<QVector>标签以使用QVector类。 -
语法
typedef struct _FILETIME FILETIME是对FILENAME语法的某种前置声明。由于我们只使用引用,我们可以在我们的文件SysInfoWindowsImpl.h中避免包含<windows.h>标签,并将其保留在 CPP 文件中。
我们现在可以切换到文件 SysInfoWindowsImpl.cpp 并实现这些函数,以完成 Windows 上的 CPU 平均负载功能:
#include "SysInfoWindowsImpl.h"
#include <windows.h>
SysInfoWindowsImpl::SysInfoWindowsImpl() :
SysInfo(),
mCpuLoadLastValues()
{
}
void SysInfoWindowsImpl::init()
{
mCpuLoadLastValues = cpuRawData();
}
当调用 init() 函数时,我们将 cpuRawData() 函数的返回值存储在我们的类变量 mCpuLoadLastValues 中。这将对 cpuLoadAverage() 函数的处理有所帮助。
你可能想知道为什么我们不在这个构造函数的初始化列表中执行这个任务。那是因为当你从初始化列表中调用一个函数时,对象还没有完全构造完成!在某些情况下,这可能是不安全的,因为函数可能会尝试访问尚未构造的成员变量。然而,在这个 ch02-sysinfo 项目中,cpuRawData 函数没有使用任何成员变量,所以如果你真的想这么做,你可以安全地将 cpuRawData() 函数添加到 SysInfoWindowsImpl.cpp 文件中:
QVector<qulonglong> SysInfoWindowsImpl::cpuRawData()
{
FILETIME idleTime;
FILETIME kernelTime;
FILETIME userTime;
GetSystemTimes(&idleTime, &kernelTime, &userTime);
QVector<qulonglong> rawData;
rawData.append(convertFileTime(idleTime));
rawData.append(convertFileTime(kernelTime));
rawData.append(convertFileTime(userTime));
return rawData;
}
这里就是:调用 GetSystemTimes 函数的 Windows API!这个函数会给我们提供系统空闲、内核模式和用户模式下所花费的时间量。在填充 QVector 类之前,我们使用以下代码中描述的辅助函数 convertFileTime 将每个值进行转换:
qulonglong SysInfoWindowsImpl::convertFileTime(const FILETIME& filetime) const
{
ULARGE_INTEGER largeInteger;
largeInteger.LowPart = filetime.dwLowDateTime;
largeInteger.HighPart = filetime.dwHighDateTime;
return largeInteger.QuadPart;
}
Windows 结构 FILEFTIME 在两个 32 位部分(低和高)上存储 64 位信息。我们的函数 convertFileTime 使用 Windows 结构 ULARGE_INTEGER 在返回之前正确地在单个部分中构建一个 64 位值,然后将其作为 qulonglong 类型返回。最后但同样重要的是,cpuLoadAverage() 的实现:
double SysInfoWindowsImpl::cpuLoadAverage()
{
QVector<qulonglong> firstSample = mCpuLoadLastValues;
QVector<qulonglong> secondSample = cpuRawData();
mCpuLoadLastValues = secondSample;
qulonglong currentIdle = secondSample[0] - firstSample[0];
qulonglong currentKernel = secondSample[1] - firstSample[1];
qulonglong currentUser = secondSample[2] - firstSample[2];
qulonglong currentSystem = currentKernel + currentUser;
double percent = (currentSystem - currentIdle) * 100.0 /
currentSystem ;
return qBound(0.0, percent, 100.0);
}
这里有三个需要注意的重要点:
-
请记住,样本是一个绝对的时间量,所以减去两个不同的样本将给出瞬时值,这些值可以被处理以获取当前的 CPU 负载。
-
第一个样本来自我们的成员变量
mCpuLoadLastValues,它是在init()函数第一次探测时获取的。第二个样本是在调用cpuLoadAverage()函数时检索到的。初始化样本后,mCpuLoadLastValues变量可以存储将被用于下一次调用的新样本。 -
percent方程可能有点棘手,因为从 Windows API 检索到的内核值也包含了空闲值。
小贴士
如果你想了解更多关于 Windows API 的信息,请查看 MSDN 文档,链接为msdn.microsoft.com/library。
完成 Windows 实现的最后一步是编辑文件 ch02-sysinfo.pro,使其类似于以下片段:
QT += core gui
CONFIG += C++14
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
TARGET = ch02-sysinfo
TEMPLATE = app
SOURCES += main.cpp \
MainWindow.cpp \
SysInfo.cpp
HEADERS += MainWindow.h \
SysInfo.h
windows {
SOURCES += SysInfoWindowsImpl.cpp
HEADERS += SysInfoWindowsImpl.h
}
FORMS += MainWindow.ui
正如我们在 ch01-todo 项目中所做的那样,我们也在 ch02-sysinfo 项目中使用了 C++14。这里真正的新点是,我们将文件 SysInfoWindowsImpl.cpp 和 SysInfoWindowsImpl.h 从公共的 SOURCES 和 HEADERS 变量中移除。实际上,我们将它们添加到了 windows 平台作用域中。当为其他平台构建时,这些文件将不会被 qmake 处理。这就是为什么我们可以在 SysInfoWindowsImpl.cpp 源文件中安全地包含特定的头文件,如 windows.h,而不会损害其他平台的编译。
添加 Linux 实现
让我们开始实现我们的 ch02-sysinfo 项目的 Linux 版本。如果你已经完成了 Windows 版本的实现,这将是一件轻而易举的事情!如果你还没有,你应该看看它。本部分中不会重复一些信息和技巧,例如如何创建 SysInfo 实现类、键盘快捷键以及 SysInfo 接口的详细信息。
创建一个新的 C++ 类 SysInfoLinuxImpl,它继承自 SysInfo 类,并插入基类中的虚拟函数:
#include "SysInfo.h"
class SysInfoLinuxImpl : public SysInfo
{
public:
SysInfoLinuxImpl();
void init() override;
double cpuLoadAverage() override;
double memoryUsed() override;
};
我们将首先在文件 SysInfoLinuxImpl.cpp 中实现 memoryUsed() 函数:
#include "SysInfoLinuxImpl.h"
#include <sys/types.h>
#include <sys/sysinfo.h>
SysInfoLinuxImpl::SysInfoLinuxImpl() :
SysInfo(),
{
}
double SysInfoLinuxImpl::memoryUsed()
{
struct sysinfo memInfo;
sysinfo(&memInfo);
qulonglong totalMemory = memInfo.totalram;
totalMemory += memInfo.totalswap;
totalMemory *= memInfo.mem_unit;
qulonglong totalMemoryUsed = memInfo.totalram - memInfo.freeram;
totalMemoryUsed += memInfo.totalswap - memInfo.freeswap;
totalMemoryUsed *= memInfo.mem_unit;
double percent = (double)totalMemoryUsed /
(double)totalMemory * 100.0;
return qBound(0.0, percent, 100.0);
}
这个函数使用 Linux 特定的 API。在添加所需的包含文件后,你可以使用返回整体系统统计信息的 Linux sysinfo() 函数。有了总内存和已用内存,我们可以轻松地返回 percent 值。请注意,交换内存已被考虑在内。
CPU 负载功能比内存功能稍微复杂一些。实际上,我们将从 Linux 检索 CPU 执行不同类型工作的总耗时。这并不是我们想要的。我们必须返回瞬时 CPU 负载。获取它的常见方法是在短时间内检索两个样本值,并使用差异来获取瞬时 CPU 负载:
#include <QtGlobal>
#include <QVector>
#include "SysInfo.h"
class SysInfoLinuxImpl : public SysInfo
{
public:
SysInfoLinuxImpl();
void init() override;
double cpuLoadAverage() override;
double memoryUsed() override;
private:
QVector<qulonglong> cpuRawData();
private:
QVector<qulonglong> mCpuLoadLastValues;
};
在这个实现中,我们只添加一个辅助函数和一个成员变量:
-
cpuRawData()是一个将执行 Linux API 调用来检索系统时间信息并以qulonglong类型的QVector返回值的函数。我们检索并返回四个值,包含 CPU 在以下方面的耗时:用户模式下的普通进程、用户模式下的优先进程、内核模式下的进程和空闲时间。 -
mCpuLoadLastValues是一个变量,它将存储在给定时刻的系统时间样本。
让我们转到 SysInfoLinuxImpl.cpp 文件来更新它:
#include "SysInfoLinuxImpl.h"
#include <sys/types.h>
#include <sys/sysinfo.h>
#include <QFile>
SysInfoLinuxImpl::SysInfoLinuxImpl() :
SysInfo(),
mCpuLoadLastValues()
{
}
void SysInfoLinuxImpl::init()
{
mCpuLoadLastValues = cpuRawData();
}
如前所述,cpuLoadAverage 函数需要两个样本才能计算瞬时 CPU 负载平均值。调用 init() 函数允许我们第一次设置 mCpuLoadLastValues:
QVector<qulonglong> SysInfoLinuxImpl::cpuRawData()
{
QFile file("/proc/stat");
file.open(QIODevice::ReadOnly);
QByteArray line = file.readLine();
file.close();
qulonglong totalUser = 0, totalUserNice = 0,
totalSystem = 0, totalIdle = 0;
std::sscanf(line.data(), "cpu %llu %llu %llu %llu",
&totalUser, &totalUserNice, &totalSystem,
&totalIdle);
QVector<qulonglong> rawData;
rawData.append(totalUser);
rawData.append(totalUserNice);
rawData.append(totalSystem);
rawData.append(totalIdle);
return rawData;
}
要在 Linux 系统上检索 CPU 的原始信息,我们选择解析 /proc/stat 文件中可用的信息。所有需要的信息都在第一行,所以只需要一个 readLine() 就足够了。尽管 Qt 提供了一些有用的功能,但有时 C 标准库函数更简单。这里就是这样;我们正在使用 std::sscanf 从字符串中提取变量。现在让我们看看 cpuLoadAverage() 函数的实现:
double SysInfoLinuxImpl::cpuLoadAverage()
{
QVector<qulonglong> firstSample = mCpuLoadLastValues;
QVector<qulonglong> secondSample = cpuRawData();
mCpuLoadLastValues = secondSample;
double overall = (secondSample[0] - firstSample[0])
+ (secondSample[1] - firstSample[1])
+ (secondSample[2] - firstSample[2]);
double total = overall + (secondSample[3] - firstSample[3]);
double percent = (overall / total) * 100.0;
return qBound(0.0, percent, 100.0);
}
这里发生了魔法。在这个最后的函数中,我们将所有的拼图碎片组合在一起。这个函数使用了两个 CPU 原始数据样本。第一个样本来自我们的成员变量 mCpuLoadLastValues,它由 init() 函数首次设置。第二个样本由 cpuLoadAverage() 函数请求。然后 mCpuLoadLastValues 变量将存储新的样本,该样本将在下一次 cpuLoadAverage() 函数调用时用作第一个样本。
percent 方程应该很容易理解:
-
overall等于 user + nice + kernel -
total等于 overall + idle
小贴士
您可以在 Linux 内核文档中找到更多关于 /proc/stat 的信息,请参阅 www.kernel.org/doc/Documentation/filesystems/proc.txt。
与其他实现一样,最后要做的就是编辑 ch02-sysinfo.pro 文件,如下所示:
QT += core gui
CONFIG += C++14
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
TARGET = ch02-sysinfo
TEMPLATE = app
SOURCES += main.cpp \
MainWindow.cpp \
SysInfo.cpp \
CpuWidget.cpp \
MemoryWidget.cpp \
SysInfoWidget.cpp
HEADERS += MainWindow.h \
SysInfo.h \
CpuWidget.h \
MemoryWidget.h \
SysInfoWidget.h
windows {
SOURCES += SysInfoWindowsImpl.cpp
HEADERS += SysInfoWindowsImpl.h
}
linux {
SOURCES += SysInfoLinuxImpl.cpp
HEADERS += SysInfoLinuxImpl.h
}
FORMS += MainWindow.ui
在 ch02-sysinfo.pro 文件中,使用这个 Linux 范围条件,我们的 Linux 特定文件将不会被其他平台上的 qmake 命令处理。
添加 Mac OS 实现
让我们看看 SysInfo 类的 Mac 实现。首先创建一个名为 SysInfoMacImpl 的新 C++ 类,它继承自 SysInfo 类。覆盖 SysInfo 虚拟函数,您应该有一个类似于这样的 SysInfoMacImpl.h 文件:
#include "SysInfo.h"
#include <QtGlobal>
#include <QVector>
class SysInfoMacImpl : public SysInfo
{
public:
SysInfoMacImpl();
void init() override;
double cpuLoadAverage() override;
double memoryUsed() override;
};
我们将要做的第一个实现将是 memoryUsed() 函数,在 SysInfoMacImpl.cpp 文件中:
#include <mach/vm_statistics.h>
#include <mach/mach_types.h>
#include <mach/mach_init.h>
#include <mach/mach_host.h>
#include <mach/vm_map.h>
SysInfoMacImpl::SysInfoMacImpl() :
SysInfo()
{
}
double SysInfoMacImpl::memoryUsed()
{
vm_size_t pageSize;
vm_statistics64_data_t vmStats;
mach_port_t machPort = mach_host_self();
mach_msg_type_number_t count = sizeof(vmStats)
/ sizeof(natural_t);
host_page_size(machPort, &pageSize);
host_statistics64(machPort,
HOST_VM_INFO,
(host_info64_t)&vmStats,
&count);
qulonglong freeMemory = (int64_t)vmStats.free_count
* (int64_t)pageSize;
qulonglong totalMemoryUsed = ((int64_t)vmStats.active_count +
(int64_t)vmStats.inactive_count +
(int64_t)vmStats.wire_count)
* (int64_t)pageSize;
qulonglong totalMemory = freeMemory + totalMemoryUsed;
double percent = (double)totalMemoryUsed
/ (double)totalMemory * 100.0;
return qBound(0.0, percent, 100.0);
}
我们首先包含 Mac OS 内核的不同头文件。然后通过调用 mach_host_self() 函数初始化 machPort。machPort 是一种特殊的连接到内核的方式,使我们能够请求有关系统的信息。然后我们继续准备其他变量,以便我们可以使用 host_statistics64() 函数检索虚拟内存统计信息。
当 vmStats 类填充了所需的信息时,我们提取相关数据:freeMemory 和 totalMemoryUsed。
注意,Mac OS 有一种独特的内存管理方式:它保留了很多内存在缓存中,以便在需要时刷新。这意味着我们的统计数据可能会误导;我们看到内存被使用,而实际上它只是被保留“以防万一”。
百分比计算很简单;我们仍然返回一个 min/max 压缩值,以避免未来图表中出现任何疯狂值。
接下来是 cpuLoadAverage() 的实现。模式始终相同;在固定间隔内取样本并计算该间隔的增长。因此,我们必须存储中间值,以便能够计算与下一个样本的差异:
// In SysInfoMacImpl.h
#include "SysInfo.h"
#include <QtGlobal>
#include <QVector>
...
private:
QVector<qulonglong> cpuRawData();
private:
QVector<qulonglong> mCpuLoadLastValues;
};
// In SysInfoMacImpl.cpp
void SysInfoMacImpl::init()
{
mCpuLoadLastValues = cpuRawData();
}
QVector<qulonglong> SysInfoMacImpl::cpuRawData()
{
host_cpu_load_info_data_t cpuInfo;
mach_msg_type_number_t cpuCount = HOST_CPU_LOAD_INFO_COUNT;
QVector<qulonglong> rawData;
qulonglong totalUser = 0, totalUserNice = 0, totalSystem = 0, totalIdle = 0;
host_statistics(mach_host_self(),
HOST_CPU_LOAD_INFO,
(host_info_t)&cpuInfo,
&cpuCount);
for(unsigned int i = 0; i < cpuCount; i++) {
unsigned int maxTicks = CPU_STATE_MAX * i;
totalUser += cpuInfo.cpu_ticks[maxTicks + CPU_STATE_USER];
totalUserNice += cpuInfo.cpu_ticks[maxTicks
+ CPU_STATE_SYSTEM];
totalSystem += cpuInfo.cpu_ticks[maxTicks
+ CPU_STATE_NICE];
totalIdle += cpuInfo.cpu_ticks[maxTicks + CPU_STATE_IDLE];
}
rawData.append(totalUser);
rawData.append(totalUserNice);
rawData.append(totalSystem);
rawData.append(totalIdle);
return rawData;
}
如您所见,使用的模式严格等同于 Linux 实现。您甚至可以直接复制粘贴 SysInfoLinuxImpl.cpp 文件中 cpuLoadAverage() 函数的主体。它们执行的是完全相同的事情。
现在,对于cpuRawData()函数的实现是不同的。我们使用host_statistics()函数加载cpuInfo和cpuCount,然后我们遍历每个 CPU,使totalUser、totalUserNice、totalSystem和totalIdle函数得到填充。最后,我们在返回之前将所有这些数据追加到rawData对象中。
最后的部分是将SysInfoMacImpl类仅在 Mac OS 上编译。修改.pro文件,使其具有以下内容:
...
linux {
SOURCES += SysInfoLinuxImpl.cpp
HEADERS += SysInfoLinuxImpl.h
}
macx {
SOURCES += SysInfoMacImpl.cpp
HEADERS += SysInfoMacImpl.h
}
FORMS += MainWindow.ui
将 SysInfo 转换为单例
承诺必须遵守:我们现在将SysInfo类转换为单例。C++提供了许多实现单例设计模式的方法。我们在这里将解释其中的一种。打开SysInfo.h文件并做出以下更改:
class SysInfo
{
public:
static SysInfo& instance();
virtual ~SysInfo();
virtual void init() = 0;
virtual double cpuLoadAverage() = 0;
virtual double memoryUsed() = 0;
protected:
explicit SysInfo();
private:
SysInfo(const SysInfo& rhs);
SysInfo& operator=(const SysInfo& rhs);
};
单例必须保证只有一个类的实例,并且这个实例可以从单个访问点轻松访问。
因此,首先要做的是将构造函数的可见性更改为protected。这样,只有这个类及其子类才能调用构造函数。
由于必须只有一个对象实例存在,允许复制构造函数和赋值运算符是没有意义的。解决这个问题的方法之一是将它们设置为private。
小贴士
C++技巧
自 C++11 以来,你可以使用语法void myFunction() = delete定义一个已删除的函数。任何使用已删除函数的操作都将显示编译时错误。这是防止使用单例的复制构造函数和赋值运算符的另一种方法。
最后的更改是“唯一访问点”,它有一个static函数实例,将返回SysInfo类的引用。
现在是时候将单例更改提交到SysInfo.cpp文件中:
#include <QtGlobal>
#ifdef Q_OS_WIN
#include "SysInfoWindowsImpl.h"
#elif defined(Q_OS_MAC)
#include "SysInfoMacImpl.h"
#elif defined(Q_OS_LINUX)
#include "SysInfoLinuxImpl.h"
#endif
SysInfo& SysInfo::instance()
{
#ifdef Q_OS_WIN
static SysInfoWindowsImpl singleton;
#elif defined(Q_OS_MAC)
static SysInfoMacImpl singleton;
#elif defined(Q_OS_LINUX)
static SysInfoLinuxImpl singleton;
#endif
return singleton;
}
SysInfo::SysInfo()
{
}
SysInfo::~SysInfo()
{
}
在这里,你可以看到另一个 Qt 跨平台技巧。Qt 提供了一些宏Q_OS_WIN、Q_OS_LINUX或Q_OS_MAC。只有对应操作系统的 Qt OS 宏才会被定义。通过将这些宏与条件预处理器指令#ifdef结合使用,我们可以在所有操作系统上始终包含和实例化正确的SysInfo实现。
将singleton变量声明为instance()函数中的静态变量是 C++中创建单例的一种方法。我们倾向于选择这个版本,因为你不需要担心单例的内存管理。编译器将处理第一次实例化和销毁。此外,自 C++11 以来,这种方法是线程安全的。
探索 Qt 图表
核心部分已经准备好了。现在是时候为这个项目创建一个用户界面了,Qt Charts 可以帮助我们完成这个任务。Qt Charts 是一个模块,它提供了一套易于使用的图表组件,例如折线图、面积图、样条图、饼图等等。
Qt Charts 之前是一个仅限商业的 Qt 模块。从 Qt 5.7 开始,该模块现在包含在 Qt 中,对于开源用户,它是在 GPLv3 许可证下。如果你卡在 Qt 5.6 上,你可以从源代码构建该模块。更多信息可以在 github.com/qtproject/qtcharts 找到。
目前的目标是创建两个 Qt 控件,CpuWidget 和 MemoryWidget,以显示 CPU 和内存使用的漂亮的 Qt 图表。这两个控件将共享许多共同的任务,因此我们首先创建一个抽象类,SysInfoWidget:

然后,这两个实际的控件将从 SysInfoWidget 类继承并执行它们特定的任务。
创建一个名为 SysInfoWidget 的新 C++ 类,以 QWidget 作为基类。在 SysInfoWidget.h 文件中必须处理一些增强:
#include <QWidget>
#include <QTimer>
#include <QtCharts/QChartView>
class SysInfoWidget : public QWidget
{
Q_OBJECT
public:
explicit SysInfoWidget(QWidget *parent = 0,
int startDelayMs = 500,
int updateSeriesDelayMs = 500);
protected:
QtCharts::QChartView& chartView();
protected slots:
virtual void updateSeries() = 0;
private:
QTimer mRefreshTimer;
QtCharts::QChartView mChartView;
};
QChartView 是一个通用的控件,可以显示多种类型的图表。这个类将处理布局并显示 QChartView。一个 QTimer 将会定期调用 updateSeries() 槽函数。正如你所见,这是一个纯虚槽。这就是为什么 SysInfoWidget 类是抽象的。updateSeries() 槽函数将被子类覆盖以检索系统值并定义图表应该如何绘制。请注意,参数 startDelayMs 和 updateSeriesDelayMs 有默认值,如果需要,调用者可以自定义这些值。
我们现在可以继续到 SysInfoWidget.cpp 文件,在创建子控件之前正确准备这个 SysInfoWidget 类:
#include <QVBoxLayout>
using namespace QtCharts;
SysInfoWidget::SysInfoWidget(QWidget *parent,
int startDelayMs,
int updateSeriesDelayMs) :
QWidget(parent),
mChartView(this)
{
mRefreshTimer.setInterval(updateSeriesDelayMs);
connect(&mRefreshTimer, &QTimer::timeout,
this, &SysInfoWidget::updateSeries);
QTimer::singleShot(startDelayMs,
[this] { mRefreshTimer.start(); });
mChartView.setRenderHint(QPainter::Antialiasing);
mChartView.chart()->legend()->setVisible(false);
QVBoxLayout* layout = new QVBoxLayout(this);
layout->addWidget(&mChartView);
setLayout(layout);
}
QChartView& SysInfoWidget::chartView()
{
return mChartView;
}
SysInfoWidget 构造函数中的所有任务都是子控件 CpuWidget 和 MemoryWidget 所需要的共同任务。第一步是初始化 mRefreshTimer 以定义计时器间隔和每当超时信号被触发时调用的槽函数。然后,静态函数 QTimer::singleShot() 将在 startDelayMs 定义的延迟后启动真正的计时器。在这里,Qt 结合 lambda 函数将只使用几行代码就给我们一个强大的功能。下一部分启用了抗锯齿以平滑图表绘制。我们隐藏图表的图例以获得简约的显示。最后一部分处理布局以在 SysInfoWidget 类中显示 QChartView 控件。
使用 QCharts 的 CpuWidget
现在,基类 SysInfoWidget 已经准备好了,让我们实现它的第一个子类:CpuWidget。我们将现在使用 Qt Charts API 来显示一个好看的控件。平均 CPU 负载将以一个中心有洞的饼图显示,就像一个部分被吃掉的甜甜圈,被吃掉的部分是 CPU 使用率的百分比。第一步是添加一个名为 CpuWidget 的新 C++ 类,并使其继承 SysInfoWidget:
#include "SysInfoWidget.h"
class CpuWidget : public SysInfoWidget
{
public:
explicit CpuWidget(QWidget* parent = 0);
};
在构造函数中,唯一需要的参数是 QWidget* parent。由于我们在 SysInfoWidget 类中为 startDelayMs 和 updateSeriesDelayMs 变量提供了默认值,我们得到了最佳的行为;在子类化 SysInfoWidget 时不需要记住它,但如果需要,仍然可以轻松地覆盖它。
下一步是重写 SysInfoWidget 类中的 updateSeries() 函数并开始使用 Qt Charts API:
#include <QtCharts/QpieSeries>
#include "SysInfoWidget.h"
class CpuWidget : public SysInfoWidget
{
Q_OBJECT
public:
explicit CpuWidget(QWidget* parent = 0);
protected slots:
void updateSeries() override;
private:
QtCharts::QPieSeries* mSeries;
};
由于我们重写了 SysInfoWidget::updateSeries() 插槽,我们必须包含 Q_OBJECT 宏,以便 CPUWidget 能够响应 SysInfoWidgetmRefreshTimer::timeout() 信号。
我们从 Qt Charts 模块中包含 QPieSeries,以便我们可以创建一个名为 mSeries 的成员 QPieSeries*。QPieSeries 是 QAbstractSeries 的子类,它是所有 Qt Charts 系列的基类(QLineSeries、QAreaSeries、QPieSeries 等)。在 Qt Charts 中,QAbstractSeries 子类持有你想要显示的数据,并定义了它应该如何绘制,但它不定义数据应该在哪里显示在你的布局中。
我们现在可以继续到 CpuWidget.cpp 文件,调查我们如何告诉 Qt 绘图的位置:
using namespace QtCharts;
CpuWidget::CpuWidget(QWidget* parent) :
SysInfoWidget(parent),
mSeries(new QPieSeries(this))
{
mSeries->setHoleSize(0.35);
mSeries->append("CPU Load", 30.0);
mSeries->append("CPU Free", 70.0);
QChart* chart = chartView().chart();
chart->addSeries(mSeries);
chart->setTitle("CPU average load");
}
所有 Qt Charts 类都是在 QtCharts 命名空间中定义的。这就是为什么我们以 using namespace QtCharts 开始。
首先,我们在构造函数初始化列表中初始化 mSeries,然后继续配置它。我们使用 mSeries->setHoleSize(0.35) 切割甜甜圈,并将两个数据集添加到 mSeries:一个是假的 CPU Load 和 Cpu Free,它们以百分比表示。现在 mSeries 函数已经准备好与负责其绘制的类链接:QChart。
QChart 类是从 SysInfoWidget::chartView() 函数中检索的。当调用 chart->addSeries(mSeries) 时,chart 接管了 mSeries 的所有权,并将根据系列类型绘制它——在我们的例子中,是一个 QPieSeries。QChart 不是一个 QWidget:它是 QGraphicsWidget 的子类。QGraphicsWidget 可以被描述为一个比 QWidget 更轻量级的组件,有一些不同(它的坐标和几何形状使用 doubles 或 floats 而不是 integers 定义,支持 QWidget 属性的子集:自定义拖放框架等)。QGraphicsWidget 类被设计为添加到 QGraphicsScene 类中,这是一个高性能的 Qt 组件,用于同时绘制屏幕上的数百个项。
在我们的 SysInfo 应用程序中,QChart 必须在 SysInfoWidget 中的 QVBoxLayout 中显示。在这里,QChartView 类非常有用。它允许我们在 QWidget 布局中添加 chart。
到目前为止,QPieSeries 似乎相当抽象。让我们将它添加到 MainWindow 文件中,看看它的样子:
// In MainWindow.h
#include "CpuWidget.h"
...
private:
Ui::MainWindow *ui;
CpuWidget mCpuWidget;
};
// In MainWindow.cpp
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow),
mCpuWidget(this)
{
ui->setupUi(this);
SysInfo::instance().init();
ui->centralWidget->layout()->addWidget(&mCpuWidget);
}
我们只需在 MainWindow.h 文件中声明 mCpuWidget,初始化它,并将其添加到 MainWindow->centralWidget->layout。如果你现在运行应用程序,你应该看到类似这样的内容:

尽管看起来很酷,但这个甜甜圈有点静态,并不能反映 CPU 的使用情况。多亏了我们用SysInfo和SysInfoWidget类构建的架构,接下来的部分将迅速实现。
切换回CpuWidget.cpp文件,并实现具有以下主体的updateSeries()函数:
void CpuWidget::updateSeries()
{
double cpuLoadAverage = SysInfo::instance().cpuLoadAverage();
mSeries->clear();
mSeries->append("Load", cpuLoadAverage);
mSeries->append("Free", 100.0 - cpuLoadAverage);
}
首先,我们获取对SysInfo单例的引用。然后我们在cpuLoadAverage变量中检索当前的 CPU 平均负载。我们必须将此数据提供给我们的mSeries。mSeries对象是一个QPieCharts,这意味着我们只想获取当前 CPU 平均负载的快照。与这种类型的图表相比,过去的历史没有意义;这就是为什么我们使用mSeries->clear()语法清除mSeries数据,并附加cpuLoadAverage变量以及空闲部分(100.0 - cpuLoadAverage)。
值得注意的是,在CpuWidget类中,我们无需担心刷新问题。所有的工作都在SysInfoWidget子类中完成,该子类使用了QTimer类的所有功能和铃声。在SysInfoWidget子类中,我们只需专注于有价值的特定代码:应该显示哪些数据以及使用什么类型的图表来显示它。如果你查看整个CpuWidget类,它非常简短。下一个SysInfoWidget子类,MemoryWidget,也将非常简洁,并且易于实现。
使用 Qcharts 进行内存使用
我们的第二个SysInfoWidget是一个MemoryWidget类。这个小部件将显示数据的历史记录,以便我们可以看到内存消耗随时间的变化。为了显示这些数据,我们将使用来自 Qt Chart 模块的QLineSeries类。创建MemoryWidget类,并遵循我们为CpuWidget使用的相同模式:
#include <QtCharts/QLineSeries>
#include "SysInfoWidget.h"
class MemoryWidget : public SysInfoWidget
{
Q_OBJECT
public:
explicit MemoryWidget(QWidget *parent = 0);
protected slots:
void updateSeries() override;
private:
QtCharts::QLineSeries* mSeries;
qint64 mPointPositionX;
};
与QPieSeries*不同,mSeries是一种QLineSeries*类型,它将以与MemoryWidget.cpp非常相似的方式链接到chart对象:
#include "MemoryWidget.h"
#include <QtCharts/QAreaSeries>
using namespace QtCharts;
const int CHART_X_RANGE_COUNT = 50;
const int CHART_X_RANGE_MAX = CHART_X_RANGE_COUNT - 1;
MemoryWidget::MemoryWidget(QWidget *parent) :
SysInfoWidget(parent),
mSeries(new QlineSeries(this)),
mPointPositionX(0)
{
QAreaSeries* areaSeries = new QAreaSeries(mSeries);
QChart* chart = chartView().chart();
chart->addSeries(areaSeries);
chart->setTitle("Memory used");
chart->createDefaultAxes();
chart->axisX()->setVisible(false);
chart->axisX()->setRange(0, CHART_X_RANGE_MAX);
chart->axisY()->setRange(0, 100);
}
void MemoryWidget::updateSeries()
{
}
mSeries数据,如往常一样,在初始化列表中初始化。mPointPositionX是一个unsigned long long(使用 Qt 表示法qint64)变量,它将跟踪数据集的最后一个 X 位置。这个巨大的值用于确保mPointPositionX永远不会溢出。
然后,我们使用一个中间的areaSeries,它在QAreaSeries* areaSeries = new QareaSeries(mSeries)初始化时接管mSeries的所有权。然后areaSeries被添加到chart对象中,在chart->addSeries(areaSeries)处。我们不想在QChart中显示单一线条;相反,我们想显示一个表示已使用内存百分比的区域。这就是为什么我们使用areaSeries类型。尽管如此,我们仍然会在updateSeries()函数中向数据集添加新点时更新mSeries数据。areaSeries类型将自动处理它们并将它们传递给chart对象。
在chart->addSeries(areaSeries)之后,我们配置图表显示:
-
chart->createDefaultAxes()函数基于areaSeries类型创建一个 X 和 Y 轴。如果我们使用 3D 系列,createDefaultAxes()函数将添加一个 Z 轴。 -
使用
chart->axisX()->setVisible(false)(在轴底部显示中间值)隐藏 X 轴的刻度值。在我们的MemoryWidget类中,这个信息是不相关的。 -
为了定义我们想要显示的点数——显示历史的大小——我们调用
chart->axisX()->setRange(0, CHART_X_RANGE_MAX)。在这里,我们使用一个常量以便以后更容易修改这个值。看到文件顶部的值,我们避免了在MemoryWidget.cpp中搜索这个值用于更新的需要。 -
chart->axisY()->setRange(0, 100)定义了 Y 轴的最大范围,这是一个百分比,基于SysInfo::memoryUsed()函数返回的值。
图表现在已正确配置。我们现在必须通过填充 updateSeries() 主体来给它提供数据:
void MemoryWidget::updateSeries()
{
double memoryUsed = SysInfo::instance().memoryUsed();
mSeries->append(mPointPositionX++, memoryUsed);
if (mSeries->count() > CHART_X_RANGE_COUNT) {
QChart* chart = chartView().chart();
chart->scroll(chart->plotArea().width()
/ CHART_X_RANGE_MAX, 0);
mSeries->remove(0);
}
}
我们首先检索最新的内存使用百分比,并将其追加到 mSeries 的 X 坐标 mPointPositionX(我们后增量它以供下一次 updateSeries() 调用使用)和 Y 坐标 memoryUsed。由于我们想保留 mSeries 的历史记录,mSeries->clear() 从未调用。然而,当我们添加超过 CHART_X_RANGE_COUNT 个点时会发生什么?图表上的可见“窗口”是静态的,点将被添加到外部。这意味着我们只能看到前 CHART_X_RANGE_MAX 个点的内存使用情况,然后就没有了。
幸运的是,QChart 提供了一个在视图中滚动以移动可见窗口的功能。我们只有在数据集大于可见窗口时才开始处理这种情况,即 if (mSeries->count() > CHART_X_RANGE_COUNT)。然后我们使用 mSeries->remove(0) 移除索引 0 处的点,以确保小部件不会存储无限的数据集。一个监控内存使用并自身存在内存泄漏的 SysInfo 应用程序有点令人沮丧。
语法 chart->scroll(chart->plotArea().width() / CHART_X_RANGE_MAX, 0) 将滚动到 X 轴上的最新点,而 Y 轴上没有点。chart->scroll(dx, dy) 期望使用我们的系列坐标表示的坐标。这就是为什么我们必须检索 char->plotArea() 除以 CHART_X_RANGE_MAX,即 X 轴单位。
我们现在可以在 MainWindow 中添加 MemoryWidget 类:
// In MainWindow.h
#include "CpuWidget.h"
#include "MemoryWidget.h"
...
private:
Ui::MainWindow *ui;
CpuWidget mCpuWidget;
MemoryWidget mMemoryWidget;
};
// In MainWindow.cpp
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow),
mCpuWidget(this),
mMemoryWidget(this)
{
ui->setupUi(this);
SysInfo::instance().init();
ui->centralWidget->layout()->addWidget(&mCpuWidget);
ui->centralWidget->layout()->addWidget(&mMemoryWidget);
}
正如我们在 CPUWidget 中所做的那样,向 MainWindow 添加一个名为 mMemoryWidget 的新成员,并使用 uiâcentralWidget->layout()->addWidget(&mMemoryWidget) 语法将其添加到 centralWidget 布局中。
编译、运行应用程序,并等待几秒钟。你应该看到类似以下内容:

MemoryWidget 类运行良好,但看起来有点单调。我们可以用 Qt 非常容易地自定义它。目标是让内存区域顶部有一条粗线,从上到下有一个漂亮的渐变。我们只需修改 MemoryWidget.cpp 文件中的 areaSeries 类:
#include <QtCharts/QAreaSeries>
#include <QLinearGradient>
#include <QPen>
#include "SysInfo.h"
using namespace QtCharts;
const int CHART_X_RANGE_MAX = 50;
const int COLOR_DARK_BLUE = 0x209fdf;
const int COLOR_LIGHT_BLUE = 0xbfdfef;
const int PEN_WIDTH = 3;
MemoryWidget::MemoryWidget(QWidget *parent) :
SysInfoWidget(parent),
mSeries(new QLineSeries(this))
{
QPen pen(COLOR_DARK_BLUE);
pen.setWidth(PEN_WIDTH);
QLinearGradient gradient(QPointF(0, 0), QPointF(0, 1));
gradient.setColorAt(1.0, COLOR_DARK_BLUE);
gradient.setColorAt(0.0, COLOR_LIGHT_BLUE);
gradient.setCoordinateMode(QGradient::ObjectBoundingMode);
QAreaSeries* areaSeries = new QAreaSeries(mSeries);
areaSeries->setPen(pen);
areaSeries->setBrush(gradient);
QChart* chart = chartView().chart();
...
}
QPen pen 函数是 QPainter API 的一部分。它是 Qt 依赖以进行大多数 GUI 绘图的基础。这包括整个 QWidget API (QLabel、QPushButton、QLayout 等)。对于 pen,我们只需指定其颜色和宽度,然后通过 areaSeries->setPen(pen) 应用到 areaSeries 类。
对于渐变,原理相同。我们在指定垂直渐变每端的颜色之前,定义了起点 (QPointF(0, 0)) 和终点 (QPointF(0, 1))。QGradient::ObjectBoundingMode 参数定义了如何将起始/终点坐标映射到对象上。使用 QAreaSeries 类时,我们希望渐变坐标与整个 QareaSeries 类匹配。这些坐标是归一化坐标,意味着 0 是形状的起点,1 是终点:
-
[0.0]坐标将指向QAreaSeries类的左上角 -
[1.0]坐标将指向QAreaSeries类的左下角
最后进行构建和运行,SysInfo 应用程序将看起来像这样:

内存泄漏或启动虚拟机是让你的内存变得疯狂的好方法
SysInfo 应用程序现在已经完成,我们甚至添加了一些视觉上的润色。如果你想进一步自定义小部件以符合你的口味,可以探索 QGradient 类和 QPainter API。
.pro 文件深入解析
当你点击 构建 按钮时,Qt Creator 实际上在做什么?Qt 如何处理单个 .pro 文件对不同平台的编译?Q_OBJECT 宏的确切含义是什么?我们将在接下来的章节中深入探讨这些问题。我们的示例案例将是刚刚完成的 SysInfo 应用程序,我们将研究 Qt 在底层做了什么。
我们可以通过深入研究 .pro 文件开始这项研究。它是编译任何 Qt 项目的入口点。基本上,.pro 文件是一个 qmake 项目文件,描述了项目使用的源文件和头文件。它是对 Makefile 的平台无关定义。首先,我们可以涵盖在 ch02-sysinfo 应用程序中使用的不同 qmake 关键字:
#-------------------------------------------------
#
# Project created by QtCreator 2016-03-24T16:25:01
#
#-------------------------------------------------
QT += core gui charts
CONFIG += C++14
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
TARGET = ch02-sysinfo
TEMPLATE = app
这些函数中的每一个都有特定的作用:
-
#:这是在行上注释所需的前缀。是的,我们是在 2016-03-24-crazy 日期生成的项目,对吧? -
QT:这是项目中使用的 Qt 模块的列表。在特定平台的 Makefile 中,每个值都将包括模块头文件和相应的库链接。 -
CONFIG:这是项目配置选项的列表。在这里,我们在 Makefile 中配置了对 C++14 的支持。 -
TARGET: 这是目标输出文件的名称。 -
TEMPLATE: 这是生成Makefile.app时使用的项目模板,它告诉 qmake 生成一个针对二进制的 Makefile。如果你正在构建一个库,请使用lib值。
在ch02-sysinfo应用程序中,我们开始使用平台特定的编译规则,利用直观的作用域机制:
windows {
SOURCES += SysInfoWindowsImpl.cpp
HEADERS += SysInfoWindowsImpl.h
}
如果你必须使用Makefile来做这件事,你可能在做对之前会掉一些头发(光头不是借口)。此语法简单而强大,也用于条件语句。假设你只想在调试模式下构建一些文件。你会写出以下内容:
windows {
SOURCES += SysInfoWindowsImpl.cpp
HEADERS += SysInfoWindowsImpl.h
debug {
SOURCES += DebugClass.cpp
HEADERS += DebugClass.h
}
}
在windows作用域内嵌套debug相当于if (windows && debug)。作用域机制更加灵活;你可以使用此语法来具有 OR 布尔运算符条件:
windows|unix {
SOURCES += SysInfoWindowsAndLinux.cpp
}
你甚至可以有 else if/else 语句:
windows|unix {
SOURCES += SysInfoWindowsAndLinux.cpp
} else:macx {
SOURCES += SysInfoMacImpl.cpp
} else {
SOURCES += UltimateGenericSources.cpp
}
在这个代码片段中,我们也可以看到+=运算符的使用。qmake 工具提供了一系列运算符来修改变量的行为:
-
=: 此运算符将变量设置为指定的值。例如,SOURCES = SysInfoWindowsImpl.cpp会将单个SysInfoWindowsImpl.cpp值赋给SOURCES变量。 -
+=: 此运算符将值添加到值的列表中。这是我们通常在HEADERS、SOURCES、CONFIG等中使用的。 -
-=: 此运算符从列表中删除值。例如,你可以在通用部分添加DEFINE = DEBUG_FLAG语法,在平台特定的作用域(例如 Windows 发布版)中,使用DEFINE -= DEBUG_FLAG语法将其删除。 -
*=: 此运算符只有在值尚未存在于列表中时才将其添加到列表中。DEFINE *= DEBUG_FLAG语法只将DEBUG_FLAG值添加一次。 -
~=: 此运算符将匹配正则表达式的任何值替换为指定的值,例如DEFINE ~= s/DEBUG_FLAG/debug。
你也可以在.pro文件中定义变量,并在不同的地方重用它们。我们可以通过使用 qmake 的message()函数来简化这一点:
COMPILE_MSG = "Compiling on"
windows {
SOURCES += SysInfoWindowsImpl.cpp
HEADERS += SysInfoWindowsImpl.h
message($$COMPILE_MSG windows)
}
linux {
SOURCES += SysInfoLinuxImpl.cpp
HEADERS += SysInfoLinuxImpl.h
message($$COMPILE_MSG linux)
}
macx {
SOURCES += SysInfoMacImpl.cpp
HEADERS += SysInfoMacImpl.h
message($$COMPILE_MSG mac)
}
如果你构建项目,你将在每次在常规消息选项卡(你可以从窗口 | 输出面板 | 常规消息访问此选项卡)构建项目时看到你的平台特定消息。在这里,我们定义了一个COMPILE_MSG变量,并在调用message($$COMPILE_MSG windows)时引用它。当你需要从.pro文件编译外部库时,这提供了有趣的可能性。然后你可以将所有源聚合到一个变量中,将其与对特定编译器的调用结合起来,等等。
小贴士
如果你的作用域特定语句是一行,你可以使用以下语法来描述它:
windows:message($$COMPILE_MSG windows)
除了message()之外,还有一些其他有用的函数:
-
error(string): 此函数显示字符串并立即退出编译。 -
exists(filename): 这个函数检查filename的存在。qmake 还提供了!操作符,这意味着你可以写!exist(myfile) { ... }。 -
include(filename): 这个函数包含另一个.pro文件的内容。它赋予你将.pro文件切割成更多模块化组件的能力。当你有一个大项目需要多个.pro文件时,这将非常有用。
注意
所有内置函数的描述请见 doc.qt.io/qt-5/qmake-test-function-reference.html。
qmake 内部结构
正如我们之前所说的,qmake 是 Qt 框架编译系统的基础。在 Qt Creator 中,当你点击 构建 按钮时,会调用 qmake。让我们通过在 命令行界面 (CLI) 上自己调用它来研究 qmake 究竟在做什么。
创建一个临时目录,你将在其中存储生成的文件。我们正在使用一个 Linux 机器,但这可以在任何操作系统上实现。我们选择了 /tmp/sysinfo。使用 CLI,导航到这个新目录并执行以下命令:
/path/to/qt/installation/5.7/gcc_64/bin/qmake -makefile -o Makefile /path/to/sysinfoproject/ch02-sysinfo.pro
这个命令将以 -makefile 模式执行 qmake,根据你的 sysinfo.pro 文件生成一个 Makefile。如果你浏览 Makefile 的内容,你会看到我们在 .pro 部分之前提到过的很多东西。Qt 模块的链接、不同模块的头部文件、包含你的项目头部和源文件,等等。
现在,让我们通过简单地输入 make 命令来构建这个 Makefile。
这个命令将生成二进制文件 ch02-sysinfo(基于 .pro 文件的 TARGET 值)。如果你现在查看 /tmp/sysinfo 中现有的文件列表:
$ ls -1
ch02-sysinfo
CpuWidget.o
main.o
MainWindow.o
Makefile
MemoryWidget.o
moc_CpuWidget.cpp
moc_CpuWidget.o
moc_MainWindow.cpp
moc_MainWindow.o
moc_MemoryWidget.cpp
moc_MemoryWidget.o
moc_SysInfoWidget.cpp
moc_SysInfoWidget.o
SysInfoLinuxImpl.o
SysInfo.o
SysInfoWidget.o
ui_MainWindow.h
现在非常有趣,我们发现所有的源文件都编译成了常规的 .o 扩展名(SysInfo.o、SysInfoWidget.o 等),但还有很多以 moc_ 为前缀的其他文件。这里隐藏着 Qt 框架的另一个基石:元对象编译器。
每次你使用信号/槽系统时,你都必须在你的头文件中包含宏 Q_OBJECT。每次你从一个槽中发出信号或在槽中接收信号,而你并没有编写任何特定的代码来处理它时,Qt 会为你处理。这是通过生成一个包含 Qt 需要正确处理你的信号和槽的所有内容的中间实现(moc_*.cpp 文件)来完成的。
一图胜千言。以下是标准 qmake 项目的完整编译流程:

蓝色框代表命令,波浪形框是文档(源文件或最终二进制文件)。让我们一步步走过这些步骤:
-
qmake命令与项目.pro文件一起执行。它根据项目文件生成一个 Makefile。 -
执行
make命令,这将调用其他命令来生成中间文件。 -
uic命令代表用户界面编译器。它接受所有的.ui文件(基本上是界面的 XML 描述),并生成相应的ui_*.h头文件,你需要在你的.cpp文件中包含它(在我们的ch02-sysinfo项目中,它在MainWindow.cpp中)。 -
moc命令接受包含Q_OBJECT宏(与超类QObject配对)的每个类,并生成中间的moc_*.cpp文件,这些文件包括使信号/槽框架工作所需的一切。 -
执行
g++命令,在将所有源文件和中间moc文件编译成.o文件之前,最终将所有内容链接到二进制文件ch02-sysinfo中。
小贴士
注意,如果在创建类之后添加Q_OBJECT宏,有时编译器会对你的信号和槽进行抱怨。为了解决这个问题,只需从构建 | 运行 qmake运行qmake命令。现在你可以看到,这是因为 Makefile 需要重新生成以包含生成新的中间moc文件的事实。
通常,在开发者社区中,源代码生成被视为不良实践。Qt 长期以来一直在这个问题上受到批评。我们总是担心机器在我们背后做一些巫术。不幸的是,C++没有提供任何实际的方法来进行代码自省(即反射),而信号和槽机制需要关于你的类的某些元数据来解析你的信号和槽。这可以通过 C++模板系统部分实现,但 Qt 认为这种解决方案的可读性、可移植性、可用性和鲁棒性都较差。你还需要一个优秀的编译器对模板的支持。在 C++编译器的广阔世界中,这不能被假设。
moc系统现在已经完全成熟。有一些非常具体的边缘情况可能会带来麻烦(有些人报告了在 Visual Studio 中非常特定的情况下的问题),但即便如此,我们认为这个功能的收益远远超过了可能遇到的问题。信号/槽系统是一个令人惊叹的工作系统,如果你看看 Qt 的早期版本,该系统从最初的发布起就已经存在。Qt 5 中添加的函数表示法(它提供了connect()有效性的编译时检查)与 C++11 的lambdas结合,使其变得非常愉快。
在Q_OBJECT和信号/槽之下
现在 Qt 的构建系统应该更清晰了。尽管如此,Q_OBJECT宏以及信号/槽/emit 关键字仍然是黑盒。让我们深入探讨Q_OBJECT。
真相在于源代码中;Q_OBJECT在文件qobjectdefs.h(在 Qt 5.7 中)中定义:
#define Q_OBJECT \
public: \
// skipped details
static const QMetaObject staticMetaObject; \
virtual const QMetaObject *metaObject() const; \
virtual void *qt_metacast(const char *); \
virtual int qt_metacall(QMetaObject::Call, int, void **); \
QT_TR_FUNCTIONS \
private: \
// skipped details
qt_static_metacall(QObject *, QMetaObject::Call, int, void **);
这个宏定义了一些静态函数和一个static QMetaObject。这些静态函数的正文在生成的moc文件中实现。我们不会让你陷入QMetaObject类的细节。这个类的作用是存储QObject子类的所有元信息。它还维护了你类中的信号和槽与任何连接类中的信号和槽之间的对应表。每个信号和每个槽都被分配了一个唯一的索引:
-
metaObject()函数对于正常的 Qt 类返回&staticMetaObject,而在与 QML 对象一起工作时返回dynamicMetaObject。 -
qt_metacast()函数使用类的名称执行动态转换。这个函数是必需的,因为 Qt 不依赖于标准 C++ RTTI(运行时类型信息)来检索关于对象或类的元数据。 -
qt_metacall()直接通过索引调用内部信号或槽。因为使用了索引而不是指针,所以没有指针解引用,编译器可以对生成的 switch case 进行大量优化(编译器可以直接在早期包含jump指令到特定的 case,避免大量的分支评估)。因此,信号/槽机制的执行非常快。
Qt 还添加了非标准的 C++关键字来管理信号/槽机制,即signals、slots和emit。让我们看看每个关键字背后的含义,并看看如何在connect()函数中安排一切。
slots和signals关键字也在qobjectdefs.h中定义:
# define slots
# define signals public
确实如此:slots指向空值,而signals关键字只是public关键字的占位符。你所有的signals/slots仅仅是...函数。signals关键字被强制设置为public,以确保你的信号函数在类外部可见(毕竟private signal有什么意义呢?)。Qt 的魔法仅仅是能够向任何连接的slot关键字发出signal关键字,而不需要知道实现该slot的类的细节。所有的事情都是通过moc文件中QMetaObject类实现来完成的。当signal关键字被发出时,会调用QMetaObject::activate()函数,并传递改变后的值和信号索引。
最后要研究的是emit的定义:
# define emit
如此多的无意义定义,几乎可以说是荒谬的!从代码的角度来看,emit关键字完全无用;moc明显忽略它,之后也没有发生任何特别的事情。它仅仅是对开发者的一种提示,让他注意到他正在处理信号/槽而不是普通函数。
要触发一个槽,你必须使用QObject::connect()函数将你的signal关键字连接到它。这个函数创建了一个新的Connection实例,该实例在qobject_p.h中定义:
struct Connection
{
QObject *sender;
QObject *receiver;
union {
StaticMetaCallFunction callFunction;
QtPrivate::QSlotObjectBase *slotObj;
};
// The next pointer for the singly-linked ConnectionList
Connection *nextConnectionList;
//senders linked list
Connection *next;
Connection **prev;
...
};
Connection 实例存储了对信号发射类(sender)、槽接收类(receiver)以及连接的 signal 和 slot 关键字的索引的指针。当信号被发射时,每个连接的槽都必须被调用。为了能够做到这一点,每个 QObject 都有一个链表,其中包含其每个 signal 的 Connection 实例,以及每个 slot 关键字的相同 Connection 链表。
这对链表允许 Qt 正确遍历每个依赖的 slot/signal 对,使用索引来触发正确的函数。同样的推理用于处理 receiver 的销毁:Qt 遍历双链表并从连接处移除对象。
这个遍历发生在著名的 UI 线程中,在那里处理整个消息循环,并根据可能的事件(鼠标、键盘、网络等)触发每个连接的信号/槽。因为 QThread 类继承自 QObject,所以任何 QThread 都可以使用信号/槽机制。此外,signals 关键字可以发送到其他线程,它们将在接收线程的事件循环中处理。
摘要
在本章中,我们创建了一个跨平台的 SysInfo 应用程序。我们介绍了单例模式和策略模式,以实现具有平台特定代码的整洁代码组织。你学习了如何使用 Qt Charts 模块实时显示系统信息。最后,我们深入研究了 qmake 命令,以了解 Qt 如何实现信号/槽机制,以及 Qt 特定关键字(emit、signals 和 slots)背后隐藏的内容。
到现在为止,你应该对 Qt 的工作原理以及如何处理跨平台应用程序有了清晰的了解。在下一章中,我们将探讨如何将更大的项目拆分,以保持作为维护者的理智。我们将研究 Qt 的一个基本模式——模型/视图,并发现如何使用 Qt 与数据库交互。
第三章. 分割你的项目并统治你的代码
上一章深入探讨了 qmake,研究了信号/槽系统背后的内容,并介绍了一种实现平台特定代码的合理方法。本章旨在向您展示如何正确分割项目,以充分利用 Qt 框架。
为了做到这一点,你将创建一个处理相册和图片的画廊应用程序。你将能够创建、读取、更新和删除任何相册,并以缩略图网格或全分辨率显示图片。所有内容都将持久化在 SQL 数据库中。
本章通过创建一个将在以下两个章节中使用的核心库来奠定画廊的基础:第四章,拿下桌面 UI,和第五章,统治移动 UI。
本章涵盖了以下主题:
-
应用程序/库项目分离
-
使用 Qt 进行数据库交互
-
C++14 中的智能指针
-
Qt 中的模型/视图架构及其模型实现
设计可维护项目
设计可维护项目的第一步是将它适当地分割成明确定义的模块。一种常见的方法是将引擎与用户界面分开。这种分离迫使你减少代码不同部分之间的耦合,使其更加模块化。
这正是我们将采取的gallery应用程序的方法。项目将被分割成三个子项目:

子项目如下:
-
gallery-core:这是一个包含应用程序逻辑核心的库:数据类(或业务类)、持久化存储(在 SQL 中),以及通过单个入口点使存储对 UI 可用的模型。
-
gallery-desktop:这是一个依赖于
gallery-core库来获取数据并向用户显示的 Qt 小部件应用程序。该项目将在第四章,拿下桌面 UI中介绍。 -
gallery-mobile:这是一个针对移动平台(Android 和 iOS)的 QML 应用程序。它也将依赖于
gallery-core。该项目将在第五章,统治移动 UI中介绍。
如您所见,每一层都有单一的责任。这个原则既应用于项目结构,也应用于代码组织。在这三个项目中,我们将努力实现章节的座右铭:“分割你的项目,统治你的代码”。
为了以这种方式分离你的 Qt 项目,我们将创建一种不同类型的项目,即Subdirs项目:
-
点击文件 | 新建文件或项目。
-
在项目类型中,选择其他项目 | Subdirs 项目 | 选择。
-
将其命名为
ch03-gallery-core,然后点击选择。 -
选择你最新的 Qt 桌面套件,然后点击下一步 | 完成并添加子项目。
在这里,Qt Creator 创建了父项目ch03-gallery-core,它将托管我们的三个子项目(gallery-core、gallery-desktop和gallery-mobile)。父项目本身没有代码也没有编译单元,它只是将多个.pro项目分组并表达它们之间依赖关系的一种方便方式。
下一步是创建第一个subdir项目,这是你在点击完成并添加子项目后 Qt Creator 立即提出的。我们将从gallery-core开始:
-
在项目选项卡中选择库。
-
选择C++库。
-
选择共享库类型,并将其命名为
gallery-core,然后点击下一步。 -
选择模块,QtCore和QtSql,然后点击下一步。
-
在类名字段中输入Album,然后点击下一步。Qt Creator 将生成以这个类为例的库的基本框架。
-
确认项目已正确添加为
ch03-gallery-core.pro的子项目,然后点击完成。
在深入研究gallery-core代码之前,让我们研究一下 Qt Creator 为我们做了什么。打开父级.pro文件,ch03-gallery-core.pro:
TEMPLATE = subdirs
SUBDIRS += \
gallery-core
到目前为止,我们在.pro文件中使用了TEMPLATE = app语法。subdirs项目模板指示 Qt 去寻找要编译的子项目。当我们把gallery-core项目添加到ch03-gallery-core.pro中时,Qt Creator 将其添加到了SUBDIRS变量中。正如你所见,SUBDIRS是一个列表,所以你可以添加任意多的子项目。
当编译ch03-gallery-core.pro时,Qt 会扫描每个SUBDIRS值来编译它们。我们现在可以切换到gallery-core.pro:
QT += sql
QT -= gui
TARGET = gallery-core
TEMPLATE = lib
DEFINES += GALLERYCORE_LIBRARY
SOURCES += Album.cpp
HEADERS += Album.h\
gallery-core_global.h
unix {
target.path = /usr/lib
INSTALLS += target
}
让我们看看它是如何工作的:
-
QT已附加了sql模块并删除了gui模块。默认情况下,QtGui总是包含在内,必须显式删除。 -
TEMPLATE的值再次不同。我们使用lib来告诉 qmake 生成一个 Makefile,该 Makefile 将输出一个名为gallery-core的共享库(由TARGET变量指定)。 -
DEFINES += GALLERY_CORE_LIBRARY语法是一个编译标志,它让编译器知道何时导入或导出库符号。我们很快就会回到这个概念。 -
HEADERS包含了我们的第一个类Album.h,还有一个由 Qt 生成的头文件:gallery-core_global.h。这个文件是 Qt 提供的一种语法糖,用于简化跨平台库的痛苦。 -
unix { ... }范围指定了库的安装目标。这个平台范围是生成的,因为我们是在 Linux 上创建的项目。默认情况下,它将尝试在系统库路径(/usr/lib)中安装库。
请完全删除unix范围,我们不需要使库在系统范围内可用。
为了更好地理解跨平台共享对象问题,你可以打开gallery-core_global.h:
#include <QtCore/qglobal.h>
#if defined(GALLERYCORE_LIBRARY)
# define GALLERYCORESHARED_EXPORT Q_DECL_EXPORT
#else
# define GALLERYCORESHARED_EXPORT Q_DECL_IMPORT
#endif
我们再次遇到了在 gallery-core.pro 文件中定义的 GALLERYCORE_LIBRARY。Qt Creator 为我们生成了一段有用的代码:在共享库中处理符号可见性的跨平台方式。
当您的应用程序链接到共享库时,符号函数、变量或类必须以特殊方式标记,以便使用共享库的应用程序可见。符号的默认可见性取决于平台。某些平台默认隐藏符号,其他平台将它们公开。当然,每个平台和编译器都有自己的宏来表示这种公共/私有概念。
为了避免整个 #ifdef windows #else 模板代码,Qt 提供了 Q_DECL_EXPORT(如果我们正在编译库)和 Q_DECL_IMPORT(如果我们正在使用共享库编译您的应用程序)。因此,在您想要标记为公共的符号中,您只需使用 GALLERYCORESHARED_EXPORT 宏。
一个示例在 Album.h 文件中:
#ifndef ALBUM_H
#define ALBUM_H
#include "gallery-core_global.h"
class GALLERYCORESHARED_EXPORT Album
{
public:
Album();
};
#endif // ALBUM_H
您包含适当的 gallery-core_global.h 文件以访问宏,并在 class 关键字之后使用它。它不会过多地污染您的代码,并且仍然是跨平台的。
注意
另一种可能性是创建一个 静态链接库。如果您想要处理更少的依赖项(单个二进制文件总是更容易部署),这个路径很有趣。但有几个缺点:
-
增加编译时间:每次您修改库时,应用程序也必须重新编译。
-
更紧密的耦合,多个应用程序不能链接到您的库。每个都必须嵌入它。
定义数据类
我们正在从头开始构建我们的画廊。我们将从实现数据类开始,以便能够正确编写数据库层。应用程序的目标是将图片组织到专辑中。因此,两个明显的类是 Album 和 Picture。在我们的示例中,一个专辑仅仅有一个名称。Picture 类必须属于 Album 类,并有一个文件路径(原始文件在文件系统中的路径)。
Album 类在项目创建时就已经创建好了。打开 Album.h 文件,并更新它以包含以下实现:
#include <QString>
#include "gallery-core_global.h"
class GALLERYCORESHARED_EXPORT Album
{
public:
explicit Album(const QString& name = "");
int id() const;
void setId(int id);
QString name() const;
void setName(const QString& name);
private:
int mId;
QString mName;
};
如您所见,Album 类仅包含一个 mId 变量(数据库 ID)和一个 mName 变量。按照典型的 OOP(面向对象范式)风格,Album 类本应有一个 QVector<Picture>mPictures 字段。我们故意没有这样做。通过解耦这两个对象,当我们想要加载一个专辑而不需要拉取潜在的数千张相关图片时,我们将拥有更多的灵活性。在 Album 类中拥有 mPictures 的另一个问题是,使用此代码的开发者(您或其他人)会问自己:何时加载 mPictures?我应该只部分加载 Album 并得到一个不完整的 Album,还是应该总是加载包含所有图片的 Album?
通过完全删除该字段,问题就消失了,代码也更容易理解。开发者会直觉地知道,如果他想使用图片,就必须显式地加载它们;否则,他可以继续使用这个简单的 Album 类。
获取器和设置器已经很明确了;我们将让您在不向您展示的情况下实现它们。我们只会看看 Album.cpp 中的 Album 类的构造函数:
Album::Album(const QString& name) :
mId(-1),
mName(name)
{
}
mId 变量被初始化为 -1 以确保默认使用一个无效的 ID,并且 mName 变量被分配一个 name 值。
现在我们可以继续到 Picture 类。创建一个新的 C++ 类名为 Picture 并打开 Picture.h 进行如下修改:
#include <QUrl>
#include <QString>
#include "gallery-core_global.h"
class GALLERYCORESHARED_EXPORT Picture
{
public:
Picture(const QString& filePath = "");
Picture(const QUrl& fileUrl);
int id() const;
void setId(int id);
int albumId() const;
void setAlbumId(int albumId);
QUrl fileUrl() const;
void setFileUrl(const QUrl& fileUrl);
private:
int mId;
int mAlbumId;
QUrl mFileUrl;
};
不要忘记在 class 关键字之前添加 GALLERYCORESHARED_EXPORT 宏,以便从库中导出类。作为一个数据结构,Picture 有一个 mId 变量,属于一个 mAlbumId 变量,并且有一个 mUrl 值。我们使用 QUrl 类型来使路径操作更容易使用,这取决于平台(桌面或移动)。
让我们看看 Picture.cpp:
#include "Picture.h"
Picture::Picture(const QString& filePath) :
Picture(QUrl::fromLocalFile(filePath))
{
}
Picture::Picture(const QUrl& fileUrl) :
mId(-1),
mAlbumId(-1),
mFileUrl(fileUrl)
{
}
QUrl Picture::fileUrl() const
{
return mFileUrl;
}
void Picture::setFileUrl(const QUrl& fileUrl)
{
mFileUrl = fileUrl;
}
在第一个构造函数中,静态函数 QUrl::fromLocalFile 被调用,为其他构造函数提供一个 QUrl 对象,该构造函数接受一个 QUrl 参数。
在 C++11 中,能够调用其他构造函数是一个很好的补充。
将您的数据存储在数据库中
现在数据类已经准备好了,我们可以继续实现数据库层。Qt 提供了一个现成的 sql 模块。Qt 使用 SQL 数据库驱动程序支持各种数据库。在 gallery-desktop 中,我们将使用包含在 sql 模块中的 SQLITE3 驱动程序,它非常适合用例:
-
一个非常简单的数据库模式:不需要复杂的查询
-
非常少或没有并发事务:不需要复杂的交易模型
-
单一用途数据库:不需要启动系统服务,数据库存储在一个单独的文件中,并且不需要被多个应用程序访问
数据库将从多个位置访问;我们需要有一个单一的入口点。创建一个新的 C++ 类名为 DatabaseManager 并修改 DatabaseManager.h 以如下所示:
#include <QString>
class QSqlDatabase;
const QString DATABASE_FILENAME = "gallery.db";
class DatabaseManager
{
public:
static DatabaseManager& instance();
~DatabaseManager();
protected:
DatabaseManager(const QString& path = DATABASE_FILENAME);
DatabaseManager& operator=(const DatabaseManager& rhs);
private:
QSqlDatabase* mDatabase;
};
首先要注意的是,我们在 DatabaseManager 类中实现了单例模式,就像我们在第二章的“在单例中转换 SysInfo”部分所做的那样,即 第二章,发现 QMake 秘密。DatabaseManager 类将在 mDatabase 字段中打开连接并将其借给其他可能的类。
此外,QSqlDatabase被前置声明并用作mDatabase字段的指针。我们本可以包含QSqlDatabase头文件,但这样会有一个不希望出现的副作用:包含DatabaseManager的每个文件都必须也包含QSqlDatabase。因此,如果我们应用程序中存在一些传递性包含(链接到gallery-core库),应用程序将被迫启用sql模块。因此,存储层通过库泄漏。应用程序不应了解存储层实现。对于应用程序来说,它可以是 SQL、XML 或其他任何东西;库是一个应该遵守合同并持久化数据的黑盒。
让我们切换到DatabaseManager.cpp并打开数据库连接:
#include "DatabaseManager.h"
#include <QSqlDatabase>
DatabaseManager& DatabaseManager::instance()
{
static DatabaseManager singleton;
return singleton;
}
DatabaseManager::DatabaseManager(const QString& path) :
mDatabase(new QSqlDatabase(QSqlDatabase::addDatabase("QSQLITE")))
{
mDatabase->setDatabaseName(path);
mDatabase->open();
}
DatabaseManager::~DatabaseManager()
{
mDatabase->close();
delete mDatabase;
}
在mDatabase字段初始化时,通过QSqlDatabase::addDatabase("QSQLITE")函数调用选择正确的数据库驱动。接下来的步骤只是配置数据库名称(碰巧是 SQLITE3 中的文件路径)和通过mDatabase->open()函数打开连接。在DatabaseManager析构函数中,关闭连接并正确删除mDatabase指针。
数据库连接现在已打开;我们只需执行我们的Album和Picture查询。在DatabaseManager中为我们的数据类实现CRUD(创建/读取/更新/删除)操作会迅速将DatabaseManager.cpp的长度增加到数百行。添加更多表,你就可以看到DatabaseManager会变成一个怪物。
因此,我们的每个数据类都将有一个专门的数据库类,负责所有的数据库 CRUD 操作。我们将从Album类开始;创建一个新的 C++类名为AlbumDao(数据访问对象)并更新AlbumDao.h:
class QSqlDatabase;
class AlbumDao
{
public:
AlbumDao(QSqlDatabase& database);
void init() const;
private:
QSqlDatabase& mDatabase;
};
AlbumDao类的构造函数接受一个QSqlDatabase&参数。这个参数是AlbumDao类将用于所有 SQL 查询的数据库连接。init()函数的目的是创建albums表,应该在mDatabase打开时调用。
让我们看看AlbumDao.cpp的实现:
#include <QSqlDatabase>
#include <QSqlQuery>
#include "DatabaseManager.h"
AlbumDao::AlbumDao(QSqlDatabase& database) :
mDatabase(database)
{
}
void AlbumDao::init() const
{
if (!mDatabase.tables().contains("albums")) {
QSqlQuery query(mDatabase);
query.exec("CREATE TABLE albums (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)");
}
}
如同往常,mDatabase字段使用数据库参数进行初始化。在init()函数中,我们可以看到实际 SQL 请求的执行过程。如果albums表类不存在,将创建一个QSqlQuery查询,该查询将使用mDatabase连接来执行。如果你省略mDatabase,查询将使用默认的匿名连接。query.exec()函数是执行查询的最简单方式:你只需传递查询的QString类型,然后操作就完成了。在这里,我们创建albums表,其字段与数据类Album(id和name)相匹配。
小贴士
QSqlQuery::exec() 函数返回一个 bool 值,表示请求是否成功。在你的生产代码中,始终检查这个值。你可以使用 QSqlQuery::lastError() 进一步调查错误。一个示例可以在章节的源代码中找到,在 DatabaseManager::debugQuery() 中。
AlbumDao 类的骨架已经完成。下一步是将它与 DatabaseManager 类链接起来。更新 DatabaseManager 类如下:
// In DatabaseManager.h
#include "AlbumDao.h"
...
private:
QSqlDatabase* mDatabase;
public:
const AlbumDao albumDao;
};
// In DatabaseManager.cpp
DatabaseManager::DatabaseManager(const QString& path) :
mDatabase(new QSqlDatabase(QSqlDatabase::addDatabase("QSQLITE"))),
albumDao(*mDatabase)
{
mDatabase->setDatabaseName(path);
mDatabase->open();
albumDao.init();
}
albumDao 字段在 DatabaseManager.h 文件中被声明为 public const AlbumDao。这需要一些解释:
-
public可见性是为了让DatabaseManager客户端能够访问albumDao字段。API 已经足够直观;如果你想在album上执行数据库操作,只需调用DatabaseManager::instance().albumDao。 -
const关键字是为了确保没有人可以修改albumDao。因为它public,我们无法保证对象的安全性(任何人都可以修改对象)。作为副作用,我们强制AlbumDao的每个公共函数都是const。这很有意义;毕竟,AlbumDao字段可能是一个包含许多函数的命名空间。将其作为一个类更方便,因为我们可以用mDatabase字段保持对数据库连接的引用。
在 DatabaseManager 构造函数中,albumDao 类使用 mDatabase 解引用指针进行初始化。在数据库连接打开后,调用 albumDao.init() 函数。
我们现在可以继续实现更有趣的 SQL 查询。我们可以从在 AlbumDao 类中创建一个新的专辑开始:
// In AlbumDao.h
class QSqlDatabase;
class Album;
class AlbumDao
{
public:
AlbumDao(QSqlDatabase& database);
void init() const;
void addAlbum(Album& album) const;
...
};
// In AlbumDao.cpp
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QVariant>
...
void AlbumDao::addAlbum(Album& album) const
{
QSqlQuery query(mDatabase);
query.prepare("INSERT INTO albums (name) VALUES (:name)");
query.bindValue(":name", album.name());
query.exec();
album.setId(query.lastInsertId().toInt());
}
addAlbum() 函数接受一个 album 参数,提取其信息并执行相应的查询。在这里,我们采用了预查询的概念:query.prepare() 函数接受一个 query 参数,其中包含稍后提供的参数的占位符。我们将使用 :name 语法提供 name 参数。支持两种语法:Oracle 风格的冒号-名称(例如,:name)或 ODBC 风格的问号(例如,?name)。
然后,我们将 bind :name 语法绑定到 album.name() 函数的值。因为 QSqlQuery::bind() 期望一个 QVariant 作为参数值,所以我们必须向这个类添加 include 指令。
简而言之,QVariant 是一个通用数据持有者,可以接受广泛的原始类型(char、int、double 等)和复杂类型(QString、QByteArray、QUrl 等)。
当执行 query.exec() 函数时,绑定的值会被正确替换。prepare() 语句技术使代码对 SQL 注入(注入一个隐藏请求会失败)更加健壮,并且更易于阅读。
查询的执行会修改查询对象query本身的状态。QSqlQuery查询不仅是一个 SQL 查询执行器,它还包含活动查询的状态。我们可以使用query.lastInsertId()函数检索有关查询的信息,该函数返回一个包含我们刚刚插入的相册行 ID 的QVariant值。这个id被赋予在addAlbum()参数中提供的album。因为我们修改了album,所以我们不能将参数标记为const。对代码的const正确性严格把关是给其他开发者一个好的提示,他们可以推断出你的函数可能会(或可能不会)修改传递的参数。
剩余的更新和删除操作严格遵循与addAlbum()相同的模式。我们将在下一个代码片段中提供预期的函数签名。请参考该章节的源代码以获取完整的实现。然而,我们需要实现检索数据库中所有相册的请求。这个请求值得仔细看看:
// In AlbumDao.h
#include <QVector>
...
void addAlbum(Album& album) const;
void updateAlbum(const Album& album) const;
void removeAlbum(int id) const;
QVector<Album*> albums() const;
...
};
// In AlbumDao.cpp
QVector<Album*> AlbumDao::albums() const
{
QSqlQuery query("SELECT * FROM albums", mDatabase);
query.exec();
QVector<Album*> list;
while(query.next()) {
Album* album = new Album();
album->setId(query.value("id").toInt());
album->setName(query.value("name").toString());
list.append(album);
}
return list;
}
albums()函数必须返回一个QVector<Album*>值。如果我们看一下函数的主体,我们会看到QSqlQuery的另一个属性。为了遍历给定请求的多行,query处理一个指向当前行的内部游标。然后我们可以继续创建一个new Album*()函数,并使用query.value()语句填充行数据,该语句接受一个列名参数并返回一个被转换为正确类型的QVariant值。这个新的album参数被添加到list中,最后,这个list被返回给调用者。
PictureDao类在用法和实现上与AlbumDao类非常相似。主要区别在于图片有一个指向相册的外键。PictureDao函数必须根据albumId参数进行条件限制。下面的代码片段显示了PictureDao头文件和init()函数:
// In PictureDao.h
#include <QVector>
class QSqlDatabase;
class Picture;
class PictureDao
{
public:
explicit PictureDao(QSqlDatabase& database);
void init() const;
void addPictureInAlbum(int albumId, Picture& picture) const;
void removePicture(int id) const;
void removePicturesForAlbum(int albumId) const;
QVector<Picture*> picturesForAlbum(int albumId) const;
private:
QSqlDatabase& mDatabase;
};
// In PictureDao.cpp
void PictureDao::init() const
{
if (!mDatabase.tables().contains("pictures")) {
QSqlQuery query(mDatabase);
query.exec(QString("CREATE TABLE pictures")
+ " (id INTEGER PRIMARY KEY AUTOINCREMENT, "
+ "album_id INTEGER, "
+ "url TEXT)");
}
}
如你所见,多个函数接受一个albumId参数,以在图片和拥有album参数之间建立联系。在init()函数中,外键以album_id INTEGER语法表示。SQLITE3 没有合适的外键类型。它是一个非常简单的数据库,并且没有对这个类型字段进行严格的约束;这里使用了一个简单的整数。
最后,在DatabaseManager类中添加了PictureDao函数,其方式与我们为albumDao所做的一样。有人可能会争论,如果有很多Dao类,那么在DatabaseManager类中添加一个const Dao成员并快速调用init()函数会变得很繁琐。
一个可能的解决方案是创建一个抽象的 Dao 类,其中包含一个纯虚的 init() 函数。DatabaseManager 类将有一个 Dao 注册表,它将每个 Dao 映射到一个 QString 键,并使用 QHash<QString, const Dao> mDaos。然后,init() 函数调用将在 for 循环中进行,并使用 QString 键访问 Dao 对象。这超出了本项目范围,但仍然是一个有趣的方法。
使用智能指针保护你的代码
我们刚才描述的代码是完全有效的,但它可以通过 AlbumDao::albums() 函数来加强,特别是这个函数。在这个函数中,我们遍历数据库行并创建一个新的 Album 来填充列表。我们可以聚焦于这个特定的代码段:
QVector<Album*> list;
while(query.next()) {
Album* album = new Album();
album->setId(query.value("id").toInt());
album->setName(query.value("name").toString());
list.append(album);
}
return list;
假设 name 列已经被重命名为 title。如果你忘记更新 query.value("name"),你可能会遇到麻烦。Qt 框架不依赖于异常,但并非所有野外的 API 都是这样。这里的异常会导致内存泄漏:Album* album 函数已经在堆上分配,但未释放。为了处理这种情况,你需要在有风险的代码周围加上 try catch 语句,并在抛出异常时释放 album 参数。也许这个错误应该向上冒泡;因此,你的 try catch 语句只是为了处理潜在的内存泄漏。你能想象在你面前编织的意大利面代码吗?
指针的真正问题是所有权的不可确定性。一旦分配,指针的所有者是谁?谁负责释放对象?当你将指针作为参数传递时,调用者何时保留所有权或将其释放给被调用者?
自 C++11 以来,内存管理达到了一个重要的里程碑:智能指针功能已经稳定,可以大大提高你代码的安全性。目标是通过对简单的模板语义进行显式地指示指针的所有权。有三种类型的智能指针:
-
unique_ptr指针表明所有者是唯一的指针所有者 -
shared_ptr指针表明指针的所有权在多个客户端之间共享 -
weak_ptr指针表明指针不属于客户端
现在,我们将专注于 unique_ptr 指针来理解智能指针的机制。
unique_ptr 指针实际上是在栈上分配的一个变量,它接管了你提供的指针的所有权。让我们用这种语义来分配一个 Album:
#include <memory>
void foo()
{
Album* albumPointer = new Album();
std::unique_ptr<Album> album(albumPointer);
album->setName("Unique Album");
}
整个智能指针 API 都可以在 memory 头文件中找到。当我们将 album 声明为 unique_ptr 时,我们做了两件事:
-
我们在栈上分配了一个
unique_ptr<Album>。unique_ptr指针依赖于模板在编译时检查指针类型的有效性。 -
我们将
albumPointer内存的所有权授予了album。从这一点开始,album就是指针的所有者。
这简单的一行有重要的影响。首先,你再也不必担心指针的生命周期了。因为 unique_ptr 指针是在堆栈上分配的,所以一旦超出作用域就会销毁。在这个例子中,当我们退出 foo() 时,album 将从堆栈中移除。unique_ptr 实现将负责调用 Album 析构函数并释放内存。
其次,你在编译时明确指出了你指针的所有权。如果他们没有自愿地摆弄你的 unique_ptr 指针,没有人可以释放 albumPointer 的内容。你的同事开发者也可以一眼看出谁是你的指针的所有者。
注意,尽管 album 是 unique_ptr<Album> 类型,但你仍然可以使用 -> 操作符调用 Album 函数(例如,album->setName())。这是由于这个操作符的重载。unique_ptr 指针的使用变得透明。
好吧,这个用例很不错,但指针的目的是能够分配一块内存并共享它。比如说,foo() 函数分配了 album unique_ptr 指针,然后将所有权转让给 bar()。这看起来是这样的:
void foo()
{
std::unique_ptr<Album> album(new Album());
bar(std::move(album));
}
void bar(std::unique_ptr<Album> barAlbum)
{
qDebug() << "Album name" << barAlbum->name();
}
这里,我们介绍 std::move() 函数:它的目的是转移 unique_ptr 函数的所有权。一旦调用 bar(std::move(album)),album 就变得无效。你可以用一个简单的 if 语句来测试它:if (album) { ... }。
从现在起,bar() 函数通过在堆栈上分配一个新的 unique_ptr 来成为指针的所有者(通过 barAlbum),并在退出时释放指针。你不必担心 unique_ptr 指针的成本,因为这些对象非常轻量级,它们不太可能影响你应用程序的性能。
再次强调,bar() 函数的签名告诉开发者这个函数期望接收传递的 Album 的所有权。试图在没有使用 move() 函数的情况下传递 unique_ptr 将导致编译错误。
另一点需要注意的是,在处理 unique_ptr 指针时,.(点)和 ->(箭头)的不同含义:
-
->操作符解引用到指针成员,并允许你调用真实对象的函数 -
.操作符让你可以访问unique_ptr对象的函数
unique_ptr 指针提供了各种函数。其中最重要的包括:
-
get()函数返回原始指针。album.get()返回一个Album*值。 -
release()函数释放了指针的所有权并返回原始指针。album.release()函数返回一个Album*值。 -
reset(pointer p = pointer())函数销毁当前管理的指针,并获取给定参数的所有权。一个例子是barAlbum.reset()函数,它销毁当前拥有的Album*。带有参数的barAlbum.reset(new Album())也会销毁拥有的对象,并获取提供的参数的所有权。
最后,您可以使用*操作符取消引用对象,这意味着*album将返回一个Album&值。这种取消引用很方便,但您会看到,智能指针使用得越多,您就越不需要它。大多数时候,您会用以下语法替换原始指针:
void bar(std::unique_ptr<Album>& barAlbum);
因为我们通过引用传递unique_ptr,bar()不会获取指针的所有权,并在退出时不会尝试释放它。因此,在foo()中不需要使用move(album);bar()函数将只对album参数进行操作,但不会获取其所有权。
现在,让我们考虑shared_ptr。shared_ptr指针在指针上保持一个引用计数器。每次shared_ptr指针引用相同的对象时,计数器会增加;当这个shared_ptr指针超出作用域时,计数器会减少。当计数器达到零时,对象将被释放。
让我们用shared_ptr指针重写我们的foo()/bar()示例:
#include <memory>
void foo()
{
std::shared_ptr<Album> album(new Album()); // ref counter = 1
bar(album); // ref counter = 2
} // ref counter = 0
void bar(std::shared_ptr<Album> barAlbum)
{
qDebug() << "Album name" << barAlbum->name();
} // ref counter = 1
如您所见,语法与unique_ptr指针非常相似。每次分配新的shared_ptr指针并指向相同的数据时,引用计数器会增加,在函数退出时减少。您可以通过调用album.use_count()函数来检查当前的计数。
我们将要介绍的最后一个智能指针是weak_ptr指针。正如其名所示,它不会获取任何所有权或增加引用计数。当函数指定一个weak_ptr时,它向调用者表明它只是一个客户端,而不是指针的所有者。如果我们用weak_ptr指针重新实现bar(),我们得到:
#include <memory>
void foo()
{
std::shared_ptr<Album> album(new Album()); // ref counter = 1
bar(std::weak_ptr<Album>(album)); // ref counter = 1
} // ref counter = 0
void bar(std::weak_ptr<Album> barAlbum)
{
qDebug() << "Album name" << barAlbum->name();
} // ref counter = 1
如果故事到此为止,使用weak_ptr与原始指针之间就不会有任何兴趣。weak_ptr在悬空指针问题上有一个主要优势。如果您正在构建缓存,通常您不希望保留对对象的强引用。另一方面,您想知道对象是否仍然有效。通过使用weak_ptr,您知道对象何时被释放。现在,考虑原始指针方法:您的指针可能无效,但您不知道内存的状态。
在 C++14 中引入了另一个语义,我们必须涵盖:make_unique。这个关键字旨在替换new关键字,并以异常安全的方式构造一个unique_ptr对象。这是它的用法:
unique_ptr<Album> album = make_unique<Album>();
make_unique关键字封装了new关键字,使其异常安全,特别是在这种情况下:
foo(new Album(), new Picture())
这段代码将按以下顺序执行:
-
分配和构造
Album函数。 -
分配和构造
Picture函数。 -
执行
foo()函数。
如果 new Picture() 抛出异常,由 new Album() 分配的内存将会泄漏。这个问题可以通过使用 make_unique 关键字来解决:
foo(make_unique<Album>(), make_unique<Picture>())
make_unique 关键字返回一个 unique_ptr 指针;C++ 标准委员会还提供了 shared_ptr 的等效形式 make_shared,遵循相同的原理。
所有这些新的 C++ 语义都尽力消除 new 和 delete。然而,编写所有的 unique_ptr 和 make_unique 可能会有些繁琐。在 album 创建中,auto 关键字提供了帮助:
auto album = make_unique<Album>()
这与常见的 C++ 语法有很大的不同。变量类型是推断出来的,没有显式的指针,内存是自动管理的。在使用智能指针一段时间后,你会在代码中看到越来越少的原始指针(甚至更少的 delete,这真是一种解脱)。剩余的原始指针将简单地表明客户端正在使用指针,但并不拥有它。
总体来说,C++11 和 C++14 的智能指针在 C++ 代码编写中是一个真正的进步。在此之前,代码库越大,我们对内存管理的安全感就越低。我们的头脑在处理这种级别的复杂性时总是不够好。智能指针简单地说让你对自己的代码感到安全。另一方面,你仍然可以完全控制内存。对于性能关键代码,你始终可以自己处理内存。对于其他所有事情,智能指针是明确表示对象所有权和解放思想的一种优雅方式。
我们现在可以重新编写 AlbumDao::albums() 函数中的小片段。更新 AlbumDao::albums() 如下:
// In AlbumDao.h
std::unique_ptr<std::vector<std::unique_ptr<Album>>> albums() const;
// In AlbumDao.cpp
unique_ptr<vector<unique_ptr<Album>>> AlbumDao::albums() const
{
QSqlQuery query("SELECT * FROM albums", mDatabase);
query.exec();
unique_ptr<vector<unique_ptr<Album>>> list(new vector<unique_ptr<Album>>());
while(query.next()) {
unique_ptr<Album> album(new Album());
album->setId(query.value("id").toInt());
album->setName(query.value("name").toString());
list->push_back(move(album));
}
return list;
}
哇!album() 函数的签名已经变成了一个非常奇特的东西。智能指针本应使你的生活变得更简单,对吧?让我们分解一下,以了解 Qt 中智能指针的一个主要点:容器行为。
重写的初始目标是确保 album 的创建。我们希望 list 成为 album 的明确所有者。这将使我们的 list 类型(即 albums() 返回类型)变为 QVector<unique_ptr<Album>>。然而,当返回 list 类型时,其元素将被复制(记住,我们之前定义的返回类型为 QVector<Album>)。从这个角度来看,一个自然的解决方案是返回 QVector<unique_ptr<Album>>* 类型以保持 Album 元素的唯一性。
看看这里的主要痛点:QVector 类重载了复制操作符。因此,当返回 list 类型时,编译器无法保证我们的 unique_ptr 元素的唯一性,并且会抛出编译错误。这就是为什么我们必须求助于标准库中的 vector 对象,并编写长类型:unique_ptr<vector<unique_ptr<Album>>>。
注意
看一下官方对 Qt 容器中unique_ptr指针支持的回应。毫无疑问:lists.qt-project.org/pipermail/interest/2013-July/007776.html。简短的回答是:不,它永远不会完成。永远不要提及它。
如果我们将这个新的albums()签名翻译成普通英语,它将读作:album()函数返回一个Album的向量。这个向量是其包含的Album元素的拥有者,而你将是向量的拥有者。
要完成对albums()实现的覆盖,你可能注意到我们没有在list声明中使用auto和make_unique关键字。我们的库将在第五章的移动设备上使用,掌握移动 UI,并且该平台尚不支持 C++14。因此,我们必须将我们的代码限制在 C++11 中。
我们也遇到了在指令list->push_back(move(album))中使用move函数的情况。直到那一行,album是while作用域的“所有者”,移动操作将所有权转移给了列表。在最后的指令return list中,我们应该写成move(list),但 C++11 接受直接返回,并且会自动为我们调用move()函数。
我们在本节中涵盖的内容是AlbumDao类与PictureDao完全匹配。请参考章节的源代码以查看完整的PictureDao类实现。
实现模型
数据已经准备好向潜在的客户(将显示和编辑其内容的应用程序)公开。然而,客户端和数据库之间的直接连接将导致非常强的耦合。如果我们决定切换到另一种存储类型,视图至少部分需要重写。
这就是模型来拯救我们的地方。它是一个抽象层,与数据(我们的数据库)进行通信,并以数据特定的、实现无关的形式向客户端公开这些数据。这种方法是MVC(模型-视图-控制器)概念的直接后代。让我们回顾一下 MVC 是如何工作的:
-
模型管理数据。它负责请求数据并更新数据。
-
视图向用户显示数据。
-
控制器与模型和视图都进行交互。它负责向视图提供正确的数据,并根据从视图接收到的用户交互向模型发送命令。
这种范式使得在不干扰其他部分的情况下交换各种部分成为可能。多个视图可以显示相同的数据,数据层可以更改,而上层将不会意识到这一点。
Qt 将视图和控制结合在一起形成模型/视图架构。在实现上比完整的 MVC 方法更简单的同时,保留了存储和表示的分离。为了允许编辑和视图定制,Qt 引入了代理的概念,该代理连接到模型和视图:

Qt 关于模型/视图的文档确实非常丰富。然而,有时在细节中迷失方向;有时感觉有点令人不知所措。我们将通过实现 AlbumModel 类并观察其工作方式来尝试澄清这些问题。
Qt 提供了各种模型子类,它们都扩展自 QAbstractItemModel。在开始实现之前,我们必须仔细选择要扩展的基类。记住,我们的数据是列表的变体:我们将有一个专辑列表,每个专辑将有一个图片列表。让我们看看 Qt 提供了什么:
-
QAbstractItemModel:这是一个最抽象的类,因此也是最难实现的。我们将不得不重新定义很多函数来正确使用它。 -
QStringListModel:这是一个向视图提供字符串的模型。它太简单了。我们的模型更复杂(我们有自定义对象)。 -
QSqlTableModel(或QSqLQueryModel):这个类是一个非常有趣的竞争者。它自动处理多个 SQL 查询。另一方面,它仅适用于非常简单的表模式。例如,在pictures表中,album_id外键使其很难适应这个模型。你可能可以节省一些代码行,但感觉就像试图将一个圆形的木塞塞入一个方形的孔中。 -
QAbstractListModel:这个类提供了一个提供一维列表的模型。这非常适合我们的需求,节省了很多键盘敲击,并且仍然足够灵活。
我们将使用 QabstractListModel 类并创建一个新的 C++ 类名为 AlbumModel。更新 AlbumModel.h 文件,使其看起来像这样:
#include <QAbstractListModel>
#include <QHash>
#include <vector>
#include <memory>
#include "gallery-core_global.h"
#include "Album.h"
#include "DatabaseManager.h"
class GALLERYCORESHARED_EXPORT AlbumModel : public QAbstractListModel
{
Q_OBJECT
public:
enum Roles {
IdRole = Qt::UserRole + 1,
NameRole,
};
AlbumModel(QObject* parent = 0);
QModelIndex addAlbum(const Album& album);
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
bool setData(const QModelIndex& index, const QVariant& value, int role) override;
bool removeRows(int row, int count, const QModelIndex& parent) override;
QHash<int, QByteArray> roleNames() const override;
private:
bool isIndexValid(const QModelIndex& index) const;
private:
DatabaseManager& mDb;
std::unique_ptr<std::vector<std::unique_ptr<Album>>> mAlbums;
};
AlbumModel 类扩展了 QAbstractListModel 类,并且只有两个成员:
-
mDb:这是数据库的链接。在模型/视图架构中,模型将通过mDb与数据层进行通信。 -
mAlbums:这充当一个缓冲区,将避免过多地访问数据库。类型应该让你想起我们为AlbumDao::albums()编写的智能指针。
AlbumModel 类唯一的特定函数是 addAlbum() 和 isIndexValid()。其余的都是 QAbstractListModel 函数的重写。我们将逐一介绍这些函数,以了解模型是如何工作的。
首先,让我们看看 AlbumModel 类在 AlbumModel.cpp 文件中的构建方式:
AlbumModel::AlbumModel(QObject* parent) :
QAbstractListModel(parent),
mDb(DatabaseManager::instance()),
mAlbums(mDb.albumDao.albums())
{
}
mDb 文件使用 DatabaseManager 单例地址初始化,之后我们看到现在著名的 AlbumDao::albums() 正在发挥作用。
返回vector类型并初始化mAlbums。这种语法使得所有权转移是自动的,无需显式调用std::move()函数。如果数据库中存储有任何专辑,mAlbums将立即填充这些专辑。
每次模型与视图交互(通知我们变化或提供数据)时,都会使用mAlbums。因为它仅在内存中,所以读取将非常快。当然,我们必须小心保持mAlbum与数据库状态的连贯性,但所有操作都将保持在AlbumModel内部机制中。
如我们之前所说,该模型旨在成为与数据交互的中心点。每次数据发生变化时,模型都会发出一个信号来通知视图;每次视图想要显示数据时,它将请求模型提供数据。AlbumModel类覆盖了读写访问所需的所有内容。读函数包括:
-
rowCount(): 此函数用于获取列表大小 -
data(): 此函数用于获取要显示的数据的特定信息 -
roleNames(): 此函数用于向框架指示每个“角色”的名称。我们将在接下来的几段中解释什么是角色
编辑函数包括:
-
setData(): 此函数用于更新数据 -
removeRows(): 此函数用于删除数据
我们将从读取部分开始,其中视图请求模型提供数据。
因为我们将显示专辑列表,所以视图首先应该知道有多少可用项。这是在rowCount()函数中完成的:
int AlbumModel::rowCount(const QModelIndex& parent) const
{
return mAlbums->size();
}
作为我们的缓冲对象,使用mAlbums->size()是完美的。没有必要查询数据库,因为mAlbums已经填充了数据库中的所有专辑。rowCount()函数有一个未知参数:一个const QModelIndex& parent。在这里,它没有被使用,但我们必须在继续AlbumModel类的旅程之前解释这个类型背后的含义。
QModelIndex类是 Qt 中 Model/View 框架的一个核心概念。它是一个轻量级对象,用于在模型中定位数据。我们使用一个简单的QAbstractListModel类,但 Qt 能够处理三种表示类型:

没有比官方 Qt 图表更好的解释了
现在我们来详细看看模型:
-
列表模型:在这个模型中,数据存储在一个一维数组中(行)
-
表格模型:在这个模型中,数据存储在一个二维数组中(行和列)
-
树模型:在这个模型中,数据以父子关系存储
为了处理所有这些模型类型,Qt 提出了QModelIndex类,这是一种处理它们的抽象方式。QModelIndex类具有针对每个用例的函数:row()、column()和parent()/child()。每个QModelIndex实例都意味着是短暂的:模型可能会更新,因此索引将变得无效。
模型将根据其数据类型生成索引,并将这些索引提供给视图。然后视图将使用它们查询模型以获取新的数据,而无需知道index.row()函数是否对应于数据库行或vector索引。
我们可以通过data()实现的index参数来看到index参数的作用:
QVariant AlbumModel::data(const QModelIndex& index, int role) const
{
if (!isIndexValid(index)) {
return QVariant();
}
const Album& album = *mAlbums->at(index.row());
switch (role) {
case Roles::IdRole:
return album.id();
case Roles::NameRole:
case Qt::DisplayRole:
return album.name();
default:
return QVariant();
}
}
视图将使用两个参数请求数据:一个index和一个role。因为我们已经涵盖了index,我们可以专注于role的责任。
当数据被显示时,它可能是由多个数据聚合而成的。例如,显示图片将包括缩略图和图片名称。这些数据元素中的每一个都需要由视图检索。role参数满足这一需求,它将每个数据元素与一个标签关联,以便视图知道显示的数据类别。
Qt 提供了各种默认角色(DisplayRole、DecorationRole、EditRole等),如果需要,您可以定义自己的角色。这就是我们在AlbumModel.h文件中通过enum Roles所做的事情:我们添加了IdRole和NameRole。
data()函数的主体现在在我们手中!我们首先使用辅助函数isIndexValid()测试index的有效性。查看该章节的源代码以详细了解其功能。视图请求在特定的index处获取数据:我们使用*mAlbums->at(index.row())检索给定index处的album行。
这将在index.row()索引处返回一个unique_ptr<Album>值,并且我们取消引用它以获得一个Album&。在这里,const修饰符很有趣,因为我们处于一个读取函数中,修改album行没有意义。const修饰符在编译时添加了这个检查。
role参数上的switch告诉我们应该返回哪种数据类别。data()函数返回一个QVariant值,这是 Qt 中类型的瑞士军刀。如果我们没有处理指定的角色,我们可以安全地返回album.id()、album.name()或默认的QVariant()。
最后要覆盖的读取函数是roleNames():
QHash<int, QByteArray> AlbumModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[Roles::IdRole] = "id";
roles[Roles::NameRole] = "name";
return roles;
}
在这个抽象级别,我们不知道将使用哪种视图来显示我们的数据。如果视图是用 QML 编写的,它们将需要有关数据结构的一些元信息。roleNames()函数提供了这些信息,因此可以通过 QML 访问角色名称。如果您只为桌面小部件视图编写,可以安全地忽略此函数。我们目前正在构建的库将用于 QML;这就是为什么我们重写了这个函数。
模型的读取部分现在已完成。客户端视图已经拥有了正确查询和显示数据所需的一切。我们现在将研究 AlbumModel 的编辑部分。
我们将从创建一个新的专辑开始。视图将构建一个新的 Album 对象,并将其传递给 Album::addAlbum() 以正确持久化:
QModelIndex AlbumModel::addAlbum(const Album& album)
{
int rowIndex = rowCount();
beginInsertRows(QModelIndex(), rowIndex, rowIndex);
unique_ptr<Album> newAlbum(new Album(album));
mDb.albumDao.addAlbum(*newAlbum);
mAlbums->push_back(move(newAlbum));
endInsertRows();
return index(rowIndex, 0);
}
索引是导航模型数据的一种方式。我们首先做的事情是通过 rowCount() 获取 mAlbums 的大小,以确定这个新专辑的索引。
从这里开始,我们开始使用特定的模型函数:beginInsertRows() 和 endInsertRows()。这些函数封装了实际的数据修改。它们的目的在于自动触发可能感兴趣的任何人的信号:
-
beginInsertRows():此函数通知给定索引的行即将更改 -
endInsertRows():此函数通知行已更改
beginInsertRows() 函数的第一个参数是新元素的 parent。对于模型,根始终是空的 QModelIndex() 构造函数。因为我们没有在 AlbumModel 中处理任何层次关系,所以始终将新元素添加到根是安全的。后续参数是第一个和最后一个修改的索引。我们每次调用插入一个元素,所以我们提供 rowIndex 两次。为了说明这个信号的使用,例如,一个视图可能会显示一个加载消息,告诉用户“正在保存 5 个新专辑”。
对于 endInsertRows(),感兴趣的视图可能会隐藏保存消息并显示“保存完成”。
起初这可能会看起来有些奇怪,但它使 Qt 能够以通用的方式自动为我们处理很多信号。你很快就会看到,当在 第四章 设计应用程序的 UI 时,这种做法是如何很好地工作的,征服桌面 UI。
实际的插入操作在 beginInsertRows() 指令之后开始。我们首先使用 unique_ptr<Album> newAlbum 创建 album 行的副本。然后,使用 mDb.albumDao.addAlbum(*newAlbum) 将此对象插入数据库中。不要忘记,AlbumDao::addAlbum() 函数还会修改传入的专辑,将其 mId 设置为最后一个 SQLITE3 插入的 ID。
最后,newAlbum 被添加到 mAlbums 中,并且其所有权也通过 std::move() 转移。返回值提供了这个新专辑的索引对象,它简单地将行封装在一个 QModelIndex 对象中。
让我们继续编辑函数,使用 setData():
bool AlbumModel::setData(const QModelIndex& index, const QVariant& value, int role)
{
if (!isIndexValid(index)
|| role != Roles::NameRole) {
return false;
}
Album& album = *mAlbums->at(index.row());
album.setName(value.toString());
mDb.albumDao.updateAlbum(album);
emit dataChanged(index, index);
return true;
}
当视图想要更新数据时,会调用此函数。其签名与 data() 非常相似,但增加了额外的参数值。
主体部分也遵循相同的逻辑。在这里,album 行是一个 Album&,没有 const 关键字。唯一可以编辑的值是名称,这是在对象上完成的,然后持久化到数据库中。
我们必须发出dataChanged()信号来通知所有感兴趣的人,对于给定的索引(起始索引和结束索引),有一行发生了变化。这个强大的机制集中了所有数据的状态,使得可能的视图(例如相册列表和当前相册详情)能够自动刷新。
函数的返回值简单地指示数据更新是否成功。在生产应用程序中,您应该测试数据库处理的成功并返回相关值。
最后,我们将介绍最后一个编辑函数removeRows():
bool AlbumModel::removeRows(int row, int count, const QModelIndex& parent)
{
if (row < 0
|| row >= rowCount()
|| count < 0
|| (row + count) > rowCount()) {
return false;
}
beginRemoveRows(parent, row, row + count - 1);
int countLeft = count;
while (countLeft--) {
const Album& album = *mAlbums->at(row + countLeft);
mDb.albumDao.removeAlbum(album.id());
}
mAlbums->erase(mAlbums->begin() + row,
mAlbums->begin() + row + count);
endRemoveRows();
return true;
}
函数签名现在应该看起来很熟悉了。当一个视图想要删除行时,它必须提供起始行、要删除的行数以及行的父级。
之后,就像我们对addAlbum()所做的那样,我们用两个函数包装有效的删除操作:
-
beginRemoveRows()函数,它期望父级、起始索引和最后一个索引 -
endRemoveRows()函数,它只是简单地触发模型框架中的自动信号
函数的其余部分并不难理解。我们遍历要删除的行,并对每一行,我们从数据库中删除它,并从mAlbums中移除它。我们简单地从我们的内存mAlbums向量中检索相册,并使用mDb.albumDao.removeAlbum(album.id())处理实际的数据库删除操作。
AlbumModel类现在已经完全介绍完毕。您现在可以创建一个新的 C++类,并将其命名为PictureModel。
我们将不会详细讨论PictureModel类。主要部分是相同的(你只需将数据类Album替换为Picture)。然而,有一个主要区别:PictureModel始终处理给定相册的图片。这个设计选择说明了如何仅通过一些简单的信号将两个模型链接起来。
这里是PictureModel.h的更新版本:
#include <memory>
#include <vector>
#include <QAbstractListModel>
#include "gallery-core_global.h"
#include "Picture.h"
class Album;
class DatabaseManager;
class AlbumModel;
class GALLERYCORESHARED_EXPORT PictureModel : public QAbstractListModel
{
Q_OBJECT
public:
enum PictureRole {
FilePathRole = Qt::UserRole + 1
};
PictureModel(const AlbumModel& albumModel, QObject* parent = 0);
QModelIndex addPicture(const Picture& picture);
int rowCount(const QModelIndex& parent = QModelIndex()) const override;
QVariant data(const QModelIndex& index, int role) const override;
bool removeRows(int row, int count, const QModelIndex& parent) override;
void setAlbumId(int albumId);
void clearAlbum();
public slots:
void deletePicturesForAlbum();
private:
void loadPictures(int albumId);
bool isIndexValid(const QModelIndex& index) const;
private:
DatabaseManager& mDb;
int mAlbumId;
std::unique_ptr<std::vector<std::unique_ptr<Picture>>> mPictures;
};
有趣的部分是关于相册的部分。如您所见,构造函数期望一个AlbumModel。这个类也存储当前的mAlbumId,以便只能请求给定相册的图片。让我们看看构造函数实际上做了什么:
PictureModel::PictureModel(const AlbumModel& albumModel, QObject* parent) :
QAbstractListModel(parent),
mDb(DatabaseManager::instance()),
mAlbumId(-1),
mPictures(new vector<unique_ptr<Picture>>())
{
connect(&albumModel, &AlbumModel::rowsRemoved,
this, &PictureModel::deletePicturesForAlbum);
}
如您所见,albumModel类仅用于将信号连接到我们的槽deletePicturesForAlbum(),这一点不言自明。这确保了数据库始终有效:如果拥有相册被删除,则应该删除图片。当AlbumModel发出rowsRemoved信号时,这将自动完成。
现在,mPictures不是初始化为数据库中的所有图片。因为我们选择将PictureModel限制为只处理给定相册的图片,所以在PictureModel的构造时我们不知道要选择哪个相册。加载只能在相册被选择时进行,在setAlbumId()中:
void PictureModel::setAlbumId(int albumId)
{
beginResetModel();
mAlbumId = albumId;
loadPictures(mAlbumId);
endResetModel();
}
当相册发生变化时,我们会完全重新加载 PictureModel。重新加载阶段被 beginResetModel() 和 endResetModel() 函数包裹。它们会通知任何附加的视图,它们的状态也应该被重置。模型之前报告的任何数据(例如,QModelIndex)都变得无效。
loadPictures() 函数相当直接:
void PictureModel::loadPictures(int albumId)
{
if (albumId <= 0) {
mPictures.reset(new vector<unique_ptr<Picture>>());
return;
}
mPictures = mDb.pictureDao.picturesForAlbum(albumId);
}
按照惯例,我们决定,如果提供了一个负的 album id,则清除图片。为此,我们使用调用 mPictures.reset(new vector<unique_ptr<Picture>>()) 来重新初始化 mPictures。这将调用拥有向量的析构函数,进而对 Picture 元素执行相同的操作。我们强制 mPictures 总是拥有一个有效的向量对象,以避免任何可能的空引用(例如,在 PictureModel::rowCount() 中)。
之后,我们只需将给定 albumId 的数据库图片分配给 mPictures。因为我们每个级别都使用智能指针,所以我们甚至看不到任何特定的语义。不过,mPicture 是一个 unique_ptr<vector<unique_ptr<Picture>>>。当调用 = 操作符时,unique_ptr 会重载它,发生以下两个操作:
-
右侧(从数据库检索到的图片)的所有权转移到了
mPictures -
mPictures的旧内容会自动删除
它实际上等同于调用 mPictures.reset() 然后执行 mPictures = move(mDb.pictureDao.picturesForAlbum(albumId))。使用 = 重载,一切都会变得流畅得多,阅读起来也更加愉快。
PictureModel 展示了模型范式可以有多灵活。你可以轻松地将其适应到自己的用例中,而无需进行任何强耦合。毕竟,albumModel 只用于连接到单个信号;没有保留的引用。类的其余部分可以在章节的源代码中找到。
摘要
本章是一次创建一个定义良好的 gallery-core 库的旅程。我们使用 .pro 文件研究了高级技术,以将项目拆分为子模块,在智能指针的帮助下将数据持久化到 SQLITE3 数据库中,并最终研究了 Qt 中的模型/视图架构是如何工作的。
从现在起,Qt 的项目组织对你来说应该不再令人生畏。下一章将继续我们停止的地方:库已经准备好了,现在让我们制作出伟大的 QWidgets,以拥有一个令人惊叹的画廊应用程序,并查看模型的另一面:视图层。
第四章. 拿下桌面 UI
在上一章中,我们使用 Qt 模型构建了我们画廊的大脑。现在是时候使用这个引擎构建桌面应用程序了。此软件将使用 gallery-core 库提供的所有功能,在你的计算机上实现一个完全可用的画廊。
第一项任务是将你的项目共享库链接到这个新应用。然后你将学习如何创建自定义小部件,何时使用 Qt 视图,以及如何与模型同步它们。
本章将涵盖以下主题:
-
将应用程序链接到项目库
-
Qt 模型/视图
-
Qt 资源文件
-
推广自定义小部件
创建与核心共享库链接的 GUI
gallery-core 共享库现在已准备就绪。让我们看看如何创建桌面 GUI 项目。我们将创建一个名为 gallery-desktop 的 Qt Widgets 应用程序子项目。与经典的 Qt Widgets 应用程序相比,只有第一步不同。在主项目上右键单击,选择 ch04-gallery-desktop | New subproject | Application | Qt Widgets Application | Choose。
你将获得一个像这样的多项目层次结构:

现在是时候将此 gallery-desktop 应用程序链接到 gallery-core。你可以自己编辑 gallery-desktop.pro 文件,或者像这样使用 Qt Creator 向导:在项目上右键单击,选择 gallery-desktop | Add library | Internal library | gallery-core | Next | Finish。以下是更新的 gallery-desktop.pro:
QT += core gui
TARGET = desktop-gallery
TEMPLATE = app
SOURCES += main.cpp\
MainWindow.cpp
HEADERS += MainWindow.h
FORMS += MainWindow.ui
win32:CONFIG(release, debug|release): LIBS += -L$$OUT_PWD/../gallery-core/release/ -lgallery-core
else:win32:CONFIG(debug, debug|release): LIBS += -L$$OUT_PWD/../gallery-core/debug/ -lgallery-core
else:unix: LIBS += -L$$OUT_PWD/../gallery-core/ -lgallery-core
INCLUDEPATH += $$PWD/../gallery-core
DEPENDPATH += $$PWD/../gallery-core
LIBS 变量指定了在此项目中要链接的库。语法非常简单:你可以使用 -L 前缀提供库路径,使用 -l 前缀提供库名称。
LIBS += -L<pathToLibrary> -l<libraryName>
默认情况下,在 Windows 上编译 Qt 项目将创建一个 debug 和 release 子目录。这就是为什么根据平台创建不同的 LIBS 版本。
现在应用程序已链接到 gallery-core 库并且知道其位置,我们必须指出库头文件的位置。这就是为什么我们必须将 gallery-core 源路径添加到 INCLUDEPATH 和 DEPENDPATH。
为了成功完成所有这些任务,qmake 提供了一些有用的变量:
-
$$OUT_PWD:输出目录的绝对路径 -
$$PWD:当前.pro文件的绝对路径
为了确保 qmake 在编译桌面应用程序之前编译共享库,我们必须根据以下片段更新 ch04-gallery-desktop.pro 文件:
TEMPLATE = subdirs
SUBDIRS += \
gallery-core \
gallery-desktop
gallery-desktop.depends = gallery-core
depends 属性明确指出必须在 gallery-desktop 之前构建 gallery-core。
小贴士
尽量始终使用 depends 属性,而不是依赖于 CONFIG += ordered,后者仅指定一个简单的列表顺序。depends 属性有助于 qmake 在可能的情况下并行处理你的项目。
在盲目编码之前,我们将花些时间思考 UI 架构。我们从gallery-core库中有许多功能要实现。我们应该将这些功能拆分为独立的 QWidgets。最终的应用程序将看起来像这样:

我们未来的画廊桌面就在这里!
照片的扩展视图将看起来像这样:

双击缩略图以全尺寸显示。
总结主要的 UI 组件:
-
AlbumListWidget:此组件列出所有现有专辑 -
AlbumWidget:此组件显示所选专辑及其缩略图 -
PictureWidget:此组件以全尺寸显示图片
这是我们组织的方式:

每个小部件都有一个定义的角色,并将处理特定功能:
| 类名 | 功能 |
|---|---|
MainWindow |
处理画廊和当前图片之间的切换 |
GalleryWidget |
-
显示现有专辑
-
专辑选择
-
专辑创建
|
AlbumListWidget |
|---|
-
显示现有专辑
-
专辑选择
-
专辑创建
|
AlbumWidget |
|---|
-
显示现有图片作为缩略图
-
在专辑中添加图片
-
专辑重命名
-
专辑删除
-
图片选择
|
PictureWidget |
|---|
-
显示所选图片
-
图片选择
-
图片删除
|
在核心共享库中,我们使用了标准容器(vector)的智能指针。通常,在 GUI 项目中,我们倾向于只使用 Qt 容器及其强大的父子所有权系统。我们认为这种方法更合适。这就是为什么我们将依赖于 Qt 容器来构建 GUI(并且不会使用智能指针)在本章中。
我们现在可以安全地开始创建我们的小部件;它们都是从Qt Designer 表单类创建的。如果您有记忆缺失,您可以在第一章的Get Your Qt Feet Wet部分中查看自定义 QWidget。
使用 AlbumListWidget 列出您的专辑
此小部件必须提供创建新专辑和显示现有专辑的方法。选择专辑还必须触发一个事件,该事件将被其他小部件用于显示正确数据。AlbumListWidget组件是本项目使用 Qt 视图机制的最简单小部件。在跳转到下一个小部件之前,花时间彻底理解AlbumListWidget。
以下截图显示了文件的表单编辑器视图,AlbumListWidget.ui:

布局非常简单。组件描述如下:
-
AlbumListWidget组件使用垂直布局来显示列表上方的创建按钮 -
frame组件包含一个吸引人的按钮 -
createAlbumButton组件处理专辑创建 -
albumList组件显示专辑列表
你应该已经识别出这里使用的多数类型。让我们花点时间来谈谈真正的新类型:QListView。正如我们在上一章中看到的,Qt 提供了一个模型/视图架构。这个系统依赖于特定的接口,你必须实现这些接口以通过你的模型类提供通用的数据访问。这就是我们在gallery-core项目中使用AlbumModel和PictureModel类所做的事情。
现在是处理视图部分的时候了。视图负责数据的展示。它还将处理用户交互,如选择、拖放或项目编辑。幸运的是,为了完成这些任务,视图得到了其他 Qt 类(如QItemSelectionModel、QModelIndex或QStyledItemDelegate)的帮助,我们将在本章中很快使用这些类。
现在,我们可以享受 Qt 提供的现成视图之一:
-
QListView: 这个视图以简单列表的形式显示模型中的项目 -
QTableView: 这个视图以二维表格的形式显示模型中的项目 -
QTreeView: 这个视图以列表层次结构的形式显示项目
在这里,选择相当明显,因为我们想显示一系列专辑名称。但在更复杂的情况下,选择适当视图的一个经验法则是查找模型类型;这里我们想为类型为QAbstractListModel的AlbumModel添加一个视图,所以QListView类看起来是正确的。
如前一个截图所示,createAlbumButton对象有一个图标。你可以通过选择小部件属性:图标 | 选择资源来向QPushButton类添加一个图标。你现在可以从resource.qrc文件中选择一张图片。
Qt 资源文件是嵌入二进制文件到你的应用程序中的文件集合。你可以存储任何类型的文件,但我们通常用它来存储图片、声音或翻译文件。要创建资源文件,右键单击项目名称,然后选择添加新 | Qt | Qt 资源文件。Qt Creator 将创建一个默认文件,resource.qrc,并在你的文件gallery-desktop.pro中添加这一行:
RESOURCES += resource.qrc
资源文件可以主要以两种方式显示:资源编辑器和纯文本编辑器。你可以通过右键单击资源文件并选择打开方式来选择一个编辑器。
资源编辑器是一个可视化编辑器,它可以帮助你轻松地在资源文件中添加和删除文件,如下一个截图所示:

纯文本编辑器将像这样显示基于 XML 的文件resource.qrc:
<RCC>
<qresource prefix="/">
<file>icons/album-add.png</file>
<file>icons/album-delete.png</file>
<file>icons/album-edit.png</file>
<file>icons/back-to-gallery.png</file>
<file>icons/photo-add.png</file>
<file>icons/photo-delete.png</file>
<file>icons/photo-next.png</file>
<file>icons/photo-previous.png</file>
</qresource>
</RCC>
在构建时,qmake和rcc(Qt 资源编译器)将你的资源嵌入到应用程序的二进制文件中。
现在表单部分已经清晰,我们可以分析AlbumListWidget.h文件:
#include <QWidget>
#include <QItemSelectionModel>
namespace Ui {
class AlbumListWidget;
}
class AlbumModel;
class AlbumListWidget : public QWidget
{
Q_OBJECT
public:
explicit AlbumListWidget(QWidget *parent = 0);
~AlbumListWidget();
void setModel(AlbumModel* model);
void setSelectionModel(QItemSelectionModel* selectionModel);
private slots:
void createAlbum();
private:
Ui::AlbumListWidget* ui;
AlbumModel* mAlbumModel;
};
setModel()和setSelectionModel()函数是这个片段中最重要的一行。这个小部件需要两个东西才能正确工作:
-
AlbumModel: 这是一个提供数据访问的模型类。我们已经在gallery-core项目中创建了此类。 -
QItemSelectionModel: 这是一个 Qt 类,用于处理视图中的选择。默认情况下,视图使用它们自己的选择模型。与不同的视图或小部件共享相同的选择模型将帮助我们轻松同步专辑选择。
这是AlbumListWidget.cpp的主要部分:
#include "AlbumListWidget.h"
#include "ui_AlbumListWidget.h"
#include <QInputDialog>
#include "AlbumModel.h"
AlbumListWidget::AlbumListWidget(QWidget *parent) :
QWidget(parent),
ui(new Ui::AlbumListWidget),
mAlbumModel(nullptr)
{
ui->setupUi(this);
connect(ui->createAlbumButton, &QPushButton::clicked,
this, &AlbumListWidget::createAlbum);
}
AlbumListWidget::~AlbumListWidget()
{
delete ui;
}
void AlbumListWidget::setModel(AlbumModel* model)
{
mAlbumModel = model;
ui->albumList->setModel(mAlbumModel);
}
void AlbumListWidget::setSelectionModel(QItemSelectionModel* selectionModel)
{
ui->albumList->setSelectionModel(selectionModel);
}
这两个设置器将主要用于设置albumList的模型和选择模型。我们的QListView类将随后自动请求模型(AlbumModel)获取每个对象的行数和Qt::DisplayRole(专辑的名称)。
现在我们来看一下处理专辑创建的AlbumListWidget.cpp文件的最后一部分:
void AlbumListWidget::createAlbum()
{
if(!mAlbumModel) {
return;
}
bool ok;
QString albumName = QInputDialog::getText(this,
"Create a new Album",
"Choose an name",
QLineEdit::Normal,
"New album",
&ok);
if (ok && !albumName.isEmpty()) {
Album album(albumName);
QModelIndex createdIndex = mAlbumModel->addAlbum(album);
ui->albumList->setCurrentIndex(createdIndex);
}
}
我们已经在第一章,初识 Qt中使用了QInputDialog类。这次我们用它来询问用户输入专辑名称。然后我们创建一个具有请求名称的Album类。这个对象只是一个“数据持有者”;addAlbum()将使用它来创建和存储具有唯一 ID 的真实对象。
addAlbum()函数返回与我们创建的专辑相对应的QModelIndex值。从这里,我们可以请求列表视图选择这个新专辑。
创建ThumbnailProxyModel
未来的AlbumWidget视图将显示与所选Album相关联的图片缩略图网格。在第三章,划分你的项目和统治你的代码中,我们设计了gallery-core库,使其对图片的显示方式保持无知:Picture类只包含一个mUrl字段。
换句话说,缩略图的生成必须在gallery-desktop而不是gallery-core中进行。我们已经有负责检索Picture信息的PictureModel类,因此能够扩展其行为以包含缩略图数据将非常棒。
在 Qt 中,使用QAbstractProxyModel类及其子类可以实现这一点。这个类的主要目的是处理来自基础QAbstractItemModel的数据(排序、过滤、添加数据等),并通过代理原始模型将其呈现给视图。用数据库的类比,你可以将其视为对表的投影。
QAbstractProxyModel类有两个子类:
-
QIdentityProxyModel子类在不进行任何修改的情况下代理其源模型(所有索引匹配)。如果你想要转换data()函数,这个类是合适的。 -
QSortFilterProxyModel子类能够代理其源模型,并具有排序和过滤传递数据的权限。
前者QIdentityProxyModel符合我们的要求。我们唯一需要做的是扩展data()函数以包含缩略图生成内容。创建一个名为ThumbnailProxyModel的新类。以下是ThumbnailProxyModel.h文件:
#include <QIdentityProxyModel>
#include <QHash>
#include <QPixmap>
class PictureModel;
class ThumbnailProxyModel : public QIdentityProxyModel
{
public:
ThumbnailProxyModel(QObject* parent = 0);
QVariant data(const QModelIndex& index, int role) const override;
void setSourceModel(QAbstractItemModel* sourceModel) override;
PictureModel* pictureModel() const;
private:
void generateThumbnails(const QModelIndex& startIndex, int count);
void reloadThumbnails();
private:
QHash<QString, QPixmap*> mThumbnails;
};
此类扩展QIdentityProxyModel并重写了一些函数:
-
data()函数用于向ThumbnailProxyModel的客户端提供缩略图数据 -
setSourceModel()函数用于注册sourceModel发出的信号
剩余的自定义函数有以下目标:
-
pictureModel()是一个辅助函数,将sourceModel转换为PictureModel* -
generateThumbnails()函数负责为给定的一组图片生成QPixmap缩略图 -
reloadThumbnails()是一个辅助函数,在调用generateThumbnails()之前清除存储的缩略图
如你所猜,mThumbnails类使用filepath作为键存储QPixmap*缩略图。
我们现在切换到ThumbnailProxyModel.cpp文件,并从头开始构建它。让我们关注generateThumbnails():
const unsigned int THUMBNAIL_SIZE = 350;
...
void ThumbnailProxyModel::generateThumbnails(
const QModelIndex& startIndex, int count)
{
if (!startIndex.isValid()) {
return;
}
const QAbstractItemModel* model = startIndex.model();
int lastIndex = startIndex.row() + count;
for(int row = startIndex.row(); row < lastIndex; row++) {
QString filepath = model->data(model->index(row, 0),
PictureModel::Roles::FilePathRole).toString();
QPixmap pixmap(filepath);
auto thumbnail = new QPixmap(pixmap
.scaled(THUMBNAIL_SIZE, THUMBNAIL_SIZE,
Qt::KeepAspectRatio,
Qt::SmoothTransformation));
mThumbnails.insert(filepath, thumbnail);
}
}
此函数根据参数(startIndex和count)指定的范围生成缩略图。对于每张图片,我们使用model->data()从原始模型中检索filepath,并生成一个插入到mThumbnails QHash 中的缩小版的QPixmap。请注意,我们使用const THUMBNAIL_SIZE任意设置缩略图大小。图片被缩小到这个大小,并保持原始图片的宽高比。
每次加载相册时,我们应该清除mThumbnails类的内容并加载新的图片。这项工作由reloadThumbnails()函数完成:
void ThumbnailProxyModel::reloadThumbnails()
{
qDeleteAll(mThumbnails);
mThumbnails.clear();
generateThumbnails(index(0, 0), rowCount());
}
在此函数中,我们简单地清除mThumbnails的内容,并使用表示应生成所有缩略图的参数调用generateThumbnails()函数。让我们看看这两个函数将在何时被使用,在setSourceModel()中:
void ThumbnailProxyModel::setSourceModel(QAbstractItemModel* sourceModel)
{
QIdentityProxyModel::setSourceModel(sourceModel);
if (!sourceModel) {
return;
}
connect(sourceModel, &QAbstractItemModel::modelReset,
[this] {
reloadThumbnails();
});
connect(sourceModel, &QAbstractItemModel::rowsInserted,
[this] (const QModelIndex& parent, int first, int last) {
generateThumbnails(index(first, 0), last - first + 1);
});
}
当调用setSourceModel()函数时,ThumbnailProxyModel类被配置为知道应该代理哪个基本模型。在此函数中,我们注册了 lambda 到原始模型发出的两个信号:
-
当需要为特定相册加载图片时,会触发
modelReset信号。在这种情况下,我们必须完全重新加载缩略图。 -
rowsInserted信号在添加新图片时触发。此时,应调用generateThumbnails以更新mThumbnails并包含这些新来者。
最后,我们必须覆盖data()函数:
QVariant ThumbnailProxyModel::data(const QModelIndex& index, int role) const
{
if (role != Qt::DecorationRole) {
return QIdentityProxyModel::data(index, role);
}
QString filepath = sourceModel()->data(index,
PictureModel::Roles::FilePathRole).toString();
return *mThumbnails[filepath];
}
对于任何不是Qt::DecorationRole的角色,都会调用父类data()。在我们的案例中,这触发了原始模型PictureModel的data()函数。在那之后,当data()必须返回缩略图时,会检索由index引用的图片的filepath并用于返回mThumbnails的QPixmap对象。幸运的是,QPixmap可以隐式转换为QVariant,所以我们在这里不需要做任何特别的事情。
在ThumbnailProxyModel类中要覆盖的最后一个函数是pictureModel()函数:
PictureModel* ThumbnailProxyModel::pictureModel() const
{
return static_cast<PictureModel*>(sourceModel());
}
将与ThumbnailProxyModel交互的类需要调用一些特定于PictureModel的函数来创建或删除图片。此函数是一个辅助函数,用于集中将sourceModel转换为PictureModel*。
作为旁注,我们本可以尝试动态生成缩略图以避免在专辑加载(以及调用generateThumbnails())过程中可能出现的初始瓶颈。然而,data()是一个const函数,这意味着它不能修改ThumbnailProxyModel实例。这排除了在data()函数中生成缩略图并将其存储在mThumbnails中的任何方法。
如您所见,QIdentityProxyModel以及更一般的QAbstractProxyModel是向现有模型添加行为而不破坏它的宝贵工具。在我们的案例中,这是通过设计强制执行的,因为PictureModel类是在gallery-core中定义的,而不是在gallery-desktop中。修改PictureModel意味着修改gallery-core并可能破坏库的其他用户的其行为。这种方法让我们可以保持事物的清晰分离。
使用 AlbumWidget 显示所选专辑
此小部件将显示从AlbumListWidget选择的专辑的数据。一些按钮将允许我们与此专辑交互。
这是AlbumWidget.ui文件的布局:

顶部框架albumInfoFrame采用水平布局,包含:
-
albumName:此对象显示专辑的名称(在设计师中为Lorem ipsum) -
addPicturesButton:此对象允许用户通过选择文件添加图片 -
editButton:此对象用于重命名专辑 -
deleteButton:此对象用于删除专辑
底部元素thumbnailListView是一个QListView。此列表视图表示来自PictureModel的项目。默认情况下,QListView能够从模型请求Qt::DisplayRole和Qt::DecorationRole来显示图片。
查看头文件AlbumWidget.h:
#include <QWidget>
#include <QModelIndex>
namespace Ui {
class AlbumWidget;
}
class AlbumModel;
class PictureModel;
class QItemSelectionModel;
class ThumbnailProxyModel;
class AlbumWidget : public QWidget
{
Q_OBJECT
public:
explicit AlbumWidget(QWidget *parent = 0);
~AlbumWidget();
void setAlbumModel(AlbumModel* albumModel);
void setAlbumSelectionModel(QItemSelectionModel* albumSelectionModel);
void setPictureModel(ThumbnailProxyModel* pictureModel);
void setPictureSelectionModel(QItemSelectionModel* selectionModel);
signals:
void pictureActivated(const QModelIndex& index);
private slots:
void deleteAlbum();
void editAlbum();
void addPictures();
private:
void clearUi();
void loadAlbum(const QModelIndex& albumIndex);
private:
Ui::AlbumWidget* ui;
AlbumModel* mAlbumModel;
QItemSelectionModel* mAlbumSelectionModel;
ThumbnailProxyModel* mPictureModel;
QItemSelectionModel* mPictureSelectionModel;
};
由于此小部件需要处理Album和Picture数据,因此此类具有AlbumModel和ThumbnailProxyModel设置器。我们还希望知道并与其他小部件和视图(即AlbumListWidget)共享模型选择。这就是为什么我们还有Album和Picture模型选择设置器的原因。
当用户双击缩略图时,将触发 pictureActivated() 信号。我们将在稍后看到 MainWindow 如何连接到该信号以显示全尺寸的图片。
当用户点击这些按钮之一时,将调用私有槽 deleteAlbum()、editAlbum() 和 addPictures()。
最后,将调用 loadAlbum() 函数来更新特定相册的 UI。clearUi() 函数将用于清除此小部件 UI 显示的所有信息。
查看 AlbumWidget.cpp 文件实现的开始部分:
#include "AlbumWidget.h"
#include "ui_AlbumWidget.h"
#include <QInputDialog>
#include <QFileDialog>
#include "AlbumModel.h"
#include "PictureModel.h"
AlbumWidget::AlbumWidget(QWidget *parent) :
QWidget(parent),
ui(new Ui::AlbumWidget),
mAlbumModel(nullptr),
mAlbumSelectionModel(nullptr),
mPictureModel(nullptr),
mPictureSelectionModel(nullptr)
{
ui->setupUi(this);
clearUi();
ui->thumbnailListView->setSpacing(5);
ui->thumbnailListView->setResizeMode(QListView::Adjust);
ui->thumbnailListView->setFlow(QListView::LeftToRight);
ui->thumbnailListView->setWrapping(true);
connect(ui->thumbnailListView, &QListView::doubleClicked,
this, &AlbumWidget::pictureActivated);
connect(ui->deleteButton, &QPushButton::clicked,
this, &AlbumWidget::deleteAlbum);
connect(ui->editButton, &QPushButton::clicked,
this, &AlbumWidget::editAlbum);
connect(ui->addPicturesButton, &QPushButton::clicked,
this, &AlbumWidget::addPictures);
}
AlbumWidget::~AlbumWidget()
{
delete ui;
}
构造函数配置 thumbnailListView,这是我们用于显示当前选中相册缩略图的 QListView。我们在此设置各种参数:
-
setSpacing():在此参数中,默认情况下项目是粘合在一起的。您可以在它们之间添加间距。 -
setResizeMode():此参数在视图大小调整时动态布局项目。默认情况下,即使视图大小调整,项目也会保持其原始位置。 -
setFlow():此参数指定列表方向。在这里,我们希望从左到右显示项目。默认方向是TopToBottom。 -
setWrapping():此参数允许当没有足够的空间在可见区域显示项目时,项目可以换行。默认情况下,不允许换行,将显示滚动条。
构造函数的末尾执行所有与 UI 相关的信号连接。第一个是一个很好的信号中继示例,在 第一章 中解释,初识 Qt。我们将 QListView::doubleClicked 信号连接到我们的类信号 AlbumWidget::pictureActivated。其他连接是常见的;我们希望在用户点击按钮时调用特定的槽。像往常一样,在 Qt Designer Form Class 中,析构函数将删除成员变量 ui。
让我们看看 AlbumModel 设置器的实现:
void AlbumWidget::setAlbumModel(AlbumModel* albumModel)
{
mAlbumModel = albumModel;
connect(mAlbumModel, &QAbstractItemModel::dataChanged,
[this] (const QModelIndex &topLeft) {
if (topLeft == mAlbumSelectionModel->currentIndex()) {
loadAlbum(topLeft);
}
});
}
void AlbumWidget::setAlbumSelectionModel(QItemSelectionModel* albumSelectionModel)
{
mAlbumSelectionModel = albumSelectionModel;
connect(mAlbumSelectionModel,
&QItemSelectionModel::selectionChanged,
[this] (const QItemSelection &selected) {
if (selected.isEmpty()) {
clearUi();
return;
}
loadAlbum(selected.indexes().first());
});
}
如果所选相册的数据已更改,我们需要使用 loadAlbum() 函数更新 UI。进行测试以确保更新的相册是当前选中的相册。请注意,QAbstractItemModel::dataChanged() 函数有三个参数,但 lambda 插槽语法允许我们省略未使用的参数。
我们的 AlbumWidget 组件必须根据当前选中的相册更新其 UI。由于我们共享相同的选择模型,每次用户从 AlbumListWidget 中选择一个相册时,都会触发 QItemSelectionModel::selectionChanged 信号。在这种情况下,我们通过调用 loadAlbum() 函数来更新 UI。由于我们不支持相册多选,我们可以将过程限制在第一个选定的元素上。如果选择为空,我们只需清除 UI。
现在,轮到 PictureModel 设置器的实现:
void AlbumWidget::setPictureModel(PictureModel* pictureModel)
{
mPictureModel = pictureModel;
ui->thumbnailListView->setModel(mPictureModel);
}
void AlbumWidget::setPictureSelectionModel(QItemSelectionModel* selectionModel)
{
ui->thumbnailListView->setSelectionModel(selectionModel);
}
这里非常简单。我们设置了thumbnailListView的模型和选择模型,我们的QListView将显示所选相册的缩略图。我们还保留了图片模型以供以后操作数据。
我们现在可以逐个介绍功能。让我们从相册删除开始:
void AlbumWidget::deleteAlbum()
{
if (mAlbumSelectionModel->selectedIndexes().isEmpty()) {
return;
}
int row = mAlbumSelectionModel->currentIndex().row();
mAlbumModel->removeRow(row);
// Try to select the previous album
QModelIndex previousModelIndex = mAlbumModel->index(row - 1,
0);
if(previousModelIndex.isValid()) {
mAlbumSelectionModel->setCurrentIndex(previousModelIndex,
QItemSelectionModel::SelectCurrent);
return;
}
// Try to select the next album
QModelIndex nextModelIndex = mAlbumModel->index(row, 0);
if(nextModelIndex.isValid()) {
mAlbumSelectionModel->setCurrentIndex(nextModelIndex,
QItemSelectionModel::SelectCurrent);
return;
}
}
在deleteAlbum()函数中,最重要的任务是检索mAlbumSelectionModel中的当前行索引。然后,我们可以请求mAlbumModel删除此行。函数的其余部分将仅尝试自动选择上一个或下一个相册。再次强调,因为我们共享相同的选择模型,AlbumListWidget将自动更新其相册选择。
下面的代码片段显示了相册重命名功能:
void AlbumWidget::editAlbum()
{
if (mAlbumSelectionModel->selectedIndexes().isEmpty()) {
return;
}
QModelIndex currentAlbumIndex =
mAlbumSelectionModel->selectedIndexes().first();
QString oldAlbumName = mAlbumModel->data(currentAlbumIndex,
AlbumModel::Roles::NameRole).toString();
bool ok;
QString newName = QInputDialog::getText(this,
"Album's name",
"Change Album name",
QLineEdit::Normal,
oldAlbumName,
&ok);
if (ok && !newName.isEmpty()) {
mAlbumModel->setData(currentAlbumIndex,
newName,
AlbumModel::Roles::NameRole);
}
}
在这里,QInputDialog类将帮助我们实现一个功能。现在你应该对其行为有信心。此函数执行三个步骤:
-
从相册模型中检索当前名称。
-
生成一个出色的输入对话框。
-
请求相册模型更新名称
如您所见,当与ItemDataRole结合使用时,模型中的通用函数data()和setData()非常强大。正如已经解释的,我们不会直接更新我们的 UI;这将被自动执行,因为setData()会发出一个信号dataChanged(),该信号由AlbumWidget处理。
最后一个功能允许我们在当前相册中添加一些新的图片文件:
void AlbumWidget::addPictures()
{
QStringList filenames =
QFileDialog::getOpenFileNames(this,
"Add pictures",
QDir::homePath(),
"Picture files (*.jpg *.png)");
if (!filenames.isEmpty()) {
QModelIndex lastModelIndex;
for (auto filename : filenames) {
Picture picture(filename);
lastModelIndex = mPictureModelâpictureModel()->addPicture(picture);
}
ui->thumbnailListView->setCurrentIndex(lastModelIndex);
}
}
QFileDialog类用于帮助用户选择多个图片文件。对于每个文件名,我们创建一个Picture数据持有者,就像我们在本章创建相册时已经看到的那样。然后我们可以请求mPictureModel将此图片添加到当前相册中。请注意,因为mPictureModel是一个ThumbnailProxyModel类,我们必须使用辅助函数pictureModel()检索实际的PictureModel。由于addPicture()函数返回相应的QModelIndex,我们最终选择thumbnailListView中最新的图片。
让我们完成AlbumWidget.cpp:
void AlbumWidget::clearUi()
{
ui->albumName->setText("");
ui->deleteButton->setVisible(false);
ui->editButton->setVisible(false);
ui->addPicturesButton->setVisible(false);
}
void AlbumWidget::loadAlbum(const QModelIndex& albumIndex)
{
mPictureModel->pictureModel()->setAlbumId(mAlbumModel->data(albumIndex,
AlbumModel::Roles::IdRole).toInt());
ui->albumName->setText(mAlbumModel->data(albumIndex,
Qt::DisplayRole).toString());
ui->deleteButton->setVisible(true);
ui->editButton->setVisible(true);
ui->addPicturesButton->setVisible(true);
}
clearUi()函数清除相册的名称并隐藏按钮,而loadAlbum()函数检索Qt::DisplayRole(相册的名称)并显示按钮。
使用 PictureDelegate 增强缩略图
默认情况下,QListView类将请求Qt::DisplayRole和Qt::DecorationRole以显示每个项目的文本和图片。因此,我们已经有了一个免费的可视结果,看起来像这样:

然而,我们的画廊应用程序值得更好的缩略图渲染。希望我们可以通过使用视图的代理概念轻松地自定义它。QListView类提供了一个默认的项目渲染。我们可以通过创建一个继承自QStyledItemDelegate的类来自定义项目渲染。目标是绘制像以下截图所示的带有名称横幅的梦幻缩略图:

让我们看看PictureDelegate.h:
#include <QStyledItemDelegate>
class PictureDelegate : public QStyledItemDelegate
{
Q_OBJECT
public:
PictureDelegate(QObject* parent = 0);
void paint(QPainter* painter, const QStyleOptionViewItem&
option, const QModelIndex& index) const override;
QSize sizeHint(const QStyleOptionViewItem& option,
const QModelIndex& index) const override;
};
没错,我们只需要重写两个函数。最重要的函数是paint(),它将允许我们按照我们想要的方式绘制项目。sizeHint()函数将用于指定项目大小。
我们现在可以在PictureDelegate.cpp中看到画家的工作:
#include "PictureDelegate.h"
#include <QPainter>
const unsigned int BANNER_HEIGHT = 20;
const unsigned int BANNER_COLOR = 0x303030;
const unsigned int BANNER_ALPHA = 200;
const unsigned int BANNER_TEXT_COLOR = 0xffffff;
const unsigned int HIGHLIGHT_ALPHA = 100;
PictureDelegate::PictureDelegate(QObject* parent) :
QStyledItemDelegate(parent)
{
}
void PictureDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, const QModelIndex& index) const
{
painter->save();
QPixmap pixmap = index.model()->data(index,
Qt::DecorationRole).value<QPixmap>();
painter->drawPixmap(option.rect.x(), option.rect.y(), pixmap);
QRect bannerRect = QRect(option.rect.x(), option.rect.y(),
pixmap.width(), BANNER_HEIGHT);
QColor bannerColor = QColor(BANNER_COLOR);
bannerColor.setAlpha(BANNER_ALPHA);
painter->fillRect(bannerRect, bannerColor);
QString filename = index.model()->data(index,
Qt::DisplayRole).toString();
painter->setPen(BANNER_TEXT_COLOR);
painter->drawText(bannerRect, Qt::AlignCenter, filename);
if (option.state.testFlag(QStyle::State_Selected)) {
QColor selectedColor = option.palette.highlight().color();
selectedColor.setAlpha(HIGHLIGHT_ALPHA);
painter->fillRect(option.rect, selectedColor);
}
painter->restore();
}
每次当QListView需要显示一个项目时,此代理的paint()函数将被调用。绘图系统可以看作是层,你可以在每一层上绘制。QPainter类允许我们绘制任何我们想要的东西:圆形、饼图、矩形、文本等等。项目区域可以通过option.rect()检索。以下是步骤:
-
很容易破坏参数列表中传递的
painter状态,因此我们必须在开始绘制之前使用painter->save()保存画家状态,以便在完成我们的绘制后能够恢复它。 -
检索项目缩略图并使用
QPainter::drawPixmap()函数绘制它。 -
使用
QPainter::fillRect()函数在缩略图上方绘制一个半透明的灰色横幅。 -
使用
QPainter::drawText()函数检索项目显示名称并在横幅上绘制它。 -
如果项目被选中,我们将使用项目的突出颜色在顶部绘制一个半透明的矩形。
-
我们将画家状态恢复到其原始状态。
提示
如果你想要绘制更复杂的项目,请查看doc.qt.io/qt-5/qpainter.html的QPainter官方文档。
这是sizeHint()函数的实现:
QSize PictureDelegate::sizeHint(const QStyleOptionViewItem& /*option*/, const QModelIndex& index) const
{
const QPixmap& pixmap = index.model()->data(index,
Qt::DecorationRole).value<QPixmap>();
return pixmap.size();
}
这个比较简单。我们希望项目的大小与缩略图大小相等。因为我们保持了缩略图在创建时的宽高比,所以缩略图可以有不同的大小。因此,我们基本上检索缩略图并返回其大小。
提示
当你创建一个项目代理时,避免直接继承QItemDelegate类,而是继承QStyledItemDelegate。后者支持 Qt 样式表,允许你轻松自定义渲染。
现在PictureDelegate已经准备好了,我们可以配置我们的thumbnailListView使用它,并更新AlbumWidget.cpp文件如下:
AlbumWidget::AlbumWidget(QWidget *parent) :
QWidget(parent),
ui(new Ui::AlbumWidget),
mAlbumModel(nullptr),
mAlbumSelectionModel(nullptr),
mPictureModel(nullptr),
mPictureSelectionModel(nullptr)
{
ui->setupUi(this);
clearUi();
ui->thumbnailListView->setSpacing(5);
ui->thumbnailListView->setResizeMode(QListView::Adjust);
ui->thumbnailListView->setFlow(QListView::LeftToRight);
ui->thumbnailListView->setWrapping(true);
ui->thumbnailListView->setItemDelegate(
new PictureDelegate(this));
...
}
提示
Qt 提示
项目代理也可以使用QStyledItemDelegate::createEditor()函数管理编辑过程。
使用 PictureWidget 显示图片
此小部件将被调用来显示图片的全尺寸。我们还添加了一些按钮来跳转到上一张/下一张图片或删除当前图片。
让我们开始分析PictureWidget.ui表单,以下是设计视图:

这里是详细内容:
-
backButton: 此对象请求显示图库 -
deleteButton: 此对象从相册中删除图片 -
nameLabel: 此对象显示图片名称 -
nextButton: 此对象选择相册中的下一张图片 -
previousButton: 此对象选择相册中的上一张图片 -
pictureLabel: 此对象显示图片
现在我们可以查看头文件 PictureWidget.h:
#include <QWidget>
#include <QItemSelection>
namespace Ui {
class PictureWidget;
}
class PictureModel;
class QItemSelectionModel;
class ThumbnailProxyModel;
class PictureWidget : public QWidget
{
Q_OBJECT
public:
explicit PictureWidget(QWidget *parent = 0);
~PictureWidget();
void setModel(ThumbnailProxyModel* model);
void setSelectionModel(QItemSelectionModel* selectionModel);
signals:
void backToGallery();
protected:
void resizeEvent(QResizeEvent* event) override;
private slots:
void deletePicture();
void loadPicture(const QItemSelection& selected);
private:
void updatePicturePixmap();
private:
Ui::PictureWidget* ui;
ThumbnailProxyModel* mModel;
QItemSelectionModel* mSelectionModel;
QPixmap mPixmap;
};
没有惊喜,我们在 PictureWidget 类中有 ThumbnailProxyModel* 和 QItemSelectionModel* 设置器。当用户点击 backButton 对象时,会触发 backToGallery() 信号。它将由 MainWindow 处理,再次显示图库。我们重写 resizeEvent() 来确保我们始终使用所有可见区域来显示图片。deletePicture() 插槽将在用户点击相应的按钮时处理删除操作。loadPicture() 函数将被调用来更新 UI 并显示指定的图片。最后,updatePicturePixmap() 是一个辅助函数,用于根据当前小部件大小显示图片。
此小部件与其他小部件非常相似。因此,我们不会在这里放置 PictureWidget.cpp 的完整实现代码。如果需要,你可以检查完整的源代码示例。
让我们看看这个小部件如何在 PictureWidget.cpp 中始终以全尺寸显示图片:
void PictureWidget::resizeEvent(QResizeEvent* event)
{
QWidget::resizeEvent(event);
updatePicturePixmap();
}
void PictureWidget::updatePicturePixmap()
{
if (mPixmap.isNull()) {
return;
}
ui->pictureLabel->setPixmap(mPixmap.scaled(ui->pictureLabel->size(), Qt::KeepAspectRatio));
}
因此,每次小部件被调整大小时,我们都会调用 updatePicturePixmap()。mPixmap 变量是从 PictureModel 获取的全尺寸图片。此函数将图片缩放到 pictureLabel 的大小,并保持宽高比。你可以自由调整窗口大小,并享受最大可能的图片尺寸。
组合你的图库应用
好的,我们已经完成了 AlbumListWidget、AlbumWidget 和 PictureWidget。如果你记得正确的话,AlbumListWidget 和 AlbumWidget 包含在一个名为 GalleryWidget 的小部件中。
让我们看看 GalleryWidget.ui 文件:

此小部件不包含任何标准 Qt 小部件,而只包含我们创建的小部件。Qt 提供两种方式在 Qt 设计器中使用您自己的小部件:
-
提升小部件:这是最快、最简单的方法
-
为 Qt 设计器创建小部件插件:这更强大,但更复杂
在本章中,我们将使用第一种方法,该方法包括放置一个通用的 QWidget 作为占位符,然后将其提升到我们的自定义小部件类。你可以按照以下步骤将 albumListWidget 和 albumWidget 对象添加到 GalleryWidget.ui 文件中,从 Qt 设计器开始:
-
从容器拖放一个小部件到你的表单中。
-
从属性编辑器设置objectName(例如,
albumListWidget)。 -
从小部件上下文菜单中选择提升到...
-
设置提升的类名(例如,
AlbumWidget)。 -
确认该头文件正确(例如,
AlbumWidget.h)。 -
点击添加按钮,然后点击提升。
如果你未能提升你的小部件,你总是可以从上下文菜单中选择降级到 QWidget来撤销操作。
在GalleryWidget的头文件和实现中并没有什么真正令人兴奋的内容。我们只为Album和Picture的模型和模型选择提供了设置器,将它们转发到albumListWidget和albumWidget。这个类还转发了从albumWidget发出的pictureActivated信号。如有需要,请检查完整的源代码。
这是本章的最后一部分。现在我们将分析MainWindow。在MainWindow.ui中没有做任何事情,因为所有的事情都在代码中处理。这是MainWindow.h:
#include <QMainWindow>
#include <QStackedWidget>
namespace Ui {
class MainWindow;
}
class GalleryWidget;
class PictureWidget;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = 0);
~MainWindow();
public slots:
void displayGallery();
void displayPicture(const QModelIndex& index);
private:
Ui::MainWindow *ui;
GalleryWidget* mGalleryWidget;
PictureWidget* mPictureWidget;
QStackedWidget* mStackedWidget;
};
这两个槽displayGallery()和displayPicture()将用于在画廊(带有专辑和缩略图的专辑列表)和图片(全尺寸)之间切换显示。QStackedWidget类可以包含各种窗口,但一次只能显示一个。
让我们看看MainWindow.cpp文件中构造函数的开始部分:
ui->setupUi(this);
AlbumModel* albumModel = new AlbumModel(this);
QItemSelectionModel* albumSelectionModel =
new QItemSelectionModel(albumModel, this);
mGalleryWidget->setAlbumModel(albumModel);
mGalleryWidget->setAlbumSelectionModel(albumSelectionModel);
首先,我们通过调用ui->setupUi()初始化 UI。然后我们创建AlbumModel及其QItemSelectionModel。最后,我们调用GalleryWidget的设置器,它们将分发到AlbumListWidget和AlbumWidget对象。
继续分析这个构造函数:
PictureModel* pictureModel = new PictureModel(*albumModel, this);
ThumbnailProxyModel* thumbnailModel = new ThumbnailProxyModel(this); thumbnailModel->setSourceModel(pictureModel);
QItemSelectionModel* pictureSelectionModel =
new QItemSelectionModel(pictureModel, this);
mGalleryWidget->setPictureModel(thumbnailModel);
mGalleryWidget->setPictureSelectionModel(pictureSelectionModel);
mPictureWidget->setModel(thumbnailModel);
mPictureWidget->setSelectionModel(pictureSelectionModel);
Picture的行为与之前的Album相似。但我们还共享ThumbnailProxyModel,它从PictureModel初始化,并且与PictureWidget的QItemSelectionModel。
构造函数现在执行信号/槽连接:
connect(mGalleryWidget, &GalleryWidget::pictureActivated,
this, &MainWindow::displayPicture);
connect(mPictureWidget, &PictureWidget::backToGallery,
this, &MainWindow::displayGallery);
你还记得pictureActivated()函数吗?当你双击albumWidget中的缩略图时,这个信号会被发出。现在我们可以将它连接到我们的displayPicture槽,这将切换显示并显示全尺寸的图片。不要忘记连接从PictureWidget发出的backToGallery信号,当用户点击backButton时,它将再次切换以显示画廊。
构造函数的最后部分很简单:
mStackedWidget->addWidget(mGalleryWidget);
mStackedWidget->addWidget(mPictureWidget);
displayGallery();
setCentralWidget(mStackedWidget);
我们将我们的两个窗口mGalleryWidget和mPictureWidget添加到mStackedWidget类中。当应用程序启动时,我们希望显示画廊,因此我们调用自己的槽displayGallery()。最后,我们将mStackedWidget定义为主窗口的中心窗口。
为了完成这一章,让我们看看这两个魔法槽中发生了什么,允许在用户请求时切换显示:
void MainWindow::displayGallery()
{
mStackedWidget->setCurrentWidget(mGalleryWidget);
}
void MainWindow::displayPicture(const QModelIndex& /*index*/)
{
mStackedWidget->setCurrentWidget(mPictureWidget);
}
这看起来简单得荒谬。我们只需请求mStackedWidget选择相应的窗口。由于PictureWidget与其他视图共享相同的选择模型,我们甚至可以忽略index变量。
摘要
数据和表示之间的真正分离并不总是件容易的事。将核心和 GUI 分成两个不同的项目是一种良好的实践。这将迫使你在应用程序中设计分离的层。乍一看,Qt 模型/视图系统可能显得复杂。但这一章教你它有多么强大,以及使用它是多么简单。多亏了 Qt 框架,数据库中数据的持久化可以轻松完成,无需头疼。
本章建立在gallery-core库所奠定的基础之上。在下一章中,我们将重用相同的核心库,并在 QML 中使用 Qt Quick 创建一个移动 UI。
第五章 主导移动 UI
在 第三章,划分项目并管理代码中,我们创建了一个强大的核心库来处理图片库。现在我们将使用这个 gallery-core 库来创建一个移动应用程序。
我们将教你如何从头开始创建 Qt Quick 项目。你将使用 QML 创建自定义 Qt Quick 视图。本章还将介绍你的 QML 视图如何与 C++ 库通信。
在本章结束时,你的画廊应用程序将在你的移动设备(Android 或 iOS)上运行,并具有符合触摸设备的专用 GUI。此应用程序将提供与桌面应用程序相同的功能。
本章涵盖了以下主题:
-
创建 Qt Quick 项目
-
QML
-
Qt Quick 控件
-
Qt 移动(Android 和 iOS)
-
从 QML 调用 C++ 函数
从 Qt Quick 和 QML 开始
Qt Quick 是使用 Qt 创建应用程序的另一种方式。你可以用它来创建一个完整的应用程序,代替 Qt Widgets。Qt Quick 模块提供了过渡、动画和视觉效果。你还可以使用着色器自定义图形效果。此模块特别擅长制作使用触摸屏的设备软件。Qt Quick 使用一种专用语言:Qt 模型语言(QML)。它是一种声明性语言;其语法接近 JSON(JavaScript 对象表示法)语法。此外,QML 还支持内联或单独文件中的 JavaScript 表达式。
让我们从使用 QML 的一个简单的 Qt Quick 应用程序示例开始。创建一个名为 main.qml 的新文件,并使用以下代码片段:
import QtQuick 2.5
import QtQuick.Window 2.2
Window {
visible: true
width: 640; height: 480
// A nice red rectangle
Rectangle {
width: 200; height: 200
color: "red"
}
}
Qt 5 提供了一个名为 qmlscene 的良好工具,用于原型设计 QML 用户界面。你可以在 Qt 安装文件夹中找到二进制文件,例如:Qt/5.7/gcc_64/bin/qmlscene。要加载你的 main.qml 文件,你可以运行该工具并选择文件,或者使用 CLI 并在参数中使用 .qml 文件:qmlscene main.qml。你应该会看到类似这样的内容:

要使用 Qt Quick 模块,你需要导入它。语法很简单:
import <moduleName> <moduleVersion>
在这个例子中,我们导入了 QtQuick,这是一个提供基本组件(Rectangle、Image、Text)的通用模块,我们还导入了 QtQuick.Window 模块,它将提供主窗口应用程序(Window)。
一个 QML 组件可以有属性。例如,我们将 Window 类的 width 属性设置为值 640。以下是通用的语法:
<ObjectType> {
<PropertyName>: <PropertyValue>
}
我们现在可以更新 main.qml 文件,添加一些新的矩形:
import QtQuick 2.5
import QtQuick.Window 2.2
Window {
visible: true
width: 640; height: 480
Rectangle {
width: 200; height: 200
color: "red"
}
Rectangle {
width: 200; height: 200
color: "green"
x: 100; y: 100
Rectangle {
width: 50; height: 50
color: "blue"
x: 100; y: 100
}
}
}
这是视觉结果:

你的 QML 文件将 UI 描述为组件的层次结构。Window 元素以下的层次结构如下:
-
红色
Rectangle -
绿色
Rectangle -
蓝色
Rectangle
每个嵌套项都将始终相对于其父项具有其 x、y 坐标。
为了构建你的应用程序结构,你可以构建可重用的 QML 组件。你可以轻松地创建一个新的组件。所有 QML 组件都必须有一个根项目。让我们通过创建一个名为MyToolbar.qml的新文件来构建一个新的MyToolbar组件:
import QtQuick 2.5
import QtQuick 2.5
Rectangle {
color: "gray"
height: 50
Rectangle {
id: purpleRectangle
width: 50; height: parent.height
color: "purple"
radius: 10
}
Text {
anchors.left: purpleRectangle.right
anchors.right: parent.right
text: "Dominate the Mobile UI"
font.pointSize: 30
}
}
灰色的Rectangle元素将是我们用作背景的根项目。我们还创建了两个项目:
-
一个可以与 ID
purpleRectangle关联的紫色Rectangle元素。此项目的高度将是其父元素的高度,即灰色的Rectangle元素。 -
一个
Text元素。在这种情况下,我们使用锚点。它将帮助我们布局项目而不使用硬编码的坐标。Text元素的左侧将与purpleRectangle的右侧对齐,而Text元素的右侧将与父元素的右侧(灰色的Rectangle元素)对齐。
注意
Qt Quick 提供了许多锚点:left、horizontalCenter、right、top、verticalCenter和bottom。你还可以使用便利锚点,如fill或centerIn。有关锚点的更多信息,请参阅doc.qt.io/qt-5/qtquick-positioning-anchors.html。
你可以通过更新你的main.qml来在你的窗口中使用MyToolbar:
Window {
...
MyToolbar {
width: parent.width
}
}
我们将宽度设置为父级宽度。就像这样,工具栏填满了窗口的宽度。以下是结果:

锚点非常适合对齐特定项目,但如果你想要以网格、行或列的形式布局多个项目,你可以使用QtQuick.layouts模块。以下是一个更新后的main.qml示例:
import QtQuick 2.5
import QtQuick.Window 2.2
import QtQuick.Layouts 1.3
Window {
visible: true
width: 640; height: 480
MyToolbar {
id: myToolbar
width: parent.width
}
RowLayout {
anchors.top: myToolbar.bottom
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
Rectangle { width: 200; height: 200; color: "red" }
Rectangle { width: 200; height: 200 color: "green" }
Rectangle { width: 50; height: 50; color: "blue" }
}
}
你应该得到类似这样的结果:

如你所见,我们使用了一个RowLayout元素,它位于myToolbar下方,以及其父元素Window元素。此项目提供了一种动态布局行中项目的方法。Qt Quick 还提供了其他布局元素:GridLayout和ColumnLayout。
你的自定义组件也可以公开自定义属性,这些属性可以在组件外部修改。你可以通过添加property属性来实现。请更新MyToolbar.qml:
import QtQuick 2.5
Rectangle {
property color iconColor: "purple"
property alias title: label.text
color: "gray"
height: 50
Rectangle {
id: purpleRectangle
width: 50; height: parent.height
color: iconColor
radius: 10
}
Text {
id: label
anchors.left: purpleRectangle.right
anchors.right: parent.right
text: "Dominate the Mobile UI"
font.pointSize: 30
}
}
iconColor是一个全新的属性,它是一个完整的变量。我们还更新了Rectangle属性以使用此属性作为color。title属性只是一个alias,你可以将其视为更新label.text属性的指针。
从外部,你可以使用相同的语法使用这些属性;请使用以下片段更新main.qml文件:
import QtQuick 2.5
import QtQuick.Window 2.2
import QtQuick.Layouts 1.3
Window {
visible: true
width: 640; height: 480
MyToolbar {
id: myToolbar
width: parent.width
title: "Dominate Qt Quick"
iconColor: "yellow"
}
...
}
你应该得到一个像这样的漂亮的更新后的工具栏:

我们已经涵盖了 QML 的基础知识,现在我们将继续使用 QML 进行移动应用程序开发。
检查你的开发环境
要能够为 Android 创建 Qt 应用程序,你必须有:
-
支持 Android v2.3.3(API 10)或更高版本的设备
-
Android SDK
-
Android NDK
-
JDK
-
Qt 为 Android 预构建的组件 x86(来自 Qt 维护工具)
-
Qt 预构建组件用于 Android ARMv7(来自 Qt 维护工具)
要能够为 iOS 创建 Qt 应用程序,您必须具备:
-
搭载 iOS 5.0 或更高版本的设备
-
一台 Mac 桌面电脑
-
Xcode
-
Qt for iOS(来自 Qt 维护工具)
当启动时,Qt Creator 将检测并创建 Android 和 iOS Qt 工具包。您可以从工具 | 选项 | 构建和运行 | 工具包检查您现有的工具包,如下面的截图所示:

创建 Qt Quick 项目
本章将遵循我们在第四章中介绍的项目结构,即征服桌面 UI:一个父项目ch05-gallery-mobile.pro将包含我们的两个子项目,gallery-core和新的gallery-mobile。
在 Qt Creator 中,您可以从文件 | 新建文件或项目 | 应用程序 | Qt Quick Controls 应用程序 | 选择创建一个 Qt Quick 子项目。
向导将允许您自定义项目创建:
-
位置
- 选择一个项目名称(
gallery-mobile)和位置
- 选择一个项目名称(
-
详细信息
-
取消选择包含.ui.qml 文件
-
取消选择启用原生样式
-
-
工具包
-
选择您的桌面工具包
-
至少选择一个移动工具包
-
-
摘要
- 请确保将 gallery-mobile 添加为
ch05-gallery-mobile.pro的子项目
- 请确保将 gallery-mobile 添加为
让我们花些时间解释为什么我们选择这些选项来创建我们的项目。
首先要分析的是应用程序模板。默认情况下,Qt Quick 仅提供基本的 QML 组件(Rectangle、Image、Text等)。高级组件将由 Qt Quick 模块处理。对于本项目,我们将使用 Qt Quick Controls(ApplicationWindow、Button、TextField等)。这就是我们选择从Qt Quick Controls 应用程序开始的原因。请记住,您始终可以在以后导入和使用 Qt Quick 模块。
在本章中,我们将不使用 Qt Quick Designer。因此,.ui.qml文件不是必需的。尽管设计师可以提供很多帮助,但了解并自己编写 QML 文件是很好的。
桌面“原生样式”被禁用,因为本项目主要针对移动平台。此外,禁用“原生样式”可以避免对 Qt 小部件模块的过度依赖。
最后,我们选择至少两个工具包。第一个是我们的桌面工具包。其他工具包是您要针对的移动平台。我们通常使用以下开发工作流程:
-
在桌面上的快速迭代
-
在移动模拟器/模拟器上检查和修复行为
-
在移动设备上进行真实测试
在真实设备上的部署通常需要更长的时间,因此您可以使用桌面工具包进行大部分开发。移动工具包将允许您在真实移动设备或模拟器(例如使用 Qt Android x86 工具包)上检查应用程序的行为。
让我们讨论向导自动生成的文件。以下是main.cpp文件:
#include <QGuiApplication>
#include <QQmlApplicationEngine>
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
QQmlApplicationEngine engine;
engine.load(QUrl(QStringLiteral("qrc:/main.qml")));
return app.exec();
}
我们在这里使用 QGuiApplication 而不是 QApplication,因为我们在这个项目中不使用 Qt 小部件。然后,我们创建 QML 引擎并加载 qrc:/mail.qml。正如你可能已经猜到的(带有 qrc:/ 前缀),这个 QML 文件位于 Qt 资源文件中。
你可以打开 qml.qrc 文件来找到 main.qml:
import QtQuick 2.5
import QtQuick.Controls 1.4
ApplicationWindow {
visible: true
width: 640
height: 480
title: qsTr("Hello World")
menuBar: MenuBar {
Menu {
title: qsTr("File")
MenuItem {
text: qsTr("&Open")
onTriggered: console.log("Open action triggered");
}
MenuItem {
text: qsTr("Exit")
onTriggered: Qt.quit();
}
}
}
Label {
text: qsTr("Hello World")
anchors.centerIn: parent
}
}
首先要做的事情是导入文件中使用的类型。注意每个导入语句末尾的模块版本。QtQuick 模块将导入基本的 QML 元素(Rectangle、Image 等),而 QtQuick.Controls 模块将导入来自 QtQuick Controls 子模块的高级 QML 元素(ApplicationWindow、MenuBar、MenuItem、Label 等)。
然后,我们定义了类型为 ApplicationWindow 的根元素。它提供了一个顶级应用程序窗口,包含以下项目:MenuBar、ToolBar 和 StatusBar。ApplicationWindow 的 visible、width、height 和 title 属性是原始类型。语法简单易懂。
menuBar 属性更复杂。这个 MenuBar 属性由一个 Menu 文件组成,该文件本身由两个 MenuItems 组成:Open 和 Exit。每次激活 MenuItem 时,它都会发出一个 triggered() 信号。在这种情况下,MenuItem 文件将在控制台记录一条消息。退出 MenuItem 将终止应用程序。
最后,我们在 ApplicationWindow 类型的内容区域添加了一个显示 "Hello World" 的 Label。使用锚点定位项目很有用。在我们的例子中,标签在父元素 ApplicationWindow 中垂直和水平居中。
在继续之前,请确保此示例在您的桌面和移动设备上都能正确运行。
准备你的 Qt Quick 图库入口点
首先,你需要将此项目链接到我们的 gallery-core 库。我们已经在 第四章 中介绍了如何链接内部库,征服桌面 UI。更多详情请参阅该章节。这是更新后的 gallery-mobile.pro 文件:
TEMPLATE = app
QT += qml quick sql svg
CONFIG += c++11
SOURCES += main.cpp
RESOURCES += gallery.qrc
LIBS += -L$$OUT_PWD/../gallery-core/ -lgallery-core
INCLUDEPATH += $$PWD/../gallery-core
DEPENDPATH += $$PWD/../gallery-core
contains(ANDROID_TARGET_ARCH,x86) {
ANDROID_EXTRA_LIBS = \
$$[QT_INSTALL_LIBS]/libQt5Sql.so
}
请注意,我们在这里做了几处修改:
-
我们添加了
sql模块以在移动设备上部署依赖项 -
我们为按钮图标添加了
svg模块 -
qml.qrc文件已重命名为gallery.qrc -
我们链接了
gallery-core库 -
默认情况下,
sql共享对象(libQt5Sql.so)不会部署到你的 Android x86 设备上。你必须明确将其包含在你的.pro文件中。
现在,你可以在我们的 gallery-mobile 应用程序中使用 gallery-core 库中的类。让我们看看如何将 C++ 模型与 QML 绑定。这是更新后的 main.cpp:
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQmlContext>
#include <QQuickView>
#include "AlbumModel.h"
#include "PictureModel.h"
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
AlbumModel albumModel;
PictureModel pictureModel(albumModel);
QQmlApplicationEngine engine;
QQmlContext* context = engine.rootContext();
context->setContextProperty("albumModel", &albumModel);
context->setContextProperty("pictureModel", &pictureModel);
engine.load(QUrl(QStringLiteral("qrc:/qml/main.qml")));
return app.exec();
}
我们将在 C++ 中实例化模型,并通过根 QQmlContext 对象将其暴露给 QML。setContextProperty() 函数允许我们将 C++ 的 QObject 绑定到 QML 属性。第一个参数将是 QML 属性名称。我们只绑定一个 C++ 对象到 QML 属性;上下文对象不拥有此对象。
现在我们来谈谈移动应用程序本身。我们将定义三个具有特定角色的页面:
-
AlbumListPage-
显示现有专辑
-
专辑创建
-
专辑选择
-
-
AlbumPage-
以缩略图形式显示现有图片
-
在专辑中添加图片
-
专辑重命名
-
专辑删除
-
图片选择
-
-
PicturePage-
显示所选图片
-
图片选择
-
图片删除
-
为了处理导航,我们将使用来自 Qt Quick Controls 的StackView组件。这个 QML 组件实现了基于堆栈的导航。当你想要显示一个页面时,你可以将其推入。当用户请求返回时,你可以将其弹出。以下是使用StackView组件为我们的图库移动应用程序创建的工作流程。带有实线边框的页面是当前屏幕上显示的页面:

这是main.qml的实现:
import QtQuick 2.6
import QtQuick.Controls 2.0
ApplicationWindow {
readonly property alias pageStack: stackView
id: app
visible: true
width: 768
height: 1280
StackView {
id: stackView
anchors.fill: parent
initialItem: AlbumListPage {}
}
onClosing: {
if (Qt.platform.os == "android") {
if (stackView.depth > 1) {
close.accepted = false
stackView.pop()
}
}
}
}
这个主文件非常简单。应用程序是围绕StackView组件构建的。我们将id属性设置为允许我们的StackView被其他 QML 对象识别和引用。anchors属性将stackView设置为填充其父元素,即ApplicationWindow类型。最后,我们将initialItem属性设置为即将实现的页面AlbumListPage。
在 Android 上,每次用户按下返回按钮时,onClosing都会执行。为了模仿原生 Android 应用程序,我们将在真正关闭应用程序之前首先弹出最后一个堆叠的页面。
在文件顶部,我们为stackView定义了一个property alias。一个property alias是对另一个现有属性的简单引用。这个别名将非常有用,可以从其他 QML 组件访问stackView。为了防止 QML 组件破坏我们正在使用的stackView,我们使用了readonly关键字。初始化后,组件可以访问属性但不能更改其值。
使用 ListView 显示专辑
让我们为这个移动应用程序创建第一个页面!在gallery.qrc文件中创建一个名为AlbumListPage.qml的文件。以下是页面头部的实现:
import QtQuick 2.0
import QtQuick.Layouts 1.3
import QtQuick.Controls 2.0
Page {
header: ToolBar {
Label {
Layout.fillWidth: true
text: "Albums"
font.pointSize: 30
}
}
...
}
Page是一个带有头部和脚部的容器控件。在这个应用程序中,我们只会使用头部项。我们将ToolBar分配给header属性。这个工具栏的高度将由 Qt 处理,并根据目标平台进行调整。在这个第一个简单实现中,我们只放置了一个显示文本“专辑”的Label。
在header初始化之后,向此页面添加一个ListView元素:
ListView {
id: albumList
model: albumModel
spacing: 5
anchors.fill: parent
delegate: Rectangle {
width: parent.width
height: 120
color: "#d0d1d2"
Text {
text: name
font.pointSize: 16
color: "#000000"
anchors.verticalCenter: parent.verticalCenter
}
}
}
Qt Quick 的ListView是 Qt 小部件QListView的等价物。它显示从提供的模型中提供的项目列表。我们将model属性设置为值albumModel。这指的是从main.cpp文件中可从 QML 访问的 C++模型,因为我们使用了setContextProperty()函数。在 Qt Quick 中,您必须提供一个委托来描述行将如何显示。在这种情况下,一行将只显示相册名称,使用Text项目。在 QML 中访问相册名称很容易,因为我们的AlbumModel模型向 QML 公开其角色列表。让我们回顾一下AlbumModel的覆盖roleNames()函数:
QHash<int, QByteArray> AlbumModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[Roles::IdRole] = "id";
roles[Roles::NameRole] = "name";
return roles;
}
因此,每次您的 Qt Quick 委托使用name角色时,它将调用AlbumModel函数data(),并带有正确的角色整数,并返回正确的相册名称字符串。
要处理鼠标,点击一行并在委托上添加一个MouseArea元素:
ListView {
...
delegate: Rectangle {
...
MouseArea {
anchors.fill: parent
onClicked: {
albumList.currentIndex = index
pictureModel.setAlbumId(id)
pageStack.push("qrc:/qml/AlbumPage.qml",
{ albumName: name, albumRowIndex: index })
}
}
}
}
MouseArea是一个不可见的项目,可以与任何可见项目一起使用来处理鼠标事件。这也适用于手机触摸屏上的简单触摸。在这里,我们告诉MouseArea元素占用父Rectangle的整个区域。
在我们的情况下,我们只在clicked信号上执行任务。我们使用index更新ListView的currentIndex。这个index是一个特殊角色,包含模型中项目的索引。
当用户点击时,我们将通过pictureModel.setAlbumId(id)调用告诉pictureModel加载选定的相册。我们很快就会看到 QML 如何调用 C++方法。
最后,我们将AlbumPage推送到pageStack属性。push()函数允许我们使用{key: value, ... }语法设置一系列 QML 属性。每个属性都将复制到推入的项目中。在这里,name和index将复制到AlbumPage的albumName和albumRowIndex属性中。这是一种简单而强大的方式,可以通过属性参数实例化一个 QML 页面。
从您的 QML 代码中,您只能调用一些特定的 C++方法:
-
属性(使用
Q_PROPERTY) -
公共槽
-
装饰为可调用的函数(使用
Q_INVOKABLE)
在此情况下,我们将PictureModel::setAlbumId()装饰为Q_INVOKABLE,请更新PictureModel.h文件:
class GALLERYCORESHARED_EXPORT PictureModel : public QAbstractListModel
{
Q_OBJECT
public:
...
Q_INVOKABLE void setAlbumId(int albumId);
...
};
使用 QML 单例主题化应用程序
QML 应用程序的样式和主题化可以通过多种方式完成。在本章中,我们将声明一个带有主题数据的 QML 单例,这些数据由自定义组件使用。此外,我们还将创建一个自定义的Page组件来处理工具栏及其默认项(返回按钮和页面标题)。
请创建一个新的Style.qml文件:
pragma Singleton
import QtQuick 2.0
QtObject {
property color text: "#000000"
property color windowBackground: "#eff0f1"
property color toolbarBackground: "#eff0f1"
property color pageBackground: "#fcfcfc"
property color buttonBackground: "#d0d1d2"
property color itemHighlight: "#3daee9"
}
我们声明一个只包含我们的主题属性的QtObject组件。QtObject是一个非视觉的 QML 组件。
在 QML 中声明单例类型需要两个步骤。首先,你需要使用 pragma singleton,这将指示组件的单例使用。第二步是注册它。这可以通过 C++ 或创建一个 qmldir 文件来完成。让我们看看第二步。创建一个新的纯文本文件,命名为 qmldir:
singleton Style 1.0 Style.qml
这条简单的语句将声明一个名为 Style 的 QML singleton 类型,版本为 1.0,来自名为 Style.qml 的文件。
现在是时候在自定义组件中使用这些主题属性了。让我们看看一个简单的例子。创建一个新的 QML 文件,命名为 ToolBarTheme.qml:
import QtQuick 2.0
import QtQuick.Controls 2.0
import "."
ToolBar {
background: Rectangle {
color: Style.toolbarBackground
}
}
这个 QML 对象描述了一个自定义的 ToolBar。在这里,background 元素是一个简单的 Rectangle,带有我们的颜色。我们可以通过 Style.toolbarBackground 容易地访问我们的单例 Style 和其主题属性。
注意
QML 单例需要显式导入来加载 qmldir 文件。import "." 是解决这个 Qt 错误的一个方法。更多信息,请查看 bugreports.qt.io/browse/QTBUG-34418。
我们现在将创建一个名为 PageTheme.qml 的 QML 文件,目的是包含与页面工具栏和主题相关的所有代码:
import QtQuick 2.0
import QtQuick.Layouts 1.3
import Qt.labs.controls 1.0
import QtQuick.Controls 2.0
import "."
Page {
property alias toolbarButtons: buttonsLoader.sourceComponent
property alias toolbarTitle: titleLabel.text
header: ToolBarTheme {
RowLayout {
anchors.fill: parent
ToolButton {
background: Image {
source: "qrc:/res/icons/back.svg"
}
onClicked: {
if (stackView.depth > 1) {
stackView.pop()
}
}
}
Label {
id: titleLabel
Layout.fillWidth: true
color: Style.text
elide: Text.ElideRight
font.pointSize: 30
}
Loader {
Layout.alignment: Qt.AlignRight
id: buttonsLoader
}
}
}
Rectangle {
color: Style.pageBackground
anchors.fill: parent
}
}
这个 PageTheme 元素将自定义页面的头部。我们使用之前创建的 ToolBarTheme。这个工具栏只包含一个 RowLayout 元素,用于在一行中水平显示项目。这个布局包含三个元素:
-
ToolButton:这是显示来自gallery.qrc的图像并弹出当前页面的 "返回" 按钮 -
Label:这是显示页面标题的元素 -
Loader:这是允许页面在通用工具栏中动态添加特定元素的元素
Loader 元素拥有一个 sourceComponent 属性。在这个应用程序中,这个属性可以通过 PageTheme 页面分配,以添加特定的按钮。这些按钮将在运行时实例化。
PageTheme 页面还包含一个 Rectangle 元素,它适合父元素并使用 Style.pageBackground 配置页面背景颜色。
现在我们已经准备好了 Style.qml 和 PageTheme.qml 文件,我们可以更新 AlbumListPage.qml 文件来使用它:
import QtQuick 2.6
import QtQuick.Controls 2.0
import "."
PageTheme {
toolbarTitle: "Albums"
ListView {
id: albumList
model: albumModel
spacing: 5
anchors.fill: parent
delegate: Rectangle {
width: parent.width
height: 120
color: Style.buttonBackground
Text {
text: name
font.pointSize: 16
color: Style.text
anchors.verticalCenter: parent.verticalCenter
}
...
}
}
}
现在,AlbumListPage 是一个 PageTheme 元素,我们不再直接操作 header。我们只需要设置属性 toolbarTitle 来在工具栏中显示一个漂亮的 "Albums" 文本。我们还可以使用 Style 单例的属性来享受漂亮的颜色。
通过将主题属性集中在一个文件中,你可以轻松地更改应用程序的外观和感觉。项目的源代码还包含一个暗色主题。
在移动设备上加载数据库
在继续 UI 实现之前,我们必须注意在移动设备上数据库部署的问题。剧透:这不会很有趣。
我们必须回到 gallery-core 项目的 DatabaseManager.cpp:
DatabaseManager& DatabaseManager::instance()
{
return singleton;
}
DatabaseManager::DatabaseManager(const QString& path) :
mDatabase(new QSqlDatabase(QSqlDatabase::addDatabase("QSQLITE"))),
albumDao(*mDatabase),
pictureDao(*mDatabase)
{
mDatabase->setDatabaseName(path);
...
}
在桌面环境中,SQLite3 数据库是在mDatabase->setDatabaseName()指令下创建的,但在移动设备上则完全不工作。这是由于每个移动平台(Android 和 iOS)的文件系统都非常特定。应用程序只能访问一个狭窄的沙盒,它不能干扰文件系统的其余部分。应用程序目录中的所有文件都必须具有特定的文件权限。如果我们让 SQLite3 创建数据库文件,它将没有正确的权限,操作系统将阻止数据库打开。
因此,数据库将无法正确创建,你的数据也无法持久化。当使用原生 API 时,这不是问题,因为操作系统会负责数据库的正确配置。由于我们使用 Qt 进行开发,我们无法轻松访问此 API(除非使用 JNI 或其他黑魔法)。一种解决方案是将一个“即用”数据库嵌入到应用程序包中,并将其复制到正确的文件系统路径,并赋予正确的权限。
此数据库应包含一个空的已创建数据库,没有任何内容。该数据库位于该章节的源代码中(你也可以从第四章的源代码中生成,征服桌面 UI)。你可以将其添加到gallery.qrc文件中。
由于我们的层定义清晰,我们只需修改DatabaseManager::instance()的实现来处理这种情况:
DatabaseManager& DatabaseManager::instance()
{
#if defined(Q_OS_ANDROID) || defined(Q_OS_IOS)
QFile assetDbFile(":/database/" + DATABASE_FILENAME);
QString destinationDbFile = QStandardPaths::writableLocation(
QStandardPaths::AppLocalDataLocation)
.append("/" + DATABASE_FILENAME);
if (!QFile::exists(destinationDbFile)) {
assetDbFile.copy(destinationDbFile);
Qfile::setPermissions(destinationDbFile,
QFile::WriteOwner | QFile::ReadOwner);
}
}
static DatabaseManager singleton(destinationDbFile);
#else
static DatabaseManager singleton;
#endif
return singleton;
}
我们首先使用一个巧妙的 Qt 类QStandardPaths检索应用程序的平台特定路径。此类返回多种类型的路径(AppLocalDataLocation、DocumentsLocation、PicturesLocation等)。数据库应存储在应用程序数据目录中。如果文件不存在,我们从我们的资源中复制它。
最后,修改文件的权限以确保操作系统不会因为权限不够严格而阻止数据库的打开。
当一切完成后,DatabaseManager 单例将使用正确的数据库文件路径实例化,构造函数可以透明地打开此数据库。
注意
在 iOS 模拟器中,QStandardPaths::writableLocation()函数不会返回正确的路径。自 iOS 8 以来,模拟器在主机上的存储路径已更改,Qt 没有反映这一点。有关更多信息,请参阅bugreports.qt.io/browse/QTCREATORBUG-13655。
这些解决方案并不简单。这显示了跨平台应用程序在移动设备上的局限性。每个平台都有自己非常特定的处理文件系统和部署内容的方式。即使我们设法在 QML 中编写平台无关的代码,我们仍然必须处理操作系统之间的差异。
从自定义 InputDialog 创建新相册
AlbumListPage 需要一些数据来显示。下一步是能够添加一个新的专辑。为此,在某个时刻,我们将不得不从 QML 中调用 AlbumModel 函数来添加这个新专辑。在构建 UI 之前,我们不得不在 gallery-core 中进行一些小的修改。
AlbumModel 函数已经在 QML 中可用。然而,我们无法直接从 QML 代码中调用 AlbumModel::addAlbum(const Album& album);QML 引擎将不会识别该函数并抛出错误 TypeError: Property 'addAlbum' of object AlbumModel(...) is not a function。这可以通过简单地用 Q_INVOKABLE 宏装饰所需的函数来修复(就像我们对 PictureModel::setAlbumId() 所做的那样)。
尽管如此,这里还有一个问题:Album 是一个在 QML 中不被识别的 C++ 类。如果我们想在 QML 中完全访问 Album,这将涉及到对类的重大修改:
-
强制
Album类继承自QObject类。 -
添加一个
Q_PROPERTY宏来指定类中哪个属性应该可以从 QML 访问。 -
添加多个构造函数(复制构造函数、
QObject* parent等)。 -
强制
AlbumModel::addAlbum()函数接受一个Album*而不是Album&。对于复杂对象(即非基本类型),QML 只能处理指针。这并不是一个大问题,但使用引用而不是指针往往会使代码更安全。
如果类在 QML 中被大量操作,这些修改是完全合理的。我们的用例非常有限:我们只想创建一个新的专辑。在整个应用程序中,我们将依赖于本地的 Model/View API 来显示专辑数据,而不会使用 Album 的任何特定功能。
由于所有这些原因,我们将在 AlbumModel 中简单地添加一个包装函数:
// In AlbumModel.h
...
QModelIndex addAlbum(const Album& album);
Q_INVOKABLE void addAlbumFromName(const QString& name);
...
// In AlbumModel.cpp
void AlbumModel::addAlbumFromName(const QString& name)
{
addAlbum(Album(name));
}
新函数 addAlbumFromName() 只是包装了对 addAlbum() 的调用,并带有所需的专辑 name 参数。它可以通过 Q_INVOKABLE 宏从 QML 中调用。
我们现在可以切换回 gallery-mobile 项目的 UI。我们将使用 QML 的 Dialog 来添加这个专辑。QtQuick 提供了各种默认的对话框实现:
-
ColorDialog:此对话框用于选择颜色 -
Dialog:此对话框使用通用的对话框和标准按钮(相当于QDialog) -
FileDialog:此对话框用于从本地文件系统中选择文件 -
FontDialog:此对话框用于选择字体 -
MessageDialog:此对话框用于显示消息
你本应在这个列表中看到 InputDialog(因为我们曾在 第四章 中使用过 QInputDialog 小部件,征服桌面 UI),但 Qt Quick 并没有提供它。创建一个新的 QML 文件 (Qt Quick 2) 并将其命名为 InputDialog.qml。内容应如下所示:
import QtQuick 2.6
import QtQuick.Layouts 1.3
import Qt.labs.controls 1.0
import QtQuick.Dialogs 1.2
import QtQuick.Window 2.2
import "."
Dialog {
property string label: "New item"
property string hint: ""
property alias editText : editTextItem
standardButtons: StandardButton.Ok | StandardButton.Cancel
onVisibleChanged: {
editTextItem.focus = true
editTextItem.selectAll()
}
onButtonClicked: {
Qt.inputMethod.hide();
}
Rectangle {
implicitWidth: parent.width
implicitHeight: 100
ColumnLayout {
Text {
id: labelItem
text: label
color: Style.text
}
TextInput {
id: editTextItem
inputMethodHints: Qt.ImhPreferUppercase
text: hint
color: Style.text
}
}
}
}
在这个自定义InputDialog中,我们使用了通用的 Qt Quick Dialog,并将其修改为包含通过 IDeditTextItem引用的TextInput项。我们还在editTextItem上方添加了一个labelItem来描述预期的输入。在这个对话框中有几个需要注意的事项。
首先,因为我们希望这个对话框是通用的,所以它必须是可配置的。调用者应该能够提供参数来显示其特定的数据。这是通过在Dialog元素顶部的三个属性来完成的:
-
label: 这个属性配置了在labelItem中显示的文本。 -
hint: 这个属性是editTextItem中显示的默认文本。 -
editText: 这个属性引用了“本地”的editTextItem元素。这将允许调用者在对话框关闭时检索值。
我们还配置了Dialog元素,使其能够自动使用平台按钮,通过standardButtons: StandardButton.Ok | StandardButton.Cancel语法来验证或取消对话框。
最后,为了使对话框更加用户友好,当Dialog元素变为可见并且文本被选中时,editTextItem将获得焦点。这两个步骤在onVisibleChanged()回调函数中完成。当对话框隐藏(即,点击了确定或取消),我们使用Qt.InputMethod.hide()隐藏虚拟键盘。
InputDialog已准备好使用!打开AlbumListPage.qml并按如下方式修改:
PageTheme {
toolbarTitle: "Albums"
toolbarButtons: ToolButton {
background: Image {
source: "qrc:/res/icons/album-add.svg"
}
onClicked: {
newAlbumDialog.open()
}
}
InputDialog {
id: newAlbumDialog
title: "New album"
label: "Album name:"
hint: "My Album"
onAccepted: {
albumModel.addAlbumFromName(editText.text)
}
}
我们在PageTheme元素内部添加了 ID 为newAlbumDialog的InputDialog。我们定义了所有自定义属性:title、label和hint。当用户点击确定按钮时,将调用onAccepted()函数。在这里,只需调用AlbumModel元素中的包装函数addAlbumFromName()并传入输入的文本即可。
默认情况下,这个Dialog元素是不可见的,我们通过在toolbarButtons中添加一个ToolButton来打开它。这个ToolButton将按照我们在PageTheme.qml文件中指定的方式添加到页眉的右侧。为了符合移动标准,我们在这个按钮内部使用了一个自定义图标而不是文本。
这里可以看到,可以使用语法qrc:/res/icons/album-add.svg引用存储在.qrc文件中的图像。我们使用 SVG 文件来拥有可缩放的图标,但你也可以为gallery-mobile应用程序使用你自己的图标。
当用户点击ToolButton时,将调用onClicked()函数,在这里我们打开newAlbumDialog。在我们的参考设备 Nexus 5X 上,它看起来是这样的:

当用户点击确定按钮时,整个模型/视图管道开始工作。这个新相册被持久化,AlbumModel元素发出正确的信号来通知我们的ListView、albumList刷新自己。我们开始利用gallery-core的强大功能,它可以在桌面应用程序和移动应用程序中使用,而无需重写大量引擎代码。
使用 ImageProvider 加载图像
现在是时候显示我们刚刚持久化的相册的缩略图了。这些缩略图需要以某种方式加载。由于我们的应用程序针对的是移动设备,我们无法在加载缩略图时冻结 UI 线程。我们可能会占用 CPU 或者被操作系统杀死,这两种情况对于gallery-mobile来说都不是理想的结果。Qt 提供了一个非常方便的类来处理图像加载:QQuickImageProvider。
QQuickImageProvider类提供了一个接口,以异步方式在您的 QML 代码中加载QPixmap类。这个类会自动创建线程来加载QPixmap类,您只需实现requestPixmap()函数即可。还有更多内容,QQuickImageProvider默认会缓存请求的位图,以避免过多地击中数据源。
我们的缩略图必须从PictureModel元素加载,该元素提供了对给定Picture的fileUrl的访问。我们的rQQuickImageProvider 实现将需要为PicturelModel中的行索引获取QPixmap类。创建一个新的 C++类名为PictureImageProvider,并按如下方式修改PictureImageProvider.h:
#include <QQuickImageProvider>
class PictureModel;
class PictureImageProvider : public QQuickImageProvider
{
public:
PictureImageProvider(PictureModel* pictureModel);
QPixmap requestPixmap(const QString& id, QSize* size,
const QSize& requestedSize) override;
private:
PictureModel* mPictureModel;
};
在构造函数中必须提供一个指向PictureModel元素的指针,以便能够检索fileUrl。我们重写了requestPixmap(),它在参数列表中有一个id参数(现在可以安全地忽略size和requestedSize)。当我们想要加载图片时,这个id参数将在 QML 代码中提供。对于 QML 中的给定Image,PictureImageProvider类将被调用如下:
Image { source: "image://pictures/" + index }
让我们分解一下:
-
image:这是图像 URL 源的方案。这告诉 Qt 与图像提供者一起加载图像。 -
pictures:这是图像提供者的标识符。我们将在main.cpp中初始化QmlEngine时将PictureImageProvider类和此标识符链接起来。 -
index:这是图像的 ID。在这里,它是图片的行索引。这对应于requestPixmap()中的id参数。
我们已经知道我们想要以两种模式显示图片:缩略图和全分辨率。在这两种情况下,都将使用QQuickImageProvider类。这两个模式的行为非常相似:它们将请求PictureModel的fileUrl并返回加载的QPixmap。
这里有一个模式。我们可以轻松地将这两种模式封装在PictureImageProvider中。我们唯一需要知道的是调用者想要缩略图还是全分辨率QPixmap。这可以通过使id参数更加明确来实现。
我们将实现requestPixmap()函数,使其能够以两种方式被调用:
-
images://pictures/<index>/full:使用这种语法来检索全分辨率图片 -
images://pictures/<index>/thumbnail:使用这种语法来检索图片的缩略图版本
如果index值是0,这两个调用将在requestPixmap()中将 ID 设置为0/full或0/thumbnail。让我们看看PictureImageProvider.cpp中的实现:
#include "PictureModel.h"
PictureImageProvider::PictureImageProvider(PictureModel* pictureModel) :
QQuickImageProvider(QQuickImageProvider::Pixmap),
mPictureModel(pictureModel)
{
}
QPixmap PictureImageProvider::requestPixmap(const QString& id, QSize* /*size*/, const QSize& /*requestedSize*/)
{
QStringList query = id.split('/');
if (!mPictureModel || query.size() < 2) {
return QPixmap();
}
int row = query[0].toInt();
QString pictureSize = query[1];
QUrl fileUrl = mPictureModel->data(mPictureModel->index(row, 0), PictureModel::Roles::UrlRole).toUrl();
return ?? // Patience, the mystery will be soon unraveled
}
我们首先使用带有QQuickImageProvider::Pixmap参数的QQuickImageProvider构造函数来配置QQuickImageProvider以调用requestPixmap()。QQuickImageProvider构造函数支持各种图像类型(QImage、QPixmap、QSGTexture、QQuickImageResponse),并且每种类型都有自己的requestXXX()函数。
在requestPixmap()函数中,我们首先使用/分隔符拆分这个 ID。从这里,我们检索row值和所需的pictureSize。通过简单地调用带有正确参数的mPictureModel::data()函数来加载fileUrl。我们在第四章,征服桌面 UI中使用了完全相同的调用。
太好了,我们知道应该加载哪个fileUrl以及期望的维度。然而,我们还有最后一件事要处理。因为我们操作的是行而不是数据库 ID,所以对于不同相册中的两张不同图片,我们将会有相同的请求 URL。记住,PictureModel会为给定的Album加载一系列图片。
我们应该想象(这里是一个双关语)这种情况。对于一个名为Holidays的相册,请求 URL 将是images://pictures/0/thumbnail以加载第一张图片。对于另一个名为Pets的相册,它将以相同的 URLimages://pictures/0/thumbnail加载第一张图片。正如我们之前所说的,QQuickImageProvider会自动生成缓存,这将避免对相同 URL 的后续requestPixmap()调用。因此,无论选择哪个相册,我们总是会提供相同的图片。
这个约束迫使我们禁用PictureImageProvider中的缓存并推出我们自己的缓存。这是一件有趣的事情来做;以下是一个可能的实现:
// In PictureImageProvider.h
#include <QQuickImageProvider>
#include <QCache>
...
public:
static const QSize THUMBNAIL_SIZE;
QPixmap requestPixmap(const QString& id, QSize* size, const QSize& requestedSize) override;
QPixmap* pictureFromCache(const QString& filepath, const QString& pictureSize);
private:
PictureModel* mPictureModel;
QCache<QString, QPixmap> mPicturesCache;
};
// In PictureImageProvider.cpp
const QString PICTURE_SIZE_FULL = "full";
const QString PICTURE_SIZE_THUMBNAIL = "thumbnail";
const QSize PictureImageProvider::THUMBNAIL_SIZE = QSize(350, 350);
QPixmap PictureImageProvider::requestPixmap(const QString& id, QSize* /*size*/, const QSize& /*requestedSize*/)
{
...
return *pictureFromCache(fileUrl.toLocalFile(), pictureSize);
}
QPixmap* PictureImageProvider::pictureFromCache(const QString& filepath, const QString& pictureSize)
{
QString key = QStringList{ pictureSize, filepath }
.join("-");
QPixmap* cachePicture = nullptr;
if (!mPicturesCache.contains(pictureSize)) {
QPixmap originalPicture(filepath);
if (pictureSize == PICTURE_SIZE_THUMBNAIL) {
cachePicture = new QPixmap(originalPicture
.scaled(THUMBNAIL_SIZE,
Qt::KeepAspectRatio,
Qt::SmoothTransformation));
} else if (pictureSize == PICTURE_SIZE_FULL) {
cachePicture = new QPixmap(originalPicture);
}
mPicturesCache.insert(key, cachePicture);
} else {
cachePicture = mPicturesCache[pictureSize];
}
return cachePicture;
}
这个新的pictureFromCache()函数旨在将生成的QPixmap存储在mPicturesCache中,并返回正确的QPixmap。mPicturesCache类依赖于一个QCache;这个类允许我们以键/值的方式存储数据,并且可以为每个条目分配一个成本。这个成本应该大致映射对象的内存成本(默认情况下,cost = 1)。当QCache实例化时,它使用一个maxCost值初始化(默认100)。当所有对象的成本总和超过maxCost时,QCache开始删除对象以腾出空间,从最近最少访问的对象开始。
在pictureFromCache()函数中,我们在尝试从缓存中检索QPixmap之前,首先生成一个由fileUrl和pictureSize组成的键。如果它不存在,将生成适当的QPixmap(如果需要,将其缩放到THUMBNAIL_SIZE宏),并将其存储在缓存中。mPicturesCache类成为这个QPixmap的所有者。
完成PictureImageProvider类的最后一步是使其在 QML 上下文中可用。这可以在main.cpp中完成:
#include "AlbumModel.h"
#include "PictureModel.h"
#include "PictureImageProvider.h"
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
...
QQmlContext* context = engine.rootContext();
context->setContextProperty("thumbnailSize", PictureImageProvider::THUMBNAIL_SIZE.width());
context->setContextProperty("albumModel", &albumModel);
context->setContextProperty("pictureModel", &pictureModel);
engine.addImageProvider("pictures", new
PictureImageProvider(&pictureModel));
...
}
使用engine.addImageProvider()将PictureImageProvider类添加到 QML 引擎中。第一个参数将是 QML 中的提供者标识符。请注意,引擎将接管传递的PictureImageProvider。最后一件事情,thumbnailSize参数也传递给engine,它将在 QML 代码中将缩略图限制为指定的尺寸。
在 GridView 中显示缩略图
下一步是显示这些缩略图。创建一个新的 QML 文件,命名为AlbumPage.qml:
import QtQuick 2.6
import QtQuick.Layouts 1.3
import QtQuick.Controls 2.0
import "."
PageTheme {
property string albumName
property int albumRowIndex
toolbarTitle: albumName
GridView {
id: thumbnailList
model: pictureModel
anchors.fill: parent
anchors.leftMargin: 10
anchors.rightMargin: 10
cellWidth : thumbnailSize
cellHeight: thumbnailSize
delegate: Rectangle {
width: thumbnailList.cellWidth - 10
height: thumbnailList.cellHeight - 10
color: "transparent"
Image {
id: thumbnail
anchors.fill: parent
fillMode: Image.PreserveAspectFit
cache: false
source: "image://pictures/" + index + "/thumbnail"
}
}
}
}
这个新的PageTheme元素定义了两个属性:albumName和albumRowIndex。albumName属性用于更新toolbarTitle中的标题;albumRowIndex将用于与AlbumModel交互,以便从当前页面重命名或删除专辑。
要显示缩略图,我们依赖于一个GridView元素,它将缩略图布局在单元格的网格中。这个thumbnailList项目使用pictureModel请求其数据。代理只是一个包含单个Image的Rectangle元素。这个Rectangle元素略小于thumbnailList.cellWidth或thumbnailList.cellHeight。GridView元素不提供spacing属性(像ListView一样,在项目之间留出一些空间)。因此,我们通过使用较小的区域来显示内容来模拟它。
Image项目将尝试使用anchors.fill: parent占用所有可用空间,但仍然会使用fillMode: Image.PreserveAspectFit保持提供的图片的宽高比。你可以识别出source属性,其中提供了当前代理index以检索缩略图。最后,cache: false属性确保PictureImageProvider类不会尝试使用本地缓存。
要显示 AlbumPage.qml,我们必须更新 stackView(位于 main.qml 中)。记住,stackView 已声明为属性(pageStack),因此可以从任何 QML 文件中访问它。
当用户点击 AlbumListPage.qml 中给定 Album 值的 MouseArea 元素时,将显示 AlbumPage 元素。
现在,我们将赋予用户添加新图片的能力。为此,我们将依赖于 QtQuick 对话框:FileDialog。以下是更新后的 AlbumPage.qml 版本:
import QtQuick 2.6
import QtQuick.Layouts 1.3
import QtQuick.Controls 2.0
import QtQuick.Dialogs 1.2
import "."
PageTheme {
property string albumName
property int albumRowIndex
toolbarTitle: albumName
toolbarButtons: RowLayout {
ToolButton {
background: Image {
source: "qrc:/res/icons/photo-add.svg"
}
onClicked: {
dialog.open()
}
}
}
FileDialog {
id: dialog
title: "Open file"
folder: shortcuts.pictures
onAccepted: {
var pictureUrl = dialog.fileUrl
pictureModel.addPictureFromUrl(pictureUrl)
dialog.close()
}
}
GridView {
...
}
FileDialog 元素的实现非常直接。通过使用 folder: shortcuts.pictures 属性,QtQuick 将自动将 FileDialog 元素定位到平台特定的图片目录。更好的是,在 iOS 上,它将打开原生照片应用程序,您可以在其中选择自己的图片。
当用户验证其图片选择时,路径在 onAccepted() 函数的 dialog.fileUrl 字段中可用,我们将其存储在 pictureUrl 变量中。然后,将此 pictureUrl 变量传递给 PictureModel 的新包装函数 addPictureFromUrl()。使用的模式与我们为 AlbumModel::addAlbumFromName() 所做的完全相同:一个围绕 PictureModel::addPicture() 的 Q_INVOKABLE 包装函数。
AlbumPage 中缺失的部分是删除专辑和重命名专辑的功能。它们遵循我们已讨论过的模式。删除操作将通过 AlbumModel 中的包装函数来完成,而重命名将重用我们为 AlbumListPage.qml 创建的 InputDialog。请参考该章节的源代码以查看这些功能的实现。以下是缩略图在 Android 设备上的显示效果:

滑动浏览全分辨率图片
在 gallery-mobile 中,我们还需要实现最后一页,即全分辨率图片页面。在 第四章,征服桌面 UI 中,我们使用上一页/下一页按钮浏览图片。在本章中,我们针对移动平台。因此,导航应使用基于触摸的手势:滑动。
这是新 PicturePage.qml 文件的实现:
import QtQuick 2.0
import QtQuick.Layouts 1.3
import QtQuick.Controls 2.0
import "."
PageTheme {
property string pictureName
property int pictureIndex
toolbarTitle: pictureName
ListView {
id: pictureListView
model: pictureModel
anchors.fill: parent
spacing: 5
orientation: Qt.Horizontal
snapMode: ListView.SnapOneItem
currentIndex: pictureIndex
Component.onCompleted: {
positionViewAtIndex(currentIndex,
ListView.SnapPosition)
}
delegate: Rectangle {
property int itemIndex: index
property string itemName: name
width: ListView.view.width == 0 ?
parent.width : ListView.view.width
height: pictureListView.height
color: "transparent"
Image {
fillMode: Image.PreserveAspectFit
cache: false
width: parent.width
height: parent.height
source: "image://pictures/" + index + "/full"
}
}
}
}
我们首先定义两个属性,pictureName 和 pictureIndex。当前的 pictureName 属性在 toolbarTitle 中显示,而 pictureIndex 用于在 ListView 中初始化正确的 currentIndex,即 currentIndex: pictureIndex。
要能够滑动浏览图片,我们再次使用 ListView。在这里,每个项目(一个简单的 Image 元素)将占据其父元素的全尺寸。当组件加载时,即使 currentIndex 设置正确,视图也必须更新到正确的索引位置。这就是我们在 pictureListView 中通过以下方式所做的:
Component.onCompleted: {
positionViewAtIndex(currentIndex, ListView.SnapPosition)
}
这将更新当前可见项的位置到currentIndex。到目前为止,一切顺利。然而,当创建一个ListView时,它首先做的事情是初始化其代理。ListView有一个view属性,其中填充了delegate内容。这意味着在Component.onCompleted()中,ListView.view(是的,这很痛苦)没有任何宽度。因此,positionViewAtIndex()函数实际上什么也不做。为了防止这种行为,我们必须使用三元表达式ListView.view.width == 0 ? parent.width : ListView.view.width为代理提供一个默认初始宽度。这样,视图在第一次加载时将有一个默认宽度,并且positionViewAtIndex()函数可以愉快地移动,直到ListView.view正确加载。
要滑动查看每张图片,我们将ListView的snapMode值设置为ListView.SnapOneItem。每次快速滑动都会跳转到下一张或上一张图片,而不会继续运动。
代理的Image项看起来非常像缩略图版本。唯一的区别是源属性,我们请求PictureImageProvider类使用全分辨率。
当PicturePage打开时,正确的pictureName属性会在标题中显示。然而,当用户快速滑动到另一张图片时,名称不会更新。为了处理这个问题,我们必须检测运动状态。在pictureListView中添加以下回调:
onMovementEnded: {
currentIndex = itemAt(contentX, contentY).itemIndex
}
onCurrentItemChanged: {
toolbarTitleLabel.text = currentItem.itemName
}
当由滑动引起的运动结束时,会触发onMovementEnded()类。在这个函数中,我们使用contentX和contentY坐标的可见项的itemIndex更新ListViewcurrentIndex。
第二个功能,onCurrentItemChanged(),在currentIndex更新时被调用。它将简单地使用当前项的图片名称更新toolbarTitleLabel.text。
要显示PicturePage.qml,在AlbumPage.qml的thumbnailList代理中使用了相同的MouseArea模式:
MouseArea {
anchors.fill: parent
onClicked: {
thumbnailList.currentIndex = index
pageStack.push("qrc:/qml/PicturePage.qml",
{ pictureName: name, pictureIndex: index })
}
}
再次强调,PicturePage.qml文件被推送到pageStack,并且所需的参数(pictureName和pictureIndex)以相同的方式提供。
摘要
本章结束了画廊应用程序的开发。我们使用gallery-core建立了坚实的基础,使用gallery-desktop创建了一个小部件 UI,最后使用gallery-mobile制作了一个 QML UI。
QML 使 UI 开发变得非常快速。不幸的是,这项技术仍然很年轻,并且正在快速发展。与移动操作系统(Android、iOS)的集成正在积极开发中,我们希望它能带来使用 Qt 的伟大移动应用程序。目前,移动跨平台工具包的固有限制仍然难以克服。
下一章将把 QML 技术带到新的领域:在 Raspberry Pi 上运行的蛇形游戏开发。
第六章. 即使 Qt 也值得在树莓派上拥有一片天地
在上一章中,我们创建了一个针对 Android 和 iOS 的 QML UI。我们将继续在嵌入式世界中的旅程,探索我们如何在树莓派上部署 Qt 应用程序。本主题的示例项目将是一个使用 Qt3D 模块的蛇游戏。玩家将控制一条蛇,试图吃苹果以变得尽可能大。
在本章中,你将学习:
-
Qt3D 模块的架构
-
跨编译的基本原则
-
如何构建自己的 Qt Creator 工具包以在树莓派上编译和部署你的游戏
-
如何处理各种平台(桌面、树莓派)的差异和限制
-
工厂设计模式
-
如何使用 JavaScript 和 QML 编写完整的游戏引擎
-
QML 分析器的使用
探索 Qt3D
本章的示例项目将依赖于 3D 渲染。为此,我们将使用 Qt3D。框架的这一部分被划分为各种 Qt 模块,使应用程序能够实现接近实时模拟的 3D 环境。建立在 OpenGL 之上,Qt3D 提供了一个高级 API 来描述复杂的场景,而无需编写低级 OpenGL 指令。Qt3D 支持以下基本功能:
-
C++和 Qt Quick 的 2D 和 3D 渲染
-
网格
-
材质
-
GLSL 着色器
-
阴影映射
-
延迟渲染
-
实例渲染
-
常量缓冲区对象
所有这些功能都是在ECS(实体组件系统)架构中实现的。你定义的每个网格、材质或着色器都是一个组件。这些组件的聚合形成一个实体。如果你想绘制一个 3D 红色苹果,你需要以下组件:
-
网格组件,持有你的苹果的顶点
-
材质组件,将纹理应用到网格或为其上色
这两个组件将被重新组合以定义实体 Apple。你在这里可以看到 ECS 的两个部分:实体和组件。整体架构看起来像这样:

这些组件可以根据方面重新分组。方面是多个组件在同一部分(渲染、音频、逻辑和物理)上工作的“切片”。当 Qt3D 引擎处理所有实体的图时,每个方面的层会依次处理。
基本的方法是优先考虑组合而非继承。在游戏中,一个实体(一个苹果、一个玩家、一个敌人)在其生命周期中可以有多种状态:生成、为给定状态进行动画、死亡动画等等。使用继承来描述这些状态会导致一个令人头疼的树状结构:AppleSpawn、AppleAnimationShiny、AppleDeath等等。这会很快变得难以维护。对任何类的任何修改都可能对许多其他类产生巨大影响,并且可能的状态组合数量会失控。说一个状态只是给定实体的一个组件,这给了我们灵活性,可以轻松地交换组件,同时仍然保持实体抽象;一个苹果Entity元素仍然是苹果,即使它使用的是AnimationShiny组件而不是AnimationSpawn组件。
让我们看看如何在 QML 中定义一个基本的Entity元素。想象这是我们一直在谈论的苹果。Apple.qml文件看起来会是这样:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Extras 2.0
Entity {
property alias position: transform.translation
PhongMaterial {
id: material
diffuse: "red"
}
SphereMesh {
id: mesh
}
Transform {
id: transform
}
components: [material, mesh, transform]
}
在非常少的几行代码中,你描述了Entity元素的各个方面:
-
Entity:这是文件的根对象;它遵循我们在第五章,掌握移动 UI中研究的相同的 QML 模式。 -
PhongMaterial:这定义了表面将如何渲染。在这里,它使用 Phong 着色技术来实现平滑表面。它继承自QMaterial,这是所有材质类的基类。 -
CuboidMesh:这定义了将使用哪种类型的网格。它继承自QGeometryRenderer,这也赋予了加载自定义模型(从 3D 建模软件导出)的能力。 -
Transform:这定义了组件的变换矩阵。它可以自定义Entity元素的平移、缩放和位置。 -
Position:这是一个属性,用于暴露给定调用者/父对象的transform.translation。如果我们想移动苹果,这可能会很快变得很有用。 -
Components:这是一个数组,包含Entity元素所有组件的所有 ID。
如果我们想将这个Apple变成另一个Entity的子对象,我们只需在这个新的Entity元素内部定义苹果即可。让我们称它为World.qml:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Extras 2.0
Entity {
id: sceneRoot
RenderSettings {
id: frameFraph
activeFrameGraph: ForwardRenderer {
clearColor: Qt.rgba(0, 0, 0, 1)
}
}
Apple {
id: apple
position: Qt.vector3d(3.0, 0.0, 2.0)
}
components: [frameGraph]
}
在这里,World Entity没有视觉表示;我们希望它是我们 3D 场景的根。它只包含我们之前定义的Apple。苹果的x、y、z坐标相对于父对象。当父对象进行平移时,相同的平移也会应用到苹果上。
这就是定义实体/组件层次结构的方法。如果你用 C++编写 Qt3D 代码,相同的逻辑也适用于等效的 C++类(QEntity、QComponent等等)。
因为我们决定使用World.qml文件作为场景的根,所以它必须定义场景将如何渲染。Qt3D 渲染算法是数据驱动的。换句话说,存在一个清晰的分离,即应该渲染什么(实体和组件的树)以及如何渲染。
如何渲染依赖于使用framegraph的类似树结构。在 Qt Quick 中,使用单一的渲染方法,它涵盖了 2D 绘图。另一方面,在 3D 中,灵活渲染的需求使得解耦渲染技术成为必要。
考虑这个例子:你玩一个游戏,游戏中你控制你的角色并遇到了一面镜子。必须从多个视图中渲染相同的 3D 场景。如果渲染技术是固定的,这会带来多个问题:应该先绘制哪个视口?能否在 GPU 中并行渲染视口?如果我们需要多次渲染呢?
在这个代码片段中,我们使用传统的 OpenGL 渲染技术,通过ForwardRenderer树,其中每个对象都是直接在后缓冲区上渲染,一次一个。Qt3D 提供了选择渲染器(ForwardRenderer、DeferredRenderer等)并配置场景应如何渲染的可能性。
OpenGL 通常使用双缓冲技术来渲染其内容。前缓冲区是显示在屏幕上的内容,后缓冲区是场景正在渲染的地方。当后缓冲区准备好后,两个缓冲区就会交换。
在每个Entity元素顶部需要注意的最后一点是,我们指定了以下内容:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Extras 2.0
导入部分只有 Qt3D 模块。Qt3D 类不继承Item,因此不能直接与 QML 组件混合。这个基本 Qt3D 构建块继承树如下:

QNode类是所有 Qt3D 节点类的基类。它依赖于QObject类来定义父子关系。每个QNode类实例还添加了一个唯一的id变量,这使得它可以被识别为其他实例。
即使QNode不能与 Qt Quick 类型混合,但它们可以被添加到Q3DScene元素(或在 QML 中的Scene3D),它作为 Qt3D 内容的画布,并可以被添加到 Qt Quick 的Item中。将World.qml添加到场景就像这样简单:
Rectangle {
Scene3D {
id: scene
anchors.fill: parent
focus: true
World { }
}
}
Scene3D元素包含一个World实例并定义了常见的 Qt Quick 属性(anchors、focus)。
为你的树莓派配置 Qt
这个项目针对一个新的嵌入式平台:树莓派。Qt 官方支持树莓派 2,但我们没有遇到任何麻烦地在树莓派 3 上运行了项目。如果你没有这些设备之一,尽管如此,阅读这一节了解交叉编译的工作原理以及如何在 Qt Creator 中配置自己的工具包可能仍然很有趣。无论如何,本章的其余部分将在桌面平台上运行。
在深入到树莓派配置之前,让我们退一步来理解我们的目标。你的电脑可能运行在 x86 CPU 架构上。这意味着你运行的每个程序都将使用你 CPU 的 x86 指令集执行。在 Qt Creator 中,这转化为你的可用套件。一个套件必须匹配你的目标平台。在启动时,Qt Creator 会在你的电脑上搜索可用的套件,并为你加载它们。
在第五章中,掌握移动 UI,我们针对不同的平台:Android 和 iOS。这些平台运行在不同的 CPU 指令集:ARM。幸运的是,Qt 背后的团队为我们自动配置了必要的组件,使其能够工作。
树莓派也运行在 ARM 上,但默认情况下并不适用于 Qt。在 Qt Creator 中玩耍之前,我们必须先准备它。请注意,以下命令是在 Linux 盒子上运行的,但你应该能够使用 Cygwin 在 Mac 或 Windows 上运行它们。
注意
请遵循完整的指南来准备你的树莓派以用于 Qt,请参阅wiki.qt.io/RaspberryPi2EGLFS,或者简单地从www.qtrpi.com下载预编译的捆绑包。
完整的树莓派安装指南超出了本书的范围。尽管如此,总结主要步骤仍然很有趣:
-
将开发包添加到树莓派中。
-
获取完整的工具链,包括将在你的机器上执行的交叉编译器。
-
在你的机器上创建一个
sysroot文件夹,它将镜像树莓派上必要的目录。 -
在
sysroot文件夹中使用交叉编译器编译 Qt。 -
将这个
sysroot与树莓派同步。
sysroot只是一个包含给定平台最小文件系统的目录。它通常包含/usr/lib和/usr/include目录。在你的机器上拥有这个目录使得交叉编译器能够正确编译和链接输出二进制文件,而无需从树莓派上执行。
所有这些步骤都是为了避免在树莓派上直接编译任何东西。作为一个低功耗设备,任何编译的执行都会花费非常非常长的时间。在树莓派上编译 Qt 可能需要超过 40 小时。了解这一点后,配置交叉编译器所花费的时间似乎更容易承受。
在 wiki 中提到的qopenglwidget示例在继续之前应该能够正常运行。一旦完成,我们必须交叉编译一些更多的 Qt 模块,以便我们的项目能够运行:
-
Qtdeclarative:这个模型用于访问 Qt Quick -
qt3d:这个模型用于构建 3D 世界 -
qtquickcontrols:这个模型用于包含有趣的控件(标签) -
qtquickcontrols2:这个模型用于提供一些新的布局
对于这些模块中的每一个,执行以下命令(从你的~/raspi目录):
git clone git://code.qt.io/qt/<modulename>.git -b 5.7
cd <modulename>
~/raspi/qt5/bin/qmake -r
make
make install
小贴士
您可以通过在 make 中添加参数 -j(或 --jobs)来加快编译速度。make 命令将尝试并行化编译任务,如果您的 CPU 有四个核心,使用 make -j 4,八个核心,make -j 8,依此类推。
当所有内容都编译完成后,再次使用以下命令同步您的 sysroot 目录:
rsync -avz qt5pi pi@IP:/usr/local
在之前的命令中,你必须将 IP 替换为实际的树莓派地址。
树莓派已准备好执行我们的 Qt 代码。然而,我们必须在 Qt Creator 中创建自己的工具包,以便能够编译和部署我们的程序。一个工具包由以下部分组成:
-
一个编译器,它将使用目标平台的 CPU 指令集编译您的代码。
-
一个能够了解目标平台指令集的调试器,以便正确地中断并读取内存内容。
-
为目标平台编译和链接您的二进制文件而编译的Qt 版本。
-
一个设备,Qt Creator 可以连接到以部署和执行您的程序。
我们将从编译器开始。在 Qt Creator 中:
-
前往工具 | 选项 | 构建和运行 | 编译器。
-
点击添加 | GCC。
-
浏览到
~/raspi/tools/arm-bcm2708/gcc-linaro-arm-linux-gnueabihf-raspbian/bin/arm-linux-gnueabihf-g++。 -
将编译器重命名为
Rpi GCC。
这个奇怪的二进制名称使得 Qt 更容易解析 ABI(应用程序二进制接口),以找出平台架构、文件格式等。它应该看起来像这样:

现在是调试器的时间。正如我们之前所说的,我们是从 Linux 箱子(Ubuntu)构建这个项目的。交叉编译和嵌入式开发在 Linux 上通常更容易,但您应该能够在 Windows 或 Mac 上通过一些额外的步骤完成相同的工作。
在 Ubuntu Linux 上,只需使用命令sudo apt-get install gdb-multiarch安装多架构的 gdb。在 Qt Creator 中,在调试器选项卡中添加这个新的调试器:

接下来,在Qt 版本选项卡中添加在维基页面上解释的交叉编译 Qt。点击添加并浏览到~/raspi/qt5/bin/qmake。这是生成的 Qt 版本:

我们几乎完成了!在构建工具包之前,我们只需配置 Raspberry Pi 设备访问。在选项 | 设备中,按照以下步骤操作:
-
点击添加.. | 通用 Linux 设备 | 开始向导。
-
名称将是
Rpi 2(如果你有,则为 3)。 -
输入您的设备 IP 地址(是的,您必须连接到您的本地网络!)。
-
默认用户名是pi。
-
默认密码是 "raspberry"。
-
点击下一步以测试与设备的连接。
如果一切顺利,这就是您的新设备:

最后,套件将把所有这些部分组合成一个有效的 Qt Creator 平台。回到构建和运行 | 套件。从这里,您只需指向我们之前构建的每个部分。以下是生成的套件:

注意,Sysroot字段应指向我们之前在~/raspi/sysroot中创建的sysroot文件夹。
小贴士
如果您点击名称右侧的按钮,您可以为一套件选择自定义图片,例如 Raspberry Pi 标志。
现在一切准备就绪,可以制作一个精彩的蛇形游戏。
为您的 Qt3D 代码创建一个入口点。
对于那些在年轻时没有玩过蛇形游戏的人来说,这里有一个关于游戏玩法的小提醒:
-
您控制着在一个空旷区域移动的蛇。
-
这个区域被墙壁包围。
-
游戏区域内随机生成一个苹果。
-
如果蛇吃到了苹果,它会变长,并且您会得到一分。之后,游戏区域中会再次生成一个苹果。
-
如果蛇碰到墙壁或其自身的任何一部分,您就会失败。
目标是吃尽可能多的苹果以获得最高分。蛇越长,避开墙壁和自身尾巴就越困难。哦,而且每次蛇吃苹果时,它都会变得越来越快。游戏架构将是以下这样:
-
所有游戏项目都将使用 Qt3D 在 QML 中定义。
-
所有游戏逻辑都将使用 JavaScript 完成,它将与 QML 元素通信。
我们将通过将相机放置在游戏区域上方来保持原始蛇形游戏的 2D 感觉,但我们将通过 3D 模型和一些着色器来增加趣味性。
好的,我们花费了大量页面来准备这一刻。现在是时候开始蛇形项目了。创建一个名为ch06-snake的新Qt Quick Controls 应用程序。在项目详情中:
-
在最小所需 Qt 版本字段中选择Qt 5.6。
-
取消选中带有 ui.qml 文件。
-
取消选中启用本地样式。
-
点击下一步并选择以下套件:
-
RaspberryPi 2
-
桌面
-
-
点击下一步 | 完成。
我们必须添加 Qt3D 模块。按照如下方式修改ch06-snake.pro:
TEMPLATE = app
QT += qml quick 3dcore 3drender 3dquick 3dinput 3dextras
CONFIG += c++11
SOURCES += main.cpp
RESOURCES += \
snake.qrc
HEADERS +=
target.files = ch06-snake
target.path = /home/pi
INSTALLS += target
我们必须准备应用程序的入口点,以便拥有一个适当的 OpenGL 上下文,Qt3D 可以在此上下文中工作。按照如下方式打开并更新main.cpp:
#include <QGuiApplication>
#include <QtGui/QOpenGLContext>
#include <QtQuick/QQuickView>
#include <QtQml/QQmlEngine>
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
qputenv("QT3D_GLSL100_WORKAROUND", "");
QSurfaceFormat format;
if (QOpenGLContext::openGLModuleType() ==
QOpenGLContext::LibGL) {
format.setVersion(3, 2);
format.setProfile(QSurfaceFormat::CoreProfile);
}
format.setDepthBufferSize(24);
format.setStencilBufferSize(8);
QQuickView view;
view.setFormat(format);
view.setResizeMode(QQuickView::SizeRootObjectToView);
QObject::connect(view.engine(), &QQmlEngine::quit,
&app, &QGuiApplication::quit);
view.setSource(QUrl("qrc:/main.qml"));
view.show();
return app.exec();
}
想法是配置一个QSurfaceFormat以正确处理 OpenGL,并将其提供给自定义的QQuickView view。这个view将使用此格式来绘制自己。
qputenv("QT3D_GLSL100_WORKAROUND", "")指令是与某些嵌入式 Linux 设备(如 Raspberry Pi)上的 Qt3D 着色器相关的绕过方法。它将为一些嵌入式设备所需的灯光启用一个单独的 GLSL 1.00 片段。如果您不使用此绕过方法,您将得到一个黑色屏幕,并且无法在 Raspberry Pi 上正确运行项目。
小贴士
Qt3d 灯光绕过细节在此:codereview.qt-project.org/#/c/143136/。
我们选择使用 Qt Quick 来处理视图。另一种方法是为 QMainWindow 创建一个 C++ 类,使其成为 QML 内容的父类。这种方法可以在许多 Qt3D 示例项目中找到。两种方法都是有效的并且可以工作。你倾向于使用 QMainWindow 方法编写更多的代码,但它允许你仅使用 C++ 创建 3D 场景。
注意,main.cpp 文件中的 view 尝试加载一个 main.qml 文件。你可以看到它正在加载;以下是 main.qml:
import QtQuick 2.6
import QtQuick.Controls 1.4
Item {
id: mainView
property int score: 0
readonly property alias window: mainView
width: 1280; height: 768
visible: true
Keys.onEscapePressed: {
Qt.quit()
}
Rectangle {
id: hud
color: "#31363b"
anchors.left: parent.left
anchors.right: parent.right
anchors.top : parent.top
height: 60
Label {
id: snakeSizeText
anchors.centerIn: parent
font.pointSize: 25
color: "white"
text: "Score: " + score
}
}
}
在这里,我们定义了屏幕顶部的 HUD(抬头显示),其中将显示得分(吃掉苹果的数量)。注意,我们将 Esc 键绑定到了 Qt.quit() 信号。这个信号在 main.cpp 中连接到了 QGuiApplication::quit() 信号,以便退出应用程序。
QML 上下文现在已准备好欢迎 Qt3D 内容。按照如下方式修改 main.qml:
import QtQuick 2.6
import QtQuick.Controls 1.4
import QtQuick.Scene3D 2.0
Item {
...
Rectangle {
id: hud
...
}
Scene3D {
id: scene
anchors.top: hud.bottom
anchors.bottom: parent.bottom
anchors.left: parent.left
anchors.right: parent.right
focus: true
aspects: "input"
}
}
Scene3D 元素占据了 hud 对象下面的所有可用空间。它获取窗口的焦点,以便能够拦截键盘事件。它还启用了输入方面,以便 Qt3D 引擎在其图遍历中处理键盘事件。
设置场景
我们现在可以开始编写 Qt3D 代码了。第一步是定义场景的根。创建一个名为 GameArea.qml 的新文件:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Extras 2.0
Entity {
id: root
property alias gameRoot: root
Camera {
id: camera
property real x: 24.5
property real y: 14.0
projectionType: CameraLens.PerspectiveProjection
fieldOfView: 45
aspectRatio: 16/9
nearPlane : 0.1
farPlane : 1000.0
position: Qt.vector3d( x, y, 33.0 )
upVector: Qt.vector3d( 0.0, 1.0, 0.0 )
viewCenter: Qt.vector3d( x, y, 0.0 )
}
RenderSettings {
id: frameFraph
activeFrameGraph: ForwardRenderer {
clearColor: Qt.rgba(0, 0, 0, 1)
camera: camera
}
}
components: [frameFraph]
}
我们首先做的事情是创建一个相机并将其定位。记住,在 OpenGL 中,坐标遵循你右手拇指指向的左侧!:

通过将相机放置在 Qt.vector3d(x, y, 33),我们使其“从屏幕中出来”,以便能够用简单的 x、y 轴来表示我们尚未创建的实体的坐标。upVector: Qt.vector3d(0.0, 1.0, 0.0) 指定了相机的向上向量,在我们的例子中是 Y 轴。最后,我们指向 Qt.vector(x, y, 0),意味着屏幕的中心。
总体目标是简化坐标表达。通过这种方式定位相机,将对象放置在坐标 0, 0 将使其位于窗口的左下角,而坐标 50, 28 则意味着窗口的右上角。
我们还使用一个 ForwardRendered 配置 RenderSettings,它定义了两个属性:
-
clearColor:这个属性Qt.rgba(0, 0, 0, 1)表示背景将是漆黑一片 -
camera:这个属性用于确定要渲染的视口
场景已准备好渲染,但我们需要处理用户输入,即键盘。为了捕获键盘事件,将 GameArea.qml 修改如下:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Input 2.0
Entity {
...
RenderSettings {
...
}
KeyboardDevice {
id: keyboardController
}
InputSettings { id: inputSettings }
KeyboardHandler {
id: input
sourceDevice: keyboardController
focus: true
onPressed: { }
}
components: [frameFraph, input]
}
KeyboardDevice 元素负责将按键事件分发到活动的 KeyboardHandler,即 input。KeyboardHandler 组件附加到控制器上,并且每次按键时都会调用 onPressed() 函数。KeyboardHandler 是一个组件;因此它需要被添加到 GameArea 的组件列表中。
GameArea 最后缺失的部分是准备引擎执行(初始化和更新):
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Input 2.0
import QtQuick 2.6 as QQ2
Entity {
id: root
property alias gameRoot: root
property alias timerInterval: timer.interval
property int initialTimeInterval: 80
property int initialSnakeSize: 5
property string state: ""
...
KeyboardDevice {
id: keyboardController
}
QQ2.Component.onCompleted: {
console.log("Start game...");
timer.start()
}
QQ2.Timer {
id: timer
interval: initialTimeInterval
repeat: true
onTriggered: {}
}
components: [frameFraph, input]
}
在这里,我们混合了 Qt Quick 元素和 Qt3D。由于可能存在名称冲突,我们必须使用别名QQ2导入模块。我们已经在第五章,掌握移动 UI中遇到了Component.onCompleted。它的任务将是启动游戏引擎并启动定义在后面的timer。
这个timer变量将每 80 毫秒(如initialTimeInterval属性中定义的)重复一次,并调用引擎的update()函数。当我们构建引擎代码时,这个函数将在本章后面被介绍。目标是尽可能接近地模拟原始的蛇游戏。整个游戏逻辑将在这个间隔更新,而不是在正常的帧刷新间隔。每次调用update()后,蛇将前进。因此,蛇的移动不会是平滑的,而是断断续续的。这显然是我们为了获得复古游戏感觉而做出的设计选择。
每当蛇吃到一个苹果时,会发生两件事:
-
计时器的
interval将由引擎(通过timerInterval属性访问)减少。 -
蛇会生长。它的初始大小由
intialSnakeSize属性定义。
减少计时器间隔会使蛇移动得更快,直到很难控制其方向。
组装你的 Qt3D 实体
我们现在将开始创建游戏的基本构建块,每个都是以Entity元素的形式:
-
Wall:这代表蛇不能去的极限 -
SnakePart:这代表蛇身体的一部分 -
Apple:这代表在随机位置生成的苹果(不可能!) -
Background:这代表蛇和苹果后面的漂亮背景
每个实体都将放置在由引擎处理的网格上,并将有一个类型标识符,以便更容易找到。为了将这些属性归一化,让我们创建一个名为GameEntity.qml的父 QML 文件:
import Qt3D.Core 2.0
Entity {
property int type: 0
property vector2d gridPosition: Qt.vector2d(0, 0)
}
这个Entity元素只定义了一个type属性和一个gridPosition属性,这些将由引擎用于在网格上布局内容。
我们将构建的第一个项目是Wall.qml文件:
import Qt3D.Core 2.0
GameEntity {
id: root
property alias position: transform.translation
Transform {
id: transform
}
components: [transform]
}
正如你所见,Wall类型没有任何视觉表示。因为我们针对的是树莓派设备,我们必须非常小心 CPU/GPU 的消耗。游戏区域将是一个网格,其中每个单元格包含我们实体中的一个实例。蛇将被Wall实例所包围。树莓派的运行速度远低于普通电脑,如果显示所有墙壁,游戏将变得难以忍受地慢。
为了解决这个问题,墙壁是看不见的。它们将被放置在可见视口之外,窗口的边缘将作为蛇的视觉极限。当然,如果你不是针对树莓派,而是你的电脑,显示墙壁并使它们看起来比什么都没有更华丽是可以的。
我们接下来要实现的下一个Entity元素是SnakePart.qml:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Extras 2.0
GameEntity {
id: root
property alias position: transform.translation
PhongMaterial {
id: material
diffuse: "green"
}
CuboidMesh {
id: mesh
}
Transform {
id: transform
}
components: [material, mesh, transform]
}
如果添加到GameArea场景中,SnakePart块将显示一个单独的绿色立方体。SnakePart块不是完整的蛇,而是其身体的一部分。记住,蛇每次吃苹果时都会增长。增长意味着将一个新的SnakePart实例添加到SnakePart列表中。
让我们继续使用Apple.qml:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Extras 2.0
GameEntity {
id: root
property alias position: transform.translation
property alias color: material.diffuse
Transform {
id: transform
scale: 0.5
}
Mesh {
id: mesh
source: "models/apple.obj"
}
DiffuseMapMaterial {
id: material
diffuse: "qrc:/models/apple-texture.png"
}
components: [material, mesh, transform]
}
这个片段首先介绍了 Qt3D 更复杂但易于使用的功能,即自定义网格及其上的纹理。Qt3D 支持 Wavefront obj格式来加载自定义网格。在这里,我们将一个自制的苹果添加到应用程序的.qrc文件中,我们只需提供这个资源的路径来加载它。
同样的原则也应用于DiffuseMapMaterial元素。我们添加了一个自定义纹理,并将其作为组件的源添加。
如你所见,Entity定义及其组件看起来非常相似。然而,我们轻松地将 Qt3D 的CuboidMesh与自定义模型交换。
我们将使用Background.qml进一步推进:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Extras 2.0
Entity {
id: root
property alias position: transform.translation
property alias scale3D: transform.scale3D
MaterialBackground {
id: material
}
CuboidMesh {
id: mesh
}
Transform {
id: transform
}
components: [material, mesh, transform]
}
Background块将在蛇和苹果后面显示。乍一看,这个实体非常像SnakePart。然而,Material不是一个 Qt3D 类。它是一个自定义定义的Material,依赖于着色器。让我们看看MaterialBackground.qml:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
Material {
id: material
effect: Effect {
techniques: [
Technique {
graphicsApiFilter {
api: GraphicsApiFilter.OpenGL
majorVersion: 3
minorVersion: 2
}
renderPasses: RenderPass {
shaderProgram: ShaderProgram {
vertexShaderCode:
loadSource("qrc:/shaders/gl3/grass.vert")
fragmentShaderCode:
loadSource("qrc:/shaders/gl3/grass.frag")
}
}
}
]
}
}
如果你不太熟悉着色器,我们可以用以下语句来概括它们:着色器是使用 C 风格语法编写的计算机程序,由 GPU 执行。你的逻辑数据将由 CPU 提供,并使 GPU 内存中的着色器可用。在这里,我们操作两种类型的着色器:
-
顶点着色器,它在你的网格源中的每个顶点上执行
-
片段着色器,它在每个像素上执行以生成最终渲染
通过在 GPU 上执行,这些着色器利用了 GPU 巨大的并行化能力(这比你的 CPU 高几个数量级)。它使得现代游戏能够拥有如此惊人的视觉效果。涵盖着色器和 OpenGL 管道超出了本书的范围(仅此主题就可以填满几个书架)。我们将限制自己向您展示如何在 Qt3D 中使用着色器。
注意
如果你想深入研究 OpenGL 或提高你的着色器技能,我们推荐 Graham Sellers、Richard S Wright Jr.和 Nicholas Haemel 合著的《OpenGL SuperBible》。
Qt3D 以非常方便的方式支持着色器。只需将你的着色器文件添加到.qrc资源文件中,并在给定Material的effect属性中加载它。
在这个片段中,我们指定这个着色器Technique只应在 OpenGL 3.2 上运行。这由graphicsApiFilter块指示。这个版本的 OpenGL 针对你的桌面机器。因为你的桌面和树莓派之间的性能差距非常明显,所以我们有能力根据平台执行不同的着色器。
因此,以下是树莓派兼容的技术:
Technique {
graphicsApiFilter {
api: GraphicsApiFilter.OpenGLES
majorVersion: 2
minorVersion: 0
}
renderPasses: RenderPass {
shaderProgram: ShaderProgram {
vertexShaderCode:
loadSource("qrc:/shaders/es2/grass.vert")
fragmentShaderCode:
loadSource("qrc:/shaders/es2/grass.frag")
}
}
}
你只需将其添加到Material的techniques属性中。请注意,目标 OpenGL 版本是 OpenGLES 2.0,它可以在你的树莓派上运行得很好,甚至可以在你的 iOS/Android 手机上运行。
最后要讨论的是如何将参数传递给着色器。以下是一个例子:
Material {
id: material
parameters: [
Parameter {
name: "score"; value: score
}
]
...
}
score变量将通过这个简单的部分在着色器中可访问。请查看该章节的源代码以查看此Material元素的完整内容。我们编写了一个着色器的乐趣,它可以在草地纹理上显示移动和发光的波浪。
游戏中唯一固定的元素是背景。我们可以直接将其添加到GameArea.qml:
Entity {
id: root
...
Background {
position: Qt.vector3d(camera.x, camera.y, 0)
scale3D: Qt.vector3d(camera.x * 2, camera.y * 2, 0)
}
components: [frameFraph, input]
}
Background元素被定位以覆盖蛇和苹果后面的整个可见区域。由于它定义在GameArea内部,它将被自动添加到实体/组件树中,并立即绘制。
准备游戏板
即使我们的游戏有 3D 的表现形式,我们也会实现像原始贪吃蛇游戏那样的 2D 游戏玩法。我们的游戏物品将在一个 2D 区域内诞生、生存和死亡。就像棋盘一样,这个板将由行和列组成。但在我们的贪吃蛇游戏中,每个方块都可以是:
-
一个苹果
-
一条蛇
-
一堵墙
-
空的
这里是引擎视角下板表示的一个例子:

这是一个 10x8 的小板;即使大小不重要,你也将能够定义一个更大的板。你的游戏,你的规则!我们的游戏区域周围有墙壁(W)。一个苹果(A)在 7x2 的位置生成。最后,我们有一条蛇(S),从 3x4 开始,到 5x5 结束。
是时候创建我们的板类了。请创建一个名为board.js的 JS 文件:
function Board(columnCount, rowCount, blockSize) {
this.columnCount = columnCount;
this.rowCount = rowCount;
this.blockSize = blockSize;
this.maxIndex = columnCount * rowCount;
this.data = new Array(this.maxIndex);
}
这个对象构造函数需要三个参数。columnCount和rowCount参数将帮助您选择板的尺寸。最后一个参数blockSize是 OpenGL 世界中板方块的尺寸。例如,我们可以将blockSize设置为 10。在这种情况下,7x2 的苹果在板上的显示将是 OpenGL 世界中的x = 70和y = 20。在本章中,我们将使用blockSize为 1,因此板坐标与 OpenGL 坐标匹配。
让我们在board.js中添加一些实用函数:
Board.prototype.init = function() {
for (var i = 0; i < this.data.length; i++) {
this.data[i] = null;
}
}
Board.prototype.index = function(column, row) {
return column + (row * this.columnCount);
}
Board.prototype.setData = function(data, column, row) {
this.data[this.index(column, row)] = data;
}
Board.prototype.at = function(column, row) {
return this.data[this.index(column, row)];
}
在 JavaScript 中定义一个类可能会让 C++开发者感到困扰。每个 JavaScript 对象都有一个原型对象,你可以向其中添加函数。我们正在使用它来向Board添加类方法。
下面是Board类每个函数目的的总结:
-
init(): 这个函数将所有数组值初始化为null值 -
index(): 这个函数从列/行坐标返回数组索引 -
setData(): 这个函数将根据列/行坐标在板上分配data值 -
at(): 这个函数从列/行坐标检索数组中的data值
请注意,在我们的情况下,一个null方块表示一个空方块。
从工厂创建实体
现在我们有一个接收项目的棋盘,我们将创建游戏项目工厂。工厂是一种设计模式,它允许我们创建一个对象,而不向调用者暴露创建逻辑。这个工厂可以被视为一个辅助类,它将处理当你想从 JavaScript 中创建一个新的游戏项目时所需的全部脏活累活。你还记得 GameEntity.qml 吗?它是 Apple.qml、Snake.qml 和 Wall.qml 的父类。工厂将能够根据给定的类型和坐标创建特定的实体。我们将使用属性类型来识别实体种类。以下是我们在蛇形游戏中使用的工厂模式架构:

我们现在可以创建一个名为 factory.js 的文件,其内容如下:
var SNAKE_TYPE = 1;
var WALL_TYPE = 2;
var APPLE_TYPE = 3;
var snakeComponent = Qt.createComponent("Snake.qml");
var wallComponent = Qt.createComponent("Wall.qml");
var appleComponent = Qt.createComponent("Apple.qml");
首先,我们定义所有游戏实体类型。在我们的例子中,我们有苹果、蛇和墙壁类型。然后,我们从 QML 文件中创建游戏项目组件。这些组件将由工厂用于动态创建新的游戏实体。
我们现在可以添加构造函数和一个 removeAllEntities() 工具函数来删除所有实例化的实体:
function GameFactory() {
this.board = null;
this.parentEntity = null;
this.entities = [];
}
GameFactory.prototype.removeAllEntities = function() {
for(var i = 0; i < this.entities.length; i++) {
this.entities[i].setParent(null);
}
这个工厂有三个成员变量:
-
对上一节中描述的游戏
board的引用 -
对
parentEntity变量的引用,即游戏区域 -
一个保存已创建项目引用的
entities数组
removeAllEntities() 函数将从其父级(即游戏区域)中删除项目,并创建一个新的空 entities 数组。这确保了旧实体被垃圾回收器删除。
让我们在工厂中添加核心函数 createGameEnity():
GameFactory.prototype.createGameEntity = function(type, column, row) {
var component;
switch(type) {
case SNAKE_TYPE:
component = snakeComponent;
break;
case WALL_TYPE:
component = wallComponent;
break;
case APPLE_TYPE:
component = appleComponent;
break;
}
var gameEntity = component.createObject(this.parentEntity);
gameEntity.setParent(this.parentEntity);
this.board.setData(gameEntity, column, row);
gameEntity.gridPosition = Qt.vector2d(column, row);
gameEntity.position.x = column * this.board.blockSize;
gameEntity.position.y = row * this.board.blockSize;
this.entities.push(gameEntity);
return gameEntity;
}
如您所见,调用者提供了一个实体 type 和棋盘坐标(column 和 row)。第一部分是一个开关,用于选择正确的 QML 组件。一旦我们有了组件,我们就可以调用 component.createObject() 来创建该组件的一个实例。这个新组件的父级将是 this.parentEntity,在我们的例子中,是 GameArea。然后,我们可以更新棋盘,更新实体位置,并将这个新实体添加到 entities 数组中。
最后一件要做的事情是更新我们的 QML 游戏实体以正确的工厂类型。请打开 Apple.qml 并按照以下方式更新文件:
import "factory.js" as Factory
GameEntity {
id: root
type: Factory.APPLE_TYPE
...
}
你现在可以更新 Snake.qml 中的 Factory.SNAKE_TYPE 类型,以及 Wall.qml 中的 Factory.WALL_TYPE 类型。
在 JavaScript 中构建蛇形引擎
是时候动手实践了。让我们看看如何使用我们的棋盘、我们的工厂和 QML 的力量在 JavaScript 中创建一个管理蛇形游戏的引擎。
请创建一个新的 engine.js 文件,包含以下代码片段:
.import "factory.js" as Factory
.import "board.js" as Board
var COLUMN_COUNT = 50;
var ROW_COUNT = 29;
var BLOCK_SIZE = 1;
var factory = new Factory.GameFactory();
var board = new Board.Board(COLUMN_COUNT, ROW_COUNT, BLOCK_SIZE);
var snake = [];
var direction;
前几行是 Qt 从另一个 JavaScript 文件中导入 JavaScript 文件的方式。然后,我们可以轻松实例化一个 factory 变量和一个 50x29 的 board 变量。snake 数组包含所有实例化的蛇形游戏项目。这个数组将有助于移动我们的蛇。最后,direction 变量是一个处理当前蛇方向的 2d 向量。
这是我们的引擎的第一个函数:
function start() {
initEngine();
createSnake();
createWalls();
spawnApple();
gameRoot.state = "PLAY";
}
这为我们总结了启动引擎时所做的操作:
-
初始化引擎。
-
创建初始蛇。
-
创建围绕游戏区域的墙壁。
-
生成第一个苹果。
-
将
GameArea状态切换到PLAY。
让我们从initEngine()函数开始:
function initEngine() {
timer.interval = initialTimeInterval;
score = 0;
factory.board = board;
factory.parentEntity = gameRoot;
factory.removeAllEntities();
board.init();
direction = Qt.vector2d(-1, 0);
}
此函数初始化并重置所有变量。第一个任务是设置GameArea计时器的初始值。每次蛇吃到一个苹果,这个间隔就会减少,从而增加游戏速度和蛇的移动速度。从逻辑上讲,我们将玩家的分数重置为0。然后我们初始化工厂,提供板和gameRoot引用。gameRoot指的是GameArea;这个实体将是工厂实例化所有项目的父项。然后,我们从工厂中移除所有现有实体,并调用板的init()函数来清除板。最后,我们为蛇设置一个默认方向。向量-1,0意味着蛇将开始向左移动。如果你想让蛇向上移动,可以将向量设置为0, 1。
下一个函数是创建蛇:
function createSnake() {
snake = [];
var initialPosition = Qt.vector2d(25, 12);
for (var i = 0; i < initialSnakeSize; i++) {
snake.push(factory.createGameEntity(Factory.SNAKE_TYPE,
initialPosition.x + i,
initialPosition.y));
}
}
这里没有太大的问题,我们重置并初始化snake数组。第一个蛇项将在 25x12 处创建。然后我们继续创建我们需要的蛇项以生成具有正确初始大小的蛇。请注意,其他蛇项将创建在第一个项的右侧(26x12,27x12,等等)。你可以看到调用我们的工厂并请求新的蛇项实例是多么容易。
让我们在engine.js中添加createWalls()函数:
function createWalls() {
for (var x = 0; x < board.columnCount; x++) {
factory.createGameEntity(Factory.WALL_TYPE, x, 0);
factory.createGameEntity(Factory.WALL_TYPE, x, board.rowCount - 1);
}
for (var y = 1; y < board.rowCount - 1; y++) {
factory.createGameEntity(Factory.WALL_TYPE, 0, y);
factory.createGameEntity(Factory.WALL_TYPE, board.columnCount - 1, y);
}
}
第一个循环创建顶部和底部墙壁。第二个循环创建左侧和右侧墙壁。第二个循环的索引与第一个不同,以避免两次创建角落。
现在我们来看看如何在engine.js中实现spawnApple()函数:
function spawnApple() {
var isFound = false;
var position;
while (!isFound) {
position = Qt.vector2d(Math.floor(Math.random()
* board.columnCount),
Math.floor(Math.random()
* board.rowCount));
if (board.at(position.x, position.y) == null) {
isFound = true;
}
}
factory.createGameEntity(Factory.APPLE_TYPE, position.x, position.y);
if (timerInterval > 10) {
timerInterval -= 2;
}
}
第一步是找到一个空方格。while 循环将生成一个随机的板位置并检查一个方格是否为空。一旦找到一个空方格,我们就请求工厂在这个位置创建一个苹果实体。最后,我们减少GameArea的timerInterval值以加快游戏速度。
我们现在将在engine.js中添加一些与蛇位置相关的实用函数:
function setPosition(item, column, row) {
board.setData(item, column, row);
item.gridPosition = Qt.vector2d(column, row);
item.position.x = column * board.blockSize;
item.position.y = row * board.blockSize;
}
function moveSnake(column, row) {
var last = snake.pop();
board.setData(null, last.gridPosition.x, last.gridPosition.y);
setPosition(last, column, row);
snake.unshift(last);
}
setPosition()函数处理当我们想要移动一个游戏项时所需的所有必要任务。我们首先将游戏项分配到正确的板方格,然后更新gridPosition属性(从GameEntity),但也更新 OpenGL 的position.x和position.y。
第二个函数moveSnake()将蛇移动到相邻的方格。让我们分析这个函数执行的所有步骤:
-
snake是我们包含所有蛇项的全局数组。pop()方法移除并返回我们存储在last变量中的最后一个元素。 -
last变量包含蛇尾的网格位置。我们将此板方格设置为null;对我们来说这意味着一个空方格。 -
last变量现在放置在调用者请求的相邻方块上。 -
last变量最终插入到snake数组的开始处。
下一个图例展示了蛇在左侧移动时 moveSnake() 的过程。我们还用字母命名蛇项,以可视化尾巴变成头部,模拟移动的蛇:

现在我们能够移动我们的蛇,我们必须处理按键事件以正确地移动蛇。请将此新函数添加到 engine.js:
function handleKeyEvent(event) {
switch(event.key) {
// restart game
case Qt.Key_R:
start();
break;
// direction UP
case Qt.Key_I:
if (direction != Qt.vector2d(0, -1)) {
direction = Qt.vector2d(0, 1);
}
break;
// direction RIGHT
case Qt.Key_L:
if (direction != Qt.vector2d(-1, 0)) {
direction = Qt.vector2d(1, 0);
}
break;
// direction DOWN
case Qt.Key_K:
if (direction != Qt.vector2d(0, 1)) {
direction = Qt.vector2d(0, -1);
}
break;
// direction LEFT
case Qt.Key_J:
if (direction != Qt.vector2d(1, 0)) {
direction = Qt.vector2d(-1, 0);
}
break;
}
}
在这个游戏中,我们使用 I-J-K-L 键来更新蛇的方向向量。就像原始的蛇形游戏一样,你不能改变方向。会进行一次检查以避免这种行为。请注意,按下 R 键将调用 start() 并重新开始游戏。我们很快就会看到如何将此功能与 QML 键盘控制器绑定。
到这里,我们来到了最后一个(但不是最不重要的)函数,即 engine.js 的 update() 函数:
function update() {
if (gameRoot.state == "GAMEOVER") {
return;
}
var headPosition = snake[0].gridPosition;
var newPosition = Qt.vector2d(headPosition.x + direction.x,
headPosition.y + direction.y);
var itemOnNewPosition = board.at(newPosition.x,
newPosition.y);
...
}
此函数将由 QML 以固定间隔调用。正如你所见,如果 gameRoot(即 GameArea)的 state 变量等于 GAMEOVER,此函数将不执行任何操作并立即返回。然后,执行三个重要步骤:
-
在
headPosition中检索蛇头的网格位置。 -
使用
newPosition中的direction向量确定蛇的移动路径。 -
将项目放置在蛇将要移动的位置
itemOnNewPosition。
update() 函数的第二部分如下所示:
function update() {
...
if(itemOnNewPosition == null) {
moveSnake(newPosition.x, newPosition.y);
return;
}
switch(itemOnNewPosition.type) {
case Factory.SNAKE_TYPE:
gameRoot.state = "GAMEOVER";
break;
case Factory.WALL_TYPE:
gameRoot.state = "GAMEOVER";
break;
case Factory.APPLE_TYPE:
itemOnNewPosition.setParent(null);
board.setData(null, newPosition.x, newPosition.y);
snake.unshift(factory.createGameEntity(
Factory.SNAKE_TYPE,
newPosition.x,
newPosition.y));
spawnApple();
score++;
break;
}
}
如果蛇要移动到一个空方块(itemOnNewPosition 是 null),这是可以的,我们只需将蛇移动到 newPosition。
如果方块不为空,我们必须根据项目类型应用正确的规则。如果下一个方块是蛇的一部分或墙壁,我们将状态更新为 GAMEOVER。另一方面,如果下一个方块是苹果,将执行以下几个步骤:
-
从
GameArea中移除苹果项,将其父级设置为null。 -
从板上移除苹果,将方块设置为
null。 -
让蛇变长,在
snake数组的开始处创建一个蛇的部分。 -
在一个随机的空方块中生成一个新的苹果。
-
增加分数。
我们的蛇形引擎现在已经完成。最后一步是从 QML 调用一些引擎函数。请更新 GameArea.qml:
...
import "engine.js" as Engine
Entity {
...
QQ2.Component.onCompleted: {
console.log("Start game...");
Engine.start();
timer.start()
}
QQ2.Timer {
id: timer
interval: initialTimeInterval
repeat: true
onTriggered: Engine.update()
}
KeyboardInput {
id: input
controller: keyboardController
focus: true
onPressed: Engine.handleKeyEvent(event)
}
...
}
你现在可以玩游戏了。如果你吃了一个苹果,蛇会变长,你得到一分。当你撞到自己或墙壁时,游戏状态切换到 GAMEOVER,游戏停止。最后,如果你按下 R 键,游戏将重新开始。游戏看起来就像下面的截图所示,在 Raspberry Pi 上:

使用 QML 状态改变 HUD
我们现在将创建一个 "游戏结束" HUD,在游戏失败时显示。创建一个新的文件 GameOverItem.qml:
Item {
id: root
anchors.fill: parent
onVisibleChanged: {
scoreLabel.text = "Your score: " + score
}
Rectangle {
anchors.fill: parent
color: "black"
opacity: 0.75
}
Label {
id: gameOverLabel
anchors.centerIn: parent
color: "white"
font.pointSize: 50
text: "Game Over"
}
Label {
id: scoreLabel
width: parent.width
anchors.top: gameOverLabel.bottom
horizontalAlignment: "AlignHCenter"
color: "white"
font.pointSize: 20
}
Label {
width: parent.width
anchors.bottom: parent.bottom
anchors.bottomMargin: 50
horizontalAlignment: "AlignHCenter"
color: "white"
font.pointSize: 30
text:"Press R to restart the game"
}
}
让我们检查这个游戏结束屏幕的元素:
-
一个黑色矩形,填充整个屏幕,具有 75% 的不透明度值。因此,游戏区域在“游戏结束”屏幕后面仍然可见,占 25%。
-
一个显示文本“游戏结束”的
gameOverLabel标签。这是一个传统的视频游戏消息,但你可以用“失败!”或“太糟糕了!”等文本编辑此标签。 -
一个动态的
scoreLabel标签,将显示最终得分。 -
一个标签,解释玩家如何重新开始游戏。
请注意,当根项目的可见性改变时,scoreLabel 文本会更新为 main.qml 中的当前 score 变量。
Qt Quick 提供了一个与 UI 状态相关的有趣功能。你可以为项目定义几个状态并描述每个状态的行为。我们现在将使用这个功能以及我们的 GameOverItem 在一个名为 OverlayItem.qml 的新文件中:
Item {
id: root
states: [
State {
name: "PLAY"
PropertyChanges { target: root; visible: false }
},
State {
name: "GAMEOVER"
PropertyChanges { target: root; visible: true }
PropertyChanges { target: gameOver; visible: true }
}
]
GameOverItem {
id: gameOver
}
}
你可以看到,states 元素是一个 Item 属性。默认情况下,states 元素包含一个空字符串状态。在这里,我们定义了两个名为 PLAY 和 GAMEOVER 的 State 项目。我们使用与 engine.js 中相同的命名约定。之后,我们可以将属性值绑定到状态。在我们的案例中,当状态是 GAMEOVER 时,我们将此 OverlayItem 和其 GameOverItem 的可见性设置为 true。否则,对于状态 PLAY,我们将其隐藏。
覆盖层 HUD 和其“游戏结束”屏幕已准备好使用。请更新你的 mail.qml,如下所示:
Item {
id: mainView
property int score: 0
readonly property alias window: mainView
...
OverlayItem {
id: overlayItem
anchors.fill: mainView
visible: false
Connections {
target: gameArea
onStateChanged: {
overlayItem.state = gameArea.state;
}
}
}
}
我们的 OverlayItem 元素适合屏幕,默认情况下不可见。就像 C++ Qt Widgets 信号/槽连接一样,你可以执行 QML 连接。目标属性包含将发送信号的项。然后你可以使用 QML 槽语法:
on<PropertyName>Changed
在我们的案例中,目标是 gameArea。这个项目包含 state 变量,因此我们可以使用 onStateChanged 通知状态变量更新。然后,我们切换 OverlayItem 的状态。这个赋值将触发 OverlayItem 元素中定义的所有 ProperyChanged,并显示或隐藏我们的 GameOverItem。
你现在可以输掉游戏并享受你的“游戏结束”覆盖层:

性能分析你的 QML 应用程序
Qt Creator 提供了一个 QML 分析器,可以在应用程序运行时收集有用的数据。你可以在桌面和远程目标(如我们的树莓派)上使用它。让我们检查你的调试构建配置是否允许 QML 调试和性能分析。点击 项目 | Rpi 2 | 构建。然后你可以点击 构建步骤 中的 qmake 的 详细信息。你也应该检查你的桌面工具包:

默认情况下,只有当你停止性能分析时,数据才会从目标发送到主机。你可以定期刷新数据:工具 | 选项 | 分析器 | QML 性能分析器。
请记住,在性能分析时刷新数据可以在目标设备上释放内存,但会花费时间。因此,它可能会影响你的性能分析结果和分析。
当我们使用 Qt Creator 套件时,我们可以以相同的方式在桌面或远程设备上启动 QML 分析器。切换到套件并点击分析 | QML 分析器以启动 QML 分析。如果你正在分析在桌面运行的程序,Qt Creator 将使用如下参数启动你的软件:
-qmljsdebugger=file:/tmp/QtCreator.OU7985
如果你正在分析远程设备(如树莓派)上的应用程序,Qt Creator 使用 TCP 套接字检索数据,添加如下参数:
-qmljsdebugger=port:10000
对于两个目标,QML 分析器之后将尝试连接到你的应用程序。在远程设备上启动 QML 分析器的另一种方法是使用-qmljsdebugger参数启动应用程序,例如:
./ch06-snake -qmljsdebugger=port:3768
然后,你可以点击分析 | QML 分析器(外部)。选择你的远程套件(例如 Rpi 2),将端口设置为3768,然后点击确定。
太好了,QML 分析器已启动,出现了一个新的工具栏。你可以玩几秒钟的游戏,然后从 QML 分析器工具栏中点击停止按钮。然后 QML 分析器处理数据并显示类似以下内容:

让我们从左到右开始分析顶部按钮:
-
启动 QML 分析器。
-
停止应用程序和 QML 分析器。
-
启用/禁用分析。你也可以选择一个事件来捕获。
-
丢弃数据以清理你的分析会话。
-
搜索时间线事件备注。
-
隐藏或显示事件类别。
-
已用时间表示会话持续时间。
-
视图隐藏或显示时间线、统计信息和火焰图选项卡。
为了学习如何使用 QML 分析器,我们将用一个实际案例。在树莓派上重启游戏有点慢。让我们用 QML 分析器找出需要几秒钟才能重启游戏的原因!
请遵循此操作模式以从 QML 分析器收集数据:
-
选择树莓派套件。
-
启动 QML 分析器。
-
等待蛇撞到墙壁。
-
按下R键重启游戏。
-
等待游戏重启和蛇再次移动。
-
停止 QML 分析器。
让我们使用时间线选项卡开始我们的调查。此视图显示事件的按时间顺序视图,按事件类型分组。JavaScript 行分解你的代码并显示有用的信息。你可以点击一个项目以获取一些详细信息。在时间线中识别你重启游戏的时间点。JavaScript 行可以读作从上到下的调用堆栈:

在我们这个案例中,我们在应用程序启动后大约 3.5 秒重启了游戏。以下是 QML 分析器提供的带有持续时间的堆栈。以下是 QML 分析器提供的带有持续时间的堆栈。让我们追踪当我们按下 R 键重启游戏时调用的所有函数:
-
来自
GameArea.qml的onPressed()函数 -
来自
engine.js的handleKetEvent()函数 -
在 4.2 秒的
engine.js中的start()函数-
initEngine()在 80 毫秒 -
createSnake()在 120 毫秒 -
createWalls()在 4.025 秒!
-
到这里了,createWalls() 在我们重启游戏时在 Raspberry Pi 上需要大约 4 秒钟。
让我们切换到 统计 视图:

统计视图显示有关事件调用次数的数字。事件可以是 QML 绑定、创建、信号触发或 JavaScript 函数。底部部分显示 QML 调用者和被调用者。
调用者是绑定中变化的来源。例如,JS 函数 createWalls() 是一个调用者。
被调用者是绑定影响的受影响项。例如,QML 项 Wall.qml 是一个被调用者。
再次,createWalls() 请求许多工厂项创建似乎负责 Raspberry Pi 上游戏缓慢重启。
看看 QML 分析器的最后一个视图,火焰图:

火焰图 视图是您在运行游戏时 QML 和 JavaScript 代码的紧凑总结。您可以看到调用次数和相对于总时间的持续时间。像 时间轴 视图一样,您可以看到调用栈,但自下而上!
再次,分析器表明 createWalls() 是一个耗时的函数。在一个持续 10 秒钟的剖析会话中,有 77% 的时间花在 engine.createWalls() 上。
现在,您将能够分析一个 QML 应用程序。您可以尝试编辑代码以加快重启速度。以下是一些提示:
-
在应用程序启动时仅创建墙壁一次;不要在每次重启时删除和重新创建它们。
-
在视频游戏中实现一个常见的模式:预加载项的对象池。当需要时请求墙壁,当不再使用时将其返回到池中。
摘要
在本章中,我们发现了如何使用 Qt3D 模块。您还学习了如何配置 Qt Creator 以创建用于嵌入式 Linux 设备的新套件。您的 Raspberry Pi 现在可以运行您的 Qt 应用程序了。我们使用 QML 视图和 JavaScript 引擎创建了一个蛇形游戏。我们还介绍了工厂设计模式,以便从引擎中轻松创建新的游戏项。最后,您现在可以使用强大的 QML 分析器调查 QML 软件的糟糕行为。
即使 Qt 是一个强大的框架,有时您也需要使用第三方库。在下一章中,我们将看到如何将 OpenCV 库集成到您的 Qt 应用程序中。
第七章. 无烦恼的第三方库
在前面的章节中,我们使用了我们自己的库或 Qt 提供的库。在本章中,我们将学习如何将第三方库 OpenCV 集成到 Qt 项目中。这个库将为你提供一个令人印象深刻的图像处理工具箱。对于每个平台,你将学习使用不同的特定编译器链接配置。
Qt Designer 是一个功能强大的 WYSIWYG 编辑器。这就是为什么本章还将教你如何构建一个可以从 小部件框 拖放到 表单编辑器 的 Qt Designer 插件,并直接从 Qt Creator 进行配置。
在示例项目中,用户可以加载一张图片,从缩略图预览中选择一个过滤器,并保存结果。这个应用程序将依赖于 OpenCV 函数进行图像处理。
本章将涵盖以下主题:
-
准备一个跨平台项目以托管第三方库
-
链接第三方库
-
使用 Qt Designer 插件构建自定义
QWidget类 -
OpenCV API 如何与 Qt 一起工作
-
创建一个依赖于自定义
QWidget类的 Qt 应用程序
创建您的 Qt Designer 插件
在 第四章,征服桌面 UI 中,我们使用提升技术,在 Qt Designer 中创建了一个自定义 Qt 小部件。现在是时候学习如何通过为 Qt Designer 构建插件来创建自定义 Qt 小部件了。你的小部件将与其他常规 Qt 小部件一起,在 设计模式 中的 小部件框 中可用。对于这个项目示例,我们将创建一个 FilterWidget 类,该类处理输入图像以应用过滤器。小部件还将显示过滤器名称和过滤后图片的动态缩略图。
该项目由两个子项目组成:
-
filter-plugin-designer: 这是一个包含FilterWidget类和图像处理代码的 Qt Designer 插件。这个插件是一个动态库,将被 Qt Creator 使用,以便在 表单编辑器 中提供我们的新FilterWidget。 -
image-filter: 这是一个使用多个FilterWidget的 Qt 小部件应用程序。用户可以从他们的硬盘上打开一个图像,选择一个过滤器(灰度、模糊等),并保存过滤后的图像。
我们的 filter-plugin-designer 将使用第三方库 OpenCV(开源计算机视觉)。这是一个强大的、跨平台的开源库,用于操作图像。以下是一个概述图:

你可以将插件视为一种模块,它可以轻松地添加到现有软件中。插件必须遵守特定的接口,才能被应用程序自动调用。在我们的案例中,Qt Designer 是加载 Qt 插件的程序。因此,创建插件允许我们增强应用程序,而无需修改 Qt Designer 源代码并重新编译它。插件通常是一个动态库(.dll/.so),因此它将在运行时由应用程序加载。
现在你已经对 Qt Designer 插件有了清晰的认识,让我们来构建一个!首先,创建一个名为ch07-image-filter的Subdirs项目。然后,你可以添加一个子项目,filter-plugin-designer。你可以使用空 qmake 项目模板,因为我们从这个项目开始。以下是filter-plugin-designer.pro文件:
QT += widgets uiplugin
CONFIG += plugin
CONFIG += c++14
TEMPLATE = lib
DEFINES += FILTERPLUGINDESIGNER_LIBRARY
TARGET = $$qtLibraryTarget($$TARGET)
INSTALLS += target
请注意uiplugin和plugin关键字对于QT和CONFIG。它们是创建 Qt Designer 插件所必需的。我们将TEMPLATE关键字设置为lib,因为我们正在创建一个动态库。定义FILTERPLUGINDESIGNER_LIBRARY将被库的导入/导出机制使用。我们已经在第三章中介绍了这个主题,划分你的项目和统治你的代码。默认情况下,我们的TARGET是filter-plugin-designer;$$qtLibraryTarget()函数将根据你的平台更新它。例如,Windows 上会附加后缀“d”(代表调试)。最后,我们将target添加到INSTALLS中。目前,这一行没有任何作用,但我们将很快为每个平台指定一个目标路径;这样,执行make install命令将把我们的目标库文件(.dll/.so)复制到正确的文件夹。要自动在每个编译时执行此任务,你可以添加一个新的构建步骤。
部署路径已配置,但不会自动执行。打开项目选项卡,然后执行以下操作:
-
打开构建设置 | 构建步骤。
-
点击添加构建步骤 | Make。
-
在构建参数字段中,输入
install。
你应该得到类似这样的结果:

每次构建项目时,都会调用make install命令,并在 Qt Creator 中部署库。
配置 Windows 项目
在 Windows 上准备此项目之前,让我们谈谈在 Windows 主机上开发 Qt 应用程序时可供选择的内容。官方 Qt 网站提供了多个二进制包。我们主要关注以下内容:
-
Windows 32 位 Qt(MinGW)
-
Windows 32 位 Qt(VS 2013)
你可能已经在使用这些版本中的某一个。第一个版本包含 MinGW GCC 编译器和 Qt 框架。第二个版本只提供 Qt 框架,并依赖于将随 Visual Studio 一起安装的 Microsoft Visual C++编译器。
当你想为 Windows 创建一个通用的 Qt 应用程序时,两个版本都很好。然而,对于本章,我们希望将我们的filter-plugin-designer项目与 OpenCV 库链接起来。Qt Designer 还必须能够动态加载filter-plugin-designer,因此我们必须在所有阶段使用一致的编译器版本。
请注意,Windows 上的 Qt Creator 始终基于 MSVC,即使在 MinGW 二进制包中也是如此!因此,如果你使用 MinGW 编译器创建 Qt Designer 插件,你的 Qt Creator 将无法加载它。Windows 版本的 OpenCV 仅提供 MSVC 库,编译为 MSVC11(即 VS 2012)和 MSVC12(VS 2013)。
这里是构建我们的项目示例在 Windows 中的不同解决方案的总结:

请记住,对于像 Qt Creator 和 OpenCV 这样的开源软件,你总是可以尝试使用不同的编译器从源代码编译它们。因此,如果你绝对想使用 MinGW 编译器,你必须从源代码重新编译 OpenCV 和 Qt Creator。否则,我们建议你使用稍后解释的 Qt for Windows 32-bit (VS 2013)。以下是准备你的开发环境的步骤:
-
下载并安装 Visual Studio Community Edition。
-
下载并安装 Qt for Windows 32-bit (VS 2013)。
-
下载并解压 Windows 版本的 OpenCV(例如:
C:\lib\opencv)。 -
创建一个新的
OPENCV_HOME环境变量:C:\lib\opencv\build\x86\vc12。 -
将系统环境变量
Path追加为C:\lib\opencv\build\x86\vc12\bin。
OPENCV_HOME目录将在我们的.pro文件中使用。我们还向Path目录添加了一个 OpenCV 库文件夹,以便在运行时轻松解决依赖关系。
你现在可以将以下片段添加到filter-plugin-designer.pro文件中:
windows {
target.path = $$(QTDIR)/../../Tools/QtCreator/bin/plugins/designer
debug:target_lib.files = $$OUT_PWD/debug/$${TARGET}.lib
release:target_lib.files = $$OUT_PWD/release/$${TARGET}.lib
target_lib.path = $$(QTDIR)/../../Tools/QtCreator/bin/plugins/designer
INSTALLS += target_lib
INCLUDEPATH += $$(OPENCV_HOME)/../../include
LIBS += -L$$(OPENCV_HOME)/lib
-lopencv_core2413
-lopencv_imgproc2413
}
target路径设置为 Qt Creator 插件文件夹。我们还创建了一个target_lib库来复制由 MSVC 生成的.lib文件,当我们创建动态库(.dll)时。我们将 OpenCV 头文件文件夹添加到INCLUDEPATH,以便在代码中轻松包含它们。最后,我们更新LIBS变量,以便将我们的插件与 OpenCV 库(core和imgproc)链接起来,这些库来自 OpenCV 的lib文件夹。
请注意,独立的 Qt Designer 应用程序和 Qt Creator 是不同的软件。这两个程序使用不同的插件路径。在我们的案例中,我们只使用了 Qt Creator 中的表单编辑器,因此我们针对的是 Qt Creator 插件路径。
正如我们将target和target_lib追加到INSTALLS一样,在make install命令中,.dll和.lib文件都将复制到 Qt Creator 插件路径。Qt Creator 仅需要.dll文件在运行时加载插件。.lib文件仅用于在构建我们的image-filter应用程序时解决与filter-plugin-designer的链接。为了简单起见,我们使用相同的目录。
为 Linux 配置项目
OpenCV 二进制文件当然可以在官方软件仓库中找到。根据您的发行版和包管理器,您可以使用以下命令之一进行安装:
apt-get install libopencv
yum install opencv
当 OpenCV 在您的 Linux 上安装后,您可以将以下片段添加到 filter-plugin-designer.pro 文件中:
linux {
target.path = $$(QTDIR)/../../Tools/QtCreator/lib/Qt/plugins/designer/
CONFIG += link_pkgconfig
PKGCONFIG += opencv
}
这次我们不使用 LIBS 变量,而是使用 PKGCONFIG,它依赖于 pkg-config。这是一个辅助工具,它将正确的选项插入到编译命令行中。在我们的情况下,我们将请求 pkg-config 将我们的项目与 OpenCV 链接。
注意
您可以使用 pkg-config --list-all 命令列出 pkg-config 管理的所有库。
配置 Mac 的项目
在 Mac OS 上使项目工作的第一步是安装 OpenCV。幸运的是,使用 brew 命令非常简单。如果您在 Mac OS 上开发并且尚未使用它,您应该立即下载它。简而言之,brew 是一个替代包管理器,它为您提供了访问许多包(针对开发人员和非开发者)的权限,这些包在 Mac App Store 上不可用。
注意
您可以从 brew.sh/ 下载并安装 brew。
在终端中,只需输入以下命令:
brew install opencv
这将在您的机器上下载、编译和安装 OpenCV。在撰写本文时,brew 上可用的最新 OpenCV 版本是 2.4.13。完成此操作后,打开 filter-plugin-designer.pro 并添加以下块:
macx {
target.path = "$$(QTDIR)/../../QtCreator.app/Contents/PlugIns/designer/"
target_lib.files = $$OUT_PWD/lib$${TARGET}.dylib
target_lib.path = "$$(QTDIR)/../../QtCreator.app/Contents/PlugIns/designer/"
INSTALLS += target_lib
INCLUDEPATH += /usr/local/Cellar/opencv/2.4.13/include/
LIBS += -L/usr/local/lib \
-lopencv_core \
-lopencv_imgproc
}
我们添加 OpenCV 头文件,并使用 INCLUDEPATH 和 LIBS 变量链接路径。target 定义和 INSTALLS 用于自动将输出共享对象部署到 Qt Creator 应用程序插件目录。
我们最后要做的就是添加一个环境变量,让 Qt Creator 知道它将在哪里找到将链接到最终应用程序的库。在 项目 选项卡中,按照以下步骤操作:
-
在 构建环境 中打开 详细信息 窗口。
-
点击 添加 按钮。
-
在
<VARIABLE>字段中输入DYLD_LIBRARY_PATH。 -
在
<VALUE>中输入构建目录的路径(您可以从 通用 | 构建目录 部分复制并粘贴)。
实现您的 OpenCV 过滤器
现在您的开发环境已经准备好了,我们可以开始有趣的部分了!我们将使用 OpenCV 实现三个过滤器:
-
FilterOriginal:这个过滤器什么都不做,返回相同的图片(懒惰!) -
FilterGrayscale:这个过滤器将图片从彩色转换为灰度 -
FilterBlur:这个过滤器使图片变得平滑
所有这些过滤器的父类是 Filter。以下是这个抽象类:
//Filter.h
class Filter
{
public:
Filter();
virtual ~Filter();
virtualQImage process(constQImage& image) = 0;
};
//Filter.cpp
Filter::Filter() {}
Filter::~Filter() {}
如您所见,process() 是一个纯抽象方法。所有过滤器都将通过这个函数实现特定的行为。让我们从简单的 FilterOriginal 类开始。以下是 FilterOriginal.h:
class FilterOriginal : public Filter
{
public:
FilterOriginal();
~FilterOriginal();
QImageprocess(constQImage& image) override;
};
这个类继承自 Filter 并重写了 process() 函数。实现也非常简单。在 FilterOriginal.cpp 中填写以下内容:
FilterOriginal::FilterOriginal() :
Filter()
{
}
FilterOriginal::~FilterOriginal()
{
}
QImageFilterOriginal::process(constQImage& image)
{
return image;
}
没有进行任何修改;我们返回相同的图片。现在过滤器结构已经清晰,我们可以创建FilterGrayscale。.h/.cpp文件接近FilterOriginalFilter,所以让我们跳转到FilterGrayscale.cpp的process()函数:
QImageFilterGrayscale::process(constQImage& image)
{
// QImage => cv::mat
cv::Mattmp(image.height(),
image.width(),
CV_8UC4,
(uchar*)image.bits(),
image.bytesPerLine());
cv::MatresultMat;
cv::cvtColor(tmp, resultMat, CV_BGR2GRAY);
// cv::mat =>QImage
QImageresultImage((constuchar *) resultMat.data,
resultMat.cols,
resultMat.rows,
resultMat.step,
QImage::Format_Grayscale8);
returnresultImage.copy();
}
在 Qt 框架中,我们使用QImage类来操作图片。在 OpenCV 世界中,我们使用Mat类,因此第一步是从QImage源创建一个正确的Mat对象。OpenCV 和 Qt 都处理许多图像格式。图像格式描述了数据字节的组织,如下面的信息所示:
-
通道数:灰度图片只需要一个通道(白色强度),而彩色图片需要三个通道(红色、绿色和蓝色)。处理不透明度(alpha)像素信息甚至需要四个通道。 -
位深度:存储像素颜色的位数。 -
通道顺序:最常见的顺序是 RGB 和 BGR。Alpha 可以放在颜色信息之前或之后。
例如,OpenCV 图像格式CV_8UC4表示四个无符号 8 位通道,这是 alpha 颜色图片的完美匹配。在我们的例子中,我们使用兼容的 Qt 和 OpenCV 图像格式将我们的QImage转换为Mat。以下是一个简短的总结:

请注意,一些QImage类格式也取决于你的平台字节序。前面的表是为小端系统设计的。对于 OpenCV,顺序始终相同:BGRA。在我们的项目示例中这不是必需的,但你可以按照以下方式交换蓝色和红色通道:
// with OpenCV
cv::cvtColor(mat, mat, CV_BGR2RGB);
// with Qt
QImage swapped = image.rgbSwapped();
OpenCV 的Mat和 Qt 的QImage类默认执行浅构造/复制。这意味着实际上只复制了元数据;像素数据是共享的。要创建图片的深拷贝,你必须调用copy()函数:
// with OpenCV
mat.clone();
// with Qt
image.copy();
我们从QImage类创建了一个名为tmp的Mat类。请注意,tmp不是image的深拷贝;它们共享相同的数据指针。然后,我们可以调用 OpenCV 函数使用cv::cvtColor()将图片从彩色转换为灰度。最后,我们从灰度resultMat元素创建一个QImage类。在这种情况下,resultMat和resultImage也共享相同的数据指针。完成操作后,我们返回resultImage的深拷贝。
现在是时候实现最后一个过滤器了。以下是FilterBlur.cpp中的process()函数:
QImageFilterBlur::process(constQImage& image)
{
// QImage => cv::mat
cv::Mattmp(image.height(),
image.width(),
CV_8UC4,
(uchar*)image.bits(),
image.bytesPerLine());
int blur = 17;
cv::MatresultMat;
cv::GaussianBlur(tmp,
resultMat,
cv::Size(blur, blur),
0.0,
0.0);
// cv::mat =>QImage
QImageresultImage((constuchar *) resultMat.data,
resultMat.cols,
resultMat.rows,
resultMat.step,
QImage::Format_RGB32);
returnresultImage.copy();
}
从QImage到Mat的转换是相同的。处理不同是因为我们使用cv::GaussianBlur()OpenCV 函数来平滑图片。"blur"是高斯模糊使用的核大小。你可以增加这个值以获得更柔和的图片,但只能使用奇数和正数。最后,我们将Mat转换为QImage并返回给调用者的深拷贝。
使用 FilterWidget 设计 UI
好的。我们的过滤器类已经实现,现在我们可以创建我们的自定义小部件。这个小部件将接受输入、源图片和缩略图图片。然后缩略图立即处理以显示过滤器的预览。如果用户点击小部件,它将处理源图片并触发一个带有过滤后图片的信号。请注意,这个小部件稍后将被拖放到 Qt Creator 的 表单编辑器 中。这就是为什么我们将提供带有获取器和设置器的属性来从 Qt Creator 中选择过滤器。请使用 Qt Designer 表单类 模板创建一个名为 FilterWidget 的新小部件。FilterWidget.ui 非常简单:

titleLabel 是位于 QWidget 顶部的 QLabel。下面,thumbnailLabel 将显示过滤后的图片缩略图。让我们切换到 FilterWidget.h:
class FILTERPLUGINDESIGNERSHARED_EXPORT FilterWidget : public QWidget
{
Q_OBJECT
Q_ENUMS(FilterType)
Q_PROPERTY(QString title READ title WRITE setTitle)
Q_PROPERTY(FilterTypefilterType READ filterType WRITE setFilterType)
public:
enumFilterType { Original, Blur, Grayscale };
explicitFilterWidget(QWidget *parent = 0);
~FilterWidget();
void process();
voidsetSourcePicture(constQImage&sourcePicture);
voidupdateThumbnail(constQImage&sourceThumbnail);
QStringtitle() const;
FilterTypefilterType() const;
public slots:
voidsetTitle(constQString& tile);
voidsetFilterType(FilterTypefilterType);
signals:
voidpictureProcessed(constQImage& picture);
protected:
voidmousePressEvent(QMouseEvent*) override;
private:
Ui::FilterWidget *ui;
std::unique_ptr<Filter>mFilter;
FilterTypemFilterType;
QImagemDefaultSourcePicture;
QImagemSourcePicture;
QImagemSourceThumbnail;
QImagemFilteredPicture;
QImagemFilteredThumbnail;
};
顶部部分使用 enumFilterType 定义了所有可用的过滤器类型。我们还使用 Qtproperty 系统将小部件标题和当前过滤器类型暴露给 Qt Creator 的 属性编辑器。语法如下:
Q_PROPERTY(<type><name> READ <getter> WRITE <setter>)
请注意,暴露枚举需要使用 Q_ENUM() 宏进行注册,这样 属性编辑器 将显示一个组合框,允许您从 Qt Creator 中选择过滤器类型。
中间部分列出了所有函数、槽和信号。最值得注意的是 process() 函数,它将使用当前过滤器修改源图片。pictureProcessed() 信号将通知应用程序带有过滤后图片的信号。
底部部分列出了在这个类中使用的图片和缩略图 QImage 变量。在两种情况下,我们都处理了源图片和过滤后的图片。默认源图片是插件中嵌入的图片。这允许你在没有提供缩略图时显示默认预览。mFilter 变量是当前 Filter 类的智能指针。
让我们切换到 FilterWidget.cpp 的实现:
FilterWidget::FilterWidget(QWidget *parent) :
QWidget(parent),
ui(new Ui::FilterWidget),
mFilterType(Original),
mDefaultSourcePicture(":/lenna.jpg"),
mSourcePicture(),
mSourceThumbnail(mDefaultSourcePicture.scaled(QSize(256, 256),
Qt::KeepAspectRatio,
Qt::SmoothTransformation)),
mFilteredPicture(),
mFilteredThumbnail()
{
ui->setupUi(this);
setFilterType(Original);
}
FilterWidget::~FilterWidget()
{
deleteui;
}
这里是构造函数和析构函数。请注意,默认源图片加载了在图像处理文献中经常使用的美丽 Lenna 嵌入式图片。图片位于资源文件 filter-plugin-designer.qrc 中。mSourceThumbnail 函数初始化为 Lenna 缩放后的图片。构造函数调用 setFilterType() 函数以默认初始化 Original 过滤器。以下是核心的 process() 函数:
voidFilterWidget::process()
{
mFilteredPicture = mFilter->process(mSourcePicture);
emitpictureProcessed(mFilteredPicture);
}
process() 函数功能强大,但非常简单。我们调用当前过滤器的 process() 函数来更新从当前源图片中过滤后的图片。然后我们使用过滤后的图片触发 pictureProcessed() 信号。现在我们可以添加我们的 QImage 设置器:
voidFilterWidget::setSourcePicture(constQImage&sourcePicture)
{
mSourcePicture = sourcePicture;
}
voidFilterWidget::updateThumbnail(constQImage&sourceThumbnail)
{
mSourceThumbnail = sourceThumbnail;
mFilteredThumbnail = mFilter->process(mSourceThumbnail);
QPixmappixmap = QPixmap::fromImage(mFilteredThumbnail);
ui->thumbnailLabel->setPixmap(pixmap);
}
setSourcePicture() 函数是一个简单的设置器,由应用程序调用并传入新的源图片。updateThumbnail() 方法将过滤新的源缩略图并显示它。让我们添加由 Q_PROPERTY 使用的设置器:
voidFilterWidget::setTitle(constQString& tile)
{
ui->titleLabel->setText(tile);
}
voidFilterWidget::setFilterType(FilterWidget::FilterTypefilterType)
{
if (filterType == mFilterType&&mFilter) {
return;
}
mFilterType = filterType;
switch (filterType) {
case Original:
mFilter = make_unique<FilterOriginal>();
break;
case Blur:
mFilter = make_unique<FilterBlur>();
break;
case Grayscale:
mFilter = make_unique<FilterGrayscale>();
break;
default:
break;
}
updateThumbnail(mSourceThumbnail);
}
setTitle() 函数是一个简单的设置器,用于自定义小部件标题。让我们谈谈 setFilterType() 函数。如您所见,此函数不仅更新当前过滤器类型 mFilterType,根据类型,还会创建相应的过滤器。你还记得第三章 Dividing Your Project and Ruling Your Code 中的智能指针吗?在这里,我们使用 unique_ptr 指针为 mFilter 变量,因此我们可以使用 make_unique 而不是原始的 new。FilterWidget 类拥有 Filter 类的所有权,我们不需要担心内存管理。在 make_unique 指令之后,旧的拥有指针(如果有)将被自动删除。
最后,我们调用 updateThumbnail() 函数以显示与所选过滤器类型相对应的过滤缩略图。以下是获取器和鼠标事件处理程序:
QStringFilterWidget::title() const
{
returnui->titleLabel->text();
}
FilterWidget::FilterTypeFilterWidget::filterType() const
{
returnmFilterType;
}
voidFilterWidget::mousePressEvent(QMouseEvent*)
{
process();
}
title() 和 filterType() 函数是 Qt 属性系统使用的获取器。我们重写 mousePressEvent() 函数,以便每次用户点击该小部件时都调用我们的 process() 函数。
将您的插件暴露给 Qt Designer
FilterWidget 类已完成并准备好使用。我们现在必须将 FilterWidget 注册到 Qt Designer 插件系统中。这段粘合代码使用 QDesignerCustomWidgetInterface 的子类制作。
创建一个名为 FilterPluginDesigner 的新 C++ 类,并按如下方式更新 FilterPluginDesigner.h:
#include <QtUiPlugin/QDesignerCustomWidgetInterface>
class FilterPluginDesigner : public QObject, public QDesignerCustomWidgetInterface
{
Q_OBJECT
Q_PLUGIN_METADATA(IID
"org.masteringqt.imagefilter.FilterWidgetPluginInterface")
Q_INTERFACES(QDesignerCustomWidgetInterface)
public:
FilterPluginDesigner(QObject* parent = 0);
};
FilterPlugin 类从两个类继承:
-
QObject类,以依赖 Qt 的父级系统 -
QDesignerCustomWidgetInterface类用于正确暴露FilterWidget信息给插件系统
QDesignerCustomWidgetInterface 类引入了两个新的宏:
-
Q_PLUGIN_METADATA()宏注释了该类,以向元对象系统指示我们过滤器的唯一名称 -
Q_INTERFACES()宏告诉元对象系统当前类实现了哪个接口
Qt Designer 现在能够检测到我们的插件。我们现在必须提供有关插件本身的信息。更新 FilterPluginDesigner.h:
class FilterPluginDesigner : public QObject, public QDesignerCustomWidgetInterface
{
...
FilterPluginDesigner(QObject* parent = 0);
QStringname() const override;
QStringgroup() const override;
QStringtoolTip() const override;
QStringwhatsThis() const override;
QStringincludeFile() const override;
QIconicon() const override;
boolisContainer() const override;
QWidget* createWidget(QWidget* parent) override;
boolisInitialized() const override;
void initialize(QDesignerFormEditorInterface* core) override;
private:
boolmInitialized;
};
这比看起来要简单得多。每个函数的主体通常只有一行。以下是这些最直接函数的实现:
QStringFilterPluginDesigner::name() const
{
return "FilterWidget";
}
QStringFilterPluginDesigner::group() const
{
return "Mastering Qt5";
}
QStringFilterPluginDesigner::toolTip() const
{
return "A filtered picture";
}
QStringFilterPluginDesigner::whatsThis() const
{
return "The filter widget applies an image effect";
}
QIconFilterPluginDesigner::icon() const
{
returnQIcon(":/icon.jpg");
}
boolFilterPluginDesigner::isContainer() const
{
return false;
}
如您所见,关于这些函数没有太多可说的。大多数函数将简单地返回一个 QString 值,该值将在 Qt Designer UI 的适当位置显示。我们只关注最有趣的函数。让我们从 includeFile() 开始:
QStringFilterPluginDesigner::includeFile() const
{
return "FilterWidget.h";
}
此函数将由 uic(用户界面编译器)调用,以生成对应于 .ui 文件的头文件。继续使用 createWidget():
QWidget* FilterPluginDesigner::createWidget(QWidget* parent)
{
return new FilterWidget(parent);
}
这个函数在 Qt Designer 和 FilterWidget 之间建立桥梁。当你在 .ui 文件中添加 FilterWidget 类时,Qt Designer 将调用 createWidget() 函数来创建 FilterWidget 类的一个实例并显示其内容。它还提供了 parent 元素,FilterWidget 将附加到该元素上。
让我们以 initialize() 结束:
voidFilterPluginDesigner::initialize(QDesignerFormEditorInterface*)
{
if (mInitialized)
return;
mInitialized = true;
}
在这个函数中并没有做太多事情。然而,QDesignerFormEditorInterface* 参数值得一些解释。这个指针由 Qt Designer 提供,通过函数可以访问 Qt Designer 的几个组件:
-
actionEditor(): 这个函数是动作编辑器(设计器的底部面板) -
formWindowManager(): 这个函数是允许你创建新表单窗口的接口 -
objectInspector(): 这个函数是您布局的层次表示(设计器的右上角面板) -
propertyEditor(): 这个函数是当前选中小部件的所有可编辑属性的列表(设计器的右下角面板) -
topLevel(): 这个函数是设计器的顶级小部件
我们在 第一章 中介绍了这些面板,初识 Qt。如果您的小部件插件需要干预这些区域中的任何一个,这个函数就是自定义 Qt Designer 行为的入口点。
使用您的 Qt Designer 插件
我们的定制插件现在已经完成。因为我们添加了一个定制的 Build 命令来自动部署 filter-widget 库,所以它应该在 Qt Designer 中可见。我们指定的部署路径在 Qt Creator 目录内。Qt Creator 通过一个插件集成 Qt Designer,该插件在 Qt Creator 内部显示 UI。
当 Qt Creator 启动时,它将尝试加载其特定路径下可用的每个库。这意味着每次你修改插件时(如果你想在设计器中看到修改的结果),你都必须重新启动 Qt Creator。
要看到插件的实际效果,我们现在必须创建本章的应用程序项目。在 ch07-image-filter 项目中创建一个名为 image-filter 的 Qt Widgets Application 子项目。在向导中,让它生成表单,MainWindow.ui。
要正确使用插件,只需在 image-filter.pro 中链接 filter-plugin-designer 库,如下所示:
QT += core gui
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
TARGET = image-filter
TEMPLATE = app
INCLUDEPATH += ../filter-plugin-designer
win32 {
LIBS += -L$$(QTDIR)/../../Tools/QtCreator/bin/plugins/designer -lfilter-plugin-designer
}
macx {
LIBS += -L$$(QTDIR)/../../"QtCreator.app"/Contents/PlugIns/designer/ -lfilter-plugin-designer
}
linux {
LIBS += -L$$(QTDIR)/../../Tools/QtCreator/lib/Qt/plugins/designer/ -lfilter-plugin-designer
}
SOURCES += main.cpp\
MainWindow.cpp
HEADERS += MainWindow.h
FORMS += MainWindow.ui
要访问 filter-plugin-designer 的头文件,我们只需将其添加到 INCLUDEPATH 目录。最后,链接器被指示链接到我们在 Qt Creator 中部署的库。这确保了 Qt Designer 和我们的应用程序使用的是相同的库。
打开 MainWindow.ui 文件,并滚动到 **Widget box** 的底部。瞧,你应该能看到这个:

FilterWidget 插件出现在 Mastering Qt5 部分。它甚至显示著名的 Lenna 作为预览图标。如果你看不到 FilterWidget 插件,那么重新启动 Qt Creator 并确保插件已正确加载。为了检查这一点(在 设计 选项卡中),转到 工具 | 表单编辑器 | 关于 Qt Designer 插件。这是它应该显示的内容:

如果 FilterWidget 插件没有出现在这个列表中,你应该检查 Qt Creator 插件目录的内容(路径在 image-filter.pro 中声明)。
构建 image-filter 应用程序
我们可以继续构建应用程序的 UI。想法是从文件系统中打开一个图片,并应用我们在 filter-designer-plugin 项目中开发的各个过滤器。如果你想保留结果,你可以保存生成的图片。
我们将首先设计 UI。将 MainWindow.ui 修改如下:

这里是对象检查器内容,以帮助您构建此布局:

这个 UI 有三个元素:
-
menuFile元素,它包含三个可能的操作:actionOpenPicture、actionExit和actionSaveAs。您可以在Action Editor窗口中查看这些操作的详细信息。 -
pictureLabel元素,它将在空顶部分显示加载的图片。 -
filtersLayout元素,它包含底部三个我们的FilterWidget类的实例。
当你在 filtersLayout 中添加 FilterWidget 类时,你可以看到你可以在 属性编辑器 窗口中自定义 title 和 filterType。预览将自动更新为应用了所选过滤器的默认图片。这样的动态预览简直太棒了,你可以预见你的自定义 Qt Designer 小部件可以变得相当强大。
让我们实现应用程序的逻辑。按照如下方式更新 MainWindow.h:
#include <QMainWindow>
#include <QImage>
#include <QVector>
namespaceUi {
classMainWindow;
}
classFilterWidget;
classMainWindow : public QMainWindow
{
Q_OBJECT
public:
explicitMainWindow(QWidget *parent = 0);
~MainWindow();
voidloadPicture();
private slots:
voiddisplayPicture(constQImage& picture);
private:
voidinitFilters();
voidupdatePicturePixmap();
private:
Ui::MainWindow *ui;
QImagemSourcePicture;
QImagemSourceThumbnail;
QPixmapmCurrentPixmap;
FilterWidget* mCurrentFilter;
QVector<FilterWidget*>mFilters;
};
这里有一些我们必须解释的元素:
-
mSourcePicture:这个元素是加载的图片。 -
mSourceThumbnail:这个元素是从mSourcePicture生成的缩略图。为了避免浪费 CPU 循环,mSourcePicture只会调整大小一次,而每个FilterWidget实例将处理这个缩略图而不是全分辨率图片。 -
mCurrentPixmap:这个元素是当前在pictureLabel小部件中显示的QPixmap。 -
mCurrentFilter:这个元素是当前应用的过滤器。每次用户点击不同的FilterWidget时,这个指针都会更新。 -
mFilters:这个元素是我们添加到MainWindow.ui的FilterWidget类的QVector。它只是一个辅助工具,引入了轻松应用相同指令到每个FilterWidget类。
现在让我们概述一下函数,具体细节将在查看每个函数的实现时进行说明:
-
loadPicture(): 此函数触发整个管道。当用户点击actionOpenPicture时将调用此函数。 -
initFilters(): 此函数负责初始化mFilters。 -
displayPicture(): 此函数是mCurrentWidget::pictureProcessed()调用的插槽,用于显示过滤后的图片。 -
updatePicturePixmap(): 此函数处理在pictureLabel内显示mCurrentPixmap。
让我们看看 MainWindow 类构造函数在 MainWindow.cpp 中的实现:
#include <QFileDialog>
#include <QPixmap>
#include <QDir>
#include "FilterWidget.h"
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow),
mSourcePicture(),
mSourceThumbnail(),
mCurrentPixmap(),
mCurrentFilter(nullptr),
mFilters()
{
ui->setupUi(this);
ui->pictureLabel->setMinimumSize(1, 1);
connect(ui->actionOpenPicture, &QAction::triggered,
this, &MainWindow::loadPicture);
connect(ui->actionExit, &QAction::triggered,
this, &QMainWindow::close);
initFilters();
}
我们将 actionOpenPicture::triggered() 信号连接到尚未实现的 loadPicture() 函数。actionExit 操作很简单;它只是连接到 QMainWindow::close() 插槽。最后,调用 initFilter()。让我们看看它的主体:
voidMainWindow::initFilters()
{
mFilters.push_back(ui->filterWidgetOriginal);
mFilters.push_back(ui->filterWidgetBlur);
mFilters.push_back(ui->filterWidgetGrayscale);
for (inti = 0; i<mFilters.size(); ++i) {
connect(mFilters[i], &FilterWidget::pictureProcessed,
this, &MainWindow::displayPicture);
}
mCurrentFilter = mFilters[0];
}
每个 FilterWidget 实例都被添加到 mFilters 中。然后我们继续将 pictureProcessed() 信号连接到 MainWindow::displayPicture 指令,并将 mCurrentFilter 初始化为原始过滤器。
类现在可以加载一些图片了!这是 loadPicture() 的实现:
voidMainWindow::loadPicture()
{
QString filename = QFileDialog::getOpenFileName(this,
"Open Picture",
QDir::homePath(),
tr("Images (*.png *.jpg)"));
if (filename.isEmpty()) {
return;
}
mSourcePicture = QImage(filename);
mSourceThumbnail = mSourcePicture.scaled(QSize(256, 256),
Qt::KeepAspectRatio, Qt::SmoothTransformation);
for (inti = 0; i<mFilters.size(); ++i) {
mFilters[i]->setSourcePicture(mSourcePicture);
mFilters[i]->updateThumbnail(mSourceThumbnail);
}
mCurrentFilter->process();
}
使用 QFileDialog 加载 mSourcePicture 方法,并从该输入生成 mSourceThumbnail。每个 FilterWidget 类都使用这些新数据更新,并通过调用其 process() 函数触发 mCurrentFilter 元素。
当 FilterWidget::process() 完成时,它会发出 pictureProcessed() 信号,该信号连接到我们的 displayPicture() 插槽。让我们切换到这个函数:
voidMainWindow::displayPicture(constQImage& picture)
{
mCurrentPixmap = QPixmap::fromImage(picture);
updatePicturePixmap();
}
这里没有什么特别之处:mCurrentPixmap 从给定的图片更新,而 updatePicturePixmap() 函数负责更新 pictureLabel 元素。以下是 updatePicturePixmap() 的实现:
voidMainWindow::updatePicturePixmap()
{
if (mCurrentPixmap.isNull()) {
return;
}
ui->pictureLabel->setPixmap(
mCurrentPixmap.scaled(ui->pictureLabel->size(),
Qt::KeepAspectRatio,
Qt::SmoothTransformation));
}
此函数简单地创建了一个缩放版本的 mCurrentPixmap,使其适合于 pictureLabel 内。
整个图片加载/过滤处理已完成。如果您运行应用程序,您应该能够加载和修改您的图片。然而,如果您调整窗口大小,您会看到 pictureLabel 元素缩放得不是很好。
为了解决这个问题,每次窗口调整大小时,我们必须重新生成 mCurrentPixmap 的缩放版本。更新 MainWindow 如下:
// In MainWindow.h
classMainWindow : public QMainWindow
{
...
voidloadPicture();
protected:
voidresizeEvent(QResizeEvent* event) override;
...
};
// In MainWindow.cpp
voidMainWindow::resizeEvent(QResizeEvent* /*event*/)
{
updatePicturePixmap();
}
在这里,将 mCurrentPixmap 和 pictureLabel 元素的 pixmap 分开是有意义的。因为我们总是从全分辨率的 mCurrentPixmap 生成缩放版本,所以我们确信生成的 pixmap 会看起来很好。
图像过滤器应用程序如果没有保存过滤后的图片的能力将是不完整的。这不会花费太多精力。以下是 MainWindow.h 的更新版本:
classMainWindow : public QMainWindow
{
...
private slots:
voiddisplayPicture(constQImage& picture);
voidsaveAsPicture();
...
private:
Ui::MainWindow *ui;
QImagemSourcePicture;
QImagemSourceThumbnail;
QImage&mFilteredPicture;
...
};
在这里,我们简单地添加了一个 saveAsPicture() 函数,该函数将 mFilteredPicture 元素保存到文件中。MainWindow.cpp 中的实现应该不会让您感到惊讶:
// In MainWindow.cpp
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow),
mSourcePicture(),
mSourceThumbnail(),
mFilteredPicture(mSourcePicture),
...
{
ui->setupUi(this);
ui->actionSaveAs->setEnabled(false);
ui->pictureLabel->setMinimumSize(1, 1);
connect(ui->actionOpenPicture, &QAction::triggered,
this, &MainWindow::loadPicture);
connect(ui->actionSaveAs, &QAction::triggered,
this, &MainWindow::saveAsPicture);
...
}
voidMainWindow::loadPicture()
{
...
if (filename.isEmpty()) {
return;
}
ui->actionSaveAs->setEnabled(true);
...
}
voidMainWindow::displayPicture(constQImage& picture)
{
mFilteredPicture = picture;
mCurrentPixmap = QPixmap::fromImage(picture);
updatePicturePixmap();
}
voidMainWindow::saveAsPicture()
{
QString filename = QFileDialog::getSaveFileName(this,
"Save Picture",
QDir::homePath(),
tr("Images (*.png *.jpg)"));
if (filename.isEmpty()) {
return;
}
mFilteredPicture.save(filename);
}
代码片段虽然长,但并不复杂。actionSaveAs 函数仅在图片被加载时启用。当图片被处理完毕后,mFilteredPicture 会更新为给定的图片。因为它是一个引用,存储这个过滤后的图片几乎不花费任何代价。
最后,saveAsPicture() 函数会询问用户路径,并使用 QImage API 保存图片,该 API 会尝试根据文件扩展名推断图片类型。
摘要
在本章中,你学习了如何将第三方库与每个桌面操作系统(Windows、Linux 和 Mac)集成。我们选择了 OpenCV 库,它已被包含在一个自定义的 Qt Designer 插件中,并且可以在 Qt Designer 中显示你的图像处理结果的实时预览。我们创建了一个图像过滤应用程序,它可以打开图片,对它们应用过滤器,并将结果保存在你的机器上。
我们仔细研究了如何集成第三方库以及如何创建一个 Qt Designer 插件。在下一章中,我们将通过使 image-filter 应用程序准备好加载由第三方开发者实现的过滤器插件来推进事情。为了使事情更加酷炫,我们将介绍 Qt 动画框架,使 image-filter 更加引人注目。
第八章. 动画 - 活起来,活起来!
在上一章中,你学习了如何创建自定义 Qt Designer 插件。本章将进一步深入,教你如何创建可分发的软件开发工具包(SDK)供第三方开发者使用,Qt 插件系统的工作原理,以及如何使用华丽的动画使你的应用程序更具吸引力。
示例项目将是第七章中的项目重新实现,无痛第三方库。你将构建相同的图像处理应用程序,但具有从插件导入滤镜的能力。
本章将教你如何完成以下任务:
-
使用 Qt 插件系统创建 SDK
-
使用 SDK 实现自定义插件
-
使用
.pri文件分解构建任务 -
在最终应用程序中动态加载插件
-
理解 Qt 动画框架
-
使用简单、顺序和并行动画
-
使用
QPropertyAnimation和QGraphics效果应用自定义效果
准备 SDK
在深入代码之前,我们必须花点时间来反思我们将如何构建它。本章有两个目标:
-
深入探讨 Qt 插件系统
-
研究和集成 Qt 动画框架
本章的第一部分将专注于插件系统。我们的目标是提供一个方法来构建可以集成到我们的应用程序中的插件,供第三方开发者使用。这些插件应该是动态加载的。应用程序将是第七章中的示例项目的直接后代,无痛第三方库。功能将完全相同,但将使用这个新的插件系统,并将具有华丽的动画。
项目的结构将如下所示:

父级项目是 ch08-image-animation,它由以下内容组成:
-
filter-plugin-original:一个库项目,是原始滤镜的实现 -
filter-plugin-grayscale:一个库项目,是灰度滤镜的实现 -
filter-plugin-blur:一个库项目,是模糊滤镜的实现 -
image-animation:一个 Qt Widgets 应用程序,它将加载显示所需的插件,并使每个插件应用于加载的图片成为可能
我们将开发这些插件中的每一个,但请记住,它们可能是由第三方开发者创建的。为了实现这种开放性,每个插件都将提供 SDK。这个 SDK 依赖于 Qt 插件系统。
考虑到插件应该处理什么内容至关重要。我们的应用程序是一个图像处理软件。我们选择将插件的责任限制在图片处理部分,但这绝对是一个设计选择。
另一种方法可能是让插件开发者提供自己的 UI 来配置插件(例如,调整模糊的强度)。在本章中,我们通过仅关注插件开发本身来保持简单。这完全取决于你以及你想要如何设计你的应用程序。通过扩大插件可以执行的范围,你也增加了插件开发者的负担。总是存在权衡;提供更多选择往往会增加复杂性。众所周知,我们开发者是一群懒惰的人。至少,我们希望在计算机为我们工作时能够偷懒。
我们将首先构建将在每个插件中部署的 SDK。执行以下步骤:
-
创建一个名为
ch08-image-animation的 Subdirs 项目(在向导的末尾不要添加子项目)。 -
在你的文件系统资源管理器中,打开
ch08-image-animation目录并创建一个sdk目录。 -
在
sdk内部创建一个空的Filter.h文件。
我们的 SDK 将由一个文件组成,Filter.h,这是每个插件应该实现的接口(或头文件)。每个插件负责根据其所需功能返回修改后的图片。因为此 SDK 没有链接到任何特定项目,我们将在 Qt Creator 中的特殊文件夹 Other files 下显示它。为此,更新 ch08-image-animation.pro:
TEMPLATE = subdirs
CONFIG += c++14
OTHER_FILES += \
sdk/Filter.h
在 Qt Creator 解析 ch08-image-animation.pro 之后,你应该在 Projects 选项卡中看到以下内容:

Filter.h 文件位于父项目级别。因此,它将更容易在我们的各种插件之间分解 SDK 的管道代码。让我们实现 Filter.h:
#include <QImage>
class Filter
{
public:
virtual ~Filter() {}
virtual QString name() const = 0;
virtual QImage process(const QImage& image) = 0;
};
#define Filter_iid "org.masteringqt.imageanimation.filters.Filter"
Q_DECLARE_INTERFACE(Filter, Filter_iid)
让我们分解这个接口:Filter 子类必须通过实现 name() 方法提供名称,并在实现 process() 方法时返回处理后的图像。正如你所见,Filter.h 确实非常接近在 第七章 中看到的版本,无需烦恼的第三方库。
然而,真正的新内容出现在类定义之后:
#define Filter_iid "org.masteringqt.imageanimation.filters.Filter"
Q_DECLARE_INTERFACE(Filter, Filter_iid)
Filter_iid 是一个唯一标识符,让 Qt 知道接口名称。这将强制实施在实现者一方,该方还必须声明此标识符。
提示
对于实际应用场景,你应该给这个唯一标识符添加一个版本号。这将让你能够正确处理 SDK 和附加插件的版本。
Q_DECLARE_INTERFACE 宏将类与给定的标识符关联起来。这将使 Qt 能够检查加载的插件是否可以安全地转换为 Filter 类型。
提示
在生产代码中,在命名空间内声明您的接口更安全。您永远不知道您的 SDK 将部署在哪种代码环境中。这样,您可以避免潜在的名称冲突。如果您在命名空间中声明,请确保 Q_DECLARE_INTERFACE 宏在命名空间作用域之外。
创建您的插件
SDK 的创建过程非常顺利。我们现在可以继续创建我们的第一个插件。我们已经知道所有我们的插件都将包含我们刚刚完成的 SDK。幸运的是,这可以通过一个 .pri 文件(PRoject Include)轻松实现。.pri 文件的行为与 .pro 文件完全一样;唯一的区别是它旨在包含在 .pro 文件中。
在 ch08-image-animation 目录中创建一个名为 plugins-common.pri 的文件,其中包含以下代码:
INCLUDEPATH += $$PWD/sdk
DEPENDPATH += $$PWD/sdk
此文件将包含在每个 .pro 插件中。它的目的是告诉编译器在哪里可以找到 SDK 的头文件,以及在哪里查找头文件和源文件之间的依赖关系。这将增强修改检测,并在需要时正确编译源文件。
要在项目中看到此文件,我们必须将其添加到 ch08-image-animation.pro 中的 OTHER_FILES 宏:
OTHER_FILES += \
sdk/Filter.h \
plugins-common.pri
构建最直接的插件是 filter-plugin-original,因为它不对图像执行任何特定的处理。让我们按照以下步骤创建此插件:
-
在
ch08-image-animation中创建一个新的 子项目。 -
选择 库 | C++ 库 | 选择...。
-
选择 共享库,将其命名为
filter-plugin-original,然后点击 下一步。 -
选择 QtCore,然后转到 QtWidgets | 下一步。
-
将创建的类命名为
FilterOriginal,然后点击 下一步。 -
将其作为 子项目 添加到
ch08-image-animation,然后点击 完成。
Qt Creator 为我们创建了很多样板代码,但在这个案例中,我们不需要它。按照以下方式更新 filter-plugin-original.pro:
QT += core widgets
TARGET = $$qtLibraryTarget(filter-plugin-original)
TEMPLATE = lib
CONFIG += plugin
SOURCES += \
FilterOriginal.cpp
HEADERS += \
FilterOriginal.h
include(../plugins-common.pri)
我们首先指定 TARGET 应根据操作系统约定正确命名,使用 $$qtLibraryTarget()。CONFIG 属性添加了 plugin 指令,这告诉生成的 Makefile 包含编译 dll/so/dylib(根据您的操作系统选择)所需的必要指令。
我们移除了不必要的 DEFINES 和 FilterOriginal_global.h。插件不应该暴露给调用者任何特定内容,因此不需要处理符号导出。
我们现在可以继续到 FilterOriginal.h:
#include <QObject>
#include <Filter.h>
class FilterOriginal : public QObject, Filter
{
Q_OBJECT
Q_PLUGIN_METADATA(IID "org.masteringqt.imageanimation.filters.Filter")
Q_INTERFACES(Filter)
public:
FilterOriginal(QObject* parent = 0);
~FilterOriginal();
QString name() const override;
QImage process(const QImage& image) override;
};
FilterOriginal 类必须首先继承 QObject;当插件将被加载时,它将首先是一个 QObject 类,然后再被转换为实际类型,Filter。
Q_PLUGIN_METADATA 宏声明导出适当的实现接口标识符到 Qt。它注释了类,让 Qt 元系统了解它。我们在 Filter.h 中再次遇到了我们定义的唯一标识符。
Q_INTERFACES 宏告诉 Qt 元对象系统该类实现了哪个接口。
最后,FilterOriginal.cpp 几乎不值得打印:
FilterOriginal::FilterOriginal(QObject* parent) :
QObject(parent)
{
}
FilterOriginal::~FilterOriginal()
{
}
QString FilterOriginal::name() const
{
return "Original";
}
QImage FilterOriginal::process(const QImage& image)
{
return image;
}
如你所见,其实现是一个空操作。我们从第七章,无烦恼的第三方库版本中添加的唯一内容是name()函数,它返回Original。
我们现在将实现灰度滤镜。正如我们在第七章,无烦恼的第三方库中所做的那样,我们将依赖 OpenCV 库来处理图片。同样适用于以下插件,模糊。
由于这两个项目都有自己的.pro文件,你可以预见 OpenCV 的链接将相同。这是一个.pri文件的完美用例。
在ch08-image-animation目录内,创建一个名为plugins-common-opencv.pri的新文件。不要忘记将其添加到ch08-image-animation.pro中的OTHER_FILES:
OTHER_FILES += \
sdk/Filter.h \
plugins-common.pri \
plugins-common-opencv.pri
下面是plugins-common-opencv.pri的内容:
windows {
INCLUDEPATH += $$(OPENCV_HOME)/../../include
LIBS += -L$$(OPENCV_HOME)/lib \
-lopencv_core2413 \
-lopencv_imgproc2413
}
linux {
CONFIG += link_pkgconfig
PKGCONFIG += opencv
}
macx {
INCLUDEPATH += /usr/local/Cellar/opencv/2.4.13/include/
LIBS += -L/usr/local/lib \
-lopencv_core \
-lopencv_imgproc
}
plugins-common-opencv.pri的内容是我们第七章,无烦恼的第三方库中制作的直接复制。
所有必要的准备工作都已经就绪;我们现在可以继续filter-plugin-grayscale项目。与filter-plugin-original一样,我们将按照以下方式构建它:
-
在
ch08-image-animation中创建一个共享库类型的C++库子项目。 -
创建一个名为
FilterGrayscale的类。 -
在必需模块中选择QtCore和QWidgets。
下面是filter-plugin-grayscale.pro的更新版本:
QT += core widgets
TARGET = $$qtLibraryTarget(filter-plugin-grayscale)
TEMPLATE = lib
CONFIG += plugin
SOURCES += \
FilterGrayscale.cpp
HEADERS += \
FilterGrayscale.h
include(../plugins-common.pri)
include(../plugins-common-opencv.pri)
内容与filter-plugin-original.pro非常相似。我们只是添加了plugins-common-opencv.pri,以便我们的插件能够链接到 OpenCV。
关于FilterGrayscale,其头文件与FilterOriginal.h完全相同。以下是FilterGrayscale.cpp中的相关部分:
#include <opencv/cv.h>
// Constructor & Destructor here
...
QString FilterOriginal::name() const
{
return "Grayscale";
}
QImage FilterOriginal::process(const QImage& image)
{
// QImage => cv::mat
cv::Mat tmp(image.height(),
image.width(),
CV_8UC4,
(uchar*)image.bits(),
image.bytesPerLine());
cv::Mat resultMat;
cv::cvtColor(tmp, resultMat, CV_BGR2GRAY);
// cv::mat => QImage
QImage resultImage((const uchar *) resultMat.data,
resultMat.cols,
resultMat.rows,
resultMat.step,
QImage::Format_Grayscale8);
return resultImage.copy();
}
包含plugins-common-opencv.pri使我们能够正确地包含cv.h头文件。
我们将要实现的最后一个插件是模糊插件。再次创建一个C++库子项目,并创建FilterBlur类。项目结构和.pro文件的内容相同。以下是FilterBlur.cpp:
QString FilterOriginal::name() const
{
return "Blur";
}
QImage FilterOriginal::process(const QImage& image)
{
// QImage => cv::mat
cv::Mat tmp(image.height(),
image.width(),
CV_8UC4,
(uchar*)image.bits(),
image.bytesPerLine());
int blur = 17;
cv::Mat resultMat;
cv::GaussianBlur(tmp,
resultMat,
cv::Size(blur, blur),
0.0,
0.0);
// cv::mat => QImage
QImage resultImage((const uchar *) resultMat.data,
resultMat.cols,
resultMat.rows,
resultMat.step,
QImage::Format_RGB32);
return resultImage.copy();
}
模糊量是硬编码在17。在生产应用程序中,使这个量可变可能更有吸引力。
提示
如果你想进一步推进项目,尝试在 SDK 中包含一个配置插件属性的布局方式。
动态加载你的插件
我们现在将处理加载这些插件的应用程序:
-
在
ch08-image-animation内部创建一个新的子项目。 -
选择类型Qt Widgets 应用程序。
-
命名为
image-animation并接受默认的类信息设置。
我们在.pro文件中还有一些最后要完成的事情。首先,image-animation将尝试从其输出目录的某个地方加载插件。因为每个过滤器插件项目都是独立的,其输出目录与image-animation分开。因此,每次你修改一个插件时,你都必须将编译好的共享库复制到正确的image-animation目录中。这样做可以使它对image-animation应用程序可用,但我们都是懒惰的开发者,对吧?
我们可以通过更新plugins-common-pri来实现自动化,如下所示:
INCLUDEPATH += $$PWD/sdk
DEPENDPATH += $$PWD/sdk
windows {
CONFIG(debug, debug|release) {
target_install_path = $$OUT_PWD/../image-animation/debug/plugins/
} else {
target_install_path = $$OUT_PWD/../image-animation/release/plugins/
}
} else {
target_install_path = $$OUT_PWD/../image-animation/plugins/
}
# Check Qt file 'spec_post.prf' for more information about '$$QMAKE_MKDIR_CMD'
createPluginsDir.path = $$target_install_path
createPluginsDir.commands = $$QMAKE_MKDIR_CMD $$createPluginsDir.path
INSTALLS += createPluginsDir
target.path = $$target_install_path
INSTALLS += target
简而言之,输出库被部署在输出image-animation/plugins目录中。Windows 有一个不同的输出项目结构;这就是为什么我们必须有一个特定于平台的章节。
更好的是,plugins目录会自动通过指令createPluginsDir.commands = $$QMAKE_MKDIR_CMD $$createPluginsDir.path创建。我们不得不用特殊的$$QMAKE_MKDIR_CMD命令而不是系统命令(mkdir)。Qt 会将其替换为正确的 shell 命令(取决于你的操作系统),仅在目录不存在时创建该目录。不要忘记添加make install构建步骤来执行此任务!
在.pro文件中最后要处理的是image-animation的image-animation。应用程序将操作Filter实例。因此,它需要访问 SDK。请在image-animation.pro中添加以下内容:
INCLUDEPATH += $$PWD/../sdk
DEPENDPATH += $$PWD/../sdk
系好安全带。我们现在将加载我们新鲜出炉的插件。在image-animation中创建一个名为FilterLoader的新类。以下是FilterLoader.h的内容:
#include <memory>
#include <vector>
#include <Filter.h>
class FilterLoader
{
public:
FilterLoader();
void loadFilters();
const std::vector<std::unique_ptr<Filter>>& filters() const;
private:
std::vector<std::unique_ptr<Filter>> mFilters;
};
这个类负责加载插件。再一次,我们依靠 C++11 智能指针中的unique_ptr来解释Filter实例的所有权。FilterLoader类将是拥有者,通过mFilters提供对vector的访问器filters()。
注意,filter()函数返回一个const&给vector。这种语义带来了两个好处:
-
参考确保了
vector不会被复制。如果没有它,编译器可能会发出类似“FilterLoader不再是mFilters内容的拥有者!”这样的警告。当然,因为它处理的是 C++模板,编译器错误看起来可能更像是对英语语言的惊人侮辱。 -
const关键字确保调用者不能修改vector类型。
现在我们可以创建FilterLoader.cpp文件:
#include "FilterLoader.h"
#include <QApplication>
#include <QDir>
#include <QPluginLoader>
FilterLoader::FilterLoader() :
mFilters()
{
}
void FilterLoader::loadFilters()
{
QDir pluginsDir(QApplication::applicationDirPath());
#ifdef Q_OS_MAC
pluginsDir.cdUp();
pluginsDir.cdUp();
pluginsDir.cdUp();
#endif
pluginsDir.cd("plugins");
for(QString fileName: pluginsDir.entryList(QDir::Files)) {
QPluginLoader pluginLoader(
pluginsDir.absoluteFilePath(fileName));
QObject* plugin = pluginLoader.instance();
if (plugin) {
mFilters.push_back(std::unique_ptr<Filter>(
qobject_cast<Filter*>(plugin)
));
}
}
}
const std::vector<std::unique_ptr<Filter>>& FilterLoader::filters() const
{
return mFilters;
}
类的核心在于loadFilter()方法。我们首先使用pluginsDir移动到plugins目录,它位于image-animation的输出目录中。对于 Mac 平台有一个特殊情况处理:QApplication::applicationDirPath()返回生成应用程序包内的路径。唯一的方法是使用cdUp()指令向上爬三次。
对于这个目录中的每个fileName,我们尝试加载一个QPluginLoader加载器。QPluginLoader提供了对 Qt 插件的访问。这是加载共享库的跨平台方式。此外,QPluginLoader加载器有以下优点:
-
它检查插件是否与宿主应用程序相同的 Qt 版本链接
-
通过提供通过
instance()直接访问插件的方式,而不是依赖于 C 函数,它简化了插件的加载。
我们接着尝试使用pluginLoader.instance()加载插件。这将尝试加载插件的根组件。在我们的案例中,根组件是FilerOriginal、FilterGrayscale或FilterBlur。这个函数总是返回一个QObject*;如果插件无法加载,它返回0。这就是为什么我们在自定义插件中继承了QObject类。
instance()的调用隐式地尝试加载插件。一旦完成,QPluginLoader不再处理plugin的内存。从这里,我们使用qobject_cast()将插件转换为Filter*。
qobject_cast()函数的行为类似于标准的 C++ dynamic_cast();区别在于它不需要RTTI(运行时类型信息)。
最后但同样重要的是,将Filter*类型的plugin封装在unique_ptr中,并添加到mFilters向量中。
在应用程序中使用插件
现在插件已正确加载,它们必须可以从应用程序的 UI 中访问。为此,我们将从第七章,无需烦恼的第三方库中的FilterWidget类中汲取一些灵感(无耻的借鉴)。
使用 Qt Designer 创建一个新的表单类,使用名为FilterWidget的Widget模板。FilterWidget.ui文件与第七章中完成的一样,无需烦恼的第三方库。
创建FilterWidget.h文件如下:
#include <QWidget>
#include <QImage>
namespace Ui {
class FilterWidget;
}
class Filter;
class FilterWidget : public QWidget
{
Q_OBJECT
public:
explicit FilterWidget(Filter& filter, QWidget *parent = 0);
~FilterWidget();
void process();
void setSourcePicture(const QImage& sourcePicture);
void setSourceThumbnail(const QImage& sourceThumbnail);
void updateThumbnail();
QString title() const;
signals:
void pictureProcessed(const QImage& picture);
protected:
void mousePressEvent(QMouseEvent*) override;
private:
Ui::FilterWidget *ui;
Filter& mFilter;
QImage mDefaultSourcePicture;
QImage mSourcePicture;
QImage mSourceThumbnail;
QImage mFilteredPicture;
QImage mFilteredThumbnail;
};
总体来说,我们简化了与 Qt Designer 插件相关的一切,只是通过引用将mFilter值传递给构造函数。FilterWidget类不再是Filter的所有者;它更像是调用它的客户端。记住,Filter(即插件)的所有者是FilterLoader。
另一个修改是新的setThumbnail()函数。它应该替代旧的updateThumbnail()调用。新的updateThumbnail()现在只执行缩略图处理,不接触源缩略图。这种划分是为了为即将到来的动画部分做准备。缩略图更新将在动画完成后进行。
注意
请参阅该章节的源代码以查看FilterWidget.cpp。
所有底层都已经完成。下一步是填充 MainWindow。再次强调,它遵循我们在第七章中覆盖的模式,无需烦恼的第三方库。与 MainWindow.ui 的唯一区别是 filtersLayout 是空的。显然,插件是动态加载的,因此在编译时我们无法在其中放置任何内容。
让我们来看看 MainWindow.h:
#include <QMainWindow>
#include <QImage>
#include <QVector>
#include "FilterLoader.h"
namespace Ui {
class MainWindow;
}
class FilterWidget;
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = 0);
~MainWindow();
void loadPicture();
protected:
void resizeEvent(QResizeEvent* event) override;
private slots:
void displayPicture(const QImage& picture);
void saveAsPicture();
private:
void initFilters();
void updatePicturePixmap();
private:
Ui::MainWindow *ui;
QImage mSourcePicture;
QImage mSourceThumbnail;
QImage& mFilteredPicture;
QPixmap mCurrentPixmap;
FilterLoader mFilterLoader;
FilterWidget* mCurrentFilter;
QVector<FilterWidget*> mFilters;
};
唯一值得注意的是将 mFilterLoader 添加为成员变量。在 MainWindow.cpp 中,我们将专注于更改:
void MainWindow::initFilters()
{
mFilterLoader.loadFilters();
auto& filters = mFilterLoader.filters();
for(auto& filter : filters) {
FilterWidget* filterWidget = new FilterWidget(*filter);
ui->filtersLayout->addWidget(filterWidget);
connect(filterWidget, &FilterWidget::pictureProcessed,
this, &MainWindow::displayPicture);
mFilters.append(filterWidget);
}
if (mFilters.length() > 0) {
mCurrentFilter = mFilters[0];
}
}
initFilters() 函数不会从 ui 内容中加载过滤器。相反,它首先调用 mFilterLoader.loadFilters() 函数,从 plugins 目录动态加载插件。
之后,一个 auto& 过滤器被分配给 mFilterLoader.filters()。请注意,使用 auto 关键字要容易阅读得多。实际类型是 std::vector<std::unique_ptr<Filter>>&,这看起来更像是一个神秘的咒语,而不是一个简单的对象类型。
对于这些过滤器中的每一个,我们创建一个 FilterWidget* 并传递 filter 的引用。在这里,filter 事实上是一个 unique_ptr。C++11 背后的开发者们明智地修改了解引用操作符,使得新的 FilterWidget(*filter) 变得透明。auto 关键字和 -> 操作符(或解引用操作符)的重载组合使得使用新的 C++ 功能变得更加愉快。
看看这个 for 循环。对于每个 filter,我们执行以下任务:
-
创建一个
FilterWidget模板。 -
将
FilterWidget模板添加到filtersLayout子项中。 -
将
FilterWidget::pictureProcessed信号连接到MainWindow::displayPicture插槽。 -
将新的
FilterWidget模板添加到QVectormFilters中。
最后,选择第一个 FilterWidget。
对 MainWindow.cpp 的唯一其他修改是 loadPicture() 的实现:
void MainWindow::loadPicture()
{
...
for (int i = 0; i <mFilters.size(); ++i) {
mFilters[i]->setSourcePicture(mSourcePicture);
mFilters[i]->setSourceThumbnail(mSourceThumbnail);
mFilters[i]->updateThumbnail();
}
mCurrentFilter->process();
}
updateThumbnail() 函数已经被拆分为两个函数,这里就是使用它的地方。
应用程序现在可以进行测试。你应该能够执行它,并看到动态加载的插件以及显示处理后的默认 Lenna 图片。
探索动画框架
你的应用程序运行得非常出色。现在是时候看看我们如何让它跳跃和移动,或者说,让它“活”起来。Qt 动画框架可以用来创建和启动 Qt 属性的动画。Qt 将通过一个内部的全局定时器句柄平滑地插值属性值。只要它是 Qt 属性,你可以对任何东西进行动画处理。你甚至可以使用 Q_PROPERTY 为你的对象创建属性。如果你忘记了 Q_PROPERTY,请参阅第七章,无需烦恼的第三方库。
提供了三个主要类来构建动画:
-
QPropertyAnimation:这个类用于动画一个 Qt 属性动画 -
QParallelAnimationGroup:这个类并行动画化多个动画(所有动画同时开始) -
QSequentialAnimationGroup:这个类按顺序动画化多个动画(动画按定义的顺序一个接一个地运行)
所有这些类都继承自QAbstractAnimation。以下是来自官方 Qt 文档的图表:

请注意,QAbstractAnimation、QVariantAnimation和QAnimationGroup都是抽象类。以下是一个简单的 Qt 动画示例:
QLabel label;
QPropertyAnimation animation;
animation.setTargetObject(&label);
animation.setPropertyName("geometry");
animation.setDuration(4000);
animation.setStartValue(QRect(0, 0, 150, 50));
animation.setEndValue(QRect(300, 200, 150, 50));
animation.start();
前面的代码片段将一个QLabel标签从 0 x 0 位置移动到 300 x 200 位置,耗时四秒。首先需要定义目标对象及其属性。在我们的例子中,目标对象是label,我们想要动画化名为geometry的属性。然后,我们设置动画的持续时间(以毫秒为单位):4000毫秒对应四秒。最后,我们可以决定geometry属性的开始和结束值,这是一个QRect,定义如下:
QRect(x, y, width, height)
label对象从 0 x 0 位置开始,到 300 x 200 位置结束。在这个例子中,大小是固定的(150 x 50),但如果你愿意,也可以动画化宽度和高度。
最后,我们调用start()函数开始动画。在四秒内,动画平滑地将标签从 0 x 0 位置移动到 300 x 200 位置。默认情况下,动画使用线性插值来提供中间值,因此,两秒后,label将位于 150 x 100 位置。值的线性插值看起来像以下图示:

在我们的例子中,label对象将以恒定速度从起始位置移动到结束位置。缓动函数是一个描述值随时间演变的数学函数。缓动曲线是数学函数的视觉表示。默认的线性插值是一个好的起点,但 Qt 提供了许多缓动曲线来控制动画的速度行为。以下是更新后的示例:
QLabel label;
QPropertyAnimation animation(&label, "geometry");
animation.setDuration(4000);
animation.setStartValue(QRect(0, 0, 150, 50));
animation.setEndValue(QRect(300, 200, 150, 50));
animation.setEasingCurve(QEasingCurve::InCirc);
animation.start();
你可以直接使用QPropertyAnimation构造函数设置目标对象和属性名。因此,我们移除了setTargetObject()和setPropertyName()函数。之后,我们使用setEasingCurve()为这个动画指定一个曲线。InCirc看起来如下:

使用这个缓动曲线,标签开始移动速度很慢,但在动画过程中逐渐加速。
另一种方法是使用setKeyValueAt()函数自己定义中间的关键步骤。让我们更新我们的例子:
QLabel label;
QPropertyAnimation animation(&label, "geometry");
animation.setDuration(4000);
animation.setKeyValueAt(0, QRect(0, 0, 150, 50));
animation.setKeyValueAt(0.25, QRect(225, 112.5, 150, 50));
animation.setKeyValueAt(1, QRect(300, 200, 150, 50));
animation.start();
我们现在正在使用 setKeyValueAt() 设置关键帧。第一个参数是 0 到 1 范围内的时间步长。在我们的例子中,步骤 1 意味着四秒。步骤 0 和步骤 1 的关键帧提供了与第一个示例的起始/结束位置相同的坐标。正如你所见,我们还在步骤 0.25(对我们来说是一秒)处添加了一个位置 225 x 112.5 的关键帧。下一个图示说明了这一点:

你可以清楚地区分使用 setKeyValueAt() 创建的三个关键帧。在我们的例子中,我们的 label 将在 一秒内迅速达到 225 x 112.5 的位置。然后标签将在剩余的三秒内缓慢移动到 300 x 200 的位置。
如果你有一个以上的 QPropertyAnimation 对象,你可以使用组来创建更复杂的序列。让我们看一个例子:
QPropertyAnimation animation1(&label1, "geometry");
QPropertyAnimation animation2(&label2, "geometry");
...
QSequentialAnimationGroup animationGroup;
animationGroup.addAnimation(&anim1);
animationGroup.addAnimation(&anim2);
animationGroup.start();
在这个例子中,我们使用 QSequentialAnimationGroup 来依次运行动画。首先要做的是将动画添加到 animationGroup 中。然后,当我们对动画组调用 start() 时,animation1 将被 启动。当 animation1 完成 时,animationGroup 将运行 animation2。当列表中的最后一个动画结束时,QSequentialAnimationGroup 才会完成。下一个图示描述了这种行为:

第二个动画组 QParallelAnimationGroup 以与 QSequentialAnimationGroup 相同的方式初始化和启动。但行为不同:它并行启动所有动画,等待最长的动画结束。以下是这一点的说明:

请记住,动画组本身也是一个动画(它继承自 QAbstractAnimation)。因此,你可以将动画组添加到其他动画组中,以创建一个非常复杂的动画序列!
让你的缩略图跳跃
让我们将我们学到的有关 Qt 动画框架的知识应用到我们的项目中。每次用户点击过滤器缩略图时,我们希望对其进行“戳”操作。所有修改都将在对 FilterWidget 类进行。让我们从 FilterWidget.h 开始:
#include <QPropertyAnimation>
class FilterWidget : public QWidget
{
Q_OBJECT
public:
explicit FilterWidget(Filter& filter, QWidget *parent = 0);
~FilterWidget();
...
private:
void initAnimations();
void startSelectionAnimation();
private:
...
QPropertyAnimation mSelectionAnimation;
};
第一个函数 initAnimations() 初始化了 FilterWidget 所使用的动画。第二个函数 startSelectionAnimation() 执行启动此动画所需的任务。正如你所见,我们还在前一个章节中介绍了 QPropertyAnimation 类。
我们现在可以更新 FilterWidget.cpp。让我们更新构造函数:
FilterWidget::FilterWidget(Filter& filter, QWidget *parent) :
QWidget(parent),
...
mSelectionAnimation()
{
...
initAnimations();
updateThumbnail();
}
我们初始化了一个名为 mSelectionAnimation 的 QPropertyAnimation。构造函数还调用了 initAnimations()。以下是其实施方法:
void FilterWidget::initAnimations()
{
mSelectionAnimation.setTargetObject(ui->thumbnailLabel);
mSelectionAnimation.setPropertyName("geometry");
mSelectionAnimation.setDuration(200);
}
你现在应该熟悉这些动画初始化步骤了。目标对象是显示过滤器插件预览的 thumbnailLabel。要动画化的属性名称是 geometry,因为我们想更新这个 QLabel 的位置。最后,我们将动画时长设置为 200 毫秒。就像笑话一样,保持简短而甜蜜。
更新现有的鼠标事件处理程序如下:
void FilterWidget::mousePressEvent(QMouseEvent*)
{
process();
startSelectionAnimation();
}
每次用户点击缩略图时,都会调用移动缩略图的选中动画。我们现在可以添加这个最重要的函数如下:
void FilterWidget::startSelectionAnimation()
{
if (mSelectionAnimation.state() ==
QAbstractAnimation::Stopped) {
QRect currentGeometry = ui->thumbnailLabel->geometry();
QRect targetGeometry = ui->thumbnailLabel->geometry();
targetGeometry.setY(targetGeometry.y() - 50.0);
mSelectionAnimation.setKeyValueAt(0, currentGeometry);
mSelectionAnimation.setKeyValueAt(0.3, targetGeometry);
mSelectionAnimation.setKeyValueAt(1, currentGeometry);
mSelectionAnimation.start();
}
}
首先要做的是获取 thumbnailLabel 的当前几何形状,称为 currentGeometry。然后,我们创建一个具有相同 x、width 和 height 值的 targetGeometry 对象。我们只减少 y 位置 50,因此目标位置始终在当前位置之上。
之后,我们定义我们的关键帧:
-
在步骤 0,值是当前位置。
-
在步骤 0.3(60 毫秒,因为总时长是 200 毫秒),值是目标位置。
-
在步骤 1(动画的结尾),我们将它恢复到原始位置。缩略图将迅速达到目标位置,然后缓慢下降到其原始位置。
这些关键帧必须在每次动画开始之前初始化。因为布局是动态的,当用户调整主窗口大小时,位置(以及因此的几何形状)可能已经被更新。
请注意,我们正在防止如果当前状态没有停止,动画再次开始。如果没有这个预防措施,如果用户像疯子一样连续点击小部件,缩略图可能会一次又一次地移动到顶部。
你现在可以测试你的应用程序并点击一个过滤器效果。过滤器缩略图将跳起来响应你的点击!
淡入图片
当用户打开图片时,我们希望通过调整其不透明度来淡入图像。QLabel 或 QWidget 类不提供不透明度属性。然而,我们可以使用 QGraphicsEffect 为任何 QWidget 添加视觉效果。对于这个动画,我们将使用 QGraphicsOpacityEffect 来提供 opacity 属性。
这里是一个图解来描述每个组件的作用:

在我们的案例中,QWidget 类是我们的 QLabel,而 QGraphicsEffect 类是 QGraphicsOpacityEffect。Qt 提供了图形效果系统来改变 QWidget 类的渲染。抽象类 QGraphicsEffect 有一个纯虚方法 draw(),该方法由每个图形效果实现。
我们现在可以根据下一个片段更新 MainWindow.h:
#include <QPropertyAnimation>
#include <QGraphicsOpacityEffect>
class MainWindow : public QMainWindow
{
...
private:
...
void initAnimations();
private:
...
QPropertyAnimation mLoadPictureAnimation;
QGraphicsOpacityEffect mPictureOpacityEffect;
};
initAnimations() 这个私有函数负责所有的动画初始化。mLoadPictureAnimation 成员变量负责对加载的图片执行淡入动画。最后,我们声明了 mPictureOpacityEffect,这是必须的 QGraphicsOpacityEffect。
让我们切换到实现部分,使用 MainWindow.cpp 构造函数:
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
...
mLoadPictureAnimation(),
mPictureOpacityEffect()
{
...
initFilters();
initAnimations();
}
没有惊喜。我们使用初始化列表来构造我们的两个新成员变量。MainWindow构造函数还调用了initAnimations()。
让我们看看这个动画是如何初始化的:
void MainWindow::initAnimations()
{
ui->pictureLabel->setGraphicsEffect(&mPictureOpacityEffect);
mLoadPictureAnimation.setTargetObject(&mPictureOpacityEffect);
mLoadPictureAnimation.setPropertyName("opacity");
mLoadPictureAnimation.setDuration(500);
mLoadPictureAnimation.setStartValue(0);
mLoadPictureAnimation.setEndValue(1);
mLoadPictureAnimation.setEasingCurve(QEasingCurve::InCubic);
}
首先要做的是将我们的QGraphicsOpacityEffect与QLabel链接起来。这可以通过在pictureLabel上调用setGraphicsEffect()函数轻松完成。
现在我们可以设置动画。在这种情况下,mLoadPictureAnimation针对mPictureOpacityEffect,将影响其名为opacity的属性。动画持续时间为500毫秒。接下来,我们设置动画开始和结束时的透明度值:
-
在开始时,图片是完全透明的(
透明度值为0) -
最后,图片完全可见(
透明度值为1)
对于这个动画,我们使用InCubic缓动曲线。这个曲线看起来像这样:

随意尝试其他曲线,找到最适合您的曲线。
注意
您可以在此处获取所有缓动曲线的视觉预览列表:doc.qt.io/qt-5/qeasingcurve.html
最后一步是在正确的地方开始动画:
void MainWindow::loadPicture()
{
...
mCurrentFilter->process();
mLoadPictureAnimation.start();
}
您现在可以启动应用程序并加载一张图片。您应该会看到图片在 500 毫秒内淡入!
按顺序闪烁缩略图
对于最后一个动画,我们想在缩略图更新时在每个过滤器预览上显示蓝色闪光。我们不想同时闪烁所有预览,而是按顺序逐个闪烁。这个功能将通过两部分实现:
-
在
FilterWidget中创建一个颜色动画以显示蓝色闪光 -
在
MainWindow中构建一个包含所有FilterWidget颜色动画的顺序动画组
让我们开始添加颜色动画。按照以下代码片段更新FilterWidget.h:
#include <QGraphicsColorizeEffect>
class FilterWidget : public QWidget
{
Q_OBJECT
public:
explicit FilterWidget(Filter& filter, QWidget *parent = 0);
~FilterWidget();
...
QPropertyAnimation* colorAnimation();
private:
...
QPropertyAnimation mSelectionAnimation;
QPropertyAnimation* mColorAnimation;
QGraphicsColorizeEffect mColorEffect;
};
这次我们不想影响透明度,而是将缩略图着色为蓝色。因此,我们使用另一个 Qt 标准效果:QGraphicsColorizeEffect。我们还声明了一个新的QPropertyAnimation,名为mColorAnimation,以及其对应的获取器colorAnimation()。我们将mColorAnimation声明为指针,因为所有权将由MainWindow的动画组接管。这个话题很快就会涉及。
让我们更新FilterWidget.cpp中的构造函数:
FilterWidget::FilterWidget(Filter& filter, QWidget *parent) :
QWidget(parent),
...
mColorAnimation(new QPropertyAnimation()),
mColorEffect()
{
...
}
我们只需要构造我们的两个新成员变量,mColorAnimation和mColorEffect。让我们看看获取器的惊人复杂性:
QPropertyAnimation* FilterWidget::colorAnimation()
{
return mColorAnimation;
}
这是个谎言:我们总是试图编写全面的代码!
现在初步工作完成,我们可以通过更新initAnimations()函数来初始化颜色动画,如下所示:
void FilterWidget::initAnimations()
{
...
mColorEffect.setColor(QColor(0, 150, 150));
mColorEffect.setStrength(0.0);
ui->thumbnailLabel->setGraphicsEffect(&mColorEffect);
mColorAnimation->setTargetObject(&mColorEffect);
mColorAnimation->setPropertyName("strength");
mColorAnimation->setDuration(200);
mColorAnimation->setStartValue(1.0);
mColorAnimation->setEndValue(0.0);
}
第一部分设置了颜色过滤器。在这里,我们选择了一种青绿色来作为闪烁效果。着色效果由其 strength 属性处理。默认值是 1.0,因此,我们将它设置为 0.0 以防止它影响我们默认的 Lenna 缩略图。最后,我们通过调用 setGraphicsEffect() 将 thumbnailLabel 与 mColorEffect 链接起来。
第二部分是颜色动画的准备。这个动画针对颜色效果及其属性,名为 strength。这是一个短暂的闪烁;200 毫秒就足够了:
-
我们希望从全强度效果开始,所以我们将起始值设置为
1.0 -
在动画过程中,着色效果将逐渐减弱,直到达到
0.0
这里的默认线性插值就足够好了,所以我们没有使用任何缓动曲线。
我们在这里。颜色效果/动画已初始化,我们提供了一个 colorAnimation() 获取器。现在我们可以开始这个功能的第二部分,更新 MainWindow.h:
#include <QSequentialAnimationGroup>
class MainWindow : public QMainWindow
{
Q_OBJECT
...
private:
...
QSequentialAnimationGroup mFiltersGroupAnimation;
};
我们声明一个 QSequentialAnimationGroup 类来依次触发所有显示蓝色闪烁的 FilterWidget 颜色动画。让我们更新 MainWindow.cpp 中的构造函数:
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
...
mFiltersGroupAnimation()
{
...
}
新成员变量意味着初始化列表中的新构造:这就是规则!
我们现在可以更新 initAnimations() 来准备我们的动画组:
void MainWindow::initAnimations()
{
...
for (FilterWidget* filterWidget : mFilters) {
mFiltersGroupAnimation.addAnimation(
filterWidget->colorAnimation());
}
}
你还记得吗,动画组只是一个动画容器?因此,我们遍历每个 FilterWidget 来获取其颜色动画,并调用 addAnimation() 填充我们的 mFiltersGroupAnimation。多亏了 C++11 的基于范围的 for 循环,这使得代码非常易于阅读。请注意,当你向动画组添加动画时,组将拥有这个动画的所有权。
我们的动画组已经准备好了。现在我们可以启动它:
void MainWindow::loadPicture()
{
...
mCurrentFilter->process();
mLoadPictureAnimation.start();
mFiltersGroupAnimation.start();
}
启动您的应用程序并打开一张图片。您可以看到所有过滤器缩略图将一个接一个地从左到右闪烁。这正是我们想要的,但它还不够完美,因为所有缩略图在闪烁之前都已经更新了。我们之所以有这种行为,是因为 loadPicture() 函数实际上设置了并更新了所有缩略图,然后最终启动了顺序动画组。以下是说明当前行为的方案:

该方案仅描述了两个缩略图的行为,但三个缩略图的原则是相同的。以下是目标行为:

我们必须在闪烁动画结束后才更新缩略图。幸运的是,QPropertyAnimation 在动画结束后会发出 finished 信号,所以我们只需要做几个更改。更新 MainWindow.cpp 中的 loadPicture() 函数:
void MainWindow::loadPicture()
{
...
for (int i = 0; i <mFilters.size(); ++i) {
mFilters[i]->setSourcePicture(mSourcePicture);
mFilters[i]->setSourceThumbnail(mSourceThumbnail);
//mFilters[i]->updateThumbnail();
}
...
}
如您所见,我们保留了设置,只是在用户打开新图片时移除了更新缩略图。在这个阶段,所有 FilterWidget 实例都拥有正确的缩略图,但它们没有显示出来。让我们通过更新 FilterWidget.cpp 来解决这个问题:
void FilterWidget::initAnimations()
{
...
mColorAnimation->setTargetObject(&mColorEffect);
mColorAnimation->setPropertyName("strength");
mColorAnimation->setDuration(200);
mColorAnimation->setStartValue(1.0);
mColorAnimation->setEndValue(0.0);
connect(mColorAnimation, &QPropertyAnimation::finished, [this]
{
updateThumbnail();
});
}
我们将一个 lambda 函数连接到颜色动画的完成信号。这个 lambda 函数简单地更新缩略图。现在你可以重新启动你的应用程序并加载一张图片。你应该能看到我们不仅动画化了连续的蓝色闪光,还更新了缩略图。
摘要
在本章中,你在自己的 SDK 中定义了一个 Filter 接口。你的过滤器现在变成了插件。你知道如何创建和加载一个新的插件,因此你的应用程序现在是模块化的,并且可以轻松扩展。我们还使用 Qt 动画框架增强了应用程序。你知道如何使用 QGraphicsEffect(如果需要的话)来动画化任何 QWidget 的位置、颜色和透明度。我们创建了一个顺序动画,通过 QSequentialAnimationGroup 依次启动三个动画。
在下一章中,我们将讨论一个重要主题:线程。Qt 框架可以帮助你构建一个健壮且可靠的线程应用程序。为了说明本章内容,我们将使用线程池创建一个曼德布罗特分形生成器。
第九章。用多线程保持你的理智
在前面的章节中,我们设法总是编写不依赖线程的代码。现在是时候面对这个怪物,真正理解在 Qt 中线程是如何工作的了。在本章中,你将开发一个显示曼德布罗特分形的多线程应用程序。这是一个计算密集型过程,将让你的 CPU 核心流泪。
在示例项目中,用户可以看到曼德布罗特分形,放大图片,并四处移动以发现分形的神奇之处。
本章涵盖了以下主题:
-
深入理解
QThread框架 -
Qt 中所有可用的线程技术概述
-
使用
QThreadPool类调度任务并汇总结果 -
如何同步线程并最小化共享状态
-
低级绘图以优化性能
-
常见的线程陷阱和挑战
发现 QThread
Qt 提供了一个复杂的线程系统。我们假设你已经了解了线程基础知识及其相关的问题(死锁、线程同步、资源共享等),我们将重点介绍 Qt 如何实现它。
QThread 是 Qt 线程系统的核心类。一个 QThread 实例管理程序中的一个执行线程。
你可以继承 QThread 来重写 run() 函数,该函数将在 QThread 框架中执行。以下是创建和启动 QThread 的方法:
QThread thread;
thread.start();
调用 start() 函数将自动调用线程的 run() 函数并发出 started() 信号。只有在这一点上,新的执行线程才会被创建。当 run() 完成时,thread 对象将发出 finished() 信号。
这将我们带到了 QThread 的一个基本方面:它与信号/槽机制无缝工作。Qt 是一个事件驱动的框架,其中主事件循环(或 GUI 循环)处理事件(用户输入、图形等)以刷新 UI。
每个 QThread 都有自己的事件循环,可以处理主循环之外的事件。如果不重写,run() 将调用 QThread::exec() 函数,该函数启动 thread 对象的事件循环。你也可以重写 QThread 并调用自己的 exec(),如下所示:
class Thread : public QThread
{
Q_OBJECT
protected:
void run()
{
Object* myObject = new Object();
connect(myObject, &Object::started,
this, &Thread::doWork);
exec();
}
private slots:
void doWork();
};
只有在调用 exec() 后,started() 信号才会由 Thread 事件循环处理。它将阻塞并等待直到调用 QThread::exit()。
一个需要注意的关键点是,线程事件循环为该线程中所有存活的 QObjects 提供事件。这包括在该线程中创建的所有对象或移动到该线程的对象。这被称为对象线程亲和力。让我们看一个例子:
class Thread : public QThread
{
Thread() :
mObject(new QObject())
{
}
private :
QObject* myObject;
};
// Somewhere in MainWindow
Thread thread;
thread.start();
在这个片段中,myObject 在 Thread 类的构造函数中构建,该构造函数在 MainWindow 中创建。此时,thread 正在 GUI 线程中运行。因此,myObject 也生活在 GUI 线程中。
注意
在创建 QCoreApplication 对象之前创建的对象没有线程亲和性。因此,不会向其派遣任何事件。
能够在我们的 QThread 中处理信号和槽是非常棒的,但我们如何控制跨多个线程的信号呢?一个经典的例子是,一个长时间运行的过程在一个单独的线程中执行,必须通知 UI 更新某些状态:
class Thread : public QThread
{
Q_OBJECT
void run() {
// long running operation
emit result("I <3 threads");
}
signals:
void result(QString data);
};
// Somewhere in MainWindow
Thread* thread = new Thread(this);
connect(thread, &Thread::result, this, &MainWindow::handleResult);
connect(thread, &Thread::finished, thread, &QObject::deleteLater);
thread->start();
直觉上,我们假设第一个 connect 会将信号发送到多个线程(以便在 MainWindow::handleResult 中可用结果),而第二个 connect 应该只在工作线程的事件循环上工作。
幸运的是,这是由于 connect() 函数签名中的默认参数:连接类型。让我们看看完整的签名:
QObject::connect(
const QObject *sender, const char *signal,
const QObject *receiver, const char *method,
Qt::ConnectionType type = Qt::AutoConnection)
type 关键字默认取 Qt::AutoConnection。让我们回顾一下 Qt::ConnectionType 枚举的可能值,如官方 Qt 文档所述:
-
Qt::AutoConnection:如果接收器位于发出信号的线程中,则使用Qt::DirectConnection。否则,使用Qt::QueuedConnection。连接类型在信号发出时确定。 -
Qt::DirectConnection:当信号发出时,将立即调用此槽。槽在发出信号的线程中执行。 -
Qt::QueuedConnection:当控制权返回接收器线程的事件循环时,将调用此槽。槽在接收器线程中执行。 -
Qt::BlockingQueuedConnection:这与Qt::QueuedConnection相同,只不过信号线程会阻塞,直到槽返回。如果接收器位于信号线程中,则不得使用此连接,否则应用程序将发生死锁。 -
Qt::UniqueConnection:这是一个可以与之前任何一种连接类型组合的标志,使用位或运算。当设置Qt::UniqueConnection时,如果连接已经存在(即,如果相同的信号已经连接到相同的槽,针对同一对对象),QObject::connect()将失败。
当使用 Qt::AutoConnection 时,最终的 ConnectionType 仅在信号实际发出时才被解决。如果你再次看我们的例子,第一个 connect():
connect(thread, &Thread::result,
this, &MainWindow::handleResult);
当 result() 发出时,Qt 会查看 handleResult() 线程亲和性,这与 result() 信号的线程亲和性不同。thread 对象位于 MainWindow 中(记住它是在 MainWindow 中创建的),但 result() 信号是在 run() 函数中发出的,该函数在不同的执行线程中运行。因此,将使用 Qt::QueuedConnection 槽。
我们现在可以看看第二个 connect():
connect(thread, &Thread::finished, thread, &QObject::deleteLater);
在这里,deleteLater() 和 finished() 都位于同一个线程中;因此,将使用 Qt::DirectConnection 槽。
你必须明白,Qt 并不关心发出信号的线程亲和性,它只关注信号的“执行上下文”。
带着这些知识,我们可以再次审视我们的第一个 QThread 类示例,以全面理解这个系统:
class Thread : public QThread
{
Q_OBJECT
protected:
void run()
{
Object* myObject = new Object();
connect(myObject, &Object::started,
this, &Thread::doWork);
exec();
}
private slots:
void doWork();
};
当 Object::started() 函数被发出时,将使用一个 Qt::QueuedConnection 插槽。这就是你的大脑冻结的地方。Thread::doWork() 函数位于 Object::started() 所在的另一个线程中,该线程是在 run() 中创建的。如果线程是在 UI 线程中实例化的,那么这就是 doWork() 应该属于的地方。
这个系统功能强大,但也很复杂。为了使事情更简单,Qt 倾向于使用工作模型。它将线程管道与实际处理分离。以下是一个例子:
class Worker : public QObject
{
Q_OBJECT
public slots:
void doWork()
{
emit result("workers are the best");
}
signals:
void result(QString data);
};
// Somewhere in MainWindow
QThread* thread = new Thread(this);
Worker* worker = new Worker();
worker->moveToThread(thread);
connect(thread, &QThread::finished,
worker, &QObject::deleteLater);
connect(this, &MainWindow::startWork,
worker, &Worker::doWork);
connect(worker, &Worker::resultReady,
this, handleResult);
thread->start();
// later on, to stop the thread
thread->quit();
thread->wait();
我们首先创建一个具有以下内容的 Worker 类:
-
一个将包含我们旧的
QThread::run()内容的doWork()插槽 -
一个将发出结果的
result()信号
在 MainWindow 类中,我们创建了一个简单的 thread 对象和一个 Worker 实例。worker->moveToThread(thread) 是魔法发生的地方。它改变了 worker 对象的亲和力。现在 worker 位于 thread 对象中。
你只能从当前线程向另一个线程推送对象。相反,你不能从另一个线程中拉取一个对象。如果你不在你的线程中,你不能改变对象的线程亲和力。一旦执行了 thread->start(),除非我们从这个新线程中执行,否则我们不能调用 worker->moveToThread(this)。
之后,我们做了三个 connect():
-
我们通过在线程完成时回收它来处理
worker生命周期。这个信号将使用Qt::DirectConnection。 -
在可能的 UI 事件上启动
Worker::doWork()。这个信号将使用Qt::QueuedConnection。 -
我们使用
handleResult()在 UI 线程中处理结果数据。这个信号将使用Qt::QueuedConnection。
总结来说,QThread 可以被继承或与 worker 类一起使用。通常,更倾向于使用工作方法,因为它更清晰地分离了线程亲和力管道和实际并行执行的操作。
飞越 Qt 多线程技术
基于 QThread,Qt 中提供了几种线程技术。首先,为了同步线程,通常的方法是使用互斥锁(mutex)来为给定资源提供互斥。Qt 通过 QMutex 类提供它。其用法很简单:
QMutex mutex;
int number = 1;
mutex.lock();
number *= 2;
mutex.unlock();
从 mutex.lock() 指令开始,任何尝试锁定 mutex 的其他线程都将等待直到 mutex.unlock() 被调用。
在复杂代码中,锁定/解锁机制容易出错。你可能会忘记在特定的退出条件下解锁互斥锁,从而导致死锁。为了简化这种情况,Qt 提供了一个 QMutexLocker,应该在需要锁定 QMutex 的地方使用:
QMutex mutex;
QMutexLocker locker(&mutex);
int number = 1;
number *= 2;
if (overlyComplicatedCondition) {
return;
} else if (notSoSimple) {
return;
}
当创建locker对象时,mutex会被锁定,并在locker对象被销毁时解锁;例如,当它超出作用域时。对于每个我们提到的出现return语句的条件,情况都是如此。这使得代码更简单、更易读。
你可能需要频繁地创建和销毁线程,因为手动管理QThread实例可能会变得繁琐。为此,你可以使用QThreadPool类,它管理着一组可重用的QThread。
要在由QThreadPool类管理的线程中执行代码,你将使用一个与我们之前覆盖的工人非常相似的模式。主要区别在于处理类必须扩展QRunnable类。以下是它的样子:
class Job : public QRunnable
{
void run()
{
// long running operation
}
}
Job* job = new Job();
QThreadPool::globalInstance()->start(job);
只需重写run()函数,并让QThreadPool在单独的线程中执行你的任务。QThreadPool::globalInstance()是一个静态辅助函数,它为你提供了访问应用程序全局实例的权限。如果你需要更精细地控制QThreadPool的生命周期,你可以创建自己的QThreadPool。
注意,QThreadPool::start()函数会接管job的所有权,并在run()完成后自动删除它。小心,这不会像QObject::moveToThread()对工作者那样改变线程亲和力!QRunnable类不能重用,它必须是一个全新的实例。
如果你启动了多个任务,QThreadPool会根据你的 CPU 核心数自动分配理想数量的线程。QThreadPool类可以启动的最大线程数可以通过QThreadPool::maxThreadCount()获取。
小贴士
如果你需要手动管理线程,但希望基于你的 CPU 核心数,你可以使用方便的静态函数QThreadPool::idealThreadCount()。
使用 Qt 并发框架,还有另一种多线程开发的方法。它是一个高级 API,避免了使用互斥锁/锁定/等待条件,并促进了处理在 CPU 核心之间的分布。
Qt 并发依赖于QFuture类来执行函数,并期望稍后得到结果:
void longRunningFunction();
QFuture<void> future = QtConcurrent::run(longRunningFunction);
longRunningFunction()函数将在从默认的QThreadPool类获得的单独线程中执行。
要将参数传递给QFuture类并检索操作的结果,请使用以下代码:
QImage processGrayscale(QImage& image);
QImage lenna;
QFuture<QImage> future = QtConcurrent::run(processGrayscale,
lenna);
QImage grayscaleLenna = future.result();
在这里,我们将lenna作为参数传递给processGrayscale()函数。因为我们想要一个QImage作为结果,所以我们使用模板类型声明QFuture类,为QImage。之后,future.result()会阻塞当前线程,等待操作完成以返回最终的QImage。
为了避免阻塞,QFutureWatcher来帮忙:
QFutureWatcher<QImage> watcher;
connect(&watcher, &QFutureWatcher::finished,
this, &QObject::handleGrayscale);
QImage processGrayscale(QImage& image);
QImage lenna;
QFuture<QImage> future = QtConcurrent::run(processImage, lenna);
watcher.setFuture(future);
我们首先声明一个QFutureWatcher类,其模板参数与QFuture使用的参数相匹配。然后只需将QFutureWatcher::finished信号连接到当操作完成后要调用的槽。
最后一步是告诉watcher对象使用watcher.setFuture(future)来监视未来的对象。这个语句看起来几乎像是来自科幻电影。
Qt 并发还提供了MapReduce和FilterReduce的实现。MapReduce是一种编程模型,基本上做两件事:
-
在 CPU 的多个核心之间映射或分配数据集的处理
-
将结果减少或聚合以提供给调用者
这种技术最初由谷歌推广,以便能够在 CPU 集群中处理大量数据集。
这里是一个简单的映射操作的例子:
QList images = ...;
QImage processGrayscale(QImage& image);
QFuture<void> future = QtConcurrent::mapped(
images, processGrayscale);
我们不是使用QtConcurrent::run(),而是使用映射函数,该函数每次都接受一个列表和应用于列表中每个元素的不同线程中的函数。images列表就地修改,因此不需要使用模板类型声明QFuture。
可以通过使用QtConcurrent::blockingMapped()而不是QtConcurrent::mapped()来使操作阻塞。
最后,一个MapReduce操作看起来是这样的:
QList images = ...;
QImage processGrayscale(QImage& image);
void combineImage(QImage& finalImage, const QImage& inputImage);
QFuture<void> future = QtConcurrent::mappedReduced(
images,
processGrayscale,
combineImage);
在这里,我们添加了一个combineImage()函数,它将在processGrayscale()映射函数返回的每个结果上被调用。它将中间数据inputImage合并到finalImage中。这个函数在每个线程中只被调用一次,因此不需要使用互斥锁来锁定结果变量。
FilterReduce遵循完全相同的模式;过滤器函数只是允许您过滤输入列表而不是转换它。
构建曼德布罗特项目架构
本章的示例项目是计算曼德布罗特分形的多线程计算。用户将看到分形,并能够在该窗口中平移和缩放。
在深入代码之前,我们必须对分形有一个广泛的理解,以及我们如何实现其计算。
曼德布罗特分形是一个使用复数(a + bi)的数值集。每个像素都与通过迭代计算出的一个值相关联。如果这个迭代值发散到无穷大,那么这个像素就超出了曼德布罗特集。如果不发散,那么这个像素就在曼德布罗特集内。曼德布罗特分形的视觉表示如下:

这张图像中的每一个黑色像素都倾向于发散到无限大的值,而白色像素则被限制在有限值内。白色像素属于曼德布罗特集。
从多线程的角度来看,使其变得有趣的是,为了确定像素是否属于曼德布罗特集,我们必须迭代一个公式来假设它的发散与否。我们进行的迭代越多,我们声称“是的,这个像素在曼德布罗特集中,它是一个白色像素”就越安全。
更有趣的是,我们可以取图形图中的任何值,并始终应用 Mandelbrot 公式来推断像素应该是黑色还是白色。因此,你可以在分形图形内部无限缩放。只有两个主要限制:
-
你的 CPU 性能阻碍了图片生成速度。
-
你的 CPU 架构的浮点数精度限制了缩放。如果你继续缩放,你会得到视觉伪影,因为缩放因子只能处理 15 到 17 位有效数字。
应用程序的架构必须精心设计。因为我们正在使用线程,所以很容易导致死锁、线程饥饿,甚至更糟糕的是,冻结 UI。
我们真的想最大化 CPU 的使用。为此,我们将尽可能在每个核心上执行尽可能多的线程。每个线程将负责计算 Mandelbrot 集的一部分,然后再返回其结果。
应用程序的架构如下:

应用程序被分为三个部分:
-
MandelbrotWidget:这个请求显示图片。它处理绘图和用户交互。此对象位于 UI 线程中。 -
MandelbrotCalculator:这个处理图片请求并在发送回MandelbrotWidget之前聚合结果JobResults的对象。此对象在其自己的线程中运行。 -
Job:这个在将结果传回MandelbrotCalculator之前计算最终图片的一部分。每个任务都位于自己的线程中。
MandelbrotCalculator 线程将使用 QThreadPool 类在其自己的线程中调度任务。这将根据你的 CPU 核心完美缩放。每个任务将在将结果通过 JobResult 对象发送回 MandelbrotCalculator 之前计算最终图片的一行。
MandelbrotCalculator 线程实际上是计算的总指挥。考虑一个用户在计算完成之前放大图片的情况; MandelbrotWidget 将请求新的图片给 MandelbrotCalculator,而 MandelbrotCalculator 必须在调度新任务之前取消所有当前任务。
我们将为此项目添加最后一个约束:它必须是互斥锁免费的。互斥锁是非常方便的工具,但它们迫使线程相互等待,并且容易出错。为此,我们将依赖 Qt 提供的多个概念和技术:多线程信号/槽,隐式共享等。
通过最小化线程间的共享状态,我们将能够让它们尽可能快地执行。这就是我们在这里的原因,对吧?燃烧一些 CPU 核心?
现在宏观图景已经更加清晰,我们可以开始实施。创建一个名为 ch09-mandelbrot-threadpool 的新 Qt Widget Application 项目。记得将 CONFIG += c++14 添加到 .pro 文件中。
使用 QRunnable 定义 Job 类
让我们深入到项目的核心。为了加快 Mandelbrot 图片的生成,我们将整个计算分成多个工作。一个 Job 是一个任务请求。根据您的 CPU 架构,将同时执行多个工作。Job 类生成包含结果值的 JobResult 函数。在我们的项目中,Job 类为完整图片的一行生成值。例如,800 x 600 的图像分辨率需要 600 个工作,每个工作生成 800 个值。
请创建一个名为 JobResult.h 的 C++ 头文件:
#include <QSize>
#include <QVector>
#include <QPointF>
struct JobResult
{
JobResult(int valueCount = 1) :
areaSize(0, 0),
pixelPositionY(0),
moveOffset(0, 0),
scaleFactor(0.0),
values(valueCount)
{
}
QSize areaSize;
int pixelPositionY;
QPointF moveOffset;
double scaleFactor;
QVector<int> values;
};
这个结构包含两个部分:
-
输入数据(
areaSize,pixelPositionY,...) -
由
Job类生成的结果values
我们现在可以创建 Job 类本身。使用以下 Job.h 片段创建一个 C++ 类 Job:
#include <QObject>
#include <QRunnable>
#include "JobResult.h"
class Job : public QObject, public QRunnable
{
Q_OBJECT
public:
Job(QObject *parent = 0);
void run() override;
};
这个 Job 类是一个 QRunnable,因此我们可以重写 run() 来实现 Mandelbrot 图片算法。如您所见,Job 也继承自 QObject,这允许我们使用 Qt 的信号/槽功能。算法需要一些输入数据。更新您的 Job.h 如下:
#include <QObject>
#include <QRunnable>
#include <QPointF>
#include <QSize>
#include <QAtomicInteger>
class Job : public QObject, public QRunnable
{
Q_OBJECT
public:
Job(QObject *parent = 0);
void run() override;
void setPixelPositionY(int value);
void setMoveOffset(const QPointF& value);
void setScaleFactor(double value);
void setAreaSize(const QSize& value);
void setIterationMax(int value);
private:
int mPixelPositionY;
QPointF mMoveOffset;
double mScaleFactor;
QSize mAreaSize;
int mIterationMax;
};
让我们讨论这些变量:
-
mPixelPositionY变量是图片高度索引。因为每个Job只为一条图片线生成数据,我们需要这个信息。 -
mMoveOffset变量是 Mandelbrot 原点偏移。用户可以平移图片,因此原点不总是 (0, 0)。 -
mScaleFactor变量是 Mandelbrot 缩放值。用户也可以放大图片。 -
mAreaSize变量是最终图片的像素大小。 -
mIterationMax变量是允许用于确定一个像素的 Mandelbrot 结果的迭代次数。
我们现在可以向 Job.h 添加一个信号,jobCompleted(),以及中止功能:
#include <QObject>
#include <QRunnable>
#include <QPointF>
#include <QSize>
#include <QAtomicInteger>
#include "JobResult.h"
class Job : public QObject, public QRunnable
{
Q_OBJECT
public:
...
signals:
void jobCompleted(JobResult jobResult);
public slots:
void abort();
private:
QAtomicInteger<bool> mAbort;
...
};
当算法结束时,将发出 jobCompleted() 信号。jobResult 参数包含结果值。abort() 槽将允许我们停止工作,更新 mIsAbort 标志值。请注意,mAbort 不是一个经典的 bool,而是一个 QAtomicInteger<bool>。这种 Qt 跨平台类型允许我们在不中断的情况下执行原子操作。您可以使用互斥锁或其他同步机制来完成工作,但使用原子变量是安全地从不同线程更新和访问变量的快速方法。
是时候切换到使用 Job.cpp 的实现部分了。以下是 Job 类的构造函数:
#include "Job.h"
Job::Job(QObject* parent) :
QObject(parent),
mAbort(false),
mPixelPositionY(0),
mMoveOffset(0.0, 0.0),
mScaleFactor(0.0),
mAreaSize(0, 0),
mIterationMax(1)
{
}
这是一个经典的初始化;不要忘记调用 QObject 构造函数。
我们现在可以实现 run() 函数:
void Job::run()
{
JobResult jobResult(mAreaSize.width());
jobResult.areaSize = mAreaSize;
jobResult.pixelPositionY = mPixelPositionY;
jobResult.moveOffset = mMoveOffset;
jobResult.scaleFactor = mScaleFactor;
...
}
在这个第一部分,我们初始化一个 JobResult 变量。区域大小的宽度用于将 JobResult::values 构造为具有正确初始大小的 QVector。其他输入数据从 Job 复制到 JobResult,以便 JobResult 的接收者可以使用上下文输入数据获取结果。
然后,我们可以使用 Mandelbrot 算法更新 run() 函数:
void Job::run()
{
...
double imageHalfWidth = mAreaSize.width() / 2.0;
double imageHalfHeight = mAreaSize.height() / 2.0;
for (int imageX = 0; imageX < mAreaSize.width(); ++imageX) {
int iteration = 0;
double x0 = (imageX - imageHalfWidth)
* mScaleFactor + mMoveOffset.x();
double y0 = (mPixelPositionY - imageHalfHeight)
* mScaleFactor - mMoveOffset.y();
double x = 0.0;
double y = 0.0;
do {
if (mAbort.load()) {
return;
}
double nextX = (x * x) - (y * y) + x0;
y = 2.0 * x * y + y0;
x = nextX;
iteration++;
} while(iteration < mIterationMax
&& (x * x) + (y * y) < 4.0);
jobResult.values[imageX] = iteration;
}
emit jobCompleted(jobResult);
}
曼德布罗特算法本身超出了本书的范围。但您必须理解这个run()函数的主要目的。让我们分解一下:
-
for 循环遍历一行中所有像素的
x位置 -
像素位置被转换为复平面坐标
-
如果尝试次数超过最大授权迭代次数,算法将以
iteration到mIterationMax的值结束 -
如果曼德布罗特检查条件为真,算法将以
iteration < mIterationMax结束 -
在任何情况下,对于每个像素,迭代次数都存储在
JobResult的values中 -
最后,使用此算法的结果值发出
jobCompleted()信号 -
我们使用
mAbort.load()执行原子的读取;注意,如果返回值是true,则算法被终止,并且不会发出任何内容
最后一个函数是abort()槽:
void Job::abort()
{
mAbort.store(true);
}
此方法执行原子的值写入,true。原子机制确保我们可以从多个线程调用abort()而不会干扰run()函数中的mAbort读取。
在我们的情况下,run()函数存在于受QThreadPool影响的线程中(我们很快会介绍它),而abort()槽将在MandelbrotCalculator线程上下文中被调用。
您可能想使用QMutex来确保对mAbort的操作。但是,请记住,如果频繁地进行锁定和解锁,锁定和解锁互斥锁可能会变得代价高昂。在这里使用QAtomicInteger类只提供了优势:对mAbort的访问是线程安全的,我们避免了昂贵的锁定。
Job实现的末尾只包含设置函数。如果您有任何疑问,请参阅完整的源代码。
在 MandelbrotCalculator 中使用 QThreadPool
现在当我们的Job类准备好使用时,我们需要创建一个类来管理作业。请创建一个新的类,MandelbrotCalculator。让我们看看在文件MandelbrotCalculator.h中我们需要什么:
#include <QObject>
#include <QSize>
#include <QPointF>
#include <QElapsedTimer>
#include <QList>
#include "JobResult.h"
class Job;
class MandelbrotCalculator : public QObject
{
Q_OBJECT
public:
explicit MandelbrotCalculator(QObject *parent = 0);
void init(QSize imageSize);
private:
QPointF mMoveOffset;
double mScaleFactor;
QSize mAreaSize;
int mIterationMax;
int mReceivedJobResults;
QList<JobResult> mJobResults;
QElapsedTimer mTimer;
};
我们已经在上一节中讨论了mMoveOffset、mScaleFactor、mAreaSize和mIterationMax。我们还有一些新的变量:
-
mReceivedJobResults变量是接收到的JobResult的数量,这是由作业发送的 -
mJobResults变量是一个包含接收到的JobResult的列表 -
mTimer变量计算运行请求图片所需的所有作业的经过时间
现在您对所有的成员变量有了更好的了解,我们可以添加信号、槽和私有方法。更新您的MandelbrotCalculator.h文件:
...
class MandelbrotCalculator : public QObject
{
Q_OBJECT
public:
explicit MandelbrotCalculator(QObject *parent = 0);
void init(QSize imageSize);
signals:
void pictureLinesGenerated(QList<JobResult> jobResults);
void abortAllJobs();
public slots:
void generatePicture(QSize areaSize, QPointF moveOffset,
double scaleFactor, int iterationMax);
void process(JobResult jobResult);
private:
Job* createJob(int pixelPositionY);
void clearJobs();
private:
...
};
这里是这些角色的作用:
-
generatePicture():这个槽由调用者用来请求新的曼德布罗特图片。这个函数准备并启动作业。 -
process():这个槽处理作业生成的结果。 -
pictureLinesGenerated():这个信号会定期触发以分发结果。 -
abortAllJobs():这个信号用于终止所有活动作业。 -
createJob():这是一个辅助函数,用于创建和配置一个新的作业。 -
clearJobs():这个槽移除队列中的作业并中止正在进行的作业。
头文件已完成,我们现在可以执行实现。以下是 MandelbrotCalculator.cpp 实现的开始:
#include <QDebug>
#include <QThreadPool>
#include "Job.h"
const int JOB_RESULT_THRESHOLD = 10;
MandelbrotCalculator::MandelbrotCalculator(QObject *parent)
: QObject(parent),
mMoveOffset(0.0, 0.0),
mScaleFactor(0.005),
mAreaSize(0, 0),
mIterationMax(10),
mReceivedJobResults(0),
mJobResults(),
mTimer()
{
}
和往常一样,我们使用默认值初始化列表来设置我们的成员变量。JOB_RESULT_THRESHOLD 的作用将在稍后介绍。以下是 generatePicture() 槽:
void MandelbrotCalculator::generatePicture(QSize areaSize, QPointF moveOffset, double scaleFactor, int iterationMax)
{
if (areaSize.isEmpty()) {
return;
}
mTimer.start();
clearJobs();
mAreaSize = areaSize;
mMoveOffset = moveOffset;
mScaleFactor = scaleFactor;
mIterationMax = iterationMax;
for(int pixelPositionY = 0;
pixelPositionY < mAreaSize.height(); pixelPositionY++) {
QThreadPool::globalInstance()->
start(createJob(pixelPositionY));
}
}
如果 areaSize 维度为 0x0,我们就没有什么要做的。如果请求有效,我们可以启动 mTimer 来跟踪整个生成持续时间。每次生成新图片时,首先通过调用 clearJobs() 取消现有作业。然后我们设置成员变量为提供的那些。最后,为每条垂直图片线创建一个新的 Job 类。将很快介绍返回 Job* 值的 createJob() 函数。
QThreadPool::globalInstance() 是一个静态函数,它根据我们 CPU 的核心数提供最优的全局线程池。即使我们为所有 Job 类调用 start(),也只有一个会立即启动。其他则被添加到池队列中,等待可用的线程。
现在我们来看看如何使用 createJob() 函数创建一个 Job 类:
Job* MandelbrotCalculator::createJob(int pixelPositionY)
{
Job* job = new Job();
job->setPixelPositionY(pixelPositionY);
job->setMoveOffset(mMoveOffset);
job->setScaleFactor(mScaleFactor);
job->setAreaSize(mAreaSize);
job->setIterationMax(mIterationMax);
connect(this, &MandelbrotCalculator::abortAllJobs,
job, &Job::abort);
connect(job, &Job::jobCompleted,
this, &MandelbrotCalculator::process);
return job;
}
如你所见,作业是在堆上分配的。这个操作在 MandelbrotCalculator 线程中会花费一些时间。但结果是值得的;开销被多线程系统所补偿。注意,当我们调用 QThreadPool::start() 时,线程池会接管 job 的所有权。因此,当 Job::run() 结束时,它将被线程池删除。我们设置了由 Mandelbrot 算法所需的 Job 类的输入数据。
然后执行两个连接:
-
发出我们的
abortAllJobs()信号将调用所有作业的abort()槽 -
我们的
process()槽在每次Job完成其任务时执行
最后,将 Job 指针返回给调用者,在我们的例子中,是 generatePicture() 槽。
最后一个辅助函数是 clearJobs()。将其添加到你的 MandelbrotCalculator.cpp:
void MandelbrotCalculator::clearJobs()
{
mReceivedJobResults = 0;
emit abortAllJobs();
QThreadPool::globalInstance()->clear();
}
重置接收到的作业结果计数器。我们发出信号以中止所有正在进行的作业。最后,我们移除线程池中等待可用线程的队列中的作业。
这个类的最后一个函数是 process(),可能是最重要的函数。用以下代码片段更新你的代码:
void MandelbrotCalculator::process(JobResult jobResult)
{
if (jobResult.areaSize != mAreaSize ||
jobResult.moveOffset != mMoveOffset ||
jobResult.scaleFactor != mScaleFactor) {
return;
}
mReceivedJobResults++;
mJobResults.append(jobResult);
if (mJobResults.size() >= JOB_RESULT_THRESHOLD ||
mReceivedJobResults == mAreaSize.height()) {
emit pictureLinesGenerated(mJobResults);
mJobResults.clear();
}
if (mReceivedJobResults == mAreaSize.height()) {
qDebug() << "Generated in " << mTimer.elapsed() << " ms";
}
}
这个槽将在每次作业完成其任务时被调用。首先需要检查的是当前的 JobResult 是否仍然与当前输入数据有效。当请求新的图片时,我们清除作业队列并中止正在进行的作业。然而,如果旧的 JobResult 仍然发送到这个 process() 槽,我们必须忽略它。
之后,我们可以增加 mReceivedJobResults 计数器并将此 JobResult 添加到我们的成员队列 mJobResults 中。计算器等待获取 JOB_RESULT_THRESHOLD(即 10)个结果,然后通过发出 pictureLinesGenerated() 信号来分发它们。您可以小心地尝试调整此值:
-
较低的值,例如 1,将在计算器获取每行数据后立即将每行数据发送到小部件。但是,小部件处理每行数据会比计算器慢。此外,您将淹没小部件事件循环。
-
较高的值可以缓解小部件事件循环。但用户在看到动作之前需要等待更长的时间。连续的局部帧更新可以提供更好的用户体验。
注意,当事件被分发时,包含作业结果的 QList 类是通过复制发送的。但是 Qt 对 QList 执行隐式共享,所以我们只发送浅拷贝而不是昂贵的深拷贝。然后我们清除计算器的当前 QList。
最后,如果处理过的 JobResult 是区域中的最后一个,我们将显示一个调试消息,其中包含用户调用 generatePicture() 以来经过的时间。
小贴士
Qt 小贴士
您可以使用 setMaxThreadCount(x) 设置 QThreadPool 类使用的线程数,其中 x 是线程数。
使用 MandelbrotWidget 显示分形
这里我们完成了,曼德布罗特算法已完成,多线程系统已准备好在所有 CPU 核心上计算复杂的分形。我们现在可以创建一个将所有 JobResult 转换为显示漂亮图片的小部件。创建一个新的 C++ 类 MandelbrotWidget。对于这个小部件,我们将自己处理绘图。因此,我们不需要任何 .ui Qt Designer 表单文件。让我们从 MandelbrotWidget.h 文件开始:
#include <memory>
#include <QWidget>
#include <QPoint>
#include <QThread>
#include <QList>
#include "MandelbrotCalculator.h"
class QResizeEvent;
class MandelbrotWidget : public QWidget
{
Q_OBJECT
public:
explicit MandelbrotWidget(QWidget *parent = 0);
~MandelbrotWidget();
private:
MandelbrotCalculator mMandelbrotCalculator;
QThread mThreadCalculator;
double mScaleFactor;
QPoint mLastMouseMovePosition;
QPointF mMoveOffset;
QSize mAreaSize;
int mIterationMax;
std::unique_ptr<QImage> mImage;
};
您应该能识别一些已知的变量名,例如 mScaleFactor、mMoveOffset、mAreaSize 或 mIterationMax。我们已经在 JobResult 和 Job 实现中介绍了它们。以下是真正的新变量:
-
mMandelbrotCalculator变量是我们多线程的Job管理器。小部件会向其发送请求并等待结果。 -
mThreadCalculator变量允许曼德布罗特计算器在其自己的线程中运行。 -
mLastMouseMovePosition变量被小部件用于处理用户事件,以实现平移功能。 -
mImage变量是小部件当前显示的图片。它是一个unique_ptr指针,因此MandelbrotWidget是mImage的所有者。
我们现在可以添加函数。更新您的代码如下:
class MandelbrotWidget : public QWidget
{
...
public slots:
void processJobResults(QList<JobResult> jobResults);
signals:
void requestPicture(QSize areaSize, QPointF moveOffset, double scaleFactor, int iterationMax);
protected:
void paintEvent(QPaintEvent*) override;
void resizeEvent(QResizeEvent* event) override;
void wheelEvent(QWheelEvent* event) override;
void mousePressEvent(QMouseEvent* event) override;
void mouseMoveEvent(QMouseEvent* event) override;
private:
QRgb generateColorFromIteration(int iteration);
private:
...
};
在我们深入实现之前,让我们谈谈这些函数:
-
processJobResults()函数将处理由MandelbrotCalculator分发的JobResult列表。 -
每当用户更改输入数据(偏移量、缩放或区域大小)时,都会发出
requestPicture()信号。 -
paintEvent()函数使用当前的mImage绘制小部件。 -
当用户调整窗口大小时,
resizeEvent()函数会调整曼德布罗特区域的大小。 -
wheelEvent()函数处理用户的鼠标滚轮事件以应用缩放因子。 -
mousePressEvent()函数和mouseMoveEvent()函数检索用户的鼠标事件以移动 Mandelbrot 图片。 -
generateColorFromIteration()是一个辅助函数,用于将 Mandelbrot 图片着色。将像素的迭代值转换为颜色值。
我们现在可以实现MandelbrotWidget类。以下是MandelbrotWidget.cpp文件的开始部分:
#include "MandelbrotWidget.h"
#include <QResizeEvent>
#include <QImage>
#include <QPainter>
#include <QtMath>
const int ITERATION_MAX = 4000;
const double DEFAULT_SCALE = 0.005;
const double DEFAULT_OFFSET_X = -0.74364390249094747;
const double DEFAULT_OFFSET_Y = 0.13182589977450967;
MandelbrotWidget::MandelbrotWidget(QWidget *parent) :
QWidget(parent),
mMandelbrotCalculator(),
mThreadCalculator(this),
mScaleFactor(DEFAULT_SCALE),
mLastMouseMovePosition(),
mMoveOffset(DEFAULT_OFFSET_X, DEFAULT_OFFSET_Y),
mAreaSize(),
mIterationMax(ITERATION_MAX)
{
mMandelbrotCalculator.moveToThread(&mThreadCalculator);
connect(this, &MandelbrotWidget::requestPicture,
&mMandelbrotCalculator,
&MandelbrotCalculator::generatePicture);
connect(&mMandelbrotCalculator,
&MandelbrotCalculator::pictureLinesGenerated,
this, &MandelbrotWidget::processJobResults);
mThreadCalculator.start();
}
在代码片段的顶部,我们设置了一些默认的常量值。如果您希望在启动应用程序时看到不同的视图,可以随意调整这些值。构造函数首先执行的操作是改变mMandelbrotCalculator类的线程亲和性。这样,计算器执行的处理(创建和启动任务、汇总任务结果以及清除任务)不会干扰 UI 线程。然后,我们与MandelbrotCalculator的信号和槽进行连接。由于小部件和计算器有不同的线程亲和性,连接将自动成为Qt::QueuedConnection槽。最后,我们可以启动mThreadCalculator的线程。现在我们可以添加析构函数:
MandelbrotWidget::~MandelbrotWidget()
{
mThreadCalculator.quit();
mThreadCalculator.wait(1000);
if (!mThreadCalculator.isFinished()) {
mThreadCalculator.terminate();
}
}
我们需要请求计算器线程退出。当计算器线程的事件循环处理我们的请求时,线程将返回代码 0。我们等待 1,000 毫秒以等待线程结束。我们可以继续实现所有请求新图片的情况。以下是resizeEvent()槽的实现:
void MandelbrotWidget::resizeEvent(QResizeEvent* event)
{
mAreaSize = event->size();
mImage = std::make_unique<QImage>(mAreaSize,
QImage::Format_RGB32);
mImage->fill(Qt::black);
emit requestPicture(mAreaSize, mMoveOffset, mScaleFactor,
mIterationMax);
}
我们使用新的小部件大小更新mAreaSize。然后,创建一个新的具有正确尺寸的黑色QImage。最后,我们请求MandelbrotCalculator进行图片计算。让我们看看如何处理鼠标滚轮:
void MandelbrotWidget::wheelEvent(QWheelEvent* event)
{
int delta = event->delta();
mScaleFactor *= qPow(0.75, delta / 120.0);
emit requestPicture(mAreaSize, mMoveOffset, mScaleFactor,
mIterationMax);
}
可以从QWheelEvent::delta()中检索鼠标滚轮值。我们使用幂函数在mScaleFactor上应用一个连贯的值,并请求一张更新后的图片。现在我们可以实现平移功能:
void MandelbrotWidget::mousePressEvent(QMouseEvent* event)
{
if (event->buttons() & Qt::LeftButton) {
mLastMouseMovePosition = event->pos();
}
}
第一个函数存储用户开始移动手势时的鼠标位置。然后下一个函数将使用mLastMouseMovePosition来创建一个偏移量:
void MandelbrotWidget::mouseMoveEvent(QMouseEvent* event)
{
if (event->buttons() & Qt::LeftButton) {
QPointF offset = event->pos() - mLastMouseMovePosition;
mLastMouseMovePosition = event->pos();
offset.setY(-offset.y());
mMoveOffset += offset * mScaleFactor;
emit requestPicture(mAreaSize, mMoveOffset, mScaleFactor,
mIterationMax);
}
}
新旧鼠标位置之间的差异给我们提供了平移偏移量。请注意,我们反转了 y 轴的值,因为鼠标事件是在一个左上参照系中,而 Mandelbrot 算法依赖于一个左下参照系。最后,我们使用更新后的输入值请求一张图片。我们已经涵盖了所有发出requestPicture()信号的用戶事件。现在让我们看看我们如何处理由MandelbrotCalculator分发的JobResult:
void MandelbrotWidget::processJobResults(QList<JobResult> jobResults)
{
int yMin = height();
int yMax = 0;
for(JobResult& jobResult : jobResults) {
if (mImage->size() != jobResult.areaSize) {
continue;
}
int y = jobResult.pixelPositionY;
QRgb* scanLine =
reinterpret_cast<QRgb*>(mImage->scanLine(y));
for (int x = 0; x < mAreaSize.width(); ++x) {
scanLine[x] =
generateColorFromIteration(jobResult.values[x]);
}
if (y < yMin) {
yMin = y;
}
if (y > yMax) {
yMax = y;
}
}
repaint(0, yMin,
width(), yMax);
}
计算器发送给我们一个 QList 的 JobResult。对于每一个,我们需要检查相关区域的大小是否仍然有效。我们直接更新 mImage 的像素颜色。scanLine() 函数返回像素数据的指针。这是一种快速更新 QImage 像素颜色的方法。JobResult 函数包含迭代次数,我们的辅助函数 generateColorFromIteration() 根据迭代值返回一个 RGB 值。不需要完全重绘小部件,因为我们只更新 QImage 的几行。因此,我们只重绘更新区域。
这里是如何将迭代值转换为 RGB 值的:
QRgb MandelbrotWidget::generateColorFromIteration(int iteration)
{
if (iteration == mIterationMax) {
return qRgb(50, 50, 255);
}
return qRgb(0, 0, (255.0 * iteration / mIterationMax));
}
为曼德布罗集上色本身就是一种艺术。在这里,我们在蓝色通道上实现了一种简单的线性插值。一个漂亮的曼德布罗集图片取决于每个像素的最大迭代次数及其着色技术。你可以随意增强它!
到这里了,最后一个但同样重要的函数,paintEvent():
void MandelbrotWidget::paintEvent(QPaintEvent* event)
{
QPainter painter(this);
painter.save();
QRect imageRect = event->region().boundingRect();
painter.drawImage(imageRect, *mImage, imageRect);
painter.setPen(Qt::white);
painter.drawText(10, 20, QString("Size: %1 x %2")
.arg(mImage->width())
.arg(mImage->height()));
painter.drawText(10, 35, QString("Offset: %1 x %2")
.arg(mMoveOffset.x())
.arg(mMoveOffset.y()));
painter.drawText(10, 50, QString("Scale: %1")
.arg(mScaleFactor));
painter.drawText(10, 65, QString("Max iteration: %1")
.arg(ITERATION_MAX));
painter.restore();
}
我们必须重写这个函数,因为我们自己处理小部件的绘制。首先要做的是绘制图像的更新区域。QPaintEvent 对象包含需要更新的区域。QPainter 类使绘制变得简单。最后,我们用白色绘制一些当前输入数据的文本信息。你现在可以逐行查看完整的图片显示概览。让我们总结一下这个功能的流程:
-
每个
Job::run()生成一个JobResult对象。 -
MandelbrotCalculator::process()信号聚合JobResult对象并将它们按组(默认为 10 组)分发。 -
MandelbrotWidget::processJobResults()信号只更新图片的相关行,并请求小部件的部分重绘。 -
MandelbrotWidget::paintEvent()信号只重新绘制带有新值的图片。
这个功能会产生一点开销,但用户体验更平滑。确实,应用程序对用户事件反应迅速:前几行几乎立即更新。用户不需要等待整个图片生成才能看到变化。
小部件已经准备好了;不要忘记将其添加到 MainWindow。现在提升自定义小部件应该对你来说是个简单的任务。如果你有任何疑问,请查看第四章,“征服桌面 UI”,或本章的完整源代码。你现在应该能够显示并导航到你的多线程曼德布罗集了!
如果你启动应用程序,你应该会看到类似这样的内容:

现在尝试放大并平移到曼德布罗集。你应该会找到一些有趣的地方,就像这样:

概述
您已经了解了QThread类的工作原理,并学习了如何高效地使用 Qt 提供的工具来创建强大的多线程应用程序。您的 Mandelbrot 应用程序能够利用 CPU 的所有核心快速计算图片。
创建一个多线程应用程序存在许多陷阱(死锁、事件循环泛滥、孤儿线程、开销等)。应用程序架构非常重要。如果您能够隔离您想要并行化的重代码,一切应该都会顺利。然而,用户体验是最重要的;如果您的应用程序能够给用户带来更平滑的感觉,有时您可能不得不接受一点开销。
在下一章中,我们将探讨几种在应用程序之间实现进程间通信(IPC)的方法。项目示例将使用 TCP/IP 套接字系统增强您当前的 Mandelbrot 应用程序。因此,Mandelbrot 生成器将能够在多台计算机的多个 CPU 核心上计算图片!
第十章。需要 IPC?让你的小兵开始工作
在上一章中,你学习了如何在同一进程的线程之间发送信息。在本章中,你将发现如何在不同进程的线程之间共享数据。我们甚至将在不同物理计算机上运行的应用程序之间共享信息。我们将增强第九章中提到的 Mandelbrot 生成器应用程序,即通过多线程保持理智。现在,Mandelbrot 应用程序将只显示由工作程序处理的结果。这些小兵只有一个任务:尽可能快地计算任务并返回结果给主应用程序。
本章涵盖了以下主题:
-
两个应用程序如何相互通信
-
创建一个多线程 TCP 服务器
-
在 TCP 套接字上读写
-
其他 IPC 技术,如
QSharedMemory、QProcess和 Qt D-Bus -
使用
QDataStream进行网络序列化 -
计算机集群
-
进程间通信技术
IPC(进程间通信)是两个或更多进程之间的通信。它们可以是同一应用或不同应用的实例。Qt 框架提供了多个模块来帮助你实现应用程序之间的通信。大多数这些模块都是跨平台的。让我们来谈谈 Qt 提供的 IPC 工具。
第一工具是 TCP/IP 套接字。它们在网络中提供双向数据交换。因此,你可以使用它们与不同计算机上的进程通信。此外,loopback接口允许你与同一计算机上运行的进程通信。所有必需的类都在QtNetwork模块中。这种技术依赖于客户端-服务器架构。以下是一个服务器部分的示例:
QTcpServer* tcpServer = new QTcpServer(this);
tcpServer->listen(QHostAddress::Any, 5000);
connect(tcpServer, &QTcpServer::newConnection, [tcpServer] {
QTcpSocket *tcpSocket = tcpServer->nextPendingConnection();
QByteArray response = QString("Hello").toLatin1();
tcpSocket->write(response);
tcpSocket->disconnectFromHost();
qDebug() << "Send response and close the socket";
});
第一步是实例化一个QTcpServer类。它处理新的传入 TCP 连接。然后,我们调用listen()函数。你可以提供一个网络接口并指定服务器必须监听传入连接的端口。在这个例子中,我们在端口5000上监听所有网络地址(例如,127.0.0.1、192.168.1.4等等)。当客户端与这个服务器建立连接时,会触发QTcpServer::newConnection()信号。让我们一起分析这个 lambda 槽:
-
我们使用与这个新连接相关的
QTcpSocket类来检索客户端。 -
准备一个包含 ASCII 消息“Hello”的
QByteArray响应。不要在意原创性的缺乏。 -
消息通过套接字发送到客户端。
-
最后,我们关闭套接字。因此,在这个客户端,将会断开连接。
小贴士
你可以使用 Windows 上的 PuTTY 或 Linux 和 Mac OS 上的telnet命令这样的 telnet 工具来测试QTcpServer类。
以下片段是客户端部分:
QTcpSocket *tcpSocket = new QTcpSocket(this);
tcpSocket->connectToHost("127.0.0.1", 5000);
connect(tcpSocket, &QTcpSocket::connected, [tcpSocket] {
qDebug() << "connected";
});
connect(tcpSocket, &QTcpSocket::readyRead, [tcpSocket] {
qDebug() << QString::fromLatin1(tcpSocket->readAll());
});
connect(tcpSocket, &QTcpSocket::disconnected, [tcpSocket] {
qDebug() << "disconnected";
});
客户端也使用QTcpSocket类进行通信。结果是连接是由客户端发起的,因此我们需要使用服务器地址和端口调用connectToHost()函数。这个类提供了一些有用的信号,如connected()和disconnected(),它们指示连接状态。当有新数据可供读取时,会发出readyRead()信号。readAll()函数返回包含所有可用数据的QByteArray。在我们的例子中,我们知道服务器向其客户端发送 ASCII 消息。因此,我们可以将这个字节数组转换为QString并显示它。
对于这个例子,服务器在 TCP 套接字中写入,客户端读取。但是这种通信是双向的,所以客户端也可以写入数据,服务器可以读取它。尝试从客户端发送数据并在服务器上显示。请注意,您需要通过在服务器部分移除disconnectFromHost()调用来保持通信活跃。
Qt 框架提供了一个辅助类QDataStream,可以轻松发送复杂对象并处理数据包分段。这个概念将在本章的项目示例中稍后介绍。
让我们谈谈第二种 IPC 技术:共享内存。默认情况下,不同的进程不使用相同的内存空间。QSharedMemory类提供了一个跨平台的方法,可以在多个进程之间创建和使用共享内存。尽管如此,这些进程必须在同一台计算机上运行。共享内存由一个键标识。所有进程都必须使用相同的键来共享相同的共享内存段。第一个进程将创建共享内存段并将数据放入其中:
QString sharedMessage("Hello");
QByteArray sharedData = sharedMessage.toLatin1();
QSharedMemory* sharedMemory = new QSharedMemory(
"sharedMemoryKey", this);
sharedMemory->create(sharedMessage.size());
sharedMemory->lock();
memcpy(sharedMemory->data(),
sharedData.data(),
sharedData.size());
sharedMemory->unlock();
让我们一起分析所有步骤:
-
再次,我们想要共享将
QString"Hello"转换为QByteArray类。 -
使用键
sharedMemoryKey初始化了一个QSharedMemory类。第二个进程也应该使用这个相同的键。 -
第一个进程使用特定字节的尺寸创建共享内存段。创建过程也将进程附加到共享内存段。
-
您现在应该对锁定/解锁系统有信心。
QSharedMemory类使用信号量来保护共享访问。在操作共享内存之前,您必须锁定它。 -
使用经典的
memcpy()函数将数据从QByteArray类复制到QSharedMemory类。 -
最后,我们可以解锁共享内存。
销毁QShareMemory类将调用detach()函数,该函数将进程从共享内存段中分离出来。如果这个进程是最后一个附加的进程,detach()也会销毁共享内存段。当一个附加的QShareMemory对象存活时,共享内存段对其他进程可用。下面的片段描述了第二个段如何访问共享内存:
QSharedMemory* sharedMemory = new QSharedMemory(
"sharedMemoryKey", this);
sharedMemory->attach();
sharedMemory->lock();
QByteArray sharedData(sharedMemory->size(), '\0');
memcpy(sharedData.data(),
sharedMemory->data(),
sharedMemory->size());
sharedMemory->unlock();
QString sharedMessage = QString::fromLatin1(sharedData);
qDebug() << sharedMessage;
sharedMemory->detach();
这里是关键步骤:
-
与第一个进程一样,第二个进程使用键
sharedMemoryKey初始化了一个QShareMemory类。 -
然后我们使用
attach()函数将进程附加到共享内存段。 -
在访问
QShareMemory类之前,我们必须锁定它。 -
我们使用空字符
\0初始化一个QByteArray,其大小与共享内存相同。 -
memcpy()函数将数据从QShareMemory复制到QByteArray。 -
我们可以将
QByteArray转换为QString并显示我们的消息。 -
最后一步是调用
detach()函数,将进程从共享内存段中分离出来。
请注意,create()和attach()函数默认指定QShareMemory::ReadWrite访问权限。你也可以使用QShareMemory::ReadOnly访问权限。
小贴士
你可以使用QBuffer和QDataStream类将复杂对象序列化到或从字节数组中。
另一种 IPC 方法是使用QProcess类。主进程以子进程的形式启动外部应用。通信是通过标准输入和输出设备完成的。让我们创建一个依赖于标准输入和输出通道的hello控制台应用:
QTextStream out(stdout);
QTextStream in(stdin);
out << QString("Please enter your name:\n");
out.flush();
QString name = in.readLine();
out << "Hello " << name << "\n";
return 0;
我们使用QTextStream类轻松地处理标准流,stdout和stdin。应用打印消息请输入你的名字:。然后我们等待用户通过调用readLine()函数输入他的名字。最后,程序显示消息Hello和用户name。如果你自己启动这个控制台应用,你必须按键盘输入你的名字才能看到带有你名字的最终问候信息。
以下代码片段运行并与hello应用进行通信。此外,我们可以通过编程方式控制子hello应用:
QProcess* childProcess = new QProcess(this);
connect(childProcess,
&QProcess::readyReadStandardOutput, [childProcess] {
qDebug().noquote() << "[*]" << childProcess->readAll();
});
connect(childProcess, &QProcess::started, [childProcess] {
childProcess->write("Sophie\n");
});
childProcess->start("/path/to/hello");
这里是主应用执行的所有步骤:
-
我们初始化一个可以启动外部应用的
QProcess对象。 -
子进程在控制台上显示消息,因此写入标准输出。然后,发送
readyReadStandardOutput()信号。在这种情况下,我们以带有前缀[*]的调试文本形式打印消息,以标识它来自子进程。 -
子进程启动后,立即发送
started()信号。在我们的例子中,我们在子进程的标准输入中写入名字Sophie(Lenna 会嫉妒的!)。 -
一切准备就绪,我们可以使用
QProcess类的路径启动hello控制台应用。
如果你启动主应用,你应该在其控制台中看到以下结果:
[*] Please enter your name:
[*] Hello Sophie
任务完成!主要应用是对hello应用的包装。我们接收来自子进程的所有消息,并且可以发送一些信息,比如特定的名字。
小贴士
QProcess::start()函数还接受第二个变量:子进程的命令行参数。
我们将要一起覆盖的最后一种 IPC 机制是D-Bus 协议。目前,Qt D-Bus 模块仅在 Linux 上官方支持。如果您需要在 Windows 上使用它,您将不得不从 Qt 源代码编译它。它可以被视为 IPC 和RPC(远程过程调用)的统一协议。可能的通信形式很多,例如:
-
一对一
-
一对多
-
多对多
Qt D-Bus 最好的事情是您甚至可以在总线上使用信号/槽机制。一个应用程序发出的信号可以连接到另一个应用程序的槽。Linux 桌面环境,如 KDE 和 GNOME 使用 D-Bus。这意味着您可以用 D-Bus(也)控制您的桌面。
这里是 D-Bus 的主要概念:
-
总线:这在多对多通信中使用。D-Bus 定义了两个总线:系统总线和会话总线。 -
服务名称:这是总线上服务的标识符。 -
消息:这是由一个应用程序发送的消息。如果使用总线,则消息包含目的地。
一个 Qt D-Bus 查看器工具可以在您的 Qt 安装文件夹中找到(例如,/Qt/5.7/gcc_64/bin/qdbusviewer)。所有服务对象和消息都显示在系统总线和服务总线上。尝试调用公开的方法并检索结果。
现在您已经玩弄了您的 Linux D-Bus 服务,是时候创建您自己的了!首先,我们将创建一个简单的HelloService对象:
//HelloService.h
class HelloService : public QObject
{
Q_OBJECT
public slots:
QString sayHello(const QString &name);
};
//HelloService.cpp
QString HelloService::sayHello(const QString& name)
{
qDebug().noquote() << name << " is here!";
return QString("Hello %1!").arg(name);;
}
这里没什么大不了的,只有一个需要name参数的公共槽,显示谁在这里,并返回一个问候消息。在下面的代码片段中,主应用注册了一个新的 D-Bus 服务和HelloService对象:
HelloService helloService;
QString serviceName("org.masteringqt.QtDBus.HelloService");
QDBusConnection::sessionBus().registerService(serviceName);
QDBusConnection::sessionBus().registerObject("/",
&helloService, QDBusConnection::ExportAllSlots);
主要应用初始化一个HelloService对象。然后,我们在会话总线上注册了一个名为org.masteringqt.QtDBus.HelloService的新服务。最后,我们注册了HelloService对象,暴露了它所有的槽。注意这个示例中使用的简单对象路径/。服务应用部分完成。以下是调用HelloService对象的客户端应用:
QString serviceName("org.masteringqt.QtDBus.HelloService");
QDBusInterface serviceInterface(serviceName, "/");
QDBusReply<QString> response = serviceInterface.call(
"sayHello", "Lenna");
qDebug().noquote() << response;
让我们逐步分析客户端部分:
-
我们使用与服务应用相同的服务名称和路径初始化一个
QDBusInterface对象。 -
我们调用
HelloService上的远程方法sayHello(),参数为Lenna(等等,Sophie 在哪里!?)。 -
响应存储在
QDBusReply对象中。在我们的例子中,类型为QString。 -
最后,我们显示由
HelloService对象生成的消息。
如果您先启动服务应用,然后启动客户端应用,您应该得到以下控制台输出:
//service application output
Lenna is here!
//client application output
Hello Lenna!
使用QDBusViewer工具查找您的 D-Bus 服务。选择会话总线选项卡。在列表中选择您的服务。然后您可以选择sayHello方法。右键单击它允许您调用该方法。一个输入弹出窗口会要求您填写方法参数,在我们的例子中是一个名字。以下截图显示了它的样子(看起来 Sophie 在这里):

架构化 IPC 项目
来自第九章 “保持你的理智:多线程” 的曼德布罗特图像生成器使用您计算机的所有核心来加速计算。这次,我们想要使用您所有计算机的所有核心!首先要做的事情是选择一个合适的 IPC 技术。对于这个项目示例,我们想要在运行主应用程序的服务器与充当工作进程的多个客户端之间建立通信。TCP/IP 套接字允许一对多通信。此外,这种 IPC 方法不受单个计算机的限制,可以通过网络在多台计算机上操作。这个项目示例通过实现多线程 TCP 服务器来使用套接字。
下一个图描述了架构:

让我们谈谈每个角色的全局作用:
-
mandelbrot-app:这是主应用程序,用于显示曼德布罗特图像和处理用户鼠标事件。然而,在本章中,应用程序本身不计算算法,而是生成连接到工作进程的请求。然后,它汇总工作进程提供的结果。 -
mandelbrot-worker:这里是我们的小弟!一个工作进程是一个独立的程序。它通过 TCP 套接字连接到mandelbrot-app。工作进程接收请求,计算任务,并将结果发送回去。 -
SDK:它将两个应用程序共同使用的常用功能重新组合。如果 SDK 发生变化,所有依赖的应用程序都必须更新。
如您所见,这种架构很好地适应了本项目所需的一对多通信。mandelbrot-app 应用程序可以使用一个或多个工作进程来生成相同的曼德布罗特图像。
现在您已经了解了整体情况,让我们详细看看每个模块。您可以在以下图中看到 SDK 中的所有类:

当您有多个模块(应用程序、库等)需要相互通信或执行相同操作时,SDK 是必不可少的。您可以将 SDK 交给第三方开发者,而不会损害您的主要源代码。在我们的项目中,mandelbrot-app 和 mandelbrot-worker 通过交换 Message 来进行通信。消息结构必须为双方所知。Message 类包含一个 type 和一个 QByteArray 类型的原始 data。根据消息 type,原始数据可以是空的,也可以包含一个对象。在这个项目中,消息 data 可以是一个 JobRequest 或一个 JobResult。mandelbrot-app 向 mandelbrot-worker 发送一个 JobRequest。然后,工作进程返回 JobResult 给主应用程序。最后,MessageUtils 包含主应用程序和工作进程用于发送和检索 Message 的函数。
我们现在可以更详细地讨论 mandelbrot-worker。下一个图解描述了它:

mandelbrot-worker 程序能够使用机器的所有 CPU 核心。套接字机制允许我们同时运行在多个物理机器上。WorkerWidget 类显示 Worker 对象的状态。Worker 对象使用 QTcpSocket 与 mandelbrot-app 进行通信。一个 Job 是一个计算任务的 QRunnable 类。以下是该软件的工作流程:
-
向
mandelbrot-app应用程序发送注册Message。 -
从
mandelbrot-app接收一些JobRequest并创建几个Job实例以完成所有任务。 -
每个
Job都在一个专用的线程中运行,并将生成一个JobResult。 -
将
JobResult发送到mandelbrot-app。 -
在退出时,向
mandelbrot-app发送注销Message。
现在是时候讨论 mandelbrot-app 架构了。看看下一个图解:

这是主要的应用。你可以在一个 CPU 性能较弱的电脑上启动它,而真正的重活是由运行mandelbrot-worker软件的工作者完成的。GUI MainWindow 和 MandelbrotWidget 对象与第九章中的相同,通过多线程保持理智。在这个项目中,MandelbrotCalculator 类略有不同,因为它本身不运行任何 QRunnable。它是一个 TCP 服务器,处理所有已注册的工作者并将任务分配给这些任务。每个 mandelbrot-worker 都由一个带有专用 QTcpSocket 的 WorkerClient 对象实例管理。以下是 mandelbrot-app 的工作流程:
-
在特定端口上运行 TCP 服务器。
-
接收注册
Message并为每个注册的工作者创建一个WorkerClient对象。 -
当
MandelbrotWidget请求生成图片时,MandelbrotCalculator创建计算完整曼德布罗特图片所需的JobRequest对象。 -
将
JobRequest对象发送给工作者。 -
从
mandelbrot-worker接收并汇总JobResult。 -
将
JobResult传输到显示图片的MandelbrotWidget对象。 -
如果收到来自工作者的注销
Message,则释放WorkerClient对象,并且这个工作者将不再用于图片生成。
现在,你应该对这个项目架构有一个完整的了解。我们可以开始这个项目的实现了。创建一个名为 ch10-mandelbrot-ipc 的 Subdirs 项目。正如你可能猜到的,我们现在创建了两个子项目:mandelbrot-app 和 mandelbrot-worker。
后续章节中的实现遵循架构展示的顺序:
-
SDK。
-
mandelbrot-worker。 -
mandelbrot-app。
实现的复杂性有所增加。不要犹豫,休息一下,然后回到这一节,以保持整体架构清晰。
使用 SDK 奠定基础
第一步是实现将在我们的应用程序和工作者之间共享的类。为此,我们将依赖一个自定义 SDK。如果您需要刷新对这个技术的记忆,请查看第八章,动画 - 它是活生生的,活生生的!。
作为提醒,以下是描述 SDK 的图示:

让我们描述一下这些组件的职责:
-
Message组件封装了应用程序和工作者之间交换的信息 -
JobRequest组件包含将适当工作分配给工作者的必要信息 -
JobResult组件包含针对给定行的 Mandelbrot 集计算的输出结果 -
MessageUtils组件包含用于在 TCP 套接字间序列化/反序列化数据的辅助函数
所有这些文件都必须可以从我们 IPC 机制的每一侧访问(应用程序和工作者)。请注意,SDK 将仅包含头文件。我们故意这样做是为了简化 SDK 的使用。
让我们从 sdk 目录中的 Message 实现开始。创建一个包含以下内容的 Message.h 文件:
#include <QByteArray>
struct Message {
enum class Type {
WORKER_REGISTER,
WORKER_UNREGISTER,
ALL_JOBS_ABORT,
JOB_REQUEST,
JOB_RESULT,
};
Message(const Type type = Type::WORKER_REGISTER,
const QByteArray& data = QByteArray()) :
type(type),
data(data)
{
}
~Message() {}
Type type;
QByteArray data;
};
首先要注意的是 enum class Type,它详细说明了所有可能的消息类型:
-
WORKER_REGISTER:这是工作者首次连接到应用程序时发送的消息。消息的内容仅是工作者 CPU 的核心数。我们很快就会看到这为什么有用。 -
WORKER_UNREGISTER:这是工作者断开连接时发送的消息。这使应用程序知道它应该从其列表中删除此工作者并停止向其发送任何消息。 -
ALL_JOBS_ABORT:这是应用程序在每次取消图片生成时发送的消息。然后工作者负责取消其所有当前本地线程。 -
JOB_REQUEST:这是应用程序发送以计算所需图片特定行的消息。 -
JOB_RESULT:这是工作者从JOB_REQUEST输入中计算出的结果发送的消息。
简单介绍一下 enum 类类型,它是 C++11 的一个新增功能。它是枚举(有些人可能会说它应该是枚举)的一个更安全版本:
-
值的作用域是局部的。在这个例子中,你只能使用语法
Message::Type::WORKER_REGISTER引用enum值;没有更多的Message::WORKER_REGISTER快捷方式。这个限制的好处是,你不需要在enum值前加上MESSAGE_TYPE_前缀,以确保名称不会与其他任何内容冲突。 -
没有到
int的隐式转换。enum类表现得像一个真实类型,要将enum类转换为int,你必须写static_cast<int>( Message::Type::WORKER_REGISTER)。 -
没有前向声明。你可以指定一个
enum class是 char 类型(语法为enum class Test : char { ... }),但编译器无法通过前向声明推断出enum类的大小。因此,它被简单地禁止了。
我们尽可能使用enum类,这意味着当它不与 Qt enum使用冲突时。
如您所见,消息只有两个成员:
-
type:这是我们刚刚描述的消息类型 -
data:这是一个不透明的类型,包含要传输的信息片段
我们选择使data非常通用,将序列化/反序列化的责任放在Message调用者身上。根据消息type,他们应该知道如何读取或写入消息内容。
通过使用这种方法,我们避免了与MessageRegister、MessageUnregister等纠缠不清的类层次结构。添加一个新的Message 类型只需在Type enum class中添加一个值,并在data中进行适当的序列化/反序列化(你无论如何都必须这样做)。
要在 Qt Creator 中查看文件,请务必在ch10-mandelbrot-ipc.pro文件中添加Message.h:
OTHER_FILES += \
sdk/Message.h
我们接下来要查看的下一个头文件是JobRequest.h:
#include <QSize>
#include <QPointF>
struct JobRequest
{
int pixelPositionY;
QPointF moveOffset;
double scaleFactor;
QSize areaSize;
int iterationMax;
};
Q_DECLARE_METATYPE(JobRequest)
// In ch10-mandelbrot-ipc
OTHER_FILES += \
sdk/Message.h \
sdk/JobRequest.h
这个struct元素包含了工作线程计算目标 Mandelbrot 图片一行所需的所有必要数据。因为应用程序和工作线程将存在于不同的内存空间(甚至不同的物理机器),因此必须以某种方式传输计算 Mandelbrot 集的参数。这就是JobRequest的目的。每个字段的意义与第九章中的JobResult相同,使用多线程保持理智。
注意Q_DECLARE_METATYPE(JobRequest)宏的存在。这个宏用于让 Qt 元对象系统了解JobRequest。这是能够与QVariant一起使用类所必需的。我们不会直接使用QVariant,而是通过使用依赖于QVariant的QDataStream。
说到JobResult,这里是新版的JobResult.h:
#include <QSize>
#include <QVector>
#include <QPointF>
struct JobResult
{
JobResult(int valueCount = 1) :
areaSize(0, 0),
pixelPositionY(0),
moveOffset(0, 0),
scaleFactor(0.0),
values(valueCount)
{
}
QSize areaSize;
int pixelPositionY;
QPointF moveOffset;
double scaleFactor;
QVector<int> values;
};
Q_DECLARE_METATYPE(JobResult)
// In ch10-mandelbrot-ipc
OTHER_FILES += \
sdk/Message.h \
sdk/JobRequest.h \
sdk/JobResult.h
新版本是无耻地复制粘贴(添加了小的Q_DECLARE_METATYPE)了第九章的项目示例,使用多线程保持理智。
使用 QDataStream 和 QTcpSocket
SDK 缺少的部分是MesssageUtils。它值得一个专门的章节,因为它涵盖了两个主要主题:序列化和QDataStream事务。
我们将从序列化开始。我们已经知道Message只存储一个不透明的QByteArray数据成员。因此,在传递给Message之前,所需的数据必须被序列化为QByteArray。
如果我们以 JobRequest 对象为例,它不是直接发送的。我们首先将其放入一个通用的 Message 对象中,并指定适当的 Message 类型。以下图表总结了需要执行的操作序列:

JobRequest 对象首先序列化到一个 QByteArray 类;然后将其传递给一个 Message 实例,该实例随后被序列化到一个最终的 QByteArray。反序列化过程与此序列(从右到左)完全相同。
序列化数据带来了许多问题。我们如何以通用方式执行它?我们如何处理可能的 CPU 架构的端序?我们如何指定数据长度以便正确反序列化?
一次又一次,Qt 团队做得非常出色,为我们提供了一款处理这些问题的强大工具:QDataStream。
QDataStream 类允许您将二进制数据序列化到任何 QIODevice(QAbstractSocket、QProcess、QFileDevice、QSerialPort 等)。QDataStream 的巨大优势在于它以平台无关的格式编码信息。您无需担心字节序、操作系统或 CPU。
QDataStream 类实现了 C++ 原始类型和几个 Qt 类型(QBrush、QColor、QString 等)的序列化。以下是一个基本写入的示例:
QFile file("myfile");
file.open(QIODevice::WriteOnly);
QDataStream out(&file);
out << QString("QDataStream saved my day");
out << (qint32)42;
如您所见,QDataStream 依赖于 << 操作符的重载来写入数据。为了读取信息,以正确的模式打开文件,并使用 >> 操作符读取。
回到我们的案例;我们想要序列化自定义类,如 JobRequest。为了做到这一点,我们必须重载 JobRequest 的 << 操作符。函数的签名将如下所示:
QDataStream& operator<<(QDataStream& out,
const JobRequest& jobRequest)
我们在这里写的是,我们想要重载 out << jobRequest 操作符调用,使用我们的自定义版本。通过这样做,我们的意图是将 out 对象填充为 jobRequest 的内容。因为 QDataStream 已经支持原始类型的序列化,我们只需要序列化它们。
这里是 JobRequest.h 的更新版本:
#include <QSize>
#include <QPointF>
#include <QDataStream>
struct JobRequest
{
...
};
inline QDataStream& operator<<(QDataStream& out,
const JobRequest& jobRequest)
{
out << jobRequest.pixelPositionY
<< jobRequest.moveOffset
<< jobRequest.scaleFactor
<< jobRequest.areaSize
<< jobRequest.iterationMax;
return out;
}
inline QDataStream& operator>>(QDataStream& in,
JobRequest& jobRequest)
{
in >> jobRequest.pixelPositionY;
in >> jobRequest.moveOffset;
in >> jobRequest.scaleFactor;
in >> jobRequest.areaSize;
in >> jobRequest.iterationMax;
return in;
}
我们很容易就包含了 QDataStream 并重载了 << 操作符。返回的 out 将会更新为传递的 jobRequest 的平台无关内容。>> 操作符的重载遵循相同的模式:我们将 jobRequest 参数填充为 in 变量的内容。在幕后,QDataStream 将变量大小存储在序列化数据中,以便之后能够读取。
注意以相同的顺序序列化和反序列化成员。如果您不留意这一点,您可能会在 JobRequest 中遇到非常奇特的数据。
JobResult 操作符的重载遵循相同的模式,并且不值得包含在本章中。如果您对其实现有任何疑问,请查看项目的源代码。
另一方面,需要覆盖 Message 操作符的重载:
#include <QByteArray>
#include <QDataStream>
#include <QByteArray>
#include <QDataStream>
struct Message {
...
};
inline QDataStream &operator<<(QDataStream &out, const Message &message)
{
out << static_cast<qint8>(message.type)
<< message.data;
return out;
}
inline QDataStream &operator>>(QDataStream &in, Message &message)
{
qint8 type;
in >> type;
in >> message.data;
message.type = static_cast<Message::Type>(type);
return in;
}
由于Message::Type枚举类信号没有隐式转换为int,我们需要显式转换它才能进行序列化。我们知道消息类型不会超过 255 种,因此我们可以安全地将它转换为qint8类型。
相同的故事也适用于读取部分。我们首先声明一个qint8类型的变量,该变量将被in >> type填充,然后,将type变量转换为message中的Message::Type。
我们的 SDK 类已经准备好进行序列化和反序列化。让我们在MessageUtils中看看它是如何工作的,我们将对一条消息进行序列化并将其写入QTcpSocket类。
总是在sdk目录下,创建一个包含以下内容的MessageUtils.h头文件:
#include <QByteArray>
#include <QTcpSocket>
#include <QDataStream>
#include "Message.h"
namespace MessageUtils {
inline void sendMessage(QTcpSocket& socket,
Message::Type messageType,
QByteArray& data,
bool forceFlush = false)
{
Message message(messageType, data);
QByteArray byteArray;
QDataStream stream(&byteArray, QIODevice::WriteOnly);
stream << message;
socket.write(byteArray);
if (forceFlush) {
socket.flush();
}
}
没有必要实例化MessageUtils类,因为它不持有任何状态。在这里,我们使用MessageUtils命名空间来简单地保护我们的函数免受任何名称冲突的影响。
段落的精髓在于sendMessage()函数。让我们看看它的参数:
-
socket: 这是将要发送消息的QTcpSocket类。确保它被正确打开是调用者的责任。 -
messageType: 这是将要发送的消息类型。 -
data: 这是包含在消息中的序列化数据。它是一个QByteArray类,意味着调用者已经序列化其自定义类或数据。 -
forceFlush: 这是一个标志,用于在消息发送时强制套接字刷新。操作系统保留套接字缓冲区,等待填充后再通过网络发送。有些消息需要立即发送,比如中止所有作业的消息。
在函数本身中,我们首先使用传入的参数创建一个消息。然后,创建一个QByteArray类。这个byteArray将是序列化数据的容器。
实际上,byteArray是在QDataStream流构造函数中传入的,该流以QIODevice::WriteOnly模式打开。这意味着流将输出其数据到byteArray。
之后,消息通过stream << message优雅地序列化到流中,并且修改后的byteArray通过socket.write(byteArray)写入套接字。
最后,如果forceFlush标志设置为true,则使用socket.flush()刷新套接字。
一些消息可能没有任何负载相关联。因此,我们添加了一个小型辅助函数来完成这个目的:
inline void sendMessage(QTcpSocket& socket,
Message::Type messageType,
bool forceFlush = false) {
QByteArray data;
sendMessage(socket, messageType, data, forceFlush);
}
现在已经完成了sendMessage(),让我们转向readMessages()。因为我们正在处理 IPC,更具体地说是在套接字上,当我们想要读取和解析消息时,会出现一些有趣的问题。
当套接字中有东西准备好读取时,一个信号会通知我们。但我们如何知道要读取多少呢?在WORKER_DISCONNECT消息的情况下,没有负载。另一方面,JOB_RESULT消息可能非常庞大。更糟糕的是,几个JOB_RESULT消息可以排队在套接字中,等待读取。
要使事情更加复杂,我们必须承认我们正在与网络一起工作。数据包可能会丢失、重传、不完整或任何其他情况。当然,TCP 确保我们最终会得到所有信息,但它可能会延迟。
如果我们必须自己完成,那就意味着需要一个自定义的消息头,包括每个消息的有效负载大小和脚部。
Qt 5.7 中引入的一个特性提供了帮助:QDataStream 事务。其思路如下:当你开始在 QIODevice 类上读取时,你已经知道你需要读取多少数据(基于你想要填充的对象的大小)。然而,你可能不会在一次读取中获取所有数据。
如果读取未完成,QDataStream 将已读取的数据存储在一个临时缓冲区中,并在下一次读取时恢复它。下一次读取将包含已加载的内容加上新读取的内容。你可以将其视为读取流中的一个检查点,稍后可以加载。
此过程可以重复进行,直到读取数据。官方文档提供了一个足够简单的示例:
in.startTransaction();
qint8 messageType;
QByteArray messageData;
in >> messageType >> messageData;
if (!in.commitTransaction())
return;
在我们想要读取的 QDataStream 类中,in.startTransaction() 标记流中的检查点。然后,它将尝试原子地读取 messageType 和 messageData。如果它无法这样做,in.commitTransaction() 返回 false,并且读取的数据被复制到一个内部缓冲区中。
在下一次调用此代码(读取更多数据)时,in.startTransaction() 将恢复前面的缓冲区并尝试完成原子读取。
在我们的 readMessages() 情况中,我们一次可以接收多个消息。这就是为什么代码稍微复杂一些。以下是 MessageUtils 的更新版本:
#include <memory>
#include <vector>
#include <QByteArray>
#include <QTcpSocket>
#include <QDataStream>
#include "Message.h"
...
inline std::unique_ptr<std::vector<std::unique_ptr<Message>>> readMessages(QDataStream& stream)
{
auto messages = std::make_unique<std::vector<std::unique_ptr<Message>>>();
bool commitTransaction = true;
while (commitTransaction
&& stream.device()->bytesAvailable() > 0) {
stream.startTransaction();
auto message = std::make_unique<Message>();
stream >> *message;
commitTransaction = stream.commitTransaction();
if (commitTransaction) {
messages->push_back(std::move(message));
}
}
return messages;
}
}
在函数中,参数仅是一个 QDataStream。我们假设调用者使用 stream.setDevice(socket) 将流与套接字链接。
由于我们不知道要读取的内容长度,我们准备读取多个消息。为了明确表示所有权并避免任何内存泄漏,我们返回一个 vector<unique_ptr<Message>>。这个 vector 必须是一个 unique_ptr 指针,以便能够在堆上分配它,并在函数返回时避免任何复制。
在函数本身中,我们首先声明 vector。之后,执行一个 while 循环。保持循环的两个条件是:
-
commitTransaction == true: 这表示在流中已经执行了一个原子读取;已读取了一个完整的message -
stream.device().bytesAvailable() > 0: 这表示在流中仍有数据可读
在 while 循环中,我们首先使用 stream.startTransaction() 标记流。之后,我们尝试执行一个原子的 *message 信号读取,并使用 stream.commitTransaction() 查看结果。如果成功,新的 message 将被添加到 messages 向量中。这会一直重复,直到我们通过 bytesAvailable() > 0 测试读取了流的全部内容。
让我们通过一个用例来了解会发生什么。假设我们在readMessages()中接收到多个消息:
-
stream对象将尝试将其读取到message中。 -
commitTransaction变量将被设置为true,并将第一条消息添加到messages中。 -
如果
stream中仍有可读的字节,则从第一步重复。否则,退出循环。
总结来说,使用套接字会引发一系列问题。一方面,它是一个非常强大的具有很多灵活性的 IPC 机制。另一方面,由于网络的本质,它带来了很多复杂性。幸运的是,Qt(尤其是 Qt 5.7)为我们带来了许多优秀的类来帮助我们。
请记住,我们容忍QDataStream序列化和事务开销,因为它非常适合我们的需求。如果你在一个受限制的嵌入式平台上工作,你可能没有太多关于序列化开销和缓冲区复制的自由。然而,你仍然需要手动重建消息,以处理传入的字节。
在工作线程中与套接字交互
现在 SDK 已经完成,我们可以转向工作线程。项目已经足够复杂;我们可以通过mandelbrot-worker架构来刷新我们的记忆:

我们将首先实现Job类。在mandelbrot-worker项目中,创建一个名为Job的新 C++类。以下是Job.h的内容:
#include <QObject>
#include <QRunnable>
#include <QAtomicInteger>
#include "JobRequest.h"
#include "JobResult.h"
class Job : public QObject, public QRunnable
{
Q_OBJECT
public:
explicit Job(const JobRequest& jobRequest,
QObject *parent = 0);
void run() override;
signals:
void jobCompleted(JobResult jobResult);
public slots:
void abort();
private:
QAtomicInteger<bool> mAbort;
JobRequest mJobRequest;
};
如果你还记得第九章中的Job类,即第九章。通过多线程保持理智,这个头文件应该会让你想起。唯一的区别是,作业的参数(区域大小、缩放因子等)是从JobRequest对象中提取的,而不是直接作为成员变量存储。
如您所见,JobRequest对象是在Job的构造函数中提供的。我们不会介绍Job.cpp,因为它与第九章中第九章。通过多线程保持理智中的版本非常相似。
我们现在继续研究Worker类。这个类有以下角色:
-
它使用
QTcpSocket类与mandelbrot-app交互 -
它将
JobRequests调度到QThreadPool类,聚合结果,并通过QTcpSocket类将它们发送回mandelbrot-app应用程序
我们将首先研究与QTcpSocket类的交互。创建一个名为Worker的新类,并具有以下头文件:
#include <QObject>
#include <QTcpSocket>
#include <QDataStream>
#include "Message.h"
#include "JobResult.h"
class Worker : public QObject
{
Q_OBJECT
public:
Worker(QObject* parent = 0);
~Worker();
private:
void sendRegister();
private:
QTcpSocket mSocket;
};
Worker类是mSocket的所有者。我们将要实现的第一件事是与mandelbrot-app的连接。以下是Worker.cpp中Worker的构造函数:
#include "Worker.h"
#include <QThread>
#include <QDebug>
#include <QHostAddress>
#include "JobRequest.h"
#include "MessageUtils.h"
Worker::Worker(QObject* parent) :
QObject(parent),
mSocket(this)
{
connect(&mSocket, &QTcpSocket::connected, [this] {
qDebug() << "Connected";
sendRegister();
});
connect(&mSocket, &QTcpSocket::disconnected, [] {
qDebug() << "Disconnected";
});
mSocket.connectToHost(QHostAddress::LocalHost, 5000);
}
构造函数使用this作为父类初始化mSocket,然后继续将相关的mSocket信号连接到 lambda 函数:
-
QTcpSocket::connected:当套接字连接时,它将发送其注册消息。我们很快就会介绍这个函数 -
QTcpSocket::disconnected: 当套接字断开连接时,它会在控制台简单地打印一条消息。
最后,mSocket 尝试在 localhost 的端口 5000 上建立连接。在代码示例中,我们假设你在同一台机器上执行工作器和应用程序。如果你在不同的机器上运行工作器和应用程序,请随意更改此值。
sendRegister() 函数的主体如下:
void Worker::sendRegister()
{
QByteArray data;
QDataStream out(&data, QIODevice::WriteOnly);
out << QThread::idealThreadCount();
MessageUtils::sendMessage(mSocket,
Message::Type::WORKER_REGISTER,
data);
}
QByteArray 类被工作机的 idealThreadCount 函数填充。之后,我们调用 MessageUtils::sendMessage 来序列化消息并通过我们的 mSocket 发送它。
一旦工作器注册,它将开始接收工作请求,处理它们,并发送工作结果。以下是更新的 Worker.h:
class Worker : public QObject
{
...
signals:
void abortAllJobs();
private slots:
void readMessages();
private:
void handleJobRequest(Message& message);
void handleAllJobsAbort(Message& message);
void sendRegister();
void sendJobResult(JobResult jobResult);
void sendUnregister();
Job* createJob(const JobRequest& jobRequest);
private:
QTcpSocket mSocket;
QDataStream mSocketReader;
int mReceivedJobsCounter;
int mSentJobsCounter;
};
让我们回顾一下这些新成员的每个角色的作用:
-
mSocketReader: 这是我们将通过它读取mSocket内容的QDataStream类。它将被作为参数传递给我们的MessageUtils::readMessages()函数。 -
mReceivedJobsCounter: 每当从mandelbrot-app接收到新的JobRequest时,这个计数器会增加。 -
mSentJobsCounter: 每当向mandelbrot-app发送新的JobResult时,这个计数器会增加。
现在来看看新的函数:
-
abortAllJobs(): 当Worker类接收到适当的消息时,会发出这个信号。 -
readMessages(): 这是每次在mTcpSocket中有可读内容时被调用的槽。它解析消息,并为每种消息类型调用相应的函数。 -
handleJobRequest(): 这个函数根据消息参数中包含的JobRequest对象创建和调度Job类。 -
handleAllJobsAbort(): 这个函数取消所有当前的工作,并清除线程队列。 -
sendJobResult(): 这个函数将JobResult对象发送到mandelbrot-app。 -
sendUnregister(): 这个函数将注销消息发送到mandelbrot-app。 -
createJob(): 这是一个辅助函数,用于创建并正确连接新Job的信号。
现在头部已经完整。我们可以继续更新 Worker.cpp 中的构造函数:
Worker::Worker(QObject* parent) :
QObject(parent),
mSocket(this),
mSocketReader(&mSocket),
mReceivedJobsCounter(0),
mSentJobsCounter(0)
{
...
connect(&mSocket, &QTcpSocket::readyRead,
this, &Worker::readMessages);
mSocket.connectToHost(QHostAddress::LocalHost, 5000);
}
QDataStream mSocketReader 变量被初始化为 mSocket 的地址。这意味着它将从 QIODevice 类读取其内容。之后,我们添加了新的连接到 QTcpSocket 信号,readyRead()。每次套接字上有可读数据时,我们的槽 readMessages() 会被调用。
下面是 readMessages() 的实现:
void Worker::readMessages()
{
auto messages = MessageUtils::readMessages(mSocketReader);
for(auto& message : *messages) {
switch (message->type) {
case Message::Type::JOB_REQUEST:
handleJobRequest(*message);
break;
case Message::Type::ALL_JOBS_ABORT:
handleAllJobsAbort(*message);
break;
default:
break;
}
}
}
消息是通过 MessageUtils::readMessages() 函数解析的。注意使用了 C++11 语义中的 auto,它优雅地隐藏了智能指针的语法,同时仍然为我们处理内存。
对于每个解析的 message,它都在 switch 案中处理。让我们回顾一下 handleJobRequest():
void Worker::handleJobRequest(Message& message)
{
QDataStream in(&message.data, QIODevice::ReadOnly);
QList<JobRequest> requests;
in >> requests;
mReceivedJobsCounter += requests.size();
for(const JobRequest& jobRequest : requests) {
QThreadPool::globalInstance()
->start(createJob(jobRequest));
}
}
在这个函数中,message 对象已经被反序列化。然而,message.data 仍然需要被反序列化。为了实现这一点,我们在一个变量中创建一个 QDataStream,它将读取 message.data。
从这里,我们解析请求的 QList。因为 QList 已经重载了 >> 操作符,它以级联方式工作并调用我们的 JobRequest >> 操作符重载。数据反序列化从未如此简单!
之后,我们增加 mReceivedJobsCounter 并开始处理这些 JobRequests。对于每一个,我们创建一个 Job 类并将其调度到全局的 QThreadPool 类。如果你对 QThreadPool 有疑问,请回到第九章,使用多线程保持理智。
createJob() 函数实现起来很简单:
Job* Worker::createJob(const JobRequest& jobRequest)
{
Job* job = new Job(jobRequest);
connect(this, &Worker::abortAllJobs,
job, &Job::abort);
connect(job, &Job::jobCompleted,
this, &Worker::sendJobResult);
return job;
}
创建一个新的 Job 类,并正确连接其信号。当 Worker::abortAllJobs 被发射时,每个正在运行的 Job 都应该通过 Job::abort 插槽被取消。
第二个信号 Job::jobCompleted 在 Job 类完成计算其值时被发射。让我们看看连接的槽,sendJobResult():
void Worker::sendJobResult(JobResult jobResult)
{
mSentJobsCounter++;
QByteArray data;
QDataStream out(&data, QIODevice::WriteOnly);
out << jobResult;
MessageUtils::sendMessage(mSocket,
Message::Type::JOB_RESULT,
data);
}
我们首先增加 mSentJobsCounter,然后将 JobResult 序列化到 QByteArray 数据中,并将其传递给 MessageUtils::sendMessage()。
我们完成了对 JobRequest 处理和随后的 JobResult 发送的巡礼。我们仍然需要涵盖 handleAllJobsAbort(),它从 readMessages() 中被调用:
void Worker::handleAllJobsAbort(Message& /*message*/)
{
emit abortAllJobs();
QThreadPool::globalInstance()->clear();
mReceivedJobsCounter = 0;
mSentJobsCounter = 0;
}
首先发射 abortAllJobs() 信号,告诉所有正在运行的任务取消其进程。之后,清除 QThreadPool 类并重置计数器。
Worker 的最后一部分是 sendUnregister(),它在 Worker 析构函数中被调用:
Worker::~Worker()
{
sendUnregister();
}
void Worker::sendUnregister()
{
MessageUtils::sendMessage(mSocket,
Message::Type::WORKER_UNREGISTER,
true);
}
sendUnregister() 函数只是调用 sendMessage 而不进行任何数据序列化。注意,它将 forceFlush 标志传递为 true,以确保套接字被刷新,并且 mandelbrot-app 应用程序能够尽可能快地接收到消息。
Worker 实例将由一个显示当前计算进度的窗口小部件管理。创建一个名为 WorkerWidget 的新类,并更新 WorkerWidget.h,如下所示:
#include <QWidget>
#include <QThread>
#include <QProgressBar>
#include <QTimer>
#include "Worker.h"
class WorkerWidget : public QWidget
{
Q_OBJECT
public:
explicit WorkerWidget(QWidget *parent = 0);
~WorkerWidget();
private:
QProgressBar mStatus;
Worker mWorker;
QThread mWorkerThread;
QTimer mRefreshTimer;
};
WorkerWidget 的成员包括:
-
mStatus: 将显示已处理JobRequests百分比的QProgressBar -
mWorker: 由WorkerWidget拥有和启动的Worker实例 -
mWorkerThread:mWorker将在其中执行的QThread类 -
mRefreshTimer: 将周期性地轮询mWorker以了解进程进度的QTimer类
我们可以继续到 WorkerWidget.cpp:
#include "WorkerWidget.h"
#include <QVBoxLayout>
WorkerWidget::WorkerWidget(QWidget *parent) :
QWidget(parent),
mStatus(this),
mWorker(),
mWorkerThread(this),
mRefreshTimer()
{
QVBoxLayout* layout = new QVBoxLayout(this);
layout->addWidget(&mStatus);
mWorker.moveToThread(&mWorkerThread);
connect(&mRefreshTimer, &QTimer::timeout, [this] {
mStatus.setMaximum(mWorker.receivedJobsCounter());
mStatus.setValue(mWorker.sentJobCounter());
});
mWorkerThread.start();
mRefreshTimer.start(100);
}
WorkerWidget::~WorkerWidget()
{
mWorkerThread.quit();
mWorkerThread.wait(1000);
}
首先,将 mStatus 变量添加到 WorkerWidget 布局中。然后,将 mWorker 线程亲和性移动到 mWorkerThread,并将 mRefreshTimer 配置为轮询 mWorker 并更新 mStatus 数据。
最后,启动mWorkerThread,触发mWorker进程。同时,mRefreshTimer对象也被启动,每个超时之间的间隔为 100 毫秒。
在mandelbrot-worker中要讨论的最后一件事是main.cpp:
#include <QApplication>
#include "JobResult.h"
#include "WorkerWidget.h"
int main(int argc, char *argv[])
{
qRegisterMetaType<JobResult>();
QApplication a(argc, argv);
WorkerWidget workerWidget;
workerWidget.show();
return a.exec();
}
我们首先使用qRegisterMetaType注册JobResult,因为它在信号/槽机制中使用。之后,我们实例化一个WorkerWidget布局并显示它。
从应用程序与套接字交互
下一个要完成的项目是mandelbrot-app。它将包含与工作者交互的QTcpServer以及曼德布罗特集的图片绘制。作为提醒,mandelbrot-app架构的图示如下:

我们将从零开始构建这个应用程序。让我们从负责与特定Worker保持连接的类WorkerClient开始。这个类将存在于其特定的QThread中,并使用我们在上一节中介绍的相同的QTcpSocket/QDataStream机制与Worker类交互。
在mandelbrot-app中,创建一个名为WorkerClient的新 C++类,并更新WorkerClient.h如下:
#include <QTcpSocket>
#include <QList>
#include <QDataStream>
#include "JobRequest.h"
#include "JobResult.h"
#include "Message.h"
class WorkerClient : public QObject
{
Q_OBJECT
public:
WorkerClient(int socketDescriptor);
private:
int mSocketDescriptor;
int mCpuCoreCount;
QTcpSocket mSocket;
QDataStream mSocketReader;
};
Q_DECLARE_METATYPE(WorkerClient*)
它看起来与Worker非常相似。然而,从生命周期角度来看,它可能表现得不同。每次新的Worker连接到我们的QTcpServer时,都会创建一个新的WorkerClient,并关联一个QThread。WorkerClient对象将负责通过mSocket与Worker类交互。
如果Worker断开连接,WorkerClient对象将被删除并从QTcpServer类中移除。
让我们回顾一下这个标题的内容,从成员开始:
-
mSocketDescriptor:这是系统分配的唯一整数,用于与套接字交互。stdin、stdout和stderr也是指向应用程序中特定流的描述符。对于给定的套接字,该值将在QTcpServer中检索。关于这一点,稍后会有更多介绍。 -
mCpuCoreCount:这是连接的Worker的 CPU 核心数。当Worker发送WORKER_REGISTER消息时,该字段将被初始化。 -
mSocket:这是用于与Worker类交互的QTcpSocket。 -
mSocketReader:这个信号在Worker类中扮演相同的角色——它读取mSocket的内容。
现在我们可以向WorkerClient.h添加函数:
class WorkerClient : public QObject
{
Q_OBJECT
public:
WorkerClient(int socketDescriptor);
int cpuCoreCount() const;
signals:
void unregistered(WorkerClient* workerClient);
void jobCompleted(WorkerClient* workerClient,
JobResult jobResult);
void sendJobRequests(QList<JobRequest> requests);
public slots:
void start();
void abortJob();
private slots:
void readMessages();
void doSendJobRequests(QList<JobRequest> requests);
private:
void handleWorkerRegistered(Message& message);
void handleWorkerUnregistered(Message& message);
void handleJobResult(Message& message);
...
};
让我们看看每个函数的作用:
-
WorkerClient():这个函数期望一个socketDescriptor作为参数。因此,没有有效的套接字,WorkerClient函数不能被初始化。 -
cpuCoreCount():这个函数是一个简单的获取器,让WorkerClient的所有者知道Worker有多少个核心。
这个类有三个信号:
-
unregister():这是WorkerClient在接收到WORKER_UNREGISTER消息时发送的信号。 -
jobCompleted():这是WorkerClient在接收到JOB_RESULT消息时发送的信号。它将通过复制反序列化的JobResult传递。 -
sendJobRequests():这个信号由WorkerClient的所有者发出,以将JobRequests通过队列连接传递到适当的槽位:doSendJobRequests()。
下面是槽位的详细信息:
-
start():当WorkerClient可以开始其进程时调用这个槽位。通常,它将连接到与WorkerClient关联的QThread的start信号。 -
abortJob():这个槽位触发将ALL_JOBS_ABORT消息发送到Worker。 -
readMessages():每次在套接字中有东西要读取时调用这个槽位。 -
doSendJobRequests():这个槽位是实际触发将JobRequests发送到Worker的槽位。
最后,以下是私有函数的详细信息:
-
handleWorkerRegistered():这个函数处理WORKER_REGISTER消息并初始化mCpuCoreCount。 -
handleWorkerUnregistered():这个函数处理WORKER_UNREGISTER消息并发出unregistered()信号。 -
handleJobResult():这个函数处理JOB_RESULT消息,并通过jobCompleted()信号分发内容。
WorkerClient.cpp中的实现应该相当熟悉。以下是构造函数:
#include "MessageUtils.h"
WorkerClient::WorkerClient(int socketDescriptor) :
QObject(),
mSocketDescriptor(socketDescriptor),
mSocket(this),
mSocketReader(&mSocket)
{
connect(this, &WorkerClient::sendJobRequests,
this, &WorkerClient::doSendJobRequests);
}
字段在初始化列表中初始化,并将sendJobRequests信号连接到私有槽doSendJobRequests。这个技巧用于在避免多个函数声明的同时,仍然能够在线程之间保持队列连接。
我们将继续处理start()函数:
void WorkerClient::start()
{
connect(&mSocket, &QTcpSocket::readyRead,
this, &WorkerClient::readMessages);
mSocket.setSocketDescriptor(mSocketDescriptor);
}
这确实非常简短。它首先将套接字的readyRead()信号连接到我们的readMessages()槽。之后,使用mSocketDescriptor正确配置mSocket。
连接必须在start()中完成,因为它应该在关联我们的WorkerClient的QThread类中执行。这样做,我们知道这将是一个直接连接,并且mSocket不需要排队信号以与WorkerClient交互。
注意,在函数的末尾,相关的QThread并没有终止。相反,它正在通过QThread::exec()执行其事件循环。QThread类将继续运行其事件循环,直到有人调用QThread::exit()。
start()函数的唯一目的是在正确的线程亲和度中执行mSocket连接工作。之后,我们完全依赖于 Qt 的信号/槽机制来处理数据。不需要忙的while循环。
readMessages()类正在等待我们;让我们看看它:
void WorkerClient::readMessages()
{
auto messages = MessageUtils::readMessages(mSocketReader);
for(auto& message : *messages) {
switch (message->type) {
case Message::Type::WORKER_REGISTER:
handleWorkerRegistered(*message);
break;
case Message::Type::WORKER_UNREGISTER:
handleWorkerUnregistered(*message);
break;
case Message::Type::JOB_RESULT:
handleJobResult(*message);
break;
default:
break;
}
}
}
没有任何惊喜。它与我们为Worker所做的一模一样。使用MessageUtils::readMessages()反序列化Messages,并为每种消息类型调用适当的函数。
下面是每个函数的内容,从handleWorkerRegistered()开始:
void WorkerClient::handleWorkerRegistered(Message& message)
{
QDataStream in(&message.data, QIODevice::ReadOnly);
in >> mCpuCoreCount;
}
对于WORKER_REGISTER消息,Worker只在message.data中序列化了一个int,因此我们可以立即使用in >> mCpuCoreCount初始化mCpuCoreCount。
现在是handleWorkerUnregistered()函数的主体:
void WorkerClient::handleWorkerUnregistered(Message& /*message*/)
{
emit unregistered(this);
}
它是一个中继器,用于发送unregistered()信号,该信号将被WorkerClient的所有者捕获。
最后一个"读取"函数是handleJobResult():
void WorkerClient::handleJobResult(Message& message)
{
QDataStream in(&message.data, QIODevice::ReadOnly);
JobResult jobResult;
in >> jobResult;
emit jobCompleted(this, jobResult);
}
这看似简短。它只从message.data反序列化jobResult组件并发出jobCompleted()信号。
"写入套接字"函数是abortJob()和doSendJobRequest():
void WorkerClient::abortJob()
{
MessageUtils::sendMessage(mSocket,
Message::Type::ALL_JOBS_ABORT,
true);
}
void WorkerClient::doSendJobRequests(QList<JobRequest> requests)
{
QByteArray data;
QDataStream stream(&data, QIODevice::WriteOnly);
stream << requests;
MessageUtils::sendMessage(mSocket,
Message::Type::JOB_REQUEST,
data);
}
abortJob()函数发送带有forceFlush标志设置为true的ALL_JOBS_ABORT消息,而doSendJobRequests()在发送之前使用MessageUtils::sendMessage()将requests序列化到流中。
构建自己的 QTcpServer
我们的网络套接字已经准备好读写。我们仍然需要一个服务器来协调所有这些实例。为此,我们将开发一个修改版的MandelbrotCalculator类,该类在第九章中有所介绍,使用多线程保持理智。
理念是尊重相同的接口,以便让MandelbrotWidget对曼德布罗特图片生成被委派到不同的进程/机器的事实无感。
旧版MandelbrotCalculator与新版的主要区别在于,我们用QTcpServer类替换了QThreadPool类。现在MandelbrotCalculator类只负责将JobRequests分发给工作者并汇总结果,但它不再与QThreadPool类交互。
创建一个名为MandelbrotCalculator.cpp的新 C++类,并更新MandelbrotCalculator.h以匹配以下内容:
#include <memory>
#include <vector>
#include <QTcpServer>
#include <QList>
#include <QThread>
#include <QMap>
#include <QElapsedTimer>
#include "WorkerClient.h"
#include "JobResult.h"
#include "JobRequest.h"
class MandelbrotCalculator : public QTcpServer
{
Q_OBJECT
public:
MandelbrotCalculator(QObject* parent = 0);
~MandelbrotCalculator();
signals:
void pictureLinesGenerated(QList<JobResult> jobResults);
void abortAllJobs();
public slots:
void generatePicture(QSize areaSize, QPointF moveOffset,
double scaleFactor, int iterationMax);
private slots:
void process(WorkerClient* workerClient, JobResult jobResult);
void removeWorkerClient(WorkerClient* workerClient);
protected:
void incomingConnection(qintptr socketDescriptor) override;
private:
std::unique_ptr<JobRequest> createJobRequest(
int pixelPositionY);
void sendJobRequests(WorkerClient& client,
int jobRequestCount = 1);
void clearJobs();
private:
QPointF mMoveOffset;
double mScaleFactor;
QSize mAreaSize;
int mIterationMax;
int mReceivedJobResults;
QList<JobResult> mJobResults;
QMap<WorkerClient*, QThread*> mWorkerClients;
std::vector<std::unique_ptr<JobRequest>> mJobRequests;
QElapsedTimer mTimer;
};
修改(或新)的数据被突出显示。首先,请注意,类现在从QTcpServer继承而不是QObject。MandelbrotCalculator类现在是一个QTcpServer,能够接受和管理连接。在深入这个主题之前,我们可以回顾一下新成员:
-
mWorkerClients:这是一个存储WorkerClient和QThread对的QMap。每次创建WorkerClient时,都会生成一个相关的QThread,并将它们都存储在mWorkerClients中。 -
mJobRequests:这是当前图片的JobRequests列表。每次请求生成图片时,都会生成完整的JobRequest列表,准备分发给WorkerClients(即套接字另一侧的Worker)。
并且函数是:
-
process():这个函数是第九章中看到的函数的一个略微修改版本,使用多线程保持理智。它不仅在使用pictureLinesGenerated()信号发送之前汇总JobResults,还将JobRequest分发给传递的WorkerClient以保持它们忙碌。 -
removeWorkerClient(): 这个函数从mWorkerClients中移除并删除给定的WorkerClient。 -
incomingConnection(): 这个函数是来自QTcpServer的重载函数。每次有新客户端尝试连接到MandelbrotCalculator时都会调用它。 -
createJobRequest(): 这是一个辅助函数,它创建一个被添加到mJobRequests的JobRequest。 -
sendJobRequests(): 这个函数负责向指定的WorkerClient发送JobRequests列表。
让我们转到 MandelbrotCalculator.cpp 并从构造函数开始:
#include <QDebug>
#include <QThread>
using namespace std;
const int JOB_RESULT_THRESHOLD = 10;
MandelbrotCalculator::MandelbrotCalculator(QObject* parent) :
QTcpServer(parent),
mMoveOffset(),
mScaleFactor(),
mAreaSize(),
mIterationMax(),
mReceivedJobResults(0),
mWorkerClients(),
mJobRequests(),
mTimer()
{
listen(QHostAddress::Any, 5000);
}
这是一个带有 listen() 指令的常见初始化列表。因为我们正在继承 QTcpServer,我们可以对自己调用 listen。请注意,QHostAddress::Any 适用于 IPv4 和 IPv6。
让我们看看重载函数 incomingConnection():
void MandelbrotCalculator::incomingConnection(
qintptr socketDescriptor)
{
qDebug() << "Connected workerClient";
QThread* thread = new QThread(this);
WorkerClient* client = new WorkerClient(socketDescriptor);
int workerClientsCount = mWorkerClients.keys().size();
mWorkerClients.insert(client, thread);
client->moveToThread(thread);
connect(this, &MandelbrotCalculator::abortAllJobs,
client, &WorkerClient::abortJob);
connect(client, &WorkerClient::unregistered,
this, &MandelbrotCalculator::removeWorkerClient);
connect(client, &WorkerClient::jobCompleted,
this, &MandelbrotCalculator::process);
connect(thread, &QThread::started,
client, &WorkerClient::start);
thread->start();
if(workerClientsCount == 0 &&
mWorkerClients.size() == 1) {
generatePicture(mAreaSize, mMoveOffset,
mScaleFactor, mIterationMax);
}
}
一旦调用 listen(),每次有人连接到我们的 IP/端口对时,incomingConnection() 都会被触发,并将 socketDescriptor 作为参数传递。
小贴士
你可以通过在机器上使用简单的 telnet 127.0.0.1 5000 命令来测试这个机器连接。你应该在 mandelbrot-app 中看到 Connected workerClient 日志。
我们首先创建一个 QThread 类和一个 WorkerClient。这个对立即插入到 mWorkerClients 映射中,并将 client 线程亲和力更改为 thread。
之后,我们执行所有连接来管理 client(abortJob、unregister 和 jobCompleted)。我们继续使用 QThread::started() 信号,它连接到 WorkerClient::start() 插槽,最后,thread 被启动。
函数的最后部分用于在第一个 client 连接时触发图片生成。如果我们不这样做,屏幕将保持黑色,直到我们进行平移或缩放。
我们已经涵盖了 WorkerClient 的创建;让我们通过 removeWorkerClient() 来完成其生命周期:
void MandelbrotCalculator::removeWorkerClient(WorkerClient* workerClient)
{
qDebug() << "Removing workerClient";
QThread* thread = mWorkerClients.take(workerClient);
thread->quit();
thread->wait(1000);
delete thread;
delete workerClient;
}
workerClient/thread 对从 mWorkerClients 中移除并干净地删除。请注意,这个函数可以从 WorkerClient::unregistered 信号或 MandelbrotCalculator 析构函数中调用:
MandelbrotCalculator::~MandelbrotCalculator()
{
while (!mWorkerClients.empty()) {
removeWorkerClient(mWorkerClients.firstKey());
}
}
当 MandelbrotCalculator 被删除时,mWorkerClients 必须被适当地清空。迭代风格的 while 循环很好地完成了调用 removeWorkerClient() 的工作。
在 MandelbrotCalculator 的这个新版本中,generatePicture() 的行为并不完全相同:
void MandelbrotCalculator::generatePicture(
QSize areaSize, QPointF moveOffset,
double scaleFactor, int iterationMax)
{
// sanity check & members initization
...
for(int pixelPositionY = mAreaSize.height() - 1;
pixelPositionY >= 0; pixelPositionY--) {
mJobRequests.push_back(move(
createJobRequest(pixelPositionY)));
}
for(WorkerClient* client : mWorkerClients.keys()) {
sendJobRequests(*client, client->cpuCoreCount() * 2);
}
}
开始部分是相同的。然而,结束部分相当不同。而不是创建 Jobs 并将它们分配给 QThreadPool,MandelbrotCalculator 现在:
-
创建用于生成整个图片的
JobRequests。请注意,它们是按相反顺序创建的。我们很快就会看到原因。 -
向它拥有的每个
WorkerClient分发多个JobRequests。
第二点值得强调。如果我们想最大化我们系统的速度,我们必须使用多个工作者,每个工作者拥有多个核心以同时处理多个任务。
即使 Worker 类可以同时处理多个工作,它也只能通过 WorkerClient::jobCompleted 逐个发送 JobResults。每次我们从 WorkerClient 处理一个 JobResult 对象时,我们都会向它分发单个 JobRequest。
假设 Worker 类有八个核心。如果我们逐个发送 JobRequests,Worker 将始终有七个核心空闲。我们在这里是为了让你的 CPU 加热,而不是让它们在海滩上喝莫吉托!
为了减轻这种情况,我们发送给工作者的第一批 JobResults 必须高于其 coreCount()。通过这样做,我们确保它始终有一个 JobRequests 队列要处理,直到我们生成整个图片。这就是为什么我们发送 client->cpuCoreCount() * two 个初始 JobRequests。如果你玩这个值,你会看到:
-
如果
jobCount小于cpuCoreCount(),一些Worker的核心将会空闲,你将无法充分利用其 CPU 的全部功率 -
如果
jobCount比cpuCoreCount()多得太多,你可能会超载某个Worker的队列
记住,这个系统足够灵活,可以拥有多个工作者。如果你有一个 RaspberryPI 和一个 16 核的 x86 CPU,RaspberryPI 将落后于 x86 CPU。通过提供过多的初始 JobRequests,RaspberryPI 将阻碍整个图片生成过程,而 x86 CPU 已经完成了所有的工作。
让我们继续探讨 MandelbrotCalculator 的剩余功能,从 createJobRequest() 开始:
std::unique_ptr<JobRequest> MandelbrotCalculator::createJobRequest(int pixelPositionY)
{
auto jobRequest = make_unique<JobRequest>();
jobRequest->pixelPositionY = pixelPositionY;
jobRequest->moveOffset = mMoveOffset;
jobRequest->scaleFactor = mScaleFactor;
jobRequest->areaSize = mAreaSize;
jobRequest->iterationMax = mIterationMax;
return jobRequest;
}
这只是使用 MandelbrotCalculator 的成员字段创建一个简单的 jobRequest。同样,我们使用 unique_ptr 来明确表示 jobRequest 的所有权,以避免任何内存泄漏。
接下来,使用 sendJobRequests():
void MandelbrotCalculator::sendJobRequests(WorkerClient& client, int jobRequestCount)
{
QList<JobRequest> listJobRequest;
for (int i = 0; i < jobRequestCount; ++i) {
if (mJobRequests.empty()) {
break;
}
auto jobRequest = move(mJobRequests.back());
mJobRequests.pop_back();
listJobRequest.append(*jobRequest);
}
if (!listJobRequest.empty()) {
emit client.sendJobRequests(listJobRequest);
}
}
由于我们可以同时发送多个 JobRequests,我们通过循环 jobRequestCount,取 mJobRequests 的最后一个 jobRequest 并将其添加到 listJobRequest。这就是为什么我们必须以相反的顺序填充 mJobRequests。
最后,发出 client.sendJobRequests() 信号,这反过来触发 WorkerClient::doSendJobRequests() 插槽。
现在,我们将看到 process() 的修改版本:
void MandelbrotCalculator::process(WorkerClient* workerClient,
JobResult jobResult)
{
// Sanity check and JobResult aggregation
if (mReceivedJobResults < mAreaSize.height()) {
sendJobRequests(*workerClient);
} else {
qDebug() << "Generated in" << mTimer.elapsed() << "ms";
}
}
在这个版本中,我们传递 workerClient 作为参数。这在函数的末尾使用,以便能够向给定的 workerClient 分发新的 JobRequest。
最后,abortAllJobs() 的更新版本:
void MandelbrotCalculator::clearJobs()
{
mReceivedJobResults = 0;
mJobRequests.clear();
emit abortAllJobs();
}
这只是简单地清除了 mJobRequests,而不是清空 QThreadPool。
MandelbrotCalculator 类已经完成!你可以从 第九章,通过多线程保持理智 中复制并粘贴 MandelBrotWidget 和 MainWindow(包含 .ui 文件)。我们设计它是即插即用的,无需知道是谁生成图片:一个本地的 QThreadPool 使用 QRunnable 或通过 IPC 机制的小兵。
在 main.cpp 中只有微小的差别:
#include <QApplication>
#include <QList>
#include "JobRequest.h"
#include "JobResult.h"
#include "WorkerClient.h"
int main(int argc, char *argv[])
{
qRegisterMetaType<QList<JobRequest>>();
qRegisterMetaType<QList<JobResult>>();
qRegisterMetaType<WorkerClient*>();
QApplication a(argc, argv);
MainWindow w;
w.show();
return a.exec();
}
现在,你可以启动 mandelbrot-app,然后启动一个或多个将连接到应用程序的 mandelbrot-worker 程序。它应该会自动触发图片生成。Mandelbrot 图片生成现在可以在多个进程之间工作!因为我们选择使用套接字,所以你可以在不同的物理机器上启动应用程序和工作进程。
小贴士
使用 IPv6,你可以在不同的位置非常容易地测试应用程序/工作进程的连接。如果你没有高速互联网连接,你会看到网络是如何阻碍图片生成的。
你可能想花些时间在多台机器上部署应用程序,看看这个集群是如何协同工作的。在我们的测试中,我们将集群扩展到 18 个核心,使用了非常异构的机器(PC、笔记本电脑、Macbook 等等)。
摘要
IPC(进程间通信)是计算机科学中的一个基本机制。在本章中,你学习了 Qt 提供的各种 IPC 技巧,以及如何创建一个使用套接字进行交互、发送和接收命令的应用程序。你通过启用它能够在机器集群上生成图片,将原始的 mandelbrot-threadpool 应用程序提升到了一个新的水平。
在多线程应用程序之上添加 IPC 会带来一些问题。你会有更多的潜在瓶颈,内存泄漏的机会,以及低效的计算。Qt 提供了多种机制来进行 IPC。在 Qt 5.7 中,事务的添加使得序列化/反序列化部分变得更加容易。
在下一章中,你将了解 Qt 多媒体框架以及如何从文件中保存和加载 C++ 对象。项目示例将是一个虚拟鼓机。你将能够保存和加载你的音轨。
第十一章. 与序列化一起玩乐
上一章充满了线程、套接字和工作者的内容。我们希望你的小兵们一直在努力工作。在这一章中,我们将关注使用 Qt 的序列化。你将学习如何使用灵活的系统以多种格式序列化数据。示例项目将是一个虚拟鼓机,你可以创作自己的鼓点,录制、播放、保存和重新加载它。你的鼓点可能非常棒,以至于你想与他人分享:你现在可以以各种格式做到了。
本章将涵盖以下主题:
-
如何构建一个播放和录制声音的应用程序架构
-
QVariant类及其内部机制 -
一个灵活的序列化系统
-
JSON 序列化
-
XML 序列化
-
二进制序列化
-
Qt 多媒体框架
-
使用 Qt 处理拖放
-
从键盘触发按钮
构建鼓机项目
如往常一样,在深入代码之前,让我们研究一下项目的结构。项目的目标是能够:
-
从鼓机播放和录制声音轨道
-
将此轨道保存到文件中并加载以播放
为了播放声音,我们将布局四个大按钮,点击(或键盘事件)时将播放特定的鼓声:一个踢鼓、一个军鼓、一个高脚鼓和一个钹。这些声音将由应用程序加载的 .wav 文件。用户将能够录制他的声音序列并重新播放。
对于序列化部分,我们不仅希望将轨道保存到单个文件格式,我们更愿意做三个:
-
JSON(JavaScript 对象表示法)
-
XML(可扩展标记语言)
-
二进制
不仅涵盖三种格式更有趣,而且也给了我们了解每种格式的优缺点以及它们如何在 Qt 框架中定位的机会。我们将要实施的架构将力求灵活以应对未来的演变。你永远不知道一个项目会如何发展!
类的组织结构如下:

让我们回顾一下这些类的作用:
-
SoundEvent类是轨道的基本构建块。它是一个简单的类,包含timestamp(声音播放的时间)和soundId变量(播放了什么声音)。 -
Track类包含一个SoundEvents列表、一个持续时间和一个状态(播放、录制、停止)。每次用户播放声音时,都会创建一个SoundEvent类并将其添加到Track类。 -
PlaybackWorker类是一个在单独线程中运行的工人类。它负责遍历Track类的soundEvents并在其timestamp达到时触发适当的音效。 -
Serializable类是一个接口,每个想要被序列化的类都必须实现(在我们的案例中:SoundEvent和Track)。 -
Serializer类是一个接口,每个特定格式的实现类都必须实现它。 -
JsonSerializer、XmlSerializer和BinarySerializer是Serializer类的子类,它们执行特定格式的序列化和反序列化任务。 -
SoundEffectWidget类是包含播放单个声音信息的窗口小部件。它显示我们四个声音中的一个按钮。它还拥有一个QSoundEffect类,该类将声音发送到音频卡。 -
MainWindow类将一切整合在一起。它拥有Track类,生成PlaybackWorker线程,并触发序列化和反序列化。
输出格式应该易于更换。为了实现这一点,我们将依赖一个修改过的桥接设计模式,这将允许 Serializable 和 Serializer 类独立演进。
整个项目围绕模块之间独立性的概念展开。它甚至到了在播放过程中可以即时替换声音的程度。比如说,你正在听你令人难以置信的节奏,并想尝试另一个鼓声。你将能够通过简单地将 .wav 文件拖放到持有鼓声的 SoundEffectWidget 类上来替换它。
创建鼓点轨道
让我们系好安全带,开始这个项目!创建一个新的 Qt Widgets Application 项目,命名为 ch11-drum-machine。像往常一样,在 ch11-drum-machine.pro 中添加 CONFIG += c++14。
现在创建一个新的 C++ 类,命名为 SoundEvent。以下是 SoundEvent.h 中移除了函数的部分:
#include <QtGlobal>
class SoundEvent
{
public:
SoundEvent(qint64 timestamp = 0, int soundId = 0);
~SoundEvent();
qint64 timestamp;
int soundId;
};
这个类只包含两个公共成员:
-
timestamp:一个包含从轨道开始以来SoundEvent当前时间的qint64(long long类型),单位为毫秒。 -
soundId:播放过的声音的 ID
在录制模式下,每次用户播放声音时,都会创建一个带有适当数据的 SoundEvent。SoundEvent.cpp 文件非常无聊,所以我们不会把它强加给你。
下一个要构建的类是 Track。再次创建一个新的 C++ 类。让我们回顾一下 Track.h 中的成员:
#include <QObject>
#include <QVector>
#include <QElapsedTimer>
#include "SoundEvent.h"
class Track : public QObject
{
Q_OBJECT
public:
enum class State {
STOPPED,
PLAYING,
RECORDING,
};
explicit Track(QObject *parent = 0);
~Track();
private:
qint64 mDuration;
std::vector<std::unique_ptr<SoundEvent>> mSoundEvents;
QElapsedTimer mTimer;
State mState;
State mPreviousState;
};
我们现在可以详细地介绍它们:
-
mDuration:这个变量持有Track类的持续时间。当开始录制时,这个成员被重置为 0,并在录制停止时更新。 -
mSoundEvents:这个变量是给定Track的SoundEvents列表。正如unique_ptr语义所表明的,Track是声音事件的拥有者。 -
mTimer:每次播放或录制Track时,这个变量都会启动。 -
mState:这个变量是Track类的当前State,它可以有三个可能的值:STOPPED、PLAYING、RECORDING。 -
mPreviousState:这个变量是Track的上一个State。当你想知道在新的STOPPED状态上要执行什么操作时,这很有用。如果mPreviousState处于PLAYING状态,我们将不得不停止播放。
Track类是项目业务逻辑的核心。它持有mState,这是整个应用程序的状态。其内容将在播放你令人惊叹的音乐表演时被读取,并将其序列化到文件中。
让我们用函数丰富Track.h:
class Track : public QObject
{
Q_OBJECT
public:
...
qint64 duration() const;
State state() const;
State previousState() const;
quint64 elapsedTime() const;
const std::vector<std::unique_ptr<SoundEvent>>& soundEvents() const;
signals:
void stateChanged(State state);
public slots:
void play();
void record();
void stop();
void addSoundEvent(int soundEventId);
private:
void clear();
void setState(State state);
private:
...
};
我们将跳过简单的获取器,专注于重要的函数:
-
elapsedTime():这个函数返回mTimer.elapsed()的值。 -
soundEvents():这个函数是一个更复杂的获取器。Track类是mSoundEvents内容的拥有者,我们确实想强制执行这一点。为此,获取器返回mSoundEvents的const &。 -
stateChanged():当mState值更新时,会发出这个函数。新的State作为参数传递。 -
play():这个函数是一个槽,开始播放Track。这种播放完全是逻辑上的,真正的播放将由PlaybackWorker触发。 -
record():这个函数是一个槽,开始Track的录制状态。 -
stop():这个函数是一个槽,停止当前开始或录制状态。 -
addSoundEvent():这个函数使用给定的soundId创建一个新的SoundEvent并将其添加到mSoundEvents。 -
clear():这个函数重置Track的内容:它清除mSoundEvents并将mDuration设置为0。 -
setState():这个函数是一个私有辅助函数,用于更新mState、mPreviousState并发出stateChanged()信号。
现在已经覆盖了头文件,我们可以研究Track.cpp中的有趣部分:
void Track::play()
{
setState(State::PLAYING);
mTimer.start();
}
调用Track.play()只是将状态更新为PLAYING并启动mTimer。Track类不包含与 Qt 多媒体 API 相关的任何内容;它仅限于一个进化的数据持有者(因为它还管理状态)。
现在来看record()函数,它带来了很多惊喜:
void Track::record()
{
clearSoundEvents();
setState(State::RECORDING);
mTimer.start();
}
它首先清除数据,将状态设置为RECORDING,并启动mTimer。现在考虑stop(),这是一个轻微的变化:
void Track::stop()
{
if (mState == State::RECORDING) {
mDuration = mTimer.elapsed();
}
setState(State::STOPPED);
}
如果我们在RECORDING状态下停止,mDuration将被更新。这里没有什么特别之处。我们看到了三次setState()调用,但没有看到其主体:
void Track::setState(Track::State state)
{
mPreviousState = mState;
mState = state;
emit stateChanged(mState);
}
在更新之前,当前mState的值存储在mPreviousState中。最后,使用新值发出stateChanged()。
Track的状态系统被完全覆盖。最后缺失的部分是SoundEvents的交互。我们可以从addSoundEvent()片段开始:
void Track::addSoundEvent(int soundEventId)
{
if (mState != State::RECORDING) {
return;
}
mSoundEvents.push_back(make_unique<SoundEvent>(
mTimer.elapsed(),
soundEventId));
}
只有在我们处于RECORDING状态时,才会创建soundEvent。之后,将带有当前mTimer的流逝时间和传递的soundEventId的SoundEvent添加到mSoundEvents。
现在来看clear()函数:
void Track::clear()
{
mSoundEvents.clear();
mDuration = 0;
}
因为我们在mSoundEvents中使用unique_ptr<SoundEvent>,所以mSoundEvents.clear()函数足以清空向量并删除每个SoundEvent。这减少了你需要担心智能指针的事情。
SoundEvent和Track是包含你未来节奏信息的基类。我们将看到负责读取这些数据以播放的类:PlaybackWorker。
创建一个新的 C++类,并像这样更新PlaybackWorker.h:
#include <QObject>
#include <QAtomicInteger>
class Track;
class PlaybackWorker : public QObject
{
Q_OBJECT
public:
explicit PlaybackWorker(const Track& track, QObject *parent = 0);
signals:
void playSound(int soundId);
void trackFinished();
public slots:
void play();
void stop();
private:
const Track& mTrack;
QAtomicInteger<bool> mIsPlaying;
};
PlaybackWorker类将在不同的线程中运行。如果你的记忆需要刷新,请回到第九章,使用多线程保持你的理智。它的作用是遍历Track类的内容以触发声音。让我们分解这个头文件:
-
mTrack: 这个函数是PlaybackWorker正在工作的Track类的引用。它作为const引用传递给构造函数。有了这个信息,你已经知道PlaybackWorker不能以任何方式修改mTrack。 -
mIsPlaying: 这个函数是一个标志,用于能够从另一个线程停止工作。它是一个QAtomicInteger,以保证对变量的原子访问。 -
playSound(): 这个函数由PlaybackWorker在需要播放声音时发出。 -
trackFinished(): 当播放被播放到结束时,这个函数会被发出。如果在途中停止,这个信号将不会被发出。 -
play(): 这个函数是PlaybackWorker的主要函数。在其中,将查询mTrack内容以触发声音。 -
stop(): 这个函数是更新mIsPlaying标志并导致play()退出其循环的函数。
类的核心在于play()函数:
void PlaybackWorker::play()
{
mIsPlaying.store(true);
QElapsedTimer timer;
size_t soundEventIndex = 0;
const auto& soundEvents = mTrack.soundEvents();
timer.start();
while(timer.elapsed() <= mTrack.duration()
&& mIsPlaying.load()) {
if (soundEventIndex < soundEvents.size()) {
const auto& soundEvent =
soundEvents.at(soundEventIndex);
if (timer.elapsed() >= soundEvent->timestamp) {
emit playSound(soundEvent->soundId);
soundEventIndex++;
}
}
QThread::msleep(1);
}
if (soundEventIndex >= soundEvents.size()) {
emit trackFinished();
}
}
play()函数首先做的事情是准备它的读取:将mIsPlaying设置为true,声明一个QElapsedTimer类,并初始化一个soundEventIndex。每次调用timer.elapsed()时,我们都会知道是否应该播放声音。
为了知道应该播放哪个声音,soundEventIndex将用来知道我们在soundEvents向量中的位置。
随后,启动timer对象,我们进入while循环。这个while循环有两个条件以继续:
-
timer.elapsed() <= mTrack.duration(): 这个条件表明我们没有完成播放曲目 -
mIsPlaying.load(): 这个条件返回true:没有人要求PlaybackWorker停止
直观地讲,你可能在while条件中添加了soundEventIndex < soundEvents.size()条件。这样做的话,你会在最后一个声音播放完毕后立即退出PlaybackWorker。技术上,这是可行的,但这样就不会尊重用户记录的内容。
考虑一个用户创建了一个复杂的节奏(不要低估你用四个声音能做什么!)并在歌曲结束时决定有一个 5 秒的长时间暂停。当他点击停止按钮时,时间显示为 00:55(55 秒)。然而,当他播放他的表演时,最后一个声音在 00:50 结束。播放在 00:50 停止,程序不尊重他记录的内容。
因此,soundEventIndex < size()测试被移动到while循环内部,并仅用作通过soundEvents读取的保险丝。
在这个条件内部,我们检索当前soundEvent的引用。然后我们将已过时间与soundEvent的timestamp进行比较。如果timer.elapsed()大于或等于soundEvent->timestamp,则使用soundId发出playSound()信号。
这只是一个播放声音的请求。PlaybackWorker类仅限于读取soundEvents并在适当的时候触发playSound()。真正的声音将在稍后由SoundEffectWidget类处理。
在while循环的每次迭代中,都会执行QThread::msleep(1)以避免忙等待。我们尽量减少睡眠时间,因为我们希望回放尽可能忠实于原始乐谱。睡眠时间越长,回放时可能遇到的时差就越大。
最后,如果整个soundEvents已经被处理,将发出trackFinished信号。
使用 QVariant 使你的对象可序列化
现在我们已经在业务类中实现了逻辑,我们必须考虑我们将要序列化什么以及我们将如何进行序列化。用户与包含所有要记录和回放数据的Track类进行交互。
从这里开始,我们可以假设要序列化的对象是Track,它应该以某种方式携带其包含SoundEvent实例列表的mSoundEvents。为了实现这一点,我们将大量依赖QVariant类。
你可能之前已经使用过QVariant。它是一个用于任何原始类型(char、int、double等)以及复杂类型(QString、QDate、QPoint等)的通用占位符。
注意
QVariant 支持的所有类型完整列表可在doc.qt.io/qt-5/qmetatype.html#Type-enum找到。
QVariant的一个简单例子是:
QVariant variant(21);
int answer = variant.toInt() * 2;
qDebug() << "what is the meaning of the universe,
life and everything?"
<< answer;
我们在variant中存储21。从这里,我们可以要求variant拥有一个值副本,该值被转换为我们期望的类型。这里我们想要一个int值,所以我们调用variant.toInt()。variant.toX()语法已经提供了很多转换。
我们可以快速了解一下QVariant背后的情况。它是如何存储我们给它提供的内容的?答案在于 C++类型的union。QVariant类是一种超级union。
union是一种特殊类类型,一次只能持有其非静态数据成员中的一个。一个简短的代码片段可以说明这一点:
union Sound
{
int duration;
char code;
};
Sound s = 10;
qDebug() << "Sound duration:" << s.duration;
// output= Sound duration: 10
s.code = 'K';
qDebug() << "Sound code:" << s.code;
// output= Sound code: K
首先,声明一个类似于struct的union类。默认情况下,所有成员都是public的。union的特殊之处在于它在内存中只占用最大成员的大小。在这里,Sound将只占用内存中int duration空间的大小。
因为union只占用这个特定的空间,每个成员变量共享相同的内存空间。因此,一次只有一个成员是可用的,除非你想要有未定义的行为。
当使用Sound片段时,我们首先使用默认值10(默认情况下第一个成员被初始化)进行初始化。从这里,s.duration是可访问的,但s.code被认为是未定义的。
一旦我们给s.code赋值,s.duration就变为未定义,而s.code现在是可访问的。
union类使得内存使用非常高效。在QVariant中,当你存储一个值时,它被存储在一个私有的union中:
union Data
{
char c;
uchar uc;
short s;
signed char sc;
ushort us;
...
qulonglong ull;
QObject *o;
void *ptr;
PrivateShared *shared;
} data;
注意基本类型列表,最后是复杂类型QObject*和void*。
除了Data,一个QMetaType对象被初始化以了解存储对象的类型。union和QMetaType的结合让QVariant知道应该使用哪个Data成员来转换值并将其返回给调用者。
现在你已经知道了union是什么以及QVariant如何使用它,你可能会问:为什么还要创建一个QVariant类呢?一个简单的union不是足够了吗?
答案是否定的。这还不够,因为union类不能有没有默认构造函数的成员。这极大地减少了你可以放入union中的类的数量。Qt 开发者想要在union中包含许多没有默认构造函数的类。为了减轻这个问题,QVariant应运而生。
QVariant非常有趣的地方在于它可以存储自定义类型。如果我们想将SoundEvent类转换为QVariant类,我们会在SoundEvent.h中添加以下内容:
class SoundEvent
{
...
};
Q_DECLARE_METATYPE(SoundEvent);
我们已经在第十章中使用了Q_DECLARE_METATYPE宏,需要 IPC?让你的仆从去工作。这个宏有效地将SoundEvent注册到QMetaType注册表中,使其对QVariant可用。因为QDataStream依赖于QVariant,所以我们不得不在上一个章节中使用这个宏。
现在要使用QVariant进行转换:
SoundEvent soundEvent(4365, 0);
QVariant stored;
stored.setValue(soundEvent);
SoundEvent newEvent = stored.value<SoundEvent>();
qDebug() << newEvent.timestamp;
如你所猜,这个片段的输出是4365,这是存储在soundEvent中的原始timestamp。
如果我们只想进行二进制序列化,这种方法将完美无缺。数据可以轻松地写入和读取。然而,我们希望将Track和SoundEvents输出到标准格式:JSON 和 XML。
Q_DECLARE_METATYPE/QVariant组合有一个主要问题:它不存储序列化类字段的任何键。我们可以预见,SoundEvent类的 JSON 对象将看起来像这样:
{
"timestamp": 4365,
"soundId": 0
}
QVariant类不可能知道我们想要一个timestamp键。它只会存储原始的二进制数据。同样的原则也适用于 XML 对应物。
由于这个原因,我们将使用QVariant的一个变体,配合QVariantMap。QVariantMap类仅仅是QMap<QString, QVariant>的一个typedef。这个映射将用于存储字段的键名和QVariant类中的值。反过来,这些键将由 JSON 和 XML 序列化系统使用,以输出格式化的文件。
由于我们旨在拥有一个灵活的序列化系统,我们必须能够以多种格式序列化和反序列化这个QVariantMap。为了实现这一点,我们将定义一个接口,它赋予一个类在QVariantMap中序列化/反序列化其内容的能力。
这个QVariantMap将被用作一个中间格式,与最终的 JSON、XML 或二进制格式无关。
创建一个名为Serializer.h的 C++头文件。以下是内容:
#include <QVariant>
class Serializable {
public:
virtual ~Serializable() {}
virtual QVariant toVariant() const = 0;
virtual void fromVariant(const QVariant& variant) = 0;
};
通过实现这个抽象基类,一个类将变为Serializable。这里只有两个虚拟纯函数:
-
toVariant()函数,其中类必须返回一个QVariant(或者更精确地说是一个QVariantMap,它可以因为QMetaType系统而转换为QVariant) -
fromVariant()函数,其中类必须从作为参数传递的变体中初始化其成员
通过这样做,我们将加载和保存其内容的责任交给最终类。毕竟,谁比SoundEvent本身更了解SoundEvent呢?
让我们看看Serializable在SoundEvent上的实际应用。像这样更新SoundEvent.h:
#include "Serializable.h"
class SoundEvent : public Serializable
{
SoundEvent(qint64 timestamp = 0, int soundId = 0);
~SoundEvent();
QVariant toVariant() const override;
void fromVariant(const QVariant& variant) override;
...
};
SoundEvent类现在是Serializable。让我们在SoundEvent.cpp中做实际的工作:
QVariant SoundEvent::toVariant() const
{
QVariantMap map;
map.insert("timestamp", timestamp);
map.insert("soundId", soundId);
return map;
}
void SoundEvent::fromVariant(const QVariant& variant)
{
QVariantMap map = variant.toMap();
timestamp = map.value("timestamp").toLongLong();
soundId = map.value("soundId").toInt();
}
在toVariant()中,我们简单地声明一个QVariantMap,并用timestamp和soundId填充它。
在另一方面,在fromVariant()中,我们将variant转换为QVariantMap,并使用与toVariant()中相同的键检索其内容。就这么简单!
下一个需要实现序列化(Serializable)的类是Track。在使Track继承自Serializable之后,更新Track.cpp:
QVariant Track::toVariant() const
{
QVariantMap map;
map.insert("duration", mDuration);
QVariantList list;
for (const auto& soundEvent : mSoundEvents) {
list.append(soundEvent->toVariant());
}
map.insert("soundEvents", list);
return map;
}
原则相同,尽管稍微复杂一些。mDuration变量以我们在SoundEvent中看到的方式存储在map对象中。对于mSoundEvents,我们必须生成一个QVariant(一个QVariantList)列表,其中每个项都是soundEvent键转换后的QVariant版本。
要做到这一点,我们只需遍历mSoundEvents,并用之前提到的soundEvent->toVariant()结果填充list。
现在来看fromVariant():
void Track::fromVariant(const QVariant& variant)
{
QVariantMap map = variant.toMap();
mDuration = map.value("duration").toLongLong();
QVariantList list = map.value("soundEvents").toList();
for(const QVariant& data : list) {
auto soundEvent = make_unique<SoundEvent>();
soundEvent->fromVariant(data);
mSoundEvents.push_back(move(soundEvent));
}
}
在这里,对于soundEvents键的每个元素,我们创建一个新的SoundEvent,用data的内容加载它,并将其最终添加到mSoundEvents向量中。
以 JSON 格式序列化对象
Track和SoundEvent类现在可以被转换成通用的 Qt 格式QVariant。我们现在需要将Track(及其SoundEvent对象)类写入一个文本或二进制格式的文件中。这个示例项目允许你处理所有格式。它将允许你在一行中切换保存的文件格式。那么具体格式代码应该放在哪里呢?这是一个价值百万的问题!这里有一个主要的方法:

在这个提议中,特定的文件格式序列化代码位于一个专门的子类中。好吧,它工作得很好,但如果我们添加两种新的文件格式,层次结构会是什么样子?此外,每次我们添加一个要序列化的新对象时,我们必须创建所有这些子类来处理不同的序列化文件格式。这个庞大的继承树很快就会变得混乱不堪。代码将变得难以维护。你不想这样做。所以,这就是桥接模式可以成为一个好解决方案的地方:

在桥接模式中,我们解耦了两个继承层次结构中的类:
-
与文件格式无关的组件。
SoundEvent和Track对象不关心 JSON、XML 或二进制格式。 -
文件格式实现。
JsonSerializer、XmlSerializer和BinarySerializer处理一个通用格式Serializable,而不是特定的组件,如SoundEvent或Track。
注意,在经典的桥接模式中,一个抽象(Serializable)应该包含一个实现者(Serializer)变量。调用者只处理抽象。然而,在这个项目示例中,MainWindow拥有Serializable和Serializer的所有权。这是在保持功能类解耦的同时使用设计模式力量的个人选择。
Serializable和Serializer的架构是清晰的。Serializable类已经实现,因此你现在可以创建一个新的 C++头文件,名为Serializer.h:
#include <QString>
#include "Serializable.h"
class Serializer
{
public:
virtual ~Serializer() {}
virtual void save(const Serializable& serializable,
const QString& filepath,
const QString& rootName = "") = 0;
virtual void load(Serializable& serializable,
const QString& filepath) = 0;
};
Serializer类是一个接口,一个只包含纯虚函数而没有数据的抽象类。让我们来谈谈save()函数:
-
这个函数将
Serializable保存到硬盘驱动器上的文件中。 -
Serializable类是const的,不能被这个函数修改。 -
filepath函数指示要创建的目标文件 -
一些
Serializer实现可以使用rootName变量。例如,如果我们请求保存一个Track对象,rootName变量可以是字符串track。这是用于写入根元素的标签。XML 实现需要这个信息。
load()函数也容易理解:
-
这个函数从文件中加载数据以填充
Serializable类 -
这个函数将更新
Serializable类 -
filepath函数指示要读取的文件
接口 Serializer 已准备就绪,等待一些实现!让我们从 JSON 开始。创建一个 C++ 类,JsonSerializer。以下是 JsonSerializer.h 的头文件:
#include "Serializer.h"
class JsonSerializer : public Serializer
{
public:
JsonSerializer();
void save(const Serializable& serializable,
const QString& filepath,
const QString& rootName) override;
void load(Serializable& serializable,
const QString& filepath) override;
};
这里没有困难;我们必须提供 save() 和 load() 的实现。以下是 save() 的实现:
void JsonSerializer::save(const Serializable& serializable,
const QString& filepath, const QString& /*rootName*/)
{
QJsonDocument doc =
QJsonDocument::fromVariant(serializable.toVariant());
QFile file(filepath);
file.open(QFile::WriteOnly);
file.write(doc.toJson());
file.close();
}
Qt 框架提供了一个很好的方式来使用 QJsonDocument 类读取和写入 JSON 文件。我们可以从 QVariant 类创建一个 QJsonDocument 类。请注意,QJsonDocument 接受的 QVariant 必须是 QVariantMap、QVariantList 或 QStringList。不用担心,Track 类和 SoundEvent 的 toVariant() 函数会生成一个 QVariantMap。然后,我们可以使用目标 filepath 创建一个 QFile 文件。QJsonDocument::toJson() 函数将其转换为 UTF-8 编码的文本表示。我们将此结果写入 QFile 文件并关闭文件。
提示
QJsonDocument::toJson() 函数可以生成 Indented 或 Compact JSON 格式。默认情况下,格式是 QJsonDocument::Indented。
load() 的实现也很简短:
void JsonSerializer::load(Serializable& serializable,
const QString& filepath)
{
QFile file(filepath);
file.open(QFile::ReadOnly);
QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
file.close();
serializable.fromVariant(doc.toVariant());
}
我们使用源 filepath 打开一个 QFile。使用 QFile::readAll() 读取所有数据。然后我们可以使用 QJsonDocument::fromJson() 函数创建一个 QJsonDocument 类。最后,我们可以用转换为 QVariant 类的 QJsonDocument 填充我们的目标 Serializable。请注意,QJsonDocument::toVariant() 函数可以返回 QVariantList 或 QVariantMap,具体取决于 JSON 文档的性质。
这里是使用这个 JsonSerializer 保存的 Track 类的示例:
{
"duration": 6205,
"soundEvents": [
{
"soundId": 0,
"timestamp": 2689
},
{
"soundId": 2,
"timestamp": 2690
},
{
"soundId": 2,
"timestamp": 3067
}
]
}
根元素是一个 JSON 对象,由包含两个键的映射表示:
-
Duration: 这是一个简单的整数值 -
soundEvents: 这是一个对象的数组。每个对象是一个包含以下键的映射: -
soundId: 这是一个整数 -
timestamp: 这也是一个整数值
以 XML 格式序列化对象
JSON 序列化是 C++ 对象的直接表示,Qt 已经提供了我们所需的一切。然而,C++ 对象的序列化可以用各种表示形式在 XML 格式中完成。因此,我们必须自己编写 XML 到 QVariant 的转换。我们决定使用以下 XML 表示形式:
<[name]> type="[type]">[data]</[name]>
例如,soundId 类型给出以下 XML 表示形式:
<soundId type="int">2</soundId>
创建一个继承自 Serializer 的 C++ 类 XmlSerializer。让我们从 save() 函数开始,以下是 XmlSerializer.h:
#include <QXmlStreamWriter>
#include <QXmlStreamReader>
#include "Serializer.h"
class XmlSerializer : public Serializer
{
public:
XmlSerializer();
void save(const Serializable& serializable,
const QString& filepath,
const QString& rootName) override;
};
现在,我们可以看到 XmlSerializer.cpp 中的 save() 实现:
void XmlSerializer::save(const Serializable& serializable, const QString& filepath, const QString& rootName)
{
QFile file(filepath);
file.open(QFile::WriteOnly);
QXmlStreamWriter stream(&file);
stream.setAutoFormatting(true);
stream.writeStartDocument();
writeVariantToStream(rootName, serializable.toVariant(),
stream);
stream.writeEndDocument();
file.close();
}
我们使用 filepath 目的地创建一个 QFile 文件。我们构造一个写入 QFile 的 QXmlStreamWriter 对象。默认情况下,写入器将生成紧凑的 XML;你可以使用 QXmlStreamWriter::setAutoFormatting() 函数生成格式化的 XML。QXmlStreamWriter::writeStartDocument() 函数写入 XML 版本和编码。我们使用 writeVariantToStream() 函数将我们的 QVariant 写入 XML 流中。最后,我们结束文档并关闭 QFile。如前所述,将 QVariant 写入 XML 流取决于你如何表示数据。因此,我们必须编写转换函数。请更新你的类,添加如下 writeVariantToStream():
//XmlSerializer.h
private:
void writeVariantToStream(const QString& nodeName,
const QVariant& variant, QXmlStreamWriter& stream);
//XmlSerializer.cpp
void XmlSerializer::writeVariantToStream(const QString& nodeName,
const QVariant& variant, QXmlStreamWriter& stream)
{
stream.writeStartElement(nodeName);
stream.writeAttribute("type", variant.typeName());
switch (variant.type()) {
case QMetaType::QVariantList:
writeVariantListToStream(variant, stream);
break;
case QMetaType::QVariantMap:
writeVariantMapToStream(variant, stream);
break;
default:
writeVariantValueToStream(variant, stream);
break;
}
stream.writeEndElement();
}
这个 writeVariantToStream() 函数是一个通用入口点。每次我们想要将一个 QVariant 放入 XML 流时,它都会被调用。QVariant 类可以是列表、映射或数据。因此,如果 QVariant 是一个容器(QVariantList 或 QVariantMap),我们将应用特定的处理。所有其他情况都被视为数据值。以下是此函数的步骤:
-
使用
writeStartElement()函数开始一个新的 XML 元素。nodeName将用于创建 XML 标签。例如,<soundId>。 -
在当前元素中写入一个名为
type的 XML 属性。我们使用存储在QVariant中的类型名称。例如,<soundId type="int" />。 -
根据数据类型
QVariant,我们调用我们的一个 XML 序列化函数。例如,<soundId type="int">2。 -
最后,我们使用
writeEndElement()结束当前 XML 元素:-
最终结果是:
<soundId type="int">2</soundId> -
在这个函数中,我们调用我们现在将创建的三个辅助函数。其中最容易的是
writeVariantValueToStream()。请更新你的XmlSerializer类://XmlSerializer.h void writeVariantValueToStream(const QVariant& variant, QXmlStreamWriter& stream); //XmlSerializer.cpp void XmlSerializer::writeVariantValueToStream( const QVariant& variant, QXmlStreamWriter& stream) { stream.writeCharacters(variant.toString()); }
-
如果 QVariant 是一个简单类型,我们检索它的 QString 表示形式。然后我们使用 QXmlStreamWriter::writeCharacters() 将这个 QString 写入 XML 流中。
第二个辅助函数是 writeVariantListToStream()。以下是它的实现:
//XmlSerializer.h
private:
void writeVariantListToStream(const QVariant& variant,
QXmlStreamWriter& stream);
//XmlSerializer.cpp
void XmlSerializer::writeVariantListToStream(
const QVariant& variant, QXmlStreamWriter& stream)
{
QVariantList list = variant.toList();
for(const QVariant& element : list) {
writeVariantToStream("item", element, stream);
}
}
在这一步,我们已经知道 QVariant 是一个 QVariantList。我们调用 QVariant::toList() 来检索列表。然后我们遍历列表的所有元素并调用我们的通用入口点,writeVariantToStream()。请注意,我们从列表中检索元素,因此我们没有元素名称。但是,对于列表项的序列化,标签名称并不重要,所以插入任意标签 item。
最后一个写入辅助函数是 writeVariantMapToStream():
//XmlSerializer.h
private:
void writeVariantMapToStream(const QVariant& variant,
QXmlStreamWriter& stream);
//XmlSerializer.cpp
void XmlSerializer::writeVariantMapToStream(
const QVariant& variant, QXmlStreamWriter& stream)
{
QVariantMap map = variant.toMap();
QMapIterator<QString, QVariant> i(map);
while (i.hasNext()) {
i.next();
writeVariantToStream(i.key(), i.value(), stream);
}
}
QVariant 是一个容器,但这次是 QVariantMap。我们对每个找到的元素调用 writeVariantToStream()。标签名称对于映射很重要。我们使用 QMapIterator::key() 作为节点名称。
保存部分已经完成。现在我们可以实现加载部分。它的架构与保存函数遵循相同的理念。让我们从 load() 函数开始:
//XmlSerializer.h
public:
void load(Serializable& serializable,
const QString& filepath) override;
//XmlSerializer.cpp
void XmlSerializer::load(Serializable& serializable,
const QString& filepath)
{
QFile file(filepath);
file.open(QFile::ReadOnly);
QXmlStreamReader stream(&file);
stream.readNextStartElement();
serializable.fromVariant(readVariantFromStream(stream));
}
首先要做的事情是创建一个包含源 filepath 的 QFile。我们使用 QFile 构造一个 QXmlStreamReader。QXmlStreamReader ::readNextStartElement() 函数读取直到 XML 流中的下一个起始元素。然后我们可以使用我们的读取辅助函数 readVariantFromStream() 从 XML 流中创建一个 QVariant 类。最后,我们可以使用我们的 Serializable::fromVariant() 来填充目标 serializable。让我们实现辅助函数 readVariantFromStream():
//XmlSerializer.h
private:
QVariant readVariantFromStream(QXmlStreamReader& stream);
//XmlSerializer.cpp
QVariant XmlSerializer::readVariantFromStream(QXmlStreamReader& stream)
{
QXmlStreamAttributes attributes = stream.attributes();
QString typeString = attributes.value("type").toString();
QVariant variant;
switch (QVariant::nameToType(
typeString.toStdString().c_str())) {
case QMetaType::QVariantList:
variant = readVariantListFromStream(stream);
break;
case QMetaType::QVariantMap:
variant = readVariantMapFromStream(stream);
break;
default:
variant = readVariantValueFromStream(stream);
break;
}
return variant;
}
这个函数的作用是创建一个 QVariant。首先,我们从 XML 属性中检索 "type"。在我们的例子中,我们只有一个属性需要处理。然后,根据类型,我们将调用我们三个读取辅助函数中的一个。让我们实现 readVariantValueFromStream() 函数:
//XmlSerializer.h
private:
QVariant readVariantValueFromStream(QXmlStreamReader& stream);
//XmlSerializer.cpp
QVariant XmlSerializer::readVariantValueFromStream(
QXmlStreamReader& stream)
{
QXmlStreamAttributes attributes = stream.attributes();
QString typeString = attributes.value("type").toString();
QString dataString = stream.readElementText();
QVariant variant(dataString);
variant.convert(QVariant::nameToType(
typeString.toStdString().c_str()));
return variant;
}
这个函数创建一个根据类型数据而定的 QVariant。和之前的函数一样,我们从 XML 属性中检索类型。我们同样使用 QXmlStreamReader::readElementText() 函数读取文本数据。使用这个 QString 数据创建一个 QVariant 类。在这个步骤中,QVariant 类型是 QString。因此,我们使用 QVariant::convert() 函数将 QVariant 转换为实际类型(int、qlonglong 等)。
第二个读取辅助函数是 readVariantListFromStream():
//XmlSerializer.h
private:
QVariant readVariantListFromStream(QXmlStreamReader& stream);
//XmlSerializer.cpp
QVariant XmlSerializer::readVariantListFromStream(QXmlStreamReader& stream)
{
QVariantList list;
while(stream.readNextStartElement()) {
list.append(readVariantFromStream(stream));
}
return list;
}
我们知道流元素包含一个数组。因此,这个函数创建并返回一个 QVariantList。QXmlStreamReader::readNextStartElement() 函数读取直到下一个起始元素,如果当前元素内找到起始元素则返回 true。我们为每个元素调用入口点函数 readVariantFromStream()。最后,我们返回 QVariantList。
最后要覆盖的辅助函数是 readVariantMapFromStream()。更新你的文件,使用以下片段:
//XmlSerializer.h
private:
QVariant readVariantMapFromStream(QXmlStreamReader& stream);
//XmlSerializer.cpp
QVariant XmlSerializer::readVariantMapFromStream(
QXmlStreamReader& stream)
{
QVariantMap map;
while(stream.readNextStartElement()) {
map.insert(stream.name().toString(),
readVariantFromStream(stream));
}
return map;
}
这个函数听起来像 readVariantListFromStream()。这次我们必须创建一个 QVariantMap。用于插入新项的键是元素名称。我们使用 QXmlStreamReader::name() 函数检索名称。
使用 XmlSerializer 序列化的 Track 类看起来像这样:
<?xml version="1.0" encoding="UTF-8"?>
<track type="QVariantMap">
<duration type="qlonglong">6205</duration>
<soundEvents type="QVariantList">
<item type="QVariantMap">
<soundId type="int">0</soundId>
<timestamp type="qlonglong">2689</timestamp>
</item>
<item type="QVariantMap">
<soundId type="int">2</soundId>
<timestamp type="qlonglong">2690</timestamp>
</item>
<item type="QVariantMap">
<soundId type="int">2</soundId>
<timestamp type="qlonglong">3067</timestamp>
</item>
</soundEvents>
</track>
以二进制格式序列化对象
XML 序列化已经完全可用!我们现在可以切换到本章中介绍的序列化的最后一种类型。
二进制序列化比较简单,因为 Qt 提供了一个直接的方法来做这件事。请创建一个继承自 Serializer 的 BinarySerializer 类。头文件是通用的,我们只有重写的函数,save() 和 load()。以下是 save() 函数的实现:
void BinarySerializer::save(const Serializable& serializable,
const QString& filepath, const QString& /*rootName*/)
{
QFile file(filepath);
file.open(QFile::WriteOnly);
QDataStream dataStream(&file);
dataStream << serializable.toVariant();
file.close();
}
我们希望你能认出在 第十章 中使用的 QDataStream 类,需要 IPC?让你的小弟去工作。这次我们使用这个类在目标 QFile 中序列化二进制数据。QDataStream 类接受一个带有 << 操作符的 QVariant 类。请注意,rootName 变量在二进制序列化器中没有被使用。
这里是 load() 函数:
void BinarySerializer::load(Serializable& serializable, const QString& filepath)
{
QFile file(filepath);
file.open(QFile::ReadOnly);
QDataStream dataStream(&file);
QVariant variant;
dataStream >> variant;
serializable.fromVariant(variant);
file.close();
}
多亏了 QVariant 和 QDataStream 机制,任务变得简单。我们使用源 filepath 打开 QFile。然后,我们使用这个 QFile 构造一个 QDataStream 类。然后,我们使用 >> 操作符读取根 QVariant。最后,我们使用 Serializable::fromVariant() 函数填充源 Serializable。
不要担心,我们不会包含使用 BinarySerializer 类序列化的 Track 类的示例。
序列化部分已完成。本例项目的 GUI 部分在本书的前几章中已经多次介绍。以下章节将仅涵盖在 MainWindow 和 SoundEffectWidget 类中使用的特定功能。如果需要完整的 C++ 类,请检查源代码。
使用 QSoundEffect 播放低延迟声音
项目应用程序 ch11-drum-machine 显示了四个 SoundEffectWidget 小部件:kickWidget、snareWidget、hihatWidget 和 crashWidget。
每个 SoundEffectWidget 小部件显示一个 QLabel 和一个 QPushButton。标签显示声音名称。如果按钮被点击,就会播放声音。
Qt 多媒体模块提供了两种主要方式来播放音频文件:
-
QMediaPlayer:这个文件可以播放歌曲、电影和互联网广播,支持各种输入格式 -
QSoundEffect:这个文件可以播放低延迟的.wav文件
这个项目示例是一个虚拟鼓机,所以我们使用了一个 QSoundEffect 对象。使用 QSoundEffect 的第一步是更新你的 .pro 文件,如下所示:
QT += core gui multimedia
然后,你可以初始化声音。以下是一个示例:
QUrl urlKick("qrc:/sounds/kick.wav");
QUrl urlBetterKick = QUrl::fromLocalFile("/home/better-kick.wav");
QSoundEffect soundEffect;
QSoundEffect.setSource(urlBetterKick);
第一步是为你的声音文件创建一个有效的 QUrl。urlKick 从 .qrc 资源文件路径初始化,而 urlBetterKick 是从本地文件路径创建的。然后我们可以创建 QSoundEffect 并使用 QSoundEffect::setSource() 函数设置要播放的 URL 声音。
现在我们已经初始化了一个 QSoundEffect 对象,我们可以使用以下代码片段来播放声音:
soundEffect.setVolume(1.0f);
soundEffect.play();
使用键盘触发 QButton
让我们来探索 SoundEffectWidget 类中的公共槽,triggerPlayButton():
//SoundEffectWidget.h
class SoundEffectWidget : public QWidget
{
...
public slots:
void triggerPlayButton();
...
private:
QPushButton* mPlayButton;
...
};
//SoundEffectWidget.cpp
void SoundEffectWidget::triggerPlayButton()
{
mPlayButton->animateClick();
}
这个小部件有一个名为 mPlayButton 的 QPushButton。triggerPlayButton() 槽调用 QPushButton::animateClick() 函数,默认情况下通过 100 毫秒模拟按钮点击。所有信号都将像真实点击一样发送。按钮看起来确实被按下了。如果你不想有动画,可以调用 QPushButton::click()。
现在我们来看看如何使用键盘触发这个槽。每个 SoundEffectWidget 都有一个 Qt:Key:
//SoundEffectWidget.h
class SoundEffectWidget : public QWidget
{
...
public:
Qt::Key triggerKey() const;
void setTriggerKey(const Qt::Key& triggerKey);
};
//SoundEffectWidget.cpp
Qt::Key SoundEffectWidget::triggerKey() const
{
return mTriggerKey;
}
void SoundEffectWidget::setTriggerKey(const Qt::Key& triggerKey)
{
mTriggerKey = triggerKey;
}
SoundEffectWidget 类提供了一个获取器和设置器来获取和设置成员变量 mTriggerKey。
MainWindow 类初始化其四个 SoundEffectWidget 的键如下:
ui->kickWidget->setTriggerKey(Qt::Key_H);
ui->snareWidget->setTriggerKey(Qt::Key_J);
ui->hihatWidget->setTriggerKey(Qt::Key_K);
ui->crashWidget->setTriggerKey(Qt::Key_L);
默认情况下,QObject::eventFilter() 函数不会被调用。为了启用它并拦截这些事件,我们需要在 MainWindow 上安装一个事件过滤器:
installEventFilter(this);
因此,每次 MainWindow 接收到事件时,都会调用 MainWindow::eventFilter() 函数。
这里是 MainWindow.h 头文件:
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
...
bool eventFilter(QObject* watched, QEvent* event) override;
private:
QVector<SoundEffectWidget*> mSoundEffectWidgets;
...
};
MainWindow 类有一个 QVector,包含四个 SoundEffectWidgets (kickWidget、snareWidget、hihatWidget 和 crashWidget)。让我们看看 MainWindow.cpp 中的实现:
bool MainWindow::eventFilter(QObject* watched, QEvent* event)
{
if (event->type() == QEvent::KeyPress) {
QKeyEvent* keyEvent = static_cast<QKeyEvent*>(event);
for(SoundEffectWidget* widget : mSoundEffectWidgets) {
if (keyEvent->key() == widget->triggerKey()) {
widget->triggerPlayButton();
return true;
}
}
}
return QObject::eventFilter(watched, event);
}
首先要做的是检查 QEvent 类是否为 KeyPress 类型。我们不关心其他事件类型。如果事件类型正确,我们继续下一步:
-
将
QEvent类转换为QKeyEvent。 -
然后我们搜索按下的键是否属于
SoundEffectWidget类。 -
如果
SoundEffectWidget类与键相对应,我们调用我们的SoundEffectWidget::triggerPlayButton()函数,并返回true以指示我们已消费该事件,并且它不得传播到其他类。 -
否则,我们调用
QObject类的eventFilter()实现。
使 PlaybackWorker 生动起来
用户可以通过鼠标点击或键盘键实时播放声音。但是,当他录制一个令人惊叹的节奏时,应用程序必须能够使用 PlaybackWorker 类再次播放它。让我们看看 MainWindow 如何使用这个工作器。以下是与 PlaybackWorker 类相关的 MainWindow.h:
class MainWindow : public QMainWindow
{
...
private slots:
void playSoundEffect(int soundId);
void clearPlayback();
void stopPlayback();
...
private:
void startPlayback();
...
private:
PlaybackWorker* mPlaybackWorker;
QThread* mPlaybackThread;
...
};
如您所见,MainWindow 有 PlaybackWorker 和一个 QThread 成员变量。让我们看看 startPlayback() 的实现:
void MainWindow::startPlayback()
{
clearPlayback();
mPlaybackThread = new QThread();
mPlaybackWorker = new PlaybackWorker(mTrack);
mPlaybackWorker->moveToThread(mPlaybackThread);
connect(mPlaybackThread, &QThread::started,
mPlaybackWorker, &PlaybackWorker::play);
connect(mPlaybackThread, &QThread::finished,
mPlaybackWorker, &QObject::deleteLater);
connect(mPlaybackWorker, &PlaybackWorker::playSound,
this, &MainWindow::playSoundEffect);
connect(mPlaybackWorker, &PlaybackWorker::trackFinished,
&mTrack, &Track::stop);
mPlaybackThread->start(QThread::HighPriority);
}
让我们分析所有步骤:
-
我们使用
clearPlayback()函数清除当前播放,这个功能很快就会介绍。 -
新的
QThread和PlaybackWorker被构造。此时,当前曲目被传递给工作器。像往常一样,工作器随后被移动到其专用线程。 -
我们希望尽快播放曲目。因此,当
QThread发出started()信号时,会调用PlaybackWorker::play()插槽。 -
我们不想担心
PlaybackWorker的内存。因此,当QThread结束并发送了finished()信号时,我们调用QObject::deleteLater()插槽,该插槽安排工作器进行删除。 -
当
PlaybackWorker类需要播放声音时,会发出playSound()信号,并调用我们的MainWindow::playSoundEffect()插槽。 -
最后一个连接覆盖了当
PlaybackWorker类播放完整个曲目时的情况。会发出一个trackFinished()信号,然后我们调用Track::Stop()插槽。 -
最后,以高优先级启动线程。请注意,某些操作系统(例如 Linux)不支持线程优先级。
现在我们可以看到 stopPlayback() 函数体:
void MainWindow::stopPlayback()
{
mPlaybackWorker->stop();
clearPlayback();
}
我们从我们的线程中调用 PlaybackWorker 的 stop() 函数。因为我们使用 QAtomicInteger 在 stop() 中,所以该函数是线程安全的,可以直接调用。最后,我们调用我们的辅助函数 clearPlayback()。这是我们第二次使用 clearPlayback(),所以让我们来实现它:
void MainWindow::clearPlayback()
{
if (mPlaybackThread) {
mPlaybackThread->quit();
mPlaybackThread->wait(1000);
mPlaybackThread = nullptr;
mPlaybackWorker = nullptr;
}
}
没有任何惊喜。如果线程有效,我们要求线程退出并等待 1 秒。然后,我们将线程和工作者设置为 nullptr。
PlaybackWorker::PlaySound 信号连接到 MainWindow::playSoundEffect()。以下是其实施:
void MainWindow::playSoundEffect(int soundId)
{
mSoundEffectWidgets[soundId]->triggerPlayButton();
}
此槽获取与 soundId 对应的 SoundEffectWidget 类。然后,我们调用 triggerPlayButton(),这是当你按下键盘上的触发键时调用的相同方法。
因此,当你点击按钮、按下一个键,或者当 PlaybackWorker 类请求播放声音时,SoundEffectWidget 的 QPushButton 会发出 clicked() 信号。这个信号连接到我们的 SoundEffectWidget::play() 槽。下一个片段描述了这个槽:
void SoundEffectWidget::play()
{
mSoundEffect.play();
emit soundPlayed(mId);
}
没有什么特别之处。我们在已经覆盖的 QSoundEffect 上调用 play() 函数。最后,如果我们处于 RECORDING 状态,我们发出 soundPlayed() 信号,该信号由 Track 用于添加新的 SoundEvent。
接受鼠标拖放事件
在这个项目示例中,如果你将 .wav 文件拖放到 SoundEffectWidget 上,你可以更改播放的声音。SoundEffectWidget 的构造函数执行特定任务以允许拖放:
setAcceptDrops(true);
我们现在可以覆盖拖放回调。让我们从 dragEnterEvent() 函数开始:
//SoundEffectWidget.h
class SoundEffectWidget : public QWidget
{
...
protected:
void dragEnterEvent(QDragEnterEvent* event) override;
...
};
//SoundEffectWidget.cpp
void SoundEffectWidget::dragEnterEvent(QDragEnterEvent* event)
{
if (event->mimeData()->hasFormat("text/uri-list")) {
event->acceptProposedAction();
}
}
dragEnterEvent() 函数会在用户在部件上拖动对象时被调用。在我们的例子中,我们只想允许拖放那些 MIME 类型为 "text/uri-list"(URI 列表,可以是 file://、http:// 等等)的文件。在这种情况下,尽管我们可以调用 QDragEnterEvent::acceptProposedAction() 函数来通知我们接受这个对象进行拖放。
我们现在可以添加第二个函数,dropEvent():
//SoundEffectWidget.h
class SoundEffectWidget : public QWidget
{
...
protected:
void dropEvent(QDropEvent* event) override;
...
};
//SoundEffectWidget.cpp
void SoundEffectWidget::dropEvent(QDropEvent* event)
{
const QMimeData* mimeData = event->mimeData();
if (!mimeData->hasUrls()) {
return;
}
const QUrl url = mimeData->urls().first();
QMimeType mime = QMimeDatabase().mimeTypeForUrl(url);
if (mime.inherits("audio/wav")) {
loadSound(url);
}
}
第一步是进行合理性检查。如果事件没有 URL,我们就不做任何事情。QMimeData::hasUrls() 函数仅在 MIME 类型为 "text/uri-text" 时返回 true。注意,用户可以一次性拖放多个文件。在我们的例子中,我们只处理第一个 URL。你可以检查文件是否为 .wav 文件,通过其 MIME 类型。如果 MIME 类型是 "audio/wav",我们调用 loadSound() 函数,该函数更新分配给此 SoundEffectWidget 的声音。
以下截图显示了 ch11-drum-machine 的完整应用程序:

摘要
序列化是在你关闭应用程序时使你的数据持久化的好方法。在本章中,你学习了如何使用 QVariant 使你的 C++ 对象可序列化。你使用桥接模式创建了一个灵活的序列化结构。你将对象保存为不同的文本格式,如 JSON 或 XML,以及二进制格式。
你还学会了使用 Qt 多媒体模块来播放一些音效。这些声音可以通过鼠标点击或键盘按键触发。你实现了友好的用户交互,允许你通过文件拖放来加载新的声音。
在下一章中,我们将发现 QTest 框架以及如何组织你的项目,使其具有清晰的应用程序/测试分离。
第十二章。使用 QTest(不)通过
在上一章中,我们创建了一个具有一些序列化功能的鼓机软件。在本章中,我们将为这个应用程序编写单元测试。为了实现这个目标,我们将使用 Qt Test,它是 Qt 应用程序的专用测试模块。
示例项目是一个使用 CLI 命令执行并生成测试报告的测试应用程序。我们将涵盖包括数据集、GUI、信号和基准测试在内的不同类型的测试。
本章将涵盖以下主题:
-
Qt Test 框架
-
单元测试的项目布局
-
个性化您的测试执行
-
使用数据集编写测试
-
基准测试您的代码
-
模拟 GUI 事件
-
使用
QSignalSpy类执行信号内省
发现 Qt Test
Qt 框架提供了 Qt Test,这是一个完整的 API,用于在 C++中创建您的单元测试。测试执行您的应用程序代码并对其进行验证。通常,测试会将一个变量与预期值进行比较。如果变量不匹配特定值,则测试失败。如果您想更进一步,您可以基准测试您的代码,并获取您的代码所需的时间/CPU 滴答/事件。不断点击 GUI 进行测试可能会很快变得无聊。Qt Test 为您提供在您的小部件上模拟键盘输入和鼠标事件的可能性,以完全检查您的软件。
在我们的案例中,我们想要创建一个名为drum-machine-test的单元测试程序。这个控制台应用程序将检查上一章中我们著名的鼓机代码。创建一个名为ch12-drum-machine-test的subdirs项目,其拓扑结构如下:
-
drum-machine:drum-machine.pro
-
drum-machine-test:drum-machine-test.pro
-
ch12-drum-machine-test.pro -
drum-machine-src.pri
drum-machine和drum-machine-test项目共享相同的源代码。因此,所有公共文件都放在一个项目包含文件中:drum-machine-src.pri。以下是更新的drum-machine.pro:
QT += core gui multimedia widgets
CONFIG += c++14
TARGET = drum-machine
TEMPLATE = app
include(../drum-machine-src.pri)
SOURCES += main.cpp
如您所见,我们只执行重构任务;鼓机项目不受鼓机测试应用程序的影响。您现在可以创建如下所示的drum-machine-test.pro文件:
QT += core gui multimedia widgets testlib
CONFIG += c++14 console
TARGET = drum-machine-test
TEMPLATE = app
include(../drum-machine-src.pri)
DRUM_MACHINE_PATH = ../drum-machine
INCLUDEPATH += $$DRUM_MACHINE_PATH
DEPENDPATH += $$DRUM_MACHINE_PATH
SOURCES += main.cpp
首先要注意的是,我们需要启用testlib模块。然后即使我们正在创建控制台应用程序,我们也想对 GUI 进行测试,因此还需要使用主要应用程序所使用的模块(gui、multimedia和widgets)。最后,我们将包含所有应用程序文件(源文件、头文件、表单和资源)的项目包含文件。drum-machine-test应用程序还将包含新的源文件,因此我们必须正确设置INCLUDEPATH和DEPENDPATH变量到源文件文件夹。
Qt Test 易于使用,并基于一些简单的假设:
-
测试用例是一个
QObject类 -
私有槽是一个测试函数
-
测试用例可以包含多个测试函数
注意,以下名称的私有槽不是测试函数,而是自动调用来初始化和清理测试的特殊函数:
-
initTestCase(): 这个函数在第一个测试函数之前被调用 -
init(): 这个函数在每个测试函数之前被调用 -
cleanup(): 这个函数在每个测试函数之后被调用 -
cleanupTestCase(): 这个函数在最后一个测试函数之后被调用
好的,我们已经准备好在drum-machine-test应用程序中编写第一个测试用例。drum-machine对象的序列化是一个重要的部分。对保存功能的错误修改可以轻易地破坏加载功能。它可能在编译时不会产生错误,但它可能导致无法使用的应用程序。这就是为什么测试很重要的原因。首先,我们需要验证序列化/反序列化过程。创建一个新的 C++类,DummySerializable。以下是头文件:
#include "Serializable.h"
class DummySerializable : public Serializable
{
public:
DummySerializable();
QVariant toVariant() const override;
void fromVariant(const QVariant& variant) override;
int myInt = 0;
double myDouble = 0.0;
QString myString = "";
bool myBool = false;
};
这是一个简单的类,实现了我们在第十一章中创建的Serializable接口,与序列化一起玩乐。这个类将有助于验证我们序列化过程中的底层。正如你所见,该类包含了一些不同类型的变量,以确保完整的序列化功能。让我们看看文件,DummySerializable.cpp:
#include "DummySerializable.h"
DummySerializable::DummySerializable() :
Serializable()
{
}
QVariant DummySerializable::toVariant() const
{
QVariantMap map;
map.insert("myInt", myInt);
map.insert("myDouble", myDouble);
map.insert("myString", myString);
map.insert("myBool", myBool);
return map;
}
void DummySerializable::fromVariant(const QVariant& variant)
{
QVariantMap map = variant.toMap();
myInt = map.value("myInt").toInt();
myDouble = map.value("myDouble").toDouble();
myString = map.value("myString").toString();
myBool = map.value("myBool").toBool();
}
没有惊喜;我们使用QVariantMap执行操作,就像在上一章中已经执行的那样。我们的虚拟类已经准备好了;创建一个新的 C++类,TestJsonSerializer,其头文件如下:
#include <QtTest/QTest>
#include "JsonSerializer.h"
class TestJsonSerializer : public QObject
{
Q_OBJECT
public:
TestJsonSerializer(QObject* parent = nullptr);
private slots:
void cleanup();
void saveDummy();
void loadDummy();
private:
QString loadFileContent();
private:
JsonSerializer mSerializer;
};
到这里,我们的第一个测试用例!这个测试用例对我们类JsonSerializer进行验证。你可以看到两个测试函数,saveDummy()和loadDummy()。cleanup()槽是之前提到的特殊 Qt 测试槽,它在每个测试函数之后执行。我们现在可以在TestJsonSerializer.cpp中编写实现:
#include "DummySerializable.h"
const QString FILENAME = "test.json";
const QString DUMMY_FILE_CONTENT = "{\n "myBool": true,\n "myDouble": 5.2,\n "myInt": 1,\n "myString": "hello"\n}\n";
TestJsonSerializer::TestJsonSerializer(QObject* parent) :
QObject(parent),
mSerializer()
{
}
这里创建了两个常量:
-
FILENAME: 这是用于测试保存和加载数据的文件名 -
DUMMY_FILE_CONTENT: 这是测试函数saveDummy()和loadDummy()使用的参考文件内容
让我们实现测试函数,saveDummy():
void TestJsonSerializer::saveDummy()
{
DummySerializable dummy;
dummy.myInt = 1;
dummy.myDouble = 5.2;
dummy.myString = "hello";
dummy.myBool = true;
mSerializer.save(dummy, FILENAME);
QString data = loadFileContent();
QVERIFY(data == DUMMY_FILE_CONTENT);
}
第一步是使用一些固定值实例化一个DummySerializable类。因此,我们调用测试函数,JsonSerializer::save(),它将在test.json文件中序列化我们的虚拟对象。然后,我们调用一个辅助函数,loadFileContent(),以获取test.json文件中的文本。最后,我们使用一个 Qt 测试宏,QVERIFY(),来执行验证,即 JSON 序列化器保存的文本与DUMMY_FILE_CONTENT中预期的值相同。如果data等于正确的值,则测试函数成功。以下是日志输出:
PASS : TestJsonSerializer::saveDummy()
如果数据与预期值不同,则测试失败,并在控制台日志中显示错误:
FAIL! : TestJsonSerializer::saveDummy()
'data == DUMMY_FILE_CONTENT' returned FALSE. ()
Loc: [../../ch12-drum-machine-test/drum-machine-test/TestJsonSerializer.cpp(31)]
让我们简要看看辅助函数,loadFileContent():
QString TestJsonSerializer::loadFileContent()
{
QFile file(FILENAME);
file.open(QFile::ReadOnly);
QString content = file.readAll();
file.close();
return content;
}
这没什么大不了的。我们打开文件 test.json,读取所有文本内容,并返回相应的 QString。
宏 QVERIFY() 非常适合检查布尔值,但 Qt 测试在你想比较数据与预期值时提供了一个更好的宏。让我们通过测试函数 loadDummy() 来发现 QCOMPARE():
void TestJsonSerializer::loadDummy()
{
QFile file(FILENAME);
file.open(QFile::WriteOnly | QIODevice::Text);
QTextStream out(&file);
out << DUMMY_FILE_CONTENT;
file.close();
DummySerializable dummy;
mSerializer.load(dummy, FILENAME);
QCOMPARE(dummy.myInt, 1);
QCOMPARE(dummy.myDouble, 5.2);
QCOMPARE(dummy.myString, QString("hello"));
QCOMPARE(dummy.myBool, true);
}
第一部分创建一个 test.json 文件,包含参考内容。然后我们创建一个空的 DymmySerializable 并调用函数来测试 Serializable::load()。最后,我们使用 Qt 测试宏 QCOMPARE()。语法很简单:
QCOMPARE(actual_value, expected_value);
我们现在可以测试从 JSON 加载的模拟对象的每个字段。测试函数 loadDummmy() 只有在所有 QCOMPARE() 调用都成功时才会成功。QCOMPARE() 错误会更加详细:
FAIL! : TestJsonSerializer::loadDummy() Compared values are not the same
Actual (dummy.myInt): 0
Expected (1) : 1
Loc: [../../ch12-drum-machine-test/drum-machine-test/TestJsonSerializer.cpp(45)]
每次执行测试函数时,都会调用特殊的 cleanup() 槽。让我们更新你的文件 TestJsonSerializable.cpp,如下所示:
void TestJsonSerializer::cleanup()
{
QFile(FILENAME).remove();
}
这是一个简单的安全措施,将在每个测试函数之后删除 test.json 文件,并防止保存和加载测试冲突。
执行你的测试
我们编写了一个测试用例 TestJsonSerializer,其中包含一些测试函数。我们在 drum-machine-test 应用程序中需要一个 main() 函数。我们将探讨三种可能性:
-
QTEST_MAIN()函数 -
编写我们自己的简单
main()函数 -
编写我们自己的支持多个测试类的增强
main()
QTest 模块提供了一个有趣的宏,QTEST_MAIN()。此宏为你的应用程序生成一个完整的 main() 函数。此生成方法运行你的测试用例中的所有测试函数。要使用它,请将以下片段添加到 TestJsonSerializer.cpp 文件末尾:
QTEST_MAIN(TestJsonSerializer)
此外,如果你仅在 .cpp 文件中声明和实现测试类(没有头文件),你需要在 QTEST_MAIN 宏之后包含生成的 moc 文件:
QTEST_MAIN(TestJsonSerializer)
#include "testjsonserializer"
如果你使用 QTEST_MAIN() 宏,不要忘记删除现有的 main.cpp。否则,你将有两个 main() 函数,并且会发生编译错误。
你现在可以尝试运行你的鼓机测试应用程序并查看应用程序输出。你应该看到类似以下内容:
$ ./drum-machine-test
********* Start testing of TestJsonSerializer *********
Config: Using QtTest library 5.7.0, Qt 5.7.0 (x86_64-little_endian-lp64 shared (dynamic) release build; by GCC 4.9.1 20140922 (Red Hat 4.9.1-10))
PASS : TestJsonSerializer::initTestCase()
PASS : TestJsonSerializer::saveDummy()
PASS : TestJsonSerializer::loadDummy()
PASS : TestJsonSerializer::cleanupTestCase()
Totals: 4 passed, 0 failed, 0 skipped, 0 blacklisted, 1ms
********* Finished testing of TestJsonSerializer *********
我们的测试函数,saveDummy() 和 loadDummy(),按照声明顺序执行。两者都成功,状态为 PASS。生成的测试应用程序处理了一些选项。通常,你可以通过执行以下命令来显示帮助菜单:
$ ./drum-machine-test -help
让我们看看一些酷炫的功能。我们可以通过名称执行单个函数。以下命令仅执行 saveDummy 测试函数:
$ ./drum-machine-test saveDummy
你也可以通过空格分隔名称来执行多个测试函数。
QTest 应用程序提供了日志详细选项:
-
-silent用于静默模式。仅显示致命错误和摘要信息。 -
-v1用于详细输出。显示测试函数输入的信息。 -
-v2用于扩展详细输出。显示每个QCOMPARE()和QVERIFY()。 -
-vs用于详细信号。显示发出的信号和连接的槽。
例如,我们可以使用以下命令显示 loadDummy 执行的详细信息:
$ ./drum-machine-test -v2 loadDummy
********* Start testing of TestJsonSerializer *********
Config: Using QtTest library 5.7.0, Qt 5.7.0 (x86_64-little_endian-lp64 shared (dynamic) release build; by GCC 4.9.1 20140922 (Red Hat 4.9.1-10))
INFO : TestJsonSerializer::initTestCase() entering
PASS : TestJsonSerializer::initTestCase()
INFO : TestJsonSerializer::loadDummy() entering
INFO : TestJsonSerializer::loadDummy() QCOMPARE(dummy.myInt, 1)
Loc: [../../ch12-drum-machine-test/drum-machine-test/TestJsonSerializer.cpp(45)]
INFO : TestJsonSerializer::loadDummy() QCOMPARE(dummy.myDouble, 5.2)
Loc: [../../ch12-drum-machine-test/drum-machine-test/TestJsonSerializer.cpp(46)]
INFO : TestJsonSerializer::loadDummy() QCOMPARE(dummy.myString, QString("hello"))
Loc: [../../ch12-drum-machine-test/drum-machine-test/TestJsonSerializer.cpp(47)]
INFO : TestJsonSerializer::loadDummy() QCOMPARE(dummy.myBool, true)
Loc: [../../ch12-drum-machine-test/drum-machine-test/TestJsonSerializer.cpp(48)]
PASS : TestJsonSerializer::loadDummy()
INFO : TestJsonSerializer::cleanupTestCase() entering
PASS : TestJsonSerializer::cleanupTestCase()
Totals: 3 passed, 0 failed, 0 skipped, 0 blacklisted, 1ms
********* Finished testing of TestJsonSerializer *********
另一个很棒的功能是日志输出格式。您可以使用各种格式(如 .txt、.xml、.csv 等)创建测试报告文件。语法要求一个文件名和一个文件格式,由逗号分隔:
$ ./drum-machine-test -o <filename>,<format>
在以下示例中,我们创建了一个名为 test-report.xml 的 XML 报告:
$ ./drum-machine-test -o test-report.xml,xml
注意,某些日志级别仅影响纯文本输出。此外,CSV 格式只能与测试宏 QBENCHMARK 一起使用,该宏将在本章后面介绍。
如果您想自定义生成的测试应用程序,您可以编写 main() 函数。从 TestJsonSerializer.cpp 中移除 QTEST_MAIN 宏。然后创建一个类似以下的 main.cpp:
#include "TestJsonSerializer.h"
int main(int argc, char *argv[])
{
TestJsonSerializer test;
QStringList arguments = QCoreApplication::arguments();
return QTest::qExec(&test, arguments);
}
在这种情况下,我们使用静态函数 QTest::qExec() 来启动 TestJsonSerializer 测试。不要忘记提供命令行参数以享受 QTest CLI 选项。
如果您将测试函数写在不同的测试类中,您将创建一个由测试类生成的一个应用程序。如果您按测试应用程序保留一个测试类,甚至可以使用 QTEST_MAIN 宏来生成主函数。
有时候您只想创建一个测试应用程序来处理所有测试类。在这种情况下,您在同一个应用程序中有多个测试类,因此您不能使用 QTEST_MAIN 宏,因为您不希望为每个测试类生成多个主函数。
让我们看看一种简单的方法来调用所有测试类在一个独特应用程序中:
int main(int argc, char *argv[])
{
int status = 0;
TestFoo testFoo;
TestBar testBar;
status |= QTest::qExec(&testFoo);
status |= QTest::qExec(&testBar);
return status;
}
在这个简单的自定义 main() 函数中,我们正在执行 TestFoo 和 TestBar 测试。但我们失去了 CLI 选项。实际上,多次使用带有命令行参数的 QTest::qExec() 函数会导致错误和不良行为。例如,如果您只想从 TestBar 中执行一个特定的测试函数。TestFoo 的执行将找不到测试函数,显示错误消息,并停止应用程序。
这里有一个处理一个应用程序中多个测试类的解决方案。我们将为我们的测试应用程序创建一个新的命令行选项 -select。此选项允许您选择要执行的特定测试类。以下是一个语法示例:
$ ./drum-machine-test -select foo fooTestFunction
如果使用 -select 选项,则必须将其放在命令的开头,后跟测试类名称(在这个例子中是 foo)。然后,我们可以选择性地添加 Qt 测试选项。为了实现这个目标,我们将创建一个增强型的 main() 函数,该函数解析新的 select 选项并执行相应的测试类。
我们将一起创建我们的增强型 main() 函数:
QApplication app(argc, argv);
QStringList arguments = QCoreApplication::arguments();
map<QString, unique_ptr<QObject>> tests;
tests.emplace("jsonserializer",
make_unique<TestJsonSerializer>());
tests.emplace("foo", make_unique<TestFoo>());
tests.emplace("bar", make_unique<TestBar>());
QApplication将在我们之后的其他 GUI 测试用例中需要。我们检索命令行参数以供以后使用。名为tests的std::map模板包含测试类的智能指针,而QString标签用作键。请注意,我们正在使用map::emplace()函数,该函数不会将源复制到映射中,而是在原地创建它。使用map::insert()函数会导致错误,因为智能指针的复制是不合法的。还可以使用std::map模板和make_unique一起使用的另一种语法是:
tests["bar"] = make_unique<TestBar>();
现在我们可以解析命令行参数:
if (arguments.size() >= 3 && arguments[1] == "-select") {
QString testName = arguments[2];
auto iter = tests.begin();
while(iter != tests.end()) {
if (iter->first != testName) {
iter = tests.erase(iter);
} else {
++iter;
}
}
arguments.removeOne("-select");
arguments.removeOne(testName);
}
如果使用-select选项,此代码段执行两个重要任务:
-
从
tests映射中删除与测试名称不匹配的测试类 -
从
-select选项和testName变量中删除参数,为QTest::qExec()函数提供干净的参数
现在我们可以添加执行测试类的最后一步:
int status = 0;
for(auto& test : tests) {
status |= QTest::qExec(test.second.get(), arguments);
}
return status;
如果没有使用-select选项,将执行所有测试类。如果我们使用带有测试类名称的-select选项,则只执行这个类。
使用数据集编写因子化测试
我们现在将注意力转向测试Track类。我们将特别关注Track类可以具有的不同状态:STOPPED、PLAYING和RECORDING。对于这些状态中的每一个,我们都要确保只有在正确的状态(RECORDING)下添加SoundEvents才会生效。
要做到这一点,我们可以编写以下测试函数:
-
testAddSoundEvent(): 这个函数将Track置于STOPPED状态,调用track.addSoundEvent(0),并检查track.soundEvents().size == 0 -
testAddSoundEvent(): 这个函数将Track置于PLAYING状态,调用track.addSoundEvent(0),并检查track.soundEvents().size == 0 -
testAddSoundEvent(): 这个函数将Track置于RECORDING状态,调用track.addSoundEvent(0),并检查track.soundEvents().size == 1
如您所见,逻辑是相同的,我们只是更改了输入和期望的输出。为了简化,Qt Test 提供了一个另一个模块:数据集。
数据集可以看作是一个二维表,其中每一行是一个测试,列是输入和期望的输出。对于我们的Track状态测试,它看起来会是这样:

使用这种方法,你只需编写一个addSoundEvent()测试函数,Qt Test 就会负责遍历这个表格并比较结果。目前,这看起来像是魔法。让我们来实现它!
创建一个名为TestTrack的新 C++类,遵循与TestJsonSerializer类相同的模式(继承自QObject,包含QTest)。更新TestTrack.h如下:
class TestTrack : public QObject
{
Q_OBJECT
public:
explicit TestTrack(QObject *parent = 0);
private slots:
void addSoundEvent_data();
void addSoundEvent();
};
在这里我们添加了两个函数:
-
addSoundEvent_data(): 这是一个填充真实测试数据集的函数 -
addSoundEvent(): 这是一个执行测试的函数
如您所见,为给定 xxx() 函数填充数据集的函数必须命名为 xxx_data()。让我们看看 addSoundEvent_data() 的实现:
void TestTrack::addSoundEvent_data()
{
QTest::addColumn<int>("trackState");
QTest::addColumn<int>("soundEventCount");
QTest::newRow("STOPPED")
<< static_cast<int>(Track::State::STOPPED)
<< 0;
QTest::newRow("PLAYING")
<< static_cast<int>(Track::State::PLAYING)
<< 0;
QTest::newRow("RECORDING")
<< static_cast<int>(Track::State::RECORDING)
<< 1;
}
如您所见,数据集的构建就像一个表格。我们首先使用 trackState 和 soundEventCount 列定义表格的结构。请注意,QTest::addColumn 依赖于模板来了解变量的类型(两种情况下都是 int)。
之后,使用 QTest::newRow() 函数将一行添加到表中,并将测试名称作为参数传递。QTest::newRow 语法支持 << 操作符,这使得为给定行打包所有数据变得非常容易。
注意,添加到数据集的每一行都对应于 addSoundEvent() 函数的一次执行,其中该行的数据将可用。
我们现在可以将注意力转向 addSoundEvent():
void TestTrack::addSoundEvent()
{
QFETCH(int, trackState);
QFETCH(int, soundEventCount);
Track track;
switch (static_cast<Track::State>(trackState)) {
case Track::State::STOPPED:
track.stop();
break;
case Track::State::PLAYING:
track.play();
break;
case Track::State::RECORDING:
track.record();
break;
default:
break;
}
track.addSoundEvent(0);
track.stop();
QCOMPARE(track.soundEvents().size(),
static_cast<size_t>(soundEventCount));
}
因为 addSoundEvent() 是由 QTest 执行的,并且提供了数据集数据,所以我们可以安全地访问数据集的当前行,就像在数据库中使用游标一样。QFETCH(int, trackState) 是一个有用的宏,它执行两个操作:
-
声明一个名为
trackState的int变量 -
获取数据集当前列索引数据并将其内容存储在
trackState中
同样的原则也应用于 soundEventCount。现在我们已经有了所需的轨道状态和预期的声音事件计数,我们可以继续进行实际测试:
-
根据
trackState将轨道置于适当的状态。请记住,Track::setState()函数是私有的,因为Track关键字独立处理trackState变量,基于调用者的指令(stop()、play()、record())。 -
尝试向轨道添加一个
SoundEvent。 -
停止轨道。
-
将轨道中的
SoundEvents数量与soundEventCount中预期的数量进行比较。
不要忘记在 main.cpp 中添加 TestTrack 类:
#include "TestJsonSerializer.h"
#include "TestTrack.h"
...
int main(int argc, char *argv[])
{
...
map<QString, unique_ptr<QObject>> tests;
tests.emplace("jsonserializer",
make_unique<TestJsonSerializer>());
tests.emplace("track",
make_unique<TestTrack>());
...
}
您现在可以运行测试,并看到 addSoundEvent() 的三个测试在控制台输出其结果:
PASS : TestTrack::addSoundEvent(STOPPED)
PASS : TestTrack::addSoundEvent(PLAYING)
PASS : TestTrack::addSoundEvent(RECORDING)
数据集通过为单个测试的数据变体进行因式分解,使得编写测试不那么枯燥。
您还可以使用命令行对数据集的特定条目运行单个测试:
$ ./drum-machine-test <testfunction>:<dataset entry>
假设我们只想使用 RECORDING 状态执行 TestTrack 中的测试函数 addSoundEvent()。以下是运行命令行:
$ ./drum-machine-test -select track addSoundEvent:RECORDING
代码基准测试
Qt Test 还提供了一个非常易于使用的语义来基准测试代码的执行速度。为了看到它的实际效果,我们将基准测试将 Track 保存为 JSON 格式所需的时间。根据轨道长度(SoundEvents 的数量),序列化所需的时间会有所不同。
当然,使用不同的轨道长度基准测试此功能更有趣,看看时间节省是否呈线性。数据集可以提供帮助!它不仅适用于运行具有预期输入和输出的相同函数,还适用于运行具有不同参数的相同函数。
我们将首先在TestJsonSerializer中创建数据集函数:
class TestJsonSerializer : public QObject
{
...
private slots:
void cleanup();
void saveDummy();
void loadDummy();
void saveTrack_data();
...
};
void TestJsonSerializer::saveTrack_data()
{
QTest::addColumn<int>("soundEventCount");
QTest::newRow("1") << 1;
QTest::newRow("100") << 100;
QTest::newRow("1000") << 1000;
}
saveTrack_data()函数简单地存储在保存之前要添加到Track类中的SoundEvent数量。"1"、"100"和"1000"字符串在这里是为了在测试执行输出中有清晰的标签。这些字符串将在每次执行saveTrack()时显示。请随意调整这些数字!
现在进行真正的测试,使用基准调用:
class TestJsonSerializer : public QObject
{
...
void saveTrack_data();
void saveTrack();
...
};
void TestJsonSerializer::saveTrack()
{
QFETCH(int, soundEventCount);
Track track;
track.record();
for (int i = 0; i < soundEventCount; ++i) {
track.addSoundEvent(i % 4);
}
track.stop();
QBENCHMARK {
mSerializer.save(track, FILENAME);
}
}
saveTrack()函数首先从其数据集中获取soundEventCount列。之后,它添加正确数量的soundEvent(具有正确的record()状态!)并最终在 JSON 格式中进行序列化基准测试。
你可以看到,基准测试本身只是一个看起来像这样的宏:
QBENCHMARK {
// instructions to benchmark
}
包含在QBENCHMARK宏中的指令将被自动测量。如果你使用更新的TestJsonSerializer类执行测试,你应该看到类似以下输出:
PASS : TestJsonSerializer::saveTrack(1)
RESULT : TestJsonSerializer::saveTrack():"1":
0.041 msecs per iteration (total: 84, iterations: 2048)
PASS : TestJsonSerializer::saveTrack(100)
RESULT : TestJsonSerializer::saveTrack():"100":
0.23 msecs per iteration (total: 59, iterations: 256)
PASS : TestJsonSerializer::saveTrack(1000)
RESULT : TestJsonSerializer::saveTrack():"1000":
2.0 msecs per iteration (total: 66, iterations: 32)
如你所见,QBENCHMARK宏使 Qt Test 输出非常有趣的数据。为了保存一个包含单个SoundEvent的Track类,花费了 0.041 毫秒。Qt Test 重复了这个测试 2048 次,总共花费了 84 毫秒。
在接下来的测试中,QBENCHMARK 宏的力量开始显现。在这里,saveTrack() 函数尝试保存一个包含 100 个SoundEvents的Track类。完成这个操作花费了 0.23 毫秒,并且指令重复了 256 次。这表明 Qt Test 基准会自动根据单次迭代的平均时间调整迭代次数。
QBENCHMARK宏有这种行为,因为如果重复多次(以避免可能的外部噪声),指标往往更准确。
小贴士
如果你想要你的测试在没有多次迭代的情况下进行基准测试,请使用QBENCHMARK_ONCE。
如果你使用命令行执行测试,你可以向QBENCHMARK提供额外的指标。以下是总结可用选项的表格:
| 名称 | 命令行参数 | 可用性 |
|---|---|---|
| 墙时 | (默认) | 所有平台 |
| CPU tick 计数器 | -tickcounter |
Windows、OS X、Linux、许多类 UNIX 系统。 |
| 事件计数器 | -eventcounter |
所有平台 |
| Valgrind Callgrind | -callgrind |
Linux(如果已安装) |
| Linux Perf | -perf |
Linux |
这些选项中的每一个都将替换用于测量基准测试代码执行时间的所选后端。例如,如果你使用-tickcounter参数运行drum-machine-test:
$ ./drum-machine-test -tickcounter
...
RESULT : TestJsonSerializer::saveTrack():"1":
88,062 CPU cycles per iteration (total: 88,062, iterations: 1)
PASS : TestJsonSerializer::saveTrack(100)
RESULT : TestJsonSerializer::saveTrack():"100":
868,706 CPU cycles per iteration (total: 868,706, iterations: 1)
PASS : TestJsonSerializer::saveTrack(1000)
RESULT : TestJsonSerializer::saveTrack():"1000":
7,839,871 CPU cycles per iteration (total: 7,839,871, iterations: 1)
...
你可以看到,以毫秒为单位测量的墙时已经替换为每个迭代完成的 CPU 周期数。
另一个有趣的选择是-eventcounter,它测量在发送到相应目标之前事件循环接收到的数字。这可能是一种检查你的代码是否发出正确数量信号的好方法。
测试你的 GUI
现在是时候看看你如何使用 Qt 测试 API 测试你的 GUI 了。QTest类提供了几个函数来模拟键盘和鼠标事件。
为了演示它,我们将继续使用测试Track状态的概念,但提升到一个更高的层面。而不是测试Track状态本身,我们将检查当Track状态改变时,drum-machine应用程序的 UI 状态是否被正确更新。具体来说,当开始录音时,控制按钮(播放、停止、录音)应该处于特定的状态。
首先,在drum-machine-test项目中创建一个TestGui类。别忘了在main.cpp的tests映射中添加TestGui类。像往常一样,让它继承QObject并更新TestGui.h如下:
#include <QTest>
#include "MainWindow.h"
class TestGui : public QObject
{
Q_OBJECT
public:
TestGui(QObject* parent = 0);
private:
MainWindow mMainWindow;
};
在这个头文件中,我们有一个成员mMainWindow,它是drum-machine项目中的MainWindow关键字的实例。在整个TestGui测试过程中,将使用单个MainWindow,我们将注入事件并检查其反应。
让我们切换到TestGui构造函数:
#include <QtTest/QtTest>
TestGui::TestGui(QObject* parent) :
QObject(parent),
mMainWindow()
{
QTestEventLoop::instance().enterLoop(1);
}
构造函数初始化了mMainWindow变量。请注意,mMainWindow从未被显示(使用mMainWindow.show())。我们不需要显示它,我们只想测试其状态。
在这里,我们使用一个相当晦涩的函数调用(QTestEventLoop完全没有文档说明)来强制事件循环在 1 秒后开始。
我们必须这样做的原因在于QSoundEffect类。QSoundEffect类在调用QSoundEffect::setSource()函数时被初始化(在MainWindow中,这是在SoundEffectWidgets的初始化时完成的)。如果我们省略显式的enterLoop()调用,drum-machine-test执行将因段错误而崩溃。
似乎必须显式进入事件循环,以便让QSoundEffect类正确完成初始化。我们通过研究QSoundEffect类的 Qt 单元测试找到了这个未记录的解决方案。
现在进行真正的 GUI 测试!为了测试控制按钮,更新TestGui:
// In TestGui.h
class TestGui : public QObject
{
...
private slots:
void controlButtonState();
...
};
// In TestGui.cpp
#include <QtTest/QtTest>
#include <QPushButton>
...
void TestGui::controlButtonState()
{
QPushButton* stopButton =
mMainWindow.findChild<QPushButton*>("stopButton");
QPushButton* playButton =
mMainWindow.findChild<QPushButton*>("playButton");
QPushButton* recordButton =
mMainWindow.findChild<QPushButton*>("recordButton");
QTest::mouseClick(recordButton, Qt::LeftButton);
QCOMPARE(stopButton->isEnabled(), true);
QCOMPARE(playButton->isEnabled(), false);
QCOMPARE(recordButton->isEnabled(), false);
}
在controlButtonState()函数中,我们首先使用方便的mMainWindow.findChild()函数检索我们的按钮。这个函数在QObject中可用,并且传递的名称对应于我们在 Qt Designer 中创建MainWindow.ui时为每个按钮使用的objectName变量。
一旦我们检索到所有按钮,我们使用QTest::mouseClick()函数注入一个鼠标点击事件。它需要一个QWidget*参数作为目标以及应该被点击的按钮。你甚至可以传递键盘修饰符(控制、shift 等)和可能的点击延迟(以毫秒为单位)。
一旦点击了recordButton,我们就测试所有控制按钮的状态,以确保它们处于期望的启用状态。
备注
这个函数可以很容易地扩展以测试所有状态(PLAYING、STOPPED、RECORDING),其中输入是期望的状态,输出是预期的按钮状态。
QTest类提供了许多有用的函数来注入事件,包括:
-
keyEvent(): 这个函数用于模拟按键事件 -
keyPress(): 这个函数用于模拟按键按下事件 -
keyRelease(): 这个函数用于模拟按键释放事件 -
mouseClick(): 这个函数用于模拟鼠标点击事件 -
mouseDClick(): 这个函数用于模拟鼠标双击事件 -
mouseMove(): 这个函数用于模拟鼠标移动事件
使用 QSignalSpy 监视你的应用程序
在 Qt 测试框架中,我们将要讨论的最后一部分是使用QSignalSpy监视信号的能力。这个类允许你对任何QObject发出的信号进行内省。
让我们通过SoundEffectWidget来观察它的实际效果。我们将测试当调用SoundEffectWidget::play()函数时,soundPlayed信号会带有正确的soundId参数被触发。
这里是TestGui的playSound()函数:
#include <QTest>
#include "MainWindow.h"
// In TestGui.h
class TestGui : public QObject
{
...
void controlButtonState();
void playSound();
...
};
// In TestGui.cpp
#include <QPushButton>
#include <QtTest/QtTest>
#include "SoundEffectWidget.h"
...
void TestGui::playSound()
{
SoundEffectWidget widget;
QSignalSpy spy(&widget, &SoundEffectWidget::soundPlayed);
widget.setId(2);
widget.play();
QCOMPARE(spy.count(), 1);
QList<QVariant> arguments = spy.takeFirst();
QCOMPARE(arguments.at(0).toInt(), 2);
}
我们首先初始化一个SoundEffectWidget小部件和一个QSignalSpy类。spy类的构造函数接受要监视的对象的指针以及要监视的信号的成员函数的指针。在这里,我们想检查SoundEffectWidget::soundPlayed()信号。
然后,widget被配置了一个任意的soundId(2)并且调用了widget.play()。这里变得有趣的是:spy将信号触发的参数存储在QVariantList中。每次soundPlayed()被触发时,spy中都会创建一个新的QVariantList,其中包含触发的参数。
第一步是检查信号只触发一次,通过比较spy.count()与1。紧接着,我们将这个信号的参数存储在arguments中,并检查它的值是否为2,这是widget配置的初始soundId。
如你所见,QSignalSpy使用简单;你可以为每个你想要监视的信号创建任意数量的实例。
摘要
Qt 测试模块优雅地帮助我们轻松创建测试应用程序。你学会了如何使用独立的测试应用程序来组织你的项目。你能够比较和验证简单测试中的特定值。对于复杂的测试,你可以使用数据集。你实现了一个简单的基准测试,记录执行函数所需的时间或 CPU 周期数。你已经模拟了 GUI 事件并监视 Qt 信号以确保你的应用程序运行良好。
你的应用程序已经创建,单元测试显示通过状态。在下一章中,我们将学习如何部署你的应用程序。
第十三章。全部打包,准备部署
在上一章中,您学习了如何创建具有单元测试的健壮应用程序。应用程序的最终步骤是打包。Qt 框架使您能够开发跨平台应用程序,但打包实际上是一个特定于平台的任务。此外,当您的应用程序准备发货时,您需要一个一步到位的流程来生成和打包您的应用程序。
在本章中,我们将重用画廊应用程序(包括桌面和移动平台)来学习打包 Qt 应用程序所需的步骤。准备应用程序打包的方法有很多。在本章中,我们想要打包画廊应用程序,从 第四章,征服桌面 UI 和 第五章,主宰移动 UI 在支持的平台上(Windows、Linux、Mac、Android 和 iOS)。
本章涵盖了以下主题:
-
在 Windows 上打包 Qt 应用程序
-
在 Linux 上打包 Qt 应用程序
-
在 Mac 上打包 Qt 应用程序
-
在 Android 上打包 Qt 应用程序
-
在 iOS 上打包 Qt 应用程序
打包您的应用程序
您将为每个平台创建一个专门的脚本,以执行构建独立应用程序所需的所有任务。根据操作系统类型,打包的应用程序将是 gallery-desktop 或 gallery-mobile。因为整个画廊项目必须编译,所以它还必须包含 gallery-core。因此,我们将创建一个包含 gallery-core、gallery-desktop 和 gallery-mobile 的父项目。
对于每个平台,我们将准备要打包的项目并创建一个特定的脚本。所有脚本遵循相同的流程:
-
设置输入和输出目录。
-
使用
qmake创建 Makefiles。 -
构建项目。
-
仅在输出目录中重新组合必要的文件。
-
使用平台特定的任务打包应用程序。
-
将打包的应用程序存储在输出目录中。
这些脚本可以在开发计算机或运行类似 Jenkins 等软件的持续集成服务器上运行,只要打包计算机的操作系统与脚本目标操作系统相匹配(除了移动平台)。换句话说,您需要在运行 Windows 的计算机上运行 Windows 脚本,才能为 Windows 打包 Qt 应用程序。
技术上,您可以进行交叉编译(给定适当的工具链和库),但这超出了本书的范围。当您在 Linux 上交叉编译 RaspberryPI 时,这很容易,但当您想在 Windows 上编译 MacOS 时,情况就不同了。
注意
从 Linux,您可以使用 MXE 等工具在 mxe.cc/ 上交叉编译 Qt。
创建一个名为 ch13-gallery-packaging 的新子目录项目,具有以下层次结构:
-
ch13-gallery-packaging:-
gallery-core -
gallery-desktop -
gallery-mobile
-
即使您现在是 Qt 子目录项目的专家,这里也有 ch13-gallery-packaging.pro 文件:
TEMPLATE = subdirs
SUBDIRS += \
gallery-core \
gallery-desktop \
gallery-mobile
gallery-desktop.depends = gallery-core
gallery-mobile.depends = gallery-core
您现在可以开始处理以下任何部分,具体取决于您要针对的平台。
Windows 的打包
要在 Windows 上打包独立应用程序,您需要提供可执行文件的所有依赖项。gallery-core.dll 文件、Qt 库(例如,Qt5Core.dll)和特定编译器的库(例如,libstdc++-6.dll)是我们可执行文件所需的一些依赖项示例。如果您忘记提供库,则在运行 gallery-desktop.exe 程序时将显示错误。
注意
在 Windows 上,您可以使用实用工具依赖关系查看器 (depends)。它将为您提供应用程序所需的所有库的列表。您可以从这里下载:www.dependencywalker.com。
对于本节,我们将创建一个脚本,通过命令行界面构建项目。然后我们将使用 Qt 工具 windeployqt 收集应用程序所需的所有依赖项。此示例适用于 MinGW 编译器,但您可以轻松地将其适应 MSVC 编译器。
以下是 winqtdeploy 收集的所需文件和文件夹列表,以便在 Windows 上正确运行 gallery-desktop:
-
iconengines:qsvgicon.dll
-
imageformats:-
qjpeg.dll -
qwbmp.dll -
...
-
-
Platforms:qwindows.dll
-
translations:-
qt_en.qm -
qt_fr.qm -
...
-
-
D3Dcompiler_47.dll -
gallery-core.dll -
gallery-desktop.exe -
libEGL.dll -
libgcc_s_dw2-1.dll -
libGLESV2.dll -
libstdc++-6.dll -
libwinpthread-1.dll -
opengl32sw.dll -
Qt5Core.dll -
Qt5Gui.dll -
Qt5Svg.dll -
Qt5Widgets.dll
检查您的环境变量是否设置正确:

在 scripts 目录中创建一个名为 package-windows.bat 的文件:
@ECHO off
set DIST_DIR=dist\desktop-windows
set BUILD_DIR=build
set OUT_DIR=gallery
mkdir %DIST_DIR% && pushd %DIST_DIR%
mkdir %BUILD_DIR% %OUT_DIR%
pushd %BUILD_DIR%
%QTDIR%\bin\qmake.exe ^
-spec win32-g++ ^
"CONFIG += release" ^
..\..\..\ch13-gallery-packaging.pro
%MINGWROOT%\bin\mingw32-make.exe qmake_all
pushd gallery-core
%MINGWROOT%\bin\mingw32-make.exe && popd
pushd gallery-desktop
%MINGWROOT%\bin\mingw32-make.exe && popd
popd
copy %BUILD_DIR%\gallery-core\release\gallery-core.dll %OUT_DIR%
copy %BUILD_DIR%\gallery-desktop\release\gallery-desktop.exe %OUT_DIR%
%QTDIR%\bin\windeployqt %OUT_DIR%\gallery-desktop.exe %OUT_DIR%\gallery-core.dll
popd
让我们讨论一下执行步骤:
-
设置主路径变量。输出目录是
DIST_DIR。所有文件都在dist/desktop-windows/build目录中生成。 -
创建所有目录并启动
dist/desktop-windows/build。 -
在 Win32 平台上以发布模式执行
qmake以生成父项目Makefile。win32-g++规范适用于 MinGW 编译器。如果您想使用 MSVC 编译器,应使用win32-msvc规范。 -
运行
mingw32-make qmake_all命令以生成子项目的 Makefile。如果您使用 MSVC 编译器,必须将mingw32-make替换为nmake或jom。 -
执行
mingw32-make命令以构建每个所需的子项目。 -
将生成的文件
gallery-desktop.exe和gallery-core.dll复制到gallery目录。 -
在两个文件上调用 Qt 工具
windeployqt并复制所有必需的依赖项(例如,Qt5Core.dll、Qt5Sql.dll、libstdc++-6.dll、qwindows.dll等)。
使用发行版包的 Linux 打包
为 Linux 发行版打包应用程序是一条坎坷的道路。因为每个发行版都可以有自己的打包格式(.deb、.rpm 等),首先要回答的问题是:你希望针对哪个发行版?涵盖每一个主要的打包格式需要几章内容。甚至详细说明一个单一的发行版也可能是不公平的(你想要为 RHEL 打包?很遗憾,我们只覆盖了 Arch Linux!)。毕竟,从 Qt 应用程序开发者的角度来看,你想要的是将你的产品发送给你的用户,你(目前)并不打算成为官方 Debian 仓库维护者。
考虑到所有这些,我们决定专注于一个为你为每个分发打包应用程序的工具。没错,你不需要学习 Debian 或 Red Hat 的内部结构!我们仍然会解释打包系统中的共同原则,而不会过度详细。
对于我们的目的,我们将演示如何在 Ubuntu 机器上使用 .deb 格式进行打包,但正如你将看到的,它可以很容易地更新以生成 .rpm。
我们将要使用的工具名为 fpm(eFfing Package Management)。
注意
fpm 工具可在 github.com/jordansissel/fpm 获取。
fpm 工具是一个 Ruby 应用程序,旨在完成我们需要的任务:处理特定于分发的细节并生成最终的包。首先,花时间在你的机器上安装 fpm 并确保它正在运行。
简而言之,Linux 打包是一种文件格式,它包含了你想要部署的所有文件以及大量的元数据。它可以包含内容的描述、变更日志、许可文件、依赖项列表、校验和、安装前和安装后触发器等等。
注意
如果你想要学习如何手动打包 Debian 二进制文件,请访问 tldp.org/HOWTO/html_single/Debian-Binary-Package-Building-HOWTO/。
在我们的案例中,我们仍然需要进行一些项目准备,以便 fpm 执行其工作。我们想要部署的文件必须与目标文件系统相匹配。以下是部署应该看起来像这样:
-
gallery-desktop:这个二进制文件应该部署在/usr/bin -
libgallery-core.so:这个文件应该部署在/usr/lib
为了实现这一点,我们将按照以下方式在 dist/desktop-linux 中组织我们的输出:
-
build目录将包含编译后的项目(这是我们发布的影子构建) -
root目录将包含待打包的文件,即二进制文件和库文件在适当的层次结构中(usr/bin和usr/lib)。
为了生成根目录,我们将依赖 Qt 和 .pro 文件的力量。当编译 Qt 项目时,目标文件已经跟踪。我们只需要为 gallery-core 和 gallery-desktop 添加一个额外的安装目标。
在 gallery-core/gallery-core.pro 中添加以下作用域:
linux {
target.path = $$_PRO_FILE_PWD_/../dist/desktop-linux/root/usr/lib/
INSTALLS += target
}
在这里,我们定义了一个新的 target.path,它将部署 DISTFILES(.so 文件)到我们期望的根目录。注意使用 $$_PRO_FILE_PWD_,它指向当前 .pro 文件存储的目录。
在 gallery-desktop/gallery-desktop.pro 中执行几乎相同的程序:
linux {
target.path = $$_PRO_FILE_PWD_/../dist/desktop-linux/root/usr/bin/
INSTALLS += target
}
通过这些行,当我们调用 make install 时,文件将被部署到 dist/desktop-linux/root/...。
现在项目配置已完成,我们可以切换到打包脚本。我们将分两部分介绍脚本:
-
项目编译和
root准备 -
使用
fpm生成.deb软件包
首先,检查你的环境变量是否设置正确:

使用以下内容创建 scripts/package-linux-deb.sh:
#!/bin/bash
DIST_DIR=dist/desktop-linux
BUILD_DIR=build
ROOT_DIR=root
BIN_DIR=$ROOT_DIR/usr/bin
LIB_DIR=$ROOT_DIR/usr/lib
mkdir -p $DIST_DIR && cd $DIST_DIR
mkdir -p $BIN_DIR $LIB_DIR $BUILD_DIR
pushd $BUILD_DIR
$QTDIR/bin/qmake \
-spec linux-g++ \
"CONFIG += release" \
../../../ch13-gallery-packaging.pro
make qmake_all
pushd gallery-core && make && make install ; popd
pushd gallery-desktop && make && make install ; popd
popd
让我们分解一下:
-
设置主路径变量。输出目录是
DIST_DIR。所有文件都在dist/desktop-linux/build文件夹中生成。 -
创建所有目录并启动
dist/desktop-linux/build。 -
在 Linux 平台上以发布模式执行
qmake以生成父项目Makefile。 -
执行
make qmake_all命令以生成子项目的 Makefile。 -
执行
make命令来构建每个所需的子项目。 -
使用
make install命令将二进制文件和库部署到dist/desktop-linux/root目录。
如果你执行 scripts/package-linux-deb.sh,dist/desktop-linux 中的最终文件树看起来像这样:
-
build/-
gallery-core/*.o -
gallery-desktop/*.p -
Makefile
-
-
root/-
usr/bin/gallery-desktop -
usr/lib/libgallery-core.so
-
现在一切准备就绪,fpm 可以工作了。scripts/package-linux-deb.sh 的最后一部分包含以下内容:
fpm --input-type dir \
--output-type deb \
--force \
--name gallery-desktop \
--version 1.0.0 \
--vendor "Mastering Qt 5" \
--description "A Qt gallery application to organize and manage your pictures in albums" \
--depends qt5-default \
--depends libsqlite3-dev \
--chdir $ROOT_DIR \
--package gallery-desktop_VERSION_ARCH.deb
大多数参数都很明确。我们将重点关注最重要的几个:
-
--input-type:此参数表示fpm将与之交互的内容。它可以接受deb、rpm、gem、dir等格式,并将其重新包装为另一种格式。在这里,我们使用dir选项告诉fpm使用目录树作为输入源。 -
--output-type:此参数表示期望的输出类型。查看官方文档以了解支持多少平台。 -
--name:这是分配给软件包的名称(如果你想卸载它,你可以写apt-get remove gallery-desktop)。 -
--depends:此参数表示项目的库包依赖。你可以添加任意多的依赖。在我们的例子中,我们只依赖于qt5 -default和sqlite3-dev。此选项非常重要,确保应用程序能够在目标平台上运行。你可以使用--depends library >= 1.2.3来指定依赖的版本。 -
--chdir:此参数表示fpm将从中运行的基准目录。我们将其设置为dist/desktop-linux/root,我们的文件树已准备好加载! -
--package:此参数是最终软件包的名称。VERSION和ARCH是占位符,将根据您的系统自动填充。
其余的选项纯粹是信息性的;您可以指定一个变更日志、许可文件等等。只需将 --output-type 的 deb 更改为 rpm,软件包格式就会正确更新。fpm 工具还提供了特定的软件包格式选项,让您可以精细控制生成的内容。
如果您现在执行 scripts/package-linux-deb.sh,应该会得到一个新的 dist/desktop-linux/gallery-desktop_1.0.0_amd64.deb 文件。尝试使用以下命令安装它:
sudo dpkg -i dist/desktop-linux/gallery-desktop_1.0.0_amd64.deb
sudo apt-get install -f
第一个命令将在您的系统中部署该软件包。现在您应该拥有文件 /usr/bin/gallery-desktop 和 /usr/lib/libgallery-core.so。
然而,因为我们使用 dpkg 命令安装了软件包,所以依赖项并没有自动安装。如果软件包是由 Debian 仓库提供的(因此,使用 apt-get install gallery-desktop 安装软件包),则会这样做。缺失的依赖项仍然“标记”着,apt-get install -f 会安装它们。
现在,您可以使用命令 gallery-desktop 从系统中的任何位置启动 gallery-desktop。当我们于 2016 年编写这一章节时,如果在“全新”的 Ubuntu 上执行它,可能会遇到以下问题:
$ gallery-desktop
gallery-desktop: /usr/lib/x86_64-linux-gnu/libQt5Core.so.5: version `Qt_5.7' not found (required by gallery-desktop)
gallery-desktop: /usr/lib/x86_64-linux-gnu/libQt5Core.so.5: version `Qt_5' not found (required by gallery-desktop)
...
gallery-desktop: /usr/lib/x86_64-linux-gnu/libQt5Core.so.5: version `Qt_5' not found (required by /usr/lib/libgallery-core.so.1)
发生了什么?我们使用 apt-get install -f 安装了依赖项!在这里,我们遇到了 Linux 软件包管理的一个主要痛点。我们在 .deb 文件中指定的依赖项可能指向 Qt 的特定版本,但实际情况是我们依赖于上游维护的软件包版本。换句话说,每当 Qt 发布新版本时,发行版维护者(Ubuntu、Fedora 等等)必须重新打包它,以便在官方仓库中提供。这可能是一个漫长的过程,维护者需要移植大量的软件包!
为了确保我们所说的内容准确无误,让我们使用 ldd 命令查看 gallery-desktop 的库依赖项:
$ ldd /usr/bin/gallery-desktop
libgallery-core.so.1 => /usr/lib/libgallery-core.so.1 (0x00007f8110775000)
libQt5Widgets.so.5 => /usr/lib/x86_64-linux-gnu/libQt5Widgets.so.5 (0x00007f81100e8000)
libQt5Gui.so.5 => /usr/lib/x86_64-linux-gnu/libQt5Gui.so.5 (0x00007f810fb9f000)
libQt5Core.so.5 => /usr/lib/x86_64-linux-gnu/libQt5Core.so.5 (0x00007f810f6c9000)
...
libXext.so.6 => /usr/lib/x86_64-linux-gnu/libXext.so.6 (0x00007f810966e000)
如您所见,libgallery-core.so 在 /usr/lib 中被正确解析,Qt 的依赖项也在 /usr/lib/x86_64-linux-gnu 中。但使用了哪个版本的 Qt 呢?答案在于库的详细信息:
$ ll /usr/lib/x86_64-linux-gnu/libQt5Core.*
-rw-r--r-- 1 root root 1014 may 2 15:37 libQt5Core.prl
lrwxrwxrwx 1 root root 19 may 2 15:39 libQt5Core.so -> libQt5Core.so.5.5.1
lrwxrwxrwx 1 root root 19 may 2 15:39 libQt5Core.so.5 -> libQt5Core.so.5.5.1
lrwxrwxrwx 1 root root 19 may 2 15:39 libQt5Core.so.5.5 -> libQt5Core.so.5.5.1
-rw-r--r-- 1 root root 5052920 may 2 15:41 libQt5Core.so.5.5.1
libQt5Core.so 文件是 libQt5Core.so.5.5.1 的软链接,这意味着系统版本的 Qt 是 5.5.1,而 gallery-desktop 依赖于 Qt 5.7。您可以配置系统,使系统 Qt 指向您的 Qt 安装(通过 Qt 安装程序完成)。然而,您的客户手动安装 Qt 只为了让 gallery-desktop 运行几乎是不可能的。
更糟糕的是,对于较旧的发行版,经过一段时间后,软件包通常根本不会更新。只需尝试在 Ubuntu 14.04 上安装 Qt 5.7 Debian 软件包,就能理解事情变得多么复杂。我们甚至还没有提到不兼容的依赖项。如果我们依赖于特定版本的libsqlite3-dev,而另一个应用程序需要另一个版本,事情就会变得很糟糕,只有一个能够幸存。
如果你想要 Linux 官方仓库中有这个软件包或者你有特定的需求,一个 Linux 软件包有很多优点。在 Linux 上安装应用程序通常使用官方仓库,这样你的用户就不会感到困惑。如果你能将 Qt 版本限制在 Linux 发行版上部署的版本,这可能是一个不错的解决方案。
不幸的是,这也带来了巨大的头痛:你需要支持多个发行版,处理依赖关系而不会破坏系统,并确保你的应用程序有足够的旧依赖项,等等。
不要担心,一切还没有失去;聪明的人已经在 Linux 上通过自包含的软件包来解决这个问题。实际上,我们将要介绍一个自包含的软件包。
使用 AppImage 对 Linux 进行软件打包
在 Windows 或 Mac 上,一个应用程序是自给自足的:它包含执行所需的所有依赖项。一方面,这造成了更多的文件重复,另一方面也简化了开发者的打包工作。
基于这个前提,人们已经努力在 Linux 上实现相同的模式(与特定仓库/发行版的软件包相反)。今天,有几个解决方案在 Linux 上提供自包含的软件包。我们建议你研究这些解决方案之一:AppImage。这个特定的工具在 Linux 社区中越来越受欢迎。越来越多的开发者依赖 AppImage 来打包和部署他们的应用程序。
AppImage 是一种包含所有库的应用程序的文件格式。你下载一个单一的 AppImage 文件,执行它,就完成了:应用程序正在运行。幕后,AppImage 是一个强化版的 ISO 文件,在你执行时即时挂载。AppImage 文件本身是只读的,也可以在沙盒中运行,例如 Firejail(一个 SUID 沙盒程序,通过限制应用程序的运行环境来降低安全漏洞的风险)。
注意
关于 AppImage 的更多信息可以在appimage.org/找到。
将gallery-desktop打包成 AppImage 有两个主要步骤:
-
收集
gallery-desktop的所有依赖项。 -
将
gallery-desktop及其依赖项打包成 AppImage 格式。
幸运的是,整个流程可以通过使用一个巧妙的小工具来完成:linuxdeployqt。它最初是一个爱好项目,后来成为了 AppImage 文档中打包 Qt 应用程序的官方方法。
注意
从github.com/probonopd/linuxdeployqt/获取linuxdeployqt。
我们将要编写的脚本假设二进制文件linuxdeployqt在您的$PATH变量中可用。请确保您的环境变量设置正确:

创建scripts/package-linux-appimage.sh并更新如下:
#!/bin/bash
DIST_DIR=dist/desktop-linux
BUILD_DIR=build
mkdir -p $DIST_DIR && cd $DIST_DIR
mkdir -p $BUILD_DIR
pushd $BUILD_DIR
$QTDIR/bin/qmake \
-spec linux-g++ \
"CONFIG += release" \
../../../ch13-gallery-packaging.pro
make qmake_all
pushd gallery-core && make ; popd
pushd gallery-desktop && make ; popd
popd
export QT_PLUGIN_PATH=$QTDIR/plugins/
export LD_LIBRARY_PATH=$QTDIR/lib:$(pwd)/build/gallery-core
linuxdeployqt \
build/gallery-desktop/gallery-desktop \
-appimage
mv build/gallery-desktop.AppImage .
第一部分是项目的编译:
-
设置主路径变量。输出目录是
DIST_DIR。所有文件都在dist/desktop-linux/build文件夹中生成。 -
创建所有目录并进入
dist/desktop-linux/build。 -
在 Linux 平台上以发布模式执行
qmake以生成父项目Makefile。 -
运行
make qmake_all命令以生成子项目的 Makefiles。 -
执行
make命令来构建每个所需的子项目。
脚本的第二部分涉及linuxdeployqt。我们首先必须导出一些路径,以便linuxdeployqt能够正确地找到gallery-desktop的所有依赖项(Qt 库和gallery-core库)。
之后,我们通过指定要处理的源二进制文件和目标文件类型(AppImage)来执行linuxdeployqt。生成的文件是一个单独的gallery-desktop.AppImage,无需安装任何 Qt 包即可在用户的计算机上启动!
Mac OS X 的打包
在 OS X 上,应用程序是通过一个包来构建和运行的:一个包含应用程序二进制文件及其所有依赖项的单个目录。在 Finder 中,这些包被视为.app特殊目录。
当从 Qt Creator 运行gallery-desktop时,应用程序已经打包在一个.app文件中。因为我们使用的是自定义库gallery-core,所以这个gallery-desktop.app不包含所有依赖项,Qt Creator 会为我们处理。
我们旨在创建一个脚本,将gallery-desktop(包括gallery-core)完全打包在一个.dmg文件中,这是一个 Mac OS X 磁盘映像文件,在执行时挂载,并允许用户轻松安装应用程序。
为了实现这一点,Qt 提供了macdeployqt工具,它收集依赖项并创建.dmg文件。
首先,检查您的环境变量是否设置正确:

创建scripts/package-macosx.sh文件,内容如下:
#!/bin/bash
DIST_DIR=dist/desktop-macosx
BUILD_DIR=build
mkdir -p $DIST_DIR && cd $DIST_DIR
mkdir -p $BUILD_DIR
pushd $BUILD_DIR
$QTDIR/bin/qmake \
-spec macx-clang \
"CONFIG += release x86_64" \
../../../ch13-gallery-packaging.pro
make qmake_all
pushd gallery-core && make ; popd
pushd gallery-desktop && make ; popd
cp gallery-core/*.dylib \
gallery-desktop/gallery-desktop.app/Contents/Frameworks/
install_name_tool -change \
libgallery-core.1.dylib \
@rpath/libgallery-core.1.dylib \
gallery-desktop/gallery-desktop.app/Contents/MacOS/gallery-desktop
popd
$QTDIR/bin/macdeployqt \
build/gallery-desktop/gallery-desktop.app \
-dmg
mv build/gallery-desktop/gallery-desktop.dmg .
我们可以将脚本分成两部分。第一部分是为macdeployqt准备应用程序:
-
设置主路径变量。输出目录是
DIST_DIR。所有文件都在dist/desktop-macosx/build文件夹中生成。 -
创建所有目录并进入
dist/desktop-macosx/build。 -
在 Mac OS X 平台上以发布模式执行
qmake以生成父项目Makefile。 -
运行
make qmake_all命令以生成子项目的 Makefiles。 -
执行
make命令来构建每个所需的子项目。
以下部分包括在生成的gallery-desktop.app中的gallery-core库。如果我们不执行脚本中提到的cp命令及其之后的内容,我们可能会对gallery-desktop的二进制内容感到非常惊讶。让我们通过执行以下命令来查看它:
$ otool -L dist/desktop-macosx/build/gallery-desktop/gallery-desktop.app/Contents/MacOS/gallery-desktop
dist/desktop-macosx/build/gallery-desktop/gallery-desktop.app/Contents/MacOS/gallery-desktop:
libgallery-core.1.dylib (compatibility version 1.0.0, current version 1.0.0)
@rpath/QtWidgets.framework/Versions/5/QtWidgets (compatibility version 5.7.0, current version 5.7.0)
...
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1226.10.1)
如您所见,libgallery-core.1.dylib在本地路径中解析,但不在特殊依赖路径中,就像为QtWidget使用@rpath(即Contents/Frameworks/)那样。为了减轻这种情况,package-macosx.sh将.dylib文件复制到gallery-desktop.app/Contents/Frameworks/,并使用install_name_tool重新生成二进制文件的依赖项索引。
最后,在package-macosx.sh中,使用更新的gallery-deskop.app和目标dmg格式调用macdeployqt。生成的gallery-desktop.dmg可以部署到您的用户计算机上。
Android 打包
本节的目标是为gallery-mobile应用程序生成一个独立的 APK 文件。为 Android 打包和部署应用程序需要多个步骤:
-
配置 Android 构建细节。
-
生成一个密钥库和一个证书。
-
从模板中自定义 Android 清单。
-
创建一个脚本来自动化打包。
您可以直接从 Qt Creator 中完成大多数任务。在底层,Qt 工具androiddeployqt被调用以生成 APK 文件。转到项目 | armeabi-v7a 的 Android | 构建步骤。您应该看到一个特殊的构建步骤:构建 Android APK。细节如下截图:

首件事是选择您想用于生成应用程序的 Android API 级别。在我们的案例中,我们选择了android-23作为 Android API Level 23。尽量始终使用可用的最新 SDK 版本构建您的应用程序。
要在 Play Store 上发布您的应用程序,您必须对包进行签名。要能够更新应用程序,当前版本和新版本的签名必须相同。此程序是一个保护措施,以确保任何未来的应用程序版本确实是由您创建的。第一次您应该创建密钥库,下次您可以使用浏览...按钮重用它。现在,点击签名包 | 密钥库行上的创建...按钮。您将得到以下弹出窗口:

按照以下步骤生成新的密钥库:
-
密钥库必须通过密码进行保护。不要忘记它,否则你将无法为未来的版本使用此密钥库。
-
为证书指定一个别名名称。对于密钥大小和有效期(天)的默认值是合适的。您可以指定不同的密码用于证书或使用密钥库的密码。
-
在证书区分名称组中,输入有关您和您公司的信息。
-
将密钥库文件保存在安全的地方。
-
输入 keystore 密码以验证其选择用于部署。
下一个部分是关于 Qt 部署。确实,您的应用程序需要一些 Qt 库。Qt 支持三种部署方式:
-
创建一个依赖 Ministro 的最小 APK,Ministro 是一个可以从 Play Store 下载的 Android 应用程序。它充当 Android 上所有 Qt 应用程序的 Qt 共享库安装程序/提供者。
-
创建一个嵌入 Qt 库的独立 bundle APK。
-
创建一个依赖于 Qt 库位于特定目录的 APK。在第一次部署期间,库被复制到 临时目录。
在开发和调试阶段,您应该选择 临时目录 方式以减少打包时间。对于部署,您可以使用 Ministro 或 bundle 选项。在我们的案例中,我们选择了独立的 bundle 来生成完整的 APK。
高级操作 面板提供了三个选项:
-
使用 Gradle:此选项生成 Gradle 包装器和脚本,如果您计划在 Android Studio 等 IDE 中自定义 Java 部分,则非常有用。
-
构建后打开包位置:此选项将打开由
androiddeployqt生成的包所在的目录。 -
详细输出:此选项显示有关
androiddeployqt处理的附加信息。
Android 构建细节和签名选项已完成。我们现在可以自定义 Android 清单。点击 创建模板,选择 gallery-mobile.pro 文件,然后点击 完成。向导为您创建一个包含多个文件的 android 子目录;例如,AndroidManifest.xml。gallery-mobile.pro 文件必须自动更新这些文件。然而,不要忘记添加 android 范围,如下面的代码片段所示:
TEMPLATE = app
...
android {
contains(ANDROID_TARGET_ARCH,x86) {
ANDROID_EXTRA_LIBS = \
$$[QT_INSTALL_LIBS]/libQt5Sql.so
}
DISTFILES += \
android/AndroidManifest.xml \
android/gradle/wrapper/gradle-wrapper.jar \
android/gradlew \
android/res/values/libs.xml \
android/build.gradle \
android/gradle/wrapper/gradle-wrapper.properties \
android/gradlew.bat
ANDROID_PACKAGE_SOURCE_DIR = $$PWD/android
}
您现在可以编辑 AndroidManifest.xml 文件。Qt Creator 提供了一个专门的编辑器。您也可以小心地使用纯文本编辑器进行编辑。您可以从分层项目视图打开它:gallery-mobile | 其他文件 | android。
这里是我们在 Qt Creator 中的 Android 清单文件:

这里是最重要的步骤:
-
将默认的 包名 替换为您自己的。
-
版本代码 是一个整数,必须为每个官方版本增加。
-
版本名称 是用户显示的版本。
-
选择 最低要求的 SDK。使用较旧版本的用户将无法安装您的应用程序。
-
选择用于编译应用程序的 目标 SDK 将使用的 SDK。
-
更改应用程序和活动名称。
-
根据屏幕 DPI(每英寸点数)选择 应用程序图标。从左到右:低、中、高 DPI 图标。
-
最后,如果您的应用程序需要,您可以添加一些 Android 权限。
你已经可以从 Qt Creator 中构建和部署你的签名应用程序。你应该能在你的 Android 手机或模拟器上看到新的应用程序名称和图标。然而,我们现在将创建一个脚本,以便从命令行轻松生成和打包签名 APK。
Android 和 Qt 工具以及脚本本身需要几个环境变量。以下是一个带有示例的总结:

这个例子是一个 bash 脚本,但如果你在 Windows 上,请随意将其适配为 .bat 文件。在 scripts 目录中创建一个 package-android.sh 文件:
#!/bin/bash
DIST_DIR=dist/mobile-android
BUILD_DIR=build
APK_DIR=apk
KEYSTORE_PATH="$(pwd)/scripts/android-data"
ANDROID_BUILD_PATH="$(pwd)/$DIST_DIR/$BUILD_DIR/android-build"
mkdir -p $DIST_DIR && cd $DIST_DIR
mkdir -p $APK_DIR $BUILD_DIR
pushd $BUILD_DIR
$QTDIR_ANDROID/bin/qmake \
-spec android-g++ \
"CONFIG += release" \
../../../ch13-gallery-packaging.pro
make qmake_all
pushd gallery-core && make ; popd
pushd gallery-mobile && make ; popd
pushd gallery-mobile && make INSTALL_ROOT=$ANDROID_BUILD_PATH install ; popd
$QTDIR_ANDROID/bin/androiddeployqt
--input ./gallery-mobile/android-libgallery-mobile.so-deployment-settings.json \
--output $ANDROID_BUILD_PATH \
--deployment bundled \
--android-platform android-23 \
--jdk $JAVA_HOME \
--ant $ANT_ROOT/ant \
--sign $KEYSTORE_PATH/android.keystore myandroidkey \
--storepass 'masteringqt'
cp $ANDROID_BUILD_PATH/bin/QtApp-release-signed.apk ../apk/cute-gallery.apk
popd
让我们一起分析这个脚本:
-
设置主要路径变量。输出目录是
DIST_DIR。所有文件都在dist/mobile-android/build目录中生成。最终的签名 APK 被复制到dist/mobile-android/apk目录。 -
创建所有目录并进入
dist/mobile-android/build。 -
为 Android 平台执行发布模式的
qmake以生成父项目 Makefile。 -
执行
make qmake_all命令来生成子项目的 Makefiles。 -
执行
make命令来构建每个所需的子项目。 -
在
gallery-mobile目录中运行make install命令,指定INSTALL_ROOT以复制 APK 生成所需的全部二进制文件和文件。
脚本的最后部分调用 androiddeployqt 二进制文件,这是一个用于生成 APK 的 Qt 工具。查看以下选项:
-
这里使用的
--deployment选项是bundled,就像我们在 Qt Creator 中使用的那样。 -
--sign选项需要两个参数:密钥库文件的 URL 和证书的别名。 -
--storepass选项用于指定密钥库密码。在我们的例子中,密码是 "masteringqt"。
最后,生成的签名 APK 被复制到 dist/mobile-android/apk 目录,文件名为 cute-gallery.apk。
iOS 打包
为 iOS 打包 Qt 应用程序依赖于 XCode。当你从 Qt Creator 中构建和运行 gallery-mobile 时,XCode 将在后台被调用。最后,生成一个 .xcodeproj 文件并将其传递给 XCode。
了解这一点后,打包部分将相当有限:唯一可以自动化的就是 .xcodeproj 文件的生成。
首先,检查你的环境变量是否设置正确:

创建 scripts/package-ios.sh 并将以下片段添加到其中:
#!/bin/bash
DIST_DIR=dist/mobile-ios
BUILD_DIR=build
mkdir -p $DIST_DIR && cd $DIST_DIR
mkdir -p $BIN_DIR $LIB_DIR $BUILD_DIR
pushd $BUILD_DIR
$QTDIR_IOS/bin/qmake \
-spec macx-ios-clang \
"CONFIG += release iphoneos device" \
../../../ch13-gallery-packaging.pro
make qmake_all
pushd gallery-core && make ; popd
pushd gallery-mobile && make ; popd
popd
脚本执行以下步骤:
-
设置主要路径变量。输出目录是
DIST_DIR。所有文件都在dist/mobile-ios/build文件夹中生成。 -
创建所有目录并进入
dist/mobile-ios/build。 -
为 iPhone 设备(与 iPhone 模拟器平台相对)执行发布模式的
qmake以生成父项目Makefile。 -
执行
make qmake_all命令来生成子项目的 Makefiles。 -
执行
make命令来构建每个所需的子项目。
一旦执行了这个脚本,dist/mobile-ios/build/gallery-mobile/gallery-mobile.xcodeproj 就可以打开在 XCode 中。接下来的步骤完全在 XCode 中完成:
-
在 XCode 中打开
gallery-mobile.xcodeproj。 -
为 iOS 设备编译应用程序。
-
按照苹果的流程分发您的应用程序(通过 App Store 或作为独立文件)。
之后,gallery-mobile 将为您的用户准备好!
摘要
即使您的应用程序在您的电脑上运行良好,您的开发环境也可能影响这种行为。其打包必须正确,以便在用户的硬件上运行您的应用程序。您已经学习了部署应用程序之前所需的打包步骤。某些平台需要遵循特定的任务。如果您的应用程序正在运行独特的脚本,您现在可以制作一个独立的包。
下一章描述了一些在开发 Qt 应用程序时可能有用的技巧。您将学习一些关于 Qt Creator 的提示。
第十四章。Qt 小贴士和技巧
上一章教了你如何在所有主要的桌面和移动平台上打包 Qt 应用程序。这是将你的应用程序发送给用户之前的最后一步。本章汇集了一些技巧和窍门,将帮助你更轻松地开发你的 Qt 应用程序。
本章涵盖了以下主题:
-
Qt Creator 小贴士 - 有用的键盘快捷键、会话管理以及更多
-
使用 Qt Creator 检查内存
-
生成随机数
-
静默未使用变量和编译器警告
-
如何轻松地将对象的内容记录到
QDebug -
自定义
QDebug格式化 -
将日志保存到文件
-
创建友好的命令行界面
-
发送
HTTPGET和POST请求
使用会话管理你的工作空间
对于商业产品通常由几个 Qt 项目组成是很常见的。我们在本书中经常遇到这种做法——例如,由核心项目和 GUI 项目组成的应用程序。Qt 子目录项目是在同一应用程序内处理相互依赖项目的一种很好的方式。
然而,当你的产品成长起来时,你可能会想在 Qt Creator 中打开一些无关的项目。在这种情况下,你应该使用一个 会话。会话是 Qt Creator 中工作空间的一个完整快照。你可以轻松地从 文件 | 会话管理器 | 新建 创建一个新的会话。不要忘记切换到新的会话。例如,你可以创建一个名为 "Mastering Qt5" 的会话,并在一个公共工作空间中加载所有项目示例。
当你需要快速在两个不同的工作空间之间切换时,会话非常有用。以下在 Qt Creator 中的项目将在会话中自动保存:
-
层次视图打开的项目
-
编辑器窗口(包括分割)
-
调试断点和表达式视图
-
书签
使用 文件 | 会话管理器 或 欢迎 选项卡来切换到不同的会话。会话可以被销毁,而不会对你的项目产生任何影响。
使用定位器搜索
使用 Qt Creator 提高你的生产力的另一种方法是使用键盘快捷键。Qt Creator 提供了许多优秀的键盘快捷键。以下是我们的选择:

我们最喜欢的一个是定位器。按 Ctrl + K 激活它。然后你可以享受以下功能:
-
输入一个文件名(你甚至可以使用部分输入)并按 Enter 键打开此文件。如果定位器建议多个文件,你可以使用上下箭头进行导航。
-
在搜索前加上
.(一个点后跟一个空格)以在当前文档中搜索 C++ 符号。例如,在第一章的Task.cpp文件中,尝试使用定位器输入. set并按 Enter 键跳转到Task::setName()函数。 -
输入
l(L 后跟一个空格)以跳转到特定的行。例如,"l 37" 将将我们带到当前文件的第 37 行
定位器提供了丰富的功能;下次您按下 Ctrl + K 时,请查看一下!
提高编译速度
您可以在多核计算机上加快编译速度。默认情况下,当您使用 Qt Creator 构建项目时,您只使用一个作业(因此,一个核心)。但是make支持使用多个作业进行编译。您可以使用make -j N选项一次允许 N 个作业。不要忘记更新您的打包脚本!
如果您从 Qt Creator 构建项目,您可以从项目 | 构建步骤 | 构建设置此选项。点击详细信息,然后在构建参数字段中,输入值-j 8以允许在编译期间进行八个作业,如下面的截图所示:

使用 Qt Creator 检查内存
对于本节,我们将使用以下代码片段:
bool boolean = true;
int integer = 5;
char character = 'A';
int* integerPointer = &integer;
qDebug() << "boolean is:" << boolean;
qDebug() << "integer is:" << integer;
qDebug() << "character is:" << character;
qDebug() << "integerPointer is:" << integerPointer;
qDebug() << "*integerPointer is:" << *integerPointer;
qDebug() << "done!";
我们声明了三种原始类型:boolean、integer和character。我们还添加了一个指向integer变量的integerPointer指针。在最后一行设置断点并开始调试。在调试面板上,您应该有局部变量和表达式视图。您可以从窗口 | 视图 | 局部变量和表达式轻松添加/删除它。以下是它的截图:

您可以看到,所有我们的局部变量都显示其值。字符行甚至显示了字母 'A' 的三种格式(ASCII、整数和十六进制)。您可能还会注意到,integerPointer行显示的是自动解引用的值,而不是指针地址。您可以通过在局部变量和表达式窗口的背景上右键单击并选择自动解引用指针来禁用它。您可以看到指针地址和解引用值如以下截图所示:

控制台输出显示以下信息:
boolean is: true
integer is: 5
character is: A
integerPointer is: 0x7ffe601153ac
*integerPointer is: 5
您可以看到,我们在控制台输出中检索到相同的信息。局部变量和表达式视图可以帮助您节省时间。您可以在不使用qDebug()函数记录的情况下显示大量信息。
Qt Creator 提供了一个有用的内存编辑器。您可以通过在局部变量和表达式窗口中变量名上右键单击来打开它,然后选择打开内存编辑器 | 在对象的地址处打开内存编辑器。
在内存编辑器中,查看boolean变量的值:

一个十六进制编辑器出现,分为三个部分(从左到右):
-
数据的内存地址
-
数据的十六进制表示
-
数据的 ASCII 表示
十六进制表示中的选择对应于变量。我们可以确认boolean变量在内存中以 1 个字节表示。因为值是true,内存表示为0x01。
让我们使用内存编辑器工具检查character内存:

字符也以 1 个字节存储在内存中。十六进制表示为0x41。字符使用众所周知的 ASCII 格式编码。请注意,在右侧,ASCII 表示显示为'A'。
这是integer变量的内存编辑器位置:

有两个有趣的事实需要注意。整数存储在 4 个字节中。值05以十六进制形式存储为05 00 00 00。字节顺序取决于处理器的端序。我们使用的是 Little-Endian 的 Intel CPU。具有 Big-Endian 内存存储的其他 CPU 架构将变量显示为00 00 00 05。
在我们继续深入了解应用程序的内存之前,仔细查看最后三个屏幕截图。你可能注意到,在这种情况下,三个变量在堆栈内存中是连续的。这取决于你操作系统实现的行为并不保证。
尝试在integerPointer变量上打开内存编辑器。上下文菜单提供了两种不同的方式:
-
在对象的地址打开内存编辑器选项取消引用指针,直接带你到指向的值。你得到与整数内存视图相同的结果。
-
在指针地址打开内存编辑器选项显示原始指针数据,这是一个指向其所在位置的内存地址。
这里是显示integerPointer指针地址的内存编辑器工具:

我们运行在 64 位操作系统上,因此我们的指针存储在 8 个字节中。这个指针的数据是十六进制值ac 53 11 60 fe 7f 00 00。这是内存地址0x7ffe601153ac的 Little-Endian 表示,该地址由局部变量和表达式以及我们的控制台输出显示。
我们显示内存,但我们也可以更改它。按照以下步骤操作:
-
移除当前断点并在第一个
qDebug()行上添加一个新的断点。 -
重新启动调试并查看局部变量和表达式。如果你双击一个变量的值,你可以编辑它。请注意,内存编辑器窗口会立即更新其表示。
-
在我们的例子中,我们将
boolean值设置为 false,character设置为 68(即'D'),integer设置为 9。当你对自己的更改有信心时,继续调试。
这是反映我们修改的最终控制台输出:
boolean is: false
integer is: 9
character is: D
integerPointer is: 0x7fff849203dc
*integerPointer is: 9
done!
内存编辑器是一个强大的工具:你可以在不更改源代码和重新编译应用程序的情况下,在运行时显示和更改变量的值。
生成随机数
对于计算机来说,生成真正的随机数是一项相当困难的任务。通常,我们只使用伪随机数生成(PRNG)。Qt 框架提供了函数qrand(),这是std::rand()的一个线程安全版本。这个函数返回一个介于 0 和RAND_MAX(在stdlib.h中定义)之间的整数。以下代码显示了两个伪随机数:
qDebug() << "first number is" << qrand() % 10;
qDebug() << "second number is" << qrand() % 10;
我们使用模运算符来获取介于 0 和 9 之间的值。尝试多次运行你的应用程序。数字总是相同的,在我们的例子中,先是 3 然后是 7。那是因为每次我们调用qrand()时,我们都会检索伪随机序列的下一个数字,但序列总是相同的!幸运的是,我们可以使用qsrand()来使用种子初始化 PRNG。种子是一个用于生成序列的无符号整数。尝试下一个代码片段:
qsrand(3);
qDebug() << "first number is" << qrand() % 10;
qDebug() << "second number is" << qrand() % 10;
在这个例子中,我们使用了种子 3,并从qrand()得到了不同的值——在我们的计算机上它是 5 和 4。很好,但如果多次运行这个应用程序,你总是会得到这个序列。每次运行应用程序时生成不同序列的一种方法是在每次运行时使用不同的种子。运行以下代码片段:
qsrand(QDateTime::currentDateTime().toTime_t());
qDebug() << "first number is" << qrand() % 10;
qDebug() << "second number is" << qrand() % 10;
如你所见,我们现在正在使用QDateTime的纪元时间初始化 PRNG。你可以尝试多次运行你的应用程序,以查看我们每次都会得到不同的数字!然而,这个解决方案不建议用于加密。在这种情况下,你应该使用更强的随机数生成器。
关闭未使用变量的警告
如果你的编译器配置为输出其警告,你可能会看到这种类型的日志:
warning: unused parameter 'myVariable' [-Wunused-parameter]
这是一个安全警告,告诉开发者保持他们的代码整洁并避免死变量。尽量减少这种类型的警告是一种好的做法。然而,有时你不得不这样做:你重写了一个现有的函数,但没有使用所有参数。你现在面临一个困境:一方面,你可以为整个应用程序关闭警告,另一方面,你可以让这些安全警告在编译输出中累积。必须有一个更好的选择。
事实上,你只能为你的函数关闭警告。有两种方法可以做到这一点:
-
使用 C/C++语法
-
使用 Qt 宏
假设你重写了myFunction(QString name, QString myVariable)函数,但你没有使用myVariable。使用 C/C++语法,你只需像这样实现myFunction():
void myFunction(QString name, QString /*myVariable*/)
通过在函数签名中注释变量的名称myVariable,你确保你不会(即不能)在函数体中使用这个变量。编译器也会这样解释,并且不会输出任何警告。
Qt 还提供了使用Q_UNUSED宏标记未使用变量的方法。让我们看看它是如何工作的:
void myFunction(QString name, QString myVariable)
{
Q_UNUSED(myVariable)
...
}
简单地将myVariable传递给Q_UNUSED,它将从编译器输出中移除警告。幕后,Q_UNUSED对变量没有任何神奇的操作:
#define Q_UNUSED(x) (void)x;
这是一个愚弄编译器的简单技巧;它看到 myVariable “被使用”,但实际上并没有对它做任何事情。
将自定义对象记录到 QDebug
当您调试复杂对象时,将它们当前成员的值输出到 qDebug() 中是很方便的。在其他语言(如 Java)中,您可能已经遇到了 toString() 方法或类似的方法,这非常方便。
当然,您可以为每个想要记录的对象添加一个 void toString() 函数,以便使用以下语法编写代码:
qDebug() << "Object content:" << myObject.toString()
在 C++ 中,肯定有更自然的方式来做到这一点。此外,Qt 已经提供了这种功能:
QDate today = QDate::currentDate();
qDebug() << today;
// Output: QDate("2016-10-03")
为了实现这一点,我们将依赖于 C++ 操作符重载。这看起来将非常类似于我们在第十章需要 IPC?让你的仆人去工作中使用的 QDataStream 操作符,需要 IPC?让你的仆人去工作。
考虑一个 struct Person:
struct Person {
QString name;
int age;
};
要添加将正确输出到 QDebug 的功能,您只需覆盖 QDebug 和 Person 之间的 << 操作符,如下所示:
#include <QDebug>
struct Person {
...
};
QDebug operator<<(QDebug debug, const Person& person)
{
QDebugStateSaver saver(debug);
debug.nospace() << "("
<< "name: " << person.name << ", "
<< "age: " << person.age
<< ")";
return debug;
}
QDebugStateSaver 是一个便利类,用于保存 QDebug 的设置,并在销毁时自动恢复它们。始终使用它是良好的实践,以确保您不会在 << 操作符重载中破坏 QDebug。
函数的其余部分是使用 QDebug 的常规方式,最后返回修改后的 debug 变量。现在您可以使用 Person 如此:
Person person = { "Lenna", 64 };
qDebug() << "Person info" << person;
不需要 toString() 函数;只需使用 person 对象。对于那些想知道的人,是的,Lenna 在写作时(2016 年)确实是 64。
改进日志消息
Qt 提供了多种实现方式。在结果和复杂性之间取得良好平衡的方法是将 Qt 日志类型与自定义消息模式相结合。
Qt 定义了五种日志类型,从最不严重到最关键级别:
-
qDebug():用于写入自定义调试消息 -
qInfo():用于写入信息性消息 -
qWarning():用于在您的应用程序中写入警告和可恢复的错误 -
qCritical():用于写入关键错误消息和报告系统错误 -
qFatal():用于在自动退出前写入最后一条消息
尝试始终使用最合适的一个!
默认情况下,消息模式配置为仅显示您的消息而不显示任何额外数据,但您可以自定义模式以显示更多信息。此模式可以通过设置 QT_MESSAGE_PATTERN 环境变量在运行时更改。您还可以从您的软件中调用 qSetMessagePattern 函数来更改模式。模式只是一个包含一些占位符的字符串。
这些是最常用的占位符,您可以使用:
-
%{appname}:这是您的应用程序名称 -
%{file}:这是源文件的路径 -
%{function}:这是函数名 -
%{line}:这是源文件中的一行 -
%{message}:这是原始消息 -
%{type}:这是 Qt 日志类型("debug"、"info"、"warning"、"critical" 或 "fatal") -
%{time [format]}: 这是消息发生时的系统时间
使用它的一个简单方法是像这样编辑你的main.cpp文件:
#include <QApplication>
#include <QDebug>
...
int main(int argc, char *argv[])
{
qSetMessagePattern("[%{time yyyy-MM-dd hh:mm:ss}] [%{type}]
%{function} %{message}");
qInfo() << "Application starting...";
QApplication a(argc, argv);
...
return a.exec();
}
你的应用程序输出应该类似于以下内容:
[2016-10-03 10:22:40] [info] qMain Application starting...
尝试玩转 Qt 日志类型和自定义消息模式,直到找到对你有用的模式。
小贴士
对于更复杂的应用程序,你可以使用QLoggingCategory类来定义日志类别。有关更多信息,请访问doc.qt.io/qt-5/qloggingcategory.html。
将日志保存到文件中
开发者通常需要日志。在某些情况下,你可能无法访问控制台输出,或者你可能需要在之后研究应用程序状态。在这两种情况下,日志必须输出到文件。
Qt 提供了一个将你的日志(qDebug, qInfo, qWarning等)重定向到任何方便的设备的实用方法:QtMessageHandler。要使用它,你必须注册一个函数,该函数将日志保存到所需的输出。
例如,在你的main.cpp文件中添加以下函数:
#include <QFile>
#include <QTextStream>
void messageHander(QtMsgType type,
const QMessageLogContext& context,
const QString& message) {
QString levelText;
switch (type) {
case QtDebugMsg:
levelText = "Debug";
break;
case QtInfoMsg:
levelText = "Info";
break;
case QtWarningMsg:
levelText = "Warning";
break;
case QtCriticalMsg:
levelText = "Critical";
break;
case QtFatalMsg:
levelText = "Fatal";
break;
}
QString text = QString("[%1] %2")
.arg(levelText)
.arg(message);
QFile file("app.log");
file.open(QIODevice::WriteOnly | QIODevice::Append);
QTextStream textStream(&file);
textStream << text << endl;
}
函数的签名必须被尊重,以便 Qt 能够正确调用。让我们回顾一下参数:
-
QtMsgType type: 这是一个enum类型,用于描述生成消息的函数(例如qDebug(),qInfo(),qWarning()等) -
QMessageLogContext& context: 这包含有关日志消息的附加信息(日志产生的源文件、函数名称、行号等) -
const QString& message: 这是实际记录的消息
函数体在将日志消息附加到名为app.log的文件之前格式化日志消息。你可以通过添加循环日志文件、通过网络发送日志或进行其他操作来轻松地在此函数中添加功能。
最后缺少的部分是messageHandler()的注册,这通常在main()函数中完成:
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
qInstallMessageHandler(messageHander);
...
}
调用qInstallMessageHander()函数足以将所有日志消息重路由到app.log。一旦完成,日志将不再显示在控制台输出中,而只会附加到app.log。
小贴士
如果你需要注销你的自定义消息处理函数,请调用qInstallMessageHandler(0)。
生成命令行界面
命令行界面可以是一个以一些特定选项启动应用程序的好方法。Qt 框架提供了一个使用QCommandLineParser类定义选项的简单方法。你可以提供一个简短(例如,-t)或长(例如,--test)的选项名称。应用程序版本和帮助菜单将自动生成。你可以在代码中轻松检索是否设置了选项。一个选项可以接受一个值,并且你可以定义一个默认值。
例如,我们可以创建一个命令行界面来配置日志文件。我们希望定义三个选项:
-
如果设置了,
-debug命令将启用日志文件写入 -
-f或--file命令用于定义日志的写入位置 -
-l或--level <level>命令用于指定最小日志级别
看看下面的片段:
QCoreApplication app(argc, argv);
QCoreApplication::setApplicationName("ch14-hat-tips");
QCoreApplication::setApplicationVersion("1.0.0");
QCommandLineParser parser;
parser.setApplicationDescription("CLI helper");
parser.addHelpOption();
parser.addVersionOption();
parser.addOptions({
{"debug",
"Enable the debug mode."},
{{"f", "file"},
"Write the logs into <file>.",
"logfile"},
{{"l", "level"},
"Restrict the logs to level <level>. Default is 'fatal'.",
"level",
"fatal"},
});
parser.process(app);
qDebug() << "debug mode:" << parser.isSet("debug");
qDebug() << "file:" << parser.value("file");
qDebug() << "level:" << parser.value("level");
让我们讨论每个步骤:
-
第一部分使用
QCoreApplication的函数来设置应用程序名称和版本。这些信息将被--version选项使用。 -
实例化一个
QCommandLineParser类。然后我们指示它自动添加帮助(-h或--help)和版本(-v或--version)选项。 -
使用
QCommandLineParser::addOptions()函数添加我们的选项。 -
请求
QCommandLineParser类处理命令行参数。 -
检索并使用选项。
这里是创建选项的参数:
-
optionName: 通过使用此参数,你可以使用单个或多个名称 -
description:在这个参数中,选项的描述将在帮助菜单中显示 -
valueName(可选):如果您的选项期望一个值,则显示值名称 -
defaultValue(可选):这显示了选项的默认值
您可以使用QCommandLineParser::isSet()检索并使用选项,如果用户设置了选项,则返回 true。如果您的选项需要值,您可以使用QCommandLineParser::value()检索它。
这里是生成的帮助菜单的显示:
$ ./ch14-hat-tips --help
Usage: ./ch14-hat-tips [options]
Helper of the command-line interface
Options:
-h, --help Displays this help.
-v, --version Displays version information.
--debug Enable the debug mode.
-f, --file <logfile> Write the logs into <file>.
-l, --level <level> Restrict the logs to level <level>. Default is 'fatal'.
最后,以下片段显示了正在使用的 CLI:
$ ./ch14-hat-tips --debug -f log.txt --level info
debug mode: true
file: "log.txt"
level: "info"
发送和接收 HTTP 数据
向 HTTP 服务器请求信息是一个常见任务。在这里,Qt 的人们也准备了一些有用的类来简化这个过程。为了实现这一点,我们将依赖于三个类:
-
QNetworkAccessManager:这个类允许您的应用程序发送请求并接收回复 -
QNetworkRequest:这个类包含要发送的请求以及所有信息(头信息、URL、数据等) -
QNetworkReply:这个类包含QNetworkRequest类的结果,包括头信息和数据
QNetworkAccessManager类是整个 Qt HTTP API 的枢纽点。它围绕一个单一的QNetworkAccessManager对象构建,该对象包含客户端的配置、代理设置、缓存信息等等。这个类被设计为异步的,所以你不需要担心阻塞当前线程。
让我们在自定义的HttpRequest类中看看它的实际应用。首先,是头文件:
#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
class HttpRequest : public QObject
{
Q_OBJECT
public:
HttpRequest(QObject* parent = 0);
void executeGet();
private slots:
void replyFinished(QNetworkReply* reply);
private:
QNetworkAccessManager mAccessManager;
};
QNetworkAccessManager类与信号/槽机制一起工作,所以HttpRequest从QObject继承并使用Q_OBJECT宏。我们声明以下函数和成员:
-
executeGet(): 这用于触发一个HTTP GET请求 -
replyFinished():这是在GET请求完成时调用的槽 -
mAccessManager:这是我们用于所有异步请求的对象
让我们把注意力转向HttpRequest.cpp中的HttpRequest类的构造函数:
HttpRequest::HttpRequest(QObject* parent) :
QObject(parent),
mAccessManager()
{
connect(&mAccessManager, &QNetworkAccessManager::finished,
this, &HttpRequest::replyFinished);
}
在构造函数体中,我们将mAccessManager的finished()信号连接到我们的replyFinished()槽。这意味着通过mAccessManager发送的每个请求都会触发此槽。
准备工作就到这里;让我们看看请求和回复的实际操作:
// Request
void HttpRequest::executeGet()
{
QNetworkRequest request(QUrl("http://httpbin.org/ip"));
mAccessManager.get(QNetworkRequest(request));
}
// Response
void HttpRequest::replyFinished(QNetworkReply* reply)
{
int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
qDebug() << "Reponse network error" << reply->error();
qDebug() << "Reponse HTTP status code" << statusCode;
qDebug() << "Reply content:" << reply->readAll();
reply->deleteLater();
}
使用 mAccessManager.get() 处理 HTTP GET 请求。QNetworkAccessManager 类提供了其他 HTTP 动词(head()、post()、put()、delete() 等)的函数。它期望一个 QNetworkRequest 访问,该访问在其构造函数中接受一个 URL。这是 HTTP 请求的最简单形式。
注意,我们使用 URL httpbin.org/ip 进行了请求,它将以 JSON 格式响应发射器的 IP 地址:
{
"origin": "1.2.3.4"
}
这个网站是一个实用的开发者资源,你可以发送你的测试请求,并收到有用的信息反馈。它避免了需要启动一个自定义的 Web 服务器来测试几个请求。这个网站是一个由 Runscope 免费托管的开源项目。当然,你可以用你想要的任何内容替换请求 URL。
注意
查看网址 httpbin.org/ 以查看所有支持请求类型。
在 executeGet() 函数完成后,mAccessManager 对象在单独的线程中执行请求,并使用结果 QNetworkReply* 对象调用我们的槽 replyFinished()。在这个代码片段中,你可以看到如何检索 HTTP 状态码并检查是否发生了任何网络错误,以及如何使用 reply->readAll() 获取响应体。
QNetworkReply 类继承自 QIODevice,因此你可以使用 readAll() 一次性读取它,或者通过在 read() 上的循环以块的形式读取。这让你能够使用熟悉的 QIODevice API 根据你的需求调整读取。
注意,你是 QNetworkReply* 对象的所有者。你不应该手动删除它(如果你这样做,你的应用程序可能会崩溃);相反,最好使用 reply->deleteLater() 函数,这将让 Qt 事件循环选择合适的时机来删除此对象。
现在让我们看看一个更复杂的 QNetworkReply 示例,它使用 HTTP POST 方法。有时你需要跟踪 QNetworkReply 类并对其生命周期有更精细的控制。
下面是一个依赖于 HttpRequest::mAccessManager 的 HTTP POST 方法的实现:
void HttpRequest::executePost()
{
QNetworkRequest request(QUrl("http://httpbin.org/post"));
request.setHeader(QNetworkRequest::ContentTypeHeader,
"application/x-www-form-urlencoded");
QUrlQuery urlQuery;
urlQuery.addQueryItem("book", "Mastering Qt 5");
QUrl params;
params.setQuery(urlQuery);
QNetworkReply* reply = mAccessManager.post(
request, params.toEncoded());
connect(reply, &QNetworkReply::readyRead,
[reply] () {
qDebug() << "Ready to read from reply";
});
connect(reply, &QNetworkReply::sslErrors,
[this] (QList<QSslError> errors) {
qWarning() << "SSL errors" << errors;
});
}
我们首先创建一个带有自定义头部的 QNetworkRequest 类:Content-Type 现在是 application/x-www-form-urlencoded 以遵守 HTTP RFC。之后,构建一个 URL 表单,准备与请求一起发送。你可以向 urlQuery 对象添加你想要的任何项目。
下一个部分很有趣。当使用请求和 URL 编码表单执行 mAccessManager.post() 时,QNetworkReply* 对象立即返回给我们。从这里,我们使用一些直接连接到回复的 lambda 插槽,而不是使用 mAccessManage 插槽。这让你能够精确控制每个回复发生的情况。
注意,QNetworkReply::readyRead 信号来自 QIODevice API,并且它不会在参数中传递 QNetworkReply* 对象。将回复存储在某个成员字段中或检索信号发射者的责任在于您。
最后,这个代码片段不会撤销我们之前定义的槽函数 replyFinished(),该函数连接到 mAccessManager。如果您执行此代码,您将得到以下输出序列:
Ready to read from reply
Reponse network error QNetworkReply::NetworkError(NoError)
Reponse HTTP status code 200
连接到 QNetworkReply::readyRead 信号的 lambda 表达式首先被调用,然后调用 HttpRequest::replyFinished 信号。
我们将在 Qt HTTP 栈上介绍的最后特性是同步请求。如果您需要自己管理请求线程,QNetworkAccessManager 的默认异步工作模式可能会给您带来麻烦。为了绕过这个问题,您可以使用自定义的 QEventLoop:
void HttpRequest::executeBlockingGet()
{
QNetworkAccessManager localManager;
QEventLoop eventLoop;
QObject::connect(
&localManager, &QNetworkAccessManager::finished,
&eventLoop, &QEventLoop::quit);
QNetworkRequest request(
QUrl("http://httpbin.org/user-agent"));
request.setHeader(QNetworkRequest::UserAgentHeader,
"MasteringQt5Browser 1.0");
QNetworkReply* reply = localManager.get(request);
eventLoop.exec();
qDebug() << "Blocking GET result:" << reply->readAll();
reply->deleteLater();
}
在这个函数中,我们声明了另一个 QNetworkAccessManager,它不会干扰在 HttpRequest 中声明的那个。紧接着,声明了一个 QEventLoop 对象并将其连接到 localManager。当 QNetworkAccessManager 发射 finished() 信号时,eventLoop 将退出,调用函数将恢复。
request 按照常规构建,检索到 reply 对象,函数因调用 eventLoop.exec() 而阻塞。函数会一直阻塞,直到 localManager 发射其完成信号。换句话说,请求仍然是异步完成的;唯一的不同是函数会阻塞直到请求完成。
最后,可以在函数的末尾安全地读取和删除 reply 对象。这个 QEventLoop 技巧可以在需要同步等待 Qt 信号时使用;明智地使用它以避免阻塞 UI 线程!
摘要
在本章中,您学到了一些完善您 Qt 知识的技巧。现在您应该能够轻松高效地使用 Qt Creator。QDebug 格式现在不应该有任何秘密了,您现在可以轻松地将日志保存到文件中,甚至不需要眨眼。您可以创建一个看起来不错的 CLI 接口,无需颤抖地调试任何程序的内存,并且可以自信地执行 HTTP 请求。
我们衷心希望您阅读这本书时能像我们写作时一样享受乐趣。在我们看来,Qt 是一个优秀的框架,它涵盖了众多值得通过书籍(或几本书)深入探讨的领域!我们希望您在构建高效且精美构建的应用程序时,能够愉快地编码 C++ Qt 代码。


浙公网安备 33010602011771号