Qt5-学习手册(全)

Qt5 学习手册(全)

原文:annas-archive.org/md5/9fdbc9f976587acda3d186af05c73879

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Qt 是一个成熟而强大的框架,可在多种平台上交付复杂的应用程序。它在嵌入式设备中被广泛使用,包括电视、卫星机顶盒、医疗设备、汽车仪表板等。它在 Linux 世界中也有丰富的历史,KDE 和 Sailfish OS 广泛使用它,许多应用程序也是使用 Qt 开发的。在过去几年中,它在移动领域也取得了巨大进展。然而,在 Microsoft Windows 和 Apple macOS X 世界中,C#/.NET 和 Objective-C/Cocoa 的主导地位意味着 Qt 经常被忽视。

本书旨在展示 Qt 框架的强大和灵活性,并展示如何编写应用程序一次并将其部署到多个操作系统的桌面。读者将从头开始构建一个完整的现实世界业务线LOB)解决方案,包括独立的库、用户界面和单元测试项目。

我们将使用 QML 构建现代和响应式的用户界面,并将其连接到丰富的 C++类。我们将使用 QMake 控制项目配置和输出的每个方面,包括平台检测和条件表达式。我们将构建“自我意识”的数据实体,它们可以将自己序列化到 JSON 并从中反序列化。我们将在数据库中持久化这些数据实体,并学习如何查找和更新它们。我们将访问互联网并消费 RSS 源。最后,我们将生成一个安装包,以便将我们的应用部署到其他机器上。

这是一套涵盖大多数 LOB 应用程序核心要求的基本技术,将使读者能够从空白页面到已部署应用程序的进程。

本书的受众

本书面向寻找在 Microsoft Windows、Apple Mac OS X 和 Linux 桌面平台上创建现代和响应式应用程序的强大而灵活的框架的应用程序开发人员。虽然专注于桌面应用程序开发,但所讨论的技术在移动开发中也大多适用。

充分利用本书

读者应该熟悉 C++,但不需要先前了解 Qt 或 QML。在 Mac OS X 上,您需要安装 XCode 并至少启动一次。在 Windows 上,您可以选择安装 Visual Studio 以便使用 MSVC 编译器。

下载示例代码文件

您可以从www.packtpub.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packtpub.com/support并注册,以便直接通过电子邮件接收文件。

您可以按照以下步骤下载代码文件:

  1. www.packtpub.com登录或注册。

  2. 选择“支持”选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

下载文件后,请确保使用最新版本的以下工具解压或提取文件夹:

  • Windows 需要 WinRAR/7-Zip

  • Mac 需要 Zipeg/iZip/UnRarX

  • Linux 需要 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-Qt-5。我们还有其他书籍和视频的代码包可供下载,网址为github.com/PacktPublishing/。请查看!

使用的约定

本书中使用了许多文本约定。

CodeInText:指示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“在cm-ui/ui/views中创建SplashView.qml文件”。

代码块设置如下:

<RCC>
    <qresource prefix="/views">
        <file alias="MasterView">views/MasterView.qml</file>
    </qresource>
    <qresource prefix="/">
        <file>views/SplashView.qml</file>
        <file>views/DashboardView.qml</file>
        <file>views/CreateClientView.qml</file>
        <file>views/EditClientView.qml</file>
        <file>views/FindClientView.qml</file>
    </qresource>
</RCC>

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

QT += sql network

任何命令行输入或输出都以以下方式书写:

$ <Qt Installation Path> \Tools \QtInstallerFramework \3.0\ bin\ binarycreator.exe -c config\config.xml -p packages ClientManagementInstaller.exe

粗体:表示一个新术语,一个重要词,或者你在屏幕上看到的词。例如,菜单或对话框中的单词会以这种方式出现在文本中。这是一个例子:“用 Client Management 替换 Hello World 标题,并在 Window 的正文中插入一个 Text 组件”。

警告或重要说明会出现在这样的地方。

提示和技巧会出现在这样的地方。

第一章:Hello Qt

Qt 是一个成熟而强大的框架,可在多种平台上交付复杂的应用程序。它被广泛应用于嵌入式设备,包括电视、卫星机顶盒、医疗设备、汽车仪表板等。它在 Linux 世界中也有丰富的历史,KDE 和 Sailfish OS 广泛使用它,许多应用程序也是使用 Qt 开发的。在过去几年中,它在移动领域也取得了巨大进展。然而,在 Microsoft Windows 和 Apple Mac OS X 世界中,C#/.NET 和 Objective-C/Cocoa 的主导地位意味着 Qt 经常被忽视。

本书旨在演示 Qt 框架的强大和灵活性,并展示如何编写应用程序一次并部署到多个操作系统桌面上。我们将从头开始构建一个完整的现实世界的业务线LOB)解决方案,包括独立的库、用户界面和单元测试项目。

我们将介绍如何使用 QML 构建现代、响应式的用户界面,并将其与丰富的 C++类连接起来。我们将使用 QMake 控制项目配置和输出的每个方面,包括平台检测和条件表达式。我们将构建“自我意识”的数据实体,可以将自己序列化到 JSON 并从中反序列化。我们将在数据库中持久化这些数据实体,并学习如何查找和更新它们。我们将访问互联网并消费 RSS 源。最后,我们将生成一个安装包,以便将我们的应用程序部署到其他机器上。

在这一章中,我们将安装和配置 Qt 框架以及相关的集成开发环境IDE)Qt Creator。我们将创建一个简单的草稿应用程序,我们将在本书的其余部分中使用它来演示各种技术。我们将涵盖以下主题:

  • 安装 Qt

  • 维护你的安装

  • Qt Creator

  • 草稿项目

  • qmake

安装 Qt

让我们首先访问 Qt 网站www.qt.io

网站布局经常变化,但你要找的是下载桌面和移动端的 Qt 开源版本:

  1. 从顶级菜单中选择产品,然后选择 IDE 和工具

  2. 点击免费开始

  3. 选择桌面和移动应用程序

  4. 点击获取你的开源软件包

如果你继续在这些个人项目之外使用 Qt,请确保阅读 Qt 网站上提供的许可信息(www.qt.io/licensing/)。如果你的项目范围需要或者你想要访问官方 Qt 支持和与 Qt 公司的紧密战略关系的好处,升级到商业 Qt 许可证。

该网站将检测你的操作系统并建议一个推荐的下载:

在 Windows 上,你将被推荐使用在线安装程序*.exe文件,而在 Linux 上,你将被提供一个*.run文件,如果你使用 Mac OS X,则会提供一个.dmg文件。在所有情况下,下载并启动安装程序:

在 Linux 上,一旦下载完成,你可能需要首先转到*.run文件并将其标记为可执行,以便能够启动它。要做到这一点,右键单击文件管理器中的文件,然后单击属性。单击权限选项卡,选中“允许作为程序执行文件”的复选框。

在初始的欢迎对话框之后,你首先看到的是注册或使用 Qt 账户登录的选项。如果你愿意,可以随意创建一个,但现在我们将继续跳过:

然后会要求你选择要安装的组件。

你的第一个决定是你想要哪个版本的 Qt 框架。你可以同时安装多个版本。让我们选择最新和最好的(写作时的 Qt 5.10),并取消选择所有旧版本。

接下来,展开所选版本,你会看到一个次要的选项列表。所有描述为“Qt 5.9.x 预构建组件...”的选项都被称为工具包。工具包本质上是一组工具,使你能够使用特定的编译器/链接器构建你的应用程序,并在特定的目标架构上运行它。每个工具包都带有专门为该特定工具集编译的 Qt 框架二进制文件以及必要的支持文件。请注意,工具包不包含所引用的编译器;你需要提前安装它们。在 Windows 上的一个例外是 MinGW(包括 Windows 的 GCC),你可以选择通过底部的工具组件列表安装。

在 Windows 上,我们将选择 MinGW 5.3.0 32 位工具包,还有来自工具部分的 MinGW 5.3.0 开发环境。在我的(64 位)机器上,我已经安装了 Microsoft Visual Studio 2017,所以我们还会选择 MSVC 2017 64 位工具包,以帮助在本书后面演示一些技术。在 Linux 上,我们选择 GCC 64 位,而在 Mac OS 上,我们选择 macOS 64 位(使用 Clang 编译器)。请注意,在 Mac OS 上,你必须安装 XCode,并且最好至少启动一次 XCode,让它有机会完成初始化和配置。

随意暂停,安装任何其他 IDE 或编译器,然后回来选择相匹配的工具包。你选择哪个并不太重要——本书中介绍的技术适用于任何工具包,只是结果可能略有不同。请注意,你所看到的可用工具包将取决于你的操作系统和芯片组;例如,如果你使用的是 32 位机器,就不会提供 64 位工具包。

在工具包下面是一些可选的 Qt API(如 Qt Charts),在本书涉及的主题中我们不需要,但如果你想探索它们的功能,可以随意添加。请注意,它们可能与核心 Qt 框架有不同的许可协议。

无论工具包和 API,你会注意到在工具部分,Qt Creator 是默认安装的 IDE,这也是我们在本书中将要使用的 IDE。

完成选择后,点击下一步和更新开始安装。

通常最好将安装位置保持默认以保持机器的一致性,但随意选择任何你想要安装的位置。

维护你的安装

安装后,你可以通过位于你安装 Qt 的目录中的维护工具应用程序来更新、添加和删除组件(甚至整个 Qt 安装)。

启动这个工具基本上和我们第一次安装 Qt 时的体验是一样的。添加或移除组件选项是你想要添加之前可能不需要的项目,包括工具包甚至是全新的框架发布。除非你主动取消选择,已经安装在系统上的组件不会受到影响。

Qt Creator

虽然 Qt Creator 的详细概述超出了本书的范围(Qt Creator 手册可以通过帮助模式访问,如此处所述),但在我们开始第一个项目之前,快速浏览一下是值得的,所以启动新安装的应用程序,我们来看一下:

在左上角(1)是应用程序的不同区域或模式:

  • 欢迎模式是 Qt Creator 启动时的默认模式,是创建或打开项目的起点。有一套广泛的示例,帮助展示框架的各种功能,以及一些教程视频的选择。

  • 编辑模式是您将花费绝大部分时间的地方,用于编辑各种基于文本的文件。

  • 设计仅在打开 UI 文件时可访问,并且是用于视图的所见即所得编辑器。虽然对 UX 设计和基本布局工作很有用,但它可能会很快变得令人沮丧,因此我们将在编辑模式下进行所有 QML 工作。以这种方式工作有助于理解 QML(因为你必须编写它),并且还具有编辑器不添加不需要的代码的优势。

  • 调试模式用于调试应用程序,超出了本书的范围。

  • 项目模式是管理项目配置的地方,包括构建设置。在此处进行的更改将反映在*.pro.user文件中。

  • 帮助模式带您进入 Qt Creator 手册和 Qt 库参考。

在识别的 Qt 符号上按下F1将自动打开该符号的上下文相关帮助。

在下面,我们有构建/运行工具(2):

  • Kit/Build 让您选择您的工具包并设置构建模式

  • 运行构建并在不进行调试的情况下运行应用程序

  • 开始调试构建并使用调试器运行应用程序(请注意,您必须在所选工具包中安装和配置调试器才能使用此功能)

  • 构建项目构建应用程序而不运行它

在底部(3),我们有一个搜索框,然后是几个输出窗口:

问题显示任何警告或错误。对于与您的代码相关的编译器错误,双击该项将导航到相关的源代码。

  • 搜索结果让您在各种范围内查找文本的出现。Ctrl F会带出一个快速搜索,然后从那里选择高级…也会带出搜索结果控制台。

  • 应用程序输出是控制台窗口;所有来自应用程序代码的输出,如std::cout 和 Qt 的等效qDebug(),以及 Qt 框架的某些消息都会显示在这里。

  • 编译输出包含来自构建过程的输出,从 qmake 到编译和链接。

  • 调试器控制台包含我们在本书中不会涉及的调试信息。

  • 常规消息包含其他杂项输出,其中最有用的是来自*.pro文件的 qmake 解析,我们稍后会看到。

搜索框真的是一个隐藏的宝石,可以帮助您避免点击无尽的文件和文件夹,试图找到您要找的东西。您可以在框中开始输入要查找的文件名,然后会出现一个带有所有匹配文件的过滤列表。只需单击您想要的文件,它就会在编辑器中打开。不仅如此,您还可以应用大量的过滤器。单击光标放在空的搜索框中,它会显示一个可用过滤器的列表。例如,过滤器m会搜索 C++方法。所以,假设您记得写了一个名为SomeAmazingFunction()的方法,但不记得它在哪里,只需转到搜索框,开始输入m Some,它就会出现在过滤列表中。

在编辑模式下,布局会略有变化,并且会出现一些新的窗格。最初它们将是空的,但一旦打开项目,它们将类似于以下内容:

在导航栏旁边是项目资源管理器,您可以使用它来浏览解决方案的文件和文件夹。下面的窗格是您当前打开的所有文档的列表。右侧的较大区域是编辑器窗格,您可以在其中编写代码和编辑文档。

在项目资源管理器中双击文件通常会在编辑器窗格中打开它并将其添加到打开的文档列表中。单击打开文档列表中的文档将在编辑器窗格中激活它,而单击文件名右侧的小 x 将关闭它。

窗格可以更改以显示不同的信息,调整大小,分割,关闭,并可能使用标题中的按钮过滤或与编辑器同步。尝试一下,看看它们能做什么。

正如你所期望的,现代 IDE 的外观和感觉是非常可定制的。选择工具 > 选项…来查看可用的选项。我通常编辑以下内容:

  • 环境 > 接口 > 主题 > 平面

  • 文本编辑器 > 字体和颜色 > 颜色方案 > 我自己的方案

  • 文本编辑器 > 完成 > 用括号包围文本选择 > 关闭

  • 文本编辑器 > 完成 > 用引号包围文本选择 > 关闭

  • C++ > 代码风格 > 当前设置 > 复制…然后编辑…

  • 编辑代码风格 > 指针和引用 > 绑定到类型名称 > 打开(其他选项关闭)

玩弄一下,把东西弄得你喜欢。

草稿项目

为了演示 Qt 项目可以有多简单,并给我们一个编程沙盒来玩耍,我们将创建一个简单的草稿项目。对于这个项目,我们甚至不会使用 IDE 来为我们做,这样你就可以真正看到项目是如何建立起来的。

首先,我们需要创建一个根文件夹来存储所有的 Qt 项目。在 Windows 上,我使用c:\projects\qt,而在 Linux 和 Mac OS 上我使用~/projects/qt。在任何你喜欢的地方创建这个文件夹。

请注意,文件同步工具(OneDrive,DropBox 等)有时会导致项目文件夹出现问题,因此请将项目文件保存在常规的未同步文件夹中,并使用远程存储库进行版本控制以进行备份和共享。

在本书的其余部分,我会宽松地将这个文件夹称为<Qt 项目>或类似的。我们也倾向于使用 Unix 风格的/分隔符来表示文件路径,而不是 Windows 风格的反斜杠\。因此,对于使用 Windows 的读者,<Qt 项目>/scratchpad/amazing/code等同于c:\projects\qt\scratchpad\amazing\code。Qt 也倾向于使用这种约定。

同样,本书中大部分截图将来自 Windows,因此 Linux/Mac 用户应将任何关于c:\projects\qt的引用解释为~/projects/qt

在我们的 Qt 项目文件夹中,创建一个名为 scratchpad 的新文件夹并进入其中。创建一个名为scratchpad.pro的新纯文本文件,记得删除操作系统可能想要为你添加的任何.txt扩展名。

接下来,只需双击该文件,它将在 Qt Creator 中打开:

在这里,Qt Creator 问我们如何配置我们的项目,即在构建和运行代码时我们想要使用哪些工具包。选择一个或多个可用的工具包,然后点击配置项目。您可以随后轻松添加和删除工具包,所以不用担心选择哪个。

如果你切换回到文件系统,你会看到 Qt Creator 已经为我们创建了一个名为scratchpad.pro.user的新文件。这只是一个包含配置信息的 XML 文件。如果你删除这个文件并再次打开.pro文件,你将被提示再次配置项目。正如它的名字所暗示的那样,配置设置与本地用户有关,所以通常如果你加载了别人创建的项目,你也需要通过配置项目步骤。

成功配置项目后,您将看到项目已经打开,即使是一个完全空的.pro文件。这就是一个项目可以变得多么简单!

回到文件系统,创建以下纯文本文件:

  • main.cpp

  • main.qml

  • qml.qrc

我将逐个查看这些文件,解释它们的目的,并很快添加它们的内容。在现实世界的项目中,我们当然会使用 IDE 为我们创建文件。事实上,当我们创建主解决方案文件时,这正是我们要做的。然而,以这种方式做的目的是向您展示,归根结底,项目只是一堆文本文件。永远不要害怕手动创建和编辑文件。许多现代 IDE 可能会通过一个又一个的菜单和永无止境的选项窗口使人困惑和复杂化。Qt Creator 可能会错过其他 IDE 的一些高级功能,但它非常简洁和直观。

创建了这些文件后,在项目窗格中双击 scratchpad.pro 文件,我们将开始编辑我们的新项目。

qmake

我们的项目(.pro)文件由一个名为 qmake 的实用程序解析,它生成驱动应用程序构建的 Makefiles。我们定义了我们想要的项目输出类型,包括哪些源文件以及依赖关系等等。我们现在将在项目文件中简单地设置变量来实现这些。

将以下内容添加到 scratchpad.pro

TEMPLATE = app

QT += qml quick

CONFIG += c++14
SOURCES += main.cpp
RESOURCES += qml.qrc

让我们依次浏览每一行:

TEMPLATE = app

TEMPLATE 告诉 qmake 这是什么类型的项目。在我们的情况下,它是一个可执行应用程序,由 app 表示。我们感兴趣的其他值是用于构建库二进制文件的 lib 和用于多项目解决方案的 subdirs。请注意,我们使用 = 运算符设置变量:

QT += qml quick

Qt 是一个模块化框架,允许您只引入您需要的部分。QT 标志指定我们想要使用的 Qt 模块。coregui 模块默认包含在内。请注意,我们使用 += 将附加值追加到期望列表的变量中:

CONFIG += c++14

CONFIG 允许您添加项目配置和编译器选项。在这种情况下,我们指定要使用 C++14 特性。请注意,如果您使用的编译器不支持这些语言特性标志,它们将不起作用。

SOURCES += main.cpp

SOURCES 是我们想要包含在项目中的所有 *.cpp 源文件的列表。在这里,我们添加了我们的空 main.cpp 文件,我们将在其中实现我们的 main() 函数。我们目前还没有,但当我们有时,我们的头文件将使用 HEADERS 变量指定:

RESOURCES += qml.qrc 

RESOURCES 是项目中包含的所有资源集合文件(*.qrc)的列表。资源集合文件用于管理应用程序资源,如图像和字体,但对我们来说最关键的是我们的 QML 文件。

更新项目文件后,保存更改。

每当您保存对 *.pro 文件的更改时,qmake 将解析该文件。如果一切顺利,您将在 Qt Creator 的右下角获得一个小绿条。红色条表示某种问题,通常是语法错误。进程的任何输出都将写入“常规消息”窗口,以帮助您诊断和解决问题。空格将被忽略,所以不用担心完全匹配空行。

要让 qmake 重新审视您的项目并生成新的 Makefiles,请在项目窗格中右键单击您的项目,然后选择“运行 qmake”。这可能有点乏味,但在构建和运行应用程序之前手动运行 qmake 是一个好习惯。我发现某些类型的代码更改可能会“悄悄地”通过,当您运行应用程序时,它们似乎没有产生任何效果。如果您看到应用程序忽略了您刚刚进行的更改,请在每个项目上运行 qmake 并重试。如果出现虚假的链接器错误,也是同样的情况。

您会看到我们的其他文件现在神奇地出现在项目窗格中:

双击 main.cpp 进行编辑,我们将写入我们的第一行代码:

#include <QGuiApplication>
#include <QQmlApplicationEngine>

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);
    QQmlApplicationEngine engine;

    engine.load(QUrl(QStringLiteral("qrc:/main.qml")));

    return app.exec();
}

我们在这里所做的就是实例化一个 Qt GUI 应用程序对象,并要求它加载我们的main.qml文件。这非常简短和简单,因为 Qt 框架为我们做了所有复杂的底层工作。我们不必担心平台检测或管理窗口句柄或 OpenGL。

可能最有用的事情之一是学会将光标放在 Qt 对象中,然后按下F1将打开该类型的帮助。对于 Qt 对象上的方法和属性也是如此。在帮助文件中查看QGuiApplicationQQmlApplicationEngine是关于什么的。

要编辑项目中的下一个文件qml.qrc,您需要右键单击并选择要打开它的编辑器。默认是资源编辑器。

我个人不喜欢这个编辑器。我觉得它并没有比纯文本编辑更容易,也不是特别直观。关闭它,选择以纯文本编辑器打开

添加以下内容:

<RCC>
    <qresource prefix="/">
        <file>main.qml</file>
    </qresource>
</RCC>

回到main.cpp,我们要求 Qt 加载qrc:/main.qml文件。这基本上可以解释为“在具有前缀/和名称main.qmlqrc文件中查找文件”。现在在我们的qrc文件中,我们创建了一个具有前缀属性/qresource元素。在这个元素内部,我们有一个资源集合(尽管只有一个),它的名称是main.qml。将qrc文件视为一个可移植的文件系统。请注意,资源文件相对于引用它们的.qrc文件而言。在这种情况下,我们的main.qml文件与我们的qml.qrc文件在同一个文件夹中。例如,如果它在名为views的子文件夹中,那么qml.qrc中的行将是这样的:

<file>views/main.qml</file>

同样,在main.cpp中的字符串将是qrc:/views/main.qml

保存这些更改后,您将看到我们空的main.qml文件出现在项目窗格中qml.qrc文件的子文件夹中。双击该文件进行编辑,我们将完成我们的项目:

import QtQuick 2.9
import QtQuick.Window 2.3

Window {
    visible: true
    width: 1024
    height: 768
    title: qsTr("Scratchpad")
    color: "#ffffff"

    Text {
        id: message
        anchors.centerIn: parent
        font.pixelSize: 44
        text: qsTr("Hello Qt Scratchpad!")
        color: "#008000"
    }
}

我们将在第二章中详细介绍 QML,项目结构,但简而言之,这个文件代表了应用程序启动时向用户呈现的屏幕或视图。

导入行类似于 C++中的#include语句,不过不是包含单个头文件,而是导入整个模块。在这种情况下,我们希望使用基本的 QtQuick 模块来访问所有核心的 QML 类型,还有 QtQuick 窗口模块来访问Window组件。模块是有版本的,通常情况下,你会想要使用你所使用的 Qt 版本的最新版本。当前的版本号可以在 Qt 文档中找到。请注意,尽管在输入版本号时会有代码补全,但有时呈现的选项并不反映最新可用的版本。

正如其名称所示,Window元素为我们提供了一个顶级窗口,在其中我们的所有其他内容将被呈现。我们给它一个大小为 1024 x 765 像素,一个标题为“scratchpad”,以及一个白色的背景颜色,用十六进制 RGB 值表示。

在该组件中(QML 是一种分层标记语言),我们使用Text组件添加了一个欢迎消息。我们将文本居中显示在屏幕上,并设置了字体大小和颜色,但除此之外,在这个阶段我们不关心花哨的格式或其他任何东西,所以这就是我们会做的复杂程度。我们稍后会更详细地介绍这个,所以如果看起来有点陌生,不要担心。

就是这样。要构建和运行我们令人惊叹的新应用程序,首先使用左下角的监视器图标选择您想要的工具包和构建配置:

接下来,在项目窗格中右键单击项目名称,然后选择运行 qmake。完成后,使用绿色播放图标运行应用程序:

总结

在本章中,我们下载、安装和配置了 Qt。我们快速浏览了 Qt Creator IDE,尝试了它的选项,并了解了如何使用它编辑各种文件。我们对 qmake 有了初步了解,并看到了创建项目是多么简单,从而使事情变得不再神秘。最后,我们从头开始构建了我们的处女作品(弱笑话打算),并在屏幕上得到了必不可少的“Hello World”消息。

在第二章 项目结构中,我们将在这些基础上建立,并设置我们的主要解决方案。

第二章:项目结构

在本章中,我们将创建一个新的多项目解决方案,这将是我们示例应用程序的基础。我们将应用模型视图控制器模式,将用户界面和业务逻辑分离。我们还将介绍 Qt 的单元测试框架—QtTest,并演示如何将其集成到我们的解决方案中。我们将在本章中涵盖以下内容:

  • 项目、MVC 和单元测试

  • 创建库项目

  • 创建单元测试项目

  • 创建用户界面项目

  • 掌握 MVC

  • QObject 基类

  • QML

  • 控制项目输出

项目、MVC 和单元测试

我们在上一章中构建的草稿应用是一个 Qt 项目,由一个.pro文件表示。在商业环境中,技术解决方案通常作为公司倡议的一部分开发,这些倡议通常也被称为项目。为了尽量减少混淆(和项目出现的次数!),我们将使用项目来表示由.pro文件定义的 Qt 项目,倡议一词用来指代商业意义上的项目。

我们将要开展的倡议是一个通用的客户管理系统。它将是一个可以调整和重新用于多个应用程序的东西—供应商管理客户、卫生服务管理患者等。它将执行现实世界业务线LOB)应用程序中一遍又一遍发现的常见任务,主要是添加、编辑和删除数据。

我们的草稿应用完全封装在一个项目中。对于较小的应用程序,这是完全可行的。然而,对于较大的代码库,特别是涉及多个开发人员的情况,通常最好将事情分解成更易管理的部分。

我们将使用超轻量级的模型视图控制MVC)架构模式的实现。如果你之前没有接触过 MVC,它主要用于将业务逻辑与用户界面解耦。用户界面(视图)向一个类似于交换机的类(控制器)传达命令,以检索数据并执行所需的操作。控制器反过来将数据、逻辑和规则的责任委托给数据对象(模型):

关键是视图知道控制器模型,因为它需要向控制器发送命令并显示模型中保存的数据。控制器知道模型,因为它需要将工作委托给它,但它不知道视图。模型对控制器视图一无所知。

在商业环境中以这种方式设计应用程序的一个关键好处是,专门的用户体验专家可以在视图上工作,而程序员可以在业务逻辑上工作。第二个好处是,因为业务逻辑层对 UI 一无所知,所以你可以添加、编辑,甚至完全替换用户界面而不影响逻辑层。一个很好的用例是为桌面应用程序拥有“全功能”UI,为移动设备拥有一个伴侣“半功能”UI,两者都可以使用相同的业务逻辑。考虑到所有这些,我们将把我们的 UI 和业务逻辑物理上分开成两个项目。

我们还将研究如何将自动化单元测试集成到我们的解决方案中。单元测试和测试驱动开发TDD)在最近变得非常流行,当在商业环境中开发应用程序时,你很可能会被要求在编写代码时编写单元测试。如果没有,你应该提议这样做,因为它具有很大的价值。如果你以前没有进行过单元测试,不要担心;它非常简单,我们将在本书的后面更详细地讨论它。

最后,我们需要一种方法来将这些子项目聚合在一起,以便我们不必单独打开它们。我们将通过一个伞解决方案项目来实现这一点,该项目除了将其他项目绑在一起外,什么也不做。这就是我们将布置我们的项目的方式:

项目创建

在上一章中,我们看到了通过创建一些文本文件来设置新项目是多么容易。但是,我们将使用 Qt Creator 创建我们的新解决方案。我们将使用新项目向导来引导我们创建一个顶级解决方案和一个单个子项目。

从顶部菜单中,选择文件>新文件或项目,然后选择项目>其他项目>Subdirs 项目,然后单击“选择...”:

Subdirs Project 是我们需要的顶级解决方案项目的模板。将其命名为cm,并在我们的qt项目文件夹中创建:

在 Kit Selection 窗格中,选中我们安装的 Desktop Qt 5.10.0 MinGW 32 位套件。如果您已安装其他套件,可以随意选择要尝试的其他套件,但这并非必需。然后单击“下一步”:

如前所述,版本控制超出了本书的范围,因此在项目管理窗格中,从“添加到版本控制”下拉菜单中选择“无”。然后单击“完成并添加子项目”:

我们将把用户界面项目作为第一个子项目添加。向导遵循的步骤与我们刚刚遵循的步骤更多或更少相同,因此执行以下操作:

  1. 选择项目>应用程序>Qt Quick 应用程序-空,并单击“选择...”

  2. 在项目位置对话框中,将其命名为cm-ui(用于客户端管理-用户界面),将位置保留为我们的新cm文件夹,然后单击“下一步”。

  3. 在定义构建系统对话框中,选择构建系统 qmake,然后单击“下一步”。

  4. 在定义项目详细信息对话框中,保留默认的最小 Qt 版本 QT 5.9 和未选中使用 Qt 虚拟键盘框,然后单击“下一步”。

  5. 在 Kit Selection 对话框中,选择桌面 Qt 5.10.0 MinGW 32 位套件以及您希望尝试的其他套件,然后单击“下一步”。

  6. 最后,在项目管理对话框中,跳过版本控制(将其保留为<无>)并单击“完成”。

我们的顶级解决方案和 UI 项目现在已经启动,所以让我们按照以下步骤添加其他子项目。接下来添加业务逻辑项目,如下所示:

  1. 在“项目”窗格中,右键单击顶级cm文件夹,然后选择“新建子项目...”。

  2. 选择项目>库> C++库,并单击“选择...”。

  3. 在介绍和项目位置对话框中,选择共享库作为类型,将其命名为cm-lib,在<Qt Projects>/cm中创建它,然后单击“下一步”。

  4. 在选择所需模块对话框中,只接受 QtCore 的默认设置,然后单击“下一步”。

  5. 类信息对话框中,我们有机会创建一个新类来帮助我们入门。给出类名Client,使用client.h头文件和client.cpp源文件,然后单击“下一步”。

  6. 最后,在项目管理对话框中,跳过版本控制(将其保留为<无>)并单击“完成”。

最后,我们将重复这个过程来创建我们的单元测试项目:

  1. 新子项目....

  2. 项目>其他项目>Qt 单元测试。

  3. 项目名称cm-tests

  4. 包括 QtCore 和 QtTest。

  5. 创建ClientTests测试类,其中包括testCase1测试槽和client-tests.cpp文件名。将类型设置为测试,并检查生成初始化和清理代码。

  6. 跳过版本控制并完成。

我们刚刚经历了很多对话框,但现在我们已经将骨架解决方案放置好了。您的项目文件夹应该如下所示:

现在让我们依次查看每个项目,并在开始添加内容之前进行一些调整。

cm-lib

首先,前往文件资源管理器,在cm-lib下创建一个名为source的新子文件夹;将cm-lib_global.h移动到其中。在source中创建另一个名为models的子文件夹,并将Client类文件都移动到其中。

接下来,在 Qt Creator 中,打开cm-lib.pro并编辑如下:

QT -= gui
TARGET = cm-lib
TEMPLATE = lib
CONFIG += c++14
DEFINES += CMLIB_LIBRARY
INCLUDEPATH += source

SOURCES += source/models/client.cpp

HEADERS += source/cm-lib_global.h \
    source/models/client.h

由于这是一个库项目,我们不需要加载默认的 GUI 模块,因此我们使用QT变量将其排除。TARGET变量是我们希望给我们的二进制输出的名称(例如cm-lib.dll)。这是可选的,如果未提供,将默认为项目名称,但我们将明确指定。接下来,与我们在草稿应用程序中看到的app模板不同,这次我们使用lib来创建一个库。我们通过CONFIG变量添加了 c++14 特性。

cm-lib_global.h文件是一个有用的预处理器样板,我们可以用它来导出我们的共享库符号,您很快就会看到它的用途。我们在DEFINES变量中使用CMLIB_LIBRARY标志来触发此导出。

最后,我们稍微重写了SOURCESHEADERS变量列表,以考虑在我们移动了一些东西之后的新文件位置,并且我们将源文件夹(这是我们所有代码的所在地)添加到INCLUDEPATH中,这样当我们使用#include语句时就可以搜索到路径。

在项目窗格中右键单击cm-lib文件夹,选择运行 qmake。完成后,再次右键单击并选择重新构建。一切应该都是绿色和愉快的。

cm-tests

创建新的source/models子文件夹,并将client-tests.cpp移动到那里。切换回 Qt Creator 并编辑cm-tests.pro

QT += testlib
QT -= gui
TARGET = client-tests
TEMPLATE = app

CONFIG += c++14 
CONFIG += console 
CONFIG -= app_bundle

INCLUDEPATH += source 

SOURCES += source/models/client-tests.cpp

这基本上与cm-lib的方法相同,唯一的区别是我们想要一个控制台应用程序而不是一个库。我们不需要 GUI 模块,但我们将添加testlib模块以获取 Qt 测试功能的访问权限。

目前这个子项目还没有太多内容,但您应该能够成功运行 qmake 并重新构建。

cm-ui

这次创建两个子文件夹:sourceviews。将main.cpp移动到source中,将main.qml移动到views中。将qml.qrc重命名为views.qrc,并编辑cm-ui.pro

QT += qml quick

TEMPLATE = app

CONFIG += c++14 

INCLUDEPATH += source 

SOURCES += source/main.cpp 

RESOURCES += views.qrc 

# Additional import path used to resolve QML modules in Qt Creator's code model 
QML_IMPORT_PATH = $$PWD

我们的 UI 是用 QML 编写的,需要qmlquick模块,所以我们添加了这些。我们编辑RESOURCES变量以获取我们重命名的资源文件,并编辑QML_IMPORT_PATH变量,我们将在进入自定义 QML 模块时详细介绍。

接下来,编辑views.qrc以考虑我们已将main.qml文件移动到views文件夹中。记得右键单击并选择“使用其他应用程序打开”>“纯文本编辑器”:

<RCC>
    <qresource prefix="/">
        <file>views/main.qml</file>
    </qresource>
</RCC>

最后,我们还需要编辑main.cpp中的一行以考虑文件移动:

engine.load(QUrl(QStringLiteral("qrc:/views/main.qml")));

现在,您应该能够运行 qmake 并重新构建cm-ui项目。在运行之前,让我们快速看一下构建配置按钮,因为现在我们有多个项目打开了:

请注意,现在除了工具链和构建选项之外,我们还必须选择要运行的可执行文件。确保选择了cm-ui,然后运行应用程序:

确实是世界你好。这是相当令人失望的东西,但我们已经成功地构建和运行了一个多项目解决方案,这是一个很好的开始。当您无法再忍受更多乐趣时,请关闭应用程序!

MVC 的掌握

现在我们的解决方案结构已经就位,我们将开始 MVC 实现。正如您将看到的那样,它非常简单,非常容易设置。

首先,展开cm-ui > Resources > views.qrc > / > views,右键单击main.qml,选择重命名,将文件重命名为MasterView.qml。如果收到有关项目编辑的消息,请选择“是”以继续:

如果您收到错误消息,文件仍将在项目窗格中显示为main.qml,但文件在文件系统中已被重命名。

接下来,编辑views.qrc(右键单击它,然后选择使用纯文本编辑器打开)。将内容替换为以下内容:

<RCC>
    <qresource prefix="/views">
        <file alias="MasterView.qml">views/MasterView.qml</file>
    </qresource>
</RCC>

如果您还记得我们如何在main.cpp中加载这个 QML 文件,语法是qrc:<prefix><filename>。我们以前有一个/前缀和一个views/main.qml相对文件名。这给了我们qrc:/views/main.qml

/的前缀并不是非常描述性的。随着您添加更多的 QML 文件,将它们组织成具有有意义前缀的块会非常有帮助。拥有无结构的资源块也会使项目面板变得混乱,导航起来更加困难,就像您刚才在views.qrc > / > views中看到的那样。因此,第一步是将前缀从/重命名为/views

然而,使用/views作为前缀和views/main.qml作为相对文件名,我们的 URL 现在是qrc:/views/views/main.qml

这比以前更糟糕了,在views.qrc中我们仍然有一个深层的文件夹结构。幸运的是,我们可以为我们的文件添加一个别名来解决这两个问题。您可以使用资源的别名来代替相对路径,因此如果我们分配一个main.qml的别名,我们可以用main.qml来替换views/main.qml,得到qrc:/views/main.qml

这是简洁和描述性的,我们的项目面板也更整洁了。

因此,回到我们更新后的views.qrc版本,我们只是将文件名从main.qml更新为MasterView.qml,与我们执行的文件重命名一致,并且我们还提供了一个快捷别名,这样我们就不必两次指定 views。

现在我们需要更新main.cpp中的代码以反映这些更改:

engine.load(QUrl(QStringLiteral("qrc:/views/MasterView.qml")));

您应该能够运行 qmake,并构建和运行以验证没有出现问题。

接下来,我们将创建一个MasterController类,因此右键单击cm-lib项目,然后选择添加新内容… > C++ > C++类 > 选择…:

使用“浏览…”按钮创建source/controllers子文件夹。

通过选择 QObject 作为基类并包含它,Qt Creator 将为我们编写一些样板代码。您随后可以自己添加它,所以不要觉得这是创建新类的必要部分。

一旦您跳过了版本控制并创建了类,声明和定义如下。我们的MasterController目前还没有做任何特别激动人心的事情,我们只是在做基础工作。

这是master-controller.h

#ifndef MASTERCONTROLLER_H
#define MASTERCONTROLLER_H
#include <QObject>

#include <cm-lib_global.h>
namespace cm {
namespace controllers {
class CMLIBSHARED_EXPORT MasterController : public QObject
{
    Q_OBJECT
public:
    explicit MasterController(QObject* parent = nullptr);
};

}}

#endif

我们真正添加到 Qt Creator 默认实现的只是CMLIBSHARED_EXPORT宏,Qt Creator 在cm-lib_global.h中为我们编写的,以处理我们的共享库导出,并将类放在一个命名空间中。

我总是将项目名称作为根命名空间,然后是反映源目录中类文件物理位置的其他命名空间,所以在这种情况下,我使用cm::controllers,因为该类位于source/controllers目录中。

这是master-controller.cpp

#include "master-controller.h"

namespace cm {
namespace controllers {
MasterController::MasterController(QObject* parent)
    : QObject(parent)
{
}

}}

在实现文件中,我使用了一个略微不正统的风格——大多数人只是在.cpp文件的顶部添加using namespace cm::controllers;。我经常喜欢将代码放在命名空间的范围内,因为在 IDE 中可以折叠它。通过重复最内层的命名空间范围(在这个例子中是controllers),您可以将代码分解成可折叠的区域,就像在 C#中一样,这有助于在更大的文件中进行导航,因为您可以折叠您不感兴趣的部分。这在功能上没有任何区别,所以使用您喜欢的风格。

QObject

那么,我们继承的这个古怪的QObject是什么东西?它是所有 Qt 对象的基类,并且它为我们提供了一些强大的功能。

QObjects 将自己组织成对象层次结构,parent对象承担其child对象的所有权,这意味着我们不必太担心内存管理。例如,如果我们有一个从 QObject 派生的 Client 类的实例,它是从 QObject 派生的 Address 的父类,那么当客户端被销毁时,地址会自动被销毁。

QObjects 携带元数据,允许一定程度的类型检查,并且是与 QML 交互的支柱。它们还可以通过事件订阅机制相互通信,其中事件被发射为signals,订阅的代理被称为slots

现在您需要记住的是,对于您编写的任何自定义类,如果您希望在 UI 中与之交互,请确保它派生自 QObject。每当您从 QObject 派生时,请确保在做任何其他事情之前始终向您的类添加神奇的 Q_OBJECT 宏。它注入了一堆超级复杂的样板代码,您不需要理解就可以有效地使用 QObjects。

我们现在需要引用一个子项目(cm-lib中的MasterController)中的代码到另一个子项目(cm-ui)中。我们首先需要能够访问我们的#include语句的声明。编辑cm-ui.pro中的INCLUDEPATH变量如下:

INCLUDEPATH += source \
    ../cm-lib/source

\符号是“继续到下一行”的指示符,因此您可以将一个变量设置为跨越多行的多个值。就像控制台命令一样,‘..’表示向上遍历一个级别,所以这里我们从本地文件夹(cm-ui)中跳出,然后进入cm-lib文件夹以获取其源代码。您需要小心,项目文件夹保持相对位置不变,否则这将无法工作。

紧接着,我们将告诉我们的 UI 项目在哪里找到我们的库项目的实现(已编译的二进制文件)。如果您查看与顶级cm项目文件夹并排的文件系统,您会看到一个或多个构建文件夹,例如,build-cm-Desktop_Qt_5_9_0_MinGW_32bit-Debug。每个文件夹在为给定的工具包和配置运行 qmake 时创建,并在构建时填充输出。

接下来,导航到与您正在使用的工具包和配置相关的文件夹,您会发现一个带有另一个配置文件夹的 cm-lib 文件夹。复制这个文件路径;例如,我正在使用 MinGW 32 位工具包进行调试配置,所以我的路径是<Qt Projects>/build-cm-Desktop_Qt_5_10_0_MinGW_32bit-Debug/cm-lib/debug

在那个文件夹中,您会找到与您的操作系统相关的已编译二进制文件,例如,在 Windows 上是cm-lib.dll。这是我们希望我们的cm-ui项目引用的cm-lib库实现的文件夹。为了设置这一点,将以下语句添加到cm-ui.pro中:

LIBS += -L$$PWD/../../build-cm-Desktop_Qt_5_10_0_MinGW_32bit-Debug/cm-lib/debug -lcm-lib

LIBS是用于向项目添加引用库的变量。-L前缀表示目录,而-l表示库文件。使用这种语法允许我们忽略文件扩展名(.a.o.lib)和前缀(lib...),这些可能因操作系统而异,让 qmake 自行解决。我们使用特殊的$$符号来访问PWD变量的值,该变量包含当前项目的工作目录(在这种情况下是cm/cm-ui的完整路径)。然后,我们从该位置向上两个目录,使用../..来到 Qt 项目文件夹。然后,我们再次向下钻取到我们知道cm-lib二进制文件构建的位置。

现在,这个写起来很痛苦,丑陋得要命,一旦我们切换工具包或配置,它就会崩溃,但我们稍后会回来整理所有这些。项目引用都已连接好,我们可以前往cm-ui中的main.cpp

为了能够在 QML 中使用给定的类,我们需要在创建 QML 应用程序引擎之前在main()中注册它。首先,包括MasterController

#include <controllers/master-controller.h>

然后,在实例化QGuiApplication之后但在声明QQmlApplicationEngine之前,添加以下行:

qmlRegisterType<cm::controllers::MasterController>("CM", 1, 0, "MasterController");

我们在这里所做的是将类型注册到 QML 引擎中。请注意,模板参数必须使用所有命名空间进行完全限定。我们将类型的元数据添加到一个名为 CM 的模块中,版本号为 1.0,并且我们希望在 QML 标记中将此类型称为MasterController

然后,我们实例化MasterController的一个实例,并将其注入到根 QML 上下文中:

cm::controllers::MasterController masterController;

QQmlApplicationEngine engine;
engine.rootContext()->setContextProperty("masterController", &masterController);
engine.load(QUrl(QStringLiteral("qrc:/views/MasterView")));

请注意,在加载 QML 文件之前,您需要设置上下文属性,并且还需要添加以下标头:

#include <QQmlContext>

因此,我们已经创建了一个控制器,将其注册到了 QML 引擎中,并且一切就绪。现在呢?让我们开始我们的第一段 QML。

QML

Qt 建模语言QML)是一种用于用户界面布局的分层声明性语言,其语法类似于JavaScript 对象表示法JSON)。它可以通过 Qt 的元对象系统绑定到 C++对象,并且还支持内联 JavaScript。它很像 HTML 或 XAML,但没有 XML 的繁琐。如果你更喜欢 JSON 而不是 XML,这只能是一件好事!

继续打开MasterView.qml,我们将看到发生了什么。

您将看到的第一件事是一对import语句。它们类似于 C++中的#include语句,它们引入了我们想要在视图中使用的功能部分。它们可以是打包和版本化的模块,如 QtQuick 2.9,也可以是指向本地内容的相对路径。

接下来,QML 层次结构从一个 Window 对象开始。对象的范围由随后的{}表示,因此括号内的所有内容都是对象的属性或子对象。

属性遵循 JSON 属性语法,形式为 key: value。一个显着的区别是,除非您提供字符串文字作为值,否则不需要引号。在这里,我们将窗口对象的visible属性设置为true,窗口的大小设置为 640 x 480 像素,并在标题栏中显示 Hello World。

让我们更改标题并添加一个简单的消息。将 Hello World 的标题更改为 Client Management,并在窗口的正文中插入一个 Text 组件:

Window {
    visible: true
    width: 640
    height: 480
    title: qsTr("Client Management")

    Text {
        text: "Welcome to the Client Management system!"
    }
}

保存您的更改,并运行 qmake 并运行应用程序:

让我们让MasterController开始发挥作用,而不是在 UI 中硬编码我们的欢迎消息,我们将从我们的控制器动态获取它。

编辑master-controller.h,并添加一个名为welcomeMessage的新的QString类型的公共属性,并将其设置为初始值:

QString welcomeMessage = "This is MasterController to Major Tom";

你还需要#include <QString>

为了能够从 QML 访问此成员,我们需要配置一个新的属性。在 Q_OBJECT 宏之后但在第一个公共访问修饰符之前,添加以下内容:

Q_PROPERTY( QString ui_welcomeMessage MEMBER welcomeMessage CONSTANT )

在这里,我们正在创建一个新的QString类型的属性,QML 可以访问。QML 将把属性称为ui_welcomeMessage,在调用时,将获取(或设置)MEMBER变量中称为welcomeMessage的值。我们明确地设置了变量的值,并且不会更改它,因此它将保持CONSTANT

您可以简单地将属性命名为welcomeMessage,而不是ui_welcomeMessage。我个人偏好于明确地为仅用于 UI 消耗的事物添加 ui_ 前缀,以将其与成员变量和方法区分开。做适合您的事情。

返回MasterView.qml,我们将使用这个属性。将Text组件的text属性更改为以下内容:

text: masterController.ui_welcomeMessage

注意 QML 编辑器如何识别masterController,甚至为其提供代码完成。现在,QML 不再显示字符串文字作为消息,而是访问我们在main()中注入到根上下文中的MasterController实例的ui_welcomeMessage属性,这将进而获取welcomeMessage成员变量的值。

构建和运行,现在您应该能够看到来自MasterController的消息:

我们现在有了一个让 QML 调用 C++代码并获取我们想要提供的任何数据和业务逻辑的工作机制。在这里,需要注意的一点是我们的MasterControllerMasterView的存在一无所知,这是 MVC 模式的关键部分。

项目输出

为了让我们的cm-ui项目知道在哪里找到cm-lib的实现,我们在项目文件中使用了LIBS变量。这是一个相当丑陋的文件夹名,但只有一行,一切都运行得很完美,所以很容易就会让事情保持原样。然而,期待着当我们准备好为测试或者生产制作我们的第一个构建时。我们编写了一些非常聪明的代码,一切都构建和运行得很好。我们将配置从 Debug 切换到 Release 然后...一切都垮掉了。问题在于我们在项目文件中硬编码了库路径,以便在Debug文件夹中查找。切换到不同的套件或另一个操作系统,问题会更糟,因为使用不同的编译器会导致二进制兼容性问题。

让我们设定一些目标:

  • 摆脱笨重的build-cm…文件夹

  • 将所有编译后的二进制输出聚合到一个共同的文件夹cm/binaries

  • 将所有临时构建工件隐藏在它们自己的文件夹cm/<project>/build

  • 为不同的编译器和架构创建单独的构建和二进制文件夹

  • 自动检测这些编译器和架构

那么,这些有趣的长文件夹名字首先是从哪里来的呢?在 Qt Creator 中,点击导航栏中的项目模式图标。在左侧的构建和运行部分,选择桌面 Qt 5.9.0 MinGW 32 位 > 构建。在这里,您将看到此解决方案中 MinGW 套件的构建设置,并在影子构建复选框下,您将认出长的构建目录。

我们需要保持影子构建的启用,因为这使我们能够对不同的套件执行构建到替代位置的能力。我们将在.pro文件中控制我们构建的确切输出,但我们仍然需要在这里指定一个构建目录,以使 Qt Creator 保持愉快。输入/shadow-builds。使用窗格顶部的下拉菜单重复此设置,为每个构建配置(Debug/Release/Profile)和您正在使用的所有套件:

在您的文件系统中,删除任何旧的build-cm…文件夹。右键单击解决方案文件夹并运行 qmake。qmake 完成后,您应该看到cm-libcm-testscm-ui文件夹已经在/shadow-builds 中创建,并且长的build-cm…文件夹没有重新出现。

动态设置任何相对路径的第一步是知道您当前所在的路径。我们已经在 qmake 中看到了$$PWD的作用,以获取项目工作目录。为了帮助我们可视化正在发生的事情,让我们介绍我们的第一个 qmake 函数——message()

cm.pro中添加以下行——放在文件的任何位置都可以:

message(cm project dir: $${PWD})

cm-lib.pro中添加以下行:

message(cm-lib project dir: $${PWD})

message()是 qmake 支持的测试函数,它将提供的字符串参数输出到控制台。请注意,您不需要用双引号括起文本。当您保存更改时,您将看到解决方案项目和库项目的项目工作目录PWD)被记录到 General Messages 控制台中:

Project MESSAGE: cm project dir: C:/projects/qt/cm

Project MESSAGE: cm-lib project dir: C:/projects/qt/cm/cm-lib

qmake 实际上会对.pro文件进行多次处理,因此每当您使用message()时,您可能会在控制台中看到相同的输出多次。您可以使用message()与作用域一起来过滤掉大部分重复的内容——!build_pass:message(Here is my message)。这可以防止在构建过程中调用message()方法。

如果我们回顾 Qt Creator 对于影子构建的默认行为,我们会发现其目的是允许多个构建并存。这是通过构建包含工具包、平台和构建配置的不同文件夹名称来实现的:

build-cm-solution-Desktop_Qt_5_10_0_MinGW_32bit-Debug

仅通过查看文件夹名称,您就可以看出其中的内容是使用 Qt 5.10.0 为 Desktop MinGW 32 位工具包在调试模式下构建的cm项目。我们现在将以更清晰和更灵活的方式重新实施这种方法。

我们将更喜欢一个分层结构,包括操作系统 > 编译器 > 处理器架构 > 构建配置文件夹,而不是将信息连接成一个很长的文件夹名称。

首先硬编码此路径,然后再进行自动化。编辑cm-lib.pro并添加以下内容:

DESTDIR = $$PWD/../binaries/windows/gcc/x86/debug
message(cm-lib output dir: $${DESTDIR})

这是为了反映我们正在使用 MinGW 32 位工具包在 Windows 上以调试模式构建。如果您使用不同的操作系统,请将Windows替换为osxLinux。我们在 General Messages 控制台中添加了另一个message()调用以输出此目标目录。请记住,$$PWD提取正在处理的.pro文件(在本例中为cm-lib.pro)的工作目录,因此这给了我们<Qt Projects>/cm/cm-lib

右键单击cm-lib项目,运行 qmake 并构建。确保选择了 MinGW 工具包以及调试模式。

在文件系统中导航到<Qt Projects>/cm/binaries/<OS>/gcc/x86/debug,您将看到我们的库二进制文件,而不会有构建工件的混乱。这是一个很好的第一步,但是如果您现在将构建配置更改为 Release 或切换工具包,目标目录将保持不变,这不是我们想要的。

我们即将实施的技术将在我们的三个项目中使用,因此我们不必在所有的.pro文件中重复配置,让我们将配置提取到一个共享文件中并进行包含。

在根目录cm文件夹中,创建两个名为qmake-target-platform.priqmake-destination-path.pri的新空文本文件。在cm-lib.procm-tests.procm-ui.pro中添加以下行:

include(../qmake-target-platform.pri)
include(../qmake-destination-path.pri)

*.pro文件的顶部附近添加这些行。只要它们在设置DESTDIR变量之前,确切的顺序并不太重要。

编辑qmake-target-platform.pri如下:

win32 {
    CONFIG += PLATFORM_WIN
    message(PLATFORM_WIN)
    win32-g++ {
        CONFIG += COMPILER_GCC
        message(COMPILER_GCC)
    }
    win32-msvc2017 {
        CONFIG += COMPILER_MSVC2017
        message(COMPILER_MSVC2017)
        win32-msvc2017:QMAKE_TARGET.arch = x86_64
    }
}

linux {
    CONFIG += PLATFORM_LINUX
    message(PLATFORM_LINUX)
    # Make QMAKE_TARGET arch available for Linux
    !contains(QT_ARCH, x86_64){
        QMAKE_TARGET.arch = x86
    } else {
        QMAKE_TARGET.arch = x86_64
    }
    linux-g++{
        CONFIG += COMPILER_GCC
        message(COMPILER_GCC)
    }
}

macx {
    CONFIG += PLATFORM_OSX
    message(PLATFORM_OSX)
    macx-clang {
        CONFIG += COMPILER_CLANG
        message(COMPILER_CLANG)
        QMAKE_TARGET.arch = x86_64
    }
    macx-clang-32{
        CONFIG += COMPILER_CLANG
        message(COMPILER_CLANG)
        QMAKE_TARGET.arch = x86
    }
}

contains(QMAKE_TARGET.arch, x86_64) {
    CONFIG += PROCESSOR_x64
    message(PROCESSOR_x64)
} else {
    CONFIG += PROCESSOR_x86
    message(PROCESSOR_x86)
}
CONFIG(debug, release|debug) {
    CONFIG += BUILD_DEBUG
    message(BUILD_DEBUG)
} else {
    CONFIG += BUILD_RELEASE
    message(BUILD_RELEASE)
}

在这里,我们利用了 qmake 的平台检测功能,将个性化标志注入CONFIG变量中。在每个操作系统上,不同的平台变量变得可用。例如,在 Windows 上,存在win32变量,Linux 由linux表示,Mac OS X 由macx表示。我们可以使用这些平台变量与花括号一起充当 if 语句:

win32 {
    # This block will execute on Windows only…
}

我们可以考虑不同的平台变量组合,以确定当前选择的套件正在使用的编译器和处理器架构,然后向CONFIG添加开发人员友好的标志,以便稍后在我们的.pro文件中使用。请记住,我们正在尝试构建一个构建路径——操作系统 > 编译器 > 处理器架构 > 构建配置

当你保存这些更改时,你应该会在通用消息控制台中看到类似以下的标志:

Project MESSAGE: PLATFORM_WIN
Project MESSAGE: COMPILER_GCC
Project MESSAGE: PROCESSOR_x86
Project MESSAGE: BUILD_DEBUG

尝试切换套件或更改构建配置,你应该会看到不同的输出。当我将套件切换到 Visual Studio 2017 64 位的 Release 模式时,我现在得到了这个结果:

Project MESSAGE: PLATFORM_WIN
Project MESSAGE: COMPILER_MSVC2017
Project MESSAGE: PROCESSOR_x64
Project MESSAGE: BUILD_RELEASE

在使用 MinGW 64 位套件的 Linux 机器上,我得到了这个结果:

Project MESSAGE: PLATFORM_LINUX
Project MESSAGE: COMPILER_GCC
Project MESSAGE: PROCESSOR_x64
Project MESSAGE: BUILD_DEBUG

在使用 Clang 64 位的 Mac 上,我得到了以下结果:

Project MESSAGE: PLATFORM_OSX
Project MESSAGE: COMPILER_CLANG
Project MESSAGE: PROCESSOR_x64
Project MESSAGE: BUILD_DEBUG

为了使其在 Windows 上工作,我不得不做一个假设,因为QMAKE_TARGET.arch在 MSVC2017 上没有正确检测到,所以我假设如果编译器是 MSVC2017,那么它必须是 x64,因为没有 32 位套件可用。

现在所有的平台检测都已完成,我们可以动态构建目标路径。编辑qmake-destination-path.pri

platform_path = unknown-platform
compiler_path = unknown-compiler
processor_path = unknown-processor
build_path = unknown-build

PLATFORM_WIN {
    platform_path = windows
}
PLATFORM_OSX {
    platform_path = osx
}
PLATFORM_LINUX {
    platform_path = linux
}

COMPILER_GCC {
    compiler_path = gcc
}
COMPILER_MSVC2017 {
    compiler_path = msvc2017
}
COMPILER_CLANG {
    compiler_path = clang
}

PROCESSOR_x64 {
    processor_path = x64
}
PROCESSOR_x86 {
    processor_path = x86
}

BUILD_DEBUG {
    build_path = debug
} else {
    build_path = release
}

DESTINATION_PATH = $$platform_path/$$compiler_path/$$processor_path/$$build_path
message(Dest path: $${DESTINATION_PATH})

在这里,我们创建了四个新变量——platform_pathcompiler_pathprocessor_pathbuild_path——并为它们都分配了默认值。然后我们使用了在前一个文件中创建的CONFIG标志,并构建了我们的文件夹层次结构,将其存储在我们自己的变量DESTINATION_PATH中。例如,如果我们检测到操作系统是 Windows,我们会将PLATFORM_WIN标志添加到CONFIG中,从而将platform_path设置为windows。在 Windows 上切换套件和配置,我现在得到了这些消息:

Dest path: windows/gcc/x86/debug

或者,我得到了这个结果:

Dest path: windows/msvc2017/x64/release

在 Linux 上,我得到了以下结果:

Dest path: linux/gcc/x64/debug

在 Mac OS 上,我得到了这个结果:

Dest path: osx/clang/x64/debug

你可以将这些平台检测和目标路径创建技巧结合在一个文件中,但通过将它们分开,你可以在项目文件的其他地方使用这些标志。无论如何,我们现在正在根据我们的构建环境动态创建路径,并将其存储在一个变量中以供以后使用。

接下来要做的事情是将这个DESTINATION_PATH变量插入到我们的项目文件中。在这里,我们还可以使用相同的机制来构建我们的构建产物,通过添加几行代码。将以下内容添加到所有三个*.pro文件中,替换cm-lib.pro中已有的DESTDIR语句:

DESTDIR = $$PWD/../binaries/$$DESTINATION_PATH
OBJECTS_DIR = $$PWD/build/$$DESTINATION_PATH/.obj
MOC_DIR = $$PWD/build/$$DESTINATION_PATH/.moc
RCC_DIR = $$PWD/build/$$DESTINATION_PATH/.qrc
UI_DIR = $$PWD/build/$$DESTINATION_PATH/.ui

临时构建产物现在将放置在构建文件夹内的离散目录中。

最后,我们可以解决最初导致我们来到这里的问题。在cm-testscm-ui中,我们现在可以使用我们新的动态目标路径设置LIBS变量:

LIBS += -L$$PWD/../binaries/$$DESTINATION_PATH -lcm-lib

你现在可以右键单击cm项目,运行 qmake,并构建以自动构建所有三个子项目。所有的输出将被发送到正确的位置,库二进制文件可以很容易地被其他项目找到。你可以切换套件和配置,而不必担心引用错误的库。

总结

在本章中,我们将我们的项目创建技能提升到了一个新的水平,我们的解决方案现在开始成形。我们实现了 MVC 模式,并弥合了 UI 和业务逻辑项目之间的差距。我们尝试了我们的第一点 QML,并研究了 Qt 框架的基石 QObject。

我们移除了所有那些难看的build-cm…文件夹,展示了我们的 qmake 技巧,并控制了所有文件的位置。所有的二进制文件现在都放在cm/binaries文件夹中,按平台、编译器、处理器架构和构建配置进行组织。所有不需要的临时构建产物现在都被隐藏起来。我们可以自由切换套件和构建配置,并且我们的输出会自动重定向到正确的位置。

在第三章中,用户界面,我们将设计我们的 UI,并深入了解更多的 QML。

第三章:用户界面

在本章中,我们将更详细地了解 QML 并勾勒出我们的用户界面布局。我们将为所有屏幕创建占位视图,并实现一个在它们之间导航的框架。我们还将讨论这些视图中的内容,特别是如何以灵活和响应的方式锚定和调整元素的大小。我们将涵盖以下主题:

  • 用户界面设计

  • 创建视图

  • StackView 组件

  • 锚定元素

  • 调整元素大小

  • 在视图之间导航

UX

如果您曾经使用过其他声明性 UI 技术,如 HTML 和 XAML,它们通常采用父/子方法来处理 UI,即存在一个父视图或根视图,其中包含全局功能,例如顶级导航。然后有动态内容或子视图,根据需要切换并呈现上下文相关的命令。

我们将采用相同的方法,将我们的 MasterView 作为 UI 的根。我们将添加一个全局导航栏和一个内容窗格,我们可以根据需要添加和删除内容。子视图将可选择地呈现命令栏以执行操作,例如将记录保存到数据库。

让我们看看我们的基本布局目标:

导航栏(1)将一直存在,并包含按钮,这些按钮将引导用户进入应用程序中的关键区域。默认情况下,该栏将很窄,并且与按钮相关的命令将由图标表示;然而,按下切换按钮将展开该栏,以显示每个按钮的附带描述文本。

内容窗格(2)将是一堆子视图。通过在内容窗格中替换子视图来导航到应用程序的不同区域。例如,如果我们在导航栏上添加一个新客户按钮并按下它,我们将把新客户视图推送到内容框架堆栈上。

命令栏(3)是一个可选元素,将用于向用户呈现更多的命令按钮。与导航栏的关键区别在于,这些命令将与当前视图相关,与上下文相关。例如,当创建新客户时,我们将需要一个保存按钮,但当我们搜索客户时,保存按钮就没有意义。每个子视图将可选择地呈现自己的命令栏。命令将由图标呈现,并在下面有一个简短的描述。

现在让我们规划屏幕的流程,或者我们称之为视图:

创建视图

cm-ui中,右键单击views.qrc,然后选择添加新项…. 选择 Qt > QML 文件,然后单击选择…:

cm-ui/ui/views中创建SplashView.qml文件。重复此过程,直到创建了以下所有视图为止:

文件 目的
SplashView.qml 在加载 UI 时显示的占位视图。
DashboardView.qml 中央的“主页”视图。
CreateClientView.qml 用于输入新客户详细信息的视图。
EditClientView.qml 用于阅读/更新现有客户详细信息的视图。
FindClientView.qml 用于搜索现有客户的视图。

像之前一样在纯文本编辑器中编辑views.qrc。您会看到我们的新视图已经添加到了一个新的qresource块中,并且具有以下默认前缀:

<RCC>
    <qresource prefix="/views">
        <file alias="MasterView">views/MasterView.qml</file>
    </qresource>
    <qresource prefix="/">
        <file>views/SplashView.qml</file>
        <file>views/DashboardView.qml</file>
        <file>views/CreateClientView.qml</file>
        <file>views/EditClientView.qml</file>
        <file>views/FindClientView.qml</file>
    </qresource>
</RCC>

还要注意,项目导航器有点混乱:

将所有新文件移动到“/views”前缀块中,并删除“/”块。为每个新文件添加别名:

<RCC>
    <qresource prefix="/views">
        <file alias="MasterView.qml">views/MasterView.qml</file>
        <file alias="SplashView.qml">views/SplashView.qml</file>
        <file alias="DashboardView.qml">views/DashboardView.qml</file>
        <file alias="CreateClientView.qml">views/CreateClientView.qml</file>
        <file alias="EditClientView.qml">views/EditClientView.qml</file>
        <file alias="CreateAppointmentView.qml">views/CreateAppointmentView.qml</file>
        <file alias="FindClientView.qml">views/FindClientView.qml</file>
    </qresource>
</RCC>

一旦保存了这些更改,您应该看到导航器变得整洁了:

StackView

我们的子视图将通过StackView组件呈现,它提供了一个基于堆栈的导航模型,并内置了历史记录。当要显示新视图(在这种情况下,视图几乎可以是任何 QML)时,它们被推送到堆栈上,并且可以从堆栈中弹出,以返回到上一个视图。我们不需要使用历史记录功能,但它们是一个非常有用的功能。

要访问组件,我们首先需要引用该模块,因此在MasterView中添加导入:

import QtQuick.Controls 2.2

完成后,让我们用StackView替换包含欢迎消息的Text元素:

StackView {
    id: contentFrame
    initialItem: "qrc:/views/SplashView.qml"
}

我们为组件分配一个唯一标识符contentFrame,这样我们就可以在 QML 的其他地方引用它,并指定我们要默认加载的子视图——新的SplashView

接下来,编辑SplashView。将QtQuick模块版本更新为 2.9,以便与MasterView匹配(如果没有明确说明,对所有后续的 QML 文件都要这样做)。这并不是严格必要的,但避免视图之间的不一致是一个好习惯。Qt 的次要版本发布通常不会有太多破坏性的变化,但是在两个引用不同版本 QtQuick 的视图上运行相同的代码可能会表现出不同的行为,这可能会引起问题。

现在我们对这个视图所做的就是让一个矩形的宽度为 400 像素,高度为 200 像素,具有“充满活力”的背景颜色,这样我们就可以看到它已经加载了:

import QtQuick 2.9

Rectangle {
    width: 400
    height: 200
    color: "#f4c842"
}

颜色可以使用十六进制 RGB 值或命名的 SVG 颜色来指定,就像我们在这里做的一样。我通常觉得十六进制更容易,因为我永远记不住颜色的名称!

如果你将鼠标悬停在 Qt Creator 中的十六进制字符串上,你会得到一个非常有用的小弹出颜色样本。

现在运行应用程序,你会看到欢迎消息不再显示,取而代之的是一个绚丽的橙黄色矩形,这就是我们的SplashView

锚点

我们美妙的新SplashView有一个小问题,那就是它实际上并没有填满窗口。当然,我们可以将 400 x 200 的尺寸改为 1024 x 768,这样它就与MasterView匹配了,但是如果用户调整窗口大小会发生什么呢?现代 UI 都是响应式设计——动态内容可以适应呈现的显示器,因此为只适用于一个平台的硬编码属性并不理想。幸运的是,锚点来拯救我们了。

让我们利用我们可靠的旧scratchpad项目,看看锚点是如何运作的。

右键单击qml.qrc,在scratchpad文件夹中的main.qml文件旁边添加一个新的AnchorsDemo.qml QML 文件。不要担心子文件夹、.qrc前缀、别名或任何其他东西。

进入main.cpp,加载我们的新文件,而不是main.qml

engine.load(QUrl(QStringLiteral("qrc:/AnchorsDemo.qml")));

接下来,将以下代码粘贴到AnchorsDemo中:

import QtQuick 2.9
import QtQuick.Window 2.2

Window {
    visible: true
    width: 1024
    height: 768
    title: qsTr("Scratchpad")
    color: "#ffffff"
    Rectangle {
        id: paleYellowBackground
        anchors.fill: parent
        color: "#cece9e"
    }
    Rectangle {
        id: blackRectangleInTheCentre
        width: 120
        height: 120
        anchors.centerIn: parent
        color: "#000000"
    }
    Rectangle {
        id: greenRectangleInTheCentre
        width: 100
        height: 100
        anchors.centerIn: parent
        anchors.verticalCenterOffset: 20
        color: "#008000"
    }
    Rectangle {
        id: redRectangleTopLeftCorner
        width: 100
        height: 100
        anchors {
            top: parent.top
            left: parent.left
        }
        color: "#800000"
    }
    Rectangle {
        id: blueRectangleTopLeftCorner
        width: 100
        height: 100
        anchors{
            top: redRectangleTopLeftCorner.bottom
            left: parent.left
        }
        color: "#000080"
    }
    Rectangle {
        id: purpleRectangleTopLeftCorner
        width: 100
        height: 100
        anchors{
            top: blueRectangleTopLeftCorner.bottom
            left: parent.left
            leftMargin: 20
        }
        color: "#800080"
    }
    Rectangle {
        id: turquoiseRectangleBottomRightCorner
        width: 100
        height: 100
        anchors{
            bottom: parent.bottom
            right: parent.right
            margins: 20
        }
        color: "#008080"
    }
}

构建和运行应用程序,你会看到这个相当令人困惑的景象:

这一切乍一看可能有点令人困惑,如果你的颜色感知不够理想,我很抱歉,但我们所做的只是用不同的锚点值绘制一系列花哨的彩色矩形。让我们逐个矩形地走一遍,看看发生了什么:

Rectangle {
    id: paleYellowBackground
    anchors.fill: parent
    color: "#cece9e"
}

我们的第一个矩形是沉闷的黄褐色背景;anchors.fill: parent告诉矩形填充其父级,无论大小如何。任何给定的 QML 组件的父级是包含它的 QML 组件——在层次结构中的下一个级别。在这种情况下,它是Window元素。Window元素是 1024 x 768 像素,所以矩形就是这么大。请注意,我们不需要为矩形指定宽度和高度属性,因为它们是从锚点中推断出来的。

这正是我们想要的SplashView的行为,但在我们回到主项目之前,让我们看看锚点的一些其他功能:

Rectangle {
    id: blackRectangleInTheCentre
    width: 120
    height: 120
    anchors.centerIn: parent
    color: "#000000"
}
Rectangle {
    id: greenRectangleInTheCentre
    width: 100
    height: 100
    anchors.centerIn: parent
    anchors.verticalCenterOffset: 20
    color: "#008000"
}

我们将一起看接下来的两个矩形。首先是一个边长为 120 像素的黑色矩形;anchors.centerIn: parent将其定位在其父元素的中心。我们必须指定widthheight,因为我们只是定位它,而不是调整大小。

接下来,我们有一个稍小一点的绿色矩形,也是在其父元素中居中。然后我们使用anchors.verticalCenterOffset属性将其向下移动 20 像素。用于定位的xy坐标系统的根(0, 0)位于屏幕的左上角;verticalCenterOffset会增加 y 坐标。正数会将项目向下移动,负数会将项目向上移动。它的姐妹属性horizontalCenterOffset用于x轴的调整。

这里要注意的最后一件事是,矩形重叠,显示的是绿色矩形,黑色矩形被推到后面并被遮挡。同样,我们所有的小矩形都在大背景矩形的前面。QML 以自上而下的方式呈现,因此当根元素(Window)被绘制时,其子元素会从文件顶部到底部依次处理。因此,文件底部的项目将呈现在文件顶部的项目前面。如果你先把墙涂成白色,然后再涂成黑色,墙会变成黑色,因为那是最后涂的(呈现的):

Rectangle {
    id: redRectangleTopLeftCorner
    width: 100
    height: 100
    anchors {
        top: parent.top
        left: parent.left
    }
    color: "#800000"
}

接下来,我们画一个红色矩形,而不是一次性定位或调整整个矩形,我们只是锚定某些边。我们将其top边的锚点与其父元素(Window)的top边的锚点对齐。我们将其left边锚定到其父元素的left边。因此,它变成了与左上角“连接”起来。

我们必须输入以下内容:

anchors.top: parent.top
anchors.left: parent.left

这里还有一个有用的语法糖,我们可以去掉重复的部分,并在花括号内设置anchors组的子属性:

anchors {
    top: parent.top
    left: parent.left
}

接下来是蓝色矩形:

Rectangle {
    id: blueRectangleTopLeftCorner
    width: 100
    height: 100
    anchors{
        top: redRectangleTopLeftCorner.bottom
        left: parent.left
    }
    color: "#000080"
}

这遵循相同的模式,不过这次我们不仅仅附加到其父元素,还要锚定到一个兄弟元素(红色矩形),我们可以通过id属性引用它:

Rectangle {
    id: purpleRectangleTopLeftCorner
    width: 100
    height: 100
    anchors{
        top: blueRectangleTopLeftCorner.bottom
        left: parent.left
        leftMargin: 20
    }
    color: "#800080"
}

紫色矩形锚定在蓝色矩形的底部和窗口的左侧,但这里我们引入了第一个边距。每一边都有自己的边距,在这种情况下,我们使用leftMargin来给我们一个从左锚点的偏移,就像我们之前在verticalCenterOffset中看到的一样:

Rectangle {
    id: turquoiseRectangleBottomRightCorner
    width: 100
    height: 100
    anchors{
        bottom: parent.bottom
        right: parent.right
        margins: 20
    }
    color: "#008080"
}

最后,我们的青绿色矩形利用了屏幕右侧的一些空白空间,并演示了如何使用margins属性同时设置四个边的边距。

请注意,所有这些绑定都是动态的。尝试调整窗口大小,所有的矩形都会自动适应。锚点是响应式 UI 设计的好工具。

让我们回到我们的cm-ui项目中的SplashView,并应用我们刚学到的知识。用更动态的anchors.fill属性替换固定的widthheight属性:

Rectangle {
    anchors.fill: parent
    color: "#f4c842"
}

现在,SplashView将填充其父元素。构建并运行,你会发现,我们原本期望的可爱多彩的矩形已经完全消失了。让我们看看为什么会这样。

大小

我们的矩形将填满其父元素,因此矩形的大小完全取决于其父元素的大小。沿着 QML 层次结构向上走,包含矩形的组件是MasterView中的StackView元素:

StackView {
    id: contentFrame
    initialItem: Qt.resolvedUrl("qrc:/views/SplashView.qml")
}

通常,QML 组件足够聪明,可以根据它们的子元素自行调整尺寸。以前,我们将矩形设置为固定尺寸的 400 x 200。StackView可以查看并说:“我需要包含一个尺寸为 400 x 200 的Rectangle,所以我也会把自己做成 400 x 200。简单!”我们总是可以通过它的widthheight属性来覆盖它,并将其设置为其他尺寸,但它可以计算出它想要的尺寸。

回到scratchpad,创建一个新的SizingDemo.qml视图,并编辑main.cpp以在启动时加载它,就像我们在AnchorsDemo中所做的那样。编辑SizingDemo如下:

import QtQuick 2.9
import QtQuick.Window 2.2

Window {
    visible: true
    width: 1024
    height: 768
    title: qsTr("Scratchpad")
    color: "#ffffff"
    Column {
        id: columnWithText
        Text {
            id: text1
            text: "Text 1"
        }
        Text {
            id: text2
            text: "Text 2"
            width: 300
            height: 20
        }
        Text {
            id: text3
            text: "Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3"
        }
        Text {
            id: text4
            text: "Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4"
            width: 300
        }
        Text {
            id: text5
            text: "Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5"
            width: 300
            wrapMode: Text.Wrap
        }
    }
    Column {
        id: columnWithRectangle
        Rectangle {
            id: rectangle
            anchors.fill: parent
        }
    }
    Component.onCompleted: {
        console.log("Text1 - implicitWidth:" + text1.implicitWidth + " implicitHeight:" + text1.implicitHeight + " width:" + text1.width + " height:" + text1.height)
        console.log("Text2 - implicitWidth:" + text2.implicitWidth + " implicitHeight:" + text2.implicitHeight + " width:" + text2.width + " height:" + text2.height)
        console.log("Text3 - implicitWidth:" + text3.implicitWidth + " implicitHeight:" + text3.implicitHeight + " width:" + text3.width + " height:" + text3.height)
        console.log("Text4 - implicitWidth:" + text4.implicitWidth + " implicitHeight:" + text4.implicitHeight + " width:" + text4.width + " height:" + text4.height)
        console.log("Text5 - implicitWidth:" + text5.implicitWidth + " implicitHeight:" + text5.implicitHeight + " width:" + text5.width + " height:" + text5.height)
        console.log("ColumnWithText - implicitWidth:" + columnWithText.implicitWidth + " implicitHeight:" + columnWithText.implicitHeight + " width:" + columnWithText.width + " height:" + columnWithText.height)
        console.log("Rectangle - implicitWidth:" + rectangle.implicitWidth + " implicitHeight:" + rectangle.implicitHeight + " width:" + rectangle.width + " height:" + rectangle.height)
        console.log("ColumnWithRectangle - implicitWidth:" + columnWithRectangle.implicitWidth + " implicitHeight:" + columnWithRectangle.implicitHeight + " width:" + columnWithRectangle.width + " height:" + columnWithRectangle.height)
    }
}

运行这个,你会得到另一个充满无意义的屏幕:

对我们来说,更有趣的是控制台输出的内容:

qml: Text1 - implicitWidth:30 implicitHeight:13 width:30 height:13

qml: Text2 - implicitWidth:30 implicitHeight:13 width:300 height:20

qml: Text3 - implicitWidth:1218 implicitHeight:13 width:1218 height:13

qml: Text4 - implicitWidth:1218 implicitHeight:13 width:300 height:13

qml: Text5 - implicitWidth:1218 implicitHeight:65 width:300 height:65

qml: ColumnWithText - implicitWidth:1218 implicitHeight:124 width:1218 height:124

qml: Rectangle - implicitWidth:0 implicitHeight:0 width:0 height:0

qml: ColumnWithRectangle - implicitWidth:0 implicitHeight:0 width:0 height:0

那么,发生了什么?我们创建了两个Column元素,这是不可见的布局组件,可以垂直排列它们的子元素。我们用各种Text元素填充了第一个列,并在第二个列中添加了一个Rectangle。视图底部是一个 JavaScript 函数,当Window组件完成(即加载完成)时将执行。函数所做的就是写出视图上各个元素的implicitWidthimplicitHeightwidthheight属性。

让我们逐个浏览元素和相应的控制台行:

Text {
    id: text1
    text: "Text 1"
}

qml: Text1 - implicitWidth:30 implicitHeight:13 width:30 height:13

这个文本元素包含了一小段文本,我们没有指定任何尺寸。它的implicitWidthimplicitHeight属性是基于其内容所需的尺寸。它的widthheight属性是元素实际的尺寸。在这种情况下,它会根据自己的需求调整尺寸,因为我们没有另外指定,所以它的width/heightimplicitWidth/implicitHeight相同:

Text {
    id: text2
    text: "Text 2"
    width: 300
    height: 20
}

qml: Text2 - implicitWidth:30 implicitHeight:13 width:300 height:20

对于text2,隐式尺寸与text1相同,因为内容几乎相同。然而,这次,我们明确告诉它宽度为 300,高度为 20。控制台告诉我们,元素按照指示进行,并且确实是那个尺寸:

Text {
    id: text3
    text: "Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3 Text 3"
}

qml: Text3 - implicitWidth:1218 implicitHeight:13 width:1218 height:13

text3采取了与text1相同的不干涉方式,但内容是一段更长的文本。这次,implicitWidth要大得多,因为它需要适应长文本的空间。请注意,这实际上比窗口还要宽,文本被截断了。同样,我们没有另外指示,所以它自行调整尺寸:

Text {
    id: text4
    text: "Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4 Text 4"
    width: 300
}

qml: Text4 - implicitWidth:1218 implicitHeight:13 width:300 height:13

text4有相同的冗长文本块,但这次我们告诉它我们想要的宽度。你会注意到,即使元素只有 300 像素宽,文本也能在整个窗口上都可见。内容溢出了容器的边界。你可以将clip属性设置为true来防止这种情况,但我们在这里并不太关心:

Text {
    id: text5
    text: "Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 
    5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5   
    Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 
    5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5 Text 5"
    width: 300
    wrapMode: Text.Wrap
}

qml: Text5 - implicitWidth:1218 implicitHeight:65 width:300 height:65

text5重复了相同的长文本块,并将宽度限制为 300,但这次,我们通过将wrapMode属性设置为Text.Wrap来使事情更有条理。通过这个设置,启用的行为更像是你从一个文本块中期望的——它填满了可用的宽度,然后换行到下一行。元素的implicitHeight和因此height已增加以容纳内容。然而,请注意,implicitHeight仍然与之前相同;这仍然是控件希望的宽度,以便根据我们定义的约束来容纳其所有内容,而我们没有定义高度约束。

然后我们打印出包含所有这些文本的列的属性:

qml: ColumnWithText - implicitWidth:1218 implicitHeight:124 width:1218 height:124

需要注意的重要一点是,列能够计算出需要多宽和多高才能容纳所有子元素。

接下来,我们遇到了在SplashView中遇到的问题:

Column {
    id: columnWithRectangle
    Rectangle {
        id: rectangle
        anchors.fill: parent
    }
}

在这里,我们遇到了一个鸡生蛋蛋生鸡的情况。Column试图计算出容纳其子元素所需的大小,因此它查看了RectangleRectangle没有显式的大小信息,也没有自己的子元素,它只是设置为填充其父元素Column。两个元素都无法确定自己应该有多大,因此它们都默认为 0x0,这使它们变得不可见。

qml: Rectangle - implicitWidth:0 implicitHeight:0 width:0 height:0

qml: ColumnWithRectangle - implicitWidth:0 implicitHeight:0 width:0 height:0

多年来,元素的大小调整可能是我在 QML 中遇到的最困扰的问题。作为一般指导方针,如果您编写了一些 QML 但无法在屏幕上看到它呈现,那可能是一个大小问题。我通常发现,当调试时,给每个元素一个任意的固定宽度高度是一个好的开始,然后逐个使尺寸动态化,直到重新创建问题。

有了这个知识,让我们回到MasterView并解决之前的问题。

anchors.fill: parent添加到StackView组件:

StackView {
    id: contentFrame
    anchors.fill: parent
    initialItem: Qt.resolvedUrl("qrc:/views/SplashView.qml")
}

StackView现在将填充其父级Window,我们已经明确给定了固定大小为 1024 x 768。再次运行应用程序,现在您应该有一个可爱的橙黄色的SplashView,它填满了屏幕,并且在调整窗口大小时可以愉快地调整大小:

导航

让我们快速在我们的SplashView中添加一个内容:

Rectangle {
    anchors.fill: parent
    color: "#f4c842"
    Text {
        anchors.centerIn: parent
        text: "Splash View"
    }
}

这只是将视图的名称添加到屏幕上,因此当我们开始在视图之间移动时,我们知道我们正在查看哪一个。完成后,将SplashView的内容复制到所有其他新视图中,并更新每个视图中的文本以反映视图的名称,例如,在DashboardView中,文本可以说“Dashboard View”。

我们想要进行的第一次导航是当MasterView加载完成并且我们准备好进行操作时,加载DashboardView。我们可以使用我们刚刚看到的 QML 组件插槽之一Component.onCompleted()来实现这一点。

MasterView中的根Window组件中添加以下行:

Component.onCompleted: contentFrame.replace("qrc:/views/DashboardView.qml");

现在构建和运行时,一旦MasterView加载完成,它就会将子视图切换到DashboardView。这可能发生得如此之快,以至于您甚至不再看到SplashView,但它仍然存在。如果您的应用程序需要进行大量初始化,并且无法使用非阻塞 UI,那么拥有这样的启动视图是非常好的。这是一个方便的地方,可以放置公司标志和“Reticulating splines...”加载消息。是的,这是一个模拟人生的参考!

StackView 就像是你的网络浏览器中的历史记录。如果你访问www.google.com,然后访问www.packtpub.com,你就是在将www.packtpub.com 推送到堆栈上。如果你在浏览器上点击返回,你就会回到www.google.com。这个历史记录可以包含多个页面(或视图),你可以通过它们向后和向前导航。有时你不需要历史记录,有时你甚至不希望用户能够返回。我们调用的replace()方法,正如其名称所示,会将一个新视图推送到堆栈上,并清除任何历史记录,这样你就无法返回。

Component.onCompleted槽中,我们已经看到了如何直接从 QML 中导航到视图的示例。我们可以使用这种方法来进行应用程序的所有导航。例如,我们可以添加一个按钮,让用户创建一个新的客户,当点击时,直接将CreateClientView推送到堆栈上,如下所示:

Button {
    onClicked: contentFrame.replace("qrc:/views/CreateClientView.qml")
}

对于 UX 设计或简单的 UI 重型应用程序,这是一个完全有效的方法。问题在于你的 QML 视图和组件变得非常紧密地耦合,而业务逻辑层对用户的操作一无所知。很多时候,移动到应用程序的新屏幕并不像只是显示一个新视图那么简单。你可能需要更新状态机,设置一些模型,或者清除前一个视图中的一些数据。通过将所有的导航请求都通过我们的MasterController中转站,我们解耦了我们的组件,并获得了业务逻辑拦截点,以便执行任何必要的操作,并验证请求是否合适。

我们将通过从业务逻辑层发出信号并让我们的MasterView对其做出响应并执行过渡来请求导航到这些视图。我们不会在MasterController中添加这些功能,而是将导航的责任委托给cm-lib中的一个新控制器,因此在cm/cm-lib/source/controllers中创建一个名为navigation-controller.h的新头文件(没有实际的实现,所以我们不需要一个.cpp文件),并添加以下代码:

#ifndef NAVIGATIONCONTROLLER_H
#define NAVIGATIONCONTROLLER_H

#include <QObject>

#include <cm-lib_global.h>
#include <models/client.h>

namespace cm {
namespace controllers {

class CMLIBSHARED_EXPORT NavigationController : public QObject
{
    Q_OBJECT

public:
    explicit NavigationController(QObject* _parent = nullptr)
        : QObject(_parent)
    {}

signals:
    void goCreateClientView();
    void goDashboardView();
    void goEditClientView(cm::models::Client* client);
    void goFindClientView();
};

}
}
#endif

我们创建了一个最小的类,它继承自QObject,并为我们的新视图实现了一个信号。请注意,我们不需要导航到MasterViewSplashView,因此没有相应的信号。当我们导航到EditClientView时,我们需要通知 UI 我们想要编辑哪个Client,因此我们将其作为参数传递。从业务逻辑代码的任何地方调用这些方法会向外界发出一个请求,说“我想去某个视图,请”。然后由 UI 层的MasterView来监视这些请求并做出相应的响应。请注意,业务逻辑层仍然对 UI 实现一无所知。如果没有人响应这个信号,也没关系;这不是双向通信。

每当你从QObject继承时,一定要记住Q_OBJECT宏,还有一个接受QObject父对象的重载构造函数。由于我们希望在这个项目之外(在 UI 项目中)使用这个类,我们还必须记住 CMLIBSHARED_EXPORT 宏。

我们在这里稍微展望了一下,并假设我们的 Client 类将在cm::models命名空间中,但 Qt 在我们创建项目时为我们添加的默认Client类并不在这个命名空间中,所以在继续之前让我们先修复这个问题。

client.h

#ifndef CLIENT_H
#define CLIENT_H

#include "cm-lib_global.h"

namespace cm {
namespace models {

class CMLIBSHARED_EXPORT Client
{
public:
    Client();
};

}}

#endif

client.cpp

#include "client.h"

namespace cm {
namespace models {

Client::Client()
{
}

}}

我们需要能够创建一个 NavigationController 的实例,并让我们的 UI 与它交互。出于单元测试的原因,将对象创建隐藏在某种对象工厂接口后面是一个很好的做法,但在这个阶段我们不关心这个,所以我们将简单地在MasterController中创建对象。让我们趁机在MasterController中添加私有实现(PImpl)习惯用法。如果你以前没有接触过 PImpl,它只是一种将所有私有实现细节从头文件中移出并放入定义中的技术。这有助于保持头文件尽可能短和干净,只包含对公共 API 的消费者必要的包含。将声明和实现替换为以下内容:

master-controller.h

#ifndef MASTERCONTROLLER_H
#define MASTERCONTROLLER_H

#include <QObject>
#include <QScopedPointer>
#include <QString>

#include <cm-lib_global.h>
#include <controllers/navigation-controller.h>

namespace cm {
namespace controllers {

class CMLIBSHARED_EXPORT MasterController : public QObject
{
    Q_OBJECT
    Q_PROPERTY( QString ui_welcomeMessage READ welcomeMessage CONSTANT )
    Q_PROPERTY( cm::controllers::NavigationController* ui_navigationController READ navigationController CONSTANT )

public:
    explicit MasterController(QObject* parent = nullptr);
    ~MasterController();

    NavigationController* navigationController();
    const QString& welcomeMessage() const;

private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};

}}
#endif

master-controller.cpp

#include "master-controller.h"

namespace cm {
namespace controllers {

class MasterController::Implementation
{
public:
    Implementation(MasterController* _masterController)
        : masterController(_masterController)
    {
        navigationController = new NavigationController(masterController);
    }

    MasterController* masterController{nullptr};
    NavigationController* navigationController{nullptr};
    QString welcomeMessage = "This is MasterController to Major Tom";
};

MasterController::MasterController(QObject* parent)
    : QObject(parent)
{
    implementation.reset(new Implementation(this));
}

MasterController::~MasterController()
{
}

NavigationController* MasterController::navigationController()
{
    return implementation->navigationController;
}

const QString& MasterController::welcomeMessage() const
{
    return implementation->welcomeMessage;
}

}}

你可能已经注意到,对于 NavigationController 的访问器方法,我们没有指定 cm::controllers 命名空间,但对于Q_PROPERTY我们做了。这是因为属性是由 UI QML 访问的,它不在cm命名空间的范围内执行,所以我们必须明确指定完全限定的名称。作为一个一般的经验法则,对于 QML 直接交互的任何东西,包括信号和插槽中的参数,都要明确指定命名空间。

接下来,我们需要在cm-ui项目中使用main.cpp注册新的NavigationController类,所以在现有的MasterController旁边添加以下注册:

qmlRegisterType<cm::controllers::NavigationController>("CM", 1, 0, "NavigationController");

我们现在准备好让MasterView对这些导航信号做出反应。在StackView之前添加以下元素:

Connections {
    target: masterController.ui_navigationController
    onGoCreateClientView: contentFrame.replace("qrc:/views/CreateClientView.qml")
    onGoDashboardView: contentFrame.replace("qrc:/views/DashboardView.qml")
    onGoEditClientView: contentFrame.replace("qrc:/views/EditClientView.qml", {selectedClient: client})
    onGoFindClientView: contentFrame.replace("qrc:/views/FindClientView.qml")
}

我们正在创建一个连接组件,绑定到我们的新NavigationController实例,它对我们添加的每个 go 信号做出反应,并通过contentFrame导航到相关视图,使用我们之前用于移动到仪表板的replace()方法。因此,每当NavigationController上触发goCreateClientView()信号时,我们的Connections组件上的onGoCreateClientView()插槽将被调用,并且CreateClientView将加载到名为contentFrameStackView中。在onGoEditClientView的情况下,从信号传递了一个client参数,我们将该对象传递给一个名为selectedClient的属性,稍后我们将在视图中添加该属性。

在 QML 组件中,一些信号和插槽是自动生成并连接的,遵循约定。插槽的命名方式是on[CapitalisedNameOfRelatedSignal]。例如,如果有一个名为mySplendidSignal()的信号,那么相应的插槽将被命名为onMySplendidSignal。这些约定适用于我们的NavigationControllerConnections组件。

接下来,让我们在MasterView中添加一个导航栏,带有一些占位按钮,以便我们可以尝试这些信号。

添加一个Rectangle来形成我们条的背景:

Rectangle {
    id: navigationBar
    anchors {
        top: parent.top
        bottom: parent.bottom
        left: parent.left
    }
    width: 100
    color: "#000000"
}

这会在视图的左侧绘制一个宽度为 100 像素的黑色条。

我们还需要调整我们的StackView,以便为我们的条留出一些空间。我们不是填充其父级,而是将其四个边的三个边锚定到其父级,但将左侧与我们的条的右侧连接起来:

StackView {
    id: contentFrame
    anchors {
        top: parent.top
        bottom: parent.bottom
        right: parent.right
        left: navigationBar.right
    }
    initialItem: Qt.resolvedUrl("qrc:/views/SplashView.qml")
}

现在,让我们在我们的导航Rectangle中添加一些按钮:

 Rectangle {
    id: navigationBar
    …

    Column {
        Button {
            text: "Dashboard"
            onClicked: masterController.ui_navigationController.goDashboardView()
        }
        Button {
            text: "New Client"
            onClicked: masterController.ui_navigationController.goCreateClientView()
        }
        Button {
            text: "Find Client"
            onClicked: masterController.ui_navigationController.goFindClientView()
        }
    }

}

我们使用Column组件来为我们布局按钮,而不是必须单独将按钮锚定到彼此。每个按钮显示一些文本,当点击时,调用NavigationController上的一个信号。我们的Connection组件对信号做出反应,并为我们执行视图转换:

太棒了,我们有一个功能完善的导航框架!然而,当你点击导航按钮时,导航栏会短暂消失然后再次出现。我们的应用输出控制台中也出现了“冲突的锚点”消息,这表明我们做了一些不太对的事情。在继续之前,让我们解决这些问题。

解决冲突

导航栏的问题很简单。如前所述,QML 的结构是分层的。这体现在元素的渲染方式上——首先出现的子元素首先被渲染。在我们的情况下,我们先绘制导航栏,然后再绘制内容框架。当StackView组件加载新内容时,默认情况下会应用花哨的过渡效果,使其看起来很漂亮。这些过渡效果可能导致内容移出控件的边界并覆盖在其下方的任何内容上。有几种方法可以解决这个问题。

首先,我们可以重新排列组件的渲染顺序,并将导航栏放在内容框架之后。这将在StackView的顶部绘制导航栏,而不管它的情况如何。第二个选项,也是我们将实现的选项,就是简单地设置StackViewclip属性:

clip: true

这会裁剪任何超出控件边界的内容,并且不会渲染它。

下一个问题有点更加深奥。正如我们讨论过的,QML 开发过去几年中我遇到的最令人困惑的问题之一是组件的大小。我们使用的一些组件,比如Rectangle,本质上是视觉元素。如果它们的大小没有被定义,要么是直接使用width/height属性,要么是间接使用anchors,那么它们就不会被渲染。其他元素,比如Connections,根本不是视觉元素,大小属性是多余的。布局元素,比如Column,可能在一个轴上有固定的大小,但在另一个轴上是动态的。

大多数组件共同的一点是它们都继承自Item,而Item又直接继承自QtObject,它只是一个普通的QObject。就像 C++端的 Qt 框架为普通的QObject实现了很多默认行为一样,QML 组件通常为我们可以在这里利用的Item组件实现了默认行为。

在我们的子视图中,我们使用Rectangle作为根对象。这是有道理的,因为我们想要显示一个固定大小和颜色的矩形。然而,这对StackView造成了问题,因为它不知道自己应该有多大。为了提供这些信息,我们尝试将其锚定到其父级(StackView),但这又会引发自己的问题,与我们切换视图时StackView正在执行的过渡效果发生冲突。

我们摆脱这个困境的方法是,将子视图的根改为普通的ItemStackView组件具有处理Item组件的内部逻辑,并且会自动调整大小。然后,我们的Rectangle组件就成为了已经自动调整大小的Item组件的子组件,我们可以将其锚定到这个组件上:

Item {
    Rectangle {
        ...
    }
}

这有点令人困惑,感觉像巫术一样,但这里的要点是,在你的自定义 QML 中,将Item作为根元素通常是一个好主意。继续在所有子视图中以这种方式添加根Item组件(但不包括MasterView)。

再次运行应用程序,现在你应该有流畅的过渡效果,并且控制台中没有警告消息。

总结

我们已经建立了一个灵活的、解耦的导航机制,并成功地在不同的视图之间进行了过渡。我们已经建立了导航栏的基本结构,并且在本章开头设计的工作内容窗格中工作。

让 UI 调用业务逻辑层发出信号,然后 UI 对此做出反应,可能看起来有点绕弯,但这种业务逻辑信号/UI 插槽设计带来了好处。它使 UI 模块化,因为视图不需要相互了解。它将导航逻辑保留在业务逻辑层,并使该层能够请求 UI 将用户导航到特定视图,而无需了解 UI 或视图本身的任何信息。关键是,它还为我们提供了拦截点,因此当用户请求导航到特定视图时,我们可以处理它并执行任何我们需要的额外处理,比如状态管理或清理。

在第四章“样式”中,我们将介绍共享样式组件,以及在完成动态命令栏的 UI 设计之前,介绍 QML 模块和图标。

第四章:样式

在开发过程中,通常最好先考虑功能,然后再考虑形式,但 UI 是我们的用户与之交互的应用程序的一部分,也是成功解决方案的关键因素。在本章中,我们将介绍类似 CSS 的样式资源,并在上一章介绍的响应式设计原则的基础上进行构建。

我们将创建自定义的 QML 组件和模块,以最大程度地重用代码。我们将集成 Font Awesome 到我们的解决方案中,为我们提供一套可扩展的图标,并帮助我们的 UI 呈现出现代的图形外观。我们将整理导航栏,引入命令的概念,并构建一个动态的、上下文敏感的命令栏的框架。

在本章中,我们将涵盖以下主题:

  • 自定义样式资源

  • 字体真棒

  • 自定义组件

  • 导航栏样式

  • 命令

样式资源

首先,让我们创建一个新的资源文件,以包含我们需要的非 QML 视觉元素。在cm-ui项目中,添加新... > Qt > Qt 资源文件:

将文件命名为assets.qrc,并将其放置在cm/cm-ui中。您的新文件将自动在资源编辑器中打开,我发现这个编辑器并不是特别有用,所以关闭它。您将看到assets.qrc文件已添加到cm-ui项目的资源部分。右键单击它,然后选择添加新... > Qt > QML 文件。将文件命名为Style.qml,并将其保存到cm/cm-ui/assets

在纯文本编辑器中编辑assets.qrc文件,方式与我们为视图所做的方式相同:

<RCC>
    <qresource prefix="/assets">
        <file alias="Style.qml">assets/Style.qml</file>
    </qresource>
</RCC>

现在,编辑Style.qml,我们将添加一个用于视图背景颜色的单个样式属性:

pragma Singleton
import QtQuick 2.9

Item {
    readonly property color colourBackground: "#f4c842"
}

在 C++术语中,我们正在创建一个具有名为colourBackground的 const 颜色类型的公共成员变量的单例类,并初始化为(非常)浅灰色的十六进制 RGB 代码的值。

现在,我们需要进行一点手动的调整。我们需要在与Style.qmlcm/cm-ui/assets)相同的文件夹中创建一个名为qmldir的模块定义文件(没有文件扩展名)。对于这种类型的文件,没有内置模板,因此我们需要自己创建它。在旧版本的 Windows 中,文件资源管理器总是坚持要求文件扩展名,因此这总是一个痛苦的练习。需要使用控制台命令强制重命名文件。Windows 10 将愉快地创建没有扩展名的文件。在 Unix 世界中,没有扩展名的文件更常见。

创建qmldir文件后,编辑assets.qrc,并在/assets前缀内的Style.qml旁边插入一个新条目:

<file alias="qmldir">assets/qmldir</file>

双击新添加的qmldir文件,并输入以下行:

module assets
singleton Style 1.0 Style.qml

我们已经在导入 QtQuick 2.9时看到了模块。这使得 QtQuick 模块的 2.9 版本可以在我们的视图中使用。在我们的qmldir文件中,我们正在定义一个名为assets的新模块,并告诉 Qt 该模块的 1.0 版本中有一个Style对象,其实现在我们的Style.qml文件中。

创建并连接了我们的新样式模块后,现在让我们开始使用这种现代的米白色。从我们看到的第一个子视图SplashView开始,并添加以下内容以访问我们的新模块:

import assets 1.0

您会注意到我们被呈现出愤怒的红色下划线,表明一切并不顺利。将鼠标指针悬停在该行上,工具提示会告诉我们,我们需要将导入路径添加到我们的新qmldir定义文件中。

有几种方法可以做到这一点。第一种选择是转到“项目”模式,选择当前“工具包”的构建设置,然后选择调试模式。在“构建环境”部分的底部,单击“详细信息”。在这里,您可以看到当前工具包和配置的所有环境变量的列表。添加一个名为 QML2_IMPORT_PATH 的新变量,并将其值设置为cm-ui文件夹:

这将cm-ui项目的工作目录(/projects/qt/cm/cm-ui)添加到 QML 导入路径。请注意,我们的模块名必须反映到qmldir文件相对于此导入路径的相对路径。

这种方法的问题在于,这个环境变量与cm.pro.user文件绑定。如果您与其他开发人员共享项目,他们将拥有自己的cm.pro.user文件,并且他们必须记住也要添加这个变量。此外,它与绝对路径绑定,如果您将项目代码复制到另一台机器上,它可能不在那个位置。

第二种,也是首选的选项是在实例化QQmlApplicationEngine之后立即在main.cpp中添加以下行:

engine.addImportPath("qrc:/");

那么为什么是qrc:/而不是我们qmldir文件的绝对路径?您会记得我们在cm-ui.pro中的RESOURCES变量中添加了我们的views.qrc资源包。这样做的作用是将views.qrc中的所有文件编译到应用程序二进制文件中,形成一种虚拟文件系统,其中前缀充当虚拟文件夹。这个虚拟文件系统的根目录被引用为qrc:/,通过在导入路径中使用这个,我们实质上是在要求 Qt 在我们的所有捆绑资源文件中查找任何模块。转到cm-ui.pro,确保我们的新assets.qrc也已添加到RESOURCES中:

RESOURCES += views.qrc \
    assets.qrc

这可能有点令人困惑,所以重申一下,我们已经添加了以下文件夹来搜索新的模块,可以使用 QML2_IMPORT_PATH 环境变量在本地物理文件系统上搜索我们的cm-ui项目文件夹,或者使用addImportPath()方法在运行时搜索我们虚拟资源文件系统的根目录。

在这两种情况下,定义我们的新模块的qmldir文件位于一个名为assets的文件夹中,即在物理文件系统中的<Qt Projects>/cm/cm-ui/assets或虚拟文件系统中的qrc:/assets

这给我们模块名assets。如果我们的文件夹结构更深,比如 stuff/badgers/assets,那么我们的模块需要被称为stuff.badgers.assets,因为这是相对于我们定义的导入路径的路径。同样,如果我们想为现有视图添加另一个模块,我们将在cm-ui/views中创建一个qmldir文件,并称模块为views

如果您发现 Qt Creator 仍然有点困惑,红线仍然存在,请确保cm-ui.pro包含QML_IMPORT_PATH += $$PWD行。

有了这一切,我们现在可以使用我们的新模块。包括模块意味着我们现在可以访问我们的单例Style对象并从中读取属性。替换我们的SplashViewcolor属性:

Rectangle {
    ...    
    color: Style.colourBackground
    ...
}

重复此操作,为除MasterView之外的所有视图设置背景颜色。记得在每个视图中也包含include ui.assets 1.0

当您构建和运行应用程序时,您可能会想知道为什么我们要经历所有这些麻烦,而视图看起来与以前完全相同。好吧,假设我们刚刚与营销部的人开了个会,他们告诉我们,橙黄色不再适合品牌,我们需要将所有视图更改为干净的米白色。以前,我们必须进入每个视图,并将颜色从#f4c842更改为#efefef。现在,只有七个,所以这没什么大不了的,但是想象一下,如果我们不得不为 50 个复杂的视图中的所有组件更改所有颜色,那将是一个非常痛苦的过程。

然而,转到Style.qml并将colourBackground属性从#f4c842更改为#efefef。构建和运行应用程序,沐浴在我们重新品牌的应用程序的荣耀中!通过尽早设置我们的共享样式组件,我们可以在进行的过程中添加属性,然后稍后重新设计我们的应用程序变得更容易。我们可以在这里添加所有类型的属性,不仅仅是颜色,所以随着我们进一步开发,我们将添加大小、字体和其他东西。

Font Awesome

有了我们的样式框架,让我们来看看我们的导航栏是什么样子的,然后想想我们想要实现什么:

我们想要在导航栏上显示的按钮是仪表板视图(主页视图)、新客户视图和查找客户视图,以及顶部的切换按钮,用于展开和折叠栏。

常见的 UI 设计模式是使用图标表示简单的命令。有多种方式可以获取有关命令的更多信息;例如,当您悬停在按钮上时,可以在工具提示中或屏幕底部的状态栏中显示信息。我们的方法是拥有一个可折叠的栏。栏的默认状态将是折叠的,并显示代表每个命令的图标。在展开状态下,栏将显示图标和命令的文本描述。用户可以使用额外的按钮切换状态。这是一种在移动应用程序开发中特别普遍的模式,因为您希望默认情况下尽可能少地占用屏幕空间。

有几种选项可以显示按钮的图标。较旧的桌面应用程序很可能会使用某种图像文件。这样可以完全控制图标的外观,但也带来了一些缺点。图像文件往往比较大,并且是固定大小的。如果需要以不同的大小绘制它们,它们可能会看起来很糟糕,特别是如果它们被放大或者纵横比发生变化。

可缩放矢量图形SVG)文件要小得多,并且缩放效果非常好。它们更难创建,在艺术上可能有一些限制,但对于图标的用途非常有用。然而,根据经验,它们在 Qt/QML 中可能会很棘手。

第三种选项可以让您获得 SVG 的小文件大小和可伸缩性优势,但更容易使用的是符号字体文件。这是 Web 开发中非常常见的解决方案,也是我们将采取的方法。

有许多符号字体可用,但也许最受欢迎的是Font Awesome。它提供了各种精彩的符号,并且有一个非常有帮助的网站;请查看:fontawesome.io/

检查您选择使用的字体的任何许可证,特别是如果您要商业使用它们。

下载工具包并打开存档文件。我们感兴趣的文件是fonts/fontawesome-webfont.ttf。将此文件复制到我们项目文件夹中的cm/cm-ui/assets中。

在我们的cm-ui项目中,编辑assets.qrc并将字体添加到我们的资源中:

<file alias="fontawesome.ttf">assets/fontawesome-webfont.ttf</file>

请记住,我们的别名不一定要与原始文件名相同,我们已经有机会将其缩短一点。

接下来,编辑Style.qml,我们将把字体与我们的自定义样式连接起来,以便轻松使用。我们首先需要加载字体并使其可用,我们使用FontLoader组件来实现这一点。在根Item元素内添加以下内容:

FontLoader {
    id: fontAwesomeLoader
    source: "qrc:/assets/fontawesome.ttf"
}    

source属性中,我们使用了我们在assets.qrc文件中定义的/assets前缀(或虚拟文件夹),以及fontawesome.ttf的别名。现在,我们已经加载了字体,但是就目前而言,我们无法从Style.qml之外引用它。这是因为只有根组件级别的属性可以在文件之外访问。子组件被视为私有的。我们绕过这个问题的方法是为我们想要公开的元素创建一个property alias

Item {
    property alias fontAwesome: fontAwesomeLoader.name

    readonly property color colourBackground: "#efefef"

    FontLoader {
        id: fontAwesomeLoader
        source: "qrc:/assets/fontawesome.ttf"
    }    
}

这将创建一个名为fontAwesome的公共可用属性,当调用时,它会简单地将调用者重定向到内部fontAwesomeLoader元素的name属性。

完成连接后,让我们找到我们想要使用的图标。回到 Font Awesome 网站,转到图标页面。在这里,您可以看到所有可用的图标。单击其中一个将显示有关它的更多信息,我们可以从中获取需要显示它的关键信息,即 Unicode 字符。我将为我们的菜单选择以下图标,但请随意选择任何您想要的图标:

命令 图标 Unicode 字符
Toggle Menu bars f0c9
Dashboard home f015
New Client user-plus f234
Find Client search f002

现在,让我们用每个图标的Text组件替换MasterView上的Button组件:

Column {
    Text {
        font {
            family: Style.fontAwesome
            pixelSize: 42
        }
        color: "#ffffff"
        text: "\uf0c9"
    }
    Text {
        font {
            family: Style.fontAwesome
            pixelSize: 42
        }
        color: "#ffffff"
        text: "\uf015"
    }
    Text {
        font {
            family: Style.fontAwesome
            pixelSize: 42
        }
        color: "#ffffff"
        text: "\uf234"
    }
    Text {
        font {
            family: Style.fontAwesome
            pixelSize: 42
        }
        color: "#ffffff"
        text: "\uf002"
    }
}

如果您还没有添加assets 1.0导入,则还需要添加它:

接下来,我们将为客户命令添加描述性文本。将每个Text组件包装在Row中,并添加一个描述的Text组件,如下所示:

Row {
    Text {
        font {
            family: Style.fontAwesome
            pixelSize: 42
        }
        color: "#ffffff"
        text: "\uf234"
    }
    Text {
        color: "#ffffff"
        text: "New Client"
    }
}

Row组件将水平布置其子元素——首先是图标,然后是描述性文本。对其他命令重复此操作。为其他按钮添加 Dashboard 和 Find Client 的描述,对于切换命令只需添加空字符串:

在我们进一步进行更改之前,我们将停下来,进行一些重构,并开始引入组件。

组件

我们刚刚编写的 QML 已经足够功能,但已经变得难以维护。我们的MasterView变得有点长,难以阅读。例如,当我们要更改命令按钮的外观时,例如对齐图标和文本,我们将不得不在四个地方进行更改。如果我们想要添加第五个按钮,我们必须复制、粘贴和编辑大量的 QML。这就是可重用组件发挥作用的地方。

组件与我们已经创建的视图完全相同——只是 QML 的片段。区别纯粹是语义上的。在本书中,视图代表布局内容的屏幕,而组件是内容。

创建新组件的最简单方法是当您已经编写了要形成组件基础的 QML 时。右键单击我们为命令添加的任何Row元素,并选择重构 > 将组件移动到单独的文件中

将新组件命名为NavigationButton并将其保存到一个新文件夹cm/cm-ui/components中:

Row元素将移动到我们的新文件中,在MasterView中,您将得到一个空的NavigationButton组件:

NavigationButton {
}

不幸的是,它带有一个大大的红色波浪线,我们的应用程序将不再运行。虽然重构步骤已经为我们创建了一个新的NavigationButton.qml文件,但它实际上并没有包含在我们的项目中,所以 Qt 不知道它在哪里。不过,解决起来很容易,我们只需要像我们对视图和资产所做的那样设置我们的资源包:

  1. 创建一个名为components.qrc的新的Qt Resource File,放在cm/cm-ui文件夹中

  2. cm/cm-ui/components中创建一个空的qmldir文件,就像我们为我们的资产所做的那样

  3. 编辑components.qrc以在/components前缀下包含我们的两个新文件:

<RCC>
    <qresource prefix="/components">
        <file alias="qmldir">components/qmldir</file>
        <file   
 alias="NavigationButton.qml">components/NavigationButton.qml</file>
    </qresource>
</RCC>
  1. 编辑qmldir以设置我们的模块并将我们的NavigationButton组件添加到其中:
module components
NavigationButton 1.0 NavigationButton.qml
  1. 确保components.qrc已添加到cm-ui.pro中的RESOURCES变量中

  2. MasterView中,包含我们的新组件模块,以便访问我们的新组件:

import components 1.0

有时,要使我们的模块得到完全识别并消除红色波浪线,可能只能通过重新启动 Qt Creator 来实现,因为这样可以强制重新加载所有的 QML 模块。

现在我们有一个可重用的组件,隐藏了实现细节,减少了代码重复,并且更容易添加新的命令和维护旧的命令。然而,在我们可以为其他命令利用它之前,还有一些改变需要做。

目前,我们的NavigationButton有硬编码的图标和描述文本值,无论何时我们使用组件,它们都将是相同的。我们需要公开文本属性,以便我们可以为我们的每个命令设置不同的值。正如我们所看到的,我们可以使用属性别名来实现这一点,但我们需要为此添加唯一的标识符到我们的Text元素中。让我们将默认值设置为一些通用的内容,并且还要实现本书早期的建议,将Item组件作为根元素:

import QtQuick 2.9
import assets 1.0

Item {
    property alias iconCharacter: textIcon.text
    property alias description: textDescription.text

    Row {
        Text {
            id: textIcon
            font {
                family: Style.fontAwesome
                pixelSize: 42
            }
            color: "#ffffff"
            text: "\uf11a"
        }
        Text {
            id: textDescription
            color: "#ffffff"
            text: "SET ME!!"
        }
    }
}

现在我们的组件可以通过属性进行配置,我们可以替换MasterView中的命令:

Column {
    NavigationButton {
        iconCharacter: "\uf0c9"
        description: ""
    }
    NavigationButton {
        iconCharacter: "\uf015"
        description: "Dashboard"
    }
    NavigationButton {
        iconCharacter: "\uf234"
        description: "New Client"
    }
    NavigationButton {
        iconCharacter: "\uf002"
        description: "Find Client"
    }
}

这比我们之前拥有的所有重复的 QML 要简洁和易于管理得多。现在,如果你运行应用程序,你会看到虽然我们已经向前迈出了一小步,但我们也后退了一步:

正如你所看到的,我们所有的组件都是叠加在一起的。这个问题的根本原因是我们之前提到的关于大小的问题。我们有一个带有根Item元素的可视组件,并且我们没有明确定义它的大小。我们忽视的另一件事是我们的自定义样式。让我们接下来修复这些问题。

样式化导航栏

从简单的部分开始,让我们首先将NavigationButton中的硬编码颜色和图标像素大小移到Style.qml中:

readonly property color colourNavigationBarBackground: "#000000"
readonly property color colourNavigationBarFont: "#ffffff"
readonly property int pixelSizeNavigationBarIcon: 42

我们现在需要考虑我们想要调整按钮元素的大小。我们有一个图标,我们希望它是正方形的,所以宽度和高度将是相同的。接下来,我们有一个文本描述,它的高度将与图标相同,但宽度会更宽:

整个组件的宽度是图标的宽度加上描述的宽度。整个组件的高度与图标和描述的高度相同;然而,这样做可以让我们更灵活地将高度设置为两者中较大的一个。这样,如果我们决定将一个项目变大,我们知道组件将足够大以容纳它们。让我们选择图标的起始尺寸为 80 x 80,描述的尺寸为 80 x 240,并定义这些属性:

readonly property real widthNavigationButtonIcon: 80
readonly property real heightNavigationButtonIcon: widthNavigationButtonIcon
readonly property real widthNavigationButtonDescription: 240
readonly property real heightNavigationButtonDescription: heightNavigationButtonIcon
readonly property real widthNavigationButton: widthNavigationButtonIcon + widthNavigationButtonDescription
readonly property real heightNavigationButton: Math.max(heightNavigationButtonIcon, heightNavigationButtonDescription)

这里有几件事情需要注意。属性可以直接绑定到其他属性,这样可以减少重复的数量,使整个设置更加动态。我们知道我们希望我们的图标是正方形的,所以通过将高度绑定为与宽度相同,如果我们想要改变图标的总大小,我们只需要更新宽度,高度将自动更新。QML 还与 JavaScript 引擎有很强的集成,所以我们可以使用Math.max()函数来帮助我们找出哪个高度更大。

我们希望导航按钮提供一些视觉提示,当用户将鼠标悬停在按钮上时,指示它是一个交互元素。为了做到这一点,我们需要每个按钮都有自己的背景矩形。

NavigationButton中,将Row元素包装在一个新的Rectangle中,并将尺寸插入到我们的组件中:

Item {
    property alias iconCharacter: textIcon.text
    property alias description: textDescription.text

    width: Style.widthNavigationButton
    height: Style.heightNavigationButton

    Rectangle {
        id: background
        anchors.fill: parent
        color: Style.colourNavigationBarBackground

        Row {
            Text {
                id: textIcon
                width: Style.widthNavigationButtonIcon
                height: Style.heightNavigationButtonIcon
                font {
                    family: Style.fontAwesome
                    pixelSize: Style.pixelSizeNavigationBarIcon
                }
                color: Style.colourNavigationBarFont
                text: "\uf11a"
            }
            Text {
                id: textDescription
                width: Style.widthNavigationButtonDescription
                height: Style.heightNavigationButtonDescription
                color: Style.colourNavigationBarFont
                text: "SET ME!!"
            }
        }
    }
}

再次运行,你会看到略微的改进:

我们的导航栏被硬编码为 100 像素宽,导致部分描述被切断。我们需要改变这一点,并且还要实现切换展开/折叠的功能。我们已经计算出了我们需要的尺寸,所以让我们通过向Style.qml添加一些新属性来做好准备:

readonly property real widthNavigationBarCollapsed: widthNavigationButtonIcon
readonly property real heightNavigationBarExpanded: widthNavigationButton

折叠状态将刚好宽到足够容纳图标,而展开状态将包含整个按钮,包括描述。

接下来,让我们将我们的导航栏封装在一个新的组件中。在这种情况下,不会有任何重用的好处,因为只会有一个,但这有助于保持我们的 QML 组织有序,并使MasterView更简洁和易于阅读。

你可以右键单击MasterView中的Rectangle组件,并将我们的导航栏重构为一个新的 QML 文件,就像我们为我们的NavigationButton所做的那样。然而,让我们手动操作,这样你就可以熟悉这两种方法。右键单击components.qrc,然后选择添加新内容... > Qt > QML 文件。将NavigationBar.qml添加到cm/cm-ui/components中:

编辑components.qrc,将我们的新NavigationBar移动到/components前缀部分,并使用别名:

<file alias="NavigationBar.qml">components/NavigationBar.qml</file>

将组件添加到我们的组件模块中,编辑qmldir

NavigationBar 1.0 NavigationBar.qml

MasterView中剪切Rectangle及其子元素,并将其粘贴到NavigationBar.qml中的根Item元素内。如果已经初始化为较旧的版本,请将QtQuick模块导入更新为版本 2.9。添加一个导入我们资产模块的导入,以获得对我们 Style 对象的访问。将Rectangleanchorswidth属性移到根Item,并设置Rectangle以填充其父元素:

import QtQuick 2.9
import assets 1.0

Item {
    anchors {
        top: parent.top
        bottom: parent.bottom
        left: parent.left
    }
    width: 100

    Rectangle {
        anchors.fill: parent
        color: "#000000"

        Column {
            NavigationButton {
                iconCharacter: "\uf0c9"
                description: ""
            }
            NavigationButton {
                iconCharacter: "\uf015"
                description: "Dashboard"
            }
            NavigationButton {
                iconCharacter: "\uf234"
                description: "New Client"
            }
            NavigationButton {
                iconCharacter: "\uf002"
                description: "Find Client"
            }
        }
    }
}

回到MasterView,现在可以在原来的Rectangle位置添加新的NavigationBar组件:

NavigationBar {
    id: navigationBar
}

虽然你会再次看到可怕的红色波浪线,但你实际上可以运行应用程序并验证重构没有出现任何问题。

我们新的NavigationBar组件的定位是好的,但width要复杂一些——我们怎么知道它应该是Style.widthNavigationBarCollapsed还是Style.heightNavigationBarExpanded?我们将通过一个公开访问的布尔属性来控制这一点,该属性指示栏是否已折叠。然后我们可以使用这个属性的值来决定我们想要使用哪个宽度,使用条件?操作符语法。最初将属性设置为 true,这样栏将默认以折叠状态呈现:

property bool isCollapsed: true

有了这个,替换 100 的硬编码width如下:

width: isCollapsed ? Style.widthNavigationBarCollapsed : Style.heightNavigationBarExpanded

接下来,更新Rectanglecolor属性为Style.colourNavigationBarBackground

现在我们已经接近了,但我们一路上错过的一个关键点是,现在点击按钮实际上什么都不做了。让我们下一步修复这个问题。

点击

在本书的早期,我们看过一个叫做MouseArea的组件。这很快被我们使用的Button组件所取代,它为我们提供了点击功能。然而,现在我们正在开发自己的按钮形式,我们需要自己实现点击功能。与Button组件类似,我们的NavigationButton在被点击时实际上不应该做任何事情,除了通知其父组件事件已发生。组件应尽可能地通用和无知于上下文,以便您可以在多个地方使用它们。我们需要做的是添加一个MouseArea组件,并通过自定义信号简单地传递onClicked事件。

NavigationButton中,我们首先添加我们希望在组件被点击时发出的信号。在属性之后添加这个:

signal navigationButtonClicked()

尽量给信号起相当具体的名称,即使有点长。如果你简单地把一切都叫做clicked(),那么事情可能会变得有点混乱,有时你可能会发现自己引用了一个不同于你打算的信号。

接下来,我们将添加另一个属性来支持我们将要实现的鼠标悬停效果。这将是一个color类型,并且我们将默认它为常规背景颜色:

property color hoverColour: Style.colourNavigationBarBackground

我们将与Rectanglestates属性一起使用这个颜色:

states: [
    State {
        name: "hover"
        PropertyChanges {
            target: background
            color: hoverColour
        }
    }
]

将数组中的每个状态视为一个命名配置。默认配置没有名称(""),由我们已经在Rectangle元素中设置的属性组成。 “悬停”状态应用于PropertyChanges元素中指定的属性的更改,也就是说,它将把 ID 为background的元素的color属性更改为hoverColour的值。

接下来,在Rectangle内但在Row下方,添加我们的MouseArea

MouseArea {
    anchors.fill: parent
    cursorShape: Qt.PointingHandCursor
    hoverEnabled: true
    onEntered: background.state = "hover"
    onExited: background.state = ""
    onClicked: navigationButtonClicked()
}

我们使用anchors属性来填充整个按钮背景区域,包括图标和描述。接下来,我们将通过将鼠标光标更改为指向手指,当它进入按钮区域时启用悬停hoverEnabled标志来使事情变得有趣一些。启用后,当光标进入和退出区域时会发出enteredexited信号,我们可以使用相应的插槽通过在刚刚实现的悬停状态和默认("")之间切换来改变我们的背景Rectangle的外观。最后,我们通过MouseAreaclicked()信号响应onClicked()插槽并简单地发出我们自己的信号。

现在我们可以对NavigationBar组件中的navigationButtonClicked()信号做出反应,并在此过程中添加一些悬停颜色。首先实现切换按钮:

NavigationButton {
    iconCharacter: "\uf0c9"
    description: ""
    hoverColour: "#993333"
    onNavigationButtonClicked: isCollapsed = !isCollapsed
}

我们实现了<MyCapitalisedSignalName>约定来为我们的信号创建一个插槽,当它触发时,我们只需在truefalse之间切换isCollapsed的值。

现在可以运行应用程序。单击切换按钮以展开和折叠导航栏:

请注意,由于我们使用了anchors,子视图会动态调整大小以适应导航栏。当您悬停在按钮上时,还会看到指向手指光标和一道闪烁的颜色,这有助于用户理解它是一个交互式元素并可视化边界。

对于剩余的导航按钮,我们希望在点击事件发生时发出NavigationCoordinator上的goDashboardView()goCreateClientView()goFindClientView()信号。

onNavigationButtonClicked插槽添加到其他按钮,并通过masterController对象深入到我们想要调用的信号。也可以添加一些自己喜欢的花哨颜色:

NavigationButton {
    iconCharacter: "\uf015"
    description: "Dashboard"
    hoverColour: "#dc8a00"
    onNavigationButtonClicked: masterController.ui_navigationController.goDashboardView();
}
NavigationButton {
    iconCharacter: "\uf234"
    description: "New Client"
    hoverColour: "#dccd00"
    onNavigationButtonClicked: masterController.ui_navigationController.goCreateClientView();
}
NavigationButton {
    iconCharacter: "\uf002"
    description: "Find Client"
    hoverColour: "#8aef63"
    onNavigationButtonClicked: masterController.ui_navigationController.goFindClientView();
}

现在可以单击按钮导航到不同的子视图。

为了完成导航栏的最后一些微调,我们需要更好地对齐按钮的内容并调整一些大小。

描述文本应该垂直对齐到图标的中心而不是顶部,我们的图标应该居中而不是紧贴窗口边缘。第一个问题很容易解决,因为我们已经在大小上保持了一致并且明确。只需将以下属性添加到NavigationButton中的两个Text组件中:

verticalAlignment: Text.AlignVCenter

两个Text元素的大小被调整为占据整个按钮的高度,因此我们只需要在该空间内垂直对齐文本。

修复图标的对齐方式与之前一样,但这次是在水平轴上。在图标的Text组件中添加以下内容:

horizontalAlignment: Text.AlignHCenter

至于大小,我们的描述文本有点小,文本后面有很多空白。向我们的Style对象添加一个新属性:

readonly property int pixelSizeNavigationBarText: 22

在描述Text元素中使用新属性:

font.pixelSize: Style.pixelSizeNavigationBarText

接下来,将Style中的widthNavigationButtonDescription属性减小到 160。

运行应用程序,我们几乎到达目标了。大小和对齐现在好多了:

但是,您可能没有注意到的一件事是,当栏被折叠并且只显示图标时,MouseArea仍然是包括描述的整个按钮的宽度。尝试将鼠标移动到描述的位置,您会看到指向手光标出现。您甚至可以单击组件,然后进行过渡。我们需要做的是,而不是NavigationButton中的根Item元素是一个固定宽度(Style.widthNavigationButton),我们需要使其动态,并将其设置为parent.width。为了使其工作,我们需要沿着 QML 层次结构向上走,并确保其父级也有宽度。其父级是NavigationBar中的Column元素。将Columnwidth属性设置为parent.width

有了这些改变,导航栏现在的行为符合预期。

命令

我们待办事项清单上的下一件事是实现一个上下文敏感的命令栏。虽然我们的导航栏是一个恒定的存在,无论用户在做什么,都有相同的按钮,但是命令栏会出现和消失,并且会根据上下文包含不同的按钮。例如,如果用户正在添加或编辑客户,我们将需要一个保存按钮来提交对数据库的任何更改。然而,如果我们正在搜索客户,那么保存就没有意义,而查找按钮更相关。虽然创建命令栏的技术与导航栏大致相似,但所需的额外灵活性提出了更大的挑战。

为了帮助我们克服这些障碍,我们将实现命令。这种方法的额外好处是,我们可以将逻辑从 UI 层移出,并移到业务逻辑层。我喜欢 UI 尽可能愚蠢和通用。这样可以使您的应用程序更加灵活,而且 C++代码中的错误比 QML 中的错误更容易识别和解决。

命令对象将封装一个图标,描述性文本,一个用于确定按钮是否启用的函数,最后,一个在相关按钮被按下时将被发射的executed()信号。然后我们的命令栏中的每个按钮将绑定到一个命令对象上。

我们的每个子视图可能都有一个命令列表和一个关联的命令栏。对于具有这些功能的视图,我们将通过命令控制器向 UI 呈现命令列表。

cm-lib项目中创建两个新的C++类,两者都应该继承自 QObject:

  • 在新文件夹cm-lib/source/framework中的命令

  • 现有文件夹cm-lib/source/controllers中的命令控制器

command.h

#ifndef COMMAND_H
#define COMMAND_H

#include <functional>

#include <QObject>
#include <QScopedPointer>
#include <QString>

#include <cm-lib_global.h>

namespace cm {
namespace framework {

class CMLIBSHARED_EXPORT Command : public QObject
{
    Q_OBJECT
    Q_PROPERTY( QString ui_iconCharacter READ iconCharacter CONSTANT )
    Q_PROPERTY( QString ui_description READ description CONSTANT )
    Q_PROPERTY( bool ui_canExecute READ canExecute NOTIFY canExecuteChanged )

public:
    explicit Command(QObject* parent = nullptr,
                     const QString& iconCharacter = "",
                     const QString& description = "",
                     std::function<bool()> canExecute = [](){ return 
                                                           true; });
    ~Command();

    const QString& iconCharacter() const;
    const QString& description() const;
    bool canExecute() const;

signals:
    void canExecuteChanged();
    void executed();

private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};

}}

#endif

command.cpp

#include "command.h"

namespace cm {
namespace framework {

class Command::Implementation
{
public:
    Implementation(const QString& _iconCharacter, const QString& 
     _description, std::function<bool()> _canExecute)
        : iconCharacter(_iconCharacter)
        , description(_description)
        , canExecute(_canExecute)
    {
    }

    QString iconCharacter;
    QString description;
    std::function<bool()> canExecute;
};

Command::Command(QObject* parent, const QString& iconCharacter, const QString& description, std::function<bool()> canExecute)
    : QObject(parent)
{
    implementation.reset(new Implementation(iconCharacter, description, canExecute));
}

Command::~Command()
{
}

const QString& Command::iconCharacter() const
{
    return implementation->iconCharacter;
}

const QString& Command::description() const
{
    return implementation->description;
}

bool Command::canExecute() const
{
    return implementation->canExecute();
}

}
}

现在,QObject,命名空间和 dll 导出代码应该是熟悉的。我们将要在 UI 按钮上显示的图标字符和描述值表示为字符串。我们将成员变量隐藏在私有实现中,并为它们提供访问器方法。我们可以将canExecute成员表示为一个简单的bool成员,调用代码可以根据需要将其设置为truefalse;然而,一个更加优雅的解决方案是传入一个方法,让它在运行时为我们计算值。默认情况下,我们将其设置为返回true的 lambda,这意味着按钮将被启用。我们提供了一个canExecuteChanged()信号来配合使用,我们可以在需要 UI 重新评估按钮是否启用时触发它。最后一个元素是executed()信号,当相应的按钮被按下时将被 UI 触发。

command-controller.h

#ifndef COMMANDCONTROLLER_H
#define COMMANDCONTROLLER_H

#include <QObject>
#include <QtQml/QQmlListProperty>
#include <cm-lib_global.h>
#include <framework/command.h>

namespace cm {
namespace controllers {

class CMLIBSHARED_EXPORT CommandController : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QQmlListProperty<cm::framework::Command> 
     ui_createClientViewContextCommands READ  
     ui_createClientViewContextCommands CONSTANT)

public:
    explicit CommandController(QObject* _parent = nullptr);
    ~CommandController();

    QQmlListProperty<framework::Command> 
    ui_createClientViewContextCommands();

public slots:
    void onCreateClientSaveExecuted();

private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};

}}

#endif

command-controller.cpp

#include "command-controller.h"

#include <QList>
#include <QDebug>

using namespace cm::framework;

namespace cm {
namespace controllers {

class CommandController::Implementation
{
public:
    Implementation(CommandController* _commandController)
        : commandController(_commandController)
    {
        Command* createClientSaveCommand = new Command( 
          commandController, QChar( 0xf0c7 ), "Save" );
        QObject::connect( createClientSaveCommand, &Command::executed,   
   commandController, &CommandController::onCreateClientSaveExecuted );
        createClientViewContextCommands.append( createClientSaveCommand );
    }

    CommandController* commandController{nullptr};

    QList<Command*> createClientViewContextCommands{};
};

CommandController::CommandController(QObject* parent)
    : QObject(parent)
{
    implementation.reset(new Implementation(this));
}

CommandController::~CommandController()
{
}

QQmlListProperty<Command> CommandController::ui_createClientViewContextCommands()
{
    return QQmlListProperty<Command>(this, implementation->createClientViewContextCommands);
}

void CommandController::onCreateClientSaveExecuted()
{
    qDebug() << "You executed the Save command!";
}

}}

在这里,我们引入了一个新类型——QQmlListProperty。它本质上是一个包装器,使 QML 能够与自定义对象列表进行交互。请记住,我们需要在Q_PROPERTY语句中完全限定模板化类型。实际保存数据的私有成员是一个 QList,并且我们已经实现了一个将 QList 取出并将其转换为相同模板化类型的QQmlListProperty访问器方法。

根据QQmlListProperty的文档,这种对象构造方法不应该在生产代码中使用,但我们将使用它来保持简单。

我们为CreateClientView创建了一个单一的命令列表。稍后我们将为其他视图添加命令列表。同样,现在我们会保持简单;我们只创建一个用于保存新创建客户的命令。在创建命令时,我们将其父级设置为命令协调器,这样我们就不必担心内存管理。我们为其分配了一个软盘图标(unicode f0c7)和Save标签。我们暂时将canExecute函数保持为默认值,这样它将始终处于启用状态。接下来,我们将commandexecuted()信号连接到CommandControlleronCreateClientSaveExecuted()槽。连接完成后,我们将命令添加到列表中。

我们的意图是向用户呈现一个绑定到Command对象的命令按钮。当用户按下按钮时,我们将从 UI 触发executed()信号。我们设置的连接将导致命令控制器上的槽被调用,然后我们将执行我们的业务逻辑。现在,当按钮被按下时,我们将简单地在控制台上打印一行。

接下来,在main.cpp中注册我们的两种新类型(记住#includes):

qmlRegisterType<cm::controllers::CommandController>("CM", 1, 0, "CommandController");
qmlRegisterType<cm::framework::Command>("CM", 1, 0, "Command");

最后,我们需要将CommandCoordinator属性添加到MasterController中:

Q_PROPERTY( cm::controllers::CommandController* ui_commandController READ commandController CONSTANT )

然后,我们添加一个accessor方法:

CommandController* commandController();

最后,在master-controller.cpp中,实例化私有实现中的对象,并以与我们为NavigationController做的方式完全相同的方式实现accessor方法。

现在,我们已经为我们的CreateClientView准备好了一个(非常简短的!)命令列表。

命令栏

让我们首先为我们的命令组件的样式添加一些属性:

readonly property color colourCommandBarBackground: "#cecece"
readonly property color colourCommandBarFont: "#131313"
readonly property color colourCommandBarFontDisabled: "#636363"
readonly property real heightCommandBar: heightCommandButton
readonly property int pixelSizeCommandBarIcon: 32
readonly property int pixelSizeCommandBarText: 12

readonly property real widthCommandButton: 80
readonly property real heightCommandButton: widthCommandButton

接下来,在我们的 UI 项目中创建两个新的 QML 组件:在cm-ui/components中创建CommandBar.qmlCommandButton.qml。更新components.qrc并将新组件移动到带有别名的/components前缀中。编辑qmldir并追加新组件:

CommandBar 1.0 CommandBar.qml
CommandButton 1.0 CommandButton.qml

对于我们的按钮设计,我们希望在图标下方布置描述。图标应该略微位于中心位置之上。组件应该是正方形的,如下所示:

CommandButton.qml

import QtQuick 2.9
import CM 1.0
import assets 1.0

Item {
    property Command command
    width: Style.widthCommandButton
    height: Style.heightCommandButton

    Rectangle {
        id: background
        anchors.fill: parent
        color: Style.colourCommandBarBackground

        Text {
            id: textIcon
            anchors {
                centerIn: parent
                verticalCenterOffset: -10
            }
            font {
                family: Style.fontAwesome
                pixelSize: Style.pixelSizeCommandBarIcon
            }
            color: command.ui_canExecute ? Style.colourCommandBarFont : 
                                          colourCommandBarFontDisabled
            text: command.ui_iconCharacter
            horizontalAlignment: Text.AlignHCenter
        }

        Text {
            id: textDescription
            anchors {
                top: textIcon.bottom
                bottom: parent.bottom
                left: parent.left
                right: parent.right
            }
            font.pixelSize: Style.pixelSizeNavigationBarText
            color: command.ui_canExecute ? Style.colourCommandBarFont : 
                                          colourCommandBarFontDisabled
            text: command.ui_description
            horizontalAlignment: Text.AlignHCenter
            verticalAlignment: Text.AlignVCenter
        }

        MouseArea {
            anchors.fill: parent
            cursorShape: Qt.PointingHandCursor
            hoverEnabled: true
            onEntered: background.state = "hover"
            onExited: background.state = ""
            onClicked: if(command.ui_canExecute) {
                           command.executed();
                       }
        }

        states: [
            State {
                name: "hover"
                PropertyChanges {
                    target: background
                    color: Qt.darker(Style.colourCommandBarBackground)
                }
            }
        ]
    }
}

这与我们的NavigationButton组件非常相似。我们传入一个Command对象,从中我们将获取图标字符和描述以显示在Text元素中,以及在按钮被按下时发出的信号,只要命令可以执行。

我们使用了一种替代Row/Column布局的方法,并使用锚点来定位我们的图标和描述。我们将图标居中放置在父Rectangle中,然后应用垂直偏移将其向上移动,以便为描述留出空间。我们将描述的顶部锚定到图标的底部。

我们不是在按钮被按下时传播信号,而是首先验证命令是否可以执行,然后发出Command对象的executed()信号。我们还使用这个标志有选择地为我们的文本元素着色,如果命令被禁用,我们使用较浅的灰色字体。

我们使用MouseArea实现了一些更多的悬停功能,但我们不是暴露一个属性来传递悬停颜色,而是使用内置的Qt.darker()方法将默认颜色变暗几个色调。如果命令可以执行,我们也只在MouseAreaonEntered()槽中应用状态更改。

CommandBar.qml

import QtQuick 2.9
import assets 1.0

Item {
    property alias commandList: commandRepeater.model

    anchors {
        left: parent.left
        bottom: parent.bottom
        right: parent.right
    }
    height: Style.heightCommandBar

    Rectangle {
        anchors.fill: parent
        color: Style.colourCommandBarBackground

        Row {
            anchors {
                top: parent.top
                bottom: parent.bottom
                right: parent.right
            }

            Repeater {
                id: commandRepeater
                delegate: CommandButton {
                    command: modelData
                }
            }
        }
    }
}

这基本上与NavigationBar相同,但是使用动态命令列表而不是硬编码的 QML 按钮。我们引入了另一个新组件——Repeater。通过model属性提供的对象列表,Repeater将为列表中的每个项目实例化在delegate属性中定义的 QML 组件。列表中的对象可通过内置的modelData变量获得。使用这种机制,我们可以为给定列表中的每个命令自动生成一个CommandButton元素。我们使用另一个属性别名,以便调用者可以设置命令列表。

让我们在CreateClientView中使用它。首先,import components 1.0,然后在根Item内以及Rectangle之后添加以下内容:

CommandBar {
    commandList: masterController.ui_commandController.ui_createClientViewContextCommands
}

我们通过属性层次结构深入到创建客户端视图的命令列表,并将该列表传递给负责处理其余部分的命令栏。如果CommandBar有红色波浪线,不要担心,Qt Creator 只是需要跟上我们的快速步伐。

运行应用程序并导航到创建客户端视图:

单击按钮,您将看到消息输出到控制台。添加新命令就像将新的Command对象附加到CommandController内的 QList 一样简单——不需要 UI 更改!命令栏将自动为列表中找到的每个命令创建一个新按钮。还要注意,此命令栏仅出现在CreateClientView上,因此它是上下文敏感的。我们可以通过简单地向CommandController添加额外的列表和属性来轻松地将命令栏添加到其他视图中,就像我们稍后将要做的那样。

总结

在本章中,我们对导航栏进行了急需的改进。我们添加了我们的前几个组件,并利用了我们的新自定义样式对象,Font Awesome 为我们提供了一些可爱的可伸缩图形。我们还引入了命令,并且已经准备好能够向我们的视图添加上下文敏感的命令按钮。

在第五章 数据中,我们将深入研究业务逻辑层,并完善我们的第一个数据模型。

第五章:数据

在本章中,我们将实现处理任何业务应用程序中最关键部分的类——数据。我们将引入自我感知的数据实体,它们可以自动序列化到JavaScript 对象表示JSON)中,这是一种在 Web 通信中经常使用的流行序列化格式。我们将为应用程序创建核心模型,并通过自定义控件将它们连接到我们的 UI 以进行读取和写入。我们将涵盖以下主题:

  • JSON

  • 数据装饰器

  • 抽象数据实体

  • 数据实体的集合

  • 具体数据模型

  • UI 控件和数据绑定

JSON

如果您以前从未接触过 JSON,让我们快速进行一次简短的课程。这是一种简单而轻量的表达对象层次结构及其属性的方式。在发送 HTTP 请求时,这是一个非常受欢迎的选择。它类似于 XML 的意图,但要简洁得多。

JSON 对象封装在大括号{}中,属性以 key: value 的格式表示。字符串用双引号""括起来。我们可以将单个客户对象表示如下:

{
    "reference": "CLIENT0001",
    "name": "Dale Cooper"
}

请注意,空格和制表符等控制字符会被忽略——缩进的属性只是为了使事情更易读。

在通过网络传输 JSON 时,通常最好去除其中的多余字符(例如在 HTTP 请求中),以减少有效负载的大小;每个字节都很重要!

属性值可以是以下类型之一:StringNumberJSON 对象JSON 数组,以及字面值truefalsenull

我们可以将供应地址和账单地址添加到我们的客户作为子 JSON 对象,为每个对象提供一个唯一的键。虽然键可以是任何格式,只要它们是唯一的,但通常使用驼峰命名法,例如myAwesomeJsonKey。我们可以用 null 表示一个空地址对象:

{
    "reference": "CLIENT0001",
    "name": "Dale Cooper",
    "supplyAddress": {
         "number": 7,
        "name": "White Lodge",
        "street": "Lost Highway",
        "city": "Twin Peaks",
        "postcode": "WS119"
    },
    "billingAddress": null
}

对象的集合(数组)用方括号[]括起来,用逗号分隔。我们可以通过简单地留空方括号来表示没有预约:

{
    "reference": "CLIENT0001",
    "name": "Dale Cooper",
    "supplyAddress": {
        "number": 7,
        "name": "White Lodge",
        "street": "Lost Highway",
        "city": "Twin Peaks",
        "postcode": "WS119"
    },
    "billingAddress": null,
    "contacts": [
        {
            "type": 1,
            "address": "+12345678"
        },
        {
            "type": 2,
            "address": "dale.cooper@fbi.com"
        }
    ],
    "appointments": []
}

对象层次结构

大多数现实世界的应用程序以分层或关系方式表示数据,将数据合理化为离散对象。通常有一个中心的“根”对象,它作为父对象包含了几个其他子对象,可以是单个对象或集合。每个离散对象都有自己的一组数据项,可以是任意数量的类型。我们要涵盖的关键原则如下所列:

  • 一系列数据类型(stringintegerdatetime)和枚举值

  • 对象层次结构

  • 多个相同类型的单个子实体

  • 实体的集合

在平衡这些目标与简单性的基础上,我们将致力于实现以下数据图表:

每个模型的目的在下表中描述:

模型 描述
客户 这是我们对象层次结构的根,代表了我们公司与个人或团体的关系,例如客户或患者。
联系人 我们可以用来联系客户的地址集合。可能的联系方式包括电话、电子邮件和传真。每个客户可以有一个或多个联系人。
预约 与客户安排的预约集合,例如现场访问或咨询。每个客户可以有零个或多个预约。
供应地址 与客户关系密切的地址,例如我们公司供应能源的地点或患者的家庭地址。每个客户必须有一个供应地址。
账单地址 用于开具发票的可选地址,例如公司的总部。每个客户可以有零个或一个账单地址。

另一种完全有效的方法是将地址聚合到一个集合中,就像我们在联系人中所做的那样,但我想演示如何在多个属性中使用相同类型的对象(地址)。

高级设计就位后,我们现在可以编写我们的类。但是,在开始处理数据实体之前,让我们先看一下数据项。

数据装饰器

我们的客户端模型的name属性的一个简单实现是将其添加为QString;然而,这种方法有一些缺点。每当我们在 UI 中显示此属性时,我们可能希望在文本框旁边显示一个信息性标签,以便用户知道它是用来做什么的,比如说“姓名”或类似的内容。每当我们想要验证用户输入的姓名时,我们必须在代码中的其他地方进行管理。最后,如果我们想要将值序列化到 JSON 中或从 JSON 中反序列化,再次需要有一些其他组件来为我们完成。

为了解决所有这些问题,我们将引入DataDecorator的概念,它将提升给定的基本数据类型,并为我们提供标签、验证功能和 JSON 序列化。我们的模型将维护一个DataDecorators集合,允许它们通过简单地遍历数据项并执行相关操作来验证和将自己序列化为 JSON。

在我们的cm-lib项目中,在一个新文件夹cm-lib/source/data中创建以下类:

目的
DataDecorator 我们数据项的基类
StringDecorator 用于字符串属性的派生类
IntDecorator 用于整数属性的派生类
DateTimeDecorator 用于日期/时间属性的派生类
EnumeratorDecorator 用于枚举属性的派生类

我们的DataDecorator基类将包含所有数据项共享的特性。

data-decorator.h

#ifndef DATADECORATOR_H
#define DATADECORATOR_H

#include <QJsonObject>
#include <QJsonValue>
#include <QObject>
#include <QScopedPointer>

#include <cm-lib_global.h>

namespace cm {
namespace data {

class Entity;

class CMLIBSHARED_EXPORT DataDecorator : public QObject
{
    Q_OBJECT
    Q_PROPERTY( QString ui_label READ label CONSTANT )

public:
    DataDecorator(Entity* parent = nullptr, const QString& key = 
                  "SomeItemKey", const QString& label = "");
                                 virtual ~DataDecorator();

    const QString& key() const;
    const QString& label() const;
    Entity* parentEntity();

    virtual QJsonValue jsonValue() const = 0;
    virtual void update(const QJsonObject& jsonObject) = 0;

private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};

}}

#endif

我们从 QObject 继承,添加我们的dllexport宏,并像往常一样将整个内容放入命名空间中。此外,因为这是一个抽象基类,我们确保已实现了虚拟析构函数。

我们知道,因为我们从 QObject 继承,我们希望在构造函数中接收一个父指针。我们还知道所有数据项都将是Entity的子项(我们将很快编写并在此处进行前向声明),它本身将从 QObject 派生。我们可以利用这两个事实,将我们的DataDecorator直接作为 Entity 的子项。

我们用一对字符串构造装饰器。我们所有的数据装饰器必须有一个键,该键在序列化到 JSON 和从 JSON 中使用时将被使用,并且它们还将共享一个label属性,UI 可以用来在数据控件旁边显示描述性文本。我们将这些成员隐藏在私有实现中,并为它们实现一些访问器方法。

最后,我们开始实现 JSON 序列化,声明虚拟方法来表示值为QJsonValue,并从提供的QJsonObject更新值。由于基类中未知值,而是在派生类中实现,因此这两种方法都是纯虚拟函数。

data-decorator.cpp

#include "data-decorator.h"

namespace cm {
namespace data {

class DataDecorator::Implementation
{
public:
    Implementation(Entity* _parent, const QString& _key, const QString& 
                                                         _label)
        : parentEntity(_parent)
        , key(_key)
        , label(_label)
    {
    }
    Entity* parentEntity{nullptr};
    QString key;
    QString label;
};

DataDecorator::DataDecorator(Entity* parent, const QString& key, const QString& label)
    : QObject((QObject*)parent)
{
    implementation.reset(new Implementation(parent, key, label));
}

DataDecorator::~DataDecorator()
{
}

const QString& DataDecorator::key() const
{
    return implementation->key;
}

const QString& DataDecorator::label() const
{
    return implementation->label;
}

Entity* DataDecorator::parentEntity()
{
    return implementation->parentEntity;
}

}}

实现非常简单,基本上只是管理一些数据成员。

接下来,我们将实现用于处理字符串的派生装饰器类。

string-decorator.h

#ifndef STRINGDECORATOR_H
#define STRINGDECORATOR_H

#include <QJsonObject>
#include <QJsonValue>
#include <QObject>
#include <QScopedPointer>
#include <QString>

#include <cm-lib_global.h>
#include <data/data-decorator.h>

namespace cm {
namespace data {

class CMLIBSHARED_EXPORT StringDecorator : public DataDecorator
{
    Q_OBJECT

    Q_PROPERTY( QString ui_value READ value WRITE setValue NOTIFY 
               valueChanged )
public:
    StringDecorator(Entity* parentEntity = nullptr, const QString& key = "SomeItemKey", const QString& label = "", const QString& value = "");
    ~StringDecorator();

    StringDecorator& setValue(const QString& value);
    const QString& value() const;

    QJsonValue jsonValue() const override;
    void update(const QJsonObject& jsonObject) override;

signals:
    void valueChanged();

private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};

}}

#endif

这里没有太多其他事情发生 - 我们只是添加了一个强类型的QString值属性来保存我们的值。我们还重写了虚拟的与 JSON 相关的方法。

从继承自 QObject 的类派生时,如果派生类实现了自己的信号或槽,您需要在派生类以及基类中添加Q_OBJECT宏。

string-decorator.cpp

#include "string-decorator.h"

#include <QVariant>

namespace cm {
namespace data {

class StringDecorator::Implementation
{
public:
    Implementation(StringDecorator* _stringDecorator, const QString& 
                                                      _value)
        : stringDecorator(_stringDecorator)
        , value(_value)
    {
    }

    StringDecorator* stringDecorator{nullptr};
    QString value;
};

StringDecorator::StringDecorator(Entity* parentEntity, const QString& key, const QString& label, const QString& value)
    : DataDecorator(parentEntity, key, label)
{
    implementation.reset(new Implementation(this, value));
}

StringDecorator::~StringDecorator()
{
}

const QString& StringDecorator::value() const
{
    return implementation->value;
}

StringDecorator& StringDecorator::setValue(const QString& value)
{
    if(value != implementation->value) {
        // ...Validation here if required...
        implementation->value = value;
        emit valueChanged();
    }
    return *this;
}

QJsonValue StringDecorator::jsonValue() const
{
    return QJsonValue::fromVariant(QVariant(implementation->value));
}

void StringDecorator::update(const QJsonObject& _jsonObject)
{
    if (_jsonObject.contains(key())) {
        setValue(_jsonObject.value(key()).toString());
    } else {
        setValue("");
    }
}
}}

这里没有什么特别复杂的。通过使用READWRITE属性语法,而不是更简单的MEMBER关键字,我们现在有了一种拦截 UI 设置值的方法,并且我们可以决定是否要将更改应用到成员变量。修改器可以像你需要的那样复杂,但我们现在所做的一切只是设置值并发出信号告诉 UI 它已经被更改。我们将操作包装在一个相等检查中,所以如果新值与旧值相同,我们就不会采取任何行动。

在这里,修改器返回对自身(*this)的引用,这很有帮助,因为它使方法链接成为可能,例如,myName.setValue(“Nick”).setSomeNumber(1234).setSomeOtherProperty(true)。然而,这对于属性绑定并不是必要的,所以如果你喜欢的话,可以使用更常见的void返回类型。

我们使用两步转换过程,将我们的QString值转换为QVariant,然后再将其转换为我们目标的QJsonValue类型。QJsonValue将被插入到父实体 JSON 对象中,使用DataDecorator基类的key。当我们编写Entity相关的类时,我们将更详细地介绍这一点。

另一种方法是简单地将各种数据项的值表示为DataDecorator基类中的QVariant成员,而不需要为QStringint等编写单独的类。这种方法的问题在于,最终你将不得不编写大量的恶心代码,比如“如果你有一个包含字符串的QVariant,那么运行这段代码,如果它包含一个int,那么运行这段代码...”。我更喜欢写额外的类来换取已知类型和更清晰、更简单的代码。当我们进行数据验证时,这将变得特别有帮助。验证字符串与验证数字完全不同,而验证日期又与二者不同。

IntDecoratorDateTimeDecoratorStringDecorator几乎相同,只是用QString值替换为 int 或QDateTime。然而,我们可以为DateTimeDecorator补充一些额外的属性来帮助我们。添加以下属性和每个属性对应的访问器方法:

Q_PROPERTY( QString ui_iso8601String READ toIso8601String NOTIFY valueChanged )
Q_PROPERTY( QString ui_prettyDateString READ toPrettyDateString NOTIFY valueChanged )
Q_PROPERTY( QString ui_prettyTimeString READ toPrettyTimeString NOTIFY valueChanged )
Q_PROPERTY( QString ui_prettyString READ toPrettyString NOTIFY valueChanged )

这些属性的目的是使 UI 能够轻松地访问日期/时间值,作为预先格式化为几种不同样式的QString。让我们逐个运行每个访问器的实现。

Qt 内置支持 ISO8601 格式的日期,这是在系统之间传输日期时间值时非常常见的格式,例如在 HTTP 请求中。这是一种灵活的格式,支持几种不同的表示,但通常遵循格式 yyyy-MM-ddTHH:mm:ss.zt,其中 T 是一个字符串文字,z 是毫秒,t 是时区信息:

QString DateTimeDecorator::toIso8601String() const
{
    if (implementation->value.isNull()) {
        return "";
    } else {
        return implementation->value.toString(Qt::ISODate);
    }
}

接下来,我们提供一种方法来以长的人类可读格式显示完整的日期时间,例如,Sat 22 Jul 2017 @ 12:07:45:

QString DateTimeDecorator::toPrettyString() const
{
    if (implementation->value.isNull()) {
        return "Not set";
    } else {
        return implementation->value.toString( "ddd d MMM yyyy @ HH:mm:ss" );
    }
}

最后两种方法分别显示日期或时间组件,例如,22 Jul 2017 或 12:07 pm:

QString DateTimeDecorator::toPrettyDateString() const
{
    if (implementation->value.isNull()) {
        return "Not set";
    } else {
        return implementation->value.toString( "d MMM yyyy" );
    }
}

QString DateTimeDecorator::toPrettyTimeString() const
{
    if (implementation->value.isNull()) {
        return "Not set";
    } else {
        return implementation->value.toString( "hh:mm ap" );
    }
}

我们的最终类型,EnumeratorDecorator,与IntDecorator基本相同,但它还接受一个映射器。这个容器帮助我们将存储的整数值映射为字符串表示。如果我们考虑要实现的Contact.type枚举器,枚举值将是 0、1、2 等;然而,当涉及到 UI 时,这个数字对用户来说没有任何意义。我们真的需要呈现EmailTelephone或其他字符串表示,而映射允许我们做到这一点。

enumerator-decorator.h

#ifndef ENUMERATORDECORATOR_H
#define ENUMERATORDECORATOR_H

#include <map>

#include <QJsonObject>
#include <QJsonValue>
#include <QObject>
#include <QScopedPointer>

#include <cm-lib_global.h>
#include <data/data-decorator.h>

namespace cm {
namespace data {

class CMLIBSHARED_EXPORT EnumeratorDecorator : public DataDecorator
{
    Q_OBJECT
    Q_PROPERTY( int ui_value READ value WRITE setValue NOTIFY 
                                              valueChanged )
    Q_PROPERTY( QString ui_valueDescription READ valueDescription 
                                             NOTIFY valueChanged )

public:
    EnumeratorDecorator(Entity* parentEntity = nullptr, const QString& 
    key = "SomeItemKey", const QString& label = "", int value = 0,  
    const std::map<int, QString>& descriptionMapper = std::map<int, 
     QString>());
    ~EnumeratorDecorator();

    EnumeratorDecorator& setValue(int value);
    int value() const;
    QString valueDescription() const;

    QJsonValue jsonValue() const override;
    void update(const QJsonObject& jsonObject) override;

signals:
    void valueChanged();

private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};

}}

#endif

我们将映射存储为私有实现类中的另一个成员变量,然后使用它来提供枚举值的字符串表示:

QString EnumeratorDecorator::valueDescription() const
{
    if (implementation->descriptionMapper.find(implementation->value) 
                       != implementation->descriptionMapper.end()) {
        return implementation->descriptionMapper.at(implementation-
                                                    >value);
    } else {
        return {};
    }
}

现在我们已经介绍了我们实体所需的数据类型,让我们继续讨论实体本身。

实体

由于我们希望在我们的数据模型之间共享许多功能,我们将实现一个Entity基类。我们需要能够表示父/子关系,以便客户可以拥有供应和账单地址。我们还需要支持实体的集合,用于我们的联系人和约会。最后,每个实体层次结构必须能够将自身序列化为 JSON 对象,并从 JSON 对象中反序列化。

cm-lib/source/data中创建一个名为 Entity 的新类。

entity.h

#ifndef ENTITY_H
#define ENTITY_H

#include <map>

#include <QObject>
#include <QScopedPointer>

#include <cm-lib_global.h>
#include <data/data-decorator.h>

namespace cm {
namespace data {

class CMLIBSHARED_EXPORT Entity : public QObject
{
    Q_OBJECT

public:
    Entity(QObject* parent = nullptr, const QString& key = 
                                                  "SomeEntityKey");
    Entity(QObject* parent, const QString& key, const QJsonObject& 
     jsonObject);
    virtual ~Entity();

public:
    const QString& key() const;
    void update(const QJsonObject& jsonObject);
    QJsonObject toJson() const;

signals:
    void childEntitiesChanged();
    void dataDecoratorsChanged();

protected:
    Entity* addChild(Entity* entity, const QString& key);
    DataDecorator* addDataItem(DataDecorator* dataDecorator);

protected:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};

}}

#endif

entity.cpp

#include "entity.h"

namespace cm {
namespace data {

class Entity::Implementation
{
public:
    Implementation(Entity* _entity, const QString& _key)
        : entity(_entity)
        , key(_key)
    {
    }
    Entity* entity{nullptr};
    QString key;
    std::map<QString, Entity*> childEntities;
    std::map<QString, DataDecorator*> dataDecorators;
};

Entity::Entity(QObject* parent, const QString& key)
    : QObject(parent)
{
    implementation.reset(new Implementation(this, key));
}

Entity::Entity(QObject* parent, const QString& key, const QJsonObject& 
               jsonObject) : Entity(parent, key)
{
    update(jsonObject);
}

Entity::~Entity()
{
}

const QString& Entity::key() const
{
    return implementation->key;
}

Entity* Entity::addChild(Entity* entity, const QString& key)
{
    if(implementation->childEntities.find(key) == 
        std::end(implementation->childEntities)) {
        implementation->childEntities[key] = entity;
        emit childEntitiesChanged();
    }
    return entity;
}

DataDecorator* Entity::addDataItem(DataDecorator* dataDecorator)
{
    if(implementation->dataDecorators.find(dataDecorator->key()) == 
       std::end(implementation->dataDecorators)) {
        implementation->dataDecorators[dataDecorator->key()] = 
        dataDecorator;
        emit dataDecoratorsChanged();
    }
    return dataDecorator;
}

void Entity::update(const QJsonObject& jsonObject)
{
    // Update data decorators
    for (std::pair<QString, DataDecorator*> dataDecoratorPair : 
         implementation->dataDecorators) {
        dataDecoratorPair.second->update(jsonObject);
    }
    // Update child entities
    for (std::pair<QString, Entity*> childEntityPair : implementation-
    >childEntities) {childEntityPair.second>update(jsonObject.value(childEntityPair.first).toObject());
    }
}

QJsonObject Entity::toJson() const
{
    QJsonObject returnValue;
    // Add data decorators
    for (std::pair<QString, DataDecorator*> dataDecoratorPair : 
                         implementation->dataDecorators) {
        returnValue.insert( dataDecoratorPair.first, 
        dataDecoratorPair.second->jsonValue() );
    }
    // Add child entities
    for (std::pair<QString, Entity*> childEntityPair : implementation->childEntities) {
        returnValue.insert( childEntityPair.first, childEntityPair.second->toJson() );
    }
    return returnValue;
}

}}

与我们的DataDecorator基类非常相似,我们为所有实体分配一个唯一的键,这将用于 JSON 序列化。我们还添加了一个重载的构造函数,我们可以通过它传递一个QJsonObject,以便我们可以从 JSON 实例化一个实体。另外,我们还声明了一对方法来将现有实例序列化为 JSON 并从 JSON 中反序列化。

我们的实体将维护一些集合——表示模型属性的数据装饰器的地图,以及表示单个子项的实体的地图。我们将每个项的键映射到实例。

我们公开了一些受保护的方法,派生类将使用这些方法来添加其数据项和子项;例如,我们的客户模型将添加一个名称数据项以及supplyAddressbillingAddress子项。为了补充这些方法,我们还添加了信号,告诉任何感兴趣的观察者集合已经发生了变化。

在这两种情况下,我们在添加之前检查地图上是否已经存在该键。然后我们返回提供的指针,以便消费者可以将其用于进一步操作。当我们开始实现数据模型时,您将看到这一点的价值。

我们使用填充的地图来进行 JSON 序列化方法。我们已经在我们的DataDecorator基类上声明了一个update()方法,因此我们只需迭代所有数据项,并依次将 JSON 对象传递给每个数据项。每个派生的装饰器类都有自己的实现来处理解析。类似地,我们对每个子实体递归调用Entity::update()

将序列化为 JSON 对象遵循相同的模式。每个数据项都可以将其值转换为QJsonValue对象,因此我们依次获取每个值,并将其附加到根 JSON 对象中,使用每个项的键。我们对每个子项递归调用Entity::toJson(),这样就可以级联到层次结构树下。

在我们完成Entity之前,我们需要声明一组类来表示实体集合。

实体集合

要实现实体集合,我们需要利用一些更高级的 C++技术,并且我们将暂时中断我们迄今为止的惯例,实现在单个头文件中的多个类。

cm-lib/source/data中创建entity-collection.h,并在其中像平常一样添加我们的命名空间并前向声明 Entity:

#ifndef ENTITYCOLLECTION_H
#define ENTITYCOLLECTION_H

namespace cm {
namespace data {
    class Entity;
}}

#endif

接下来,我们将依次讨论必要的类,每个类都必须按顺序添加到命名空间中。

我们首先定义根类,它除了继承自QObject并给我们访问它带来的所有好处外,什么也不做,比如对象所有权和信号。这是必需的,因为直接从QObject派生的类不能被模板化:

class CMLIBSHARED_EXPORT EntityCollectionObject : public QObject
{
    Q_OBJECT

public:
    EntityCollectionObject(QObject* _parent = nullptr) : QObject(_parent) {}
    virtual ~EntityCollectionObject() {}

signals:
    void collectionChanged();
};

你需要添加QObject和我们的 DLL 导出宏的包含。接下来,我们需要一个类型不可知的接口,用于与我们的实体一起使用,就像我们已经实现的DataDecorator和实体映射一样。然而,在这里情况会有些复杂,因为我们不会为每个集合派生一个新类,所以我们需要一种获取类型化数据的方法。我们有两个要求。首先,UI 需要一个派生类型的QList(例如Client),这样它就可以访问特定于客户的所有属性并显示所有数据。其次,我们的Entity类需要一个基本类型的向量(Entity*),这样它就可以迭代它的集合而不用关心它正在处理的确切类型。我们实现这一点的方法是声明两个模板方法,但推迟到以后再定义它们。derivedEntities()将在消费者想要一个派生类型的集合时使用,而baseEntities()将在消费者只想要访问基本接口时使用。

class EntityCollectionBase : public EntityCollectionObject
{
public:
    EntityCollectionBase(QObject* parent = nullptr, const QString& key 
                                         = "SomeCollectionKey")
        : EntityCollectionObject(parent)
        , key(key)
    {}

    virtual ~EntityCollectionBase()
    {}

    QString getKey() const
    {
        return key;
    }

    virtual void clear() = 0;
    virtual void update(const QJsonArray& json) = 0;
    virtual std::vector<Entity*> baseEntities() = 0;

    template <class T>
    QList<T*>& derivedEntities();

    template <class T>
    T* addEntity(T* entity);

private:
    QString key;
};

接下来,我们声明一个完整的模板类,其中我们存储我们的派生类型的集合并实现我们所有的方法,除了我们刚刚讨论的两个模板方法:

template <typename T>
class EntityCollection : public EntityCollectionBase
{
public:
    EntityCollection(QObject* parent = nullptr, const QString& key = 
             "SomeCollectionKey")
        : EntityCollectionBase(parent, key)
    {}

    ~EntityCollection()
    {}

    void clear() override
    {
        for(auto entity : collection) {
            entity->deleteLater();
        }
        collection.clear();
    }

    void update(const QJsonArray& jsonArray) override
    {
        clear();
        for(const QJsonValue& jsonValue : jsonArray) {
            addEntity(new T(this, jsonValue.toObject()));
        }
    }

    std::vector<Entity*> baseEntities() override
    {
        std::vector<Entity*> returnValue;
        for(T* entity : collection) {
            returnValue.push_back(entity);
        }
        return returnValue;
    }

    QList<T*>& derivedEntities()
    {
        return collection;
    }

    T* addEntity(T* entity)
    {
        if(!collection.contains(entity)) {
            collection.append(entity);
            EntityCollectionObject::collectionChanged();
        }
        return entity;
    }

private:
    QList<T*> collection;       
};

你需要#include <QJsonValue><QJsonArray>来获取这些类。

clear()方法只是清空集合并整理内存;update()在概念上与我们在 Entity 中实现的 JSON 方法相同,只是我们处理的是一组实体,所以我们使用 JSON 数组而不是对象。addEntity()将派生类的实例添加到集合中,derivedEntities()返回集合;baseEntities()做了更多的工作,根据请求创建一个新的向量,并用集合中的所有项目填充它。它只是隐式地转换指针,所以我们不用担心昂贵的对象实例化。

最后,我们为我们的魔术模板方法提供实现:

template <class T>
QList<T*>& EntityCollectionBase::derivedEntities()
{
    return dynamic_cast<const EntityCollection<T>&>(*this).derivedEntities();
}

template <class T>
T* EntityCollectionBase::addEntity(T* entity)
{
    return dynamic_cast<const EntityCollection<T>&>(*this).addEntity(entity);
}

通过推迟实现这些方法,我们现在已经完全声明了我们的模板化EntityCollection类。现在我们可以将任何对模板方法的调用“路由”到模板类中的实现。这是一种让你头脑转弯的棘手技术,但当我们开始在我们的现实世界模型中实现这些集合时,它将有望更加合理。

现在我们的实体集合已经准备就绪,我们可以返回到我们的 Entity 类并将它们加入其中。

在头文件中,#include <data/entity-collection.h>,添加信号:

void childCollectionsChanged(const QString& collectionKey);

还有,添加受保护的方法:

EntityCollectionBase* addChildCollection(EntityCollectionBase* entityCollection);

在实现文件中,添加私有成员:

std::map<QString, EntityCollectionBase*> childCollections;

然后,添加这个方法:

EntityCollectionBase* Entity::addChildCollection(EntityCollectionBase* entityCollection)
{
    if(implementation->childCollections.find(entityCollection- 
     >getKey()) == std::end(implementation->childCollections)) {
        implementation->childCollections[entityCollection->getKey()] =  
                                        entityCollection;
        emit childCollectionsChanged(entityCollection->getKey());
    }
    return entityCollection;
}

这与其他映射的工作方式完全相同,将键与基类的指针关联起来。

接下来,将集合添加到update()方法中:

void Entity::update(const QJsonObject& jsonObject)
{
    // Update data decorators
    for (std::pair<QString, DataDecorator*> dataDecoratorPair :   
         implementation->dataDecorators) {
        dataDecoratorPair.second->update(jsonObject);
    }

    // Update child entities
    for (std::pair<QString, Entity*> childEntityPair : implementation- 
       >childEntities) { childEntityPair.second- 
       >update(jsonObject.value(childEntityPair.first).toObject());
    }

    // Update child collections
    for (std::pair<QString, EntityCollectionBase*> childCollectionPair 
         : implementation->childCollections) {
            childCollectionPair.second-
        >update(jsonObject.value(childCollectionPair.first).toArray());
    }
}

最后,将集合添加到toJson()方法中:

QJsonObject Entity::toJson() const
{
    QJsonObject returnValue;

    // Add data decorators
    for (std::pair<QString, DataDecorator*> dataDecoratorPair : 
        implementation->dataDecorators) {
        returnValue.insert( dataDecoratorPair.first, 
        dataDecoratorPair.second->jsonValue() );
    }

    // Add child entities
    for (std::pair<QString, Entity*> childEntityPair : implementation-
        >childEntities) {
        returnValue.insert( childEntityPair.first, 
       childEntityPair.second->toJson() );
    }

    // Add child collections
    for (std::pair<QString, EntityCollectionBase*> childCollectionPair 
        : implementation->childCollections) {
        QJsonArray entityArray;
            for (Entity* entity : childCollectionPair.second-
           >baseEntities()) {
            entityArray.append( entity->toJson() );
        }
        returnValue.insert( childCollectionPair.first, entityArray );
    }

    return returnValue;
}

你需要#include <QJsonArray>来获取最后一段代码。

我们使用baseEntities()方法来给我们一个Entity*的集合。然后我们将每个实体的 JSON 对象附加到一个 JSON 数组中,当完成时,将该数组添加到我们的根 JSON 对象中,带有集合的键。

过去几节内容非常长且复杂,可能看起来需要大量工作才能实现一些数据模型。然而,这是你只需要编写一次的所有代码,并且它可以为你提供大量的功能,让你在创建每个实体时都能免费使用,所以从长远来看是值得投资的。我们将继续看如何在我们的数据模型中实现这些类。

数据模型

现在我们已经有了基础设施,可以定义数据对象(实体和实体集合)和各种类型的属性(数据装饰器),我们可以继续构建我们在本章前面所列出的对象层次结构。我们已经有了一个由 Qt Creator 创建的默认Client类,所以在cm-lib/source/models中补充以下新类:

目的
Address 代表供应或结算地址
Appointment 代表与客户的约会
Contact 代表与客户联系的方法

我们将从最简单的模型开始——地址。

address.h

#ifndef ADDRESS_H
#define ADDRESS_H

#include <QObject>

#include <cm-lib_global.h>
#include <data/string-decorator.h>
#include <data/entity.h>

namespace cm {
namespace models {

class CMLIBSHARED_EXPORT Address : public data::Entity
{
    Q_OBJECT
    Q_PROPERTY(cm::data::StringDecorator* ui_building MEMBER building 
                                                      CONSTANT)
    Q_PROPERTY(cm::data::StringDecorator* ui_street MEMBER street  
                                                    CONSTANT)
    Q_PROPERTY(cm::data::StringDecorator* ui_city MEMBER city CONSTANT)
    Q_PROPERTY(cm::data::StringDecorator* ui_postcode MEMBER postcode 
                                                      CONSTANT)
    Q_PROPERTY(QString ui_fullAddress READ fullAddress CONSTANT)

public:
    explicit Address(QObject* parent = nullptr);
    Address(QObject* parent, const QJsonObject& json);

    data::StringDecorator* building{nullptr};
    data::StringDecorator* street{nullptr};
    data::StringDecorator* city{nullptr};
    data::StringDecorator* postcode{nullptr};

    QString fullAddress() const;
};

}}

#endif

我们定义了我们在本章开头设计的属性,但是我们使用我们的新StringDecorators,而不是使用常规的QString对象。为了保护数据的完整性,我们应该真正使用READ关键字,并通过访问器方法返回StringDecorator* const,但为了简单起见,我们将使用MEMBER。我们还提供了一个重载的构造函数,我们可以用它来从QJsonObject构造地址。最后,我们添加了一个辅助的fullAddress()方法和属性,将地址元素连接成一个单一的字符串,以在 UI 中使用。

address.cpp

#include "address.h"

using namespace cm::data;

namespace cm {
namespace models {

Address::Address(QObject* parent)
        : Entity(parent, "address")
{
    building = static_cast<StringDecorator*>(addDataItem(new StringDecorator(this, "building", "Building")));
    street = static_cast<StringDecorator*>(addDataItem(new StringDecorator(this, "street", "Street")));
    city = static_cast<StringDecorator*>(addDataItem(new StringDecorator(this, "city", "City")));
    postcode = static_cast<StringDecorator*>(addDataItem(new StringDecorator(this, "postcode", "Post Code")));
}

Address::Address(QObject* parent, const QJsonObject& json)
        : Address(parent)
{
    update(json);
}

QString Address::fullAddress() const
{
    return building->value() + " " + street->value() + "\n" + city->value() + "\n" + postcode->value();
}

}}

这是我们所有辛苦工作开始汇聚的地方。我们需要对我们的每个属性做两件事。首先,我们需要一个指向派生类型(StringDecorator)的指针,这样我们就可以向 UI 呈现并编辑值。其次,我们需要让基本的 Entity 类知道基本类型(DataDecorator),以便它可以迭代数据项并为我们执行 JSON 序列化工作。我们可以使用addDataItem()方法在一行语句中实现这两个目标:

building = static_cast<StringDecorator*>(addDataItem(new StringDecorator(this, "building", "Building")));

分解一下,我们使用building键和Building UI 标签创建一个新的StringDecorator*。这立即传递给addDataItem(),它将其添加到Entity中的dataDecorators集合中,并将数据项作为DataDecorator*返回。然后我们可以将其强制转换回StringDecorator*,然后将其存储在building成员变量中。

这里的另一个实现部分是获取 JSON 对象,通过调用默认构造函数正常构造地址,然后使用update()方法更新模型。

AppointmentContact模型遵循相同的模式,只是具有不同的属性和每种数据类型的适当变体的DataDecoratorContact的变化更显著的是在其对contactType属性使用EnumeratorDecorator。为了支持这一点,我们首先在头文件中定义一个枚举器,其中包含我们想要的所有可能值:

enum eContactType {
    Unknown = 0,
    Telephone,
    Email,
    Fax
};

请注意,我们将Unknown的默认值表示为0。这很重要,因为它允许我们容纳初始未设置的值。接下来,我们定义一个映射器容器,允许我们将枚举类型中的每个类型映射到一个描述性字符串:

std::map<int, QString> Contact::contactTypeMapper = std::map<int, QString> {
    { Contact::eContactType::Unknown, "" }
    , { Contact::eContactType::Telephone, "Telephone" }
    , { Contact::eContactType::Email, "Email" }
    , { Contact::eContactType::Fax, "Fax" }
};

在创建新的EnumeratorDecorator时,我们提供默认值(对于eContactType::Unknown为 0)以及映射器:

contactType = static_cast<EnumeratorDecorator*>(addDataItem(new EnumeratorDecorator(this, "contactType", "Contact Type", 0, contactTypeMapper)));

我们的客户模型稍微复杂一些,因为它不仅有数据项,还有子实体和集合。但是,我们创建和公开这些内容的方式与我们已经看到的非常相似。

client.h

#ifndef CLIENT_H
#define CLIENT_H

#include <QObject>
#include <QtQml/QQmlListProperty>

#include <cm-lib_global.h>
#include <data/string-decorator.h>
#include <data/entity.h>
#include <data/entity-collection.h>
#include <models/address.h>
#include <models/appointment.h>
#include <models/contact.h>

namespace cm {
namespace models {

class CMLIBSHARED_EXPORT Client : public data::Entity
{
    Q_OBJECT
    Q_PROPERTY( cm::data::StringDecorator* ui_reference MEMBER 
                                           reference CONSTANT )
    Q_PROPERTY( cm::data::StringDecorator* ui_name MEMBER name CONSTANT )
    Q_PROPERTY( cm::models::Address* ui_supplyAddress MEMBER 
                                     supplyAddress CONSTANT )
    Q_PROPERTY( cm::models::Address* ui_billingAddress MEMBER 
                                     billingAddress CONSTANT )
    Q_PROPERTY( QQmlListProperty<Appointment> ui_appointments READ 
                        ui_appointments NOTIFY appointmentsChanged )
    Q_PROPERTY( QQmlListProperty<Contact> ui_contacts READ ui_contacts 
                                          NOTIFY contactsChanged )

public:    
    explicit Client(QObject* parent = nullptr);
    Client(QObject* parent, const QJsonObject& json);

    data::StringDecorator* reference{nullptr};
    data::StringDecorator* name{nullptr};
    Address* supplyAddress{nullptr};
    Address* billingAddress{nullptr};
    data::EntityCollection<Appointment>* appointments{nullptr};
    data::EntityCollection<Contact>* contacts{nullptr};

    QQmlListProperty<cm::models::Appointment> ui_appointments();
    QQmlListProperty<cm::models::Contact> ui_contacts();

signals:
    void appointmentsChanged();
    void contactsChanged();
};

}}

#endif

我们将子实体公开为指向派生类型的指针,将集合公开为指向模板化的EntityCollection的指针。

client.cpp

#include "client.h"

using namespace cm::data;

namespace cm {
namespace models {

Client::Client(QObject* parent)
    : Entity(parent, "client")
{
    reference = static_cast<StringDecorator*>(addDataItem(new 
                StringDecorator(this, "reference", "Client Ref")));
    name = static_cast<StringDecorator*>(addDataItem(new 
                StringDecorator(this, "name", "Name")));
    supplyAddress = static_cast<Address*>(addChild(new Address(this), 
                                          "supplyAddress"));
    billingAddress = static_cast<Address*>(addChild(new Address(this), 
                                          "billingAddress"));
    appointments = static_cast<EntityCollection<Appointment>*>
    (addChildCollection(new EntityCollection<Appointment>(this, 
                                            "appointments")));
    contacts = static_cast<EntityCollection<Contact>*>(addChildCollection(new EntityCollection<Contact>(this, "contacts")));
}

Client::Client(QObject* parent, const QJsonObject& json)
    : Client(parent)
{
    update(json);
}

QQmlListProperty<Appointment> Client::ui_appointments()
{
    return QQmlListProperty<Appointment>(this, appointments->derivedEntities());
}

QQmlListProperty<Contact> Client::ui_contacts()
{
    return QQmlListProperty<Contact>(this, contacts->derivedEntities());
}

}}

添加子实体遵循与数据项相同的模式,但使用addChild()方法。请注意,我们添加了多个相同地址类型的子实体,但确保它们具有不同的key值,以避免重复和无效的 JSON。实体集合使用addChildCollection()添加,除了使用模板化之外,它们遵循相同的方法。

虽然创建实体和数据项需要大量工作,但创建模型实际上非常简单,现在它们都具有我们原本没有的功能。

在 UI 中使用我们新的模型之前,我们需要在cm-uimain.cpp中注册类型,包括表示数据项的数据装饰器。记得先添加相关的#include语句:

qmlRegisterType<cm::data::DateTimeDecorator>("CM", 1, 0, "DateTimeDecorator");
qmlRegisterType<cm::data::EnumeratorDecorator>("CM", 1, 0, "EnumeratorDecorator");
qmlRegisterType<cm::data::IntDecorator>("CM", 1, 0, "IntDecorator");
qmlRegisterType<cm::data::StringDecorator>("CM", 1, 0, "StringDecorator");

qmlRegisterType<cm::models::Address>("CM", 1, 0, "Address");
qmlRegisterType<cm::models::Appointment>("CM", 1, 0, "Appointment");
qmlRegisterType<cm::models::Client>("CM", 1, 0, "Client");
qmlRegisterType<cm::models::Contact>("CM", 1, 0, "Contact");

完成后,我们将在MasterController中创建一个客户端的实例,用于填充新客户端的数据。这完全遵循了我们用于添加其他控制器的相同模式。

首先,在MasterController的私有实现中添加成员变量:

Client* newClient{nullptr};

然后,在Implementation构造函数中初始化它:

newClient = new Client(masterController);

第三,添加访问器方法:

Client* MasterController::newClient()
{
    return implementation->newClient;
}

最后,添加Q_PROPERTY

Q_PROPERTY( cm::models::Client* ui_newClient READ newClient CONSTANT )

现在,我们有一个空的客户端实例可供 UI 使用,特别是CreateClientView,我们将在下一步中编辑它。首先添加一个新客户端实例的快捷属性:

property Client newClient: masterController.ui_newClient

请记住,所有属性都应在根 Item 级别定义,并且您需要import CM 1.0才能访问已注册的类型。这只是让我们能够使用newClient作为访问实例的简写,而不必每次都输入masterController.ui_newClient

到目前为止,一切都已经准备就绪,您应该能够运行应用程序并导航到新的客户端视图,而没有任何问题。视图目前还没有使用新的客户端实例,但它已经准备好进行操作。现在,让我们看看如何与它进行交互。

自定义文本框

我们将从客户端的name数据项开始。当我们在 UI 中使用另一个QString属性时,我们使用基本文本组件显示它。这个组件是只读的,所以为了查看和编辑我们的属性,我们需要寻找其他东西。在基本的QtQuick模块中有几个选项:TextInputTextEditTextInput用于单行可编辑的纯文本,而TextEdit处理多行文本块,并支持富文本。TextInput非常适合我们的name

导入QtQuick.Controls模块可以使其他基于文本的组件如LabelTextFieldTextArea可用。Label 继承并扩展 Text,TextField继承并扩展TextInputTextArea继承并扩展TextEdit。在这个阶段,基本控件已经足够了,但请注意这些替代品的存在。如果您发现自己尝试使用基本控件做一些它似乎不支持的事情,那么导入QtQuick.Controls并查看它更强大的同类。它很可能具有您正在寻找的功能。

让我们在所学知识的基础上构建一个新的可重用组件。和往常一样,我们将首先准备我们需要的样式属性:

readonly property real sizeScreenMargin: 20
readonly property color colourDataControlsBackground: "#ffffff"
readonly property color colourDataControlsFont: "#131313" 
readonly property int pixelSizeDataControls: 18 
readonly property real widthDataControls: 400 
readonly property real heightDataControls: 40

接下来,在cm/cm-ui/components中创建StringEditorSingleLine.qml。这可能不是最美观的名称,但至少它是描述性的!

通常有助于在自定义 QML 视图和组件中使用前缀,以帮助区分它们与内置的 Qt 组件,并避免命名冲突。如果我们在这个项目中使用这种方法,我们可以将这个组件称为CMTextBox或者其他同样简短简单的名称。使用任何适合您的方法和约定,这不会产生功能上的差异。

编辑components.qrcqmldir,就像我们之前做的那样,以便在我们的组件模块中使用新组件。

我们尝试实现这个组件的目标如下:

  • 能够传递任何数据模型和视图中的StringDecorator属性并查看/编辑值

  • 查看StringDecoratorui_label属性中定义的控件的描述性标签

  • 查看/编辑StringDecoratorui_value属性在TextBox

  • 如果窗口足够宽,则标签和文本框将水平布局

  • 如果窗口不够宽,则标签和文本框将垂直布局

考虑到这些目标,实现StringEditorSingleLine如下:

import QtQuick 2.9
import CM 1.0
import assets 1.0

Item {
    property StringDecorator stringDecorator

    height: width > textLabel.width + textValue.width ? 
    Style.heightDataControls : Style.heightDataControls * 2

    Flow {
        anchors.fill: parent

        Rectangle {
            width: Style.widthDataControls
            height: Style.heightDataControls
            color: Style.colourBackground
            Text {
                id: textLabel
                anchors {
                    fill: parent
                    margins: Style.heightDataControls / 4
                }
                text: stringDecorator.ui_label
                color: Style.colourDataControlsFont
                font.pixelSize: Style.pixelSizeDataControls
                verticalAlignment: Qt.AlignVCenter
            }
        }

        Rectangle {
            id: background
            width: Style.widthDataControls
            height: Style.heightDataControls
            color: Style.colourDataControlsBackground
            border {
                width: 1
                color: Style.colourDataControlsFont
            }
            TextInput {
                id: textValue
                anchors {
                    fill: parent
                    margins: Style.heightDataControls / 4
                }
                text: stringDecorator.ui_value
                color: Style.colourDataControlsFont
                font.pixelSize: Style.pixelSizeDataControls
                verticalAlignment: Qt.AlignVCenter
            }
        }

        Binding {
            target: stringDecorator
            property: "ui_value"
            value: textValue.text
        }
    }
}

我们从公共StringDecorator属性开始(因为它在根 Item 元素中),我们可以从组件外部设置它。

我们引入了一种新的元素——Flow——来为我们布置标签和文本框。与始终沿着单个方向(如行或列)布置内容不同,Flow 项将将其子元素并排布置,直到可用空间用尽,然后像页面上的单词一样将它们包裹起来。我们通过将其锚定到根 Item 来告诉它有多少可用空间可以使用。

接下来是我们描述性标签在文本控件中和可编辑值在TextInput控件中。我们将两个控件嵌入明确大小的矩形中。这些矩形帮助我们对齐元素,并为我们提供绘制背景和边框的机会。

Binding组件在两个不同对象的属性之间建立了依赖关系;在我们的情况下,是名为textValueTextInput控件和名为stringDecoratorStringDecorator实例。target属性定义了我们要更新的对象,property是我们要设置的Q_PROPERTYvalue是我们要设置的值。这是一个关键元素,使我们实现了真正的双向绑定。没有这个,我们将能够从StringDecorator中查看值,但我们在 UI 中进行的任何更改都不会更新该值。

回到CreateClientView,用我们的新组件替换旧的文本元素,并传入ui_name属性:

StringEditorSingleLine {
    stringDecorator: newClient.ui_name
}

现在构建并运行应用程序,导航到创建客户端视图,并尝试编辑名称:

如果您切换到查找客户端视图,然后再切换回来,您会看到该值被保留,证明更新成功地设置在字符串装饰器中。

我们新绑定的视图目前还没有太多数据,但在接下来的章节中,我们将为这个视图添加更多内容,因此让我们添加一些最后的修饰来做好准备。

首先,我们只需要向视图添加另外三四个属性,我们将会用完空间,因为我们为窗口设置的默认大小非常小,所以在MasterView中将窗口大小调整到适合您显示器的舒适大小。我会给自己一些待遇,选择全高清的 1920 x 1080。

即使有更大的窗口可供使用,我们仍然需要准备可能溢出的情况,因此我们将将我们的内容添加到另一个名为ScrollView的新元素中。顾名思义,它的工作方式类似于流,并根据其可用的空间来管理其内容。如果内容超出可用空间,它将为用户呈现滚动条。它还是一个非常适合手指操作的控件,在触摸屏上,用户可以直接拖动内容,而不必费力地操作微小的滚动条。

尽管我们目前只有一个属性,但当我们添加更多属性时,我们需要对它们进行布局,因此我们将添加一列。

最后,控件粘附在视图的边界上,因此我们将在视图周围添加一点间隙和一些列间距。

修改后的视图应如下所示:

import QtQuick 2.9
import QtQuick.Controls 2.2
import CM 1.0
import assets 1.0
import components 1.0

Item {
    property Client newClient: masterController.ui_newClient

    Rectangle {
        anchors.fill: parent
        color: Style.colourBackground
    }

    ScrollView {
        id: scrollView
        anchors {
            left: parent.left
            right: parent.right
            top: parent.top
            bottom: commandBar. top
            margins: Style.sizeScreenMargin
        }
        clip: true
        Column {
            spacing: Style.sizeScreenMargin
            width: scrollView.width
            StringEditorSingleLine {
                stringDecorator: newClient.ui_name
                anchors {
                    left: parent.left
                    right: parent.right
                }
            }
        }
    }

    CommandBar {
        id: commandBar
        commandList: masterController.ui_commandController.ui_createClientViewContextCommands
    }
}

构建并运行,您应该会看到漂亮整洁的屏幕边距。您还应该能够将窗口从宽变窄,并看到字符串编辑器自动调整其布局。

总结

这是一个相当庞大的章节,但我们已经涵盖了任何业务应用程序中可能最重要的元素,那就是数据。我们实现了一个能够将自身序列化到 JSON 并开始构建数据绑定控件的自我意识实体框架。我们已经设计并创建了我们的数据模型,现在正在进入回家的阶段。在第六章中,单元测试,我们将关注到迄今为止被忽视的单元测试项目,并检查我们的实体是否按预期行为。

第六章:单元测试

在本章中,我们将介绍一个近年来真正流行起来的过程——单元测试。我们将简要讨论它是什么以及为什么我们要这样做,然后介绍如何使用 Qt 自己的单元测试工具 Qt Test 将其集成到我们的解决方案中。我们将涵盖以下主题:

  • 单元测试原则

  • 默认的 Qt 方法

  • 另一种方法

  • DataDecorator 测试

  • 实体测试

  • 模拟

单元测试

单元测试的本质是将应用程序分解为最小的功能块(单元),然后在倡议范围内使用真实场景测试每个单元。例如,考虑一个简单的方法,它接受两个有符号整数并将它们相加:

int add(intx, int y);

一些示例场景可以列举如下:

  • 添加两个正数

  • 添加两个负数

  • 添加两个零

  • 添加一个正数和一个负数

  • 添加零和一个正数

  • 添加零和一个负数

我们可以为每种情况编写一个测试,然后每当我们的代码库发生更改(任何代码,不仅仅是我们的add()方法),都可以执行这些测试,以确保代码仍然按预期运行。这是一个非常有价值的工具,可以让您确信您所做的任何代码更改都不会对现有功能产生不利影响。

历史上,这些测试通常是手动执行的,但现在存在工具可以使我们能够编写代码自动测试代码,听起来有点矛盾,但它确实有效。Qt 提供了一个专门为基于 Qt 的应用程序设计的单元测试框架,称为 Qt Test,这就是我们将要使用的。

您可以使用其他 C++测试框架,如 Google 测试,这些框架可能提供更多的功能和灵活性,特别是在与 Google 模拟一起使用时,但设置起来可能会更加麻烦。

测试驱动开发TDD)将单元测试提升到了一个新的水平,并实际上改变了你编写代码的方式。实质上,你首先编写一个测试。测试最初会失败(实际上,可能甚至无法构建),因为你没有实现。然后,你编写最少量的代码使测试通过,然后继续编写下一个测试。你以这种方式迭代地构建你的实现,直到你交付所需的功能块。最后,你根据所需的标准重构代码,使用完成的单元测试来验证重构后的代码仍然按预期运行。有时这被称为红-绿-重构

这不是一本关于单元测试的书,当然也不是关于 TDD 的书,所以我们的方法会很宽松,但它是现代应用程序开发的重要组成部分,了解它如何融入到您的 Qt 项目中是很重要的。

我们已经演示了从业务逻辑项目向 UI 传递简单数据(欢迎消息)的机制,因此,像往常一样,本章的第一个目标是为该行为编写一个基本的单元测试。完成后,我们将继续测试我们在上一章中实现的数据类。

默认的 Qt 方法

当我们创建cm-tests项目时,Qt Creator 会为我们创建一个ClientTests类,供我们用作起点,其中包含一个名为testCase1的单个测试。让我们直接执行这个默认测试,看看会发生什么。然后我们将查看代码并讨论发生了什么。

将运行输出切换到cm-tests,然后编译和运行:

这次你不会看到任何花哨的应用程序,但你会在 Qt Creator 的 Application Output 窗格中看到一些文本:

********* Start testing of ClientTests *********
Config: Using QtTest library 5.10.0, Qt 5.10.0 (i386-little_endian-ilp32 shared (dynamic) debug build; by GCC 5.3.0)
PASS : ClientTests::initTestCase()
PASS : ClientTests::testCase1()
PASS : ClientTests::cleanupTestCase()
Totals: 3 passed, 0 failed, 0 skipped, 0 blacklisted, 0ms
********* Finished testing of ClientTests *********

我们可以看到已经调用了三个方法,其中第二个是我们的默认单元测试。另外两个函数——initTestCase()cleanupTestCase()——是在类的测试套件之前和之后执行的特殊方法,允许您设置执行测试所需的任何前提条件,然后执行任何清理工作。所有三个步骤都通过了。

现在,在client-tests.cpp中,添加另一个方法testCase2(),它与testCase1()相同,但将true条件替换为false。请注意,类声明和方法定义都在同一个.cpp文件中,所以你需要在两个地方都添加这个方法。再次运行测试:

********* Start testing of ClientTests *********
Config: Using QtTest library 5.10.0, Qt 5.10.0 (i386-little_endian-ilp32 shared (dynamic) debug build; by GCC 5.3.0)
PASS : ClientTests::initTestCase()
PASS : ClientTests::testCase1()
FAIL! : ClientTests::testCase2() 'false' returned FALSE. (Failure)
..\..\cm\cm-tests\source\models\client-tests.cpp(37) : failure location
PASS : ClientTests::cleanupTestCase()
Totals: 3 passed, 1 failed, 0 skipped, 0 blacklisted, 0ms
********* Finished testing of ClientTests *********

这一次,你可以看到testCase2()试图验证 false 是否为 true,当然它不是,我们的测试失败了,并在过程中输出了我们的失败消息。initTestCase()cleanupTestCase()仍然在测试套件的开始和结束时执行。

现在我们已经看到了通过和失败的测试是什么样子的,但实际上发生了什么呢?

我们有一个派生自QObject的类ClientTests,它实现了一个空的默认构造函数。然后我们有一些声明为私有Q_SLOTS的方法。就像Q_OBJECT一样,这是一个宏,为我们注入了一堆聪明的样板代码,而且就像Q_OBJECT一样,你不需要担心理解它的内部工作原理就可以使用它。类中定义为这些私有槽之一的每个方法都作为一个单元测试执行。

然后,单元测试方法使用QVERIFY2宏来验证给定的布尔条件,即 true 是 true。如果失败了,我们在testCase2中设计的话,有用的消息失败将被输出到控制台。

如果有一个QVERIFY2,那么可能会有一个QVERIFY1,对吧?嗯,几乎是的,有一个QVERIFY,它执行相同的测试,但没有失败消息参数。其他常用的宏是QCOMPARE,它验证相同类型的两个参数是否等价,以及QVERIFY_EXCEPTION_THROWN,它验证在执行给定表达式时是否抛出了异常。这可能听起来有点奇怪,因为我们理想情况下不希望我们的代码抛出异常。然而,事情并不总是理想的,我们应该始终编写负面测试,验证代码在出现问题时的行为。一个常见的例子是,当我们有一个接受对象指针作为参数的方法时。我们应该编写一个负面测试,验证如果我们传入一个nullptr会发生什么(无论你多么小心,这总是可能发生的)。我们可能希望代码忽略它并不再采取进一步的行动,或者我们可能希望抛出某种空参数异常,这就是QVERIFY_EXCEPTION_THROWN的用武之地。

在测试用例定义之后,另一个宏QTEST_APPLESS_MAIN将一个main()钩子桩出来执行测试,最后的#include语句引入了构建过程生成的.moc 文件。每个继承自 QObject 的类都会生成一个companion .moc文件,其中包含由Q_OBJECT和其他相关宏创建的所有magic元数据代码。

现在,如果你在想“为什么要测试 true 是否为 true,false 是否为 true?”,那么你绝对不会这样做,这是一对完全无意义的测试。这个练习的目的只是看看 Qt Creator 为我们提供的默认方法是如何工作的,它确实有效,但它有一些关键的缺陷,我们需要在编写真正的测试之前解决这些问题。

第一个问题是,QTEST_APPLESS_MAIN创建了一个main()方法,以便在ClientTests中运行我们的测试用例。当我们编写另一个测试类时会发生什么?我们将有两个main()方法,事情将变得不顺利。另一个问题是,我们的测试输出只是传输到“应用程序输出”窗格。在商业环境中,通常会有构建服务器拉取应用程序代码,执行构建,运行单元测试套件,并标记任何测试失败以进行调查。为了使这项工作正常进行,构建工具需要能够访问测试输出,并且不能像人类一样读取 IDE 中的“应用程序输出”窗格。让我们看看解决这些问题的另一种方法。

自定义方法

我们将采取的自定义方法仍然适用于我们刚刚讨论的相同基本概念。在其核心,我们仍将拥有一个包含一系列要执行的单元测试方法的测试类。我们将只是补充一些额外的样板代码,以便我们可以轻松地容纳多个测试类,并将输出传输到文件而不是控制台。

让我们首先在源文件夹中的cm-tests中添加一个新的TestSuite类:

test-suite.h

#ifndef TESTSUITE_H
#define TESTSUITE_H

#include <QObject>
#include <QString>
#include <QtTest/QtTest>

#include <vector>

namespace cm {

class TestSuite : public QObject
{
    Q_OBJECT
public:
    explicit TestSuite(const QString& _testName = "");
    virtual ~TestSuite();

    QString testName;
    static std::vector<TestSuite*>& testList();
};

}

#endif

test-suite.cpp

#include "test-suite.h"

#include <QDebug>

namespace cm {

TestSuite::TestSuite(const QString& _testName)
    : QObject()
    , testName(_testName)
{
    qDebug() << "Creating test" << testName;
    testList().push_back(this);
    qDebug() << testList().size() << " tests recorded";
}

TestSuite::~TestSuite()
{
    qDebug() << "Destroying test";
}

std::vector<TestSuite*>& TestSuite::testList()
{
    static std::vector<TestSuite*> instance = std::vector<TestSuite*>();
    return instance;
}

}

在这里,我们正在创建一个基类,该基类将用于我们的每个测试类。通常情况下,常规类和测试套件类之间是一对一的关系,例如ClientClientTests类。TestSuite的每个派生实例都会将自己添加到共享向量中。乍一看可能有点混乱,因此我们还使用qDebug()向控制台输出一些信息,以便您可以跟踪发生的情况。当我们创建第一个从TestSuite派生的类时,这将更有意义。

接下来,再次在源文件夹中添加一个新的 C++源文件main.cpp

main.cpp

#include <QtTest/QtTest>
#include <QDebug>

#include "test-suite.h"

using namespace cm;

int main(int argc, char *argv[])
{
    Q_UNUSED(argc);
    Q_UNUSED(argv);

    qDebug() << "Starting test suite...";
    qDebug() << "Accessing tests from " << &TestSuite::testList();
    qDebug() << TestSuite::testList().size() << " tests detected";

    int failedTestsCount = 0;

    for(TestSuite* i : TestSuite::testList()) {
        qDebug() << "Executing test " << i->testName;
        QString filename(i->testName + ".xml");
        int result = QTest::qExec(i, QStringList() << " " << "-o" << 
                                  filename << "-xunitxml");
        qDebug() << "Test result " << result;
        if(result != 0) {
            failedTestsCount++;
        }
    }

    qDebug() << "Test suite complete - " << 
          QString::number(failedTestsCount) << " failures detected.";

    return failedTestsCount;
}

由于添加了用于信息的qDebug()语句,这看起来比实际情况更复杂。我们遍历每个注册的测试类,并使用静态的QTest::qExec()方法来检测并运行其中发现的所有测试。然而,一个关键的补充是,我们为每个类创建一个 XML 文件,并将结果传输到其中。

这种机制解决了我们的两个问题。现在我们有一个单一的main()方法,将检测并运行我们所有的测试,并且我们得到一个单独的 XML 文件,其中包含每个测试套件的输出。但是,在构建项目之前,您需要重新查看client-tests.cpp,并注释或删除QTEST_APPLESS_MAIN行,否则我们将再次面临多个main()方法的问题。现在不要担心client-tests.cpp的其余部分;当我们开始测试我们的数据类时,我们将稍后重新访问它。

现在构建和运行,您将在“应用程序输出”中获得一组不同的文本:

Starting test suite...
Accessing tests from 0x40b040
0 tests detected
Test suite complete - "0" failures detected.

让我们继续实现我们的第一个TestSuite。我们有一个MasterController类,向 UI 呈现消息字符串,因此让我们编写一个简单的测试来验证消息是否正确。我们需要在cm-tests项目中引用cm-lib中的代码,因此请确保将相关的INCLUDE指令添加到cm-tests.pro中:

INCLUDEPATH += source \
    ../cm-lib/source

cm-tests/source/controllers中创建一个名为MasterControllerTests的新的伴随测试类。

master-controller-tests.h

#ifndef MASTERCONTROLLERTESTS_H
#define MASTERCONTROLLERTESTS_H

#include <QtTest>

#include <controllers/master-controller.h>
#include <test-suite.h>

namespace cm {
namespace controllers {

class MasterControllerTests : public TestSuite
{
    Q_OBJECT

public:
    MasterControllerTests();

private slots:
    /// @brief Called before the first test function is executed
    void initTestCase();
    /// @brief Called after the last test function was executed.
    void cleanupTestCase();
    /// @brief Called before each test function is executed.
    void init();
    /// @brief Called after every test function.
    void cleanup();

private slots:
    void welcomeMessage_returnsCorrectMessage();

private:
    MasterController masterController;
};

}}

#endif

我们明确添加了initTestCase()cleanupTestCase()支撑方法,这样它们的来源就不再神秘。我们还为完整性添加了另外两个特殊的支撑方法:init()cleanup()。不同之处在于这些方法在每个单独的测试之前和之后执行,而不是在整个测试套件之前和之后执行。

这些方法对我们没有任何作用,只是为了将来参考。如果您想简化事情,可以安全地将它们删除。

master-controller-tests.cpp

#include "master-controller-tests.h"

namespace cm {
namespace controllers { // Instance

static MasterControllerTests instance;

MasterControllerTests::MasterControllerTests()
    : TestSuite( "MasterControllerTests" )
{
}

}

namespace controllers { // Scaffolding

void MasterControllerTests::initTestCase()
{
}

void MasterControllerTests::cleanupTestCase()
{
}

void MasterControllerTests::init()
{
}

void MasterControllerTests::cleanup()
{
}

}

namespace controllers { // Tests

void MasterControllerTests::welcomeMessage_returnsCorrectMessage()
{
    QCOMPARE( masterController.welcomeMessage(), QString("Welcome to the Client Management system!") );
}

}}

我们再次有一个单一的测试,但这次它确实有一些有意义的目的。我们希望测试当我们实例化一个MasterController对象并访问其welcomeMessage方法时,它是否返回我们想要的消息,即 Welcome to the Client Management system!。

与搭建方法不同,您的测试命名完全取决于个人喜好。我倾向于松散地遵循methodIAmTesting_givenSomeScenario_doesTheCorrectThing的格式,例如:

divideTwoNumbers_givenTwoValidNumbers_returnsCorrectResult()
divideTwoNumbers_givenZeroDivisor_throwsInvalidArgumentException()

我们构造一个MasterController的实例作为我们将用来测试的私有成员变量。在实现中,我们通过构造函数指定了测试套件的名称,并创建了测试类的静态实例。这是将MasterControllerTests添加到我们在TestSuite类中看到的静态向量的触发器。

最后,对于我们测试的实现,我们使用QCOMPARE宏测试我们masterController实例的welcomeMessage的值与我们想要的消息。请注意,因为QCOMPARE是一个宏,您不会得到隐式类型转换,因此您需要确保期望和实际结果的类型相同。在这里,我们通过从文字构造一个QString对象来实现这一点。

运行qmake,构建并运行以查看我们在应用程序输出窗格中的测试结果:

Creating test "MasterControllerTests"
1 tests recorded
Starting test suite...
Accessing tests from 0x40b040
1 tests detected
Executing test "MasterControllerTests"
Test result 1
Test suite complete - "1" failures detected.
Destroying test

这始于通过静态实例注册MasterControllerTests类。main()方法迭代注册的测试套件集合,并找到一个,然后执行该套件中的所有单元测试。测试套件包含一个运行并立即失败的单元测试。这可能看起来比之前不太有用,因为没有指示哪个测试失败或失败的原因。但是,请记住,这个输出只是我们为额外信息添加的qDebug()语句的输出;这不是测试执行的真正输出。在master-controller-tests.cpp中,我们使用TestSuite实例化了一个testName参数为MasterControllerTests,因此输出将被导入到名为MasterControllerTests.xml的文件中。

导航到cm/binaries文件夹,通过文件夹浏览到我们为所选配置指定项目输出的位置,在那里,您将看到MasterControllerTests.xml

<testsuite name="cm::controllers::MasterControllerTests" tests="3" failures="1" errors="0">
    <properties>
       <property name="QTestVersion" value="5.10.0"/>
       <property name="QtVersion" value="5.10.0"/>
       <property name="QtBuild" value="Qt 5.10.0 (i386-little_endian- 
                 ilp32 shared (dynamic) debug build; by GCC 5.3.0)"/>
    </properties>
    <testcase name="initTestCase" result="pass"/>
    <testcase name="welcomeMessage_returnsCorrectMessage" 
                    result="fail">
    <failure result="fail" message="Compared values are not the same Actual (masterController.welcomeMessage) : "This is MasterController to Major Tom" Expected (QString("Welcome to the Client Management system!")): "Welcome to the Client Management system!""/>
    </testcase>
    <testcase name="cleanupTestCase" result="pass"/>
    <system-err/>
</testsuite>

在这里,我们有来自测试的完整输出,您可以看到失败是因为我们从masterController得到的欢迎消息是 This is MasterController to Major Tom,而我们期望的是 Welcome to the Client Management system!。

MasterController的行为与预期不符,我们发现了一个错误,所以前往master-controller.cpp并修复问题:

QString welcomeMessage = "Welcome to the Client Management system!";

重新构建两个项目,再次执行测试,并沐浴在 100%的通过率的荣耀中:

Creating test "MasterControllerTests"
1 tests recorded
Starting test suite...
Accessing tests from 0x40b040
1 tests detected
Executing test "MasterControllerTests"
Test result 0
Test suite complete - "0" failures detected.
Destroying test

现在我们已经设置好了测试框架,让我们测试一些比简单的字符串消息更复杂的东西,并验证我们在上一章中所做的工作。

DataDecorator 测试

在第五章中,数据,我们创建了从DataDecorator派生的各种类。让我们为每个类创建相应的测试类,并测试以下功能:

  • 对象构造

  • 设置值

  • 将值作为 JSON 获取

  • 从 JSON 更新值

cm-tests/source/data中,创建DateTimeDecoratorTestsEnumeratorDecoratorTestsIntDecoratorTestsStringDecoratorTests类。

让我们从最简单的套件IntDecoratorTests开始。测试在套件之间基本上是相似的,因此一旦我们编写了一个套件,我们就能够将大部分内容复制到其他套件中,然后根据需要进行补充。

int-decorator-tests.h

#ifndef INTDECORATORTESTS_H
#define INTDECORATORTESTS_H

#include <QtTest>

#include <data/int-decorator.h>
#include <test-suite.h>

namespace cm {
namespace data {

class IntDecoratorTests : public TestSuite
{
    Q_OBJECT

public:
    IntDecoratorTests();

private slots:
    void constructor_givenNoParameters_setsDefaultProperties();
    void constructor_givenParameters_setsProperties();
    void setValue_givenNewValue_updatesValueAndEmitsSignal();
    void setValue_givenSameValue_takesNoAction();
    void jsonValue_whenDefaultValue_returnsJson();
    void jsonValue_whenValueSet_returnsJson();
    void update_whenPresentInJson_updatesValue();
    void update_whenNotPresentInJson_updatesValueToDefault();
};

}}

#endif

一种常见的方法是遵循“方法作为单元”的方法,其中每个方法是类中最小的可测试单元,然后以多种方式测试该单元。因此,我们首先测试构造函数,无论是否有参数。setValue()方法只有在实际更改值时才会执行任何操作,因此我们测试设置不同的值和相同的值。接下来,我们测试是否可以将装饰器转换为 JSON 值,无论是使用默认值(在int的情况下为0)还是使用设置的值。最后,我们对update()方法执行了一些测试。如果我们传入包含该属性的 JSON,那么我们期望值将根据 JSON 值进行更新。但是,如果 JSON 中缺少该属性,我们期望类能够优雅地处理并重置为默认值。

请注意,我们并没有明确测试value()方法。这只是一个简单的访问方法,没有副作用,我们将在其他单元测试中调用它,因此我们将在那里间接测试它。如果您愿意,可以为其创建额外的测试。

int-decorator-tests.cpp

#include "int-decorator-tests.h"

#include <QSignalSpy>

#include <data/entity.h>

namespace cm {
namespace data { // Instance

static IntDecoratorTests instance;

IntDecoratorTests::IntDecoratorTests()
    : TestSuite( "IntDecoratorTests" )
{
}

}

namespace data { // Tests

void IntDecoratorTests::constructor_givenNoParameters_setsDefaultProperties()
{
    IntDecorator decorator;
    QCOMPARE(decorator.parentEntity(), nullptr);
    QCOMPARE(decorator.key(), QString("SomeItemKey"));
    QCOMPARE(decorator.label(), QString(""));
    QCOMPARE(decorator.value(), 0);
}

void IntDecoratorTests::constructor_givenParameters_setsProperties()
{
    Entity parentEntity;
    IntDecorator decorator(&parentEntity, "Test Key", "Test Label", 
                                                       99);
    QCOMPARE(decorator.parentEntity(), &parentEntity);
    QCOMPARE(decorator.key(), QString("Test Key"));
    QCOMPARE(decorator.label(), QString("Test Label"));
    QCOMPARE(decorator.value(), 99);
}

void IntDecoratorTests::setValue_givenNewValue_updatesValueAndEmitsSignal()
{
    IntDecorator decorator;
    QSignalSpy valueChangedSpy(&decorator, 
                               &IntDecorator::valueChanged);
    QCOMPARE(decorator.value(), 0);
    decorator.setValue(99);
    QCOMPARE(decorator.value(), 99);
    QCOMPARE(valueChangedSpy.count(), 1);
}

void IntDecoratorTests::setValue_givenSameValue_takesNoAction()
{
    Entity parentEntity;
    IntDecorator decorator(&parentEntity, "Test Key", "Test Label", 
                                                               99);
    QSignalSpy valueChangedSpy(&decorator, 
                               &IntDecorator::valueChanged);
    QCOMPARE(decorator.value(), 99);
    decorator.setValue(99);
    QCOMPARE(decorator.value(), 99);
    QCOMPARE(valueChangedSpy.count(), 0);
}

void IntDecoratorTests::jsonValue_whenDefaultValue_returnsJson()
{
    IntDecorator decorator;
    QCOMPARE(decorator.jsonValue(), QJsonValue(0));
}
void IntDecoratorTests::jsonValue_whenValueSet_returnsJson()
{
    IntDecorator decorator;
    decorator.setValue(99);
    QCOMPARE(decorator.jsonValue(), QJsonValue(99));
}

void IntDecoratorTests::update_whenPresentInJson_updatesValue()
{
    Entity parentEntity;
    IntDecorator decorator(&parentEntity, "Test Key", "Test Label", 99);
    QSignalSpy valueChangedSpy(&decorator, 
                               &IntDecorator::valueChanged);
    QCOMPARE(decorator.value(), 99);
    QJsonObject jsonObject;
    jsonObject.insert("Key 1", "Value 1");
    jsonObject.insert("Test Key", 123);
    jsonObject.insert("Key 3", 3);
    decorator.update(jsonObject);
    QCOMPARE(decorator.value(), 123);
    QCOMPARE(valueChangedSpy.count(), 1);
}

void IntDecoratorTests::update_whenNotPresentInJson_updatesValueToDefault()
{
    Entity parentEntity;
    IntDecorator decorator(&parentEntity, "Test Key", "Test Label", 
                                                                99);
    QSignalSpy valueChangedSpy(&decorator, 
                               &IntDecorator::valueChanged);
    QCOMPARE(decorator.value(), 99);
    QJsonObject jsonObject;
    jsonObject.insert("Key 1", "Value 1");
    jsonObject.insert("Key 2", 123);
    jsonObject.insert("Key 3", 3);
    decorator.update(jsonObject);
    QCOMPARE(decorator.value(), 0);
    QCOMPARE(valueChangedSpy.count(), 1);
}

}}

单元测试通常遵循“安排 > 执行 > 断言”的模式。首先满足测试的前提条件:变量被初始化,类被配置等。然后执行一个操作,通常是调用正在测试的函数。最后,检查操作的结果。有时,这些步骤中的一个或多个将是不必要的,或者可能与另一个步骤合并,但这是一般模式。

我们通过初始化一个没有传递任何参数的新IntDecorator来开始测试构造函数,然后测试对象的各种属性是否已初始化为预期的默认值,使用QCOMPARE将实际值与预期值进行匹配。然后我们重复测试,但这次,我们为每个参数传递值,并验证它们是否已在实例中更新。

在测试setValue()方法时,我们需要检查valueChanged()信号是否被发射。我们可以通过将 lambda 连接到信号来设置一个标志来实现这一点,如下所示:

bool isCalled = false;
QObject::connect(&decorator, &IntDecorator::valueChanged, [&isCalled](){
    isCalled = true;
});

/*...Perform action...*/ 

QVERIFY(isCalled);

然而,我们在这里使用的一个更简单的解决方案是使用 Qt 的QSignalSpy类来跟踪对指定信号的调用。然后,我们可以使用count()方法来检查信号被调用的次数。

第一个setValue()测试确保当我们提供一个与现有值不同的新值时,该值将被更新,并且valueChanged()信号将被发射一次。第二个测试确保当我们设置相同的值时,不会采取任何操作,并且信号不会被发射。请注意,在这两种情况下,我们都使用额外的QCOMPARE调用来断言在采取行动之前值是否符合我们的预期。考虑以下伪测试:

  1. 设置你的类。

  2. 执行一个操作。

  3. 测试值为99

如果一切都按预期工作,第 1 步将值设置为0,第 2 步执行正确的操作并将值更新为99,第 3 步通过,因为值为99。然而,第 1 步可能是有错误的,并错误地将值设置为99,第 2 步甚至没有实现并且不采取任何行动,但第 3 步(和测试)通过,因为值为99。通过在第 1 步之后使用QCOMPARE前提条件,可以避免这种情况。

jsonValue()测试是简单的相等性检查,无论是使用默认值还是设置值。

最后,在update()测试中,我们构造了一对 JSON 对象。在一个对象中,我们添加了一个具有与我们的装饰器对象相同键的项(“Test Key”),我们期望它被匹配,并且相关值(123)通过setValue()传递。在第二个对象中,该键不存在。在这两种情况下,我们还添加了其他多余的项,以确保类能够正确地忽略它们。后续操作的检查与setValue()测试相同。

StringDecoratorTests类基本上与IntDecoratorTests相同,只是值数据类型不同,并且默认值为空字符串""而不是0

DateTimeDecorator也遵循相同的模式,但还有额外的测试用于字符串格式化辅助方法toIso8601String()等。

EnumeratorDecoratorTests执行相同的测试,但需要更多的设置,因为需要一个枚举器和相关的映射器。在测试的主体中,每当我们测试value()时,我们还需要测试valueDescription()以确保两者保持一致。例如,每当值是eTestEnum::Value2时,valueDescription()必须是Value 2。请注意,我们总是将枚举值与value()检查一起使用,并将它们static_castint。考虑以下例子:

QCOMPARE(decorator.value(), static_cast<int>(eTestEnum::Value2));

通过使用原始的int值来缩短这个过程可能很诱人:

QCOMPARE(decorator.value(), 2);

这种方法的问题,除了数字 2 对于代码读者来说比枚举Value2的意义要少得多之外,是eTestEnum的值可能会改变并使测试无效。考虑这个例子:

enum eTestEnum {
    Unknown = 0,
    MyAmazingNewTestValue,
    Value1,
    Value2,
    Value3
};

由于插入了MyAmazingNewTestValueValue2的数字等价物实际上现在是 3。任何使用数字 2 表示Value2的测试现在都是错误的,而那些使用更冗长的static_cast<int>(eTestEnum::Value2)的测试仍然是正确的。

重新构建并运行新的测试套件,它们应该都能愉快地通过并让我们对之前编写的代码重新获得信心。测试了数据装饰器后,让我们继续测试我们的数据模型。

实体测试

既然我们对数据装饰器的工作有了一些信心,让我们提升一个层次,测试我们的数据实体。Client 类是我们模型层次结构的根,通过测试它,我们可以测试我们的其他模型。

我们已经在cm-tests/source/models中有client-tests.cpp,这是 Qt Creator 在创建项目时为我们添加的,所以继续添加一个配套的头文件client-tests.h

client-tests.h

#ifndef CLIENTTESTS_H
#define CLIENTTESTS_H

#include <QtTest>
#include <QJsonObject>

#include <models/client.h>
#include <test-suite.h>

namespace cm {
namespace models {

class ClientTests : public TestSuite
{
    Q_OBJECT

public:
    ClientTests();

private slots:
    void constructor_givenParent_setsParentAndDefaultProperties();
    void constructor_givenParentAndJsonObject_setsParentAndProperties();
    void toJson_withDefaultProperties_constructsJson();
    void toJson_withSetProperties_constructsJson();
    void update_givenJsonObject_updatesProperties();
    void update_givenEmptyJsonObject_updatesPropertiesToDefaults();

private:
    void verifyBillingAddress(const QJsonObject& jsonObject);
    void verifyDefaultBillingAddress(const QJsonObject& jsonObject);
    void verifyBillingAddress(Address* address);
    void verifyDefaultBillingAddress(Address* address);
    void verifySupplyAddress(const QJsonObject& jsonObject);
    void verifyDefaultSupplyAddress(const QJsonObject& jsonObject);
    void verifySupplyAddress(Address* address);
    void verifyDefaultSupplyAddress(Address* address);
    void verifyAppointments(const QJsonObject& jsonObject);
    void verifyDefaultAppointments(const QJsonObject& jsonObject);
    void verifyAppointments(const QList<Appointment*>& appointments);
    void verifyDefaultAppointments(const QList<Appointment*>& appointments);
    void verifyContacts(const QJsonObject& jsonObject);
    void verifyDefaultContacts(const QJsonObject& jsonObject);
    void verifyContacts(const QList<Contact*>& contacts);
    void verifyDefaultContacts(const QList<Contact*>& contacts);

    QByteArray jsonByteArray = R"(
    {
        "reference": "CM0001",
        "name": "Mr Test Testerson",
        "billingAddress": {
            "building": "Billing Building",
            "city": "Billing City",
            "postcode": "Billing Postcode",
            "street": "Billing Street"
        },
        "appointments": [
         {"startAt": "2017-08-20T12:45:00", "endAt": "2017-08-
                      20T13:00:00", "notes": "Test appointment 1"},
         {"startAt": "2017-08-21T10:30:00", "endAt": "2017-08-
                      21T11:30:00", "notes": "Test appointment 2"}
        ],
        "contacts": [
            {"contactType": 2, "address":"email@test.com"},
            {"contactType": 1, "address":"012345678"}
        ],
        "supplyAddress": {
            "building": "Supply Building",
            "city": "Supply City",
            "postcode": "Supply Postcode",
            "street": "Supply Street"
        }
    })";
};

}}

#endif

这里有三个主要的测试区域:

  • 对象构造

  • 序列化为 JSON

  • 从 JSON 反序列化

与之前的套件一样,每个区域都有几种不同的测试方式,一种是使用默认数据,一种是使用指定数据。在私有部分,您会看到许多验证方法。它们用于封装测试特定子集所需的功能。这样做的优势与常规代码相同:它们使单元测试更加简洁和可读,并且允许轻松重用验证规则。此外,在私有部分,我们定义了一个 JSON 块,我们可以用它来构造我们的 Client 实例。QByteArray,顾名思义,只是一个带有许多相关有用函数的字节数组:

void ClientTests::constructor_givenParent_setsParentAndDefaultProperties()
{
    Client testClient(this);
    QCOMPARE(testClient.parent(), this);
    QCOMPARE(testClient.reference->value(), QString(""));
    QCOMPARE(testClient.name->value(), QString(""));

    verifyDefaultBillingAddress(testClient.billingAddress);
    verifyDefaultSupplyAddress(testClient.supplyAddress);
    verifyDefaultAppointments(testClient.appointments-
                              >derivedEntities());
    verifyDefaultContacts(testClient.contacts->derivedEntities());
}

void ClientTests::constructor_givenParentAndJsonObject_setsParentAndProperties()
{
    Client testClient(this, QJsonDocument::fromJson(jsonByteArray).object());
    QCOMPARE(testClient.parent(), this);
    QCOMPARE(testClient.reference->value(), QString("CM0001"));
    QCOMPARE(testClient.name->value(), QString("Mr Test Testerson"));

    verifyBillingAddress(testClient.billingAddress);
    verifySupplyAddress(testClient.supplyAddress);
    verifyAppointments(testClient.appointments->derivedEntities());
    verifyContacts(testClient.contacts->derivedEntities());
}

从构造函数测试开始,我们实例化一个新的 Client,有时带有 JSON 对象,有时没有。请注意,为了将我们的 JSON 字节数组转换为QJsonObject,我们需要通过QJsonDocument进行传递。一旦我们有了初始化的客户端,我们检查名称属性并利用验证方法来测试子对象的状态。无论我们是否通过 JSON 对象提供任何初始数据,我们都希望supplyAddressbillingAddress对象以及预约和联系人集合都会自动为我们创建。默认情况下,集合应该是空的:

void ClientTests::toJson_withDefaultProperties_constructsJson()
{
    Client testClient(this);
    QJsonDocument jsonDoc(testClient.toJson());
    QVERIFY(jsonDoc.isObject());
    QJsonObject jsonObject = jsonDoc.object();
    QVERIFY(jsonObject.contains("reference"));
    QCOMPARE(jsonObject.value("reference").toString(), QString(""));
    QVERIFY(jsonObject.contains("name"));
    QCOMPARE(jsonObject.value("name").toString(), QString(""));
    verifyDefaultBillingAddress(jsonObject);
    verifyDefaultSupplyAddress(jsonObject);
    verifyDefaultAppointments(jsonObject);
    verifyDefaultContacts(jsonObject);
}

void ClientTests::toJson_withSetProperties_constructsJson()
{
    Client testClient(this, QJsonDocument::fromJson(jsonByteArray).object());
    QCOMPARE(testClient.reference->value(), QString("CM0001"));
    QCOMPARE(testClient.name->value(), QString("Mr Test Testerson"));

    verifyBillingAddress(testClient.billingAddress);
    verifySupplyAddress(testClient.supplyAddress);
    verifyAppointments(testClient.appointments->derivedEntities());
    verifyContacts(testClient.contacts->derivedEntities());
    QJsonDocument jsonDoc(testClient.toJson());
    QVERIFY(jsonDoc.isObject());
    QJsonObject jsonObject = jsonDoc.object();
    QVERIFY(jsonObject.contains("reference"));
    QCOMPARE(jsonObject.value("reference").toString(), QString("CM0001"));
    QVERIFY(jsonObject.contains("name"));
    QCOMPARE(jsonObject.value("name").toString(), QString("Mr Test 
                                                  Testerson"));
    verifyBillingAddress(jsonObject);
    verifySupplyAddress(jsonObject);
    verifyAppointments(jsonObject);
    verifyContacts(jsonObject);
}

toJson()测试遵循相同的模式。我们构造一个没有 JSON 对象的对象,这样我们就可以得到所有属性和子对象的默认值。然后我们立即在构造函数中使用toJson()调用来构造一个QJsonDocument,以获取序列化的 JSON 对象。测试name属性,然后再次利用验证方法。当使用 JSON 构造Client时,我们添加前置检查,以确保我们的属性在再次调用toJson()之前已经正确设置,并测试结果:

void ClientTests::update_givenJsonObject_updatesProperties()
{
    Client testClient(this);
    testClient.update(QJsonDocument::fromJson(jsonByteArray).object());
    QCOMPARE(testClient.reference->value(), QString("CM0001"));
    QCOMPARE(testClient.name->value(), QString("Mr Test Testerson"));

    verifyBillingAddress(testClient.billingAddress);
    verifySupplyAddress(testClient.supplyAddress);
    verifyAppointments(testClient.appointments->derivedEntities());
    verifyContacts(testClient.contacts->derivedEntities());
}

void ClientTests::update_givenEmptyJsonObject_updatesPropertiesToDefaults()
{
    Client testClient(this, QJsonDocument::fromJson(jsonByteArray).object());
    QCOMPARE(testClient.reference->value(), QString("CM0001"));
    QCOMPARE(testClient.name->value(), QString("Mr Test Testerson"));
    verifyBillingAddress(testClient.billingAddress);
    verifySupplyAddress(testClient.supplyAddress);
    verifyAppointments(testClient.appointments->derivedEntities());
    verifyContacts(testClient.contacts->derivedEntities());
    testClient.update(QJsonObject());
    QCOMPARE(testClient.reference->value(), QString(""));
    QCOMPARE(testClient.name->value(), QString(""));

    verifyDefaultBillingAddress(testClient.billingAddress);
    verifyDefaultSupplyAddress(testClient.supplyAddress);
    verifyDefaultAppointments(testClient.appointments-
                              >derivedEntities());
    verifyDefaultContacts(testClient.contacts->derivedEntities());
}

update()测试与toJson()相同,但方向相反。这次,我们使用我们的字节数组构造一个 JSON 对象,并将其传递给update(),然后检查模型的状态。

各种私有验证方法只是一系列检查,可以避免我们重复编写相同的代码。考虑以下示例:

void ClientTests::verifyDefaultSupplyAddress(Address* address)
{
    QVERIFY(address != nullptr);
    QCOMPARE(address->building->value(), QString(""));
    QCOMPARE(address->street->value(), QString(""));
    QCOMPARE(address->city->value(), QString(""));
    QCOMPARE(address->postcode->value(), QString(""));
}

再次构建和运行单元测试,新的客户端测试应该都能顺利通过。

Mocking

到目前为止,我们编写的单元测试都相当简单。虽然我们的Client类并不是完全独立的,但它的依赖关系都是其他数据模型和装饰器,它可以随意拥有和更改。然而,展望未来,我们将希望将客户端数据持久化到数据库中。让我们看一些如何实现这一点的例子,并讨论我们所做的设计决策如何影响 Client 类的可测试性。

打开scratchpad项目并创建一个新的头文件mocking.h,在这里我们将实现一个虚拟的 Client 类来进行测试。

mocking.h

#ifndef MOCKING_H
#define MOCKING_H

#include <QDebug>

class Client
{
public:
    void save()
    {
        qDebug() << "Saving Client";
    }
};

#endif

main.cpp中,#include <mocking.h>,更新engine.load()行以加载默认的main.qml,如果还没有的话,并添加几行来启动和保存一个虚拟的 Client 对象:

engine.load(QUrl(QStringLiteral("qrc:/main.qml")));

Client client;
client.save();

构建并运行应用程序,忽略窗口,查看应用程序输出控制台:

Saving Client

我们有一种方法可以要求客户端保存自身,但它也需要一个数据库来保存自身。让我们将数据库管理功能封装到DatabaseController类中。在 mocking.h 中,在 Client 类之前添加以下实现。注意,你需要提前声明 Client:

class Client;

class DatabaseController
{
public:
    DatabaseController()
    {
        qDebug() << "Creating a new database connection";
    }

    void save(Client* client)
    {
        qDebug() << "Saving a Client to the production database";
    }
};

现在,编辑 Client 类:

class Client
{
    DatabaseController databaseController;

public:
    void save()
    {
        qDebug() << "Saving Client";
        databaseController.save(this);
    }
};

回到main.cpp,用以下内容替换 Client 行:

qDebug() << "Running the production code...";

Client client1;
client1.save();
Client client2;
client2.save();

现在我们创建并保存两个客户端而不是一个。再次构建、运行,并再次检查控制台:

Running the production code…
Creating a new database connection
Saving Client
Saving a Client to the production database
Creating a new database connection
Saving Client
Saving a Client to the production database

好了,现在我们将客户端保存到生产数据库中,但我们为每个客户端创建了一个新的数据库连接,这似乎有点浪费。Client 类需要一个DatabaseController的实例来运行,这就是一个依赖关系。然而,我们不需要 Client 负责创建该实例;我们可以通过构造函数传递或注入该实例,并在其他地方管理DatabaseController的生命周期。这种依赖注入技术是一种更广泛的设计模式,称为控制反转。让我们将一个共享的DatabaseController的引用传递给我们的 Client 类:

class Client
{
    DatabaseController& databaseController;

public:
    Client(DatabaseController& _databaseController)
        : databaseController(_databaseController)
    {
    }

    void save()
    {
        qDebug() << "Saving Client";
        databaseController.save(this);
    }
};

main.cpp中:

qDebug() << "Running the production code...";

DatabaseController databaseController;

Client client1(databaseController);
client1.save();
Client client2(databaseController);
client2.save();

构建并运行以下内容:

Running the production code…
Creating a new database connection
Saving Client
Saving a Client to the production database
Saving Client
Saving a Client to the production database

很好,我们已经建立了一个高效的解耦系统架构;让我们来测试一下。

mocking.h中,在 Client 类之后添加一个假的测试套件:

class ClientTestSuite
{
public:
    void saveTests()
    {
        DatabaseController databaseController;
        Client client1(databaseController);
        client1.save();
        Client client2(databaseController);
        client2.save();

        qDebug() << "Test passed!";
    }
};

main.cpp中,在保存client2后,添加以下内容来运行我们的测试:

qDebug() << "Running the test code...";

ClientTestSuite testSuite;
testSuite.saveTests();

构建并运行这个:

Running the production code...
Creating a new database connection
Saving Client
Saving a Client to the production database
Saving Client
Saving a Client to the production database
Running the test code...
Creating a new database connection
Saving Client
Saving a Client to the production database
Saving Client
Saving a Client to the production database
Test passed!

我们的测试通过了,太棒了!有什么不喜欢的呢?嗯,我们刚刚将一些测试数据保存到了我们的生产数据库中。

如果你还没有为大多数类实现接口,那么在开始单元测试后,你很快就会这样做。这不仅仅是为了避免像将测试数据写入生产数据库这样的不良副作用;它还允许你模拟各种行为,从而使单元测试变得更加容易。

因此,让我们将DatabaseController移到接口后面。用一个超级接口驱动版本替换mocking.h中的普通DatabaseController

class IDatabaseController
{
public:
    virtual ~IDatabaseController(){}
    virtual void save(Client* client) = 0;
};

class DatabaseController : public IDatabaseController
{
public:
    DatabaseController()
    {
        qDebug() << "Creating a new database connection";
    }

    void save(Client* client) override
    {
        qDebug() << "Saving a Client to the production database";
    }
};

接口已经就位,我们现在可以创建一个虚假或模拟的实现:

class MockDatabaseController : public IDatabaseController
{
public:
    MockDatabaseController()
    {
        qDebug() << "Absolutely not creating any database connections 
                                                           at all";
    }

    void save(Client* client) override
    {
        qDebug() << "Just testing - not saving any Clients to any 
                                                   databases";
    }
};

接下来,调整我们的客户端,保存一个对接口的引用,而不是具体的实现:

class Client
{
    IDatabaseController& databaseController;

public:
    Client(IDatabaseController& _databaseController)
        : databaseController(_databaseController)
    {
    }

    void save()
    {
        qDebug() << "Saving Client";
        databaseController.save(this);
    }
};

最后,改变我们的测试套件,创建一个模拟控制器传递给客户端:

void saveTests()
{
    MockDatabaseController databaseController;
    ...
}

构建并运行这个:

Running the production code...
Creating a new database connection
Saving Client
Saving a Client to the production database
Saving Client
Saving a Client to the production database
Running the test code...
Absolutely not creating any database connections at all
Saving Client
Just testing - not saving any Clients to any databases
Saving Client
Just testing - not saving any Clients to any databases
Test passed!

完美。通过编程接口和注入依赖,我们可以安全地进行隔离测试。我们可以创建尽可能多的模拟实现,并用它们来模拟我们想要的任何行为,从而使我们能够测试多种不同的场景。一旦你更深入地涉足模拟,使用像google mock这样的专用框架确实很值得,因为它们可以节省你编写大量样板模拟类的麻烦。你可以使用辅助宏轻松地一次性模拟接口,然后在运行时指定各个方法的行为。

总结

在本章中,我们首次正式查看了单元测试项目,并且你已经看到了如何使用 Qt 测试框架实现单元测试。我们还讨论了编程接口的重要性,以实现模拟。现在我们已经为我们的主要数据类准备好了单元测试,所以如果我们不小心改变了行为,单元测试将失败并为我们指出潜在的问题。

正如我们讨论过的,这不是一本关于测试驱动开发的书,我们有时会采取捷径,违背本章的建议,以尽可能简单地解释其他概念,但我敦促你在项目中实现某种单元测试,因为这是一种非常有价值的实践,总是值得额外的时间投资。一些开发人员喜欢全面的 TDD 的严谨性,而其他人更喜欢事后编写单元测试来验证他们所做的工作。找到适合你和你编码风格的方法。

我们将偶尔返回测试项目,以演示某些行为。但我们肯定不会达到 100%的代码覆盖率。现在你已经有了测试项目和脚手架,只需要为想要测试的每个类添加进一步的测试类。只要你像本章中一样从TestSuite继承,它们将在运行测试项目时被自动检测和执行。

在第七章 持久化中,我们将继续实现我们刚讨论过的功能——将数据持久化到数据库中。

第七章:持久性

在第五章中,Data,我们创建了一个在内存中捕获和保存数据的框架。然而,这只是故事的一半,因为如果不将数据持久化到某个外部目的地,那么一旦关闭应用程序,数据就会丢失。在本章中,我们将在之前的工作基础上,将数据保存到 SQLite 数据库中,以便它可以在应用程序的生命周期之外存在。保存后,我们还将构建用于查找、编辑和删除数据的方法。为了在各种数据模型中免费获得所有这些操作,我们将扩展我们的数据实体,以便它们可以自动加载和保存到我们的数据库,而无需我们在每个类中编写样板代码。我们将涵盖以下主题:

  • SQLite

  • 主键

  • 创建客户端

  • 查找客户端

  • 编辑客户端

  • 删除客户端

SQLite

近年来,通用数据库技术已经分化,NoSQL 和图形数据库的爆炸使得 SQL 数据库仍然非常适用,并且在许多应用程序中仍然是一个合适的选择。Qt 内置支持多种 SQL 数据库驱动程序类型,并且可以通过自定义驱动程序进行扩展。MySQL 和 PostgreSQL 是非常流行的开源 SQL 数据库引擎,并且默认情况下都受到支持,但是它们是用于服务器的,并且需要管理,这使得它们对我们的目的来说有点不必要地复杂。相反,我们将使用更轻量级的 SQLite,它通常用作客户端数据库,并且由于其小的占用空间,在移动应用程序中非常受欢迎。

根据官方网站www.sqlite.org,“SQLite 是一个独立的、高可靠性的、嵌入式的、功能齐全的、公共领域的 SQL 数据库引擎。SQLite 是世界上使用最多的数据库引擎”。配合 Qt 的 SQL 相关类,创建数据库并存储数据非常容易。

我们需要做的第一件事是将 SQL 模块添加到我们的库项目中,以便访问 Qt 的所有 SQL 功能。在cm-lib.pro中添加以下内容:

QT += sql

接下来,我们将接受前一章讨论的内容,并在接口后面实现与数据库相关的功能。在cm-lib/source/controllers中创建一个新的i-database-controller.h头文件:

#ifndef IDATABASECONTROLLER_H
#define IDATABASECONTROLLER_H

#include <QJsonArray>
#include <QJsonObject>
#include <QList>
#include <QObject>
#include <QString>

#include <cm-lib_global.h>

namespace cm {
namespace controllers {

class CMLIBSHARED_EXPORT IDatabaseController : public QObject
{
    Q_OBJECT

public:
    IDatabaseController(QObject* parent) : QObject(parent){}
    virtual ~IDatabaseController(){}

    virtual bool createRow(const QString& tableName, const QString& id, 
                           const QJsonObject& jsonObject) const = 0;
    virtual bool deleteRow(const QString& tableName, const QString& id) 
                                                     const = 0;
    virtual QJsonArray find(const QString& tableName, const QString& 
                                           searchText) const = 0;
    virtual QJsonObject readRow(const QString& tableName, const 
                                      QString& id) const = 0;
    virtual bool updateRow(const QString& tableName, const QString& id, 
                           const QJsonObject& jsonObject) const = 0;
};

}}

#endif

在这里,我们正在实现(创建读取更新删除) CRUD的四个基本功能,这些功能与持久存储一般相关,而不仅仅是 SQL 数据库。我们还通过一个额外的find()方法来补充这些功能,我们将使用它来查找基于提供的搜索文本的匹配客户端数组。

现在,让我们创建一个接口的具体实现。在cm-lib/source/controllers中创建一个新的DatabaseController类。

database-controller.h

#ifndef DATABASECONTROLLER_H
#define DATABASECONTROLLER_H

#include <QObject>
#include <QScopedPointer>

#include <controllers/i-database-controller.h>

#include <cm-lib_global.h>

namespace cm {
namespace controllers {

class CMLIBSHARED_EXPORT DatabaseController : public IDatabaseController
{
    Q_OBJECT

public:
    explicit DatabaseController(QObject* parent = nullptr);
    ~DatabaseController();

    bool createRow(const QString& tableName, const QString& id, const 
                         QJsonObject& jsonObject) const override;
    bool deleteRow(const QString& tableName, const QString& id) const 
                                                            override;
    QJsonArray find(const QString& tableName, const QString& 
                                   searchText) const override;
    QJsonObject readRow(const QString& tableName, const QString& id) 
                                                  const override;
    bool updateRow(const QString& tableName, const QString& id, const 
                         QJsonObject& jsonObject) const override;

private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};

}}

#endif

现在,让我们逐步了解database-controller.cpp中的每个关键实现细节:

class DatabaseController::Implementation
{
public:
    Implementation(DatabaseController* _databaseController)
        : databaseController(_databaseController)
    {
        if (initialise()) {
            qDebug() << "Database created using Sqlite version: " + 
                                                sqliteVersion();
            if (createTables()) {
                qDebug() << "Database tables created";
            } else {
                qDebug() << "ERROR: Unable to create database tables";
            }
        } else {
            qDebug() << "ERROR: Unable to open database";
        }
    }

    DatabaseController* databaseController{nullptr};
    QSqlDatabase database;

private:
    bool initialise()
    {
        database = QSqlDatabase::addDatabase("QSQLITE", "cm");
        database.setDatabaseName( "cm.sqlite" );
        return database.open();
    }

    bool createTables()
    {
        return createJsonTable( "client" );
    }

    bool createJsonTable(const QString& tableName) const
    {
        QSqlQuery query(database);
        QString sqlStatement = "CREATE TABLE IF NOT EXISTS " + 
         tableName + " (id text primary key, json text not null)";

        if (!query.prepare(sqlStatement)) return false;

        return query.exec();
    }

    QString sqliteVersion() const
    {
        QSqlQuery query(database);

        query.exec("SELECT sqlite_version()");

        if (query.next()) return query.value(0).toString();

        return QString::number(-1);
    }
};

从私有实现开始,我们将初始化分为两个操作:initialise()实例化一个连接到名为cm.sqlite的 SQLite 数据库的操作,如果数据库文件不存在,此操作将首先为我们创建数据库文件。文件将在与应用程序可执行文件相同的文件夹中创建,createTables()然后创建我们需要的任何表,这些表在数据库中不存在。最初,我们只需要一个名为 client 的单个表,但稍后可以轻松扩展。我们将实际创建命名表的工作委托给createJsonTable()方法,以便我们可以在多个表中重用它。

传统的规范化关系数据库方法是将我们的每个数据模型持久化到自己的表中,字段与类的属性匹配。回想一下第五章中的模型图,如下所示:

我们可以创建一个带有“reference”和“name”字段的客户端表,一个带有“type”、“address”和其他字段的联系人表。然而,我们将利用我们已经实现的 JSON 序列化代码,并实现一个伪文档式数据库。我们将利用一个单一的客户端表,该表将存储客户端的唯一 ID 以及整个客户端对象层次结构序列化为 JSON。

最后,我们还添加了一个sqliteVersion()实用方法来识别数据库使用的 SQLite 版本:

bool DatabaseController::createRow(const QString& tableName, const QString& id, const QJsonObject& jsonObject) const
{
    if (tableName.isEmpty()) return false;
    if (id.isEmpty()) return false;
    if (jsonObject.isEmpty()) return false;

    QSqlQuery query(implementation->database);

    QString sqlStatement = "INSERT OR REPLACE INTO " + tableName + " 
                            (id, json) VALUES (:id, :json)";

    if (!query.prepare(sqlStatement)) return false;

    query.bindValue(":id", QVariant(id));
    query.bindValue(":json",    
   QVariant(QJsonDocument(jsonObject).toJson(QJsonDocument::Compact)));

    if(!query.exec()) return false;

    return query.numRowsAffected() > 0;
}

bool DatabaseController::deleteRow(const QString& tableName, const QString& id) const
{
    if (tableName.isEmpty()) return false;
    if (id.isEmpty()) return false;

    QSqlQuery query(implementation->database);

    QString sqlStatement = "DELETE FROM " + tableName + " WHERE 
                            id=:id";

    if (!query.prepare(sqlStatement)) return false;

    query.bindValue(":id", QVariant(id));

    if(!query.exec()) return false;

    return query.numRowsAffected() > 0;
}

QJsonObject DatabaseController::readRow(const QString& tableName, const QString& id) const
{
    if (tableName.isEmpty()) return {};
    if (id.isEmpty()) return {};

    QSqlQuery query(implementation->database);

    QString sqlStatement = "SELECT json FROM " + tableName + " WHERE 
                            id=:id";

    if (!query.prepare(sqlStatement)) return {};

    query.bindValue(":id", QVariant(id));

    if (!query.exec()) return {};

    if (!query.first()) return {};

    auto json = query.value(0).toByteArray();
    auto jsonDocument = QJsonDocument::fromJson(json);

    if (!jsonDocument.isObject()) return {};

    return jsonDocument.object();
}

bool DatabaseController::updateRow(const QString& tableName, const QString& id, const QJsonObject& jsonObject) const
{
    if (tableName.isEmpty()) return false;
    if (id.isEmpty()) return false;
    if (jsonObject.isEmpty()) return false;

    QSqlQuery query(implementation->database);

    QString sqlStatement = "UPDATE " + tableName + " SET json=:json 
                            WHERE id=:id";

    if (!query.prepare(sqlStatement)) return false;

    query.bindValue(":id", QVariant(id));
    query.bindValue(":json", 
   QVariant(QJsonDocument(jsonObject).toJson(QJsonDocument::Compact)));

    if(!query.exec()) return false;

    return query.numRowsAffected() > 0;
}

CRUD 操作都是基于QSqlQuery类和准备的sqlStatements。在所有情况下,我们首先对参数进行一些例行检查,以确保我们不会做一些愚蠢的事情。然后,我们将表名连接到一个 SQL 字符串中,用:myParameter语法表示参数。在准备好语句之后,随后使用查询对象的bindValue()方法替换参数。

在创建、删除或更新行时,我们只需在查询执行时返回一个true/false的成功指示器。假设查询准备和执行没有错误,我们检查操作受影响的行数是否大于0。读取操作返回从匹配记录中存储的 JSON 文本解析出的 JSON 对象。如果找不到记录或无法解析 JSON,则返回默认的 JSON 对象:

QJsonArray DatabaseController::find(const QString& tableName, const QString& searchText) const
{
    if (tableName.isEmpty()) return {};
    if (searchText.isEmpty()) return {};

    QSqlQuery query(implementation->database);

    QString sqlStatement = "SELECT json FROM " + tableName + " where 
                            lower(json) like :searchText";

    if (!query.prepare(sqlStatement)) return {};

    query.bindValue(":searchText", QVariant("%" + searchText.toLower() 
                                                             + "%"));

    if (!query.exec()) return {};

    QJsonArray returnValue;

    while ( query.next() ) {
        auto json = query.value(0).toByteArray();
        auto jsonDocument = QJsonDocument::fromJson(json);
        if (jsonDocument.isObject()) {
            returnValue.append(jsonDocument.object());
        }
    }

    return returnValue;
}

最后,find()方法本质上与 CRUD 操作相同,但编译一个 JSON 对象数组,因为可能有多个匹配项。请注意,我们在 SQL 语句中使用like关键字,结合%通配符字符,以查找包含搜索文本的任何 JSON。我们还将比较的两侧转换为小写,以使搜索有效地不区分大小写。

主键

在大多数这些操作中,使用 ID 参数作为我们表中的主键至关重要。为了支持使用这个新的数据库控制器持久化我们的实体,我们需要向我们的Entity类添加一个属性,用于唯一标识该实体的一个实例。

entity.cpp中,向Entity::Implementation添加一个成员变量:

QString id;

然后,在构造函数中初始化它:

Implementation(Entity* _entity, IDatabaseController* _databaseController, const QString& _key)
    : entity(_entity)
    , databaseController(_databaseController)
    , key(_key)
    , id(QUuid::createUuid().toString())
{
}

当我们实例化一个新的Entity时,我们需要生成一个新的唯一 ID,并使用createUuid()方法使用 QUuid 类为我们生成。通用唯一标识符UUID)本质上是一个随机生成的数字,然后我们将其转换为字符串格式“{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}”,其中“x”是一个十六进制数字。您需要#include <QUuid>

接下来,为其提供一个公共访问器方法:

const QString& Entity::id() const
{
    return implementation->id;
}

现在的挑战是,如果我们正在创建一个已经具有 ID 的Entity(例如,从数据库加载客户端),我们需要一些机制来用已知值覆盖生成的 ID 值。我们将在update()方法中执行此操作:

void Entity::update(const QJsonObject& jsonObject)
{
    if (jsonObject.contains("id")) {
        implementation->id = jsonObject.value("id").toString();
    }

    …

}

同样,当我们将对象序列化为 JSON 时,我们也需要包含 ID:

QJsonObject Entity::toJson() const
{
    QJsonObject returnValue;
    returnValue.insert("id", implementation->id);
    …
}

太好了!这为我们所有的数据模型自动生成了唯一的 ID,我们可以将其用作数据库表中的主键。然而,数据库表的一个常见用例是实际上存在一个非常适合用作主键的现有字段,例如国民保险号码、社会安全号码、帐户参考或站点 ID。如果设置了,让我们添加一个指定要用作 ID 的数据装饰器的机制,以覆盖默认的 UUID。

在我们的Entity类中,在Implementation中添加一个新的私有成员:

class Entity::Implementation
{
    ...
    StringDecorator* primaryKey{nullptr};
    ...
}

您需要#include StringDecorator头文件。添加一个受保护的修改器方法来设置它:

void Entity::setPrimaryKey(StringDecorator* primaryKey) 
{ 
    implementation->primaryKey = primaryKey; 
}

然后,我们可以调整我们的id()方法,以便在适当的情况下返回主键值,否则默认返回生成的 UUID 值:

const QString& Entity::id() const
{
    if(implementation->primaryKey != nullptr && !implementation->primaryKey->value().isEmpty()) {
        return implementation->primaryKey->value();
    }
    return implementation->id;
}

然后,在client.cpp构造函数中,在我们实例化所有数据装饰器之后,我们可以指定我们要使用引用字段作为我们的主键:

Client::Client(QObject* parent)
    : Entity(parent, "client")
{
    ...

    setPrimaryKey(reference);
}

让我们添加一些测试来验证这种行为。我们将验证如果设置了引用值,id()方法将返回该值,否则将返回一个松散地符合“{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}”格式的生成的 UUID。

cm-tests项目的client-tests.h中,在私有槽范围中添加两个新测试:

void id_givenPrimaryKeyWithNoValue_returnsUuid();
void id_givenPrimaryKeyWithValue_returnsPrimaryKey();

然后,在client-tests.cpp中实现测试:

void ClientTests::id_givenPrimaryKeyWithNoValue_returnsUuid()
{
    Client testClient(this);

    // Using individual character checks
    QCOMPARE(testClient.id().left(1), QString("{"));
    QCOMPARE(testClient.id().mid(9, 1), QString("-"));
    QCOMPARE(testClient.id().mid(14, 1), QString("-"));
    QCOMPARE(testClient.id().mid(19, 1), QString("-"));
    QCOMPARE(testClient.id().mid(24, 1), QString("-"));
    QCOMPARE(testClient.id().right(1), QString("}"));

    // Using regular expression pattern matching
    QVERIFY(QRegularExpression("\\{.{8}-(.{4})-(.{4})-(.{4})-(.
                        {12})\\}").match(testClient.id()).hasMatch());
}

void ClientTests::id_givenPrimaryKeyWithValue_returnsPrimaryKey()
{
    Client testClient(this, QJsonDocument::fromJson(jsonByteArray).object());
    QCOMPARE(testClient.reference->value(), QString("CM0001"));
    QCOMPARE(testClient.id(), testClient.reference->value());
}

请注意,在第一个测试中,检查实际上进行了两次,只是为了演示您可以采取的几种不同方法。首先,我们使用单个字符匹配('{','-'和'}')进行检查,这相当冗长,但其他开发人员很容易阅读和理解。然后,我们再次使用 Qt 的正则表达式辅助类进行检查。这要短得多,但对于不懂正则表达式语法的普通人来说更难解析。

构建并运行测试,它们应该验证我们刚刚实施的更改。

创建客户端

让我们利用我们的新基础设施,并连接CreateClientView。如果您记得,我们提供了一个保存命令,当单击时,会调用CommandController上的onCreateClientSaveExecuted()。为了能够执行任何有用的操作,CommandController需要能够序列化和保存客户端实例,并且需要一个IDatabaseController接口的实现来为我们执行创建操作。

将它们注入到command-controller.h中的构造函数中,包括任何必要的头文件:

explicit CommandController(QObject* _parent = nullptr, IDatabaseController* databaseController = nullptr, models::Client* newClient = nullptr);

正如我们现在已经看到了几次,将成员变量添加到Implementation中:

IDatabaseController* databaseController{nullptr};
Client* newClient{nullptr};

将它们通过CommandController构造函数传递到 Implementation 构造函数:

Implementation(CommandController* _commandController, IDatabaseController* _databaseController, Client* _newClient)
    : commandController(_commandController)
    , databaseController(_databaseController)
    , newClient(_newClient)           
{
    ...
}
CommandController::CommandController(QObject* parent, IDatabaseController* databaseController, Client* newClient)
    : QObject(parent)
{
    implementation.reset(new Implementation(this, databaseController, newClient));
}

现在我们可以更新onCreateClientSaveExecuted()方法来创建我们的新客户端:

void CommandController::onCreateClientSaveExecuted()
{
    qDebug() << "You executed the Save command!";

    implementation->databaseController->createRow(implementation->newClient->key(), implementation->newClient->id(), implementation->newClient->toJson());

    qDebug() << "New client saved.";
}

我们的客户端实例为我们提供了保存到数据库所需的所有信息,数据库控制器执行数据库交互。

我们的CommandController现在已经准备就绪,但我们实际上还没有注入数据库控制器或新客户端,因此转到master-controller.cpp,并像我们在CommandControllerNavigationController中一样添加一个DatabaseController实例。添加一个私有成员,访问器方法和Q_PROPERTY

Implementation构造函数中,我们需要确保在初始化CommandController之前初始化新的客户端和DatabaseController,然后通过指针传递:

Implementation(MasterController* _masterController)
    : masterController(_masterController)
{
    databaseController = new DatabaseController(masterController);
    navigationController = new NavigationController(masterController);
    newClient = new Client(masterController);
    commandController = new CommandController(masterController, databaseController, newClient);
}

构建和运行cm-ui,您应该在应用程序输出中看到新实例化的DatabaseController的消息,告诉您它已经创建了数据库和表:

Database created using Sqlite version: 3.20.1
Database tables created

查看您的二进制文件所在的输出文件夹,您将看到一个新的cm.sqlite文件。

如果您导航到创建客户端视图,输入名称,然后单击保存按钮,您将看到进一步的输出,确认新客户端已成功保存:

You executed the Save command!
New client saved

让我们来看看我们的数据库内部,并查看为我们完成了哪些工作。有几个 SQLite 浏览应用程序和 Web 浏览器插件可用,但我倾向于使用的是sqlitebrowser.org/上找到的一个。下载并安装这个,或者您选择的任何其他客户端适用于您的操作系统,并打开cm.sqlite文件:

您将看到我们有一个客户端表,就像我们要求的那样,有两个字段:id 和 json。浏览客户端表的数据,您将看到我们新创建的记录,其中包含我们在 UI 上输入的名称属性:

太棒了,我们已经在数据库中创建了我们的第一个客户端。请注意,DatabaseController初始化方法是幂等的,因此您可以再次启动应用程序,现有的数据库不会受到影响。同样,如果您手动删除cm.sqlite文件,然后启动应用程序将为您创建一个新版本(不包括旧数据),这是一种简单的删除测试数据的方法。

让我们快速调整一下,添加客户的reference属性。在CreateClientView中,复制绑定到ui_nameStringEditorSingleLine组件,并将新控件绑定到ui_reference。构建、运行,并创建一个新的客户:

我们的新客户愉快地使用指定的客户引用作为唯一的主键:

面板

现在,让我们稍微完善一下我们的CreateClientView,这样我们就可以保存一些有意义的数据,而不仅仅是一堆空字符串。我们还有很多字段要添加,所以我们会稍微分开一些东西,并且通过将它们封装在具有描述性标题和下拉阴影的离散面板中,从视觉上将数据与不同的模型分开,为我们的 UI 增添一些活力:

我们将首先创建一个通用的面板组件。在cm-ui/components中创建一个名为Panel.qml的新的 QML 文件。更新components.qrcqmldir,就像我们为所有其他组件所做的那样:

import QtQuick 2.9
import assets 1.0

Item {
    implicitWidth: parent.width
    implicitHeight: headerBackground.height +    
    contentLoader.implicitHeight + (Style.sizeControlSpacing * 2)
    property alias headerText: title.text
    property alias contentComponent: contentLoader.sourceComponent

    Rectangle {
        id: shadow
        width: parent.width
        height: parent.height
        x: Style.sizeShadowOffset
        y: Style.sizeShadowOffset
        color: Style.colourShadow
    }

    Rectangle {
        id: headerBackground
        anchors {
            top: parent.top
            left: parent.left
            right: parent.right
        }
        height: Style.heightPanelHeader
        color: Style.colourPanelHeaderBackground

        Text {
            id: title
            text: "Set Me!"
            anchors {
                fill: parent
                margins: Style.heightDataControls / 4
            }
            color: Style.colourPanelHeaderFont
            font.pixelSize: Style.pixelSizePanelHeader
            verticalAlignment: Qt.AlignVCenter
        }
    }

    Rectangle {
        id: contentBackground
        anchors {
            top: headerBackground.bottom
            left: parent.left
            right: parent.right
            bottom: parent.bottom
        }
        color: Style.colourPanelBackground

        Loader {
            id: contentLoader
            anchors {
                left: parent.left
                right: parent.right
                top: parent.top
                margins: Style.sizeControlSpacing
            }
        }
    }
}

这是一个非常动态的组件。与我们的其他组件不同,我们在这里传递整个面板的内容,而不是传递一个字符串或者甚至是一个自定义类。我们使用Loader组件来实现这一点,它可以根据需要加载 QML 子树。我们别名sourceComponent属性,以便调用元素可以在运行时注入他们想要的内容。

由于内容的动态性,我们无法设置组件的固定大小,因此我们利用implicitWidthimplicitHeight属性告诉父元素组件希望的大小,基于标题栏的大小加上动态内容的大小。

为了渲染阴影,我们绘制一个简单的Rectangle,确保它首先被渲染,通过将它放在文件的顶部附近。然后我们使用xy属性来使其与其他元素偏移,稍微向下和向下移动。然后,用于标题条和面板背景的其余Rectangle元素被绘制在阴影的顶部。

为了支持这里的样式,我们需要添加一系列新的Style属性:

readonly property real sizeControlSpacing: 10
readonly property color colourPanelBackground: "#ffffff"
readonly property color colourPanelBackgroundHover: "#ececec"
readonly property color colourPanelHeaderBackground: "#131313"
readonly property color colourPanelHeaderFont: "#ffffff"
readonly property color colourPanelFont: "#131313"
readonly property int pixelSizePanelHeader: 18
readonly property real heightPanelHeader: 40
readonly property real sizeShadowOffset: 5
readonly property color colourShadow: "#dedede"

接下来,让我们添加一个地址编辑组件,这样我们就可以在供应地址和账单地址上重用它。在cm-ui/components中创建一个名为AddressEditor.qml的新的 QML 文件。像之前一样更新components.qrcqmldir

我们将使用我们的新的Panel组件作为根元素,并添加一个Address属性,这样我们就可以传递一个任意的数据模型进行绑定:

import QtQuick 2.9
import CM 1.0
import assets 1.0

Panel {
    property Address address

    contentComponent:
        Column {
            id: column
            spacing: Style.sizeControlSpacing
            StringEditorSingleLine {
                stringDecorator: address.ui_building
                anchors {
                    left: parent.left
                    right: parent.right
                }
            }
            StringEditorSingleLine {
                stringDecorator: address.ui_street
                anchors {
                    left: parent.left
                    right: parent.right
                }
            }
            StringEditorSingleLine {
                stringDecorator: address.ui_city
                anchors {
                    left: parent.left
                    right: parent.right
                }
            }
            StringEditorSingleLine {
                stringDecorator: address.ui_postcode
                anchors {
                    left: parent.left
                    right: parent.right
                }
            }
        }
}

在这里,你可以看到我们新的Panel组件的灵活性,这要归功于嵌入的Loader元素。我们可以传递任何我们想要的 QML 内容,并且它将显示在面板中。

最后,我们可以更新我们的CreateClientView,添加我们新重构的地址组件。我们还将客户控件移动到它们自己的面板上:

import QtQuick 2.9
import QtQuick.Controls 2.2
import CM 1.0
import assets 1.0
import components 1.0

Item {
    property Client newClient: masterController.ui_newClient

    Column {
        spacing: Style.sizeScreenMargin
        anchors {
            left: parent.left
            right: parent.right
            top: parent.top
            margins: Style.sizeScreenMargin
        }
        Panel {
            headerText: "Client Details"
            contentComponent:
                Column {
                    spacing: Style.sizeControlSpacing
                    StringEditorSingleLine {
                        stringDecorator: newClient.ui_reference
                        anchors {
                            left: parent.left
                            right: parent.right
                        }
                    }
                    StringEditorSingleLine {
                        stringDecorator: newClient.ui_name
                        anchors {
                            left: parent.left
                            right: parent.right
                        }
                    }
                }
        }
        AddressEditor {
            address: newClient.ui_supplyAddress
            headerText: "Supply Address"
        }
        AddressEditor {
            address: newClient.ui_billingAddress
            headerText: "Billing Address"
        }
    }
    CommandBar {
        commandList: masterController.ui_commandController.ui_createClientViewContextCommands
    }
}

在构建和运行之前,我们只需要调整StringEditorSingleLinetextLabel的背景颜色,以使其与它们现在显示在的面板匹配:

Rectangle {
    width: Style.widthDataControls
    height: Style.heightDataControls
    color: Style.colourPanelBackground
    Text {
        id: textLabel
        …
    }
}

继续创建一个新的客户并检查数据库。现在你应该看到供应和账单地址的详细信息已经成功保存。我们现在已经让我们的 CRUD 操作起作用了,所以让我们继续进行‘R’。

查找客户

我们刚刚成功地将我们的第一个客户保存到数据库中,现在让我们看看如何找到并查看这些数据。我们将在cm-lib中的一个专用类中封装我们的搜索功能,所以继续在cm-lib/source/models中创建一个名为ClientSearch的新类。

client-search.h:

#ifndef CLIENTSEARCH_H
#define CLIENTSEARCH_H

#include <QScopedPointer>

#include <cm-lib_global.h>
#include <controllers/i-database-controller.h>
#include <data/string-decorator.h>
#include <data/entity.h>
#include <data/entity-collection.h>
#include <models/client.h>

namespace cm {
namespace models {

class CMLIBSHARED_EXPORT ClientSearch : public data::Entity
{
    Q_OBJECT
    Q_PROPERTY( cm::data::StringDecorator* ui_searchText READ 
                                           searchText CONSTANT )
    Q_PROPERTY( QQmlListProperty<cm::models::Client> ui_searchResults 
                READ ui_searchResults NOTIFY searchResultsChanged )

public:
    ClientSearch(QObject* parent = nullptr, 
    controllers::IDatabaseController* databaseController = nullptr);
    ~ClientSearch();

    data::StringDecorator* searchText();
    QQmlListProperty<Client> ui_searchResults();
    void search();

signals:
    void searchResultsChanged();

private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};

}}

#endif

client-search.cpp:

#include "client-search.h"
#include <QDebug>

using namespace cm::controllers;
using namespace cm::data;

namespace cm {
namespace models {

class ClientSearch::Implementation
{
public:
    Implementation(ClientSearch* _clientSearch, IDatabaseController* 
                                                _databaseController)
        : clientSearch(_clientSearch)
        , databaseController(_databaseController)
    {
    }

    ClientSearch* clientSearch{nullptr};
    IDatabaseController* databaseController{nullptr};
    data::StringDecorator* searchText{nullptr};
    data::EntityCollection<Client>* searchResults{nullptr};
};

ClientSearch::ClientSearch(QObject* parent, IDatabaseController* databaseController)
    : Entity(parent, "ClientSearch")
{
    implementation.reset(new Implementation(this, databaseController));
    implementation->searchText = static_cast<StringDecorator*>(addDataItem(new StringDecorator(this, "searchText", "Search Text")));
    implementation->searchResults = static_cast<EntityCollection<Client>*>(addChildCollection(new EntityCollection<Client>(this, "searchResults")));

    connect(implementation->searchResults, &EntityCollection<Client>::collectionChanged, this, &ClientSearch::searchResultsChanged);
}

ClientSearch::~ClientSearch()
{
}

StringDecorator* ClientSearch::searchText()
{
    return implementation->searchText;
}

QQmlListProperty<Client> ClientSearch::ui_searchResults()
{
    return QQmlListProperty<Client>(this, implementation->searchResults->derivedEntities());
}

void ClientSearch::search()
{
    qDebug() << "Searching for " << implementation->searchText->value() << "...";
}

}}

我们需要从用户那里捕获一些文本,使用该文本搜索数据库,并将结果显示为匹配客户的列表。我们使用StringDecorator来容纳文本,实现一个search()方法来执行搜索,最后,添加一个EntitityCollection<Client>来存储结果。这里还有一个额外的要点是,我们需要向 UI 发出信号,告诉它搜索结果已经改变,这样它就知道需要重新绑定列表。为此,我们使用searchResultsChanged()信号进行通知,并将此信号直接连接到EntityCollection中内置的collectionChanged()信号。现在,每当隐藏在EntityCollection中的列表更新时,UI 将自动收到更改通知,并根据需要重新绘制自己。

接下来,在MasterController中添加一个ClientSearch的实例,就像我们为新的客户模型所做的那样。添加一个名为clientSearch的私有成员变量,类型为ClientSearch*,并在Implementation构造函数中对其进行初始化。记得将databaseController依赖项传递给构造函数。现在我们正在传递越来越多的依赖项,我们需要小心初始化顺序。ClientSearch依赖于DatabaseController,当我们来实现在CommandController中的搜索命令时,它将依赖于ClientSearch。因此,请确保在初始化ClientSearch之前初始化DatabaseController,并且CommandController在它们两者之后初始化。完成对MasterController的更改后,添加一个clientSearch()访问器方法和一个名为ui_clientSearchQ_PROPERTY

和往常一样,在我们可以在 UI 中使用它之前,我们需要在 QML 子系统中注册新的类。在main.cpp中,#include <models/client-search.h>并注册新类型:

qmlRegisterType<cm::models::ClientSearch>("CM", 1, 0, "ClientSearch");

有了这一切,我们可以连接我们的FindClientView

import QtQuick 2.9
import assets 1.0
import CM 1.0
import components 1.0

Item {
    property ClientSearch clientSearch: masterController.ui_clientSearch

    Rectangle {
        anchors.fill: parent
        color: Style.colourBackground

        Panel {
            id: searchPanel
            anchors {
                left: parent.left
                right: parent.right
                top: parent.top
                margins: Style.sizeScreenMargin
            }
            headerText: "Find Clients"
            contentComponent:
                StringEditorSingleLine {
                    stringDecorator: clientSearch.ui_searchText
                    anchors {
                        left: parent.left
                        right: parent.right
                    }
                }
        }
    }
}

我们通过MasterController访问ClientSearch实例,并使用属性创建一个快捷方式。我们还再次利用我们的新Panel组件,这样可以在视图之间提供一个漂亮一致的外观和感觉,而工作量很小:

下一步是添加一个命令按钮,以便我们能够发起搜索。我们在CommandController中完成这个操作。在我们开始命令之前,我们对ClientSearch实例有一个额外的依赖,所以在构造函数中添加一个参数:

CommandController::CommandController(QObject* parent, IDatabaseController* databaseController, Client* newClient, ClientSearch* clientSearch)
    : QObject(parent)
{
    implementation.reset(new Implementation(this, databaseController, newClient, clientSearch));
}

像我们对newClient所做的那样,通过参数传递到Implementation类,并将其存储在一个私有成员变量中。暂时回到MasterController,并将clientSearch实例添加到CommandController的初始化中:

commandController = new CommandController(masterController, databaseController, newClient, clientSearch);

接下来,在CommandController中,复制并重命名我们为创建客户视图添加的私有成员变量、访问器和Q_PROPERTY,这样你就会得到一个ui_findClientViewContextCommands属性供 UI 使用。

创建一个额外的公共槽,onFindClientSearchExecuted(),当我们点击搜索按钮时将被调用:

void CommandController::onFindClientSearchExecuted()
{
    qDebug() << "You executed the Search command!";

    implementation->clientSearch->search();
}

现在我们为我们的查找视图有一个空的命令列表,并且有一个在点击按钮时要调用的委托;我们现在需要做的就是在Implementation构造函数中添加一个搜索按钮:

Command* findClientSearchCommand = new Command( commandController, QChar( 0xf002 ), "Search" );
QObject::connect( findClientSearchCommand, &Command::executed, commandController, &CommandController::onFindClientSearchExecuted );
findClientViewContextCommands.append( findClientSearchCommand );

命令管道就到这里了;现在我们可以很容易地向FindClientView添加一个命令栏。将以下内容插入到根项目的最后一个元素中:

CommandBar {
    commandList: masterController.ui_commandController.ui_findClientViewContextCommands
} 

输入一些搜索文本并点击按钮,你会看到在应用程序输出控制台中一切都按预期触发了:

You executed the Search command!
Searching for "Testing"...

太好了,现在我们需要做的是获取搜索文本,查询 SQLite 数据库以获取结果列表,并在屏幕上显示这些结果。幸运的是,我们已经为查询数据库做好了准备,所以我们可以很容易地实现这一点:

void ClientSearch::search()
{
    qDebug() << "Searching for " << implementation->searchText->value() 
                                 << "...";

    auto resultsArray = implementation->databaseController-
         >find("client", implementation->searchText->value());
    implementation->searchResults->update(resultsArray);

    qDebug() << "Found " << implementation->searchResults-
             >baseEntities().size() << " matches";
}

在 UI 方面还有更多工作要做来显示结果。我们需要绑定到ui_searchResults属性,并动态显示列表中每个客户端的某种 QML 子树。我们将使用一个新的 QML 组件ListView来为我们完成繁重的工作。让我们从简单开始,以演示原理,然后逐步构建。在FindClientView中,立即在 Panel 元素之后添加以下内容:

ListView {
    id: itemsView
    anchors {
        top: searchPanel.bottom
        left: parent.left
        right: parent.right
        bottom: parent.bottom
        margins: Style.sizeScreenMargin
    }
    clip: true
    model: clientSearch.ui_searchResults
    delegate:
        Text {
            text: modelData.ui_reference.ui_label + ": " + 
                  modelData.ui_reference.ui_value
            font.pixelSize: Style.pixelSizeDataControls
            color: Style.colourPanelFont
        }
}

ListView的两个关键属性如下:

  • model,即你想要显示的项目列表

  • 代理,即你想要如何在视觉上表示每个项目

在我们的情况下,我们将模型绑定到我们的ui_searchResults,并用一个简单的Text元素表示每个项目,显示客户参考编号。这里特别重要的是modelData属性,它被神奇地注入到代理中,为我们暴露了底层项目(在这种情况下是一个客户对象)。

构建,运行,并对你迄今为止创建的一个测试客户端的 JSON 中存在的文本进行搜索,你会发现每个结果都显示了参考编号。如果你得到了多个结果并且它们排列不正确,不要担心,因为我们无论如何都会替换代理:

为了保持整洁,我们将编写一个新的自定义组件用作代理。在cm-ui/components中创建SearchResultDelegate,并像往常一样更新components.qrcqmldir

import QtQuick 2.9
import assets 1.0
import CM 1.0

Item {
    property Client client

    implicitWidth: parent.width
    implicitHeight: Math.max(clientColumn.implicitHeight, 
    textAddress.implicitHeight) + (Style.heightDataControls / 2)

    Rectangle {
        id: background
        width: parent.width
        height: parent.height
        color: Style.colourPanelBackground

        Column {
            id: clientColumn
            width: parent / 2
            anchors {
                left: parent.left
                top: parent.top
                margins: Style.heightDataControls / 4
            }
            spacing: Style.heightDataControls / 2

            Text {
                id: textReference
                anchors.left: parent.left
                text: client.ui_reference.ui_label + ": " + 
                      client.ui_reference.ui_value
                font.pixelSize: Style.pixelSizeDataControls
                color: Style.colourPanelFont
            }
            Text {
                id: textName
                anchors.left: parent.left
                text: client.ui_name.ui_label + ": " + 
                      client.ui_name.ui_value
                font.pixelSize: Style.pixelSizeDataControls
                color: Style.colourPanelFont
            }
        }

        Text {
            id: textAddress
            anchors {
                top: parent.top
                right: parent.right
                margins: Style.heightDataControls / 4
            }
            text: client.ui_supplyAddress.ui_fullAddress
            font.pixelSize: Style.pixelSizeDataControls
            color: Style.colourPanelFont
            horizontalAlignment: Text.AlignRight
        }

        Rectangle {
            id: borderBottom
            anchors {
                bottom: parent.bottom
                left: parent.left
                right: parent.right
            }
            height: 1
            color: Style.colourPanelFont
        }

        MouseArea {
            anchors.fill: parent
            cursorShape: Qt.PointingHandCursor
            hoverEnabled: true
            onEntered: background.state = "hover"
            onExited: background.state = ""
            onClicked: masterController.selectClient(client)
        }

        states: [
            State {
                name: "hover"
                PropertyChanges {
                    target: background
                    color: Style.colourPanelBackgroundHover
                }
            }
        ]
    }
}

这里并没有什么新东西,我们只是结合了其他组件中涵盖的技术。请注意,MouseArea元素将触发masterController上我们尚未实现的方法,所以如果你点击其中一个客户端时出现错误,不要担心。

FindClientView中用我们的新组件替换旧的Text代理,使用modelData属性来设置client

ListView {
    id: itemsView
    ...
    delegate:
        SearchResultDelegate {
            client: modelData
        }
}

现在,让我们在MasterController上实现selectClient()方法:

我们可以直接从SearchResultDelegate发出goEditClientView()信号,并完全绕过MasterController。这是一个完全有效的方法,而且确实更简单;然而,我更喜欢通过业务逻辑层路由所有交互,即使所有业务逻辑只是发出导航信号。这意味着如果以后需要添加任何进一步的逻辑,一切都已经连接好,你不需要更改任何管道。而且,调试 C++比 QML 要容易得多。

master-controller.h中,我们需要将我们的新方法添加为公共槽,因为它将直接从 UI 中调用,而 UI 无法看到常规的公共方法:

public slots:
    void selectClient(cm::models::Client* client);

master-controller.cpp中提供实现,简单地调用导航协调器上的相关信号,并传递客户端:

void MasterController::selectClient(Client* client)
{
    implementation->navigationController->goEditClientView(client);
}

搜索和选择已经就位,现在我们可以转向编辑客户端。

编辑客户端

现在已经从数据库中找到并加载了现有的客户端,我们需要一种机制来查看和编辑数据。首先,让我们创建在编辑视图中将使用的上下文命令。重复我们为查找客户端视图所采取的步骤,在CommandController中添加一个名为editClientViewContextCommands的新命令列表,以及一个访问方法和Q_PROPERTY

创建一个新的槽,当用户在编辑视图上保存他们的更改时调用:

void CommandController::onEditClientSaveExecuted()
{
    qDebug() << "You executed the Save command!";
}

在调用时向列表添加一个新的保存命令,调用槽:

Command* editClientSaveCommand = new Command( commandController, QChar( 0xf0c7 ), "Save" );
QObject::connect( editClientSaveCommand, &Command::executed, commandController, &CommandController::onEditClientSaveExecuted );
editClientViewContextCommands.append( editClientSaveCommand );

我们现在有一个可以呈现给编辑客户端视图的命令列表;然而,我们现在需要克服的一个挑战是,当我们执行这个命令时,CommandController 不知道它需要处理哪个客户端实例。我们不能像处理新客户端那样将选定的客户端作为依赖项传递给构造函数,因为我们不知道用户会选择哪个客户端。一个选择是将编辑命令列表从CommandController移出,并放入客户端模型中。然后,每个客户端实例可以向 UI 呈现自己的命令。然而,这意味着命令功能被分割,我们失去了命令控制器给我们的封装性。它还使客户端模型膨胀了不应该关心的功能。相反,我们将当前选定的客户端作为CommandController的成员添加到其中,并在用户导航到editClientView时设置它。在CommandController::Implementation中添加以下内容:

Client* selectedClient{nullptr};

添加一个新的公共槽:

void CommandController::setSelectedClient(cm::models::Client* client)
{
    implementation->selectedClient = client;
}

现在我们有了选定的客户端,我们可以继续完成保存槽的实现。同样,我们已经在DatabaseController和客户端类中完成了繁重的工作,所以这个方法非常简单:

void CommandController::onEditClientSaveExecuted()
{
    qDebug() << "You executed the Save command!";

    implementation->databaseController->updateRow(implementation->selectedClient->key(), implementation->selectedClient->id(), implementation->selectedClient->toJson());

    qDebug() << "Updated client saved.";
}

从 UI 的角度来看,编辑现有客户端基本上与创建新客户端是一样的。实际上,我们甚至可能可以使用相同的视图,只是在每种情况下传入不同的客户端对象。然而,我们将保持这两个功能分开,并只是复制和调整我们已经为创建客户端编写的 QML。更新EditClientView

import QtQuick 2.9
import QtQuick.Controls 2.2
import CM 1.0
import assets 1.0
import components 1.0

Item {
    property Client selectedClient
    Component.onCompleted: masterController.ui_commandController.setSelectedClient(selectedClient)

    Rectangle {
        anchors.fill: parent
        color: Style.colourBackground
    }

    ScrollView {
        id: scrollView
        anchors {
            left: parent.left
            right: parent.right
            top: parent.top
            bottom: commandBar. top
            margins: Style.sizeScreenMargin
        }
        clip: true

        Column {
            spacing: Style.sizeScreenMargin
            width: scrollView.width

            Panel {
                headerText: "Client Details"
                contentComponent:
                    Column {
                        spacing: Style.sizeControlSpacing
                        StringEditorSingleLine {
                            stringDecorator: 
                            selectedClient.ui_reference
                            anchors {
                                left: parent.left
                                right: parent.right
                            }
                        }
                        StringEditorSingleLine {
                            stringDecorator: selectedClient.ui_name
                            anchors {
                                left: parent.left
                                right: parent.right
                            }
                        }
                    }
            }

            AddressEditor {
                address: selectedClient.ui_supplyAddress
                headerText: "Supply Address"
            }

            AddressEditor {
                address: selectedClient.ui_billingAddress
                headerText: "Billing Address"
            }
        }
    }

    CommandBar {
        id: commandBar
        commandList: masterController.ui_commandController.ui_editClientViewContextCommands
    }
}

我们将客户端属性更改为MasterViewConnections元素中设置的selectedClient属性。我们使用Component.onCompleted槽调用CommandController并设置当前选定的客户端。最后,我们更新CommandBar以引用我们刚刚添加的新上下文命令列表。

构建并运行,现在您应该能够对选定的客户端进行更改,并使用保存按钮更新数据库。

删除客户端

我们 CRUD 操作的最后一部分是删除现有客户端。让我们通过EditClientView上的一个新按钮触发这个操作。我们将首先向CommandController添加在按下按钮时将被调用的槽:

void CommandController::onEditClientDeleteExecuted()
{
    qDebug() << "You executed the Delete command!";

    implementation->databaseController->deleteRow(implementation->selectedClient->key(), implementation->selectedClient->id());
    implementation->selectedClient = nullptr;

    qDebug() << "Client deleted.";

    implementation->clientSearch->search();
}

这遵循了其他槽的相同模式,只是这一次我们还清除了selectedClient属性,因为虽然客户端实例仍然存在于应用程序内存中,但它已经被用户语义化地删除了。我们还刷新搜索,以便从搜索结果中删除已删除的客户端。就目前而言,我们已经执行了正确的数据库交互,但用户将被留在刚刚要求删除的客户端的editClientView上。我们希望用户被导航回仪表板。为了做到这一点,我们需要将NavigationController作为CommandController类的附加依赖项添加进去。复制我们为DatabaseController依赖项所做的操作,以便我们可以将其注入到构造函数中。记得更新MasterController并传入导航控制器实例。

有了数据库控制器的实例,我们可以将用户发送到仪表板视图:

void CommandController::onEditClientDeleteExecuted()
{
    ...

    implementation->navigationController->goDashboardView();
}

现在我们有了导航控制器,我们还可以改进创建新客户端时的体验。让用户不再停留在新客户端视图上,而是执行对新创建的客户端 ID 的搜索并将他们导航到结果。然后他们可以轻松地选择新客户端,如果他们希望查看或编辑:

void CommandController::onCreateClientSaveExecuted()
{
    ...

    implementation->clientSearch->searchText()-
                   >setValue(implementation->newClient->id());
    implementation->clientSearch->search();
    implementation->navigationController->goFindClientView();
}

删除槽完成后,我们现在可以在CommandControllereditClientContextCommands列表中添加一个新的删除命令:

Command* editClientDeleteCommand = new Command( commandController, QChar( 0xf235 ), "Delete" );
QObject::connect( editClientDeleteCommand, &Command::executed, commandController, &CommandController::onEditClientDeleteExecuted );
editClientViewContextCommands.append( editClientDeleteCommand );

现在我们可以选择删除现有的客户端了:

如果删除客户端,您将看到该行已从数据库中删除,并且用户成功导航回仪表板。但是,您还会看到应用程序输出窗口中充满了类似qrc:/views/EditClientView:62: TypeError: Cannot read property 'ui_billingAddress' of null的 QML 警告。

原因是编辑视图绑定到搜索结果的客户端实例。当我们刷新搜索时,我们会删除旧的搜索结果,这意味着编辑视图现在绑定到nullptr,无法再访问数据。即使在刷新搜索之前导航到仪表板,也会发生这种情况,因为执行导航的信号/槽的异步性质。修复这些警告的一种方法是在视图中对所有绑定添加空检查,并在主对象为空时绑定到本地临时对象。考虑以下示例:

StringEditorSingleLine {
    property StringDecorator temporaryObject
    stringDecorator: selectedClient ? selectedClient.ui_reference : 
    temporaryObject
    anchors {
        left: parent.left
        right: parent.right
    }
}

因此,如果selectedClient不为空,则绑定到该对象的ui_reference属性,否则绑定到temporaryObject。甚至可以在根客户端属性上添加一层间接,并替换整个客户端对象:

property Client selectedClient
property Client localTemporaryClient
property Client clientToBindTo: selectedClient ? selectedClient : localTemporaryClient

在这里,selectedClient将像往常一样由父级设置;localTemporaryClient将不会被设置,因此将在本地创建一个默认实例。然后,clientToBindTo将选择适当的对象使用,并且所有子控件都可以绑定到该对象。由于这些绑定是动态的,如果在加载视图后删除了selectedClient(就像我们的情况一样),那么clientToBindTo将自动切换。

由于这只是一个演示项目,我们可以安全地忽略警告,因此我们在这里不会采取任何行动,以保持简单。

摘要

在本章中,我们为客户端模型添加了数据库持久性。我们使其通用和灵活,以便我们可以通过简单地向DatabaseController类添加新表来轻松持久化其他模型层次结构。我们涵盖了所有核心 CRUD 操作,包括针对整个 JSON 对象进行匹配的自由文本搜索功能。

在第八章中,Web 请求,我们将继续探讨超出我们应用程序范围的数据,并查看另一个极其常见的业务应用程序需求,即向 Web 服务发出 HTTP 请求。

第八章:Web 请求

这一章将带我们走向全球,从我们的应用程序进一步走向互联网。从编写一些辅助类来管理我们的 Web 请求开始,我们将从实时 RSS 订阅中提取数据,并通过一些 XML 处理来解释它。有了解析后的数据,我们可以利用我们的 QML 技能并在新视图上显示项目。点击 RSS 项目中的一个将启动一个 Web 浏览器窗口,以便更详细地查看相关文章。我们将涵盖以下主题:

  • 网络访问

  • Web 请求

  • RSS 视图

  • RSS

网络访问

低级网络协议协商由 Qt 内部处理,我们可以轻松通过QNetworkAccessManager类连接到外部世界。为了能够访问这个功能,我们需要将network模块添加到cm-lib.pro中:

QT += sql network

Qt 的一个弱点是缺乏接口,在某些情况下使单元测试变得困难。如果我们直接使用QNetworkAccessManager,我们将无法在不进行真实网络调用的情况下测试我们的代码,这是不可取的。然而,这个问题的一个快速简单的解决方案是将 Qt 实现隐藏在我们自己的接口后面,我们将在这里这样做。

在本章中,我们需要能够通过网络检查连接性并发送 HTTP GET 请求。考虑到这一点,在cm-lib/source/networking中创建一个新文件夹i-network-access-manager.h并实现接口:

#ifndef INETWORKACCESSMANAGER_H
#define INETWORKACCESSMANAGER_H
#include <QNetworkReply>
#include <QNetworkRequest>

namespace cm {
namespace networking {
class INetworkAccessManager
{
public:
    INetworkAccessManager(){}
    virtual ~INetworkAccessManager(){}
    virtual QNetworkReply* get(const QNetworkRequest& request) = 0;
    virtual bool isNetworkAccessible() const = 0;
};
}}
#endif

QNetworkRequest是另一个 Qt 类,表示要发送到网络的请求,QNetworkReply表示从网络接收到的响应。理想情况下,我们也会隐藏这些实现在接口后面,但现在让我们先使用网络访问接口。有了这个,继续在同一文件夹中创建一个具体的实现类NetworkAccessManager

network-access-manager.h

#ifndef NETWORKACCESSMANAGER_H
#define NETWORKACCESSMANAGER_H
#include <QObject>
#include <QScopedPointer>
#include <networking/i-network-access-manager.h>
namespace cm {
namespace networking {
class NetworkAccessManager : public QObject, public INetworkAccessManager
{
    Q_OBJECT
public:
    explicit NetworkAccessManager(QObject* parent = nullptr);
    ~NetworkAccessManager();
    QNetworkReply* get(const QNetworkRequest& request) override;
    bool isNetworkAccessible() const override;
private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};
}}
#endif

network-access-manager.cpp

#include "network-access-manager.h"
#include <QNetworkAccessManager>
namespace cm {
namespace networking {
class NetworkAccessManager::Implementation
{
public:
    Implementation()
    {}
    QNetworkAccessManager networkAccessManager;
};
NetworkAccessManager::NetworkAccessManager(QObject *parent)
    : QObject(parent)
    , INetworkAccessManager()
{
    implementation.reset(new Implementation());
}
NetworkAccessManager::~NetworkAccessManager()
{
}
QNetworkReply* NetworkAccessManager::get(const QNetworkRequest& request)
{
    return implementation->networkAccessManager.get(request);
}
bool NetworkAccessManager::isNetworkAccessible() const
{
    return implementation->networkAccessManager.networkAccessible() == QNetworkAccessManager::Accessible;
}
}}

我们所做的就是持有一个QNetworkAccessManager的私有实例,并通过接口将调用传递给它。接口可以很容易地扩展以包括使用相同方法的 HTTP POST 请求等其他功能。

Web 请求

如果你以前没有使用过 HTTP 协议,它归结为客户端和服务器之间的请求和响应对话。例如,我们可以在我们喜爱的网络浏览器中向www.bbc.co.uk发出请求,我们将收到一个包含各种新闻和文章的响应。在我们的NetworkAccessManager包装器的get()方法中,我们引用了一个QNetworkRequest(我们对服务器的请求)和一个QNetworkReply(服务器对我们的响应)。虽然我们不会直接隐藏QNetworkRequestQNetworkReply在它们自己独立的接口后面,但我们将采用 Web 请求和相应的概念,并为该交互创建一个接口和实现。在cm-lib/source/networking中,创建一个接口头文件i-web-request.h

#ifndef IWEBREQUEST_H
#define IWEBREQUEST_H
#include <QUrl>
namespace cm {
namespace networking {
class IWebRequest
{
public:
    IWebRequest(){}
    virtual ~IWebRequest(){}
    virtual void execute() = 0;
    virtual bool isBusy() const = 0;
    virtual void setUrl(const QUrl& url) = 0;
    virtual QUrl url() const = 0;
};
}}
#endif

HTTP 请求的关键信息是请求要发送到的 URL,由QUrl Qt 类表示。我们为该属性提供了url()访问器和setUrl()修改器。另外两个方法是检查isBusy()网络请求对象是否正在进行请求或接收响应,以及execute()或将请求发送到网络。同样,有了接口,让我们直接转向在同一文件夹中创建一个新的WebRequest类的实现。

web-request.h

#ifndef WEBREQUEST_H
#define WEBREQUEST_H
#include <QList>
#include <QObject>
#include <QSslError>
#include <networking/i-network-access-manager.h>
#include <networking/i-web-request.h>
namespace cm {
namespace networking {
class WebRequest : public QObject, public IWebRequest
{
    Q_OBJECT
public:
    WebRequest(QObject* parent, INetworkAccessManager* networkAccessManager, const QUrl& url);
    WebRequest(QObject* parent = nullptr) = delete;
    ~WebRequest();
public:
    void execute() override;
    bool isBusy() const override;
    void setUrl(const QUrl& url) override;
    QUrl url() const override;
signals:
    void error(QString message);
    void isBusyChanged();
    void requestComplete(int statusCode, QByteArray body);
    void urlChanged();
private slots:
    void replyDelegate();
    void sslErrorsDelegate( const QList<QSslError>& _errors );
private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};
}}
#endif

web-request.cpp

#include "web-request.h"

#include <QMap>
#include <QNetworkReply>
#include <QNetworkRequest>
namespace cm {
namespace networking { // Private Implementation
static const QMap<QNetworkReply::NetworkError, QString> networkErrorMapper = {
    {QNetworkReply::ConnectionRefusedError, "The remote server refused the connection (the server is not accepting requests)."},
    /* ...section shortened in print for brevity...*/
    {QNetworkReply::UnknownServerError, "An unknown error related to the server response was detected."}
};
class WebRequest::Implementation
{
public:
    Implementation(WebRequest* _webRequest, INetworkAccessManager* _networkAccessManager, const QUrl& _url)
        : webRequest(_webRequest)
        , networkAccessManager(_networkAccessManager)
        , url(_url)
    {
    }
    WebRequest* webRequest{nullptr};
    INetworkAccessManager* networkAccessManager{nullptr};
    QUrl url {};
    QNetworkReply* reply {nullptr};
public: 
    bool isBusy() const
    {
        return isBusy_;
    }
    void setIsBusy(bool value)
    {
        if (value != isBusy_) {
            isBusy_ = value;
            emit webRequest->isBusyChanged();
        }
    }
private:
    bool isBusy_{false};
};
}
namespace networking {  // Structors
WebRequest::WebRequest(QObject* parent, INetworkAccessManager* networkAccessManager, const QUrl& url)
    : QObject(parent)
    , IWebRequest()
{
    implementation.reset(new WebRequest::Implementation(this, networkAccessManager, url));
}
WebRequest::~WebRequest()
{
}
}
namespace networking { // Methods
void WebRequest::execute()
{
    if(implementation->isBusy()) {
        return;
    }

    if(!implementation->networkAccessManager->isNetworkAccessible()) {
        emit error("Network not accessible");
        return;
    }
    implementation->setIsBusy(true);
    QNetworkRequest request;
    request.setUrl(implementation->url);
    implementation->reply = implementation->networkAccessManager->get(request);
    if(implementation->reply != nullptr) {
        connect(implementation->reply, &QNetworkReply::finished, this, &WebRequest::replyDelegate);
        connect(implementation->reply, &QNetworkReply::sslErrors, this, &WebRequest::sslErrorsDelegate);
    }
}
bool WebRequest::isBusy() const
{
    return implementation->isBusy();
}
void WebRequest::setUrl(const QUrl& url)
{
    if(url != implementation->url) {
        implementation->url = url;
        emit urlChanged();
    }
}
QUrl WebRequest::url() const
{
    return implementation->url;
}
}
namespace networking { // Private Slots
void WebRequest::replyDelegate()
{
    implementation->setIsBusy(false);
    if (implementation->reply == nullptr) {
        emit error("Unexpected error - reply object is null");
        return;
    }
    disconnect(implementation->reply, &QNetworkReply::finished, this, &WebRequest::replyDelegate);
    disconnect(implementation->reply, &QNetworkReply::sslErrors, this, &WebRequest::sslErrorsDelegate);
    auto statusCode = implementation->reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
    auto responseBody = implementation->reply->readAll();
    auto replyStatus = implementation->reply->error();
    implementation->reply->deleteLater();
    if (replyStatus != QNetworkReply::NoError) {
        emit error(networkErrorMapper[implementation->reply->error()]);
    }
    emit requestComplete(statusCode, responseBody);
}
void WebRequest::sslErrorsDelegate(const QList<QSslError>& errors)
{
    QString sslError;
    for (const auto& error : errors) {
        sslError += error.errorString() + "\n";
    }
    emit error(sslError);
}
}}

实现看起来比实际复杂,纯粹是因为错误代码映射过长。在出现问题时,Qt 将使用枚举器报告错误。映射的目的只是将枚举器与人类可读的错误描述匹配,以便向用户呈现或写入控制台或日志文件。

除了接口方法之外,我们还有一些信号,可以用来告诉任何感兴趣的观察者发生了什么事件:

  • error() 将在出现问题时被发出,并将错误描述作为参数传递

  • isBusyChanged() 在请求开始或结束时被触发,并且请求变得繁忙或空闲

  • requestComplete() 在接收和处理响应后被发出,并将包含 HTTP 状态代码和表示响应主体的字节数组

  • urlChanged() 当 URL 更新时将被触发

我们还有一些私有槽,将作为处理回复和处理任何 SSL 错误的委托。当我们执行新请求时,它们连接到 QNetworkReply 对象上的信号,当我们收到回复时再次断开连接。

实现的核心是两个方法——execute() 用于发送请求和 replyDelegate() 用于处理响应。

在执行时,我们首先确保我们没有在执行另一个请求,然后与网络访问管理器检查我们是否有可用连接。假设我们有,然后设置繁忙标志并使用当前设置的 URL 构造一个 QNetworkRequest。然后将请求传递给我们的网络访问管理器(作为接口注入,因此我们可以更改其行为),最后,我们连接我们的委托槽并等待响应。

当我们收到回复时,我们取消繁忙标志并断开我们的槽,然后读取我们感兴趣的响应细节,主要是 HTTP 状态代码和响应主体。我们检查回复是否成功完成(请注意,在此上下文中,“负面”的 HTTP 响应代码在 4xx 或 5xx 范围内仍然算作成功完成的请求),并为任何感兴趣的方捕获和处理细节。

RSS View

让我们向我们的应用程序添加一个新视图,我们可以使用我们的新类显示来自 Web 服务的一些信息。

这里没有什么新的或复杂的,所以我不会展示所有的代码,但有一些步骤要记住:

  1. cm-ui/views 中创建一个新的 RssView.qml 视图,并暂时从 SplashView 复制 QML,将“Splash View”文本替换为“Rss View”

  2. /views 前缀块中的 views.qrc 中添加视图,并使用别名 RssView.qml

  3. goRssView() 信号添加到 NavigationController

  4. MasterView 中,将 onGoRssView 槽添加到 Connections 元素,并使用它导航到 RssView

  5. NavigationBar 中,添加一个新的 NavigationButton,其中 iconCharacter\uf09e,描述为 RSS FeedhoverColour#8acece,并使用 onNavigationButtonClicked 槽调用 NavigationController 上的 goRssView()

只需几个简单的步骤,我们现在已经有了一个全新的视图,可以使用导航栏访问:

接下来,我们将通过以下步骤向视图添加一个上下文命令栏:

  1. CommandController 中,添加一个新的私有成员列表 rssViewContextCommands

  2. 添加一个访问器方法 ui_rssViewContextCommands()

  3. 添加一个名为 ui_rssViewContextCommandsQ_PROPERTY

  4. 添加一个新的槽 onRssRefreshExecuted(),它只是向控制台写入调试消息;目前表示它已被调用

  5. 将一个名为 rssRefreshCommand 的新命令附加到 rssViewContextCommands,其中 0xf021 为图标字符,标签为“Refresh”,并将其连接到 onRssRefreshExecuted()

  6. RssView 中,添加一个 CommandBar 组件,其中 commandList 与命令控制器上的 ui_rssViewContextCommands 相连

前几章的辛勤工作现在真的开始见效了;我们的新视图有了自己的命令栏和一个完全功能的刷新按钮。当您单击它时,它应该将您添加到控制台的调试消息:

接下来,我们需要创建我们的NetworkAccessManagerWebRequest类的实例。像往常一样,我们将把这些添加到MasterController并向CommandController注入依赖项。

MasterController中,添加两个新的私有成员:

NetworkAccessManager* networkAccessManager{nullptr};
WebRequest* rssWebRequest{nullptr};

记得包含相关的头文件。在Implementation构造函数中实例化这些新成员,确保它们在commandController之前创建:

networkAccessManager = new NetworkAccessManager(masterController);
rssWebRequest = new WebRequest(masterController, networkAccessManager, QUrl("http://feeds.bbci.co.uk/news/rss.xml?edition=uk"));

在这里,我们使用了与英国相关的 BBC RSS 订阅的 URL;随时可以通过替换超链接文本来将其替换为您选择的其他订阅。

接下来,将rssWebRequest作为新参数传递给commandController构造函数:

commandController = new CommandController(masterController, databaseController, navigationController, newClient, clientSearch, rssWebRequest);

接下来,编辑CommandController以将此新参数作为接口的指针:

explicit CommandController(QObject* _parent = nullptr, IDatabaseController* databaseController = nullptr, NavigationController* navigationController = nullptr, models::Client* newClient = nullptr, models::ClientSearch* clientSearch = nullptr, networking::IWebRequest* rssWebRequest = nullptr);

通过Implementation构造函数将此指针传递并将其存储为私有成员变量,就像我们对所有其他依赖项所做的那样:

IWebRequest* rssWebRequest{nullptr};

现在我们可以更新onRssRefreshExecuted()槽来执行网络请求:

void CommandController::onRssRefreshExecuted()
{
    qDebug() << "You executed the Rss Refresh command!";

    implementation->rssWebRequest->execute();
}

命令控制器现在对用户按下刷新按钮做出反应并执行网络请求。但是,当我们收到响应时,我们目前并没有做任何事情。让我们在公共槽部分为MasterController添加一个委托:

void MasterController::onRssReplyReceived(int statusCode, QByteArray body)
{
    qDebug() << "Received RSS request response code " << statusCode << ":";
    qDebug() << body;
}

现在,在Implementation中实例化rssWebRequest后,我们可以将requestComplete信号连接到我们的新委托:

QObject::connect(rssWebRequest, &WebRequest::requestComplete, masterController, &MasterController::onRssReplyReceived);

现在构建并运行应用程序,导航到 RSS 视图,并单击刷新。在请求执行时稍等片刻,您将在应用程序输出控制台上看到各种无意义的打印内容:

Received RSS request response code 200 :
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<?xml-stylesheet title=...”

恭喜!您已经获得了一个 RSS 订阅!现在,这是什么?

RSS

Rich Site Summary (RSS)是一种用于定期传递变化的网络内容的格式,本质上是整个网站、新闻广播、博客或类似内容被压缩成要点。每个项目由日期和描述性标题等基本信息组成,并附有指向包含完整文章的网页的超链接。

数据是从 XML 扩展的,并且必须遵守在www.rssboard.org/rss-specification中描述的定义标准。

为了本示例的目的,将 XML 简化如下:

<rss>
    <channel>
        <title></title>
        <description></description>
        <link></link>
        <image>
            <url></url>
            <title></title>
            <link></link>
            <width></width>
            <height></height>
        </image>
        <item>
            <title></title>
            <description></description>
            <link></link>
            <pubDate></pubDate>
        </item>
        <item>
                …
          </item>
    </channel>
</rss>

在根<rss>节点内,我们有一个<channel>节点,它又包含一个<image>节点和一个或多个<item>节点的集合。

我们将这些节点建模为类,但首先我们需要引入 XML 模块并编写一个小的辅助类来为我们进行一些解析。在cm-lib.procm-ui.pro中,将xml模块添加到QT变量中的模块中;考虑以下示例:

QT += sql network xml

接下来,在新文件夹cm-lib/source/utilities中创建一个新的XmlHelper类。

xml-helper.h

#ifndef XMLHELPER_H
#define XMLHELPER_H
#include <QDomNode>
#include <QString>
namespace cm {
namespace utilities {
class XmlHelper
{
public:
    static QString toString(const QDomNode& domNode);
private:
    XmlHelper(){}
    static void appendNode(const QDomNode& domNode, QString& output);
};
}}
#endif

xml-helper.cpp

#include "xml-helper.h"

namespace cm {
namespace utilities {
QString XmlHelper::toString(const QDomNode& domNode)
{
    QString returnValue;
    for(auto i = 0; i < domNode.childNodes().size(); ++i) {
        QDomNode subNode = domNode.childNodes().at(i);
        appendNode(subNode, returnValue);
    }
    return returnValue;
}
void XmlHelper::appendNode(const QDomNode& domNode, QString& output)
{
    if(domNode.nodeType() == QDomNode::TextNode) {
        output.append(domNode.nodeValue());
        return;
    }
    if(domNode.nodeType() == QDomNode::AttributeNode) {
        output.append(" ");
        output.append(domNode.nodeName());
        output.append("=\"");
        output.append(domNode.nodeValue());
        output.append("\"");
        return;
    }
    if(domNode.nodeType() == QDomNode::ElementNode) {
        output.append("<");
        output.append(domNode.nodeName());
        // Add attributes
        for(auto i = 0; i < domNode.attributes().size(); ++i) {
            QDomNode subNode = domNode.attributes().item(i);
            appendNode(subNode, output);
        }
        output.append(">");
        for(auto i = 0; i < domNode.childNodes().size(); ++i) {
            QDomNode subNode = domNode.childNodes().at(i);
            appendNode(subNode, output);
        }
        output.append("</" + domNode.nodeName() + ">");
    }
}
}}

我不会详细介绍这个类的功能,因为它不是本章的重点,但基本上,如果我们收到包含 HTML 标记的 XML 节点(这在 RSS 中很常见),XML 解析器会有点困惑并将 HTML 分解为 XML 节点,这不是我们想要的。考虑以下示例:

<xmlNode>
    Here is something from a website that has a <a href=”http://www.bbc.co.uk”>hyperlink</a> in it.
</xmlNode>

在这种情况下,XML 解析器将把<a>视为 XML,并将内容分解为三个类似于这样的子节点:

<xmlNode>
    <textNode1>Here is something from a website that has a </textNode1>
    <a href=”http://www.bbc.co.uk”>hyperlink</a>
    <textNode2>in it.</textNode2>
</xmlNode>

这使得在 UI 上向用户显示 xmlNode 的内容变得困难。相反,我们使用 XmlHelper 手动解析内容并构造一个单个字符串,这样更容易处理。

现在,让我们继续处理 RSS 类。在新的cm-lib/source/rss文件夹中,创建新的RssChannelRssImageRssItem类。

rss-image.h

#ifndef RSSIMAGE_H
#define RSSIMAGE_H
#include <QObject>
#include <QScopedPointer>
#include <QtXml/QDomNode>
#include <cm-lib_global.h>
namespace cm {
namespace rss {
class CMLIBSHARED_EXPORT RssImage : public QObject
{
    Q_OBJECT
    Q_PROPERTY(quint16 ui_height READ height CONSTANT)
    Q_PROPERTY(QString ui_link READ link CONSTANT)
    Q_PROPERTY(QString ui_title READ title CONSTANT)
    Q_PROPERTY(QString ui_url READ url CONSTANT)
    Q_PROPERTY(quint16 ui_width READ width CONSTANT)
public:
    explicit RssImage(QObject* parent = nullptr, const QDomNode& domNode = QDomNode());
    ~RssImage();
    quint16 height() const;
    const QString& link() const;
    const QString& title() const;
    const QString& url() const;
    quint16 width() const;
private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};
}}

#endif

rss-image.cpp

#include "rss-image.h"

namespace cm {
namespace rss {
class RssImage::Implementation
{
public:
    QString url;    // Mandatory. URL of GIF, JPEG or PNG that represents the channel.
    QString title;  // Mandatory.  Describes the image.
    QString link;   // Mandatory.  URL of the site.
    quint16 width;  // Optional.  Width in pixels.  Max 144, default 
                                                                    88.
    quint16 height; // Optional.  Height in pixels.  Max 400, default 
                                                                    31
    void update(const QDomNode& domNode)
    {
        QDomElement imageUrl = domNode.firstChildElement("url");
        if(!imageUrl.isNull()) {
            url = imageUrl.text();
        }
        QDomElement imageTitle = domNode.firstChildElement("title");
        if(!imageTitle.isNull()) {
            title = imageTitle.text();
        }
        QDomElement imageLink = domNode.firstChildElement("link");
        if(!imageLink.isNull()) {
            link = imageLink.text();
        }
        QDomElement imageWidth = domNode.firstChildElement("width");
        if(!imageWidth.isNull()) {
            width = static_cast<quint16>(imageWidth.text().toShort());
        } else {
            width = 88;
        }
        QDomElement imageHeight = domNode.firstChildElement("height");
        if(!imageHeight.isNull()) {
            height = static_cast<quint16>
                                  (imageHeight.text().toShort());
        } else {
            height = 31;
        }
    }
};
RssImage::RssImage(QObject* parent, const QDomNode& domNode)
    : QObject(parent)
{
    implementation.reset(new Implementation());
    implementation->update(domNode);
}
RssImage::~RssImage()
{
}
quint16 RssImage::height() const
{
    return implementation->height;
}
const QString& RssImage::link() const
{
    return implementation->link;
}
const QString& RssImage::title() const
{
    return implementation->title;
}
const QString& RssImage::url() const
{
    return implementation->url;
}
quint16 RssImage::width() const
{
    return implementation->width;
}
}}

这个类只是一个普通的纯数据模型,唯一的例外是它将从 Qt 的QDomNode类表示的 XML <image>节点构造而成。我们使用firstChildElement()方法来定位<url><title><link>强制的子节点,然后通过text()方法访问每个节点的值。<width><height>节点是可选的,如果它们不存在,我们将使用默认的图像尺寸 88 x 31 像素。

rss-item.h

#ifndef RSSITEM_H
#define RSSITEM_H
#include <QDateTime>
#include <QObject>
#include <QscopedPointer>
#include <QtXml/QDomNode>
#include <cm-lib_global.h>
namespace cm {
namespace rss {
class CMLIBSHARED_EXPORT RssItem : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString ui_description READ description CONSTANT)
    Q_PROPERTY(QString ui_link READ link CONSTANT)
    Q_PROPERTY(QDateTime ui_pubDate READ pubDate CONSTANT)
    Q_PROPERTY(QString ui_title READ title CONSTANT)
public:
    RssItem(QObject* parent = nullptr, const QDomNode& domNode = QDomNode());
    ~RssItem();
    const QString& description() const;
    const QString& link() const;
    const QDateTime& pubDate() const;
    const QString& title() const;
private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};
}}
#endif

rss-item.cpp

#include "rss-item.h"
#include <QTextStream>
#include <utilities/xml-helper.h>
using namespace cm::utilities;
namespace cm {
namespace rss {
class RssItem::Implementation
{
public:
    Implementation(RssItem* _rssItem)
        : rssItem(_rssItem)
    {
    }
    RssItem* rssItem{nullptr};
    QString description;    // This or Title mandatory.  Either the 
                            synopsis or full story.  HTML is allowed.
    QString link;           // Optional. Link to full story.  Populated 
                                  if Description is only the synopsis.
    QDateTime pubDate;      // Optional. When the item was published. 
                     RFC 822 format e.g. Sun, 19 May 2002 15:21:36 GMT.
    QString title;          // This or Description mandatory.
    void update(const QDomNode& domNode)
    {
        for(auto i = 0; i < domNode.childNodes().size(); ++i) {
            QDomNode childNode = domNode.childNodes().at(i);
            if(childNode.nodeName() == "description") {
                description = XmlHelper::toString(childNode);
            }
        }
        QDomElement itemLink = domNode.firstChildElement("link");
        if(!itemLink.isNull()) {
            link = itemLink.text();
        }
        QDomElement itemPubDate = domNode.firstChildElement("pubDate");
        if(!itemPubDate.isNull()) {
            pubDate = QDateTime::fromString(itemPubDate.text(), 
                                                     Qt::RFC2822Date);
        }
        QDomElement itemTitle = domNode.firstChildElement("title");
        if(!itemTitle.isNull()) {
            title = itemTitle.text();
        }
    }
};
RssItem::RssItem(QObject* parent, const QDomNode& domNode)
{
    implementation.reset(new Implementation(this));
    implementation->update(domNode);
}
RssItem::~RssItem()
{
}
const QString& RssItem::description() const
{
    return implementation->description;
}
const QString& RssItem::link() const
{
    return implementation->link;
}
const QDateTime& RssItem::pubDate() const
{
    return implementation->pubDate;
}
const QString& RssItem::title() const
{
    return implementation->title;
}
}}

这个类与上一个类基本相同。这次我们在解析<description>节点时使用我们的 XMLHelper 类,因为它很有可能包含 HTML 标记。还要注意,Qt 还包含了Qt::RFC2822Date格式说明符,当使用静态的QDateTime::fromString()方法将字符串转换为QDateTime对象时,这是 RSS 规范中使用的格式,可以节省我们手动解析日期的工作。

rss-channel.h

#ifndef RSSCHANNEL_H
#define RSSCHANNEL_H
#include <QDateTime>
#include <QtXml/QDomElement>
#include <QtXml/QDomNode>
#include <QList>
#include <QObject>
#include <QtQml/QQmlListProperty>
#include <QString>
#include <cm-lib_global.h>
#include <rss/rss-image.h>
#include <rss/rss-item.h>
namespace cm {
namespace rss {
class CMLIBSHARED_EXPORT RssChannel : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QString ui_description READ description CONSTANT)
    Q_PROPERTY(cm::rss::RssImage* ui_image READ image CONSTANT)
    Q_PROPERTY(QQmlListProperty<cm::rss::RssItem> ui_items READ 
                                                ui_items CONSTANT)
    Q_PROPERTY(QString ui_link READ link CONSTANT)
    Q_PROPERTY(QString ui_title READ title CONSTANT)
public:
    RssChannel(QObject* parent = nullptr, const QDomNode& domNode = QDomNode());
    ~RssChannel();
    void addItem(RssItem* item);
    const QString& description() const;
    RssImage* image() const;
    const QList<RssItem*>& items() const;
    const QString& link() const;
    void setImage(RssImage* image);
    const QString& title() const;
    QQmlListProperty<RssItem> ui_items();
    static RssChannel* fromXml(const QByteArray& xmlData, QObject* 
                                            parent = nullptr);
private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};
}}
#endif

rss-channel.cpp

#include "rss-channel.h"
#include <QtXml/QDomDocument>
namespace cm {
namespace rss {
class RssChannel::Implementation
{
public:
    QString description;            // Mandatory.  Phrase or sentence describing the channel.
    RssImage* image{nullptr};       // Optional.  Image representing the channel.
    QList<RssItem*> items;          // Optional.  Collection representing stories.
    QString link;                   // Mandatory.  URL to the corresponding HTML website.
    QString title;                  // Mandatory.  THe name of the Channel.
    void update(const QDomNode& domNode)
    {
        QDomElement channelDescription = domNode.firstChildElement("description");
        if(!channelDescription.isNull()) {
            description = channelDescription.text();
        }
        QDomElement channelLink = domNode.firstChildElement("link");
        if(!channelLink.isNull()) {
            link = channelLink.text();
        }
        QDomElement channelTitle = domNode.firstChildElement("title");
        if(!channelTitle.isNull()) {
            title = channelTitle.text();
        }
    }
};
RssChannel::RssChannel(QObject* parent, const QDomNode& domNode)
    : QObject(parent)
{
    implementation.reset(new Implementation());
    implementation->update(domNode);
}
RssChannel::~RssChannel()
{
}
void RssChannel::addItem(RssItem* item)
{
    if(!implementation->items.contains(item)) {
        item->setParent(this);
        implementation->items.push_back(item);
    }
}
const QString&  RssChannel::description() const
{
    return implementation->description;
}
RssImage* RssChannel::image() const
{
    return implementation->image;
}
const QList<RssItem*>&  RssChannel::items() const
{
    return implementation->items;
}
const QString&  RssChannel::link() const
{
    return implementation->link;
}
void RssChannel::setImage(RssImage* image)
{
    if(implementation->image) {
        implementation->image->deleteLater();
        implementation->image = nullptr;
    }
    image->setParent(this);
    implementation->image = image;
}
const QString& RssChannel::title() const
{
    return implementation->title;
}
QQmlListProperty<RssItem> RssChannel::ui_items()
{
    return QQmlListProperty<RssItem>(this, implementation->items);
}
RssChannel* RssChannel::fromXml(const QByteArray& xmlData, QObject* parent)
{
    QDomDocument doc;
    doc.setContent(xmlData);
    auto channelNodes = doc.elementsByTagName("channel");
    // Rss must have 1 channel
    if(channelNodes.size() != 1) return nullptr;
    RssChannel* channel = new RssChannel(parent, channelNodes.at(0));
    auto imageNodes = doc.elementsByTagName("image");
    if(imageNodes.size() > 0) {
        channel->setImage(new RssImage(channel, imageNodes.at(0)));
    }
    auto itemNodes = doc.elementsByTagName("item");
    for (auto i = 0; i < itemNodes.size(); ++i) {
        channel->addItem(new RssItem(channel, itemNodes.item(i)));
    }
    return channel;
}
}}

这个类与之前的类基本相同,但因为这是我们 XML 树的根对象,所以我们还有一个静态的fromXml()方法。这里的目标是获取包含 RSS feed XML 的 RSS 网络请求响应的字节数组,并让该方法为我们创建一个 RSS Channel、Image 和 Items 层次结构。

我们将 XML 字节数组传递给 Qt 的QDomDocument类,就像我们之前使用 JSON 和QJsonDocument类一样。我们使用elementsByTagName()方法找到<channel>标签,然后使用该标签作为构造函数的QDomNode参数构造一个新的RssChannel对象。RssChannel通过update()方法填充自己的属性。然后我们定位<image><item>子节点,并创建新的RssImageRssItem实例,将它们添加到根RssChannel对象中。同样,这些类能够从提供的QDomNode中填充自己的属性。

在我们忘记之前,让我们也在main()中注册这些类:

qmlRegisterType<cm::rss::RssChannel>("CM", 1, 0, "RssChannel");
qmlRegisterType<cm::rss::RssImage>("CM", 1, 0, "RssImage");
qmlRegisterType<cm::rss::RssItem>("CM", 1, 0, "RssItem");

我们现在可以在MasterController中添加一个RssChannel,供 UI 绑定:

  1. MasterController中,添加一个新的rssChannel私有成员变量,类型为RssChannel*

  2. 添加一个rssChannel()访问器方法

  3. 添加一个rssChannelChanged()信号

  4. 添加一个名为ui_rssChannelQ_PROPERTY,使用READ访问器和NOTIFY信号

当我们没有任何 RSS 数据来提供时,我们不会创建一个构造,而是在 RSS 回复委托中执行:

void MasterController::onRssReplyReceived(int statusCode, QByteArray body)
{
    qDebug() << "Received RSS request response code " << statusCode << ":";
    qDebug() << body;
    if(implementation->rssChannel) {
        implementation->rssChannel->deleteLater();
        implementation->rssChannel = nullptr;
        emit rssChannelChanged();
    }
    implementation->rssChannel = RssChannel::fromXml(body, this);
    emit rssChannelChanged();
}

我们进行一些清理工作,检查我们是否已经在内存中有一个旧的频道对象,如果有,我们使用QObjectdeleteLater()方法安全地删除它。然后我们继续使用来自网络请求的 XML 数据构造一个新的频道。

始终在QObject派生类上使用deleteLater(),而不是标准的 C++ delete关键字,因为销毁将与事件循环同步,您将最小化意外异常的风险。

我们将以与管理搜索结果类似的方式显示响应中的 RSS 项目,使用ListView和相关的委托。将RssItemDelegate.qml添加到cm-ui/components,并执行编辑components.qrcqmldir文件的常规步骤:

import QtQuick 2.9
import assets 1.0
import CM 1.0
Item {
    property RssItem rssItem
    implicitWidth: parent.width
    implicitHeight: background.height
    Rectangle {
        id: background
        width: parent.width
        height: textPubDate.implicitHeight + textTitle.implicitHeight + 
                       borderBottom.height + (Style.sizeItemMargin * 3)
        color: Style.colourPanelBackground
        Text {
            id: textPubDate
            anchors {
                top: parent.top
                left: parent.left
                right: parent.right
                margins: Style.sizeItemMargin
            }
            text: Qt.formatDateTime(rssItem.ui_pubDate, "ddd, d MMM 
                                                    yyyy @ h:mm ap")
            font {
                pixelSize: Style.pixelSizeDataControls
                italic: true
                weight: Font.Light
            }
            color: Style.colorItemDateFont
        }
        Text {
            id: textTitle
            anchors {
                top: textPubDate.bottom
                left: parent.left
                right: parent.right
                margins: Style.sizeItemMargin
            }
            text: rssItem.ui_title
            font {
                pixelSize: Style.pixelSizeDataControls
            }
            color: Style.colorItemTitleFont
            wrapMode: Text.Wrap
        }
        Rectangle {
            id: borderBottom
            anchors {
                top: textTitle.bottom
                left: parent.left
                right: parent.right
                topMargin: Style.sizeItemMargin
            }
            height: 1
            color: Style.colorItemBorder
        }
        MouseArea {
            anchors.fill: parent
            cursorShape: Qt.PointingHandCursor
            hoverEnabled: true
            onEntered: background.state = "hover"
            onExited: background.state = ""
            onClicked: if(rssItem.ui_link !== "") {
                           Qt.openUrlExternally(rssItem.ui_link);
                       }
        }
        states: [
            State {
                name: "hover"
                PropertyChanges {
                    target: background
                    color: Style.colourPanelBackgroundHover
                }
            }
        ]
    }
}

为了支持这个组件,我们需要添加一些额外的样式属性:

readonly property color colourItemBackground: "#fefefe"
readonly property color colourItemBackgroundHover: "#efefef"
readonly property color colorItemBorder: "#efefef"
readonly property color colorItemDateFont: "#636363"
readonly property color colorItemTitleFont: "#131313"
readonly property real sizeItemMargin: 5

我们现在可以在RssView中利用这个委托:

import QtQuick 2.9
import assets 1.0
import components 1.0
Item {
    Rectangle {
        anchors.fill: parent
        color: Style.colourBackground
    }
    ListView {
        id: itemsView
        anchors {
            top: parent.top
            left: parent.left
            right: parent.right
            bottom: commandBar.top
            margins: Style.sizeHeaderMargin
        }
        clip: true
        model: masterController.ui_rssChannel ? masterController.ui_rssChannel.ui_items : 0
        delegate: RssItemDelegate {
            rssItem: modelData
        }
    }
    CommandBar {
        id: commandBar
        commandList: masterController.ui_commandController.ui_rssViewContextCommands
    }
}

构建并运行,导航到 RSS 视图,并单击刷新按钮以进行网络请求并显示响应:

将鼠标悬停在项目上以查看光标效果,并单击项目以在默认的网络浏览器中打开它。Qt 在Qt.openUrlExternally()方法中为我们处理此操作,我们将 RSS 项目link属性传递给它。

摘要

在本章中,我们将我们的触角延伸到应用程序之外,并开始使用互联网上的 HTTP 请求与外部 API 进行交互。我们使用自己的接口对 Qt 功能进行了抽象,以改善解耦并使我们的组件更易于测试。我们快速了解了 RSS 及其结构,以及如何使用 Qt 的 XML 模块处理 XML 节点树。最后,我们加强了我们一直在做的出色的 UI 工作,并添加了一个交互式视图来显示 RSS 订阅,并启动默认的 Web 浏览器以显示给定的 URL。

在第九章 总结 中,我们将看看将我们的应用程序打包部署到其他计算机所需的步骤。

第九章:总结

在本章中,我们将总结一些在之前章节中没有完全涉及的主题。我们将通过将对象创建移动到对象工厂来使我们的应用程序更具可测试性。我们将通过添加缩放功能使我们的 UI 更加动态。EnumeratorDecorator属性将拥有自己的 UI 组件,并且当我们添加联系人管理时,我们将利用它们。最后,我们将通过打包和部署我们的应用程序来总结一切。我们将涵盖以下主题:

  • 对象工厂

  • 动态 UI 缩放

  • 将图像添加到仪表板

  • 枚举选择器

  • 管理联系人

  • 部署和安装我们的应用程序

对象工厂

在一个更大的系统中,如果MasterController测试更全面,那么将所有这些对象创建硬编码在私有实现中将会导致问题,因为MasterController与其依赖之间的紧密耦合。一个选择是在main()中创建所有其他对象,并将它们注入到MasterController构造函数中,就像我们对其他控制器所做的那样。这将意味着注入大量的构造函数参数,而且能够将MasterController实例作为所有其他对象的父对象是很方便的,因此我们将注入一个单一的对象工厂,控制器可以使用它来满足其所有对象创建需求。

这个工厂模式的关键部分是将所有内容隐藏在接口后面,因此在测试MasterController时,您可以传入一个模拟工厂并控制所有对象的创建。在cm-lib中,在source/framework中创建一个新的i-object-factory.h头文件:

#ifndef IOBJECTFACTORY_H
#define IOBJECTFACTORY_H

#include <controllers/i-command-controller.h>
#include <controllers/i-database-controller.h>
#include <controllers/i-navigation-controller.h>
#include <models/client.h>
#include <models/client-search.h>
#include <networking/i-network-access-manager.h>
#include <networking/i-web-request.h>

namespace cm {
namespace framework {

class IObjectFactory
{
public:
    virtual ~IObjectFactory(){}

    virtual models::Client* createClient(QObject* parent) const = 0;
    virtual models::ClientSearch* createClientSearch(QObject* parent, controllers::IDatabaseController* databaseController) const = 0;
    virtual controllers::ICommandController* createCommandController(QObject* parent, controllers::IDatabaseController* databaseController, controllers::INavigationController* navigationController, models::Client* newClient, models::ClientSearch* clientSearch, networking::IWebRequest* rssWebRequest) const = 0;
    virtual controllers::IDatabaseController* createDatabaseController(QObject* parent) const = 0;
    virtual controllers::INavigationController* createNavigationController(QObject* parent) const = 0;
    virtual networking::INetworkAccessManager* createNetworkAccessManager(QObject* parent) const = 0;
    virtual networking::IWebRequest* createWebRequest(QObject* parent, networking::INetworkAccessManager* networkAccessManager, const QUrl& url) const = 0;
};

}}

#endif

除了模型之外,我们将创建的所有对象都将被移动到接口后面。这是因为它们本质上只是数据容器,我们可以在测试场景中轻松创建真实实例,而不会产生任何副作用。

出于简洁起见,我们将跳过这个练习,并将其留给读者自己练习。使用IDatabaseController作为示例或参考代码示例。

有了工厂接口可用,将MasterController构造函数更改为接受一个实例作为依赖项:

MasterController::MasterController(QObject* parent, IObjectFactory* objectFactory)
    : QObject(parent)
{
    implementation.reset(new Implementation(this, objectFactory));
}

我们将对象传递给Implementation并将其存储在一个私有成员变量中,就像以前做过的那样。有了工厂可用,我们现在可以将所有基于new的对象创建语句移动到IObjectFactory接口的具体实现(ObjectFactory类)中,并用更抽象和可测试的内容替换MasterController中的这些语句:

Implementation(MasterController* _masterController, IObjectFactory* _objectFactory)
    : masterController(_masterController)
    , objectFactory(_objectFactory)
{
    databaseController = objectFactory->createDatabaseController(masterController);
    clientSearch = objectFactory->createClientSearch(masterController, databaseController);
    navigationController = objectFactory->createNavigationController(masterController);
    networkAccessManager = objectFactory->createNetworkAccessManager(masterController);
    rssWebRequest = objectFactory->createWebRequest(masterController, networkAccessManager, QUrl("http://feeds.bbci.co.uk/news/rss.xml?edition=uk"));
    QObject::connect(rssWebRequest, &IWebRequest::requestComplete, masterController, &MasterController::onRssReplyReceived);
    newClient = objectFactory->createClient(masterController);
    commandController = objectFactory->createCommandController(masterController, databaseController, navigationController, newClient, clientSearch, rssWebRequest);
}

现在,在测试MasterController时,我们可以传入IObjectFactory接口的模拟实现,并控制对象的创建。除了实现ObjectFactory并在实例化MasterController时将其传递给它之外,另一个变化是在main.cpp中,我们现在需要注册接口到NavigationControllerCommandController,而不是具体的实现。我们可以通过简单地用qmlRegisterUncreatableType的伴随语句替换qmlRegisterType语句来实现这一点:

qmlRegisterUncreatableType<cm::controllers::INavigationController>("CM", 1, 0, "INavigationController", "Interface");
qmlRegisterUncreatableType<cm::controllers::ICommandController>("CM", 1, 0, "ICommandController", "Interface");

UI 缩放

在本书中,我们非常关注响应式 UI,尽可能使用锚点和相对定位,以便当用户调整窗口大小时,内容可以按比例缩放和适当调整。我们还将所有“硬编码”的属性,如大小和颜色,都放入了一个集中的样式对象中。

如果我们选择一个与大小有关的属性,例如sizeScreenMargin,它目前具有固定值20。如果我们决定增加MasterViewWindow元素的起始大小,这个屏幕边距大小将保持不变。现在,由于样式对象,增加屏幕边距也非常容易,但如果所有硬编码的属性都可以随着Window元素的动态缩放而动态地放大和缩小,那将是很好的。这样,我们可以尝试不同的窗口大小,而无需每次更新样式。

正如我们已经看到的,QML 的灵活性通过内置的 JavaScript 支持得到了进一步扩展,我们可以做到这一点。

首先,让我们在 Style 中为窗口创建新的宽度和高度属性:

readonly property real widthWindow: 1920
readonly property real heightWindow: 1080

MasterView中使用这些新属性:

Window {
    width: Style.widthWindow
    height: Style.heightWindow
    ….
}

到目前为止,在 Style 中创建的所有尺寸属性都与 1920 x 1080 的窗口尺寸相关,因此让我们将其记录为 Style 中的新属性:

readonly property real widthWindowReference: 1920
readonly property real heightWindowReference: 1080

然后,我们可以使用参考尺寸和实际尺寸来计算水平和垂直轴上的缩放因子。因此,简单来说,如果我们设计时考虑到窗口宽度为 1,000,然后我们将窗口设置为 2,000 宽,我们希望一切在水平方向上按 2 的比例缩放。在 Style 中添加以下函数:

function hscale(size) {
    return Math.round(size * (widthWindow / widthWindowReference))
}
function vscale(size) {
    return Math.round(size * (heightWindow / heightWindowReference))
}
function tscale(size) {
    return Math.round((hscale(size) + vscale(size)) / 2)
}

hscalevscale函数分别计算水平和垂直缩放因子。对于像字体像素大小这样的特定尺寸属性,没有独立的宽度和高度,因此我们可以使用tscale函数计算水平和垂直缩放的平均值。

然后,我们可以将我们想要缩放的任何属性包装在适当的函数中。例如,我们的屏幕边距可以使用tscale函数:

readonly property real sizeScreenMargin: tscale(20)

现在,不仅可以在 Style 中增加窗口的初始大小,而且所选的属性将自动按比例缩放到新的大小。

一个非常有用的模块,可以帮助调整大小是QtQuick.Window。我们已经将其添加到MasterView中,以便访问 Window 元素。该模块中还有另一个对象 Screen,它提供有关用户显示的信息。它包含诸如屏幕宽度和高度、方向和像素密度等属性,如果您正在使用高 DPI 显示器(如 Microsoft Surface 或 Macbook),这些属性可能会很有用。您可以将这些值与您的样式属性结合使用,以执行诸如使窗口全屏或使其占据屏幕大小的 50%并将其定位在显示器中心等操作。

仪表板

仪表板或“主页”是欢迎用户并展示当前状态的好地方。每日消息、事实和数据、性能图表,或者简单地一些公司品牌都可以帮助定位和聚焦用户。让我们稍微改进一下我们的仪表板视图,并演示如何显示图像。

选择一张宽高比为 1:1 的图片,这意味着宽度和高度相同。不一定要是正方形,只是为了简化本例中的缩放管理。我选择了Packt的标志,尺寸为 500 x 500 像素,保存为packt-logo-500x500.jpg。保存到cm/cm-ui/assets并将其添加到我们的assets.qrc资源中:

<file alias="packt-logo-500x500">assets/packt-logo-500x500.jpg</file>

添加一些新的 Style 属性,利用我们的新的缩放能力:

readonly property color colourDashboardBackground: "#f36f24"
readonly property color colourDashboardFont: "#ffffff"
readonly property int pixelSizeDashboard: tscale(36)
readonly property real sizeDashboardLogo: tscale(500)

然后,我们可以将我们的图像添加到DashboardView中:

Item {
    Rectangle {
        anchors.fill: parent
        color: Style.colourDashboardBackground
        Image {
            id: logo
            source: "qrc:/assets/packt-logo-500x500"
            anchors.centerIn: parent
            width: Style.sizeDashboardLogo
            height: Style.sizeDashboardLogo
        }
        Text {
            anchors {
                top: logo.bottom
                horizontalCenter: logo.horizontalCenter
            }
            text: "Client Management System"
            color: Style.colourDashboardFont
            font.pixelSize: Style.pixelSizeDashboard
        }
    }
}

现在,当我们转到仪表板时,可以看到更有刺激性的东西:

枚举选择器

回到第五章,数据,我们创建了一个联系人模型,其中实现了一个带有EnumeratorDecoratorContactType属性。对于本书中使用的其他基于字符串的属性,简单的文本框是捕获数据的良好解决方案,但是如何捕获枚举值呢?用户不应该知道枚举器的基础整数值,并要求他们输入所需选项的字符串表示形式是在寻找麻烦。我们真正想要的是一个下拉列表,以某种方式利用我们添加到类中的contactTypeMapper容器。我们希望向用户呈现字符串描述供其选择,然后将整数值存储在EnumeratorDecorator对象中。

桌面应用程序通常以特定方式呈现下拉列表,其中有一种选择器,您按下它然后弹出(或更准确地说,下拉!)一个可滚动的选项列表供选择。然而,QML 不仅面向跨平台,而且面向跨设备应用程序。许多笔记本电脑具有触摸屏功能,而且市场上出现了越来越多的混合设备,它们既可以作为笔记本电脑,也可以作为平板电脑。因此,即使我们不打算为移动商店构建下一个大型应用程序,考虑我们的应用程序在“手指友好”方面是很重要的,经典的下拉列表在触摸屏上可能很难使用。让我们改用移动设备上使用的基于按钮的方法。

不幸的是,我们无法直接在 QML 中使用现有的std::map,因此我们需要添加一些新的类来为我们构建桥梁。我们将把每个键/值对表示为DropDownValue,并在DropDown对象中保存这些对象的集合。DropDown对象应该在其构造函数中接受一个std::map<int, QString>,并为我们创建DropDownValue集合。

首先在cm-lib/source/data中创建DropDownValue类。

dropdown-value.h

#ifndef DROPDOWNVALUE_H
#define DROPDOWNVALUE_H
#include <QObject>
#include <cm-lib_global.h>
namespace cm {
namespace data {
class CMLIBSHARED_EXPORT DropDownValue : public QObject
{
    Q_OBJECT
    Q_PROPERTY(int ui_key MEMBER key CONSTANT )
    Q_PROPERTY(QString ui_description MEMBER description CONSTANT)
public:
    DropDownValue(QObject* parent = nullptr, int key = 0, const QString& description = "");
    ~DropDownValue();
public:
    int key{0};
    QString description{""};
};
}}
#endif

dropdown-value.cpp

#include "dropdown-value.h"
namespace cm {
namespace data {
DropDownValue::DropDownValue(QObject* parent, int _key, const QString& _description)
        : QObject(parent)
{
    key = _key;
    description = _description;
}
DropDownValue::~DropDownValue()
{
}
}}

这里没有什么复杂的东西,只是一个整数值和相关字符串描述的 QML 友好封装。

接下来,在cm-lib/source/data中再次创建DropDown类。

dropdown.h

#ifndef DROPDOWN_H
#define DROPDOWN_H
#include <QObject>
#include <QtQml/QQmlListProperty>
#include <cm-lib_global.h>
#include <data/dropdown-value.h>
namespace cm {
namespace data {
class CMLIBSHARED_EXPORT DropDown : public QObject
{
    Q_OBJECT
    Q_PROPERTY(QQmlListProperty<cm::data::DropDownValue> ui_values READ ui_values CONSTANT)
public:
    explicit DropDown(QObject* _parent = nullptr, const std::map<int, QString>& values = std::map<int, QString>());
    ~DropDown();
public:
    QQmlListProperty<DropDownValue> ui_values();
public slots:
    QString findDescriptionForDropdownValue(int valueKey) const;
private:
    class Implementation;
    QScopedPointer<Implementation> implementation;
};
}}
#endif

dropdown.cpp

#include "dropdown.h"

namespace cm {
namespace data {
class DropDown::Implementation
{
public:
    Implementation(DropDown* _dropdown, const std::map<int, QString>& _values)
        : dropdown(_dropdown)
    {
        for(auto pair : _values) {
             values.append(new DropDownValue(_dropdown, pair.first, pair.second));
        }
    }
    DropDown* dropdown{nullptr};
    QList<DropDownValue*> values;
};
DropDown::DropDown(QObject* parent, const std::map<int, QString>& values)
   : QObject(parent)
{
    implementation.reset(new DropDown::Implementation(this, values));
}
DropDown::~DropDown()
{
}
QString DropDown::findDescriptionForDropdownValue(int valueKey) const
{
    for (auto value : implementation->values) {
        if (value->key == valueKey) {
            if(!value->description.isEmpty()) {
                return value->description;
            }
            break;
        }
    }
    return "Select >";
}
QQmlListProperty<DropDownValue> DropDown::ui_values()
{
    return QQmlListProperty<DropDownValue>(this, implementation->values);
}
}}

如讨论的,我们实现一个构造函数,它接受我们在EnumeratorDecorator类中使用的相同类型的std::map,并基于它创建一个DropDownValue对象集合。然后 UI 可以通过ui_values属性访问该集合。我们为 UI 提供的另一个功能是通过findDescriptionForDropdownValue公共槽,这允许 UI 从EnumeratorDecorator中获取所选整数值并获取相应的文本描述。如果没有当前选择(即,描述是空字符串),那么我们将返回Select >,以提示用户他们需要进行选择。

由于我们将在 QML 中使用这些新类型,因此需要在main.cpp中注册它们:

qmlRegisterType<cm::data::DropDown>("CM", 1, 0, "DropDown");
qmlRegisterType<cm::data::DropDownValue>("CM", 1, 0, "DropDownValue");

在 Contact 中添加一个新的DropDown属性,名为ui_contactTypeDropDown,并在构造函数中使用contactTypeMapper实例化成员变量。现在,每当在 UI 中呈现联系人时,相关的DropDown将可用。如果您想要在整个应用程序中重用下拉列表,这可以很容易地放入一个专用组件,比如下拉管理器,但是对于这个例子,让我们避免额外的复杂性。

我们还需要能够从 UI 中添加一个新的联系人对象,因此在Client中添加一个新的公共槽:

void Client::addContact()
{
    contacts->addEntity(new Contact(this));
    emit contactsChanged();
}

完成 C++后,我们可以继续进行 UI 实现。

我们需要一些组件来进行下拉选择。当呈现EnumeratorDecorator属性时,我们希望显示当前选定的值,就像我们在字符串编辑器中所做的那样。在视觉上,它将类似于一个按钮,其关联的字符串描述作为其标签,当按下时,用户将转换到第二个组件,这实质上是一个视图。这个子视图将占据整个内容框架,并呈现所有可用的枚举选项的列表,再次表示为按钮。当用户通过按下其中一个按钮进行选择时,他们将转换回原始视图,并且他们的选择将在原始组件中更新。

首先,我们将创建用户将要转换到的视图,其中将列出所有可用选项。为了支持这一点,我们需要在 Style 中添加一些额外的属性:

readonly property color colourDataSelectorBackground: "#131313"
readonly property color colourDataControlsBackgroundSelected: "#f36f24"
readonly property color colourDataSelectorFont: "#ffffff"
readonly property int sizeDataControlsRadius: tscale(5)

cm-ui/components中创建EnumeratorSelectorView.qml

import QtQuick 2.9
import QtQuick.Controls 2.2
import CM 1.0
import assets 1.0
Item {
    id: stringSelectorView
    property DropDown dropDown
    property EnumeratorDecorator enumeratorDecorator
    property int selectedValue
    ScrollView {
        id: scrollView
        visible: true
        anchors.fill: parent
        anchors {
            top: parent.bottom
             left: parent.left
             right: parent.right
             bottom: parent.top
             margins: Style.sizeScreenMargin
        }
        Flow {
            flow: Grid.TopToBottom
            spacing: Style.sizeControlSpacing
            height: scrollView.height
            Repeater {
                id: repeaterAnswers
                model: dropDown.ui_values
                delegate:
                    Rectangle {
                        property bool isSelected: modelData.ui_key.ui_value === enumeratorDecorator.ui_value
                        width: Style.widthDataControls
                        height: Style.heightDataControls
                        radius: Style.sizeDataControlsRadius
                        color: isSelected ? Style.colourDataControlsBackgroundSelected : Style.colourDataSelectorBackground
                        Text {
                            anchors {
                                fill: parent
                                margins: Style.heightDataControls / 4
                            }
                            text: modelData.ui_description
                            color: Style.colourDataSelectorFont
                            font.pixelSize: Style.pixelSizeDataControls
                            verticalAlignment: Qt.AlignVCenter
                        }
                        MouseArea {
                            anchors.fill: parent
                            onClicked: {
                                selectedValue = modelData.ui_key;
                                contentFrame.pop();
                            }
                        }
                    }
            }
        }
    }
    Binding {
        target: enumeratorDecorator
        property: "ui_value"
        value: selectedValue
    }
}

在这里,我们第一次使用Repeater元素。Repeater 为其模型属性中找到的每个项目实例化其代理属性中定义的 QML 元素。我们将其模型设置为DropDownValue对象的集合,并创建内联代理。代理本质上是另一个带有一些选择代码的按钮。我们可以创建一个新的自定义组件,并将其用于代理,以保持代码更清晰,但出于简洁起见,我们将在这里跳过。此组件的关键部分是Binding元素,它为我们提供了与提供的EnumeratorDecorator的双向绑定,以及MouseArea中的onClicked事件代理,它执行更新并将此组件弹出堆栈,将我们返回到我们来自的任何视图。

cm-ui/components中创建一个新的EnumeratorSelector.qml

import QtQuick 2.9
import QtQuick.Controls 2.2
import CM 1.0
import assets 1.0
Item {
    property DropDown dropDown
    property EnumeratorDecorator enumeratorDecorator
    id: enumeratorSelectorRoot
    height: width > textLabel.width + textAnswer.width ? 
    Style.heightDataControls : Style.heightDataControls * 2
    Flow {
        anchors.fill: parent
        Rectangle {
            width: Style.widthDataControls
            height: Style.heightDataControls
            Text {
                id: textLabel
                anchors {
                    fill: parent
                    margins: Style.heightDataControls / 4
                }
                text: enumeratorDecorator.ui_label
                color: Style.colourDataControlsFont
                font.pixelSize: Style.pixelSizeDataControls
                verticalAlignment: Qt.AlignVCenter
            }
        }
        Rectangle {
            id: buttonAnswer
            width: Style.widthDataControls
            height: Style.heightDataControls
            radius: Style.sizeDataControlsRadius
            enabled: dropDown ? dropDown.ui_values.length > 0 : false
            color: Style.colourDataSelectorBackground
            Text {
                id: textAnswer
                anchors {
                    fill: parent
                    margins: Style.heightDataControls / 4
                }
                text: dropDown.findDescriptionForDropdownValue(enumeratorDecorator.ui_value)
                color: Style.colourDataSelectorFont
                font.pixelSize: Style.pixelSizeDataControls
                verticalAlignment: Qt.AlignVCenter
            }
            MouseArea {
                anchors.fill: parent
                onClicked: contentFrame.push("qrc:/components/EnumeratorSelectorView.qml",
 {dropDown: enumeratorSelectorRoot.dropDown,
 enumeratorDecorator: enumeratorSelectorRoot.enumeratorDecorator})
            }
        }
    }
}

这个组件在布局上与StringEditorSingleLine有很多相似之处,但它用按钮表示替换了文本元素。我们从绑定的EnumeratorDecorator中获取值,并将其传递给我们在DropDown类上创建的插槽,以获取当前选定值的字符串描述。当用户按下按钮时,MouseAreaonClicked事件执行与我们在MasterView中看到的相同类型的视图转换,将用户带到新的EnumeratorSelectorView

我们在这里有点作弊,因为我们直接通过其contentFrame ID 在MasterView中引用StackView。在设计时,Qt Creator 无法知道contentFrame是什么,因为它在一个完全不同的文件中,所以它可能会标记为错误,而且您肯定不会得到自动完成。然而,在运行时,这个组件将成为与MasterView相同的 QML 层次结构的一部分,因此它将能够找到它。这是一种风险的方法,因为如果层次结构中的另一个元素也被称为contentFrame,那么可能会发生糟糕的事情。更安全的方法是通过MasterViewQtObject属性作为contentFramecontentFrame一直传递到 QML 层次结构的底部。

当我们添加或编辑客户时,我们目前忽略联系人,并始终有一个空集合。让我们看看如何向集合添加对象,并在此过程中使用我们闪亮的新EnumeratorSelector

联系人

我们将需要一些新的 UI 组件来管理我们的联系人。我们之前使用过AddressEditor来管理我们的地址详细信息,所以我们将继续在那个模式下创建一个ContactEditor组件。该组件将显示我们的联系人集合,每个联系人都将由ContactDelegate表示。在最初创建新的客户对象时,不会有任何联系人,因此我们还需要一种让用户添加新联系人的方式。我们将通过按钮按下来实现这一点,并创建一个可以添加到内容视图的新组件。让我们先做这个。

为了支持这个新组件,像往常一样,我们将在 Style 中添加一些属性:

readonly property real widthFormButton: 240
readonly property real heightFormButton: 60
readonly property color colourFormButtonBackground: "#f36f24"
readonly property color colourFormButtonFont: "#ffffff"
readonly property int pixelSizeFormButtonIcon: 32
readonly property int pixelSizeFormButtonText: 22
readonly property int sizeFormButtonRadius: 5

cm-ui/components中创建FormButton.qml

import QtQuick 2.9
import CM 1.0
import assets 1.0
Item {
    property alias iconCharacter: textIcon.text
    property alias description: textDescription.text
    signal formButtonClicked()
    width: Style.widthFormButton
    height: Style.heightFormButton
    Rectangle {
        id: background
        anchors.fill: parent
        color: Style.colourFormButtonBackground
        radius: Style.sizeFormButtonRadius
        Text {
            id: textIcon
            anchors {
                verticalCenter: parent.verticalCenter
                left: parent.left
                margins: Style.heightFormButton / 4
            }
            font {
                family: Style.fontAwesome
                pixelSize: Style.pixelSizeFormButtonIcon
            }
            color: Style.colourFormButtonFont
            text: "\uf11a"
            horizontalAlignment: Text.AlignHCenter
            verticalAlignment: Text.AlignVCenter
        }
        Text {
            id: textDescription
            anchors {
                left: textIcon.left
                bottom: parent.bottom
                top: parent.top
                right: parent.right
            }
            font.pixelSize: Style.pixelSizeFormButtonText
            color: Style.colourFormButtonFont
            text: "SET ME!!"
            horizontalAlignment: Text.AlignHCenter
            verticalAlignment: Text.AlignVCenter
        }
        MouseArea {
            anchors.fill: parent
            cursorShape: Qt.PointingHandCursor
            hoverEnabled: true
            onEntered: background.state = "hover"
            onExited: background.state = ""
            onClicked: formButtonClicked()
        }
        states: [
            State {
                name: "hover"
                PropertyChanges {
                    target: background
                    color: Qt.darker(Style.colourFormButtonBackground)
                }
            }
        ]
    }
}

在这里,我们结合了我们在本书中早期编写的NavigationButtonCommandButton控件的方面。唯一的真正区别是它旨在更自由地在主内容框架中使用,而不是被限制在其中一个工具栏中。

接下来,让我们添加我们将用于显示/编辑单个联系人对象的组件。在cm-ui/components中创建ContactDelegate.qml

import QtQuick 2.9
import CM 1.0
import assets 1.0
Item {
    property Contact contact
    implicitWidth: flow.implicitWidth
    implicitHeight: flow.implicitHeight + borderBottom.implicitHeight + Style.sizeItemMargin
    height: width > selectorType.width + textAddress.width + Style.sizeScreenMargin
            ? selectorType.height + borderBottom.height + Style.sizeItemMargin
            : selectorType.height + textAddress.height + Style.sizeScreenMargin + borderBottom.height + Style.sizeItemMargin
    Flow {
        id: flow
        width: parent.width
        spacing: Style.sizeScreenMargin
        EnumeratorSelector {
            id: selectorType
            width: Style.widthDataControls
            dropDown: contact.ui_contactTypeDropDown
            enumeratorDecorator: contact.ui_contactType
        }
        StringEditorSingleLine {
            id: textAddress
            width: Style.widthDataControls
            stringDecorator: contact.ui_address
        }
    }
    Rectangle {
        id: borderBottom
        anchors {
            top: flow.bottom
            left: parent.left
            right: parent.right
            topMargin: Style.sizeItemMargin
        }
        height: 1
        color: Style.colorItemBorder
    }
}

这与我们在第八章中添加的RssItemDelegate几乎相同,Web 请求。我们添加了新的EnumeratorSelector并将其绑定到ui_contactType属性,使用ui_contactTypeDropDown为控件提供下拉信息。

cm-ui/components中创建ContactsEditor.qml

import QtQuick 2.9
import CM 1.0
import assets 1.0
Panel {
    property Client client
    id: contactsEditorRoot
    contentComponent:
        Column {
            id: column
            spacing: Style.sizeControlSpacing
            Repeater {
                id: contactsView
                model: client.ui_contacts
                delegate:
                    ContactDelegate {
                        width: contactsEditorRoot.width
                        contact: modelData
                    }
            }
            FormButton {
                iconCharacter: "\uf067"
                description: "Add Contact"
                onFormButtonClicked: {
                    client.addContact();
                }
            }
        }
}

我们已经在ContactDelegateFormButton控件中完成了所有的艰苦工作,所以这部分非常简短。我们将所有内容添加到Panel中,以便外观和感觉与其他视图保持一致。我们使用另一个Repeater,以便为集合中的每个联系人启动一个ContactDelegate,并在联系人之后立即显示一个按钮,以将新联系人添加到列表中。为了做到这一点,我们调用了本章前面添加的addContact()方法。

现在,我们只需要将我们的ContactsEditor实例添加到CreateClientView中:

ContactsEditor {
    width: scrollView.width
    client: newClient
    headerText: "Contact Details"
}

我们还可以在EditClientView中使用相同的组件:

ContactsEditor {
    width: scrollView.width
    client: selectedClient
    headerText: "Contact Details"
}

就是这样。构建和运行,您可以尽情添加和编辑联系人:

一旦保存了新的客户端,如果查看数据库,您会看到联系人数组已经相应地更新,如下面的屏幕截图所示:

现在剩下的就是约会集合,我们已经涵盖了您需要处理的所有技能,所以我们将把它作为读者的练习,并继续进行最后一个话题 - 将我们的应用部署到最终用户。

部署准备

我们应用程序的核心是cm-ui可执行文件。这是最终用户启动并打开图形窗口并编排我们编写的所有花哨东西的文件。当我们在 Qt Creator 中运行cm-ui项目时,它会为我们打开可执行文件,一切都能正常工作。然而,将我们的应用程序分发给其他用户比简单地在他们的机器上放置可执行文件的副本并启动它要复杂得多。

我们的可执行文件有各种依赖关系,需要放置在适当位置才能运行。一个依赖关系的典型例子是我们自己的cm-lib库。几乎所有的业务逻辑都隐藏在那里,没有这个功能,我们的 UI 就无法做太多事情。跨各种操作系统的依赖关系解析的实现细节非常复杂,远远超出了本书的范围。然而,我们的应用程序的基本要求是相同的,无论平台如何。

有四类依赖关系需要考虑,并确保它们在我们目标用户的机器上,以便我们的应用程序能够正常运行:

  • 第 1 项:我们编写或手动添加到解决方案中的自定义库。在这种情况下,我们只需要担心cm-lib库。

  • 第 2 项:我们的应用程序直接或间接链接到的 Qt 框架的部分。通过我们在.pro文件中添加的模块,我们已经了解了其中一些,例如,qmlquick模块需要QtQmlQtQuick组件。

  • 第 3 项:Qt 框架本身的任何内部依赖关系。这包括特定于平台的文件,QML 子系统的资源以及诸如sqliteopenssl之类的第三方库。

  • 第 4 项:我们用 C++编译器构建应用程序所需的任何库。

我们已经在第二章中广泛使用了第 1 项,项目结构,我们在控制输出的确切位置方面付出了很多工作。我们并不需要担心第 2 和第 3 项,因为我们在开发机器上有完整的 Qt 框架安装,它为我们处理了一切。同样,第 4 项由我们使用的工具包决定,如果我们的机器上有编译器可用,那么它需要的库也是有的。

确定我们需要为最终用户复制的内容(很可能他们没有安装 Qt 或其他开发工具)可能是一项痛苦的工作。即使我们做到了这一点,将所有东西打包成一个整洁的包或安装程序,让用户轻松运行,也可能是一个项目。幸运的是,Qt 以捆绑工具的形式为我们提供了一些帮助。

Linux 和 macOS X 有应用程序包的概念,应用程序可执行文件和所有依赖项可以一起打包成一个单个文件,然后可以轻松分发和点击按钮启动。Windows 则更加自由,如果我们想将所有文件捆绑到一个可安装文件中,我们需要做更多的工作,但是 Qt 再次拯救了我们,并提供了出色的 Qt 安装程序框架,为我们简化了这个过程。

让我们依次查看每个操作系统,并为每个操作系统生成一个应用程序包或安装程序。

OS X

首先,使用您选择的套件在发布模式下构建解决方案。您已经知道,如果我们在 Qt Creator 中按下运行按钮,我们的应用程序会启动,一切都很顺利。但是,导航到 Finder 中的cm-ui.app文件,尝试直接启动它;在这方面,情况就不那么美好了:

问题在于缺少依赖项。我们可以使用otool来查看这些依赖项是什么。首先,将cm-ui.app包复制到一个新目录——cm/installer/osx

这并不是绝对必要的,但我喜欢将构建和部署文件分开。这样,如果我们进行代码更改并重新构建解决方案,我们只会更新二进制文件夹中的应用程序,而我们的部署文件则保持不变。

接下来,查看应用程序包内部,看看我们正在处理什么。在 Finder 中,Ctrl点击我们刚刚复制到安装程序文件夹的cm-ui.app,然后选择显示包内容。我们感兴趣的部分是Contents/MacOS文件夹。在那里,你会找到我们的cm-ui应用程序可执行文件。

有了这个识别,打开一个命令终端,导航到cm/installer/osx,并在可执行文件上运行otool

$ otool -L cm-ui.app/Contents/MacOS/cm-ui

你会看到一个与以下内容相同或类似的输出:

cm-ui:
libcm-lib.1.dylib (compatibility version 1.0.0, current version 1.0.0)
@rpath/QtQuick.framework/Versions/5/QtQuick (compatibility version 5.9.0, current version 5.9.1)
@rpath/QtQml.framework/Versions/5/QtQml (compatibility version 5.9.0, current version 5.9.1)
@rpath/QtNetwork.framework/Versions/5/QtNetwork (compatibility version 5.9.0, current version 5.9.1)
@rpath/QtCore.framework/Versions/5/QtCore (compatibility version 5.9.0, current version 5.9.1)
/System/Library/Frameworks/DiskArbitration.framework/Versions/A/DiskArbitration (compatibility version 1.0.0, current version 1.0.0)
/System/Library/Frameworks/IOKit.framework/Versions/A/IOKit (compatibility version 1.0.0, current version 275.0.0)
@rpath/QtGui.framework/Versions/5/QtGui (compatibility version 5.9.0, current version 5.9.1)
@rpath/QtXml.framework/Versions/5/QtXml (compatibility version 5.9.0, current version 5.9.1)
/System/Library/Frameworks/OpenGL.framework/Versions/A/OpenGL (compatibility version 1.0.0, current version 1.0.0)
/System/Library/Frameworks/AGL.framework/Versions/A/AGL (compatibility version 1.0.0, current version 1.0.0)
/usr/lib/libc++.1.dylib (compatibility version 1.0.0, current version 307.5.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1238.50.2)

让我们提醒自己需要考虑的依赖关系,并看看它们与我们刚刚看到的输出有什么关系:

  • 我们手动编写或添加到解决方案中的自定义库(cm-lib)。这是libcm-lib.1.dylib的引用。没有路径组件的事实表明工具不太确定这个文件的位置。它应该在可执行文件本身的相同文件夹中吗?它应该在标准的/usr/lib/文件夹中吗?幸运的是,我们可以在打包应用程序时指定这个文件的位置。

  • 我们的应用程序链接到的 Qt 框架的部分。QtQuickQtQml等都是我们在cm-ui代码中直接引用的框架模块。其中一些是通过我们的cm-ui.pro文件中的 QT 变量明确引入的,而其他一些则是使用 QML 等隐式包含的。

  • Qt 框架本身的任何内部依赖项。我们之前没有看到这些列出来,但如果我们对QtQuick模块运行 otool,你会看到它依赖于QtQmlQtNetworkQtGuiQtCore。还有一些系统级别的库是必需的,比如 OpenGL,虽然我们没有明确针对它们编码,但 Qt 使用了它们。

  • 由我们构建应用程序的 C++编译器所需的任何库;这里特别突出的是libc++.1.dylib

为了手动捆绑所有的依赖项,我们可以将它们全部复制到应用程序包中,然后执行一些重新配置步骤,以更新我们从 otool 中看到的位置元数据。

让我们选择一个框架依赖项——QtQuick——并快速浏览一下我们需要做什么才能实现这一点,然后我们将转向真正方便的工具,它可以为我们完成所有这些非常不愉快的繁重工作。

首先,我们将创建一个Frameworks目录,系统将在其中搜索捆绑的依赖项:

$ mkdir cm-ui.app/Contents/Frameworks

接下来,我们将物理地将引用的文件复制到新目录。由于前面的LC_RPATH条目,我们知道在我们的开发机器上查找现有文件的位置,即/Users/<Your Username>/Qt5.9.1/5.9.1/clang_64/lib

$ cp -R /Users/<Your Username>  /Qt5.9.1 /5.9.1/clang_64 /lib/ QtQuick.framework cm-ui.app/Contents/Frameworks

然后,我们需要使用install_name_tool更改复制的库文件的共享库标识名称:

$ install_name_tool -id @executable_path /../Frameworks / QtQuick.framework/Versions/5/QtQuick cm-ui.app /Contents /Frameworks / QtQuick.framework/Versions/5/QtQuick

这里的语法是install_name_tool -id [新名称] [共享库文件]。要到达库文件(而不是我们复制的框架包),我们要深入到Versions/5/QtQuick。我们将该二进制文件的 ID 设置为可执行文件将查找到的位置,即在这种情况下,是在与可执行文件本身相同级别的Frameworks文件夹中(../)。

接下来,我们还需要更新可执行文件的依赖项列表,以便在正确的位置查找这个新文件:

$ install_name_tool -change @rpath/QtQuick.framework/Versions/5/QtQuick @executable_path/../Frameworks/QtQuick.framework/Versions/5/QtQuick cm-ui.app/Contents/MacOs/cm-ui

这里的语法是install_name_tool -change [旧值] [新值] [可执行文件]。我们要将旧的@rpath条目更改为我们刚刚添加的新 Frameworks 路径。同样,我们使用@executable_path变量,以便依赖项始终位于相对于可执行文件的相同位置。现在,可执行文件和共享库中的元数据都相互匹配,并与我们现在添加到应用程序包中的Frameworks文件夹相关联。

请记住,这还不是全部,因为QtQuick本身也有依赖项,所以我们需要复制和重新配置所有这些文件,然后检查它们的依赖项。一旦我们耗尽了cm-ui可执行文件的整个依赖树,我们还需要为cm-lib库重复这个过程。正如你所想象的那样,这很快就会变得乏味。

幸运的是,macdeployqt Qt Mac 部署工具正是我们需要的。它会扫描可执行文件的 Qt 依赖项,并将它们复制到我们的应用程序包中,同时处理重新配置工作。该工具位于您构建应用程序的已安装工具包的bin文件夹中,例如/Qt/5.9.1/5.9.1/clang_64/bin

在命令终端中,执行macdeployqt如下(假设你在cm/installer/osx目录中):

$ <Path to bin>/macdeployqt cm-ui.app -qmldir=<Qt Projects>/cm/cm-ui -libpath=<Qt Projects>/cm/binaries/osx/clang/x64/release

记得用尖括号中的参数替换你系统上的完整路径(或将可执行文件路径添加到系统的 PATH 变量中)。

qmldir标志告诉工具在哪里扫描 QML 导入,并设置为我们的 UI 项目文件夹。libpath标志用于指定我们编译的cm-lib文件所在的位置。

此操作的输出将如下所示:

File exists, skip copy: "cm-ui.app/Contents/PlugIns/quick/libqtquick2plugin.dylib"
File exists, skip copy: "cm-ui.app/Contents/PlugIns/quick/libqtquickcontrols2plugin.dylib"
File exists, skip copy: "cm-ui.app/Contents/PlugIns/quick/libqtquickcontrols2materialstyleplugin.dylib"
File exists, skip copy: "cm-ui.app/Contents/PlugIns/quick/libqtquickcontrols2universalstyleplugin.dylib"
File exists, skip copy: "cm-ui.app/Contents/PlugIns/quick/libwindowplugin.dylib"
File exists, skip copy: "cm-ui.app/Contents/PlugIns/quick/libqtquicktemplates2plugin.dylib"
File exists, skip copy: "cm-ui.app/Contents/PlugIns/quick/libqtquickcontrols2materialstyleplugin.dylib"
File exists, skip copy: "cm-ui.app/Contents/PlugIns/quick/libqtquickcontrols2materialstyleplugin.dylib"
File exists, skip copy: "cm-ui.app/Contents/PlugIns/quick/libqtquickcontrols2universalstyleplugin.dylib"
File exists, skip copy: "cm-ui.app/Contents/PlugIns/quick/libqtquickcontrols2universalstyleplugin.dylib"
WARNING: Plugin "libqsqlodbc.dylib" uses private API and is not Mac App store compliant.
WARNING: Plugin "libqsqlpsql.dylib" uses private API and is not Mac App store compliant.
ERROR: no file at "/opt/local/lib/mysql55/mysql/libmysqlclient.18.dylib"
ERROR: no file at "/usr/local/lib/libpq.5.dylib"

Qt 在 SQL 模块上有点古怪,如果你使用一个 SQL 驱动程序,它会尝试打包所有的驱动程序;然而,我们知道我们只使用 SQLite,不需要 MySQL 或 PostgreSQL,所以我们可以安全地忽略这些错误。

执行完毕后,你应该能够在 Finder 中再次查看包内容,并看到所有准备好等待部署的依赖项,如下所示:

多么巨大的时间节省者!它已经为我们创建了适当的文件结构,并复制了所有的 Qt 模块和插件,以及我们的cm-lib共享库。现在尝试执行cm-ui.app文件,它应该成功启动应用程序。

Linux

Linux 的打包和部署与 OS X 大致相似,我们不会以相同的细节水平进行覆盖,所以如果你还没有这样做,至少先略读一下 OS X 部分。与所有平台一样,首先要做的是使用你选择的工具包在Release模式下构建解决方案,以生成二进制文件。

第一次以 Release 模式构建时,我收到了“无法找到-lGL”错误。这是因为 OpenGL 的dev库没有安装在我的系统上。获取这些库的一种方法是安装 FreeGlut:

$ sudo apt-get update

$ sudo apt-get install build-essential

$ sudo apt-get install freeglut3-dev

编译完成后,将cm-ui二进制文件复制到新的cm/installer/linux目录中。

接下来,我们可以查看我们的应用程序有哪些依赖项。在命令终端中,切换到cm/installer/linux文件夹并运行ldd

$ ldd <Qt Projects>/cm/binaries/linux/gcc/x64/release/cm-ui

您将看到类似以下的输出:

linux-vdso.so.1 => (0x00007ffdeb1c2000)
libcm-lib.so.1 => /usr/lib/libcm-lib.so.1 (0x00007f624243d000)
libQt5Gui.so.5 => /home/nick/Qt/5.9.1/gcc_64/lib/libQt5Gui.so.5 (0x00007f6241c8f000)
libQt5Qml.so.5 => /home/nick/Qt/5.9.1/gcc_64/lib/libQt5Qml.so.5 (0x00007f6241698000)
libQt5Xml.so.5 => /home/nick/Qt/5.9.1/gcc_64/lib/libQt5Xml.so.5 (0x00007f624145e000)
libQt5Core.so.5 => /home/nick/Qt/5.9.1/gcc_64/lib/libQt5Core.so.5 (0x00007f6240d24000)
libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f62409a1000)
libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f624078b000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f62403c1000)
libQt5Sql.so.5 => /home/nick/Qt/5.9.1/gcc_64/lib/libQt5Sql.so.5 (0x00007f6240179000)
libQt5Network.so.5 => /home/nick/Qt/5.9.1/gcc_64/lib/libQt5Network.so.5 (0x00007f623fde8000)
libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f623fbcb000)
libGL.so.1 => /usr/lib/x86_64-linux-gnu/mesa/libGL.so.1 (0x00007f623f958000)
libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f623f73e000)
libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f623f435000)
librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007f623f22c000)
libicui18n.so.56 => /home/nick/Qt/5.9.1/gcc_64/lib/libicui18n.so.56 (0x00007f623ed93000)
libicuuc.so.56 => /home/nick/Qt/5.9.1/gcc_64/lib/libicuuc.so.56 (0x00007f623e9db000)
libicudata.so.56 => /home/nick/Qt/5.9.1/gcc_64/lib/libicudata.so.56 (0x00007f623cff7000)
libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f623cdf3000)
libgthread-2.0.so.0 => /usr/lib/x86_64-linux-gnu/libgthread-2.0.so.0 (0x00007f623cbf1000)
libglib-2.0.so.0 => /lib/x86_64-linux-gnu/libglib-2.0.so.0 (0x00007f623c8df000)
/lib64/ld-linux-x86-64.so.2 (0x0000562f21a5c000)
libexpat.so.1 => /lib/x86_64-linux-gnu/libexpat.so.1 (0x00007f623c6b6000)
libxcb-dri3.so.0 => /usr/lib/x86_64-linux-gnu/libxcb-dri3.so.0 (0x00007f623c4b2000)
libxcb-present.so.0 => /usr/lib/x86_64-linux-gnu/libxcb-present.so.0 (0x00007f623c2af000)
libxcb-sync.so.1 => /usr/lib/x86_64-linux-gnu/libxcb-sync.so.1 (0x00007f623c0a8000)
libxshmfence.so.1 => /usr/lib/x86_64-linux-gnu/libxshmfence.so.1 (0x00007f623bea4000)
libglapi.so.0 => /usr/lib/x86_64-linux-gnu/libglapi.so.0 (0x00007f623bc75000)
libXext.so.6 => /usr/lib/x86_64-linux-gnu/libXext.so.6 (0x00007f623ba63000)
libXdamage.so.1 => /usr/lib/x86_64-linux-gnu/libXdamage.so.1 (0x00007f623b85f000)
libXfixes.so.3 => /usr/lib/x86_64-linux-gnu/libXfixes.so.3 (0x00007f623b659000)
libX11-xcb.so.1 => /usr/lib/x86_64-linux-gnu/libX11-xcb.so.1 (0x00007f623b457000)
libX11.so.6 => /usr/lib/x86_64-linux-gnu/libX11.so.6 (0x00007f623b11c000)
libxcb-glx.so.0 => /usr/lib/x86_64-linux-gnu/libxcb-glx.so.0 (0x00007f623af03000)
libxcb-dri2.so.0 => /usr/lib/x86_64-linux-gnu/libxcb-dri2.so.0 (0x00007f623acfe000)
libxcb.so.1 => /usr/lib/x86_64-linux-gnu/libxcb.so.1 (0x00007f623aadb000)
libXxf86vm.so.1 => /usr/lib/x86_64-linux-gnu/libXxf86vm.so.1 (0x00007f623a8d5000)
libdrm.so.2 => /usr/lib/x86_64-linux-gnu/libdrm.so.2 (0x00007f623a6c4000)
libpcre.so.3 => /lib/x86_64-linux-gnu/libpcre.so.3 (0x00007f623a453000)
libXau.so.6 => /usr/lib/x86_64-linux-gnu/libXau.so.6 (0x00007f623a24e000)
libXdmcp.so.6 => /usr/lib/x86_64-linux-gnu/libXdmcp.so.6 (0x00007f623a048000)

这是一些依赖项的列表!关键是要注意我们的cm-lib库的依赖关系:

libcm-lib.so.1 => /usr/lib/libcm-lib.so.1

这表明可执行文件将在/usr/lib文件夹中查找我们的库,因此在继续之前,让我们确保它在那里可用,将libcm-lib.so.1复制到/usr/lib中:

$ sudo cp <Qt Projects>/cm/binaries/linux/gcc/x64/release/libcm-lib.so.1 /usr/lib

我们已经可以猜到手动管理所有这些依赖项将是一场噩梦,讨论了 OS X 的过程并看到了有多少依赖项,所以我们的 Kit 的bin文件夹中一定有一个工具可以为我们完成所有工作,对吗?嗯,是和不是。与 OS X 和 Windows 一样,我们没有官方的 Qt 工具可以为我们完成这项工作。幸运的是,Qt 社区的出色成员probonopd已经挺身而出,用linuxdeployqt填补了这一空白。

您可以从 GitHub 项目的发布页面github.com/probonopd/linuxdeployqt获取linuxdeployqt应用程序映像。下载文件(linuxdeployqt-continuous-x86_64.AppImage),然后将其设置为可执行文件:

$ chmod a+x <Path to downloaded file>/linuxdeployqt-continuous-x86_64.AppImage

然后,我们可以执行它,并让它按照依赖关系进行操作。首先将目录更改为cm/installer/linux

$ <Path to downloaded file>/linuxdeployqt-continuous-x86_64.AppImage cm-ui -qmldir=<Qt Projects>/cm/cm-ui -appimage

qmldir标志告诉工具在哪里扫描 QML 导入,并设置为我们的 UI 项目文件夹。appimage标志用于让工具为我们创建一个应用程序映像文件,这是一个包含所有内容的单个文件。

第一次可能不会完全正常工作。您的输出可能如下所示:

ERROR: Desktop file missing, creating a default one (you will probably want to edit it)
ERROR: Icon file missing, creating a default one (you will probably want to edit it)
ERROR: "/usr/bin/qmake -query" exited with 1 : "qmake: could not exec '/usr/lib/x86_64-linux-gnu/qt4/bin/qmake': No such file or directory\n"
ERROR: Qt path could not be determined from qmake on the $PATH
ERROR: Make sure you have the correct Qt on your $PATH
ERROR: You can check this with qmake -v

前两个错误只是因为我们没有提供桌面文件或图标,系统已经为我们生成了默认值;我们可以忽略这些。其余的是因为linuxdeployqt不知道qmake在哪里。我们可以提供路径作为额外参数(-qmake=<PATH>),或者为了节省我们每次都要这样做,我们可以将其添加到我们的 PATH 环境变量中:

$ export PATH=<Qt Path>/5.9.1/gcc_64/bin/:$PATH

然后我们可以检查是否可以找到 qmake,尝试检索版本信息:

$ qmake -v

如果它正常,您将看到版本信息:

QMake version 3.1
Using Qt version 5.9.1 in /home/nick/Qt/5.9.1/gcc_64/lib

修复后,我们现在可以尝试再次运行linuxdeployqt命令。但是,我们解决了一个问题,现在又遇到了另一个问题:

ERROR: Desktop file missing, creating a default one (you will probably want to edit it)
ERROR: Icon file missing, creating a default one (you will probably want to edit it)
ERROR: ldd outputLine: "libmysqlclient.so.18 => not found"
ERROR: for binary: "/home/nick/Qt/5.9.1/gcc_64/plugins/sqldrivers/libqsqlmysql.so"
ERROR: Please ensure that all libraries can be found by ldd. Aborting.

再次忽略前两个错误。现在它找不到 MySQL 驱动程序,这很烦人,因为我们甚至不是 MySQL,这与我们在 OS X 上看到的相同的 Qt SQL 问题。作为解决方法,让我们通过临时重命名来有效地“隐藏”我们不想要的 SQL 驱动程序:

$ cd <Qt Path>/5.9.1/gcc_64/plugins/sqldrivers
$ mv libqsqlmysql.so libqsqlmysql.so_ignore
$ mv libqsqlpsql.so libqsqlpsql.so_ignore

再次运行linuxdeployqt命令。这次会有大量输出,最终会出现成功消息,包括以下内容:

App name for filename: Application
dest_path: Application-x86_64.AppImage

这告诉我们,我们的应用程序映像已命名为Application-x86_64.AppImage,它保存在Downloads文件夹中。

在文件管理器中查看,您会看到它已经在我们的可执行文件旁边添加了各种文件和目录:

它还将Application-x86_64.AppImage文件放在了Downloads文件夹中,这是一个包含所有依赖项的单个自包含可执行文件包。但是,如果您前往Downloads并尝试启动AppImage,可能会出现错误(通过终端命令执行它以查看错误消息):

QXcbIntegration: Cannot create platform OpenGL context, neither GLX nor EGL are enabled

这似乎是linuxdeployqt缺少一些依赖项的问题,但由于某种原因,再次运行该工具会神奇地解决这些问题。再次执行linuxdeployqt命令,嘿,AppImage现在可以正常工作了。

Windows

首先,使用您选择的套件在Release模式下构建解决方案。完成后,将cm-ui.execm-lib.dll应用程序二进制文件复制到新的cm/installer/windows/packages/com.packtpub.cm/data目录。这种奇怪的目录结构将在下一节—Qt 安装程序框架中解释,我们只是在简化后续的额外复制。

接下来,让我们回顾一下我们需要考虑的依赖关系:

  • 条款 1:我们已经编写或手动添加到解决方案中的自定义库(cm-lib

  • 条款 2:我们的应用程序链接到的 Qt 框架的部分

  • 条款 3:Qt 框架本身的任何内部依赖项

  • 条款 4:C++编译器所需的任何库

好消息是第 1 项已经完成了!Windows 将在与可执行文件相同的文件夹中查找可执行文件的依赖项。这真的很有帮助,通过简单地将 DLL 复制到与可执行文件相同的文件夹中,我们已经解决了这个依赖关系。Qt 安装程序框架会将给定文件夹中的所有文件部署到目标机器上的相同位置,因此我们知道这在部署后也会被保留。

坏消息是剩下的步骤手动管理有点噩梦。我们可以通过查看我们明确添加到*.pro文件的模块来初步确定我们需要 Qt 的哪些部分。这将是从cm-uicm-lib中添加的qmlquickxml,以及默认包括的core中的sqlnetworkxml

我们可以使用我们为cm-lib.dll所做的方法,简单地手动将每个 Qt DLL 文件复制到数据文件夹中。这将满足第 2 项,虽然非常乏味,但相当简单。然而,第 3 项是一个痛苦的练习,我们真的不想自己做。

幸运的是,windeployqt Qt Windows 部署工具正是我们需要的。它会扫描一个.exe文件以查找 Qt 依赖项,并将它们复制到我们的安装程序文件夹中。该工具位于您使用的已安装套件的bin文件夹中,例如/Qt/5.9.1/mingw32/bin

在命令终端中,执行以下命令windeployqt

$ <Path to bin>/windeployqt.exe --qmldir <Qt Projects>/cm/cm-ui <Qt Projects>/cm/installer/windows/packages/com.packtpub.cm/data/cm-ui.exe --compiler-runtime

请记住,用尖括号中的参数替换您系统上的完整路径(或将可执行文件路径添加到系统的 PATH 变量中)。

qmldir标志告诉工具在哪里扫描 QML 导入,并设置为我们的 UI 项目文件夹。告诉工具要扫描哪个.exe文件的后,compiler-runtime标志表示我们也想要编译器运行时文件,所以它甚至为我们处理了第 4 项作为奖励!

默认情况下,找到的依赖项随后将被复制到与被扫描的可执行文件相同的文件夹中。这是将编译后的二进制文件首先复制到专用安装程序文件夹的一个很好的理由,以便开发项目输出和部署内容保持分开。

执行后,您应该看到大量的输出。虽然诱人地认为“哦,那做了一些事情,所以一切都应该没问题”,但浏览输出是一个好主意,即使您不确定它在做什么,因为有时您可以发现明显的问题,可以采取行动来解决。

例如,当首次部署 MinGW 套件构建时,我遇到了给定的行:

Warning: Cannot find GCC installation directory. g++.exe must be in the path.

尽管命令已成功执行,我可以在安装程序文件夹中看到一大堆 Qt 依赖项,但我实际上缺少了 GCC 依赖项。按照说明并将<Qt Installation path>/Tools/mingw530_32/bin添加到系统环境变量的 PATH 变量中是一个简单的修复方法。重新启动命令终端并再次运行windeployqt命令后,它随后成功完成,没有警告,并且 GCC 文件如预期地出现在数据文件夹中,与所有 Qt 二进制文件一起。如果没有注意到这个安静的小警告,我可能会继续进行一些潜在的关键文件缺失。

正如您所看到的,windeployqt是一个巨大的时间节省器,但不幸的是,它并不是一个万能解决方案,有时会漏掉所需的文件。存在诸如 Dependency Walker 之类的工具,可以帮助详细分析依赖树,但一个很好的起点就是手动从数据文件夹启动cm-ui可执行文件并查看发生了什么。在我们的情况下,是这样的:

坏消息是它不起作用,但好消息是至少它清楚地告诉我们为什么它不起作用——它缺少Qt5Sql.dll依赖项。我们知道我们确实在那里有一个依赖项,因为当我们开始进行数据库工作时,我们不得不在.pro文件中添加sql模块。然而,等等,我们刚刚执行了一个应该为我们拉取所有 Qt 依赖项的命令,对吧?对,我不知道为什么这个工具会漏掉一些它真的应该知道的依赖项,但它确实漏掉了。我不知道这是一个错误、一个疏忽,还是与底层第三方 SQLite 实现相关的许可限制,但无论如何,简单的解决方案是我们只需要自己复制它。

转到<Qt Installation>/5.9.1/<kit>/bin,将Qt5Sql.dll复制到我们的数据文件夹中。再次启动cm-ui.exe,哇,它成功打开了!

除了从 bin 目录中缺少.dll文件之外,还要注意来自插件目录的缺少文件/文件夹。在我们的情况下,您将看到已成功复制了几个文件夹(bearer、iconengines 等),但有时它们不会复制,并且很难弄清楚,因为您不会像我们在缺少 DLL 时那样得到有用的错误消息。在这种情况下,我只能推荐三件事:试验、错误和互联网。

所以,现在我们有一个包含我们可爱的应用程序二进制文件和一大堆同样可爱的其他文件和文件夹的文件夹。现在呢?嗯,我们可以简单地将整个文件夹复制到用户的计算机上,并让他们像我们一样启动可执行文件。然而,一个更整洁和更专业的解决方案是将所有内容打包成一个漂亮的安装包,这就是 Qt Installer Framework 工具的用武之地。

Qt Installer 框架

让我们编辑我们的 Qt 安装并获取 Qt Installer 框架。

从您的 Qt 安装目录启动 MaintenanceTool 应用程序,您将看到一个与我们第一次安装 Qt 时看到的几乎相同的向导。要将 Qt Installer Framework 添加到您现有的安装中,请按照以下步骤操作:

  1. 要么登录到您的 Qt 帐户,要么跳过

  2. 选择添加或删除组件,然后点击下一步

  3. 在选择组件对话框中,勾选工具 > Qt Installer Framework 3.0,然后点击下一步

  4. 通过点击更新开始安装

安装完成后,您可以在Qt/Tools/QtInstallerFramework/3.0中找到已安装的工具。

您可以以完全相同的方式添加更多的模块、工具包等。除非您主动取消选择,否则您已安装的任何组件都不会受到影响。

Qt Installer Framework 需要存在两个特定目录:config 和 packages。Config 是描述整个安装程序的单一配置,而您可以将多个包(或组件)捆绑在同一个安装包中。每个组件在 packages 文件夹内有自己的子目录,其中包含一个数据文件夹,其中包含该组件要安装的所有项目,以及一个 meta 文件夹,其中包含包的配置数据。

在我们的情况下,虽然我们有两个项目(cm-libcm-ui),但将一个项目分发而不包括另一个是没有意义的,因此我们将文件聚合到一个包中。包的常见命名约定是com.<publisher>.<component>,所以我们将命名为com.packtpub.cm.我们已经在上一节创建了所需的数据文件夹(为前瞻性规划欢呼!),并且windeployqt已经为我们填充了文件。

这里没有必需的命名约定,所以如果愿意,可以随意为包命名其他名称。如果我们想要将一个额外的可选组件与我们的应用程序捆绑在一起,只需创建一个额外的包文件夹(例如,com.packtpub.amazingcomponent),其中包含相关的数据和元数据文件,包括一个单独的package.xml来配置该组件。

创建任何缺失的文件夹,以便在cm/installer/windows内部得到以下文件夹结构:

为了补充这些文件夹,我们还需要提供两个 XML 配置文件。

在 config 子文件夹中创建config.xml

<?xml version="1.0" encoding="UTF-8"?>
<Installer>
    <Name>Client Management</Name>
    <Version>1.0.0</Version>
    <Title>Client Management Application Installer</Title>
    <Publisher>Packt Software Publishing</Publisher>
    <StartMenuDir>Client Management</StartMenuDir>
    <TargetDir>@HomeDir@/ClientManagement</TargetDir>
</Installer>

此配置文件自定义了安装程序的行为。我们在这里指定的属性如下:

属性 目的
Name 应用程序名称
Version 应用程序版本
Title 标题栏中显示的安装程序名称
Publisher 软件的发布者
StartMenuDir Windows 开始菜单中的默认程序组
TargetDir 应用程序安装的默认目标目录

TargetDir属性中,您会注意到奇怪的@符号,它们定义了一个预定义变量HomeDir,允许我们动态获取到最终用户的主目录路径。您也可以以同样的方式访问其他属性的值,例如,@ProductName@将返回“Client Management”。更多信息请参阅doc.qt.io/qtinstallerframework/scripting.html#predefined-variables

接下来,在packages/com.packtpub.cm/meta子文件夹中创建package.xml

<?xml version="1.0" encoding="UTF-8"?>
<Package>
    <DisplayName>Client Management application</DisplayName>
    <Description>Install the Client Management application.</Description>
    <Version>1.0.0</Version>
    <ReleaseDate>2017-10-30</ReleaseDate>
    <Licenses>
        <License name="Fictional Training License Agreement" file="license.txt" />
    </Licenses>
    <Default>true</Default>
</Package>

此文件配置了com.packtpub.cm包(我们的 Client Management 应用程序)的以下属性:

属性 目的
DisplayName 组件的名称。
Description 选择组件时显示的文本。
Version 组件的版本(用于推广组件更新)。
ReleaseDate 组件发布的日期。
Licenses 必须同意才能安装包的许可证集合。许可协议的文本是从 meta 文件夹中的指定文件中获取的。
Default 表示组件是否默认选中的布尔标志。

您还需要在 meta 文件夹中创建license.txt;在这种情况下,内容并不重要,因为这只是用于演示,所以可以在里面写任何无关紧要的东西。

当所有的二进制文件、依赖项和配置都就绪后,我们现在可以在命令终端中运行 Qt Framework Installer 来生成我们的安装包。首先,切换到cm/installer/windows文件夹,然后执行binarycreator

$ <Qt Installation Path> \Tools \QtInstallerFramework \3.0\ bin\ binarycreator.exe -c config\config.xml -p packages ClientManagementInstaller.exe

-c标志告诉工具config.xml文件的位置,-p告诉工具所有包的位置。最后一个参数是您想要给结果安装程序的名称。

将我们的应用程序整齐地打包成一个单独的安装程序文件ClientManagementInstaller.exe后,我们现在可以轻松地将其分发给最终用户进行安装。

安装

启动安装程序后,您将看到一个欢迎对话框,其内容源自我们的config.xml文件:

然后我们被提示指定安装的目标目录,我们期望的是安装后,该文件夹将包含我们在数据文件夹中汇总的所有文件和文件夹:

然后,我们将看到我们通过包目录定义的所有组件的列表,这种情况下只是com.packtpub.cm文件夹中的应用程序和依赖项:

接下来,我们将看到我们在packages.xml中定义的任何许可证,包括文本文件中提供的许可证信息:

然后我们被提示设置开始菜单快捷方式,默认情况下由config.xml提供:

我们现在已经准备好安装了,并在确认之前提供了磁盘使用情况统计信息:

在安装完成时稍等片刻后,我们将看到最终确认对话框:

您应该在目标目录中看到一个新的ClientManagement文件夹,其中包含我们安装的应用程序!

总结

在这一章中,我们通过引入第一个对象工厂,使我们的应用程序更具可测试性。它们是一个非常有用的抽象层,使单元测试变得更加容易,在更大的项目中,通常会出现几个工厂。然后,我们通过具有可以随着窗口缩放的样式属性,使我们的 UI 更加动态。EnumeratorDecorators得到了一些关注,并且有了自己的编辑组件,完全适合手指操作。然后我们利用该编辑器实现了联系人管理,展示了如何轻松查看和编辑对象集合。

随着我们的应用程序更加完善,我们看了一下如何将我们闪亮的新作品交到最终用户手中。不同的操作系统都有自己的看法,您无疑会在自己的特定环境中发现怪癖并遇到挑战,但希望您现在有了解决它们所需的工具。

这种情绪不仅适用于部署,还适用于整个项目生命周期。本书的目标不是讨论理论问题,虽然有趣,但在您作为开发人员的日常角色中永远不会出现。目标是提出解决实际问题的解决方案。我们从头到尾开发了一个功能性的业务应用程序,通过常见任务,您将在日常工作中遇到,无论是在工作中进行倡议还是在家中进行个人项目。

我希望本书中详细介绍的一些方法对您有所帮助,并且您将继续享受与 Qt 一起工作的乐趣,就像我一样。

posted @ 2024-05-05 00:04  绝不原创的飞龙  阅读(133)  评论(0编辑  收藏  举报