QT-游戏编程初学者指南-全-
QT 游戏编程初学者指南(全)
原文:
zh.annas-archive.org/md5/d8f1f7b7bdb1e30ca6c39c1ae87ed04f译者:飞龙
前言
作为所有重要桌面、移动和嵌入式*台的首选跨*台工具包,Qt 正变得越来越受欢迎。本书将帮助您学习 Qt 的细节,并为您提供构建应用程序和游戏的必要工具集。本书旨在作为初学者指南,将新接触 Qt 的程序员从基础,如对象、核心类、小部件以及 5.4 版本的新特性,引导到能够使用 Qt 的最佳实践创建自定义应用程序的水*。
在简要介绍如何创建应用程序并为桌面和移动*台准备工作环境之后,我们将在尝试创建游戏之前,更深入地探讨创建图形界面和 Qt 数据处理和显示的核心概念。随着您通过章节的进展,您将学习通过实现网络连接和采用脚本编写来丰富您的游戏。深入了解 Qt Quick、OpenGL 以及各种其他工具,以添加游戏逻辑、设计动画、添加游戏物理以及为游戏构建惊人的用户界面。本书的结尾部分,您将学习如何利用移动设备功能,如加速度计和传感器,来构建引人入胜的用户体验。
本书涵盖内容
第一章, Qt 简介,将使您熟悉在创建跨*台应用程序时所需的标准行为,同时也会向您展示 Qt 的一些历史以及它是如何随着时间的推移而演变的,重点介绍 Qt 最*在架构上的重大变化*。
第二章, 安装,将指导您完成在桌面*台上安装 Qt 二进制发布版、设置捆绑的 IDE 以及查看与跨*台编程相关的各种配置选项的过程。
第三章, Qt GUI 编程,将向您展示如何使用 Qt Widgets 模块创建经典用户界面。它还将使您熟悉使用 Qt 编译应用程序的过程。
第四章, Qt 核心基础,将使您熟悉与 Qt 中数据处理和显示相关的概念——不同格式的文件处理、Unicode 文本处理以及在不同语言中显示用户可见的字符串,以及正则表达式匹配。
第五章, Qt 中的图形,描述了在 Qt 中创建和使用 2D 和 3D 图形的整个机制。它还介绍了音频和视频的多媒体功能(捕获、处理和输出)。
第六章,图形视图,将使你熟悉 Qt 中的 2D 面向对象的图形。你将学习如何使用内置项来组合最终结果,以及创建自己的项来补充现有内容,并可能使它们动画化。
第七章,网络,将演示 Qt 中可用的 IP 网络技术。它将教你如何连接到 TCP 服务器,使用 TCP 实现可靠的服务器,以及使用 UDP 实现不可靠的服务器。
第八章,脚本,将向你展示脚本在应用程序中的优势。它将教你如何通过使用 JavaScript 为游戏实现脚本引擎。它还将建议一些可以轻松与 Qt 集成的 JavaScript 脚本替代方案。
第九章,Qt Quick 基础,将教你如何使用 QML 声明性引擎和 Qt Quick 2 场景图环境来编写分辨率无关的流畅用户界面。此外,你还将学习如何在场景中实现新的图形项。
第十章,Qt Quick,将向你展示如何将动态效果引入 UI 的各个方面。你将看到如何通过使用粒子引擎、GLSL 着色器和内置动画以及状态机功能,在 Qt Quick 中创建复杂的图形和动画,并学习如何在游戏中使用这些技术。
第十一章,杂项和高级概念,涵盖了 Qt 编程的重要方面,这些方面没有包含在其他章节中,但对于游戏编程可能很重要。本章可在以下链接中在线获取:www.packtpub.com/sites/default/files/downloads/Advanced_Concepts.pdf。
你需要这本书的什么
你需要的只是安装了最新版本的 Qt 的 Windows 机器。本书中提供的示例基于 Qt 5.4。
Qt 可以从 www.qt.io/download-open-source/ 下载。
这本书面向的对象
这本书的预期读者将是具有基本/中级 C++ 功能知识的应用程序和 UI 开发者/程序员。目标受众还包括 C++ 程序员。阅读这本书不需要你具备 Qt 的先前经验。拥有最多一年 Qt 经验的开发者也将从本书涵盖的主题中受益。
部分
在这本书中,你会找到几个频繁出现的标题(行动时间、刚刚发生了什么?、快速问答和英雄试炼)。
为了清楚地说明如何完成一个程序或任务,我们使用以下部分如下:
行动时间 – 标题
-
行动 1
-
行动 2
-
行动 3
指令通常需要一些额外的解释以确保它们有意义,因此它们后面跟着这些部分:
刚才发生了什么?
本节解释了您刚刚完成的任务或指令的工作原理。
您还可以在书中找到一些其他的学习辅助工具,例如:
快速问答 – 标题
这些是简短的多项选择题,旨在帮助您测试自己的理解。
尝试一下英雄 – 标题
这些是实际挑战,为您提供实验您所学内容的想法。
惯例
您还可以找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将如下所示:"此 API 以QNetworkAccessManager为中心,它处理您游戏与互联网之间的完整通信。"
代码块设置如下:
QNetworkRequest request;
request.setUrl(QUrl("http://localhost/version.txt"));
request.setHeader(QNetworkRequest::UserAgentHeader, "MyGame");
m_nam->get(request);
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
void FileDownload::downloadFinished(QNetworkReply *reply) {
const QByteArray content = reply->readAll();
m_edit->setPlainText(content);
reply->deleteLater();
}
任何命令行输入或输出将如下所示:
git clone git://code.qt.io/qt/qt5.git
cd qt5
perl init-repository
新术语和重要词汇将以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,将以如下方式显示:"在选择目标位置屏幕上,点击下一步以接受默认目标。"
注意
警告或重要注意事项如下所示。
小贴士
小技巧和窍门看起来像这样。
读者反馈
我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。
如要向我们发送一般反馈,请简单地发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍标题。
如果您在某个主题领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南,网址为 www.packtpub.com/authors。
客户支持
现在,您已经成为 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您充分利用您的购买。
下载示例代码
您可以从您的账户下载示例代码文件,网址为 www.packtpub.com,适用于您购买的所有 Packt Publishing 书籍。如果您在其他地方购买了此书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些彩色图像将帮助您更好地理解输出的变化。您可以从 www.packtpub.com/sites/default/files/downloads/GameProgrammingUsingQt_ColoredImages.pdf 下载此文件。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
侵权
互联网上版权材料的侵权是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 与我们联系,并提供疑似侵权材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面所提供的帮助。
询问
如果您对本书的任何方面有问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。
第一章。Qt 简介
在本章中,您将学习 Qt 是什么以及它是如何演变的。我们将特别关注 Qt 的主要版本 4 和 5 之间的区别。最后,您将学习如何决定为我们的项目选择哪种可用的 Qt 许可方案。
跨*台编程
Qt 是一个用于开发跨*台应用程序的应用程序编程框架。这意味着为某个*台编写的软件可以轻松地移植到另一个*台并执行,几乎不需要做任何工作。这是通过将应用程序源代码限制为所有支持的*台都可用的一组例程和库的调用,并将所有可能在*台之间不同的任务(如屏幕绘制、访问系统数据或硬件)委托给 Qt 来实现的。这实际上创建了一个分层环境(如下图所示),Qt 隐藏了所有*台相关的方面,使其从应用程序代码中不可见:

当然,有时我们需要使用 Qt 不提供的一些功能。在这种情况下,使用条件编译,如以下代码中所示,是很重要的:
#ifdef Q_OS_WIN32
// Windows specific code
#elif defined(Q_OS_LINUX) || defined(Q_OS_MAC)
// Mac and Linux specific code
#endif
小贴士
下载示例代码
您可以从您在www.packtpub.com的账户下载示例代码文件,以获取您购买的所有 Packt Publishing 书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
刚才发生了什么?
在代码编译之前,它首先被传递到一个预处理器,该预处理器可能会更改将要发送给编译器的最终文本。当它遇到#ifdef指令时,它会检查是否存在一个随后的标签(例如Q_OS_WIN32),并且只有在标签被定义的情况下才将代码块包含在编译中。Qt 确保为每个系统和编译器提供适当的定义,以便我们可以在这种情况下使用它们。
小贴士
您可以在 Qt 参考手册中找到所有此类宏的列表,在“QtGlobal”这个术语下。
Qt *台抽象
Qt 本身分为两层。一层是在标准 C++语言中实现的 Qt 核心功能,它基本上是*台无关的。另一层是一组小型插件,实现了所谓的Qt *台抽象(QPA),它包含所有与创建窗口、在表面上绘制、使用字体等相关联的*台特定代码。因此,在实践中将 Qt 移植到新*台实际上归结为为它实现 QPA 插件,前提是这个*台使用支持的标准 C++编译器之一。正因为如此,为新的*台提供基本支持可能是几个小时就能完成的工作。
支持的*台
该框架适用于多种*台,从传统的桌面环境到嵌入式系统,再到移动电话。以下表格列出了在撰写本文时 Qt 支持的所有*台和编译器家族。有可能在你阅读本文时,此表可能已增加了几行:
| *台 | QPA 插件 | 支持的编译器 |
|---|---|---|
| Linux | XCB (X11) 和 Wayland | GCC, LLVM (clang), 和 ICC |
| Windows XP, Vista, 7, 8, and 10 | Windows | MinGW, MSVC, and ICC |
| Mac OS X | Cocoa | LLVM (clang) 和 GCC |
| Linux Embedded | DirectFB, EGLFS, KMS, 和 Wayland | GCC |
| Windows Embedded | Windows | MSVC |
| Android | Android | GCC |
| iOS | iOS | LLVM (clang) 和 GCC |
| Unix | XCB (X11) | GCC |
| RTOS (QNX, VxWorks, 和 INTEGRITY) | qnx | qcc, dcc 和 GCC |
| BlackBerry 10 | qnx | qcc |
| Windows 8 (WinRT) | winrt | MSVC |
| Maemo, MeeGo, 和 Sailfish OS | XCB (X11) | GCC |
| Google Native Client (unsupported) | pepper | GCC |
时光之旅
Qt 的发展始于 1991 年,由两位挪威人——Eirik Chambe-Eng 和 Haavard Nord——发起,他们希望创建一个跨*台的 GUI 编程工具包。Trolltech(创建 Qt 工具包的公司)的第一个商业客户是欧洲航天局。Qt 的商业使用帮助 Trolltech 持续发展。当时,Qt 可用于两个*台——Unix/X11 和 Windows;然而,使用 Qt 为 Windows 开发需要购买专有许可证,这在移植现有的 Unix/Qt 应用程序时是一个重大的缺点。
2001 年 Qt 3.0 版本的发布是一个重要的进步,它看到了对 Mac 的初始支持,以及使用自由 GPL 许可证在 Unix 和 Mac 下使用 Qt 的选项。尽管如此,Qt for Windows 仍然仅限于付费许可证。然而,在当时,Qt 已经支持市场上的所有重要参与者——Windows、Mac 和 Unix 桌面,以及 Trolltech 的主流产品和 Qt for 嵌入式 Linux。
2005 年,Qt 4.0 发布,这在多个方面都是一个真正的突破。首先,Qt API 完全重新设计,使其更加简洁和一致。不幸的是,与此同时,它使得现有的基于 Qt 的代码与 4.0 不兼容,许多应用程序需要从头开始重写,或者需要大量努力才能适应新的 API。这是一个艰难的决定,但从时间角度来看,我们可以看到这是值得的。API 变化带来的困难被 Qt for Windows 最终在 GPL 下发布的事实很好地抵消了。引入了许多优化,使 Qt 显著更快。最后,Qt,直到现在都是一个单一库,被分割成多个模块:

这使得程序员只需链接到他们在应用程序中使用的功能,从而减少了软件的内存占用和依赖。
2008 年,Trolltech 被诺基亚收购,当时诺基亚正在寻找一个软件框架来帮助其扩展并未来取代其 Symbian *台。Qt 社区因此出现了分歧,一些人看到 Qt 的发展转向诺基亚后感到兴奋,而另一些人则感到担忧。无论如何,新的资金被注入到 Qt 中,加速了其发展,并使其对移动*台——Symbian、Maemo 和 MeeGo 开放。
对于诺基亚来说,Qt 并没有被看作是自己的产品,而是一种工具。因此,他们决定通过添加一个非常自由的 LGPL 许可证来向更多开发者介绍 Qt,该许可证允许在开源和闭源开发中使用该框架。
将 Qt 带到新的*台和较弱的硬件上需要一种新的方法来创建用户界面,并使它们更加轻量级、流畅和美观。在 Qt 上工作的诺基亚工程师提出了一种新的声明性语言来开发此类界面——Qt 模型语言(QML)以及为其提供的 Qt 运行时 Qt Quick。
后者成为了 Qt 进一步发展的主要焦点,实际上阻碍了所有非移动相关的工作,将所有努力集中在使 Qt Quick 更快、更简单和更普及上。Qt 4 已经在市场上存在了 7 年,显然需要发布 Qt 的另一个主要版本。决定通过允许任何人向项目贡献来吸引更多工程师加入 Qt。
诺基亚未能完成 Qt 5.0 的开发工作。由于 2011 年诺基亚对不同技术的意外转向,Qt 部门在 2012 年中旬被出售给了芬兰公司 Digia,该公司完成了这项工作,并在同年 12 月发布了 Qt 5.0。
Qt 5 的新特性
Qt 5 的 API 与 Qt 4 的 API 差别不大。因此,Qt 5 几乎完全与前辈源代码兼容,这意味着我们只需要最小的努力就可以将现有应用程序移植到 Qt 5。本节简要介绍了 Qt 4 和 5 版本之间主要的更改。如果您已经熟悉 Qt 4,这可以作为一个小型的汇编,如果您想最大限度地使用 Qt 5 的功能,则需要关注的内容。
重新构建的代码库
与 Qt 之前的主要版本相比,最大的变化是整个框架被重构为另一组模块。由于它随着时间的推移而扩展,并且对于它所支持的不断增长的*台集合来说,维护和更新变得更加困难,因此决定将框架拆分为包含在两个模块组中的更小的模块——Qt Essentials 和 Qt Add-ons。与拆分相关的一个重大决定是,每个模块现在都可以有自己的独立发布计划。
Qt Essentials
必需模块组包含每个支持*台都必须实现的模块。这意味着如果您仅使用此组中的模块来实现您的系统,您可以确信它可以轻松地移植到 Qt 支持的任何其他*台。以下是一些模块的说明:
-
QtCore 模块包含所有其他模块所依赖的最基本的 Qt 功能。它提供对事件处理、元对象、数据 I/O、文本处理和线程的支持。它还带来了许多框架,例如动画框架、状态机框架和插件框架。
-
Qt GUI 模块提供了构建用户界面的基本跨*台支持。与 Qt 4 中相同的模块相比,它要小得多,因为对小部件和打印的支持已移至单独的模块。Qt GUI 包含用于操作可以使用光栅引擎(通过指定
QSurface::RasterSurface作为表面类型)或 OpenGL (QSurface::OpenGLSurface) 渲染的窗口的类。Qt 支持桌面 OpenGL 以及 OpenGL ES 1.1 和 2.0。 -
Qt 网络模块提供了使用 TCP 和 UDP 以及通过控制设备连接状态来支持 IPv4 和 IPv6 网络的功能。与 Qt 4 相比,此模块增强了 IPv6 支持,增加了对不透明 SSL 密钥(如硬件密钥设备)和 UDP 多播的支持,并将 MIME 多部分消息组装成通过 HTTP 发送的格式。它还扩展了对 DNS 查询的支持。
-
Qt 多媒体允许程序员访问音频和视频硬件(包括摄像头和 FM 收音机)以记录和播放多媒体内容。
-
Qt SQL 提供了一个框架,用于以抽象方式操作 SQL 数据库。
-
Qt WebKit 是 WebKit 2 网络浏览器引擎到 Qt 的移植。它提供了用于显示和操作网页内容的类,并与您的桌面应用程序集成。
-
Qt Widgets 通过使用小部件(如按钮、编辑框、标签、数据视图、对话框、菜单和工具栏)以及使用特殊布局引擎排列的能力扩展了 GUI 模块,以创建用户界面。它还包含一个名为 Graphics View 的面向对象的 2D 图形画布的实现。当将 Qt 4 应用程序移植到 Qt 5 时,一个好的做法是首先启用对 widgets 模块的支持(通过在项目文件中添加 QT += widgets),然后从这里开始逐步工作。
-
Qt Quick 是 Qt GUI 的扩展,它提供了使用 QML 创建轻量级流畅用户界面的方法。在本章的后续部分以及第九章中,即 Qt Quick 基础,有更详细的描述。
小贴士
此组中还有其他模块,但在此书中我们将不会关注它们。如果您想了解更多关于它们的信息,可以在 Qt 参考手册中查找。
Qt 插件
此组包含任何*台都可选的模块。这意味着如果某些*台上的特定功能不可用,或者没有人愿意花时间为此*台工作此功能,它将不会阻止 Qt 支持此*台。
一些最重要的模块包括 QtConcurrent 用于并行处理、Qt Script 允许我们在 C++ 应用程序中使用 JavaScript、Qt3D 提供高级 OpenGL 构建块以及 Qt XML Patterns 帮助我们访问 XML 数据。还有许多其他模块也可用,但在此处我们将不涉及它们。
Qt Quick 2.0
在功能方面,Qt 的最大升级是 Qt Quick 2.0。在 Qt 4 中,该框架是在 Graphics View 之上实现的。即使启用了 OpenGL ES 加速,当与低端硬件一起使用时,这也证明速度过慢。这是因为 Graphics View 渲染其内容的方式——它按顺序迭代所有项目,计算并设置其变换矩阵,绘制项目,重新计算并重置下一个项目的矩阵,绘制它,依此类推。由于一个项目可以包含任何以任意顺序绘制的通用内容,因此它需要频繁更改 GL 管道,导致严重减速。
Qt Quick 的新版本采用了场景图方法。它将整个场景描述为一个属性和已知操作的图。为了绘制场景,会收集关于当前图状态的详细信息,并以更优化的方式渲染场景。例如,它可以先从所有项目中绘制三角形带,然后从所有项目中渲染字体,依此类推。此外,由于每个项目的状态由一个子图表示,因此可以跟踪每个项目的更改,并决定特定项目的视觉表示是否需要更新。
旧的 QDeclarativeItem 类已被 QQuickItem 替换,它与图形视图架构没有关联。没有可以直接绘制项的常规方法,但有一个 QQuickPaintedItem 类可用,它通过将基于 QPainter 的内容渲染到纹理中,然后使用场景图渲染该纹理来帮助移植旧代码。然而,这类项的运行速度比直接使用图形方法慢得多,所以如果性能很重要,应避免使用。
Qt Quick 在 Qt 5 中扮演着重要角色,并且对于创建游戏非常有用。我们将在第九章 Qt Quick Basics 和第十章 Qt Quick 中详细介绍这项技术。
元对象
在 Qt 4 中,将信号和槽添加到类需要该类的元对象(即描述另一个类的类的实例)的存在。这是通过从 QObject 派生,向其中添加 Q_OBJECT 宏,并在类的特殊作用域中声明信号和槽来完成的。在 Qt 5 中,这仍然是可能的,并且在许多情况下是建议的,但我们现在有了一些新的有趣的可能性。
现在将信号连接到类的任何兼容成员函数或任何可调用实体(例如独立函数或函数对象(functor))是可以接受的。副作用是信号和槽(与“旧”语法的运行时检查相对)的编译时兼容性检查。
C++11 支持
2011 年 8 月,ISO 批准了新的 C++ 标准,通常称为 C++11。它提供了一系列优化,并使程序员更容易创建有效的代码。虽然您可以将 C++11 与 Qt 4 一起使用,但它并没有提供任何针对它的专用支持。这种情况在 Qt 5 中发生了变化,Qt 5 现在了解 C++11 并支持语言新版本引入的许多构造。在这本书中,我们有时会在代码中使用 C++11 功能。一些编译器默认启用了 C++11 支持,而在其他编译器中,您需要启用它。如果您的编译器不支持 C++11,请不要担心。每次我们使用这些功能时,我都会让您知道。
选择正确的许可证
Qt 可在两种不同的许可方案下使用——您可以选择商业许可或开源许可。我们将在这里讨论两者,以便您更容易选择。如果您对特定许可方案是否适用于您的用例有任何疑问,最好咨询专业律师。
开源许可证
开源许可证的优势是,我们不必为使用 Qt 向任何人付费;然而,缺点是它对如何使用 Qt 施加了一些限制。
在选择开源版本时,我们必须在 GPL 3.0 和 LGPL 2.1 或 3 之间做出选择。由于 LGPL 更为自由,在本章中我们将重点关注它。选择 LGPL 允许您使用 Qt 实现开源或闭源的系统——如果您不想,您不必向任何人透露您应用程序的源代码。
然而,您需要了解一些限制:
-
您对 Qt 本身所做的任何修改都需要公开,例如,通过将源代码补丁与您的应用程序二进制文件一起分发。
-
LGPL 要求您的应用程序用户必须能够用具有相同功能的其他库(例如,Qt 的不同版本)替换您提供的 Qt 库。这通常意味着您必须将应用程序动态链接到 Qt,以便用户可以简单地用自己的 Qt 库替换它们。您应该意识到,这种替换可能会降低您系统的安全性,因此,如果您需要非常安全,开源可能不是您的选择。
-
LGPL 与许多许可证不兼容,尤其是专有许可证,因此您可能无法使用 Qt 与某些商业组件一起使用。
Qt 的开源版本可以直接从www.qt.io下载。
商业许可证
如果您决定为 Qt 购买商业许可证,所有这些限制都将被解除。这允许您将整个源代码保密,包括您可能想要合并到 Qt 中的任何更改。您可以自由地将应用程序静态链接到 Qt,这意味着更少的依赖项、更小的部署包大小和更快的启动速度。它还提高了您应用程序的安全性,因为最终用户无法通过用自己的代码替换动态加载的库来向应用程序中注入自己的代码。
注意
要购买商业许可证,请访问qt.io/buy。
摘要
在本章中,您了解了 Qt 的架构。我们看到了它是如何随着时间的推移而演变的,并对现在的样子进行了简要概述。Qt 是一个复杂的框架,我们无法涵盖所有内容,因为其功能的一些部分对于游戏编程来说比其他部分更重要,您可以在需要时自行学习。现在您已经了解了 Qt 是什么,我们可以继续下一章,在那里您将学习如何在您的开发机器上安装 Qt。
第二章。安装
在本章中,您将学习如何在您的开发机器上安装 Qt,包括专为与 Qt 一起使用而设计的 IDE——Qt Creator。您将了解如何根据您的需求配置 IDE,并学习使用该环境的基本技能。此外,本章还将描述从源代码构建 Qt 的过程,这对于自定义您的 Qt 安装以及为嵌入式*台获取一个可工作的 Qt 安装非常有用。在本章结束时,您将能够使用 Qt 发布中包含的工具为桌面和嵌入式*台准备您的开发环境。
安装 Qt SDK
在您可以在您的机器上开始使用 Qt 之前,它需要被下载和安装。Qt 可以使用两种类型的专用安装程序进行安装——在线安装程序,它会在运行时下载所有需要的组件,以及一个更大的离线安装程序,它已经包含了所有需要的组件。使用在线安装程序对于常规桌面安装来说更容易,因此我们将优先选择这种方法。
使用在线安装程序安装 Qt 的行动时间
首先,访问 qt.io 并点击 下载。这将带您到一个包含不同许可方案选项的页面。要使用开源版本,请选择受 GPL 和 LGPL 许可的开源版。然后,您可以点击 立即下载 按钮以获取您当前运行的*台的在线安装程序,或者您可以点击任何标题部分以查看更全面的选项列表。在线安装程序的链接位于列表开头,如下面的截图所示。点击并下载适合您主机机的版本:

下载完成后,运行安装程序,如下所示:

点击 下一步,在下载器检查远程存储库的一段时间后,您将被要求输入安装路径。请确保选择您有写入权限的路径(最好将 Qt 放入您的个人目录中,除非您是以系统管理员用户身份运行安装程序)。再次点击 下一步 将会向您展示您希望安装的组件的选择,如下面的截图所示。您将根据您的*台获得不同的选择。

选择您需要的*台,例如,要在 Linux 上构建原生和 Android 应用程序,请选择基于 gcc 的安装和所需 Android *台的安装。在 Windows 上,您需要做出额外的选择。当使用 Microsoft 编译器时,您可以选择是否使用带 OpenGL 后缀的本地 OpenGL 驱动程序,或者使用 DirectX 调用来模拟 OpenGL ES。如果您没有 Microsoft 编译器或者您根本不想使用它,请选择 MinGW 编译器的 Qt 版本。如果您没有 MinGW 安装,请不要担心——安装程序也会为您安装它。
在选择所需组件并再次点击下一步后,您需要通过标记适当的选项来接受 Qt 的许可条款,如图所示。点击安装后,安装程序将开始下载和安装所需的软件包。一旦完成,您的 Qt 安装就绪。在过程结束时,您将有一个选项来启动 Qt Creator。

发生了什么?
我们所经历的过程会在您的磁盘上出现整个 Qt 基础设施。您可以检查指向安装程序的目录,看看它是否在这个目录中创建了许多子目录——每个子目录对应于安装程序选择的 Qt 的一个版本,还有一个名为 Tools 的子目录,其中包含 Qt Creator。您可以看到,如果您决定安装另一个版本的 Qt,它不会与您的现有安装冲突。此外,对于每个版本,您可以有多个*台子目录,其中包含特定*台的实际 Qt 安装。
设置 Qt Creator
Qt Creator 启动后,您应该会看到以下屏幕:

程序应该已经为您正确配置,以便您可以使用刚刚安装的 Qt 版本和编译器,但让我们无论如何验证一下。从工具菜单中选择选项。一旦弹出对话框,从侧边列表中选择构建和运行。这是我们可以配置 Qt Creator 构建项目方式的地方。一个完整的构建配置称为套件。它由一个 Qt 安装和一个用于执行构建的编译器组成。您可以在选项对话框的构建和运行部分看到这三个实体的标签页。
让我们从编译器选项卡开始。如果您的编译器没有正确检测到并且不在列表中,请点击添加按钮,从列表中选择您的编译器类型,并填写编译器的名称和路径。如果设置正确输入,Creator 将自动填写所有其他详细信息。然后,您可以点击应用来保存更改。
接下来,您可以切换到Qt 版本标签页。如果您的 Qt 安装没有自动检测到,您可以点击添加。这将打开一个文件对话框,您需要找到您的 Qt 安装目录,其中存储了所有二进制可执行文件(通常在bin目录中),并选择一个名为qmake的二进制文件。如果选择错误文件,Qt Creator 会警告您。否则,您的 Qt 安装和版本应该能够正确检测。如果您愿意,可以在相应的框中调整版本名称。
最后一个要查看的标签页是套件标签页。它允许您将编译器与用于编译的 Qt 版本配对。此外,对于嵌入式和移动*台,您可以指定要部署的设备以及包含构建指定嵌入式*台所需所有文件的sysroot目录。
动手时间 - 加载示例项目
Qt 自带了很多示例。让我们尝试构建一个示例来检查安装和配置是否正确完成。在 Qt Creator 中,点击窗口左上角的欢迎按钮进入 IDE 的初始屏幕。在出现的页面右侧(参考前面的截图),有几个标签页,其中一个是名为示例的标签页。点击该标签页将打开一个示例列表,并包含一个搜索框。确保在搜索框旁边的列表中选择了您刚刚安装的 Qt 版本。在框中输入aff以过滤示例列表,然后点击仿射变换以打开项目。如果您被问及是否要将项目复制到新文件夹,请同意。然后 Qt Creator 将向您展示以下窗口:

发生了什么?
Qt Creator 加载了项目并设置了一个视图,该视图将帮助我们学习示例项目。视图分为四个部分。让我们从左侧开始列举。首先是 Qt Creator 的工作模式选择器,其中包含一个操作栏,允许我们在 IDE 的不同模式之间切换。然后是项目视图,其中包含项目文件列表。接下来是源代码编辑器,显示项目的主要源代码部分。最后,在右侧远处,您可以看到在线帮助窗口,显示打开示例的文档。
动手时间 - 运行仿射变换项目
让我们尝试构建并运行项目以检查构建环境是否配置正确。首先,点击绿色三角形图标直接上方的图标以打开构建配置弹出窗口,如图所示:

您获得的确切内容可能因您的安装而异,但通常,在左侧您将看到为项目配置的套件列表,在右侧您将看到为该套件定义的构建配置列表。为您的桌面安装选择一个套件以及为该套件定义的任何配置。您可以通过切换 Qt Creator 到项目管理模式来调整配置,方法是点击工作模式选择栏中的项目按钮。在那里,您可以向项目添加和删除套件,并管理每个套件的构建配置,如图中所示:

您可以调整、构建和清理步骤,并切换阴影构建(即在源代码目录树外构建您的项目)。
要构建项目,请点击操作栏底部的锤子图标。您还可以点击绿色三角形图标来构建和运行项目。如果一切正常,经过一段时间后,应用程序应该会启动,如图所示:

发生了什么?
项目是如何构建的?如果您打开项目模式并查看分配给项目的构建设置(如图中所示的前一个截图),您会注意到定义了多个构建步骤。Qt 项目的第一步通常是qmake步骤,它运行一个特殊的工具,为项目生成一个Makefile,该Makefile在第二步被传递给经典的make工具。您可以通过点击相应的详细信息按钮来展开每个步骤,以查看每个步骤的配置选项。
虽然make被认为是构建软件项目的标准工具,但qmake是 Qt 提供的自定义工具。如果您回到编辑模式并查看项目内容中列出的文件,您会注意到一个扩展名为pro的文件。这是主要的项目文件,其中包含项目中的源文件和头文件列表,为项目定义的 Qt 模块,以及可选的外部库,项目需要链接这些库。如果您想了解此类项目文件的管理细节,可以切换到帮助模式,从窗口顶部的下拉列表中选择索引,并输入qmake Manual以找到该工具的说明书。否则,只需让 Qt Creator 为您管理项目即可。对于自包含的 Qt 项目,您不需要成为 qmake 专家。
从源代码构建 Qt
在大多数情况下,对于桌面和移动*台,您从网页上下载的 Qt 的二进制发布版足以满足您的所有需求。然而,对于嵌入式系统,尤其是基于 ARM 的系统,可能没有可用的二进制发布版,或者对于这样一个轻量级系统来说,二进制发布版资源太重。在这种情况下,需要执行自定义的 Qt 构建。有两种方式进行这样的构建。一种是将源代码作为压缩归档下载,就像二进制包一样。另一种是从 Git 仓库直接下载代码。由于第一种方法相当直观,我们将重点介绍第二种方法。
行动时间 - 使用 Git 设置 Qt 源代码
首先,如果您还没有安装 Git,您需要在系统上安装它。如何做取决于您的操作系统。对于 Windows,只需从git-for-windows.github.io下载安装程序。对于 Mac,安装程序可在code.google.com/p/git-osx-installer找到。对于 Linux,最简单的方法是使用系统包管理器。例如,在基于 Debian 的发行版上,只需在终端中输入sudo apt-get install git命令,然后等待安装完成。
之后,您需要克隆 Qt 的 Git 仓库。由于 Git 是一个命令行工具,我们将从现在开始使用命令行。要将 Qt 的仓库克隆到您想要保存源代码的目录中,请输入以下命令:
git clone git://code.qt.io/qt/qt5.git
如果一切顺利,Git 将从网络下载大量源代码并创建一个名为qt5的目录,其中包含所有下载的文件。然后,将当前工作目录更改为包含新下载代码的目录:
cd qt5
然后,您需要运行一个 Perl 脚本,该脚本将为您设置所有额外的仓库。如果您还没有安装 Perl,您应该现在安装它(您可以从www.activestate.com/activeperl/downloads获取 Windows 版本的 Perl)。然后,输入以下命令:
perl init-repository
脚本将开始下载 Qt 所需的所有模块,并在依赖于您的网络链路速度的一段时间后成功完成。
发生了什么?
在此qt5目录中,您将看到为不同的 Qt 模块(其中一些在第一章,Qt 简介中提到)创建的多个子目录,每个子目录都包含相应 Qt 模块和工具的源代码的本地 Git 仓库。如果需要,每个模块都可以单独更新。
行动时间 - 配置和构建 Qt
在源代码就绪的情况下,我们可以开始构建框架。为此,除了支持的编译器外,您还需要安装 Perl 和 Python(版本 2.7 或更高)。对于 Windows,您还需要 Ruby。如果您缺少任何工具,现在是安装它们的好时机。之后,打开命令行,将当前工作目录更改为包含 Qt 源代码的目录。然后,输入以下命令:
configure -opensource -nomake tests
这将启动一个工具,用于检测是否满足所有要求,并将报告任何不一致之处。它还将报告构建的确切配置。您可以通过向configure传递额外的选项来自定义构建(例如,如果您需要启用或禁用某些功能或为嵌入式*台交叉编译 Qt),以运行configure -help来查看可用选项。
如果configure报告了问题,您将需要修复这些问题并重新启动工具。否则,通过调用make(或者在 MinGW 中使用mingw32-make,或者在 MSVC 中使用nmake)来启动构建过程。
小贴士
除了nmake,您还可以使用 Qt 捆绑的工具jom。它将在多核机器上减少编译时间,这是默认的nmake工具所做不到的。对于make和mingw32-make,您可以通过传递-j N参数来传递,其中N代表您机器上的核心数。
刚才发生了什么?
经过一段时间(通常不到一小时),如果一切顺利,构建应该完成,您将准备好将编译好的框架添加到 Qt Creator 中可用的工具包列表中。
小贴士
在 Unix 系统上,构建完成后,您可以使用超级用户权限(例如,通过sudo获得)调用make install命令,将框架复制到更合适的位置。
摘要
到目前为止,您应该能够在您的开发机器上安装 Qt。现在,您可以使用 Qt Creator 浏览现有示例并从中学习,或者阅读 Qt 参考手册以获取更多知识。您也可以开始一个新的 C++项目,为其编写代码,构建并执行它。一旦您成为经验丰富的 Qt 开发者,您也将能够创建自己的自定义 Qt 构建。在下一章中,我们最终将开始使用框架,您将学习如何通过实现我们非常简单的第一个游戏来创建图形用户界面。
第三章. Qt 图形界面编程
本章将帮助你学习如何使用 Qt Creator IDE 开发具有图形用户界面的应用程序。我们将熟悉 Qt 的核心功能、属性系统以及我们将用于创建复杂系统(如游戏)的信号和槽机制。我们还将介绍 Qt 的各种操作和资源系统。到本章结束时,你将能够编写自己的程序,通过窗口和控件与用户进行通信。
窗口和对话框
你需要学习的最基本技能是创建窗口,在屏幕上显示它们,并管理它们的内容。
创建 Qt 项目
使用 Qt Creator 开发应用程序的第一步是使用编辑器提供的模板之一创建一个项目。
行动时间 – 创建一个 Qt 桌面项目
当你第一次启动 Qt Creator 时,你将看到一个欢迎屏幕。从 文件 菜单中选择 新建文件或项目。有几种项目类型可供选择。按照以下步骤创建 Qt 桌面项目:
-
对于基于小部件的应用程序,选择 应用程序 组和 Qt Gui 应用程序 模板:
![行动时间 – 创建一个 Qt 桌面项目]()
-
下一步是选择新项目的名称和位置:
![行动时间 – 创建一个 Qt 桌面项目]()
-
我们将创建一个简单的井字棋游戏,因此我们将我们的项目命名为
tictactoe并为其提供一个合适的位置。小贴士
如果你有一个存放所有项目的公共目录,你可以勾选 用作默认项目位置 复选框,以便 Creator 记住位置并在下次启动新项目时建议该位置。
-
当你点击 下一步 时,你将看到一个窗口,允许你选择一个或多个为项目定义的编译工具包。继续下一步,不做任何更改。你将看到创建项目第一个小部件的选项。按照以下截图所示填写数据:
![行动时间 – 创建一个 Qt 桌面项目]()
-
然后,点击 下一步 和 完成。
刚才发生了什么?
Creator 在你之前选择的项目位置目录中创建了一个新的子目录,并将一些文件放在那里。其中两个文件(tictactoewidget.h 和 tictactoewidget.cpp)实现了 TicTacToeWidget 类,作为 QWidget 的子类。第三个文件名为 main.cpp,包含应用程序入口点的代码:
#include "tictactoewidget.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication a(argc, argv);
TicTacToeWidget w;
w.show();
return a.exec();
}
此文件创建了一个 QApplication 类的实例,并将其标准参数传递给 main() 函数。然后,它实例化我们的 TicTacToeWidget 类,调用其 show 方法,并最终返回应用程序对象的 exec 方法返回的值。
QApplication 是一个单例类,它管理整个应用程序。特别是,它负责处理来自应用程序内部或外部来源的事件。为了处理事件,需要一个事件循环正在运行。循环等待传入的事件并将它们分派到适当的例程。Qt 中的大多数事情都是通过事件完成的——输入处理、重绘、通过网络接收数据、触发计时器等等。这就是我们说 Qt 是一个面向事件框架的原因。如果没有活跃的事件循环,任何东西都无法正常工作。QApplication 中的 exec 调用(或者更具体地说,在其基类 QCoreApplication 中)负责进入应用程序的主事件循环。该函数在应用程序请求事件循环终止之前不会返回。当这最终发生时,main 函数返回,你的应用程序结束。
生成的最终文件名为 tictactoe.pro,是项目配置文件。它包含了使用 Qt 提供的工具构建项目所需的所有信息。让我们分析这个文件:
QT += core gui
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
TARGET = tictactoe
TEMPLATE = app
SOURCES += main.cpp tictactoewidget.cpp
HEADERS += tictactoewidget.h
前两行启用了 Qt 的 core、gui 和 widgets 模块。接下来的两行指定了你的项目文件描述了一个应用程序(而不是,例如,一个库),并声明了可执行的目标名称为 tictactoe。最后两行添加了 Creator 为我们生成的文件,用于构建过程。
我们现在有一个完整的最小化 Qt GUI 项目。要构建和运行它,只需从 构建 下拉菜单中选择 运行 选项,或者在 Qt Creator 窗口的左侧点击绿色的三角形图标。过了一会儿,你应该会看到一个窗口弹出。由于我们没有向窗口添加任何内容,所以它是空的。
向窗口添加子控件
在我们成功在屏幕上得到一个空白窗口之后,下一步就是向其中添加一些内容。为此,你需要创建控件并告诉 Qt 将它们定位在窗口中。基本的方法是为控件提供一个父控件。
在 Qt 中,我们将对象(如小部件)组合成父子关系。这种方案在 QWidget 的超类 QObject 中定义,QObject 是 Qt 中最重要的类,我们将在本章后面更详细地介绍它。现在重要的是,每个对象都可以有一个父对象和任意数量的子对象。在部件的情况下,有一个规则,即子对象占据其父对象的一个子区域。如果没有父对象,则它成为一个顶级窗口,通常可以拖动、调整大小和关闭。我们可以通过两种方式为对象设置父对象。一种方式是调用 QObject 中定义的 setParent 方法,该方法接受一个 QObject 指针。由于前面提到的规则,QWidget 希望有其他小部件作为父对象,因此该方法在 QWidget 中被重载以接受一个 QWidget 指针。另一种方式是将父对象指针传递给子对象的 QWidget 构造函数。如果您查看 Creator 生成的部件代码,您会注意到构造函数也接受一个指向小部件的指针作为其最后一个(可选)参数:
TicTacToeWidget::TicTacToeWidget(QWidget *parent)
: QWidget(parent)
{
}
然后,它将那个指针传递给其基类的构造函数。因此,您始终记得为您的部件创建一个接受指向 QWidget 实例的指针并将其传递到继承树上的构造函数是非常重要的。所有标准 Qt 小部件也都以这种方式行为。
管理小部件内容
使小部件显示为其父对象的一部分并不足以制作出良好的用户界面。您还需要设置其位置和大小,并对其内容和父小部件内容的变化做出反应。在 Qt 中,我们使用称为布局的机制来完成这项工作。
布局允许我们安排小部件的内容,确保其空间得到有效利用。当我们为小部件设置布局时,我们可以开始添加小部件甚至其他布局,机制将根据我们指定的规则调整大小和重新定位它们。当用户界面中发生影响小部件显示方式的事件时(例如,按钮文本被替换为更长的文本,这使得按钮需要更多空间来显示其内容;如果没有,则某个小部件被隐藏),布局会被触发,重新计算所有位置和大小,并根据需要更新小部件。
Qt 提供了一组预定义的布局,这些布局是从 QLayout 类派生出来的,但您也可以创建自己的。我们目前可用的布局有 QHBoxLayout 和 QVBoxLayout,它们分别水*垂直定位项目;QGridLayout,它以网格排列项目,以便项目可以跨越列或行;以及 QFormLayout,它创建两列项目,其中一列包含项目描述,另一列包含项目内容。还有一个 QStackedLayout,它很少直接使用,并且使分配给它的一个项目拥有所有可用空间。您可以在以下图中看到最常见的布局的实际应用:

要使用布局,我们需要创建其实例,并将我们想要它管理的小部件的指针传递给它。然后,我们可以开始向布局中添加小部件:
QHBoxLayout *layout = new QHBoxLayout(parentWidget);
QPushButton *button1 = new QPushButton;
QPushButton *button2 = new QPushButton;
layout->addWidget(button1);
layout->addWidget(button2);
我们甚至可以通过设置布局上的间距和在布局上设置自定义边距来将小部件彼此进一步移动:
layout->setSpacing(10);
layout->setMargins(10, 5, 10, 5); // left, top, right, bottom
在构建和运行此代码后,您会看到两个均匀分布在父空间中的按钮。请注意,尽管我们没有明确传递父小部件指针,但将小部件添加到布局中会使它重新将新添加的小部件作为布局管理的小部件的子部件。水*调整父小部件的大小也会导致按钮再次调整大小,覆盖所有可用空间。然而,如果您垂直调整 parentWidget 的大小,按钮将改变其位置但不会改变其高度。
这是因为每个小部件都有一个名为大小策略的属性,它决定了布局如何调整小部件的大小。您可以为水*和垂直方向设置不同的尺寸策略。按钮的垂直尺寸策略为 Fixed,这意味着无论有多少可用空间,小部件的高度都不会从默认高度改变。以下是可以用的尺寸策略:
-
忽略: 在此,小部件的默认大小被忽略,小部件可以自由地增长和缩小 -
固定: 在此,默认大小是小部件唯一允许的大小 -
首选: 在此,默认大小是期望的大小,但较小和较大的尺寸也是可接受的 -
最小: 在此,小部件的默认大小是最小可接受的大小,但小部件可以被放大而不会损害其功能 -
最大: 在此,默认大小是小部件的最大大小,小部件可以被缩小(甚至缩小到无),而不会损害其功能 -
扩展: 在此,默认大小是期望的大小;较小的尺寸(甚至为零)是可接受的,但小部件能够在分配更多空间时增加其有用性 -
最小扩展: 这是由最小和扩展组合而成的——小部件在空间方面是贪婪的,并且不能缩小到其默认大小以下
我们如何确定默认大小?答案是通过对sizeHint虚拟方法返回的大小。对于布局,大小是根据其子小部件和嵌套布局的大小和大小策略计算的。对于基本小部件,sizeHint返回的值取决于小部件的内容。在按钮的情况下,如果它包含一行文本和一个图标,sizeHint将返回完全包含文本、图标、它们之间的一些空间、按钮框架以及框架和内容本身之间的填充所需的大小。
动手时间 - 实现井字棋游戏棋盘
现在,我们将创建一个使用按钮实现井字棋游戏棋盘的小部件。
在 Creator 中打开tictactoewidget.h文件,并通过添加以下高亮代码来更新它:
#ifndef TICTACTOEWIDGET_H
#define TICTACTOEWIDGET_H
#include <QWidget>
class QPushButton;
class TicTacToeWidget : public QWidget
{
Q_OBJECT
public:
TicTacToeWidget(QWidget *parent = 0);
~TicTacToeWidget();
private:
QList<QPushButton*> board;
};
#endif // TICTACTOEWIDGET_H
我们的增加创建了一个可以持有QPushButton类实例指针的列表,这是 Qt 中最常用的按钮类。它将代表我们的游戏棋盘。我们必须教会编译器理解我们使用的类;因此,我们添加了QPushButton类的前置声明。
下一步是创建一个方法,帮助我们创建所有按钮并使用布局来管理它们的几何形状。再次进入头文件,并在类的private部分添加一个void setupBoard();声明。为了快速实现新声明的方法,我们可以请求 Qt Creator 为我们创建骨架代码,只需将文本光标定位在方法声明(分号之前)之前,然后在键盘上按Alt + Enter,并从弹出菜单中选择在 tictactoewidget.cpp 中添加定义。
小贴士
反过来也适用。你可以先编写方法主体,然后将光标定位在方法签名上,按Alt + Enter,并从快速修复菜单中选择添加公共声明。Creator 中还有各种其他上下文相关的修复方案。
因为在头文件中我们只进行了QPushButton的前置声明,所以我们现在需要通过包含适当的头文件来提供完整的类定义。在 Qt 中,所有类都在与类本身名称完全相同的头文件中声明。因此,为了包含QPushButton的头文件,我们需要在实现文件中添加一行#include <QPushButton>。我们还将使用QGridLayout类来管理小部件中的空间,因此我们还需要#include <QGridLayout>。
小贴士
从现在开始,这本书将不再提醒你添加include指令到你的源代码中——你必须自己负责这一点。这真的很简单,只需记住,要使用 Qt 类,你需要包含一个以该类命名的文件。
现在,让我们将代码添加到setupBoard方法的主体中。首先,让我们创建一个将包含我们的按钮的布局:
QGridLayout *gridLayout = new QGridLayout;
然后,我们可以开始向布局中添加按钮:
for(int row = 0; row < 3; ++row) {
for(int column = 0; column < 3; ++column) {
QPushButton *button = new QPushButton;
button->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
button->setText(" ");
gridLayout->addWidget(button, row, column);
board.append(button);
}
}
代码在棋盘的行和列上创建了一个循环。在每次迭代中,它创建一个QPushButton类的实例,并将按钮的大小策略设置为Minimum/Minimum,这样当我们调整小部件大小时,按钮也会调整大小。按钮被分配一个空格作为其内容,以便它获得正确的初始大小。然后,我们将按钮添加到row和column中的布局中。最后,我们将按钮的指针存储在之前声明的列表中。这使得我们可以在以后引用任何按钮。它们在列表中的顺序是这样的:首先存储第一行的前三个按钮,然后是第二行的按钮,最后是最后一行的按钮。
最后一件要做的事情是告诉我们的小部件gridLayout将管理其大小:
setLayout(gridLayout);
或者,我们可能将此作为参数传递给布局的构造函数。
现在我们有了准备棋盘的代码,我们需要在某个地方调用它。一个很好的地方是在类构造函数中执行:
TicTacToeWidget::TicTacToeWidget(QWidget *parent)
: QWidget(parent)
{
setupBoard();
}
现在,构建并运行程序。
刚才发生了什么?
你应该会看到一个包含九个按钮的窗口,这些按钮以网格状排列。如果你开始调整窗口大小,按钮也会相应调整大小。这是因为我们设置了一个包含三列和三行的网格布局,它将小部件均匀地分布在管理区域内,如下所示:

当我们在这里时,给这个类添加另一个public方法,并将其命名为initNewGame。我们将使用这个方法在开始新游戏时清除棋盘。方法体应该如下所示:
void TicTacToeWidget::initNewGame() {
for(int i=0; i<9; ++i) board.at(i)->setText(" ");
}
小贴士
你可能已经注意到,尽管我们在setupBoard中使用new操作符创建了许多对象,但我们并没有在任何地方(例如,在析构函数中)销毁这些对象。这是因为 Qt 管理内存的方式。Qt 不会进行垃圾回收(如 Java 那样),但它有一个与QObject父子层次结构相关的良好特性。规则是,每当一个QObject实例被销毁时,它也会删除所有子对象。由于布局对象和按钮都是TicTacToeWidget实例的子对象,因此当主小部件被销毁时,它们都会被删除。这也是为什么我们要设置我们创建的对象的父对象的原因——如果我们这样做,我们就不必担心显式释放任何内存。
Qt 元对象
Qt 提供的许多特殊功能都围绕着QObject类和我们现在将更详细地探讨的元对象范式。范式表明,对于每个QObject子类,都有一个与之关联的特殊对象,它包含有关该类的信息。它允许我们在运行时查询以了解有关类的有用信息——类的名称、超类、构造函数、方法、字段、枚举等。当满足三个条件时,元对象在编译时为类生成:
-
该类是
QObject的子类 -
它在其定义的私有部分包含一个特殊的
Q_OBJECT宏 -
类的代码由一个特殊的 元对象编译器(moc)工具预处理
我们可以通过为类编写适当的代码来满足前两个条件,就像 Qt Creator 在我们创建一个从 QObject 派生的类时所做的那样。最后一个条件在您使用 Qt(和 Qt Creator)附带的工具链构建项目时自动满足。然后,只需确保包含类定义的文件被添加到项目文件的 HEADERS 变量中,Qt 就会处理其余部分。实际上发生的是 moc 为我们生成一些代码,这些代码随后在主程序中编译。
本章本节中讨论的所有功能都需要类的元对象。因此,如果您想使类使用这些功能中的任何一个,确保满足我提到的三个条件是至关重要的。
信号和槽
为了响应应用程序中发生的事情而触发功能,Qt 使用信号和槽的机制。这是基于将关于某个对象状态变化的通告(我们称之为 信号)与一个函数或方法(称为 槽)相连接,当这种通告出现时,该函数或方法将被执行。
信号和槽可以与所有继承自 QObject 的类一起使用。一个信号可以连接到一个槽、成员函数或函数对象(包括常规的全局函数)。当一个对象发出信号时,任何连接到该信号的这些实体都将被调用。一个信号也可以连接到另一个信号,在这种情况下,发出第一个信号将使另一个信号也被发出。你可以将任意数量的槽连接到单个信号,也可以将任意数量的信号连接到单个槽。
信号槽连接由以下四个属性定义:
-
改变其状态的对象(发送者)
-
发送者的信号
-
包含要调用的函数的对象(接收者)
-
接收者的槽
要声明一个信号,我们将它的声明,即一个常规成员函数声明,放在一个称为 signals 的特殊类作用域中。然而,我们并不实现这样的函数——这将由 moc 自动完成。要声明一个槽,我们将声明放在公共槽、受保护槽或私有槽的类作用域中。槽是常规方法,可以在代码中直接调用,就像任何其他方法一样。与信号相反,我们需要为槽方法提供主体。
实现了一些信号和槽的示例类如下所示:
class ObjectWithSignalsAndSlots : public QObject {
Q_OBJECT
public:
ObjectWithSignalsAndSlots(QObject *parent = 0) : QObject(parent) {
}
public slots:
void setValue(int v) { … }
void setColor(QColor c) { … }
private slots:
void doSomethingPrivate();
signals:
void valueChanged(int);
void colorChanged(QColor);
};
void ObjectWithSignalsAndSlots::doSomethingPrivate() {
// …
}
可以使用 connect() 和 disconnect() 语句动态地连接和断开信号和槽。
经典的 connect 语句如下所示:
connect(spinBox, SIGNAL(valueChanged(int)), dial, SLOT(setValue(int)));
此语句在名为valueChanged的spinBox对象的SIGNAL和名为dial对象的setValue槽之间建立连接,该槽接受一个int参数。在connect语句中放置变量名或值是禁止的。你只能连接具有匹配签名的信号和槽,这意味着它们接受相同类型的参数(不允许任何类型转换,并且类型名称必须完全匹配),除了槽可以省略任意数量的最后一个参数。因此,以下connect语句是有效的:
connect(spinBox, SIGNAL(valueChanged(int)), lineEdit, SLOT(clear()));
这是因为在调用clear之前可以丢弃valueChanged信号的参数。然而,以下语句是无效的:
connect(button, SIGNAL(clicked()), lineEdit, SLOT(setText(QString)));
没有地方可以获取要传递给setText的值,因此这种连接将失败。
提示
重要的是,你必须将信号和槽签名包装在SIGNAL和SLOT宏中,并且在指定签名时,你只传递参数类型,而不是值或变量名。否则,连接将失败。
自从 Qt 5 以来,有几种不同的连接语法可用,不需要实现槽的类的元对象。尽管如此,QObject的遗留要求仍然存在,并且元对象对于发出信号的类仍然是必需的。
我们可以使用的第一种附加语法是,我们传递信号方法指针和槽方法指针,而不是在SIGNAL和SLOT宏中包装签名:
connect(button, &QPushButton::clicked, lineEdit, &QLineEdit::clear);
在这种情况下,槽可以是任何QObject子类的任何成员函数,其参数类型与信号匹配,或者可以转换为与信号匹配的类型。这意味着,例如,你可以将携带双值信号的信号与接受整型参数的槽连接起来:
class MyClass : public QObject {
Q_OBJECT
public:
MyClass(QObject *parent = 0) : QObject(parent) {
connect(this, &MyClass::somethingHappened, this, &MyClass::setValue);
}
void setValue(int v) { … }
signals:
void somethingHappened(double);
};
提示
一个重要的方面是,你不能自由地混合基于元对象和基于函数指针的方法。如果你决定在特定的连接中使用成员方法指针,你必须对信号和槽都这样做。
我们甚至可以更进一步,将一个信号连接到一个独立的函数:
connect(button, &QPushButton::clicked, &someFunction);
如果你使用 C++11,函数也可以是一个 lambda 表达式,在这种情况下,你可以在connect语句中直接编写槽的正文:
connect(pushButton, SIGNAL(clicked()), []() { std::cout << "clicked!" << std::endl; });
如果你想调用一个具有固定参数值的槽,而这个值不能由信号携带,因为它有更少的参数,这特别有用。一种解决方案是从 lambda 函数(或独立函数)中调用槽:
connect(pushButton, SIGNAL(clicked()), [label]() { label->setText("button was clicked"); });
函数甚至可以被函数对象(functor)所替代。为此,我们创建一个类,为该类重载的调用操作符与我们要连接的信号兼容,如下面的代码片段所示:
class Functor {
public:
Functor(Object *object, const QString &str) : m_object(object), m_str(str) {}
void operator()(int x, int y) const {
m_object->set(x, y, m_str);
}
private:
Object *m_object;
QString m_str;
};
connect(obj1, SIGNAL(coordChanged(int, int)), Functor("Some Text"));
这通常是一种执行带有额外参数的槽的方法,该参数不是由信号携带的,因为这样做比使用 lambda 表达式要干净得多。
在这里,我们还没有涵盖信号和槽的一些方面。当我们处理多线程时,我们将在稍后回到它们。
速问速答 - 建立信号-槽连接
Q1. 对于以下哪个选项,你必须提供自己的实现?
-
信号
-
槽
-
两者
Q2. 以下哪些陈述是有效的?
-
connect(sender, SIGNAL(textEdited(QString)), receiver, SLOT(setText("foo"))) -
connect(sender, SIGNAL(toggled(bool)), receiver, SLOT(clear())); -
connect(sender, SIGNAL(valueChanged(7)), receiver, SLOT(setValue(int))); -
connect(sender, &QPushButton::clicked, receiver, &QLineEdit::clear);
行动时间 - 跳棋板的功能
我们需要实现一个函数,该函数将在点击板上的任何九个按钮时被调用。它必须根据哪个玩家移动来更改被点击的按钮上的文本——要么是X要么是O——然后,它必须检查该移动是否使玩家获胜(如果没有更多移动,则为*局),如果游戏结束,它应该发出适当的信号,通知环境有关事件。
当用户点击按钮时,会发出clicked()信号。将此信号连接到自定义槽允许我们实现所提到的功能,但由于该信号不携带任何参数,我们如何知道哪个按钮触发了槽?我们可以将每个按钮连接到单独的槽,但这是一种丑陋的解决方案。幸运的是,有两种方法可以解决这个问题。当槽被调用时,可以通过QObject中的特殊方法sender()访问导致信号发出的对象的指针。我们可以使用该指针来确定哪个存储在板列表中的九个按钮导致了信号的触发:
void TicTacToeWidget::someSlot() {
QObject *btn = sender();
int idx = board.indexOf(btn);
QPushButton *button = board.at(idx);
// ...
}
虽然sender()是一个有用的调用,但我们应该尽量避免在我们的代码中使用它,因为它破坏了一些面向对象编程的原则。此外,还有一些情况下调用此函数是不安全的。更好的方法是使用一个专门的类,称为QSignalMapper,它允许我们在不直接使用sender()的情况下达到类似的结果。按照以下方式修改TicTacToeWidget中的setupBoard()方法:
QGridLayout *gridLayout = new QGridLayout;
QSignalMapper *mapper = new QSignalMapper(this);
for(int row = 0; row < 3; ++row) {
for(int column = 0; column < 3; ++column) {
QPushButton *button = new QPushButton;
button->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
button->setText(" ");
gridLayout->addWidget(button, row, column);
board.append(button);
mapper->setMapping(button, board.count()-1);
connect(button, SIGNAL(clicked()), mapper, SLOT(map()));
}
}
connect(mapper, SIGNAL(mapped(int)), this, SLOT(handleButtonClick(int)));
setLayout(gridLayout);
在这里,我们首先创建了一个QSignalMapper的实例,并将棋盘小部件的指针传递给它作为其父对象,这样当小部件被删除时,映射器也会被删除。然后,当我们创建按钮时,我们“教导”映射器每个按钮都有一个与之关联的数字——第一个按钮将具有数字0,第二个按钮将绑定到数字1,依此类推。通过将按钮的clicked()信号连接到映射器的map()槽,我们告诉映射器在接收到该信号时执行其魔法。映射器将执行的操作是找到信号发送者的映射,并发出另一个信号——mapped(),其参数为映射的数字。这允许我们连接到该信号,并使用一个槽(handleButtonClick)来处理按钮在棋盘列表中的索引。
现在是时候实现槽本身了(记得在头文件中声明它!)然而,在我们这样做之前,让我们向类中添加一个有用的枚举和一些辅助方法:
enum Player {
Invalid, Player1, Player2, Draw
};
这个枚举让我们可以指定游戏中玩家的信息。我们可以立即使用它来标记现在是哪个玩家的回合。为此,向类中添加一个私有字段:
Player m_currentPlayer;
然后,添加两个公共方法来操作这个字段的值:
Player currentPlayer() const { return m_currentPlayer; }
void setCurrentPlayer(Player p) {
if(m_currentPlayer == p) return;
m_currentPlayer = p;
emit currentPlayerChanged(p);
}
最后一个方法发出一个信号,因此我们必须将信号声明添加到类定义中,以及我们将要使用的另一个信号:
signals:
void currentPlayerChanged(Player);
void gameOver(TicTacToeWidget::Player);
小贴士
注意,我们只在当前玩家真正改变时发出currentPlayerChanged信号。你总是必须注意,当你将一个字段的值设置为在函数调用之前它所拥有的相同值时,不要发出“已更改”信号。你的类的用户期望如果调用了一个名为“已更改”的信号,那么它是在值真正更改时发出的。否则,如果你有两个对象,它们将它们的值设置器连接到另一个对象的“已更改”信号,这可能导致信号发射中的无限循环。
现在让我们声明handleButtonClick槽:
public slots:
void handleButtonClick(int);
然后在.cpp文件中实现它:
void TicTacToeWidget::handleButtonClick(int index) {
if(index < 0 || index >= board.size()) return; // out of bounds check
QPushButton *button = board.at(index);
if(button->text() != " ") return; // invalid move
button->setText(currentPlayer() == Player1 ? "X" : "O");
Player winner = checkWinCondition(index / 3, index % 3);
if(winner == Invalid) {
setCurrentPlayer(currentPlayer() == Player1 ? Player2 : Player1);
return;
} else {
emit gameOver(winner);
}
}
在这里,我们首先根据索引检索按钮的指针。然后,我们检查按钮是否包含任何文本——如果是这样,这意味着它不再参与游戏,因此我们从方法中返回,以便玩家可以在棋盘上选择另一个字段。接下来,我们在按钮上设置当前玩家的标记。然后,我们检查玩家是否赢得了游戏,传递当前移动的行(index / 3)和列(index % 3)索引。如果游戏没有结束,我们切换当前玩家并返回。否则,我们发出gameOver()信号,告诉我们的环境谁赢得了游戏。checkWinCondition()方法在游戏结束时返回Player1、Player2或Draw,否则返回Invalid。我们不会在这里展示这个方法的实现,因为它相当复杂。尝试自己实现它,如果遇到问题,你可以在本书附带的代码包中查看解决方案。
属性
除了信号和槽,Qt 元对象还让程序员能够使用所谓的属性,这些属性本质上是可以分配特定类型值的命名属性。它们对于表达对象的重要特性非常有用——比如按钮的文本、小部件的大小、游戏中的玩家名字等等。
声明属性
要创建一个属性,我们首先需要在继承自QObject的类的私有部分使用特殊的Q_PROPERTY宏来声明它,这样 Qt 就能知道如何使用这个属性。一个最小的声明包含属性的类型、它的名字以及用于检索属性值的方方法的名称信息。例如,以下代码声明了一个类型为double的属性,名为height,并使用名为height的方法来读取属性值:
Q_PROPERTY(double height READ height)
获取器方法必须按照常规进行声明和实现。它的原型必须遵守以下规则:它必须是一个返回属性类型值或常量引用的公共方法,它不能接受任何输入参数,并且方法本身必须是常量。通常,属性会操作类的私有成员变量:
class Tower : public QObject {
Q_OBJECT // enable meta-object generation
Q_PROPERTY(double height READ height) // declare the property
public:
Tower(QObject *parent = 0) : QObject(parent) { m_height = 6.28; }
double height() const { return m_height; } // return property value
private:
double m_height; // internal member variable holding the property value
};
这样的属性实际上是没有用的,因为没有方法可以改变它的值。幸运的是,我们可以扩展声明以包括如何将值写入属性的信息:
Q_PROPERTY(double height READ height WRITE setHeight)
同样,我们必须声明和实现setHeight,使其作为属性的设置器方法——它需要是一个接受属性类型值或常量引用的公共方法,并返回 void:
void setHeight(double newHeight) { m_height = newHeight; }
小贴士
属性设置器是公共槽的良好候选者,这样你就可以通过信号和槽轻松地操作属性值。
我们将在本书的后续章节中学习关于Q_PROPERTY声明的其他扩展。
使用属性
你可以通过两种方式访问属性。一种当然是使用我们用READ和WRITE关键字在Q_PROPERTY宏中声明的获取器和设置器方法——这自然会起作用,因为它们是常规的 C++方法。
另一种方法是使用 QObject 和元对象系统提供的功能。它们允许我们通过两个接受属性名称作为字符串的方法按名称访问属性。一个通用的属性获取器(返回属性值)是一个名为 property 的方法。它的设置器对应物(接受值并返回 void)是 setProperty。由于我们可以有不同数据类型的属性,那么这两个方法所使用的数据结构是什么,它们用于存储不同类型属性的值?Qt 有一个专门用于此的类,称为 QVariant,它在行为上与 C 联合体非常相似,因为它可以存储不同类型的值。尽管如此,使用联合体有几个优点——其中三个最重要的优点是你可以询问对象它当前持有哪种类型的数据,你可以将一些类型转换为其他类型(例如,将字符串转换为整数),并且你可以教会它操作你自己的自定义类型。
行动时间 – 向棋盘类添加属性
在这个练习中,我们将向棋盘类添加一个有用的属性。该属性将保存关于应该进行下一步棋的玩家的信息。属性的类型将是我们在之前创建的 TicTacToeWidget::Player 枚举。对于获取器和设置器方法,我们将使用我们之前创建的两个函数:currentPlayer() 和 setCurrentPlayer()。
打开我们类的头文件,并按照以下代码修改类定义:
class TicTacToeWidget : public QWidget {
Q_OBJECT
Q_ENUMS(Player)
Q_PROPERTY(Player currentPlayer READ currentPlayer
WRITE setCurrentPlayer
NOTIFY currentPlayerChanged)
public:
enum Player { Invalid, Player1, Player2, Draw };
刚才发生了什么?
由于我们想将枚举用作属性的类型,我们必须通知 Qt 的元对象系统关于枚举的信息。这是通过使用 Q_ENUMS 宏来完成的。然后,我们声明一个名为 currentPlayer 的属性,并将我们现有的两个方法标记为属性的获取器和设置器。我们还使用 NOTIFY 关键字将 currentPlayerChanged 标记为发送通知以告知属性值变化的信号。在我们的小游戏中,我们不会使用这些额外的信息,而且我们根本不需要 currentPlayer 是一个属性,但始终尝试找到好的属性候选者并公开它们是一个好主意,因为总有一天,有人可能会以我们没有预测到的方式使用我们的类,某个特定的属性可能会变得有用。
设计 GUI
到目前为止,我们都是通过手动编写实例化小部件、在布局中排列它们并将信号连接到槽的 C++代码来编码所有用户界面。对于简单的小部件来说,这并不难,但当 UI 变得越来越复杂时,就会变得繁琐且耗时。幸运的是,Qt 提供了工具,可以以更愉快的方式完成所有这些工作。我们不必编写 C++代码,而可以通过在画布上拖放小部件、应用布局以及甚至使用点按技术建立信号-槽连接来创建表单。在编译的后期,这些表单将为我们转换为 C++代码,并准备好应用于小部件。
这个工具被称为 Qt Designer,并且与 Qt Creator 集成。要使用它,从文件菜单中选择新建文件或项目,然后在对话框的文件和类部分选择 Qt,之后选择可用的Qt Designer 表单类模板。你可以选择表单的模板并配置诸如要创建的文件名称等细节。最后,将创建三个文件——其中两个实现从QWidget或其子类派生的 C++类,最后一个包含表单本身的数据。
关闭向导后,我们将进入 Qt Creator 的设计模式,其外观如下所示:

设计模式由四个主要部分组成,如图中用数字标记所示。
标记为 1 的区域是主要的工作表。它包含正在设计的表单的图形表示,你可以移动小部件,将它们组合成布局,并查看它们的反应。它还允许使用我们稍后将要学习的点按方法进一步操作表单。
第二个区域 2 是小部件框。它包含一个可用小部件类型的列表,这些类型被组织成包含具有相关或相似功能的项目的小组。在列表上方,你可以看到一个框,允许你过滤列表中显示的小部件,只显示与输入的表达式匹配的小部件。在列表的起始处,也有一些实际上不是小部件的项目——一个组包含布局,另一个组包含所谓的间隔符,这是一种将其他项目彼此推开的方式。
小部件框的主要目的是在电子表格中向表单添加小部件。你可以通过用鼠标从列表中抓取一个小部件,将其拖动到画布上,然后释放鼠标按钮来实现这一点。小部件将出现在表单中,并且可以使用 Creator 的“设计”模式中的其他工具进一步操作。
我们接下来要讨论的下一个区域 3 位于窗口的右侧,由两部分组成。在图象的顶部,你可以看到对象检查器。它展示了当前编辑表单中所有小部件的父子关系。每一行包含对象的名称以及元对象系统所看到的其类名。如果你点击一个条目,表单中相应的部件就会被选中(反之亦然)。
图象的下半部分显示了属性编辑器。你可以用它来更改每个对象的所有属性值。属性根据它们声明的类分组,从 QObject(实现属性的基类)开始,它只声明了一个但很重要的属性—objectName。在 QObject 之后,是 QWidget 中声明的属性,它是 QObject 的直接后代。它们主要与部件的几何和布局策略相关。在列表的下方,你可以找到来自 QWidget 进一步派生的属性。如果你更喜欢纯字母顺序,其中属性不是按其类分组,你可以通过点击属性列表上方的扳手图标后出现的弹出菜单来切换视图;然而,一旦你熟悉了 Qt 类的层次结构,当按类排序时,导航列表将会容易得多。
仔细观察属性编辑器,你会发现其中一些属性下面有箭头,点击后会展开新的行。这些是复合属性,其完整属性值由多个子属性值确定;例如,如果有一个名为 geometry 的属性定义了一个矩形,它可以展开以显示四个子属性:x、y、width 和 height。还有一点你应该很快就能注意到,一些属性名以粗体显示。这意味着该属性的值已被修改,并且与该属性的默认值不同。这让你可以快速找到你已修改的属性。
我们现在要解释的最后一个功能组 4 位于窗口的下半部分。默认情况下,你会看到两个标签页—动作编辑器和信号/槽编辑器。它们允许我们通过干净的表格界面创建辅助实体,例如菜单和工具栏的动作,或者小部件之间的信号-槽连接。
这里所描述的是基本工具布局。如果你不喜欢它,你可以从主工作表调用上下文菜单,取消选择 锁定 条目,并将所有窗口重新排列到你喜欢的样子,甚至关闭你现在不需要的窗口。
行动时间 – 设计游戏配置对话框
现在,我们将使用 Qt Designer 表单来构建一个简单的游戏配置对话框,这将允许我们为我们的玩家选择名字。
首先,从菜单中调用新的文件对话框,选择创建一个如以下截图所示的Qt Designer 表单类:

在出现的窗口中,选择底部有按钮的对话框:

将类名调整为ConfigurationDialog,将其他设置保留为默认值,并完成向导。
将两个标签和两个行编辑拖放到表单上,将它们大致放置在一个网格中,双击每个标签,调整它们的标题以获得以下类似的结果:

选择要编辑的第一行,查看属性编辑器。找到一个名为objectName的属性,将其更改为player1Name。对另一行也做同样的操作,并将其命名为player2Name。然后,在表单上的某个空白区域单击,在上工具栏中选择在网格中布局选项。你应该看到部件自动对齐——这是因为你刚刚将布局应用到表单上。完成操作后,打开工具菜单,转到表单编辑器子菜单,并选择预览选项。
刚才发生了什么?
你可以看到一个新窗口打开,其外观与我们刚刚设计的表单完全一样。你可以调整窗口大小并与之交互,以监控布局和部件的行为。实际上发生的事情是 Qt Creator 根据我们在设计模式的所有区域提供的描述为我们构建了一个真实的窗口。无需任何编译,在一瞬间我们就得到了一个完全工作的窗口,所有布局都正常工作,所有属性都调整到我们喜欢的样子。这是一个非常重要的工具,所以请确保经常使用它来验证你的布局是否按照你的意图控制所有部件——这比编译和运行整个应用程序以检查部件是否正确拉伸或挤压要快得多。这一切都得益于 Qt 的元对象系统。
是时候行动起来——润色对话框
现在 GUI 本身已经按照我们的预期工作,我们可以专注于给对话框添加更多润色。
加速器和标签伙伴
我们将要做的第一件事是为我们的部件添加加速器。这些是键盘快捷键,当激活时,会导致特定部件获得键盘焦点或执行预定的操作(例如,切换复选框或按下按钮)。加速器通常通过下划线标记,如下面的图所示:

我们将为行编辑设置加速键,以便当用户激活第一个字段的加速键时,它将获得焦点。通过这种方式,我们可以输入第一个玩家的名字,同样,当第二个行编辑的加速键被触发时,我们可以开始输入第二个玩家的名字。
首先,在第一行编辑的左侧选择标签。按F2键或双击标签(或者,在属性编辑器中找到标签的文本属性并激活其值字段)。这样我们就可以更改标签的文本。使用光标键导航,使文本光标位于字符1之前,并输入&字符。这个字符将紧随其后的字符标记为小部件的加速键。对于由文本和实际功能(例如,按钮)组成的控件,这足以使加速键工作。然而,由于QLineEdit没有与之关联的任何文本,我们必须使用单独的控件。这就是为什么我们在标签上设置了加速键。现在,我们需要将标签与行编辑关联起来,以便标签加速器的激活可以将其转发到我们选择的控件。这是通过为标签设置所谓的伙伴来完成的。您可以使用QLabel类的setBuddy方法在代码中这样做,或者使用 Creator 的表单设计器。由于我们已经在设计模式中,我们将使用后一种方法。为此,我们需要在表单设计器中激活一个专用模式。
看看 Creator 窗口的上方;在表单上方,你会找到一个包含几个图标的工具栏。点击标有编辑伙伴的图标或直接在键盘上按F5键。现在,将鼠标光标移到标签上,按下鼠标按钮,并从标签拖动到行编辑。当你将标签拖动到行编辑上时,你会看到一个标签和行编辑之间正在设置连接的图形可视化。如果你现在释放按钮,这个关联将被永久化。你应该注意到,当这种关联被建立时,&字符将从标签中消失,并且它后面的字符会得到一个下划线。对其他标签和相应的行编辑重复此操作。现在,您可以再次预览表单,并检查加速键是否按预期工作。
标签顺序
当你在预览表单时,你可以检查 UI 设计的另一个方面。首先,按Tab键,看看焦点是如何从一个控件移动到另一个控件的。有很大可能性,焦点将开始在前一个按钮和行编辑之间来回跳跃,而不是从上到下(这是这个特定对话框的直观顺序)。要检查和修改焦点顺序,请离开预览,并切换到标签顺序编辑模式,方法是点击工具栏中称为编辑标签顺序的图标。
此模式将一个框与一个数字关联到每个可聚焦的小部件。通过按照您希望小部件获得焦点的顺序单击矩形,您可以重新排序值,从而重新排序焦点。现在,使其顺序如下所示:

再次进入预览并检查焦点是否根据您设置的进行改变。
小贴士
在决定标签顺序时,考虑对话框中哪些字段是必需的,哪些是可选的,是很好的。一个好的习惯是首先允许用户遍历所有必需的字段,然后到对话框确认按钮(例如,一个写着确定或接受的按钮),然后遍历所有可选字段。这样,用户将能够快速填写所有必需的字段并接受对话框,而无需遍历所有用户希望保留为默认值的可选字段。
信号和槽
我们现在要做的最后一件事是确保信号-槽连接设置正确。为此,通过按F4或从工具栏中选择编辑信号/槽来切换到信号-槽编辑模式。底部按钮对话框小部件模板为我们预定义了两个连接,现在应该可以在主画布区域中看到:

实现 Qt 中对话框的QDialog类有两个有用的槽——accept()和reject()——它们通知调用者对话框所表示的操作是否被接受。为了方便起见,这些槽应该已经连接到相应的accepted()和rejected()信号,这些信号来自默认包含确定和取消按钮的按钮组(这是一个QDialogButtonBox类的实例)。如果您单击其中的任何一个,将分别发出信号accepted()或rejected()。
在这个阶段,我们可以添加一些更多连接,使我们的对话框更加实用。让我们设置成只有当两个行编辑中的任何一个都不为空时(即,当两个字段都包含玩家名称时),接受对话框的按钮才可用。虽然我们将在稍后实现逻辑本身,但现在我们可以将连接到一个将执行此任务的槽。
由于默认情况下没有这样的槽,我们需要通知表单编辑器,在应用程序编译时将存在这样的槽。为此,我们需要通过按F3或从工具栏中选择编辑小部件来切换回表单编辑器的默认模式。然后,您可以调用表单的上下文菜单并选择更改信号/槽。将弹出一个窗口,如下所示,列出了可用的信号和槽:

在槽组中单击+按钮,创建一个名为updateOKButtonState()的槽:

然后,接受对话框并返回到信号/槽模式。通过用鼠标拖拽一个行编辑创建一个新的连接。当您将光标移出小部件时,您会注意到一个红色线条跟随您的指针。如果线条遇到一个有效目标,线条将变成箭头,并且目标对象将被突出显示。表单本身也可以是一个目标(或一个源);在这种情况下,线条将以一个接地标记结束(两条短的水*线)。
当您释放鼠标按钮时,将弹出一个窗口,列出源对象的所有信号和目标对象的所有槽。选择textChanged(QString)信号。请注意,当您这样做时,一些可用的槽将消失。这是因为工具只允许我们从与突出显示的信号兼容的槽中进行选择。选择我们新创建的槽并接受对话框。对其他行编辑重复相同的操作。
我们在这里所做的是创建了两个连接,当两个行编辑中的任何一个文本发生变化时,它们将会触发。它们将执行一个尚不存在的槽——通过“创建”槽,我们只声明了在我们的QDialog子类中实现它的意图,该子类也是为我们创建的。现在您可以继续保存表单了。
发生了什么?
我们执行了多项任务,使我们的表单遵循来自许多应用程序的标准行为——这使得表单导航变得简单,并显示了用户可以执行哪些操作以及哪些操作目前不可用。
使用设计器表单
如果您在文本编辑器中打开表单(例如,通过切换到创建者的编辑面板),您会注意到它实际上是一个 XML 文件。那么我们如何使用这个文件呢?
作为构建过程的一部分,Qt 调用一个名为用户界面编译器(uic)的特殊工具,该工具读取文件并生成一个包含setupUi()方法的 C++类。此方法接受一个指向小部件的指针,并包含代码,该代码实例化所有小部件,设置它们的属性,并建立信号-槽连接,而我们负责调用它来准备 GUI。该类本身(以您的表单命名,即表单对象的objectName属性的值)前面加上一个Ui命名空间(例如,Ui::MyForm),并不是从小部件类派生的,而是旨在与一个小部件一起使用。基本上有三种方法可以这样做。
直接方法
使用 Qt Designer 表单的最基本方法是实例化一个小部件和一个表单对象,并在小部件上调用setupUi,如下所示:
QWidget *widget = new QWidget
Ui_form ui * = new Ui_form;
ui->setupUi(widget);
这种方法存在一些缺陷。首先,它可能导致 ui 对象的潜在内存泄漏(记住,它不是 QObject,因此你不能设置其父对象,以便在父对象被删除时删除它)。其次,由于表单中的所有小部件都是未与小部件对象绑定的 ui 对象的变量,它破坏了封装性,这是面向对象编程最重要的范式之一。然而,有一种情况下这种结构是可以接受的。那就是当你创建一个简单的短期模态对话框时。你肯定需要记住,为了显示常规小部件,我们一直在使用 show() 方法。这对于非模态小部件来说是好的,但对于模态对话框,你应该调用 QDialog 类中定义的 exec() 方法。这是一个阻塞方法,它不会返回,直到对话框关闭。这允许我们修改代码,使其变为:
QDialog dialog;
Ui_form ui;
ui.setupUi(&dialog);
dialog.exec();
由于我们是在栈上创建对象,编译器将负责在局部作用域结束时删除它们。
多重继承方法
使用 Designer 表单的第二种方式是创建一个从 QWidget(或其子类之一)和表单类本身派生的类。然后我们可以从构造函数中调用 setupUi:
class Widget : public QWidget, private Ui::MyForm {
public:
Widget(QWidget *parent = 0) : QWidget(parent) {
setupUi(this);
}
};
这样,我们保持了封装性,因为我们的类从 Ui 类继承字段和方法,并且我们可以在类代码内部直接调用它们,同时通过使用私有继承来限制外部世界的访问。这种方法的缺点是它会污染类命名空间,例如,如果我们有 Ui::MyForm 中的 name 对象,我们就无法在 Widget 中创建一个 name 方法。
单重继承方法
幸运的是,我们可以通过组合而非继承来解决这个问题。我们只从 QWidget 派生我们的小部件类,而不是也继承自 Ui::MyForm,我们可以将其实例作为新类的一个私有成员:
class Widget : public QWidget {
public:
Widget(QWidget *parent = 0) : QWidget(parent) {
ui = new Ui::MyForm;
ui->setupUi(this);
}
~Widget() { delete ui; }
private:
Ui::MyForm *ui;
};
以必须手动创建和销毁 Ui::MyForm 实例为代价,我们可以获得额外的好处,即在一个专用对象中包含表单的所有变量和代码,这防止了上述命名空间污染。
这是使用 Designer 表单的推荐方式,也是当你告诉 Qt Creator 为你生成 Designer 表单类时的默认操作模式。
行动时间 – 对话框的逻辑
现在,是我们让游戏设置对话框工作的时候了。之前,我们声明了一个信号-槽连接,但现在槽本身需要实现。
打开由 Creator 生成的表单类。如果你仍然处于设计模式,你可以使用 Shift + F4 键盘快捷键快速跳转到相应的表单类文件。为该类创建一个公共槽段,并声明一个 void updateOKButtonState() 槽。打开重构菜单 (Alt + Enter),并让 Creator 为你创建该槽的骨架实现。在函数体中填充以下代码:
void ConfigurationDialog::updateOKButtonState() {
bool pl1NameEmpty = ui->player1Name->text().isEmpty();
bool pl2NameEmpty = ui->player2Name->text().isEmpty();
QPushButton *okButton = ui->buttonBox->button(QDialogButtonBox::Ok);
okButton->setDisabled(pl1NameEmpty || pl2NameEmpty);
}
此代码检索玩家名称并检查其中任何一个是否为空。然后,它要求包含当前 OK 和 Cancel 按钮的按钮框提供一个指向接受对话框的按钮的指针。然后,我们根据两个玩家名称是否都包含有效值来设置按钮的禁用状态。当第一次创建对话框时,也需要更新按钮状态,因此将 updateOKButtonState() 的调用添加到对话框的构造函数中:
ConfigurationDialog::ConfigurationDialog(QWidget *parent) :
QDialog(parent), ui(new Ui::ConfigurationDialog)
{
ui->setupUi(this);
updateOKButtonState();
}
下一步是允许从对话框外部存储和读取玩家名称——由于 ui 组件是私有的,因此无法从类代码外部访问它。这是一个常见的情况,Qt 也遵循这一原则。几乎每个 Qt 类中的每个数据字段都是私有的,可能包含访问器(一个获取器和可选的设置器),这些是允许读取和存储数据字段值的公共方法。我们的对话框有两个这样的字段——两个玩家的名称。在此阶段,我们应该注意它们是属性的良好候选者,因此最终我们将它们声明为属性。但首先,让我们从实现访问器开始。
在 Qt 中,设置器方法通常使用小写模式命名,例如,set 后跟属性名称,首字母转换为大写。在我们的情况下,两个设置器将分别称为 setPlayer1Name 和 setPlayer2Name,它们都将接受 QString 并返回 void。在类头文件中声明它们,如下面的代码片段所示:
void setPlayer1Name(const QString &p1name);
void setPlayer2Name(const QString &p2name);
在 .cpp 文件中实现它们的主体:
void ConfiguratiosDialog::setPlayer1Name(const QString &p1name) {
ui->player1Name->setText(p1name);
}
void ConfigurationDialog::setPlayer2Name(const QString &p2name) {
ui->player2Name->setText(p2name);
}
在 Qt 中,获取器方法通常与它们相关的属性同名——player1Name 和 player2Name。将以下代码放入头文件中:
QString player1Name() const;
QString player2Name() const;
将以下代码放入实现文件中:
QString ConfigurationDialog::player1Name() const { return ui->player1Name->text(); }
QString ConfigurationDialog::player2Name() const { return ui->player2Name->text(); }
现在唯一剩下的事情就是声明属性。将以下高亮行添加到类声明中:
class ConfigurationDialog : public QDialog {
Q_OBJECT
Q_PROPERTY(QString player1Name READ player1Name WRITE setPlayer1Name)
Q_PROPERTY(QString player2Name READ player2Name WRITE setPlayer2Name)
public:
ConfigurationDialog(QWidget *parent = 0);
我们的对话框现在已准备就绪。您可以通过在 main() 中创建其实例并调用 show() 或 exec() 来测试它。
应用程序的主窗口
我们的游戏中已经有了两个主要组件——游戏板和配置对话框。现在,我们需要将它们绑定在一起。为此,我们将使用另一个重要组件——QMainWindow 类。一个“主窗口”代表应用程序的控制中心。它可以包含菜单、工具栏、停靠小部件、状态栏以及称为“中央小部件”的实际小部件内容,如下面的图所示:

中心小部件部分不需要任何额外的解释——它是一个像任何其他小部件一样的常规小部件。我们也不会在这里关注停靠小部件或状态栏。它们是有用的组件,但它们如此容易掌握,以至于你可以自己学习它们。相反,我们将花一些时间掌握菜单和工具栏。你肯定在许多应用程序中看到并使用过工具栏和菜单,你知道它们对于良好的用户体验是多么重要。
这两个概念共享的主要英雄是一个名为QAction的类,它代表用户可以调用的功能。一个单独的动作可以有多个化身——它可以是一个菜单(QMenu实例)、工具栏(QToolBar)、按钮或键盘快捷键(QShortcut)。操作动作(例如,更改其文本)会导致所有化身更新。例如,如果你在菜单中有一个保存条目(与键盘快捷键绑定),工具栏中的保存图标,以及可能在你的用户界面中的其他地方的保存按钮,并且你想要禁止保存文档(例如,你的地下城与龙游戏关卡编辑器中的地图),因为自上次加载文档以来其内容没有变化。在这种情况下,如果菜单条目、工具栏图标和按钮都链接到同一个QAction实例,那么一旦你将动作的enabled属性设置为false,这三个实体也将被禁用。这是一个保持应用程序不同部分同步的简单方法——如果你禁用动作对象,你可以确信触发动作所代表功能的所有条目也被禁用。动作可以在代码中实例化,也可以使用 Qt Creator 中的动作编辑器图形化创建。动作可以与不同的数据相关联——文本、工具提示、状态栏提示、图标以及其他较少使用的。所有这些都被你的动作的化身所使用。
Qt 资源系统
当谈到图标时,Qt 有一个重要的特性你应该学习。创建图标的一种自然方式是从文件系统中加载图像。这个问题在于你必须与你的应用程序一起安装一堆文件,并且你需要始终知道它们的位置,以便能够提供路径来访问它们。这是困难的,但幸运的是,Qt 有一个解决方案——它允许你将任意文件(如图标图像)直接嵌入到可执行的应用程序中。这是通过准备随后编译到二进制中的资源文件来完成的。幸运的是,Qt Creator 也提供了一个图形化工具来完成这项工作。
行动时间——应用程序的主窗口
创建一个新的Qt Designer Form Class应用程序。作为一个模板,选择主窗口。接受向导中其余部分的默认值。
使用动作编辑器创建一个动作,并在对话框中输入以下值:

现在,创建另一个动作并填写以下截图中的值:

我们希望我们的游戏看起来很漂亮,所以我们将为动作提供图标,并使用资源系统将图像嵌入到我们的应用程序中。创建一个新文件,将其命名为 Qt Resource File。命名为 resources.qrc。点击 Add 按钮,选择 Add Prefix。将前缀的值更改为 /。然后,再次点击 Add 按钮,选择 Add Files。找到适合你动作的图像并将它们添加到资源文件中。将出现一个对话框询问你是否希望将文件复制到项目目录。通过选择 Copy 来同意。

现在,在动作编辑器中再次编辑动作并为它们选择图标。
发生了什么?
我们向项目中添加了一个资源文件。在该资源文件中,我们为许多图像创建了条目。每个图像都被放置在一个 / 前缀下,这代表我们创建的人工文件系统的根节点。资源文件中的每个条目都可以通过手动编写的代码直接访问,作为一个具有特殊名称的文件。这个名称由三个部分组成。首先是冒号字符(:),它标识资源文件系统。接着是一个前缀(例如,/)和资源条目的完整路径(例如,exit.png)。这使得名为 exit.png 的图像可以通过 :/exit.png 路径访问。当我们构建项目时,该文件将被转换成 C 数据数组代码并与应用程序二进制文件集成。在准备完资源文件后,我们使用了其中嵌入的图像作为我们动作的图标。
下一步是将这些动作添加到菜单和工具栏中。
现在是时候添加下拉菜单了
要为窗口创建一个菜单,双击表单顶部的 Type Here 文本,并将其替换为 &File。然后,将动作编辑器中的 New Game 动作拖动到新创建的菜单上,但不要放下它。菜单现在应该打开了,你可以拖动动作,直到子菜单中出现一个红色条,在你想菜单条目出现的位置——现在你可以释放鼠标按钮来创建条目。之后,再次通过点击 File 来打开菜单,并选择 Add Separator。然后,重复拖放操作为 Quit 动作插入一个菜单条目,位于 File 菜单中的分隔符下方,如图所示:

发生了什么?
使用图形工具,我们为我们的程序创建了一个菜单,并向该菜单添加了多个操作(这些操作被自动转换为菜单项)。每个菜单条目都接收了一些文本和一个由放入菜单中的操作指定的图标。
小贴士
要创建子菜单,首先通过点击在此处输入行创建一个菜单条目,并输入子菜单名称。然后,将一个操作拖动并悬停在这样一个子菜单上。经过一段时间,子菜单将弹出,你将能够将操作放下以在二级菜单中创建一个条目。
行动时间 – 创建工具栏
要创建工具栏,请在表单上调用上下文菜单并选择添加工具栏。然后,将新游戏操作拖动到工具栏上并放下。为工具栏打开上下文菜单并选择追加分隔符。然后,从操作编辑器中将退出操作拖动到分隔符后面的工具栏中。以下图展示了你现在应该拥有的最终布局:

发生了什么?
创建工具栏与创建菜单非常相似。首先创建容器(工具栏),然后从操作编辑器中拖放操作。你甚至可以从菜单栏拖动一个操作并将其放到工具栏上,反之亦然!
行动时间 – 填充中心部件
在主窗口区域添加两个标签 – 一个在顶部用于第一个玩家名称,一个在表单底部用于第二个玩家名称 – 然后将它们的objectName属性分别更改为player1和player2。清除它们的文本属性,以便它们不显示任何内容。然后,从部件框中拖动Widget,将其放在两个标签之间,并将它的对象名称设置为gameBoard。在刚刚放置的部件上调用上下文菜单并选择提升到。这允许我们用另一个类替换表单中的部件;在我们的情况下,我们希望用我们的游戏板替换空部件。将刚刚出现的对话框填写如下图的值:

然后,点击标有添加的按钮,然后点击提升以关闭对话框并确认提升。你不会在表单上注意到任何变化,因为替换仅在编译期间发生。现在,在表单上应用垂直布局,以便标签和空部件能够正确对齐。
发生了什么?
并非所有部件类型都在表单设计器中直接可用。有时,我们需要使用仅在构建的项目中创建的部件类。将自定义部件放在表单上的最简单方法是在生成表单的 C++代码时,要求设计者将类名替换为一些对象。通过将对象提升到不同的类,我们节省了大量将游戏板适应用户界面的工作。
行动时间 - 整合所有内容
游戏的视觉部分已经准备好,接下来需要完成主窗口逻辑的编写并将所有组件整合在一起。给类添加一个公共槽位,命名为 startNewGame。在类构造函数中,将 New Game 动作的触发信号连接到这个槽位,并将应用程序的退出槽位连接到另一个动作:
connect(ui->actionNewGame, SIGNAL(triggered()), this, SLOT(startNewGame()));
connect(ui->actionQuit, SIGNAL(triggered()), qApp, SLOT(quit()));
qApp 特殊宏代表指向应用程序对象实例的指针,因此前面的代码将调用在 main() 中创建的 QApplication 对象的 quit() 槽位,这最终将导致应用程序结束。
让我们按照以下方式实现 startNewGame 槽位:
void MainWindow::startNewGame() {
ConfigurationDialog dlg(this);
if(dlg.exec() == QDialog::Rejected) {
return; // do nothing if dialog rejected
}
ui->player1->setText(dlg.player1Name());
ui->player2->setText(dlg.player2Name());
ui->gameBoard->initNewGame();
ui->gameBoard->setEnabled(true);
}
在这个槽位中,我们创建设置对话框并展示给用户,强制用户输入玩家名称。如果对话框被取消,我们放弃创建新游戏。否则,我们从对话框获取玩家名称并将它们设置在适当的标签上。最后,我们初始化棋盘并启用它,以便用户可以与之交互。
在编写回合制棋盘游戏时,始终清楚地标记现在是哪个玩家的回合进行移动是个好主意。我们将通过在粗体中标记移动玩家的名称来实现这一点。棋盘类中已经有一个信号告诉我们已经完成了一次有效移动,我们可以通过它来更新标签。让我们在主窗口类的构造函数中添加适当的代码:
connect(ui->gameBoard, SIGNAL(currentPlayerChanged(Player)), this, SLOT(updateNameLabels()));
现在让我们来看槽位本身;让我们在类中添加一个私有槽位部分并声明该槽位:
private slots:
void updateNameLabels();
现在,我们可以实现它:
void MainWindow::updateNameLabels() {
QFont f = ui->player1->font();
f.setBold(ui->gameBoard->currentPlayer() == TicTacToeWidget::Player1);
ui->player1->setFont(f);
f.setBold(ui->gameBoard->currentPlayer() == TicTacToeWidget::Player2);
ui->player2->setFont(f);
}
除了在信号发出后调用槽位之外,我们还可以在游戏开始时使用它来设置标签的初始数据。由于所有槽位也都是常规方法,我们只需从 startNewGame() 中调用 updateNameLabels() 即可——在 startNewGame() 的末尾调用 updateNameLabels()。
最后需要完成的事情是处理游戏结束的情况。将棋盘的 gameOver() 信号连接到主窗口类中的新槽位。如下实现该槽位:
void MainWindow::handleGameOver(TicTacToeWidget::Player winner) {
ui->gameBoard->setEnabled(false);
QString message;
if(winner == TicTacToeWidget::Draw) {
message = "Game ended with a draw.";
} else {
message = QString("%1 wins").arg(winner == TicTacToeWidget::Player1
? ui->player1->text() : ui->player2->text());
}
QMessageBox::information(this, "Info", message);
}
发生了什么?
我们的代码做了两件事。首先,它禁用了棋盘,这样玩家就不能再与之交互了。其次,它检查谁赢得了游戏,组装消息(我们将在下一章中了解更多关于 QString 的内容),并使用静态方法 QMessageBox::information() 显示它,该方法显示一个包含消息和允许我们关闭对话框的按钮的模态对话框。最后一件剩下的事情是更新 main() 函数,以便创建我们的 MainWindow 类的实例:
#include "mainwindow.h"
#include <QApplication>
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
MainWindow w;
w.show();
return a.exec();
}
现在,你可以运行你的第一个 Qt 游戏。
英雄尝试扩展游戏
作为一项附加练习,你可以尝试修改本章中我们编写的代码,以便在大于 3 x 3 的棋盘上玩游戏。让用户决定棋盘的大小(你可以修改游戏选项对话框来实现这一点,并使用 QSlider 和 QSpinBox 允许用户选择棋盘的大小),然后你可以指导 TicTacToeWidget 根据它得到的大小构建棋盘。记住调整游戏胜利逻辑!如果在任何时刻你遇到了死胡同,不知道该使用哪些类和函数,请查阅参考手册。
小贴士
要快速查找类的文档(或文档中的任何其他页面),切换到 帮助 面板,从侧边栏顶部的下拉列表中选择 索引,并输入搜索词,例如 QAction。此外,F1 键在浏览手册时非常有用。将鼠标指针或文本光标放在代码编辑器中类的名称、函数或对象上,然后按键盘上的 F1。通过这样做,Qt Creator 将乐意向你展示所选主题的可用帮助信息。
快速问答——使用小部件
Q1. 返回小部件首选大小的方法被称为:
-
preferredSize -
sizeHint -
defaultSize
Q2. 哪个 Qt 类可以携带任何属性的值?
-
QVariant -
QUnion -
QPropertyValue
Q3. QAction 对象的目的是什么?
-
它代表用户可以在程序中调用的功能。
-
它包含一个用于将焦点移动到小部件上的快捷键序列。
-
它是使用 Qt Designer 生成的所有表单的基类。
摘要
在本章中,你学习了如何使用 Qt 创建简单的图形用户界面。我们探讨了两种方法——通过直接编写所有代码来创建用户界面类,以及使用生成大部分代码的图形工具来设计用户界面。无法确定两种方法中哪一种更好;它们各自在某些方面更好,在其他方面则较差。一般来说,你应该优先使用 Qt Designer 表单来直接编写代码,因为它更快,更不容易出错,因为大部分代码都是自动生成的。然而,如果你想要对代码有更多的控制,或者你的 GUI 非常动态,自己编写所有代码会更容易,尤其是在你积累了足够的 Qt 经验,可以避免常见陷阱并学会使用高级编程结构之后。
我们还学习了 Qt 的核心——元对象系统是如何工作的。你现在应该能够通过连接信号到槽(预定义的以及你现在已经知道如何定义并填充代码的自定义槽)来创建简单的用户界面并填充逻辑。
Qt 包含许多小部件类型,但我没有逐一向你介绍它们。Qt 手册中有一个非常好的关于许多小部件类型的解释,称为 Qt 小部件画廊,它展示了其中大部分的实际应用。
如果你对这些小部件的任何使用有疑问,你可以查看示例代码,并在 Qt 参考手册中查找相应的类,以了解更多关于它们的信息。
使用 Qt 远不止在表单上拖放小部件并提供一些代码来将这些部件粘合在一起。在下一章中,你将了解 Qt 提供的一些最实用的功能;它们与在屏幕上显示图形无关,而是让你能够操作各种类型的数据。这对于比简单的井字棋更复杂的任何游戏来说都是必不可少的。
第四章。Qt 核心基础
本章将帮助你掌握 Qt 的基本数据处理和存储方式。首先,你将学习如何处理文本数据以及如何将文本与正则表达式匹配。然后,你将了解如何从文件中存储和检索数据,以及如何使用不同的存储格式来存储文本和二进制数据。到本章结束时,你将能够高效地在你的游戏中实现非*凡逻辑和数据处理。你还将了解如何在游戏中加载外部数据,以及如何将你的数据保存到永久存储中以便将来使用。
文本处理
带有图形用户界面(游戏当然属于这一类)的应用程序能够通过显示文本并期望用户输入文本与用户交互。我们已经在上一章通过使用QString类来触及了这个话题的表面。现在,我们将深入探讨。
字符串操作
Qt 内部使用 Unicode 对文本进行编码,这允许表示世界上几乎所有的语言字符,并且是大多数现代操作系统中文本本地编码的事实标准。然而,你必须意识到,与QString类不同,C++语言默认不使用 Unicode。因此,你输入代码中的每个字符串字面量(即你用引号包裹的每个裸文本)在可以存储在 Qt 的任何字符串处理类之前,都需要先转换为 Unicode。默认情况下,这会隐式地假设字符串字面量是 UTF-8 编码的,但QString提供了一系列静态方法来从其他编码(如QString::fromLatin1()或QString::fromUtf16())转换。这种转换是在运行时完成的,这会增加程序执行时间,特别是如果你在程序中倾向于进行大量的此类转换。幸运的是,有一个解决方案:
QString str = QStringLiteral("I'm writing my games using Qt");
你可以将你的字符串字面量包裹在QStringLiteral的调用中,就像前面代码所示,如果你的编译器支持,它将在编译时执行转换。将所有字符串字面量包裹成QStringLiteral是一个好习惯,但这不是必需的,所以如果你忘记这样做,请不要担心。
在描述QString类时,我们不会深入细节,因为它在许多方面与 C++标准库中的std::string相似。相反,我们将关注这两个类之间的差异。
文本编码和解码
第一个差异已经提到——QString将数据编码为 Unicode。这有一个优点,即能够用几乎任何语言表达文本,但代价是需要从其他编码转换。大多数流行的编码——UTF-8、UTF-16 和 Latin1——在QString中都有方便的方法来转换到和从内部表示。但是,Qt 也知道如何处理许多其他编码。这是通过使用QTextCodec类来完成的。
小贴士
你可以使用 QTextCodec::availableCodecs() 静态方法列出你的安装上支持的 codec。在大多数安装中,Qt 可以处理* 1,000 种不同的文本 codec。
大多数处理文本的 Qt 实体都可以访问此类实例以透明地执行转换。如果你想手动执行此类转换,你可以通过名称请求 Qt 的 codec 实例并使用 fromUnicode() 和 toUnicode() 方法:
QByteArray big5Encoded = "你好";
QTextCodec *big5Codec = QTextCodec::codecForName("Big5");
QString text = big5Codec->toUnicode(big5Encoded);
QTextCodec *utf8Codec = QTextCodec::codecForMib(106); // UTF-8
QByteArray utf8Encoded = utf8Codec->fromUnicode(text);
基本字符串操作
涉及文本字符串的最基本任务包括添加或删除字符串中的字符、连接字符串以及访问字符串内容。在这方面,QString 提供了一个与 std::string 兼容的接口,但它还超越了这一点,暴露了许多更多有用的方法。
使用 prepend() 和 append() 方法可以在字符串的开始或末尾添加数据,这些方法有几个重载,可以接受不同可以包含文本数据的对象,包括经典的 const char* 数组。使用 insert() 方法可以在字符串的中间插入数据,该方法将需要开始插入的字符位置作为其第一个参数,实际文本作为其第二个参数。insert 方法具有与 prepend 和 append 相同的重载,但不包括 const char*。从字符串中删除字符的方式类似。基本方法是使用 remove() 方法,该方法接受需要删除字符的位置和要删除的字符数,如下所示:
QString str = QStringLiteral("abcdefghij");
str.remove(2, 4); // str = "abghij"
还有一个接受另一个字符串的重载。当调用时,它会从原始字符串中删除所有其出现。此重载有一个可选参数,指定比较是否应该以默认的大小写敏感(Qt::CaseSensitive)或大小写不敏感(Qt::CaseInsensitive)的方式进行:
QString str = QStringLiteral("Abracadabra");
str.remove(QStringLiteral("ab"), Qt::CaseInsensitive); // str = "racadra"
要连接字符串,你可以简单地将两个字符串相加,或者将一个字符串追加到另一个字符串上:
QString str1 = QStringLiteral("abc");
QString str2 = QStringLiteral("def");
QString str1_2 = str1+str2;
QString str2_1 = str2;
str2_1.append(str1);
访问字符串可以分为两种用例。第一种是你希望提取字符串的一部分。为此,你可以使用以下三种方法之一:left()、right() 和 mid(),它们从字符串的开始或末尾返回指定数量的字符,或者从字符串中指定位置开始提取指定长度的子字符串:
QString original = QStringLiteral("abcdefghij");
QString l = original.left(3); // "abc"
QString r = original.right(2); // "ij"
QString m = original.mid(2, 5); // "cdefg"
第二种用例是你希望访问字符串的单个字符。索引操作符在 QString 中的使用方式与 std::string 类似,返回一个副本或非 const 引用到由 QChar 类表示的给定字符,如下面的代码所示:
QString str = "foo";
QChar f = str[0]; // const
str[0] = 'g'; // non-const
此外,Qt 还提供了一个专门的方法——at(),它返回字符的副本:
QChar f = str.at(0);
小贴士
你应该优先使用 at() 而不是索引操作符来执行不修改字符的操作,因为这明确设置了操作。
字符串搜索和查找
功能的第二组与字符串搜索相关。你可以使用 startsWith()、endsWith() 和 contains() 等方法在字符串的开始、结束或任意位置搜索子字符串。可以通过使用 count() 方法检索字符串中子字符串的出现次数。
小贴士
注意,还有一个不带参数的 count() 方法,它返回字符串中的字符数。
如果你需要知道匹配的确切位置,可以使用 indexOf() 或 lastIndexOf() 来接收字符串中匹配发生的位置。第一个调用通过向前搜索工作,而另一个调用通过向后搜索。这些调用都接受两个可选参数——第二个参数确定搜索是否区分大小写(类似于 remove 的工作方式)。第一个参数是字符串中搜索开始的位臵。它让你能够找到给定子字符串的所有出现:
#include <QtDebug>
// ...
int pos = -1;
QString str = QStringLiteral("Orangutans like bananas.");
do {
pos = str.indexOf("an", pos+1);
qDebug() << "'an' found starts at position" << pos;
} while(pos!=-1);
字符串分解
还有另一组有用的字符串功能,这使得 QString 与 std::string 不同。那就是,将字符串切割成更小的部分,并从更小的片段构建更大的字符串。
很常见,一个字符串包含通过重复分隔符粘合在一起的子字符串。一个常见的情况是 逗号分隔值(CSV)格式,其中数据记录被编码在一个单独的字符串中,记录中的字段通过逗号分隔。虽然你可以使用你已知的函数(例如,indexOf)从记录中提取每个字段,但存在一种更简单的方法。QString 包含一个 split() 方法,它接受分隔符字符串作为参数,并返回一个由 Qt 中的 QStringList 类表示的字符串列表。然后,将记录分解成单独的字段就像调用以下代码一样简单:
QString record = "1,4,8,15,16,24,42";
QStringList fields = record.split(",");
for(int i=0; i< fields.count(); ++i){
qDebug() << fields.at(i);
}
这种方法的逆操作是 QStringList 类中存在的 join() 方法,它将列表中的所有项合并成一个字符串,并用给定的分隔符连接起来:
QStringList fields = { "1", "4", "8", "15", "16", "24", "42" }; // C++11 syntax!
QString record = fields.join(",");
数字与字符串之间的转换
QString 还提供了一些方便在文本和数值之间进行转换的方法。例如 toInt()、toDouble() 或 toLongLong() 可以轻松地从字符串中提取数值。除了 toDouble() 之外,它们都接受两个可选参数——第一个是一个指向 bool 变量的指针,根据转换是否成功将其设置为 true 或 false。第二个参数指定值的数值基数(例如,二进制、八进制、十进制或十六进制)。toDouble() 方法只接受一个 bool 指针来标记成功或失败,如下面的代码所示:
bool ok;
int v1 = QString("42").toInt(&ok, 10); // v1 = 42, ok = true
long long v2 = QString("0xFFFFFF").toInt(&ok, 16); // v2 = 16777215, ok = true
double v3 = QString("not really a number").toDouble(&ok); //v3 = 0.0, ok = false
一个名为 number() 的静态方法执行相反方向的转换——它接受一个数值和数值基数,并返回值的文本表示:
QString txt = QString::number(255, 16); // txt = "0xFF"
如果你必须在一个程序中同时使用 QString 和 std::string,QString 提供了 toStdString() 和 fromStdString() 方法来执行适当的转换。
小贴士
一些表示值的其他类也提供了到和从 QString 的转换。这样的一个类是 QDate,它表示一个日期并提供 fromString() 和 toString() 方法。
在字符串中使用参数
一个常见的任务是需要一个字符串,其内容需要是动态的,这样它的内容就依赖于某些外部变量的值——例如,你可能想通知用户正在复制的文件数量,显示“正在复制文件 1/2”或“正在复制文件 2/5”,这取决于表示当前文件和文件总数的计数器的值。可能会诱使你通过使用可用的方法之一将所有片段组装在一起来完成这项任务:
QString str = "Copying file " + QString::number(current) + " of "+QString::number(total);
这种方法有几个缺点;其中最大的问题是将字符串翻译成其他语言的问题(这个问题将在本章后面讨论),在这些语言中,它们的语法可能要求这两个参数的位置与英语不同。
相反,Qt 允许我们在字符串中指定位置参数,然后使用实值替换它们。字符串中的位置用 % 符号标记(例如,%1、%2 等),并通过调用 arg() 并传递用于替换字符串中下一个最低标记的值来替换它们。然后我们的文件复制消息构建代码变为:
QString str = QStringLiteral("Copying file %1 of %2")
.arg(current).arg(total);
arg 方法可以接受单个字符、字符串、整数和实数,其语法与 QString::number() 类似。
正则表达式
让我们简要地谈谈正则表达式——通常简称为regex或regexp。当你需要检查一个字符串或其部分是否与给定的模式匹配,或者当你想要在文本中找到特定的部分并可能提取它们时,你需要这些正则表达式。验证和查找/提取都是基于所谓的正则表达式模式,它描述了字符串必须具有的格式才能有效、可找到或可提取。由于这本书专注于 Qt,很遗憾没有时间深入探讨正则表达式。然而,这不是一个大问题,因为你可以在网上找到许多提供正则表达式介绍的优质网站。Qt 的 QRegExp 文档中也可以找到简短的介绍。
尽管正则表达式的语法有很多种,但 Perl 使用的语法已经成为事实上的标准。根据 QRegularExpression,Qt 提供了与 Perl 兼容的正则表达式。
注意
QRegularExpression 首次在 Qt 5 中引入。在之前的版本中,你会找到较老的 QRegExp 类。由于 QRegularExpression 更接* Perl 标准,并且其执行速度比 QRegExp 快得多,我们建议尽可能使用 QRegularExpression。尽管如此,你仍然可以阅读有关正则表达式一般介绍的 QRegExp 文档。
是时候进行一个简单的问答游戏了
为了让你了解 QRegularExpression 的主要用法,让我们想象这个游戏:展示一个物体的照片给多个玩家看,每个玩家都必须估计物体的重量。估计值最接*实际重量的玩家获胜。估计将通过 QLineEdit 提交。由于你可以在行编辑中写任何东西,我们必须确保内容是有效的。
那么“有效”是什么意思呢?在这个例子中,我们定义一个介于 1 克和 999 公斤之间的值是有效的。了解这个规范后,我们可以构建一个正则表达式来验证格式。文本的第一部分是一个数字,可以是 1 到 999 之间的任何数字。因此,相应的模式看起来像 [1-9][0-9]{0,2},其中 [1-9] 允许并且要求恰好一个数字,除了零,零可以可选地后面跟最多两个数字,包括零。这通过 [0-9]{0,2} 来表达。输入的最后部分是重量的单位。使用如 (mg|g|kg) 这样的模式,我们允许重量以 毫克(mg)、克(g)或 公斤(kg)输入。通过 [ ]?,我们最终允许数字和单位之间有一个可选的空格。结合模式和相关 QRegularExpression 对象的构建,看起来是这样的:
QRegularExpression regex("[1-9][0-9]{0,2}[ ]? (mg|g|kg)");
regex.setPatternOptions(QRegularExpression:: CaseInsensitiveOption);
刚才发生了什么?
在第一行,我们构建了上述 QRegularExpression 对象,同时将正则表达式的模式作为参数传递给构造函数。我们也可以调用 setPattern() 来设置模式:
QRegularExpression regex;
regex.setPattern("[1-9][0-9]{0,2}[ ]?(mg|g|kg)");
这两种方法都是等效的。如果你仔细看看单位,你会看到现在单位只能以小写形式输入。然而,我们希望它也可以是大写或混合大小写。为了实现这一点,我们当然可以写 (mg|mG|Mg|MG|g|G|kg|kG|Kg|KG)。当你有更多单位时,这确实是一项艰巨的工作,而且很容易出错,所以我们选择了更干净、更易读的解决方案。在初始代码示例的第二行,你看到了答案:一个模式选项。我们使用了 setPatternOptions() 来设置 QRegularExpression::CaseInsensitiveOption 选项,该选项不尊重字符的大小写。当然,你还可以在 Qt 的 QRegularExpression::PatternOption 文档中了解一些更多选项。我们也可以将选项作为 QRegularExpression 构造函数的第二个参数传递,而不是调用 setPatternOptions():
QRegularExpression regex("[1-9][0-9]{0,2}[ ]?(mg|g|kg)",
QRegularExpression::CaseInsensitiveOption);
现在,让我们看看如何使用这个表达式来验证字符串的有效性。为了简单起见和更好的说明,我们简单地声明了一个名为 input 的字符串:
QString input = "23kg";
QRegularExpressionMatch match = regex.match(input);
bool isValid = match.hasMatch();
我们所需要做的就是调用 match(),传递我们想要检查的字符串。作为回报,我们得到一个 QRegularExpressionMatch 类型的对象,它包含所有进一步需要的信息——而不仅仅是检查有效性。然后,我们可以通过 QRegularExpressionMatch::hasMatch() 确定输入是否匹配我们的标准,因为它在找到模式时返回 true。当然,如果没有找到模式,则返回 false。
仔细的读者肯定已经注意到我们的模式还没有完全完成。hasMatch() 方法也会在将模式与 "foo 142g bar" 进行匹配时返回 true。因此,我们必须定义模式是从匹配字符串的开始到结束进行检查的。这是通过 \A 和 \z 锚点来完成的。前者标记字符串的开始,后者标记字符串的结束。在使用这样的锚点时,不要忘记转义斜杠。正确的模式如下所示:
QRegularExpression regex("\\A[1-9][0-9]{0,2}[ ]?(mg|g|kg)\\z",
QRegularExpression::CaseInsensitiveOption);
从字符串中提取信息
在我们检查发送的猜测是否良好形成之后,我们必须从字符串中提取实际的重量。为了能够轻松比较不同的猜测,我们还需要将所有值转换为共同的参考单位。在这种情况下,应该是毫克,这是最低的单位。那么,让我们看看 QRegularExpressionMatch 可以为我们提供什么来完成任务。
使用 capturedTexts(),我们得到一个包含模式捕获组的字符串列表。在我们的例子中,这个列表将包含 "23kg" 和 "kg"。列表的第一个元素总是被模式完全匹配的字符串,然后是所有由使用的括号捕获的子字符串。由于我们缺少实际的数字,我们必须将模式的开始更改为 ([1-9][0-9]{0,2})。现在,列表的第二个元素是数字,第三个元素是单位。因此,我们可以写出以下内容:
int getWeight(const QString &input) {
QRegularExpression regex("\\A([1-9][0-9]{0,2}) [ ]?(mg|g|kg)\\z");
regex.setPatternOptions(QRegularExpression:: CaseInsensitiveOption);
QRegularExpressionMatch match = regex.match(input);
if(match.hasMatch()) {
const QString number = match.captured(1);
int weight = number.toInt();
const QString unit = match.captured(2).toLower();
if (unit == "g") {
weight *= 1000;
} else if (unit == "kg") {
weight *= 1000000 ;
}
return weight;
} else {
return -1;
}
}
在函数的前两行中,我们设置了模式和它的选项。然后,我们将它与传递的参数进行匹配。如果 QRegularExpressionMatch::hasMatch() 返回 true,则输入有效,我们提取数字和单位。我们不是通过调用 capturedTexts() 获取捕获文本的整个列表,而是通过调用 QRegularExpressionMatch::captured() 直接查询特定元素。传递的整数参数表示列表中元素的位位置。因此,调用 captured(1) 返回匹配的数字作为一个 QString。
小贴士
QRegularExpressionMatch::captured() 也接受 QString 作为参数类型。如果你在模式中使用了命名组,这会很有用,例如,如果你写的是 (?<number>[1-9][0-9]{0,2}),那么你可以通过调用 match.captured("number") 来获取数字。如果模式很长或者未来有很高的概率会添加更多的括号,命名组会很有用。请注意,稍后添加一个组将会将所有后续组的索引移动 1 位,你将不得不调整你的代码!
为了能够使用提取出的数字进行计算,我们需要将 QString 转换为整数。这是通过调用 QString::toInt() 来完成的。转换的结果随后存储在 weight 变量中。接下来,我们获取单位并将其转换为小写字母。这样,例如,我们可以轻松地确定用户的猜测是否以克为单位,只需将单位与小写 "g" 进行比较。我们不需要关心大写 "G" 或 "KG"、"Kg" 和不寻常的 "kG"(千克)。
为了得到标准化的重量(毫克),我们需要将 weight 乘以 1,000 或 1,000,000,具体取决于这是否以 g 或 kg 表示。最后,我们返回这个标准化的重量。如果字符串格式不正确,我们返回 -1 来指示给定的猜测无效。然后调用者负责确定哪个玩家的猜测是最好的。
注意
注意你选择的整数类型是否可以处理重量的值。在我们的例子中,对于 32 位系统,1,000,000,000 是可以由有符号整数持有的最大可能值。如果你不确定你的代码是否会在 32 位系统上编译,使用 qint32,它在 Qt 支持的每个系统上都是保证为 32 位整数的,允许十进制表示法。
作为练习,尝试扩展示例,允许小数数字,例如 23.5g 是一个有效的猜测。为了实现这一点,你必须修改模式以输入小数数字,并且你还必须处理 double 而不是 int 作为标准化的重量。
查找所有模式出现
最后,让我们看看如何找到字符串中的所有数字,即使是那些以零开头的数字:
QString input = "123 foo 09 1a 3";
QRegularExpression regex("\\b[0-9]+\\b");
QRegularExpressionMatchIterator i = regex.globalMatch(input);
while (i.hasNext()) {
QRegularExpressionMatch match = i.next();
qWarning() << match.capturedTexts();
}
input QString 实例包含一个示例文本,我们希望在其中找到所有数字。由于“foo”以及“1a”不是有效的数字,因此不应通过该模式找到这些变量。因此,我们设置了定义我们至少需要一个数字 [0-9]+,并且这个数字——或者这些数字——应该被单词边界 \b 包围的模式。请注意,您必须转义斜杠。使用此模式,我们初始化 QRegularExpression 对象,并在其上调用 globalMatch()。在传递的参数内部,将搜索该模式。这次,我们没有返回 QRegularExpressionMatch,而是返回 QRegularExpressionMatchIterator 类型的迭代器。由于 QRegularExpressionMatchIterator 的行为类似于 Java 迭代器,具有 hasNext() 方法,我们检查是否存在进一步的匹配,如果存在,则通过调用 next() 获取下一个匹配。返回的匹配类型是 QRegularExpressionMatch,这是您已经知道的。
小贴士
如果你需要在 while 循环内部了解下一个匹配项,你可以使用 QRegularExpressionMatchIterator::peekNext() 来接收它。这个函数的优点是它不会移动迭代器。
这样,你可以遍历字符串中的所有模式出现。如果你,例如,想在文本中突出显示搜索字符串,这将很有帮助。
我们的示例将给出输出:("123"), ("09") and ("3")。
考虑到这只是一个关于正则表达式的简要介绍,我们鼓励你阅读文档中关于 QRegularExpression、QRegularExpressionMatch 和 QRegularExpressionMatchIterator 的 详细描述 部分。正则表达式非常强大且有用,因此,在你的日常编程生活中,你可以从正则表达式的深刻知识中受益!
数据存储
在实现游戏时,你通常会需要处理持久数据——你需要存储保存的游戏数据、加载地图等等。为此,你必须了解让你能够使用存储在数字媒体上的数据的机制。
文件和设备
访问数据的最基本和底层机制是从文件中保存和加载它。虽然你可以使用 C 和 C++ 提供的经典的文件访问方法,如 stdio 或 iostream,但 Qt 提供了对文件抽象的自己的包装,它隐藏了*台相关的细节,并提供了一个在所有*台上以统一方式工作的干净 API。
当使用文件时,你将工作的两个基本类是 QDir 和 QFile。前者表示目录的内容,允许你遍历文件系统,创建和删除目录,最后,访问特定目录中的所有文件。
遍历目录
使用 QDir 遍历目录非常简单。首先要做的事情是首先有一个 QDir 实例。最简单的方法是将目录路径传递给 QDir 构造函数。
小贴士
Qt 以*台无关的方式处理文件路径。尽管 Windows 上的常规目录分隔符是反斜杠字符(\),而其他*台上是正斜杠(/),但 Qt 在 Windows *台上也接受正斜杠作为目录分隔符。因此,当将路径传递给 Qt 函数时,你始终可以使用/来分隔目录。
你可以通过调用QDir::separator()静态函数来学习当前*台的本地目录分隔符。你可以使用QDir::toNativeSeparators()和QDir::fromNativeSeparators()函数在本地和非本地分隔符之间进行转换。
Qt 提供了一些静态方法来访问一些特殊目录。以下表格列出了这些特殊目录及其访问函数:
| 访问函数 | 目录 |
|---|---|
QDir::current() |
当前工作目录 |
QDir::home() |
当前用户的家目录 |
QDir::root() |
根目录——通常在 Unix 中为/,在 Windows 中为C:\ |
QDir::temp() |
系统临时目录 |
当你已经有一个有效的QDir对象时,你可以开始在不同目录之间移动。为此,你可以使用cd()和cdUp()方法。前者移动到命名的子目录,而后者移动到父目录。
要列出特定目录中的文件和子目录,你可以使用entryList()方法,该方法返回目录中符合entryList()传入的标准的条目列表。此方法有两个重载版本。基本版本接受一个标志列表,这些标志对应于条目需要具有的不同属性才能包含在结果中,以及一组标志,用于确定条目在集合中包含的顺序。另一个重载版本还接受一个QStringList格式的文件名模式列表作为其第一个参数。最常用的筛选和排序标志如下所示:
| 筛选标志 |
|---|
QDir::Dirs, QDir::Files, QDir::Drives, QDir::AllEntries |
QDir::AllDirs |
QDir::Readable, QDir::Writable, QDir::Executable |
QDir::Hidden, QDir::System |
| 排序标志 |
QDir::Unsorted |
QDir::Name, QDir::Time, QDir::Size, QDir::Type |
QDir::DirsFirst, QDir::DirsLast |
这里是一个示例调用,它返回用户home目录中所有按大小排序的 JPEG 文件:
QDir dir = QDir::home();
QStringList nameFilters;
nameFilters << QStringLiteral("*.jpg") << QStringLiteral("*.jpeg");
QStringList entries = dir.entryList(nameFilters,
QDir::Files|QDir::Readable, QDir::Size);
小贴士
<<运算符是一种简单快捷的方法,可以将条目追加到QStringList。
获取基本文件的访问权限
一旦知道了文件的路径(无论是通过使用 QDir::entryList()、来自外部源,甚至是在代码中硬编码文件路径),就可以将其传递给 QFile 以接收一个作为文件句柄的对象。在可以访问文件内容之前,需要使用 open() 方法打开文件。此方法的基本变体需要一个模式,其中我们需要打开文件。以下表格解释了可用的模式:
| 模式 | 描述 |
|---|---|
ReadOnly |
此文件可读 |
WriteOnly |
此文件可写入 |
ReadWrite |
此文件可读和写 |
Append |
所有数据写入都将写入文件末尾 |
Truncate |
如果文件存在,则在打开之前删除其内容 |
Text |
本地行结束符转换为 \n 并返回 |
Unbuffered |
该标志防止系统对文件进行缓冲 |
open() 方法根据文件是否被打开返回 true 或 false。可以通过在文件对象上调用 isOpen() 来检查文件当前的状态。一旦文件打开,就可以根据打开文件时传递的选项来读取或写入。读取和写入是通过 read() 和 write() 方法完成的。这些方法有很多重载,但我建议您专注于使用那些接受或返回 QByteArray 对象的变体,它本质上是一系列字节——它可以存储文本和非文本数据。如果您正在处理纯文本,那么 write 方法的一个有用的重载是直接接受文本作为输入的变体。只需记住,文本必须是空或终止的。当从文件读取时,Qt 提供了其他一些可能在某些情况下很有用的方法。其中一种方法是 readLine(),它尝试从文件中读取,直到遇到新行字符。如果您与告诉您是否已到达文件末尾的 atEnd() 方法一起使用,您就可以实现逐行读取文本文件:
QStringList lines;
while(!file.atEnd()) {
QByteArray line = file.readLine();
lines.append(QString::fromUtf8(line));
}
另一种有用的方法是 readAll(),它简单地返回从文件指针当前位置开始直到文件末尾的内容。
你必须记住,在使用这些辅助方法时,如果你不知道文件包含多少数据,你应该非常小心。可能会发生这种情况,当你逐行读取或尝试一次性将整个文件读入内存时,你会耗尽你的进程可用的内存量(你可以通过在QFile实例上调用size()来检查文件的大小)。相反,你应该分步骤处理文件数据,一次只读取所需的量。这使得代码更复杂,但使我们能够更好地管理可用资源。如果你需要经常访问文件的一部分,你可以使用map()和unmap()调用,这些调用将文件的一部分添加到或从内存地址映射中移除,然后你可以像使用常规字节数组一样使用它:
QFile f("myfile");
if(!f.open(QFile::ReadWrite)) return;
uchar *addr = f.map(0, f.size());
if(!addr) return;
f.close();
doSomeComplexOperationOn(addr);
f.unmap(addr);
设备
QFile实际上是QIODevice的子类,QIODevice是一个 Qt 接口,用于抽象与读取和写入相关的实体。有两种类型的设备:顺序访问设备和随机访问设备。QFile属于后者——它具有开始、结束、大小和当前位置的概念,用户可以通过seek()方法更改这些概念。顺序设备,如套接字和管道,表示数据流——没有方法可以回滚流或检查其大小;你只能按顺序逐个读取数据——一次读取一部分,你可以检查你目前距离数据末尾有多远。
所有 I/O 设备都可以打开和关闭。它们都实现了open()、read()和write()接口。向设备写入数据会将数据排队等待写入;当数据实际写入时,会发出bytesWritten()信号,该信号携带写入设备的数据量。如果在顺序设备中还有更多数据可用,它会发出readyRead()信号,通知你如果现在调用read,你可以期望从设备接收一些数据。
实施加密数据设备的行动时间
让我们实现一个非常简单的设备,它使用一个非常简单的算法——凯撒密码来加密或解密通过它的数据。它的作用是在加密时,将明文中的每个字符按密钥定义的字符数进行移位,解密时进行相反的操作。因此,如果密钥是2,明文字符是a,密文就变成了c。使用密钥4解密z将得到值v。
我们将首先创建一个新的空项目,并添加一个从QIODevice派生的类。该类的基本接口将接受一个整数密钥并设置一个作为数据源或目的地的底层设备。这些都是你应该已经理解的简单编码,因此不需要任何额外的解释,如下所示:
class CaesarCipherDevice : public QIODevice
{
Q_OBJECT
Q_PROPERTY(int key READ key WRITE setKey)
public:
explicit CaesarCipherDevice(QObject *parent = 0) : QIODevice(parent) {
m_key = 0;
m_device = 0;
}
void setBaseDevice(QIODevice *dev) { m_device = dev; }
QIODevice *baseDevice() const { return m_device; }
void setKey(int k) { m_key = k; }
inline int key() const { return m_key; }
private:
int m_key;
QIODevice *m_device;
};
下一步是确保如果没有设备可供操作(即当 m_device == 0 时),则不能使用该设备。为此,我们必须重新实现 QIODevice::open() 方法,并在我们想要阻止操作我们的设备时返回 false:
bool open(OpenMode mode) {
if(!baseDevice())
return false;
if(baseDevice()->openMode() != mode)
return false;
return QIODevice::open(mode);
}
该方法接受用户想要以何种模式打开设备。我们在调用将设备标记为打开的基类实现之前执行一个额外的检查,以验证基本设备是否以相同的模式打开。
要有一个完全功能的设备,我们仍然需要实现两个受保护的纯虚方法,这些方法执行实际的读取和写入操作。这些方法在需要时由 Qt 从类的其他方法中调用。让我们从 writeData() 开始,它接受一个指向包含数据的缓冲区的指针以及该缓冲区的大小:
qint64 CaesarCipherDevice::writeData(const char *data, qint64 len) {
QByteArray ba(data, len);
for(int i=0;i<len;++i)
ba.data()[i] += m_key;
int written = m_device->write(ba);
emit bytesWritten(written);
return written;
}
首先,我们将数据复制到一个局部字节数组中。然后,我们遍历数组,将密钥的值添加到每个字节(这实际上执行了加密)。最后,我们尝试将字节数组写入底层设备。在通知调用者实际写入的数据量之前,我们发出一个携带相同信息的信号。
我们需要实现的最后一个方法是执行解密操作,通过从基本设备读取并给数据中的每个单元格添加密钥。这是通过实现 readData() 来完成的,它接受一个指向方法需要写入的缓冲区的指针以及缓冲区的大小。代码与 writeData() 非常相似,只是我们是在减去密钥值而不是添加它:
qint64 CaesarCipherDevice::readData(char *data, qint64 maxlen) {
QByteArray baseData = m_device->read(maxlen);
const int s = baseData.size();
for(int i=0;i<s;++i)
data[i] = baseData[i]-m_key;
return s;
}
首先,我们从底层设备读取尽可能多的数据,将其存储在字节数组中。然后,我们遍历数组并将数据缓冲区的后续字节设置为解密值。最后,我们返回实际读取的数据量。
一个简单的 main() 函数,可以测试该类,如下所示:
int main(int argc, char **argv) {
QByteArray ba = "plaintext";
QBuffer buf;
buf.open(QIODevice::WriteOnly);
CaesarCipherDevice encrypt;
encrypt.setKey(3);
encrypt.setBaseDevice(&buf);
encrypt.open(buf.openMode());
encrypt.write(ba);
qDebug() << buf.data();
CaesarCipherDevice decrypt;
decrypt.setKey(3);
decrypt.setBaseDevice(&buf);
buf.open(QIODevice::ReadOnly);
decrypt.open(buf.openMode());
qDebug() << decrypt.readAll();
return 0;
}
我们使用实现 QIODevice API 并作为 QByteArray 或 QString 适配器的 QBuffer 类。
发生了什么?
我们创建了一个加密对象,并将其密钥设置为 3。我们还告诉它使用一个 QBuffer 实例来存储处理后的内容。在打开它以供写入后,我们向其中发送了一些数据,这些数据被加密并写入基本设备。然后,我们创建了一个类似的设备,再次将相同的缓冲区作为基本设备传递,但现在我们打开设备以供读取。这意味着基本设备包含密文。在此之后,我们从设备中读取所有数据,这导致从缓冲区中读取数据,解密它,并将数据返回以便写入调试控制台。
尝试一下英雄 - 一个凯撒密码的图形用户界面
你可以通过实现一个完整的 GUI 应用程序来结合你已知的知识,该应用程序能够使用我们刚刚实现的 Caesar cipher QIODevice类加密或解密文件。记住,QFile也是QIODevice,所以你可以直接将其指针传递给setBaseDevice()。
这只是你的起点。QIODevice API 非常丰富,包含许多虚拟方法,因此你可以在子类中重新实现它们。
文本流
现今计算机产生的大部分数据都是基于文本的。你可以使用你已知的机制创建此类文件——打开QFile进行写入,使用QString::arg()将所有数据转换为字符串,可选地使用QTextCodec对字符串进行编码,并通过调用write将生成的字节写入文件。然而,Qt 提供了一个很好的机制,可以自动为你完成大部分工作,其工作方式类似于标准 C++ iostream类。QTextStream类以流式方式操作任何QIODevice API。你可以使用<<运算符向流发送标记,它们将被转换为字符串,用空格分隔,使用你选择的编解码器编码,并写入底层设备。它也可以反过来工作;使用>>运算符,你可以从文本文件中流式传输数据,透明地将字符串转换为适当的变量类型。如果转换失败,你可以通过检查status()方法的结果来发现它——如果你得到ReadPastEnd或ReadCorruptData,这意味着读取失败。
提示
虽然QIODevice是QTextStream操作的主要类,但它也可以操作QString或QByteArray,这使得它对我们来说很有用,可以组合或解析字符串。
使用QTextStream很简单——你只需传递给它你想要其操作的设备,然后就可以开始了。流接受字符串和数值:
QFile file("output.txt");
file.open(QFile::WriteOnly|QFile::Text);
QTextStream stream(&file);
stream << "Today is " << QDate::currentDate().toString() << endl;
QTime t = QTime::currentTime();
stream << "Current time is " << t.hour() << " h and " << t.minute() << "m." << endl;
除了将内容直接导入流中,流还可以接受多个操作符,例如endl,这些操作符会直接或间接影响流的行为。例如,你可以告诉流以十进制显示一个数字,并以大写字母显示另一个十六进制数字,如下面的代码所示(代码中突出显示的都是操作符):
for(int i=0;i<10;++i) {
int num = qrand() % 100000; // random number between 0 and 99999
stream << dec << num << showbase << hex << uppercasedigits << num << endl;
}
这并不是QTextStream功能的终点。它还允许我们通过定义列宽和对齐方式以表格形式显示数据。假设你有一组游戏玩家记录,其结构如下:
struct Player {
QString name;
qint64 experience;
QPoint position;
char direction;
};
QList<Player> players;
让我们将此类信息以表格形式输出到文件中:
QFile file("players.txt");
file.open(QFile::WriteOnly|QFile::Text);
QTextStream stream(&file);
stream << center;
stream << qSetFieldWidth(16) << "Player" << qSetFieldWidth(0) << " ";
stream << qSetFieldWidth(10) << "Experience" << qSetFieldWidth(0) << " ";
stream << qSetFieldWidth(13) << "Position" << qSetFieldWidth(0) << " ";
stream << "Direction" << endl;
for(int i=0;i<players.size();++i) {
const Player &p = players.at(i);
stream << left << qSetFieldWidth(16) << p.name << qSetFieldWidth(0) << " ";
stream << right << qSetFieldWidth(10) << p.experience << qSetFieldWidth(0) << " ";
stream << right << qSetFieldWidth(6) << p.position.x() << qSetFieldWidth(0) << " " << qSetFieldWidth(6) << p.position.y() << qSetFieldWidth(0) << " ";
stream << center << qSetFieldWidth(10);
switch(p.direction) {
case 'n' : stream << "north"; break;
case 's' : stream << "south"; break;
case 'e' : stream << "east"; break;
case 'w' : stream << "west"; break;
default: stream << "unknown"; break;
}
stream << qSetFieldWidth(0) << endl;
}
运行程序后,你应该得到一个类似于以下截图所示的结果:

关于QTextStream的最后一件事是,它可以操作标准 C 文件结构,这使得我们可以使用QTextStream,例如写入stdout或从stdin读取,如下面的代码所示:
QTextStream qout(stdout);
qout << "This text goes to process standard output." << endl;
数据序列化
更多的时候,我们必须以设备无关的方式存储对象数据,以便以后可以恢复,可能在不同的机器上,具有不同的数据布局等等。在计算机科学中,这被称为序列化。Qt 提供了几种序列化机制,现在我们将简要地看看其中的一些。
二进制流
如果你从远处看QTextStream,你会注意到它真正做的是将数据序列化和反序列化到文本格式。它的*亲是QDataStream类,它处理任意数据的序列化和反序列化到二进制格式。它使用自定义数据格式以*台无关的方式存储和检索QIODevice中的数据。它存储足够的数据,以便在一个*台上编写的流可以在不同的*台上成功读取。
QDataStream的使用方式与QTextStream类似——运算符<<和>>用于将数据重定向到或从流中。该类支持大多数内置的 Qt 类型,因此你可以直接操作QColor、QPoint或QStringList等类:
QFile file("outfile.dat");
file.open(QFile::WriteOnly|QFile::Truncate);
QDataStream stream(&file);
double dbl = 3.14159265359;
QColor color = Qt::red;
QPoint point(10, -4);
QStringList stringList = QStringList() << "foo" << "bar";
stream << dbl << color << point << stringList;
如果你想序列化自定义数据类型,你可以通过实现适当的重定向运算符来教会QDataStream这样做。
是时候进行动作了——自定义结构的序列化
让我们通过实现使用QDataStream序列化包含我们用于文本流传输的玩家信息的简单结构的函数来做一个小的练习:
struct Player {
QString name;
qint64 experience;
QPoint position;
char direction;
};
为了实现这一点,需要实现两个函数,这两个函数都返回一个之前作为调用参数传入的QDataStream引用。除了流本身之外,序列化运算符还接受一个对正在保存的类的常量引用。最简单的实现是将每个成员流式传输到流中,然后返回流:
QDataStream& operator<<(QDataStream &stream, const Player &p) {
stream << p.name;
stream << p.experience;
stream << p.position;
stream << p.direction;
return stream;
}
作为补充,反序列化是通过实现一个接受对由从流中读取的数据填充的结构的可变引用的重定向运算符来完成的:
QDataStream& operator>>(QDataStream &stream, Player &p) {
stream >> p.name;
stream >> p.experience;
stream >> p.position;
stream >> p.direction;
return stream;
}
再次强调,最后返回的是流本身。
刚才发生了什么?
我们提供了两个独立的函数,用于定义Player类到QDataStream实例的重定向运算符。这使得你的类可以使用 Qt 提供的机制进行序列化和反序列化。
XML 流
XML 已经成为存储层次化数据的最受欢迎的标准之一。尽管它冗长且难以用肉眼阅读,但它几乎在需要数据持久化的任何领域都被使用,因为它非常容易由机器读取。Qt 提供了两个模块来支持读取和写入 XML 文档。首先,QtXml模块通过文档对象模型(DOM)标准,使用QDomDocument、QDomElement等类提供访问。我们在这里不会讨论这种方法,因为现在推荐的方法是使用来自QtCore模块的流式类。QDomDocument的一个缺点是它要求我们在解析之前将整个 XML 树加载到内存中。在某些情况下,与流式方法相比,DOM 方法的易用性可以弥补这一点,所以如果你觉得找到了合适的任务,可以考虑使用它。
小贴士
如果你想在 Qt 中使用 DOM 访问 XML,请记住在项目配置文件中添加QT += xml行以启用QtXml模块。
正如之前所说,我们将关注由QXmlStreamReader和QXmlStreamWriter类实现的流式方法。
开始行动时间 - 实现玩家数据的 XML 解析器
在这个练习中,我们将创建一个解析器来填充表示玩家及其在 RPG 游戏中的库存数据的结构:
struct InventoryItem {
enum Type { Weapon, Armor, Gem, Book, Other } type;
QString subType;
int durability;
};
struct Player {
QString name;
QString password;
int experience;
int hitPoints;
QList<Item> inventory;
QString location;
QPoint position;
};
struct PlayerInfo {
QList<Player> players;
};
将以下文档保存在某个地方。我们将使用它来测试解析器是否可以读取它:
<PlayerInfo>
<Player hp="40" exp="23456">
<Name>Gandalf</Name>
<Password>mithrandir</Password>
<Inventory>
<InvItem type="weapon" durability="3">
<SubType>Long sword</SubType>
</InvItem>
<InvItem type="armor" durability="10">
<SubType>Chain mail</SubType>
</InvItem>
</Inventory>
<Location name="room1">
<Position x="1" y="0"/>
</Location>
</Player>
</PlayerInfo>
让我们创建一个名为PlayerInfoReader的类,它将包装QXmlStreamReader并公开一个解析器接口,用于PlayerInfo实例。该类将包含两个私有成员——读取器本身以及一个PlayerInfo实例,它作为当前正在读取的数据的容器。我们将提供一个result()方法,在解析完成后返回此对象,如下面的代码所示:
class PlayerInfoReader {
public:
PlayerInfoReader(QIODevice *);
inline const PlayerInfo& result() const { return m_pinfo; }
private:
QXmlStreamReader reader;
PlayerInfo m_pinfo;
};
类构造函数接受一个QIODevice指针,读者将使用它来按需检索数据。构造函数很简单,因为它只是将设备传递给reader对象:
PlayerInfoReader(QIODevice *device) {
reader.setDevice(device);
}
在我们开始解析之前,让我们准备一些代码来帮助我们处理这个过程。首先,让我们向类中添加一个枚举类型,它将列出所有可能的令牌——我们希望在解析器中处理的标签名称:
enum Token {
T_Invalid = -1,
T_PlayerInfo, /* root tag */
T_Player, /* in PlayerInfo */
T_Name, T_Password, T_Inventory, T_Location, /* in Player */
T_Position, /* in Location */
T_InvItem /* in Inventory */
};
要使用这些标签,我们将在类中添加一个静态方法,它根据其文本表示返回令牌类型:
static Token PlayerInfoReader::tokenByName(const QStringRef &r) {
static QStringList tokenList = QStringList() << "PlayerInfo" << "Player"
<< "Name" << "Password"
<< "Inventory" << "Location"
<< "Position" << "InvItem";
int idx = tokenList.indexOf(r.toString());
return (Token)idx;
}
你可以注意到我们正在使用一个名为QStringRef的类。它代表一个字符串引用——现有字符串中的子串,并且以避免昂贵的字符串构造的方式实现;因此,它非常快。我们在这里使用这个类是因为这是QXmlStreamReader报告标签名的方式。在这个静态方法中,我们将字符串引用转换为真实字符串,并尝试将其与已知标签列表进行匹配。如果匹配失败,则返回-1,这对应于我们的T_Invalid令牌。
现在,让我们添加一个入口点来启动解析过程。添加一个公共的read方法,它初始化数据结构并对输入流进行初始检查:
bool PlayerInfoReader::read() {
m_pinfo = PlayerInfo();
if(reader.readNextStartElement() && tokenByName(reader.name()) == T_PlayerInfo) {
return readPlayerInfo();
} else {
return false;
}
}
在清除数据结构后,我们在读取器上调用readNextStartElement(),使其找到第一个元素的起始标签,并且如果找到了,我们检查文档的根标签是否是我们期望的。如果是这样,我们调用readPlayerInfo()方法并返回其结果,表示解析是否成功。否则,我们退出,报告错误。
QXmlStreamReader子类通常遵循相同的模式。每个解析方法首先检查它是否操作的是它期望找到的标签。然后,它迭代所有起始元素,处理它所知道的元素,并忽略所有其他元素。这种做法使我们能够保持向前兼容性,因为较旧解析器会静默跳过文档新版本中引入的所有标签。
现在,让我们实现readPlayerInfo方法:
bool readPlayerInfo() {
if(tokenByName(reader.name()) != T_PlayerInfo)
return false;
while(reader.readNextStartElement()) {
if(tokenByName(reader.name()) == T_Player) {
Player p = readPlayer();
m_pinfo.players.append(p);
} else
reader.skipCurrentElement();
}
return true;
}
在验证我们正在处理PlayerInfo标签后,我们迭代当前标签的所有起始子元素。对于其中的每一个,我们检查它是否是Player标签,并调用readPlayer()来进入单个玩家解析数据级别的解析。否则,我们调用skipCurrentElement(),这将快速前进流,直到遇到匹配的结束元素。
readPlayer()的结构类似;然而,它更复杂,因为我们还想要从Player标签本身的属性中读取数据。让我们逐部分查看这个函数:
Player readPlayer() {
if(tokenByName(reader.name()) != T_Player) return Player();
Player p;
const QXmlStreamAttributes& playerAttrs = reader.attributes();
p.hitPoints = playerAttrs.value("hp").toString().toInt();
p.experience = playerAttrs.value("exp").toString().toInt();
在检查正确的标签后,我们获取与打开标签关联的属性列表,并请求我们感兴趣的两种属性的值。之后,我们循环所有子标签,并根据标签名填充Player结构。通过将标签名转换为令牌,我们可以使用switch语句来整洁地组织代码,以便从不同的标签类型中提取信息,如下面的代码所示:
while(reader.readNextStartElement()) {
Token t = tokenByName(reader.name());
switch(t) {
case Name: p.name = reader.readElementText(); break;
case Password: p.password = reader.readElementText(); break;
case Inventory: p.inventory = readInventory(); break;
如果我们对标签的文本内容感兴趣,我们可以使用readElementText()来提取它。此方法读取直到遇到关闭标签,并返回其内的文本。对于Inventory标签,我们调用专门的readInventory()方法。
对于Location标签,代码比之前更复杂,因为我们再次进入读取子标签,提取所需信息并跳过所有未知标签:
case T_Location: {
p.location = reader.attributes().value("name").toString();
while(reader.readNextStartElement()) {
if(tokenByName(reader.name()) == T_Position) {
const QXmlStreamAttributes& attrs = reader.attributes();
p.position.setX(attrs.value("x").toString().toInt());
p.position.setY(attrs.value("y").toString().toInt());
reader.skipCurrentElement();
} else
reader.skipCurrentElement();
}
}; break;
default:
reader.skipCurrentElement();
}
}
return p;
}
最后一个方法的结构与上一个类似——遍历所有标签,跳过我们不想处理的标签(即不是存货项目的所有标签),填充存货项目数据结构,并将项目添加到已解析项目列表中,如下面的代码所示:
QList<InventoryItem> readInventory() {
QList<InventoryItem> inventory;
while(reader.readNextStartElement()) {
if(tokenByName(reader.name()) != T_InvItem) {
reader.skipCurrentElement();
continue;
}
InventoryItem item;
const QXmlStreamAttributes& attrs = reader.attributes();
item.durability = attrs.value("durability").toString().toInt();
QStringRef typeRef = attrs.value("type");
if(typeRef == "weapon") {
item.type = InventoryItem::Weapon;
} else if(typeRef == "armor") {
item.type = InventoryItem::Armor;
} else if(typeRef == "gem") {
item.type = InventoryItem::Gem;
} else if(typeRef == "book") {
item.type = InventoryItem::Book;
} else item.type = InventoryItem::Other;
while(reader.readNextStartElement()) {
if(reader.name() == "SubType")
item.subType = reader.readElementText();
else
reader.skipCurrentElement();
}
inventory << item;
}
return inventory;
}
在你的项目main()函数中,编写一些代码来检查解析器是否工作正确。你可以使用qDebug()语句来输出列表的大小和变量的内容。以下代码是一个示例:
qDebug() << "Count:" << playerInfo.players.count();
qDebug() << "Size of inventory:" << playerInfo.players.first().inventory.size();
qDebug() << "Room: " << playerInfo.players.first().location << playerInfo.players.first().position;
刚才发生了什么?
你刚才编写的代码实现了 XML 数据的完整自顶向下解析器。首先,数据通过一个分词器,它返回比字符串更容易处理的标识符。然后,每个方法都可以轻松检查它接收到的标记是否是当前解析阶段的可接受输入。根据子标记,确定下一个解析函数,并解析器下降到较低级别,直到没有下降的地方。然后,流程向上回退一级并处理下一个子项。如果在任何点上发现未知标签,它将被忽略。这种方法支持一种情况,即新版本的软件引入了新的标签到文件格式规范中,但旧版本的软件仍然可以通过跳过所有它不理解的标签来读取文件。
尝试一下英雄——玩家数据的 XML 序列化器
现在你已经知道了如何解析 XML 数据,你可以创建其互补部分——一个模块,它将使用QXmlStreamWriter将PlayerInfo结构序列化为 XML 文档。为此,你可以使用writeStartDocument()、writeStartElement()、writeCharacters()和writeEndElement()等方法。验证你用代码保存的文档是否可以用我们共同实现的解析器进行解析。
JSON 文件
JSON代表JavaScript 对象表示法,这是一种流行的轻量级文本格式,用于以人类可读的形式存储面向对象的数据。它源自 JavaScript,在那里它是存储对象信息的原生格式;然而,它被广泛应用于许多编程语言,并且是网络数据交换的流行格式。一个简单的 JSON 格式定义如下:
{
"name": "Joe",
"age": 14,
"inventory: [
{ "type": "gold; "amount": "144000" },
{ "type": "short_sword"; "material": "iron" }
]
}
JSON 可以表示两种类型的实体:对象(用大括号括起来)和数组(用方括号括起来),其中对象被定义为键值对的集合,其中值可以是简单的字符串、对象或数组。在先前的例子中,我们有一个包含三个属性的对象——名称、年龄和存货。前两个属性是简单值,最后一个属性是一个包含两个对象且每个对象有两个属性的数组。
Qt 可以使用QJsonDocument类创建和读取 JSON 描述。可以使用QJsonDocument::fromJson()静态方法从 UTF-8 编码的文本中创建一个文档,稍后可以使用toJson()方法将其再次存储为文本形式。由于 JSON 的结构与QVariant(也可以使用QVariantMap来存储键值对,使用QVariantList来存储数组)非常相似,因此也存在一组fromVariant()和toVariant()调用方法来实现到这个类的转换。一旦创建了一个 JSON 文档,就可以使用isArray和isObject调用之一来检查它是否表示一个对象或数组。然后,可以使用toArray和toObject方法将文档转换为QJsonArray或QJsonObject。
QJsonObject是一种可迭代的类型,可以查询其键列表(使用keys())或询问特定键的值(使用value()方法)。值使用QJsonValue类表示,它可以存储简单值、数组或对象。可以使用insert()方法向对象添加新属性,该方法接受一个字符串形式的键,可以将值作为QJsonValue添加,并可以使用remove()方法移除现有属性。
QJsonArray也是一种可迭代的类型,它包含一个经典的列表 API——它包含append()、insert()、removeAt()、at()和size()等方法来操作数组中的条目,再次以QJsonValue作为项目类型。
开始行动——玩家数据 JSON 序列化器
我们接下来的练习是创建一个与我们在 XML 练习中使用的PlayerInfo结构相同的序列化器,但这次目标数据格式将是 JSON。
首先,创建一个PlayerInfoJSON类,并给它一个类似于以下代码的接口:
class PlayerInfoJSON {
public:
PlayerInfoJSON(){}
QByteArray writePlayerInfo(const PlayerInfo &pinfo) const;
};
真正需要实现的是writePlayerInfo方法。这个方法将使用QJsonDocument::fromVariant()来进行序列化;因此,我们真正需要做的是将我们的玩家数据转换为一种变体。让我们添加一个受保护的方法来完成这个任务:
QVariant PlayerInfoJSON::toVariant(const PlayerInfo &pinfo) const {
QVariantList players;
foreach(const Player &p, pinfo.players) players << toVariant(p);
return players;
}
由于结构实际上是一个玩家列表,我们可以迭代玩家列表,将每个玩家序列化为一个变体,并将结果追加到QVariantList中。有了这个函数,我们就可以向下深入并实现一个toVariant()的重载,它接受一个Player对象:
QVariant PlayerInfoJSON::toVariant(const Player &player) const {
QVariantMap map;
map["name"] = player.name;
map["password"] = player.password;
map["experience"] = player.experience;
map["hitpoints"] = player.hitPoints;
map["location"] = player.location;
map["position"] = QVariantMap({ {"x", player.position.x()},
{"y", player.position.y()} });
map["inventory"] = toVariant(player.inventory);
return map;
}
小贴士
Qt 的foreach宏接受两个参数——一个变量的声明和一个要迭代的容器。在每次迭代中,宏将后续元素分配给声明的变量,并执行宏后面的语句。C++11 中foreach的等价物是基于 for 构造的 range:
for(const Player &p: pinfo.players) players << toVariant(p);
这次,我们使用 QVariantMap 作为我们的基本类型,因为我们想将值与键关联起来。对于每个键,我们使用索引运算符向映射中添加条目。位置键包含一个 QPoint 值,这是 QVariant 本地支持的;然而,这样的变体不能自动编码为 JSON,因此我们使用 C++11 初始化列表将点转换为变体映射。对于库存,情况不同——我们再次必须为 toVariant 编写一个重载,以执行转换:
QVariant PlayerInfoJSON::toVariant(const QList<InventoryItem> &items) const {
QVariantList list;
foreach(const InventoryItem &item, items) list << toVariant(item);
return list;
}
代码几乎与处理 PlayerInfo 对象的代码相同,所以让我们关注 toVariant 的最后一个重载——接受 Item 实例的那个:
QVariant PlayerInfoJSON::toVariant(const InventoryItem &item) const {
QVariantMap map;
map["type"] = (int)item.type;
map["subtype"] = item.subType;
map["durability"] = item.durability;
return map;
}
这里没有太多可评论的——我们将所有键添加到映射中,将项目类型视为整数以简化(在一般情况下,这不是最佳方法,因为我们序列化数据并更改原始枚举中的值顺序后,反序列化后不会得到正确的项目类型)。
剩下的就是使用我们在 writePlayerInfo 方法中刚刚编写的代码:
QByteArray PlayerInfoJSON::writePlayerInfo(const PlayerInfo &pinfo) const {
QJsonDocument doc = QJsonDocument::fromVariant(toVariant(pinfo));
return doc.toJson();
}
是时候实现 JSON 解析器了
让我们扩展 PlayerInfoJSON 类并为其添加反向转换:
PlayerInfo PlayerInfoJSON::readPlayerInfo(const QByteArray &ba) const {
QJsonDocument doc = QJsonDocument::fromJson(ba);
if(doc.isEmpty() || !doc.isArray()) return PlayerInfo();
return readPlayerInfo(doc.array());
}
首先,我们读取文档并检查它是否有效以及是否包含预期的数组。如果失败,则返回空结构;否则,调用 readPlayerInfo 并提供 QJsonArray 进行操作:
PlayerInfo PlayerInfoJSON::readPlayerInfo(const QJsonArray &array) const {
PlayerInfo pinfo;
foreach(QJsonValue value, array)
pinfo.players << readPlayer(value.toObject());
return pinfo;
}
由于数组是可迭代的,我们再次可以使用 foreach 来迭代它,并使用另一个方法——readPlayer——来提取所有所需的数据:
Player PlayerInfoJSON::readPlayer(const QJsonObject &object) const {
Player player;
player.name = object.value("name").toString();
player.password = object.value("password").toString();
player.experience = object.value("experience").toDouble();
player.hitPoints = object.value("hitpoints").toDouble();
player.location = object.value("location").toString();
QVariantMap positionMap = object.value("position").toVariant().toMap();
player.position = QPoint(positionMap["x"].toInt(), positionMap["y"].toInt());
player.inventory = readInventory(object.value("inventory").toArray());
return player;
}
在这个函数中,我们使用 QJsonObject::value() 从对象中提取数据,然后使用不同的函数将数据转换为所需的类型。请注意,为了转换为 QPoint,我们首先将其转换为 QVariantMap,然后提取值,在构建 QPoint 之前使用它们。在每种情况下,如果转换失败,我们都会得到该类型的默认值(例如,一个空字符串)。为了读取库存,我们采用自定义方法:
QList<InventoryItem> PlayerInfoJSON::readInventory(const QJsonArray &array) const {
QList<InventoryItem> inventory;
foreach(QJsonValue value, array) inventory << readItem(value.toObject());
return inventory;
}
剩下的就是实现 readItem():
InventoryItem PlayerInfoJSON::readItem(const QJsonObject &object) const {
Item item;
item.type = (InventoryItem::Type)object.value("type").toDouble();
item.subType = object.value("subtype").toString();
item.durability = object.value("durability").toDouble();
return item;
}
发生了什么?
实现的类可用于在 Item 实例和包含对象数据的 JSON 格式的 QByteArray 对象之间进行双向转换。在这里我们没有进行任何错误检查;相反,我们依赖于 QJsonObject 和 QVariant 中的自动类型转换处理。
QSettings
虽然这严格来说不是一个序列化问题,但存储应用程序设置的方面与描述的主题密切相关。Qt 的解决方案是 QSettings 类。默认情况下,它在不同*台上使用不同的后端,例如 Windows 上的系统注册表或 Linux 上的 INI 文件。QSettings 的基本用法非常简单——你只需要创建对象并使用 setValue() 和 value() 来存储和加载数据:
QSettings settings;
settings.setValue("windowWidth", 80);
settings.setValue("windowTitle", "MySuperbGame");
// …
int windowHeight = settings.value("windowHeight").toInt();
你需要记住的唯一一点是它操作的是QVariant,因此如果需要,返回值需要转换为正确的类型,如前述代码的最后一行所示。如果请求的键不在映射中,value()调用可以接受一个额外的参数,该参数包含要返回的值。这允许你在默认值的情况下进行处理,例如,当应用程序首次启动且设置尚未保存时:
int windowHeight = settings.value("windowHeight", 800);
最简单的情况假设设置是“扁*”的,即所有键都在同一级别上定义。然而,这不必是这种情况——相关的设置可以放入命名的组中。要操作一个组,你可以使用beginGroup()和endGroup()调用:
settings.beginGroup("Server");
QString srvIP = settings.value("host").toString();
int port = settings.value("port").toInt();
settings.endGroup();
当使用此语法时,你必须记住在完成操作后结束组。除了使用前面提到的两种方法之外,还可以直接将组名传递给value()调用的调用:
QString srvIP = settings.value("Server/host").toString();
int port = settings.value("Server/port").toInt();
如前所述,QSettings可以在不同的*台上使用不同的后端;然而,我们可以通过向settings对象的构造函数传递适当的选项来影响选择哪个后端以及传递哪些选项给它。默认情况下,应用程序设置存储的位置由两个值决定——组织名称和应用程序名称。这两个都是文本值,都可以作为参数传递给QSettings构造函数,或者使用QCoreApplication中的适当静态方法预先定义:
QCoreApplication::setOrganizationName("Packt");
QCoreApplication::setApplicationName("Game Programming using Qt");
QSettings settings;
此代码等同于:
QSettings settings("Packt", "Game Programming using Qt");
所有的前述代码都使用了系统的默认后端。然而,通常希望使用不同的后端。这可以通过Format参数来完成,其中我们可以传递两个选项之一——NativeFormat或IniFormat。前者选择默认后端,而后者强制使用 INI 文件后端。在选择后端时,你还可以决定是否应该将设置保存在系统范围内的位置或用户的设置存储中,通过传递另一个参数——其作用域可以是UserScope或SystemScope。这可以扩展我们的最终构造调用为:
QSettings settings(QSettings::IniFormat, QSettings::UserScope,
"Packt", "Game Programming using Qt");
还有一个选项可以完全控制设置数据的位置——直接告诉构造函数数据应该位于何处:
QSettings settings(
QStandardPaths::writableLocation(
QStandardPaths::ConfigLocation
) +"/myapp.conf", QSettings::IniFormat
);
小贴士
QStandardPaths类提供了根据任务确定文件标准位置的方法。
QSettings还允许你注册自己的格式,以便你可以控制设置存储的方式——例如,通过使用 XML 存储或添加即时加密。这是通过QSettings::registerFormat()完成的,你需要传递文件扩展名和两个函数指针,分别用于读取和写入设置,如下所示:
bool readCCFile(QIODevice &device, QSettings::SettingsMap &map) {
CeasarCipherDevice ccDevice;
ccDevice.setBaseDevice(&device);
// ...
return true;
}
bool writeCCFile(QIODevice &device, const QSettings::SettingsMap &map) { ... }
const QSettings::Format CCFormat = QSettings::registerFormat("ccph", readCCFile, writeCCFile);
快速测验 - Qt 核心要点
Q1. 在 Qt 中,std::string最接*的等效是什么?
-
QString -
QByteArray -
QStringLiteral
Q2. 哪个正则表达式可以用来验证一个 IPv4 地址,IPv4 地址是由四个用点分隔的十进制数字组成,其值范围在 0 到 255 之间?
Q3. 如果你预计数据结构将在软件的未来版本中演变(获取新信息),你认为使用哪种序列化机制是最好的?
-
JSON
-
XML
-
QDataStream
摘要
在本章中,你学习了从文本操作到访问可以使用 XML 或 JSON 等流行技术传输或存储数据的设备的一系列核心 Qt 技术。你应该意识到,我们只是触及了 Qt 所能提供的一小部分,还有许多其他有趣的类你需要熟悉。但这个最小量的信息应该能给你一个良好的起点,并展示你未来研究的方向。
在下一章中,我们将从描述数据操作(可以使用文本或仅凭想象力进行可视化)转换到一个更吸引人的媒体。我们将开始讨论图形,以及如何将你想象中看到的内容传输到电脑屏幕上。
第五章:Qt 中的图形
在图形方面,我们到目前为止只使用现成的控件来构建用户界面,这导致了使用按钮进行井字棋游戏的粗糙方法。在本章中,你将了解 Qt 在自定义图形方面提供的许多功能。这将让你不仅能够创建自己的控件,包含完全定制的内嵌内容,还能够将多媒体集成到你的程序中。你还将学习如何使用 OpenGL 技能来显示快速 3D 图形。如果你不熟悉 OpenGL,本章应该为你在这个主题上的进一步研究提供一个起点。到本章结束时,你将能够使用 Qt 提供的类创建 2D 和 3D 图形,并将它们与用户界面的其余部分集成。
在图形方面,Qt 将这个领域分为两个独立的部分。其中之一是光栅图形(例如,由控件使用)。这部分侧重于使用高级操作(如绘制线条或填充矩形)来操纵可以在不同设备上可视化的点的网格的颜色,例如图像或计算机设备的显示。另一个是矢量图形,它涉及操纵顶点、三角形和纹理。这是为了利用现代显卡提供的硬件加速,以实现处理和显示的最大速度。Qt 通过使用它所绘制的表面的概念来抽象图形。表面(由QSurface类表示)可以是两种类型之一——RasterSurface或OpenGLSurface。可以使用QSurfaceFormat类进一步自定义表面,但我们将稍后讨论这一点,因为它现在并不重要。
光栅绘制
当我们谈论 GUI 框架时,光栅绘制通常与在控件上绘制相关联。然而,由于 Qt 不仅仅是一个 GUI 工具包,它提供的光栅绘制的范围要广泛得多。
通常,Qt 的绘制架构由三个部分组成。最重要的部分是绘制发生的设备,由QPaintDevice类表示。Qt 提供了一系列的绘制设备子类,如QWidget、QImage、QPrinter或QPdfWriter。你可以看到,在控件上绘制和在打印机上打印的方法将非常相似。区别在于架构的第二部分——绘制引擎(QPaintEngine)。引擎负责在特定的绘制设备上执行实际的绘制操作。不同的绘制引擎用于在图像上绘制和在打印机上打印。这对于你作为开发者来说是完全隐藏的,所以你真的不需要担心这一点。
对您来说,最重要的部分是第三个组件——QPainter——它是对整个绘画框架的适配器。它包含一组可以在绘图设备上调用的高级操作。幕后,所有工作都委托给适当的绘图引擎。在讨论绘画时,我们将专注于画家对象,因为任何绘画代码都只能通过在另一个绘图设备上初始化的画家来在目标设备上调用。这有效地使 Qt 的绘画设备无关,如下面的示例所示:
void doSomePainting(QPainter *painter) {
painter->drawLine(QPoint(0,0), QPoint(100, 40));
}
同一段代码可以在任何可能的QPaintDevice类上执行,无论是小部件、图像还是 OpenGL 上下文(通过使用QOpenGLPaintDevice)。
画家属性
QPainter类有一个丰富的 API,基本上可以分为三组方法。第一组包含画家的属性设置器和获取器。第二组由以draw和fill开头的方法组成,这些方法在设备上执行绘图操作。最后一组有其他方法,主要是允许操作画家的坐标系。
让我们从属性开始。最重要的三个属性是字体、画笔和刷子。第一个是QFont类的实例。它包含大量用于控制字体参数的方法,如字体家族、样式(斜体或倾斜)、字体粗细和字体大小(以点或设备相关像素为单位)。所有参数都是不言自明的,所以我们在这里不会详细讨论它们。重要的是要注意QFont可以使用系统上安装的任何字体。如果需要更多对字体的控制或需要使用系统上未安装的字体,可以利用QFontDatabase类。它提供了有关可用字体(例如,特定字体是否可缩放或位图或它支持哪些书写系统)的信息,并允许通过直接从文件加载它们的定义来将新字体添加到注册表中。
在字体方面,一个重要的类别是QFontMetrics类。它允许计算使用字体绘制特定文本所需的空间,或者计算文本的省略。最常见的用例是检查为特定用户可见字符串分配多少空间,例如:
QFontMetrics fm = painter.fontMetrics();
QRect rect = fm.boundingRect("Game Programming using Qt");
这在尝试确定小部件的sizeHint时特别有用。
笔刷和画笔是两个属性,它们定义了不同的绘图操作如何执行。笔刷定义了轮廓,而画笔则填充使用绘图器绘制的形状。前者由QPen类表示,后者由QBrush表示。每个都是一组参数。最简单的一个是颜色,它可以是预定义的全局颜色枚举值(例如Qt::red或Qt::transparent)或QColor类的实例。有效颜色由四个属性组成——三个颜色分量(红色、绿色和蓝色)以及一个可选的 alpha 通道值,该值决定了颜色的透明度(值越大,颜色越不透明)。默认情况下,所有分量都表示为 8 位值(0 到 255),也可以表示为表示分量最大饱和度百分比的实数值;例如,0.6 对应于 153(0.6255*)。为了方便,QColor构造函数之一接受 HTML 中使用的十六进制颜色代码(例如,#0000FF是一个不透明的蓝色颜色)或甚至是从静态函数QColor::colorNames()返回的预定义颜色列表中的裸颜色名称(例如,blue)。一旦使用 RGB 分量定义了颜色对象,就可以使用不同的颜色空间进行查询(例如,CMYK 或 HSV)。此外,还有一系列静态方法,它们作为不同颜色空间中表达的颜色构造函数。例如,要构造一个清澈的洋红色颜色,可以使用以下任何一种表达式:
-
QColor("magenta") -
QColor("#FF00FF") -
QColor(255, 0, 255) -
QColor::fromRgbF(1, 0, 1) -
QColor::fromHsv(300, 255, 255) -
QColor::fromCmyk(0, 255, 0, 0) -
Qt::magenta
除了颜色之外,QBrush还有两种表示形状填充的方式。您可以使用QBrush::setTexture()设置一个用作戳记的位图,或者使用QBrush::setGradient()使画笔使用渐变进行填充。例如,要使用一个对角线渐变,从形状的左上角开始为黄色,在形状的中间变为红色,并在形状的右下角结束为洋红色,可以使用以下代码:
QLinearGradient gradient(0, 0, width, height);
gradient.setColorAt(0, Qt::yellow);
gradient.setColorAt(0.5, Qt::red);
gradient.setColorAt(1.0, Qt::magenta);
QBrush brush = gradient;
当与绘制矩形一起使用时,此代码将产生以下输出:

Qt 可以处理线性(QLinearGradient)、径向(QRadialGradient)和锥形(QConicalGradient)渐变。它附带了一个示例(如下面的屏幕截图所示),其中可以看到不同的渐变效果。此示例位于examples/widgets/painting/gradients。

至于笔,它的主要属性是其宽度(以像素为单位),它决定了形状轮廓的厚度。一个特殊的宽度设置是0,这构成了所谓的装饰性笔,无论对画家应用什么变换,它总是以 1 像素宽的线条绘制(我们稍后会介绍这一点)。当然,笔可以设置颜色,但除此之外,您还可以使用任何画笔作为笔。这种操作的结果是,您可以使用渐变或纹理绘制形状的粗轮廓。
对于笔来说,还有三个更重要的属性。第一个是笔的样式,通过QPen::setStyle()设置。它决定了笔绘制的线条是连续的还是以某种方式分割的(虚线、点等)。您可以在以下图中看到可用的线条样式及其对应的常量:

第二个属性是帽样式,可以是*的、方的或圆的。第三个属性——连接样式——对于多段线轮廓很重要,它决定了多段线的不同部分是如何连接的。您可以使连接尖锐(使用Qt::MiterJoin),圆形(Qt::RoundJoin),或者两者的混合(Qt::BevelJoin)。您可以通过启动以下截图所示的路径描边示例来查看不同的笔属性配置(包括不同的连接和帽样式):

画家的下一个重要方面是其坐标系。实际上,画家有两个坐标系。一个是它自己的逻辑坐标系,它使用实数进行操作,另一个是画家操作的设备的物理坐标系。逻辑坐标系上的每个操作都会映射到设备的物理坐标,并在那里应用。让我们首先解释逻辑坐标系,然后我们将看到这与物理坐标有什么关系。
画家代表一个无限大的笛卡尔画布,默认情况下水*轴指向右,垂直轴指向下。可以通过对其应用仿射变换来修改系统——*移、旋转、缩放和剪切。这样,您可以通过执行一个循环来绘制一个模拟时钟面,每个小时用一条线标记,该循环将坐标系旋转 30 度,并在新获得的坐标系中绘制一条垂直线。另一个例子是当您希望绘制一个简单的图表,其中x轴向右,y轴向上。为了获得正确的坐标系,您需要在垂直方向上将坐标系缩放为-1,从而有效地反转垂直轴的方向。
我们在这里描述的修改了由QTransform类实例表示的绘图者的世界变换矩阵。您可以通过在绘图者上调用transform()来查询矩阵的当前状态,您可以通过调用setTransform()来设置一个新的矩阵。QTransform有scale()、rotate()和translate()等方法来修改矩阵,但QPainter有直接操作世界矩阵的等效方法。在大多数情况下,使用这些方法会更可取。
每个绘图操作都使用逻辑坐标表示,经过世界变换矩阵,然后达到坐标操作的第二个阶段,即视图矩阵。绘图者有viewport()和window()矩形的观念。viewport矩形表示任意矩形的物理坐标,而window矩形表示相同的矩形,但在逻辑坐标中。将一个映射到另一个给出一个需要应用于每个绘制的原型的变换,以计算要绘制的物理设备的区域。默认情况下,这两个矩形与底层设备的矩形相同(因此不执行window-viewport映射)。这种变换在您希望使用除目标设备像素以外的测量单位执行绘图操作时很有用。例如,如果您想使用目标设备宽度和高度的百分比来表示坐标,您可以将窗口宽度和高度都设置为100。然后,要绘制从宽度 20%和高度 10%开始,到宽度 70%和高度 30%结束的线,您会告诉绘图者绘制从(20, 10)到(70, 30)的线。如果您想将这些百分比应用于图像的左半部分而不是整个区域,您只需将视口矩形设置为图像的左半部分。
小贴士
仅设置window和viewport矩形仅定义了坐标映射;它不会阻止绘图操作在viewport矩形之外绘制。如果您想有这种行为,您必须在绘图者上设置一个clipping矩形。
一旦正确设置了绘图者,您就可以开始发出绘图操作。QPainter有一套丰富的操作来绘制不同类型的原型。所有这些操作在其名称中都有draw前缀,后面跟着要绘制的原型的名称。因此,drawLine、drawRoundedRect和drawText等操作都可用,具有多个重载,通常允许我们使用不同的数据类型来表示坐标。这些可能是纯值(整数或实数),Qt 的类,如QPoint和QRect,或它们的浮点等效类——QPointF和QRectF。每个操作都是使用当前的绘图者设置(字体、笔和画刷)执行的。
小贴士
要查看所有可用的绘图操作列表,请切换到 Qt Creator 中的 帮助 面板。在窗口顶部的下拉列表中选择 索引,然后输入 qpainter。确认搜索后,你应该会看到 QPainter 类的参考手册,其中列出了所有绘图操作。
在开始绘图之前,你必须告诉画家你希望在哪个设备上绘图。这是通过使用 begin() 和 end() 方法来完成的。前者接受一个指向 QPaintDevice 实例的指针并初始化绘图基础设施,后者标记绘图完成。通常,我们不需要直接使用这些方法,因为 QPainter 的构造函数会为我们调用 begin(),而析构函数会调用 end()。因此,典型的流程是实例化一个画家对象,传递设备,然后通过调用 set 和 draw 方法进行绘图,最后让画家在超出作用域时被销毁,如下所示:
{
QPainter painter(this); // paint on the current object
QPen pen = Qt::red;
pen.setWidth(2);
painter.setPen(pen);
painter.setBrush(Qt::yellow);
painter.drawRect(0, 0, 100, 50);
}
我们将在本章的后续部分介绍 draw 家族中的更多方法。
小部件绘图
是时候通过在小部件上绘图来将一些内容真正显示到屏幕上了。小部件由于接收到一个名为 QEvent::Paint 的事件而被重新绘制,这个事件通过重写虚拟方法 paintEvent() 来处理。此方法接受一个类型为 QPaintEvent 的事件对象的指针,该对象包含有关重新绘制请求的各种信息。记住,你只能在 paintEvent() 调用内部对小部件进行绘图。
行动时间 – 自定义绘制小部件
让我们立即将我们的新技能付诸实践!
在 Qt Creator 中开始创建一个新的 Qt Widgets 应用程序,选择 QWidget 作为基类,并确保 生成表单 复选框未勾选。
切换到新创建类的头文件,在类中添加一个受保护的节,并为此节输入 void paintEvent。然后按键盘上的 Ctrl + 空格键,Creator 将会建议方法的参数。你应该得到以下代码:
protected:
void paintEvent(QPaintEvent *);
Creator 将光标定位在分号之前。按 Alt + Enter 将打开重构菜单,让你在实现文件中添加定义。绘制事件的常规代码是实例化小部件上的画家,如下所示:
void Widget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
}
如果你运行此代码,小部件将保持空白。现在我们可以开始添加实际的绘图代码了:
void Widget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
QPen pen(Qt::black);
pen.setWidth(4);
painter.setPen(pen);
QRect r = rect().adjusted(10, 10, -10, -10);
painter.drawRoundedRect(r, 20, 10);
}
编译并运行代码,你将得到以下输出:

发生了什么事?
首先,我们为画家设置了一个 2 像素宽的黑笔。然后我们调用 rect() 来检索小部件的几何矩形。通过调用 adjusted(),我们得到了一个新的矩形,其坐标(按左、上、右、下的顺序)被给定的参数修改,从而有效地给我们一个每边有 10 像素边距的矩形。
提示
Qt 通常提供两种方法,允许我们处理修改后的数据。调用adjusted()返回一个具有修改后属性的新对象,而如果我们调用adjust(),修改将就地完成。请特别注意你使用的方法,以避免意外结果。最好始终检查方法的返回值——它返回的是副本还是空值。
最后,我们调用drawRoundedRect(),该方法绘制一个矩形,其角落通过第二个和第三个参数(x,y顺序)给出的像素数进行圆滑处理。如果你仔细观察,你会注意到矩形有讨厌的锯齿状圆滑部分。这是由混叠效应引起的,其中逻辑线使用屏幕有限的分辨率进行*似;由于这个原因,一个像素要么完全绘制,要么完全不绘制。Qt 提供了一种称为抗锯齿的机制,通过在绘制圆角矩形之前在画家上设置适当的渲染提示来抵消这种效果。你可以通过在绘制圆角矩形之前在画家上设置适当的渲染提示来启用此机制,如下所示:
void Widget::paintEvent(QPaintEvent *)
{
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
// …
}
现在,你会得到以下输出:

当然,这会对性能产生负面影响,因此只有在混叠效果明显的地方才使用抗锯齿。
行动时间 - 转换视口
让我们扩展我们的代码,以便所有未来的操作都只关注在绘制边框边界内,边框绘制完毕后。如下使用window和viewport转换:
void Widget::paintEvent(QPaintEvent *) {
QPainter painter(this);
painter.setRenderHint(QPainter::Antialiasing, true);
QPen pen(Qt::black);
pen.setWidth(4);
painter.setPen(pen);
QRect r = rect().adjusted(10, 10, -10, -10);
painter.drawRoundedRect(r, 20, 10);
painter.save();
r.adjust(2, 2, -2, -2);
painter.setViewport(r);
r.moveTo(0, -r.height()/2);
painter.setWindow(r);
drawChart(&painter, r);
painter.restore();
}
还创建一个名为drawChart()的保护方法:
void Widget::drawChart(QPainter *painter, const QRect &rect) {
painter->setPen(Qt::red);
painter->drawLine(0, 0, rect.width(), 0);
}
让我们看看我们的输出:

发生了什么?
在新添加的代码中,我们首先调用painter.save()。此调用将画家的所有参数存储在一个内部堆栈中。然后我们可以修改画家状态(通过更改其属性、应用转换等),然后,如果我们想在任何时候返回到保存的状态,只需调用painter.restore()即可一次性撤销所有修改。
小贴士
save()和restore()方法可以按需调用。只需记住,始终将save()的调用与类似的restore()调用配对,否则内部画家状态将损坏。每次调用restore()都会将画家恢复到最后保存的状态。
状态保存后,我们再次调整矩形,以适应边框的宽度。然后,我们将新矩形设置为视口,通知画家操作坐标的物理范围。然后,我们将矩形移动到其高度的一半,并将其设置为画家窗口。这有效地将画家的原点放置在窗口高度的一半处。然后,调用drawChart()方法,在新的坐标系x轴上绘制一条红线。
行动时间 - 绘制示波图
让我们进一步扩展我们的小部件,使其成为一个简单的示波图渲染器。为此,我们需要让小部件记住一组值并将它们绘制成一系列线条。
让我们先添加一个QList<quint16>成员变量,它包含一个无符号 16 位整数值的列表。我们还将添加用于向列表添加值和清除列表的槽,如下所示:
class Widget : public QWidget
{
// ...
public slots:
void addPoint(unsigned yVal) { m_points << qMax(0u, yVal); update(); }
void clear() { m_points.clear(); update(); }
protected:
// ...
QList<quint16> m_points;
};
注意,每次修改列表都会调用一个名为update()的方法。这将安排一个绘图事件,以便我们的小部件可以用新值重新绘制。
绘图代码也很简单;我们只需遍历列表,并根据列表中的值绘制对称的蓝色线条。由于线条是垂直的,它们不会受到混叠的影响,因此我们可以禁用此渲染提示,如下所示:
void Widget::drawChart(QPainter *painter, const QRect &rect) {
painter->setPen(Qt::red);
painter->drawLine(0, 0, rect.width(), 0);
painter->save();
painter->setRenderHint(QPainter::Antialiasing, false);
painter->setPen(Qt::blue);
for(int i=0;i < m_points.size(); ++i) {
painter->drawLine(i, -m_points.at(i), i, m_points.at(i));
}
painter->restore();
}
要查看结果,请将以下循环添加到main中。这将用数据填充小部件:
for(int i=0;i<450;++i) w.addPoint(qrand() % 120);
这个循环从0到119之间取一个随机数,并将其作为一个点添加到小部件中。运行此类代码的示例结果可以在下面的屏幕截图中看到:

小贴士
如果你缩小窗口,你会注意到示波图超出了圆角矩形的边界。还记得剪裁吗?现在你可以通过在调用drawChart()之前添加一个简单的painter.setClipRect(r)调用来约束绘图。
输入事件
到目前为止,自定义小部件根本不具备交互性。尽管可以从源代码中操作小部件内容(例如,通过向图表添加新点),但小部件对任何用户操作(除了调整小部件大小,这会导致重绘)都充耳不闻。在 Qt 中,用户与小部件之间的任何交互都是通过向小部件传递事件来完成的。这类事件通常被称为输入事件,包括键盘事件和不同形式的指向设备事件——鼠标、*板和触摸事件。
在典型的鼠标事件流程中,小部件首先接收到鼠标按下事件,然后是一系列鼠标移动事件(当用户在鼠标按钮按下时移动鼠标时),最后是鼠标释放事件。小部件还可以接收到除了这些事件之外的额外鼠标双击事件。重要的是要记住,默认情况下,只有在鼠标移动时按下鼠标按钮时,才会传递鼠标移动事件。要接收鼠标移动事件而无需按下按钮,小部件需要激活一个称为鼠标跟踪的功能。
执行时间 – 使示波图可选择
是时候让我们的示波图小部件变得交互式了。我们将教会它添加几行代码,使用户能够选择图表的一部分。让我们从存储选择开始。我们需要两个可以通过只读属性访问的整数变量;因此,向类中添加以下两个属性(你可以将它们都初始化为-1)并实现它们的 getter:
Q_PROPERTY(int selectionStart READ selectionStart NOTIFY selectionChanged)
Q_PROPERTY(int selectionEnd READ selectionEnd NOTIFY selectionChanged)
用户可以通过将鼠标光标拖动到图表上来更改选择。当用户在图表的某个位置按下鼠标按钮时,我们将该位置标记为选择的开始。拖动鼠标将确定选择的结束。事件命名的方案与绘图事件类似;因此,我们需要声明并实现以下两个受保护的方法:
void Widget::mousePressEvent(QMouseEvent *mouseEvent) {
m_selectionStart = m_selectionEnd = mouseEvent->pos().x() - 12;
emit selectionChanged();
update();
}
void Widget::mouseMoveEvent(QMouseEvent *mouseEvent) {
m_selectionEnd = mouseEvent->pos().x() - 12;
emit selectionChanged();
update();
}
两个事件处理程序的结构类似。我们更新所需值,考虑到图表的左填充(12 像素),类似于我们在绘图时所做的。然后,发出一个信号并调用update()来安排小部件的重绘。
剩下的工作就是引入绘图代码的更改。我建议您添加一个类似于drawChart()的drawSelection()方法,但该方法在drawChart()之前立即从绘图事件处理程序中调用,如下所示:
void Widget::drawSelection(QPainter *painter, const QRect &rect) {
if(m_selectionStart < 0 ) return;
painter->save();
painter->setPen(Qt::NoPen);
painter->setBrush(palette().highlight());
QRect selectionRect = rect;
selectionRect.setLeft(m_selectionStart);
selectionRect.setRight(m_selectionEnd);
painter->drawRect(selectionRect);
painter->restore();
}
首先,我们检查是否需要绘制任何选择。然后,我们保存绘图器的状态并调整绘图器的笔和画刷。笔被设置为Qt::NoPen,这意味着绘图器不应绘制任何轮廓。为了确定画刷,我们使用palette();这返回一个包含小部件基本颜色的QPalette对象。对象中包含的颜色之一是常用于标记选择的突出显示颜色。如果您使用调色板中的条目而不是手动指定颜色,那么当类的用户修改调色板时,这种修改会被我们的小部件代码考虑在内。
提示
您可以使用小部件中的调色板中的其他颜色来绘制小部件中的其他内容。您甚至可以在小部件的构造函数中定义自己的QPalette对象,以提供默认颜色。
最后,我们调整要绘制的矩形并发出绘图调用。
当您运行此程序时,您会注意到选择颜色与图表本身的对比度不是很好。为了克服这个问题,一个常见的方法是用不同的(通常是反转的)颜色绘制“已选择”的内容。在这种情况下,可以通过稍微修改drawChart()代码轻松应用:
for(int i=0; i < m_points.size(); ++i) {
if(m_selectionStart <= i && m_selectionEnd >=i) {
painter->setPen(Qt::white);
} else
painter->setPen(Qt::blue);
painter->drawLine(i, -m_points.at(i), i, m_points.at(i));
}
现在您可以看到以下输出:

尝试一下英雄 – 只对左鼠标按钮做出反应
作为练习,您可以修改事件处理代码,使其仅在鼠标事件由左键触发时更改选择。要查看哪个按钮触发了鼠标按下事件,您可以使用QMouseEvent::button()方法,该方法对于左键返回Qt::LeftButton,对于右键返回Qt::RightButton,依此类推。
处理触摸事件是不同的。对于任何此类事件,你都会收到对touchEvent()虚拟方法的调用。此类调用的参数是一个对象,可以检索用户当前触摸的点列表,以及有关用户交互历史(触摸是否刚刚开始或点是否之前被按下并移动)以及用户施加到点的力的附加信息。请注意,这是一个低级框架,允许你精确地跟踪触摸交互的历史。如果你对高级手势识别(*移、捏合和滑动)更感兴趣,有专门的事件系列可供使用。
处理手势是一个两步过程。首先,你需要通过调用grabGesture()并在其中传入你想要处理的手势类型来在你的小部件上激活手势识别。这样的代码的好地方是小部件构造函数。
然后,你的小部件将开始接收手势事件。没有专门的手势事件处理程序,但幸运的是,所有对象的事件都通过其event()方法流动,我们可以重新实现它。以下是一些处理*移手势的示例代码:
bool Widget::event(QEvent *e) {
if(e->type() == QEvent::Gesture) {
QGestureEvent *gestureEvent = static_cast<QGestureEvent*>(e);
QGesture *pan = gestureEvent->gesture(Qt::PanGesture);
if(pan) {
handlePanGesture(static_cast<QPanGesture*>(pan));
}
}
return QWidget::event(e);
}
首先,检查事件类型;如果它与预期值匹配,则将事件对象转换为QGestureEvent。然后,询问事件是否识别出Qt::PanGesture。最后,调用handlePanGesture方法。你可以实现这样的方法来处理你的*移手势。
处理图像
Qt 有两个用于处理图像的类。第一个是QImage,更侧重于直接像素操作。你可以检查图像的大小或检查和修改每个像素的颜色。你可以将图像转换为不同的内部表示(例如从 8 位调色板到带有预乘 alpha 通道的完整 32 位颜色)。然而,这种类型并不适合渲染。为此,我们有一个不同的类,称为QPixmap。这两个类之间的区别在于QImage始终保留在应用程序内存中,而QPixmap只能是一个指向可能位于图形卡内存或远程X服务器上的资源的句柄。它相对于QImage的主要优势是它可以非常快速地渲染,但代价是无法访问像素数据。你可以自由地在两种类型之间转换,但请记住,在某些*台上,这可能是一个昂贵的操作。始终考虑哪个类更适合你的特定情况。如果你打算裁剪图像、用某种颜色着色或在其上绘制,QImage是一个更好的选择。但如果你只是想渲染一些图标,最好将它们保持为QPixmap实例。
加载
加载图像非常简单。QPixmap 和 QImage 都有构造函数,它们简单地接受包含图像的文件的路径。Qt 通过实现不同图像格式读取和写入操作的插件来访问图像数据。不深入插件细节,只需说明默认的 Qt 安装支持读取以下图像类型:
| 类型 | 描述 |
|---|---|
| BMP | Windows 位图 |
| GIF | 图像交换格式 |
| ICO | Windows 图标 |
| JPEG | 联合摄影专家小组 |
| MNG | 多图像网络图形 |
| PNG | 可移植网络图形 |
| PPM/PBM/PGM | 可移植任意映射 |
| SVG | 可缩放矢量图形 |
| TIFF | 标签图像文件格式 |
| XBM | X 位图 |
| XPM | X 图像 |
如你所见,大多数流行的图像格式都是可用的。通过安装额外的插件,列表可以进一步扩展。
小贴士
你可以通过调用静态方法 QImageReader::supportedImageFormats() 来请求 Qt 支持的图像类型列表,该方法返回 Qt 可以读取的格式列表。对于可写格式的列表,请调用 QImageWriter::supportedFileFormats()。
也可以直接从现有的内存缓冲区加载图像。这可以通过两种方式完成。第一种是使用 loadFromData() 方法(它存在于 QPixmap 和 QImage 中),其行为与从文件加载图像时相同——你传递一个数据缓冲区和缓冲区的大小,然后根据这些信息,加载器通过检查头部数据来确定图像类型,并将图片加载到 QImage 或 QPixmap 中。第二种情况是当你没有存储在“文件类型”如 JPEG 或 PNG 中的图像,而是有原始像素数据本身。在这种情况下,QImage 提供了一个构造函数,它接受一个数据块的指针以及图像的大小和数据格式。格式不是如前面列出的文件格式,而是一个表示单个像素数据的内存布局。
最流行的格式是 QImage::Format_ARGB32,这意味着每个像素由 32 位(4 字节)的数据表示,这些数据在 alpha、红色、绿色和蓝色通道之间*均分配——每个通道 8 位。另一种流行的格式是 QImage::Format_ARGB32_Premultiplied,其中红色、绿色和蓝色通道的值在乘以 alpha 通道的值之后存储,这通常会导致渲染速度更快。你可以通过调用 convertToFormat() 方法来更改内部数据表示。例如,以下代码将真彩色图像转换为 256 种颜色,其中每个像素的颜色由颜色表中索引表示:
QImage trueColor(image.png);
QImage indexed = trueColor.convertToFormat(QImage::Format_Indexed8);
颜色表本身是一个颜色定义的向量,可以使用 colorTable() 方法获取,并使用 setColorTable() 方法替换。将索引图像转换为灰度的最简单方法是调整其颜色表如下:
QImage indexed = …;
QVector<QRgb> ct = indexed.colorTable();
for(int i=0;i<ct.size();++i) ct[i] = qGray(ct[i]);
indexed.setColorTable(ct);
修改
修改图像像素数据有两种方式。第一种仅适用于 QImage,涉及使用 setPixel() 调用直接操作像素,该调用接受像素坐标和要设置的像素颜色。第二种方式适用于 QImage 和 QPixmap,利用这两个类都是 QPaintDevice 的子类这一事实。因此,你可以在这类对象上打开 QPainter 并使用其绘图 API。以下是一个绘制带有蓝色矩形和红色圆圈的位图的示例:
QPixmap px(256, 256);
px.fill(Qt::transparent);
QPainter painter(&px);
painter.setPen(Qt::NoPen);
painter.setBrush(Qt::blue);
QRect r = px.rect().adjusted(10, 10, -10, -10);
painter.drawRect(r);
painter.setBrush(Qt::red);
painter.drawEllipse(r);
首先,我们创建一个 256 x 256 的位图,并用透明颜色填充它。然后,我们在其上打开一个绘图器,并调用一系列绘制蓝色矩形和红色圆圈的调用。
QImage 还提供了一系列用于变换图像的方法,包括 scaled()、mirrored()、transformed() 和 copy()。它们的 API 很直观,所以我们在这里不讨论它们。
绘制
在其基本形式中,绘制图像就像从 QPainter API 调用 drawImage() 或 drawPixmap() 一样简单。这两个方法有不同的变体,但基本上它们都允许指定要绘制给定图像或位图的哪个部分以及在哪里绘制。值得注意的是,绘制位图比绘制图像更受欢迎,因为图像必须首先转换为位图,然后才能绘制。
如果你有很多位图需要绘制,一个名为 QPixmapCache 的类可能会很有用。它为位图提供了一个应用程序范围内的缓存。通过使用它,你可以加快位图加载速度,同时限制内存使用量。
绘制文本
使用 QPainter 绘制文本值得单独解释,不是因为其复杂,而是因为 Qt 在这方面提供了很多灵活性。一般来说,绘制文本是通过调用 QPainter::drawText() 或 QPainter::drawStaticText() 来实现的。我们先关注前者,它允许绘制通用文本。
绘制一些文本的最基本调用是这个方法的变体,它接受 x 和 y 坐标以及要绘制的文本:
painter.drawText(10, 20, "Drawing some text at (10, 20)");
前面的调用在水*位置 10 处绘制了给定的文本,并将文本的基线垂直放置在位置 20。文本使用绘图器的当前字体和笔来绘制。坐标也可以作为 QPoint 实例传递,而不是分别给出 x 和 y 值。这个方法的问题在于它对文本的绘制控制很少。一个更灵活的变体是允许我们给出一系列标志,并将文本的位置表示为一个矩形而不是一个点。标志可以指定文本在给定矩形内的对齐方式,或者指示渲染引擎关于文本换行和裁剪的指令。你可以在以下图像中看到向调用提供不同组合的标志的结果:

为了获得前面的每个结果,运行类似于以下代码的代码:
painter.drawText(rect, Qt::AlignLeft|Qt::TextShowMnemonic, "&ABC");
你可以看到,除非你设置了 Qt::TextDontClip 标志,否则文本会被剪切到给定的矩形内;设置 Qt::TextWordWrap 允许文本换行,而 Qt::TextSingleLine 使得引擎忽略遇到的任何换行符。
静态文本
Qt 在布局文本时需要进行一系列的计算,并且每次渲染文本时都必须执行这些计算。如果自上次渲染以来文本及其属性没有发生变化,这将是一种时间的浪费。为了避免重新计算布局的需要,引入了静态文本的概念。
要使用它,实例化 QStaticText 并用你想要渲染的文本以及你可能希望它具有的任何选项(保持为 QTextOption 实例)进行初始化。然后,将对象存储在某个地方,每次你想渲染文本时,只需调用 QPainter::drawStaticText(),并将静态文本对象传递给它。如果自上次绘制文本以来文本的布局没有发生变化,则不会重新计算,从而提高性能。以下是一个使用静态文本方法简单地绘制文本的自定义小部件的示例:
class TextWidget : public QWidget {
public:
TextWidget(QWidget *parent = 0) : QWidget(parent) {}
void setText(const QString &txt) {
m_staticText.setText(txt);
update();
}
protected:
void paintEvent(QPaintEvent *) {
QPainter painter(this);
paitner.drawStaticText(0, 0, m_staticText);
}
private:
QStaticText m_staticText;
};
富文本
到目前为止,我们已经看到了如何绘制所有符号都使用相同属性(字体、颜色和样式)渲染的文本,并且作为字符的连续流进行布局。虽然很有用,但这不处理我们想要使用不同颜色标记文本部分或以不同方式对其对齐的情况。为了使其工作,我们可能需要执行一系列带有修改后的画家属性和手动计算文本位置的 drawText 调用。幸运的是,有更好的解决方案。
Qt 通过其 QTextDocument 类支持复杂的文档格式。使用它,我们可以以类似文本处理器的风格操纵文本,对文本段落或单个字符应用格式。然后,我们可以根据我们的需求布局和渲染生成的文档。
虽然很有用且功能强大,但如果我们只想用简单的自定义应用来绘制少量文本,构建 QTextDocument 就太复杂了。Qt 的作者们也考虑了这一点,并实现了一种富文本模式来渲染文本。启用此模式后,你可以直接使用 HTML 标签的子集来指定格式化文本给 drawText,以获得如更改文本颜色、加下划线或使其成为上标等格式化效果。在给定矩形内绘制居中加下划线的标题,然后跟一个完全对齐的描述,就像发出以下调用一样简单:
painter.drawText(rect,
"<div align='center'><b>Disclaimer</b></div>"
"<div align='justify'>You are using <i>this software</i> "
"at your own risk. The authors of the software do not give "
"any warranties that using this software will not ruin your "
"business.</div>");
小贴士
Qt 的富文本引擎没有实现完整的 HTML 规范;它不会处理层叠样式表、超链接、表格或 JavaScript。Qt 参考手册中的“支持的 HTML 子集”页面描述了哪些 HTML 4 标准的部分被支持。如果您需要完整的 HTML 支持,您将不得不使用 Qt 的网页和小部件类,这些类包含在 webkitwidgets(类 QWebPage 和 QWebView)或 webenginewidgets(类 QWebEnginePage 和 QWebEngineView)模块中。
优化绘制
在游戏编程中,性能通常是瓶颈。Qt 尽力做到尽可能高效,但有时代码需要额外的调整才能运行得更快。使用静态文本而不是常规文本就是这样一种调整;尽可能使用它。
另一个重要的技巧是,除非确实需要,否则避免重新渲染整个小部件。一方面,传递给 paintEvent() 的 QPaintEvent 对象包含有关需要重绘的小部件区域的信息。如果您的部件逻辑允许,您可以通过仅渲染所需部分来优化此过程。
行动时间 - 优化示波器绘制
作为练习,我们将修改我们的示波器小部件,使其仅重新渲染所需的数据部分。第一步是修改绘制事件处理代码,以获取需要更新的区域信息并将其传递给绘制图表的方法。这里已经突出显示了更改的部分代码:
void Widget::paintEvent(QPaintEvent *pe)
{
QRect exposedRect = pe->rect();
...
drawSelection(&painter, r, exposedRect);
drawChart(&painter, r, exposedRect);
painter.restore();
}
下一步是修改 drawSelection() 以仅绘制与暴露矩形相交的选择部分。幸运的是,QRect 提供了一个为我们计算交集的方法:
void Widget::drawSelection(QPainter *painter, const QRect &rect, const QRect &exposedRect)
{
// ...
QRect selectionRect = rect;
selectionRect.setLeft(m_pressX);
selectionRect.setRight(m_releaseX);
painter->drawRect(selectionRect.intersected(exposedRect));
painter->restore();
}
最后,drawChart 需要调整以省略暴露矩形外的值:
void Widget::drawChart(QPainter *painter, const QRect &rect, const QRect &exposedRect)
{
painter->setPen(Qt::red);
painter->drawLine(exposedRect.left(), 0, exposedRect.width(), 0);
painter->save();
painter->setRenderHint(QPainter::Antialiasing, false);
const int lastPoint = qMin(m_points.size(), exposedRect.right()+1);
for(int i=exposedRect.left(); i < lastPoint; ++i) {
if(m_selectionStart <= i && m_selectionEnd >=i) {
painter->setPen(Qt::white);
} else
painter->setPen(Qt::blue);
painter->drawLine(i, -m_points.at(i), i, m_points.at(i));
}
painter->restore();
}
刚才发生了什么?
通过实施这些更改,我们已经有效地将绘制区域减少到事件接收到的矩形。在这种情况下,我们不会节省太多时间,因为绘制图表并不那么耗时;然而,在许多情况下,您将能够通过这种方法节省大量时间。例如,如果我们需要绘制一个游戏世界的非常详细的地形图,如果只有一小部分被修改,重新绘制整个地图将非常昂贵。我们可以通过利用暴露区域的信息来轻松减少计算和绘图调用的数量。
利用暴露矩形已经是提高效率的良好步骤,但我们还可以更进一步。当前的方法要求我们在暴露矩形内重新绘制每一行图表,这仍然需要一些时间。相反,我们可以将这些线条只绘制一次到位图中,然后,每当小部件需要重新绘制时,告诉 Qt 将位图的一部分渲染到小部件上。这种方法通常被称为“双缓冲”(第二个缓冲区是作为缓存的位图)。
英雄,尝试实现一个双缓冲的示波器
现在应该很容易为你示例控件实现这种方法。主要区别是,对绘图内容的每次更改不应导致调用update(),而应调用将重新渲染位图并随后调用update()的调用。paintEvent方法因此变得非常简单:
void Widget::paintEvent(QPaintEvent *pe)
{
QRect exposedRect = pe->rect();
QPainter painter(this);
painter.drawPixmap(exposedRect, pixmap(), exposedRect);
}
你还需要在控件大小调整时重新渲染位图。这可以通过在void resizeEvent(QResizeEvent*)方法内部完成。
到目前为止,你已经准备好运用你新获得的使用 Qt 渲染图形的技能来创建一个使用自定义图形的控件游戏。今天的英雄将是象棋和其他类似象棋的游戏。
行动时间——开发游戏架构
创建一个新的Qt Widgets 应用程序项目。在项目基础设施准备就绪后,从文件菜单中选择新建文件或项目,然后选择创建一个C++ 类。将新类命名为ChessBoard,并将QObject设置为它的基类。重复此过程创建一个从QObject派生的GameAlgorithm类,另一个名为ChessView,但这次,选择QWidget作为基类。你应该最终得到一个名为main.cpp的文件和四个类——MainWindow、ChessView、ChessBoard和ChessAlgorithm。
现在,导航到ChessAlgorithm的头文件,并向该类添加以下方法:
public:
ChessBoard* board() const;
public slots:
virtual void newGame();
signals:
void boardChanged(ChessBoard*);
protected:
virtual void setupBoard();
void setBoard(ChessBoard *board);
还要添加一个私有的m_board字段,类型为ChessBoard*。记住要么包含chessboard.h,要么提前声明ChessBoard类。实现board()作为一个简单的获取m_board的方法。setBoard()方法将是一个受保护的设置器m_board:
void ChessAlgorithm::setBoard(ChessBoard *board)
{
if(board == m_board) return;
if(m_board) delete m_board;
m_board = board;
emit boardChanged(m_board);
}
接下来,让我们提供一个setupBoard()的基础实现来创建一个默认的棋盘,具有八个等级和八个列:
void ChessAlgorithm::setupBoard()
{
setBoard(new ChessBoard(8,8, this));
}
准备棋盘的自然地方是在启动新游戏时执行的功能中:
void ChessAlgorithm::newGame()
{
setupBoard();
}
目前对这个类最后的添加是扩展提供的构造函数以初始化m_board为空指针。
在最后显示的方法中,我们实例化了一个ChessBoard对象,所以现在让我们专注于这个类。首先扩展构造函数以接受两个额外的整数参数,除了常规的父参数。将这些值存储在私有的m_ranks和m_columns字段中(记住在类头文件中声明这些字段本身)。
在头文件中,在Q_OBJECT宏下方,添加以下两行作为属性定义:
Q_PROPERTY(int ranks READ ranks NOTIFY ranksChanged)
Q_PROPERTY(int columns READ columns NOTIFY columnsChanged)
声明信号并实现获取方法以与这些定义协同工作。还要添加两个受保护的函数:
protected:
void setRanks(int newRanks);
void setColumns(int newColumns);
这些将是等级和列属性的设置器,但我们不希望将它们暴露给外部世界,因此我们将给予它们protected访问范围。
将以下代码放入setRanks()方法体中:
void ChessBoard::setRanks(int newRanks)
{
if(ranks() == newRanks) return;
m_ranks = newRanks;
emit ranksChanged(m_ranks);
}
接下来,以类似的方式,你可以实现setColumns()。
我们现在要处理的最后一个类是我们的自定义小部件,ChessView。目前,我们只为一个方法提供一个基本的实现,但我们将随着实现的扩展而扩展它。添加一个公共的setBoard(ChessBoard *)方法,其内容如下:
void ChessView::setBoard(ChessBoard *board)
{
if(m_board == board) return;
if(m_board) {
// disconnect all signal-slot connections between m_board and this
m_board->disconnect(this);
}
m_board = board;
// connect signals (to be done later)
updateGeometry();
}
现在我们来声明m_board成员。因为我们不是棋盘对象的所有者(算法类负责管理它),我们将使用QPointer类,该类跟踪QObject的生命周期,并在对象被销毁后将其自身设置为 null:
private:
QPointer<ChessBoard> m_board;
QPointer将其值初始化为 null,因此我们不需要在构造函数中自己进行初始化。为了完整性,让我们提供一个获取棋盘的方法:
ChessBoard *ChessView::board() const { return m_board; }
刚才发生了什么?
在上一个练习中,我们定义了我们解决方案的基本架构。我们可以看到涉及三个类:ChessView作为用户界面,ChessAlgorithm用于驱动实际游戏,以及ChessBoard作为视图和引擎之间共享的数据结构。算法将负责设置棋盘(通过setupBoard()),进行移动,检查胜利条件等。视图将渲染棋盘的当前状态,并将用户交互信号传递给底层逻辑。

大部分代码都是自解释的。您可以在ChessView::setBoard()方法中看到,我们正在断开旧棋盘对象的所有信号,连接新对象(我们将在定义了它们之后回来连接信号),最后告诉小部件更新其大小并使用新棋盘重新绘制自己。
行动时间 – 实现游戏棋盘类
现在我们将关注我们的数据结构。向ChessBoard添加一个新的私有成员,它是一个字符向量,将包含关于棋盘上棋子的信息:
QVector<char> m_boardData;
考虑以下表格,它显示了棋子类型及其所用的字母:
| 棋子类型 | 白色 | 黑色 |
|---|---|---|
| 国王 | K | |
| 后 | Q | |
| 车 | R | |
| 象 | B | |
| 马兵 | N | |
| 兵 | P |
你可以看到,白棋使用大写字母,而黑棋使用相同字母的小写变体。此外,我们还将使用空格字符(ASCII 值为 0x20)来表示一个字段为空。我们将添加一个受保护的方法来根据棋盘上的行数和列数设置一个空棋盘,并添加一个boardReset()信号来通知棋盘上的位置已更改:
void ChessBoard::initBoard()
{
m_boardData.fill(' ', ranks()*columns());
emit boardReset();
}
我们可以更新我们的设置行数和列数的方法,以便使用该方法:
void ChessBoard::setRanks(int newRanks)
{
if(ranks() == newRanks) return;
m_ranks = newRanks;
initBoard();
emit ranksChanged(m_ranks);
}
void ChessBoard::setColumns(int newColumns)
{
if(columns() == newColumns) return;
m_columns = newColumns;
initBoard();
emit columnsChanged(m_columns);
}
initBoard()方法也应该在构造函数内部调用,所以也要在那里放置调用。
接下来,我们需要一个方法来读取棋盘特定字段中放置的是哪个棋子。
char ChessBoard::data(int column, int rank) const
{
return m_boardData.at((rank-1)*columns()+(column-1));
}
行和列的索引从 1 开始,但数据结构是从 0 开始的;因此,我们必须从行和列索引中减去 1。还需要有一个方法来修改棋盘的数据。实现以下公共方法:
void ChessBoard::setData(int column, int rank, char value)
{
if(setDataInternal(column, rank, value))
emit dataChanged(column, rank);
}
该方法利用另一个实际执行工作的方法。然而,这个方法应该声明为protected访问范围。我们再次调整索引差异。
bool ChessBoard::setDataInternal(int column, int rank, char value)
{
int index = (rank-1)*columns()+(column-1);
if(m_boardData.at(index) == value) return false;
m_boardData[index] = value;
return true;
}
由于setData()使用了一个信号,我们也要声明它:
signals:
void ranksChanged(int);
void columnsChanged(int);
void dataChanged(int c, int r);
void boardReset();
每当棋盘上的情况成功更改时,将发出该信号。我们将实际工作委托给受保护的方法,以便在不发出信号的情况下修改棋盘。
定义了setData()之后,我们可以添加另一个方便的方法:
void ChessBoard::movePiece(int fromColumn, int fromRank, int toColumn, int toRank)
{
setData(toColumn, toRank, data(fromColumn, fromRank));
setData(fromColumn, fromRank, ' ');
}
你能猜到它做什么吗?没错!它将一个棋子从一个字段移动到另一个字段,并在后面留下一个空位。
仍然有一个值得实现的方法。标准的国际象棋游戏包含 32 个棋子,而游戏变体中棋子的起始位置可能不同。通过单独调用setData()来设置每个棋子的位置将非常繁琐。幸运的是,有一种整洁的国际象棋记法称为福赛斯-爱德华斯记法(FEN),它可以存储为单行文本,以表示游戏的完整状态。如果你想知道记法的完整定义,你可以自己查找。简而言之,我们可以这样说,文本字符串按行列出棋子的放置,从最后一行开始,每个位置由一个字符描述,该字符被解释为我们的内部数据结构(K代表白王,q代表黑后,等等)。每个行描述由一个/字符分隔。如果棋盘上有空位,它们不会存储为空格,而是存储为指定连续空位数量的数字。因此,标准游戏的起始位置可以写成如下:
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
这可以如下直观地解释:

让我们编写一个名为setFen()的方法,根据 FEN 字符串设置棋盘:
void ChessBoard::setFen(const QString &fen)
{
int index = 0;
int skip = 0;
const int columnCount = columns();
QChar ch;
for(int rank = ranks(); rank >0; --rank) {
for(int column = 1; column <= columnCount; ++column) {
if(skip > 0) {
ch = ' ';
skip--;
} else {
ch = fen.at(index++);
if(ch.isDigit()) {
skip = ch.toLatin1()-'0';
ch = ' ';
skip--;
}
}
setDataInternal(column, rank, ch.toLatin1());
}
QChar next = fen.at(index++);
if(next != '/' && next != ' ') {
initBoard();
return; // fail on error
}
}
emit boardReset();
}
该方法遍历棋盘上的所有方格,并确定它是否正在中间插入空方格,或者应该从字符串中读取下一个字符。如果遇到数字,它将通过减去 0 字符的 ASCII 值(即 7-0 = 7)将其转换为整数。设置每个等级后,我们要求从字符串中读取一个斜杠或空格。否则,我们将棋盘重置为空棋盘,并退出该方法。
刚才发生了什么?
我们教会了 ChessBoard 类使用字符的单维数组存储关于棋子的简单信息。我们还为其配备了允许查询和修改游戏数据的方法。我们通过采用 FEN 标准来实现设置游戏当前状态的一种快速方法。游戏数据本身并不局限于经典象棋。尽管我们遵守了描述棋子的标准记法,但可以使用其他字母和字符,这些字母和字符超出了定义良好的棋子集。这为存储类似棋类游戏(如国际象棋)的信息提供了一种灵活的解决方案,可能还可以用于任何其他在任意大小、带有等级和列的二维棋盘上进行的自定义游戏。我们提出的数据结构并非愚蠢——它通过在游戏状态修改时发出信号与其环境进行通信。
行动时间 - 理解 ChessView 类
这是一章关于图形制作的章节,因此现在是时候专注于显示我们的棋盘游戏了。我们的小部件目前什么也不显示,我们的第一个任务将是显示带有等级和列符号以及适当着色的棋盘。
默认情况下,小部件没有定义任何合适的尺寸,我们将通过实现 sizeHint() 来解决这个问题。然而,为了能够计算尺寸,我们必须决定棋盘上单个方格的大小。因此,在 ChessView 中,你应该声明一个包含方格大小的属性,如下所示:
Q_PROPERTY(QSize fieldSize
READ fieldSize WRITE setFieldSize
NOTIFY fieldSizeChanged)
为了加快编码速度,你可以将光标放在属性声明上,按 Alt + Enter 组合键,并从弹出菜单中选择 生成缺失的 Q_PROPERTY 成员 修复。Creator 将为你提供 getter 和 setter 的简单实现。你可以通过将光标放在每个方法上,按 Alt + Enter,并选择 将定义移动到 chessview.cpp 文件 修复,将生成的代码移动到实现文件中。虽然生成的 getter 方法是好的,但 setter 需要一些调整。通过添加以下突出显示的代码来修改它:
void ChessView::setFieldSize(QSize arg)
{
if (m_fieldSize == arg)
return;
m_fieldSize = arg;
emit fieldSizeChanged(arg);
updateGeometry();
}
这告诉我们的小部件,每当方格的大小被修改时,就重新计算其大小。现在我们可以实现 sizeHint():
QSize ChessView::sizeHint() const
{
if(!m_board) return QSize(100,100);
QSize boardSize = QSize(fieldSize().width() * m_board->columns() +1,
m_fieldSize.height() * m_board->ranks() +1);
int rankSize = fontMetrics().width('M')+4;
int columnSize = fontMetrics().height()+4;
return boardSize+QSize(rankSize, columnSize);
}
首先,我们检查是否有有效的棋盘定义,如果没有,则返回一个合理的 100 x 100 像素大小。否则,该方法通过将每个字段的大小乘以列数或等级数来计算所有字段的大小。我们在每个维度上添加一个像素以容纳右侧和底部的边框。棋盘不仅由字段本身组成,还在棋盘的左侧边缘显示等级符号,在棋盘的底部边缘显示列号。由于我们使用字母来枚举等级,我们使用QFontMetrics类检查字母表中字母的最宽宽度。我们使用相同的类来检查使用当前字体渲染一行文本所需的空间,以便我们有足够的空间放置列号。在这两种情况下,我们将结果增加 4,以便在文本和棋盘边缘之间以及文本和部件边缘之间留出 2 像素的边距。
定义一个辅助方法来返回包含特定字段的矩形非常有用,如下所示:
QRect ChessView::fieldRect(int column, int rank) const
{
if(!m_board) return QRect();
const QSize fs = fieldSize();
QRect fRect = QRect(QPoint((column-1)*fs.width(), (m_board->ranks()-rank)*fs.height()), fs);
// offset rect by rank symbols
int offset = fontMetrics().width('M'); // 'M' is the widest letter
return fRect.translated(offset+4, 0);
}
由于等级数字从棋盘顶部到底部递减,我们在计算fRect时从最大等级中减去所需的等级。然后,我们像在sizeHint()中做的那样计算等级符号的水*偏移量,并在返回结果之前通过该偏移量*移矩形。
最后,我们可以继续实现绘制事件的处理器。声明paintEvent()方法(在Alt + Enter键盘快捷键下可用的修复菜单将允许你生成方法的存根实现)并填充以下代码:
void ChessView::paintEvent(QPaintEvent *event)
{
if(!m_board) return;
QPainter painter(this);
for(int r = m_board->ranks(); r>0; --r) {
painter.save();
drawRank(&painter, r);
painter.restore();
}
for(int c = 1; c<=m_board->columns();++c) {
painter.save();
drawColumn(&painter, c);
painter.restore();
}
for(int r = 1; r<=m_board->ranks();++r) {
for(int c = 1; c<=m_board->columns();++c) {
painter.save();
drawField(&painter, c, r);
painter.restore();
}
}
}
处理器相当简单。首先,我们实例化一个在部件上操作的QPainter对象。然后我们有三个循环——第一个遍历行,第二个遍历列,第三个遍历所有字段。每个循环的体都非常相似:都有一个调用自定义绘图方法的调用,该方法接受指向绘图器的指针和行、列或两者的索引。每个调用都被执行save()和restore()操作包围在我们的QPainter实例周围。这些调用是做什么的?三个绘图方法——drawRank()、drawColumn()和drawField()——将是负责渲染行符号、列数字和字段背景的虚拟方法。将能够子类化ChessView并为这些渲染器提供自定义实现,以便能够提供不同的棋盘外观。由于这些方法都接受绘图器实例作为参数,因此这些方法的覆盖可以改变绘图器背后的属性值。在将绘图器传递给这样的覆盖之前调用save()会将它的状态存储在一个内部堆栈上,在覆盖返回后调用restore()会将绘图器重置为save()存储的状态。这实际上为我们提供了一个安全措施,以避免在覆盖没有清理自己修改的绘图器时破坏绘图器。
小贴士
频繁调用save()和restore()会引入性能损失,因此在时间敏感的情况下应避免过于频繁地保存和恢复绘图器状态。由于我们的绘图非常简单,所以在绘制棋盘时我们不必担心这一点。
介绍了我们的三种方法后,我们可以开始实施它们。让我们从drawRank和drawColumn开始。请记住将它们声明为虚拟的,并将它们放在受保护的访问范围内(这通常是 Qt 类放置此类方法的地点),如下所示:
void ChessView::drawRank(QPainter *painter, int rank)
{
QRect r = fieldRect(1, rank);
QRect rankRect = QRect(0, r.top(), r.left(), r.height()).adjusted(2, 0, -2, 0);
QString rankText = QString::number(rank);
painter->drawText(rankRect, Qt::AlignVCenter|Qt ::AlignRight, rankText);
}
void ChessView::drawColumn(QPainter *painter, int column)
{
QRect r = fieldRect(column, 1);
QRect columnRect = QRect(r.left(), r.bottom(),
r.width(), height()-r.bottom()).adjusted(0, 2, 0, -2);
painter->drawText(columnRect, Qt:: AlignHCenter|Qt::AlignTop, QChar('a'+column-1));
}
这两种方法非常相似。我们使用fieldRect()查询最左列和最底行的位置,然后根据这个位置计算行符号和列数字应该放置的位置。调用QRect::adjusted()是为了适应将要绘制的文本周围的 2 像素边距。最后,我们使用drawText()来渲染适当的文本。对于行,我们要求绘图器将文本对齐到矩形的右边缘并垂直居中。以类似的方式,在绘制列时,我们将文本对齐到顶部边缘并水*居中。
现在我们可以实现第三个绘图方法。它也应该被声明为受保护的虚拟方法。将以下代码放置在方法体中:
void ChessView::drawField(QPainter *painter, int column, int rank)
{
QRect rect = fieldRect(column, rank);
QColor fillColor = (column+rank) % 2 ? palette().
color(QPalette::Light) : palette().color(QPalette::Mid);
painter->setPen(palette().color(QPalette::Dark));
painter->setBrush(fillColor);
painter->drawRect(rect);
}
在这个方法中,我们使用与每个部件耦合的 QPalette 对象来查询 Light(通常是白色)和 Mid(较暗)颜色,这取决于我们在棋盘上绘制的字段是被认为是白色还是黑色。我们这样做而不是硬编码颜色,以便可以通过调整调色板对象来修改瓷砖的颜色,而无需子类化。然后我们再次使用调色板来请求 Dark 颜色,并将其用作画家的笔。当我们用这样的设置绘制矩形时,笔将勾勒出矩形的边缘,使其看起来更优雅。注意我们如何在方法中修改画家的属性,并且在之后没有将它们设置回原位。我们可以这样做是因为 save() 和 restore() 调用包围了 drawField() 的执行。
我们现在准备好看到我们工作的结果。让我们切换到 MainWindow 类,并为其配备以下两个私有变量:
ChessView *m_view;
ChessAlgorithm *m_algorithm;
然后通过添加以下突出显示的代码来修改构造函数,以设置视图和游戏引擎:
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent),
ui(new Ui::MainWindow)
{
ui->setupUi(this);
m_view = new ChessView;
m_algorithm = new ChessAlgorithm(this);
m_algorithm->newGame();
m_view->setBoard(m_algorithm->board());
setCentralWidget(m_view);
m_view->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
m_view->setFieldSize(QSize(50,50));
layout()->setSizeConstraint(QLayout::SetFixedSize);
}
此后,你应该能够构建项目。当你运行它时,你应该看到以下截图中的类似结果:

发生了什么?
在这个练习中,我们做了两件事。首先,我们提供了一些方法来计算棋盘重要部分和部件大小的几何形状。其次,我们定义了三个用于渲染棋盘视觉原语的方法。通过使这些方法成为虚拟的,我们提供了一个基础设施,允许通过子类化和覆盖基本实现来自定义外观。此外,通过从 QPalette 读取颜色,我们允许自定义原语的颜色,即使不进行子类化也可以。
主窗口构造函数的最后一行告诉布局强制窗口大小等于其中部件的大小提示。
行动时间 - 渲染棋子
现在我们可以看到棋盘了,是时候在上面放置棋子了。我们将使用图像来完成这个任务。在我的情况下,我们找到了一些带有棋子的 SVG 文件,并决定使用它们。SVG 是一种矢量图形格式,其中所有曲线都不是定义为固定的一组点,而是定义为数学曲线。它们的主要优点是它们可以很好地缩放,而不会产生锯齿效应。
让我们为我们的视图配备一个用于“盖章”特定棋子类型的图像注册表。由于每个棋子类型都与字符相关联,我们可以使用它来生成图像映射的键。让我们将以下 API 放入 ChessView:
public:
void setPiece(char type, const QIcon &icon);
QIcon piece(char type) const;
private:
QMap<char,QIcon> m_pieces;
对于图像类型,我们不使用QImage或QPixmap,而是使用QIcon。这是因为QIcon可以存储不同尺寸的多个位图,并在我们请求绘制给定尺寸的图标时使用最合适的一个。如果我们使用矢量图像,这并不重要,但如果选择使用 PNG 或其他类型的图像,那就很重要了。在这种情况下,你可以使用addFile()向单个图标添加多个图像。
回到我们的注册表,实现非常简单。我们只需将图标存储在映射中,并要求小部件重新绘制自己:
void ChessView::setPiece(char type, const QIcon &icon)
{
m_pieces.insert(type, icon);
update();
}
QIcon ChessView::piece(char type) const
{
return m_pieces.value(type, QIcon());
}
现在我们可以在MainWindow构造函数内部创建视图后立即用实际图像填充注册表。请注意,我们已将所有图像存储在一个资源文件中,如下所示:
m_view->setPiece('P', QIcon(":/pieces/Chess_plt45.svg")); // pawn
m_view->setPiece('K', QIcon(":/pieces/Chess_klt45.svg")); // king
m_view->setPiece('Q', QIcon(":/pieces/Chess_qlt45.svg")); // queen
m_view->setPiece('R', QIcon(":/pieces/Chess_rlt45.svg")); // rook
m_view->setPiece('N', QIcon(":/pieces/Chess_nlt45.svg")); // knight
m_view->setPiece('B', QIcon(":/pieces/Chess_blt45.svg")); // bishop
m_view->setPiece('p', QIcon(":/pieces/Chess_pdt45.svg")); // pawn
m_view->setPiece('k', QIcon(":/pieces/Chess_kdt45.svg")); // king
m_view->setPiece('q', QIcon(":/pieces/Chess_qdt45.svg")); // queen
m_view->setPiece('r', QIcon(":/pieces/Chess_rdt45.svg")); // rook
m_view->setPiece('n', QIcon(":/pieces/Chess_ndt45.svg")); // knight
m_view->setPiece('b', QIcon(":/pieces/Chess_bdt45.svg")); // bishop
下一步是扩展视图的paintEvent()方法以实际渲染我们的棋子。为此,我们将引入另一个受保护的虚拟方法,称为drawPiece()。我们将在遍历棋盘的所有等级和列时调用它,如下所示:
void ChessView::paintEvent(QPaintEvent *event)
{
// ...
for(int r = m_board->ranks(); r>0; --r) {
for(int c = 1; c<=m_board->columns();++c) {
drawPiece(&painter, c, r);
}
}
}
我们从最高(顶部)等级开始绘制到最低(底部)等级并不是巧合。通过这样做,我们允许产生伪 3D 效果:如果一个绘制的棋子超出了棋盘区域,它将从下一个等级(可能被另一个棋子占据)相交。通过先绘制等级较高的棋子,我们使它们被等级较低的棋子部分覆盖,从而模仿深度效果。通过提前思考,我们允许重新实现drawPiece()方法有更多的自由度。
最后一步是为这个方法提供一个基本实现,如下所示:
void ChessView::drawPiece(QPainter *painter, int column, int rank)
{
QRect rect = fieldRect(column, rank);
char value = m_board->data(column, rank);
if(value != ' ') {
QIcon icon = piece(value);
if(!icon.isNull()) {
icon.paint(painter, rect, Qt::AlignCenter);
}
}
}
这个方法很简单,它查询给定列和行的矩形,然后询问ChessBoard实例关于给定场地的棋子。如果有棋子在那里,我们要求注册表提供适当的图标;如果我们得到一个有效的图标,我们就调用它的paint()例程来在场的矩形中居中绘制棋子。绘制的图像将被缩放到矩形的尺寸。重要的是,你只能使用具有透明背景的图像(如 PNG 或 SVG 文件,而不是 JPEG 文件),这样就可以通过棋子看到场的颜色。
刚才发生了什么?
要测试实现,你可以修改算法,通过向ChessAlgorithm类引入以下更改来用默认的棋子设置填充棋盘:
void ChessAlgorithm::newGame()
{
setupBoard();
board()->setFen(
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
);
}
运行程序应该显示以下结果:

在这一步中我们做的修改非常简单。首先,我们提供了一种方法来告诉棋盘每种棋子类型的外观。这包括不仅限于标准棋子,任何适合放入字符并可以设置在ChessBoard类内部数据数组中的东西。其次,我们为绘制棋子提供了一个抽象,使用最简单的基类实现:从注册表中取一个图标并将其渲染到字段上。通过使用QIcon,我们可以添加不同大小的多个位图,用于不同大小的单个字段。或者,图标可以包含一个单矢量图像,它可以自行很好地缩放。
行动时间——使棋盘游戏交互式
我们已经成功显示了棋盘,但要实际玩游戏,我们必须告诉程序我们想要进行的移动。我们可以通过添加QLineEdit小部件来实现这一点,在那里我们将以代数形式输入移动(例如,Nf3将马移动到f3),但更自然的方式是使用鼠标光标(或用手指轻触)点击一个棋子,然后再次点击目标字段。为了获得这种功能,首先要做的是教会ChessView检测鼠标点击。因此,添加以下方法:
QPoint ChessView::fieldAt(const QPoint &pt) const
{
if(!m_board) return QPoint();
const QSize fs = fieldSize();
int offset = fontMetrics().width('M')+4; // 'M' is the widest letter
if(pt.x() < offset) return QPoint();
int c = (pt.x()-offset) / fs.width();
int r = pt.y()/fs.height();
if(c < 0 || c >= m_board->columns() || r<0 || r >= m_board->ranks())
return QPoint();
return QPoint(c+1, m_board->ranks() - r); // max rank - r
}
代码看起来与fieldRect()的实现非常相似。这是因为fieldAt()实现了其逆操作——它将小部件坐标空间中的点转换为包含该点的字段的列和秩索引。索引是通过将点坐标除以字段大小来计算的。你肯定还记得,在列的情况下,字段通过最宽字母的大小和 4 个边距进行偏移,我们在这里的计算中也要考虑这一点。我们进行两个检查:首先,我们将水*点坐标与偏移量进行比较,以检测用户是否点击了显示列符号的小部件部分,然后我们检查计算出的秩和列是否适合在板上表示的范围。最后,我们将结果作为QPoint值返回,因为这是在 Qt 中表示二维值的最简单方式。
现在我们需要找到一种方法让小部件通知其环境特定字段已被点击。我们可以通过信号-槽机制来实现。切换到ChessView的头文件(如果你目前在 Qt Creator 中打开了chessview.cpp,你可以简单地按F4键跳转到相应的头文件)并声明一个clicked(const QPoint &)信号:
signals:
void clicked(const QPoint &);
要检测鼠标输入,我们必须重写小部件具有的一个鼠标事件处理程序,即mousePressEvent或mouseReleaseEvent。显然,我们应该选择前者事件;这将有效,但并不是最佳选择。让我们想想鼠标点击的语义:它是由按下和释放鼠标按钮组成的复杂事件。实际的“点击”发生在鼠标释放之后。因此,让我们使用mouseReleaseEvent作为我们的事件处理程序:
void ChessView::mouseReleaseEvent(QMouseEvent *event)
{
QPoint pt = fieldAt(event->pos());
if(pt.isNull()) return;
emit clicked(pt);
}
代码很简单;我们使用刚刚实现的方法,并传递从QMouseEvent对象中读取的位置。如果返回的点无效,我们默默地从方法中返回。否则,将发出带有获得的列和行值的clicked()。
我们现在可以利用这个信号了。转到MainWindow的构造函数,并添加以下行以将小部件的点击信号连接到自定义槽位:
connect(m_view, SIGNAL(clicked(QPoint)), this, SLOT(viewClicked(QPoint)));
声明槽位并按以下方式实现:
void MainWindow::viewClicked(const QPoint &field)
{
if(m_clickPoint.isNull()) {
m_clickPoint = field;
} else {
if(field != m_clickPoint) {
m_view->board()->movePiece(
m_clickPoint.x(), m_clickPoint.y(),
field.x(), field.y()
);
}
m_clickPoint = QPoint();
}
}
函数使用类成员变量m_clickPoint来存储点击的字段。变量值在移动后变为无效。因此,我们可以检测我们目前正在处理的点击是否具有“选择”或“移动”语义。在前一种情况下,我们将选择存储在m_clickPoint中;在另一种情况下,我们要求棋盘使用我们之前实现的一些辅助方法进行移动。请记住将m_clickPoint声明为MasinWindow的私有成员变量。
现在应该一切正常。然而,如果你构建应用程序,运行它,并在棋盘上开始点击,你会发现没有任何反应。这是因为我们忘记告诉视图在棋盘上的游戏位置改变时刷新自己。我们必须将棋盘发出的信号连接到视图的update()槽位。打开小部件类的setBoard()方法,并按以下方式修复:
void ChessView::setBoard(ChessBoard *board)
{
// ...
m_board = board;
// connect signals
if(board){
connect(board, SIGNAL(dataChanged(int,int)), this, SLOT(update()));
connect(board, SIGNAL(boardReset()), this, SLOT(update()));
}
updateGeometry();
}
如果你现在运行程序,你做出的移动将在小部件中反映出来,如下所示:

到目前为止,我们可能认为游戏的视觉部分已经完成,但在测试我们最新的添加时,你可能已经注意到了一个问题。当你点击棋盘时,没有任何视觉提示表明任何棋子实际上已被选中。现在让我们通过引入突出显示棋盘上任何字段的能力来修复这个问题。
为了做到这一点,我们将开发一个用于不同突出显示的通用系统。首先,将Highlight类作为ChessView的内部类添加:
class ChessView : public QWidget
// ...
public:
class Highlight {
public:
Highlight() {}
virtual ~Highlight() {}
virtual int type() const { return 0; }
};
// ...
};
这是一个简约的突出显示界面,仅通过一个返回突出显示类型的虚拟方法暴露方法。在我们的练习中,我们将专注于仅标记单个字段的基本类型,该类型使用给定的颜色。这种情况将由FieldHighlight类表示:
class FieldHighlight : public Highlight {
public:
enum { Type = 1 };
FieldHighlight(int column, int rank, QColor color)
: m_field(column, rank), m_color(color) {}
inline int column() const { return m_field.x(); }
inline int rank() const { return m_field.y(); }
inline QColor color() const { return m_color; }
int type() const { return Type; }
private:
QPoint m_field;
QColor m_color;
};
您可以看到我们提供了一个构造函数,它接受列索引和行索引以及一个用于高亮的颜色,并将它们存储在私有成员变量中。此外,type() 被重新定义以返回 FieldHighlight::Type,我们可以用它来轻松地识别高亮类型。下一步是扩展 ChessView 以添加和删除高亮功能。由于容器声明了一个私有的 QList<Highlight*> m_highlights 成员变量,因此添加方法声明:
public:
void addHighlight(Highlight *hl);
void removeHighlight(Highlight *hl);
inline Highlight *highlight(int index) const {return m_highlights.at(index); }
inline int highlightCount() const { return m_highlights.size(); }
接下来提供非内联方法的实现:
void ChessView::addHighlight(ChessView::Highlight *hl)
{ m_highlights.append(hl); update(); }
void ChessView::removeHighlight(ChessView::Highlight *hl)
{ m_highlights.removeOne(hl); update(); }
绘制高亮非常简单:我们将使用另一个虚拟 draw 方法。在 paintEvent() 实现中,在负责渲染棋子的循环之前放置以下调用:
drawHighlights(&painter);
实现只是简单地遍历所有高亮,并渲染它所理解的高亮。
void ChessView::drawHighlights(QPainter *painter)
{
for(int idx=0; idx < highlightCount(); ++idx) {
Highlight *hl = highlight(idx);
if(hl->type() == FieldHighlight::Type) {
FieldHighlight *fhl = static_cast<FieldHighlight*>(hl);
QRect rect = fieldRect(fhl->column(), fhl->rank());
painter->fillRect(rect, fhl->color());
}
}
}
通过检查高亮的类型,我们知道要将泛型指针转换成哪个类。然后我们可以查询对象以获取所需的数据。最后,我们使用 QPainter::fillRect() 用给定的颜色填充场地。由于 drawHighlights() 在棋子绘制循环之前和场地绘制循环之后被调用,因此高亮将覆盖背景但不会覆盖棋子。
这就是基本的高亮系统。让我们让 viewClicked() 插槽使用它:
void MainWindow::viewClicked(const QPoint &field)
{
if(m_clickPoint.isNull()) {
if(m_view->board()->data(field.x(), field.y()) != ' ') {
m_clickPoint = field;
m_selectedField = new ChessView::FieldHighlight(
field.x(), field.y(), QColor(255, 0, 0, 50)
);
m_view->addHighlight(m_selectedField);
}
} else {
if(field != m_clickPoint) {
m_view->board()->movePiece(
m_clickPoint.x(), m_clickPoint.y(), field.x(), field.y()
);
};
m_clickPoint = QPoint();
m_view->removeHighlight(m_selectedField);
delete m_selectedField;
m_selectedField = 0;
}
}
注意我们是如何检查一个场地只有在它不为空的情况下(也就是说,有一个现有的棋子占据该场地)才能被选中的?
您还应该添加一个 ChessView::FieldHighlight *m_selectedField 私有成员变量,并在构造函数中将其初始化为空指针。现在您可以构建游戏,执行它,并开始移动棋子。

刚才发生了什么?
通过添加几行代码,我们成功地使棋盘可点击。我们连接了一个自定义槽,该槽读取被点击的场地,并可以用半透明的红色颜色高亮显示它。点击另一个场地将移动高亮显示的棋子到那里。我们开发的高亮系统非常通用。我们用它用纯色高亮显示单个场地,但您可以用多种不同的颜色标记任意数量的场地,例如,在选中一个棋子后显示有效移动。该系统可以很容易地通过新的高亮类型进行扩展;例如,您可以使用 QPainterPath 在棋盘上绘制箭头,以拥有一个复杂提示系统(比如向玩家显示建议的移动)。

是时候行动起来——连接游戏算法
在这里实现完整的棋盘游戏算法会花费我们太多时间,所以我们将满足于一个名为狐狸与猎犬的简单游戏。其中一位玩家有四个兵(猎犬),它们只能移动到黑色场地,并且兵只能向前移动(向更高的排数移动)。另一位玩家只有一个兵(狐狸),它从棋盘的另一侧开始。

它只能移动到黑色棋盘上;然而,它可以向前(向更高等级)和向后(向更低等级)移动。玩家通过将他们的棋子移动到相邻的黑色棋盘上来轮流移动。狐狸的目标是到达棋盘的另一端;猎犬的目标是捕捉狐狸,使其无法移动。

是时候开始工作了!首先,我们将扩展 ChessAlgorithm 类以包含所需的接口:
class ChessAlgorithm : public QObject
{
Q_OBJECT
Q_ENUMS(Result Player)
Q_PROPERTY(Result result READ result)
Q_PROPERTY(Player currentPlayer
READ currentPlayer
NOTIFY currentPlayerChanged)
public:
enum Result { NoResult, Player1Wins, Draw, Player2Wins };
enum Player { NoPlayer, Player1, Player2 };
explicit ChessAlgorithm(QObject *parent = 0);
ChessBoard* board() const;
inline Result result() const { return m_result; }
inline Player currentPlayer() const { return m_currentPlayer; }
signals:
void boardChanged(ChessBoard*);
void gameOver(Result);
void currentPlayerChanged(Player);
public slots:
virtual void newGame();
virtual bool move(int colFrom, int rankFrom, int colTo, int rankTo);
bool move(const QPoint &from, const QPoint &to);
protected:
virtual void setupBoard();
void setBoard(ChessBoard *board);
void setResult(Result);
void setCurrentPlayer(Player);
private:
ChessBoard *m_board;
Result m_result;
Player m_currentPlayer;
};
这里有两组成员。首先,我们有一些与游戏状态相关的枚举、变量、信号和方法:哪个玩家应该移动,以及当前游戏的结果是什么。Q_ENUMS 宏用于在 Qt 的元类型系统中注册枚举,以便它们可以用作属性或信号中的值。属性声明及其获取器不需要任何额外说明。我们还在子类中声明了用于设置变量的受保护方法。以下是它们的建议实现:
void ChessAlgorithm::setResult(Result value)
{
if(result() == value) return;
if(result() == NoResult) {
m_result = value;
emit gameOver(m_result);
} else { m_result = value; }
}
void ChessAlgorithm::setCurrentPlayer(Player value)
{
if(currentPlayer() == value) return;
m_currentPlayer = value;
emit currentPlayerChanged(m_currentPlayer);
}
记得在 ChessAlgorithm 类的构造函数中将 m_currentPlayer 和 m_result 初始化为 NoPlayer 和 NoResult。
第二组函数是修改游戏状态的函数——move() 的两个变体。虚拟变体意味着由实际算法重新实现,以检查给定移动在当前游戏状态中是否有效,如果是这样,则执行游戏棋盘的实际修改。在基类中,我们可以简单地拒绝所有可能的移动:
bool ChessAlgorithm::move(int colFrom, int rankFrom, int colTo, int rankTo)
{
Q_UNUSED(colFrom)
Q_UNUSED(rankFrom)
Q_UNUSED(colTo)
Q_UNUSED(rankTo)
return false;
}
提示
Q_UNUSED 是一个宏,用于防止编译器在编译期间发出关于包含的局部变量从未在作用域中使用过的警告。
重载是一个方便的方法,它接受两个 QPoint 对象而不是四个整数。
bool ChessAlgorithm::move(const QPoint &from, const QPoint &to)
{
return move(from.x(), from.y(), to.x(), to.y());
}
算法的接口现在已经准备好了,我们可以为狐狸和猎犬游戏实现它。从 ChessAlgorithm 派生一个 FoxAndHounds 类:
class FoxAndHounds : public ChessAlgorithm
{
public:
FoxAndHounds(QObject *parent = 0);
void newGame();
bool move(int colFrom, int rankFrom, int colTo, int rankTo);
};
newGame() 的实现相当简单:我们设置棋盘,放置棋子,并发出信号,表示现在是第一位玩家的移动时间。
void FoxAndHounds::newGame()
{
setupBoard();
board()->setFen("3p4/8/8/8/8/8/8/P1P1P1P1 w"); // 'w' - white to move
m_fox = QPoint(5,8);
setResult(NoResult);
setCurrentPlayer(Player1);
}
游戏的算法相当简单。按照以下方式实现 move():
bool FoxAndHounds::move(int colFrom, int rankFrom, int colTo, int rankTo)
{
if(currentPlayer() == NoPlayer) return false;
// is there a piece of the right color?
char source = board()->data(colFrom, rankFrom);
if(currentPlayer() == Player1 && source != 'P') return false;
if(currentPlayer() == Player2 && source != 'p') return false;
// both can only move one column right or left
if(colTo != colFrom+1 && colTo != colFrom-1) return false;
// do we move within the board?
if(colTo < 1 || colTo > board()->columns()) return false;
if(rankTo < 1 || rankTo > board()->ranks()) return false;
// is the destination field black?
if((colTo + rankTo) % 2) return false;
// is the destination field empty?
char destination = board()->data(colTo, rankTo);
if(destination != ' ') return false;
// is white advancing?
if(currentPlayer() == Player1 && rankTo <= rankFrom) return false;
board()->movePiece(colFrom, rankFrom, colTo, rankTo); // make the move
if(currentPlayer() == Player2) {
m_fox = QPoint(colTo, rankTo); // cache fox position
}
// check win condition
if(currentPlayer() == Player2 && rankTo == 1){
setResult(Player2Wins); // fox has escaped
} else if(currentPlayer() == Player1 && !foxCanMove()) {
setResult(Player1Wins); // fox can't move
} else {
// the other player makes the move now
setCurrentPlayer(currentPlayer() == Player1 ? Player2 : Player1);
}
return true;
}
声明一个受保护的 foxCanMove() 方法,并使用以下代码实现它:
bool FoxAndHounds::foxCanMove() const
{
if(emptyByOffset(-1, -1) || emptyByOffset(-1, 1)
|| emptyByOffset( 1, -1) || emptyByOffset( 1, 1)) return true;
return false;
}
然后对 emptyByOffset() 也进行相同的操作:
bool FoxAndHounds::emptyByOffset(int x, int y) const
{
const int destCol = m_fox.x()+x;
const int destRank = m_fox.y()+y;
if(destCol < 1 || destRank < 1
|| destCol > board()->columns() || destRank > board()->ranks()) return false;
return (board()->data(destCol, destRank) == ' ');
}
最后,声明一个私有的 QPoint m_fox 成员变量。
测试游戏的简单方法是对代码进行两项更改。首先,在主窗口类的构造函数中,将 m_algorithm = new ChessAlgorithm(this) 替换为 m_algorithm = new FoxAndHounds(this)。其次,修改 viewClicked() 槽如下:
void MainWindow::viewClicked(const QPoint &field)
{
if(m_clickPoint.isNull()) {
// ...
} else {
if(field != m_clickPoint) {
m_algorithm->move(m_clickPoint, field);
}
// ...
}
}
您还可以将算法类的信号连接到视图或窗口的自定义槽,以通知游戏结束,并为当前应该移动的玩家提供视觉提示。
发生了什么?
我们通过在算法类中引入newGame()和move()虚拟方法来创建一个实现类似国际象棋游戏的非常简单的 API。前者方法只是简单地设置一切。后者使用简单的检查来确定特定的移动是否有效以及游戏是否结束。我们使用m_fox成员变量来跟踪狐狸的当前位置,以便能够快速确定它是否有任何有效的移动。当游戏结束时,会发出gameOver()信号,并可以从算法中获取游戏的结果。你可以使用完全相同的框架来实现所有国际象棋规则。
大胆尝试英雄——围绕棋盘实现 UI
在练习过程中,我们专注于开发游戏板视图和必要的类,以便使游戏能够实际运行。但我们完全忽略了游戏可能拥有的常规用户界面,例如工具栏和菜单。你可以尝试为游戏设计一套菜单和工具栏。使其能够启动新游戏,保存进行中的游戏(例如通过实现 FEN 序列化器),加载已保存的游戏(例如通过利用现有的 FEN 字符串解析器),或者选择不同的游戏类型,这将生成不同的ChessAlgorithm子类。你也可以提供一个设置对话框来调整游戏板的样式。如果你愿意,你可以添加棋钟或实现一个简单的教程系统,该系统将通过文本和视觉提示(通过我们实现的突出显示系统)引导玩家了解国际象棋的基础。
大胆尝试英雄——连接一个 UCI 兼容的棋引擎
如果你真的想测试你的技能,你可以实现一个连接到通用国际象棋接口(UCI)棋引擎(如 StockFish stockfishchess.org)的ChessAlgorithm子类,并为人类玩家提供一个具有挑战性的人工智能对手。UCI 是棋引擎和棋前端之间通信的事实标准。其规范是免费提供的,因此你可以自行研究。要与 UCI 兼容的引擎通信,你可以使用QProcess,它将引擎作为外部进程启动,并将其附加到其标准输入和标准输出。然后你可以通过写入标准输入向引擎发送命令,通过读取标准输出从引擎读取消息。为了帮助你入门,这里有一段简短的代码片段,用于启动引擎并附加到其通信通道:
class UciEngine : public QObject {
Q_OBJECT
public:
UciEngine(QObject *parent = 0) : QObject(parent) {
m_uciEngine = new QProcess(this);
m_uciEngine->setReadChannel(QProcess:StandardOutput);
connect(m_uciEngine, SIGNAL(readyRead()), SLOT(readFromEngine()));
}
public slots:
void startEngine(const QString &enginePath) {
m_uciEngine->start(enginePath);
}
void sendCommand(const QString &command) {
m_uciEngine->write(command.toLatin1());
}
private slots:
void readFromEngine() {
while(m_uciEngine->canReadLine()) {
QString line = QString::fromLatin1(m_uciEngine->readLine());
emit messageReceived(line);
}
}
signals:
void messageReceived(QString);
private:
QProcess *m_uciEngine;
};
OpenGL
我们不是 OpenGL 的专家,所以在本章的这一部分,我们不会教您如何使用 OpenGL 和 Qt 做任何花哨的事情,而是向您展示如何使您的 OpenGL 技能在 Qt 应用程序中使用。关于 OpenGL 有很多教程和课程,如果您对 OpenGL 的技能不是那么熟练,您仍然可以通过应用在这里获得的知识来更容易地学习花哨的事情。您可以使用外部材料和 Qt 提供的高级 API,这将加快教程中描述的许多任务的执行。
使用 Qt 的 OpenGL 简介
在 Qt 中使用 OpenGL 基本上有两种方式。第一种方法是使用 QOpenGLWidget。这通常在你应用程序严重依赖于其他小部件时很有用(例如,3D 视图只是你应用程序中的视图之一,并且通过围绕主视图的一堆其他小部件来控制)。另一种方法是使用 QOpenGLWindow;这在 GL 窗口是主导的甚至可能是程序唯一部分时最有用。这两个 API 非常相似;它们使用 QOpenGLContext 类的实例来访问 GL 上下文。它们之间的区别实际上仅在于它们将场景渲染到窗口的方式。QOpenGLWindow 直接渲染到指定的窗口,而 QOpenGLWidget 首先渲染到一个离屏缓冲区,然后该缓冲区被渲染到小部件上。后一种方法的优势在于 QOpenGLWidget 可以成为更复杂的小部件布局的一部分,而 QOpenGLWindow 通常用作唯一的、通常是全屏的窗口。在本章中,我们将使用更直接的方法(QOpenGLWindow);然而,请注意,您也可以使用小部件来完成这里描述的所有操作。只需将窗口类替换为它们的小部件等效类,您就应该可以开始了。
我们提到整个 API 都围绕着 QOpenGLContext 类展开。它代表了 GL 管道整体状态,指导数据处理和渲染到特定设备的过程。
需要解释的另一个相关概念是 GL 上下文在某个线程中是“当前”的。OpenGL 调用的方式是,它们不使用任何包含有关在哪里以及如何执行一系列低级 GL 调用的对象的句柄。相反,它们假定是在当前机器状态的环境中执行的。状态可能规定是否将场景渲染到屏幕或帧缓冲区对象,启用了哪些机制,或者 OpenGL 正在渲染的表面的属性。使上下文“当前”意味着所有由特定线程发出的后续 OpenGL 操作都将应用于此上下文。此外,上下文在同一时间只能在一个线程中“当前”;因此,在执行任何 OpenGL 调用之前使上下文“当前”,并在完成访问 OpenGL 资源后将其标记为可用,这一点非常重要。
QOpenGLWindow有一个非常简单的 API,它隐藏了大多数不必要的细节,对开发者来说。除了构造函数和析构函数之外,它还提供了一小部分非常有用的方法。首先,有一些辅助方法用于管理 OpenGL 上下文:context()返回上下文,以及makeCurrent()和doneCurrent()用于获取和释放上下文。该类剩余的方法是一系列我们可以重写的虚拟方法,用于显示 OpenGL 图形。
第一个方法被称为initializeGL(),框架在实际上进行任何绘画之前会调用它一次,以便你可以准备任何资源或以任何你需要的任何方式初始化上下文。
然后有两个最重要的方法:resizeGL()和paintGL()。第一个方法是在窗口大小改变时被调用的回调函数。它接受窗口的宽度和高度作为参数。你可以通过重写该方法来利用它,以便为其他方法paintGL()的调用做好准备,该方法将渲染不同大小的视口。说到paintGL(),这是小部件类中paintEvent()的等价方法;每当窗口需要重绘时,它都会被执行。这是你应该放置 OpenGL 渲染代码的函数。
行动时间 - 使用 Qt 和 OpenGL 绘制三角形
对于第一个练习,我们将创建一个QOpenGLWindow的子类,使用简单的 OpenGL 调用渲染一个三角形。从其他项目组中选择空 qmake 项目作为模板,创建一个新的项目。在项目文件中,输入以下内容:
QT = core gui
TARGET = triangle
TEMPLATE = app
基本项目设置就绪后,让我们定义一个SimpleGLWindow类作为QOpenGLWindow的子类,并重写initializeGL()方法,将白色设置为场景的清除颜色。我们通过调用名为glClearColor的 OpenGL 函数来实现这一点。Qt 提供了一个名为QOpenGLFunctions的便利类,它以*台无关的方式处理大多数常用的 OpenGL 函数。这是以*台无关的方式访问 OpenGLES 函数的推荐方法。我们的窗口将继承QOpenGLWindow和QOpenGLFunctions。然而,由于我们不希望允许外部访问这些函数,我们使用了保护继承。
class SimpleGLWindow : public QOpenGLWindow, protected QOpenGLFunctions {
public:
SimpleGLWindow(QWindow *parent = 0) : QOpenGLWindow(NoPartialUpdate, parent) { }
protected:
void initializeGL() {
initializeOpenGLFunctions();
glClearColor(1,1,1,0);
}
在initializeGL()中,我们首先调用initializeOpenGLFunctions(),这是QOpenGLFunctions类的一个方法,也是我们窗口类的一个基类。该方法负责根据当前 GL 上下文的参数设置所有函数(因此,首先使上下文成为当前上下文非常重要,幸运的是,在调用initializeGL()之前,这已经在幕后为我们完成了)。然后我们将场景的清除颜色设置为白色。
下一步是重写paintGL()并将实际的绘图代码放在那里:
void paintGL() {
glClear(GL_COLOR_BUFFER_BIT);
glViewport(0, 0, width(), height());
glBegin(GL_TRIANGLES);
glColor3f(1, 0, 0);
glVertex3f( 0.0f, 1.0f, 0.0f);
glColor3f(0, 1, 0);
glVertex3f( 1.0f,-1.0f, 0.0f);
glColor3f(0, 0, 1);
glVertex3f(-1.0f,-1.0f, 0.0f);
glEnd();
}
};
这个函数首先清除颜色缓冲区,并将上下文的 GL 视口设置为窗口的大小。然后我们告诉 OpenGL 使用glBegin()调用开始绘制,传递GL_TRIANGLES作为绘制模式。然后我们传递三个顶点及其颜色来形成一个三角形。最后,通过调用glEnd()通知管道我们已完成当前模式的绘制。
剩下的只是一个简单的main()函数,用于设置窗口并启动事件循环。添加一个新的C++源文件,命名为 main.cpp,并实现main()如下:
int main(int argc, char **argv) {
QGuiApplication app(argc, argv);
SimpleGLWindow window;
window.resize(600,400);
window.show();
return app.exec();
}

提示
你可以看到三角形有锯齿状的边缘。这是因为走样效应。你可以通过为窗口启用多采样来抵消它,这将使 OpenGL 多次渲染内容,然后*均结果,这起到抗锯齿的作用。为此,将以下代码添加到窗口的构造函数中:
QSurfaceFormat fmt = format();
fmt.setSamples(16); // multisampling set to 16
setFormat(fmt);
绘制彩色三角形很有趣,但绘制纹理立方体更有趣,所以让我们看看我们如何使用 OpenGL 纹理与 Qt 结合。
动手实践时间 – 基于场景的渲染
让我们把我们的渲染代码提升到一个更高的层次。直接将 OpenGL 代码放入window类需要子类化窗口类,并使窗口类变得越来越复杂。让我们遵循良好的编程实践,将渲染代码与窗口代码分开。
创建一个新的类,命名为AbstractGLScene。它将成为 OpenGL 场景定义的基类。你可以从QOpenGLFunctions派生这个类(具有保护作用),以便更容易访问不同的 GL 函数。让场景类接受一个指向QOpenGLWindow的指针,无论是在构造函数中还是在专门的设置器方法中。确保将指针存储在类中,以便更容易访问,因为我们将要依赖这个指针来访问窗口的物理属性。添加查询窗口 OpenGL 上下文的方法。最终,你的代码可能类似于以下内容:
class AbstractGLScene : protected QOpenGLFunctions {
public:
AbstractGLScene(QOpenGLWindow *wnd = 0) { m_window = wnd; }
QOpenGLWindow* window() const { return m_window; }
QOpenGLContext* context() { return window() ? window()->context() : 0;
}
const QOpenGLContext* context() const {
return window() ? window()->context() : 0;
}
private:
QOpenGLWindow *m_window = nullptr; // C++11 required for assignment
};
现在,最重要的部分开始了。添加两个纯虚方法,分别命名为paint()和initialize()。还要记得添加一个虚析构函数。
提示
你不必将initialize()实现为纯虚函数,你可以以这种方式实现其主体,使其调用initializeOpenGLFunctions()来满足QOpenGFunctions类的要求。然后,AbstractGLScene的子类可以通过调用基类的initialize()实现来确保函数被正确初始化。
接下来,创建一个QOpenGLWindow的子类,命名为SceneGLWindow。给它配备设置器和获取器方法,以便对象能够操作AbstractGLScene实例。
然后重新实现initializeGL()和paintGL()方法,并使它们调用场景中的适当等效方法:
void SceneGLWindow::initializeGL() { if(scene()) scene()->initialize(); }
void SceneGLWindow::paintGL() { if(scene()) scene()->paint(); }
发生了什么?
我们刚刚设置了一个类链,它将窗口代码与实际的 OpenGL 场景分开。窗口将所有与场景内容相关的调用转发到场景对象,以便当窗口被请求重绘时,它将任务委托给场景对象。请注意,在这样做之前,窗口将使 GL 上下文成为当前上下文;因此,场景所做的所有 OpenGL 调用都将与该上下文相关。您可以将在此练习中创建的代码存储起来,以供后续练习和自己的项目重用。
行动时间 - 绘制纹理立方体
继承AbstractGLScene并实现构造函数以匹配AbstractGLScene中的构造函数。添加一个方法来存储包含立方体纹理数据的QImage对象。同时添加一个QOpenGLTexture指针成员,它将包含纹理,在构造函数中将它初始化为 0,并在析构函数中删除它。让我们称图像对象为m_tex,纹理为m_texture。现在添加一个受保护的initializeTexture()方法,并用以下代码填充它:
void initializeTexture() {
m_texture = new QOpenGLTexture(m_tex.mirrored());
m_texture->setMinificationFilter(QOpenGLTexture::LinearMipMapLinear);
m_texture->setMagnificationFilter(QOpenGLTexture::Linear);
}
函数首先垂直翻转图像。这是因为 OpenGL 期望纹理是“颠倒的”。然后我们创建一个QOpenGLTexture对象,传递我们的图像。然后我们设置缩小和放大过滤器,以便在缩放时纹理看起来更好。
我们现在可以开始实现initialize()方法,该方法将负责设置纹理和场景本身。
void initialize() {
AbstractGLScene::initialize();
m_initialized = true;
if(!m_tex.isNull()) initializeTexture();
glClearColor(1,1,1,0);
glShadeModel(GL_SMOOTH);
}
我们使用一个名为m_initialized的标志。这个标志是必要的,以防止纹理设置得太早(当还没有 GL 上下文可用时)。然后我们检查纹理图像是否已设置(使用QImage::isNull()方法);如果是,我们初始化纹理。然后我们设置 GL 上下文的某些附加属性。
小贴士
在m_tex的设置器中,添加代码检查m_initialized是否设置为true,如果是,则调用initializeTexture()。这是为了确保无论设置器和initialize()调用的顺序如何,纹理都能正确设置。同时,记得在构造函数中将m_initialized设置为false。
下一步是准备立方体数据。我们将为立方体定义一个特殊的数据结构,该结构将顶点坐标和纹理数据组合在一个单独的对象中。为了存储坐标,我们将使用专门为此目的定制的类——QVector3D和QVector2D。
struct TexturedPoint {
QVector3D coord;
QVector2D uv;
TexturedPoint(const QVector3D& pcoord, const QVector2D& puv) { coord = pcoord; uv = puv; }
};
QVector<TexturedPoint>将保存整个立方体的信息。该向量使用以下代码初始化:
void CubeGLScene::initializeCubeData() {
m_data = {
// FRONT FACE
{{-0.5, -0.5, 0.5}, {0, 0}}, {{ 0.5, -0.5, 0.5}, {1, 0}},
{{ 0.5, 0.5, 0.5}, {1, 1}}, {{-0.5, 0.5, 0.5}, {0, 1}},
// TOP FACE
{{-0.5, 0.5, 0.5}, {0, 0}}, {{ 0.5, 0.5, 0.5}, {1, 0}},
{{ 0.5, 0.5, -0.5}, {1, 1}}, {{-0.5, 0.5, -0.5}, {0, 1}},
// BACK FACE
{{-0.5, 0.5, -0.5}, {0, 0}}, {{ 0.5, 0.5, -0.5}, {1, 0}},
{{ 0.5, -0.5, -0.5}, {1, 1}}, {{-0.5, -0.5, -0.5}, {0, 1}},
// BOTTOM FACE
{{-0.5, -0.5, -0.5}, {0, 0}}, {{ 0.5, -0.5, -0.5}, {1, 0}},
{{ 0.5, -0.5, 0.5}, {1, 1}}, {{-0.5, -0.5, 0.5}, {0, 1}},
// LEFT FACE
{{-0.5, -0.5, -0.5}, {0, 0}}, {{-0.5, -0.5, 0.5}, {1, 0}},
{{-0.5, 0.5, 0.5}, {1, 1}}, {{-0.5, 0.5, -0.5}, {0, 1}},
// RIGHT FACE
{{ 0.5, -0.5, 0.5}, {0, 0}}, {{ 0.5, -0.5, -0.5}, {1, 0}},
{{ 0.5, 0.5, -0.5}, {1, 1}}, {{ 0.5, 0.5, 0.5}, {0, 1}},
};
}
代码使用 C++11 语法来操作向量。如果你有一个较旧的编译器,你将不得不使用QVector::append()。
m_data.append(TexturedPoint(QVector3D(...), QVector2D(...)));
立方体由六个面组成,位于坐标系的原点。以下图像以图形形式展示了相同的数据。紫色图形是 UV 坐标空间中的纹理坐标。

initializeCubeData()应该从场景构造函数或从initialize()方法中调用。剩下的就是绘图代码。
void CubeGLScene::paint() {
glClear(GL_COLOR_BUFFER_BIT| GL_DEPTH_BUFFER_BIT);
glViewport(0, 0, window()->width(), window()->height());
glLoadIdentity();
glRotatef( 45, 1.0, 0.0, 0.0 );
glRotatef( 45, 0.0, 1.0, 0.0 );
glEnable(GL_DEPTH_TEST);
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
paintCube();
}
首先,我们设置视口,然后旋转视图。在调用paintCube()之前,该函数将渲染立方体本身,我们启用深度测试和面剔除,以便只绘制可见的面。paintCube()例程如下所示:
void CubeGLScene::paintCube() {
if(m_texture)
m_texture->bind();
glEnable(GL_TEXTURE_2D);
glBegin(GL_QUADS);
for(int i=0;i<m_data.size();++i) {
const TexturedPoint &pt = m_data.at(i);
glTexCoord2d(pt.uv.x(), pt.uv.y());
glVertex3f(pt.coord.x(), pt.coord.y(), pt.coord.z());
}
glEnd();
glDisable(GL_TEXTURE_2D);
}
首先绑定纹理并启用纹理映射。然后我们进入四边形绘制模式,并从我们的数据结构中流式传输数据。最后,再次禁用纹理映射。
为了完整性,这里是一个执行场景的main()函数:
int main(int argc, char **argv) {
QGuiApplication app(argc, argv);
SceneGLWindow window;
QSurfaceFormat fmt;
fmt.setSamples(16);
window.setFormat(fmt);
CubeGLScene scene(&window);
window.setScene(&scene);
scene.setTexture(QImage(":/texture.jpg"));
window.resize(600,600);
window.show();
return app.exec();
}
请注意使用QSurfaceFormat为场景启用多采样抗锯齿。我们还将纹理图像放入资源文件中,以避免文件相对路径的问题。
尝试英雄 - 动画一个立方体
尝试修改代码以使立方体动画化。为此,让场景继承QObject,向其中添加一个类型为float的角度属性(记得关于Q_OBJECT宏)。然后修改glRotatef()中的一行,使用角度值而不是常数值。在main()中,在调用app.exec()之前放入以下代码:
QPropertyAnimation anim(&scene, "angle");
anim.setStartValue(0);
anim.setEndValue(359);
anim.setDuration(5000);
anim.setLoopCount(-1);
anim.start();
记得在角度属性的 setter 中调用window()->update(),以便重新绘制场景。
带 Qt 的现代 OpenGL
上一节中显示的 OpenGL 代码使用了一种非常古老的技术,即逐个将顶点流式传输到一个固定的 OpenGL 管道中。如今,现代硬件功能更加丰富,不仅允许更快地处理顶点数据,而且还提供了使用可重编程单元(着色器)调整不同处理阶段的能力。在本节中,我们将探讨 Qt 在“现代”OpenGL 使用方法领域的提供内容。
着色器
Qt 可以通过基于QOpenGLShaderProgram的一系列类来使用着色器。这个类允许编译、链接和执行用 GLSL 编写的着色器程序。你可以通过检查静态QOpenGLShaderProgram::hasOpenGLShaderPrograms()调用的结果来检查你的 OpenGL 实现是否支持着色器。所有现代硬件和所有不错的图形驱动程序都应该对着色器有一些支持。一个着色器由QOpenGLShader类的实例表示。使用它,你可以决定着色器的类型,关联和着色器源代码。后者是通过调用QOpenGLShader::compileSourceCode()来完成的,它有几个重载来处理不同的输入格式。
Qt 支持所有类型的着色器,其中最常见的是顶点着色器和片段着色器。这些都是经典 OpenGL 管道的一部分。你可以在以下图中看到管道的示意图:

当你定义了一组着色器后,你可以通过使用 QOpenGLShaderProgram::addShader() 来组装一个完整的程序。在所有着色器都添加完毕后,你可以 link() 程序并将其 bind() 到当前的 GL 上下文中。程序类提供了一系列方法来设置不同输入参数的值——包括单值和数组版本的统一变量和属性。Qt 提供了其自身类型(如 QSize 或 QColor)与 GLSL 对应类型(例如,vec2 和 vec4)之间的映射,以使程序员的开发工作更加轻松。
使用着色器进行渲染的典型代码流程如下(首先创建并编译一个顶点着色器):
QOpenGLShader vertexShader(QOpenGLShader::Vertex);
QByteArray code = "uniform vec4 color;\n"
"uniform highp mat4 matrix;\n"
"void main(void) { gl_Position = gl_Vertex*matrix; }";
vertexShader.compileSourceCode(code);
该过程对片段着色器重复进行:
QOpenGLShader fragmentShader(QOpenGLShader::Fragment);
code = "uniform vec4 color;\n"
"void main(void) { gl_FragColor = color; }";
fragmentShader.compileSourceCode(code);
然后将着色器链接到给定 GL 上下文中的单个程序中:
QOpenGLShaderProgram program(context);
program.addShader(vertexShader);
program.addShader(fragmentShader);
program.link();
每次使用程序时,它都应绑定到当前 GL 上下文并填充所需数据:
program.bind();
QMatrix4x4 m = …;
QColor color = Qt::red;
program.setUniformValue("matrix", m);
program.setUniformValue("color", color);
之后,激活渲染管道的调用将使用绑定的程序:
glBegin(GL_TRIANGLE_STRIP);
…
glEnd();
是时候进行着色物体操作了
让我们将最后一个程序转换为使用着色器。为了使立方体更好,我们将使用 Phong 算法实现*滑光照模型。同时,我们将学习使用 Qt 为 OpenGL 提供的一些辅助类。
本小项目的目标如下:
-
使用顶点和片段着色器来渲染复杂对象
-
处理模型、视图和投影矩阵
-
使用属性数组进行快速绘制
首先,创建一个 AbstractGLScene 的新子类。让我们给它以下接口:
class ShaderGLScene : public QObject, public AbstractGLScene {
Q_OBJECT
public:
ShaderGLScene(SceneGLWindow *wnd);
void initialize();
void paint();
protected:
void initializeObjectData();
private:
struct ScenePoint {
QVector3D coords;
QVector3D normal;
ScenePoint(const QVector3D &c, const QVector3D &n);
};
QOpenGLShaderProgram m_shader;
QMatrix4x4 m_modelMatrix;
QMatrix4x4 m_viewMatrix;
QMatrix4x4 m_projectionMatrix;
QVector<ScenePoint> m_data;
};
与之前的项目相比,类接口有显著的变化。在这个项目中我们不使用纹理,因此 TexturedPoint 被简化为 ScenePoint,并移除了 UV 纹理坐标。
我们可以从 initializeObjectData() 函数开始实现接口。我们不会逐行解释方法体做了什么。你可以按自己的意愿实现它;重要的是确保该方法将有关顶点和它们法线的信息填充到 m_data 成员中。
小贴士
在本书附带示例代码中,你可以找到使用 Blender 3D 程序生成的 PLY 格式文件加载数据的代码。要从 Blender 导出模型,请确保它仅由三角形组成(为此,选择模型,按 Tab 键进入编辑模式,使用 Ctrl + F 打开 面 菜单,并选择 三角化面)。然后点击 文件 和 导出;选择 斯坦福 (.ply)。你将得到一个包含顶点和法线数据以及顶点面定义的文本文件。
你可以始终重用之前项目中使用的立方体对象。但请注意,它的法线没有正确计算以进行*滑着色;因此,你必须纠正它们。
在我们可以设置着色器程序之前,我们必须了解实际的着色器是什么样的。着色器代码将从外部文件加载,因此第一步是为项目添加一个新文件。在 Creator 中,点击文件并选择新建文件或项目;从底部面板中选择GLSL,然后从可用模板列表中选择顶点着色器(桌面 OpenGL)。将新文件命名为phong.vert并输入以下代码:
uniform highp mat4 modelViewMatrix;
uniform highp mat3 normalMatrix;
uniform highp mat4 projectionMatrix;
uniform highp mat4 mvpMatrix;
attribute highp vec4 Vertex;
attribute mediump vec3 Normal;
varying mediump vec3 N;
varying highp vec3 v;
void main(void) {
N = normalize(normalMatrix * Normal);
v = vec3(modelViewMatrix * Vertex);
gl_Position = mvpMatrix*Vertex;
}
代码非常简单。我们声明了四个矩阵,分别代表场景坐标映射的不同阶段。我们还定义了两个输入属性——Vertex和Normal——它们包含顶点数据。着色器将输出两份数据——一个归一化的顶点法线和从相机视角看到的变换后的顶点坐标。当然,除此之外,我们还将gl_Position设置为最终的顶点坐标。在每种情况下,我们都希望符合 OpenGL/ES 规范,因此在每个变量声明前加上一个精度指定符。
接下来,添加另一个文件,命名为phong.frag,并将其设置为片段着色器(桌面 OpenGL)。文件的内容是典型的环境、漫反射和镜面反射计算:
struct Material {
lowp vec3 ka;
lowp vec3 kd;
lowp vec3 ks;
lowp float shininess;
};
struct Light {
lowp vec4 position;
lowp vec3 intensity;
};
uniform Material mat;
uniform Light light;
varying mediump vec3 N;
varying highp vec3 v;
void main(void) {
mediump vec3 n = normalize(N);
highp vec3 L = normalize(light.position.xyz - v);
highp vec3 E = normalize(-v);
mediump vec3 R = normalize(reflect(-L, n));
lowp float LdotN = dot(L, n);
lowp float diffuse = max(LdotN, 0.0);
lowp vec3 spec = vec3(0,0,0);
if(LdotN > 0.0) {
float RdotE = max(dot(R, E), 0.0);
spec = light.intensity*pow(RdotE, mat.shininess);
}
vec3 color = light.intensity * (mat.ka + mat.kd*diffuse + mat.ks*spec);
gl_FragColor = vec4(color, 1.0);
}
除了使用两个变化变量来获取插值后的法线(N)和片段(v)位置外,着色器还声明了两个结构来保存光和材料信息。不深入着色器本身的工作细节,它计算三个组件——环境光、漫射光和镜面反射——将它们相加,并将结果设置为片段颜色。由于所有顶点输入数据都会为每个片段进行插值,因此最终颜色是针对每个像素单独计算的。
一旦我们知道着色器期望什么,我们就可以设置着色器程序对象。让我们看一下initialize()方法:
void initialize() {
AbstractGLScene::initialize();
glClearColor(0,0,0,0);
首先,我们调用基类实现并设置场景的背景颜色为黑色,如下面的代码所示:
m_shader.addShaderFromSourceCode(QOpenGLShader::Vertex, fileContent("phong.vert"));
m_shader.addShaderFromSourceCode(QOpenGLShader::Fragment, fileContent("phong.frag"));
m_shader.link();
然后我们向程序中添加两个着色器,使用一个名为fileContent()的自定义辅助函数从外部文件中读取它们的源代码。这个函数本质上会打开一个文件并返回其内容。然后我们链接着色器程序。link()函数返回一个布尔值,但为了简单起见,这里我们跳过了错误检查。下一步是为着色器准备所有输入数据,如下所示:
m_shader.bind();
m_shader.setAttributeArray("Vertex", GL_FLOAT, m_data.constData(), 3, sizeof(ScenePoint));
m_shader.enableAttributeArray("Vertex");
m_shader.setAttributeArray("Normal", GL_FLOAT, &m_data[0].normal, 3, sizeof(ScenePoint));
m_shader.enableAttributeArray("Normal");
m_shader.setUniformValue("material.ka", QVector3D(0.1, 0, 0.0));
m_shader.setUniformValue("material.kd", QVector3D(0.7, 0.0, 0.0));
m_shader.setUniformValue("material.ks", QVector3D(1.0, 1.0, 1.0));
m_shader.setUniformValue("material.shininess", 128.0f);
m_shader.setUniformValue("light.position", QVector3D(2, 1, 1));
m_shader.setUniformValue("light.intensity", QVector3D(1,1,1));
首先,将着色器程序绑定到当前上下文,以便我们可以对其操作。然后我们启用设置两个属性数组——一个用于顶点坐标,另一个用于它们的法线。我们通知程序,一个名为Vertex的属性由三个GL_FLOAT类型的值组成。第一个值位于m_data.constData(),下一个顶点的数据位于当前点数据sizeof(ScenePoint)字节之后。然后我们对Normal属性有类似的声明,唯一的区别是第一个数据块放置在&m_data[0].normal。通过通知程序数据布局,我们允许它在需要时快速读取所有顶点信息。
在设置属性数组之后,我们将统一变量的值传递给着色器程序,这完成了着色器程序的设置。你会注意到我们没有为表示各种矩阵的统一变量设置值;我们将为每次重绘分别设置。paint()方法负责设置所有矩阵:
void ObjectGLScene::paint() {
m_projectionMatrix.setToIdentity();
qreal ratio = qreal(window()->width()) / qreal(window()->height());
m_projectionMatrix.perspective(90, ratio, 0.5, 40); // angle, ratio, near plane, far plane
m_viewMatrix.setToIdentity();
QVector3D eye = QVector3D(0,0,2);
QVector3D center = QVector3D(0,0,0);
QVector3D up = QVector3D(0, 1, 0);
m_viewMatrix.lookAt(eye, center, up);
在这个方法中,我们大量使用了表示 4 x 4 矩阵的QMatrix4x4类,该矩阵以所谓的行主序排列,适合与 OpenGL 一起使用。一开始,我们重置投影矩阵,并使用perspective()方法根据当前窗口大小给它一个透视变换。之后,视图矩阵也被重置,并使用lookAt()方法为摄像机准备变换;中心值表示视图眼睛所看的中心。up向量指定了摄像机的垂直方向(相对于眼睛位置)。
接下来的几行与上一个项目中的类似:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glViewport(0, 0, window()->width(), window()->height());
glEnable(GL_DEPTH_TEST);
glEnable(GL_CULL_FACE);
glCullFace(GL_BACK);
之后,我们进行对象的实际绘制:
m_modelMatrix.setToIdentity();
m_modelMatrix.rotate(45, 0, 1, 0);
QMatrix4x4 modelViewMatrix = m_viewMatrix*m_modelMatrix;
paintObject(modelViewMatrix);
}
我们首先设置模型矩阵,它决定了渲染对象相对于世界中心的位置(在这种情况下,我们说它是绕 y 轴旋转了 45 度)。然后我们组装模型视图矩阵(表示对象相对于摄像机的位置)并将其传递给paintObject()方法,如下所示:
void paintCube(const QMatrix4x4& mvMatrix) {
m_shader.bind();
m_shader.setUniformValue("projectionMatrix", m_projectionMatrix);
m_shader.setUniformValue("modelViewMatrix", mvMatrix);
m_shader.setUniformValue("mvpMatrix", m_projectionMatrix*mvMatrix);
m_shader.setUniformValue("normalMatrix", mvMatrix.normalMatrix());
const int pointCount = m_data.size();
glDrawArrays(GL_TRIANGLES, 0, pointCount);
}
这个方法非常简单,因为大部分工作都是在设置着色器程序时完成的。首先激活着色器程序。然后设置所有所需的矩阵作为着色器的统一变量。包括从模型视图矩阵计算出的法线矩阵。最后,发出调用glDrawArrays(),告诉它以GL_TRIANGLES模式使用活动数组进行渲染,从数组的开始(偏移0)读取pointCount个实体。
运行项目后,你应该得到一个类似于以下的结果,它恰好包含了 Blender 猴子,Suzanne:

GL 缓冲区
使用属性数组可以加快编程速度,但在渲染时,所有数据仍然需要在每次使用时复制到图形卡上。这可以通过 OpenGL 缓冲区对象来避免。Qt 通过其QOpenGLBuffer类提供了一个方便的接口。目前支持的缓冲区类型包括顶点缓冲区(其中缓冲区包含顶点信息)、索引缓冲区(其中缓冲区的内容是一组索引,可以与glDrawElements()一起使用),以及较少使用的像素打包缓冲区和像素解包缓冲区。缓冲区本质上是一块内存,可以上传到图形卡并存储在那里以实现更快的访问。有不同可用使用模式,这些模式规定了缓冲区如何在主机内存和 GPU 内存之间传输以及何时传输。最常见模式是一次性将顶点信息上传到 GPU,以后在渲染过程中可以多次引用。将使用属性数组的现有应用程序更改为使用顶点缓冲区非常简单。首先需要一个缓冲区实例:
QOpenGLBuffer vbo(QOpenGLBuffer::VertexBuffer);
然后需要设置其使用模式。在一次性上传的情况下,最合适的类型是StaticDraw,如下所示:
vbo.setUsagePattern(QOpenGLBuffer::StaticDraw);
然后需要为当前上下文创建缓冲区本身:
context->makeCurrent(this);
vbo.create();
下一步是实际为缓冲区分配一些内存:
vbo.allocate(vertexCount*sizeof(ScenePoint));
要将数据写入缓冲区,有两种选择。首先,您可以通过调用map()将缓冲区附加到应用程序的内存空间,然后使用返回的指针填充数据:
ScenePoint *buffer = static_cast<ScenePoint*>(vbo.map(QOpenGLBuffer::WriteOnly));
assert(buffer!=0);
for(int i=0;i<vbo.size();++i) { buffer[i] = m_data[i]; }
vbo.unmap();
一种替代方法是直接使用write()将数据写入缓冲区:
const int spSize = sizeof(ScenePoint);
for(int i=0;i<vbo.size();++i) { vbo.write (i*spSize, &m_data[i], spSize); }
最后,缓冲区可以以类似于属性数组的方式在着色器程序中使用:
vbo.bind();
m_shader.setAttributeBuffer("Vertex"", GL_FLOAT, 0, 3, sizeof(ScenePoint));
m_shader.setAttributeBuffer("Normal"", GL_FLOAT, sizeof(QVector3D), 3, sizeof(ScenePoint));
结果是,所有数据都一次性上传到 GPU,然后根据当前着色器程序或其他支持缓冲区对象的 OpenGL 调用按需使用。
离屏渲染
有时,将 GL 场景渲染到屏幕之外而不是屏幕上是有用的,这样可以将图像稍后外部处理或用作渲染其他部分的纹理。为此,创建了帧缓冲对象(FBO)的概念。FBO 是一个渲染表面,其行为类似于常规设备帧缓冲区,唯一的区别是生成的像素不会出现在屏幕上。FBO 目标可以作为纹理绑定到现有场景中,或者作为图像存储在常规计算机内存中。在 Qt 中,此类实体由QOpenGLFramebufferObject类表示。
一旦您有一个当前的 OpenGL 上下文,您可以使用可用的构造函数之一创建QOpenGLFramebufferObject的实例。必须传递的强制参数是画布的大小(可以是QSize对象,也可以是一对整数,描述帧的宽度和高度)。不同的构造函数接受其他参数,例如 FBO 要生成的纹理类型或封装在QOpenGLFramebufferObjectFormat中的参数集。
当对象被创建时,你可以在其上发出一个bind()调用,这将切换 OpenGL 管道以渲染到 FBO 而不是默认目标。一个互补的方法是release(),它将恢复默认渲染目标。之后,可以通过调用texture()方法查询 FBO 以返回 OpenGL 纹理的 ID,或者通过调用toImage()将纹理转换为QImage。
摘要
在本章中,我们学习了如何使用 Qt 进行图形处理。你应该意识到,关于 Qt 在这方面我们只是触及了皮毛。本章所介绍的内容将帮助你实现自定义小部件,对图像进行一些基本的绘制,以及渲染 OpenGL 场景。还有很多其他的功能我们没有涉及,例如合成模式、路径、SVG 处理等。我们将在后续章节中回顾一些这些功能,但大部分我们将留给你自己探索。
在下一章中,我们将学习一种更面向对象的方法来进行图形处理,称为图形视图。
第六章:图形视图
小部件非常适合设计图形用户界面。然而,如果你希望在应用程序中同时通过不断移动它们来动画化多个小部件,你可能会遇到问题。在这些情况下,或者更一般地说,对于经常变换 2D 图形,Qt 为你提供了图形视图。在本章中,你将学习图形视图架构及其项目的基本知识。你还将学习如何将小部件与图形视图项目结合使用。一旦你掌握了基础知识,我们接下来将开发一个简单的跳跃跑酷游戏,展示如何动画化项目。最后,我们将探讨一些优化图形视图性能的可能性。
图形视图架构
三个组件构成了图形视图的核心:一个QGraphicsView的实例,被称为视图;一个QGraphicsScene的实例,被称为场景;以及通常多个QGraphicsItem的实例,被称为项目。通常的工作流程是首先创建几个项目,然后将它们添加到场景中,最后将场景设置在视图上。
在下一节中,我们将依次讨论图形视图架构的三个部分,首先是项目,然后是场景,最后是视图。

图形视图组件的示例
然而,由于无法将一个组件完全独立于其他组件来处理,你需要一开始就了解整体情况。这将帮助你更好地理解三个单独部分的描述。如果你第一次出现时没有完全理解所有细节,请不要担心。要有耐心,完成这三个部分的工作,希望最终所有问题都会变得清晰。
将这些项目想象成便利贴。你可以在上面写信息,画图像,或者两者都做,或者,很可能是直接留空。这相当于创建了一个具有定义的绘制函数的项目,无论是默认的函数还是你自定义的函数。由于项目没有预定的尺寸,你需要在其中完成所有绘制操作的定义边界矩形。就像便利贴一样,它不关心自己的位置或从哪个角度被观察,项目总是以未变换的状态绘制其内容,其中长度单位对应于 1 像素。项目存在于自己的坐标系中。尽管你可以对项目应用各种变换,如旋转和缩放,但这不是项目绘制函数的工作;那是场景的工作。
那么,场景是什么呢?好吧,把它想象成一张更大的纸,你在上面贴上你的小便签,也就是笔记。在场景中,你可以自由地移动项目,并对它们应用有趣的变换。显示项目的位置和任何应用到的变换是场景的责任。场景还会通知项目任何影响它们的事件,并且它像项目一样有一个边界矩形,项目可以在这个矩形内定位。
最后但同样重要的是,让我们把注意力转向视图。把视图想象成一个检查窗口或者一个手里拿着带有笔记的纸张的人。你可以整体观察纸张,或者只看特定的部分。就像人可以用手旋转和剪切纸张一样,视图也可以旋转和剪切场景,并对它进行很多其他变换。
注意
你可能会看前面的图并担心所有项目都在视图之外。它们不是在浪费 GPU 渲染时间吗?你不需要通过添加所谓的“视图视锥剔除”机制(检测哪些项目不可见,因此不需要绘制/渲染)来照顾它们吗?嗯,简短的答案是“不”,因为 Qt 已经处理了这一点。
项目
那么,让我们来看看这些项目。在图形视图中,项目的最基本特征是它们的面向对象方法。场景中的所有项目都必须继承自QGraphicsItem,这是一个具有众多其他公共函数的抽象类,其中包括两个纯虚函数,分别叫做boundingRect()和paint()。正因为这个简单而明确的事实,有一些原则适用于每个项目。
父子关系
QGraphicsItem的构造函数接受另一个项目的指针,该指针被设置为项目的父项。如果指针是0,则项目没有父项。这给了你机会以类似于QObject对象的结构来组织项目,尽管QGraphicsItem元素并不继承自QObject对象。你可以通过调用setParentItem()函数在任何给定时间改变项目之间的关系。如果你想从父项中移除子项目,只需在子项目上调用setParentItem(0)函数。以下代码说明了创建项目之间关系的两种可能性。(请注意,这段代码将无法编译,因为QGraphicsItem是一个抽象类。这里只是为了说明,但它将适用于真实的项目类。)
QGraphicsItem *parentItem = new QGraphicsItem();
QGraphicsItem *firstChildItem = new QGraphicsItem(parentItem);
QGraphicsItem *secondChildItem = new QGraphicsItem();
secondChildItem->setParentItem(parentItem);
delete parentItem;
首先,我们创建一个名为parentItem的项,因为我们没有使用构造函数的参数,所以这个项没有父项或子项。接下来,我们创建另一个名为firstChildItem的项,并将parentItem项的指针作为参数传递。因此,它以parentItem项作为其父项,而parentItem项现在以firstChildItem项作为其子项。接下来,我们创建一个名为secondChildItem的第三项,但由于我们没有将其传递给构造函数,所以它目前没有父项。然而,在下一行中,我们通过调用setParentItem()函数来改变这一点。现在它也是parentItem项的子项。
小贴士
你可以使用parentItem()函数始终检查一个项是否有父项,并将返回的QGraphicsItem指针与0进行比较,这意味着该项没有父项。要找出是否有任何子项,请在项上调用childItems()函数。它返回一个包含所有子项的QList方法。

父子关系
这种父子关系的优点是,在父项上执行的具体操作也会影响相关的子项。例如,当你删除父项时,所有子项也将被删除。因此,在前面代码中删除parentItem项就足够了。firstChildItem和secondChildItem项的析构函数将隐式调用。当你从场景中添加或删除父项时,也是如此。所有子项随后也将被添加或删除。当你隐藏父项或移动父项时,这也适用。在这两种情况下,子项的行为将与父项相同。想想之前提到的便利贴;它们会有相同的行为。如果你有一个带有其他便利贴附加的便签,当你移动父便签时,它们也会移动。
小贴士
如果你不确定对父项的函数调用是否传播到其子项,你总是可以查看源代码。如果你在安装时选择了安装源代码的选项,你可以在你的 Qt 安装中找到它们。你也可以在网上找到它们,网址为github.com/qtproject/qtbase。
即使没有有意义的注释,你也容易找到相关的代码。只需寻找通过 d-pointer 访问的children变量。在QGraphicsItem项的析构函数中,相关的代码片段如下:
if (!d_ptr->children.isEmpty()) {
while (!d_ptr->children.isEmpty())
delete d_ptr->children.first();
Q_ASSERT(d_ptr->children.isEmpty());
}
外观
你可能想知道一个QGraphicsItem项看起来是什么样子。嗯,因为它是一个抽象类(而且不幸的是,绘制函数是一个纯虚函数),所以它看起来什么都没有。你将不得不自己完成所有的绘制工作。幸运的是,由于QGraphicsItem项的绘制函数为你提供了一个你已知的技巧,即QPainter指针,所以这并不困难。
别慌!你不必自己绘制所有项目。Qt 提供了许多标准形状的项目,你可以直接使用。你将在名为 标准项目 的下一节中找到它们的讨论。然而,由于我们偶尔需要绘制自定义项目,我们通过这个过程进行。
行动时间 - 创建一个黑色矩形项目
作为第一步,让我们创建一个绘制黑色矩形的项:
class BlackRectangle : public QGraphicsItem {
public:
explicit BlackRectangle(QGraphicsItem *parent = 0)
: QGraphicsItem(parent) {}
virtual ~BlackRectangle() {}
QRectF boundingRect() const {
return QRectF(0, 0, 75, 25);
}
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
Q_UNUSED(option)
Q_UNUSED(widget)
painter->fillRect(boundingRect(), Qt::black);
}
};
刚才发生了什么?
首先,我们继承 QGraphicItem 并将新类命名为 BlackRectangle。类的构造函数接受一个指向 QGraphicItem 项目的指针。然后,这个指针被传递给 QGraphicItem 项目的构造函数。我们不必担心它;QGraphicItem 将会处理它,并为我们项目建立父子关系,以及其他事情。接下来,虚拟析构函数确保即使在通过基类指针删除类的情况下也会被调用。这是一个关键点,你将在我们讨论场景时学到这一点。
接下来,我们定义我们项目的 boundingRect() 函数,其中我们返回一个宽度为 75 像素、高度为 25 像素的矩形。这个返回的矩形是 paint 方法的画布,同时也是对场景的承诺,即项目将只在这个区域内绘制。场景依赖于该信息的正确性,因此你应该严格遵守这个承诺。否则,场景将充满你绘制的遗迹!
最后,我们从 QPainter 和 QWidget 项目结合进行实际绘画。这里没有其他不同之处,只是画家已经通过第一个参数给出的适当值初始化。即使不需要,我也建议在函数结束时保持画家处于与开始时相同的状态。如果你遵循这个建议,并且只使用场景中的自定义项目,你可以在以后极大地优化渲染速度。这尤其适用于项目众多的场景。但让我们回到我们实际上在做什么。我们已经取出了画家并调用了 fillRect() 函数,这个函数不会影响画家的内部状态。作为参数,我们使用了 boundingRect() 函数,它定义了要填充的区域,以及 Qt::black 参数,它定义了填充颜色。因此,通过只填充项目的边界矩形,我们遵守了边界矩形的承诺。
在我们的例子中,我们没有使用 paint 函数的两个其他参数。为了抑制编译器关于未使用变量的警告,我们使用了 Qt 的 Q_UNUSED 宏。
行动时间 - 对项目选择状态的响应
如果你想改变与项目状态相关的项目的外观,分配给 QStyleOptionGraphicsItem 项目的指针可能会很有用。例如,假设你想在项目被选中时用红色填充矩形。为此,你只需输入以下内容:
void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
Q_UNUSED(widget)
if (option->state & QStyle::State_Selected)
painter->fillRect(boundingRect(), Qt::red);
else
painter->fillRect(boundingRect(), Qt::black);
}
刚才发生了什么?
state 变量是一个位掩码,包含项目的可能状态。您可以使用位运算符将其值与 QStyle::StateFlag 参数的值进行比较。在前面的例子中,state 变量被检查与 State_Selected 参数。如果此标志被设置,则矩形将被绘制为红色。
小贴士
状态的类型是 QFlags<StateFlag>。因此,您不需要使用位运算符来测试标志是否设置,而是可以使用方便的函数 testFlag()。使用前面的示例,它将是这样的:
if (option->state.testFlag(QStyle::State_Selected))
您可以使用的项目最重要的状态在以下表中描述:
| 状态 | 描述 |
|---|---|
State_Enabled |
表示项目处于启用状态。如果项目被禁用,您可能希望将其绘制为灰色。 |
State_HasFocus |
表示项目具有输入焦点。要接收此状态,项目需要将 ItemIsFocusable 标志设置为。 |
State_MouseOver |
表示光标当前悬停在项目上。要接收此状态,项目需要将 acceptHoverEvents 变量设置为 true。 |
State_Selected |
表示项目被选中。要接收此状态,项目需要将 ItemIsSelectable 标志设置为。正常情况下,会绘制一个虚线围绕项目作为选择标记。 |
除了状态之外,QStyleOptionGraphicsItem 还提供了关于当前使用样式的更多信息,例如使用的调色板和字体,分别通过 QStyleOptionGraphicsItem::palette 和 QStyleOptionGraphicsItem::fontMetrics 参数访问。如果您旨在实现样式感知的项目,请在文档中更深入地了解此类。
行动时间 - 使项目的大小可定义
让我们把黑色矩形的例子再进一步。到目前为止,BlackRectangle 绘制了一个固定大小的 75 x 25 像素的矩形。如果能定义这个大小会很好,所以让我们添加定义矩形大小的功能。记住,仅仅将矩形画得更大在这里没有帮助,因为那样你会打破关于边界矩形的承诺。因此,我们还需要按照以下方式更改边界矩形:
class BlackRectangle : public QGraphicsItem {
public:
BlackRectangle(QGraphicsItem *parent = 0)
: QGraphicsItem(parent), m_rect(0, 0, 75, 25) {}
//...
QRectF boundingRect() const {
return m_rect;
}
//...
QRectF rect() const {
return m_rect;
}
void setRect(const QRectF& rect) {
if (rect == m_rect)
return;
prepareGeometryChange();
m_rect = rect;
}
private:
QRectF m_rect;
};
刚才发生了什么?
由于析构函数和 paint 函数没有变化,因此省略了它们。我们在这里到底做了什么?首先,我们引入了一个名为 m_rect 的私有成员,用于保存当前矩形的值。在初始化列表中,我们将 m_rect 设置为默认值 QRectF(0, 0, 75, 25),就像我们在第一个示例中硬编码的那样。由于边界矩形应该与 m_rect 相同,我们修改了 boundingRect() 以返回 m_rect。获取器函数 rect() 也返回相同的值。目前,似乎有两个函数返回相同的值是多余的,但一旦您在矩形周围绘制边界,就需要返回一个不同的边界矩形。它需要增加所使用的笔的宽度。因此,我们保留这种冗余,以便于进一步改进。最后新的部分是设置函数,它相当标准。我们检查值是否已更改,如果没有,则退出函数。否则,我们设置一个新的值,但必须在 prepareGeometryChange() 调用之后进行。这个调用很重要,因为它会通知场景即将发生几何变化。然后,场景会要求项目重新绘制自己。我们不需要处理这部分。
尝试一下英雄 - 定制项目
作为练习,您可以尝试添加一个选项来更改背景颜色。您还可以创建一个新的项目,允许您设置一个图像。如果这样做,请记住,您必须根据图像的大小更改项目的边界矩形。
标准项目
正如您所看到的,创建自己的项目需要一些工作,但总体来说并不困难。一个很大的优势是您可以使用 QPainter 来绘制项目,这与您用于绘制小部件的技术相同。因此,您不需要学习任何新的东西。确实,虽然绘制填充矩形或其他任何形状很容易,但每次需要创建执行此类基本任务的项目时,都要子类化 QGraphicsItem 是一项大量工作。这就是为什么 Qt 提供以下标准项目,使您作为开发者的生活变得更加容易:
| 标准项目 | 描述 |
|---|---|
QGraphicsLineItem |
绘制简单线条。您可以使用 setLine(const QLineF&) 定义线条。 |
QGraphicsRectItem |
绘制矩形。您可以使用 setRect(const QRectF&) 定义矩形的几何形状。 |
QGraphicsEllipseItem |
绘制椭圆。您可以使用 setRect(const QRectF&) 定义绘制椭圆的矩形。此外,您还可以通过调用 setStartAngle(int) 和 setSpanAngle(int) 来定义是否只绘制椭圆的某一段。这两个函数的参数是以度数的十六分之一表示的。 |
QGraphicsPolygonItem |
绘制多边形。您可以使用 setPolygon(const QPolygonF&) 定义多边形。 |
QGraphicsPathItem |
绘制路径。您可以使用 setPath(const QPainterPath&) 定义路径。 |
QGraphicsSimpleTextItem |
绘制简单的文本路径。您可以使用 setText(const QString&) 定义文本,并使用 setFont(const QFont&) 定义字体。此项目仅用于绘制不带任何修改的 纯文本。 |
QGraphicsTextItem |
绘制文本。与 QGraphicsSimpleTextItem 不同,此项目可以显示 HTML 或渲染 QTextDocument 元素。您可以使用 setHtml(const QString&) 设置 HTML,并使用 setDocument(QTextDocument*) 设置文档。QGraphicsTextItem 甚至可以与显示的文本进行交互,以便实现文本编辑或 URL 打开。 |
QGraphicsPixmapItem |
绘制位图。您可以使用 setPixmap(const QPixmap&) 定义位图。 |
由于这些项目的绘制是通过 QPainter 指针完成的,因此您也可以定义应该使用哪种笔和哪种刷子。笔是通过 setPen(const QPen&) 设置的,刷是通过 setBrush(const QBrush&) 设置的。然而,这两个函数并不适用于 QGraphicsTextItem 和 QGraphicsPixmapItem。要定义 QGraphicsTextItem 项目的外观,您必须使用 setDefaultTextColor() 或 Qt 支持的 HTML 标签。请注意,位图通常没有笔或刷。
小贴士
在可能的情况下使用 QGraphicsSimpleTextItem,并尽量在绝对必要时才使用 QGraphicsTextItem。原因是 QGraphicsTextItem 带有一个 QTextDocument 对象,它不仅是 QGraphicsItem 的子类,也是 QObject 的子类。这无疑增加了太多的开销,并且对于显示简单文本来说性能成本过高。
关于如何设置项目的说明。而不是写两个表达式,一个用于初始化项目,另一个用于设置其关键信息,例如 QGraphicsRextItem 项目的矩形或 QGraphicsPixmapItem 项目的位图,几乎所有标准项目都提供了将关键信息作为第一个参数传递给其构造函数的选项——除了用于设置项目父级的可选最后一个参数。比如说,您可能会写出以下代码:
QGraphicsRectItem *item = new QGraphicsRectItem();
item->setRect(QRectF(0, 0, 25, 25));
您现在可以简单地这样写:
QGraphicsRectItem *item = new QGraphicsRectItem(QRectF(0, 0, 25, 25));
您甚至可以简单地这样写:
QGraphicsRectItem *item = new QGraphicsRectItem(0, 0, 25, 25);
这非常方便,但请记住,紧凑的代码可能比通过设置器方法设置所有变量的代码更难维护。
项目的坐标系
最后但同样重要的一点是关于所使用的坐标系。总的来说,图形视图处理三个不同但相互关联的坐标系。这里有项目的坐标系、场景的坐标系和视图的坐标系。这三个坐标系在 y 轴上与笛卡尔坐标系不同:在图形视图中,就像在 QPainter 指针的坐标系中,y 轴是从原点向底部测量的,并且是定向的。这意味着位于原点下方的点具有正的 y 值。目前,我们只关心项目的坐标系。由于图形视图是用于二维图形的,我们有一个 x 坐标和一个 y 坐标,原点位于 (0, 0)。所有点、线、矩形等都在项目的自身坐标系中指定。这适用于处理 QGraphicsItem 类及其派生类中代表坐标的值的几乎所有情况。例如,如果你定义一个 QGraphicsRectItem 项目的矩形,你将使用项目坐标。如果一个项目接收到鼠标按下事件,QGraphicsSceneMouseEvent::pos() 将以项目坐标表示。但是,这个陈述有一些容易识别的例外。scenePos() 和 sceneBoundingRect() 的返回值以场景坐标表示。很明显,不是吗?有一点稍微有点难以识别的是 pos() 返回的 QPointF 指针。这个点的坐标以项目的父坐标系表示。这可以是父项目的坐标系,或者更有可能的是,当项目没有父项目时,是场景的坐标系。
为了更好地理解pos()和涉及的坐标系,再次想想便利贴。如果你在一块更大的纸上贴上一张便利贴,然后必须确定它的确切位置,你会怎么做?可能就像这样:“便利贴的左上角位于纸张左上角的右边 3 厘米和下面 5 厘米处”。在图形视图世界中,这对应于一个没有父项的项目,其pos()函数返回场景坐标中的位置,因为项目的原点直接固定到场景上。另一方面,假设你在已经贴在纸上的(更大的)便利贴 B 的上面贴上便利贴 A,你必须确定 A 的位置;这次你会怎么描述它?可能你会说便利贴 A 放在便利贴 B 的上面,或者“从便利贴 B 的左上角右边 2 厘米和下面 1 厘米处”。你很可能不会使用底下的纸张作为参考,因为它不是下一个参考点。这是因为,如果你移动便利贴 B,A 相对于纸张的位置会改变,而 A 相对于 B 的相对位置仍然保持不变。为了回到图形视图,等效的情况是一个具有父项的项目。在这种情况下,pos()函数返回的值是在其父项的坐标系中表达的。所以setPos()和pos()指定了项目的原点相对于下一个(更高)参考点的位置。这可能是场景或项目的父项。
然而,请记住,改变项目的位置不会影响项目的内部坐标系。
行动时间 - 创建具有不同来源的项目
让我们更仔细地看看以下代码片段定义的这三个项目:
QGraphicsRectItem *itemA = QGraphicsRectItem(-10, -10, 20, 20);
QGraphicsRectItem *itemB = QGraphicsRectItem(0, 0, 20, 20);
QGraphicsRectItem *itemC = QGraphicsRectItem(10, 10, 20, 20);
发生了什么?
这三个项目都是边长为 20 像素的矩形。它们之间的区别在于它们的坐标原点位置。itemA的坐标原点位于矩形的中心,itemB的坐标原点位于矩形的左上角,而itemC的坐标原点位于绘制的矩形之外。在下面的图中,你可以看到原点被标记为红色圆点。

那么,这些原点有什么作用呢?一方面,原点用于在项目的坐标系和场景坐标系之间建立关系。正如你将在后面更详细地看到的那样,如果你设置了项目在场景中的位置,场景中的位置就是项目的原点。你可以这样说:场景 (x, y) = 项目(0, 0)。另一方面,原点用作所有可用于项目的变换的中心点,例如缩放、旋转或添加一个可自由定义的QTransform类型的变换矩阵。作为一个附加功能,你始终可以选择将新的变换与已应用的变换组合,或者用新的变换替换旧的变换。
行动时间 - 旋转项目
例如,让我们将itemB和itemC逆时针旋转 45 度。对于itemB,函数调用将如下所示:
itemB->setRotation(-45);
setRotation()函数接受qreal作为参数值,因此你可以设置非常精确的值。该函数将数字解释为围绕z坐标的顺时针旋转角度。如果你设置一个负值,则执行逆时针旋转。即使没有太多意义,你也可以将项目旋转 450 度,这将导致旋转 90 度。以下是逆时针旋转 45 度后的两个项目的外观:

发生了什么?
如你所见,旋转的中心在项目的原点。现在你可能遇到的问题是,你想要围绕itemC的矩形中心旋转。在这种情况下,你可以使用setTransformOriginPoint()。对于描述的问题,相关的代码将如下所示:
QGraphicsRectItem *itemC = QGraphicsRectItem(10, 10, 20, 20);
itemC->setTransformOriginPoint(20, 20);
itemC->rotate(-45);
让我们利用这个机会回顾一下项目的坐标系。项目的原点在(0, 0)。在QGraphicsRectItem的构造函数中,你定义矩形应将其左上角放在(10, 10)。由于你给矩形设置了 20 像素的宽度和高度,其右下角在(30, 30)。这使得(20, 20)成为矩形的中心。在将变换的原点设置为(20, 20)后,你逆时针旋转 45 度。你将在以下图像中看到结果,其中变换的原点用十字标记。

即使通过这样的变换“改变”了项目的原点,这也不会影响项目在场景中的位置。首先,场景根据其原点定位未变换的项目,然后才对所有变换应用于项目。
来试试吧英雄——应用多个变换
要理解变换的概念及其原点,请亲自尝试。对一个项目依次应用rotate()和scale()。同时,改变原点并观察项目如何反应。第二步,使用QTransform与setTransform()结合,为一个项目添加自定义变换。
场景
让我们看看我们如何即兴发挥场景。
向场景中添加项目
到目前为止,你应该对项目有一个基本的了解。下一个问题是你要如何处理它们。如前所述,你通过调用 addItem(QGraphicsItem *item) 方法将项目放置在 QGraphicsScene 上。这是通过调用 addItem(QGraphicsItem *item) 方法来完成的。你注意到参数的类型了吗?它是一个指向 QGraphicsItem 的指针。由于场景上的所有项目都必须继承 QGraphicsItem,因此你可以使用这个函数与任何项目一起使用,无论是 QGraphicsRectItem 项目还是任何自定义项目。如果你查看 QGraphicsScene 的文档,你会注意到所有返回项目或处理它们的函数都期望指向 QGraphicsItem 项目的指针。这种通用可用性是图形视图面向对象方法的一个巨大优势。
小贴士
如果你有一个指向 QGraphicsItem 类型的指针,它指向一个 QGraphicsRectItem 实例,并且你想使用 QGraphicsRectItem 的一个函数,请使用 qgraphicsitem_cast<>() 来转换指针。这是因为它比使用 static_cast<>() 或 dynamic_cast<>() 更安全、更快。
QGraphicsItem *item = new QGraphicsRectItem(0, 0, 5, 5);
QGraphicsRectItem *rectItem = qgraphicsitem_cast<QGraphicsRectItem*>(item);
if (rectItem)
rectItem->setRect(0, 0, 10, 15);
请注意,如果你想使用 qgraphicsitem_cast<>() 与你自己的自定义项目,你必须确保 QGraphicsItem::type() 被重新实现,并且它为特定项目返回一个唯一的类型。为了确保唯一类型,使用 QGraphicsItem::UserType + x 作为返回值,其中你为每个创建的自定义项目递增 x。
是时候行动了——向场景添加项目
让我们尝试一下,将一个项目添加到场景中:
QGraphicsScene scene;
QGraphicsRectItem *rectItem = new QGraphicsRectItem(0,0,50,50);
scene.addItem(rectItem);
刚才发生了什么?
这里没有复杂的东西。你创建一个场景,创建一个类型为 QGraphicsRectItem 的项目,定义项目的矩形几何形状,然后通过调用 addItem() 将项目添加到场景中。非常直接。但这里没有展示的是这给场景带来的影响。现在场景负责添加的项目!首先,项目的所有权被转移给了场景。对你来说,这意味着你不需要担心释放项目的内存,因为删除场景也会删除与场景关联的所有项目。现在记住我们之前提到的自定义项目的析构函数:它必须是虚拟的!QGraphicsScene 使用指向 QGraphicsItem 的指针。因此,当它删除分配的项目时,它会通过在基类指针上调用 delete 来执行。如果你没有声明派生类的析构函数为虚拟的,它将不会执行,这可能会导致内存泄漏。因此,养成声明析构函数为虚拟的习惯。
将项目的所有权转移到场景也意味着一个项目只能添加到一个场景中。如果项目之前已经被添加到另一个场景中,它会在被添加到新场景之前从那里移除。下面的代码将演示这一点:
QGraphicsScene firstScene;
QGraphicsScene secondScene;
QGraphicsRectItem *item = new QGraphicsRectItem;
firstScene.addItem(item);
qDebug() << firstScene.items().count(); // 1
secondScene.addItem(item);
qDebug() << firstScene.items().count(); // 0
创建两个场景和一个项目后,我们将项目item添加到场景firstScene中。然后,通过调试信息,我们打印出与该firstScene场景关联的项目数量。为此,我们在场景上调用items(),它返回一个包含指向场景中所有项目指针的QList列表。在该列表上调用count()告诉我们列表的大小,这相当于添加的项目数量。正如你在将项目添加到secondScene后所看到的,firstScene的项目计数返回0。在item被添加到secondScene之前,它首先从firstScene中移除。
小贴士
如果你想从场景中移除一个项目,而不直接将其设置到另一个场景或删除它,你可以调用removeItem(),它需要一个指向要移除的项目指针。但是请注意,现在你有责任删除该项目以释放分配的内存!
与场景中的项目交互
当场景接管一个项目时,场景还必须注意很多其他事情。场景必须确保事件被传递到正确的项目。如果你点击场景(更准确地说,你点击一个将事件传播到场景的视图),场景会接收到鼠标按下事件,然后它就变成了场景的责任来确定点击的是哪个项目。为了能够做到这一点,场景始终需要知道所有项目的位置。因此,场景通过二叉空间划分树跟踪项目。
你也可以从这项知识中受益!如果你想知道在某个位置显示的是哪个项目,请使用QPointF作为参数调用itemAt()。你将收到该位置上最顶部的项目。如果你想获取所有位于该位置的项目,例如多个项目重叠的情况,请调用items()的重载函数(它需要一个QPointF指针作为参数)。它将返回一个包含所有包含该点的边界矩形的项目的列表。items()函数还接受QRectF、QPolygonF和QPainterPath作为参数,如果你需要获取一个区域的全部可见项目。使用类型为Qt::ItemSelectionMode的第二个参数,你可以改变区域中项目的确定模式。以下表格显示了不同的模式:
| 模式 | 含义 |
|---|---|
Qt::ContainsItemShape |
项目形状必须完全在选择区域内。 |
Qt::IntersectsItemShape |
与Qt::ContainsItemShape类似,但还返回形状与选择区域相交的项目。 |
Qt::ContainsItemBoundingRect |
项目的边界矩形必须完全在选择区域内。 |
Qt::IntersectsItemBoundingRect |
与Qt::ContainsItemBoundingRect类似,但还返回边界矩形与选择区域相交的项目。 |
场景负责传递事件的责任不仅适用于鼠标事件;它也适用于键盘事件和其他所有类型的事件。传递给项目的这些事件是 QGraphicsSceneEvent 的子类。因此,项目不会像小部件那样获得 QMouseEvent 事件,而是获得 QGraphicsSceneMouseEvent 事件。通常,这些场景事件的行为类似于正常事件,但与 globalPos() 函数不同,你有 scenePos()。
场景还处理项目的选择。要可选中,项目必须将 QGraphicsItem::ItemIsSelectable 标志打开。你可以通过调用 QGraphicsItem::setFlag() 并将标志和 true 作为参数来实现。除此之外,还有不同的方式来选择项目。有项目的 QGraphicsItem::setSelected() 函数,它接受一个 bool 值来切换选择状态,或者你可以在场景上调用 QGraphicsScene::setSelectionArea(),它接受一个 QPainterPath 参数作为参数,在这种情况下,所有项目都会被选中。使用鼠标,你可以点击一个项目来选中或取消选中它,或者如果视图的橡皮筋选择模式被启用,你可以使用该橡皮筋选择多个项目。
注意
要激活视图的橡皮筋选择,请在视图上调用 setDragMode (QGraphicsView::RubberBandDrag)。然后你可以按下鼠标左键,在按住鼠标的同时移动鼠标以定义选择区域。选择矩形由第一次鼠标点击的点和当前鼠标位置定义。
使用场景的 QGraphicsScene::selectedItems() 函数,你可以查询实际选中的项目。该函数返回一个包含指向选中项目的 QGraphicsItem 指针的 QList 列表。例如,在该列表上调用 QList::count() 会给出选中项目的数量。要清除选择,请调用 QGraphicsScene::clearSelection()。要查询项目的选择状态,使用 QGraphicsItem::isSelected(),如果项目被选中则返回 true,否则返回 false。如果你编写了一个自定义的 paint 函数,不要忘记更改项目的外观以表明它已被选中。否则,用户将无法知道这一点。在 paint 函数内部的判断是通过 QStyle::State_Selected 来完成的,如前所述。

标准项目在选中项目周围显示一个虚线矩形。
项目处理焦点的方式也类似。要成为可聚焦的,项目必须启用QGraphicsItem::ItemIsFocusable标志。然后,可以通过鼠标点击、通过项目的QGraphicsItem::setFocus()函数,或者通过场景的QGraphicsScene::setFocusItem()函数来聚焦项目,该函数期望一个指向你想要聚焦的项目指针作为参数。要确定一个项目是否有焦点,你有两种可能性。一种是你可以对一个项目调用QGraphicsItem::hasFocus(),如果项目有焦点则返回true,否则返回false。或者,你可以通过调用场景的QGraphicsScene::focusItem()方法来获取实际聚焦的项目。另一方面,如果你调用项目的QGraphicsItem::focusItem()函数,如果项目本身或任何子项目有焦点,则返回聚焦的项目;否则,返回0。要移除焦点,请在聚焦的项目上调用clearFocus()或在场景的背景或无法获取焦点的项目上点击。
小贴士
如果你希望点击场景的背景不会导致焦点项目失去焦点,请将场景的stickyFocus属性设置为true。
渲染
这也是场景的责任,使用所有分配的项目渲染自己。
执行时间 – 将场景内容渲染为图像
让我们尝试将一个场景渲染成图像。为了做到这一点,我们从第一个示例中提取以下代码片段,在第一个示例中我们尝试将项目放置在场景中:
QGraphicsScene scene;
QGraphicsRectItem *rectItem = new QGraphicsRectItem();
rectItem->setRect(0,0,50,50);
rectItem->setBrush(Qt::green);
rectItem->setPen(QColor(255,0,0));
scene.addItem(rectItem);
我们在这里做的唯一改变是设置了一个画刷,它产生一个绿色填充、红色边框的矩形,这是通过setBrush()和setPen()定义的。你也可以通过传递一个带有相应参数的QPen对象来定义笔划的粗细。要渲染场景,你只需要调用render(),它接受一个指向QPainter指针的指针。这样,场景就可以将其内容渲染到画家指向的任何绘图设备上。对我们来说,一个简单的 PNG 文件就可以完成这项工作。
QRect rect = scene.sceneRect().toAlignedRect();
QImage image(rect.size(), QImage::Format_ARGB32);
image.fill(Qt::transparent);
QPainter painter(&image);
scene.render(&painter);
image.save("scene.png", "PNG");

渲染结果
发生了什么?
首先,你使用 sceneRect() 确定了场景的矩形。由于这个函数返回一个 QRectF 参数,而 QImage 只能处理 QRect,所以你通过调用 toAlignedRect() 在线转换它。toRect() 函数和 toAlignedRect() 之间的区别在于前者四舍五入到最接*的整数,这可能会导致矩形更小,而后者则扩展到包含原始 QRectF 参数的最小可能矩形。然后,你创建了一个具有对齐场景矩形大小的 QImage 文件。因为图像是用未初始化的数据创建的,所以你需要使用 Qt::transparent 调用 fill() 来接收一个透明图像。你可以将任何颜色作为参数分配,无论是 Qt::GlobalColor 枚举的值还是一个普通的 QColor 对象;QColor(0, 0, 255) 将导致蓝色背景。接下来,你创建了一个指向图像的 QPainter 对象。这个绘图对象现在被用于场景的 render() 函数来绘制场景。之后,你所要做的就是将图像保存到你选择的任何位置。文件名(也可以包含一个绝对路径,例如 /path/to/image.png)由第一个参数给出,而第二个参数确定图像的格式。在这里,我们将文件名设置为 scene.png 并选择 PNG 格式。由于我们没有指定路径,图像将被保存在应用程序的当前目录中。
尝试一下英雄——仅渲染场景的特定部分
这个示例绘制了整个场景。当然,你也可以通过使用 render() 函数的其他参数来仅渲染场景的特定部分。这里我们不会深入探讨这一点,但你可能想作为一个练习尝试一下。
场景的坐标系
剩下的就是查看场景的坐标系了。和项目一样,场景也存在于自己的坐标系中,原点位于 (0, 0)。现在当你通过 addItem() 添加一个项目时,该项目就被定位在场景的 (0, 0) 坐标上。如果你想将项目移动到场景上的另一个位置,请在项目上调用 setPos()。
QGraphicsScene scene;
QGraphicsRectItem *item = QGraphicsRectItem(0, 0, 10, 10);
scene.addItem(item);
item.setPos(50,50);
在创建场景和项目后,您可以通过调用addItem()将项目添加到场景中。在这个阶段,场景的原点和项目的原点在(0, 0)处重叠。通过调用setPos(),您将项目向右和向下移动 50 像素。现在项目的原点在场景坐标中的位置是(50, 50)。如果您需要知道项目矩形右下角在场景坐标中的位置,您需要进行快速计算。在项目的坐标系中,右下角位于(10, 10)。在项目的坐标系中,项目的原点是(0, 0),这对应于场景坐标系中的点(50, 50)。因此,您只需将(50, 50)和(10, 10)相加,得到(60, 60)作为项目右下角在场景坐标中的位置。这是一个简单的计算,但当您旋转、缩放和/或扭曲项目时,它会迅速变得复杂。正因为如此,您应该使用QGraphicsItem提供的便利函数之一:
| 函数 | 描述 |
|---|---|
mapToScene(const QPoint &point) |
将位于项目坐标系中的点point映射到场景坐标系中的对应点。 |
mapFromScene(const QPoint &point) |
将位于场景坐标系中的点point映射到项目坐标系中的对应点。此函数是mapToScene()的逆函数。 |
mapToParent(const QPoint &point) |
将位于项目坐标系中的点point映射到项目父级坐标系中的对应点。如果项目没有父级,此函数的行为类似于mapToScene();因此,它返回场景坐标系中的对应点。 |
mapFromParent(const QPoint &point) |
将位于项目父级坐标系中的点point映射到项目自身坐标系中的对应点。此函数是mapToParent()的逆函数。 |
mapToItem(const QGraphicsItem *item, const QPointF &point) |
将位于项目自身坐标系中的点point映射到项目item的坐标系中的对应点。 |
mapFromItem(const QGraphicsItem *item, const QPointF &point) |
将位于项目item坐标系中的点point映射到项目自身坐标系中的对应点。此函数是mapToItem()的逆函数。 |
这些函数的伟大之处在于它们不仅适用于 QPointF。同样的函数也适用于 QRectF、QPolygonF 和 QPainterPath。更不用说这些当然都是便利函数:如果你用两个 qreal 类型的数字调用这些函数,数字会被解释为 QPointF 指针的 x 和 y 坐标;如果你用四个数字调用这些函数,数字会被解释为 QRectF 参数的 x 和 y 坐标以及宽度和高度。
由于项目的定位是由项目本身完成的,因此一个项目可能会独立移动。不要担心;场景会通知任何项目位置的变化。而且不仅仅是场景!记得项目和它们之间的父子关系,当父项目被销毁时,它们会删除它们的子项目?这与 setPos() 是一样的。如果你移动一个父项目,所有子项目也会被移动。如果你有一堆应该在一起的项目,这可以非常有用。你不需要移动所有项目,只需移动一个项目即可。由于应用于父项目的变换也会影响子项目,这可能不是将应该独立变换但也可以一起变换的相等项目分组在一起的最佳解决方案。这种情况的解决方案是 QGraphicsItemGroup。它就像父子关系中的父项目一样表现。QGraphicsItemGroup 是一个不可见的父项目,这样你就可以通过它们的变换函数单独改变子项目,或者通过调用 QGraphicsItemGroup 的变换函数一起改变所有子项目。
是时候行动了——变换父项目和子项目
看看下面的代码:
QGraphicsScene scene;
QGraphicsRectItem *rectA = new QGraphicsRectItem(0,0,45,45);
QGraphicsRectItem *rectB = new QGraphicsRectItem(0,0,45,45);
QGraphicsRectItem *rectC = new QGraphicsRectItem(0,0,45,45);
QGraphicsRectItem *rectD = new QGraphicsRectItem(0,0,45,45);
rectB->moveBy(50,0);
rectC->moveBy(0,50);
rectD->moveBy(50,50);
QGraphicsItemGroup *group = new QGraphicsItemGroup;
group->addToGroup(rectA);
group->addToGroup(rectB);
group->addToGroup(rectC);
rectD->setGroup(group);
group->setRotation(70);
rectA->setRotation(-25);
rectB->setRotation(-25);
rectC->setRotation(-25);
rectD->setRotation(-25);
scene.addItem(group);
刚才发生了什么?
在创建场景之后,我们创建了四个矩形元素,它们被排列成一个 2 x 2 的矩阵。这是通过调用moveBy()函数实现的,该函数将第一个参数解释为向右或向左的移动,当参数为负时,第二个参数解释为向上或向下的移动。然后我们创建了一个新的QGraphicsItemGroup元素,由于它继承自QGraphicsItem,因此它是一个常规元素,可以像这样使用。通过调用addToGroup(),我们将想要放置在该组内部的元素添加进去。如果你以后想从组中移除一个元素,只需调用removeFromGroup()并传递相应的元素即可。rectD参数以不同的方式添加到组中。通过在rectD上调用setGroup(),它被分配给group;这种行为与setParent()类似。如果你想检查一个元素是否分配给了组,只需调用它上的group()即可。它将返回指向组的指针或0,如果元素不在组中。在将组添加到场景中,从而也将元素添加到场景中之后,我们将整个组顺时针旋转 70 度。之后,所有元素分别绕其左上角逆时针旋转 25 度。这将导致以下外观:

这里你可以看到移动元素后的初始状态,然后是旋转组 70 度后的状态,然后是每个元素旋转-25 度后的状态
如果我们继续旋转这些元素,它们将相互重叠。但是哪个元素会覆盖哪个元素?这由元素的z值定义;你可以通过使用QGraphicsItem::setZValue()来定义这个值,否则它的值是0。基于这个值,元素被堆叠。具有更高z值的元素显示在具有较低z值的元素之上。如果元素具有相同的z值,则插入顺序决定放置:后来添加的元素会覆盖先添加的元素。此外,也可以使用负值。
尝试一下英雄般的操作——玩转 z 值
以示例中的元素组为起点,并对其应用各种变换,以及为元素设置不同的z值。你会发现你可以用这四个元素创造出多么疯狂的几何图形。编码真的很有趣!
为了完整性,有必要对场景的边界矩形说一句话(通过 setSceneRect() 设置)。正如项目边界矩形的偏移量会影响其在场景中的位置一样,场景边界矩形的偏移量会影响场景在视图中的位置。然而,更重要的是,边界矩形被用于各种内部计算,例如计算视图滚动条的值和位置。即使你不需要设置场景的边界矩形,也建议你这样做。这尤其适用于你的场景包含大量项目时。如果你不设置边界矩形,场景将通过遍历所有项目,检索它们的位置和边界矩形以及它们的变换来自己计算最大占用空间。这个计算是通过函数 itemsBoundingRect() 完成的。正如你可能想象的那样,随着场景中项目的增加,这个计算变得越来越资源密集。此外,如果你不设置场景的矩形,场景会在每个项目的更新时检查项目是否仍然在场景的矩形内。如果不是,它会扩大矩形以包含项目在边界矩形内。缺点是它永远不会通过缩小来调整;它只会扩大。因此,当你将一个项目移动到外面,然后再移动到里面时,你会搞乱滚动条。
小贴士
如果你不想自己计算场景的大小,你可以将所有项目添加到场景中,然后使用 itemsBoundingRect() 作为参数调用 setSceneRect()。这样,你就可以停止场景在项目更新时检查和更新最大边界矩形。
查看
使用 QGraphicsView,我们回到了小部件的世界。由于 QGraphicsView 继承自 QWidget,你可以像使用任何其他小部件一样使用视图,并将其放置到布局中,以创建整洁的图形用户界面。对于图形视图架构,QGraphicsView 提供了一个场景的检查窗口。通过视图,你可以显示整个场景或其一部分,并且通过使用变换矩阵,你可以操纵场景的坐标系。内部,视图使用 QGraphicsScene::render() 来可视化场景。默认情况下,视图使用一个 QWidget 元素作为绘图设备。由于 QGraphicsView 继承自 QAbstractScrollArea,该小部件被设置为它的视口。因此,当渲染的场景超出视图的几何形状时,会自动显示滚动条。
注意
而不是使用默认的 QWidget 元素作为视口小部件,你可以通过调用 setViewport() 并将自定义小部件作为参数来设置自己的小部件。然后视图将接管分配的小部件的所有权,这可以通过 viewport() 访问。这也给你提供了使用 OpenGL 进行渲染的机会。只需调用 setViewport(new QGLWidget) 即可。
行动时间 - 将所有这些放在一起!
在我们继续之前,然而,在大量讨论了项目和场景之后,让我们看看视图、场景和项目是如何一起工作的:
#include <QApplication>
#include <QGraphicsView>
#include <QGraphicsRectItem>
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
QGraphicsScene scene;
scene.addEllipse(QRectF(0, 0, 100, 100), QColor(0, 0, 0));
scene.addLine(0, 50, 100, 50, QColor(0, 0, 255));
QGraphicsRectItem *item = scene.addRect(0, 0, 25, 25, Qt::NoPen, Qt::red);
item->setPos(scene.sceneRect().center() - item->rect().center());
QGraphicsView view;
view.setScene(&scene);
view.show();
return app.exec();
}
构建并运行此示例,你将在视图中间看到以下图像:

发生了什么?
我们在这里做了什么?在顶部,我们包含了所需的头文件,然后编写了一个正常的 main 函数并创建了一个QApplication元素。其事件循环在底部的返回语句中启动。在中间,我们创建了一个场景,并通过调用addEllipse()将其第一个项目添加到场景中。这个函数是 Qt 的许多便利函数之一,在我们的情况下,等同于以下代码:
QGraphicsEllipseItem *item = new QGraphicsEllipseItem;
item->setRect(0, 0, 100, 100);
item->setPen(QColor(0, 0, 0));
scene.addItem(item);
因此,我们在场景中放置了一个半径为 50 像素的圆。圆的起点和场景的起点是重叠的。接下来,通过调用addLine(),我们添加了一条通过圆中心点、与场景底部线*行的蓝色线。前两个参数是线的起始点的x和y坐标,后两个参数是终点的x和y坐标。使用addRect(),我们在场景的左上角添加了一个边长为 25 像素的正方形。然而,这次我们获取了指针,然后这些函数返回这个指针。这是因为我们想要将矩形移动到场景的中心。为了做到这一点,我们使用setPos()并需要进行一些算术运算。为什么?因为场景和项目坐标系统之间的关系。通过简单地调用item->setPos(scene.sceneRect().center()),项目的起点(在项目的坐标中是(0, 0),因此是矩形的左上角)就会位于场景的中间,而不是红色正方形本身。因此,我们需要将矩形向回移动其宽度和高度的一半。这是通过从场景的中心点减去其中心点来完成的。正如你可能已经猜到的,QRectF::center()返回一个矩形的中心点作为QPointF指针。最后,我们创建了一个视图,并通过调用setScene()并传入场景作为参数来声明它应该显示场景。然后我们显示了视图。这就是显示带有项目的场景所需做的全部工作。
如果你查看结果,你可能会注意到两件事:绘图看起来是像素化的,并且在调整视图大小时它保持在视图的中心。对于第一个问题的解决方案,你应该已经从上一章学到了。你必须打开抗锯齿。对于视图,你可以用以下代码行来实现:
view.setRenderHint(QPainter::Antialiasing);
使用 setRenderHint(),你可以将你知道的所有来自 QPainter 的提示设置到视图中。在视图在其视口小部件上渲染场景之前,它会使用这些提示初始化内部使用的 QPainter 元素。当开启抗锯齿标志时,绘图会更加*滑。不幸的是,线条也被绘制成抗锯齿效果(尽管我们并不希望这样,因为现在线条看起来模糊)。为了防止线条被绘制成抗锯齿效果,你必须覆盖项目的 paint() 函数并显式关闭抗锯齿。然而,你可能希望在某个地方有一条带有抗锯齿的线条,因此有一个小而简单的解决方案来解决这个问题,而不需要重新实现 paint 函数。你所要做的就是将位置移动到笔宽的一半。为此,请编写以下代码:
QGraphicsLineItem *line = scene.addLine(0, 50, 100, 50, QColor (0, 0, 255));
const qreal shift = line->pen().widthF() / 2.0;
line->moveBy(-shift, -shift);
通过调用 pen(),你可以获取用于绘制线条的笔。然后通过调用 widthF() 并将其除以 2 来确定其宽度。然后只需移动线条,其中 moveBy() 函数的行为就像我们调用了以下代码:
line->setPosition(item.pos() - QPointF(shift, shift))
为了达到像素级的精确,你可能需要改变线条的长度。
第二个“问题”是场景总是可视化在视图的中心,这是视图的默认行为。你可以使用 setAlignment() 来更改此设置,它接受 Qt::Alignment 标志作为参数。因此,调用 view.setAlignment(Qt::AlignBottom | Qt::AlignRight);会导致场景保持在视图的右下角。
显示场景的特定区域
当场景的边界矩形超过视口大小时,视图将显示滚动条。除了使用鼠标导航到场景中的特定项目或点之外,你还可以通过代码访问它们。由于视图继承自 QAbstractScrollArea,你可以使用所有其函数来访问滚动条。horizontalScrollBar() 和 verticalScrollBar() 返回一个指向 QScrollBar 的指针,因此你可以使用 minimum() 和 maximum() 查询它们的范围。通过调用 value() 和 setValue(),你可以获取并设置当前值,这将导致场景滚动。
但通常,你不需要从源代码中控制视图内的自由滚动。正常任务是将滚动到特定项目。为了做到这一点,你不需要自己进行任何计算;视图为你提供了一个相当简单的方法:centerOn()。使用 centerOn(),视图确保你传递作为参数的项目在视图中居中,除非它太靠*场景的边缘甚至在外面。然后,视图尝试尽可能地将它移动到中心。centerOn() 函数不仅接受 QGraphicsItem 项目作为参数;你也可以将其居中到一个 QPointF 指针,或者作为一个便利的 x 和 y 坐标。
如果你不关心项显示的位置,你可以直接调用 ensureVisible() 并将项作为参数。然后视图尽可能少地滚动场景,使得项的中心保持或变为可见。作为第二个和第三个参数,你可以定义水*和垂直边距,这两个边距都是项的边界矩形和视图边框之间的最小空间。这两个值的默认值都是 50 像素。除了 QGraphicsItem 项之外,你也可以确保 QRectF 元素(当然,也有接受四个 qreal 元素作为参数的便利函数)的可见性。
小贴士
如果你希望确保项的整个可见性(因为 ensureVisible(item) 只考虑项的中心),请使用 ensureVisible(item->boundingRect())。或者,你也可以使用 ensureVisible(item),但此时你必须将边距至少设置为项的一半宽度或高度。
centerOn() 和 ensureVisible() 只会滚动场景,但不会改变其变换状态。如果你绝对想要确保超出视图大小的项或矩形的可见性,你必须变换场景。通过将 QGraphicsItem 或 QRectF 元素作为参数调用 fitInView(),视图将滚动并缩放场景,使其适应视口大小。作为第二个参数,你可以控制缩放的方式。你有以下选项:
| 值 | 描述 |
|---|---|
Qt::IgnoreAspectRatio |
缩放是绝对自由进行的,不考虑项或矩形的宽高比。 |
Qt::KeepAspectRatio |
在尽可能扩展的同时,考虑项或矩形的宽高比,并尊重视口的尺寸。 |
Qt::KeepAspectRatioByExpanding |
考虑项或矩形的宽高比,但视图尝试用最小的重叠填充整个视口的大小。 |
fitInView() 函数不仅将较大的项缩小以适应视口,还将项放大以填充整个视口。以下图片展示了放大项的不同缩放选项:

左侧的圆圈是原始项。然后,从左到右依次是 Qt::IgnoreAspectRatio、Qt::KeepAspectRatio 和 Qt::KeepAspectRatioByExpanding。
变换场景
在视图中,你可以按需变换场景。除了 rotate()、scale()、shear() 和 translate() 等常规便利函数之外,你还可以通过 setTransform() 应用自定义的 QTransform 参数,在那里你也可以决定变换是否应该与现有的变换组合,或者是否应该替换它们。作为一个可能是在视图中使用最多的变换示例,让我们看看如何缩放和移动视图内的场景。
行动时间 - 创建一个可以轻松看到变换的项目
首先,我们设置一个游乐场。为此,我们从一个 QGraphicsRectItem 项目派生并自定义其绘制函数,如下所示:
void ScaleItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) {
Q_UNUSED(option)
Q_UNUSED(widget)
const QPen oldPen = painter->pen();
const QRectF r = rect();
const QColor fillColor = Qt::red;
const qreal square = r.width() / 10.0;
painter->fillRect(QRectF(0, 0, square, square), fillColor);
painter->fillRect(QRectF(r.width() - square, 0, square, square), fillColor);
painter->fillRect(QRectF(0,r.height() - square, square, square), fillColor);
painter->fillRect(QRectF(r.width() - square, r.height() - square,square, square), fillColor);
painter->setPen(Qt::black);
painter->drawRect(r);
painter->drawLine(r.topLeft(), r.bottomRight());
painter->drawLine(r.topRight(), r.bottomLeft());
const qreal padding = r.width() / 4;
painter->drawRect(r.adjusted(padding, padding, -padding, - padding));
painter->setPen(oldPen);
}
发生了什么?
通过使用 Q_UNUSED 宏,我们简单地抑制了编译器关于未使用变量的警告。该宏展开为 (void)x;,这什么也不做。然后我们缓存当前的画笔,以便在函数末尾将其放回。这样,painter 就保持不变了。当然,我们可以在画笔上调用 save() 和 restore(),但这些函数会保存很多我们不希望改变的属性,所以简单地保存和恢复画笔要快得多。接下来,我们通过调用 fillRect() 在边界矩形的四个角绘制四个红色矩形,fillRect() 不会改变画笔状态。然后我们设置一个 1 像素粗细的实心黑色画笔——因为这将改变画笔的状态,所以我们保存了旧的画笔——并绘制边界矩形、对角线和中心矩形,中心矩形的尺寸是边界矩形尺寸的四分之一。这将给我们以下项目,它比用黑色填充的矩形更好地显示了变换:

行动时间 - 实现缩放场景的能力
首先进行缩放操作。我们将项目添加到一个场景中,并将该场景放置在我们从 QGraphicsView 派生出的自定义视图中。在我们的自定义视图中,我们只需要重写 wheelEvent() 方法,因为我们想通过鼠标的滚轮来缩放视图。
void MyView::wheelEvent(QWheelEvent *event) {
const qreal factor = 1.1;
if (event->angleDelta().y() > 0)
scale(factor, factor);
else
scale(1/factor, 1/factor);
}
发生了什么?
缩放的 factor 参数可以自由定义。你也可以为它创建一个获取器和设置器方法。对我们来说,1.1 就足够了。使用 event->angleDelta(),你可以得到鼠标滚轮旋转的距离,作为一个 QPoint 指针。由于我们只关心垂直滚动,因此对我们来说,只有 y 轴是相关的。在我们的例子中,我们也不关心滚轮滚动的距离,因为通常,每一步都会单独传递给 wheelEvent()。但是如果你需要它,它是以八分之一度为单位,并且由于鼠标通常以 15 度的步长工作,因此值应该是 120 或-120,具体取决于你是向前还是向后滚动滚轮。在向前滚动滚轮时,如果 y() 大于零,我们使用内置的 scale() 函数进行缩放。它接受 x 和 y 坐标的缩放因子。否则,如果滚轮向后移动,我们进行缩放。就是这样。当你尝试这个例子时,你会注意到,在缩放时,视图在视图的中心进行缩放和缩小,这是视图的默认行为。你可以使用 setTransformationAnchor() 来改变这种行为。QGraphicsView::AnchorViewCenter 正如描述的那样,是默认行为。使用 QGraphicsView::NoAnchor,缩放中心位于视图的左上角,你可能想要使用的值是 QGraphicsView::AnchorUnderMouse。使用该选项,鼠标下的点构成缩放的中心,因此保持在视图内的同一位置。
是时候行动了——实现移动场景的能力
接下来,我们最好能够在不使用滚动条的情况下移动场景。让我们添加按下并保持左鼠标按钮的功能。首先,我们在视图中添加两个私有成员:类型为 bool 的 m_pressed 参数和类型为 QPoint 的 m_lastMousePos 元素。然后,我们按照以下方式重新实现 mousePressEvent() 和 mouseReleaseEvent() 函数:
void MyView::mousePressEvent(QMouseEvent *event) {
if (Qt::LeftButton == event->button()) {
m_pressed = true;
m_lastMousePos = event->pos();
}
QGraphicsView::mousePressEvent(event);
}
void MyView::mouseReleaseEvent(QMouseEvent *event) {
if (Qt::LeftButton == event->button())
m_pressed = false;
QGraphicsView::mouseReleaseEvent(event);
}
刚才发生了什么?
在 mousePressEvent() 函数中,我们检查是否按下了左鼠标按钮。如果是 true,则将 m_pressed 设置为 true 并将当前鼠标位置保存到 m_lastMousePos。然后我们将事件传递给基类的处理程序。在 mouseReleaseEvent() 函数中,如果按的是左按钮,则将 m_pressed 设置为 false;然后我们将事件传递给基类的实现。在这里我们不需要修改 m_pressPoint。使用 mouseMoveEvent(),我们就可以对这两个变量的值做出反应:
void MyView::mouseMoveEvent(QMouseEvent *event) {
if (!m_pressed)
return QGraphicsView::mouseMoveEvent(event);
QPoint diff = m_lastMousePos - event->pos();
if (QScrollBar *hbar = horizontalScrollBar())
hbar->setValue(hbar->value() + diff.x());
if (QScrollBar *vbar = verticalScrollBar())
vbar->setValue(vbar->value() + diff.y());
m_lastMousePos = event->pos();
return QGraphicsView::mouseMoveEvent(event);
}
如果m_pressed为false——这意味着左键没有被按下并保持——我们将传递事件到基类实现时退出函数。顺便说一下,这对于正确传播未处理的事件到场景中是很重要的。如果按钮已被按下,我们首先计算鼠标按下点和当前位置之间的差异(diff)。这样我们就知道鼠标移动了多少。现在我们只需通过该值移动滚动条。对于水*滚动条,通过调用horizontalScrollBar()接收其指针。在if子句中的封装只是一个偏执的安全检查,以确保指针不是 null。通常,这种情况永远不会发生。通过该指针,我们将通过将value()接收到的旧值与移动距离diff.x()相加来设置新的值。然后我们对垂直滚动条做同样的操作。最后,我们将当前鼠标位置保存到m_lastMousePos。就是这样。现在您可以在按下左鼠标按钮的同时移动场景。这种方法的一个缺点是左鼠标点击不会到达场景,因此,如项目选择等功能不会工作。如果您需要在场景上实现类似的功能,请检查键盘修饰符。例如,如果必须按下Shift键才能移动场景,请还检查事件modifiers()以确定Qt::ShiftModifier是否被设置为激活鼠标移动功能:
void MyView::mousePressEvent(QMouseEvent *event) {
if (Qt::LeftButton == event->button()
&& (event->modifiers() & Qt::ShiftModifier)) {
m_pressed = true;
//...
考虑缩放级别进行操作
作为最后的细节,我想提到的是,您可以根据项目的缩放比例以不同的方式绘制项目。为此,可以使用细节级别。您使用传递给项目paint函数的QStyleOptionGraphicsItem指针,并使用画家的世界变换调用levelOfDetailFromTransform()。我们将ScaleItem项目的paint函数更改为以下内容:
const qreal detail = option->levelOfDetailFromTransform(painter->worldTransform());
const QColor fillColor = (detail >= 5) ? Qt::yellow : Qt::red;
刚才发生了什么?
detail参数现在包含单位正方形的最大宽度,该宽度通过画家的世界变换矩阵映射到画家坐标系。基于这个值,我们将边框矩形的填充颜色设置为黄色或红色。当矩形显示的尺寸至少是正常状态下的五倍时,表达式detail >= 5将变为true。当您只想在项目可见时绘制更多细节时,细节级别很有帮助。通过使用细节级别,您可以控制何时执行可能资源密集型的绘图。例如,只有在您可以看到它们时才进行困难绘图是有意义的。
当您放大场景时,对角线和矩形线也会被放大。但您可能希望无论缩放级别如何,都保持笔触不变。Qt 也提供了一个简单的方法来实现这一点。在之前用于演示缩放功能的项目的paint函数中,定位以下代码行:
painter->setPen(Qt::black);
将其替换为以下行:
QPen p(Qt::black);
p.setCosmetic(true);
painter->setPen(p);
重要的是要使画家外观美观。现在,无论放大或任何其他变换,笔的宽度都保持不变。这可以非常有助于绘制轮廓形状。
你应该记住的问题
每当你准备使用图形视图架构时,问问自己这些问题:哪些标准项目适合我的特定需求?我是不是一次又一次地重新发明轮子?我需要QGraphicsTextItem还是QGraphicsSimpleTextItem就足够好了?我需要项目继承QObject还是普通的项就足够了?(我们将在下一节中讨论这个话题。)我能为了更干净和精简的代码将项目组合在一起吗?父子关系足够还是我需要使用QGraphicsItemGroup元素?
现在,你真的已经了解了图形视图框架的大部分功能。有了这些知识,你现在已经可以做很多酷的事情。但对于一个游戏来说,它仍然太静态了。我们将在下一节中改变这一点!
跳跃的大象或如何动画场景
到目前为止,你应该对项目、场景和视图有了很好的理解。凭借你如何创建项目(标准项目和自定义项目)、如何在场景中定位它们以及如何设置视图以显示场景的知识,你可以制作出相当酷的东西。你甚至可以用鼠标缩放和移动场景。这当然很好,但对于一个游戏来说,还有一个关键点仍然缺失:你必须对项目进行动画。而不是遍历所有动画场景的可能性,让我们开发一个简单的跳跃和奔跑游戏,其中我们回顾了前几个主题,并学习如何在屏幕上对项目进行动画。那么,让我们来认识本杰明,这只大象:

游戏玩法
游戏的目标是让本杰明收集散布在游戏场上的硬币。除了左右走动,本杰明当然也可以跳跃。在下面的屏幕截图中,你可以看到这个简约游戏最终应该是什么样子:

玩家项目
现在我们来看看如何让本杰明动起来。
行动时间——为本杰明创建一个项目
首先,我们需要为本杰明创建一个自定义项目类。我们称这个类为Player,并选择QGraphicsPixmapItem作为基类,因为本杰明是一个 PNG 图像。在Player类的项目项中,我们进一步创建一个整型属性,并称其为m_direction。它的值表示本杰明走向哪个方向——左或右——或者如果他静止不动。当然,我们为这个属性使用获取器和设置器函数。由于头文件很简单,让我们直接看看实现(你将在本书末尾找到整个源代码):
Player::Player(QGraphicsItem *parent)
: QGraphicsPixmapItem(parent), m_direction(0) {
setPixmap(QPixmap(":/elephant"));
setTransformOriginPoint(boundingRect().center());
}
在构造函数中,我们将 m_direction 设置为 0,这意味着本杰明根本不会移动。如果 m_direction 为 1,本杰明向右移动,如果值为 -1,则向左移动。在构造函数的主体中,我们通过调用 setPixmap() 来设置物品的图像。本杰明的图像存储在 Qt 资源系统中;因此,我们通过 QPixmap(":/elephant") 来访问它,其中 elephant 是实际图像的本杰明的给定别名。最后,我们设置所有将要应用于物品的变换的原点,这等于图像的中心。
int Player::direction() const {
return m_direction;
}
direction() 函数是 m_direction 的标准获取函数,返回其值。这个类中的下一个函数要重要得多:
void Player::addDirection(int direction) {
direction = qBound(-1, direction, 1);
m_direction += direction;
if (0 == m_direction)
return;
if (-1 == m_direction)
setTransform(QTransform(-1, 0, 0, 1, boundingRect().width(), 0));
else
setTransform(QTransform());
}
刚才发生了什么?
使用 addDirection(),可以“设置”本杰明的移动方向。“设置”这个词加上了引号,因为您不是将 m_direction 设置为传递的值;相反,您将传递的值添加到 m_direction 中。这是在我们确保 m_direction 正确性之后在第二行完成的。为此,我们使用 qBound(),它返回一个由第一个和最后一个参数限制的值。中间的参数是我们想要获取限制的实际值。因此,m_direction 的可能值被限制为 -1、0 和 1。如果 direction 属性为 0,玩家物品不会移动,函数将退出。
如果您之前还没有这样做,现在您可能会想知道为什么不直接设置值?为什么要这样做加法?好吧,这是因为我们将如何使用这个函数:本杰明通过左右箭头键移动。如果按下右键,则加 1;如果它被释放,则加 -1。将其视为向右(1)和向左(-1)的脉冲。第一个会加速玩家,第二个会减慢他的速度。对于左键也是如此,但方向相反。由于我们不允许多次加速,我们限制 m_direction 的值为 1 和 -1。现在,由于以下情况,需要添加值而不是设置它:用户按下并保持右键,因此 m_direction 的值因此为 1。现在,在不释放右键的情况下,他也按下并保持左键。因此,m_direction 的值减少了一个;现在值为 0,本杰明停止。但请记住,两个键仍然被按下。当左键释放时会发生什么?在这种情况下,您如何知道本杰明应该向哪个方向移动?为了实现这一点,您需要找到一些额外的信息:右键是否仍然被按下。这似乎太麻烦,开销太大。在我们的实现中,当左键释放时,会添加 1,使 m_direction 的值变为 1,使本杰明向右移动。哇!没有任何关于其他按钮状态的担忧。
最后,我们检查本杰明正在移动的方向。如果他正在向左移动,我们需要翻转他的图像,使本杰明看起来向左,即他移动的方向。因此,我们应用一个QTransform矩阵,该矩阵垂直翻转图像。如果他正在向右移动,我们通过分配一个空的QTransform对象来恢复正常状态,这是一个单位矩阵。
因此,我们现在有了游戏角色的Player类项,它显示了本杰明的图像。该项还存储当前的移动方向,并根据该信息,如果需要,垂直翻转图像。
游戏场地
为了理解以下代码,了解我们的象将在其中行走和跳跃的环境组成可能是有益的。总的来说,我们有一个固定大小的视图,其中包含一个场景,其大小正好与视图相同。我们不考虑大小变化,因为这会使示例过于复杂,并且当你为移动设备开发游戏时,你知道可用的尺寸。
游戏场地内的所有动画都是通过移动项来完成的,而不是场景。因此,我们必须区分视图的宽度,或者更确切地说,场景的宽度与象的虚拟“世界”的宽度,在这个虚拟世界中他可以移动。这个虚拟世界的宽度由m_fieldWidth定义,并且与场景没有(直接)关联。在m_fieldWidth的范围内,例如示例中的 500 像素,本杰明或图形项可以从由m_minX定义的最小x坐标移动到由m_maxX定义的最大x坐标。我们使用变量m_realPos跟踪他的实际x位置。接下来,项允许的最小y坐标由m_groundLevel定义。对于m_maxX和m_groundLevel,我们必须考虑到项的位置是由其左上角确定的。最后,剩下的是视图,它具有由场景边界矩形大小定义的固定大小,这并不像m_fieldWidth那么宽。因此,场景(和视图)跟随象穿过他的虚拟世界,该虚拟世界的长度为m_fieldWidth。请看图片以了解变量的图形表示:

场景
由于我们将在场景上做一些工作,我们子类化QGraphicsScene并将新类命名为MyScene。在那里我们实现游戏逻辑的一部分。这很方便,因为QGraphicsScene继承自QObject,因此我们可以使用 Qt 的信号和槽机制。此外,对于场景的下一部分代码,我们只通过函数的实现来处理。有关头文件的更多信息,请参阅本书附带源代码。
行动时间 – 让本杰明移动
我们首先想做的事情是使我们的象可移动。为了实现这一点,我们使用一个名为 m_timer 的 QTimer 参数,它是 MyScene 的私有成员。在构造函数中,我们使用以下代码设置定时器:
m_timer.setInterval(30);
connect(&m_timer, &QTimer::timeout, this, &MyScene::movePlayer);
首先,我们定义定时器每 30 毫秒发出一个超时信号。然后,我们将该信号连接到场景的 movePlayer() 插槽,但我们还没有启动定时器。这是通过箭头键完成的,我们已经在介绍 Player 类的 m_direction 变量时讨论过了。以下是那里描述的实现:
void MyScene::keyPressEvent(QKeyEvent *event) {
if (event->isAutoRepeat())
return;
switch (event->key()) {
case Qt::Key_Right:
m_player->addDirection(1);
checkTimer();
break;
case Qt::Key_Left:
m_player->addDirection(-1);
checkTimer();
break;
//...
default:
break;
}
}
注意
作为一个小插曲,在以下代码段中,如果代码片段与实际细节无关,我将跳过代码,但会用 //... 指示缺失的代码,这样你知道这不是完整的代码。我们将在更合适的时候覆盖跳过的部分。
发生了什么?
在按键事件处理程序中,我们首先检查按键事件是否由于自动重复而触发。如果是这种情况,我们退出函数,因为我们只想对第一次真正的按键事件做出反应。我们也没有调用该事件处理程序的基类实现,因为场景上的任何项目都不需要获得按键事件。如果你有可以并且应该接收事件的项目,请不要忘记在重新实现事件处理程序时转发它们。
注意
如果你按下并保持一个键,Qt 将持续传递按键事件。为了确定这是第一次真正的按键还是自动生成的事件,请使用 QKeyEvent::isAutoRepeat()。如果事件是自动生成的,它将返回 true。由于它依赖于*台,并且你必须使用*台 API 来关闭自动重复,因此没有简单的方法来关闭自动重复。
一旦我们知道事件不是由自动重复触发的,我们就对不同的按键做出反应。如果按下了左键,我们将玩家项的方向属性减少一个;如果按下了右键,我们将它增加一个。m_player 元素是玩家项的实例。在两种情况下,调用 addDirection() 后,我们都调用 checkTimer():
void MyScene::checkTimer() {
if (0 == m_player->direction())
m_timer.stop();
else if (!m_timer.isActive())
m_timer.start();
}
此函数首先检查玩家是否移动。如果没有移动,定时器将停止,因为当我们的象静止时,不需要更新任何内容。否则,定时器将启动,但只有当它尚未运行时。我们通过在定时器上调用 isActive() 来检查这一点。
当用户按下右键时,例如在游戏开始时,checkTimer() 将启动 m_timer。由于其超时信号已连接到 movePlayer(),插槽将每 30 毫秒被调用一次,直到键被释放。由于 move() 函数有点长,让我们一步一步地过一遍:
void MyScene::movePlayer() {
const int direction = m_player->direction();
if (0 == direction)
return;
首先,我们将玩家的当前方向缓存到一个局部变量中,以避免多次调用direction()。然后我们检查玩家是否在移动。如果他们没有移动,我们就退出函数,因为没有东西要动画化。
const int dx = direction * m_velocity;
qreal newPos = m_realPos + dx;
newPos = qBound(m_minX, newPos, m_maxX);
if (newPos == m_realPos)
return;
m_realPos = newPos;
接下来,我们计算玩家物品应该获得的位移并将其存储在dx中。玩家每 30 毫秒应该移动的距离由成员变量m_velocity定义,以像素为单位。如果您喜欢,可以为该变量创建 setter 和 getter 函数。对我们来说,默认的 4 像素值就足够了。乘以方向(此时只能是 1 或-1),我们得到玩家向右或向左移动 4 像素的位移。基于这个位移,我们计算玩家的新x位置并将其存储在newPos中。接下来,我们检查这个新位置是否在m_minX和m_maxX的范围内,这两个成员变量已经在此点正确计算和设置。接下来,如果新位置不等于存储在m_realPos中的实际位置,我们就将新位置赋值为当前位置。否则,我们退出函数,因为没有东西要移动。
const int leftBorder = 150;
const int rightBorder = 350 - m_player->boundingRect().width();
接下来要解决的问题是否在象移动时视图应该始终移动,这意味着象将始终保持在视图的中间。不,他不应该停留在视图内的一个特定点上。相反,当象移动时,视图应该是固定的。只有当它达到边界时,视图才应该跟随。这个“不可移动”的中心由leftBorder和rightBorder定义,它们与物品的位置相关;因此,我们必须从rightBorder元素中减去物品的宽度。如果我们不考虑物品的宽度,宽度超过 150 像素的玩家的右侧在滚动发生之前就会消失。请注意,leftBorder和rightBorder的值是随机选择的。您可以随意更改它们。在这里,我们决定将边界设置为 150 像素。当然,您也可以为这些参数创建 setter 和 getter:
if (direction > 0) {
if (m_realPos > m_fieldWidth - (width() - rightBorder)) {
m_player->moveBy(dx, 0);
} else {
if (m_realPos - m_skippedMoving < rightBorder) {
m_player->moveBy(dx, 0);
} else {
m_skippedMoving += dx;
}
}
} else {
if (m_realPos < leftBorder && m_realPos >= m_minX) {
m_player->moveBy(dx, 0);
} else {
if (m_realPos - m_skippedMoving > leftBorder) {
m_player->moveBy(dx, 0);
} else {
m_skippedMoving = qMax(0, m_skippedMoving + dx);
}
}
}
//...
}
好吧,那么我们在这里做了什么?这里我们计算了是否只有象移动,或者视图也移动,这样象就不会走出屏幕。当象向右移动时,if子句适用。为了更好地理解,让我们从这个作用域的末尾开始。有一种情况是我们不移动象,而是简单地将位移dx添加到一个名为m_skippedMoving的变量中。这意味着什么?这意味着虚拟“世界”在移动,但视图中的象没有移动。这是象移动得太远到边界的情况。换句话说,你通过dx将视图向左移动,使象在虚拟世界中移动。让我们看看下面的图示:

m_skippedMoving元素是视图的x坐标和虚拟世界的x坐标之间的差值。所以if子句m_realPos - m_skippedMoving < rightBorder读取为:如果大象在“视图坐标”中的位置,通过m_realPos – m_skippedMoving计算,小于rightBorder,那么通过调用moveBy()移动大象,因为允许它走到rightBorder。 m_realPos - m_skippedMoving与m_player->pos().x() + dx相同。
最后,让我们转向第一个子句:m_realPos > m_fieldWidth - (width() - rightBorder)。当实际位置在rightBorder元素之后,但虚构世界移动到最左边时,这个表达式返回true。然后我们还需要移动大象,以便它能够到达m_maxX。表达式width() - rightBorder计算了rightBorder和场景右侧边界的宽度。
对于向左移动,其他分支也适用相同的考虑和计算。
到目前为止,我们已经完成了两件事。首先,使用QTimer对象,我们触发了一个移动项目的槽,因此我们已经动画化了场景。其次,我们已经确定了大象在虚拟世界中的位置。你可能想知道我们为什么要这样做。为了能够实现视差滚动!
视差滚动
视差滚动是一种在游戏背景中添加深度错觉的技巧。这种错觉发生在背景有不同层,并且以不同速度移动时。最*的背景必须比远离的背景移动得更快。在我们的例子中,我们有这些四个背景,从最远到最*排序:

天空

树木

草地

地面
行动时间 – 移动背景
现在的问题是,如何以不同的速度移动它们。解决方案相当简单:最慢的,天空,是最小的图像。最快的背景,地面和草地,是最大的图像。现在当我们查看movePlayer()函数槽的末尾时,我们看到这个:
qreal ff = qMin(1.0, m_skippedMoving/(m_fieldWidth - width()));
m_sky->setPos(-(m_sky->boundingRect().width() - width()) * ff, 0);
m_grass->setPos(-(m_grass->boundingRect().width() - width()) * ff, m_grass->y());
m_trees->setPos(-(m_trees->boundingRect().width() - width()) * ff, m_trees->y());
m_ground->setPos(-(m_ground->boundingRect().width() - width()) * ff, m_ground->y());
刚才发生了什么?
我们在这里做什么?一开始,天空的左边界与视图的左边界相同,都在点(0,0)。到结束时,当本杰明走到最右边时,天空的右边界应该与视图的右边界相同。因此,我们需要随时间移动天空的距离是天空的宽度(m_sky->boundingRect().width())减去视图的宽度(width())。天空的移动取决于玩家的位置:如果玩家在左边很远,天空不移动;如果玩家在右边很远,天空最大程度地移动。因此,我们必须将天空的最大移动值乘以一个基于玩家当前位置的系数。与玩家位置的关系是为什么这个处理在movePlayer()函数中。我们必须计算的系数必须在 0 到 1 之间。所以我们得到最小移动(0 * 移动,等于 0)和最大移动(1 * 移动,等于移动)。我们将这个系数命名为ff。计算公式如下:如果我们从虚拟字段宽度(m_fieldWidth)中减去视图宽度(width()),我们就得到了玩家没有移动的区域(m_player->moveBy()),因为在这个范围内只有背景应该移动。
玩家移动被跳过的频率保存在m_skippedMoving中。所以通过将m_skippedMoving除以m_fieldWidth – width(),我们得到所需的系数。当玩家在左边很远时,它是 0;如果他们在右边很远,它是 1。然后我们只需将ff与天空的最大移动值相乘。为了避免背景移动得太远,我们通过qMin()确保系数始终小于或等于 1.0。
同样的计算也用于其他背景项目。这个计算也解释了为什么较小的图像移动较慢。这是因为较小图像的重叠小于较大图像的重叠。由于背景在同一时间段内移动,较大的图像必须移动得更快。
尝试英雄 - 添加新的背景层
按照前面的示例尝试向游戏中添加额外的背景层。作为一个想法,你可以在树后面添加一个谷仓或者让一架飞机飞过天空。
QObject和项目
QGraphicsItem项目以及迄今为止引入的所有标准项目都不继承QObject,因此不能有槽或发出信号;它们也不从QObject属性系统中受益。但我们可以让它们使用QObject!
行动时间 - 使用属性、信号和槽与项目一起使用
因此,让我们修改Player类以使用QObject:
class Player : public QObject, public QGraphicsPixmapItem {
Q_OBJECT
你需要做的只是将QObject作为基类,并添加Q_OBJECT宏。现在你可以在项目上使用信号和槽了。请注意,QObject必须是一个项目的第一个基类。
小贴士
如果你想要一个继承自 QObject 和 QGraphicsItem 的项目,你可以直接继承 QGraphicsObject。此外,这个类定义并发出一些有用的信号,例如当项目的 x 坐标发生变化时发出 xChanged() 信号,或者当项目缩放时发出 scaleChanged() 信号。
注意
一个警告:只有在你确实需要其功能时才使用 QObject 与项目结合。QObject 为项目添加了很多开销,当你有很多项目时,这将对性能产生明显的影响。所以请明智地使用它,而不仅仅是因为你可以。
让我们回到我们的玩家项目。在添加 QObject 之后,我们定义了一个名为 m_jumpFactor 的属性,它具有获取器、设置器和更改信号。我们需要这个属性来让本杰明跳跃,正如我们稍后将会看到的。在头文件中,我们定义属性如下:
Q_PROPERTY(qreal jumpFactor READ jumpFactor WRITE setjumpFactor NOTIFY jumpFactorChanged)
获取函数 jumpFactor() 简单地返回私有成员 m_jumpFactor,该成员用于存储实际位置。设置器的实现如下:
void Player::setjumpFactor(const qreal pos) {
if (pos == m_jumpFactor)
return;
m_jumpFactor = pos;
emit jumpFactorChanged(m_jumpFactor);
}
需要检查 pos 是否会改变 m_jumpFactor 的当前值。如果不是这种情况,则退出函数,因为否则即使没有变化,也会发出一个更改信号。否则,我们将 m_jumpFactor 设置为 pos 并发出一个通知变化的信号。
属性动画
我们使用新的 jumpFactor 属性与 QPropertyAnimation 元素立即结合,这是对项目进行动画处理的第二种方式。
使用动画*滑移动项目的时间
为了使用它,我们在 Player 构造函数中添加了一个新的私有成员 m_animation,其类型为 QPropertyAnimation 并对其进行初始化:
m_animation = new QPropertyAnimation(this);
m_animation->setTargetObject(this);
m_animation->setPropertyName("jumpFactor");
m_animation->setStartValue(0);
m_animation->setKeyValueAt(0.5, 1);
m_animation->setEndValue(0);
m_animation->setDuration(800);
m_animation->setEasingCurve(QEasingCurve::OutInQuad);
发生了什么?
对于在这里创建的QPropertyAnimation实例,我们将物品定义为父级;因此,当场景删除物品时,动画将被删除,我们不必担心释放使用的内存。然后我们定义动画的目标——我们的Player类——以及应该被动画化的属性——在这种情况下是jumpFactor。然后我们定义该属性的起始和结束值,并且除此之外,我们还通过设置setKeyValueAt()定义一个中间值。qreal类型的第一个参数定义动画中的时间,其中 0 是开始,1 是结束,第二个参数定义动画在此时间应具有的值。所以你的jumpFactor元素将在 800 毫秒内从 0 动画到 1,再从 1 动画回 0。这是由setDuration()定义的。最后,我们定义起始值和结束值之间的插值方式,并通过将QEasingCurve::OutInQuad作为参数调用setEasingCurve()。Qt 定义了多达 41 种不同的缓动曲线,用于线性、二次、三次、四次、五次、正弦、指数、圆形、弹性、回弹和弹跳函数。这里描述太多。相反,请查看文档。只需搜索QEasingCurve::Type。在我们的情况下,QEasingCurve::OutInQuad确保本杰明的跳跃速度看起来像真正的跳跃:开始时快,顶部慢,然后再次变快。我们通过跳跃函数开始这个动画:
void Player::jump() {
if (QAbstractAnimation::Stopped == m_animation->state())
m_animation->start();
}
我们只有在动画未运行时才通过调用start()来启动动画。因此,我们检查动画的状态以确定它是否已停止。其他状态可能是Paused或Running。我们希望当玩家按下键盘上的空格键时,这个跳跃动作被激活。因此,我们通过以下代码扩展了按键事件处理程序内的 switch 语句:
case Qt::Key_Space:
m_player->jump();
break;
现在属性开始动画化了,但本杰明仍然不会跳起来。因此,我们将jumpFactorChange()信号连接到处理跳跃的场景槽中:
void MyScene::jumpPlayer(qreal factor) {
const qreal y = (m_groundLevel - m_player->boundingRect().height()) - factor * m_jumpHeight;
m_player->setPos(m_player->pos().x(), y);
//...
}
在该函数内部,我们计算玩家物品的Y坐标,以尊重由m_groundLevel定义的地*面。这是通过从地*面的值中减去物品的高度来完成的,因为物品的原始点是左上角。然后我们减去由m_jumpHeight定义的最大跳跃高度,该高度乘以实际的跳跃因子。由于该因子在 0 到 1 的范围内,新的Y坐标保持在允许的跳跃高度内。然后我们通过调用setPos()来改变玩家物品的Y位置,同时保持X坐标不变。就这样,本杰明跳起来了!
尝试一下英雄——让场景处理本杰明的跳跃
当然,我们可以在场景类内部进行属性动画,而不需要通过QObject扩展Player。但这是一个如何做的示例。所以尝试将使本杰明跳跃的逻辑放入场景类中。然而,这样做更一致,因为我们已经在那里移动本杰明了。或者,也可以反过来,将本杰明的左右移动也放到Player类中。
行动时间 - 保持多个动画同步
如果你查看硬币(其类名为Coin)的创建方式,你会看到类似的结构。它们从QObject和QGraphicsEllipseItem继承,并定义了两个属性:类型为qreal的不透明度和类型为QRect的rect。这是通过以下代码完成的:
Q_PROPERTY(qreal opacity READ opacity WRITE setOpacity)
Q_PROPERTY(QRectF rect READ rect WRITE setRect)
没有添加任何函数或槽,因为我们只是使用了QGraphicsItem的内置函数并将它们“重新声明”为属性。然后,这两个属性通过两个QPropertyAnimation对象进行动画处理。一个使硬币淡出,而另一个使硬币放大。为了确保两个动画同时开始,我们使用以下方式QParallelAnimationGroup:
QPropertyAnimation *fadeAnimation = /* set up */
QPropertyAnimation *scaleAnimation = /* set up */
QParallelAnimationGroup *group = new QParallelAnimationGroup(this);
group->addAnimation(fadeAnimation);
group->addAnimation(scaleAnimation);
group->start();
刚才发生了什么?
在设置完每个属性动画后,我们通过在组上调用addAnimation()并将我们想要添加的动画的指针传递给组,将它们添加到组动画中。然后,当我们开始组动画时,QParallelAnimationGroup确保所有分配的动画同时开始。
当硬币爆炸时,动画被设置好了。你可能想看看源代码中硬币的explode()函数。当本杰明触摸硬币时,硬币应该爆炸。
提示
如果你想要一个接一个地播放动画,你可以使用QSequentialAnimationGroup。
物件碰撞检测
检查玩家物件是否与硬币发生碰撞是通过场景的checkColliding()函数完成的,该函数在玩家物件移动后(movePlayer())或本杰明跳跃后(jumpPlayer())被调用。
行动时间 – 使硬币爆炸
checkColliding()的实现如下:
QList<QGraphicsItem*> items = collidingItems(m_player);
for (int i = 0, total = items.count(); i < total; ++i) {
if (Coin *c = qgraphicsitem_cast<Coin*>(items.at(i)))
c->explode();
}
刚才发生了什么?
首先,我们调用场景的 QGraphicsScene::collidingItems() 函数,该函数接受一个参数,即需要检测碰撞项的第一个参数。通过第二个可选参数,你可以定义如何检测碰撞。该参数的类型是 Qt::ItemSelectionMode,这在前面已经解释过。在我们的例子中,将返回与 m_player 碰撞的所有项的列表。因此,我们遍历这个列表,检查当前项是否是 Coin 对象。这是通过尝试将指针转换为 Coin. 来实现的。如果成功,我们将通过调用 explode() 来爆炸硬币。多次调用 explode() 函数没有问题,因为它不会允许发生多次爆炸。这很重要,因为 checkColliding() 将在玩家的每次移动后被调用。所以,当玩家第一次碰到硬币时,硬币会爆炸,但这需要时间。在爆炸期间,玩家很可能会再次移动,因此会再次与硬币碰撞。在这种情况下,explode() 可能会被第二次、第三次、第 x 次调用。
collidingItems() 函数总是会返回背景项,因为玩家项通常位于所有这些项之上。为了避免不断检查它们是否实际上是硬币,我们使用了一个技巧。在用于背景项的 BackgroundItem 类中,实现 QGraphicsItem 项的虚拟 shape() 函数如下:
QPainterPath BackgroundItem::shape() const {
return QPainterPath();
}
由于碰撞检测是通过项的形状来完成的,背景项不能与其他任何项发生碰撞,因为它们的形状始终是空的。QPainterPath 本身是一个包含图形形状信息的类。有关更多信息——由于我们不需要为我们的游戏做任何特殊处理——请查看文档。这个类相当直观。
如果我们在 Player 中实现跳跃逻辑,我们可以在项内部实现项碰撞检测。QGraphicsItem 还提供了一个 collidingItems() 函数,用于检查与自身碰撞的项。所以 scene->collidingItems(item) 等同于 item->collidingItems()。
如果你只对项是否与另一个项发生碰撞感兴趣,你可以在项上调用 collidesWithItem(),并将另一个项作为参数传递。
设置游戏场地
我们必须讨论的最后一个函数是场景的 initPlayField() 函数,在这里所有设置都已完成。在这里,我们初始化天空、树木、地面和玩家项。由于没有特殊之处,我们跳过这部分,直接看看硬币是如何初始化的:
const int xrange = (m_maxX - m_minX) * 0.94;
m_coins = new QGraphicsRectItem(0,0,m_fieldWidth, m_jumpHeight);
m_coins->setPen(Qt::NoPen);
for (int i = 0; i < 25; ++i) {
Coin *c = new Coin(m_coins);
c->setPos(m_minX + qrand()%xrange, qrand()%m_jumpHeight);
}
addItem(m_coins);
m_coins->setPos(0, m_groundLevel - m_jumpHeight);
总共,我们添加了 25 枚硬币。首先,我们计算 m_minX 和 m_maxX 之间的宽度。这是本杰明可以移动的空间。为了使其稍微小一点,我们只取 94%的宽度。然后,我们设置一个大小为虚拟世界的不可见项目,称为 m_coins。这个项目应该是所有硬币的父项目。然后,在 for 循环中,我们创建一个硬币并随机设置其 x 和 y 位置,确保通过计算可用宽度和最大跳跃高度的模数,本杰明可以到达它们。添加完所有 25 枚硬币后,我们将持有所有硬币的父项目放置在场景中。由于大多数硬币都在实际视图的矩形之外,我们还需要在移动本杰明时移动硬币。因此,m_coins 必须像任何其他背景一样行为。为此,我们只需添加以下代码:
m_coins->setPos(-(m_coins->boundingRect().width() - width()) * ff,m_coins->y());
我们将前面的代码添加到 movePlayer() 函数中,我们也会以相同的模式移动天空。
来吧,英雄——扩展游戏
就这些了。这是我们的小游戏。当然,还有很多改进和扩展的空间。例如,你可以添加一些本杰明必须跳过的障碍物。然后,你必须检查当玩家项目向前移动时,玩家项目是否与这样的障碍物项目发生碰撞,如果是,则拒绝移动。你已经学会了完成这个任务所需的所有必要技术,所以尝试实现一些额外的功能来加深你的知识。
动画的第三种方法
除了 QTimer 和 QPropertyAnimation,还有第三种方法来动画化场景。场景提供了一个名为 advance() 的槽。如果你调用这个槽,场景会将这个调用转发给它持有的所有项目,通过在每个项目上调用 advance() 来实现。场景会这样做两次。首先,所有项目的 advance() 函数都会以 0 作为参数被调用。这意味着项目即将前进。然后在第二轮中,所有项目都会被调用,将 1 传递给项目的 advance() 函数。在这个阶段,每个项目都应该前进,无论这意味着什么;可能是移动,可能是颜色变化,等等。场景的 advance 槽通常由 QTimeLine 元素调用;通过这个,你可以定义在特定时间段内时间线应该触发多少次。
QTimeLine *timeLine = new QTimeLine(5000, this);
timeLine->setFrameRange(0, 10);
这个时间线将每 5 秒发出一次 frameChanged() 信号,共 10 次。你所要做的就是将这个信号连接到场景的 advance() 槽,这样场景将在 50 秒内前进 10 次。然而,由于每个项目都会为每次前进接收两次调用,这可能不是场景中只有少数项目应该前进的动画解决方案的最佳选择。
图形视图内的小部件
为了展示图形视图的一个整洁功能,请看以下代码片段,它向场景添加了一个小部件:
QSpinBox *box = new QSpinBox;
QGraphicsProxyWidget *proxyItem = new QGraphicsProxyWidget;
proxyItem->setWidget(box);
QGraphicsScene scene;
scene.addItem(proxyItem);
proxyItem->setScale(2);
proxyItem->setRotation(45);
首先,我们创建一个 QSpinBox 和一个 QGraphicsProxyWidget 元素,它们作为小部件的容器并间接继承 QGraphicsItem。然后,我们通过调用 addWidget() 将旋转框添加到代理小部件中。旋转框的所有权并未转移,但当 QGraphicsProxyWidget 被删除时,它会调用所有分配的小部件的 delete 方法。因此,我们不必担心这一点。你添加的小部件应该是无父级的,并且不得在其他地方显示。在将小部件设置到代理后,你可以像对待任何其他项目一样对待代理小部件。接下来,我们将它添加到场景中,并应用一个变换以进行演示。结果如下:

场景中旋转并缩放的旋转框
由于它是一个常规项目,你甚至可以为其添加动画,例如,使用属性动画。然而,请注意,最初,Graphics View 并未设计为容纳小部件。因此,当你向场景中添加大量小部件时,你将很快注意到性能问题,但在大多数情况下,它应该足够快。
如果你想要在布局中排列一些小部件,可以使用 QGraphicsAnchorLayout、QGraphicsGridLayout 或 QGraphicsLinearLayout。创建所有小部件,创建你选择的布局,将小部件添加到该布局中,并将布局设置到一个 QGraphicsWidget 元素上,这是所有小部件的基类,并且可以通过调用 setLayout() 轻易地被认为是 Graphics View 的 QWidget 等价物:
QGraphicsScene scene;
QGraphicsProxyWidget *edit = scene.addWidget(
new QLineEdit("Some Text"));
QGraphicsProxyWidget *button = scene.addWidget(
new QPushButton("Click me!"));
QGraphicsLinearLayout *layout = new QGraphicsLinearLayout;
layout->addItem(edit);
layout->addItem(button);
QGraphicsWidget *graphicsWidget = new QGraphicsWidget;
graphicsWidget->setLayout(layout);
scene.addItem(graphicsWidget);
场景的 addWidget() 函数是一个便利函数,在第一次使用 QLineEdit 时表现如下,如下代码片段所示:
QGraphicsProxyWidget *proxy = new QGraphicsProxyWidget(0);
proxy->setWidget(new QLineEdit("Some Text"));
scene.addItem(proxy);
带有布局的项目将看起来像这样:

优化
让我们来看看我们可以执行的一些优化,以加快场景的运行速度。
二叉空间划分树
场景持续记录其内部二叉空间划分树中项目的位置。因此,每当移动一个项目时,场景都必须更新树,这个操作可能会变得非常耗时和消耗内存。这对于具有大量动画项目的场景尤其如此。另一方面,树允许你以极快的速度找到项目(例如,使用 items() 或 itemAt()),即使你有成千上万的项。
因此,当你不需要任何关于物品的位置信息时——这也包括碰撞检测——你可以通过调用 setItemIndexMethod(QGraphicsScene::NoIndex) 来禁用索引函数。然而,请注意,调用 items() 或 itemAt() 会导致遍历所有物品以进行碰撞检测,这可能会对具有许多物品的场景造成性能问题。如果你不能完全放弃树,你仍然可以通过 setBspTreeDepth() 调整树的深度,将深度作为参数。默认情况下,场景将在考虑了几个参数(如大小和物品数量)后猜测一个合理的值。
缓存物品的涂漆功能
如果你有一些具有耗时涂漆功能的物品,你可以更改物品的缓存模式。默认情况下,没有渲染被缓存。使用 setCacheMode(),你可以将模式设置为 ItemCoordinateCache 或 DeviceCoordinateCache。前者在给定 QSize 元素的缓存中渲染物品。该缓存的大小可以通过 setCacheMode() 的第二个参数来控制。因此,质量取决于你分配的空间大小。缓存随后被用于每个后续的涂漆调用。缓存甚至用于应用变换。如果质量下降太多,只需通过再次调用 setCacheMode() 并使用更大的 QSize 元素来调整分辨率即可。另一方面,DeviceCoordinateCache 不在物品级别上缓存物品,而是在设备级别上缓存。因此,对于不经常变换的物品来说,这是最优的,因为每次新的变换都会导致新的缓存。然而,移动物品并不会导致新的缓存。如果你使用这种缓存模式,你不需要使用第二个参数定义分辨率。缓存始终以最大质量执行。
优化视图
由于我们正在讨论物品的涂漆功能,让我们谈谈相关的内容。一开始,当我们讨论物品的外观并创建了一个黑色矩形物品时,我告诉你要像得到画家一样返回。如果你遵循了这个建议,你可以在视图中调用 setOptimizationFlag(DontSavePainterState, true)。默认情况下,视图确保在调用物品的涂漆功能之前保存画家状态,并在之后恢复状态。如果你有一个包含 50 个物品的场景,这将导致画家状态保存和恢复大约 50 次。如果你防止自动保存和恢复,请记住,现在标准物品将改变画家状态。所以如果你同时使用标准和自定义物品,要么保持默认行为,要么设置 DontSavePainterState,然后在每个物品的涂漆函数中使用默认值设置笔和刷。
可以与setOptimizationFlag()一起使用的另一个标志是DontAdjustForAntialiasing。默认情况下,视图会通过所有方向调整每个项目的绘制区域 2 个像素。这很有用,因为当绘制抗锯齿时,很容易画出边界矩形之外。如果你不绘制抗锯齿或者确定你的绘制将始终在边界矩形内,请启用此优化。如果你启用了此标志并在视图中发现绘画伪影,那么你没有尊重项目的边界矩形!
作为进一步的优化,你可以定义视图在场景变化时应如何更新其视口。你可以使用setViewportUpdateMode()设置不同的模式。默认情况下(QGraphicsView::MinimalViewportUpdate),视图试图确定需要更新的区域,并且只重新绘制这些区域。然而,有时找到所有需要重新绘制的区域比简单地绘制整个视口更耗时。如果你有很多小的更新,那么QGraphicsView::FullViewportUpdate是更好的选择,因为它简单地重新绘制整个视口。最后两种模式的组合是QGraphicsView::BoundingRectViewportUpdate。在此模式下,Qt 检测所有需要重新绘制的区域,然后重新绘制覆盖所有受更改影响的区域的视口矩形。如果最佳更新模式随时间变化,你可以使用QGraphicsView::SmartViewportUpdate来告诉 Qt 确定最佳模式。然后,视图会尝试找到最佳的更新模式。
作为最后的优化,你可以利用 OpenGL。而不是使用基于QWidget的默认视口,建议图形视图使用 OpenGL 小部件。这样,你可以使用 OpenGL 带来的所有功能。
GraphicsView view;
view.setViewport(new QGLWidget(&view));
不幸的是,你不仅要输入这一行,还需要做更多的工作,但这超出了本章的主题和范围。然而,你可以在 Qt 的文档示例中找到更多关于 OpenGL 和图形视图的信息,在“盒子”部分以及 Rødal 的 Qt 季度文章中——第 26 期——可以在网上找到,网址为doc.qt.digia.com/qq/qq26-openglcanvas.html。
注意
关于优化的通用说明:不幸的是,我无法说你必须这样做或那样做来优化图形视图,因为这高度依赖于你的系统和视图/场景。然而,我可以告诉你如何进行。一旦你完成了基于图形视图的游戏,使用分析器测量你游戏的性能。进行你认为可能带来收益的优化,或者简单地猜测,然后再次分析你的游戏。如果结果更好,保留更改;否则,拒绝它。这听起来很简单,这是进行优化的唯一方法。然而,随着时间的推移,你的预测将变得更好。
突击测验——掌握图形视图
在学习本章后,你应该能够回答这些问题,因为当涉及到基于图形视图设计游戏组件时,这些问题非常重要:
Q1. Qt 提供哪些标准项目?
Q2. 项目的坐标系与场景的坐标系有何关联?接下来,场景的坐标系与视图的坐标系有何关联?
Q3. 如何扩展项目以使用属性以及信号和槽?
Q4. 如何借助动画创建逼真的运动?
Q5. 如何提高图形视图的性能?
摘要
在本章的第一部分,你学习了图形视图架构的工作原理。首先,我们查看了一些项目。在那里,你学习了如何使用 QPainter 创建自己的项目,以及 Qt 提供哪些标准项目。随后,我们也讨论了如何转换这些项目,以及转换的原点与项目有何关联。接下来,我们了解了项目的坐标系、场景和视图的坐标系。我们还看到了这三个部分是如何协同工作的,例如如何将项目放置在场景中。最后,我们学习了如何在视图中缩放和移动场景。同时,你也阅读了关于高级主题的内容,例如在绘制项目时考虑缩放级别。
在第二部分,你深化了对项目、场景和视图的知识。在开发游戏的过程中,你熟悉了不同的动画项目方法,并学习了如何检测碰撞。作为一个高级主题,你被引入了视差滚动的概念。
在完成整个章节后,你现在应该几乎了解关于图形视图的所有内容。你能够创建完整的自定义项目,你可以修改或扩展标准项目,并且根据细节级别信息,你甚至有能力根据缩放级别改变项目的外观。你可以转换项目和场景,并且可以动画化项目和整个场景。
此外,正如你在开发游戏时所看到的,你的技能足够开发一个具有视差滚动的跳跃和跑酷游戏,这在高度专业的游戏中是常见的。为了保持游戏流畅和高度响应,我们最后看到了一些如何充分利用图形视图的技巧。
为了搭建通往小部件世界的桥梁,你也学习了如何将基于 QWidget 的项目整合到图形视图中。有了这些知识,你可以创建现代的基于小部件的用户界面。
第七章:网络编程
在本章中,您将学习如何与互联网服务器以及一般套接字进行通信。首先,我们将查看
QNetworkAccessManager,它使得发送网络请求和接收回复变得非常简单。基于这些基本知识,我们将使用谷歌的距离 API 来获取两个位置之间的距离以及从一个位置到另一个位置所需的时间。这种技术和相应的知识也可以用来通过各自的 API 将 Facebook 或 Twitter 集成到您的应用程序中。然后,我们将查看 Qt 的 Bearer API,它提供了设备连接状态的信息。在最后一节中,您将学习如何使用套接字创建自己的服务器和客户端,使用 TCP 或 UDP 作为网络协议。
QNetworkAccessManager
访问互联网上的文件最简单的方法是使用 Qt 的网络访问 API。此 API 以QNetworkAccessManager为中心,它处理您游戏与互联网之间的完整通信。
当我们现在开发和测试一个网络启用应用程序时,如果可行,建议您使用一个私有、本地的网络。这样,您可以调试连接的两端,并且错误不会暴露敏感数据。如果您不熟悉在您的机器上本地设置 Web 服务器,幸运的是,有许多免费的全能安装程序可供使用。这些程序将自动在您的系统上配置 Apache2、MySQL(或 MariaDB)、PHP 以及更多。例如,在 Windows 上,您可以使用 XAMPP (www.apachefriends.org) 或 Uniform Server (www.uniformserver.com);在苹果电脑上,有 MAMP (www.mamp.info);而在 Linux 上,通常您不需要做任何事情,因为已经存在 localhost。如果不是这样,请打开您首选的包管理器,搜索名为Apache2或类似名称的包,并安装它。或者,查看您发行版的文档。
在您在机器上安装 Apache 之前,考虑使用虚拟机,如 VirtualBox (www.virtualbox.org) 来完成这项任务。这样,您可以保持机器的整洁,并且可以轻松尝试为测试服务器设置不同的配置。使用多个虚拟机,您甚至可以测试您游戏的不同实例之间的交互。如果您使用 Unix,那么 Docker (www.docker.com) 可能值得一看。
通过 HTTP 下载文件
为了做到这一点,首先尝试设置一个本地服务器并在已安装服务器的根目录下创建一个名为version.txt的文件。这个文件应该包含一小段文本,例如“我是一个在 localhost 上的文件”或类似的内容。为了测试服务器和文件是否正确设置,启动一个网页浏览器并打开http://localhost/version.txt。你应该会看到文件的内容。当然,如果你有访问域名的权限,你也可以使用它。只需相应地更改示例中使用的 URL。如果这失败了,可能是因为你的服务器不允许你显示文本文件。与其在服务器的配置中迷失方向,不如将文件重命名为version.html。这应该会解决问题!

在浏览器上请求 http://localhost/version.txt 的结果
如你所猜,由于文件名,实际场景可能是检查服务器上是否有你游戏或应用的更新版本。要获取文件的内容,只需要五行代码。
行动时间——下载文件
首先,创建一个QNetworkAccessManager的实例:
QNetworkAccessManager *m_nam = new QNetworkAccessManager(this);
由于QNetworkAccessManager继承自QObject,它需要一个指向QObject的指针,该指针用作父对象。因此,你不必担心稍后删除管理器。此外,一个QNetworkAccessManager的单例就足够整个应用程序使用。所以,要么在你的游戏中传递网络访问管理器的指针,要么为了方便使用,创建一个单例模式并通过它访问管理器。
小贴士
单例模式确保一个类只被实例化一次。这种模式对于访问应用程序的全局配置或——在我们的例子中——QNetworkAccessManager的一个实例非常有用。在www.qtcentre.org和www.qt-project.org的维基页面上,你可以找到不同单例模式的示例。一个简单的基于模板的方法可能看起来像这样(作为一个头文件):
template <class T>
class Singleton
{
public:
static T& Instance()
{
static T _instance;
return _instance;
}
private:
Singleton();
~Singleton();
Singleton(const Singleton &);
Singleton& operator=(const Singleton &);
};
在源代码中,你将包含那个头文件,并使用以下方式获取名为MyClass的类的单例:
MyClass *singleton = &Singleton<MyClass>::Instance();
如果你使用 Qt Quick——它将在第九章(ch09.html,“Qt Quick 基础知识”)中解释——并且使用QQuickView,你可以直接使用视图的QNetworkAccessManager实例:
QQuickView *view = new QQuickView;
QNetworkAccessManager *m_nam
= view->engine()->networkAccessManager();
第二,我们将管理器的finished()信号连接到我们选择的槽;例如,在我们的类中,我们有一个名为downloadFinished()的槽:
connect(m_nam, SIGNAL(finished(QNetworkReply*)), this,
SLOT(downloadFinished(QNetworkReply*)));
第三,我们实际上是从本地主机请求version.txt文件:
m_nam->get(QNetworkRequest(QUrl("http://localhost/version.txt")));
使用 get(),会发布一个获取指定 URL 文件内容的请求。该函数期望 QNetworkRequest,它定义了发送网络请求所需的所有信息。此类请求的主要信息自然是文件 URL。这就是为什么 QNetworkRequest 在其构造函数中将 QUrl 作为参数的原因。你也可以使用 setUrl() 将 URL 设置到请求中。如果你想要定义一些额外的头部,你可以使用 setHeader() 来设置最常见的头部,或者使用 setRawHeader() 来获得完全的灵活性。如果你想设置一个自定义的用户代理到请求中,调用将看起来像:
QNetworkRequest request;
request.setUrl(QUrl("http://localhost/version.txt"));
request.setHeader(QNetworkRequest::UserAgentHeader, "MyGame");
m_nam->get(request);
setHeader() 函数接受两个参数,第一个是 QNetworkRequest::KnownHeaders 枚举的值,它包含最常见的——自解释的——头部,例如 LastModifiedHeader 或 ContentTypeHeader,第二个是实际值。你也可以使用 setRawHeader() 编写头部:
request.setRawHeader("User-Agent", "MyGame");
当你使用 setRawHeader() 时,你必须自己编写头部字段名称。除此之外,它表现得像 setHeader()。所有 HTTP 协议版本 1.1 中可用的头部列表可以在 RFC 2616 的第十四部分中找到(www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14)。
回到我们的例子:使用 get() 函数,我们从本机请求了 version.txt 文件。从现在开始,我们只需要等待服务器回复。一旦服务器的回复完成,之前连接语句中定义的 downloadFinished() 插槽将被调用。作为参数,一个 QNetworkReply 类型的回复被传递到插槽中,我们可以读取回复的数据并将其设置到 m_edit,一个 QPlainTextEdit 的实例,使用以下方式:
void FileDownload::downloadFinished(QNetworkReply *reply) {
const QByteArray content = reply->readAll();
m_edit->setPlainText(content);
reply->deleteLater();
}
由于 QNetworkReply 继承了 QIODevice,因此还有其他读取回复内容的方法,包括 QDataStream 或 QTextStream,用于读取和解释二进制数据或文本数据。在这里,作为第四个命令,使用 QIODevice::readAll() 获取请求文件的完整内容到 QByteArray。转移给相应 QNetworkReply 的指针的责任在我们这里,因此我们需要在插槽的末尾删除它。这将是需要下载文件时 Qt 的第五行代码。然而,请注意,不要直接调用 delete。始终使用 deleteLater(),如文档中建议的那样!
完整的源代码可以在本书附带捆绑的 FileDownload 示例中找到。如果你启动这个小型的演示应用程序并点击 Load File 按钮,你应该会看到:

尝试一下英雄 - 扩展基本文件下载器
如果您还没有设置本地主机,只需将源代码中的 URL 更改为下载另一个文件。当然,为了下载另一个文件而不得不更改源代码远非理想的方法。因此,尝试通过添加一行编辑框来扩展对话框,以便您可以指定要下载的 URL。此外,您还可以提供一个文件对话框来选择下载文件将保存的位置。
错误处理
如果您看不到文件的内容,可能出了问题。就像现实生活中一样,这种情况经常发生。因此,我们需要确保在这种情况下有一个良好的错误处理机制来通知用户发生了什么。
动手实践 – 显示适当的错误消息
幸运的是,QNetworkReply提供了几种处理错误的方法。在名为downloadFinished()的槽中,我们首先想要检查是否发生了错误:
if (reply->error() != QNetworkReply::NoError) {/* error occurred */}
QNetworkReply::error()函数返回处理请求时发生的错误。错误被编码为QNetworkReply::NetworkError类型的值。最常见的前两个错误可能是这些:
| 错误代码 | 含义 |
|---|---|
ContentNotFoundError |
此错误表示请求的 URL 无法找到。它类似于 HTTP 错误代码 404。 |
ContentAccessDenied |
此错误表示您没有权限访问请求的文件。它类似于 HTTP 错误代码 401。 |
您可以在文档中查找其他 23 个错误代码。但通常,您不需要确切知道发生了什么错误。您只需要知道一切是否顺利——在这种情况下,QNetworkReply::NoError将是返回值——或者是否出现了错误。
小贴士
由于QNetworkReply::NoError的值为0,您可以将测试语句缩短以检查是否发生了错误:
if (reply->error()) {
// an error occurred
}
为了向用户提供有意义的错误描述,您可以使用QIODevice::errorString()。文本已经设置了相应的错误消息,我们只需显示它:
if (reply->error()) {
const QString error = reply->errorString();
m_edit->setPlainText(error);
return;
}
在我们的例子中,假设我们在 URL 中犯了错误,错误地写成了versions.txt,应用程序将看起来像这样:

如果请求是 HTTP 请求并且您对状态码感兴趣,可以通过QNetworkReply::attribute()获取:
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute)
由于它返回QVariant,你可以使用QVariant::toInt()将代码作为整数获取,或者使用QVariant::toString()将数字作为QString获取。除了 HTTP 状态码外,你还可以通过attribute()查询很多其他信息。查看文档中QNetworkRequest::Attribute枚举的描述。在那里,你还可以找到QNetworkRequest::HttpReasonPhraseAttribute,它包含 HTTP 状态码的可读原因短语。例如,如果发生 HTTP 错误 404,则为“未找到”。此属性的值用于设置QIODevice::errorString()的错误文本。因此,你可以使用errorString()提供的默认错误描述,或者通过解释回复的属性来创建自己的描述。
小贴士
如果下载失败并且你想恢复它,或者如果你只想下载文件的一部分,你可以使用Range头:
QNetworkRequest req(QUrl("..."));
req.setRawHeader("Range", "bytes=300-500");
QNetworkReply *reply = m_nam->get(req);
在此示例中,只会下载从300到500的字节。然而,服务器必须支持这一点。
通过 FTP 下载文件
通过 FTP 下载文件与通过 HTTP 下载文件一样简单。如果是一个不需要认证的匿名 FTP 服务器,只需使用我们之前使用的 URL 即可。假设在本地主机的 FTP 服务器上再次有一个名为version.txt的文件,输入:
m_nam->get(QNetworkRequest(QUrl("ftp://localhost/version.txt")));
就这些了,其他一切保持不变。如果 FTP 服务器需要认证,你会得到一个错误,例如:

设置用户名和密码以访问 FTP 服务器同样简单:要么将其写入 URL 中,要么使用QUrl的setUserName()和setPassword()函数。如果服务器不使用标准端口,你可以使用QUrl::setPort()显式设置端口。
小贴士
要将文件上传到 FTP 服务器,使用QNetworkAccessManager::put(),它以QNetworkRequest作为其第一个参数,调用一个 URL 来定义服务器上新文件的名字,以及实际数据作为其第二个参数,该数据应该被上传。对于小文件上传,你可以将内容作为QByteArray传递。对于更大的内容,最好使用QIODevice的指针。确保设备在上传完成前保持打开和可用。
并行下载文件
关于 QNetworkAccessManager 的一个非常重要的注意事项:它是异步工作的。这意味着您可以在不阻塞主事件循环的情况下发布网络请求,这正是保持 GUI 响应的原因。如果您发布多个请求,它们将被放入管理器的队列中。根据使用的协议,它们将并行处理。如果您正在发送 HTTP 请求,通常一次最多处理六个请求。这不会阻塞应用程序。因此,实际上没有必要在线程中封装 QNetworkAccessManager;然而,遗憾的是,这种不必要的做法在互联网上被频繁推荐。QNetworkAccessManager 已经内部线程化。实际上,除非您确切知道自己在做什么,否则不要将 QNetworkAccessManager 移到线程中。
如果您发送多个请求,连接到管理器 finished() 信号的槽位将根据请求从服务器获得回复的速度以任意顺序被调用。这就是为什么您需要知道回复属于哪个请求的原因。这也是每个 QNetworkReply 都携带其相关 QNetworkRequest 的原因之一。它可以通过 QNetworkReply::request() 访问。
即使确定回复及其目的可能适用于单个槽位的小型应用,但如果发送大量请求,问题会迅速变得庞大且混乱。由于所有回复都只发送到单个槽位,这个问题变得更加严重。由于很可能存在需要不同处理的多种回复类型,因此将它们捆绑在特定槽位中会更好,这些槽位专门用于特定任务。幸运的是,这可以非常容易地实现。QNetworkAccessManager::get() 返回一个指向 QNetworkReply 的指针,该指针将获取您使用 get() 发送的请求的所有信息。通过使用此指针,您可以将特定槽位连接到回复的信号。
例如,如果您有多个 URL 并且想要将这些网站的所有链接图片保存到您的硬盘上,那么您可以通过 QNetworkAccessManager::get() 请求所有网页,并将它们的回复连接到一个专门用于解析接收到的 HTML 的槽位。如果找到图片链接,此槽位将再次使用 get() 请求它们。然而,这次对这些请求的回复将连接到第二个槽位,该槽位设计用于将图片保存到磁盘。因此,您可以分离这两个任务:解析 HTML 和将数据保存到本地驱动器。
接下来讨论 QNetworkReply 的最重要信号。
完成信号
finished() 信号是我们之前使用的 QNetworkAccessManager::finished() 信号的等价物。一旦回复返回(无论成功与否),就会触发此信号。在此信号发出后,回复的数据及其元数据将不再被更改。通过此信号,你现在可以将一个回复连接到特定的槽。这样,你可以实现上一节中概述的保存图像的场景。
然而,一个问题仍然存在:如果你同时发送请求,你不知道哪个请求已经完成并因此调用了连接的槽。与 QNetworkAccessManager::finished() 不同,QNetworkReply::finished() 不会传递一个指向 QNetworkReply 的指针;在这种情况下,这实际上是指向它自己的指针。一个快速解决这个问题的方法就是使用 sender()。它返回调用槽的 QObject 实例的指针。由于我们知道它是 QNetworkReply,我们可以编写:
QNetworkReply *reply = qobject_cast<QNetworkReply*>
(sender());
if (!reply)
return;
这是通过将 sender() 强制转换为 QNetworkReply 类型的指针来实现的。
提示
每当你将继承自 QObject 的类进行强制类型转换时,请使用 qobject_cast。与 dynamic_cast 不同,它不使用 RTTI,并且可以在动态库边界之间工作。
虽然我们可以相当确信强制类型转换将成功,但不要忘记检查指针是否有效。如果是空指针,则退出槽。
实践时间 – 使用 QSignalMapper 编写符合 OOP 代码
一种不依赖于 sender() 的更优雅的方法是使用 QSignalMapper 和一个局部哈希,其中存储了连接到该槽的所有回复。所以,每次你调用 QNetworkAccessManager::get() 时,将返回的指针存储在 QHash<int, QNetworkReply*> 类型的成员变量中,并设置映射器。假设我们有以下成员变量,并且它们已经正确设置:
QNetworkAccessManager *m_nam;
QSignalMapper *m_mapper;
QHash<int, QNetworkReply*> m_replies;
然后,你这样连接一个回复的 finished() 信号:
QNetworkReply *reply = m_nam->get(QNetworkRequest(QUrl(/*...*/)));
connect(reply, SIGNAL(finished()), m_mapper, SLOT(map()));
int id = /* unique id, not already used in m_replies*/;
m_replies.insert(id, reply);
m_mapper->setMapping(reply, id);
刚才发生了什么?
首先,我们发送了请求并使用 reply 获取了 QNetworkReply 的指针。然后,我们将回复的 finished 信号连接到映射器的槽 map()。接下来,我们找到了一个唯一的 ID,这个 ID 必须不在 m_replies 变量中使用。你可以使用 qrand() 生成的随机数,只要它们是唯一的就可以。为了确定一个键是否已被使用,调用 QHash::contains()。它接受一个键作为参数,该参数应与之进行比较。或者甚至更简单,增加另一个私有成员变量。一旦我们有了唯一的 ID,我们就可以使用 ID 作为键将 QNetworkReply 的指针插入到哈希中。最后,使用 setMapping(),我们设置映射器的映射:ID 的值对应于实际的回复。
在一个显眼的位置,很可能是类的构造函数中,我们已将 map() 信号连接到自定义槽。例如:
connect(m_mapper, SIGNAL(mapped(int)), this, SLOT(downloadFinished(int)));
当调用 downloadFinished() 插槽时,我们可以通过以下方式获取相应的回复:
void SomeClass::downloadFinished(int id) {
QNetworkReply *reply = m_replies.take(id);
// do some stuff with reply here
reply->deleteLater();
}
提示
QSignalMapper 还允许您使用 QString 作为标识符而不是使用前面代码中使用的整数。因此,您可以重写示例并使用 URL 来识别相应的 QNetworkReply,至少在 URL 是唯一的情况下。
错误信号
如果您按顺序下载文件,您可以替换错误处理。您不需要在连接到 finished() 信号的槽中处理错误,而是可以使用回复的 error() 信号,它将 QNetworkReply::NetworkError 类型的错误传递到槽中。在 error() 信号发出后,finished() 信号很可能会很快发出。
readyRead 信号
到目前为止,我们一直使用连接到 finished() 信号的槽来获取回复的内容。如果您处理的是小文件,这工作得很好。然而,当处理大文件时,这种方法不适用,因为它们会不必要地绑定太多资源。对于大文件,最好在数据可用时立即读取并保存传输的数据。每当有新数据可供读取时,QIODevice::readyRead() 会通知我们。因此,对于大文件,您应该使用以下代码:
connect(reply, SIGNAL(readyRead()), this, SLOT(readContent()));
file.open(QIODevice::WriteOnly);
这将帮助您将回复的 readyRead() 信号连接到一个槽,设置 QFile 并打开它。在连接的槽中,输入以下代码片段:
const QByteArray ba = reply->readAll();
file.write(ba);
file.flush();
现在,您可以获取已传输的内容,并将其保存到(已打开的)文件中。这样,所需资源最小化。在 finished() 信号发出后,别忘了关闭文件。
在这个上下文中,如果您事先知道要下载的文件大小,这将很有帮助。因此,我们可以使用 QNetworkAccessManager::head()。它类似于 get() 函数,但不传输文件内容。只传输头部信息。如果我们幸运的话,服务器会发送 "Content-Length" 头部,其中包含字节数的文件大小。为了获取这些信息,我们输入:
reply->head(QNetworkRequest::ContentLengthHeader).toInt();
基于这些信息,我们还可以提前检查磁盘上是否有足够的空间。
下载进度方法
尤其是在下载大文件时,用户通常想知道已经下载了多少数据以及下载完成大约需要多长时间。
显示下载进度的时机
为了实现这一点,我们可以使用回复的 downloadProgress() 信号。作为第一个参数,它传递了已接收的字节数信息,作为第二个参数,传递了总字节数。这使我们能够使用 QProgressBar 来指示下载进度。由于传递的参数是 qint64 类型,我们不能直接使用 QProgressBar,因为它只接受 int。因此,在连接的槽中,我们首先计算下载进度的百分比:
void SomeClass::downloadProgress(qint64 bytesReceived, qint64 bytesTotal) {
qreal progress = (bytesTotal < 1) ? 1.0
: bytesReceived * 100.0 / bytesTotal;
progressBar->setValue(progress * progressBar->maximum());
}
刚才发生了什么?
使用百分比,我们为进度条设置新的值,其中progressBar是指向此进度条的指针。然而,progressBar->maximum()将会有什么值,我们在哪里设置进度条的取值范围?令人高兴的是,你不必为每个新的下载设置它。它只需要设置一次,例如,在包含进度条的类的构造函数中。作为取值范围,我建议:
progressBar->setRange(0, 2048);
原因在于,如果你以 0 到 100 的范围为例,并且进度条宽度为 500 像素,每次值的变化都会使进度条前进 5 像素。这看起来会很丑。为了得到*滑的进度,每次进度条只扩展 1 像素,范围应该是 0 到 99.999.999,这当然可以工作,但效率会很高。这是因为进度条的当前值会变化很多,而没有任何图形表示。因此,最佳的范围值应该是 0 到实际进度条宽度的像素数。不幸的是,进度条的宽度可能会根据实际小部件的宽度而变化,每次值变化时频繁查询实际大小也不是一个好的解决方案。那么为什么是2048呢?这个值背后的想法是屏幕的分辨率。全高清显示器通常宽度为 1920 像素,因此使用 2¹¹(2048)确保进度条即使在完全展开的情况下也能*滑运行。所以,2048 不是一个完美的数字,但是一个相当好的折衷方案。如果你针对的是较小的设备,选择一个更小、更合适的数字。
为了能够计算下载完成剩余时间,你必须启动一个计时器。在这种情况下,使用QElapsedTimer。在通过QNetworkAccessManager::get()发送请求后,通过调用QElapsedTimer::start()来启动计时器。假设计时器被命名为m_timer,计算将是:
qint64 total = m_timer.elapsed() / progress;
qint64 remaining = (total – m_timer.elapsed()) / 1000;
QElapsedTimer::elapsed()返回从计时器开始计数的毫秒数。这个值除以进度等于估计的总下载时间。如果你从已过的时间中减去,然后除以 1000,你将得到剩余时间的秒数。
使用代理
如果你喜欢使用代理,你首先必须设置QNetworkProxy。你必须使用setType()定义代理的类型。作为参数,你很可能会传递QNetworkProxy::Socks5Proxy或QNetworkProxy::HttpProxy。然后,使用setHostName()设置主机名,使用setUserName()设置用户名,使用setPassword()设置密码。最后两个属性当然只在代理需要认证时才需要。一旦代理设置好,你可以通过QNetworkAccessManager::setProxy()将其设置到访问管理器中。现在,所有新的请求都将使用此代理。
连接到 Google、Facebook、Twitter 等
由于我们已经讨论了 QNetworkAccessManager,你现在拥有了将 Facebook、Twitter 或类似网站集成到你的应用程序所需的知识。它们都使用 HTTP 协议和简单的请求来从它们那里检索数据。对于 Facebook,你必须使用所谓的 Graph API。它描述了哪些接口可用以及它们提供了哪些选项。如果你想搜索名为 "Helena" 的用户,你必须请求 graph.facebook.com/search?q=helena&type=user。当然,你可以使用 QNetworkManager 来做这件事。你可以在 developers.facebook.com/docs/graph-api 找到有关 Facebook 可能请求的更多信息。
如果你希望在游戏中显示推文,你必须使用 Twitter 的 REST 或搜索 API。假设你知道你想要显示的推文的 ID,那么你可以通过 api.twitter.com/1.1/statuses/show.json?id=12345 获取它,其中 12345 是推文的实际 ID。如果你想找到提到 #Helena 的推文,你可以写 api.twitter.com/1.1/search/tweets.json?q=%23Helena。你可以在 dev.twitter.com/docs/api 找到有关 Twitter API 参数和其他可能性的更多信息。
由于 Facebook 和 Twitter 使用它们的 API 都需要认证,我们将转向 Google。让我们使用 Google 的距离矩阵 API 来获取从一个城市到另一个城市所需时间的信息。我们将使用的 API 的技术文档可以在 developers.google.com/maps/documentation/distancematrix 找到。
行动时间 - 使用 Google 的距离矩阵 API
这个示例的 GUI 保持简单——源代码附在书中。它由两个行编辑(ui->from 和 ui->to)组成,允许你输入旅行的起点和终点。它还提供了一个组合框(ui->vehicle),允许你选择一种交通方式——无论是开车、骑自行车还是步行——一个按钮(ui->search)来启动请求,以及一个文本编辑器(ui->result)来显示结果。结果将看起来像这样:

MainWindow——QMainWindow 的子类——是应用程序的主要类,它包含两个私有成员:m_nam,它是一个指向 QNetworkAccessManager 的指针,以及 m_reply,它是一个指向 QNetworkReply 的指针。
行动时间 - 构建查询
每次按钮被按下时,都会调用 sendRequest() 槽:
void MainWindow::sendRequest()
{
if (m_reply != 0 && m_reply->isRunning())
m_reply->abort();
ui->result->clear();
在这个槽中,我们首先检查是否有一个旧请求,它存储在 m_reply 中,并且它仍在运行。如果是 true,我们将终止旧请求,因为我们即将安排一个新的请求。然后,我们通过在文本编辑器上调用 QPlainTextEdit::clear() 来清除上一次请求的结果。
接下来,我们将构建请求的 URL。我们可以通过手动组合字符串来完成此操作,我们将查询参数添加到基本 URL 中,类似于以下内容:
url = baseUrl + "?origin=" + ui->from->text() + "&...";
除了当我们包含多个参数时这很快变得难以阅读的问题之外,它还相当容易出错。行编辑器的值必须进行编码以符合有效 URL 的标准。因此,对于每个用户值,我们都必须显式调用 QUrl::toPercentEncoding()。一个更好的方法,它更容易阅读且错误更少,是使用 QUrlQuery。它避免了由于忘记编码数据而可能产生的问题。因此,我们这样做:
QUrlQuery query;
query.addQueryItem("sensor", "false");
query.addQueryItem("language", "en");
query.addQueryItem("units", "metric");
query.addQueryItem("origins", ui->from->text());
query.addQueryItem("destinations", ui->to->text());
query.addQueryItem("mode", ui->vehicle->currentText());
使用方法非常清晰:我们创建一个实例,然后使用 addQueryItem() 添加查询参数。第一个参数被视为键,第二个参数被视为值,结果是一个如 "key=value" 的字符串。当我们将 QUrlQuery 与 QUrl 一起使用时,值将被自动编码。使用 QUrlQuery 的其他好处是,我们可以使用 hasQueryItem() 检查是否已经设置了键,将键作为参数传递,或者通过调用 removeQueryItem() 删除之前设置的键。
在实际情况下,我们当然会使用 QStringLiteral 将所有前面的字面量包装起来,但在这里我们为了更好的可读性而省略了它。因此,让我们回顾一下我们设置了哪些参数。sensor 键设置为 false,因为我们没有使用 GPS 设备来定位我们的位置。language 键设置为 English,对于单位,我们更喜欢公制而不是英制。然后,我们设置了与搜索相关的参数。origins 键包含我们想要开始的地点。其值是 ui->from 行编辑器的文本。如果您想查询多个起始位置,只需使用 | 将它们组合即可。与 origins 相似,我们为目的地设置了值。最后,我们将组合框的值传递给 mode,它定义了我们想要开车、骑自行车还是步行,如下面的代码所示:
QUrl url
= ("http://maps.googleapis.com/maps/api/distancematrix/json");
url.setQuery(query);
m_reply = m_nam->get(QNetworkRequest(url));
}
接下来,我们创建一个包含查询应提交的地址的 QUrl。通过在末尾包含 "json",我们定义服务器应使用 JSON 格式传输其回复。Google 还为我们提供了将结果获取为 XML 的选项。要实现这一点,只需将 "json" 替换为 "xml"。然而,由于 Facebook 和 Twitter 的 API 返回 JSON,我们将使用此格式。
然后,我们通过调用 QUrl::setQuery() 将先前构建的 query 设置到 URL 中。这会自动编码值,所以我们不需要担心这一点。最后,我们通过调用 get() 函数发送请求,并将返回的 QNetworkReply 存储在 m_reply 中。
行动时间 - 解析服务器的回复
在构造函数中,我们将管理器的 finish() 信号连接到 MainWindow 类的 finished() 槽。因此,它将在请求发送后调用:
void MainWindow::finished(QNetworkReply *reply)
{
if (m_reply != reply) {
reply->deleteLater();
return;
}
首先,我们检查传递的回复是否是我们通过 m_nam 请求的那个。如果不是这种情况,我们删除 reply 并退出函数。这可能会发生在 sendRequest() 槽取消回复的情况下:
m_reply = 0;
if (reply->error()) {
ui->result->setPlainText(reply->errorString());
reply->deleteLater();
return;
}
既然我们现在确定这是我们请求的,我们将 m_reply 设置为 null,因为我们已经处理了它,不再需要这个信息。接下来我们检查是否发生了错误,如果发生了,我们将回复的错误字符串放入文本编辑器中,删除回复,并退出函数:
const QByteArray content = reply->readAll();
QJsonDocument doc = QJsonDocument::fromJson(content);
if (doc.isNull() || !doc.isObject()) {
ui->result->setPlainText("Error while reading the JSON file.");
reply->deleteLater();
return;
}
使用 readAll(),我们获取服务器的回复内容。由于传输的数据量不大,我们不需要使用 readyRead() 进行部分读取。然后,使用 QJsonDocument::fromJson() 静态函数将内容转换为 QJsonDocument,该函数接受 QByteArray 作为参数并解析其数据。如果文档为空,则表示服务器的回复无效,然后我们在文本编辑器上显示错误消息,删除回复,并退出函数。如果文档不包含对象,我们也会这样做,因为 API 调用应该只响应一个对象,如下所示:
QJsonObject obj = doc.object();
QVariantList origins = obj.value("origin_addresses")
.toArray().toVariantList();
QVariantList destinations = obj.value("destination_addresses")
.toArray().toVariantList();
由于我们现在已经确认存在一个对象,我们将其存储在 obj 中。此外,由于 API,我们还知道该对象包含 origin_addresses 和 destination_addresses 键。这两个值都是数组,包含请求的起点和终点。从这一点开始,如果值存在且有效,我们将跳过任何测试,因为我们信任 API。该对象还包含一个名为 status 的键,其值可以用来检查查询是否可能失败,如果是,原因是什么?源代码的最后两行将起点和终点存储在两个变体列表中。通过 obj.value("origin_addresses"),我们获取包含 origin_addresses 键指定的值对的 QJsonValue。QJsonValue::toArray() 将此值转换为 QJsonArray,然后使用 QJsonArray::toVariantList() 转换为 QVariantList。对于请求从华沙或埃尔兰根到伯明翰的距离的搜索,返回的 JSON 文件将如下所示:
{
"destination_addresses" : [ "Birmingham, West Midlands, UK" ],
"origin_addresses" : [ "Warsaw, Poland", "Erlangen, Germany" ],
"rows" : [ ... ],
"status" : "OK"
}
rows 键持有实际结果作为数组。该数组中的第一个对象属于第一个起点,第二个对象属于第二个起点,依此类推。每个对象都包含一个名为 elements 的键,其值也是一个对象数组,属于相应的目的地:
"rows" : [
{
"elements" : [{...}, {...}]
},
{
"elements" : [{...}, {...}]
}
],
每个起点-终点对(前一个示例中的 {...})的 JSON 对象由两个键值对组成,分别是距离和持续时间键。这两个键的值都是数组,包含 text 和 value 键,其中 text 是 value 的人类可读短语。Warsaw-Birmingham 搜索的对象如下所示:
{
"distance" : {
"text" : "1,835 km",
"value" : 1834751
},
"duration" : {
"text" : "16 hours 37 mins",
"value" : 59848
},
"status" : "OK"
}
如您所见,距离的 value 是以米为单位的距离——因为我们已经在请求中使用了 units=metric——而 text 的值是将值转换为带有后缀 "km" 的千米。同样的情况也适用于持续时间。在这里,值以秒为单位表示,而 text 是将值转换为小时和分钟的表示。
现在我们知道了返回的 JSON 的结构,我们将在文本编辑器中显示每个起点-终点对的值。因此,我们使用 QVariantLists 遍历每个可能的配对:
for (int i = 0; i < origins.count(); ++i) {
for (int j = 0; j < destinations.count(); ++j) {
这个作用域将针对每种组合被访问。想象一下传递的结果就像一个表格,其中起点是行,目的地是列:
QString output;
output += QString("From:").leftJustified(10, ' ')
+ origins.at(i).toString() + "\n";
output += QString("To:").leftJustified(10, ' ')
+ destinations.at(j).toString() + "\n";
我们将构建的文本缓存到一个名为 output 的局部变量中。首先,我们在 output 中添加字符串 "From:" 和当前源地址。为了让它看起来至少稍微美观一些,我们调用 leftJustified()。这会导致 "From:" 被填充空格直到整个字符串的大小为 10。然后输出将被对齐。当前源地址的值通常通过 QList::at() 访问,由于它是 QVariantList,我们需要将返回的 QVariant 转换为 QString。因此,我们调用 toString()。同样的操作也应用于目的地,结果如下所示,这是 output 的值:
From: Warsaw, Poland
To: Birmingham, West Midlands, UK
接下来,我们将从我们调用 data 的相应 QJsonObject 中读取持续时间和距离:
QJsonObject data = obj.value("rows").toArray().at(i).toObject()
.value("elements").toArray().at(j).toObject();
从回复的根对象开始,我们获取行值并将其转换为数组(obj.value("rows").toArray())。然后,我们获取当前行的值(.at(i)),将其转换为 JSON 对象,并获取其元素键(.toObject().value("elements"))。由于这个值也是一个数组——行的列——我们将其转换为数组,获取当前列(.toArray().at(j)),并将其转换为对象。这就是包含起点-终点对单元格(i;j)中的距离和持续时间的对象。除了这两个键之外,该对象还包含一个名为 status 的键。它的值表示搜索是否成功(OK),起点或目的地是否找不到(NOT_FOUND),或者搜索是否无法在起点和目的地之间找到路线(ZERO_RESULTS):
QString status = data.value("status").toString();
我们将状态值存储在一个也命名为 status 的局部变量中:
if (status == "OK") {
output += QString("Distance:").leftJustified(10, ' ') +
data.value("distance").toObject().value("text").toString()
+ "\n";
output += QString("Duration:").leftJustified(10, ' ') +
data.value("duration").toObject().value("text").toString()
+ "\n";
}
如果一切顺利,我们接着将distance和duration添加到输出中,并且像之前使用leftJustified()一样对标签进行对齐。对于距离,我们希望显示短语化的结果。因此,我们首先获取距离键的 JSON 值(data.value("distance")),将其转换为对象,并请求文本键的值(.toObject().value("text"))。最后,我们使用toString()将QJsonValue转换为QString。对于持续时间也是如此:
else if (status == "NOT_FOUND") {
output += "Origin and/or destination of this pairing could not be geocoded.\n";
} else if (status == "ZERO_RESULTS") {
output += "No route could be found.\n";
} else {
output += "Unknown error.\n";
}
如果 API 返回错误,我们将设置适当的错误文本作为输出:
output += QString("\n").fill('=', 35) + "\n\n";
ui->result->moveCursor(QTextCursor::End);
ui->result->insertPlainText(output);
}
}
reply->deleteLater();
}
最后,我们添加一行由35个等号(fill('=', 35))组成的线,以将结果单元格与其他单元格分开。然后将输出放置在文本编辑的末尾。这是通过将光标移动到编辑的末尾,调用moveCursor(QTextCursor::End),并使用insertPlainText(output)将输出插入到编辑中完成的。
当循环结束时,我们一定不要忘记删除回复。实际结果如下所示:

尝试一下英雄 – 选择 XML 作为回复的格式
为了磨练你的 XML 技能,你可以使用maps.googleapis.com/maps/api/distancematrix/xml作为 URL,向其发送请求。然后,你可以像处理 JSON 一样解析 XML 文件,并同样显示检索到的数据。
控制连接状态
实际上,只有在你有活跃的互联网连接时才能使用QNetworkAccessManager。由于理论上你无法知道连接状态,你必须在应用程序运行时进行检查。借助 Bearer API,你可以检查计算机、移动设备或*板电脑是否在线,甚至可以启动一个新的连接——如果操作系统支持的话。
携带者 API 主要由四个类组成。QNetworkConfigurationManager 是基础和起点。它包含系统上所有可用的网络配置。此外,它还提供有关网络功能的信息,例如,您是否可以启动和停止接口。它找到的网络配置存储为 QNetworkConfiguration 类。QNetworkConfiguration 包含有关接入点的一切信息,但不包括网络接口,因为一个接口可以提供多个接入点。此类还仅提供有关网络配置的信息。您不能通过 QNetworkConfiguration 配置接入点或网络接口。网络配置由操作系统管理,因此,QNetworkConfiguration 是一个只读类。然而,使用 QNetworkConfiguration,您可以确定连接类型是以太网、WLAN 还是 2G 连接。这可能会影响您将要下载的数据类型,更重要的是,数据的大小。使用 QNetworkSession,您可以启动或停止由配置定义的系统网络接口。这样,您就可以控制接入点。QNetworkSession 还提供会话管理,这在系统接入点被多个应用程序使用时非常有用。会话确保在最后一个会话关闭之后,底层接口才会终止。最后,QNetworkInterface 提供经典信息,如硬件地址或接口名称。
QNetworkConfigurationManager
QNetworkConfigurationManager 管理系统上可用的所有网络配置。您可以通过调用 allConfigurations() 来访问这些配置。当然,您首先必须创建管理器的实例:
QNetworkConfigurationManager manager;
QList<QNetworkConfiguration> cfgs = manager.allConfigurations();
配置作为列表返回。allConfigurations() 的默认行为是返回所有可能的配置。但是,您也可以获取一个过滤后的列表。如果您将 QNetworkConfiguration::Active 作为参数传递,列表中只包含至少有一个活动会话的配置。如果您基于此类配置创建新的会话,它将是活动的并已连接。通过传递 QNetworkConfiguration::Discovered 作为参数,您将获得一个包含可以立即启动会话的配置的列表。请注意,然而,在此阶段,您不能确定底层接口是否可以启动。最后一个重要的参数是 QNetworkConfiguration::Defined。使用此参数,allConfigurations() 返回一个列表,其中包含系统已知但当前不可用的配置。这可能是一个之前使用的已超出范围的 WLAN 热点。
当配置更改时,您将收到通知。如果出现新的配置,则管理器会发出 configurationAdded() 信号。例如,这可能发生在移动数据传输变得可用或用户打开其设备的 WLAN 适配器时。如果移除配置,例如关闭 WLAN 适配器,则发出 configurationRemoved()。最后,当配置更改时,您将通过 configurationChanged() 信号收到通知。所有三个信号都传递对配置的常量引用,说明添加、移除或更改的内容。通过 configurationRemoved() 信号传递的配置当然无效。它仍然包含已移除配置的名称和标识符。
要确定系统中的任何网络接口是否处于活动状态,请调用 isOnline()。如果您想被通知模式更改,请跟踪 onlineStateChanged() 信号。
提示
由于 WLAN 扫描需要一定的时间,allConfigurations() 可能不会返回所有可用的配置。为确保配置完全填充,请先调用 updateConfigurations()。由于收集系统网络配置的所有信息可能需要很长时间,此调用是异步的。等待 updateCompleted() 信号,然后才能调用 allConfigurations()。
QNetworkConfigurationManager 还会通知您 Bearer API 的功能。capabilities() 函数返回 QNetworkConfigurationManager::Capabilities 类型的标志,并描述了特定*台的可用可能性。您可能最感兴趣的值如下:
| 值 | 含义 |
|---|---|
CanStartAndStopInterfaces |
这表示您可以启动和停止接入点。 |
ApplicationLevelRoaming |
这表示系统会通知您是否有更合适的接入点可用,并且如果您认为有更好的接入点,您可以主动更改接入点。 |
DataStatistics |
使用此功能,QNetworkSession 包含有关传输和接收的数据信息。 |
QNetworkConfiguration
QNetworkConfiguration 如前所述,包含有关接入点的信息。使用 name(),您可以获取配置的用户可见名称,而使用 identifier(),您可以获取一个唯一的、系统特定的标识符。如果您为移动设备开发游戏,了解正在使用哪种类型的连接可能对您有利。这可能会影响您请求的数据;例如,视频的质量及其大小。使用 bearerType(),将返回配置使用的承载类型。返回的枚举值相当直观:BearerEthernet、BearerWLAN、Bearer2G、BearerCDMA2000、BearerWCDMA、BearerHSPA、BearerBluetooth、BearerWiMAX 等。您可以在 QNetworkConfiguration::BearerType 的文档中查找完整的值列表。
使用 purpose(),您可以获取配置的目的,例如,它是否适合访问私有网络(QNetworkConfiguration::PrivatePurpose)或访问公共网络(QNetworkConfiguration::PublicPurpose)。如果已定义、发现或激活,配置的状态,如前所述,可以通过 state() 访问。
QNetworkSession
要启动网络接口或告诉系统保持接口连接以满足您的需求,您必须启动一个会话:
QNetworkConfigurationManager manager;
QNetworkConfiguration cfg = manager.defaultConfiguration();
QNetworkSession *session = new QNetworkSession(cfg, this);
session->open();
会话基于配置。当存在多个会话且您不确定使用哪个时,请使用 QNetworkConfigurationManager::defaultConfiguration()。它返回系统的默认配置。基于此,您可以创建 QNetworkSession 的一个实例。第一个参数,即配置,是必需的。第二个参数是可选的,但建议使用,因为它设置了一个父对象,我们不需要处理删除。您可能想先检查配置是否有效(QNetworkConfiguration::isValid())。调用 open() 将启动会话并在需要和受支持的情况下连接接口。由于 open() 可能需要一些时间,该调用是异步的。因此,您可以监听 opened() 信号,该信号在会话打开时立即发出,或者监听 error() 信号,如果发生错误。错误类型为 QNetworkSession::SessionError。或者,您也可以监听 stateChanged() 信号。会话的可能状态可以是:Invalid(无效)、NotAvailable(不可用)、Connecting(连接中)、Connected(已连接)、Closing(关闭中)、Disconnected(断开连接)和 Roaming(漫游)。如果您想使 open() 同步,请在调用 open() 后立即调用 waitForOpened()。它将阻塞事件循环,直到会话打开。此函数在成功时返回 true,否则返回 false。为了限制等待时间,您可以定义一个超时。只需将您愿意等待的毫秒数作为参数传递给 waitForOpened()。要检查会话是否打开,请使用 isOpen()。
要关闭会话,请调用 close()。如果界面上没有剩余的会话,它将被关闭。要强制断开接口,请调用 stop()。此调用将使基于该接口的所有会话无效。
您可能会收到 preferredConfigurationChanged() 信号,这表示首选配置,即例如首选接入点已更改。这可能是在现在有 WLAN 网络范围内,您不再需要使用 2G 的情况下。新的配置作为第一个参数传递,第二个参数指示更改新的接入点是否会同时更改 IP 地址。除了检查信号外,您还可以通过调用 QNetworkConfiguration::isRoamingAvailable() 来查询配置是否支持漫游。如果漫游可用,您必须决定通过调用 ignore() 拒绝提议,或者通过调用 migrate() 接受它。如果您接受漫游,当会话漫游时将发出 newConfigurationActivated()。在检查新的连接后,您可以选择接受新的接入点或拒绝它。后者意味着您将返回到以前的接入点。如果您接受新的接入点,以前的接入点将被终止。
QNetworkInterface
要获取会话使用的接口,请调用 QNetworkSession::interface()。它将返回 QNetworkInterface 对象,该对象描述了接口。通过 hardwareAddress(),您可以得到接口的低级硬件地址,通常是 MAC 地址。接口的名称可以通过 name() 获取,它是一个如 "eth0" 或 "wlan0" 的字符串。addressEntries() 返回与接口注册的 IP 地址列表以及它们的子网掩码和广播地址。此外,您可以通过 flags() 查询接口是否是环回或是否支持多播。返回的位掩码是这些值的组合:IsUp、IsRunning、CanBroadcast、IsLoopBack、IsPointToPoint 和 CanMulticast。
游戏间的通信
在讨论了 Qt 的高级网络类,如 QNetworkAccessManager 和 QNetworkConfigurationManager 之后,我们现在将查看较低级别的网络类,并了解 Qt 在实现 TCP 或 UDP 服务器和客户端时如何支持您。当您计划通过包括多人模式来扩展游戏时,这一点变得相关。为此类任务,Qt 提供 QTcpSocket、QUdpSocket 和 QTcpServer。
实现简单聊天程序的时间
为了熟悉 QTcpServer 和 QTcpSocket,让我们开发一个简单的聊天程序。这个例子将教会您 Qt 中网络处理的基本知识,以便您以后可以使用这项技能连接两个或更多个游戏副本。在这个练习结束时,我们希望看到如下内容:

在前面图例的左侧和右侧,你可以看到一个客户端,而服务器位于中间。我们将首先仔细看看服务器。
服务器 – QTcpServer
作为通信协议,我们将使用 传输控制协议(TCP)。你可能从两个最流行的互联网协议 HTTP 和 FTP 中了解到这个网络协议。这两个协议都使用 TCP 进行通信,全球使用的电子邮件流量协议也是如此:SMTP、POP3 和 IMAP。然而,TCP 的主要优势是其可靠性和基于连接的架构。通过 TCP 传输的数据保证是完整、有序且无重复的。此外,该协议是面向流的,这允许我们使用 QDataStream 或 QTextStream。TCP 的缺点是速度较慢。这是因为丢失的数据必须重新传输,直到接收方完全接收为止。默认情况下,这会导致丢失部分之后传输的所有数据重新传输。因此,你应该只在速度不是首要考虑的情况下选择 TCP 作为协议,而是数据的完整性和正确性。如果你发送的是唯一非重复数据,这也适用。
行动时间 – 设置服务器
服务器 GUI 的外观显示,它基本上由 QPlainTextEdit (ui->log) 组成,用于显示系统消息,以及一个按钮(ui->disconnectClients),允许我们断开所有当前连接的客户端。在按钮旁边,显示服务器的地址和端口(ui->address 和 ui->port)。在服务器类的构造函数中设置用户界面后,我们初始化内部使用的 QTcpServer,它存储在 m_server 私有成员变量中:
if (!m_server->listen(QHostAddress::LocalHost, 52693)) {
ui->log->setPlainText("Failure while starting server: "
+ m_server->errorString());
return;
}
connect(m_server, SIGNAL(newConnection()),
this, SLOT(newConnection()));
发生了什么?
使用 QTcpServer::listen(),我们定义服务器应监听本地的 52693 端口以接收新的连接。这里使用的值,QHostAddress::LocalHost 来自 QHostAddress::SpecialAddress 枚举,将解析为 127.0.0.1。如果你传递 QHostAddress::Any,服务器将监听所有 IPv4 接口以及 IPv6 接口。如果你只想监听特定的地址,只需将此地址作为 QHostAddress 传递:
m_server->listen(QHostAddress("127.0.0.1"), 0);
这将表现得像前面代码中的那个,只是现在服务器将监听一个自动选择的端口。如果成功,listen() 将返回 true。因此,如果示例中出现问题,它将在文本编辑器上显示错误消息并退出函数。为了组成错误消息,我们使用 QTcpServer::errorString(),它包含一个可读的错误短语。
要处理游戏中代码的错误,错误字符串并不适用。在任何需要知道确切错误的情况下,使用 QTcpServer::serverError(),它返回 QAbstractSocket::SocketError 的枚举值。基于此,你可以确切地知道出了什么问题,例如,QAbstractSocket::HostNotFoundError。如果 listen() 成功,我们将服务器的 newConnection() 信号连接到类的 newConnection() 槽。每当有新的连接可用时,该信号将被触发:
ui->address->setText(m_server->serverAddress().toString());
ui->port->setText(QString::number(m_server->serverPort()));
最后,我们展示了可以通过 serverAddress() 和 serverPort() 获取的服务器地址和端口号。这些信息对于客户端来说是必要的,以便它们能够连接到服务器。
行动时间 - 对新挂起连接做出反应
一旦客户端尝试连接到服务器,newConnection() 槽将被调用:
void TcpServer::newConnection() {
while (m_server->hasPendingConnections()) {
QTcpSocket *con = m_server->nextPendingConnection();
m_clients << con;
ui->disconnectClients->setEnabled(true);
connect(con, SIGNAL(disconnected()), this, SLOT(removeConnection()));
connect(con, SIGNAL(readyRead()), this, SLOT(newMessage()));
ui->log->insertPlainText(
QString("* New connection: %1, port %2\n")
.arg(con->peerAddress().toString())
.arg(QString::number(con->peerPort())));
}
}
发生了什么?
由于可能有多个挂起的连接,我们使用 hasPendingConnections() 来确定是否至少还有一个挂起的连接。然后在 while 循环内部处理每一个连接。要获取 QTcpSocket 类型的挂起连接,我们调用 nextPendingConnection() 并将此连接添加到名为 m_clients 的私有列表中,该列表包含所有活动连接。在下一行,由于现在至少有一个连接,我们启用了允许关闭所有连接的按钮。因此,连接到按钮 click() 信号的槽将调用每个单独连接的 QTcpSocket::close()。当连接关闭时,其套接字发出 disconnected() 信号。我们将此信号连接到我们的 removeConnection() 槽。对于最后一个连接,我们响应套接字的 readyRead() 信号,这表示有新数据可用。在这种情况下,我们的 newMessage() 槽将被调用。最后,我们打印一条系统消息,表明已建立新的连接。连接客户端和对方的地址和端口号可以通过套接字的 peerAddress() 和 peerPort() 函数获取。
小贴士
如果无法接受新的连接,将发出 acceptError() 信号而不是 newConnection()。它将 QAbstractSocket::SocketError 类型的失败原因作为参数传递。如果您想暂时拒绝新的连接,请在 QTcpServer 上调用 pauseAccepting()。要恢复接受新的连接,请调用 resumeAccepting()。
行动时间 - 转发新消息
当连接的客户端发送新的聊天消息时,由于它继承自 QIODevice,底层的套接字会发出 readyRead() 信号,因此我们的 newMessage() 槽将被调用。
在我们查看这个槽之前,有一些重要的事情需要你记住。尽管 TCP 是有序的且没有重复,但这并不意味着所有数据都作为一个大块传输。因此,在处理接收到的数据之前,我们需要确保我们得到了整个消息。不幸的是,没有简单的方法来检测是否所有数据都已传输,也没有一个通用的方法来完成这项任务。因此,这个问题取决于你的用例。然而,两种常见的解决方案是发送魔法令牌来指示消息的开始和结束,例如单个字符或 XML 标签,或者你可以提前发送消息的大小。第二种解决方案在 Qt 文档中显示,其中消息长度放在消息前面的 quint16 中。另一方面,我们将探讨一种使用简单魔法令牌正确处理消息的方法。作为分隔符,我们使用“传输结束块”字符——ASCII 码 23——来指示消息的结束。
由于接收数据的处理相当复杂,我们这次将逐步解释代码:
void TcpServer::newMessage()
{
if (QTcpSocket *con = qobject_cast<QTcpSocket*>(sender())) {
m_receivedData[con].append(con->readAll());
要确定哪个套接字调用了槽,我们使用 sender()。如果将 QTcpSocket 强制转换为成功,我们进入 if 范围,并使用 readAll() 读取传输的——可能是片段化的——消息。
注意
请注意,sender() 用于简化。如果你编写实际的代码,最好使用 QSignalMapper。
然后,read 数据将与存储在名为 m_receivedData 的 QHash 私有成员中的先前接收到的数据连接起来,其中套接字用作键:
if (!m_receivedData[con].contains(QChar(23)))
return;
在这里,我们检查接收到的数据是否包含我们的特殊令牌,“传输结束块”。否则,我们退出并等待更多数据的到来,这些数据随后被附加到字符串上。一旦我们至少有一个特殊令牌,我们就通过分割数据为单个消息来继续操作:
QStringList messages = m_receivedData[con].split(QChar(23));
m_receivedData[con] = messages.takeLast();
接收到的数据恰好包含一条消息,其中“传输结束块”令牌是最后一个字符,因此消息列表有两个元素:第一个是实际的消息,最后一个是没有内容的。这样,m_receivedData[con] 就被重置了。如果 QChar(23) 不是接收文本的最后一个字符怎么办?那么,最后一个元素是下一个尚未完成的,消息的开始。因此,我们将该消息存储在 m_receivedData[con] 中。这保证了不会丢失任何数据:
foreach (QString message, messages) {
ui->log->insertPlainText("Sending message: " + message + "\n");
由于我们不知道从套接字最后读取将得到多少消息,我们需要遍历消息列表。对于每条消息,我们在服务器的日志上显示一条简短的通知,然后将其发送给其他客户端:
message.append(QChar(23));
foreach (QTcpSocket *socket, m_clients) {
if (socket == con)
continue;
if (socket->state() == QAbstractSocket::ConnectedState)
socket->write(message.toLocal8Bit());
}
}
}
}
在发送消息之前,我们添加 QChar(23) 来表示消息的结束,当然,然后通过在套接字上调用 write 简单地将它发送到所有已连接的客户端,除了最初发送它的那个客户端。由于套接字继承了 QIODevice,你可以使用从 QFile 知道的多数函数。
尝试一下英雄——使用 QSignalMapper
如前所述,使用 sender() 是一种方便的方法,但不是面向对象的方法。因此,尝试使用 QSignalMapper 来确定哪个套接字调用了槽。为了实现这一点,你必须将套接字的 readyRead() 信号连接到一个映射器,并将槽直接连接。所有与信号-映射器相关的代码都将放入 newConnection() 槽中。
这同样适用于连接到 removeConnection() 槽。让我们接下来看看它。
行动时间——检测断开连接
当客户端终止连接时,我们必须从本地的 m_clients 列表中删除套接字。因此,我们必须将套接字的 disconnected() 信号连接到:
void TcpServer::removeConnection()
{
if (QTcpSocket *con = qobject_cast<QTcpSocket*>(sender())) {
ui->log->insertPlainText(
QString("* Connection removed: %1, port %2\n")
.arg(con->peerAddress().toString())
.arg(QString::number(con->peerPort())));
m_clients.removeOne(con);
con->deleteLater();
ui->disconnectClients->setEnabled(!m_clients.isEmpty());
}
}
刚才发生了什么?
在通过 sender() 获取发出调用的套接字后,我们发布一个信息,表明套接字正在被移除。然后,我们将套接字从 m_clients 中移除,并在其上调用 deleteLater()。不要使用 delete。最后,如果没有客户端剩下,断开连接按钮将被禁用。
这是第一部分。现在让我们看看客户端。
客户端
客户端的图形用户界面 (TcpClient) 非常简单。它有三个输入字段来定义服务器的地址 (ui->address)、服务器的端口 (ui->port) 和一个用户名 (ui->user)。当然,还有一个按钮来连接到 (ui->connect) 和从服务器断开连接 (ui->disconnect)。最后,GUI 有一个文本编辑器来保存接收到的消息 (ui->chat) 和一个行编辑器 (ui->text) 来发送消息。
行动时间——设置客户端
当用户提供了服务器的地址和端口,并选择了一个用户名后,他/她可以连接到服务器:
void TcpClient::on_connect_clicked()
{
//...
if (m_socket->state() != QAbstractSocket::ConnectedState) {
m_socket->connectToHost(ui->address->text(), ui->port->value());
ui->chat->insertPlainText("== Connecting...\n");
}
//...
}
刚才发生了什么?
私有成员变量 m_socket 持有 QTcpSocket 的一个实例。如果此套接字已经连接,则不会发生任何事情。否则,通过调用 connectToHost() 将套接字连接到给定的地址和端口。除了必需的服务器地址和端口号之外,您还可以传递第三个参数来定义套接字将打开的模式。对于可能的值,您可以使用 OpenMode,就像我们为 QIODevice 所做的那样。由于此调用是异步的,我们在聊天中打印一条通知,以便用户得知应用程序目前正在尝试连接到服务器。当连接建立时,套接字发送 connected() 信号,在聊天中打印 "Connected to server",以指示我们已经连接到槽。除了聊天中的消息外,我们还通过例如禁用连接按钮等方式更新了 GUI,但这都是基本操作。如果您已经查看过源代码,那么您不会对此有任何困难。因此,这些细节在这里被省略了。
当然,在尝试连接到服务器时可能会出错,但幸运的是,我们也可以通过 error() 信号得知失败,该信号通过 QAbstractSocket::SocketError 形式传递错误描述。最频繁的错误可能是 QAbstractSocket::ConnectionRefusedError,如果对端拒绝连接,或者 QAbstractSocket::HostNotFoundError,如果找不到主机地址。然而,如果连接成功建立,则稍后应该关闭它。您可以调用 abort() 立即关闭套接字,而 disconnectFromHost() 将等待所有挂起数据被写入。
行动时间 - 接收短信
在构造函数中,我们将套接字的 readyRead() 信号连接到了一个本地槽。因此,每当服务器通过 QTcpSocket::write() 发送消息时,我们读取数据并在聊天窗口中显示它:
m_receivedData.append(m_socket->readAll());
if (!m_receivedData.contains(QChar(23)))
return;
QStringList messages = m_receivedData.split(QChar(23));
m_receivedData = messages.takeLast();
foreach (const QString &message, messages) {
ui->chat->insertPlainText(message + "\n");
}
如您所知,QTcpSocket 继承了 QIODevice,因此我们使用 QIODevice::readAll() 来获取发送的整个文本。接下来,我们存储接收到的数据并确定消息是否已完全传输。这种方法与我们之前用于服务器的方法相同。最后,在 for 循环中,我们将消息添加到聊天窗口中。
行动时间 - 发送短信
现在剩下的就是描述如何发送聊天消息。在行编辑中按回车键时,将调用一个本地槽,该槽检查是否有实际要发送的文本以及 m_socket 是否仍然连接:
QString message = m_user + ": " + ui->text->text();
m_socket->write(message.toLocal8Bit());
ui->text->clear();
如果是这样,将组成一条包含自给用户名、冒号和行编辑文本的消息。要将此字符串发送到对等方,需要调用 QTcpSocket::write() 服务器。由于 write() 只接受 const char* 或 QByteArray,我们使用 QString::toLocal8Bit() 来获取可以发送到套接字的 QByteArray。
就这些了。这就像是从文件中写入和读取一样。对于完整的示例,请查看本书附带源代码,并运行服务器和几个客户端。
尝试扩展聊天功能,添加用户列表
这个例子向我们展示了如何发送简单的文本。如果你现在继续定义一个通信应该如何工作的模式,你可以将其作为更复杂通信的基础。例如,如果你想使客户端能够接收所有其他客户端(及其用户名)的列表,你需要定义服务器在接收到来自客户端的类似 rq:allClients 的消息时将返回这样一个列表。因此,你必须在将消息转发给所有已连接的客户端之前解析服务器接收到的所有消息。现在就尝试自己实现这样的要求。到目前为止,可能有多位用户选择了相同的用户名。通过获取用户列表的新功能,你可以防止这种情况发生。因此,你必须将用户名发送到跟踪它们的服务器。
改进
我们所解释的示例使用的是非阻塞、异步方法。例如,在异步调用如 connectToHost() 之后,我们不会阻塞线程直到我们得到结果,而是连接到套接字的信号以继续。另一方面,在互联网以及 Qt 的文档中,你会找到许多解释阻塞和同步方法的示例。你将很容易通过它们使用 waitFor...() 函数来识别。这些函数会阻塞当前线程,直到 connectToHost() 等函数有结果——即 connected() 或 error() 信号被发射。connectToHost() 的对应阻塞函数是 waitForConnected()。其他可以使用的阻塞函数包括 waitForReadyRead(),它等待直到套接字上有新数据可供读取;waitForBytesWritten(),它等待直到数据已写入套接字;以及 waitForDisconnected(),它等待直到连接被关闭。
注意;即使 Qt 提供了这些 waitFor...() 函数,也不要使用它们!同步方法并不是最聪明的选择,因为它会冻结你的游戏 GUI。冻结的 GUI 是游戏中可能发生的最糟糕的事情,并且会令每个用户感到烦恼。所以,当在 GUI 线程内工作时,你最好是对应地响应 QIODevice::readyRead()、QIODevice::bytesWritten()、QAbstractSocket::connected() 和 QAbstractSocket::disconnected() 信号。
注意
QAbstractSocket 是 QTcpSocket 以及 QUdpSocket 的基类。
按照异步方法进行,应用程序只有在同时读取和写入套接字以及确定消息是否完整时才会变得无响应。然而,最佳做法是将整个套接字处理移动到额外的线程。这样,GUI 线程只会接收信号,传递新消息,发送时只需将QString传递给工作线程。这样,你将得到一个超级流畅的绒毛 GUI。
使用 UDP
与 TCP 相比,UDP 是不可靠的,无连接的。既不能保证数据包的顺序,也不能保证它们的交付。然而,UDP 非常快。所以,如果你有频繁的数据,这些数据不一定需要被对端接收,可以使用 UDP。这些数据可以是玩家实时位置,这些位置会频繁更新,或者实时视频/音频流。由于缺少QUdpSocket,你必须使用QAbstractSocket::bind()而不是QTcpServer::listen()。与listen()类似,bind()接受允许发送数据报的地址和端口作为参数。每当一个新的数据包到达时,QIODevice::readyRead()信号就会被发射。要读取数据,请使用readDatagram()函数,它接受四个参数。第一个参数是char*类型,用于写入数据,第二个参数指定要写入的字节数,最后两个参数是QHostAddress*和quint16*类型,用于存储发送者的 IP 地址和端口号。由于缺少QUdpServer,你必须使用QAbstractSocket::bind()而不是QTcpServer::listen()。与listen()类似,bind()接受允许发送数据报的地址和端口作为参数。每当一个新的数据包到达时,QIODevice::readyRead()信号就会被发射。要读取数据,请使用readDatagram()函数,它接受四个参数。第一个参数是char*类型,用于写入数据,第二个参数指定要写入的字节数,最后两个参数是QHostAddress*和quint16*类型,用于存储发送者的 IP 地址和端口号。发送数据的工作方式类似:writeDatagram()将第一个参数的char*类型数据发送到由第三个(地址)和第四个(端口号)参数定义的主机。通过第二个参数,你可以限制要发送的数据量。
行动时间 - 通过 UDP 发送文本
例如,假设我们有两个QUpSocket类型的套接字。我们将第一个称为socketA,另一个称为socketB。它们都绑定到本地主机,socketA绑定到52000端口,而socketB绑定到52001端口。因此,如果我们想从socketA向socketB发送字符串"Hello!",我们必须在持有socketA的应用程序中编写:
socketA->writeDatagram(QByteArray("Hello!"), QHostAddress("127.0.0.1"), 52001);
在这里,我们使用了writeDatagram()函数的便利功能,它接受QByteArray而不是const char*和qint64。持有socketB的类必须将套接字的readyRead()信号连接到一个槽。然后,由于我们的writeDatagram()调用,该槽将被调用,假设数据报没有丢失!在槽中,我们使用以下方式读取数据报和发送者的地址和端口号:
while (socketB->hasPendingDatagrams()) {
QByteArray datagram;
datagram.resize(socketB->pendingDatagramSize())
QHostAddress sender;
quint16 senderPort;
socketB->readDatagram(datagram.data(), datagram.size(),
&sender, &senderPort);
// do something with datagram, sender and port.
}
只要还有挂起的报文 - 这通过 hasPendingDatagrams() 检查,只要还有挂起的报文就返回 true - 我们就读取它们。这是通过创建 QByteArray 来完成的,它用于存储传输的报文。为了能够容纳整个传输数据,它的大小被调整到挂起的报文长度。这个信息是通过 pendingDatagramSize() 获取的。接下来,我们创建 QHostAddress 和 quint16,这样 readDatagram() 就可以将发送者的地址和端口号存储在这些变量中。现在,所有设置都已就绪,可以调用 readDatagram() 来获取报文。
尝试一下英雄 - 连接本杰明游戏的玩家
在掌握了这些基础知识之后,你可以尝试自己做一些事情。例如,你可以玩本杰明大象游戏,并将本杰明的当前位置从一个客户端发送到另一个客户端。这样,你既可以克隆一个客户端的屏幕到另一个客户端,或者两个客户端都可以玩游戏,并且还可以看到其他玩家的大象当前的位置。对于这样的任务,你会使用 UDP,因为位置需要非常快地更新,而丢失一个位置并不是灾难性的。
快速测验 - 测试你的知识
Q1. 你需要哪三个(主要)类来下载文件?
Q2. 你如何只下载文件的前 100 个字节?
Q3. 如果你需要通过参数扩展 URL 并包含特殊字符,你需要使用 QUrl::toPercentEncoding() 来转义它们。Qt 还提供了哪些其他更方便的选项?
Q4. 你如何删除从 QNetworkAccessManager 接收到的 QNetworkReply?
Q5. QTcpSocket 和 QUdpSocket 的类型层次结构是什么?这个层次结构有什么重大优势?
Q6. readDatagram() 函数属于 QTcpSocket 还是 QUdpSocket?
摘要
在本章的第一部分,你熟悉了 QNetworkAccessManager。每当你要在互联网上下载或上传文件时,这个类就是你的代码的核心。在了解了你可以用来获取错误、接收新数据通知或显示进度的不同信号之后,你现在应该知道你需要了解的所有关于这个主题的内容。
关于距离矩阵 API 的例子依赖于你对 QNetworkAccessManager 的了解,并且展示了它的实际应用案例。处理作为服务器响应格式的 JSON 是一个对第四章,Qt 核心基础的回顾,但这是高度必要的,因为 Facebook 或 Twitter 只使用 JSON 来格式化它们的网络响应。
在最后一节中,你学习了如何设置自己的 TCP 服务器和客户端。这使你能够连接不同实例的游戏以提供多人游戏功能。或者,你被教导如何使用 UDP。
请记住,由于这个主题的复杂性,我们只是触及了表面。全面覆盖它将超出这本入门指南的范围。对于真正使用网络的实时游戏,你应该了解 Qt 通过 SSL 或其他机制建立安全连接的可能性。
在下一章中,你将学习如何使用脚本引擎扩展你的游戏。这允许你,例如,轻松更改游戏的各种方面,而无需重新编译它。
第八章:脚本
在本章中,你将学习如何将脚本功能引入你的程序。你将了解如何使用基于 JavaScript 的语言来实现游戏逻辑和细节,而无需重建主游戏引擎。虽然我们将关注的这个环境与 Qt 应用程序结合得最好,但如果不喜欢 JavaScript,你将得到关于你可以使用的其他语言的建议,以使你的游戏可脚本化。
为什么使用脚本?
你可能会问自己,既然我可以用 C++实现所有需要的功能,为什么还要使用任何脚本语言呢?为你的游戏提供脚本环境有许多好处。大多数现代游戏实际上由两部分组成。其中一部分是主游戏引擎,它实现了游戏的核心(数据结构、处理算法和渲染层),并向其他组件提供了一个 API,该组件为游戏提供详细信息、行为模式和动作流程。这个其他组件通常是用脚本语言编写的。这样做的主要好处是,故事设计师可以独立于引擎开发者工作,他们不需要重建整个游戏,只需修改一些参数或检查新任务是否与现有故事很好地融合。这使得开发速度比单体方法快得多。另一个好处是,这种开发使游戏开放给模组化——有技能的最终用户可以扩展或修改游戏,为游戏提供一些增值服务。这也是通过在现有的脚本 API 上实现扩展来为游戏赚取额外收入的一种方式,而无需重新部署完整的游戏二进制文件给每个玩家,或者向模组开发者暴露新的脚本端点以进一步增强他们的创造力。最后,你可以重用相同的游戏驱动程序为其他游戏,只需替换脚本即可获得一个完全不同的产品。
Qt 提供了两种基于 JavaScript 的脚本环境实现。在本章中,我们将重点关注 Qt Script。在文档中,你可以看到该模块被标记为“已弃用”;然而,它目前提供的 API(尽管执行速度较慢)比其他实现更丰富。在描述 Qt Script 之后,我们还将简要地看看另一种实现。我们不会讨论 JavaScript 语言本身的细节,因为有许多优秀的书籍和网站可供学习 JavaScript。此外,JavaScript 的语法与 C 非常相似,即使你之前没有见过任何 JavaScript 代码,你也不应该有任何问题理解我们本章中使用的脚本。
Qt Script 的基础
要在程序中使用 Qt Script,你必须通过在项目文件中添加QT += script行来为你的项目启用脚本模块。
评估 JavaScript 表达式
C++ 编译器不理解 JavaScript。因此,要执行任何脚本,你需要有一个正在运行的解释器,该解释器将解析脚本并评估它。在 Qt 中,这是通过 QScriptEngine 类来完成的。这是一个 Qt 脚本运行时,它处理脚本代码的执行并管理所有与脚本相关的资源。它提供了 evaluate() 方法,可以用来执行 JavaScript 表达式。让我们看看 Qt 脚本中的“Hello World”程序:
#include <QCoreApplication>
#include <QScriptEngine>
int main(int argc, char **argv) {
QCoreApplication app(argc, argv);
QScriptEngine engine;
engine.evaluate("print('Hello World!')");
return 0;
}
这个程序非常简单。首先,它创建了一个应用程序对象,这是脚本环境正常工作所必需的,然后它只是实例化 QScriptEngine 并调用 evaluate 来执行作为参数传递给它的脚本源代码。构建并运行程序后,你将在控制台看到熟悉的 Hello World!。
如果你没有获得任何输出,那么这很可能意味着脚本没有正确执行,可能是由于脚本源代码中的错误。为了验证这一点,我们可以扩展我们的简单程序来检查脚本执行过程中是否出现了任何问题。为此,我们可以使用 hasUncaughtExceptions() 查询引擎状态:
#include <QCoreApplication>
#include <QScriptEngine>
#include <QtDebug>
int main(int argc, char **argv) {
QCoreApplication app(argc, argv);
QScriptEngine engine;
engine.evaluate("print('Hello World!')");
if(engine.hasUncaughtException()) {
QScriptValue exception = engine.uncaughtException();
qDebug() << exception.toString();
}
return 0;
}
突出的代码检查是否存在异常,如果存在,则获取异常对象。你可以看到它的类型是 QScriptValue。这是一个特殊类型,用于在脚本引擎和 C++ 世界之间交换数据。它在某种程度上类似于 QVariant,因为它实际上是一个用于脚本引擎内部使用的多个原始类型的包装器。其中一种类型是包含错误的类型。我们可以使用其 isError() 方法检查脚本值对象是否为错误,但在这个例子中,我们不这样做,因为 uncaughtException() 的目的是返回错误对象。相反,我们立即将错误转换为字符串表示形式,并使用 qDebug() 将其输出到控制台。例如,如果你在脚本源文本中省略了关闭的单引号并运行程序,将显示以下消息:
"SyntaxError: Parse error"
QScriptEngine::evaluate() 同样返回 QScriptValue。此对象代表已评估脚本的输出结果。你可以让脚本为你计算一些值,这些值你可以在之后的 C++ 代码中使用。例如,脚本可以计算当生物被特定武器击中时造成的伤害量。修改我们的代码以使用脚本的结果非常简单。所需做的只是存储 evaluate() 返回的值,然后它就可以在代码的其他地方使用:
QScriptValue result = engine.evaluate("(7+8)/2");
if(engine.hasUncaughtException()) {
// ...
} else {
qDebug() << result.toString();
}
行动时间 - 创建一个 Qt 脚本编辑器
让我们做一个简单的练习,创建一个图形编辑器来编写和执行脚本。首先创建一个新的 GUI 项目,实现一个由两个纯文本编辑小部件(ui->codeEditor 和 ui->logWindow)组成的窗口,这两个小部件通过垂直分隔符分开。其中一个编辑框将用作输入代码的编辑器,另一个将用作显示脚本结果的控制台。然后,向窗口添加菜单和工具栏,并创建打开(ui->actionOpen)、保存(ui->actionSave)、创建新文档(ui->actionNew)、执行脚本(ui->actionExecute)和退出应用程序(ui->actionQuit)的操作。请记住将它们添加到菜单和工具栏中。结果,您应该得到以下截图所示的窗口:

将退出操作连接到 QApplication::quit() 插槽。然后,创建一个 openDocument() 插槽并将其连接到相应的操作。在插槽中,使用 QFileDialog::getOpenFileName() 请求用户输入文档路径,如下所示:
void MainWindow::openDocument() {
QString filePath = QFileDialog::getOpenFileName(this, "Open Document", QDir::homePath(), "JavaScript Documents (*.js)");
if(filePath.isEmpty()) return;
open(filePath);
}
以类似的方式实现 保存 和 另存为 操作处理程序。最后,创建 open(const QString &filePath) 插槽,使其读取文档并将内容放入代码编辑器:
void MainWindow::open(const QString &filePath) {
QFile file(filePath);
if(!file.open(QFile::ReadOnly|QFile::Text)) {
QMessageBox::critical(this, "Error", "Can't open file.");
return;
}
setWindowFilePath(filePath);
ui->codeEditor->setPlainText(QTextStream(&file).readAll());
ui->logWindow->clear();
}
小贴士
QWidget 的 windowFilePath 属性可以用来将文件与窗口关联。然后,您可以在与文件使用相关的操作中使用它——在保存文档时,您可以检查此属性是否为空,并要求用户提供文件名。然后,在创建新文档或用户为文档提供新路径时,您可以重置此属性。
到目前为止,您应该能够运行程序并使用它来创建脚本,并在编辑器中保存和重新加载它们。
现在,为了执行脚本,向窗口类中添加一个名为 QScriptEngine m_engine 的成员变量。创建一个新的插槽,命名为 run,并将其连接到执行操作。在插槽的主体中放入以下代码:
void Main Window::run() {
ui->logWindow->clear();
QScriptValue result
= m_engine.evaluate(scriptSourceCode, windowFilePath());
if(m_engine.hasUncaughtException()) {
QScriptValue exception = m_engine.uncaughtException();
QTextCursor cursor = ui->logWindow->textCursor();
QTextCharFormat errFormat;
errFormat.setForeground(Qt::red);
cursor.insertText(
QString("Exception at line %1:")
.arg(m_engine.uncaughtExceptionLineNumber()),
errFormat
);
cursor.insertText(exception.toString(), errFormat);
QStringList trace = m_engine.uncaughtExceptionBacktrace();
errFormat.setForeground(Qt::darkRed);
for(int i = 0; i < trace.size(); ++i) {
const QString & traceFrame = trace.at(i);
cursor.insertBlock();
cursor.insertText(QString("#%1: %2")
.arg(i).arg(traceFrame), errFormat);
}
} else {
QTextCursor cursor = ui->logWindow->textCursor();
QTextCharFormat resultFormat;
resultFormat.setForeground(Qt::blue);
cursor.insertText(result.toString(), resultFormat);
}
}
编译并运行程序。为此,在编辑器中输入以下脚本:
function factorial(n) {
if( n < 0 ) return undefined
if( n == 0 ) return 1
return n*factorial(n-1)
}
factorial(7)
将脚本保存到名为 factorial.js 的文件中,然后运行它。你应该得到以下截图所示的输出:

接下来,将脚本替换为以下内容:
function factorial(n) {
return N
}
factorial(7)
运行脚本应该得到以下结果:

刚才发生了什么?
run() 方法清除日志窗口并使用我们在本章前面学到的方法评估脚本。如果评估成功,它将在日志窗口中打印结果,这就是我们在上一节中看到的第一个截图。
在第二次尝试中,我们在脚本中使用了一个不存在的变量,导致错误。评估此类代码会导致异常。除了报告实际错误外,我们还使用uncaughtExceptionLineNumber()来报告导致问题的行号。接下来,我们调用引擎的uncaughtExceptionBacktrace()方法,该方法返回一个包含问题回溯(函数调用栈)的字符串列表,我们也将它打印在控制台上。
让我们尝试另一个脚本。以下代码定义了一个局部变量fun,它被分配了一个返回数字的匿名函数:
var fun = function() { return 42 }
然后,你可以像调用常规函数一样调用fun(),如下所示:

现在,让我们看看如果我们从脚本中删除fun的定义,但仍然保留调用会发生什么。

即使没有定义fun的含义,我们仍然得到相同的结果!这是因为QScriptEngine对象在evaluate()调用之间保持其状态。如果你在脚本中定义了一个变量,它将被保留在引擎的当前上下文中。下次调用evaluate()时,它将在与之前相同的上下文中执行脚本;因此,之前定义的所有变量仍然有效。有时,这种行为是期望的;然而,恶意脚本可能会破坏上下文,这可能会给引擎后续的评估造成麻烦。因此,通常在脚本执行完毕后,确保引擎处于干净的状态会更好。
行动时间 - 限制环境的脚本评估
我们接下来的任务是修改我们的脚本编辑器,以便在每次脚本执行后进行清理。如前所述,每个脚本都是在引擎的当前上下文中执行的,因此解决问题的任务归结为确保每个脚本都在一个单独的上下文中执行。在run()方法中包含以下代码:
void MainWindow::run() {
ui->logWindow->clear();
QString scriptSourceCode = ui->codeEditor->toPlainText();
m_engine.pushContext();
QScriptValue result = m_engine.evaluate
(scriptSourceCode, windowFilePath());
if(m_engine.hasUncaughtException()) {
// …
}
m_engine.popContext();
}
运行程序并重复最后一个测试,以查看fun不再在执行之间持续存在。
刚刚发生了什么?
当一个函数被调用时,一个新的执行上下文会被推送到栈顶。当引擎尝试解析一个对象时,它首先在最高上下文中寻找该对象(即函数调用的上下文)。如果找不到,引擎会检查栈中的下一个上下文,然后是下一个,直到找到对象或到达栈底。当函数返回时,上下文从栈中弹出,并销毁其中定义的所有变量。你可以使用以下脚本查看这是如何工作的:
var foo = 7
function bar() { return foo }
bar()
当调用bar时,会在栈中添加一个新的上下文。脚本请求foo对象,但当前上下文中不存在该对象,因此引擎会检查周围上下文并找到foo的定义。在我们的代码中,我们通过显式地使用pushContext()创建一个新的上下文,然后使用popContext()移除它来遵循这种行为。
小贴士
你可以使用currentContext()检索当前上下文对象。
上下文有两个与其相关的重要对象:activation对象和this对象。前者定义了一个对象,其中所有局部变量都作为对象的属性存储。如果你在调用脚本之前在对象上设置了任何属性,它们将直接对脚本可用:
QScriptContext *context = engine.pushContext();
QScriptValue activationObject = context->activationObject();
activationObject.setProperty("foo", "bar");
engine.evaluate("print(foo)");
this对象以类似的方式工作——它确定当脚本引用名为this的对象时要使用哪个对象。任何在 C++中定义的属性都可以从脚本中访问,反之亦然:
QScriptContext *context = engine.pushContext();
QScriptValue thisObject = context->thisObject();
thisObject.setProperty("foo", "bar");
engine.evaluate("print(this.foo)");
集成 Qt 和 Qt Script
到目前为止,我们只评估了一些可以充分利用 JavaScript 内置功能的独立脚本。现在,是时候学习如何在脚本中使用程序中的数据了。
这是通过将不同类型的实体暴露给脚本来完成的。
暴露对象
将数据暴露给 Qt Script 的最简单方法就是利用 Qt 的元对象系统。Qt Script 能够检查QObject实例并检测它们的属性和方法。为了在脚本中使用它们,对象必须对脚本执行上下文可见。最简单的方法是将它添加到引擎的全局对象或某个上下文的激活对象中。正如你所记得的,脚本引擎和 C++之间的所有数据交换都使用QScriptValue类,所以首先我们必须为 C++对象获取一个脚本值句柄:
QScriptEngine engine;
QPushButton *button = new QPushButton("Button");
// …
QScriptValue scriptButton = engine.newQObject(button);
engine.globalObject().setProperty("pushButton", scriptButton);
QScriptEngine::newQObject()为现有的QObject实例创建一个脚本包装器。然后我们将包装器设置为名为pushButton的全局对象的属性。这使得按钮在引擎的全局上下文中作为 JavaScript 对象可用。所有使用Q_PROPERTY定义的属性都作为对象的属性可用,每个槽都可以作为该对象的方法访问。使用这种方法,你可以在 C++和 JavaScript 世界之间共享现有对象:
int main(int argc, char **argv) {
QApplication app(argc, argv);
QScriptEngine engine;
QPushButton button;
engine.globalObject().setProperty
("pushButton", engine.newQObject(&button));
QString script = "pushButton.text = 'My Scripted Button'\n"+
"pushButton.checkable = true\n" +
"pushButton.setChecked(true)"
engine.evaluate(script);
return app.exec();
}
有时候,你希望为类提供一个丰富的接口,以便在 C++中轻松操作它,但同时又想严格控制使用脚本可以执行的操作,因此你想阻止脚本编写者使用类的一些属性或方法。
对于方法,这相当简单——只需不要将它们作为槽位。记住,如果你使用connect()变体,它接受一个函数指针作为参数,你仍然可以将它们用作槽位。
对于属性,你可以在Q_PROPERTY声明中使用SCRIPTABLE关键字将属性标记为从脚本中可访问或不可访问。默认情况下,所有属性都是可脚本化的,但你可以通过将SCRIPTABLE设置为false来禁止它们对脚本的暴露,如下面的示例所示:
Q_PROPERTY(QString internalName READ internalName SCRIPTABLE false)
行动时间 - 使用脚本为 NPC AI
让我们实现一个脚本,作为简单龙与地下城游戏中非玩家角色的人工智能(AI)。该引擎将定期执行脚本,向它暴露两个对象——生物和玩家。脚本将能够查询玩家的属性并在生物上调用函数。
让我们创建一个新的项目。我们将从实现我们游戏世界中生物的 C++类开始。由于 NPC 和玩家都是生物实体,我们可以为它们有一个共同的基类。在第四章中,Qt 核心基础,我们已经有一个玩家的数据结构,所以让我们使用它作为基类,通过为我们的实体配备类似的属性来实现。将LivingEntity实现为QObject的子类,具有以下属性:
Q_PROPERTY(QString name READ name NOTIFY nameChanged)
Q_PROPERTY(char direction READ direction NOTIFY directionChanged)
Q_PROPERTY(QPoint position READ position NOTIFY positionChanged)
Q_PROPERTY(int hp READ hp NOTIFY hpChanged)
Q_PROPERTY(int maxHp READ maxHp NOTIFY maxHpChanged)
Q_PROPERTY(int dex READ dex NOTIFY dexChanged)
Q_PROPERTY(int baseAttack READ baseAttack NOTIFY baseAttackChanged)
Q_PROPERTY(int armor READ armor NOTIFY armorChanged)
你可以看到这个接口是只读的——你不能使用LivingEntity类修改任何属性。当然,我们仍然需要方法来改变这些值;因此,在类的public接口中实现它们:
public:
void setName(const QString &newName);
void setDirection(char newDirection);
void setPosition(const QPoint &newPosition);
void setHp(int newHp);
void setMaxHp(int newMaxHp);
void setBaseAttack(int newBaseAttack);
void setArmor(int newArmor);
void setDex(int newDex);
当你实现这些方法时,确保在修改属性值时发出适当的信号。让我们添加更多与生物可以执行的动作相对应的方法:
public:
void attack(LivingEntity *enemy);
void dodge();
void wait();
bool moveForward();
bool moveBackward();
void turnLeft();
void turnRight();
最后四个方法实现起来很简单;对于前三个方法,使用以下代码:
void LivingEntity::wait() { if(hp() < maxHp()) setHp(hp()+1); }
void LivingEntity::dodge() {
m_armorBonus += dex();
emit armorChanged(armor()); // m_baseArmor + m_armorBonus
}
void LivingEntity::attack(LivingEntity *enemy) {
if (baseAttack() <=0) return;
int damage = qrand() % baseAttack();
int enemyArmor = enemy->armor();
int inflictedDamage = qMax(0, damage-enemyArmor);
enemy->setHp(qMax(0, enemy->hp() - inflictedDamage));
}
实质上,如果生物选择等待,它将恢复一个生命值。如果它闪避,这增加了它在被攻击时避免伤害的机会。如果它攻击另一个生物,这将根据它自己的攻击力和对手的防御分数造成伤害。
下一步是实现LivingEntity的子类,这样我们就可以使用 Qt 脚本操作对象。为此,按照以下方式实现NPC类:
class NPC : public LivingEntity {
Q_OBJECT
public:
NPC(QObject *parent = 0) : LivingEntity(parent) {}
public slots:
void attack(LivingEntity *enemy) { LivingEntity::attack(enemy); }
void dodge() { LivingEntity::dodge(); }
void wait() { LivingEntity::wait(); }
bool moveForward() { return LivingEntity::moveForward(); }
bool moveBackward() { return LivingEntity::moveBackward(); }
void turnLeft() { LivingEntity::turnLeft(); }
void turnRight() { LivingEntity::turnRight(); }
};
剩下的工作就是创建一个简单的游戏引擎来测试我们的工作。为此,首先在LivingEntity中添加一个reset()方法,在每个回合开始前重置护甲加成。然后,实现GameEngine类:
class GameEngine : public QScriptEngine {
public:
GameEngine(QObject *parent = 0) : QScriptEngine(parent) {
m_timerId = 0;
m_player = new LivingCreature(this);
m_creature = new NPC(this);
QScriptValue go = globalObject();
go.setProperty("player", newQObject(m_player));
go.setProperty("self", newQObject(m_creature));
}
LivingCreature *player() const {return m_player; }
LivingCreature *npc() const { return m_creature; }
void start(const QString &fileName) {
if(m_timerId) killTimer(m_timerId);
m_npcProgram = readScriptFromFile(fileName);
m_timerId = startTimer(1000);
}
protected:
QScriptProgram readScriptFromFile(const QString &fileName) const {
QFile file(fileName);
if(!file.open(QFile::ReadOnly|QFile::Text)) return QScriptProgram();
return QScriptProgram(file.readAll(), fileName);
}
void timerEvent(QTimerEvent *te) {
if(te->timerId() != m_timerId) return;
m_creature->reset();
m_player->reset();
evaluate(m_npcProgram);
}
private:
LivingEntity *m_player;
NPC *m_creature;
QScriptProgram m_npcProgram;
int m_timerId;
};
最后,编写主函数:
int main(int argc, char **argv) {
QCoreApplication app(argc, argv);
GameEngine engine;
engine.player()->setMaxHp(50);
engine.player()->setHp(50);
engine.player()->setDex(10);
engine.player()->setBaseAttack(12);
engine.player()->setArmor(3);
engine.npc()->setMaxHp(100);
engine.npc()->setHp(100);
engine.npc()->setDex(4);
engine.npc()->setBaseAttack(2);
engine.npc()->setArmor(1);
engine.start(argv[1]);
return app.exec();
}
你可以使用以下脚本测试应用程序:
print("Player HP:", player.hp)
print("Creature HP:", self.hp)
var val = Math.random() * 100
if(val < 50) {
print("Attack!")
self.attack(player)
} else {
print("Dodge!");
self.dodge();
}
刚才发生了什么?
我们创建了两种对象类别:LivingCreature,这是读取生物数据的基 API,以及 NPC,它提供了一个更丰富的 API。我们通过重新声明现有函数为槽来实现这种效果。即使方法不是虚拟的,这也是可能的,因为当槽使用 Qt 的元对象系统执行时,它们总是被视为虚拟方法——派生类中的声明总是覆盖父类中的声明。有了这两个类,我们将它们的实例暴露给了脚本环境,并使用计时器每秒调用一个用户定义的脚本。当然,这是一种非常简单的脚本方法,如果用户在脚本中调用多个动作函数,例如在一个脚本中多次调用attack(),生物可以对对手进行多次攻击。说到attack(),请注意它接受一个LivingCreature指针作为其参数。在脚本中,我们用对应于 C++中所需类型的玩家对象来填充它。转换由 Qt Script 自动完成。因此,你可以通过使用QObject指针并使用它们与暴露给脚本的QObject实例来定义方法。以类似的方式,你可以通过使用QVariant或QScriptValue并使用脚本中的任何值来传递它们来定义函数。如果脚本引擎能够将给定的值转换为请求的类型,它将这样做。
尝试成为英雄 - 扩展龙与地下城游戏
这里有一些可以用来扩展我们小型游戏的想法。第一个是为玩家添加脚本执行,以便它试图防御生物。为此,你需要使用LivingCreature API 公开生物的数据,使其只读,并使用读写接口公开玩家。有许多方法可以做到这一点;最简单的方法是提供两个公共QObject接口,它们在共享指针上操作,如下面的图所示:

API 已经包含了移动生物的方法。你可以扩展战斗规则,考虑对手之间的距离和它们之间的相对方向(例如,从背后攻击通常比面对面攻击造成更多伤害)。你甚至可以引入远程战斗。通过扩展LivingCreature接口,添加操作生物存货的属性和方法。允许生物更改其活动武器。
你可以应用的最后一种修改是防止作弊,使用前面描述的机制。不是立即执行动作,而是标记脚本选择的动作(及其参数),然后在脚本执行完毕后执行该动作,例如,如下所示:
void timerEvent(QTimerEvent *te) {
if(te->timerId() != m_timerId) return;
m_creature.reset();
m_player.reset();
evaluate(m_npcProgram);
evaluate(m_playerProgram);
m_creature.executeAction();
m_player.executeAction();
}
另一种方法是给每个生物的每一回合分配行动点,并允许生物将它们用于不同的行动。如果没有足够的点来执行行动,脚本会通知这一点,并且行动失败。
暴露函数
到目前为止,我们一直是在导出对象到脚本中,并调用它们的属性和方法。然而,也有一种方法可以从脚本中调用独立的 C++函数,以及从 C++代码中调用 JavaScript 编写的函数。让我们看看这是如何工作的。
将 C++函数暴露给脚本
您可以使用QScriptEngine::newFunction()调用将独立函数暴露给 Qt 脚本。它返回QScriptValue,就像 JavaScript 中的任何函数一样,也是一个对象,可以用QScriptValue表示。在 C++中,如果一个函数接受三个参数,那么在调用它时必须传递正好三个参数。在 JavaScript 中,这不同——您始终可以向函数传递任意数量的参数,并且这是函数的责任进行适当的参数验证。因此,实际导出的函数应该被包装在另一个函数中,该函数将在调用实际函数之前执行 JavaScript 期望的操作。包装函数需要有一个与newFunction()期望兼容的接口。它应该接受两个参数:脚本上下文和脚本引擎,并且应该返回QScriptValue。context包含有关函数参数的所有信息,包括它们的数量。让我们尝试包装一个接受两个整数并返回它们的和的函数:
int sum(int a, int b) { return a+b; }
QScriptValue sum_wrapper(QScriptContext *context, QScriptEngine *engine) {
if(context->argumentCount() != 2) return engine->undefinedValue();
QScriptValue arg0 = context->argument(0);
QScriptValue arg1 = context->argument(1);
if(!arg0.isNumber() || !arg1.isNumber())
return engine->undefinedValue();
return sum(arg0.toNumber()+arg1.toNumber());
}
现在我们有了包装器,我们可以为它创建一个函数对象,并以与导出常规对象相同的方式将其导出到脚本环境中——通过将其作为脚本的全局对象的属性:
QScriptValue sumFunction = engine.newFunction(sum_wrapper, 2);
engine.globalObject().setProperty("sum", sumFunction);
newFunction()的第二个参数定义了函数期望的参数数量,并且可以通过函数对象的长度属性来获取。这只是供你了解的信息,因为调用者可以传递尽可能多的参数。尝试在导出求和函数之后评估以下脚本:
print("Arguments expected:", sum.length)
print(sum(1,2,3) // sum returns Undefined
我们可以利用这种行为,通过使sum函数返回所有传递给它的参数的总和来扩展其功能:
QScriptValue sum_wrapper(QScriptContext *context,
QScriptEngine *engine) {
int result = 0;
for(int i=0; i<context->argumentCount();++i) {
QScriptValue arg = context->argument(i);
result = sum(result, arg.toNumber());
}
return result;
}
现在,您可以用任意数量的参数调用求和函数:
print(sum());
print(sum(1,2));
print(sum(1,2,3));
这引出了一个有趣的问题:函数的功能是否可以根据传递给它的参数数量而有所不同?答案是肯定的;你可以以任何你想要的方式实现函数,利用 C++ 的全部功能。对于 JavaScript,当这种行为特别有意义时,有一个特定的案例。这就是当函数应该作为属性的获取器和设置器工作时。获取器和设置器是在脚本想要检索或设置某个对象的属性值时调用的函数。通过将获取器和设置器附加到对象上,你可以控制值存储的位置(如果有的话)以及如何检索它。这为向导出的 Qt 对象添加未使用 Q_PROPERTY 宏声明的属性打开了可能性:
class CustomObject : public QObject {
Q_OBJECT
public:
CustomObject(QObject *parent = 0) : QObject(parent) { m_value = 0; }
int value() const { return m_value; }
void setValue(int v) { m_value = v; }
private:
int m_value;
};
QScriptValue getSetValue(QScriptContext *, QScriptEngine*); // function prototype
int main(int argc, char **argv) {
QCoreApplication app(argc, argv);
QScriptEngine engine;
CustomObject object;
QScriptValue object_value = engine.newQObject(&object);
QScriptValue getSetValue_fun = engine.newFunction(getSetValue);
object_value.setProperty("value", getSetValue_fun,
QScriptValue::PropertyGetter|QScriptValue::PropertySetter);
engine.globalObject().setProperty("customObject", object_value);
engine.evaluate("customObject.value = 42");
qDebug() << object.value();
return 0;
}
让我们分析一下这段代码;在这里,我们以标准方式将 CustomObject 的一个实例暴露给脚本引擎。我们还设置了对象的值属性为一个函数,通过传递额外的值给 setProperty(),其中包含一组标志,告诉脚本环境如何处理该属性。在这种情况下,我们告诉它传递的值应用作属性的获取器和设置器。让我们看看函数本身是如何实现的:
QScriptValue getSetValue(QScriptContext *context, QScriptEngine *engine) {
QScriptValue object = context->thisObject();
CustomObject *customObject = qobject_cast<CustomObject*>(object.toQObject());
if(!customObject) return engine->undefinedValue();
if(context->argumentCount() == 1) {
// property setter
customObject->setValue(context->argument(0).toNumber());
return engine->undefinedValue();
} else {
// property getter
return customObject->value();
}
}
首先,我们要求函数提供表示被调用函数的对象的值的上下文。然后,我们使用 qobject_cast 从其中提取一个 CustomObject 指针。接下来,我们检查函数调用的参数数量。在设置器的情况下,函数传递一个参数——要设置到属性中的值。在这种情况下,我们使用对象的 C++ 方法来应用该值。否则,(没有传递参数)函数用作获取器,我们使用 C++ 方法获取值后返回。
将脚本函数暴露给 C++
与使用 QScriptValue 将 C++ 函数导出为 Qt Script 的方式相同,JavaScript 函数也可以导入到 C++ 中。你可以像请求任何其他属性一样请求表示函数的脚本值。以下代码请求引擎的 Math.pow() 函数,该函数对其参数执行幂运算:
QScriptValue powFunction = engine.globalObject().property("Math").property("pow");
让 QScriptValue 表示一个函数,你可以使用值的 call() 方法来调用它,并将任何参数作为脚本值列表传递:
QScriptValueList arguments = { QScriptValue(2), QScriptValue(10) };
QScriptValue result = powFunction.call(QScriptValue(), arguments);
qDebug() << result.toNumber(); // yields 1024
call() 的第一个参数是要用作函数的 this 对象的值。在这个特定的情况下,我们传递一个空对象,因为该函数是独立的——它没有使用其环境。然而,有些情况下,你可能想要在这里设置一个现有的对象,例如,允许函数直接访问现有的属性或定义对象的新的属性。
让我们利用新学的功能来改进我们的《龙与地下城》游戏,以便使用基于 JavaScript 函数和属性的更丰富的脚本功能集。使用的脚本将包含一组用 JavaScript 编写的函数,这些函数将被存储在程序中并在各种情况下调用。在这里,我们只关注脚本部分。你肯定能够自己填补 C++的空白。
行动时间 – 存储脚本
第一个任务是阅读脚本,从中提取所需的函数,并将它们存储在安全的地方。然后,加载游戏项目并添加一个包含以下代码的新类:
class AIScript {
public:
QScriptProgram read(const QString &fileName);
bool evaluate(const QScriptProgram &program, QScriptEngine *engine);
QScriptValue initFunction;
QScriptValue heartbeatFunction;
QScriptValue defendFunction;
};
读取方法可以与原始的readScriptFromFile方法具有相同的内容。评估方法如下:
bool AIScript::evaluate(const QScriptProgram &program, QScriptEngine *engine) {
QScriptContext *context = engine->pushContext();
QScriptValue activationObject;
QScriptValue result = engine->evaluate(program);
activationObject = context->activationObject();
if(!result.isError()) {
initFunction = activationObject.property("init");
heartbeatFunction = activationObject.property("heartbeat");
defendFunction = activationObject.property("defend");
}
engine->popContext();
return !result.isError();
}
修改GameEngine类以使用新代码(记得添加m_ai类成员):
void start(const QString &fileName) {
m_ai = AIScript();
QScriptProgram program = m_ai.read(fileName);
m_ai.evaluate(program, this);
qDebug() << m_ai.initFunction.toString();
qDebug() << m_ai.heartbeatFunction.toString();
qDebug() << m_ai.defendFunction.toString();
}
通过以下脚本运行程序:
function init() {
print("This is init function")
}
function heartbeat() {
print("This is heartbeat function")
}
function defend() {
print("This is defend function")
}
刚才发生了什么?
AIScript对象包含单个实体的 AI 信息。start()方法现在从文件中加载脚本并评估它。脚本预计将定义多个函数,然后从激活对象中检索并存储在AIScript对象中。
行动时间 – 提供初始化函数
本练习的任务是使 AI 能够通过调用init()函数来初始化自己。让我们直接进入正题。将另一个字段扩展到AIScript结构中:
QScriptValue m_thisObject;
这个对象将代表 AI 本身。脚本将能够存储数据或在其中定义函数。同时也要在类中添加以下代码:
void AIScript::initialize(QScriptEngine *engine) {
m_thisObject = engine->newObject();
engine->pushContext();
initFunction.call(m_thisObject);
engine->popContext();
}
在start()的末尾添加对initialize()的调用:
void start(const QString &fileName) {
m_ai = AIScript();
QScriptProgram program = m_ai.read(fileName);
evaluate(program, this);
m_ai.initialize(this);
}
现在,使用以下init()函数运行程序:
function init() {
print("This is init function")
this.distance = function(p1, p2) {
// Manhattan distance
return Math.abs(p1.x-p2.x)+Math.abs(p1.y-p2.y)
}
this.actionHistory = []
}
刚才发生了什么?
在初始化中,我们使用一个空的 JavaScript 对象准备脚本对象,并调用存储在initFunction中的函数,将脚本对象作为this传递。该函数打印一个调试语句,并在该对象中定义两个属性——一个是计算曼哈顿距离的函数,另一个是空的数组,我们将在这里存储 AI 所采取的行动历史。
小贴士
曼哈顿距离是一种计算物体之间距离的度量标准;这比计算实际的欧几里得距离要快得多。它基于这样的假设:当穿越一个由建筑网格组成的大城市时,人们只能沿着那些建筑物的街道行走,并作 90 度转弯。两个位置之间的曼哈顿距离就是一个人必须走过的交叉路口数量,才能从源点到达目的地。在 C++和 Qt 中,你可以使用QPoint类中的manhattanLength()方法轻松计算这个距离。
行动时间 – 实现心跳事件
AI 的核心是心跳函数,它在相等的时间间隔内执行,以允许 AI 决定对象的行为。执行的脚本将能够访问它操作的生物以及其环境。它还可以使用在this对象中定义的任何内容。现在,向AIScript添加一个心跳函数:
void AIScript::heartbeat(QScriptEngine *engine, QObject *personObject, QObject *otherObject) {
QScriptValueList params;
params << engine->newQObject(personObject);
m_thisObject.setProperty("enemy", engine->newQObject(otherObject));
heartbeatFunction.call(m_thisObject, params);
m_thisObject.setProperty("enemy", QScriptValue::UndefinedValue);
}
将计时器恢复,设置为start(),并在计时器事件中启用运行心跳功能:
void timerEvent(QTimerEvent *te) {
if(te->timerId() != m_timerId) return;
m_creature->reset();
m_player->reset();
m_ai.heartbeat(this, m_creature, m_player);
}
运行程序,给它以下heartbeat函数:
function heartbeat(person) {
person.attack(this.enemy)
this.actionHistory.push("ATTACK")
}
刚才发生了什么?
在heartbeat中,我们以与init类似的方式进行,但在这里,我们将 AI 工作的生物作为函数的参数传递,并将其他实体设置为this对象的敌人属性,使其对函数可访问。调用后,我们从this对象中删除敌人属性。该函数本身对敌人进行攻击,并将条目推送到脚本对象历史记录。与在函数调用时直接调用 evaluate 不同,我们不需要推送和弹出执行上下文,因为这是在QScriptValue::call期间自动为我们完成的。
尝试成为英雄——防御攻击
你可能已经注意到我们省略了防御脚本。尝试通过在主体被对手攻击时调用脚本来扩展游戏。在脚本中,允许生物采取不同的防御姿态,例如躲避、阻挡或格挡攻击。让每个动作对攻击结果产生不同的影响。此外,应用你对原始游戏所做的所有修改。尝试通过提供额外的钩子来扩展已编写的代码,在脚本运行时添加新动作和对象。给游戏添加更多敌人怎么样?组织一场最佳 AI 算法竞赛如何?
在脚本中使用信号和槽
Qt 脚本还提供了使用信号和槽的能力。槽可以是 C++方法或 JavaScript 函数。连接可以在 C++或脚本中进行。
首先,让我们看看如何在脚本内部建立连接。当一个QObject实例暴露给脚本时,对象信号成为包装对象的属性。这些属性有一个connect方法,它接受一个函数对象,当信号被发射时将被调用。接收者可以是常规槽或 JavaScript 函数。要将名为button的对象的clicked()信号连接到名为lineEdit的另一个对象的clear()槽,可以使用以下语句:
button.clicked.connect(lineEdit.clear)
如果接收者是名为clearLineEdit的独立函数,调用变为:
button.clicked.connect(clearLineEdit)
你还可以将信号连接到一个直接在连接语句中定义的匿名函数:
button.clicked.connect(function() { lineEdit.clear() })
有额外的语法可用,其中可以定义函数的this对象:
var obj = { "name": "FooBar" }
button.clicked.connect(obj, function() { print(this.name) })
如果需要在脚本内部断开信号,只需将connect替换为disconnect:
button.clicked.disconnect(clearLineEdit)
在脚本中发出信号也很简单——只需将信号作为函数调用,并传递任何必要的参数:
spinBox.valueChanged(7)
要在 C++ 端创建一个接收器为脚本函数的信号-槽连接,而不是使用常规的 connect() 语句,请使用 qScriptConnect()。它的前两个参数与常规调用相同,另外两个参数对应于表示将作为 this 对象的对象的脚本值和表示要调用的函数的脚本值:
QScriptValue function = engine.evaluate("(function() { })");
qScriptConnect(button, SIGNAL(clicked()), QScriptValue(), function);
在这个特定的例子中,我们将一个无效的对象作为第三个参数传递。在这种情况下,this 对象将指向引擎的全局对象。
至于断开信号,当然,有 qScriptDisconnect() 可用。
尝试一下英雄——使用信号和槽触发防御
作为一项任务,尝试修改《龙与地下城》游戏,使得防御脚本函数不是由脚本引擎手动调用,而是通过信号-槽连接来调用。当生物被攻击时,让生物发出 attacked() 信号,并让脚本连接一个处理程序到该信号。使用定义了连接 this 对象的连接变体。
在脚本中创建 Qt 对象
从脚本中有时使用现有的对象并不足以获得丰富的脚本体验。同时能够从脚本中创建新的 Qt 对象,甚至将它们返回到 C++ 中以便游戏引擎使用,也是非常有用的。解决这个问题有两种方法。在我们描述它们之前,了解 JavaScript 如何实例化对象是很重要的。
JavaScript 没有类这个概念。它使用原型来构建对象——原型是一个其属性被克隆到新对象中的对象。对象通过调用构造函数来构建,构造函数可以是任何函数。当你使用关键字 new 调用一个函数时,引擎会创建一个新的空对象,将其构造函数属性设置为作为构造函数的函数,将对象原型设置为该函数的原型,并最终在新的对象上下文中调用该函数,使该函数作为具有特定属性集的对象的工厂函数。因此,要构建 QLineEdit 类型的对象,需要有一个可以作为类似 Qt 小部件对象行为的构造函数的函数。
我们已经知道函数可以存储在 QScriptValue 对象中。有两种方法可以获得可以作为 Qt 对象构造函数的函数。首先,我们可以自己实现它:
QScriptValue pushbutton_ctor(QScriptContext *context, QScriptEngine *engine) {
QScriptValue parentValue = context->argument(0);
QWidget *parent = qscriptvalue_cast<QWidget*>(parentValue);
QPushButton *button = new QPushButton(parent);
QScriptValue buttonValue = engine->newQObject(button, QScriptEngine::AutoOwnership);
return buttonValue;
}
QScriptValue buttonConstructor = engine.newFunction(pushbutton_ctor);
engine.globalObject().setProperty("QPushButton", buttonConstructor);
在这里我们做了三件事。首先,我们定义了一个函数,该函数使用作为函数第一个参数传递的父对象实例化 QPushButton,将对象包装在 QScriptValue 中(带有一个额外的参数,指出负责删除对象的环境应由父对象确定),并将 QScriptValue 返回给调用者。其次,我们将该函数本身包装在 QScriptValue 中,就像我们之前对其他函数所做的那样。最后,我们将该函数设置为引擎的全局对象的一个属性,以便始终可以访问。
获取构造函数的第二种方式是利用 Qt 的元对象系统。你可以使用以下宏来定义一个与手动编写的非常相似的构造函数:
Q_SCRIPT_DECLARE_QMETAOBJECT(QPushButton, QWidget*)
接下来,你可以使用 QScriptEngine::scriptValueFromQMetaObject() 模板方法来获取包装该函数的脚本值:
QScriptValue pushButtonClass = engine.scriptValueFromQMetaObject<QPushButton>();
最后,你可以像之前一样将获得的脚本值设置为脚本引擎中的构造函数。以下是一个完整的代码示例,用于在脚本中创建可创建的按钮:
#include <QtWidgets>
#include <QScriptEngine>
Q_SCRIPT_DECLARE_QMETAOBJECT(QPushButton, QWidget*)
int main(int argc, char **argv) {
QApplication app(argc, argv);
QScriptEngine engine;
QScriptValue pushButtonClass
= engine.scriptValueFromQMetaObject<QPushButton>();
engine.globalObject().setProperty("QPushButton", pushButtonClass);
QString script = "pushButton = new QPushButton\n"
"pushButton.text = 'Script Button'\n"
"pushButton.show()";
engine.evaluate(script);
return app.exec();
}
错误恢复和调试
我们之前讨论的唯一错误恢复是检查脚本是否最终出现错误,并在专用上下文中执行脚本以防止将不再使用的局部变量污染命名空间。这已经很多了;然而,我们还可以做更多。首先,我们可以注意防止全局命名空间的污染。推送和弹出执行上下文并不能阻止脚本修改引擎的全局对象,我们应该防止脚本,例如,替换 Math 对象或打印函数的情况。解决方案是提供自己的全局对象来替代原始的一个。有两种简单的方法可以做到这一点。首先,你可以使用名为 QScriptValueIterator 的类来复制全局对象的所有属性到一个新对象:
QScriptValue globalObject = engine.globalObject();
QScriptValue newGO = engine.newObject();
QScriptValueIterator iter(globalObject);
while(iter.hasNext()) {
iter.next(); newGO.setProperty(iter.key(), iter.value());
}
或者,你可以将原始的全局对象设置为新对象的内部原型:
QScriptValue globalObject = engine.globalObject();
QScriptValue newGO = engine.newObject();
newGO.setPrototype(globalObject);
无论哪种方式,你都需要用临时对象替换原始的全局对象:
engine.setGlobalObject(newGO);
在讨论错误恢复时,另一件要做的大事是为脚本提供调试功能。幸运的是,Qt 包含一个内置的脚本调试组件。如果你使用 QT+=scripttools 选项构建项目,你将能够访问 QScriptEngineDebugger 类。要开始使用脚本引擎的调试器,你需要将它们附加并绑定:
QScriptEngine engine;
QScriptEngineDebugger debugger;
debugger.attachTo(&engine);
每当发生未捕获的异常时,调试器将启动并显示其窗口:

你可以在脚本中设置断点,检查变量或调用栈,并继续或中断执行。一个好的想法是将调试器集成到你的游戏中,以便脚本设计者在开发脚本时可以使用它。当然,调试器不应该在游戏的发布版本中运行。
扩展
QScriptEngine 具有使用 importExtension() 方法导入扩展的能力,这些扩展为脚本环境提供附加功能(例如,可以在游戏的不同部分使用而无需在这里和那里重新定义的实用函数库)。扩展可以通过提供包含脚本的文件集以 JavaScript 实现或通过从 QScriptExtensionPlugin 派生以 C++ 实现。现在,我们将关注第二种方法。以下是一个简单的 C++ 扩展的示例:
class SimpleExtension : public QScriptExtensionPlugin {
Q_OBJECT
Q_PLUGIN_METADATA(IID "org.qt- project.Qt.QScriptExtensionInterface")
public:
SimpleExtension(QObject *parent = 0) : QScriptExtensionPlugin(parent) {}
QStringList keys() const Q_DECL_OVERRIDE { return QStringList() << "simple"; }
void initialize(const QString &key, QScriptEngine *engine) {
QScriptValue simple = engine->newObject();
simple.setProperty("name", "This is text from Simple extension");
engine->globalObject().setProperty("Simple", simple);
}
};
在此处定义的扩展很简单——它只向引擎的全局对象附加一个属性,该属性返回一个文本字符串的名称属性。你应该将生成的库放在一个名为 Simple 的子目录中,该子目录位于应用程序查找插件的脚本子目录中的目录(例如,放置应用程序二进制文件的应用程序)。然后,你可以使用 importExtension() 导入插件:
QScriptEngine engine;
engine.importExtension("Simple");
engine.evaluate("print(Simple.name)")
提示
有关插件放置位置以及如何告诉 Qt 在何处查找它们的信息,请参阅 Qt 参考手册中的 部署插件 部分。
其他 Qt JavaScript 环境
如本章开头所述,Qt 提供了两个环境来使用 JavaScript。我们已经讨论了 Qt Script;现在是时候告诉我们它的对应物:QJSEngine。Qt 中较新的 JavaScript 引擎,它也用于 QML(你将在下一章中学习),它具有与 Qt Script 不同的内部架构,但我们所教授的大部分内容也适用于 QJSEngine。主要区别在于根类的命名不同。请查看以下表格,其中显示了两个引擎的等效类:
| QtScript | QJSEngine |
|---|---|
QScriptEngine |
QJSEngine |
QScriptValue |
QJSValue |
QScriptContext |
– |
QJSEngine 类相当于 QScriptEngine。它还有一个 evaluate() 方法,用于评估脚本。此方法可以创建对象,包装 QObject 实例,并使用 QJSValue(相当于 QScriptValue)以这种方式存储脚本中使用的值,以便可以从 C++ 访问它们。你还可以看到没有 QScriptContext 的等效项,因此其功能在基于 QJSEngine 的实现中不可用。另一个缺失的组件是集成引擎调试器。此外,在撰写本文时,没有简单的方法可以将自己的类导出到基于 QJSEngine 的 JavaScript 环境中,以允许创建这些类的实例。
JavaScript 的替代方案
Qt 脚本是一个设计成 Qt 世界一部分的环境。由于不是每个人都了解或喜欢 JavaScript,我们将介绍另一种可以轻松用于为使用 Qt 创建的游戏提供脚本环境的语言。只是请注意,这不会是对环境的深入描述——我们只会展示一些基础知识,这些知识可以为你的研究提供基础。
Python
用于脚本的一种流行语言是 Python。Python 有两种可用于 Python 的 Qt 绑定:PySide 和 PyQt。PySide 是官方绑定,可在 LGPL 下使用,但目前仅适用于 Qt 4。PyQt 是一个第三方库,可在 GPL v3 和商业许可证下使用,有适用于 Qt 4 和 Qt 5 的变体。请注意,PyQt 不在 LGPL 下可用,因此对于商业闭源产品,你需要从 Riverbank Computing 获得商业许可证!
这些绑定允许你在 Python 中使用 Qt API——你可以仅使用 Python 编写一个完整的 Qt 应用程序。然而,要从 C++ 中调用 Python 代码,你需要一个标准的 Python 解释器。幸运的是,在 C++ 应用程序中嵌入这样的解释器非常简单。
首先,你需要安装 Python 以及其开发包。例如,对于基于 Debian 的系统,最简单的方法是简单地安装 libpythonX.Y-dev(或更新的版本)包,其中 X 和 Y 代表 Python 的版本:
sudo apt-get install libpython3.3-dev
然后,你需要告诉你的程序链接到这个库:
LIBS += -lpython3.3m
INCLUDEPATH += /usr/include/python3.3m/
要从 Qt 应用程序中调用 Python 代码,最简单的方法是使用以下代码:
#include <Python.h>
#include <QtCore>
int main(int argc, char **argv) {
QApplication app(argc, argv);
Py_SetProgramName(argv[0]);
Py_Initialize();
const char *script = "print(\"Hello from Python\")"
PyRun_SimpleString(script);
Py_Finalize();
return app.exec();
}
此代码初始化一个 Python 解释器,然后通过直接传递字符串来调用脚本,最后在调用 Qt 的事件循环之前关闭解释器。这样的代码只适用于简单的脚本。在现实生活中,你可能会想向脚本传递一些数据或获取结果。为此,我们必须编写更多的代码。由于库仅公开 C API,让我们为它编写一个漂亮的 Qt 封装器。
是时候编写一个用于嵌入 Python 的 Qt 封装器了
作为第一个任务,我们将使用面向对象的 API 实现最后一个程序。创建一个新的控制台项目,并向其中添加以下类:
#include <Python.h>
#include <QObject>
#include <QString>
class QtPython : public QObject {
Q_OBJECT
public:
QtPython(const char *progName, QObject *parent = 0) : QObject(parent) {
if(progName != 0) {
wchar_t buf[strlen(progName+1)];
mbstowcs(buf, progName, strlen(progName));
Py_SetProgramName(buf);
}
Py_InitializeEx(0);
}
~QtPython() { Py_Finalize(); }
void run(const QString &program) {
PyRun_SimpleString(qPrintable(program));
}
};
然后,添加一个 main() 函数,如下面的代码片段所示:
#include "qtpython.h"
int main(int argc, char **argv) {
QtPython python(argv[0]);
python.run("print('Hello from Python')");
return 0;
}
最后打开 .pro 文件,并告诉 Qt 链接到 Python 库。在 Linux 的情况下,你可以通过向文件中添加两行来使用 pkg-config:
CONFIG += link_pkgconfig
PKGCONFIG += python-3.3m # adjust the version number to suit your needs
你可能需要使用类似于 apt-get install libpython3.4-dev 的调用安装 Python 库。对于 Windows,你需要手动将信息传递给编译器:
INCLUDEPATH += C:\Python33\include
LIBS += -LC:\Python33\include -lpython33
发生了什么?
我们创建了一个名为 QtPython 的类,它为我们封装了 Python C API。
小贴士
永远不要使用 Q 前缀来调用你的自定义类,因为这个前缀是为官方 Qt 类保留的。这是为了确保你的代码永远不会与 Qt 中将来添加的代码发生名称冲突。另一方面,Qt 前缀是用来与 Qt 的扩展类一起使用的。你可能仍然不应该使用它,但名称冲突的概率要小得多,影响也较小。最好想出一个自己的前缀(例如 Qxy,其中 x 和 y 是你的首字母)。
类构造函数创建一个 Python 解释器,类析构函数销毁它。我们使用 Py_InitializeEx(0),它具有与 Py_Initialize() 相同的功能,但它不会应用 C 信号处理器,因为在嵌入 Python 时我们不会想要这样做。在此之前,我们使用 Py_SetProgramName() 通知解释器我们的上下文。我们还定义了一个 run() 方法,它接受 QString 并返回 void。它使用 qPrintable(),这是一个便利函数,它从 QString 对象中提取一个 C 字符串指针,然后将其输入到 PyRun_SimpleString()。
小贴士
永远不要存储 qPrintable() 的输出,因为它返回一个指向临时字节数组的内部指针(这相当于在字符串上调用 toLocal8Bit().constData())。它可以安全地直接使用,但字节数组随后立即被销毁;因此,如果你将指针存储在变量中,当你在稍后尝试使用该指针时,数据可能不再有效。
使用嵌入式解释器时最困难的工作是将值在 C++ 和解释器期望的类型之间进行转换。在 Qt Script 中,使用了 QScriptValue 类型来完成这项工作。我们可以为我们的 Python 脚本环境实现类似的功能。
行动时间 - 在 C++ 和 Python 之间转换数据
创建一个新的类,并将其命名为 QtPythonValue。然后,向其中添加以下代码:
#include <Python.h>
class QtPythonValue {
public:
QtPythonValue() { incRef(Py_None);}
QtPythonValue(const QtPythonValue &other) { incRef(other.m_value); }
QtPythonValue& operator=(const QtPythonValue &other) {
if(m_value == other.m_value) return *this;
decRef();
incRef(other.m_value);
return *this;
}
QtPythonValue(int val) { m_value = PyLong_FromLong(val); }
QtPythonValue(const QString &str) {
m_value = PyUnicode_FromString(qPrintable(str));
}
~QtPythonValue() { decRef(); }
int toInt() const { return PyLong_Check(m_value) ? PyLong_AsLong(m_value) : 0; }
QString toString() const {
return PyUnicode_Check(m_value) ? QString::fromUtf8(PyUnicode_AsUTF8(m_value)) : QString();
}
bool isNone() const { return m_value == Py_None; }
private:
QtPythonValue(PyObject *ptr) { m_value = ptr; }
void incRef() { if(m_value) Py_INCREF(m_value); }
void incRef(PyObject *val) { m_value = val; incRef(); }
void decRef() { if(m_value) Py_DECREF(m_value); }
PyObject *m_value;
friend class QtPython;
};
接下来,让我们修改 main() 函数以测试我们的新代码:
#include "qtpython.h"
#include "qtpythonvalue.h"
#include <QtDebug>
int main(int argc, char *argv[]) {
QtPython python(argv[0]);
QtPythonValue integer = 7, string = QStringLiteral("foobar"), none;
qDebug() << integer.toInt() << string.toString() << none.isNone();
return 0;
}
当你运行程序时,你会看到 C++ 和 Python 之间的转换在两个方向上都是正确的。
刚才发生了什么?
QtPythonValue 类封装了一个 PyObject 指针(通过 m_value 成员),提供了一个良好的接口,用于在解释器期望的类型和我们的 Qt 类型之间进行转换。让我们看看这是如何实现的。首先,看一下三个私有方法:两个版本的 incRef() 和一个 decRef()。PyObject 包含一个内部引用计数器,它计算包含值的句柄数量。当计数器降至 0 时,对象可以被销毁。我们的三个方法使用适当的 Python C API 调用来增加或减少计数器,以防止内存泄漏并使 Python 的垃圾回收器保持满意。
第二个重要方面是,该类定义了一个私有构造函数,它接受一个PyObject指针,实际上是在给定值上创建了一个包装器。构造函数是私有的;然而,QtPython类被声明为QtPythonValue的朋友,这意味着只有QtPython和QtPythonValue可以通过传递PyObject指针来实例化值。现在,让我们看看公共构造函数。
默认构造函数创建了一个指向None值的对象,这是 Python 中 C++空值的等价物。复制构造函数和赋值运算符相当标准,负责管理引用计数。然后,我们有两个构造函数——一个接受int类型,另一个接受QString值。它们使用适当的 Python C API 调用来获取值的PyObject表示形式。请注意,这些调用已经为我们增加了引用计数,所以我们不需要自己操作。
代码以一个析构函数结束,该析构函数减少引用计数,以及三个提供从QtPythonValue到适当 Qt/C++类型安全转换的方法。
大胆尝试英雄——实现剩余的转换
现在,您应该能够实现QtPythonValue的其他构造函数和转换,它操作的是float、bool类型,甚至是QDate和QTime类型。尝试自己实现它们。如果需要,查看docs.python.org/3/以找到您应该使用的适当调用。我们将通过提供一个如何将QVariant转换为QtPythonValue的骨架实现来给您一个先手。这尤其重要,因为 Python 使用了两种在 C++中不可用的类型,即元组和字典。我们稍后会需要它们,所以有一个合适的实现是至关重要的。以下是代码:
QtPythonValue::QtPythonValue(const QVariant &variant)
{
switch(variant.type()) {
case QVariant::Invalid: incRef(Py_None);
return;
case QVariant::String: m_value
= PyUnicode_FromString(qPrintable(variant.toString()));
return;
case QVariant::Int: m_value = PyLong_FromLong(variant.toInt());
return;
case QVariant::LongLong: m_value
= PyLong_FromLongLong(variant.toLongLong());
return;
case QVariant::List: {
QVariantList list = variant.toList();
const int listSize = list.size();
PyObject *tuple = PyTuple_New(listSize);
for(int i=0;i<listSize;++i) {
PyTuple_SetItem(tuple, i, QtPythonValue(list.at(i)).m_value);
}
m_value = tuple;
return;
}
case QVariant::Map: {
QVariantMap map = variant.toMap();
PyObject *dict = PyDict_New();
for(QVariantMap::const_iterator iter = map.begin();
iter != map.end(); ++iter) {
PyDict_SetItemString(dict,
qPrintable(iter.key()),
QtPythonValue(iter.value()).m_value
);
}
m_value = dict;
return;
}
default: incRef(Py_None); return;
}
}
突出的代码显示了如何从QVariantList创建一个元组(这是一个任意元素的列表),以及如何从QVariantMap创建一个字典(这是一个关联数组)。尝试通过直接接受QStringList、QVariantList和QVariantMap并分别返回元组和字典来添加构造函数。
我们现在已经编写了相当多的代码,但到目前为止,还没有方法将我们的程序中的任何数据绑定到 Python 脚本。让我们改变这一点。
是时候采取行动——调用函数和返回值
下一个任务是提供调用 Python 函数和从脚本中返回值的方法。让我们首先提供一个更丰富的run() API。在QtPython类中实现以下方法:
QtPythonValue QtPython::run(const QString &program, const QtPythonValue &globals, const QtPythonValue &locals)
{
PyObject *retVal = PyRun_String(qPrintable(program),
Py_file_input, globals.m_value, locals.m_value);
return QtPythonValue(retVal);
}
我们还需要一个导入 Python 模块的功能。向类中添加以下方法:
QtPythonValue QtPython::import(const QString &name) const {
return QtPythonValue(PyImport_ImportModule(qPrintable(name)));
}
QtPythonValue QtPython::addModule(const QString &name) const {
PyObject *retVal = PyImport_AddModule(qPrintable(name));
Py_INCREF(retVal);
return QtPythonValue(retVal);
}
QtPythonValue QtPython::dictionary(const QtPythonValue &module) const {
PyObject *retVal = PyModule_GetDict(module.m_value);
Py_INCREF(retVal);
return QtPythonValue(retVal);
}
代码的最后部分是扩展QtPythonValue的以下代码:
bool QtPythonValue::isCallable() const {
return PyCallable_Check(m_value);
}
QtPythonValue QtPythonValue::attribute(const QString &name) const {
return QtPythonValue(PyObject_GetAttrString(m_value, qPrintable(name)));
}
bool QtPythonValue::setAttribute(const QString &name, const QtPythonValue &value) {
int retVal = PyObject_SetAttrString(m_value, qPrintable(name), value.m_value);
return retVal != -1;
}
QtPythonValue QtPythonValue::call(const QVariantList &arguments) const {
return QtPythonValue(PyObject_CallObject(m_value, QtPythonValue(arguments).m_value));
}
QtPythonValue QtPythonValue::call(const QStringList &arguments) const {
return QtPythonValue(PyObject_CallObject(m_value, QtPythonValue(arguments).m_value));
}
最后,您可以将main()修改为测试新功能:
int main(int argc, char *argv[]) {
QtPython python(argv[0]);
QtPythonValue mainModule = python.addModule("__main__");
QtPythonValue dict = python.dictionary(mainModule);
python.run("foo = (1, 2, 3)", dict, dict);
python.run("print(foo)", dict, dict);
QtPythonValue module = python.import("os");
QtPythonValue chdir = module.attribute("chdir");
chdir.call(QStringList() << "/home");
QtPythonValue func = module.attribute("getcwd");
qDebug() << func.call(QVariantList()).toString();
return 0;
}
您可以将/home替换为您选择的目录。然后,您就可以运行程序了。
刚才发生了什么?
在上一个程序中,我们进行了两次测试。首先,我们使用了新的 run() 方法,向它传递要执行的代码和两个定义当前执行上下文的字典——第一个字典包含全局符号,第二个包含局部符号。这些字典来自 Python 的 __main__ 模块(它定义了 print 函数等)。run() 方法可能会修改这两个字典的内容——第一次调用定义了一个名为 foo 的元组,第二次调用将其打印到标准输出。
第二次测试调用了一个导入模块中的函数;在这种情况下,我们调用 os 模块中的两个函数——第一个函数 chdir 改变当前工作目录,另一个函数 getcwd 返回当前工作目录。惯例是我们应该向 call() 函数传递一个元组,其中我们传递所需的参数。第一个函数接受一个字符串作为参数,因此我们传递一个 QStringList 对象,假设存在一个 QtPythonValue 构造函数,它将 QStringList 转换为元组(如果你还没有实现它,你需要实现它)。由于第二个函数不接受任何参数,我们向调用传递一个空元组。同样,你也可以提供自己的模块并从中调用函数,查询结果,检查字典等。这对于一个嵌入式 Python 解释器来说是一个很好的开始。记住,一个合适的组件应该有一些错误检查代码来避免整个应用程序崩溃。
你可以通过许多方式扩展解释器的功能。你甚至可以使用 PyQt5 在脚本中使用 Qt 绑定,将 Qt/C++ 代码与 Qt/Python 代码结合在一起。
尝试将 Qt 对象包装成 Python 对象——英雄出马
到目前为止,你应该已经足够有经验去尝试实现一个 QObject 实例的包装器,以便将信号和槽暴露给 Python 脚本。如果你决定追求这个目标,docs.python.org/3/ 将是你的最佳朋友,特别是关于使用 C++ 扩展 Python 的部分。记住,QMetaObject 提供了有关 Qt 对象属性和方法的信息,QMetaObject::invokeMethod() 允许你通过名称执行方法。这不是一个容易的任务,所以如果你没有能够完成它,不要对自己太苛刻。一旦你获得了更多使用 Qt 和 Python 的经验,你总是可以回过头来做这件事。
在你进入下一章之前,试着测试一下你对 Qt 脚本知识的掌握。
快速问答——脚本
Q1. 你可以使用哪个方法来执行 JavaScript 语句?
-
QScriptEngine::run() -
QScriptEngine::evaluate() -
QScriptProgram::execute()
Q2. 哪个类的名称用作在 Qt 脚本和 C++ 之间交换数据的桥梁?
-
QScriptContext -
QScriptValue -
QVariant
Q3. 哪个类的名称用作在 Python 和 C++ 之间交换数据的桥梁?
-
PyValue -
PyObject -
QVariant
Q4. 执行上下文是如何工作的?
-
它们将一些变量标记为“可执行”,以防止恶意代码被执行。
-
它们允许并行执行脚本,从而提高它们的速度。
-
它们包含函数调用中定义的所有变量,这样一组在脚本内部可见的变量就可以被修改,而不会影响全局环境(称为沙盒)。
摘要
在本章中,你了解到为你的游戏提供脚本环境可以开启新的可能性。使用脚本语言实现功能通常比使用 C++ 的完整编写-编译-测试周期要快,你甚至可以使用那些不了解你游戏引擎内部结构的用户的技能和创造力来使你的游戏更好、功能更丰富。你已经看到了如何使用 Qt Script,它通过将 Qt 对象暴露给 JavaScript 并实现跨语言信号-槽连接,将 C++ 和 JavaScript 世界融合在一起。如果你不是 JavaScript 的粉丝,你也学习了使用 Python 的脚本基础知识。还有其他脚本语言可供选择(例如 Lua),许多语言都可以与 Qt 一起使用。利用本章获得的经验,你应该甚至能够将其他脚本环境带到你的程序中,因为大多数可嵌入的解释器都提供了与 Python 类似的方法。
在下一章中,你将了解到一个非常类似于 Qt Script 的环境,因为它在 JavaScript 上有很重的依赖。然而,使用它的目的完全不同——我们将用它来实现前沿的复杂图形。欢迎来到 Qt Quick 的世界。
第九章。Qt Quick 基础
在本章中,你将了解到一种名为 Qt Quick 的技术,它允许我们实现具有许多视觉效果的分辨率无关的用户界面,这些效果可以与实现应用程序逻辑的常规 Qt 代码结合使用。你将学习 QML 表达性语言的基础,它是 Qt Quick 的基础。使用这种语言,你可以定义复杂的图形和动画,利用粒子引擎,并使用有限状态机来结构化你的代码。纯 QML 代码可以通过与 JavaScript 或 C++逻辑类似的方式补充,正如你在上一章中学到的。到本章结束时,你应该具备足够的知识,可以快速实现具有自定义图形、移动元素和大量视觉特效的出色 2D 游戏。
流体用户界面
到目前为止,我们一直将图形用户界面视为一组嵌套在一起的面板。这在由窗口和子窗口组成的桌面实用程序世界中得到了很好的体现,这些窗口和子窗口主要包含静态内容,散布在广阔的桌面区域中,用户可以使用鼠标指针移动窗口或调整它们的大小。然而,这种设计与现代用户界面不太相符,现代用户界面通常试图最小化它们所占用的区域(因为嵌入式和移动设备的小屏幕尺寸,或者为了避免在游戏中遮挡主显示面板),同时提供丰富的内容,包含许多移动或动态调整大小的项目。这类用户界面通常被称为“流体”,以表明它们不是由多个不同的屏幕组成,而是包含动态内容和布局,其中一屏可以流畅地转换成另一屏。Qt 5 的一部分是 Qt Quick(Qt 用户界面创建工具包)模块,它提供了一个运行时来创建具有流体用户界面的丰富应用程序。它建立在包含层次结构中相互连接项的二维硬件加速画布之上。
表达性 UI 编程
虽然技术上可以使用 C++代码编写 Qt Quick,但该模块附带了一种称为QML(Qt 建模语言)的专用编程语言。QML 是一种易于阅读和理解的表达性语言,它将世界描述为由相互交互和关联的组件组成的层次结构。它使用类似 JSON 的语法,并允许我们使用命令式 JavaScript 表达式以及动态属性绑定。那么,什么是表达性语言呢?
声明式编程是一种编程范式,它规定程序描述计算的逻辑,而不指定如何获得这种结果。与命令式编程相反,在命令式编程中,逻辑以一系列显式步骤的形式表达为一个算法,该算法直接修改中间程序状态,声明式方法侧重于操作最终结果应该是什么。
我们通过创建一个或多个 QML 文档来使用 QML,在这些文档中我们定义对象的层次结构。每个文档由两个部分组成。
你可以通过创建一个新的 Qt Quick UI 项目,并将展示的代码放入为你创建的 QML 文件中,来在 Qt Creator 中跟随我们解释的每个示例。关于使用此项目类型的详细信息将在本章的后续部分描述。
小贴士
如果你无法在 Creator 的向导中看到 Qt Quick UI 项目,你必须通过从 Creator 的 帮助 菜单中选择 关于插件 项来启用名为 QmlProjectManager 的插件,然后滚动到 QtQuick 部分,并确保 QmlProjectManager 项被选中。如果没有选中,请选中它并重新启动 Creator:

第一个部分包含一系列 import 语句,这些语句定义了可以在特定文档中使用的组件范围。在其最简单的形式中,每个语句由 import 关键字后跟要导入的模块 URI(名称)和模块版本组成。以下语句导入了版本 2.1 的 QtQuick 模块:
import QtQuick 2.1
第二个部分包含一个对象层次结构的定义。每个对象声明由两部分组成。首先,你必须指定对象类型,然后跟随着用大括号括起来的详细定义。由于详细定义可以是空的,因此最简单的对象声明类似于以下内容:
Item { }
这声明了一个 Item 元素的实例,这是最基础的 Qt Quick 元素,它代表用户界面中的一个抽象项,没有任何视觉外观。
元素属性
QML 中的每个元素类型都定义了一组属性。这些属性的值可以作为对象详细定义的一部分来设置。Item 类型提供了一些属性来指定项的几何形状:
Item {
x: 10
y: 20
width: 400
height: 300
}
Item 是一个非常有趣和有用的元素,但由于它是完全透明的,我们将现在关注其子类型,该子类型绘制一个填充矩形。这种类型被称为 Rectangle。它有许多额外的属性,其中之一是用于指定矩形填充颜色的 color 属性。为了定义一个红色方块,我们可以编写以下代码:
Rectangle {
color: "red"
width: 400
height: 400
}
这段代码的问题在于,如果我们决定更改方块的大小,我们必须分别更新两个属性的值。然而,我们可以利用声明式方法的力量,将其中一个属性指定为与其他属性的关系:
Rectangle {
color: "red"
width: 400
height: width
}
这被称为属性绑定。它与常规的值赋值不同,将高度值绑定到宽度值。每当宽度发生变化时,高度值会反映这种变化。
注意,在定义中语句的顺序并不重要,因为你在声明属性之间的关系。以下声明与上一个声明在语义上是相同的:
Rectangle {
height: width
color: "red"
width: 400
}
你不仅可以绑定一个属性到另一个属性的值,还可以绑定到任何返回值的 JavaScript 语句。例如,我们可以通过使用三元条件表达式运算符来声明矩形颜色取决于元素的宽度和高度的比例:
Rectangle {
width: 600
height: 400
color: width > height ? "red" : "blue"
}
当对象的width或height发生变化时,绑定到color属性的语句将被重新评估,如果矩形的width大于其height,则矩形将变为红色;否则,它将是蓝色。
属性绑定语句也可以包含函数调用。我们可以扩展color声明,如果矩形是正方形,则使用自定义函数来使用不同的颜色:
Rectangle {
width: 600
height: 400
color: colorFromSize()
function colorFromSize() {
if(width == height) return "green"
if(width > height) return "red"
return "blue"
}
}
QML 尽力确定函数值何时可能发生变化,但它并非万能。对于我们的最后一个函数,它可以很容易地确定函数结果依赖于width和height属性的值,因此如果这两个值中的任何一个发生变化,它将重新评估绑定。然而,在某些情况下,它无法知道函数在下一次调用时可能会返回不同的值,在这种情况下,该语句将不会被重新评估。考虑以下函数:
function colorByTime() {
var d = new Date()
var minutes = d.getMinutes()
if(minutes < 15) return "red"
if(minutes < 30) return "green"
if(minutes < 45) return "blue"
return "purple"
}
将color属性绑定到该函数的结果将不会正常工作。QML 仅在对象初始化时调用此函数一次,并且它永远不会再次调用。这是因为它无法知道该函数的值依赖于当前时间。稍后,我们将看到如何通过一点命令式代码和一个计时器来克服这个问题。
组属性
Rectangle元素不仅允许我们定义填充颜色,还可以定义轮廓大小和颜色。这是通过使用border.width和border.color属性来完成的。你可以看到它们有一个共同的前缀,后面跟着一个点。这意味着这些属性是属性组border的子属性。有两种方法可以将值绑定到这些属性。第一种方法是使用点表示法:
Rectangle {
color: "red"
width: 400
height: 300
border.width: 4
border.color: "black"
}
另一种方法,如果你想在单个组中设置大量子属性,特别有用,是使用括号来包围组中的定义:
Rectangle {
color: "red"
width: 400
height: 300
border {
width: 4
color: "black"
}
}
对象层次结构
我们说过,QML 是关于定义对象层次结构的。你可以以最简单的方式做到这一点——通过将一个对象声明放入另一个对象的声明中。为了创建一个包含圆角框架和一些文本的按钮样式的对象,我们将结合一个Rectangle元素和一个Text元素:
Rectangle {
border { width: 2; color: "black" }
radius: 5
color: "transparent"
width: 50; height: 30
Text {
text: "Button Text"
}
}
注意
你可以使用分号而不是换行符来在 QML 中分隔语句,以获得更紧凑的对象定义,但会牺牲可读性。
运行此代码会产生与以下图示类似的结果:

如我们所见,这看起来并不好——框架不够大,无法容纳文本,因此文本流出了框架之外。此外,文本的位置也不正确。
与子小部件被剪裁到其父几何形状的 widgets 不同,Qt Quick 项目可以定位在其父元素之外。
由于我们没有指定文本的 x 和 y 坐标,它们被设置为默认值,即 0。因此,文本被固定在框架的左上角,并流出了框架的右边缘。
为了纠正这种行为,我们可以将框架的宽度绑定到文本的宽度。为此,在矩形宽度的属性绑定中,我们必须指定我们想要使用文本对象的宽度。QML 提供了一个名为 id 的伪属性,允许程序员命名对象。让我们为 Text 元素提供一个 ID,并将外部对象的宽度绑定到文本的宽度,同时也要对高度做同样的绑定。同时,让我们稍微重新定位文本,为框架和文本本身之间的四个像素提供填充:
Rectangle {
border { width: 2; color: "black" }
radius: 5
color: "transparent"
width: buttonText.width+8; height: buttonText.height+8
Text {
id: buttonText
text: "Button Text"
x:4; y: 4
}
}
如以下图像所示,这样的代码可以工作,但仍然存在问题:

如果你将空文本设置到内部元素中,矩形宽度和高度将降至 8,这看起来并不好。如果文本非常长,看起来也会很糟糕:

让我们进一步复杂化问题,通过向矩形添加另一个子元素来给按钮添加一个图标。Qt Quick 提供了一个 Image 类型来显示图像,所以让我们用它来将图标定位在文本的左侧:
Rectangle {
id: button
border { width: 2; color: "black" }
radius: 5
color: "transparent"
width: 4 + buttonIcon.width + 4 + buttonText.width + 4
height: Math.max(buttonIcon.height, buttonText.height) + 8
Image {
id: buttonIcon
source: "edit-undo.png"
x: 4; y: button.height/2-height/2
}
Text {
id: buttonText
text: "Button Text"
x: 4+buttonIcon.width+4
y: button.height/2-height/2
}
}
在此代码中,我们使用了 JavaScript 中可用的 Math.max 函数来计算按钮的高度,并修改了内部对象的 y 属性的定义,以在按钮中垂直居中。Image 的源属性包含要显示在项目中的图像的文件 URL。
注意
URL 可以指向本地文件,也可以指向远程 HTTP 资源。在这种情况下,如果远程机器可访问,文件将自动从远程服务器获取。
代码的结果可以在以下图像中看到:

计算每个内部元素的位置以及按钮框架的大小变得越来越复杂。幸运的是,我们不必这样做,因为 Qt Quick 通过将某些对象的某些点附着到另一个对象的点上,提供了一种更好的管理项目几何形状的方法。这些点被称为锚线。以下锚线对每个 Qt Quick 项目都是可用的:

你可以建立锚点线之间的绑定来管理项目的相对位置。每个锚点线由两个属性表示——一个可以绑定到某物,另一个可以绑定从某物绑定。要绑定的锚点是对象的常规属性。它们可以作为在锚点属性组中定义的属性的绑定参数。因此,要将当前对象的 "left" 锚点绑定到 otherObject 对象的 "right" 锚点,可以编写如下代码:
anchors.left: otherObject.right
除了指定任意数量的锚点绑定之外,我们还可以为每个锚点(或所有锚点)设置边距,以偏移两个绑定的锚点线。使用锚点,我们可以简化之前的按钮定义:
Rectangle {
border { width: 2; color: "black" }
radius: 5
color: "transparent"
width: 4 + buttonIcon.width + 4 + buttonText.width + 4
height: Math.max(buttonIcon.height, buttonText.height) + 8
Image {
id: buttonIcon
source: "edit-undo.png"
anchors {
left: parent.left;
leftMargin: 4;
verticalCenter: parent.verticalCenter
}
}
Text {
id: butonText
text: "Button Text"
anchors {
left: buttonIcon.right;
leftMargin: 4;
verticalCenter: parent.verticalCenter
}
}
}
你可以看到 button ID 已经不再使用了。取而代之的是,我们使用 parent,这是一个始终指向项目父项的属性。
行动时间 – 创建按钮组件
作为练习,让我们尝试使用到目前为止所学的内容来创建一个更完整且更好的按钮组件。按钮应具有圆角形状和漂亮的背景,并应包含可定义的文本和图标。按钮应适用于不同的文本和图标。
首先,在 Qt Creator 中创建一个新的项目。选择 Qt Quick UI 作为项目类型。当被问及组件集时,选择 Qt Quick 的最低可用版本:

到目前为止,你应该有一个包含两个文件的项目——一个带有 QML 项目扩展名的文件,这是你的项目管理文件,另一个带有 QML 扩展名的文件,这是你的主要用户界面文件。你可以看到这两个文件都包含 QML 定义。这是因为 Qt Creator 使用 QML 本身来管理 Qt Quick 项目(你会注意到它导入了 QmlProject 模块)。
为我们创建的 QML 文档包含一个 "Hello World" 示例代码,我们可以将其作为 Qt Quick 实验的起点。如果你转到 项目 面板并查看项目的 运行配置,你会注意到它使用了一个名为 QML Scene 的东西来运行你的项目。此配置调用一个名为 qmlscene 的外部应用程序,该程序能够加载并显示任意 QML 文档。如果你运行示例代码,你应该看到一个带有一些居中文本的白色窗口。如果你在窗口的任何地方点击,应用程序将关闭。
首先,让我们创建按钮框架。将Text项目替换为Rectangle项目。你可以看到,通过使用我们之前未提及的centerIn锚点绑定,文本被居中显示在窗口中。这是两个特殊锚点之一,提供便利以避免编写过多的代码。使用centerIn相当于设置horizontalCenter和verticalCenter。另一个便利绑定是fill,它使一个项目占据另一个项目的整个区域(类似于将左、右、上、下锚点设置为目的地项目中的相应锚点线)。
让我们通过设置一些基本属性来给按钮面板一个基本的样式。这次,我们不会为按钮设置纯色,而是声明背景为线性渐变。将Text定义替换为以下代码:
Rectangle {
id: button
anchors.centerIn: parent
border { width: 1; color: "black" }
radius: 5
width: 100; height: 30
gradient: Gradient {
GradientStop { position: 0; color: "#eeeeee" }
GradientStop { position: 1; color: "#777777" }
}
}
运行项目后,你应该看到以下类似的结果图像:

刚才发生了什么?
我们将一个Gradient元素绑定到渐变属性,并定义了两个GradientStop元素作为其子元素,其中我们指定了两种颜色进行混合。Gradient不继承自Item,因此不是一个视觉 Qt Quick 元素。相反,它只是一个作为渐变定义数据持有者的对象。
Item类型有一个名为children的属性,包含一个项目视觉子项(Item实例)的列表,还有一个名为resources的属性,包含一个项目非视觉对象(如Gradient或GradientStop)的列表。通常,在向项目添加视觉或非视觉对象时,你不需要使用这些属性,因为项目会自动将子对象分配到适当的属性中。请注意,在我们的代码中,Gradient对象不是Rectangle的子对象;它只是被分配到其gradient属性。
执行动作 – 添加按钮内容
下一步是向按钮添加文本和图标。我们将通过使用另一种项目类型Row来完成此操作,如下所示:
Rectangle {
id: button
// …
gradient: Gradient {
GradientStop { position: 0; color: "#eeeeee" }
GradientStop { position: 1; color: "#777777" }
}
width: buttonContent.width+8
height: buttonContent.height+8
Row {
id: buttonContent
anchors.centerIn: parent
spacing: 4
Image {
id: buttonIcon
source: "edit-undo.png"
}
Text {
id: buttonText
text: "ButtonText"
}
}
}
你将得到以下输出:

刚才发生了什么?
Row是四种位置器类型之一(其他为Column、Grid和Flow),它将子项水*展开。这使得在不使用锚点的情况下定位一系列项目成为可能。Row有一个间距属性,用于指定项目之间应留多少空间。
执行动作 – 正确设置按钮大小
我们当前的面板定义在调整按钮大小时仍然表现不佳。如果按钮内容非常小(例如,图标不存在或文本非常短),按钮看起来将不会很好。通常,按钮强制执行最小尺寸–如果内容小于指定的尺寸,按钮将扩展到允许的最小尺寸。另一个问题是用户可能想要覆盖项的宽度和高度。在这种情况下,按钮的内容不应超出按钮的边界。让我们通过用以下代码替换width和height属性绑定来修复这两个问题:
clip: true
implicitWidth: Math.max(buttonContent.implicitWidth+8, 80)
implicitHeight: buttonContent.implicitHeight+8
刚才发生了什么?
implicitWidth和implicitHeight属性可以包含项目希望拥有的所需大小。它是从小部件世界中的sizeHint()的直接等价物。通过使用这两个属性而不是width和height(默认情况下它们绑定到implicitWidth和implicitHeight),我们允许组件的使用者覆盖这些隐式值。当这种情况发生且用户没有设置足够宽或高以包含图标和按钮文本时,我们通过将clip属性设置为true来防止内容超出父项的边界。
行动时间 – 将按钮制作成可重用组件
到目前为止,我们一直在处理单个按钮。通过复制代码、更改所有组件的标识符以及将不同的绑定设置到属性上,添加另一个按钮是非常繁琐的任务。相反,我们可以将我们的按钮项制作成一个真正的组件,即一个新的 QML 类型,可以根据需要实例化,所需次数不限。
首先,将文本光标定位在按钮定义的大括号开括号之前,然后在键盘上按Alt + Enter以打开重构菜单,如下截图所示:

从菜单中选择将组件移动到单独的文件。在弹出的对话框中,输入新类型的名称(例如,按钮)并通过点击确定按钮接受对话框:

刚才发生了什么?
您可以看到项目中有一个名为Button.qml的新文件,它包含按钮项曾经拥有的所有内容。主文件被简化为以下类似的内容:
import QtQuick 2.0
Rectangle {
width: 360
height: 360
Button {
id: button
}
}
Button 已经成为一个组件——一个新类型元素的定义,它可以像导入 QML 的元素类型一样使用。记住,QML 组件名称以及代表它们的文件名称需要以大写字母开头!如果你将文件命名为 "button.qml" 而不是 "Button.qml",那么你将无法使用 "Button" 作为组件名称,尝试使用 "button" 代替将会导致错误信息。这同样适用于两种情况——每个以大写字母开头的 QML 文件都可以被视为组件定义。我们稍后会更多地讨论组件。
事件处理器
Qt Quick 旨在用于创建高度交互的用户界面。它提供了一些元素,用于从用户那里获取输入事件。
鼠标输入
在所有这些中,最简单的是 MouseArea。它定义了一个透明的矩形,暴露了与鼠标输入相关的多个属性和信号。常用的信号包括 clicked、pressed 和 released。让我们做一些练习,看看这个元素如何使用。
开始行动——使按钮可点击
到目前为止,我们的组件看起来就像一个按钮。接下来的任务是让它能够响应鼠标输入。正如你可能猜到的,这是通过使用 MouseArea 元素来实现的。
将一个 MouseArea 子项添加到按钮中,并使用锚点使其填充按钮的整个区域。将此元素命名为 buttonMouseArea。将以下代码放入项的主体中:
Rectangle {
id: button
// ...
Row { ... }
MouseArea {
id: buttonMouseArea
anchors.fill:parent
onClicked: button.clicked()
}
}
除了这个之外,在按钮对象的 ID 声明之后设置以下声明:
Rectangle {
id: button
signal clicked()
// ...
}
为了测试修改,在按钮对象定义的末尾添加以下代码,就在闭合括号之前:
onClicked: console.log("Clicked!")
然后,运行程序并点击按钮。你会在 Creator 的控制台中看到你的消息被打印出来。恭喜你!
发生了什么?
使用 clicked() 信号语句,我们声明按钮对象会发出一个名为 clicked 的信号。通过 MouseArea 元素,我们定义了一个矩形区域(覆盖整个按钮),该区域会响应鼠标事件。然后,我们定义了 onClicked,这是一个信号处理器。对于对象拥有的每个信号,都可以将一个脚本绑定到以信号名命名并以 "on" 为前缀的处理程序;因此,对于 clicked 信号,处理程序被命名为 onClicked,而对于 valueChanged,它被命名为 onValueChanged。在这种情况下,我们定义了两个处理程序——一个用于按钮,我们在控制台写入一个简单的语句,另一个用于 MouseArea 元素,我们调用按钮的信号函数,从而有效地发出该信号。
MouseArea 有更多功能,所以现在让我们尝试正确使用它们,使我们的按钮功能更丰富。
开始行动——可视化按钮状态
目前,点击按钮没有视觉反应。在现实世界中,按钮有一定的深度,当你按下它并从上方看时,其内容似乎会稍微向右和向下移动。让我们通过利用MouseArea具有的按下属性来模拟这种行为,该属性表示鼠标按钮是否当前被按下(注意,按下属性与前面提到的按下信号不同)。按钮的内容由Row元素表示,因此在其定义内添加以下语句:
Row {
id: buttonContent
// …
anchors.verticalCenterOffset: buttonMouseArea.pressed ? 1 : 0
anchors.horizontalCenterOffset: buttonMouseArea.pressed ? 1 : 0
// …
}
我们还可以使文本在鼠标光标悬停在按钮上时改变颜色。为此,我们必须做两件事。首先,让我们通过设置hoverEnabled属性来启用MouseArea接收悬停事件:
hoverEnabled: true
当此属性被设置时,MouseArea将在检测到鼠标光标在其自身区域上方时,将它的containsMouse属性设置为true。我们可以使用这个值来设置文本颜色:
Text {
id: buttonText
text: "ButtonText"
color: buttonMouseArea.containsMouse ? "white" : "black"
}
发生了什么?
在上一个练习中,我们学习了如何使用MouseArea的一些属性和信号来使按钮组件更加交互式。然而,这个元素的功能要丰富得多。特别是,如果启用了悬停事件,你可以通过返回值的mouseX和mouseY属性在项目的局部坐标系中获取当前鼠标位置。通过处理positionChanged信号也可以报告光标位置。说到信号,大多数MouseArea信号都携带一个MouseEvent对象作为它们的参数。这个参数被称为mouse,并包含有关鼠标当前状态的有用信息,包括其位置和当前按下的按钮:
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: {
switch(mouse.button) {
case Qt.LeftButton: console.log("Left button clicked"); break;
case Qt.MiddleButton: console.log("Middle button clicked"); break;
case Qt.RightButton: console.log("Right button clicked"); break;
}
}
onPositionChanged: {
console.log("Position: ["+mouse.x+"; "+mouse.y+"]")
}
}
行动时间 - 通知环境按钮状态
我们添加了一些代码,通过改变其视觉外观使按钮看起来更自然。现在,让我们扩展按钮编程接口,以便开发者可以使用更多按钮功能。
我们可以做的第一件事是为按钮引入一些新的属性,使其颜色可定义。让我们将高亮代码放在按钮组件定义的开始部分:
Rectangle {
id: button
property color topColor: "#eeeeee"
property color bottomColor: "#777777"
property color textColor: "black"
property color textPressedColor: "white"
signal clicked()
然后,我们将使用新的背景渐变定义:
gradient: Gradient {
GradientStop { position: 0; color: button.topColor }
GradientStop { position: 1; color: button.bottomColor }
}
现在对于文本颜色:
Text {
id: buttonText
text: "ButtonText"
color: buttonMouseArea.containsMouse ?
button.textPressedColor : button.textColor
}
此外,请注意,我们使用了MouseArea的pressed属性来检测当前是否在区域上按下鼠标按钮。我们可以给我们的按钮配备一个类似的属性。将以下代码添加到Button组件中:
property alias pressed: buttonMouseArea.pressed
发生了什么?
第一组更改引入了四个新属性,定义了四种颜色,我们后来在定义按钮渐变和文本颜色时使用了这些颜色。在 QML 中,你可以使用property关键字为对象定义新属性。关键字后面应跟属性类型和属性名称。QML 理解许多属性类型,最常见的是 int、real、string、font 和 color。属性定义可以包含一个可选的默认值,该值以冒号开头。对于pressed属性定义,情况则不同。对于属性类型,定义中包含单词 alias。它不是一个属性类型,而是一个指示符,表示该属性实际上是另一个属性的别名——每次访问按钮的pressed属性时,都会返回buttonMouseArea.pressed属性的值,每次属性值改变时,实际上是鼠标区域的属性被真正改变。使用常规属性声明时,你可以提供任何有效的表达式作为默认值,因为表达式绑定到属性上。使用属性别名时,情况则不同——值是强制性的,并且必须指向相同或另一个对象的现有属性。你可以将属性别名视为 C++中的引用。
考虑以下两个定义:
property int foo: someobject.prop
property alias bar: someobject.prop
初看之下,它们很相似,因为它们指向相同的属性,因此返回的属性值是相同的。然而,这些属性实际上非常不同,如果你尝试修改它们的值,这一点就会变得明显:
foo = 7
bar = 7
第一个属性实际上有一个表达式绑定到它,所以将7赋值给foo只是释放了绑定,并将值7赋给foo属性,而someobject.prop保持其原始值。然而,第二个语句却像 C++引用一样操作;因此,赋值新值将修改别名真正指向的someobject.prop属性。
说到属性,当属性值被修改时,有一个简单的方法可以做出反应。对于每个现有属性,都有一个处理程序可用,每当属性值被修改时都会执行该处理程序。处理程序名称是on后跟属性名称,然后是单词Changed,全部使用驼峰式命名法——因此,对于 foo 属性,它变为onFooChanged,对于topColor,它变为onTopColorChanged。要将按钮当前的按下状态记录到控制台,我们只需要实现此属性的属性更改处理程序:
Button {
// …
onPressedChanged: {
console.log("The button is currently "
+(pressed ? "" : "not ")+"pressed")
}
触摸输入
如前所述,MouseArea是输入事件元素中最简单的。如今,越来越多的设备具有触摸功能,Qt Quick 也能处理它们。目前,我们有三种处理触摸输入的方法。
首先,我们可以继续使用 MouseArea,因为简单的触摸事件也被报告为鼠标事件;因此,点击和滑动手指在屏幕上是支持的。以下练习在触摸设备上使用鼠标作为输入时也有效。
操作时间 - 拖动项目
创建一个新的 Qt Quick UI 项目。通过丢弃现有的子项目并添加一个圆形来修改默认代码:
Rectangle {
id: circle
width: 60; height: width
radius: width/2
color: "red"
}
接下来,使用 MouseArea 的 drag 属性来通过触摸(或鼠标)启用移动圆形:
MouseArea {
anchors.fill: parent
drag.target: circle
}
然后,你可以启动应用程序并开始移动圆形。
发生了什么?
通过定义一个高度等于宽度的矩形来创建一个圆形,使其成为正方形,并将边框圆滑到边长的一半。drag 属性可以用来告诉 MouseArea 使用输入事件管理给定项目在区域元素中的位置。我们使用目标子属性来表示要拖动的项目。你可以使用其他子属性来控制项目允许移动的轴或限制移动到给定区域。要记住的一个重要事情是,正在拖动的项目不能在请求拖动的轴上锚定;否则,项目将尊重锚点而不是拖动。我们没有将我们的圆形项目锚定,因为我们希望它可以在两个轴上拖动。
处理 Qt Quick 应用程序中触摸输入的第二种方法是使用 PinchArea,它是一个类似于 MouseArea 的项目,但它不是拖动项目,而是允许你使用两个手指(所谓的“捏合”手势)旋转或缩放它,如图所示。请注意,PinchArea 只对触摸输入做出反应,因此要测试示例,你需要一个真正的触摸设备。

操作时间 - 通过捏合旋转和缩放图片
开始一个新的 Qt Quick UI 项目。在 QML 文件中,删除除外部项目之外的所有内容。然后,向 UI 添加一个图像并将其居中在其父元素中:
Image {
id: image
anchors.centerIn: parent
source: "wilanow.jpg"
}
现在,我们将添加一个 PinchArea 元素。这类项目可以用两种方式使用——要么通过手动实现信号处理程序 onPinchStarted、onPinchUpdated 和 onPinchFinished 以完全控制手势的功能,要么通过使用类似于 MouseArea 拖动属性的简化界面。由于简化界面正好符合我们的需求,因此无需手动处理捏合事件。让我们将以下声明添加到文件中:
PinchArea {
anchors.fill: parent
pinch {
target: image
minimumScale: 0.2; maximumScale: 2.0
minimumRotation: -90; maximumRotation: 90
}
}
你将得到一个类似于以下截图的输出:

发生了什么?
我们简单的应用程序加载一张图片并将其在视图中居中。然后,有一个PinchArea项目填充视图区域,它被指示对图像对象进行操作。我们定义了项目缩放和旋转的范围。其余的由PinchArea项目本身处理。如果你开始与应用程序交互,你会看到项目旋转和缩放。实际上幕后发生的事情是PinchArea修改了每个 Qt Quick 项目所拥有的两个属性值——rotation和scale。
注意
PinchArea也可以通过pinch.dragAxis控制项目的拖动,就像MouseArea通过拖动一样,但为了简单起见,我们没有使用这个 API 的部分。请随意在自己的代码中实验它。
尝试一下英雄般的旋转和缩放——使用鼠标
当然,你不必使用PinchArea来旋转或缩放项目。控制这些方面的属性是常规属性,你可以在任何时候读取和写入它们。尝试用MouseArea替换PinchArea,通过修改缩放和旋转属性作为接收鼠标事件的结果来获得类似的效果——当用户按住左键拖动鼠标时,图像被缩放;当用户按住右键做同样的事情时,图像被旋转。你可以通过操作acceptedButtons属性来控制哪些按钮触发鼠标事件(将acceptedButtons设置为Qt.LeftButton|Qt.RightButton将导致两个按钮都触发事件)。触发事件的按钮通过事件对象的button属性报告(称为mouse),所有当前按下的按钮列表都可在button属性中找到:
MouseArea {
acceptedButtons: Qt.LeftButton|Qt.RightButton
onPositionChanged: console.log(mouse.button)
}
如果你完成了这个任务,尝试再次用PinchArea替换MouseArea,但这次不是使用pinch属性,而是手动处理事件以获得相同的效果(事件对象称为pinch,并且有许多你可以操作的属性)。
处理触摸输入的第三种方法是使用MultiPointTouchArea项目。它通过分别报告每个触摸点提供对手势的低级接口。它可以用来创建类似于PinchArea的自定义高级手势处理器。
键盘输入
到目前为止,我们一直在处理指针输入,但用户输入不仅仅是那样——我们还可以处理键盘输入。这相当简单,基本上归结为两个简单的步骤。
首先,你必须通过声明特定项目具有键盘焦点来启用接收键盘事件:
focus: true
然后,你可以通过以类似处理鼠标事件的方式编写处理程序来开始处理事件。然而,Item没有提供自己的处理程序来操作键,这是QWidget的keyPressEvent和keyReleaseEvent的对应物。相反,Keys附加属性提供了适当的处理程序。
附加属性是由不作为独立元素使用的元素提供的,而是通过附加到其他对象来为它们提供属性。这是在不修改原始元素 API 的情况下添加对新属性支持的一种方式(它不是通过is-a关系添加新属性,而是通过has-a关系)。每个引用附加属性的对象都会获得一个附加对象的副本,然后处理额外的属性。我们将在本章的后面部分回到附加属性。现在,你只需要记住,在某些情况下,一个元素可以获取不属于其 API 的附加属性。
让我们回到实现键盘输入的事件处理器。正如我们之前所说的,每个 Item 都有一个名为Keys的附加属性,它允许我们安装自己的键盘处理器。Keys为Item添加的基本两个信号是按下和释放;因此,我们可以实现具有KeyEvent参数的onPressed和onReleased处理器,这些参数提供的信息与在控件世界中QKeyEvent类似。作为一个例子,我们可以看到一个检测空格键被按下的项目:
Rectangle {
focus: true
Keys.onPressed: { if(event.key == Qt.Key_Space) color = "red" }
Keys.onReleased: { if(event.key == Qt.Key_Space) color = "blue" }
}
如果你想在同一个项目中处理许多不同的键,可能会出现问题,因为onPressed处理器可能包含一个巨大的 switch 部分,其中包含每个可能键的分支。幸运的是,Keys提供了更多的属性。大多数常用的键(但不是字母)都有自己的处理器,当特定键被按下时会被调用。因此,我们可以轻松实现一个项目,根据最后按下的键来改变不同的颜色:
Rectangle {
focus: true
Keys.onSpacePressed: color = "purple"
Keys.onReturnPressed: color = "navy"
Keys.onVolumeUpPressed: color = "blue"
Keys.onRightPressed: color = "green"
Keys.onEscapePressed: color = "yellow"
Keys.onTabPressed: color = "orange"
Keys.onDigit0Pressed: color = "red"
}
请注意,即使某个键有自己的按下信号,仍然只有一个释放信号。
现在,考虑另一个例子:
import QtQuick 2.1
Item {
property int number: 0
width: 200; height: width
focus: true
Keys.onSpacePressed: number++
Text { text: number; anchors.centerIn: parent }
}
我们预计,当我们按下并保持空格键时,我们会看到文本从0变为1并保持在该值,直到我们释放键。如果你运行示例,你会看到相反的情况,数字会一直增加,只要你按住键。这是因为默认情况下,键会自动重复——当你按住键时,操作系统会持续发送一系列针对该键的按下-释放事件(你可以通过在Keys.onPressed和Keys.onReleased处理器中添加console.log()语句来验证这一点)。为了抵消这种效果,你可以禁用系统中的键重复(当然,如果有人在自己的计算机上安装了你的程序,这将不起作用)或者你可以区分自动重复和常规事件。在 Qt Quick 中,你可以轻松地做到这一点,因为每个键事件都携带适当的信息。只需用以下处理器替换最后一个示例中的处理器即可:
Keys.onSpacePressed: if(!event.isAutoRepeat) number++
我们在这里使用的事件变量是spacePressed信号的参数名称。由于我们无法像在 C++中那样为参数声明自己的名称,对于每个信号处理器,你将不得不在文档中查找参数的名称,如下所示:

在标准的 C++ 应用程序中,我们通常使用 Tab 键在可聚焦项之间导航。在游戏(以及通用的流畅用户界面)中,更常见的是使用箭头键进行项目导航。当然,我们可以通过使用 Keys 附加属性并为每个我们想要修改所需项目焦点属性的项添加 Keys.onRightPressed、Keys.onTabPressed 和其他信号处理程序来处理这种情况,但这会使我们的代码很快变得杂乱。Qt Quick 再次伸出援手,通过提供 KeyNavigation 附加属性来处理这种情况,该属性旨在处理这种特定情况,并允许我们极大地简化所需的代码。现在,我们只需指定在触发特定键时哪个项目应该获得焦点:
Row {
spacing: 5
Rectangle {
id: first
width: 50; height: width
color: focus ? "blue" : "lightgray"
focus: true
KeyNavigation.right: second
}
Rectangle {
id: second
width: 50; height: width
color: focus ? "blue" : "lightgray"
KeyNavigation.right: third
KeyNavigation.left: first
}
Rectangle {
id: third
width: 50; height: width
color: focus ? "blue" : "lightgray"
KeyNavigation.left: second
}
}
注意到我们通过显式设置焦点属性,使第一个项目在开始时获得焦点。
Keys 和 KeyNavigation 附加属性都有一种定义每个机制接收事件顺序的方法。这是通过优先级属性来处理的,它可以设置为 BeforeItem 或 AfterItem。默认情况下,Keys 将首先接收到事件(BeforeItem),然后进行内部事件处理,最后 KeyNavigation 将有机会处理该事件(AfterItem)。请注意,如果事件被其中一个机制处理,则事件被接受,其余的机制将无法处理该事件。
尝试一下英雄 - 练习关键事件传播
作为练习,您可以通过构建一个更大的项目数组(您可以使用 Grid 元素来定位它们)并定义一个利用 KeyNavigation 附加属性的导航系统来扩展我们的最后一个示例。让一些项目使用 Keys 附加属性自行处理事件。看看当同一个键被两个机制处理时会发生什么。尝试使用优先级属性来影响行为。
除了我们描述的附加属性外,Qt Quick 还提供了处理键盘输入的内置元素。最基本的两类是 TextInput 和 TextEdit,它们是 QLineEdit 和 QTextEdit 的 QML 等价物。前者用于单行文本输入,而后者作为其多行对应物。它们都提供光标处理、撤销-重做功能和文本选择。您可以通过将验证器分配给 validator 属性来验证 TextInput 中输入的文本。例如,为了获得一个用户可以输入点分隔 IP 地址的项目,我们可以使用以下声明:
TextInput {
id: ipAddress
width: 100
validator: RegExpValidator {
regExp: /\d+\.\d+\.\d+\.\d+/
/* four numbers separated by dots*/
}
focus: true
}
正则表达式仅验证地址的格式。用户仍然可以插入无效的数字。您应该在使用地址之前进行适当的检查,或者提供一个更复杂的正则表达式,以限制用户可以输入的数字范围。
有一个需要注意的事情是,TextInput 和 TextEdit 都没有任何视觉外观(除了它们包含的文本和光标之外),所以如果你想给用户一些视觉提示,说明项目在哪里定位,最简单的解决方案是将它包裹在一个样式矩形中:
Rectangle {
id: textInputFrame
width: 200
height: 40
border { color: "black"; width: 2 }
radius: 10
antialiasing: true
color: "darkGray"
}
TextInput {
id: textInput
anchors.fill: textInputFrame
anchors.margins: 5
font.pixelSize: height-2
verticalAlignment: TextInput.AlignVCenter
clip: true
}
注意高亮显示的代码——textInput 的 clip 属性被启用,这样默认情况下,如果输入框中的文本不适合项目,它将溢出到项目外并保持可见。通过启用裁剪,我们明确表示任何不适合项目的内容不应被绘制。

在 Qt Quick 中使用组件
到现在为止,你应该已经熟悉了 QML 和 Qt Quick 的基础知识。现在,我们可以开始结合你所知道的知识,并用更多信息来填补空白,构建一个功能性的 Qt Quick 应用程序。我们的目标是显示一个模拟时钟。
动手实践时间 – 一个简单的模拟时钟应用程序
创建一个新的 Qt Quick UI 项目。为了创建一个时钟,我们将实现一个代表时钟指针的组件,并在实际时钟元素中使用该组件的实例。除此之外,我们还将使时钟成为一个可重用的组件;因此,我们将它创建在一个单独的文件中,并在 main.qml 内部实例化它:
import QtQuick 2.0
Clock {
id: clock
width: 400
height: 400
}
然后,将新的 QML 文件添加到项目中,并将其命名为 Clock.qml。让我们首先声明一个圆形时钟盘面:
import QtQuick 2.0
Item {
id: clock
property color color: "lightgray"
Rectangle {
id: plate
anchors.centerIn: parent
width: Math.min(clock.width, clock.height)
height: width
radius: width/2
color: clock.color
border.color: Qt.darker(color)
border.width: 2
}
}
如果你现在运行程序,你会看到一个朴素的灰色圆圈,几乎不像是时钟盘面:

下一步是添加将盘面分成 12 个部分的标记。我们可以通过在 plate 对象内部放置以下声明来完成此操作:
Repeater {
model: 12
Item {
id: hourContainer
property int hour: index
height: plate.height/2
transformOrigin: Item.Bottom
rotation: index * 30
x: plate.width/2
y: 0
Rectangle {
width: 2
height: (hour % 3 == 0) ? plate.height*0.1
: plate.height*0.05
color: plate.border.color
antialiasing: true
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 4
}
}
}
现在运行程序应该给出以下结果,看起来更像是一个时钟盘面:

发生了什么?
我们刚刚创建的代码引入了一些新功能。让我们逐一介绍它们。
首先,我们使用了一个名为 Repeater 的新元素。它确实如其名所示——使用给定的模型重复其内部声明的项目。对于模型中的每个条目,它都会为名为 delegate 的属性分配的组件创建一个实例(该属性名意味着它包含一个实体,调用者将一些责任委托给该实体,例如描述一个调用者用作模板的组件)。在 Repeater 中声明的 Item 描述了委托,尽管我们无法明确看到它被分配给任何属性。这是因为 delegate 是 Repeater 类型的默认属性,这意味着任何未明确分配给任何属性的任何内容都将隐式地分配给类型的默认属性。
Item 类型还有一个默认属性,称为 data。它包含一个元素列表,该列表会自动拆分为两个“子列表”——一个是项的子项列表(这创建了 Qt Quick 中 Item 实例的层次结构)和另一个名为资源的列表,它包含所有不继承自 Item 的“子”元素。你可以直接访问这三个列表,这意味着调用 children[2] 将返回在项中声明的第三个 Item 元素,而 data[5] 将返回在 Item 中声明的第六个元素,无论该元素是否为视觉项(继承自 Item)。
模型可以是许多事物,但就我们而言,它只是一个表示代理应该重复多少次的数字。要重复的组件是一个包含矩形的透明项。该项有一个名为 hour 的属性,它与 index 绑定。后者是 Repeater 分配给代理组件每个实例的属性。它包含的值是实例在 Repeater 对象中的索引——由于我们有一个包含十二个元素的模型,index 将在 0 到 11 的范围内持有值。项可以使用 index 属性来定制 Repeater 创建的实例。在这种情况下,我们使用 index 为 rotation 属性提供值,并通过将索引乘以 30,我们得到从第一个实例的 0 开始,到最后一个实例的 330 结束的值。
rotation 属性引出了第二个最重要的主题——项的转换。每个项都可以以多种方式转换,包括旋转项和在二维空间中缩放,正如我们之前提到的。另一个名为 transformOrigin 的属性表示应用缩放和旋转的原点。默认情况下,它指向 Item.Center,这使得项围绕其中心进行缩放和旋转,但我们可以将其更改为其他八个值,例如 Item.TopLeft 用于顶左角或 Item.Right 用于项右侧边缘的中间。在我们的代码中,我们围绕每个项的底部边缘顺时针旋转。每个项使用 plate.width/2 表达式在盘子的中间水*定位,并且垂直于盘子的顶部,默认宽度为 0,高度为盘子高度的一半;因此,每个项是贯穿顶部到盘子中心的细长垂直线。然后,每个项围绕盘子的中心(每个项的底部边缘)旋转,比前一个项多 30 度,从而有效地将项均匀地放置在盘子上。
最后,每个项目都有一个灰色的 Rectangle 附着在其顶部边缘(偏移量为 4),并在透明父元素中水*居中。应用于项目的变换会影响项目的子元素,类似于我们在 Graphics View 中看到的;因此,矩形的实际旋转遵循其父元素的旋转。矩形的高度取决于 hour 的值,它映射到 Repeater 中项目的索引。在这里,你不能直接使用 index,因为它仅在委托的最顶层项中可见。这就是为什么我们创建了一个真正的属性 hour,它可以从整个委托项层次结构中引用。
注意
如果你想要对项目变换有更多的控制,那么我们很高兴地告诉你,除了旋转和缩放属性之外,每个项目还可以将 Rotation、Scale 和 Translate 等元素数组分配给名为 transform 的属性,这些元素按顺序逐个应用。这些类型具有对变换进行精细控制的属性。例如,使用 Rotation,你可以实现沿任意三个轴的旋转以及围绕自定义原点的旋转(而不是像使用 Item 的 rotation 属性那样限制在九个预定义的原点)。
实践时间 – 向时钟添加指针
下一步是将小时、分钟和秒指针添加到时钟中。让我们首先在名为 Needle.qml 的文件中创建一个新的组件 Needle(记住,组件名称和表示它们的文件名需要以大写字母开头):
import QtQuick 2.0
Rectangle {
id: root
property int value: 0
property int granularity: 60
property alias length: root.height
width: 2
height: parent.height/2
radius: width/2
antialiasing: true
anchors.bottom: parent.verticalCenter
anchors.horizontalCenter: parent.horizontalCenter
transformOrigin: Item.Bottom
rotation: 360/granularity * (value % granularity)
}
Needle 基本上是一个矩形,通过其底部边缘锚定到父元素的中心,这也是项目的旋转中心。它还具有 value 和 granularity 属性,驱动项目的旋转,其中 value 是指针显示的当前值,而 granularity 是它可以显示的不同值的数量。此外,我们启用了指针的抗锯齿,因为我们希望指针的尖端圆润。有了这样的定义,我们可以使用该组件在时钟盘对象内部声明三个指针:
Needle {
length: plate.height*0.3
color: "blue"
value: clock.hours; granularity: 12
}
Needle {
length: plate.height*0.4
color: "darkgreen"
value: clock.minutes; granularity: 60
}
Needle {
width: 1
length: plate.height*0.45
color: "red"
value: clock.seconds; granularity: 60
}
三个指针使用了时钟的 hours、minutes 和 seconds 属性,因此这些属性也需要声明:
property int hours: 0
property int minutes: 0
property int seconds: 0
通过在 main.qml 中为 Clock 的属性分配不同的值,你可以使时钟显示不同的时间:
import QtQuick 2.0
Clock {
id: clock
width: 400
hours: 7
minutes: 42
seconds: 17
}
你将得到如下所示的输出:

刚才发生了什么?
大多数 Needle 功能都在组件本身中声明,包括几何形状和变换。然后,无论何时我们想要使用该组件,我们都会声明一个 Needle 的实例,并可选择自定义 length 和 color 属性,以及设置其 value 和 granularity 以获得我们需要的确切功能。
实践时间 – 使时钟功能化
创建时钟的最终步骤是让它实际显示当前时间。在 JavaScript 中,我们可以使用Date对象查询当前时间:
var currentDate = new Date()
var hours = currentDate.getHours()
var minutes = currentDate.getMinutes()
var seconds = currentDate.getSeconds()
因此,首先想到的是使用前面的代码来显示时钟上的当前时间:
Item {
id: clock
property int hours: currentDate.getHours()
property int minutes: currentDate.getMinutes()
property int seconds: currentDate.getSeconds()
property var currentDate: new Date()
// ...
}
这确实会在你启动应用程序时显示当前时间,但时钟不会随着时间流逝而更新自己。这是因为new Date()返回一个表示特定时刻的对象(对象实例化的那一刻的日期和时间)。我们需要的相反,是让currentDate属性随着当前时间的改变而更新为新对象。为了获得这种效果,我们可以使用一个Timer元素,它是 C++中QTimer的等价物,并允许我们定期执行一些代码。让我们修改代码以使用定时器:
Item {
id: clock
property int hours: currentDate.getHours()
property int minutes: currentDate.getMinutes()
property int seconds: currentDate.getSeconds()
readonly property var currentDate: new Date()
property alias running: timer.running
Timer {
id: timer
repeat: true
interval: 500
running: true
onTriggered: clock.currentDate = new Date()
}
}
刚才发生了什么?
根据间隔属性,我们可以确定定时器每 500 毫秒发出一个triggered信号,导致currentDate使用一个代表当前时间的新的Date对象更新。时钟还得到了一个running属性(指向定时器中的等效属性),可以控制是否启用更新。定时器被设置为repeat;否则,它只会触发一次。
动态对象
简要总结到目前为止你所学的,我们可以这样说:你知道如何通过声明实例来创建对象层次结构,你也知道如何在单独的文件中编程新类型,使定义作为组件在其他 QML 文件中实例化可用。你甚至可以使用Repeater元素根据一个共同的模板声明一系列对象。
详细使用组件
我们承诺要给你更多关于组件的信息,现在是时候这样做。你已经知道如何在单独的文件中创建组件的基础知识。每个以大写字母开头的 QML 文件都被视为组件定义。这个定义可以直接由位于组件定义同一目录中的其他 QML 文件使用。如果你需要从位于其他位置的文件中访问组件定义,你将不得不首先在你想使用它的文件中导入包含该组件的模块。模块的定义非常简单——它只是包含 QML 文件的目录的相对路径。路径使用点作为分隔符。这意味着如果你有一个名为Baz.qml的文件位于名为Base/Foo/Bar的目录中,并且你想要在Base/Foo/Ham.qml文件中使用Baz组件,你必须在Ham.qml中放置以下导入语句:
import "Bar"
如果你想在Base/Spam.qml文件中使用相同的组件,你必须将导入语句替换为:
import "Foo.Bar"
导入一个模块会使所有其组件可用。然后,你可以声明从某个模块导入的类型对象。
按需创建对象
在 QML 文件中直接预声明对象的问题在于,你需要事先知道你需要多少个对象。更常见的情况是,你将想要动态地向你的场景添加和删除对象,例如,在一个外星人入侵游戏中,随着玩家的进步,新的外星飞碟将进入游戏屏幕,其他飞碟将被击落并摧毁。此外,玩家的飞船将“生产”新的子弹,在飞船前方划过,最终耗尽燃料或以其他方式从游戏场景中消失。通过在解决这个问题上投入大量精力,你将能够使用 Repeater 来获得这种效果,但手头还有更好的工具。
QML 为我们提供了另一种元素类型,称为 Component,这是通过在 QML 中声明其内容来向引擎介绍新元素类型的另一种方法。基本上有两种方法来做这件事。
第一种方法是,在 QML 文件中声明一个 Component 元素实例,并将新类型的定义直接内联在元素内部:
Component {
id: circleComponent
Item {
property int diameter: 20
property alias color: rect.color
property alias border: rect.border
implicitWidth: diameter
implicitHeight: diameter
Rectangle {
id: rect
width: radius; height: radius; radius: diameter/2
anchors.centerIn: parent
}
}
}
这样的代码声明了一个名为 circleComponent 的组件,它定义了一个圆并公开了其 diameter、color 和 border 属性。
另一种方法是,从现有的 QML 文件中加载组件定义。QML 提供了一个特殊的全局对象 Qt,它提供了一套有趣的方法。其中一种方法允许调用者通过传递现有 QML 文档的 URL 来创建组件:
var circleComponent = Qt.createComponent("circle.qml")
一个有趣的注意点是 createComponent 不仅接受本地文件路径,还可以接受远程 URL,如果它理解网络方案(例如,http),它将自动下载文档。在这种情况下,你必须记住这需要时间,因此组件可能在调用 createComponent 后不会立即就绪。由于当前加载状态保存在 status 属性中,你可以连接到 statusChanged 信号以在发生这种情况时得到通知。一个典型的代码路径看起来类似于以下:
var circleComponent = Qt.createComponent("http://example.com/circle.qml")
if(circleComponent.status === Component.Ready) {
// use the component
} else {
circleComponent.statusChanged.connect(function() {
if(circleComponent.status === Component.Ready) {
// use the component
}
})
}
如果组件定义不正确或无法检索文档,对象的状态将变为 Error。在这种情况下,你可以使用 errorString() 方法来查看实际的问题是什么:
if(circleComponent.status === Component.Error) {
console.warn(circleComponent.errorString())
}
一旦你确认组件已准备就绪,你就可以开始从它创建对象了。为此,组件提供了一个名为 createObject 的方法。在其最简单的形式中,它接受一个将成为新生实例父对象的对象(类似于接受父小部件指针的控件构造函数)并返回新对象本身,以便你可以将其分配给某个变量:
var circle = circleComponent.createObject(someItem)
然后,你可以开始设置对象的属性:
circle.diameter = 20
circle.color = 'red'
更复杂的调用允许我们在单个调用中执行这两个操作(创建对象并设置其属性),通过将第二个参数传递给 createObject:
var circle = circleComponent.createObject(someItem, {diameter: 20, color: 'red'})
第二个参数是一个对象(在此使用 JSON 语法创建),其属性将被应用到正在创建的对象上。这种语法的优点是,所有属性值都作为一个原子操作应用到对象上(就像在 QML 文档中声明项目时那样),而不是一系列单独的操作,每个操作都为单个属性设置值,这可能会在对象中引发一系列更改处理程序的调用。
创建后,该对象成为场景的一等公民,以与在 QML 文档中直接声明的项目相同的方式行事。唯一的区别是,动态创建的对象也可以通过调用其 destroy() 方法来动态销毁,这在 C++ 对象中相当于调用 delete。当谈到销毁动态项目时,我们必须指出,当你将 createObject 的结果分配给一个变量(如我们示例中的 circle)并且该变量超出作用域时,项目将不会被释放和垃圾回收,因为其父对象仍然持有对该对象的引用,从而阻止其被回收。
我们之前没有明确提到这一点,但我们已经在本章介绍 Repeater 元素时使用过内联组件定义。在重复器内定义的重复项实际上不是一个真实的项目,而是一个组件定义,该定义会根据重复器的需要被实例化多次。
延迟项目创建
另一个常见的场景是你知道你需要多少个元素,但问题是你不能提前确定它们的类型。在应用程序的生命周期中的某个时刻,你将了解到这些信息,并将能够实例化一个对象。在你获得有关给定组件的知识之前,你需要某种类型的项目占位符,你将在其中放置真实的项目。当然,你可以编写一些代码来使用组件的 createObject() 功能,但这很麻烦。幸运的是,Qt Quick 提供了一个更好的解决方案,即 Loader 项目。这种项目类型正是我们描述的那样——一个临时占位符,用于按需从现有组件加载真实项目。你可以将 Loader 放在另一个项目位置,当你需要创建此项目时,一种方法是将组件的 URL 设置为 source 属性:
Loader {
id: ldr
}
ldr.source = "MightySword.qml"You could also directly attach a real component to sourceComponent of a Loader:
Component {
id: swordComponent
// ...
}
Loader {
id: ldr
sourceComponent: shouldBeLoaded ? swordComponent : undefined
}
紧接着,魔法开始发挥作用,组件的一个实例出现在加载器中。如果 Loader 对象的大小被明确设置(例如,通过锚点或设置宽度和高度),则项目将被调整到加载器的大小。如果没有设置显式大小,那么一旦组件被实例化,Loader 将会调整到加载元素的大小:
Loader {
anchors {
left: parent.left; leftMargin: 0.2*parent.width
right: parent.right;
verticalCenter: parent.verticalCenter
}
height: 250
source: "Armor.qml"
}
在前一种情况下,加载器的大小被明确设置,因此当其项目被创建时,它将尊重此处声明的锚点和大小。
访问项目组件功能
Qt Quick 中的每个项目都是某种组件的实例化。每个对象都有一个附加的Component属性,它提供了两个信号,用于通知对象生命周期的关键时刻。第一个信号——completed()——在对象实例化后被触发。如果您为该信号提供处理程序,您可以在对象完全实例化后执行一些后期初始化。这个信号有很多用例,首先是向控制台记录消息:
Rectangle {
Component.onCompleted: console.log("Rectangle created")
}
该信号的更高级用法是通过对延迟昂贵的操作直到组件完全构建来优化性能:
Item {
id: root
QtObject {
id: priv
property bool complete: false
function layoutItems() {
if(!complete) return
// ...
}
}
onChildrenChanged: priv.layoutItems()
Component.onCompleted: { priv.complete = true; priv.layoutItems(); }
}
当项目被创建时,它们会被添加到其父级的children属性中。因此,随着项目的创建和销毁,该属性的值会发生变化,从而触发childrenChanged信号。当这种情况发生时,我们希望根据某种算法重新定位项目的子项。为此,我们有一个内部的QtObject实例(代表QObject),称为priv,在其中我们可以声明不会在组件定义外部可见的函数和属性。在那里,我们有一个layoutItems()函数,每当子项列表更新时都会被调用。如果项目是动态创建或销毁的(例如,使用Component.createObject()函数),这是可以的。然而,当根对象正在构建时,它可能直接在文档中声明了多个子项。在声明实例化时反复重新定位它们是没有意义的。只有当对象列表完整时,定位项目才有意义。因此,我们在私有对象中声明了一个布尔属性,表示根项是否已完全构建。在它完成之前,每次调用layoutItems()时,它将立即退出而不进行任何计算。当Component.onCompleted被调用时,我们设置标志并调用layoutItems(),该函数计算文档中声明的所有静态子项的几何形状。
附加的Component属性中的另一个信号是destruction。当组件仍然完全构建时,在对象销毁过程开始后立即触发。通过处理该信号,您可以执行诸如在持久存储中保存对象状态或以其他方式清理对象等操作。
强制性绘制
声明图形项既简单又容易,但作为程序员,我们更习惯于编写命令式代码,有些事情用算法表达比用达到最终结果的描述更容易。使用 QML 以紧凑的方式编码原始形状的定义,如矩形,很容易——我们只需要标记矩形的原点、宽度、高度,以及可选的颜色。在给定的绝对坐标中声明由许多控制点组成的复杂形状的定义,可能在其某些部分有轮廓,可能还伴随一些图像,在 QML 这样的语言中仍然是可能的;然而,这将导致一个更加冗长且可读性更差的定义。这是一个使用命令式方法可能更有效的情况。HTML(作为一种声明性语言)已经暴露了一个用于绘制不同原语(称为 Canvas)的经过验证的命令式接口,该接口已在许多 Web 应用程序中使用。幸运的是,Qt Quick 通过允许我们实例化 Canvas 元素为我们提供了类似 Web 的 Canvas 接口的实现。这些项目可以用来绘制直线和曲线、简单和复杂的形状、图表和图形图像。它还可以添加文本、颜色、阴影、渐变和图案。它甚至可以执行低级像素操作。最后,输出可以保存为图像文件或序列化为可由 Image 项目使用的 URL。关于使用 HTML 画布的许多教程和论文都可用,并且它们通常可以很容易地应用于 Qt Quick 画布(参考手册甚至包括在将 HTML 画布应用程序移植到 Qt Quick 画布时需要注意的方面列表),因此在这里我们只给出 Qt Quick 中命令式绘图的非常基础的内容。
考虑一个游戏,玩家的健康状态由他的心脏状况来衡量——心跳越慢,玩家就越健康。我们将使用这种可视化作为我们练习使用 Canvas 元素绘画的练习。
行动时间 – 准备画布以进行心跳可视化
让我们从简单的事情开始,创建一个基于最新版 Qt Quick 的快速 UI 项目。将 Creator 为我们创建的 QML 文件重命名为 HeartBeat.qml。打开与项目一起创建的 qmlproject 文件,并将 Project 对象的 mainFile 属性更改为 HeartBeat.qml。然后,你可以关闭 qmlproject 文档,返回到 HeartBeat.qml。在那里,你可以用以下内容替换原始内容:
import QtQuick 2.2
Canvas {
id: canvas
implicitWidth: 600
implicitHeight: 300
onPaint: {
var ctx = canvas.getContext("2d")
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
}
当你运行项目时,你会看到一个空白窗口。
刚才发生了什么?
在前面的代码中,我们创建了一个基本的模板代码,用于使用 canvas。首先,我们将现有的文件重命名为我们希望组件使用的名称,然后我们通知 Creator,当使用 qmlscene 运行项目时,将执行此文档。
然后,我们创建了一个具有隐式宽度和高度的 Canvas 实例。在那里,我们创建了一个处理 paint 信号的处理器,该信号在画布需要重绘时发出。放置在那里的代码检索画布的上下文,这可以被视为我们在 Qt 小部件上绘图时使用的 QPainter 实例的等效物。我们通知画布我们想要其 2D 上下文,这为我们提供了在二维中绘制的方式。2D 上下文是当前 Canvas 元素唯一存在的上下文,但你仍然必须明确地识别它——类似于 HTML。有了上下文准备就绪,我们告诉它清除画布的整个区域。这与小部件世界不同,在 paintEvent 处理器被调用时,小部件已经为我们清除,并且必须从头开始重绘一切。在 Canvas 中,情况不同;默认情况下保留以前的内容,以便你可以覆盖它。由于我们希望从一张干净的画布开始,我们在上下文中调用 clearRect()。
动手实践 – 绘制心电图
现在我们将扩展我们的组件并实现其主要功能——绘制一个类似心电图的图形。
将以下属性声明添加到 canvas 中:
property int lineWidth: 2
property var points: []
property real arg: -Math.PI
下面,添加一个定时器的声明,该定时器将驱动整个组件:
Timer {
interval: 10
repeat: true
running: true
onTriggered: {
arg += Math.PI/180
while(arg >= Math.PI) arg -= 2*Math.PI
}
}
然后,定义当 arg 的值被修改时的处理程序:
onArgChanged: {
points.push(func(arg))
points = points.slice(-canvas.width)
canvas.requestPaint()
}
然后,实现 func:
function func(argument) {
var a=(2*Math.PI/10); var b=4*Math.PI/5
return Math.sin(20*argument) * (
Math.exp(-Math.pow(argument/a, 2)) +
Math.exp(-Math.pow((argument-b)/a,2)) +
Math.exp(-Math.pow((argument+b)/a,2))
)
}
最后,修改 onPaint:
onPaint: {
var ctx = canvas.getContext("2d")
ctx.reset()
ctx.clearRect(0, 0, canvas.width, canvas.height)
var pointsToDraw = points.slice(-canvas.width)
ctx.translate(0, canvas.height/2)
ctx.beginPath()
ctx.moveTo(0, -pointsToDraw[0]*canvas.height/2)
for(var i=1; i<pointsToDraw.length; i++)
ctx.lineTo(i, -pointsToDraw[i]*canvas.height/2)
ctx.lineWidth = canvas.lineWidth
ctx.stroke()
}
然后,你可以运行代码,并看到类似心电图的图形出现在画布上:

发生了什么?
我们向元素添加了两种类型的属性。通过引入 lineWidth,我们可以操纵可视化心电图的线条宽度。points 和 arg 变量是两个辅助变量,它们存储已计算的点数数组和最后一次评估的函数参数。我们将要使用的函数是一个从 -Π 到 +Π 的周期函数;因此,我们将 arg 初始化为 -Math.PI,并在 points 中存储一个空数组。
然后,我们添加了一个定时器,以固定的时间间隔滴答作响,将 arg 增加 1°,直到它达到 +Π,在这种情况下,它将重置到初始值。
在我们实现的下一个处理程序中,拦截对arg的更改。在那里,我们将一个新的项目推送到点的数组中。这个值是通过函数func计算的,该函数相当复杂,但可以简单地说它返回一个在-1到+1范围内的值。然后使用Array.slice()对点的数组进行压缩,这样数组中最多只保留canvas.width个最后一个元素。这样做是为了我们可以为画布宽度的每个像素绘制一个点,并且不需要存储比所需更多的数据。在函数的末尾,我们调用requestPaint(),它等同于QWidget::update(),并安排一个调用paint。
这反过来又调用了我们的onPaint。在那里,在检索上下文后,我们将画布重置为其初始状态,然后使用slice()计算一个要再次绘制的点的数组。然后,我们通过在垂直轴上*移和缩放画布来准备画布,以便将原点移动到画布高度的一半(这就是为什么在程序开始时调用reset()的原因——为了撤销这种转换)。之后,调用beginPath()来通知上下文我们开始构建一个新的路径。然后,通过逐段添加线条来构建路径。每个值都乘以canvas.height/2,以便将点数组的值缩放到项目的大小。由于画布的垂直轴增长到底部,我们希望正值在原点线上方,因此该值被取反。之后,我们设置笔的宽度并通过调用stroke()来绘制路径。
行动时间 – 使图表更加多彩
该图表完成了它的任务,但它看起来有点单调。通过在画布对象中定义三个新的颜色属性——color、topColor和bottomColor——并将它们的默认值分别设置为black、red和blue,来给它添加一些光泽。
由于points和arg实际上不应该成为任何人都可以随意更改的公共属性,我们现在就来修正这个问题。将QtObject画布的子元素声明为priv,并设置其 ID 为priv。将points和arg的声明移入该对象内部。同时,将onArgChanged处理程序也移入那里:
QtObject {
id: priv
property var points: []
property real arg: -Math.PI
onArgChanged: {
points.push(func(arg))
points = points.slice(-canvas.width)
canvas.requestPaint()
}
}
然后,在整个代码中搜索,并将所有新声明对象外部出现的arg和points的实例前缀为priv,这样每次调用都会指向priv对象。
然后,让我们利用我们通过扩展onPaint定义的三个颜色:
onPaint: {
...
// fill:
ctx.beginPath()
ctx.moveTo(0, 0)
var i
for(i=0; i<pointsToDraw.length; i++)
ctx.lineTo(i, -pointsToDraw[i]*canvas.height/2)
ctx.lineTo(i, 0)
var gradient = ctx.createLinearGradient(0, -canvas.height/2, 0, canvas.height/2)
gradient.addColorStop(0.1, canvas.topColor)
gradient.addColorStop(0.5, Qt.rgba(1, 1, 1, 0))
gradient.addColorStop(0.9, canvas.bottomColor)
ctx.fillStyle = gradient
ctx.fill()
// stroke:
ctx.beginPath()
ctx.moveTo(0, -pointsToDraw[0]*canvas.height/2)
for(var i=1; i<pointsToDraw.length; i++)
ctx.lineTo(i, -pointsToDraw[i]*canvas.height/2)
ctx.lineWidth = canvas.lineWidth
ctx.strokeStyle = canvas.color
ctx.stroke()
}
运行前面的代码片段后,你会得到以下输出:

发生了什么?
通过将两个属性移动到priv对象内部,我们实际上已经将它们作为对象的子对象(例如priv是canvas的子对象)隐藏在外部世界,因为子对象在外部定义对象的 QML 文档中是不可访问的。这确保了points和arg都不能从HeartBeat.qml文档外部修改。
我们对onPaint进行的修改是创建另一条路径,并使用该路径通过渐变填充一个区域。这条路径与原始路径非常相似,但它包含两个额外的点,即第一个和最后一个绘制到水*轴上的点。这确保了渐变能够正确填充区域。请注意,画布使用命令式代码进行绘制;因此,填充和描边的绘制顺序很重要——填充必须先绘制,以免遮挡描边。
Qt Quick 和 C++
到目前为止,我们一直在使用标准的 Qt Quick 元素或通过在 QML 中组合现有元素类型来创建新的元素。但如果你使用 Qt 提供的技术将 QML 和 C++接口,你还能做更多的事情。本质上,QML 运行时在设计上与 Qt Script 没有太大区别,你可以在本书的前一章中了解到 Qt Script。在接下来的段落中,你将学习如何从另一个环境中访问存在于一个环境中的对象,以及如何通过新的模块和元素扩展 QML。
到目前为止,我们在本章中进行的所有示例项目都是用 QML 编写的,因此我们选择的项目类型是 Qt Quick UI,这让我们能够通过qmlscene工具解释它来快速查看我们建模的 Qt Quick 场景。现在,我们希望将 C++添加到等式中,因为 C++是一种编译型语言,我们需要进行一些适当的编译才能使一切正常工作。因此,我们将使用Qt Quick 应用程序模板。
从 C++创建 QML 对象
当你在 Qt Creator 中启动此类新项目时,在你回答关于你希望使用的组件集的问题(为常规 Qt Quick 应用程序选择任何 Qt Quick 2.x选项)之后,你将收到一些样板代码——一个包含 C++部分的main.cpp文件和包含场景定义的main.qml。让我们先看看后者:
import QtQuick 2.3
import QtQuick.Window 2.2
Window {
visible: true
width: 360
height: 360
MouseArea {
anchors.fill: parent
onClicked: {
Qt.quit();
}
}
Text {
text: qsTr("Hello World")
anchors.centerIn: parent
}
}
代码与之前略有不同;只需看看高亮部分。现在我们不再使用Item根对象,而是有一个窗口以及一个QtQuick.Window模块的import语句。要理解为什么会这样,我们需要了解调用此 QML 文档的 C++代码:
#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();
}
源代码相当简单。首先,我们实例化一个应用程序对象,就像任何其他类型的应用程序一样。由于我们不使用 Qt 小部件,因此使用 QGuiApplication 而不是 QApplication。主函数的最后一行也很明显——启动应用程序的事件循环。在这两行之间,我们可以看到一个 QQmlApplicationEngine 的实例被创建,并提供了我们 QML 文档的 URL。
QML 由 QQmlEngine 中实现的引擎驱动,这与 QScriptEngine 有一定的相似性。QQmlApplicationEngine 是 QQmlEngine 的一个子类,它提供了一种简单的方法来从单个 QML 文件加载应用程序。这个类不会创建一个根窗口来显示我们的 Qt Quick 场景(QML 应用程序不一定是 Qt Quick 应用程序;它们根本不需要处理用户界面),因此如果应用程序想要在其中显示 Qt Quick 场景,创建窗口的责任就由应用程序承担。
加载基于 Qt Quick 的用户界面的另一种选择是使用 QQuickView 或其不太方便的超类 QQuickWindow,它们继承自 QWindow 并能够渲染 Qt Quick 场景。
你可以将 main.cpp 的内容替换为以下代码:
#include <QGuiApplication>
#include <QQuickView>
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
QQuickView view;
view.setSource(QUrl::fromLocalFile(QStringLiteral("main.qml")));
view.show();
return app.exec();
}
由于 QQuickView 继承自 QWindow,我们可以看到将创建一个窗口来包含在 main.qml 中定义的 Qt Quick 场景。在这种情况下,你可以用类似我们在早期示例中看到的 Item 替换窗口声明。
提示
如果你想将 Qt Quick 场景与基于 Qt 小部件的用户界面结合使用,可以使用 QtQuickWidgets 模块中提供的 QQuickWidget(将 QT += quickwidgets 添加到项目文件以激活该模块),它与 QQuickView 类似,并且具有类似的 API,但它不是将场景渲染到单独的窗口中,而是渲染到你可以将其放置在其他小部件旁边的窗口中。
创建 QML 对象的最后一种方式是使用 QQmlComponent。与之前的方法不同,那些方法在创建 QML 对象的对象中嵌入了一个 QQmlEngine 实例,我们必须使用一个单独的引擎和组件方法。
QQmlComponent 是围绕 QML 组件定义的一个包装器,类似于 QML 侧的 Component 元素。它可以使用给定的 QQmlEngine 实例通过 create() 方法创建该组件的实例:
QQmlEngine *engine = new QQmlEngine;
QQmlComponent component(engine, QUrl::fromLocalFile(QStringLiteral("main.qml")));
QObject *object = component.create();
创建的对象是 QObject,因为它是 QML 中所有对象的基类。如果该对象代表一个 Qt Quick 用户界面,你可以将其转换为 QQuickItem 并使用其方法来访问项的功能:
QQuickItem *item = qobject_cast<QQuickItem*>(object);
Q_CHECK_PTR(item); // assert to check if qobject_cast returned a valid pointer
item->setOpacity(0.5);
QQmlComponent 是实例化 QML 对象最“经典”的方式。你甚至可以使用它来在现有视图中创建额外的对象:
QQuickView *view;
// …
QQmlComponent component(view->engine(), QUrl::fromLocalFile("foobar.qml"));
component.create();
使用 QQmlComponent 的一个变体是使用 QQmlIncubator 对象异步地在 QML 引擎中创建一个对象。在创建复杂对象时,它们实例化需要时间,有时,为了避免等待操作完成而长时间阻塞控制流,我们希望使用孵化器对象来安排实例化并继续程序的流程。我们可以查询孵化器的状态,当对象构建完成后,我们将能够访问它。以下代码演示了如何使用孵化器实例化一个对象并在等待操作完成时处理挂起的事件:
QQmlComponent component(engine, QUrl::fromLocalFile("ComplexObject.qml"));
QQmlIncubator incubator;
component.create(incubator);
while(!incubator.isError() && !incubator.isReady())
QCoreApplication::processEvents();
QObject *object = incubator.isReady() ? incubator.object() : 0;
将 QML 对象拉到 C++
在我们的术语中,将 QML 对象拉到 C++ 意味着通过使用 C++ 代码,我们希望访问存在于 QML 引擎中的对象(例如,在某个 QML 文件中声明的那些)。在我们这样做之前,重要的是强调,通常尝试从 QML 引擎中拉取对象是不良的做法。这有几个原因,但我们只想强调其中两个。
首先,如果我们假设最常见的情况,即我们的应用程序的 QML 部分处理用 C++ 编写的逻辑的 Qt Quick 用户界面,那么从 C++ 访问 QtQuick 对象会打破逻辑和表示层之间的分离,这是 GUI 编程中的一个主要原则。第二个原因是,QML 文档(尤其是 Qt Quick 的文档)通常是由不同于实现应用程序逻辑的人(设计师)制作的。用户界面容易受到动态变化的影响,重排甚至彻底翻新。对 QML 文档的重度修改,如添加或删除设计中的项,随后需要调整应用程序逻辑以应对这些变化。这反过来又需要整个应用程序的重新编译,这是繁琐的。此外,如果我们允许一个应用程序有多个用户界面(皮肤),可能会发生这样的情况,因为它们如此不同,以至于不可能决定一个单一的具有硬编码名称的通用实体集合,可以从 C++ 中检索并操作。即使你设法做到了,这样的应用程序也可能会因为设计师没有严格遵守规则而轻易崩溃。
话虽如此,我们必须承认,在某些情况下,从 QML 拉取对象到 C++ 是有意义的,这就是我们决定让你熟悉这样做的方法的原因。这种方法被期望的一种情况是,当 QML 作为一种快速定义具有不同对象属性并通过更多或更少的复杂表达式链接的对象层次结构的方式时,允许它们对层次结构中发生的变化做出响应。
例如,如果你创建一个 Qt Quick UI 项目,在生成的文件中,你会找到一个包含项目定义的 qmlproject 文件,这个定义是用 QML 本身表达的,例如这个:
import QmlProject 1.1
Project {
mainFile: "main.qml"
importPaths: [ "plugins" ]
QmlFiles {
files: [ "Clock.qml", "Needle.qml" ]
}
JavaScriptFiles {
directory: "."
}
ImageFiles {
directory: "."
}
}
它包含项目内容,指定为一系列文件选择器和附加属性,如主项目文件或查找 QML 模块的目录列表。在 QML 中指定此类项目描述非常简单,在这样做并从 C++ 中获取 Project 实例的句柄后,可以直接从对象及其属性中读取所需的信息。
Project 被认为是该文档的根对象。根据文档实际加载到引擎中的方式,有五种方法可以获取对根对象的访问权限:
-
如果使用
QQmlApplicationEngine,则使用QQmlApplicationEngine::rootObjects() -
如果使用
QQuickView,则使用QQuickView::rootObject() -
如果使用
QQuickWidget,则使用QQuickWidget::rootObject() -
如果使用
QQmlComponent,则使用QQmlComponent::create() -
如果使用带有
QQmlIncubator的QQmlComponent,则使用QQmlIncubator::object()
如前所述,在检索到对象后,可以使用 qobject_cast 将其向下转换为适当的类型。或者,您可以通过通用的 QObject 接口开始使用该对象——使用 property() 和 setProperty() 访问属性,通过 QMetaObject::invokeMethod() 运行函数,并像往常一样连接到信号。
提供的使用场景是在您希望从 QML 世界中拉取视图根对象或手动创建的对象到 C++ 中的有效且公*的情况。现在,我们将向您展示如何对对象树任意深度的对象执行相同的操作。
QML 文档定义对象树。我们可以要求 Qt 遍历一个 QObject 树,并返回匹配指定标准的单个对象或对象列表。相同的策略也可以用于 QML 对象树。在搜索时可以使用两个标准。首先,我们可以搜索继承自给定类的对象。然后,我们可以搜索匹配 QObject 中定义的 objectName 属性给定值的对象。要搜索树中的对象,可以使用 findChild 模板方法。
考虑一个定义了多个项目的 Qt Quick 文档:
import QtQuick 2.0
Item {
width: 400; height: 400
Rectangle {
id: rect
objectName: "redRectangle"
color: "red"
anchors.centerIn: parent
width: height; height: parent.height*2/3
}
Rectangle {
id: circle
objectName: "blueCircle"
color: "blue"
anchors.centerIn: parent
radius: width/2; width: height; height: parent.height*1/3
}
}
在使用前面描述的任何一种方法获取对根对象的访问权限后,我们可以使用 objectName 值查询对象树中的任何彩色形状项目:
QObject *root = view->rootObject();
QObject *rect = root->findChild<QObject*>("redRectangle");
QObject *circle = root->findChild<QObject*>("blueCircle");
if(circle && rect)
circle->setProperty("width", rect->property("width").toInt());
findChild() 方法要求我们传递一个类指针作为模板参数。在不了解实际实现给定类型的类的情况下,最安全的方法是简单地传递 QObject*,因为我们知道所有 QML 对象都继承自它。更重要的是传递给函数参数的值——它是我们想要返回的对象的名称。注意,它不是对象的 id,而是 objectName 属性的值。当结果被分配给变量时,我们验证是否成功找到了项目,如果是这样,就使用通用的 QObject API 将圆的宽度设置为矩形的宽度。
让我们再次强调:如果你必须使用这种方法,请将其限制在最小范围内。并且始终验证返回的项目是否存在(不是空指针);QML 文档可能在程序后续编译之间发生变化,文档中存在的一个版本中的项目和它们的名称可能在下一个版本中不再存在。
将 C++ 对象推送到 QML
一个更好的方法是反向跨越边界——通过从 C++ 导出对象到 QML。这允许 C++ 开发者决定脚本可用的 API。选择使用哪个 API 的决定留给 QML 开发者。应用程序逻辑和用户界面之间的分离得到保持。
在上一章中,你学习了如何使用 Qt Script。我们告诉你如何通过使用脚本引擎的全局对象将现有的 QObject 实例暴露给脚本。我们还讨论了执行上下文,它们在调用函数时提供对象可见性的层级。如前所述,QML 与该框架有许多相似之处,在 QML 中,使用非常类似的方法将对象暴露给引擎。
QML 引擎也使用上下文为语言提供数据作用域。你可以在上下文中设置属性,使某些名称解析为给定的对象:
QQmlContext *context = new QQmlContext(engine);
QObject *object = new MyObject(...);
context->setContextProperty("foo", object);
从这个时刻起,object 在 context 中以 foo 的名称可见。
上下文可以形成层次结构。在层次结构的顶部是引擎的根上下文。上下文属性是从下往上解析的,这意味着在子上下文中重新定义一个名称会覆盖父上下文中定义的名称。让我们看一个例子:
QQmlContext *parentContext = new QQmlContext(engine);
QQmlContext *childContext1 = new QQmlContext(parentContext);
QQmlContext *childContext2 = new QQmlContext(parentContext);
QQmlContext *childContext3 = new QQmlContext(parentContext);
QObject *objectA = new A, *objectB = new B, *object C = new C;
parentContext->setContextProperty("foo", objectA);
childContext1->setContextProperty("foo", objectB);
childContext2->setContextProperty("foo", objectC);
我们创建了类 A、B 和 C 的实例,并将它们分配给不同上下文的 foo 属性,形成五个上下文的层次结构。为什么是五个?当将 QQmlEngine 传递给 QQmlContext 构造函数时,创建的上下文成为引擎根上下文的子上下文。因此,我们有四个我们自己创建的上下文和一个始终存在于引擎中的附加上下文:

现在,如果我们从 childContext1 内部调用 foo,我们将访问对象 B,当我们从 childContext2 调用 foo 时,我们将访问 C。如果我们从 childContext3 调用它,那么,由于 foo 在那里没有定义,调用将传播到 parentContext,因此将访问 A。在 rootContext 中,上下文 foo 完全不可用。
在大多数情况下,我们不会自己创建上下文,因此最常见的情况是我们将只控制根上下文,因为它始终存在且易于访问。因此,这个上下文通常用于注册 C++ 对象。由于根引擎上下文是所有其他上下文的祖先,在那里注册的对象将可以从任何 QML 文档中看到。
那么,我们使用 QML 导出的对象能做什么呢?对象本身可以通过使用 setContextProperty() 给定的标识符来访问。标识符可以被视为 QML 文档中对象上声明的 ID 伪属性。可以从 QML 访问的功能取决于导出对象的类型。
您可以导出两种类型的对象。首先,您可以导出一个 QVariant 值,然后将其转换为等效的 QML 实体。以下表格列出了最常用的基本类型:
| Qt 类型 | QML 基本类型 |
|---|---|
bool |
bool |
unsigned int, int |
int |
double |
double |
float, qreal |
real |
QString |
string |
QUrl |
url |
QColor |
color |
QFont |
font |
QDate |
date |
QPoint, QPointF |
point |
QSize, QSizeF |
size |
QRect, QRectF |
rect |
这允许我们导出广泛的对象:
int temperature = 17;
double humidity = 0.648;
QDate today = QDate::currentDate();
engine->rootContext()->setContextProperty("temperature", temperature);
engine->rootContext()->setContextProperty("humidity", humidity);
engine->rootContext()->setContextProperty("today", Qt.formatDate(today, ""));
并且可以轻松地在 QtQuick 中使用:
import QtQuick 2.0
Rectangle {
id: root
width: 400; height: width; radius: width/10
color: "navy"
border { width: 2; color: Qt.darker(root.color) }
Grid {
id: grid
anchors.centerIn: parent
columns: 2; spacing: 5
Text { color: "white"; font.pixelSize: 20; text: "Temperature:" }
Text { color: "white"; font.pixelSize: 20; text: temperature+"°C"}
Text { color: "white"; font.pixelSize: 20; text: "Humidity:" }
Text { color: "white"; font.pixelSize: 20; text: humidity*100+"%"}
}
Text {
anchors {
horizontalCenter: grid.horizontalCenter;
bottom: grid.top; bottomMargin: 5
}
font.pixelSize: 24; color: "white"
text: "Weather for "+Qt.formatDate(today)
}
}
这将给我们以下输出:

除了基本类型外,QML 引擎还提供了在特殊 QVariant 情况和 JavaScript 类型之间的自动类型转换 – QVariantList 转换为 JavaScript 数组,QVariantMap 转换为 JavaScript 对象。这允许我们采取更加灵活的方法。我们可以通过利用 QVariantMap 转换来将所有天气信息组合在一个单一的 JavaScript 对象中:
QVariantMap weather;
weather["temperature"] = 17;
weather["humidity"] = 0.648;
weather["today"] = QDate::currentDate();
engine->rootContext()->setContextProperty("weather", weather);
因此,我们在 QML 端获得了更好的封装:
Grid {
// ...
Text { color: "white"; font.pixelSize: 20; text: "Temperature:" }
Text { color: "white"; font.pixelSize: 20; text: weather.temperature+"°C" }
Text { color: "white"; font.pixelSize: 20; text: "Humidity:" }
Text { color: "white"; font.pixelSize: 20; text: weather.humidity*100+"%"}
}
Text {
// ...
text: "Weather for "+Qt.formatDate(weather.today)
}
在一个天气条件永远不会改变的世界上,这一切都很好,很顺利。然而,在现实生活中,人们需要一种处理数据变化情况的方法。当然,我们可以在任何值发生变化时重新导出地图,但这会很繁琐。
幸运的是,可以导出到 QML 的第二种类型的对象也帮了我们一个大忙。除了 QVariant 之外,引擎还可以接受 QObject 实例作为上下文属性值。当将此类实例导出到 QML 时,所有对象属性都会公开,并且所有槽都成为声明性环境中的可调用函数。所有对象信号都提供了处理程序。
行动时间 – 自更新汽车仪表盘
在下一个练习中,我们将实现一个赛车游戏可用的汽车仪表盘,并显示当前速度和每分钟发动机转速等参数。最终结果将类似于以下图片:

我们将从 C++ 部分开始。设置一个新的 Qt Quick 应用程序。为 Qt Quick 组件集选择最新的 Qt Quick 版本。这将为您生成一个主函数,该函数实例化 QGuiApplication 和 QQmlApplicationEngine 并将它们设置为加载 QML 文档。
使用 文件 菜单创建 新文件或项目 并创建一个新的 C++ 类。将其命名为 CarInfo 并选择 QWidget 作为其基类。你可能会问为什么不选择 QObject?这是因为我们的类也将是一个小部件,它将被用来修改不同参数的值,以便我们可以观察它们如何影响 Qt Quick 场景的显示。在类头文件中,声明以下属性:
Q_PROPERTY(int rpm READ rpm NOTIFY rpmChanged)
Q_PROPERTY(int gear READ gear NOTIFY gearChanged)
Q_PROPERTY(int speed READ speed NOTIFY speedChanged)
Q_PROPERTY(QDate today READ today NOTIFY todayChanged)
Q_PROPERTY(double distance READ distance NOTIFY distanceChanged)
属性是只读的,而 NOTIFY 子句定义了当相应属性值更改时发出的信号。继续为每个属性实现适当的函数。除了获取器之外,还实现一个公共槽作为设置器。以下是一个控制汽车速度的属性的示例:
int CarInfo::speed() const { return m_speed; }
void CarInfo::setSpeed(int newSpeed) {
if(m_speed == newSpeed) return;
m_speed = newSpeed;
emit speedChanged(m_speed);
}
你应该能够自己完成剩余属性的示例。
由于我们想使用小部件来调整属性值,请使用 Qt Designer 表单设计用户界面。它可以看起来像这样:

在小部件中建立适当的信号-槽连接,以便修改给定参数的任何小部件或直接使用设置器槽更新该参数的所有小部件。
小贴士
而不是在 CarInfo 类中添加成员变量来表示 speed、rpm、distance 或 gear 等属性,你可以直接操作放置在 ui 表单上的小部件,例如,distance 属性的获取器将看起来如下:
qreal CarInfo::distance() const { return ui->distanceBox->value(); }
设置器将修改为:
void CarInfo::setDistance(qreal newDistance)
{ ui->distanceBox->setValue(newDistance); }
然后,你需要向构造函数中添加 connect() 语句,以确保信号从 ui 表单传播:
connect(ui->distanceBox, SIGNAL(valueChanged(double)), this, SIGNAL(distanceChanged(double)));
接下来,你可以通过运行小部件来测试你的工作。为此,你必须修改主函数,使其看起来如下:
int main(int argc, char **argv) {
QApplication app(argc, argv);
CarInfo cinfo;
cinfo.show();
return app.exec();
};
由于我们正在使用小部件,我们必须将 QGuiApplication 替换为 QApplication,并通过在项目文件中放置 QT += widgets 来启用小部件模块(记得在项目上下文菜单中运行 qmake)。在继续下一步之前,请确保一切按预期工作(即,移动滑块和更改微调框值会反映到小部件属性上)。
现在,我们将把 QtQuick 加入到等式中,所以让我们先更新我们的主函数以显示场景。引入代码中的高亮更改:
int main(int argc, char **argv) {
QApplication app(argc, argv);
CarInfo cinfo;
QQuickView view;
view.engine()->rootContext()->setContextProperty("carData", &cinfo);
view.setSource("qrc:/main.qml");
view.show();
cinfo.show();
return app.exec();
};
这些修改为我们的场景创建了一个视图,将 CarInfo 实例导出到 QML 引擎的全局上下文中,并从资源中的文件加载并显示场景。
首先导出所有对象,然后再加载场景是很重要的。这是因为我们希望在场景初始化时所有名称都已可解析,以便它们可以立即使用。如果我们颠倒调用顺序,我们将在控制台得到许多关于身份未定义的警告。
最后,我们可以专注于 QML 部分。看看练习开始时我们想要显示的结果的图片。对于黑色背景,我们使用了一个在图形编辑器中创建的位图图像(你可以在本书的材料中找到该文件),但你也可以通过在QtQuick中直接组合三个黑色圆角矩形来获得类似的效果——两个外侧部分是完美的圆形,而内部模块是一个水*拉伸的椭圆。
如果你决定使用我们的背景文件(或制作你自己的更漂亮的图像),你可以将以下代码放入main.qml:
import QtQuick 2.3
Image {
source: "dashboard.png"
Item {
id: leftContainer
anchors.centerIn: parent
anchors.horizontalCenterOffset: -550
width: 400; height: width
}
Item {
id: middleContainer
anchors.centerIn: parent
width: 700; height: width
}
Item {
id: rightContainer
anchors.centerIn: parent
anchors.horizontalCenterOffset: 525
width: 400; height: width
}
}
我们在这里做的是将图像作为根项目,并创建三个项目作为仪表盘不同元素的容器。这些容器都居中在父元素中,我们使用horizontalCenterOffset属性将两个外侧项目向侧面移动。偏移量以及宽度的值是通过试错来计算的,以看起来更好(注意,所有三个容器都是完美的正方形)。如果你不使用我们的文件,而是决定自己使用 Qt Quick 项目创建三个部分,容器将简单地锚定到三个黑色项目的中心。
仪表盘看起来很复杂,但实际上,它们非常容易实现,你已经学到了设计它们所需的一切。
让我们从针开始。创建一个新的 QML 文档,并将其命名为Needle.qml。打开文件,并将以下内容放入其中:
import QtQuick 2.0
Item {
id: root
property int length: parent.width*0.4
property color color: "white"
property color middleColor: "red"
property int size: 2
Rectangle { // needle
width: root.size
height: length+20
color: root.color
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
anchors.bottomMargin: -20
antialiasing: true
}
Rectangle { // fixing
anchors.centerIn: parent
width: 8+root.size; height: width; radius: width/2
color: root.color
Rectangle { // middle dot
anchors { fill: parent; margins: parent.width*0.25 }
color: root.middleColor
}
}
}
该文档定义了一个具有四个属性的项目——针的长度(默认为刻度盘半径的 80%),针的颜色,middleColor,代表针固定处的颜色,以及大小,它定义了针的宽度。代码是自我解释的。该项目本身没有尺寸,仅作为视觉元素的锚点——针本身是一个垂直方向的细长矩形,从末端起固定 20 个单位。固定是一个与针颜色相同的圆圈,中间有一个使用不同填充颜色的较小圆圈。内圆的较小半径是通过从每侧填充 25%的边距来获得的。
至于仪表盘,由于我们只有两个,而且它们略有不同,所以创建一个具有精心设计的属性集的单独组件的开销将超过拥有良好封装对象的益处。
如果你考虑一下要完成仪表盘显示和工作的操作,似乎最难的事情是将数字整齐地布局在圆周上,所以让我们从这里开始。以下是一个基于圆的半径和角度(以度为单位)计算圆周上位置的功能实现:
function calculatePosition(angle, radius) {
if( radius === undefined) radius = width/2*0.8
var a = angle * Math.PI/180 // convert degrees to radians
var px = width/2 + radius * Math.cos(a)
var py = width/2 + radius * Math.sin(a)
return Qt.point(px, py)
}
该函数将度数转换为弧度,并返回所需点。该函数期望有一个宽度属性可用,这有助于计算圆的中心,如果没有给出半径,则作为计算其可行值的手段。
有这样的函数可用,我们可以使用已经熟悉的Repeater元素来定位我们想要的位置。让我们将这个函数放入middleContainer中,并声明汽车速度的旋钮:
Item {
id: middleContainer
// ...
function calculatePosition(angle, radius) { /* ... */ }
Repeater {
model: 24/2
Item {
property point pt:
middleContainer.calculatePosition(120+index*12*2)
x: pt.x; y: pt.y
Label {
anchors.centerIn: parent
text: index*20
}
}
}
Needle {
anchors.centerIn: parent
length: parent.width*0.35
size: 4
rotation: 210+(carData.speed*12/10)
color: "yellow"
}
}
你可能已经注意到我们使用了一个名为Label的元素。我们创建它是为了避免在用户界面中为所有使用的文本设置相同的属性值:
import QtQuick 2.0
Text {
color: "white"
font.pixelSize: 24
}
旋钮由一个重复器组成,将创建 12 个元素。每个元素都是使用前面描述的函数定位的项目。项目有一个标签与之锚定,显示给定的速度。我们使用120+index*12*2作为角度表达式,因为我们希望“0”位于 120 度,每个后续项目再额外 24 度。
指针的旋转是基于从carData对象读取的值。由于连续 20 公里每小时标签之间的角度距离是 24 度,因此每公里每小时的距离是 1.2,所以我们把carData.speed乘以这个系数。项目旋转是以 0 度“指向右”为基准计算的;因此,我们将第一个标签的初始 120 度偏移量加上 90 度,以获得与标签系统匹配的起始坐标。
如图中所示,速度旋钮每 2 公里每小时包含一条小线,那些可以被 10 公里每小时整除的线比其他线更长。我们可以使用另一个Repeater来声明这样的刻度:
Repeater {
model: 120-4
Item {
property point pt: middleContainer.calculatePosition(
120+index*1.2*2, middleContainer.width*0.35
)
x: pt.x; y: pt.y
Rectangle {
width: 2
height: index % 5 ? 5 : 10
color: "white"
rotation: 210+index*1.2*2
anchors.centerIn: parent
antialiasing: true
}
}
}
最后,我们可以为旋钮添加一个标签:
Text {
anchors.centerIn: parent
anchors.verticalCenterOffset: 40
text: "SPEED\n[kph]"
horizontalAlignment: Text.AlignHCenter
color: "#aaa"
font.pixelSize: 16
}
确保在旋钮指针之前声明标签,或者给指针一个更高的z值,这样标签就不会覆盖指针。
接下来,通过创建一个读取自carData.rpm的 RPM 旋钮,在自己的左侧容器中重复这个过程。旋钮也显示汽车发动机的当前档位。将以下代码放在leftContainer对象定义内部:
Item {
id: gearContainer
anchors.centerIn: parent
anchors.horizontalCenterOffset: 10
anchors.verticalCenterOffset: -10
Text {
id: gear
property int value: carData.gear
property var gears: [
"R", "N",
"1<sup>st</sup>", "2<sup>nd</sup>", "3<sup>rd</sup>",
"4<sup>th</sup>", "5<sup>th</sup>"
]
text: gears[value+1]
anchors.left: parent.left
anchors.bottom: parent.bottom
color: "yellow"
font.pixelSize: 32
textFormat: Text.RichText
}
}
需要解释的唯一部分已经突出显示。它定义了一个从倒档开始,经过空档,然后是五个前进档位的档位标签数组。然后,该数组以当前档位进行索引,并将该值的文本应用于标签。请注意,值增加了 1,这意味着当carData.gear设置为1时,将使用数组的 0 索引。
我们将不会展示如何实现右侧容器。现在,你可以使用Grid定位器轻松地自己实现它,以布局标签及其值。要显示右侧容器底部的一系列控件(文本为ABS、ESP、BRK和CHECK),可以使用Label实例的Row。
现在,启动程序并开始移动小部件上的滑块。看看 Qt Quick 场景是如何跟随变化的。
发生了什么?
我们创建了一个非常简单的 QObject 实例,并将其作为我们的“数据模型”暴露给 QML。该对象具有多个可以接收不同值的属性。更改值会导致发出信号,这反过来又会通知 QML 引擎,并导致包含这些属性的绑定被重新评估?因此,我们的用户界面得到更新。
我们创建的 QML 和 C++ 世界之间的数据接口非常简单,并且属性数量很少。但随着我们想要公开的数据量的增加,对象可能会变得杂乱。当然,我们可以通过将其分成多个具有单独职责的小对象来抵消这种效果,然后将所有这些对象导出到 QML 中,但这并不总是理想的。在我们的情况下,我们可以看到 rpm 和 gear 是发动机子系统的属性,因此我们可以将它们移动到单独的对象中;然而,在现实中,它们的值与汽车的速度紧密相关,为了计算速度,我们需要知道这两个参数的值。但速度还取决于其他因素,例如道路的坡度,所以将速度放入发动机子系统对象中似乎并不合适。幸运的是,有一个很好的解决方案。
动作时间 - 对发动机属性进行分组
QML 有一个称为分组属性的概念。这些是一个对象的属性,包含了一组“子属性”。你已经知道其中的一些,例如 Rectangle 元素的边框属性或 Item 元素的锚点属性。让我们看看如何为我们的公开对象定义这样的属性。
创建一个新的由 QObject 派生的类,并将其命名为 CarInfoEngine。将 rpm 和 gear 的属性定义移动到那个新类中,并在 CarInfo 中添加以下属性声明:
Q_PROPERTY(Object* engine READ engine NOTIFY engineChanged)
实现获取器和私有字段:
QObject* engine() const { return m_engine; }
private:
CarInfoEngine *m_engine;
我们现在不会使用这个信号;然而,我们必须声明它,否则 QML 会抱怨我们正在绑定依赖于非可通知属性的表达示:
signals:
void engineChanged();
在 CarInfo 的构造函数中初始化 m_engine:
m_engine = new CarInfoEngine(this);
接下来,更新 CarInfo 的代码,以便在部件上的相应滑块移动时修改 m_engine 的属性。也要提供反向链接,即如果属性值发生变化,相应地更新用户界面。
更新 QML 文档,将 carData.gear 替换为 carData.engine.gear。对 carData.rpm 和 carData.engine.rpm 也做同样的处理。最终的结果应该是这样的:
Item {
id: leftContainer
// ...
Item {
id: gearContainer
Text {
id: gear
property int value: carData.engine.gear
// ...
}
}
Needle {
anchors.centerIn: parent
length: parent.width*0.35
rotation: 210+(carInfo.engine.rpm*35)
}
}
发生了什么?
实质上,我们所做的是在 CarInfo 中公开了一个属性,该属性本身就是一个公开了一组属性的属性。这个类型为 CarInfoEngine 的对象绑定到了它所引用的 CarInfo 实例上。
扩展 QML
到目前为止,我们所做的是将创建和初始化在 C++中的单个对象公开给 QML。但我们可以做更多的事情 - 框架允许我们定义新的 QML 类型。这些可以是通用的QObject派生的 QML 元素或针对 Qt Quick 专门化的项目。在本节中,您将学习如何做到这两点。
将类注册为 QML 元素
我们将从简单的事情开始 - 将CarInfo类型公开给 QML,这样我们就可以直接在 QML 中声明元素,而不是在 C++中实例化并在 QML 中公开它,同时仍然允许小部件的变化反映在场景中。
要使某个类(从QObject派生)在 QML 中可实例化,所需的所有操作只是使用qmlRegisterType模板函数将该类注册到声明性引擎中。此函数接受类作为其模板参数以及一系列函数参数:模块uri、主版本号和次版本号,以及我们正在注册的 QML 类型的名称。以下调用将类FooClass注册为 QML 类型Foo,在导入foo.bar.baz后,在版本 1.0 中可用:
qmlRegisterType<FooClass>("foo.bar.baz", 1, 0, "Foo");
您可以在 C++代码的任何地方放置这个调用;只需确保在尝试加载可能包含Foo对象声明的 QML 文档之前这样做。函数调用的典型位置是在程序的主函数中:
#include <QGuiApplication>
#include <QQuickView>
#include <QtQml>
int main(int argc, char **argv) {
QGuiApplication app(argc, argv);
QQuickView view;
qmlRegisterType<FooClass>("foo.bar.baz", 1, 0, "Foo");
view.setSource(QUrl("main.qml"));
view.show();
return app.exec();
}
之后,您可以在文档中开始声明Foo类型的对象。只需记住,您必须首先导入相应的模块:
import QtQuick 2.0
import foo.bar.baz 1.0
Item {
Foo {
id: foo
}
}
行动时间 - 使CarInfo可从 QML 实例化
首先,我们将更新 QML 文档以创建 CarInfo 1.0 模块中存在的CarInfo实例:
import QtQuick 2.0
import CarInfo 1.0
Image {
source: "dashboard.png"
CarInfo {
id: carData
visible: true // make the widget visible
}
// ...
}
至于注册CarInfo,可能会让人想简单地调用qmlRegisterType在CarInfo上,并为自己做得很好而庆祝:
int main(int argc, char **argv) {
QGuiApplication app(argc, argv);
QQuickView view;
qmlRegisterType<CarInfo>("CarInfo", 1, 0, "CarInfo");
view.setSource(QUrl("qrc://main.qml"));
view.show();
return app.exec();
}
通常这会起作用(是的,就这么简单)。然而,在撰写本文时,尝试将任何小部件作为QtQuick项的子项实例化到 QML 文档中会导致崩溃(也许在您阅读此文本时,问题已经得到解决)。为了避免这种情况,我们需要确保我们实例化的不是小部件。为此,我们将使用一个代理对象,该对象将转发我们的调用到实际的小部件。因此,创建一个新的类CarInfoProxy,它从QObject派生,并使其具有与CarInfo相同的属性,例如:
class CarInfoProxy : public QObject {
Q_OBJECT
Q_PROPERTY(QObject *engine READ engine NOTIFY engineChanged)
Q_PROPERTY(int speed READ speed WRITE setSpeed NOTIFY speedChanged)
// ...
声明一个额外的属性,这样我们就可以根据需要显示和隐藏小部件:
Q_PROPERTY(bool visible READ visible WRITE setVisible NOTIFY visibleChanged)
然后,我们可以将小部件作为代理的成员变量放置,这样它就会与其代理一起创建和销毁:
private:
CarInfo m_car;
接下来,实现缺失的接口。为了简单起见,我们向您展示了部分属性的代码。其他属性类似,您可以自行填写空白:
public:
CarInfoProxy(QObject *parent = 0) : QObject(parent) {
connect(&m_car, SIGNAL(engineChanged()), this, SIGNAL(engineChanged()));
connect(&m_car, SIGNAL(speedChanged(int)), this, SIGNAL(speedChanged(int)));
}
QObject *engine() const { return m_car.engine(); }
bool visible() const { return m_car.isVisible(); }
void setVisible(bool v) {
if(v == visible()) return;
m_car.setVisible(v);
emit visibleChanged(v);
}
int speed() const { return m_car.speed(); }
void setSpeed(int v) { m_car.setSpeed(v); }
signals:
void engineChanged();
void visibleChanged(bool);
void speedChanged(int);
};
您可以看到,我们是从小部件中复用CarInfoEngine实例,而不是在代理类中重复它。最后,我们可以将CarInfoProxy注册为CarInfo:
qmlRegisterType<CarInfoProxy>("CarInfo", 1, 0, "CarInfo");
如果你现在运行代码,你会看到它工作得很好——CarInfo 已经成为了一个常规的 QML 元素。正因为如此,它的属性可以直接在文档中设置和修改,对吧?如果你尝试设置速度或距离,它将正常工作。然而,一旦你尝试设置任何在引擎属性中分组的属性,QML 运行时将开始抱怨,显示类似于以下的消息:
Cannot assign to non-existent property "gear"
engine.gear: 3
^
这是因为运行时不理解引擎属性——我们将其声明为 QObject,但我们使用了一个这个类没有的属性。为了避免这个问题,我们必须教运行时关于 CarInfoEngine 的知识。
首先,让我们更新属性声明宏,使用 CarInfoEngine 而不是 QObject:
Q_PROPERTY(CarInfoEngine* engine READ engine NOTIFY engineChanged)
以及获取函数本身:
CarInfoEngine* engine() const { return m_engine; }
然后,我们应该教运行时关于类型的知识:
QString msg = QStringLiteral("Objects of type CarInfoEngine cannot be created");
qmlRegisterUncreatableType<CarInfoEngine>("CarInfo", 1, 0, "CarInfoEngine", msg);
发生了什么?
在这个练习中,我们让 QML 运行时了解两个新元素。其中一个是 CarInfo,它是我们小部件类的代理。我们告诉引擎这是一个具有完整功能的类,可以从 QML 中实例化。另一个类 CarInfoEngine 也被 QML 所知;然而,区别在于在 QML 中声明此类对象的每个尝试都会以给定的警告消息失败。QML 中有其他用于注册类型的函数,但它们很少使用,所以我们不会在这里描述它们。如果你对此好奇,可以在 Creator 的 帮助 面板的索引选项卡中输入 qmlRegister。
自定义 Qt Quick 项目
能够创建新的 QML 元素类型,这些类型可以用来提供动态数据引擎或其他类型的非视觉对象,这很好;然而,这一章是关于 Qt Quick 的,现在是时候学习如何向 Qt Quick 提供新的项目类型了。
你应该问自己的第一个问题是你是否真的需要一个新类型的元素。也许你可以用已经存在的元素达到同样的目标?非常常见的是,你可以在应用程序中使用矢量或位图图像来使用自定义形状,或者你可以使用 Canvas 在 QML 中直接快速绘制所需的图形。
如果你决定你需要自定义项目,你将通过实现 QQuickItem 的子类来完成,这是 Qt Quick 中所有项目的基类。创建新类型后,你将始终需要使用 qmlRegisterType 将其注册到 QML 中。
OpenGL 项目
为了提供非常快速的场景渲染,Qt Quick 使用了一种称为场景图的机制。该图由多个已知类型的节点组成,每个节点描述一个要绘制的原始形状。框架利用对每个允许的原始形状及其参数的知识,找到渲染项目时性能最优的顺序。渲染本身使用 OpenGL 进行,所有形状都使用 OpenGL 调用来定义。
为 Qt Quick 提供新项归结为提供一组节点,这些节点使用图形理解的术语定义形状。这是通过子类化QQuickItem并实现纯虚函数updatePaintNode()来完成的,该函数应该返回一个节点,告诉场景图如何渲染项。该节点很可能是描述一个应用了材质(颜色、纹理)的几何形状(形状)。
行动时间 – 创建正多边形项
让我们通过提供一个用于渲染凸正多边形的项类来了解场景图。我们将使用称为“三角形扇”的 OpenGL 绘图模式来绘制多边形。它绘制了一组所有三角形都具有公共顶点的三角形。后续的三角形由共享顶点、前一个三角形的顶点和指定的下一个顶点定义。看看图解,了解如何使用 8 个控制点将六边形作为三角形扇绘制:

同样的方法适用于任何正多边形。定义的第一个顶点始终是占据形状中心的共享顶点。其余的点均匀地位于形状边界圆的圆周上。角度可以通过将完整角度除以边的数量来轻松计算。对于六边形,这会产生 60 度。
让我们开始处理子类QQuickItem。我们将给它一个非常简单的接口:
class RegularPolygon : public QQuickItem {
Q_OBJECT
Q_PROPERTY(int sides READ sides WRITE setSides NOTIFY sidesChanged)
Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged)
public:
RegularPolygon(QQuickItem *parent = 0);
int sides() const { return m_sideCount; }
void setSides (int s);
QColor color() const { return m_color; }
void setColor(const QColor &c);
QSGNode *updatePaintNode(QSGNode *, UpdatePaintNodeData *);
signals:
void sidesChanged(int);
void colorChanged(QColor);
private:
int m_sideCount;
QColor m_color;
};
我们的正多边形由边的数量和填充颜色定义。我们还继承了从QQuickItem继承的所有内容,包括项的宽度和高度。除了明显的属性获取器和设置器之外,我们只定义了一个方法–updatePaintNode(),它负责构建场景图。
在我们处理更新图形节点之前,让我们先处理容易的部分。构造函数实现如下:
RegularPolygon::RegularPolygon(QQuickItem *parent) : QQuickItem(parent) {
setFlag(ItemHasContents, true);
m_sideCount = 6;
}
我们默认将我们的多边形设置为六边形。我们还设置了一个标志ItemHasContents,它告诉场景图项不是完全透明的,并且应该通过调用updatePaintNode()来询问我们项应该如何绘制。这是一个早期优化,以避免在项根本不会绘制任何内容的情况下准备整个基础设施。
设置器也很容易理解:
void RegularPolygon::setSides(int s) {
s = qMax(3, s);
if(s == sides()) return;
m_sideCount = v;
emit sidesChanged(v);
update();
}
void RegularPolygon::setColor(const QColor &c) {
if(color() == c) return;
m_color = c;
emit colorChanged(c);
update();
}
多边形至少需要有三条边;因此,我们通过qMax强制执行这个最小值,对输入值进行清理。在我们更改可能影响项外观的任何属性之后,我们调用update()让 Qt Quick 知道项需要重新渲染。现在让我们处理updatePaintNode()。我们将将其分解成更小的部分,这样你更容易理解函数的工作方式:
QSGNode *RegularPolygon::updatePaintNode(QSGNode *oldNode,
QQuickItem::UpdatePaintNodeData *) {
当函数被调用时,它可能会收到在之前的调用中返回的节点。请注意,图可以自由删除所有节点,因此即使在函数的前一次运行中返回了一个有效的节点,您也不应该依赖于节点存在:
QSGGeometryNode *node = 0;
QSGGeometry *geometry = 0;
QSGFlatColorMaterial *material = 0;
我们将要返回的节点是一个包含要绘制的形状的几何和材质信息的几何节点。我们将随着方法的进行填充这些变量:
if (!oldNode) {
node = new QSGGeometryNode;
geometry = new QSGGeometry(QSGGeometry::defaultAttributes_Point2D(), m_sideCount+2);
geometry->setDrawingMode(GL_TRIANGLE_FAN);
node->setGeometry(geometry);
node->setFlag(QSGNode::OwnsGeometry);
如我们之前提到的,函数使用之前返回的节点作为参数调用,但我们应该准备好节点可能不存在的情况,并且应该创建它。因此,如果这种情况发生,我们将创建一个新的QSGGeometryNode以及为其创建一个新的QSGGeometry。几何构造函数接受一个所谓的属性集作为其参数,该参数定义了几何中数据的布局。大多数常见的布局已经被预定义:
| 属性集 | 用途 | 第一个属性 | 第二个属性 |
|---|---|---|---|
Point2D |
Solid colored shape |
float x, y |
- |
ColoredPoint2D |
Per-vertex color |
float x, y |
uchar red, green, blue, alpha |
TexturedPoint2D |
Per-vertex texture coordinate |
float x, y |
float tx, float ty |
我们将使用 2D 点来定义几何,每个点不附加任何额外信息;因此,我们传递QSGGeometry::defaultAttributes_Point2D()来构建我们需要的布局。正如您在前面的表中可以看到,该布局的每个属性由两个浮点值组成,表示一个点的x和y坐标。
QSGGeometry构造函数的第二个参数告诉我们我们将使用多少个顶点。构造函数将根据给定的属性布局分配足够的内存来存储所需数量的顶点。在几何容器准备好后,我们将它的所有权传递给几何节点,以便当几何节点被销毁时,几何的内存也会被释放。在这个时候,我们还标记我们将以GL_TRIANGLE_FAN模式进行渲染:
material = new QSGFlatColorMaterial;
material->setColor(m_color);
node->setMaterial(material);
node->setFlag(QSGNode::OwnsMaterial);
该过程对材质重复进行。我们使用QSGFlatColorMaterial作为整个形状将有一个从m_color设置的单一颜色。Qt 提供了一些预定义的材质类型。例如,如果我们想要给每个顶点一个单独的颜色,我们会使用QSGVertexColorMaterial与ColoredPoint2D属性布局一起:
} else {
node = static_cast<QSGGeometryNode *>(oldNode);
geometry = node->geometry();
geometry->allocate(m_sideCount+2);
这段代码处理了oldNode确实包含了一个指向已经初始化的节点的有效指针的情况。在这种情况下,我们只需要确保几何可以容纳我们需要的顶点数量,以防自上次函数执行以来边的数量发生了变化:
material = static_cast<QSGFlatColorMaterial*>(node->material());
if(material->color() != m_color) {
material->setColor(m_color);
node->markDirty(QSGNode::DirtyMaterial);
}
}
这对于材质也是重复的。如果颜色不同,我们将重置它,并通过标记DirtyMaterial标志来告诉几何节点材质需要更新:
QRectF bounds = boundingRect();
QSGGeometry::Point2D *vertices = geometry->vertexDataAsPoint2D();
// first vertex is the shared one (middle)
QPointF center = bounds.center();
vertices[0].set(center.x(), center.y());
// vertices are distributed along circumference of a circle
const qreal angleStep = 360.0/m_sideCount;
const qreal radius = qMin(width(), height())/2;
for (int i = 0; i < m_sideCount; ++i) {
qreal rads = angleStep*i*M_PI/180;
qreal x = center.x()+radius*std::cos(rads);
qreal y = center.y()+radius*std::sin(rads);
vertices[1+i].set(x, y);
}
vertices[1+m_sideCount] = vertices[1];
最后,我们可以设置顶点数据。首先,我们要求几何对象为我们准备一个从分配的内存到QSGGeometry::Point2D结构的映射,这样我们可以方便地为每个顶点设置数据。然后,使用计算圆上点的方程进行实际计算。圆的半径取为项目宽度和高度的较小部分,以便形状在项目中居中。正如你在练习开始时的图中可以看到的,数组中的最后一个点与数组中的第二个点具有相同的坐标,以便将扇形闭合成一个正多边形:
node->markDirty(QSGNode::DirtyGeometry);
return node;
}
最后,我们将几何标记为已更改,并将节点返回给调用者。
发生了什么?
在 Qt Quick 中的渲染可以发生在主线程之外的线程。通过实现updatePaintNode(),我们在 GUI 线程和渲染线程之间进行了同步。执行主线程的函数被阻塞。由于这个原因,它必须尽可能快地执行,并且不要进行任何不必要的计算,因为这会直接影响性能。这也是你可以在代码中安全调用你的项目函数(如读取属性)以及与场景图(创建和更新节点)交互的唯一地方。尽量不要在这个方法中发出任何信号或创建任何对象,因为它们将具有与渲染线程而不是 GUI 线程的亲和力。
话虽如此,你现在可以将你的类注册到 QML 中,并使用以下 QML 文档进行测试:
RegularPolygon {
id: poly
vertices: 5
color: "blue"
}
这应该给你一个漂亮的蓝色五边形。如果形状看起来是锯齿状的,你可以在窗口上强制抗锯齿:
int main(int argc, char **argv) {
QGuiApplication app(argc, argv);
QQuickView view;
QSurfaceFormat format = view.format();
format.setSamples(16); // enable multisampling
view.setFormat(format);
qmlRegisterType<RegularPolygon>("RegularPolygon", 1, 0,
"RegularPolygon");
view.setSource(QUrl("qrc://main.qml"));
view.setResizeMode(QQuickView::SizeRootObjectToView);
view.show();
return app.exec();
}
尝试英雄 – 创建 RegularPolygon 的支持边框
updatePaintNode()返回的可能是单个QSGGeometryNode,也可能是一个更大的QSGNode项树。每个节点可以有任意数量的子节点。通过返回一个有两个几何节点作为子节点的节点,你可以在项目中绘制两个不同的形状:

作为挑战,扩展RegularPolygon以绘制多边形的内部填充部分以及不同颜色的边。你可以使用GL_QUAD_STRIP绘制模式来绘制边。点的坐标很容易计算——靠*形状中间的点就是形成形状本身的点。其余的点也位于一个略微更大的圆的圆周上(边框的宽度)。因此,你可以使用相同的方程来计算它们。GL_QUAD_STRIP模式通过指定在第一个四个顶点之后的每两个顶点来渲染四边形,组成一个连接的四边形。以下图表应该能清楚地说明我们想要达到的效果:

绘制的项目
在 OpenGL 中实现项目相当困难 – 你需要想出一个算法,使用 OpenGL 原语来绘制你想要的形状,然后你还需要足够熟练地使用 OpenGL 来为你的项目构建一个合适的场景图节点树。但还有另一种方法 – 你可以通过使用QPainter来绘制项目。这会带来性能上的损失,因为幕后,画家在一个间接的表面(一个帧缓冲对象或一个图像)上绘制,然后场景图将其转换为 OpenGL 纹理并在四边形上渲染。即使考虑到这种性能损失,使用丰富且方便的绘图 API 来绘制项目通常比在 OpenGL 或使用 Canvas 上花费数小时做同样的事情要简单得多。
要使用这种方法,我们不会直接子类化QQuickItem,而是QQuickPaintedItem,这为我们提供了使用画家绘制项目所需的基础设施。
因此,我们基本上需要做的就是实现纯虚paint()方法,该方法使用接收到的画家渲染项目。让我们看看这个实践,并将其与我们之前获得的技术结合起来。
行动时间 – 创建用于绘制轮廓文本的项目
当前练习的目标是能够使以下 QML 代码工作:
import QtQuick 2.3
import OutlineTextItem 1.0
Rectangle {
width: 800; height: 400
OutlineTextItem {
anchors.centerIn: parent
text: "This is outlined text"
fontFamily: "Arial"
fontPixelSize: 64
color: "#33ff0000"
antialiasing: true
border {
color: "blue"
width: 2
style: Qt.DotLine
}
}
}
以下是一个结果:

从一个激活了core、gui和quick模块的空 Qt 项目开始。创建一个新的类,并将其命名为OutlineTextItemBorder。删除实现文件,因为我们将要把所有代码放入头文件中。将以下代码放入类定义中:
class OutlineTextItemBorder : public QObject {
Q_OBJECT
Q_PROPERTY(int width MEMBER m_width NOTIFY widthChanged)
Q_PROPERTY(QColor color MEMBER m_color NOTIFY colorChanged)
Q_PROPERTY(int style MEMBER m_style NOTIFY styleChanged)
public:
OutlineTextItemBorder(QObject *parent) : QObject(parent),
m_width(0), m_color(Qt::transparent), m_style(Qt::SolidLine) {}
int width() const { return m_width; }
QColor color() const { return m_color; }
Qt::PenStyle style() const { return (Qt::PenStyle)m_style; }
QPen pen() const {
QPen p;
p.setColor(m_color);
p.setWidth(m_width);
p.setStyle((Qt::PenStyle)m_style);
return p;
}
signals:
void widthChanged(int);
void colorChanged(QColor);
void styleChanged(int);
private:
int m_width;
QColor m_color;
int m_style;
};
你可以看到Q_PROPERTY宏没有我们迄今为止使用的READ和WRITE关键字。这是因为我们现在正在走捷径,我们让moc生成代码,该代码将通过直接访问给定的类成员来操作属性。通常,我们不会推荐这种做法,因为没有 getter,访问属性的唯一方法是通过通用的property()和setProperty()调用。然而,在这种情况下,我们不会将这个类公开在 C++中,所以我们不需要设置器,我们仍然会实现 getter。关于MEMBER关键字的好处是,如果我们还提供了NOTIFY信号,生成的代码将在属性值变化时发出该信号,这将使 QML 中的属性绑定按预期工作。类的其余部分相当简单 – 实际上,我们正在提供一个用于定义将要用于描边文本的笔的类。实现一个返回实际笔的方法似乎是个好主意。
该类将为我们的主要项目类提供一个分组属性。创建一个名为OutlineTextItem的类,并从QQuickPaintedItem派生,如下所示:
class OutlineTextItem : public QQuickPaintedItem {
Q_OBJECT
Q_PROPERTY(OutlineTextItemBorder* border READ border NOTIFY borderChanged)
Q_PROPERTY(QString text MEMBER m_text NOTIFY textChanged)
Q_PROPERTY(QColor color MEMBER m_color NOTIFY colorChanged)
Q_PROPERTY(QString fontFamily MEMBER m_ffamily NOTIFY fontFamilyChanged)
Q_PROPERTY(int fontPixelSize MEMBER m_fsize NOTIFY fontPixelSizeChanged)
public:
OutlineTextItem(QQuickItem *parent = 0);
void paint(QPainter *painter);
OutlineTextItemBorder* border() const { return m_border; }
QPainterPath shape(const QPainterPath &path) const;
private slots:
void updateItem();
signals:
void textChanged(QString);
void colorChanged(QColor);
void borderChanged();
void fontFamilyChanged(QString);
void fontPixelSizeChanged(int);
private:
OutlineTextItemBorder* m_border;
QPainterPath m_path;
QRectF m_br;
QString m_text;
QColor m_color;
QString m_ffamily;
int m_fsize;
};
接口定义了要绘制的文本的属性,包括其颜色、字体以及轮廓数据的分组属性。同样,我们使用 MEMBER 以避免手动实现获取器和设置器。不幸的是,这使得我们的构造函数代码更加复杂,因为我们仍然需要一种方法在任何属性被修改时运行一些代码。使用以下代码实现构造函数:
OutlineTextItem::OutlineTextItem(QQuickItem *parent) : QQuickPaintedItem(parent) {
m_border = new OutlineTextItemBorder(this);
connect(this, SIGNAL(textChanged(QString)), SLOT(updateItem()));
connect(this, SIGNAL(colorChanged(QColor)), SLOT(updateItem()));
connect(this, SIGNAL(fontFamilyChanged(QString)), SLOT(updateItem()));
connect(this, SIGNAL(fontPixelSizeChanged(int)), SLOT(updateItem()));
connect(m_border, SIGNAL(widthChanged(int)), SLOT(updateItem()));
connect(m_border, SIGNAL(colorChanged(QColor)), SLOT(updateItem()));
connect(m_border, SIGNAL(styleChanged(int)), SLOT(updateItem()));
updateItem();
}
我们基本上将对象及其分组属性对象的所有属性更改信号连接到同一个槽,该槽将更新项目的数据,如果其任何组件被修改。我们还直接调用相同的槽来准备项目的初始状态。槽可以像这样实现:
void OutlineTextItem::updateItem() {
QFont font(m_ffamily, m_fsize);
m_path = QPainterPath();
m_path.addText(0, 0 , font, m_text);
m_br = shape(m_path).controlPointRect();
setImplicitWidth(m_br.width());
setImplicitHeight(m_br.height());
update();
}
在开始时,该函数重置了一个画家路径对象,该对象作为绘制带轮廓文本的后端,并使用设置的字体初始化它。然后,槽函数使用我们很快就会看到的 shape() 函数计算路径的边界矩形。最后,它将计算出的尺寸设置为项目的尺寸提示,并通过 update() 调用请求项目重新绘制:
QPainterPath OutlineTextItem::shape(const QPainterPath &path) const
{
QPainterPathStroker ps;
if(m_border->width() > 0 && m_border->style() != Qt::NoPen) {
ps.setWidth(m_border->width());
} else {
ps.setWidth(0.0000001); // workaround a bug in Qt
}
QPainterPath p = ps.createStroke(path);
p.addPath(path);
return p;
}
shape() 函数返回一个新的画家路径,它包括原始路径以及使用 QPainterPathStroker 对象创建的轮廓。这样做是为了在计算边界矩形时正确考虑笔触的宽度。我们使用 controlPointRect() 来计算边界矩形,因为它比 boundingRect() 快得多,并且返回的面积大于或等于 boundingRect() 会返回的面积,这对我们来说是可以接受的。
剩下的工作是实现 paint() 例程本身:
void OutlineTextItem::paint(QPainter *painter) {
if(m_text.isEmpty()) return;
painter->setPen(m_border->pen());
painter->setBrush(m_color);
painter->setRenderHint(QPainter::Antialiasing, true);
painter->translate(-m_br.topLeft());
painter->drawPath(m_path);
}
代码非常简单——如果没有东西要绘制,我们就会提前退出。否则,我们使用从项目属性中获得的笔和颜色设置画家。我们启用抗锯齿并使用项目的边界矩形校准画家坐标。最后,我们在画布上绘制路径。
刚才发生了什么?
在这次练习中,我们利用了 Qt 图形引擎强大的 API,通过简单的功能来补充现有的 Qt Quick 元素集。否则,使用预定义的 Qt Quick 元素来实现这一点非常困难,而使用 OpenGL 实现则更加困难。我们同意为了只需编写大约一百行代码就能得到一个完全工作的解决方案,而牺牲一点性能。如果你想在代码中使用它,请记住将类注册到 QML 中:
qmlRegisterUncreatableType<OutlineTextItemBorder>(
"OutlineTextItem", 1, 0, "OutlineTextItemBorder",
"Can't create items of OutlineTetItemBorder type"
);
qmlRegisterType<OutlineTextItem>(
"OutlineTextItem", 1, 0, "OutlineTextItem"
);
摘要
在本章中,你已经熟悉了一种名为 QML 的声明性语言。这种语言用于驱动 Qt Quick——一个用于高度动态和交互式内容的框架。你学习了 Qt Quick 的基础知识——如何使用多种元素类型创建文档,以及如何在 QML 或 C++中创建自己的元素。你还学习了如何将表达式绑定到属性上,以便自动重新评估它们。但到目前为止,尽管我们谈论了“流畅”和“动态”的界面,你还没有看到很多这样的内容。不要担心;在下一章中,我们将专注于 Qt Quick 中的动画,以及一些花哨的图形,并将本章所学应用于创建看起来不错且有趣的游戏。所以,继续阅读吧!
第十章:Qt Quick
在前一章中,我们向您介绍了 Qt Quick 和 QML 的基础知识。到现在,你应该已经足够熟练,能够掌握语法并理解 Qt Quick 的工作基本概念。在本章中,我们将向您展示如何通过引入不同类型的动画,使您的游戏脱颖而出,让您的应用程序感觉更像现实世界。您还将学习如何将 Qt Quick 对象视为可使用状态机编程的独立实体。本章的大部分内容将致力于通过使用 OpenGL 效果和粒子系统使您的游戏更加美观。本章的另一重要部分将介绍如何使用 Qt Quick 实现许多重要的游戏概念。所有这些将通过构建一个简单的 2D 动作游戏来展示,该游戏将使用所介绍的概念。
为静态用户界面注入活力
到目前为止我们所描述的,根本不能称之为“流畅”。现在,让我们通过学习如何将一些动态效果添加到我们创建的用户界面中来改变这一点。到目前为止,书籍无法包含动态图片,因此,这里描述的大多数内容,你都需要通过运行提供的 Qt Quick 代码自行测试。
动画元素
Qt Quick 提供了一个非常广泛的框架来创建动画。我们这里所说的“动画”,并不仅仅是指移动项目。我们定义动画为“随时间改变任意值”。那么,我们可以动画化什么?当然,我们可以动画化项目几何形状。但我们也可以动画化旋转、缩放、其他数值,甚至是颜色。但不要止步于此。Qt Quick 还允许你动画化项目的父子层次结构或锚点分配。几乎任何可以用项目属性表示的东西都可以进行动画化。
此外,变化很少是线性的——如果你把球踢向空中,它首先会迅速上升,因为它的初始速度很大。然而,球是一个受到地球重力作用的物理对象,这会减缓上升速度,直到球停止并开始下落,加速直到它触地。根据地面和球体的属性,物体可以弹起再次进入空中,动量减小,重复类似弹簧的运动,直到最终消失,球体落在地面上。Qt Quick 允许你使用可以分配给动画的缓动曲线来模拟所有这些。
通用动画
Qt Quick 提供了从通用的 Animation 元素派生出的多种动画类型,你永远不会直接使用它们。这种类型的存在只是为了提供不同动画类型共有的 API。
让我们通过查看从最常见的动画类型PropertyAnimation派生的一系列动画类型来更仔细地查看动画框架。正如其名所示,它们提供了动画对象属性值的方法。尽管您可以直接使用PropertyAnimation元素,但通常更方便使用其专门处理不同数据类型特性的子类。
最基本的属性动画类型是NumberAnimation,它允许您动画化整数和实数的所有数值。使用它的最简单方法是声明一个动画,告诉它在一个特定对象中动画化一个特定的属性,然后设置动画的长度以及属性的起始和结束值:
import QtQuick 2.0
Item {
id: root
width: 600; height: width
Rectangle {
id: rect
color: "red"; width: 50; height: width
}
NumberAnimation {
target: rect
property: "x"
from: 0; to: 550
duration: 3000
running: true
}
}
行动时间 – 动作游戏的场景
创建一个新的 Qt Quick UI 项目。在项目目录中,创建一个名为 images 的子目录,并从使用 Graphics View 创建的游戏项目中复制 grass.png、sky.png 和 trees.png。然后,将以下代码放入 QML 文档中:
import QtQuick 2.1
Image {
id: root
property int dayLength: 60000 // 1 minute
source: "images/sky.png"
Item {
id: sun
x: 140
y: root.height-170
Rectangle {
id: sunVisual
width: 40
height: width
radius: width/2
color: "yellow"
anchors.centerIn: parent
}
}
Image {
source: "images/trees.png"
x: -200
anchors.bottom: parent.bottom
}
Image {
source: "images/grass.png"
anchors.bottom: parent.bottom
}
}
当你现在运行项目时,你会看到一个类似于这个屏幕的界面:

发生了什么?
我们设置了一个非常简单的场景,由三个图像堆叠形成一幅风景画。在背景层(天空)和前景(树木)之间,我们放置了一个代表太阳的黄色圆圈。由于我们很快就要移动太阳,我们将对象的中心锚定到一个没有物理尺寸的空项目上,这样我们就可以设置太阳相对于其中心的位置。我们还为场景配备了一个dayLength属性,它将保存有关游戏时间一天长度的信息。默认情况下,我们将其设置为 60 秒,这样事情就会发生得非常快,我们可以看到动画的进度而无需等待。在所有事情都设置正确之后,我们可以调整一天的长度以适应我们的需求。
图形设计使我们能够轻松地操作太阳,同时保持它在树线之后。注意堆叠顺序是如何隐式地由文档中元素的顺序决定的。
行动时间 – 动画太阳的水*运动
太阳在天空中的日常巡航从东方开始,继续向西,直到傍晚时分隐藏在地*线下。让我们通过向我们的sun对象添加动画来尝试复制这种水*运动。
打开我们上一个项目的 QML 文档。在root项目内部添加以下声明:
NumberAnimation {
target: sun
property: "x"
from: 0
to: root.width
duration: dayLength
running: true
}
对程序进行这样的修改后,将产生一个太阳水*移动的运行效果。以下图像是运行过程中多个帧的组合:

发生了什么?
我们引入了一个 NumberAnimation 元素,该元素被设置为动画化 sun 对象的 x 属性。动画从 0 开始,直到 x 达到 root 项的宽度(这是场景的右边缘)。运动持续 dayLength 毫秒。动画的 running 属性被设置为 true 以启用动画。由于我们没有指定其他方式,运动是线性的。
你可能认为动画运行的方向是错误的——“西”在左边,“东”在右边,对吧?是的,然而,这只在观察者面向北方时才成立。如果我们的场景是这样的,我们就根本看不到太阳——在中午时分,它会穿过南方方向。
组合动画
我们在上一个部分中制作的动画看起来不错,但并不非常逼真。太阳应该在早上升起,在中午之前达到顶峰,然后稍后开始向傍晚方向落下,此时它应该穿过地*线并隐藏在地形之下。
为了实现这样的效果,我们可以为太阳的 y 属性添加两个额外的动画。第一个动画将从一开始就降低太阳的垂直位置(记住,垂直几何轴指向下方,所以降低垂直位置意味着对象向上移动)。动画将在一天长度的三分之一时完成。然后我们需要一种方法来等待一段时间,然后启动第二个动画,该动画将把对象向下拉向地面。启动和停止动画很容易——我们可以在动画项上调用 start() 和 stop() 函数,或者直接更改 running 属性的值。每个 Animation 对象都会发出 started() 和 stopped() 信号。延迟可以通过使用计时器来实现。我们可以为第一个动画的停止信号提供一个信号处理器来触发计时器以启动另一个,如下所示:
NumberAnimation {
id: sunGoesUpAnim
// …
onStopped: sunGoesDownAnimTimer.start()
}
Timer {
id: sunGoesDownAnimTimer
interval: dayLength/3
onTriggered: sunGoesDownAnim.start()
}
即使忽略这可能会带来的任何副作用(例如,如何在启动第二个动画之前停止动画),这样的方法也不能被称为“声明式”,对吧?
幸运的是,类似于我们在 C++ 中所拥有的,Qt Quick 允许我们形成动画组,这些动画组可以相互并行运行或按顺序运行。这里有 SequentialAnimation 和 ParallelAnimation 类型,您可以在其中声明任意数量的子动画元素来形成组。要并行运行两个动画,我们可以声明以下元素层次结构:
ParallelAnimation {
id: parallelAnimationGroup
NumberAnimation {
target: obj1; property: "prop1"
from: 0; to: 100
duration: 1500
}
NumberAnimation {
target: obj2; property: "prop2"
from: 150; to: 0
duration: 1500
}
running: true
}
同样的技术可以用来同步更大的动画组,即使每个组件的持续时间不同:
SequentialAnimation {
id: sequentialAnimationGroup
ParallelAnimation {
id: parallelAnimationGroup
NumberAnimation {
id: A1
target: obj2; property: "prop2"
from: 150; to: 0
duration: 1000
}
NumberAnimation {
id: A2
target: obj1; property: "prop1"
from: 0; to: 100
duration: 2000
}
}
PropertyAnimation {
id: A3
target: obj1; property: "prop1"
from: 100; to: 300
duration: 1500
}
running: true
}
段落中展示的组由三个动画组成。前两个动画一起执行,因为它们形成一个并行子组。组中的一个成员运行时间是另一个的两倍。只有当整个子组完成之后,第三个动画才开始。这可以通过一个 UML 活动图来可视化,其中每个活动的尺寸与该活动的持续时间成比例:

动作时间 – 制作日出日落
让我们在 QML 文档中添加垂直移动(y属性的动画)到我们的太阳,通过添加一系列动画来实现。由于我们的新动画将与水*动画并行运行,我们可以将两个方向的动画都包含在一个ParallelAnimation组中。这会起作用,但据我们看来,这会不必要地使文档变得杂乱。指定并行动画的另一种方式是将它们声明为独立的元素层次结构,使每个动画独立于其他动画,这正是我们要在这里做的。
从上一个练习的文档中打开,在之前的动画下方放置以下代码:
SequentialAnimation {
NumberAnimation {
target: sun
property: "y"
from: root.height+sunVisual.height
to: root.height-270
duration: dayLength/3
}
PauseAnimation { duration: dayLength/3 }
NumberAnimation {
target: sun
property: "y"
from: root.height-270
to: root.height+sunVisual.height
duration: dayLength/3
}
running: true
}
运行程序将导致光源在早晨升起,在傍晚落下。然而,移动的轨迹似乎有些笨拙。

发生了什么?
我们声明了一个由三个动画组成的顺序动画组,每个动画持续时间为一天长度的 1/3。组中的第一个成员使太阳升起。第二个成员,它是一个新元素类型PauseAnimation的实例,引入了一个等于其持续时间的延迟。这反过来又让第三个组件在下午开始工作,将太阳拉向地*线。
这样声明的问题在于太阳以极其角度化的方式移动,如图像所示。
非线性动画
描述的问题的原因是我们的动画是线性的。正如我们在本章开头所指出的,线性动画在自然界中很少发生,这通常使得它们的使用产生非常不真实的结果。
我们之前也说过,Qt Quick 允许我们使用缓动曲线来执行沿非线性路径的动画。提供了大量的曲线。以下是一个列出可用非线性缓动曲线的图表:

您可以在PropertyAnimation类型或其派生类型(例如NumberAnimation)的元素上使用任何曲线。这是通过使用easing属性组来完成的,您可以在其中设置曲线的type。不同的曲线类型可以通过在easing属性组中设置多个属性进一步调整,例如amplitude(用于弹跳和弹性曲线)、overshoot(用于回弹曲线)或period(用于弹性曲线)。
声明沿InOutBounce路径的动画非常简单:
NumberAnimation {
target: obj; property: prop;
from: startValue; to: endValue;
easing.type: Easing.InOutBounce
}
行动时间 – 改善太阳的路径
当前任务是要改进太阳的动画,使其表现得更加逼真。我们将通过调整动画,使对象沿着曲线路径移动。
在我们的 QML 文档中,将之前的垂直动画替换为以下动画:
SequentialAnimation {
NumberAnimation {
target: sun
property: "y"
from: root.height+sunVisual.height
to: root.height-270
duration: dayLength/2
easing.type: Easing.OutCubic
}
NumberAnimation {
target: sun
property: "y"
to: root.height+sunVisual.height
duration: dayLength/2
easing.type: Easing.InCubic
}
running: true
}

刚才发生了什么?
三个动画序列(两个线性动画和一个暂停)被另一个由三次函数确定的路径的动画序列所取代。这使得我们的太阳升起很快,然后减速到几乎在太阳接*中午时几乎察觉不到的程度。当第一个动画完成后,第二个动画会反转运动,使太阳缓慢下降,然后在黄昏临*时增加速度。因此,太阳离地面越远,它看起来移动得越慢。同时,水*动画保持线性,因为地球在围绕太阳运动的速度实际上是恒定的。当我们结合水*和垂直动画时,我们得到一条看起来非常类似于我们在现实世界中观察到的路径。
属性值来源
从 QML 的角度来看,动画及其衍生元素类型被称为 属性值来源。这意味着它们可以附加到属性上并为它生成值。重要的是,它允许我们使用更简单的语法来使用动画。不需要显式声明动画的目标和属性,可以将动画附加到父对象的命名属性上。
要这样做,对于 Animation,不要指定 target 和 property,而是使用 on 关键字后跟属性名,该属性名是动画的值来源。例如,要使用 NumberAnimation 对象动画化对象的 rotation 属性,可以使用以下代码:
NumberAnimation on rotation {
from: 0
to: 360
duration: 500
}
对于同一个对象的同一属性,指定多个属性值来源是有效的。
行动时间 – 调整太阳的颜色
如果你黄昏或黎明时分看太阳,你会发现它不是黄色的,而是越接*地*线就越变成红色。让我们通过为表示太阳的对象提供一个属性值来源来教会它做同样的事情。
打开 QML 文档,找到 sunVisual 对象的声明,并用下面的高亮部分扩展它:
Rectangle {
id: sunVisual
// ...
SequentialAnimation on color {
ColorAnimation {
from: "red"
to: "yellow"
duration: 0.2*dayLength/2
}
PauseAnimation { duration: 2*0.8*dayLength/2 }
ColorAnimation {
to: "red"
duration: 0.2*dayLength/2
}
running: true
}
}
刚才发生了什么?
我们将一个动画附加到了矩形的color属性上,以模拟太阳的视觉方面。这个动画由三个部分组成。首先,我们使用ColorAnimation对象从红色过渡到黄色。这是一个专门用于修改颜色的Animation子类型。由于矩形的颜色不是数字,使用NumberAnimation对象将不起作用,因为该类型无法插值颜色值。因此,我们只能使用PropertyAnimation或ColorAnimation对象。动画的持续时间设置为半日长度的 20%,以便黄色能够非常快地获得。第二个组件是一个PauseAnimation对象,在执行第三个组件之前提供延迟,该组件逐渐将颜色变回红色。对于最后一个组件,我们没有为from属性提供值。这导致动画以动画执行时的属性当前值启动(在这种情况下,太阳应该是黄色的)。
注意,我们只需要指定顶级动画的属性名称。这个特定的元素充当属性值源,所有下级动画对象“继承”了目标属性。
行动时间 – 装饰太阳动画
目前太阳的动画看起来几乎完美。尽管如此,我们仍然可以改进它。如果你在清晨和中午时分观察天空,你会注意到,与太阳在顶点的大小相比,太阳在日出或日落时看起来要大得多。我们可以通过缩放对象来模拟这种效果。
在我们的场景文档中,添加另一个操作太阳scale属性的顺序动画:
SequentialAnimation on scale {
NumberAnimation {
from: 1.6; to: 0.8
duration: dayLength/2
easing.type: Easing.OutCubic
}
NumberAnimation {
from: 0.8; to: 1.6
duration: dayLength/2
easing.type: Easing.InCubic
}
}

发生了什么?
在本节中,我们只是遵循了早期声明的路径——恒星体的垂直运动影响其感知的大小;因此,将两个动画绑定在一起似乎是一个好决定。注意,我们可能修改了原始动画,并使缩放动画与操作y属性的动画并行,而不是为缩放指定一个新的属性值源:
SequentialAnimation {
ParallelAnimation {
NumberAnimation {
target: sun
property: "y"
from: root.height+sunVisual.height
to: root.height-270
duration: dayLength/2
easing.type: Easing.OutCubic
}
NumberAnimation {
target: sun
property: "scale"
from: 1.6; to: 0.8
duration: dayLength/2
easing.type: Easing.OutCubic
}
// …
尝试一下英雄 – 动画太阳光线
到目前为止,你应该已经成为一个动画专家。如果你想尝试你的技能,这里有一个任务给你。以下代码可以应用于sun对象,并将显示从太阳发出的非常简单的红色光线:
Item {
id: sunRays
property int count: 10
width: sunVisual.width
height: width
anchors.centerIn: parent
z: -1
Repeater {
model: sunRays.count
Rectangle {
color: "red"
rotation: index*360/sunRays.count
anchors.fill: parent
}
}
}

目标是使光线动画看起来整体效果良好,并符合场景的调子风格。尝试不同的动画——旋转、大小变化和颜色。将它们应用于不同的元素——一次所有光线(例如,使用sunRays标识符)或仅由重复器生成的特定矩形。
行为
在上一章中,我们实现了一个赛车游戏的仪表盘,其中包含了一些带有指针的时钟。我们可以为每个时钟设置值(例如,汽车速度),相应的指针会立即调整到给定的值。但这种方法在现实中是不现实的——在现实世界中,值的改变是随着时间的推移发生的。在我们的例子中,汽车通过 10 英里/小时加速到 50 英里/小时,经过 11 英里/小时、12 英里/小时等等,直到经过一段时间后达到期望的值。我们称这种值为行为——它本质上是一个模型,描述了参数如何达到其目标值。定义这样的模型是声明式编程的完美用例。幸运的是,QML 公开了一个行为元素,它允许我们模拟 Qt Quick 中属性变化的动态行为。
行为元素允许我们将一个动画与一个特定的属性关联起来,这样每次需要改变属性值时,就会通过运行指定的动画来代替直接改变属性值:
import QtQuick 2.0
Item {
width: 600; height: width
Item {
id: empty
x: parent.width/2; y: parent.height/2
Rectangle {
id: rect
width: 100; height: width; color: "red"
anchors.centerIn: parent
}
}
MouseArea {
anchors.fill: parent
onClicked: { empty.x = mouse.x; empty.y = mouse.y }
}
}
上述代码实现了一个简单的场景,其中有一个红色矩形被锚定到一个空的项目上。每当用户在场景内点击时,空项目就会移动到那里,并拖动矩形。让我们看看如何使用行为元素来*滑地改变空项目的位置。行为元素就像动画元素本身一样,是一个属性值源;因此,它最容易在属性语法中使用:
Item {
id: rect
x: parent.width/2; y: parent.height/2
Rectangle {
width: 100; height: width; color: "red"
anchors.centerIn: parent
}
Behavior on x { NumberAnimation { } }
Behavior on y { NumberAnimation { } }
}
通过添加两个标记的声明,我们为属性x和y定义了遵循NumberAnimation定义的动画的行为。我们不包括动画的起始或结束值,因为这些将取决于属性的初始和最终值。我们也没有在动画中设置属性名称,因为默认情况下,定义行为所用的属性将被使用。因此,我们得到一个从原始值到目标值的线性数值属性动画,持续时间为默认值。
小贴士
对于现实世界中的对象,使用线性动画通常看起来并不好。通常,如果你为动画设置一个缓动曲线,那么动画会从慢速开始,然后加速,并在完成前减速,这样你会得到更好的结果。
设置在行为上的动画可以像你想要的那样复杂:
Behavior on x {
SequentialAnimation {
PropertyAction {
target: rect; property: "color"; value: "yellow"
}
ParallelAnimation {
NumberAnimation { easing.type: Easing.InOutQuad; duration: 1000
}
SequentialAnimation {
NumberAnimation {
target: rect; property: "scale"
from: 1.0; to: 1.5; duration: 500
}
NumberAnimation {
target: rect; property: "scale"
from: 1.5; to: 1.0; duration: 500
}
}
}
PropertyAction { target: rect; property: "color"; value: "red" }
}
}
最后一段代码中声明的行为模型执行了一个顺序动画。它首先使用 PropertyAction 元素将矩形的颜色更改为黄色,该元素执行属性值的即时更新(我们稍后会详细讨论这一点)。然后,在模型的最后一步,颜色将恢复为红色。同时,执行一个并行动画。其中一个组件是 NumberAnimation 类,它执行 empty 的 x 属性的实际动画(因为动画的目标和属性没有明确设置)。第二个组件是矩形的 scale 属性的顺序动画,它在动画的前半部分将项目放大 50%,然后在动画的后半部分将其缩小回原始大小。
行动时间 - 动画汽车仪表盘
让我们运用刚刚学到的知识来改进我们的汽车仪表盘,使其在时钟更新值的方式上显示一些逼真性。
打开仪表盘项目并导航到 dashboard.qml 文件。找到负责可视化车辆速度的 Needle 对象的声明。向该对象添加以下声明:
Behavior on rotation {
SmoothedAnimation { velocity: 50 }
}
对左侧的时钟重复此过程。将动画的速度设置为 100。构建并运行项目。观察当你在微调框中修改参数值时,指针的行为。调整每个动画的 velocity,直到得到逼真的结果。
发生了什么?
我们已经在需要请求属性新值的针旋转上设置了属性值源。而不是立即接受新值,Behavior 元素拦截请求并启动 SmoothedAnimation 类,以逐渐达到请求的值。SmoothedAnimation 类是一种动画类型,它动画化数值属性。动画的速度不由其持续时间决定,而是通过设置一个 velocity 属性。该属性决定了值改变的速度。然而,动画使用的是非线性路径——它首先缓慢开始,然后加速到给定的速度,并在动画接*结束时以*滑的方式减速。这产生了一个既吸引人又逼真的动画,同时,根据起始值和结束值之间的距离,动画的持续时间可以是较短或较长的。
小贴士
您可以通过继承 QQmlPropertyValueSource 并在 QML 引擎中注册该类来实现自定义属性值源。
状态和转换
当你观察现实世界中的对象时,通常很容易通过提取对象可能采取的几个状态并分别描述每个状态来定义其行为。灯可以开启或关闭。当它“开启”时,它会发出特定颜色的光,但在“关闭”状态下则不会这样做。对象的行为可以通过描述如果对象离开一个状态并进入另一个状态时会发生什么来定义。以我们的灯为例,如果你打开灯,它不会瞬间以全功率发出光,而是亮度逐渐增加,在很短的时间内达到最终功率。
Qt Quick 通过允许我们声明状态及其之间转换来支持状态驱动开发。这种模型非常适合 Qt Quick 的声明性特性。
默认情况下,每个项目只有一个匿名状态,你定义的所有属性都取绑定或基于不同条件强制分配给它们的表达式的值。相反,可以为对象定义一组状态,以及对象本身的每个状态属性;此外,在其中定义的对象可以用不同的值或表达式进行编程。我们的示例灯定义可能类似于:
Item {
id: lamp
property bool lampOn: false
Rectangle {
id: lightsource
anchors.fill: parent
color: transparent
}
}
当然,我们可以将lightsource的color属性绑定到lamp.lampOn ? "yellow" : "transparent",但相反,我们可以为灯定义一个“开启”状态,并使用PropertyChanges元素来修改矩形颜色:
Item {
id: lamp
property bool lampOn: false
// …
states: State {
name: "on"
PropertyChanges {
target: lightsource
color: "yellow"
}
}
}
每个项目都有一个state属性,你可以读取它来获取当前状态,但也可以写入它来触发转换到给定状态。默认情况下,state属性被设置为空字符串,表示匿名状态。请注意,根据前面的定义,项目有两个状态——“开启”状态和匿名状态(在这种情况下,当灯关闭时使用)。记住,状态名称必须是唯一的,因为name参数是用来在 Qt Quick 中标识状态的。
要进入一个状态,我们当然可以使用一个事件处理器,当lampOn参数的值被修改时触发:
onLampOnChanged: state = lampOn ? "on" : ""
这样的命令式代码可以工作,但可以在状态本身中用声明性定义替换:
State {
name: "on"
when: lamp.lampOn
PropertyChanges {
target: lightsource
color: "yellow"
}
}
当绑定到when属性的表达式评估为true时,状态变为活动状态。如果表达式变为false,对象将返回默认状态或进入其when属性评估为true的状态。
要定义多个自定义状态,只需将状态定义列表分配给states属性即可:
states: [
State {
name: "on"
when: lamp.lampOn
},
State {
name: "off"
when: !lamp.lampOn
}
]
PropertyChanges元素是在状态定义中最常用的更改,但它不是唯一的。与ParentChange元素可以为项目分配不同的父元素以及AnchorChange元素可以更新锚定义的方式完全相同,使用StateChangeScript元素也可以在状态进入时运行脚本。所有这些元素类型都是通过在State对象中将它们的实例作为子元素声明来使用的。
状态机框架的第二部分是定义一个对象如何从一个状态转换到另一个状态。类似于states属性,所有项目都有一个transitions属性,它接受一个由Transition对象表示的定义列表,并提供有关在特定转换发生时应播放的动画的信息。
转换通过三个属性来识别——源状态、目标状态和一组动画。源状态名称(设置为from属性)和目标状态名称(设置为to属性)都可以为空,在这种情况下,它们应被解释为“任何”。如果存在一个与当前状态更改匹配的Transition,则其动画将被执行。一个更具体的转换定义(其中from和/或to被显式设置)比一个更通用的定义具有优先级。
假设我们想在灯开启时将灯矩形的透明度从0动画到1。我们可以作为操作颜色的替代方案来完成它。让我们更新灯的定义:
Item {
id: lamp
property bool lampOn: false
Rectangle {
id: lightsource
anchors.fill: parent
color: "yellow"
opacity: 0
}
states: State {
name: "on"
when: lamp.lampOn
PropertyChanges {
target: lightsource
opacity: 1
}
}
transitions: Transition {
NumberAnimation { duration: 100 }
}
}
转换对任何源状态和任何目标状态都会触发——当灯从匿名状态切换到“开启”状态时,它将处于活动状态,以及相反的方向。它定义了一个持续 100 毫秒的单一NumberAnimation元素。动画没有定义目标对象或它所操作的属性;因此,它将为任何属性和任何需要作为转换一部分进行更新的对象执行——在灯的情况下,它将仅是lightsource对象的opacity属性。
如果在转换中定义了多个动画,所有动画将并行运行。如果您需要一个顺序动画,您需要显式使用SequentialAnimation元素:
Transition {
SequentialAnimation {
NumberAnimation { target: lightsource; property: "opacity"; duration: 200 }
ScriptAction { script: console.log("Transition has ended") }
}
}
小贴士
状态是所有Item类型及其派生类型的一个特性。然而,通过使用StateGroup元素,可以与未从Item对象派生的元素一起使用状态,这是一个包含状态和转换的自包含功能,具有与这里描述的Item对象完全相同的接口。
更多动画类型
我们之前讨论的动画类型用于修改可以使用物理度量(位置、大小、颜色、角度)描述的类型值。但还有更多类型可供选择。
第一组特殊动画包括AnchorAnimation和ParentAnimation元素。
AnchorAnimation 元素在需要状态改变导致项目定义锚点发生变化时非常有用。没有它,项目会立即跳到其位置。通过使用 AnchorAnimation 元素,我们可以触发所有锚点变化逐渐动画化。
另一方面,ParentAnimation 元素使得在项目获得新的父元素时定义动画成为可能。这通常会导致项目在场景中移动到不同的位置。通过在状态转换中使用 ParentAnimation 元素,我们可以定义项目如何进入其目标位置。该元素可以包含任意数量的子动画元素,这些元素将在 ParentChange 元素执行期间并行运行。
第二组特殊的动画是动作动画——PropertyAction 和 ScriptAction。这些动画类型不会在时间上拉伸,而是执行一次性的给定动作。
PropertyAction 元素是一种特殊的动画,它将属性立即更新到给定的值。它通常用作更复杂动画的一部分,以修改未动画化的属性。如果属性需要在动画期间具有某个特定值,则使用它是有意义的。
ScriptAction 是一个元素,允许在动画期间(通常在开始或结束时)执行命令式代码片段。
快速游戏编程
在这里,我们将通过使用 Qt Quick 创建一个*台游戏的过程。它将是一款类似于第六章图形视图中的本杰明大象的游戏。玩家将控制一个角色,该角色将在景观中行走并收集金币。金币将随机出现在世界中。角色可以通过跳跃来获取放置在高处的金币。角色跳得越多,就越累,开始移动得越慢,并且需要休息以恢复速度。为了使游戏更具挑战性,有时会生成移动障碍。当角色撞到任何障碍时,他会变得越来越累。当疲劳超过一定水*时,角色就会死亡,游戏结束。
在本章以及上一章中,我们为这个游戏准备了一些将重新使用的组件。当你学习动画时安排的分层场景将作为我们的游戏场景。动画太阳将代表时间的流逝。当太阳落山时,时间耗尽,游戏结束。心跳图将用来表示角色的疲劳程度——角色越累,心跳越快。
游戏可以以多种方式实现,我们希望给你一定的自由度,所以这不会是一个逐步指导如何实现完整游戏的指南。在某些时候,我们会告诉你使用你已经学到的技能来完成某些任务,而不会告诉你如何做。在其他时候,我们会提供广泛的描述和完整的解决方案。
游戏循环
大多数游戏都围绕某种游戏循环展开。这通常是一种在固定间隔重复调用的函数,其任务是推进游戏——处理输入事件,移动对象,计算和执行动作,检查胜利条件等等。这种方法非常命令式,通常会导致一个非常复杂的函数,需要了解每个人的所有信息(这种反模式有时被称为神对象模式)。在 QML(为 Qt Quick 框架提供动力)中,我们旨在分离责任并为特定对象声明定义良好的行为。因此,尽管可以设置一个计时器,定期调用游戏循环函数,但这在声明式世界中并不是最佳方法。
相反,我们建议使用 Qt Quick 中已经存在的自然时间流动机制——该机制控制动画的一致性。还记得我们在本章开头定义太阳在天空中移动的方式吗?我们不是通过设置计时器和移动对象一定数量的像素来设置,而是创建了一个动画,为它定义了总运行时间,并让 Qt 负责更新对象。这有一个很大的好处,就是忽略了函数执行中的延迟。如果你使用计时器,而某些外部事件在超时函数运行之前引入了显著的延迟,动画就会开始落后。当使用 Qt Quick 动画时,框架会补偿这些延迟,跳过一些帧更新,以确保尊重请求的动画持续时间。因此,你不必自己处理这一切。
为了克服游戏循环的第二个难题——神反模式,我们建议将每个项目的逻辑直接封装在项目本身中,使用我们之前介绍的状态和转换框架。如果你定义一个对象,使用自然的时间流动描述它在生命周期中可以进入的所有状态以及导致状态转换的动作,你将能够将包含行为的对象随意放置在任何需要的地方,从而在不同的游戏中轻松重用这些定义,减少使对象适应游戏所需的工作量。
至于输入事件处理,在游戏中常用的方法是从输入事件中读取事件并调用与特定事件相关的动作负责的函数:
void Scene::keyEvent(QKeyEvent *ke) {
switch(ke->key()) {
case Qt::Key_Right: player->goRight(); break;
case Qt::Key_Left: player->goLeft(); break;
case Qt::Key_Space: player->jump(); break;
// ...
}
}
然而,这种方法有其缺点,其中之一是需要检查事件在均匀的时间间隔。这可能很困难,肯定不是一种声明式方法。
我们已经知道 Qt Quick 通过Keys附加属性来处理键盘输入。可以编写类似于刚才展示的 QML 代码,但这种方法的问题在于,玩家在键盘上按得越快,角色移动、跳跃或射击的频率就越高。如果做得恰当,这并不困难。
行动时间 - 角色导航
创建一个新的 QML 文档,并将其命名为Player.qml。在文档中,放置以下声明:
Item {
id: player
y: parent.height
focus: true
Keys.onRightPressed: x = Math.min(x+20, parent.width)
Keys.onLeftPressed: x = Math.max(0, x-20)
Keys.onUpPressed: jump()
function jump() { jumpAnim.start() }
Image {
source: "elephant.png"
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
}
Behavior on x { NumberAnimation { duration: 100 } }
SequentialAnimation on y {
id: jumpAnim
running: false
NumberAnimation { to: player.parent.height-50; easing.type: Easing.OutQuad }
NumberAnimation { to: player.parent.height; easing.type: Easing.InQuad }
}
}
接下来,打开包含主场景定义的文档,并在所有背景层定义之后,在文档末尾声明玩家角色:
Player {
id: player
x:40
}
刚才发生了什么?
玩家本身是一个带有键盘焦点的空项目,它处理右箭头、左箭头和上箭头的按键,使它们能够操纵玩家的x和y坐标。x属性设置了一个Behavior元素,以便玩家在场景内*滑移动。最后,与玩家项目锚定的是玩家实际的可视化——我们的象朋友。
当按下右箭头或左箭头键时,将计算并应用角色的新位置。多亏了Behavior元素,项目将逐渐(在一秒内)移动到新位置。保持按键将触发自动重复,处理程序将被再次调用。以类似的方式,当按下空格键时,它将激活一个准备好的顺序动画,将角色向上提升 50 像素,然后再次将其移回到初始位置。
这种方法可行,但我们能做得更好。让我们尝试不同的方法。
行动时间 - 另一种角色导航方法
用以下代码替换先前的键处理程序:
QtObject {
id: flags
readonly property int speed: 20
property int horizontal: 0
}
Keys.onRightPressed: { recalculateDurations(); flags.horizontal = 1 }
Keys.onLeftPressed: {
if(flags.horizontal != 0) return
recalculateDurations()
flags.horizontal = -1
}
Keys.onUpPressed: jump()
Keys.onReleased: {
if(event.key == Qt.Key_Right) flags.horizontal = 0
if(event.key == Qt.Key_Left && flags.horizontal < 0) flags.horizontal = 0
}
function recalculateDurations() {
xAnimRight.duration = (xAnimRight.to-x)*1000/flags.speed
xAnimLeft.duration = (x-xAnimLeft.to)*1000/flags.speed
}
NumberAnimation on x {
id: xAnimRight
running: flags.horizontal > 0
to: parent.width
}
NumberAnimation on x {
id: xAnimLeft
running: flags.horizontal < 0
to: 0
}
刚才发生了什么?
现在我们在按下键时不是立即执行动作,而是设置(在私有对象中)表示角色应移动方向的标志。在我们的情况下,右方向优先于左方向。设置标志会触发一个尝试将角色移动到场景边缘的动画。释放按钮将清除标志并停止动画。在动画开始之前,我们调用recalculateDurations()函数,该函数检查动画应该持续多长时间,以便角色以期望的速度移动。
小贴士
如果你想用其他方式替换基于键盘的输入,例如加速度计或自定义按钮,可以应用相同的原则。当使用加速度计时,你甚至可以通过测量设备倾斜的程度来控制玩家的速度。你还可以将倾斜存储在flags.horizontal参数中,并在recalculateDurations()函数中使用该变量。
英雄尝试 - 完善动画
我们所做的是许多应用所必需的。然而,你可以尝试控制移动得更加精细。作为一个挑战,尝试修改系统,使得在跳跃过程中,惯性保持角色的当前水*方向和移动速度直到跳跃结束。如果玩家在跳跃过程中释放左右键,角色将只在跳跃完成后停止。
尽管我们试图以声明性方式完成所有事情,但仍然需要一些命令式代码。如果某些操作需要定期执行,你可以使用 Timer 参数按需执行函数。让我们一起来完成这样的模式实现过程。
行动时间——生成硬币
我们试图实现的游戏的目的是收集硬币。我们现在将在场景的随机位置生成硬币。
创建一个新的 QML 文档,并将其命名为 Coin.qml。在编辑器中,输入以下代码:
Item {
id: coin
Rectangle {
id: coinVisual
color: "yellow"
border.color: Qt.darker(color)
border.width: 2
width: 30; height: width
radius: width/2
anchors.centerIn: parent
transform: Rotation {
axis.y: 1
NumberAnimation on angle {
from: 0; to: 360
loops: Animation.Infinite
running: true
}
}
Text {
color: coinVisual.border.color
anchors.centerIn: parent
text: "1"
}
}
}
接下来,打开定义场景的文档,并在场景定义的某个地方输入以下代码:
Component {
id: coinGenerator
Coin {}
}
Timer {
id: coinTimer
interval: 1000
repeat: true
onTriggered: {
var cx = Math.floor(Math.random() * scene.width)
var cy = Math.floor(Math.random() * scene.height/3)
+ scene.height/2
coinGenerator.createObject(scene, { x: cx, y: cy});
}
}
发生了什么事?
首先,我们定义了一个新的元素类型,Coin,它由一个黄色圆圈组成,圆圈中心有一个数字覆盖在一个空的项目上。矩形应用了一个动画,使项目围绕垂直轴旋转,从而产生一个伪三维效果。
接下来,将能够创建 Coin 元素实例的组件放置在场景中。然后,声明一个每秒触发一次的 Timer 元素,在场景的随机位置生成一个新的硬币。
精灵动画
玩家角色以及游戏的任何其他组件都应该被动画化。如果组件使用简单的 Qt Quick 形状实现,通过流畅地更改项目的属性,使用属性动画(就像我们在 Coin 对象中所做的那样)来做这件事相当容易。如果组件足够复杂,以至于在图形程序中绘制它并使用游戏中的图像比尝试使用 Qt Quick 项目重新创建对象更容易,那么就需要一系列图像——每帧动画一个。为了制作令人信服的动画,图像必须不断相互替换。
行动时间——实现简单的角色动画
让我们尝试以简单的方式使玩家角色动画化。在本书附带的材料中,你会找到一些不同行走阶段的 Benjamin 大象的图像。你可以使用它们,或者你可以绘制或下载一些其他图像来替换我们提供的图像。
将所有图像放在一个目录中(例如,images),并将它们重命名,使它们遵循包含基本动画名称后跟帧编号的图案,例如,walking_01、walking_02、walking_03,依此类推。
接下来,打开 Player.qml 文档,将显示 "elephant.png" 的图像元素替换为以下代码:
Image {
property int currentFrame: 1
property int frameCount: 10
source: "images/walking_"+currentFrame+".png"
mirror: player.facingLeft
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
Animation on currentFrame {
from: 1
to: frameCount
loops: Animation.Infinite
duration: frameCount*40
running: player.walking
}
}
在 Player.qml 的根元素中添加以下属性:
property bool walking: flags.horizontal != 0
property bool facingLeft: flags.horizontal < 0
启动程序并使用箭头键查看本杰明移动。
发生了什么?
准备了一系列图像,遵循一个包含数字的通用命名模式。所有图像都具有相同的大小。这使得我们只需通过更改source属性的值来指向不同的图像,就可以用另一个图像替换一个图像。为了简化操作,我们引入了一个名为currentFrame的属性,它包含要显示的图像的索引。我们使用currentFrame元素在一个字符串表达式中绑定到图像的source元素。为了使帧替换更容易,我们声明了一个NumberAnimation元素,在循环中从1到可用的动画帧数(由frameCount属性表示)修改currentFrame元素的值,这样每帧就会显示 40 毫秒。如果walking属性评估为true(基于玩家对象中的flags.horizontal元素的值),则动画正在播放。最后,我们使用Image参数的mirror属性在角色向左行走时翻转图像。
前面的方法可行,但并不完美。当我们想要使运动动画更复杂(例如,如果我们想要引入跳跃)时,遵循此模式的声明复杂性增长速度远快于所需。但这并不是唯一的问题。加载图像并不立即发生。第一次使用某个特定图像时,动画可能会因为图形加载而暂停片刻,这可能会破坏用户体验。最后,对于每个图像动画,这里那里放一堆图片实在太过杂乱。
解决这个问题的方法是使用精灵——由小图像组合成的一个大图像的几何动画对象,这样可以提高性能。Qt Quick 通过其精灵引擎支持精灵,该引擎负责从精灵字段中加载图像序列,对它们进行动画处理,并在不同的精灵之间进行转换。
在 Qt Quick 中,精灵是 Qt 支持的任何类型的图像,它包含包含动画所有帧的图像条。后续帧应形成一个从左到右、从上到下连续的线条。然而,它们不必从包含图像的左上角开始,也不必在右下角结束——一个文件可以包含多个精灵。精灵通过提供单个帧的大小(以像素为单位)和帧数来定义。可选地,可以指定一个偏移量,从该偏移量读取精灵的第一个帧,该偏移量从左上角开始。以下图表可以帮助可视化该方案:

QML 提供了一个 Sprite 元素类型,它有一个 source 属性,指向容器图像的 URL,一个 frameWidth 和 frameHeight 元素确定每帧的大小,以及一个 frameCount 元素定义精灵中的帧数。通过设置 frameX 和 frameY 属性的值可以偏移图像。除此之外,还有一些额外的属性;其中最重要的三个是 frameRate、frameDuration 和 duration。所有这些属性都用于确定动画的速度。如果定义了 frameRate 元素,它被解释为每秒循环的帧数。如果没有定义此属性,则 frameDuration 元素生效,它被视为显示单帧的时间段(因此,它是 frameRate 元素的倒数)。如果没有定义此属性,则使用 duration 元素,它包含整个动画的持续时间。你可以设置这三个属性中的任何一个,优先级规则(frameRate、frameDuration、duration)将决定哪些属性将被应用。
行动时间 - 使用精灵来动画化角色
我们不再等待。当前的任务是将之前练习中的手动动画替换为基于精灵的动画。
打开 Player.qml 文档,移除负责显示玩家角色的整个图像元素:
AnimatedSprite {
id: sprite
source: "images/walking.png"
frameX: 560
frameY: 0
frameWidth: 80
frameHeight: 52
frameCount: 7
frameRate: 10
interpolate: true
width: frameWidth
height: frameHeight
running: player.walking
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
transform: Scale {
origin.x: sprite.width/2
xScale: player.facingLeft ? -1 : 1
}
}
刚才发生了什么?
我们已经将之前的静态图像替换为不断变化的源,使用不同的项目。由于 Sprite 参数不是一个 Item 元素,而是一个精灵的数据定义,我们无法将其用作 Image 元素的替代。相反,我们将使用 AnimatedSprite 元素,这是一个可以显示单个内联定义的动画精灵的项目。它甚至具有与 Sprite 参数相同的属性集。我们定义了一个嵌入在 images/walking.png 中的精灵,宽度为 80 像素,高度为 52 像素。该精灵由七个帧组成,应以每秒 10 帧的速度显示。running 属性的设置类似于原始的 Animation 元素。由于 AnimatedSprite 元素没有 mirror 属性,我们通过应用一个水*翻转项目的缩放变换来模拟它,如果 player.facingLeft 元素评估为 true。此外,我们设置 interpolate 属性为 true,这使得精灵引擎计算帧之间的更*滑的过渡。
我们留下的结果是类似于早期尝试的结果,所以如果这两个相似,为什么还要使用精灵呢?在许多情况下,你想要的动画比单个帧序列更复杂。如果我们想动画化 Benjamin 跳跃的方式,除了他走路之外呢?虽然可能嵌入更多的手动动画,但这会爆炸性地增加保持对象状态所需的内部变量数量。幸运的是,Qt Quick 精灵引擎可以处理这种情况。我们使用的 AnimatedSprite 元素只提供了整个框架功能的一个子集。通过用 SpriteSequence 元素替换项目,我们获得了精灵的全部功能。在谈论 Sprite 时,我们没有告诉你对象的一个附加属性,即名为 to 的属性,它包含从当前精灵到另一个精灵的转换概率映射。通过声明当前精灵迁移到的精灵,我们创建了一个具有加权转换到其他精灵的状态机,以及循环回当前状态。
通过在 SpriteSequence 对象上设置 goalSprite 属性来触发过渡到另一个精灵。这将导致精灵引擎遍历图直到达到请求的状态。这是一种通过经过多个中间状态来流畅地从一个动画切换到另一个动画的绝佳方式。
而不是要求精灵机优雅地过渡到某个给定状态,可以通过调用 SpriteSequence 类的 jumpTo() 方法并传入应开始播放的精灵名称来强制立即更改。
需要澄清的最后一件事是如何将精灵状态机实际附加到 SpriteSequence 类。这非常简单;只需将 Sprite 对象数组分配给 sprites 属性。
行动时间 – 添加带有精灵转换的跳跃
让我们在 Benjamin the Elephant 动画中将 AnimatedSprite 类替换为 SpriteSequence 类,添加一个在跳跃阶段要播放的精灵。
打开 Player.qml 文件,并用以下代码替换 AnimatedSprite 对象:
SpriteSequence {
id: sprite
width: 80
height: 52
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
currentSprite: "still"
running: true
Sprite {
name: "still"
source: "images/walking.png"
frameCount: 1
frameWidth: 80
frameHeight: 52
frameDuration: 100
to: {"still": 1, "walking": 0, "jumping": 0}
}
Sprite {
name: "walking"
source: "images/walking.png"
frameCount: 7
frameWidth: 80
frameHeight: 52
frameRate: 10
to: {"walking": 1, "still": 0, "jumping": 0}
}
Sprite {
name: "jumping"
source: "images/jumping.png"
frameCount: 11
frameWidth: 80
frameHeight: 70
frameRate: 4
to: { "still" : 1 }
}
transform: Scale {
origin.x: sprite.width/2
xScale: player.facingLeft ? -1 : 1
}
}
接下来,通过添加以下高亮更改来扩展 jumpAnim 对象:
SequentialAnimation {
id: jumpAnim
running: false
ScriptAction { script: sprite.goalSprite = "jumping" }
NumberAnimation {
target: player; property: "y"
to: player.parent.height-50; easing.type: Easing.OutQuad
}
NumberAnimation {
target: player; property: "y"
to: player.parent.height; easing.type: Easing.InQuad
}
ScriptAction {
script: { sprite.goalSprite = ""; sprite.jumpTo("still"); }
}
}
发生了什么?
我们所介绍的 The SpriteSequence 元素,其 Item 元素相关的属性设置方式与使用 AnimatedSprite 元素时相同。除此之外,一个名为 "still" 的精灵被明确设置为当前精灵。我们定义了一系列 Sprite 对象作为 SpriteSequence 元素的子元素。这相当于将这些精灵分配给对象的 sprites 属性。所声明的完整状态机在以下图中展示:

一个名为 "still" 的精灵仅有一个帧,代表本杰明不动的情景。由于加权转换回 "still" 状态,该精灵保持旋转在相同的状态。从该状态剩余的两个转换的权重被设置为 0,这意味着它们永远不会自发触发,但可以通过将 goalSprite 属性设置为可以通过激活这些转换之一到达的精灵来调用。
连续动画被扩展到当大象升空时触发精灵变化。
尝试一下英雄 – 让本杰明在期待中摇尾巴
为了练习精灵转换,你的目标是扩展本杰明 SpriteSequence 元素的状态机,使他在大象站立时摇尾巴。你可以在本书附带的材料中找到适当的精灵。精灵字段称为 wiggling.png。通过使其可能从 "still" 状态自发地转换到 "wiggling" 来实现此功能。注意确保动物在玩家激活左右箭头键的瞬间停止摇尾巴并开始行走。
透视滚动
我们已经在 第六章 图形视图 中讨论了有用的透视滚动技术。为了回顾,它通过以不同的速度移动背景的多个层,根据假设的层与观察者的距离,给 2D 游戏带来深度感。现在我们将看到在 Qt Quick 中应用相同技术是多么容易。
行动时间 – 回顾透视滚动
我们将使用一组与玩家移动方向相反移动的层来实现透视滚动。因此,我们需要定义场景和移动层。
创建一个新的 QML 文档(Qt Quick 2)。命名为 ParallaxScene.qml。场景将涵盖整个游戏 "级别" 并将玩家的位置暴露给移动层。在文件中放入以下代码:
import QtQuick 2.2
Item {
id: root
property int currentPos
x: -currentPos*(root.width-root.parent.width)/width
}
然后,创建另一个 QML 文档(Qt Quick 2),命名为 ParallaxLayer.qml。使其包含以下定义:
import QtQuick 2.2
Item {
property real factor: 0
x: factor > 0 ? -parent.currentPos/factor - parent.x : 0
}
现在,让我们在主 QML 文档中使用两种新的元素类型。我们将从早期的场景定义中提取元素,并将它们制作成不同的透视层——天空、树木和草地:
Rectangle {
id: view
width: 600
height: 380
ParallaxScene {
id: scene
width: 1500; height: 380
anchors.bottom: parent.bottom
currentPos: player.x
ParallaxLayer {
factor: 7.5
width: sky.width; height: sky.height
anchors.bottom: parent.bottom
Image { id: sky; source: "sky.png" }
}
ParallaxLayer {
factor: 2.5
width: trees.width; height: trees.height
anchors.bottom: parent.bottom
Image { id: trees; source: "trees.png" }
}
ParallaxLayer {
factor: 0
width: grass.width; height: grass.height
anchors.bottom: parent.bottom
Image { id: grass; source: "grass.png" }
}
Item {
id: player
// ...
}
}
}

发生了什么?
我们实现的ParallaxScene元素是一个移动*面。其水*偏移量取决于角色的当前位置和视图的大小。场景滚动的范围由场景大小和视图大小的差值决定——它说明了当角色从场景的左侧移动到右侧时,我们需要滚动多少,以便它始终在视图中。如果我们用场景宽度作为分数乘以角色与场景左侧的距离,我们将得到视图中的所需场景偏移量(或者说,场景的投影偏移量)。
第二种类型——ParallaxLayer也是一个移动*面。它定义了一个距离因子,表示背景层相对于前景层的相对距离(深度),这影响了*面相对于前景(场景)应该滚动多快。0的值意味着该层应该以与前景层完全相同的速度移动。值越大,层相对于角色的移动速度越慢。偏移值是通过将角色在场景中的位置除以因子来计算的。由于前景层也在移动,我们必须在计算每个视差层的偏移时考虑它。因此,我们减去场景的水*位置以获得实际的层偏移。
当层逻辑上定义后,我们可以将它们添加到场景中。每一层都有一个物理表示,在我们的例子中,是包含天空、树木和草地纹理的静态图像。每一层都是单独定义的,可以独立存在,包含静态和动画元素,这些元素对其他层没有影响。如果我们想要渲染一个从东向西移动的太阳,我们会将它放在天空层上,并从层的一边动画到另一边,持续时间较长。
尝试一下英雄 - 垂直视差滑动
作为额外的练习,你可能还想在水*视差滑动的基础上实现垂直视差滑动。只需使你的场景更大,并使其除了报告由currentPos元素报告的水*滚动位置外,还暴露垂直滚动位置。然后,只需重复对每一层的y属性的所有计算,你很快就能完成。记住,x和y的距离因子可能不同。
碰撞检测
Qt Quick 中没有内置的碰撞检测支持,但有三种提供此类支持的方法。首先,你可以使用在许多 2D 物理引擎中可用的现成碰撞系统,如 Box2D。其次,你可以自己用 C++实现一个简单的碰撞系统。最后,你可以通过比较对象坐标和边界框直接在 JavaScript 中执行碰撞检查。
我们的游戏非常简单;因此,我们将使用最后一种方法。如果我们游戏中涉及更多的移动对象,那么我们可能会选择第二种方法。如果你有一个可以旋转并与其他对象碰撞反弹的非矩形形状的对象,第一种方法是最好的。在这种情况下,有一个物理引擎在手变得非常有用。
行动时间 – 收集硬币
从 Qt Creator 的菜单中,访问 文件 | 新建文件或项目。从 Qt 文件和类,选择 JS 文件模板。将文件命名为 "collisions.js"。将以下内容放入文档中:
pragma library
function boundingBox(object1) {
var cR = object1.childrenRect
var mapped = object1.mapToItem(object1.parent, cR.x, cR.y, cR.width, cR.height)
return Qt.rect(mapped.x, mapped.y, mapped.width, mapped.height)
}
function intersect(object1, object2) {
var r1 = boundingBox(object1)
var r2 = boundingBox(object2)
return (r1.x <= r2.x+r2.width && // r1.left <= r2.right
r2.x <= r1.x+r1.width && // r2.left <= r1.right
r1.y <= r2.y+r2.height && // r1.top <= r2.bottom
r2.y <= r1.y+r1.height) // r2.top <= r1.bottom
}
创建另一个 JS 文件并命名为 "coins.js"。输入以下内容:
import "collisions.js"
var coins = []
coins.collisionsWith = function(player) {
var collisions = []
for(var index = 0; index < length; ++index) {
var obj = this[index]
if(intersect(player, obj)) collisions.push(obj)
}
return collisions
}
coins.remove = function(obj) {
var arr = isArray(obj) ? obj : [ obj ]
var L = arr.length
var idx, needle
while(L && this.length) {
var needle = arr[--L]
idx = this.indexOf(needle)
if(idx != -1) { this.splice(idx, 1) }
}
return this
}
最后,打开主文档并添加以下 import 语句:
import "coins.js"
在玩家对象中,定义 checkCollisions() 函数:
function checkCollisions() {
var result = coins.collisionsWith(player)
if(result.length == 0) return
result.forEach(function(coin) { coin.hit() })
coins.remove(result) // prevent the coin from being hit again
}
最后,在同一个玩家对象中,通过处理玩家位置的变化来触发碰撞检测:
onXChanged: { checkCollisions() }
onYChanged: { checkCollisions() }
在 Coin.qml 文件中,定义一个动画和一个 hit() 函数:
SequentialAnimation {
id: hitAnim
running: false
NumberAnimation {
target: coin
property: "opacity"
from: 1; to: 0
duration: 250
}
ScriptAction {
script: coin.destroy()
}
}
function hit() {
hitAnim.start()
}
发生了什么?
文件 collisions.js 包含用于执行碰撞检查的函数。文件的第一行是一个 pragma 语句,指出该文档只包含函数,不包含任何可变对象。这样我们就可以添加一个 .pragma library 语句,将文档标记为可以由导入它的文档共享的库。这有助于减少内存消耗并提高速度,因为引擎不需要每次导入时都重新解析和执行文档。
库中定义的函数非常简单。第一个函数根据对象的坐标和子元素的大小返回对象的边界矩形。它假设顶级项为空,并包含代表对象视觉方面的子元素。子元素坐标使用 mapToItem 元素映射,以便返回的矩形以父项坐标表示。第二个函数对两个边界矩形之间的交集进行简单检查,如果它们相交则返回 true,否则返回 false。
第二个文档保持了一个硬币数组的定义。它向数组对象添加了两个方法。第一个方法——collisionsWith——使用在 collisions.js 中定义的函数在数组中的任何项和给定的对象之间执行碰撞检查。这就是为什么我们在文档开头导入库的原因。该方法返回另一个包含与 player 参数相交的对象的数组。另一个方法,称为 remove,接受一个对象或对象数组,并将它们从 coins 中移除。
该文档不是一个库;因此,每个导入 coins.js 的文档都会得到该对象的一个单独副本。因此,我们需要确保在游戏中只导入一次 coins.js,以便该文档中定义的对象的所有引用都与程序内存中对象的同一实例相关联。
我们的主要文档导入了coins.js,它创建了一个用于存储硬币对象的数组,并使其辅助函数可用。这使得定义的checkCollisions()函数能够检索与玩家碰撞的硬币列表。对于每个与玩家碰撞的硬币,我们执行一个hit()方法;作为最后一步,所有碰撞的硬币都会从数组中移除。由于硬币是静止的,碰撞只能在玩家角色进入硬币占据的区域时发生。因此,当玩家角色的位置改变时触发碰撞检测就足够了——我们使用onXChanged和onYChanged处理程序。
由于击中硬币会导致其从数组中移除,我们失去了对该对象的引用。hit()方法必须启动从场景中移除对象的过程。这个函数的最简实现可能只是调用对象的destroy()函数,但我们做得更多——通过在硬币上运行淡出动画可以使移除过程更加*滑。作为最后一步,动画可以销毁对象。
碰撞检测的注意事项
我们在场景中追踪的对象数量非常少,并且我们将每个对象的形状简化为矩形。这使得我们可以通过 JavaScript 来检查碰撞。对于大量移动对象、自定义形状和旋转处理,拥有一个基于 C++的碰撞系统会更好。这样一个系统的复杂程度取决于你的需求。
眼睛的甜点
一款游戏不应该仅仅基于一个有趣的想法;它不仅应该在各种设备上流畅运行,为玩家提供娱乐,还应该看起来很漂亮,表现得很优雅。无论是从同一游戏的多个类似实现中选择,还是愿意为另一个价格相似且有趣的类似游戏花钱,有很大可能性,玩家选择的游戏将是看起来最好的那款——拥有大量的动画、图形和闪亮的元素。我们已经学习了许多使游戏更吸引眼球的技巧,例如使用动画或 GLSL 着色器。在这里,我们将向您展示一些其他可以使您的 Qt Quick 应用程序更具吸引力的技巧。
自动缩放的用户界面
你可能首先实现的功能是让你的游戏自动调整到它正在运行的设备分辨率。基本上有两种方法可以实现这一点。第一种是在窗口(或屏幕)中居中放置用户界面,如果它不合适,则启用滚动。另一种方法是缩放界面,使其始终适合窗口(或屏幕)。选择哪种方法取决于许多因素,其中最重要的是你的 UI 在放大时是否足够好。如果界面由文本和非图像原语(基本上是矩形)组成,或者如果它包括图像但只有矢量图像或非常高分辨率的图像,那么尝试缩放用户界面可能是可以的。否则,如果你使用了大量低分辨率的位图图像,你将不得不为 UI 选择一个特定的尺寸(可选地允许它降级,因为如果你启用抗锯齿,质量下降应该在这个方向上不那么明显)。
无论你选择缩放还是居中并滚动,基本方法都是相同的——你将你的 UI 元素放入另一个元素中,这样无论顶级窗口发生什么变化,你都可以精细控制 UI 几何形状。采用居中方法相当简单——只需将 UI 锚定到父元素的中央。要启用滚动,将 UI 包装在 Flickable 元素中,并如果窗口的大小不足以容纳整个用户界面,则约束其大小:
Item {
id: window
Flickable {
id: uiFlickable
anchors.centerIn: parent
contentWidth: ui.width; contentHeight: ui.height
width: parent.width >= contentWidth ? contentWidth : parent.width
height: parent.height >= contentHeight ? contentHeight : parent.height
UI { id: ui }
}
}
如果 UI 元素没有占据其父元素的全部区域,你可能需要用漂亮的背景装饰顶级元素。
缩放看起来可能更复杂,但使用 Qt Quick 真的非常简单。再次强调,你有两个选择——要么拉伸,要么缩放。拉伸就像执行 anchors.fill: parent 命令一样简单,这个命令有效地迫使 UI 重新计算所有项的几何形状,但可能使我们能够更有效地使用空间。通常,对于开发者来说,在视图大小变化时为用户界面中的每个元素提供计算几何形状的表达式是非常耗时的。这通常不值得付出努力。一个更简单的方法是将 UI 元素缩放到适合窗口,这将隐式地缩放包含的元素。在这种情况下,它们的大小可以相对于用户界面主视图的基本大小来计算。为了使这可行,你需要计算应用于用户界面的缩放比例,使其填满整个可用空间。当元素的有效宽度等于其隐式宽度,以及其有效高度等于其隐式高度时,该元素的缩放比例为 1。如果窗口更大,我们希望将元素缩放到达到窗口大小。因此,窗口宽度除以元素的隐式宽度将是元素在水*方向上的缩放比例。这将在以下图中展示:

同样的方法也适用于垂直方向,但如果 UI 的宽高比与窗口不同,其水*和垂直缩放因子也会不同。为了使 UI 看起来更美观,我们必须取两个值中较小的一个——只将方向扩展到较少空间允许的程度,在另一个方向上留下空白:
Item {
id: window
UI {
id: ui
anchors.centerIn: parent
scale: Math.min(parent.width/width, parent.height/height)
}
}
再次,给窗口项添加一些背景以填补空白可能是个好主意。
如果你想在用户界面和窗口之间保留一些边距怎么办?当然,你可以在计算缩放时考虑这一点(例如(window.width-2*margin)/width等),但有一个更简单的方法——只需在窗口内放置一个额外的项,留下适当的边距,并将用户界面项放在该额外项中,并将其缩放到额外项的大小:
Item {
id: window
Item {
anchors { fill: parent; margins: 10 }
UI {
id: ui
anchors.centerIn: parent
scale: Math.min(parent.width/width, parent.height/height)
}
}
}
当你大量缩放元素时,你应该考虑为那些在渲染为不同于其原始大小的尺寸时可能会失去质量的项启用抗锯齿(例如,图像)。在 Qt Quick 中,这非常容易,因为每个Item实例都有一个名为antialiasing的属性,当启用时,将导致渲染后端尝试减少由抗锯齿效果引起的失真。记住,这会带来渲染复杂性的增加,因此请尝试在质量和效率之间找到*衡,尤其是在低端硬件上。你可能可以为用户提供一个选项,全局启用或禁用所有游戏对象的抗锯齿,或者为不同类型的对象逐渐调整质量设置。
图形效果
Qt Quick 中的基本两个预定义项是矩形和图像。人们可以用各种创造性的方式使用它们,并通过应用 GLSL 着色器使它们看起来更愉快。然而,从头开始实现着色器程序是繁琐的,并且需要深入了解着色器语言。幸运的是,许多常见效果已经实现,并以QtGraphicalEffects模块的形式准备好使用。
要给在HeartBeat.qml文件中定义的基于画布的心跳元素添加微妙的黑色阴影,可以使用类似于以下代码的代码,该代码利用了DropShadow效果:
import QtQuick 2.0
import QtGraphicalEffects 1.0
Item {
width: 1000; height: 600
HeartBeat { id: hb; anchors.centerIn: parent; visible: false }
DropShadow {
source: hb
anchors.fill: hb
horizontalOffset: 3
verticalOffset: 3
radius: 8
samples: 16
color: "black"
}
}
要应用阴影效果,你需要一个现有的项作为效果源。在我们的例子中,我们使用了一个HeartBeat类的实例,它位于顶级项的中心。然后,定义阴影效果,并使用anchors.fill元素使其几何形状跟随其源。就像DropShadow类渲染原始项及其阴影一样,可以通过将原始项的visible属性设置为false来隐藏原始项。

大多数 DropShadow 类的属性都是不言自明的,但有两个属性——radius 和 samples——需要一些额外的解释。阴影是以给定位置偏移的原始项目的模糊单色副本的形式绘制的。这两个提到的属性控制模糊的程度和质量——用于模糊的样本越多,效果越好,但同时也需要执行的计算量也越大。
说到模糊,纯模糊效果在图形效果模块中也是通过 GaussianBlur 元素类型提供的。要将模糊效果应用于上一个示例而不是阴影,只需将 DropShadow 类的实例替换为以下代码:
GaussianBlur {
source: hb
anchors.fill: hb
radius: 12
samples: 20
transparentBorder: true
}

在这里,你可以看到前面提到的两个属性以及一个名称模糊的 transparentBorder 属性。启用此属性可以修复模糊边缘的一些伪影,并且通常你希望保持这种方式。
尝试一下英雄——模糊视差滚动游戏视图
blur 属性是一个非常棒的效果,可以在许多情况下使用。例如,你可以在我们的象形游戏中尝试实现一个功能,当用户暂停游戏时(例如,通过按键盘上的 P 键),视图会变得模糊。通过应用动画到效果属性的 radius 上,使效果*滑。
另一个有趣的效果是 Glow。它渲染源元素的彩色和模糊副本。对于游戏的一个用例是突出显示用户界面的某些部分——你可以通过使元素周期性地闪烁来引导用户的注意力(例如,按钮或徽章):
Badge {
id: importantBadge
}
Glow {
source: importantBadge
anchors.fill: source
samples: 16
color: "red"
SequentialAnimation on radius {
loops: Animation.Infinite
running: true
NumberAnimation { from: 0; to: 10; duration: 2000 }
PauseAnimation { duration: 1000 }
NumberAnimation { from: 10; to: 0; duration: 2000 }
PauseAnimation { duration: 1000 }
}
}
完整模块包含 20 种不同的效果。我们无法在这里详细描述每种效果。不过,你可以自己了解它们。如果你在克隆的模块源 git 仓库(位于克隆仓库的 tests/manual/testbed 子目录下)中克隆了模块,你将找到一个用于测试现有效果的不错应用程序。要运行此工具,请使用 qmlscene 打开 testBed.qml 文件。

小贴士
你也可以通过导航到文档中的 GraphicalEffects 帮助页面来访问完整的效应列表及其简短描述。
粒子系统
在游戏等系统中,常用的视觉效果是生成大量小型、通常短暂、通常快速移动、模糊的对象,如星星、火花、烟雾、灰尘、雪、碎片、落叶等。将这些对象作为场景中的常规项目放置会大大降低性能。相反,使用一个特殊的引擎来维护此类对象的注册表,并跟踪(模拟)它们的逻辑属性,而不在场景中具有物理实体。这些称为粒子的对象,在请求时使用非常高效的算法在场景中进行渲染。这允许我们使用大量粒子,而不会对场景的其他部分产生负面影响。
Qt Quick 在 QtQuick.Particles 导入中提供了一个粒子系统。ParticleSystem 元素提供了模拟的核心,它使用 Emitter 元素来生成粒子。然后根据 ParticlePainter 元素中的定义进行渲染。可以使用 Affector 对象来操纵模拟实体,这些对象可以修改粒子的轨迹或生命周期。
让我们从简单的例子开始。以下代码片段声明了最简单的粒子系统:
import QtQuick 2.0
import QtQuick.Particles 2.0
ParticleSystem {
id: particleSystem
width: 360; height: 360
Emitter { anchors.fill: parent }
ImageParticle { source: "star.png" }
}
结果可以在以下图像中观察到:

让我们分析一下代码。在导入 QtQuick.Particles 2.0 之后,实例化了一个 ParticleSystem 项目,它定义了粒子系统的域。我们在该系统中定义了两个对象。第一个对象是 Emitter,它定义了粒子生成的区域。该区域设置为包含整个域。第二个对象是 ImageParticle 类型的对象,它是 ParticlePainter 子类的实例。它确定粒子应以给定图像的实例进行渲染。默认情况下,Emitter 对象每秒生成 10 个粒子,每个粒子存活一秒后死亡并被从场景中移除。在所提供的代码中,Emitter 和 ImageParticle 对象是 ParticleSystem 类的直接子代;然而,这不必是这种情况。可以通过设置 system 属性显式指定粒子系统。
调整发射器
您可以通过设置发射器的 emitRate 属性来控制发射的粒子数量。另一个名为 lifeSpan 的属性决定了粒子死亡前需要多少毫秒。为了引入一些随机行为,您可以使用 lifeSpanVariation 属性来设置系统可以改变生命周期(在两个方向上)的最大时间(以毫秒为单位)。增加粒子的发射率和生命周期可能导致需要管理(和可能渲染)的粒子数量非常大。这可能会降低性能;因此,可以通过 maximumEmitted 属性设置可以同时存在的粒子的上限:
ParticleSystem {
id: particleSystem
width: 360; height: 360
Emitter {
anchors.fill: parent
emitRate: 350
lifeSpan: 1500
lifeSpanVariation: 400 // effective: 1100-1900 ms
}
ImageParticle { source: "star.png" }
}

调整粒子的生命周期可以使系统更加多样化。为了增强效果,您还可以通过 size 和 sizeVariation 属性来操作每个粒子的尺寸:
ParticleSystem {
id: particleSystem
width: 360; height: 360
Emitter {
anchors.fill: parent
emitRate: 50
size: 12
sizeVariation: 6
endSize: 2
}
ImageParticle { source: "star.png" }
}

到目前为止展示的功能范围应该足够创建许多看起来不错且实用的粒子系统。到目前为止的限制是粒子从发射器的整个区域发射出来,这是一个常规的 QQuickItem,因此是矩形的。但这不必是这种情况。Emitter 元素包含一个 shape 属性,这是一种声明要产生粒子的区域的方式。QtQuick.Particles 参数定义了三种可用的自定义形状类型——EllipseShape、LineShape 和 MaskShape。前两种非常简单,定义了在项目内绘制的空椭圆或填充椭圆,或者穿过项目两条对角线之一的线。MaskShape 元素更有趣,因为它使得可以使用图像作为 Emitter 元素的形状。
ParticleSystem {
id: particleSystem
width: 360; height: 360
Emitter {
anchors.fill: parent
emitRate: 1600
shape: MaskShape { source: "star.png" }
}
ImageParticle { source: "star.png" }
}

渲染粒子
到目前为止,我们使用裸露的 ImageParticle 元素来渲染粒子。它只是三种 ParticlePainters 之一,其他两种是 ItemParticle 和 CustomParticle。但在我们继续探讨其他渲染器之前,让我们专注于调整 ImageParticle 元素以获得一些有趣的效果。
ImageParticle 元素将每个逻辑粒子渲染为图像。可以通过改变颜色和旋转、变形形状或将其用作精灵动画来分别对每个粒子进行操作。
要影响粒子的颜色,您可以使用大量专用属性中的任何一个——alpha、color、alphaVariation、colorVariation、redVariation、greenVariation 和 blueVariation。前两个属性定义了相应属性的基值,其余属性设置相应参数从基值出发的最大偏差。在透明度的情况下,您只能使用一种类型的偏差,但在定义颜色时,您可以为红色、绿色和蓝色通道设置不同的值,或者您可以使用全局的 colorVariation 属性,这类似于为所有三个通道设置相同的值。允许的值在 0(不允许偏差)到 1.0(任一方向 100%)之间。

提到的属性是静态的——粒子在其整个生命周期中遵循恒定值。ImageParticle 元素还公开了两个属性,让您可以控制粒子相对于其年龄的颜色。首先,有一个名为 entryEffect 的属性,它定义了粒子在其诞生和死亡时会发生什么。默认值是 Fade,这使得粒子在其生命开始时从 0 不透明度淡入,并在它们死亡前恢复到 0 不透明度。您已经在所有之前展示的粒子动画中体验过这种效果。该属性的其它值是 None 和 Scale。第一个值很明显——与粒子无关的进入效果。第二个值在粒子诞生时从 0 开始缩放,并在生命结束时缩放回 0。
另一个与时间相关的属性是 colorTable。您可以向其中提供用作确定粒子在其生命周期中颜色的单维纹理的图像的 URL。一开始,粒子由图像的左侧边缘定义颜色,然后以线性方式向右移动。在这里设置包含颜色渐变的图像以实现颜色之间的*滑过渡是最常见的做法。
可以改变的第二个参数是粒子的旋转。在这里,我们既可以使用定义旋转常量值(rotation 和 rotationVariation)的属性,这些属性以度为单位指定,也可以使用 rotationVelocity 和 rotationVelocityVariation 在时间上修改粒子的旋转。速度定义了每秒旋转的度数。
粒子还可以变形。xVector 和 yVector 属性允许绑定向量,这些向量定义了水*和垂直轴上的扭曲。我们将在下一节中描述如何设置向量。最后但同样重要的是,使用 sprites 属性,您可以定义一个将用于渲染粒子的精灵列表。这与本章早期部分中描述的 SpriteAnimation 的工作方式类似。
使粒子移动
除了淡入淡出和旋转之外,我们迄今为止看到的粒子系统都非常静态。虽然这对于制作星系很有用,但对于爆炸、火花甚至下雪来说却毫无用处。这是因为粒子主要关于运动。在这里,我们将向您展示使您的粒子飞行的两个方面。
第一个方面是模拟粒子的诞生方式。这意味着创建粒子的物体的物理条件。在爆炸过程中,物质以非常大的力量从震中推开,导致空气和小物体以极高的速度向外冲出。火箭发动机的烟雾以与推进器相反的方向以高速喷射。移动的彗星会拖着一缕尘埃和气体,这些尘埃和气体是由惯性引起的。
所有这些条件都可以通过设置粒子的速度或加速度来建模。这两个指标由确定给定数量方向和数量(大小或长度)的向量的向量来描述。在 Qt Quick 中,这样的向量由一个称为StochasticDirection的元素类型表示,其中向量的尾部连接到对象,而头部位置由StochasticDirection实例计算。由于我们没有设置粒子属性的方法,因为我们没有代表它们的对象,所以这两个属性——velocity和acceleration——应用于产生粒子的发射器。因为你可以在一个粒子系统中有很多发射器,所以你可以为不同来源的粒子设置不同的速度和加速度。
有四种不同类型的方向元素,代表关于方向的不同信息来源。首先,有CumulativeDirection,它作为其他方向类型的容器,并像包含在内的方向的总和一样工作。
然后,有PointDirection,在这里你可以指定向量头部应该连接的点的x和y坐标。为了避免所有粒子朝同一方向的不现实效果,你可以指定xVariation和yVariation来引入从给定点的允许偏差。

第三种类型是最受欢迎的随机方向类型——AngleDirection,它直接指定向量的角度(从正右方向顺时针计算)和大小(每秒像素数)。角度可以从基础值通过angleVariation变化,同样,magnitudeVariation可以用来引入向量大小的变化:

最后一种类型与前面的一种类似。TargetDirection向量可以用来将向量指向给定 Qt Quick 项的中心(通过targetItem属性设置)。向量的长度通过给出magnitude和magnitudeVariation来计算,两者都可以解释为每秒像素数或源点和目标点之间距离的倍数(取决于proportionalMagnitude属性的值):

让我们回到设置粒子速度。我们可以使用AngleDirection向量来指定粒子应该向左移动,以最大 45 度的角度扩散:
Emitter {
anchors.centerIn: parent
width: 50; height: 50
emitRate: 50
velocity: AngleDirection {
angleVariation: 45
angle: 180
magnitude: 200
}
}

设置加速度的工作方式相同。你甚至可以设置每个粒子应该具有的初始速度和加速度。向左发射粒子并开始向下拉是非常容易的:
Emitter {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
emitRate: 15
lifeSpan: 5000
velocity: AngleDirection {
angle: 180
magnitude: 200
}
acceleration: AngleDirection {
angle: 90 // local left = global down
magnitude: 100
}
}

Emitter元素有一个在移动粒子上下文中很有用的额外属性。将velocityFromMovement参数设置为不同于0的值,会使Emitter元素的任何移动都应用于粒子的速度。附加向量的方向与发射器的移动方向相匹配,其大小设置为发射器速度乘以设置为velocityFromMovement的值。这是一种生成火箭发动机喷出的烟雾的绝佳方式:
Item {
Image {
id: image
source: "rocket.png"
}
Emitter {
anchors.right: image.right
anchors.verticalCenter: image.verticalCenter
emitRate: 500
lifeSpan: 3000
lifeSpanVariation: 1000
velocityFromMovement: -20
velocity: AngleDirection {
magnitude: 100
angleVariation: 40
}
}
NumberAnimation on x {
...
}
}

处理粒子行为的第二种方式是在粒子出生后影响它们的属性——在任何特定的生活时刻。这可以通过使用影响者来实现。这些是继承影响者的项目,可以修改正在通过影响者区域的粒子的某些属性。最简单的影响者之一是Age。它可以将粒子推进到它们生命中的某个点,此时它们只剩下lifeLeft毫秒的生命。
Age {
once: true
lifeLeft: 500
shape: EllipseShape { fill: true }
anchors.fill: parent
}
将once设置为true使得每个影响者只对给定的粒子产生一次影响。否则,每个粒子可以多次修改其属性。
另一种影响类型是重力,它可以在给定角度加速粒子。摩擦可以减慢粒子的速度,而吸引物将影响粒子的位置、速度或加速度,使其开始向一个特定点移动。漫游非常适合模拟雪花或蝴蝶以伪随机方向飞行的场景。
还有其他类型的影响者可用,但在这里我们不会详细介绍。然而,我们想提醒您,不要过度使用影响者——它们可能会严重降低性能。
行动时间——消失的硬币产生粒子
现在是时候练习我们新获得的本领了。任务是当玩家收集硬币时,向游戏中添加粒子效果。当收集到硬币时,硬币会爆炸成一片五彩缤纷的星星。
首先,声明一个粒子系统,使其填充游戏场景,以及粒子画家定义:
ParticleSystem {
id: coinParticles
anchors.fill: parent // scene is the parent
ImageParticle {
source: "particle.png"
colorVariation: 1
rotationVariation: 180
rotationVelocityVariation: 10
}
}
接下来,修改硬币的定义以包括一个发射器:
Emitter {
id: emitter
system: coinParticles
emitRate: 0
lifeSpan: 500
lifeSpanVariation: 100
velocity: AngleDirection { angleVariation: 180; magnitude: 10 }
acceleration: AngleDirection { angle: 270; magnitude: 2 }
}
最后,必须更新击中函数:
function hit() {
emitter.burst(50)
hitAnim.start()
}
刚才发生了什么?
在这个练习中,我们定义了一个简单的粒子系统,它填充了整个场景。我们为粒子定义了一个简单的图像画家,其中我们允许粒子采用所有颜色并在所有可能的旋转中开始。我们使用星形像素图作为粒子模板。
然后,将Emitter对象附加到每个硬币上。其emitRate设置为0,这意味着它不会自行发射任何粒子。我们为粒子设置了不同的生存期,并通过设置它们的初始速度,在两个方向上设置角度变化为 180 度(总共 360 度),使它们向所有方向飞行。通过设置加速度,我们给粒子一个向场景底部边缘移动的倾向。
在击中函数中,我们在发射器上调用一个 burst() 函数,这使得它能够立即产生一定数量的粒子。
摘要
在本章中,我们向您展示了如何扩展您的 QML 技能,使您的应用程序动态且吸引人。我们回顾并改进了之前用 C++ 创建的游戏,让您熟悉碰撞检测、状态驱动对象和时间驱动的游戏循环等概念。我们还向您介绍了一种名为 ShaderEffect 的工具,它可以作为创建令人惊叹的图形而不牺牲性能的手段,并教会您使用粒子系统。
当然,Qt Quick 比所有这些都要丰富得多,但我们不得不在某处停下来。我们希望传授给您的技能集应该足够开发许多优秀的游戏。然而,许多元素具有比我们在这里描述的更多的属性。每当您想要扩展您的技能时,您都可以查看参考手册,看看元素类型是否有更多有趣的属性。
这就结束了我们关于使用 Qt 进行游戏编程的书籍。我们向您介绍了 Qt 的一般基础知识,描述了其小部件领域,并引入了 Qt Quick 的迷人世界。小部件(包括图形视图)和 Qt Quick 是您在用 Qt 框架创建游戏时可以采取的两种路径。我们还向您展示了如何通过利用您可能拥有的任何 OpenGL 技能来合并这两种方法,超越 Qt 当前的提供。此时,您应该开始尝试和实验,如果在任何时刻您感到迷茫或只是缺乏如何做某事的信息,那么非常有帮助的 Qt 参考手册应该是您首先指向的资源。
祝您好运,玩得开心!
附录 A. 突击测验答案
第三章,Qt GUI 编程
突击测验 – 建立信号-槽连接
| Q1 | 一个槽 |
|---|---|
| Q2 | connect(sender, SIGNAL(toggled(bool)), receiver, SLOT(clear())); 和 connect(sender, &QPushButton::clicked, receiver, &QLineEdit::clear); |
突击测验 – 使用小部件
| Q1 | sizeHint |
|---|---|
| Q2 | QVariant |
| Q3 | 它代表用户可以在程序中调用的功能。 |
第四章,Qt 核心基础
突击测验 – Qt 核心基础
| Q1 | QString |
|---|---|
| Q2 | ((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.|$)){4} |
| Q3 | XML |
第六章,图形视图
突击测验 – 掌握图形视图
| Q1 | 例如,你应该知道有一个 QGraphicsSimpleTextItem,你可以用它来绘制简单的文本,在这些情况下你不需要自己处理 QPainter。你还应该知道,如果你有一个包含粗体字符的更复杂的文本,你可以使用 QGraphicsTextItem,它能够处理富文本。 |
|---|---|
| Q2 | 这些问题的正确答案涉及不同系统的原点。 |
| Q3 | 注意,QObject 并不仅限于 "小部件" 的世界。你还可以用它与项目一起使用。 |
| Q4 | 正确答案的关键词是视差滚动。 |
| Q5 | 正确答案将考虑你如何控制缓存以及如何影响在请求更新时实际重绘的视图部分。 |
第七章,网络
突击测验 – 测试你的知识
| Q1 | QNetworkAccessManager、QNetworkRequest 和 QNetworkReply。 |
|---|---|
| Q2 | 必须使用 QNetworkRequest::setRawHeader() 并设置适当的 HTTP 头字段 "Range"。 |
| Q3 | QUrlQuery |
| Q4 | 必须使用 deleteLater() 而不是 delete。 |
| Q5 | 它们都继承自 QAbstractSocket,而 QAbstractSocket 继承自 QIODevice。QIODevice 本身也是 QFile 的基类。因此,文件和套接字的处理有很多共同之处。因此,你不必学习第二个(复杂的)API 只是为了与套接字通信。 |
| Q6 | QUdpSocket |
第八章,脚本
突击测验 – 脚本
| Q1 | QScriptEngine::evaluate() |
|---|---|
| Q2 | QScriptValue |
| Q3 | PyValue |
| Q4 | 它们包含函数调用中定义的所有变量,因此可以从脚本中修改一组变量,而不会影响全局环境(称为沙盒)。 |
第十一章,杂项和高级概念
突击测验 – 测试你的知识
| Q1 | 后缀是 Reading,例如,QRotationReading。 |
|---|---|
| Q2 | 命名为 QSensorGestureRecognizer 的类。 |
| Q3 | 这是 Qt 定位模块,你可以通过在项目文件中添加 QT += positioning 来激活它。 |
| Q4 | 必须重载 QDebug& operator<<() |
| Q5 | 只有在程序以调试模式构建时,如果 condition 为 false,它才会终止程序的执行。 |





浙公网安备 33010602011771号