QT5-蓝图-全-

QT5 蓝图(全)

原文:zh.annas-archive.org/md5/9509766d1e9be05e604942ababfd43b4

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Qt 已被开发为一个跨平台框架,并且多年来一直免费提供给公众。它主要用于构建 GUI 应用程序。它还提供了数千个 API,以简化开发。

Qt 5,Qt 的最新主要版本,再次证明是最受欢迎的跨平台工具包。凭借所有这些平台无关的类和函数,您只需编写一次代码,然后就可以让它在任何地方运行。

除了传统的强大 C++之外,Qt Quick 2,一个更加成熟的版本,可以帮助网页开发者开发动态且可靠的应用程序,因为 QML 与 JavaScript 非常相似。

本书涵盖内容

第一章, 创建您的第一个 Qt 应用程序,带您了解 Qt 的基本概念,如信号和槽,并帮助您创建第一个 Qt 和 Qt Quick 应用程序。

第二章, 构建一个漂亮的跨平台时钟,教您如何读取和写入配置以及处理跨平台开发。

第三章, 使用 Qt Quick 制作 RSS 阅读器,演示了如何在 QML 中开发一个时尚的 RSS 阅读器,QML 是一种与 JavaScript 非常相似的脚本语言。

第四章, 控制摄像头和拍照,展示了如何通过 Qt API 访问摄像头设备并利用状态栏和菜单栏。

第五章, 使用插件扩展绘图应用程序,教您如何通过使用绘图应用程序作为示例来使应用程序可扩展并编写插件。

第六章, 使用进度条利用 Qt 的网络模块以及学习 Qt 中的线程编程,展示了如何使用进度条来利用 Qt 的网络模块,以及学习 Qt 中的线程编程。

第七章, 解析 JSON 和 XML 文档以使用在线 API,教您如何在 Qt/C++和 Qt Quick/QML 中解析 JSON 和 XML 文档,这对于从在线 API 获取数据至关重要。

第八章, 使您的 Qt 应用程序支持其他语言,演示了如何制作国际化应用程序,使用 Qt Linguist 翻译字符串,然后动态加载翻译文件。

第九章, 在其他设备上部署应用程序,展示了如何打包并使您的应用程序在 Windows、Linux 和 Android 上可重新分发。

第十章, 遇到这些问题时不要慌张,为您提供了在 Qt 和 Qt Quick 应用程序开发过程中遇到的一些常见问题的解决方案和建议,并展示了如何调试 Qt 和 Qt Quick 应用程序。

您需要为本书准备什么

Qt 是跨平台的,这意味着您几乎可以在所有操作系统上使用它,包括 Windows、Linux、BSD 和 Mac OS X。硬件要求如下:

  • 一台计算机(PC 或 Macintosh)

  • 一台网络摄像头或已连接的摄像头设备

  • 可用的互联网连接

不需要安卓手机或平板电脑,但推荐使用,以便您可以在真实的安卓设备上测试应用程序。

本书提到的所有软件,包括 Qt 本身,都是免费的,可以从互联网上下载。

本书面向的对象

如果您是一位寻找真正跨平台的 GUI 框架的程序员,希望通过避免不同平台之间不兼容的问题来节省时间,并使用 Qt 5 为多个目标构建应用程序,那么这本书绝对是为您准备的。假设您具有基本的 C++ 编程经验。

术语

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 账号都显示如下:“UI 文件位于 Forms 目录下。”

代码块设置为如下所示:

#include "mainwindow.h"
#include <QApplication>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    MainWindow w;
    w.show();

    return a.exec();
}

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

#include <QStyleOption>
#include <QPainter>
#include <QPaintEvent>
#include <QMouseEvent>
#include <QResizeEvent>
#include "canvas.h"

Canvas::Canvas(QWidget *parent) :
  QWidget(parent)
{
}

void Canvas::paintEvent(QPaintEvent *e)
{
  QPainter painter(this);

  QStyleOption opt;
  opt.initFrom(this);
  this->style()->drawPrimitive(QStyle::PE_Widget, &opt, &painter, this);

 painter.drawImage(e->rect().topLeft(), image);
}

void Canvas::updateImage()
{
  QPainter painter(&image);
  painter.setPen(QColor(Qt::black));
  painter.setRenderHint(QPainter::Antialiasing);
  painter.drawPolyline(m_points.data(), m_points.count());
  this->update();
}

void Canvas::mousePressEvent(QMouseEvent *e)
{
  m_points.clear();
  m_points.append(e->localPos());
  updateImage();
}

void Canvas::mouseMoveEvent(QMouseEvent *e)
{
  m_points.append(e->localPos());
  updateImage();
}

void Canvas::mouseReleaseEvent(QMouseEvent *e)
{
  m_points.append(e->localPos());
  updateImage();
}

void Canvas::resizeEvent(QResizeEvent *e)
{
  QImage newImage(e->size(), QImage::Format_RGB32);
  newImage.fill(Qt::white);
  QPainter painter(&newImage);
  painter.drawImage(0, 0, image);
  image = newImage;
  QWidget::resizeEvent(e);
}

任何命令行输入或输出都写成如下所示:

..\..\bin\binarycreator.exe -c config\config.xml -p packages internationalization_installer.exe

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“导航到文件 | 新建文件项目。”

注意

警告或重要注意事项以如下所示的框显示。

小贴士

小贴士和技巧看起来像这样。

读者反馈

我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。

要向我们发送一般反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及本书的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您已经成为 Packt 书籍的骄傲拥有者,我们有一些东西可以帮助您充分利用您的购买。

下载示例代码

您可以从 www.packtpub.com 下载您购买的所有 Packt 出版物的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。

错误清单

尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误清单,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误部分下的现有错误清单中。

要查看之前提交的错误清单,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在错误清单部分。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 <copyright@packtpub.com> 联系我们,并提供疑似盗版材料的链接。

我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。

询问

如果您对本书的任何方面有问题,您可以通过 <questions@packtpub.com> 联系我们,我们将尽力解决问题。

第一章. 创建您的第一个 Qt 应用程序

GUI 编程并不像您想象的那么困难。至少,当您进入 Qt 的世界时,它不是那么困难。这本书将带您穿越这个世界,并让您深入了解这个令人难以置信的神奇工具包。无论您是否听说过它,只要您具备 C++ 编程的基本知识即可。

在本章中,我们将让您熟悉 Qt 应用程序的开发。简单的应用程序被用作演示,以便您涵盖以下主题:

  • 创建一个新项目

  • 更改小部件布局

  • 理解信号和槽的机制

  • 连接两个信号

  • 创建一个 Qt Quick 应用程序

  • 将 C++ 槽连接到 QML 信号

创建一个新项目

如果您还没有安装 Qt 5,请参考www.qt.io/download安装最新版本。建议您安装社区版,它是完全免费的,并且符合 GPL/LGPL。通常,安装程序会为您安装Qt 库Qt Creator。在这本书中,我们将使用 Qt 5.4.0 和 Qt Creator 3.3.0。较新版本可能会有细微差异,但概念保持不变。如果您电脑上没有 Qt Creator,强烈建议您安装它,因为本书中的所有教程都是基于它的。它也是 Qt 应用程序开发的官方 IDE。尽管您可能能够使用其他 IDE 开发 Qt 应用程序,但这通常会更加复杂。所以,如果您准备好了,让我们通过以下步骤开始吧:

  1. 打开 Qt Creator。

  2. 导航到文件 | 新建文件项目

  3. 选择Qt Widgets 应用程序

  4. 输入项目的名称和位置。在这种情况下,项目的名称是 layout_demo

您可以选择跟随向导并保留默认值。在此过程之后,Qt Creator 将根据您的选择生成项目的骨架。UI 文件位于 Forms 目录下。当您双击一个 UI 文件时,Qt Creator 将将您重定向到集成设计器。模式选择器应该突出显示设计,主窗口应包含几个子窗口,以便您设计用户界面。这正是我们要做的。有关 Qt Creator UI 的更多详细信息,请参阅doc.qt.io/qtcreator/creator-quick-tour.html

从小部件框(小部件调色板)中拖动三个按钮到中心MainWindow的框架中。这些按钮上显示的默认文本是PushButton,但你可以通过双击按钮来更改文本。在这种情况下,我将按钮改为HelloHolaBonjour。请注意,此操作不会影响objectName属性。为了保持整洁且易于查找,我们需要更改objectName属性。UI 的右侧包含两个窗口。右上部分包括对象检查器,而右下部分包括属性编辑器。只需选择一个按钮;你可以在属性编辑器中轻松更改objectName。为了方便起见,我将这些按钮的objectName属性分别更改为helloButtonholaButtonbonjourButton

提示

使用小写字母作为objectName的第一个字母,大写字母作为类名是一个好习惯。这有助于使你的代码对熟悉此约定的人更易读。

好了,是时候看看你对你的第一个 Qt 应用程序的用户界面做了什么。在左侧面板上点击运行。它将自动构建项目然后运行。看到应用程序与设计完全相同的界面,是不是很神奇?如果一切正常,应用程序应该看起来与以下截图所示相似:

创建新项目

你可能想查看源代码看看那里发生了什么。所以,让我们通过返回到编辑模式来回到源代码。在模式选择器中点击编辑按钮。然后,在项目树视图的文件夹中双击main.cppmain.cpp的代码如下所示:

#include "mainwindow.h"
#include <QApplication>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    MainWindow w;
    w.show();

    return a.exec();
}

注意

QApplication类管理 GUI 应用程序的控制流和主要设置。

实际上,你不需要也不太可能在这个文件中做太多改动。主作用域的第一行只是初始化用户桌面上的应用程序并处理一些事件。然后还有一个对象w,它属于MainWindow类。至于最后一行,它确保应用程序在执行后不会终止,而是保持在一个事件循环中,以便能够响应外部事件,如鼠标点击和窗口状态变化。

最后但同样重要的是,让我们看看在MainWindow对象初始化过程中会发生什么,w是这个内容,如下所示:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>

namespace Ui {
    class MainWindow;
}

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = 0);
    ~MainWindow();

private:
    Ui::MainWindow *ui;
};

#endif // MAINWINDOW_H

如果你第一次编写 Qt 应用程序,看到Q_OBJECT宏可能会让你感到有些惊讶。在 QObject 文档中,它说:

Q_OBJECT宏必须出现在声明其自己的信号和槽或使用 Qt 元对象系统提供的其他服务的类定义的私有部分中。

好吧,这意味着如果你打算使用 Qt 的元对象系统以及(或)其信号和槽机制,就必须声明 QObject。信号和槽,几乎是 Qt 的核心,将在本章后面进行介绍。

有一个名为 ui 的私有成员,它是 Ui 命名空间中 MainWindow 类的指针。你还记得我们之前编辑的 UI 文件吗?Qt 的魔法在于它将 UI 文件和父源代码链接起来。我们可以通过代码行来操作 UI,也可以在 Qt Creator 的集成设计器中设计它。最后,让我们看看 mainwindow.cppMainWindow 的构造函数:

#include "mainwindow.h"
#include "ui_mainwindow.h"

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::MainWindow)
{
    ui->setupUi(this);
}

MainWindow::~MainWindow()
{
    delete ui;
}

你看到用户界面是从哪里来的了吗?它是 Ui::MainWindow 的成员函数 setupUi,它初始化并为我们设置它。你可能想检查如果我们把成员函数改为类似这样会发生什么:

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::MainWindow)
{
    ui->setupUi(this);
    ui->holaButton->setEnabled(false);
}

这里发生了什么?由于我们禁用了它,所以无法点击 Hola 按钮!如果在设计器中取消勾选 启用 复选框而不是在这里编写语句,也会有相同的效果。请在进入下一个主题之前应用此更改,因为我们不需要禁用的按钮在本章中进行任何演示。

更改小部件的布局

你已经知道如何在 设计 模式下添加和移动小部件。现在,我们需要使 UI 整洁有序。我会一步步地教你如何做。

删除小部件的一个快捷方法是选择它并按 Delete 按钮。同时,一些小部件,如菜单栏、状态栏和工具栏,不能被选择,因此我们必须在 对象检查器 中右键点击它们并删除它们。由于它们在这个例子中无用,所以安全地移除它们,我们可以永久地这样做。

好的,让我们了解在移除之后需要做什么。你可能希望将这些按钮都保持在同一水平轴上。为此,执行以下步骤:

  1. 通过逐个点击按钮同时按住 Ctrl 键或绘制一个包含所有按钮的包围矩形来选择所有按钮。

  2. 右键点击并选择 布局 | 水平布局,此操作的快捷键是 Ctrl + H

  3. 通过选择并拖动选择框周围的任何一点来调整水平布局的大小,直到它最适合。

嗯…!你可能已经注意到Bonjour按钮的文本比其他两个按钮长,它应该比其他按钮更宽。你该如何做到这一点?你可以在属性编辑器中更改水平布局对象的layoutStretch属性。此值表示水平布局内小部件的拉伸因子。它们将按比例排列。将其更改为3,3,4,就是这样。拉伸的大小肯定不会小于最小尺寸提示。这就是当存在非零自然数时零因子的作用,这意味着你需要保持最小尺寸,而不是因为零除数而出现错误。

现在,将纯文本编辑拖动到水平布局的下方,而不是内部。显然,如果我们能扩展纯文本编辑的宽度,会看起来更整洁。然而,我们不必手动这样做。实际上,我们可以更改父窗口的布局,即MainWindow。就是这样!右键点击MainWindow,然后导航到布局 | 垂直布局。哇!所有子小部件都会自动扩展到MainWindow的内边界;它们保持垂直顺序。你也会在centralWidget属性中找到布局设置,这与之前的水平布局完全相同。

使这个应用程序变得半 decent 的最后一件事是更改窗口的标题。"MainWindow"不是你想要的标题,对吧?在对象树中点击MainWindow。然后,滚动其属性以找到windowTitle。给它起个你想的名字。在这个例子中,我将其更改为Greeting。现在,再次运行应用程序,你将看到它看起来就像以下截图所示:

更改小部件布局

理解信号和槽的机制

保持好奇心并探索这些属性究竟有什么作用,这一点非常重要。然而,请记住恢复你对应用程序所做的更改,因为我们即将进入 Qt 的核心部分,即信号和槽。

注意

信号和槽用于对象之间的通信。信号和槽机制是 Qt 的核心特性,可能是与其他框架提供的特性差异最大的部分。

您是否曾经想过为什么在点击关闭按钮后窗口会关闭?熟悉其他工具包的开发者会说,点击关闭按钮是一个事件,这个事件绑定了一个回调函数,该函数负责关闭窗口。然而,在 Qt 的世界中,情况并不完全相同。由于 Qt 使用名为信号和槽的机制,它使得回调函数与事件之间的耦合变得较弱。此外,我们通常在 Qt 中使用信号和槽这两个术语。当特定事件发生时发出信号。槽是响应特定信号而被调用的函数。以下简单且示意图有助于您理解信号、事件和槽之间的关系:

理解信号和槽的机制

Qt 有大量的预定义信号和槽,涵盖了其通用目的。然而,添加自己的槽来处理目标信号确实是常见的做法。您可能还对子类化小部件并编写自己的信号感兴趣,这将在稍后介绍。由于信号和槽机制要求具有相同参数的列表,因此它被设计为类型安全的。实际上,槽可以比信号具有更短的参数列表,因为它可以忽略额外的参数。您可以拥有尽可能多的参数。这使得您可以在 C 和其他工具包中忘记通配符 void* 类型。

自从 Qt 5 以来,这种机制变得更加安全,因为我们可以使用新的信号和槽语法来处理连接。这里演示了一段代码的转换。让我们看看旧式风格中典型的连接语句:

connect(sender, SIGNAL(textChanged(QString)), receiver, SLOT(updateText(QString)));

这可以用新的语法风格重写:

connect(sender, &Sender::textChanged, receiver, &Receiver::updateText);

在传统的代码编写方式中,信号和槽的验证仅在运行时发生。在新风格中,编译器可以在编译时检测参数类型的不匹配以及信号和槽的存在。

注意

只要可能,本书中的所有 connect 语句都使用新的语法风格编写。

现在,让我们回到我们的应用程序。我将向您展示如何在点击Hello按钮时在纯文本编辑器中显示一些文字。首先,我们需要创建一个槽,因为 Qt 已经为 QPushButton 类预定义了点击信号。编辑 mainwindow.h 并添加槽声明:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>

namespace Ui {
    class MainWindow;
}

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = 0);
    ~MainWindow();

private slots:
    void displayHello();

private:
    Ui::MainWindow *ui;
};

#endif // MAINWINDOW_H

如您所见,是 slots 关键字将槽与普通函数区分开来。我将其声明为私有以限制访问权限。如果您需要在其他类的对象中调用它,必须将其声明为 public 槽。在此声明之后,我们必须在 mainwindow.cpp 文件中实现它。displayHello 槽的实现如下:

void MainWindow::displayHello()
{
    ui->plainTextEdit->appendPlainText(QString("Hello"));
}

它只是调用纯文本编辑的一个成员函数,以便向其中添加一个Hello QString。QString是 Qt 引入的一个核心类。它提供了一个 Unicode 字符字符串,有效地解决了国际化问题。它也方便地将QString类转换为std::string,反之亦然。此外,就像其他QObject类一样,QString使用隐式共享机制来减少内存使用并避免不必要的复制。如果你不想关心以下代码中显示的场景,只需将QString视为std::string的改进版本。现在,我们需要将这个槽连接到Hello按钮将发出的信号:

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::MainWindow)
{
    ui->setupUi(this);

    connect(ui->helloButton, &QPushButton::clicked, this, &MainWindow::displayHello);
}

我所做的是在MainWindow的构造函数中添加了一个connect语句。实际上,我们可以在任何地方和任何时候连接信号和槽。然而,连接只有在执行这一行之后才会存在。因此,在构造函数中放置大量的connect语句是一种常见的做法。为了更好地理解,运行你的应用程序并看看点击Hello按钮时会发生什么。每次点击,都会在纯文本编辑中追加一个Hello文本。以下是在我们点击了Hello按钮三次之后的截图:

理解信号和槽的机制

感到困惑?让我带你一步步走过这个过程。当你点击Hello按钮时,它发出了一个点击信号。然后,displayHello槽中的代码被执行,因为我们把Hello按钮的点击信号连接到了MainWindowdisplayHello槽。displayHello槽所做的是简单地将Hello追加到纯文本编辑中。

完全理解信号和槽的机制可能需要一些时间。请慢慢来。在我们点击了Hola按钮之后,我会给你展示一个如何断开这种连接的例子。同样,将槽的声明添加到头文件中,并在源文件中定义它。我已经粘贴了mainwindow.h头文件的内容,如下所示:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>

namespace Ui {
    class MainWindow;
}

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = 0);
    ~MainWindow();

private slots:
    void displayHello();
    void onHolaClicked();

private:
    Ui::MainWindow *ui;
};

#endif // MAINWINDOW_H

它只是声明了一个与原始版本不同的onHolaClicked槽。以下是源文件的内容:

#include "mainwindow.h"
#include "ui_mainwindow.h"

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::MainWindow)
{
    ui->setupUi(this);

    connect(ui->helloButton, &QPushButton::clicked, this, &MainWindow::displayHello);
    connect(ui->holaButton, &QPushButton::clicked, this, &MainWindow::onHolaClicked);
}

MainWindow::~MainWindow()
{
    delete ui;
}

void MainWindow::displayHello()
{
    ui->plainTextEdit->appendPlainText(QString("Hello"));
}

void MainWindow::onHolaClicked()
{
    ui->plainTextEdit->appendPlainText(QString("Hola"));
    disconnect(ui->helloButton, &QPushButton::clicked, this, &MainWindow::displayHello);
}

你会发现点击了Hola按钮之后,Hello按钮不再工作。这是因为在我们点击了onHolaClicked槽之后,我们只是断开了helloButton的点击信号和MainWindowdisplayHello槽之间的绑定。实际上,disconnect有一些重载函数,可以用更破坏性的方式使用。例如,你可能想要断开特定信号发送者和特定接收者之间的所有连接:

disconnect(ui->helloButton, 0, this, 0);

如果你想要断开与一个信号相关联的所有槽,因为一个信号可以连接到任意多个槽,代码可以写成这样:

disconnect(ui->helloButton, &QPushButton::clicked, 0, 0);

我们还可以断开一个对象中的所有信号,无论它们连接到哪个槽。以下代码将断开helloButton中的所有信号,当然包括点击信号:

disconnect(ui->helloButton, 0, 0, 0);

就像信号一样,槽可以连接到任意多的信号。然而,没有这样的函数可以从所有信号中断开特定槽的连接。

小贴士

总是记住你连接的信号和槽。

除了传统的信号和槽连接的新语法之外,Qt 5 还提供了一种使用 C++11 lambda 表达式简化这种绑定过程的新方法。正如你可能已经注意到的,在头文件中声明槽、在源代码文件中定义它,然后将其连接到信号,这有点繁琐。如果槽有很多语句,这很值得,否则它会变得耗时并增加复杂性。在我们继续之前,我们需要在 Qt 上打开 C++11 支持。编辑 pro 文件(我的例子中的layout_demo.pro),并向其中添加以下行:

CONFIG += c++11

注意

注意,一些旧的编译器不支持 C++11。如果发生这种情况,请升级您的编译器。

现在,你需要导航到构建 | 运行 qmake来正确地重新配置项目。如果一切正常,我们可以回到编辑mainwindow.cpp文件。这样,就没有必要声明槽并定义和连接它。只需向MainWindow的构造函数中添加一个connect语句即可:

connect(ui->bonjourButton, &QPushButton::clicked, [this](){
    ui->plainTextEdit->appendPlainText(QString("Bonjour"));
});

这非常直接,不是吗?第三个参数是一个 lambda 表达式,它自 C++11 以来被添加到 C++中。

注意

关于 lambda 表达式的更多详细信息,请访问en.cppreference.com/w/cpp/language/lambda

如果你不需要断开这样的连接,则会执行这对信号和槽的连接。但是,如果你需要,你必须保存这个连接,它是一个QMetaObject::Connection类型的对象。为了在别处断开这个连接,最好将其声明为MainWindow变量的一个变量。因此,头文件变为以下内容:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>

namespace Ui {
    class MainWindow;
}

class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    explicit MainWindow(QWidget *parent = 0);
    ~MainWindow();

private slots:
    void displayHello();
    void onHolaClicked();

private:
    Ui::MainWindow *ui;
    QMetaObject::Connection bonjourConnection;
};

#endif // MAINWINDOW_H

在这里,我将bonjourConnection声明为QMetaObject::Connection对象,这样我们就可以保存处理未命名槽的连接。同样,断开连接发生在onHolaClicked中,因此在我们点击Hola按钮后,屏幕上不会出现任何新的Bonjour文本。以下是mainwindow.cpp的内容:

#include "mainwindow.h"
#include "ui_mainwindow.h"

MainWindow::MainWindow(QWidget *parent) :
    QMainWindow(parent),
    ui(new Ui::MainWindow)
{
    ui->setupUi(this);

    connect(ui->helloButton, &QPushButton::clicked, this, &MainWindow::displayHello);
    connect(ui->holaButton, &QPushButton::clicked, this, &MainWindow::onHolaClicked);
    bonjourConnection = connect(ui->bonjourButton, &QPushButton::clicked, [this](){
        ui->plainTextEdit->appendPlainText(QString("Bonjour"));
    });
}

MainWindow::~MainWindow()
{
    delete ui;
}

void MainWindow::displayHello()
{
    ui->plainTextEdit->appendPlainText(QString("Hello"));
}

void MainWindow::onHolaClicked()
{
    ui->plainTextEdit->appendPlainText(QString("Hola"));
    disconnect(ui->helloButton, &QPushButton::clicked, this, &MainWindow::displayHello);
    disconnect(bonjourConnection);
}

小贴士

下载示例代码

您可以从您在www.packtpub.com的账户中下载示例代码文件,以获取您购买的所有 Packt Publishing 书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

这确实是disconnect的另一种新用法。它只接受一个QMetaObject::Connection对象作为参数。如果你打算将 lambda 表达式用作槽,你会感谢这个新重载函数。

连接两个信号

由于 Qt 信号和槽机制的弱耦合,将信号绑定到彼此是可行的。这听起来可能有些令人困惑,所以让我画一个图表来使其更清晰:

连接两个信号

当一个事件触发一个特定信号时,这个发出的信号可能是一个事件,它将发出另一个特定信号。这并不是一个非常常见的做法,但当您处理一些复杂的信号和槽连接网络时,它往往很有用,尤其是当大量事件导致仅发出几个信号时。尽管这肯定会增加项目的复杂性,但绑定这些信号可以大大简化代码。将以下语句添加到 MainWindow 的构造函数中:

connect(ui->bonjourButton, &QPushButton::clicked, ui->helloButton, &QPushButton::clicked);

在您点击 Bonjour 按钮后,您将在纯文本编辑器中看到两行。第一行是 Bonjour,第二行是 Hello。显然,这是因为我们将 Bonjour 按钮的点击信号与 Hello 按钮的点击信号耦合起来。后者的点击信号已经与一个槽耦合,这导致了新的文本行 Hello。实际上,它具有与以下语句相同的效果:

connect(ui->bonjourButton, &QPushButton::clicked, [this](){
    emit ui->helloButton->clicked();
});

基本上,连接两个信号是连接信号和槽的简化版本,而槽的目的是发出另一个信号。至于优先级,后一个信号的槽将在事件循环返回到对象时被处理。

然而,由于机制要求一个信号,而槽被看作是接收者而不是发送者,因此无法连接两个槽。因此,如果您想简化连接,只需将这些槽封装为一个槽,它可以用于连接。

创建一个 Qt Quick 应用程序

我们已经介绍了如何创建一个 Qt (C++) 应用程序。那么,尝试一下新引入的 Qt Quick 应用程序开发如何?Qt Quick 自 Qt 4.8 以来被引入,现在在 Qt 5 中已经变得成熟。由于 QML 文件通常是平台无关的,它使您能够使用相同的代码为多个目标开发应用程序,包括移动操作系统。

在本章中,我将向您展示如何创建一个基于 Qt Quick Controls 1.2 的简单 Qt Quick 应用程序,具体如下:

  1. 创建一个名为 HelloQML 的新项目。

  2. 选择 Qt Quick Application 而不是我们之前选择的 Qt Widgets Application

  3. 当向导引导您到 选择 Qt Quick 组件集 时,选择 Qt Quick Controls 1.2

Qt Quick Controls 自 Qt 5.1 以来已被引入,并且强烈推荐使用,因为它使您能够构建一个完整且本地的用户界面。您还可以从 QML 控制顶级窗口属性。对 QML 和 Qt Quick 感到困惑?

注意

QML 是一种用户界面规范和编程语言。它允许开发者和设计师创建高性能、流畅动画和视觉吸引力的应用程序。QML 提供了一种高度可读的、声明性的、类似 JSON 的语法,并支持命令式 JavaScript 表达式与动态属性绑定的结合。

虽然 Qt Quick 是 QML 的标准库,但它听起来与 STL 和 C++ 之间的关系相似。不同之处在于 QML 专注于用户界面设计,Qt Quick 包含了许多视觉类型、动画等功能。在我们继续之前,我想通知您,QML 与 C++ 不同,但与 JavaScript 和 JSON 相似。

编辑位于 Resources 文件根目录下的 main.qml 文件,qml.qrc,这是 Qt Creator 为我们新的 Qt Quick 项目生成的。让我们看看代码应该如何编写:

import QtQuick 2.3
import QtQuick.Controls 1.2

ApplicationWindow {
    visible: true
    width: 640
    height: 480
    title: qsTr("Hello QML")

    menuBar: MenuBar {
        Menu {
            title: qsTr("File")
            MenuItem {
                text: qsTr("Exit")
                shortcut: "Ctrl+Q"
                onTriggered: Qt.quit()
            }
        }
    }

    Text {
        id: hw
        text: qsTr("Hello World")
        font.capitalization: Font.AllUppercase
        anchors.centerIn: parent
    }

    Label {
        anchors { bottom: hw.top; bottomMargin: 5; horizontalCenter: hw.horizontalCenter }
        text: qsTr("Hello Qt Quick")
    }
}

如果您曾经接触过 Java 或 Python,前两行对您来说不会太陌生。它只是简单地导入 Qt Quick 和 Qt Quick Controls,后面的数字是库的版本号。如果您有更新的库,可能需要更改版本。在开发 Qt Quick 应用程序时,导入其他库是一种常见做法。

这个 QML 源文件的主体实际上采用了 JSON 风格,这使得您可以通过代码理解用户界面的层次结构。在这里,根项是 ApplicationWindow,这基本上与前面主题中的 MainWindow 相同,我们使用大括号来包围语句,就像在 JSON 文件中一样。虽然您可以使用分号来标记语句的结束,就像我们在 C++ 中做的那样,但这样做是没有必要的。正如您所看到的,如果属性定义是单行语句,则需要冒号;如果包含多个子属性,则需要大括号。

这些语句相当自解释,并且与我们在 Qt Widgets 应用程序中看到的属性相似。qsTr 函数用于国际化本地化。被 qsTr 标记的字符串可以被 Qt Linguist 翻译。除此之外,您再也不需要关心 QStringstd::string 了。QML 中的所有字符串都使用与 QML 文件相同的编码,并且 QML 文件默认以 UTF-8 编码创建。

关于 Qt Quick 中的信号和槽机制,如果您只使用 QML 编写回调函数到相应的槽,那么它很容易。在这里,我们在 MenuItemonTriggered 槽中执行 Qt.quit()。将 QML 项的信号连接到 C++ 对象的槽是可行的,我将在后面介绍。

当你在 Windows 上运行此应用程序时,你几乎找不到 Text 项和 Label 项之间的区别。然而,在某些平台或更改系统字体及其颜色时,你会发现 Label 会遵循系统的字体和颜色方案,而 Text 则不会。虽然你可以使用 Text 的属性来自定义 Label 的外观,但使用系统设置以保持应用程序的本地外观会更好。好吧,如果你现在运行这个应用程序,它将看起来与以下截图所示相似:

创建 Qt Quick 应用程序

由于 Qt Quick 应用程序没有单独的 UI 文件,只有一个 QML 文件,我们使用 anchors 属性来定位项目,anchors.centerIn 将项目定位在父级的中心。Qt Creator 中有一个集成的 Qt Quick 设计器,可以帮助你设计 Qt Quick 应用的用户界面。如果你需要它,只需在编辑 QML 文件时导航到 设计 模式。然而,我建议你保持在 编辑 模式下,以便理解每个语句的含义。

连接 C++ 插槽到 QML 信号

用户界面和后端的分离使我们能够将 C++ 插槽连接到 QML 信号。虽然可以在 QML 中编写处理函数并在 C++ 中操作界面元素,但这违反了分离原则。因此,你可能首先想知道如何将 C++ 插槽连接到 QML 信号。至于将 QML 插槽连接到 C++ 信号,我将在本书的后面介绍。

为了演示这一点,我们首先需要在 项目 面板中右键单击项目,并选择 添加新…。然后在弹出的窗口中点击 C++ 类。新创建的类至少应该通过选择 QObject 作为其基类来继承 QObject。这是因为一个普通的 C++ 类不能包含 Qt 的插槽或信号。头文件的内容如下所示:

#ifndef PROCESSOR_H
#define PROCESSOR_H

#include <QObject>

class Processor : public QObject
{
    Q_OBJECT
public:
    explicit Processor(QObject *parent = 0);

public slots:
    void onMenuClicked(const QString &);
};

#endif // PROCESSOR_H

这是源文件的内容:

#include <QDebug>
#include "processor.h"

Processor::Processor(QObject *parent) :
    QObject(parent)
{
}

void Processor::onMenuClicked(const QString &str)
{
    qDebug() << str;
}

C++ 文件与我们在前几节中处理的是同一个。我定义的 onMenuClicked 插槽仅仅是为了输出通过信号的字符串。请注意,如果你想使用 qDebugqWarningqCritical 等内置函数,你必须包含 QDebug

插槽已经准备好了,因此我们需要在 QML 文件中添加一个信号。QML 文件修改为以下代码:

import QtQuick 2.3
import QtQuick.Controls 1.2

ApplicationWindow {
    id: window
    visible: true
    width: 640
    height: 480
    title: qsTr("Hello QML")
    signal menuClicked(string str)

    menuBar: MenuBar {
        Menu {
            title: qsTr("File")
            MenuItem {
                text: qsTr("Exit")
                shortcut: "Ctrl+Q"
                onTriggered: Qt.quit()
            }
            MenuItem {
                text: qsTr("Click Me")
                onTriggered: window.menuClicked(text)
            }
        }
    }

    Text {
        id: hw
        text: qsTr("Hello World")
        font.capitalization: Font.AllUppercase
        anchors.centerIn: parent
    }

    Label {
        anchors { bottom: hw.top; bottomMargin: 5; horizontalCenter: hw.horizontalCenter }
        text: qsTr("Hello Qt Quick")
    }
}

如你所见,我指定了根 ApplicationWindow 项的 ID 为窗口,并声明了一个名为 menuClicked 的信号。除此之外,菜单文件中还有一个 MenuItem。它使用其文本作为参数,发出窗口的 menuClicked 信号。

现在,让我们将 C++ 文件中的插槽连接到这个新创建的 QML 信号。编辑 main.cpp 文件。

#include <QApplication>
#include <QQmlApplicationEngine>
#include "processor.h"

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

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

    QObject *firstRootItem = engine.rootObjects().first();
    Processor myProcessor;
    QObject::connect(firstRootItem, SIGNAL(menuClicked(QString)), &myProcessor, SLOT(onMenuClicked(QString)));

    return app.exec();
}

在 QML 文件中的项以 QObject 的形式在 C++ 中访问,并且它可以被转换为 QQuickItem。目前,我们只需要连接其信号,所以 QObject 就足够了。

你可能会注意到我使用了 connect 语句的老式语法。这是因为 QML 是动态的,C++ 编译器无法检测 QML 文件中信号的存在。由于 QML 中的事物是在运行时检查的,所以在这里使用老式语法是没有意义的。

当你运行此应用程序并导航到菜单栏中的 文件 | 点击我 时,你将在 Qt Creator 中看到 应用程序输出

"Click Me"

让我们再次回顾这个过程。触发 点击我 菜单项导致窗口的信号 menuClicked 被发射。这个信号将 MenuItem 的文本(点击我)传递给 C++ 类 Processor 中的槽,处理器 myProcessor 的槽 onMenuClicked 将字符串打印到 应用程序输出 面板。

摘要

在本章中,我们学习了 Qt 的基础知识,包括创建 Qt 应用程序的步骤。然后,我们了解了 Qt Widgets 和 Qt Quick 的使用,以及如何更改布局。最后,我们通过介绍关于信号和槽机制的重要概念来结束本章。

在下一章中,我们将有机会将所学知识付诸实践,并开始构建一个真实世界、当然也是跨平台的 Qt 应用程序。

第二章. 构建一个美观的跨平台时钟

在本章中,你将了解到 Qt 是构建跨平台应用程序的伟大工具。这里使用 Qt/C++ 时钟示例作为演示。本章涵盖的主题,如以下列出,对于任何实际应用都是必不可少的。以下是具体内容:

  • 创建基本数字时钟

  • 调整数字时钟

  • 保存和恢复设置

  • 在 Unix 平台上构建

创建基本数字时钟

是时候创建一个新项目了,因此我们将创建一个名为 Fancy_Clock 的 Qt Widgets 应用程序。

注意

在本章中,我们不会使用任何 Qt Quick 知识。

现在,将窗口标题更改为 Fancy Clock 或你喜欢的任何其他名称。然后,需要调整主窗口 UI,因为时钟显示在桌面顶部。菜单栏、状态栏和工具栏都被移除。之后,我们需要将一个 LCD Number 小部件拖入 centralWidget。接下来,将 MainWindow 的布局更改为 水平布局 以自动调整子小部件的大小。对 UI 文件进行的最后修改是在 QFrame 列的属性下将 frameShape 更改为 NoFrame。如果你做得正确,你将得到一个数字时钟的原型,如图所示:

创建基本数字时钟

为了重复更新 LCD 数字显示,我们必须使用 QTimer 类设置一个重复发出信号的计时器。除此之外,我们还需要创建一个槽来接收信号并更新 LCD 数字显示到当前时间。因此,也需要 QTime 类。这就是 MainWindowmainwindow.h 的头文件现在看起来是这样的:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>

namespace Ui {
  class MainWindow;
}

class MainWindow : public QMainWindow
{
  Q_OBJECT

public:
  explicit MainWindow(QWidget *parent = 0);
  ~MainWindow();

private:
  Ui::MainWindow *ui;

private slots:
  void updateTime();
};

#endif // MAINWINDOW_H

如你所见,这里所做的唯一修改是声明了一个私有的 updateTime 槽。像往常一样,我们应在 mainwindow.cpp 中定义此槽,其内容如下。请注意,我们需要包含 QTimerQTime

#include <QTimer>
#include <QTime>
#include "mainwindow.h"
#include "ui_mainwindow.h"

MainWindow::MainWindow(QWidget *parent) :
  QMainWindow(parent),
  ui(new Ui::MainWindow)
{
  ui->setupUi(this);

  QTimer *timer = new QTimer(this);
  connect(timer, &QTimer::timeout, this, &MainWindow::updateTime);
  timer->start(1000);

  updateTime();
}

MainWindow::~MainWindow()
{
  delete ui;
}

void MainWindow::updateTime()
{
  QTime currentTime = QTime::currentTime();
  QString currentTimeText = currentTime.toString("hh:mm");
  if (currentTime.second() % 2 == 0) {
    currentTimeText[2] = ' ';
  }
  ui->lcdNumber->display(currentTimeText);
}

updateTime 槽内部,使用 QTime 类来处理时间,即时钟。如果底层操作系统支持,此类可以提供高达 1 毫秒的精度。然而,QTime 与时区或夏令时无关。至少,这对我们的小时钟来说是足够的。currentTime() 函数是一个静态公共函数,用于创建一个包含系统本地时间的 QTime 对象。

至于 updateTime 函数的第二行,我们使用了 QTime 提供的 toString 函数将时间转换为字符串,并将其保存在 currentTimeText 中。传递给 toString 的参数是时间字符串的格式。完整的表达式列表可以从Qt 参考文档中获取。时钟中间的冒号应该闪烁,就像真实数字时钟一样。因此,我们使用了一个 if 语句来控制这一点。当秒的值是偶数时,冒号将消失,当秒的值是奇数时,它将重新出现。在这里,在 if 块内部,我们使用了 [2] 操作符来获取第三个字符的可修改引用,因为这是在字符串内部直接修改字符的唯一方法。在这里,currentTimeText 字符串的计数从 0 开始。同时,QStringat() 函数返回一个常量字符,你无权更改它。最后,这个函数将让 lcdNumber 显示时间字符串。现在,让我们回到 MainWindow 的构造函数。在初始化 UI 之后,它首先做的事情是创建一个 QTimer 对象。为什么我们不能使用局部变量?这个问题的答案是,因为局部变量将在 MainWindow 构造之后被销毁。如果定时器已经消失,就没有办法重复触发 updateTime。我们不使用成员变量,因为没有必要在头文件中进行声明工作,因为我们不会在其他地方使用这个定时器。

QTimer 类用于创建重复和单次定时器。在调用 start 后,它将在恒定的时间间隔后发出 timeout 信号。在这里,我们创建了一个定时器,并将 timeout 信号连接到 updateTime 插槽,以便每秒钟调用 updateTime

在 Qt 中还有一个重要的方面,称为父子机制。尽管它不如信号和槽那么知名,但在 Qt 应用程序的开发中起着至关重要的作用。基本上说,当我们创建一个带有父对象或通过调用 setParent 显式设置父对象的 QObject 子对象时,父对象会将这个 QObject 子对象添加到其子对象列表中。然后,当父对象被删除时,它会遍历其子对象列表并删除每个子对象。在大多数情况下,尤其是在 UI 设计中,父子关系是隐式设置的。父小部件或布局自动成为其子小部件或布局的父对象。在其他情况下,我们必须显式设置 QObject 子对象的父对象,以便父对象可以接管其所有权并管理其内存释放。因此,我们将 QObject 父对象,即这个 MainWindow 类,传递给 QTimer 构造函数。这确保了在 MainWindow 被删除后,QTimer 也会被删除。这就是为什么我们不需要在析构函数中显式编写 delete 语句的原因。

在构造函数的末尾,我们需要显式调用updateTime,这将允许时钟显示当前时间。如果我们不这样做,应用程序将显示一个零秒,直到timer发出timeout信号。现在,运行你的应用程序;它将类似于以下截图:

创建基本的数字时钟

调整数字时钟

是时候让这个基本的数字时钟看起来更漂亮了。让我们添加一些像透明背景这样的东西,它位于无框窗口的顶部。使用透明背景可以产生惊人的视觉效果。当无框窗口隐藏窗口装饰,包括边框和标题栏时,桌面小部件,如时钟,应该是无边框的,并显示在桌面顶部。

要使我们的时钟透明,只需将以下行添加到MainWindow的构造函数中:

setAttribute(Qt::WA_TranslucentBackground);

WA_TranslucentBackground属性的效果取决于 X11 平台上的合成管理器。

小部件可能有大量的属性,这个函数用于打开或关闭指定的属性。默认情况下是开启的。你需要传递一个假的布尔值作为第二个参数来禁用属性。Qt::WidgetAttribute的完整列表可以在 Qt 参考文档中找到。

现在,将以下行添加到构造函数中,这将使时钟看起来无边框,并使其保持在桌面顶部:

setWindowFlags(Qt::WindowStaysOnTopHint | Qt::FramelessWindowHint);

类似地,Qt::WindowFlags用于定义小部件的类型。它控制小部件的行为,而不是其属性。因此,给出了两个提示:一个是保持在顶部,另一个是无边框。如果你想保留旧标志同时设置新标志,你需要将它们添加到组合中。

setWindowFlags(Qt::WindowStaysOnTopHint | Qt::FramelessWindowHint | windowFlags());

在这里,windowFlags函数用于检索窗口标志。你可能感兴趣的一件事是,setWindowFlags将在show函数之后导致小部件不可见。所以,你可以在窗口或小部件的show函数之前调用setWindowFlags,或者调用show后再调用setWindowFlags

在修改构造函数后,时钟应该看起来是这样的:

调整数字时钟

有一个有用的技巧,你可以用它来隐藏时钟从任务栏中。当然,时钟不需要在任务栏中的应用程序中显示。你不应该单独设置一个像Qt::ToolQt::ToolTip这样的标志来达到这个目的,因为这会导致应用程序的退出行为异常。这个技巧甚至更简单;下面是main.cpp的代码:

#include "mainwindow.h"
#include <QApplication>

int main(int argc, char *argv[])
{
  QApplication a(argc, argv);

  QWidget wid;
  MainWindow w(&wid);
  w.show();

  return a.exec();
}

上述代码使我们的MainWindow w对象成为QWidget wid的子对象。子小部件不会显示在任务栏上,因为应该只有一个顶级父小部件。同时,我们的父小部件wid甚至不会显示。这很棘手,但这是唯一一个在不破坏任何其他逻辑的情况下做到这一点的方法。

嗯,一个新的问题刚刚出现。时钟无法移动,唯一的关闭方式是通过 Qt Creator 的面板或通过键盘快捷键停止它。这是因为我们将其声明为无边框窗口,导致无法通过窗口管理器控制它。由于无法与之交互,因此无法自行关闭。因此,解决这个问题的方法是编写我们自己的函数来移动和关闭时钟。

关闭此应用程序可能更为紧急。让我们看看如何重新实现一些功能以达到这个目标。首先,我们需要声明一个新的 showContextMenu 槽来显示上下文菜单,并重新实现 mouseReleaseEvent。以下代码展示了 mainwindow.h 的内容:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>

namespace Ui {
  class MainWindow;
}

class MainWindow : public QMainWindow
{
  Q_OBJECT
public:
  explicit MainWindow(QWidget *parent = 0);
  ~MainWindow();

private:
  Ui::MainWindow *ui;

private slots:
  void updateTime();
  void showContextMenu(const QPoint &pos);

protected:
  void mouseReleaseEvent(QMouseEvent *);
};

#endif // MAINWINDOW_H

在前面的代码中定义了两个新的类:QPointQMouseEventQPoint 类通过使用整数精度定义平面上的一个点。相对地,还有一个名为 QPointF 的类,它提供浮点精度。嗯,QMouseEvent 类继承自 QEventQInputEvent。它包含一些描述鼠标事件的参数。让我们看看为什么在 mainwindow.cpp 中需要它们:

#include <QTimer>
#include <QTime>
#include <QMouseEvent>
#include <QMenu>
#include <QAction>
#include "mainwindow.h"
#include "ui_mainwindow.h"

MainWindow::MainWindow(QWidget *parent) :
  QMainWindow(parent),
  ui(new Ui::MainWindow)
{
  ui->setupUi(this);

  setAttribute(Qt::WA_TranslucentBackground);
  setWindowFlags(Qt::WindowStaysOnTopHint | Qt::FramelessWindowHint | windowFlags());

  connect(this, &MainWindow::customContextMenuRequested, this, &MainWindow::showContextMenu);

  QTimer *timer = new QTimer(this);
  connect(timer, &QTimer::timeout, this, &MainWindow::updateTime);
  timer->start(1000);

  updateTime();
}

MainWindow::~MainWindow()
{
  delete ui;
}

void MainWindow::updateTime()
{
  QTime currentTime = QTime::currentTime();
  QString currentTimeText = currentTime.toString("hh:mm");
  if (currentTime.second() % 2 == 0) {
    currentTimeText[2] = ' ';
  }
  ui->lcdNumber->display(currentTimeText);
}

void MainWindow::showContextMenu(const QPoint &pos)
{
  QMenu contextMenu;
  contextMenu.addAction(QString("Exit"), this, SLOT(close()));
  contextMenu.exec(mapToGlobal(pos));
}

void MainWindow::mouseReleaseEvent(QMouseEvent *e)
{
  if (e->button() == Qt::RightButton) {
    emit customContextMenuRequested(e->pos());
  }
  else {
    QMainWindow::mouseReleaseEvent(e);
  }
}

注意,你应该包含 QMouseEventQMenuQAction 以利用这些类。有一个预定义的 customContextMenuRequested 信号,它与新创建的 showContextMenu 槽相关联。为了保持一致性,我们将遵循 Qt 定义的规则,这意味着 customContextMenuRequested 中的 QPoint 参数应该是一个局部位置而不是全局位置。这就是为什么我们需要一个 mapToGlobal 函数将 pos 转换为全局位置。至于 QMenu 类,它提供了一个菜单栏、上下文菜单或其他弹出菜单的 menu 小部件。因此,我们创建了 contextMenu 对象,然后添加一个带有 Exit 文本的新的操作。这与 MainWindowclose 槽相关联。最后的语句用于在指定的全局位置执行 contextMenu 对象。换句话说,这个槽将在给定位置显示一个弹出菜单。

重新实现 mouseReleaseEvent 的目的是检查事件触发按钮。如果是右键,则使用鼠标的局部位置发出 customContextMenuRequested 信号。否则,简单地调用 QMainWindow 的默认 mouseReleaseEvent 函数。

在重新实现它时,利用基类的默认成员函数。

再次运行应用程序;你可以通过右键单击它并选择退出来退出。现在,我们应该继续重新实现,使时钟可移动。这次,我们需要重写两个受保护的函数:mousePressEventmouseMoveEvent。因此,这是头文件的外观:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>

namespace Ui {
  class MainWindow;
}

class MainWindow : public QMainWindow
{
  Q_OBJECT

public:
  explicit MainWindow(QWidget *parent = 0);
  ~MainWindow();

private:
  Ui::MainWindow *ui;
  QPoint m_mousePos;

private slots:
  void updateTime();
  void showContextMenu(const QPoint &pos);

protected:
  void mouseReleaseEvent(QMouseEvent *);
  void mousePressEvent(QMouseEvent *);
  void mouseMoveEvent(QMouseEvent *);
};

#endif // MAINWINDOW_H

在前面的代码中,还声明了一个新的私有成员变量 m_mousePos,它是一个用于存储鼠标局部位置的 QPoint 对象。下面的代码定义了 mousePressEventmouseMoveEvent

void MainWindow::mousePressEvent(QMouseEvent *e)
{
  m_mousePos = e->pos();
}

void MainWindow::mouseMoveEvent(QMouseEvent *e)
{
  this->move(e->globalPos() - m_mousePos);
}

这比你想象的要简单。当鼠标按钮被按下时,鼠标的局部位置被存储为 m_mousePos。当鼠标移动时,我们调用 move 函数将 MainWindow 移动到新的位置。因为传递给 move 的位置是一个全局位置,我们需要使用事件的 globalPos 减去鼠标的局部位置。困惑吗?m_mousePos 变量是鼠标相对于父小部件(在我们的例子中是 MainWindow)的相对位置。move 函数将 MainWindow 的左上角移动到给定的全局位置。而 e->globalPos() 函数是鼠标的全局位置,而不是 MainWindow,我们需要减去 m_mousePos 的相对位置,以将鼠标的全局位置转换为 MainWindow 的左上角位置。经过所有这些努力,时钟应该看起来更加令人满意。

保存和恢复设置

尽管时钟可以被移动,但它重启后不会恢复到最后的位置。此外,我们可以为用户提供一些选项来调整时钟的外观,例如字体颜色。为了使其工作,我们需要 QSettings 类,它提供平台无关的持久设置。它需要一个公司或组织名称以及应用程序名称。一个典型的 QSettings 对象可以通过以下行构建:

QSettings settings("Qt5 Blueprints", "Fancy Clock");

在这里,Qt5 Blueprints 是组织的名称,而 Fancy Clock 是应用程序的名称。

在 Windows 上,设置存储在系统注册表中,而在 Mac OS X 上存储在 XML 预设文件中,在其他 Unix 操作系统(如 Linux)上存储在 INI 文本文件中。然而,我们通常不需要担心这一点,因为 QSettings 提供了高级接口来操作设置。

如果我们打算在多个地方读取和/或写入设置,我们最好在继承自 QApplicationQCoreApplication 中设置组织和应用程序。main.cpp 文件的内容如下所示:

#include "mainwindow.h"
#include <QApplication>

int main(int argc, char *argv[])
{
  QApplication a(argc, argv);

  a.setOrganizationName(QString("Qt5 Blueprints"));
  a.setApplicationName(QString("Fancy Clock"));

  QWidget wid;
  MainWindow w(&wid);
  w.show();

  return a.exec();
}

这使得我们可以使用默认的 QSettings 构造函数来访问相同的设置。为了保存 MainWindow 的几何形状和状态,我们需要重新实现 closeEvent。首先,我们需要将 closeEvent 声明为一个受保护的成员函数,如下所示:

void closeEvent(QCloseEvent *);

然后,让我们在 mainwindow.cpp 中定义 closeEvent 函数,如下所示:

void MainWindow::closeEvent(QCloseEvent *e)
{
  QSettings sts;
  sts.setValue("MainGeometry", saveGeometry());
  sts.setValue("MainState", saveState());
  e->accept();
}

记得添加 #include <QSettings> 以包含 QSettings 头文件。

多亏了setOrganizationNamesetApplicationName,我们现在不需要向QSettings构造函数传递任何参数。相反,我们调用一个setValue函数来保存设置。saveGeometry()saveState()函数分别返回MainWindow的几何形状和状态作为QByteArray对象。

下一步是读取这些设置并恢复几何形状和状态。这可以在MainWindow的构造函数内部完成。您只需向其中添加两个语句即可:

QSettings sts;
restoreGeometry(sts.value("MainGeometry").toByteArray());
restoreState(sts.value("MainState").toByteArray());

在这里,toByteArray()可以将存储的值转换为QByteArray对象。我们如何测试它是否工作?为此,请执行以下步骤:

  1. 重新构建此应用程序。

  2. 运行它。

  3. 移动其位置。

  4. 关闭它。

  5. 再次运行它。

您会看到时钟将出现在与关闭前完全相同的位置。现在,您已经相当熟悉小部件、布局、设置、信号和槽,是时候通过以下步骤制作一个首选项对话框了:

  1. 项目面板中右键单击Fancy_Clock项目。

  2. 选择添加新…

  3. 文件和类面板中选择Qt

  4. 在中间面板中点击Qt Designer 表单类

  5. 选择带有按钮底部的对话框

  6. 类名下填写Preference

  7. 点击下一步,然后选择完成

Qt Creator 将您重定向到设计模式。首先,让我们将windowTitle更改为Preference,然后进行一些 UI 操作。执行以下步骤来完成此操作:

  1. 标签拖到QDialog中,并将其objectName属性更改为colourLabel。接下来,将其文本更改为颜色

  2. 添加QComboBox并将其objectName属性更改为colourBox

  3. BlackWhiteGreenRed项添加到colourBox中。

  4. Preference的布局更改为表单布局

关闭此 UI 文件。返回编辑preference.h,添加一个私有的onAccepted槽。以下代码显示了此文件的内容:

#ifndef PREFERENCE_H
#define PREFERENCE_H

#include <QDialog>

namespace Ui {
  class Preference;
}

class Preference : public QDialog
{
  Q_OBJECT

public:
  explicit Preference(QWidget *parent = 0);
  ~Preference();

private:
  Ui::Preference *ui;

private slots:
  void onAccepted();
};

#endif // PREFERENCE_H

如同往常,我们在源文件中定义此槽。此外,我们还需要在Preference的构造函数中设置一些初始化。因此,preference.cpp变成了以下代码:

#include <QSettings>
#include "preference.h"
#include "ui_preference.h"

Preference::Preference(QWidget *parent) :
  QDialog(parent),
  ui(new Ui::Preference)
{
  ui->setupUi(this);

  QSettings sts;
  ui->colourBox->setCurrentIndex(sts.value("Colour").toInt());

  connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &Preference::onAccepted);
}

Preference::~Preference()
{
  delete ui;
}

void Preference::onAccepted()
{
  QSettings sts;
  sts.setValue("Colour", ui->colourBox->currentIndex());
}

同样,我们加载设置并更改colourBox的当前项。然后,接下来是信号和槽的耦合。请注意,Qt Creator 已为我们自动生成了buttonBoxPreference之间的接受和拒绝连接。当点击OK按钮时,buttonBoxaccepted信号被触发。同样,如果用户点击取消,则触发rejected信号。您可能想检查设计模式下的信号与槽编辑器,以查看那里定义了哪些连接。这在上面的屏幕截图中显示:

保存和恢复设置

对于onAccepted槽的定义,它将colourBoxcurrentIndex保存到设置中,这样我们就可以在其他地方读取此设置。

现在,我们接下来要做的就是在弹出菜单中添加一个Preference的条目,并根据Colour设置值更改lcdNumber的颜色。因此,你首先需要在mainwindow.h中定义一个私有槽和一个私有成员函数。

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>

namespace Ui {
  class MainWindow;
}

class MainWindow : public QMainWindow
{
  Q_OBJECT

public:
  explicit MainWindow(QWidget *parent = 0);
  ~MainWindow();

private:
  Ui::MainWindow *ui;
  QPoint m_mousePos;
  void setColour();

private slots:
  void updateTime();
  void showContextMenu(const QPoint &pos);
  void showPreference();

protected:
  void mouseReleaseEvent(QMouseEvent *);
  void mousePressEvent(QMouseEvent *);
  void mouseMoveEvent(QMouseEvent *);
  void closeEvent(QCloseEvent *);
};

#endif // MAINWINDOW_H

setColour函数用于更改lcdNumber的颜色,而showPreference槽将执行一个Preference对象。这两个成员的定义在mainwindow.cpp文件中,如下所示:

#include <QTimer>
#include <QTime>
#include <QMouseEvent>
#include <QMenu>
#include <QAction>
#include <QSettings>
#include "mainwindow.h"
#include "preference.h"
#include "ui_mainwindow.h"

MainWindow::MainWindow(QWidget *parent) :
  QMainWindow(parent),
  ui(new Ui::MainWindow)
{
  ui->setupUi(this);

  setAttribute(Qt::WA_TranslucentBackground);
  setWindowFlags(Qt::WindowStaysOnTopHint | Qt::FramelessWindowHint | windowFlags());

  QSettings sts;
  restoreGeometry(sts.value("MainGeometry").toByteArray());
  restoreState(sts.value("MainState").toByteArray());
  setColour();

  connect(this, &MainWindow::customContextMenuRequested, this, &MainWindow::showContextMenu);

  QTimer *timer = new QTimer(this);
  connect(timer, &QTimer::timeout, this, &MainWindow::updateTime);
  timer->start(1000);

  updateTime();
}

MainWindow::~MainWindow()
{
  delete ui;
}

void MainWindow::updateTime()
{
  QTime currentTime = QTime::currentTime();
  QString currentTimeText = currentTime.toString("hh:mm");
  if (currentTime.second() % 2 == 0) {
    currentTimeText[2] = ' ';
  }
  ui->lcdNumber->display(currentTimeText);
}

void MainWindow::showContextMenu(const QPoint &pos)
{
  QMenu contextMenu;
  contextMenu.addAction(QString("Preference"), this, SLOT(showPreference()));
  contextMenu.addAction(QString("Exit"), this, SLOT(close()));
  contextMenu.exec(mapToGlobal(pos));
}

void MainWindow::mouseReleaseEvent(QMouseEvent *e)
{
  if (e->button() == Qt::RightButton) {
    emit customContextMenuRequested(e->pos());
  }
  else {
    QMainWindow::mouseReleaseEvent(e);
  }
}

void MainWindow::mousePressEvent(QMouseEvent *e)
{
  m_mousePos = e->pos();
}

void MainWindow::mouseMoveEvent(QMouseEvent *e)
{
  this->move(e->globalPos() - m_mousePos);
}

void MainWindow::closeEvent(QCloseEvent *e)
{
  QSettings sts;
  sts.setValue("MainGeometry", saveGeometry());
  sts.setValue("MainState", saveState());
  e->accept();
}

void MainWindow::setColour()
{
  QSettings sts;
  int i = sts.value("Colour").toInt();
  QPalette c;
  switch (i) {
  case 0://black
    c.setColor(QPalette::Foreground, Qt::black);
    break;
  case 1://white
    c.setColor(QPalette::Foreground, Qt::white);
    break;
  case 2://green
    c.setColor(QPalette::Foreground, Qt::green);
    break;
  case 3://red
    c.setColor(QPalette::Foreground, Qt::red);
    break;
  }
  ui->lcdNumber->setPalette(c);
  this->update();
}

void MainWindow::showPreference()
{
  Preference *pre = new Preference(this);
  pre->exec();
  setColour();
}

我们在构造函数中调用setColour是为了正确设置lcdNumber的颜色。在setColour内部,我们首先从设置中读取Colour值,然后使用switch语句在调用setPalette更改lcdNumber的颜色之前获取正确的QPalette类。由于 Qt 没有提供直接更改QLCDNumber对象的前景色的方法,我们需要使用这种方法来实现。在这个成员函数的末尾,我们调用update()来更新MainWindow用户界面。

注意

不要忘记在showContextMenu内部将Preference动作添加到contextMenu中。否则,将无法打开对话框。

在相关的showPreference槽中,我们创建一个新的Preference对象,它是MainWindow的子对象,然后调用exec()来执行并显示它。最后,我们调用setColour()来更改lcdNumber的颜色。由于Preference是模态的,且exec()有自己的事件循环,它将阻塞应用程序直到pre完成。pre执行完成后,无论是通过accepted还是rejected,接下来都会调用setColour。当然,你可以使用信号-槽的方式来实现它,但我们必须对之前的代码进行一些修改。首先,在设计模式下删除preference.ui中的accepted-accept信号-槽对。然后,将accept()添加到preference.cpp中的onAccepted的末尾。

void Preference::onAccepted()
{
  QSettings sts;
  sts.setValue("Colour", ui->colourBox->currentIndex());
  this->accept();
}

现在,mainwindow.cpp中的showPreference可以重写为以下内容:

void MainWindow::showPreference()
{
  Preference *pre = new Preference(this);
  connect(pre, &Preference::accepted, this, &MainWindow::setColour);
  pre->exec();
}

小贴士

connect语句不应该放在exec()之后,因为它会导致绑定失败。

无论你更喜欢哪种方式,现在时钟都应该有一个Preference对话框。运行它,从弹出菜单中选择Preference,并将颜色更改为你想要的任何颜色。你应该期待的结果类似于以下截图所示:

保存和恢复设置

在 Unix 平台上构建

到目前为止,我们仍然被困在 Windows 的应用程序中。是时候测试我们的代码是否可以在其他平台上构建了。在本章中,涉及的代码仅限于桌面操作系统,而在此书的后半部分,我们将有机会为移动平台构建应用程序。至于其他桌面操作系统,种类繁多,其中大多数是类 Unix 系统。Qt 官方支持 Linux 和 Mac OS X,以及 Windows。因此,使用其他系统(如FreeBSD)的用户可能需要从头编译 Qt 或从他们自己的社区获取预构建的包。在本书中,使用Fedora 20 Linux 发行版作为演示,介绍平台跨编译。请记住,Linux 上有许多桌面环境和主题工具,所以如果用户界面有所不同,请不要感到惊讶。嗯,既然你很好奇,让我告诉你,桌面环境是带有QtCurveKDE 4,在我的情况下,它统一了 GTK+ / Qt 4 / Qt 5。一旦你准备好了,我们就开始吧。你可以执行以下步骤来完成这个任务:

  1. Fancy Clock的源代码复制到 Linux 下的一个目录中。

  2. 删除Fancy_Clock.pro.user文件。

  3. 在 Qt Creator 中打开此项目。

现在,构建并运行这个应用程序。除了任务栏图标外,一切都很正常。这种小问题在测试中是无法避免的。嗯,为了解决这个问题,只需修改MainWindow构造函数中的一行。更改窗口标志将修正这个问题:

setWindowFlags(Qt::WindowStaysOnTopHint | Qt::FramelessWindowHint | Qt::Tool);

如果你再次运行文件,Fancy Clock将不再出现在任务栏中。请确保将MainWindow对象w作为QWidget wid的子对象;否则,点击关闭后应用程序不会终止。

注意,首选项对话框使用原生 UI 控件,而不是将其他平台的控件带到这个平台上。这是 Qt 提供的最迷人的功能之一。所有 Qt 应用程序都将跨所有平台看起来和表现得像原生应用程序。

在 Unix 平台上构建

这不是麻烦,但事实是,一旦你编写了 Qt 应用程序,你就可以在任何地方运行它。你不需要为不同的平台编写不同的 GUI。那个黑暗的时代已经过去了。然而,你可能想为特定平台编写一些函数,无论是由于特定的需求还是解决方案。首先,我想向你介绍一些针对几个平台定制的 Qt 附加模块。

以 Qt Windows 附加组件为例。Windows 提供的一些酷炫功能,如缩略图工具栏Aero Peek,通过这个附加模块得到了 Qt 的支持。

好吧,直接将此模块添加到项目文件中,在这种情况下是 Fancy_Clock.pro 文件,肯定会惹恼其他平台。更好的方法是测试它是否在 Windows 上;如果是,则将此模块添加到项目中。否则,跳过此步骤。以下代码显示了 Fancy_Clock.pro 文件,如果它在 Windows 上构建,则会添加 winextras 模块:

QT       += core gui

win32: QT += winextras

greaterThan(QT_MAJOR_VERSION, 4): QT += widgets

TARGET = Fancy_Clock
TEMPLATE = app

SOURCES += main.cpp\
    mainwindow.cpp \
    preference.cpp

HEADERS  += mainwindow.h \
    preference.h

FORMS    += mainwindow.ui \
    preference.ui

正如你所见,win32 是一个条件语句,仅在主机机器是 Windows 时才为 true。在为该项目重新运行 qmake 之后,你将能够包含并使用那些额外的类。

同样,如果你想在 Unix 平台上做些什么,只需使用关键字 unix,但 unix 只在 Linux/X11 或 Mac OS X 上为 true。为了区分 Mac OS X 和 Linux,这里有一个例子:

win32 {
  message("Built on Windows")
}
else: unix: macx{
  message("Built on Mac OS X")
}
else {
  message("Built on Linux")
}

实际上,你可以使用 unix: !macx 作为条件语句在 Linux 上执行一些特定平台的工作。在项目文件(s)中包含许多特定平台语句是一种常见做法,特别是当你的项目需要与其他库链接时。你必须为这些库在不同的平台上指定不同的路径,否则编译器会抱怨缺少库或未知符号。

此外,你可能还想知道如何在保持与其他平台兼容的同时编写特定平台的代码。类似于 C++,它是一个由各种编译器处理的预定义宏。然而,这些编译器宏列表可能因编译器而异。因此,最好使用 Global Qt Declarations。我将使用以下简短示例进一步解释这一点:

void MainWindow::showContextMenu(const QPoint &pos)
{
  QMenu contextMenu;
  #ifdef Q_OS_WIN
  contextMenu.addAction(QString("Options"), this, SLOT(showPreference()));
  #elif defined(Q_OS_LINUX)
  contextMenu.addAction(QString("Profile"), this, SLOT(showPreference()));
  #else
  contextMenu.addAction(QString("Preference"), this, SLOT(showPreference()));
  #endif
  contextMenu.addAction(QString("Exit"), this, SLOT(close()));
  contextMenu.exec(mapToGlobal(pos));
}

上述代码显示了 showContextMenu 的新版本。Preference 菜单项将在不同的平台上使用不同的文本,即 Windows、Linux 和 Mac OS X。更改你的 showContextMenu 函数并再次运行它。你将在 Windows 上看到 选项,在 Linux 上看到 配置文件,在 Mac OS X 上看到 偏好设置。以下是有关特定平台宏的列表。你可以在 QtGlobal 文档中找到完整的描述,包括其他宏、函数和类型。

对应平台
Q_OS_ANDROID Android
Q_OS_FREEBSD FreeBSD
Q_OS_LINUX Linux
Q_OS_IOS iOS
Q_OS_MAC Mac OS X 和 iOS (基于 Darwin)
Q_OS_WIN 所有 Windows 平台,包括 Windows CE
Q_OS_WINPHONE Windows Phone 8
Q_OS_WINRT Windows 8 上的 Windows Runtime。Windows RT 和 Windows Phone 8

摘要

在本章中,包括一些技巧在内的 UI 设计信息。此外,还有一些基本但有用的跨平台主题。现在,你能够使用你最喜欢的,也许已经熟练掌握的 C++ 编写优雅的 Qt 应用程序。

在下一章中,我们将学习如何使用 Qt Quick 编写应用程序。然而,无需担心;Qt Quick 甚至更容易,当然,开发起来也更快。

第三章。使用 Qt Quick 制作 RSS 阅读器

在本章中,我们将专注于开发使用 Qt Quick 的应用程序。对于支持触控屏的设备,Qt Quick 应用程序响应更快,编写起来也更简单。本章使用 RSS 阅读器作为演示。以下主题将帮助您构建优雅的 Qt Quick 应用程序:

  • 理解模型和视图

  • 通过 XmlListModel 解析 RSS 源

  • 调整分类

  • 利用 ScrollView

  • 添加 BusyIndicator

  • 制作无边框窗口

  • QML 调试

理解模型和视图

如前所述,Qt Quick 应用程序与传统 Qt Widgets 应用程序不同。您将编写 QML 而不是 C++ 代码。因此,让我们创建一个新的项目,一个名为 RSS_Reader 的 Qt Quick 应用程序。这次,我们将使用 Qt Quick 2.3 作为组件集。由于我们不会使用 Qt Quick Controls 提供的小部件,我们将编写自己的小部件。

在我们动手之前,让我们勾勒出这个应用程序的外观。根据以下图表,将有两个部分。左侧面板提供一些分类,以便用户可以选择有趣的分类。右侧面板是主要区域,显示当前分类下的新闻。这是一个典型的 RSS 新闻阅读器的用户界面。

理解模型和视图

我们可以通过使用 ListView 来实现 分类 面板。这种类型(我们在 QML 中说 "类型" 而不是 "类")用于显示来自各种列表模型的数据。所以让我们编辑我们的 main.qml,使其类似于以下内容:

import QtQuick 2.3
import QtQuick.Window 2.2

Window {
  id: mainWindow
  visible: true
  width: 720
  height: 400

  ListView {
    id: categories

    width: 150
    height: parent.height
    orientation: ListView.Vertical
    anchors.top: parent.top
    spacing: 3
  }
}

ListView 需要一个模型来获取数据。在这种情况下,我们可以利用 ListModel 的简单性。为了实现这一点,让我们创建一个新的 Feeds.qml 文件,它将包含一个自定义的 ListModel 示例:

  1. 右键单击项目。

  2. 选择 添加新…

  3. 导航到 Qt | QML 文件(Qt Quick 2)

  4. 输入 Feeds.qml 文件名。

这是 Feeds.qml 的内容:

import QtQuick 2.3

ListModel {
  ListElement { name: "Top Stories"; url: "http://feeds.bbci.co.uk/news/rss.xml" }
  ListElement { name: "World"; url: "http://feeds.bbci.co.uk/news/world/rss.xml" }
  ListElement { name: "UK"; url: "http://feeds.bbci.co.uk/news/uk/rss.xml" }
  ListElement { name: "Business"; url: "http://feeds.bbci.co.uk/news/business/rss.xml" }
  ListElement { name: "Politics"; url: "http://feeds.bbci.co.uk/news/politics/rss.xml" }
  ListElement { name: "Health"; url: "http://feeds.bbci.co.uk/news/health/rss.xml" }
  ListElement { name: "Education & Family"; url: "http://feeds.bbci.co.uk/news/education/rss.xml" }
  ListElement { name: "Science & Environment"; url: "http://feeds.bbci.co.uk/news/science_and_environment/rss.xml" }
  ListElement { name: "Technology"; url: "http://feeds.bbci.co.uk/news/technology/rss.xml" }
  ListElement { name: "Entertainment & Arts"; url: "http://feeds.bbci.co.uk/news/entertainment_and_arts/rss.xml" }
}

注意

我们使用 BBC News RSS 作为源,但您可能希望将其更改为另一个。

正如您所看到的,前面的 ListModel 示例有两个角色,nameurl。一个 "角色"基本上是孩子项的另一种说法。这些可以通过我们即将创建的 ListView 代理来绑定。以这种方式,角色通常代表实体的属性或表格的列。

让我解释一下视图、模型和代理之间的关系,这是 Qt 世界中另一个重要但难以理解的概念。这官方上被称为 模型-视图 架构。除了传统的视图之外,Qt 将视图和控制器解耦,以便数据可以以许多定制的方式进行渲染和编辑。后者更加优雅和高效。以下图表有助于您理解这个概念:

理解模型和视图

以用于排列数据的模型ListModel为例来阐述它们之间的关系。以下代码中显示的CategoriesDelegate是一个代理,用于控制如何从模型中显示角色。最后,我们使用一个视图,在这个例子中是ListView,来渲染代理。

模型、视图和代理之间的通信基于信号和槽机制。这需要您花费一些时间才能完全理解这个概念。希望我们可以通过练习这个例子来缩短这个时间。在这个阶段,我们已经有了一个视图和一个模型。我们必须定义一个代理,正如之前提到的CategoriesDelegate,来控制模型中的数据并将其渲染在视图上。添加一个新的CategoriesDelegate.qml文件,其内容如下:

import QtQuick 2.3

Rectangle {
  id: delegate
  property real itemWidth
  width: itemWidth
  height: 80
  Text {
    id: title
    anchors { left: parent.left; leftMargin: 10; right: parent.right; rightMargin: 10 }
    anchors.verticalCenter: delegate.verticalCenter
    text: name
    font { pointSize: 18; bold: true }
    verticalAlignment: Text.AlignVCenter
    wrapMode: Text.WordWrap
  }
}

您应该对模型、代理和视图之间的关系有所了解。在这里,我们使用Rectangle作为代理类型。在Rectangle类型内部是一个Text对象,用于显示来自我们的ListModel示例中的name。至于font属性,在这里我们使用pointSize来指定文本的大小,而您也可以使用pixelSize作为替代。

要完成模型-视图架构,请回到main.qml编辑:

import QtQuick 2.3
import QtQuick.Window 2.2
import "qrc:/"

Window {
  id: mainWindow
  visible: true
  width: 720
  height: 400

  Feeds {
    id: categoriesModel
  }

  ListView {
    id: categories

    width: 150
    height: parent.height
    orientation: ListView.Vertical
    anchors.top: parent.top
    spacing: 3
    model:categoriesModel
    delegate: CategoriesDelegate {
      id: categoriesDelegate
      width: categories.width
    }
  }
}

注意第三行;将其导入qrc是至关重要的。我们使用"qrc:/"是因为我们需要将 QML 文件放在根目录下。如果您使用子目录来保持Feeds.qmlCategoriesDelegate.qml,请修改它。在这个例子中,这些文件是未组织的。但强烈建议将它们分类为不同的模块。如果您没有导入目录,您将无法使用这些 QML 文件。

Window项中,我们创建Feeds,它是来自Feeds.qmlListModel的一个元素。然后,我们给这个Feeds项一个categoriesModel ID,并使用它作为ListView的模型。指定代理的方式与指定视图的模型类似。我们不是在ListView外部声明它,而必须在delegate作用域内定义它,否则代理项CategoriesDelegate将无法从模型中获取数据。如您所见,我们可以操作categoriesDelegatewidth。这是为了确保文本不会超出ListView的边界。

如果一切设置正确,点击运行,您将看到它这样运行:

理解模型和视图

使用 XmlListModel 解析 RSS 源

的确,我们现在有了分类,但它们似乎与 RSS 没有任何关系。此外,如果您进一步挖掘,您会发现 RSS 源实际上是 XML 文档。Qt 已经提供了一个有用的类型来帮助我们解析它们。我们不需要重新发明轮子。这个强大的类型就是所谓的XmlListModel元素,它使用XmlRole进行查询。

首先,我们需要将categoriesModelurl角色暴露给主作用域。这是通过在ListView内部声明存储模型当前元素url的属性来完成的,即url。然后,我们可以添加一个XmlListModel元素,并使用该url元素作为其source。相应地,修改后的main.qml文件如下所示:

import QtQuick 2.3
import QtQuick.Window 2.2
import QtQuick.XmlListModel 2.0
import "qrc:/"

Window {
  id: mainWindow
  visible: true
  width: 720
  height: 400

  Feeds {
    id: categoriesModel
  }

  ListView {
    id: categories
    width: 150
    height: parent.height
    orientation: ListView.Vertical
    anchors.top: parent.top
    spacing: 3
    model:categoriesModel
    delegate: CategoriesDelegate {
      id: categoriesDelegate
      width: categories.width
    }
    property string currentUrl: categoriesModel.get(0).url
  }

  XmlListModel {
    id: newsModel

    source: categories.currentUrl
    namespaceDeclarations: "declare namespace media = 'http://search.yahoo.com/mrss/'; declare namespace atom = 'http://www.w3.org/2005/Atom';"
    query: "/rss/channel/item"

    XmlRole { name: "title"; query: "title/string()" }
    XmlRole { name: "description"; query: "description/string()" }
    XmlRole { name: "link"; query: "link/string()" }
    XmlRole { name: "pubDate"; query: "pubDate/string()" }
    //XPath starts from 1 not 0 and the second thumbnail is larger and more clear
    XmlRole { name: "thumbnail"; query: "media:thumbnail[2]/@url/string()" }
  }
}

注意

在 Qt Quick 中,对象的值会动态改变并隐式更新。你不需要显式地给出新值。

为了使用此元素,你需要通过添加import QtQuick.XmlListModel 2.0行来导入模块。此外,XmlListModel是一个只读模型,这意味着你不能通过此模型修改数据源。这是完全可以接受的,因为我们需要从 RSS 源检索新闻数据。以“头条新闻”为例;以下代码是该 XML 文档内容的一部分:

<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet title="XSL_formatting" type="text/xsl" href="/shared/bsp/xsl/rss/nolsol.xsl"?>

<rss   version="2.0">  
<channel> 
<title>BBC News - Home</title>  
<link>http://www.bbc.co.uk/news/#sa-ns_mchannel=rss&amp;ns_source=PublicRSS20-sa</link>  
<description>The latest stories from the Home section of the BBC News web site.</description>  
<language>en-gb</language>  
<lastBuildDate>Mon, 26 Jan 2015 23:19:42 GMT</lastBuildDate>  
<copyright>Copyright: (C) British Broadcasting Corporation, see http://news.bbc.co.uk/2/hi/help/rss/4498287.stm for terms and conditions of reuse.</copyright>  
<image> 
  <url>http://news.bbcimg.co.uk/nol/shared/img/bbc_news_120x60.gif</url>  
  <title>BBC News - Home</title>  
  <link>http://www.bbc.co.uk/news/#sa-ns_mchannel=rss&amp;ns_source=PublicRSS20-sa</link>  
  <width>120</width>  
  <height>60</height> 
</image>  
<ttl>15</ttl>  
<atom:link href="http://feeds.bbci.co.uk/news/rss.xml" rel="self" type="application/rss+xml"/>  
<item> 
  <title>Germany warns Greece over debts</title>  
  <description>The German government warns Greece that it must meet its commitments to lenders, after the election win of the Greek anti-austerity Syriza party.</description>  
  <link>http://www.bbc.co.uk/news/business-30977714#sa-ns_mchannel=rss&amp;ns_source=PublicRSS20-sa</link>  
  <guid isPermaLink="false">http://www.bbc.co.uk/news/business-30977714</guid>  
  <pubDate>Mon, 26 Jan 2015 20:15:56 GMT</pubDate>  
  <media:thumbnail width="66" height="49" url="http://news.bbcimg.co.uk/media/images/80536000/jpg/_80536447_025585607-1.jpg"/>  
  <media:thumbnail width="144" height="81" url="http://news.bbcimg.co.uk/media/images/80536000/jpg/_80536448_025585607-1.jpg"/> 
</item>
……

需要设置namespaceDeclarations属性,因为 XML 文档有 XML 命名空间。

<rss   version="2.0">

在这里,xmlns代表 XML 命名空间,因此我们相应地声明了命名空间。

namespaceDeclarations: "declare namespace media = 'http://search.yahoo.com/mrss/'; declare namespace atom = 'http://www.w3.org/2005/Atom';"

实际上,你只需声明一个media命名空间并安全地忽略一个atom命名空间。然而,如果你没有声明media命名空间,应用程序最终会失败解析 XML 文档。因此,回到 XML 文档,你会发现它有一个数据排序的层次结构。我们这里需要的是这些项。以顶级作为根/,因此item的路径可以写成/rss/channel/item。这正是我们放入query中的内容。

所有的XmlRole元素都是使用query作为基础创建的。对于XmlRolename定义了它的名称,它不需要与 XML 文档中的名称相同。它类似于常规 Qt Quick 项的id。然而,XmlRole的查询必须使用相对于XmlListModel查询的相对路径。尽管在大多数情况下它是string()类型,但它仍然必须显式声明。如果有共享相同键的元素,它将是一个数组,其中列出的第一个元素具有第一个索引。

注意

XPath 的第一个索引是1而不是0

有时,我们需要获取一个属性thumbnail。这是media:thumbnail标签的url属性。在这种情况下,是@符号将完成我们需要的所有魔法。

与这些类别类似,我们必须为XmlListModel元素编写一个委托以渲染视图。新的 QML NewsDelegate.qml文件如下所示:

import QtQuick 2.3

Column {
  id: news
  spacing: 8

  //used to separate news item
  Item { height: news.spacing; width: news.width }

  Row {
    width: parent.width
    height: children.height
    spacing: news.spacing

    Image {
      id: titleImage
      source: thumbnail
    }

    Text {
      width: parent.width - titleImage.width
      wrapMode: Text.WordWrap
      font.pointSize: 20
      font.bold: true
      text: title
    }
  }

  Text {
    width: parent.width
    font.pointSize: 9
    font.italic: true
    text: pubDate + " (<a href=\"" + link + "\">Details</a>)"
    onLinkActivated: {
      Qt.openUrlExternally(link)
    }
  }

  Text {
    width: parent.width
    wrapMode: Text.WordWrap
    font.pointSize: 10.5
    horizontalAlignment: Qt.AlignLeft
    text: description
  }
}

不同之处在于这次我们使用Column来组织新闻数据并以直观的方式表示。相关的图示如下:

通过 XmlListModel 解析 RSS 源

因此,这就是我们为什么在 Column 中使用 Row 来将 缩略图标题 一起装箱的原因。因此,我们需要在前面放置一个空的 item 元素来分隔每个新闻代理。除了这些不言自明的行之外,还有一个处理链接的技巧。您需要指定 onLinkActivated 信号的槽,在这种情况下是 Qt.openUrlExternally(link)。否则,点击链接时将不会发生任何操作。

在完成所有这些之后,是时候在 main.qml 中编写一个视图来显示我们的新闻了:

import QtQuick 2.3
import QtQuick.Window 2.2
import QtQuick.XmlListModel 2.0
import "qrc:/"

Window {
  id: mainWindow
  visible: true
  width: 720
  height: 400

  Feeds {
    id: categoriesModel
  }

  ListView {
    id: categories

    width: 150
    height: parent.height
    orientation: ListView.Vertical
    anchors.top: parent.top
    spacing: 3
    model:categoriesModel
    delegate: CategoriesDelegate {
      id: categoriesDelegate
      width: categories.width
    }
    property string currentUrl: categoriesModel.get(0).url
  }

  XmlListModel {
    id: newsModel

    source: categories.currentUrl
    namespaceDeclarations: "declare namespace media = 'http://search.yahoo.com/mrss/'; declare namespace atom = 'http://www.w3.org/2005/Atom';"
    query: "/rss/channel/item"

    XmlRole { name: "title"; query: "title/string()" }
    XmlRole { name: "description"; query: "description/string()" }
    XmlRole { name: "link"; query: "link/string()" }
    XmlRole { name: "pubDate"; query: "pubDate/string()" }
    //XPath starts from 1 not 0 and the second thumbnail is larger and more clear
    XmlRole { name: "thumbnail"; query: "media:thumbnail[2]/@url/string()" }
  }

  ListView {
    id: newsList

    anchors { left: categories.right; leftMargin: 10; right: parent.right; rightMargin: 4; top: parent.top; bottom: parent.bottom; }
    model: newsModel
    delegate: NewsDelegate {
      width: newsList.width
    }
  }
}

记得定义 NewsDelegatewidth,以防它显示异常。点击 运行;应用程序将看起来像以下截图:

通过 XmlListModel 解析 RSS 源

调整类别

此应用程序仍然不完整。例如,点击其他类别后,新闻视图将完全不会改变。在这个阶段,我们将解决这个问题并使其更加美观。

我们需要做的是向 CategoriesDelegate 添加 MouseArea。此元素用于处理各种鼠标交互,包括点击。新的 CategoriesDelegate.qml 文件的代码如下所示:

import QtQuick 2.3

Rectangle {
  id: delegate
  height: 80

  Text {
    id: title
    anchors { left: parent.left; leftMargin: 10; right: parent.right; rightMargin: 10 }
    anchors.verticalCenter: delegate.verticalCenter
    text: name
    font { pointSize: 18; bold: true }
    verticalAlignment: Text.AlignVCenter
    wrapMode: Text.WordWrap
  }

  MouseArea {
    anchors.fill: delegate
    onClicked: {
      categories.currentIndex = index
      if(categories.currentUrl == url)
      newsModel.reload()
      else
      categories.currentUrl = url
    }
  }
}

如您所见,一旦点击了代理,如果需要,它将更改 categories.currentIndexcurrentUrl,或者简单地让 newsModel 重新加载。如前所述,QML 是一种动态语言,它更改 categories.currentUrlnewsModelsource 属性,这会自动导致 newsModel 重新加载。

为了帮助用户区分当前选中的类别和其他类别,我们可能希望改变其大小和缩放。有一些附加属性,它们附加到每个代理实例上,或者简单地被它们共享。.isCurrentItem 属性是会帮我们大忙的一个属性。它是一个布尔值,表示此代理是否是当前项。然而,只有代理的根项可以直接访问这些附加属性。为了以干净的方式编写代码,我们在 CategoriesDelegateRectangle 中添加了一行来持有这个属性:

property bool selected: ListView.isCurrentItem

现在,我们可以通过在 Text 项中添加以下行来利用 selected

scale: selected ? 1.0 : 0.8
color: selected ? "#000" : "#AAA"

Text 将在未选中时缩放至 0.8,并在激活时保持正常行为。对于其颜色也有类似条件。#AAA 颜色代码是一种非常浅的灰色,这使得激活时的黑色文本更加突出。然而,这些变化没有动画效果。虽然我们希望这些过渡更加自然,Qt Quick 提供了 带有状态的 Behavior 来实现这些过渡。通过将这些行添加到 Text 项目中,我们得到以下代码:

Behavior on color { ColorAnimation { duration: 300 } }
Behavior on scale { PropertyAnimation { duration: 300 } }

预期在更改当前代理时会出现动画,这会导致颜色和缩放的变化。如果您不确定是否已执行正确的修改,以下代码显示了新修改的 CategoriesDelegate.qml 文件:

import QtQuick 2.3

Rectangle {
  id: delegate
  height: 80

  property bool selected: ListView.isCurrentItem

  Text {
    id: title
    anchors { left: parent.left; leftMargin: 10; right: parent.right; rightMargin: 10 }
    anchors.verticalCenter: delegate.verticalCenter
    text: name
    font { pointSize: 18; bold: true }
    verticalAlignment: Text.AlignVCenter
    wrapMode: Text.WordWrap
    scale: selected ? 1.0 : 0.8
    color: selected ? "#000" : "#AAA"
    Behavior on color { ColorAnimation { duration: 300 } }
    Behavior on scale { PropertyAnimation { duration: 300 } }
  }

  MouseArea {
    anchors.fill: delegate
    onClicked: {
      categories.currentIndex = index
      if(categories.currentUrl == url)
      newsModel.reload()
      else
      categories.currentUrl = url
    }
  }
}

有改进分类的空间,包括背景图像,它只是一个简单的Image元素,可以成为你的练习的一部分。然而,这部分内容不会在本章中涉及。接下来,我们将更改 Windows 平台上的显示字体。我们将在main.cpp(而不是main.qml)中添加几行代码来将字体更改为Times New Roman

#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QFont>

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

  #ifdef Q_OS_WIN
  app.setFont(QFont(QString("Times New Roman")));
  #endif

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

  return app.exec();
}

在这里,我们使用一个预定义的宏来限制这种更改仅适用于 Windows 平台。通过设置类型为QGuiApplicationapp的字体,所有子小部件,包括engine,都将受到这种更改的影响。现在再次运行应用程序;你应该期望看到一个带有这种报纸式字体的新的 RSS 阅读器:

调整分类

利用 ScrollView

我们现在正在构建 RSS 新闻阅读器。从现在开始,让我们关注那些不愉快的细节。我们首先要添加的是滚动条。更具体地说,ScrollView即将被添加。

回到 Qt 4 时代,你必须编写自己的ScrollView组件来获得这个虽小但非常不错的功能。尽管你可以在 X11 平台上使用 KDE Plasma Components 的ScrollArea,但 Qt 没有捆绑的模块用于此目的,这意味着你无法在 Windows 和 Mac OS X 上使用这些模块。感谢 Qt 项目的开放治理,许多社区代码被合并,特别是来自 KDE 社区。从 Qt 5.1 开始,我们有了QtQuick.Controls模块,它包含许多内置的桌面组件,包括ScrollView

这是一个非常易于使用的元素,它为子项提供了滚动条和内容框架。只能有一个直接的Item子项,并且这个子项会隐式地锚定以填充ScrollView组件。这意味着我们只需要锚定ScrollView组件。

指定子项有两种方式。第一种是在ScrollView组件的作用域内声明子项,那么内部的项将隐式地成为ScrollView组件的子项。另一种方式是设置contentItem属性,这是一个显式的方法。在本章的示例中,这两种方式都为你演示了。以下是main.qml的内容:

import QtQuick 2.3
import QtQuick.Window 2.2
import QtQuick.XmlListModel 2.0
import QtQuick.Controls 1.2
import QtQuick.Controls.Styles 1.2
import "qrc:/"

Window {
  id: mainWindow
  visible: true
  width: 720
  height: 400

  Feeds {
    id: categoriesModel
  }

  ListView {
    id: categories

    orientation: ListView.Vertical
    spacing: 3
    model:categoriesModel
    delegate: CategoriesDelegate {
      id: categoriesDelegate
      width: categories.width
    }
    property string currentUrl: categoriesModel.get(0).url
  }

  ScrollView {
    id: categoriesView
    contentItem: categories
    anchors { left: parent.left; top: parent.top; bottom: parent.bottom; }
    width: 0.2 * parent.width
    style: ScrollViewStyle {
      transientScrollBars: true
    }
  }

  XmlListModel {
  id: newsModel

  source: categories.currentUrl
  namespaceDeclarations: "declare namespace media = 'http://search.yahoo.com/mrss/'; declare namespace atom = 'http://www.w3.org/2005/Atom';"
  query: "/rss/channel/item"

  XmlRole { name: "title"; query: "title/string()" }
  XmlRole { name: "description"; query: "description/string()" }
  XmlRole { name: "link"; query: "link/string()" }
  XmlRole { name: "pubDate"; query: "pubDate/string()" }
  //XPath starts from 1 not 0 and the second thumbnail is larger and more clear
  XmlRole { name: "thumbnail"; query: "media:thumbnail[2]/@url/string()" }
  }

  ScrollView {
    id: newsView
    anchors { left: categoriesView.right; leftMargin: 10; right: parent.right; top: parent.top; bottom: parent.bottom }
    style: ScrollViewStyle {
      transientScrollBars: true
    }
    ListView {
      id: newsList
      model: newsModel
      delegate: NewsDelegate {
        width: newsList.width
      }
    }
  }
}

由于子项会自动填充anchorsListView内部的一些行被删除了。尽管如此,大多数行只是被移动到了ScrollView中。你可以看到我们为categories使用了显式方式,而为newsList使用了隐式方式。

查看一下ScrollView,我们通过将transientScrollBars设置为true来定义了一个自定义的style元素。需要注意的是,transientScrollBars的默认值是平台相关的。瞬态滚动条仅在内容被滚动时出现,并在不再需要时消失。无论如何,默认情况下在 Windows 上是false,所以我们显式地将其打开,从而得到以下更好的视觉样式:

利用 ScrollView

添加 BusyIndicator

缺少忙碌指示器也会让人感到不舒服。无论指示器有多短或多长,下载数据和解析 XML 都需要时间。我非常确信您会想添加这样的指示器,告诉用户保持冷静并等待。幸运的是,BusyIndicator,它只是一个运行中的圆圈,是QtQuick.Controls的一个元素。这正好是我们想要的。

您需要做的是将这些行添加到main.qml文件中的Window元素内部:

BusyIndicator {
  anchors.centerIn: newsView
  running: newsModel.status == XmlListModel.Loading
}

注意,我们不需要更改BusyIndicatorvisible属性,因为BusyIndicator仅在running属性设置为true时可见。在这种情况下,当newsModel状态为Loading时,我们将running设置为true

制作无边框窗口

与我们在上一章中所做的一样,我们在这里不希望系统窗口的边框装饰我们的 Qt Quick 应用程序。这主要是因为它看起来像是一个 Web 应用程序,这使得它带有原生的窗口装饰看起来很奇怪。在 QML 中完成这项工作比在 C++中更容易。我们可以在main.qml中的Window元素中添加以下行:

flags: Qt.Window | Qt.FramelessWindowHint

尽管我们的 RSS 阅读器以无边框风格运行,但无法移动它,关闭它也很困难,就像上一章中的情况一样。由于我们的鼠标在类别和新闻ListView以及ScrollView上有很多任务,我们无法简单地使用一个新的MouseArea元素来填充Window根。因此,我们将绘制自己的标题栏,当然还有退出按钮。

要将退出按钮图片添加到qrc文件中,右键点击qml.qrc,选择在编辑器中打开,导航到添加 | 添加文件,然后选择close.png

小贴士

使用不同的资源文件(qrc)为不同类型的文件会更好,这会使它更有组织。我们将在第八章中更多关于资源文件的内容,启用您的 Qt 应用程序支持其他语言

现在,添加一个新的 QML TitleBar.qml文件,其内容如下:

import QtQuick 2.3

Row {
  id: titlebar
  width: parent.width
  height: 22
  layoutDirection: Qt.RightToLeft

  property point mPos: Qt.point(0,0)

  Image {
    id: closebutton
    width: 22
    height: 22
    fillMode: Image.PreserveAspectFit
    source: "qrc:/close.png"

    MouseArea {
      anchors.fill: parent
      onClicked: {
        Qt.quit()
      }
    }
  }

  Rectangle {
    width: titlebar.width - closebutton.width
    height: titlebar.height
    color: "#000"

    MouseArea {
      anchors.fill: parent
      onPressed: {
        mPos = Qt.point(mouseX, mouseY)
      }
      onPositionChanged: {
        mainWindow.setX(mainWindow.x + mouseX - mPos.x)
        mainWindow.setY(mainWindow.y + mouseY - mPos.y)
      }
    }
  }
}

在这里,我们使用一个QPoint对象,mPos,来存储鼠标按钮被点击时的位置。

注意

尽管我们过去可能将其声明为varvariant,但为了获得最佳性能,您应避免使用var。此外,请注意,variant现在已弃用,因此在任何情况下都不应使用。

用于移动的MouseArea元素位于Rectangle元素内部。MouseArea有很多预定义的信号和槽。请注意,我们在这里使用onPressed槽而不是onClicked来获取鼠标位置。这是因为clicked信号仅在鼠标按钮按下然后释放时发出,这使得它不适合移动窗口。

当鼠标按钮被按下然后移动时,会发出 positionChanged 信号。除此之外,还有一个名为 hoverEnabled 的属性,默认值为 false。如果您将其设置为 true,则即使没有鼠标按钮被点击,也会处理所有鼠标事件。换句话说,当鼠标移动时,无论是否点击,都会发出 positionChanged 信号。因此,在这个例子中,我们不将 hoverEnabled 设置为 true

现在让我们回到 Image 元素并检查它。fillMode 元素决定了图像应该如何调整。默认情况下,它会被拉伸,尽管有比例。在这里,我们将其设置为在调整 Image 时保持比例。source 属性持有图像文件路径。在这种情况下,它是位于 Resources 文件中的 qml.qrcclose.png 文件。这里我们走;这是一个简单的 MouseArea,它将 Image 变成关闭按钮。

最后,是时候将 TitleBar 添加到 main.qml 中了,如下所示:

import QtQuick 2.3
import QtQuick.Window 2.2
import QtQuick.XmlListModel 2.0
import QtQuick.Controls 1.2
import QtQuick.Controls.Styles 1.2
import "qrc:/"

Window {
  id: mainWindow
  visible: true
  width: 720
  height: 400
  flags: Qt.Window | Qt.FramelessWindowHint

  TitleBar {
    id: titleBar
  }

  Text {
    id: windowTitle
    anchors { left: titleBar.left; leftMargin: 10; verticalCenter: titleBar.verticalCenter }
    text: "BBC News Reader"
    color: "#FFF"
    font.pointSize: 10
  }

  Feeds {
    id: categoriesModel
  }

  ListView {
    id: categories

    orientation: ListView.Vertical
    spacing: 3
    model:categoriesModel
    delegate: CategoriesDelegate {
      id: categoriesDelegate
      width: categories.width
    }
    property string currentUrl: categoriesModel.get(0).url
  }

  ScrollView {
    id: categoriesView
    contentItem: categories
    anchors { left: parent.left; top: titleBar.bottom; bottom: parent.bottom; }
    width: 0.2 * parent.width
    style: ScrollViewStyle {
      transientScrollBars: true
    }
  }

  XmlListModel {
    id: newsModel

    source: categories.currentUrl
    namespaceDeclarations: "declare namespace media = 'http://search.yahoo.com/mrss/'; declare namespace atom = 'http://www.w3.org/2005/Atom';"
    query: "/rss/channel/item"

    XmlRole { name: "title"; query: "title/string()" }
    XmlRole { name: "description"; query: "description/string()" }
    XmlRole { name: "link"; query: "link/string()" }
    XmlRole { name: "pubDate"; query: "pubDate/string()" }
    //XPath starts from 1 not 0 and the second thumbnail is larger and more clear
    XmlRole { name: "thumbnail"; query: "media:thumbnail[2]/@url/string()" }
  }

  ScrollView {
    id: newsView
    anchors { left: categoriesView.right; leftMargin: 10; right: parent.right; top: titleBar.bottom; bottom: parent.bottom }
    style: ScrollViewStyle {
      transientScrollBars: true
    }
    ListView {
      id: newsList
      model: newsModel
      delegate: NewsDelegate {
        width: newsList.width
      }
    }
  }

  BusyIndicator {
    anchors.centerIn: newsView
    running: newsModel.status == XmlListModel.Loading
  }
}

我们还使用了一个 Text 元素,windowTitle,在 titleBar 中显示窗口标题。由于我们从 BBC News 获取数据,将其称为 BBC News Reader 或随意命名都是不错的选择。

除了添加标题栏外,还需要修改一些代码以留出空间。ScrollView 组件的锚定 top 应该改为 titleBar.bottom 而不是 parent.top,否则标题栏将部分位于这两个滚动视图的顶部。

运行应用程序;它应该提供一种新的视觉风格。尽管它看起来更像是一个网络应用程序,但整个界面整洁且集成。这种改变的另一个好处是在所有平台上实现统一的 UI。

创建无框窗口

调试 QML

调试 QML 最常见的做法是使用 API 控制台。JavaScript 开发者应该熟悉这一点,因为 QML 中有控制台支持。console 函数与 Qt/C++ QDebug 函数之间的关系如下:

QML Qt/C++
console.log() qDebug()
console.debug() qDebug()
console.info() qDebug()
console.warn() qWarning()
console.error() qCritical()

在现有的支持基础上,QML 就像 JavaScript 编程一样。同时,以下函数也被引入到 QML 中:

函数 描述
console.assert() 此函数测试表达式是否为真。如果不是,它将可选地将消息写入控制台并打印堆栈跟踪。
console.exception() 此函数在调用点打印错误消息以及 JavaScript 执行的堆栈跟踪。
console.trace() 此函数在调用点打印 JavaScript 执行的堆栈跟踪。
console.count() 此函数打印特定代码块当前执行的次数,并附带一条消息。
console.time()``console.timeEnd() 这对函数将打印它们之间特定代码块所花费的时间(以毫秒为单位)。
console.profile()``console.profileEnd() 这对函数可以分析QDeclarativeEngine的状态以及 V8 调用方法。然而,在调用console.profileEnd()之前,您需要将 QML 分析器工具附加到应用程序上。

除了前面提到的有用功能外,Qt Creator 中的常见调试模式也适用于 QML。操作几乎与 C++调试相同。您可以设置断点、观察值等。然而,QML 还提供了一项额外功能。那就是QML/JS 控制台!Qt Creator 默认不显示QML/JS 控制台,您需要手动开启。只需点击以下截图中的小按钮(红色圆圈),然后勾选QML/JS 控制台

调试 QML

小贴士

当应用程序被断点中断时,您可以使用QML/JS 控制台执行当前上下文中的 JavaScript 表达式。您可以在不编辑源代码的情况下临时更改属性值,并在运行的应用程序中查看结果。

QML/JS 控制台标签以吸引人的方式显示调试输出,包括 Qt 调试信息和 JavaScript 控制台消息。它提供了一个按钮组,帮助您过滤信息、警告和错误。因此,当您调试 Qt Quick 应用程序时,只需使用这个QML/JS 控制台标签来替换应用程序输出

摘要

在本章中,我们详细介绍了 Qt Quick。我们还涵盖了模型-视图编程,这是 Qt/C++和 Qt Quick/QML 中的关键概念。您可能还会发现,QML 在某种程度上是 JavaScript 的可扩展版本。这对于 JavaScript 开发者来说是一个额外的优势。然而,如果您之前从未编写过脚本,开始并不困难。一旦开始,您将能够探索 Qt Quick 的迷人特性。我们将在下一章向您展示如何使用 Qt 访问相机设备。

第四章:控制相机和拍照

通过本章,你会发现使用 Qt 访问和控制相机是多么容易。本章中的示例还演示了如何利用状态栏和菜单栏。除了传统的 Qt Widget 应用程序外,还有一个 QML 相机示例,它以更优雅的方式完成与 Qt/C++相同的功能。本章涵盖的以下主题将扩展你的应用程序:

  • 在 Qt 中访问相机

  • 控制相机

  • 在状态栏中显示错误

  • 在状态栏中显示永久小部件

  • 利用菜单栏

  • 使用QFileDialog

  • 使用 QML 相机

在 Qt 中访问相机

尽管我们不会讨论相机工作原理的技术细节,但 Qt 中实现相机的概述将会被涵盖。相机支持包含在 Qt Multimedia 模块中,这是一个提供丰富 QML 类型和 C++类以处理多媒体内容的模块。例如,音频播放、相机和收音机功能等。为了补充这一点,Qt Multimedia Widgets 模块提供了基于小部件的多媒体类,以简化工作。

有一些类帮助我们处理相机。例如,viewfinder允许用户通过相机来构图,并在许多情况下聚焦图片。在 Qt/C++中,你可以使用QGraphicsViewQGraphicsVideoItem来完成这项工作。QGraphicsView提供了一个小部件来显示QGraphicsScene的内容。在这种情况下,QGraphicsVideoItem是场景中的一个项目。这个视图-场景-项目是图形视图框架。关于这个概念的具体信息,请访问doc.qt.io/qt-5/graphicsview.html。在这个例子中,我们使用QCameraViewfinder,这是一个专门的viewfinder类,它更简单、更直接。

要捕捉照片,我们需要使用QCameraImageCapture类,它记录媒体内容,而聚焦和缩放则由QCameraFocus类管理。

最终,QCamera在这个过程中扮演核心角色。QCamera类提供了一个接口来访问相机设备,包括网络摄像头和移动设备摄像头。还有一个名为QCameraInfo的类,可以列出所有可用的相机设备并选择使用哪一个。以下图表将帮助您理解这一点:

在 Qt 中访问相机

创建一个名为CameraDemo的新 Qt Widget 应用程序项目。编辑CameraDemo.pro文件。通过添加一行,如所示,将多媒体multimediawidgets添加到 QT 中,或者将两个模块添加到预定义的 QT 行中:

QT       += multimedia multimediawidgets

进行此修改后,您需要保存文件并导航到构建 | 运行 qmake以加载这些新模块。让我们编辑CameraDemomainwindow.ui文件,通过以下步骤添加一些小部件来使用相机:

  1. 移除状态栏和菜单栏。它们将在下一节中重新添加。目前,为了获得更简洁的用户界面,它们已被移除。

  2. Widget拖入框架中。

  3. 将其名称更改为viewfinder

  4. 右键单击viewfinder并选择提升到…

  5. 提升类名称字段中填写QCameraViewfinder。请记住勾选全局包含复选框,因为这是一个预定义的 Qt 类。点击添加,然后点击提升

  6. 主窗口设置为水平布局

  7. 将一个垂直布局拖到viewfinder的右侧。随后,组件将被添加到布局中。

  8. 添加标签,用于显示捕获的图像。请注意,在这里我们不使用QGraphicsView,仅仅因为QLabel足以满足这个目的,并且更容易使用。

  9. 将其重命名为previewLabel并清除其文本。

  10. 组合框拖到previewLabel下方。

  11. 由于它将用于显示所有可用的摄像头设备,将其重命名为cameraComboBox

  12. 垂直布局ComboBox下方添加一个名为captureButton按钮,让用户点击拍照。此按钮上应显示文本Capture

它应该看起来像以下截图:

在 Qt 中访问摄像头

现在,回到mainwindow.h编辑,如下所示:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include <QCamera>
#include <QCameraInfo>
#include <QCameraImageCapture>

namespace Ui {
  class MainWindow;
}

class MainWindow : public QMainWindow
{
  Q_OBJECT

public:
  explicit MainWindow(QWidget *parent = 0);
  ~MainWindow();

private:
  Ui::MainWindow *ui;
  QList<QCameraInfo> camList;
  QCamera *camera;
  QCameraImageCapture *imgCapture;

private slots:
  void onCameraChanged(int);
  void onCaptureButtonClicked();
  void onImageCaptured(int, const QImage &);
};

#endif // MAINWINDOW_H

如同往常,为了使用前面代码中的类,我们必须正确包含它们。此外,我们使用camList,它是一种QList<QCameraInfo>类型,用于存储可用的摄像头设备。由于QList是一个模板类,我们必须将列表元素的类型传递给构造函数,在这种情况下是QCameraInfo

这些私有槽负责摄像头控制,即更改摄像头设备和点击捕获按钮。同时,onImageCaptured用于处理QCameraImageCaptureimageCaptured信号。

maindow.cpp文件的内容如下所示:

#include "mainwindow.h"
#include "ui_mainwindow.h"

MainWindow::MainWindow(QWidget *parent) :
  QMainWindow(parent),
  ui(new Ui::MainWindow)
{
  ui->setupUi(this);

  camera = NULL;
  connect(ui->captureButton, &QPushButton::clicked, this, &MainWindow::onCaptureButtonClicked);
  connect(ui->cameraComboBox, static_cast<void (QComboBox::*) (int)>(&QComboBox::currentIndexChanged), this, &MainWindow::onCameraChanged);

  camList = QCameraInfo::availableCameras();
  for (QList<QCameraInfo>::iterator it = camList.begin(); it != camList.end(); ++it) {
    ui->cameraComboBox->addItem(it->description());
  }
}

MainWindow::~MainWindow()
{
  delete ui;
}

void MainWindow::onCameraChanged(int idx)
{
  if (camera != NULL) {
    camera->stop();
  }

  camera = new QCamera(camList.at(idx), this);
  camera->setViewfinder(ui->viewfinder);
  camera->setCaptureMode(QCamera::CaptureStillImage);
  camera->start();
}

void MainWindow::onCaptureButtonClicked()
{
  imgCapture = new QCameraImageCapture(camera, this);
  connect(imgCapture, &QCameraImageCapture::imageCaptured, this, &MainWindow::onImageCaptured);
  camera->searchAndLock();
  imgCapture->setCaptureDestination(QCameraImageCapture::CaptureToFile);
  imgCapture->capture();
}

void MainWindow::onImageCaptured(int, const QImage &img)
{
  QPixmap pix = QPixmap::fromImage(img).scaled(ui->previewLabel->size(), Qt::KeepAspectRatio);
  ui->previewLabel->setPixmap(pix);
  camera->unlock();
  imgCapture->deleteLater();
}

让我们先看看构造函数。我们给camera一个NULL地址来标记没有分配和/或激活摄像头。这将在后面解释。

由于QComboBox::currentIndexChanged有重载的信号函数,你必须使用static_cast指定你想要的信号。否则,编译器会报错并无法编译。只有信号和槽的新语法语句受到影响,这意味着这里显示的旧语法语句不会引起任何错误:

connect(ui->cameraComboBox, SIGNAL(currentIndexChanged(int)), this, SLOT(onCameraChanged(int)));

然而,如前所述,新语法有许多优点,并且强烈建议你替换旧语法。

在我们继续的过程中,我们可以用可用的摄像头填充camList,因为availableCamerasQCameraInfo的一个静态成员函数,它返回系统上所有可用的摄像头列表。你也可以传递一个参数来指定摄像头位置,如前置或后置摄像头,这在移动平台上非常有用。

然后,我们将camList中的所有项目添加到我们的相机combobox中。在这里,迭代器遍历列表并对每个项目进行操作。在处理列表、数组等时,使用迭代器非常快。Qt 支持这种方法,包括 Java 风格和 STL 风格的迭代器。在这种情况下,我们更喜欢并使用 STL 风格的迭代器。QCameraInfo的描述函数返回相机的可读描述。

现在,让我们进入onCameraChanged函数内部。在构建相机之前,我们需要检查是否已经存在相机。如果存在,我们停止旧的相机。然后,我们使用viewfinder小部件设置viewfinder类,这是我们之前在设计模式中做的。指定捕获模式为CaptureStillImage后,我们可以开始相机。

提示

如果没有释放(停止),相机将无法再次启动。

因此,它进入onCaptureButtonClicked槽。同样,imgCapture对象通过传递camerathis作为其QCamera目标和QObject父对象来构造。然后,我们必须将imageCaptured信号连接到MainWindowonImageCaptured槽。现在,让camera->searchAndLock()锁定所有相机设置。这个函数是对快门按钮半按的响应。在拍照之前,我们将捕获目标设置为文件。如果需要,可以使用QCameraImageCapture::CaptureToBuffer标志将其设置为缓冲区,但请注意,这并不是所有平台都支持的。

如果一切顺利,camera将捕获一张图片,并发出imageCaptured信号。然后,将执行onImageCaptured槽函数。在这个函数内部,我们将捕获的图片缩放到previewLabel的大小。然后,只需为previewLabel设置QPixmap并解锁camera。最后,我们调用deleteLater()函数来安全地删除imgCapture实例。

注意

你应该明确指出Qt::KeepAspectRatio,否则它将使用默认的Qt::IgnoreAspectRatio标志。

现在,运行演示并查看你得到的结果。

在 Qt 中访问相机

就像我们在前面的章节中所做的那样,你可以随意更改窗口标题、应用程序字体等。这些微小的调整将不再详细说明。

控制相机

QCameraFocus类被提及以控制相机的缩放和聚焦。说到缩放,Qt 支持光学和数字缩放。众所周知,光学缩放的质量优于数字缩放。因此,光学缩放应优先于数字缩放。

将一个水平滑块和一个标签拖到 MainWindow 面板的 verticalLayout 中,位于捕获按钮上方。分别命名为 zoomSliderzoomLabel。记得将 zoomLabel 的文本改为 Zoom,并将 alignment 中的文本改为 AlignHCenter。由于 Qt 不提供浮点数滑块,我们简单地乘以 10 以在滑块中得到一个整数。因此,将 zoomSliderminimum 值改为 10,这意味着缩放 1.0。

mainwindow.h 中包含 QCameraFocus 并添加这两个私有成员:

QCameraFocus *cameraFocus;
qreal maximumOptZoom;

提示

并非每个相机都支持缩放。如果不支持,最大缩放为 1.0,适用于光学和数字缩放。

存在一个名为 qreal 的类型,它基本上是一个 double 值。出于性能考虑,在 ARM 平台上它是 float,而在其他平台上是 double。然而,从 Qt 5.2 版本开始,Qt 默认在 ARM 上使用 double。无论如何,如果应用程序部署在不同的硬件平台上,建议使用 qreal

还需要声明一个新的插槽:

void onZoomChanged(int);

现在,在 MainWindow 类的构造函数中连接 zoomSlider

connect(ui->zoomSlider, &QSlider::valueChanged, this, &MainWindow::onZoomChanged);

然而,QCameraFocus 不能显式构造。相反,它只能从 QCamera 类中检索。因此,我们在 onCameraChangedcamera 参数构造之后获得 cameraFocus

cameraFocus = camera->focus();

然后,设置 maximumOptZoomzoomSlidermaximum 值:

maximumOptZoom = cameraFocus->maximumOpticalZoom();
ui->zoomSlider->setMaximum(maximumOptZoom * cameraFocus->maximumDigitalZoom() * 10);

如果相机根本不支持缩放,滑块将无法滑动。onZoomChanged 插槽的定义如下所示:

void MainWindow::onZoomChanged(int z)
{
  qreal zoom = qreal(z) / 10.0;
  if (zoom > maximumOptZoom) {
    cameraFocus->zoomTo(maximumOptZoom, zoom / maximumOptZoom);
  }
  else {
    cameraFocus->zoomTo(zoom, 1.0);
  }
}

如您所见,zoomTo 的第一个参数是光学缩放因子,而另一个是数字缩放因子。

在状态栏上显示错误

首先,整个相机过程中可能会出现错误,让用户了解错误是什么是一个好的做法。可以通过弹出对话框和/或状态栏来实现。你不想提醒用户每个琐碎的错误。因此,最好只对关键错误使用弹出对话框,而在状态栏上显示非关键错误和警告。

Qt 很早就开始支持状态栏了。QStatusBar 类是提供适合展示状态信息的水平栏。相机的状态也可以显示,这将在后续主题中介绍。

要使用状态栏,编辑 mainwindow.ui,右键单击 MainWindow,如果不存在或之前已删除,则选择 Create Status Bar

然后,我们应该声明两个插槽来分别处理相机和图像捕获错误。将这两行添加到 mainwindow.h 中的 private slots

void onCameraError();
void onImageCaptureError(int, QCameraImageCapture::Error, const QString &);

mainwindow.cpp 中的定义如下所示:

void MainWindow::onCameraError()
{
  ui->statusBar->showMessage(camera->errorString(), 5000);
}

void MainWindow::onImageCaptureError(int, QCameraImageCapture::Error, const QString &err)
{
  ui->statusBar->showMessage(err, 5000);
  imgCapture->deleteLater();
}

这只是让 statusBar 显示一个持续五秒钟的临时消息。即使你传递零给 showMessage,它仍然是一个临时消息。在后续情况下,它不会在给定时间段后消失;相反,如果有新的临时消息,它才会消失。

由于QCameraQCameraImageCapture中的信号错误不同,我们使用不同的槽来处理它。对于QCameraerror信号函数只有一个参数QCamera::Error

相比之下,QCameraImageCapture::error提供了三个参数:intQCameraImageCapture::Error和一个const QString引用。因此,我们可以通过直接使用其错误string来利用这个信号。

不要忘记连接信号和槽。在这里,在onCameraChanged函数中,在camera构造之后,连接camera错误和onCameraError槽。

connect(camera, static_cast<void (QCamera::*) (QCamera::Error)>(&QCamera::error), this, &MainWindow::onCameraError);

由于QCamera类中还有一个名为error的重载函数,我们必须使用static_cast来指定信号函数,就像我们在QComboBox中做的那样。

类似地,在onCaptureButtonClicked函数中imgCapture对象构造之后添加connect语句。

connect(imgCapture, static_cast<void (QCameraImageCapture::*) (int, QCameraImageCapture::Error, const QString &)>(&QCameraImageCapture::error), this, &MainWindow::onImageCaptureError);

这是另一个重载的error信号函数。然而,由于有三个参数,它很繁琐。

状态栏中的永久控件

有时,我们想在状态栏内显示某种指示器以显示实时状态,例如相机状态。如果它被临时消息覆盖,这就不合适了。在这种情况下,QStatusBar提供了insertPermanentWidget函数以永久地将控件添加到状态栏。这意味着它不会被临时消息遮挡,并且位于状态栏的右侧。

首先,让我们创建一个相机状态控件。添加一个名为CameraStatusWidget的新 C++类,它继承自QWidget,但使用QLabel作为基类。我们使用QLabel作为基类,因为相机的状态以文本形式显示,基本上是一个定制的标签控件。camerastatuswidget.h的内容如下所示:

#ifndef CAMERASTATUSWIDGET_H
#define CAMERASTATUSWIDGET_H

#include <QLabel>
#include <QCamera>

class CameraStatusWidget : public QLabel
{
  Q_OBJECT
  public:
    explicit CameraStatusWidget(QWidget *parent = 0);

  public slots:
    void onCameraStatusChanged(QCamera::Status);
};

#endif // CAMERASTATUSWIDGET_H

除了#include <QCamera>,我们只在这个头文件中添加了onCameraStatusChanged槽的声明。相关的camerastatuswidget.cpp源文件如下所示:

#include "camerastatuswidget.h"

CameraStatusWidget::CameraStatusWidget(QWidget *parent) :
  QLabel(parent)
{
}

void CameraStatusWidget::onCameraStatusChanged(QCamera::Status s)
{
  QString status;
  switch (s) {
    case QCamera::ActiveStatus:
      status = QString("Active");
      break;
    case QCamera::StartingStatus:
      status = QString("Starting");
      break;
    case QCamera::StoppingStatus:
      status = QString("Stopping");
      break;
    case QCamera::StandbyStatus:
      status = QString("Standby");
      break;
    case QCamera::LoadedStatus:
      status = QString("Loaded");
      break;
    case QCamera::LoadingStatus:
      status = QString("Loading");
      break;
    case QCamera::UnloadingStatus:
      status = QString("Unloading");
      break;
    case QCamera::UnloadedStatus:
      status = QString("Unloaded");
      break;
    case QCamera::UnavailableStatus:
      status = QString("Unavailable");
      break;
    default:
      status = QString("Unknown");
  }
  this->setText(status);
}

提示

总是在switch语句中处理异常。

QCamera::Status是一个enum类型。这就是为什么我们必须使用switch语句将状态转换为string。既然我们现在有了相机状态控件,是时候将其添加到状态栏了。编辑mainwindow.h并添加以下CameraStatusWidget指针:

CameraStatusWidget *camStatusWid;

不要忘记包含camerastatuswidget.h头文件。然后,在ui->setupUi(this)之后立即设置camStatusWid,添加以下行:

camStatusWid = new CameraStatusWidget(ui->statusBar);
ui->statusBar->addPermanentWidget(camStatusWid);

导航到onCameraChanged函数;我们需要连接QCamera::statusChanged信号。只需在相机构造之后添加以下行:

connect(camera, &QCamera::statusChanged, camStatusWid, &CameraStatusWidget::onCameraStatusChanged);

同样,我们还可以将当前缩放添加到状态栏。实际上,对于这个小型且易于实现的控件,我们不需要创建一个新的类。相反,我们将使用现有的QLabel类通过声明一个新成员来实现这一点。在mainwindow.h中添加一个新的私有成员:

QLabel *zoomStatus;

同时,在 mainwindow.cpp 文件的 MainWindow 类构造函数中构建并插入 zoomStatusstatusBar

zoomStatus = new QLabel(QString::number(qreal(ui->zoomSlider->value()) / 10.0), this);
ui->statusBar->addPermanentWidget(zoomStatus);

在这里,我们使用一个 number 函数,它是 QString 类的静态公共函数,用于将数字(可以是 doubleinteger)转换为 QString。为了及时更新 zoomStatus,将此行添加到 onZoomChanged 函数中:

zoomStatus->setText(QString::number(zoom));

经过这些修改后,应用程序将如以下截图所示运行:

状态栏中的永久小部件

利用菜单栏

现在你已经完成了底部的栏,是时候开始顶部的菜单栏了。与状态栏类似,在 设计 模式下右键单击 MainWindow,如果不存在或之前已删除,则选择 创建菜单栏

然后,只需遵循提示。添加一个包含 退出 动作的 文件 菜单。另一个菜单可以是 关于,其中包含 关于 CameraDemo 动作。你应该知道,这些动作可以在 动作编辑器 面板中更改,该面板与 信号与槽编辑器 在同一位置。

利用菜单栏

如以下截图所示,这些动作的名称分别更改为 actionAboutactionExit。此外,actionExit 有一个快捷键,Ctrl + Q。只需双击动作,然后按住你想要的快捷键添加快捷键。这在上面的截图中有展示:

利用菜单栏

我们已经在 第二章 构建一个漂亮的跨平台时钟 中使用了 QMenuQAction。这里的区别在于你使用 QMenu 作为菜单栏,并在 设计 模式下设置它,而不是编写代码。但为什么它叫做 QAction 呢?这是因为用户可以在菜单、工具栏或快捷键上触发一个命令。他们期望无论在哪里都能有相同的行为。因此,它应该被抽象成一个动作,可以插入到菜单或工具栏中。你可以将其设置为可复选的 QAction 选项,并用作简单的 QCheckbox 选项。

让我们先完成 actionExit,因为它比另一个简单。对于 actionExit,我们只需要一个 connect 语句来使其工作。将以下语句添加到 mainwindow.cpp 文件的 MainWindow 类构造函数中:

connect(ui->actionExit, &QAction::triggered, this, &MainWindow::close);

如果有快捷键,triggered 信号将由鼠标点击或键盘快捷键(如果有快捷键)触发。由于我们将其连接到 MainWindowclose 槽,它将关闭 MainWindow,从而导致整个应用程序退出。

同时,我们需要声明一个槽来满足与 actionAbout 的连接。像往常一样,在 mainwindow.h 头文件中声明它。

void onAboutTriggered();

你可能认为我们将创建一个新的类来显示关于对话框。但是,我们不必自己制作关于对话框,因为 Qt 已经为我们做了。它包含在QMessageBox中,所以你应该使用以下行包含它:

#include <QMessageBox>

这是槽的定义:

void MainWindow::onAboutTriggered()
{
  QMessageBox::about(this, QString("About"), QString("Camera Demonstration of Qt5"));
}

注意

QMessageBox类提供了一个模态对话框,用于通知用户或询问用户一个问题,并接收答案。

几乎所有类型的弹出对话框都可以在QMessageBox中找到。在这里,我们使用静态的About函数创建一个关于对话框。它有三个参数。第一个是父QObject指针,后面是标题和上下文。请记住在MainWindow类的构造函数中连接信号和槽。

connect(ui->actionAbout, &QAction::triggered, this, &MainWindow::onAboutTriggered);

如果再次编译并运行应用程序,尝试触发关于对话框,它将类似于以下截图:

利用菜单栏

注意

除了About之外,QMessageBox还有其他有用的静态公共成员。最常用的是criticalinformationquestionwarning,用于弹出消息框。有时,你会在菜单栏中看到关于 Qt的条目,这是调用aboutQt函数。

实际上,如果存在,关于对话框将显示一个图标。由于缺少图标,这里有一个空白区域。搜索图标的顺序如下所示:

  • 如果存在,这个第一个图标将是parent->icon()

  • 第二个图标将是包含parent的顶层小部件。

  • 第三个图标将是活动窗口。

  • 第四个图标将是Information图标。

使用 QFileDialog

拍照的最后一步是将照片保存到磁盘。在这个阶段,程序会将图像保存到文件中,但文件的位置由相机后端决定。我们可以简单地使用一个对话框,让用户选择照片的目录和文件名。有一个QFileDialog类可以帮助我们简化工作。创建QFileDialog类最简单的方法是使用静态函数。因此,请编辑mainwindow.cpp文件中的onCaptureButtonClicked函数。

void MainWindow::onCaptureButtonClicked()
{
  imgCapture = new QCameraImageCapture(camera, this);
  connect(imgCapture, static_cast<void (QCameraImageCapture::*) (int, QCameraImageCapture::Error, const QString &)>(&QCameraImageCapture::error), this, &MainWindow::onImageCaptureError);
  connect(imgCapture, &QCameraImageCapture::imageCaptured, this, &MainWindow::onImageCaptured);

  camera->searchAndLock();
  imgCapture->setCaptureDestination(QCameraImageCapture::CaptureToFile);
  QString location = QFileDialog::getSaveFileName(this, QString("Save Photo As"), QString(), "JPEG Image (*.jpg)");
  imgCapture->capture(location);
}

在这里,我们使用getSaveFileName静态函数创建一个文件对话框,以返回用户选择的文件。如果用户点击取消,则location类型将是一个空的QString引用,图像将被保存在默认位置。文件不需要存在。实际上,如果它存在,将会有一个覆盖对话框。此函数的原型如下所示:

QString QFileDialog::getSaveFileName(QWidget * parent = 0, const QString & caption = QString(), const QString & dir = QString(), const QString & filter = QString(), QString * selectedFilter = 0, Options options = 0)

第一个参数是QObject父对象,如通常一样。第二个是对话框的标题,后面是默认目录。filter对象用于限制文件类型,并且可以使用多个由两个分号;;分隔的过滤器。以下是一个示例:

"JPEG (*.jpeg *.jpg);;PNG (*.png);;BMP (*.bmp)"

设置selectedFilter可以更改默认过滤器。最后,Options用于定义文件对话框的行为。有关更多详细信息,请参阅QFileDialog文档。

QML 相机

到目前为止,我们讨论了如何在 Qt/C++中访问和控制相机。现在是时候看看 QML 在这个领域的表现了。尽管有一些限制,但你会发现,由于 Qt 提供了许多包,使用 Qt Quick/QML 来做这件事要容易得多,也更优雅。

创建一个新的 Qt Quick 应用程序项目。main.qml的内容如下所示:

import QtQuick 2.3
import QtQuick.Controls 1.2
import QtMultimedia 5.3
import "qrc:/"

ApplicationWindow {
  visible: true
  width: 640
  height: 480
  title: qsTr("QML Camera Demo")

  Camera {
    id: camera

    imageCapture {
      onImageCaptured: {
        photoPreview.source = preview
        photoPreview.visible = true;
        previewTimer.running = true;
      }
    }
  }

  VideoOutput {
    id: viewfinder
    source: camera
    anchors.fill: parent
  }

  Image {
    id: photoPreview
    anchors.fill: viewfinder
  }

  Timer {
    id: previewTimer
    interval: 2000
    onTriggered: photoPreview.visible = false;
  }

  CaptureButton {
    anchors.right: parent.right
    anchors.verticalCenter: parent.verticalCenter
    diameter: 50
  }
}

让我带你了解这个。

CameraVideoOutputQtMultimedia模块提供。与 Qt/C++类类似,Camera类型与QCamera类相同。当使用VideoOutput作为取景器时,预览的处理方式不同。一个image对象用于显示捕获的图片,并且每次拍照时只可见 2 秒钟。这个photoPreview由计时器previewTimer控制。换句话说,photoPreview的 2 秒显示取决于这个previewTimer计时器。同时,camera类型的imageCapture将提供preview图像给photoPreview,并在捕获照片时启动previewTimer

最后一个部分是CaptureButton,它不是由 Qt 提供的,而是在另一个文件CaptureButton.qml中编写的。其内容如下所示:

import QtQuick 2.3

Rectangle {
  property real diameter

  width: diameter
  height: diameter

  color: "blue"
  border.color: "grey"
  border.width: diameter / 5
  radius: diameter * 0.5

  MouseArea {
    anchors.fill: parent
    onClicked: camera.imageCapture.capture()
  }
}

由于 Qt Quick 没有提供圆形形状,我们使用这个Rectangle对象作为替代方案来显示圆形。就像我们在上一章所做的那样,定义一个diameter属性来同时持有heightwidth。关键在于半径值。通过将其设置为直径的一半,这个Rectangle对象就变成了圆形。最后但同样重要的是,添加MouseArea来响应用户的点击。遗憾的是,MouseArea不能是圆形的,所以只需留下它并填写按钮。

现在,你可以运行你的应用程序,它应该类似于以下内容:

QML 相机

它不如 Qt/C++演示强大。你可能首先注意到的是,你不能更改相机设备。在当前版本的 Qt 中缺少这一功能,但未来应该会支持。与此同时,唯一的解决方案是在主部分仍然是 QML 编写的同时编写一个 C++插件。由于在后面的章节中会介绍如何为 QML 开发 C++插件,所以我们在这里简单地跳过这一部分。

我们可以在 QML 中以一种更加优雅的方式制作文件对话框。Qt Quick 通过QtQuick.Dialogs模块提供了常用的对话框。因此,首先导入这个模块:

import QtQuick.Dialogs 1.2

我们感兴趣的是FileDialog类型,它提供了一个基本的文件选择器。它允许用户选择现有的文件和/或目录,或创建新的文件名。它尽可能使用原生平台的文件对话框。要使用此类型,请在main.qml文件中的ApplicationWindow内添加FileDialog

FileDialog {
  id: saveDlg
  property string location

  title: "Save Photo As"
  selectExisting: false
  nameFilters: [ "JPEG (*.jpg)" ]
  onAccepted: {
    location = saveDlg.fileUrl
    camera.imageCapture.captureToLocation(location.slice(8))
  }
}

QML 中的string类型是 JavaScript string类型的扩展版本。尽可能避免使用var关键字,而应使用确切的数据类型,如intdoublestring。根据 QML 文档,这样做可以提高性能,因为机器不需要猜测数据类型。在这里,我们声明了一个location,其类型为string,而其余属性与 Qt/C++中的对话框设置类似,包括其title(标题)和nameFilters。你应该将selectExisting属性设置为false,因为默认值为true。否则,它将表现得像一个打开文件对话框。

onAccepted处理程序内部,首先将fileUrl值传递给location。此处理程序是对accepted信号的响应,该信号在用户成功选择文件时发出。然后,fileUrl属性将发生变化。它采用 URI 格式,有一个额外的file:///前缀。此外,如果我们直接在fileUrl上执行slice操作,目前存在一个问题。因此,作为解决方案,我们使用显式声明的string location来调用slice函数。这是一个 JavaScript 函数,它将返回一个新的string类型,其中包含字符串的提取部分。slice方法的原型是slice(start, end),其中end将是字符串类型的末尾,如果省略。此外,请注意,start位置的字符被包含在内,索引从零开始。之后,我们简单地调用imageCapturecaptureToLocation函数,以将图像存储在所选位置。

为了使其工作,我们必须改变CaptureButton的行为。编辑CaptureButton.qml文件,并更改MouseArea,如下所示:

MouseArea {
  anchors.fill: parent
  onClicked: saveDlg.open()
  onPressed: parent.color = "black"
  onReleased: parent.color = "blue"
}

此外,为了改变onClicked处理程序,我们还添加了onPressedonReleased,以便它具有推压效果。正如你所看到的,open()函数将执行我们的FileDialog。在桌面操作系统(如 Windows)上,平台文件对话框被用作以下示例所示:

QML 相机

CaptureButton被按下时,其内部圆圈将变为黑色,然后在鼠标释放时恢复为蓝色。尽管这只是一个小小的视觉效果,但它确实提高了用户体验。

"不要因为规模小就错过行善的机会。"

为了完成这个 QML 相机应用程序,我们需要添加一个缩放控制,就像我们在 Qt/C++相机中做的那样。添加一个名为ZoomControl.qml的新 QML 文件,其内容如下所示:

import QtQuick 2.3

Item {
  property real zoom: camera.opticalZoom * camera.digitalZoom

  function zoomControl() {
    if (zoom > camera.maximumOpticalZoom) {
      camera.digitalZoom = zoom / camera.maximumOpticalZoom
      camera.opticalZoom = camera.maximumOpticalZoom
    }
    else {
      camera.digitalZoom = 1.0
      camera.opticalZoom = zoom
    }
  }

  Text {
    id: indicator
    anchors.fill: parent
    horizontalAlignment: Text.AlignHCenter
    verticalAlignment: Text.AlignVCenter
    color: "darkgrey"
    font.bold: true
    font.family: "Segoe"
    font.pointSize: 20
    style: Text.Raised
    styleColor: "black"
  }

  Timer {
    id: indicatorTimer
    interval: 2000
    onTriggered: indicator.visible = false
  }

  MouseArea {
    anchors.fill: parent
    onWheel: {
      if (wheel.angleDelta.y > 0) {
        zoom += 1.0
        if (zoom > camera.maximumOpticalZoom * camera.maximumOpticalZoom) {
          zoom -= 1.0
        }
        else {
          zoomControl()
        }
      }
      else {
        zoom -= 1.0
        if (zoom < camera.maximumOpticalZoom * camera.maximumOpticalZoom) {
          zoom += 1.0
        }
        else {
          zoomControl()
        }
      }
      indicator.text = "X " + zoom.toString()
      indicator.visible = true
      indicatorTimer.running = true
    }
  }
}

首先,我们声明一个property,类型为real,用于存储当前的缩放级别,其初始值是摄像头的当前缩放级别,这本身是当前数字缩放和光学缩放相乘的结果。随后是一个 JavaScript 风格的函数,zoomControl。如前所述,你可以在 QML 的任何地方无缝使用 JavaScript。此函数与上一主题中的 Qt/C++槽函数onZoomChanged相同。

然后,有一个 Text 元素用于在屏幕上显示当前的 zoom 函数。这些只是在 Text 元素内部的一些视觉自定义,包括通过设置水平和垂直对齐来在父元素中居中。

接下来是一个控制 Text 可见性的 Timer 元素,类似于 photoPreview 的控制器。

最后但也是最复杂的是 MouseArea。我们使用鼠标滚轮来控制缩放,因此可以获取滚轮事件的处理器是 onWheelwheel.angleDelta.y 是滚轮,它被旋转到垂直方向。如果是正数,它向上移动;否则,它向下移动。正数值用于放大,负数用于缩小。在调用 zoomControl 函数之前,你必须确保新的缩放值在 camera 支持的缩放范围内。之后,让 Text 指示器显示 zoom 并打开 Timer 以使其仅在 2 秒内可见。你还可以看到有一个内置函数可以将 real 元素转换为 string,就像 Qt/C++ 中的 QString::number 函数一样。

经过所有这些之后,编辑 main.qml 并将 ZoomControl 添加到应用程序中,如下面的代码所示:

ZoomControl {
  anchors.fill: viewfinder
}

注意,ZoomControl 应该填充 viewfinder 而不是 parent;否则,它可能被其他组件,如 viewfinder,覆盖。

对这个 QML 相机进行测试运行,并比较哪个更好。

摘要

到本章结束时,你应该能够编写能够访问 Qt/C++ 或 QML 中的相机设备的应用程序。更重要的是,你应该能够利用传统桌面应用程序中的状态栏和菜单栏,这些在历史上非常重要,并且继续作为交互式功能小部件发挥关键作用。此外,别忘了文件对话框和消息框,因为它们使你的编码工作变得更简单。在下一章中,我们将讨论一个高级主题,即插件,这是扩展大型应用程序的流行方式。

第五章:使用插件扩展绘图应用程序

插件使您能够使您的应用程序可扩展且对其他开发者友好。因此,在本章中,我们将指导您如何为 Qt 应用程序编写插件。一个绘图应用程序展示了 Qt/C++的配方。一个简单的演示向您展示了如何编写一个 C++插件用于 QML。本章我们将涵盖的主题如下所示:

  • 通过QPainter绘图

  • 编写静态插件

  • 编写动态插件

  • 合并插件和主程序项目

  • 为 QML 应用程序创建 C++插件

通过 QPainter 绘图

在我们开始之前,让我向您介绍QPainter类。这个类在窗口和其他绘图设备上执行低级绘图。实际上,在 Qt 应用程序中屏幕上绘制的所有内容都是QPainter的结果。它可以绘制几乎任何东西,包括简单的线条和对齐文本。多亏了 Qt 提供的高级 API,使用这些丰富的功能变得极其简单。

Qt 的绘图系统由QPainterQPaintDeviceQPaintEngine组成。在本章中,我们不需要处理后两者。关系图如下所示:

通过 QPainter 绘图

QPainter用于执行绘图操作,而QPaintDevice是一个可以由QPainter绘制的二维空间抽象。QPaintEngine提供了画家用于在不同类型的设备上绘图的接口。请注意,QPaintEngine类是QPainterQPaintDevice内部使用的。它也被设计成对程序员隐藏,除非他们创建自己的设备类型。

因此,我们基本上需要关注的是QPainter。让我们创建一个新的项目并在其中做一些练习。新的painter_demo项目是一个 Qt 小部件应用程序。快速创建它并添加一个新的从QWidget继承的 C++ Canvas类。Canvas是我们自定义的小部件,其头文件如下所示:

#ifndef CANVAS_H
#define CANVAS_H

#include <QWidget>

class Canvas : public QWidget
{
  Q_OBJECT
  public:
    explicit Canvas(QWidget *parent = 0);

  private:
    QVector<QPointF> m_points;

  protected:
    void paintEvent(QPaintEvent *);
    void mousePressEvent(QMouseEvent *);
    void mouseMoveEvent(QMouseEvent *);
    void mouseReleaseEvent(QMouseEvent *);
};

#endif // CANVAS_H

QVector类是一个模板类,它提供了一个快速且动态的数组。它之所以快速,是因为元素存储在相邻的内存位置,这意味着索引时间保持恒定。在这里,我们将QPointF元素存储在m_points中,其中QPointF是一个使用浮点精度定义点的类。

protected作用域中,有四个事件函数。我们熟悉这些鼠标事件。最前面的是新的事件,即paintEvent函数。由于我们在小部件上绘图,QPainter应该只在使用paintEvent函数时使用。

canvas.cpp中函数的定义如下所示:

#include <QStyleOption>
#include <QPainter>
#include <QPaintEvent>
#include <QMouseEvent>
#include "canvas.h"

Canvas::Canvas(QWidget *parent) :
  QWidget(parent)
{
}

void Canvas::paintEvent(QPaintEvent *)
{
  QPainter painter(this);

  QStyleOption opt;
  opt.initFrom(this);
  this->style()->drawPrimitive(QStyle::PE_Widget, &opt, &painter, this);

  painter.setPen(QColor(Qt::black));
  painter.setRenderHint(QPainter::Antialiasing);
  painter.drawPolyline(m_points.data(), m_points.count());
}

void Canvas::mousePressEvent(QMouseEvent *e)
{
  m_points.clear();
  m_points.append(e->localPos());
  this->update();
}

void Canvas::mouseMoveEvent(QMouseEvent *e)
{
  m_points.append(e->localPos());
  this->update();
}

void Canvas::mouseReleaseEvent(QMouseEvent *e)
{
  m_points.append(e->localPos());
  this->update();
}

首先,让我们检查paintEvent函数内部的内容。第一条是初始化一个QPainter对象,它使用此作为QPaintDevice。嗯,还有另一种初始化QPainter类的方法,这里将演示:

QPainter painter;
painter.begin(this);
painter.drawPolyline(m_points.data(), m_points.count());
painter.end();

如果你使用前面代码中所示的方法,请记住调用end()函数来销毁painter。相比之下,如果你通过构造函数初始化QPainter,析构函数将自动调用end()函数。然而,构造函数不会返回一个指示是否成功初始化的值。因此,当处理外部QPaintDevice(如打印机)时,选择后者方法会更好。

初始化之后,我们使用QStyleOption,它包含了QStyle函数绘制图形元素和使我们的自定义小部件样式感知所需的所有信息。我们简单地使用initFrom函数来获取样式信息。然后,我们获取我们小部件的QStyle函数,并使用painter和由opt指定的样式选项来绘制QStyle::PE_Widget。如果我们不写这三行代码,我们就无法更改小部件的显示样式,例如背景颜色。

然后,我们让画家使用一支黑色笔在部件上绘制一个抗锯齿多段线。在这里,使用了重载的setPen函数。painter.setPen(QColor(Qt::black))函数将设置一个宽度为1的实线样式笔,颜色为黑色。painter.setRenderHint(QPainter::Antialiasing)函数将使绘图平滑。

注意

第二个参数bool控制渲染提示。默认值为true,这意味着你需要打开渲染提示。虽然你可以通过传递false值来关闭渲染提示。

如下所示,列出了可用的渲染提示:

QPainter::Antialiasing
QPainter::TextAntialiasing
QPainter::SmoothPixmapTransform
QPainter::Qt4CompatiblePainting

此外,还有两个已废弃的提示:QPainter::HighQualityAntialiasingQPainter::NonCosmeticDefaultPen。第一个被QPainter::Antialiasing所取代,而第二个现在是无用的,因为QPen默认是非装饰性的。

最后,drawPolyline函数将在Canvas小部件上绘制一个多段线,该多段线由鼠标移动生成。第一个参数是指向QPointFQPoint数组的指针,而第二个参数是数组中项的数量。

说到鼠标移动,使用了三个鼠标事件函数来跟踪鼠标。实际上,它们相当直观。当鼠标按下事件发生时,清除点数组,因为很明显现在是一个新的多段线,然后通过调用localPos()函数添加鼠标位置。localPos()函数将返回鼠标相对于接收事件的部件或项的位置。虽然你可以通过screenPos()globalPos()函数获取全局位置,但在大多数情况下,我们只需要本地位置。在这些事件函数的末尾,调用update()来重新绘制小部件,以显示鼠标移动路径作为多段线。

现在,在设计模式下编辑mainwindow.ui。删除状态栏,因为我们在这个章节中不会使用它,但保留菜单栏。将Widget拖到centralWidget上,并将其重命名为canvas。右键单击canvas并选择提升到…,然后在提升的类名中填写Canvas。现在,点击添加,然后点击提升。你不应该勾选全局包含框,因为canvas.h头文件在我们的项目目录中,而不是全局包含目录中。

属性中,编辑styleSheet,输入background-color: rgb(255, 255, 255);以便画布具有白色背景。然后,将MainWindow类的布局更改为水平布局垂直布局,以便canvas小部件可以填充整个框架。现在运行你的应用程序;你应该期望看到一个简单的白色画笔,如下所示:

通过 QPainter 绘图

这个画笔太简单,无法保留旧线条。虽然 Qt 没有提供在旧场景上绘制的 API,但QImage可以帮助我们摆脱这个困境。换句话说,当鼠标移动时,我们在QImage对象上绘制一个笔触,然后将这个QImage对象绘制到Canvas上。

新的头文件canvas.h如下所示:

#ifndef CANVAS_H
#define CANVAS_H

#include <QWidget>

class Canvas : public QWidget
{
  Q_OBJECT
  public:
    explicit Canvas(QWidget *parent = 0);

  private:
    QVector<QPointF> m_points;
    QImage image;

    void updateImage();

  protected:
    void paintEvent(QPaintEvent *);
    void mousePressEvent(QMouseEvent *);
    void mouseMoveEvent(QMouseEvent *);
    void mouseReleaseEvent(QMouseEvent *);
    void resizeEvent(QResizeEvent *);
};

#endif // CANVAS_H

差异包括QImage对象的声明,image;私有成员函数,updateImage();以及重写的函数,resizeEvent(QResizeEvent *)paintEvent(QPaintEvent *)函数也被修改为绘制image对象,而在canvas.cpp源文件中的修改比头文件要多,其内容如下所示:

#include <QStyleOption>
#include <QPainter>
#include <QPaintEvent>
#include <QMouseEvent>
#include <QResizeEvent>
#include "canvas.h"

Canvas::Canvas(QWidget *parent) :
  QWidget(parent)
{
}

void Canvas::paintEvent(QPaintEvent *e)
{
  QPainter painter(this);

  QStyleOption opt;
  opt.initFrom(this);
  this->style()->drawPrimitive(QStyle::PE_Widget, &opt, &painter, this);

 painter.drawImage(e->rect().topLeft(), image);
}

void Canvas::updateImage()
{
  QPainter painter(&image);
  painter.setPen(QColor(Qt::black));
  painter.setRenderHint(QPainter::Antialiasing);
  painter.drawPolyline(m_points.data(), m_points.count());
  this->update();
}

void Canvas::mousePressEvent(QMouseEvent *e)
{
  m_points.clear();
  m_points.append(e->localPos());
  updateImage();
}

void Canvas::mouseMoveEvent(QMouseEvent *e)
{
  m_points.append(e->localPos());
  updateImage();
}

void Canvas::mouseReleaseEvent(QMouseEvent *e)
{
  m_points.append(e->localPos());
  updateImage();
}

void Canvas::resizeEvent(QResizeEvent *e)
{
  QImage newImage(e->size(), QImage::Format_RGB32);
  newImage.fill(Qt::white);
  QPainter painter(&newImage);
  painter.drawImage(0, 0, image);
  image = newImage;
  QWidget::resizeEvent(e);
}

让我们来看看鼠标事件处理器;在m_points操作之后,调用的是updateImage()函数而不是update()函数。在updateImage()函数内部,我们使用QImage对象作为QPaintDevice来创建一个QPainter对象,而其余的与paintEvent中的相同。

尽管如此,还有一个新的成员函数,名为resizeEvent,它是从QWidget重写的。正如你所想象的那样,我们在小部件大小改变时更改了底层的QImage对象,这可能是由于窗口大小调整的结果。因此,我们只需将旧图像绘制到新图像上。如果新的大小小于之前的大小,这可能会导致图像的一部分丢失。你可能希望向MainWindow添加Scroll Area,并将Canvas作为Scroll Area的子小部件。你已经在 QML 中知道了如何做,而在 Qt/C++中也是类似的。因此,只需将其视为练习,并为这个应用程序实现Scroll Area

编写静态插件

插件有两种类型:静态和动态。静态插件与可执行文件静态链接,而动态插件在运行时加载。动态插件以.dll.so文件的形式存在,具体取决于平台。尽管静态插件将被构建为.lib.a文件,但它们将在主程序编译时集成到可执行文件中。

在这个主题中,我们将了解如何编写一个静态插件以扩展应用程序。作为外部插件,它获得了在保持接口兼容性的同时更改其内部代码的灵活性。是否在主程序或不同的插件中维护接口取决于您。在这个例子中,我们将interface.h文件放在主程序painter_demo中。interface.h的内容如下:

#ifndef INTERFACE_H
#define INTERFACE_H

#include <QtPlugin>
#include <QPainterPath>

class InsertInterface
{
  public:
    virtual ~InsertInterface() {}
    virtual QString name() const = 0;
    virtual QPainterPath getObject(QWidget *parent) = 0;
};

#define InsertInterface_iid "org.qt-project.Qt.PainterDemo.InsertInterface"
Q_DECLARE_INTERFACE(InsertInterface, InsertInterface_iid)

#endif // INTERFACE_H

如您所见,我们声明了一个纯虚类InsertInterface。为了避免错误,您必须声明一个虚析构函数。否则,编译器可能会抱怨并终止编译。QPainterPath类提供了一个用于常见 2D 绘图操作的容器,包括ellipsetext。因此,getObject的返回类型是QPainterPath,如果有一个新创建的对话框从用户那里获取任何输入,QWidget参数可以直接使用。

在此文件的末尾,我们通过Q_DECLARE_INTERFACE宏将InsertInterface声明为一个接口,其中InsertInterface_iidInsertInterface类的标识符。请注意,标识符必须是唯一的,因此建议您使用 Java 风格的命名规则。

现在,我们需要创建一个新的项目。导航到 | C++库。然后,如以下截图所示,选择Qt 插件作为类型,并为了方便或任何顾虑,将此项目放在主程序项目文件夹内:

编写静态插件

点击下一步并选择与painter_demo项目相同的 Qt 工具包。在这个例子中,build目录设置在与painter_demo项目相同的目录中,即D:\Projects\build。因此,TextPluginbuild目录为D:\Projects\build\TextPlugin-Qt_5_4_0_mingw491_32-DebugD:\Projects\build\TextPlugin-Qt_5_4_0_mingw491_32-Release,分别对应DebugRelease

注意

此外,您可以在工具 | 选项 | 构建和运行 | 常规中更改默认构建目录。在这本书中,我们使用D:/Projects/build/%{CurrentProject:Name}-%{CurrentKit:FileSystemName}-%{CurrentBuild:Name},以便将所有构建组织在一个地方。

然后,在类名字段中填写TextPlugin,如下面的截图所示:

编写静态插件

我们需要对TextPlugin.pro项目文件进行一些修改,如下所示:

QT       += core gui widgets

TARGET = TextPlugin
TEMPLATE = lib
CONFIG += plugin static

DESTDIR = ../plugins

SOURCES += textplugin.cpp

INCLUDEPATH += ../

HEADERS += textplugin.h
OTHER_FILES += TextPlugin.json

通过添加小部件,我们可以使用一些有用的类,例如 QMessageBox。我们还需要将 static 添加到 CONFIG 中,以声明这是一个静态插件项目。然后,将 DESTDIR 变量更改为 ../plugins,以便插件安装到 build 文件夹外的 plugins 目录。最后,我们将上级目录 ../ 添加到 INCLUDEPATH 中,以便我们可以在这个子项目中包含 interface.h 头文件。textplugin.h 文件如下所示:

#ifndef TEXTPLUGIN_H
#define TEXTPLUGIN_H

#include "interface.h"

class TextPlugin : public QObject,
                   public InsertInterface
{
  Q_OBJECT
  Q_PLUGIN_METADATA(IID "org.qt-project.Qt.PainterDemo.InsertInterface" FILE "TextPlugin.json")
  Q_INTERFACES(InsertInterface)

  public:
    QString name() const;
    QPainterPath getObject(QWidget *parent);
};

#endif // TEXTPLUGIN_H

我们使用 Q_PLUGIN_METADATA 宏来指定唯一的 IID,它与我们在 interface.h 中声明的相同,其中 FILE "TextPlugin.json" 可以用来包含此插件的元数据。在这种情况下,我们只是保留 TextPlugin.json 文件不变。然后,Q_INTERFACES 宏告诉编译器这是一个 InsertInterface 的插件。在 public 范围内,只有两个重新实现的功能。它们的定义位于 textplugin.cpp 源文件中,其内容如下所示:

#include <QInputDialog>
#include "textplugin.h"

QString TextPlugin::name() const
{
  return QString("Text");
}

QPainterPath TextPlugin::getObject(QWidget *parent)
{
  QPainterPath ppath;
  QString text = QInputDialog::getText(parent, QString("Insert Text"), QString("Text"));

  if (!text.isEmpty()) {
    ppath.addText(10, 80, QFont("Cambria", 60), text);
  }
  return ppath;
}

name() 函数简单地返回这个插件的名称,在这个例子中是 Text。至于 getObject,它构建一个包含用户通过弹出对话框提供的文本的 QPainterPath 类,然后将 QPainterPath 对象返回给主程序。addText 函数将文本绘制为从字体创建的一组封闭子路径,而前两个参数定义了此文本基线的左端。

插件项目就到这里。现在,只需构建它,你应该期望在 plugins 目录下找到一个 libTextPlugin.a 文件,而 plugins 目录本身应该位于项目 build 文件夹的父目录中,如图所示:

编写静态插件

如果你将文件放在其他目录下,这并不会影响太多,尽管这意味着你可能需要相应地进行一些路径修改。

现在,让我们回到主程序的项目,在这个例子中是 painter_demo。编辑它的 painter_demo.pro 项目文件,并向其中添加以下行:

LIBS     += -L../plugins -lTextPlugin

提示

编译过程中的工作目录是 build 目录,而不是项目源代码目录。

然后,在 设计 模式下编辑 mainwindow.ui;向菜单栏添加一个名为 Plugins 的菜单,其对象名为 menuPlugins

在主程序的所有更改中,对 MainWindow 类的修改最多。以下是新 mainwindow.h 文件的代码:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>

namespace Ui {
  class MainWindow;
}

class MainWindow : public QMainWindow
{
  Q_OBJECT

  public:
    explicit MainWindow(QWidget *parent = 0);
    ~MainWindow();

  private:
    Ui::MainWindow *ui;

    void loadPlugins();
    void generatePluginMenu(QObject *);

  private slots:
    void onInsertInterface();
};

#endif // MAINWINDOW_H

对于这个问题还是毫无头绪?嗯,它的 mainwindow.cpp 源文件也粘贴在这里:

#include <QPluginLoader>
#include "mainwindow.h"
#include "ui_mainwindow.h"
#include "interface.h"

Q_IMPORT_PLUGIN(TextPlugin)

MainWindow::MainWindow(QWidget *parent) :
  QMainWindow(parent),
  ui(new Ui::MainWindow)
{
  ui->setupUi(this);
  loadPlugins();
}

MainWindow::~MainWindow()
{
  delete ui;
}

void MainWindow::loadPlugins()
{
  foreach(QObject *plugin, QPluginLoader::staticInstances()) {
    generatePluginMenu(plugin);
  }
}

void MainWindow::generatePluginMenu(QObject *plugin)
{
  InsertInterface *insertInterfacePlugin = qobject_cast<InsertInterface *>(plugin);
  if (insertInterfacePlugin) {
    QAction *action = new QAction(insertInterfacePlugin->name(), plugin);
    connect(action, &QAction::triggered, this, &MainWindow::onInsertInterface);
    ui->menuPlugins->addAction(action);
  }
}

void MainWindow::onInsertInterface()
{
  QAction *action = qobject_cast<QAction *>(sender());
  InsertInterface *insertInterfacePlugin = qobject_cast<InsertInterface *>(action->parent());
  const QPainterPath ppath = insertInterfacePlugin->getObject(this);
  if (!ppath.isEmpty()) {
    ui->canvas->insertPainterPath(ppath);
  }
}

你可能已经弄清楚,Q_IMPORT_PLUGIN 宏用于导入插件。是的,是这样的,但仅限于静态插件。在 loadPlugins() 函数中,我们遍历了所有静态插件实例,并通过调用 generatePluginMenu 函数将它们添加到菜单中。

插件最初被视为普通的QOjbect对象,然后您可以使用qobject_cast将它们转换为它们自己的类。如果qobject_cast类失败,它将返回一个NULL指针。在if语句中,有一个技巧可以在稍后成功使用插件。我们不是调用简化和重载的addAction函数,而是构造QAction并将其添加到菜单中,因为QAction将插件作为其QObject父对象。因此,您可以看到我们在onInsertInterface函数中将它的父对象转换为相关的插件类。在这个函数内部,我们调用insertPainterPath函数,将插件返回的QPainterPath类绘制到canvas上。当然,我们需要在Canvas类中声明和定义这个函数。将此语句添加到canvas.h文件的public域:

void insertPainterPath(const QPainterPath &);

前面代码在canvas.cpp中的定义如下:

void Canvas::insertPainterPath(const QPainterPath &ppath)
{
  QPainter painter(&image);
  painter.drawPath(ppath);
  this->update();
}

前面的语句应该对您来说很熟悉,它们也是自我解释的。现在,再次构建并运行此应用程序;不要忘记通过右键单击painter_demo项目并选择将"painter_demo"设置为活动项目来将当前活动项目切换回painter_demo。当它运行时,点击插件,选择文本,在弹出对话框中输入Plugin!!,然后确认。然后,您将看到文本Plugin!!如预期地绘制在画布上。

编写静态插件

可执行文件的大小也会增加,因为我们将我们的TextPlugin项目文件静态链接到了它。此外,如果您更改了插件,您还必须重新构建主程序。否则,新生成的插件将不会像应该的那样链接到可执行文件。

编写动态插件

静态插件提供了一种方便的方式来分发您的应用程序。然而,这通常需要重新构建主程序。相比之下,动态插件由于它们是动态链接的,因此具有更大的灵活性。这意味着在本例中,主项目painter_demo不需要使用动态插件构建,也不需要发布其源代码。相反,它只需要提供一个接口及其头文件,然后在运行时扫描这些动态插件,以便它们可以被加载。

注意

动态插件在复杂应用程序中很常见,尤其是在像 Adobe Illustrator 这样的商业软件中。

与我们刚刚编写的静态插件类似,我们需要创建一个新的 Qt 插件项目,这次我们将其命名为EllipsePlugin。虽然您可以在插件中编写新的接口,但在这里我们将只关注插件相关的话题。因此,我们只是重用了InsertInterface类,而ellipseplugin.pro项目文件如下所示:

QT       += core gui widgets

TARGET = EllipsePlugin
TEMPLATE = lib
CONFIG += plugin

DESTDIR = ../plugins

SOURCES +=  ellipseplugin.cpp \
            ellipsedialog.cpp

HEADERS +=  ellipseplugin.h \
            ellipsedialog.h
OTHER_FILES += EllipsePlugin.json

INCLUDEPATH += ../

FORMS += ellipsedialog.ui

尽管如此,不要忘记在ellipseplugin.pro文件中更改DESTDIRINCLUDEPATH变量,它们基本上与之前的TextPlugin项目相同。

忽略源文件、表单等,基本上是一样的,只是去掉了 CONFIG 中的 static。以下展示了 ellipseplugin.h 头文件:

#ifndef ELLIPSEPLUGIN_H
#define ELLIPSEPLUGIN_H

#include "interface.h"

class EllipsePlugin : public QObject,
                      public InsertInterface
{
  Q_OBJECT
  Q_PLUGIN_METADATA(IID "org.qt-project.Qt.PainterDemo.InsertInterface" FILE "EllipsePlugin.json")
  Q_INTERFACES(InsertInterface)

  public:
    QString name() const;
    QPainterPath getObject(QWidget *parent);

  public slots:
    void onDialogAccepted(qreal x, qreal y, qreal wid, qreal hgt);

  private:
    qreal m_x;
    qreal m_y;
    qreal width;
    qreal height;
};

#endif // ELLIPSEPLUGIN_H

如前述代码所示,我们声明这是一个插件,使用 InsertInterface 作为与 TextPlugin 相同的方式,而不同之处在于声明了一个 onDialogAccepted 插槽函数和几个 private 变量。因此,ellipseplugin.cpp 文件如下所示:

#include "ellipsedialog.h"
#include "ellipseplugin.h"

QString EllipsePlugin::name() const
{
  return QString("Ellipse");
}

QPainterPath EllipsePlugin::getObject(QWidget *parent)
{
  m_x = 0;
  m_y = 0;
  width = 0;
  height = 0;

  EllipseDialog *dlg = new EllipseDialog(parent);
  connect(dlg, &EllipseDialog::accepted, this, &EllipsePlugin::onDialogAccepted);
  dlg->exec();

  QPainterPath ppath;
  ppath.addEllipse(m_x, m_y, width, height);
  return ppath;
}

void EllipsePlugin::onDialogAccepted(qreal x, qreal y, qreal wid, qreal hgt)
{
  m_x = x;
  m_y = y;
  width = wid;
  height = hgt;
}

name() 函数没有特别之处。相比之下,我们使用 EllipseDialog 自定义对话框从用户那里获取一些输入。记住在执行 exec() 函数之前连接与对话框相关的所有信号和槽;否则,槽将无法连接。还要注意,exec() 函数将阻塞事件循环,并且只有在对话框关闭后才会返回,这对于我们的目的来说非常方便,因为我们可以使用接受值,如 m_xm_y,来向 QPainterPath 添加椭圆。

至于 EllipseDialog 自定义对话框本身,它是通过在 Qt Creator 中添加一个新的 Qt Designer 表单类创建的。由于它用于为用户提供指定一些参数的接口,我们在该对话框中使用了 表单布局。按照以下截图中的建议添加 QLabelQDoubleSpinBox

编写动态插件

因此,它们的 objectName 值分别是 tlXLabeltlXDoubleSpinBoxtlYLabeltlYDoubleSpinBoxwidthLabelwidthDoubleSpinBoxheightLabelheightDoubleSpinBox。你还需要在 QDoubleSpinBox属性 面板中将 最大值 改为 9999.99 或更大的数值。

此外,还要注意在 信号与槽编辑器 中移除了默认的信号和槽。只需删除 buttonBoxaccepted() 信号对,因为我们需要一个更高级的处理程序。在这个表单类头文件 ellipsedialog.h 中,我们声明了一个新的信号和一个新的槽:

#ifndef ELLIPSEDIALOG_H
#define ELLIPSEDIALOG_H

#include <QDialog>

namespace Ui {
  class EllipseDialog;
}

class EllipseDialog : public QDialog
{
  Q_OBJECT

  public:
    explicit EllipseDialog(QWidget *parent = 0);
    ~EllipseDialog();

  signals:
    void accepted(qreal, qreal, qreal, qreal);

  private:
    Ui::EllipseDialog *ui;

  private slots:
    void onAccepted();
};

#endif // ELLIPSEDIALOG_H

这里传递的 accepted(qreal, qreal, qreal, qreal) 信号将这些值传回插件,而 onAccepted() 插槽处理来自 buttonBoxaccepted() 信号。它们在 ellipsedialog.cpp 源文件中定义,如下所示:

#include "ellipsedialog.h"
#include "ui_ellipsedialog.h"

EllipseDialog::EllipseDialog(QWidget *parent) :
    QDialog(parent),
    ui(new Ui::EllipseDialog)
{
  ui->setupUi(this);

  connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &EllipseDialog::onAccepted);
}

EllipseDialog::~EllipseDialog()
{
  delete ui;
}

void EllipseDialog::onAccepted()
{
  emit accepted(ui->tlXDoubleSpinBox->value(), ui->tlYDoubleSpinBox->value(), ui->widthDoubleSpinBox->value(), ui->heightDoubleSpinBox->value());
  this->accept();
}

在构造函数中,将 buttonBoxaccepted() 信号连接到 onAccepted() 高级处理槽。在这个槽中,我们发出 accepted 信号,其中包含用户输入的值。然后,调用 accept() 函数来关闭此对话框。

到此为止,EllipsePlugin 已完成。在面板中单击 构建 按钮来构建此项目。你应该期望输出,Windows 上的 EllipsePlugin.dll,位于与之前的 TextPlugin 项目相同的 plugins 目录中。

要使用这个动态插件,我们需要一个最终步骤,即让主程序加载动态插件(s)。在这里我们需要更改的是mainwindow.cpp中的loadPlugins()函数:

void MainWindow::loadPlugins()
{
  foreach(QObject *plugin, QPluginLoader::staticInstances()) {
    generatePluginMenu(plugin);
  }

  //search and load dynamic plugins
  QDir pluginDir = QDir(qApp->applicationDirPath());
  #ifdef Q_OS_WIN
  QString dirName = pluginDir.dirName();
  if (dirName.compare(QString("debug"), Qt::CaseInsensitive) == 0 || dirName.compare(QString("release"), Qt::CaseInsensitive) == 0) {
    pluginDir.cdUp();
    pluginDir.cdUp();
  }
  #endif
  pluginDir.cd(QString("plugins"));

  foreach (QString fileName, pluginDir.entryList(QDir::Files)) {
    QPluginLoader loader(pluginDir.absoluteFilePath(fileName));
    QObject *plugin = loader.instance();
    if (plugin) {
      generatePluginMenu(plugin);
    }
  }
}

为了使用QDir类,你可能还需要包含以下内容:

#include <QDir>

QDir类将提供对目录结构和其内容的访问,我们使用它来定位我们的动态插件。qApp宏是一个全局指针,指向这个应用程序实例。它对于非 GUI 和 GUI 应用程序分别等同于QCoreApplication::instance()函数和QApplication::instance()。在 Windows 平台上,我们的plugins目录位于build路径的第二上级目录。

然后,我们只需测试plugins目录中的每个文件,加载它,如果它是一个可加载的插件,就生成适当的菜单项。再次运行此应用程序;你将在插件菜单中看到一个椭圆条目。它按预期工作。

编写动态插件

合并插件和主程序项目

打开几个项目并按顺序构建它们是一件繁琐的事情。鉴于我们只有两个插件和一个主程序,这并不是什么大问题。然而,一旦插件数量增加,这将成为一个严重的效率问题。因此,将插件合并到主项目中,并在每次点击构建按钮时按指定顺序构建它们,是一种更好的做法。这在 Qt 项目中是完全可行的,并且很常见。

首先,我们将painter_demo目录中的所有文件(除了EllipsePluginTextPlugin文件夹)移动到一个新创建的main文件夹中。

然后,在main文件夹中,将painter_demo.pro重命名为main.pro,同时在painter_demo目录外创建一个新的painter_demo.pro项目文件。这个新的painter_demo.pro项目文件需要包含以下代码所示的内容:

TEMPLATE  = subdirs
CONFIG   += ordered
SUBDIRS   = TextPlugin \
            EllipsePlugin \
            main

subdirs项目是一个特殊的模板,这意味着这个项目文件不会生成应用程序或库。相反,它告诉qmake构建子目录。通过将ordered添加到CONFIG中,我们可以确保编译过程按照SUBDIRS中的确切顺序进行。

为了完成这个任务,我们需要修改两个插件目录中的项目文件。将INCLUDEPATH变量更改为以下行:

INCLUDEPATH += ../main

这个更改很明显,因为我们已经将所有源代码移动到了main目录。如果我们不更改INCLUDEPATH,编译器将抱怨找不到interface.h头文件。

为 QML 应用程序创建 C++插件

为 Qt/C++应用程序编写插件并不太难,而创建 QML 应用程序的插件则稍微复杂一些。思路是相同的,在这里我们将使用一个非常基本的示例来演示这个主题。基本上,这个应用程序将文本输入编码为Base64并显示出来。Base64编码部分是在 C++插件中实现的。

这次,我们将首先创建插件项目,然后完成 QML 部分。为 QML 应用程序创建插件项目遵循相同的步骤。导航到 | C++库,然后选择名为Base64PluginQt 插件。其项目文件Base64Plugin.pro如下所示:

QT       += core qml

TARGET = qmlbase64Plugin
TEMPLATE = lib
CONFIG += plugin

DESTDIR = ../imports/Base64

SOURCES += base64.cpp \
           base64plugin.cpp

HEADERS += base64.h \
           base64plugin.h

OTHER_FILES += \
           qmldir

我们将DESTDIR设置为../imports/Base64以方便起见。你可以将其更改为其他路径,但可能需要稍后进行一些相关更改才能导入此插件。

此项目由两个 C++类组成。Base64类将被导出到 QML 中,而Base64Plugin注册Base64类。前者的base64.h头文件如下所示:

#ifndef BASE64_H
#define BASE64_H

#include <QObject>

class Base64 : public QObject
{
  Q_OBJECT

  public:
    explicit Base64(QObject *parent = 0);

  public slots:
    QString get(QString);
};

#endif // BASE64_H

base.cpp的对应部分定义了get函数,如下所示:

#include "base64.h"

Base64::Base64(QObject *parent) :
  QObject(parent)
{
}

QString Base64::get(QString in)
{
  return QString::fromLocal8Bit(in.toLocal8Bit().toBase64());
}

复杂的部分在Base64Plugin类中,它与之前的插件类不完全相同。其base64plugin.h头文件如下所示:

#ifndef BASE64PLUGIN_H
#define BASE64PLUGIN_H

#include <QQmlExtensionPlugin>

class Base64Plugin : public QQmlExtensionPlugin
{
  Q_OBJECT
  Q_PLUGIN_METADATA(IID "org.qt-project.Qt.QmlExtensionInterface")

  public:
    void registerTypes(const char *uri);
};

#endif // BASE64PLUGIN_H

通过QQmlExtensionPlugin子类,我们可以编写自己的 QML 插件。实际上,这个类用于声明 QML 中的Base64类。还请注意,由于Q_PLUGIN_METADATA中的IID是固定的,你可能不想更改它。作为一个子类,它必须重新实现registerTypes函数,该函数简单地注册类。详细的定义位于baseplugin.cpp文件中,其内容如下所示:

#include <QtQml>
#include "base64plugin.h"
#include "base64.h"

void Base64Plugin::registerTypes(const char *uri)
{
  Q_ASSERT(uri == QLatin1String("Base64"));
  qmlRegisterType<Base64>(uri, 1, 0, "Base64");
}

Q_ASSERT宏将确保插件位于Base64目录内。如果不是,它将打印包含源代码、文件名和行号的警告信息。请注意,在此情况下预期的uriBase64,它是 QML 的模块名。在这行下面,qmlRegisterType是一个模板函数,其中你需要将类名Base64放在括号内。这些参数将使用版本 1.0 将类注册为具有Base64 QML 名称。

最后还需要一个声明可加载插件的文件,即qmldir文件。请注意,它没有扩展名。此文件定义了模块名和目录中的相关文件。以下是内容:

module Base64
plugin qmlbase64Plugin

我们需要将此文件放在../imports/Base64目录中,这是Base64PluginDESTDIR。在 QML 应用程序项目的main.cpp文件中的几行代码之后,QML 就可以像导入任何其他 Qt Quick 模块一样导入插件。

现在是时候创建一个新的 Qt Quick 应用程序项目了。项目名称简单地是QML_Plugin,我们将Base64Plugin类移动到QML_Plugin目录中,这样 Qt Creator 就能突出显示Base64Plugin类。以下是main.qml的内容:

import QtQuick 2.3
import QtQuick.Controls 1.2
import Base64 1.0

ApplicationWindow {
  visible: true
  width: 180
  height: 100
  title: qsTr("QML Plugin")

  Base64 {
    id: b64
  }

  Column {
    spacing: 6
    anchors {left: parent.left; right: parent.right; top: parent.top; bottom: parent.bottom; leftMargin: 6; rightMargin: 6; topMargin: 6; bottomMargin: 6}
    Label {
      text: "Input"
    }
    TextField {
      id: input
      width: parent.width
      placeholderText: "Input string here"
      onEditingFinished: bt.text = b64.get(text)
    }
    Label {
      text: "Base64 Encoded"
    }
    TextField {
      id: bt
      readOnly: true
      width: parent.width
    }
  }
}

记得在代码开头声明import Base64 1.0,以便我们的插件可以被加载。然后,Base64就像我们之前使用过的其他 QML 类型一样。在input TextFieldonEditingFinished处理程序中,我们使用Base64类中的get函数,将bt.text设置为相应的Base64类编码字符串。

你可能会想知道如何将 QML 的string类型转换为QString对象。嗯,在 QML 和 Qt/C++之间是隐式转换的。对于常见的 QML 数据类型和 Qt 数据类,有很多这样的转换。有关详细信息,您可以查看 Qt 文档以查看完整列表。

另一件事是我们需要更改main.cpp,如前所述。类似于 Qt/C++的情况,我们使用QDir类来获取应用程序目录并将其更改为../imports。请注意,您应该使用addImportPath而不是addPluginPath来将../imports添加到 QML 引擎的模块搜索路径。这是因为我们使用Base64作为模块,它应该位于imports路径。同时,插件路径是用于导入模块的本地插件,这些插件在qmldir中声明。main.cpp文件的内容如下:

#include <QApplication>
#include <QDir>
#include <QQmlApplicationEngine>

int main(int argc, char *argv[])
{
  QApplication app(argc, argv);

  QQmlApplicationEngine engine;
  QDir pluginDir = app.applicationDirPath();
  pluginDir.cdUp();
  pluginDir.cdUp();
  pluginDir.cd("imports");
  engine.addImportPath(pluginDir.absolutePath());
  engine.load(QUrl(QStringLiteral("qrc:/main.qml")));

  return app.exec();
}

为了运行此应用程序,请执行以下步骤:

  1. 构建Base64Plugin

  2. qmldir文件复制到../imports/Base64目录(imports文件夹应位于与plugins相同的目录中)。

  3. 构建并运行QML_Plugin项目。

您可以通过在第一个输入字段中输入任何字符串并仅按Enter键来测试此应用程序。此应用程序的一个场景是将您的电子邮件地址编码以避免网络爬虫,如下所示:

为 QML 应用程序创建 C++插件

如果模块没有正确放置,应用程序将不会显示,并且会抱怨Base64未安装。如果发生这种情况,请确保在main.cpp中添加正确的路径,并且在Base64文件夹内有一个qmldir文件。

摘要

开始编写插件有些困难。然而,经过一些基本实践后,你会发现它实际上比看起来容易。对于 Qt Widgets 应用程序,插件以灵活的方式扩展应用程序。同时,它们使开发者能够为 QML 应用程序设计新的形式。我们还介绍了使用subdirs项目来管理多个子项目。即使您不打算编写插件,本章也涵盖了对于 GUI 应用程序开发至关重要的绘画相关内容,包括QPainterpaintEventresizeEvent

在下一章中,我们将讨论 Qt 中的网络编程和多线程。

第六章:连接网络和管理下载

网络模块在当今已成为关键部分,也是开发框架必备的功能;因此,Qt 为网络编程提供了 API。请耐心等待,我们将连接网络并下载文件。此外,本章还包括了线程,这是避免阻塞的重要编程技能。本章的主题如下:

  • 介绍 Qt 网络编程

  • 利用 QNetworkAccessManager

  • 使用进度条

  • 编写多线程应用程序

  • 管理系统网络会话

介绍 Qt 网络编程

Qt 支持网络编程并提供大量高级 API 以简化您的开发工作。QNetworkRequestQNetworkReplyQNetworkAccessManager 使用通用协议执行网络操作。Qt 还提供表示低级网络概念的底层类。

在本章中,我们将利用 Qt 提供的高级 API 来编写一个下载器,用于检索互联网文件并将它们保存到您的磁盘上。正如我之前提到的,该应用程序将需要 QNetworkRequestQNetworkReplyQNetworkAccessManager 类。

首先,所有网络请求都由 QNetworkRequest 类表示,它是一个用于与请求相关信息的通用容器,包括头信息和加密。目前,支持 HTTP、FTP 和本地文件 URL 的上传和下载。

一旦创建了一个请求,就使用 QNetworkAccessManager 类来分发它并发出信号,报告进度。然后,它创建一个网络请求的回复,由 QNetworkReply 类表示。同时,QNetworkReply 提供的信号可以用来单独监控每个回复。尽管如此,一些开发者会丢弃回复的引用,并使用 QNetworkAccessManager 类的信号来达到这个目的。所有回复都可以同步或异步处理,因为 QNetworkReplyQIODevice 的子类,这意味着可以实现非阻塞操作。

下面是一个描述这些类之间关系的图示:

介绍 Qt 网络编程

同样,网络相关的内容在网络模块中提供。要使用此模块,您需要编辑项目文件并将网络添加到 QT。现在,创建一个新的 Qt Widget 应用程序项目并编辑项目文件。在我们的 Downloader_Demo 示例中,downloader_demo.pro 项目文件如下所示:

QT       += core gui network

greaterThan(QT_MAJOR_VERSION, 4): QT += widgets

TARGET = Downloader_Demo
TEMPLATE = app

SOURCES +=  main.cpp\
            mainwindow.cpp \
            downloader.cpp \
            downloaddialog.cpp

HEADERS  += mainwindow.h \
            downloader.h \
            downloaddialog.h

FORMS    += mainwindow.ui \
            downloaddialog.ui

利用 QNetworkAccessManager

现在,我们将探讨如何编写一个能够从其他位置下载文件的应用程序。这里的“其他位置”意味着您可以从本地位置下载文件;它不一定是互联网地址,因为 Qt 也支持本地文件 URL。

首先,让我们创建一个Downloader类,它将使用QNetworkAccessManager为我们执行下载工作。以下是将downloader.h头文件粘贴显示的内容:

#ifndef DOWNLOADER_H
#define DOWNLOADER_H

#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>

class Downloader : public QObject
{
  Q_OBJECT
public:
  explicit Downloader(QObject *parent = 0);

public slots:
  void download(const QUrl &url, const QString &file);

signals:
  void errorString(const QString &);
  void available(bool);
  void running(bool);
  void downloadProgress(qint64, qint64);

private:
  QNetworkAccessManager *naManager;
  QString saveFile;

  void saveToDisk(QNetworkReply *);

private slots:
  void onDownloadFinished(QNetworkReply *);
};

#endif // DOWNLOADER_H

我们暴露下载槽以获取 URL 和保存目标。相应地,saveFile用于存储保存目标。除此之外,我们使用QNetworkAccessManager类的naManager对象来管理下载过程。

让我们检查downloader.cpp文件中这些函数的定义。在以下构造函数中,我们将naManager对象的finished信号连接到onDownloadFinished槽。因此,当网络连接完成时,将通过此信号传递一个相关的QNetworkReply引用。

Downloader::Downloader(QObject *parent) :
  QObject(parent)
{
  naManager = new QNetworkAccessManager(this);
  connect(naManager, &QNetworkAccessManager::finished, this, &Downloader::onDownloadFinished);
}

相应地,在onDownloadFinished槽中,我们必须谨慎处理QNetworkReply。如果有任何错误,这意味着下载失败,我们通过errorString信号暴露errorString()函数。否则,我们调用saveToDisk函数将文件保存到磁盘。然后,我们使用deleteLater()安全地释放QNetworkReply对象。正如 Qt 文档中所述,直接使用delete语句是不安全的;因为它已经完成,我们发出可用性和运行信号。这些信号将后来用于更改用户界面。

void Downloader::onDownloadFinished(QNetworkReply *reply)
{
  if (reply->error() != QNetworkReply::NoError) {
    emit errorString(reply->errorString());
  }
  else {
    saveToDisk(reply);
  }
  reply->deleteLater();
  emit available(true);
  emit running(false);
}

saveToDisk函数中,我们只是实现QFile将所有下载的数据保存到磁盘。这是可行的,因为QNetworkReply继承自QIODevice。因此,除了网络 API 之外,您可以将QNetworkReply视为一个普通的QIODevice对象。在这种情况下,使用readAll()函数获取所有数据:

void Downloader::saveToDisk(QNetworkReply *reply)
{
  QFile f(saveFile);
  f.open(QIODevice::WriteOnly | QIODevice::Truncate);
  f.write(reply->readAll());
  f.close();
}

最后,让我们看看将被MainWindow后来使用的download函数内部。首先,我们将保存的文件存储到saveFile中。然后,我们使用QUrl对象url构建QNetworkRequest req。接下来,我们将req发送到QNetworkAccessManagernaManager对象,同时将创建的QNetworkManager对象的引用保存到reply中。之后,我们将两个downloadProgress信号连接在一起,这仅仅是暴露了回复的downloadProgress信号。最后,我们发出两个信号,分别表示可用性和运行状态。

void Downloader::download(const QUrl &url, const QString &file)
{
  saveFile = file;
  QNetworkRequest req(url);
  QNetworkReply *reply = naManager->get(req);
  connect(reply, &QNetworkReply::downloadProgress, this, &Downloader::downloadProgress);
  emit available(false);
  emit running(true);
}

我们描述了Downloader类。现在,我们将通过导航到Qt Designer | 带有底部按钮的对话框来添加DownloadDialog。这个类用于获取用户输入的 URL 和保存路径。对于downloaddialog.ui的设计,我们使用两个QLineEdit对象分别获取 URL 和保存路径。其中一个对象的名字是urlEdit,另一个是saveAsEdit。为了打开文件对话框让用户选择保存位置,我们在saveAsEdit的右侧添加了一个QPushButtonsaveAsButton属性。以下截图显示了此 UI 文件的布局:

利用 QNetworkAccessManager

您需要将此对话框的布局更改为网格布局。与之前类似,为了将值传递到主窗口,我们需要在信号与槽编辑器中删除默认的accepted信号和槽连接。

此类的downloaddialog.h头文件内容如下所示:

#ifndef DOWNLOADDIALOG_H
#define DOWNLOADDIALOG_H

#include <QDialog>

namespace Ui {
  class DownloadDialog;
}

class DownloadDialog : public QDialog
{
  Q_OBJECT

public:
  explicit DownloadDialog(QWidget *parent = 0);
  ~DownloadDialog();

signals:
  void accepted(const QUrl &, const QString &);

private:
  Ui::DownloadDialog *ui;

private slots:
  void onButtonAccepted();
  void onSaveAsButtonClicked();
};

#endif // DOWNLOADDIALOG_H

如您所见,添加了一个名为accepted的新信号,用于传递 URL 和保存位置。此外,两个private槽分别用于处理按钮框的accepted事件和saveAsButtonClicked信号。

定义在downloaddialog.cpp源文件中,如下所示:

#include <QFileDialog>
#include "downloaddialog.h"
#include "ui_downloaddialog.h"

DownloadDialog::DownloadDialog(QWidget *parent) :
  QDialog(parent),
  ui(new Ui::DownloadDialog)
{
  ui->setupUi(this);

  connect(ui->buttonBox, &QDialogButtonBox::accepted, this, &DownloadDialog::onButtonAccepted);
  connect(ui->saveAsButton, &QPushButton::clicked, this, &DownloadDialog::onSaveAsButtonClicked);
}

DownloadDialog::~DownloadDialog()
{
  delete ui;
}

void DownloadDialog::onButtonAccepted()
{
  emit accepted(QUrl(ui->urlEdit->text()), ui->saveAsEdit->text());
  this->accept();
}

void DownloadDialog::onSaveAsButtonClicked()
{
  QString str = QFileDialog::getSaveFileName(this, "Save As");
  if (!str.isEmpty()) {
    ui->saveAsEdit->setText(str);
  }
}

DownloadDialog的构造函数中,仅连接信号和槽。在onButtonAccepted槽中,我们发出accepted信号,用于传递 URL 和保存路径,其中使用urlEdit的文本构造一个临时的QUrl类。然后,调用accept函数关闭对话框。同时,在onSaveAsButtonClicked槽函数中,我们使用QFileDialog类提供的static函数获取保存位置。如果QString返回值为空,则不执行任何操作;这意味着用户可能在文件对话框中点击了取消

利用进度条

使用进度条直观地指示下载进度是一种方法。在 Qt 中,提供水平或垂直进度条小部件的是QProgressBar类。它使用minimumvaluemaximum来确定完成百分比。百分比通过以下公式计算,(value – minimum) / (maximum – minimum)。我们将在示例应用程序中通过以下步骤使用这个有用的组件:

  1. 返回到MainWindow类。

  2. 设计模式下编辑mainwindow.ui文件。

  3. 拖动按钮并将其重命名为newDownloadButton,其文本为New Download

  4. 进度条拖到newDownloadButton下方。

  5. 将布局更改为垂直布局

  6. progressBar小部件的属性中取消选中textVisible

推出按钮newDownloadButton用于弹出DownloadDialog以获取新的下载任务。我们需要根据以下建议对mainwindow.h进行一些修改:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include "downloader.h"
#include "downloaddialog.h"

namespace Ui {
  class MainWindow;
}

class MainWindow : public QMainWindow
{
  Q_OBJECT

public:
  explicit MainWindow(QWidget *parent = 0);
  ~MainWindow();

private:
  Ui::MainWindow *ui;
 Downloader *downloader;
 DownloadDialog *ddlg;

private slots:
  void onNewDownloadButtonPressed();
  void showMessage(const QString &);
  void onDownloadProgress(qint64, qint64);
};

#endif // MAINWINDOW_H

为了使用DownloaderDownloadDialog类,我们必须在header文件中包含它们。然后,我们可以将它们作为private指针包含。对于private槽,onNewDownloadButtonPressed用于处理newDownloadButton点击信号。而showMessage是一个槽函数,用于在状态栏上显示消息,最后一个onDownloadProgress用于更新进度条。

类似地,对于mainwindow.cpp源文件,我们在构造函数中连接信号和槽,如下所示:

MainWindow::MainWindow(QWidget *parent) :
  QMainWindow(parent),
  ui(new Ui::MainWindow)
{
  ui->setupUi(this);
  ui->progressBar->setVisible(false);

  downloader = new Downloader(this);

  connect(ui->newDownloadButton, &QPushButton::clicked, this, &MainWindow::onNewDownloadButtonPressed);
  connect(downloader, &Downloader::errorString, this, &MainWindow::showMessage);
  connect(downloader, &Downloader::downloadProgress, this, &MainWindow::onDownloadProgress);
  connect(downloader, &Downloader::available, ui->newDownloadButton, &QPushButton::setEnabled);
  connect(downloader, &Downloader::running, ui->progressBar, &QProgressBar::setVisible);
}

在开始创建这些连接之前,我们需要隐藏进度条并创建一个新的Downloader类,使用MainWindow作为QObject父类。同时,在这些连接中,第一个是将newDownloadButton点击信号连接起来。然后,我们将下载器的errorString信号连接到showMessage,这样状态栏就可以直接显示错误消息。接下来,我们将downloadProgress信号连接到我们的onDownloadProgress处理程序。至于可用和运行信号,它们分别连接到控制newDownloadButtonprogressBar的可用性和可见性。

onNewDownloadButtonPressed槽函数内部,我们构建一个DownloadDialog对象ddlg,然后将DownloadDialog的接受信号连接到Downloader类的下载槽。然后使用exec运行对话框并阻塞事件循环。之后,我们调用deleteLater来安全地释放为ddlg分配的资源。

void MainWindow::onNewDownloadButtonPressed()
{
  ddlg = new DownloadDialog(this);
  connect(ddlg, &DownloadDialog::accepted, downloader, &Downloader::download);
  ddlg->exec();
  ddlg->deleteLater();
}

对于showMessage槽函数,它只是简单地调用statusBarshowMessage函数,并设置三秒的超时时间,如下所示:

void MainWindow::showMessage(const QString &es)
{
  ui->statusBar->showMessage(es, 3000);
}

最后,我们可以通过onDownloadProgress函数更新进度条,如下面的代码所示。由于minimum值默认为0,我们不需要更改它。相反,我们将maximum值更改为下载的总字节数,并将value更改为当前已下载的字节数。请注意,如果总大小未知,则总大小的值为-1,这将导致进度条以忙碌样式显示。

void MainWindow::onDownloadProgress(qint64 r, qint64 t)
{
  ui->progressBar->setMaximum(t);
  ui->progressBar->setValue(r);
}

现在,运行应用程序并点击新下载按钮。将弹出添加新下载对话框,您可以在其中添加新的下载任务,如下所示:

利用进度条

点击确定,如果没有错误;预期将显示进度条并显示当前的下载进度,如下所示:

利用进度条

如您所见,新下载按钮目前不可用,因为它与downloader的可用信号相关联。此外,如果downloader没有运行,进度条甚至不会显示。

虽然这个下载器演示仍然缺少一个基本功能,即取消下载,但实际上很容易实现。在QNetworkReply类中有一个名为abort的槽函数。您可能需要存储QNetworkReply的引用,然后在MainWindow中的某个按钮被点击时调用abort。这里不会演示这个功能。它已经留给了您自己练习。

编写多线程应用程序

我敢打赌,多线程或线程对于您来说并不陌生。使用其他线程可以防止 GUI 应用程序冻结。如果应用程序在单个线程上运行,并且有一个同步的耗时操作,它将会卡住。多线程可以使应用程序运行得更加流畅。尽管大多数 Qt 网络 API 都是非阻塞的,但实践起来并不困难。

Qt 提供了一个 QThread 类,用于在所有支持的平台上实现多线程。换句话说,我们不需要编写特定于平台的代码,利用 POSIX 线程或 Win32 API。相反,QThread 提供了一种平台无关的方式来管理线程。一个 QThread 对象在程序中管理一个线程,该线程从 run() 函数开始执行,并在调用 quit()exit() 时结束。

由于某些历史原因,仍然可以子类化 QThread 并将阻塞或耗时的代码放入重新实现的 run() 函数中。然而,这被认为是不正确的做法,并且不建议这样做。正确的方法是使用 QObject::moveToThread,稍后将会演示。

我们打算将 Downloader::download 函数放入一个新的线程中。实际上,是 QNetworkAccessManager::get 函数将被移动到另一个线程。让我们创建一个新的 C++ 类,DownloadWorker,其 downloadworker.h 头文件如下所示:

#ifndef DOWNLOADWORKER_H
#define DOWNLOADWORKER_H

#include <QObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QNetworkAccessManager>

class DownloadWorker : public QObject
{
  Q_OBJECT

public slots:
  void doDownload(const QUrl &url, QNetworkAccessManager *nm);

signals:
  void downloadProgress(qint64, qint64);
};

#endif // DOWNLOADWORKER_H

由于我们无法创建将在另一个线程中存在的子对象,因此已从代码中删除构造函数。这是 QThread 几乎唯一的限制。相比之下,您可以在不同线程之间连接信号和槽而不会出现任何问题。

不要在线程之间分割父对象和子对象。父对象和子对象只能位于同一个线程中。

我们声明了 doDownload 插槽函数,用于为我们执行 QNetworkAccessManager::get 函数的工作。另一方面,downloadProgress 信号用于公开 QNetworkReplydownloadProgress 信号,就像我们之前做的那样。downloadworker.cpp 的内容如下所示:

#include "downloadworker.h"

void DownloadWorker::doDownload(const QUrl &url, QNetworkAccessManager *nm)
{
  QNetworkRequest req(url);
  QNetworkReply *reply = nm->get(req);
  connect(reply, &QNetworkReply::downloadProgress, this, &DownloadWorker::downloadProgress);
}

上述代码是一个简单的 worker 类的示例。现在,我们必须将 Downloader 类更改为使用 DownloadWorker 类。Downloader 类的 header 文件 downloader.h 需要一些修改,如下所示:

#ifndef DOWNLOADER_H
#define DOWNLOADER_H

#include <QObject>
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QThread>
#include "downloadworker.h"

class Downloader : public QObject
{
  Q_OBJECT
public:
  explicit Downloader(QObject *parent = 0);
  ~Downloader();

public slots:
  void download(const QUrl &url, const QString &file);

signals:
  void errorString(const QString &);
  void available(bool);
  void running(bool);
  void downloadProgress(qint64, qint64);

private:
  QString saveFile;
  QNetworkAccessManager *naManager;
  DownloadWorker *worker;
  QThread workerThread;

  void saveToDisk(QNetworkReply *);

private slots:
  void onDownloadFinished(QNetworkReply *);
};

#endif // DOWNLOADER_H

如您所见,我们已声明了一个新的 private 成员 workerThread,它是一种 QThread 类型。同时,还声明了一个 DownloadWorker 对象 worker。在 downloader.cpp 源文件中还有更多更改,如下所示:

#include <QFile>
#include "downloader.h"

Downloader::Downloader(QObject *parent) :
  QObject(parent)
{
  naManager = new QNetworkAccessManager(this);
  worker = new DownloadWorker;
  worker->moveToThread(&workerThread);

  connect(naManager, &QNetworkAccessManager::finished, this, &Downloader::onDownloadFinished);
  connect(&workerThread, &QThread::finished, worker, &DownloadWorker::deleteLater);
  connect(worker, &DownloadWorker::downloadProgress, this, &Downloader::downloadProgress);

  workerThread.start();
}

Downloader::~Downloader()
{
  workerThread.quit();
  workerThread.wait();
}

void Downloader::download(const QUrl &url, const QString &file)
{
  saveFile = file;
  worker->doDownload(url, naManager);
  emit available(false);
  emit running(true);
}

void Downloader::onDownloadFinished(QNetworkReply *reply)
{
  if (reply->error() != QNetworkReply::NoError) {
    emit errorString(reply->errorString());
  }
  else {
    saveToDisk(reply);
  }
  reply->deleteLater();
  emit available(true);
  emit running(false);
}

void Downloader::saveToDisk(QNetworkReply *reply)
{
  QFile f(saveFile);
  f.open(QIODevice::WriteOnly | QIODevice::Truncate);
  f.write(reply->readAll());
  f.close();
}

在构造函数中,我们将创建一个新的 DownloadWorker 类,并将其移动到另一个线程 workerThread。通过将 workerThreadfinished 信号连接到 workerdeleteLater 函数,可以在 workerThread 退出后安全地删除 worker 的资源。然后,我们需要再次公开 downloadProgress,因为它被移动到了 worker 中。最后,我们调用 start() 函数,以启动 workerThread

作为反向操作,我们调用quit()函数退出workerThread,然后使用wait()确保其成功退出。

由于大量的代码已经移动到workerdoDownload函数中,我们在这里只需要调用workerdoDownload。实际上,函数调用是跨线程的,这意味着主线程不会因为那个语句而被阻塞。

由于get不是阻塞的,你可能感觉不到区别。然而,我相信你有一些应用程序已经冻结了,因此需要修改以适应QThread。始终记得只将后台阻塞操作放在另一个线程中。这主要是因为这些操作很容易从 GUI 中分离成没有父或子对象的单个对象。由于这种限制,几乎所有 GUI 对象都必须在同一个线程中,在大多数情况下是主线程。

管理系统网络会话

除了网络应用程序之外,Qt 还为你提供了跨平台的 API 来控制网络接口和接入点。尽管控制网络状态并不常见,但在某些情况下确实需要这样做。

首先,我想向你介绍QNetworkConfigurationManager。这个类管理由系统提供的网络配置。它使你能够访问它们,并在运行时检测系统的能力。网络配置由QNetworkConfiguration类表示,它抽象了一组配置选项,这些选项涉及如何配置网络接口以连接到目标网络。要控制网络会话,你需要使用QNetworkSession类。这个类为你提供了对系统接入点的控制,并允许会话管理。它还允许你控制由QNetworkInterface类表示的网络接口。为了帮助你理解这种关系,这里显示了一个图表:

管理系统网络会话

如你所见,结构类似于QNetworkAccessManagerQNetworkReplyQNetworkRequest。特别是,还有一个另一个管理类。让我们看看在实际中如何处理这些类。

按照常规创建一个新的 Qt Widgets Application 项目。关于这个主题的示例称为NetworkManager_Demo。记得在你的项目文件中将网络添加到 Qt 中,就像我们在前面的示例中所做的那样。然后,在Design模式下编辑mainwindow.ui并执行以下步骤:

  1. 由于我们在这个应用程序中不需要它们,请移除状态栏、菜单栏和工具栏。

  2. Item Views (Model-Based)类别下添加List View

  3. Vertical Layout拖到listView的右侧。

  4. MainWindow中的Lay out改为Lay Out Horizontally

  5. Label拖入verticalLayout并重命名为onlineStatus

  6. 进度条拖动到verticalLayout中。将其maximum值更改为0并取消选中textVisible,以便它可以作为忙碌指示器使用。

  7. 添加三个按钮刷新连接断开连接;在进度条下方。它们的对象名称分别是refreshButtonconnectButtondisconnectButton

  8. 最后,将垂直间隔拖动到progressBaronlineStatus之间以分隔它们。

如同往常,我们需要在mainwindow.h头文件中进行一些声明,如下所示:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include <QNetworkConfigurationManager>
#include <QNetworkConfiguration>
#include <QNetworkSession>
#include <QStandardItemModel>

namespace Ui {
  class MainWindow;
}

class MainWindow : public QMainWindow
{
  Q_OBJECT

public:
  explicit MainWindow(QWidget *parent = 0);
  ~MainWindow();

private:
  Ui::MainWindow *ui;
  QNetworkConfigurationManager *networkConfManager;
  QStandardItemModel *confListModel;

private slots:
  void onOnlineStateChanged(bool isOnline);
  void onConfigurationChanged(const QNetworkConfiguration &config);
  void onRefreshClicked();
  void onRefreshCompleted();
  void onConnectClicked();
  void onDisconnectClicked();
};

#endif // MAINWINDOW_H

在这种情况下,我们只利用QNetworkConfigurationManagerQNetworkConfigurationQNetworkSession类来管理系统网络会话。因此,我们需要在适当的位置包含它们。

注意

注意,我们只需要声明一个private成员,在这种情况下是networkConfManagerQNetworkConfigurationManager类,因为可以从这个管理器中检索QNetworkConfiguration,而QNetworkSession绑定到QNetworkConfiguration

至于QStandardItemModel,记得第三章中的模型/视图内容,使用 Qt Quick 制作 RSS 阅读器。这一章和这一章之间的唯一区别是我们之前写了 QML。然而,在这一章中,我们使用的是 C++ 应用程序。尽管工具不同,但它们共享相同的概念。QStandardItemModel *confListModel是 UI 文件中listView的确切模型。

最后,但同样重要的是,是一些槽的声明。除了按钮点击处理程序之外,前两个用于监控网络系统。这将在后面解释。

让我们编辑mainwindow.cpp文件,看看MainWindow的构造函数:

MainWindow::MainWindow(QWidget *parent) :
  QMainWindow(parent),
  ui(new Ui::MainWindow)
{
  ui->setupUi(this);

  networkConfManager = new QNetworkConfigurationManager(this);
  confListModel = new QStandardItemModel(0, 1, this);

  ui->listView->setModel(confListModel);
  ui->progressBar->setVisible(false);

  connect(networkConfManager, &QNetworkConfigurationManager::onlineStateChanged, this, &MainWindow::onOnlineStateChanged);
  connect(networkConfManager, &QNetworkConfigurationManager::configurationChanged, this, &MainWindow::onConfigurationChanged);
  connect(networkConfManager, &QNetworkConfigurationManager::updateCompleted, this, &MainWindow::onRefreshCompleted);

  connect(ui->refreshButton, &QPushButton::clicked, this, &MainWindow::onRefreshClicked);
  connect(ui->connectButton, &QPushButton::clicked, this, &MainWindow::onConnectClicked);
  connect(ui->disconnectButton, &QPushButton::clicked, this, &MainWindow::onDisconnectClicked);

  onOnlineStateChanged(networkConfManager->isOnline());
  onRefreshClicked();
}

我们使用此对象,也称为MainWindow作为其QObject父对象来构建QNetworkConfigurationManager。然后,我们来看confListModel的构建。参数包括行数、列数以及QObject父对象,通常情况下就是它。我们将只使用一列,因为我们使用列表视图来显示数据。如果你使用表格视图,你可能需要使用更多的列。然后,我们将此模型绑定到uilistView。在此之后,我们隐藏progressBar,因为它是一个忙碌指示器,只有在有工作运行时才会显示。在我们显式调用两个成员函数之前,将会有几个connect语句。其中,你可能想查看QNetworkConfigurationManager的信号。如果系统的online状态发生变化,即从online变为offline,则onlineStateChanged信号会被发出。每当QNetworkConfiguration的状态发生变化时,configurationChanged信号会被发出。一旦QNetworkConfigurationManager完成updateConfigurationsupdateCompleted信号将被发出。在构造函数的末尾,我们直接调用onOnlineStateChanged以设置onlineStatus的文本。同样,调用onRefreshClicked可以使应用程序在启动时扫描所有网络配置。

如前所述,onOnlineStateChanged函数用于设置onlineStatus。如果系统被认为通过一个活动的网络接口连接到另一个设备,它将显示Online;否则,它将显示Offline。此函数的定义如下所示:

void MainWindow::onOnlineStateChanged(bool isOnline)
{
  ui->onlineStatus->setText(isOnline ? "Online" : "Offline");
}

在以下代码中显示的onConfigurationChanged槽函数内部,我们更改项的背景颜色以指示配置是否活动。我们使用findItems函数获取itemList,它只包含一些与config.name()完全匹配的QStandardItem。然而,配置名称可能不是唯一的。这就是为什么我们使用一个foreach循环来比较config的标识符,它是一个唯一的字符串,其中使用data函数检索特定数据,其类型为QVariant。然后,我们使用toString将其转换回QStringQStandardItem使我们能够将多个数据设置到一个项中。

void MainWindow::onConfigurationChanged(const QNetworkConfiguration &config)
{
  QList<QStandardItem *> itemList = confListModel->findItems(config.name());
  foreach (QStandardItem *i, itemList) {
    if (i->data(Qt::UserRole).toString().compare(config.identifier()) == 0) {
      if (config.state().testFlag(QNetworkConfiguration::Active)) {
        i->setBackground(QBrush(Qt::green));
      }
      else {
        i->setBackground(QBrush(Qt::NoBrush));
      }
    }
  }
}

这意味着我们将identifier存储为Qt::UserRole数据。它不会显示在屏幕上;相反,它作为一个特定的数据载体,在这种情况下非常有助于我们。因此,在此之后,如果它是活动的,我们将背景颜色设置为绿色;否则,不使用画笔,这意味着默认背景。请注意,QNetworkConfigurationstate函数返回StateFlags,这实际上是一个QFlag模板类,其中最佳实践是检查是否设置了标志,可以使用testFlag函数。

让我们检查onRefreshClicked函数,该函数在onRefreshCompleted之前显示。它将调用QNetworkConfigurationManager *networkConfManagerupdateConfigurations函数。这个函数是一个耗时的函数,特别是如果它需要扫描 WLAN。因此,我们显示progressBar来告诉用户要有耐心,并禁用refreshButton,因为它正在刷新。

void MainWindow::onRefreshClicked()
{
  ui->progressBar->setVisible(true);
  ui->refreshButton->setEnabled(false);
  networkConfManager->updateConfigurations();
}

更新完成后,将发出updateCompleted信号,并执行与onRefreshCompleted绑定的槽。检查以下函数,在这里我们需要清除列表。然而,我们不是调用clear函数,而是使用removeRows,这样可以保留列。如果你调用clear,请注意将列添加回来;否则,实际上就没有列了,这意味着没有地方放置项目。在foreach循环中,我们将networkConfManager找到的所有配置添加到confListModel中。正如我之前提到的,我们使用名称作为显示的text,而将其标识符设置为隐藏的用户角色数据。循环结束后,隐藏progressBar,因为刷新已完成,然后启用refreshButton

void MainWindow::onRefreshCompleted()
{
  confListModel->removeRows(0, confListModel->rowCount());
  foreach(QNetworkConfiguration c, networkConfManager->allConfigurations()) {
    QStandardItem *item = new QStandardItem(c.name());
    item->setData(QVariant(c.identifier()), Qt::UserRole);
    if (c.state().testFlag(QNetworkConfiguration::Active)) {
      item->setBackground(QBrush(Qt::green));
    }
    confListModel->appendRow(item);
  }
  ui->progressBar->setVisible(false);
  ui->refreshButton->setEnabled(true);
}

剩下的两个处理程序是对connectdisconnect按钮的处理。对于connectButton,我们显示progressBar,因为从路由器获取 IP 地址可能需要很长时间。然后,我们直接从confListModel的数据中获取identifier并将其保存为QString ident,其中listViewcurrentIndex函数将返回当前视图的QModelIndex。通过使用此索引,我们可以从模型中获取当前选中的数据。然后,我们通过调用networkConfManagerconfigurationFromIdentifierident构建QNetworkConfiguration。最后,使用QNetworkConfiguration构建QNetworkSession会话,并打开此网络会话,等待 1,000 毫秒。然后,调用deleteLater以安全地释放会话。最后,在这些工作完成后,隐藏progressBar

void MainWindow::onConnectClicked()
{
  ui->progressBar->setVisible(true);
  QString ident = confListModel->data(ui->listView->currentIndex(), Qt::UserRole).toString();
  QNetworkConfiguration conf = networkConfManager->configurationFromIdentifier(ident);
  QNetworkSession *session = new QNetworkSession(conf, this);
  session->open();
  session->waitForOpened(1000);
  session->deleteLater();
  ui->progressBar->setVisible(false);
}

void MainWindow::onDisconnectClicked()
{
  QString ident = confListModel->data(ui->listView->currentIndex(), Qt::UserRole).toString();
  QNetworkConfiguration conf = networkConfManager->configurationFromIdentifier(ident);
  QNetworkSession *session = new QNetworkSession(conf, this);
  if (networkConfManager->capabilities().testFlag(QNetworkConfigurationManager::SystemSessionSupport)) {
    session->close();
  }
  else {
    session->stop();
  }
  session->deleteLater();
}

对于disconnectButtononDisconnectClicked处理程序将执行相反的操作,即停止网络会话。前三行与onConnectClicked中的相同。然而,我们需要测试平台是否支持进程外会话。正如 Qt 文档中所述,调用close的结果如下:

void QNetworkSession::close() [slot]

减少关联网络配置的会话计数器。如果会话计数器达到零,则关闭活动网络接口。这也意味着,只有当当前会话是最后一个打开的会话时,状态()才会从 Connected 变为 Disconnected。

然而,如果平台不支持进程外会话,close函数将不会停止接口,在这种情况下,我们需要使用stop代替。

因此,我们调用 networkConfManagercapabilities 函数来检查它是否具有 SystemSessionSupport。如果有,则调用 close,否则调用 stop。然后,我们只需调用 deleteLater 来安全地释放会话。

现在,运行这个应用程序,你期望它的工作方式如下截图所示:

管理系统网络会话

在 Windows 上,网络架构与 Unix 世界不同。因此,你可能会在列表中找到一些奇怪的配置,例如截图中的Teredo 隧道伪接口。不用担心这些配置,只需忽略它们!此外,没有 Qt API 允许你连接到一个新发现的加密 Wi-Fi 接入点。这是因为没有实现用于访问 WLAN 系统密码的功能。换句话说,它只能用来控制系统已知的网络会话。

摘要

在本章中,你有机会在掌握前几章所学内容的同时,学习 Qt 的新技能。到目前为止,你已经对 Qt 的常见架构有了深入了解,这是其子模块共享的。毕竟,网络和线程技术将使你的应用程序达到更高的水平。

在下一章中,除了解析 XML 和 JSON 文档外,我们还将用 Qt 来震撼 Android!

第七章:解析 JSON 和 XML 文档以使用在线 API

在本章中,您将找到强大的应用程序 Qt 在流行的 Android 设备上运行。在介绍 Android Qt 应用程序开发之后,它还利用在线 API,这些 API 通常返回 JSON 或 XML 文档。本章涵盖的主题如下:

  • 设置 Qt for Android

  • 解析 JSON 结果

  • 解析 XML 结果

  • 为 Android 构建 Qt 应用程序

  • 在 QML 中解析 JSON

设置 Qt for Android

Qt for Android 至少需要 API 级别 10(适用于 Android 2.3.3 平台)。大多数 Qt 模块都得到支持,这意味着您的 Qt 应用程序可以在 Android 上部署,只需进行少量或无需修改。在 Qt Creator 中,支持基于 Qt Widget 的应用程序和 Qt Quick 应用程序的开发。然而,在 Windows PC 上设置 Qt for Android 并非非常直接。因此,在我们深入之前,让我们先设置 Android 上的 Qt 开发环境。

首先,您需要安装 Qt for Android。如果您使用的是在线安装程序,请记住选择 Android 组件,如下面的截图所示:

设置 Qt for Android

在这里,我们只选择了 Android armv7,这使得我们能够部署适用于 ARMv7 Android 设备的应用程序。如果您使用的是离线安装程序,请下载 Android 安装程序的 Qt。

现在,让我们安装一个 Java 开发工具包JDK)。由于 Android 严重依赖它,所以无法摆脱 Java。此外,请注意,根据 doc.qt.io/qt-5/androidgs.html,您需要安装至少 JDK 版本 6。您可以从 www.oracle.com/technetwork/java/javase/downloads/index.html 下载 JDK。您还需要在 JDK 安装目录 D:\Program Files\Java\jdk1.8.0_25 中设置一个 JAVA_HOME 环境变量。

现在,让我们安装来自 Google 的两个工具包,Android SDK 和 Android NDK。始终记住下载最新版本;这里我们使用 Android SDK r24.0.2 和 Android NDK r10b。

在您安装 Android SDK 后,运行 SDK 管理器。安装或更新 Android SDK 工具Android SDK 平台工具Android SDK 构建工具Google USB 驱动程序,至少一个 API 级别的 SDK 平台,以及 ARM EABI v7a 系统镜像,以完成我们的任务。对于本章,我们安装了 API 19 的 SDK 平台ARM EABI v7a 系统镜像。然后,编辑 PATH 环境变量。使用分号作为分隔符,将平台和 SDK 工具的路径添加到其中。如果 D:\Program Files (x86)\Android\android-sdkAndroid SDK 工具 的路径,它将如下所示:

D:\Program Files (x86)\Android\android-sdk\platform-tools;D:\Program Files (x86)\Android\android-sdk\tools

注意

Android SDK 和 NDK 可以在 Android 开发者网站上获得,developer.android.com

下载 NDK 后,将zip文件解压到您的硬盘上,D:\android-ndk。然后,添加一个名为ANDROID_NDK_ROOT的环境变量,其值为D:\android-ndk

对于 Apache Ant,也应采用类似步骤。您可以从ant.apache.org/bindownload.cgi下载它。本书中使用的是 Apache Ant 1.9.4。这里不需要设置任何环境变量。现在,如果您使用的是 Windows,请重新启动计算机,以便刷新并正确加载环境变量。

打开 AVD 管理器并创建一个新的虚拟设备。对于这个练习,您最好选择一个较小的虚拟设备,如 Nexus S,如图所示。如果您想更改它,请随意更改,但请记住勾选使用主机 GPU,这将使虚拟设备使用 GLES 来加速图形。如果您没有开启它,您将得到一个极其缓慢的虚拟设备,甚至可能太慢以至于无法在上面测试应用程序。

设置 Qt for Android

现在,打开 Qt Creator;导航到工具 | 选项。查看构建和运行中的 Qt 版本是否有 Android 条目。如果没有,您必须手动添加 Qt for Android。然后,切换到Android选项,设置 JDK、Android SDK、Android NDK 和 Ant,如图所示:

设置 Qt for Android

对于缺少架构的警告可以安全忽略,因为我们不会在本章中为 MIPS 和 x86 Android 开发应用程序。但是,如果您需要在这些硬件平台上部署应用程序,请注意这一点。

点击应用并切换到设备选项。在设备组合框中应该有一个在 Android 上运行项。如果您现在导航到构建和运行 | 套件,应该期望自动检测到Android for armeabi-v7a

现在,让我们测试一下我们是否可以在我们的虚拟 Android 设备上运行 Qt 应用程序。打开 AVD 管理器并启动虚拟设备。我们首先启动它,因为它可能需要很长时间。然后,打开 Qt Creator 并创建一个简单的应用程序。

  1. 创建一个新的基于 Qt Widget 的应用程序项目。

  2. 选择Android for armeabi-v7a Kit

  3. 编辑mainwindow.ui并将一个标签拖到centralWidget

  4. 主窗口页面的布局更改为垂直布局(或其它)以便小部件可以自动拉伸。

  5. 将标签的文本更改为Hello Android!或其他内容。

等待耗时的虚拟 Android 设备完全启动。如果它没有启动,请点击运行并等待几分钟。您将看到此应用程序在我们的虚拟 Android 设备上运行。如图所示,Qt for Android 开发环境已成功设置。因此,我们可以继续编写一个可以使用摄像头拍照的应用程序:

设置 Qt 用于 Android

小贴士

在应用程序不完整的情况下在桌面上进行测试,然后在实际移动平台上进行测试,与始终在虚拟 Android 设备上进行测试相比,可以节省大量时间。此外,与虚拟设备相比,在真实设备上进行测试要快得多。

我们将不再忍受慢速的模拟器,而是首先在桌面上开发应用程序,然后在实际的 Android 设备上部署,看看是否有任何不适合移动设备的地方。相应地进行任何相关更改。这可以节省您大量时间。然而,即使实际的 Android 设备比虚拟设备更响应,这仍然需要更长的时间。

解析 JSON 结果

有许多公司为开发者提供 API,以访问他们的服务,包括字典、天气等。在本章中,我们将以 Yahoo!天气为例,向您展示如何使用其在线 API 获取天气数据。有关 Yahoo!天气 API 的更多详细信息,请参阅developer.yahoo.com/weather/

现在,让我们创建一个名为Weather_Demo的新项目,这是一个基于 Qt Widget 的应用程序项目。像往常一样,让我们首先设计 UI。

解析 JSON 结果

我们已经移除了之前所做的菜单栏、工具栏和状态栏。然后,我们在centralWidget的顶部添加了一个标签行编辑推送按钮。它们的对象名称分别是woeidLabelwoeidEditokButton。之后,另一个名为locationLabel的标签用于显示 API 返回的位置。红色矩形是水平布局,由tempLabelwindLabel组成,它们都是标签,并通过水平间隔分隔。添加一个名为attrLabel标签,然后将其对齐方式更改为AlignRightAlignBottom

Where On Earth ID (WOEID) 是一个唯一的 32 位标识符,且不会重复。通过使用 WOEID,我们可以避免重复。然而,这也意味着我们需要找出我们所在位置的 WOEID。幸运的是,有几个网站提供了易于使用的在线工具来获取 WOEID。其中之一是 Zourbuth 项目,Yahoo! WOEID Lookup,可以通过zourbuth.com/tools/woeid/访问。

现在,让我们继续前进,专注于 API 结果的解析。我们创建了一个新的 C++类,Weather,用于处理 Yahoo!天气 API。在介绍如何解析JSONJavaScript 对象表示法)结果之前,我想先介绍 XML。然而,在我们准备Weather类之前,请记住在项目文件中添加网络到QT。在这种情况下,Weather_Demo.pro项目文件看起来如下:

QT       += core gui network

greaterThan(QT_MAJOR_VERSION, 4): QT += widgets

TARGET = Weather_Demo
TEMPLATE = app

SOURCES += main.cpp\
        mainwindow.cpp \
        weather.cpp

HEADERS  += mainwindow.h \
            weather.h

FORMS    += mainwindow.ui

现在,我们可以编写Weather类。它的weather.h头文件如下所示:

#ifndef WEATHER_H
#define WEATHER_H

#include <QObject>
#include <QJsonDocument>
#include <QJsonObject>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QImage>

class Weather : public QObject
{
    Q_OBJECT
    public:
    explicit Weather(QObject *parent = 0);

    signals:
    void updateFinished(const QString &location, const QString &temp, const QString &wind);
    void imageDownloaded(const QImage &);

    public slots:
    void updateData(const QString &woeid);
    void getAttrImg();

    private:
    QNetworkAccessManager *naManager;
    QNetworkReply *imgReply;
    QImage attrImg;

    private slots:
    void onSSLErrors(QNetworkReply *);
    void onQueryFinished(QNetworkReply *);
};

#endif // WEATHER_H

除了查询天气信息之外,我们还使用这个类来获取 Yahoo! 文档中提到的归属图像。在传统的 Qt/C++ 中,我们必须使用 QNetworkAccessManager 来访问 QUrl 是一件很平常的事情,因为 QJsonDocument 不能直接从 QUrl 加载。无论如何,让我们看看如何在 weather.cpp 文件中从 Yahoo! 天气 API 获取结果。头文件部分包括以下行:

#include <QDebug>
#include <QNetworkRequest>
#include <QJsonArray>
#include "weather.h"

然后,让我们看看 Weather 的构造函数。在这里,我们简单地构建 QNetworkAccessManager 对象 naManager 并连接其信号:

Weather::Weather(QObject *parent) :
    QObject(parent)
{
    naManager = new QNetworkAccessManager(this);

    connect(naManager, &QNetworkAccessManager::finished, this, &Weather::onQueryFinished);
    connect(naManager, &QNetworkAccessManager::sslErrors, this, &Weather::onSSLErrors);
}

onSSLErrors 插槽只是简单地让 QNetworkReply 对象忽略所有 SSL 错误。在这种情况下,这不会引起任何严重问题。然而,如果您正在处理需要验证连接的安全通信或其他任何内容,您可能希望查看错误。

void Weather::onSSLErrors(QNetworkReply *re)
{
    re->ignoreSslErrors();
}

然后,让我们检查 onQueryFinished 之前的 updateData 函数。在这里,我们构建 QUrl,这是 Yahoo! 天气 API 的确切地址。请注意,您不需要为 QUrl 使用 HTML 代码。实际上,直接使用空格和其他符号会更合适。之后,类似于上一章,我们使用 QNetworkRequest 将此 QUrl 封装并通过 QNetworkAccessManager 分发请求。

void Weather::updateData(const QString &woeid)
{
    QUrl url("https://query.yahooapis.com/v1/public/yql?q=select * from weather.forecast where woeid = " + woeid + "&format=json");
    QNetworkRequest req(url);
    naManager->get(req);
}

至于 getAttrImg 函数,几乎相同。唯一的区别是,这个函数用于获取归属图像而不是天气信息。我们将回复存储为 imgReply,这样我们就可以区分图像和天气。

void Weather::getAttrImg()
{
    QUrl url("https://poweredby.yahoo.com/purple.png");
    QNetworkRequest req(url);
    imgReply = naManager->get(req);
}

如果相应的 QNetworkReply 对象已完成,则将执行 onQueryFinished 插槽函数,如下面的代码所示。在所有铺垫之后,让我们看看这个函数内部的内容。我们可以在一开始就检查回复中是否存在任何错误。然后,如果是 imgReply,我们将从数据中制作 QImage 并发出信号以发送此图像。如果这些都没有发生,我们将解析 JSON 回复中的天气信息。

void Weather::onQueryFinished(QNetworkReply *re)
{
    if (re->error() != QNetworkReply::NoError) {
        qDebug() << re->errorString();
        re->deleteLater();
        return;
    }

    if (re == imgReply) {
        attrImg = QImage::fromData(imgReply->readAll());
        emit imageDownloaded(attrImg);
        imgReply->deleteLater();
        return;
    }

    QByteArray result = re->readAll();
    re->deleteLater();

    QJsonParseError err;
    QJsonDocument doc = QJsonDocument::fromJson(result, &err);
    if (err.error != QJsonParseError::NoError) {
        qDebug() << err.errorString();
        return;
    }
    QJsonObject obj = doc.object();
    QJsonObject res = obj.value("query").toObject().value("results").toObject().value("channel").toObject();

    QJsonObject locObj = res["location"].toObject();
    QString location;
    for(QJsonObject::ConstIterator it = locObj.constBegin(); it != locObj.constEnd(); ++it) {
        location.append((*it).toString());
        if ((it + 1) != locObj.constEnd()) {
            location.append(", ");
        }
    }

    QString temperature = res["item"].toObject()["condition"].toObject()["temp"].toString() + res["units"].toObject()["temperature"].toString();

    QJsonObject windObj = res["wind"].toObject();
    QString wind;
    for(QJsonObject::ConstIterator it = windObj.constBegin(); it != windObj.constEnd(); ++it) {
        wind.append(it.key());
        wind.append(": ");
        wind.append((*it).toString());
        wind.append("\n");
    }

    emit updateFinished(location, temperature, wind);
}

如我之前所述,这是很平常的。首先,我们从 QNetworkReply 中读取结果,然后使用 QJsonDocument::fromJsonbyte 数组解析为 JSON 文档。如果在处理过程中出现错误,我们简单地打印错误字符串并返回。然后,我们需要获取 QJsonDocument 中包含的 QJsonObject。只有在这种情况下,我们才能解析其中的所有信息。使用 560743 作为 WOEID 的格式化结果如下所示:

{
  "query":{
    "count":1,
    "created":"2014-12-05T23:19:54Z",
    "lang":"en-GB",
    "results":{
      "channel":{
        "title":"Yahoo! Weather - Dublin, IE",
        "link":"http://us.rd.yahoo.com/dailynews/rss/weather/Dublin__IE/*http://weather.yahoo.com/forecast/EIXX0014_f.html",
        "description":"Yahoo! Weather for Dublin, IE",
        "language":"en-us",
        "lastBuildDate":"Fri, 05 Dec 2014 9:59 pm GMT",
        "ttl":"60",
        "location":{
          "city":"Dublin",
          "country":"Ireland",
          "region":"DUB"
        },
        "units":{
          "distance":"mi",
          "pressure":"in",
          "speed":"mph",
          "temperature":"F"
        },
        "wind":{
          "chill":"29",
          "direction":"230",
          "speed":"8"
        },
        "atmosphere":{
          "humidity":"93",
          "pressure":"30.36",
          "rising":"1",
          "visibility":"6.21"
        },
        "astronomy":{
          "sunrise":"8:22 am",
          "sunset":"4:09 pm"
        },
        "image":{
          "title":"Yahoo! Weather",
          "width":"142",
          "height":"18",
          "link":"http://weather.yahoo.com",
          "url":"http://l.yimg.com/a/i/brand/purplelogo//uh/us/news-wea.gif"
        },
        "item":{
          "title":"Conditions for Dublin, IE at 9:59 pm GMT",
          "lat":"53.33",
          "long":"-6.29",
          "link":"http://us.rd.yahoo.com/dailynews/rss/weather/Dublin__IE/*http://weather.yahoo.com/forecast/EIXX0014_f.html",
          "pubDate":"Fri, 05 Dec 2014 9:59 pm GMT",
          "condition":{
            "code":"29",
            "date":"Fri, 05 Dec 2014 9:59 pm GMT",
            "temp":"36",
            "text":"Partly Cloudy"
          },
          "description":"\n<img src=\"http://l.yimg.com/a/i/us/we/52/29.gif\"/><br />\n<b>Current Conditions:</b><br />\nPartly Cloudy, 36 F<BR />\n<BR /><b>Forecast:</b><BR />\nFri - Partly Cloudy. High: 44 Low: 39<br />\nSat - Mostly Cloudy. High: 48 Low: 41<br />\nSun - Mostly Sunny/Wind. High: 43 Low: 37<br />\nMon - Mostly Sunny/Wind. High: 43 Low: 37<br />\nTue - PM Light Rain/Wind. High: 52 Low: 38<br />\n<br />\n<a href=\"http://us.rd.yahoo.com/dailynews/rss/weather/Dublin__IE/*http://weather.yahoo.com/forecast/EIXX0014_f.html\">Full Forecast at Yahoo! Weather</a><BR/><BR/>\n(provided by <a href=\"http://www.weather.com\" >The Weather Channel</a>)<br/>\n",
          "forecast":[
          {
            "code":"29",
            "date":"5 Dec 2014",
            "day":"Fri",
            "high":"44",
            "low":"39",
            "text":"Partly Cloudy"
          },
          {
            "code":"28",
            "date":"6 Dec 2014",
            "day":"Sat",
            "high":"48",
            "low":"41",
            "text":"Mostly Cloudy"
          },
          {
            "code":"24",
            "date":"7 Dec 2014",
            "day":"Sun",
            "high":"43",
            "low":"37",
            "text":"Mostly Sunny/Wind"
          },
          {
            "code":"24",
            "date":"8 Dec 2014",
            "day":"Mon",
            "high":"43",
            "low":"37",
            "text":"Mostly Sunny/Wind"
          },
          {
            "code":"11",
            "date":"9 Dec 2014",
            "day":"Tue",
            "high":"52",
            "low":"38",
            "text":"PM Light Rain/Wind"
          }
          ],
          "guid":{
            "isPermaLink":"false",
            "content":"EIXX0014_2014_12_09_7_00_GMT"
          }
        }
      }
    }
  }
}

注意

有关 JSON 的详细信息,请访问 www.json.org

如您所见,所有信息都存储在query/results/channel中。因此,我们需要逐级将其转换为QJsonObject。如代码所示,QJsonObject reschannel。请注意,value函数将返回一个QJsonValue对象,在您可以使用value函数再次解析值之前,您需要调用toObject()将其转换为QJsonObject。之后,操作就相当直接了。locObj对象是我们使用for循环将值组合在一起的位置,而QJsonObject::ConstIterator只是 Qt 对 STL const_iterator的包装。

要获取当前温度,我们需要经历与通道类似的旅程,因为温度位于 item/condition/temp,而其单位是units/temperature

对于wind部分,我们使用一种懒惰的方式来检索数据。windObj行不是一个单一值语句;相反,它有几个键和值。因此,我们使用for循环遍历这个数组,并检索其键及其值,然后将它们简单地组合在一起。

现在,让我们回到MainWindow类,看看如何与Weather类交互。MainWindow的头文件mainwindow.h在此粘贴:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include "weather.h"

namespace Ui {
  class MainWindow;
}

class MainWindow : public QMainWindow
{
  Q_OBJECT

public:
  explicit MainWindow(QWidget *parent = 0);
  ~MainWindow();

private:
  Ui::MainWindow *ui;
  Weather *w;

private slots:
  void onOkButtonClicked();
  void onAttrImageDownloaded(const QImage &);
  void onWeatherUpdateFinished(const QString &location, const QString &temp, const QString &wind);
};

#endif // MAINWINDOW_H

我们声明一个Weather对象指针w作为MainWindow类的私有成员。同时,onOkButtonClicked是当okButton被点击时的处理程序。onAttrImageDownloadedonWeatherUpdateFinished函数将与Weather类的信号相关联。现在,让我们看看源文件中有什么:

#include "mainwindow.h"
#include "ui_mainwindow.h"

MainWindow::MainWindow(QWidget *parent) :
  QMainWindow(parent),
  ui(new Ui::MainWindow)
{
  ui->setupUi(this);
  w = new Weather(this);

  connect(ui->okButton, &QPushButton::clicked, this, &MainWindow::onOkButtonClicked);
  connect(w, &Weather::updateFinished, this, &MainWindow::onWeatherUpdateFinished);
  connect(w, &Weather::imageDownloaded, this, &MainWindow::onAttrImageDownloaded);
  w->getAttrImg();
}

MainWindow::~MainWindow()
{
  delete ui;
}

void MainWindow::onOkButtonClicked()
{
  w->updateData(ui->woeidEdit->text());
}

void MainWindow::onAttrImageDownloaded(const QImage &img)
{
  ui->attrLabel->setPixmap(QPixmap::fromImage(img));
}

void MainWindow::onWeatherUpdateFinished(const QString &location, const QString &temp, const QString &wind)
{
  ui->locationLabel->setText(location);
  ui->tempLabel->setText(temp);
  ui->windLabel->setText(wind);
}

在构造函数中,除了信号连接和w对象的构建外,我们调用wgetAttrImg来检索属性图像。当图像下载完成后,将执行onAttrImageDownloaded槽函数,其中图像将在attrLabel上显示。

当用户点击okButton时,将执行onOkButtonClicked槽函数,其中我们调用Weather类的updateData函数来传递 WOEID。然后,当更新完成后,将发出updateFinished信号并执行onWeatherUpdateFinished。我们仅使用这三个QString对象来设置相应标签的文本。

现在,测试您的应用程序,看看它是否如以下截图所示运行:

解析 JSON 结果

解析 XML 结果

尽管许多 API 都提供 XML 和 JSON 结果,但您可能仍然会发现其中一些只提供一种格式。此外,您可能会觉得在 C++/Qt 中解析 JSON 不是一个愉快的体验。您可能还记得在 QML/Qt Quick 中解析 XML 模型是多么容易。那么,让我们看看如何在 C++/Qt 中实现这一点。

要使用 xml 模块,我们必须在 project 文件中将 xml 添加到 QT 中,就像我们添加网络一样。这次,Qt 提供了一个名为 QXmlStreamReader 的 XML 读取器类,以帮助我们解析 XML 文档。我们需要做的第一件事是将 Weather 类中的 updateData 函数更改为让 Yahoo! 天气 API 返回 XML 结果。

void Weather::updateData(const QString &woeid)
{
  QUrl url("https://query.yahooapis.com/v1/public/yql?q=select * from weather.forecast where woeid = " + woeid + "&format=xml");
    QNetworkRequest req(url);
    naManager->get(req);
}

&format=json 更改为 &format=xml 需要在这里完成。相比之下,在 onQueryFinished 槽函数中有很多工作要做。旧的 JSON 部分被注释掉,这样我们就可以编写 XML 解析代码。不带注释的修改后的函数如下所示:

void Weather::onQueryFinished(QNetworkReply *re)
{
  if (re->error() != QNetworkReply::NoError) {
    qDebug() << re->errorString();
    re->deleteLater();
    return;
  }

  if (re == imgReply) {
    attrImg = QImage::fromData(imgReply->readAll());
    emit imageDownloaded(attrImg);
    imgReply->deleteLater();
    return;
  }

  QByteArray result = re->readAll();
  re->deleteLater();

  QXmlStreamReader xmlReader(result);
  while (!xmlReader.atEnd() && !xmlReader.hasError()) {
    QXmlStreamReader::TokenType token = xmlReader.readNext();
    if (token == QXmlStreamReader::StartElement) {
      QStringRef name = xmlReader.name();
      if (name == "channel") {
        parseXMLChannel(xmlReader);
      }
    }
  }
}

这里,parseXMLChannel 是一个新创建的成员函数。我们可以使用一个单独的函数来使我们的代码整洁有序。

注意

记得在头文件中声明 parseXMLChannel 函数。

其定义如下粘贴:

void Weather::parseXMLChannel(QXmlStreamReader &xml)
{
  QString location, temperature, wind;
  QXmlStreamReader::TokenType token = xml.readNext();
  while (token != QXmlStreamReader::EndDocument) {
    if (token == QXmlStreamReader::EndElement || xml.name().isEmpty()) {
      token = xml.readNext();
      continue;
    }

    QStringRef name = xml.name();
    if (name == "location") {
      QXmlStreamAttributes locAttr = xml.attributes();
      location = locAttr.value("city").toString() + ", " + locAttr.value("country").toString() + ", " + locAttr.value("region").toString();
    }
    else if (name == "units") {
      temperature = xml.attributes().value("temperature").toString();
    }
    else if (name == "wind") {
      QXmlStreamAttributes windAttr = xml.attributes();
      for (QXmlStreamAttributes::ConstIterator it = windAttr.begin(); it != windAttr.end(); ++it) {
        wind.append(it->name().toString());
        wind.append(": ");
        wind.append(it->value());
        wind.append("\n");
      }
    }
    else if (name == "condition") {
      temperature.prepend(xml.attributes().value("temp").toString());
      break;//we got all information, exit the loop
    }
    token = xml.readNext();
  }

  emit updateFinished(location, temperature, wind);
}

在我们遍历 parseXMLChannel 函数之前,我想向你展示 XML 文档的样子,如下所示:

<?xml version="1.0"?>
<query  yahoo:count="1" yahoo:created="2014-12-06T22:50:22Z" yahoo:lang="en-GB">
  <results>
    <channel>
      <title>Yahoo! Weather - Dublin, IE</title>
      <link>http://us.rd.yahoo.com/dailynews/rss/weather/Dublin__IE/*http://weather.yahoo.com/forecast/EIXX0014_f.html</link>
      <description>Yahoo! Weather for Dublin, IE</description>
      <language>en-us</language>
      <lastBuildDate>Sat, 06 Dec 2014 9:59 pm GMT</lastBuildDate>
      <ttl>60</ttl>
      <yweather:location  city="Dublin" country="Ireland" region="DUB"/>
      <yweather:units  distance="mi" pressure="in" speed="mph" temperature="F"/>
      <yweather:wind  chill="41" direction="230" speed="22"/>
      <yweather:atmosphere  humidity="93" pressure="30.03" rising="2" visibility="6.21"/>
      <yweather:astronomy  sunrise="8:24 am" sunset="4:07 pm"/>
      <image>
        <title>Yahoo! Weather</title>
        <width>142</width>
        <height>18</height>
        <link>http://weather.yahoo.com</link>
        <url>http://l.yimg.com/a/i/brand/purplelogo//uh/us/news-wea.gif</url>
      </image>
      <item>
        <title>Conditions for Dublin, IE at 9:59 pm GMT</title>
        <geo:lat >53.33</geo:lat>
        <geo:long >-6.29</geo:long>
        <link>http://us.rd.yahoo.com/dailynews/rss/weather/Dublin__IE/*http://weather.yahoo.com/forecast/EIXX0014_f.html</link>
        <pubDate>Sat, 06 Dec 2014 9:59 pm GMT</pubDate>
        <yweather:condition  code="27" date="Sat, 06 Dec 2014 9:59 pm GMT" temp="48" text="Mostly Cloudy"/>
        <description><![CDATA[<img src="img/27.gif"/><br /> <b>Current Conditions:</b><br /> Mostly Cloudy, 48 F<BR /> <BR /><b>Forecast:</b><BR /> Sat - Light Rain/Wind Late. High: 48 Low: 42<br /> Sun - Mostly Sunny/Wind. High: 44 Low: 37<br /> Mon - Sunny. High: 43 Low: 37<br /> Tue - Showers/Wind. High: 53 Low: 39<br /> Wed - Partly Cloudy/Wind. High: 45 Low: 39<br /> <br /> <a href="http://us.rd.yahoo.com/dailynews/rss/weather/Dublin__IE/*http://weather.yahoo.com/forecast/EIXX0014_f.html">Full Forecast at Yahoo! Weather</a><BR/><BR/> (provided by <a href="http://www.weather.com" >The Weather Channel</a>)<br/>]]></description>
        <yweather:forecast  code="11" date="6 Dec 2014" day="Sat" high="48" low="42" text="Light Rain/Wind Late"/>
        <yweather:forecast  code="24" date="7 Dec 2014" day="Sun" high="44" low="37" text="Mostly Sunny/Wind"/>
        <yweather:forecast  code="32" date="8 Dec 2014" day="Mon" high="43" low="37" text="Sunny"/>
        <yweather:forecast  code="11" date="9 Dec 2014" day="Tue" high="53" low="39" text="Showers/Wind"/>
        <yweather:forecast  code="24" date="10 Dec 2014" day="Wed" high="45" low="39" text="Partly Cloudy/Wind"/>
        <guid isPermaLink="false">EIXX0014_2014_12_10_7_00_GMT</guid>
      </item>
    </channel>
  </results>
</query>
<!--  total: 27  -->
<!--  engine4.yql.bf1.yahoo.com  -->

如你所推断,XML 结构与 JSON 文档有很多相似之处。例如,我们所需的所有数据仍然存储在 query/results/channel 中。然而,差异却比你想象的要大。

注意

如果你想要彻底学习 XML,请查看 www.w3schools.com/xml/ 上的 XML 教程。

onQueryFinished 槽中,我们使用一个 while 循环让 xmlReader 继续读取,直到结束或出现错误。QXmlStreamReader 类的 readNext 函数将读取下一个标记并返回其类型。TokenType 是一个枚举,它描述了当前正在读取的标记的类型。每次调用 readNext 时,QXmlStreamReader 将向前移动一个标记。如果我们想读取一个元素的所有数据,我们可能需要从开始读取。因此,我们使用一个 if 语句来确保标记处于起始位置。除此之外,我们还测试我们现在是否正在读取通道。然后,我们调用 parseXMLChannel 来检索我们所需的所有数据。

parseXMLChannel 函数中,基本上采用了相同的策略。我们测试 name 元素,以便我们知道我们处于哪个阶段。值得你注意的是,所有前缀,例如 yweather: 都被省略了。因此,你应该使用 location 而不是 yweather:location。其他部分与 JSON 中的对应部分类似,其中 QStringRef 类似于 QJsonValue。最后但同样重要的是,QXmlStreamReader 是一个流读取器,这意味着它是按顺序读取的。换句话说,我们可以在获取 condition 中的 temp 后跳出 while 循环,因为 condition 是我们感兴趣的最后一个元素。

在这些更改之后,你可以再次构建和运行此应用程序,你应该期望它以相同的方式运行。

构建 Android 的 Qt 应用程序

您可能想知道如何为 Android 设备构建 Qt 应用程序,因为此应用程序是为桌面 PC 构建的。嗯,这比您想象的要简单得多。

  1. 切换到项目模式。

  2. 点击添加工具包并选择Android for armeabit-v7a (GCC 4.9 and Qt 5.3.2)。请注意,文本可能略有不同。

  3. 如果您使用手机作为目标 Android 设备,请将其连接到计算机。

  4. 打开命令提示符并运行adb devices。确保您的设备在列表中。

现在,点击运行,Qt 将弹出一个对话框,提示您选择 Android 设备,如下面的截图所示:

为 Android 构建 Qt 应用程序

我们选择在真实的 Android 设备上运行我们的应用程序,在这个例子中是一个 HTC One 手机。如果您没有可用的 Android 设备,您可能不得不创建一个虚拟设备,如本章开头所述。对于这两种选项,选择设备并点击确定按钮。

注意

在实际的 Android 设备上,您需要进入设置,然后在开发者选项中开启USB 调试

如以下截图所示,演示运行良好。在提交之前,它确实需要持续改进和 UI 优化。然而,请记住,我们设计和构建这个应用程序是为了桌面 PC!我们只是构建了一个没有修改的移动手机版本,它按预期运行。

为 Android 构建 Qt 应用程序

当您测试应用程序时,所有信息都会打印到 Qt Creator 中的应用程序输出面板。这可能对您的应用程序运行异常时很有用。

QML 中的 JSON 解析

让我们用 QML 重写天气演示。您会发现用 QML 编写这样的应用程序是多么简单和优雅。由于 XML 部分在前一章已经介绍过,这次我们将专注于解析 JSON。

首先,创建一个名为Weather_QML的新 Qt Quick 应用程序项目。保持其他设置默认,这意味着我们使用Qt Quick Controls。请记住勾选 Android 工具包的复选框。

创建一个名为Weather.qml的新 QML 文件,以模拟之前 C++代码中的Weather类。此文件内容如下:

import QtQuick 2.3
import QtQuick.Controls 1.2

Rectangle {
  Column {
    anchors.fill: parent
    spacing: 6

    Label {
      id: location
      width: parent.width
      fontSizeMode: Text.Fit
      minimumPointSize: 9
      font.pointSize: 12
    }

    Row {
      spacing: 20
      width: parent.width
      height: parent.height

      Label {
        id: temp
        width: parent.width / 2
        height: parent.height
        fontSizeMode: Text.Fit
        minimumPointSize: 12
        font.pointSize: 72
        font.bold: true
      }

      Label {
        id: wind
        width: temp.width - 20
        height: parent.height
        fontSizeMode: Text.Fit
        minimumPointSize: 9
        font.pointSize: 24
      }
    }
  }

  Image {
    id: attrImg
    anchors { right: parent.right; bottom: parent.bottom }
    fillMode: Image.PreserveAspectFit
    source: 'https://poweredby.yahoo.com/purple.png'
  }

  function query (woeid) {
    var url = 'https://query.yahooapis.com/v1/public/yql?q=select * from weather.forecast where woeid = ' + woeid + '&format=json'
    var res
    var doc = new XMLHttpRequest()
    doc.onreadystatechange = function() {
      if (doc.readyState == XMLHttpRequest.DONE) {
        res = doc.responseText
        parseJSON(res)
      }
    }
    doc.open('GET', url, true)
    doc.send()
  }

  function parseJSON(data) {
    var obj = JSON.parse(data)

    if (typeof(obj) == 'object') {
      if (obj.hasOwnProperty('query')) {
        var ch = obj.query.results.channel
        var loc = '', win = ''
        for (var lk in ch.location) {
          loc += ch.location[lk] + ', '
        }
        for (var wk in ch.wind) {
          win += wk + ': ' + ch.wind[wk] + '\n'
        }
        location.text = loc
        temp.text = ch.item.condition.temp + ch.units.temperature
        wind.text = win
      }
    }
  }
}

第一部分只是之前应用程序的 QML 版本 UI。您可能需要注意Label中的fontSizeModeminimumPointSize属性。这些属性是 Qt 5 中新引入的,允许文本大小动态调整。通过将Text.Fit设置为fontSizeMode,如果heightwidth不足以容纳文本,它将缩小文本,其中minimumPointSize是最小点大小。如果无法以最小大小显示,文本将被截断。类似于elide属性,您必须显式设置TextLabelwidthheight属性,以使这种动态机制生效。

属性图像的显示方式与 C++ 略有不同。我们利用 Qt Quick 的灵活性,通过仅设置 anchorsImage 浮动在所有项目之上。此外,我们不需要使用 QNetworkAccessManager 来下载图像。一切都在一个地方。

在 UI 部分之后,我们创建了两个 JavaScript 函数来完成脏活。query 函数用于发送 http 请求,并在完成后将接收到的数据传递给 parseJSON 函数。不要被 XMLHttpRequest 中的 XML 搞混;它只是一个传统的命名约定。然后,我们为 onreadystatechanged 创建一个 handler 函数,以便在请求完成后调用 parseJSON。请注意,open 函数不会发送请求,只有 send 函数才会。

parseJSON 函数中,代码依然简洁。JSON.parse 如果解析成功,将返回一个 JSON 对象。因此,在我们开始解析之前,我们需要测试其类型是否为 object。然后,我们再进行一个测试,看看它是否有 query 属性。如果有,我们就可以开始从 obj 中提取数据。与它的 C++ 对应物不同,我们可以将其所有键视为属性,并使用 dot 操作直接访问它们。为了缩短操作,我们首先创建一个 ch 变量,它是 query/results/channel。接下来,我们从 ch 对象中提取数据。最后,我们直接更改文本。

注意

ch.locationch.wind 对象可以被视为 QVariantMap 对象。因此,我们可以使用 for 循环轻松提取值。

让我们按照以下方式编辑 main.qml 文件:

import QtQuick 2.3
import QtQuick.Controls 1.2
import "qrc:/"

ApplicationWindow {
  visible: true
  width: 240
  height: 320
  title: qsTr("Weather QML")

  Row {
    id: inputField
    anchors { top: parent.top; topMargin: 10; left: parent.left; leftMargin: 10; right: parent.right; rightMargin: 10 }
    spacing: 6

    Label {
      id: woeidLabel
      text: "WOEID"
    }
    TextField {
      width: inputField.width - woeidLabel.width
      inputMethodHints: Qt.ImhDigitsOnly
      onAccepted: weather.query(text)
    }
  }

  Weather {
    anchors { top: inputField.bottom; topMargin: 10; left: parent.left; leftMargin: 10; right: parent.right; rightMargin: 10; bottom: parent.bottom; bottomMargin: 10 }
    id: weather
  }
}

Row 是相同的 WOEID 输入面板,这次我们没有创建一个 OK 按钮。相反,我们在 onAccepted 中处理接受信号,通过调用 weather 中的 query 函数,其中 weather 是一个 Weather 元素。我们将 inputMethodHints 属性设置为 Qt.ImhDigitsOnly,这在移动平台上非常有用。这个应用程序应该几乎与 C++ 版本一样运行,或者我们应该说更好。

在 QML 中解析 JSON

inputMethodHints 属性在桌面上的可能看起来没有用;确实,您需要使用 inputMaskvalidator 来限制可接受的输入。然而,它在移动设备上展示了其力量,如下所示:

在 QML 中解析 JSON

如您所见,inputMethodHints 不仅限制了输入,还为用户提供了更好的体验。这在 C++/Qt 开发中也是可行的;您可以找到相关的函数来实现这一点。QML 的整个要点在于解析 JSON 和 XML 文档比 C++ 更容易且更整洁。

摘要

在本章之后,你将预期处理常见任务并编写真实世界应用的各种类型。你将对 Qt Quick 和传统 Qt 有自己的理解。编写混合应用也是一个当前趋势,通过编写 C++ 插件来增强 QML,充分利用它们。QML 在灵活的 UI 设计方面具有无与伦比的优势,这在移动平台上尤为明显。尽管开发部分即将结束,但在下一章中,我们将讨论如何支持多种语言。

第八章:使您的 Qt 应用程序支持其他语言

在这个全球化的时代,应用程序的国际化与本地化几乎是不可避免的。幸运的是,Qt 提供了相关的类,以及一些实用的工具,如Qt Linguist,以减轻开发者和翻译者的负担。在本章中,我们将使用两个示例应用程序来展示以下主题:

  • Qt 应用程序的国际化

  • 翻译 Qt Widgets 应用程序

  • 区分相同的文本

  • 动态更改语言

  • 翻译 Qt Quick 应用程序

Qt 应用程序的国际化

国际化和本地化是将应用程序适应其他地区的过程,这可能包括不同的语言和区域差异。在软件开发中,国际化是指以这种方式设计应用程序,使其可以适应各种语言和地区,而无需更改代码。另一方面,本地化是指为特定语言或地区调整国际化软件。这通常涉及特定于区域的组件和翻译文本。

Qt 已经做了很多工作,让开发者摆脱了不同的书写系统。只要我们使用 Qt 的输入和显示控件或它们的子类,我们就不必担心不同语言如何显示和输入。

在大多数情况下,我们需要做的是生成翻译并在应用程序中启用它们。Qt 提供了QTranslator类,该类加载翻译文件并在屏幕上显示相应的语言。整个过程总结在以下图中:

Qt 应用程序国际化

首先,Qt 不会自动将所有字符串都设置为可翻译的,因为这显然会是一场灾难。相反,您需要在代码或设计模式中显式设置字符串是否可翻译。在 Qt/C++代码中,使用tr()函数将所有可翻译的字符串括起来。在 Qt Quick/QML 代码中,我们使用qsTr()函数来完成这项工作。让我给您举一个例子。以下是一个字符串正常使用的演示:

qDebug() << "Hello World";

这将在标准输出流中输出Hello World,在一般情况下,这是您的命令提示符或 shell。如果我们想使Hello World可翻译,我们需要使用tr()函数将字符串括起来,如下所示:

qDebug() << tr("Hello World");

由于tr()QObject类的静态公共成员函数,因此即使是非QObject类,您仍然可以使用它。

qDebug() << QObject::tr("Hello World");

然后,我们需要使用lupdate命令,该命令位于 Qt Creator 的工具 | 外部 | Linguist | 更新翻译(lupdate)。这将更新(如果翻译源文件不存在则创建)。然后,您可以使用 Qt Linguist 翻译字符串。在您发布应用程序之前,运行工具 | 外部 | Linguist | 发布翻译(lrelease)中的lrelease命令,以生成应用程序可以动态加载的Qt 消息(QM)文件。不用担心它会让您感到困惑;我们将使用两个示例来引导您完成这些步骤。

翻译 Qt 小部件应用程序

首先,让我们创建一个新的 Qt Widget 项目,其名称为Internationalization。然后,在设计模式下编辑mainwindow.ui

  1. 如同往常,移除状态栏、菜单栏和工具栏。

  2. 标签添加到centralWidget中,并将其对象名更改为nonTransLabel。然后,将其文本更改为这是一个不可翻译的标签,并在属性编辑器中取消选中text下的translatable

  3. 将一个按钮拖到nonTransLabel下方,其对象名为transButton。将其文本更改为这是一个可翻译的按钮

  4. MainWindow中将布局更改为垂直布局

  5. 调整框架大小到一个舒适的大小。

返回到编辑模式下编辑Internationalization.pro项目文件。添加一行指示翻译源文件,如下所示:

TRANSLATIONS = Internationalization_de.ts

_de后缀是一个区域设置代码,表示这是一个德语翻译源文件。区域设置代码由互联网工程任务组BCP 47文档系列中定义。历史上,Qt 遵循 POSIX 定义,这与 BCP 47 略有不同。在这个中,它使用下划线(_)而不是连字符(-)来分隔子标签。换句话说,巴西葡萄牙语表示为pt_BR而不是pt-BR。同时,Qt 从 Qt 4.8 版本开始提供了一些 API 来使区域名称符合 BCP 47 定义。

为了确保此更改有效,保存项目文件,然后在项目上右键单击并选择运行 qmake。之后,我们可以通过执行lupdate命令生成翻译源文件,该文件正好是Internationalization_de.ts。结果将打印在常规消息面板中,其中包含添加到 TS 文件中的字符串,如下所示:

Updating 'Internationalization_de.ts'...
Found 3 source text(s) (3 new and 0 already existing)

现在,在 Qt Linguist 中打开Internationalization_de.ts文件。以下截图显示了 Qt Linguist 的概览 UI:

翻译 Qt 小部件应用程序

上下文列出源文本上下文,在大多数情况下是类名,而字符串包含所有可翻译的字符串。源和形式显示字符串的对应位置,可以是代码片段或 UI 表单。在其下方是翻译区域,允许您输入翻译和注释(如果有)。

除了概述之外,每个条目前面的图标也值得关注。一个黄色的问号(?)简单地表示目前没有翻译,而绿色的勾号表示已接受/正确,黄色的勾号则表示已接受/有警告。您还可能遇到红色的感叹号(!),这表示警告。在“来源和形式”面板中按钮文本前面的尖锐符号(#)表示未翻译,可能可翻译的字符串。Qt Linguist 会根据其自己的算法自动检查字符串翻译,这意味着它可能会给出错误的警告。在这种情况下,只需忽略警告并接受翻译。

您会发现标签文本不在源文本中。这是因为我们没有勾选translatable属性。现在,在翻译区域输入德语翻译,然后在工具栏中单击完成并下一步按钮,然后导航到翻译 | 完成并下一步。或者,更快的方法是按Ctrl + Enter来接受翻译。完成翻译后,单击保存按钮,然后退出 Qt Linguist。

虽然推荐使用 Qt Linguist 进行翻译任务,但直接使用普通文本编辑器编辑 TS 文件也是可行的。TS 文件是 XML 格式的,应该被其他编辑器很好地支持。

翻译后,返回 Qt Creator 并运行lrelease命令以生成Internationalization_de.qm文件。在当前阶段,您的项目文件夹应包含 TS 和 QM 文件,如下面的截图所示:

翻译 Qt 小部件应用程序

注意

注意,由于操作系统和(或)软件安装的不同,您的计算机上的文件图标可能会有所不同。

我们已经生成了 QM 文件;现在是时候修改main.cpp文件,以便将翻译加载到这个应用程序中。

#include "mainwindow.h"
#include <QApplication>
#include <QTranslator>

int main(int argc, char *argv[])
{
  QApplication a(argc, argv);

 QTranslator translator;
 translator.load(QLocale::German, "Internationalization", "_");
 a.installTranslator(&translator);

  MainWindow w;
  w.show();

  return a.exec();
}

在这里,使用QTranslator来加载德语翻译。在我们将翻译器安装到QApplication之前,我们必须通过调用load函数来加载一个 QM 文件。这将加载一个翻译文件,其文件名由Internationalization后跟_和 UI 语言名称(在这种情况下是de)以及.qm(默认值)组成。有一个简化的重载load函数。我们的等效函数如下:

translator.load("Internationalization_de");

通常,调用前面的load函数会更好,因为它使用QLocale::uiLanguages(),如果新区域需要,它还会格式化日期和数字。无论您选择哪种方式,都请记住,如果您在MainWindow w;行之后加载翻译,MainWindow将无法使用该翻译。

如果您现在运行应用程序,应用程序不会显示德语。为什么?这仅仅是因为QTranslator找不到Internationalization_de.qm文件。有很多方法可以解决这个问题。最干净的方法是在 Qt Creator 中运行应用程序时更改工作目录。

  1. 切换到项目模式。

  2. 切换到运行设置

  3. 工作目录更改为包含Internationalization_de.qm文件的项目源目录。

再次运行它;你会在屏幕上看到德语文本,如下所示:

翻译 Qt 小部件应用程序

标签以我们预期的英语显示,而窗口标题和按钮文本则以德语显示。

你可能认为这个解决方案毫无意义,因为尽管系统语言环境设置已经加载,德语翻译仍然可用。然而,只需进行一项修改,应用程序就可以根据系统语言环境加载翻译;那就是,将翻译器加载行更改为以下所示:

translator.load(QLocale::system().language(), "Internationalization", "_");

在这里,system()QLocale类的静态成员函数,它返回一个用系统语言环境初始化的QLocale对象。然后我们调用language()函数来获取当前语言环境的语言。

区分相同文本

如果存在相同的文本,默认行为是将它们视为具有相同意义的文本。这可以有效地避免翻译员翻译相同的文本。然而,这并不总是正确的。例如,单词open可以用作名词或形容词,在其他语言中可能是不同的单词。幸运的是,在 Qt 中区分相同的文本是可能且容易的。

现在,让我们在transButtonnonTransLabel之间添加一个PushButtonopenButton。使用Open作为其文本,然后编辑mainwindow.h。添加一个名为onOpenButtonClicked()的新私有槽,用于处理openButton被点击的事件。相关的源文件mainwindow.cpp如下所示:

#include <QMessageBox>
#include "mainwindow.h"
#include "ui_mainwindow.h"

MainWindow::MainWindow(QWidget *parent) :
  QMainWindow(parent),
  ui(new Ui::MainWindow)
{
  ui->setupUi(this);

 connect(ui->openButton, &QPushButton::clicked, this, &MainWindow::onOpenButtonClicked);
}

MainWindow::~MainWindow()
{
  delete ui;
}

void MainWindow::onOpenButtonClicked()
{
 QMessageBox::information(this, tr("Dialog"), tr("Open"));
}

首先,我们在MainWindow的构造函数中将openButton的点击信号连接到MainWindowonOpenButtonClicked槽。然后,我们简单地使用QMessageBox的静态成员函数information弹出信息对话框,使用Dialog作为标题,Open作为上下文。别忘了使用tr()函数使这些字符串可翻译。

现在,运行lupdate并打开 Qt Linguist 中的 TS 文件。在字符串面板中只有一个Open字符串,如图所示:

区分相同文本

然而,信息对话框中的Open应该有一个形容词,它不应该与openButton中的文本混淆。这是一个我们需要将这个Open与其他Open区分开来的注释。修改mainwindow.cpp中的onOpenButtonClicked函数:

void MainWindow::onOpenButtonClicked()
{
  QMessageBox::information(this, tr("Dialog"), tr("Open", "adj."));
}

在这里,tr()函数的第二个参数是注释。不同的注释代表不同的文本。这样,lupdate会将它们视为非相同文本。重新运行lupdate,你就能在 Qt Linguist 中翻译两个Open字符串。翻译区域中的开发者注释列如图所示。Qt Linguist 也会显示两个可翻译的Open字符串。

区分相同文本

设计 模式下,openButton 的等效属性是在 text 属性下的 text 属性的区分。翻译后执行 lrelease,然后重新运行应用程序,两个 Open 字符串应该有两个不同的翻译,如下所示演示:

区分相同文本

动态更改语言

有时,人们想使用除系统区域设置指定的语言之外的语言。这是一个应用自定义设置的问题。这通常意味着重新启动应用程序以加载相应的翻译文件。这部分的理由是动态更改语言需要额外的工作。然而,这是可行的,并且可以通过几行代码实现。更重要的是,它提供了更好的用户体验!

让我们在 MainWindow 中添加一个新的 push 按钮。将其命名为 loadButton 并更改其文本为 Load/Unload Translation。然后,以 编辑 模式编辑 main.cpp 文件。删除所有与 QTranslator 相关的行,因为我们将在 MainWindow 类中实现这种动态语言切换。main.cpp 文件应看起来与最初生成的文件一样,如下所示:

#include "mainwindow.h"
#include <QApplication>

int main(int argc, char *argv[])
{
  QApplication a(argc, argv);
  MainWindow w;
  w.show();

  return a.exec();
}

现在,编辑 mainwindow.h,因为我们需要在这里声明一些成员:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include <QTranslator>

namespace Ui {
class MainWindow;
}

class MainWindow : public QMainWindow
{
  Q_OBJECT

public:
  explicit MainWindow(QWidget *parent = 0);
  ~MainWindow();

private:
  Ui::MainWindow *ui;
 QTranslator *deTranslator;
 bool deLoaded;

private slots:
  void onOpenButtonClicked();
 void onLoadButtonClicked();

protected:
 void changeEvent(QEvent *);
};

#endif // MAINWINDOW_H

如您所知,我们将 QTranslator 移到这里,命名为 deTranslator,并将其用作 deLoaded 变量的指针,以表示我们是否已经加载了德语翻译。下面的 onLoadButtonClicked 是一个 private 插槽函数,它将被连接到 loadButton 的点击信号。最后但同样重要的是,我们重写了 changeEvent,这样我们就可以在飞行中翻译整个用户界面。它将在 mainwindow.cpp 源文件中变得清晰,如下所示粘贴:

#include <QMessageBox>
#include "mainwindow.h"
#include "ui_mainwindow.h"

MainWindow::MainWindow(QWidget *parent) :
  QMainWindow(parent),
  ui(new Ui::MainWindow)
{
  ui->setupUi(this);

  deTranslator = new QTranslator(this);
  deTranslator->load(QLocale::German, "Internationalization", "_");
  deLoaded = false;

  connect(ui->openButton, &QPushButton::clicked, this, &MainWindow::onOpenButtonClicked);
  connect(ui->loadButton, &QPushButton::clicked, this, &MainWindow::onLoadButtonClicked);
}

MainWindow::~MainWindow()
{
  delete ui;
}

void MainWindow::onOpenButtonClicked()
{
  QMessageBox::information(this, tr("Dialog"), tr("Open", "adj."));
}

void MainWindow::onLoadButtonClicked()
{
 if (deLoaded) {
 deLoaded = false;
 qApp->removeTranslator(deTranslator);
 }
 else {
 deLoaded = true;
 qApp->installTranslator(deTranslator);
 }
}

void MainWindow::changeEvent(QEvent *e)
{
 if (e->type() == QEvent::LanguageChange) {
 ui->retranslateUi(this);
 }
 else {
 QMainWindow::changeEvent(e);
 }
}

在构造函数中,我们初始化 deTranslator 并加载德语翻译,这与我们在之前的 main.cpp 中所做几乎相同。然后,我们将 deLoaded 设置为 false,表示德语翻译尚未安装。接下来,这是一个 connect 语句。

现在,让我们看看 onLoadButtonClicked 函数,看看如果点击 loadButton 会发生什么。我们将 deLoaded 设置为 false 并删除已加载的 deTranslator。否则,我们安装 deTranslator 并将 deLoaded 设置为 true。请记住,qApp 是一个预定义的宏,它简单地指向当前的 QCoreApplication 实例。installTranslatorremoveTranslator 都会将事件传播到所有顶级窗口,也就是说,在这种情况下将触发 MainWindowchangeEvent

为了根据翻译器更新所有文本,我们必须 重写 changeEvent。在这个 重写 的函数中,如果事件是 languageChange,我们调用 retranslateUi 函数来重新翻译 MainWindow。否则,我们简单地调用继承的默认 QMainWindow::changeEvent 函数。

当你第一次启动应用程序时,它将显示英文文本。

动态更改语言

一旦你点击 加载/卸载翻译 按钮,所有可翻译和已翻译的文本将显示为德语。

动态更改语言

如果你再次点击按钮,它将显示英文。除了不可翻译的标签外,loadButton 也不会被翻译。这是因为我们根本就没有翻译按钮。然而,正如你所看到的,缺少一些翻译不会阻止应用程序加载其他翻译文本。

翻译 Qt Quick 应用程序

翻译 Qt Quick 应用程序的过程与 Qt Widgets 应用程序类似。我们将通过另一个示例应用程序来介绍这个过程。

创建一个新的 Qt Quick 应用程序项目,并将其命名为 Internationalization_QML。生成的 main.qml 文件已经为我们添加了 qsTr() 函数。在 Qt Creator 和(或)Qt 库的后续版本中,内容可能会有所不同。然而,它应该看起来与这个类似:

import QtQuick 2.3
import QtQuick.Controls 1.2

ApplicationWindow {
  visible: true
  width: 640
  height: 480
  title: qsTr("Hello World")

  menuBar: MenuBar {
    Menu {
      title: qsTr("File")
      MenuItem {
        text: qsTr("&Open")
        onTriggered: console.log("Open action triggered");
      }
      MenuItem {
        text: qsTr("Exit")
        onTriggered: Qt.quit();
      }
    }
  }

  Text {
    text: qsTr("Hello World")
    anchors.centerIn: parent
  }
}

现在,让我们编辑 Internationalization_QML.pro 项目文件,其修改后的版本如下粘贴:

TEMPLATE = app

QT += qml quick widgets

SOURCES += main.cpp

RESOURCES += qml.qrc

lupdate_only {
 SOURCES += main.qml
}

TRANSLATIONS = Internationalization_QML_de.ts

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

# Default rules for deployment.
include(deployment.pri)

除了 TRANSLATIONS 行之外,我们还添加了一个 lupdate_only 块。在这种情况下,这是至关重要的。

注意

我们可能不需要在 Qt/C++ 项目中此块,因为 lupdate 工具从 SOURCESHEADERSFORMS 中提取可翻译的字符串。

然而,这意味着所有位于其他位置的字串都不会被找到,更不用说翻译了。另一方面,qml 文件不是将被 C++ 编译器编译的 C++ 源文件。在这种情况下,我们使用 lupdate_only 来限制那些仅对 lupdate 可用的 SOURCES

现在,执行 lupdate 可以为我们生成翻译源文件。同样,我们使用 Qt Linguist 来翻译 Internationalization_QML_de.ts 文件。然后,执行 lrelease 来生成 QM 文件。

要加载翻译,我们需要将 main.cpp 修改为以下所示:

#include <QApplication>
#include <QQmlApplicationEngine>
#include <QTranslator>

int main(int argc, char *argv[])
{
  QApplication app(argc, argv);

 QTranslator translator;
 translator.load(QLocale::German, "Internationalization_QML", "_");
 app.installTranslator(&translator);

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

  return app.exec();
}

此外,我们还需要将工作目录项目模式下的运行设置中更改为该项目的目录。现在,再次运行应用程序;我们应该能够在屏幕上看到德语文本,就像以下截图所示:

翻译 Qt Quick 应用程序

有一种加载翻译文件的方法,不需要更改工作目录。首先,将 main.cpp 中的 translator.load 行更改为以下内容:

translator.load(QLocale::German, "Internationalization_QML", "_", ":/");

我们指定了翻译者应该搜索的目录。在这种情况下,它是 ":/",这是 Resources 中的顶级目录。请勿在目录字符串前添加 qrc;这将导致 translator 无法找到 QM 文件。在这里,一个冒号(:)就足够了,以表示 Resources 中有一个 qrc 路径。

您可以创建一个新的 qrc 文件,或者类似我们这样做,将 Internationalization_QML_de.qm 添加到当前的 qml.qrc 文件中。

  1. 项目编辑器 下的 资源 中的 qml.qrc 文件上右键单击。

  2. 编辑器 中选择 打开

  3. 在右下角的面板上导航到 添加 | 添加文件

  4. 选择 Internationalization_QML_de.qm 文件,并单击 打开

现在,Internationalization_QML_de.qm 文件应该在 编辑器项目 树中同时显示,如下面的截图所示:

翻译 Qt Quick 应用程序

切换到 项目 模式,并在 运行设置 中重置 工作目录。然后再次运行应用程序;德语翻译应该仍然可以成功加载。

到目前为止,Qt 和 Qt Quick 之间没有太大的区别。然而,在 Qt Quick 中实现动态翻译的安装和删除是繁琐的。您必须编写一个 C++ 类来安装和删除翻译器,然后它发出一个信号,指示文本有变化。因此,Qt Quick 应用程序的最佳实践是将语言作为一个设置。用户可以加载不同的翻译。尽管如此,这需要重新启动应用程序。

摘要

现在,您可以通过添加对其他语言的支持来使您的应用程序更具竞争力。此外,Qt Linguist,这是一个由 Qt 提供的跨平台工具,也非常易于使用,也包含在本章中。除了您学到的技能外,您还可以看出 Qt/C++ 在 API 和功能方面仍然比 Qt Quick/QML 具有巨大优势。

在下一章中,我们将使我们的 Qt 应用程序可重新分发,并将它们部署到其他设备上。

第九章:部署到其他设备上的应用程序

在开发完成后,是时候分发你的应用程序了。我们将使用上一章中的示例应用程序Internationalization来演示如何将 Qt 应用程序推广到 Windows、Linux 和 Android。本章将涵盖以下主题:

  • 在 Windows 上发布 Qt 应用程序

  • 创建安装程序

  • 在 Linux 上打包 Qt 应用程序

  • 在 Android 上部署 Qt 应用程序

在 Windows 上发布 Qt 应用程序

在开发阶段之后,你可以使用release作为构建配置来构建你的应用程序。在release配置中,编译器会对代码进行优化,并且不会产生调试符号,这反过来又减少了大小。请确保项目处于release配置。

在我们深入包装过程之前,我想谈谈静态链接和动态链接之间的区别。你可能在这本书的整个过程中一直在使用 Qt 库的动态链接。如果你从 Qt 网站上下载社区版,这可以得到证实。

那么,动态链接是什么意思呢?嗯,这意味着当可执行文件执行时,操作系统将在运行时加载和链接必要的共享库。换句话说,你将在 Windows 上看到很多.dll文件,在 Unix 平台上看到很多.so文件。这种技术允许开发者分别更新这些共享库和可执行文件,这意味着如果你更改共享库,只要它们的 ABIs 兼容,你不需要重新构建可执行文件。尽管这种方法更灵活,但开发者被警告要小心避免DLL 地狱

在 Windows 上解决 DLL 地狱最常用的解决方案是选择静态链接。相比之下,静态链接将在编译时解析所有函数调用和变量,并将它们复制到目标中,以生成独立的可执行文件。优势是显而易见的。首先,你不需要分发所有必要的共享库。在这种情况下不会出现 DLL 地狱。在 Windows 上,根据你使用的编译器,静态库可能具有.lib.a扩展名,而在 Unix 平台上通常具有.a扩展名。

为了进行清晰的比较,为你制作了一个表格,以查看动态链接和静态链接之间的差异:

动态链接 静态链接
库类型 共享库 静态库
可执行文件大小 相对较小 大于动态链接
库更新 只有库本身 可执行文件需要重新构建
不兼容的库 需要小心避免这种情况 不会发生

然而,如果与动态链接的可执行文件一起分发的共享库被视为包的一部分,那么动态链接风格的包将比静态链接的独立可执行文件大。

现在,回到主题!由于 Windows 没有标准的 Qt 运行时库安装程序,最好的做法是生成一个静态链接的目标,因为要发布的包会更小,并且可执行文件不会受到 DLL 地狱的影响。

然而,如前所述,你下载的 Qt 库只能用于动态链接应用程序,因为它们是共享库。将 Qt 编译为静态库是可行的。但在你继续之前,你需要了解 Qt 的许可证。

目前,除了 Qt 开源许可证外,还有 Qt 商业许可证。对于开源许可证,大多数 Qt 库都根据GNU Lesser General Public License(LPGL)授权。在这种情况下,如果你使用 Qt 库静态链接构建应用程序,你的应用程序必须根据 LPGL 向用户提供应用程序的源代码。如果你的应用程序与 Qt 库动态链接,则可以保持专有和闭源。换句话说,如果你想静态链接应用程序并保持其专有性,你必须购买 Qt 商业许可证。有关 Qt 许可的详细信息,请参阅www.qt.io/licensing/

如果你决定使用静态链接,你可能需要在构建应用程序之前将 Qt 库静态编译。在这种情况下,可执行目标是需要打包和发布的唯一东西。如果你的应用程序具有多语言支持,不要忘记之前提到的 QM 文件。

另一方面,如果你想走动态方式,则需要额外努力。首先,有一些核心 DLL 必须存在,而且根据编译器的不同,列表也不同。以下表格包括 MSVC 和 MinGW/GCC 场景:

MSVC 2013 MinGW/GCC
msvcp120.dll libgcc_s_dw2-1.dll
msvcr120.dll libstdc++-6.dll
libwinpthread-1.dll

需要包含一些常见的 DLL,例如icudt53.dllicuin53.dllicuuc53.dll。你可以在 Qt 库目录中找到这些文件。以 MinGW/GCC 为例;它们位于QT_DIR\5.4\mingw491_32\bin,其中QT_DIR是 Qt 安装路径,例如D:\Qt。请注意,Qt 的后续版本可能具有略微不同的文件名。

此外,如果目标用户已经安装了 Visual Studio 2013 的Visual C++ Redistributable Packages,则不需要分发msvcp120.dllmsvcr120.dll,这些可以从www.microsoft.com/en-ie/download/details.aspx?id=40784下载。

在此之后,你可能想通过查看项目文件来检查你需要的其他 DLL。以Internationalization项目为例。其项目文件Internationalization.pro为我们提供了线索。有两行与 QT 配置相关,如下所示:

QT       += core gui

greaterThan(QT_MAJOR_VERSION, 4): QT += widgets

QT变量包括core gui小部件。实际上,所有 Qt 应用程序至少都会包含core,而其他则是依赖的。在这种情况下,我们必须将Qt5Core.dllQt5Gui.dllQt5Widgets.dll与可执行目标一起分发。

现在,使用 MinGW/GCC 构建Internationalization项目。可执行目标Internationalization.exe应位于构建目录的release文件夹中,这可以在项目模式下读取。接下来,我们创建一个名为package的新文件夹,并将可执行文件复制到那里。然后,我们将所需的 DLL 文件也复制到package中。现在,这个文件夹应该包含所有必要的 DLL 文件,如下所示:

在 Windows 上发布 Qt 应用程序

在大多数情况下,如果缺少所需的库,应用程序将无法运行,操作系统将提示缺少的库名称。例如,如果缺少Qt5Widgets.dll,当您尝试运行Internationalizationi.exe时,将显示以下系统错误对话框:

在 Windows 上发布 Qt 应用程序

基本上,常规操作是将缺失的库复制到应用程序所在的同一文件夹中。此外,您还可以使用一些工具,例如Dependency Walker来获取库依赖项。

请不要使用Qt 编辑器文件夹中的 DLL 文件。这个版本通常与您使用的 Qt 库不同。除了这些库之外,您可能还需要包含应用程序将要使用到的所有资源。例如,用于翻译的 QM 文件,即复制Internationalization_de.qm文件以加载德语翻译。

文件列表如下:

  • icudt53.dll

  • icuin53.dll

  • icuuc53.dll

  • Internationalization.exe

  • Internationalization_de.qm

  • libgcc_s_dw2-1.dll

  • libstdc++-6.dll

  • libwinpthread-1.dll

  • Qt5Core.dll

  • Qt5Gui.dll

  • Qt5Widgets.dll

不要忘记,这是 Qt 5.4.0 中 MinGW/GCC 的情况,而不同版本和编译器可能会有略微不同的列表,正如我们之前讨论的那样。

在这次首次准备之后,在某种程度上这个列表是固定的。您只需要更改可执行目标,如果 QM 文件有变化,也需要更改。一种简单的方法是将它们全部压缩成tarball

创建安装程序

虽然使用存档文件分发您的应用程序很快捷,但如果您提供安装程序,看起来会更专业。Qt 提供了Qt 安装框架,目前最新的开源版本是 1.5.0,可以从download.qt.io/official_releases/qt-installer-framework/1.5.0/获取。

为了方便起见,让我们在 Qt 安装框架的安装路径D:\Qt\QtIFW-1.5.0下创建一个名为dist的文件夹。这个文件夹用于存储所有需要打包的应用程序项目。

然后,在dist下创建一个名为internationalization的文件夹。在internationalization中,创建两个文件夹,configpackages

packages目录内目录的名称充当类似域名或 Java 风格的标识符。在这个例子中,我们有两个包,一个是应用程序,另一个是翻译。因此,它们分别添加到packages目录下的两个文件夹中,com.demo.internationalizationcom.demo.internationalization.translation。每个文件夹内都将存在metadata目录,所以整体目录结构如下所示:

创建安装程序

让我们编辑全局配置文件,config.xml,它首先位于config目录中。您需要创建一个名为config.xml的文件。

注意

总是记住不要使用 Windows 内置的记事本编辑此文件,或者实际上任何文件。您可以使用 Qt Creator 或其他高级编辑器,如 Notepad++来编辑它。这仅仅是因为记事本作为一个代码编辑器缺少很多功能。

在这个例子中,config.xml文件的内容粘贴在这里:

<?xml version="1.0" encoding="UTF-8"?>
<Installer>
  <Name>Internationalization</Name>
  <Version>1.0.0</Version>
  <Title>Internationalization Installer</Title>
  <Publisher>Packt</Publisher>
  <TargetDir>@homeDir@/Internationalization</TargetDir>
  <AdminTargetDir>@rootDir@/Internationalization</AdminTargetDir>
</Installer>

对于一个最小的config.xml文件,<Installer>中必须存在<Name><Version>元素。所有其他元素都是可选的,但如果需要,您应该指定它们。同时,<TargetDir><AdminTargetDir>可能有点令人困惑。它们都指定默认的安装路径,其中<AdminTargetDir>用于指定获得管理员权限时的安装路径。其他元素基本上是自我解释的。您还可以设置其他元素来自定义安装程序。有关更多详细信息,请参阅doc.qt.io/qtinstallerframework/ifw-globalconfig.html

让我们导航到com.demo.internationalization内的meta文件夹。此目录包含指定部署和安装设置的文件。此目录中的所有文件(除许可证外)都不会被安装程序提取,也不会被安装。必须至少有一个包信息文件,例如package.xml。以下位于com.demo.internationalization/metapackage.xml示例如下:

<?xml version="1.0" encoding="UTF-8"?>
<Package>
  <DisplayName>Core Application</DisplayName>
  <Description>Essential part of Internationalization</Description>
  <Version>1.0.0</Version>
  <ReleaseDate>2014-12-27</ReleaseDate>
  <Name>com.demo.internationalization</Name>
  <Licenses>
    <License name="License Agreement" file="license.txt" />
  </Licenses>
  <Default>true</Default>
  <ForcedInstallation>true</ForcedInstallation>
</Package>

<Default>元素指定此包是否应默认选中。同时,我们将<ForcedInstallation>设置为true,表示最终用户不能取消选择此包。而<Licenses>元素可以有多个子元素<License>,在这种情况下我们只有一个。我们必须提供license.txt文件,其内容仅是一行演示,如下所示:

This is the content of license.txt.

位于com.demo.internationalization.translation/meta的以下package.xml文件行数较少:

<?xml version="1.0" encoding="UTF-8"?>
<Package>
  <DisplayName>German Translation</DisplayName>
  <Description>German translation file</Description>
  <Version>1.0.0</Version>
  <ReleaseDate>2014-12-27</ReleaseDate>
  <Name>com.demo.internationalization.translation</Name>
  <Default>false</Default>
</Package>

<DisplayName><Description>之间的区别通过以下截图演示:

创建安装程序

<Description>元素是在包被选中时在右侧显示的文本。它也是当鼠标悬停在条目上时弹出的文本提示。您还可以看到这两个包之间的关系。正如名称com.demo.internationalization.translation所暗示的,它是一个com.demo.internationalization的子包。

此步骤之后将显示许可证,如下面的截图所示。如果您设置了多个许可证,对话框将有一个面板来分别查看这些许可证,类似于您在安装 Qt 本身时看到的。

创建安装程序

关于package.xml文件中的更多设置,请参阅doc.qt.io/qtinstallerframework/ifw-component-description.html#package-information-file-syntax

相比之下,data目录存储了所有需要安装的文件。在这个例子中,我们将之前准备的所有文件保存在com.demo.internationalizationdata文件夹中,除了 QM 文件。QM 文件Internationalization_de.qm保存在com.demo.internationalization.translation内部的data文件夹中。

在完成所有初始准备后,我们来到生成此项目安装程序的最终步骤。根据您的操作系统,打开命令提示符终端,将当前目录更改为dist/internationalization。在这种情况下,它是D:\Qt\QtIFW-1.5.0\dist\internationalization。然后,执行以下命令以生成internationalization_installer.exe安装程序文件:

..\..\bin\binarycreator.exe -c config\config.xml -p packages internationalization_installer.exe

注意

在 Unix 平台(包括 Linux 和 Mac OS X)上,您必须使用斜杠(/)而不是反斜杠(\),并删除.exe后缀,这使得命令略有不同,如下所示:

../../bin/binarycreator -c config/config.xml -p packages internationalization_installer

您需要等待一段时间,因为binarycreator工具会将data目录中的文件打包成7zip存档,这是一个耗时的过程。之后,您应该会在当前目录中看到internationalization_installer.exe(或没有.exe后缀)。

安装程序更加方便,尤其是对于包含多个可选包的大型应用程序项目。此外,它会在控制面板中注册并允许最终用户卸载。

在 Linux 上打包 Qt 应用程序

在 Linux 上比在 Windows 上更复杂。有两种流行的包格式:RPM 包管理器RPM)和Debian 二进制包DEB)。RPM 最初是为Red Hat Linux开发的,它是Linux 标准基的基本包格式。它主要用于FedoraOpenSUSERed Hat Enterprise Linux及其衍生版本;而后者因在Debian及其知名且流行的衍生版Ubuntu中使用而闻名。

除了这些格式,还有其他 Linux 发行版使用不同的软件包格式,例如 Arch LinuxGentoo。为不同的 Linux 发行版打包你的应用程序将需要额外的时间。

然而,这不会太耗费时间,尤其是对于开源应用程序。如果你的应用程序是开源的,你可以参考文档来编写一个格式化的脚本,用于编译和打包你的应用程序。有关创建 RPM 软件包的详细信息,请参阅fedoraproject.org/wiki/How_to_create_an_RPM_package,而对于 DEB 打包,请参阅www.debian.org/doc/manuals/maint-guide/index.en.html。稍后会有一个示例演示如何打包 DEB。

尽管打包专有应用程序,如 RPM 和 DEB 软件包是可行的,但它们不会进入官方仓库。在这种情况下,你可能会想在你的服务器上设置一个仓库,或者只是通过文件托管发布这些软件包。

或者,你可以将你的应用程序存档,类似于我们在 Windows 上所做的那样,并为安装和卸载编写一个 shell 脚本。这样,你可以使用一个 tarball 或 Qt 安装器框架为各种发行版制作安装程序。但是,永远不要忘记适当地处理依赖关系。在 Linux 上,不兼容的共享库问题更为严重,因为几乎所有的库和应用程序都是动态链接的。最糟糕的是不同发行版之间的不兼容性,因为它们可能使用不同的库版本。因此,要么注意这些陷阱,要么选择静态链接的方式。

正如我们之前提到的,除非你购买了 Qt 商业许可证,否则静态链接的软件必须是开源的。这种困境使得静态链接的开源应用程序变得毫无意义。这不仅是因为动态链接是标准方式,而且因为静态链接的 Qt 应用程序将无法使用系统主题,也无法从系统升级中受益,这在涉及安全更新时是不合适的。无论如何,如果你的应用程序是专有的并且你获得了商业许可证,你可以使用静态链接来编译你的应用程序。在这种情况下,就像 Windows 上的静态链接一样,你只需要发布带有必要资源的目标可执行文件,例如图标和翻译。值得注意的是,即使你构建了静态链接的 Qt 应用程序,也无法在任何 Linux 发行版上运行它们。

因此,推荐的方式是在虚拟机上安装几个主流的 Linux 发行版,然后使用这些虚拟机来打包你的动态链接应用程序,并使用它们自己的软件包格式。二进制软件包不包含源代码,并且从二进制软件包中剥离符号也是常见的做法。这样,你的专有软件的源代码就不会通过这些软件包泄露。

在这里我们仍然以 Internationalization 为例。让我们看看如何创建一个 DEB 软件包。以下操作是在最新的 Debian Wheezy 上测试过的;后续版本或不同的 Linux 发行版可能会有所不同。

在我们打包应用程序之前,我们必须编辑项目文件 Internationalization.pro,使其可安装,如下所示:

QT       += core gui

greaterThan(QT_MAJOR_VERSION, 4): QT += widgets

TARGET = Internationalization
TEMPLATE = app

SOURCES += main.cpp \
           mainwindow.cpp

HEADERS  += mainwindow.h

FORMS    += mainwindow.ui

TRANSLATIONS = Internationalization_de.ts

unix: {
 target.path  = /opt/internationalization_demo
 qmfile.path  = $$target.path
 qmfile.files = Internationalization_de.qm

 INSTALLS += target \
 qmfile
}

qmake 中有一个称为 安装集 的概念。每个安装集有三个成员:pathfilesextrapath 成员定义了目标位置,而 files 告诉 qmake 应该复制哪些文件。你可以在 extra 中指定一些在执行其他指令之前需要执行的命令。

TARGET 是一个有点特殊的概念。首先,它是目标可执行文件(或库),另一方面,它也暗示了 target.files。因此,我们只需要指定 target 的路径。我们同样使用相同的路径为 qmfile,它包括 QM 文件。不要忘记使用双美元符号 $$ 来使用变量。最后,我们设置了 INSTALLS 变量,它定义了在调用 make install 时要安装的内容。unix 括号用于限制仅在 Unix 平台上由 qmake 读取的行。

现在,我们可以通过执行以下步骤进入 DEB 打包的部分:

  1. 将你的工作目录(当前目录)更改为项目的根目录,即 ~/Internationalization

  2. 创建一个名为 debian 的新文件夹。

  3. debian 文件夹中创建四个必需的文件:controlcopyrightchangelogrules,分别。然后,在 debian 文件夹中还可以创建一个可选的 compat 文件。

control 文件定义了最基本同时也是最关键的内容。这个文件全部关于源软件包和二进制软件包。我们示例中的 control 文件如下所示:

Source: internationalization
Section: misc
Priority: extra
Maintainer: Symeon Huang <hzwhuang@gmail.com>
Build-Depends: debhelper (>=9),
               qt5-qmake,
               qtbase5-dev,
               qtbase5-private-dev
Standards-Version: 3.9.6

Package: internationalization
Architecture: any
Depends: ${shlibs:Depends}, ${misc:Depends}
Description: An example of Qt5 Blueprints

第一段是用于控制源信息,而接下来的每一部分描述的是源树构建的各个二进制软件包。换句话说,一个源软件包可以构建多个二进制软件包。在这种情况下,我们只构建一个二进制软件包,其名称与 Sourceinternationalization 相同。

Source段落中,SourceMaintainer是必填项,而SectionPriorityStandards-Version是推荐填写的。Source用于标识源包名,其中不能包含大写字母。同时,Maintainer包含维护者包的名称和按照 RFC822 格式的电子邮件地址。Section字段指定了包被分类的应用领域。Priority是一个解释性的字段,表示这个包的重要性。最后,Standards-Version描述了与包兼容的最新标准版本。在大多数情况下,你应该使用最新的标准版本,目前是 3.9.6。还有一些可能有用但不是必须的字段。更多详情,请参考www.debian.org/doc/debian-policy/ch-controlfields.html

你可以在Build-Depends中指定构建所需的某些包,类似于我们示例中的qt5-qmakeqtbase5-dev。它们仅用于构建过程,不会包含在二进制包的依赖项中。

二进制段落与源代码类似,只是没有Maintainer,但现在ArchitectureDescription是必填项。对于二进制包,Architecture可以是任何特定的架构,也可以简单地是anyall。指定any表示源包不依赖于任何特定的架构,因此可以在任何架构上构建。相比之下,all表示源包将只产生架构无关的包,例如文档和脚本。

在二进制段落的Depends中,我们使用${shlibs:Depends}, ${misc:Depends}代替特定的包。${shlibs:Depends}行可以用来让dpkg-shlibdeps自动生成共享库依赖。另一方面,根据debhepler的建议,你被鼓励在字段中放置${misc:Depends}以补充${shlibs:Depends}。这样,我们就不需要手动指定依赖项,这对打包者来说是一种缓解。

第二个必需的文件是copyright,用于描述源代码以及 DEB 包的许可证。在copyright文件中,格式字段是必需的,而其他字段是可选的。有关版权格式的更多详情,请参考www.debian.org/doc/packaging-manuals/copyright-format/1.0/。本例中的copyright文件如下所示:

Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Contact: Symeon Huang <hzwhuang@gmail.com>

File: *
Copyright: 2014, 2015 Symeon Huang
License: Packt

License: Packt
  This package is released under Packt license.

第一段被称为标题段落,它只需要填写一次。在这个段落中,Format行是唯一必填的字段,在大多数情况下,这一行是相同的。Upstream-Contact字段的语法与control文件中的Maintainer相同。

文件中的第二段是文件段落,这是强制性的且可重复的。在这些段落中,FileCopyrightLicense是必需的。我们使用一个星号(*)表示这个段落适用于所有文件。Copyright字段可能包含从文件中复制来的原始声明或简短文本。Files段落中的License字段描述了由File定义的文件的许可条款。

在文件段落之后,独立许可段落是可选的且可重复的。如果 Debian 没有提供许可,我们必须提供完整的许可文本。一般来说,只有常见的开源许可才提供。第一行必须是一个单独的许可简称,然后是许可文本。对于许可文本,每行的开头必须有两个空格缩进。

不要被changelog文件名误导。这个文件也有一个特殊的格式,并且被dpkg用来获取你的软件包的版本号、修订、发行版和紧急程度。记录在这个文件中你所做的所有更改是一个好的做法。然而,如果你有一个版本控制系统,你只需列出最重要的更改即可。我们示例中的changelog文件包含以下内容:

internationalization (1.0.0-1) unstable; urgency=low

  * Initial release

 -- Symeon Huang <hzwhuang@gmail.com>  Mon, 29 Dec 2014 18:45:31 +0000

第一行是软件包名称、版本、发行版和紧急程度。名称必须与源软件包名称匹配。在这个例子中,internationalization是名称,1.0.0-1是版本,unstable代表发行版,urgencylow。然后,使用一个空行来分隔第一行和日志条目。在日志条目中,你应该列出所有想要记录的更改。对于每个条目,标题部分有两个空格和一个星号(*)。段落结尾的部分是一个维护者行,以空格开头。有关此文件及其格式的更多详细信息,请参阅www.debian.org/doc/debian-policy/ch-source.html#s-dpkgchangelog

现在,我们需要看看dpkg-buildpackage将如何创建软件包。这个过程由rules文件控制;示例内容如下:

#!/usr/bin/make -f

export QT_SELECT := qt5

%:
  dh $@

override_dh_auto_configure:
  qmake

这个文件,类似于Makefile,由几个规则组成。同时,每个规则都以目标声明开始,而配方则是以下以 TAB 代码(不是四个空格)开始的行。我们明确地将 Qt 5 设置为 Qt 版本,这可以避免 Qt 5 与 Qt 4 共存时的一些问题。百分号(%)是一个特殊的目标,表示任何目标,它只是调用带有目标名称的dh程序,而dh只是一个包装脚本,根据其参数运行适当的程序,真正的目标是。

剩余的行是对dh命令的定制。例如,dh_auto_configure默认会调用./configure。在我们的例子中,我们使用qmake来生成Makefile而不是配置脚本。因此,我们通过添加带有qmake作为脚本的override_dh_auto_configure目标来覆盖dh_auto_configure

虽然compat文件是可选的,但如果你不指定它,你会收到大量的警告。目前,你应该将其内容设置为9,可以通过以下单行命令完成:

echo 9 > debian/compat

现在我们可以生成二进制 DEB 包了。-uc参数表示不检查,而-us表示不签名。如果你有一个 PKG 密钥,你可能需要签名包,以便用户可以信任你发布的包。我们不需要源包,所以最后一个参数-b表示只构建二进制包。

dpkg-buildpackage -uc -us -b

可以在debian/文件中的internationalization.substvars中查看自动检测到的依赖项。此文件的内容如下:

shlibs:Depends=libc6 (>= 2.13-28), libc6 (>= 2.4), libgcc1 (>= 1:4.4.0), libqt5core5a (>= 5.0.2), libqt5gui5 (>= 5.0.2), libqt5widgets5 (>= 5.0.2), libstdc++6 (>= 4.3.0)
misc:Depends=

如我们之前讨论的,依赖项是由shlibsmisc生成的。最大的优点是这些生成的版本号往往是最小的,这意味着最大的向后兼容性。正如你所看到的,我们的Internationalization示例可以在 Qt 5.0.2 上运行。

如果一切顺利,你会在上级目录中期望看到一个 DEB 文件。然而,你只能构建当前架构的二进制包amd64。如果你想为i386原生构建,你需要安装 32 位 x86 Debian。对于交叉编译,请参阅wiki.debian.org/CrossBuildPackagingGuidelineswiki.ubuntu.com/CrossBuilding

使用以下单行命令可以轻松安装本地 DEB 文件:

sudo dpkg -i internationalization_1.0.0-1_amd64.deb

安装后,我们可以通过运行/opt/internationalization_demo/Internationalization来运行我们的应用程序。它应该按预期运行,并且行为与 Windows 上完全相同,如下面的截图所示:

在 Linux 上打包 Qt 应用程序

在 Android 上部署 Qt 应用程序

internationalization应用程序需要一个 QM 文件才能正确加载。在 Windows 和 Linux 上,我们选择将它们与目标可执行文件一起安装。然而,这并不总是一个好的方法,尤其是在 Android 上。路径比桌面操作系统更复杂。此外,我们正在构建 Qt 应用程序而不是 Java 应用程序。本地化与纯 Java 应用程序肯定不同,正如 Android 文档中所述。因此,我们将所有资源打包到qrc文件中,该文件将被构建到二进制目标中:

  1. 通过在项目上右键单击并选择添加新文件…来向项目中添加新文件。

  2. 新建文件对话框中导航到Qt | Qt 资源文件

  3. 将其命名为 res 并点击确定;Qt Creator 将带您编辑 res.qrc

  4. 导航到添加 | 添加前缀,并将前缀更改为/

  5. 导航到添加 | 添加文件,并在对话框中选择 .Internationalization_de.qm 文件。

现在,我们需要编辑 mainwindow.cpp 以使其从 Resources 加载翻译文件。我们只需要更改 MainWindow 构造函数中加载翻译的部分,如下所示:

MainWindow::MainWindow(QWidget *parent) :
  QMainWindow(parent),
  ui(new Ui::MainWindow)
{
  ui->setupUi(this);

  deTranslator = new QTranslator(this);
 deTranslator->load(QLocale::German, "Internationalization", "_", ":/");
  deLoaded = false;

  connect(ui->openButton, &QPushButton::clicked, this, &MainWindow::onOpenButtonClicked);
  connect(ui->loadButton, &QPushButton::clicked, this, &MainWindow::onLoadButtonClicked);
}

上述代码是为了指定 QTranslator::load 函数的目录。正如我们在上一章中提到的,:/ 表示这是一个 qrc 路径。除非它是一个 QUrl 对象,否则不要添加 qrc 前缀。

现在我们可以从项目文件中移除 qmfile 安装集,因为我们已经打包了 QM 文件。换句话说,在此更改之后,您不再需要在 Windows 或 Linux 上发送 QM 文件。编辑项目文件,Internationalization.pro,如下面的代码所示:

QT       += core gui

greaterThan(QT_MAJOR_VERSION, 4): QT += widgets

TARGET = Internationalization
TEMPLATE = app

SOURCES += main.cpp \
           mainwindow.cpp

HEADERS  += mainwindow.h

FORMS    += mainwindow.ui

TRANSLATIONS = Internationalization_de.ts

unix: {
    target.path  = /opt/internationalization_demo

    INSTALLS += target
}

RESOURCES += \
    res.qrc

现在,切换到项目模式并添加Android工具包。别忘了将构建切换到发布。在项目模式下,您可以修改 Qt Creator 应如何构建 Android APK 包。在构建步骤中有一个名为构建 Android APK的条目,如下面的截图所示:

在 Android 上部署 Qt 应用程序

在这里,您可以指定 Android API 级别和您的证书。默认情况下,Qt 部署设置为将 Qt 库打包到 APK 中,这会创建一个可重新分发的 APK 文件。让我们点击创建模板按钮来生成一个清单文件,AndroidManifest.xml。通常,您只需在弹出对话框中点击完成按钮,然后 Qt Creator 将带您回到编辑模式,在编辑区域打开 AndroidManifest.xml,如图所示:

在 Android 上部署 Qt 应用程序

让我们通过以下步骤对清单文件进行一些修改:

  1. 包名更改为com.demo.internationalization

  2. 最小所需 SDK更改为API 14: Android 4.0, 4.0.1, 4.0.2

  3. 目标 SDK更改为API 19: Android 4.4

  4. 保存更改。

不同的 API 级别会影响兼容性和 UI;您必须仔细决定级别。在这种情况下,我们需要至少 Android 4.0 来运行此应用程序,我们将针对 Android 4.4 进行此操作。一般来说,API 级别越高,整体性能越好。Internationalization.pro 项目文件也会自动更改。

QT       += core gui

greaterThan(QT_MAJOR_VERSION, 4): QT += widgets

TARGET = Internationalization
TEMPLATE = app

SOURCES += main.cpp \
           mainwindow.cpp

HEADERS  += mainwindow.h

FORMS    += mainwindow.ui

TRANSLATIONS = Internationalization_de.ts

unix: {
    target.path  = /opt/internationalization_demo

    INSTALLS += target
}

RESOURCES += \
res.qrc

DISTFILES += \
 android/gradle/wrapper/gradle-wrapper.jar \
 android/AndroidManifest.xml \
 android/res/values/libs.xml \
 android/build.gradle \
 android/gradle/wrapper/gradle-wrapper.properties \
 android/gradlew \
 android/gradlew.bat

ANDROID_PACKAGE_SOURCE_DIR = $$PWD/android

现在,构建一个发布版。APK 文件位于项目构建目录中的android-build/bin目录内。APK 文件名为QtApp-release.apk,如果您没有设置您的证书,则为QtApp-debug.apk。如果您打算将您的应用程序提交到 Google Play 或任何其他 Android 市场,您必须设置您的证书并上传QtApp-release.apk而不是QtApp-debug.apk。同时,QtApp-debug.apk可用于您自己的设备上测试应用程序的功能。

下面是 HTC One 上运行国际化功能的截图:

在 Android 上部署 Qt 应用程序

如您所见,德语翻译已按预期加载,而弹出对话框具有本地化的外观和感觉。

摘要

在本章中,我们比较了静态链接和动态链接的优缺点。稍后,我们通过一个示例应用程序,向您展示了如何在 Windows 上创建安装程序,以及如何在 Debian Linux 上将其打包为 DEB 包。最后但同样重要的是,我们还学习了如何为 Android 创建可重新分发的 APK 文件。口号“代码更少,创造更多,部署到任何地方”现在得到了实现。

在下一章,也是本书的最后一章,除了如何调试应用程序外,我们还将探讨一些常见问题和解决方案。

第十章. 遇到这些问题时不要慌张

在应用程序开发过程中,你可能会遇到一些问题。Qt 总是令人惊叹,因为 Qt Creator 有一个出色的 调试 模式,这可以在调试时节省你的时间。你将学习如何调试 Qt/C++ 或 Qt Quick/QML 应用程序。本章将涵盖以下主题:

  • 常见问题

  • 调试 Qt 应用程序

  • 调试 Qt Quick 应用程序

  • 有用资源

常见问题

错误,或者更恰当地说,意外结果,在应用程序开发过程中肯定是不可避免的。此外,还可能有编译器错误,甚至应用程序崩溃。当你遇到这类问题时,请不要慌张。为了减轻你的痛苦并帮助你定位问题,我们已经收集了一些常见的、可复现的意外结果,并将它们分类,如下节所示。

C++ 语法错误

对于编程初学者,或者不熟悉 C 和 C++ 的开发者来说,C++ 的语法并不容易记住。如果出现任何语法错误,编译器将会因为错误信息而终止。实际上,编辑器会在有问题的语句下方显示波浪线,如下所示:

C++ 语法错误

在所有 C++ 语法错误中,最常见的一个是缺少分号 (😉。C++ 需要分号来标记语句的结束。因此,第 7 行和第 8 行等同于以下行:

    MainWindow w w.show();

在 C++ 中,这显然是写错了。不仅编辑器会突出显示错误,编译器也会给你一个详尽的错误信息。在这种情况下,它将显示以下信息:

C:\Users\Symeon\OneDrive\Book_Dev\4615OS\4615OS_07\project\Weather_Demo\main.cpp:8: 错误: C2146: 语法错误 : 在标识符 'w' 前缺少 ';'

如你所见,编译器不会告诉你应该在第 7 行末尾添加分号。相反,它在第 8 行的 w 标识符之前读取 missing;。无论如何,在大多数情况下,C++ 语法错误可以被编译器检测到,而其中大多数首先会被编辑器检测到。多亏了 Qt Creator 的突出显示功能,这些类型的错误应该能够有效地避免。

建议养成一个好习惯,在按下 Enter 键之前添加分号。这是因为在某些情况下,语法可能对编译器和 Qt Creator 来说看起来是正确的,但它肯定是不正确的编码,并可能导致意外的行为。

指针和内存

任何熟悉 C 及其野指针的人都知道,在内存管理方面出错是多么容易。正如我们之前提到的,Qt 有一个优越的内存管理机制,一旦父对象被删除,它将释放其子对象。不幸的是,如果开发者明确使用 delete 来释放子对象,这可能会导致崩溃。

这背后的主要原因是 delete 不是一个线程安全的操作。它可能会导致双重删除,从而引发段错误。因此,为了以线程安全的方式释放内存,我们使用在 QObject 类中定义的 deleteLater() 函数,这意味着这个方法对所有继承自 QObject 的类都是可用的。正如文档中所述,deleteLater() 将安排对象进行删除,但删除不会立即发生。

注意

调用 deleteLater() 多次是完全安全的。一旦第一次延迟删除完成,任何挂起的删除都将从事件队列中移除。不会发生双重删除。

Qt 中还有一个处理内存管理的类,QObjectCleanupHandler。这个类监视多个 QObjects 的生命周期。你可以将其视为一个简单的 Qt 垃圾收集器。例如,有很多 QTcpSocket 对象需要被监视和正确删除。这类情况并不少见,尤其是在网络程序中。一个简单的技巧是将所有这些对象添加到 QObjectCleanupHandler 中。以下代码片段是一个简单的演示,它将 QObject 添加到 QObjectCleanupHandler ch

QTcpSocket *t = new QTcpSocket(this);
QObjectCleanupHandler ch;
ch.add(t);

t 对象添加到 ch 中不会改变 t 的父对象从 this&chQObjectCleanupHandler 在这方面更像是 QList。如果 t 在其他地方被删除,它将自动从 ch 的列表中移除。如果没有对象剩下,isEmpty() 函数将返回 true。当 QObjectCleanupHandler 被销毁时,其中所有的对象都将被删除。你也可以显式调用 clear() 来手动删除 QObjectCleanupHandler 中的所有对象。

不兼容的共享库

这种类型的错误被称为 DLL 地狱,我们在上一章中讨论过。它是由不兼容的共享库引起的,可能会导致奇怪的行为或崩溃。

在大多数情况下,Qt 库是向后兼容的,这意味着你可以用新的 DLL 替换所有的 DLL,而不需要重新编译可执行文件。某些特定的模块或 API 可能已经被弃用,并将在 Qt 的后续版本中删除。例如,QGLWidget 类在 Qt 5.4 中被新引入的 QOpenGLWidget 类所取代。尽管如此,QGLWidget 目前仍然提供。

在相反的方向上,事情变得相当糟糕。如果你的应用程序调用了一个自 Qt 5.4 以来引入的 API,那么应用程序在较老的 Qt 版本(如 Qt 5.2)中肯定会出现故障。

以下是一个简单的程序,它使用了在 Qt 5.4 中引入的 QSysInfo。这个简单 incompat_demo 项目的 main.cpp 文件如下所示:

#include <QDebug>
#include <QSysInfo>
#include <QCoreApplication>

int main(int argc, char *argv[])
{
    QCoreApplication a(argc, argv);

    qDebug() << "CPU:" << QSysInfo::currentCpuArchitecture();

    return a.exec();
}

QSysInfo::currentCpuArchitecture() 返回应用程序正在运行的 CPU 架构,作为一个 QString 对象。如果 Qt 的版本足够高(大于或等于 5.4),它将按预期运行,如下面的截图所示:

不兼容的共享库

如你所见,它说我们在一个 64 位 x86 CPU 机器上运行此应用程序。然而,如果我们使用 Qt 5.2 的 DLL 编译的可执行文件,它将显示错误,如这里所示,并崩溃:

不兼容的共享库

这种情况当然很少见。然而,如果发生这种情况,你会对出了什么问题有一个大概的了解。从错误对话框中,我们可以看到错误是因为动态链接库中缺少 QSysInfo::currentCpuArchitecture 行。

另一个 DLL 地狱更为复杂,可能被初学者忽略。所有库都必须由相同的编译器构建。你不能使用 MSVC 库与 GCC 一起,这对其他编译器也适用,例如 ICC 和 Clang。不同的编译器版本也可能导致不兼容。你可能不希望在 GCC 版本为 4.9 的开发环境中使用由 GCC 4.3 编译的库。然而,由 GCC 4.9.1 编译的库应该与由 GCC 4.9.2 编译的库兼容。

除了编译器之外,不同的架构通常是不兼容的。例如,64 位库在 32 位平台上无法工作。同样,x86 库和可执行文件不能用于非 x86 设备,如 ARM 和 MIPS。

在 Android 上无法运行!

Qt 最近才移植到 Android。因此,它在桌面 PC 上运行良好,但在 Android 上可能不行。一方面,Android 硬件各不相同,更不用说成千上万的定制 ROM 了。因此,一些 Android 设备可能遇到兼容性问题是有道理的。另一方面,在 Android 上运行的 Qt 应用程序是一个带有 Java 包装器的本地 C++ 应用程序,而二进制可执行文件自然更容易遇到兼容性问题,比脚本更敏感。

总之,这里有一个步骤:

  1. 尝试在另一部 Android 手机或虚拟 Android 设备上运行你的应用程序。

  2. 如果仍然不起作用,它可能是 Qt 在 Android 上的潜在错误。我们将在本章末尾讨论如何向 Qt 报告错误。

调试 Qt 应用程序

要调试任何 Qt 应用程序,你需要确保你已经安装了 Qt 库的调试符号。在 Windows 上,它们与发布版本的 DLL 一起安装。同时,在 Linux 上,你可能需要通过发行版的包管理器安装调试符号。

一些开发者倾向于使用类似于 printf 的函数来调试应用程序。Qt 提供了四个全局函数,如下表所示,用于打印调试、警告和错误文本:

函数 用途
qDebug() 此函数用于写入自定义调试输出。
qWarning() 此函数用于报告警告和可恢复的错误。
qCritical() 此函数用于写入关键错误消息和报告系统错误。
qFatal() 此函数用于在退出前打印致命错误消息。

通常,您可以使用类似于printf的 C 风格方法。

qDebug("Hello %s", "World!");

然而,在大多数情况下,我们会包含<QtDebug>头文件,这样我们就可以使用流操作符(<<)作为更方便的方式。

qDebug() << "Hello World!"

这些函数最强大的地方在于它们可以输出某些复杂类、QListQMap的内容。需要注意的是,这些复杂的数据类型只能通过流操作符(<<)打印。

qDebug()qWarning()都是调试工具,这意味着它们可以通过定义QT_NO_DEBUG_OUTPUTQT_NO_WARNING_OUTPUT分别在编译时禁用。

除了这些功能外,Qt 还提供了QObject::dumpObjectTree()QObject::dumpObjectInfo()函数,这些函数通常很有用,尤其是在应用程序看起来异常时。QObject::dumpObjectTree()会输出信号连接的信息,如果您认为信号槽连接可能存在问题,这会非常有用。同时,后者会将子节点以树状结构输出到调试输出。别忘了以调试模式构建应用程序,否则它们都不会打印任何内容。

除了这些有用的调试功能外,Qt Creator 还提供了一种直观的方式来调试您的应用程序。如果您使用的是 MSVC 编译器,请确保已安装 Microsoft 控制台调试器CDB)。在其他情况下,GDB 调试器包含在 MinGW 版本中。

注意

CDB 现在是Windows 驱动程序工具包WDK)的一部分;请访问msdn.microsoft.com/en-us/windows/hardware/hh852365下载它。别忘了在安装过程中检查调试工具包。

以第二章中的Fancy_Clock为例,构建一个漂亮的跨平台时钟。在MainWindow::setColour()函数中,将光标移至第 97 行,即switch (i) {。然后,导航到调试 | 切换断点,或者直接按键盘上的F9。这将在第 97 行添加一个断点,并添加一个断点标记(行号前的红色暂停图标),如图所示:

调试 Qt 应用程序

现在点击面板上的开始调试按钮,它上面有一个错误,或者导航到菜单栏上的调试 | 开始调试 | 开始调试,或者按键盘上的F5。这将根据需要重新编译应用程序,并以调试模式启动。同时,Qt Creator 将自动切换到调试模式。

调试 Qt 应用程序

应用程序中断是因为我们设置的断点。您可以看到一个黄色箭头指示应用程序当前所在的行,如前一张截图所示。默认情况下,在右侧面板中,您可以查看局部变量和表达式,其中显示了所有局部变量及其值和类型。要更改默认设置,请转到窗口 | 视图,然后选择要显示或隐藏的内容。

在此截图中的调试模式面板以蓝色文字标记:

调试 Qt 应用程序

简而言之,您可以在局部变量中监控变量,在表达式中监控表达式。堆栈显示当前堆栈,所有断点都可以在断点面板中管理。

在底部面板中,有一系列按钮用于控制调试过程。前六个按钮分别是继续停止调试器单步执行进入退出重启调试会话单步执行是将一行代码作为一个整体执行。进入将进入一个函数或子函数,而退出可以离开当前函数或子函数。

断点在调试中起着至关重要的作用,因为它可以告诉您断点代表的是代码中的位置或一组位置,这些位置会中断应用程序的调试并赋予您控制权。一旦中断,您可以检查程序的状态或继续执行,无论是逐行还是连续执行。Qt Creator 在断点视图中显示断点,默认情况下位于右下角。您可以在断点视图中添加或删除断点。要添加断点,右键单击断点视图并选择添加断点…;将出现一个添加断点对话框,如下所示:

调试 Qt 应用程序

断点类型字段中,选择程序代码中您希望应用程序中断的位置。其他选项取决于所选类型。

要移动断点,只需拖动断点标记并将其放置在目标位置。尽管这不是一个经常需要的功能。

删除断点有许多方法。

  • 通过在编辑器中单击断点标记,将光标移动到相应的行,然后转到调试 | 切换断点,或按F9

  • 通过在断点视图中右键单击断点并选择删除断点

  • 通过在断点视图中选择断点并按下键盘上的删除按钮

最强大的地方是之前介绍的局部变量和表达式视图。每次程序在调试器的控制下停止时,它都会检索信息并在局部变量和表达式视图中显示。局部窗格显示函数参数和局部变量。Qt 基本对象的数据有全面的显示。在这种情况下,当程序在MainWindow::setColour()中断时,有一个其"MainWindow"的指针。除了这个指针的内存地址之外,它还可以显示属于此对象的所有数据和子项:

调试 Qt 应用程序

如前一个截图所示,这是一个MainWindow实例,它继承自QMainWindow。它有三个子项:_layoutqt_rubberbandcentralWidget。请注意,在[方法]中只显示槽函数。现在你将理解为什么局部窗格是调试模式中最重要且最常用的视图。

另一方面,表达式窗格功能更强大,可以计算算术表达式或函数调用的值。在局部变量和表达式视图中右键单击,并在上下文菜单中选择添加新表达式计算器…

注意,上下文菜单条目仅在程序中断时可用。在这种情况下,Fancy_ClockMainWindow::setColour()函数中被中断,其中局部变量i可以用来执行一些算术运算。例如,我们在新评估表达式弹出对话框中填写i * 5

调试 Qt 应用程序

除了算术运算之外,你可以调用一个函数来评估返回值。然而,这个函数必须对调试器是可访问的,这意味着它要么被编译到可执行文件中,要么可以从库中调用。

表达式的值将在每次步骤后重新评估。点击确定按钮后,表达式i * 5将如所示在表达式窗格中显示:

调试 Qt 应用程序

现在i的值是3。因此,表达式i * 5被评估为15

"表达式计算器功能强大,但会显著减慢调试器的操作速度。建议不要过度使用,并尽快移除不必要的表达式计算器。"

即使表达式中的函数有副作用,它们也将在当前帧更改时被调用。毕竟,表达式计算器功能强大,但会影响调试速度。

调试 Qt Quick 应用程序

我们将使用来自第七章,解析 JSON 和 XML 文档以使用在线 APIWeather_QML项目作为演示程序,展示如何调试 Qt Quick 应用程序。

首先,我们需要确保已启用 QML 调试。在 Qt Creator 中打开Weather_QML项目。然后,执行以下步骤:

  1. 切换到项目模式。

  2. 构建步骤中扩展qmake步骤。

  3. 如果未勾选,请勾选启用 QML 调试

    小贴士

    调试 QML 将在一个知名端口上打开一个套接字,这会带来安全风险。您的网络上的任何人都可以连接到调试应用程序并执行任何 JavaScript 函数。因此,您必须确保有适当的防火墙规则。

启动 QML 调试使用相同的步骤,即导航到调试 | 开始调试 | 开始调试,或点击调试按钮,或直接按F5。它可能会触发Windows 安全警报,如下面的截图所示。别忘了点击允许访问按钮。

调试 Qt Quick 应用程序

一旦应用程序开始运行,它将像往常一样运行和表现。然而,在调试模式下,您可以执行一些有用的任务。您可以在局部变量面板中看到所有元素及其属性,就像我们对 Qt/C++应用程序所做的那样。

除了观察这些变量之外,您还可以临时更改它们,并在运行时立即看到更改。要更改值,您可以直接在局部变量面板中更改它,或者在QML/JS 控制台中更改它。

例如,要更改ApplicationWindowtitle属性,请执行以下步骤:

  1. 局部变量面板中展开ApplicationWindow | 属性

  2. 双击title条目。

  3. 将值从Weather QML更改为Yahoo! Weather

  4. 按键盘上的EnterReturn键确认。

或者,您也可以在QML/JS 控制台中更改它。无需展开ApplicationWindow;只需在局部变量面板中点击ApplicationWindow。您会注意到QML/JS 控制台面板上的上下文将变为ApplicationWindow,如下面的截图所示。然后,只需输入title="Yahoo! Weather"命令来更改标题。

调试 Qt Quick 应用程序

您会注意到应用程序窗口中的标题立即更改为Yahoo! Weather,如下所示:

调试 Qt Quick 应用程序

同时,源代码保持不变。当您想测试属性的更好值时,这个功能特别方便。您不必在代码中更改并重新运行,可以直接更改并测试。实际上,您还可以在QML/JS 控制台中执行 JavaScript 表达式,而不仅仅是更改它们的值。

有用资源

仍然遇到问题?除了在线搜索引擎外,还有两个在线论坛可能对您也很有用。第一个是 Qt 项目论坛,其网址为 qt-project.org/forums。另一个是由社区网站 Qt Centre 维护的,其网址为 www.qtcentre.org/forum.php

在大多数情况下,您应该能够在这些网站上找到类似或甚至完全相同的问题。如果没有,您可以发起新的主题寻求帮助。尽可能详细地描述问题,以便其他用户可以了解出了什么问题。

有可能您已经正确地做了所有事情,但仍然可能得到意外的结果、编译错误或崩溃。在这种情况下,这可能是 Qt 错误。如果您认为您遇到了 Qt 错误,您被鼓励报告它。由于 Qt 有错误跟踪器,因此报告错误很容易,其网址为 bugreports.qt.io

小贴士

错误报告的质量将直接影响错误被修复的速度。

要生成高质量的错误报告,以下是一个简单的分步指南:

  1. 访问 Qt 错误跟踪器网站。

  2. 登录。如果是第一次,您需要创建一个新账户。请记住提供有效的电子邮件地址,因为这是 Qt 开发人员与您联系的唯一方式。

  3. 使用右上角的搜索字段查找任何类似或甚至完全相同的错误。

  4. 如果您发现了错误,您可以在评论中留下任何额外的信息。此外,您还可以点击投票为该错误投票。最后,如果您想跟踪进度,可以添加自己作为观察者。

  5. 如果没有,请点击创建新问题并填写字段。

您应该在摘要中输入简短的描述性文本。这不仅增加了修复错误的机会,而且对其他正在搜索现有错误的人也有帮助。对于其他字段,您总是被鼓励提供尽可能多的信息。

摘要

在阅读完本章后,您可以自己解决大多数基于 Qt 的问题。我们首先介绍了一些常见问题,然后是如何调试 Qt 和 Qt Quick 应用程序。最后,提供了一些有用的链接,以帮助您解决各种问题和错误。如果您遇到特定 Qt 错误的问题,不要慌张,只需去错误跟踪器报告即可。

posted @ 2025-10-24 09:53  绝不原创的飞龙  阅读(3)  评论(0)    收藏  举报