QT5-游戏编程初学者指南第二版-全-
QT5 游戏编程初学者指南第二版(全)
原文:
zh.annas-archive.org/md5/07f193a0a5e288bdcb9ff0464d494cf1译者:飞龙
前言
作为所有重要桌面、移动和嵌入式*台的主要跨*台工具包,Qt 正变得越来越受欢迎。本书将帮助你学习 Qt 的细节,并为你提供构建应用程序和游戏的必要工具集。本书旨在作为入门指南,将 Qt 新手从基础,如对象、核心类、小部件和 5.9 版本的新特性,引导到能够使用 Qt 的最佳实践创建自定义应用程序的水*。
从简要介绍如何创建应用程序并为桌面和移动*台准备工作环境开始,我们将深入探讨创建图形界面和 Qt 数据处理和显示的核心概念。随着你通过章节的进展,你将学会通过实现网络连接和采用脚本丰富你的游戏。深入研究 Qt Quick、OpenGL 和其他工具,以添加游戏逻辑、设计动画、添加游戏物理、处理游戏手柄输入以及为游戏构建惊人的用户界面。本书的后期,你将学会利用移动设备功能,如传感器和地理位置服务,来构建引人入胜的用户体验。
本书面向的对象
本书对具有 C++基本知识的程序员和应用程序及 UI 开发者来说,既有趣又实用。此外,Qt 的一些部分允许你使用 JavaScript,因此对该语言的基本了解也将有所帮助。不需要有 Qt 的先前经验。拥有最多一年 Qt 经验的开发者也将从本书涵盖的主题中受益。
为了最大限度地利用本书
在开始使用本书之前,你不需要拥有或安装任何特定的软件。一个常见的 Windows、Linux 或 MacOS 系统就足够了。第二章,安装,包含了如何下载和设置所需所有内容的详细说明。
在本书中,你将发现一些频繁出现的标题:
-
行动时间部分包含了完成一个程序或任务的具体指导。
-
发生了什么?部分解释了你刚刚完成的任务或指令的工作原理。
-
尝试英雄部分包含了一些实际挑战,这些挑战能给你提供实验你所学内容的灵感。
-
快速问答部分包含了一些简短的单选题,旨在帮助你测试自己的理解。你将在本书的末尾找到答案。
在阅读章节时,您将看到多个游戏和其他项目,以及如何创建它们的详细描述。我们建议您尝试使用我们提供的说明自己创建这些项目。如果在任何时候您在遵循说明或不知道如何执行某个步骤时遇到困难,您应该查看示例代码文件以了解如何操作。然而,学习最重要的和最激动人心的部分是决定您想要实现什么,然后找到实现它的方法,因此请注意“英雄试炼”部分或考虑您自己的方法来改进每个项目。
下载示例代码文件
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
下载文件后,请确保使用最新版本的以下软件解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Game-Programming-Using-Qt-5-Beginners-Guide-Second-Edition。我们还有其他丰富的图书和视频代码包可供选择,网址为github.com/PacktPublishing/。请查看它们!
约定使用
本书使用了多种文本约定。
CodeInText:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“此 API 以QNetworkAccessManager为中心,该管理器处理您的游戏与互联网之间的完整通信。”
代码块设置如下:
QNetworkRequest request;
request.setUrl(QUrl("http://localhost/version.txt"));
request.setHeader(QNetworkRequest::UserAgentHeader, "MyGame");
m_manager->get(request);
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
void FileDownload::downloadFinished(QNetworkReply *reply) {
const QByteArray content = reply->readAll();
_edit->setPlainText(content);
reply->deleteLater();
}
粗体:表示新术语、重要词汇或屏幕上看到的词汇。例如,菜单或对话框中的文字如下所示。以下是一个示例:“在“选择目标位置”屏幕上,点击“下一步”以接受默认目标。”
警告或重要提示如下所示。
小贴士和技巧如下所示。
联系我们
我们欢迎读者的反馈。
一般反馈:请发送邮件至feedback@packtpub.com,并在邮件主题中提及书名。如果您对本书的任何方面有疑问,请发送邮件至questions@packtpub.com。
勘误:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击“勘误提交表单”链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何非法副本,我们将非常感激您能提供位置地址或网站名称。请通过链接至材料的方式与我们联系至copyright@packtpub.com。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评价
请留下您的评价。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 公司可以了解您对我们产品的看法,并且我们的作者可以查看他们对书籍的反馈。谢谢!
如需了解更多关于 Packt 的信息,请访问 packtpub.com。
第一章:Qt 简介
在本章中,你将了解 Qt 是什么以及它是如何演变的。我们将描述 Qt 框架的结构及其版本之间的差异。最后,你将学习如何决定哪种 Qt 许可方案适合你的项目。
本章涵盖的主要主题包括:
-
Qt 历史
-
支持的*台
-
Qt 框架结构
-
Qt 版本
-
Qt 许可
时光之旅
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 许可下使用 Qt 为 Unix 和 Mac 的选项。然而,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 已经在市场上存在了七年,显然必须发布 Qt 的另一个主要版本。决定通过允许任何人向项目贡献来吸引更多工程师。诺基亚于 2011 年成立的 Qt 项目为代码审查提供了基础设施,并引入了开放治理模式,允许外部开发者参与决策。
诺基亚未能完成 Qt 5.0 的开发工作。由于 2011 年诺基亚意外转向不同技术的转变,Qt 部门在 2012 年中被出售给了芬兰公司 Digia,该公司成功完成了这项工作,并在同年 12 月发布了 Qt 5.0,这是一个完全重构的框架。虽然 Qt 5.0 引入了许多新功能,但它与 Qt 4 大部分兼容,允许开发者无缝迁移到新的大版本。
2014 年,Digia 成立了 Qt 公司,现在负责 Qt 的开发、商业化和许可。所有散布在 Qt 项目和 Digia 网站上的 Qt 相关网络资源最终都统一到了www.qt.io/。Qt 继续接收错误修复、新功能和新的*台支持。本书基于 2017 年发布的 Qt 5.9。
跨*台编程
Qt 是一个用于开发跨*台应用程序的应用程序编程框架。这意味着为某个*台编写的软件可以轻松地移植到另一个*台执行,几乎不需要任何努力。这是通过限制应用程序源代码为所有支持的*台提供的例程和库的调用集来实现的,并通过将所有可能在*台之间不同的任务(如屏幕绘制、访问系统数据或硬件)委托给 Qt 来实现的。这实际上创建了一个分层环境(如下面的图所示),其中 Qt 隐藏了所有*台相关的方面,使其从应用程序代码中不可见:

当然,有时我们需要使用 Qt 不提供的一些功能。在这种情况下,使用条件编译来编写特定*台的代码非常重要。Qt 提供了一组广泛的宏,用于指定当前*台。我们将在 第六章,Qt 核心基础中回到这个话题。
支持的*台
该框架适用于多种*台,从传统的桌面环境到嵌入式系统再到移动设备。Qt 5.9 支持以下*台:
-
桌面*台:Windows、Linux 和 macOS
-
移动*台:UWP、Android 和 iOS
-
嵌入式*台:VxWorks、INTEGRITY、QNX 和嵌入式 Linux
很可能,在未来的 Qt 版本中,支持的*台列表将发生变化。你应该参考你 Qt 版本的“支持的*台”文档页面,以获取有关支持的操作系统和编译器版本的详细信息。
GUI 可伸缩性
在桌面应用程序开发的绝大部分历史中,以像素为单位指定 GUI 元素的大小是常见的做法。虽然大多数操作系统长期以来都有 每英寸点数(DPI)设置和相应的 API 来考虑它,但大多数现有的显示器具有大约相同的 DPI,因此没有高 DPI 支持的应用程序很常见。
当高 DPI 显示器在市场上变得更加普遍时,情况发生了变化——最显著的是在手机和*板电脑上,但笔记本电脑和台式机上也是如此。现在,即使你只针对桌面*台,你也应该考虑支持不同的 DPI 设置。当你针对移动设备时,这变得强制性的。
如果你正在使用 Qt Widgets 或 Qt Quick,你通常根本不需要指定像素大小。标准小部件和控制将使用由样式定义的字体、边距和偏移量。如果使用布局,Qt 将自动确定所有 GUI 元素的位置和大小。尽可能避免为 GUI 元素指定固定大小。你可以使用与其它 GUI 元素大小、窗口或屏幕大小相关的尺寸。Qt 还提供了一组 API 用于查询屏幕 DPI、GUI 样式指标和字体指标,这有助于确定当前设备的最佳尺寸。
在 macOS 和 iOS 上,Qt Widgets 和 Qt Quick 应用程序使用虚拟坐标系自动缩放。应用程序中的像素值保持不变,但 GUI 将根据当前显示的 DPI 进行缩放。例如,如果像素比设置为 2(这是视网膜显示的常见值),则创建宽度为 100 "像素"的小部件将生成具有 200 个物理像素的小部件。这意味着应用程序不必高度关注 DPI 变化。然而,这种缩放不适用于 OpenGL,它始终使用物理像素。
Qt 版本
每个 Qt 版本号(例如,5.9.2)由主版本、次版本和补丁版本组成。Qt 特别关注不同版本之间的向前和向后兼容性。只有当变更既向前又向后兼容(通常是无需更改任何 API 的 bug 修复)时,才会通过仅更改补丁版本来表示这些小变更。新的次版本通常引入新的 API 和功能,因此它们不是向前兼容的。然而,所有次版本都是向后二进制和源兼容的。这意味着如果您正在过渡到新的次版本(例如,从 5.8 到 5.9),您应该始终能够不进行任何更改就重新构建您的项目。您甚至可以通过仅更新共享的 Qt 库(或让操作系统的包管理器执行此操作)来过渡到新的次版本,而无需重新构建。主要版本表示重大变更,可能会破坏向后兼容性。然而,最新的主要版本(5.0)与上一个版本在源代码级别上主要兼容。
Qt 为某些版本声明长期支持(LTS)。LTS 版本在三年内接收补丁级别的发布,包括 bug 修复和安全修复。商业支持可提供更长时间。截至撰写本文时,当前的 LTS 发布版本是 5.6 和 5.9。
Qt 框架的结构
随着 Qt 随着时间的推移而扩展,其结构也发生了演变。最初,它只是一个单独的库,然后是一组库。当它难以维护和更新它所支持的不断增长的*台时,决定将框架拆分为包含在两个模块组中的更小的模块——Qt Essentials 和 Qt Add-ons。与拆分相关的一个主要决定是,每个模块现在都可以有自己的独立发布计划。
Qt Essentials
Essentials 组包含必须为每个支持的*台实现的模块。这意味着如果您仅使用此组中的模块来实现您的系统,您可以确信它可以轻松地移植到 Qt 支持的任何其他*台。Qt Essentials 模块之间最重要的关系如下所示:

以下是对一些模块的解释:
-
Qt Core模块包含所有其他模块所依赖的最基本的 Qt 功能。它提供对事件处理、元对象、数据 I/O、文本处理和线程的支持。它还带来了一些框架,例如动画框架、状态机框架和插件框架。
-
Qt GUI模块提供了构建用户界面的基本跨*台支持。它包含更多高级 GUI 模块(Qt Widgets 和 Qt Quick)所需的功能。Qt GUI 包含用于操作可以使用光栅引擎或 OpenGL 渲染的窗口的类。Qt 支持桌面 OpenGL 以及 OpenGL ES 1.1 和 2.0。
-
Qt Widgets 通过使用小部件(如按钮、编辑框、标签、数据视图、对话框、菜单和工具栏)以及使用特殊布局引擎来创建用户界面,扩展了 GUI 模块。Qt Widgets 利用 Qt 的事件系统以跨*台的方式处理输入事件。此模块还包含一个面向对象的 2D 图形画布的实现,称为 Graphics View。
-
Qt Quick 是 Qt GUI 的扩展,它提供了使用 QML 创建轻量级流畅用户界面的方法。它将在本章后面以及 第十一章,Qt Quick 简介 中更详细地描述。
-
Qt QML 是 Qt Quick 中使用的 QML 语言的实现。它还提供了 API,用于将自定义 C++ 类型集成到 QML 的 JavaScript 引擎中,以及将 QML 代码与 C++ 集成。
-
Qt Network 提供了对 IPv4 和 IPv6 网络的支持,使用 TCP 和 UDP。它还包含 HTTP、HTTPS、FTP 客户端,并扩展了对 DNS 查询的支持。
-
Qt Multimedia 允许程序员访问音频和视频硬件(包括摄像头和 FM 收音机)以录制和播放多媒体内容。它还提供了 3D 位置音频支持。
-
Qt SQL 提供了一个用于以抽象方式操作 SQL 数据库的框架。
此组中还有其他模块,但在此书中我们将不关注它们。如果您想了解更多关于它们的信息,可以在 Qt 参考手册中查找。
Qt Add-ons
此组包含任何*台都可选的模块。这意味着如果某些*台上的特定功能不可用,或者没有人愿意花时间为此*台上的此功能工作,这不会阻止 Qt 支持该*台。我们将在下面提到一些最重要的模块:
-
Qt Concurrent:这处理多线程处理
-
Qt 3D:这提供了高级 OpenGL 构建块
-
Qt Gamepad:这使应用程序能够支持游戏手柄硬件
-
Qt D-Bus:这允许您的应用程序通过 D-Bus 机制与其他应用程序进行通信
-
Qt XML Patterns:这帮助我们访问 XML 数据
许多其他模块也可用,但在此处我们将不涉及它们。
qmake
一些 Qt 功能在项目的编译和链接过程中需要额外的构建步骤。例如,元对象编译器(moc)、用户界面编译器(uic)和资源编译器(rcc)可能需要执行以处理 Qt 的 C++ 扩展和功能。为了方便,Qt 提供了 qmake 可执行文件,该文件管理您的 Qt 项目并生成在当前*台上构建它所需的文件(例如,make 工具的 Makefile)。qmake 从具有 .pro 扩展名的项目文件中读取项目的配置。Qt Creator(Qt 伴随的 IDE)自动创建和更新该文件,但可以手动编辑以更改构建过程。
或者,可以使用 CMake 来组织和构建项目。Qt 提供了用于执行所有必要构建操作的 CMake 插件。Qt Creator 也对 CMake 项目有相当好的支持。CMake 比 qmake 更高级、更强大,但对于具有简单构建过程的项目来说,可能并不需要。
现代 C++标准
您可以在您的 Qt 项目中使用现代 C++。Qt 的构建工具(qmake)允许您指定要针对的 C++ 标准版本。Qt 本身通过尽可能使用新的 C++ 特性引入了改进和扩展的 API。例如,它使用 ref-qualified 成员函数,并引入了接受初始化列表和右值引用的方法。它还引入了新的宏,帮助您处理可能或可能不支持新标准的编译器。
如果您使用的是最新的 C++ 版本,您必须注意您在目标*台上的编译器版本,因为较旧的编译器可能不支持新的标准。在本书中,我们将假设支持 C++11,因为它已经广泛可用。因此,我们将使用 C++11 的特性,例如基于范围的 for 循环、作用域枚举和 lambda 表达式。
选择合适的许可证
Qt 在两种不同的许可方案下可用——您可以选择商业许可或开源许可。我们将在这里讨论两者,以便您更容易选择。如果您对特定许可方案是否适用于您的用例有疑问,您最好咨询专业律师。
开源许可证
开源许可证的优势是我们不必为使用 Qt 向任何人付费;然而,缺点是它对如何使用 Qt 施加了一些限制。
当选择开源版本时,我们必须在 GPL 3.0 和 LGPL 3.0 之间进行选择。由于 LGPL 更为宽松,在本章中我们将重点关注它。选择 LGPL 允许您使用 Qt 来实现开源或闭源的系统——如果您不想的话,不需要向任何人透露您应用程序的源代码。
然而,有一些限制您需要了解:
-
对 Qt 本身进行的任何修改都需要公开,例如,通过将源代码补丁与您的应用程序二进制文件一起分发。
-
LGPL 要求您的应用程序用户能够用具有相同功能的其他库(例如,Qt 的不同版本)替换您提供的 Qt 库。这通常意味着您必须将您的应用程序动态链接到 Qt,以便用户可以简单地用自己的 Qt 库替换它们。您应该意识到这种替换可能会降低您系统的安全性;因此,如果您需要非常安全,开源可能不是您的选择。
-
LGPL 与许多许可证不兼容,特别是专有许可证,因此您可能无法使用 Qt 与某些商业组件一起使用。
一些 Qt 模块可能有不同的许可限制。例如,Qt Charts、Qt 数据可视化和 Qt 虚拟键盘模块在 LGPL 下不可用,只能使用 GPL 或商业许可证。
Qt 的开源版本可以直接从www.qt.io下载。
商业许可证
如果你决定购买 Qt 的商业许可证,大多数限制都会被解除。这允许你将整个源代码保密,包括你可能想要整合到 Qt 中的任何更改。你可以自由地将你的应用程序静态链接到 Qt,这意味着更少的依赖项、更小的部署包大小和更快的启动速度。这也有助于提高你应用程序的安全性,因为最终用户不能通过用自己的库替换动态加载的库来向应用程序中注入自己的代码。
摘要
在本章中,你了解了 Qt 的架构。我们看到了它是如何随着时间的推移而演变的,并对它现在的样子有一个简要的了解。Qt 是一个复杂的框架,我们不可能涵盖所有内容,因为其功能的一些部分对于游戏编程来说比其他部分更重要,这些部分你可能需要时可以自己学习。现在你已经知道了 Qt 是什么,我们可以继续到下一章,在那里你将学习如何在你的开发机器上安装 Qt。
第二章:安装
在本章中,您将学习如何在您的开发机器上安装 Qt,包括 Qt Creator,这是一个针对 Qt 使用的 IDE。您将了解如何根据您的需求配置 IDE,并学习使用该环境的基本技能。到本章结束时,您将能够使用 Qt 发布中包含的工具为桌面和嵌入式*台准备您的开发环境。
本章涵盖的主要主题如下:
-
安装 Qt 及其开发工具
-
Qt Creator 的主要控件
-
Qt 文档
安装 Qt SDK
在您可以在您的机器上开始使用 Qt 之前,它需要被下载和安装。Qt 可以使用专门的安装程序进行安装,分为两种类型:在线安装程序,它会即时下载所有需要的组件,以及一个更大的离线安装程序,它已经包含了所有需要的组件。使用在线安装程序对于常规桌面安装来说更容易,所以我们更倾向于这种方法。
操作时间 – 使用在线安装程序安装 Qt
所有 Qt 资源,包括安装程序,都可以在 qt.io 找到。要获取 Qt 的开源版本,请访问 www.qt.io/download-open-source/。页面默认建议您下载适用于当前操作系统的在线安装程序,如下面的截图所示。点击“立即下载”按钮下载在线安装程序,或点击“查看所有下载”选择不同的下载选项:

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

点击“下一步”开始安装过程。如果您正在使用代理服务器,请点击“设置”并调整您的代理配置。然后,要么登录您的 Qt 账户,要么如果您没有账户,请点击“跳过”。
再次点击“下一步”,在下载程序检查远程存储库一段时间后,您将被要求输入安装路径。请确保您选择一个您有写入权限并且有足够空间的位置。最好将 Qt 放入您的个人目录中,除非您以系统管理员用户身份运行安装程序。再次点击“下一步”将显示您希望安装的组件的选择,如下面的截图所示。您将根据您的*台获得不同的选择:

在我们继续之前,您需要选择您想要安装的 Qt 版本。我们建议您使用最新的稳定版本,即在 Qt 部分下的第一个项目。忽略“预览”部分,因为它包含可能不稳定的预发布包。如果您想与本书完全一致,可以选择 Qt 5.9.0,但这不是必需的。安装程序还允许您一次性安装多个 Qt 版本。
扩展你想要安装的 Qt 版本对应的章节,并选择你需要的*台。至少选择一个桌面*台,以便能够构建和运行桌面应用程序。在 Windows 上,你必须为桌面构建进行额外的选择。选择 32 位或 64 位版本,并选择你想要使用的编译器。如果你有 Microsoft C++ 编译器(由 Visual Studio 或 Visual C++ Build Tools 提供),你可以选择与已安装的 MSVC 版本对应的构建版本。如果你没有 Microsoft 编译器或者你只是不想使用它,请选择 MinGW 构建,并在包树工具部分选择相应的 MinGW 版本。
如果你想要构建 Android 应用程序,请选择对应所需 Android *台的选项。在 Windows 上,你可以选择 UWP 构建,以创建通用 Windows *台应用程序。
安装程序将始终安装 Qt Creator——一个针对创建 Qt 应用程序进行优化的 IDE(集成开发环境)。你也可以选择你想要使用的 Qt 扩展。
在选择所需的组件并再次点击“下一步”后,你必须通过标记适当的选项来接受 Qt 的许可条款,如下面的截图所示:

在点击“安装”后,安装程序将开始下载和安装所需的包。一旦完成,你的 Qt 安装将准备就绪。在过程结束时,你将有一个选项来启动 Qt Creator:

发生了什么?
我们所经历的过程会在你的磁盘上生成整个 Qt 基础设施。你可以检查安装程序指向的目录,以查看它在这个目录中创建了许多子目录,每个子目录对应安装程序选择的 Qt 版本,还有一个名为 Tools 的子目录,其中包含 Qt Creator。Qt 目录还包含一个 MaintenanceTool 可执行文件,它允许你添加、删除和更新已安装的组件。目录结构确保如果你决定安装另一个版本的 Qt,它不会与你的现有安装冲突。此外,对于每个版本,你可以有多个*台子目录,它们包含特定*台的实际 Qt 安装。
Qt Creator
现在 Qt 已经安装,我们将熟悉 Qt Creator 并使用它来验证安装。
Qt Creator 的模式
在 Qt Creator 启动后,你应该会看到以下屏幕:

左侧的面板允许你在 IDE 的不同 模式 之间切换:
-
欢迎模式:允许你快速打开上次会话、项目、加载示例和教程。
-
编辑模式:这是主要用于编辑应用程序源代码的主要模式。
-
设计模式:包含一个可视化表单编辑器。当您创建或打开 Qt Widgets 表单文件(
.ui)或 QML 表单文件(.ui.qml)时,设计模式会自动激活。 -
调试模式:在您在调试器下启动应用程序时自动激活。它包含用于显示调用堆栈、断点列表和局部变量值的额外视图。在需要时可以启用更多视图(如线程列表或寄存器值)。
-
项目模式:允许您配置 Qt Creator 如何构建和运行您的应用程序。例如,您可以选择它将使用哪个 Qt 版本,或者在此处添加命令行参数。
-
帮助模式:提供对 Qt 文档的访问。我们将在本章的后面部分关注这个主题。
设置编译器、Qt 版本和工具包
在 Qt Creator 能够构建和运行项目之前,它需要知道哪些 Qt 构建、编译器、调试器和其他工具可用。幸运的是,Qt 安装程序通常会自动完成这项工作,Qt Creator 能够自动检测系统范围内可用的工具。但是,让我们验证我们的环境是否已正确配置。从“工具”菜单中选择“选项”。一旦弹出对话框,从侧边列表中选择“构建和运行”。这是我们可以配置 Qt Creator 构建项目方式的地方。一个完整的构建配置称为 工具包。它由一个 Qt 安装和一个用于执行构建的编译器组成。您可以在“选项”对话框的“构建和运行”部分看到这三个实体的选项卡。
让我们从“编译器”选项卡开始。如果您的编译器没有正确自动检测并且不在列表中,请点击“添加”按钮,从列表中选择您的编译器类型,并填写编译器的名称和路径。如果设置正确输入,Creator 将自动填写所有其他详细信息。然后,您可以点击“应用”以保存更改。
接下来,您可以切换到 Qt 版本选项卡。同样,如果您的 Qt 安装没有自动检测到,您可以点击“添加”。这将打开一个文件对话框,您需要找到您的 Qt 安装目录,其中存储了所有二进制可执行文件(通常在 bin 目录中),并选择一个名为 qmake 的二进制文件。如果选择错误文件,Qt Creator 将会警告您。否则,您的 Qt 安装和版本应该能够正确检测。如果您愿意,可以在相应的框中调整版本名称。
最后要查看的选项卡是“工具包”选项卡。它允许您将编译器与用于编译的 Qt 版本配对。此外,对于嵌入式和移动*台,您可以指定要部署到的设备以及包含构建指定嵌入式*台所需所有文件的 sysroot 目录。请确保每个工具包的名称足够描述性,以便您可以为每个应用程序选择正确的工具包(或工具包)。如果需要,可以调整工具包的名称。
动手时间 - 加载示例项目
示例是探索 Qt 功能和查找典型任务所需代码的绝佳方式。每个 Qt 版本都包含一组始终更新的示例。Qt Creator 提供了一种简单的方法来加载和编译任何示例项目。
让我们尝试加载一个示例,以便熟悉 Qt Creator 的项目编辑界面。然后,我们将构建项目以检查安装和配置是否正确完成。
在 Qt Creator 中,点击窗口左上角的欢迎按钮以切换到欢迎模式。点击示例按钮(参考上一张截图)以打开带有搜索框的示例列表。确保在搜索框旁边的下拉列表中选择你想要使用的工具包。在框中输入 aff 以过滤示例列表,然后点击仿射变换以打开项目。如果你被问及是否要将项目复制到新文件夹,请同意。
选择一个示例后,将出现一个包含加载示例文档页面的附加窗口。当你不需要它时,可以关闭该窗口。切换回主 Qt Creator 窗口。
Qt Creator 将显示带有可用工具包列表的“配置项目”对话框:

确保你想要使用的工具包被勾选,然后点击“配置项目”按钮。Qt Creator 将随后显示以下窗口:

这是 Qt Creator 的编辑模式。让我们浏览一下这个界面的最重要的部分:
-
项目树位于窗口的左上角。它显示所有打开的项目以及它们内部文件的层次结构。你可以双击一个文件来打开它进行编辑。项目树中项目、目录和文件的下拉菜单包含许多有用的功能。
-
在窗口的左下角,有一个打开文档的列表。在此列表中选中的文件将出现在窗口中央的代码编辑器中。如果选中的文件是 Qt Designer 表单,Qt Creator 将自动切换到设计模式。列表中的每个文件都有一个关闭按钮。
-
“定位到类型”字段位于底部面板的左侧。如果你想快速导航到项目中的另一个文件,在该字段中输入其名称的开头,并在弹出列表中选择它。可以使用特殊前缀来启用其他搜索模式。例如,
c前缀允许你搜索 C++ 类。你可以按 Ctrl + K 来激活此字段。 -
左侧面板底部的按钮允许你在调试器下构建和运行当前项目,或者正常运行。上面的按钮显示当前项目和当前构建配置(例如,调试或发布)的名称,并允许你更改它们。
-
当您在底部面板中选择它们时,输出面板会出现在代码编辑器下方。问题面板包含编译错误和其他相关消息。搜索结果面板允许您在整个项目中运行文本搜索并查看其结果。应用程序输出面板显示您的应用程序打印到其标准输出(
stderr或stdout)的文本。
Qt Creator 非常可配置,因此您可以调整布局以符合您的喜好。例如,您可以更改面板的位置、添加更多面板,以及更改每个操作的快捷键。
Qt 文档
Qt 项目具有非常详尽的文档。对于每个 API 项目(类、方法等),文档中都有一个部分描述该项目并提及您需要知道的事情。还有许多概述页面描述模块及其部分。当您想知道某个 Qt 类或模块是做什么的或如何使用它时,Qt 文档始终是获取信息的好来源。
Qt Creator 集成了文档查看器。最常用的文档功能是上下文帮助。要尝试它,请打开 main.cpp 文件,将文本光标置于 QApplication 文本内部,然后按 F1 键。帮助部分应该出现在代码编辑器的右侧。它显示 QApplication 类的文档页面。对于任何其他 Qt 类、方法、宏等,效果都应相同。您可以在帮助页面顶部的“在帮助模式下打开”按钮上点击,切换到帮助模式,在那里您有更多空间查看页面。
另一个重要功能是文档索引中的搜索。为此,请通过点击左侧面板上的“帮助”按钮进入帮助模式。在帮助模式下,在窗口的左上角,有一个下拉列表,允许您选择左侧部分的模式:书签、目录、索引或搜索。选择索引模式,在“查找:”文本字段中输入您的请求,并查看文本字段下方是否有任何搜索结果。例如,尝试键入 qt core 以搜索 Qt 核心模块概述。如果有结果,您可以按 Enter 键快速打开第一个结果,或者双击列表中的任何结果以打开它。如果安装了多个 Qt 版本,可能会出现一个对话框,您需要从中选择您感兴趣的 Qt 版本。
在本书的后面部分,我们有时会通过它们的名称来引用 Qt 文档页面。您可以使用之前描述的方法在 Qt Creator 中打开这些页面。
行动时间 - 运行仿射变换项目
让我们尝试构建和运行项目以检查构建环境是否配置正确。要构建项目,请点击左侧面板底部的锤子图标(构建)。在底部面板的右侧,将出现一个灰色进度条以指示构建进度。构建完成后,如果构建成功,进度条将变为绿色,否则为红色。应用程序构建完成后,请点击绿色三角形图标以运行项目。
Qt Creator 可以在运行项目之前自动保存所有文件并构建项目,因此你只需在更改项目后点击运行(Ctrl + R)或开始调试(F5)按钮。要验证此功能是否启用,请点击主菜单中的“工具”和“选项”,进入“构建和运行”部分,进入“常规”选项卡,并检查“构建前保存所有文件”、“部署前始终构建项目”和“运行前始终部署项目”选项是否被选中。
如果一切正常,经过一段时间后,应用程序应该会启动,如图以下截图所示:

刚才发生了什么?
项目究竟是如何构建的?要查看使用了哪个套件以及哪个构建配置,请直接点击位于绿色三角形图标正上方的操作栏中的图标以打开构建配置弹出窗口,如图以下截图所示:

你获得的确切内容取决于你的安装,但通常,在左侧,你会看到为项目配置的套件列表,在右侧,你会看到为该套件定义的构建配置列表。你可以点击这些列表以快速切换到不同的套件或不同的构建配置。如果你的项目仅配置了一个套件,套件列表将不会出现在这里。
如果你想使用另一个套件或更改项目的构建方式?如前所述,这可以在项目模式下完成。如果你通过点击左侧面板上的“项目”按钮进入此模式,Qt Creator 将显示当前的构建配置,如图以下截图所示:

此窗口的左侧包含所有套件的列表。未配置为与该项目一起使用的套件以灰色显示。你可以点击它们以启用当前项目的套件。要禁用套件,请在其上下文菜单中选择“禁用套件”选项。
在每个启用的套件下,有两个配置部分。构建部分包含与构建项目相关的设置:
-
阴影构建是一种将所有临时构建文件放置在单独的构建目录中的构建模式。这可以使你的源目录保持清洁,并使你的源文件更容易跟踪(特别是如果你使用版本控制系统)。此模式默认启用。
-
构建目录是临时构建文件的位置(仅当启用阴影构建时)。项目的每个构建配置都需要一个单独的构建目录。
-
构建步骤部分显示了执行实际项目构建的命令。你可以编辑现有步骤的命令行参数,并添加自定义构建步骤。默认情况下,构建过程包括两个步骤:
qmake(在上一章中描述的 Qt 项目管理工具)读取项目的.pro文件并生成 makefile,然后某些make工具的变体(取决于*台)读取 makefile 并执行 Qt 的特殊编译器、C++编译器和链接器。有关qmake的更多信息,请在文档索引中查找qmake 手册。 -
构建环境部分允许你查看和更改构建工具可用的环境变量。
大多数make工具的变体(包括mingw32-make)接受-j num_cores命令行参数,这允许make同时生成多个编译器进程。强烈建议你设置此参数,因为它可以显著减少大型项目的编译时间。为此,请点击构建步骤右侧的详细信息,并将-j num_cores输入到 Make 参数字段中(将num_cores替换为系统上的实际处理器核心数)。然而,MSVC nmake不支持此功能。为了解决这个问题,Qt 提供了一个名为jom的替代工具,它支持此功能。
对于每个工具包,可以有多个构建配置。默认情况下,生成三个配置:调试(调试器正常工作所必需的)、性能(用于性能分析)和发布(具有更多优化且无调试信息的构建)。
运行部分决定了你的项目生成的可执行文件如何启动。在这里,你可以更改程序的命令行参数、工作目录和环境变量。你可以添加多个运行配置,并使用与选择当前工具包和构建配置相同的按钮在它们之间切换。
对于桌面和移动*台,大多数情况下,从网页上下载的 Qt 二进制发布版就足够满足所有需求。然而,对于嵌入式系统,特别是基于 ARM 的系统,没有可用的二进制发布版,或者对于这样一个轻量级系统来说,它太重了。幸运的是,Qt 是一个开源项目,因此你可以始终从源代码构建它。Qt 允许你选择想要使用的模块,并且有更多的配置选项。有关更多信息,请在文档索引中查找“构建 Qt 源代码”。
摘要
到现在为止,你应该能够在你的开发机器上安装 Qt。现在你可以使用 Qt Creator 浏览现有的示例并从中学习,或者阅读 Qt 参考手册以获取更多知识。你应该对 Qt Creator 的主要控件有一个基本的了解。在下一章中,我们终于将开始使用这个框架,你将学习如何通过实现我们非常第一个简单的游戏来创建图形用户界面。
第三章:Qt 图形界面编程
本章将帮助你学习如何使用 Qt Creator IDE 使用 Qt 开发具有图形用户界面的应用程序。我们将熟悉 Qt 的核心功能、小部件、布局以及我们将用于创建复杂系统(如游戏)的信号和槽机制。我们还将介绍 Qt 的各种操作和资源系统。到本章结束时,你将能够编写自己的程序,通过窗口和小部件与用户进行通信。
本章涵盖的主要主题如下:
-
窗口和小部件
-
创建 Qt Widgets 项目并实现井字棋游戏
-
使用或不用可视化表单编辑器创建小部件
-
使用布局自动定位小部件
-
创建和使用信号和槽
-
使用 Qt 资源系统
在 Qt 中创建 GUI
如 第一章 所述,Qt 简介,Qt 由多个模块组成。在本章中,你将学习如何使用 Qt Widgets 模块。它允许你创建经典桌面应用程序。这些应用程序的 用户界面(UI)由 小部件 组成。
小部件是具有特定外观和行为的 UI 片段。Qt 提供了许多内置的小部件,这些小部件在应用程序中广泛使用:标签、文本框、复选框、按钮等等。这些小部件中的每一个都表示为从 QWidget 派生的 C++ 类的实例,并提供读取和写入小部件内容的方法。你也可以创建具有自定义内容和行为的小部件。
QWidget 的基类是 QObject —— 这是 Qt 中最重要的类,它包含多个有用的功能。特别是,它实现了对象之间的父子关系,允许你在程序中组织对象集合。每个对象都可以有一个父对象和任意数量的子对象。在两个对象之间建立父子关系有多个后果。当一个对象被删除时,所有其子对象也将自动删除。对于小部件,还有一个规则,即子对象占据其父对象边界内的区域。例如,典型的表单包括多个标签、输入字段和按钮。表单的每个元素都是一个小部件,表单是它们的父小部件。
每个小部件都有一个独立的坐标系,用于小部件内的绘制和事件处理。默认情况下,该坐标系的起点位于其左上角。子坐标系统相对于其父坐标系统。
任何未包含在其他小部件中(即任何 顶级小部件)的小部件都将成为一个窗口,桌面操作系统将为其提供一个窗口框架,通常允许用户拖动、调整大小和关闭窗口(尽管可以配置窗口框架的存在和内容)。
行动时间 - 创建 Qt Widgets 项目
使用 Qt Creator 开发应用程序的第一步是使用 IDE 提供的模板之一创建项目。
从 Qt Creator 的“文件”菜单中选择“新建文件”或“项目”。有多个项目类型可供选择。按照以下步骤创建 Qt 桌面项目:
- 对于基于小部件的应用程序,请选择应用程序组以及 Qt 小部件应用程序模板,如下面的截图所示:

- 下一步是选择您新项目的名称和位置:

- 我们将创建一个简单的井字棋游戏,因此我们将我们的项目命名为
tictactoe并为其提供一个合适的位置。
如果您有一个存放所有项目的公共目录,您可以为 Qt Creator 选择“使用默认项目位置”复选框,以便它记住位置并在您下次启动新项目时建议该位置。
- 接下来,您需要选择与项目一起使用的工具包(或多个工具包)。选择与您想要使用的 Qt 版本相对应的桌面 Qt 工具包:

- 现在,您将看到创建项目第一个小部件的选项。我们想要创建一个代表应用程序主窗口的小部件,因此我们可以保持类名和基类字段不变。我们还想要使用可视化表单编辑器来编辑主窗口的内容,因此“生成表单”也应该保持选中状态:

- 然后,点击“下一步”和“完成”。
刚才发生了什么?
Creator 在您之前选择的用于项目位置的目录中创建了一个新的子目录。这个新目录(项目目录)现在包含了一些文件。您可以使用 Qt Creator 的“项目”窗格列出和打开这些文件(有关 Qt Creator 基本控件的解释,请参阅第二章,安装)。让我们来看看这些文件。
main.cpp 文件包含 main() 函数的实现,这是应用程序的入口点,如下面的代码所示:
#include "mainwindow.h"
#include <QApplication>
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
MainWindow w;
w.show();
return a.exec();
}
main() 函数创建 QApplication 类的实例,并给它提供包含命令行参数的变量。然后,它实例化我们的 MainWindow 类,调用其 show 方法,并最终返回应用程序对象 exec 方法的返回值。
QApplication 是一个单例类,负责管理整个应用程序。特别是,它负责处理来自应用程序内部或外部来源的事件。为了处理事件,需要一个事件循环正在运行。循环等待传入的事件并将它们调度到适当的例程。在 Qt 中,大多数事情都是通过事件完成的:输入处理、重绘、通过网络接收数据、触发计时器等等。这就是我们说 Qt 是一个面向事件框架的原因。如果没有活跃的事件循环,事件处理将无法正常工作。QApplication 中的 exec() 调用(或者更具体地说,在其基类 QCoreApplication 中)负责进入应用程序的主事件循环。该函数在应用程序请求事件循环终止之前不会返回。当最终发生这种情况时,main 函数返回,你的应用程序结束。
mainwindow.h 和 mainwindow.cpp 文件实现了 MainWindow 类。目前,其中几乎没有代码。这个类是从 QMainWindow(它反过来是从 QWidget 派生的)派生的,因此它从其基类继承了大量的方法和行为。它还包含一个 Ui::MainWindow *ui 字段,它在构造函数中初始化并在析构函数中删除。构造函数还调用 ui->setupUi(this); 函数。
Ui::MainWindow 是一个自动生成的类,因此在源代码中没有它的声明。当项目构建时,它将在构建目录中创建。这个类的作用是根据表单编辑器的变化来设置我们的小部件并填充内容。自动生成的类不是 QWidget。实际上,它只包含两个方法:setupUi,它执行初始设置,以及retranslateUi,它在 UI 语言更改时更新可见文本。在表单编辑器中添加的所有小部件和其他对象都作为 Ui::MainWindow 类的公共字段可用,因此我们可以从 MainWindow 方法中通过 ui->objectName 访问它们。
mainwindow.ui 是一个可以在可视化表单编辑器中编辑的表单文件。如果你通过在项目面板中双击它来在 Qt Creator 中打开它,Qt Creator 将切换到设计模式。如果你切换回编辑模式,你会看到这个文件实际上是一个包含在设计模式中编辑的所有对象的层次结构和属性的 XML 文件。在项目的构建过程中,一个名为用户界面编译器的特殊工具将这个 XML 文件转换为 MainWindow 类中使用的 Ui::MainWindow 类的实现。
注意,你不需要手动编辑 XML 文件或编辑 Ui::MainWindow 类中的任何代码。在可视化编辑器中做出更改就足以将它们应用到你的 MainWindow 类中,并使其表单对象可供它使用。
生成的最终文件名为 tictactoe.pro,是项目配置文件。它包含构建项目所需的所有信息,使用 Qt 提供的工具。让我们分析这个文件(省略了不太重要的指令):
QT += core gui
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
TARGET = tictactoe
TEMPLATE = app
SOURCES += main.cpp mainwindow.cpp
HEADERS += mainwindow.h
FORMS += mainwindow.ui
前两行启用了 Qt 的 core、gui 和 widgets 模块。TEMPLATE 变量用于指定项目文件描述的是一个应用程序(而不是,例如,一个库)。TARGET 变量包含生成的可执行文件(tictactoe)的名称。最后三行列出了构建项目时应使用的所有文件。
事实上,qmake 默认启用 Qt Core 和 Qt GUI 模块,即使您在项目文件中没有明确指定它们。如果您想不使用默认模块,可以选择退出。例如,您可以通过在项目文件中添加 QT -= gui 来禁用 Qt GUI。
在我们继续之前,让我们通过在 tictactoe.pro 中添加以下行来告诉构建系统我们想在项目中使用 C++11 功能(如 lambda 表达式、作用域枚举和基于范围的 for 循环):
CONFIG += c++11
如果我们这样做,C++ 编译器将收到一个标志,指示应启用 C++11 支持。如果您的编译器默认启用了 C++11 支持,可能不需要这样做。如果您想使用 C++14,请使用 CONFIG += c++14。
我们现在有一个完整的 Qt Widgets 项目。要构建和运行它,只需从构建下拉菜单中选择运行条目,或者在 Qt Creator 窗口的左侧点击绿色三角形图标。过了一会儿,你应该会看到一个窗口弹出。由于我们没有向窗口添加任何内容,所以它是空的:

设计模式界面
打开 mainwindow.ui 文件,检查 Qt Creator 的设计模式:

设计模式由五个主要部分组成(它们在这张截图上已标记):
-
核心区域 (1) 是主要的工作表。它包含正在设计的表单的图形表示,您可以在其中移动小部件,将它们组合成布局,并查看它们的反应。它还允许使用我们稍后将要学习的点选方法进一步操作表单。
-
工具箱 (2) 位于窗口的左侧部分。它包含一组可用的小部件类型,这些类型按具有相关或相似功能的项目分组。在列表上方,您可以看到一个框,允许您过滤列表中显示的小部件,以仅显示与输入的表达式匹配的小部件。列表开头也有一些实际上不是小部件的项目——一个组包含布局,另一个组包含所谓的间隔符,这是一种将其他项彼此推开或创建布局中的空白区域的方法。工具箱的主要目的是将项添加到工作表中的表单。您可以通过用鼠标从列表中拖取小部件,将其拖动到中央区域的小部件上,然后释放鼠标按钮来实现这一点。
-
窗口下方的两个标签 (3)——动作编辑器和信号/槽编辑器——允许我们创建辅助实体,例如菜单和工具栏的动作或小部件之间的信号/槽连接。
-
对象树 (4) 位于窗口的右上角,包含表单项的层次结构树。在树中显示每个添加到表单的对象名称和类名称。最顶部的项对应于表单本身。您可以使用中央区域和对象树来选择现有项并访问它们的上下文菜单(例如,如果您想删除一个项,您可以在上下文菜单中选择“移除...”选项)。
-
属性编辑器 (5) 位于窗口的右下角。它允许您查看和更改中央区域和对象树中当前选定的项的所有属性值。属性按它们声明的类分组,从
QObject(实现属性的基类)开始,它只声明了一个但非常重要的属性——objectName。在QObject之后,是QWidget中声明的属性,它是QObject的直接后代。它们主要与小部件的几何和布局策略相关。在列表的下方,您可以找到来自QWidget进一步派生的属性,直到所选小部件的具体系列。属性上方的过滤器可以帮助您快速找到所需的属性。
仔细查看属性编辑器,您可以看到其中一些具有
箭头,点击时可以显示新行。这些是由多个子属性值确定的复合属性,例如,如果有一个名为geometry的属性定义了一个矩形,它可以展开以显示四个子属性:x、y、width和height。您可能还会很快注意到,一些属性名以粗体显示。这意味着该属性值已被修改,并且与该属性的默认值不同。这使您可以快速找到您已修改的属性。
如果您更改了属性值,但后来决定坚持使用默认值,您应该点击相应的输入字段,然后点击右侧带有箭头的小按钮:
。这不同于手动设置原始值。例如,如果您检查
对于某些布局的spacing属性,它可能看起来有一个固定的默认值(例如,6)。然而,实际的默认值取决于应用程序使用的样式,并且可能在不同的操作系统上有所不同,因此设置默认值的唯一方法是使用专用按钮,并确保该属性不再以粗体显示。
如果您更喜欢纯字母顺序,其中属性不是按其类别分组,您可以通过点击位于属性列表上方的扳手图标后出现的弹出菜单来切换视图;然而,一旦您熟悉了 Qt 类的层次结构,当按类亲和度排序时,导航列表将更容易。
这里所描述的是基本工具布局。如果您不喜欢它,您可以从主工作表中的上下文菜单调用,取消选中“自动隐藏视图标题栏”的选项,并使用出现的标题栏来重新排列所有窗格,或者甚至关闭您当前不需要的窗格。
现在您已经熟悉了视觉表单编辑器的结构,您终于可以向我们的小部件添加一些内容了。我们正在制作一个具有本地多人游戏的井字棋游戏,因此我们需要一种方式来显示哪两位玩家当前在移动。让我们将游戏板放在窗口的中心,并在游戏板上方和下方显示玩家的名字。当一个玩家需要移动时,我们将使相应名字的字体加粗。我们还需要一个按钮来开始新游戏。
动手实践 - 向表单添加小部件
在工具箱中找到 Label 项(它在 Display Widgets 类别中),并将其拖动到我们的表单中。使用属性编辑器将标签的 objectName 属性设置为 player1Name。objectName 是表单项的唯一标识符。对象名称用作 Ui::MainWindow 类中的公共字段名称,因此标签将在 MainWindow 类中作为 ui->player1Name 可用(并将具有 QLabel * 类型)。然后,在属性编辑器中找到 text 属性(它将在 QLabel 组中,因为它引入了该属性)并将其设置为 Player 1。你会看到中央区域中的文本将相应更新。添加另一个标签,将其 objectName 设置为 player2Name 并将其 text 设置为 Player 2。
你可以在中央区域选择一个小部件并按 F2 键来就地编辑文本。另一种方法是双击表单中的小部件。这适用于任何可以显示文本的小部件。
将一个按钮(来自 Buttons 组)拖动到表单中,并使用 F2 键将其重命名为 Start new game。如果按钮中放不下这个名称,你可以使用其边缘的蓝色矩形来调整其大小。将按钮的 objectName 设置为 startNewGame。
对于我们的游戏板,没有内置的小部件,因此我们稍后需要为它创建一个自定义小部件。现在,我们将使用一个空的小部件。在工具箱的 Containers 组中找到 Widget,并将其拖动到表单中。将其 objectName 设置为 gameBoard:

布局
如果你现在构建并运行项目,你会看到带有两个标签和一个按钮的窗口,但它们将保持你离开时的确切位置。这几乎是你不想要的。通常,人们希望小部件能够根据其内容和相邻小部件的大小自动调整大小。它们需要适应窗口大小的变化(或者相反,窗口的大小可能需要根据其中小部件的可能大小进行限制)。这对于跨*台应用程序来说是一个非常重要的特性,因为你不能假设任何特定的屏幕分辨率或控件大小。在 Qt 中,所有这些都需要我们使用一种称为布局的特殊机制。
布局允许我们安排小部件的内容,确保其空间得到有效利用。当我们对一个小部件设置布局时,我们可以开始添加小部件,甚至其他布局,该机制将根据我们指定的规则调整大小和重新定位它们。当用户界面中发生影响小部件显示方式的事件(例如,标签文本被替换为更长的文本,这使得标签需要更多空间来显示其内容)时,布局会被再次触发,这会重新计算所有位置和大小,并根据需要更新小部件。
Qt 提供了一组预定义的布局,这些布局是从 QLayout 类派生出来的,但你也可以创建自己的布局。我们目前可用的布局有 QHBoxLayout 和 QVBoxLayout,它们分别用于水*排列和垂直排列项目;QGridLayout 用于在网格中排列项目,以便项目可以跨越列或行;还有 QFormLayout,它创建两列项目,其中一列包含项目描述,另一列包含项目内容。还有 QStackedLayout,它很少直接使用,并且使分配给它的一个项目拥有所有可用空间。你可以在以下图中看到最常见的布局示例:

行动时间 – 为表单添加布局
在对象树中选择 MainWindow 顶级项目,然后点击上工具栏中的
图标,即垂直布局图标。按钮、标签和空白的部件将自动调整大小以占用表单中央区域的所有可用空间:

如果项目以不同的顺序排列,你可以拖放它们来改变顺序。
运行应用程序并检查窗口内容在窗口大小调整时是否自动定位和调整大小以使用所有可用空间。不幸的是,标签占用的垂直空间比实际需要的多,导致应用程序窗口中出现空白空间。我们将在本章后面学习尺寸策略时修复这个问题。
你可以在不构建和运行整个应用程序的情况下测试表单的布局。打开工具菜单,转到表单编辑器子菜单,并选择预览条目。你将看到一个新窗口打开,其外观与我们刚刚设计的表单完全一样。你可以调整窗口大小并与内部对象交互,以监控布局和部件的行为。实际上,Qt Creator 根据我们在设计模式的所有区域提供的描述为我们构建了一个真正的窗口。无需任何编译,瞬间我们就得到了一个完全工作的窗口,其中所有布局都正常工作,所有属性都调整到我们喜欢的样子。这是一个非常重要的工具,所以请确保你经常使用它来验证你的布局是否按照你的意图控制所有部件——这比编译和运行整个应用程序来检查部件是否正确拉伸或挤压要快得多。你还可以通过拖动表单编辑器中央区域的右下角来调整表单的大小,如果布局设置正确,内容应该会调整大小并重新定位。
现在你已经可以创建和显示表单了,需要实现两个重要的操作。首先,你需要接收用户与你的表单交互(例如,按下按钮)时的通知,以便在代码中执行一些操作。其次,你需要以编程方式更改表单内容的属性,并用真实数据填充它(例如,从代码中设置玩家名称)。
信号和槽
为了响应应用程序中发生的事情而触发功能,Qt 使用信号和槽机制。这是QObject类的重要特性之一。它基于将关于某个对象状态变化的通告(Qt 称为信号)与一个函数或方法(称为槽)连接起来,当这种通告出现时执行该函数或方法。例如,如果按下按钮,它将发出(发送)一个clicked()信号。如果某个方法连接到这个信号,那么每次按钮被按下时,该方法都会被调用。
信号可以有作为有效负载的参数。例如,一个输入框小部件(QLineEdit)有一个textEdited(const QString &text)信号,当用户编辑输入框中的文本时发出。连接到这个信号的槽将接收输入框中的新文本作为其参数(如果它有参数的话)。
信号和槽可以与所有继承自QObject(包括所有小部件)的类一起使用。一个信号可以被连接到一个槽、成员函数或函数对象(这包括一个常规的全局函数)。当一个对象发出一个信号时,连接到该信号的任何这些实体都将被调用。一个信号也可以连接到另一个信号,在这种情况下,发出第一个信号将使另一个信号也被发出。你可以将任意数量的槽连接到单个信号,也可以将任意数量的信号连接到单个槽。
创建信号和槽
如果你创建了一个QObject子类(或者一个QWidget子类,因为 QWidget 继承自 QObject),你可以将这个类的某个方法标记为信号或槽。如果父类有任何信号或非私有槽,你的类也将继承它们。
为了使信号和槽正常工作,类声明必须在定义的私有部分包含Q_OBJECT宏(Qt Creator 已经为我们生成了它)。当项目构建时,一个称为元对象编译器(moc)的特殊工具将检查类的头文件,并生成一些必要的额外代码,以便信号和槽能够正常工作。
请记住,moc和所有其他 Qt 构建工具都不会编辑项目文件。你的 C++文件在没有任何更改的情况下传递给编译器。所有特殊效果都是通过生成单独的 C++文件并将它们添加到编译过程中来实现的。
可以通过在类声明的signals部分声明一个类方法来创建一个信号:
signals:
void valueChanged(int newValue);
然而,我们不会实现这样的方法;这将由moc自动完成。我们可以通过调用方法来发送(发出)信号。有一个约定,信号调用应该由emit宏 precede。这个宏没有效果(它实际上是一个空宏),但它帮助我们阐明我们发出信号的意图:
void MyClass::setValue(int newValue) {
m_value = newValue;
emit valueChanged(newValue);
}
您应该只从类方法中发出信号,就像它是一个受保护的函数一样。
槽是声明在类声明中的private slots、protected slots或public slots部分的类方法。与信号相反,槽需要实现。Qt 将在连接到它的信号发出时调用槽。槽的可见性(私有、保护或公共)应使用与正常方法相同的原理来选择。
C++标准只描述了类定义中的三种类型部分(private、protected和public),因此您可能会想知道这些特殊部分是如何工作的。实际上,它们是简单的宏:signals
宏展开为public,而slots是一个空宏。因此,编译器将它们视为普通方法。然而,这些关键字由moc用于确定如何生成额外的代码。
连接信号和槽
信号和槽可以使用QObject::connect()和QObject::disconnect()函数动态地连接和断开。常规的信号-槽连接由以下四个属性定义:
-
改变其状态的对象(发送者)
-
发送者对象中的信号
-
包含要调用的函数的对象(接收者)
-
接收者中的槽
如果您想建立连接,您需要调用QObject::connect函数并将这四个参数传递给它。例如,以下代码可以在按钮被点击时清除输入框:
connect(button, &QPushButton::clicked,
lineEdit, &QLineEdit::clear);
在此代码中,信号和槽是通过称为成员函数指针的标准 C++特性来指定的。这样的指针包含类的名称和该类中的方法名称(在我们的情况下,是信号或槽)。Qt Creator 的代码自动补全将帮助您编写连接语句。特别是,如果您在按下Ctrl + Space之后
connect(button, &,它将插入类的名称,如果您在connect(button, &QPushButton::之后这样做,它将建议一个可用的信号(在另一个上下文中,它将建议类中所有现有的方法)。
注意,在建立连接时,您不能设置信号或槽的参数。源信号的参数始终由发出信号的函数确定。接收槽(或信号)的参数始终与源信号的参数相同,有两个例外:
-
如果接收槽或信号的参数比源信号少,则忽略剩余的参数。例如,如果你想使用
valueChanged(int)信号,但不在乎传递的值,你可以将此信号连接到一个不带参数的槽。 -
如果对应参数的类型不同,但存在它们之间的隐式转换,则执行该转换。这意味着你可以,例如,将携带
double值的信号连接到一个接受int参数的槽。
如果信号和槽没有兼容的签名,你将得到一个编译时错误。
在发送者或接收者对象被删除后,现有的连接将自动销毁。手动断开连接很少需要。connect()函数返回一个连接句柄,可以传递给disconnect()。或者,你可以使用与connect()相同的参数调用disconnect()来撤销连接。
你不总是需要声明一个槽来执行连接。可以将信号连接到一个独立的函数:
connect(button, &QPushButton::clicked, someFunction);
函数也可以是 lambda 表达式,在这种情况下,可以在connect语句中直接编写代码:
connect(pushButton, &QPushButton::clicked, []()
{
qDebug() << "clicked!";
});
如果你想调用一个具有固定参数值且无法由信号携带的槽,这可能很有用,因为它的参数较少。一种解决方案是从 lambda 函数(或独立函数)中调用该槽:
connect(pushButton, &QPushButton::clicked, [label]()
{
label->setText("button was clicked");
});
函数甚至可以被函数对象(functor)替代。为此,我们创建一个类,为它重载与我们要连接的信号兼容的调用操作符,如下面的代码片段所示:
class Functor {
public:
Functor(const QString &name) : m_name(name) {}
void operator()(bool toggled) const {
qDebug() << m_name << ": button state changed to" << toggled;
}
private:
QString m_name;
};
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
QPushButton *button = new QPushButton();
button->setCheckable(true);
QObject::connect(button, &QPushButton::toggled,
Functor("my functor"));
button->show();
return a.exec();
}
这通常是一种执行带有信号未携带的额外参数的槽的好方法,因为这比使用 lambda 表达式要干净得多。然而,请注意,当 lambda 表达式或 functor 中引用的对象被删除时,不会发生自动断开连接。这可能导致使用后释放的漏洞。
虽然实际上可以将信号连接到一个不是槽的QObject类的方法,但这样做并不推荐。将方法声明为槽可以更好地表达你的意图。此外,非槽方法在运行时对 Qt 不可用,这在某些情况下是必需的。
旧连接语法
在 Qt 5 之前,旧连接语法是唯一的选择。它看起来如下:
connect(spinBox, SIGNAL(valueChanged(int)),
dial, SLOT(setValue(int)));
该语句在spinBox对象的valueChanged信号与dial对象的setValue槽之间建立连接,该槽接受一个int参数。不允许在
connect语句。如果你在SIGNAL(或SLOT(之后按Ctrl + Space,Qt Creator 通常会能够在此上下文中建议所有可能的输入。
虽然此语法仍然可用,但我们不建议广泛使用,因为它有以下缺点:
-
如果信号或槽引用不正确(例如,其名称或参数类型不正确)或如果信号和槽的参数类型不兼容,则不会在编译时出现错误,而只会出现运行时警告。新的语法方法在编译时执行所有必要的检查。
-
旧语法不支持将参数值强制转换为另一种类型(例如,将携带
double值的信号连接到接受int参数的槽)。 -
旧语法不支持将信号连接到独立函数、lambda 表达式或仿函数。
旧语法还使用宏,可能对不熟悉 Qt 的开发者来说不清楚。很难说哪种语法更容易阅读(旧语法显示参数类型,而新语法显示类名)。然而,当使用重载信号或槽时,新语法有一个很大的缺点。解决重载函数类型的唯一方法是通过显式转换:
connect(spinBox,
static_cast<void (QSpinBox::*)(int)>(&QSpinBox::valueChanged),
...);
旧连接语法包括参数类型,因此没有这个问题。在这种情况下,旧语法可能看起来更可接受,但编译时检查可能仍然被认为比更短的代码更有价值。在这本书中,我们更喜欢新语法,但在处理重载方法时使用旧语法以提高清晰度。
信号和槽访问指定符
如前所述,您应该只从拥有该信号或其子类发出信号。然而,如果信号确实是受保护的或私有的,您将无法使用成员指针函数语法连接到它们。为了使这种连接成为可能,信号被定义为公共函数。这意味着编译器不会阻止您从外部调用信号。如果您想防止此类调用,可以将QPrivateSignal声明为信号的最后一个参数:
signals:
void valueChanged(int value, QPrivateSignal);
QPrivateSignal是由Q_OBJECT宏在每个QObject子类中创建的私有结构,因此您只能在当前类中创建QPrivateSignal对象。
槽可以是公共的、受保护的或私有的,具体取决于您想如何限制对它们的访问。当使用成员函数指针语法进行连接时,您只能创建对您有访问权限的槽的指针。只要您有访问权限,也可以从任何其他位置直接调用槽。
话虽如此,Qt 实际上并不支持限制对信号和槽的访问。无论信号或槽如何声明,您都可以始终使用旧连接语法访问它。您还可以使用QMetaObject::invokeMethod方法调用任何信号或槽。虽然您可以限制直接 C++调用以减少错误的可能性,但请记住,如果您的 API 用户真的想访问任何信号或槽,他们仍然可以这样做。
这里有一些关于信号和槽的方面我们没有涉及。当我们处理多线程时,我们将在稍后讨论它们([在线章节,*www.packtpub.com/sites/default/files/downloads/MiscellaneousandAdvancedConcepts.pdf))。
行动时间 - 接收表单的按钮点击信号
打开 mainwindow.h 文件,在类声明中创建一个 private slots 部分,然后声明 startNewGame() 私有槽,如下面的代码所示:
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
~MainWindow();
private slots:
void startNewGame();
}
为了快速实现一个新声明的方法,我们可以让 Qt Creator 为我们创建代码框架,方法是在方法声明处放置文本光标,在键盘上按 Alt + Enter,然后从弹出菜单中选择在 tictactoewidget.cpp 中添加定义。
反过来也适用。您可以先编写方法体,然后将光标放在方法签名上,按 Alt + Enter,并从快速修复菜单中选择添加 (...) 声明。在 Creator 中还有各种其他上下文相关的修复可用。
在此方法的实现中写下高亮显示的代码:
void MainWindow::startNewGame()
{
qDebug() << "button clicked!";
}
将 #include <QDebug> 添加到 mainwindow.cpp 文件的最顶部,以便使 qDebug() 宏可用。
最后,在 setupUi() 调用之后,在构造函数中添加一个连接语句:
ui->setupUi(this);
connect(ui->startNewGame, &QPushButton::clicked,
this, &MainWindow::startNewGame);
运行应用程序并尝试点击按钮。在 Qt Creator 窗口的底部部分的应用程序输出面板中应该会显示 button clicked! 文本(如果面板未激活,请使用底部面板中的应用程序输出按钮打开它):

刚才发生了什么?
我们在 MainWindow 类中创建了一个新的私有槽,并将启动新游戏按钮的 clicked() 信号连接到该槽。当用户点击按钮时,Qt 将调用我们的槽,并执行我们编写在其中的代码。
确保将任何与表单元素相关的操作放在 setupUi() 调用之后。这个函数创建了元素,所以
在调用 setupUi() 之前,ui->startNewGame 将会被初始化,尝试使用它将导致未定义行为。
qDebug() << ... 是将调试信息打印到应用程序进程的 stderr(标准错误输出)的便捷方式。它与标准库中可用的 std::cerr << ... 方法非常相似,但它使用空格分隔提供的值,并在末尾添加一个新行。
将调试输出放在任何地方很快就会变得不方便。幸运的是,Qt Creator 与 C++ 调试器有强大的集成,因此您可以使用调试模式来检查是否执行了某些特定的行,查看该位置的局部变量的当前值等。例如,尝试通过单击行号左侧的空间(一个表示断点的红色圆圈应该出现)来设置包含qDebug()的行的断点。单击“开始调试”按钮(Qt Creator 左下角带有虫子的绿色三角形),等待应用程序启动,然后按“开始新游戏”按钮。当应用程序进入断点位置时,它将暂停,Qt Creator 的窗口将置于最前。断点圆圈上的黄色箭头将指示当前执行的步骤。您可以使用代码编辑器下方的按钮继续执行、停止或逐步执行过程。当开发大型应用程序时,学习使用调试器变得非常重要。我们将在稍后更多地讨论使用调试器(在线 章节,www.packtpub.com/sites/default/files/downloads/MiscellaneousandAdvancedConcepts.pdf)。
自动槽连接及其缺点
Qt 还提供了一种更简单的方法来连接表单元素的信号和类的槽。您可以在表单编辑器的中央区域右键单击按钮,并选择“转到槽...”选项。您将被提示选择按钮类(QPushButton)中可用的信号之一。在选择clicked()信号后,Qt Creator 将自动为我们的MainWindow类添加一个新的on_startNewGame_clicked槽。
难以理解的部分是没有任何connect()调用强制连接。按钮的信号是如何连接到这个槽的呢?答案是 Qt 的自动槽连接功能。当构造函数调用ui->setupUi(this)函数时,它创建了表单中的小部件和其他对象,然后调用QMetaObject::connectSlotsByName方法。此方法查看小部件类(在我们的情况下,MainWindow)中存在的槽列表,并搜索名称为on_<object name>_<signal name>的槽。
模式,其中<object name>是现有子小部件的objectName,<signal name>是此小部件的信号之一。在我们的例子中,一个名为startNewGame的按钮是我们小部件的子小部件,并且它有一个clicked信号,因此此信号自动连接到on_startNewGame_clicked槽。
虽然这是一个非常方便的功能,但它有很多缺点:
-
这使得你的应用程序更难维护。如果你重命名或删除表单元素,你必须手动更新或删除槽。如果你忘记这样做,当自动连接失败时,应用程序将在运行时仅产生警告。在一个大型应用程序中,尤其是在应用程序启动时并非所有表单都实例化的情况下,你可能会错过警告,并且应用程序将无法按预期工作。
-
你必须为槽指定一个特定的名称(例如,
on_startNewGame_clicked()而不是看起来干净的startNewGame())。 -
有时你希望将多个对象的信号连接到同一个槽。自动槽连接不提供这样做的方法,仅为了调用单个函数而创建多个槽会导致不必要的代码膨胀。
-
自动槽连接有运行时成本,因为它需要检查可用的子元素和槽位并找到匹配项,但由于它仅在表单对象创建时运行,因此通常不显著。
上一个部分中展示的基本方法更具可维护性。通过使用指向成员函数的指针显式调用connect(),将确保信号和槽都正确指定。如果你重命名或删除按钮,它将立即导致无法忽略的编译错误。你也可以自由地为槽选择一个有意义的名称,这样你就可以根据需要将其作为你的公共 API 的一部分。
考虑到所有这些,我们建议不要使用自动槽连接功能,因为便利性并不超过其缺点。
行动时间 – 从代码中更改标签上的文本
将文本打印到控制台不如更改表单中的文本那么令人印象深刻。我们还没有 GUI 让用户输入他们的名字,所以现在我们将硬编码一些名字。让我们将我们的槽的实现更改为以下内容:
void MainWindow::startNewGame()
{
ui->player1Name->setText(tr("Alice"));
ui->player2Name->setText(tr("Bob"));
}
现在,当你运行应用程序并点击按钮时,表单中的标签将发生变化。让我们将此代码分解成几个部分:
-
如前所述,第一个标签的对象在我们的类中可通过
ui->player1Name访问,其类型为QLabel *。 -
我们正在调用
QLabel类的setText方法。这是QLabel的text属性的 setter(与我们在设计模式属性编辑器中编辑的相同属性)。根据 Qt 的命名约定,获取器应该与属性本身具有相同的名称,而设置器应该有一个set前缀,后跟属性名称。你可以在setText上设置文本光标并按F1键了解更多关于属性及其访问函数的信息。 -
tr()函数(其简称为“翻译”)用于将文本翻译为应用程序当前的用户界面语言。我们将在第六章中描述 Qt 的翻译基础设施,Qt 核心基础。默认情况下,此函数返回未更改的传递字符串,但将任何显示给用户的字符串字面量包装在此函数中是一个好习惯。在表单编辑器中输入的任何可见文本也受翻译影响,并自动通过类似函数传递。只有那些不应受翻译影响的字符串(例如,用作标识符的对象名称)才应不使用tr()函数创建。
创建井字棋游戏板小部件
让我们继续实现游戏板。它应该包含九个可以显示“X”或“O”并允许玩家进行移动的按钮。我们可以直接将按钮添加到表单的空小部件中。然而,游戏板的操作与其他表单的其余部分相对独立,并且其中将包含相当多的逻辑。遵循封装原则,我们更倾向于将游戏板实现为一个独立的小部件类。然后,我们将用我们创建的游戏板小部件替换主窗口中的空小部件。
在设计表单和纯 C++类之间进行选择
创建自定义小部件的一种方法是将设计表单类添加到项目中。设计表单类是 Qt Creator 提供的一个模板。它由一个继承自QWidget(直接或间接)的 C++类和一个设计表单(.ui文件)组成,通过一些自动生成的代码连接在一起。我们的MainWindow类也遵循这个模板。
然而,如果您尝试使用可视化表单编辑器来创建我们的井字棋游戏板,您可能会发现这个任务非常不方便。一个问题是需要手动将九个相同的按钮添加到表单中。另一个问题是,当您需要建立信号连接或更改按钮文本时,从代码中访问这些按钮。使用ui->objectName的方法不适用,因为您只能通过这种方式访问一个具体的控件,因此您将不得不求助于其他方法,例如允许您通过名称搜索子对象的findChild()方法。
在这个例子中,我们更倾向于在代码中添加按钮,这样我们可以创建一个循环,设置每个按钮,并将它们放入数组中以方便引用。这个过程与设计表单的操作非常相似,但我们将通过手动方式完成。当然,任何表单编辑器能做的,都可以通过 API 访问。
在构建项目后,您可以在mainwindow.cpp的开头按住Ctrl并单击ui_mainwindow.h,以查看实际设置我们的主窗口的代码。您不应编辑此文件,因为您的更改将不会持久保存。
行动时间 – 创建游戏板小部件
在项目树中定位tictactoe文件夹(它是对应于我们整个项目的顶层条目),打开其上下文菜单,并选择添加新... 在左侧列表中选择 C++,在中央列表中选择 C++类。点击选择按钮,在类名字段中输入TicTacToeWidget,并在基类下拉列表中选择 QWidget。点击下一步和完成。Qt Creator 将为我们的新类创建头文件和源文件,并将它们添加到项目中。
在 Creator 中打开tictactoewidget.h文件,并通过添加高亮代码来更新它:
#ifndef TICTACTOEWIDGET_H
#define TICTACTOEWIDGET_H
#include <QWidget>
class TicTacToeWidget : public QWidget
{
Q_OBJECT
public:
TicTacToeWidget(QWidget *parent = nullptr);
~TicTacToeWidget();
private:
QVector<QPushButton*> m_board;
};
#endif // TICTACTOEWIDGET_H
我们的增加创建了一个QVector对象(一个类似于std::vector的容器),它可以存储指向QPushButton类实例的指针,这是 Qt 中最常用的按钮类。我们必须包含包含QPushButton声明的 Qt 头文件。Qt Creator 可以帮助我们快速完成此操作。将文本光标放在QPushButton上,按Alt + Enter,并选择添加#include
从现在起,这本书将不再提醒您添加包含指令到您的源代码中——您必须自己负责这一点。这真的很简单;只需记住,要使用 Qt 类,您需要包含一个以该类命名的文件。
下一步是创建所有按钮并使用布局来管理它们的几何形状。切换到tictactoewidget.cpp文件并定位构造函数。
您可以使用F4键在相应的头文件和源文件之间切换。您还可以使用F2键从方法定义导航到其实施,然后返回。
首先,让我们创建一个将包含我们的按钮的布局:
QGridLayout *gridLayout = new QGridLayout(this);
通过将this指针传递给布局的构造函数,我们将布局附加到我们的小部件上。然后,我们可以开始向布局中添加按钮:
for(int row = 0; row < 3; ++row) {
for(int column = 0; column < 3; ++column) {
QPushButton *button = new QPushButton(" ");
gridLayout->addWidget(button, row, column);
m_board.append(button);
}
}
代码创建了一个遍历棋盘行和列的循环。在每次迭代中,它创建一个QPushButton类的实例。每个按钮的内容被设置为单个空格,以便它获得正确的初始大小。然后,我们将按钮添加到row和column中的布局。最后,我们将按钮的指针存储在之前声明的向量中。这使得我们可以在以后引用任何按钮。它们在向量中的存储顺序是,首先存储第一行的前三个按钮,然后是第二行的按钮,最后是最后一行的按钮。
这对于测试小部件应该是足够的。让我们将其添加到主窗口中。打开mainwindow.ui文件。调用名为gameBoard的空小部件的上下文菜单,并选择提升到。这允许我们将小部件提升到另一个类,即在表单中用另一个类的实例替换小部件。
在我们的案例中,我们希望用我们的游戏板替换空的小部件。在基类名称列表中选择 QWidget,因为我们的TicTacToeWidget是从QWidget继承的。在提升的类名称字段中输入TicTacToeWidget,并验证头文件字段是否包含类头文件的正确名称,如图所示:

然后,点击标有“添加”和“提升”的按钮,以关闭对话框并确认提升。你将不会在表单中注意到任何变化,因为替换仅在运行时发生(然而,你将在对象树中看到gameBoard旁边的TicTacToeWidget类名)。
运行应用程序并检查游戏板是否出现在主窗口中:

刚才发生了什么?
并非所有的小部件类型都在表单设计器中直接可用。有时,我们需要使用仅在构建的项目中创建的小部件类。将自定义小部件放置在表单上的最简单方法就是要求设计者将标准小部件的类名替换为自定义名称。通过将对象提升到不同的类,我们节省了大量尝试将游戏板适配到用户界面的工作。
你现在熟悉了创建自定义小部件的两种方式:你可以使用表单编辑器或从代码中添加小部件。两种方法都很重要。在项目中创建新的小部件类时,根据当前任务选择最方便的方法。
对象的自动删除
你可能已经注意到,尽管我们在构造函数中使用new运算符创建了许多对象,但我们没有在任何地方(例如,在析构函数中)销毁这些对象。这是因为 Qt 管理内存的方式。Qt 不执行垃圾回收(如 C#或 Java 所做的那样),但它有一个与QObject父子层次结构相关的良好特性。规则是,每当一个QObject实例被销毁时,它也会删除其所有子项。这也是为什么我们要将创建的对象设置为父对象的原因之一——如果我们这样做,我们就不必担心显式释放任何内存。
由于我们顶级小部件(MainWindow类的实例)内部的所有布局和小部件都是其直接或间接的子项,因此当主窗口被销毁时,它们都将被删除。MainWindow对象在main()函数中创建,没有使用new关键字,因此将在a.exec()返回后,在应用程序结束时被删除。
当与小部件一起工作时,很容易验证每个对象都有一个合适的父对象。你可以假设显示在窗口内的任何内容都是该窗口的直接或间接子对象。然而,当与不可见对象一起工作时,父子关系变得不那么明显,因此你应该始终检查每个对象是否有一个合适的父对象,并且因此将在某个时候被删除。例如,在我们的TicTacToeWidget类中,gridLayout对象通过构造函数参数(this)接收其父对象。按钮对象最初创建时没有父对象,但addWidget()函数将父小部件分配给它们。
行动时间 – 费用板的功能
我们需要实现一个函数,该函数将在点击板上的任意一个九个按钮时被调用。它必须根据哪个玩家移动来更改被点击按钮的文本——要么是“X”要么是“O”。然后它必须检查这个移动是否导致玩家获胜(如果没有更多移动,则为*局),如果游戏结束,它应该发出适当的信号,通知环境有关事件。
当用户点击按钮时,会发出clicked()信号。将此信号连接到自定义槽位使我们能够实现所提到的功能,但由于信号不携带任何参数,我们如何知道哪个按钮触发了槽位?我们可以将每个按钮连接到单独的槽位,但这是一种丑陋的解决方案。幸运的是,有两种方法可以解决这个问题。当槽位被调用时,可以通过QObject中的特殊方法sender()访问导致信号发出的对象的指针。我们可以使用该指针来确定存储在板列表中的九个按钮中哪一个导致了信号的触发:
void TicTacToeWidget::someSlot() {
QPushButton *button = static_cast<QPushButton*>(sender());
int buttonIndex = m_board.indexOf(button);
// ...
}
虽然sender()是一个有用的调用,但我们应该尽量避免在我们的代码中使用它,因为它破坏了一些面向对象编程的原则。此外,有些情况下调用此函数是不安全的。更好的方法是使用一个专门的类QSignalMapper,它允许我们在不直接使用sender()的情况下实现类似的结果。按照以下方式修改TicTacToeWidget的构造函数:
QGridLayout *gridLayout = new QGridLayout(this);
QSignalMapper *mapper = new QSignalMapper(this);
for(int row = 0; row < 3; ++row) {
for(int column = 0; column < 3; ++column) {
QPushButton *button = new QPushButton(" ");
gridLayout->addWidget(button, row, column);
m_board.append(button);
mapper->setMapping(button, m_board.count() - 1);
connect(button, SIGNAL(clicked()), mapper, SLOT(map()));
}
}
connect(mapper, SIGNAL(mapped(int)),
this, SLOT(handleButtonClick(int)));
在这里,我们首先创建了一个QSignalMapper的实例,并将指向板小部件的指针作为其父对象传递,这样当小部件被删除时,映射器也会被删除。
几乎所有QObject的子类都可以在构造函数中接收父对象的指针。实际上,我们的MainWindow和TicTacToeWidget类也可以这样做,这要归功于 Qt Creator 在它们的构造函数中生成的代码。在自定义基于QObject的类中遵循此规则是推荐的。虽然父参数通常是可选的,但在可能的情况下传递它是好主意,因为当父对象被删除时,对象将被自动删除。然而,有一些情况下这是多余的,例如,当你将小部件添加到布局中时,布局将自动设置父小部件。
然后,当我们创建按钮时,我们“教”映射器每个按钮都有一个与之关联的数字——第一个按钮将具有数字0,第二个按钮将绑定到数字1,依此类推。通过将按钮的clicked()信号连接到映射器的map()槽,我们告诉映射器处理该信号。当映射器从任何按钮接收到信号时,它将找到信号发送者的映射,并发出另一个信号——mapped()——其参数为映射的数字。这允许我们通过一个新的槽(handleButtonClick())连接到该信号,该槽接受按钮在板列表中的索引。
在创建和实现槽之前,我们需要创建一个有用的枚举类型和一些辅助方法。首先,将以下代码添加到tictactoewidget.h文件中的类声明的公共部分:
enum class Player {
Invalid, Player1, Player2, Draw
};
Q_ENUM(Player)
这个枚举让我们可以指定有关游戏中玩家的信息。Q_ENUM宏将使 Qt 识别枚举(例如,它将允许你将此类型的值传递给qDebug(),并使序列化更容易)。通常,在基于QObject的类中使用Q_ENUM是一个好主意。
我们可以立即使用Player枚举来标记现在是哪个玩家的回合。为此,向类中添加一个私有字段:
Player m_currentPlayer;
不要忘记在构造函数中为新字段提供一个初始值:
m_currentPlayer = Player::Invalid;
然后,添加两个公共方法来操作该字段的值:
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(Player);
我们只在当前玩家确实改变时发出currentPlayerChanged信号。你总是要注意,当你将值设置为一个字段,并且该值与函数调用之前该字段的值相同时,不要发出“已更改”信号。你的类的用户期望如果调用“已更改”信号,则只有在值真正更改时才发出。否则,如果你有两个对象,它们将它们的值设置器连接到另一个对象的更改信号,这可能导致信号发射中的无限循环。
现在是时候实现槽本身了。首先,在头文件中声明它:
private slots:
void handleButtonClick(int index);
使用Alt+Enter快速生成新方法的定义,就像我们之前做的那样。
当按下任意按钮时,将调用handleButtonClick()槽函数。点击的按钮索引将作为参数接收。现在我们可以在.cpp文件中实现这个槽函数:
void TicTacToeWidget::handleButtonClick(int index)
{
if (m_currentPlayer == Player::Invalid) {
return; // game is not started
}
if(index < 0 || index >= m_board.size()) {
return; // out of bounds check
}
QPushButton *button = m_board[index];
if(button->text() != " ") return; // invalid move
button->setText(currentPlayer() == Player::Player1 ? "X" : "O");
Player winner = checkWinCondition();
if(winner == Player::Invalid) {
setCurrentPlayer(currentPlayer() == Player::Player1 ?
Player::Player2 : Player::Player1);
return;
} else {
emit gameOver(winner);
}
}
在这里,我们首先根据索引检索按钮的指针。然后,我们检查按钮是否包含空格——如果没有,那么它已经被占用,所以我们从方法中返回,让玩家可以在棋盘上选择另一个字段。接下来,我们在按钮上设置当前玩家的标记。然后,我们检查玩家是否赢得了游戏。如果游戏没有结束,我们切换当前玩家并返回;否则,我们发出一个gameOver()信号,告诉我们的环境谁赢得了游戏。checkWinCondition()方法在游戏结束时返回Player1、Player2或Draw,否则返回Invalid。我们不会在这里展示这个方法的实现,因为它相当长。尝试自己实现它,如果遇到问题,你可以在本书附带的代码包中查看解决方案。
在这个类中,我们最后需要做的是添加另一个公共方法来启动新游戏。这个方法将清除棋盘并设置当前玩家:
void TicTacToeWidget::initNewGame() {
for(QPushButton *button: m_board) {
button->setText(" ");
}
setCurrentPlayer(Player::Player1);
}
现在我们只需要在MainWindow::startNewGame方法中调用这个方法:
void MainWindow::startNewGame()
{
ui->player1Name->setText(tr("Alice"));
ui->player2Name->setText(tr("Bob"));
ui->gameBoard->initNewGame();
}
注意,ui->gameBoard实际上有一个TicTacToeWidget *类型,我们可以调用它的方法,即使表单编辑器对我们的自定义类没有任何具体了解。这是我们在之前所做的提升的结果。
是时候看看这一切是如何协同工作的了!运行应用程序,点击“开始新游戏”按钮,你应该能够玩一些井字棋。
行动时间 – 对游戏板信号的响应
在编写回合制棋盘游戏时,始终清楚地标记现在是哪个玩家的回合进行移动是一个好主意。我们将通过在粗体中标记移动玩家的名字来实现这一点。棋盘类中已经有一个信号告诉我们当前玩家已经改变,我们可以对此做出反应来更新标签。
我们需要将棋盘的currentPlayerChanged信号连接到MainWindow类中的一个新的槽函数。让我们在MainWindow构造函数中添加适当的代码:
ui->setupUi(this);
connect(ui->gameBoard, &TicTacToeWidget::currentPlayerChanged,
this, &MainWindow::updateNameLabels);
现在,对于槽函数本身,在MainWindow类中声明以下方法:
private:
void setLabelBold(QLabel *label, bool isBold);
private slots:
void updateNameLabels();
现在按照以下代码实现它们:
void MainWindow::setLabelBold(QLabel *label, bool isBold)
{
QFont f = label->font();
f.setBold(isBold);
label->setFont(f);
}
void MainWindow::updateNameLabels()
{
setLabelBold(ui->player1Name,
ui->gameBoard->currentPlayer() ==
TicTacToeWidget::Player::Player1);
setLabelBold(ui->player2Name,
ui->gameBoard->currentPlayer() ==
TicTacToeWidget::Player::Player2);
}
刚才发生了什么?
QWidget(以及通过扩展,任何小部件类)有一个font属性,它决定了这个小部件使用的字体属性。这个属性具有QFont类型。我们不能简单地写label->font()->setBold(isBold);,因为font()返回一个 const 引用,所以我们必须复制QFont对象。这个副本与标签没有连接,因此我们需要调用label->setFont(f)来应用我们的更改。为了避免重复这个程序,我们创建了一个辅助函数,称为setLabelBold。
需要完成最后一件事情是处理游戏结束的情况。将来自棋盘的gameOver()信号连接到主窗口类中的一个新槽。如下所示实现该槽:
void MainWindow::handleGameOver(TicTacToeWidget::Player winner) {
QString message;
if(winner == TicTacToeWidget::Player::Draw) {
message = tr("Game ended with a draw.");
} else {
QString winnerName = winner == TicTacToeWidget::Player::Player1 ?
ui->player1Name->text() : ui->player2Name->text();
message = tr("%1 wins").arg(winnerName);
}
QMessageBox::information(this, tr("Info"), message);
}
此代码检查谁赢得了游戏,组装消息(我们将在第六章,Qt 核心基础),并使用静态方法QMessageBox::information()显示包含消息和允许我们关闭对话框的按钮的模态对话框。
运行游戏并检查它现在是否突出显示了当前玩家,并在游戏结束时显示消息。
高级表单编辑器使用
现在是时候给玩家提供一个输入他们名字的方法了。我们将通过添加一个在开始新游戏时出现的游戏配置对话框来实现这一点。
行动时间 - 设计游戏配置对话框
首先,在tictactoe项目的上下文菜单中选择“添加新...”,然后选择创建一个新的 Qt Designer 表单类,如图所示:

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

将类名调整为ConfigurationDialog,将其他设置保留为默认值,并完成向导。项目中出现的文件(.cpp、.h和.ui)与我们创建项目时为MainWindow类生成的文件非常相似。唯一的区别是MainWindow使用QMainWindow作为其基类,而ConfigurationDialog使用QDialog。此外,在main函数中创建了一个MainWindow实例,因此当应用程序启动时它就会显示,而我们需要在代码的其他地方创建一个ConfigurationDialog实例。QDialog实现了对话框的常见行为;除了主要内容外,它还显示一个或多个按钮。当对话框被选中时,用户可以与对话框交互,然后按下其中一个按钮。之后,对话框通常会被销毁。QDialog有一个方便的exec()方法,它不会返回直到用户做出选择,然后它返回有关按下的按钮的信息。我们将在创建对话框后看到这一点。
将两个标签和两个行编辑拖放到表单上,将它们大致放置在一个网格中,双击每个标签,并调整它们的标题以获得以下类似的结果:

将行编辑的objectName属性更改为player1Name和player2Name。然后,在表单中点击一些空白区域,并在上工具栏中选择布局中的网格选项。你应该会看到小部件自动对齐——这是因为你刚刚为表单应用了一个布局。打开工具菜单,转到表单编辑器子菜单,并选择预览选项来预览表单。
加速器和标签伙伴
现在,我们将专注于给对话框添加更多润色。我们首先将要做的是为我们的小部件添加加速器。这些是键盘快捷键,当激活时,会导致特定的小部件获得键盘焦点或执行预定的动作(例如,切换复选框或按下按钮)。加速器通常通过以下方式标记:下划线。

我们将为我们的行编辑设置加速器,这样当用户激活第一个字段的加速器时,它将获得焦点。通过这种方式,我们可以输入第一个玩家的名字,同样地,当第二个行编辑的加速器被触发时,我们可以开始输入第二个玩家的名字。
首先,选择第一个行编辑左侧的第一行标签。按下 F2 并将文本更改为 Player &A Name:。和号字符(&)标记了其后的字符作为小部件的加速器。在某些*台上,加速器可能无法与数字一起工作,所以我们决定使用字母。同样,将第二个标签重命名为 Player &B Name:。
对于由文本和实际功能(例如,按钮)组成的小部件,这足以使加速器工作。然而,由于 QLineEdit 没有任何与之关联的文本,我们必须使用一个单独的小部件。这就是为什么我们在标签上设置了加速器。现在我们需要将标签与行编辑关联起来,以便标签加速器的激活可以将其转发到我们选择的小部件。这是通过为标签设置所谓的 伙伴 来实现的。你可以使用 QLabel 类的 setBuddy 方法在代码中这样做,或者使用创建者的表单设计器。由于我们已经在设计模式中,我们将使用后一种方法。为此,我们需要在表单设计器中激活一个专用模式。
查看创建者窗口的上部;在表单上方,你会找到一个包含几个图标的工具栏。点击标有“编辑好友”的图标!。现在,将鼠标光标移至标签上,按下鼠标按钮,并从标签拖动到行编辑。当你将标签拖动到行编辑上时,你会看到一个图形化的连接设置过程,连接标签和行编辑。如果你现在释放按钮,这个关联将会被永久建立。你应该注意,当这种关联建立后,和号字符(&)将从标签中消失,并且它后面的字符会得到一个下划线。重复此过程为其他标签和相应的行编辑设置。点击表单上方的“编辑小部件”按钮!,以将表单编辑器返回到默认模式。现在,你可以再次预览表单,并检查加速器是否按预期工作;按下 Alt + A 和 Alt + B 应该分别将文本光标设置到第一个和第二个文本字段。
标签顺序
当你在预览表单时,可以检查 UI 设计的另一个方面。注意当表单打开时,哪一行编辑框会获得焦点。有可能第二行编辑框会首先被激活。要检查和修改焦点顺序,请关闭预览,并通过点击工具栏中的图标“编辑标签顺序”切换到标签顺序编辑模式!
。
此模式将一个带有数字的框与每个可聚焦的小部件关联起来。通过按照你希望小部件获得焦点的顺序点击矩形,你可以重新排序值,从而重新排序焦点。现在将其设置为如下所示:

我们的形式只有两个可以接收焦点的小部件(除了对话框的按钮,但它们的标签顺序是自动管理的)。如果你创建了一个包含多个控件的形式,那么当你反复按Tab键时,焦点可能会在按钮和行编辑之间来回跳跃,而不是从上到下的线性进度(这对于这个特定的对话框来说是一个直观的顺序)。你可以使用此模式来纠正标签顺序。
再次进入预览并检查焦点是否根据你的设置发生变化。
在决定标签顺序时,考虑对话框中哪些字段是必填的,哪些是可选的,是个好主意。允许用户首先遍历所有必填字段,然后到对话框确认按钮(例如,一个写着 OK 或 Accept 的按钮),然后遍历所有可选字段是个好主意。这样,用户就可以快速填写所有必填字段并接受对话框,而无需遍历所有用户希望保留为默认值的可选字段。
行动时间 – 对话框的公共接口
下一步是允许从对话框外部存储和读取玩家名称——由于ui组件是私有的,因此无法从类代码外部访问它。这是一个常见的情况,Qt 也符合这种情况。几乎每个 Qt 类中的每个数据字段都是私有的,可能包含访问器(一个获取器和可选的设置器),这些是公共方法,允许我们读取和存储数据字段的值。我们的对话框有两个这样的字段——两个玩家的名称。
Qt 中设置器方法的名称通常以set开头,后跟属性名称,首字母转换为大写。在我们的情况下,两个设置器将被称为setPlayer1Name和setPlayer2Name,它们都将接受QString并返回void。在类头文件中声明它们,如下面的代码片段所示:
void setPlayer1Name(const QString &p1name);
void setPlayer2Name(const QString &p2name);
在.cpp文件中实现它们的主体:
void ConfigurationDialog::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();
}
我们的对话框现在已准备就绪。让我们在 MainWindow::startNewGame 函数中使用它,在开始游戏之前请求玩家名称:
ConfigurationDialog dialog(this);
if(dialog.exec() == QDialog::Rejected) {
return; // do nothing if dialog rejected
}
ui->player1Name->setText(dialog.player1Name());
ui->player2Name->setText(dialog.player2Name());
ui->gameBoard->initNewGame();
在这个槽中,我们创建设置对话框并展示给用户,强迫他们输入玩家名称。exec() 函数只有在对话框被接受或取消时才会返回。如果对话框被取消,我们将放弃创建新游戏。否则,我们向对话框请求玩家名称并将它们设置在适当的标签上。最后,我们初始化棋盘,以便用户可以玩游戏。对话框对象是在没有使用 new 关键字的情况下创建的,所以它将在之后立即被删除。
现在,你可以运行应用程序并查看配置对话框是如何工作的。
应用程序抛光
我们已经实现了我们游戏的所有重要功能,现在我们将通过探索其他 Qt 功能来开始改进它。
尺寸策略
如果你改变我们游戏主窗口的高度,你会注意到不同的部件以不同的方式调整大小。特别是,按钮保持其原始高度,而标签在文本的顶部和底部获得空白区域:

这是因为每个小部件都有一个名为 sizePolicy 的属性,它决定了布局如何调整小部件的大小。您可以分别为水*和垂直方向设置不同的尺寸策略。按钮默认的垂直尺寸策略为 Fixed,这意味着无论有多少可用空间,小部件的高度都不会从默认高度改变。标签默认的尺寸策略为 Preferred。以下是可以用的尺寸策略:
-
Ignored:在这种情况下,小部件的默认大小被忽略,小部件可以自由地增长和缩小 -
Fixed:在这种情况下,默认大小是小部件唯一允许的大小 -
Preferred:在这种情况下,默认大小是期望的大小,但较小和较大的尺寸都是可接受的 -
Minimum:在这种情况下,默认大小是小部件可以接受的最小大小,但小部件可以被放大而不会损害其功能 -
Maximum:在这种情况下,默认大小是小部件的最大大小,小部件可以被缩小(甚至缩小到无),而不会损害其功能 -
Expanding:在这种情况下,默认大小是期望的大小;较小的尺寸(甚至为零)是可接受的,但小部件能够在其分配更多空间时增加其有用性 -
MinimumExpanding:这是Minimum和Expanding的组合——小部件在空间方面是贪婪的,并且不能被缩小到小于其默认大小
我们如何确定默认大小?答案是sizeHint虚拟方法返回的大小。对于布局,大小是根据其子小部件和嵌套布局的大小和大小策略计算的。对于基本小部件,sizeHint返回的值取决于小部件的内容。在按钮的情况下,如果它包含一行文本和一个图标,sizeHint将返回包含文本、图标、它们之间的一些空间、按钮框架以及框架和内容本身之间的填充所需的大小。
在我们的表单中,我们希望当主窗口大小改变时,标签的高度保持不变,而游戏板按钮则增长。为此,在表单编辑器中打开mainwindow.ui,选择第一个标签,然后按住Ctrl并点击第二个标签。现在两个标签都被选中,因此我们可以同时编辑它们的属性。在属性编辑器中找到sizePolicy(如果你在查找属性时遇到困难,请使用属性编辑器上方的过滤器字段)。通过点击左侧的三角形来展开它。将垂直策略设置为固定。你将立即在表单布局中看到变化。
游戏板上的按钮是在代码中创建的,因此导航到TicTacToeWidget类的构造函数,并使用以下代码设置大小策略:
QPushButton *button = new QPushButton(" ");
button->setSizePolicy(QSizePolicy::Preferred,
QSizePolicy::Preferred);
这将改变按钮的水*垂直策略为Preferred。运行游戏并观察变化:

防止无效输入
配置对话框之前没有进行任何验证。让我们使其按钮仅在两个行编辑器都不为空(即两个字段都包含玩家名称)时启用。为此,我们需要将每个行编辑的textChanged信号连接到一个将执行此任务的槽。
首先,转到configurationdialog.h文件,并在ConfigurationDialog类中创建一个私有槽void updateOKButtonState();(你需要手动添加private slots部分)。使用以下代码来实现此槽:
void ConfigurationDialog::updateOKButtonState()
{
QPushButton *okButton = ui->buttonBox->button(QDialogButtonBox::Ok);
okButton->setEnabled(!ui->player1Name->text().isEmpty() &&
!ui->player2Name->text().isEmpty());
}
此代码要求包含 OK 和 Cancel 按钮的按钮框提供接受对话框的指针(我们必须这样做,因为按钮不是直接包含在表单中,所以在ui中没有它们的字段)。然后,我们根据两个玩家名称是否包含有效值来设置按钮的enabled属性。
接下来,编辑对话框的构造函数以将两个信号连接到我们新的槽。当第一次创建对话框时,按钮状态也需要更新,因此将updateOKButtonState()的调用添加到构造函数中:
ui->setupUi(this);
connect(ui->player1Name, &QLineEdit::textChanged,
this, &ConfigurationDialog::updateOKButtonState);
connect(ui->player2Name, &QLineEdit::textChanged,
this, &ConfigurationDialog::updateOKButtonState);
updateOKButtonState();
主菜单和工具栏
如您所记,任何没有父级的小部件都会显示为一个窗口。然而,当我们创建主窗口时,我们选择了 QMainWindow 作为基类。如果我们选择了 QWidget,我们仍然能够做到这一点。然而,QMainWindow 类提供了一些独特的功能,我们现在将使用这些功能。
主窗口代表应用程序的控制中心。它可以包含菜单、工具栏、停靠小部件、状态栏以及包含窗口主要内容的中心小部件,如下面的图示所示:

如果您打开 mainwindow.ui 文件并查看对象树,您将看到包含我们的表单的强制性的 centralWidget。还有可选的 menuBar、mainToolBar 和 statusBar,它们是在 Qt Creator 生成表单时自动添加的。
中心小部件部分不需要额外的解释;它就像任何其他常规小部件一样。我们也不会在这里关注停靠小部件或状态栏。它们是有用的组件,但您可以自己学习它们。相反,我们将花一些时间掌握菜单和工具栏。您肯定在许多应用程序中看到并使用过工具栏和菜单,您知道它们对于良好的用户体验是多么重要。
主菜单有一些不寻常的行为。它通常位于窗口的顶部,但在 macOS 和一些 Linux 环境中,主菜单与窗口分离,显示在屏幕的顶部区域。另一方面,工具栏可以被用户自由移动,并水*或垂直停靠在主窗口的两侧。
这两个概念共享的主要类是QAction,它代表用户可以调用的功能。单个动作可以在多个地方使用——它可以是一个菜单(QMenu实例)或工具栏(QToolBar)、按钮或键盘快捷键(QShortcut)的条目。操作动作(例如,更改其文本)会导致所有其化身更新。例如,如果你在菜单中有一个带有键盘快捷键的“保存”条目(与工具栏中的保存图标和可能还位于用户界面其他位置的保存按钮相关联),并且你想要禁止保存文档(例如,你的地下城与龙游戏关卡编辑器中的地图)因为自上次加载文档以来其内容没有变化。在这种情况下,如果菜单条目、工具栏图标和按钮都链接到同一个QAction实例,那么一旦你将动作的enabled属性设置为false,这三个实体都将被禁用。这是一个保持应用程序不同部分同步的简单方法——如果你禁用动作对象,你可以确信触发动作所代表的功能的所有条目也都已被禁用。动作可以在代码中实例化或使用 Qt Creator 中的动作编辑器图形化创建。动作可以与不同的数据相关联——文本、工具提示、状态栏提示、图标以及其他较少使用的其他内容。所有这些都被你的动作的化身所使用。
动作时间 – 创建菜单和工具栏
让我们把无聊的“开始新游戏”按钮替换成一个菜单项和一个工具栏图标。首先,选择按钮并按Delete键删除它。然后,在表单编辑器的底部中央找到动作编辑器,并点击其工具栏上的“新建”按钮。在对话框中输入以下值(你可以通过按下你想要使用的快捷键组合来填充“快捷键”字段):

在中央区域(在“在此输入文本”和第一个标签之间)找到工具栏,并将包含“新游戏”动作的行从动作编辑器拖到工具栏上,这将导致工具栏中出现一个按钮。
要为窗口创建一个菜单,双击表单顶部的“在此输入文本”并替换文本为&File(尽管我们的应用程序不处理文件,但我们将遵循这一传统)。然后,将“新游戏”动作从动作编辑器拖到新创建的菜单上,但不要将其放下。现在菜单应该打开了,你可以拖动动作,直到在子菜单中你想要菜单项出现的位置出现一个红色条,现在你可以释放鼠标按钮来创建该条目。
现在我们应该恢复在删除按钮时被破坏的功能。导航到MainWindow类的构造函数并调整connect()调用:
connect(ui->startNewGame, &QAction::triggered,
this, &MainWindow::startNewGame);
操作,就像小部件一样,可以通过ui对象访问。ui->startNewGame对象现在是一个QAction而不是QPushButton,我们使用它的triggered()信号来检测操作是否以某种方式被选中。
现在,如果你运行应用程序,你可以选择菜单项,点击工具栏上的按钮,或者按*Ctrl* + *N*键。这些操作中的任何一个都会导致操作发出triggered()`信号,游戏配置对话框应该会出现。
与小部件一样,QAction对象有一些有用的方法,可以在我们的表单类中访问。例如,执行ui->startNewGame->setEnabled(false)将禁用触发新游戏操作的所有方式。
让我们再添加一个退出应用程序的操作(尽管用户可以通过关闭主窗口来做到这一点)。使用操作编辑器添加一个带有文本Quit、对象名quit和快捷键Ctrl + Q的新操作。将其添加到菜单和工具栏中,就像第一个操作一样。
我们可以添加一个新的槽来停止应用程序,但这样的槽已经在QApplication中存在,所以让我们重用它。在mainwindow.cpp中找到我们表单的构造函数,并附加以下代码:
connect(ui->quit, &QAction::triggered,
qApp, &QApplication::quit);
刚才发生了什么?
qApp宏是一个指向应用程序单例对象的函数的快捷方式,因此当操作被触发时,Qt 将调用在main()中创建的QApplication对象的quit()槽,这将反过来导致应用程序结束。
Qt 资源系统
工具栏中的按钮通常显示图标而不是文本。为了实现这一点,我们需要将图标文件添加到我们的项目中,并将它们分配给创建的操作。
创建图标的一种方法是从文件系统中加载图像。问题是你必须安装与应用程序一起的一堆文件,并且你需要始终知道它们的位置,以便能够提供路径来访问它们。幸运的是,Qt 提供了一种方便且可移植的方法,可以将任意文件(如图标图像)直接嵌入到可执行文件中。这是通过准备随后编译到二进制中的资源文件来完成的。Qt Creator 也为此提供了一个图形工具。
行动时间 – 向项目中添加图标
我们将向“开始新游戏”和“退出”操作添加图标。首先,使用你的文件管理器在项目目录中创建一个名为icons的新子目录。在该目录中放置两个图标文件。你可以使用书中提供的图标文件。
在tictactoe项目的上下文菜单中点击“添加新...”,然后在 Qt 类别中选择 Qt 资源文件。将其命名为resources,完成向导。Qt Creator 将为项目添加一个新的resources.qrc文件(它将在项目树中的“资源”类别下显示)。
在 Qt Creator 的项目树中定位新的 resources.qrc 文件,并在其上下文菜单中选择“添加现有文件...”。选择两个图标,并确认将它们添加到资源中。
打开 mainwindow.ui 表单,并在动作编辑器中双击一个动作。点击图标字段旁边的“...”按钮,在窗口的左侧选择图标,并在窗口的右侧选择合适的图标。一旦你在对话框中确认更改,工具栏上的相应按钮将切换为显示图标而不是文本。菜单项也将获得所选的图标。为第二个动作重复此操作。我们的游戏现在应该看起来像这样:

尝试一下英雄扩展游戏
你可以在项目中做出很多细微的改进。例如,你可以更改主窗口的标题(通过编辑其 windowTitle 属性),为动作添加快捷键,禁用点击时无操作的棋盘按钮,移除状态栏,或用它来显示游戏状态。
作为一项附加练习,你可以尝试修改本章中我们编写的代码,以便在大于 3 × 3 的棋盘上玩游戏。让用户决定棋盘的大小(你可以修改游戏选项对话框来实现这一点,并使用 QSlider 和 QSpinBox 允许用户选择棋盘的大小),然后你可以指导 TicTacToeWidget 根据它得到的大小构建棋盘。记住调整游戏胜利逻辑!如果在任何时刻你遇到了死胡同,不知道要使用哪些类和函数,请查阅参考手册。
快速问答
Q1. 哪些类可以有信号?
-
所有从
QWidget派生的类。 -
所有从
QObject派生的类。 -
所有类。
Q2. 对于以下哪个,你必须提供自己的实现?
-
一个信号。
-
一个槽。
-
两者都。
Q3. 返回小部件首选大小的方法被称为以下哪个?
-
preferredSize。 -
sizeHint。 -
defaultSize。
Q4. QAction 对象的目的是什么?
-
它代表用户可以在程序中调用的功能。
-
它包含一个键序列,用于将焦点移动到小部件上。
-
它是使用表单编辑器生成的所有表单的基类。
概述
在本章中,你学习了如何使用 Qt 创建简单的图形用户界面。我们介绍了两种方法:使用图形工具设计用户界面,该工具为我们生成大部分代码,以及通过直接编写所有代码来创建用户界面类。它们没有哪一个比另一个更好。表单设计器允许你避免样板代码,并帮助你处理带有大量控件的大型表单。另一方面,编写代码的方法让你对过程有更多的控制,并允许你创建自动填充和动态的界面。
我们还学习了如何在 Qt 中使用信号和槽。现在您应该能够通过将信号连接到槽(预定义的以及您现在知道如何定义并填充代码的自定义槽)来创建简单的用户界面并填充逻辑。
Qt 包含许多小部件类型,但我们并没有逐一向您介绍它们。Qt 手册中有一个名为 Qt 小部件画廊的非常棒的说明,其中展示了大多数小部件的实际应用。如果您对使用这些小部件中的任何一个有任何疑问,您可以查看示例代码,并在 Qt 参考手册中查找相应的类以了解更多信息。
正如您已经看到的,Qt 允许您创建自定义小部件类,但在这个章节中,我们的自定义类主要重用了默认的小部件。您也可以修改小部件对事件的响应方式,并实现自定义绘制。我们将在第八章自定义小部件中深入探讨这个高级主题。然而,如果您想实现一个带有自定义 2D 图形的游戏,有一个更简单的替代方案——我们将在下一章中使用的图形视图框架。
第四章:使用图形视图进行自定义 2D 图形
小部件非常适合设计图形用户界面,但如果你想要一起使用具有自定义绘制和行为的多重对象,例如在 2D 游戏中,它们就不太方便了。如果你希望同时动画化多个小部件,通过在应用程序中不断移动它们,你也会遇到问题。在这些情况下,或者一般来说,对于经常变换 2D 图形的情况,Qt 为你提供了图形视图。在本章中,你将学习图形视图架构及其项目的基础知识。你还将学习如何将小部件与图形视图项目结合使用。
本章涵盖的主要主题如下:
-
图形视图架构
-
坐标系统
-
标准图形项目
-
笔刷和画笔
-
图形视图的有用功能
-
创建自定义项目
-
事件处理
-
在视图中嵌入小部件
-
优化
图形视图架构
图形视图框架是 Qt 小部件模块的一部分,它提供了一个更高层次的抽象,这对于自定义 2D 图形非常有用。它默认使用软件渲染,但它非常优化,并且使用起来非常方便。如图所示,三个组件构成了图形视图的核心:
-
QGraphicsView的一个实例,被称为视图 -
QGraphicsScene的一个实例,被称为场景 -
QGraphicsItem的实例,被称为项目
常规的工作流程是首先创建几个项目,将它们添加到场景中,然后在一个视图中显示该场景:

之后,你可以从代码中操作项目并添加新项目,同时用户也有能力与可见项目进行交互。
将项目视为便利贴。你可以拿一张便利贴并写上一条信息,画上一个图像,或者两者兼而有之,或者,很可能是直接留白。Qt 提供了大量的项目类,所有这些类都继承自QGraphicsItem。你也可以创建自己的项目类。每个类都必须提供一个paint()函数的实现,该函数负责绘制当前项目,以及一个boundingRect()函数的实现,该函数必须返回paint()函数绘制的区域的边界。
那么,场景是什么呢?好吧,把它想象成一张更大的纸,你可以在上面贴上你的小便利贴,也就是笔记。在场景上,你可以自由地移动项目,并对它们应用有趣的变换。正确显示项目的位置和对其应用的任何变换是场景的责任。场景还会通知项目任何影响它们的事件。
最后,但同样重要的是,让我们把注意力转向 视图。将视图想象成一个检查窗口或一个人,他手里拿着带有笔记的纸张。你可以看到整个纸张,或者你只能看到特定的部分。此外,正如一个人可以用手旋转和剪切纸张一样,视图也可以旋转和剪切场景的内容,并对其进行更多变换。QGraphicsView 是一个小部件,因此你可以像使用任何其他小部件一样使用视图,并将其放入布局中,以创建整洁的图形用户界面。
你可能已经看过前面的图表,并担心所有项目都在视图之外。它们不是在浪费 CPU 时间吗?你不需要通过添加所谓的 视图视锥剔除 机制(检测哪些项目不需要绘制/渲染因为它不可见)来照顾它们吗?嗯,简短的答案是“不”,因为 Qt 已经在处理这个问题了。
实践时间 - 使用图形视图创建项目
让我们把所有这些组件组合在一个简约的项目中。从欢迎屏幕,点击新建项目按钮,再次选择 Qt Widgets 应用程序。将项目命名为 graphics_view_demo,选择正确的工具包,取消选择“生成表单”复选框,完成向导。实际上我们不需要为我们生成的 MainWindow 类,所以让我们从项目中删除它。在项目树中定位 mainwindow.h,并在上下文菜单中选择“移除文件”。启用“永久删除文件”复选框,然后点击确定。这将导致从磁盘删除 mainwindow.h 文件,并从 graphics_view_demo.pro 文件中删除其名称。如果文件在 Qt Creator 中打开,它将建议你关闭它。为 mainwindow.cpp 重复此过程。
打开 main.cpp 文件,删除 #include "mainwindow.h",并编写以下代码:
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
QGraphicsScene scene;
QGraphicsRectItem *rectItem =
new QGraphicsRectItem(QRectF(0, 0, 100, 50));
scene.addItem(rectItem);
QGraphicsEllipseItem *circleItem =
new QGraphicsEllipseItem(QRect(0, 50, 25, 25));
scene.addItem(circleItem);
QGraphicsSimpleTextItem *textItem =
new QGraphicsSimpleTextItem(QObject::tr("Demo"));
scene.addItem(textItem);
QGraphicsView view(&scene);
view.show();
return a.exec();
}
当你运行项目时,你应该得到以下结果:

发生了什么?
我们的新项目非常简单,所有代码都位于 main() 函数中。让我们检查一下代码。首先,我们创建一个 QApplication 对象,就像在任何 Qt Widgets 项目中一样。接下来,我们创建一个场景对象和三个不同项目类的实例。每个项目类的构造函数接受一个参数,用于定义项目的内容:
-
QGraphicsRectItem构造函数接收一个QRectF对象,其中包含矩形的坐标 -
与此类似,
QGraphicsEllipseItem构造函数接收一个QRectF对象,该对象定义了圆的边界矩形 -
QGraphicsSimpleTextItem构造函数接收要显示的文本
QRectF基本上是一个包含四个字段的实用结构,允许我们指定矩形的边界坐标(左、上、宽和高)。Qt 还提供了QPointF,它包含点的x和y坐标,QLineF包含线的两个端点的x和y坐标,以及QPolygonF,它包含点的向量。F字母代表“浮点”并表明这些类包含实数。它们在图形视图中被广泛使用,因为它始终使用浮点坐标。没有F的对应类(如QPoint、QRect等)存储整数坐标,并且在处理小部件时更有用。
创建每个项目后,我们使用QGraphicsScene::addItem函数将项目添加到场景中。最后,我们创建一个QGraphicsView对象,并将场景的指针传递给其构造函数。show()方法将使视图可见,就像对任何QWidget一样。程序以a.exec()调用结束,这是必要的,以启动事件循环并保持应用程序运行。
场景会接管项目,因此它们将随着场景的删除而自动删除。这也意味着一个项目只能添加到一个场景中。如果项目之前被添加到另一个场景中,它将在添加到新场景之前从那里移除。
如果你想要从场景中移除一个项目,而不直接将其设置到另一个场景或删除它,你可以调用scene.removeItem(rectItem)。然而,请注意,现在你有责任删除rectItem以释放分配的内存!
检查生成的窗口并将其与代码中矩形的坐标进行比较(我们使用的QRectF构造函数接受以下顺序的四个参数:左、上、宽、高)。你应该能够看到所有三个元素都位于一个坐标系统中,其中x轴指向右,y轴指向下。我们没有为文本项目指定任何坐标,因此它显示在原点(即坐标为零的点)旁边,位于矩形的左上角。然而,这个(0,0)点并不对应于窗口的左上角。实际上,如果你调整窗口大小,你会注意到原点相对于窗口发生了偏移,因为视图试图将场景内容居中显示。
坐标系统
正确使用图形视图,你需要了解这个框架中坐标系统的工作原理。我们将遍历所有层次,看看我们如何在每个层次上改变项目位置和整个场景的位置。我们将提供你可以粘贴到我们的演示项目中的代码示例,并检查其效果。
项目的坐标系
每个项目都有自己的坐标系。在我们的便签示例中,每张便签的内容是相对于便签的左上角定义的。无论你如何移动或旋转项目,这些坐标都保持不变。绘制对象的坐标通常可以传递给类的构造函数,就像我们在我们的演示项目中做的那样,或者传递给一个特殊的设置函数(例如,rectItem->setRect(0, 10, 20, 25))。这些是在项目坐标系中的坐标。
一些类,例如 QGraphicsSimpleTextItem,不提供更改内容坐标的能力,因此它们始终位于项目坐标系的起点。这根本不是问题;正如我们接下来将要看到的,有方法可以更改内容的可见位置。
如果你尝试创建自己的图形项目类(我们将在本章后面讨论这个问题),你需要实现 paint() 和 boundingRect() 函数,并且它们总是在项目的坐标系中操作。没错,当你绘制内容时,你只需假装你的项目永远不会移动或变换。当这实际上发生时,Qt 会为你处理绘制操作的变换。此外,项目接收到的任何事件中的坐标(例如,鼠标按钮点击的坐标)都是以项目的坐标系表示的。
场景的坐标系
任何项目都可以使用 setPos() 函数在场景中移动。尝试调用 textItem->setPos(50, 50) 并验证文本是否在场景中移动。技术上,这个操作改变了项目坐标系和场景坐标系之间的变换。一个名为 moveBy() 的便利函数允许你通过指定的量移动位置。
项目也可以使用 setRotation() 旋转,并使用 setScale() 缩放。尝试调用 textItem->setRotation(20) 来查看这个动作。如果你需要更高级的变换,例如剪切,或者你想要以特定的顺序执行多个*移,你可以创建一个 QTransform 对象,应用所需的变换,并使用项目的 setTransform() 函数。
setRotation() 函数接受 qreal 作为参数值,这通常是对 double 的 typedef。该函数将数字解释为围绕 z 坐标的顺时针旋转的度数。如果你设置一个负值,则执行逆时针旋转。即使这没有太多意义,你也可以通过 450 度旋转一个项目,这将导致 90 度的旋转。
视口坐标系统
视图由 视口 和两个滚动条组成。视口是一个子小部件,实际上包含场景的内容。视图根据多个参数执行从场景坐标到视口坐标的转换。
首先,视图需要知道场景中我们想要看到的每一项的边界矩形。它被称为场景矩形,在场景坐标系中进行测量。默认情况下,场景矩形是自创建以来添加到场景的所有项的边界矩形。这通常没问题,但如果移动或删除一个项,该边界矩形不会缩小(由于性能原因),这可能会导致显示大量不想要的空白空间。幸运的是,在这种情况下,你可以使用场景或视图的setSceneRect函数手动设置场景矩形。
QGraphicsScene::setSceneRect和QGraphicsView::setSceneRect之间的区别通常很小,因为你通常每个场景只有一个视图。然而,对于单个场景,可能存在多个视图。在这种情况下,QGraphicsScene::setSceneRect为所有视图设置场景矩形,而QGraphicsView::setSceneRect允许你为每个视图覆盖场景矩形。
如果对应场景矩形的区域足够小,可以适应视口,视图将根据视图的alignment属性对内容进行对齐。如我们之前所见,默认情况下它将内容置于中心。例如,调用view.setAlignment(Qt::AlignTop | Qt::AlignLeft)将导致场景保持在视图的左上角。
如果场景矩形区域太大,无法适应视口,则默认会显示水*或垂直滚动条。可以使用它们来滚动视图并查看场景矩形内的任何点(但不超过它)。滚动条的存在也可以通过视图的horizontalScrollBarPolicy和verticalScrollBarPolicy属性进行配置。
尝试调用scene.setSceneRect(0, 20, 100, 100)并查看在调整窗口大小时视图的行为。如果窗口太小,场景的顶部将不再可见。如果窗口足够大且视图具有默认对齐方式,场景的顶部将可见,但只有定义的场景矩形将居中,不考虑其外的项。
视图提供了转换整个场景的能力。例如,你可以调用view.scale(5, 5)使一切放大五倍,view.rotate(20)整体旋转场景,或view.shear(1, 0)进行剪切。与项目一样,你可以使用setTransform()方法应用更复杂的转换。
你可能会注意到,图形视图(以及 Qt 小部件通常)默认使用左手坐标系,其中x轴指向右,y轴指向下。然而,OpenGL 和科学相关应用程序通常使用右手或标准坐标系,其中y轴向上。如果你需要改变y轴的方向,最简单的解决方案是通过调用view.scale(1, -1)来变换视图。
变换的原点
在我们的下一个示例中,我们将在(0, 0)点创建一个十字形,并将一个矩形添加到场景中:

你可以用以下代码做到这一点:
scene.addLine(-100, 0, 100, 0);
scene.addLine(0, -100, 0, 100);
QGraphicsRectItem* rectItem = scene.addRect(50, 50, 50, 50);
在这段代码中,我们使用了addLine()和addRect()便利函数。这相当于手动创建一个QGraphicsLineItem或QGraphicsRectItem并将其添加到场景中。
现在,假设你想要将矩形旋转 45 度以产生以下结果:

直接尝试这样做将使用setRotation()方法:
QGraphicsRectItem* rectItem = scene.addRect(50, 50, 50, 50);
rectItem->setRotation(45);
然而,如果你尝试这样做,你将得到以下结果:

刚才发生了什么?
大多数变换都依赖于坐标系的原点。对于旋转和缩放,原点是唯一保持不变的点。在上面的例子中,我们使用了一个左上角在(50, 50)且大小为(50, 50)的矩形。这些坐标是在项目的坐标系中。由于我们最初没有移动项目,项目的坐标系与场景的坐标系相同,原点也与场景的原点相同(它是以十字标记的点)。应用的旋转使用(0, 0)作为旋转中心,因此产生了意想不到的结果。
有多种方法可以克服这个问题。第一种方法是改变变换的原点:
QGraphicsRectItem* rectItem = scene.addRect(50, 50, 50, 50);
rectItem->setTransformOriginPoint(75, 75);
rectItem->setRotation(45);
这段代码产生了我们想要的旋转,因为它改变了setRotation()和setScale()函数使用的原点。注意,项目的坐标系没有被*移,并且(75, 75)点在项目的坐标系中仍然是矩形的中心。
然而,这个解决方案有其局限性。如果你使用setTransform()而不是setRotation(),你将再次得到不想要的结果:
QGraphicsRectItem* rectItem = scene.addRect(50, 50, 50, 50);
rectItem->setTransformOriginPoint(75, 75);
QTransform transform;
transform.rotate(45);
rectItem->setTransform(transform);
另一种解决方案是将矩形设置得使其中心位于项目坐标系的原点:
QGraphicsRectItem* rectItem = scene.addRect(-25, -25, 50, 50);
rectItem->setPos(75, 75);
这段代码使用完全不同的矩形坐标,但结果与我们的第一个例子完全相同。然而,现在,场景坐标中的(75, 75)点对应于项目坐标中的(0, 0)点,因此所有变换都将使用它作为原点:
QGraphicsRectItem* rectItem = scene.addRect(-25, -25, 50, 50);
rectItem->setPos(75, 75);
rectItem->setRotation(45);
这个例子表明,通常更方便将项目设置得使其原点对应于其实际位置。
尝试一下英雄——应用多个变换
要理解变换及其原点的概念,尝试对一个项目应用rotate()和scale()。然后,改变原点并看看项目将如何反应。作为第二步,使用QTransform与setTransform()结合,以特定顺序对一个项目应用多个变换。
项目之间的父子关系
假设您需要创建一个包含多个几何原型的图形项,例如,矩形内部的圆。您可以单独创建这两个项目并将它们添加到场景中,但这种方法不方便。首先,当您需要从场景中删除该组合时,您需要手动删除这两个项目。然而,更重要的是,当您需要移动或变换该组合时,您将需要为每个图形项计算位置和复杂的变换。
幸运的是,图形项不必是直接添加到场景中的扁*项目列表。项目可以被添加到任何其他项目中,形成一个类似于我们在上一章中观察到的 QObject 关系的父子关系:

将一个项目作为另一个项目的子项具有以下后果:
-
当父项被添加到场景中时,子项自动成为该场景的一部分,因此不需要为它调用
QGraphicsScene::addItem()。 -
当父项被删除时,其子项也会被删除。
-
当使用
hide()或setVisible(false)函数隐藏父项时,子项也会被隐藏。 -
最重要的是,子项的坐标系是从父项的坐标系派生出来的,而不是从场景的坐标系。这意味着当父项被移动或变换时,所有子项也会受到影响。子项的位置和变换相对于父项的坐标系。
您可以使用 parentItem() 函数始终检查一个项目是否有父项,并通过比较返回的 QGraphicsItem 指针与 nullptr 来检查,这意味着该项目没有父项。要确定是否有任何子项,请在项目上调用 childItems() 函数。该方法返回一个包含所有子项 QGraphicsItem 指针的 QList。
为了更好地理解pos()及其涉及的坐标系,再次想想便利贴。如果你在一张更大的纸上贴上一张便利贴,然后需要确定它的确切位置,你会怎么做?可能像这样:“便利贴的左上角位于纸张左上角的右边 3 厘米和下面 5 厘米处”。在图形视图世界中,这将对应该没有父项的项,其pos()函数返回场景坐标中的位置,因为项的原点直接固定到场景中。另一方面,假设你在已经贴在纸上的(更大的)便利贴 B 的上面贴上便利贴 A,你需要确定 A 的位置;这次你会怎么描述它?可能通过说便利贴 A 放在便利贴 B 的上面,或者“从便利贴 B 的左上角右边 2 厘米和下面 1 厘米处”。你很可能不会使用底下的纸张作为参考,因为它不是下一个参考点。这是因为如果你移动便利贴 B,A 相对于纸张的位置会改变,而 A 相对于 B 的相对位置仍然保持不变。要切换回图形视图,等效的情况是一个具有父项的项。在这种情况下,pos()函数返回的值是在其父项的坐标系中表达的。因此,setPos()和pos()指定了项的原点相对于下一个(更高)参考点的位置。这可以是场景或项的父项。所以,setPos()和pos()指定了项的原点相对于下一个(更高)参考点的位置。这可以是场景或项的父项。
然而,请注意,改变项的位置不会影响项的内部坐标系。
对于小部件,子项始终占据其直接父项的子区域。对于图形项,默认情况下不适用这样的规则。子项可以显示在父项的边界矩形或可见内容之外。实际上,一个常见的情况是父项本身没有视觉内容,而只作为属于一个对象的一组原语集合的容器。
行动时间 – 使用子项
让我们尝试创建一个包含多个子项的项。我们想要创建一个矩形,每个角落都有一个填充的圆,并且能够作为一个整体移动和旋转,如下所示:

首先,你需要创建一个函数,通过以下代码创建一个单个复杂矩形:
QGraphicsRectItem *createComplexItem(
qreal width, qreal height, qreal radius)
{
QRectF rect(-width / 2, -height / 2, width, height);
QGraphicsRectItem *parent = new QGraphicsRectItem(rect);
QRectF circleBoundary(-radius, -radius, 2 * radius, 2 * radius);
for(int i = 0; i < 4; i++) {
QGraphicsEllipseItem *child =
new QGraphicsEllipseItem(circleBoundary, parent);
child->setBrush(Qt::black);
QPointF pos;
switch(i) {
case 0:
pos = rect.topLeft();
break;
case 1:
pos = rect.bottomLeft();
break;
case 2:
pos = rect.topRight();
break;
case 3:
pos = rect.bottomRight();
break;
}
child->setPos(pos);
}
return parent;
}
我们首先创建一个包含矩形坐标的QRectF变量,这些坐标位于项目的坐标系中。按照我们之前提供的提示,我们创建一个以原点为中心的矩形。接下来,我们创建一个通常称为parent的矩形图形项。将circleBoundary矩形设置为包含单个圆的边界矩形(再次强调,中心位于原点)。当我们为每个角落创建一个新的QGraphicsEllipseItem时,我们将parent传递给构造函数,因此新的圆形项自动成为矩形项的子项。
要设置子圆,我们首先使用 setBrush() 函数,该函数允许填充圆。此函数期望一个 QBrush 对象,允许你指定高级填充样式,但在我们的简单情况下,我们使用从 Qt::GlobalColor 枚举到 QBrush 的隐式转换。你将在本章的后面部分了解更多关于画笔的信息。
接下来,我们为每个圆选择矩形的不同角落,并调用 setPos() 将圆移动到该角落。最后,我们将父项目返回给调用者。
你可以使用此函数如下:
QGraphicsRectItem *item1 = createComplexItem(100, 60, 8);
scene.addItem(item1);
QGraphicsRectItem *item2 = createComplexItem(100, 60, 8);
scene.addItem(item2);
item2->setPos(200, 0);
item2->setRotation(20);
注意,当你调用 setPos() 时,圆会随着父项目一起移动,但圆的 pos() 值不会改变。这是由于 pos() 表示相对于父项目(或如果没有父项目,则为场景的起点)的位置的事实。当矩形旋转时,圆会随着它一起旋转,就像它们被固定在角落一样。如果圆不是矩形的子项,在这个情况下,正确定位它们将是一个更具挑战性的任务。
英雄尝试一下 - 将自定义矩形作为类实现
在这个例子中,我们为了避免创建自定义矩形的类,使代码尽可能简单。遵循面向对象编程的原则,在新的类的构造函数中子类化 QGraphicsRectItem 并创建子项目是一个好主意。这样做不需要你不知道的任何东西。例如,当子类化 QGraphicsRectItem 时,你不需要实现任何虚拟函数,因为它们都在基类中得到了适当的实现。
坐标系之间的转换
如果一个项目仅使用 setPos() 移动,则从项目坐标到场景坐标的转换就像 sceneCoord = itemCoord + item->pos() 那样简单。然而,当你使用变换和父子关系时,这种转换很快就会变得非常复杂,因此你应该始终使用专用函数来执行此类转换。QGraphicsItem 提供以下函数:
| 函数 | 描述 |
|---|
| mapToScene(
const QPointF &point) | 将项目坐标系中的点 point 映射到场景坐标系中的对应点。|
scenePos() |
将项目的原点映射到场景坐标系。这与 mapToScene(0, 0) 相同。 |
|---|---|
sceneBoundingRect() |
返回项目在场景坐标系中的边界矩形。 |
mapFromScene( const QPointF &point) |
将场景坐标系中的点 point 映射到项目坐标系中的对应点。此函数是 mapToScene() 的逆函数。 |
mapFromParent( const QPointF &point) 将位于项目坐标系统中的点 point 映射到项目父级坐标系统中的对应点。如果项目没有父级,此函数的行为类似于 mapToScene();因此,它返回场景坐标系统中的对应点。 |
|
mapFromParent( const QPointF &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 坐标 -
如果您使用四个数字调用这些函数,这些数字被解释为 x 和 y 坐标以及
QRectF参数的宽度和高度
QGraphicsView 类还包含一组 mapToScene() 函数,这些函数将视口坐标系统的坐标映射到场景坐标,以及 mapFromScene() 函数,这些函数将场景坐标映射到视口坐标。
功能概述
您现在应该对 Graphics View 的架构和转换机制有所了解。我们将现在描述一些易于使用的功能,这些功能在创建 Graphics View 应用程序时您可能会用到。
标准项目
为了有效地使用框架,您需要了解它提供的图形项目类。识别您可以使用来构建所需图片的类很重要,只有在没有合适的项目或您需要更好的性能时,才应创建自定义项目类。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 不同,此项目可以显示存储在 QTextDocument 对象中的 HTML。您可以使用 setHtml(const QString&) 设置 HTML,并使用 setDocument(QTextDocument*) 设置文档。QGraphicsTextItem 甚至可以与显示的文本交互,以便进行文本编辑或打开 URL。 |
QGraphicsPixmapItem |
绘制位图(一个光栅图像)。您可以使用 setPixmap(const QPixmap&) 定义位图。可以从本地文件或资源加载位图,类似于图标(有关资源的信息,请参阅第三章,Qt GUI 编程)。 |
QGraphicsProxyWidget |
绘制任意 QWidget 并允许您与之交互。您可以使用 setWidget(QWidget*) 设置小部件。 |
正如我们已经看到的,您通常可以将项目的内 容传递给构造函数,而不是调用 setRect() 等设置器方法。然而,请记住,紧凑的代码可能比通过设置器方法设置所有变量的代码更难维护。|
对于大多数项目,您还可以定义应使用哪种笔和哪种画刷。笔通过 setPen() 设置,画刷通过 setBrush() 设置(我们已经在之前的示例中使用了它)。然而,这两个函数在 QGraphicsTextItem 中不存在。要定义 QGraphicsTextItem 项目的外观,您必须使用 setDefaultTextColor() 或 Qt 支持的 HTML 标签。QGraphicsPixmapItem 没有类似的方法,因为笔和画刷的概念不能应用于位图。|
尽可能使用 QGraphicsSimpleTextItem,并尽量避免使用 QGraphicsTextItem,除非绝对必要。原因是 QGraphicsTextItem 是 QObject 的子类,并使用 QTextDocument,这基本上是一个 HTML 引擎(尽管相当有限)。这比*均图形项目要重得多,并且对于显示简单文本来说,绝对是一个过大的开销。|
通常使用标准项比从头开始实现它们更容易。每次你将使用 Graphics View 时,都要问自己这些问题:哪些标准项适合我的特定需求?我是不是一次又一次地重复造轮子?然而,有时你需要创建自定义图形项,我们将在本章的后面部分介绍这个主题。
抗锯齿
如果你看一下上一张截图的结果,你可能会注意到绘图看起来是像素化的。这是因为线条中的每个像素都是完全黑色的,而所有周围的像素都是完全白色的。物理显示器的分辨率有限,但称为 抗锯齿 的技术允许你以相同的分辨率产生更*滑的图像。当使用抗锯齿绘制线条时,一些像素会比其他像素更黑或更白,这取决于线条如何穿过像素网格。
你可以使用以下代码轻松地在 Graphics View 中启用抗锯齿:
view.setRenderHint(QPainter::Antialiasing);
当开启抗锯齿标志时,绘制过程会更加*滑:

然而,左侧矩形中的线条现在看起来更粗。这是因为我们使用了具有整数坐标和 1 像素宽度的线条。这样的线条正好位于像素行之间的边界上,当进行抗锯齿处理时,相邻的像素行将部分被绘制。这可以通过将所有坐标加 0.5 来修复。
QRectF rect(-width / 2, -height / 2, width, height);
rect.translate(0.5, 0.5);
QGraphicsRectItem *parent = new QGraphicsRectItem(rect);
现在线条位于像素行的正中间,因此它只占用单行:

另一种解决方案是在实现自定义项类时禁用绘制水*或垂直线时的抗锯齿。
QGraphicsView 也支持 QPainter::TextAntialiasing 标志,该标志在绘制文本时启用抗锯齿,以及 QPainter::SmoothPixmapTransform 标志,该标志启用*滑位图变换。注意抗锯齿和*滑对应用程序性能的影响,因此仅在需要时使用它们。
笔刷和画笔
笔和画刷是两个属性,它们定义了不同的绘图操作如何执行。笔(由 QPen 类表示)定义轮廓,而画刷(由 QBrush 类表示)填充绘制的形状。每个都是一组参数。最简单的一个是定义的颜色,可以是预定义的全局颜色枚举值(例如 Qt::red 或 Qt::transparent),或者 QColor 类的实例。有效颜色由四个属性组成:三个颜色分量(红色、绿色和蓝色)以及一个可选的 alpha 通道值,它决定了颜色的透明度(值越大,颜色越不透明)。默认情况下,所有分量都表示为 8 位值(0 到 255),但也可以表示为表示分量最大饱和度百分比的实数值;例如,0.6 对应于 153(0.6⋅255)。为了方便,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) 渐变。Qt 提供了一个渐变示例(如下截图所示),您可以在其中看到不同的渐变效果:

至于画笔,其主要属性是其宽度(以像素为单位),它决定了形状轮廓的厚度。画笔当然可以设置颜色,但除此之外,您还可以使用任何画刷作为画笔。这种操作的结果是,您可以使用渐变或纹理绘制形状的粗轮廓。
对于笔来说,有三个更重要的属性。第一个是笔的样式,通过QPen::setStyle()设置。它决定了笔绘制的线条是连续的还是以某种方式分割的(如虚线、点等)。您可以在以下位置查看可用的线条样式:

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

项目选择
场景支持选择项目的功能,类似于在文件管理器中选择文件的方式。为了可被选择,项目必须将QGraphicsItem::ItemIsSelectable标志打开。尝试将parent->setFlag(QGraphicsItem::ItemIsSelectable, true)添加到我们之前创建的createComplexItem()函数中。现在,如果您运行应用程序并点击一个矩形,它将被选中,这由虚线表示:

您可以使用Ctrl按钮一次性选择多个项目。或者,您可以调用view.setDragMode(QGraphicsView::RubberBandDrag)来激活视图的橡皮筋选择。
图形视图的另一个有用的拖动模式是ScrollHandDrag。它允许您通过用左鼠标按钮拖动场景来滚动视图,而不需要使用滚动条。
除了这些,还有不同的方式可以通过编程选择项目。有QGraphicsItem::setSelected()函数,它接受一个bool值来切换选择状态的开或关,或者您可以在场景上调用QGraphicsScene::setSelectionArea(),它接受一个QPainterPath参数作为参数,在这种情况下,区域内的所有项目都将被选中。
使用场景的QGraphicsScene::selectedItems()函数,您可以查询实际选中的项目。该函数返回一个包含指向选中项目的QGraphicsItem指针的QList。例如,在列表上调用QList::count()将给出选中项目的数量。要清除选择,请调用QGraphicsScene::clearSelection()。要查询项目的选择状态,请使用QGraphicsItem::isSelected(),如果项目被选中则返回true,否则返回false。
GraphicsItem的另一个有趣的标志是ItemIsMovable。它允许您通过用左鼠标按钮抓住项目在场景内拖动它,从而有效地改变项目的pos()。尝试将parent->setFlag(QGraphicsItem::ItemIsMovable, true)添加到我们的createComplexItem函数中,并拖动矩形。
图形场景中的键盘焦点
该场景实现了类似于小部件中键盘焦点的工作原理的焦点概念。一次只能有一个项目拥有焦点。当场景接收到键盘事件时,它会被分发到拥有焦点的项目。
要使项目可聚焦,项目必须启用QGraphicsItem::ItemIsFocusable标志:
item1->setFlag(QGraphicsItem::ItemIsFocusable, true);
item2->setFlag(QGraphicsItem::ItemIsFocusable, true);
然后,可以通过鼠标点击来聚焦一个项目。你也可以从代码中更改聚焦的项目:
item1->setFocus();
另一种设置焦点的方法是使用场景的QGraphicsScene::setFocusItem()函数,该函数期望一个指向你想要聚焦的项目指针作为参数。每次一个项目获得焦点时,之前聚焦的项目(如果有的话)将自动失去焦点。
要确定一个项目是否有焦点,你又有两种可能性。一种是在项目上调用QGraphicsItem::hasFocus(),如果项目有焦点则返回true,否则返回false。或者,你可以通过调用场景的QGraphicsScene::focusItem()方法来获取实际聚焦的项目。另一方面,如果你调用项目的QGraphicsItem::focusItem()函数,如果项目本身或任何子项目有焦点,则返回聚焦的项目;否则,返回nullptr。要移除焦点,请在聚焦的项目上调用clearFocus()或在场景的背景或无法聚焦的项目上点击。
如果你希望点击场景的背景不会导致聚焦的项目失去焦点,将场景的stickyFocus属性设置为true。
绘画路径
如果你想要创建一个由多个几何原语组成的图形项目,创建多个QGraphicsItem对象似乎很繁琐。幸运的是,Qt 提供了一个QGraphicsPathItem类,它允许你在QPainterPath对象中指定多个原语。QPainterPath允许你“记录”多个绘图指令(包括填充、轮廓和裁剪),然后高效地多次重用它们。
动手时间 - 将路径项目添加到场景中
让我们绘制一些由大量线条组成的对象:
static const int SIZE = 100;
static const int MARGIN = 10;
static const int FIGURE_COUNT = 5;
static const int LINE_COUNT = 500;
for(int figureNum = 0; figureNum < FIGURE_COUNT; ++figureNum) {
QPainterPath path;
path.moveTo(0, 0);
for(int i = 0; i < LINE_COUNT; ++i) {
path.lineTo(qrand() % SIZE, qrand() % SIZE);
}
QGraphicsPathItem *item = scene.addPath(path);
item->setPos(figureNum * (SIZE + MARGIN), 0);
}
对于每个项目,我们首先创建一个QPainterPath并设置当前位置为(0, 0)。然后,我们使用qrand()函数生成随机数,应用模运算符(%)生成一个从 0 到SIZE(不包括SIZE)的数字,并将它们输入到lineTo()函数,该函数从当前位置绘制一条线到指定位置,并将它设置为新的当前位置。接下来,我们使用addPath()便利函数创建一个QGraphicsPathItem对象并将其添加到场景中。最后,我们使用setPos()将每个项目移动到场景中的不同位置。结果看起来像这样:

QPainterPath允许你使用 Qt 支持的几乎所有绘图操作。例如,QGraphicsPathItem是唯一能够在场景中绘制贝塞尔曲线的标准项目,因为QPainterPath支持它们。有关更多信息,请参阅QPainterPath的文档。
在这个例子中使用绘图路径非常高效,因为我们避免了在堆栈上创建成千上万的单个线条对象。然而,将场景的大部分内容放在单个项目中可能会降低性能。当场景的部分是独立的图形项目时,Qt 可以有效地确定哪些项目是不可见的,并跳过它们的绘制。
项目的 z-顺序
你是否想过当多个项目在同一场景区域绘制时会发生什么?让我们尝试这样做:
QGraphicsEllipseItem *item1 = scene.addEllipse(0, 0, 100, 50);
item1->setBrush(Qt::red);
QGraphicsEllipseItem *item2 = scene.addEllipse(50, 0, 100, 50);
item2->setBrush(Qt::green);
QGraphicsEllipseItem *item3 = scene.addEllipse(0, 25, 100, 50);
item3->setBrush(Qt::blue);
QGraphicsEllipseItem *item4 = scene.addEllipse(50, 25, 100, 50);
item4->setBrush(Qt::gray);
默认情况下,项目会按照它们被添加的顺序进行绘制,因此最后一个项目将显示在其他项目的前面:

然而,你可以通过调用setZValue()函数来更改z-顺序:
item2->setZValue(1);
现在第二个项目显示在其他项目的前面:

z值较高的项目会显示在z值较低的项目之上。默认z值为 0。负值也是可能的。如果项目具有相同的z值,则插入顺序决定位置,并且后来添加的项目会覆盖早期添加的项目。
在开发 2D 游戏时,能够更改项目 z-顺序的能力非常重要。任何场景通常都由多个必须按特定顺序绘制的层组成。你可以根据该项目所属的层为每个项目设置一个z值。
项目的父子关系也会影响 z-顺序。子项目会显示在其父项目之上。此外,如果一个项目显示在另一个项目之前,那么前者的子项目也会显示在后者的子项目之前。
忽略变换
如果你尝试放大我们的自定义矩形场景(例如,通过调用view.scale(4, 4)),你会注意到一切都会按比例缩放,正如你所期望的那样。然而,有些情况下你不想某些元素受到缩放或其他变换的影响。Qt 提供了多种处理方法。
如果你希望线条始终具有相同的宽度,而不管缩放如何,你需要使画笔外观化:
QPen pen = parent->pen();
pen.setCosmetic(true);
parent->setPen(pen);
现在,矩形将始终具有一像素宽的线条,而不管视图的缩放如何(尽管抗锯齿仍然可能使它们模糊)。也可以使用任何宽度的外观化画笔,但在 Graphics View 中使用它们并不推荐。
你不希望变换应用的其他常见情况是显示文本。旋转和剪切文本通常会使文本难以阅读,所以你通常会希望将其设置为水*且未变换。让我们尝试在我们的项目中添加一些文本并看看我们如何解决这个问题。
动手时间 - 向自定义矩形添加文本
让我们在每个角落圆上添加一个数字:
child->setPos(pos);
QGraphicsSimpleTextItem *text =
new QGraphicsSimpleTextItem(QString::number(i), child);
text->setBrush(Qt::green);
text->setPos(-text->boundingRect().width() / 2,
-text->boundingRect().height() / 2);
QString::number(i)函数返回数字i的字符串表示形式。文本项是圆项的子项,所以它的位置相对于圆的起点(在我们的例子中,是圆心)。正如我们之前看到的,文本显示在项的左上角,所以如果我们想在圆内居中文本,我们需要将其向上和向右移动项大小的一半。现在文本已经定位并与其父圆一起旋转:

然而,我们不想文本旋转,因此我们需要为文本项启用ItemIgnoresTransformations标志:
text->setFlag(QGraphicsItem::ItemIgnoresTransformations);
此标志使项忽略其父项或视图的任何变换。然而,其坐标系统的原点仍然由父坐标系统中pos()的位置定义。因此,文本项仍然会跟随圆,但它将不再缩放或旋转:

然而,现在我们遇到了另一个问题:文本在圆中不再正确居中。如果你再次缩放视图,这个问题会更加明显。为什么会这样呢?当启用ItemIgnoresTransformations标志时,我们的text->setPos(...)语句就不再正确了。实际上,pos()使用的是父坐标系统中的坐标,但我们使用了boundingRect()的结果,它使用的是项的坐标系统。这两个坐标系统之前是相同的,但启用ItemIgnoresTransformations标志后,它们现在不同了。
为了详细说明这个问题,让我们看看坐标发生了什么(我们只考虑x坐标,因为y的行为相同)。假设我们的文本项宽度为八个像素,所以设置的pos()的x = -4。如果没有应用任何变换,这个pos()会导致文本向左移动四个像素。如果禁用ItemIgnoresTransformations标志,并且视图缩放为 2,文本相对于圆心的位移是八个像素,但文本本身的大小现在是 16 像素,所以它仍然居中。如果启用ItemIgnoresTransformations标志,文本相对于圆心的位移仍然是八个像素(因为pos()在父项的坐标系统中操作,而圆被缩放),但项的宽度现在是 8,因为它忽略了缩放,所以不再居中。当视图旋转时,结果会更加不正确,因为setPos()会将项移动到取决于旋转方向的位置。由于文本项本身没有旋转,我们总是希望将其向上和向左移动。
如果项目已经围绕其原点居中,这个问题就会消失。不幸的是,QGraphicsSimpleTextItem 无法做到这一点。现在,如果它是 QGraphicsRectItem,这样做会很简单,但没有任何阻止我们添加一个忽略变换的矩形,然后在其中添加文本!让我们这样做:
QGraphicsSimpleTextItem *text =
new QGraphicsSimpleTextItem(QString::number(i));
QRectF textRect = text->boundingRect();
textRect.translate(-textRect.center());
QGraphicsRectItem *rectItem = new QGraphicsRectItem(textRect, child);
rectItem->setPen(QPen(Qt::green));
rectItem->setFlag(QGraphicsItem::ItemIgnoresTransformations);
text->setParentItem(rectItem);
text->setPos(textRect.topLeft());
text->setBrush(Qt::green);
在此代码中,我们首先创建一个文本项,但未设置其父项。然后,我们获取项目的边界矩形,这将告诉我们文本需要多少空间。然后,我们将矩形移动,使其中心位于原点(0, 0)。现在我们可以为这个矩形创建一个矩形项,将其设置为圆的父项,并禁用矩形项的变换。最后,我们将矩形项设置为文本项的父项,并更改文本项的位置,使其位于矩形内。
矩形现在正确地定位在圆的中心,并且文本项始终跟随矩形,就像子项通常做的那样:

由于我们最初并不想添加矩形,我们可能想隐藏它。在这种情况下,我们不能使用 rectItem->hide(),因为这也会隐藏其子项(文本)。解决方案是通过调用 rectItem->setPen(Qt::NoPen) 来禁用矩形的绘制。
解决这个问题的另一种方法是翻译文本项的坐标系,而不是使用 setPos()。QGraphicsItem 没有专门用于*移的函数,因此我们需要使用 setTransform:
QTransform transform;
transform.translate(-text->boundingRect().width() / 2,
-text->boundingRect().height() / 2);
text->setTransform(transform);
与你的预期相反,ItemIgnoresTransformations 并不会使项目忽略其自身的变换,并且这段代码将正确地定位文本,而无需额外的矩形项。
通过位置查找项目
如果你想知道在某个特定位置显示的是哪个项目,你可以使用 QGraphicsScene::itemAt() 函数,该函数接受场景坐标系中的位置(一个 QPointF 或两个 qreal 数字)和可以通过 QGraphicsView::transform() 函数获得的设备变换对象 (QTransform)。该函数返回指定位置的最顶层项目,如果没有找到项目则返回空指针。设备变换只有在你的场景包含忽略变换的项目时才有意义。如果你没有这样的项目,你可以使用默认构造的 QTransform 值:
QGraphicsItem *foundItem = scene.itemAt(scenePos, QTransform());
如果你的场景包含忽略变换的项目,使用 QGraphicsView::itemAt() 函数可能更方便,该函数会自动考虑设备变换。请注意,此函数期望位置在视口坐标系中。
如果您想找到位于某个位置的所有物品,例如在多个物品堆叠在一起的情况下,或者如果您需要在某个区域搜索物品,请使用QGraphicsScene::items()函数。它将返回由指定参数定义的物品列表。此函数有几个重载,允许您指定一个点、一个矩形、一个多边形或一个绘图路径。deviceTransform参数的作用方式与前面讨论的QGraphicsScene::itemAt()函数相同。mode参数允许您更改确定区域中物品的方式。下表显示了不同的模式:
| 模式 | 含义 |
|---|---|
Qt::ContainsItemBoundingRect |
物品的边界矩形必须完全位于选择区域内。 |
Qt::IntersectsItemBoundingRect |
与Qt::ContainsItemBoundingRect类似,但还返回与选择区域相交的物品的边界矩形。 |
Qt::ContainsItemShape |
物品的形状必须完全位于选择区域内。形状可能比边界矩形更精确地描述物品的边界,但此操作的计算量更大。 |
Qt::IntersectsItemShape |
与Qt::ContainsItemShape类似,但还返回与选择区域相交的物品的形状。 |
items()函数根据物品的堆叠顺序对物品进行排序。order参数允许您选择结果返回的顺序。Qt::DescendingOrder(默认)将最顶部的物品放在开头,而Qt::AscendingOrder将导致顺序相反。
视图还提供了一个类似的QGraphicsView::items()函数,该函数在视口坐标中操作。
显示场景的特定区域
当场景的边界矩形超过视口大小时,视图将显示滚动条。除了使用鼠标导航到场景中的特定物品或点之外,您还可以通过代码访问它们。由于视图继承了QAbstractScrollArea,您可以使用所有其函数来访问滚动条;horizontalScrollBar()和verticalScrollBar()返回一个指向QScrollBar的指针,因此您可以使用minimum()和maximum()查询它们的范围。通过调用value()和setValue(),您可以获取并设置当前值,这将导致场景滚动。
然而,通常你不需要从源代码中控制视图内的自由滚动。正常任务是将滚动到特定的项目。为了做到这一点,你不需要自己进行任何计算;视图提供了一个相当简单的方法为你完成这项工作——centerOn()。使用centerOn(),视图确保你传递作为参数的项目在视图中居中,除非它太靠*场景的边框甚至在外面。然后,视图会尽量将其移动到中心。centerOn()函数不仅接受QGraphicsItem项目作为参数;你也可以以QPointF指针或便利方式以x和y坐标为中心。
如果你不在乎一个项目显示的位置,你可以简单地传递项目作为参数调用ensureVisible()。然后,视图会尽可能少地滚动场景,以便项目的中心保持或变为可见。作为第二个和第三个参数,你可以定义水*和垂直边距,这两个边距都是项目边界矩形和视图边框之间的最小空间。这两个值的默认值都是 50 像素。除了QGraphicsItem项目外,你也可以确保QRectF元素(当然,也有接受四个qreal元素的便利函数)的可见性。
如果你需要确保一个项目的整个可见性,请使用ensureVisible(item->boundingRect())(因为ensureVisible(item)只考虑项目的中心)。
centerOn()和ensureVisible()只会滚动场景,但不会改变其变换状态。如果你绝对想要确保一个项目或超过视图大小的矩形的可见性,你必须同时变换场景。在这个任务中,视图会再次帮助你。通过传递QGraphicsItem或QRectF元素作为参数调用fitInView(),视图会滚动并缩放场景,使其适合视口大小。
作为第二个参数,你可以控制缩放是如何进行的。你有以下选项:
| 值 | 描述 |
|---|---|
Qt::IgnoreAspectRatio |
缩放是绝对自由进行的,不考虑项目或矩形的纵横比。 |
Qt::KeepAspectRatio |
尝试尽可能扩展时,会考虑项目或矩形的纵横比,同时尊重视口的大小。 |
Qt::KeepAspectRatioByExpanding |
考虑项目或矩形的纵横比,但视图尝试用最小的重叠填充整个视口的大小。 |
fitInView()函数不仅将较大的项目缩小以适应视口,还将项目放大以填充整个视口。以下图表说明了放大项目的不同缩放选项(左边的圆是原始项目,黑色矩形是视口):

将场景保存到图像文件
到目前为止,我们只在视图中显示了我们的场景,但也可以将其渲染到图像、打印机或其他 Qt 可以用于绘制的对象。让我们将场景保存为 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");
刚才发生了什么?
首先,您使用 sceneRect() 确定了场景的矩形。由于这个函数返回一个 QRectF 参数,而 QImage 只能处理 QRect,您通过调用 toAlignedRect() 在线转换了它。toRect() 函数和 toAlignedRect() 之间的区别在于前者四舍五入到最接*的整数,这可能会导致矩形更小,而后者扩展到包含原始 QRectF 参数的最小可能矩形。
然后,您创建了一个与对齐场景矩形大小相同的 QImage 文件。由于图像是用未初始化的数据创建的,您需要使用 Qt::transparent 调用 fill() 以获取透明背景。您可以将任何颜色作为参数分配,无论是 Qt::GlobalColor 枚举的值还是一个普通的 QColor 对象;QColor(0, 0, 255) 将导致蓝色背景。接下来,您创建了一个指向图像的 QPainter 对象。然后,这个绘图对象在场景的 render() 函数中使用来绘制场景。之后,您只需使用 save() 函数将图像保存到您选择的位置。输出文件的格式由其扩展名确定。Qt 支持多种格式,Qt 插件可以添加对新格式的支持。由于我们没有指定路径,图像将被保存在应用程序的工作目录中(通常是构建目录,除非您使用 Qt Creator 的项目面板更改了它)。您也可以指定一个绝对路径,例如 /path/to/image.png。
当然,您需要构造一个在当前系统上有效的路径,而不是在源代码中硬编码它。例如,您可以使用 QFileDialog::getSaveFileName() 函数请求用户输入路径。
尝试一下英雄般的渲染 - 仅渲染场景的特定部分
此示例绘制了整个场景。当然,您也可以使用 render() 的其他参数仅渲染场景的特定部分。我们在这里不会深入探讨,但您可能想将其作为练习尝试。
自定义项
正如我们已经看到的,图形视图提供了很多有用的功能,涵盖了大多数典型用例。然而,Qt 的真正力量在于其可扩展性,图形视图允许我们创建 QGraphicsItem 的自定义子类,以实现针对您的应用程序定制的项。当您需要执行以下操作时,您可能想实现一个自定义项类:
-
绘制标准项类无法或难以完成的事情
-
实现与该项相关的逻辑,例如,添加您自己的方法
-
在单个项中处理事件
在我们的下一个小型项目中,我们将创建一个可以绘制正弦函数 sin(x) 图形的项,并实现一些事件处理。
行动时间 - 创建正弦图项目
使用 Qt Creator 创建一个新的 Qt Widgets 项目,并将其命名为 sine_graph。在向导的“类信息”页面,选择 QWidget 作为基类,并将类名输入为 View。取消选中“生成表单”复选框,然后完成向导。
我们希望 View 类成为图形视图,所以你需要将基类更改为 QGraphicsView(向导没有建议这样的选项)。为此,编辑类声明使其看起来像 class View : public QGraphicsView ...,并编辑构造函数实现使其看起来像 View::View(QWidget *parent) : QGraphicsView(parent) ...。
接下来,编辑 View 构造函数以启用抗锯齿并为我们视图设置一个新的图形场景:
setRenderHint(QPainter::Antialiasing);
setScene(new QGraphicsScene);
视图在销毁时不会删除关联的场景(因为你可能对同一个场景有多个视图),所以你应该在析构函数中手动删除场景:
delete scene();
你可以尝试运行应用程序并检查它是否显示了一个空视图。
行动时间 - 创建图形项目类
请求 Qt Creator 向项目中添加一个新的 C++ 类。输入 SineItem 作为类名,将下拉列表中的 QGraphicsItem。完成向导并打开创建的 sineitem.h 文件。
在类声明中设置文本光标在 QGraphicsItem 内,然后按 Alt + Enter。一开始,Qt Creator 会建议你添加 #include <QGraphicsItem>。确认后,再次在 QGraphicsItem 上按 Alt + Enter。现在,Qt Creator 应该会建议你选择插入基类的虚拟函数。当你选择这个选项时,会出现一个特殊的对话框:

函数列表包含基类的所有虚拟函数。默认情况下,纯虚拟函数(如果你想要创建类的对象,则必须实现)是启用的。请确认一切设置如前一个截图所示,然后点击 OK。这个方便的操作会将所选虚拟函数的声明和实现添加到我们类的源文件中。如果你愿意,也可以手动编写它们。
让我们编辑 sineitem.cpp 来实现两个纯虚拟函数。首先,在文件顶部添加一些常量:
static const float DX = 1;
static const float MAX_X = 50;
在我们的图中,x 将从 0 变化到 MAX_X,而 DX 将是图中两个连续点的差值。正如你可能知道的,sin(x) 的值可以从 -1 到 1。这些信息足以实现 boundingRect() 函数:
QRectF SineItem::boundingRect() const
{
return QRectF(0, -1, MAX_X, 2);
}
这个函数简单地每次都返回相同的矩形。在这个矩形中,x 从 0 变化到 MAX_X,而 y 从 -1 变化到 1。这个返回的矩形是对场景的一个承诺,表示项目只会在这个区域中绘制。场景依赖于这个信息的正确性,所以你应该严格遵守这个承诺。否则,场景将充满你绘制的遗迹!
现在,实现paint()函数,如下所示:
void SineItem::paint(QPainter *painter,
const QStyleOptionGraphicsItem *option, QWidget *widget)
{
QPen pen;
pen.setCosmetic(true);
painter->setPen(pen);
const int steps = qRound(MAX_X / DX);
QPointF previousPoint(0, sin(0));
for(int i = 1; i < steps; ++i) {
const float x = DX * i;
QPointF point(x, sin(x));
painter->drawLine(previousPoint, point);
previousPoint = point;
}
Q_UNUSED(option)
Q_UNUSED(widget)
}
将#include <QtMath>添加到文件的顶部部分,以便使数学函数可用。
刚才发生了什么?
当视图需要显示场景时,它会调用每个可见项目的paint()函数,并提供三个参数:一个用于绘制的QPainter指针,一个包含此项目绘制相关参数的QStyleOptionGraphicsItem指针,以及一个可选的QWidget指针,它可能指向当前绘制的窗口小部件。在函数的实现中,我们首先在painter中设置一个装饰性笔,以便我们的图形线条宽度始终为 1。然后,我们计算图形中的点数并将其保存到steps变量中。接下来,我们创建一个变量来存储图形的前一个点,并将其初始化为图形的第一个点的位置(对应于x = 0)。然后,我们遍历点,为每个点计算x和y,然后使用painter对象从前一个点到当前点绘制一条线。之后,我们更新previousPoint变量的值。我们使用Q_UNUSED()宏来抑制编译器关于未使用参数的警告,并表明我们有意没有使用它们。
编辑我们的View类的构造函数以创建我们新项目的实例:
SineItem *item = new SineItem();
scene()->addItem(item);
应用程序现在应该显示正弦图,但它非常小:

我们应该添加一种方式,让用户可以通过鼠标滚轮缩放我们的视图。然而,在我们做到这一点之前,你需要学习更多关于事件处理的知识。
事件
任何 GUI 应用程序都需要对输入事件做出反应。我们已经熟悉了基于QObject类的信号和槽机制。然而,QObject并不是一个轻量级的类。信号和槽对于连接应用程序的各个部分来说非常强大和方便,但为每个键盘按键或鼠标移动调用信号将会非常低效。为了处理此类事件,Qt 有一个特殊的系统,它使用QEvent类。
事件调度器是 事件循环。几乎任何 Qt 应用程序都使用由在 main() 函数末尾调用 QCoreApplication::exec 启动的主事件循环。当应用程序运行时,控制流程要么在您的代码中(即在项目的任何函数的实现中),要么在事件循环中。当操作系统或应用程序的组件要求事件循环处理一个事件时,它确定接收器并调用与事件类型相对应的虚函数。一个包含有关事件信息的 QEvent 对象被传递到该函数。虚函数可以选择 接受 或 忽略 事件。如果事件未被接受,事件将被 传播 到层次结构中的父对象(例如,从一个小部件到其父小部件,从一个图形项到其父项)。您可以通过子类化 Qt 类并重新实现虚函数来添加自定义事件处理。
以下表格显示了最有用的事件:
| 事件类型 | 描述 |
|---|---|
QEvent::KeyPress、QEvent::KeyRelease |
按键被按下或释放。 |
QEvent::MouseButtonPress、QEvent::MouseButtonRelease、QEvent::MouseButtonDblClick |
鼠标按钮被按下或释放。 |
QEvent::Wheel |
鼠标滚轮被滚动。 |
QEvent::Enter |
鼠标光标进入了对象的边界。 |
QEvent::MouseMove |
鼠标光标被移动。 |
QEvent::Leave |
鼠标光标离开了对象的边界。 |
QEvent::Resize |
小部件的大小被调整(例如,因为用户调整了窗口大小或布局发生了变化)。 |
QEvent::Close |
用户尝试关闭小部件的窗口。 |
QEvent::ContextMenu |
用户请求一个上下文菜单(确切的操作取决于操作系统打开上下文菜单的方式)。 |
QEvent::Paint |
小部件需要重新绘制。 |
QEvent::DragEnter、QEvent::DragLeave、QEvent::DragMove、QEvent::Drop |
用户执行了拖放操作。 |
QEvent::TouchBegin、QEvent::TouchUpdate、QEvent::TouchEnd、QEvent::TouchCancel |
触摸屏或触摸板报告了一个事件。 |
每种事件类型都有一个对应的类,该类继承自 QEvent(例如,QMouseEvent)。许多事件类型都有专门的虚函数,例如,QWidget::mousePressEvent 和 QGraphicsItem::mousePressEvent。更特殊的事件必须通过重新实现接收所有事件的 QWidget::event(或 QGraphicsItem::sceneEvent)函数来处理,并使用 event->type() 来检查事件类型。
图形场景中派发的事件有特殊类型(例如,QEvent::GraphicsSceneMousePress)和特殊类(例如,QGraphicsSceneMouseEvent),因为它们包含有关事件的扩展信息集。特别是鼠标事件包含有关项目及其场景坐标系统中的坐标信息。
行动时间 - 实现缩放场景的能力
让我们允许用户通过在视图中使用鼠标滚轮来缩放场景。切换到view.h文件,并添加一个声明和一个实现wheelEvent()虚拟函数的声明,使用我们在SineItem类中刚刚使用的方法。在view.cpp文件中编写以下代码:
void View::wheelEvent(QWheelEvent *event)
{
QGraphicsView::wheelEvent(event);
if (event->isAccepted()) {
return;
}
const qreal factor = 1.1;
if (event->angleDelta().y() > 0) {
scale(factor, factor);
} else {
scale(1 / factor, 1 / factor);
}
event->accept();
}
如果你现在运行应用程序,你可以使用鼠标滚轮来缩放正弦图。
刚才发生了什么?
当事件发生时,Qt 会调用事件发生的小部件中的相应虚拟函数。在我们的情况下,每当用户在我们的视图中使用鼠标滚轮时,wheelEvent()虚拟函数将被调用,event参数将包含有关事件的信息。
在我们的实现中,我们首先调用基类的实现。在重新实现虚拟函数时,无论何时都要这样做,除非你希望默认行为完全禁用。在我们的情况下,QGraphicsView::wheelEvent()会将事件传递给场景,如果我们忘记调用此函数,场景及其任何项目都不会收到任何滚轮事件,在某些情况下这可能会非常不受欢迎。
在默认实现完成后,我们使用isAccepted()函数来检查事件是否被场景或任何项目接受。默认情况下,事件将被拒绝,但如果我们后来添加一些可以处理滚轮事件的项目(例如,带有自己滚动条的文本文档),它将接收并接受该事件。在这种情况下,我们不想基于此事件执行任何其他操作,因为通常希望任何事件只在一个位置被处理(和接受)。
在某些情况下,你可能希望你的自定义实现比默认实现具有优先级。在这种情况下,将调用默认实现的代码移动到函数体的末尾。当你想要阻止特定事件被分派到场景时,使用早期的return来防止默认实现执行。
缩放的factor参数可以自由定义。你也可以为它创建一个 getter 和 setter 方法。对我们来说,1.1 就可以完成工作。使用event->angleDelta(),你可以得到鼠标滚轮旋转的距离作为一个QPoint指针。由于我们只关心垂直滚动,所以对我们来说,只有y轴是相关的。在我们的例子中,我们也不关心滚轮滚动的距离有多远,因为通常,每个步骤都会单独传递给wheelEvent()。但是,如果你需要它,它是以八分之一度为单位,而且由于大多数鼠标以 15 度的通用步骤工作,所以值应该是 120 或-120,具体取决于你是向前还是向后滚动滚轮。在向前滚动滚轮时,如果y()大于零,我们使用已经熟悉的scale()函数进行放大。否则,如果滚轮向后移动,我们进行缩小。最后,我们接受事件,表示用户的输入已被理解,不需要将事件传播到父小部件(尽管当前视图没有父小部件)。这就是全部内容。
当你尝试这个示例时,你会注意到,在缩放时,视图在视图的中心进行放大和缩小,这是视图的默认行为。你可以使用setTransformationAnchor()来改变这种行为。QGraphicsView::AnchorViewCenter,正如描述的那样,是默认行为。使用QGraphicsView::NoAnchor,缩放中心位于视图的左上角,你可能想要使用的值是QGraphicsView::AnchorUnderMouse。使用这个选项,鼠标下的点构建缩放的中心,因此保持在视图内的同一位置。
行动时间 – 考虑缩放级别
我们当前的图表包含具有整数x值的点,因为我们设置了DX = 1。这正是我们想要的默认缩放级别,但一旦视图被放大,就会明显看出图表的线条并不*滑。我们需要根据当前的缩放级别来改变DX。我们可以在paint()函数的开始处添加以下代码来实现这一点:
const qreal detail = QStyleOptionGraphicsItem::levelOfDetailFromTransform(
painter->worldTransform());
const qreal dx = 1 / detail;
删除DX常量,并在代码的其余部分将DX替换为dx。现在,当你缩放视图时,图表的线条保持*滑,因为点的数量会动态增加。levelOfDetailFromTransform辅助函数检查画笔变换的值(这是应用于项目的所有变换的组合)并返回细节级别。如果项目以 2:1 的比例放大,细节级别为 2,如果项目以 1:2 的比例缩小,细节级别为 0.5。
行动时间 – 响应项目的选择状态
标准项目在被选中时,会改变外观(例如,轮廓通常变为虚线)。当我们创建自定义项目时,我们需要手动实现这个功能。让我们在View构造函数中使我们的项目可选中:
SineItem *item = new SineItem();
item->setFlag(QGraphicsItem::ItemIsSelectable);
现在,让我们在项目被选中时将图表线条设置为绿色:
if (option->state & QStyle::State_Selected) {
pen.setColor(Qt::green);
}
painter->setPen(pen);
刚才发生了什么?
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 参数访问。如果你旨在创建具有样式感知的项目,请在文档中更深入地了解此类。
动作时间 - 在自定义项目中的事件处理
项目,就像小部件一样,可以在虚拟函数中接收事件。如果你点击场景(更准确地说,你点击一个将事件传播到场景的视图),场景将接收鼠标按下事件,然后它就变成了场景的责任来确定点击的是哪个项目。
让我们重写当用户在项目内部按下鼠标按钮时调用的 SineItem::mousePressEvent 函数:
void SineItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
{
if (event->button() & Qt::LeftButton) {
float x = event->pos().x();
QPointF point(x, sin(x));
static const float r = 0.3;
QGraphicsEllipseItem *ellipse =
new QGraphicsEllipseItem(-r, -r, 2 * r, 2 * r, this);
ellipse->setPen(Qt::NoPen);
ellipse->setBrush(QBrush(Qt::red));
ellipse->setPos(point);
event->accept();
} else {
event->ignore();
}
}
当鼠标按下事件发生时,此函数会被调用,传递的event对象包含有关事件的信息。在我们的例子中,我们检查是否按下了左鼠标按钮,并使用event->pos()函数来获取点击点的坐标,该函数返回点击点在项目坐标系统中的坐标。在这个例子中,我们忽略了y坐标,并使用x坐标来找到我们图表上的对应点。然后,我们简单地创建了一个子圆形项目来显示该点。如果我们理解了所执行的操作,我们就接受事件,如果我们不知道它的含义,我们就忽略它,以便它可以传递给另一个项目。你可以运行应用程序并点击图表来查看这些圆圈。请注意,当你点击图表外部时,场景不会将事件分发给我们的项目,其mousePressEvent()函数不会被调用。
event对象还包含button()函数,该函数返回被按下的按钮,以及scenePos()函数,该函数返回场景坐标系统中的点击点。场景负责传递事件不仅适用于鼠标事件,还适用于键盘事件和其他所有类型的事件。
行动时间 - 实现使用鼠标创建和删除元素的能力
让我们允许用户在点击视图时使用左鼠标按钮创建我们正弦项的新实例,如果他们使用右鼠标按钮,则删除这些项目。重新实现View::mousePressEvent虚函数,如下所示:
void View::mousePressEvent(QMouseEvent *event)
{
QGraphicsView::mousePressEvent(event);
if (event->isAccepted()) {
return;
}
switch (event->button()) {
case Qt::LeftButton: {
SineItem *item = new SineItem();
item->setPos(mapToScene(event->pos()));
scene()->addItem(item);
event->accept();
break;
}
case Qt::RightButton: {
QGraphicsItem *item = itemAt(event->pos());
if (item) {
delete item;
}
event->accept();
break;
}
default:
break;
}
}
在这里,我们首先检查事件是否被场景或其任何项目接受。如果没有,我们确定哪个按钮被按下。对于左按钮,我们创建一个新的项目并将其放置在场景的相应点上。对于右按钮,我们在该位置搜索项目并将其删除。在这两种情况下,我们都接受事件。当你运行应用程序时,你会注意到,如果用户点击现有项目,将添加一个新的圆圈,如果用户点击任何项目外部,将添加一个新的正弦项目。这是因为我们正确地设置了事件的accepted属性。
你可能会注意到,当我们添加新项目时,场景在视图中跳跃。这是由于场景矩形的变化引起的。为了防止这种情况,你可以使用setSceneRect()设置一个常量矩形,或者在视图的构造函数中使用setAlignment(Qt::AlignTop | Qt::AlignLeft)来更改对齐方式。
行动时间 - 更改项目的大小
我们的定制图形项目始终显示x值在 0 到 50 之间的图表。将此设置为可配置设置会很好。在SineItem类中声明一个私有的float m_maxX字段,删除MAX_X常量,并在代码的其余部分使用m_maxX替换其使用。始终必须在构造函数中设置字段的初始值,否则可能会发生不良情况。最后,实现它的 getter 和 setter,如下所示:
float SineItem::maxX()
{
return m_maxX;
}
void SineItem::setMaxX(float value)
{
if (m_maxX == value) {
return;
}
prepareGeometryChange();
m_maxX = value;
}
这里唯一非*凡的部分是 prepareGeometryChange() 调用。这个方法是从 QGraphicsItem 继承的,并通知场景我们的 boundingRect() 函数将在下一次更新时返回不同的值。场景缓存了项的边界矩形,所以如果你不调用 prepareGeometryChange(),边界矩形的更改可能不会生效。这个动作也会为我们的项安排一个更新。
当边界矩形没有变化,但项的实际内容发生变化时,你需要在小部件上调用 update() 来通知场景它应该重新绘制该小部件。
尝试扩展项的功能
SineItem 的功能仍然相当有限。作为一个练习,你可以尝试添加一个选项来更改图形的最小 x 值或设置不同的画笔。你甚至可以允许用户指定一个任意函数指针来替换 sin() 函数。然而,请记住,项的边界矩形取决于函数的值域,因此你需要准确更新项的几何形状。
Graphics View 中的小部件
为了展示 Graphics View 的一个整洁特性,请看以下代码片段,它向场景添加了一个小部件:
QSpinBox *box = new QSpinBox;
QGraphicsProxyWidget *proxyItem = new QGraphicsProxyWidget;
proxyItem->setWidget(box);
scene()->addItem(proxyItem);
proxyItem->setScale(2);
proxyItem->setRotation(45);
首先,我们创建一个 QSpinBox 和一个 QGraphicsProxyWidget 元素,它们作为小部件的容器,并间接继承 QGraphicsItem。然后,我们通过调用 addWidget() 将旋转框添加到代理小部件中。当 QGraphicsProxyWidget 被删除时,它会调用所有分配的小部件的 delete 方法,所以我们不必自己担心这一点。你添加的小部件应该是无父级的,并且不能在其他地方显示。在将小部件设置到代理后,你可以像对待任何其他项一样处理代理小部件。接下来,我们将它添加到场景中,并应用一个变换以进行演示。结果如下:

注意,最初,Graphics View 并不是为容纳小部件而设计的。所以当你向场景添加很多小部件时,你将很快注意到性能问题,但在大多数情况下,它应该足够快。
如果你想在布局中排列一些小部件,你可以使用 QGraphicsAnchorLayout、QGraphicsGridLayout 或 QGraphicsLinearLayout。创建所有小部件,创建一个你选择的布局,将小部件添加到该布局中,并将布局设置到 QGraphicsWidget 元素上,这是所有小部件的基类,简单来说,是 Graphics View 的 QWidget 等价物,通过调用 setLayout() 实现:
QGraphicsProxyWidget *edit = scene()->addWidget(
new QLineEdit(tr("Some Text")));
QGraphicsProxyWidget *button = scene()->addWidget(
new QPushButton(tr("Click me!")));
QGraphicsLinearLayout *layout = new QGraphicsLinearLayout;
layout->addItem(edit);
layout->addItem(button);
QGraphicsWidget *graphicsWidget = new QGraphicsWidget;
graphicsWidget->setLayout(layout);
scene()->addItem(graphicsWidget);
场景的 addWidget() 函数是一个便利函数,其行为类似于 addRect,如下面的代码片段所示:
QGraphicsProxyWidget *proxy = new QGraphicsProxyWidget(0);
proxy->setWidget(new QLineEdit(QObject::tr("Some Text")));
scene()->addItem(proxy);
带有布局的项将看起来像这样:

优化
当向场景添加许多项目或使用具有复杂paint()函数的项目时,您应用程序的性能可能会下降。虽然图形视图的默认优化适用于大多数情况,但您可能需要调整它们以获得更好的性能。现在让我们看看我们可以执行的一些优化,以加快场景的速度。
二叉空间划分树
场景持续记录项目在其内部二叉空间划分树中的位置。因此,每当项目移动时,场景都必须更新树,这个操作可能非常耗时,并且消耗内存。这对于包含大量动画项目的场景尤其如此。另一方面,树使您能够以极快的速度找到项目(例如,使用items()或itemAt()),即使您有成千上万的项目。
因此,当您不需要任何关于项目的位置信息时——这包括碰撞检测——您可以通过调用setItemIndexMethod(QGraphicsScene::NoIndex)来禁用索引函数。然而,请注意,调用items()或itemAt()会导致遍历所有项目以进行碰撞检测,这可能会对包含许多项目的场景造成性能问题。如果您不能完全放弃树,您仍然可以通过setBspTreeDepth()调整树的深度,将深度作为参数。默认情况下,场景将在考虑了几个参数(如大小和项目数量)后猜测一个合理的值。
缓存项目的绘制函数
如果您有耗时的绘制函数的项目,您可以更改项目的缓存模式。默认情况下,没有渲染缓存。通过setCacheMode(),您可以设置模式为ItemCoordinateCache或DeviceCoordinateCache。前者在给定QSize元素的缓存中渲染项目。该缓存的大小可以通过setCacheMode()的第二个参数控制,因此质量取决于您分配的空间量。缓存随后用于每个后续的绘制调用。缓存甚至用于应用变换。如果质量下降太多,只需通过再次调用setCacheMode()并使用更大的QSize元素来调整分辨率。另一方面,DeviceCoordinateCache不在项目级别缓存项目,而是在设备级别缓存。因此,这对于不经常变换的项目是最佳的,因为每次新的变换都会导致新的缓存。但是,移动项目不会使缓存失效。如果您使用此缓存模式,您不需要使用第二个参数定义分辨率。缓存始终以最大质量执行。
优化视图
既然我们在谈论项目的paint()函数,让我们谈谈相关的内容。默认情况下,视图确保在调用项目的paint()函数之前保存绘图状态,并在之后恢复状态。如果您有一个包含 50 个项目的场景,这将最终导致保存和恢复绘图状态约 50 次。然而,您可以通过在视图中调用setOptimizationFlag(DontSavePainterState, true)来禁用此行为。如果您这样做,现在您有责任确保任何更改绘图状态(包括笔、画刷、变换和许多其他属性)的paint()函数必须在结束时恢复先前的状态。如果您阻止了自动保存和恢复,请记住现在标准项目将改变绘图状态。所以如果您同时使用标准和自定义项目,要么保持默认行为,要么设置DontSavePainterState,然后在每个项目的paint()函数中使用默认值设置笔和画刷。
可以与setOptimizationFlag()一起使用的另一个标志是DontAdjustForAntialiasing。默认情况下,视图通过所有方向调整每个项目的绘制区域两个像素。这很有用,因为当您绘制抗锯齿时,很容易绘制到边界矩形之外。如果您不绘制抗锯齿或确信您的绘制将始终保持在边界矩形内,请启用此优化。如果您启用了此标志并在视图中发现绘制伪影,那么您没有尊重项目的边界矩形!
作为进一步的优化,您可以定义视图在场景变化时应该如何更新其视口。您可以使用setViewportUpdateMode()设置不同的模式。默认情况下(QGraphicsView::MinimalViewportUpdate),视图试图确定仅需要更新的那些区域,并且只重新绘制这些区域。然而,有时找到所有需要重新绘制的区域比简单地绘制整个视口更耗时。如果您有很多小的更新,那么QGraphicsView::FullViewportUpdate是更好的选择,因为它简单地重新绘制整个视口。最后两种模式的组合是QGraphicsView::BoundingRectViewportUpdate。在这种模式下,Qt 检测所有需要重新绘制的区域,然后重新绘制一个覆盖所有受更改影响的区域视口矩形。如果最佳更新模式随时间变化,您可以使用QGraphicsView::SmartViewportUpdate告诉 Qt 确定最佳模式。然后视图会尝试找到最佳的更新模式。
图形视图中的 OpenGL
作为最后的优化,您可以利用 OpenGL。而不是使用基于QWidget的默认视口,建议图形视图使用 OpenGL 小部件:
QGraphicsView view;
view.setViewport(new QOpenGLWidget());
这通常可以提高渲染性能。然而,Graphics View 并不是为 GPU 设计的,无法有效地使用它们。有一些方法可以改善这种情况,但这超出了本章的主题和范围。你可以在 Qt 的示例中以及 Rødal 的文章“使用 OpenGL 加速你的小部件”中找到更多关于 OpenGL 和 Graphics View 的信息,该文章可在网上找到doc.qt.io/archives/qq/qq26-openglcanvas.html。
如果你想使用一个旨在支持 GPU 加速的框架,你应该关注 Qt Quick(我们将在第十一章,Qt Quick 简介)开始使用它)。然而,与 Graphics View 相比,Qt Quick 有其自身的局限性。这个话题在 Nichols 的文章你仍然应该使用 QGraphicsView 吗?中有详细阐述,该文章可在blog.qt.io/blog/2017/01/19/should-you-be-using-qgraphicsview/找到。或者,你可以直接通过其 API 和有用的 Qt 工具访问 OpenGL 的全部功能。我们将在第九章,Qt 应用程序中的 OpenGL 和 Vulkan中描述这种方法。
不幸的是,我们无法告诉你必须做什么或做什么来优化 Graphics View,因为它高度依赖于你的系统和视图/场景。然而,我们可以告诉你如何进行。一旦你完成了基于 Graphics View 的游戏,使用分析器测量你游戏的性能。进行你认为可能带来收益的优化,或者简单地猜测,然后再次分析你的游戏。如果结果更好,保留这个变化,否则拒绝它。这听起来很简单,这是优化可以进行的唯一方法。没有隐藏的技巧或更深入的知识。然而,随着时间的推移,你的预测将变得更好。
快速问答
Q1. 以下哪个类是窗口小部件类?
-
QGraphicsView -
QGraphicsScene -
QGraphicsItem
Q2. 以下哪个操作不会改变图形项在屏幕上的位置?
-
缩放视图。
-
剪切此项目的父项目。
-
翻译此项目。
-
旋转此项目的子项目。
Q3. 在从 QGraphicsItem 派生的新类中,哪个函数不是必须实现的?
-
boundingRect() -
shape() -
paint()
Q4. 在 Graphics View 中显示位图图像应该使用哪个项目类?
-
QGraphicsRectItem -
QGraphicsWidget -
QGraphicsPixmapItem
摘要
在本章中,你学习了 Graphics View 架构是如何工作的。我们探讨了框架的构建块(项目、场景和视图)。接下来,你学习了它们的坐标系是如何相关的,以及如何使用它们来获取你想要的画面。随后,我们描述了 Graphics View 最有用且最常需要的特性。然后,我们介绍了创建自定义项目和处理输入事件的方法。为了连接到 widgets 的世界,你还学习了如何将基于 QWidget 的项目整合到 Graphics View 中。最后,我们讨论了优化场景的方法。
现在,你已经了解了 Graphics View 框架的大部分功能。有了这些知识,你现在已经可以做一些很酷的事情了。然而,对于一个游戏来说,它仍然太静态了。在下一章中,我们将通过创建一个完整游戏的过程,并学习如何使用 Animation 框架。
第五章:图形视图中的动画
上一章为你提供了大量关于 Graphics View 框架功能的信息。有了这些知识,我们现在可以继续实现我们的第一个 2D 游戏。将来,我们将学习更多关于 Qt 的属性系统,探索执行动画的多种方式,并将游戏手柄支持添加到我们的应用程序中。到本章结束时,你将了解 Graphics View 的所有最有用的功能。
本章涵盖的主要主题如下:
-
使用计时器
-
摄像机控制
-
垂直滚动
-
Qt 的属性系统
-
动画框架
-
使用 Qt 游戏手柄模块
跳跃的大象或如何动画化场景
到目前为止,你应该对物品、场景和视图有了很好的理解。凭借你创建物品(标准或自定义)、在场景中定位它们以及设置视图以显示场景的知识,你可以制作出相当酷的东西。你甚至可以用鼠标缩放和移动场景。这当然很好,但对于一个游戏来说,还有一个关键点仍然缺失——你必须动画化物品。
我们不通过所有可能的场景动画方式,而是开发一个简单的跳跃跑酷游戏,在这个游戏中我们会回顾之前的一些主题,并学习如何在屏幕上动画化物品。那么,让我们来认识一下本杰明,这只大象:

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

行动时间 - 为本杰明创建一个物品
让我们创建一个新的 Qt Widgets 项目,并开始制作我们的游戏。由于这个项目将比我们之前的项目更复杂,我们不会给出精确的代码编辑指令。如果你在任何时候对所做的更改不确定,可以查看书中提供的参考实现。它还包含了你可以用来实现游戏的图像文件。
现在,让我们看看如何让本杰明动起来。首先,我们需要为他创建一个自定义的物品类。我们调用 Player 类,并选择 QGraphicsPixmapItem 作为基类,因为本杰明是一个 PNG 图像。在 Player 物品类中,我们进一步创建一个整型私有字段,并称其为 m_direction。它的值表示本杰明走向哪个方向——左或右——或者如果他静止不动。接下来,我们实现构造函数:
Player::Player(QGraphicsItem *parent)
: QGraphicsPixmapItem(parent)
, m_direction(0)
{
QPixmap pixmap(":/elephant");
setPixmap(pixmap);
setOffset(-pixmap.width() / 2, -pixmap.height() / 2);
}
在构造函数中,我们将 m_direction 设置为 0,这意味着本杰明根本不会移动。如果 m_direction 是 1,本杰明会向右移动,如果值是 -1,他会向左移动。在构造函数的主体中,我们通过调用 setPixmap() 来设置物品的图像。本杰明的图像存储在 Qt 资源系统中;因此,我们通过 QPixmap(":/elephant") 来访问它,其中 elephant 是本杰明实际图像的给定别名。最后,我们使用 setOffset() 函数来改变图像在物品坐标系中的位置。默认情况下,原点对应于图像的左上角,但我们更喜欢将其放在图像的中心,这样应用变换会更加容易。
当你不确定如何指定资源路径时,你可以询问 Qt Creator。为此,展开项目树中的资源分支,定位资源,并在其上下文菜单中选择复制路径...条目。
接下来,我们为 m_direction 字段创建一个获取器和设置器函数:
int Player::direction() const {
return m_direction;
}
void Player::setDirection(int direction)
{
m_direction = direction;
if (m_direction != 0) {
QTransform transform;
if (m_direction < 0) {
transform.scale(-1, 1);
}
setTransform(transform);
}
}
direction() 函数是 m_direction 的标准获取器函数,返回其值。setDirection() 设置器函数还检查本杰明正在向哪个方向移动。如果他正在向左移动,我们需要翻转他的图像,使本杰明看起来向左,这是他移动的方向。如果他正在向右移动,我们通过分配一个空的 QTransform 对象来恢复正常状态,这是一个单位矩阵。
我们不能在这里使用 QGraphicsItem::setScale,因为它只支持 x 和 y 轴相同的缩放因子。幸运的是,setTransform() 允许我们设置任何仿射或透视变换。
因此,我们现在有了游戏角色的 Player 类的物品,它显示了本杰明的图像。该物品还存储当前的移动方向,并根据该信息,如果需要,图像会垂直翻转。
比赛场地
由于我们将在场景上做一些工作,我们派生 QGraphicsScene 并将新类命名为 MyScene。在那里,我们实现游戏逻辑的一部分。这是方便的,因为 QGraphicsScene 继承自 QObject,因此我们可以使用 Qt 的信号和槽机制。
场景创建了一个环境,我们的象将在其中行走和跳跃。总的来说,我们有一个固定大小的视图,其中包含一个场景,其大小正好与视图相同。我们不考虑视图的大小变化,因为它们会使示例过于复杂。
比赛场地内的所有动画都是通过移动物品而不是场景来完成的。因此,我们必须区分视图的宽度(或者更确切地说,场景的宽度)和象虚拟“世界”的宽度,在这个虚拟世界中象可以移动。为了正确处理移动,我们需要在 MyScene 类中创建一些私有字段。
这个虚拟世界的宽度由 int m_fieldWidth 字段定义,并且与场景没有(直接)关联。在 m_fieldWidth 的范围内,例如示例中的 500 像素,本杰明或图形项可以从由 qreal m_minX 定义的最低 x 坐标移动到由 qreal m_maxX 定义的最高 x 坐标。我们使用 qreal m_currentX 变量跟踪其实际 x 位置。接下来,允许项目拥有的最低 y 坐标由 qreal m_groundLevel 定义。我们还需要考虑项目的大小。
最后,剩下的就是视图,其大小由场景的边界矩形大小固定定义,它不如 m_fieldWidth 宽。因此,场景(和视图)在象走过其 m_fieldWidth 长度的虚拟世界时跟随。请看下面的图解以了解变量的图形表示:

行动时间 - 让本杰明移动
下一步,我们想要做的是让我们的象可以移动。为了实现这一点,我们在 MyScene 中添加了一个私有成员 QTimer m_timer。QTimer 是一个可以定期以给定间隔发出 timeout() 信号的类。在 MyScene 构造函数中,我们使用以下代码设置定时器:
m_timer.setInterval(30);
connect(&m_timer, &QTimer::timeout,
this, &MyScene::movePlayer);
首先,我们定义定时器每 30 毫秒发出一次超时信号。然后,我们将该信号连接到场景的 movePlayer() 插槽,但我们还没有启动定时器。定时器将在玩家按下键移动时启动。
接下来,我们需要正确处理输入事件并更新玩家的方向。我们引入 Player * m_player 字段,它将包含指向玩家对象的指针,以及 int m_horizontalInput 字段,它将累积移动命令,正如我们将在下一部分代码中看到的那样。最后,我们重新实现了 keyPressEvent 虚拟函数:
void MyScene::keyPressEvent(QKeyEvent *event)
{
if (event->isAutoRepeat()) {
return;
}
switch (event->key()) {
case Qt::Key_Right:
addHorizontalInput(1);
break;
case Qt::Key_Left:
addHorizontalInput(-1);
break;
//...
}
}
void MyScene::addHorizontalInput(int input)
{
m_horizontalInput += input;
m_player->setDirection(qBound(-1, m_horizontalInput, 1));
checkTimer();
}
作为一个小插曲,当以下代码段中的代码片段对于实际细节不重要时,我们将跳过代码,但会用 //... 指示缺失的代码,这样您就知道这不是完整的代码。我们将在更合适的时候覆盖跳过的部分。
刚才发生了什么?
在按键事件处理程序中,我们首先检查按键事件是否是由于自动重复触发的。如果是这种情况,我们退出函数,因为我们只想对第一个实际按键事件做出反应。此外,我们不会调用该事件处理程序的基类实现,因为场景上的任何项目都不需要获得按键事件。如果您确实有可以并且应该接收事件的项目,在重新实现场景的事件处理程序时,不要忘记转发它们。
如果你按下并保持一个键,Qt 将持续传递按键事件。为了确定这是否是第一次真正的按键还是自动生成的事件,请使用 QKeyEvent::isAutoRepeat()。如果事件是自动生成的,则返回 true。
一旦我们知道事件不是由自动重复产生的,我们就对不同的按键做出反应。我们不是直接调用 Player *m_player 字段的 setDirection() 方法,而是使用 m_horizontalInput 类字段来累积输入值。每当它发生变化时,我们确保在将其传递给 setDirection() 之前值的正确性。为此,我们使用 qBound(),它返回一个由第一个和最后一个参数限制的值。中间的参数是我们想要获取限制的实际值,因此在我们的情况下,可能的值被限制为 -1、0 和 1。
你可能会想,为什么当按下右键时,不直接调用 m_player->setDirection(1) 呢?为什么要在 m_horizontalInput 变量中累积输入?嗯,本杰明是由左右箭头键控制的。如果按下右键,则加 1;如果释放,则减 1。左键的情况相同,但方向相反。现在,由于用户按下并保持右键,m_direction 的值因此为 1。现在,如果没有释放右键,他们也会按下并保持左键。因此,m_direction 的值会减少 1;现在值为 0,本杰明停止。然而,记住两个键仍然被按下。当左键释放时会发生什么?在这种情况下,你如何知道本杰明应该朝哪个方向移动?为了实现这一点,你需要找到额外的信息——即右键是否仍然被按下,这似乎太麻烦且开销太大。在我们的实现中,当左键释放时,加 1,使 m_direction 的值变为 1,使本杰明向右移动。哇!没有任何关于其他按钮状态的担忧。
在调用 setDirection() 之后,我们调用 checkTimer() 函数:
void MyScene::checkTimer()
{
if (m_player->direction() == 0) {
m_timer.stop();
} else if (!m_timer.isActive()) {
m_timer.start();
}
}
这个函数首先检查玩家是否移动。如果没有移动,则停止计时器,因为当我们的象静止时,不需要更新任何内容。否则,如果计时器尚未运行,则启动计时器。我们通过在计时器上调用 isActive() 来检查这一点。
当用户在游戏开始时按下右键,例如,checkTimer() 将启动 m_timer。由于它的 timeout 信号连接到了 movePlayer(),槽将在按键释放之前每 30 毫秒被调用一次。
由于 movePlayer() 函数有点长,让我们一步一步地过一遍:
const int direction = m_player->direction();
if (0 == direction) {
return;
}
首先,我们将玩家的当前方向缓存在一个局部变量中,以避免多次调用 direction()。然后,我们检查玩家是否在移动。如果没有,我们退出函数,因为没有东西可以动画化:
const int dx = direction * m_velocity;
qreal newX = qBound(m_minX, m_currentX + dx, m_maxX);
if (newX == m_currentX) {
return;
}
m_currentX = newX;
接下来,我们计算玩家项应该获得的移动量并将其存储在 dx 中。玩家每 30 毫秒应该移动的距离由 int m_velocity 成员变量定义,以像素为单位。如果你喜欢,可以为该变量创建设置器和获取器函数。对我们来说,默认的 4 像素值将足够用。乘以方向(此时只能是 1 或 -1),我们得到玩家向右或向左移动 4 像素的移动。基于这个移动,我们计算玩家的新 x 位置。接下来,我们检查这个新位置是否在 m_minX 和 m_maxX 的范围内,这两个成员变量已经计算并正确设置。然后,如果新位置不等于存储在 m_currentX 中的实际位置,我们就将新位置赋值为当前位置。否则,我们退出函数,因为没有东西要移动。
接下来要解决的问题是在大象移动时视图是否应该始终移动,这意味着大象将始终保持在视图的中间,比如说。不,他不应该停留在视图内部的某个特定点上。相反,当大象移动时,视图应该是固定的。只有当它达到边界时,视图才应该跟随。比如说,当大象中心与窗口边界的距离小于 150 像素时,我们将尝试移动视图:
const int shiftBorder = 150;
const int rightShiftBorder = width() - shiftBorder;
const int visiblePlayerPos = m_currentX - m_worldShift;
const int newWorldShiftRight = visiblePlayerPos - rightShiftBorder;
if (newWorldShiftRight > 0) {
m_worldShift += newWorldShiftRight;
}
const int newWorldShiftLeft = shiftBorder - visiblePlayerPos;
if (newWorldShiftLeft > 0) {
m_worldShift -= newWorldShiftLeft;
}
const int maxWorldShift = m_fieldWidth - qRound(width());
m_worldShift = qBound(0, m_worldShift, maxWorldShift);
m_player->setX(m_currentX - m_worldShift);
int m_worldShift 类字段显示了我们已经将世界向右移动了多少。首先,我们计算大象在视图中的实际坐标并将其保存到 visiblePlayerPos 变量中。然后,我们计算其相对于由 shiftBorder 和 rightShiftBorder 变量定义的允许区域的相对位置。如果 visiblePlayerPos 超出了允许区域的右边界,newWorldShiftRight 将为正值,我们需要将世界向右移动 newWorldShiftRight。同样,当我们需要将其向左移动时,newWorldShiftLeft 将为正值,它将包含所需的移动量。最后,我们使用一个类似于 setPos() 但不改变 y 坐标的 setX() 辅助方法来更新 m_player 的位置。
注意,shiftBorder 的值是随机选择的。你可以按需更改它。当然,你也可以为这个参数创建设置器和获取器。
在这里要做的最后一个重要部分是应用新的 m_worldShift 值,通过设置其他世界项的位置来实现。在这个过程中,我们将实现垂直滚动。
垂直滚动
垂直滚动是一种技巧,用于为游戏背景添加深度错觉。这种错觉发生在背景有不同层,并且以不同速度移动时。最*的背景必须比远离的背景移动得更快。在我们的例子中,我们有这些四个背景,从最远到最*排序:
天空:

树木:

草地:

地面:

行动时间 - 移动背景
场景将为背景的每一部分创建一个图形项,并将它们的指针存储在m_sky、m_grass和m_trees私有字段中。现在的问题是,如何以不同的速度移动它们。解决方案相当简单——最慢的一个,天空,是最小的图像。最快的背景,地面和草地,是最大的图像。现在当我们查看movePlayer()函数槽的末尾时,我们看到这个:
qreal ratio = qreal(m_worldShift) / maxWorldShift;
applyParallax(ratio, m_sky);
applyParallax(ratio, m_grass);
applyParallax(ratio, m_trees);
applyParallax() 辅助方法包含以下代码:
void MyScene::applyParallax(qreal ratio, QGraphicsItem* item) {
item->setX(-ratio * (item->boundingRect().width() - width()));
}
刚才发生了什么?
我们在这里做什么?一开始,天空的左边界与视图的左边界相同,都在点(0, 0)。在 Benjamin 走到最右边的时候,天空的右边界应该与视图的右边界相同。所以,在这个位置,天空的移动量将等于天空的宽度(m_sky->boundingRect().width())减去视图的宽度(width())。天空的移动量取决于摄像机的位置,从而决定了m_worldShift变量的值;如果它远离左边,天空不会移动;如果摄像机远离右边,天空会最大程度地移动。因此,我们必须将天空的最大移动值乘以一个基于当前摄像机位置的系数。与摄像机位置的关系是为什么这个处理在movePlayer()函数中完成的原因。我们必须计算的系数必须在 0 到 1 之间。所以我们得到最小移动量(0 * shift,等于 0)和最大移动量(1 * shift,等于shift)。我们将这个系数命名为ratio。
世界移动的距离被保存在m_worldShift中,所以通过将m_worldShift除以maxWorldShift,我们得到所需的系数。当玩家在最左边时,它是 0;如果他们在最右边,则是 1。然后,我们必须简单地用ratio乘以天空的最大移动量。
对于其他背景项也使用相同的计算,所以它被移动到单独的函数中。这个计算也解释了为什么较小的图像移动较慢。这是因为较小图像的重叠小于较大图像的重叠,并且由于背景在同一时间段内移动,较大的图像必须移动得更快。
尝试添加新的背景层
尝试根据前面的示例添加额外的背景层到游戏中。作为一个想法,你可以在树后面添加一个谷仓或者让飞机在天空中飞行。
动画框架
目前,我们手动计算并应用了我们的图形项的新位置。然而,Qt 提供了一种自动执行此操作的方法,称为动画框架。
该框架是动画的抽象实现,因此它可以应用于任何QObject,例如小部件,甚至是普通变量。图形项也可以进行动画处理,我们很快就会讨论这个话题。动画不仅限于对象的坐标。你可以对颜色、不透明度或完全不可见的属性进行动画处理。
要创建一个动画,通常需要执行以下步骤:
-
创建一个动画对象(例如
QPropertyAnimation) -
设置应进行动画的对象
-
设置要动画化的属性名称
-
定义值应该如何精确地改变(例如,设置起始和结束值)
-
开始动画
如你所知,在 C++中通过名称调用任意方法是不可能的,而动画对象却能随意更改任意属性。这是因为“属性”不仅是一个花哨的名称,也是QObject类和 Qt 元对象编译器的一个强大功能。
属性
在第三章,“Qt GUI 编程”,我们在表单编辑器中编辑了小部件的预定义属性,并在代码中使用它们的获取器和设置器方法。然而,直到现在,我们还没有真正的原因去声明一个新的属性。在动画框架中这将很有用,所以让我们更加关注属性。
只有继承自QObject的类可以声明属性。要创建一个属性,我们首先需要在类的私有部分(通常在Q_OBJECT强制宏之后)使用特殊的Q_PROPERTY宏声明它。该宏允许你指定关于新属性的信息:
-
属性名称——一个字符串,用于在 Qt 元系统中标识属性。
-
属性类型——任何有效的 C++类型都可以用于属性,但动画仅与有限的一组类型一起工作。
-
该属性的获取器和设置器方法的名称。如果在
Q_PROPERTY中声明,你必须将它们添加到你的类中并正确实现它们。 -
当属性改变时发出的信号名称。如果在
Q_PROPERTY中声明,你必须添加该信号并确保它被正确发出。
还有更多的配置选项,但它们不太常用。你可以从《属性系统文档页面》了解更多关于它们的信息。
动画框架支持以下属性类型:int、unsigned int、double、float、QLine、QLineF、QPoint、QPointF、QSize、QSizeF、QRect、QRectF 和 QColor。其他类型不受支持,因为 Qt 不知道如何插值它们,也就是说,不知道如何根据起始值和结束值计算中间值。然而,如果你真的需要为它们添加动画支持,是有可能添加对自定义类型的支持的。
与信号和槽类似,属性由 moc 驱动,它读取你的类的头文件并生成额外的代码,使 Qt(以及你)能够在运行时访问属性。例如,你可以使用 QObject::property() 和 QObject::setProperty() 方法通过名称获取和设置属性。
行动时间 - 添加跳跃动画
打开 myscene.h 文件并添加一个私有 qreal m_jumpFactor 字段。接下来,声明这个字段的获取器、设置器和更改信号:
public:
//...
qreal jumpFactor() const;
void setJumpFactor(const qreal &jumpFactor);
signals:
void jumpFactorChanged(qreal);
在头文件中,我们在 Q_OBJECT 宏之后添加以下代码来声明 jumpFactor 属性:
Q_PROPERTY(qreal jumpFactor
READ jumpFactor
WRITE setjumpFactor
NOTIFY jumpFactorChanged)
在这里,qreal 是属性的类型,jumpFactor 是注册的名称,接下来的三行注册了属性系统中 MyScene 类的相应成员函数。我们稍后会看到,我们需要这个属性来让本杰明跳跃。
jumpFactor() 获取函数简单地返回 m_jumpFactor 私有成员,它用于存储实际位置。设置器的实现如下:
void MyScene::setjumpFactor(const qreal &pos) {
if (pos == m_jumpFactor) {
return;
}
m_jumpFactor = pos;
emit jumpFactorChanged(m_jumpFactor);
}
检查 pos 是否会改变 m_jumpFactor 的当前值非常重要。如果不是这样,退出函数,因为我们不希望即使没有任何变化也发出更改信号。否则,我们将 m_jumpFactor 设置为 pos 并发出关于更改的信号。
属性动画
我们使用 QTimer 实现了水*移动。现在,让我们尝试第二种动画项目的方法——动画框架。
行动时间 - 使用动画*滑移动项目
让我们在 MyScene 的构造函数中添加一个新的私有成员 m_jumpAnimation,类型为 QPropertyAnimation * 并进行初始化:
m_jumpAnimation = new QPropertyAnimation(this);
m_jumpAnimation->setTargetObject(this);
m_jumpAnimation->setPropertyName("jumpFactor");
m_jumpAnimation->setStartValue(0);
m_jumpAnimation->setKeyValueAt(0.5, 1);
m_jumpAnimation->setEndValue(0);
m_jumpAnimation->setDuration(800);
m_jumpAnimation->setEasingCurve(QEasingCurve::OutInQuad);
刚才发生了什么?
对于在这里创建的 QPropertyAnimation 实例,我们将项目定义为父项;因此,当场景删除项目时,动画将被删除,我们不必担心释放使用的内存。然后,我们定义动画的目标——我们的 MyScene 类——以及应该动画化的属性,在这种情况下是 jumpFactor。然后,我们定义该属性的起始和结束值;除此之外,我们还通过设置 setKeyValueAt() 定义一个中间值。qreal 类型的第一个参数定义动画中的时间,其中 0 是开始,1 是结束,第二个参数定义动画在该时间应具有的值。因此,你的 jumpFactor 元素将在 800 毫秒内从 0 动画到 1,然后再回到 0。这是由 setDuration() 定义的。最后,我们定义起始值和结束值之间的插值方式,并调用 setEasingCurve(),将 QEasingCurve::OutInQuad 作为参数。
Qt 定义了多达 41 种不同的缓动曲线,用于线性、二次、三次、四次、五次、正弦、指数、圆形、弹性、回弹和弹跳函数。这些在这里难以一一描述。相反,请查看文档;只需简单地搜索 QEasingCurve::Type。
在我们的情况下,QEasingCurve::OutInQuad 确保本杰明的跳跃速度看起来像真正的跳跃:开始时快,顶部时慢,然后再次变快。我们使用 jump 函数开始这个动画:
void MyScene::jump()
{
if (QAbstractAnimation::Stopped == m_jumpAnimation->state()) {
m_jumpAnimation->start();
}
}
我们只有在动画未运行时才通过调用 start() 来启动动画。因此,我们检查动画的状态,看它是否已被停止。其他状态可能是 Paused 或 Running。我们希望这个跳跃动作在玩家按下键盘上的空格键时被激活。因此,我们使用此代码扩展按键事件处理程序内的 switch 语句:
case Qt::Key_Space:
jump();
break;
现在属性开始动画,但本杰明仍然不会跳跃。因此,我们在 setJumpFactor 函数的末尾处理 jumpFactor 值的变化:
void MyScene::setJumpFactor(const qreal &jumpFactor)
{
//...
qreal groundY = (m_groundLevel - m_player->boundingRect().height()
/ 2);
qreal y = groundY - m_jumpAnimation->currentValue().toReal() *
m_jumpHeight;
m_player->setY(y);
//...
}
当我们的 QPropertyAnimation 正在运行时,它将调用我们的 setJumpFactor() 函数来更新属性的值。在该函数内部,我们计算玩家项目的 y 坐标,以尊重由 m_groundLevel 定义的地面水*。这是通过从地面水*值中减去项目高度的一半来完成的,因为项目原点在其中心。然后,我们减去由 m_jumpHeight 定义的最大的跳跃高度,该高度乘以实际的跳跃因子。由于因子在 0 和 1 的范围内,新的 y 坐标保持在允许的跳跃高度内。然后,我们通过调用 setY() 改变玩家项目的 y 位置,同时保持 x 坐标不变。Et voilà,本杰明正在跳跃!
尝试一下英雄 - 让项目处理本杰明的跳跃
由于场景已经是QObject,向其中添加属性很容易。然而,想象一下,你想为两个玩家创建一个游戏,每个玩家控制一个单独的Player项。在这种情况下,两个大象的跳跃因子需要独立动画,所以你希望在Player类中创建一个动画属性,而不是将其放入场景中。
到目前为止引入的所有QGraphicsItem项和标准项都不继承QObject,因此不能有槽或发出信号;它们也不受益于QObject属性系统。然而,我们可以使它们使用QObject!你只需要将QObject作为基类,并添加Q_OBJECT宏:
class Player : public QObject, public QGraphicsPixmapItem {
Q_OBJECT
//...
};
现在,你可以使用属性、信号和槽与项一起使用。请注意,QObject必须是项的第一个基类。
一个警告
只有在真正需要其功能时才使用QObject与项一起使用。QObject
为项添加了很多开销,当你有许多项时,这将对性能产生明显影响,所以请明智地使用它,而不仅仅是因为你可以。
如果你进行这个更改,你可以将jumpFactor属性从MyScene移动到Player,以及大量相关代码。你还可以通过在Player中处理水*移动来使代码更加一致。让MyScene处理输入事件,并将移动命令转发给Player。
行动时间 - 保持多个动画同步
现在,我们将开始实现硬币类。我们可以使用一个简单的QGraphicsEllipseItem对象,但我们需要对其属性进行动画处理,所以让我们创建一个新的Coin类,并从QObject和QGraphicsEllipseItem派生。定义两个属性:opacity为qreal类型和rect为QRect类型。这只需以下代码即可完成:
class Coin : public QObject, public QGraphicsEllipseItem
{
Q_OBJECT
Q_PROPERTY(qreal opacity READ opacity WRITE setOpacity)
Q_PROPERTY(QRectF rect READ rect WRITE setRect)
//...
};
没有添加任何函数或槽,因为我们只是使用了QGraphicsItem的内置函数,并将它们与属性相关联。
如果你想要一个继承自QObject和QGraphicsItem的项,你可以直接继承QGraphicsObject。此外,它已经将所有通用的QGraphicsItem属性注册到元系统中,包括pos、scale、rotation和opacity。所有属性都带有相应的通知信号,例如opacityChanged()。然而,当你继承QGraphicsObject时,你不能同时继承QGraphicsEllipseItem或任何其他项类。因此,在这种情况下,我们需要手动实现椭圆的绘制,或者添加一个可以为我们执行绘制的子QGraphicsEllipseItem。
接下来,我们将创建一个explode()函数,当玩家收集到硬币时,该函数将启动一些动画。在类中创建一个布尔私有字段,并使用它来确保每个硬币只能爆炸一次:
void Coin::explode()
{
if (m_explosion) {
return;
}
m_explosion = true;
//...
}
我们想通过两个QPropertyAnimation对象来动画化我们的两个属性。一个使硬币淡出,另一个使硬币放大。为了确保两个动画同时开始,我们使用QParallelAnimationGroup,如下所示:
QPropertyAnimation *fadeAnimation =
new QPropertyAnimation(this, "opacity");
//...
QPropertyAnimation *scaleAnimation = new QPropertyAnimation(this, "rect");
//...
QParallelAnimationGroup *group = new QParallelAnimationGroup(this);
group->addAnimation(scaleAnimation);
group->addAnimation(fadeAnimation);
connect(group, &QParallelAnimationGroup::finished,
this, &Coin::deleteLater);
group->start();
刚才发生了什么?
你已经知道如何设置单个属性动画,所以我们省略了它的代码。在设置好两个动画后,我们通过在组动画上调用addAnimation()并将我们想要添加的动画的指针传递给它,将它们添加到组动画中。然后,当我们启动组时,QParallelAnimationGroup确保所有分配的动画同时开始。
当两个动画都完成时,group将发出finished()信号。我们将该信号连接到我们类的deleteLater()槽,这样当硬币对象不再可见时,它就会被删除。这个实用的槽在QObject类中声明,并在许多情况下很有用。
在某些情况下,你可能想停止一个动画。你可以通过调用stop()方法来实现。你也可以使用pause()和resume()方法暂停和恢复动画。在QParallelAnimationGroup上使用这些方法将影响该组添加的所有变换。
连接多个动画
如果我们想在另一个动画的末尾执行一个动画,我们可以将第一个动画的finished()信号连接到第二个动画的start()槽。然而,一个更方便的解决方案是使用QSequentialAnimationGroup。例如,如果我们想让硬币先放大然后淡出,以下代码就能实现这个效果:
QSequentialAnimationGroup *group = new QSequentialAnimationGroup(this);
group->addAnimation(scaleAnimation);
group->addAnimation(fadeAnimation);
group->start();
添加游戏手柄支持
玩家可以使用键盘来玩我们的游戏,但允许使用游戏手柄来玩会更好。幸运的是,Qt 提供了 Qt Gamepad 插件,使我们能够轻松实现这一点。与 Qt Essentials(例如 Qt Widgets)不同,插件可能只在有限数量的*台上受支持。截至 Qt 5.9,Qt Gamepad 支持 Windows、Linux、Android、macOS、iOS 和 tvOS(包括 tvOS 遥控器)。
在 Qt 中使用游戏手柄
游戏手柄 API 的起点是QGamepadManager类。可以使用QGamepadManager::instance()函数获取该类的单例对象。它允许你使用connectedGamepads()函数请求可用游戏手柄的标识符列表。可以使用gamepadConnected()信号实时检测新的游戏手柄。QGamepadManager还提供了配置游戏手柄按钮和轴的 API,并且能够将配置保存到指定的设置文件中。
在检测到系统中有可用的一个或多个游戏手柄后,你应该创建一个新的QGamepad对象,并将获得的设备标识符作为构造函数的参数传递。你可以使用第一个可用的游戏手柄,或者允许用户选择要使用哪个游戏手柄。在这种情况下,你可以利用游戏手柄的name属性,它返回设备的可读名称。
Gamepad 对象包含每个轴和按钮的专用属性。这为你提供了两种接收控制状态信息的方式。首先,你可以使用属性的获取器来检查按钮或轴的当前状态。例如,buttonL1() 函数将在 L1 按钮当前被按下时返回 true,而 axisLeftX() 将返回左摇杆当前水*位置的 double 值,该值在 -1 到 1 的范围内。对于触发按钮(例如,buttonL2()),属性包含一个从 0(未按下)到 1(完全按下)的范围内的 double 值。
第二种方式是使用对应每个属性的信号。例如,你可以连接到游戏手柄的 buttonL1Changed(bool value) 和 axisLeftXChanged(double value) 信号来监控相应属性的变化。
最后,QGamepadKeyNavigation 类可以用来快速为以键盘为导向的应用程序添加游戏手柄支持。当你创建这个类的对象时,你的应用程序将开始接收由游戏手柄引起的关键事件。默认情况下,GamepadKeyNavigation 将在相应的游戏手柄按钮被按下时模拟上、下、左、右、后退、前进和回车键。然而,你可以覆盖默认映射或为其他游戏手柄按钮添加自己的映射。
行动时间 - 处理游戏手柄事件
让我们从通过编辑 jrgame.pro 文件将 Qt 游戏手柄插件添加到我们的项目中开始:
QT += core gui widgets gamepad
这将使库的标题对我们项目可用,并告诉 qmake 将项目链接到这个库。现在将以下代码添加到 MyScene 类的构造函数中:
QList<int> gamepadIds = QGamepadManager::instance()->connectedGamepads();
if (!gamepadIds.isEmpty()) {
QGamepad *gamepad = new QGamepad(gamepadIds[0], this);
connect(gamepad, &QGamepad::axisLeftXChanged,
this, &MyScene::axisLeftXChanged);
connect(gamepad, &QGamepad::axisLeftYChanged,
this, &MyScene::axisLeftYChanged);
}
代码相当直接。首先,我们使用
使用 QGamepadManager::connectedGamepads 获取可用游戏手柄的 ID 列表。如果找到了一些游戏手柄,我们为第一个找到的游戏手柄创建一个 QGamepad 对象。我们将 this 传递给其构造函数,使其成为我们的 MyScene 对象的子对象,因此我们不需要担心删除它。最后,我们将游戏手柄的 axisLeftXChanged() 和 axisLeftYChanged() 信号连接到 MyScene 类中的新槽位。现在,让我们实现这些槽位:
void MyScene::axisLeftXChanged(double value)
{
int direction;
if (value > 0) {
direction = 1;
} else if (value < 0) {
direction = -1;
} else {
direction = 0;
}
m_player->setDirection(direction);
checkTimer();
}
void MyScene::axisLeftYChanged(double value)
{
if (value < -0.25) {
jump();
}
}
信号的 value 参数包含一个从 -1 到 1 的数字。这允许我们
不仅要检测是否有摇杆被按下,还要获取更精确的信息
关于其位置。然而,在我们的简单游戏中,我们不需要这种精度。在 axisLeftXChanged() 槽位中,我们根据接收到的值的符号计算并设置大象的 direction。在 axisLeftYChanged() 槽位中,如果我们收到足够大的负值,我们将其解释为 jump 命令。这将帮助我们避免意外跳跃。就这样!我们的游戏现在支持键盘和游戏手柄。
如果需要响应游戏手柄上的其他按钮和摇杆,请使用QGamepad类的其他信号。同时读取多个游戏手柄也是可能的,通过创建具有不同 ID 的多个QGamepad对象来实现。
项目碰撞检测
检查玩家项目是否与硬币碰撞是通过场景的checkColliding()函数来完成的,该函数在玩家项目水*或垂直移动后调用。
行动时间 - 使硬币爆炸
checkColliding()函数的实现如下:
void MyScene::checkColliding()
{
for(QGraphicsItem* item: collidingItems(m_player)) {
if (Coin *c = qgraphicsitem_cast<Coin*>(item)) {
c->explode();
}
}
}
刚才发生了什么?
首先,我们调用场景的QGraphicsScene::collidingItems()函数,它接受一个参数,即要检测碰撞项目的项。通过第二个可选参数,你可以定义如何检测碰撞。该参数的类型是Qt::ItemSelectionMode,这在前面已经解释过了。默认情况下,如果一个项目的形状与m_player相交,则认为该项目与m_player发生碰撞。
接下来,我们遍历找到的项目列表,检查当前项目是否是Coin对象。这是通过尝试将指针转换为Coin来完成的。如果成功,我们通过调用explode()来使硬币爆炸。多次调用explode()函数没有问题,因为它不会允许发生多次爆炸。这很重要,因为checkColliding()将在玩家的每次移动之后被调用。所以当玩家第一次击中硬币时,硬币会爆炸,但这需要时间。在这场爆炸期间,玩家很可能会再次移动,因此会再次与硬币碰撞。在这种情况下,explode()可能会被多次调用。
qgraphicsitem_cast<>()是dynamic_cast<>()的一个更快的替代品。然而,只有当自定义类型正确实现了type()时,它才会正确地工作。这个虚拟函数必须为应用程序中的每个自定义项目类返回不同的值。
collidingItems()函数总是会返回背景项目,因为玩家项目通常位于所有这些项目之上。为了避免持续检查它们是否实际上是硬币,我们使用了一个技巧。我们不是直接使用QGraphicsPixmapItem,而是从它派生出一个子类并重新实现其虚拟shape()函数,如下所示:
QPainterPath BackgroundItem::shape() const {
return QPainterPath();
}
我们已经在上一章中使用了QPainterPath类。这个函数只是返回一个空的QPainterPath。由于碰撞检测是通过项目的形状来完成的,背景项目不能与其他任何项目发生碰撞,因为它们的形状始终是空的。不过,不要尝试使用boundingRect()来玩这个技巧,因为它必须始终有效。
如果我们在Player内部实现跳跃逻辑,我们就可以从项目本身内部实现项目碰撞检测。QGraphicsItem还提供了一个collidingItems()函数,用于检查与自身碰撞的项目。所以scene->collidingItems(item)等同于item->collidingItems()。
如果你只关心一个物品是否与另一个物品发生碰撞,你可以在该物品上调用collidesWithItem()方法,并将另一个物品作为参数传递。
完成游戏
我们必须讨论的最后一部分是场景的初始化。为所有字段和构造函数设置初始值,创建一个initPlayField()函数来设置所有物品,并在构造函数中调用该函数。首先,我们初始化天空、树木、地面和玩家物品:
void MyScene::initPlayField()
{
setSceneRect(0, 0, 500, 340);
m_sky = new BackgroundItem(QPixmap(":/sky"));
addItem(m_sky);
BackgroundItem *ground = new BackgroundItem(QPixmap(":/ground"));
addItem(ground);
ground->setPos(0, m_groundLevel);
m_trees = new BackgroundItem(QPixmap(":/trees"));
m_trees->setPos(0, m_groundLevel - m_trees->boundingRect().height());
addItem(m_trees);
m_grass = new BackgroundItem(QPixmap(":/grass"));
m_grass->setPos(0,m_groundLevel - m_grass->boundingRect().height());
addItem(m_grass);
m_player = new Player();
m_minX = m_player->boundingRect().width() * 0.5;
m_maxX = m_fieldWidth - m_player->boundingRect().width() * 0.5;
m_player->setPos(m_minX, m_groundLevel - m_player->boundingRect().height() / 2);
m_currentX = m_minX;
addItem(m_player);
//...
}
接下来,我们创建金币对象:
m_coins = new QGraphicsRectItem(0, 0, m_fieldWidth, m_jumpHeight);
m_coins->setPen(Qt::NoPen);
m_coins->setPos(0, m_groundLevel - m_jumpHeight);
const int xRange = (m_maxX - m_minX) * 0.94;
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);
总共我们添加了 25 个金币。首先,我们设置一个大小与虚拟世界相同、不可见的物品,称为m_coins。这个物品应该是所有金币的父级。然后,我们计算m_minX和m_maxX之间的宽度。这是本杰明可以移动的空间。为了使其稍微小一点,我们只取 94%的宽度。然后,在for循环中,我们创建一个金币并随机设置其x和y位置,确保本杰明可以通过计算可用宽度和最大跳跃高度的模数来到达它们。添加完所有 25 个金币后,我们将持有所有金币的父级物品放置在场景中。由于大多数金币都在实际视图矩形之外,我们还需要在移动本杰明时移动金币。因此,m_coins必须像任何其他背景一样表现。为此,我们只需将以下代码添加到movePlayer()函数中,我们在其中也以相同的模式移动背景:
applyParallax(ratio, m_coins);
尝试一下英雄 - 扩展游戏
就这样。这是我们的小游戏。当然,还有很多改进和扩展的空间。例如,你可以添加一些本杰明需要跳过的障碍物。然后,你将不得不检查玩家物品在向前移动时是否与这样的障碍物发生碰撞,如果是,则拒绝移动。你已经学会了完成这项任务所需的所有必要技术,所以尝试实现一些额外的功能来加深你的知识。
动画的第三种方式
除了QTimer和QPropertyAnimation之外,还有第三种方法可以动画化场景。场景提供了一个名为advance()的槽。如果你调用该槽,场景将通过在每一个物品上调用advance()方法将调用传递给它所持有的所有物品。场景这样做两次。首先,所有物品的advance()函数都使用0作为参数被调用。这意味着物品即将前进。然后,在第二轮中,所有物品都被调用,并将1传递给物品的advance()函数。在这个阶段,每个物品都应该前进,无论这意味着什么——可能是移动,可能是颜色变化,等等。场景的advance槽通常由一个QTimeLine元素调用;通过这个,你可以定义在特定时间段内时间轴应该被触发多少次。
QTimeLine *timeLine = new QTimeLine(5000, this);
timeLine->setFrameRange(0, 10);
此时间表将在 10 次内每 5 秒发出一次 frameChanged() 信号。你所要做的就是将此信号连接到场景的 advance() 槽,场景将在 50 秒内前进 10 次。然而,由于每个前进都会对每个项目进行两次调用,这可能不是场景中有许多项目而只有少数应该前进的最佳动画解决方案。
快速问答
Q1. 以下哪项是动画属性的要求?
-
属性的名称必须以 "
m_" 开头。 -
属性的获取器和设置器必须是槽。
-
属性必须使用
Q_PROPERTY宏声明。
Q2. 哪个类在游戏手柄按钮被按下或释放时发送信号?
-
QGamepad -
QWidget -
QGraphicsScene
Q3. QGraphicsItem 的 shape() 和 boundingRect() 函数之间的区别是什么?
-
shape()返回一个QPainterPath作为边界矩形,而不是QRectF -
shape()导致项目被重新绘制。 -
share()可以返回比boundingRect()更精确的项目边界描述
摘要
在本章中,你加深了对项目、场景和视图的知识。在开发游戏的过程中,你熟悉了不同的动画项目的方法,并学习了如何检测碰撞。作为一个高级话题,你被引入了视差滚动的概念。
在完成描述图形视图的两个章节之后,你现在应该几乎了解关于图形视图的所有内容。你能够创建完整的自定义项目,你可以修改或扩展标准项目,并且根据细节级别,你甚至有权根据缩放级别改变项目的外观。你可以变换项目和场景,并且你可以动画化项目以及整个场景。
此外,正如你在开发游戏时所看到的,你的技能足够开发一个具有视差滚动的跳跃和跑酷游戏,正如它在高度专业的游戏中所使用的那样。我们还学习了如何将游戏手柄支持添加到我们的游戏中。为了保持流畅和高度响应,最后我们看到了如何充分利用图形视图的一些技巧。
当我们与小部件和图形视图框架一起工作时,我们必须使用一些通用目的的 Qt 类型,例如 QString 或 QVector。在简单的情况下,它们的 API 很明显。然而,Qt 核心模块提供的这些和其他许多类都非常强大,你将极大地从对它们的深入了解中受益。当你开发一个严肃的项目时,了解这些基本类型的工作原理以及它们在使用不当时可能带来的危险非常重要。在下一章中,我们将关注这个话题。你将学习如何在 Qt 中处理文本,在不同情况下应该使用哪些容器,以及如何操作各种类型的数据和实现持久化存储。这对于任何比我们的简单示例更复杂的游戏都是必不可少的。
第六章:Qt 核心基础
本章将帮助你掌握 Qt 基本数据处理和存储的方法。首先,你将学习如何处理文本数据以及如何将文本与正则表达式进行匹配。接下来,我们将概述 Qt 容器并描述与它们相关的常见陷阱。然后,你将了解如何从文件中存储和检索数据,以及如何使用不同的存储格式来存储文本和二进制数据。到本章结束时,你将能够高效地在你的游戏中实现非*凡逻辑和数据处理。你还将知道如何在游戏中加载外部数据,以及如何将你的数据保存到永久存储中以便将来使用。
本章涵盖的主要主题:
-
文本处理
-
Qt 容器
-
将数据序列化为 INI、JSON、XML 和二进制数据
-
保存应用程序的设置
文本处理
具有图形用户界面(游戏当然属于这一类别)的应用程序能够通过显示文本并期望用户输入文本与用户交互。我们已经在之前的章节中通过使用 QString 类触及了这一主题的表面。现在,我们将进一步深入探讨。
字符串编码
C++ 语言没有指定字符串的编码。因此,任何 char* 数组和任何 std::string 对象都可以使用任意编码。当使用这些类型与原生 API 和第三方库交互时,你必须参考它们的文档以了解它们使用的编码。操作系统原生 API 使用的编码通常取决于当前的区域设置。第三方库通常使用与原生 API 相同的编码,但某些库可能期望另一种编码,例如 UTF-8。
字符串字面量(即你用引号包裹的每个裸文本)将使用实现定义的编码。自 C++11 以来,你可以指定你的文本将具有的编码:
-
u8"text"将生成一个 UTF-8 编码的const char[]数组 -
u"text"将生成一个 UTF-16 编码的const char16_t[]数组 -
U"text"将生成一个 UTF-32 编码的const char32_t[]数组
不幸的是,用于解释源文件的编码仍然是实现定义的,因此将非 ASCII 符号放入字符串字面量中是不安全的。你应该使用转义序列(例如 \unnnn)来编写这样的字面量。
在 Qt 中,文本使用 QString 类进行存储,该类内部使用 Unicode。Unicode 允许我们表示世界上几乎所有的语言中的字符,并且是大多数现代操作系统中文本原生编码的事实标准。存在多种基于 Unicode 的编码。QString 内容的内存表示类似于 UTF-16 编码。基本上,它由一个 16 位值的数组组成,其中每个 Unicode 字符由 1 或 2 个值表示。
当从 char 数组或 std::string 对象构造 QString 时,使用适当的转换方法非常重要,该转换方法取决于文本的初始编码。默认情况下,QString 假设输入文本的编码为 UTF-8。UTF-8 与 ASCII 兼容,因此将 UTF-8 或仅 ASCII 文本传递给 QString(const char *str) 是正确的。QString 提供了多个静态方法来从其他编码转换,例如 QString::fromLatin1() 或 QString::fromUtf16()。QString::fromLocal8Bit() 方法假定与系统区域设置对应的编码。
如果您必须在同一个程序中结合使用 QString 和 std::string,QString 提供了 toStdString() 和 fromStdString() 方法来执行转换。这些方法也假设 std::string 的编码为 UTF-8,因此如果您的字符串使用其他编码,则不能使用它们。
字面量的默认表示(例如,"text")不是 UTF-16,因此每次将其转换为 QString 时,都会发生分配和转换。可以使用 QStringLiteral 宏避免这种开销:
QString str = QStringLiteral("I'm writing my games using Qt");
QStringLiteral 执行两个操作:
-
它在您的字符串字面量前添加一个
u前缀,以确保它在编译时以 UTF-16 编码 -
它以低廉的成本创建一个
QString并指示它使用字面量,而不进行任何分配或编码转换
将所有字符串字面量(除了需要翻译的)包装在 QStringLiteral 中是一个好习惯,但这不是必需的,所以如果您忘记这样做,请不要担心。
QByteArray 和 QString
QString 总是包含 UTF-16 编码的字符串,但如果有未知(尚未确定)编码的数据怎么办?或者,如果数据甚至不是文本怎么办?在这些情况下,Qt 使用 QByteArray 类。当您直接从文件读取数据或从网络套接字接收数据时,Qt 将数据作为 QByteArray 返回,表示这是一个没有关于编码信息的任意字节数组:
QFile file("/path/to/file");
file.open(QFile::ReadOnly);
QByteArray array = file.readAll();
在标准库中,QByteArray 的最接*等价物是 std::vector<char>。正如其名称所暗示的,这只是一个带有一些有用方法的字节数组。在前面的示例中,如果您知道您读取的文件是 UTF-8 编码的,您可以按以下方式将数据转换为字符串:
QString text = QString::fromUtf8(array);
如果您不知道文件使用什么编码,最好使用系统编码,因此 QString::fromLocal8Bit 会更好。同样,在写入文件时,您需要在将字符串传递给 write() 函数之前将其转换为字节数组:
QString text = "new file content\n";
QFile file("/path/to/file");
file.open(QFile::WriteOnly);
QByteArray array = text.toUtf8();
file.write(array);
您可以使用 file.close() 来关闭文件。QFile 也会在删除时自动关闭文件,因此如果您的 QFile 对象在完成文件操作后立即超出作用域,则不需要显式调用 close()。
使用其他编码
正如我们已经提到的,QString 提供了方便的方法来解码和编码在最受欢迎的编码中,如 UTF-8、UTF-16 和 Latin1。然而,Qt 也能处理许多其他编码。你可以使用 QTextCodec 类来访问它们。例如,如果你有一个 Big-5 编码的文件,你可以通过其名称请求 Qt 的编解码器对象,并使用 fromUnicode() 和 toUnicode() 方法:
QByteArray big5Encoded = big5EncodedFile.readAll();
QTextCodec *big5Codec = QTextCodec::codecForName("Big5");
QString text = big5Codec->toUnicode(big5Encoded);
QByteArray big5EncodedBack = big5Codec->fromUnicode(text);
你可以使用 QTextCodec::availableCodecs() 静态方法列出你的安装上支持的编解码器。在大多数安装中,Qt 可以处理* 1,000 种不同的文本编解码器。
基本字符串操作
涉及文本字符串的最基本任务包括添加或删除字符串中的字符、连接字符串以及访问字符串内容。在这方面,QString 提供了一个与 std::string 兼容的接口,但它还超越了这一点,暴露了许多更多有用的方法。
使用 prepend() 和 append() 方法可以在字符串的开始或末尾添加数据。使用 insert() 方法可以在字符串的中间插入数据,该方法将其第一个参数作为我们需要开始插入字符的位置,第二个参数是实际文本。所有这些方法都有一些重载,可以接受不同可以包含文本数据的对象,包括经典的 const char* 数组。
从字符串中删除字符的方式类似。基本方法是使用接受我们需要删除字符的位置和要删除的字符数的 remove() 方法,如下所示:
QString str = QStringLiteral("abcdefghij");
str.remove(2, 4); // str = "abghij"
还有一个接受另一个字符串的 remove() 重载。当调用时,它会从原始字符串中删除所有其出现。这个重载有一个可选参数,它指定比较应该是在默认的大小写敏感(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,返回一个副本或非常量引用到由 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 的工作方式)。第一个参数是字符串中搜索开始的起始位置。它让你找到给定子字符串的所有出现:
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 不同,那就是将字符串切割成更小的部分,并从更小的片段构建更大的字符串。
非常常见,一个字符串包含由重复分隔符粘合在一起的子字符串(例如,"1,4,8,15")。虽然你可以使用你已知的函数(例如,indexOf)从记录中提取每个字段,但存在一种更简单的方法。QString 包含一个 split() 方法,它接受分隔符字符串作为其参数,并返回一个由 Qt 中的 QStringList 类表示的字符串列表。然后,将记录分解成单独的字段就像调用以下代码一样简单:
QString record = "1,4,8,15,16,24,42";
QStringList items = record.split(",");
for(const QString& item: items) {
qDebug() << item;
}
此方法的逆操作是 QStringList 类中存在的 join() 方法,它返回列表中的所有项作为一个由给定分隔符合并的单个字符串:
QStringList fields = { "1", "4", "8", "15", "16", "24", "42" };
QString record = fields.join(",");
在数字和字符串之间转换
QString 还提供了一些方便地在文本和数值之间进行转换的方法。例如 toInt()、toDouble() 或 toLongLong() 的方法使得从字符串中提取数值变得容易。所有这些方法都接受一个可选的 bool *ok 参数。如果你将一个指向 bool 变量的指针作为此参数传递,该变量将被设置为 true 或 false,具体取决于转换是否成功。返回整数的方法还接受第二个可选参数,该参数指定值的数值基数(例如,二进制、八进制、十进制或十六进制):
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(42); // txt = "42"
此函数有一些可选参数,允许您控制数字的字符串表示形式。对于整数,您可以指定数值基数。对于双精度浮点数,您可以选择科学格式'e'或传统格式'f',并指定小数分隔符后的数字位数:
QString s1 = QString::number(42, 16); // "2a"
QString s2 = QString::number(42.0, 'f', 6); // "42.000000"
QString s3 = QString::number(42.0, 'e', 6); // "4.200000e+1"
一些表示值的其他类也提供了与QString之间的转换功能。这样的一个例子是QDate,它表示日期并提供fromString()和toString()方法。
这些方法对于技术目的来说很棒且易于使用,例如,在配置文件中读取和写入数字。然而,当您需要向用户显示数字或解析用户输入时,它们并不适用,因为不同国家的数字书写方式不同。这引出了国际化这一主题。
国际化
大多数实际项目都有多个国家的目标受众。它们之间最显著的区别是 spoken language,但还有其他一些开发者可能没有考虑到的方面。例如,点.和逗号,在全球范围内都相当常见,作为小数分隔符。日期格式也非常不同且不兼容,使用错误的格式(例如,mm/dd/yyyy而不是dd/mm/yyyy)将导致日期完全不同。
Qt 提供了QLocale类来处理与区域设置相关的操作,包括字符串中数字之间的转换。在以下代码中,text和number的值可能因系统区域设置的不同而不同:
QLocale locale = QLocale::system();
QString text = locale.toString(1.2);
double number = locale.toDouble(QStringLiteral("1,2"));
QLocale还提供了格式化日期和价格的方法,并允许我们请求有关本地约定的更多信息。
关于翻译,我们已提到,任何用户可见的文本都应该包裹在tr()函数中。现在我们将解释这一要求。
Qt 的翻译系统使得开发和翻译团队能够独立工作。项目经过以下步骤:
-
开发者创建一个应用程序,并将所有应翻译的文本包裹在特殊的翻译函数中(例如
tr())。表单中的可见文本会自动包裹在翻译函数中。 -
一个特殊的 Qt 工具(lupdate)搜索所有包裹在翻译函数中的字符串,并生成一个翻译文件(
.ts)。 -
翻译者在一个称为Qt Linguist的特殊应用程序中打开此文件。在该应用程序中,他们能够看到所有按上下文分组排列的字符串,这通常是指文本所属的类。他们可以在翻译文件中添加翻译并保存。
-
当这个新的翻译文件被复制回项目并使用
QCoreApplication::installTranslator函数应用时,翻译函数开始返回翻译后的文本,而不是简单地返回参数。 -
随着应用程序的发展和新未翻译文本的出现,它默认显示为未翻译。然而,它可以自动添加到翻译文件中,翻译者可以为新内容添加新的翻译,而不会丢失现有的翻译。
我们不会深入这个过程的细节。作为一个开发者,你只需要确保所有可见的字符串都被包裹在翻译函数中,并提供适当的上下文。上下文是必要的,因为简短的文本(例如,按钮上的一个单词)可能不足以理解其含义并提供适当的翻译,但我们如何指定上下文呢?
主要的翻译函数是QCoreApplication::translate()。它接受三个参数:上下文、要翻译的文本和一个可选的歧义文本。歧义参数很少需要。它可以用来区分同一上下文中多个相同文本的实例,以及它们应该有不同的翻译。
通常,你应该使用tr()函数而不是QCoreApplication::translate(),该函数在每个继承自QObject的类中声明。MyClass::tr(text, disambiguation)是QCoreApplication::translate(**"MyClass"**, text, disambiguation)的快捷方式。因此,位于一个类中的所有可翻译文本将共享相同的context字符串,这样它们将在 Qt Linguist 中分组,以便使翻译者的工作更容易。
如果你有一个不在QObject子类之外的翻译文本,默认情况下tr()函数将不可用。在这种情况下,你有以下选项:
-
使用
QCoreApplication::translate()函数并显式写出context参数 -
重新使用相关类的
tr()函数(例如,MyClass::tr()) -
在你的(非
QObject基于的)类中声明tr()函数,通过在类声明顶部添加Q_DECLARE_TR_FUNCTIONS(context)宏
注意,翻译函数应该直接接收字符串字面量。否则,lupdate将无法理解正在翻译哪个文本。以下代码是不正确的,因为两个字符串将不会被翻译者看到:
const char* text;
if (condition) {
text = "translatable1";
} else {
text = "translatable2";
}
QString result = tr(text); // not recognized!
解决这个问题的最简单方法是直接将tr()函数应用到每个字符串字面量上:
QString result;
if (condition) {
result = tr("translatable1");
} else {
result = tr("translatable2");
}
另一个解决方案是使用QT_TR_NOOP宏标记可翻译文本:
if (condition) {
text = QT_TR_NOOP("translatable1");
} else {
text = QT_TR_NOOP("translatable2");
}
QString result = tr(text);
QT_TR_NOOP宏返回其参数不变,但lupdate将识别这些字符串必须被翻译。
还可以通过使用特殊的 C++注释形式为翻译者添加注释://: ...或/*: ... */。考虑以下示例:
//: The button for sending attachment files
QPushButton *button = new QPushButton(tr("Send"));
在本节中,我们仅描述了在开始开发多语言游戏之前你需要了解的绝对最小知识。这些知识可以为你节省大量时间,因为在你编写时标记一些文本进行翻译比在大型代码库中后期进行要容易得多。然而,你需要学习更多才能在你的项目中实际实现国际化。我们将在稍后深入探讨这个主题(在线章节,*www.packtpub.com/sites/default/files/downloads/MiscellaneousandAdvancedConcepts.pdf)。
在字符串中使用参数
一个常见的任务是拥有一个需要动态变化的字符串,其内容取决于某些外部变量的值——例如,你可能想通知用户正在复制的文件数量,显示“正在复制文件 1/2”或“正在复制文件 2/5”,这取决于表示当前文件和文件总数的计数器的值。可能会诱使你通过使用可用的方法之一将所有片段组装在一起来完成这项任务:
QString str = "Copying file " + QString::number(current)
+ " of " + QString::number(total);
采用这种方法存在一些缺点;最大的问题是将字符串翻译成其他语言时,不同语言的语法可能要求两个参数的位置与英语不同。
相反,Qt 允许我们在字符串中指定位置参数,然后使用实际值替换它们。这种方法称为字符串插值。字符串中的位置用 % 符号标记(例如,%1、%2 等等),并通过调用 arg() 并传递用于替换字符串中下一个最低标记的值来替换它们。然后我们的文件复制消息构建代码变为如下:
QString str = tr("Copying file %1 of %2").arg(current).arg(total);
与内置的 printf() 函数的行为相反,你不需要在占位符中指定值的类型(如 %d 或 %s)。相反,arg() 方法有几个重载,可以接受单个字符、字符串、整数和实数。arg() 方法具有与 QString::number() 相同的可选参数,允许你配置数字的格式。此外,arg() 方法还有一个 fieldWidth 参数,它强制它始终输出指定长度的字符串,这对于格式化表格来说很方便:
const int fieldWidth = 4;
qDebug() << QStringLiteral("%1 | %2").arg(5, fieldWidth).arg(6, fieldWidth);
qDebug() << QStringLiteral("%1 | %2").arg(15, fieldWidth).arg(16, fieldWidth);
// output:
// " 5 | 6"
// " 15 | 16"
如果你想要使用除空格以外的字符来填充空白,请使用 arg() 的可选参数 fillChar。
正则表达式
让我们简要地谈谈正则表达式——通常简称为 "regex" 或 "regexp"。当您需要检查一个字符串或其部分是否与给定的模式匹配,或者当您想要在文本中找到特定的部分并可能提取它们时,您将需要这些正则表达式。验证有效性和查找/提取都基于所谓的正则表达式模式,它描述了字符串必须具有的格式才能有效、可找到或可提取。由于本书专注于 Qt,很遗憾没有时间深入探讨正则表达式。然而,这并不是一个大问题,因为您可以在互联网上找到许多提供正则表达式介绍的优质网站。
尽管正则表达式的语法有很多变体,但 Perl 使用的那个已经成为了事实上的标准。在 Qt 中,QRegularExpression 类提供了与 Perl 兼容的正则表达式。
QRegularExpression 首次在 Qt 5.0 中引入。在之前的版本中,唯一的正则异常类是 QRegExp,但为了兼容性它仍然可用。由于 QRegularExpression 更接* Perl 标准,并且与 QRegExp 相比执行速度要快得多,我们建议尽可能使用 QRegularExpression。尽管如此,您仍然可以阅读 QRegExp 的文档,其中包含对正则表达式的一个很好的通用介绍。
行动时间 - 一个简单的问答游戏
为了向您介绍 QRegularExpression 的主要用法,让我们想象这个游戏:一张显示物体的照片被展示给多个玩家,他们中的每一个都必须估计物体的重量。估计值最接*实际重量的玩家获胜。估计将通过 QLineEdit 提交。由于您可以在行编辑中写入任何内容,我们必须确保内容是有效的。
那么“有效”是什么意思呢?在这个例子中,我们定义一个介于 1g 和 999kg 之间的值是有效的。了解这个规范后,我们可以构建一个验证格式的正则表达式。文本的第一部分是一个数字,可以是 1 到 999 之间的任何数字。因此,相应的模式看起来像 [1-9]\d{0,2},其中 [1-9] 允许并且要求恰好一个数字,除了零。它可以选择性地后面跟多达两个数字,包括零。这通过 \d{0,2} 来表达,其中 \d 表示“任何数字”,0 是最小允许的计数,2 是最大允许的计数。输入的最后部分是重量的单位。使用如 (mg|g|kg) 这样的模式,我们允许重量以毫克(mg)、克(g)或千克(kg)输入。通过 \s*,我们最终允许在数字和单位之间有任意数量的空白字符。让我们将它们全部组合起来,并立即测试我们的正则表达式:
QRegularExpression regex("[1-9]\\d{0,2}\\s*(mg|g|kg)");
regex.setPatternOptions(QRegularExpression::CaseInsensitiveOption);
qDebug() << regex.match("100 kg").hasMatch(); // true
qDebug() << regex.match("I don't know").hasMatch(); // false
刚才发生了什么?
在第一行,我们构建了上述的 QRegularExpression 对象,并将正则表达式的模式作为参数传递给构造函数。请注意,我们必须转义 \ 字符,因为它在 C++ 语法中有特殊含义。
默认情况下,正则表达式是区分大小写的。然而,我们希望允许输入为大写或混合大小写。为了实现这一点,我们当然可以写出 (mg|mG|Mg|MG|g|G|kg|kG|Kg|KG) 或者匹配之前将字符串转换为小写,但有一个更干净、更易读的解决方案。在代码示例的第二行,你可以看到答案——一个模式选项。我们使用了 setPatternOptions() 来设置 QRegularExpression::CaseInsensitiveOption 选项,它不尊重使用的字符的大小写。当然,你还可以在 Qt 的 QRegularExpression::PatternOption 文档中了解更多选项。我们也可以将选项作为 QRegularExpression 构造函数的第二个参数传递,而不是调用 setPatternOptions():
QRegularExpression regex("[1-9]\\d{0,2}\\s*(mg|g|kg)",
QRegularExpression::CaseInsensitiveOption);
当我们需要测试一个输入时,我们只需要调用 match(),传递我们想要检查的字符串。作为回报,我们得到一个 QRegularExpressionMatch 类型的对象,它包含进一步所需的所有信息——而不仅仅是检查有效性。然后,我们可以通过 QRegularExpressionMatch::hasMatch() 确定输入是否匹配我们的标准,因为它在找到模式时返回 true。否则,当然返回 false。
我们的模式还没有完成。如果我们将它匹配到 "foo 142g bar",hasMatch() 方法也会返回 true。因此,我们必须定义模式是从匹配字符串的开始到结束进行检查的。这是通过 \A 和 \z 锚点完成的。前者标记字符串的开始,后者标记字符串的结束。当你使用这样的锚点时,不要忘记转义斜杠。正确的模式将看起来像这样:
QRegularExpression regex("\\A[1-9]\\d{0,2}\\s*(mg|g|kg)\\z",
QRegularExpression::CaseInsensitiveOption);
从字符串中提取信息
在我们检查发送的猜测是否格式良好之后,我们必须从字符串中提取实际的重量。为了能够轻松比较不同的猜测,我们还需要将所有值转换为共同的参考单位。在这种情况下,应该是毫克,这是最低的单位。那么,让我们看看 QRegularExpressionMatch 可以为我们提供什么来完成任务。
使用 capturedTexts(),我们可以得到一个包含模式捕获组的字符串列表。在我们的例子中,这个列表将包含 "23kg" 和 "kg"。列表的第一个元素总是被模式完全匹配的字符串。接下来的元素都是被使用的括号捕获的子字符串。由于我们缺少实际的数字,我们必须将模式的开始更改为 ([1-9]\d{0,2})。现在,列表的第二个元素是数字,第三个元素是单位。因此,我们可以写出以下内容:
int getWeight(const QString &input) {
QRegularExpression regex("\\A([1-9]\\d{0,2})\\s*(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。
请注意,在以后的时间添加一个组将使所有后续组的索引增加 1,您将不得不调整您的代码!如果您有长的模式,或者有很高的概率未来会添加更多的括号,您可以使用命名组来使您的代码更易于维护。有一个QRegularExpressionMatch::captured()重载,允许您指定组名而不是索引。例如,如果您已经编写了(?<number>[1-9][0-9]{0,2}),那么您可以通过调用match.captured("number")来获取数字。
为了能够使用提取的数字进行计算,我们需要将QString转换为整数。这是通过调用QString::toInt()完成的。此转换的结果随后存储在weight变量中。接下来,我们获取单位并将其转换为小写字符。这样,我们可以轻松地确定用户的猜测是否以克为单位,只需将单位与小写的"g"进行比较。我们不需要关心大写的"G"或变体"KG"、"Kg"和奇特的"kG"(千克)。
为了得到标准化的毫克重量,我们需要将weight乘以 1,000 或 1,000,000,具体取决于这是否以 g 或 kg 表示。最后,我们返回这个标准化重量。如果字符串格式不正确,我们返回-1以指示给定的猜测无效。然后,调用者负责确定哪个玩家的猜测是最好的。
注意您选择的整数类型是否可以处理重量的值。在我们的例子中,9.99 亿是可能的最大结果,幸运的是,它小于有符号 32 位整数的最大可能值(2,147,483,647)。如果您不确定您使用的类型在所有目标系统上是否足够大,请使用固定宽度整数类型(例如,int64_t)。
作为练习,尝试通过允许小数来扩展示例,使得"23.5g"是一个有效的猜测。为了实现这一点,您必须更改模式以输入小数,并且您还必须处理double而不是int作为标准化重量。
查找所有模式出现
最后,让我们看看如何查找字符串中的所有数字,即使是那些以零开头的数字:
QString input = QStringLiteral("123 foo 09 1a 3");
QRegularExpression regex("\\b\\d+\\b");
QRegularExpressionMatchIterator i = regex.globalMatch(input);
while (i.hasNext()) {
QRegularExpressionMatch match = i.next();
qDebug() << match.captured();
}
输入字符串包含一段示例文本,我们希望在其中找到所有数字。模式不应找到“foo”以及“1a”变量,因为这些不是有效的数字。因此,我们设置了模式,定义我们需要至少一个数字,\d+,并且这个数字——或者这些数字——应该被单词边界\b包围。请注意,您必须转义斜杠。使用这个模式,我们初始化QRegularExpression对象,并在其上调用globalMatch()。在传递的参数中,将搜索该模式。这次,我们没有返回QRegularExpressionMatch;相反,我们得到了QRegularExpressionMatchIterator类型的迭代器。由于QRegularExpressionMatchIterator有一个方便的hasNext()方法,我们检查是否存在进一步的匹配,如果有,就通过调用next()来获取下一个匹配。返回的匹配类型是QRegularExpressionMatch,您已经知道了。
如果您需要在while循环中了解下一个匹配项,您可以使用QRegularExpressionMatchIterator::peekNext()来获取它。这个函数的好处是它不会移动迭代器。
这样,您就可以遍历字符串中的所有模式出现。如果您,例如,想在文本中突出显示搜索字符串,这将很有帮助。
我们的示例将给出输出"123"、"09"和"3"。
考虑到这只是一个对正则表达式的简要介绍,我们鼓励您阅读文档中关于QRegularExpression、QRegularExpressionMatch和QRegularExpressionMatchIterator的详细描述部分。正则表达式非常强大且有用,因此,在您的日常编程生活中,您可以从正则表达式的深刻知识中受益!
容器
当您需要存储一组对象时,您需要一个容器来容纳它们。C++标准库提供了许多强大的容器,例如std::vector、std::list或std::map。然而,Qt 不使用这些容器(实际上,它几乎不使用任何标准库类)而是提供了自己的容器实现。当 Qt 容器被引入时,它们在不同*台上提供了比标准库实现显著更一致的性能,因此它们被要求创建可靠的跨*台应用程序。现在这并不是真的,因为 STL 实现和编译器已经发展并获得了新的优化和功能。然而,仍然有使用 Qt 容器的原因,尤其是在一个大量使用其他 Qt 类的应用程序中:
-
Qt API 始终使用 Qt 容器。当您收到一个
QList时,几乎永远不会比将其转换为标准库容器更高效或方便。在调用接受QList的方法之前,您应该在QList中填充输入数据,而不是将其从 STL 容器中转换。 -
Qt 容器提供了独特的功能,如隐式共享(我们将在本章后面讨论)或 Java 风格的迭代器,以及 STL 容器缺乏的一些便利方法。
-
Qt 容器遵循 Qt 的命名方案和 API 习惯,因此在以 Qt 为中心的程序中看起来更自然。例如,
QVector::isEmpty()比std::vector::empty()更像 Qt 风格。
此外,Qt 容器提供了与 STL 兼容的 API(例如,append() 方法有 push_back() 别名),这使得我们可以在不改变代码大部分内容的情况下用 STL 容器替换 Qt 容器。基于范围的 for 循环和一些标准库算法也与 Qt 容器兼容。话虽如此,如果你需要 Qt 容器中不可用的某些功能,使用 STL 容器是个不错的选择。
主要容器类型
当你与 Qt API 方法交互时,你在容器类型上没有太多选择,因为你需要使用该方法使用的容器。然而,通常,你可以自由选择容器来存储你的数据。让我们了解一下主要的 Qt 容器以及何时使用它们。
我们将只提供一个简要概述 Qt 容器,不会深入到不同操作的算法复杂度等细节。对于大多数 Qt 容器,都有一个类似的 STL 容器,我们将命名它。选择正确容器的主题被广泛讨论,并且不难找到更多相关信息,特别是对于 STL 容器。你还可以在 Qt 容器类文档页面找到更多信息。
QVector 在内存的连续区域存储项目。项目是紧密打包的,这意味着这种类型是最节省内存和缓存友好的。它的 STL 等价物是 std::vector。QVector 应该是默认选择的容器,这意味着只有在你有理由这样做的情况下才应该使用不同的容器。QVector 提供了按项目编号快速查找、*均快速在末尾追加项目和从末尾删除项目。从向量开始或中间插入和删除项目较慢,因为这会导致右侧的所有项目在内存中移动。使用 QVector 是直接的:
QVector<int> numbers;
numbers.append(1);
numbers.append(5);
numbers.append(7);
qDebug() << numbers.count(); // 3
qDebug() << numbers[1]; // 5
QLinkedList 容器,正如其名所示,实现了一个链表。它的 STL 等价物是 std::list。与 QVector 相比,它可以在任何位置(开始、中间或末尾)快速插入和删除项目,但按索引查找较慢,因为它需要从开始遍历项目以找到按索引的项目。QLinkedList 适用于需要多次在长列表中间插入或删除项目的情况。然而,请注意,在实际应用中,QVector 在这种情况下可能仍然更高效,因为 QLinkedList 在内存中不是紧密打包的,这增加了额外的开销。
QSet 是 Qt 的 std::unordered_set 等效物,是一个无序的唯一项目集合。它的优点是能够高效地添加项目、删除项目以及检查特定项目是否存在于集合中。其他列表类无法快速执行最后操作,因为它们需要遍历所有项目并将每个项目与参数进行比较。像任何其他集合一样,你可以遍历集合的项目,但迭代顺序未指定,也就是说,任何项目都可能出现在第一次迭代中,依此类推。以下代码展示了 QSet API 的一个示例:
QSet<QString> names;
names.insert("Alice");
names.insert("Bob");
qDebug() << names.contains("Alice"); // true
qDebug() << names.contains("John"); // false
for(const QString &name: names) {
qDebug() << "Hello," << name;
}
最后一个*面集合是 QList。目前不推荐使用它,除非与接受或生成 QList 对象的方法交互。它的性能和内存效率取决于项目类型,而定义“良好”项目类型的规则很复杂。对于“不良”类型,QList 表示为一个 void * 向量,每个项目都作为单独分配的对象存储在堆上。QList 实现可能在 Qt 6 中发生变化,但目前还没有官方信息。
有一些专门列表容器为特定项目类型提供了额外的功能:
-
已经熟悉的
QString类本质上是一个QChar(16 位 Unicode 字符)的向量 -
熟悉的
QByteArray是一个char向量 -
QStringList是一个带有额外便利操作的QList<QString> -
QBitArray提供了一个具有一些有用 API 的内存高效位数组
接下来,有两个主要的键值集合:QMap<K, T> 和 QHash<K, T>。它们允许你将类型为 T 的值(或多个值)与类型为 K 的键关联起来。它们都提供了相对快速的键查找。当遍历 QMap(类似于 std::map)时,项目按键排序,而不考虑插入顺序:
QMap<int, QString> map;
map[3] = "three";
map[1] = "one";
map[2] = "two";
for(auto i = map.begin(); i != map.end(); ++i) {
qDebug() << i.key() << i.value();
}
// output:
// 1 "one"
// 2 "two"
// 3 "three"
QHash(类似于 std::unordered_map)与 QMap 有非常相似的 API,但会按未指定的顺序遍历项目,就像 QSet。你可以在前面的例子中将 QMap 替换为 QHash,并看到即使重复运行相同的程序,迭代顺序也会改变。作为交换,QHash 在*均插入和键查找方面比 QMap 更快。如果你不关心迭代顺序,你应该使用 QHash 而不是 QMap。
一个细心的读者可能会想知道看起来非常确定性的代码如何产生随机结果。这种随机性是故意引入的,以防止对 QHash 和 QSet 的 算法复杂性攻击。你可以阅读 QHash 文档页面的相应部分,了解更多关于攻击和配置随机化的方法。
最后,QPair<T1, T2> 是一个简单的类,可以持有两种不同类型的两个值,就像 std::pair。你可以使用 qMakePair() 函数从两个值中创建一个对。
便利容器
除了前面描述的容器之外,还有一些容器建立在它们之上,提供了一些在特殊情况下更方便的 API 和行为:
| 容器 | 描述 |
|---|---|
QStack |
一个实现后进先出(LIFO)结构的QVector。它包含用于向栈中添加项目的push()函数,用于移除栈顶元素的pop()函数,以及用于读取栈顶元素而不移除它的top()函数。 |
QQueue |
一个实现先进先出(FIFO)结构的QList。使用enqueue()将项目追加到队列中,使用dequeue()从队列中取出头部项目,使用head()读取头部项目而不移除它。 |
QMultiMap |
一个针对具有多个键值的 API 定制的QMap。QMap已经允许我们这样做;例如,你可以使用QMap::insertMulti()方法使用一个键添加多个项目。然而,QMultiMap将其重命名为insert(),并隐藏了不允许每个键有多个值的原始QMap::insert()方法。 |
QMultiHash |
与QMultiMap类似,它是一个QHash,具有更方便的 API,用于存储每个键的多个值。 |
QCache |
一个类似于QHash的键值存储,允许你实现缓存。QCache将在元素未被最*使用时删除其元素,以保持缓存大小在最大允许大小之下。由于无法知道任意项目实际消耗的空间量,你可以为每个项目手动指定一个成本,以及特定QCache对象的最大总成本。 |
QContiguousCache |
一个扁*容器,允许你缓存大列表的一个子列表。这在实现大表格的查看器时很有用,因为在当前滚动位置附*很可能发生读写操作。 |
当你的任务与它们的用例匹配时,使用这些类中的一个是个好主意。
允许的项目类型
并非所有类型都可以放入容器中。所有容器只能持有提供默认构造函数、拷贝构造函数和赋值运算符的类型。所有原始类型和大多数 Qt 数据类型(如QString或QPointF)都满足这些要求。简单的结构体也可以存储在容器中,因为根据 C++标准,会自动为它们生成所需的构造函数和运算符。|
特定类型通常不能放入容器中,因为它没有无参构造函数,或者故意禁用了此类型的复制。对于QObject及其所有子类来说,情况确实如此。QObject的使用模式表明,你通常想要存储指向QObject的指针以供以后引用。如果该对象被移动到容器中或容器内部移动,指针将被无效化,因此这些类型没有复制构造函数。然而,你可以将指向QObject的指针放入容器中(例如,QVector<QObject *>),因为指针是一种满足所有要求的基本类型。在这种情况下,你必须手动确保在对象被删除后,你的容器不会包含任何悬垂指针。
前面的限制适用于列表项和键值集合的值,但它们的键呢?事实证明,键类型有更多的限制,这取决于集合类型。
QMap<K, T>还要求键类型K具有比较运算符operator<,它提供了一种全序(即满足一组特定的公理)。作为一个例外,指针类型也被允许作为键类型。
QHash<K, T>和QSet<K>要求K类型具有operator==运算符,并且存在qHash(K key)函数重载。Qt 为大量可能实现这些重载的类型提供了支持,如果需要,你可以为你的自定义类型创建重载。
隐式共享
标准库容器和 Qt 容器之间最显著的区别是隐式共享功能。在 STL 中,创建容器副本会立即导致内存分配并复制数据缓冲区:
std::vector<int> x { 1, 2, 3};
std::vector<int> y = x; // full copy
如果你没有打算编辑副本,这本质上是一种资源的浪费,你想要避免这种情况。在某些情况下,通过提供一个引用(const std::vector<int> &)而不是创建副本,可以轻松地做到这一点。然而,有时很难确保引用能够有效足够长的时间,例如,如果你想将其存储在类字段中。解决这个任务的另一种方法是使用shared_ptr包装一个向量,以显式地在多个对象之间共享它。当你使用 Qt 容器和一些其他 Qt 类型时,这变得不再必要。
在 Qt 中,所有主要的容器类型都实现了隐式共享或写时复制语义。复制一个QVector不会导致新的内存分配,直到两个向量中的任何一个发生变化:
QVector<int> x { 1, 2, 3};
QVector<int> y = x;
// x and y share one buffer now
y[0] = 5; // new allocation happens here
// x and y have different buffers now
只要不对副本或原始对象进行编辑,复制成本非常低。这允许你以低成本轻松地在对象之间共享常量数据,而无需在代码中手动管理共享对象。此功能也适用于QString、QPen和许多其他 Qt 值类型。任何复制操作仍然有一些由引用计数引起的运行时开销,因此当容易时,你被鼓励传递引用而不是创建副本。然而,在大多数情况下,这种开销微不足道,除了计算密集型的地方。
如果你喜欢隐式共享,你可以使用QSharedDataPointer在自己的数据类型中实现它。请参阅其文档以获取详细说明。
在大多数情况下,你可以像它们没有实现隐式共享一样使用容器,但有一些情况下你必须注意这一点。
指针失效
首先,隐式共享意味着在有可能更改此对象或任何共享相同缓冲区的对象时,不允许持有对容器内容的任何引用或指针。以下小型示例说明了问题:
// don't do this!
QVector<int> x { 1, 2, 3 };
int *x0 = x.begin();
QVector<int> y = x;
x[0] = 42;
qDebug() << *x0; // output: 1
我们用x向量的第一个元素的指针初始化了x0变量。然而,当我们为该元素设置新值然后尝试使用该指针读取它时,我们再次得到了旧值。
刚才发生了什么?
当我们将x向量复制到y时,两个向量的状态变得共享,原始缓冲区对它们两个都可用。然而,当我们使用operator[]修改x时,它变成了分离的,也就是说,为它分配了新的缓冲区,而y保留了原始缓冲区。x0指针继续指向原始缓冲区,现在它只对y可用。如果你删除QVector<int> y = x;这一行,输出将变为预期的 42。一般规则是,你应该避免在对象被修改或与另一个对象共享时存储指向其内容的指针或引用。
不必要的分配
接下来的问题是,对对象采取哪些操作会触发新缓冲区的实际分配?显然,x[0] = 42会触发分配,因为向量需要一个缓冲区来写入新数据。然而,如果x没有声明为const值或引用,int i = x[0]也会触发分配。这是因为即使在这次情况下并不必要,C++中的此代码也会触发可用的非const重载的operator[]。向量不知道请求的项目是否会更改,因此它必须假设它将会更改,并在返回新缓冲区中项目引用之前触发分配。
当使用具有 const 和非 const 重载的其他方法时,也会出现相同的问题,例如begin()或data()。基于范围的for循环也会调用begin(),所以如果你迭代非const值,它也会分离。
如果你显式地将容器变量声明为 const(例如,const QVector<int> y 或 const QVector<int> &y),则不可用非 const 方法,并且无法使用此变量触发分配。一个替代方案是使用仅对 const 版本可用的特殊方法别名,例如 at() 用于 operator=,constBegin() 用于 begin(),以及 constData() 用于 data()。然而,此解决方案不适用于基于范围的 for 循环。
基于范围的 for 循环和 Qt foreach 宏
Qt 提供了用于遍历 Qt 容器的 foreach 宏:
QVector<int> x { 1, 2, 3 };
foreach(const int i, x) {
qDebug() << i;
}
这个宏在基于范围的 for 循环进入 C++ 标准之前就已经可用,因此在 Qt 代码中仍然非常常见,你应该熟悉它。foreach 循环始终创建迭代对象的临时常量副本。由于它使用隐式共享,这非常便宜。如果你在遍历 x 时编辑它,更改将不会影响 i 的值,因为迭代使用的是副本,但这也意味着这种操作是安全的。请注意,当使用基于范围的 for 循环、STL 风格迭代器或 Java 风格迭代器时,编辑你正在遍历的同一容器通常是不安全的。例如,更改项目值可能是允许的,但删除项目可能会导致未定义的行为。
我们讨论了基于范围的 for 循环如何导致容器深拷贝。foreach 宏本身永远不会导致深拷贝。然而,如果你在遍历容器的同时编辑它,这将导致深拷贝,因为必须将两个数据版本存储在某个地方。
当使用基于范围的 for 循环时,你应该小心不要传递临时对象的引用。例如,此代码看起来合法,但它会导致未定义的行为:
// don't do this!
for(QChar c: QString("abc").replace('a', 'z')) {
qDebug() << c;
}
发生了什么?
我们创建了一个临时的 QString 对象并调用了它的 replace() 方法。此方法的返回类型是 QString &,因此它不拥有字符串的数据。如果我们立即将此值赋给拥有变量,则它是正确的,因为原始临时 QString 的生命周期持续到整个表达式结束(在这种情况下,赋值):
QString string = QString("abc").replace('a', 'z');
for(QChar c: string) { // correct
qDebug() << c;
}
然而,原始示例中的临时对象在 for 循环结束时不会存活,因此这会导致使用后释放的漏洞。此代码的 foreach 版本将包含对变量的隐式赋值,因此它是正确的。
另一方面,foreach 的宏特性是其缺点。例如,以下代码无法编译,因为项目类型包含逗号:
QVector<QPair<int, int>> x;
foreach(const QPair<int, int>& i, x) {
//...
}
错误是 "宏 Q_FOREACH 传递了 3 个参数,但它只接受 2 个"。要修复此问题,你必须为项目类型创建一个 typedef。
自 C++11 以来,基于范围的 for 循环是 foreach 的本地、干净的替代方案,因此我们建议你优先选择本地构造而不是宏,但请记住我们描述的陷阱。
数据存储
在实现游戏时,你经常会需要处理持久数据;你需要存储保存的游戏数据、加载地图等。为此,你必须了解让你可以使用存储在数字媒体上的数据的机制。
文件和设备
用于访问数据的最低级和最基本机制是从文件中保存和加载它。虽然你可以使用 C 和 C++提供的经典文件访问方法,例如stdio或iostream,但 Qt 提供了自己的文件抽象,它隐藏了*台相关的细节,并提供了一个在所有*台上以统一方式工作的干净 API。
当使用文件时,你将与之合作的两个基本类是QDir和QFile。前者代表目录的内容,允许你遍历文件系统、创建和删除目录,最后,访问特定目录中的所有文件。
遍历目录
使用QDir遍历目录非常简单。首先,你需要有一个QDir实例。最简单的方法是将目录路径传递给QDir构造函数。
Qt 以*台无关的方式处理文件路径。尽管 Windows 上的常规目录分隔符是反斜杠字符(\),而其他*台上是正斜杠(/),Qt 内部始终使用正斜杠,并且大多数 Qt 方法返回的路径从不包含反斜杠。你始终可以使用正斜杠将路径传递给 Qt 方法,即使在 Windows 上也是如此。如果你需要将 Qt 的路径表示形式转换为本地形式(例如,传递给标准库或第三方库),可以使用QDir::toNativeSeparators()。QDir::fromNativeSeparators()执行相反的操作。
Qt 提供了一系列静态方法来访问一些特殊目录。以下表格列出了这些特殊目录及其访问函数:
| 访问函数 | 目录 |
|---|---|
QDir::current() |
当前工作目录 |
QDir::home() |
当前用户的家目录 |
QDir::root() |
根目录——通常在 Unix 系统中为/,在 Windows 系统中为C:\ |
QDir::temp() |
系统临时目录 |
QStandardPaths类提供了关于系统中存在的其他标准位置的信息。例如,QStandardPaths::writableLocation(QStandardPaths::MusicLocation)返回用户音乐文件夹的路径。
请参考QStandardPaths::StandardLocation枚举文档以获取可用位置列表。
当你已经有一个有效的QDir对象时,你可以开始在不同目录之间移动。为此,你可以使用cd()和cdUp()方法。前者移动到命名的子目录,而后者移动到父目录。你应该始终检查这些命令是否成功。如果它们返回false,你的QDir对象将保持在同一目录!
要列出特定目录中的文件和子目录,您可以使用 entryList() 方法,该方法返回与 entryList() 传递的准则匹配的目录条目列表。filters 参数接受一个标志列表,这些标志对应于条目需要具有的不同属性才能包含在结果中。以下表格列出了最有用的标志:
| 过滤器 | 含义 |
|---|---|
QDir::Dirs, QDir::Files, QDir::Drives |
列出目录、文件或 Windows 驱动器。您至少应指定这些过滤器之一以获取任何结果。 |
QDir::AllEntries |
列出目录、文件和驱动器。这是 `Dirs |
QDir::AllDirs |
即使它们不匹配名称过滤器,也会列出目录。 |
QDir::NoDotAndDotDot |
不要列出 .(当前目录)和 ..(父目录)条目。如果存在 Dirs 标志且未指定 NoDotAndDotDot,则这些条目将始终列出。 |
QDir::Readable, QDir::Writable, QDir::Executable |
仅列出可读、可写或可执行的项目。 |
QDir::Hidden, QDir::System |
列出隐藏文件和系统文件。如果未指定这些标志,则不会列出隐藏和系统标志。 |
entryList() 的 sort 参数允许您选择结果的排序方式:
| 标志 | 含义 |
|---|---|
QDir::Unsorted |
项目顺序未定义。如果您不关心顺序,使用它是个好主意,因为它可能更快。 |
QDir::Name, QDir::Time, QDir::Size, QDir::Type |
按适当的条目属性排序。 |
QDir::DirsFirst, QDir::DirsLast |
确定目录是否应在文件之前或之后列出。如果未指定任何标志,则目录将与文件混合在输出中。 |
QDir::Reversed |
反转顺序。 |
此外,entryList() 方法还有一个重载版本,它接受一个以 QStringList 形式的文件名模式列表作为其第一个参数。以下是一个示例调用,它返回目录中所有按大小排序的 JPEG 文件:
QStringList nameFilters = { QStringLiteral("*.jpg"), QStringLiteral("*.jpeg") };
QStringList entries = dir.entryList(nameFilters,
QDir::Files | QDir::Readable, QDir::Size);
除了 entryList() 方法之外,还有一个 entryInfoList() 方法,它将每个返回的文件名包裹在一个具有许多方便功能的 QFileInfo 对象中。例如,QFileInfo::absoluteFilePath() 返回文件的绝对路径,而 QFileInfo::suffix() 返回文件的扩展名。
如果您需要递归遍历目录(例如,用于在所有子目录中查找所有文件),则可以使用 QDirIterator 类。
读取和写入文件
一旦您知道了文件的路径(例如,使用 QDir::entryList()、QFileDialog::getOpenFileName() 或某些外部来源),您就可以将其传递给 QFile 以接收一个作为文件句柄的对象。在可以访问文件内容之前,需要使用 open() 方法打开文件。此方法的基本变体需要一个模式,其中我们需要打开文件。以下表格解释了可用的模式:
| 模式 | 描述 |
|---|---|
只读 |
此文件可读取。 |
只写 |
此文件可写入。 |
读写 |
此文件可读取和写入。 |
追加 |
所有数据写入都将写入文件末尾。 |
截断 |
如果文件存在,在打开文件之前,其内容将被删除。 |
文本 |
读取时,所有行结束符都转换为 \n。写入时,所有 \n 符号都转换为本地格式(例如,Windows 上的 \r\n 或 Linux 上的 \n)。 |
无缓冲 |
该标志防止文件被缓冲。 |
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);
当QFile对象被销毁时,映射将自动删除。
设备
QFile实际上是QIODevice("输入/输出设备")的子类,QIODevice是 Qt 接口,用于抽象与读取和写入数据块相关的实体。有两种类型的设备:顺序访问设备和随机访问设备。QFile属于后者;它具有起始、结束、大小和当前位置的概念,用户可以通过seek()方法更改这些概念。顺序设备,如套接字和管道,表示数据流——无法回滚流或检查其大小;你只能按顺序逐个读取数据——一次读取一部分,你可以检查你目前距离数据末尾有多远。我们将在第七章,网络中处理此类设备。
所有 I/O 设备都可以打开和关闭。它们都实现了open()、read()和write()接口。向设备写入数据会将数据排队等待写入;当数据实际写入时,会发出bytesWritten()信号,该信号携带写入设备的数据量。如果在顺序设备中还有更多数据可用,它会发出readyRead()信号,通知你如果现在调用read,你可以期待从设备接收一些数据。
行动时间 - 实现加密数据的设备
让我们实现一个非常简单的设备,该设备使用一个非常简单的算法——凯撒密码来加密或解密通过它的数据流。在加密时,它将明文中的每个字符按密钥定义的字符数进行移位。解密时执行相反操作。因此,如果密钥是2,明文字符是a,密文变为c。使用密钥4解密z将得到值v。
首先,通过从“其他项目”类别中选择“空 qmake 项目模板”来创建一个新的空项目。接下来,添加一个main.cpp文件和一个新的CaesarCipherDevice类,该类从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_baseDevice = 0;
}
void setBaseDevice(QIODevice *dev) {
m_baseDevice = dev;
}
QIODevice *baseDevice() const {
return m_baseDevice;
}
void setKey(int k) {
m_key = k;
}
inline int key() const {
return m_key;
}
private:
int m_key;
QIODevice *m_baseDevice;
};
下一步是确保如果没有设备可以操作(即当m_baseDevice == nullptr时),则不能使用该设备。为此,我们必须重新实现QIODevice::open()方法,并在我们想要防止操作我们的设备时返回false:
bool CaesarCipherDevice::open(OpenMode mode) {
if(!m_baseDevice) {
return false;
}
if(!m_baseDevice->isOpen()) {
return false;
}
if(m_baseDevice->openMode() != mode) {
return false;
}
return QIODevice::open(mode);
}
该方法接受用户想要以何种模式打开设备。我们在调用基类实现之前执行一个额外的检查,以验证基础设备是否以相同的模式打开,这将标记设备为打开。
调用QIODevice::setErrorString来让用户知道错误是一个好主意。此外,当发生错误时,可以使用qWarning("message")将警告打印到控制台。
要有一个完全功能性的设备,我们仍然需要实现两个受保护的纯虚方法,这些方法执行实际的读取和写入。这些方法在需要时由 Qt 从类的其他方法调用。让我们从writeData()开始,它接受一个包含数据的缓冲区的指针和与缓冲区大小相等的大小:
qint64 CaesarCipherDevice::writeData(const char *data, qint64 len) {
QByteArray byteArray;
byteArray.resize(len);
for(int i = 0; i < len; ++i) {
byteArray[i] = data[i] + m_key;
}
int written = m_baseDevice->write(byteArray);
emit bytesWritten(written);
return written;
}
首先,我们创建一个局部字节数组并将其调整到输入的长度。然后,我们迭代输入的字节,将密钥的值添加到每个字节(这实际上执行了加密)并将其放入字节数组中。最后,我们尝试将字节数组写入底层设备。在通知调用者实际写入的数据量之前,我们发出一个携带相同信息的信号。
我们需要实现的最后一种方法是通过对基础设备进行读取并添加密钥到数据中的每个单元格来执行解密。这是通过实现readData()来完成的,它接受一个指向方法需要写入的缓冲区的指针以及缓冲区的大小。
代码与writeData()非常相似,只是我们是在减去密钥值而不是添加它:
qint64 CaesarCipherDevice::readData(char *data, qint64 maxlen) {
QByteArray baseData = m_baseDevice->read(maxlen);
const int size = baseData.size();
for(int i = 0; i < size; ++i) {
data[i] = baseData[i] - m_key;
}
return size;
}
首先,我们尝试从底层设备读取maxlen个字节并将数据存储在字节数组中。请注意,字节数组可能包含少于maxlen个字节(例如,如果我们到达了文件的末尾),但它不能包含更多。然后,我们迭代数组并将数据缓冲区的后续字节设置为解密值。最后,我们返回实际读取的数据量。
一个简单的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
你可以通过实现一个完整的 GUI 应用程序来结合你已知的知识,该应用程序能够使用我们刚刚实现的凯撒密码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很简单——你只需将其设备传递给构造函数,然后就可以使用了。QTextStream对象将从这个设备中读取或写入。默认情况下,QTextStream使用当前区域设置的编码,但如果它遇到 UTF-16 或 UTF-32 的字节顺序标记(BOM),它将切换到由 BOM 指定的编码。流接受字符串和数值:
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;
};
假设你有一组存储在QVector<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(const Player &player: players) {
stream << left << qSetFieldWidth(16) << player.name
<< qSetFieldWidth(0) << " ";
stream << right << qSetFieldWidth(10) << player.experience
<< qSetFieldWidth(0) << " ";
stream << right << qSetFieldWidth(6) << player.position.x()
<< qSetFieldWidth(0) << " ";
stream << qSetFieldWidth(6) << player.position.y()
<< qSetFieldWidth(0) << " ";
stream << center << qSetFieldWidth(10);
switch(player.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;
}
程序创建的文件应该看起来像这样:
Player Experience Position Direction
Gondael 46783 10 -5 north
Olrael 123648 -5 103 east
Nazaal 99372641 48 634 south
关于QTextStream的最后一件事是,它可以操作标准的 C 文件结构,这使得我们能够使用QTextStream,例如,写入stdout或从stdin读取,如下面的代码所示:
QTextStream stdoutStream(stdout);
stdoutStream << "This text goes to 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 { "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;
}
同样,最后,流本身被返回。
现在,我们可以使用QDataStream将我们的对象写入任何 I/O 设备(例如,文件、缓冲区或网络套接字):
Player player = /* ... */;
QDataStream stream(device);
stream << player;
读取对象同样简单:
Player player;
QDataStream stream(device);
stream >> player;
刚才发生了什么?
我们提供了两个独立的函数,用于为Player类定义到QDataStream实例的重新定向运算符。这使得你的类可以使用 Qt 提供和使用的机制进行序列化和反序列化。
XML 流
XML 已经成为用于存储层次化数据的最受欢迎的标准之一。尽管它冗长且难以用肉眼阅读,但它几乎在需要数据持久化的任何领域都被使用,因为它非常容易由机器读取。Qt 提供了两个模块来支持读取和写入 XML 文档:
-
Qt Xml 模块通过
QDomDocument、QDomElement等类提供使用文档对象模型(DOM)标准的访问。 -
Qt Core 模块包含
QXmlStreamReader和QXmlStreamWriter类,它们实现了流式 API。
QDomDocument的一个缺点是它要求我们在解析之前将整个 XML 树加载到内存中。此外,Qt Xml 没有积极维护。因此,我们将专注于 Qt Core 提供的流式方法。
在某些情况下,与流式方法相比,DOM 方法的缺点可以通过其易用性得到补偿,所以如果你觉得你已经找到了适合它的任务,你可以考虑使用它。如果你想在 Qt 中使用 DOM 访问 XML,记得在项目配置文件中添加QT += xml行以启用QtXml模块。
是时候采取行动 - 实现玩家数据的 XML 解析器
在这个练习中,我们将创建一个解析器来填充表示 RPG 游戏中玩家及其库存的数据。首先,让我们创建将保存数据的类型:
class InventoryItem {
Q_GADGET
public:
enum class Type {
Weapon,
Armor,
Gem,
Book,
Other
};
Q_ENUM(Type)
Type type;
QString subType;
int durability;
static Type typeByName(const QStringRef &r);
};
class Player {
public:
QString name;
QString password;
int experience;
int hitPoints;
QVector<InventoryItem> inventory;
QString location;
QPoint position;
};
struct PlayerInfo {
QVector<Player> players;
};
刚才发生了什么?
我们想在我们的枚举上使用Q_ENUM宏,因为它将使我们能够轻松地将枚举值转换为字符串并反向转换,这对于序列化非常有用。由于InventoryItem不是QObject,我们需要在类声明开头添加Q_GADGET宏,以便Q_ENUM宏能够工作。将Q_GADGET视为Q_OBJECT的一个轻量级变体,它启用了一些功能但不是全部。
typeByName()方法将接收一个字符串并返回相应的枚举变体。我们可以按以下方式实现此方法:
InventoryItem::Type InventoryItem::typeByName(const QStringRef &r) {
QMetaEnum metaEnum = QMetaEnum::fromType<InventoryItem::Type>();
QByteArray latin1 = r.toLatin1();
int result = metaEnum.keyToValue(latin1.constData());
return static_cast<InventoryItem::Type>(result);
}
实现可能看起来很复杂,但它比手动编写一大堆 if 语句来手动选择正确的返回值要少出错。首先,我们使用 QMetaEnum::fromType<T>() 模板方法来获取与我们的 enum 对应的 QMetaEnum 对象。此对象的 keyToValue() 方法执行我们需要的转换,但它需要伴随一些转换。
你可以注意到我们正在使用一个名为 QStringRef 的类。它代表一个字符串引用——现有字符串中的子串——并且以避免昂贵的字符串构造的方式实现;因此,它非常快。类似的 std::string_view 类型是在 C++17 中添加到标准库中的。我们将其用作参数类型,因为 QXmlStreamReader 将以这种格式提供字符串。
然而,keyToValue() 方法期望一个 const char * 参数,所以我们使用 toLatin1() 方法将我们的字符串转换为 QByteArray,然后使用 constData() 获取其缓冲区的 const char * 指针。最后,我们使用 static_cast 将结果从 int 转换为我们的 enum 类型。
将以下 XML 文档保存在某个地方。我们将使用它来测试解析器是否可以读取它:
<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 实例提供解析器接口:
class PlayerInfoReader {
public:
PlayerInfoReader(QIODevice *device);
PlayerInfo read();
private:
QXmlStreamReader reader;
};
类构造函数接受一个 QIODevice 指针,该指针将用于读取所需的数据。构造函数很简单,因为它只是将设备传递给 reader 对象:
PlayerInfoReader(QIODevice *device) {
reader.setDevice(device);
}
在我们开始解析之前,让我们准备一些代码来帮助我们处理这个过程。首先,让我们向类中添加一个枚举类型,该类型将列出所有可能的令牌——我们希望在解析器中处理的标签名称:
enum class Token {
Invalid = -1,
PlayerInfo, // root tag
Player, // in PlayerInfo
Name, Password, Inventory, Location, // in Player
Position, // in Location
InvItem // in Inventory
};
然后,就像我们在 InventoryItem 类中所做的那样,我们使用 Q_GADGET 和 Q_ENUM 宏,并实现 PlayerInfoReader::tokenByName() 便利方法。
现在,让我们实现解析过程的入口点:
PlayerInfo PlayerInfoReader::read() {
if(!reader.readNextStartElement()) {
return PlayerInfo();
}
if (tokenByName(reader.name()) != Token::PlayerInfo) {
return PlayerInfo();
}
PlayerInfo info;
while(reader.readNextStartElement()) {
if(tokenByName(reader.name()) == Token::Player) {
Player p = readPlayer();
info.players.append(p);
} else {
reader.skipCurrentElement();
}
}
return info;
}
首先,我们在读取器上调用 readNextStartElement(),使其找到第一个元素的起始标签,如果找到了,我们检查文档的根标签是否是我们期望的。如果不是,我们返回一个默认构造的 PlayerInfo,表示没有可用的数据。
接下来,我们创建一个 PlayerInfo 变量。我们遍历当前标签(PlayerInfo)中的所有起始子元素。对于每个子元素,我们检查它是否是 Player 标签,并调用 readPlayer() 下降到解析单个玩家数据的级别。否则,我们调用 skipCurrentElement(),这将快速前进流,直到遇到匹配的结束元素。
这个类中的其他方法通常遵循相同的模式。每个解析方法迭代所有起始元素,处理它所知道的元素,并忽略所有其他元素。这种做法使我们能够保持向前兼容性,因为较新版本的文档中引入的所有标签都会被较旧的解析器静默跳过。
readPlayer()的结构与之前相似;然而,它更复杂,因为我们还想要从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 Token::Name:
p.name = reader.readElementText();
break;
case Token::Password:
p.password = reader.readElementText();
break;
case Token::Inventory:
p.inventory = readInventory();
break;
//...
}
}
如果我们对标签的文本内容感兴趣,我们可以使用readElementText()来提取它。此方法读取直到遇到关闭标签,并返回其内的文本。对于Inventory标签,我们调用专门的readInventory()方法。
对于Location标签,代码比之前更复杂,因为我们再次进入读取子标签,提取所需信息并跳过所有未知标签:
case Token::Location:
p.location = reader.attributes().value("name").toString();
while(reader.readNextStartElement()) {
if(tokenByName(reader.name()) == Token::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;
接下来,我们再次跳过与任何已知标记不匹配的标签。在readPlayer()的末尾,我们简单地返回填充好的Player值。
最后一种方法在结构上与之前的方法类似——迭代所有标签,跳过我们不想处理的任何内容(即不是库存项目的所有内容),填充库存项目数据结构,并将项目追加到已解析项目列表中,如下所示:
QVector<InventoryItem> PlayerInfoReader::readInventory() {
QVector<InventoryItem> inventory;
while(reader.readNextStartElement()) {
if(tokenByName(reader.name()) != Token::InvItem) {
reader.skipCurrentElement();
continue;
}
InventoryItem item;
const QXmlStreamAttributes& attrs = reader.attributes();
item.durability = attrs.value("durability").toString().toInt();
item.type = InventoryItem::typeByName(attrs.value("type"));
while(reader.readNextStartElement()) {
if(reader.name() == "SubType") {
item.subType = reader.readElementText();
}
else {
reader.skipCurrentElement();
}
}
inventory << item;
}
return inventory;
}
在你的项目的main()函数中,编写一些代码来检查解析器是否工作正常。你可以使用qDebug()语句来输出列表的大小和变量的内容。以下代码是一个示例:
QFile file(filePath);
file.open(QFile::ReadOnly | QFile::Text);
PlayerInfoReader reader(&file);
PlayerInfo playerInfo = reader.read();
if (!playerInfo.players.isEmpty()) {
qDebug() << "Count:" << playerInfo.players.count();
qDebug() << "Size of inventory:" <<
playerInfo.players.first().inventory.size();
qDebug() << "Inventory item:"
<< playerInfo.players.first().inventory[0].type
<< playerInfo.players.first().inventory[0].subType;
qDebug() << "Room:" << playerInfo.players.first().location
<< playerInfo.players.first().position;
}
刚才发生了什么?
你刚才编写的代码实现了 XML 数据的完整自顶向下解析器。首先,数据通过一个标记化器,它返回比字符串更容易处理的标识符。然后,每个方法都可以轻松检查它接收到的标记是否是当前解析阶段的可接受输入。根据子标记,确定下一个解析函数,并解析器下降到较低级别,直到没有下降的地方。然后,流程向上回退一级并处理下一个子元素。如果在任何时刻发现未知标签,它将被忽略。这种方法支持一种情况,即新版本的软件引入了新的标签到文件格式规范中,但旧版本的软件仍然可以通过跳过它不理解的标签来读取文件。
尝试一下英雄——玩家数据的 XML 序列化器
现在你已经知道了如何解析 XML 数据,你可以创建互补的部分——一个模块,它将使用QXmlStreamWriter将PlayerInfo结构序列化到 XML 文档中。为此,你可以使用writeStartDocument()、writeStartElement()、writeCharacters()和writeEndElement()等方法。验证使用你的代码保存的文档是否可以使用我们共同实现的代码进行解析。
QVariant
QVariant是一个可以持有多种类型值的类:
QVariant intValue = 1;
int x = intValue.toInt();
QVariant stringValue = "ok";
QString y = stringValue.toString();
当你将一个值赋给一个QVariant对象时,该值及其类型信息将存储在其中。你可以使用它的type()方法来找出它持有哪种类型的值。QVariant的默认构造函数创建了一个无效值,你可以使用isValid()方法来检测它。
QVariant支持大量类型,包括 Qt 值类型,如QDateTime、QColor和QPoint。你还可以注册自己的类型以将它们存储在QVariant中。QVariant最强大的功能之一是能够存储值集合或值的层次结构。你可以使用QVariantList类型(它是QList<QVariant>的typedef)来创建一个QVariant对象的列表,并且你实际上可以将整个列表放入一个单一的QVariant对象中!你将能够检索列表并检查单个值:
QVariant listValue = QVariantList { 1, "ok" };
for(QVariant item: listValue.toList()) {
qDebug() << item.toInt() << item.toString();
}
同样,你可以使用QVariantMap或QVariantHash来创建一个具有QString键和QVariant值的键值集合。不用说,你还可以将这样的集合存储在一个单一的QVariant中。这允许你构建深度无限且结构任意的层次结构。
正如你所见,QVariant是一个相当强大的类,但我们如何用它进行序列化呢?首先,QVariant由QDataStream支持,因此你可以使用之前描述的二进制序列化来序列化和恢复你构造的任何QVariant值。例如,你不必将你的结构体中的每个字段放入QDataStream,你可以将它们放入一个QVariantMap,然后将其放入流中:
Player player;
QVariantMap map;
map["name"] = player.name;
map["experience"] = player.experience;
//...
stream << map;
加载数据也是直截了当的:
QVariantMap map;
stream >> map;
Player player;
player.name = map["name"].toString();
player.experience = map["experience"].toLongLong();
这种方法允许你在任意位置存储任意数据。然而,你也可以使用QVariant和QSettings一起方便地将数据存储在适当的位置。
QSettings
虽然这并不是严格意义上的序列化问题,但存储应用程序设置的方面与描述的主题密切相关。Qt 为此提供了一个解决方案,即QSettings类。默认情况下,它在不同的*台上使用不同的后端,例如在 Windows 上使用系统注册表或在 Linux 上使用 INI 文件。QSettings的基本使用非常简单——你只需要创建对象并使用setValue()和value()来存储和加载数据:
QSettings settings;
settings.setValue("level", 4);
settings.setValue("playerName", "Player1");
// ...
int level = settings.value("level").toInt();
你需要记住的唯一一件事是它操作的是QVariant,所以如果需要,返回值需要转换为适当的类型,就像前面代码中的toInt()一样。如果请求的键不在映射中,value()调用可以接受一个额外的参数,该参数包含要返回的值。这允许你在应用程序首次启动且设置尚未保存的情况下处理默认值,例如:
int level = settings.value("level", 1).toInt();
如果你没有指定默认值,当没有存储任何内容时,将返回一个无效的QVariant,你可以使用isValid()方法来检查这一点。
为了确保默认设置位置正确,你需要设置组织名称和应用程序名称。它们决定了QSettings默认存储数据的确切位置,并确保存储的数据不会与其他应用程序冲突。这通常在main()函数的开始处完成:
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
QCoreApplication::setOrganizationName("Packt");
QCoreApplication::setApplicationName("Game Programming using Qt");
//...
}
设置层次结构
最简单的情况假设设置是“扁*”的,即所有键都在同一级别上定义。然而,这不必是这种情况——相关的设置可以放入命名的组中。要操作一个组,你可以使用beginGroup()和endGroup()调用:
settings.beginGroup("server");
QString serverIP = settings.value("host").toString();
int port = settings.value("port").toInt();
settings.endGroup();
当使用这种语法时,你必须记住在完成操作后结束组。做同样事情的一种替代方法是直接将组名传递给value()的调用,使用/来分隔它和值名:
QString serverIP = settings.value("server/host").toString();
int port = settings.value("server/port").toInt();
你可以通过多次调用beginGroup()(或者,等价地,在值名中写入多个斜杠)来创建多个嵌套组。
向QSettings引入非扁*结构还有另一种方法。它可以处理复合QVariant值——QVariantMap和QVariantList。你可以简单地将你的数据转换为QVariant,就像我们之前将其转换为QJsonValue一样:
QVariant inventoryItemToVariant(const InventoryItem &item) {
QVariantMap map;
map["type"] = InventoryItem::typeToName(item.type);
map["subtype"] = item.subType;
map["durability"] = item.durability;
return map;
}
这个QVariant值可以传递给QSettings::setValue()。当然,你还需要实现逆操作。更重要的是,没有任何阻止你将数据转换为 JSON 并将其作为QByteArray保存到QSettings中。然而,这些方法可能比适当的序列化要慢,并且生成的设置文件难以手动编辑。
各种 Qt 类都有旨在与QSettings一起使用的方法,以便轻松保存一组属性。例如,QWidget::saveGeometry()和QWidget::restoreGeometry()辅助函数允许你将窗口的位置和大小保存到QSettings:
settings.setValue("myWidget/geometry", myWidget->saveGeometry());
//...
myWidget->restoreGeometry(
settings.value("myWidget/geometry").toByteArray());
类似地,多个小部件类有saveState()和restoreState()方法来保存小部件状态的信息:
-
QMainWindow可以保存工具栏和停靠小部件的位置 -
QSplitter可以保存其手柄的位置 -
QHeaderView可以保存表格的行或列的大小 -
QFileDialog可以保存对话框的布局、历史记录和当前目录
这些方法是保留用户在应用程序界面中做出的所有更改的绝佳方式。
自定义设置位置和格式
QSettings 类的构造函数有多个重载,允许您通过特定的 QSettings 对象更改数据存储的位置,而不是使用默认位置。首先,您可以覆盖组织名称和应用程序名称:
QSettings settings("Packt", "Game Programming using Qt");
接下来,您可以通过传递 QSettings::SystemScope 作为 scope 参数来使用系统范围的存储位置:
QSettings settings(QSettings::SystemScope,
"Packt", "Game Programming using Qt");
在此情况下,QSettings 将尝试读取所有用户的设置,然后回退到用户特定的位置。请注意,系统范围的存储位置可能不可写,因此在该位置上使用 setValue() 不会产生预期效果。
您还可以使用 QSettings::setDefaultFormat() 函数禁用首选格式检测。例如,在 Windows 上,使用以下代码禁用使用注册表:
QSettings::setDefaultFormat(QSettings::IniFormat);
最后,还有一个选项可以完全控制设置数据的位置——直接告诉构造函数数据应该位于何处:
QSettings settings(
QStandardPaths::writableLocation(QStandardPaths::ConfigLocation) +
"/myapp.ini",
QSettings::IniFormat
);
如果您将 QSettings::NativeFormat 传递给此构造函数,路径的含义将取决于*台。例如,在 Windows 上,它将被解释为注册表路径。
由于您可以使用 QSettings 读取和写入任意 INI 文件,因此它是实现对象序列化为 INI 格式的方便且简单的方法,这在简单情况下是合适的。
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);
JSON 文件
JSON 代表“JavaScript 对象表示法”,这是一种流行的轻量级文本格式,用于以可读的形式存储面向对象的数据。它起源于 JavaScript,在那里它是存储对象信息的原生格式;然而,它被广泛应用于许多编程语言,并且是网络数据交换的流行格式。Qt Core 支持 JSON 格式,如下面的代码所示。一个简单的 JSON 格式定义如下:
{
"name": "Joe",
"age": 14,
"inventory": [
{ "type": "gold", "amount": "144000" },
{ "type": "short_sword", "material": "iron" }
]
}
JSON 对象可以包含以下类型的值:
| 类型 | 描述 |
|---|---|
| bool | 布尔值(true 或 false)。 |
| double | 一个数值(例如,42.1)。 |
| string | 引号中的字符串(例如,"Qt")。 |
| array | 用方括号括起来的任何类型的值集合(例如,[42.1, "Qt"])。 |
| object | 用大括号括起来的键值对集合。键是字符串,值可以是任何类型(例如,{ "key1": 42.1, "key2": [42.1, "Qt"] })。 |
| null | 表示数据缺失的特殊值(null)。 |
一个合适的 JSON 文档 必须在顶层有一个数组或对象。在前面的例子中,我们有一个包含三个属性的对象:name、age 和 inventory。前两个属性是简单值,最后一个属性是一个包含两个对象且每个对象有两个属性的数组。
Qt 可以使用 QJsonDocument 类创建和读取 JSON 描述。可以使用 QJsonDocument::fromJson() 静态方法从 UTF-8 编码的文本创建文档,并且可以使用 toJson() 方法再次将其存储为文本形式。一旦创建了一个 JSON 文档,就可以使用 isArray() 和 isObject() 调用之一来检查它是否表示一个对象或数组。然后,可以使用 array() 或 object() 方法将文档转换为 QJsonArray 或 QJsonObject。
由于 JSON 的结构紧密地类似于 QVariant(它也可以使用 QVariantMap 来存储键值对,使用 QVariantList 来存储数组),因此也存在转换方法 QJsonDocument::fromVariant() 和 QJsonDocument::toVariant()。
QJsonObject 是一种可迭代的类型,可以查询其键列表(使用 keys() 方法)或请求特定键的值(使用 value() 方法或 operator[])。值使用 QJsonValue 类表示,它可以存储前面列出的任何值类型。可以使用 insert() 方法向对象添加新属性,该方法接受一个字符串键和一个作为 QJsonValue 的值。可以使用 remove() 方法删除现有属性。
QJsonArray 也是一种可迭代的类型,它包含一个经典列表 API;它包含 append()、insert()、removeAt()、at() 和 size() 等方法来操作数组中的条目,再次以 QJsonValue 作为项目类型。
行动时间 - 玩家数据 JSON 序列化器
我们接下来的练习是创建一个序列化器,其结构与我们在 XML 练习中使用的 PlayerInfo 结构相同,但这次的目标数据格式将是 JSON。
首先,创建一个 PlayerInfoJson 类,并给它一个类似于以下代码的接口:
class PlayerInfoJson {
public:
PlayerInfoJson() {}
QByteArray playerInfoToJson(const PlayerInfo &pinfo);
};
实际上所需做的就是实现 playerInfoToJson 方法。通常,我们需要将我们的 PlayerInfo 数据转换为 QJsonArray,然后使用 QJsonDocument 将其编码为 JSON:
QByteArray PlayerInfoJson::playerInfoToJson(const PlayerInfo &pinfo)
{
QJsonDocument doc(toJson(pinfo));
return doc.toJson();
}
现在,让我们开始实现 toJson() 方法:
QJsonArray PlayerInfoJson::toJson(const PlayerInfo &pinfo) {
QJsonArray array;
for(const Player &p: pinfo.players) {
array << toJson(p);
}
return array;
}
由于结构实际上是一个玩家列表,我们可以遍历它,将每个玩家转换为 QJsonValue,并将结果追加到 QJsonArray 中。有了这个函数,我们就可以向下级实现 toJson() 的重载,它接受一个 Player 对象:
QJsonValue PlayerInfoJson::toJson(const Player &player) {
QJsonObject object;
object["name"] = player.name;
object["password"] = player.password;
object["experience"] = player.experience;
object["hitpoints"] = player.hitPoints;
object["location"] = player.location;
object["position"] = QJsonObject({ { "x", player.position.x() },
{ "y", player.position.y() } });
object["inventory"] = toJson(player.inventory);
return object;
}
这次,我们使用 QJsonObject 作为我们的基本类型,因为我们想将值与键关联起来。对于每个键,我们使用索引操作符向对象添加条目。位置键包含一个 QPoint 值,这不是一个有效的 JSON 值,因此我们使用 C++11 初始化列表将点转换为包含两个键(x 和 y)的 QJsonObject。情况与存货不同——我们再次需要为 toJson 编写一个重载,以便执行转换:
QJsonValue PlayerInfoJson::toJson(const QVector<InventoryItem> &items) {
QJsonArray array;
for(const InventoryItem &item: items) {
array << toJson(item);
}
return array;
}
代码几乎与处理 PlayerInfo 对象的代码相同,所以让我们关注 toVariant 的最后一个重载——接受 Item 实例的那个:
QJsonValue PlayerInfoJson::toJson(const InventoryItem &item) {
QJsonObject object;
object["type"] = InventoryItem::typeToName(item.type);
object["subtype"] = item.subType;
object["durability"] = item.durability;
return object;
}
这里没有太多可评论的——我们向对象添加所有键,将项目类型转换为字符串。为此,我们必须添加一个静态的 InventoryItem::typeToName() 方法,它是 typeByName() 的反向操作,即它接受枚举变体并输出其名称作为字符串:
const char *InventoryItem::typeToName(InventoryItem::Type value)
{
QMetaEnum metaEnum = QMetaEnum::fromType<InventoryItem::Type>();
return metaEnum.valueToKey(static_cast<int>(value));
}
这基本上是 QMetaEnum::valueToKey() 方法的包装,它执行所有不可能在没有 Qt 的情况下完成的魔法。
序列化器已经完成!现在你可以使用 PlayerInfoJson::playerInfoToJson() 将 PlayerInfo 转换为包含 JSON 的 QByteArray。它适合写入文件或通过网络发送。然而,为了使其更有用,我们需要实现反向操作(反序列化)。
行动时间 - 实现一个 JSON 解析器
让我们扩展 PlayerInfoJSON 类,并为其添加一个 playerInfoFromJson() 方法:
PlayerInfo PlayerInfoJson::playerInfoFromJson(const QByteArray &ba) {
QJsonDocument doc = QJsonDocument::fromJson(ba);
if(!doc.isArray()) {
return PlayerInfo();
}
QJsonArray array = doc.array();
PlayerInfo pinfo;
for(const QJsonValue &value: array) {
pinfo.players << playerFromJson(value.toObject());
}
return pinfo;
}
首先,我们读取文档并检查它是否有效以及是否包含预期的数组。如果失败,则返回一个空结构;否则,我们遍历接收到的数组并将每个元素转换为对象。类似于序列化示例,我们为我们的数据结构中的每个复杂项创建一个辅助函数。因此,我们编写一个新的 playerFromJson() 方法,将 QJsonObject 转换为 Player,即与 toJson(Player) 相比执行反向操作:
Player PlayerInfoJson::playerFromJson(const QJsonObject &object) {
Player player;
player.name = object["name"].toString();
player.password = object["password"].toString();
player.experience = object["experience"].toDouble();
player.hitPoints = object["hitpoints"].toDouble();
player.location = object["location"].toString();
QJsonObject positionObject = object["position"].toObject();
player.position = QPoint(positionObject["x"].toInt(),
positionObject["y"].toInt());
player.inventory = inventoryFromJson(object["inventory"].toArray());
return player;
}
在这个函数中,我们使用了 operator[] 从 QJsonObject 中提取数据,然后使用不同的函数将数据转换为所需的类型。请注意,为了转换为 QPoint,我们首先将其转换为 QJsonObject,然后提取值,在它们用于构建 QPoint 之前使用。在每种情况下,如果转换失败,我们将为该类型获取一个默认值(例如,一个空字符串或一个零数字)。为了读取存货,我们采用另一个自定义方法:
QVector<InventoryItem> PlayerInfoJson::inventoryFromJson(
const QJsonArray &array)
{
QVector<InventoryItem> inventory;
for(const QJsonValue &value: array) {
inventory << inventoryItemFromJson(value.toObject());
}
return inventory;
}
剩下的就是实现 inventoryItemFromJson():
InventoryItem PlayerInfoJson::inventoryItemFromJson(
const QJsonObject &object)
{
InventoryItem item;
item.type = InventoryItem::typeByName(object["type"].toString());
item.subType = object["subtype"].toString();
item.durability = object["durability"].toDouble();
return item;
}
不幸的是,我们的 typeByName() 函数需要 QStringRef,而不是 QString。我们可以通过添加几个重载并将它们转发到单个实现来修复这个问题:
InventoryItem::Type InventoryItem::typeByName(const QStringRef &r) {
return typeByName(r.toLatin1());
}
InventoryItem::Type InventoryItem::typeByName(const QString &r) {
return typeByName(r.toLatin1());
}
InventoryItem::Type InventoryItem::typeByName(const QByteArray &latin1) {
QMetaEnum metaEnum = QMetaEnum::fromType<InventoryItem::Type>();
int result = metaEnum.keyToValue(latin1.constData());
return static_cast<InventoryItem::Type>(result);
}
刚才发生了什么?
实现的类可用于在Item实例和包含 JSON 格式对象数据的QByteArray对象之间进行双向转换。在这里我们没有进行任何错误检查;相反,我们依赖于 Qt 的规则,即错误会导致一个合理的默认值。
如果你想进行错误检查,在这种情况下最直接的方法是使用异常,因为它们会自动从多个嵌套调用传播到调用者的位置。确保你捕获你抛出的任何异常,否则应用程序将终止。一个更 Qt 的方法是在所有方法(包括内部方法)中创建一个bool *ok参数,并在发生任何错误时将该布尔值设置为false。
快速问答
Q1. 在 Qt 中,std::string最接*的等效是什么?
-
QString -
QByteArray -
QStringLiteral
Q2. 哪些字符串与\A\d\z正则表达式匹配?
-
由数字组成的字符串
-
由单个数字组成的字符串
-
这不是一个有效的正则表达式
Q3. 你可以使用哪种容器类型来存储小部件列表?
-
QVector<QWidget> -
QList<QWidget> -
QVector<QWidget*>
Q4. 你可以使用哪个类将包含 JSON 的文本字符串转换为 Qt JSON 表示?
-
QJsonValue -
QJsonObject -
QJsonDocument
摘要
在本章中,你学习了大量的核心 Qt 技术,从文本操作和容器到使用 XML 或 JSON 等流行技术访问可用于传输或存储数据的设备。你应该意识到,我们只是触及了 Qt 所能提供的皮毛,还有许多其他有趣的类你应该熟悉,但这个最小量的信息应该能让你领先一步,并展示你未来研究的方向。
在下一章中,我们将超越你电脑的边界,探索使用现代互联网强大世界的方法。你将学习如何与现有的网络服务交互,检查当前网络可用性,并实现你自己的服务器和客户端。如果你想要实现多人网络游戏,这些知识将非常有用。
第七章:网络
在本章中,您将学习如何与互联网服务器以及一般套接字进行通信。首先,我们将查看 QNetworkAccessManager,它使得发送网络请求和接收回复变得非常简单。基于这些基本知识,我们将使用 Google 的距离 API 来获取两个位置之间的距离以及从一个位置到另一个位置需要多长时间的信息。这种技术和相应的知识也可以用来通过它们的相应 API 将 Facebook 或 Twitter 包含到您的应用程序中。然后,我们将查看 Qt 的 Bearer API,它提供有关设备连接状态的信息。在最后一节中,您将学习如何使用套接字创建自己的服务器和客户端,使用 TCP 或 UDP 作为网络协议。
本章涵盖的主要主题如下:
-
使用
QNetworkAccessManager下载文件 -
使用 Google 的距离矩阵 API
-
实现 TCP 聊天服务器和客户端
-
使用 UDP 套接字
QNetworkAccessManager
Qt 中所有与网络相关的功能都在 Qt 网络模块中实现。访问互联网上的文件最简单的方法是使用 QNetworkAccessManager 类,该类处理您游戏与互联网之间的完整通信。
设置本地 HTTP 服务器
在我们的下一个示例中,我们将通过 HTTP 下载文件。如果您没有本地 HTTP 服务器,您可以使用任何公开可用的 HTTP 或 HTTPS 资源来测试您的代码。然而,当您开发和测试一个网络启用应用程序时,如果可能的话,建议您使用私有、本地网络。这样,您可以调试连接的两端,并且错误不会暴露敏感数据。
如果您不熟悉在您的机器上本地设置 Web 服务器,幸运的是,有许多免费的全能安装程序可供使用。这些程序将自动配置 Apache2、MySQL(或 MariaDB)、PHP 以及许多其他服务器。例如,在 Windows 上,您可以使用 XAMPP (www.apachefriends.org) 或 Uniform Server (www.uniformserver.com);在苹果电脑上,有 MAMP (www.mamp.info);在 Linux 上,您可以打开您首选的包管理器,搜索名为 Apache2 或类似名称的包,并安装它。或者,查看您发行版的文档。
在您在机器上安装 Apache 之前,考虑使用虚拟机,例如 VirtualBox (www.virtualbox.org) 来完成这项任务。这样,您可以保持机器的整洁,并且可以轻松尝试为您的测试服务器设置不同的配置。使用多个虚拟机,您甚至可以测试您游戏的不同实例之间的交互。如果您使用 Unix 系统,Docker (www.docker.com) 可能值得一看。
准备测试的 URL
如果你已经设置了一个本地 HTTP 服务器,请在已安装服务器的根目录下创建一个名为version.txt的文件。此文件应包含一小段文本,例如“我是一个本地的文件”或类似的内容。正如你可能已经猜到的,现实生活中的场景可能是检查服务器上是否有你游戏或应用的更新版本。为了测试服务器和文件是否正确设置,启动一个网页浏览器并打开http://localhost/version.txt。你应该会看到文件的内容:
.
如果这失败了,可能是因为你的服务器不允许你显示文本文件。不要在服务器的配置中迷失方向,只需将文件重命名为version.html。这应该会解决问题!
如果你没有 HTTP 服务器,你可以使用你最喜欢的网站的 URL,但要做好准备接收 HTML 代码而不是纯文本,因为大多数网站都使用 HTML。你也可以使用https://www.google.com/robots.txt URL,因为它会以纯文本响应。
行动时间 – 下载文件
创建一个 Qt Widgets 项目,并添加一个名为FileDownload的小部件类。添加一个按钮来启动下载,并添加一个纯文本编辑来显示结果。如往常一样,如果你需要任何帮助,可以查看书中提供的代码文件。
接下来,通过在项目文件中添加QT += network来启用 Qt 网络模块。然后,在构造函数中创建QNetworkAccessManager的一个实例并将其放入私有字段中:
m_network_manager = new QNetworkAccessManager(this);
由于QNetworkAccessManager继承自QObject,它需要一个指向QObject的指针,这被用作父对象。因此,你不需要在之后删除管理器。
第二,我们将管理器的finished()信号连接到我们选择的槽;例如,在我们的类中,我们有一个名为downloadFinished()的槽:
connect(m_network_manager, &QNetworkAccessManager::finished,
this, &FileDownload::downloadFinished);
我们必须这样做,因为QNetworkAccessManager的 API 是异步的。这意味着网络请求、读取或写入操作都不会阻塞当前线程。相反,当数据可用或发生其他网络事件时,Qt 会发送一个相应的信号,以便你可以处理数据。
第三,当按钮被点击时,我们实际上从本地主机请求version.txt文件:
QUrl url("http://localhost/version.txt");
m_network_manager->get(QNetworkRequest(url));
使用get(),我们向指定的 URL 发送请求以获取文件内容。该函数期望一个QNetworkRequest对象,它定义了通过网络发送请求所需的所有信息。此类请求的主要信息自然是文件 URL。这就是为什么QNetworkRequest在构造函数中将QUrl作为参数。你也可以使用setUrl()将 URL 设置到请求中。如果你希望定义一个请求头(例如,自定义用户代理),可以使用setHeader():
QNetworkRequest request;
request.setUrl(QUrl("http://localhost/version.txt"));
request.setHeader(QNetworkRequest::UserAgentHeader, "MyGame");
m_network_manager->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中显示,m_edit是QPlainTextEdit的一个实例:
void FileDownload::downloadFinished(QNetworkReply *reply) {
const QByteArray content = reply->readAll();
m_edit->setPlainText(QString::fromUtf8(content));
reply->deleteLater();
}
由于QNetworkReply继承了QIODevice,因此还有其他方法可以读取回复的内容。例如,你可以使用QDataStream或QTextStream分别读取和解释二进制或文本数据。在这里,作为第四个命令,使用QIODevice::readAll()在QByteArray对象中获取请求文件的完整内容。这与上一章中展示的从文件中读取非常相似。转移给相应QNetworkReply的指针的责任在我们这里,因此我们需要在槽函数的末尾删除它。然而,要小心,不要直接调用delete。始终使用deleteLater(),如文档中建议的那样!
在上一章中,我们警告你不要使用readAll()来读取大文件,因为它们无法适应单个QByteArray。对于QNetworkReply也是如此。如果服务器决定发送一个大的响应(例如,如果你尝试下载一个大文件),响应的第一部分将被保存到QNetworkReply对象内部的缓冲区中,然后下载将减慢速度,直到你从缓冲区中读取一些数据。然而,如果你只使用finished()信号,你无法做到这一点。相反,你需要使用QNetworkReply::readyRead()信号并按顺序读取数据的每一部分,以便释放缓冲区并允许接收更多数据。我们将在本章后面展示如何做到这一点。
完整的源代码可以在本书附带的书签下载示例中找到。如果你启动这个小型的演示应用程序并点击加载文件按钮,你应该能看到加载文件的正文内容:

尝试一下英雄——扩展基本文件下载器
当然,为了下载另一个文件而不得不修改源代码,这远非理想的方法,因此尝试通过添加一行编辑框来扩展对话框,以便您可以指定要下载的 URL。此外,您还可以提供一个文件对话框来选择下载文件将保存的位置。实现这一点的最简单方法是使用 QFileDialog::getSaveFileName() 静态函数。
每个应用程序一个网络管理器
对于整个应用程序来说,只需要一个 QNetworkAccessManager 的单例实例。例如,您可以在主窗口类中创建一个 QNetworkAccessManager 实例,并将其指针传递到所有需要它的其他地方。为了方便使用,您还可以创建一个 单例 并通过它来访问管理器。
单例模式确保一个类只被实例化一次。该模式对于访问应用程序范围内的配置或——在我们的案例中——QNetworkAccessManager 的实例很有用。
基于模板的简单单例创建方法如下(作为一个头文件):
template <class T>
class Singleton
{
public:
static T& instance()
{
static T static_instance;
return static_instance;
}
private:
Singleton();
~Singleton();
Singleton(const Singleton &);
Singleton& operator=(const Singleton &);
};
在源代码中,您将包含该头文件,并使用以下方式获取名为 MyClass 的类的单例:
MyClass &singleton = Singleton<MyClass>::instance();
这种单例实现不是 线程安全 的,这意味着尝试从多个线程同时访问实例将导致未定义的行为。线程安全单例模式的实现示例可以在 wiki.qt.io/Qt_thread-safe_singleton 找到。
如果您使用 Qt Quick——它将在第十一章中解释,Qt Quick 简介——与 QQmlApplicationEngine 一起,您可以直接使用引擎的 QNetworkAccessManager 实例:
QQmlApplicationEngine engine;
QNetworkAccessManager *network_manager = engine.networkAccessManager();
行动时间 - 显示适当的错误消息
如果您看不到文件的内容,说明出了问题。就像现实生活中一样,这种情况经常发生。因此,我们需要确保在这种情况下有一个良好的错误处理机制来通知用户发生了什么。幸运的是,QNetworkReply 提供了几个这样做的方法。
在名为 downloadFinished() 的槽函数中,我们首先想要检查是否发生了错误:
if (reply->error() != QNetworkReply::NoError) {
// error occurred
}
QNetworkReply::error() 函数返回处理请求时发生的错误。错误被编码为 QNetworkReply::NetworkError 类型的值。最常见的问题可能是这些:
| 错误代码 | 含义 |
|---|---|
QNetworkReply::ConnectionRefusedError |
程序根本无法连接到服务器(例如,如果没有服务器运行) |
QNetworkReply::ContentNotFoundError |
服务器响应了 HTTP 错误代码 404,表示无法找到请求的 URL 对应的页面 |
QNetworkReply::ContentAccessDenied |
服务器响应了 HTTP 错误代码 403,表示您没有权限访问请求的文件 |
有超过 30 种可能的错误类型,你可以在QNetworkReply::NetworkError枚举的文档中查找它们。然而,通常你不需要确切知道出了什么问题。你只需要知道是否一切顺利——在这种情况下,QNetworkReply::NoError将是返回值——或者是否出了问题。为了向用户提供有意义的错误描述,你可以使用QIODevice::errorString()。文本已经设置了相应的错误消息,我们只需要显示它:
if (reply->error()) {
const QString error = reply->errorString();
m_edit->setPlainText(error);
return;
}
在我们的示例中,假设我们在 URL 中犯了一个错误,错误地写成了versions.txt,应用程序将看起来像这样:

如果请求是 HTTP 请求并且状态码是有兴趣的,可以通过QNetworkReply::attribute()检索:
int statusCode =
reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
由于它返回QVariant,你需要使用QVariant::toInt()来将代码作为整数获取。除了 HTTP 状态码之外,你还可以通过attribute()查询很多其他信息。查看文档中QNetworkRequest::Attribute枚举的描述。在那里,你也会找到QNetworkRequest::HttpReasonPhraseAttribute,它包含 HTTP 状态码的可读原因短语,例如,如果发生 HTTP 错误 404,则为“未找到”。此属性的值用于设置QIODevice::errorString()的错误文本。因此,你可以使用errorString()提供的默认错误描述,或者通过解释回复的属性来创建自己的描述。
如果下载失败并且你想恢复它,或者如果你只想下载文件的一部分,你可以使用Range头。但是,服务器必须支持这一点。
在以下示例中,只有从300到500的字节将被下载:
QNetworkRequest request(url);
request.setRawHeader("Range", "bytes=300-500");
QNetworkReply *reply = m_network_manager->get(request);
如果你想要模拟在网站上发送表单,你通常需要发送 POST 请求而不是 GET 请求。这是通过使用QNetworkAccessManager::post()函数而不是我们使用的get()函数来完成的。你还需要指定有效载荷,例如,使用QHttpMultiPart类。
通过 FTP 下载文件
通过 FTP 下载文件与通过 HTTP 下载文件一样简单。如果是一个不需要认证的匿名 FTP 服务器,只需像我们之前做的那样使用 URL。假设在本地主机的 FTP 服务器上再次有一个名为version.txt的文件,输入以下内容:
m_network_manager->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的指针。使用这个指针,你可以将回复的信号连接到你的特定槽位。例如,如果你有多个 URL,并且你想将这些网站的所有链接图片保存到你的硬盘上,你可以通过QNetworkAccessManager::get()请求所有网页,并将它们的回复连接到一个专门用于解析接收到的 HTML 的槽位。如果找到图片链接,这个槽位将再次使用get()请求它们。然而,这次,这些请求的回复将连接到第二个槽位,该槽位是为将图片保存到磁盘而设计的。因此,你可以分离这两个任务:解析 HTML 和将数据保存到本地驱动器。
下一个将讨论QNetworkReply最重要的信号。
完成后的信号
finished()信号相当于我们之前使用的QNetworkAccessManager::finished()信号。一旦回复返回——无论成功与否——就会触发。在此信号发出后,回复的数据及其元数据将不再被更改。通过此信号,你现在可以将一个回复连接到特定的槽。这样,你可以实现上一节中概述的保存图像的场景。
然而,一个问题仍然存在:如果你发布同时请求,你不知道
哪一个已经完成,因此调用了连接的槽。与QNetworkAccessManager::finished()不同,QNetworkReply::finished()不传递QNetworkReply的指针;在这种情况下,这实际上是指向自身的指针。我们已经在第三章,Qt GUI 编程中遇到了类似的问题,所以让我们记住我们如何处理它。
解决这个问题的快速方法是使用sender()。它返回调用槽的QObject实例的指针。由于我们知道它是QNetworkReply,我们可以编写以下代码:
QNetworkReply *reply = qobject_cast<QNetworkReply*>(sender());
if (!reply) {
return;
}
在此代码中,我们需要将sender()返回的QObject指针转换为QNetworkReply类型的指针。
当你正在将继承自QObject的类进行类型转换时,使用qobject_cast。与dynamic_cast不同,它不使用 RTTI,并且可以在动态库边界之间工作。
虽然我们可以相当有信心类型转换会成功,但不要忘记检查指针是否有效。如果是空指针,则退出槽。
行动时间 - 使用 QSignalMapper 编写符合 OOP 的代码
一种更优雅的方法,不依赖于sender(),是使用QSignalMapper接收槽的参数中的回复对象。首先,你需要将QSignalMapper *m_imageFinishedMapper私有字段添加到你的类中。当你调用QNetworkAccessManager::get()请求每个图像时,设置映射器如下:
for(const QString& url: urls) {
QNetworkRequest request(url);
QNetworkReply *reply = m_network_manager->get(request);
connect(reply, SIGNAL(finished()),
m_imageFinishedMapper, SLOT(map()));
m_imageFinishedMapper->setMapping(reply, reply);
}
在一个显眼的位置,很可能是类的构造函数中,将映射器的map()信号连接到一个自定义槽。考虑以下示例:
connect(m_imageFinishedMapper, SIGNAL(mapped(QObject*)),
this, SLOT(imageFinished(QObject*)));
现在,你的槽接收回复对象作为参数:
void Object::imageFinished(QObject *replyObject)
{
QNetworkReply *reply = qobject_cast<QNetworkReply *>(replyObject);
//...
}
发生了什么?
首先,我们发布了请求并获取了QNetworkReply对象的指针。
然后,我们将回复完成的信号连接到了映射器的槽map()。接下来,我们调用映射器的setMapping()方法来指示发送者本身应该
作为槽的参数发送。效果与直接使用QNetworkAccessManager::finished(QNetworkReply *reply)信号非常相似,但这种方式,我们可以使用多个针对不同目的的槽(每个槽对应一个单独的映射器),所有这些槽都由一个QNetworkAccessManager实例提供服务。
QSignalMapper还允许您使用int或QString作为标识符,而不是像前面代码中使用QObject *。因此,您可以重写示例并使用 URL 来识别相应的请求。
错误信号
您可以在连接到finished()信号的槽中处理错误,也可以使用回复的error()信号,它将QNetworkReply::NetworkError类型的错误传递到槽中。在发出error()信号后,finished()信号很可能会很快发出。
readyRead 信号
到目前为止,我们一直使用连接到finished()信号的槽来获取回复的内容。如果您处理的是小文件,这种方法工作得很好。然而,当处理大文件时,这种方法不适用,因为它们会不必要地绑定太多资源。对于大文件,最好在数据可用时立即读取并保存传输的数据。每当有新数据可供读取时,我们都会通过QIODevice::readyRead()得到通知。因此,对于大文件,您应该使用以下代码:
QNetworkReply *reply = m_network_manager->get(request);
connect(reply, &QIODevice::readyRead,
this, &SomeClass::readContent);
m_file.open(QIODevice::WriteOnly);
这将帮助您连接回复的readyRead()信号到槽,设置QFile并打开它。在连接的槽中,输入以下代码片段:
QNetworkReply *reply = /* ... */;
const QByteArray byteArray = reply->readAll();
m_file.write(byteArray);
m_file.flush();
现在,您可以获取到目前为止已传输的内容,并将其保存到(已打开的)文件中。这样,所需资源最小化。别忘了在发出finished()信号后关闭文件。
在这种情况下,如果您事先知道要下载的文件大小,将会很有帮助。有了这些信息,我们可以提前检查磁盘上是否有足够的空间。我们可以使用QNetworkAccessManager::head()来完成这个目的。它就像get()函数一样,但它不会请求文件的内容。只传输头部信息,如果幸运的话,服务器会发送Content-Length头部信息,其中包含文件大小(以字节为单位)。为了获取这些信息,我们输入以下内容:
int length = reply->header(QNetworkRequest::ContentLengthHeader).toInt();
操作时间 - 显示下载进度
尤其是在下载大文件时,用户通常想知道已经下载了多少数据,以及下载完成大约需要多长时间。
为了实现这一点,我们可以使用回复的downloadProgress()信号。作为第一个参数,它传递已接收的字节数信息,作为第二个参数,传递总字节数。这使我们能够使用QProgressBar来指示下载进度。由于传递的参数是qint64类型,我们不能直接使用它们与QProgressBar一起,因为它只接受int。因此,在连接的槽中,我们可以执行以下操作:
void SomeClass::downloadProgress(qint64 bytesReceived, qint64 bytesTotal) {
qreal progress = (bytesTotal < 1) ? 1.0
: static_cast<qreal>(bytesReceived) / bytesTotal;
progressBar->setValue(qRound(progress * progressBar->maximum()));
}
刚才发生了什么?
首先,我们计算下载进度的百分比。计算出的progress值将在 0(0%)到 1(100%)之间。然后,我们为进度条设置新的值,其中progressBar是指向此进度条的指针。然而,progressBar->maximum()将有什么值,我们在哪里设置进度条的取值范围?令人高兴的是,你不必为每次新的下载设置它。它只需要设置一次,例如,在包含进度条的类的构造函数中。作为取值范围,我们建议使用以下值:
progressBar->setRange(0, 2048);
原因在于,如果你以 0 到 100 的范围为例,并且进度条宽度为 500 像素,每次值的变化都会使进度条前进 5 像素。这看起来会很丑。为了得到*滑的进度,每次进度条只增加 1 像素,范围应该是 0 到 99.999.999,这肯定可以工作,但效率会非常高。这是因为进度条当前值的变化会很大,而没有任何图形表示。因此,最佳的范围值应该是 0 到实际进度条宽度的像素数。不幸的是,进度条的宽度可能会根据实际小部件的宽度而变化,并且每次值变化时频繁查询实际大小也不是一个好的解决方案。那么,为什么是 2048 呢?它只是一个比我们可能得到的任何屏幕分辨率都要大的漂亮的整数。这确保了进度条即使在完全展开的情况下也能*滑运行。如果你针对的是较小的设备,请选择一个更小、更合适的数字。
为了能够计算下载完成剩余时间,你必须开始一个计时器。在这种情况下,使用QElapsedTimer。在通过QNetworkAccessManager::get()发布请求后,通过调用QElapsedTimer::start()来启动计时器。假设计时器被命名为m_timer,计算方法如下:
qreal remaining = m_timer.elapsed() *
(1.0 - progress) / progress;
int remainingSeconds = qRound(remaining / 1000);
QElapsedTimer::elapsed()返回从计时器开始时计数的毫秒数。假设下载进度是线性的,剩余时间与经过时间的比率等于(1.0 - progress) / progress。例如,如果progress是 0.25(25%),预期的剩余时间将是经过时间的三倍:(1.0 - 0.25) / 0.25) = 3。如果你将结果除以 1,000 并四舍五入到最接*的整数,你将得到剩余时间(以秒为单位)。
QElapsedTimer不要与QTimer混淆。QTimer用于在经过一定时间后调用槽。QElapsedTimer仅仅是一个方便的类,能够记住开始时间并通过从当前时间减去开始时间来计算经过的时间。
使用代理
如果你想使用代理,首先你需要设置QNetworkProxy。你可以使用setType()方法来定义代理的类型。作为参数,你很可能会传递QNetworkProxy::Socks5Proxy或QNetworkProxy::HttpProxy。然后,使用setHostName()设置主机名,使用setUserName()设置用户名,使用setPassword()设置密码。当然,后两个属性只有在代理需要认证时才需要。一旦设置了代理,你可以通过QNetworkAccessManager::setProxy()将其设置到访问管理器中。现在,所有新的请求都将使用这个代理。
连接到 Google、Facebook、Twitter 等。
由于我们讨论了QNetworkAccessManager,你现在有了将 Facebook、Twitter 或类似网站集成到你的应用程序所需的知识。它们都使用 HTTPS 协议和简单的请求来从它们那里检索数据。对于 Facebook,你必须使用所谓的 Graph API。它描述了哪些接口可用以及它们提供了哪些选项。如果你想搜索名为Helena的用户,你必须请求https://graph.facebook.com/search?q=helena&type=user。当然,你可以使用QNetworkManager来做这件事。你可以在developers.facebook.com/docs/graph-api找到有关 Facebook 可能请求的更多信息。
如果你希望在游戏中显示推文,你必须使用 Twitter 的 REST 或搜索 API。假设你知道你想显示的推文的 ID,你可以通过https://api.twitter.com/1.1/statuses/show.json?id=12345来获取它,其中12345是推文的实际 ID。如果你想找到提到#Helena的推文,你可以写https://api.twitter.com/1.1/search/tweets.json?q=%23Helena。你可以在developer.twitter.com/en/docs找到有关参数和其他 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_network_manager,它是一个指向 QNetworkAccessManager 的指针,以及 m_reply,它是一个指向 QNetworkReply 的指针。
行动时间 - 构建查询
每当按钮被按下时,就会调用 sendRequest() 槽:
void MainWindow::sendRequest()
{
if (m_reply != nullptr && m_reply->isRunning()) {
m_reply->abort();
}
ui->result->clear();
//...
}
在这个槽中,我们首先检查是否有旧请求,该请求存储在 m_reply 中,并且它是否仍在运行。如果是 true,我们将终止旧请求,因为我们即将安排一个新的请求。然后,我们通过在文本编辑上调用 QPlainTextEdit::clear() 来清除上一次请求的结果。
接下来,我们将构建请求的 URL。我们可以通过手动组合字符串来完成此操作,将查询参数添加到基本 URL 中,如下所示:
// don't do this!
QString url = baseUrl + "?origin=" + ui->from->text() + "&...";
除了当我们包含多个参数时这很快变得难以阅读的问题之外,它还相当容易出错。行编辑的值必须编码以符合有效 URL 的标准。因此,对于每个用户值,我们都必须显式调用 QUrl::toPercentEncoding()。一个更好的方法,它更容易阅读且错误更少,是使用 QUrlQuery。它避免了忘记编码数据时可能产生的问题。因此,我们这样做:
QUrlQuery query;
query.addQueryItem(QStringLiteral("sensor"), QStringLiteral("false"));
query.addQueryItem(QStringLiteral("language"), QStringLiteral("en"));
query.addQueryItem(QStringLiteral("units"), QStringLiteral("metric"));
query.addQueryItem(QStringLiteral("mode"), ui->vehicle->currentText());
query.addQueryItem(QStringLiteral("origins"), ui->from->text());
query.addQueryItem(QStringLiteral("destinations"), ui->to->text());
使用方法相当清晰:我们创建一个实例,然后使用 addQueryItem() 添加查询参数。第一个参数被视为键,第二个参数被视为值,结果是一个如 "key=value" 的字符串。当我们将 QUrlQuery 与 QUrl 一起使用时,值将自动编码。使用 QUrlQuery 的其他好处是,我们可以使用 hasQueryItem() 检查是否已经设置了键,将键作为参数传递,或者通过调用 removeQueryItem() 删除之前设置的键。
让我们回顾一下我们设置了哪些参数。sensor 键设置为 false,因为我们没有使用 GPS 设备来确定我们的位置。language 键设置为 English,对于单位,我们更倾向于公制而不是英制。然后,我们设置了与搜索相关的参数。origins 键包含我们想要开始的地点。其值是 ui->from 行编辑的文本。如果你想要查询多个起始位置,你只需使用 | 将它们组合起来。与起点等效,我们为目的地设置了值。最后,我们将组合框的值传递给模式,这定义了我们是想开车、骑自行车还是步行。接下来,我们执行请求:
QUrl url(QStringLiteral(
"https://maps.googleapis.com/maps/api/distancematrix/json"));
url.setQuery(query);
m_reply = m_network_manager->get(QNetworkRequest(url));
我们创建一个包含查询应发送到的地址的 QUrl。通过在末尾包含 json,我们定义服务器应使用 JSON 格式传输其响应。Google 还为我们提供了将结果作为 XML 获取的选项。要实现这一点,只需将 json 替换为 xml。然而,由于 Facebook 和 Twitter 的 API 返回 JSON,我们将使用此格式。
然后,我们通过调用 QUrl::setQuery() 将之前构建的 query 设置到 URL 中。这会自动编码值,所以我们不需要担心这一点。最后,我们通过调用 get() 函数发布请求,并将返回的 QNetworkReply 存储在 m_reply 中。
行动时间 - 解析服务器的响应
在构造函数中,我们将管理器的 finished() 信号连接到了 MainWindow 类的 finished() 插槽。因此,它将在请求发布后被调用:
void MainWindow::finished(QNetworkReply *reply)
{
if (m_reply != reply) {
reply->deleteLater();
return;
}
//...
}
首先,我们检查传入的响应是否是通过 m_network_manager 请求的。如果不是这种情况,我们删除 reply 并退出函数。这可能发生在 sendRequest() 插槽取消响应的情况下。由于我们现在确定这是我们请求的,我们将 m_reply 设置为 nullptr,因为我们已经处理了它,不再需要这个信息:
m_reply = nullptr;
if (reply->error() != QNetworkReply::NoError) {
ui->result->setPlainText(reply->errorString());
reply->deleteLater();
return;
}
接下来,我们检查是否发生了错误,如果发生了,我们将响应的错误字符串放入文本编辑中,删除响应,并退出函数。之后,我们最终可以开始解码服务器的响应:
const QByteArray content = reply->readAll();
const QJsonDocument doc = QJsonDocument::fromJson(content);
if (!doc.isObject()) {
ui->result->setPlainText(tr("Error while reading the JSON file."));
reply->deleteLater();
return;
}
使用 readAll(),我们可以获取服务器的响应内容。由于传输的数据量不大,我们不需要使用 readyRead() 进行部分读取。然后,我们使用 QJsonDocument::fromJson() 静态函数将内容转换为 QJsonDocument,该函数接受 QByteArray 作为参数并解析其数据。如果文档不是一个对象,那么服务器的响应是不有效的,因为 API 调用应该响应一个单一的对象。在这种情况下,我们在文本编辑中显示错误消息,删除响应,并退出函数。让我们看看代码的下一部分:
const QJsonObject obj = doc.object();
const QJsonArray origins = obj.value("origin_addresses").toArray();
const QJsonArray destinations = obj.value("destination_addresses").toArray();
由于我们现在已经确保存在一个对象,我们将其存储在 obj 中。此外,由于 API,我们还知道该对象包含 origin_addresses 和 destination_addresses 键。这两个值都是包含请求的来源和目的地的数组。从这一点开始,如果值存在且有效,我们将跳过任何测试,因为我们信任 API。该对象还包含一个名为 status 的键,其值可以用来检查查询是否可能失败,如果是,原因是什么。源代码的最后两行将来源和目的地存储在两个变量中。使用 obj.value("origin_addresses"),我们得到一个 QJsonValue,它包含由 origin_addresses 键指定的值的对,而 QJsonValue::toArray() 将此值转换为 QJsonArray。对于请求从华沙或埃尔兰根到伯明翰的距离的搜索,返回的 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 的结构,我们在文本编辑器中显示每个源-目的地对的值。因此,我们使用两个 QJsonArray 遍历每个可能的配对。我们需要索引以及值,所以我们使用经典的 for 循环而不是基于范围的循环:
QString output;
for (int i = 0; i < origins.count(); ++i) {
const QString origin = origins.at(i).toString();
const QJsonArray row = obj.value("rows").toArray().at(i).toObject()
.value("elements").toArray();
for (int j = 0; j < destinations.count(); ++j) {
首先,我们创建一个 output 字符串变量来缓存构建的文本。在开始第二个循环之前,我们计算两个变量,这两个变量对于所有目的地都是相同的。origin 变量包含当前源的文字表示,而 row 变量包含表格的相应行。每次我们尝试从一个 QJsonArray 或 QJsonObject 中获取项时,返回的值将具有 QJsonValue 类型,因此每次我们这样做时,我们需要根据 API 的预期结果将其转换为数组、对象或字符串。当我们计算 row 变量时,从回复的根对象开始,我们获取 rows 键的值并将其转换为数组 (obj.value("rows").toArray())。然后,我们获取当前行的值 (.at(i)),将其转换为对象,并获取其 elements 键 (.toObject().value("elements"))。由于此值也是一个数组——行的列,我们将其转换为数组。
两个循环内的作用域将遍历每个组合。想象一下传递的结果就像一个表格,其中源是行,目的地是列:
output += tr("From: %1\n").arg(origin);
output += tr("To: %1\n").arg(destinations.at(j).toString());
首先,我们在输出中添加 "From:" 字符串和当前源地址。对于目的地也执行相同的操作,这导致输出值为以下内容:
From: Warsaw, Poland
To: Birmingham, West Midlands, UK
接下来,我们将从调用 data 的相应 QJsonObject 中读取持续时间和距离:
const QJsonObject data = row.at(j).toObject();
const QString status = data.value("status").toString();
在此代码中,我们从行中获取当前列 (at(j)) 并将其转换为对象。这是包含 (i; j) 单元中起点-终点对的距离和持续时间的对象。除了 distance 和 duration,该对象还包含一个名为 status 的键。其值表示搜索是否成功(OK),起点或目的地是否无法找到(NOT_FOUND),或者搜索是否无法在起点和目的地之间找到路线(ZERO_RESULTS)。我们将 status 的值存储在一个同名的局部变量中。
接下来,我们检查状态并将距离和持续时间追加到输出中:
if (status == "OK") {
output += tr("Distance: %1\n").arg(
data.value("distance").toObject().value("text").toString());
output += tr("Duration: %1\n").arg(
data.value("duration").toObject().value("text").toString());
} else { /*...*/ }
对于距离,我们希望显示短语结果。因此,我们首先获取距离键的 JSON 值 (data.value("distance")),将其转换为对象,并请求文本键的值 (toObject().value("text"))。最后,我们使用 toString() 将 QJsonValue 转换为 QString。对于持续时间也是如此。最后,我们需要处理 API 可能返回的错误:
} else if (status == "NOT_FOUND") {
output += tr("Origin and/or destination of this "
"pairing could not be geocoded.\n");
} else if (status == "ZERO_RESULTS") {
output += tr("No route could be found.\n");
} else {
output += tr("Unknown error.\n");
}
output += QStringLiteral("=").repeated(35) + QStringLiteral("\n");
在每个单元的输出末尾,我们添加由 35 个等号组成的行 (QStringLiteral("=").repeated(35)) 以将结果与其他单元分开。最后,在所有循环完成后,我们将文本放入文本编辑器中并删除回复对象:
ui->result->setPlainText(output);
reply->deleteLater();
实际结果如下所示:

尝试一下英雄 - 选择 XML 作为回复的格式
要磨练您的 XML 技能,您可以使用 maps.googleapis.com/maps/api/distancematrix/xml 作为发送请求的 URL。然后,您可以像处理 JSON 一样解析 XML 文件,并同样显示检索到的数据。
控制连接状态
在尝试访问网络资源之前,检查您是否已建立到互联网的活跃连接是有用的。Qt 允许您检查计算机、移动设备或*板电脑是否在线。如果操作系统支持,您甚至可以启动新的连接。
相关 API 主要由四个类组成。QNetworkConfigurationManager 是基础和起点。它包含系统上可用的所有网络配置。此外,它提供有关网络功能的信息,例如,您是否可以启动和停止接口。它找到的网络配置存储为 QNetworkConfiguration 类。
QNetworkConfiguration 包含有关接入点的所有信息,但不包含网络接口的信息,因为一个接口可以提供多个接入点。此类还仅提供有关网络配置的信息。您无法通过 QNetworkConfiguration 配置接入点或网络接口。网络配置由操作系统负责,因此 QNetworkConfiguration 是一个只读类。然而,使用 QNetworkConfiguration,您可以确定连接类型是以太网、WLAN 还是 4G 连接。这可能会影响您将下载的数据类型,更重要的是,影响您将下载的数据大小。
使用 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 类型表示。或者,你也可以不检查 opened() 信号,而是监听 stateChanged() 信号。会话的可能状态有 Invalid(无效)、NotAvailable(不可用)、Connecting(连接中)、Connected(已连接)、Closing(关闭中)、Disconnected(已断开)和 Roaming(漫游)。
如果你想要以同步方式打开会话,请在调用 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(tr("Failure while starting server: %1")
.arg(m_server->errorString()));
return;
}
connect(m_server, &QTcpServer::newConnection,
this, &TcpServer::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()槽。每当有新的连接可用时,都会发出此信号。最后,我们显示可以通过serverAddress()和serverPort()访问的服务器的地址和端口号:
ui->address->setText(m_server->serverAddress().toString());
ui->port->setText(QString::number(m_server->serverPort()));
客户端需要这些信息以便能够连接到服务器。
行动时间 - 对新的挂起连接做出反应
当客户端尝试连接到服务器时,newConnection()槽会被调用:
void TcpServer::newConnection()
{
while (m_server->hasPendingConnections()) {
QTcpSocket *socket = m_server->nextPendingConnection();
m_clients << socket;
ui->disconnectClients->setEnabled(true);
connect(socket, &QTcpSocket::disconnected,
this, &TcpServer::removeConnection);
connect(socket, &QTcpSocket::readyRead,
this, &TcpServer::readyRead);
ui->log->appendPlainText(tr("* New connection: %1, port %2\n")
.arg(socket->peerAddress().toString())
.arg(socket->peerPort()));
}
}
刚才发生了什么?
由于可能存在多个挂起的连接,我们使用hasPendingConnections()来确定是否至少还有一个挂起的连接。然后,在while循环的迭代中处理每一个连接。为了获取QTcpSocket类型的挂起连接,我们调用nextPendingConnection()并将此连接添加到一个名为m_clients的私有向量中,该向量包含所有活动连接。在下一行,由于现在至少有一个连接,我们启用了允许关闭所有连接的按钮。连接到按钮click()信号的槽将调用每个单独连接的QTcpSocket::close()。当一个连接关闭时,其套接字会发出disconnected()信号。我们将此信号连接到我们的removeConnection()槽。在最后一个连接时,我们响应套接字的readyRead()信号,这表示有新数据可用。在这种情况下,我们的readyRead()槽会被调用。最后,我们打印一条系统消息,表明已建立新的连接。连接客户端和对方的地址和端口号可以通过套接字的peerAddress()和peerPort()函数获取。
如果无法接受新的连接,将发出acceptError()信号而不是newConnection()。它将QAbstractSocket::SocketError类型失败原因作为参数传递。如果你想要暂时拒绝新的连接,请在QTcpServer上调用pauseAccepting()。要恢复接受新的连接,请调用resumeAccepting()。
行动时间 - 前向转发新消息
当连接的客户端发送新的聊天消息时,由于它继承了QIODevice,底层的套接字会发出readyRead(),因此我们的readyRead()槽会被调用。
在我们查看这个槽之前,有一些重要的事情需要你记住。尽管 TCP 是有序且无重复的,但这并不意味着所有数据都作为一个大块传输。因此,在处理接收到的数据之前,我们需要确保我们得到了整个消息。不幸的是,没有简单的方法来检测是否所有数据都已传输,也没有一个全局通用的方法来完成这项任务。因此,这个问题取决于你,因为它取决于用例。然而,两种常见的解决方案是发送魔法令牌来指示消息的开始和结束,例如单个字符或 XML 标签,或者你可以提前发送消息的大小。
第二种解决方案在 Qt 文档中有所展示,其中消息长度被放在消息前的quint16中。另一方面,我们将探讨一种使用简单魔法令牌正确处理消息的方法。作为分隔符,我们使用“传输结束块”字符——ASCII 码 23——来指示消息的结束。我们还选择 UTF-8 作为传输消息的编码,以确保具有不同地域的客户之间可以相互通信。
由于接收数据的处理相当复杂,这次我们将逐步分析代码:
void TcpServer::readyRead()
{
QTcpSocket *socket = qobject_cast<QTcpSocket*>(sender());
if (!socket) {
return;
}
//...
}
要确定哪个套接字调用了槽,我们使用sender()。如果将QTcpSocket进行类型转换失败,我们将退出槽。
注意,sender()的使用是为了简单起见。如果你编写实际的代码,最好使用QSignalMapper。
接下来,我们使用readAll()读取传输的——可能是片段化的——消息:
QByteArray &buffer = m_receivedData[socket];
buffer.append(socket->readAll());
在这里,QHash<QTcpSocket*, QByteArray> m_receivedData是一个私有类成员,其中我们存储每个连接之前接收到的数据。当从客户端接收到第一块数据时,m_receivedData[socket]将自动将一个空的QByteArray插入到哈希中,并返回对其的引用。在后续调用中,它将返回对同一数组的引用。我们使用append()将新接收到的数据追加到数组的末尾。最后,我们需要确定现在是否已经完全接收了消息,如果有这样的消息:
while(true) {
int endIndex = buffer.indexOf(23);
if (endIndex < 0) {
break;
}
QString message = QString::fromUtf8(buffer.left(endIndex));
buffer.remove(0, endIndex + 1);
newMessage(socket, message);
}
在循环的每次迭代中,我们尝试找到第一个分隔符字符。如果我们没有找到(endIndex < 0),我们将退出循环,并将剩余的局部消息留在m_receivedData中。如果我们找到了分隔符,我们使用left(endIndex)函数获取第一个消息的数据,该函数返回从数组中左侧的endIndex个字节。为了从buffer中移除第一个消息,我们使用remove()函数,该函数将移除指定数量的字节,并将剩余的字节向左移动。我们希望移除endIndex + 1个字节(消息本身及其后的分隔符)。根据我们的传输协议,我们将数据解释为 UTF-8,并调用我们的newMessage()函数来处理接收到的消息。
在 newMessage() 函数中,我们将新消息追加到服务器日志并发送给所有客户端:
void TcpServer::newMessage(QTcpSocket *sender, const QString &message)
{
ui->log->appendPlainText(tr("Sending message: %1\n").arg(message));
QByteArray messageArray = message.toUtf8();
messageArray.append(23);
for(QTcpSocket *socket: m_clients) {
if (socket->state() == QAbstractSocket::ConnectedState) {
socket->write(messageArray);
}
}
Q_UNUSED(sender)
}
在这个函数中,我们根据我们的传输协议对消息进行编码。首先,我们使用 toUtf8() 将 QString 转换为 UTF-8 编码的 QByteArray。然后,我们添加分隔符字符。最后,我们遍历客户端列表,检查它们是否仍然连接,并将编码后的消息发送给它们。由于套接字继承了 QIODevice,你可以使用你从 QFile 知道的几乎所有函数。我们的服务器当前行为非常简单,所以我们没有使用 sender 参数的必要,因此我们添加了 Q_UNUSED 宏来抑制未使用参数的警告。
来试试英雄吧——使用 QSignalMapper
如前所述,使用 sender() 是一种方便的方法,但不是面向对象的方法。因此,尝试使用 QSignalMapper 来确定哪个套接字调用了槽。为了实现这一点,你必须将套接字的 readyRead() 信号连接到一个映射器,并将槽直接连接。所有与信号-映射器相关的代码都将放入 newConnection() 槽中。
这同样适用于连接到 removeConnection() 槽。让我们接下来看看它。
是时候行动了——检测断开连接
当客户端终止连接时,我们必须从本地的 m_clients 列表中删除套接字。套接字的 disconnected() 信号已经连接到 removeConnection() 插槽,所以我们只需按以下方式实现它:
void TcpServer::removeConnection()
{
QTcpSocket *socket = qobject_cast<QTcpSocket*>(sender());
if (!socket) {
return;
}
ui->log->appendPlainText(tr("* Connection removed: %1, port %2\n")
.arg(socket->peerAddress().toString())
.arg(socket->peerPort()));
m_clients.removeOne(socket);
m_receivedData.remove(socket);
socket->deleteLater();
ui->disconnectClients->setEnabled(!m_clients.isEmpty());
}
刚才发生了什么?
在通过 sender() 获取发出调用的套接字后,我们发布一个套接字正在被移除的信息。然后,我们从 m_clients 中删除套接字,从 m_receivedData 中删除相关的缓冲区,并对其调用 deleteLater()。不要使用 delete。最后,如果没有剩余的客户端,断开连接按钮将被禁用。
服务器已准备就绪。现在让我们看看客户端。
客户端
客户端的 GUI (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) {
ui->chat->appendPlainText(tr("== Connecting..."));
m_socket->connectToHost(ui->address->text(), ui->port->value());
//...
}
}
刚才发生了什么?
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());
while(true) {
int endIndex = m_receivedData.indexOf(23);
if (endIndex < 0) {
break;
}
QString message = QString::fromUtf8(m_receivedData.left(endIndex));
m_receivedData.remove(0, endIndex + 1);
newMessage(message);
}
这段代码与服务器中的 readyRead() 槽位非常相似。它甚至更简单,因为我们只有一个套接字和一个数据缓冲区,所以 m_receivedData 是一个单一的 QByteArray。客户端中 newMessage() 的实现也比服务器简单得多:
void TcpClient::newMessage(const QString &message)
{
ui->chat->appendPlainText(message);
}
在这里,我们只需要将接收到的消息显示给用户。
行动时间——发送文本消息
现在剩下的就是描述如何发送聊天消息。在行编辑器中按回车键时,会调用一个本地槽,该槽检查是否有实际要发送的文本以及 m_socket 是否仍然连接。如果一切准备就绪,我们构建一个包含自给用户名、冒号和行编辑器文本的消息:
QString message = QStringLiteral("%1: %2")
.arg(m_user).arg(ui->text->text());
然后,我们编码并发送消息,就像我们在服务器端做的那样:
QByteArray messageArray = message.toUtf8();
messageArray.append(23);
m_socket->write(messageArray);
就这么多了。就像从文件中写入和读取一样。对于完整的示例,请查看本书附带源代码,并运行服务器和几个客户端。
你可以看到服务器和客户端共享大量的代码。在实际项目中,你肯定希望避免这种重复。你可以将所有重复的代码移动到一个由服务器和客户端共同使用的公共库中。或者,你可以在一个项目中实现服务器和客户端,并使用命令行参数或条件编译启用所需的功能。
来试试吧——扩展聊天服务器和客户端
这个例子向我们展示了如何发送简单的文本。如果你现在继续定义一个通信应该如何工作的模式,你可以将其作为更复杂通信的基础。例如,如果你想使客户端能够接收所有其他客户端(及其用户名)的列表,你需要定义服务器在接收到客户端的特殊消息时将返回这样一个列表。你可以使用特殊的文本命令,如/allClients,或者你可以使用QDataStream或 JSON 序列化实现更复杂的信息结构。因此,在将消息转发给所有已连接的客户端之前,你必须解析服务器接收到的所有消息。现在就尝试自己实现这样的要求吧。
到目前为止,可能有多位用户选择了相同的用户名。通过获取用户列表的新功能,你可以防止这种情况发生。因此,你必须将用户名发送到跟踪它们的服务器。在当前实现中,没有阻止客户端每次使用不同的用户名发送消息。你可以让服务器处理用户名,而不是信任客户端的每条消息。
同步网络操作
我们解释的例子使用的是非阻塞、异步的方法。例如,在异步调用如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 线程将只接收信号,传递新消息,发送时,它只需将所需数据传递给工作线程。这样,你将得到一个超级流畅的绒毛 GUI。
使用 UDP
与 TCP 相比,UDP 是不可靠的且无连接的。既不能保证数据包的顺序,也不能保证它们的交付。然而,这些限制使得 UDP 非常快。所以,如果你有频繁的数据,这些数据不一定需要被对等方接收,可以使用 UDP。这些数据可以是玩家实时位置,频繁更新,或者实时视频/音频流。由于 QUdpSocket 主要与 QTcpSocket 相同——两者都继承自 QAbstractSocket——因此没有太多可解释的。它们之间的主要区别是,TCP 是面向流的,而 UDP 是面向数据报的。这意味着数据以小包的形式发送,其中包含实际内容,以及发送者和接收者的 IP 地址和端口号。
与 QTcpSocket 和 QTcpServer 不同,UDP 不需要一个单独的服务器类,因为它是无连接的。单个 QUdpSocket 可以用作服务器。在这种情况下,你必须使用 QAbstractSocket::bind() 而不是 QTcpServer::listen()。与 listen() 类似,bind() 接受允许发送数据报的地址和端口作为参数。请注意,TCP 端口和 UDP 端口之间完全无关。
每当一个新的数据包到达时,QIODevice::readyRead() 信号会被触发。要读取数据,请使用 receiveDatagram() 或 readDatagram() 函数。receiveDatagram() 函数接受一个可选的 maxSize 参数,允许你限制接收数据的尺寸。这个函数返回一个包含数据报的 QNetworkDatagram 对象,并具有许多获取数据的方法。其中最有用的是 data(),它返回作为 QByteArray 的有效载荷,以及 senderAddress() 和 senderPort(),允许你识别发送者。
readDatagram() 函数是一个更底层的函数,它接受四个参数。第一个参数是 char* 类型,用于写入数据,第二个参数指定要写入的字节数,最后两个参数是 QHostAddress* 和 quint16* 类型,用于存储发送者的 IP 地址和端口号。这个函数不太方便,但你可以比 receiveDatagram() 更高效地使用它,因为你可以为所有数据报使用相同的数据缓冲区,而不是为每个数据报分配一个新的缓冲区。
QUdpSocket 还提供了重载的 writeDatagram() 函数用于发送数据。其中一个重载简单地接受一个 QNetworkDatagram 对象。你也可以以 QByteArray 或 char* 缓冲区形式提供数据,但在这两种情况下,你还需要指定接收者的地址和端口号作为单独的参数。
是时候通过 UDP 发送文本了
例如,让我们假设我们有两个 QUdpSocket 类型的套接字。我们将第一个称为 socketA,另一个称为 socketB。它们都绑定到本机,socketA 绑定到 52000 端口,socketB 绑定到 52001 端口。因此,如果我们想从 socketA 向 socketB 发送字符串 Hello!,我们必须在持有 socketA 的应用程序中编写:
socketA->writeDatagram(QByteArray("Hello!"),
QHostAddress("127.0.0.1"), 52001);
包含 socketB 的类必须将套接字的 readyRead() 信号连接到一个槽。然后,由于我们的 writeDatagram() 调用,该槽将被调用,假设数据报没有丢失!在槽中,我们读取数据报和发送者的地址和端口号,使用:
while (socketB->hasPendingDatagrams()) {
QNetworkDatagram datagram = socketB->receiveDatagram();
qDebug() << "received data:" << datagram.data();
qDebug() << "from:" << datagram.senderAddress()
<< datagram.senderPort();
}
只要存在挂起的数据报——这是通过 hasPendingDatagrams() 检查的——我们就使用高级 QNetworkDatagram API 读取它们。在接收到数据报后,我们使用获取函数来读取数据和识别发送者。
勇敢的尝试者——连接本杰明游戏玩家
在掌握了这些基础知识后,你可以尝试自己做一些事情。例如,你可以玩本杰明大象游戏,并将本杰明的当前位置从一个客户端发送到另一个客户端。这样,你可以从客户端克隆屏幕到另一个客户端,或者两个客户端都可以玩游戏,并且还可以看到其他玩家的大象当前的位置。对于这样的任务,你会使用 UDP,因为位置更新非常快很重要,而丢失一个位置并不是灾难。
请记住,由于网络的复杂性,我们只是触及了网络的一角。全面覆盖将超出这本入门指南的范围。对于使用网络的真正游戏,你应该了解 Qt 通过 SSL 或其他机制建立安全连接的可能性。
快速问答
Q1. 你可以使用哪个类来读取通过网络接收到的数据?
-
QNetworkReply -
QNetworkRequest -
QNetworkAccessManager
Q2. 在 finished() 信号处理程序中,你通常应该对 QNetworkReply *reply 对象做什么?
-
使用
delete reply删除它 -
使用
reply->deleteLater()删除它 -
不要删除它
Q3. 如何确保你的应用程序不会因为处理 HTTP 请求而冻结?
-
使用
waitForConnected()或waitForReadyRead()函数 -
使用
readyRead()或finished()信号 -
将
QNetworkAccessManager移动到单独的线程
Q4. 你可以使用哪个类来创建 UDP 服务器?
-
QTcpServer -
QUdpServer -
QUdpSocket
摘要
在本章的第一部分,你熟悉了QNetworkAccessManager。每当你要在互联网上下载或上传文件时,这个类就是你的代码核心。在了解了你可以用来获取错误、接收新数据通知或显示进度的不同信号之后,你现在应该对该主题所需的一切都了如指掌。
关于距离矩阵 API 的示例依赖于你对QNetworkAccessManager的了解,并且展示了它的实际应用案例。处理作为服务器响应格式的 JSON 是第四章,“Qt 核心基础”的总结,但这是非常必要的,因为 Facebook 和 Twitter 只使用 JSON 来格式化它们的网络响应。
在最后一节,你学习了如何设置自己的 TCP 服务器和客户端。这使得你能够连接游戏的不同实例以提供多人游戏功能。或者,你被教导如何使用 UDP。
你现在熟悉了 Qt 小部件、图形视图框架、核心 Qt 类和网络 API。这些知识已经允许你实现具有丰富和高级功能的游戏。我们将探索 Qt 的最后一个大型和重要部分是 Qt Quick。然而,在我们到达那里之前,让我们巩固我们已经知道的知识,并研究一些高级主题。
现在,我们回到了小部件的世界。在第三章,“Qt GUI 编程”中,我们只使用了 Qt 提供的小部件类。在下一章,你将学习如何创建自己的小部件并将它们集成到你的表单中。
第八章:自定义小部件
我们到目前为止一直在使用现成的用户界面小部件,这导致了使用按钮进行井字棋游戏的粗糙方法。在本章中,你将了解 Qt 在自定义小部件方面提供的许多功能。这将使你能够实现自己的绘制和事件处理,并融入完全定制的内容。
本章涵盖的主要主题如下:
-
使用
QPainter -
创建自定义小部件
-
图像处理
-
实现一个棋盘游戏
光栅和矢量图形
当谈到图形时,Qt 将这个领域分为两个独立的部分。其中之一是光栅图形(例如,由小部件和图形视图使用)。这部分侧重于使用高级操作(如绘制线条或填充矩形)来操纵可以可视化在不同设备上的点的颜色网格,例如图像、打印机或你的计算机设备的显示。另一个是矢量图形,它涉及操纵顶点、三角形和纹理。这是针对处理和显示的最大速度,使用现代显卡提供的硬件加速。
Qt 使用表面(由QSurface类表示)的概念来抽象图形,它在表面上绘制。表面的类型决定了可以在表面上执行哪些绘图操作:支持软件渲染和光栅图形的表面具有RasterSurface类型,而支持 OpenGL 接口的表面具有OpenGLSurface类型。在本章中,你将深化你对 Qt 光栅绘制系统的了解。我们将在下一章回到 OpenGL 的话题。
QSurface对象可以有其他类型,但它们需要的频率较低。RasterGLSurface旨在供 Qt 内部使用。OpenVGSurface支持 OpenVG(一个硬件加速的 2D 矢量图形 API),在支持 OpenVG 但缺乏 OpenGL 支持的嵌入式设备上很有用。Qt 5.10 引入了VulkanSurface,它支持 Vulkan 图形 API。
光栅绘制
当我们谈论 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)。我们已经在第四章,使用 Graphics View 的定制 2D 图形中看到了QPainter的实际应用,当时我们创建了一个自定义的图形项。现在,让我们更深入地了解这个重要的类。
QPainter类有一个丰富的 API。这个类中最重要的方法可以分为三个组:
-
绘图器属性的设置器和获取器
-
以
draw和fill开头名称的方法,在设备上执行绘图操作 -
允许操作绘图器坐标系的方法
绘图器属性
让我们从属性开始。最重要的三个属性是画笔、刷子和字体。画笔持有绘图器绘制轮廓的属性,而刷子决定了如何填充形状。我们已经在第四章,使用 Graphics View 的定制 2D 图形中描述了画笔和刷子,所以你应该已经理解了如何使用它们。
font属性是QFont类的实例。它包含大量用于控制字体参数的方法,例如字体家族、样式(斜体或倾斜)、字体粗细和字体大小(以点或设备相关像素为单位)。所有参数都是不言自明的,所以我们在这里不会详细讨论它们。重要的是要注意QFont可以使用系统上安装的任何字体。如果需要更多对字体的控制或需要使用系统上未安装的字体,可以利用QFontDatabase类。它提供了有关可用字体的信息(例如,特定字体是否可缩放或位图,以及它支持哪些书写系统),并允许通过直接从文件中加载它们的定义来将新字体添加到注册表中。
在字体方面,一个重要的类是QFontMetrics类。它允许计算使用字体绘制特定文本所需的空间量,或者计算文本的省略。最常见的用例是检查为特定用户可见字符串分配多少空间;考虑以下示例:
QFontMetrics fm = painter.fontMetrics();
QRect rect = fm.boundingRect("Game Programming using Qt");
这在尝试确定小部件的sizeHint时特别有用。
坐标系
绘图器的下一个重要方面是其坐标系。实际上,绘图器有两个坐标系。一个是它自己的逻辑坐标系,它使用实数操作,另一个是绘图器操作的设备的物理坐标系。逻辑坐标系上的每个操作都映射到设备中的物理坐标,并在那里应用。让我们首先解释逻辑坐标系,然后我们将看到这与物理坐标有什么关系。
绘图器代表一个无限大的笛卡尔画布,默认情况下水*轴指向右,垂直轴指向下。可以通过对该系统应用仿射变换来修改它——*移、旋转、缩放和剪切。这样,你可以通过执行一个循环来绘制一个模拟时钟面,每个小时用一个线标记,每次循环将坐标系旋转 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矩形外进行绘制。如果您希望这种行为,您必须在画家中启用裁剪并定义裁剪区域或路径。
绘图操作
一旦正确设置了画家,您就可以开始发出绘图操作。QPainter提供了一套丰富的操作来绘制不同类型的原语。所有这些操作在其名称中都包含draw前缀,后跟要绘制的原语名称。因此,drawLine、drawRoundedRect和drawText等操作都提供了一些重载,通常允许我们使用不同的数据类型来表示坐标。这些可能是纯值(整数或实数),Qt 的类,如QPoint和QRect,或它们的浮点等效类——QPointF和QRectF。每个操作都是使用当前画家设置(字体、笔和画刷)执行的。
请参阅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家族的更多方法。
创建一个自定义小部件
是时候通过在部件上绘图将一些内容真正显示到屏幕上了。部件由于收到绘图事件而被重新绘制,该事件通过重新实现paintEvent()虚方法来处理。该方法接受一个指向QPaintEvent类型的事件对象的指针,该对象包含有关重新绘制请求的各种信息。记住,你只能在部件的paintEvent()调用内进行绘图。
行动时间 - 定制绘制的小部件
让我们立即将新技能付诸实践!在 Qt Creator 中创建一个新的 Qt Widgets 应用程序,选择QWidget作为基类,并确保不勾选生成表单框。我们的部件类名称将是Widget。
切换到新创建的类的头文件,在类中添加一个受保护的节,并在该节中键入void paintEvent。然后,按键盘上的Ctrl + Space,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);
}
编译并运行代码,你将得到以下输出:

刚才发生了什么?
首先,我们为画家设置了一个宽度为四像素的黑色笔。然后,我们调用rect()来获取小部件的几何矩形。通过调用adjusted(),我们接收一个新的矩形,其坐标(按照左、上、右、下的顺序)被给定的参数修改,从而有效地给我们一个每边有 10 像素边距的矩形。
Qt 通常提供两种方法,允许我们处理修改后的数据。调用adjusted()返回一个具有修改后属性的新对象,而如果我们调用adjust(),修改将就地完成。请特别注意你使用的方法,以避免意外结果。最好始终检查方法的返回值——它是否返回一个副本或空值。
最后,我们调用drawRoundedRect(),它使用第二个和第三个参数(在x,y顺序中)给出的像素数来绘制一个圆角矩形。如果你仔细看,你会注意到矩形有讨厌的锯齿状圆角部分。这是由抗锯齿效应引起的,其中逻辑线使用屏幕有限的分辨率进行*似;由于这个原因,一个像素要么完全绘制,要么完全不绘制。正如我们在第四章,“使用 Graphics View 的 2D 自定义图形”,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()方法,在新的坐标系中绘制一条红色线条。
动手时间——绘制示波器
让我们进一步扩展我们的小部件,使其成为一个简单的示波器渲染器。为此,我们必须让小部件记住一组值,并将它们绘制成一系列线条。
让我们从添加一个QVector<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:
// ...
QVector<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 中,用户与小部件之间的任何交互都是通过向小部件传递事件来完成的。这类事件通常被称为输入事件,包括键盘事件和不同形式的指向设备事件——鼠标、*板和触摸事件。
在典型的鼠标事件流程中,小部件首先接收到鼠标按下事件,然后是一系列鼠标移动事件(当用户在鼠标按钮按下时移动鼠标时),最后是一个鼠标释放事件。小部件还可以接收到除了这些事件之外的额外鼠标双击事件。重要的是要记住,默认情况下,只有在鼠标移动时按下鼠标按钮时,才会传递鼠标移动事件。要接收没有按钮按下时的鼠标移动事件,小部件需要激活一个称为鼠标跟踪的功能。
行动时间 - 使示波图可选择
是时候使我们的示波器小部件交互式了。我们将教会它添加几行代码,使用户能够选择绘图的一部分。让我们从存储选择开始。我们需要两个可以通过只读属性访问的整数变量;因此,向类中添加以下两个属性:
Q_PROPERTY(int selectionStart READ selectionStart
NOTIFY selectionChanged)
Q_PROPERTY(int selectionEnd READ selectionEnd
NOTIFY selectionChanged)
接下来,您需要创建相应的私有字段(您可以初始化它们都为-1)、获取器和信号。
用户可以通过将鼠标光标拖动到绘图上来更改选择。当用户在绘图上的某个位置按下鼠标按钮时,我们将该位置标记为选择的开始。拖动鼠标将确定选择的结束。事件命名的方案类似于绘图事件;因此,我们需要声明和实现以下两个受保护的方法:
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 | 图像交换格式 |
| JPG/JPEG | 联合摄影专家组 |
| PNG | 可移植网络图形 |
| PPM/PBM/PGM | 可移植任意图 |
| XBM | X 位图 |
| XPM | X Pixmap |
如你所见,最流行的图像格式都可用。通过安装额外的插件,该列表可以进一步扩展。
你可以通过调用静态方法 QImageReader::supportedImageFormats() 来请求 Qt 支持的图像类型列表,它返回 Qt 可以读取的格式列表。对于可写入的格式列表,请调用 QImageWriter::supportedImageFormats()。
图像也可以直接从现有的内存缓冲区加载。这可以通过两种方式完成。第一种是使用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> colorTable = indexed.colorTable();
for(QRgb &item: colorTable) {
int gray = qGray(item);
item = qRgb(gray, gray, gray);
}
indexed.setColorTable(colorTable);
然而,对于这个任务有一个更简洁的解决方案。您可以将任何图像转换为Format_Grayscale8格式:
QImage grayImage = coloredImage.convertToFormat(QImage::Format_Grayscale8);
此格式使用每个像素 8 位,没有颜色表,因此只能存储灰度图像。
修改
修改图像像素数据有两种方法。第一种仅适用于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 的类可能会很有用。它为位图提供了一个应用程序范围内的缓存。使用它,你可以加快位图加载速度,同时限制内存使用量。
最后,如果你只想将位图作为一个单独的小部件显示,你可以使用 QLabel。这个小部件通常用于显示文本,但你可以通过 setPixmap() 函数配置它以显示位图。默认情况下,位图以不缩放的方式显示。当标签比位图大时,它的位置由标签的对齐方式决定,你可以通过 setAlignment() 函数更改它。你还可以调用 setScaledContents(true) 将位图拉伸到标签的全尺寸。
绘制文本
使用 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 = nullptr) : QWidget(parent) {}
void setText(const QString &txt) {
m_staticText.setText(txt);
update();
}
protected:
void paintEvent(QPaintEvent *) {
QPainter painter(this);
painter.drawStaticText(0, 0, m_staticText);
}
private:
QStaticText m_staticText;
};
优化小部件绘制
作为一项练习,我们将修改我们的示波器小部件,使其只重绘所需的数据部分。
行动时间 – 优化示波器绘制
第一步是修改绘制事件处理代码,以获取需要更新的区域信息并将其传递给绘制图表的方法。代码中的改动部分已在此处突出显示:
void Widget::paintEvent(QPaintEvent *event)
{
QRect exposedRect = event->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_selectionStart);
selectionRect.setRight(m_selectionEnd);
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();
Q_UNUSED(rect)
}
刚才发生了什么?
通过实施这些更改,我们已经有效地将绘制的区域减少到事件接收到的矩形。在这种情况下,我们不会节省太多时间,因为绘制图表并不那么耗时;然而,在许多情况下,你将能够使用这种方法节省大量时间。例如,如果我们绘制一个游戏世界的非常详细的空中地图,如果只有一小部分被修改,重新绘制整个地图将非常昂贵。我们可以通过利用暴露区域的信息轻松减少计算和绘制调用的数量。
利用暴露矩形已经是提高效率的良好一步,但我们还可以更进一步。当前方法要求我们在暴露矩形内重绘图表的每一行,这仍然需要一些时间。相反,我们可以将这些线条只绘制一次到 pixmap 中,然后每当小部件需要重绘时,告诉 Qt 将 pixmap 的一部分渲染到小部件上。
尝试一下英雄 – 在 pixmap 中缓存示波器
现在,你应该很容易为我们的示例小部件实现这种方法。主要区别在于,对绘图内容的每次更改不应导致调用 update(),而应导致调用将重绘 pixmap 并随后调用 update() 的调用。paintEvent 方法因此变得非常简单:
void Widget::paintEvent(QPaintEvent *event)
{
QRect exposedRect = event->rect();
QPainter painter(this);
painter.drawPixmap(exposedRect, m_pixmap, exposedRect);
}
你还需要在部件大小调整时重新绘制位图。这可以在 resizeEvent() 虚拟函数内部完成。
虽然掌握可用的优化方法很有用,但始终重要的是要检查它们是否实际上使你的应用程序更快。通常情况下,直接的方法比巧妙的优化更优。在先前的例子中,调整部件大小(以及随后调整位图大小)可能会触发潜在的昂贵内存分配。只有当直接在部件上绘制更加昂贵时,才使用此优化。
实现象棋游戏
到目前为止,你已经准备好运用你新获得的使用 Qt 绘制图形的技能来创建一个使用具有自定义图形的部件的游戏。今天的英雄将是象棋和其他类似象棋的游戏。
是时候采取行动了——开发游戏架构
创建一个新的 Qt Widgets 应用程序项目。在项目基础设施准备就绪后,从文件菜单中选择新建文件或项目,然后选择创建一个 C++ 类。将新类命名为 ChessBoard,并将 QObject 设置为其基类。重复此过程以创建一个从 QObject 派生的 ChessAlgorithm 类,另一个名为 ChessView,但这次选择 QWidget 作为基类。你应该会得到一个名为 main.cpp 的文件和四个类:
-
MainWindow将是我们的主窗口类,其中包含一个ChessView -
ChessView将是显示我们的棋盘的部件 -
ChessAlgorithm将包含游戏逻辑 -
ChessBoard将保存棋盘的状态,并将其提供给ChessView和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;
}
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 | k |
![]() |
女王 | Q | q |
![]() |
车辆 | R | r |
![]() |
象 | B | b |
![]() |
骑士 | N | n |
![]() |
兵 | P | 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);
// 'M' is the widest letter
int rankSize = fontMetrics().width('M') + 4;
int columnSize = fontMetrics().height() + 4;
return boardSize + QSize(rankSize, columnSize);
}
首先,我们检查是否有有效的棋盘定义,如果没有,则返回一个合理的 100 × 100 像素大小。否则,该方法通过将每个字段的大小乘以列数或等级数来计算所有字段的大小。我们在每个维度上添加一个像素以容纳右侧和底部边框。棋盘不仅由字段本身组成,还在棋盘的左侧边缘显示等级符号,在棋盘的底部边缘显示列号。
由于我们使用字母来枚举等级,我们使用QFontMetrics类检查最宽字母的宽度。我们使用相同的类来检查使用当前字体渲染一行文本所需的空间,以便我们有足够的空间放置列号。在这两种情况下,我们将结果增加 4,以便在文本和棋盘边缘之间以及文本和部件边缘之间留出 2 像素的边距。
实际上,在最常见的字体中,最宽的字母是 W,但它在我们的游戏中不会出现。
定义一个辅助方法来返回包含特定字段的矩形非常有用,如下所示:
QRect ChessView::fieldRect(int column, int rank) const
{
if(!m_board) {
return QRect();
}
const QSize fs = fieldSize();
QPoint topLeft((column - 1) * fs.width(),
(m_board->ranks()-rank) * fs.height());
QRect fRect = QRect(topLeft, fs);
// offset rect by rank symbols
int offset = fontMetrics().width('M');
return fRect.translated(offset+4, 0);
}
由于等级数字从棋盘顶部到底部递减,我们在计算fRect时从最大等级中减去所需的等级。然后,我们像在sizeHint()中做的那样计算等级符号的水*偏移量,并在返回结果之前将矩形*移该偏移量。
最后,我们可以继续实现绘制事件的处理器。声明paintEvent()方法(在Alt + Enter键盘快捷键下可用的修复菜单将允许您生成方法的存根实现)并填充以下代码:
void ChessView::paintEvent(QPaintEvent *)
{
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 对象。然后,我们有三个循环:第一个遍历等级,第二个遍历列,第三个遍历所有字段。每个循环的体非常相似;都有一个调用自定义绘制方法的调用,该方法接受指向画家的指针和等级、列或两者的索引。每个调用都围绕在我们的 QPainter 实例上执行 save() 和 restore()。这里的调用有什么用?三个绘制方法——drawRank()、drawColumn() 和 drawField()——将是负责渲染等级符号、列号和字段背景的虚拟方法。将能够子类化 ChessView 并为这些渲染器提供自定义实现,以便能够提供不同的棋盘外观。由于这些方法都接受画家实例作为其参数,因此这些方法的覆盖可以改变画家背后的属性值。在将画家传递给此类覆盖之前调用 save() 会将状态存储在内部堆栈上,并在覆盖返回后调用 restore() 会将画家重置为 save() 存储的状态。请注意,如果覆盖调用 save() 和 restore() 的次数不同,画家仍然可能处于无效状态。
频繁调用 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 *)
{
// ...
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类内部数据数组中的、适合 char 类型的东西。其次,我们为用最简单的基类实现绘制棋子进行了抽象:从一个注册表中获取图标并将其渲染到字段上。通过使用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, &ChessView::clicked,
this, &MainWindow::viewClicked);
声明槽位并实现它,如下所示:
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声明为MainWindow的私有成员变量。
现在一切应该都正常工作了。然而,如果你构建应用程序,运行它,并在棋盘上开始点击,你会发现没有任何反应。这是因为我们忘记告诉视图在棋盘上的游戏位置改变时刷新自己。我们必须将棋盘发出的信号连接到视图的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();
}
如果你现在运行程序,你做出的移动将在小部件中反映出来,如下所示:

到目前为止,我们可能会认为游戏的视觉部分已经完成,但在测试我们最新的添加时,你可能已经注意到了一个问题。当你点击棋盘时,没有任何视觉提示表明任何棋子实际上已被选中。现在让我们通过引入在棋盘上高亮任何区域的能力来解决这个问题。
为了做到这一点,我们将开发一个用于不同高亮的通用系统。首先,在ChessView中添加一个Highlight类作为内部类:
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 = nullptr;
}
}
注意我们是如何检查一个方格只有在它不为空(也就是说,有一个现有的棋子占据该方格)的情况下才能被选中的。
你还应该添加一个 ChessView::FieldHighlight *m_selectedField 私有成员变量,并在构造函数中将其初始化为空指针。现在你可以构建游戏,执行它,并开始移动棋子:

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

行动时间 – 连接游戏算法
在这里实现完整的国际象棋游戏算法会花费我们太多时间,所以我们将满足于一个更简单的游戏,称为狐狸与猎犬。其中一名玩家有四个兵(猎犬),它们只能移动到黑色方格上,兵只能向前移动(向更高的排数移动)。另一名玩家只有一个兵(狐狸),它从棋盘的另一侧开始:

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

是时候开始工作了!首先,我们将使用所需的接口扩展ChessAlgorithm类:
class ChessAlgorithm : public QObject
{
Q_OBJECT
Q_PROPERTY(Result result READ result)
Q_PROPERTY(Player currentPlayer
READ currentPlayer
NOTIFY currentPlayerChanged)
public:
enum Result { NoResult, Player1Wins, Draw, Player2Wins };
Q_ENUM(Result)
enum Player { NoPlayer, Player1, Player2 };
Q_ENUM(Player)
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_ENUM宏用于在 Qt 的元类型系统中注册枚举,以便它们可以用作属性或信号中的参数值。属性声明和它们的 getter 不需要额外解释。我们还在子类中声明了用于设置变量的受保护方法。以下是它们的建议实现:
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;
}
重载只是一个方便的方法,它接受两个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)棋引擎的ChessAlgorithm子类,例如 StockFish(stockfishchess.org),并为人类玩家提供一个具有挑战性的人工智能对手。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;
};
快速问答
Q1. 你应该使用哪个类来从文件中加载 JPEG 图像并更改其中的一些像素?
-
QImage -
QPixmap -
QIcon
Q2. 哪个函数可以用来安排小部件的重绘?
-
paintEvent() -
update() -
show()
Q3. 哪个函数可以用来改变QPainter绘制的轮廓颜色?
-
setColor() -
setBrush() -
setPen()
概述
在本章中,我们学习了如何使用 Qt Widgets 进行光栅图形。本章所介绍的内容将使您能够实现具有绘图和事件处理的自定义小部件。我们还描述了如何处理图像文件以及在图像上进行一些基本的绘图。本章总结了 Qt 中 CPU 渲染的概述。
在下一章中,我们将从光栅绘图切换到加速矢量图形,并探索与 OpenGL 和 Vulkan 相关的 Qt 功能。
第九章:Qt 应用程序中的 OpenGL 和 Vulkan
对于实现具有高级图形效果的现代游戏来说,硬件加速至关重要。Qt Widgets 模块使用针对基于 CPU 渲染的传统方法进行优化的方法。尽管你可以让任何小部件使用 OpenGL,但性能通常不会最大化。然而,Qt 允许你直接使用 OpenGL 或 Vulkan 来创建高性能的图形,其性能仅受显卡处理能力的限制。在本章中,你将学习如何利用你的 OpenGL 和 Vulkan 技能来显示快速 3D 图形。如果你不熟悉这些技术,本章应该能为你在这个主题上的进一步研究提供一个起点。我们还将描述多个 Qt 辅助类,这些类简化了 OpenGL 纹理、着色器和缓冲区的使用。到本章结束时,你将能够使用 Qt 提供的 OpenGL 和 Vulkan 类创建 2D 和 3D 图形,并将它们与用户界面的其余部分集成。
本章涵盖的主要主题如下:
-
Qt 应用程序中的 OpenGL
-
立即模式
-
纹理
-
着色器
-
OpenGL 缓冲区
-
Qt 应用程序中的 Vulkan
使用 Qt 的 OpenGL 简介
我们不是 OpenGL 的专家,所以在本章的这一部分,我们不会教你如何使用 OpenGL 和 Qt 做任何花哨的事情,而是会向你展示如何在 Qt 应用程序中启用你的 OpenGL 技能。关于 OpenGL 有很多教程和课程,所以如果你对 OpenGL 不是很熟练,你仍然可以通过应用在这里获得的知识来更容易地学习花哨的东西。你可以使用外部材料和 Qt 提供的高级 API,这将加快教程中描述的许多任务的执行。
OpenGL 窗口和上下文
你可以在 Qt 中执行 OpenGL 渲染的许多方法。我们将主要使用的一种最直接的方法是子类化QOpenGLWindow。它允许 OpenGL 直接将你的内容渲染到整个窗口,如果你使用 OpenGL 在应用程序中绘制所有内容,它是非常合适的。如果你想让它成为全屏窗口,也可以做到。然而,稍后我们还将讨论其他方法,这些方法将允许你将 OpenGL 内容集成到基于小部件的应用程序中。
OpenGL 上下文代表 OpenGL 管道整体状态,它指导数据处理和渲染到特定设备的过程。在 Qt 中,它由 QOpenGLContext 类表示。需要解释的另一个相关概念是 OpenGL 上下文在某个线程中“当前”的概念。OpenGL 调用的方式是它们不使用任何包含有关在哪里以及如何执行一系列低级 OpenGL 调用的对象的句柄。相反,它们假定是在当前机器状态的环境中执行的。状态可能决定了是否将场景渲染到屏幕或帧缓冲区对象,启用了哪些机制,或者 OpenGL 正在渲染的表面的属性。使上下文“当前”意味着所有由特定线程发出的进一步 OpenGL 操作都将应用于此上下文。此外,上下文在同一时间只能在一个线程中“当前”;因此,在调用任何 OpenGL 调用之前使上下文“当前”并标记它为可用是很重要的。
QOpenGLWindow 拥有一个非常简单的 API,它隐藏了大部分对开发者不必要的细节。除了构造函数和析构函数之外,它还提供了一些非常实用的方法。首先,有一些辅助方法用于管理 OpenGL 上下文:context(),它返回上下文,以及 makeCurrent() 和 doneCurrent() 用于获取和释放上下文。该类还提供了一些虚拟方法,我们可以重新实现它们来显示 OpenGL 图形。
我们将使用以下三种虚拟方法:
-
initializeGL()会在实际进行任何绘制之前由框架调用一次,这样你就可以准备任何资源或以任何你需要的任何方式初始化上下文。 -
paintGL()对于小部件类来说相当于paintEvent()。每当窗口需要重新绘制时,它都会被执行。这是你应该放置你的 OpenGL 渲染代码的函数。 -
每次窗口大小改变时,都会调用
resizeGL()。它接受窗口的宽度和高度作为参数。你可以通过重新实现该方法来准备自己,以便下一次调用paintGL()时渲染到不同大小的视口。
在调用这些虚拟函数之前,QOpenGLWindow 确保 OpenGL 上下文是当前的,因此在这些函数中不需要手动调用 makeCurrent()。
访问 OpenGL 函数
与 OpenGL 的交互通常是通过调用 OpenGL 库提供的函数来完成的。例如,在一个常规的 C++ OpenGL 应用程序中,您可以看到对 glClearColor() 等 OpenGL 函数的调用。这些函数在您的二进制文件与 OpenGL 库链接时被解析。然而,当您编写跨*台应用程序时,解析所有必需的 OpenGL 函数并非易事。幸运的是,Qt 提供了一种无需担心*台特定细节即可调用 OpenGL 函数的方法。
在 Qt 应用程序中,您应通过 QOpenGLFunctions 类族访问 OpenGL 函数。QOpenGLFunctions 类本身仅提供对 OpenGL ES 2.0 API 中部分函数的访问。这个子集预计将在 Qt 支持的最多桌面和嵌入式*台上工作(在这些*台上 OpenGL 都是可用的)。然而,这是一个非常有限的函数集,有时您可能愿意以支持较少*台为代价使用更新的 OpenGL 版本。对于每个已知的 OpenGL 版本和配置文件,Qt 提供了一个包含可用函数集的单独类。例如,QOpenGLFunctions_3_3_Core 类将包含 OpenGL 3.3 核心配置文件提供的所有函数。
Qt 推荐的方法是选择与您想要使用的版本相对应的 OpenGL 函数类,并将其添加到您的窗口或小部件的第二个基类中。这将使该版本的 OpenGL 函数在您的类中可用。这种方法允许您使用直接使用 OpenGL 库的代码,而无需对其进行更改。当您将此类代码放入您的类中时,编译器将使用 QOpenGLFunctions::glClearColor() 函数而不是 OpenGL 库提供的全局 glClearColor() 函数。
然而,在使用这种方法时,您必须小心,只使用您基类提供的函数。如果您选择的 Qt 类不包含它,您可能会意外地使用全局函数而不是 Qt 类提供的函数。例如,如果您使用 QOpenGLFunctions 作为基类,您就不能使用 glBegin() 函数,因为这个函数不是由这个 Qt 类提供的。这样的错误代码可能在某个操作系统上工作,然后突然在另一个操作系统上无法编译,因为您没有链接到 OpenGL 库。只要您只使用 Qt 类提供的 OpenGL 函数,您就无需考虑与 OpenGL 库的链接或跨*台方式解决函数。
如果您想确保只使用 Qt OpenGL 函数包装器,您可以将 Qt 类用作私有字段而不是基类。在这种情况下,您必须通过私有字段访问每个 OpenGL 函数,例如,m_openGLFunctions->glClearColor()。这将使您的代码更加冗长,但至少您可以确信不会意外地使用全局函数。
在使用 Qt OpenGL 函数之前,您必须调用当前 OpenGL 上下文中函数类的 initializeOpenGLFunctions() 方法。这通常在窗口的 initializeGL() 函数中完成。期望 QOpenGLFunctions 类始终初始化成功,因此其 initializeOpenGLFunctions() 方法不返回任何内容。在其他所有函数类的所有函数中,此函数返回 bool。如果它返回 false,则表示 Qt 无法成功解析所有必需的函数,并且您的应用程序应带错误消息退出。
在我们的示例中,我们将使用包含我们将使用所有 OpenGL 函数的 QOpenGLFunctions_1_1 类。当您创建自己的项目时,考虑您想要针对的 OpenGL 配置文件并选择适当的函数类。
使用立即模式的 OpenGL
我们将从最基本的方法开始,称为 立即模式。在这种模式下,不需要额外的 OpenGL 缓冲区或着色器设置。您只需提供一些几何原语即可立即获得结果。立即模式现在已被弃用,因为它比更高级的技术慢得多,并且灵活性较低。然而,它比它们容易得多,因此基本上每个 OpenGL 教程都是从描述立即模式调用开始的。在本节中,我们将展示如何用很少的代码执行一些简单的 OpenGL 绘图。更现代的方法将在本章下一节中介绍。
行动时间 - 使用 Qt 和 OpenGL 绘制三角形
在第一个练习中,我们将创建一个 QOpenGLWindow 的子类,使用简单的 OpenGL 调用来渲染一个三角形。创建一个新的项目,从“其他项目”组中的“空 qmake 项目”模板开始。在项目文件中,添加以下内容:
QT = core gui
TARGET = triangle
TEMPLATE = app
注意,我们的项目不包括 Qt Widgets 模块。使用 QOpenGLWindow 方法允许我们删除这个不必要的依赖,并使我们的应用程序更轻量级。
注意,Qt Core 和 Qt GUI 模块默认启用,因此您不需要将它们添加到 QT 变量中,但我们更喜欢明确地显示我们在项目中使用它们。
在基本项目设置就绪后,让我们定义一个 SimpleGLWindow 类,作为 QOpenGLWindow 和 QOpenGLFunctions_1_1 的子类。由于我们不希望允许外部访问 OpenGL 函数,我们使用保护继承来作为第二个基类。接下来,我们重写 QOpenGLWindow 的虚拟 initializeGL() 方法。在这个方法中,我们初始化我们的 QOpenGLFunctions_1_1 基类,并使用它提供的 glClearColor() 函数:
class SimpleGLWindow : public QOpenGLWindow,
protected QOpenGLFunctions_1_1
{
public:
SimpleGLWindow(QWindow *parent = 0) :
QOpenGLWindow(NoPartialUpdate, parent) {
}
protected:
void initializeGL() {
if (!initializeOpenGLFunctions()) {
qFatal("initializeOpenGLFunctions failed");
}
glClearColor(1, 1, 1, 0);
}
};
在initializeGL()函数中,我们首先调用initializeOpenGLFunctions(),这是QOpenGLFunctions_1_1类的一个方法,它是我们窗口类的基础类之一。该方法负责根据当前 OpenGL 上下文的参数设置所有函数(因此,首先使上下文成为当前上下文是很重要的,幸运的是,在调用initializeGL()之前,这已经在幕后为我们完成了)。如果此函数失败,我们使用qFatal()宏将错误信息打印到stderr并终止应用程序。然后,我们使用QOpenGLFunctions_1_1::glClearColor()函数将场景的清除颜色设置为白色。
下一步是重新实现paintGL()函数,并将实际的绘图代码放在那里:
void SimpleGLWindow::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();
}
此函数首先清除颜色缓冲区,并将上下文的 OpenGL 视口设置为窗口的大小。然后,我们通过调用glBegin()并传递GL_TRIANGLES作为绘图模式来告诉 OpenGL 开始使用三角形进行绘制。然后,我们传递三个顶点及其颜色来形成一个三角形。最后,通过调用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();
}
这个函数与我们通常在main()函数中看到的内容非常相似,但我们使用QGuiApplication而不是QApplication,因为我们只使用 Qt GUI 模块。运行项目后,你应该看到以下内容:

多重采样
你可以看到三角形有锯齿状的边缘。这是因为走样效应。你可以通过为窗口启用多重采样来对抗它,这将使 OpenGL 渲染的内容看起来像屏幕有更高的分辨率,然后*均结果,这起到抗走样的作用。为此,将以下代码添加到窗口的构造函数中:
QSurfaceFormat fmt = format();
fmt.setSamples(16); // multisampling set to 16
setFormat(fmt);
注意,多重采样是资源密集型的,所以设置一个很高的样本数可能会导致你的应用程序失败,如果你的硬件或驱动程序无法处理它。如果启用多重采样后应用程序不起作用,请尝试降低样本数或直接禁用它。
行动时间 - 基于场景的渲染
让我们把我们的渲染代码提升到一个更高的层次。直接将 OpenGL 代码放入窗口类需要子类化窗口类,并使窗口类变得越来越复杂。让我们遵循良好的编程实践,将渲染代码与窗口代码分离。
创建一个新的类,命名为AbstractGLScene。它将是以下类的基类:
OpenGL 场景的定义。我们还从QOpenGLFunctions_1_1派生类(具有保护作用域),以便更容易访问不同的 OpenGL 函数。确保场景类接受一个指向QOpenGLWindow的指针,无论是在构造函数中还是在专门的设置方法中。确保将指针存储在类中,以便更容易访问,因为我们将会依赖这个指针来访问窗口的物理属性。添加查询窗口 OpenGL 上下文的方法。您最终应该得到类似以下代码的代码:
class AbstractGLScene : protected QOpenGLFunctions_1_1 {
public:
AbstractGLScene(QOpenGLWindow *window = nullptr) {
m_window = window;
}
QOpenGLWindow* window() const { return m_window; }
QOpenGLContext* context() {
return m_window ? m_window->context() : nullptr;
}
const QOpenGLContext* context() const {
return m_window ? m_window->context() : nullptr;
}
private:
QOpenGLWindow *m_window = nullptr;
};
现在最重要的部分开始了。添加两个纯虚方法,分别称为paint()和initialize()。同时,记得添加一个虚析构函数。
您不必将initialize()实现为纯虚函数,您可以以这种方式实现其主体,使其调用initializeOpenGLFunctions()以满足QOpenGFunctions类的要求。然后,AbstractGLScene的子类可以通过调用基类的initialize()实现来确保函数被正确初始化。
接下来,创建一个QOpenGLWindow的子类,并将其命名为SceneGLWindow。添加一个AbstractGLScene *m_scene私有字段,并为其实现一个获取器和设置器。使用以下代码创建一个构造函数:
SceneGLWindow::SceneGLWindow(QWindow *parent) :
QOpenGLWindow(NoPartialUpdate, parent)
{
}
此构造函数将父参数传递给基构造函数,并将NoPartialUpdate作为窗口的UpdateBehavior。此选项意味着窗口将在每次paintGL()调用时完全重绘,因此不需要帧缓冲区。这是第一个参数的默认值,但因为我们提供了第二个参数,所以我们有义务明确提供第一个参数。
然后,重新实现initializeGL()和paintGL()方法,并使它们调用场景中的适当等效方法:
void SceneGLWindow::initializeGL() {
if(m_scene) {
m_scene->initialize();
}
}
void SceneGLWindow::paintGL() {
if(m_scene) {
m_scene->paint();
}
}
最后,在main()函数中实例化SceneGLWindow。
刚才发生了什么?
我们刚刚设置了一个类链,将窗口代码与实际的 OpenGL 场景分离。窗口将所有与场景内容相关的调用转发给场景对象,以便当窗口被要求重绘自身时,它将任务委托给场景对象。请注意,在此之前,窗口将使 OpenGL 上下文成为当前上下文;因此,场景所做的所有 OpenGL 调用都将与此上下文相关。您可以将在此练习中创建的代码存储起来,以便在后续练习和自己的项目中重复使用。
行动时间 - 绘制纹理立方体
创建一个名为CubeGLScene的新类,并从AbstractGLScene派生。实现构造函数以将其参数传递给基类构造函数。添加一个方法来存储场景中的QImage对象,该对象将包含立方体的纹理数据。同时添加一个QOpenGLTexture指针成员,它将包含纹理,在构造函数中将它初始化为nullptr,并在析构函数中删除它。我们将称这个m_textureImage图像对象为m_texture纹理。现在添加一个受保护的initializeTexture()方法,并用以下代码填充它:
void CubeGLScene::initializeTexture() {
m_texture = new QOpenGLTexture(m_textureImage.mirrored());
m_texture->setMinificationFilter(QOpenGLTexture::LinearMipMapLinear);
m_texture->setMagnificationFilter(QOpenGLTexture::Linear);
}
函数首先垂直翻转图像。这是因为 OpenGL 中默认的y轴向上,所以纹理将显示为“颠倒”。然后,我们创建一个QOpenGLTexture对象,并将我们的图像传递给它。之后,我们设置缩小和放大过滤器,以便纹理在缩放时看起来更好。
我们现在准备实现initialize()方法,该方法将负责设置纹理和场景本身:
void CubeGLScene::initialize() {
AbstractGLScene::initialize();
m_initialized = true;
if(!m_textureImage.isNull()) {
initializeTexture();
}
glClearColor(1, 1, 1, 0);
glShadeModel(GL_SMOOTH);
}
我们使用一个名为m_initialized的标志。这个标志是必要的,以防止纹理设置得太早(当还没有 OpenGL 上下文时)。然后,我们检查纹理图像是否已设置(使用QImage::isNull()方法);如果是,则初始化纹理。然后,我们设置 OpenGL 上下文的某些附加属性。
在m_textureImage的设置器中添加代码,检查m_initialized是否设置为true,如果是,则调用initializeTexture()。这是为了确保无论设置器和initialize()调用的顺序如何,纹理都得到正确设置。同时,请记住在构造函数中将m_initialized设置为false。
下一步是准备立方体数据。我们将为立方体定义一个特殊的数据结构,该结构将顶点坐标和纹理数据组合在一个对象中。为了存储坐标,我们将使用专门为此目的定制的类——QVector3D和QVector2D:
struct TexturedPoint {
QVector3D coord;
QVector2D uv;
TexturedPoint(const QVector3D& pcoord = QVector3D(),
const QVector2D& puv = QVector2D()) :
coord(pcoord), uv(puv) {
}
};
QVector2D、QVector3D和QVector4D是表示空间中单个点的辅助类,并提供了一些方便的方法。例如,QVector2D存储两个float变量(x和y),就像QPointF类一样。这些类不应与QVector<T>容器模板类混淆,后者存储元素集合。
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}},
//...
};
}
代码使用 C++11 初始化列表语法设置向量的数据。立方体由六个面组成,且以坐标系的原点为中心。以下图表以图形形式展示了相同的数据:

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(const TexturedPoint &point: m_data) {
glTexCoord2d(point.uv.x(), point.uv.y());
glVertex3f(point.coord.x(), point.coord.y(), point.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 animation(&scene, "angle");
animation.setStartValue(0);
animation.setEndValue(359);
animation.setDuration(5000);
animation.setLoopCount(-1);
animation.start();
请记住,在角度属性的设置器中调用 window()->update(),以便重新绘制场景。
带有 Qt 的现代 OpenGL
上一节中显示的 OpenGL 代码使用了一种非常古老的逐个将顶点流式传输到固定 OpenGL 管道的技巧。如今,现代硬件功能更加丰富,不仅允许更快地处理顶点数据,而且还提供了使用可重编程单元(称为着色器)调整不同处理阶段的能力。在本节中,我们将探讨 Qt 在使用 OpenGL 的“现代”方法领域所能提供的内容。
着色器
Qt 可以通过围绕 QOpenGLShaderProgram 类的一系列类来使用着色器。这个类允许编译、链接和执行用 GLSL 编写的着色器程序。您可以通过检查静态 QOpenGLShaderProgram::hasOpenGLShaderPrograms() 调用的结果来检查您的 OpenGL 实现是否支持着色器,该调用接受一个 OpenGL 上下文的指针。所有现代硬件和所有不错的图形驱动程序都应该有一些对着色器的支持。
Qt 支持所有类型的着色器,其中最常见的是顶点着色器和片段着色器。这些都是经典 OpenGL 管道的一部分。您可以在以下图中看到管道的示意图:

单个着色器由 QOpenGLShader 类的实例表示。您需要在类的构造函数中指定着色器的类型。然后,您可以通过调用 QOpenGLShader::compileSourceCode() 编译着色器的源代码,该函数有多个重载用于处理不同的输入格式,或者调用 QOpenGLShader::compileSourceFile()。QOpenGLShader 对象存储了编译后着色器的 ID,以供将来使用。
当你定义了一组着色器后,你可以使用 QOpenGLShaderProgram::addShader() 组装一个完整的程序。在所有着色器都添加完毕后,你可以调用 link() 将程序链接,并使用 bind() 将其绑定到当前的 OpenGL 上下文。程序类提供了一系列方法来设置不同输入参数的值——包括单值和数组版本的 uniforms 和 attributes。Qt 提供了它自己的类型(如QSize或QColor)与 GLSL 对应类型(例如vec2和vec4)之间的映射,以使程序员的开发工作更加轻松。
使用着色器进行渲染的典型代码流程如下(首先创建并编译一个顶点着色器):
QOpenGLShader vertexShader(QOpenGLShader::Vertex);
vertexShader.compileSourceCode(
"uniform vec4 color;\n"
"uniform highp mat4 matrix;\n"
"void main(void) { gl_Position = gl_Vertex * matrix; }"
);
对于片段着色器,过程是重复的:
QOpenGLShader fragmentShader(QOpenGLShader::Fragment);
fragmentShader.compileSourceCode(
"uniform vec4 color;\n"
"void main(void) { gl_FragColor = color; }"
);
然后,着色器在给定的 OpenGL 上下文中被链接成一个单独的程序:
QOpenGLShaderProgram program(context);
program.addShader(&vertexShader);
program.addShader(&fragmentShader);
program.link();
当着色器被链接在一起时,OpenGL 会在它们中搜索公共变量(如 uniforms 或 buffers)并将它们映射在一起。这使得你可以,例如,从顶点着色器传递一个值到片段着色器。在幕后,link()函数使用了glLinkProgram() OpenGL 调用。
每次使用程序时,都应该将其绑定到当前的 OpenGL 上下文,并填充所需的数据:
program.bind();
QMatrix4x4 matrix = /* ... */;
QColor color = Qt::red;
program.setUniformValue("matrix", matrix);
program.setUniformValue("color", color);
之后,激活渲染管道的调用将使用绑定的程序:
glBegin(GL_TRIANGLE_STRIP);
//...
glEnd();
行动时间 - 着色物体
让我们将最后一个程序转换为使用着色器。为了使立方体更好,我们将实现一个使用 Phong 算法的*滑光照模型。同时,我们将学习如何使用 Qt 为 OpenGL 提供的辅助类。
这个迷你项目的目标如下:
-
使用顶点和片段着色器渲染复杂物体
-
处理模型、视图和投影矩阵
-
使用属性数组进行更快的绘制
首先创建一个新的AbstractGLScene子类。让我们给它以下接口:
class ShaderGLScene : public QObject, public AbstractGLScene {
Q_OBJECT
public:
ShaderGLScene(SceneGLWindow *window);
void initialize();
void paint();
protected:
void initializeObjectData();
private:
struct ScenePoint {
QVector3D coords;
QVector3D normal;
ScenePoint(const QVector3D &c = QVector3D(),
const QVector3D &n = QVector3D()) :
coords(c), normal(n)
{
}
};
QOpenGLShaderProgram m_shader;
QMatrix4x4 m_modelMatrix;
QMatrix4x4 m_viewMatrix;
QMatrix4x4 m_projectionMatrix;
QVector<ScenePoint> m_data;
};
在本项目我们未使用纹理,因此TexturedPoint被简化为ScenePoint,并移除了 UV 纹理坐标。更新main()函数以使用ShaderGLScene类。
我们可以从initializeObjectData()函数开始实现接口,该函数将在构造函数中被调用。这个函数必须用关于顶点和它们法线的信息填充m_data成员。实现将取决于你的数据来源。
在本书附带示例代码中,你可以找到使用 Blender 3D 程序生成的 PLY 格式文件加载数据的代码。要从 Blender 导出模型,确保它只由三角形组成(为此,选择模型,按Tab进入编辑模式,使用Ctrl + F打开面菜单,并选择三角化面)。然后,点击文件并导出;选择斯坦福(.ply)。你将得到一个包含顶点和法线数据以及顶点面定义的文本文件。我们将 PLY 文件添加到项目的资源中,以便它始终可供我们的程序使用。然后,我们使用实现解析的PlyReaderC++类。
你可以始终重用前一个项目中使用的立方体对象。只需注意,它的法线没有正确计算以进行*滑着色;因此,你必须纠正它们。
在我们可以设置着色器程序之前,我们必须了解实际的着色器看起来是什么样子。着色器代码将从外部文件加载,因此第一步是向项目中添加一个新文件。在 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) {
vec3 n = normalize(N);
vec3 L = normalize(light.position.xyz - v);
vec3 E = normalize(-v);
vec3 R = normalize(reflect(-L, n));
float LdotN = dot(L, n);
float diffuse = max(LdotN, 0.0);
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.addShaderFromSourceFile(QOpenGLShader::Vertex, ":/phong.vert");
m_shader.addShaderFromSourceFile(QOpenGLShader::Fragment, ":/phong.frag");
m_shader.link();
link() 函数返回一个布尔值,但为了简单起见,我们这里跳过了错误检查。下一步是准备所有着色器的输入数据,如下所示:
m_shader.bind();
m_shader.setAttributeArray("Vertex", GL_FLOAT,
&m_data[0].coords, 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("mat.ka", QVector3D(0.1, 0, 0.0));
m_shader.setUniformValue("mat.kd", QVector3D(0.7, 0.0, 0.0));
m_shader.setUniformValue("mat.ks", QVector3D(1.0, 1.0, 1.0));
m_shader.setUniformValue("mat.shininess", 128.0f);
m_shader.setUniformValue("light.position", QVector3D(2, 1, 1));
m_shader.setUniformValue("light.intensity", QVector3D(1, 1, 1));
首先,我们将着色器程序绑定到当前上下文,这样我们就可以对其操作。然后,我们启用两个属性数组的设置——一个用于顶点坐标,另一个用于它们的法线。在我们的程序中,数据存储在 QVector<ScenePoint> 中,其中每个 ScenePoint 有 coords 和法线 fields 字段,因此没有单独的 C++ 数组用于坐标和法线。幸运的是,OpenGL 足够智能,可以直接使用我们的内存布局。我们只需要将我们的向量映射到两个属性数组。
我们通知程序一个名为 Vertex 的属性是一个数组。该数组的每个元素由三个 GL_FLOAT 类型的值组成。第一个数组元素位于 &m_data[0].coords,下一个顶点的数据位于当前点数据之后 sizeof(ScenePoint) 字节的位置。然后我们对 Normal 属性有类似的声明,唯一的区别是第一条数据存储在 &m_data[0].normal。通过通知程序数据布局,我们允许它在需要时快速读取所有顶点信息。
在设置属性数组之后,我们将均匀变量的值传递给着色器程序,这标志着着色器程序设置的完成。您会注意到我们没有设置表示各种矩阵的均匀变量的值;我们将为每次重绘分别设置这些值。paint() 方法负责这一点:
void ShaderGLScene::paint() {
m_projectionMatrix.setToIdentity();
float aspectRatio = qreal(window()->width()) / window()->height();
m_projectionMatrix.perspective(90, aspectRatio, 0.5, 40);
m_viewMatrix.setToIdentity();
QVector3D eye(0, 0, 2);
QVector3D center(0, 0, 0);
QVector3D up(0, 1, 0);
m_viewMatrix.lookAt(eye, center, up);
//...
}
在这个方法中,我们大量使用了表示 4×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 ShaderGLScene::paintObject(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());
glDrawArrays(GL_TRIANGLES, 0, m_data.size());
}
这种方法非常简单,因为大部分工作都是在设置着色器程序时完成的。首先,激活着色器程序,然后将所有所需的矩阵设置为着色器的统一变量。其中包括从模型视图矩阵计算出的法线矩阵。最后,发出对glDrawArrays()的调用,告诉它使用GL_TRIANGLES模式通过活动数组进行渲染,从数组的开始(偏移0)读取m_data.size()个实体。
运行项目后,你应该得到一个类似于以下的结果,它恰好包含了 Blender 猴子,Suzanne:

GL 缓冲区
使用属性数组可以加快编程速度,但为了渲染,每次使用时仍然需要将所有数据复制到图形卡上。这可以通过 OpenGL 缓冲区对象避免。Qt 通过其QOpenGLBuffer类提供了一个方便的接口。目前支持的缓冲区类型包括顶点缓冲区(其中缓冲区包含顶点信息)、索引缓冲区(其中缓冲区的内容是一组索引,可以与glDrawElements()一起使用),以及较少使用的像素打包缓冲区和像素解包缓冲区。缓冲区本质上是一块内存,可以上传到图形卡并存储在那里以实现更快的访问。有不同使用模式可供选择,这些模式决定了缓冲区如何在主机内存和 GPU 内存之间传输以及何时传输。最常见模式是一次性将顶点信息上传到 GPU,之后在渲染过程中可以多次引用。将使用属性数组的现有应用程序更改为使用顶点缓冲区非常简单。首先,需要实例化一个缓冲区:
ShaderGLScene::ShaderGLScene(SceneGLWindow *window) :
AbstractGLScene(window), m_vertexBuffer(QOpenGLBuffer::VertexBuffer)
{ /* ... */ }
然后,需要设置其使用模式。在一次性上传的情况下,最合适的类型是StaticDraw:
m_vertexBuffer.setUsagePattern(QOpenGLBuffer::StaticDraw);
然后,必须创建并绑定缓冲区到当前上下文(例如,在initializeGL()函数中):
m_vertexBuffer.create();
m_vertexBuffer.bind();
下一步是实际为缓冲区分配一些内存并初始化它:
m_vertexBuffer.allocate(m_data.constData(),
m_data.count() * sizeof(ScenePoint));
要更改缓冲区中的数据,有两种选择。首先,你可以通过调用map()将缓冲区附加到应用程序的内存空间,然后使用返回的指针填充数据:
ScenePoint *buffer = static_cast<ScenePoint*>(
vbo.map(QOpenGLBuffer::WriteOnly));
assert(buffer != nullptr);
for(int i = 0; i < vbo.size(); ++i) {
buffer[i] = ...;
}
vbo.unmap();
另一种方法是直接使用write()写入缓冲区:
vbo.write(0, m_data.constData(), m_data.size() * sizeof(ScenePoint));
最后,缓冲区可以以类似于属性数组的方式在着色器程序中使用:
vbo.bind();
m_shader.setAttributeBuffer("Vertex", GL_FLOAT,
0, 3, sizeof(ScenePoint));
m_shader.enableAttributeArray("Vertex");
m_shader.setAttributeBuffer("Normal", GL_FLOAT,
sizeof(QVector3D), 3, sizeof(ScenePoint));
m_shader.enableAttributeArray("Normal");
结果是所有数据一次性上传到 GPU,然后根据当前着色器程序或其他 OpenGL 调用支持的缓冲区对象的需求使用。
使用多个 OpenGL 版本
在本章的早期部分,我们讨论了一组 QOpenGLFunctions 类,这些类提供了访问特定 OpenGL 配置中包含的 OpenGL 函数的方法。如果你的整个应用程序可以使用一个配置,你只需选择合适的 Qt 类并使用它即可。然而,有时你不想在当前系统不支持请求的配置时完全关闭应用程序。相反,你可以放宽要求,使用较旧的 OpenGL 版本,并为不支持新配置的系统提供简化但仍可工作的渲染。在 Qt 中,你可以使用 QOpenGLContext::versionFunctions() 来实现这种方法:
class MyWindow : public QOpenGLWindow {
protected:
QOpenGLFunctions_4_5_Core *glFunctions45;
QOpenGLFunctions_3_3_Core *glFunctions33;
void initializeGL()
{
glFunctions33 = context()->versionFunctions<QOpenGLFunctions_3_3_Core>();
glFunctions45 = context()->versionFunctions<QOpenGLFunctions_4_5_Core>();
}
void paintGL() {
if (glFunctions45) {
// OpenGL 4.5 rendering
// glFunctions45->...
} else if (glFunctions33) {
// OpenGL 3.3 rendering
// glFunctions33->...
} else {
qFatal("unsupported OpenGL version");
}
}
};
在 initializeGL() 函数中,我们尝试请求多个 OpenGL 版本的包装对象。如果请求的版本当前不可用,versionFunctions() 将返回 nullptr。在 paintGL() 函数中,我们使用最佳可用版本进行实际渲染。
接下来,你可以使用 QSurfaceFormat 类来指定你想要使用的 OpenGL 版本和配置:
MyWindow window;
QSurfaceFormat format = window.format();
format.setVersion(4, 0);
format.setProfile(QSurfaceFormat::CoreProfile);
window.setFormat(format);
window.show();
通过请求核心配置,你可以确保旧的和已弃用的功能不会在我们的应用程序中可用。
离屏渲染
有时,将 OpenGL 场景渲染到屏幕上而不是某些图像是有用的,这些图像可以稍后在外部处理或用作渲染其他部分的纹理。为此,创建了 帧缓冲对象(FBO)的概念。FBO 是一个渲染表面,其行为类似于常规设备帧缓冲区,唯一的区别是生成的像素不会出现在屏幕上。FBO 目标可以作为纹理绑定到现有场景中,或作为图像输出到常规计算机内存中。在 Qt 中,这样的实体由 QOpenGLFramebufferObject 类表示。
一旦你有一个当前的 OpenGL 上下文,你可以使用其中一个可用的构造函数创建 QOpenGLFramebufferObject 的实例。必须传递的强制参数是画布的大小(可以是 QSize 对象,也可以是一对整数,描述帧的宽度和高度)。不同的构造函数接受其他参数,例如 FBO 将生成的纹理类型或封装在 QOpenGLFramebufferObjectFormat 中的参数集。
当对象被创建时,你可以在它上面发出一个 bind() 调用,这将切换 OpenGL 管道以渲染到 FBO 而不是默认目标。一个互补的方法是 release(),它将恢复默认渲染目标。之后,可以查询 FBO 以返回 OpenGL 纹理的 ID(使用 texture() 方法)或将纹理转换为 QImage(通过调用 toImage())。
在 Qt 应用程序中使用 Vulkan
随着图形卡硬件的发展,OpenGL 已经经历了重大变化。OpenGL API 的许多旧部分现在已被弃用,即使是更新的 API 也不太适合利用现代硬件的能力。Vulkan 的设计初衷是尝试创建一个更适合此目的的 API。
Vulkan 是一种新的 API,可以用作 OpenGL 的替代品,以执行硬件加速的渲染和计算。虽然 Vulkan 比 OpenGL 更冗长和复杂,但它更接* CPU 和 GPU 之间的实际交互。这使得 Vulkan 用户能够更好地控制 GPU 资源的利用,从而可能带来更好的性能。Vulkan API 的第一个稳定版本于 2016 年发布。
虽然 Vulkan 是一个跨*台解决方案,但 Vulkan 应用程序仍然需要包含一些*台特定的代码,主要与窗口创建和事件处理相关。自 5.10 版本以来,Qt 提供了一种方法,可以在保留对原始 Vulkan API 渲染的完全访问权限的同时,使用 Qt 的现有窗口和事件基础设施来使用 Vulkan。您仍然可以完全访问原始 Vulkan API 进行渲染,同时,您还可以使用已经熟悉的 Qt API 来处理其他所有事情。
与 OpenGL 类似,这里我们不会深入讲解 Vulkan。我们只会提供一些简单示例,并涵盖 Qt 和 Vulkan 之间的交互。如果您需要更多关于 Vulkan 的信息,可以参考其官方页面 www.khronos.org/vulkan/。
准备开发环境
在您开始使用 Vulkan 和 Qt 开发游戏之前,您需要做一些准备工作。首先,您需要安装 Vulkan SDK。为此,请访问 www.lunarg.com/vulkan-sdk/,下载适用于您操作系统的文件,并执行或解压它。检查安装文件夹中的 doc 子目录下的 index.html 文件,以查看是否需要执行任何额外操作。
接下来,您需要一个支持 Vulkan 的 Qt 构建;它必须是 Qt 5.10 或更高版本。如果您通过安装程序安装了最新版本,它可能已经适合使用。
要检查您的 Qt 版本是否支持 Vulkan,创建一个新的 Qt 控制台应用程序,确保您选择了对应于最新安装的 Qt 版本的工具包。Vulkan SDK 还要求您设置一些环境变量,例如 VULKAN_SDK、PATH、LD_LIBRARY_PATH 和 VK_LAYER_PATH(确切名称和值可能取决于操作系统,因此请参阅 SDK 文档)。您可以通过切换到 Qt Creator 的“项目”面板并展开“构建环境”部分来编辑项目环境变量。
将以下代码放入 main.cpp 文件中:
#include <QGuiApplication>
#include <vulkan/vulkan.h>
#include <QVulkanInstance>
int main(int argc, char *argv[]) {
QGuiApplication app(argc, argv);
QVulkanInstance vulkan;
return app.exec();
}
此外,调整项目文件,以便我们实际上有一个 Qt GUI 应用程序而不是控制台应用程序:
QT += gui
CONFIG += c++11
DEFINES += QT_DEPRECATED_WARNINGS
SOURCES += main.cpp
如果项目构建成功,则您的设置已完成。
如果编译器找不到 vulkan/vulkan.h 头文件,那么 Vulkan SDK 没有正确安装,或者其头文件不在默认的包含路径中。请检查 Vulkan SDK 文档,看是否遗漏了某些内容。您还可以切换到 Qt Creator 的“项目”面板,并编辑项目的构建环境,使已安装的头文件可见。根据编译器的不同,您可能需要设置 INCLUDEPATH 或 CPATH 环境变量。
如果您遇到了与 QVulkanInstance 头文件对应的编译错误,您正在使用 5.10 版本之前的 Qt 版本。请确保安装一个较新版本,并在 Qt Creator 的“项目”面板上选择正确的工具包。
然而,如果 QVulkanInstance 包含指令工作,但 QVulkanInstance 类仍然未定义,这意味着您的 Qt 构建缺少 Vulkan 支持。在这种情况下,首先尝试使用官方安装程序安装最新版本,如果您还没有这样做的话:
-
关闭 Qt Creator
-
从 Qt 安装目录启动 维护工具 可执行文件
-
选择“添加或删除组件”
-
选择最新的桌面 Qt 版本
-
确认更改
安装完成后,重新打开 Qt Creator,切换到“项目”面板,并选择项目的新工具包。
不幸的是,在撰写本文时,通过官方安装程序提供的 Qt 构建版本没有 Vulkan 支持。未来版本可能(并且很可能)会启用。
如果 QVulkanInstance 类仍然未被识别,您必须从源代码构建 Qt。这个过程取决于操作系统和 Qt 版本,因此我们不会在书中详细说明。请访问 doc.qt.io/qt-5/build-sources.html 页面,并遵循您操作系统的说明。如果 Vulkan SDK 已正确安装,configure 命令的输出应包含 Vulkan ... yes,表示已启用 Vulkan 支持。在构建 Qt 后,打开 Qt Creator 的选项对话框,并设置 Qt 版本和工具包,如 第二章 中所述的“安装”部分。
最后,在“项目”面板上选择项目的新工具包:

如果您已经正确完成所有操作,项目现在应该能够成功构建和执行。
Vulkan 实例、窗口和渲染器
在我们开始创建第一个最小 Vulkan 应用程序之前,让我们熟悉我们将需要的 Qt 类。
与 OpenGL 不同,Vulkan 没有全局状态。与 Vulkan 的交互从由VkInstance类型表示的实例对象开始。应用程序通常创建一个包含应用程序范围状态的单个VkInstance对象。所有其他 Vulkan 对象都只能从实例对象创建。在 Qt 中,相应的类是QVulkanInstance。这个类提供了一种方便的方式来配置 Vulkan,然后使用给定的配置初始化它。你还可以使用它的supportedExtensions()和supportedLayers()函数在使用之前查询支持的功能。配置完成后,你应该调用create()函数,该函数实际上触发加载 Vulkan 库并创建一个VkInstance对象。如果这个函数返回true,则 Vulkan 实例对象已准备好使用。
下一步是创建一个能够进行 Vulkan 渲染的窗口。这是通过派生QVulkanWindow类来完成的。类似于QOpenGLWindow,QVulkanWindow扩展了QWindow,并提供了利用 Vulkan 功能以及一些便利函数的功能。你还可以使用从QWindow继承的虚函数来处理 Qt 事件系统分发的任何事件。然而,QVulkanWindow的子类不应执行任何实际的渲染。这项任务委托给了QVulkanWindowRenderer类。QVulkanWindow::createRenderer()虚函数将在窗口首次显示后立即被调用,你应该重写这个函数以返回你的渲染器对象。
现在,关于渲染器本身:QVulkanWindowRenderer是一个简单的类,除了包含一组虚函数之外,没有其他内容。你可以通过派生QVulkanWindowRenderer并重写唯一的纯虚函数startNextFrame()来创建自己的渲染器。这个函数将在请求绘制下一帧时被调用。你可以在该函数中执行所有必要的绘图操作,并在调用QVulkanWindow::frameReady()以指示绘图完成时结束。你还可以重写渲染器的其他虚函数。其中最有用的是initResources()和releaseResources(),它们允许你创建所需资源,将它们存储在渲染器类的私有成员中,并在必要时销毁它们。
这三个类定义了你的 Vulkan 应用程序的基本结构。让我们看看它们是如何工作的。
行动时间 - 创建最小化的 Vulkan 项目
在测试开发环境时,我们已经创建了一个项目。现在让我们向项目中添加两个新的类。一个名为MyWindow的类应该从QVulkanWindow派生,另一个名为MyRenderer的类应该从QVulkanWindowRenderer派生。实现窗口的createRenderer()虚函数:
QVulkanWindowRenderer *MyWindow::createRenderer() {
return new MyRenderer(this);
}
将QVulkanWindow *m_window私有字段添加到渲染器类中。实现构造函数以初始化此字段,并重写startNextFrame()虚函数,如下所示:
MyRenderer::MyRenderer(QVulkanWindow *window)
{
m_window = window;
}
void MyRenderer::startNextFrame() {
m_window->frameReady();
}
最后,编辑main()函数:
int main(int argc, char *argv[]) {
QGuiApplication app(argc, argv);
QVulkanInstance vulkan;
if (!vulkan.create()) {
qFatal("Failed to create Vulkan instance: %d", vulkan.errorCode());
}
MyWindow window;
window.resize(1024, 768);
window.setVulkanInstance(&vulkan);
window.show();
return app.exec();
}
当你编译并运行项目时,应该出现一个带有黑色背景的空白窗口。
刚才发生了什么?
我们已创建一个将使用 Vulkan 进行渲染的窗口。main()函数初始化 Vulkan,创建一个窗口,将实例对象传递给窗口,并在屏幕上显示它。像往常一样,对exec()的最终调用启动 Qt 的事件循环。当窗口显示时,Qt 将在窗口上调用createRenderer()函数,并在你的函数实现中创建一个新的渲染器对象。渲染器附加到窗口,并将自动与其一起删除,因此无需手动删除。每次窗口需要绘制时,Qt 都会调用渲染器的startNextFrame()函数。我们还没有进行任何绘制,因此窗口保持空白。
每一帧的绘制都以调用frameReady()结束是很重要的。在此函数被调用之前,帧的处理不能完成。然而,并不需要直接从startNextFrame()函数中调用此函数。如果你需要,可以延迟调用,例如,等待在单独的线程中完成计算。
与paintEvent()的工作方式类似,startNextFrame()默认情况下不会连续调用。它只会在显示窗口后调用一次。它还会在窗口的任何部分暴露时调用(例如,由于移动窗口或恢复最小化窗口的结果)。如果你需要连续渲染动态场景,请在调用m_window->frameReady()后调用m_window->requestUpdate()。
使用 Vulkan 类型和函数
我们可以让 Qt 处理加载 Vulkan 库和为我们解析函数。它的工作方式类似于QOpenGLFunctions类集。Qt 为 Vulkan 提供了两个函数类:
-
QVulkanFunctions类提供了访问非设备特定 Vulkan 函数的接口。 -
QVulkanDeviceFunctions类提供了在特定VkDevice上工作的函数。
你可以通过调用QVulkanInstance类的functions()和deviceFunctions(VkDevice device)方法来获取这些对象。你通常会在渲染器中大量使用设备函数,因此一个常见的模式是在你的渲染器类中添加QVulkanDeviceFunctions *m_devFuncs私有字段,并在initResources()虚函数中初始化它:
void MyRenderer::initResources()
{
VkDevice device = m_window->device();
m_devFuncs = m_window->vulkanInstance()->deviceFunctions(device);
//...
}
现在,你可以使用m_devFuncs来访问 Vulkan API 函数。我们不会直接使用它们,因此不需要为每个*台确定如何链接到 Vulkan 库。Qt 为我们完成这项工作。
对于结构体、联合体和类型定义,我们可以直接使用它们,无需担心*台细节。只要系统中存在 Vulkan SDK 的头文件就足够了。
动态背景颜色绘制的时间到了
让我们看看如何在我们的 Qt 项目中使用 Vulkan API 来改变窗口的背景颜色。我们将循环遍历所有可能的色调,同时保持饱和度和亮度不变。当你想到在 RGB 空间中的颜色时,这听起来可能很复杂,但如果你使用 HSL(色调、饱和度、亮度)颜色模型工作,实际上非常简单。幸运的是,QColor支持多种颜色模型,包括 HSL。
首先,添加并初始化m_devFuncs私有字段,就像刚刚展示的那样。接下来,添加一个float m_hue私有字段,它将保存背景颜色的当前色调。将其初始值设置为零。现在我们可以开始编写我们的startNextFrame()函数,它将完成所有的魔法。让我们一步一步来看。首先,我们增加我们的m_hue变量,并确保我们不会超出范围;然后,我们使用QColor::fromHslF()函数根据给定的色调、饱和度和亮度(它们的范围都是 0 到 1)来构建一个QColor值:
void MyRenderer::startNextFrame()
{
m_hue += 0.005f;
if (m_hue > 1.0f) {
m_hue = 0.0f;
}
QColor color = QColor::fromHslF(m_hue, 1, 0.5);
//...
}
接下来,我们使用这个color变量来构建一个VkClearValue数组,我们将用它来设置背景颜色:
VkClearValue clearValues[2];
memset(clearValues, 0, sizeof(clearValues));
clearValues[0].color = {
static_cast<float>(color.redF()),
static_cast<float>(color.greenF()),
static_cast<float>(color.blueF()),
1.0f
};
clearValues[1].depthStencil = { 1.0f, 0 };
要在 Vulkan 中开始一个新的渲染过程,我们需要初始化一个VkRenderPassBeginInfo结构。它需要很多数据,但幸运的是,QVulkanWindow为我们提供了大部分数据。我们只需要将其放入结构中,并使用我们之前设置的clearValues数组:
VkRenderPassBeginInfo info;
memset(&info, 0, sizeof(info));
info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO;
info.renderPass = m_window->defaultRenderPass();
info.framebuffer = m_window->currentFramebuffer();
const QSize imageSize = m_window->swapChainImageSize();
info.renderArea.extent.width = imageSize.width();
info.renderArea.extent.height = imageSize.height();
info.clearValueCount = 2;
info.pClearValues = clearValues;
最后,是时候进行渲染了:
VkCommandBuffer commandBuffer = m_window->currentCommandBuffer();
m_devFuncs->vkCmdBeginRenderPass(commandBuffer, &info,
VK_SUBPASS_CONTENTS_INLINE);
m_devFuncs->vkCmdEndRenderPass(commandBuffer);
m_window->frameReady();
m_window->requestUpdate();
vkCmdBeginRenderPass() Vulkan API 函数将开始渲染过程,这将导致清除我们设置的颜色的窗口。由于我们没有其他东西要绘制,我们立即使用vkCmdEndRenderPass()函数完成渲染过程。然后,我们通过调用frameReady()函数来表明我们已经完成了这个帧的所有操作。这允许 Qt 推进渲染循环。作为最后一步,我们请求更新窗口,以确保新帧将很快被请求,颜色动画将继续。
如果你现在运行项目,你应该会看到一个不断改变其背景颜色的窗口:

我们很乐意展示一个更高级的例子。然而,即使在 Vulkan 中绘制一个简单的三角形通常也需要几百行代码,因为 Vulkan 要求你明确设置很多东西。虽然 Qt 为 OpenGL 渲染提供了许多辅助类,但它并没有包含任何类似的类来帮助进行 Vulkan 渲染或计算(截至 Qt 5.10),因此在这些任务中并没有针对 Qt 的具体内容。
如果你想深化你对 Vulkan 的了解,你可以研究其官方网站和 Vulkan SDK 网站上的文档和教程。Qt 还包含一些基于 Vulkan 的良好示例,例如 Hello Vulkan Triangle、Hello Vulkan Texture 和 Hello Vulkan Cubes。
日志和验证
Qt 自动接收来自 Vulkan 库的消息并将它们放入 Qt 的日志系统中。关键错误将通过 qWarning() 传递,因此它们默认会出现在应用程序输出中。然而,Qt 还记录了在调试时可能有用的附加信息。默认情况下,这些信息是隐藏的,但你可以通过在 main() 函数中添加以下行来使其可见,就在 QGuiApplication 构造之后:
QLoggingCategory::setFilterRules(QStringLiteral("qt.vulkan=true"));
Vulkan API 默认不执行任何健全性检查。如果你向 Vulkan API 函数传递一个无效的参数,应用程序可能会静默崩溃,或者工作不一致。然而,你可以为你的 Vulkan 实例启用 验证层。它们不会改变 API 调用的功能,但它们在可能的情况下启用额外的检查。在调试构建中启用验证层是一个好主意。你可以在调用 create() 之前通过在实例对象上调用 setLayers() 来实现这一点:
vulkan.setLayers({ "VK_LAYER_LUNARG_standard_validation" });
请记住,尝试请求当前不受支持的层或扩展将被 Qt 忽略。
让我们通过在我们的代码中插入一个无效参数来测试验证层:
info.renderArea.extent.width = -5; // invalid
当你运行应用程序时,Qt 应该将警告打印到应用程序输出:
vkDebug: CORE: 4: Cannot execute a render pass with renderArea not within the bound of the framebuffer. RenderArea: x 0, y 0, width -5, height 768\. Framebuffer: width 1024, height 768.
如果没有出现警告,这意味着验证层不可用或加载失败。检查应用程序输出中验证层的存在(它们将在“支持的 Vulkan 实例层”行之后打印出来)以及任何库加载错误。确保你已经根据文档设置了 Vulkan SDK 和项目的环境变量。
然而,请注意,验证层会对你的应用程序性能产生影响。你可能在最终构建中禁用它们。你还可以使用以下代码禁用将 Vulkan 的调试输出重定向到 Qt 日志系统:
QVulkanInstance vulkan;
vulkan.setFlags(QVulkanInstance::NoDebugOutputRedirect);
将 OpenGL 或 Vulkan 与 Qt Widgets 结合使用
有时你可能想结合加速图形和 Qt Widgets 的功能。虽然 OpenGL 和 Vulkan 在渲染高性能 2D 和 3D 场景方面非常出色,但 Qt Widgets 模块在创建用户界面方面要容易得多。Qt 提供了几种方法将它们结合成一个单一强大的界面。这在你应用程序严重依赖小部件时非常有用(例如,3D 视图只是你应用程序中的视图之一,并且通过围绕主视图的一堆其他小部件来控制)。
第一种方法是QWidget::createWindowContainer()函数。它接受一个任意的QWindow并创建一个QWidget,该窗口保持在其边界内。这个小部件可以被放入另一个小部件中,并且可以通过布局进行管理。虽然窗口看起来是嵌入到另一个窗口中的,但从操作系统的角度来看,它仍然是一个本地窗口,任何加速渲染都将直接在窗口上执行,而不会对性能产生重大影响。尽管这种方法有一些限制。例如,嵌入的窗口将始终堆叠在其他小部件之上。然而,它在大多数情况下都是合适的。
让我们回到我们的 OpenGL 立方体项目,并给它添加一个额外的标签:
QWidget widget;
QVBoxLayout* layout = new QVBoxLayout(&widget);
layout->addWidget(new QLabel("Scene"), 0);
QWidget* container = QWidget::createWindowContainer(&window, &widget);
layout->addWidget(container, 1);
widget.resize(600, 600);
widget.show();
我们没有显示 OpenGL 窗口,而是创建了一个小部件,并将窗口放入该小部件的布局中:

你可以将这种方法应用于任何QWindow,包括基于 Vulkan 的窗口和 Qt Quick 窗口,我们将在随后的章节中处理这些窗口。
有另一种解决相同任务的方法,但它仅适用于 OpenGL。你可以简单地用QOpenGLWidget替换QOpenGLWindow,将窗口转换成一个功能齐全的小部件。QOpenGLWidget的 API(包括虚拟函数)与QOpenGLWindow兼容,因此它可以作为即插即用的替代品。QOpenGLWidget的堆叠顺序、焦点或透明度没有任何限制。你甚至可以将 OpenGL 渲染与QPainter操作混合。然而,这种解决方案会有性能成本。QOpenGLWindow直接渲染到指定的窗口,而QOpenGLWidget首先渲染到一个离屏缓冲区,然后该缓冲区被渲染到小部件上,所以它会慢一些。
突击测验
Q1. 以下哪种编程语言被QOpenGLShader::compileSourceCode()函数接受?
-
C
-
C++
-
GLSL
Q2. 你应该实现QOpenGLWindow类的哪个虚拟函数来执行 OpenGL 绘图?
-
paintGL() -
paintEvent() -
makeCurrent()
Q3. 你应该在何时删除你的QVulkanWindowRenderer子类的对象?
-
在
QVulkanWindow子类的析构函数中 -
在删除
QVulkanInstance对象之后 -
从不
摘要
在本章中,我们学习了如何使用 Qt 结合 OpenGL 和 Vulkan 图形。有了这些知识,你可以创建硬件加速的 2D 和 3D 图形。我们还探讨了 Qt 类,这些类简化了在 Qt 应用程序中使用这些技术的过程。如果你想要提高你的 OpenGL 和 Vulkan 技能,你可以研究许多专注于这些主题的书籍和文章。Qt 提供了非常透明的硬件加速图形访问,因此将任何纯 OpenGL 或 Vulkan 方法适配到 Qt 应该很容易。如果你更喜欢使用高级 API 进行加速图形,你应该关注 Qt Quick 和 Qt 3D。我们将在本书的最后一部分介绍它。
在下一章中,你将学习如何在你的游戏中实现脚本编写。这将使游戏更加可扩展,并且更容易修改。脚本还可以用来在你的游戏中启用模组,允许玩家按照他们想要的定制游戏玩法。
第十章:脚本
在本章中,你将学习如何将脚本功能引入你的程序。你将了解如何使用 JavaScript 来实现游戏逻辑和细节,而无需重新构建主游戏引擎。这些技能在本书的最后一部分也将非常有用,当我们与 Qt Quick 一起工作时。尽管我们将关注的最佳环境与 Qt 应用程序相融合,但如果你不喜欢 JavaScript,还有其他选择。我们还将展示如何使用 Python 使你的游戏可脚本化。
本章涵盖的主要主题如下:
-
执行 JavaScript 代码
-
C++与 JavaScript 之间的交互
-
实现脚本游戏
-
集成 Python 解释器
为什么使用脚本?
你可能会问自己,“如果我可以使用 C++实现所有需要的功能,为什么还要使用任何脚本语言”?为你的游戏提供脚本环境有许多好处。大多数现代游戏实际上由两部分组成。一部分是主游戏引擎,它实现了游戏的核心(数据结构、处理算法和渲染层),并向其他组件提供了一个 API,该组件提供游戏的具体细节、行为模式和动作流程。这个其他组件有时是用脚本语言编写的。这种做法的主要好处是,故事设计师可以独立于引擎开发者工作,他们不需要重建整个游戏,只需修改一些参数或检查新的任务是否与现有故事很好地融合。这使得开发速度比单一方法快得多。
另一个好处是,这为游戏开启了模组化——熟练的最终用户可以扩展或修改游戏,为游戏提供一些附加价值。这也是在不重新部署整个游戏二进制文件到每个玩家的情况下,在现有的脚本 API 之上实现游戏扩展的一种方式。最后,你可以重用相同的游戏驱动程序为其他游戏服务,只需替换脚本即可获得一个完全不同的产品。
在本章中,我们将使用 Qt QML 模块来实现脚本。此模块实现了 Qt Quick 中使用的 QML 语言。由于 QML 是基于 JavaScript 的,Qt QML 包含一个 JavaScript 引擎,并提供运行 JavaScript 代码的 API。它还允许你将 C++对象暴露给 JavaScript,反之亦然。
我们将不会讨论 JavaScript 语言本身的细节,因为有许多优秀的书籍和网站可供学习 JavaScript。此外,JavaScript 的语法与 C 语言非常相似,即使你之前没有见过任何 JavaScript 代码,也应该没有问题理解我们在这章中使用的脚本。
评估 JavaScript 表达式
要在程序中使用 Qt QML,你必须通过在项目文件中添加QT += qml行来为你的项目启用脚本模块。
C++编译器不理解 JavaScript。因此,要执行任何脚本,你需要有一个正在运行的解释器,该解释器将解析脚本并评估它。在 Qt 中,这是通过QJSEngine类完成的。这是一个 JavaScript 运行时,负责执行脚本代码并管理所有与脚本相关的资源。它提供了evaluate()方法,可以用来执行 JavaScript 表达式。让我们看看使用QJSEngine的“Hello World”程序:
#include <QCoreApplication>
#include <QJSEngine>
int main(int argc, char **argv) {
QCoreApplication app(argc, argv);
QJSEngine engine;
engine.installExtensions(QJSEngine::ConsoleExtension);
engine.evaluate("console.log('Hello World!');");
return 0;
}
此程序非常简单。首先,它创建一个应用程序对象,这对于脚本环境正常工作来说是必需的,并实例化一个QJSEngine对象。接下来,我们要求QJSEngine安装控制台扩展——一个全局的console对象,可以用来向控制台打印消息。它不是 ECMAScript 标准的一部分,因此默认情况下不可用,但我们可以通过使用installExtensions()函数轻松启用它。最后,我们调用evaluate()函数来执行作为参数传递给它的脚本源代码。构建并运行程序后,你将在控制台看到带有js:前缀的熟悉的Hello World!。
默认情况下,QJSEngine提供了由 ECMA-262 标准定义的内置对象,包括Math、Date和String。例如,一个脚本可以使用Math.abs(x)来获取一个数字的绝对值。
如果你没有获得任何输出,这可能意味着脚本没有正确执行,可能是由于脚本源代码中的错误。为了验证这一点,我们可以检查evaluate()函数返回的值:
QJSValue result = engine.evaluate("console.log('Hello World!')");
if (result.isError()) {
qDebug() << "JS error:" << result.toString();
}
此代码检查是否存在异常或语法错误,如果有,则显示相应的错误消息。例如,如果你在脚本源文本中省略了关闭的单引号并运行程序,将显示以下消息:
JS error: "SyntaxError: Expected token `)'"
你可以看到evaluate()返回一个QJSValue。这是一种特殊类型,用于在 JavaScript 引擎和 C++世界之间交换数据。像QVariant一样,它可以持有多种原始类型(boolean、integer、string等)。然而,实际上它更强大,因为它可以持有指向 JavaScript 引擎中存在的 JavaScript 对象或函数的引用。复制一个QJSValue将产生另一个引用相同 JavaScript 对象的另一个对象。你可以使用QJSValue的成员函数与 C++中的对象交互。例如,你可以使用property()和setProperty()来操作对象的属性,使用call()来调用函数并获取作为另一个QJSValue返回的值。
在前面的例子中,QJSEngine::evaluate() 返回了一个 Error 对象。当 JavaScript 代码成功运行时,您可以在 C++ 代码中稍后使用返回的值。例如,脚本可以计算当生物被特定武器击中时造成的伤害量。修改我们的代码以使用脚本的输出非常简单。所需的所有操作只是存储 evaluate() 返回的值,然后它可以在代码的其他部分使用:
QJSValue result = engine.evaluate("(7 + 8) / 2");
if (result.isError()) {
//...
} else {
qDebug() << result.toNumber();
}
行动时间 – 创建 JavaScript 编辑器
让我们做一个简单的练习,创建一个图形编辑器来编写和执行脚本。首先创建一个新的 Qt Widgets 项目,并实现一个由两个纯文本编辑小部件(ui->codeEditor 和 ui->logWindow)组成的窗口,这两个小部件通过垂直分隔符分开。其中一个编辑框将用作输入代码的编辑器,另一个将用作显示脚本结果的控制台。然后,向窗口添加菜单和工具栏,并创建打开(ui->actionOpenDocument)、保存(ui->actionSaveDocument 和 ui->actionSaveDocumentAs)、创建新文档(ui->actionNewDocument)、执行脚本(ui->actionExecuteScript)和退出应用程序(ui->actionQuit)的操作。请记住将它们添加到菜单和工具栏中。
因此,你应该会收到一个类似于以下截图的窗口:

将退出操作连接到 QApplication::quit() 插槽。然后,创建一个 openDocument() 插槽并将其连接到适当操作的 triggered 信号。在插槽中,使用 QFileDialog::getOpenFileName() 询问用户文档路径,如下所示:
void MainWindow::openDocument()
{
QString filePath = QFileDialog::getOpenFileName(
this, tr("Open Document"),
QDir::homePath(), tr("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, tr("Error"), tr("Can't open file."));
return;
}
setWindowFilePath(filePath);
ui->codeEditor->setPlainText(QString::fromUtf8(file.readAll()));
ui->logWindow->clear();
}
QWidget 的 windowFilePath 属性可以用来将文件与窗口关联。当此属性被设置时,Qt 会自动调整窗口标题,甚至在 macOS 上添加代理图标,以便方便地访问文件。然后,您可以在与文件使用相关的操作中使用此属性——在保存文档时,您可以检查此属性是否为空,并要求用户提供文件名。然后,在创建新文档或用户为文档提供新路径时,您可以重置此属性。
在这个阶段,你应该能够运行程序,并使用它来创建脚本,在编辑器中保存和重新加载它们。
现在,为了执行脚本,向窗口类中添加一个 QJSEngine m_engine 成员变量。创建一个新的插槽,命名为 run,并将其连接到执行操作。在插槽的主体中放入以下代码:
void MainWindow::run()
{
ui->logWindow->clear();
QTextCursor logCursor = ui->logWindow->textCursor();
QString scriptSourceCode = ui->codeEditor->toPlainText();
QJSValue result = m_engine.evaluate(scriptSourceCode,
windowFilePath());
if(result.isError()) {
QTextCharFormat errFormat;
errFormat.setForeground(Qt::red);
logCursor.insertText(tr("Exception at line %1:\n")
.arg(result.property("lineNumber").toInt()), errFormat);
logCursor.insertText(result.toString(), errFormat);
logCursor.insertBlock();
logCursor.insertText(result.property("stack").toString(),
errFormat);
} else {
QTextCharFormat resultFormat;
resultFormat.setForeground(Qt::blue);
logCursor.insertText(result.toString(), resultFormat);
}
}
编译并运行程序。为此,在编辑器中输入以下脚本:
function factorial(n) {
if(n < 0) {
return;
}
if(n == 0) {
return 1;
}
return n * factorial(n - 1);
}
factorial(7)
将脚本保存到名为 factorial.js 的文件中,然后运行它。你应该得到如下输出:

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

刚才发生了什么?
run()方法清除日志窗口并使用我们在本章前面学到的方法评估脚本。如果评估成功,它将在日志窗口中打印结果,这就是我们在上一节中看到的第一个截图所示的内容。
在第二次尝试中,我们在脚本中使用了一个不存在的变量,导致错误。评估此类代码会导致异常。除了报告实际错误外,我们还使用返回的Error对象的lineNumber属性来报告导致问题的行。接下来,我们显示错误对象的stack属性,它返回问题的回溯(函数调用堆栈),我们也在日志中打印出来。
全局对象状态
让我们尝试另一个脚本。以下代码定义了fun局部变量,它被分配了一个返回数字的匿名函数:
var fun = function() {
return 42;
}
然后,您可以像调用常规函数一样调用fun(),如下所示:

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

即使没有定义fun的含义,我们仍然得到相同的结果!这是因为任何在顶层作用域中的变量都成为全局对象的属性。全局对象的状态在QJSEngine存在期间被保留,所以fun变量将保持可用,直到它被覆盖或引擎被销毁。
为了防止用户意外地使用局部变量更改全局对象,我们可以将提供的代码包裹在一个匿名函数中:
QString wrappedCode =
QStringLiteral("(function() { %1\n})()").arg(scriptSourceCode);
QJSValue result = m_engine.evaluate(wrappedCode, windowFilePath());
在这种情况下,JavaScript 代码必须使用return语句实际上将值返回到编辑器:
var fun = function() {
return 42;
}
return fun();
移除fun变量初始化现在将导致错误:
ReferenceError: fun is not defined
然而,移除var关键字将使变量成为全局并保留。恶意用户还可以破坏现有全局对象的属性。例如,评估Math.floor = null;将使内置的Math.floor函数在所有后续调用中不可用。
实际上并没有很好的方法来保护或重置全局对象。如果您担心恶意脚本,销毁并创建一个新的QJSEngine对象是最佳选择。如果您需要运行多个不允许相互干扰的脚本,您必须为每个脚本创建一个单独的QJSEngine。然而,在大多数应用程序中,这种沙箱化似乎是一种过度行为。
将 C++对象和函数暴露给 JavaScript 代码
到目前为止,我们只评估了一些可以充分利用内置 JavaScript 功能的独立脚本。现在,是时候学习如何在脚本中使用程序中的数据了。这是通过将不同类型的实体暴露给脚本和从脚本中实现的。
访问 C++ 对象的属性和方法
将 C++ 对象暴露给 JavaScript 代码的最简单方法就是利用 Qt 的元对象系统。QJSEngine 能够检查 QObject 实例并检测它们的属性和方法。要在脚本中使用它们,对象必须对脚本可见。使这一行为发生的最简单方法是将它添加到引擎的全局对象中。正如你所记得的,脚本引擎和 C++ 之间的所有数据交换都使用 QJSValue 类进行,因此我们首先需要为 C++ 对象获取一个 JS 值句柄:
QJSEngine engine;
QPushButton *button = new QPushButton("Button");
// ...
QJSValue scriptButton = engine.newQObject(button);
engine.globalObject().setProperty("pushButton", scriptButton);
QJSEngine::newQObject() 创建一个 JavaScript 对象,它封装了一个现有的 QObject 实例。然后我们将包装器设置为全局对象的属性,称为 pushButton。这使得按钮作为 JavaScript 对象在引擎的全局上下文中可用。所有使用 Q_PROPERTY 定义的属性都作为对象的属性可用,每个槽都可以作为该对象的方法访问。在 JavaScript 中,你将能够像这样使用 pushButton 对象:
pushButton.text = 'My Scripted Button';
pushButton.checkable = true;
pushButton.setChecked(true);
pushButton.show();
Qt 槽传统上返回 void。技术上它们可以有任何返回类型,但 Qt 不会使用返回值,所以在大多数情况下,返回任何值都没有意义。相反,当你将 C++ 方法暴露给 JavaScript 引擎时,你通常希望返回一个值并在 JavaScript 中接收它。在这些情况下,你不应该创建槽,因为这会破坏约定。你应该使方法可调用。为此,将方法声明放在常规 public 范围内,并在其前面添加 Q_INVOKABLE:
public:
Q_INVOKABLE int myMethod();
此宏指示 moc 使此方法在元对象系统中可调用,以便 Qt 能够在运行时调用它。所有可调用的方法都会自动暴露给脚本。
C++ 和 JavaScript 之间的数据类型转换
Qt 会自动将方法的参数和返回类型转换为它的 JavaScript 对应类型。支持的转换包括以下内容:
-
基本类型(
bool、int、double等)未经更改地暴露 -
Qt 数据类型(
QString、QUrl、QColor、QFont、QDate、QPoint、QSize、QRect、QMatrix4x4、QQuaternion、QVector2D等)转换为具有可用属性的对象 -
QDateTime和QTime值自动转换为 JavaScriptDate对象 -
使用
Q_ENUM宏声明的枚举可以在 JavaScript 中使用 -
使用
Q_FLAG宏声明的标志可以用作 JavaScript 中的标志 -
QObject*指针将自动转换为 JavaScript 包装对象 -
包含任何支持类型的
QVariant对象被识别 -
QVariantList是一个具有任意项的 JavaScript 数组的等价物 -
QVariantMap是具有任意属性的 JavaScript 对象的等效物 -
一些 C++ 列表类型(
QList<int>,QList<qreal>,QList<bool>,QList<QString>,QStringList,QList<QUrl>,QVector<int>,QVector<qreal>, 和QVector<bool>)在 JavaScript 中暴露,无需执行额外的数据转换
如果你需要更精细地控制数据类型转换,你可以简单地使用 QJSValue 作为参数类型或返回类型。例如,这将允许你返回现有 JavaScript 对象的引用,而不是每次都创建一个新的对象。这种方法对于创建或访问具有复杂结构的二维数组或其他对象也非常有用。虽然你可以使用嵌套的 QVariantList 或 QVariantMap 对象,但直接创建 QJSValue 对象可能更有效。
Qt 无法识别并自动转换自定义类型。尝试从 JavaScript 访问此类方法或属性将导致错误。你可以使用 Q_GADGET 宏使 C++ 数据类型对 JavaScript 可用,并使用 Q_PROPERTY 声明应公开的属性。
有关此主题的更多信息,请参阅 QML 和 C++ 之间数据类型转换文档页面。
在脚本中访问信号和槽
QJSEngine 还提供了使用信号和槽的能力。槽可以是 C++ 方法或 JavaScript 函数。连接可以在 C++ 或脚本中创建。
首先,让我们看看如何在脚本内部建立连接。当一个 QObject 实例暴露给脚本时,对象信号成为包装对象的属性。这些属性有一个 connect 方法,它接受一个函数对象,当信号被发射时将调用该对象。接收者可以是常规槽或 JavaScript 函数。最常见的情况是将信号连接到一个匿名函数:
pushButton.toggled.connect(function() {
console.log('button toggled!');
});
如果你需要撤销连接,你需要将函数存储在一个变量中:
function buttonToggled() {
//...
}
pushButton.toggled.connect(buttonToggled);
//...
pushButton.toggled.disconnect(buttonToggled);
你可以通过向 connect() 提供一个额外的参数来为函数定义 this 对象:
var obj = { 'name': 'FooBar' };
pushButton.clicked.connect(obj, function() {
console.log(this.name);
});
你还可以将信号连接到另一个暴露对象的信号或槽。要将名为 pushButton 的对象的 clicked() 信号连接到名为 lineEdit 的另一个对象的 clear() 槽,可以使用以下语句:
pushButton.clicked.connect(lineEdit.clear);
在脚本内部发射信号也很简单——只需将信号作为函数调用,并传递任何必要的参数:
pushButton.clicked();
spinBox.valueChanged(7);
要在 C++ 端创建接收器为 JavaScript 函数的信号-槽连接,你可以利用 C++ lambda 函数和 QJSValue::call() 函数:
QJSValue func = engine.evaluate(
"function(checked) { console.log('func', checked); }");
QObject::connect(&button, &QPushButton::clicked, func {
QJSValue(func).call({ checked });
});
行动时间 – 使用 JavaScript 中的按钮
让我们把所有这些放在一起,构建一个可脚本化的按钮的完整示例:
int main(int argc, char *argv[]) {
QApplication app(argc, argv);
QJSEngine engine;
engine.installExtensions(QJSEngine::ConsoleExtension);
QPushButton button;
engine.globalObject().setProperty("pushButton", engine.newQObject(&button));
QString script =
"pushButton.text = 'My Scripted Button';\n"
"pushButton.checkable = true;\n"
"pushButton.setChecked(true);\n"
"pushButton.toggled.connect(function(checked) {\n"
" console.log('button toggled!', checked);\n"
"});\n"
"pushButton.show();";
engine.evaluate(script);
QJSValue func = engine.evaluate(
"function(checked) { console.log('button toggled 2!', checked); }");
QObject::connect(&button, &QPushButton::clicked, func {
QJSValue(func).call({ checked });
});
return app.exec();
}
在此代码中,我们将函数暴露给 JavaScript 并执行设置按钮某些属性和访问其toggled信号的代码。接下来,我们创建一个 JavaScript 函数,将其引用存储在func变量中,并从 C++侧将按钮的toggled信号连接到这个函数。
限制从 JavaScript 访问 C++类
有时候,你可能想为类提供一个丰富的接口,以便从 C++内部轻松地操作它,但同时又想严格控制使用脚本可以执行的操作,因此你想阻止脚本编写者使用类的一些属性或方法。
最安全的做法是创建一个只暴露允许的方法和信号的包装器类。这将允许你自由地设计你的原始类。例如,如果你想隐藏某些方法,这相当简单——只需不要将它们作为槽函数,也不要用Q_INVOKABLE声明它们。然而,你可能希望在内部实现中将它们作为槽函数。通过创建包装器类,你可以轻松地将内部类的槽函数隐藏起来,使其无法被 JavaScript 代码访问。我们将在本章后面展示如何应用这种方法。
如果你的内部对象使用的数据类型不能直接暴露给 JavaScript,可能会出现另一个问题。例如,如果你的某个方法返回一个QVector<QVector<int>>,你将无法直接从 JavaScript 调用此方法。包装器类是放置所需数据转换操作的好地方。
你还应该意识到,JavaScript 代码可以发出暴露的 C++对象的任何信号。在某些情况下,这可能会破坏你应用程序的逻辑。如果你使用包装器,你只需将内部类的信号连接到暴露的包装器的信号。脚本将能够连接到包装器的信号,但无法发出原始信号。然而,脚本将能够发出包装器的信号,这可能会影响引擎中所有其他 JavaScript 代码。
如果类的所有或几乎所有 API 都安全地暴露给 JavaScript,那么直接使对象本身可用,而不是创建包装器,会更容易。如果你想限制对某些方法的访问,请记住,JavaScript 代码只能访问用Q_INVOKABLE声明的公共和受保护方法以及槽函数。记住,如果你使用接受函数指针作为参数的connect()变体,你仍然可以将信号连接到非槽方法。JavaScript 代码也无法访问任何私有方法。
对于属性,你可以在Q_PROPERTY声明中使用SCRIPTABLE关键字来标记它们不被脚本访问。默认情况下,所有属性都是可脚本化的,但你可以通过将SCRIPTABLE设置为false来禁止它们被脚本暴露,如下面的示例所示:
Q_PROPERTY(QString internalName READ internalName SCRIPTABLE false)
从 JavaScript 创建 C++对象
我们到目前为止只暴露了现有的 C++对象给 JavaScript,但如果你想在 JavaScript 中创建一个新的 C++对象怎么办?你可以使用你已知的知识来做这件事。一个已暴露对象的 C++方法可以为你创建一个新的对象:
public:
Q_INVOKABLE QObject* createMyObject(int argument) {
return new MyObject(argument);
}
在函数签名中,我们使用QObject*而不是MyObject*。这允许我们自动将对象导入 JS 引擎。当 JavaScript 中没有更多引用时,引擎将接管对象并删除它。
使用这种方法从 JavaScript 中调用也是相当直接的:
var newObject = originalObject.createMyObject(42);
newObject.slot1();
如果你有合适的createMyObject函数位置,这种方法是可行的。然而,有时你想要独立于现有对象创建新对象,或者你还没有创建任何对象。对于这些情况,有一种巧妙的方法可以将类的构造函数暴露给 JavaScript 引擎。首先,你需要使类声明中的构造函数可调用:
public:
Q_INVOKABLE explicit MyObject(int argument, QObject *parent = nullptr);
然后,你应该使用newQMetaObject()函数将类的元对象导入到引擎中。你可以立即将导入的元对象分配给全局对象的属性:
engine.globalObject().setProperty("MyObject",
engine.newQMetaObject(&MyObject::staticMetaObject));
现在,你可以通过使用new关键字调用暴露的对象来调用构造函数:
var newObject = new MyObject(42);
newObject.slot1();
将 C++函数暴露给 JavaScript
有时候你只想提供一个函数而不是一个对象。不幸的是,QJSEngine只支持属于QObject派生类的函数。然而,我们可以从 JavaScript 方面隐藏这个实现细节。首先,创建QObject的一个子类并添加一个可调用的成员函数,该函数代理原始独立函数:
Q_INVOKABLE double factorial(int x) {
return superFastFactorial(x);
}
接下来,使用newQObject()函数像往常一样暴露包装对象。然而,不要将此对象分配给全局对象的属性,而是从对象中提取factorial属性:
QJSValue myObjectJS = engine.newQObject(new MyObject());
engine.globalObject().setProperty("factorial",
myObjectJS.property("factorial"));
现在,JavaScript 代码可以像访问全局函数一样访问方法,例如factorial(4)。
创建一个 JavaScript 脚本游戏
让我们通过实现一个允许玩家使用 JavaScript 的游戏来完善我们的技能。规则很简单。每个玩家在棋盘上都有一些实体移动。所有实体轮流移动;在每一轮中,实体可以静止不动或移动到相邻的方格(直线或对角线)。如果一个实体移动到另一个实体占据的方格,那个实体就会被杀死并从棋盘上移除。
游戏开始时,所有实体都随机放置在棋盘上。以下图像显示了起始位置的示例:

每个玩家必须提供一个 JavaScript 函数,该函数接收一个实体对象并返回其新位置。当玩家的某个实体应该移动时,将调用此函数。此外,玩家还可以提供一个初始化函数,该函数将在游戏开始时被调用。棋盘的状态和其上的实体将通过全局 JavaScript 对象的一个属性公开。
在我们的游戏中,玩家将竞争创建最佳的生存策略。一旦游戏开始,玩家就无法控制实体,提供的 JavaScript 函数必须考虑到任何可能的游戏情况。当棋盘上只剩下一个玩家的实体时,该玩家获胜。规则允许任何数量的玩家参与,尽管在我们的示例中我们只有两个玩家。
行动时间 – 实现游戏引擎
我们将使用图形视图框架来实现棋盘可视化。由于本章重点在于脚本编写,我们将不对实现细节进行过多介绍。你在第四章,使用图形视图的定制 2D 图形中学到的基本技能应该足够你实现这个游戏。本书提供了这个示例的完整代码。然而,我们将突出展示项目的架构,并简要描述其工作原理。
游戏引擎的实现包括两个类:
-
Scene类(由QGraphicsScene派生)管理图形场景,创建项目,并实现通用游戏逻辑 -
Entity类(由QGraphicsEllipseItem派生)代表棋盘上的单个游戏实体
每个Entity对象是一个半径为 0.4 且中心为(0, 0)的圆。它是在构造函数中初始化的,使用以下代码:
setRect(-0.4, -0.4, 0.8, 0.8);
setPen(Qt::NoPen);
我们将使用pos属性(从QGraphicsItem继承)来移动棋盘上的圆。棋盘的瓦片将具有单位大小,因此我们可以将pos视为整数QPoint,而不是具有double坐标的QPointF。我们将放大图形视图以实现实体所需的可见大小。
Entity类有两个具有获取器和设置器的特殊属性。team属性是该实体所属玩家的编号。此属性还定义了圆的颜色:
void Entity::setTeam(int team) {
m_team = team;
QColor color;
switch(team) {
case 0:
color = Qt::green;
break;
case 1:
color = Qt::red;
break;
}
setBrush(color);
}
alive标志指示实体是否仍在游戏中。为了简单起见,我们不会立即删除被杀死的实体对象,而是将其隐藏:
void Entity::setAlive(bool alive)
{
m_alive = alive;
setVisible(alive);
//...
}
让我们把注意力转向Scene类。首先,它定义了一些游戏配置选项:
-
fieldSize属性确定棋盘的二维大小 -
teamSize属性确定每个玩家在游戏开始时拥有的实体数量 -
stepDuration属性确定执行下一轮回合之间经过的毫秒数
fieldSize 属性的设置器调整场景矩形,以便在游戏开始时正确调整图形视图的大小:
void Scene::setFieldSize(const QSize &fieldSize)
{
m_fieldSize = fieldSize;
setSceneRect(-1, -1,
m_fieldSize.width() + 2,
m_fieldSize.height() + 2);
}
每一轮游戏的执行将在 step() 函数中完成。在构造函数中,我们初始化一个负责调用此函数的 QTimer 对象:
m_stepTimer = new QTimer(this);
connect(m_stepTimer, &QTimer::timeout,
this, &Scene::step);
m_stepTimer->setInterval(1000);
在 setStepDuration() 函数中,我们简单地改变这个计时器的间隔。
Scene 类的 QVector<Entity*> m_entities 私有字段将包含所有在游戏中运行的实体。游戏通过调用 start() 函数开始。让我们看看它:
void Scene::start() {
const int TEAM_COUNT = 2;
for(int i = 0; i < m_teamSize; i++) {
for(int team = 0; team < TEAM_COUNT; team++) {
Entity* entity = new Entity(this);
entity->setTeam(team);
QPoint pos;
do {
pos.setX(qrand() % m_fieldSize.width());
pos.setY(qrand() % m_fieldSize.height());
} while(itemAt(pos, QTransform()));
entity->setPos(pos);
addItem(entity);
m_entities << entity;
}
}
//...
m_stepTimer->start();
}
我们为每个团队创建所需数量的实体,并将它们随机放置在棋盘上的位置。如果我们恰好选择了一个已被占用的位置,我们将进入 do-while 循环的下一个迭代并选择另一个位置。接下来,我们将新项目添加到场景和 m_entities 向量中。最后,我们开始计时器,以便 step() 函数能定期被调用。
在 main() 函数中,我们初始化随机数生成器,因为我们希望每次都能得到新的随机数:
qsrand(QDateTime::currentMSecsSinceEpoch());
然后,我们创建并初始化 Scene 对象,并创建一个 QGraphicsView 来显示我们的场景。
游戏引擎几乎准备好了。我们只需要实现脚本。
行动时间 - 将游戏状态暴露给 JS 引擎
在执行玩家的脚本之前,我们需要创建一个 QJSEngine 并向其全局对象中插入一些信息。脚本将使用这些信息来决定最佳移动。
首先,我们将 QJSEngine m_jsEngine 私有字段添加到 Scene 类中。接下来,我们创建一个新的 SceneProxy 类,并从 QObject 派生它。这个类将向脚本暴露 Scene 的允许 API。我们将 Scene 对象的指针传递给 SceneProxy 对象的构造函数,并将其存储在一个私有变量中:
SceneProxy::SceneProxy(Scene *scene) :
QObject(scene), m_scene(scene)
{
}
向类声明中添加两个可调用方法:
Q_INVOKABLE QSize size() const;
Q_INVOKABLE QJSValue entities() const;
size() 函数的实现相当简单:
QSize SceneProxy::size() const {
return m_scene->fieldSize();
}
然而,entities() 函数有点复杂。我们不能将 Entity 对象添加到 JS 引擎中,因为它们不是基于 QObject 的。即使我们可以,我们也更喜欢为实体创建一个代理类。
让我们立即这样做。创建 EntityProxy 类,从 QObject 派生它,并将底层 Entity 对象的指针传递给构造函数,就像我们在 SceneProxy 中做的那样。在新的类中声明两个可调用函数和一个信号:
class EntityProxy : public QObject
{
Q_OBJECT
public:
explicit EntityProxy(Entity *entity, QObject *parent = nullptr);
Q_INVOKABLE int team() const;
Q_INVOKABLE QPoint pos() const;
//...
signals:
void killed();
private:
Entity *m_entity;
};
方法的实现只是将调用转发到底层的 Entity 对象:
int EntityProxy::team() const
{
return m_entity->team();
}
QPoint EntityProxy::pos() const
{
return m_entity->pos().toPoint();
}
Entity 类将负责创建自己的代理对象。向 Entity 类添加以下私有字段:
EntityProxy *m_proxy;
QJSValue m_proxyValue;
m_proxy 字段将保存代理对象。m_proxyValue 字段将包含添加到 JS 引擎的同一对象的引用。在构造函数中初始化这些字段:
m_proxy = new EntityProxy(this, scene);
m_proxyValue = scene->jsEngine()->newQObject(m_proxy);
我们修改 Entity::setAlive() 函数,当实体被杀死时发出 killed() 信号:
void Entity::setAlive(bool alive)
{
m_alive = alive;
setVisible(alive);
if (!alive) {
emit m_proxy->killed();
}
}
通常认为,从拥有该信号类的外部发出信号是不良实践。如果信号的来源是另一个基于 QObject 的类,你应该在那个类中创建一个单独的信号并将其连接到目标信号。在我们的情况下,我们无法这样做,因为 Entity 不是一个 QObject,所以我们选择直接发出信号以避免进一步的复杂化。
为这些字段创建 proxy() 和 proxyValue() 获取器。我们现在可以回到 SceneProxy 的实现并使用实体代理:
QJSValue SceneProxy::entities() const
{
QJSValue list = m_scene->jsEngine()->newArray();
int arrayIndex = 0;
for(Entity *entity: m_scene->entities()) {
if (entity->isAlive()) {
list.setProperty(arrayIndex, entity->proxyValue());
arrayIndex++;
}
}
return list;
}
刚才发生了什么?
首先,我们要求 JS 引擎创建一个新的 JavaScript 数组对象。然后,我们遍历所有实体并跳过已死亡的实体。我们使用 QJSValue::setProperty 将每个实体的代理对象添加到数组中。我们需要指定新数组项的索引,因此我们创建 arrayIndex 计数器并在每次插入后递增。最后,我们返回数组。
此函数完成了 SceneProxy 类的实现。我们只需要在 Scene 类的构造函数中创建一个代理对象并将其添加到 JS 引擎中:
SceneProxy *sceneProxy = new SceneProxy(this);
m_sceneProxyValue = m_jsEngine.newQObject(sceneProxy);
行动时间 – 加载用户提供的脚本
每个玩家将提供他们自己的策略脚本,因此 Scene 类应该有一个字段来存储所有提供的脚本:
QHash<int, QJSValue> m_teamScripts;
让我们提供 setScript() 函数,该函数接受玩家的脚本并将其加载到 JS 引擎中:
void Scene::setScript(int team, const QString &script) {
QJSValue value = m_jsEngine.evaluate(script);
if (value.isError()) {
qDebug() << "js error: " << value.toString();
return;
}
if(!value.isObject()) {
qDebug() << "script must return an object";
return;
}
m_teamScripts[team] = value;
}
在此函数中,我们尝试评估提供的代码。如果代码返回了一个 JavaScript 对象,我们将它放入 m_teamScripts 哈希表中。我们期望提供的对象包含一个 step 属性,该属性包含决定实体移动的函数。该对象可能还包含一个 init 属性,该属性将在游戏开始时执行。
在 main() 函数中,我们从项目的资源中加载脚本:
scene.setScript(0, loadFile(":/scripts/1.js"));
scene.setScript(1, loadFile(":/scripts/2.js"));
loadFile() 辅助函数简单地加载文件内容到 QString:
QString loadFile(const QString& path) {
QFile file(path);
if (!file.open(QFile::ReadOnly)) {
qDebug() << "failed to open " << path;
return QString();
}
return QString::fromUtf8(file.readAll());
}
如果你想允许用户提供他们的脚本而无需重新编译项目,你可以从命令行参数接受脚本文件:
QStringList arguments = app.arguments();
if (arguments.count() < 3) {
qDebug() << "usage: " << argv[0] << " path/to/script1.js path/to/script2.js";
return 1;
}
scene.setScript(0, loadFile(arguments[1]));
scene.setScript(1, loadFile(arguments[2]));
要设置项目的命令行参数,切换到项目面板,在左侧列中选择运行,并定位命令行参数输入框。提供的项目在 scripts 子目录中包含两个示例脚本。
行动时间 – 执行策略脚本
首先,我们需要检查玩家是否提供了一个 init 函数并执行它。我们将在 Scene::start() 函数中这样做:
for(int team = 0; team < TEAM_COUNT; team++) {
QJSValue script = m_teamScripts.value(team);
if (script.isUndefined()) {
continue;
}
if (!script.hasProperty("init")) {
continue;
}
m_jsEngine.globalObject().setProperty("field", m_sceneProxyValue);
QJSValue scriptOutput = script.property("init").call();
if (scriptOutput.isError()) {
qDebug() << "script error: " << scriptOutput.toString();
continue;
}
}
在此代码中,我们使用 isUndefined() 来检查代码是否已提供并成功解析。接下来,我们使用 hasProperty() 来检查返回的对象是否包含可选的 init 函数。如果我们找到了它,我们将使用 QJSValue::call() 来执行它。我们通过将我们的 SceneProxy 实例分配给全局对象的 field 属性来提供有关板的信息。
最激动人心的部分是step()函数,它实现了实际的游戏执行。让我们看看它:
void Scene::step() {
for(Entity* entity: m_entities) {
if (!entity->isAlive()) {
continue;
}
QJSValue script = m_teamScripts.value(entity->team());
if (script.isUndefined()) {
continue;
}
m_jsEngine.globalObject().setProperty("field", m_sceneProxyValue);
QJSValue scriptOutput =
script.property("step").call({ entity->proxyValue() });
//...
}
}
首先,我们遍历所有实体并跳过已死亡的实体。接下来,我们使用Entity::team()来确定这个实体属于哪个玩家。我们从m_teamScripts字段中提取相应的策略脚本,并从中提取step属性。然后,我们尝试将其作为函数调用,并将当前实体的代理对象作为参数传递。让我们看看我们对脚本输出的处理:
if (scriptOutput.isError()) {
qDebug() << "script error: " << scriptOutput.toString();
continue;
}
QJSValue scriptOutputX = scriptOutput.property("x");
QJSValue scriptOutputY = scriptOutput.property("y");
if (!scriptOutputX.isNumber() || !scriptOutputY.isNumber()) {
qDebug() << "invalid script output: " << scriptOutput.toVariant();
continue;
}
QPoint pos(scriptOutputX.toInt(), scriptOutputY.toInt());
if (!moveEntity(entity, pos)) {
qDebug() << "invalid move";
}
我们尝试将函数的返回值解释为一个具有x和y属性的对象。如果这两个属性都包含数字,我们就从它们中构建一个QPoint,并调用我们的moveEntity()函数,该函数尝试执行策略选择的移动。
我们不会盲目地相信用户脚本返回的值。相反,我们仔细检查移动是否有效:
bool Scene::moveEntity(Entity *entity, QPoint pos) {
if (pos.x() < 0 || pos.y() < 0 ||
pos.x() >= m_fieldSize.width() ||
pos.y() >= m_fieldSize.height())
{
return false; // out of field bounds
}
QPoint posChange = entity->pos().toPoint() - pos;
if (posChange.isNull()) {
return true; // no change
}
if (qAbs(posChange.x()) > 1 || qAbs(posChange.y()) > 1) {
return false; // invalid move
}
QGraphicsItem* item = itemAt(pos, QTransform());
Entity* otherEntity = qgraphicsitem_cast<Entity*>(item);
if (otherEntity) {
otherEntity->setAlive(false);
}
entity->setPos(pos);
return true;
}
我们检查新位置是否在范围内,并且是否离实体的当前位置不远。如果一切正常,我们执行移动。如果目的地格子上已经有另一个实体,我们将其标记为已死亡。如果移动成功,函数返回true。
那就结束了!我们的游戏已经准备好运行。让我们创建一些策略脚本来进行游戏。
行动时间 - 编写策略脚本
我们的第一脚本将简单地选择一个随机移动:
{
"step": function(current) {
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min)) + min;
}
return {
x: current.pos().x + getRandomInt(-1, 2),
y: current.pos().y + getRandomInt(-1, 2),
}
}
}
当然,一个更智能的策略可以击败这个脚本。你可以在代码包中找到一个更高级的脚本。首先,当它看到附*的敌人实体时,它总是试图杀死它。如果没有这样的敌人,它试图远离最*的盟友,试图填满整个棋盘。这个脚本将轻易地消灭随机移动的敌人:

当然,总有改进的空间。试着想出一个更好的策略,并编写一个可以赢得游戏的脚本。
尝试一下英雄 - 扩展游戏
有几种方法可以改进游戏实现。例如,你可以检测玩家何时获胜并显示一个弹出消息。你也可以允许任意数量的玩家。你只需要将TEAM_COUNT常量替换为Scene类中的一个新属性,并定义更多的团队颜色。你甚至可以为用户提供一个 GUI,让他们提供脚本而不是作为命令行参数传递。
脚本环境也可以得到改进。你可以提供更多的辅助函数(例如,计算两个格子之间距离的函数)来使创建脚本更容易。另一方面,你可以修改规则并减少可用的信息量,例如,每个实体只能看到一定距离内的其他实体。
如前所述,每个脚本都有方法来破坏全局对象或发出暴露的 C++对象信号,从而影响其他玩家。为了防止这种情况,你可以为每个玩家创建一个单独的QJSEngine和一组单独的代理对象,从而有效地隔离它们。
Python 脚本
Qt QML 是一个设计为 Qt 世界一部分的环境。由于并非每个人都了解或喜欢 JavaScript,我们将介绍另一种可以轻松用于为使用 Qt 创建的游戏提供脚本环境的语言。只需注意,这不会是环境的深入描述——我们只会展示可以为您自己的研究提供基础的基本知识。
用于脚本的一种流行语言是 Python。Python 有两种针对 Python 的 Qt 绑定可用:PySide2 和 PyQt。PySide2 是在 LGPL 下可用的官方绑定。PyQt 是一个在 GPL v3 和商业许可证下可用的第三方库。
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.5-dev
在我们的示例中,我们将使用 Python 3.5,但稍后的较小版本也应与我们的代码兼容。
然后,您需要告诉 qmake 将您的程序链接到库。对于 Linux,您可以使用 pkgconfig 自动完成此操作:
CONFIG += link_pkgconfig no_keywords
# adjust the version number to suit your needs
PKGCONFIG += python-3.5m
no_keywords 配置选项告诉构建系统禁用 Qt 特定的关键字(signals、slots 和 emit)。我们必须这样做,因为 Python 头文件使用 slots 标识符,这会与相同的 Qt 关键字冲突。如果您将它们写成 Q_SIGNALS、Q_SLOTS 和 Q_EMIT,您仍然可以访问 Qt 关键字。
对于 Windows,您需要手动将信息传递给编译器:
CONFIG += no_keywords
INCLUDEPATH += C:\Python35\include
LIBS += -LC:\Python35\include -lpython35
要从 Qt 应用程序中调用 Python 代码,最简单的方法是使用以下代码:
#include <Python.h>
#include <QtCore>
int main(int argc, char **argv) {
QCoreApplication app(argc, argv);
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 实现最后一个程序。创建一个新的控制台项目,并向其中添加以下类:
class QtPython : public QObject {
Q_OBJECT
public:
QtPython(QObject *parent = 0);
~QtPython();
void run(const QString &program);
private:
QVector<wchar_t> programNameBuffer;
};
实现文件应如下所示:
#include <Python.h>
//...
QtPython::QtPython(QObject *parent) : QObject(parent) {
QStringList args = qApp->arguments();
if (args.count() > 0) {
programNameBuffer.resize(args[0].count());
args[0].toWCharArray(programNameBuffer.data());
Py_SetProgramName(programNameBuffer.data());
}
Py_InitializeEx(0);
}
QtPython::~QtPython() {
Py_Finalize();
}
void QtPython::run(const QString &program) {
PyRun_SimpleString(qPrintable(program));
}
然后,添加一个 main() 函数,如下面的代码片段所示:
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QtPython python;
python.run("print('Hello from Python')");
return 0;
}
最后,打开 .pro 文件,并告诉 Qt 将其链接到 Python 库,如之前所示。
刚才发生了什么?
我们创建了一个名为 QtPython 的类,它为我们封装了 Python C API。
永远不要使用 Q 前缀来调用你的自定义类,因为这个前缀是为官方 Qt 类保留的。这是为了确保你的代码永远不会与 Qt 中添加的未来代码发生名称冲突。另一方面,Qt 前缀是用来与 Qt 的扩展类一起使用的。你可能仍然不应该使用它,但名称冲突的概率要小得多,并且影响也小于与官方类的冲突。最好想出一个特定于应用程序的前缀或使用命名空间。
类构造函数创建了一个 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:
class QtPythonValue {
public:
QtPythonValue();
QtPythonValue(const QtPythonValue &other);
QtPythonValue& operator=(const QtPythonValue &other);
QtPythonValue(int val);
QtPythonValue(const QString &str);
~QtPythonValue();
int toInt() const;
QString toString() const;
bool isNone() const;
private:
QtPythonValue(PyObject *ptr);
void incRef();
void incRef(PyObject *val);
void decRef();
PyObject *m_value;
friend class QtPython;
};
接下来,实现构造函数、赋值运算符和析构函数,如下所示:
QtPythonValue::QtPythonValue() {
incRef(Py_None);
}
QtPythonValue::QtPythonValue(const QtPythonValue &other) {
incRef(other.m_value);
}
QtPythonValue::QtPythonValue(PyObject *ptr) {
m_value = ptr;
}
QtPythonValue::QtPythonValue(const QString &str) {
m_value = PyUnicode_FromString(qPrintable(str));
}
QtPythonValue::QtPythonValue(int val) {
m_value = PyLong_FromLong(val);
}
QtPythonValue &QtPythonValue::operator=(const QtPythonValue &other) {
if(m_value == other.m_value) {
return *this;
}
decRef();
incRef(other.m_value);
return *this;
}
QtPythonValue::~QtPythonValue()
{
decRef();
}
然后,实现 incRef() 和 decRef() 函数:
void QtPythonValue::incRef(PyObject *val) {
m_value = val;
incRef();
}
void QtPythonValue::incRef() {
if(m_value) {
Py_INCREF(m_value);
}
}
void QtPythonValue::decRef() {
if(m_value) {
Py_DECREF(m_value);
}
}
接下来,实现从 QtPythonValue 到 C++ 类型的转换:
int QtPythonValue::toInt() const {
return PyLong_Check(m_value) ? PyLong_AsLong(m_value) : 0;
}
QString QtPythonValue::toString() const {
return PyUnicode_Check(m_value) ?
QString::fromUtf8(PyUnicode_AsUTF8(m_value)) : QString();
}
bool QtPythonValue::isNone() const {
return m_value == Py_None;
}
最后,让我们修改 main() 函数以测试我们的新代码:
int main(int argc, char *argv[]) {
QCoreApplication app(argc, argv);
QtPython python;
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 值的对象,它表示值的缺失。复制构造函数和赋值运算符相当标准,负责管理引用计数。然后,我们有两个构造函数——一个接受 int 值,另一个接受 QString 值。它们使用适当的 Python C API 调用来获取值的 PyObject 表示形式。请注意,这些调用已经为我们增加了引用计数,所以我们不需要自己操作。
代码以一个析构函数结束,该函数减少引用计数,并提供了三个将 QtPythonValue 安全转换为适当的 Qt/C++ 类型的方法。
英雄尝试 - 实现剩余的转换
现在,您应该能够实现其他构造函数和 QtPythonValue 的转换,这些转换操作 float、bool,甚至是 QDate 和 QTime 类型。尝试自己实现它们。如果需要,查看 Python 文档以找到您应该使用的适当调用。
Python 3.5 的文档可在网上找到,链接为 docs.python.org/3.5/。如果您安装了不同的 Python 版本,您可以在同一网站上找到您版本的相关文档。
我们将提供一个如何将 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(auto 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 并生成元组的 QtPythonValue 构造函数。
我们已经编写了相当多的代码,但到目前为止,我们还没有办法将程序中的任何数据绑定到 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[])
{
QCoreApplication app(argc, argv);
QtPython python;
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 代码?
-
QJSValue::call() -
QJSEngine::evaluate() -
QJSEngine::fromScriptValue()
Q2. 什么类作为 JS 引擎和 C++ 之间交换数据的桥梁?
-
QObject -
QJSValue -
QVariant
Q3. 如果你想将一个 C++ 对象暴露给脚本,这个对象必须从哪个类派生?
-
QObject -
QJSValue -
QGraphicsItem
Q4. 以下哪种类型的函数对 JavaScript 代码不可用?
-
信号
-
Q_INVOKABLE方法 -
槽
-
全局函数
Q5. PyObject 实例何时被销毁?
-
当其值设置为
Py_None时 -
当其内部引用计数器降至 0 时
-
当相应的
QtPythonValue被销毁时
摘要
在本章中,你了解到为你的游戏提供脚本环境可以开启新的可能性。使用脚本语言实现功能通常比使用 C++ 的完整编写-编译-测试周期要快,你甚至可以利用那些不了解你游戏引擎内部结构的用户的技能和创造力,使你的游戏变得更好、功能更丰富。你学习了如何使用 QJSEngine,它通过将 Qt 对象暴露给 JavaScript 并实现跨语言信号-槽连接,将 C++ 和 JavaScript 世界融合在一起。你还学习了使用 Python 的脚本基础。还有其他脚本语言可用(例如 Lua),并且许多脚本语言都可以与 Qt 一起使用。利用本章获得的经验,你应该甚至能够将其他脚本环境带到你的程序中,因为大多数可嵌入的解释器都提供了类似于 Python 的方法。
在下一章中,你将介绍 Qt Quick——一个用于创建流畅和动态用户界面的库。它可能听起来与本章无关,但 Qt Quick 基于 Qt QML。实际上,任何 Qt Quick 应用都包含一个 QJSEngine 对象,该对象执行应用程序的 JavaScript 代码。熟悉这个系统将帮助你理解这类应用程序是如何工作的。你还将能够在需要从 Qt Quick 访问 C++ 对象以及反之亦然时应用你在这里学到的技能。欢迎来到 Qt Quick 的世界。
第十一章:Qt Quick 简介
在本章中,您将了解到一种名为 Qt Quick 的技术,它允许我们实现具有众多视觉效果的分辨率无关的用户界面,包括动画和效果,这些都可以与实现应用程序逻辑的常规 Qt 代码相结合。您将学习构成 Qt Quick 基础的 QML 声明性语言的基础知识。您将创建一个简单的 Qt Quick 应用程序,并看到声明性方法提供的优势。
本章涵盖的主要主题包括这些:
-
QML 基础
-
Qt 模块概述
-
使用 Qt Quick 设计器
-
利用 Qt Quick 模块
-
属性绑定和信号处理
-
Qt Quick 和 C++
-
状态和转换
声明性 UI 编程
虽然技术上可以使用 C++ 代码使用 Qt Quick,但此模块附带一种称为 QML(Qt 模型语言)的专用编程语言。QML 是一种易于阅读和理解的表达性语言,它将世界描述为组件的层次结构,这些组件相互交互并关联。它使用类似 JSON 的语法,并允许我们使用命令式 JavaScript 表达式以及动态属性绑定。那么,声明性语言到底是什么呢?
声明性编程是编程范式之一,它规定程序描述计算的逻辑,而不指定如何获得此结果。与将逻辑表达为形成算法的显式步骤列表、直接修改中间程序状态的命令式编程相反,声明性方法侧重于操作最终结果应该是什么。
动手时间 – 创建第一个项目
让我们创建一个项目,以便更好地理解 QML 是什么。在 Qt Creator 中,选择文件,然后在主菜单中选择新建文件或项目。在左侧列中选择应用程序,并选择 Qt Quick 应用程序 - 空模板。将项目命名为 calculator 并完成向导的其余部分。
Qt Creator 创建了一个示例应用程序,显示一个空窗口。让我们检查项目文件。第一个文件是常规的 main.cpp:
#include <QGuiApplication>
#include <QQmlApplicationEngine>
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
QQmlApplicationEngine engine;
engine.load(QUrl(QStringLiteral("qrc:/main.qml")));
if (engine.rootObjects().isEmpty())
return -1;
return app.exec();
}
此代码仅创建应用程序对象,实例化 QML 引擎,并请求它从资源中加载 main.qml 文件。如果发生错误,rootObjects() 将返回一个空列表,应用程序将终止。如果 QML 文件成功加载,应用程序将进入主事件循环。
*.qrc文件是一个资源文件。从第三章,Qt GUI 编程中你应该熟悉资源文件的概念。基本上,它包含项目执行所需的任意项目文件列表。在编译期间,这些文件的内容被嵌入到可执行文件中。然后你可以通过指定一个虚拟文件名来在运行时检索内容,例如前述代码中的qrc:/main.qml。你可以进一步展开项目树的Resources部分,以查看添加到资源文件中的所有文件。
在示例项目中,qml.qrc引用了一个名为main.qml的 QML 文件。如果你在项目树中看不到它,请展开Resources、qml.qrc,然后是/部分。main.qml文件是加载到引擎中的顶级 QML 文件。让我们看看它:
import QtQuick 2.9
import QtQuick.Window 2.2
Window {
visible: true
width: 640
height: 480
title: qsTr("Hello World")
}
此文件声明了在应用程序开始时应创建哪些对象。因为它使用了 Qt 提供的某些 QML 类型,所以在文件的顶部包含两个import指令。每个import指令包含导入模块的名称和版本。在这个例子中,import QtQuick.Window 2.2使我们能够使用此模块提供的Window QML 类型。
文件的其余部分是引擎应创建的对象的声明。Window { ... }构造告诉 QML 创建一个新的Window类型的对象。此部分内的代码为此对象的属性赋值。我们显式地为窗口对象的visible、width和height属性分配了一个常量。qsTr()函数是翻译函数,就像 C++代码中的tr()一样。它默认返回传递的字符串而不做任何更改。title属性将包含评估传递的表达式的结果。
行动时间 - 编辑 QML
让我们在窗口中添加一些内容。使用以下代码编辑main.qml文件:
import QtQuick 2.9
import QtQuick.Window 2.2
import QtQuick.Controls 2.2
Window {
visible: true
width: 640
height: 480
title: qsTr("Hello World")
TextField {
text: "Edit me"
anchors {
top: parent.top
left: parent.left
}
}
Label {
text: "Hello world"
anchors {
bottom: parent.bottom
left: parent.left
}
}
}
当你运行项目时,你将在窗口中看到一个文本字段和一个标签:

刚才发生了什么?
首先,我们添加了一个导入语句,使QtQuick.Controls模块在当前作用域中可用。如果你不确定使用哪个版本,请调用 Qt Creator 的代码补全并使用最新版本。由于新的导入,我们现在可以在我们的 QML 文件中使用TextField和Label QML 类型。
接下来,我们声明了顶级Window对象的两个子元素。QML 对象形成一个父子关系,类似于 C++中的QObject。然而,你不需要显式地为项目分配父元素。相反,你可以在其父元素的声明中声明对象,QML 将自动确保这种关系。在我们的例子中,TextField { ... }部分告诉 QML 创建一个新的TextField类型的 QML 对象。
由于这个声明位于Window { ... }声明内,TextField对象将以Window对象为其父对象。对Label对象也是如此。如果需要,您可以在单个文件中创建多个嵌套级别。您可以使用parent属性来访问当前项目的父项目。
在声明新对象后,我们将在其声明内为其属性分配值。text属性是自解释的——它包含在 UI 中显示的文本。请注意,TextField对象允许用户编辑文本。当在 UI 中编辑文本时,对象的text属性将反映新值。
最后,我们为anchors 属性组分配值以按我们的喜好定位项目。我们将文本字段放在窗口的左上角,并将标签放在左下角。这一步需要更详细的解释。
属性组
在我们讨论锚点之前,让我们先谈谈属性组的一般概念。这是在 QML 中引入的新概念。当存在多个具有相似目的的属性时,会使用属性组。例如,Label类型有几个与字体相关的属性。它们可以实施为单独的属性;考虑以下示例:
Label {
// this code does not work
fontFamily: "Helvetica"
fontSize: 12
fontItalic: true
}
然而,这样的重复代码难以阅读。幸运的是,字体属性被实现为一个属性组,因此您可以使用分组符号语法来设置它们:
Label {
font {
family: "Helvetica"
pointSize: 12
italic: true
}
}
这段代码更简洁!请注意,在font之后没有冒号字符,因此您可以知道这是一个属性组赋值。
此外,如果您只需要设置组中的一个子属性,您可以使用点符号语法:
Label {
font.pointSize: 12
}
点符号也用于在文档中引用子属性。请注意,如果您需要设置多个子属性,应首选分组符号。
关于属性组,你需要了解的就是这些。除了font之外,你还可以在 QML 的一些类型中找到许多其他属性组,例如border、easing和anchors。
锚点
锚点允许您通过将某些对象的某些点附着到另一个对象的点上来管理项目几何形状。这些点被称为锚线。以下图显示了每个 Qt Quick 项目可用的锚线:

你可以建立锚线之间的绑定来管理项目的相对位置。对于每个锚线,都有一个属性返回该锚线的当前坐标。例如,left属性返回项目左侧边框的x坐标,而top属性返回其顶部边框的y坐标。接下来,每个对象都包含anchors属性组,允许你设置该项目的锚线坐标。例如,anchors.left属性可以用来请求对象的左侧边框位置。你可以使用这两种类型的属性一起指定对象的相对位置:
anchors.top: otherObject.bottom
此代码声明对象的顶部锚线必须绑定到另一个对象的底部锚线。也可以通过属性(如anchors.topMargin)指定此类绑定的边距。
anchors.fill属性是将top、bottom、left和right锚点绑定到指定对象的相应锚线上的快捷方式。因此,项目将具有与其他对象相同的几何形状。以下代码片段通常用于将项目扩展到其父对象的整个区域:
anchors.fill: parent
行动时间 - 相对定位项目
在我们之前的例子中,我们使用了以下代码来定位标签:
anchors {
bottom: parent.bottom
left: parent.left
}
现在你应该能够理解这段代码。parent属性返回父 QML 对象的引用。在我们的例子中,它是窗口。parent.bottom表达式返回父对象的底部锚线的y坐标。通过将此表达式分配给anchors.bottom属性,我们确保标签的底部锚线与窗口的底部锚线保持在同一位置。x坐标以类似的方式受到限制。
现在,让我们看看我们是否可以将标签定位在文本框下方。为了做到这一点,我们需要将标签的anchors.top属性绑定到文本框的底部锚线。然而,我们目前无法从标签内部访问文本框。我们可以通过定义文本框的id属性来解决这个问题:
TextField {
id: textField
text: "Edit me"
anchors {
top: parent.top
left: parent.left
}
}
Label {
text: "Hello world"
anchors {
top: textField.bottom
topMargin: 20
left: parent.left
}
}
设置一个 ID 类似于将对象分配给一个变量。现在我们可以使用textField变量来引用我们的TextField对象。标签现在位于文本框下方 20 像素处。
QML 类型、组件和文档
QML 引入了一些新的概念,你应该熟悉。QML 类型是一个类似于 C++类的概念。QML 中的任何值或对象都应该有某种类型,并且应以某种方式暴露给 JavaScript 代码。QML 类型主要有两种:
-
基本类型是包含具体值且不引用任何其他对象的类型,例如
string或point -
对象类型是可以用来创建具有特定功能一致接口的对象的类型
基本的 QML 类型类似于 C++ 原始类型和数据结构,例如 QPoint。对象类型更接*于小部件类,例如 QLineEdit,但它们不一定与 GUI 相关。
Qt 提供了大量的 QML 类型。我们已经在之前的示例中使用了 Window、TextField 和 Label 类型。你还可以创建具有独特功能和行为的自定义 QML 类型。创建 QML 类型最简单的方法是将一个新 .qml 文件(以大写字母命名)添加到项目中。基本文件名定义了创建的 QML 类型的名称。例如,MyTextField.qml 文件将声明一个新的 MyTextField QML 类型。
任何完整且有效的 QML 代码都称为 文档。任何有效的 QML 文件都包含一个文档。从任何来源(例如,通过网络)加载文档也是可能的。组件是加载到 QML 引擎中的文档。
它是如何工作的?
Qt Quick 基础设施隐藏了大部分实现细节,让开发者能够保持应用程序代码的整洁。然而,始终了解正在发生的事情是很重要的。
QML 引擎是一个理解 QML 代码并执行使其工作的必要操作的 C++ 类。特别是,QML 引擎负责根据请求的层次结构创建对象,为属性分配值,并在事件发生时执行事件处理器。
虽然 QML 语言本身与 JavaScript 相去甚远,但它允许你使用任何 JavaScript 表达式和代码块来计算值和处理事件。这意味着 QML 引擎必须能够执行 JavaScript。在底层,实现使用了一个非常快速的 JavaScript 引擎,所以你通常不需要担心 JavaScript 代码的性能。
JavaScript 代码应该能够与 QML 对象交互,因此每个 QML 对象都作为具有相应属性和方法的 JavaScript 对象公开。这种集成使用了我们在第十章“脚本”中学到的相同机制。在 C++ 代码中,你可以对嵌入到 QML 引擎中的对象进行一些控制,甚至可以创建新的对象。我们将在本章的后面回到这个话题。
虽然 QML 是一种通用语言,但 Qt Quick 是一个基于 QML 的模块,专注于用户界面。它提供了一个二维的硬件加速画布,其中包含一系列相互连接的项目。与 Qt Widgets 不同,Qt Quick 被设计成能够高效地支持视觉效果和动画,因此你可以使用其功能而不会显著降低性能。
Qt Quick 视图不是基于网页浏览器引擎的。浏览器通常比较重,尤其是对于移动设备。但是,当你需要时,可以通过在 QML 文件中添加 WebView 或 WebEngine 对象来显式使用网页引擎。
行动时间 - 属性绑定
QML 比简单的 JSON 强大得多。你不需要为属性指定一个显式的值,你可以使用任意 JavaScript 表达式,该表达式将被自动评估并分配给属性。例如,以下代码将在标签中显示 "ab":
Label {
text: "a" + "b"
//...
}
你还可以引用文件中其他对象的属性。正如我们之前看到的,你可以使用 textEdit 变量来设置标签的相对位置。这是属性绑定的一个例子。如果 textField.bottom 表达式的值因某种原因而改变,标签的 anchors.top 属性将自动更新为新值。QML 允许你为每个属性使用相同的机制。为了使效果更明显,让我们将一个表达式分配给标签的文本属性:
Label {
text: "Your input: " + textField.text
//...
}
现在,标签的文本将根据这个表达式进行更改。当你更改输入字段中的文本时,标签的文本将自动更新!:

属性绑定与常规值赋值不同,它将属性的值绑定到提供的 JavaScript 表达式的值。每当表达式的值发生变化时,属性将反映这种变化在其自己的值中。请注意,QML 文档中语句的顺序并不重要,因为你在声明属性之间的关系。
这个例子展示了声明式方法的一个优点。我们不必连接信号或明确确定何时应该更改文本。我们只需 声明 文本应该受输入字段的影响,QML 引擎将自动强制执行这种关系。
如果表达式复杂,你可以用多行文本块替换它,该文本块作为一个函数工作:
text: {
var x = textField.text;
return "(" + x + ")";
}
你也可以在任何 QML 对象声明中声明和使用一个命名 JavaScript 函数:
Label {
function calculateText() {
var x = textField.text;
return "(" + x + ")";
}
text: calculateText()
//...
}
自动属性更新的限制
QML 尽力确定函数值何时可能发生变化,但它并非万能。对于我们的最后一个函数,它可以很容易地确定函数结果取决于 textField.text 属性的值,因此如果该值发生变化,它将重新评估绑定。然而,在某些情况下,它无法知道函数在下一次调用时可能返回不同的值,在这种情况下,该语句将不会被重新评估。考虑以下属性绑定:
Label {
function colorByTime() {
var d = new Date();
var seconds = d.getSeconds();
if(seconds < 15) return "red";
if(seconds < 30) return "green";
if(seconds < 45) return "blue";
return "purple";
}
color: colorByTime()
//...
}
颜色将在应用程序开始时设置,但将无法正常工作。QML 只会在对象初始化时调用 colorByTime() 函数一次,并且它将永远不会再次调用它。这是因为它不知道这个函数必须调用多少次。我们将在第十二章 自定义 Qt Quick 中看到如何克服这个问题。
Qt 提供的 QML 类型概述
在我们继续开发我们的 QML 应用程序之前,让我们看看内置库的功能。这将使我们能够选择适合任务的正确模块。Qt 提供了许多有用的 QML 类型。在本节中,我们将概述 Qt 5.9 中可用的最有用的模块。
以下模块对于构建用户界面非常重要:
-
QtQuick基础模块提供了与绘图、事件处理、元素定位、转换以及许多其他有用类型相关的功能 -
QtQuick.Controls提供了用户界面的基本控件,例如按钮和输入字段 -
QtQuick.Dialogs包含文件对话框、颜色对话框和消息框 -
QtQuick.Extras提供了额外的控件,例如旋钮、开关和仪表 -
QtQuick.Window启用窗口管理 -
QtQuick.Layouts提供了在屏幕上自动定位对象的布局 -
UIComponents提供了标签控件、进度条和开关类型 -
QtWebView允许您将网页内容添加到应用程序中 -
QtWebEngine提供了更复杂的网页浏览器功能
如果您想实现丰富的图形,以下模块可能会有所帮助:
-
QtCanvas3D提供了一个用于 3D 渲染的画布 -
Qt3D模块提供了访问支持 2D 和 3D 渲染的实时仿真系统的权限 -
QtCharts允许您创建复杂的图表 -
QtDataVisualization可以用于构建数据集的 3D 可视化 -
QtQuick.Particles允许您添加粒子效果 -
QtGraphicalEffects可以将图形效果(如模糊或阴影)应用于其他 Qt Quick 对象
Qt 提供了在移动设备上通常所需的大量功能:
-
QtBluetooth支持通过蓝牙与其他设备的基本通信 -
QtLocation允许您显示地图和查找路线 -
QtPositioning提供了当前位置信息 -
QtNfc允许您利用 NFC 硬件 -
QtPurchasing实现了应用内购买 -
QtSensors提供了对板载传感器(如加速度计或陀螺仪)的访问 -
QtQuick.VirtualKeyboard提供了一个屏幕键盘的实现
最后,有两个模块提供了多媒体功能:
-
QtMultimedia提供了对音频和视频播放、音频录制、摄像头和收音机的访问 -
QtAudioEngine实现了 3D 定位音频播放
还有许多我们没有提到的 QML 模块。您可以在所有 QML 模块文档页面上找到完整的列表。请注意,某些模块不在 LGPL 许可下提供。
Qt Quick 设计器
我们可以使用 QML 轻松创建对象层次结构。如果我们需要几个输入框或按钮,我们只需在代码中添加一些块,就像我们在前面的例子中添加TextField和Label组件一样,我们的更改将出现在窗口中。然而,在处理复杂表单时,有时很难正确定位对象。与其尝试不同的anchors并重新启动应用程序,不如使用可视表单编辑器在制作更改时查看更改。
动手实践 - 向项目中添加表单
在 Qt Creator 的项目树中找到qml.qrc文件,并在其上下文菜单中调用“添加新...”选项。从 Qt 部分,选择“QtQuick UI 文件模板”。在组件名称字段中输入Calculator。组件表单名称字段将自动设置为CalculatorForm。完成向导。
在我们的项目中将出现两个新文件。CalculatorForm.ui.qml文件是可以在表单编辑器中编辑的表单文件。Calculator.qml文件是一个常规的 QML 文件,可以手动编辑以实现表单的行为。这些文件中的每一个都引入了一个新的 QML 类型。CalculatorForm QML 类型立即在生成的Calculator.qml文件中使用:
import QtQuick 2.4
CalculatorForm {
}
接下来,我们需要编辑main.qml文件,向窗口添加一个Calculator对象:
import QtQuick 2.9
import QtQuick.Window 2.2
import QtQuick.Controls 2.2
Window {
visible: true
width: 640
height: 480
title: qsTr("Calculator")
Calculator {
anchors.fill: parent
}
}
QML 组件在某些方面类似于 C++类。一个 QML 组件封装了一个对象树,这样你就可以在不了解组件确切内容的情况下使用它。当应用程序启动时,main.qml文件将被加载到引擎中,因此将创建Window和Calculator对象。Calculator对象反过来将包含一个CalculatorForm对象。CalculatorForm对象将包含我们在表单编辑器中稍后添加的项目。
表单编辑器文件
当我们使用 Qt Widgets 表单编辑器工作时,你可能已经注意到,小部件表单是一个在编译期间转换为 C++类的 XML 文件。这并不适用于 Qt Quick Designer。事实上,此表单编辑器生成的文件是完全有效的 QML 文件,它们直接包含在项目中。然而,表单编辑器文件有一个特殊的扩展名(.ui.qml),并且有一些人工限制来保护你免于做错事。
ui.qml文件应只包含在表单编辑器中可见的内容。你不需要手动编辑这些文件。无法从这些文件中调用函数或执行 JavaScript 代码。相反,你应该在单独的 QML 文件中实现任何逻辑,该文件将表单作为组件使用。
如果你好奇ui.qml文件的内容,可以点击位于表单编辑器中央区域右侧的文本编辑器标签。
表单编辑器界面
当你打开.ui.qml文件时,Qt Creator 将进入设计模式并打开 Qt Quick Designer 界面:

我们已经突出显示了界面的以下重要部分:
-
主区域(1)包含文档内容的可视化。您可以通过点击主区域右侧边界的文本编辑器标签来查看和编辑表单的 QML 代码,而无需退出表单编辑器。主区域底部显示组件的状态列表。
-
库面板(2)显示可用的 QML 对象类型,并允许您通过将它们拖动到导航器或主区域来创建新对象。导入标签包含可用 QML 模块的列表,并允许您导出模块并访问更多 QML 类型。
-
导航器面板(3)显示现有对象及其名称的层次结构。名称右侧的按钮允许您将对象作为公共属性导出并在表单编辑器中切换其可见性。
-
连接面板(4)提供了连接信号、更改属性绑定和管理表单公共属性的能力。
-
属性面板(5)允许您查看和编辑所选对象的属性。
我们现在将使用表单编辑器创建一个简单的计算器应用程序。我们的表单将包含两个用于操作数的输入框,两个用于选择操作的单选按钮,一个用于显示结果的标签,以及一个用于重置一切到原始状态的按钮。
操作时间 – 添加导入
默认对象调色板包含由QtQuick模块提供的非常少的类型集。要访问更丰富的控件集,我们需要在我们的文档中添加一个import指令。为此,请定位窗口左上角的库面板并转到其导入标签。接下来,点击添加导入,并在下拉列表中选择 QtQuick.Controls 2.2。选定的导入将出现在标签中。您可以通过点击导入左侧的×按钮来删除它。请注意,您不能删除默认导入。
使用表单编辑器添加导入将会在.ui.qml文件中添加import QtQuick.Controls 2.2指令。您可以将主区域切换到文本编辑器模式以查看此更改。
现在,您可以切换回库面板的 QML 类型标签。调色板将包含导入模块提供的控件。
操作时间 – 向表单添加项目
在库面板的 Qt Quick - Controls 2 部分中找到文本字段类型,并将其拖动到主区域。将创建一个新的文本字段。我们还需要从同一部分获取单选按钮、标签和按钮类型。将它们拖动到表单中,并按所示排列:

接下来,您需要选择每个元素并编辑其属性。在主区域或导航器中单击第一个文本字段。主区域中对象周围的蓝色框架将指示该对象已被选中。现在您可以使用属性编辑器查看和编辑选中元素的属性。首先,我们想要设置一个id属性,该属性将用于在代码中引用对象。将文本编辑的id属性设置为argument1和argument2。在属性编辑器中的TextField选项卡下找到Text属性。将两个文本字段的Text属性都设置为0。更改后的文本将立即在主区域中显示。
将单选按钮的id设置为operationAdd和operationMultiply。将它们的文本设置为+和×。通过在属性编辑器中切换相应的复选框,将operationAdd按钮的checked属性设置为true。
第一个标签将用于静态显示=符号。将其id设置为equalSign,text设置为=。第二个标签实际上将显示结果。将其id设置为result。我们稍后会处理text属性。
该按钮将重置计算器到原始状态。将其id设置为reset,text设置为Reset。
您现在可以运行应用程序。您会看到控件在窗口中显示,但它们相对于窗口大小没有重新定位。它们始终保持在相同的位置。如果您检查CalculatorForm.ui.qml的文本内容,您会看到表编辑器为每个元素设置了x和y属性。为了创建一个更响应式的表单,我们需要利用anchors属性。
动作时间 - 编辑锚点
让我们看看如何在表编辑器中编辑锚点,并实时查看结果。选择argument1文本字段,切换到属性面板中间部分的布局选项卡。该选项卡包含“锚点”文本,后面是一组按钮,用于此项目的所有锚线。您可以将鼠标悬停在按钮上以查看其工具提示。单击第一个按钮,
将锚点项锚定到顶部。按钮下方将出现一组新的控件,允许您配置此锚点。
首先,您可以选择目标对象,即包含用作参考的锚线的对象。接下来,您可以选择参考锚线和当前对象的锚线之间的边距。边距右侧有按钮,允许您选择要作为参考的目标的哪个锚线。例如,如果您选择底部线,我们的文本字段将保持相对于表底部的位置。
将文本字段的顶部行锚定到父元素的顶部行,并将边距设置为 20。接下来,将水*中心线锚定到父元素,边距为 0。属性编辑器应如下所示:

您还可以验证这些设置的 QML 表示:
TextField {
id: a
text: qsTr("0")
anchors.horizontalCenter: parent.horizontalCenter
anchors.top: parent.top
anchors.topMargin: 20
}
如果您使用鼠标拖动文本字段而不是设置锚点,表单编辑器将设置 x 和 y 属性以根据您的操作定位元素。如果您之后编辑项目的锚点,x 和 y 属性可能仍然被设置,但它们的效果将被锚点效果覆盖。
让我们重复这个过程,针对 operationAdd 单选按钮。首先,我们需要调整其相对于表单横向中心的水*位置。选择单选按钮,点击右侧的
锚点项目,将目标设置为 parent,然后点击
将锚点设置为目标按钮右侧的横向中心。设置边距为 10。这将使我们能够将第二个单选按钮定位在横向中心的右侧 10 点处,并且单选按钮之间的空间将是 20 点。
现在,关于顶部锚点?我们可以将其附加到父元素上,并设置看起来很漂亮的边距。然而,我们最终想要的是第一个文本字段和第一个单选按钮之间的特定垂直边距。我们可以轻松地做到这一点。
为 operationAdd 单选按钮启用顶部锚点,在目标下拉列表中选择 argument1,点击
锚点至目标按钮右侧的边距字段底部,并在边距字段中输入 20。现在单选按钮已锚定到其上方的文本字段。即使我们更改文本字段的高度,元素之间的垂直边距也将保持不变。您可以运行应用程序并验证 argument1 和 operationAdd 元素现在对窗口大小变化做出响应。
现在,我们只需要对剩余的对象重复此过程。然而,这相当繁琐。在更大的表单中会更不方便。对这样的表单进行更改也会很麻烦。例如,要更改字段的顺序,您需要仔细编辑相关对象的锚点。虽然锚点在简单情况下很好,但对于大型表单,使用更自动化的方法会更好。幸运的是,Qt Quick 提供了布局来实现这个目的。
行动时间 - 将布局应用于项目
在我们将布局应用于对象之前,删除我们创建的锚点。为此,选择每个元素,然后点击“锚点”文本下的按钮取消选中它们。按钮下面的锚点属性将消失。布局现在可以定位对象。
首先,将 QtQuick.Layouts 1.3 模块导入表单,就像我们之前导入 QtQuick.Controls 一样。在调色板中找到 Qt Quick - 布局部分,并检查可用的布局:
-
列布局将垂直排列其子元素。
-
行布局将水*排列其子元素。
-
网格布局将垂直和水*排列其子元素。
-
栈布局将只显示其子项中的一个,并隐藏其余的。
布局对对象的层次结构很敏感。让我们使用导航器而不是主区域来管理我们的项目。这将使我们能够更清楚地看到项目之间的父子关系。首先,将行布局拖动到导航器中的根项目上。将一个新的rowLayout对象添加为根对象的子项。接下来,将导航器中的operationAdd和operationMultiply对象拖动到rowLayout上。单选按钮现在是行布局的子项,并且它们自动并排定位。
现在,将列布局拖动到根对象。在导航器中选择根对象的其余子项,包括rowLayout,并将它们拖动到columnLayout对象。如果项目最终顺序错误,请使用导航器顶部的向上移动和向下移动按钮来正确排列项目。您应该得到以下层次结构:

columnLayout对象将自动定位其子项,但如何定位对象本身呢?我们应该使用锚点来做到这一点。选择columnLayout,在属性编辑器中切换到布局选项卡并点击
填充父项按钮。这将自动创建 4 个锚点绑定并将columnLayout扩展以填充表单。
项目现在已自动定位,但它们被绑定到窗口的左侧边界。让我们将它们对齐到中间。选择第一个文本字段并切换到布局选项卡。由于对象现在处于布局中,锚点设置被布局理解的设置所取代。对齐属性定义了项目如何在可用空间内定位。在第一个下拉列表中选择AlignHCenter。为columnLayout的每个直接子项重复此过程。
您现在可以运行应用程序并查看它如何对窗口大小的变化做出反应:

表单已准备好。现在让我们进行计算。
行动时间 - 将表达式分配给属性
正如您已经看到的,将常量文本分配给标签很容易。然而,您也可以在表单编辑器中为任何属性分配动态表达式。为此,选择result标签并将鼠标悬停在文本属性输入字段左侧的部分圆圈上。当圆圈变成箭头时,点击它并在菜单中选择设置绑定。在绑定编辑器中输入argument1.text + argument2.text并确认更改。
如果现在运行应用程序,您将看到result标签将始终显示用户在字段中输入的字符串的连接。这是因为argument1.text和argument2.text属性具有string类型,所以+操作执行连接。
如果您需要应用简单的绑定,此功能非常有用。然而,在我们的情况下,这并不足够,因为我们需要将字符串转换为数字并选择用户请求的算术运算。在表单编辑器中使用函数是不允许的,因此我们无法在这里实现这种复杂的逻辑。我们需要在Calculator.qml文件中完成它。这种限制将帮助我们分离视图及其背后的逻辑。
动手时间 - 将项目公开为属性
组件的子组件默认情况下不可从外部访问。这意味着Calculator.qml无法访问表单的输入字段或单选按钮。为了实现计算器的逻辑,我们需要访问这些对象,因此让我们将它们作为公共属性公开。在导航器中选择argument1文本字段,然后点击
切换是否将此项目作为根项按钮右侧对象 ID 的别名属性导出。点击按钮后,其图标将改变以指示项目已导出。现在我们可以在Calculator.qml中使用argument1公共属性来访问输入字段对象。
为argument1、argument2、operationAdd、operationMultiply和result对象启用公共属性。其余对象将保持隐藏,作为表单的实现细节。
现在转到Calculator.qml文件,并使用公开属性来实现计算器逻辑:
CalculatorForm {
result.text: {
var value1 = parseFloat(argument1.text);
var value2 = parseFloat(argument2.text);
if(operationMultiply.checked) {
return value1 * value2;
} else {
return value1 + value2;
}
}
}
刚才发生了什么?
由于我们已将对象作为属性导出,我们可以从表单外部通过 ID 访问它们。在这段代码中,我们将result对象的text属性绑定到括号内代码块的返回值。我们使用argument1.text和argument2.text来访问输入字段的当前文本。我们还使用operationMultiply.checked来查看用户是否选中了operationMultiply单选按钮。其余部分只是简单的 JavaScript 代码。
运行应用程序并观察当用户与表单交互时,结果标签如何自动显示结果。
动手时间 - 创建事件处理器
让我们实现最后一点功能。当用户点击重置按钮时,我们应该更改表单的值。回到表单编辑器,在导航器或主区域中右键单击reset按钮。选择添加新信号处理器。Qt Creator 将导航到相应的实现文件(Calculator.qml)并显示“实现信号处理器”对话框。在下拉列表中选择clicked信号,然后点击“确定”按钮以确认操作。此操作将执行以下两项操作:
-
reset按钮将自动导出为公共属性,就像我们手动为其他控件做的那样。 -
Qt Creator 将在
Calculator.qml文件中为新的信号处理器创建模板。
让我们将我们的实现添加到自动生成的块中:
reset.onClicked: {
argument1.text = "0";
argument2.text = "0";
operationAdd.checked = true;
}
当按钮被点击时,这段代码将被执行。文本字段将被设置为 0,并且 operationAdd 单选按钮将被选中。operationMultiply 单选按钮将自动取消选中。
我们的计算器现在完全工作!我们使用了声明式方法来实现一个看起来很好看且响应迅速的应用程序。
Qt 快速开发与 C++
虽然 QML 有很多内置的功能可用,但它几乎永远不够用。当你开发一个真实的应用程序时,它总是需要一些独特的功能,而这些功能在 Qt 提供的 QML 模块中是不可用的。C++ Qt 类功能更强大,第三方 C++ 库也是一个选项。然而,C++ 世界被 QML 引擎的限制与我们的 QML 应用程序隔离开来。让我们立即打破这个界限。
从 QML 访问 C++ 对象
假设我们想在 C++ 中执行一个复杂的计算,并从我们的 QML 计算器中访问它。我们将选择阶乘作为这个项目的功能。
QML 引擎非常快,所以你很可能会直接在 JavaScript 中计算阶乘而不会出现性能问题。我们在这里只是用它作为一个简单的例子。
我们的目标是将我们的 C++ 类注入到 QML 引擎中,作为一个 JavaScript 对象,这样我们就可以在我们的 QML 文件中使用它。我们将按照我们在 第十章,脚本 中所做的那样来做。main 函数创建了一个继承自 QJSEngine 的 QQmlApplicationEngine 对象,因此我们可以访问从那一章中已经熟悉的 API。在这里,我们将仅展示如何将此知识应用到我们的应用程序中,而不会深入细节。
进入编辑模式,在项目树中右键单击项目,并选择“添加新项”。选择 C++ 类模板,输入 AdvancedCalculator 作为类名,并在基类下拉列表中选择 QObject。
在生成的 advancedcalculator.h 文件中声明可调用的 factorial 函数:
Q_INVOKABLE double factorial(int argument);
我们可以使用以下代码来实现这个函数:
double AdvancedCalculator::factorial(int argument) {
if (argument < 0) {
return std::numeric_limits<double>::quiet_NaN();
}
if (argument > 180) {
return std::numeric_limits<double>::infinity();
}
double r = 1.0;
for(int i = 2; i <= argument; ++i) {
r *= i;
}
return r;
}
我们保护实现以防止输入过大,因为 double 无法容纳结果值。我们还在无效输入时返回 NaN。
接下来,我们需要创建这个类的实例并将其导入 QML 引擎。我们在 main() 中这样做:
engine.globalObject().setProperty("advancedCalculator",
engine.newQObject(new AdvancedCalculator));
return app.exec();
我们的对象现在作为 advancedCalculator 全局变量可用。现在我们需要在 QML 文件中使用这个变量。打开表单编辑器,并将第三个单选按钮添加到 rowLayout 项目中。将单选按钮的 id 设置为 operationFactorial 并将文本设置为 !。将这个单选按钮导出为一个公共属性,这样我们就可以从外部访问它。接下来,让我们调整 Calculator.qml 文件中的 result.text 属性绑定:
result.text: {
var value1 = parseFloat(argument1.text);
var value2 = parseFloat(argument2.text);
if(operationMultiply.checked) {
return value1 * value2;
} else if (operationFactorial.checked) {
return advancedCalculator.factorial(value1);
} else {
return value1 + value2;
}
}
如果勾选了operationFactorial单选按钮,此代码将调用advancedCalculator变量的factorial()方法,并将其作为结果返回。用户将看到它作为result标签的文本。当选择阶乘操作时,第二个文本字段将不被使用。我们将在本章后面对此进行处理。
关于将 C++ API 暴露给 JavaScript 的更多信息,请参阅第十章,脚本。其中描述的大部分技术也适用于 QML 引擎。
我们将一个 C++对象暴露为 JavaScript 对象,该对象可以从 QML 引擎中访问。然而,它不是一个 QML 对象,因此您不能将其包含在 QML 对象层次结构中,也不能将属性绑定应用于以这种方式创建的对象的属性。可以创建一个 C++类,使其作为一个完全功能的 QML 类型工作,从而实现更强大的 C++和 QML 集成。我们将在第十二章,Qt Quick 中的自定义中展示这种方法。
另一种将我们的AdvancedCalculator类暴露给 JavaScript 的方法是,而不是将其添加到全局对象中,我们可以使用qmlRegisterSingletonType()函数将其注册为 QML 模块系统中的单例对象:
qmlRegisterSingletonType("CalculatorApp", 1, 0, "AdvancedCalculator",
[](QQmlEngine *engine, QJSEngine *scriptEngine) -> QJSValue {
Q_UNUSED(scriptEngine);
return engine->newQObject(new AdvancedCalculator);
});
QQmlApplicationEngine engine;
我们将 QML 模块名称、主版本号和次版本号以及单例名称传递给此函数。您可以选择这些值。最后一个参数是一个回调函数,当此单例对象在 JS 引擎中首次被访问时将被调用。
QML 代码也需要稍作调整。首先,将我们的新 QML 模块导入作用域中:
import CalculatorApp 1.0
现在,您只需通过名称访问单例即可:
return AdvancedCalculator.factorial(value1);
当此行首次执行时,Qt 将调用我们的 C++回调并创建单例对象。对于后续调用,将使用相同的对象。
从 C++访问 QML 对象
同样,也可以从 C++ 创建 QML 对象并访问存在于 QML 引擎中的现有对象(例如,在某个 QML 文件中声明的那些)。然而,总的来说,这样做通常是不良实践。如果我们假设最常见的情况,即我们的应用程序的 QML 部分处理 Qt Quick 的用户界面,而逻辑是用 C++ 编写的,那么从 C++ 访问 Qt Quick 对象会打破逻辑和表示层之间的分离,这是 GUI 编程中的一个主要原则。用户界面容易受到动态变化的影响,包括重新布局甚至彻底的改造。对 QML 文档的重度修改,如添加或删除设计中的项目,随后将需要调整应用程序逻辑以应对这些变化。此外,如果我们允许单个应用程序拥有多个用户界面(皮肤),可能会发生这样的情况,即由于它们差异很大,很难决定一组具有硬编码名称的通用实体,这些实体可以从 C++ 中检索并操作。即使你设法做到了,如果 QML 部分没有严格遵守规则,这样的应用程序也可能会轻易崩溃。
话虽如此,我们不得不承认,确实存在一些情况下从 C++ 访问 QML 对象是有意义的,这就是我们决定向您介绍如何实现这一方法的原因。其中一种希望采用这种方法的情形是,当 QML 作为一种快速定义具有不同对象属性的对象层次结构的方式时,通过更多或更少的复杂表达式将这些对象链接起来,使它们能够响应层次结构中发生的变化。
QQmlApplicationEngine 类通过 rootObjects() 函数提供对其顶级 QML 对象的访问。所有嵌套的 QML 对象形成一个从 C++ 可见的父子层次结构,因此您可以使用 QObject::findChild 或 QObject::findChildren 来访问嵌套对象。找到特定对象最方便的方法是设置其 objectName 属性。例如,如果我们想从 C++ 访问重置按钮,我们需要设置其对象名。
表单编辑器不提供为项目设置 objectName 的方法,因此我们需要使用文本编辑器来做出这个更改:
Button {
id: reset
objectName: "buttonReset"
//...
}
现在,我们可以从 main 函数中访问这个按钮:
if (engine.rootObjects().count() == 1) {
QObject *window = engine.rootObjects()[0];
QObject *resetButton = window->findChild<QObject*>("buttonReset");
if (resetButton) {
resetButton->setProperty("highlighted", true);
}
}
在此代码中,我们首先访问顶级的 Window QML 对象。然后,我们使用 findChild 方法找到与我们的重置按钮相对应的对象。findChild() 方法要求我们传递一个类指针作为模板参数。由于不知道实际实现给定类型的类是什么,最安全的方法是简单地传递 QObject*,因为我们知道所有 QML 对象都继承自它。更重要的是传递给函数参数的值——它是我们想要返回的对象的名称。请注意,这并不是对象的 id,而是 objectName 属性的值。当结果被分配给变量时,我们验证是否成功找到了项目,如果是这样,就使用通用的 QObject API 将其 highlighted 属性设置为 true。这个属性将改变按钮的外观。
QObject::findChild 和 QObject::findChildren 函数执行无限深度的递归搜索。虽然它们使用起来很方便,但如果对象有很多子对象,这些函数可能会很慢。为了提高性能,你可以通过将这些函数的 Qt::FindDirectChildrenOnly 标志传递给这些函数来关闭递归搜索。如果目标对象不是直接子对象,考虑多次调用 QObject::findChild 来找到每个中间父对象。
如果你需要创建一个新的 QML 对象,你可以使用 QQmlComponent 类来完成。它接受一个 QML 文档,并允许你从中创建一个 QML 对象。文档通常是从文件中加载的,但你甚至可以直接在 C++ 代码中提供它:
QQmlComponent component(&engine);
component.setData(
"import QtQuick 2.6\n"
"import QtQuick.Controls 2.2\n"
"import QtQuick.Window 2.2\n"
"Window { Button { text: \"C++ button\" } }", QUrl());
QObject* object = component.create();
object->setProperty("visible", true);
component.create() 函数实例化我们的新组件,并返回一个指向它的 QObject 指针。实际上,任何 QML 对象都源自 QObject。你可以使用 Qt 元系统来操作对象,而无需将其转换为具体类型。你可以使用 property() 和 setProperty() 函数访问对象的属性。在这个例子中,我们将 Window QML 对象的 visible 属性设置为 true。当我们的代码执行时,一个带有按钮的新窗口将出现在屏幕上。
你也可以使用 QMetaObject::invokeMethod() 函数调用对象的方法:
QMetaObject::invokeMethod(object, "showMaximized");
如果你想将新对象嵌入到现有的 QML 表单中,你需要设置新对象的 视觉父级。假设我们想要将一个按钮添加到计算器的表单中。首先,你需要在 main.qml 中给它分配 objectName:
Calculator {
anchors.fill: parent
objectName: "calculator"
}
现在,你可以从 C++ 中向这个表单添加一个按钮:
QQmlComponent component(&engine);
component.setData(
"import QtQuick 2.6\n"
"import QtQuick.Controls 2.2\n"
"Button { text: \"C++ button2\" }", QUrl());
QObject *object = component.create();
QObject *calculator = window->findChild<QObject*>("calculator");
object->setProperty("parent", QVariant::fromValue(calculator));
在此代码中,我们创建了一个组件,并将其主表单作为其 parent 属性。这将使对象出现在表单的左上角。像任何其他 QML 对象一样,你可以使用 anchors 属性组来改变对象的位置。
当创建复杂对象时,它们需要时间来实例化,有时,人们希望不要因为等待操作完成而长时间阻塞控制流。在这种情况下,你可以使用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 如何允许我们实现这一点。
流体用户界面
到目前为止,我们一直将图形用户界面视为一组嵌入彼此中的面板。这在桌面实用程序的世界中得到了很好的体现,这些实用程序由窗口和子窗口组成,其中包含大量静态内容,散布在整个大桌面区域,用户可以使用鼠标指针在窗口之间移动或调整它们的大小。
然而,这种设计与现代用户界面不太相符,现代用户界面通常试图最小化它们占据的面积(因为嵌入式和移动设备等显示尺寸较小,或者为了避免遮挡主显示面板,如游戏中的情况),同时提供大量动态移动或动态调整大小的丰富内容。这样的用户界面通常被称为“流体”,以表明它们不是由多个不同的屏幕组成,而是包含动态内容和布局,其中一屏可以流畅地转换到另一屏。QtQuick模块提供了一个运行时,用于创建具有流体用户界面的丰富应用程序。
状态和转换
Qt Quick 引入了状态的概念。任何 Qt Quick 对象都可以有一个预定义的状态集。每个状态对应于应用程序逻辑中的某种情况。例如,我们可以说我们的计算器应用程序有两个状态:
-
当选择加法或乘法操作时,用户必须输入两个操作数
-
当选择阶乘操作时,用户只需要输入一个操作数
状态通过字符串名称来标识。隐式地,任何对象都有一个空名称的基本状态。要声明一个新的状态,你需要指定状态名称和一组与基本状态不同的属性值。
每个 Qt Quick 对象也都有一个state属性。当你将状态名称分配给此属性时,对象将进入指定的状态。默认情况下,这会立即发生,但可以定义对象的转换并执行一些状态更改时的视觉效果。
让我们看看我们如何在项目中利用状态和转换。
动手时间 - 向表单添加状态
在表单编辑器中打开CalculatorForm.ui.qml文件。主区域的底部包含状态编辑器。基本状态项始终位于左侧。点击状态编辑器右侧的“添加新状态”按钮。编辑器中会出现一个新的状态。它包含一个文本字段,你可以用它来设置状态的名称。将名称设置为single_argument。
一次只能选择一个状态。当选择自定义状态时,任何在表单编辑器中的更改都只会影响所选状态。当选择基本状态时,你可以编辑基本状态,并且所有更改都将影响所有其他状态,除非某些状态中覆盖了更改的属性。
通过在状态编辑器中点击它来选择single_argument状态。创建时它也会自动被选中。接下来,选择argument2文本字段并将其opacity属性设置为 0。该字段将变得完全透明,除了表单编辑器提供的蓝色轮廓。然而,这种变化仅影响single_argument状态。当你切换到基本状态时,文本字段将变得可见。当你切换回第二个状态时,文本字段将再次变得不可见。
你可以切换到文本编辑器来查看这个状态在代码中的表示:
states: [
State {
name: "single_argument"
PropertyChanges {
target: b
opacity: 0
}
}
]
如你所见,状态不包含表单的完整副本。相反,它只记录此状态与基本状态之间的差异。
现在我们需要确保表单的状态得到适当的更新。你只需要将表单的state属性绑定到一个返回当前状态的函数。切换到Calculator.qml文件并添加以下代码:
CalculatorForm {
state: {
if (operationFactorial.checked) {
return "single_argument";
} else {
return "";
}
}
//...
}
就像任何其他属性绑定一样,当需要时,QML 引擎会自动更新state属性的值。当用户选择阶乘操作时,代码块将返回"single_argument",第二个文本字段将被隐藏。在其他情况下,函数将返回一个空字符串,对应于基本状态。当你运行应用程序时,你应该能够看到这种行为。
动手时间 - 添加*滑转换效果
Qt Quick 允许我们轻松实现状态之间的*滑转换。它将自动检测何时需要更改某些属性,并且如果对象附加了匹配的动画,该动画将接管应用更改的过程。你甚至不需要指定动画属性的起始和结束值;这一切都是自动完成的。
要为我们的表单添加*滑的过渡,请将以下代码添加到 Calculator.qml 文件中:
CalculatorForm {
//...
transitions: Transition {
PropertyAnimation {
property: "opacity"
duration: 300
}
}
}
运行应用程序,您将看到当表单转换到另一个状态时,文本字段的透明度会逐渐变化。
刚才发生了什么?
transitions 属性包含此对象的 Transition 对象列表。如果您想在不同情况下执行不同的动画,可以为每一对状态指定不同的 Transition 对象。然而,您也可以使用单个 Transition 对象,这将影响所有转换。为了方便起见,QML 允许我们将单个对象分配给期望列表的属性。
一个 Transition 对象必须包含一个或多个动画,这些动画将在转换过程中应用。在这个例子中,我们添加了 PropertyAnimation,它允许我们动画化主表单的任何子对象的任何属性。PropertyAnimation QML 类型具有允许您配置它将执行什么操作的属性。我们指示它动画化 opacity 属性,并花费 300 毫秒来完成动画。默认情况下,不透明度变化将是线性的,但您可以使用 easing 属性来选择另一个缓动函数。
如往常一样,Qt 文档是关于可用类型和属性的详细信息的绝佳来源。请参阅 Transition QML 类型文档和 Animation QML 类型文档页面以获取更多信息。我们还将更多讨论第十三章Qt Quick 游戏中的动画中的状态和转换。
尝试一下英雄 – 添加项目位置的动画
如果您在文本字段淡出时将其飞出屏幕,可以使计算器的转换看起来更加吸引人。只需使用表单编辑器更改 single_argument 状态下文本字段的定位,然后将其附加到 Transition 对象上。您可以尝试不同的缓动类型,看看哪种更适合这个目的。
快速问答
Q1. 哪个属性允许您将 QML 对象相对于另一个对象定位?
-
border -
anchors -
id
Q2. 哪个文件扩展名表示该文件无法加载到 QML 引擎中?
-
.qml -
.ui -
.ui.qml -
所有上述都是有效的 QML 文件
Q3. Qt Quick 转换是什么?
-
现有 Qt Quick 对象之间父-子关系的改变
-
当事件发生时改变的一组属性
-
当对象状态改变时播放的一组动画
摘要
在本章中,你被介绍了一种名为 QML 的声明性语言。这种语言用于驱动 Qt Quick——一个用于高度动态和交互式内容的框架。你学习了 Qt Quick 的基础知识——如何使用多种元素类型创建文档,以及如何在 QML 或 C++中创建自己的元素。你还学习了如何将表达式绑定到属性上,以便自动重新评估它们。你看到了如何将应用程序的 C++核心暴露给基于 QML 的用户界面。你学习了如何使用可视化表单编辑器以及如何在界面中创建动画过渡。
你还学习了哪些 QML 模块可用。你被展示了如何使用QtQuick.Controls和QtQuick.Layouts模块使用标准组件构建应用程序的用户界面。在下一章中,我们将看到如何创建具有独特外观和感觉的完全自定义 QML 组件。我们将展示如何在 QML 应用程序中实现自定义图形和事件处理。
第十二章:Qt Quick 中的自定义
在上一章中,你学习了如何使用 Qt Quick 提供的控件和布局来构建应用程序的用户界面。Qt 包含许多 QML 类型,可以作为你游戏的构建块,提供丰富的功能和高雅的外观。然而,有时你需要创建一个满足你游戏需求的自定义组件。在本章中,我们将展示一些方便的方法来通过自定义组件扩展你的 QML 项目。到本章结束时,你将知道如何在画布上执行自定义绘图,处理各种输入事件,并为你的组件实现延迟加载。我们还将看到如何将 C++ 对象集成到 QML 的对象树中。
本章涵盖的主要主题如下:
-
创建自定义组件
-
处理鼠标、触摸、键盘和游戏手柄事件
-
动态和延迟加载
-
使用 JavaScript 在画布上绘图
创建自定义 QML 组件
在上一章中,当使用表单编辑器时,我们已经接触到了自定义组件的话题。我们的 QML 文件实现了具有干净界面的可重用组件,可以在应用程序的其余部分中使用。现在,我们将采用更底层的策略,直接从 QML 代码中使用基本的 Qt Quick 构建块创建一个新的 QML 组件。我们的组件将是一个具有圆角和良好背景的按钮。按钮将包含可定义的文本和图标。我们的组件应该对不同文本和图标都具有良好的外观。
动手实践 - 创建按钮组件
首先在 Qt Creator 中创建一个新的项目。选择 Qt Quick Application - Empty 作为项目模板。将项目命名为 custom_button 并保持其余选项不变。
到目前为止,你应该已经拥有一个包含空窗口的 QML 文档。让我们先创建按钮框架。编辑 main.qml 文件,向窗口添加一个新的 Rectangle 项目:
import QtQuick 2.9
import QtQuick.Window 2.2
Window {
visible: true
width: 640
height: 480
title: qsTr("Hello World")
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" }
}
}
}
运行项目后,你应该看到以下类似的结果:

刚才发生了什么?
你可以看到,使用我们之前未提及的 centerIn 锚点绑定,矩形在窗口中居中。这是两个特殊锚点之一,提供便利,以避免编写过多的代码。使用 centerIn 等同于设置 horizontalCenter 和 verticalCenter。另一个便利绑定是 fill,它使一个项目占据另一个项目的整个区域(类似于在目标项目中将左、右、上、下锚点设置为相应的锚线)。
与为按钮设置纯色背景不同,我们声明背景为线性渐变。我们将一个Gradient元素绑定到gradient属性,并定义了两个GradientStop元素作为其子元素,其中我们指定了两种颜色进行混合。Gradient不继承自Item,因此不是一个可视的 Qt Quick 元素。相反,它只是一个作为渐变定义数据持有者的 QML 对象。
Item类型有一个名为children的属性,它包含一个项目可视子项(Item实例)的列表,还有一个名为resources的属性,它包含一个项目非可视对象(如Gradient或GradientStop)的列表。通常,在向项目添加可视或非可视对象时,你不需要使用这些属性,因为项目会自动将子对象分配到适当的属性中。请注意,在我们的代码中,Gradient对象不是Rectangle的子对象;它只是被分配到其gradient属性。
行动时间 - 添加按钮内容
下一步是向按钮添加文本和图标。首先,将图标文件复制到项目目录中。在 Qt Creator 中,在项目树中定位qml.qrc资源文件。在资源文件的上下文菜单中选择“添加现有文件”,并选择你的图标文件。文件将被添加到资源中,并出现在项目树中。我们的示例文件名为edit-undo.png,相应的资源 URL 为qrc:/edit-undo.png。
你可以通过在项目树中定位该文件并使用其上下文菜单中的“复制路径”或“复制 URL”选项来获取文件的资源路径或 URL。
接下来,我们将使用另一个名为Row的项目类型将图标和文本添加到我们的按钮中,如下所示:
Rectangle {
id: button
anchors.centerIn: parent
border { width: 1; color: "black" }
radius: 5
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: "qrc:/edit-undo.png"
}
Text {
id: buttonText
text: "ButtonText"
}
}
}
你将得到以下输出:

刚才发生了什么?
Row是QtQuick模块提供的定位器QML 类型。其目的是与QtQuick.Layouts模块中的RowLayout类型类似。Row项目将其子项在水*行中展开。它使得在不使用锚点的情况下定位一系列项目成为可能。Row具有spacing属性,它决定了项目之间应留多少空间。
QtQuick模块还包含Column类型,它将子项排列成一列,Grid类型创建一个项目网格,以及Flow类型,它将子项并排放置,并在必要时进行换行。
行动时间 - 正确设置按钮大小
当涉及到按钮的大小调整时,我们当前的面板定义仍然表现不佳。如果按钮内容非常小(例如,图标不存在或文本非常短),按钮看起来就不会很好。通常,按钮强制执行最小尺寸——如果内容小于指定的大小,按钮将扩展到允许的最小尺寸。另一个问题是,用户可能想要覆盖项的宽度和高度。在这种情况下,按钮的内容不应超出按钮的边界。让我们通过用以下代码替换 width 和 height 属性绑定来解决这两个问题:
clip: true
implicitWidth: Math.max(buttonContent.implicitWidth + 8, 80)
implicitHeight: buttonContent.implicitHeight + 8
刚才发生了什么?
implicitWidth 和 implicitHeight 属性可以包含项想要具有的期望大小。它是 Qt Widgets 中的 sizeHint() 的直接等效。通过使用这两个属性而不是 width 和 height(它们绑定到 implicitWidth 和
默认情况下,implicitHeight),我们允许我们的组件用户覆盖这些隐式值。当这种情况发生且用户没有设置足够宽或高的宽度或高度以包含按钮的图标和文本时,我们通过将 clip 属性设置为 true 来防止内容超出父项的边界。
剪裁可能会降低游戏性能,因此仅在必要时使用。
行动时间 - 将按钮制作成可重用组件
到目前为止,我们一直在处理单个按钮。通过复制代码、更改所有组件的标识符以及设置不同的属性绑定来添加另一个按钮是一项非常繁琐的任务。相反,我们可以将我们的按钮项变成一个真正的组件,即一个可以在需要时实例化多次的新 QML 类型。
首先,将文本光标定位在我们的 Rectangle 项的开头,然后在键盘上按 Alt + Enter 打开重构菜单,就像以下截图所示:

从菜单中选择 将组件移动到单独的文件。在弹出窗口中,为新的类型输入一个名称(例如,Button),并在 main.qml 的属性分配列表中勾选 anchors.centerIn:

通过点击 OK 按钮接受对话框。
刚才发生了什么?
你可以看到,在项目中有一个名为 Button.qml 的新文件,它包含按钮项曾经拥有的所有内容,除了 id 和 anchors.centerIn 属性。主文件被简化为以下内容:
Window {
visible: true
width: 640
height: 480
title: qsTr("Hello World")
Button {
id: button
anchors.centerIn: parent
}
}
Button 已经成为一个组件——一个新类型元素的定义,可以像标准 QML 元素类型一样使用。记住,QML 组件名称,以及代表它们的文件名称,需要以大写字母开头!如果你将文件命名为 button.qml 而不是 Button.qml,那么你将无法将 Button 作为组件名称使用,尝试使用 "button" 将会导致错误信息。这同样适用于两种情况——每个以大写字母开头的 QML 文件都可以被视为组件定义。
由于我们在对话框中检查了 anchors.centerIn,因此这个属性没有被移动到 Button.qml。选择这个方案的原因是我们的按钮可以放在任何地方,所以它不可能知道它应该如何定位。相反,按钮的定位应该在组件使用的地方完成。现在我们可以编辑 main.qml,将按钮放入布局或使用其他定位属性,而无需更改组件的代码。
导入组件
组件定义可以直接由位于组件定义同一目录中的其他 QML 文件使用。在我们的例子中,main.qml 和 Button.qml 文件位于同一目录中,因此你可以在 main.qml 中使用 Button QML 类型,而无需进行任何导入。
如果你需要访问一个位于其他文件中的组件定义,你将不得不首先导入包含该组件的模块,在你想使用它的文件中。模块的定义非常简单——它只是一个指向包含 QML 文件的 目录 的相对路径。这意味着如果你有一个名为 Baz.qml 的文件位于名为 Base/Foo/Bar 的目录中,并且你想要在 Base/Foo/Ham.qml 文件中使用 Baz 组件,你将不得不在 Ham.qml 中放置以下导入语句:
import "Bar"
如果你想要在 Base/Spam.qml 文件中使用相同的组件,你必须将导入语句替换为以下内容:
import "Foo/Bar"
导入一个模块会使它的所有组件都可用于使用。然后你可以声明从某个模块导入的类型对象。
QML 和虚拟资源路径
我们的项目使用 Qt 资源文件将我们的 QML 文件嵌入到二进制文件中,并确保它们始终可用于应用程序,即使源目录在计算机上不存在。在启动期间,我们使用 qrc:/main.qml URL 引用主 QML 文件。这意味着运行时只看到资源文件中的文件层次结构,而不会考虑项目的实际源目录。
另一个 QML 文件具有 qrc:/Button.qml URL,因此 Qt 将它们视为同一虚拟目录,一切仍然正常。然而,如果你创建了一个 QML 文件但忘记将其添加到项目的资源中,Qt 将无法加载该文件。即使该文件与 main.qml 位于同一真实目录中,Qt 也只会搜索虚拟的 qrc:/ 目录。
有可能将带有前缀的文件添加到资源中,在这种情况下,它可以有一个类似于qrc:/some/prefix/Button.qml的 URL,并且运行时将其视为另一个虚拟目录。话虽如此,除非你明确创建一个新的前缀,否则你应该没问题。如果你的 QML 文件组织在子目录中,当你将它们添加到资源文件时,它们的层次结构将被保留。
事件处理器
Qt Quick 旨在用于创建高度交互的用户界面。它提供了一些元素来从用户那里获取输入事件。在本节中,我们将介绍它们,并了解如何有效地使用它们。
行动时间 - 使按钮可点击
到目前为止,我们的组件看起来就像一个按钮。下一个任务是让它对鼠标输入做出响应。
MouseArea QML 类型定义了一个透明的矩形,它公开了与鼠标输入相关的多个属性和信号。常用的信号包括clicked、pressed和released。让我们做一些练习,看看这个元素如何使用。
打开Button.qml文件,并将一个MouseArea子项添加到按钮中,使用锚点使其填充按钮的整个区域。将此元素命名为buttonMouseArea。在项的主体中放入以下代码:
Rectangle {
id: button
// ...
Row { ... }
MouseArea {
id: buttonMouseArea
anchors.fill: parent
onClicked: button.clicked()
}
}
此外,在按钮对象中 ID 声明之后设置以下声明:
Rectangle {
id: button
signal clicked()
// ...
}
要测试修改,请转到main.qml文件并为按钮添加一个信号处理器:
Button {
id: button
anchors.centerIn: parent
onClicked: console.log("Clicked!")
}
然后,运行程序并点击按钮。你会在 Qt Creator 的控制台中看到你的消息被打印出来。
刚才发生了什么?
使用signal clicked()语句,我们声明按钮对象可以发出一个名为clicked的信号。使用MouseArea项,我们定义了一个矩形区域(覆盖整个按钮),它对鼠标事件做出反应。然后,我们定义了onClicked,这是一个信号处理器。对于对象拥有的每个信号,都可以将一个脚本绑定到以信号名称命名并以前缀“on”开头的处理器;因此,对于clicked信号,处理器被调用为onClicked,对于valueChanged,它被调用为onValueChanged。
在这个特定的情况下,我们定义了两个处理器——一个用于按钮,我们在控制台中写入一个简单的语句;另一个用于MouseArea元素,我们调用按钮的信号函数,实际上发出了该信号。
MouseArea具有更多功能,因此现在让我们尝试正确使用它们,使我们的按钮功能更丰富。
行动时间 - 可视化按钮状态
目前,点击按钮时没有视觉反应。在现实世界中,按钮有一定的深度,当你按下它并从上方看时,其内容似乎会稍微向右和向下移动。让我们通过利用 MouseArea 具有的表示鼠标按钮是否当前被按下的 pressed 属性来模拟这种行为(注意,pressed 属性与之前提到的 pressed 信号不同)。按钮的内容由 Row 元素表示,因此在其定义内添加以下语句:
Row {
id: buttonContent
// ...
anchors.verticalCenterOffset: buttonMouseArea.pressed ? 1 : 0
anchors.horizontalCenterOffset: buttonMouseArea.pressed ? 1 : 0
// ...
}
我们还可以在鼠标光标悬停在按钮上时改变文本颜色。为此,我们必须做两件事。首先,让我们通过设置 MouseArea 的 hoverEnabled 属性来启用接收悬停事件:
hoverEnabled: true
当此属性被设置时,MouseArea 将在检测到鼠标光标在其自身区域上方时,将其 containsMouse 属性设置为 true。我们可以使用这个值来设置文本颜色:
Text {
id: buttonText
text: "ButtonText"
color: buttonMouseArea.containsMouse ? "white" : "black"
}
刚才发生了什么?
在最后一个练习中,我们学习了如何使用 MouseArea 的一些属性和信号来使按钮组件更具交互性。然而,该元素具有更多功能。特别是,如果启用了悬停事件,你可以通过返回值的 mouseX 和 mouseY 属性在项目的局部坐标系中获取当前鼠标位置。也可以通过处理 positionChanged 信号来报告光标位置。说到信号,大多数 MouseArea 信号都携带一个 MouseEvent 对象作为其参数。这个参数被称为 mouse,并包含有关鼠标当前状态的有用信息,包括其位置和当前按下的按钮。默认情况下,MouseArea 只对左键鼠标按钮做出反应,但你可以使用 acceptedButtons 属性来选择它应该处理哪些按钮。以下示例展示了这些功能:
MouseArea {
id: buttonMouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton
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.pressed ?
button.textPressedColor : button.textColor
}
正如你所注意到的,我们使用了 MouseArea 的 pressed 属性来检测当前是否在区域上按下鼠标按钮。我们可以给我们的按钮配备一个类似的属性。将以下代码添加到 Button 组件的顶级 Rectangle 中:
property alias pressed: buttonMouseArea.pressed
刚才发生了什么?
第一组更改引入了四个新属性,定义了四种颜色,我们在稍后用于定义按钮的渐变和文本颜色的语句中使用了这些颜色。在 QML 中,您可以使用property关键字为对象定义新属性。关键字后面应跟属性类型和属性名。QML 理解许多属性类型,最常见的是int、real、string、font和color。属性定义可以包含一个可选的默认值,用冒号开头。对于按下属性的定义,情况则不同。
您可以看到,对于属性类型,定义中包含单词alias。它不是一个属性类型,而是一个指示符,表示该属性实际上是另一个属性的别名——每次访问按钮的pressed属性时,都会返回buttonMouseArea.pressed属性的值,并且每次属性值改变时,实际上是鼠标区域的属性发生了变化。使用常规属性声明时,您可以提供任何有效的表达式作为默认值,因为表达式绑定到属性上。而对于属性别名,情况则不同——值是强制性的,并且必须指向相同或另一个对象中存在的属性。
考虑以下两个定义:
property int foo: someobject.prop
property alias bar: someobject.prop
初看之下,它们似乎很相似,因为它们指向相同的属性,因此返回的属性值也相同。然而,实际上这两个属性非常不同,如果您尝试修改它们的值,这一点就会变得明显:
foo = 7
bar = 7
第一个属性实际上绑定了一个表达式,因此将7赋值给foo只是释放了绑定,并将值7赋给foo属性,而someobject.prop保持其原始值。然而,第二个语句是一个别名;因此,赋值新值将修改应用到别名实际指向的someobject.prop属性。
谈到属性,当属性值被修改时,有一个简单的方法可以做出反应。对于每个现有的属性,都有一个处理程序可用,每当属性值被修改时就会执行。处理程序名称是on后跟属性名,然后是单词Changed,全部使用驼峰式命名法——因此,对于foo属性,它变为onFooChanged,对于topColor,它变为onTopColorChanged。要将按钮的当前按下状态记录到控制台,我们只需要为这个属性实现属性更改处理程序:
Button {
// ...
onPressedChanged: {
console.log("The button is currently " +
(pressed ? "" : "not ") + "pressed")
}
}
在这个例子中,我们创建了一个功能齐全的自定义 QML 组件。我们的按钮能够响应鼠标输入,并向用户公开一些有用的属性和信号。这使得它成为一个可重用和可定制的对象。在实际项目中,始终考虑 UI 中的重复部分,并考虑将它们移动到单个组件中,以减少代码重复。
触摸输入
MouseArea 是输入事件元素中最简单的。如今,越来越多的设备具有触摸功能,Qt Quick 也能处理它们。目前,我们有三种处理触摸输入的方法。
首先,简单的触摸事件也被报告为鼠标事件。在屏幕上轻触和滑动手指可以使用 MouseArea 处理,就像鼠标输入一样。
动作时间 - 在周围拖动项
创建一个新的 Qt Quick 应用程序 - 空项目。编辑 main.qml 文件,向窗口添加一个圆圈:
Rectangle {
id: circle
width: 60; height: width
radius: width / 2
color: "red"
}
接下来,向圆圈添加一个 MouseArea 并使用其 drag 属性通过触摸(或鼠标)启用圆圈的移动:
Rectangle {
//...
MouseArea {
anchors.fill: parent
drag.target: circle
}
}
然后,您可以启动应用程序并开始移动圆圈。
发生了什么?
通过定义一个高度等于宽度的矩形来创建一个圆圈,使其成为正方形,并将边框圆滑到边长的一半。可以使用 drag 属性来告诉 MouseArea 使用输入事件来管理给定项的位置,这些事件流入此 MouseArea 元素。我们使用 target 子属性来表示要拖动的项。您可以使用其他子属性来控制项可以移动的轴或限制移动到给定区域。需要记住的一个重要事情是,正在拖动的项不能在请求拖动的轴上锚定;否则,项将尊重锚点而不是拖动。我们没有将圆圈项锚定,因为我们希望它可以在两个轴上拖动。
处理 Qt Quick 应用程序中触摸输入的第二种方法是使用 PinchArea,它是一个类似于 MouseArea 的项,但不是拖动项,而是允许您使用两个手指(所谓的“捏合”手势)旋转或缩放它,如图所示:

请注意,PinchArea 只对触摸输入做出反应,因此要测试示例,您需要一个真正的多点触控设备。
动作时间 - 通过捏合旋转和缩放图片
启动一个新的 Qt Quick 应用程序 - 空项目。向资源中添加一个图像文件,就像我们在之前的按钮项目中做的那样。在 main.qml 文件中,向窗口添加一个图像并将其居中在其父元素中:
Image {
id: image
anchors.centerIn: parent
source: "qrc:/wilanow.jpg"
}
现在,我们将添加一个 PinchArea 元素。这种类型的项可以用两种方式使用——要么通过手动实现信号处理程序 onPinchStarted、onPinchUpdated 和 onPinchFinished 来完全控制手势的功能,要么使用类似于 MouseArea 的 drag 属性的简化接口。由于简化接口正好符合我们的需求,因此不需要手动处理捏合事件。让我们将以下声明添加到文件中:
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来旋转或缩放项目。控制这些方面的属性是常规属性,你可以在任何时候读取和写入它们。尝试将PinchArea替换为MouseArea,以获得类似我们刚刚通过修改缩放和旋转属性来修改的结果——当用户在按下左键的同时拖动鼠标时,图像被缩放;当用户在按下右键的同时做同样的事情时,图像被旋转。
如果你完成了这个任务,尝试再次将MouseArea替换为PinchArea,但这次,不是使用pinch属性,而是手动处理事件以获得相同的效果(事件对象称为pinch,它具有许多你可以操作的属性)。
处理触摸输入的第三种方法是使用MultiPointTouchArea项目。它通过分别报告每个触摸点提供对手势的低级接口。它可以用来创建类似于PinchArea的自定义高级手势处理器。
键盘输入
到目前为止,我们一直在处理指针输入,但用户输入不仅仅是那样——我们还可以处理键盘输入。这相当简单,基本上可以归结为两个简单的步骤。
首先,你必须通过声明特定项目具有键盘焦点来启用接收键盘事件:
focus: true
然后,你可以通过以类似鼠标事件的方式编写处理程序来开始处理事件。然而,Item没有提供自己的处理程序来操作键,这是QWidget的keyPressEvent和keyReleaseEvent的对应物。相反,Keys附加属性提供了适当的处理程序。
附加属性是由不作为独立元素使用但通过附加到其他对象来提供属性的元素提供的。这是在不修改原始元素 API 的情况下添加对新属性支持的一种方式(它不是通过一个is-a关系添加新属性,而是通过一个has-a关系)。每个引用附加属性的对象都会获得一个附加对象的副本,然后处理额外的属性。我们将在本章的后面回到附加属性。现在,你只需要记住,在某些情况下,一个元素可以获取不属于其 API 的附加属性。
让我们回到实现键盘输入的事件处理器。正如我们之前所说的,每个 Item 都有一个名为Keys的附加属性,它允许我们安装自己的键盘处理器。Keys为Item添加的基本两个信号是pressed和released;因此,我们可以实现带有KeyEvent参数的onPressed和onReleased处理器,这些参数提供的信息与在控件世界中QKeyEvent类似。作为一个例子,我们可以看到一个检测空格键被按下的项目:
Rectangle {
focus: true
color: "black"
width: 100
height: 100
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"
}
注意,即使键有自己的按下信号,released信号也会为每个释放的键发出。
现在,考虑另一个例子:
Item {
id: item
property int number: 0
width: 200; height: width
focus: true
Keys.onSpacePressed: {
number++;
}
Text {
text: item.number
anchors.centerIn: parent
}
}
我们预期当我们按下并保持空格键时,我们会看到文本从0变为1并保持在那个值,直到我们释放键。如果你运行示例,你会看到相反的情况,数字会一直增加,只要你按住键。这是因为默认情况下,键会自动重复——当你按住键时,操作系统会持续发送一系列的按键-释放事件(你可以通过在Keys.onPressed和Keys.onReleased处理器中添加console.log()语句来验证这一点)。为了抵消这种效果,你可以区分自动重复和常规事件。在 Qt Quick 中,你可以轻松地做到这一点,因为每个按键事件都携带适当的信息。只需用以下处理器替换上一个示例中的处理器:
Keys.onSpacePressed: {
if(!event.isAutoRepeat) {
number++;
}
}
我们在这里使用的事件变量是 spacePressed 信号的参数名称。由于我们无法像在 C++ 中那样为参数声明自己的名称,对于每个信号处理程序,您将不得不在文档中查找参数的名称。您可以在文档索引中搜索 Keys 以打开 Keys QML 类型 页面。信号列表将包含信号参数的类型和名称,例如,spacePressed(KeyEvent event)。
在处理事件时,您应该将其标记为已接受,以防止其传播到其他元素并由它们处理:
Keys.onPressed: {
if(event.key === Qt.Key_Space) {
color = "blue";
event.accepted = true;
}
}
然而,如果您使用针对单个按钮的专用处理程序(如 onSpacePressed),则不需要接受事件,因为 Qt 会自动为您处理。
在标准的 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
}
Rectangle {
id: third
width: 50; height: width
color: focus ? "blue" : "lightgray"
}
}
注意,我们通过显式设置 focus 属性,使第一个项目在开始时获得焦点。通过设置 KeyNavigation.right 属性,我们指示 Qt 在此项目接收到右键按下事件时关注指定的项目。反向转换会自动添加——当在第二个项目上按下左键时,第一个项目将获得焦点。除了 right,KeyNavigation 还包含 left、down、up、tab 和 backtab (Shift + Tab) 属性。
Keys 和 KeyNavigation 附加属性都有一种定义的方式
每个机制接收事件顺序。这由 priority 属性处理,可以设置为 BeforeItem 或 AfterItem。默认情况下,Keys 将首先获取事件(BeforeItem),然后进行内部事件处理,最后 KeyNavigation 将有机会处理该事件(AfterItem)。请注意,如果事件被其中一个机制处理,则事件被接受,其余机制将不会收到该事件。
尝试一下英雄——练习键事件传播
作为练习,你可以通过构建一个更大的项目数组(你可以使用Grid元素来定位它们)并定义一个使用KeyNavigation附加属性的导航系统来扩展我们最后的例子。让一些项目使用Keys附加属性自行处理事件。看看当相同的键由两种机制处理时会发生什么。尝试使用priority属性来影响行为。
当你将项目的focus属性设置为true时,任何之前使用的项目都会失去焦点。当你尝试编写一个需要将其子项设置为焦点的可重用组件时,这会成为一个问题。如果你将此类组件的多个实例添加到单个窗口中,它们的焦点请求将相互冲突。只有最后一个创建的项目将具有焦点,因为它是最先创建的。为了克服这个问题,Qt Quick 引入了焦点域的概念。通过将你的组件包裹在一个FocusScope项目中,你将获得在组件内部设置焦点而不直接影响全局焦点的能力。当你的组件实例收到焦点时,内部聚焦的项目也将收到焦点,并将能够处理键盘事件。关于此功能的良好解释可以在 Qt Quick 中的键盘焦点文档页面上找到。
文本输入字段
除了我们描述的附加属性之外,Qt Quick 还提供了处理键盘输入的内置元素。其中两种最基本类型是TextInput和TextEdit,它们是QLineEdit和QTextEdit的 QML 等价物。前者用于单行文本输入,而后者作为其多行对应物。它们都提供光标处理、撤销-重做功能和文本选择。你可以通过将验证器分配给validator属性来验证TextInput中输入的文本。例如,为了获取一个用户可以输入点分隔 IP 地址的项目,我们可以使用以下声明:
TextInput {
id: ipAddress
width: 100
validator: RegExpValidator {
// four numbers separated by dots
regExp: /\d+\.\d+\.\d+\.\d+/
}
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属性——被启用,这样默认情况下,如果框中输入的文本不适合项目,它将溢出并保持可见,超出实际项目之外。通过启用裁剪,我们明确表示任何不适合项目的任何内容都不应该被绘制。

QtQuick.Controls 模块提供了更高级的文本输入控件,例如 TextField 和 TextArea。我们已经在 第十一章,Qt Quick 简介 中使用了它们。
游戏手柄输入
处理游戏手柄事件是在开发游戏时一个非常常见的任务。幸运的是,Qt 提供了 Qt Gamepad 模块来实现这一目的。我们已经在 C++ 中学习了如何使用它。现在让我们看看如何在 QML 应用程序中实现这一点。
要启用 Qt Gamepad 模块,请在项目文件中添加 QT += gamepad。接下来,在您的 QML 文件开头添加以下行以导入 QML 模块:
import QtGamepad 1.0
此导入允许您声明 Gamepad 类型的对象。在您的顶级 QML 对象内部添加以下对象:
Gamepad {
id: gamepad
deviceId: GamepadManager.connectedGamepads.length > 0 ?
GamepadManager.connectedGamepads[0] : -1
}
GamepadManager 对象允许我们列出系统中可用的游戏手柄标识符。如果系统中存在任何游戏手柄,我们将使用第一个可用的游戏手柄。如果您希望游戏动态地拾取连接的游戏手柄,请使用以下代码片段:
Connections {
target: GamepadManager
onGamepadConnected: gamepad.deviceId = deviceId
}
刚才发生了什么?
上述代码仅为 GamepadManager 对象的 gamepadConnected 信号添加了一个信号处理器。通常,添加信号处理器的做法是直接在发送者的部分声明它。然而,在这种情况下我们无法这样做,因为 GamepadManager 是一个现有的全局对象,它不属于我们的 QML 对象树。因此,我们使用 Connections QML 类型,它允许我们指定一个任意的发送者(使用 target 属性)并将其信号处理器附加到它。您可以将 Connections 视为 QObject::connect 调用的声明性版本。
初始化已完成,因此我们现在可以使用 gamepad 对象来请求有关游戏手柄输入的信息。有两种方法可以实现这一点。
首先,我们可以使用属性绑定来根据游戏手柄上按下的按钮设置其他对象的属性:
Text {
text: gamepad.buttonStart ? "Start!" : ""
}
每当在游戏手柄上按下或释放开始按钮时,gamepad.buttonStart 属性的值将被设置为 true 或 false,并且 QML 引擎将自动更新显示的文本。
第二种方法是添加一个信号处理器来检测属性何时发生变化:
Gamepad {
//...
onButtonStartChanged: {
if (value) {
console.log("start pressed");
} else {
console.log("start released");
}
}
}
Gamepad QML 类型为每个游戏手柄按钮都提供了一个单独的属性和信号,就像 QGamepad C++ 类一样。
您还可以使用 GamepadKeyNavigation QML 类型将游戏手柄支持引入支持键盘输入的游戏:
GamepadKeyNavigation {
gamepad: gamepad
active: true
buttonStartKey: Qt.Key_S
}
当在您的 QML 文件中声明此对象时,gamepad 对象提供的游戏手柄事件将自动转换为按键事件。默认情况下,GamepadKeyNavigation 能够在按下相应的游戏手柄按钮时模拟上、下、左、右、后退、前进和回车键。但是,您可以覆盖默认映射或为其他游戏手柄按钮添加自己的映射。在上面的示例中,我们告诉 GamepadKeyNavigation,游戏手柄上的开始键应像按下键盘上的 S 键一样工作。现在您可以像处理任何常规键盘事件一样处理这些事件。
传感器输入
Qt 正在扩展到更多现在使用的*台。这包括许多流行的移动*台。移动设备通常配备有额外的硬件,这些硬件在桌面系统中较少见。让我们看看如何在 Qt 中处理传感器输入,以便您可以在游戏中使用它。
本节中讨论的大多数功能通常在桌面上不可用。如果您想尝试它们,您需要在移动设备上设置运行 Qt 应用程序。这需要一些配置步骤,这些步骤取决于您的目标*台。请参阅 Qt 文档以获取确切说明,因为它们将提供完整且最新的信息,这在书中是无法提供的。良好的起点是“Qt for Android 入门”和“Qt for iOS”文档页面。
通过 Qt Sensors 模块提供对移动设备上传感器的访问,在使用之前必须导入:
import QtSensors 5.0
有许多 QML 类型可以用来与传感器交互。看看这个令人印象深刻的列表:
| QML 类型 | 描述 |
|---|---|
Accelerometer |
报告设备沿 x、y 和 z 轴的线性加速度。 |
Altimeter |
报告相对于*均海*面的高度,单位为米。 |
AmbientLightSensor |
报告环境光的强度。 |
AmbientTemperatureSensor |
报告当前设备环境的温度,单位为摄氏度。 |
Compass |
报告设备顶部的方位角,单位为从磁北方向起的度数。 |
DistanceSensor |
报告从对象到设备的距离,单位为厘米。 |
Gyroscope |
报告设备在其轴上的运动,单位为每秒度数。 |
HolsterSensor |
报告设备是否被放置在特定的口袋中。 |
HumiditySensor |
报告湿度。 |
IRProximitySensor |
报告发出的红外光的反射率。范围从 0(无反射)到 1(完全反射)。 |
LidSensor |
报告设备是否关闭。 |
LightSensor |
报告光的强度,单位为勒克斯。 |
Magnetometer |
报告设备沿其轴线的磁通密度。 |
OrientationSensor |
报告设备的方向。 |
PressureSensor |
报告大气压力,单位为帕斯卡。 |
ProximitySensor |
报告对象是否靠*设备。被认为是“靠*”的距离取决于设备。 |
RotationSensor |
报告定义设备在三维空间中方向的三个角度。 |
TapSensor |
报告设备是否被轻触。 |
TiltSensor |
报告设备轴上的倾斜角度(以度为单位)。 |
不幸的是,并非所有*台都支持所有传感器。在尝试使用它们之前,请查看兼容性映射文档页面,以了解您的目标*台支持哪些传感器。
所有这些类型都继承自 Sensor 类型,并提供类似的 API。首先,创建一个传感器对象,并通过将其 active 属性设置为 true 来激活它。当硬件报告新值时,它们将被分配给传感器的 reading 属性。与 QML 中的任何属性一样,您可以选择直接使用属性、在属性绑定中使用它或使用 onReadingChanged 处理器来对属性的每个新值做出反应。
reading 对象的类型对应于传感器的类型。例如,如果您使用倾斜传感器,您将收到一个 TiltReading 对象,该对象提供适当的属性来访问围绕 x(xRotation)和 y(yRotation)轴的倾斜角度。对于每种传感器类型,Qt 都提供相应的读取类型,其中包含传感器数据。
所有读取都还有一个 timestamp 属性,它包含自某个固定点以来的微秒数。这个点对于不同的传感器对象可能不同,因此您只能用它来计算同一传感器两次读取之间的时间间隔。
以下 QML 代码包含使用倾斜传感器的完整示例:
import QtQuick 2.9
import QtQuick.Window 2.2
import QtSensors 5.0
Window {
visible: true
width: 640
height: 480
title: qsTr("Hello World")
Text {
anchors.centerIn: parent
text: {
if (!tiltSensor.reading) {
return "No data";
}
var x = tiltSensor.reading.xRotation;
var y = tiltSensor.reading.yRotation;
return "X: " + Math.round(x) +
" Y: " + Math.round(y)
}
}
TiltSensor {
id: tiltSensor
active: true
onReadingChanged: {
// process new reading
}
}
}
当此应用程序接收到新的读数时,屏幕上的文本将自动更新。您还可以使用 onReadingChanged 处理器以其他方式处理新数据。
检测设备位置
一些现代游戏需要关于玩家地理位置和其他相关数据的信息,例如移动速度。Qt 定位模块允许您访问这些信息。让我们看看一个基本的 QML 示例,用于确定位置:
import QtQuick 2.9
import QtQuick.Window 2.2
import QtPositioning 5.0
Window {
visible: true
width: 640
height: 480
title: qsTr("Hello World")
Text {
anchors.centerIn: parent
text: {
var pos = positionSource.position;
var coordinate = pos.coordinate;
return "latitude: " + coordinate.latitude +
"\nlongitude: " + coordinate.longitude;
}
}
PositionSource {
id: positionSource
active: true
onPositionChanged: {
console.log("pos changed",
position.coordinate.latitude,
position.coordinate.longitude);
}
}
}
首先,我们将 QtPositioning 模块导入作用域。接下来,我们创建一个 PositionSource 对象并将其 active 属性设置为 true。当有新信息可用时,PositionSource 对象的 position 属性将自动更新。除了纬度和经度外,此属性还包含有关海拔、方向、速度和位置准确性的信息。由于某些值可能不可用,每个值都附带一个布尔属性,指示数据是否存在。例如,如果 directionValid 为 true,则 direction 值已设置。
有多种方法可以确定玩家的位置。PositionSource类型有几个属性,允许你指定数据的来源。首先,preferredPositioningMethods属性允许你在卫星数据、非卫星数据或同时使用两者之间进行选择。supportedPositioningMethods属性包含有关当前可用方法的信息。你还可以使用nmeaSource属性提供一个 NMEA 位置指定数据文件,该文件覆盖任何其他数据源,并可用于模拟设备的位置和移动,这在游戏开发和测试期间非常有用。
创建高级 QML 组件
到现在为止,你应该已经熟悉了 QML 和 Qt Quick 的非常基础的知识。现在,我们可以开始结合你所知道的知识,并用更多信息来填补空白,以构建更高级的 QML 组件。我们的目标是显示一个模拟时钟。
是时候动手做一个简单的模拟时钟应用程序了
创建一个新的 Qt Quick 应用程序 - 空项目。为了创建一个时钟,我们将实现一个代表时钟指针的组件,并且我们将在实际的时钟元素中使用该组件的实例。除此之外,我们还将使时钟成为一个可重用的组件;因此,我们将它创建在一个单独的文件中,并在main.qml内部实例化它:
Window {
visible: true
width: 640
height: 480
title: qsTr("Hello World")
Clock {
id: clock
anchors {
fill: parent
margins: 20
}
}
}
我们使用anchors属性组来扩展项目,使其适应整个窗口,除了四周各 20 像素的边距。
然而,在这段代码工作之前,我们需要为Clock组件添加一个新的 QML 文件。在项目树中定位qml.qrc资源文件,并在其上下文菜单中选择“添加新...”。从“Qt”类别中选择“QML 文件(Qt Quick 2)”,输入Clock作为名称,并确认操作。将创建一个名为Clock.qml的新文件,并将其添加到资源列表中。
让我们从声明一个圆形钟表盘开始:
import QtQuick 2.9
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 度,从而有效地将项目均匀地放置在盘子上。
最后,每个项目都附有一个灰色的矩形,位于顶部边缘(偏移4个单位)并在透明父元素中水*居中。应用于项目的变换会影响项目的子元素,类似于我们在图形视图中看到的情况;因此,矩形的实际旋转跟随其父元素的旋转。矩形的高度取决于hour的值,它映射到Repeater中项目的索引。在这里,你不能直接使用index,因为它只在委托的最顶层项目内可见。这就是为什么我们创建了一个真正的属性hour,它可以从整个委托项目层次结构中引用。
如果你想要对项目变换有更多的控制,那么我们很高兴地告诉你,除了旋转和缩放属性之外,每个项目都可以分配一个元素数组,如Rotation、Scale和Translate到名为transform的属性中,这些变换按顺序逐个应用。这些类型具有用于精细控制变换的属性。例如,使用Rotation,你可以实现沿任意三个轴的旋转以及围绕自定义原点(而不是像使用Item的rotation属性时那样限制在九个预定义的原点)。
行动时间 - 向时钟添加针
下一步是将小时、分钟和秒针添加到时钟中。让我们首先在名为Needle.qml的文件中创建一个新的组件,名为Needle(记住,组件名称和表示它们的文件名需要以大写字母开头):
import QtQuick 2.9
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.9
Clock {
//...
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 date currentDate: new Date()
// ...
}
这确实会在你启动应用程序时显示当前时间,但时钟不会随着时间流逝而更新自己。这是因为new Date()返回一个表示特定时刻的对象(对象实例化的那一刻的日期和时间)。虽然 QML 通常能够在绑定的表达式值变化时自动更新属性,但它无法在此情况下做到。即使 QML 足够聪明,能够看到new Date()属性总是返回不同的日期,它也不知道我们希望多久更新一次值,而尽可能频繁地更新通常是一个坏主意。因此,我们需要一种手动安排定期执行操作的方法。
要在 QML 中获得这种效果,我们可以使用一个Timer元素,它是 C++中QTimer的等效物,并允许我们定期执行一些代码。让我们修改代码以使用计时器:
Item {
id: clock
//...
property alias running: timer.running
Timer {
id: timer
repeat: true
interval: 500
running: true
onTriggered: clock.currentDate = new Date()
}
//...
}
刚才发生了什么?
通过设置interval属性,我们要求计时器每 500 毫秒发出triggered信号,从而使我们的currentDate属性更新为一个新的Date对象,表示当前时间。时钟还获得了running属性(指向计时器中的等效属性),可以控制是否启用更新。计时器的repeat
属性设置为true;否则,它将只触发一次。
简要总结到目前为止你所学的,我们可以这样说:你知道如何通过声明实例来创建对象层次结构,你也知道如何在单独的文件中编程新类型,使定义作为组件在其他 QML 文件中实例化。你甚至可以使用 Repeater 元素根据一个共同的模板声明一系列对象。
QML 对象的动态和延迟加载
我们之前所有的 QML 项目都包含一个显式的对象树声明。我们通常创建一个窗口,并将多个特定元素按照特定顺序放入其中。QML 引擎在启动时创建这些对象,并在应用程序终止前保持它们的活动状态。这是一个非常方便的方法,可以节省你大量的时间,正如你可以在我们之前的示例中看到的那样。然而,有时你需要一个更灵活的对象树——例如,如果你事先不知道应该创建哪些元素。QML 提供了几种动态创建对象和延迟创建对象直到真正需要的方法。利用这些功能可以使你的 QML 应用程序更高效和灵活。
按需创建对象
在 QML 文件中直接预先声明对象的问题在于,你需要事先知道你需要多少个对象。更常见的情况是,你将想要动态地向场景中添加和删除对象,例如,在一个外星人入侵游戏中,随着玩家的进步,新的外星飞碟将进入游戏屏幕,而其他飞碟将被击落并摧毁。此外,玩家的飞船将“生产”新的子弹,在飞船前方划过,最终耗尽燃料或以其他方式从游戏场景中消失。通过在解决这个问题上投入大量精力,你将能够使用 Repeater 来获得这种效果,但手头还有更好的工具。
QML 给我们提供了另一种元素类型,称为 Component,这是通过在 QML 中声明其内容来向引擎介绍新元素类型的一种方法。基本上有两种实现这种方法的途径。
第一种方法是在 QML 文件中声明一个 Component 元素实例,并将新类型的定义直接内联在元素中:
Component {
id: circleComponent
Item {
//...
}
}
另一种方法是加载组件定义自现有的 QML 文件。假设我们有一个 Circle.qml 文件,其内容如下:
import QtQuick 2.9
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
}
}
这样的代码声明了一个组件,它定义了一个圆,并公开了其 diameter(直径)、color(颜色)和 border(边框)属性。让我们看看我们如何可以动态地创建这个组件的实例。
QML 提供了一个特殊的全局对象 Qt,它提供了一套有趣的方法。其中一种方法允许调用者通过传递现有 QML 文档的 URL 来创建一个组件:
var circleComponent = Qt.createComponent("Circle.qml");
一个有趣的注意事项是createComponent不仅可以接受本地文件路径,还可以接受远程 URL,并且如果它理解网络方案(例如,http),它将自动下载文档。在这种情况下,您必须记住这需要时间,因此组件在调用createComponent后可能不会立即准备好。由于当前的加载状态保存在status属性中,您可以通过连接到statusChanged信号来通知这种情况。一个典型的代码路径看起来类似于以下:
Window {
//...
Component.onCompleted: {
var circleComponent = Qt.createComponent("Circle.qml");
if(circleComponent.status === Component.Ready) {
addCircles(circleComponent);
} else {
circleComponent.statusChanged.connect(function() {
if(circleComponent.status === Component.Ready) {
addCircles(circleComponent);
}
});
}
}
}
在这个例子中,我们使用Component.onCompleted处理程序在窗口对象创建后立即运行代码。这个处理程序在所有 Qt Quick 项中都是可用的,通常用于执行初始化。您也可以在这里使用任何其他信号处理程序。例如,您可以在按钮按下或计时器到期时开始加载组件。
Component的completed()信号的对应物是destruction()。您可以使用Component.onDestruction处理程序执行诸如将对象的状态保存到持久存储或以其他方式清理对象等操作。
如果组件定义不正确或无法检索文档,对象的状态将变为错误。在这种情况下,您可以使用errorString()方法查看实际的问题是什么:
if(circleComponent.status === Component.Error) {
console.warn(circleComponent.errorString());
}
一旦您确定组件已准备好,您就可以开始从它创建对象。为此,组件公开了一个名为createObject的方法。在其最简单形式中,它接受一个将成为新创建实例的父对象的对象(类似于小部件构造函数接受父小部件的指针)并返回新对象本身,以便您可以将其分配给某个变量。然后,您可以开始设置对象的属性:
Window {
//...
ColumnLayout {
id: layout
anchors.fill: parent
}
function addCircles(circleComponent) {
["red", "yellow", "green"].forEach(function(color) {
var circle = circleComponent.createObject(layout);
circle.color = color;
circle.Layout.alignment = Qt.AlignCenter;
});
}
//...
}
更复杂的调用允许我们在一个调用中执行这两个操作(创建对象并设置其属性)通过向createObject传递第二个参数:
var circle = circleComponent.createObject(layout,
{ diameter: 20, color: 'red' });
第二个参数是一个 JavaScript 对象,其属性将被应用到正在创建的对象上。这种语法的好处是所有属性值都作为一个原子操作应用到对象上,并且它们不会触发属性更改处理程序(就像在 QML 文档中声明项时一样),而不是一系列单独的操作,每个操作都设置单个属性的值,这可能会在对象中引发一系列更改处理程序的调用。
创建后,对象成为场景的一等公民,以与在 QML 文档中直接声明的项目相同的方式行事。唯一的区别是,动态创建的对象也可以通过调用其destroy()方法动态销毁,这与在 C++ 对象上调用delete类似。当谈到销毁动态项目时,我们必须指出,当您将createObject的结果分配给一个变量(如我们的例子中的circle)并且该变量超出作用域时,项目将不会被释放和垃圾回收,因为其父对象仍然持有对该项目的引用,从而阻止其被回收。
我们之前没有明确提到这一点,但我们已经在本章介绍Repeater元素时使用过内联组件定义。实际上,在Repeater中定义的重复项不是一个真实的项目,而是一个组件定义,该定义由Repeater根据需要实例化多次。
延迟项目创建
另一个常见的场景是,您确实知道需要多少个元素,但问题是您无法提前确定它们的类型。在应用程序的生命周期中的某个时刻,您将了解到这些信息,并将能够实例化一个对象。在您获得有关给定组件的知识之前,您将需要一个某种类型的项目占位符,您将在其中放置真实的项目。当然,您可以编写一些代码来使用组件的createObject()功能,但这很麻烦。
幸运的是,Qt Quick 提供了一个更好的解决方案,即一个Loader项目。这种项目类型正是我们所描述的——一个临时占位符,用于在需要时从现有组件中加载真实项目。您可以将Loader放在另一个项目的位置,当您需要创建此项目时,一种方法是将组件的 URL 设置为source属性:
Loader {
id: loader
}
//...
onSomeSignal: loader.source = "Circle.qml"
您也可以直接将一个真实组件附加到Loader的sourceComponent上:
Loader {
id: loader
sourceComponent: shouldBeLoaded ? circleComponent : undefined
}
紧接着,魔法开始发挥作用,组件的实例在加载器中显示出来。如果Loader对象的大小被明确设置(例如,通过锚定或设置宽度和高度),则项目的大小将调整为加载器的大小。如果没有设置明确的大小,则一旦组件实例化,Loader将调整为加载元素的大小。在下面的代码中,加载器的大小被明确设置,因此当其项目创建时,它将尊重这里声明的锚点和大小:
Loader {
anchors {
left: parent.left
leftMargin: 0.2 * parent.width
right: parent.right
verticalCenter: parent.verticalCenter
}
height: 250
source: "Circle.qml"
}
使用 JavaScript 在 Canvas 上进行命令式绘制
声明图形项既方便又简单,但作为程序员,我们更习惯于编写命令式代码,有些事情用算法表达比用达到最终结果的描述更容易。使用 QML 以紧凑的方式编码原始形状的定义,如矩形,很容易——我们只需要标记矩形的原点、宽度、高度,以及可选的颜色。在 QML 这样的语言中,写下由许多控制点在给定绝对坐标中定位的复杂形状的声明性定义,可能在其某些部分有轮廓,可能还伴随一些图像,仍然是可能的;然而,这将导致一个更加冗长且可读性更差的定义。在这种情况下,使用命令式方法可能更有效。HTML(作为一种声明性语言)已经暴露了一个用于绘制不同原始形状的经过验证的命令式接口,称为 Canvas,它在网络应用中被广泛使用。幸运的是,Qt Quick 通过允许我们实例化 Canvas 项为我们提供了自己的 Canvas 接口实现,类似于网络中的实现。这些项可以用来绘制直线和曲线、简单和复杂的形状、图表和图形图像。它还可以添加文本、颜色、阴影、渐变和图案。它甚至可以执行低级像素操作。最后,输出可以保存为图像文件或序列化为一个可用的 URL,用作 Image 项的源。关于使用 HTML canvas 的许多教程和论文都可以找到,并且它们通常可以很容易地应用于 Qt Quick canvas(参考手册甚至包括在将 HTML canvas 应用程序移植到 Qt Quick Canvas 时需要关注的一些方面),因此在这里我们只给出 Qt Quick 中命令式绘图的基础知识。
考虑一个游戏,玩家的健康状态通过心脏的状况来衡量——心跳越慢,玩家就越健康。我们将使用这种可视化作为我们练习使用 Canvas 元素进行绘画的练习。
行动时间 - 准备 Canvas 用于心跳可视化
让我们从创建一个空的 Qt Quick 项目开始,做一些简单的事情。将以下代码添加到 main.qml 文件中:
Window {
//...
Canvas {
id: canvas
implicitWidth: 600
implicitHeight: 300
onPaint: {
var ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeRect(50, 50, 100, 100);
}
}
}
当你运行项目时,你会看到一个包含矩形的窗口:

刚才发生了什么?
在前面的代码中,我们创建了一个基本的模板代码,用于使用 canvas。首先,我们创建了一个具有隐式宽度和高度的Canvas实例。在那里,我们创建了一个处理程序,用于处理每当画布需要重绘时发出的paint信号。放置在那里的代码检索画布的上下文,这可以被视为我们在 Qt 小部件上绘图时使用的QPainter实例的等价物。我们通知画布我们想要其 2D 上下文,这为我们提供了在二维中绘制的方式。2D 上下文是目前Canvas元素唯一存在的上下文,但你仍然需要明确地识别它——类似于 HTML。有了上下文准备就绪,我们告诉它清除画布的整个区域。这与小部件世界不同,在paintEvent处理程序被调用时,小部件已经为我们清除了,并且必须从头开始重绘一切。使用Canvas时,情况不同;默认情况下保留先前内容,以便您可以覆盖它。由于我们想要从一张干净的画布开始,我们在上下文中调用clearRect()。最后,我们使用strokeRect()便利方法在画布上绘制矩形。
行动时间 - 绘制心电图
现在我们将扩展我们的组件并实现其主要功能——绘制类似心电图的图形。
将以下属性声明添加到canvas对象中:
property int lineWidth: 2
property var points: []
property real arg: -Math.PI
在Canvas部分中,添加一个定时器的声明,该定时器将触发图片的更新:
Timer {
interval: 10
repeat: true
running: true
onTriggered: {
canvas.arg += Math.PI / 180;
while(canvas.arg >= Math.PI) {
canvas.arg -= 2 * Math.PI;
}
}
}
然后,再次在Canvas部分中,定义当arg的值被修改时的处理程序:
onArgChanged: {
points.push(func(arg));
points = points.slice(-canvas.width);
canvas.requestPaint();
}
此处理程序使用自定义 JavaScript 函数——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。然后,我们添加一个定时器,以固定间隔进行滴答,将arg增加 1°,直到它达到+π,在这种情况下,它将重置为初始值。
在我们实现的处理器中,对arg的更改会被拦截。在那里,我们将一个新项目推送到点的数组中。这个值是通过func函数计算的,该函数相当复杂,但可以简单地说它返回一个在-1 到+1 范围内的值。使用Array.slice()从点的数组中删除最旧的记录,这样数组中最多只保留canvas.width个最后的项目。这样做是为了我们可以为画布宽度的每个像素绘制一个点,而且我们不需要存储比所需更多的数据。在函数的末尾,我们调用requestPaint(),这相当于QWidget::update(),并安排画布的重绘。
然后,这会调用我们的onPaint信号处理器。在那里,在检索上下文后,我们将画布重置到其初始状态,然后使用slice()计算要再次绘制的点的数组。然后,我们通过在垂直轴上*移和缩放画布来准备画布,以便将原点移动到画布高度的一半(这就是为什么在程序开始时调用reset()的原因——为了撤销这种转换)。之后,调用beginPath()来通知上下文我们开始构建一个新的路径。然后,通过附加线条分段构建路径。每个值都乘以canvas.height / 2,以便将点数组的值缩放到项目的大小。由于画布的垂直轴增长到底部,我们想要正值在原点线上方,因此该值被取反。之后,我们设置笔的宽度并通过调用stroke()来绘制路径。
行动时间 - 隐藏属性
如果我们将心跳画布转换为 QML 组件,points和arg属性将是组件用户可见的公共属性。然而,它们实际上是我们要隐藏的实现细节。我们应该只公开对组件用户有意义的属性,例如lineWidth或color。
由于Canvas内部的Timer对象没有作为公共属性导出,因此该定时器对象将无法从外部访问,因此我们可以将属性附加到定时器而不是顶级Canvas对象。然而,从逻辑上讲,这些属性不属于定时器,因此这种解决方案可能会令人困惑。对于这种情况,有一个约定,你应该在顶级对象中创建一个空的QtObject子对象并将属性移动到其中:
Canvas {
id: canvas
property int lineWidth: 2
//...
QtObject {
id: d
property var points: []
property real arg: -Math.PI
function func(argument) { /* ... */ }
onArgChanged: { /* ... */ }
}
//...
}
QtObject是QObject类的 QML 表示。它是一个没有特定功能但可以持有属性的 QML 类型。作为约定的一部分,我们将此对象的id设置为d。onArgChanged处理器也被移动到私有对象。在onTriggered和onPaint处理器中,我们现在应该引用内部属性为d.points和d.arg。考虑以下示例:
onTriggered: {
d.arg += Math.PI / 180;
while(d.arg >= Math.PI) {
d.arg -= 2 * Math.PI;
}
}
points 和 arg 属性现在从外部不可用,这导致我们的心跳对象具有干净的外部接口。
行动时间 – 使图表更加多彩
该图表完成了其目的,但看起来有点单调。通过在画布对象中定义三个新的颜色属性——color、topColor 和 bottomColor——并分别将它们的默认值设置为 black、red 和 blue 来给它添加一些光泽:
property color color: "black"
property color topColor: "red"
property color bottomColor: "blue"
然后,让我们通过扩展 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(i = 1; i < pointsToDraw.length; i++) {
ctx.lineTo(i, -pointsToDraw[i] * canvas.height / 2);
}
ctx.lineWidth = canvas.lineWidth;
ctx.strokeStyle = canvas.color;
ctx.stroke();
}
运行前面的代码片段后,你将得到以下输出:

刚才发生了什么?
我们对 onPaint 实现所做的修改是创建另一个路径,并使用该路径通过渐变填充一个区域。该路径与原始路径非常相似,但它包含两个额外的点,即第一个和最后一个绘制到水*轴上的点。这确保了渐变能够正确填充区域。请注意,画布使用命令式代码进行绘图;因此,填充和描边的绘制顺序很重要——填充必须先绘制,以免遮挡描边。
使用 C++类作为 QML 组件
在下一个练习中,我们将实现一个可用于赛车游戏的汽车仪表盘,并显示当前速度和每分钟发动机转速等参数。输入数据将由一个 C++对象提供。我们将看到如何将此对象包含到 QML 对象树中,并使用属性绑定来实现仪表盘。
最终结果将类似于以下内容:

行动时间 – 自更新汽车仪表盘
我们将从 C++部分开始。设置一个新的 Qt Quick 应用程序。这将为你生成主函数,该函数实例化 QGuiApplication 和 QQmlApplicationEngine 并将它们设置为加载 QML 文档。
使用 文件 菜单创建 新建文件或项目 并创建一个新的 Qt Designer 表单类。将其命名为 CarInfo 并选择 Widget 模板。我们将使用此类来修改不同参数的值,以便我们可以观察它们如何影响 Qt Quick 场景的显示。在类声明中,添加以下属性:
class CarInfo : public QWidget {
Q_OBJECT
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(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);
}
你应该能够自己跟随示例来处理剩余的属性。
由于我们希望使用小部件来调整属性值,因此使用表单编辑器设计其用户界面。它可以看起来像这样:

在小部件中建立适当的信号-槽连接,以便修改给定参数的任何小部件或直接使用设置器槽更新该参数的所有小部件。
而不是在 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)。在继续下一步之前,确保一切按预期工作(即,移动滑块和更改微调框值反映小部件属性的变化)。
我们现在将 Qt Quick 加入到等式中,所以让我们首先更新我们的主函数以显示我们的场景。向代码中引入以下高亮显示的更改:
int main(int argc, char **argv) {
QApplication app(argc, argv);
CarInfo cinfo;
QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("carData", &cinfo);
engine.load(QUrl(QStringLiteral("qrc:/main.qml")));
if (engine.rootObjects().isEmpty())
return -1;
cinfo.show();
return app.exec();
}
这些修改创建了一个用于场景的 QML 引擎,将 CarInfo 实例导出到 QML 引擎的全局上下文中,并从资源中的文件加载并显示场景。
首先导出所有对象,然后才加载场景,这是非常重要的。这是因为我们希望在场景初始化时所有名称都已可解析,以便它们可以立即使用。如果我们颠倒调用顺序,我们会在控制台得到许多关于身份未定义的警告。
最后,我们可以专注于 QML 部分。看看练习开始时我们想要显示的结果的图片。对于黑色背景,我们使用了一个在图形编辑器中创建的位图图像(你可以在本书的材料中找到该文件),但你也可以通过在 Qt Quick 中直接组合三个黑色圆角矩形来获得类似的效果——两个外部分是完美的圆形,而内部模块是一个水*拉伸的椭圆。
如果你决定使用我们的背景文件(或制作你自己的更漂亮的图片),你应该将其添加到项目的资源中,并将以下代码放入 main.qml:
import QtQuick 2.9
import QtQuick.Window 2.3
Window {
visible: true
width: backgroundImage.width
height: backgroundImage.height
Image {
id: backgroundImage
source: "qrc:/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.9
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;
var px = width / 2 + radius * Math.cos(a);
var py = width / 2 + radius * Math.sin(a);
return Qt.point(px, py);
}
函数将度数转换为弧度,并返回所需点。该函数期望 width 属性可用,这有助于计算圆的中心,如果没有给出半径,则作为计算其可行值的手段。
有这样的函数可用后,我们可以使用已经熟悉的 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.9
Text {
color: "white"
font.pixelSize: 24
}
旋钮由一个重复器组成,将创建 12 个元素。每个元素都是使用之前描述的函数定位的项目。项目有一个标签与之锚定,显示给定的速度。我们使用120 + index * 12 * 2作为角度表达式,因为我们希望“0”位于 120 度,每个后续项目再额外 24 度。
指针的旋转是基于从carData对象读取的值。由于连续 20 公里每小时标签之间的角度距离是 24 度,因此每 1 公里每小时的距离是 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 元素的 border 属性或 Item 元素的 anchors 属性。让我们看看如何为我们的公开对象定义这样的属性。
创建一个新的 QObject 派生类,并将其命名为 CarInfoEngine。将 rpm 和 gear 的属性定义及其获取器、设置器和更改信号移动到这个新类中。向 CarInfo 添加以下属性声明:
Q_PROPERTY(QObject* 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 + (carData.engine.rpm * 35)
}
}
刚才发生了什么?
实际上,我们所做的是在 CarInfo 中公开了一个属性,该属性本身是一个对象,它公开了一组属性。这个 CarInfoEngine 类型的对象绑定到它所引用的 CarInfo 实例。
行动时间 - 将 C++ 类注册为 QML 类型
到目前为止,我们所做的是公开了在 C++ 中创建和初始化的单个 QML 对象。然而,我们可以做更多——框架允许我们定义新的 QML 类型。这些类型可以是通用的 QObject 派生 QML 元素,或者针对 Qt Quick 专门化的项目。
我们将从简单的事情开始——将 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 文档之前。一个典型的放置函数调用的地方是程序的主函数。之后,你可以在你的文档中开始声明Foo类型的对象。只需记住,你必须首先导入相应的模块:
import QtQuick 2.9
import foo.bar.baz 1.0
Item {
Foo {
id: foo
}
}
是时候行动了——使CarInfo可从 QML 实例化
首先,我们将更新 QML 文档以创建CarInfo 1.0模块中存在的CarInfo实例:
import QtQuick 2.9
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);
QQmlApplicationEngine engine;
// this code does not work
qmlRegisterType<CarInfo>("CarInfo", 1, 0, "CarInfo");
//...
}
通常情况下,这会起作用(是的,就这么简单)。然而,它不适用于小部件。无法将基于QWidget的对象包含到 QML 对象树中,因为QWidget对象只能将另一个QWidget对象作为其父对象,而 QML 需要将外部 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;
这样,CarInfo小部件将没有父对象nullptr,因此它将作为一个顶级窗口显示。QML 引擎将创建一个CarInfoProxy类的对象,并将其父对象设置为另一个 QML 对象,但这不会影响小部件的父对象。
接下来,实现缺失的接口。为了简单起见,我们展示了部分属性的代码。其他属性类似,所以你可以自己填补空白:
public:
CarInfoProxy(QObject *parent = nullptr) : QObject(parent) {
connect(&m_car, &CarInfo::engineChanged,
this, &CarInfoProxy::engineChanged);
connect(&m_car, &CarInfo::speedChanged,
this, &CarInfoProxy::speedChanged);
}
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 元素。因此,它的属性可以直接在文档中设置和修改,对吧?如果你尝试设置速度或距离,它将正常工作。然而,尝试设置分组在engine属性中的属性:
CarInfo {
id: carData
visible: true
engine.gear: 3
}
QML 运行时会发出类似于以下的消息:
Cannot assign to non-existent property "gear"
engine.gear: 3
^
这是因为运行时不理解 engine 属性——我们将其声明为 QObject,但我们使用了一个这个类没有的属性。为了避免这个问题,我们必须让运行时了解 CarInfoEngine。
首先,让我们更新属性声明宏,使用 CarInfoEngine 而不是 QObject:
Q_PROPERTY(CarInfoEngine* engine READ engine NOTIFY engineChanged)
此外,获取函数本身:
CarInfoEngine* engine() const {
return m_engine;
}
你应该在 CarInfo 和 CarInfoProxy 类中做出这些更改。然后,我们应该让运行时了解这个类型:
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。
快速问答
Q1. 哪种 QML 类型允许你为稍后实例化的对象创建一个占位符?
-
Repeater -
Loader -
Component
Q2. 哪种 QML 类型提供了对单个触摸事件的低级访问?
-
PinchArea -
MouseArea -
MultiPointTouchArea
Q3. 在什么情况下,你可以访问另一个 QML 文件中定义的组件而不需要导入语句?
-
如果你使用
qmlRegisterType函数注册了组件,你可以这样做 -
如果你将组件文件添加到项目资源中,你可以这样做
-
如果你将组件文件放在与当前文件相同的目录中,你可以这样做
概述
你现在已经熟悉了多种可以用来扩展 Qt Quick 以添加自定义项目类型的方法。你学习了如何使用 JavaScript 创建自定义视觉项目。你还知道如何使用 C++ 类作为完全集成到你的 UI 中的非视觉 QML 元素。我们还讨论了如何在 Qt Quick 应用程序中处理鼠标、触摸、键盘和游戏手柄事件。然而,到目前为止,尽管我们谈论了“流畅”和“动态”的界面,但你还没有看到很多这样的界面。不要担心;在下一章中,我们将专注于 Qt Quick 中的动画以及华丽的图形,并将本章所学应用于创建看起来漂亮且有趣的游戏。所以,继续阅读吧!
第十三章:Qt Quick 游戏中的动画
在前两章中,我们向您介绍了 Qt Quick 和 QML 的基础知识。到现在,您应该已经足够熟练地掌握了语法,并理解了 Qt Quick 的工作基本概念。在本章中,我们将向您展示如何通过引入不同类型的动画,使您的游戏与众不同,使您的应用程序感觉更像现实世界。您还将学习如何将 Qt Quick 对象视为可使用状态机编程的独立实体。由于书籍无法包含动态图像,所以您将不得不通过运行提供的 Qt Quick 代码来自行测试我们描述的大多数内容。
本章涵盖的主要主题如下:
-
Qt Quick 中的动画框架
-
深入理解状态和转换
-
在 Qt Quick 中实现游戏
-
精灵动画
-
使用状态机进行动画
-
垂直滚动
-
碰撞检测
Qt Quick 中的动画框架
在 第十一章 “Qt Quick 简介”中,我们使用 Qt Quick 状态和转换实现了简单的动画。现在,我们将深化对这个主题的了解,并学习如何在我们创建的用户界面中添加一些动态效果。到目前为止,书籍无法包含动态图像,所以您将不得不通过运行提供的 Qt Quick 代码来自行测试我们描述的大多数内容。
Qt Quick 提供了一个非常广泛的框架来创建动画。我们这里所说的不仅仅是指移动项目。我们定义动画为“随时间改变任意值”。那么,我们可以动画化什么?当然,我们可以动画化项目几何形状。然而,我们还可以动画化旋转、缩放、其他数值,甚至是颜色,但让我们不要止步于此。Qt Quick 还允许您动画化项目的父子层次结构或锚点分配。几乎任何可以用项目属性表示的东西都可以进行动画化。
此外,变化很少是线性的——如果你把球踢到空中,它首先会迅速上升,因为它的初始速度很大。然而,球是一个受到地球重力拉扯的物理对象,这会减缓上升速度,直到球停止并开始下落,加速直到它触地。根据地面和球体的属性,物体可以弹起再次进入空中,动量减少,重复弹簧般的运动,直到最终它逐渐消失,球落在地上。Qt Quick 允许您使用可以分配给动画的缓动曲线来模拟所有这些。
通用动画
Qt Quick 提供了多种从通用 Animation 元素派生出的动画类型,您永远不会直接使用。这种类型的存在只是为了提供不同动画类型共有的 API。
通过查看从最常见的动画类型PropertyAnimation派生的一系列动画类型,让我们更深入地了解动画框架。正如其名所示,它们提供了动画化对象属性值的方法。尽管您可以直接使用PropertyAnimation元素,但通常使用其子类会更方便,这些子类专门处理不同数据类型的特殊性。
最基本的属性动画类型是NumberAnimation,它允许您对整数和实数的所有数值进行动画处理。使用它的最简单方法是声明一个动画,告诉它在一个特定对象中动画化一个特定的属性,然后设置动画的长度以及属性的起始和结束值:
import QtQuick 2.9
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 Creator 的文件菜单中选择新建文件或项目,切换到其他项目类别,并选择 Qt Quick UI 原型模板。Qt Creator 将创建一个主 QML 文件和一个具有.qmlproject扩展名的项目文件。这种项目文件与具有.pro扩展名的常规项目文件不同。这是一个纯 QML 项目,不包含任何 C++代码,因此不需要编译。但是,您需要一个 QML 运行时环境来运行此项目。您的 Qt 安装提供了这样的环境,因此您可以使用qmlscene main.qml命令从终端运行项目,或者让 Qt Creator 处理。请注意,这些项目不使用 Qt 资源系统,QML 文件直接从文件系统中加载。
如果您需要向项目中添加 C++代码或您打算分发项目的编译二进制文件,请使用 Qt Quick 应用程序模板。正如其名所示,Qt Quick UI 原型模板仅适用于原型。
在项目目录中,创建一个名为images的子目录,并从使用 Graphics View 创建的游戏项目中复制grass.png、sky.png和trees.png。然后,将以下代码放入 QML 文档中:
import QtQuick 2.9
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
}
}
如果您没有声明顶层Window对象,qmlscene将自动在一个窗口中显示顶层 Qt Quick 项。请注意,当编写由QQmlApplicationEngine类驱动的 Qt Quick 应用程序时,您需要显式声明Window对象。
当您现在运行项目时,您将看到一个类似于这个的屏幕:

刚才发生了什么?
我们设置了一个非常简单的场景,由三张图片堆叠形成景观。在背景层(天空)和前景(树木)之间,我们放置了一个代表太阳的黄色圆圈。由于我们很快就会移动太阳,我们将对象的中心锚定到一个没有物理尺寸的空项上,这样我们就可以设置太阳相对于其中心的位置。我们还为场景配备了一个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
running: true
NumberAnimation {
target: obj1; property: "prop1"
from: 0; to: 100
duration: 1500
}
NumberAnimation {
target: obj2; property: "prop2"
from: 150; to: 0
duration: 1500
}
}
同样的技术可以用来同步更大的动画组,即使每个组件的持续时间不同:
SequentialAnimation {
id: sequentialAnimationGroup
running: true
ParallelAnimation {
id: parallelAnimationGroup
NumberAnimation {
id: animation1
target: obj2; property: "prop2"
from: 150; to: 0
duration: 1000
}
NumberAnimation {
id: animation2
target: obj1; property: "prop1"
from: 0; to: 100
duration: 2000
}
}
PropertyAnimation {
id: animation3
target: obj1; property: "prop1"
from: 100; to: 300
duration: 1500
}
}
段落中展示的组由三个动画组成。前两个动画作为一个并行子组一起执行。组中的一个成员的运行时间是另一个的两倍。只有当整个子组完成时,第三个动画才开始。这可以通过一个 统一建模语言 (UML) 活动图来可视化,其中每个活动的尺寸与该活动的持续时间成比例:

动作时间 - 制作日出日落
让我们通过向 QML 文档中添加一系列动画来给我们的太阳添加垂直运动(y 属性的动画)。由于我们的新动画将与水*动画并行运行,我们可以将两个方向的动画都包含在一个单独的 ParallelAnimation 组中。这会起作用,但据我们看来,这会不必要地使文档变得杂乱。指定并行动画的另一种方法是声明它们为独立的元素层次结构,使每个动画独立于其他动画,这正是我们将在这里做的。
打开上一练习中的文档,并在上一个动画下方放置以下代码:
SequentialAnimation {
running: true
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
}
}
运行程序将在早晨使光源升起,在傍晚落下。然而,移动的轨迹似乎有些笨拙:

刚才发生了什么?
我们声明了一个包含三个动画的顺序动画组,每个动画占用一天长度的三分之一。组中的第一个成员使太阳升起。第二个成员是一个新元素类型——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
running: true
}
行动时间 – 改善太阳的路径
当前任务将是改善太阳的动画,使其表现得更加逼真。我们将通过调整动画,使对象沿着曲线路径移动。
在我们的 QML 文档中,将之前的垂直动画替换为以下动画:
SequentialAnimation {
running: true
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
}
}
以下图片显示了太阳现在将如何移动:

刚才发生了什么?
三个动画的序列(两个线性动画和一个暂停)被另一个由三次函数确定的路径的动画序列所取代。这使得我们的太阳升起很快,然后减速到几乎在太阳接*中午时几乎察觉不到的程度。当第一个动画完成后,第二个动画反转运动,使太阳缓慢下降,然后在黄昏临*时增加速度。结果,太阳离地面越远,它看起来移动得越慢。同时,水*动画保持线性,因为地球在其围绕太阳的运动中速度实际上是恒定的。当我们结合水*和垂直动画时,我们得到一条看起来非常类似于我们在现实世界中可以观察到的路径。
属性值来源
从 QML 的角度来看,Animation及其派生类型被称为属性值源。这意味着它们可以附加到属性并为其生成值。重要的是,它允许我们使用更简单的语法使用动画。而不是显式声明动画的目标和属性,你可以将动画附加到父对象的命名属性。
要这样做,对于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标识符)或仅应用于由重复器生成的特定矩形。
行为
在上一章中,我们实现了一个赛车游戏的仪表盘,其中包含多个带有指针的时钟。我们可以为每个时钟设置值(例如,汽车速度),相应的指针会立即调整到给定的值。然而,这种方法是不现实的——在现实世界中,值的改变是随着时间的推移发生的。在我们的例子中,汽车通过 11 英里/小时、12 英里/小时等逐步加速,直到一段时间后达到期望的值。我们称这为值的行为——它本质上是一个模型,告诉参数如何达到其目标值。定义这样的模型是声明性编程的完美用例。幸运的是,QML 公开了一个Behavior元素,允许我们模拟 Qt Quick 中属性变化的动态行为。
Behavior元素允许我们将动画与给定的属性关联起来,以便每次属性值需要更改时,它都会通过运行给定的动画而不是直接更改属性值来完成。
考虑以下代码定义的简单场景:
import QtQuick 2.9
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;
}
}
}
这个场景包含一个锚定到空项上的红色矩形。每当用户在场景内点击某处时,空项就会移动到那里,并拖动矩形。让我们看看如何使用Behavior元素来*滑地改变空项的位置。类似于Animation和其他属性值源,Behavior元素可以使用 on 属性语法:
Item {
id: empty
x: parent.width / 2; y: parent.height / 2
Rectangle {
id: rect
width: 100; height: width
color: "red"
anchors.centerIn: parent
}
Behavior on x {
NumberAnimation { }
}
Behavior on y {
NumberAnimation { }
}
}
通过添加两个标记的声明,我们为NumberAnimation定义的行为定义了x和y属性的行为。我们不包含动画的起始或结束值,因为这些将取决于属性的初始和最终值。我们也没有在动画中设置属性名称,因为默认情况下,定义行为所用的属性将被使用。因此,我们得到一个从原始值到目标值的线性动画,持续时间为默认值。
对于现实世界中的对象,使用线性动画通常看起来不太好。通常,如果你为动画设置一个缓动曲线,那么它将开始缓慢,然后加速,并在完成前减速,你会得到更好的结果。
设置在行为上的动画可以像你想要的那样复杂:
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%,然后在动画的后半部分将其缩小回原始大小。
行动时间 - 动画汽车仪表板
让我们运用刚刚学到的知识来改进我们在上一章中创建的汽车仪表板。我们将使用动画来展示时钟更新值时的逼真效果。
打开仪表板项目并导航到main.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
width: 200
height: 200
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
PropertyChanges { /*...*/ }
},
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
}
MouseArea {
anchors.fill: parent
onPressed: {
lamp.lampOn = !lamp.lampOn;
}
}
states: State {
name: "on"
when: lamp.lampOn
PropertyChanges {
target: lightsource
opacity: 1
}
}
transitions: Transition {
NumberAnimation {
duration: 500
property: "opacity"
}
}
}
转换会触发任何源状态和任何目标状态——当灯从匿名状态变为“开启”状态以及相反方向时,它将是活动的。它定义了一个单个的NumberAnimation元素,该元素作用于opacity属性,持续 500 毫秒。动画没有定义目标对象;因此,它将为需要作为转换一部分更新的任何对象执行——在灯的情况下,它将仅是lightsource对象。
如果在转换中定义了多个动画,所有动画将并行运行。如果您需要顺序动画,您需要显式使用SequentialAnimation元素:
Transition {
SequentialAnimation {
NumberAnimation {
target: lightsource
property: "opacity"
duration: 500
}
ScriptAction {
script: {
console.log("Transition has ended");
}
}
}
}
状态是所有Item类型及其派生类型的一个特性。然而,使用StateGroup元素,可以与未从Item对象派生的元素一起使用状态,StateGroup是一个包含状态和转换的自包含功能,具有与这里描述的Item对象完全相同的接口。
更多动画类型
我们之前讨论的动画类型用于修改可以使用物理度量描述的类型(位置、大小、颜色、角度)的值。然而,还有更多类型可用。
第一组特殊动画包括AnchorAnimation和ParentAnimation元素。
AnchorAnimation元素在需要状态改变导致项目定义的锚点发生变化时非常有用。没有它,项目会立即跳到其位置。通过使用AnchorAnimation元素,我们可以触发所有锚点变化逐渐动画化。
另一方面,ParentAnimation元素使得在项目获得新的父元素时定义动画成为可能。这通常会导致项目在场景中移动到不同的位置。通过在状态转换中使用ParentAnimation元素,我们可以定义项目如何进入其目标位置。该元素可以包含任何数量的子动画元素,这些元素将在ParentChange元素期间并行运行。
第二组特殊动画是动作动画——PropertyAction和ScriptAction。这些动画类型不是在时间上拉伸,而是执行给定的一次性动作。
PropertyAction元素是一种特殊的动画,它将属性立即更新到给定的值。它通常作为更复杂动画的一部分使用,以修改未动画化的属性。如果属性需要在动画期间具有某个特定值,则使用它是有意义的。
ScriptAction是一个元素,允许在动画期间执行一个命令式的代码片段(通常在其开始或结束时)。
快速游戏编程
在这里,我们将通过使用 Qt Quick 创建*台游戏的过程。这将是一款类似于第六章中提到的本杰明大象的游戏。第六章,Qt 核心基础。玩家将控制一个角色,该角色将在景观中行走并收集金币。金币将在世界中随机出现。角色可以通过跳跃来获取高处的金币。
在本章以及上一章中,我们准备了一些零件,我们将为这个游戏重新使用它们。当你学习动画时安排的分层场景将作为我们的游戏场景。动画太阳将代表时间的流逝。
我们将引导你实现游戏的主要功能。在本章结束时,你将有机会通过添加更多游戏机制来测试你的技能。
游戏循环
大多数游戏都围绕某种游戏循环展开。这通常是一种被反复调用的函数,其任务是推进游戏——处理输入事件、移动对象、计算并执行动作、检查胜利条件等等。这种做法非常命令式,通常会导致一个非常复杂的函数,需要了解每个人的所有信息(这种反模式有时被称为神对象模式)。在 QML(为 Qt Quick 框架提供动力)中,我们旨在分离责任并为特定对象声明定义良好的行为。因此,尽管可以设置一个定时器,定期调用游戏循环函数,但这在声明性世界中并不是最佳方法。
相反,我们建议使用 Qt Quick 中已经存在的自然时间流动机制——它控制动画的一致性。还记得我们在本章开头定义太阳在天空中移动的方式吗?我们不是设置一个定时器并通过计算像素数来移动对象,而是创建了一个动画,为它定义了总运行时间,并让 Qt 负责更新对象。这有一个巨大的好处,就是忽略了函数执行中的延迟。如果你使用定时器,而某些外部事件在超时函数运行之前引入了显著的延迟,动画就会开始落后。当使用 Qt Quick 动画时,框架会补偿这些延迟,跳过一些帧更新,以确保尊重请求的动画持续时间。因此,你将不必自己处理所有这些。
为了克服游戏循环的第二个难点——上帝对象反模式,我们建议使用我们之前介绍的状态和转换框架,将每个项目的逻辑直接封装在项目本身中。如果你定义一个对象,使用自然的时间流动描述其在生命周期中可以进入的所有状态以及导致状态转换的动作,你就可以将包含行为的对象随意放置在任何需要的地方,从而在不同游戏中轻松重用此类定义,减少使对象适应游戏所需的工作量。
输入处理
游戏中的一种常见方法是读取输入事件并调用与特定事件相关的动作的函数:
void Scene::keyEvent(QKeyEvent *event) {
switch(event->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: "images/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 像素,然后再次将其移回初始位置。
这种方法可行,但我们能做得更好。让我们尝试一些不同的方法。
行动时间 – 另一种角色导航方法
用以下代码替换之前的键处理程序:
Item {
id: player
//...
QtObject {
id: flags
readonly property int speed: 100
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.isAutoRepeat) return;
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 {
origin.x: coinVisual.width / 2
origin.y: coinVisual.height / 2
axis { x: 0; y: 1; z: 0 }
NumberAnimation on angle {
from: 0; to: 360
loops: Animation.Infinite
running: true
duration: 1000
}
}
Text {
color: coinVisual.border.color
anchors.centerIn: parent
text: "1"
}
}
}
接下来,打开定义场景的文档,并在场景定义中某处输入以下代码:
Component {
id: coinGenerator
Coin {}
}
Timer {
id: coinTimer
interval: 1000
repeat: true
running: true
onTriggered: {
var cx = Math.floor(Math.random() * root.width);
var cy = Math.floor(Math.random() * root.height / 3)
+ root.height / 2;
coinGenerator.createObject(root, { 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 {
id: elephantImage
property int currentFrame: 1
property int frameCount: 7
source: "images/walking_" + currentFrame + ".png"
mirror: player.facingLeft
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
NumberAnimation on currentFrame {
from: 1
to: frameCount
loops: Animation.Infinite
duration: elephantImage.frameCount * 40
running: player.walking
}
}
在Player.qml的根元素中添加以下属性:
property bool walking: flags.horizontal !== 0
property bool facingLeft: flags.horizontal < 0
启动程序并使用箭头键查看 Benjamin 移动。
刚才发生了什么?
准备了一系列图像,遵循包含数字的常见命名模式。所有图像都具有相同的大小。这使得我们只需通过更改source属性的值来指向不同的图像,就可以替换一个图像。为了简化操作,我们引入了一个名为currentFrame的属性,它包含要显示的图像的索引。我们在字符串中使用了currentFrame元素,形成一个绑定到图像source元素的表达式。为了使帧替换更容易,我们声明了一个NumberAnimation元素,以循环方式修改currentFrame元素的值,从1到可用的动画帧数(由frameCount属性表示),以便每帧显示 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 元素,它表示整个动画的持续时间。你可以设置这三个属性中的任何一个,但不需要设置超过一个。
行动时间 - 使用精灵进行角色动画
让我们不再等待。当前的任务是将之前练习中的手动动画替换为精灵图动画。
打开Player.qml文档,删除负责显示玩家角色的整个图像元素,并添加以下代码:
AnimatedSprite {
id: sprite
source: "images/sprite.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/sprite.png中定义了一个精灵,宽度为80像素,高度为52像素。该精灵由七个帧组成,应以每秒 10 帧的速度显示。running
属性的设置与原始的Animation元素类似。作为AnimatedSprite,
元素没有mirror属性,我们通过应用一个翻转项水*方向的缩放变换来模拟它,如果player.facingLeft表达式评估为true。此外,我们设置interpolate属性为true,这使得精灵引擎在帧之间计算更*滑的过渡。
我们留下的结果与早期的尝试相似,所以如果这两个相似,为什么还要使用精灵呢?在许多情况下,你想要的动画比单帧序列更复杂。如果我们想除了走路外还要动画化本杰明的跳跃方式怎么办?嵌入更多的手动动画虽然可能,但会爆炸增加保持对象状态所需的内部变量数量。幸运的是,Qt Quick 精灵引擎可以处理这种情况。我们使用的AnimatedSprite元素提供了整个框架的一部分功能。通过用SpriteSequence元素替换项目,我们获得了精灵的全部功能。当我们谈论Sprite时,我们需要告诉你该对象的一个附加属性,即名为to的属性,它包含从当前精灵到另一个精灵的转换概率映射。通过声明当前精灵迁移到的精灵,我们创建了一个具有加权转换到其他精灵以及循环回当前状态的有限状态机。
切换到另一个精灵是通过在SpriteSequence对象上设置goalSprite属性来触发的。这将导致精灵引擎遍历图直到达到请求的状态。通过经过多个中间状态,这是一种流畅地从一种动画切换到另一种动画的绝佳方式。
你可以不要求精灵机优雅地过渡到给定状态,而是可以通过调用SpriteSequence类的jumpTo()方法并传入应开始播放的精灵名称来强制立即更改。
需要澄清的最后一件事是如何将精灵状态机实际附加到SpriteSequence类。这非常简单——只需将一个Sprite对象的数组分配给sprites属性。
行动时间——添加带有精灵过渡的跳跃
让我们在本杰明大象动画中将AnimatedSprite类替换为SpriteSequence类,为跳跃阶段添加一个要播放的精灵。
打开Player.qml文件,将AnimatedSprite对象替换为以下代码:
SpriteSequence {
id: sprite
width: 80
height: 52
interpolate: false
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
running: true
Sprite {
name: "still"
source: "images/sprite.png"
frameCount: 1
frameWidth: 80; frameHeight: 52
frameDuration: 100
to: { "still": 1, "walking": 0, "jumping": 0 }
}
Sprite {
name: "walking"
source: "images/sprite.png"
frameX: 560; frameY: 0
frameCount: 7
frameWidth: 80; frameHeight: 52
frameRate: 20
to: { "walking": 1, "still": 0, "jumping": 0 }
}
Sprite {
name: "jumping"
source: "images/sprite.png"
frameX: 480; frameY: 52
frameCount: 11
frameWidth: 80; frameHeight: 70
frameDuration: 50
to: { "still" : 0, "walking": 0, "jumping": 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");
}
}
}
刚才发生了什么?
我们引入的SpriteSequence元素具有与AnimatedSprite元素相同的Item元素相关属性设置。除此之外,一个名为“静止”的精灵被明确设置为当前精灵。我们定义了多个Sprite对象作为SpriteSequence元素的子元素。这相当于将这些精灵分配给对象的sprites属性。以下图表展示了声明的完整状态机:

一个名为“静止”的精灵只有一个表示本杰明不动的帧。由于加权转换回“静止”状态,精灵保持在该状态下旋转。从该状态剩余的两个转换具有其权重设置为0,这意味着它们永远不会自发触发,但可以通过将goalSprite属性设置为可以通过激活这些转换之一到达的精灵来调用。
连续动画被扩展,当大象升空时触发精灵变化。
尝试一下英雄——让本杰明在期待中摇尾巴
为了练习精灵过渡,你的目标是扩展本杰明的SpriteSequence元素的州机,使他当大象站立时摇尾巴。你可以在本书附带的材料中找到合适的精灵。精灵字段称为wiggling.png。通过使其从“静止”状态到“摇尾巴”状态的概率性转换来实现此功能。注意确保动物在玩家激活左右箭头键时停止摇尾巴并开始行走。
行动时间——回顾视差滚动
我们已经在第六章中讨论了有用的视差滚动技术,Qt 核心基础。它通过根据层与观察者的假设距离以不同速度移动多个背景层,为 2D 游戏提供了深度感。让我们看看在 Qt Quick 中应用相同技术有多容易。
我们将使用一组移动方向与玩家移动方向相反的层来实现视差滚动。因此,我们需要定义场景和移动层。
创建一个新的 QML 文件(Qt Quick 2)。将其命名为ParallaxScene.qml。场景将包括整个游戏“关卡”,并将玩家的位置暴露给移动层。在文件中放入以下代码:
import QtQuick 2.9
Item {
id: root
property int currentPos
x: -currentPos * (root.width - root.parent.width) / width
}
然后,创建另一个 QML 文件,并将其命名为ParallaxLayer.qml。让它包含以下定义:
import QtQuick 2.9
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: "images/sky.png" }
Item {
id: sun
//...
}
}
ParallaxLayer {
factor: 2.5
width: trees.width; height: trees.height
anchors.bottom: parent.bottom
Image { id: trees; source: "images/trees.png" }
}
ParallaxLayer {
factor: 0
width: grass.width; height: grass.height
anchors.bottom: parent.bottom
Image { id: grass; source: "images/grass.png" }
}
Item {
id: player
// ...
}
Component {
id: coinGenerator
Coin {}
}
Timer {
id: coinTimer
//...
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});
}
}
}
}
你现在可以运行游戏,并观察当玩家移动时背景层的运动:

刚才发生了什么?
我们实现的ParallaxScene元素是一个移动的*面。其水*偏移量取决于角色的当前位置和视图的大小。场景滚动的范围由场景大小和视图大小的差值决定——它说明了当角色从场景的左侧移动到右侧边缘时,我们需要滚动多少,以便它始终在视图中。如果我们用场景宽度作为分数表示的角色与场景左侧边缘的距离乘以这个值,我们就会得到视图中所需要的场景偏移量(或者换句话说,场景的投影偏移量)。
第二种类型——ParallaxLayer——也是一个移动的*面。它定义了一个距离因子,表示背景层相对于前景(场景)的相对距离(深度),这影响了*面相对于前景(场景)滚动的速度。0的值意味着层应该以与前景层完全相同的速度移动。值越大,层相对于角色的移动速度越慢。偏移值是通过将场景中角色的位置除以因子来计算的。由于前景层也在移动,我们必须在计算每个视差层的偏移量时考虑它。因此,我们从场景的水*位置中减去以获得实际的层偏移量。
在逻辑上定义了层之后,我们可以将它们添加到场景中。在我们的例子中,每个层都有一个物理表示,包含天空、树木和草地纹理的静态图像。每个层都是单独定义的,可以独立存在,包含静态和动画元素,这些元素对其他层没有影响。例如,我们将太阳对象放入天空层,这样它除了播放自己的动画外,还会随着天空层移动。
最后,由于我们不再有root元素,我们修改了coinTimer处理程序,以使用scene元素代替。
尝试一下英雄垂直视差滑动
作为额外的练习,你可能还想实现垂直视差滑动,除了水*滑动。只需让场景更大,并使其除了报告currentPos元素的水*滚动位置外,还暴露垂直滚动位置。然后,只需重复对每一层的y属性的所有计算,你很快就能完成。记住,x和y的距离因子可能不同。
碰撞检测
Qt Quick 中没有内置的碰撞检测支持,但有三种提供此类支持的方法。首先,你可以使用 Box2D 等许多 2D 物理引擎中可用的现成碰撞系统。其次,你可以自己用 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" as Collisions
var coins = []
coins.collisionsWith = function(player) {
var collisions = [];
for(var index = 0; index < coins.length; ++index) {
var obj = this[index];
if(Collisions.intersect(player, obj)) {
collisions.push(obj);
}
}
return collisions;
};
coins.remove = function(obj) {
var arr = Array.isArray(obj) ? obj : [ obj ];
var L = arr.length;
var idx, needle;
while(L && this.length) {
needle = arr[--L];
idx = this.indexOf(needle);
if(idx !== -1) {
this.splice(idx, 1);
}
}
return this;
};
最后,打开main.qml文件,并添加以下import语句:
import "coins.js" as Coins
在玩家对象中,定义checkCollisions()函数:
function checkCollisions() {
var result = Coins.coins.collisionsWith(player);
if(result.length === 0) return;
result.forEach(function(coin) { coin.hit() });
Coins.coins.remove(result) // prevent the coin from being hit again
}
接下来,修改coinTimer处理程序,将新的硬币推送到列表中:
Timer {
id: coinTimer
//...
onTriggered: {
var cx = Math.floor(Math.random() * scene.width);
var cy = scene.height - 60 - Math.floor(Math.random() * 60);
var coin = coinGenerator.createObject(scene, { x: cx, y: cy});
Coins.coins.push(coin);
}
}
最后,在同一个玩家对象中,通过处理玩家位置的变化来触发碰撞检测:
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 library语句,指出该文档只包含函数,不包含任何可变对象。此语句将文档标记为库,可以在导入它的文档之间共享。这有助于减少内存消耗并提高速度,因为引擎不需要在每次导入时重新解析和执行文档。
库中定义的函数非常简单。第一个函数根据对象的坐标和子项的大小返回对象的边界矩形。它假设顶级项为空,并包含代表对象视觉方面的子项。子项坐标使用mapToItem函数进行映射,以便返回的矩形以父项坐标表示。第二个函数对两个边界矩形进行简单的交集检查,如果它们相交则返回true,否则返回false。
第二个文档保存了一个硬币数组的定义。它向数组对象添加了两个方法。第一个方法——collisionsWith——使用在collisions.js中定义的函数在数组中的任何项目与给定对象之间执行碰撞检查。这就是为什么我们在文档开头导入库的原因。该方法返回另一个包含与player参数相交的对象的数组。另一个方法,称为remove,接受一个对象或对象数组,并将它们从coins中移除。
该文档不是一个库;因此,每个导入coins.js的文档都会得到该对象的独立副本。因此,我们需要确保在游戏中只导入一次coins.js,以便该文档中定义的对象的所有引用都与程序内存中对象的同一实例相关。
我们的主要文档导入coins.js,它创建用于存储硬币对象的数组,并使其辅助函数可用。这使得定义的checkCollisions()函数能够检索与玩家碰撞的硬币列表。对于每个与玩家碰撞的硬币,我们执行一个hit()方法;作为最后一步,所有碰撞的硬币都被从数组中移除。由于硬币是静止的;碰撞只能在玩家角色进入硬币占据的区域时发生。因此,当玩家角色的位置改变时触发碰撞检测就足够了——我们使用onXChanged和onYChanged
处理程序。
由于击中硬币会导致从数组中移除它,我们失去了对该对象的引用。hit()方法必须启动从场景中移除对象的过程。这个函数的最小化实现可能只是调用对象的destroy()函数,但我们做得更多——通过在硬币上运行淡出动画可以使移除过程更加*滑。作为最后一步,动画可以销毁对象。
我们在场景中跟踪的对象数量非常小,我们将每个对象的形状简化为矩形。这使得我们可以通过 JavaScript 进行检查碰撞。对于大量移动对象、自定义形状和旋转处理,基于 C++的碰撞系统会更好。这种系统的复杂程度取决于您的需求。
尝试一下英雄 - 扩展游戏
您可以通过在我们的跳跃大象游戏中实现新的游戏机制来磨练您的游戏开发技能。例如,您可以引入疲劳的概念。角色跳得越多,就越累,它们开始移动的速度就越慢,并且需要休息以恢复速度。为了使游戏更具挑战性,有时可以生成移动障碍。当角色撞到任何一个障碍时,它们就会变得越来越累。当疲劳超过一定水*时,角色就会死亡,游戏结束。我们之前创建的心率图可以用来表示角色的疲劳程度——角色越累,心率就越快。
这些变化可以通过许多方式实现,我们希望给您一定的自由度,因此我们不会提供如何实现完整游戏的逐步指南。您已经对 Qt Quick 了解很多,这是一个测试您技能的好机会!
快速问答
Q1. 以下哪种类型不能与特殊的属性语法一起使用?
-
Animation -
Transition -
Behavior
Q2. 哪种 QML 类型允许您配置具有多个状态之间转换的精灵动画?
-
SpriteSequence -
Image -
AnimatedSprite
Q3. 哪种 QML 类型能够防止属性值的即时更改,并执行值的逐渐更改?
-
Timer -
Behavior -
PropertyAction
摘要
在本章中,我们向您展示了如何扩展您的 Qt Quick 技能,使您的应用程序动态且吸引人。我们回顾了将之前用 C++ 创建的游戏重新创建和改进的过程,以便您熟悉碰撞检测、状态驱动对象和时间驱动的游戏循环等概念。您现在已经熟悉了使用 Qt Quick 制作游戏所需的所有最重要的概念。
在下一章中,我们将关注使您的游戏更具视觉吸引力的技术。我们将探索 Qt Quick 提供的内置图形效果。您还将学习如何通过在 C++ 中实现的自定义绘制项来扩展 Qt Quick。这将使您能够创建任何您想象中的视觉效果。
第十四章:Qt Quick 中的高级视觉效果
精灵动画和流畅的过渡并不总是足以使游戏在视觉上吸引人。在本章中,我们将探讨许多方法来为您的游戏添加一些视觉亮点。Qt Quick 提供了相当数量的内置视觉效果,这些效果将非常有用。然而,有时您可能想要做一些标准组件无法完成的事情——一些独特且特定于您游戏的事情。在这些情况下,您不需要限制您的想象力。我们将教您深入 Qt Quick 的 C++ API 以实现真正独特的图形效果。
本章涵盖的主要主题包括:
-
自动缩放用户界面
-
将图形效果应用于现有项目
-
粒子系统
-
Qt Quick 中的 OpenGL 绘图
-
在 Qt Quick 中使用
QPainter
使游戏更具吸引力
一款游戏不应仅仅基于一个有趣的想法,它不仅应该在各种设备上流畅运行并给玩家带来娱乐,还应该看起来很漂亮,表现得很优雅。无论是人们从同一游戏的多个类似实现中选择,还是想要花钱购买价格相似且有趣的另一款游戏,有很大可能性他们会选择看起来最好的游戏——拥有大量的动画、图形和闪亮的元素。我们已经学习了许多使游戏更吸引眼球的技巧,例如使用动画或实现视差效果。在这里,我们将向您展示一些其他技巧,可以使您的 Qt Quick 应用程序更具吸引力。
自动缩放用户界面
您可能首先实现的扩展是使您的游戏自动调整其运行的设备分辨率。基本上有两种方法可以实现这一点。第一种是在窗口(或屏幕)中居中用户界面,如果它不合适,则启用滚动。另一种方法是缩放界面以始终适合窗口(或屏幕)。选择哪一种取决于许多因素,其中最重要的是当界面放大时,UI 是否足够好。如果界面由文本和非图像原语(基本上是矩形)组成,或者如果它包含图像但只有矢量图像或分辨率非常高的图像,那么尝试缩放用户界面可能是可以的。否则,如果您使用了大量的低分辨率位图图像,您将不得不为 UI 选择一个特定的尺寸(可选地允许它降级,因为如果启用抗锯齿,这种方向上的质量下降应该不那么明显)。
无论你选择缩放还是居中滚动,基本方法都是一样的——你将 UI 项放入另一个项中,这样你就可以对 UI 几何形状进行精细控制,无论顶级窗口发生什么变化。采用居中方法非常简单——只需将 UI 锚定到父项的中心。要启用滚动,将 UI 包裹在Flickable项中,并约束其大小,如果窗口的大小不足以容纳整个用户界面:
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.qml文件中,以查看Flickable如何定位 UI 项:
import QtQuick 2.0
Rectangle {
width: 300
height: 300
gradient: Gradient {
GradientStop { position: 0.0; color: "lightsteelblue" }
GradientStop { position: 1.0; color: "blue" }
}
}
如果 UI 项没有占据其父项的全部区域,你可能需要给顶级项装饰一个漂亮的背景。
缩放看起来可能更复杂,但使用 Qt Quick 实际上非常简单。再次强调,你有两个选择——拉伸或缩放。拉伸就像执行anchors.fill: parent命令一样简单,这实际上迫使 UI 重新计算所有其项的几何形状,但可能允许我们更有效地使用空间。通常,对于开发者来说,在视图大小变化时为每个界面元素提供表达式来计算几何形状是非常耗时的。这通常不值得努力。一个更简单的方法是将 UI 项缩放到适合窗口的大小,这将隐式地缩放包含的项。在这种情况下,它们的大小可以相对于用户界面主视图的基本大小来计算。为了使这起作用,你需要计算应用于用户界面的缩放比例,使其填充整个可用空间。当项的有效宽度等于其隐式宽度,其有效高度等于其隐式高度时,项的缩放比例为 1。如果窗口更大,我们希望缩放项,直到它达到窗口的大小。
因此,窗口宽度除以项的隐式宽度将是项在水*方向上的缩放比例。这在上面的图中有所展示:

同样的方法也适用于垂直方向,但如果 UI 的宽高比与窗口不同,其水*和垂直缩放因子也会不同。为了让 UI 看起来更美观,我们必须取两个值中较小的一个——只允许在空间较小的方向上进行缩放,在另一个方向上留下空白:
Window {
//...
UI {
id: ui
anchors.centerIn: parent
scale: Math.min(parent.width / width,
parent.height / height)
}
}
再次强调,给窗口项添加一些背景信息以填补空白可能是个不错的主意。
如果你想在用户界面和窗口之间保留一些边距呢?当然,你可以在计算缩放时考虑这一点(例如(window.width - 2 * margin) / width等),但有一个更简单的方法——只需在窗口内放置一个额外的项,留下适当的边距,并将用户界面项放入该额外项中,并将其缩放到额外项的大小:
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.9
import QtQuick.Window 2.2
import QtGraphicalEffects 1.0
Window {
//...
HeartBeat {
id: heartBeat
anchors.centerIn: parent
visible: false
}
DropShadow {
source: heartBeat
anchors.fill: heartBeat
horizontalOffset: 3
verticalOffset: 3
radius: 8
samples: 16
color: "black"
}
}
要应用阴影效果,你需要一个现有项目作为效果来源。在我们的例子中,我们使用了一个位于顶级项目中心的HeartBeat类实例。然后,定义阴影效果,并使用anchors.fill元素使其几何形状遵循其来源。正如DropShadow类渲染原始项目及其阴影一样,可以通过将visible属性设置为false来隐藏原始项目:

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

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

您也可以通过在文档索引中搜索 QtGraphicalEffects 来访问效果和它们的简短描述的完整列表。
粒子系统
游戏中常用的一种视觉效果是生成大量小、通常寿命短、通常快速移动、模糊的对象,如星星、火花、烟雾、灰尘、雪、碎片、落叶等。将这些作为场景中的常规项目放置会大大降低性能。相反,使用一个特殊的引擎,该引擎维护此类对象的注册表并跟踪(模拟)它们的逻辑属性,而不在场景中具有物理实体。这些称为 粒子 的对象,在请求时使用非常高效的算法在场景中进行渲染。这允许我们使用大量粒子,而不会对场景的其他部分产生负面影响。
Qt Quick 在 QtQuick.Particles 导入中提供了一个粒子系统。ParticleSystem 元素提供了模拟的核心,它使用 Emitter 元素来生成粒子。然后根据 ParticlePainter 元素中的定义进行渲染。可以使用 Affector 对象来操作模拟实体,这些对象可以修改粒子的轨迹或生命周期。
让我们从简单的例子开始。以下代码片段声明了最简单的粒子系统:
import QtQuick 2.0
import QtQuick.Window 2.2
import QtQuick.Particles 2.0
Window {
visible: true
width: 360
height: 360
title: qsTr("Particle system")
ParticleSystem {
id: particleSystem
anchors.fill: parent
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属性来设置系统可以改变生命周期(在两个方向上)的最大时间(以毫秒为单位):
Emitter {
anchors.fill: parent
emitRate: 350
lifeSpan: 1500
lifeSpanVariation: 400 // effective: 1100-1900 ms
}
这种变化的可能结果如下所示:

增加粒子的发射率和生命周期可能会导致需要管理(以及可能渲染)的粒子数量非常大。这可能会降低性能;因此,可以通过maximumEmitted属性设置可以同时存活的粒子的上限。
调整粒子的生命周期可以使系统更加多样化。为了增强效果,您还可以通过size和sizeVariation属性来调整每个粒子的尺寸:
Emitter {
anchors.fill: parent
emitRate: 50
size: 12
sizeVariation: 6
endSize: 2
}
这将为您提供不同大小的粒子:

到目前为止所展示的功能范围应该足够创建许多看起来很好看且实用的粒子系统。然而,粒子是从发射器的整个区域发射出来的,这是一个常规的QQuickItem,因此是矩形的。但这并不一定必须如此。Emitter元素包含一个shape属性,这是一种声明粒子生成区域的途径。QtQuick.Particles模块定义了三种可用的自定义形状类型——EllipseShape、LineShape和MaskShape。前两种非常简单,定义了在项目内绘制的空或填充的椭圆或穿过项目对角线的线。MaskShape元素更有趣,因为它使得可以使用图像作为Emitter元素的形状:
Emitter {
anchors.fill: parent
emitRate: 1600
shape: MaskShape { source: "star.png" }
}
粒子现在只能在指定的区域内生成:

渲染粒子
到目前为止,我们使用裸ImageParticle元素来渲染粒子。它只是三种ParticlePainters中的一种,其他两种是ItemParticle和CustomParticle。然而,在我们继续到其他渲染器之前,让我们专注于调整ImageParticle元素以获得一些有趣的效果。
ImageParticle 元素将每个逻辑粒子渲染为一个图像。可以通过改变每个粒子的颜色和旋转、变形其形状或将其用作精灵动画来分别操作每个图像。
要影响粒子的颜色,你可以使用大量专用属性中的任何一个——alpha、color、alphaVariation、colorVariation、redVariation、greenVariation 和 blueVariation。前两个属性定义了相应属性的基值,其余属性设置相应参数从基值的最大偏差。在透明度的情况下,你只能使用一种类型的偏差,但在定义颜色时,你可以为红色、绿色和蓝色通道设置不同的值,或者你可以使用全局的colorVariation属性,这类似于为所有三个通道设置相同的值。允许的值是 0(不允许偏差)和 1.0(任一方向的 100%)之间的任何值。
注意,当将颜色应用于图像时,相应颜色的成分(红色、绿色、蓝色和 alpha)会被相乘。黑色(0, 0, 0, 1)的所有成分都设置为 0,除了 alpha,因此将纯色应用于黑色图像将不会产生任何效果。相反,如果你的图像包含白色像素(1, 1, 1, 1),它们将以指定的颜色精确显示。透明像素将保持透明,因为它们的 alpha 成分将保持设置为 0。
在我们的例子中,我们可以使用以下代码创建不同颜色的粒子:
ImageParticle {
source: "star_white.png"
colorVariation: 1
}
结果应该看起来像这样:

提到的属性是静态的——粒子在其整个生命周期中遵循恒定值。ImageParticle 元素还公开了两个属性,允许你根据粒子的年龄控制其颜色。首先,有一个名为entryEffect的属性,它定义了粒子在其出生和死亡时会发生什么。默认值是Fade,这使得粒子在其生命开始时从 0 不透明度淡入,并在死亡前将其淡回 0。你已经在所有之前演示的粒子动画中体验过这种效果。该属性的其它值是None和Scale。第一个值很明显——与粒子无关的进入效果。第二个值将粒子从出生时的 0 缩放到生命结束时的 0。
另一个与时间相关的属性是colorTable。您可以提供用作确定粒子在其生命周期中颜色的单维纹理的图像的 URL。一开始,粒子由图像的左侧定义颜色,然后以线性方式向右移动。最常见的是在这里设置包含颜色渐变的图像,以实现颜色之间的*滑过渡。
可以改变的第二个参数是粒子的旋转。在这里,我们也可以使用定义旋转的常量值(以度为单位)的属性(rotation和rotationVariation)或使用rotationVelocity和rotationVelocityVariation在时间上修改粒子的旋转。速度定义了每秒旋转的度数或速度。
粒子还可以变形。xVector和yVector属性允许绑定向量,这些向量定义了水*和垂直轴上的扭曲。我们将在下一节中描述如何设置这些向量。最后但同样重要的是,使用sprites属性,您可以定义用于渲染粒子的精灵列表。这与前一章中描述的SpriteSequence类型的工作方式类似。
使粒子移动
除了淡入淡出和旋转之外,我们迄今为止所看到的粒子系统都非常静态。虽然这对于制作星系很有用,但对于爆炸、火花甚至下雪来说却毫无用处。这是因为粒子主要关于运动。在这里,我们将向您展示使粒子飞行的两个方面。
第一个方面是模拟粒子的生成方式。这意味着创建粒子的物体的物理条件。在爆炸过程中,物质以非常大的力量从震中推开,导致空气和小物体以极高的速度向外冲。火箭发动机的烟雾以与推进器相反的方向以高速喷射。移动的彗星会拖着一缕尘埃和气体,这些尘埃和气体是由惯性引起的。
所有这些条件都可以通过设置粒子的速度或加速度来模拟。这两个指标由向量描述,这些向量确定了给定量的方向和数量(大小或长度)。在 Qt Quick 中,这些向量由一个称为“方向”的元素类型表示,其中向量的尾部附着在对象上,而头部位置由“方向”实例计算得出。由于我们没有设置粒子属性的方法,因为我们没有代表它们的对象,所以这两个属性——速度和加速度——应用于产生粒子的发射器。由于您可以在单个粒子系统中拥有许多发射器,因此可以为不同来源的粒子设置不同的速度和加速度。
有四种类型的方向元素,代表关于方向的不同信息来源。首先,有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使得每个影响者只对给定的粒子产生一次影响。否则,每个粒子可以多次修改其属性。
另一种影响类型是Gravity,它可以在给定的角度加速粒子。Friction可以减慢粒子的速度,而Attractor将影响粒子的位置、速度或加速度,使其开始向给定点移动。Wander非常适合模拟雪花或蝴蝶以伪随机方向飞行的效果。
还有其他类型的影响者可用,但在这里我们不会深入其细节。然而,我们想提醒您,不要过度使用影响者——它们可能会严重降低性能。
行动时间 - 生成消失的硬币粒子
现在是时候练习我们新获得的知识了。任务是向我们在上一章中创建的游戏添加粒子效果。当玩家收集到硬币时,它将爆炸成一片五彩缤纷的星星。
首先,声明一个粒子系统作为游戏场景的填充,以及粒子绘制器的定义:
ParticleSystem {
id: coinParticles
anchors.fill: parent // scene is the parent
ImageParticle {
source: "images/particle.png"
colorVariation: 1
rotationVariation: 180
rotationVelocityVariation: 10
}
}
接下来,修改Coin的定义以包括发射器:
Emitter {
id: emitter
system: coinParticles
emitRate: 0
lifeSpan: 1500
lifeSpanVariation: 100
velocity: AngleDirection {
angleVariation: 180
magnitude: 10
}
acceleration: AngleDirection {
angle: 270
magnitude: 30
}
}
最后,必须更新hit()函数:
function hit() {
emitter.burst(50);
hitAnim.start();
}
运行游戏并看看当本杰明收集硬币时会发生什么:

刚才发生了什么?
在这个练习中,我们定义了一个简单的粒子系统,它填充了整个场景。我们为粒子定义了一个简单的图像绘制器,允许粒子呈现所有颜色并在所有可能的旋转中开始。我们使用星形位图作为我们的粒子模板。
然后,一个Emitter对象被附加到每个硬币上。它的emitRate被设置为0,这意味着它不会自行发射任何粒子。我们为粒子设置了不同的生命周期,并通过设置它们的初始速度,在两个方向上以 180 度的角度变化(总共 360 度)使它们向所有方向飞行。通过设置加速度,我们使粒子倾向于向场景的顶部边缘移动。
在hit函数中,我们调用发射器的burst()函数,这使得它立即产生一定数量的粒子。
基于 OpenGL 的 Qt Quick 自定义项目
在第十二章,“Qt Quick 中的自定义”,我们学习了如何创建新的 QML 元素类型,这些类型可以被用来提供动态数据引擎或其他类型的非视觉对象。现在我们将看到如何为 Qt Quick 提供新的视觉项目类型。
你应该首先问自己一个问题:你是否真的需要一种新的项目类型。也许你可以用现有的元素达到同样的目标?非常常见的是,你可以使用矢量图或位图图像来为你的应用程序添加自定义形状,或者你可以使用 Canvas 在 QML 中直接快速绘制所需的图形。
如果你决定你需要自定义项目,你将通过实现QQuickItem C++类的子类来完成,这是 Qt Quick 中所有项目的基类。创建新类型后,你将始终需要使用qmlRegisterType将其注册到 QML 中。
场景图
为了提供非常快速的场景渲染,Qt Quick 使用一种称为场景图的机制。该图由许多已知类型的节点组成,每个节点描述要绘制的原始形状。框架利用对允许的每个原始形状及其参数的知识,找到渲染项目时性能最优的顺序。渲染本身使用 OpenGL 完成,所有形状都使用 OpenGL 调用来定义。
为 Qt Quick 提供新项目归结为提供一组节点,这些节点使用图表理解的术语来定义形状。这是通过子类化QQuickItem并实现纯虚updatePaintNode()方法来完成的,该方法应该返回一个节点,告诉场景图如何渲染项目。该节点很可能会描述一个应用了材质(颜色、纹理)的几何形状(形状)。
行动时间 - 创建正多边形项
让我们通过提供一个用于渲染凸多边形的项类来了解场景图。我们将使用称为“三角形扇”的 OpenGL 绘图模式来绘制多边形。它绘制一组具有公共顶点的三角形。后续的三角形由共享顶点、前一个三角形的顶点和指定的下一个顶点定义。看看图解,了解如何使用八个顶点作为控制点将六边形作为三角形扇绘制:

同样的方法适用于任何正多边形。定义的第一个顶点始终是占据形状中心的共享顶点。其余的点位于形状边界圆的圆周上,角度相等。角度可以通过将完整角度除以边的数量来轻松计算。对于六边形,这会产生 60 度。
让我们开始处理业务和QQuickItem子类。我们将给它一个非常简单的接口:
class RegularPolygon : public QQuickItem
{
Q_OBJECT
Q_PROPERTY(int vertices READ vertices WRITE setVertices
NOTIFY verticesChanged)
Q_PROPERTY(QColor color READ color WRITE setColor NOTIFY colorChanged)
public:
RegularPolygon();
~RegularPolygon();
int vertices() const;
void setVertices(int v);
QColor color() const;
void setColor(const QColor &c);
QSGNode *updatePaintNode(QSGNode *, UpdatePaintNodeData *);
signals:
void verticesChanged(int);
void colorChanged(QColor);
private:
int m_vertexCount;
QColor m_color;
};
我们的多边形由顶点数和填充颜色定义。我们还继承了QQuickItem的所有内容,包括项目的宽度和高度。除了添加明显的属性获取器和设置器外,我们还重写了虚拟updatePaintNode()方法,该方法负责构建场景图。
在我们处理更新图节点之前,让我们先处理简单的部分。构造函数的实现如下:
RegularPolygon::RegularPolygon()
{
setFlag(ItemHasContents, true);
m_vertexCount = 6;
}
我们默认将多边形设置为六边形。我们还设置了一个标志ItemHasContents,它告诉场景图该项目不是完全透明的,并且应该通过调用updatePaintNode()来询问我们如何绘制项目。这个标志的存在允许 Qt 在项目根本不会绘制任何内容的情况下避免准备整个基础设施。
设置器也很容易理解:
void RegularPolygon::setVertices(int v) {
v = qMax(3, v);
if(v == vertices()) return;
m_vertexCount = v;
emit verticesChanged(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 = nullptr;
QSGGeometry *geometry = nullptr;
我们将返回的节点是一个包含绘制形状的几何和材质信息的几何节点。我们将随着方法的执行填充这些变量。接下来,我们检查是否提供了oldNode:
if (!oldNode) {
node = new QSGGeometryNode;
geometry = new QSGGeometry(
QSGGeometry::defaultAttributes_Point2D(), m_vertexCount + 2);
geometry->setDrawingMode(GL_TRIANGLE_FAN);
node->setGeometry(geometry);
node->setFlag(QSGNode::OwnsGeometry);
正如我们已经提到的,函数是用之前返回的节点作为参数调用的,但我们应该准备好节点可能不存在的情况,并且我们应该创建它。因此,如果是这种情况,我们将创建一个新的QSGGeometryNode和一个新的QSGGeometry。几何构造函数接受一个所谓的属性集作为其参数,该属性集定义了几何中数据的布局。
大多数常见的布局已经被预定义:
| 属性集 | 用途 | 第一个属性 | 第二个属性 |
|---|---|---|---|
Point2D |
单色形状 | float x, y |
- |
ColoredPoint2D |
每个顶点的颜色 | float x, y |
uchar red, green, blue, alpha |
TexturedPoint2D |
每个顶点的纹理坐标 | float x, y |
float tx, float ty |
我们将使用 2D 点来定义几何形状,每个点不附加任何额外信息;因此,我们传递QSGGeometry::defaultAttributes_Point2D()来构建我们需要的布局。正如你在前表中看到的,该布局的每个属性都由两个浮点值组成,表示点的x和y坐标。
QSGGeometry构造函数的第二个参数告诉我们我们将使用多少个顶点。构造函数将根据给定的属性布局分配足够的内存来存储所需数量的顶点。在几何容器准备好后,我们将它的所有权传递给几何节点,以便当几何节点被销毁时,几何形状的内存也会被释放。在此时刻,我们还标记我们将以GL_TRIANGLE_FAN模式进行渲染。这个过程会为材料重复进行。
QSGFlatColorMaterial *material = new QSGFlatColorMaterial;
material->setColor(m_color);
node->setMaterial(material);
node->setFlag(QSGNode::OwnsMaterial);
我们使用QSGFlatColorMaterial作为整个形状将有一个从m_color设置的单一颜色。Qt 提供了一系列预定义的材料类型。例如,如果我们想给每个顶点分配不同的颜色,我们会使用QSGVertexColorMaterial以及ColoredPoint2D属性布局。
下一段代码处理的是oldNode确实包含了一个指向已经初始化的节点的有效指针的情况:
} else {
node = static_cast<QSGGeometryNode *>(oldNode);
geometry = node->geometry();
geometry->allocate(m_vertexCount + 2);
}
在这种情况下,我们只需要确保几何形状能够容纳我们需要的尽可能多的顶点,以防自上次函数执行以来边的数量发生了变化。接下来,我们检查材料:
QSGMaterial *material = node->material();
QSGFlatColorMaterial *flatMaterial =
static_cast<QSGFlatColorMaterial*>(material);
if(flatMaterial->color() != m_color) {
flatMaterial->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
qreal angleStep = 360.0 / m_vertexCount;
qreal radius = qMin(width(), height()) / 2;
for (int i = 0; i < m_vertexCount; ++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_vertexCount] = vertices[1];
首先,我们要求几何对象为我们准备一个从分配的内存到QSGGeometry::Point2D结构的映射,这可以方便地为每个顶点设置数据。然后,使用计算圆上点的方程进行实际计算。圆的半径取为项目宽度和高度的较小部分,以便形状在项目中居中。正如你在练习开始时的图中可以看到的,数组中的最后一个点与数组中的第二个点具有相同的坐标,以便将扇形封闭成规则多边形。
最后,我们标记几何形状已更改,并将节点返回给调用者:
node->markDirty(QSGNode::DirtyGeometry);
return node;
}
刚才发生了什么?
在 Qt Quick 中的渲染可以在与主线程不同的线程中发生。在调用updatePaintNode()函数之前,Qt 会在 GUI 线程和渲染线程之间执行同步,以便我们安全地访问项目数据和其他存在于主线程中的对象。在执行此函数时,执行主线程的函数将被阻塞,因此它必须尽可能快地执行,并且不要进行任何不必要的计算,因为这会直接影响性能。这也是你可以在代码中同时安全调用项目中的函数(如读取属性)并与场景图(创建和更新节点)交互的唯一地方。尽量在此方法中不要发出任何信号或创建任何对象,因为它们将具有渲染线程的亲和力,而不是 GUI 线程。
话虽如此,你现在可以使用 qmlRegisterType 将你的类注册到 QML 中,并使用以下 QML 文档进行测试:
Window {
width: 600
height: 600
visible: true
RegularPolygon {
id: poly
anchors {
fill: parent
bottomMargin: 20
}
vertices: 5
color: "blue"
}
}
这应该会给你一个漂亮的蓝色五边形。如果形状看起来有锯齿,你可以通过设置应用程序的表面格式来强制抗锯齿:
int main(int argc, char **argv) {
QGuiApplication app(argc, argv);
QSurfaceFormat format = QSurfaceFormat::defaultFormat();
format.setSamples(16); // enable multisampling
QSurfaceFormat::setDefaultFormat(format);
qmlRegisterType<RegularPolygon>("RegularPolygon", 1, 0, "RegularPolygon");
QQmlApplicationEngine engine;
engine.load(QUrl(QStringLiteral("qrc:/main.qml")));
if (engine.rootObjects().isEmpty())
return -1;
return app.exec();
}
如果在启用抗锯齿后应用程序产生黑色屏幕,尝试减少样本数量或禁用它。
尝试一下英雄 – 创建 RegularPolygon 的支持边框
updatePaintNode() 返回的不仅可能是一个单一的 QSGGeometryNode,也可能是一个更大的 QSGNode 项的树。每个节点可以有任意数量的子节点。通过返回一个有两个几何节点作为子节点的节点,你可以在项中绘制两个独立的形状:

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

在 Qt Quick 中使用 QPainter 接口
在 OpenGL 中实现项相当困难——你需要想出一个使用 OpenGL 原语绘制你想要的形状的算法,然后你还需要足够熟练地使用 OpenGL 来为你的项构建一个合适的场景图节点树。然而,还有另一种方法——你可以通过 QPainter 绘制项。这会带来性能上的损失,因为幕后画家在一个间接的表面(帧缓冲对象或图像)上绘制,然后由场景图通过一个四边形将其转换为 OpenGL 纹理并渲染。即使考虑到性能损失,使用丰富且方便的绘图 API 绘制项通常比在 OpenGL 或 Canvas 中花费数小时做同样的事情要简单得多。
要使用这种方法,我们不会直接子类化 QQuickItem,而是 QQuickPaintedItem,这为我们提供了使用画家绘制项所需的基础设施。
基本上,我们接下来要做的就是实现纯虚的 paint() 方法,使用接收到的画家渲染项。让我们看看这是如何付诸实践,并将其与我们之前获得的技术结合起来。
动手实践 – 创建用于绘制轮廓文本的项
当前练习的目标是能够使以下 QML 代码工作:
import QtQuick 2.9
import QtQuick.Window 2.3
import OutlineTextItem 1.0
Window {
visible: true
width: 800
height: 400
title: qsTr("Hello World")
Rectangle {
anchors.fill: parent
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
}
}
}
}
然后,它会产生以下结果:

从一个空的 Qt Quick 应用程序项目开始。创建一个新的 C++类,并将其命名为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(Qt::PenStyle style MEMBER m_style NOTIFY styleChanged)
public:
OutlineTextItemBorder(QObject *parent = 0);
int width() const;
QColor color() const;
Qt::PenStyle style() const;
QPen pen() const;
signals:
void widthChanged(int);
void colorChanged(QColor);
void styleChanged(int);
private:
int m_width;
QColor m_color;
Qt::PenStyle m_style;
};
这是一个基于QObject的简单类,包含多个属性。你可以看到,Q_PROPERTY宏没有我们迄今为止使用的READ和WRITE关键字。这是因为我们现在正在走捷径,我们让moc生成直接访问给定类成员以操作属性的代码。通常,我们不建议采取这种方法,因为没有 getter;访问属性的唯一方式是通过通用的property()和setProperty()调用。然而,在这种情况下,我们不会将这个类公开在 C++中,所以我们不需要 setter,我们仍然会自己实现 getter。MEMBER关键字的好处是,如果我们还提供了NOTIFY信号,生成的代码将在属性值变化时发出该信号,这将使 QML 中的属性绑定按预期工作。我们还需要实现一个根据属性值返回实际画笔的方法:
QPen OutlineTextItemBorder::pen() const {
QPen p;
p.setColor(m_color);
p.setWidth(m_width);
p.setStyle(m_style);
return p;
}
该类将为我们的主要项目类提供一个分组属性。创建一个名为OutlineTextItem的类,并从QQuickPaintedItem派生,如下所示:
class OutlineTextItem : public QQuickPaintedItem
{
Q_OBJECT
Q_PROPERTY(QString text MEMBER m_text
NOTIFY textChanged)
Q_PROPERTY(QColor color MEMBER m_color
NOTIFY colorChanged)
Q_PROPERTY(OutlineTextItemBorder* border READ border
NOTIFY borderChanged)
Q_PROPERTY(QString fontFamily MEMBER m_fontFamily
NOTIFY fontFamilyChanged)
Q_PROPERTY(int fontPixelSize MEMBER m_fontPixelSize
NOTIFY fontPixelSizeChanged)
public:
OutlineTextItem(QQuickItem *parent = 0);
void paint(QPainter *painter);
OutlineTextItemBorder* border() const;
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_boundingRect;
QString m_text;
QColor m_color;
QString m_fontFamily;
int m_fontPixelSize;
};
接口定义了要绘制的文本属性,以及它的颜色、字体和轮廓数据的分组属性。同样,我们使用MEMBER来避免手动实现 getter 和 setter。不幸的是,这使得我们的构造函数代码更加复杂,因为我们仍然需要一种方法在任何属性被修改时运行一些代码。使用以下代码实现构造函数:
OutlineTextItem::OutlineTextItem(QQuickItem *parent) :
QQuickPaintedItem(parent)
{
m_border = new OutlineTextItemBorder(this);
connect(this, &OutlineTextItem::textChanged,
this, &OutlineTextItem::updateItem);
connect(this, &OutlineTextItem::colorChanged,
this, &OutlineTextItem::updateItem);
connect(this, &OutlineTextItem::fontFamilyChanged,
this, &OutlineTextItem::updateItem);
connect(this, &OutlineTextItem::fontPixelSizeChanged,
this, &OutlineTextItem::updateItem);
connect(m_border, &OutlineTextItemBorder::widthChanged,
this, &OutlineTextItem::updateItem);
connect(m_border, &OutlineTextItemBorder::colorChanged,
this, &OutlineTextItem::updateItem);
connect(m_border, &OutlineTextItemBorder::styleChanged,
this, &OutlineTextItem::updateItem);
updateItem();
}
我们基本上将对象及其分组属性对象的所有属性更改信号连接到同一个槽,该槽将在任何组件被修改时更新项目的数据。我们还将直接调用同一个槽来准备项目的初始状态。该槽可以像这样实现:
void OutlineTextItem::updateItem() {
QFont font(m_fontFamily, m_fontPixelSize);
m_path = QPainterPath();
m_path.addText(0, 0, font, m_text);
m_boundingRect = borderShape(m_path).controlPointRect();
setImplicitWidth(m_boundingRect.width());
setImplicitHeight(m_boundingRect.height());
update();
}
在开始时,该函数重置一个画家路径对象,该对象作为绘制轮廓文本的后端,并使用设置的字体初始化它。然后,槽函数使用我们很快就会看到的borderShape()函数计算路径的边界矩形。我们使用controlPointRect()来计算边界矩形,因为它比boundingRect()快得多,并且返回一个大于或等于boundingRect()的面积,这对我们来说是可以接受的。最后,它将计算出的尺寸设置为项目的尺寸提示,并使用update()调用请求项目重新绘制。使用以下代码实现borderShape()函数:
QPainterPath OutlineTextItem::borderShape(const QPainterPath &path) const
{
QPainterPathStroker pathStroker;
pathStroker.setWidth(m_border->width());
QPainterPath p = pathStroker.createStroke(path);
p.addPath(path);
return p;
}
borderShape()函数返回一个新的绘图路径,该路径包括原始路径及其使用QPainterPathStroker对象创建的轮廓。这样做是为了在计算边界矩形时正确考虑笔触的宽度。
剩下的就是实现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_boundingRect.topLeft());
painter->drawPath(m_path);
}
代码非常简单——如果没有东西要绘制,我们就会提前退出。否则,我们使用从项的属性中获得的笔和颜色设置绘图器。我们启用抗锯齿并使用项的边界矩形校准绘图器坐标。最后,我们在绘图器上绘制路径。
刚才发生了什么?
在这次练习中,我们利用 Qt 的矢量图形引擎的强大 API,通过简单的功能来补充现有的 Qt Quick 项集。否则,使用预定义的 Qt Quick 元素来实现这一点非常困难,而使用 OpenGL 实现则更加困难。我们同意为了只写大约一百行代码就能得到一个完全工作的解决方案,我们愿意承受一点性能损失。如果你想在代码中使用它,请记住将类注册到 QML 中:
qmlRegisterUncreatableType<OutlineTextItemBorder>(
"OutlineTextItem", 1, 0, "OutlineTextItemBorder", "");
qmlRegisterType<OutlineTextItem>(
"OutlineTextItem", 1, 0, "OutlineTextItem");
快速问答
Q1. 哪种 QML 类型可以用来在较小的视口中启用大项的滚动?
-
Rectangle -
Flickable -
Window
Q2. Affector QML 类型的目的何在?
-
Affector允许你在动画过程中更改 QML 项的属性 -
Affector影响由粒子系统产生的粒子的属性 -
Affector允许你控制由粒子系统产生的粒子的初始属性
Q3. 当你使用QPainter在 Qt Quick 项上绘图时会发生什么?
-
每次调用
QPainterAPI 都会转换为等效的 OpenGL 调用 -
QPainter在不可见的缓冲区上绘图,然后将其作为 OpenGL 纹理加载 -
由
QPainter绘制的项在没有硬件加速的情况下显示
摘要
现在,你已经熟悉了 Qt Quick 的功能,这些功能允许你在游戏中添加令人惊叹的图形效果。你可以配置粒子系统并在 Qt Quick 的场景图中实现 OpenGL 绘图。你也能够利用本书第一部分学到的技能来实现绘制的 Qt Quick 项。
当然,Qt Quick 的功能比这还要丰富,但我们不得不在某处停下来。我们希望传授给你的技能应该足够开发出许多优秀的游戏。然而,许多元素具有比我们这里描述的更多的属性。无论何时你想扩展你的技能,你都可以查看参考手册,看看元素类型是否有更多有趣的属性。Qt Quick 仍在积极开发中,因此查看最* Qt 版本的变更日志,看看这本书中未能涵盖的新功能是个好主意。
在下一章中,我们将关注 Qt 3D 模块,这是 Qt 框架中相对较新的一个模块。Qt 3D 提供了一个丰富的 QML API,这将使我们能够使用我们在与 Qt Quick 一起工作时学到的许多技能。然而,与用户界面和 2D 图形不同,你现在将创建显示硬件加速 3D 图形的游戏。当你学会使用 Qt 3D 时,你将能够将你的游戏提升到一个全新的水*!
第十五章:使用 Qt 进行 3D 图形
许多现代游戏都在 3D 世界中进行。图形处理单元不断进化,允许开发者创建越来越视觉上吸引人且细节丰富的世界。虽然你可以直接使用 OpenGL 或 Vulkan 来渲染 3D 对象,但这可能相当具有挑战性。幸运的是,Qt 3D 模块提供了一个使用高级 API 进行 3D 渲染的实现。在本章中,我们将学习如何使用其功能,并了解我们如何使用 Qt 创建一个 3D 游戏。
Qt 3D 不仅限于渲染。你还将学习如何处理用户输入并在 3D 游戏中实现游戏逻辑。Qt 3D 被设计成高度高效和完全可扩展的,因此它允许你向所有 Qt 3D 系统添加自己的扩展。
Qt 3D 提供了 C++ 和 QML API,功能基本相同。虽然 C++ API 允许你修改和扩展实现,但我们将使用 QML 方法,这将允许我们编写干净且声明性的代码,并使用我们在前几章中学到的技术。通过将 Qt 3D 与 QML 的力量结合起来,你将能够迅速制作出令人惊叹的游戏!
本章主要涵盖以下主题:
-
渲染 3D 对象
-
处理用户输入
-
执行动画
-
与 3D 编辑器的集成
-
使用 C++ 与 Qt 3D 一起工作
-
与 Qt Widgets 和 Qt Quick 的集成
Qt 3D 概述
在我们看到 Qt 3D 的实际应用之前,让我们先了解其架构的重要部分。
实体和组件
Qt 3D 不仅仅是一个 3D 渲染工具。当它充分发展时,它可以成为一个功能齐全的游戏引擎。这得益于其原始架构。Qt 3D 引入了一套新的抽象概念,这些概念对于其任务特别有用。
你可能已经注意到,大多数 Qt API 都大量使用了继承。例如,每个小部件类型都从 QWidget 派生而来,而 QWidget 又从 QObject 派生。Qt 形成了庞大的类家族树,以提供通用和特殊的行为。相比之下,Qt 3D 场景的元素是使用 组合 而不是继承来构建的。Qt 3D 场景的一个部分称为 实体,并由 Entity 类型表示。然而,一个 Entity 对象本身并没有任何特定的效果或行为。你可以通过添加 组件 的形式向实体添加行为片段。
每个组件控制实体行为的一部分。例如,Transform 组件控制实体在场景中的位置,Mesh 组件定义其形状,而 Material 组件控制表面的属性。这种方法允许你仅从所需的组件中组装实体。例如,如果你需要向场景中添加光源,你可以创建一个带有 PointLight 组件的实体。你仍然需要选择光源的位置,因此你还需要 Transform 组件。然而,对于光源来说,Mesh 和 Material 组件没有意义,所以你不需要使用它们。
实体按照经典的父子关系排列,就像任何其他 QML 对象或 QObjects 一样。实体树形成了一个 Qt 3D 场景。最顶层的实体通常负责定义场景级别的配置。这些设置通过将特殊组件(如 RenderSettings 和 InputSettings)附加到顶级 Entity 来指定。
Qt 3D 模块
Qt 3D 被分割成多个模块,你可以选择在项目中使用它们。可能很难看出你需要哪些模块,所以让我们看看每个模块的用途。
稳定模块
Qt3DCore 模块实现了 Qt 3D 的基本结构。它提供了 Entity 和 Component 类型,以及其他 Qt 3D 系统的基类。Qt3DCore 本身不实现任何行为,仅提供其他模块使用的框架。
Qt3DRender 模块实现了 3D 渲染,因此它是功能最丰富的模块之一。以下是它功能的一些重要部分:
-
GeometryRenderer是定义实体可见形状的基本组件类型。 -
Mesh组件允许你从文件中导入实体的几何形状。 -
Material组件是定义实体表面可见属性的基本组件类型。 -
SceneLoader组件允许你从文件中导入具有网格和材料的实体层次结构。 -
光照组件(
DirectionalLight、EnvironmentLight、PointLight和SpotLight)允许你控制场景的照明。 -
FrameGraphAPI 提供了一种定义场景应该如何精确渲染的方法。它允许你设置相机、实现多个视口、阴影映射、自定义着色器等等。 -
ObjectPicker组件允许你找出在特定窗口点位置上的哪些实体。
接下来,Qt3DLogic 是一个非常小的模块,它提供了 FrameAction 组件。这个组件允许你为实体的每一帧执行任意操作。
最后,Qt3DInput 模块专注于用户输入。它提供了一些组件,允许你在游戏中处理键盘和鼠标事件。Qt3DInput 还包含可以用于配置输入设备的类型。
实验模块
在撰写本文时,所有其他 Qt 3D 模块仍在 技术预览 中,因此它们的 API 可能不完整。未来的 Qt 版本可能会在这些模块中引入不兼容的更改,所以如果你需要修改提供的代码以使其工作,请不要感到惊讶(我们的代码已在 Qt 5.10 上进行测试)。这些模块最终将在未来稳定下来,因此你应该检查 Qt 文档以了解它们当前的状态。
如同其名,Qt3DAnimation 模块负责 Qt 3D 场景中的动画。它能够处理实体的 Transform 组件上的关键帧动画,以及混合形状和顶点混合动画。然而,在本章中,我们不会使用此模块,因为已经熟悉的 Qt Quick 动画框架对我们来说已经足够。
Qt3DExtras 模块提供了不是严格必要的 Qt 3D 工作组件,但对于构建简单的第一个项目非常有用。它们包括:
-
基本几何形状(如立方体、球体等)的网格生成器
-
ExtrudedTextMesh组件允许你在场景中显示 3D 文本 -
许多标准材质组件,例如
DiffuseSpecularMaterial和GoochMaterial
此外,Qt3DExtras 提供了两个便利类,允许用户使用鼠标和键盘控制相机的位置:
-
OrbitCameraController沿着轨道路径移动相机 -
FirstPersonCameraController以第一人称游戏的方式移动相机
Qt3DQuickExtras 模块提供了 Qt3DExtras::Quick::Qt3DQuickWindow C++ 类。这是一个显示基于 QML 的 Qt 3D 场景的窗口。
最后,Qt3DQuickScene2D 模块提供了将 Qt Quick 项目嵌入到 Qt 3D 场景的能力,而 QtQuick.Scene3D QML 模块允许你将 Qt 3D 场景嵌入到 Qt Quick 应用程序中。
如你所见,Qt 3D 的功能并不仅限于渲染。你还可以用它来处理用户输入并实现实体的游戏逻辑。Qt 3D 是完全可扩展的,因此你可以使用其 C++ API 来实现自己的组件,或者修改现有的组件。然而,在本章中,我们将主要使用基于 QML 的 API。
注意,Qt 3D 对象不是 Qt Quick 项目,因此当你使用 Qt 3D 时,并非所有 Qt Quick 功能都对你开放。例如,你不能使用 Repeater 来实例化多个 Qt 3D 实体。然而,你仍然可以使用 Qt Quick 动画,因为它们可以处理任何 QML 对象。你也可以使用 Scene3D QML 类型将 Qt 3D 场景嵌入到 Qt Quick 界面中。
使用模块
在使用每个 Qt 3D 模块之前,你必须单独在项目文件中启用该模块。例如,以下行将启用所有当前记录的模块:
QT += 3dcore 3drender 3dinput 3dlogic 3danimation \
qml quick 3dquick 3dquickextras 3dquickscene2d
当使用 QML 时,每个模块也必须单独导入:
import Qt3D.Core 2.10
import Qt3D.Render 2.10
import Qt3D.Extras 2.10
import Qt3D.Input 2.0
import Qt3D.Logic 2.0
import QtQuick.Scene2D 2.9
import QtQuick.Scene3D 2.0
你可以看到不同的 Qt 3D 模块有不同的 QML 模块版本。一些模块在 Qt 5.10 中进行了更新,并具有我们希望在代码中使用的新功能,因此你必须指定最后一个版本(2.10),以便使新的 QML 类型可用。另一方面,一些模块没有更新,所以 2.0 是唯一可用的版本。随着新 Qt 版本的发布,最新的版本将在未来发生变化。希望 Qt 文档将包含正确的导入语句。
所有 Qt 3D 模块的 C++ 类型都放置在一个命名空间中。在其他方面,Qt 命名约定适用。例如,Entity QML 类型对应于 Qt3DCore 命名空间中的 QEntity C++ 类。相应的包含指令是 #include <QEntity>。
Qt 3D 还引入了 方面 的概念。方面简单地说是一段可以添加到 Qt 3D 引擎中的行为。Qt3DQuickWindow 类包含一个内置的方面引擎,它自动启用 QRenderAspect、QInputAspect 和 QLogicAspect 方面,允许 Qt 3D 渲染场景、处理用户输入和执行帧动作。如果您决定使用 Qt3DAnimation 模块,您也应该启用 QAnimationAspect。您可以使用 Qt3DWindow::registerAspect() 方法做到这一点。其他 Qt 3D 模块不需要单独的方面。也有可能创建一个新的方面,但通常不是必要的。
渲染 3D 对象
Qt 3D 场景中的每个项目都由 Entity 类型表示。然而,并非所有实体都是可见的 3D 对象。为了使实体可见,它必须具有 网格 组件和 材质 组件。
网格、材质和变换
网格定义了实体的几何形状。它包含有关顶点、边和面的信息,这些信息是渲染对象所需的。所有网格组件的基类型是 GeometryRenderer。然而,您通常会使用其子类之一。
-
Mesh从文件中导入几何数据。 -
ConeMesh、CuboidMesh、CylinderMesh、PlaneMesh、SphereMesh和TorusMesh提供了对原始几何形状的访问。 -
ExtrudedTextMesh根据指定的文本和字体定义实体的形状。
虽然网格定义了对象表面将被绘制的位置,但材质定义了它将如何精确地被绘制。表面最明显的属性是其颜色,但根据反射模型,可能会有各种属性,例如漫反射和镜面反射的系数。Qt 3D 提供了大量的不同材质类型:
-
PerVertexColorMaterial允许您为每个顶点设置颜色属性,并渲染周围和漫反射反射。 -
TextureMaterial渲染纹理并忽略光照。 -
DiffuseSpecularMaterial实现了 Phong 反射模型,并允许您设置反射的周围、漫反射和镜面反射组件。 -
GoochMaterial实现了 Gooch 着色模型。 -
MetalRoughMaterial使用 PBR(基于物理的渲染)渲染类似金属的表面。 -
MorphPhongMaterial也遵循 Phong 反射模型,但同时也支持Qt3DAnimation模块的形态动画。
可见 3D 对象的第三个常见组件是 Transform。虽然不是严格必需的,但通常需要设置对象在场景中的位置。你可以使用 translation 属性来设置位置。也可以使用 scale3D 属性来缩放对象,该属性允许你为每个 axis 设置不同的缩放系数,或者使用接受单个系数并应用于所有轴的 scale 属性。同样,你可以使用 rotation 属性设置旋转四元数,或者使用 rotationX、rotationY 和 rotationZ 属性设置单个欧拉角。最后,你可以设置 matrix 属性来应用任意变换矩阵。
注意,变换不仅应用于当前实体,还应用于其所有子实体。
光照
一些可用的材料会考虑光照。Qt 3D 允许你向场景添加不同类型的灯光并对其进行配置。你可以通过向场景添加一个新的 Entity 并将其与一个 DirectionalLight、PointLight 或 SpotLight 组件相关联来实现这一点。这些组件中的每一个都有一个 color 属性,允许你配置灯光的颜色,以及一个 intensity 属性,允许你选择灯光的亮度。其余的属性都是特定于灯光类型的。
方向光(也称为“远光”或“日光”)从由 DirectionalLight 类型的 worldDirection 属性定义的方向发射*行光线。实体的位置和旋转对方向光的光照效果没有影响,因此不需要 Transform 组件。
点光从其位置向所有方向发射光线。可以通过附加到同一实体的 Transform 组件来更改光源的位置。PointLight 组件允许你通过设置 constantAttenuation、linearAttenuation 和 quadraticAttenuation 属性来配置在距离处灯光的亮度。
当点光可以解释为一个光源的球体时,聚光灯是一个光锥。它从其位置发射光线,但方向受到 localDirection 属性的限制,该属性定义了聚光灯面向的方向,以及 cutOffAngle 属性配置了光锥的宽度。聚光灯的位置和方向可以通过附加到同一实体的 Transform 组件的*移和旋转来影响。SpotLight 也具有与 PointLight 相同的衰减属性。
如果场景中没有灯光,Qt 将自动添加一个隐含的点光源,以便场景在一定程度上可见。
第四种光与其他不同。它被称为环境光,可以通过向实体添加EnvironmentLight组件来配置。它使用分配给其irradiance和specular属性的两种纹理来定义场景的周围照明。与其他光类型不同,此组件没有color或intensity属性。场景中只能有一个环境光。
注意,光源本身是不可见的。它们唯一的作用是影响使用特定材质类型的 3D 对象的外观。
行动时间 - 创建 3D 场景
在本章中,我们将创建著名汉诺塔游戏的实现。这个谜题游戏包含三个杆和多个不同大小的盘子。盘子可以滑到杆上,但盘子不能放在比它小的盘子上面。在起始位置,所有杆都放在一个盘子上。目标是将它们全部移动到另一个杆上。玩家一次只能移动一个盘子。
如往常一样,你将在书中附带资源中找到完整的项目。
创建一个新的 Qt Quick 应用程序 - 空项目,并将其命名为hanoi。虽然我们将使用一些 Qt Quick 工具,但我们的项目实际上不会基于 Qt Quick。Qt 3D 将做大部分工作。尽管如此,Qt Quick Application - Empty是目前最合适的模板,所以我们选择使用它。编辑hanoi.pro文件以启用我们将需要的 Qt 模块:
QT += 3dcore 3drender 3dinput quick 3dquickextras
我们将使用Qt3DQuickWindow类来实例化我们的 QML 对象,而不是我们通常与 Qt Quick 一起使用的QQmlApplicationEngine类。为此,将main.cpp文件替换为以下代码:
#include <QGuiApplication>
#include <Qt3DQuickWindow>
int main(int argc, char* argv[])
{
QGuiApplication app(argc, argv);
Qt3DExtras::Quick::Qt3DQuickWindow window;
window.setSource(QUrl("qrc:/main.qml"));
window.show();
return app.exec();
}
接下来,将main.qml文件替换为以下代码:
import Qt3D.Core 2.10
import Qt3D.Render 2.10
import Qt3D.Input 2.0
import Qt3D.Extras 2.10
Entity {
components: [
RenderSettings {
activeFrameGraph: ForwardRenderer {
clearColor: "black"
camera: Camera {
id: camera
projectionType: CameraLens.PerspectiveProjection
fieldOfView: 45
nearPlane : 0.1
farPlane : 1000.0
position: Qt.vector3d(0.0, 40.0, -40.0)
upVector: Qt.vector3d(0.0, 1.0, 0.0)
viewCenter: Qt.vector3d(0.0, 0.0, 0.0)
}
}
},
InputSettings {}
]
}
此代码声明了一个包含两个组件的单个Entity对象。RenderSettings组件定义了 Qt 3D 应该如何渲染场景。RenderSettings的activeFrameGraph属性可以包含一个渲染操作的树,但最简单的帧图是一个单独的ForwardRenderer对象,它负责所有渲染。ForwardRenderer逐个将对象直接渲染到 OpenGL 帧缓冲区。我们使用clearColor属性将场景的背景色设置为黑色。ForwardRenderer的camera属性包含它将用于计算变换矩阵的Camera对象。让我们来看看我们代码中使用的Camera对象的属性:
-
projectionType属性定义了投影的类型。除了PerspectiveProjection之外,你还可以使用OrthographicProjection、FrustumProjection或CustomProjection。 -
fieldOfView属性包含透视投影的视野参数。你可以更改它以实现缩放效果。 -
nearPlane和farPlane属性定义了在相机中可见的最*和最远*面的位置(它们对应于视口坐标中可见的z轴值)。 -
position向量定义了相机在世界坐标中的位置。 -
世界坐标中的
upVector向量是当通过相机观察时指向向上的向量。 -
世界坐标中的
viewCenter向量是将在视口中心出现的点。
当使用透视投影时,通常需要根据窗口大小设置纵横比。Camera对象有aspectRatio属性用于此目的,但我们不需要设置它,因为Qt3DQuickWindow对象将自动更新此属性。
你可以通过在main.cpp文件中添加window.setCameraAspectRatioMode(Qt3DExtras::Quick::Qt3DQuickWindow::UserAspectRatio)来禁用此功能。
如果你想使用正交投影而不是透视投影,你可以使用Camera对象的top、left、bottom和right属性来设置可见区域。
最后,我们的Entity的第二个组件是InputSettings组件。它的eventSource属性应指向提供输入事件的对象。与aspectRatio一样,我们不需要手动设置此属性。Qt3DQuickWindow将找到InputSettings对象并将其自身设置为eventSource。
你可以运行项目以验证它是否成功编译并且没有产生任何运行时错误。你应该得到一个空的黑窗口作为结果。
现在让我们在我们的场景中添加一些可见的内容。编辑main.qml文件,向根Entity添加几个子对象,如下所示:
Entity {
components: [
RenderSettings { /* ... */ },
InputSettings {}
]
FirstPersonCameraController {
camera: camera
}
Entity {
components: [
DirectionalLight {
color: Qt.rgba(1, 1, 1)
intensity: 0.5
worldDirection: Qt.vector3d(0, -1, 0)
}
]
}
Entity {
components: [
CuboidMesh {},
DiffuseSpecularMaterial { ambient: "#aaa"; shininess: 100; },
Transform { scale: 10 }
]
}
}
因此,你应该在窗口中心看到一个立方体:

更重要的是,你可以使用箭头键、Page Up和Page Down键以及左鼠标按钮来移动相机。
刚才发生了什么?
我们在我们的场景图中添加了一些对象。首先,FirstPersonCameraController对象允许用户自由控制相机。这在还没有自己的相机控制代码时测试游戏非常有用。接下来,一个带有单个DirectionalLight组件的实体在场景中充当光源。我们使用该组件的属性来设置光的颜色、强度和方向。
最后,我们添加了一个代表常规 3D 对象的实体。其形状由CuboidMesh组件提供,该组件生成一个单位立方体。其表面的外观由符合广泛使用的 Phong 反射模型的DiffuseSpecularMaterial组件定义。您可以使用ambient、diffuse和specular颜色属性来控制反射光的不同组成部分。shininess属性定义了表面有多光滑。我们使用Transform组件将立方体缩放到更大的尺寸。
是时候构建汉诺塔场景了。
我们接下来的任务是为我们的谜题创建一个基础和三个杆。我们将利用 QML 的模块化系统,将我们的代码拆分成多个组件。首先,让我们将相机和光照设置保留在main.qml中,并将我们的实际场景内容放入一个新的Scene组件中。为了做到这一点,将文本光标置于立方体的实体声明上,按Alt + Enter并选择将组件移动到单独的文件中。输入Scene作为组件名称并确认操作。Qt Creator 将创建一个新的Scene.qml文件并将其添加到项目的资源中。现在main.qml中只包含我们场景组件的一个实例化:
Entity {
//...
Scene { }
}
实际的实体属性被移动到了Scene.qml文件中。让我们调整成以下形式:
Entity {
id: sceneRoot
Entity {
components: [
DiffuseSpecularMaterial {
ambient: "#444"
},
CuboidMesh {},
Transform {
scale3D: Qt.vector3d(40, 1, 40)
}
]
}
}
我们的场景将包含多个项目,因此我们引入了一个新的Entity对象,并将其命名为sceneRoot。这个实体没有任何组件,因此它不会对场景产生任何可见的影响。这类似于Item类型的对象通常作为 Qt Quick 项的容器,而不提供任何视觉内容。
现在立方体实体是sceneRoot的子实体。我们使用Transform组件的scale3D属性来改变立方体的尺寸。现在它看起来像一张桌面,将作为其余物体的基础。
现在让我们来处理杆。自然地,我们想要有一个Rod组件,因为它是场景的一个重复部分。在项目树中调用qml.qrc的上下文菜单,并选择添加新项。从 Qt 类别中选择 QML 文件(Qt Quick 2),并将文件名输入为Rod。让我们看看我们如何实现这个组件:
import Qt3D.Core 2.10
import Qt3D.Render 2.10
import Qt3D.Extras 2.10
Entity {
property int index
components: [
CylinderMesh {
id: mesh
radius: 0.5
length: 9
slices: 30
},
DiffuseSpecularMaterial {
ambient: "#111"
},
Transform {
id: transform
translation: {
var radius = 8;
var step = 2 * Math.PI / 3;
return Qt.vector3d(radius * Math.cos(index * step),
mesh.length / 2 + 0.5,
radius * Math.sin(index * step));
}
}
]
}
与立方体实体类似,我们的杆由一个网格、一个材质和一个Transform组件组成。我们使用CylinderMesh组件来创建一个圆柱体,而不是CubeMesh。radius和length属性定义了对象的尺寸,而slices属性影响生成的三角形的数量。我们选择增加切片的数量以使圆柱体看起来更好,但请注意,这可能会对性能产生影响,如果有很多对象,这种影响可能会变得明显。
我们的Rod组件有一个索引属性,其中包含杆的位置编号。我们使用这个属性来计算杆的x和z坐标,以便所有三根杆都放置在一个半径为八的圆上。y坐标被设置为确保杆位于基础之上。我们将计算出的位置向量分配给Transform组件的translation属性。最后,将三个Rod对象添加到Scene.qml文件中:
Entity {
id: sceneRoot
//...
Rod { index: 0 }
Rod { index: 1 }
Rod { index: 2 }
}
当你运行项目时,你应该看到基础和杆:

现在是时候重复 3D 对象了
我们的代码是可行的,但我们创建杆的方式并不理想。首先,在Scene.qml中枚举杆及其索引是不方便且容易出错的。其次,我们需要有一种方法可以通过索引访问Rod对象,而当前的方法不允许这样做。在前几章中,我们使用Repeater QML 类型处理重复的 Qt Quick 对象。然而,Repeater不适用于Entity对象。它只能处理继承自 Qt Quick Item的类型。
我们问题的解决方案对你来说已经很熟悉了,因为第十二章,Qt Quick 中的自定义。我们可以使用命令式 JavaScript 代码创建 QML 对象。从Scene.qml文件中删除Rod对象,并添加以下内容:
Entity {
id: sceneRoot
property variant rods: []
Entity { /* ... */}
Component.onCompleted: {
var rodComponent = Qt.createComponent("Rod.qml");
if(rodComponent.status !== Component.Ready) {
console.log(rodComponent.errorString());
return;
}
for(var i = 0; i < 3; i++) {
var rod = rodComponent.createObject(sceneRoot, { index: i });
rods.push(rod);
}
}
}
刚才发生了什么?
首先,我们创建了一个名为rods的属性,它将保存创建的Rod对象数组。接下来,我们使用Component.onCompleted附加属性在 QML 引擎实例化我们的根对象后运行一些 JavaScript 代码。我们的第一个动作是加载Rod组件并检查它是否成功加载。在获得一个功能组件对象后,我们使用其createObject()方法创建了三根新杆。我们使用该函数的参数传递根对象和index属性的值。最后,我们将Rod对象推入数组中。
现在是创建磁盘的时候了
我们接下来的任务是创建八个将滑入杆中的磁盘。我们将以处理杆类似的方式来做这件事。首先,为我们的新组件创建一个名为Disk.qml的新文件。将以下内容放入该文件中:
import Qt3D.Core 2.10
import Qt3D.Render 2.10
import Qt3D.Extras 2.10
Entity {
property int index
property alias pos: transform.translation
components: [
DiffuseSpecularMaterial {
ambient: Qt.hsla(index / 8, 1, 0.5)
},
TorusMesh {
minorRadius: 1.1
radius: 2.5 + 1 * index
rings: 80
},
Transform {
id: transform
rotationX: 90
scale: 0.45
}
]
}
与杆一样,磁盘通过其索引来识别。在这种情况下,索引影响磁盘的颜色和大小。我们使用Qt.hsla()函数来计算磁盘的颜色,该函数接受色调、饱和度和亮度值,并返回一个可以分配给材料的ambient属性的color值。这个公式将给我们八个不同色调的有色磁盘。
磁盘的位置由Transform组件的translation属性定义。我们希望能够从外部读取和更改磁盘的位置,因此我们设置了一个名为pos的属性别名,以暴露transform.translation属性值。
接下来,我们使用TorusMesh组件来定义我们磁盘的形状。在现实中,环形状并不适合玩汉诺塔游戏,但暂时只能这样。在本章的后面部分,我们将用更合适的形状来替换它。TorusMesh组件的属性允许我们调整其一些测量值,但我们也必须对该对象应用旋转和缩放,以实现所需的大小和位置。
与将所有磁盘对象放入单个数组不同,让我们为每根杆创建一个数组。当我们把一个磁盘从一个杆移动到另一个杆时,我们将从第一个杆的数组中移除该磁盘,并将其添加到第二个杆的数组中。我们可以通过向Rod组件添加一个属性来实现这一点。在此过程中,我们还应该将杆的位置暴露给外部。我们需要它来定位杆上的磁盘。在Rod.qml中的顶级Entity中声明以下属性:
readonly property alias pos: transform.translation
property var disks: []
pos属性将遵循Transform组件的translation属性的值。由于这个值是基于index属性计算的,我们将pos属性声明为readonly。
接下来,我们需要调整Scene组件的Component.onCompleted处理程序。初始化diskComponent变量,就像我们处理rodComponent一样。然后你可以使用以下代码创建磁盘:
var startingRod = rods[0];
for(i = 0; i < 8; i++) {
var disk = diskComponent.createObject(sceneRoot, { index: i });
disk.pos = Qt.vector3d(startingRod.pos.x, 8 - i, startingRod.pos.z);
startingRod.disks.unshift(disk);
}
在创建每个磁盘后,我们根据其索引和所选杆的位置设置其位置。我们将所有磁盘累积在杆的disks属性中。我们选择数组中磁盘的顺序,使得最底部的磁盘在开始处,顶部的磁盘在末尾。unshift()函数将项目添加到数组的开始处,从而得到所需的顺序。
如果你运行项目,你应该在杆上看到所有的八个环:

我们接下来需要的下一个功能是将磁盘从一个杆移动到另一个杆的能力。然而,这是玩家做出的决定,因此我们还需要一种方式来接收用户的输入。让我们看看我们有哪些处理用户输入的选项。
处理用户输入
在 Qt 3D 中接收事件的第一种方式是使用 Qt GUI 功能。我们使用的Qt3DQuickWindow类从QWindow继承。这允许你子类化Qt3DQuickWindow并重新实现其一些虚拟函数,例如keyPressEvent()或mouseMoveEvent()。你已经在 Qt API 的这一部分中很熟悉了,因为它大致与 Qt Widgets 和 Graphics View 提供的相同。Qt 3D 在这里没有引入任何特别的东西,所以我们不会过多关注这种方法。
与 Qt Quick 类似,Qt 3D 引入了一个用于接收输入事件的更高级 API。让我们看看我们如何使用它。
设备
Qt 3D 专注于为它处理的每个方面提供良好的抽象。这也适用于输入。在 Qt 3D 的术语中,一个应用程序可能可以访问任意数量的物理设备。它们由AbstractPhysicalDevice类型表示。在撰写本文时,有两种内置的物理设备类型:键盘和鼠标。你可以在你的 QML 文件中通过声明KeyboardDevice或MouseDevice类型的对象来访问它们。
你可以使用设备对象的属性来配置其行为。目前只有一个这样的属性:MouseDevice类型有一个 sensitivity属性,它影响鼠标移动如何转换为轴输入。
在单个应用程序中创建同一设备类型的多个对象是允许的。所有设备将处理所有接收到的输入,但你可以为不同的设备对象设置不同的属性值。
你通常不希望直接从物理设备处理事件。相反,你应该设置一个逻辑设备,它从物理设备接收事件并将它们转换为对应用程序有意义的动作和输入。你可以使用LogicalDevice类型的actions和axes属性为你的设备指定一组动作和轴,Qt 3D 将识别所描述的输入并通知你的对象。
我们将提供一些代码示例来展示如何在 Qt 3D 中处理各种类型的输入。你可以通过将其放入hanoi项目的main.qml文件中或为该目的创建一个单独的项目来测试代码。
键盘和鼠标按钮
动作由Action类型表示。一个动作可以通过按下单个键、键组合或键序列来触发。这是由Action类型的inputs属性定义的。最简单类型的输入是ActionInput,它对单个键做出反应。
当动作被触发时,其active属性将从false变为true。当相应的键或键组合被释放时,active将变回false。你可以使用通常的 QML 功能来跟踪属性的变化:
Entity {
//...
KeyboardDevice { id: keyboardDevice }
MouseDevice { id: mouseDevice }
LogicalDevice {
actions: [
Action {
inputs: ActionInput {
sourceDevice: keyboardDevice
buttons: [Qt.Key_A]
}
onActiveChanged: {
console.log("A changed: ", active);
}
},
Action {
inputs: ActionInput {
sourceDevice: keyboardDevice
buttons: [Qt.Key_B]
}
onActiveChanged: {
console.log("B changed: ", active);
}
},
Action {
inputs: ActionInput {
sourceDevice: mouseDevice
buttons: [MouseEvent.RightButton]
}
onActiveChanged: {
console.log("RMB changed: ", active);
}
}
]
}
}
如你所见,键盘和鼠标按钮事件的处理方式相同。然而,它们来自不同的物理设备,所以请确保你在ActionInput的sourceDevice属性中指定了正确的设备。
你可以为ActionInput指定多个按钮。在这种情况下,如果指定的任何按钮被按下,动作将被触发。例如,使用以下代码来处理主Enter键和数字键盘上的Enter键:
Action {
inputs: ActionInput {
sourceDevice: keyboardDevice
buttons: [Qt.Key_Return, Qt.Key_Enter]
}
onActiveChanged: {
if (active) {
console.log("enter was pressed");
} else {
console.log("enter was released");
}
}
}
注意,将输入处理代码放入场景的根对象中不是必需的。你可以将其放入任何Entity中。同时,也可以有多个实体同时处理输入事件。
输入和弦
InputChord类型允许你在同时按下多个键时触发一个动作:
Action {
inputs: InputChord {
timeout: 500
chords: [
ActionInput {
sourceDevice: keyboardDevice
buttons: [Qt.Key_Q]
},
ActionInput {
sourceDevice: keyboardDevice
buttons: [Qt.Key_W]
},
ActionInput {
sourceDevice: keyboardDevice
buttons: [Qt.Key_E]
}
]
}
onActiveChanged: {
console.log("changed: ", active);
}
}
当在 500 毫秒内按下并保持 Q、W 和 E 键时,将调用 onActiveChanged 处理器。
模拟(轴)输入
轴在 Qt 3D 中是对模拟一维输入的抽象。轴输入的一个典型来源是游戏手柄的模拟摇杆。正如其名所示,Axis 只表示沿单轴的运动,因此摇杆可以表示为两个轴——一个用于垂直运动,一个用于水*运动。一个压力敏感的按钮可以表示为一个轴。轴输入产生一个范围从 -1 到 1 的 float 值,其中零对应于中性位置。
话虽如此,在撰写本文时,Qt 3D 中没有游戏手柄支持。有可能在未来版本中添加此功能。您还可以使用 Qt 3D 的可扩展 C++ API 来实现使用 Qt Gamepad 的游戏手柄设备。然而,最简单的解决方案是直接使用 Qt Gamepad。没有任何东西阻止您在使用 Qt 3D 的应用程序中使用 QML 或 Qt Gamepad 的 C++ API。
Axis 类型的 inputs 属性允许您指定哪些输入事件应被重定向到该轴。您可以使用 AnalogAxisInput 类型来访问由物理设备提供的轴数据。MouseDevice 提供了四个基于鼠标输入的虚拟轴。其中两个基于垂直和水*滚动。另外两个基于垂直和水*指针移动,但它们仅在按下任何鼠标按钮时才工作。
ButtonAxisInput 类型允许您根据按下的按钮模拟一个轴。您可以使用 scale 属性设置每个按钮对应的轴值。当同时按下多个按钮时,使用它们的轴值的*均值。
下面的示例展示了基于鼠标和按钮的轴:
LogicalDevice {
axes: [
Axis {
inputs: [
AnalogAxisInput {
sourceDevice: mouseDevice
axis: MouseDevice.X
}
]
onValueChanged: {
console.log("mouse axis value", value);
}
},
Axis {
inputs: [
ButtonAxisInput {
sourceDevice: keyboardDevice
buttons: [Qt.Key_Left]
scale: -1.0
},
ButtonAxisInput {
sourceDevice: keyboardDevice
buttons: [Qt.Key_Right]
scale: 1
}
]
onValueChanged: {
console.log("keyboard axis value", value);
}
}
]
}
对象选择器
对象选择器是一个允许实体与鼠标指针交互的组件。此组件不会直接与之前描述的输入 API 交互。例如,您不需要为此提供鼠标设备。您需要做的只是将 ObjectPicker 组件附加到一个也包含网格的实体上。ObjectPicker 的信号将通知您与该实体相关的输入事件:
| 信号 | 说明 |
|---|---|
clicked(pick) |
对象被点击。 |
pressed(pick) |
在对象上按下鼠标按钮。 |
released(pick) |
在 pressed(pick) 触发后,鼠标按钮被释放。 |
moved(pick) |
鼠标指针被移动。 |
entered() |
鼠标指针进入了对象的区域。 |
exited() |
鼠标指针离开了对象的区域。 |
此外,当鼠标按钮在对象上按下时,pressed属性将被设置为true,而当鼠标指针在对象区域上方时,containsMouse属性将被设置为true。您可以将更改处理程序附加到这些属性或像使用 QML 中的任何其他属性一样使用它们:
Entity {
components: [
DiffuseSpecularMaterial { /* ... */ },
TorusMesh { /* ... */ },
ObjectPicker {
hoverEnabled: true
onClicked: {
console.log("clicked");
}
onContainsMouseChanged: {
console.log("contains mouse?", containsMouse);
}
}
]
}
根据您的场景,拾取可能是一个计算密集型任务。默认情况下,使用最简单和最有效率的选项。默认对象拾取器将仅处理鼠标按下和释放事件。您可以将dragEnabled属性设置为true以处理在pressed(pick)触发后的鼠标移动。您还可以将hoverEnabled属性设置为true以处理所有鼠标移动,即使鼠标按钮未按下。这些属性属于ObjectPicker组件,因此您可以分别为每个实体单独设置它们。
也有一些全局拾取设置,它们会影响整个窗口。这些设置存储在RenderSettings组件的pickingSettings属性中,该组件通常附加到根实体上。设置可以像这样更改:
Entity {
components: [
RenderSettings {
activeFrameGraph: ForwardRenderer { /*...*/ }
pickingSettings.pickMethod: PickingSettings.TrianglePicking
},
InputSettings {}
]
//...
}
让我们来看看可能的设置。pickResultMode属性定义了重叠拾取器的行为。如果设置为PickingSettings.NearestPick,则只有离相机最*的对象将接收到事件。如果指定为PickingSettings.AllPicks,则所有对象都将接收到事件。
pickMethod属性允许您选择拾取器如何决定鼠标指针是否与对象重叠。默认值是PickingSettings.BoundingVolumePicking,这意味着只考虑对象的边界框。这是一个快速但不太准确的方法。为了获得更高的精度,您可以设置PickingSettings.TrianglePicking方法,它考虑所有网格三角形。
最后,faceOrientationPickingMode属性允许您选择是否使用三角形拾取的前面、背面或两个面。
基于帧的输入处理
在所有之前的示例中,我们使用了属性更改信号处理程序来在逻辑设备或对象拾取器的状态发生变化时执行代码。这允许您在按钮按下或释放时执行函数。然而,有时您希望在按钮按下时执行连续动作(例如,加速对象)。通过仅对代码进行少量更改,这很容易做到。
首先,您需要给具有有趣属性的对象附加一个id(例如Action、Axis或ObjectPicker):
LogicalDevice {
actions: [
Action {
id: myAction
inputs: ActionInput {
sourceDevice: keyboardDevice
buttons: [Qt.Key_A]
}
}
]
}
这将允许您引用其属性。接下来,您需要使用Qt3DLogic模块提供的FrameAction组件。此组件将简单地每帧发出triggered()信号。您可以将它附加到任何实体并按需使用输入数据:
Entity {
components: [
//...
FrameAction {
onTriggered: {
console.log("A state: ", myAction.active);
}
}
]
你可以使用 FrameAction 组件来运行任何应该每帧执行一次的代码。然而,不要忘记 QML 允许你使用属性绑定,因此你可以根据用户输入设置属性值,而无需编写任何命令式代码。
行动时间 - 接收鼠标输入
我们的游戏相当简单,所以玩家唯一需要做的动作是选择两根杆进行移动。让我们使用 ObjectPicker 来检测玩家何时点击杆。
首先,将 RenderSettings 对象的 pickingSettings.pickMethod 属性在 main.qml 文件中设置为 PickingSettings.TrianglePicking(你可以使用上一节中的代码示例)。我们的场景非常简单,三角形选择不应该太慢。此设置将大大提高选择器的准确性。
下一个更改集将针对 Rod.qml 文件。首先,给根实体添加一个 ID 并声明一个信号,该信号将通知外界杆已被点击:
Entity {
id: rod
property int index
readonly property alias pos: transform.translation
property var disks: []
signal clicked()
//...
}
接下来,将 ObjectPicker 添加到 components 数组中,并在选择器报告被点击时发出公共的 clicked() 信号:
Entity {
//...
components: [
//...
ObjectPicker {
id: picker
hoverEnabled: true
onClicked: rod.clicked()
}
]
}
最后,让我们给玩家一个提示,当杆与鼠标指针相交时,通过高亮显示来表明杆是可点击的:
DiffuseSpecularMaterial {
ambient: {
return picker.containsMouse? "#484" : "#111";
}
},
当玩家将鼠标指针放在杆上时,picker.containsMouse 属性将变为 true,QML 将自动更新材质的颜色。当你运行项目时,你应该看到这种行为。下一个任务是访问 Scene 组件的杆的 clicked() 信号。为此,你需要在代码中进行以下更改:
Component.onCompleted: {
//...
var setupRod = function(i) {
var rod = rodComponent.createObject(sceneRoot, { index: i });
rod.clicked.connect(function() {
rodClicked(rod);
});
return rod;
}
for(var i = 0; i < 3; i++) {
rods.push(setupRod(i));
}
//...
}
function rodClicked(rod) {
console.log("rod clicked: ", rods.indexOf(rod));
}
由于这些更改,每当点击杆时,游戏应该将一条消息打印到应用程序输出。
刚才发生了什么?
首先,我们添加了一个 setupRod() 辅助函数,用于创建一个新的杆并将其信号连接到新的 rodClicked() 函数。然后我们简单地为每个索引调用 setupRod() 并将杆对象累积到 rods 数组中。rodClicked() 函数将包含我们游戏逻辑的其余部分,但现在它只打印被点击杆的索引到应用程序输出。
注意,setupRod() 函数的内容不能直接放置在 for 循环的 i 身体中。clicked() 信号连接到一个捕获 rod 变量的闭包。在函数内部,每根杆都会连接到一个捕获相应 Rod 的闭包
对象。在 for 循环中,所有闭包都会捕获公共
rod 变量将保存所有闭包的最后一个 Rod 对象。
执行动画
动画对于制作一款优秀的游戏至关重要。Qt 3D 提供了一个独立的模块来执行动画,但在撰写本文时,它仍然处于实验阶段。幸运的是,Qt 已经提供了多种播放动画的方法。当使用 C++ API 时,你可以使用动画框架(我们曾在第五章,图形视图中的动画)中了解过)。当使用 QML 时,你可以使用 Qt Quick 提供的强大且便捷的动画系统。我们在前面的章节中已经大量使用过它,所以在这里我们将看看如何将我们的知识应用到 Qt 3D 中。
Qt Quick 动画可以应用于任何 QML 对象的几乎任何属性(严格来说,有一些属性类型它无法处理,但在这里我们不会处理这些类型)。如果你查看我们项目的 QML 文件,你会发现我们场景中的几乎所有内容都是由属性定义的。这意味着你可以动画化位置、颜色、对象的尺寸以及几乎所有其他内容。
我们当前的任务将是创建一个动画,该动画显示磁盘从杆上滑起,移动到桌子的另一端,然后沿该杆滑下。我们将动画化的属性是pos,它是transform.translation属性别名。
动手实践 - 动画磁盘移动
我们的动画将包含三个部分,因此需要相当多的代码。我们不是直接将所有这些代码放入Scene组件中,而是将其放入一个单独的组件中。创建一个名为DiskAnimation.qml的新文件,并填充以下代码:
import QtQuick 2.10
SequentialAnimation {
id: rootAnimation
property variant target: null
property vector3d rod1Pos
property vector3d rod2Pos
property int startY
property int finalY
property int maxY: 12
Vector3dAnimation {
target: rootAnimation.target
property: "pos"
to: Qt.vector3d(rod1Pos.x, maxY, rod1Pos.z)
duration: 30 * (maxY - startY)
}
Vector3dAnimation {
target: rootAnimation.target
property: "pos"
to: Qt.vector3d(rod2Pos.x, maxY, rod2Pos.z)
duration: 400
}
Vector3dAnimation {
target: rootAnimation.target
property: "pos"
to: Qt.vector3d(rod2Pos.x, finalY, rod2Pos.z)
duration: 30 * (maxY - finalY)
}
}
刚才发生了什么?
我们的动画有很多属性,因为它应该足够灵活,以处理我们需要的所有情况。首先,它应该能够动画化任何磁盘,因此我们添加了target属性,它将包含我们当前移动的磁盘。接下来,参与移动的杆会影响磁盘的中间和最终坐标(更具体地说,是其x和z坐标)。rod1Pos和rod2Pos属性将保存杆的坐标。startY和finalY属性定义了磁盘的起始和最终坐标。这些坐标将取决于每个杆上存储的磁盘数量。最后,maxY属性简单地定义了磁盘在移动过程中可以达到的最大高度。
我们所动画的属性是vector3d类型,因此我们需要使用能够正确插值向量所有三个分量的Vector3dAnimation类型。我们为动画的三个部分设置了相同的target和property。然后,我们仔细计算了每个阶段后磁盘的最终位置,并将其分配给to属性。不需要设置from属性,因为动画将自动使用磁盘的当前位置作为起始点。最后,我们计算了每一步的duration以确保磁盘的*稳移动。
当然,我们想立即测试新的动画。将DiskAnimation对象添加到Scene组件中,并在Component.onCompleted处理器的末尾初始化动画:
DiskAnimation { id: diskAnimation }
Component.onCompleted: {
//...
var disk1 = rods[0].disks.pop();
diskAnimation.rod1Pos = rods[0].pos;
diskAnimation.rod2Pos = rods[1].pos;
diskAnimation.startY = disk1.pos.y;
diskAnimation.finalY = 1;
diskAnimation.target = disk1;
diskAnimation.start();
}
当你运行应用程序时,你应该看到顶部圆盘从一个杆子移动到另一个杆子。
行动时间 - 实现游戏逻辑
所需的大部分准备工作都已完成,现在是时候使我们的游戏功能化了。玩家应该能够通过点击一个杆子然后点击另一个杆子来进行移动。在选择了第一杆后,游戏应该记住它并以不同的颜色显示它。
首先,让我们准备Rod组件。我们需要它有一个新的属性,表示该杆被选为下一次移动的第一杆:
property bool isSourceRod: false
使用属性绑定很容易根据isSourceRod值使杆子改变颜色:
DiffuseSpecularMaterial {
ambient: {
if (isSourceRod) {
return picker.containsMouse? "#f44" : "#f11";
} else {
return picker.containsMouse? "#484" : "#111";
}
}
},
现在,让我们将注意力转向Scene组件。我们需要一个包含当前所选第一杆的属性:
Entity {
id: sceneRoot
property variant rods: []
property variant sourceRod
//...
}
剩下的就是实现rodClicked()函数。让我们分两步进行:
function rodClicked(rod) {
if (diskAnimation.running) { return; }
if (rod.isSourceRod) {
rod.isSourceRod = false;
sourceRod = null;
} else if (!sourceRod) {
if (rod.disks.length > 0) {
rod.isSourceRod = true;
sourceRod = rod;
} else {
console.log("no disks on this rod");
}
} else {
//...
}
}
首先,我们检查移动动画是否已经在运行,如果是,则忽略事件。接下来,我们检查点击的杆子是否已经被选中。在这种情况下,我们简单地取消选中杆子。这允许玩家在意外选中错误的杆子时取消移动。
如果sourceRod未设置,这意味着我们处于移动的第一阶段。我们检查点击的杆子上是否有圆盘,否则移动将不可能。如果一切正常,我们设置sourceRod属性和杆子的isSourceRod属性。
函数的其余部分处理移动的第二阶段:
var targetRod = rod;
if (targetRod.disks.length > 0 &&
targetRod.disks[targetRod.disks.length - 1].index <
sourceRod.disks[sourceRod.disks.length - 1].index)
{
console.log("invalid move");
} else {
var disk = sourceRod.disks.pop();
targetRod.disks.push(disk);
diskAnimation.rod1Pos = sourceRod.pos;
diskAnimation.rod2Pos = targetRod.pos;
diskAnimation.startY = disk.pos.y;
diskAnimation.finalY = targetRod.disks.length;
diskAnimation.target = disk;
diskAnimation.start();
}
sourceRod.isSourceRod = false;
sourceRod = null;
在这个分支中,我们已经知道我们已经在sourceRod属性中存储了第一杆对象。我们将点击的杆子对象存储在targetRod变量中。接下来,我们检查玩家是否试图将较大的圆盘放在较小的圆盘上面。如果是这样,我们拒绝执行无效的移动。
如果一切正常,我们最终执行移动。我们使用pop()函数从sourceRod.disks数组的末尾移除圆盘。这是将被移动到另一个杆子的圆盘。我们立即将圆盘对象推入另一个杆子的disks数组。接下来,我们仔细设置动画的所有属性并启动它。在函数的末尾,我们清除杆子的isSourceRod属性和场景的sourceRod属性,以便玩家可以进行下一次移动。
尝试英雄之旅 - 提升游戏
尝试对游戏进行自己的修改。例如,你可以通过闪烁背景颜色或基础对象的颜色来通知玩家一个无效的移动。你甚至可以使用ExtrudedTextMesh组件在场景中添加 3D 文本。尝试使用不同的缓动模式来使动画看起来更好。
Scene对象的属性和函数对外部世界是可见的,但它们实际上是实现细节。您可以通过将它们放入一个内部的QtObject中来解决这个问题,正如我们在第十二章,“Qt Quick 的自定义”中描述的那样。
在渲染方面,Qt 3D 非常灵活。虽然它与简单的ForwardRenderer使用起来很直接,但如果您想要创建一个更复杂的渲染图,您也可以做到。您可以将渲染输出到多个视口,使用离屏纹理,应用自定义着色器,并创建自己的图形效果和材质。我们无法在本书中讨论所有这些可能性,但您可以通过查看 Qt 示例来了解如何实现。一些相关的示例包括 Qt3D:多视口 QML、Qt3D:阴影映射 QML 和 Qt3D:高级自定义材质 QML。
与 3D 建模软件的集成
Qt3DExtras模块提供的几何形状非常适合原型设计。正如我们所见,当您想要快速创建和测试新游戏时,这些网格生成器非常有用。然而,一个真正的游戏通常包含比球体和立方体更复杂的图形。网格通常使用专门的 3D 建模软件准备。Qt 3D 提供了从外部文件导入 3D 数据的广泛功能。
导入此类数据的第一种方式是Mesh组件。您只需将此组件附加到实体上,并使用source属性指定文件路径。从 Qt 5.10 版本开始,Mesh支持 OBJ、PLY、STL 和 Autodesk FBX 文件格式。
与往常一样,您可以使用真实的文件名或 Qt 资源路径。但是请注意,源属性期望一个 URL,而不是路径。正确的绝对资源路径应以qrc:/开头,而绝对文件路径应以file://开头。您还可以使用相对路径,这些路径将相对于当前 QML 文件进行解析。
如果您正在使用 OBJ 文件,Mesh为您提供了从source文件中仅加载子网格的附加选项。您可以通过在Mesh组件的meshName属性中指定子网格的名称来实现。除了确切名称外,您还可以指定一个正则表达式来加载所有匹配该表达式的子网格。
行动时间 – 使用 OBJ 文件处理磁盘
Qt 3D 不提供适合磁盘的合适网格,但我们可以使用 3D 建模软件制作我们想要的任何形状,然后将其用于我们的项目中。您将在本书附带资源中找到所需的 OBJ 文件。文件命名从disk0.obj到disk7.obj。如果您想使用 3D 建模软件进行练习,您可以自己准备这些文件。
在你的项目目录中创建一个名为 obj 的子目录,并将 OBJ 文件放在那里。在 Qt Creator 的项目树中调用 qml.qrc 的上下文菜单,并选择“添加现有文件”。将所有 OBJ 文件添加到项目中。为了使这些文件发挥作用,我们需要编辑 Disk.qml 文件。从 Transform 组件中移除缩放和旋转。将 TorusMesh 替换为 Mesh 并将资源路径指定为 source 属性:
components: [
DiffuseSpecularMaterial { /*...*/ },
Mesh {
source: "qrc:/obj/disk" + index + ".obj"
},
Transform {
id: transform
}
]
Qt 3D 将现在使用我们新的磁盘模型:

加载 3D 场景
当你想要从外部文件导入单个对象的形状时,Mesh 组件非常有用。然而,有时你希望从单个文件中导入多个对象。例如,你可以准备一些围绕你的游戏动作的装饰,然后一次性导入它们。这就是 SceneLoader 组件变得有用的地方。
它可以像 Mesh 组件一样使用:
Entity {
components: [
SceneLoader {
source: "path/to/scene/file"
}
]
}
然而,SceneLoader 不是提供其实体的形状,而是创建一个整个的 Entity 对象树,这些对象成为 SceneLoader 实体的子对象。每个新的实体都将根据文件数据提供网格、材质和变换。SceneLoader 使用 Assimp(Open Asset Import Library)来解析源文件,因此它支持许多常见的 3D 格式。
使用 C++ 与 Qt 3D 一起工作
虽然 QML 是使用 Qt 3D 的强大且便捷的方式,但有时你可能出于某些原因更愿意使用 C++ 而不是 QML。例如,如果你的项目有一个庞大的 C++ 代码库或者你的团队不熟悉 JavaScript,坚持使用 C++ 可能是正确的解决方案。如果你想用你的自定义实现扩展 Qt 3D 类,你必须使用 C++ 方法。此外,如果你处理大量对象,在 C++ 中处理它们可能比在 QML 中处理要快得多。Qt 允许你自由选择 C++ 和 QML。
Qt 3D 的 QML API 主要由没有太多变化的 C++ 类组成。这意味着到目前为止你在这个章节中看到的绝大部分代码都可以通过最小努力透明地转换为等效的 C++ 代码。当你选择不使用 QML 时,你会失去其属性绑定、语法糖以及声明自动实例化的对象树的能力。然而,只要你熟悉 Qt C++ API 的核心,你就应该不会有任何问题。你将不得不手动创建对象并将它们分配给父对象。作为属性绑定的替代,你将不得不连接到属性更改信号并手动执行所需的更新。如果你已经学习了这本书的前几章,你应该不会有任何问题。
行动时间 - 使用 C++ 创建 3D 场景
让我们看看我们如何仅使用 C++ 代码重新创建我们的第一个 Qt 3D 场景。我们的场景将包含一个光源、一个立方体和一个第一人称相机控制器。你可以使用 Qt 控制台应用程序模板来创建项目。不要忘记在项目文件中启用你想要在项目中使用的 Qt 3D 模块:
QT += 3dextras
CONFIG += c++11
与 QML 方法相比,第一个变化是你需要使用 Qt3DWindow 类而不是 Qt3DQuickWindow。Qt3DWindow 类执行了在 Qt 3D 应用程序中通常需要的几个操作。它设置了一个 QForwardRenderer、一个相机,并初始化了处理事件所需的 QInputSettings 对象。你可以使用 defaultFrameGraph() 方法访问默认帧图。默认相机可以通过 camera() 方法获得。默认相机的纵横比会自动根据窗口大小更新。如果你想要设置自定义帧图,请使用 setActiveFrameGraph() 方法。
我们小型示例中的所有代码都将放入 main() 函数中。让我们逐个分析它。首先,我们初始化通常的 QGuiApplication 对象,创建一个 Qt3DWindow 对象,并将其帧图和相机的首选设置应用于它:
int main(int argc, char *argv[]) {
QGuiApplication app(argc, argv);
Qt3DExtras::Qt3DWindow window;
window.defaultFrameGraph()->setClearColor(Qt::black);
Qt3DRender::QCamera *camera = window.camera();
camera->lens()->setPerspectiveProjection(45.0f, 16.0f / 9.0f, 0.1f, 1000.0f);
camera->setPosition(QVector3D(0, 40.0f, -40.0f));
camera->setViewCenter(QVector3D(0, 0, 0));
//...
}
接下来,我们创建一个根实体对象,它将包含我们所有的其他实体,并为相机创建一个相机控制器:
Qt3DCore::QEntity *rootEntity = new Qt3DCore::QEntity();
Qt3DExtras::QFirstPersonCameraController *cameraController =
new Qt3DExtras::QFirstPersonCameraController(rootEntity);
cameraController->setCamera(camera);
接下来,我们设置一个灯光实体:
Qt3DCore::QEntity *lightEntity = new Qt3DCore::QEntity(rootEntity);
Qt3DRender::QDirectionalLight *lightComponent = new Qt3DRender::QDirectionalLight();
lightComponent->setColor(Qt::white);
lightComponent->setIntensity(0.5);
lightComponent->setWorldDirection(QVector3D(0, -1, 0));
lightEntity->addComponent(lightComponent);
重要的是,我们需要将根实体传递给 QEntity 构造函数,以确保新实体将成为我们场景的一部分。要向实体添加组件,我们使用 addComponent() 函数。下一步是设置立方体 3D 对象:
Qt3DCore::QEntity *cubeEntity = new Qt3DCore::QEntity(rootEntity);
Qt3DExtras::QCuboidMesh *cubeMesh = new Qt3DExtras::QCuboidMesh();
Qt3DExtras::QDiffuseSpecularMaterial *cubeMaterial =
new Qt3DExtras::QDiffuseSpecularMaterial();
cubeMaterial->setAmbient(Qt::white);
Qt3DCore::QTransform *cubeTransform = new Qt3DCore::QTransform();
cubeTransform->setScale(10);
cubeEntity->addComponent(cubeMesh);
cubeEntity->addComponent(cubeMaterial);
cubeEntity->addComponent(cubeTransform);
如你所见,此代码只是创建了一些对象,并将它们的属性设置为我们在 QML 示例中使用的相同值。代码的最后几行完成了我们的设置:
window.setRootEntity(rootEntity);
window.show();
return app.exec();
我们将根实体传递给窗口并在屏幕上显示它。这就完成了!Qt 3D 将以与我们的 QML 项目相同的方式渲染构建的场景。
Qt 3D 类的所有属性都配备了变更通知信号,因此你可以使用连接语句来对外部属性变更做出反应。例如,如果你使用 Qt3DInput::QAction 组件来接收键盘或鼠标事件,你可以使用它的 activeChanged(bool isActive) 信号来获取关于事件的通知。你还可以使用 C++ 动画类,如 QPropertyAnimation,在 3D 场景中执行动画。
与 Qt Widgets 和 Qt Quick 的集成
虽然 Qt 3D 是一个非常强大的模块,但有时它不足以制作一个完整的游戏或应用程序。其他 Qt 模块,如 Qt Quick 或 Qt Widgets,可能非常有帮助,例如,当你在游戏的用户界面工作时。幸运的是,Qt 提供了一些方法来共同使用不同的模块。
当涉及到 Qt Widgets 时,您最好的选择是 QWidget::createWindowContainer() 函数。它允许您将小部件包围在 3D 视图中,并在单个窗口中显示它们。这种方法已经在 第九章 中讨论过,即 Qt 应用程序中的 OpenGL 和 Vulkan,并且可以应用于 Qt 3D 而无需任何更改。
然而,在硬件加速图形的世界中,Qt Widgets 的功能仍然有限。Qt Quick 在这个领域具有更大的潜力,Qt Quick 的 QML API 与 Qt 3D 之间的协同作用可能非常强大。Qt 提供了两种方法,可以在单个应用程序中将 Qt Quick 和 Qt 3D 结合起来,而不会产生显著的性能成本。让我们更详细地看看它们。
将 Qt Quick UI 嵌入 3D 场景
Qt 3D 允许您使用 Scene2D 类型将任意 Qt Quick 项目嵌入到您的 3D 场景中。这是如何工作的?首先,您需要将您的 Qt Quick 内容放入一个新的 Scene2D 对象中。接下来,您需要声明一个将用作表单渲染目标的纹理。每当 Qt Quick 决定更新其虚拟视图时,Scene2D 对象将直接将其渲染到指定的纹理中。您只需要按您想要的方式显示此纹理。最简单的方法是将它传递给附加到您的 3D 对象之一的 TextureMaterial 组件。
然而,这仅仅是工作的一部分。允许用户看到您的表单是件好事,但他们也应该能够与之交互。这也由 Scene2D 支持!为了使其工作,您需要执行以下操作:
-
在
RenderSettings中将pickMethod设置为TrianglePicking。这将允许对象选择器检索关于鼠标事件的更准确信息。 -
将
ObjectPicker组件附加到所有使用由Scene2D创建的纹理的实体。将对象选择器的hoverEnabled和dragEnabled属性设置为true是一个好主意,以便使鼠标事件按预期工作。 -
在
Scene2D对象的entities属性中指定所有这些实体。
这将允许 Scene2D 将鼠标事件转发到 Qt Quick 内容。不幸的是,转发键盘事件目前尚不可用。
让我们看看这个方法的示例:
import Qt3D.Core 2.0
import Qt3D.Render 2.0
import Qt3D.Input 2.0
import Qt3D.Extras 2.10
import QtQuick 2.10
import QtQuick.Scene2D 2.9
import QtQuick.Controls 2.0
import QtQuick.Layouts 1.0
Entity {
components: [
RenderSettings {
activeFrameGraph: ForwardRenderer { /*...*/ }
pickingSettings.pickMethod: PickingSettings.TrianglePicking
},
InputSettings {}
]
Scene2D {
output: RenderTargetOutput {
attachmentPoint: RenderTargetOutput.Color0
texture: Texture2D {
id: texture
width: 200
height: 200
format: Texture.RGBA8_UNorm
}
}
entities: [cube, plane]
Rectangle {
color: checkBox1.checked? "#ffa0a0" : "#a0a0ff"
width: texture.width
height: texture.height
ColumnLayout {
CheckBox {
id: checkBox1
text: "Toggle color"
}
CheckBox {
id: checkBox2
text: "Toggle cube"
}
CheckBox {
id: checkBox3
checked: true
text: "Toggle plane"
}
}
}
}
//...
}
此代码设置了一个包含 Scene2D 对象的 Qt 3D 场景。Scene2D 本身在该 3D 场景中是不可见的。我们声明了一个将接收渲染的 Qt Quick 内容的纹理。您可以根据显示内容的尺寸选择纹理的 width 和 height。
接下来,我们声明我们将在这个纹理中渲染两个实体(我们将在下一部分代码中创建它们)。最后,我们将一个 Qt Quick 项目直接放置到 Scene2D 对象中。确保您根据纹理的尺寸设置此 Qt Quick 项目的尺寸。在我们的示例中,我们创建了一个包含三个复选框的表单。
下一部分代码创建了两个用于显示基于 Qt Quick 的纹理的项目:
Entity {
id: cube
components: [
CuboidMesh {},
TextureMaterial {
texture: texture
},
Transform {
scale: 10
rotationY: checkBox2.checked ? 45 : 0
},
ObjectPicker {
hoverEnabled: true
dragEnabled: true
}
]
}
Entity {
id: plane
components: [
PlaneMesh {
mirrored: true
},
TextureMaterial {
texture: texture
},
Transform {
translation: checkBox3.checked ? Qt.vector3d(-20, 0, 0) : Qt.vector3d(20, 0, 0)
scale: 10
rotationX: 90
rotationY: 180
rotationZ: 0
},
ObjectPicker {
hoverEnabled: true
dragEnabled: true
}
]
}
第一个项是一个立方体,第二个项是一个*面。大部分属性只是使场景看起来好的任意值。重要的是每个项都有一个TextureMaterial组件,并且我们将texture对象传递给它。每个项还有一个允许用户与之交互的ObjectPicker组件。请注意,我们使用了PlaneMesh的mirrored属性来以原始(非镜像)方向显示纹理。
通常一个*面物体就足以展示你的形状。我们使用两个物体纯粹是为了演示目的。
虽然 Qt Quick 项和 Qt 3D 实体存在于不同的世界中,看起来它们不会相互交互,但它们仍然在单个 QML 文件中声明,因此你可以使用属性绑定和其他 QML 技术使所有这些项协同工作。在我们的例子中,不仅根 Qt Quick 项的背景颜色由复选框控制,3D 对象也受到复选框的影响:

将 Qt 3D 场景嵌入到 Qt Quick 表单中
现在我们来看看如何执行相反的任务。如果你的应用程序主要是围绕 Qt Quick 构建的,这种方法很有用。这意味着你在main()函数中使用QQmlApplicationEngine类,并且你的main.qml文件的根对象通常是Window对象。用一点 3D 动作扩展你的 Qt Quick 应用程序非常简单。
我们可以将所有代码放入main.qml文件中,但将其拆分更方便,因为设置 3D 场景需要相当多的代码。假设你有一个名为My3DScene.qml的文件,它包含 3D 场景的常规内容:
Entity {
components: [
RenderSettings {
activeFrameGraph: ForwardRenderer { /*...*/ },
InputSettings {}
]
Entity { /*...*/ }
Entity { /*...*/ }
//...
}
要将此 3D 场景添加到main.qml文件(或任何其他基于 Qt Quick 的 QML 文件),你应该使用可以从QtQuick.Scene3D模块导入的Scene3D QML 类型。例如,这是如何创建一个带有按钮和 3D 视图的表单的方法:
import QtQuick 2.10
import QtQuick.Layouts 1.0
import QtQuick.Controls 1.0
import QtQuick.Window 2.0
import QtQuick.Scene3D 2.0
Window {
visible: true
Button {
id: button1
text: "button1"
anchors {
top: parent.top
left: parent.left
right: parent.right
margins: 10
}
}
Scene3D {
focus: true
anchors {
top: button1.bottom
bottom: parent.bottom
left: parent.left
right: parent.right
margins: 10
}
aspects: ["input", "logic"]
My3DScene {}
}
}
大部分代码是 Qt Quick 表单的常规内容。Scene3D项执行所有魔法。根 3D 实体应直接添加到该项,或者,如我们的案例所示,以自定义组件的形式添加。Scene3D项设置 Qt 3D 引擎并渲染传递的场景:

如果你想要使用Qt3DInput或Qt3DLogic模块,你需要使用Scene3D的aspects属性启用相应的 3D 方面,如图所示。此外,可以使用multisample布尔属性启用多采样。可以使用hoverEnabled属性在鼠标按钮未按下时处理鼠标事件。
与Qt3DQuickWindow类似,Scene3D默认自动设置摄像机的宽高比。你可以通过将其cameraAspectRatioMode属性设置为Scene3D.UserAspectRatio来禁用它。
这种方法也可以用来在 3D 视图上显示一些 UI 控件。这将允许您利用 Qt Quick 的全部功能,使您的游戏 UI 精彩绝伦。
快速问答
Q1. 哪个组件可以用来旋转 3D 对象?
-
CuboidMesh -
RotationAnimation -
Transform
Q2. 哪个组件最适合模拟太阳光?
-
DirectionalLight -
PointLight -
SpotLight
Q3. Qt 3D 材料是什么?
-
一个允许您从文件中加载纹理的对象。
-
定义对象物理属性的组件。
-
定义对象表面可见属性的组件。
概述
在本章中,我们学习了如何使用 Qt 创建 3D 游戏。我们看到了如何在场景中创建和定位 3D 对象,并配置相机进行渲染。接下来,我们探讨了如何使用 Qt 3D 处理用户输入。不仅如此,您还学会了将现有的动画技能应用到 Qt 3D 对象上。最后,我们发现了如何将 Qt 3D 与其他 Qt 模块一起使用。
与 Qt Quick 一样,Qt 3D 正在快速发展。在撰写本文时,一些模块仍然是实验性的。您应该期待 Qt 3D 的 API 将得到改进和扩展,因此请确保您检查 Qt 文档以获取新版本。
这本书关于使用 Qt 进行游戏编程的内容到此结束。我们向您介绍了 Qt 的一般基础知识,描述了其小部件领域,并带您进入了 Qt Quick 和 Qt 3D 的迷人世界。Widgets(包括 Graphics View)、Qt Quick 和 Qt 3D 是您在用 Qt 框架创建游戏时可以采取的主要路径。我们还向您展示了如何利用您可能拥有的任何 OpenGL 或 Vulkan 技能,通过合并这两种方法来超越 Qt 当前的功能。此时,您应该开始尝试和实验,如果您在任何时候感到迷茫或缺乏如何做某事的信息,非常有帮助的 Qt 参考手册应该是您首先指向的资源。
祝您好运,玩得开心!
第十六章:突击测验答案
第三章
Q1: 2
Q2: 2
Q3: 2
Q4: 1
第四章
Q1: 1
Q2: 4
Q3: 2
Q4: 3
第五章
Q1: 3
Q2: 1
Q3: 3
第六章
Q1: 1
Q2: 2
Q3: 3
Q4: 3
第七章
Q1: 1
Q2: 2
Q3: 2
Q4: 3
第八章
Q1: 1
Q2: 2
Q3: 3
第九章
Q1: 3
Q2: 1
Q3: 3
第十章
Q1: 2
Q2: 2
Q3: 1
Q4: 4
Q5: 2
第十一章
Q1: 2
Q2: 2
Q3: 3
第十二章
Q1: 2
Q2: 3
Q3: 3
第十三章
Q1: 2
Q2: 1
Q3: 2
第十四章
Q1: 2
Q2: 2
Q3: 2
第十五章
Q1: 3
Q2: 1
Q3: 3
第十六章
Q1: 3
Q2: 2
Q3: 1
Q4: 3









浙公网安备 33010602011771号