QT 游戏编程初学者指南(一)
作为所有重要桌面、移动和嵌入式平台的首选跨平台工具包,Qt 正变得越来越受欢迎。本书将帮助您学习 Qt 的细节,并为您提供构建应用程序和游戏的必要工具集。本书旨在作为初学者指南,将新接触 Qt 的程序员从基础,如对象、核心类、小部件以及 5.4 版本的新特性,引导到能够使用 Qt 的最佳实践创建自定义应用程序的水平。在简要介绍如何创建应用程序并为桌面和移动平台准备工作环境之后,我们将在尝试创建游戏
原文:
zh.annas-archive.org/md5/d8f1f7b7bdb1e30ca6c39c1ae87ed04f译者:飞龙
前言
作为所有重要桌面、移动和嵌入式平台的首选跨平台工具包,Qt 正变得越来越受欢迎。本书将帮助您学习 Qt 的细节,并为您提供构建应用程序和游戏的必要工具集。本书旨在作为初学者指南,将新接触 Qt 的程序员从基础,如对象、核心类、小部件以及 5.4 版本的新特性,引导到能够使用 Qt 的最佳实践创建自定义应用程序的水平。
在简要介绍如何创建应用程序并为桌面和移动平台准备工作环境之后,我们将在尝试创建游戏之前,更深入地探讨创建图形界面和 Qt 数据处理和显示的核心概念。随着您通过章节的进展,您将学习通过实现网络连接和采用脚本编写来丰富您的游戏。深入了解 Qt Quick、OpenGL 以及各种其他工具,以添加游戏逻辑、设计动画、添加游戏物理以及为游戏构建惊人的用户界面。本书的结尾部分,您将学习如何利用移动设备功能,如加速度计和传感器,来构建引人入胜的用户体验。
本书涵盖内容
第一章, Qt 简介,将使您熟悉在创建跨平台应用程序时所需的标准行为,同时也会向您展示 Qt 的一些历史以及它是如何随着时间的推移而演变的,重点介绍 Qt 最近在架构上的重大变化*。
第二章, 安装,将指导您完成在桌面平台上安装 Qt 二进制发布版、设置捆绑的 IDE 以及查看与跨平台编程相关的各种配置选项的过程。
第三章, Qt GUI 编程,将向您展示如何使用 Qt Widgets 模块创建经典用户界面。它还将使您熟悉使用 Qt 编译应用程序的过程。
第四章, Qt 核心基础,将使您熟悉与 Qt 中数据处理和显示相关的概念——不同格式的文件处理、Unicode 文本处理以及在不同语言中显示用户可见的字符串,以及正则表达式匹配。
第五章, Qt 中的图形,描述了在 Qt 中创建和使用 2D 和 3D 图形的整个机制。它还介绍了音频和视频的多媒体功能(捕获、处理和输出)。
第六章,图形视图,将使你熟悉 Qt 中的 2D 面向对象的图形。你将学习如何使用内置项来组合最终结果,以及创建自己的项来补充现有内容,并可能使它们动画化。
第七章,网络,将演示 Qt 中可用的 IP 网络技术。它将教你如何连接到 TCP 服务器,使用 TCP 实现可靠的服务器,以及使用 UDP 实现不可靠的服务器。
第八章,脚本,将向你展示脚本在应用程序中的优势。它将教你如何通过使用 JavaScript 为游戏实现脚本引擎。它还将建议一些可以轻松与 Qt 集成的 JavaScript 脚本替代方案。
第九章,Qt Quick 基础,将教你如何使用 QML 声明性引擎和 Qt Quick 2 场景图环境来编写分辨率无关的流畅用户界面。此外,你还将学习如何在场景中实现新的图形项。
第十章,Qt Quick,将向你展示如何将动态效果引入 UI 的各个方面。你将看到如何通过使用粒子引擎、GLSL 着色器和内置动画以及状态机功能,在 Qt Quick 中创建复杂的图形和动画,并学习如何在游戏中使用这些技术。
第十一章,杂项和高级概念,涵盖了 Qt 编程的重要方面,这些方面没有包含在其他章节中,但对于游戏编程可能很重要。本章可在以下链接中在线获取:www.packtpub.com/sites/default/files/downloads/Advanced_Concepts.pdf。
你需要这本书的什么
你需要的只是安装了最新版本的 Qt 的 Windows 机器。本书中提供的示例基于 Qt 5.4。
Qt 可以从 www.qt.io/download-open-source/ 下载。
这本书面向的对象
这本书的预期读者将是具有基本/中级 C++ 功能知识的应用程序和 UI 开发者/程序员。目标受众还包括 C++ 程序员。阅读这本书不需要你具备 Qt 的先前经验。拥有最多一年 Qt 经验的开发者也将从本书涵盖的主题中受益。
部分
在这本书中,你会找到几个频繁出现的标题(行动时间、刚刚发生了什么?、快速问答和英雄试炼)。
为了清楚地说明如何完成一个程序或任务,我们使用以下部分如下:
行动时间 – 标题
-
行动 1
-
行动 2
-
行动 3
指令通常需要一些额外的解释以确保它们有意义,因此它们后面跟着这些部分:
刚才发生了什么?
本节解释了您刚刚完成的任务或指令的工作原理。
您还可以在书中找到一些其他的学习辅助工具,例如:
快速问答 – 标题
这些是简短的多项选择题,旨在帮助您测试自己的理解。
尝试一下英雄 – 标题
这些是实际挑战,为您提供实验您所学内容的想法。
惯例
您还可以找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将如下所示:“此 API 以QNetworkAccessManager为中心,它处理您游戏与互联网之间的完整通信。”
代码块设置如下:
QNetworkRequest request;
request.setUrl(QUrl("http://localhost/version.txt"));
request.setHeader(QNetworkRequest::UserAgentHeader, "MyGame");
m_nam->get(request);
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
void FileDownload::downloadFinished(QNetworkReply *reply) {
const QByteArray content = reply->readAll();
m_edit->setPlainText(content);
reply->deleteLater();
}
任何命令行输入或输出将如下所示:
git clone git://code.qt.io/qt/qt5.git
cd qt5
perl init-repository
新术语和重要词汇将以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,将以如下方式显示:“在选择目标位置屏幕上,点击下一步以接受默认目标。”
注意
警告或重要注意事项如下所示。
小贴士
小技巧和窍门看起来像这样。
读者反馈
我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。
如要向我们发送一般反馈,请简单地发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及书籍标题。
如果您在某个主题领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南,网址为 www.packtpub.com/authors。
客户支持
现在,您已经成为 Packt 书籍的骄傲拥有者,我们有一些事情可以帮助您充分利用您的购买。
下载示例代码
您可以从您的账户下载示例代码文件,网址为 www.packtpub.com,适用于您购买的所有 Packt Publishing 书籍。如果您在其他地方购买了此书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些彩色图像将帮助您更好地理解输出的变化。您可以从 www.packtpub.com/sites/default/files/downloads/GameProgrammingUsingQt_ColoredImages.pdf 下载此文件。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。
侵权
互联网上版权材料的侵权是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过 <copyright@packtpub.com> 与我们联系,并提供疑似侵权材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面所提供的帮助。
询问
如果您对本书的任何方面有问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。
第一章。Qt 简介
在本章中,您将学习 Qt 是什么以及它是如何演变的。我们将特别关注 Qt 的主要版本 4 和 5 之间的区别。最后,您将学习如何决定为我们的项目选择哪种可用的 Qt 许可方案。
跨平台编程
Qt 是一个用于开发跨平台应用程序的应用程序编程框架。这意味着为某个平台编写的软件可以轻松地移植到另一个平台并执行,几乎不需要做任何工作。这是通过将应用程序源代码限制为所有支持的平台都可用的一组例程和库的调用,并将所有可能在平台之间不同的任务(如屏幕绘制、访问系统数据或硬件)委托给 Qt 来实现的。这实际上创建了一个分层环境(如下图所示),Qt 隐藏了所有平台相关的方面,使其从应用程序代码中不可见:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_01_01.jpg
当然,有时我们需要使用 Qt 不提供的一些功能。在这种情况下,使用条件编译,如以下代码中所示,是很重要的:
#ifdef Q_OS_WIN32
// Windows specific code
#elif defined(Q_OS_LINUX) || defined(Q_OS_MAC)
// Mac and Linux specific code
#endif
小贴士
下载示例代码
您可以从您在www.packtpub.com的账户下载示例代码文件,以获取您购买的所有 Packt Publishing 书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
刚才发生了什么?
在代码编译之前,它首先被传递到一个预处理器,该预处理器可能会更改将要发送给编译器的最终文本。当它遇到#ifdef指令时,它会检查是否存在一个随后的标签(例如Q_OS_WIN32),并且只有在标签被定义的情况下才将代码块包含在编译中。Qt 确保为每个系统和编译器提供适当的定义,以便我们可以在这种情况下使用它们。
小贴士
您可以在 Qt 参考手册中找到所有此类宏的列表,在“QtGlobal”这个术语下。
Qt 平台抽象
Qt 本身分为两层。一层是在标准 C++语言中实现的 Qt 核心功能,它基本上是平台无关的。另一层是一组小型插件,实现了所谓的Qt 平台抽象(QPA),它包含所有与创建窗口、在表面上绘制、使用字体等相关联的平台特定代码。因此,在实践中将 Qt 移植到新平台实际上归结为为它实现 QPA 插件,前提是这个平台使用支持的标准 C++编译器之一。正因为如此,为新的平台提供基本支持可能是几个小时就能完成的工作。
支持的平台
该框架适用于多种平台,从传统的桌面环境到嵌入式系统,再到移动电话。以下表格列出了在撰写本文时 Qt 支持的所有平台和编译器家族。有可能在你阅读本文时,此表可能已增加了几行:
| 平台 | QPA 插件 | 支持的编译器 |
|---|---|---|
| Linux | XCB (X11) 和 Wayland | GCC, LLVM (clang), 和 ICC |
| Windows XP, Vista, 7, 8, and 10 | Windows | MinGW, MSVC, and ICC |
| Mac OS X | Cocoa | LLVM (clang) 和 GCC |
| Linux Embedded | DirectFB, EGLFS, KMS, 和 Wayland | GCC |
| Windows Embedded | Windows | MSVC |
| Android | Android | GCC |
| iOS | iOS | LLVM (clang) 和 GCC |
| Unix | XCB (X11) | GCC |
| RTOS (QNX, VxWorks, 和 INTEGRITY) | qnx | qcc, dcc 和 GCC |
| BlackBerry 10 | qnx | qcc |
| Windows 8 (WinRT) | winrt | MSVC |
| Maemo, MeeGo, 和 Sailfish OS | XCB (X11) | GCC |
| Google Native Client (unsupported) | pepper | GCC |
时光之旅
Qt 的发展始于 1991 年,由两位挪威人——Eirik Chambe-Eng 和 Haavard Nord——发起,他们希望创建一个跨平台的 GUI 编程工具包。Trolltech(创建 Qt 工具包的公司)的第一个商业客户是欧洲航天局。Qt 的商业使用帮助 Trolltech 持续发展。当时,Qt 可用于两个平台——Unix/X11 和 Windows;然而,使用 Qt 为 Windows 开发需要购买专有许可证,这在移植现有的 Unix/Qt 应用程序时是一个重大的缺点。
2001 年 Qt 3.0 版本的发布是一个重要的进步,它看到了对 Mac 的初始支持,以及使用自由 GPL 许可证在 Unix 和 Mac 下使用 Qt 的选项。尽管如此,Qt for Windows 仍然仅限于付费许可证。然而,在当时,Qt 已经支持市场上的所有重要参与者——Windows、Mac 和 Unix 桌面,以及 Trolltech 的主流产品和 Qt for 嵌入式 Linux。
2005 年,Qt 4.0 发布,这在多个方面都是一个真正的突破。首先,Qt API 完全重新设计,使其更加简洁和一致。不幸的是,与此同时,它使得现有的基于 Qt 的代码与 4.0 不兼容,许多应用程序需要从头开始重写,或者需要大量努力才能适应新的 API。这是一个艰难的决定,但从时间角度来看,我们可以看到这是值得的。API 变化带来的困难被 Qt for Windows 最终在 GPL 下发布的事实很好地抵消了。引入了许多优化,使 Qt 显著更快。最后,Qt,直到现在都是一个单一库,被分割成多个模块:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_01_02.jpg
这使得程序员只需链接到他们在应用程序中使用的功能,从而减少了软件的内存占用和依赖。
2008 年,Trolltech 被诺基亚收购,当时诺基亚正在寻找一个软件框架来帮助其扩展并未来取代其 Symbian 平台。Qt 社区因此出现了分歧,一些人看到 Qt 的发展转向诺基亚后感到兴奋,而另一些人则感到担忧。无论如何,新的资金被注入到 Qt 中,加速了其发展,并使其对移动平台——Symbian、Maemo 和 MeeGo 开放。
对于诺基亚来说,Qt 并没有被看作是自己的产品,而是一种工具。因此,他们决定通过添加一个非常自由的 LGPL 许可证来向更多开发者介绍 Qt,该许可证允许在开源和闭源开发中使用该框架。
将 Qt 带到新的平台和较弱的硬件上需要一种新的方法来创建用户界面,并使它们更加轻量级、流畅和美观。在 Qt 上工作的诺基亚工程师提出了一种新的声明性语言来开发此类界面——Qt 模型语言(QML)以及为其提供的 Qt 运行时 Qt Quick。
后者成为了 Qt 进一步发展的主要焦点,实际上阻碍了所有非移动相关的工作,将所有努力集中在使 Qt Quick 更快、更简单和更普及上。Qt 4 已经在市场上存在了 7 年,显然需要发布 Qt 的另一个主要版本。决定通过允许任何人向项目贡献来吸引更多工程师加入 Qt。
诺基亚未能完成 Qt 5.0 的开发工作。由于 2011 年诺基亚对不同技术的意外转向,Qt 部门在 2012 年中旬被出售给了芬兰公司 Digia,该公司完成了这项工作,并在同年 12 月发布了 Qt 5.0。
Qt 5 的新特性
Qt 5 的 API 与 Qt 4 的 API 差别不大。因此,Qt 5 几乎完全与前辈源代码兼容,这意味着我们只需要最小的努力就可以将现有应用程序移植到 Qt 5。本节简要介绍了 Qt 4 和 5 版本之间主要的更改。如果您已经熟悉 Qt 4,这可以作为一个小型的汇编,如果您想最大限度地使用 Qt 5 的功能,则需要关注的内容。
重新构建的代码库
与 Qt 之前的主要版本相比,最大的变化是整个框架被重构为另一组模块。由于它随着时间的推移而扩展,并且对于它所支持的不断增长的平台集合来说,维护和更新变得更加困难,因此决定将框架拆分为包含在两个模块组中的更小的模块——Qt Essentials 和 Qt Add-ons。与拆分相关的一个重大决定是,每个模块现在都可以有自己的独立发布计划。
Qt Essentials
必需模块组包含每个支持平台都必须实现的模块。这意味着如果您仅使用此组中的模块来实现您的系统,您可以确信它可以轻松地移植到 Qt 支持的任何其他平台。以下是一些模块的说明:
-
QtCore 模块包含所有其他模块所依赖的最基本的 Qt 功能。它提供对事件处理、元对象、数据 I/O、文本处理和线程的支持。它还带来了许多框架,例如动画框架、状态机框架和插件框架。
-
Qt GUI 模块提供了构建用户界面的基本跨平台支持。与 Qt 4 中相同的模块相比,它要小得多,因为对小部件和打印的支持已移至单独的模块。Qt GUI 包含用于操作可以使用光栅引擎(通过指定
QSurface::RasterSurface作为表面类型)或 OpenGL (QSurface::OpenGLSurface) 渲染的窗口的类。Qt 支持桌面 OpenGL 以及 OpenGL ES 1.1 和 2.0。 -
Qt 网络模块提供了使用 TCP 和 UDP 以及通过控制设备连接状态来支持 IPv4 和 IPv6 网络的功能。与 Qt 4 相比,此模块增强了 IPv6 支持,增加了对不透明 SSL 密钥(如硬件密钥设备)和 UDP 多播的支持,并将 MIME 多部分消息组装成通过 HTTP 发送的格式。它还扩展了对 DNS 查询的支持。
-
Qt 多媒体允许程序员访问音频和视频硬件(包括摄像头和 FM 收音机)以记录和播放多媒体内容。
-
Qt SQL 提供了一个框架,用于以抽象方式操作 SQL 数据库。
-
Qt WebKit 是 WebKit 2 网络浏览器引擎到 Qt 的移植。它提供了用于显示和操作网页内容的类,并与您的桌面应用程序集成。
-
Qt Widgets 通过使用小部件(如按钮、编辑框、标签、数据视图、对话框、菜单和工具栏)以及使用特殊布局引擎排列的能力扩展了 GUI 模块,以创建用户界面。它还包含一个名为 Graphics View 的面向对象的 2D 图形画布的实现。当将 Qt 4 应用程序移植到 Qt 5 时,一个好的做法是首先启用对 widgets 模块的支持(通过在项目文件中添加 QT += widgets),然后从这里开始逐步工作。
-
Qt Quick 是 Qt GUI 的扩展,它提供了使用 QML 创建轻量级流畅用户界面的方法。在本章的后续部分以及第九章中,即 Qt Quick 基础,有更详细的描述。
小贴士
此组中还有其他模块,但在此书中我们将不会关注它们。如果您想了解更多关于它们的信息,可以在 Qt 参考手册中查找。
Qt 插件
此组包含任何平台都可选的模块。这意味着如果某些平台上的特定功能不可用,或者没有人愿意花时间为此平台工作此功能,它将不会阻止 Qt 支持此平台。
一些最重要的模块包括 QtConcurrent 用于并行处理、Qt Script 允许我们在 C++ 应用程序中使用 JavaScript、Qt3D 提供高级 OpenGL 构建块以及 Qt XML Patterns 帮助我们访问 XML 数据。还有许多其他模块也可用,但在此处我们将不涉及它们。
Qt Quick 2.0
在功能方面,Qt 的最大升级是 Qt Quick 2.0。在 Qt 4 中,该框架是在 Graphics View 之上实现的。即使启用了 OpenGL ES 加速,当与低端硬件一起使用时,这也证明速度过慢。这是因为 Graphics View 渲染其内容的方式——它按顺序迭代所有项目,计算并设置其变换矩阵,绘制项目,重新计算并重置下一个项目的矩阵,绘制它,依此类推。由于一个项目可以包含任何以任意顺序绘制的通用内容,因此它需要频繁更改 GL 管道,导致严重减速。
Qt Quick 的新版本采用了场景图方法。它将整个场景描述为一个属性和已知操作的图。为了绘制场景,会收集关于当前图状态的详细信息,并以更优化的方式渲染场景。例如,它可以先从所有项目中绘制三角形带,然后从所有项目中渲染字体,依此类推。此外,由于每个项目的状态由一个子图表示,因此可以跟踪每个项目的更改,并决定特定项目的视觉表示是否需要更新。
旧的 QDeclarativeItem 类已被 QQuickItem 替换,它与图形视图架构没有关联。没有可以直接绘制项的常规方法,但有一个 QQuickPaintedItem 类可用,它通过将基于 QPainter 的内容渲染到纹理中,然后使用场景图渲染该纹理来帮助移植旧代码。然而,这类项的运行速度比直接使用图形方法慢得多,所以如果性能很重要,应避免使用。
Qt Quick 在 Qt 5 中扮演着重要角色,并且对于创建游戏非常有用。我们将在第九章 Qt Quick Basics 和第十章 Qt Quick 中详细介绍这项技术。
元对象
在 Qt 4 中,将信号和槽添加到类需要该类的元对象(即描述另一个类的类的实例)的存在。这是通过从 QObject 派生,向其中添加 Q_OBJECT 宏,并在类的特殊作用域中声明信号和槽来完成的。在 Qt 5 中,这仍然是可能的,并且在许多情况下是建议的,但我们现在有了一些新的有趣的可能性。
现在将信号连接到类的任何兼容成员函数或任何可调用实体(例如独立函数或函数对象(functor))是可以接受的。副作用是信号和槽(与“旧”语法的运行时检查相对)的编译时兼容性检查。
C++11 支持
2011 年 8 月,ISO 批准了新的 C++ 标准,通常称为 C++11。它提供了一系列优化,并使程序员更容易创建有效的代码。虽然您可以将 C++11 与 Qt 4 一起使用,但它并没有提供任何针对它的专用支持。这种情况在 Qt 5 中发生了变化,Qt 5 现在了解 C++11 并支持语言新版本引入的许多构造。在这本书中,我们有时会在代码中使用 C++11 功能。一些编译器默认启用了 C++11 支持,而在其他编译器中,您需要启用它。如果您的编译器不支持 C++11,请不要担心。每次我们使用这些功能时,我都会让您知道。
选择正确的许可证
Qt 可在两种不同的许可方案下使用——您可以选择商业许可或开源许可。我们将在这里讨论两者,以便您更容易选择。如果您对特定许可方案是否适用于您的用例有任何疑问,最好咨询专业律师。
开源许可证
开源许可证的优势是,我们不必为使用 Qt 向任何人付费;然而,缺点是它对如何使用 Qt 施加了一些限制。
在选择开源版本时,我们必须在 GPL 3.0 和 LGPL 2.1 或 3 之间做出选择。由于 LGPL 更为自由,在本章中我们将重点关注它。选择 LGPL 允许您使用 Qt 实现开源或闭源的系统——如果您不想,您不必向任何人透露您应用程序的源代码。
然而,您需要了解一些限制:
-
您对 Qt 本身所做的任何修改都需要公开,例如,通过将源代码补丁与您的应用程序二进制文件一起分发。
-
LGPL 要求您的应用程序用户必须能够用具有相同功能的其他库(例如,Qt 的不同版本)替换您提供的 Qt 库。这通常意味着您必须将应用程序动态链接到 Qt,以便用户可以简单地用自己的 Qt 库替换它们。您应该意识到,这种替换可能会降低您系统的安全性,因此,如果您需要非常安全,开源可能不是您的选择。
-
LGPL 与许多许可证不兼容,尤其是专有许可证,因此您可能无法使用 Qt 与某些商业组件一起使用。
Qt 的开源版本可以直接从www.qt.io下载。
商业许可证
如果您决定为 Qt 购买商业许可证,所有这些限制都将被解除。这允许您将整个源代码保密,包括您可能想要合并到 Qt 中的任何更改。您可以自由地将应用程序静态链接到 Qt,这意味着更少的依赖项、更小的部署包大小和更快的启动速度。它还提高了您应用程序的安全性,因为最终用户无法通过用自己的代码替换动态加载的库来向应用程序中注入自己的代码。
注意
要购买商业许可证,请访问qt.io/buy。
摘要
在本章中,您了解了 Qt 的架构。我们看到了它是如何随着时间的推移而演变的,并对现在的样子进行了简要概述。Qt 是一个复杂的框架,我们无法涵盖所有内容,因为其功能的一些部分对于游戏编程来说比其他部分更重要,您可以在需要时自行学习。现在您已经了解了 Qt 是什么,我们可以继续下一章,在那里您将学习如何在您的开发机器上安装 Qt。
第二章。安装
在本章中,您将学习如何在您的开发机器上安装 Qt,包括专为与 Qt 一起使用而设计的 IDE——Qt Creator。您将了解如何根据您的需求配置 IDE,并学习使用该环境的基本技能。此外,本章还将描述从源代码构建 Qt 的过程,这对于自定义您的 Qt 安装以及为嵌入式平台获取一个可工作的 Qt 安装非常有用。在本章结束时,您将能够使用 Qt 发布中包含的工具为桌面和嵌入式平台准备您的开发环境。
安装 Qt SDK
在您可以在您的机器上开始使用 Qt 之前,它需要被下载和安装。Qt 可以使用两种类型的专用安装程序进行安装——在线安装程序,它会在运行时下载所有需要的组件,以及一个更大的离线安装程序,它已经包含了所有需要的组件。使用在线安装程序对于常规桌面安装来说更容易,因此我们将优先选择这种方法。
使用在线安装程序安装 Qt 的行动时间
首先,访问 qt.io 并点击 下载。这将带您到一个包含不同许可方案选项的页面。要使用开源版本,请选择受 GPL 和 LGPL 许可的开源版。然后,您可以点击 立即下载 按钮以获取您当前运行的平台的在线安装程序,或者您可以点击任何标题部分以查看更全面的选项列表。在线安装程序的链接位于列表开头,如下面的截图所示。点击并下载适合您主机机的版本:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_02_01.jpg
下载完成后,运行安装程序,如下所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_02_02.jpg
点击 下一步,在下载器检查远程存储库的一段时间后,您将被要求输入安装路径。请确保选择您有写入权限的路径(最好将 Qt 放入您的个人目录中,除非您是以系统管理员用户身份运行安装程序)。再次点击 下一步 将会向您展示您希望安装的组件的选择,如下面的截图所示。您将根据您的平台获得不同的选择。
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_02_03.jpg
选择您需要的平台,例如,要在 Linux 上构建原生和 Android 应用程序,请选择基于 gcc 的安装和所需 Android 平台的安装。在 Windows 上,您需要做出额外的选择。当使用 Microsoft 编译器时,您可以选择是否使用带 OpenGL 后缀的本地 OpenGL 驱动程序,或者使用 DirectX 调用来模拟 OpenGL ES。如果您没有 Microsoft 编译器或者您根本不想使用它,请选择 MinGW 编译器的 Qt 版本。如果您没有 MinGW 安装,请不要担心——安装程序也会为您安装它。
在选择所需组件并再次点击下一步后,您需要通过标记适当的选项来接受 Qt 的许可条款,如图所示。点击安装后,安装程序将开始下载和安装所需的软件包。一旦完成,您的 Qt 安装就绪。在过程结束时,您将有一个选项来启动 Qt Creator。
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_02_05.jpg
发生了什么?
我们所经历的过程会在您的磁盘上出现整个 Qt 基础设施。您可以检查指向安装程序的目录,看看它是否在这个目录中创建了许多子目录——每个子目录对应于安装程序选择的 Qt 的一个版本,还有一个名为 Tools 的子目录,其中包含 Qt Creator。您可以看到,如果您决定安装另一个版本的 Qt,它不会与您的现有安装冲突。此外,对于每个版本,您可以有多个平台子目录,其中包含特定平台的实际 Qt 安装。
设置 Qt Creator
Qt Creator 启动后,您应该会看到以下屏幕:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_02_07.jpg
程序应该已经为您正确配置,以便您可以使用刚刚安装的 Qt 版本和编译器,但让我们无论如何验证一下。从工具菜单中选择选项。一旦弹出对话框,从侧边列表中选择构建和运行。这是我们可以配置 Qt Creator 构建项目方式的地方。一个完整的构建配置称为套件。它由一个 Qt 安装和一个用于执行构建的编译器组成。您可以在选项对话框的构建和运行部分看到这三个实体的标签页。
让我们从编译器选项卡开始。如果您的编译器没有正确检测到并且不在列表中,请点击添加按钮,从列表中选择您的编译器类型,并填写编译器的名称和路径。如果设置正确输入,Creator 将自动填写所有其他详细信息。然后,您可以点击应用来保存更改。
接下来,您可以切换到Qt 版本标签页。如果您的 Qt 安装没有自动检测到,您可以点击添加。这将打开一个文件对话框,您需要找到您的 Qt 安装目录,其中存储了所有二进制可执行文件(通常在bin目录中),并选择一个名为qmake的二进制文件。如果选择错误文件,Qt Creator 会警告您。否则,您的 Qt 安装和版本应该能够正确检测。如果您愿意,可以在相应的框中调整版本名称。
最后一个要查看的标签页是套件标签页。它允许您将编译器与用于编译的 Qt 版本配对。此外,对于嵌入式和移动平台,您可以指定要部署的设备以及包含构建指定嵌入式平台所需所有文件的sysroot目录。
动手时间 - 加载示例项目
Qt 自带了很多示例。让我们尝试构建一个示例来检查安装和配置是否正确完成。在 Qt Creator 中,点击窗口左上角的欢迎按钮进入 IDE 的初始屏幕。在出现的页面右侧(参考前面的截图),有几个标签页,其中一个是名为示例的标签页。点击该标签页将打开一个示例列表,并包含一个搜索框。确保在搜索框旁边的列表中选择了您刚刚安装的 Qt 版本。在框中输入aff以过滤示例列表,然后点击仿射变换以打开项目。如果您被问及是否要将项目复制到新文件夹,请同意。然后 Qt Creator 将向您展示以下窗口:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_02_08.jpg
发生了什么?
Qt Creator 加载了项目并设置了一个视图,该视图将帮助我们学习示例项目。视图分为四个部分。让我们从左侧开始列举。首先是 Qt Creator 的工作模式选择器,其中包含一个操作栏,允许我们在 IDE 的不同模式之间切换。然后是项目视图,其中包含项目文件列表。接下来是源代码编辑器,显示项目的主要源代码部分。最后,在右侧远处,您可以看到在线帮助窗口,显示打开示例的文档。
动手时间 - 运行仿射变换项目
让我们尝试构建并运行项目以检查构建环境是否配置正确。首先,点击绿色三角形图标直接上方的图标以打开构建配置弹出窗口,如图所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_02_09.jpg
您获得的确切内容可能因您的安装而异,但通常,在左侧您将看到为项目配置的套件列表,在右侧您将看到为该套件定义的构建配置列表。为您的桌面安装选择一个套件以及为该套件定义的任何配置。您可以通过切换 Qt Creator 到项目管理模式来调整配置,方法是点击工作模式选择栏中的项目按钮。在那里,您可以向项目添加和删除套件,并管理每个套件的构建配置,如图中所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_02_10.jpg
您可以调整、构建和清理步骤,并切换阴影构建(即在源代码目录树外构建您的项目)。
要构建项目,请点击操作栏底部的锤子图标。您还可以点击绿色三角形图标来构建和运行项目。如果一切正常,经过一段时间后,应用程序应该会启动,如图所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_02_11.jpg
发生了什么?
项目是如何构建的?如果您打开项目模式并查看分配给项目的构建设置(如图中所示的前一个截图),您会注意到定义了多个构建步骤。Qt 项目的第一步通常是qmake步骤,它运行一个特殊的工具,为项目生成一个Makefile,该Makefile在第二步被传递给经典的make工具。您可以通过点击相应的详细信息按钮来展开每个步骤,以查看每个步骤的配置选项。
虽然make被认为是构建软件项目的标准工具,但qmake是 Qt 提供的自定义工具。如果您回到编辑模式并查看项目内容中列出的文件,您会注意到一个扩展名为pro的文件。这是主要的项目文件,其中包含项目中的源文件和头文件列表,为项目定义的 Qt 模块,以及可选的外部库,项目需要链接这些库。如果您想了解此类项目文件的管理细节,可以切换到帮助模式,从窗口顶部的下拉列表中选择索引,并输入qmake Manual以找到该工具的说明书。否则,只需让 Qt Creator 为您管理项目即可。对于自包含的 Qt 项目,您不需要成为 qmake 专家。
从源代码构建 Qt
在大多数情况下,对于桌面和移动平台,您从网页上下载的 Qt 的二进制发布版足以满足您的所有需求。然而,对于嵌入式系统,尤其是基于 ARM 的系统,可能没有可用的二进制发布版,或者对于这样一个轻量级系统来说,二进制发布版资源太重。在这种情况下,需要执行自定义的 Qt 构建。有两种方式进行这样的构建。一种是将源代码作为压缩归档下载,就像二进制包一样。另一种是从 Git 仓库直接下载代码。由于第一种方法相当直观,我们将重点介绍第二种方法。
行动时间 - 使用 Git 设置 Qt 源代码
首先,如果您还没有安装 Git,您需要在系统上安装它。如何做取决于您的操作系统。对于 Windows,只需从git-for-windows.github.io下载安装程序。对于 Mac,安装程序可在code.google.com/p/git-osx-installer找到。对于 Linux,最简单的方法是使用系统包管理器。例如,在基于 Debian 的发行版上,只需在终端中输入sudo apt-get install git命令,然后等待安装完成。
之后,您需要克隆 Qt 的 Git 仓库。由于 Git 是一个命令行工具,我们将从现在开始使用命令行。要将 Qt 的仓库克隆到您想要保存源代码的目录中,请输入以下命令:
git clone git://code.qt.io/qt/qt5.git
如果一切顺利,Git 将从网络下载大量源代码并创建一个名为qt5的目录,其中包含所有下载的文件。然后,将当前工作目录更改为包含新下载代码的目录:
cd qt5
然后,您需要运行一个 Perl 脚本,该脚本将为您设置所有额外的仓库。如果您还没有安装 Perl,您应该现在安装它(您可以从www.activestate.com/activeperl/downloads获取 Windows 版本的 Perl)。然后,输入以下命令:
perl init-repository
脚本将开始下载 Qt 所需的所有模块,并在依赖于您的网络链路速度的一段时间后成功完成。
发生了什么?
在此qt5目录中,您将看到为不同的 Qt 模块(其中一些在第一章,Qt 简介中提到)创建的多个子目录,每个子目录都包含相应 Qt 模块和工具的源代码的本地 Git 仓库。如果需要,每个模块都可以单独更新。
行动时间 - 配置和构建 Qt
在源代码就绪的情况下,我们可以开始构建框架。为此,除了支持的编译器外,您还需要安装 Perl 和 Python(版本 2.7 或更高)。对于 Windows,您还需要 Ruby。如果您缺少任何工具,现在是安装它们的好时机。之后,打开命令行,将当前工作目录更改为包含 Qt 源代码的目录。然后,输入以下命令:
configure -opensource -nomake tests
这将启动一个工具,用于检测是否满足所有要求,并将报告任何不一致之处。它还将报告构建的确切配置。您可以通过向configure传递额外的选项来自定义构建(例如,如果您需要启用或禁用某些功能或为嵌入式平台交叉编译 Qt),以运行configure -help来查看可用选项。
如果configure报告了问题,您将需要修复这些问题并重新启动工具。否则,通过调用make(或者在 MinGW 中使用mingw32-make,或者在 MSVC 中使用nmake)来启动构建过程。
小贴士
除了nmake,您还可以使用 Qt 捆绑的工具jom。它将在多核机器上减少编译时间,这是默认的nmake工具所做不到的。对于make和mingw32-make,您可以通过传递-j N参数来传递,其中N代表您机器上的核心数。
刚才发生了什么?
经过一段时间(通常不到一小时),如果一切顺利,构建应该完成,您将准备好将编译好的框架添加到 Qt Creator 中可用的工具包列表中。
小贴士
在 Unix 系统上,构建完成后,您可以使用超级用户权限(例如,通过sudo获得)调用make install命令,将框架复制到更合适的位置。
摘要
到目前为止,您应该能够在您的开发机器上安装 Qt。现在,您可以使用 Qt Creator 浏览现有示例并从中学习,或者阅读 Qt 参考手册以获取更多知识。您也可以开始一个新的 C++项目,为其编写代码,构建并执行它。一旦您成为经验丰富的 Qt 开发者,您也将能够创建自己的自定义 Qt 构建。在下一章中,我们最终将开始使用框架,您将学习如何通过实现我们非常简单的第一个游戏来创建图形用户界面。
第三章. Qt 图形界面编程
本章将帮助你学习如何使用 Qt Creator IDE 开发具有图形用户界面的应用程序。我们将熟悉 Qt 的核心功能、属性系统以及我们将用于创建复杂系统(如游戏)的信号和槽机制。我们还将介绍 Qt 的各种操作和资源系统。到本章结束时,你将能够编写自己的程序,通过窗口和控件与用户进行通信。
窗口和对话框
你需要学习的最基本技能是创建窗口,在屏幕上显示它们,并管理它们的内容。
创建 Qt 项目
使用 Qt Creator 开发应用程序的第一步是使用编辑器提供的模板之一创建一个项目。
行动时间 – 创建一个 Qt 桌面项目
当你第一次启动 Qt Creator 时,你将看到一个欢迎屏幕。从 文件 菜单中选择 新建文件或项目。有几种项目类型可供选择。按照以下步骤创建 Qt 桌面项目:
-
对于基于小部件的应用程序,选择 应用程序 组和 Qt Gui 应用程序 模板:https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_01.jpg
-
下一步是选择新项目的名称和位置:https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_02.jpg
-
我们将创建一个简单的井字棋游戏,因此我们将我们的项目命名为
tictactoe并为其提供一个合适的位置。小贴士
如果你有一个存放所有项目的公共目录,你可以勾选 用作默认项目位置 复选框,以便 Creator 记住位置并在下次启动新项目时建议该位置。
-
当你点击 下一步 时,你将看到一个窗口,允许你选择一个或多个为项目定义的编译工具包。继续下一步,不做任何更改。你将看到创建项目第一个小部件的选项。按照以下截图所示填写数据:https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_03.jpg
-
然后,点击 下一步 和 完成。
刚才发生了什么?
Creator 在你之前选择的项目位置目录中创建了一个新的子目录,并将一些文件放在那里。其中两个文件(tictactoewidget.h 和 tictactoewidget.cpp)实现了 TicTacToeWidget 类,作为 QWidget 的子类。第三个文件名为 main.cpp,包含应用程序入口点的代码:
#include "tictactoewidget.h"
#include <QApplication>
int main(int argc, char *argv[]) {
QApplication a(argc, argv);
TicTacToeWidget w;
w.show();
return a.exec();
}
此文件创建了一个 QApplication 类的实例,并将其标准参数传递给 main() 函数。然后,它实例化我们的 TicTacToeWidget 类,调用其 show 方法,并最终返回应用程序对象的 exec 方法返回的值。
QApplication 是一个单例类,它管理整个应用程序。特别是,它负责处理来自应用程序内部或外部来源的事件。为了处理事件,需要一个事件循环正在运行。循环等待传入的事件并将它们分派到适当的例程。Qt 中的大多数事情都是通过事件完成的——输入处理、重绘、通过网络接收数据、触发计时器等等。这就是我们说 Qt 是一个面向事件框架的原因。如果没有活跃的事件循环,任何东西都无法正常工作。QApplication 中的 exec 调用(或者更具体地说,在其基类 QCoreApplication 中)负责进入应用程序的主事件循环。该函数在应用程序请求事件循环终止之前不会返回。当这最终发生时,main 函数返回,你的应用程序结束。
生成的最终文件名为 tictactoe.pro,是项目配置文件。它包含了使用 Qt 提供的工具构建项目所需的所有信息。让我们分析这个文件:
QT += core gui
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
TARGET = tictactoe
TEMPLATE = app
SOURCES += main.cpp tictactoewidget.cpp
HEADERS += tictactoewidget.h
前两行启用了 Qt 的 core、gui 和 widgets 模块。接下来的两行指定了你的项目文件描述了一个应用程序(而不是,例如,一个库),并声明了可执行的目标名称为 tictactoe。最后两行添加了 Creator 为我们生成的文件,用于构建过程。
我们现在有一个完整的最小化 Qt GUI 项目。要构建和运行它,只需从 构建 下拉菜单中选择 运行 选项,或者在 Qt Creator 窗口的左侧点击绿色的三角形图标。过了一会儿,你应该会看到一个窗口弹出。由于我们没有向窗口添加任何内容,所以它是空的。
向窗口添加子控件
在我们成功在屏幕上得到一个空白窗口之后,下一步就是向其中添加一些内容。为此,你需要创建控件并告诉 Qt 将它们定位在窗口中。基本的方法是为控件提供一个父控件。
在 Qt 中,我们将对象(如小部件)组合成父子关系。这种方案在 QWidget 的超类 QObject 中定义,QObject 是 Qt 中最重要的类,我们将在本章后面更详细地介绍它。现在重要的是,每个对象都可以有一个父对象和任意数量的子对象。在部件的情况下,有一个规则,即子对象占据其父对象的一个子区域。如果没有父对象,则它成为一个顶级窗口,通常可以拖动、调整大小和关闭。我们可以通过两种方式为对象设置父对象。一种方式是调用 QObject 中定义的 setParent 方法,该方法接受一个 QObject 指针。由于前面提到的规则,QWidget 希望有其他小部件作为父对象,因此该方法在 QWidget 中被重载以接受一个 QWidget 指针。另一种方式是将父对象指针传递给子对象的 QWidget 构造函数。如果您查看 Creator 生成的部件代码,您会注意到构造函数也接受一个指向小部件的指针作为其最后一个(可选)参数:
TicTacToeWidget::TicTacToeWidget(QWidget *parent)
: QWidget(parent)
{
}
然后,它将那个指针传递给其基类的构造函数。因此,您始终记得为您的部件创建一个接受指向 QWidget 实例的指针并将其传递到继承树上的构造函数是非常重要的。所有标准 Qt 小部件也都以这种方式行为。
管理小部件内容
使小部件显示为其父对象的一部分并不足以制作出良好的用户界面。您还需要设置其位置和大小,并对其内容和父小部件内容的变化做出反应。在 Qt 中,我们使用称为布局的机制来完成这项工作。
布局允许我们安排小部件的内容,确保其空间得到有效利用。当我们为小部件设置布局时,我们可以开始添加小部件甚至其他布局,机制将根据我们指定的规则调整大小和重新定位它们。当用户界面中发生影响小部件显示方式的事件时(例如,按钮文本被替换为更长的文本,这使得按钮需要更多空间来显示其内容;如果没有,则某个小部件被隐藏),布局会被触发,重新计算所有位置和大小,并根据需要更新小部件。
Qt 提供了一组预定义的布局,这些布局是从 QLayout 类派生出来的,但您也可以创建自己的。我们目前可用的布局有 QHBoxLayout 和 QVBoxLayout,它们分别水平垂直定位项目;QGridLayout,它以网格排列项目,以便项目可以跨越列或行;以及 QFormLayout,它创建两列项目,其中一列包含项目描述,另一列包含项目内容。还有一个 QStackedLayout,它很少直接使用,并且使分配给它的一个项目拥有所有可用空间。您可以在以下图中看到最常见的布局的实际应用:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_04.jpg
要使用布局,我们需要创建其实例,并将我们想要它管理的小部件的指针传递给它。然后,我们可以开始向布局中添加小部件:
QHBoxLayout *layout = new QHBoxLayout(parentWidget);
QPushButton *button1 = new QPushButton;
QPushButton *button2 = new QPushButton;
layout->addWidget(button1);
layout->addWidget(button2);
我们甚至可以通过设置布局上的间距和在布局上设置自定义边距来将小部件彼此进一步移动:
layout->setSpacing(10);
layout->setMargins(10, 5, 10, 5); // left, top, right, bottom
在构建和运行此代码后,您会看到两个均匀分布在父空间中的按钮。请注意,尽管我们没有明确传递父小部件指针,但将小部件添加到布局中会使它重新将新添加的小部件作为布局管理的小部件的子部件。水平调整父小部件的大小也会导致按钮再次调整大小,覆盖所有可用空间。然而,如果您垂直调整 parentWidget 的大小,按钮将改变其位置但不会改变其高度。
这是因为每个小部件都有一个名为大小策略的属性,它决定了布局如何调整小部件的大小。您可以为水平和垂直方向设置不同的尺寸策略。按钮的垂直尺寸策略为 Fixed,这意味着无论有多少可用空间,小部件的高度都不会从默认高度改变。以下是可以用的尺寸策略:
-
忽略: 在此,小部件的默认大小被忽略,小部件可以自由地增长和缩小 -
固定: 在此,默认大小是小部件唯一允许的大小 -
首选: 在此,默认大小是期望的大小,但较小和较大的尺寸也是可接受的 -
最小: 在此,小部件的默认大小是最小可接受的大小,但小部件可以被放大而不会损害其功能 -
最大: 在此,默认大小是小部件的最大大小,小部件可以被缩小(甚至缩小到无),而不会损害其功能 -
扩展: 在此,默认大小是期望的大小;较小的尺寸(甚至为零)是可接受的,但小部件能够在分配更多空间时增加其有用性 -
最小扩展: 这是由最小和扩展组合而成的——小部件在空间方面是贪婪的,并且不能缩小到其默认大小以下
我们如何确定默认大小?答案是通过对sizeHint虚拟方法返回的大小。对于布局,大小是根据其子小部件和嵌套布局的大小和大小策略计算的。对于基本小部件,sizeHint返回的值取决于小部件的内容。在按钮的情况下,如果它包含一行文本和一个图标,sizeHint将返回完全包含文本、图标、它们之间的一些空间、按钮框架以及框架和内容本身之间的填充所需的大小。
动手时间 - 实现井字棋游戏棋盘
现在,我们将创建一个使用按钮实现井字棋游戏棋盘的小部件。
在 Creator 中打开tictactoewidget.h文件,并通过添加以下高亮代码来更新它:
#ifndef TICTACTOEWIDGET_H
#define TICTACTOEWIDGET_H
#include <QWidget>
class QPushButton;
class TicTacToeWidget : public QWidget
{
Q_OBJECT
public:
TicTacToeWidget(QWidget *parent = 0);
~TicTacToeWidget();
private:
QList<QPushButton*> board;
};
#endif // TICTACTOEWIDGET_H
我们的增加创建了一个可以持有QPushButton类实例指针的列表,这是 Qt 中最常用的按钮类。它将代表我们的游戏棋盘。我们必须教会编译器理解我们使用的类;因此,我们添加了QPushButton类的前置声明。
下一步是创建一个方法,帮助我们创建所有按钮并使用布局来管理它们的几何形状。再次进入头文件,并在类的private部分添加一个void setupBoard();声明。为了快速实现新声明的方法,我们可以请求 Qt Creator 为我们创建骨架代码,只需将文本光标定位在方法声明(分号之前)之前,然后在键盘上按Alt + Enter,并从弹出菜单中选择在 tictactoewidget.cpp 中添加定义。
小贴士
反过来也适用。你可以先编写方法主体,然后将光标定位在方法签名上,按Alt + Enter,并从快速修复菜单中选择添加公共声明。Creator 中还有各种其他上下文相关的修复方案。
因为在头文件中我们只进行了QPushButton的前置声明,所以我们现在需要通过包含适当的头文件来提供完整的类定义。在 Qt 中,所有类都在与类本身名称完全相同的头文件中声明。因此,为了包含QPushButton的头文件,我们需要在实现文件中添加一行#include <QPushButton>。我们还将使用QGridLayout类来管理小部件中的空间,因此我们还需要#include <QGridLayout>。
小贴士
从现在开始,这本书将不再提醒你添加include指令到你的源代码中——你必须自己负责这一点。这真的很简单,只需记住,要使用 Qt 类,你需要包含一个以该类命名的文件。
现在,让我们将代码添加到setupBoard方法的主体中。首先,让我们创建一个将包含我们的按钮的布局:
QGridLayout *gridLayout = new QGridLayout;
然后,我们可以开始向布局中添加按钮:
for(int row = 0; row < 3; ++row) {
for(int column = 0; column < 3; ++column) {
QPushButton *button = new QPushButton;
button->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
button->setText(" ");
gridLayout->addWidget(button, row, column);
board.append(button);
}
}
代码在棋盘的行和列上创建了一个循环。在每次迭代中,它创建一个QPushButton类的实例,并将按钮的大小策略设置为Minimum/Minimum,这样当我们调整小部件大小时,按钮也会调整大小。按钮被分配一个空格作为其内容,以便它获得正确的初始大小。然后,我们将按钮添加到row和column中的布局中。最后,我们将按钮的指针存储在之前声明的列表中。这使得我们可以在以后引用任何按钮。它们在列表中的顺序是这样的:首先存储第一行的前三个按钮,然后是第二行的按钮,最后是最后一行的按钮。
最后一件要做的事情是告诉我们的小部件gridLayout将管理其大小:
setLayout(gridLayout);
或者,我们可能将此作为参数传递给布局的构造函数。
现在我们有了准备棋盘的代码,我们需要在某个地方调用它。一个很好的地方是在类构造函数中执行:
TicTacToeWidget::TicTacToeWidget(QWidget *parent)
: QWidget(parent)
{
setupBoard();
}
现在,构建并运行程序。
刚才发生了什么?
你应该会看到一个包含九个按钮的窗口,这些按钮以网格状排列。如果你开始调整窗口大小,按钮也会相应调整大小。这是因为我们设置了一个包含三列和三行的网格布局,它将小部件均匀地分布在管理区域内,如下所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_05.jpg
当我们在这里时,给这个类添加另一个public方法,并将其命名为initNewGame。我们将使用这个方法在开始新游戏时清除棋盘。方法体应该如下所示:
void TicTacToeWidget::initNewGame() {
for(int i=0; i<9; ++i) board.at(i)->setText(" ");
}
小贴士
你可能已经注意到,尽管我们在setupBoard中使用new操作符创建了许多对象,但我们并没有在任何地方(例如,在析构函数中)销毁这些对象。这是因为 Qt 管理内存的方式。Qt 不会进行垃圾回收(如 Java 那样),但它有一个与QObject父子层次结构相关的良好特性。规则是,每当一个QObject实例被销毁时,它也会删除所有子对象。由于布局对象和按钮都是TicTacToeWidget实例的子对象,因此当主小部件被销毁时,它们都会被删除。这也是为什么我们要设置我们创建的对象的父对象的原因——如果我们这样做,我们就不必担心显式释放任何内存。
Qt 元对象
Qt 提供的许多特殊功能都围绕着QObject类和我们现在将更详细地探讨的元对象范式。范式表明,对于每个QObject子类,都有一个与之关联的特殊对象,它包含有关该类的信息。它允许我们在运行时查询以了解有关类的有用信息——类的名称、超类、构造函数、方法、字段、枚举等。当满足三个条件时,元对象在编译时为类生成:
-
该类是
QObject的子类 -
它在其定义的私有部分包含一个特殊的
Q_OBJECT宏 -
类的代码由一个特殊的 元对象编译器(moc)工具预处理
我们可以通过为类编写适当的代码来满足前两个条件,就像 Qt Creator 在我们创建一个从 QObject 派生的类时所做的那样。最后一个条件在您使用 Qt(和 Qt Creator)附带的工具链构建项目时自动满足。然后,只需确保包含类定义的文件被添加到项目文件的 HEADERS 变量中,Qt 就会处理其余部分。实际上发生的是 moc 为我们生成一些代码,这些代码随后在主程序中编译。
本章本节中讨论的所有功能都需要类的元对象。因此,如果您想使类使用这些功能中的任何一个,确保满足我提到的三个条件是至关重要的。
信号和槽
为了响应应用程序中发生的事情而触发功能,Qt 使用信号和槽的机制。这是基于将关于某个对象状态变化的通告(我们称之为 信号)与一个函数或方法(称为 槽)相连接,当这种通告出现时,该函数或方法将被执行。
信号和槽可以与所有继承自 QObject 的类一起使用。一个信号可以连接到一个槽、成员函数或函数对象(包括常规的全局函数)。当一个对象发出信号时,任何连接到该信号的这些实体都将被调用。一个信号也可以连接到另一个信号,在这种情况下,发出第一个信号将使另一个信号也被发出。你可以将任意数量的槽连接到单个信号,也可以将任意数量的信号连接到单个槽。
信号槽连接由以下四个属性定义:
-
改变其状态的对象(发送者)
-
发送者的信号
-
包含要调用的函数的对象(接收者)
-
接收者的槽
要声明一个信号,我们将它的声明,即一个常规成员函数声明,放在一个称为 signals 的特殊类作用域中。然而,我们并不实现这样的函数——这将由 moc 自动完成。要声明一个槽,我们将声明放在公共槽、受保护槽或私有槽的类作用域中。槽是常规方法,可以在代码中直接调用,就像任何其他方法一样。与信号相反,我们需要为槽方法提供主体。
实现了一些信号和槽的示例类如下所示:
class ObjectWithSignalsAndSlots : public QObject {
Q_OBJECT
public:
ObjectWithSignalsAndSlots(QObject *parent = 0) : QObject(parent) {
}
public slots:
void setValue(int v) { … }
void setColor(QColor c) { … }
private slots:
void doSomethingPrivate();
signals:
void valueChanged(int);
void colorChanged(QColor);
};
void ObjectWithSignalsAndSlots::doSomethingPrivate() {
// …
}
可以使用 connect() 和 disconnect() 语句动态地连接和断开信号和槽。
经典的 connect 语句如下所示:
connect(spinBox, SIGNAL(valueChanged(int)), dial, SLOT(setValue(int)));
此语句在名为valueChanged的spinBox对象的SIGNAL和名为dial对象的setValue槽之间建立连接,该槽接受一个int参数。在connect语句中放置变量名或值是禁止的。你只能连接具有匹配签名的信号和槽,这意味着它们接受相同类型的参数(不允许任何类型转换,并且类型名称必须完全匹配),除了槽可以省略任意数量的最后一个参数。因此,以下connect语句是有效的:
connect(spinBox, SIGNAL(valueChanged(int)), lineEdit, SLOT(clear()));
这是因为在调用clear之前可以丢弃valueChanged信号的参数。然而,以下语句是无效的:
connect(button, SIGNAL(clicked()), lineEdit, SLOT(setText(QString)));
没有地方可以获取要传递给setText的值,因此这种连接将失败。
提示
重要的是,你必须将信号和槽签名包装在SIGNAL和SLOT宏中,并且在指定签名时,你只传递参数类型,而不是值或变量名。否则,连接将失败。
自从 Qt 5 以来,有几种不同的连接语法可用,不需要实现槽的类的元对象。尽管如此,QObject的遗留要求仍然存在,并且元对象对于发出信号的类仍然是必需的。
我们可以使用的第一种附加语法是,我们传递信号方法指针和槽方法指针,而不是在SIGNAL和SLOT宏中包装签名:
connect(button, &QPushButton::clicked, lineEdit, &QLineEdit::clear);
在这种情况下,槽可以是任何QObject子类的任何成员函数,其参数类型与信号匹配,或者可以转换为与信号匹配的类型。这意味着,例如,你可以将携带双值信号的信号与接受整型参数的槽连接起来:
class MyClass : public QObject {
Q_OBJECT
public:
MyClass(QObject *parent = 0) : QObject(parent) {
connect(this, &MyClass::somethingHappened, this, &MyClass::setValue);
}
void setValue(int v) { … }
signals:
void somethingHappened(double);
};
提示
一个重要的方面是,你不能自由地混合基于元对象和基于函数指针的方法。如果你决定在特定的连接中使用成员方法指针,你必须对信号和槽都这样做。
我们甚至可以更进一步,将一个信号连接到一个独立的函数:
connect(button, &QPushButton::clicked, &someFunction);
如果你使用 C++11,函数也可以是一个 lambda 表达式,在这种情况下,你可以在connect语句中直接编写槽的正文:
connect(pushButton, SIGNAL(clicked()), []() { std::cout << "clicked!" << std::endl; });
如果你想调用一个具有固定参数值的槽,而这个值不能由信号携带,因为它有更少的参数,这特别有用。一种解决方案是从 lambda 函数(或独立函数)中调用槽:
connect(pushButton, SIGNAL(clicked()), [label]() { label->setText("button was clicked"); });
函数甚至可以被函数对象(functor)所替代。为此,我们创建一个类,为该类重载的调用操作符与我们要连接的信号兼容,如下面的代码片段所示:
class Functor {
public:
Functor(Object *object, const QString &str) : m_object(object), m_str(str) {}
void operator()(int x, int y) const {
m_object->set(x, y, m_str);
}
private:
Object *m_object;
QString m_str;
};
connect(obj1, SIGNAL(coordChanged(int, int)), Functor("Some Text"));
这通常是一种执行带有额外参数的槽的方法,该参数不是由信号携带的,因为这样做比使用 lambda 表达式要干净得多。
在这里,我们还没有涵盖信号和槽的一些方面。当我们处理多线程时,我们将在稍后回到它们。
速问速答 - 建立信号-槽连接
Q1. 对于以下哪个选项,你必须提供自己的实现?
-
信号
-
槽
-
两者
Q2. 以下哪些陈述是有效的?
-
connect(sender, SIGNAL(textEdited(QString)), receiver, SLOT(setText("foo"))) -
connect(sender, SIGNAL(toggled(bool)), receiver, SLOT(clear())); -
connect(sender, SIGNAL(valueChanged(7)), receiver, SLOT(setValue(int))); -
connect(sender, &QPushButton::clicked, receiver, &QLineEdit::clear);
行动时间 - 跳棋板的功能
我们需要实现一个函数,该函数将在点击板上的任何九个按钮时被调用。它必须根据哪个玩家移动来更改被点击的按钮上的文本——要么是X要么是O——然后,它必须检查该移动是否使玩家获胜(如果没有更多移动,则为平局),如果游戏结束,它应该发出适当的信号,通知环境有关事件。
当用户点击按钮时,会发出clicked()信号。将此信号连接到自定义槽允许我们实现所提到的功能,但由于该信号不携带任何参数,我们如何知道哪个按钮触发了槽?我们可以将每个按钮连接到单独的槽,但这是一种丑陋的解决方案。幸运的是,有两种方法可以解决这个问题。当槽被调用时,可以通过QObject中的特殊方法sender()访问导致信号发出的对象的指针。我们可以使用该指针来确定哪个存储在板列表中的九个按钮导致了信号的触发:
void TicTacToeWidget::someSlot() {
QObject *btn = sender();
int idx = board.indexOf(btn);
QPushButton *button = board.at(idx);
// ...
}
虽然sender()是一个有用的调用,但我们应该尽量避免在我们的代码中使用它,因为它破坏了一些面向对象编程的原则。此外,还有一些情况下调用此函数是不安全的。更好的方法是使用一个专门的类,称为QSignalMapper,它允许我们在不直接使用sender()的情况下达到类似的结果。按照以下方式修改TicTacToeWidget中的setupBoard()方法:
QGridLayout *gridLayout = new QGridLayout;
QSignalMapper *mapper = new QSignalMapper(this);
for(int row = 0; row < 3; ++row) {
for(int column = 0; column < 3; ++column) {
QPushButton *button = new QPushButton;
button->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
button->setText(" ");
gridLayout->addWidget(button, row, column);
board.append(button);
mapper->setMapping(button, board.count()-1);
connect(button, SIGNAL(clicked()), mapper, SLOT(map()));
}
}
connect(mapper, SIGNAL(mapped(int)), this, SLOT(handleButtonClick(int)));
setLayout(gridLayout);
在这里,我们首先创建了一个QSignalMapper的实例,并将棋盘小部件的指针传递给它作为其父对象,这样当小部件被删除时,映射器也会被删除。然后,当我们创建按钮时,我们“教导”映射器每个按钮都有一个与之关联的数字——第一个按钮将具有数字0,第二个按钮将绑定到数字1,依此类推。通过将按钮的clicked()信号连接到映射器的map()槽,我们告诉映射器在接收到该信号时执行其魔法。映射器将执行的操作是找到信号发送者的映射,并发出另一个信号——mapped(),其参数为映射的数字。这允许我们连接到该信号,并使用一个槽(handleButtonClick)来处理按钮在棋盘列表中的索引。
现在是时候实现槽本身了(记得在头文件中声明它!)然而,在我们这样做之前,让我们向类中添加一个有用的枚举和一些辅助方法:
enum Player {
Invalid, Player1, Player2, Draw
};
这个枚举让我们可以指定游戏中玩家的信息。我们可以立即使用它来标记现在是哪个玩家的回合。为此,向类中添加一个私有字段:
Player m_currentPlayer;
然后,添加两个公共方法来操作这个字段的值:
Player currentPlayer() const { return m_currentPlayer; }
void setCurrentPlayer(Player p) {
if(m_currentPlayer == p) return;
m_currentPlayer = p;
emit currentPlayerChanged(p);
}
最后一个方法发出一个信号,因此我们必须将信号声明添加到类定义中,以及我们将要使用的另一个信号:
signals:
void currentPlayerChanged(Player);
void gameOver(TicTacToeWidget::Player);
小贴士
注意,我们只在当前玩家真正改变时发出currentPlayerChanged信号。你总是必须注意,当你将一个字段的值设置为在函数调用之前它所拥有的相同值时,不要发出“已更改”信号。你的类的用户期望如果调用了一个名为“已更改”的信号,那么它是在值真正更改时发出的。否则,如果你有两个对象,它们将它们的值设置器连接到另一个对象的“已更改”信号,这可能导致信号发射中的无限循环。
现在让我们声明handleButtonClick槽:
public slots:
void handleButtonClick(int);
然后在.cpp文件中实现它:
void TicTacToeWidget::handleButtonClick(int index) {
if(index < 0 || index >= board.size()) return; // out of bounds check
QPushButton *button = board.at(index);
if(button->text() != " ") return; // invalid move
button->setText(currentPlayer() == Player1 ? "X" : "O");
Player winner = checkWinCondition(index / 3, index % 3);
if(winner == Invalid) {
setCurrentPlayer(currentPlayer() == Player1 ? Player2 : Player1);
return;
} else {
emit gameOver(winner);
}
}
在这里,我们首先根据索引检索按钮的指针。然后,我们检查按钮是否包含任何文本——如果是这样,这意味着它不再参与游戏,因此我们从方法中返回,以便玩家可以在棋盘上选择另一个字段。接下来,我们在按钮上设置当前玩家的标记。然后,我们检查玩家是否赢得了游戏,传递当前移动的行(index / 3)和列(index % 3)索引。如果游戏没有结束,我们切换当前玩家并返回。否则,我们发出gameOver()信号,告诉我们的环境谁赢得了游戏。checkWinCondition()方法在游戏结束时返回Player1、Player2或Draw,否则返回Invalid。我们不会在这里展示这个方法的实现,因为它相当复杂。尝试自己实现它,如果遇到问题,你可以在本书附带的代码包中查看解决方案。
属性
除了信号和槽,Qt 元对象还让程序员能够使用所谓的属性,这些属性本质上是可以分配特定类型值的命名属性。它们对于表达对象的重要特性非常有用——比如按钮的文本、小部件的大小、游戏中的玩家名字等等。
声明属性
要创建一个属性,我们首先需要在继承自QObject的类的私有部分使用特殊的Q_PROPERTY宏来声明它,这样 Qt 就能知道如何使用这个属性。一个最小的声明包含属性的类型、它的名字以及用于检索属性值的方方法的名称信息。例如,以下代码声明了一个类型为double的属性,名为height,并使用名为height的方法来读取属性值:
Q_PROPERTY(double height READ height)
获取器方法必须按照常规进行声明和实现。它的原型必须遵守以下规则:它必须是一个返回属性类型值或常量引用的公共方法,它不能接受任何输入参数,并且方法本身必须是常量。通常,属性会操作类的私有成员变量:
class Tower : public QObject {
Q_OBJECT // enable meta-object generation
Q_PROPERTY(double height READ height) // declare the property
public:
Tower(QObject *parent = 0) : QObject(parent) { m_height = 6.28; }
double height() const { return m_height; } // return property value
private:
double m_height; // internal member variable holding the property value
};
这样的属性实际上是没有用的,因为没有方法可以改变它的值。幸运的是,我们可以扩展声明以包括如何将值写入属性的信息:
Q_PROPERTY(double height READ height WRITE setHeight)
同样,我们必须声明和实现setHeight,使其作为属性的设置器方法——它需要是一个接受属性类型值或常量引用的公共方法,并返回 void:
void setHeight(double newHeight) { m_height = newHeight; }
小贴士
属性设置器是公共槽的良好候选者,这样你就可以通过信号和槽轻松地操作属性值。
我们将在本书的后续章节中学习关于Q_PROPERTY声明的其他扩展。
使用属性
你可以通过两种方式访问属性。一种当然是使用我们用READ和WRITE关键字在Q_PROPERTY宏中声明的获取器和设置器方法——这自然会起作用,因为它们是常规的 C++方法。
另一种方法是使用 QObject 和元对象系统提供的功能。它们允许我们通过两个接受属性名称作为字符串的方法按名称访问属性。一个通用的属性获取器(返回属性值)是一个名为 property 的方法。它的设置器对应物(接受值并返回 void)是 setProperty。由于我们可以有不同数据类型的属性,那么这两个方法所使用的数据结构是什么,它们用于存储不同类型属性的值?Qt 有一个专门用于此的类,称为 QVariant,它在行为上与 C 联合体非常相似,因为它可以存储不同类型的值。尽管如此,使用联合体有几个优点——其中三个最重要的优点是你可以询问对象它当前持有哪种类型的数据,你可以将一些类型转换为其他类型(例如,将字符串转换为整数),并且你可以教会它操作你自己的自定义类型。
行动时间 – 向棋盘类添加属性
在这个练习中,我们将向棋盘类添加一个有用的属性。该属性将保存关于应该进行下一步棋的玩家的信息。属性的类型将是我们在之前创建的 TicTacToeWidget::Player 枚举。对于获取器和设置器方法,我们将使用我们之前创建的两个函数:currentPlayer() 和 setCurrentPlayer()。
打开我们类的头文件,并按照以下代码修改类定义:
class TicTacToeWidget : public QWidget {
Q_OBJECT
Q_ENUMS(Player)
Q_PROPERTY(Player currentPlayer READ currentPlayer
WRITE setCurrentPlayer
NOTIFY currentPlayerChanged)
public:
enum Player { Invalid, Player1, Player2, Draw };
刚才发生了什么?
由于我们想将枚举用作属性的类型,我们必须通知 Qt 的元对象系统关于枚举的信息。这是通过使用 Q_ENUMS 宏来完成的。然后,我们声明一个名为 currentPlayer 的属性,并将我们现有的两个方法标记为属性的获取器和设置器。我们还使用 NOTIFY 关键字将 currentPlayerChanged 标记为发送通知以告知属性值变化的信号。在我们的小游戏中,我们不会使用这些额外的信息,而且我们根本不需要 currentPlayer 是一个属性,但始终尝试找到好的属性候选者并公开它们是一个好主意,因为总有一天,有人可能会以我们没有预测到的方式使用我们的类,某个特定的属性可能会变得有用。
设计 GUI
到目前为止,我们都是通过手动编写实例化小部件、在布局中排列它们并将信号连接到槽的 C++代码来编码所有用户界面。对于简单的小部件来说,这并不难,但当 UI 变得越来越复杂时,就会变得繁琐且耗时。幸运的是,Qt 提供了工具,可以以更愉快的方式完成所有这些工作。我们不必编写 C++代码,而可以通过在画布上拖放小部件、应用布局以及甚至使用点按技术建立信号-槽连接来创建表单。在编译的后期,这些表单将为我们转换为 C++代码,并准备好应用于小部件。
这个工具被称为 Qt Designer,并且与 Qt Creator 集成。要使用它,从文件菜单中选择新建文件或项目,然后在对话框的文件和类部分选择 Qt,之后选择可用的Qt Designer 表单类模板。你可以选择表单的模板并配置诸如要创建的文件名称等细节。最后,将创建三个文件——其中两个实现从QWidget或其子类派生的 C++类,最后一个包含表单本身的数据。
关闭向导后,我们将进入 Qt Creator 的设计模式,其外观如下所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_06.jpg
设计模式由四个主要部分组成,如图中用数字标记所示。
标记为 1 的区域是主要的工作表。它包含正在设计的表单的图形表示,你可以移动小部件,将它们组合成布局,并查看它们的反应。它还允许使用我们稍后将要学习的点按方法进一步操作表单。
第二个区域 2 是小部件框。它包含一个可用小部件类型的列表,这些类型被组织成包含具有相关或相似功能的项目的小组。在列表上方,你可以看到一个框,允许你过滤列表中显示的小部件,只显示与输入的表达式匹配的小部件。在列表的起始处,也有一些实际上不是小部件的项目——一个组包含布局,另一个组包含所谓的间隔符,这是一种将其他项目彼此推开的方式。
小部件框的主要目的是在电子表格中向表单添加小部件。你可以通过用鼠标从列表中抓取一个小部件,将其拖动到画布上,然后释放鼠标按钮来实现这一点。小部件将出现在表单中,并且可以使用 Creator 的“设计”模式中的其他工具进一步操作。
我们接下来要讨论的下一个区域 3 位于窗口的右侧,由两部分组成。在图象的顶部,你可以看到对象检查器。它展示了当前编辑表单中所有小部件的父子关系。每一行包含对象的名称以及元对象系统所看到的其类名。如果你点击一个条目,表单中相应的部件就会被选中(反之亦然)。
图象的下半部分显示了属性编辑器。你可以用它来更改每个对象的所有属性值。属性根据它们声明的类分组,从 QObject(实现属性的基类)开始,它只声明了一个但很重要的属性—objectName。在 QObject 之后,是 QWidget 中声明的属性,它是 QObject 的直接后代。它们主要与部件的几何和布局策略相关。在列表的下方,你可以找到来自 QWidget 进一步派生的属性。如果你更喜欢纯字母顺序,其中属性不是按其类分组,你可以通过点击属性列表上方的扳手图标后出现的弹出菜单来切换视图;然而,一旦你熟悉了 Qt 类的层次结构,当按类排序时,导航列表将会容易得多。
仔细观察属性编辑器,你会发现其中一些属性下面有箭头,点击后会展开新的行。这些是复合属性,其完整属性值由多个子属性值确定;例如,如果有一个名为 geometry 的属性定义了一个矩形,它可以展开以显示四个子属性:x、y、width 和 height。还有一点你应该很快就能注意到,一些属性名以粗体显示。这意味着该属性的值已被修改,并且与该属性的默认值不同。这让你可以快速找到你已修改的属性。
我们现在要解释的最后一个功能组 4 位于窗口的下半部分。默认情况下,你会看到两个标签页—动作编辑器和信号/槽编辑器。它们允许我们通过干净的表格界面创建辅助实体,例如菜单和工具栏的动作,或者小部件之间的信号-槽连接。
这里所描述的是基本工具布局。如果你不喜欢它,你可以从主工作表调用上下文菜单,取消选择 锁定 条目,并将所有窗口重新排列到你喜欢的样子,甚至关闭你现在不需要的窗口。
行动时间 – 设计游戏配置对话框
现在,我们将使用 Qt Designer 表单来构建一个简单的游戏配置对话框,这将允许我们为我们的玩家选择名字。
首先,从菜单中调用新的文件对话框,选择创建一个如以下截图所示的Qt Designer 表单类:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_07.jpg
在出现的窗口中,选择底部有按钮的对话框:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_08.jpg
将类名调整为ConfigurationDialog,将其他设置保留为默认值,并完成向导。
将两个标签和两个行编辑拖放到表单上,将它们大致放置在一个网格中,双击每个标签,调整它们的标题以获得以下类似的结果:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_09.jpg
选择要编辑的第一行,查看属性编辑器。找到一个名为objectName的属性,将其更改为player1Name。对另一行也做同样的操作,并将其命名为player2Name。然后,在表单上的某个空白区域单击,在上工具栏中选择在网格中布局选项。你应该看到部件自动对齐——这是因为你刚刚将布局应用到表单上。完成操作后,打开工具菜单,转到表单编辑器子菜单,并选择预览选项。
刚才发生了什么?
你可以看到一个新窗口打开,其外观与我们刚刚设计的表单完全一样。你可以调整窗口大小并与之交互,以监控布局和部件的行为。实际上发生的事情是 Qt Creator 根据我们在设计模式的所有区域提供的描述为我们构建了一个真实的窗口。无需任何编译,在一瞬间我们就得到了一个完全工作的窗口,所有布局都正常工作,所有属性都调整到我们喜欢的样子。这是一个非常重要的工具,所以请确保经常使用它来验证你的布局是否按照你的意图控制所有部件——这比编译和运行整个应用程序以检查部件是否正确拉伸或挤压要快得多。这一切都得益于 Qt 的元对象系统。
是时候行动起来——润色对话框
现在 GUI 本身已经按照我们的预期工作,我们可以专注于给对话框添加更多润色。
加速器和标签伙伴
我们将要做的第一件事是为我们的部件添加加速器。这些是键盘快捷键,当激活时,会导致特定部件获得键盘焦点或执行预定的操作(例如,切换复选框或按下按钮)。加速器通常通过下划线标记,如下面的图所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_10.jpg
我们将为行编辑设置加速键,以便当用户激活第一个字段的加速键时,它将获得焦点。通过这种方式,我们可以输入第一个玩家的名字,同样,当第二个行编辑的加速键被触发时,我们可以开始输入第二个玩家的名字。
首先,在第一行编辑的左侧选择标签。按F2键或双击标签(或者,在属性编辑器中找到标签的文本属性并激活其值字段)。这样我们就可以更改标签的文本。使用光标键导航,使文本光标位于字符1之前,并输入&字符。这个字符将紧随其后的字符标记为小部件的加速键。对于由文本和实际功能(例如,按钮)组成的控件,这足以使加速键工作。然而,由于QLineEdit没有与之关联的任何文本,我们必须使用单独的控件。这就是为什么我们在标签上设置了加速键。现在,我们需要将标签与行编辑关联起来,以便标签加速器的激活可以将其转发到我们选择的控件。这是通过为标签设置所谓的伙伴来完成的。您可以使用QLabel类的setBuddy方法在代码中这样做,或者使用 Creator 的表单设计器。由于我们已经在设计模式中,我们将使用后一种方法。为此,我们需要在表单设计器中激活一个专用模式。
看看 Creator 窗口的上方;在表单上方,你会找到一个包含几个图标的工具栏。点击标有编辑伙伴的图标或直接在键盘上按F5键。现在,将鼠标光标移到标签上,按下鼠标按钮,并从标签拖动到行编辑。当你将标签拖动到行编辑上时,你会看到一个标签和行编辑之间正在设置连接的图形可视化。如果你现在释放按钮,这个关联将被永久化。你应该注意到,当这种关联被建立时,&字符将从标签中消失,并且它后面的字符会得到一个下划线。对其他标签和相应的行编辑重复此操作。现在,您可以再次预览表单,并检查加速键是否按预期工作。
标签顺序
当你在预览表单时,你可以检查 UI 设计的另一个方面。首先,按Tab键,看看焦点是如何从一个控件移动到另一个控件的。有很大可能性,焦点将开始在前一个按钮和行编辑之间来回跳跃,而不是从上到下(这是这个特定对话框的直观顺序)。要检查和修改焦点顺序,请离开预览,并切换到标签顺序编辑模式,方法是点击工具栏中称为编辑标签顺序的图标。
此模式将一个框与一个数字关联到每个可聚焦的小部件。通过按照您希望小部件获得焦点的顺序单击矩形,您可以重新排序值,从而重新排序焦点。现在,使其顺序如下所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_11.jpg
再次进入预览并检查焦点是否根据您设置的进行改变。
小贴士
在决定标签顺序时,考虑对话框中哪些字段是必需的,哪些是可选的,是很好的。一个好的习惯是首先允许用户遍历所有必需的字段,然后到对话框确认按钮(例如,一个写着确定或接受的按钮),然后遍历所有可选字段。这样,用户将能够快速填写所有必需的字段并接受对话框,而无需遍历所有用户希望保留为默认值的可选字段。
信号和槽
我们现在要做的最后一件事是确保信号-槽连接设置正确。为此,通过按F4或从工具栏中选择编辑信号/槽来切换到信号-槽编辑模式。底部按钮对话框小部件模板为我们预定义了两个连接,现在应该可以在主画布区域中看到:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_12.jpg
实现 Qt 中对话框的QDialog类有两个有用的槽——accept()和reject()——它们通知调用者对话框所表示的操作是否被接受。为了方便起见,这些槽应该已经连接到相应的accepted()和rejected()信号,这些信号来自默认包含确定和取消按钮的按钮组(这是一个QDialogButtonBox类的实例)。如果您单击其中的任何一个,将分别发出信号accepted()或rejected()。
在这个阶段,我们可以添加一些更多连接,使我们的对话框更加实用。让我们设置成只有当两个行编辑中的任何一个都不为空时(即,当两个字段都包含玩家名称时),接受对话框的按钮才可用。虽然我们将在稍后实现逻辑本身,但现在我们可以将连接到一个将执行此任务的槽。
由于默认情况下没有这样的槽,我们需要通知表单编辑器,在应用程序编译时将存在这样的槽。为此,我们需要通过按F3或从工具栏中选择编辑小部件来切换回表单编辑器的默认模式。然后,您可以调用表单的上下文菜单并选择更改信号/槽。将弹出一个窗口,如下所示,列出了可用的信号和槽:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_13.jpg
在槽组中单击**+**按钮,创建一个名为updateOKButtonState()的槽:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_14.jpg
然后,接受对话框并返回到信号/槽模式。通过用鼠标拖拽一个行编辑创建一个新的连接。当您将光标移出小部件时,您会注意到一个红色线条跟随您的指针。如果线条遇到一个有效目标,线条将变成箭头,并且目标对象将被突出显示。表单本身也可以是一个目标(或一个源);在这种情况下,线条将以一个接地标记结束(两条短的水平线)。
当您释放鼠标按钮时,将弹出一个窗口,列出源对象的所有信号和目标对象的所有槽。选择textChanged(QString)信号。请注意,当您这样做时,一些可用的槽将消失。这是因为工具只允许我们从与突出显示的信号兼容的槽中进行选择。选择我们新创建的槽并接受对话框。对其他行编辑重复相同的操作。
我们在这里所做的是创建了两个连接,当两个行编辑中的任何一个文本发生变化时,它们将会触发。它们将执行一个尚不存在的槽——通过“创建”槽,我们只声明了在我们的QDialog子类中实现它的意图,该子类也是为我们创建的。现在您可以继续保存表单了。
发生了什么?
我们执行了多项任务,使我们的表单遵循来自许多应用程序的标准行为——这使得表单导航变得简单,并显示了用户可以执行哪些操作以及哪些操作目前不可用。
使用设计器表单
如果您在文本编辑器中打开表单(例如,通过切换到创建者的编辑面板),您会注意到它实际上是一个 XML 文件。那么我们如何使用这个文件呢?
作为构建过程的一部分,Qt 调用一个名为用户界面编译器(uic)的特殊工具,该工具读取文件并生成一个包含setupUi()方法的 C++类。此方法接受一个指向小部件的指针,并包含代码,该代码实例化所有小部件,设置它们的属性,并建立信号-槽连接,而我们负责调用它来准备 GUI。该类本身(以您的表单命名,即表单对象的objectName属性的值)前面加上一个Ui命名空间(例如,Ui::MyForm),并不是从小部件类派生的,而是旨在与一个小部件一起使用。基本上有三种方法可以这样做。
直接方法
使用 Qt Designer 表单的最基本方法是实例化一个小部件和一个表单对象,并在小部件上调用setupUi,如下所示:
QWidget *widget = new QWidget
Ui_form ui * = new Ui_form;
ui->setupUi(widget);
这种方法存在一些缺陷。首先,它可能导致 ui 对象的潜在内存泄漏(记住,它不是 QObject,因此你不能设置其父对象,以便在父对象被删除时删除它)。其次,由于表单中的所有小部件都是未与小部件对象绑定的 ui 对象的变量,它破坏了封装性,这是面向对象编程最重要的范式之一。然而,有一种情况下这种结构是可以接受的。那就是当你创建一个简单的短期模态对话框时。你肯定需要记住,为了显示常规小部件,我们一直在使用 show() 方法。这对于非模态小部件来说是好的,但对于模态对话框,你应该调用 QDialog 类中定义的 exec() 方法。这是一个阻塞方法,它不会返回,直到对话框关闭。这允许我们修改代码,使其变为:
QDialog dialog;
Ui_form ui;
ui.setupUi(&dialog);
dialog.exec();
由于我们是在栈上创建对象,编译器将负责在局部作用域结束时删除它们。
多重继承方法
使用 Designer 表单的第二种方式是创建一个从 QWidget(或其子类之一)和表单类本身派生的类。然后我们可以从构造函数中调用 setupUi:
class Widget : public QWidget, private Ui::MyForm {
public:
Widget(QWidget *parent = 0) : QWidget(parent) {
setupUi(this);
}
};
这样,我们保持了封装性,因为我们的类从 Ui 类继承字段和方法,并且我们可以在类代码内部直接调用它们,同时通过使用私有继承来限制外部世界的访问。这种方法的缺点是它会污染类命名空间,例如,如果我们有 Ui::MyForm 中的 name 对象,我们就无法在 Widget 中创建一个 name 方法。
单重继承方法
幸运的是,我们可以通过组合而非继承来解决这个问题。我们只从 QWidget 派生我们的小部件类,而不是也继承自 Ui::MyForm,我们可以将其实例作为新类的一个私有成员:
class Widget : public QWidget {
public:
Widget(QWidget *parent = 0) : QWidget(parent) {
ui = new Ui::MyForm;
ui->setupUi(this);
}
~Widget() { delete ui; }
private:
Ui::MyForm *ui;
};
以必须手动创建和销毁 Ui::MyForm 实例为代价,我们可以获得额外的好处,即在一个专用对象中包含表单的所有变量和代码,这防止了上述命名空间污染。
这是使用 Designer 表单的推荐方式,也是当你告诉 Qt Creator 为你生成 Designer 表单类时的默认操作模式。
行动时间 – 对话框的逻辑
现在,是我们让游戏设置对话框工作的时候了。之前,我们声明了一个信号-槽连接,但现在槽本身需要实现。
打开由 Creator 生成的表单类。如果你仍然处于设计模式,你可以使用 Shift + F4 键盘快捷键快速跳转到相应的表单类文件。为该类创建一个公共槽段,并声明一个 void updateOKButtonState() 槽。打开重构菜单 (Alt + Enter),并让 Creator 为你创建该槽的骨架实现。在函数体中填充以下代码:
void ConfigurationDialog::updateOKButtonState() {
bool pl1NameEmpty = ui->player1Name->text().isEmpty();
bool pl2NameEmpty = ui->player2Name->text().isEmpty();
QPushButton *okButton = ui->buttonBox->button(QDialogButtonBox::Ok);
okButton->setDisabled(pl1NameEmpty || pl2NameEmpty);
}
此代码检索玩家名称并检查其中任何一个是否为空。然后,它要求包含当前 OK 和 Cancel 按钮的按钮框提供一个指向接受对话框的按钮的指针。然后,我们根据两个玩家名称是否都包含有效值来设置按钮的禁用状态。当第一次创建对话框时,也需要更新按钮状态,因此将 updateOKButtonState() 的调用添加到对话框的构造函数中:
ConfigurationDialog::ConfigurationDialog(QWidget *parent) :
QDialog(parent), ui(new Ui::ConfigurationDialog)
{
ui->setupUi(this);
updateOKButtonState();
}
下一步是允许从对话框外部存储和读取玩家名称——由于 ui 组件是私有的,因此无法从类代码外部访问它。这是一个常见的情况,Qt 也遵循这一原则。几乎每个 Qt 类中的每个数据字段都是私有的,可能包含访问器(一个获取器和可选的设置器),这些是允许读取和存储数据字段值的公共方法。我们的对话框有两个这样的字段——两个玩家的名称。在此阶段,我们应该注意它们是属性的良好候选者,因此最终我们将它们声明为属性。但首先,让我们从实现访问器开始。
在 Qt 中,设置器方法通常使用小写模式命名,例如,set 后跟属性名称,首字母转换为大写。在我们的情况下,两个设置器将分别称为 setPlayer1Name 和 setPlayer2Name,它们都将接受 QString 并返回 void。在类头文件中声明它们,如下面的代码片段所示:
void setPlayer1Name(const QString &p1name);
void setPlayer2Name(const QString &p2name);
在 .cpp 文件中实现它们的主体:
void ConfiguratiosDialog::setPlayer1Name(const QString &p1name) {
ui->player1Name->setText(p1name);
}
void ConfigurationDialog::setPlayer2Name(const QString &p2name) {
ui->player2Name->setText(p2name);
}
在 Qt 中,获取器方法通常与它们相关的属性同名——player1Name 和 player2Name。将以下代码放入头文件中:
QString player1Name() const;
QString player2Name() const;
将以下代码放入实现文件中:
QString ConfigurationDialog::player1Name() const { return ui->player1Name->text(); }
QString ConfigurationDialog::player2Name() const { return ui->player2Name->text(); }
现在唯一剩下的事情就是声明属性。将以下高亮行添加到类声明中:
class ConfigurationDialog : public QDialog {
Q_OBJECT
Q_PROPERTY(QString player1Name READ player1Name WRITE setPlayer1Name)
Q_PROPERTY(QString player2Name READ player2Name WRITE setPlayer2Name)
public:
ConfigurationDialog(QWidget *parent = 0);
我们的对话框现在已准备就绪。您可以通过在 main() 中创建其实例并调用 show() 或 exec() 来测试它。
应用程序的主窗口
我们的游戏中已经有了两个主要组件——游戏板和配置对话框。现在,我们需要将它们绑定在一起。为此,我们将使用另一个重要组件——QMainWindow 类。一个“主窗口”代表应用程序的控制中心。它可以包含菜单、工具栏、停靠小部件、状态栏以及称为“中央小部件”的实际小部件内容,如下面的图所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_15.jpg
中心小部件部分不需要任何额外的解释——它是一个像任何其他小部件一样的常规小部件。我们也不会在这里关注停靠小部件或状态栏。它们是有用的组件,但它们如此容易掌握,以至于你可以自己学习它们。相反,我们将花一些时间掌握菜单和工具栏。你肯定在许多应用程序中看到并使用过工具栏和菜单,你知道它们对于良好的用户体验是多么重要。
这两个概念共享的主要英雄是一个名为QAction的类,它代表用户可以调用的功能。一个单独的动作可以有多个化身——它可以是一个菜单(QMenu实例)、工具栏(QToolBar)、按钮或键盘快捷键(QShortcut)。操作动作(例如,更改其文本)会导致所有化身更新。例如,如果你在菜单中有一个保存条目(与键盘快捷键绑定),工具栏中的保存图标,以及可能在你的用户界面中的其他地方的保存按钮,并且你想要禁止保存文档(例如,你的地下城与龙游戏关卡编辑器中的地图),因为自上次加载文档以来其内容没有变化。在这种情况下,如果菜单条目、工具栏图标和按钮都链接到同一个QAction实例,那么一旦你将动作的enabled属性设置为false,这三个实体也将被禁用。这是一个保持应用程序不同部分同步的简单方法——如果你禁用动作对象,你可以确信触发动作所代表功能的所有条目也被禁用。动作可以在代码中实例化,也可以使用 Qt Creator 中的动作编辑器图形化创建。动作可以与不同的数据相关联——文本、工具提示、状态栏提示、图标以及其他较少使用的。所有这些都被你的动作的化身所使用。
Qt 资源系统
当谈到图标时,Qt 有一个重要的特性你应该学习。创建图标的一种自然方式是从文件系统中加载图像。这个问题在于你必须与你的应用程序一起安装一堆文件,并且你需要始终知道它们的位置,以便能够提供路径来访问它们。这是困难的,但幸运的是,Qt 有一个解决方案——它允许你将任意文件(如图标图像)直接嵌入到可执行的应用程序中。这是通过准备随后编译到二进制中的资源文件来完成的。幸运的是,Qt Creator 也提供了一个图形化工具来完成这项工作。
行动时间——应用程序的主窗口
创建一个新的Qt Designer Form Class应用程序。作为一个模板,选择主窗口。接受向导中其余部分的默认值。
使用动作编辑器创建一个动作,并在对话框中输入以下值:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_16.jpg
现在,创建另一个动作并填写以下截图中的值:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_17.jpg
我们希望我们的游戏看起来很漂亮,所以我们将为动作提供图标,并使用资源系统将图像嵌入到我们的应用程序中。创建一个新文件,将其命名为 Qt Resource File。命名为 resources.qrc。点击 Add 按钮,选择 Add Prefix。将前缀的值更改为 /。然后,再次点击 Add 按钮,选择 Add Files。找到适合你动作的图像并将它们添加到资源文件中。将出现一个对话框询问你是否希望将文件复制到项目目录。通过选择 Copy 来同意。
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_18.jpg
现在,在动作编辑器中再次编辑动作并为它们选择图标。
发生了什么?
我们向项目中添加了一个资源文件。在该资源文件中,我们为许多图像创建了条目。每个图像都被放置在一个 / 前缀下,这代表我们创建的人工文件系统的根节点。资源文件中的每个条目都可以通过手动编写的代码直接访问,作为一个具有特殊名称的文件。这个名称由三个部分组成。首先是冒号字符(:),它标识资源文件系统。接着是一个前缀(例如,/)和资源条目的完整路径(例如,exit.png)。这使得名为 exit.png 的图像可以通过 :/exit.png 路径访问。当我们构建项目时,该文件将被转换成 C 数据数组代码并与应用程序二进制文件集成。在准备完资源文件后,我们使用了其中嵌入的图像作为我们动作的图标。
下一步是将这些动作添加到菜单和工具栏中。
现在是时候添加下拉菜单了
要为窗口创建一个菜单,双击表单顶部的 Type Here 文本,并将其替换为 &File。然后,将动作编辑器中的 New Game 动作拖动到新创建的菜单上,但不要放下它。菜单现在应该打开了,你可以拖动动作,直到子菜单中出现一个红色条,在你想菜单条目出现的位置——现在你可以释放鼠标按钮来创建条目。之后,再次通过点击 File 来打开菜单,并选择 Add Separator。然后,重复拖放操作为 Quit 动作插入一个菜单条目,位于 File 菜单中的分隔符下方,如图所示:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_19.jpg
发生了什么?
使用图形工具,我们为我们的程序创建了一个菜单,并向该菜单添加了多个操作(这些操作被自动转换为菜单项)。每个菜单条目都接收了一些文本和一个由放入菜单中的操作指定的图标。
小贴士
要创建子菜单,首先通过点击在此处输入行创建一个菜单条目,并输入子菜单名称。然后,将一个操作拖动并悬停在这样一个子菜单上。经过一段时间,子菜单将弹出,你将能够将操作放下以在二级菜单中创建一个条目。
行动时间 – 创建工具栏
要创建工具栏,请在表单上调用上下文菜单并选择添加工具栏。然后,将新游戏操作拖动到工具栏上并放下。为工具栏打开上下文菜单并选择追加分隔符。然后,从操作编辑器中将退出操作拖动到分隔符后面的工具栏中。以下图展示了你现在应该拥有的最终布局:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_20.jpg
发生了什么?
创建工具栏与创建菜单非常相似。首先创建容器(工具栏),然后从操作编辑器中拖放操作。你甚至可以从菜单栏拖动一个操作并将其放到工具栏上,反之亦然!
行动时间 – 填充中心部件
在主窗口区域添加两个标签 – 一个在顶部用于第一个玩家名称,一个在表单底部用于第二个玩家名称 – 然后将它们的objectName属性分别更改为player1和player2。清除它们的文本属性,以便它们不显示任何内容。然后,从部件框中拖动Widget,将其放在两个标签之间,并将它的对象名称设置为gameBoard。在刚刚放置的部件上调用上下文菜单并选择提升到。这允许我们用另一个类替换表单中的部件;在我们的情况下,我们希望用我们的游戏板替换空部件。将刚刚出现的对话框填写如下图的值:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_03_21.jpg
然后,点击标有添加的按钮,然后点击提升以关闭对话框并确认提升。你不会在表单上注意到任何变化,因为替换仅在编译期间发生。现在,在表单上应用垂直布局,以便标签和空部件能够正确对齐。
发生了什么?
并非所有部件类型都在表单设计器中直接可用。有时,我们需要使用仅在构建的项目中创建的部件类。将自定义部件放在表单上的最简单方法是在生成表单的 C++代码时,要求设计者将类名替换为一些对象。通过将对象提升到不同的类,我们节省了大量将游戏板适应用户界面的工作。
行动时间 - 整合所有内容
游戏的视觉部分已经准备好,接下来需要完成主窗口逻辑的编写并将所有组件整合在一起。给类添加一个公共槽位,命名为 startNewGame。在类构造函数中,将 New Game 动作的触发信号连接到这个槽位,并将应用程序的退出槽位连接到另一个动作:
connect(ui->actionNewGame, SIGNAL(triggered()), this, SLOT(startNewGame()));
connect(ui->actionQuit, SIGNAL(triggered()), qApp, SLOT(quit()));
qApp 特殊宏代表指向应用程序对象实例的指针,因此前面的代码将调用在 main() 中创建的 QApplication 对象的 quit() 槽位,这最终将导致应用程序结束。
让我们按照以下方式实现 startNewGame 槽位:
void MainWindow::startNewGame() {
ConfigurationDialog dlg(this);
if(dlg.exec() == QDialog::Rejected) {
return; // do nothing if dialog rejected
}
ui->player1->setText(dlg.player1Name());
ui->player2->setText(dlg.player2Name());
ui->gameBoard->initNewGame();
ui->gameBoard->setEnabled(true);
}
在这个槽位中,我们创建设置对话框并展示给用户,强制用户输入玩家名称。如果对话框被取消,我们放弃创建新游戏。否则,我们从对话框获取玩家名称并将它们设置在适当的标签上。最后,我们初始化棋盘并启用它,以便用户可以与之交互。
在编写回合制棋盘游戏时,始终清楚地标记现在是哪个玩家的回合进行移动是个好主意。我们将通过在粗体中标记移动玩家的名称来实现这一点。棋盘类中已经有一个信号告诉我们已经完成了一次有效移动,我们可以通过它来更新标签。让我们在主窗口类的构造函数中添加适当的代码:
connect(ui->gameBoard, SIGNAL(currentPlayerChanged(Player)), this, SLOT(updateNameLabels()));
现在让我们来看槽位本身;让我们在类中添加一个私有槽位部分并声明该槽位:
private slots:
void updateNameLabels();
现在,我们可以实现它:
void MainWindow::updateNameLabels() {
QFont f = ui->player1->font();
f.setBold(ui->gameBoard->currentPlayer() == TicTacToeWidget::Player1);
ui->player1->setFont(f);
f.setBold(ui->gameBoard->currentPlayer() == TicTacToeWidget::Player2);
ui->player2->setFont(f);
}
除了在信号发出后调用槽位之外,我们还可以在游戏开始时使用它来设置标签的初始数据。由于所有槽位也都是常规方法,我们只需从 startNewGame() 中调用 updateNameLabels() 即可——在 startNewGame() 的末尾调用 updateNameLabels()。
最后需要完成的事情是处理游戏结束的情况。将棋盘的 gameOver() 信号连接到主窗口类中的新槽位。如下实现该槽位:
void MainWindow::handleGameOver(TicTacToeWidget::Player winner) {
ui->gameBoard->setEnabled(false);
QString message;
if(winner == TicTacToeWidget::Draw) {
message = "Game ended with a draw.";
} else {
message = QString("%1 wins").arg(winner == TicTacToeWidget::Player1
? ui->player1->text() : ui->player2->text());
}
QMessageBox::information(this, "Info", message);
}
发生了什么?
我们的代码做了两件事。首先,它禁用了棋盘,这样玩家就不能再与之交互了。其次,它检查谁赢得了游戏,组装消息(我们将在下一章中了解更多关于 QString 的内容),并使用静态方法 QMessageBox::information() 显示它,该方法显示一个包含消息和允许我们关闭对话框的按钮的模态对话框。最后一件剩下的事情是更新 main() 函数,以便创建我们的 MainWindow 类的实例:
#include "mainwindow.h"
#include <QApplication>
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
MainWindow w;
w.show();
return a.exec();
}
现在,你可以运行你的第一个 Qt 游戏。
英雄尝试扩展游戏
作为一项附加练习,你可以尝试修改本章中我们编写的代码,以便在大于 3 x 3 的棋盘上玩游戏。让用户决定棋盘的大小(你可以修改游戏选项对话框来实现这一点,并使用 QSlider 和 QSpinBox 允许用户选择棋盘的大小),然后你可以指导 TicTacToeWidget 根据它得到的大小构建棋盘。记住调整游戏胜利逻辑!如果在任何时刻你遇到了死胡同,不知道该使用哪些类和函数,请查阅参考手册。
小贴士
要快速查找类的文档(或文档中的任何其他页面),切换到 帮助 面板,从侧边栏顶部的下拉列表中选择 索引,并输入搜索词,例如 QAction。此外,F1 键在浏览手册时非常有用。将鼠标指针或文本光标放在代码编辑器中类的名称、函数或对象上,然后按键盘上的 F1。通过这样做,Qt Creator 将乐意向你展示所选主题的可用帮助信息。
快速问答——使用小部件
Q1. 返回小部件首选大小的方法被称为:
-
preferredSize -
sizeHint -
defaultSize
Q2. 哪个 Qt 类可以携带任何属性的值?
-
QVariant -
QUnion -
QPropertyValue
Q3. QAction 对象的目的是什么?
-
它代表用户可以在程序中调用的功能。
-
它包含一个用于将焦点移动到小部件上的快捷键序列。
-
它是使用 Qt Designer 生成的所有表单的基类。
摘要
在本章中,你学习了如何使用 Qt 创建简单的图形用户界面。我们探讨了两种方法——通过直接编写所有代码来创建用户界面类,以及使用生成大部分代码的图形工具来设计用户界面。无法确定两种方法中哪一种更好;它们各自在某些方面更好,在其他方面则较差。一般来说,你应该优先使用 Qt Designer 表单来直接编写代码,因为它更快,更不容易出错,因为大部分代码都是自动生成的。然而,如果你想要对代码有更多的控制,或者你的 GUI 非常动态,自己编写所有代码会更容易,尤其是在你积累了足够的 Qt 经验,可以避免常见陷阱并学会使用高级编程结构之后。
我们还学习了 Qt 的核心——元对象系统是如何工作的。你现在应该能够通过连接信号到槽(预定义的以及你现在已经知道如何定义并填充代码的自定义槽)来创建简单的用户界面并填充逻辑。
Qt 包含许多小部件类型,但我没有逐一向你介绍它们。Qt 手册中有一个非常好的关于许多小部件类型的解释,称为 Qt 小部件画廊,它展示了其中大部分的实际应用。
如果你对这些小部件的任何使用有疑问,你可以查看示例代码,并在 Qt 参考手册中查找相应的类,以了解更多关于它们的信息。
使用 Qt 远不止在表单上拖放小部件并提供一些代码来将这些部件粘合在一起。在下一章中,你将了解 Qt 提供的一些最实用的功能;它们与在屏幕上显示图形无关,而是让你能够操作各种类型的数据。这对于比简单的井字棋更复杂的任何游戏来说都是必不可少的。
第四章。Qt 核心基础
本章将帮助你掌握 Qt 的基本数据处理和存储方式。首先,你将学习如何处理文本数据以及如何将文本与正则表达式匹配。然后,你将了解如何从文件中存储和检索数据,以及如何使用不同的存储格式来存储文本和二进制数据。到本章结束时,你将能够高效地在你的游戏中实现非平凡逻辑和数据处理。你还将了解如何在游戏中加载外部数据,以及如何将你的数据保存到永久存储中以便将来使用。
文本处理
带有图形用户界面(游戏当然属于这一类)的应用程序能够通过显示文本并期望用户输入文本与用户交互。我们已经在上一章通过使用QString类来触及了这个话题的表面。现在,我们将深入探讨。
字符串操作
Qt 内部使用 Unicode 对文本进行编码,这允许表示世界上几乎所有的语言字符,并且是大多数现代操作系统中文本本地编码的事实标准。然而,你必须意识到,与QString类不同,C++语言默认不使用 Unicode。因此,你输入代码中的每个字符串字面量(即你用引号包裹的每个裸文本)在可以存储在 Qt 的任何字符串处理类之前,都需要先转换为 Unicode。默认情况下,这会隐式地假设字符串字面量是 UTF-8 编码的,但QString提供了一系列静态方法来从其他编码(如QString::fromLatin1()或QString::fromUtf16())转换。这种转换是在运行时完成的,这会增加程序执行时间,特别是如果你在程序中倾向于进行大量的此类转换。幸运的是,有一个解决方案:
QString str = QStringLiteral("I'm writing my games using Qt");
你可以将你的字符串字面量包裹在QStringLiteral的调用中,就像前面代码所示,如果你的编译器支持,它将在编译时执行转换。将所有字符串字面量包裹成QStringLiteral是一个好习惯,但这不是必需的,所以如果你忘记这样做,请不要担心。
在描述QString类时,我们不会深入细节,因为它在许多方面与 C++标准库中的std::string相似。相反,我们将关注这两个类之间的差异。
文本编码和解码
第一个差异已经提到——QString将数据编码为 Unicode。这有一个优点,即能够用几乎任何语言表达文本,但代价是需要从其他编码转换。大多数流行的编码——UTF-8、UTF-16 和 Latin1——在QString中都有方便的方法来转换到和从内部表示。但是,Qt 也知道如何处理许多其他编码。这是通过使用QTextCodec类来完成的。
小贴士
你可以使用 QTextCodec::availableCodecs() 静态方法列出你的安装上支持的 codec。在大多数安装中,Qt 可以处理近 1,000 种不同的文本 codec。
大多数处理文本的 Qt 实体都可以访问此类实例以透明地执行转换。如果你想手动执行此类转换,你可以通过名称请求 Qt 的 codec 实例并使用 fromUnicode() 和 toUnicode() 方法:
QByteArray big5Encoded = "你好";
QTextCodec *big5Codec = QTextCodec::codecForName("Big5");
QString text = big5Codec->toUnicode(big5Encoded);
QTextCodec *utf8Codec = QTextCodec::codecForMib(106); // UTF-8
QByteArray utf8Encoded = utf8Codec->fromUnicode(text);
基本字符串操作
涉及文本字符串的最基本任务包括添加或删除字符串中的字符、连接字符串以及访问字符串内容。在这方面,QString 提供了一个与 std::string 兼容的接口,但它还超越了这一点,暴露了许多更多有用的方法。
使用 prepend() 和 append() 方法可以在字符串的开始或末尾添加数据,这些方法有几个重载,可以接受不同可以包含文本数据的对象,包括经典的 const char* 数组。使用 insert() 方法可以在字符串的中间插入数据,该方法将需要开始插入的字符位置作为其第一个参数,实际文本作为其第二个参数。insert 方法具有与 prepend 和 append 相同的重载,但不包括 const char*。从字符串中删除字符的方式类似。基本方法是使用 remove() 方法,该方法接受需要删除字符的位置和要删除的字符数,如下所示:
QString str = QStringLiteral("abcdefghij");
str.remove(2, 4); // str = "abghij"
还有一个接受另一个字符串的重载。当调用时,它会从原始字符串中删除所有其出现。此重载有一个可选参数,指定比较是否应该以默认的大小写敏感(Qt::CaseSensitive)或大小写不敏感(Qt::CaseInsensitive)的方式进行:
QString str = QStringLiteral("Abracadabra");
str.remove(QStringLiteral("ab"), Qt::CaseInsensitive); // str = "racadra"
要连接字符串,你可以简单地将两个字符串相加,或者将一个字符串追加到另一个字符串上:
QString str1 = QStringLiteral("abc");
QString str2 = QStringLiteral("def");
QString str1_2 = str1+str2;
QString str2_1 = str2;
str2_1.append(str1);
访问字符串可以分为两种用例。第一种是你希望提取字符串的一部分。为此,你可以使用以下三种方法之一:left()、right() 和 mid(),它们从字符串的开始或末尾返回指定数量的字符,或者从字符串中指定位置开始提取指定长度的子字符串:
QString original = QStringLiteral("abcdefghij");
QString l = original.left(3); // "abc"
QString r = original.right(2); // "ij"
QString m = original.mid(2, 5); // "cdefg"
第二种用例是你希望访问字符串的单个字符。索引操作符在 QString 中的使用方式与 std::string 类似,返回一个副本或非 const 引用到由 QChar 类表示的给定字符,如下面的代码所示:
QString str = "foo";
QChar f = str[0]; // const
str[0] = 'g'; // non-const
此外,Qt 还提供了一个专门的方法——at(),它返回字符的副本:
QChar f = str.at(0);
小贴士
你应该优先使用 at() 而不是索引操作符来执行不修改字符的操作,因为这明确设置了操作。
字符串搜索和查找
功能的第二组与字符串搜索相关。你可以使用 startsWith()、endsWith() 和 contains() 等方法在字符串的开始、结束或任意位置搜索子字符串。可以通过使用 count() 方法检索字符串中子字符串的出现次数。
小贴士
注意,还有一个不带参数的 count() 方法,它返回字符串中的字符数。
如果你需要知道匹配的确切位置,可以使用 indexOf() 或 lastIndexOf() 来接收字符串中匹配发生的位置。第一个调用通过向前搜索工作,而另一个调用通过向后搜索。这些调用都接受两个可选参数——第二个参数确定搜索是否区分大小写(类似于 remove 的工作方式)。第一个参数是字符串中搜索开始的位臵。它让你能够找到给定子字符串的所有出现:
#include <QtDebug>
// ...
int pos = -1;
QString str = QStringLiteral("Orangutans like bananas.");
do {
pos = str.indexOf("an", pos+1);
qDebug() << "'an' found starts at position" << pos;
} while(pos!=-1);
字符串分解
还有另一组有用的字符串功能,这使得 QString 与 std::string 不同。那就是,将字符串切割成更小的部分,并从更小的片段构建更大的字符串。
很常见,一个字符串包含通过重复分隔符粘合在一起的子字符串。一个常见的情况是 逗号分隔值(CSV)格式,其中数据记录被编码在一个单独的字符串中,记录中的字段通过逗号分隔。虽然你可以使用你已知的函数(例如,indexOf)从记录中提取每个字段,但存在一种更简单的方法。QString 包含一个 split() 方法,它接受分隔符字符串作为参数,并返回一个由 Qt 中的 QStringList 类表示的字符串列表。然后,将记录分解成单独的字段就像调用以下代码一样简单:
QString record = "1,4,8,15,16,24,42";
QStringList fields = record.split(",");
for(int i=0; i< fields.count(); ++i){
qDebug() << fields.at(i);
}
这种方法的逆操作是 QStringList 类中存在的 join() 方法,它将列表中的所有项合并成一个字符串,并用给定的分隔符连接起来:
QStringList fields = { "1", "4", "8", "15", "16", "24", "42" }; // C++11 syntax!
QString record = fields.join(",");
数字与字符串之间的转换
QString 还提供了一些方便在文本和数值之间进行转换的方法。例如 toInt()、toDouble() 或 toLongLong() 可以轻松地从字符串中提取数值。除了 toDouble() 之外,它们都接受两个可选参数——第一个是一个指向 bool 变量的指针,根据转换是否成功将其设置为 true 或 false。第二个参数指定值的数值基数(例如,二进制、八进制、十进制或十六进制)。toDouble() 方法只接受一个 bool 指针来标记成功或失败,如下面的代码所示:
bool ok;
int v1 = QString("42").toInt(&ok, 10); // v1 = 42, ok = true
long long v2 = QString("0xFFFFFF").toInt(&ok, 16); // v2 = 16777215, ok = true
double v3 = QString("not really a number").toDouble(&ok); //v3 = 0.0, ok = false
一个名为 number() 的静态方法执行相反方向的转换——它接受一个数值和数值基数,并返回值的文本表示:
QString txt = QString::number(255, 16); // txt = "0xFF"
如果你必须在一个程序中同时使用 QString 和 std::string,QString 提供了 toStdString() 和 fromStdString() 方法来执行适当的转换。
小贴士
一些表示值的其他类也提供了到和从 QString 的转换。这样的一个类是 QDate,它表示一个日期并提供 fromString() 和 toString() 方法。
在字符串中使用参数
一个常见的任务是需要一个字符串,其内容需要是动态的,这样它的内容就依赖于某些外部变量的值——例如,你可能想通知用户正在复制的文件数量,显示“正在复制文件 1/2”或“正在复制文件 2/5”,这取决于表示当前文件和文件总数的计数器的值。可能会诱使你通过使用可用的方法之一将所有片段组装在一起来完成这项任务:
QString str = "Copying file " + QString::number(current) + " of "+QString::number(total);
这种方法有几个缺点;其中最大的问题是将字符串翻译成其他语言的问题(这个问题将在本章后面讨论),在这些语言中,它们的语法可能要求这两个参数的位置与英语不同。
相反,Qt 允许我们在字符串中指定位置参数,然后使用实值替换它们。字符串中的位置用 % 符号标记(例如,%1、%2 等),并通过调用 arg() 并传递用于替换字符串中下一个最低标记的值来替换它们。然后我们的文件复制消息构建代码变为:
QString str = QStringLiteral("Copying file %1 of %2")
.arg(current).arg(total);
arg 方法可以接受单个字符、字符串、整数和实数,其语法与 QString::number() 类似。
正则表达式
让我们简要地谈谈正则表达式——通常简称为regex或regexp。当你需要检查一个字符串或其部分是否与给定的模式匹配,或者当你想要在文本中找到特定的部分并可能提取它们时,你需要这些正则表达式。验证和查找/提取都是基于所谓的正则表达式模式,它描述了字符串必须具有的格式才能有效、可找到或可提取。由于这本书专注于 Qt,很遗憾没有时间深入探讨正则表达式。然而,这不是一个大问题,因为你可以在网上找到许多提供正则表达式介绍的优质网站。Qt 的 QRegExp 文档中也可以找到简短的介绍。
尽管正则表达式的语法有很多种,但 Perl 使用的语法已经成为事实上的标准。根据 QRegularExpression,Qt 提供了与 Perl 兼容的正则表达式。
注意
QRegularExpression 首次在 Qt 5 中引入。在之前的版本中,你会找到较老的 QRegExp 类。由于 QRegularExpression 更接近 Perl 标准,并且其执行速度比 QRegExp 快得多,我们建议尽可能使用 QRegularExpression。尽管如此,你仍然可以阅读有关正则表达式一般介绍的 QRegExp 文档。
是时候进行一个简单的问答游戏了
为了让你了解 QRegularExpression 的主要用法,让我们想象这个游戏:展示一个物体的照片给多个玩家看,每个玩家都必须估计物体的重量。估计值最接近实际重量的玩家获胜。估计将通过 QLineEdit 提交。由于你可以在行编辑中写任何东西,我们必须确保内容是有效的。
那么“有效”是什么意思呢?在这个例子中,我们定义一个介于 1 克和 999 公斤之间的值是有效的。了解这个规范后,我们可以构建一个正则表达式来验证格式。文本的第一部分是一个数字,可以是 1 到 999 之间的任何数字。因此,相应的模式看起来像 [1-9][0-9]{0,2},其中 [1-9] 允许并且要求恰好一个数字,除了零,零可以可选地后面跟最多两个数字,包括零。这通过 [0-9]{0,2} 来表达。输入的最后部分是重量的单位。使用如 (mg|g|kg) 这样的模式,我们允许重量以 毫克(mg)、克(g)或 公斤(kg)输入。通过 [ ]?,我们最终允许数字和单位之间有一个可选的空格。结合模式和相关 QRegularExpression 对象的构建,看起来是这样的:
QRegularExpression regex("[1-9][0-9]{0,2}[ ]? (mg|g|kg)");
regex.setPatternOptions(QRegularExpression:: CaseInsensitiveOption);
刚才发生了什么?
在第一行,我们构建了上述 QRegularExpression 对象,同时将正则表达式的模式作为参数传递给构造函数。我们也可以调用 setPattern() 来设置模式:
QRegularExpression regex;
regex.setPattern("[1-9][0-9]{0,2}[ ]?(mg|g|kg)");
这两种方法都是等效的。如果你仔细看看单位,你会看到现在单位只能以小写形式输入。然而,我们希望它也可以是大写或混合大小写。为了实现这一点,我们当然可以写 (mg|mG|Mg|MG|g|G|kg|kG|Kg|KG)。当你有更多单位时,这确实是一项艰巨的工作,而且很容易出错,所以我们选择了更干净、更易读的解决方案。在初始代码示例的第二行,你看到了答案:一个模式选项。我们使用了 setPatternOptions() 来设置 QRegularExpression::CaseInsensitiveOption 选项,该选项不尊重字符的大小写。当然,你还可以在 Qt 的 QRegularExpression::PatternOption 文档中了解一些更多选项。我们也可以将选项作为 QRegularExpression 构造函数的第二个参数传递,而不是调用 setPatternOptions():
QRegularExpression regex("[1-9][0-9]{0,2}[ ]?(mg|g|kg)",
QRegularExpression::CaseInsensitiveOption);
现在,让我们看看如何使用这个表达式来验证字符串的有效性。为了简单起见和更好的说明,我们简单地声明了一个名为 input 的字符串:
QString input = "23kg";
QRegularExpressionMatch match = regex.match(input);
bool isValid = match.hasMatch();
我们所需要做的就是调用 match(),传递我们想要检查的字符串。作为回报,我们得到一个 QRegularExpressionMatch 类型的对象,它包含所有进一步需要的信息——而不仅仅是检查有效性。然后,我们可以通过 QRegularExpressionMatch::hasMatch() 确定输入是否匹配我们的标准,因为它在找到模式时返回 true。当然,如果没有找到模式,则返回 false。
仔细的读者肯定已经注意到我们的模式还没有完全完成。hasMatch() 方法也会在将模式与 “foo 142g bar” 进行匹配时返回 true。因此,我们必须定义模式是从匹配字符串的开始到结束进行检查的。这是通过 \A 和 \z 锚点来完成的。前者标记字符串的开始,后者标记字符串的结束。在使用这样的锚点时,不要忘记转义斜杠。正确的模式如下所示:
QRegularExpression regex("\\A[1-9][0-9]{0,2}[ ]?(mg|g|kg)\\z",
QRegularExpression::CaseInsensitiveOption);
从字符串中提取信息
在我们检查发送的猜测是否良好形成之后,我们必须从字符串中提取实际的重量。为了能够轻松比较不同的猜测,我们还需要将所有值转换为共同的参考单位。在这种情况下,应该是毫克,这是最低的单位。那么,让我们看看 QRegularExpressionMatch 可以为我们提供什么来完成任务。
使用 capturedTexts(),我们得到一个包含模式捕获组的字符串列表。在我们的例子中,这个列表将包含 “23kg” 和 “kg”。列表的第一个元素总是被模式完全匹配的字符串,然后是所有由使用的括号捕获的子字符串。由于我们缺少实际的数字,我们必须将模式的开始更改为 ([1-9][0-9]{0,2})。现在,列表的第二个元素是数字,第三个元素是单位。因此,我们可以写出以下内容:
int getWeight(const QString &input) {
QRegularExpression regex("\\A([1-9][0-9]{0,2}) [ ]?(mg|g|kg)\\z");
regex.setPatternOptions(QRegularExpression:: CaseInsensitiveOption);
QRegularExpressionMatch match = regex.match(input);
if(match.hasMatch()) {
const QString number = match.captured(1);
int weight = number.toInt();
const QString unit = match.captured(2).toLower();
if (unit == "g") {
weight *= 1000;
} else if (unit == "kg") {
weight *= 1000000 ;
}
return weight;
} else {
return -1;
}
}
在函数的前两行中,我们设置了模式和它的选项。然后,我们将它与传递的参数进行匹配。如果 QRegularExpressionMatch::hasMatch() 返回 true,则输入有效,我们提取数字和单位。我们不是通过调用 capturedTexts() 获取捕获文本的整个列表,而是通过调用 QRegularExpressionMatch::captured() 直接查询特定元素。传递的整数参数表示列表中元素的位位置。因此,调用 captured(1) 返回匹配的数字作为一个 QString。
小贴士
QRegularExpressionMatch::captured() 也接受 QString 作为参数类型。如果你在模式中使用了命名组,这会很有用,例如,如果你写的是 (?<number>[1-9][0-9]{0,2}),那么你可以通过调用 match.captured("number") 来获取数字。如果模式很长或者未来有很高的概率会添加更多的括号,命名组会很有用。请注意,稍后添加一个组将会将所有后续组的索引移动 1 位,你将不得不调整你的代码!
为了能够使用提取出的数字进行计算,我们需要将 QString 转换为整数。这是通过调用 QString::toInt() 来完成的。转换的结果随后存储在 weight 变量中。接下来,我们获取单位并将其转换为小写字母。这样,例如,我们可以轻松地确定用户的猜测是否以克为单位,只需将单位与小写 “g” 进行比较。我们不需要关心大写 “G” 或 “KG”、“Kg” 和不寻常的 “kG”(千克)。
为了得到标准化的重量(毫克),我们需要将 weight 乘以 1,000 或 1,000,000,具体取决于这是否以 g 或 kg 表示。最后,我们返回这个标准化的重量。如果字符串格式不正确,我们返回 -1 来指示给定的猜测无效。然后调用者负责确定哪个玩家的猜测是最好的。
注意
注意你选择的整数类型是否可以处理重量的值。在我们的例子中,对于 32 位系统,1,000,000,000 是可以由有符号整数持有的最大可能值。如果你不确定你的代码是否会在 32 位系统上编译,使用 qint32,它在 Qt 支持的每个系统上都是保证为 32 位整数的,允许十进制表示法。
作为练习,尝试扩展示例,允许小数数字,例如 23.5g 是一个有效的猜测。为了实现这一点,你必须修改模式以输入小数数字,并且你还必须处理 double 而不是 int 作为标准化的重量。
查找所有模式出现
最后,让我们看看如何找到字符串中的所有数字,即使是那些以零开头的数字:
QString input = "123 foo 09 1a 3";
QRegularExpression regex("\\b[0-9]+\\b");
QRegularExpressionMatchIterator i = regex.globalMatch(input);
while (i.hasNext()) {
QRegularExpressionMatch match = i.next();
qWarning() << match.capturedTexts();
}
input QString 实例包含一个示例文本,我们希望在其中找到所有数字。由于“foo”以及“1a”不是有效的数字,因此不应通过该模式找到这些变量。因此,我们设置了定义我们至少需要一个数字 [0-9]+,并且这个数字——或者这些数字——应该被单词边界 \b 包围的模式。请注意,您必须转义斜杠。使用此模式,我们初始化 QRegularExpression 对象,并在其上调用 globalMatch()。在传递的参数内部,将搜索该模式。这次,我们没有返回 QRegularExpressionMatch,而是返回 QRegularExpressionMatchIterator 类型的迭代器。由于 QRegularExpressionMatchIterator 的行为类似于 Java 迭代器,具有 hasNext() 方法,我们检查是否存在进一步的匹配,如果存在,则通过调用 next() 获取下一个匹配。返回的匹配类型是 QRegularExpressionMatch,这是您已经知道的。
小贴士
如果你需要在 while 循环内部了解下一个匹配项,你可以使用 QRegularExpressionMatchIterator::peekNext() 来接收它。这个函数的优点是它不会移动迭代器。
这样,你可以遍历字符串中的所有模式出现。如果你,例如,想在文本中突出显示搜索字符串,这将很有帮助。
我们的示例将给出输出:("123"), ("09") and ("3")。
考虑到这只是一个关于正则表达式的简要介绍,我们鼓励你阅读文档中关于 QRegularExpression、QRegularExpressionMatch 和 QRegularExpressionMatchIterator 的 详细描述 部分。正则表达式非常强大且有用,因此,在你的日常编程生活中,你可以从正则表达式的深刻知识中受益!
数据存储
在实现游戏时,你通常会需要处理持久数据——你需要存储保存的游戏数据、加载地图等等。为此,你必须了解让你能够使用存储在数字媒体上的数据的机制。
文件和设备
访问数据的最基本和底层机制是从文件中保存和加载它。虽然你可以使用 C 和 C++ 提供的经典的文件访问方法,如 stdio 或 iostream,但 Qt 提供了对文件抽象的自己的包装,它隐藏了平台相关的细节,并提供了一个在所有平台上以统一方式工作的干净 API。
当使用文件时,你将工作的两个基本类是 QDir 和 QFile。前者表示目录的内容,允许你遍历文件系统,创建和删除目录,最后,访问特定目录中的所有文件。
遍历目录
使用 QDir 遍历目录非常简单。首先要做的事情是首先有一个 QDir 实例。最简单的方法是将目录路径传递给 QDir 构造函数。
小贴士
Qt 以平台无关的方式处理文件路径。尽管 Windows 上的常规目录分隔符是反斜杠字符(\),而其他平台上是正斜杠(/),但 Qt 在 Windows 平台上也接受正斜杠作为目录分隔符。因此,当将路径传递给 Qt 函数时,你始终可以使用/来分隔目录。
你可以通过调用QDir::separator()静态函数来学习当前平台的本地目录分隔符。你可以使用QDir::toNativeSeparators()和QDir::fromNativeSeparators()函数在本地和非本地分隔符之间进行转换。
Qt 提供了一些静态方法来访问一些特殊目录。以下表格列出了这些特殊目录及其访问函数:
| 访问函数 | 目录 |
|---|---|
QDir::current() |
当前工作目录 |
QDir::home() |
当前用户的家目录 |
QDir::root() |
根目录——通常在 Unix 中为/,在 Windows 中为C:\ |
QDir::temp() |
系统临时目录 |
当你已经有一个有效的QDir对象时,你可以开始在不同目录之间移动。为此,你可以使用cd()和cdUp()方法。前者移动到命名的子目录,而后者移动到父目录。
要列出特定目录中的文件和子目录,你可以使用entryList()方法,该方法返回目录中符合entryList()传入的标准的条目列表。此方法有两个重载版本。基本版本接受一个标志列表,这些标志对应于条目需要具有的不同属性才能包含在结果中,以及一组标志,用于确定条目在集合中包含的顺序。另一个重载版本还接受一个QStringList格式的文件名模式列表作为其第一个参数。最常用的筛选和排序标志如下所示:
| 筛选标志 |
|---|
QDir::Dirs, QDir::Files, QDir::Drives, QDir::AllEntries |
QDir::AllDirs |
QDir::Readable, QDir::Writable, QDir::Executable |
QDir::Hidden, QDir::System |
| 排序标志 |
QDir::Unsorted |
QDir::Name, QDir::Time, QDir::Size, QDir::Type |
QDir::DirsFirst, QDir::DirsLast |
这里是一个示例调用,它返回用户home目录中所有按大小排序的 JPEG 文件:
QDir dir = QDir::home();
QStringList nameFilters;
nameFilters << QStringLiteral("*.jpg") << QStringLiteral("*.jpeg");
QStringList entries = dir.entryList(nameFilters,
QDir::Files|QDir::Readable, QDir::Size);
小贴士
<<运算符是一种简单快捷的方法,可以将条目追加到QStringList。
获取基本文件的访问权限
一旦知道了文件的路径(无论是通过使用 QDir::entryList()、来自外部源,甚至是在代码中硬编码文件路径),就可以将其传递给 QFile 以接收一个作为文件句柄的对象。在可以访问文件内容之前,需要使用 open() 方法打开文件。此方法的基本变体需要一个模式,其中我们需要打开文件。以下表格解释了可用的模式:
| 模式 | 描述 |
|---|---|
ReadOnly |
此文件可读 |
WriteOnly |
此文件可写入 |
ReadWrite |
此文件可读和写 |
Append |
所有数据写入都将写入文件末尾 |
Truncate |
如果文件存在,则在打开之前删除其内容 |
Text |
本地行结束符转换为 \n 并返回 |
Unbuffered |
该标志防止系统对文件进行缓冲 |
open() 方法根据文件是否被打开返回 true 或 false。可以通过在文件对象上调用 isOpen() 来检查文件当前的状态。一旦文件打开,就可以根据打开文件时传递的选项来读取或写入。读取和写入是通过 read() 和 write() 方法完成的。这些方法有很多重载,但我建议您专注于使用那些接受或返回 QByteArray 对象的变体,它本质上是一系列字节——它可以存储文本和非文本数据。如果您正在处理纯文本,那么 write 方法的一个有用的重载是直接接受文本作为输入的变体。只需记住,文本必须是空或终止的。当从文件读取时,Qt 提供了其他一些可能在某些情况下很有用的方法。其中一种方法是 readLine(),它尝试从文件中读取,直到遇到新行字符。如果您与告诉您是否已到达文件末尾的 atEnd() 方法一起使用,您就可以实现逐行读取文本文件:
QStringList lines;
while(!file.atEnd()) {
QByteArray line = file.readLine();
lines.append(QString::fromUtf8(line));
}
另一种有用的方法是 readAll(),它简单地返回从文件指针当前位置开始直到文件末尾的内容。
你必须记住,在使用这些辅助方法时,如果你不知道文件包含多少数据,你应该非常小心。可能会发生这种情况,当你逐行读取或尝试一次性将整个文件读入内存时,你会耗尽你的进程可用的内存量(你可以通过在QFile实例上调用size()来检查文件的大小)。相反,你应该分步骤处理文件数据,一次只读取所需的量。这使得代码更复杂,但使我们能够更好地管理可用资源。如果你需要经常访问文件的一部分,你可以使用map()和unmap()调用,这些调用将文件的一部分添加到或从内存地址映射中移除,然后你可以像使用常规字节数组一样使用它:
QFile f("myfile");
if(!f.open(QFile::ReadWrite)) return;
uchar *addr = f.map(0, f.size());
if(!addr) return;
f.close();
doSomeComplexOperationOn(addr);
f.unmap(addr);
设备
QFile实际上是QIODevice的子类,QIODevice是一个 Qt 接口,用于抽象与读取和写入相关的实体。有两种类型的设备:顺序访问设备和随机访问设备。QFile属于后者——它具有开始、结束、大小和当前位置的概念,用户可以通过seek()方法更改这些概念。顺序设备,如套接字和管道,表示数据流——没有方法可以回滚流或检查其大小;你只能按顺序逐个读取数据——一次读取一部分,你可以检查你目前距离数据末尾有多远。
所有 I/O 设备都可以打开和关闭。它们都实现了open()、read()和write()接口。向设备写入数据会将数据排队等待写入;当数据实际写入时,会发出bytesWritten()信号,该信号携带写入设备的数据量。如果在顺序设备中还有更多数据可用,它会发出readyRead()信号,通知你如果现在调用read,你可以期望从设备接收一些数据。
实施加密数据设备的行动时间
让我们实现一个非常简单的设备,它使用一个非常简单的算法——凯撒密码来加密或解密通过它的数据。它的作用是在加密时,将明文中的每个字符按密钥定义的字符数进行移位,解密时进行相反的操作。因此,如果密钥是2,明文字符是a,密文就变成了c。使用密钥4解密z将得到值v。
我们将首先创建一个新的空项目,并添加一个从QIODevice派生的类。该类的基本接口将接受一个整数密钥并设置一个作为数据源或目的地的底层设备。这些都是你应该已经理解的简单编码,因此不需要任何额外的解释,如下所示:
class CaesarCipherDevice : public QIODevice
{
Q_OBJECT
Q_PROPERTY(int key READ key WRITE setKey)
public:
explicit CaesarCipherDevice(QObject *parent = 0) : QIODevice(parent) {
m_key = 0;
m_device = 0;
}
void setBaseDevice(QIODevice *dev) { m_device = dev; }
QIODevice *baseDevice() const { return m_device; }
void setKey(int k) { m_key = k; }
inline int key() const { return m_key; }
private:
int m_key;
QIODevice *m_device;
};
下一步是确保如果没有设备可供操作(即当 m_device == 0 时),则不能使用该设备。为此,我们必须重新实现 QIODevice::open() 方法,并在我们想要阻止操作我们的设备时返回 false:
bool open(OpenMode mode) {
if(!baseDevice())
return false;
if(baseDevice()->openMode() != mode)
return false;
return QIODevice::open(mode);
}
该方法接受用户想要以何种模式打开设备。我们在调用将设备标记为打开的基类实现之前执行一个额外的检查,以验证基本设备是否以相同的模式打开。
要有一个完全功能的设备,我们仍然需要实现两个受保护的纯虚方法,这些方法执行实际的读取和写入操作。这些方法在需要时由 Qt 从类的其他方法中调用。让我们从 writeData() 开始,它接受一个指向包含数据的缓冲区的指针以及该缓冲区的大小:
qint64 CaesarCipherDevice::writeData(const char *data, qint64 len) {
QByteArray ba(data, len);
for(int i=0;i<len;++i)
ba.data()[i] += m_key;
int written = m_device->write(ba);
emit bytesWritten(written);
return written;
}
首先,我们将数据复制到一个局部字节数组中。然后,我们遍历数组,将密钥的值添加到每个字节(这实际上执行了加密)。最后,我们尝试将字节数组写入底层设备。在通知调用者实际写入的数据量之前,我们发出一个携带相同信息的信号。
我们需要实现的最后一个方法是执行解密操作,通过从基本设备读取并给数据中的每个单元格添加密钥。这是通过实现 readData() 来完成的,它接受一个指向方法需要写入的缓冲区的指针以及缓冲区的大小。代码与 writeData() 非常相似,只是我们是在减去密钥值而不是添加它:
qint64 CaesarCipherDevice::readData(char *data, qint64 maxlen) {
QByteArray baseData = m_device->read(maxlen);
const int s = baseData.size();
for(int i=0;i<s;++i)
data[i] = baseData[i]-m_key;
return s;
}
首先,我们从底层设备读取尽可能多的数据,将其存储在字节数组中。然后,我们遍历数组并将数据缓冲区的后续字节设置为解密值。最后,我们返回实际读取的数据量。
一个简单的 main() 函数,可以测试该类,如下所示:
int main(int argc, char **argv) {
QByteArray ba = "plaintext";
QBuffer buf;
buf.open(QIODevice::WriteOnly);
CaesarCipherDevice encrypt;
encrypt.setKey(3);
encrypt.setBaseDevice(&buf);
encrypt.open(buf.openMode());
encrypt.write(ba);
qDebug() << buf.data();
CaesarCipherDevice decrypt;
decrypt.setKey(3);
decrypt.setBaseDevice(&buf);
buf.open(QIODevice::ReadOnly);
decrypt.open(buf.openMode());
qDebug() << decrypt.readAll();
return 0;
}
我们使用实现 QIODevice API 并作为 QByteArray 或 QString 适配器的 QBuffer 类。
发生了什么?
我们创建了一个加密对象,并将其密钥设置为 3。我们还告诉它使用一个 QBuffer 实例来存储处理后的内容。在打开它以供写入后,我们向其中发送了一些数据,这些数据被加密并写入基本设备。然后,我们创建了一个类似的设备,再次将相同的缓冲区作为基本设备传递,但现在我们打开设备以供读取。这意味着基本设备包含密文。在此之后,我们从设备中读取所有数据,这导致从缓冲区中读取数据,解密它,并将数据返回以便写入调试控制台。
尝试一下英雄 - 一个凯撒密码的图形用户界面
你可以通过实现一个完整的 GUI 应用程序来结合你已知的知识,该应用程序能够使用我们刚刚实现的 Caesar cipher QIODevice类加密或解密文件。记住,QFile也是QIODevice,所以你可以直接将其指针传递给setBaseDevice()。
这只是你的起点。QIODevice API 非常丰富,包含许多虚拟方法,因此你可以在子类中重新实现它们。
文本流
现今计算机产生的大部分数据都是基于文本的。你可以使用你已知的机制创建此类文件——打开QFile进行写入,使用QString::arg()将所有数据转换为字符串,可选地使用QTextCodec对字符串进行编码,并通过调用write将生成的字节写入文件。然而,Qt 提供了一个很好的机制,可以自动为你完成大部分工作,其工作方式类似于标准 C++ iostream类。QTextStream类以流式方式操作任何QIODevice API。你可以使用<<运算符向流发送标记,它们将被转换为字符串,用空格分隔,使用你选择的编解码器编码,并写入底层设备。它也可以反过来工作;使用>>运算符,你可以从文本文件中流式传输数据,透明地将字符串转换为适当的变量类型。如果转换失败,你可以通过检查status()方法的结果来发现它——如果你得到ReadPastEnd或ReadCorruptData,这意味着读取失败。
提示
虽然QIODevice是QTextStream操作的主要类,但它也可以操作QString或QByteArray,这使得它对我们来说很有用,可以组合或解析字符串。
使用QTextStream很简单——你只需传递给它你想要其操作的设备,然后就可以开始了。流接受字符串和数值:
QFile file("output.txt");
file.open(QFile::WriteOnly|QFile::Text);
QTextStream stream(&file);
stream << "Today is " << QDate::currentDate().toString() << endl;
QTime t = QTime::currentTime();
stream << "Current time is " << t.hour() << " h and " << t.minute() << "m." << endl;
除了将内容直接导入流中,流还可以接受多个操作符,例如endl,这些操作符会直接或间接影响流的行为。例如,你可以告诉流以十进制显示一个数字,并以大写字母显示另一个十六进制数字,如下面的代码所示(代码中突出显示的都是操作符):
for(int i=0;i<10;++i) {
int num = qrand() % 100000; // random number between 0 and 99999
stream << dec << num << showbase << hex << uppercasedigits << num << endl;
}
这并不是QTextStream功能的终点。它还允许我们通过定义列宽和对齐方式以表格形式显示数据。假设你有一组游戏玩家记录,其结构如下:
struct Player {
QString name;
qint64 experience;
QPoint position;
char direction;
};
QList<Player> players;
让我们将此类信息以表格形式输出到文件中:
QFile file("players.txt");
file.open(QFile::WriteOnly|QFile::Text);
QTextStream stream(&file);
stream << center;
stream << qSetFieldWidth(16) << "Player" << qSetFieldWidth(0) << " ";
stream << qSetFieldWidth(10) << "Experience" << qSetFieldWidth(0) << " ";
stream << qSetFieldWidth(13) << "Position" << qSetFieldWidth(0) << " ";
stream << "Direction" << endl;
for(int i=0;i<players.size();++i) {
const Player &p = players.at(i);
stream << left << qSetFieldWidth(16) << p.name << qSetFieldWidth(0) << " ";
stream << right << qSetFieldWidth(10) << p.experience << qSetFieldWidth(0) << " ";
stream << right << qSetFieldWidth(6) << p.position.x() << qSetFieldWidth(0) << " " << qSetFieldWidth(6) << p.position.y() << qSetFieldWidth(0) << " ";
stream << center << qSetFieldWidth(10);
switch(p.direction) {
case 'n' : stream << "north"; break;
case 's' : stream << "south"; break;
case 'e' : stream << "east"; break;
case 'w' : stream << "west"; break;
default: stream << "unknown"; break;
}
stream << qSetFieldWidth(0) << endl;
}
运行程序后,你应该得到一个类似于以下截图所示的结果:
https://github.com/OpenDocCN/freelearn-c-cpp-zh/raw/master/docs/gm-prog-qt-bgd/img/8874OS_04_01.jpg
关于QTextStream的最后一件事是,它可以操作标准 C 文件结构,这使得我们可以使用QTextStream,例如写入stdout或从stdin读取,如下面的代码所示:
QTextStream qout(stdout);
qout << "This text goes to process standard output." << endl;
数据序列化
更多的时候,我们必须以设备无关的方式存储对象数据,以便以后可以恢复,可能在不同的机器上,具有不同的数据布局等等。在计算机科学中,这被称为序列化。Qt 提供了几种序列化机制,现在我们将简要地看看其中的一些。
二进制流
如果你从远处看QTextStream,你会注意到它真正做的是将数据序列化和反序列化到文本格式。它的近亲是QDataStream类,它处理任意数据的序列化和反序列化到二进制格式。它使用自定义数据格式以平台无关的方式存储和检索QIODevice中的数据。它存储足够的数据,以便在一个平台上编写的流可以在不同的平台上成功读取。
QDataStream的使用方式与QTextStream类似——运算符<<和>>用于将数据重定向到或从流中。该类支持大多数内置的 Qt 类型,因此你可以直接操作QColor、QPoint或QStringList等类:
QFile file("outfile.dat");
file.open(QFile::WriteOnly|QFile::Truncate);
QDataStream stream(&file);
double dbl = 3.14159265359;
QColor color = Qt::red;
QPoint point(10, -4);
QStringList stringList = QStringList() << "foo" << "bar";
stream << dbl << color << point << stringList;
如果你想序列化自定义数据类型,你可以通过实现适当的重定向运算符来教会QDataStream这样做。
是时候进行动作了——自定义结构的序列化
让我们通过实现使用QDataStream序列化包含我们用于文本流传输的玩家信息的简单结构的函数来做一个小的练习:
struct Player {
QString name;
qint64 experience;
QPoint position;
char direction;
};
为了实现这一点,需要实现两个函数,这两个函数都返回一个之前作为调用参数传入的QDataStream引用。除了流本身之外,序列化运算符还接受一个对正在保存的类的常量引用。最简单的实现是将每个成员流式传输到流中,然后返回流:
QDataStream& operator<<(QDataStream &stream, const Player &p) {
stream << p.name;
stream << p.experience;
stream << p.position;
stream << p.direction;
return stream;
}
作为补充,反序列化是通过实现一个接受对由从流中读取的数据填充的结构的可变引用的重定向运算符来完成的:
QDataStream& operator>>(QDataStream &stream, Player &p) {
stream >> p.name;
stream >> p.experience;
stream >> p.position;
stream >> p.direction;
return stream;
}
再次强调,最后返回的是流本身。
刚才发生了什么?
我们提供了两个独立的函数,用于定义Player类到QDataStream实例的重定向运算符。这使得你的类可以使用 Qt 提供的机制进行序列化和反序列化。
XML 流
XML 已经成为存储层次化数据的最受欢迎的标准之一。尽管它冗长且难以用肉眼阅读,但它几乎在需要数据持久化的任何领域都被使用,因为它非常容易由机器读取。Qt 提供了两个模块来支持读取和写入 XML 文档。首先,QtXml模块通过文档对象模型(DOM)标准,使用QDomDocument、QDomElement等类提供访问。我们在这里不会讨论这种方法,因为现在推荐的方法是使用来自QtCore模块的流式类。QDomDocument的一个缺点是它要求我们在解析之前将整个 XML 树加载到内存中。在某些情况下,与流式方法相比,DOM 方法的易用性可以弥补这一点,所以如果你觉得找到了合适的任务,可以考虑使用它。
小贴士
如果你想在 Qt 中使用 DOM 访问 XML,请记住在项目配置文件中添加QT += xml行以启用QtXml模块。
正如之前所说,我们将关注由QXmlStreamReader和QXmlStreamWriter类实现的流式方法。
开始行动时间 - 实现玩家数据的 XML 解析器
在这个练习中,我们将创建一个解析器来填充表示玩家及其在 RPG 游戏中的库存数据的结构:
struct InventoryItem {
enum Type { Weapon, Armor, Gem, Book, Other } type;
QString subType;
int durability;
};
struct Player {
QString name;
QString password;
int experience;
int hitPoints;
QList<Item> inventory;
QString location;
QPoint position;
};
struct PlayerInfo {
QList<Player> players;
};
将以下文档保存在某个地方。我们将使用它来测试解析器是否可以读取它:
<PlayerInfo>
<Player hp="40" exp="23456">
<Name>Gandalf</Name>
<Password>mithrandir</Password>
<Inventory>
<InvItem type="weapon" durability="3">
<SubType>Long sword</SubType>
</InvItem>
<InvItem type="armor" durability="10">
<SubType>Chain mail</SubType>
</InvItem>
</Inventory>
<Location name="room1">
<Position x="1" y="0"/>
</Location>
</Player>
</PlayerInfo>
让我们创建一个名为PlayerInfoReader的类,它将包装QXmlStreamReader并公开一个解析器接口,用于PlayerInfo实例。该类将包含两个私有成员——读取器本身以及一个PlayerInfo实例,它作为当前正在读取的数据的容器。我们将提供一个result()方法,在解析完成后返回此对象,如下面的代码所示:
class PlayerInfoReader {
public:
PlayerInfoReader(QIODevice *);
inline const PlayerInfo& result() const { return m_pinfo; }
private:
QXmlStreamReader reader;
PlayerInfo m_pinfo;
};
类构造函数接受一个QIODevice指针,读者将使用它来按需检索数据。构造函数很简单,因为它只是将设备传递给reader对象:
PlayerInfoReader(QIODevice *device) {
reader.setDevice(device);
}
在我们开始解析之前,让我们准备一些代码来帮助我们处理这个过程。首先,让我们向类中添加一个枚举类型,它将列出所有可能的令牌——我们希望在解析器中处理的标签名称:
enum Token {
T_Invalid = -1,
T_PlayerInfo, /* root tag */
T_Player, /* in PlayerInfo */
T_Name, T_Password, T_Inventory, T_Location, /* in Player */
T_Position, /* in Location */
T_InvItem /* in Inventory */
};
要使用这些标签,我们将在类中添加一个静态方法,它根据其文本表示返回令牌类型:
static Token PlayerInfoReader::tokenByName(const QStringRef &r) {
static QStringList tokenList = QStringList() << "PlayerInfo" << "Player"
<< "Name" << "Password"
<< "Inventory" << "Location"
<< "Position" << "InvItem";
int idx = tokenList.indexOf(r.toString());
return (Token)idx;
}
你可以注意到我们正在使用一个名为QStringRef的类。它代表一个字符串引用——现有字符串中的子串,并且以避免昂贵的字符串构造的方式实现;因此,它非常快。我们在这里使用这个类是因为这是QXmlStreamReader报告标签名的方式。在这个静态方法中,我们将字符串引用转换为真实字符串,并尝试将其与已知标签列表进行匹配。如果匹配失败,则返回-1,这对应于我们的T_Invalid令牌。
现在,让我们添加一个入口点来启动解析过程。添加一个公共的read方法,它初始化数据结构并对输入流进行初始检查:
bool PlayerInfoReader::read() {
m_pinfo = PlayerInfo();
if(reader.readNextStartElement() && tokenByName(reader.name()) == T_PlayerInfo) {
return readPlayerInfo();
} else {
return false;
}
}
在清除数据结构后,我们在读取器上调用readNextStartElement(),使其找到第一个元素的起始标签,并且如果找到了,我们检查文档的根标签是否是我们期望的。如果是这样,我们调用readPlayerInfo()方法并返回其结果,表示解析是否成功。否则,我们退出,报告错误。
QXmlStreamReader子类通常遵循相同的模式。每个解析方法首先检查它是否操作的是它期望找到的标签。然后,它迭代所有起始元素,处理它所知道的元素,并忽略所有其他元素。这种做法使我们能够保持向前兼容性,因为较旧解析器会静默跳过文档新版本中引入的所有标签。
现在,让我们实现readPlayerInfo方法:
bool readPlayerInfo() {
if(tokenByName(reader.name()) != T_PlayerInfo)
return false;
while(reader.readNextStartElement()) {
if(tokenByName(reader.name()) == T_Player) {
Player p = readPlayer();
m_pinfo.players.append(p);
} else
reader.skipCurrentElement();
}
return true;
}
在验证我们正在处理PlayerInfo标签后,我们迭代当前标签的所有起始子元素。对于其中的每一个,我们检查它是否是Player标签,并调用readPlayer()来进入单个玩家解析数据级别的解析。否则,我们调用skipCurrentElement(),这将快速前进流,直到遇到匹配的结束元素。
readPlayer()的结构类似;然而,它更复杂,因为我们还想要从Player标签本身的属性中读取数据。让我们逐部分查看这个函数:
Player readPlayer() {
if(tokenByName(reader.name()) != T_Player) return Player();
Player p;
const QXmlStreamAttributes& playerAttrs = reader.attributes();
p.hitPoints = playerAttrs.value("hp").toString().toInt();
p.experience = playerAttrs.value("exp").toString().toInt();
在检查正确的标签后,我们获取与打开标签关联的属性列表,并请求我们感兴趣的两种属性的值。之后,我们循环所有子标签,并根据标签名填充Player结构。通过将标签名转换为令牌,我们可以使用switch语句来整洁地组织代码,以便从不同的标签类型中提取信息,如下面的代码所示:
while(reader.readNextStartElement()) {
Token t = tokenByName(reader.name());
switch(t) {
case Name: p.name = reader.readElementText(); break;
case Password: p.password = reader.readElementText(); break;
case Inventory: p.inventory = readInventory(); break;
如果我们对标签的文本内容感兴趣,我们可以使用readElementText()来提取它。此方法读取直到遇到关闭标签,并返回其内的文本。对于Inventory标签,我们调用专门的readInventory()方法。
对于Location标签,代码比之前更复杂,因为我们再次进入读取子标签,提取所需信息并跳过所有未知标签:
case T_Location: {
p.location = reader.attributes().value("name").toString();
while(reader.readNextStartElement()) {
if(tokenByName(reader.name()) == T_Position) {
const QXmlStreamAttributes& attrs = reader.attributes();
p.position.setX(attrs.value("x").toString().toInt());
p.position.setY(attrs.value("y").toString().toInt());
reader.skipCurrentElement();
} else
reader.skipCurrentElement();
}
}; break;
default:
reader.skipCurrentElement();
}
}
return p;
}
最后一个方法的结构与上一个类似——遍历所有标签,跳过我们不想处理的标签(即不是存货项目的所有标签),填充存货项目数据结构,并将项目添加到已解析项目列表中,如下面的代码所示:
QList<InventoryItem> readInventory() {
QList<InventoryItem> inventory;
while(reader.readNextStartElement()) {
if(tokenByName(reader.name()) != T_InvItem) {
reader.skipCurrentElement();
continue;
}
InventoryItem item;
const QXmlStreamAttributes& attrs = reader.attributes();
item.durability = attrs.value("durability").toString().toInt();
QStringRef typeRef = attrs.value("type");
if(typeRef == "weapon") {
item.type = InventoryItem::Weapon;
} else if(typeRef == "armor") {
item.type = InventoryItem::Armor;
} else if(typeRef == "gem") {
item.type = InventoryItem::Gem;
} else if(typeRef == "book") {
item.type = InventoryItem::Book;
} else item.type = InventoryItem::Other;
while(reader.readNextStartElement()) {
if(reader.name() == "SubType")
item.subType = reader.readElementText();
else
reader.skipCurrentElement();
}
inventory << item;
}
return inventory;
}
在你的项目main()函数中,编写一些代码来检查解析器是否工作正确。你可以使用qDebug()语句来输出列表的大小和变量的内容。以下代码是一个示例:
qDebug() << "Count:" << playerInfo.players.count();
qDebug() << "Size of inventory:" << playerInfo.players.first().inventory.size();
qDebug() << "Room: " << playerInfo.players.first().location << playerInfo.players.first().position;
刚才发生了什么?
你刚才编写的代码实现了 XML 数据的完整自顶向下解析器。首先,数据通过一个分词器,它返回比字符串更容易处理的标识符。然后,每个方法都可以轻松检查它接收到的标记是否是当前解析阶段的可接受输入。根据子标记,确定下一个解析函数,并解析器下降到较低级别,直到没有下降的地方。然后,流程向上回退一级并处理下一个子项。如果在任何点上发现未知标签,它将被忽略。这种方法支持一种情况,即新版本的软件引入了新的标签到文件格式规范中,但旧版本的软件仍然可以通过跳过所有它不理解的标签来读取文件。
尝试一下英雄——玩家数据的 XML 序列化器
现在你已经知道了如何解析 XML 数据,你可以创建其互补部分——一个模块,它将使用QXmlStreamWriter将PlayerInfo结构序列化为 XML 文档。为此,你可以使用writeStartDocument()、writeStartElement()、writeCharacters()和writeEndElement()等方法。验证你用代码保存的文档是否可以用我们共同实现的解析器进行解析。
JSON 文件
JSON代表JavaScript 对象表示法,这是一种流行的轻量级文本格式,用于以人类可读的形式存储面向对象的数据。它源自 JavaScript,在那里它是存储对象信息的原生格式;然而,它被广泛应用于许多编程语言,并且是网络数据交换的流行格式。一个简单的 JSON 格式定义如下:
{
"name": "Joe",
"age": 14,
"inventory: [
{ "type": "gold; "amount": "144000" },
{ "type": "short_sword"; "material": "iron" }
]
}
JSON 可以表示两种类型的实体:对象(用大括号括起来)和数组(用方括号括起来),其中对象被定义为键值对的集合,其中值可以是简单的字符串、对象或数组。在先前的例子中,我们有一个包含三个属性的对象——名称、年龄和存货。前两个属性是简单值,最后一个属性是一个包含两个对象且每个对象有两个属性的数组。
Qt 可以使用QJsonDocument类创建和读取 JSON 描述。可以使用QJsonDocument::fromJson()静态方法从 UTF-8 编码的文本中创建一个文档,稍后可以使用toJson()方法将其再次存储为文本形式。由于 JSON 的结构与QVariant(也可以使用QVariantMap来存储键值对,使用QVariantList来存储数组)非常相似,因此也存在一组fromVariant()和toVariant()调用方法来实现到这个类的转换。一旦创建了一个 JSON 文档,就可以使用isArray和isObject调用之一来检查它是否表示一个对象或数组。然后,可以使用toArray和toObject方法将文档转换为QJsonArray或QJsonObject。
QJsonObject是一种可迭代的类型,可以查询其键列表(使用keys())或询问特定键的值(使用value()方法)。值使用QJsonValue类表示,它可以存储简单值、数组或对象。可以使用insert()方法向对象添加新属性,该方法接受一个字符串形式的键,可以将值作为QJsonValue添加,并可以使用remove()方法移除现有属性。
QJsonArray也是一种可迭代的类型,它包含一个经典的列表 API——它包含append()、insert()、removeAt()、at()和size()等方法来操作数组中的条目,再次以QJsonValue作为项目类型。
开始行动——玩家数据 JSON 序列化器
我们接下来的练习是创建一个与我们在 XML 练习中使用的PlayerInfo结构相同的序列化器,但这次目标数据格式将是 JSON。
首先,创建一个PlayerInfoJSON类,并给它一个类似于以下代码的接口:
class PlayerInfoJSON {
public:
PlayerInfoJSON(){}
QByteArray writePlayerInfo(const PlayerInfo &pinfo) const;
};
真正需要实现的是writePlayerInfo方法。这个方法将使用QJsonDocument::fromVariant()来进行序列化;因此,我们真正需要做的是将我们的玩家数据转换为一种变体。让我们添加一个受保护的方法来完成这个任务:
QVariant PlayerInfoJSON::toVariant(const PlayerInfo &pinfo) const {
QVariantList players;
foreach(const Player &p, pinfo.players) players << toVariant(p);
return players;
}
由于结构实际上是一个玩家列表,我们可以迭代玩家列表,将每个玩家序列化为一个变体,并将结果追加到QVariantList中。有了这个函数,我们就可以向下深入并实现一个toVariant()的重载,它接受一个Player对象:
QVariant PlayerInfoJSON::toVariant(const Player &player) const {
QVariantMap map;
map["name"] = player.name;
map["password"] = player.password;
map["experience"] = player.experience;
map["hitpoints"] = player.hitPoints;
map["location"] = player.location;
map["position"] = QVariantMap({ {"x", player.position.x()},
{"y", player.position.y()} });
map["inventory"] = toVariant(player.inventory);
return map;
}
小贴士
Qt 的foreach宏接受两个参数——一个变量的声明和一个要迭代的容器。在每次迭代中,宏将后续元素分配给声明的变量,并执行宏后面的语句。C++11 中foreach的等价物是基于 for 构造的 range:
for(const Player &p: pinfo.players) players << toVariant(p);
这次,我们使用 QVariantMap 作为我们的基本类型,因为我们想将值与键关联起来。对于每个键,我们使用索引运算符向映射中添加条目。位置键包含一个 QPoint 值,这是 QVariant 本地支持的;然而,这样的变体不能自动编码为 JSON,因此我们使用 C++11 初始化列表将点转换为变体映射。对于库存,情况不同——我们再次必须为 toVariant 编写一个重载,以执行转换:
QVariant PlayerInfoJSON::toVariant(const QList<InventoryItem> &items) const {
QVariantList list;
foreach(const InventoryItem &item, items) list << toVariant(item);
return list;
}
代码几乎与处理 PlayerInfo 对象的代码相同,所以让我们关注 toVariant 的最后一个重载——接受 Item 实例的那个:
QVariant PlayerInfoJSON::toVariant(const InventoryItem &item) const {
QVariantMap map;
map["type"] = (int)item.type;
map["subtype"] = item.subType;
map["durability"] = item.durability;
return map;
}
这里没有太多可评论的——我们将所有键添加到映射中,将项目类型视为整数以简化(在一般情况下,这不是最佳方法,因为我们序列化数据并更改原始枚举中的值顺序后,反序列化后不会得到正确的项目类型)。
剩下的就是使用我们在 writePlayerInfo 方法中刚刚编写的代码:
QByteArray PlayerInfoJSON::writePlayerInfo(const PlayerInfo &pinfo) const {
QJsonDocument doc = QJsonDocument::fromVariant(toVariant(pinfo));
return doc.toJson();
}
是时候实现 JSON 解析器了
让我们扩展 PlayerInfoJSON 类并为其添加反向转换:
PlayerInfo PlayerInfoJSON::readPlayerInfo(const QByteArray &ba) const {
QJsonDocument doc = QJsonDocument::fromJson(ba);
if(doc.isEmpty() || !doc.isArray()) return PlayerInfo();
return readPlayerInfo(doc.array());
}
首先,我们读取文档并检查它是否有效以及是否包含预期的数组。如果失败,则返回空结构;否则,调用 readPlayerInfo 并提供 QJsonArray 进行操作:
PlayerInfo PlayerInfoJSON::readPlayerInfo(const QJsonArray &array) const {
PlayerInfo pinfo;
foreach(QJsonValue value, array)
pinfo.players << readPlayer(value.toObject());
return pinfo;
}
由于数组是可迭代的,我们再次可以使用 foreach 来迭代它,并使用另一个方法——readPlayer——来提取所有所需的数据:
Player PlayerInfoJSON::readPlayer(const QJsonObject &object) const {
Player player;
player.name = object.value("name").toString();
player.password = object.value("password").toString();
player.experience = object.value("experience").toDouble();
player.hitPoints = object.value("hitpoints").toDouble();
player.location = object.value("location").toString();
QVariantMap positionMap = object.value("position").toVariant().toMap();
player.position = QPoint(positionMap["x"].toInt(), positionMap["y"].toInt());
player.inventory = readInventory(object.value("inventory").toArray());
return player;
}
在这个函数中,我们使用 QJsonObject::value() 从对象中提取数据,然后使用不同的函数将数据转换为所需的类型。请注意,为了转换为 QPoint,我们首先将其转换为 QVariantMap,然后提取值,在构建 QPoint 之前使用它们。在每种情况下,如果转换失败,我们都会得到该类型的默认值(例如,一个空字符串)。为了读取库存,我们采用自定义方法:
QList<InventoryItem> PlayerInfoJSON::readInventory(const QJsonArray &array) const {
QList<InventoryItem> inventory;
foreach(QJsonValue value, array) inventory << readItem(value.toObject());
return inventory;
}
剩下的就是实现 readItem():
InventoryItem PlayerInfoJSON::readItem(const QJsonObject &object) const {
Item item;
item.type = (InventoryItem::Type)object.value("type").toDouble();
item.subType = object.value("subtype").toString();
item.durability = object.value("durability").toDouble();
return item;
}
发生了什么?
实现的类可用于在 Item 实例和包含对象数据的 JSON 格式的 QByteArray 对象之间进行双向转换。在这里我们没有进行任何错误检查;相反,我们依赖于 QJsonObject 和 QVariant 中的自动类型转换处理。
QSettings
虽然这严格来说不是一个序列化问题,但存储应用程序设置的方面与描述的主题密切相关。Qt 的解决方案是 QSettings 类。默认情况下,它在不同平台上使用不同的后端,例如 Windows 上的系统注册表或 Linux 上的 INI 文件。QSettings 的基本用法非常简单——你只需要创建对象并使用 setValue() 和 value() 来存储和加载数据:
QSettings settings;
settings.setValue("windowWidth", 80);
settings.setValue("windowTitle", "MySuperbGame");
// …
int windowHeight = settings.value("windowHeight").toInt();
你需要记住的唯一一点是它操作的是QVariant,因此如果需要,返回值需要转换为正确的类型,如前述代码的最后一行所示。如果请求的键不在映射中,value()调用可以接受一个额外的参数,该参数包含要返回的值。这允许你在默认值的情况下进行处理,例如,当应用程序首次启动且设置尚未保存时:
int windowHeight = settings.value("windowHeight", 800);
最简单的情况假设设置是“扁平”的,即所有键都在同一级别上定义。然而,这不必是这种情况——相关的设置可以放入命名的组中。要操作一个组,你可以使用beginGroup()和endGroup()调用:
settings.beginGroup("Server");
QString srvIP = settings.value("host").toString();
int port = settings.value("port").toInt();
settings.endGroup();
当使用此语法时,你必须记住在完成操作后结束组。除了使用前面提到的两种方法之外,还可以直接将组名传递给value()调用的调用:
QString srvIP = settings.value("Server/host").toString();
int port = settings.value("Server/port").toInt();
如前所述,QSettings可以在不同的平台上使用不同的后端;然而,我们可以通过向settings对象的构造函数传递适当的选项来影响选择哪个后端以及传递哪些选项给它。默认情况下,应用程序设置存储的位置由两个值决定——组织名称和应用程序名称。这两个都是文本值,都可以作为参数传递给QSettings构造函数,或者使用QCoreApplication中的适当静态方法预先定义:
QCoreApplication::setOrganizationName("Packt");
QCoreApplication::setApplicationName("Game Programming using Qt");
QSettings settings;
此代码等同于:
QSettings settings("Packt", "Game Programming using Qt");
所有的前述代码都使用了系统的默认后端。然而,通常希望使用不同的后端。这可以通过Format参数来完成,其中我们可以传递两个选项之一——NativeFormat或IniFormat。前者选择默认后端,而后者强制使用 INI 文件后端。在选择后端时,你还可以决定是否应该将设置保存在系统范围内的位置或用户的设置存储中,通过传递另一个参数——其作用域可以是UserScope或SystemScope。这可以扩展我们的最终构造调用为:
QSettings settings(QSettings::IniFormat, QSettings::UserScope,
"Packt", "Game Programming using Qt");
还有一个选项可以完全控制设置数据的位置——直接告诉构造函数数据应该位于何处:
QSettings settings(
QStandardPaths::writableLocation(
QStandardPaths::ConfigLocation
) +"/myapp.conf", QSettings::IniFormat
);
小贴士
QStandardPaths类提供了根据任务确定文件标准位置的方法。
QSettings还允许你注册自己的格式,以便你可以控制设置存储的方式——例如,通过使用 XML 存储或添加即时加密。这是通过QSettings::registerFormat()完成的,你需要传递文件扩展名和两个函数指针,分别用于读取和写入设置,如下所示:
bool readCCFile(QIODevice &device, QSettings::SettingsMap &map) {
CeasarCipherDevice ccDevice;
ccDevice.setBaseDevice(&device);
// ...
return true;
}
bool writeCCFile(QIODevice &device, const QSettings::SettingsMap &map) { ... }
const QSettings::Format CCFormat = QSettings::registerFormat("ccph", readCCFile, writeCCFile);
快速测验 - Qt 核心要点
Q1. 在 Qt 中,std::string最接近的等效是什么?
-
QString -
QByteArray -
QStringLiteral
Q2. 哪个正则表达式可以用来验证一个 IPv4 地址,IPv4 地址是由四个用点分隔的十进制数字组成,其值范围在 0 到 255 之间?
Q3. 如果你预计数据结构将在软件的未来版本中演变(获取新信息),你认为使用哪种序列化机制是最好的?
-
JSON
-
XML
-
QDataStream
摘要
在本章中,你学习了从文本操作到访问可以使用 XML 或 JSON 等流行技术传输或存储数据的设备的一系列核心 Qt 技术。你应该意识到,我们只是触及了 Qt 所能提供的一小部分,还有许多其他有趣的类你需要熟悉。但这个最小量的信息应该能给你一个良好的起点,并展示你未来研究的方向。
在下一章中,我们将从描述数据操作(可以使用文本或仅凭想象力进行可视化)转换到一个更吸引人的媒体。我们将开始讨论图形,以及如何将你想象中看到的内容传输到电脑屏幕上。
更多推荐

所有评论(0)