QT 开发基础知识(全)
一、C++ 的 Qt 方式
Qt 是一个跨平台、图形化的应用开发工具包,使您能够在 Windows、Mac OS X、Linux 和不同品牌的 Unix 上编译和运行您的应用。Qt 的很大一部分致力于为一切事物提供平台中立的接口,从在内存中表示字符到创建多线程图形应用程序。
注意尽管 Qt 最初是为了帮助 C++ 程序员而开发的,但是绑定也适用于许多语言。Trolltech 提供了 C++、Java 和 JavaScript 的官方绑定。第三方提供了许多语言的绑定,包括 Python、Ruby、PHP 和。NET 平台。
本章从一个普通的 C++ 类开始,将它与 Qt 集成在一起,使它更易于重用和使用。在这个过程中,您将看到用于编译和链接 Qt 应用程序的构建系统,以及在您的平台上安装和设置 Qt。
本章然后讨论 Qt 如何使你能够构建能够以非常灵活的方式互连的组件。这就是 Qt 如此强大的原因——它使得构建可重用、可交换和可互连的组件变得容易。最后,您将了解 Qt 提供的集合和助手类。
安装 Qt 开发环境
在开始开发 Qt 应用程序之前,您需要下载并安装 Qt。您将使用 Qt 的开源版本,因为它对所有人都是免费的。如果您有 Qt 的商业许可证,您会收到它的安装说明。
根据您计划用于开发的平台,安装过程会略有不同。因为 Mac OS X 和 Linux 都基于 Unix,所以两者的安装过程是相同的(以及所有的 Unix 平台)。另一方面,Windows 则不同,它是单独介绍的。从[www.trolltech.com/products/qt/downloads](http://www.trolltech.com/products/qt/downloads)
下载适合自己平台的版本就可以启动所有三个平台。
在 Unix 平台上安装
除了 Windows 之外的所有平台都可以说是 Unix 平台。然而,Mac OS X 不同于其他系统,因为它不使用 X 窗口系统,通常称为 X11,来处理图形。所以 Mac OS X 需要一个不同的 Qt 版本;必要的文件(qt-mac-opensource-src-
version
.tar.gz
)可以从奇趣下载。基于 X11 的 Unix 平台使用来自 Trolltech 的qt-x11-opensource-src-
version
.tar.gz
文件。
注意 Qt 依赖于其他组件,如编译器、连接器和开发库。根据 Qt 的配置方式,需求会有所不同,因此如果遇到问题,您应该研究参考文档。
下载完文件后,过程是这样的:解包、配置和编译。让我们一个接一个地完成这些步骤。最简单的方法是在命令提示符下工作。
要解压缩该文件,请下载它,将其放在一个目录中,然后在您的命令 shell 中进入该目录。然后输入如下内容(用 x11 或 mac 代替版本,并使用你已经下载的版本):
tar xvfz qt-edition-opensource-src-version.tar.gz
这段代码将文件解压到一个名为`qt-`edition` -opensource-src- `version`` 的文件夹中。使用`cd`命令进入该目录:
cd qt-`edition`-opensource-src-`version`
在构建 Qt 之前,您需要使用`configure`脚本及其选项来配置它。像这样运行脚本:
./configure `options`
有许多选项可供选择。最好的起点是使用`-help`,它会向您显示可用选项的列表。大多数选项通常可以保留为默认选项,但是`-prefix`选项很好用。您可以通过在选项后指定一个路径来指示安装转到特定位置。例如,要在您的主目录中的一个名为`inst/qt4`的目录中安装 Qt,使用下面的`configure`命令:
./configure -prefix ~/inst/qt4
Mac OS X 平台还有另外两个值得注意的选项。首先,添加`-universal`选项使用 Qt 创建通用二进制文件。如果您计划使用基于 PowerPC 的计算机进行开发,您必须添加`-sdk`选项。
`configure`脚本还让您接受开源许可(除非您有商业许可),然后检查所有的依赖项是否都已就绪,并开始在源代码树中创建配置文件。脚本完成后,您可以使用以下命令构建 Qt:
make
这个过程需要相对较长的时间来完成,但是完成之后,您可以使用下一行来安装 Qt:
make install
**注意**如果你试图在你的主目录之外安装 Qt,安装命令可能需要 root 权限。
安装 Qt 后,您需要将 Qt 添加到您的`PATH`环境变量中。如果你使用的编译器不支持`rpath`,你也必须更新`LD_LIBRARY_PATH`环境变量。
如果您在运行`configure`时使用了`$HOME/inst/qt4`前缀,您需要添加`$HOME/inst/qt4/bin`到`PATH`的路径。如果您使用的是 bash shell,请使用赋值来更改变量:
export PATH=$HOME/inst/qt4/bin:$PATH
如果您希望在每次启动命令 shell 时都运行这个命令,那么您可以将它添加到您的`.profile`文件中,就在显示为`export PATH`的一行之前。这会将新的`PATH`环境变量导出到命令行会话。
**注意**设置环境变量的方法因 shell 而异。如果您没有使用 bash,请参考参考文档,了解如何为您的系统设置`PATH`变量。
如果您一次安装了几个 Qt 版本,请确保您打算使用的版本首先出现在`PATH`环境变量中,因为使用的`qmake`二进制文件知道 Qt 安装在哪里。
如果您必须更改`LD_LIBRARY_PATH`环境变量,请将`$HOME/inst/qt4/lib`目录添加到变量中。在 Mac OS X 和 Linux(使用 Gnu 编译器集合[GCC])上,不需要这一步。
在 Windows 上安装
如果您计划使用 Windows 平台进行 Qt 开发,请从 Trolltech 下载一个名为`qt-win-opensource-` `version` `-mingw.exe`的文件。这个文件是一个安装程序,它将设置 Qt 和 mingw 环境。
**注** *mingw* ,是极简 GNU for Windows 的简称,是 Windows 常用 GNU 工具的发行版。Qt 开源版使用这些工具,包括 GCC 和 make,进行编译和链接。
安装程序就像一个向导,询问你在哪里安装 Qt。确保选择一个没有空格的目录路径,因为这可能会在以后给你带来问题。安装 Qt 后,你会看到一个名为`Qt by Trolltech (OpenSource)`的开始菜单文件夹。该文件夹包含 Qt 工具和文档的条目以及 Qt 命令提示符。从这个命令提示符中访问 Qt 是很重要的,因为它正确地设置了环境变量,比如 ??。简单地运行在开始菜单的`Accessories`文件夹中的命令提示符将会失败,因为变量没有正确配置。
制作 c++“Qt-er”
因为这是一本关于编程的书,你会马上从一些代码开始(见清单 1-1 )。
**清单 1-1。** *一个简单的 C++ 类*
#include <string>
using std::string;
class MyClass
{
public:
MyClass( const string& text );
const string& text() const;
void setText( const string& text );
int getLengthOfText() const;
private:
string m_text;
};
在清单 1-1 中显示的类是一个简单的字符串容器,带有获取当前文本长度的方法。实现比较琐碎,`m_text`简单设置或者返回,或者返回`m_text`的大小。让我们通过使用 Qt 使这个类更加强大。但是首先,看看已经“Qt 化”的部分:
- 类名以大写字母开头,单词使用驼峰式大小写分开。也就是说,每个新单词都以大写字母开头。这是命名 Qt 类的常用方法。
- 这些方法的名称都以小写字母开头,单词也用字母大小写来区分。这是命名 Qt 方法的常用方式。
- 属性文本的 getter 和 setter 方法被命名为
text
(getter)和setText
(setter)。这是命名 getters 和 setters 的常见方式。
都是 Qt 的特质。这看起来没什么大不了的,但是在实际编写代码时,用结构化的方式命名可以节省大量时间。
继承 Qt
您将对代码进行的第一个特定于 Qt 的调整非常简单:您将简单地让您的类继承QObject
类,这将使动态管理类的实例变得更加容易,方法是给实例提供负责删除它们的父类。
注意所有 Qt 类的前缀都是大写的 q。因此,如果你找到了类QDialog
和Dialog
,你可以马上判断出QDialog
是 Qt 类,而Dialog
是你的应用程序或第三方代码的一部分。一些第三方库使用QnnClassName
命名约定,这意味着该类属于一个扩展 Qt 的库。前缀中的nn
告诉你这个类属于哪个库。例如,类QwtDial
属于技术应用程序库的 Qt Widgets,它提供了图形、刻度盘等类。(您可以在附录中找到更多关于这个和其他第三方 Qt 扩展的信息。)
对代码的改动很小。首先,类的定义被稍微修改了一下,如清单 1-2 所示。为了方便起见,parent
参数也被添加到构造器中,因为QObject
有一个函数setParent
,可以用来在创建后将对象实例分配给父对象。然而,通常——也是推荐的——将 parent 作为参数传递给构造器,作为第一个默认参数,以避免必须为从该类创建的每个实例键入setParent
。
清单 1-2。 继承 QObject
并接受一个父
#include <QObject>
#include <string>
using std::string;
class MyClass : public QObject
{
public:
MyClass( const string& text, QObject *parent = 0 );
...
};
注意要访问QObject
类,必须包含头文件<QObject>
。这适用于大多数 Qt 类;简单地包含一个与类同名的头文件,省略掉.h
,一切都应该正常工作。
父参数简单地传递给QObject
构造器,如下所示:
MyClass::MyClass( const string& text, QObject *parent ) : QObject( parent )
让我们看看变化的影响,从清单 1-3 开始。它显示了一个动态使用MyClass
类的main
函数,没有 Qt。
清单 1-3。 没有 Qt 的动态记忆
`#include
int main( int argc, char **argv )
{
MyClass *a, *b, *c;
a = new MyClass( "foo" );
b = new MyClass( "ba-a-ar" );
c = new MyClass( "baz" );
stdcout << a->text() << " (" << a->getLengthOfText() << ")" << stdendl;
a->setText( b->text() );
stdcout << a->text() << " (" << a->getLengthOfText() << ")" << stdendl;
int result = a->getLengthOfText() - c->getLengthOfText();
delete a;
delete b;
delete c;
return result;
}`
每个new
调用后必须跟随一个对delete
的调用,以避免内存泄漏。虽然在退出main
函数时这不是一个大问题(因为大多数现代操作系统在应用程序退出时会释放内存),但是析构函数并没有像预期的那样被调用。在非无循环main
函数的位置,当系统用尽空闲内存时,泄漏最终会导致系统崩溃。与清单 1-4 中的相比,清单 1-4 中的使用了一个父类,当main
函数退出时,这个父类会被自动删除。家长负责为所有孩子调用delete
和——哒哒!—内存被释放。
注意在清单 1-4 所示的代码中,添加了parent
对象来展示这个概念。在现实生活中,它可能是一个执行某种任务的对象——例如,QApplication
对象,或者(在对话框或窗口的情况下)window
类的this
指针。
清单 1-4。 用 Qt 动态记忆
#include <QtDebug>
int main( int argc, char **argv )
{
QObject parent;
MyClass *a, *b, *c;
a = new MyClass( "foo", &parent );
b = new MyClass( "ba-a-ar", &parent );
c = new MyClass( "baz", &parent );
qDebug() << QString::fromStdString(a->text())
<< " (" << a->getLengthOfText() << ")";
a->setText( b->text() );
qDebug() << QString::fromStdString(a->text())
<< " (" << a->getLengthOfText() << ")";
return a->getLengthOfText() - c->getLengthOfText();
}
您甚至省去了将计算结果保存在变量中的额外步骤,因为动态创建的对象可以直接从return
语句中使用。拥有这样的父对象可能看起来很奇怪,但是大多数 Qt 应用程序使用一个QApplication
对象作为父对象。
注 清单 1-4 从使用std::cout
打印调试信息切换到qDebug()
。使用qDebug()
的好处是它可以在所有平台上把信息发送到正确的地方。关闭也很容易:编译时只需定义QT_NO_DEBUG_OUTPUT
符号。如果您有调试消息,在此之后您想要终止应用程序,Qt 提供了qFatal()
功能,它的工作方式就像qDebug()
一样,但是在消息之后终止应用程序。两者之间的折衷是使用qWarning()
,它表示比调试消息更严重的事情,但不是致命的。用于调试消息的 Qt 函数会在每次调用后自动追加一个换行符,因此您不必再包含std::endl
。
在比较清单 1-3 和清单 1-4 中的代码复杂度时,看看不同的内存情况,如图图 1-1 所示。父实例是灰色的,因为它是在堆栈上分配的,因此会被自动删除,而MyClass
的实例是白色的,因为它们在堆上,必须手动处理。因为您使用父级来跟踪子级,所以您信任父级会在删除子级时删除它们。因此,只要根对象在堆栈上(或者如果您跟踪它),您就不必再跟踪动态分配的内存。
图 1-1。 堆栈上有父内存和无父内存的动态内存差异
使用 Qt 字符串
使用 Qt 的另一个步骤是用相应的 Qt 类替换 C++ 标准模板库(STL)中的任何类。虽然这不是必需的(Qt 和 STL 一起工作很好),但是它确实可以避免依赖第二个框架。不使用 STL 的好处是,您可以使用与 Qt 相同的容器、字符串和助手,因此最终的应用程序很可能会更小。当在平台和编译器之间移动时,您还可以避免跟踪兼容性问题和与 STL 标准的奇怪偏差——您甚至可以在没有 STL 实现的平台上进行开发。
查看当前的类,发现string
类是唯一使用的 STL 类。对应的 Qt 类叫做QString
。你可以无缝地混合QString
对象和string
对象,但是只使用QString
意味着性能的提高和更多的特性。例如,QString
支持所有平台上的 Unicode,这使得国际用户使用您的应用程序更加容易。
清单 1-5 展示了用QString
替换所有出现的string
后,你的代码是什么样子。如您所见,对该类的更改非常小。
清单 1-5。 MyClass
用 QString
代替 string
#include <QString>
#include <QObject>
class MyClass : public QObject
{
public:
MyClass( const QString& text, QObject *parent = 0 );
const QString& text() const;
void setText( const QString& text );
int getLengthOfText() const;
private:
QString m_text;
};
提示当混合string
和QString
时,使用QString
方法toStdString
和fromStdString
将 Qt Unicode 格式转换为string
类使用的 ASCII 表示。
建立 Qt 程序
编译和构建这个应用程序应该与构建原始应用程序没有任何不同。您所要做的就是确保编译器能够找到 Qt 头文件,并且链接器能够找到 Qt 库文件。
为了以跨平台的方式顺利处理所有这些,Qt 附带了 QMake 工具,它可以为一系列不同的编译器创建 Makefiles。如果您愿意,它甚至会为您创建项目定义文件。
尝试构建一个简单的应用程序。首先创建一个名为testing
的目录。然后将来自清单 1-6 的代码放到这个目录中。您可以将该文件命名为任何名称,只要它的扩展名是cpp
。
清单 1-6。 一个微不足道的例子
`#include
int main( )
{
qDebug() << "Hello Qt World!";
return 0;
}`
现在打开一个命令行,将您的工作目录更改为您刚刚创建的目录。然后输入qmake -project
并按回车键,这会生成一个名为testing.pro
的文件。我的版本如清单 1-7 所示。
提示如果你在 Windows 中运行 Qt 的开源版本,在安装 Qt 时创建的开始菜单文件夹中有一个类似 Qt 4.2.2 命令提示符的应用程序。运行该应用程序并使用cd
命令更改目录。例如,首先使用资源管理器定位您的文件夹;然后复制整个路径(应该和c:\foo\bar\baz\testing
差不多)。现在在命令提示符下键入cd
,后跟一个空格,然后右键单击,选择粘贴,然后按回车键。这应该能让你很快找到正确的工作目录。
清单 1-7。 一个生成的项目文件
`######################################################################
Automatically generated by qmake (2.00a) to 10. aug 17:06:34 2006
######################################################################
TEMPLATE = app
TARGET +=
DEPENDPATH += .
INCLUDEPATH += .
Input
SOURCES += anything.cpp`
该文件由一组使用=
设置或使用+=
扩展的变量组成。有趣的部分是SOURCES
变量,它告诉你 QMake 已经找到了anything.cpp
文件。下一步是使用 QMake 生成特定于平台的 Makefile。因为工作目录只包含一个项目文件,只需键入qmake
并按回车键。这将为您提供一个 Makefile 和特定于平台的助手文件。
注意在 GNU/Linux 上,结果是一个名为Makefile
的文件。在 Windows 上,如果你使用开源版本和 mingw,你会得到Makefile
、Makefile.Release
、Makefile.Debug
和两个目录:debug
和release
。
最后一步是从生成的 Makefile 构建项目。如何做到这一点取决于您使用的平台和编译器。你通常应该键入make
并按回车键,但是gmake
(在 Berkeley Software Distribution[BSD]系统上很常见)和nmake
(在微软编译器上)也是其他常见的选择。如果你第一次不能让它工作,试着看看你的编译器手册。
提示运行 Windows 时,应用程序默认不会得到控制台输出。这意味着默认情况下,Windows 应用程序不能向命令行用户写入输出。要查看来自qDebug()
的任何输出,您必须在项目文件中添加一行内容CONFIG += console
。如果您构建了可执行文件,然后看到了这个提示,请尝试修复项目文件;然后运行make clean
,接着运行make
。这个过程确保项目被完全重新构建,并且新的配置被考虑在内。
现在剩下唯一要做的就是运行应用程序并观察这条消息:Hello Qt World!
。可执行文件将与您使用的目录同名。对于 Windows 用户,可执行文件在发布目录中以文件扩展名exe
结束,因此您可以通过运行以下命令来启动它:
release\testing.exe
在其他平台上,它通常直接位于工作目录中,因此您可以通过键入以下命令来启动它:
./testing
在所有平台上,结果都是一样的:将`Hello Qt World!`消息打印到控制台。在 Windows 平台上产生的命令提示符如图 1-2 中的所示。
**图 1-2。** *一个从命令行运行的 Qt 应用程序*
信号、插槽和元对象
Qt 给 C++ 带来的两个最大优势是*信号*和*插槽*,它们是非常灵活的对象互连方式,有助于使代码易于设计和重用。
一个*信号*是一个被调用时发出而不是执行的方法。所以从你作为程序员的角度来看,你声明了可能发出的信号的原型。不执行信号;只需在类的`signals`部分的类声明中声明它们。
一个*槽*是一个成员函数,可以作为信号发射的结果被调用。你必须通过将方法放在这些部分中的一个来告诉编译器将哪些方法视为槽:`public slots`、`protected slots`或`private slots`。保护级别仅在插槽用作方法时保护插槽。您仍然可以将一个`private`插槽或一个`protected`插槽连接到您从另一个类接收的信号。
说到连接信号和插槽,您可以将任意数量的信号连接到任意数量的插槽。这意味着单个插槽可以连接到多个信号,单个信号可以连接到多个插槽。对如何互连对象没有限制。当发出一个信号时,所有连接到它的插槽都会被调用。调用的顺序是不确定的,但是它们确实会被调用。让我们来看一些代码,这些代码显示了一个声明了信号和插槽的类(参见清单 1-8 )。
**清单 1-8。** *有一个信号和一个插槽的类*
#include <QString>
#include <QObject>
class MyClass : public QObject
{
` Q_OBJECT`
public:
MyClass( const QString &text, QObject *parent = 0 );
const QString& text() const;
int getLengthOfText() const;
`public slots:`
void setText( const QString &text );
`signals:`
` void textChanged( const QString& );`
private:
QString m_text;
};
代码是你在这一章中一直使用的类`MyClass`的新化身。清单中三个重点区域的信号和插槽都有变化。从底部开始,新的部分标记为`signals:`。这告诉您,本节中声明的函数将不会由您实现;它们只是这个类可以发出的信号的原型。这个类有一个信号:`textChanged`。
再往上,还有一个新的板块:`public slots:`。像其他成员一样,插槽可以是公共的、受保护的或私有的——只需在关键字`slots`前添加适当的保护级别。插槽可以被认为是一个可以连接到信号的成员函数。真的没有其他区别;它就像该类的任何其他成员函数一样被声明和实现。
**提示** Setter 方法是自然槽。通过设置所有的 setters 插槽,可以保证将信号连接到类中所有感兴趣的部分。唯一一个 setter 不应该也是 slot 的时候是当 setter 接受一些非常定制的类型,而你确信这些类型永远不会来自一个信号。
在类声明的最顶端,您可以找到`Q_OBJECT`宏。重要的是,这个宏首先出现在类声明的主体中,因为它将该类标记为需要元对象的类。在继续之前,让我们看看什么是元对象。
单词 *meta* 表示前缀的单词是关于它自己的。所以*元对象*是描述对象的对象。在 Qt 的情况下,元对象是类 `QMetaObject`的实例,包含关于类的信息,比如它的名称、它的超类、它的信号、它的槽以及许多其他有趣的东西。现在要知道的重要事情是元对象知道信号和槽。
这就引出了这个特性的下一个含义。到目前为止,所有的例子都可以很好地放入一个源代码文件中。这样继续下去是可能的,但是如果你把每个类分成一个头文件和一个源文件,这个过程会顺利得多。一个名为*元对象编译器*、`moc`的 Qt 工具解析类声明,并从中生成一个 C++ 实现文件。这听起来可能很复杂,但是只要您使用 QMake 来处理项目,对您来说没有什么不同。
这种新方法意味着来自清单 1-8 的代码将被放入一个名为`myclass.h`的文件中。实现进入`myclass.cpp`,`moc`从头文件生成另一个名为`moc_myclass.cpp`的 C++ 文件。生成文件的内容可以在 Qt 版本之间改变,这没什么好担心的。清单 1-9 包含了由于信号和插槽而改变的部分实现。
**清单 1-9。** *用信号和时隙*实现 `MyClass` ```
void MyClass::setText( const QString &text )
{
if( m_text == text )
return;
m_text = text;
emit textChanged( m_text );
}
```cpp
发出信号`textChanged`的变化可分为两部分。前半部分是检查文本是否真的发生了变化。如果您在将`textChanged`信号连接到同一个对象的`setText`插槽之前没有检查这一点,您将会以一个无限循环结束(或者像用户所说的,应用程序将会挂起)。变化的后半部分是实际发出信号,这是通过使用 Qt 关键字`emit`后跟信号的名称和参数来完成的。
**信号和机罩下的插槽**
Qt 使用函数指针来实现信号和槽。当以信号作为参数调用`emit`时,实际上调用的是信号。该信号是在由`moc`生成的源文件中实现的功能。这个函数使用持有连接插槽的对象的元对象来调用连接到信号的任何插槽。
元对象包含指向插槽的函数指针,以及它们的名称和参数类型。它们还包含可用信号及其名称和参数类型的列表。当调用`connect`时,您要求元对象将插槽添加到信号的调用列表中。如果参数匹配,则建立连接。
当匹配参数时,只对插槽接受的参数进行匹配检查。这意味着不带任何参数的槽匹配所有信号。插槽不接受的参数会被发出信号的代码直接丢弃。
#### 建立连接
为了测试`MyClass`中的信号和插槽,创建了`a`、`b`和`c`实例:
QObject parent;
MyClass *a, *b, *c;
a = new MyClass( "foo", &parent );
b = new MyClass( "bar", &parent );
c = new MyClass( "baz", &parent );
现在连接它们。使用QObject::connect
方法连接信号和插槽。论据有source object
、SIGNAL(
、source signal
、destination object
、SLOT(
、destination slot
、)
。宏SIGNAL
和SLOT
是必需的;否则,Qt 拒绝建立连接。源和目标对象是指向QObject
或继承QObject
的类的对象的指针。源信号和目标插槽是所涉及的信号和插槽的名称和参数类型。下面显示了它在代码中的样子。图 1-3 显示了对象实例是如何连接的。
QObject::connect(
a, SIGNAL(textChanged(const QString&)),
b, SLOT(setText(const QString&)) );
QObject::connect(
b, SIGNAL(textChanged(const QString&)),
c, SLOT(setText(const QString&)) );
QObject::connect(
c, SIGNAL(textChanged(const QString&)),
b, SLOT(setText(const QString&)) );
注意连接时试图指定信号或插槽参数值会导致你的代码在运行时失败。connect
函数只理解参数types
。
图 1-3。a``b
c
之间的联系** **下面一行显示了对其中一个对象的调用:
b->setText( "test" );
尝试追踪从b
开始的呼叫,其中有从"bar"
到"test"
的变化;通过连接到c
,这里有从"baz"
到"test"
的变化;并且通过连接到b
、没有变化的地方。结果是a
不变,而b
和c
将文本设置为"test
。"
这在图 1-4 中有说明,其中你可以看到文本"test"
是如何通过对象传播的。现在尝试跟踪下面的调用。你能说出结果会是什么吗?
a->setText( "Qt" );
图 1-4。 通过连接追踪文本
提示通过为每个插槽提供一个信号(例如,textChanged
对应于setText
,你可以将两个物体绑在一起。在前面的例子中,对象b
和c
总是具有相同的值,因为一个对象的变化会触发另一个对象的变化。当一个对象是图形用户界面的一部分时,这是一个非常有用的特性,您将在后面看到。
重温构建过程
上次提到构建 Qt 应用程序时,使用 QMake 工具的原因是平台独立性。另一个重要原因是 QMake 处理元对象的生成,并将它们包含在最终的应用程序中。图 1-5 显示了一个标准的 C++ 项目是如何构建的。
图 1-5。 一个标准的 C++ 项目被构建。
使用 QMake 时,所有头文件都由元对象编译器解析:moc
。moc
寻找包含Q_OBJECT
宏的类,并为这些类生成元对象。然后,生成的元对象被自动链接到最终的应用程序中。图 1-6 展示了这是如何融入构建过程的。作为开发人员,QMake 使这一点对您完全透明。
图 1-6。 正在构建元对象。
提示 记住 Qt 只是简单的标准 C++ 混合了一些宏和moc
代码生成器。如果您收到编译器或链接器消息,抱怨缺少函数,这些函数的名称告诉您它们是信号,则信号的代码没有生成。最常见的原因是该类不包含Q_OBJECT
宏。如果没有继承QObject
(直接或间接)并且仍然使用Q_OBJECT
宏,或者在类中插入或删除宏后忘记运行qmake
,也有可能出现奇怪的编译错误。
与新事物的联系
信号和插槽是非常松散的连接类型,所以唯一重要的是参数的类型在信号和插槽之间匹配。被调用的类不需要知道调用类的任何信息,反之亦然。这意味着可以对简单的示例类进行测试——让它与一组 Qt 的类进行交互。
计划是将MyClass
放在让用户输入文本的小部件QLineEdit
和显示文本的小部件QLabel
之间。一个小部件是一个可视组件,比如一个按钮、一个滑块、一个菜单项或者任何其他图形用户界面的一部分。(小部件在第三章的中有详细描述。)通过将来自QLineEdit
对象的textChanged
信号连接到MyClass
对象的setText
槽,然后将来自MyClass
对象的textChanged
信号连接到QLabel
对象的setText
槽,可以使MyClass
对象作为一座桥梁,将文本从用户可编辑字段传送到标签。整个设置如图 1-7 所示。
图 1-7。 MyClass
充当 QLineEdit
和 QLabel
之间的桥梁
这个例子的主要功能可以分为三个部分:创建相关的对象实例,建立连接,然后运行应用程序。清单 1-10 展示了如何创建相关的组件。首先,有一个QApplication
对象。对于所有的图形 Qt 应用程序,必须有一个(且只有一个)可用的应用程序实例。应用程序对象包含所谓的事件循环。在这个循环中,应用程序等待某件事情发生——等待某个事件发生。例如,用户按下一个键或移动鼠标,或者已经过了某一段时间。一旦事件发生,它就被转换成对适当的QObject
的调用。例如,按键事件将转到具有键盘焦点的小部件。事件由接收对象处理,有时会发出信号。在按键场景中,发出一个textChanged
信号;在按钮和键被输入或空格的情况下,发出一个pressed
信号。然后,信号被连接到执行应用程序实际任务的插槽。
花点时间回顾一下清单 1-10 。创建了QApplication
对象,以及三个小部件:一个普通的QWidget
、QLineEdit
和一个QLabel
。QWidget
充当另外两个的容器。这就是为什么您创建了一个QVBoxLayout
——这是一个垂直的盒子布局,它将小部件堆叠在彼此之上。然后,在将布局分配给小部件之前,在 box 布局中放置行编辑和标签。产生的小部件如图 1-8 所示。
最后,您创建一个MyClass
的实例,这是您将需要的最后一个对象。
清单 1-10。 创建一个应用程序、小部件、布局和一个 MyClass
对象
`#include
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QWidget widget;
QLineEdit *lineEdit = new QLineEdit;
QLabel *label = new QLabel;
QVBoxLayout *layout = new QVBoxLayout;
layout->addWidget( lineEdit );
layout->addWidget( label );
widget.setLayout( layout );
MyClass *bridge = new MyClass( "", &app );`
根据图 1-7 ,你需要做两个连接(见清单 1-11 )。重要的是要记住信号和插槽的名称(textChanged
和setText
)恰好与MyClass
中的名称相同。对 Qt 唯一重要的是作为参数发送和接受的类型:QString
。
清单 1-11。 设置连接
`QObject::connect(
lineEdit, SIGNAL(textChanged(const QString&)),
bridge, SLOT(setText(const QString&)) );
QObject::connect(
bridge, SIGNAL(textChanged(const QString&)),
label, SLOT(setText(const QString&)) );`
您可能担心显示用户界面,然后开始事件循环是最难的部分。事实上,恰恰相反。清单 1-12 显示了所有相关的代码。因为行编辑和标签包含在普通小部件中,所以小部件一显示,它们就显示出来。当你试图显示小部件时,Qt 意识到它缺少了一个窗口,并自动将它放在一个窗口中。然后,应用程序方法exec
运行事件循环,直到所有窗口都关闭,只要一切按预期运行,就返回零。
清单 1-12。 显示用户界面并执行事件循环
widget.show();
return app.exec();
}
一旦事件循环启动并运行,一切都会迎刃而解。键盘活动最终出现在行编辑小部件中。按键被处理,文本相应地改变。这些变化导致textChanged
信号从行编辑发送到MyClass
对象。这个信号通过MyClass
对象传播到标签,当标签用新文本重绘时,用户可以看到这个变化。来自应用程序的截图如图 1-8 所示。
图 1-8。 表面上看不出来,但是 MyClass
在这个应用中起着重要的作用。
重要的是要记住,MyClass
对QLineEdit
或QLabel
一无所知,反之亦然——它们在相互连接的主函数中相遇。不需要事件、委托或信号类为相关类所熟知。唯一的共同点是他们继承了QObject
;其余所需的信息可以在运行时从元对象中获得。
集合和迭代器
Qt 的类取代了 C++ STL 的类(到目前为止,你已经看到了QString
类)。这一节着眼于 Qt 必须提供的容器和迭代器。
Qt 的容器是模板类,可以包含任何其他可变类。有一系列不同的容器,包括不同的列表、堆栈、队列、映射和哈希表。伴随这些类而来的是迭代器——既有 STL 兼容的迭代器,也有 Qt 的 Java 版本。迭代器是轻量级的对象,用于在容器中移动,并访问保存在容器中的数据。
提示所有的 Qt 集合类都是隐式共享的,所以在列表被修改之前不会复制它。将列表作为参数传递或返回列表作为结果是低成本的性能和内存方面。将对列表的引用作为参数或结果传递甚至更便宜,因为它保证不会无意中做出任何改变。
遍历 QList
我们先来看一下QList
类。清单 1-13 展示了如何创建和填充一个QString
对象列表。使用<<
操作符追加数据使得用信息填充列表变得容易。当列表被填充时,foreach
宏用于打印列表的内容。
清单 1-13。 填充一个 QList
并打印内容
QList<QString> list;
list << "foo" << "bar" << "baz";
foreach( QString s, list )
qDebug() << s;
清单 1-13 展示了 Qt 开发者认为列表应该如何:易于使用。使用foreach
宏缩短了代码,但是迭代器实例在幕后使用。
Qt 提供了 STL 风格的迭代器和 Java 风格的迭代器。清单 1-14 中的代码展示了如何使用这两个迭代器。列表顶部的while
循环使用 Java 风格的迭代器QListIterator
。函数hasNext
检查列表中是否还有有效的条目,而next
方法返回当前条目并将迭代器移动到下一个条目。如果你想在不移动迭代器的情况下查看下一项,使用peekNext
方法。
清单末尾的for
循环使用 STL 风格的迭代器。迭代器名称可以使用 STL 命名或 Qt 命名来指定— const_iterator
和ConstIterator
是同义词,但是后者更“Qt 化”
在for
循环中迭代时,使用++iterator
而不是iterator++
是有价值的。这为您提供了更高效的代码,因为编译器避免了为for
循环的上下文创建临时对象。
清单 1-14。 * STL 风格迭代器和 Java 风格迭代器并排*
QList<int> list;
list << 23 << 27 << 52 << 52;
QListIterator<int> javaIter( list );
while( javaIter.hasNext() )
qDebug() << javaIter.next();
QList<int>::const_iterator stlIter;
for( stlIter = list.begin(); stlIter != list.end(); ++stlIter )
qDebug() << (*stlIter);
在比较 STL 和 Java 风格的迭代器时,重要的是要记住 STL 风格的迭代器效率略高。然而,Java 风格迭代器提供的可读性和代码清晰性可能是使用它们的足够动机。
提示通常使用typedef
来避免到处键入QList<>::Iterator
。例如,MyClass
条目的列表可以用一个叫做MyClassListIterator
的迭代器叫做MyClassList
(像这样创建类型:typedef QList<MyClass> MyClassList
)(像这样创建类型:typedef QList<MyClass>::Iterator MyClassListIterator
)。这个过程有助于使使用 STL 风格迭代器的代码更具可读性。
清单 1-14 向你展示了如何使用常量迭代器,但是有时候当你迭代时,修改列表是必要的。使用 STL 风格的迭代器,这意味着跳过名字的const
部分。对于 Java 风格的迭代,使用QMutableListIterator
。清单 1-15 显示了使用 Qt 类迭代和修改列表内容:
清单 1-15。 使用迭代器修改列表
QList<int> list;
list << 27 << 33 << 61 << 62;
QMutableListIterator<int> javaIter( list );
while( javaIter.hasNext() )
{
int value = javaIter.next() + 1;
javaIter.setValue( value );
qDebug() << value;
}
QList<int>::Iterator stlIter;
for( stlIter = list.begin(); stlIter != list.end(); ++stlIter )
{
(*stlIter) = (*stlIter)*2;
qDebug() << (*stlIter);
}
清单 1-15 显示 Java 风格的循环使用next
读取下一个值*,然后使用setValue
设置当前值。这意味着清单中的循环将列表中的所有值加 1。这也意味着在next
作为迭代器被调用之前setValue
不应该被使用;然后,它指向实际列表之前不存在的值。*
注意当通过删除或插入项目来修改列表时,迭代器可能会失效。在修改实际的列表(而不是列表的内容)时,请注意这一点。
在 STL 风格的循环中,什么都没有改变,只是这次迭代器引用的项可以被修改。这个例子使用了名称Iterator
而不是iterator
,这不会影响结果(它们是同义词)。
不仅可以单向迭代,对于 STL 风格的迭代器,也可以使用--
操作符和++
操作符。对于 Java 风格的迭代器,可以使用方法next
、previous
、findNext
和findPrevious
。使用next
和previous
时,使用hasNext
和hasPrevious
保护代码以避免未定义的结果是很重要的。
当你选择使用迭代器时,尽可能使用常量迭代器,因为它们能提供更快的代码,并防止你错误地修改列表项。
当你需要以一种特殊的方式迭代或者只是想访问一个特定的条目时,你可以使用带有[]
操作符或者at
方法的索引访问。对于一个QList
来说,这个过程非常快。例如,下面一行计算列表中第六和第八个元素的总和:
int sum = list[5] + list.at(7);
填写清单
到目前为止,您已经使用`<<`操作符填充了列表,这意味着将新数据追加到列表的末尾。也可以预先考虑数据;例如,将它放在列表的开头或在中间插入数据。清单 1-16 展示了在列表中放置条目的不同方式。
图 1-9 显示了列表中的每个插入。首先,字符串`"first"`被追加到一个空列表中,然后字符串`"second"`被追加到列表的末尾。之后,字符串`"third"`被添加到列表中。最后,字符串`"fourth"`和`"fifth"`被插入到列表中。
**清单 1-16。** *追加、前置、插入*
QList<QString> list;
list << "first";
list.append( "second" );
list.prepend( "third" );
list.insert( 1, "fourth" );
list.insert( 4, "fifth" );
**图 1-9。** *追加、前置、插入时的列表内容*
更多列表
`QList`不是唯一可用的列表类;对于不同的场景有几个列表。当选择使用哪个列表类时,正确的答案几乎总是`QList`。使用`QList`的唯一缺点是,当你在大列表中间插入项目时,它会变得非常慢。
另外两个列表类更加专门化,但是它们不应该被认为是特例。第一个是`QVector`类,它保证包含的条目在内存中保持有序,所以当你在列表的开始和中间插入条目时,列表中后面的所有条目都必须被移动。好处是索引访问和迭代很快。
第二个选择是`QLinkedList`,它提供了一个链表实现,可以快速迭代,但是没有索引访问。它还支持常量时间插入,而与新项目在列表中的插入位置无关。另一个好的方面是,只要元素还在列表中,迭代器就一直有效——可以自由地在列表中删除和插入新的元素,同时仍然使用迭代器。表 1-1 将链表和向量类与`QList`进行了比较。
**表 1-1。***`QList``QVector`*`QLinkedList`的比较**
```**
```cpp*
| **类** | **在开始处插入** | **插入中间的** | **结尾插入** | **通过
索引**访问 | **通过
迭代器**访问 |
| --- | --- | --- | --- | --- | --- |
| `QList` | 快的 | 在大列表上非常慢 | 快的 | 快的 | 快的 |
| `QVector` | 慢的 | 慢的 | 快的 | 快的 | 快的 |
| `QLinkedList` | 中等 | 中等 | 中等 | 无法使用 | 快的 |
****#### 特殊列表
到目前为止,您已经查看了用于通用目的的列表。Qt 也有一套专门的列表。让我们先来看看QStringList
。
字符串列表
string list 类继承了QList<QString>
,并且可以这样处理。但是,它也有一些特定于字符串的方法,这使它很有用。首先,您需要创建一个列表并用一些内容填充它。这应该不会带来任何惊喜:
QStringList list; list << "foo" << "bar" << "baz";
这将给出一个包含"foo"
、"bar"
和"baz"
的列表。你可以用你选择的一串连接它们。这是一个逗号:
QString all = list.join(",");
在这个操作之后,字符串all
将包含"foo,bar,baz"
。另一件要做的事情是替换列表中包含的所有字符串。例如,您可以将所有出现的"a"
替换为"oo"
:
list.replaceInStrings( "a", "oo" );
替换操作产生一个新的列表,包含以下内容:"foo"
、"boor"
和"booz"
。除了join
,QString
还有一种方法叫split
。这个方法通过给定字符串的每次出现来分割给定字符串,并返回一个QStringList
,它可以很容易地添加到已经存在的列表中。在本例中,您用每个逗号进行分割:
list << all.split(",");
最终列表将包含项目"foo"
、"boor"
、"booz"
、"foo"
、"bar"
和"baz"
。
堆栈和队列
string list 接受一个列表,并用方法扩展它,使它更容易处理内容。其他类型的特殊列表用于将新项目放入列表的特定部分,并从一个特定部分获取项目。类是QStack
和QQueue
,其中堆栈类可以归类为 LIFO(后进先出)列表,队列归类为 FIFO(先入先出)列表。
使用堆栈时,使用push
向堆栈中添加或推送新的项目。top
方法用于查看当前项目。通过调用pop
,当前项被返回并从堆栈中移除。这被称为弹出堆栈。在尝试弹出堆栈之前,您可以通过使用isEmpty
方法来检查是否有东西要获取。清单 1-17 显示了这些方法是如何使用的。当清单中显示的代码执行后,字符串result
将包含文本"bazbarfoo"
。注意,第一个被压入堆栈的项出现在字符串的最后——LIFO。
清单 1-17。 使用堆栈
` QStack
stack.push( "foo" );
stack.push( "bar" );
stack.push( "baz" );
QString result;
while( !stack.isEmpty() )
result += stack.pop();`
对于队列,对应的方法有enqueue
用于添加项目、dequeue
用于从队列中取出项目、head
用于查看当前项目。就像堆栈一样,有一个名为isEmpty
的方法来指示是否有任何东西排队。清单 1-18 展示了这些方法的实际应用。代码执行后,结果字符串将包含文本"foobarbaz"
。也就是说,首先排队的项目首先出现在字符串 FIFO 中。
清单 1-18。 使用队列
QQueue<QString> queue;
queue.enqueue( "foo" );
queue.enqueue( "bar" );
queue.enqueue( "baz" );
QString result;
while( !queue.isEmpty() )
result += queue.dequeue();
映射和哈希
列表有利于保存东西,但有时将东西联系起来也很有趣,这就是地图和散列进入画面的地方。让我们从看一看QMap
类开始,它使您能够将项目保存在键值对中。例如,您可以将一个值关联到一个字符串,如清单 1-19 所示。当您创建一个QMap
时,模板参数是键的类型,然后是值的类型。
清单 1-19。 创建一个将字符串与整数相关联的映射,并用信息填充它
QMap<QString, int> map;
map["foo"] = 42;
map["bar"] = 13;
map["baz"] = 9;
要在地图中插入一个新项目,你所要做的就是用[]
操作符分配它。如果该键已经存在,新项将替换现有项。如果键对于地图是新的,则创建一个新项目。
您可以使用contains
函数查看一个键是否存在,或者使用keys
方法获取所有键的列表。清单 1-20 展示了如何获取键并遍历地图中的所有条目。
清单 1-20。 显示调试控制台上所有的键值对
foreach( QString key, map.keys() )
qDebug() << key << " = " << map[key];
可以直接在 map 上使用迭代器,而不是遍历一个键列表,如清单 1-21 所示。这使得可以通过迭代器即时访问键和值,从而节省了每次循环迭代的查找时间。
清单 1-21。 遍历所有键值对
QMap<QString, int>::ConstIterator ii;
for( ii = map.constBegin(); ii != map.constEnd(); ++ii )
qDebug() << ii.key() << " = " << ii.value();
在清单 1-20 中,[]
操作符用于访问列表中已知的条目。如果使用[]
操作符来获取一个不存在的项目(如下所示),则会创建一个新项目。新项等于零或使用默认构造器创建。
sum = map["foo"] + map["ingenting"];
如果使用`[]`操作符而不是`value`方法,将会阻止地图创建新项目。相反,会返回零或默认构造项,而不会添加到地图中。建议使用`value`,因为它可以避免用来自一个很难发现的 bug 的无意义项目填充内存:
sum = map["foo"] + map.value("ingenting");
创建映射时,用作键的类型必须定义操作符`==`和`<`,因为映射必须能够比较键并对它们进行排序。`QMap`提供良好的查找性能,因为它总是保持键排序。这在执行清单 1-20 时很明显,其中结果按照`bar` - `baz` - `foo`的顺序返回,而不是按照它们被插入的顺序。如果这对您的应用程序不重要,您可以使用`QHash`来获得更高的性能。
`QHash`类可以像`QMap`一样使用,但是键的顺序是任意的。散列中用于键的类型必须有一个`==`操作符和一个名为`qHash`的全局函数。`qHash`函数应该返回一个称为哈希键的无符号整数,用于在哈希列表中查找条目。对该函数的唯一要求是,它应该总是为相同的数据返回相同的值。Qt 为最常见的类型提供了这样的函数,但是如果您想将自己的类放在一个 hash 列表中,就必须提供这样的函数。
哈希列表的性能取决于它可以预期的冲突数量;也就是说,产生相同哈希键的键的数量。通过利用您对可能出现的键的了解,您可以使用散列函数来提高性能。例如,在电话簿应用程序中,人们可能有相同的姓名,但通常不共享姓名和电话号码。清单 1-22 显示了保存有姓名和号码的人的类`Person`。
**清单 1-22。** *持姓名和班级编号*
class Person
{
public:
Person( const QString& name, const QString& number );
const QString& name() const;
const QString& number() const;
private:
QString m_name, m_number;
};
对于这个类,您必须提供一个`==`操作符和一个`qHash`函数(如清单 1-23 所示)。`==`操作符确保名字和号码匹配。`qHash`函数从`qHash(QString)`函数获取姓名和号码的散列,并使用 XOR 逻辑运算符(`^`)将它们连接起来。
**清单 1-23。** *哈希函数为* `Person` *类*
bool operator==( const Person &a, const Person &b )
{
return (a.name() == b.name()) && (a.number() == b.number());
}
uint qHash( const Person &key )
{
return qHash( key.name() ) ^ qHash( key.number() );
}
为了试验在清单 1-23 中实现的散列函数,创建一个散列列表并在试图查找现有和不存在的项目之前放入几个项目。这显示在清单 1-24 中。每行`qDebug`后的注释显示了预期的结果。
**清单 1-24。** *哈希* `Person` *类*
QHash<Person, int> hash;
hash[ Person( "Anders", "8447070" ) ] = 10;
hash[ Person( "Micke", "7728433" ) ] = 20;
qDebug() << hash.value( Person( "Anders", "8447070" ) ); // 10
qDebug() << hash.value( Person( "Anders", "8447071" ) ); // 0
qDebug() << hash.value( Person( "Micke", "7728433" ) ); // 20
qDebug() << hash.value( Person( "Michael", "7728433" ) ); // 0
有时候有趣的事情不是将一个值映射到一个键,而是记住哪些键是有效的。在这种情况下,可以使用`QSet`类。一个集合是一个没有值的散列,所以对于键必须有一个`qHash`函数和一个`==`操作符。此外,键的顺序是任意的。清单 1-25 显示了你使用和填充一个列表相同的操作符来填充一个集合。再往下,可以看到这两种访问方法。您可以使用迭代器来访问这些键,也可以调用`contains`来查看这些键是否是集合的一部分。
**清单 1-25。** *填充一个*`QSet`*;然后显示按键并测试按键*和`"FORTRAN"`
QSet
set << "Ada" << "C++" << "Ruby";
for( QSet
qDebug() << *ii;
if( set.contains( "FORTRAN" ) )
qDebug() << "FORTRAN is in the set.";
else
qDebug() << "FORTRAN is out.";
**每键多项**
`QMap`和`QHash`类为每个键存储一个项目。当你想为每一个键都列出一个条目时,你可以使用`QMultiMap`和`QMultiHash`。这些类之间的关系就像`QMap`与`QHash`之间的关系一样——键的顺序被保存在地图中;散列更快,但是任意排列密钥。
本节讨论的是`QMultiMap`类,但是我所说的也适用于`QMultiHash`类。`QMultiMap`类没有`[]`操作符;相反,`insert`方法用于添加值,而`values`方法用于访问插入的项目。因为`QMultiMap`可以包含一个键的多个元素,所以`values`方法返回一个`QList`,其中包含与给定键相关联的项目。在请求列表之前,可以使用`count`方法查看有多少项与给定的键相关联。
**注意**多集合`QMultiMap`和`QMultiHash`类只是`QMap`和`QHash`类的包装器。通过使用`insertMulti`方法,可以将`QMap`和`QHash`类用作多集合,但是使用`[]`操作符或`insert`方法很容易意外地覆盖一个项目列表。使用多集合可以在编译时检测到任何此类错误,并降低难以发现的错误的风险。
清单 1-26 显示了如何创建和填充一个`QMultiMap`。这段代码不包含任何惊喜。然而,`QMultiMap`和`QMap`的关系表明,如果你看一下从`keys`方法返回的列表,`foo`出现了两次。找到所有唯一键的最好方法是将所有键添加到一个`QSet`中,然后遍历它。清单 1-27 展示了如何首先找到所有的键,然后遍历它们,显示每个键的所有条目。
**清单 1-26。** *创建并填充一个* `QMultiMap`
QMultiMap<QString, int> multi;
multi.insert( "foo", 10 );
multi.insert( "foo", 20 );
multi.insert( "bar", 30 );
**清单 1-27。** *找到唯一的键,然后遍历每个键及其相关项*
QSet
foreach( QString key, keys )
foreach( int value, multi.values(key) )
qDebug() << key << ": " << value;
有一种更快的方法可以找到一个`QMultiMap`中的所有条目:使用迭代器。一个`QMultiMap::iterator`有成员函数`key`和`value`,用来获取它包含的信息。迭代器也可以用来高效地查找给定键的所有项。使用`find`方法,可以得到一个迭代器,指向属于给定键的第一项。随着键的排序,你可以通过迭代到达属于一个给定键的所有条目,直到从`find`开始的迭代器到达`QMultiMap`或另一个键的末尾(清单 1-28 显示了一个例子)。迭代器方法还避免了必须构建一个包含属于该键的所有条目的列表,这是使用`values`方法时会发生的情况——节省了内存和时间。
**清单 1-28。** *使用迭代器查找给定关键字的条目*
QMultiMap<QString, int> int>::ConstIterator ii = multi.find( "foo" );
while( ii != multi.end() && ii.key() == "foo" )
{
qDebug() << ii.value();
++ii;
}
在这一节的开始,我说过所有的信息也适用于`QMultiHash`类。清单 1-29 通过执行与清单 1-26 、 1-27 和 1-28 中相同的任务来显示这一点。突出显示的行包含所需的更改——只包含要使用的类的更改。结果中唯一可能的差异是键以任意顺序返回。注意,这并不意味着`find`和 iterate 方法失败——键以任意顺序出现,但仍然是有序的。
**清单 1-29。** *使用迭代器查找给定关键字的条目*
QMultiHash<QString, int> multi;
multi.insert( "foo", 10 );
multi.insert( "foo", 20 );
multi.insert( "bar", 30 );
QSet
foreach( QString key, keys )
foreach( int value, multi.values(key) )
qDebug() << key << ": " << value;
QMultiHash<QString, int>::ConstIterator ii = multi.find( "foo" );
while( ii != multi.end() && ii.key() == "foo" )
{
qDebug() << ii.value();
++ii;
}
总结
Qt 有一个推荐的命名方案,因为它使得猜测类和方法的名字变得更加容易。所有元素都使用驼色外壳;也就是每个生词都是以大写字母开头,像这样:`ThisIsAnExample`。
类名以大写字母开头,Qt 类以 q 为前缀,这是一个 Qt 类的例子:`QMessageBox`,这是另一个类:`MyClass`。以一个 Q 和一组小写字母为前缀的类是第三方 Qt 类;例如:`QjColorPicker`。
当使用 Qt 类时,确保包含与该类同名的头文件(在大多数平台上区分大小写)而没有任何文件扩展名(例如,`#include <QMessageBox>`包含了类`QMessageBox`)。
方法名以小写字母开头(例如,`thisIsAMethod`)。Getter 和 setter 方法分别被命名为`foo`和`setFoo`。如果有反映`foo`变化的信号,通常称为`fooChanged`。在这里的例子中,`foo`被称为一个属性。
关于信号和插槽:setters 是插槽的天然候选对象,也是发出关于变化的信号的好地方。如果您发出这样的信号,请确保检查 setter 接收到的是一个新值,而不是相同的值。这样做可以避免无限递归循环。
插槽可以是公共的、受保护的或私有的。这些部分被标记为`public slots:`、`protected slots:`或`private slots:`。信号是信号原型,放在`signals:`标签之后。插槽实现为任何其他成员函数,尽管您从不实现信号——只需在类定义中声明它们,并让元对象编译器处理细节。
当连接信号和插槽时,记住`connect`方法不能处理参数值,只能处理参数类型。参数的值必须来自发出的对象。
当使用信号和槽时,必须继承`QObject`并用`Q_OBJECT`宏开始类声明。这将添加所需的代码,并告诉元对象编译器该类需要一个元对象。
一旦你继承了`QObject`,你就可以给一个对象分配一个父对象和任意数量的子对象。每个父对象负责调用其子对象上的`delete`,因此只要您确保删除所有对象的祖先,所有对象都会被删除。
Qt 有处理通常由 C++ 标准模板库 STL 处理的任务的类。Qt 等价物更适合与 Qt 结合使用,但是可以轻松地与 STL 等价物交互。
对于处理文本,使用`QString`类。它支持 Unicode,并且与`QStringList`类交互良好。string list 类提供了在列表中包含的所有字符串中进行搜索和替换的方法,以及用您选择的分隔符连接字符串的方法。
为了保存任何类型对象的列表,Qt 有模板类`QList`、`QLinkedList`和`QVector`。各有利弊,但`QList`通常是正确的选择。当在一个非常大的列表中间插入项目时,当需要固定时间插入和快速顺序访问时,使用`QLinkedList`。`QVector`擅长随机存取,当项目需要在连续内存中按顺序存储时。
对于队列和堆栈,`QQueue`和`QStack`类工作良好;它们提供快速插入和从其名称所指示的末端进入。当你使用一个堆栈时,你的`push`和`pop`到顶;当你使用一个队列时,你将`enqueue`项放在尾部而`dequeue`项放在头部。
`QMap`和`QHash`类将条目与键相关联。`QHash`类以任意顺序对项目进行排序,但执行速度比`QMap`类稍快。地图总是按关键字对项目进行排序。为了管理每个键的几个项目,最好使用`QMultiMap`或`QMultiHash`类。
如果您不需要将任何条目与一个键相关联,但是想要维护一个键列表,那么`QSet`类就很适合您。它作为一个散列来工作,但是没有任何关联的值。
```****
## 二、Qt 快速应用开发
**A** 虽然 Qt 最初是作为一个开发带有图形用户界面的跨平台应用程序的工具,但它已经扩展成为一个用于构建所有类型软件的工具——命令行应用程序、嵌入式软件和用于大型工作站应用程序的图形用户界面。
Qt 的历史根源使得创建图形用户界面和围绕它构建应用程序变得非常容易。本章通过几个简单的步骤,从最初的想法一直到一个工作的应用程序。
### 素描
当开发软件时,有一个计划总是好的——一个显示你试图实现的目标的草图。这一章的目标是一个非常简单的电话簿,其中包含了联系人和电话号码的列表。
从现在开始,图形用户界面 UI 将围绕两个对话框构建:一个用于显示列表和可用操作,另一个用于编辑联系人。图 2-1 显示了这两个对话框的初稿。
![image](https://gitee.com/OpenDocCN/vkdoc-c-cpp-zh/raw/master/docs/fund-qt-dev/img/P0201.jpg)
**图 2-1。** *用户界面的初稿*
流程的下一步是将草图中的想法转化为可以实施的结构。为此,您必须理解 Qt 应用程序是如何工作的。
### 事件驱动的应用程序
所有的 Qt 应用程序都是事件驱动的,所以你不能直接跟踪从`main`函数到应用程序所有部分的执行路径。相反,您从`main`函数初始化您的应用程序,然后`main`函数调用`QApplication`对象上的`exec`方法。这将启动应用程序的事件循环。(事件可以是从网络上接收到的新包、经过了一定时间、或者用户按下了键或移动了鼠标。)
`QApplication`对象等待这些事件,并将它们传递给任何受影响的`QObject`。例如,当用户点击图 2-1 所示电话簿对话框中的 *Clear All* 按钮时,应用程序的事件循环会接收到该点击。然后,`QApplication`对象接受`clicked`事件并将其传递给受影响的对象:在本例中,是代表按钮的`QPushButton`对象。然后,这个按钮对象对事件做出反应,并发出相关信号。
通过将用于被点击的按钮和被选择的列表项的信号连接到实现应用程序的实际功能的插槽,用户界面被设置为对用户交互做出反应。因此,开发应用程序时,一个好的起点是识别用户可以通过图 2-1 所示的 UI 采取的动作。
* * *
提示这里确定的行为非常像统一建模语言(UML)中的用例,这意味着这两种方法非常兼容。
* * *
* 第一步是启动应用程序。发生这种情况时,会显示列表对话框。
* 从列表对话框中,用户添加一个新项目。这将显示一个空的编辑对话框。
* 从列表对话框中,用户编辑当前选定的项目。这将显示一个已填写的编辑对话框。
* 用户从列表对话框中移除当前选定的项目。
* 从列表对话框中,用户清除列表。
* 从列表对话框中,用户退出应用程序。
* 在编辑对话框中,用户批准所做的更改。这意味着更改将反映在列表对话框中。
* 从编辑对话框中,用户取消所做的更改。
从列表的顶部开始,主机操作系统必须负责启动应用程序。你在这个过程中的角色是从`main`函数中显示列表对话框。其余的操作显示为组成 UI 的两个对话框上的按钮。
总结一下:这个应用程序由一个主函数、一个列表对话框和一个编辑对话框组成。每个对话框由一个表单——也就是 UI 的 XML 描述——和一个组成 Qt 感兴趣的实际`QDialog`的类组成。这些信息足以创建一个项目文件。结果如清单 2-1 所示。注意,它从应用程序模板`app`开始,这是所有 Qt 应用程序的起点。项目文件的其余部分只是一个需要创建的文件列表,这也是您在本章余下部分要做的事情。
**清单 2-1。** *电话簿应用的项目文件*
```cpp
TEMPLATE = app
TARGET = addressbook
SOURCES += main.cpp editdialog.cpp listdialog.cpp
HEADERS += editdialog.h listdialog.h
FORMS += editdialog.ui listdialog.ui
现在为应用程序创建一个新目录,并将项目文件放入其中。当您将本章中显示的其余文件放在该目录中时,您将得到一个完整的应用程序。
使用设计器
Designer 是 Qt 附带的用于设计用户界面的工具。本节向您展示如何使用设计器来构建列表对话框。然后学习编辑对话框的规范,这样您就可以自己组装了。
让我们从启动 Designer 开始。你会看到如图 2-2 所示的对话框。对于列表对话框,选择创建底部带有按钮的对话框,然后单击创建。
提示如果你运行的是 Windows,可以从开始菜单中选择 designer,或者启动 Qt 命令提示符,然后在控制台键入 designer 来启动 Designer。运行 Mac OS X 的人可以使用 Finder 找到 Designer 并启动它。在 Unix 平台上,这个过程可能稍有不同——尤其是如果您同时安装了 Qt 的第 3 版和第 4 版。可能的命令可以是designer
或designer-qt4
。如果你已经使用软件包管理器安装了 Qt 4,你很可能在你的程序菜单中找到它。阅读发行版的文档以获得更多信息。
图 2-2。 用于创建新表单的设计器对话框
出现设计者的用户界面。让我们先快速概述一下这个界面。设计器可以以两种模式运行:停靠窗口或多个顶层窗口。您可以通过选择编辑用户界面模式来更改设置。拥有多个顶层窗口对于多屏设置来说是非常好的,但是如果您同时运行多个应用程序和 Designer,可能会导致工作空间混乱。尝试两种配置,以确定您更喜欢哪一种。
在任一 UI 模式中,设计器都由下面列出的许多组件组成。这些组件中的每一个都可以从工具菜单中显示或隐藏。我不喜欢总是显示所有的组件——通常小部件框和属性编辑器对我来说已经足够了——但是可以自由地进行实验以获得您喜欢的工作环境。
- 小部件框,如图 2-3 所示,包含所有可用小部件的列表,这些小部件被分成多个类别。
- 图 2-4 中的所示的属性编辑器显示了工作表单中当前选中的小部件的所有可设计属性。
- 如图 2-5 中的所示,对象检查器显示了哪个对象是哪个对象的父对象。
- 信号/插槽编辑器,也称为连接编辑器,如图 2-6 中的所示,用于管理组成工作表单的对象之间的连接。
- 资源编辑器,如图 2-7 所示,用于管理编译成可执行文件的图标等资源。
- 动作编辑器,如图图 2-8 所示,用于管理动作;也就是说,在 UI 中的许多地方都表示的对象,例如菜单栏、工具栏和键盘快捷键。
图 2-3。 设计师的小部件框连同工具栏和菜单
图 2-4。 设计师的属性编辑
图 2-5。 设计师的对象检查器
图 2-6。 设计师的信号/槽编辑
图 2-7。 设计师的资源编辑器
图 2-8。 设计师的动作编辑
图 2-9 显示了从模板创建的表单。内容由包含两个按钮的按钮框组成:OK 和 Cancel。按钮盒是一个小部件,所有使用 Qt 构建的对话框和窗口都由小部件和布局组成。小部件是 UI 的一部分,例如按钮、标签或滑块。小部件按布局组织。使用布局而不是仅仅记住每个小部件的坐标的原因是,你可以自由地调整字体和对话框的大小。此外,翻译人员可以编写任何标签文本,因为标签可以根据文本调整大小。小部件和布局有许多方面需要更详细地介绍(第三章会更详细地讨论)。
图 2-9。 表单从模板中新鲜出炉
注意我将对话框称为表单,因为可以使用 Designer 设计包含其他窗口小部件、主窗口和对话框的窗口小部件。它们都在 Designer 中显示为一个表单,但最终结果是不同的。
通过选择对话框中的按钮框并按 Delete 键,可以在 Designer 中开始工作。您会看到如图图 2-10 所示的清除对话框。
图 2-10。 从按钮中清除表单
删除小组件后,您现在可以开始添加小组件。确保您处于编辑小部件的模式。从图 2-11 所示的工具栏中选择工作模式。
图 2-11。 工作模式为(从左至右):编辑小工具、编辑连接、编辑好友、编辑标签顺序。
现在浏览部件框并找到按钮(在按钮组中)。当您单击并按住按钮时,鼠标指针会变成一个实际的按钮。将该按钮拖到表单上,并将其放在右上角。在第一个按钮下方的垂直行中再添加两个按钮;然后在右下角添加第四个按钮之前留出一个间隙。完成后,表格看起来应该类似于图 2-12 。
图 2-12。 带按钮的表单
现在在小部件框中找到垂直间隔(它在顶部附近的间隔组中)。将垫片拖动到对话框中,将其放置在上面三个按钮和下面一个按钮之间的空隙中,如图图 2-13 所示。
图 2-13。 添加间隔符后的形状
现在选择四个按钮和弹簧,然后应用垂直布局,这样你就得到如图图 2-15 所示的表单。通过单击并按住 Shift 键或拖动包含要选择的项目的框,可以选择多个项目。请注意,您不希望从小部件框添加布局。相反,在布局中选择你想要的部件,并点击工具栏中的垂直布局按钮,如图图 2-14 所示。这些按钮如下(从左到右):
- 应用水平布局将小部件放置在水平行中。
- 应用垂直布局将小部件放置在垂直行中。
- 水平分割器将小部件放置在水平行中,但也允许用户调整小部件的大小。
- 垂直分割器将小部件放置在垂直行中,但也允许用户调整小部件的大小。
- 应用网格布局将小部件放置在可拉伸的网格中。
- 中断布局移除任何当前布局。
- 调整大小调整当前布局的大小,以适合包含的小部件。
尝试将指针放在工具栏按钮上,找到工具提示垂直排列的按钮,这就是您想要的按钮。
图 2-14。 布局工具栏
图 2-15。 垂直布局的所有部件
您可以在小部件框的组项目小部件中找到列表小部件。将它放在表单上自由空间的中间。然后点击表单上的一个空闲点,这样就选择了实际的表单。通过查看对象检查器,您可以看到您已经选择了实际的表单。当对话框被选中时,你就有了正确的选择。现在,通过单击工具栏中的相应按钮来应用网格布局。在选择了包含其他小部件的小部件时应用布局会将该布局应用于表单(布局是父小部件的属性,而不是其中的子小部件的属性)。图 2-16 显示了添加列表小部件后的表单,图 2-17 显示了应用布局后的表单。
提示如果在调整对话框大小时,对话框的内容没有被拉伸,问题很可能是你忘记添加顶层布局了。选择对话框表单本身并应用一个布局——这应该可以解决问题。
图 2-16。 增加了列表控件
图 2-17。 表格布局已经应用到表格本身及其所有内容
现在,您已经在布局中放置了许多小部件,形成了一个对话框。您可以使用“表单”菜单中的预览功能尝试不同样式的对话框。尝试调整对话框的大小,看看布局是如何交互的,并在 Qt 支持的不同平台上尝试不同的样式来查看对话框。然而,在对话结束之前,还有一些细节需要整理。首先,必须设置所有文本和小部件名称。
选择一个按钮会在属性编辑器中显示其属性。只需点击该值,并编辑它来改变它。表 2-1 自上而下显示了应用于按钮的名称和文本。请注意,对话框和列表小部件都有需要更改的属性。图 2-18 显示了修改后的对话框。
表 2-1。 属性改变
| 小部件 | **属性** | **值** | | --- | --- | --- | | 顶部按钮 | `name` | `addButton` | | 顶部按钮 | `text` | 添加新的 | | 第二个按钮 | `name` | `editButton` | | 第二个按钮 | `text` | 编辑 | | 第三个按钮 | `name` | `deleteButton` | | 第三个按钮 | `text` | 删除 | | 底部按钮 | `name` | `clearButton` | | 底部按钮 | `text` | 清理所有 | | 列表小部件 | `name` | `list` | | 对话 | `name` | `ListDialog` | | 对话 | `window title` | 电话簿 |图 2-18。 姓名和文字已更新
name
属性用于给每个小部件一个变量名,这是您稍后从源代码访问小部件时将使用的名称。这意味着name
属性必须是有效的 C++ 标识符名称;也就是说,不要以数字开头,只使用英文字母、数字和下划线。
提示如果你想调整一个小部件的主要属性(例如,标签或按钮的文本),只需选择小部件并按 F2 键。
在 Designer 中构建表单的一个好处是可以用图形方式建立联系。从工作模式工具栏中选择编辑连接的模式。然后点击并从clearButton
值拖动到list
值。当在列表上释放鼠标按钮时,显示如图图 2-19 所示的对话框。
图 2-19。 通过选择左边的信号和右边的插槽进行连接
左侧显示了来自clearButton
值的可用信号;在右边,显示了list
值的槽。选择clicked()
信号和clear()
插槽,然后按 OK。由此产生的连接在表格中显示为一个箭头(见图 2-20 )。
图 2-20。 将连接直接显示在表单中
连接也可以在连接编辑器中看到,如图图 2-21 所示。
图 2-21。 连接编辑器中显示的连接
准备表单的最后一步是设置 tab 键顺序,这是用户使用 Tab 键在小部件之间跳转时访问它们的顺序。为此,首先从工作模式工具栏中选择 tab 键顺序模式。现在,每个小部件都用一个数字显示在一个蓝框中,这就是 tab 键顺序。开始按你觉得正确的顺序点击蓝框,数字会变。图 2-22 显示了带有我的标签顺序的对话框——如果你喜欢,可以随意使用其他顺序。当你感到满意时,预览对话框,并通过按 Tab 键移动部件。
图 2-22。 用 tab 键顺序设置表单
现在剩下的就是保存你的工作成果。将文件另存为listdialog.ui
,与清单 2-1 中的项目文件放在同一个目录下。
为了试验您的新设计技能,我将编辑对话框的细节展示如下,但是您必须自己创建它。请注意,如果您从底部有按钮的模板开始,所有连接都是自动设置的。图 2-23 显示了结果对话框,以及标签、按钮和对话框的文本属性。
图 2-23。 编辑对话框
对象检查器如图 2-24 所示。您可以从该视图中分辨出不同对象的名称,以及哪些对象属于哪个布局。要创建网格布局,请按某种顺序放置小部件,选择它们,然后应用网格布局。Designer 通常在第一次尝试时就能得到正确的网格,但有时可能需要中断布局(可从布局工具栏获得),重新排列小部件,然后再次应用它。这是一个熟能生巧的地方。
**图 2-24。**编辑对话框中的对象
图 2-25 显示了对话框中的连接。它们已经在模板中制作好了,所以您不应该对它们做任何事情。
**图 2-25。**编辑对话框中的连接
最后,图 2-26 显示了我选择的 tab 顺序。请随意设置适合您的标签顺序。
图 2-26。 编辑对话框的标签顺序
为了确保对话框以正确的方式组合在一起,请确保对象检查器视图和表单本身看起来百分之百正确。连接和 tab 键顺序也很重要,但是其他两个视图是最容易出现错误的地方。完成后,将对话框和其他文件保存为editdialog.ui
。
从设计师到代码
在 Designer 中创建的文件是用户界面的定义。如果在文本编辑器中打开它们,可以看到它们是 XML 文件。
注意如果你习惯于使用 Qt 和 Designer 的早期版本,你会注意到事情已经发生了变化。Qt 4 带来了一个全新的设计器应用程序,以及一种全新的从应用程序代码中使用设计的方法。您不能再使用设计器向项目中添加代码;相反,您可以从代码中使用 Designer 的结果。
通过在项目文件中包含对这些 XML 文件的引用(如清单 2-1 所示),在构建项目时会自动生成一个 C++ 文件。如果设计器文件名为foo.ui
,则生成的 C++ 文件名为ui_foo.h
。如果设计的表单被命名为FooDialog
,那么产生的类就是Ui::FooDialog
。
注意Ui::FooDialog
被放在Ui
名称空间中以避免名称空间冲突,因为你可能想要调用你的最终对话框类FooDialog
。生成的文件也在全局名称空间中创建了一个类。它叫做Ui_FooDialog
,和Ui::FooDialog
一模一样。我更喜欢使用来自Ui
名称空间的类,因为它感觉比在类名前面加上Ui_
更正确,但是您可以自由地做您想做的。
生成的 C++ 文件由用户界面编译器(uic)创建。它与构建过程的交互有点像元对象编译器,但它不是采用 C++ 头文件,而是采用用户界面的 XML 描述。图 2-27 显示了它们是如何组合在一起的。通过使用 QMake 来生成 Makefile,一切都是自动处理的。
**图 2-27。**Qt 项目是由源代码、生成的元对象和用户界面描述构建而成的。
在 Qt 应用程序中,所有对话框都继承自QDialog
类。uic 生成的代码不继承该类;事实上,它甚至没有继承QObject
。结论是你必须创建一个基于QDialog
的类。让我们从查看列表对话框开始。
清单 2-2 显示了列表对话框的头文件。创建了一个名为ListDialog
的类,它继承了QDialog
。该类有插槽,所以Q_OBJECT
宏必须在那里。然后,在最后,Ui::ListDialog
类被用来创建私有成员变量ui
。
清单 2-2。 头文件为 ListDialog
类
#ifndef LISTDIALOG_H
#define LISTDIALOG_H
#include <QDialog>
#include "ui_listdialog.h"
class ListDialog : public QDialog
{
Q_OBJECT
public:
ListDialog();
private slots:
void addItem();
void editItem();
void deleteItem();
private:
Ui::ListDialog ui;
};
#endif // LISTDIALOG_H
ui
对象由一组指针组成,指向组成对话框的所有小部件和布局。它还包含两个函数:setupUi
(用于用窗口小部件和布局填充QDialog
)和retranslateUi
(用于国际化应用程序——在第十章中有更详细的介绍)。
ListDialog
构造器的实现展示了如何使用ui
对象(参见清单 2-3 )。首先,调用setupUi
来创建对话框的 UI。当调用setupUi
时,在 Designer 中建立的连接被设置。其余的连接通过调用connect
手动完成。在调用中,ui
对象用于访问对话框中的小部件。
不需要手动连接。通过实现一个名为on_addButton_clicked()
的插槽,setupUi
调用自动将来自addButton
的clicked
信号连接到那个插槽。这适用于使用on_
widget name_signal name( signal arguments )
方案命名的所有插槽。即使这是可能的,我也建议不要使用它,因为它不鼓励为插槽提供清晰的名称来反映它们的功能。此外,当连接几个信号导致相同的动作时,这种方法会失败。您最终会有几个插槽调用同一个函数,或者——更糟糕的是——包含相同的代码。在对话框类的构造器中建立所有的连接确保了代码易于理解和阅读——您刚刚创建了一个表格,显示了用户界面如何连接到执行实际工作的插槽。
清单 2-3。ListDialog
类的构造器
ListDialog::ListDialog() : QDialog()
{
ui.setupUi( this );
connect( ui.addButton, SIGNAL(clicked()), this, SLOT(addItem()) );
connect( ui.editButton, SIGNAL(clicked()), this, SLOT(editItem()) );
connect( ui.deleteButton, SIGNAL(clicked()), this, SLOT(deleteItem()) );
}
注意 除了这里显示的方法之外,还有更多方法可以使用在 Designer 中从QDialog
对象创建的 UI。这里使用的方法叫做单一继承方法。在 Designer 用户手册中,描述了两种替代方法:多重继承方法*(继承QDialog
和Ui
类)和直接方法*(使用对话框从方法中创建一个QDialog
和一个Ui
)。我更喜欢使用单一继承方法,并将在本书中通篇使用。它通过ui
对象将生成的代码与手动编写的源代码分开——这有助于使更改更加可控。如果你想的话,请随意查阅设计者用户手册并尝试其他选择。**
清单 2-4 显示了addItem
插槽的实现。该函数看起来非常简单,使用了EditDialog
类(还没有讨论)。在继续之前,让我们看看对话框是如何使用的。首先,创建了dlg
变量。传递给EditDialog
的this
指针将dlg
的父指针设置为列表对话框。然后调用对话框的exec
方法,该方法显示处于应用程序模态状态的对话框。一个对话框是应用程序模态的,这意味着在该对话框关闭之前,应用程序的其他对话框或窗口都不能获得 UI 焦点,这迫使用户使用或关闭显示的对话框。
exec
方法从对话框返回一个状态,其中Qt::Accepted
意味着 OK 按钮是最后被点击的(或者说accept
插槽被调用来关闭对话框)。另一个可能的结果是Qt::Rejected
,意味着对话框从标题栏被关闭或取消。
当使用exec
显示对话框,并且结果是Qt::Accepted
时,一个新的项目被添加到列表小部件:ui.list
。新条目是使用编辑对话框中的name
和number
getter 成员构建的(你将在本章后面看到它们)。
清单 2-4。 向列表添加新项目
`void ListDialog::addItem()
{
EditDialog dlg( this );
if( dlg.exec() == Qt::Accepted )
ui.list->addItem( dlg.name() + " -- " + dlg.number() );
}`
添加一个新条目的反义词如清单 2-5 所示。删除一个列表小部件条目只需要在上面调用delete
就可以了。当前选中的项目是从currentItem
方法返回的,所以只需删除该方法返回的内容。
如果没有选择任何项目,返回值是0
(零,一个空指针),但是在调用delete
时这不是问题——它只是被忽略。
清单 2-5。 删除列表中的一项
void ListDialog::deleteItem()
{
delete ui.list->currentItem();
}
当试图编辑当前项目时,确保currentItem
是一个有效的指针是很重要的,这就是为什么清单 2-6 中的editItem
槽通过检查它开始。如果返回的指针是一个空指针,那么槽不做任何事情就返回。
如果遇到一个有效的指针,那么使用split
方法将当前列表小部件项目的文本分成一个名称和一个数字。它们用于设置编辑对话框。当设置名称和编号时,分割文本的部分被修剪,这意味着从字符串的末端移除所有额外的空白(空白由所有占用空间但不显示的字符组成)。空白的例子有空格、制表符、换行符、换行符等等。
编辑对话框一旦建立,代码看起来就非常像addItem
槽,只是当前项目的文本被改变,而不是向列表小部件添加新的项目。
清单 2-6。 编辑列表中的一项
void ListDialog::editItem()
{
if( !ui.list->currentItem() )
return;
QStringList parts = ui.list->currentItem()->text().split( "--" );
EditDialog dlg( this );
dlg.setName( parts[0].trimmed() );
dlg.setNumber( parts[1].trimmed() );
if( dlg.exec() == Qt::Accepted )
ui.list->currentItem()->setText( dlg.name() + " -- " + dlg.number() );
}
现在你已经使用了两次编辑对话框,所以是时候看看它了。在清单 2-7 中,你可以看到类声明。EditDialog
类继承了QDialog
,并有一个名为ui
的私有变量,包含用户界面的生成代码。这很像ListDialog
级。
该类包含两个属性的 getters 和 setter:name
和number
。因为对话框是专门为应用程序设计的,根本不可能在其他环境中重用,所以我冒昧地避开了 getters 和 setters 的策略。设置器不是插槽,也没有在属性改变时发出的任何信号。当一个类显然不会被重用时,过度设计它以使其可重用是没有意义的。
因为没有信号或插槽,所以省略了Q_OBJECT
宏,所以该类没有元对象。这可以在运行时节省内存,并使编译稍微快一些。
清单 2-7。 编辑对话框类
class EditDialog : public QDialog
{
public:
EditDialog( QWidget *parent=0 );
const QString name() const;
void setName( const QString& );
const QString number() const;
void setNumber( const QString& );
private:
Ui::EditDialog ui;
};
如清单 2-8 所示,构造器非常简单。因为所有的连接都是在 Designer 中完成的,所以只需要对setupUi
进行一次调用。查看 Designer 中的连接,您会看到来自按钮盒的accepted
和rejected
信号连接到accept
和reject
插槽。当用户点击确定时发出accepted
信号,取消时发出rejected
。accept
和reject
插槽将从exec
返回的结果设置为Qt::Accepted
或Qt::Rejected
,然后关闭对话框。这意味着从调用者的角度来看,对话已经按预期工作了。
清单 2-8。 编辑列表中的一项
EditDialog::EditDialog( QWidget *parent ) : QDialog( parent )
{
ui.setupUi( this );
}
name
和number
属性以相同的方式实现。在清单 2-9 中,显示了name
属性。设置器setName
很简单,只是将值传递给右边的QLineEdit
。getter,name
,稍微复杂一些。它不是简单地从行编辑中返回文本,而是使用replace
删除所有出现的双破折号("--"
)。所有出现的双破折号都被替换为空字符串,这与删除它们是一回事。它们必须被删除,因为在列表对话框中名称和编号被双破折号分开,编辑槽editItem
(见清单 2-9 )依赖于此。在返回没有双破折号的字符串之前,它还调用trimmed
来删除文本末尾的任何空格。这可以防止用户不小心在名称后留下空格或制表符。
清单 2-9。 编辑列表中的一项
const QString EditDialog::name() const
{
return ui.nameEdit->text().replace("--","").trimmed();
}
void EditDialog::setName( const QString &name )
{
ui.nameEdit->setText( name );
}
number
属性的实现看起来与name
属性的实现相同。唯一不同的是所涉及的QLineEdit
的名称:nameEdit
用于名称,numberEdit
用于编号。
最后一笔
现在项目文件中唯一缺少的部分是main
函数。在清单 2-10 中,你可以看到实现。首先,创建一个QApplication
对象;然后创建列表对话框。在应用程序的exec
方法被调用之前,这个对话框就会显示出来。
调用exec
意味着QApplication
对象开始处理系统事件,并将它们传递给适当的QObject
实例——应用程序是事件驱动的。该函数在所有窗口和对话框关闭后立即返回,因此当您关闭列表对话框时,exec
返回,应用程序到达其结尾。
清单 2-10。 编辑列表中的一项
int main( int argc, char **argv )
{
QApplication app( argc, argv );
ListDialog dlg;
dlg.show();
return app.exec();
}
回头看看您希望用户能够执行的用户操作列表,您可以看到大多数操作都是由一个连接表示的。可以在设计器中建立连接,也可以在对话框类的构造器中使用connect
调用来建立连接。让应用程序运行的最后一步是main
函数。它的工作是显示列表对话框并启动事件循环。
为了测试这个应用程序,首先在您开始的项目文件上运行qmake
来生成一个 Makefile。现在使用make
或您的系统的等价物构建应用程序,这会为您生成一个可执行文件。在图 2-28 中,我第一次测试这个应用程序——看起来一切正常。
该应用程序不是很有用,因为它不能保存和加载数据。但是,用户界面功能齐全。
图 2-28。 应用程序投入使用。
总结
本章展示了 Qt 应用程序中可用的两类对话框:主动的或被动的;聪明还是愚蠢。
列表对话框包含用户可以执行的每个操作的位置。这称为主动或智能对话。任何需要用户尽可能简单的输入的对话框都可以激活。小的活动元素可以使对话框更容易使用。
编辑对话框不包含任何插槽;它仅仅依赖于内置在所使用的小部件和accept
和reject
插槽中的智能。这对于非常简单的对话框来说已经足够了,在这些对话框中,用户可以填写不同类型的字段。这被称为被动或无声对话。一个应用程序中有几个被动对话框是很常见的;事实上,没有它们,应用程序就无法工作。
尽管编辑对话框对用户来说是被动的,但它对开发人员来说也不一定是被动的。编辑对话框使用name
和number
属性很好地隐藏了图形用户界面的实际实现。这使得保持ui
变量私有成为可能,代价是几行琐碎的代码。通过这样做,您可以确保在不使用编辑对话框更改代码的情况下更改 UI。在将来维护和扩展应用程序时,将应用程序的 UI 和代码分开通常会有所帮助。*
三、小部件和布局
ll 图形用户界面(ui)围绕使用布局排列的小部件构建。在这一章中,你将学习 Qt 提供了哪些小部件以及它们是如何使用的。您还将了解如何使用布局来创建所需的设计。本章在直接使用代码和使用 Designer 可视化地构建用户界面之间切换,这将教您理解 Designer 生成的代码。
**### 在 Qt 中创建对话框
正如你在上一章中了解到的,对话框是一个顶层窗口,所有的对话框都是由小部件构建的。此外,小部件是使用布局来组织的,这使得构建灵活的对话框成为可能。
布局有助于使 Qt 与众不同。使用布局可以很容易地构建适应屏幕分辨率、字体大小和不同语言变化的对话框。使用布局的另一种方法是静态布局,它确保所有的小部件都有一个大小和位置。因此,如果一个译者想在不同的语言中使用不同长度的文本,对话的设计必须适应最长的文本。使用布局,设计描述了部件的相对位置,而不是它们的绝对大小和位置。然后,小部件告诉布局它们需要多少空间,并相应地放在对话框中。
让我们通过使用 Designer 开始探索。启动设计器,从底部模板的按钮创建一个新的对话框。然后在对话框中添加一个分组框,一个行编辑,一个标签,一个垂直间隔符,如图图 3-1 所示。确保行编辑和标签在分组框内。你可以试着移动分组框。如果其他部件在其中,它们应该和分组框一起移动。
图 3-1。 小部件拖放到对话框表单上
选择分组框并应用水平布局;然后选择对话框表单本身并应用垂直布局。你的对话框现在看起来应该类似于图 3-2 。
图 3-2。 布局已经应用。
图 3-3 显示了对话框的对象检查器。包含其他小部件的所有小部件也具有布局的信息是不可见的。
图 3-3。 对象检查器,显示对话框中的小部件
只是为了测试布局的概念,试着输入supercalifragilisticiexpalidocious作为标签文本(使用鼠标右键调出上下文菜单,并从菜单中选择更改文本)。如图 3-4 所示,标签展开,行编辑器收缩。
图 3-4。 标签文字变成 supercalifaristiceexpalidocious。
规模政策
那么在这个例子中到底发生了什么呢?当计算小部件的大小时,布局查看小部件的大小提示和大小策略。如果您在 Designer 中查看sizePolicy
属性,您可以看到标签在水平和垂直方向上都有一个Preferred
大小类型(hSizeType
和vSizeType
)。线条编辑器有一个Fixed
高度(垂直方向),但有一个Expanding
宽度(水平方向)。这一切意味着什么?
每个小部件在运行时计算一个大小提示——小部件的首选大小。它还具有控制它可以接受的最小和最大尺寸的属性(minimumSize
和maximumSize
属性)。
当一个小部件说它的尺寸策略是在一个方向上保持一个Preferred
尺寸时,这意味着如果需要的话,它可以变得比尺寸提示更大或更小,但是不喜欢这样。它不想增长,除非被布局和周围的小部件强迫。例如,如果用户增加窗口的大小,而周围的窗口小部件被配置为不增长,则窗口小部件会增长超过其首选大小。
行编辑有一个Fixed
高度,所以小部件的高度是不可协商的;它总是使用大小提示来表示大小。Expanding
策略意味着小部件可以缩小,但更喜欢尽可能大;它想要成长。
有几种政策可供选择(总结在表 3-1 )。
表 3-1。 大小政策及其行为
| **规模政策** | **可以生长** | **可以收缩** | **想要成长** | **使用尺寸提示** | | --- | --- | --- | --- | --- | | `Fixed` | 不 | 不 | 不 | 是 | | `Minimum` | 是 | 不 | 不 | 是 | | `Maximum` | 不 | 是 | 不 | 是 | | `Preferred` | 是 | 是 | 不 | 是 | | `Expanding` | 是 | 是 | 是 | 是 | | `MinimumExpanding` | 是 | 不 | 是 | 是 | | `Ignored` | 是 | 是 | 是 | 不 |您可以通过在 Designer 中使用大小策略来了解它们的作用,因为一旦您将布局应用到小部件,策略更改就会直接反映在表单中。首先将标签的水平尺寸类型设置为Expanding
,这使得标签和行编辑尽可能大,以便它们共享给定的空间。您也可以将策略设置为Maximum
,然后尝试改变对话框的宽度。使用规模调整策略和布局是一项技能,技能是通过实践来学习的,所以不要害怕尝试。
提示你也可以为间隔符设置大小策略和大小提示,这对于加强空间和将对话框项目组合在一起非常有用。
在代码中设置大小策略
现在,您已经了解了使用 Designer 的布局和大小策略的基本知识。你如何用代码实现同样的事情?知道如何做到这一点很重要,因为由 Designer 生成的文件被 uic 工具转换成代码。要使用这些文件并解决编译问题,您需要了解文件中包含的内容。您也可能直接在代码中创建更小的用户界面元素,因为在这种情况下使用 Designer 是多余的。
当我用代码创建对话框时,我试图将我做的事情分组到逻辑组中——所以首先我创建所有的小部件(如清单 3-1 所示)。我不想给任何部件分配父部件,因为一旦部件被放入布局中,布局就会对部件负责。
清单 3-1。 小部件被创建。
QDialog dlg;
QGroupBox *groupBox = new QGroupBox( "Groupbox" );
QLabel *label =
new QLabel( "Supercalifragilisticexpialidocious" );
QLineEdit *lineEdit = new QLineEdit;
QDialogButtonBox *buttons =
new QDialogButtonBox( QDialogButtonBox::Ok |
QDialogButtonBox::Cancel );
下一步是将小部件放到布局中。与 Designer 中的对话框一样,您可以使用垂直布局和水平布局。从上往下看清单 3-2 ,你会看到它从水平布局开始。代表水平布局的 Qt 类是QHBoxLayout
,其中H
代表水平方向。您可以看到它将应用于groupBox
,因为它是作为父级传递的。然后从左到右添加小部件,首先添加label
,然后添加lineEdit
。当它们被添加时,hLayout
成为它们的父项,它们被放置在分组框内的父项中。
QVBoxLayout
(用于管理垂直布局)应用于对话框本身。其中,小部件是自上而下添加的。首先添加分组框;然后添加间隔物。间隔不会作为小部件添加;事实上,没有间隔小部件。通过调用addStretch
方法,一个QSpacerItem
被插入到布局中。这个项目作为一个间隔,所以效果是一样的,当你使用设计师。最后buttons
被添加到布局的底部。
清单 3-2。 把小部件摆好。
QHBoxLayout *hLayout = new QHBoxLayout( groupBox );
hLayout->addWidget( label );
hLayout->addWidget( lineEdit );
QVBoxLayout *vLayout = new QVBoxLayout( &dlg );
vLayout->addWidget( groupBox );
vLayout->addStretch();
vLayout->addWidget( buttons );
这两个列表都会导致如图 3-4 所示的对话框。如果您想使用代码中的布局策略,您需要知道要使用哪些属性和方法。所有小部件都有一个sizePolicy
属性,由一个QSizePolicy
对象表示。minimumSize
和maximumSize
属性是QSize
对象。
提示当我引用一个属性名时,例如sizePolicy
,可以理解为有一个 getter 方法叫做sizePolicy
,还有一个 setter 方法叫做setSizePolicy
。有一些没有 setter 的只读属性,但它们并不常见。
让我们从通过代码设置自定义大小策略开始。清单 3-3 展示了如何复制、修改和应用定制策略。首先,复制来自label
的大小策略。拉伸系数最好为1
。拉伸系数会更改,并且策略会应用于标签。然后拉伸因子被设置为1
,策略被应用到lineEdit
。
清单 3-3。 修改和应用自定义策略
QSizePolicy policy = label->sizePolicy();
policy.setHorizontalStretch( 3 );
label->setSizePolicy( policy );
policy = lineEdit->sizePolicy();
policy.setHorizontalStretch( 1 );
lineEdit->setSizePolicy( policy );
清单 3-3 中的代码显示了两件事。首先,它向您展示了如何使用sizePolicy
和setSizePolicy
来复制和应用策略。它还显示了拉伸因子,使用这些因子可以控制对话框中小部件的相对大小。显示了三个按钮(见图 3-5 ,所有按钮都被分配了水平尺寸策略Preferred
。它们的拉伸系数是(从左到右)1、3 和 2。这意味着第一个按钮占用可用宽度的 1/(1+3+2)-六分之一;第二个按钮取 3/(1+3+2)—二分之一;而第三个用 2/(1+3+2)—三分之一。
图 3-5。 带拉伸系数的按钮(从左到右:1、3 和 2)
布局
到目前为止,您已经了解了大小策略,并使用了水平和垂直布局。在 Designer 中,你可以获得三种最常见的布局:水平、垂直和网格。
通过类QHBoxLayout
(水平的)和QVBoxLayout
(垂直的)可以得到盒子布局(你已经看过几次了)。他们只是将小部件从左到右或从上到下排成一行或一列。图 3-6 和 3-7 显示了这两个类别的运行情况。在示例中,小部件按以下顺序添加:foo
、bar
、baz
。当与拉伸因子和大小策略结合使用时,它们可以用作许多不同对话框布局的基础。
提示如果需要,您可以使用setDirection
方法改变小部件的添加方向。这意味着您可以从右到左向水平布局添加小部件,或者向上向垂直布局添加小部件。
图 3-6。 横框布局
图 3-7。 垂直方框布局
盒子布局中更强大的老大哥是网格布局QGridLayout
。使用网格布局,您可以将小部件添加到类似表格的网格中。默认情况下,每个 widget 占用一个表格单元格,但是您可以让它跨越多个单元格。清单 3-4 向你展示了如何用三个按钮填充一个网格布局,最终的布局如图 3-8 所示。小部件是通过使用addWidget( QWidget *widget, int row, int col, int height=1, int width=1)
方法添加的。bar
和baz
按钮被添加到下一行的单元格中,并在两个方向上跨越一个单元格。foo
按钮更大(它跨越两个单元格宽),从左上角开始——第一行第一列。
清单 3-4。 网格布局被填充。
QGridLayout layout( &widget );
layout.addWidget( new QPushButton( "foo" ), 0, 0, 1, 2 );
layout.addWidget( new QPushButton( "bar" ), 1, 0 );
layout.addWidget( new QPushButton( "baz" ), 1, 1 );
图 3-8。 网格布局
对于布局,所涉及的小部件的尺寸策略起着重要的作用。例如,默认情况下,按钮部件Fixed
在垂直方向。这意味着如果你从清单 3-4 的中旋转布局,使列成为行(反之亦然),结果将看起来像图 3-9 。按钮不会拉伸以填充两个单元格;相反,它垂直居中,但保持小部件的大小提示的高度。
图 3-9。 带有固定高度小部件的网格布局
可以使用其他布局类,但直接使用它们并不常见。方框布局和网格布局通常是你所需要的;结合拉伸因子和尺寸策略,你可以构建几乎任何可以想象的对话框布局。
提示你想尝试大小政策和布局吗?在设计器中执行此操作,以便在更改属性值时立即收到可视反馈。
常用小工具
所有的用户界面都是从布局和小部件开始的,几乎所有的用户操作都是从小部件开始的,所以在设计应用程序时,了解可用的小部件非常重要。
本节介绍了最常见的小部件,以及它们在主要平台上的截图。您还将了解密切相关的小部件以及每个小部件最有用的信号和插槽。
qushbutton
按钮是对话框中最常见的按钮。由于它的标准行为(它只是对点击做出反应),最有趣的信号是clicked()
。如果希望按钮在按下和释放状态之间切换,可以将checkable
属性设置为true
。这样做使toggled(bool)
信号变得有趣,因为它携带了当前状态,并指示已经发生了点击。
清单 3-5 显示了一个对话框的实现。在构造器中,创建了两个按钮:一个普通按钮和一个切换按钮。按钮以水平布局放置,它们的信号连接到对话框的两个插槽。定制插槽使用来自QMessageBox
类的静态information
方法来显示消息。
提示在buttonToggled
槽中,QString arg
方法用于组合两个字符串。原始字符串中的%1
被赋予arg
的参数替换。您可以通过重复调用arg
来连接几个(但不超过九个)字符串。例如,QString("%1 %3 %2").arg("foo").arg("bar"). arg("baz")
产生字符串"foo baz bar"
。
清单 3-5。 按钮控件的基本演示
ButtonDialog::ButtonDialog( QWidget *parent ) : QDialog( parent )
{
clickButton = new QPushButton( "Click me!", this );
toggleButton = new QPushButton( "Toggle me!", this );
toggleButton->setCheckable( true );
QHBoxLayout *layout = new QHBoxLayout( this );
layout->addWidget( clickButton );
layout->addWidget( toggleButton );
connect( clickButton, SIGNAL(clicked()), this, SLOT(buttonClicked()) );
connect( toggleButton, SIGNAL(clicked()), this, SLOT(buttonToggled()) );
}
void ButtonDialog::buttonClicked()
{
QMessageBox::information( this, "Clicked!", "The button was clicked!" );
}
void ButtonDialog::buttonToggled()
{
QMessageBox::information( this, "Toggled!",
QString("The button is %1!")
.arg(toggleButton->isChecked()?"pressed":"released") );
}
不同的平台在对话框底部有不同的按钮位置。例如,在 Mac 或 Gnome 桌面中,最右边的按钮是接受按钮(Ok),而在 Windows 中,最右边的按钮通常是关闭或取消。通过使用QDialogButtonBox
小部件,可以自动获取普通按钮。您也可以使用addButton
添加自己的按钮,并赋予它们一个角色。当你告诉 Qt 哪个按钮有HelpRole
哪个按钮有ApplyRole
时,按钮被放置在用户期望的位置。
清单 3-6 显示了使用按钮框的对话框的一小部分。首先,创建一个带有方向的按钮框——它可以是Horizontal
或Vertical
。然后创建一个按钮,并将其连接到对话框中的一个槽,然后用QDialogButtonBox
角色将其添加到按钮框中。图 3-10 显示了 Windows XP 系统上出现的对话框。将此与图 3-11 进行比较,在图 3-11 中,样式被强制为 clean looks——Gnome 桌面的样式。排序适应当前样式,这使得用户体验更好,因为用户可以坚持旧习惯,而不是在点击之前阅读所有按钮上的文本。
清单 3-6。 创建一个按钮,连接它,然后将它与一个角色一起添加到一个按钮框中
QDialogButtonBox *box = new QDialogButtonBox( Qt::Horizontal );
button = new QPushButton( "Ok" );
connect( button, SIGNAL(clicked()), this, SLOT(okClicked()) );
box->addButton( button, QDialogButtonBox::AcceptRole );
注意不要把按钮连接到清单 3-6 中的插槽,你可以把按钮盒的角色连接成这个connect(box, SIGNAL(accepted()), this, SLOT(okClicked()))
。
图 3-10。 一个 QDialogButtonBox
带有 Windows XP 风格的按钮
图 3-11。 一个 QDialogButtonBox
带有 Windows XP 风格的按钮
QLabel
标签小部件是最常见的小部件之一,用于显示帮助用户更好地理解对话框的文本。当使用QLabel
时,可以通过在标签文本中输入一个&符号,就在您想要作为助记符的字母之前,给它一个键盘快捷键或助记符。比如将文字设置为"E&xit"
,助记符为x
,键盘快捷键为 Alt+x
通过使用setBuddy(QWidget*)
将一个好友小部件分配给标签,用户通过按助记键将焦点移动到该小部件。清单 3-7 显示了这一点,其中两个标签成为两行编辑的伙伴。
如果您正在使用 Designer,可以从工作模式工具栏进入好友编辑模式。您可以通过绘制箭头将标签连接到它们的好友小部件,就像您进行信号和插槽连接一样。
清单 3-7 显示了一个对话框是如何在一个网格布局中被两个标签和两行编辑填充的。每个线编辑都将标注指定为伙伴。如果您尝试运行该示例,您会发现您可以使用 Alt 键和相关标签的助记符在各行编辑之间移动。
清单 3-7。 标签和线编辑为好友
` QDialog dlg;
QLabel *fooLabel = new QLabel( "&Foo:" );
QLabel *barLabel = new QLabel( "&Bar:" );
QLineEdit *fooEdit = new QLineEdit;
QLineEdit *barEdit = new QLineEdit;
fooLabel->setBuddy( fooEdit );
barLabel->setBuddy( barEdit );
QGridLayout *layout = new QGridLayout( &dlg );
layout->addWidget( fooLabel, 0, 0 );
layout->addWidget( fooEdit, 0, 1 );
layout->addWidget( barLabel, 1, 0 );
layout->addWidget( barEdit, 1, 1 );`
QLineEdit
行编辑用于使用户能够编辑单行文本。(对于多行文本,使用QTextEdit
小部件。)最常见的用途是让用户输入文本,但您也可以将其用作密码。只需将echoMode
属性设置为Password
,输入的文本就会显示为星号。
你可以用setText(const QString&)
设置行编辑的文本,用text()
得到。每当文本改变时,你可以连接到textChanged(const QString&)
信号。
如果您想确保用户不会在字段中输入整篇文章,您可以使用maxLength
属性来限制文本的长度。
要试用行编辑小部件,您可以在 Designer 中测试它。首先创建一个有六行编辑和四个标签的对话框,如图图 3-12 所示。图中显示了左栏中每个行编辑的textChanged
信号连接到右栏中相应小部件的setText
插槽的连接。然后,每一行的标签会告诉您左列中的每一行编辑更改了什么属性。
提示如果您想了解一个小部件,尝试使用它的属性并做一个预览(Ctrl+R)来看看它在运行时的行为。通过这种方式,您可以快速获得关于所做更改的反馈。
图 3-12。 线条编辑小部件演示对话框及其连接
图 3-13 显示了对话框在预览模式下的样子。中间一排的密码是隐藏的,最下面一排的长度有限。
图 3-13。 线条编辑小工具演示在行动
QCheckBox
用户可以选中或取消选中复选框。该类通过一个公共基类与按钮小部件相关,因此编程接口应该是熟悉的。
在默认模式下,您可以使用isChecked()
方法来判断复选框是否被选中。在某些情况下,您可能希望有三种状态:未选中、未定义和选中(使用tristate
属性来实现)。在这种模式下,你必须使用checkState
属性来了解状态。
当检查状态改变时,发出stateChanged(int)
信号。对于非tristate
复选框,您可以连接到toggled(bool)
信号。
qradio
单选按钮是复选框的近亲。它的工作方式类似于复选框,只是每次只能选中一个组中的一个。选中组中的一个框后,就不能取消选中。您只能在组内移动它。这意味着,如果您在初始化对话框时以编程方式选中了一个框,就可以保证其中一个框始终处于选中状态。要监控按钮的状态,使用toggled(bool)
信号和isChecked
方法。
一组单选按钮由具有相同父部件的所有按钮组成。您可以使用分组框将按钮分成不同的组,这也可以在按钮周围放置一个带有标题的漂亮框架。如果你不想直观地分割它们,你可以使用一个QButtonGroup
,如清单 3-8 中的所示。图 3-14 表明不在视觉上区分它们可能是个坏主意。
清单可以分为三个部分。首先,创建分组框和按钮;然后使用addButton
方法将按钮添加到它们各自的按钮组中。按钮组不以任何方式初始化按钮;它只是确保一次最多选中一个单选按钮。清单的第三部分也是最后一部分是使用addWidget
创建网格并在网格中放置按钮。
清单 3-8。 创建四个单选按钮;然后将它们放入按钮组和布局中
QGroupBox box( "Printing Options" );
QRadioButton *portrait = new QRadioButton( "Portrait" );
QRadioButton *landscape = new QRadioButton( "Landscape" );
QRadioButton *color = new QRadioButton( "Color" );
QRadioButton *bw = new QRadioButton( "B&W" );
QButtonGroup *orientation = new QButtonGroup( &box );
QButtonGroup *colorBw = new QButtonGroup( &box );
orientation->addButton( portrait );
orientation->addButton( landscape );
colorBw->addButton( color );
colorBw->addButton( bw );
QGridLayout *grid = new QGridLayout( &box );
grid->addWidget( portrait, 0, 0 );
grid->addWidget( landscape, 0, 1 );
grid->addWidget( color, 1, 0 );
grid->addWidget( bw, 1, 1 );
图 3-14。 分组框中的四个单选按钮。你能分辨出哪一组和哪一组吗?
QGroupBox
您可以使用分组框来组织对话框的内容。它提供了一个带有标题的框架,您可以在其中放置其他小部件。分组框是一个被动的小部件,只作为其他小部件的容器。
如果您希望能够打开或关闭由分组框中的小部件控制的选项,您可以使用checkable
属性使其可检查(这意味着标题中将显示一个复选框)。取消选中该复选框时,其内容被禁用,用户无法使用。可检查分组框有isChecked()
方法和toggled(bool)
信号。
图 3-15 显示了一个从设计器运行的简单预览。我创建了三个复选框,每个都有一个按钮。最左边的分组框是不可勾选的,看起来和预期的一样,你可以点击它里面的按钮。
图 3-15。 分组框:不可勾选、可勾选(已勾选)、未勾选
中间和最右边的分组框是可勾选的——一个被勾选,另一个不被勾选。在未选中的组中,按钮被禁用,用户不能使用它。这是自动发生的;没有信号连接。所有需要的是按钮在分组框内。
注意在设计器中设置属性时,它们可能设置得太早。例如,如果在“分组框示例”对话框中将checked
属性设置为false
,按钮将保持启用状态。这是因为按钮是在checked
属性被设置后添加到分组框中的,因此保持不变(因为分组框在toggled
信号上启用和禁用所有包含的小部件)。相反,在设计器中创建对话框,但是在源代码中调用setupUi
之后初始化所有用户可修改的属性。
QListWidget
Qt 有列表、表格和树的小部件。本章仅限于列表小部件,因为 Qt 有一个非常强大的方法来使用模型和视图处理列表、表格和树(在第五章中有详细介绍)。
list 小部件用于向用户显示项目列表。您可以使用addItem(const QString&)
或addItems(const QStringList&)
方法向列表中添加小部件。当用户改变当前项目时,你可以通过连接currentItemChanged (QListWidgetItem *, QListWidgetItem *)
或currentTextChanged(const QString&)
信号来判断。请注意,并不总是需要选择当前项目,这取决于选择模式。
使用selectionMode
属性,您可以让用户只选择一个项目、一系列连续的项目或所有项目。每当选择改变时,就会发出itemSelectionChanged
信号。
列表视图的项目可以从文本字符串添加到列表中,但它们存储为QListWidgetItem
对象。这些对象归列表小部件所有,当列表小部件被析构时会被自动删除。如果你想从列表中删除一个项目,只需使用currentItem
属性或item(int row)
方法找到它;然后delete
它。
清单 3-9 展示了一个带有列表部件的对话框是如何设置的例子。首先,创建一个带有小部件的布局——两个列表小部件和两个用于在列表之间移动项目的按钮。之后,按钮被连接到 dialog 类中的插槽,这些插槽在填充列表之前执行项目的实际移动。图 3-16 显示了正在使用的列表对话框。
清单 3-9。 创建并填充列表小部件
`ListWidgetDialog::ListWidgetDialog() : QDialog()
{
QPushButton *left, *right;
QGridLayout *layout = new QGridLayout( this );
layout->addWidget( left = new QPushButton( "<<" ), 0, 1 );
layout->addWidget( right = new QPushButton( ">>" ), 1, 1 );
layout->addWidget( leftList = new QListWidget, 0, 0, 3, 1 );
layout->addWidget( rightList = new QListWidget, 0, 2, 3, 1 );
connect( left, SIGNAL(clicked()), this, SLOT(moveLeft()) );
connect( right, SIGNAL(clicked()), this, SLOT(moveRight()) );
QStringList items;
items << "Argentine" << "Brazilian" << "South African"
<< "USA West" << "Monaco" << "Belgian" << "Spanish"
<< "Swedish" << "French" << "British" << "German"
<< "Austrian" << "Dutch" << "Italian" << "USA East"
<< "Canadian";
leftList->addItems( items );
}`
图 3-16。 动作中的列表小部件对话框
清单 3-10 展示了项目是如何在两个列表部件之间移动的。该代码显示了将项目从左侧列表移动到右侧列表的位置。首先,使用selectedItems().count()
方法来确定是否真的有东西要移动。takeItem(int)
方法用于从一个列表小部件中移除一个项目,而不必删除它。这个方法告诉 list 小部件您负责管理该项,并将其从 list 小部件中移除。然后,您可以使用addItem(QListWidgetItem*)
方法将项目添加到另一个列表小部件中。这种方法使您能够在列表小部件之间移动项目,而无需删除或创建任何内容。
清单 3-10。 用于从右向左移动物品的插槽
`void ListWidgetDialog::moveLeft()
{
if( rightList->selectedItems().count() != 1 )
return;
QListWidgetItem *item = rightList->takeItem( rightList->currentRow() );
leftList->addItem( item );
}`
Q 组合盒
当只显示当前项目时,组合框可以像列表小部件一样使用。另一种用途是为用户提供一个项目列表,但也使他们能够编写自己的文本。您可以通过使用editable
属性来控制用户是否可以输入自定义文本。
当用户从列表中选择一个项目时,会发出activated(int)
和activated(const QString&)
信号。
提示如果你想在通过代码改变当前项目以及用户选择项目时发出信号,使用currentIndexChanged
。只有当用户改变当前项目时,才会发出activated
信号。
您还可以使用currentIndex
和currentText
属性来查找当前项目。当用户输入自定义文本时,您可以通过连接到editTextChanged(const QString&)
信号来检测它。
组合框小部件的一个常见用途是使用户能够在字处理器中选择字体和大小。为了选择字体,Qt 从 4.2 版本开始就有了QFontComboBox
小部件,它以正确的字体显示每个列表项。
QSpinBox
当您希望用户在给定范围内选择一个具有某种精度的数字时,数字显示框是理想的选择。因为它只允许用户输入一个值,所以它是精确的。同时,用户可以通过单击上下箭头来更改该值。如果给出某种反馈,箭头可以用来试验不同值的效果。
默认情况下,范围是 0 到 99,每次单击其中一个箭头都会将值改变 1。您可以通过更改minimum
和maximum
属性来更改范围。同样,singleStep
属性表示每次点击从当前值中增加或减少了多少。请注意,即使单步大小大于 1,用户仍然可以在框中输入任何值。
提示不要调用setMinimum(min)
和setMaximum(max)
,可以调用setRange(min,max)
,这样可以让代码可读性更好,也省去了你键入整行代码的麻烦。
当数字显示框的值改变时,它发出valueChanged(int)
信号。如果你想连接一些东西到旋转盒,可以使用setValue(int)
插槽。
为了测试数字显示框部件,我把一个对话框放在一起,这个对话框由一个 LCD 数字(QLCDNumber
)和一个数字显示框组成(见图 3-17 )。数字显示盒的valueChanged
信号已经连接到 LCD 数字的display(int)
插槽。您可以通过更改singleStep
属性、键入数字、使用箭头键上下移动、单击上下按钮,甚至使用上下翻页键来使用数字显示框。您将很快掌握如何控制数字显示框小部件来做您想要做的事情。
图 3-17。 一个数字显示框连接着一个液晶显示值
如果您需要处理更高精度的值,可以使用QDoubleSpinBox
小工具。它的编程接口类似于QSpinBox
的接口,但是decimals
属性使您能够控制值的精度。
对于处理时间、日期或两者的组合,可以使用QTimeEdit
、QDateEdit
和QDateTimeEdit
。它们的工作方式与数字显示框非常相似,但是用户可以分别控制小时、分钟、秒、年、月和日。编程接口相似但不相同。例如,范围由minimumDate
和maximumDate
、和maximumTime
控制。
如果您喜欢使用类似旋转框的小部件来选择日期,您可以使用QCalendarWidget
。它看起来像一个真正的日历,用户可以通过点击它来选择日期。您可以比较图 3-18 中的日历部件和日期编辑部件。哪个更好用?
图 3-18。 一个日历小部件和一个日期编辑小部件
QSlider
滑块的使用方式与数字显示框完全相同:使用户能够在给定的范围内选择一个值。QSlider
类还使用minimum
和maximum
属性来控制控件的范围,并使用setRange
方法来同时更改这两个属性。
说到每次变化的大小,滑块是不一样的。用户可以进行大的改变或小的改变;它们由singleStep
和pageStep
属性控制。当用户单击滑块位置指示器的任一侧时,会进行翻页。为了单步执行,用户必须点击滑块来获得焦点,然后使用键盘的箭头键。就像微调框的步长一样,用户仍然可以通过将位置指示拖动到位来达到单个步长之间的值。
要检测数值变化,连接到valueChanged(int)
信号。
注意使用valueChanged
来避免通过键盘、拖动或点击丢失更改。无论数值为何变化,总会发出valueChanged
信号。
在 Designer 中,slider 小部件显示为两个小部件:水平滑块和垂直滑块。您可以通过使用orientation
属性来控制小部件在Horizontal
和Vertical
之间的方向。
一个非常类似的小部件是QScrollBar
,它告诉用户小部件不仅选择一个值,还选择由滑块大小指示的一系列值。属性指示滑块有多大,并告诉用户选择了多大的范围。
可编程
滑块、滚动条和数字显示框对于让用户选择一个值都很有用,但是进度条可以用来以只读形式显示一个值。您可以使用minimum
和maximum
属性自定义进度条的范围(是的,还有一个setRange(int, int)
方法)。如果你把minimum
和maximum
都设置为零,你会得到一个活动条,它不停地循环,没有一个确定的终点,这对于显示你正在做一些你不能预先判断长度的长任务是很好的。
实际进度是使用setValue(int)
方法设置的,您可以使用reset()
方法将进度条归零。
您可以使用textVisible
属性打开和关闭完成百分比文本,并且可以使用format
属性修改文本以适应您的应用程序。format
属性是一个字符串,其中任何出现的%p
都被替换为当前百分比,%v
被替换为当前值,%m
被替换为maximum
值。
图 3-19 显示了一组在设计器中创建的进度条。对话框顶部的滑块通过valueChanged(int)
到setValue(int)
连接与每个滑块相连。通过移动滑块,您可以设置进度。顶部进度条具有默认样式;即format
属性为%p%
,文本可见。下一个进度条的format
文本设置为"%v out of %m steps completed."
,第三个进度条有隐藏文本。底部的进度条将minimum
和maximum
设置为零,这意味着它会一直移动以显示进度。打印出来的图形并没有显示它在连续移动——不需要调用setValue
或任何其他方法来获得移动。
测试对话框中的最后一个细节是重置按钮。它的clicked
信号连接到所有进度条的reset
槽。单击它时,会重置进度条。这意味着每个进度条的值被设置为零,并且进度条的文本是隐藏的,直到移动滑块时发出的valueChanged(int)
信号改变了进度条的值。
图 3-19。 不同配置的进度条
常见对话框
当让用户做出选择时,有许多用户期望的对话框。有用于打开和保存文件、选择颜色、选择字体等的对话框。这些对话框在 Qt 支持的不同平台上看起来是不同的。
通过使用 Qt 对这些对话框的实现,您可以访问一个类接口,这可以确保您尽可能使用本机版本,并在需要时使用通用版本。
文件
最常见的对话框是用于打开和保存文档的文件对话框。这些对话框都是通过QFileDialog
类访问的。因为对话框被反复用于相同的任务,所以这个类已经配备了一组静态方法来处理对话框的显示(和等待)。
开启
要打开一个文件,使用静态的getOpenFileName
方法。这显示了一个类似于图 3-20 中所示的文件对话框。该方法接受一大堆参数。理解如何使用它的最简单的方法是查看清单 3-11。
**图 3-20。**Windows 平台上打开文件的对话框
清单 3-11。 挑选一个文件打开
QString filename = QFileDialog::getOpenFileName(
this,
tr("Open Document"),
QDir::currentPath(),
tr("Document files (*.doc *.rtf);;All files (*.*)") );
if( !filename.isNull() )
{
...
该方法接受的第一个参数是对话框的父级。该对话框是模态的,因此当它打开时,给定的父级将被阻止与用户交互。第二个参数是窗口的标题;第三个是目录的路径,从这里开始。
第四个也是最后一个参数是由双分号(;;
)分隔的过滤器列表。过滤器中的每种文档类型都包含一个文本,后跟一个或多个用括号括起来的过滤器模式。清单中指定的过滤器如图 3-21 所示。
图 3-21。 过滤器控制哪些文件类型可以打开。
该方法的返回值是一个QString
。如果用户取消或以其他方式中止了对话,则返回的字符串是空字符串。通过使用isNull
方法,您可以看到用户是否选择了一个文件。在清单中的if
语句后面的代码块中,您可以打开文件并处理其内容。
图 3-20 中所示的对话框是 Windows 平台上使用的原生版本。当一个本地对话框丢失时,Qt 将退回到它自己的对话框(见图 3-22 )。正如你所看到的,对话框不再在左边提供快捷方式。它也不能显示不同文件类型的正确图标。
图 3-22。 Qt 打开文件的回退对话框
getOpenFileName
方法使用户只能选择打开一个文件。有些应用程序让用户一次选择几个文件,这就是可以使用getOpenFileNames
的地方。产生的文件对话框与选择一个文件时显示的对话框相同,只是可以一次选择几个文件。
清单 3-12 展示了如何使用该方法。参数与清单 3-11 中的相同,除了方法返回一个QStringList
而不是一个QString
。如果列表为空,则用户没有选择任何文件。
清单 3-12。 挑选几个文件打开
QStringList filenames = QFileDialog::getOpenFileName(
this,
tr("Open Document"),
QDir::currentPath(),
tr("Documents (*.doc);;All files (*.*)") );
...
保存
QFileDialog
类有一个在保存文件时询问文件名的方法:getSaveFileName
。如果文件已经存在,将显示一个类似于图 3-23 所示的警告对话框。
图 3-23。 Qt 验证用户何时试图替换现有文件。
在清单 3-13 中,你可以看到用于显示图 3-24 中对话框的源代码。如果将清单与打开文件的相应清单进行比较,您会发现参数是相同的。
当指定过滤器时,如果用户没有指定,Qt 有助于强制文件扩展名,这是很好的。这意味着您需要一个All files (*.*)
过滤器来让用户自由选择文件扩展名。
清单 3-13 Qt 询问用户保存文件的名称
QString filename = QFileDialog::getSaveFileName(
this,
tr("Save Document"),
QDir::currentPath(),
tr("Documents (*.doc)") );
...
图 3-24。 选择保存文件的名称
打开目录
比询问文件名稍微不常见的是询问目录,但是QFileDialog
类对此也有一个静态成员。清单 3-14 显示了正在使用的getExistingDirectory
方法。参数与打开和保存文件的方法相同,只是没有给出过滤器,因为在处理目录时没有必要过滤扩展名。
清单 3-14。 向用户询问目录
QString dirname = QFileDialog::getExistingDirectory( this, tr("Select a Directory"), QDir::currentPath() ); ...
在 Windows 平台上使用时,产生的对话框如图 3-25 中的所示。它使用户能够从对话框中选择一个目录并创建新目录。
图 3-25。 挑选目录
消息
你经常需要告诉用户一些重要的事情,或者询问一个单词或一个数字,这正是消息框和输入对话框派上用场的地方。使用它们可以使您不必设计和实现自己的对话框。相反,你可以通过静态方法使用 Qt 预制的对话框——就像询问文件名一样。
消息
QMessageBox
类用来向用户显示消息(它也可以用来询问一些基本问题,比如你想保存文件吗?).让我们先来看看可以显示的三种不同类型的消息。图 3-26 显示了三个具有不同重要性信息的对话框。
图 3-26。 三条不同的消息
这些对话框使用清单 3-15 中的源代码显示。静态方法information
、warning
和critical
接受相同的参数并以相同的方式工作。区别在于消息的重要性以及它在系统中的公布方式。所有消息都以不同的图标显示,但其他方面也会受到影响。例如,Windows 系统为信息和重要消息播放不同的声音。
发送给这些方法的参数是父对象、对话框标题和消息。可以使用标准的 C 方法对消息进行格式化(例如,\n
用作换行符)。
清单 3-15。 向用户显示三种不同的消息
QMessageBox::information(
this,
tr("Application Name"),
tr("An information message.") );
QMessageBox::warning(
this,
tr("Application Name"),
tr("A warning message.") );
QMessageBox::critical(
this,
tr("Application Name"),
tr("A critical message.") );
静态方法question
可以用来询问用户问题(清单 3-16 中显示了一个例子)。前三个参数与显示消息时相同:parent、title 和 message。接下来的两个参数指定显示哪些按钮以及哪个按钮将作为默认按钮。您可以看到列表产生的对话框中的按钮如图 3-27 中的所示。这些按钮是“是”、“否”和“取消”,后者是默认值。
注意也可以使用information
、warning
和critical
提问——只需指定默认 OK 按钮以外的按钮即可。
清单 3-16。 问用户一个问题
switch( QMessageBox::question(
this,
tr("Application Name"),
tr("An information message."),
QMessageBox::Yes |
QMessageBox::No |
QMessageBox::Cancel,
QMessageBox::Cancel ) )
{
case QMessageBox::Yes:
...
break;
case QMessageBox::No:
...
break;
case QMessageBox::Cancel:
...
break;
default:
...
break;
}
检查方法调用返回值的switch
语句决定了点击了哪个按钮。按钮比列表中显示的要多。可用选项如下:
QMessageBox::Ok
:好的QMessageBox::Open
:打开QMessageBox::Save
:保存QMessageBox::Cancel
:取消QMessageBox::Close
:关闭QMessageBox::Discard
:丢弃或不保存,取决于平台QMessageBox::Apply
:应用QMessageBox::Reset
:重置QMessageBox::RestoreDefaults
:恢复默认值QMessageBox::Help
:救命QMessageBox::SaveAll
:全部保存- 是的
- 所有人都同意
QMessageBox::No
:没有- 所有人都不同意
QMessageBox::Abort
:中止QMessageBox::Retry
:重试QMessageBox::Ignore
:忽略QMessageBox::NoButton
:当你想让 Qt 选择一个默认按钮时使用
图 3-27。 将问题显示给用户。
输入对话框
如果需要问比是/否/取消稍微高级一点的问题,可以使用QInputDialog
类。使用这个类,您可以要求用户输入值和文本,并从给定的列表中选择一项。
让我们从使用getText
方法从用户那里获取一段文本开始。你可以在清单 3-17 中看到它。清单中代码显示的对话框如图 3-28 所示。
给予该方法的参数是 parent、dialog title、label、echo mode、initial text,后跟一个指向布尔值的指针。如果用户点击 OK 关闭了对话框,则调用将布尔值设置为true
。否则,设置为false
。
回声模式是对话框中正在使用的行编辑的echoMode
属性。将其设置为QLineEdit::Normal
以照常显示输入的文本。如果设置为QLineEdit::Password
,输入的文本将显示为星号。
当方法调用返回时,检查ok
是否为true
以及返回的字符串是否包含某些内容。如果是这种情况,您可以继续对返回的文本做一些事情。
清单 3-17。 要求用户输入一些文本
bool ok;
QString text = QInputDialog::getText(
this,
tr("String"),
tr("Enter a city name:"),
QLineEdit::Normal,
tr("Alingsås"),
&ok );
if( ok && !text.isEmpty() )
{
...
图 3-28。 要求输入文本时显示给用户的对话框
当您希望用户从给定的列表中选择一个字符串或者输入一个新的字符串时,您可以使用静态的getItem
方法。清单 3-18 向你展示了它是如何使用的。出现的对话框如图 3-29 中的所示。
给予该方法的参数类似于请求字符串时使用的参数。列表以父项、对话框标题和标签文本开始,后面是项目列表。这些物品保存在QStringList
中。项目列表后跟一个零;这是项目列表中的起始索引。在这种情况下,对话框将从选择"Foo"
开始。
索引后面的false
表示对话框不允许用户输入自定义字符串。通过将其更改为true
,用户可以从列表中选择一个值,也可以写入一个新的字符串。
参数以一个指向布尔值的指针结束,用于指示用户在关闭对话框时是否接受了它。当确定用户实际上是选择了一项还是取消了对话框时,使用该值和返回字符串的内容。
清单 3-18。 要求用户从列表中选择一个项目
bool ok;
QStringList items;
items << tr("Foo") << tr("Bar") << tr("Baz");
QString item = QInputDialog::getItem(
this,
tr("Item"),
tr("Pick an item:"),
items,
0,
false,
&ok );
if( ok && !item.isEmpty() )
{
...
图 3-29。 从列表中选择项目时显示给用户的对话框
QInputDialog
还能帮你做一件事:从用户那里获取价值。使用静态getInteger
方法显示一个包含数字显示框的对话框(如图 3-30 所示)。用于生成对话框的源代码如清单 3-19 所示。
给予该方法的参数依次是父级、对话框标题和标签文本。接下来是初始值、最小值、最大值和步长。最后一个参数是一个指向布尔值的指针,用来指示用户在关闭对话框时是否接受了它。使用该值确定该数字是由用户给出的还是对话框被取消了。
清单 3-19。 向用户询问整数值
bool ok; int value = QInputDialog::getInteger( this, tr("Integer"), tr("Enter an angle:"), 90, 0, 360, 1, &ok ); if( ok ) { ...
图 3-30。 要求用户输入一个值
如果您需要向用户询问一个浮点值,您可以使用静态的getDouble
方法,它使用一个双数字显示框来显示和编辑该值。
更多对话
还存在用户希望出现标准对话框的其他情况。Qt 提供的两个对话框被选来讨论:用于选择颜色和字体的对话框。
颜色
QColorDialog
类用于让用户选择一种颜色。对话框如图图 3-31 所示。显示对话框的源代码很简单(见清单 3-20 )。对QColorDialog::getColor
的调用接受一个QColor
作为起始值和父值。返回值是一个QColor
,如果用户取消了对话框,这个值就无效。
清单 3-20。 向用户询问颜色
QColor color = QColorDialog::getColor( Qt::yellow, this ); if( color.isValid() ) { ...
图 3-31。 允许用户选择颜色
来源
当你需要让用户选择一种字体时,使用QFontDialog
类。对话框如图 3-32 中的所示。清单 3-21 展示了对话框是如何显示的,结果是如何解释的。
静态的getFont
方法显示对话框并返回一个QFont
。因为字体不能无效,所以该方法的参数以一个布尔值开始,该值指示用户是否取消了对话框。值true
表示返回的字体已经被用户接受。
第二个参数是一个开始的QFont
。第三个参数是父部件,最后一个参数是对话框的窗口标题。
清单 3-21。 对话框如何显示,结果如何解释
bool ok; QFont font = QFontDialog::getFont( &ok, QFont( "Arial", 18 ), this, tr("Pick a font") ); if( ok ) { ...
图 3-32。 挑选字体
验证用户输入
每当您要求用户在文本字段中输入内容时,您通常会得到一些奇怪的反馈。有时他们输入了几个单词,而你期望的是一个。或者他们没有使用正确的小数点。或者他们把一个数字写成文本——就好像你的应用程序要为他们解析“三”。关键是你不能总是相信用户输入了正确有效的输入——你必须总是验证一切。
验证输入时,检查输入是否正确。这并不总是等同于检查错误。即使你能发现输入中的 15 种错误,在某个地方有人会尝试第 16 种。而且会在最不方便的时间最不方便的地点发生。相信我。
验证器
因为 Qt 开发人员知道用户输入不可信,所以他们提供了QValidator
类,该类可用于验证QLineEdit
和QComboBox
小部件中的用户输入。
不能直接使用QValidator
类。相反,你必须使用它的一个子类或者自己做。
在使用验证器之前,您应该对它们的工作原理有所了解。验证器验证一个字符串,可以是Invalid
、Intermediate
或Acceptable
。一个Acceptable
字符串是您期望用户输入的内容。一个Invalid
字符串无效,不能被转换成可接受的字符串。一个Intermediate
字符串是不可接受的,但是可以变成一个。当用户输入文本时,无法输入Invalid
字符串。Intermediate
字符串被接受为输入,然而,Acceptable
字符串也是如此。因此,当带有验证器的行编辑器拒绝接受按键时,这可能是因为它会将字符串呈现为Invalid
。
验证数字
有两个用于验证数字的验证器类:QIntValidator
用于整数,QDoubleValidator
用于浮点值。这两个类在清单 3-22 中显示。突出显示的行显示了创建和分配验证器的位置,但是先看一下整个清单。
清单显示了一个对话框类及其构造器。在构造器中,创建了两个标签、两个行编辑器和一个按钮,并放在一个网格布局中。产生的对话框如图 3-33 所示。
查看突出显示的行和两个验证器,您可以看到每个验证器类都有相当多的参数。从QIntValidator
开始,它需要一个下限、上限和父级。清单中创建的对象允许从 0 到 100 的整数值。QDoubleValidator
还需要一个下限、一个上限,然后是父代之前的数字或所需的小数。
要给小部件分配一个验证器,使用setValidator(QValidator*)
方法,该方法可用于QLineEdit
和QComboBox
类。
清单 3-22。 带有两个经过验证的行编辑器的对话框
`class ValidatingDialog : public QDialog
{
public:
ValidationDialog()
{
QGridLayout *layout = new QGridLayout( this );
QLineEdit *intEdit = new QLineEdit( "42" );
QLineEdit *doubleEdit = new QLineEdit( "3.14" );
QPushButton *button = new QPushButton( "Close" );
layout->addWidget( new QLabel("Integer:"), 0, 0 );
layout->addWidget( intEdit, 0, 1 );
layout->addWidget( new QLabel("Double:"), 1, 0 );
layout->addWidget( doubleEdit, 1, 1 );
layout->addWidget( button, 2, 0, 1, 2 );
...
connect( button, SIGNAL(clicked()), this, SLOT(accept()) );
}
};`
整数验证器确保输入是好的,但是双精度验证器并不是在所有情况下都这样做。例如,它不强制指定的小数位数。
当将数据作为应用程序的输入时,您必须确保检查验证器是否确实将字符串验证为Acceptable
。此外,确保使用QString::toInt
和QString::toDouble
方法,并在使用它们之前查看它们是否真的解析了值。这里的基本教训是,在输入数据时,永远不要相信你的用户。
图 3-33。 一行编辑整数,一行编辑浮点值
正则表达式
在解析基于文本的用户输入时,您真的可以编写大量代码。想象一下,必须验证一个结构类似于+nn(p)aa...a-ll...l
的电话号码,其中n
代表国家号码,p
代表本地区号前缀,a
代表区号,l
代表该区域内的本地号码。国家编号可以有一到两位数。本地区号前缀可以是0
、8
或9
(假设区号中有两到五个数字,本地号码中至少有一个数字)。在这种情况下,正则表达式可以成为你的救星。
正则表达式,通常称为 regexp 或 RE,使您能够定义如何构造字符串。然后,您可以尝试将输入字符串与您的 RE 进行匹配。匹配的字符串是有效的,而不匹配的可以被认为是Invalid
。在 Qt 中,regexps 由QRegExp
对象表示。
在开始使用QRegExp
类之前,您需要理解 re 是如何编写的。REs 几乎可以被认为是一种自己的语言。这篇课文没有深入细节,但解释了基本概念,以便你能理解其思想。
与前面描述的电话号码匹配的 RE 看起来类似于\+\d{1,2}\([089]\)\d{2,5}\-\d+
。看到这里,很容易理解为什么一些程序员避免使用 REs。当你理解了基本的构建块,你就可以把它分解成它的组成部分并阅读它。
首先,反斜杠\
用于转义字符。例如,因为一个+
在 REs 中有意义,我们对它进行转义以告诉QRegExp
类尝试匹配一个+
,而不是解释它。这就是省略括号(以及破折号-
)的原因。
不要忘记 C++ 字符串本身是被转义的。用 C++ 写\d
,需要写\\d
。要表达\
,就得在 RE 中对其进行转义(也就是\\
,给 C++ 字符串\\\\
)。
\d
是所谓的元字符,是代表一个或多个字符的字符。\d
代表一个数字。可用的元字符如下所示。请注意,标准的 C 转义也可以工作。例如,\n
表示换行符,\t
表示制表符。
.
匹配任何字符。\s
匹配空格(QChar::isSpace()
)。\S
匹配非空白。\w
匹配一个单词字符(QChar::isLetterOrNumber()
或QChar::isMark()
或下划线_
)。\W
匹配非单词字符。\d
匹配一个数字(QChar::isDigit()
)。\D
匹配非数字。\x
nnnn
匹配 UNICODE 字符nnnn
,其中nnnn
代表十六进制数字。\0
nnn
匹配 ASCII 字符nnn
,其中nnn
代表八进制数字。
对于局部区域前缀,表达式为[089]
,是一个字符组。将字符放在方括号内意味着可以匹配任何一个字符。通过在括号内放置一个^
,告诉 RE 匹配任何不在括号内的字符。例如,[⁰⁸⁹]
可以匹配除0
、8
或9
之外的任何内容。
字符组也可以用范围来表示。假设您想要匹配a
和f
之间的所有字符(即a
、b
、c
、d
、e
或f
)。您可以通过使用[a-fA-F]
组来完成此操作。请注意,您必须有一个小写字符范围和一个大写字符范围。
仅由一个字符组成的字符组可以省去括号,因此a
匹配a
。由于点匹配任何字符,您必须对其进行转义以使用它来匹配自身。这意味着\
。匹配.
。
在一些元字符之后,你会看到表达式{m,n}
,其中m
和n
是数字。这告诉 RE 至少匹配前面的元字符或字符组的m
个实例。如果m
等于n
,可以省去n
。这意味着{m,m}
等于{m}
。
如果你想匹配一个或多个东西,你可以加一个+
来代替{1,n}
,这里n
是一个足够大的数字。同理,*
匹配零个或更多的东西,?
匹配零个或一个东西。
还有一些特殊字符用作元字符,总结如下:
- 如果第一个出现在 RE 中,则匹配要匹配的字符串的开头。
$
如果出现在 RE 中的最后一个,则匹配要匹配的字符串的结尾。\b
匹配一个单词边界。单词边界可以是空白,也可以是要匹配的字符串的开头或结尾。\B
匹配非单词边界。
返回到匹配电话号码的原始 RE,您必须添加字符串的开头和结尾,以便不匹配给定字符串中间的号码(这给出了下面的 RE: ^\+\d{1,2}\([089]\)\d{2,5}\-\d+$
)。分解后得出以下结果:
^
表示匹配字符串的开头。\+
表示一个+
。\d{1,2}
表示一位数或两位数。\(
表示左括号。[089]
是指0
、8
或9
中的一种。\)
表示右括号。\d{2,5}
表示二至五位数。\-
表示破折号。\d+
表示一个或多个数字。$
表示匹配字符串的结尾。
现在,让我们将这个 RE 与QRegExp
类结合使用(参见清单 3-23 )。首先要注意的是,RE 中的所有\
字符都被转义了,因为 RE 被表示为 C++ 字符串。
当试图将一个字符串匹配到 RE 时,使用indexIn(QString)
方法。此方法返回字符串匹配部分的开始索引。因为 RE 以^
开始,如果字符串匹配,它必须是0
,否则是−1
。如果您跳过开头的^
,第二个字符串会产生一个索引5
,因为电话号码从五个字符开始。
清单 3-23。 用正则表达式匹配电话号码
QRegExp re("^\\+\\d{1,2}\\([089]\\)\\d{2,5}\\-\\d+$");
qDebug() << re.indexIn("+46(0)31-445566"); // 0
qDebug() << re.indexIn("Tel: +46(0)31-445566"); // −1
qDebug() << re.indexIn("(0)31-445566"); // −1
通过在 RE 中添加括号,可以捕获部分匹配的字符串。清单 3-24 增加了四对括号,给出如下 RE: ^\+(\d{1,2})\(([089])\)(\d{2,5})\-(\d+$)
。可以使用cap
方法提取这些括号中的内容。
注意这就是要匹配的括号转义的原因。
cap
方法将一个索引作为参数,其中零返回整个匹配的字符串。从 1 开始的索引从左到右返回括号之间的匹配内容。
清单 3-24。 使用带有捕捉括号的正则表达式捕捉电话号码的不同部分
QRegExp reCap("^\\+(\\d{1,2})\\(([089])\\)(\\d{2,5})\\-(\\d+)$");
qDebug() << reCap.indexIn("+46(0)31-445566"); // 0
qDebug() << reCap.cap(0); // "+46(0)31-445566"
qDebug() << reCap.cap(1); // "46"
qDebug() << reCap.cap(2); // "0"
qDebug() << reCap.cap(3); // "31"
qDebug() << reCap.cap(4); // "445566"
验证文本
因为正则表达式对于验证给定字符串的格式是否正确非常有用,所以 Qt 自然会有一个基于它的验证器。QRegExpValidator
将一个QRegExp
作为构造器参数,并使用 RE 来验证输入。
清单 3-25 展示了这在真实代码中的样子。包含行编辑器、按钮和标签的对话框类是从清单中窃取和改编的——显示数字的验证器。需要注意的是,正则表达式被视为以一个^
开始,以一个$
结束,所以它们被省略了。
清单 3-25。 使用正则表达式验证用户输入
class ValidationDialog : public QDialog
{
public:
ValidationDialog()
{
QGridLayout *layout = new QGridLayout( this );
QLineEdit *reEdit = new QLineEdit( "+46(0)31-445566" );
QPushButton *button = new QPushButton( "Close" );
layout->addWidget( new QLabel("Phone:"), 0, 0 );
layout->addWidget( reEdit, 0, 1 );
layout->addWidget( button, 1, 0, 1, 2 );
...
connect( button, SIGNAL(clicked()), this, SLOT(accept()) );
}
};
当用户输入数据时,QRegExpValidator
使所有文本从右边移除。这意味着用户必须加上加号、括号和破折号。这并不总是很清楚,可能会引起混乱。
当输入有效文本时,验证器不会阻碍任何输入,但是当在文本中间进行编辑时,可能会出现问题。例如,根据 re,不可能在添加左括号后立即删除整个国家代码,因为其中必须至少有一位数字。
当用户完成输入数据时,在接受数据之前将字符串与 RE 匹配是很重要的,因为验证器不能确保字符串是完整的。建议您使用cap
方法从输入字符串中获取实际数据。请记住,您可以使用cap(0)
来获取整个匹配的字符串。与QDoubleValidator
相比,它对用户QString::toDouble
和检查结果很重要,即使字符串已经被验证器监控。参见图 3-34 。
图 3-34。 电话号码的一部分已经输入到已验证的行编辑中。
总结
小部件和布局是所有用户界面的组成部分。确保花时间学习如何使用它们。
Designer 是一个很好的工具,可以帮助您熟悉可用的组件。它使您能够尝试小部件并练习构建适当的布局。记住把所有的部件放到布局中,通过调整对话框的大小来测试你的设计。通过确保它总是看起来很好,您可以确保它可以与不同的语言、屏幕分辨率和字体设置一起工作。
本章最重要的教训如下:
- 总是将对话框按钮放在
QDialogButtonBox
中,以确保它们在所有平台上以用户期望的顺序出现。 - 确保所有的小部件都由一个布局来管理——任何分散的小部件都会使对话框在其他平台和具有不同视觉设置的系统上看起来很糟糕。
- 当设计一个对话框时,确保总是从用户的角度来看待它。参考图 3-33 并在使用设计时考虑结构、视觉辅助和用户的目的。
- 不要害怕尝试设计师。您可以通过使用设计器及其预览功能来学习构建任何设计。**
四、主窗口
到目前为止,在本书中,你主要使用对话框与用户交流。然而,虽然当您需要一个小部件来保存小部件并指导用户完成特定任务或配置特定主题的选项时,对话框是一个很好的解决方案,但大多数应用程序并不仅仅基于一个特定的任务,而是基于一个文档。这是主窗口进入画面的地方。
主窗口是应用程序所基于的顶层窗口。它可以有菜单栏、工具栏、状态栏以及工具箱和其他支持窗口可以停靠的区域。可以从主窗口打开应用程序的对话框,主窗口包含工作文档。
注除非另有说明,在本书的上下文中,术语文档不指用于文字处理目的的文件。相反,在 Qt 的上下文中,文档是指用户与之交互的实际数据。这些数据可以代表任何东西,从供观看的电影到宇宙飞船的 CAD 模型。定义一个文档代表什么以及用户可以对它做什么几乎就是桌面应用程序开发的全部内容。
窗口和文档
在 windows 中排列文档有两种思路:单文档界面(SDI)和多文档界面(MDI)。区别在于每个文档是位于一个新窗口中,还是应用程序对所有文档分别只使用一个窗口。图 4-1 展示了两者的对比。MDI 界面的例子有 Qt Designer 和 Photoshop 流行的 SDI 应用程序有写字板、谷歌地球和一个无标签的网络浏览器。
MDI 概念在 Windows 3.x 时代非常普遍,而 SDI 在 X11 上一直占主导地位。大约在 Windows 95 的时候,微软的政策开始转变,今天大多数 Windows 产品都有 SDI 接口。
为了比较这两种架构和它们带来的结构,您将围绕QTextEdit
小部件构建两个应用程序,其中文本编辑器将充当文档小部件。
图 4-1。 单文档界面与多文档界面的对比
单文档界面
让我们从单个文档界面开始。在 SDI 驱动的环境中,每个主窗口对应一个文档。文档本身保存在一个名为中心小部件的小部件中。每个主窗口都有一个中心小部件,它出现在添加了所有菜单栏、停靠小部件、工具栏等的窗口的中心区域。
这为我们的应用程序提供了一个围绕主窗口及其中心小部件构建的结构。这两个对象一起将包含几乎所有对用户交互作出反应的槽,所以对用户动作的所有响应都是从这两个类中的一个发起的。
主窗口的窗口与诸如禁用和启用菜单项、创建新文件和关闭窗口之类的任务相关联——内务处理任务。中央小部件的插槽处理修改实际文档的用户交互——工作任务。这些任务可以包括标准的剪贴板操作,例如使用剪切、复制和粘贴;执行特定于文档的操作,例如旋转图像;停止播放;或者运行向导——任何适用于相关应用程序文档的操作。
文本编辑器
让我们基于QTextEdit
小部件创建一个简单的 SDI 驱动的应用程序,它可以用作多行QLineEdit
的等价物或简单的文字处理器。你可以在清单 4-1 所示的主窗口的构造器中看到它和一些 SDI 特有的细节。应用程序的截图如图 4-2 所示。
**清单 4-1。**SDI 主窗口的构造器
SdiWindow::SdiWindow( QWidget *parent ) : QMainWindow( parent )
{
setAttribute( Qt::WA_DeleteOnClose );
setWindowTitle( QString("%1[*] - %2" ).arg("unnamed"-).arg(-"SDI") );
docWidget = new QTextEdit( this );
setCentralWidget( docWidget );
connect( docWidget->document(), SIGNAL(modificationChanged(bool)),
this, SLOT(setWindowModified(bool)) );
createActions();
createMenus();
createToolbars();
statusBar()->showMessage( "Done" );
}
图 4-2。 一个单文档应用两个文档
让我们研究一下这段代码。首先,将 window 属性设置为Qt::WA_DeleteOnClose
,这样 Qt 会在窗口关闭后立即从内存中删除它。这意味着需要担心的内存管理更少。
接下来,窗口标题被设置为QString("%1[*] - %2" ).arg("unnamed").arg("SDI")
。arg
方法调用插入"unnamed"
和"SDI"
字符串,其中%1
和%2
符号出现在第一个字符串中。最左边的arg
代替了%1
;下一个替换%2
;等等。使用这种方法,最多可以将九个字符串与一个主字符串合并。
您可以使用setWindowTitle
来设置任何窗口标题。您使用前面例子中显示的标题,因为它允许 Qt 帮助我们管理部分标题(例如,指示当前文档是否已被修改)。这解释了命令的一部分,但是没有解释为什么第一个字符串在对tr
的调用中,或者为什么你不马上使用"unnamed[*] - SDI"
。你希望能够支持其他语言(你会在第十章中了解到更多)。
现在,记住显示给用户的所有字符串都需要包含在对tr()
的调用中。虽然这是由 Designer 自动完成的,但是当你通过代码创建用户界面和设置文本时,你需要自己管理它。
提示脚本可以用来查找丢失的字符串tr()
。如果您使用的是 Unix shell,您可以使用下面这行代码来查找它们:grep -n '"' *.cpp | grep -v 'tr('
。另一种方法是阻止 Qt 自动将char*
字符串转换成QString
对象。这将导致编译器错误的所有时间,你错过了调用tr()
。您可以通过在项目文件中添加一行DEFINES += QT_NO_CAST_FROM_ASCII
来禁用转换。
您使用arg
方法是因为从翻译者的角度来看,字符串unnamed
和SDI
是独立的。比如字符串SDI
用的地方比较多。通过分割字符串,您可以确保它被翻译一次,避免任何可能的不一致。此外,通过使用一个插入了unnamed
和SDI
字符串的主字符串,您可以让翻译者重新排序这些字符串,并在它们周围添加更多的文本,使应用程序更能适应其他文化和语言。
关于设置主窗口标题的另一件事是:字符串[*]
充当一些应用程序使用的文档修改标记的占位符。当windowModified
属性设置为true
时,显示标记;也就是文档被修改的时间。让 Qt 处理标记的显示有两个原因。首先,它避免了在所有应用程序中重复处理它的代码。在 Mac OS X 上,标题文本的颜色用于指示文档是否已被修改。通过在窗口标题中不加星号,明确地使用您自己的代码并让 Qt 来处理,您也让 Qt 处理了所支持的不同平台的任何其他方面。
这是一个窗口标题的大量信息!继续向下清单 4-1 到创建QTextEdit
的行,并将其设置为主窗口的中心小部件。这意味着它将填充整个主窗口,并作为用户的文档视图。
下一行将文本编辑器文档的修改状态连接到主窗口的windowModified
属性。它让 Qt 在修改文档时显示星号并改变标题文本的颜色。信号从docWidget-> document()
发出,而不是直接从docWidget
发出,因为格式化的文本由QTextDocument
表示。QTextEdit
只是格式化文本的查看器和编辑器,所以文档是被修改的,而不是编辑器——因此信号是从文档发出的。
采取行动
继续回顾清单 4-1 中的,你会看到设置菜单、工具栏和状态栏的四行代码。在创建这些实际菜单之前,创建动作。包含在类QAction
中的动作可以将文本、工具提示、键盘快捷键、图标等存储到一个类中。每个动作都会发出信号triggered()
——当被用户调用时,还可能发出信号toggled(bool)
。当动作配置为可检查时,会发出切换信号。动作的工作方式很像按钮,既可以是可检查的,也可以是可点击的。
好的一面是,同样的操作可以添加到菜单和工具栏中,所以如果用户通过按工具栏按钮进入高级编辑模式,相应的菜单项会被自动选中。这也适用于启用和停用操作时,菜单和按钮会自动同步。此外,唯一需要的连接是从动作到动作插槽的连接。
清单 4-2 向您展示了如何在方法createActions
中创建动作,该方法是从清单 4-1 中所示的构造器中调用的。我对清单进行了略微的删减,向您展示了所使用的三种主要类型的操作。在考虑差异之前,先看看相似之处;例如,每个动作都被创建为一个QAction
。QAction
构造器接受一个可选的QIcon
,后跟一个文本和一个父对象。对于需要键盘快捷键的动作,调用setShortcut(const QKeySequence&)
方法。使用setStatusTip(const QString& )
,每个动作被分配一个提示,当该动作作为一个菜单项并被悬停时,该提示将显示在状态栏上。(试试吧!)这个图标奇怪的文件路径是一个所谓的资源路径(它的用法将在下面的资源部分解释)。
清单 4-2。 为 SDI 应用程序创建动作
void SdiWindow::createActions() { newAction = new QAction( QIcon(":/img/new.png"), tr("&New"), this ); newAction->setShortcut( tr("Ctrl+N") ); newAction->setStatusTip( tr("Create a new document") ); connect( newAction, SIGNAL(triggered()), this, SLOT(fileNew()) ); ... cutAction = new QAction( QIcon(":/img/cut.png"), tr("Cu&t"), this ); cutAction->setShortcut( tr("Ctrl+X") ); cutAction->setStatusTip( tr("Cut") ); cutAction->setEnabled(false); connect( docWidget, SIGNAL(copyAvailable(bool)), cutAction, SLOT(setEnabled(bool)) ); connect( cutAction, SIGNAL(triggered()), docWidget, SLOT(cut()) ); ... aboutQtAction = new QAction( tr("About &Qt"), this ); aboutQtAction->setStatusTip( tr("About the Qt toolkit") ); connect( aboutQtAction, SIGNAL(triggered()), qApp, SLOT(aboutQt()) ); }
首先是newAction
,它连接到主窗口中的一个插槽。这是合乎逻辑的地方,因为创建新文档不是由文档本身来处理的(除了初始化,而是放在文档的构造器中)。相反,文档的创建和关闭是由主窗口处理的。请注意,使用setShortcut
设置的键盘快捷键包含在tr()
调用中,这给了翻译人员将快捷键更改为本地化版本的自由。
接下来是cutAction
。它的triggered
信号在用户调用动作时发出,连接到文档中的一个槽。这也是合乎逻辑的,因为剪切会从文档中获取数据并修改文档。从copyAvailable
到setEnabled
的连接是如何启用和禁用动作的一个例子。一旦选择了,就会发出copyAvailable
,并以true
作为参数。当没有可用选项时,参数为false
。因此,该操作在适用时被启用,在所有其他时间被禁用。
最后一个动作是aboutQtAction
,它连接到qApp
对象。application 对象管理应用程序全局任务,例如关闭所有窗口和显示一个对话框,其中包含有关正在使用的 Qt 版本的信息。
注意全局qApp
指针变量总是被设置为指向激活的QApplication
对象。要访问这个指针,你一定不要忘记在你使用它的文件中包含<QApplication>
头文件。
菜单和工具栏
回头看看清单 4-1 中的,你可以看到在调用createActions
之后,接下来的步骤是createMenus
和createToolbars
方法。这些方法采用新创建的动作,并将它们放在正确的位置。
清单 4-3 显示了文件菜单和文件操作的工具栏是如何被动作填充的。因为每个动作已经有了文本和图标,所以只需要调用addAction(QAction*)
就可以让文本和图标出现在菜单中。menuBar()
和addToolBar(const QString&)
调用是主窗口类的一部分。第一次调用menuBar
时,会创建一个菜单栏。后面的调用将引用这个菜单栏,因为每个窗口只有一个菜单。工具栏是用addToolBar
方法创建的,你可以为每个窗口创建任意数量的工具栏。使用addSeparator()
方法,你可以把动作分成组,在菜单和工具栏中都可以使用。
清单 4-3。 菜单和工具栏被填充。
void SdiWindow::createMenus()
{
QMenu *menu;
menu = menuBar()->addMenu( tr("&File") );
menu->addAction( newAction );
menu->addAction( closeAction );
menu->addSeparator();
menu->addAction( exitAction );
...
}
void SdiWindow::createToolbars()
{
QToolBar *toolbar;
toolbar = addToolBar( tr("File") );
toolbar->addAction( newAction );
...
}
再次参考清单 4-1 中的——你会看到,在动作被添加到菜单和工具栏后,构造器中的最后一个调用创建了一个状态栏,并在其中显示了消息"Done"
。statusBar()
方法的工作方式就像menuBar()
一样:在第一次调用时创建并返回一个条,然后在随后的调用中返回一个指向它的指针。
新建文档并关闭打开的文档
您将使用QTextEdit
类作为您的文档类,因为它包含了您需要的所有功能。它可以处理创建和编辑文本,以及从剪贴板复制和粘贴。这使得您只需要实现创建新文档和关闭任何打开的文档的功能。
创建新文档很容易。所有需要做的就是打开一个新的主窗口——清单 4-1 中的构造器将会完成所有困难的工作。清单 4-4 显示了fileNew()
插槽的简单实现。它创建一个新窗口,然后显示它。
清单 4-4。 创建新文档
void SdiWindow::fileNew()
{
(new SdiWindow())->show();
}
关闭文档更复杂,因为文档(或包含文档的窗口)可以用许多不同的方式关闭。一个可能的原因是窗口管理器由于各种原因告诉窗口关闭。例如,用户可能试图通过单击标题栏中的关闭按钮来关闭窗口。或者计算机正在关闭。或者用户从应用程序的文件菜单中选择退出或关闭。
为了拦截所有这些试图关闭当前窗口的用户操作,您可以通过覆盖closeEvent(QCloseEvent*)
方法来实现 close 事件的事件处理程序。清单 4-5 展示了 SDI 应用程序的实现。
清单 4-5。 关闭文档
void SdiWindow::closeEvent( QCloseEvent *event )
{
if( isSafeToClose() )
event->accept();
else
event->ignore();
}
bool SdiWindow::isSafeToClose()
{
if( isWindowModified() )
{
switch( QMessageBox::warning( this, tr("SDI"),
tr("The document has unsaved changes.\n"
"Do you want to save it before it is closed?"),
QMessageBox::Discard | QMessageBox::Cancel ) )
{
case QMessageBox::Cancel:
return false;
default:
return true;
}
}
return true;
}
你可以选择accept()
或ignore()
一个事件:忽略一个关闭事件让窗口打开,接受它关闭窗口。为了确保关闭窗口是安全的,使用isSafeToClose
方法,该方法使用isWindowModified()
确定文档是否被修改。如果文档没有被修改,关闭它是安全的。如果文档已经被修改,询问用户是否可以使用QMessageBox
放弃修改。
向用户显示简短的信息时,提示 QMessageBox
非常有用。四个静态方法information
、question
、warning
和critical
可以用来显示不同重要性的消息。这四种方法都接受五个参数:父部件、标题文本、消息文本、要显示的按钮组合以及将用作默认按钮的按钮。按钮和默认按钮都有默认设置。
按钮可以通过对QMessageBox::StandardButtons
枚举类型的成员进行“或”运算来配置。可用按钮有:Ok
、Open
、Save
、Cancel
、Close
、Discard
、Apply
、Reset
、RestoreDefaults
、Help
、SaveAll
、Yes
、YesToAll
、No
、NoToAll
、Abort
、Retry
和Ignore
。可以从同一列表中选择默认按钮,但只允许将一个按钮设置为默认按钮。四种方法之一的返回值是选中的按钮,如列表中所示。
如果文档没有被修改,或者如果用户选择用Discard
按钮关闭消息框,并且closeEvent
成员接受事件,那么isSafeToClose
成员的结果是true
。如果用户点击Cancel
,关闭事件被忽略。
关闭事件可以有几个来源:用户可能点击了关闭或退出文件菜单,或者用户可能使用当前平台的功能关闭了窗口。如果 close 事件的来源是正在退出的应用程序,那么被忽略的 close 事件意味着不再有窗口被关闭。用户取消退出的整个过程,而不仅仅是当前窗口的关闭,这使得使用单个文档中显示的QMessageBox
的Cancel
按钮来取消整个应用程序的整个关闭过程成为可能
在第八章的中,你将会了解到如果你扩展isSafeToClose
方法,在关闭时整合保存的更改真的很容易。该结构现在看起来不必要的复杂,因为您还需要能够处理关闭前保存选项。
构建应用
要从SdiWindow
类创建,需要提供一个普通的main
函数,在创建和显示SdiWindow
之前初始化一个QApplication
对象。然后,应用程序自行运行,为新文档创建新窗口,并在所有文档关闭后结束。
要构建它,您还必须创建一个项目文件——使用通过运行qmake -project
创建的文件就足够了。然后简单地运行qmake
然后运行make
来编译和链接应用程序。
多单据界面
为了比较 SDI 和 MDI 方法并了解它们的区别,您将基于上一节中使用的相同主题创建一个 MDI 应用程序。在图 4-3 中提供了应用程序的屏幕截图。
图 4-3。 一个多文档应用有两个文档
在应用程序中,每个文档在主窗口中都有一个较小的窗口,这是使用一个文档小部件类和一个QWorkspace
实现的。工作区是包含所有文档窗口的区域。
从用户的角度来看,MDI 应用程序与 SDI 应用程序相同,除了图 4-4 中的所示的窗口菜单,它可以排列文件窗口并移动到当前活动文件以外的文件。
图 4-4。 窗口菜单
文档和主窗口
在 SDI 应用程序中,可能的用户操作分为文档、主窗口和应用程序。这同样适用于 MDI 应用程序,只是文档的所有事件都必须通过主窗口,因为主窗口必须决定将事件传递给哪个文档小部件。让我们先来看看文档小部件类。你可以在清单 4-6 中看到类的定义。
**清单 4-6。**MDI 应用程序的文档小部件类
class DocumentWindow : public QTextEdit
{
Q_OBJECT
public:
DocumentWindow( QWidget *parent = 0 );
protected:
void closeEvent( QCloseEvent *event );
bool isSafeToClose();
};
MDI 应用程序中的 document 类可以与 SDI 应用程序主窗口的精简版本相比较。它包含的所有内容都是文档的细节,因此它需要剥离所有应用程序全局代码以及用于创建新文档的函数。
该类继承了QTextEdit
类并获得了相同的接口。isSafeToClose
和closeEvent
方法的交互就像 SDI 示例一样,而构造器看起来略有不同。清单 4-7 显示了构造器,它告诉 Qt 在设置标题和在文档的修改状态和文档窗口本身的windowModified
属性之间建立联系之前,一旦关闭文档窗口就删除文档窗口。
清单 4-7。 文档控件类的构造器
DocumentWindow::DocumentWindow( QWidget *parent ) : QTextEdit( parent )
{
setAttribute( Qt::WA_DeleteOnClose );
setWindowTitle( QString("%1[*]" ).arg("unnamed") );
connect( document(), SIGNAL(modificationChanged(bool)),
this, SLOT(setWindowModified(bool)) );
}
这就是文档窗口的全部内容——只需设置一个标题并建立一个连接,让 Qt 指示文档是否被修改过。同样,使用arg
方法将unnamed
添加到窗口标题的方法给了翻译人员更多修改文本的自由。Qt 使用窗口标题的[*]
部分来显示或隐藏星号,以表明文件是否被修改。
让我们转到主窗口。它显示在清单 4-8 中,看起来非常像 SDI 应用程序构造器的其余部分——除了一点小的增加。
清单中突出显示的行显示了如何创建一个QWorkspace
并将其设置为主窗口的中心小部件。工作区是一个小部件,它将放入其中的所有小部件视为 MDI 子部件。(参见图 4-3——这两个文档是放在工作区内的小部件。)
接下来,来自工作区的信号windowActivated
连接到主窗口的enableActions
。无论是因为用户更改了文档还是因为用户关闭了最后一个文档,当前活动窗口一改变,就会发出windowActivated
信号。无论哪种方式,您都必须确保只启用相关的操作。(你很快就会回到这个话题。)
清单 4-8。 主窗口的构造器,高亮显示 MDI 和 SDI 之间的差异
MdiWindow::MdiWindow( QWidget *parent ) : QMainWindow( parent )
{
setWindowTitle( tr( "MDI" ) );
workspace = new QWorkspace;
setCentralWidget( workspace );
connect( workspace, SIGNAL(windowActivated(QWidget *)),
this, SLOT(enableActions()));
mapper = new QSignalMapper( this );
connect( mapper, SIGNAL(mapped(QWidget*)),
workspace, SLOT(setActiveWindow(QWidget*)) );
createActions();
createMenus();
createToolbars();
statusBar()->showMessage( tr("Done") );
enableActions();
}
接下来,创建并连接一个名为QSignalMapper
的信号映射对象。信号映射器用于将信号源与另一个信号的自变量联系起来。在这个例子中,对应于窗口菜单中每个窗口的菜单项的动作被绑定到实际的文档窗口。动作依次连接到mapper
。当动作发出triggered
信号时,发送动作已经与对应文档窗口的QWidget*
关联。这个指针被用作信号映射对象发出的mapped(QWidget*)
信号中的参数。
建立信号映射对象后,就像在 SDI 应用程序中一样建立操作、菜单和工具栏。然后,构造器的最后一行确保动作被正确启用。
管理动作
在创建主窗口的动作时,这个过程与 SDI 应用程序的过程非常相似。主要区别如下:
- 文档窗口是通过从工作区中移除它们来关闭的,而不是通过关闭包含文档的主窗口来关闭的。
- 窗口菜单的操作包括平铺窗口、层叠窗口、下一个窗口和上一个窗口。
- 直接连接到 SDI 应用程序中的文档的动作连接到 MDI 应用程序中的主窗口。
清单 4-9 显示了createActions
方法的部分内容。首先,你可以看到closeAction
连接到workspace
的closeActiveWindow()
。然后你可以看到一个窗口菜单项:tileAction
。它连接到workspace
的相应插槽,并使工作区平铺所有包含的文档,以便可以一次看到所有文档。排列文档窗口的其他操作有层叠窗口、下一个窗口和上一个窗口。它们的设置方式与 tile 动作相同:只需将动作的triggered
信号连接到工作空间的适当位置。下一个动作是separatorAction
,它作为一个分隔符。为什么在这里创建它将很快变得清楚。你现在只需要知道,它是用来让窗口菜单看起来像预期的那样。
清单 4-9。 为 MDI 应用程序创建动作
void MdiWindow::createActions()
{
...
closeAction = new QAction( tr("&Close"), this );
closeAction->setShortcut( tr("Ctrl+W") );
closeAction->setStatusTip( tr("Close this document") );
connect( closeAction, SIGNAL(triggered()), workspace, SLOT(closeActiveWindow()) );
...
tileAction = new QAction( tr("&Tile"), this );
tileAction->setStatusTip( tr("Tile windows") );
connect( tileAction, SIGNAL(triggered()), workspace, SLOT(tile()) );
...
separatorAction = new QAction( this );
separatorAction->setSeparator( true );
...
}
确保只启用可用的操作是很重要的,这样可以防止用户因显示可用的菜单项和工具栏按钮而产生混淆,这些菜单项和按钮用于在应用程序的当前状态下无效的任务。例如,当你没有打开一个文档时,你不能粘贴一些东西——这是没有意义的。因此,只要没有活动文档,就必须禁用pasteAction
动作。
在清单 4-10 中,方法enableActions()
显示在助手方法activeDocument()
旁边。后者从QWorkspace::activeWindow
获取QWidget*
返回值,并使用qobject_cast
将其转换成句柄DocumentWindow*
。qobject_cast
函数使用可用于所有QObject
和下降类的类型信息来提供类型安全转换。如果不能进行所请求的造型,则返回0
。
如果没有活动窗口或者活动窗口不是DocumentWindow
类型,则activeDocument
方法返回NULL
(或0
)。它被用在enableActions
法中。两个布尔值用来使代码更容易阅读:hasDocuments
和hasSelection
。如果工作区有一个正确类型的活动文档,大多数项目都被启用,并且separatorAction
是可见的。复制和剪切操作不仅需要一个文档,还需要一个有效的选择,因此只有当hasSelection
为true
时才启用。
清单 4-10。 启用和禁用动作
DocumentWindow *MdiWindow::activeDocument()
{
return qobject_cast<DocumentWindow*>(workspace->activeWindow());
}
void MdiWindow::enableActions()
{
bool hasDocuments = (activeDocument() != 0 );
closeAction->setEnabled( hasDocuments );
pasteAction->setEnabled( hasDocuments );
tileAction->setEnabled( hasDocuments );
cascadeAction->setEnabled( hasDocuments );
nextAction->setEnabled( hasDocuments );
previousAction->setEnabled( hasDocuments );
separatorAction->setVisible( hasDocuments );
bool hasSelection = hasDocuments && activeDocument()->textCursor().hasSelection();
cutAction->setEnabled( hasSelection );
copyAction->setEnabled( hasSelection );
}
助手函数activeDocument
用在了几个地方。一个示例将信号从主窗口传递到实际的文档窗口。做这件事的函数如清单 4-11 所示。在构建基于 MDI 的应用程序时,所有的QActions
如菜单项和工具栏按钮都必须像这样通过主窗口。
清单 4-11。 将信号从主窗口传递到文档控件
void MdiWindow::editCut()
{
activeDocument()->cut();
}
void MdiWindow::editCopy()
{
activeDocument()->copy();
}
void MdiWindow::editPaste()
{
activeDocument()->paste();
}
窗口菜单
与启用和禁用操作密切相关的是处理窗口菜单的功能。窗口菜单(参见图 4-4 )允许用户排列文件窗口和在不同文件之间切换。
清单 4-12 展示了菜单是如何创建的。除了窗口菜单之外的所有菜单都是通过将操作放入其中来创建的,就像在 SDI 应用程序中一样。窗口菜单是不同的,因为它随着文档的打开和关闭而变化。因为您需要能够改变它,所以指向它的指针—称为windowMenu
—保存在类中。现在,来自菜单的信号aboutToShow()
被连接到填充菜单的自定义插槽updateWindowList()
,而不是向菜单添加动作。aboutToShow
信号在菜单显示给用户之前发出,因此菜单总是有有效的内容。
清单 4-12。 创建窗口菜单
void MdiWindow::createMenus()
{
QMenu *menu;
menu = menuBar()->addMenu( tr("&File") );
menu->addAction( newAction );
menu->addAction( closeAction );
menu->addSeparator();
menu->addAction( exitAction );
...
windowMenu = menuBar()->addMenu( tr("&Window") );
connect( windowMenu, SIGNAL(aboutToShow()), this, SLOT(updateWindowList()) );
...
}
清单 4-13 中的显示了updateWindowList
插槽。在该槽中,在添加预定义的动作之前,菜单被清除。之后,每个窗口都被添加为一个操作,前九个窗口都有一个数字作为前缀,如果使用键盘导航(用户已经按下 Alt+W 到达窗口菜单),该数字将作为快捷方式。图 4-5 中的显示了一个打开了九个以上文件的窗口菜单。
清单 4-13。 更新窗口菜单
void MdiWindow::updateWindowList()
{
windowMenu->clear();
windowMenu->addAction( tileAction );
windowMenu->addAction( cascadeAction );
windowMenu->addSeparator();
windowMenu->addAction( nextAction );
windowMenu->addAction( previousAction );
windowMenu->addAction( separatorAction );
int i=1;
foreach( QWidget *w, workspace->windowList() )
{
QString text;
if( i<10 )
text = QString("&%1 %2").arg( i++ ).arg( w->windowTitle() );
else
text = w->windowTitle();
QAction *action = windowMenu->addAction( text );
action->setCheckable( true );
action->setChecked( w == activeDocument() );
connect( action, SIGNAL(triggered()), mapper, SLOT(map()) );
mapper->setMapping( action, w );
}
}
图 4-5。 窗口菜单有九个以上打开的文档
在列出窗口的foreach
循环中,每个窗口由一个QAction
表示。这些动作是从一个QString
创建的,并且属于windowMenu
对象,这意味着调用插槽中的第一个clear()
可以正确地删除它们。来自每个动作的triggered
信号被连接到信号映射对象的map()
槽。然后对setMapping(QObject*, QWidget*)
的调用将发出的动作与正确的文档窗口关联起来。如您所知,来自信号映射对象的mapped
信号连接到workspace
的setActiveWindow
插槽。信号映射对象确保右边的QWidget*
作为参数发送,而mapped
信号取决于连接到map
的原始信号源。
如果没有要添加到列表中的文档窗口,separatorAction
将作为一个分隔符悬空,下面没有任何项目——这就是为什么它在enableActions
槽中是隐藏的而不是禁用的。
创建和关闭单据
SDI 应用程序和 MDI 应用程序的区别在于处理文档的方式。这种差异在创建和关闭新文档的方法中表现得非常明显。
从清单 4-14 中的所示的主窗口的fileNew()
槽开始,你可以看到诀窍是创建一个新的文档窗口而不是一个新的主窗口。随着新窗口的创建,一些连接也需要注意。一旦发出copyAvailable(bool)
信号,当前活动文档就会丢失选择或有新的选择。这必须通过复制和剪切动作来反映,这就是两个connect
调用所做的。
当另一个文档被激活时,复制和剪切启用的状态在enableActions()
槽中管理。
清单 4-14。 创建新文档
`void MdiWindow::fileNew()
{
DocumentWindow *document = new DocumentWindow;
workspace->addWindow( document );
connect( document, SIGNAL(copyAvailable(bool)),
cutAction, SLOT(setEnabled(bool)) );
connect( document, SIGNAL(copyAvailable(bool)),
copyAction, SLOT(setEnabled(bool)) );
document->show();
}`
当用户试图关闭主窗口时,所有文档都必须关闭。如果任何文档有未保存的更改,DocumentWindow
类会询问用户是否可以关闭(如果不可以就取消事件)。主窗口的closeEvent
试图使用QWorkspace
的closeAllWindows()
方法关闭所有文档窗口。在关闭主窗口之前,它会检查是否有任何文档处于打开状态。如果是这样,关闭事件被取消,因为用户已经选择保留文档。您可以在清单 4-15 中看到主窗口关闭事件的源代码。
清单 4-15。 关闭所有文件和主窗口
void MdiWindow::closeEvent( QCloseEvent *event )
{
workspace->closeAllWindows();
if( activeDocument() )
event->ignore();
}
构建应用
类似于 SDI 应用程序过程,您需要一个简单的 main 函数来开始。在这种情况下,该函数需要做的就是初始化QApplication
对象,然后创建并显示一个MdiWindow
对象。
运行qmake -project
,然后运行qmake
和make
,应该可以编译并链接应用程序。
比较单个和多个文档界面
如果比较单文档和多文档界面方法,您会很快注意到几个重要的区别。对用户来说,最重要的区别是 SDI 应用程序通常符合普通用户的期望。在 MDI 应用程序中很容易丢失文档——至少在最大化一个文档时是这样。使用 SDI 意味着所有文档都出现在任务栏中,每个窗口总是对应一个文档。
从软件开发的角度来看,SDI 应用程序更简单。测试一个窗口就足够了,因为每个窗口只处理一个文档。从开发的角度来看,MDI 方法有一个优点:文档与主窗口明显分离。这在 SDI 案例中也是可以实现的,但是需要更多的训练。您绝不能在主窗口中添加影响文档的功能;而是放在文档小部件类中。
MDI 方法还有另一个优点:可以有几种类型的文档窗口,同时仍然保持使用单一应用程序的感觉。这可能是一个不寻常的要求,但有时它是有用的。
因为 SDI 和 MDI 都很容易使用 Qt 实现,而且这两种方法都很常见,所以最终的决定取决于您。记得评估所需的开发工作,看看你的用户将如何使用应用程序;然后选择最适合你项目的。
应用资源
在创建动作的代码中,您可能已经注意到图标是如何创建的。代码看起来像这样:QIcon(":/img/new.png")
。查看QIcon
的构造器,可以看到唯一一个以QString
作为参数的构造器期望一个文件名,这就是:/img/new.png
的内容。
冒号(:
)前缀通知 Qt 文件处理方法,正在讨论的文件将从应用程序资源中获取,这是一个在构建时嵌入到应用程序中的文件。因为它不是外部文件,所以您不必担心它在文件系统中的位置。如您所见,您仍然可以使用资源中的路径和目录来引用文件。资源文件包含一个自己的小文件系统。
资源文件
因此,您可以使用:
前缀从应用程序资源中访问文件。但是如何将文件放入资源中呢?关键在于扩展名为qrc
的 Qt 资源文件。之前的 SDI 和 MDI 应用程序使用了图 4-6 中所示的四个图标。图像文件位于project
目录下的一个名为images
的目录中。
**图 4-6。**SDI 和 MDI 应用中使用的四个图标
图像的基于 XML 的 Qt 资源文件如清单 4-16 中的所示。这是您创建的一个文件,用来告诉 Qt 将哪些文件作为资源嵌入。
提示你可以在设计器中创建资源文件。从工具菜单中调出资源编辑器,开始添加文件。
DOCTYPE
、RCC
和 qresource 标签都是必需的。每个要包含的文件都列在一个file
标签中。在清单 4-16 所示的文件中,file
标签以最简单的形式使用,没有任何属性。
**清单 4-16。**SDI 和 MDI 应用程序的 Qt 资源文件
<!DOCTYPE RCC>< RCC version="1.0">
<qresource>
<file>img/new.png</file>
<file>img/cut.png</file>
<file>img/copy.png</file>
<file>img/paste.png</file>
</qresource>
</RCC>
如果您想通过一个名称而不是用于构建资源的文件来引用一个资源文件,您可以使用alias
属性。如果您为不同的平台使用不同的资源,这样做可能会很方便。通过别名化文件名,您可以在应用程序中引用单个文件名,并根据目标平台将不同的文件放入资源中。清单 4-17 展示了如何使用alias
属性来改变一个文件的名称或者仅仅是改变资源文件中的位置。
清单 4-17。 使用 alias
来改变资源文件名
<file alias="other-new.png">img/new.png</file>
<file alias="new.png">img/new.png</file>
如果你想改变一个资源文件中几个文件的位置,你可以使用qresource
标签的prefix
属性。它可用于将资源文件的文件分组到虚拟目录中。清单 4-18 展示了如何使用多个qresource
标签将图像划分到文件和编辑目录中。例如,在生成的应用程序中,new.png
文件可以作为:/file/img/new.png
被访问。
清单 4-18。 使用 prefix
来改变资源文件的位置
<qresource prefix="/file">
<file>img/new.png</file>
</qresource>
<qresource prefix="/edit">
<file>img/cut.png</file>
<file>img/copy.png</file>
<file>img/paste.png</file>
</qresource>
项目文件
在您可以从您的应用程序访问资源之前,您必须告诉 Qt 您需要哪些资源文件。没有限制资源文件的数量—您可以有一个、几个或者没有。
使用资源编译器rcc
将资源文件编译成 C++ 源文件。这是由 QMake 处理的,就像moc
和uic
一样。只需在项目文件中添加一行RESOURCES +=
filename
.qrc
,然后重新构建。
生成的文件被命名为qrc_filename.cpp
,因此foo.qrc
生成qrc_foo.cpp
,它被编译并链接到应用程序中,就像任何其他 C++ 源文件一样。当 Qt 遇到以:
开头的文件名时,它会将资源文件中的文件添加到 Qt 使用的虚拟文件树中。
应用图标
到目前为止,你看到的所有应用程序都使用标准的 Qt 图标。相反,您可能希望在应用程序窗口的标题栏中显示您自己的图标。您可以通过用方法setWindowIcon
为所有顶层窗口和小部件设置一个窗口图标来做到这一点。例如,在 SDI 和 MDI 应用程序中,在每个主窗口的构造器中添加一个对setWindowIcon( QIcon(":/img/logo.png") )
的调用就可以做到这一点。
这个过程确保了正在运行的应用程序的所有窗口都显示正确的图标。如果你想改变应用程序可执行文件的图标,即应用程序图标,你需要区别对待每个平台。
注意您需要辨别应用程序图标和 windows 图标之间的区别。它们可以相同,但不要求必须相同。
窗户
Windows 系统上的可执行文件通常有一个应用程序图标。图标是一个ico
文件格式的图像。您可以使用许多免费工具创建ico
文件,例如 Gimp ( [
www.gimp.org](http://www.gimp.org)
)或 png2ico ( [
www.winterdrache.de/freeware/png2ico/index.html](http://www.winterdrache.de/freeware/png2ico/index.html)
)。你也可以使用微软的 Visual Studio 来创建ico
文件。
创建了一个ico
文件后,必须使用下面一行将它放入一个特定于 Windows 的资源文件中:
IDI_ICON1 ICON DISCARDABLE "filename.ico"
该行的文件名部分是图标的文件名。将 Windows 资源文件另存为`filename` `.rc`,其中`filename`是资源文件的名称(可以不同于图标)。最后,在 QMake 项目文件中添加一行代码`RC_FILE =` `filename` `.rc`。
**Mac OS X**
在 Mac OS X 系统上,可执行文件通常有一个应用程序图标。图标使用的文件格式是`icns`。您可以使用 Iconverter 等免费工具轻松创建`icns`文件。你也可以使用 OS X 附带的苹果图标编辑器来完成这项任务。
现在你所要做的就是将图标应用到你的可执行文件中,将行`ICON =` `filename` `.icns`添加到你的 QMake 项目文件中。
**Unix 桌面**
在 Unix 环境中,应用程序的可执行文件没有图标(这个概念在平台上是未知的)。然而,现代的 Unix/Linux 桌面使用由 freedesktop.org 组织指定的桌面入口文件。它看起来很好,也很有结构,但问题是不同的发行版使用不同的文件位置来存储图标。(这个话题在第十五章中有更详细的介绍。)
可停靠的部件
虽然示例 SDI 和 MDI 应用程序只使用了一个文档窗口,但有时显示文档的其他方面也很有用。在其他时候,工具栏过于有限,无法显示您需要提供的工具范围。这就是`QDockWidget`进入画面的地方。
图 4-7 显示了停靠窗口小部件可以出现在中央窗口小部件的周围,但是在工具栏内部。该图显示了可以放置工具栏和 dock 小工具的位置。如果它们不占用空间,中央的小部件会伸展以填充尽可能多的区域。
**图 4-7。** *每个主窗口都有一个中央小部件,周围是可停靠的小部件和工具栏。*
**注意**顺便问一下,你知道工具栏可以移动和隐藏吗?尝试构建如下所述的应用程序,然后右键单击其中一个工具栏将其隐藏。也试着拖动工具栏的手柄来移动它。
Dock 窗口小部件也可以显示、隐藏和移动,以贴在主窗口的不同部分。此外,它们可以在主窗口外分离和移动。(一个 *dock widget* 是一个放置在`QDockWidget`中的普通 widget。)然后,`QDockWidget`对象被添加到主窗口,一切正常。图 4-8 显示了多种显示停靠的方式:停靠、浮动和选项卡式。
**图 4-8。** *码头可以用许多不同的方式展示。*
使用 SDI 应用程序作为基础,尝试添加一个 dock 小部件。它将通过`QTextEdit::document()`方法监听来自`QTextDocument`的`contentsChange(int, int, int)`信号。文本文档一更改,就会发出信号,告诉您更改发生在哪里,删除了多少字符,添加了多少字符。将创建一个名为`InfoWidget`的新小部件,它监听信号并显示来自最新发射信号的信息。
清单 4-19 显示了`InfoWidget`的类声明。如您所见,小部件基于`QLabel`,由一个构造器和一个插槽组成。
**清单 4-19。** `InfoWidget` *类*
class InfoWidget : public QLabel
{
Q_OBJECT
public:
InfoWidget( QWidget *parent=0 );
public slots:
void documentChanged( int position, int charsRemoved, int charsAdded );
};
现在你到达了`InfoWidget`的构造器。源代码如清单 4-20 所示。代码使用`setAlignment(Qt::Alignment)`设置标签来显示水平和垂直居中的文本。如果需要,通过将`wordWrap`属性设置为`true`,确保文本被换行。最后,初始文本被设置为`Ready`。
**清单 4-20。***`InfoWidget`*类*的构造器*```
InfoWidget::InfoWidget( QWidget *parent ) : QLabel( parent )
{
setAlignment( Qt::AlignCenter );
setWordWrap( true );
setText( tr("Ready") );
}
```cpp
`InfoWidget`类有趣的部分是插槽的实现。插槽参数是三个名为`position`、`charsRemoved`和`charsAdded`的整数,与`QTextDocument::contentsChange`信号完全匹配。清单 4-21 中的代码采用`charsRemoved`和`charsAdded`,然后在每次发出信号时为小部件构建一个新的文本。`tr()`方法的`tr(QString,QString,int)`版本用于允许翻译者定义复数形式,这意味着`charsRemoved`和`charsAdded`值用于挑选翻译。它不影响英文版本,因为`"1 removed"`和`"10 removed"`都是有效文本。(对于其他语言,情况并不总是如此。你会在第十章学到更多。)
**清单 4-21。** *插槽根据参数更新文本。*
void InfoWidget::documentChanged( int position, int charsRemoved, int charsAdded )
{
QString text;
if( charsRemoved )
text = tr("%1 removed", "", charsRemoved).arg( charsRemoved );
if( charsRemoved && charsAdded )
text += tr(", ");
if( charsAdded )
text += tr("%1 added", "", charsAdded).arg( charsAdded );
setText( text );
}
如果你认为创建`InfoWidget`很简单,你会发现使用它甚至更容易。这些变化影响了`SdiWindow`类,其中添加了一个名为`createDocks()`的新方法(见清单 4-22 )。创建 dock 小部件的步骤是创建一个新的`QDockWidget`,创建您的小部件——`InfoWidget`,并将其放入 dock 小部件中,最后调用`addDockWidget(Qt:: DockWidgetArea, QDockWidget*)`将 dock 小部件添加到主窗口中。将它添加到主窗口时,您还必须指定希望它出现的位置:左侧、右侧、顶部或底部。使用`QDockWidget`的`allowedAreas`属性,您可以控制添加 dock 的位置。这个属性的缺省值是`AllDockWidgetAreas`,它给予用户完全的控制权。
在`createDocks`方法准备好之前,从文本文档到`InfoWidget`的信号被连接。
**清单 4-22。** *创建 dock widget*
void SdiWindow::createDocks()
{
dock = new QDockWidget( tr("Information"), this );
InfoWidget *info = new InfoWidget( dock );
dock->setWidget( info );
addDockWidget( Qt::LeftDockWidgetArea, dock );
connect( docWidget->document(), SIGNAL(contentsChange(int, int, int)),
info, SLOT(documentChanged(int, int, int)) );
}
这就是启用 dock 小部件的全部内容,但是因为用户可以关闭它,所以您还必须为用户提供一个显示它的方法。这通常在视图菜单中处理(或者可能在工具或窗口菜单中,取决于应用程序)。添加一个视图菜单,并使显示和隐藏 dock 小部件变得非常容易。因为这是一个常见的任务,`QDockWidget`类已经为此提供了`QAction`。该操作可通过`toggleViewAction()`方法获得。对`SdiWindow`的`createMenus`方法需要做的修改如清单 4-23 所示。
**清单 4-23。** *为主窗口创建新的视图菜单*
void SdiWindow::createMenus()
{
QMenu *menu;
menu = menuBar()->addMenu( tr("&File") );
menu->addAction( newAction );
menu->addAction( closeAction );
menu->addSeparator();
menu->addAction( exitAction );
menu = menuBar()->addMenu( tr("&Edit") );
menu->addAction( cutAction );
menu->addAction( copyAction );
menu->addAction( pasteAction );
menu = menuBar()->addMenu( tr("&View") );
menu->addAction( dock->toggleViewAction() );
menu = menuBar()->addMenu( tr("&Help") );
menu->addAction( aboutAction );
menu->addAction( aboutQtAction );
}
在构建修改后的 SDI 应用程序之前,必须确保将头文件和源文件`InfoWidget`添加到项目文件中。然后运行`qmake`和`make`来构建可执行文件。图 4-9 显示了运行两个文档的应用程序:一个文档有一个浮动信息 dock 另一个文档停靠在主窗口。
![image](https://gitee.com/OpenDocCN/vkdoc-c-cpp-zh/raw/master/docs/fund-qt-dev/img/P0409.jpg)
**图 4-9。** *带有 dock widgets 的 SDI 应用*
### 总结
有些应用程序最好以单个对话框的形式实现,但大多数应用程序都是基于文档的。对于这些应用程序,主窗口是应用程序窗口的最佳基础类,因为它提供了一个沿着工具栏、菜单、状态栏和可停靠部件的文档视图。
使用 Qt 的`QMainWindow`类,你可以在已建立的单文档和多文档界面之间进行选择,也可以“滚动你自己的”自定义界面。你所要做的就是在主窗口中提供一个中心部件。对于 SDI 应用程序,中心小部件是您的文档小部件;对于 MDI 应用程序,它是一个`QWorkspace`小部件,您可以在其中添加文档小部件。
对话框、SDI 应用程序和 MDI 应用程序的开发方法是相同的。设置用户界面,并将用户动作发出的所有有趣信号连接到执行实际工作的插槽。
信号可以来自菜单项、键盘快捷键、工具栏按钮或任何其他可以想到的来源。要管理它,你可以使用`QAction`对象,这使你能够在不同的地方放置相同的动作,并使用一个单一的信号到插槽连接来处理所有的信号源。
当提供工具栏(还有菜单)时,能够给每个动作添加图标是很好的。为了避免将应用程序可执行文件与图标图像文件集合一起发布,可以使用参考资料。通过构建一个基于 XML 的`qrc`文件并在项目文件中添加一行`RESOURCES`,您可以将文件嵌入到您的可执行文件中。在运行时,你可以通过在文件名前加上前缀`:`来访问文件。
使用 Qt 时,为应用程序的可执行文件提供图标是您必须管理的少数依赖于平台的任务之一。对于 Windows 和 Mac OS X,有一些标准化的方法可以将图标添加到可执行文件中;在 Unix 上,您仍然需要将安装包定位到特定的发行版。这里已经做了很多工作,所以我相信很快就会有一个标准的方法。
本章向您展示了通过使用 Qt 中主窗口可用的框架可以做些什么。在本书的后面,您将在应用程序中使用`QMainWindow`类,所以还会有更多内容!*
五、模型-视图框架
M 模型和视图是在所有类型的软件中频繁出现的设计模式。通过将数据分离到一个模型中,并通过视图将该模型呈现给用户,就创建了一个健壮的、可重用的设计。
模型用于描述图 5-1 所示的结构:列表、表格和树。一个列表是数据的一维向量。一个表是一个列表,但是有多个列——一个二维数据结构。一个树仅仅是一个表,但是有另一个维度,因为数据可能隐藏在其他数据中。
当您考虑如何构建应用程序时,您会发现这些结构几乎可以在所有情况下使用,因此您可以构建一个以良好方式表示您的数据结构的模型。同样重要的是要记住,您不需要改变实际存储数据的方式,您可以提供一个表示数据的模型类,然后将模型化数据中的每一项映射到应用程序数据结构中的实际项。
所有这些结构都可以用许多不同的方式表现出来。例如,列表可以显示为列表(一次显示所有项目)或组合框(仅显示当前项目)。每个值也可以以不同的方式显示,例如,显示为文本、值甚至图像。这就是视图进入画面的地方——它的任务是向用户显示来自模型的数据。
图 5-1。 一个列表,一个表格,一棵树
在经典的模型-视图-控制器(MVC)设计模式中(见图 5-2 ),模型保存数据,视图将数据呈现给显示单元。当用户想要编辑数据时,控制器类处理数据的所有修改。
Qt 以稍微不同的方式处理这种模式。视图没有控制器类,而是通过使用一个委托类来处理数据更新(参见图 5-2 )。委托有两个任务:帮助视图呈现每个值,以及在用户想要编辑值时帮助视图。将经典的 MVC 模式与 Qt 的方法进行比较,你可以说控制器和视图已经合并,但是视图使用委托来处理控制器的部分工作。
图 5-2。 MVC 与模型-视图和代理的比较
使用视图显示数据
Qt 提供了三种不同的默认视图:树、列表和表格。在第二章电话簿示例中,您通过QListWidget
看到了列表视图。QListWidget
类是QListView
的特殊版本,但是QListWidget
包含列表中显示的数据,而QListView
从模型中访问它的数据。QListWidget
有时被称为便利类,因为它不太灵活,但与使用QListView
和模型相比,在不太复杂的情况下更方便。
就像列表小部件与列表视图相关联一样,QTreeWidget-QTreeView
和QTableWidget-QTableView
对也相关联。
让我们从一个例子开始,展示如何创建一个模型,填充它,并使用所有三个视图显示它。为了简单起见,它是由一个单独的main
函数创建的。
首先要做的是创建小部件。在清单 5-1 中,您可以看到QTreeView
、QListView
和QTableView
被创建并放入一个QSplitter
中。一个分割器是一个小部件,它将可移动的条放在其子部件之间。这意味着用户可以自由地在树、列表和表格之间划分空间。你可以在图 5-3 中看到分离器的动作。
清单 5-1。 创建视图并将它们放入分割器
QTreeView *tree = new QTreeView;
QListView *list = new QListView;
QTableView *table = new QTableView;
QSplitter splitter;
splitter.addWidget( tree );
splitter.addWidget( list );
splitter.addWidget( table );
图 5-3。 使用分割器可以调整树、列表和表格的大小。顶部窗口是默认的开始状态,而下面窗口中的拆分条已被移动。
当创建小部件时,您必须创建并填充一个模型。首先使用QStandardItemModel
,这是 Qt 附带的标准型号之一。
清单 5-2 展示了模型是如何被填充的。填充过程由三个循环组成:行(r
)、列(c
)和项(i
)。这些循环创建了五行两列,其中第一列有三个子项。
清单 5-2。 创建并填充模型
` QStandardItemModel model( 5, 2 );
for( int r=0; r<5; r++ )
for( int c=0; c<2; c++)
{
QStandardItem *item =
new QStandardItem( QString("Row:%1, Column:%2").arg(r).arg(c) );
if( c == 0 )
for( int i=0; i<3; i++ )
item->appendRow( new QStandardItem( QString("Item %1").arg(i) ) );
model.setItem(r, c, item);
}`
让我们仔细看看人口是如何构成的。首先,QStandardItemModel
被创建,构造器被告知要使它变成 5 行 2 列。然后对行和列运行一对循环,其中为每个位置创建一个QStandardItem
。通过使用setItem(int, int, QStandardItem*)
方法将该项目放入模型中。对于第一列中的所有项目,其中c
等于0
,创建了三个新的QStandardItem
对象,并使用appendRow(QStandardItem*)
方法将其作为子项目。图 5-4 显示了模型在树形视图中的样子。每个列和行位置的项目以表格形式显示。在表中,第二行已经展开,显示了三个子项。
图 5-4。 模型以树形视图显示,第二行打开显示子项目
在小示例应用程序显示模型之前,你必须通过使用setModel(QAbstractItemModel*)
方法告诉视图使用什么模型,如清单 5-3 所示。
清单 5-3。 为所有视图设置模型
tree->setModel( &model );
list->setModel( &model );
table->setModel( &model );
虽然设置模型是启动和运行的全部要求,但是我想使用选择模型来演示模型之间的差异,所以在继续之前还有一个步骤要执行。
选择模型管理模型中的选择。每个视图都有自己的选择模型,但是可以使用setSelectionModel(QItemSelectionModel*)
方法分配一个模型。通过在列表和表格中设置树的模型,如清单 5-4 所示,选择将被共享。这意味着,如果您在一个视图中选择了某个项目,该项目在其他两个视图中也会被选中。
清单 5-4。 分享选择模式
list->setSelectionModel( tree->selectionModel() );
table->setSelectionModel( tree->selectionModel() );
将所有这些封装在一个main
函数和一个QApplication
对象中,就可以得到一个可以用 QMake 构建的工作应用程序。图 5-3 和图 5-4 显示了运行应用。您可以在应用程序中尝试很多东西,这些东西可以让您了解模型和视图在 Qt 中是如何工作的:
- 尝试在任一视图中一次选取一个项目,并研究该选择在其他视图中的显示位置。请注意,列表仅显示第一列,子项仅影响树视图。
- 尝试在按住 Ctrl 或 Shift 键的情况下拾取项目(然后尝试同时按住这两个键)。
- 尝试从每个视图中选取一行。当您选择列表中的一行时,只会选择第一列。
- 尝试在表中选择列(单击标题),看看在其他视图中会发生什么。确保选择第二列并观察列表视图。
- 双击任何项目并修改文本。默认情况下,对象是可编辑的。
- 不要忘记用间隔棒做实验。
提供标题
视图和标准模型是灵活的。您可能不喜欢应用程序中的一些细节,所以让我们开始查看这些细节。您可以从在标题中设置一些描述性文本开始:通过使用setHorizontalHeaderItem(int, QStandardItem*)
和setVerticalHeaderItem(int, QStandardItem*)
将QStandardItem
s 插入到模型中。清单 5-5 显示了添加到main
函数中的行,用于添加水平标题。
清单 5-5。 向标准项目模型添加标题
model.setHorizontalHeaderItem( 0, new QStandardItem( "Name" ) );
model.setHorizontalHeaderItem( 1, new QStandardItem( "Phone number" ) );
限制编辑
然后是用户可编辑的项目的问题。editable 属性在项目级别进行控制。通过在树形视图中显示的每个子项上使用setEditable(bool)
方法,你可以使它们成为只读的(参见清单 5-6 中的内部循环)。
清单 5-6。 在标准项目模型中创建只读项目
if( c == 0 )
for( int i=0; i<3; i++ )
{
QStandardItem *child = new QStandardItem( QString("Item %1").arg(i) );
child->setEditable( false );
item->appendRow( child );
}
限制选择行为
有时候,限制选择的方式是有帮助的。例如,您可能希望限制用户一次只能选择一项(或者只能选择整行)。这个限制是由每个视图的selectionBehavior
和selectionMode
属性控制的。因为它是在视图级别上控制的,所以重要的是要记住,一旦选择模型在两个视图之间共享,两个视图都需要正确设置它们的selectionBehavior
和selectionMode
属性。
选择行为可以设置为SelectItems
、SelectRows
或SelectColumns
(分别限制选择单个项目、整行或整列)。属性不限制用户可以选择的项数、行数或列数。它由selectionMode
属性控制。选择模式可设置为以下值:
NoSelection
:用户不能在视图中进行选择。SingleSelection
:用户可以在视图中选择单个项目、行或列。ContiguousSelection
:用户可以在视图中选择多个项目、行或列。选择区域必须是一个整体,彼此相邻,没有任何间隙。ExtendedSelection
:用户可以在视图中选择多个项目、行或列。选择区域是独立的,可以有间隙。用户可以通过单击和拖动来选择项目,同时按下 Shift 或 Ctrl 键来选择项目。MultiSelection
:相当于ExtendedSelection
从程序员的角度来看,选择区域是独立的,可以有间隙。用户通过单击项目来切换所选状态。不需要使用 Shift 或 Ctrl 键。
在清单 5-7 中,表格视图被配置为只允许选择一整行。尝试使用树视图和列表视图选择多个项目和单个项目。
清单 5-7。 改变选择行为
table->setSelectionBehavior( QAbstractItemView::SelectRows );
table->setSelectionMode( QAbstractItemView::SingleSelection );
单列列表
对于真正简单的列表,Qt 提供了QStringListModel
。因为项目列表通常保存在 Qt 应用程序的QStringList
对象中,所以最好有一个采用字符串列表并能与所有视图一起工作的模型。
清单 5-8 展示了如何创建和填充QStringList
对象list
。创建一个QStringListModel
,并用setStringList(const QStringList&)
设置列表。最后,在列表视图中使用该列表。
清单 5-8。 使用 QStringListModel
来填充一个 QListView
QListView list;
QStringListModel model;
QStringList strings;
strings << "Huey" << "Dewey" << "Louie";
model.setStringList( strings );
list.setModel( &model );
创建自定义视图
能够通过现有的视图显示模型是有用的,但是有时您需要能够根据自己的需要定制视图。有两种方法:要么从QAbstractItemDelegate
类构建一个委托,要么从QAbstractItemView
类创建一个完全自定义的视图。
创建代理是最简单的方法,所以从这里开始。Qt 附带的视图都使用代理来绘制和编辑它的项目。通过创建用于绘制行或列(或视图中的所有项目)的委托,您通常可以获得所需的外观。
画画的代表
首先创建一个委托,将整数值显示为条形。在图 5-5 所示的表格视图中可以看到代表的动作。条形的范围从 0 到 100,其中 0 只是一条蓝色的细线,100 是一条完整的绿色条形。如果该值超过 100,条形会变成红色,表示超出范围。
图 5-5。BarDelegate
类用于将整数值显示为条形。
*因为它是一个显示条形图的委托,所以新类被称为BarDelegate
并建立在QAbstractItemDelegate
类的基础上。抽象项委托类是所有委托的基类。清单 5-9 中显示了类声明。这段代码可以被认为是所有管理值显示的委托的样板文件,因为覆盖的两种方法在QAbstractItemDelegate
基类的文档中都有明确的说明。该方法的用途从其名称就很容易猜到。paint(QPainter*, const QStyleOptionViewItem&, const QModelIndex&)
方法绘制项目,而sizeHint(const QStyleOptionViewItem&, const QmodelIndex&)
指示每个项目想要多大。
清单 5-9。【自定义委托】的类声明
class BarDelegate : public QAbstractItemDelegate
{
public:
BarDelegate( QObject *parent = 0 );
void paint( QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index ) const;
QSize sizeHint( const QStyleOptionViewItem &option,
const QModelIndex &index ) const;
};
清单 5-10 中的显示了sizeHint
方法。它只是返回一个足够大但不超过大小限制的大小。记住这只是一个提示;实际大小可以由 Qt 针对布局问题进行更改,也可以由用户通过调整行和列的大小来进行更改。
清单 5-10。 返回自定义委托的大小提示
QSize BarDelegate::sizeHint( const QStyleOptionViewItem &option,
const QModelIndex &index ) const
{
return QSize( 45, 15 );
}
sizeHint
方法非常简单;paint
方法更有趣(见清单 5-11 )。第一个if
语句通过测试样式选项的状态来检查该项是否被选中。(样式选项用于控制 Qt 应用程序中所有东西的外观。)负责使 Qt 应用程序看起来像本机应用程序的样式化系统将样式选项对象用于调色板、区域、可视状态以及影响对象在屏幕上的外观的所有其他内容。有许多样式对象类——几乎每个图形元素都有一个。全部继承QStyleOption
类。
清单 5-11。 为自定义代理绘制值
void BarDelegate::paint( QPainter *painter,
const QStyleOptionViewItem &option, const QModelIndex &index ) const
{
if( option.state & QStyle::State_Selected )
painter->fillRect( option.rect, option.palette.highlight() );
int value = index.model()->data( index, Qt::DisplayRole ).toInt();
double factor = (double)value/100.0;
painter->save();
if( factor > 1 )
{
painter->setBrush( Qt::red );
factor = 1;
}
else
painter->setBrush( QColor( 0, (int)(factor*255), 255-(int)(factor*255) ) );
painter->setPen( Qt::black );
painter->drawRect( option.rect.x()+2, option.rect.y()+2,
(int)(factor*(option.rect.width()-5)), option.rect.height()-5 );
painter->restore();
}
如果样式选项指示该项已被选中,则背景将填充平台的选定背景颜色,该颜色也是从样式选项中获得的。对于绘图,使用QPainter
对象和填充给定矩形的fillRect(const QRect&, const QBrush&)
方法。
下一行从模型中选取值,并将其转换为整数。代码请求带有索引的DisplayRole
值。每个模型项可以有几个不同角色的数据,但是要显示的值有DisplayRole
。该值作为一个QVariant
返回。variant 数据类型可以保存任何类型的值:字符串、整数、实值、布尔值等等。toInt(bool*)
方法试图将当前值转换成整数,这是委托所期望的。
获得该项目的选择状态和值的两行被突出显示。这些线条必须总是以某种形式出现在代理绘制方法中。
模型中的值用于计算一个因子,该因子告诉您该值是 100 的几分之一。该因子用于计算条形的长度和填充条形的颜色。
下一步是保存画师的内部状态,这样就可以更改钢笔颜色和画笔,然后调用restore()
让画师保持原样。(QPainter
类在第七章中有更详细的讨论。)
if
语句检查factor
是否超过 1,并负责给用于填充条的画笔着色。如果因子大于 1,条形变为红色;否则,计算颜色时,接近零的因子给出蓝色,接近 1 的因子给出绿色。因为该因子用于控制条的长度,所以如果它太大,该因子被限制为 1,这确保了您不会试图在指定的矩形之外进行绘制。
设置画笔颜色后,在绘制线条之前,使用drawRect(int, int, int, int)
方法将钢笔颜色设置为黑色。option
的rect
成员告诉你物品有多大。最后,画师恢复到方法结束前保存的状态。
为了测试委托,在main
函数中创建了一个表视图和一个标准模型。这方面的源代码如清单 5-12 所示。该模型有两列:一个包含字符串的只读行和一个包含整数值的只读行。
在清单末尾突出显示的行中创建和设置了代理。setItemDelegateForColumn(int, QAbstractItemDelegate*)
代表被分配到第二列。如果您不想定制一个行,您可以使用setItemDelegateForRow(int, QAbstractItemDelegate*)
将一个代表分配给一个行,或者您可以使用setItemDelegate(QAbstractItemDelegate*)
将一个代表分配给整个模型。
清单 5-12。 创建并填充一个模型;然后为第二列设置代表
QTableView table;
QStandardItemModel model( 10, 2 );
for( int r=0; r<10; ++r )
{
QStandardItem *item = new QStandardItem( QString("Row %1").arg(r+1) );
item->setEditable( false );
model.setItem( r, 0, item );
model.setItem( r, 1, new QStandardItem( QString::number((r*30)%100 )) );
}
table.setModel( &model );
BarDelegate delegate;
table.setItemDelegateForColumn( 1, &delegate );
产生的应用程序在图 5-5 的中显示运行。问题是用户不能编辑条形后面的值,因为没有编辑器从委托的createEditor
方法返回。
自定义编辑
要使用户能够编辑使用自定义委托显示的项,必须扩展委托类。在清单 5-13 中,带有新成员的行被突出显示。它们都与为模型项提供编辑小部件有关。根据下面的列表,每种方法都有一个要处理的任务:
createEditor(...)
:创建一个编辑器小部件,并将 delegate 类用作事件过滤器setEditorData(...)
:用来自给定模型项的数据初始化编辑器小部件setModelData(...)
:将模型项的值设置为编辑器小部件中的值updateEditorGeometry(...)
:更新几何图形(即位置和大小)或编辑小工具
清单 5-13。 支持自定义编辑小工具的自定义代理
class BarDelegate : public QAbstractItemDelegate
{
public:
BarDelegate( QObject *parent = 0 );
void paint( QPainter *painter,
const QStyleOptionViewItem &option,
const QModelIndex &index ) const;
QSize sizeHint( const QStyleOptionViewItem &option,
const QModelIndex &index ) const;
QWidget *createEditor( QWidget *parent,
const QStyleOptionViewItem &option,
const QModelIndex &index ) const;
void setEditorData( QWidget *editor,
const QModelIndex &index ) const;
void setModelData( QWidget *editor,
QAbstractItemModel *model,
const QModelIndex &index ) const;
void updateEditorGeometry( QWidget *editor,
const QStyleOptionViewItem &option,
const QModelIndex &index ) const;
};
因为该值显示为水平增长的条形,所以使用了在水平方向移动的滑块作为编辑器。这意味着滑块的水平位置将对应于条的水平范围,如图图 5-6 所示。
图 5-6。 自定义委托将值显示为一个条,并使用一个自定义编辑小部件:滑块来编辑值。
让我们看看清单 5-14 中显示的createEditor
和updateEditorGeometry
方法。更新几何图形的成员非常简单——它只需要通过option
获得rect
,并相应地设置editor
的几何图形。
清单 5-14。 创建自定义编辑小工具并调整其大小
QWidget *BarDelegate::createEditor( QWidget *parent,
const QStyleOptionViewItem &option, const QModelIndex &index ) const
{
QSlider *slider = new QSlider( parent );
slider->setAutoFillBackground( true );
slider->setOrientation( Qt::Horizontal );
slider->setRange( 0, 100 );
slider->installEventFilter( const_cast<BarDelegate*>(this) );
return slider;
}
void BarDelegate::updateEditorGeometry( QWidget *editor,
const QStyleOptionViewItem &option, const QModelIndex &index ) const
{
editor->setGeometry( option.rect );
}
提示使用setGeometry(const QRect&)
方法来设置小部件的位置和大小似乎是个好主意,但是在 99%的情况下,布局是更好的选择。此处使用它是因为显示模型项目的区域是已知的,并且如果使用了布局,它是从布局直接或间接确定的。
创建编辑器的方法包含的代码稍微多一点,但是并不复杂。首先,设置一个QSlider
来绘制背景,以便模型项的值被小部件覆盖。然后在委托类作为事件过滤器安装之前设置方向和范围。事件过滤功能包含在基类QAbstractItemDelegate
中。
注意 事件过滤是一种在事件到达小部件之前查看发送到小部件的事件的方法。这将在第六章的中详细讨论。
在编辑小部件为用户准备好之前,它必须从模型中获取当前值。这是setEditorData
方法的责任。清单 5-15 中的方法从模型中获取值。使用toInt(bool*)
将该值转换为整数,因此非数字值将被转换为零值。最后,使用setValue(int)
方法设置编辑器小部件的值。
清单 5-15。 根据模型值初始化编辑器控件
void BarDelegate::setEditorData( QWidget *editor, const QModelIndex &index ) const { int value = index.model()->data( index, Qt::DisplayRole ).toInt(); static_cast<QSlider*>( editor )->setValue( value ); }
编辑器小部件可以正确地创建、放置和调整大小,然后用当前值进行初始化。然后,用户可以以有意义的方式编辑该值,但是新值无法到达模型。这是setModelData(QWidget*, QAbstractItemModel*, const QModelIndex&)
处理的任务。你可以在清单 5-16 中看到这个方法。代码相当简单,即使由于强制转换而有点模糊。发生的情况是,来自编辑器小部件的值被获取并在一个setData(const QModelIndex&, const QVariant&, int)
调用中使用。受影响的模型索引index
被作为参数传递给setModelData
方法,因此没有真正的障碍。
清单 5-16。 从编辑器小部件获取值并更新模型
void BarDelegate::setModelData( QWidget *editor,
QAbstractItemModel *model, const QModelIndex &index ) const
{
model->setData( index, static_cast<QSlider*>( editor )->value() );
}
生成的应用程序将值显示为条形,并允许用户使用滑块编辑它们。(运行应用参见图 5-6 。)
创造自己的观点
当您觉得通过使用可用的视图、委托或任何其他技巧无法到达您想要的位置时,您将面临一种情况,您必须实现自己的视图。
图 5-7 显示了一个表格和一个显示所选项目的自定义视图。自定义视图一次显示一个项目(如果一次选择多个项目,则显示一段解释文字)。它基于一个QAbstractItemView
并使用一个QLabel
来显示文本。
图 5-7。 行动中的自定义视图
当实现一个定制视图时,你必须提供一大堆方法的实现。有些方法很重要;其他的只是提供一个有效的返回值。哪些方法需要复杂的实现很大程度上取决于您正在实现的视图类型。
在清单 5-17 中,你可以看到自定义视图SingleItemView
的类声明。除了updateText()
之外的所有方法都是必需的,因为它们在QAbstractItemView
中被声明为纯抽象方法。
提示纯抽象方法是在基类声明中设置为零的虚方法。这意味着该方法没有实现,并且该类不能被实例化。为了能够创建继承基类的类的对象,必须实现方法,因为必须实现所有对象的所有方法。
类声明中的方法告诉您视图的职责:显示模型的视图,对模型中的变化做出反应,以及对用户动作做出反应。
清单 5-17。 包含所有必需成员的自定义视图
class SingleItemView : public QAbstractItemView
{
Q_OBJECT
public:
SingleItemView( QWidget *parent = 0 );
QModelIndex indexAt( const QPoint &point ) const;
void scrollTo( const QModelIndex &index, ScrollHint hint = EnsureVisible );
QRect visualRect( const QModelIndex &index ) const;
protected:
int horizontalOffset() const;
bool isIndexHidden( const QModelIndex &index ) const;
QModelIndex moveCursor( CursorAction cursorAction,
Qt::KeyboardModifiers modifiers );
void setSelection( const QRect &rect, QItemSelectionModel::SelectionFlags flags );
int verticalOffset() const;
QRegion visualRegionForSelection( const QItemSelection &selection ) const;
protected slots:
void dataChanged( const QModelIndex &topLeft, const QModelIndex &bottomRight );
void selectionChanged( const QItemSelection &selected,
const QItemSelection &deselected );
private:
void updateText();
QLabel *label;
};
SingleViewItem
的构造器在QAbstractItemView
小部件的视图部分设置了一个QLabel
。QAbstractItemView
类继承了QAbstractScrollArea
,用于创建可能需要滚动条的小部件。可滚动区域内部是查看端口小部件。
清单 5-18 中显示的构造器的源代码展示了如何让标签填充视口。首先,为视口创建布局,然后将标签添加到布局中。为了确保标签填满可用区域,其大小策略设置为向所有方向扩展。最后,标签被配置为在设置标准文本之前在可用区域的中间显示文本。
清单 5-18。 在自定义视图的视口中设置标签
SingleItemView::SingleItemView( QWidget *parent ) : QAbstractItemView( parent )
{
QGridLayout *layout = new QGridLayout( this->viewport() );
label = new QLabel();
layout->addWidget( label, 0, 0 );
label->setAlignment( Qt::AlignCenter );
label->setSizePolicy(
QSizePolicy( QSizePolicy::Expanding, QSizePolicy::Expanding ) );
label->setText( tr("<i>No data.</i>") );
}
在构造器中,设置一个标准文本;在updateText
方法中,设置实际的文本。清单 5-19 显示了该方法的实现。它通过查看从选择模型的selection
方法中获得的QModelIndex
个对象的数量来工作。selection
方法返回模型中所有选中项目的索引。如果选择的项目数为零,文本被设置为No data
。当选择一个项目时,将显示该项目的值。否则,意味着不止一个选择的项目,显示文本通知用户只能显示一个项目。
通过模型的data
方法和currentIndex
方法检索所选项的值。只要至少选择了一项,这些方法的组合将从当前项返回值。
清单 5-19。 更新标签的文本
void SingleItemView::updateText()
{
switch( selectionModel()->selection().indexes().count() )
{
case 0:
label->setText( tr("<i>No data.</i>") );
break;
case 1:
label->setText( model()->data( currentIndex() ).toString() );
break;
default:
label->setText( tr("<i>Too many items selected.<br>"
"Can only show one item at a time.</i>") );
break;
}
}
因为视图的大部分工作是显示项目,所以视图需要有方法来告诉什么是可见的以及在哪里。因为视图只显示了一个项目,所以您只能面对全有或全无的情况。清单 5-20 中的所示的方法visualRect
,返回一个包含给定模型索引的矩形。该方法只是检查它是否是可见项,如果是,则返回整个视图的区域;否则,返回一个空矩形。
有更多的方法以同样的方式工作:visualRegionForSelection
、isIndexHidden
和indexAt
。所有这些方法都检查给定的模型索引是否是显示的那个,然后相应地返回。
清单 5-20。 确定什么是可见的,什么是不可见的
QRect SingleItemView::visualRect( const QModelIndex &index ) const
{
if( selectionModel()->selection().indexes().count() != 1 )
return QRect();
if( currentIndex() != index )
return QRect();
return rect();
}
一些方法的目的是返回有效值来维护一个预定义的接口,这是清单 5-21 中显示的方法的工作。因为滚动条没有被使用,并且一次只显示一个项目,所以这些方法尽可能接近于空的。
清单 5-21。 返回有效响应而不采取行动
int SingleItemView::horizontalOffset() const
{
return horizontalScrollBar()->value();
}
int SingleItemView::verticalOffset() const
{
return verticalScrollBar()->value();
}
QModelIndex SingleItemView::moveCursor( CursorAction cursorAction,
Qt::KeyboardModifiers modifiers )
{
return currentIndex();
}
void SingleItemView::setSelection( const QRect &rect,
QItemSelectionModel::SelectionFlags flags )
{
// do nothing
}
void SingleItemView::scrollTo( const QModelIndex &index, ScrollHint hint )
{
// cannot scroll
}
对变化做出反应
视图的最后一个任务是对模型中的变化和用户动作做出反应(例如,通过改变选择)。方法dataChanged
和selectionChanged
通过使用updateText
更新显示的文本来对这些事件做出反应。你可以在清单 5-22 中看到这两种方法的实现。
清单 5-22。 对模型和选择的变化做出反应
void SingleItemView::dataChanged( const QModelIndex &topLeft,
const QModelIndex &bottomRight )
{
updateText();
}
void SingleItemView::selectionChanged( const QItemSelection &selected,
const QItemSelection &deselected )
{
updateText();
}
使用定制视图就像使用 Qt 附带的视图一样简单。清单 5-23 展示了它的样子(填充模型被省略了)。使用一对嵌套的for
回路来使用和填充QStandardItemModel
。如您所见,使用视图和共享选择模型非常容易。(应用见图 5-7 。)
清单 5-23。 利用单项视图结合表格视图
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QTableView *table = new QTableView;
SingleItemView *selectionView = new SingleItemView;
QSplitter splitter;
splitter.addWidget( table );
splitter.addWidget( selectionView );
...
table->setModel( &model );
selectionView->setModel( &model );
selectionView->setSelectionModel( table->selectionModel() );
splitter.show();
return app.exec();
}
创建定制模型
到目前为止,您一直在查看自定义视图和代理。模型都是QStandardItemModel
或QStringListModel
的,所以模型-视图架构的一个要点被忽略了:定制模型。
通过提供您自己的模型,您可以将应用程序的数据结构转换成一个模型,该模型可以显示为表格、列表、树或任何其他视图。通过让模型转换您现有的数据,您不必保留数据集——一个用于应用程序的内部,一个用于显示。这带来了另一个好处:您不必确保这两个集合是同步的。
定制模型有四种方法:
- 您可以将应用程序的数据保存在模型中,并通过视图使用的模型预定义的类接口来访问它。
- 您可以将应用程序的数据保存在模型中,并通过视图使用的预定义接口旁边实现的自定义类接口来访问它。
- 您可以将应用程序的数据保存在外部对象中,并让模型充当您的数据和视图所需的类接口之间的包装器。
- 您可以动态地为模型生成数据,并通过视图使用的类接口提供结果。
本节讨论表格和树,以及只读和可编辑模型。所有模型都使用不同的方法来保存和向视图提供数据;所有视图都可以与标准视图以及您使用的任何自定义视图一起使用。
只读表格模型
首先,您将看到一个动态生成数据的只读表模型。名为MulModel
的模型类显示了乘法表的可配置部分。类别声明如清单 5-24 所示。
该类基于QAbstractTableModel
,在创建二维模型时,这是一个很好的开始类。所有的模型实际上都基于QAbstractItemModel
类,但是抽象表模型类为一些需要的方法提供了存根实现。MulModel
类的方法各有特殊的责任:
flags
:告诉视图可以对每个项目做什么(是否可以编辑、选择等等)data
:给视图返回给定角色的数据headerData
:将表头数据返回给视图rowCount
和columnCount
:将模型的尺寸返回视图
清单 5-24。 自定义模型类声明
class MulModel : public QAbstractTableModel
{
public:
MulModel( int rows, int columns, QObject *parent = 0 );
Qt::ItemFlags flags( const QModelIndex &index ) const;
QVariant data( const QModelIndex &index, int role = Qt::DisplayRole ) const;
QVariant headerData( int section, Qt::Orientation orientation,
int role = Qt::DisplayRole ) const;
int rowCount( const QModelIndex &parent = QModelIndex() ) const;
int columnCount( const QModelIndex &parent = QModelIndex() ) const;
private:
int m_rows, m_columns;
};
构造器只是记住要显示的行数和列数,然后将父类传递给基类构造器。rowCount
和columnCount
方法和构造器一样简单,因为它们只是返回给构造器的维度。你可以在清单 5-25 中看到这些方法。
清单 5-25。 构造器、 rowCount
、和 columnCount
方法
MulModel::MulModel( int rows, int columns, QObject *parent ) :
QAbstractTableModel( parent )
{
m_rows = rows;
m_columns = columns;
}
int MulModel::rowCount( const QModelIndex &parent ) const
{
return m_rows;
}
int MulModel::columnCount( const QModelIndex &parent ) const
{
return m_columns;
}
data
方法返回给定角色的数据。数据总是以QVariant
的形式返回,这意味着它可以被转换成图标、大小、文本和值。角色定义了数据的用途,如下表所示:
Qt::DisplayRole
:要显示的数据(文本)Qt::DecorationRole
:用于装饰物品的数据(图标)Qt::EditRole
:可用于编辑器的格式的数据Qt::ToolTipRole
:显示为工具提示的数据(文本)Qt::StatusTipRole
:显示为状态栏信息的数据(文本)Qt::WhatsThisRole
:这是什么中要显示的数据?信息Qt::SizeHintRole
:视图的尺寸提示
MulModel
的data
方法支持DisplayRole
和ToolTipRole
。显示角色是当前乘法的值;显示的工具提示是乘法表达式本身。该方法的源代码如清单 5-26 所示。
清单 5-26。 从自定义模型中提供数据
QVariant MulModel::data( const QModelIndex &index, int role ) const
{
switch( role )
{
case Qt::DisplayRole:
return (index.row()+1) * (index.column()+1);
case Qt::ToolTipRole:
return QString( "%1 × %2" ).arg( index.row()+1 ).arg( index.column()+1 );
default:
return QVariant();
}
}
为不同的角色返回标题数据,就像为实际项目数据返回一样。当返回头数据时,注意方向通常是很重要的(即,请求的信息是针对Horizontal
还是Vertical
头)。因为它与乘法表无关,所以清单 5-27 所示的方法非常简单。
清单 5-27。 为自定义模型提供表头
QVariant MulModel::headerData( int section,
Qt::Orientation orientation, int role ) const
{
if( role != Qt::DisplayRole )
return QVariant();
return section+1;
}
最后,flags
返回的标志用于控制用户可以对项目做什么。清单 5-28 中显示的方法告诉视图所有的项目都可以被选择和启用。还有更多可用的标志。请参考以下列表进行快速概述:
Qt::ItemIsSelectable
:可以选择项目。Qt::ItemIsEditable
:该项目可以编辑。Qt::ItemIsDragEnabled
:可以从模型中拖动项目。Qt::ItemIsDropEnabled
:可以将数据拖放到项目上。Qt::ItemIsUserCheckable
:用户可以勾选和取消勾选该项。Qt::ItemIsEnabled
:该项被启用。Qt::ItemIsTristate
:项目在树形状态之间循环。
清单 5-28。 用于控制用户可以对模型项目做什么的标志
Qt::ItemFlags MulModel::flags( const QModelIndex &index ) const
{
if(!index.isValid())
return Qt::ItemIsEnabled;
return Qt::ItemIsSelectable | Qt::ItemIsEnabled;
}
这是模型需要的所有方法。在继续之前,看一下图 5-8 ,它显示了显示工具提示的MulModel
。使用带有QTableView
的MulModel
的代码如清单 5-29 所示。
图 5-8。 将 MulModel
类与 QTableView
类配合使用
清单 5-29。 使用带有表格视图的自定义模型
int main( int argc, char **argv )
{
QApplication app( argc, argv );
MulModel model( 12, 12 );
QTableView table;
table.setModel( &model );
table.show();
return app.exec();
}
属于你自己的一棵树
尽管创建一个二维表并不困难,但是创建树模型稍微复杂一些。要理解表格和树的区别,请看一下图 5-9 ,它显示了 Qt 中的一棵树。
图 5-9。 树实际上是一个表格,其中每个单元格可以包含更多的表格。
让树模型工作的诀窍是将树结构映射到模型的索引。这使得可以返回每个索引的数据以及每个索引可用的行数和列数(即每个索引可用的子项数)。
我选择将模型建立在所有 Qt 应用程序中都可用的树结构上:QObject
所有权树。每个QObject
都有一个父节点,并且可以有子节点,这就构建了一个模型将要表示的树。
注意这里展示的模型显示了一个QObject
树的快照。如果通过添加或删除对象来修改树,模型将失去同步,并且必须重置。
将要实施的应用程序在图 5-10 中显示。
图 5-10。 树形模型通过 QTreeView
显示 QObjects
*让我们先来看看类声明(参见清单 5-30 )。该类名为ObjectTreeModel
,基于QAbstractItemModel
。清单中突出显示的行显示了与MulModel
相比增加的方法。
清单 5-30。 树模型的类声明
class ObjectTreeModel : public QAbstractItemModel
{
public:
ObjectTreeModel( QObject *root, QObject *parent = 0 );
Qt::ItemFlags flags( const QModelIndex &index ) const;
QVariant data( const QModelIndex &index, int role ) const;
QVariant headerData( int section, Qt::Orientation orientation,
int role = Qt::DisplayRole ) const;
int rowCount( const QModelIndex &parent = QModelIndex() ) const;
int columnCount( const QModelIndex &parent = QModelIndex() ) const;
QModelIndex index( int row, int column,
const QModelIndex &parent = QModelIndex() ) const;
QModelIndex parent( const QModelIndex &index ) const;
private:
QObject *m_root;
};
构造器和MulModel
类一样简单。它不是记住乘法表的维数,而是存储一个指向根QObject
的指针作为m_root
。
在清单 5-31 中显示的headerData
方法比MulModel
方法稍微复杂一些,因为它只返回水平标题。从方法中可以看出,所有树节点都有两列:一列用于对象名,一列用于类名。
清单 5-31。 树形模型的表头功能
QVariant ObjectTreeModel::headerData(int section,
Qt::Orientation orientation, int role ) const
{
if( role != Qt::DisplayRole || orientation != Qt::Horizontal )
return QVariant();
switch( section )
{
case 0:
return QString( "Object" );
case 1:
return QString( "Class" );
default:
return QVariant();
}
}
如果您将index
方法与ObjectTreeModel
类和MulModel
类进行比较,您可以看到一些真正的差异,这是意料之中的,因为数据以不同的方式表示(并且索引也不同)。在MulModel
中,您不必提供一个index
方法,因为QAbstractTableModel
已经为您实现了它。
ObjectTreeModel
class' index
方法接受一个模型索引、parent
、一列和一行;它在树的表中给出一个位置。索引到实际树的映射是通过模型索引的internalPointer()
方法来处理的。这个方法使得在每个索引中存储一个指针成为可能,并且你可以存储一个指向被索引的QObject
的指针。
如果索引是有效的,您可以获得适当的QObject
,对于它,您希望每个子元素对应一行。这意味着通过使用row
作为从children()
返回的数组的索引,您可以构建一个指向新的QObject
的指针,您可以用它来构建一个新的索引。使用QAbstractItemModel
中可用的createIndex
方法构建索引(参见清单 5-32 )。
在index
方法中,做了一个假设。如果视图请求一个无效的索引,它将获得树的根,这为视图提供了一个开始的方法。
清单 5-32。 老黄牛——把 QObject
s 变成指标
QModelIndex ObjectTreeModel::index(int row, int column,
const QModelIndex &parent ) const
{
QObject *parentObject;
if( !parent.isValid() )
parentObject = m_root;
else
parentObject = static_cast<QObject*>( parent.internalPointer() );
if( row >= 0 && row < parentObject->children().count() )
return createIndex( row, column, parentObject->children().at( row ) );
else
return QModelIndex();
}
给定index
方法,返回可用行数和列数的方法(如清单 5-33 所示)很容易实现。总是有两列,行数简单地对应于children
数组的大小。
清单 5-33。 计算行数,返回 2
为列数
int ObjectTreeModel::rowCount(const QModelIndex &parent ) const
{
QObject *parentObject;
if( !parent.isValid() )
parentObject = m_root;
else
parentObject = static_cast<QObject*>( parent.internalPointer() );
return parentObject->children().count();
}
int ObjectTreeModel::columnCount(const QModelIndex &parent ) const
{
return 2;
}
获取数据几乎和计算行数一样简单。第一列的对象名可以通过objectName
属性获得,而您必须通过QMetaObject
来获得第二列的类名。你还必须确保只为DisplayRole
归还。清单 5-34 的中省略了ToolTipRole
,但是您可以看到DisplayRole
数据是如何被检索的。
清单 5-34。 返回每个指标的实际数据
QVariant ObjectTreeModel::data( const QModelIndex &index, int role) const
{
if( !index.isValid() )
return QVariant();
if( role == Qt::DisplayRole )
{
switch( index.column() )
{
case 0:
return static_cast<QObject*>( index.internalPointer() )->objectName();
case 1:
return static_cast<QObject*>( index.internalPointer() )->
metaObject()->className();
default:
break;
}
}
else if( role == Qt::ToolTipRole )
{
...
}
return QVariant();
}
最后一个方法的实现稍微复杂一些:父方法(见清单 5-35 )返回一个给定索引的父索引。很容易找到从索引中获得的QObject
的父级,但是还需要获得该父级的行号。
解决方案是,如果父对象不是根对象,它也必须有一个祖父对象。对祖父级的children
数组使用indexOf
方法,可以得到父级的行。知道自己孩子的顺序很重要!
清单 5-35。 为父节点构建索引需要向祖父节点请求 indexOf 方法。
QModelIndex ObjectTreeModel::parent(const QModelIndex &index) const
{
if( !index.isValid() )
return QModelIndex();
QObject *indexObject = static_cast<QObject*>( index.internalPointer() );
QObject *parentObject = indexObject->parent();
if( parentObject == m_root )
return QModelIndex();
QObject *grandParentObject = parentObject->parent();
return createIndex( grandParentObject->children().indexOf( parentObject ),
0, parentObject );
}
要尝试全新的ObjectTreeModel
,你可以使用清单 5-36 中的main
函数。main
函数的最大部分用于构建一棵QObjects
树。创建一个带有指向根对象的指针的模型并将其传递给视图只需要四行代码(包括创建和显示视图)。运行应用如图 5-10 中的所示。
清单 5-36。 构建 QObjects
的树,然后使用自定义树模型显示
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QObject root;
root.setObjectName( "root" );
QObject *child;
QObject *foo = new QObject( &root );
foo->setObjectName( "foo" );
child = new QObject( foo );
child->setObjectName( "Mark" );
child = new QObject( foo );
child->setObjectName( "Bob" );
child = new QObject( foo );
child->setObjectName( "Kent" );
QObject *bar = new QObject( &root );
bar->setObjectName( "bar" );
...
ObjectTreeModel model( &root );
QTreeView tree;
tree.setModel( &model );
tree.show();
return app.exec();
}
编辑模型
之前的两个模型——一个二维数组和一棵树——显示了复杂的结构,但它们是只读的。这里显示的IntModel
非常简单——只是一个整数列表——但是可以编辑。
清单 5-37 显示了IntModel
的类声明,它基于最简单的抽象模型库:QAbstractListModel
(这意味着正在创建一个一维列表)。
这个类的方法比MulModel
和ObjectTreeModel
少。唯一的新闻是用于使模型可写的setData
方法。
清单 5-37。IntModel
的方法比 MulModel
少,但是 MulModel
没有 setData
。
class IntModel : public QAbstractListModel
{
public:
IntModel( int count, QObject *parent = 0 );
Qt::ItemFlags flags( const QModelIndex &index ) const;
QVariant data( const QModelIndex &index, int role = Qt::DisplayRole ) const;
int rowCount( const QModelIndex &parent = QModelIndex() ) const;
bool setData( const QModelIndex &index, const QVariant &value,
int role = Qt::EditRole );
private:
QList<int> m_values;
};
因为IntModel
是一个非常简单的模型,所以它也有许多简单的方法。首先,如清单 5-38 所示的构造器用通过count
指定的值的数量初始化列表。
清单 5-38。 易如一、二、三...构造器只是填充列表。
IntModel::IntModel( int count, QObject *parent )
{
for( int i=0; i<count; ++i )
m_values << i+1;
}
行数等于m_values
列表的count
属性。这意味着rowCount
就像清单 5-39 一样简单。
清单 5-39。 行数是列表中项目的数量。
int IntModel::rowCount( const QModelIndex &parent ) const
{
return m_values.count();
}
返回每个索引的数据也很容易(见清单 5-40);你可以使用index
的rows
属性在m_values
列表中查找正确的值。返回与EditRole
相同的DisplayRole
的QVariant
。EditRole
代表用于初始化编辑器的值。如果忽略它,用户每次都必须从一个空的编辑器开始。
清单 5-40。 返回值就像在列表中查找一样简单。
QVariant IntModel::data( const QModelIndex &index, int role ) const
{
if( role != Qt::DisplayRole || role != Qt::EditRole )
return QVariant();
if( index.column() == 0 && index.row() < m_values.count() )
return m_values.at( index.row() );
else
return QVariant();
}
要使一个项目可编辑,返回标志值ItemIsEditable
和ItemIsSelectable
是很重要的。通过返回ItemIsEnabled
,这个项目看起来也是活动的。flag
方法如清单 5-41 所示。
清单 5-41。 标记可编辑性、可选择性和被启用
Qt::ItemFlags IntModel::flags( const QModelIndex &index ) const
{
if(!index.isValid())
return Qt::ItemIsEnabled;
return Qt::ItemIsSelectable | Qt::ItemIsEditable | Qt::ItemIsEnabled;
}
清单 5-42 显示了setData
方法,这是整个IntModel
类中最复杂的方法,仍然适合七行代码。它首先检查给定的索引是否有效,角色是否是EditRole
。(EditRole
是适合编辑的格式的数据,是用户编辑一个值后从视图中得到的。)
在您确定索引和角色都很好之后,您必须确保已经发生了实际的变化。如果值没有发生变化(或者如果索引或角色无效),则返回false
,表示没有发生变化。
当实际变化发生时,模型的值被更新,并且在返回true
之前发出dataChanged
信号。不要忘记发出信号并返回正确的值;否则,模型和视图之间的交互将会失败。
清单 5-42。 根据编辑动作更新模型
bool IntModel::setData( const QModelIndex &index, const QVariant &value, int role )
{
if( role != Qt::EditRole ||
index.column() != 0 ||
index.row() >= m_values.count() )
return false;
if( value.toInt() == m_values.at( index.row() ) )
return false;
m_values[ index.row() ] = value.toInt();
emit dataChanged( index, index );
return true;
}
清单 5-43 和图 5-11 显示了使用中的IntModel
。可编辑的模型不会以任何方式影响main
功能。这是模型和视图同意使用模型的flag
方法的返回值。
清单 5-43。 用 IntModel
带 QListView
int main( int argc, char **argv )
{
QApplication app( argc, argv );
IntModel model( 25 );
QListView list;
list.setModel( &model );
list.show();
return app.exec();
}
**图 5-11。**T3T0正在编辑
排序和过滤模型
来自模型的数据通常是未排序的,但是您可以通过实现模型的sort
方法来启用排序。如果您使用一个树形视图或者表格视图来显示您的模型,您可以通过将属性sortingEnabled
设置为true
来允许用户点击标题进行排序。
只要您使用QStandardItemModel
模型并坚持使用QVariant
处理的类型,排序马上就能工作。但是,您肯定会遇到不希望更改模型来执行排序的情况。这就是代理模型的用武之地。
一个代理模型是一个将另一个类包装在其自身中,转换它,并取代它的模型。包装后的模型通常被称为源模型。在代理模型上执行的所有操作都被转发到源模型,并且源模型中的所有更改都被传播到代理模型。要实现一个代理模型,从QAbstractProxyModel
类开始(如果您想要排序或过滤一个模型,使用QSortFilterProxyModel
类)。
首先,让我们通过代理模型提供自定义排序。在你开始实现代理模型之前,你可能想看看清单 5-44 中的main
函数。main
功能显示代理模型sorter
被插入到源模型(model
和视图(table
)之间。通过使用setSourceModel(QAbstractItemModel*)
方法将源模型分配给代理模型。然后代理被用作视图中的模型,而不是直接使用源。
清单 5-44。 源模型被分配给代理模型,然后被视图使用,而不是直接使用源模型。
`int main( int argc, char **argv )
{
QApplication app( argc, argv );
QStringListModel model;
QStringList list;
list << "Totte" << "Alfons" << "Laban" << "Bamse" << "Skalman";
model.setStringList( list );
SortOnSecondModel sorter;
sorter.setSourceModel( &model );
QTableView table;
table.setModel( &sorter );
table.setSortingEnabled( true );
table.show();
return app.exec();
}`
如果您想通过继承QSortFilterProxyModel
的类提供自定义排序,您需要覆盖lessThan(const QModelIndex&, const QModelIndex&)
方法。代理类本身非常简单——它只需要一个构造器和一个覆盖方法。示例排序代理模型在按字母顺序对字符串进行排序之前会忽略字符串的第一个字母。这个类叫做SortOnSecondModel
,声明如清单 5-45 所示。
清单 5-45。 自定义排序代理模型的类声明
class SortOnSecondModel : public QSortFilterProxyModel
{
public:
SortOnSecondModel( QObject *parent = 0 );
protected:
bool lessThan( const QModelIndex &left, const QModelIndex &right ) const;
};
SortOnSecondModel
的构造器很简单;它只是将父对象传递给基类的构造器。该类的代码包含在清单 5-46 所示的lessThan
方法中。
清单 5-46。lessThan
方法在比较字符串之前会忽略它们的第一个字符。
bool SortOnSecondModel::lessThan( const QModelIndex &left,
const QModelIndex &right ) const
{
QString leftString = sourceModel()->data( left ).toString();
QString rightString = sourceModel()->data( right ).toString();
if( !leftString.isEmpty() )
leftString = leftString.mid( 1 );
if( !rightString.isEmpty() )
rightString = rightstring.mid( 1 );
return leftString < rightString;
}
在该方法中,您使用sourceModel()
方法获取对源模型的引用,并从中获取实际数据进行比较。在比较字符串之前,从左右字符串中截取第一个字母(如果有)。图 5-12 显示了应用程序运行时,源模型按照代理模型的排序顺序进行排序。
图 5-12。 自定义排序代理模型在行动
当模型的数据改变时,排序不会自动更新,但是可以通过将代理模型的dynamicSortFilter
属性设置为true
来改变。在使用这种方法之前,请确保您的模型足够小,以便在它再次发生变化之前有时间进行排序。
之前的应用只使用了QSortFilterProxyModel
的排序功能。如果您需要过滤一个模型以省去几行,您可以重新实现filterAcceptsRow
方法。使用filterAcceptsColumn
对列进行过滤。这些方法接受源索引和行(或列),如果要显示行(或列),则返回布尔值 true。
总结
使用模型和视图似乎是一种过于复杂的做事方式,但是最终的软件是用一种已经被证明是灵活和强大的结构构建的。
当您处理需要以多种方式显示相同数据的情况时,应该考虑使用模型-视图方法;处理常见的选择;或者只显示列表、树或数据表。
使用带有自定义代理和模型的标准视图通常是比提供完全自定义的小部件更好的解决方案。****
六、创建小部件
T 术语小部件是组成应用程序的各种视觉元素的统称:按钮、标题栏、文本框、复选框等等。关于使用窗口小部件创建用户界面,有两种观点:要么坚持使用标准的窗口小部件,要么冒险创建自己的窗口小部件。Qt 两者都支持。
除非你有深奥的需求,否则你应该尽可能地坚持使用既定的小部件。当您使用 Qt 时,这让您的生活变得非常简单,因为标准的小部件在大多数平台上都是原生的。但是,如果你想在野外行走,你可以利用 Qt 出色的造型能力,继承小部件并覆盖它们的绘画;或者简单地创建自己的小部件。在某些情况下,您需要这样做,因为您的应用程序处理无法以其他方式显示的数据。本章向您展示了如何调整和创建小部件来满足您自己的需求。
编写小工具
你每次都以同样的方式组合相同的部件吗?复合小部件会有所帮助。一个复合小部件是通过组合已经存在的小部件并为它们提供一组良好的属性、信号和插槽来构建的。
例如,键盘很难管理。图 6-1 显示了一个由一串QPushButton
和一个QLineEdit
组成的小键盘。设置它包括创建一个网格布局,将小部件放入布局中,然后进行连接以使事情正常工作。
图 6-1。 由一个 QLineEdit
和一组 QPushButton
小工具组成的小键盘
让我们看看小部件集合的哪些部分是“有趣的”,哪些部分是“不有趣的”(“不有趣”类别中的所有内容都是不必要的复杂)。这种复杂性可以通过创建复合小部件来隐藏。
应用程序的其余部分需要知道QLineEdit
的文本;其他一切只是混淆了你的应用程序的源代码。清单 6-1 展示了NumericKeypad
类的类声明。如果您关注信号和公共部分,您会发现文本是所有可用的内容。私有部分涉及小部件的内部:文本、行编辑和一个用于捕捉按钮输入的槽。
清单 6-1。 复合小部件的类声明NumericKeypad
class NumericKeypad : public QWidget
{
Q_OBJECT
public:
NumericKeypad( QWidget *parent = 0 );
const QString& text() const;
public slots:
void setText( const QString &text );
signals:
void textChanged( const QString &text );
private slots:
void buttonClicked( const QString &text );
private:
QLineEdit *m_lineEdit;
QString m_text
};
在了解如何管理文本之前,您应该了解小部件是如何构造的。您可以从类声明中看出这个小部件是基于一个QWidget
的。在构造器中,一个布局被应用到QWidget
(this
);然后将QLineEdit
和QPushButton
小部件放到布局中。源代码如清单 6-2 所示。
清单 6-2。 在构造器中创建和布局按钮
NumericKeypad::NumericKeypad( QWidget *parent )
{
QGridLayout *layout = new QGridLayout( this );
m_lineEdit = new QLineEdit
m_lineEdit->setAlignment( Qt::AlignRight );
QPushButton *button0 = new QPushButton( tr("0") );
QPushButton *button1 = new QPushButton( tr("1") );
...
QPushButton *buttonDot = new QPushButton( tr(".") );
QPushButton *buttonClear = new QPushButton( tr("C") );
layout->addWidget( m_lineEdit, 0, 0, 1, 3 );
layout->addWidget( button1, 1, 0 );
layout->addWidget( button2, 1, 1 );
...
layout->addWidget( buttonDot, 4, 1 );
layout->addWidget( buttonClear, 4, 2 );
...
}
您可能会发现前一个示例中遗漏的构造器部分更有趣。每个QPushButton
对象,除了 C 按钮,都使用QSignalMapper
的setMapping(QObject *, const QString&)
方法映射到一个QString
。设置好所有映射后,来自按钮的clicked()
信号都连接到信号映射器的map()
插槽。调用map
时,信号映射器会查看信号发送器,通过mapped(const QString&)
信号发出映射后的字符串。该信号依次连接到this
的buttonClicked(const QString&)
插槽。你可以在清单 6-3 中看到这是如何设置的。
清单还显示 C 按钮的clicked
信号被映射到QLineEdit
的clear
槽,QLineEdit
的textChanged
信号被连接到小键盘小部件的setText
方法。这意味着单击 C 按钮会清除文本;对QLineEdit
的任何更改——无论是通过用户交互还是按下 C 按钮——都会更新NumericKeypad
对象的文本。
清单 6-3。 在构造器中设置信号映射
NumericKeypad::NumericKeypad( QWidget *parent )
{
...
layout->addWidget( buttonDot, 4, 1 );
layout->addWidget( buttonClear, 4, 2 );
QSignalMapper *mapper = new QSignalMapper( this );
mapper->setMapping( button0, "0" );
mapper->setMapping( button1, "1" );
...
mapper->setMapping( button9, "9" );
mapper->setMapping( buttonDot, "." );
connect( button0, SIGNAL(clicked()), mapper, SLOT(map()) );
connect( button1, SIGNAL(clicked()), mapper, SLOT(map()) );
...
connect( button9, SIGNAL(clicked()), mapper, SLOT(map()) );
connect( buttonDot, SIGNAL(clicked()), mapper, SLOT(map()) );
connect( mapper, SIGNAL(mapped(QString)), this, SLOT(buttonClicked(QString)) );
connect( buttonClear, SIGNAL(clicked()), m_lineEdit, SLOT(clear()) );
connect( m_lineEdit, SIGNAL(textChanged(QString)), this, SLOT(setText(QString)) );
}
处理文本变化的插槽如清单 6-4 中的所示。buttonClicked
槽只是将新文本附加到当前文本的末尾,当前文本保存在QString
变量m_text
中。文本保存在一个单独的字符串中,而不仅仅是保存在QLineEdit
中,因为用户可以通过在编辑器中键入来直接更改文本。如果做了这样的改变,你无法判断setText
的电话是否相关,因为你无法比较当前文本和新文本。这可能导致textChanged
方法在没有实际变化发生的情况下被发出。
提示您可以通过将文本编辑器的 enabled 属性设置为false
来解决这个问题,但是这会导致编辑器看起来不一样。
清单 6-4。 处理文字的变化
void NumericKeypad::buttonClicked( const QString &newText )
{
setText( m_text + newText );
}
void NumericKeypad::setText( const QString &newText )
{
if( newText == m_text )
return;
m_text = newText;
m_lineEdit->setText( m_text );
emit textChanged( m_text );
}
setText
槽从检查是否发生了实际变化开始。如果是这样,内部文本和QLineEdit
文本都会被更新。然后发出带有新文本的textChanged
信号。
任何对QLineEdit
的文本感兴趣的外部小部件都可以连接到textChanged
信号或者通过调用text
方法来询问。如清单 6-5 所示,这个方法很简单——它返回m_text
。
清单 6-5。 返回当前文本
const QString& NumericKeypad::text() const
{
return m_text;
}
使用复合小部件和使用普通小部件一样简单。在清单 6-6 中,你可以看到如何使用NumericKeypad
小部件。键盘放在一个标签上只是为了测试textChanged
信号。标签的setText
插槽连接到键盘的textChanged
信号。图 6-2 显示了实际应用。QLineEdit
的文字始终通过QLabel
反映出来。
清单 6-6。 使用 NumericKeypad
控件
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QWidget widget;
QVBoxLayout *layout = new QVBoxLayout( &widget );
NumericKeypad pad;
layout->addWidget( &pad );
QLabel *label = new QLabel;
layout->addWidget( label );
QObject::connect( &pad, SIGNAL(textChanged(const QString&)),
label, SLOT(setText(const QString&)) );
widget.show();
return app.exec();
}
图 6-2。 复合小部件直播
编写小部件有很多好处。使用带有main
功能的NumericKeypad
小部件比在那里配置所有按钮和QLineEdit
小部件要容易得多。此外,信号和插槽创建了一个很好的界面来连接键盘和其他小部件。
后退一步,看看小部件本身——您会发现组件远比如何设置解决方案的知识更具可重用性。这使得它更有可能在应用程序中的更多地方使用(或在更多应用程序中使用)。一旦您使用它两次,您将节省开发时间和精力,因为您只需要设置一次信号映射器。您也知道它是可行的,因为您已经验证过一次了——省去了您定位 bug 的问题。
改变和增强小工具
定制小部件的另一种方法是改变或增强它们的行为。比如一个QLabel
可以做出一个很棒的数字时钟小部件;缺少的只是用当前时间更新文本的部分。由此产生的小部件可以在图 6-3 的中看到。
图 6-3。 一个充当时钟的标签
通过使用已经存在的小部件作为新小部件的起点,您可以避免开发绘制、大小提示等所需的所有逻辑。相反,您可以专注于用您需要的功能来增强小部件。让我们看看这是如何做到的。
首先,必须有一种方法以均匀的间隔检查时间,例如每秒一次。每次检查时,文本都必须更新为当前时间。要查看每秒钟的时间,可以使用一个QTimer
。可以设置一个定时器对象,在给定的时间间隔发出timeout
信号。通过将此信号连接到时钟标签的一个插槽,您可以检查时间并每秒相应地更新文本。
清单 6-7 显示了ClockLabel
小部件的类声明。它有一个槽,updateTime
,和一个构造器。这(和继承QLabel
)就是实现这个定制行为所需要的全部。
清单 6-7。ClockLabel
类声明
*`class ClockLabel : public QLabel
{
Q_OBJECT
public:
ClockLabel( QWidget *parent = 0 );
private slots:
void updateTime();
};`
你可以在清单 6-8 中看到ClockLabel
小部件的实现。从底部开始,updateTime()
槽非常简单——它所做的只是将文本设置为当前时间。QTime::toString()
方法根据格式化字符串将时间转换为字符串,其中hh
表示当前小时,mm
表示分钟。
在构造器中创建了一个QTimer
对象。间隔(发出timeout
信号的频率)设置为 1000 毫秒(1 秒)。
提示将毫秒数除以 1000,得到相等的秒数。1000 毫秒相当于 1 秒。
当定时器的时间间隔设定后,定时器的timeout()
信号在定时器启动前连接到this
的updateTime
信号。QTimer
物体必须在开始周期性发射timeout
信号之前启动。使用stop()
方法关闭信号发射。这意味着您可以设置一个计时器,然后根据应用程序的当前状态打开和关闭它。
注意 QTimer
对象对于用户界面等来说已经足够好了,但是如果你正在开发一个需要精确计时的应用程序,你必须使用另一种解决方案。间隔的准确性取决于应用程序运行的平台。
在构造器完成之前,对updateTime
进行显式调用,这确保了文本立即更新。否则,在文本更新之前需要一秒钟,用户将能够在短时间内看到未初始化的小部件。
清单 6-8。ClockLabel
实现
ClockLabel::ClockLabel( QWidget *parent ) : QLabel( parent )
{
QTimer *timer = new QTimer( this );
timer->setInterval( 1000 );
connect( timer, SIGNAL(timeout()), this, SLOT(updateTime()) );
timer->start();
updateTime();
}
void ClockLabel::updateTime()
{
setText( QTime::currentTime().toString( "hh:mm" ) );
}
有时你可能想增强一个现有的部件;例如,您可能希望一个插槽接受另一种类型的参数,或者在缺少插槽的地方。您可以继承基本小部件,添加插槽,然后使用结果类而不是原始类。
抓捕事件
小部件通过提供对触发信号和提供交互的实际用户生成事件的访问,为处理用户动作提供了催化剂。事件是用户给计算机的原始输入。通过对这些事件作出反应,用户界面可以与用户交互并提供预期的功能。
事件由事件处理程序处理,事件处理程序是虚拟的受保护方法,当小部件类需要对给定事件做出反应时,它们会覆盖这些方法。每个事件都伴随着一个事件对象。所有事件类的基类是QEvent
,它使接收者能够接受或忽略使用相同名称的方法的事件。被忽略的事件可以通过 Qt 传播到父部件。
图 6-4 显示了QApplication
接收到的触发事件的用户动作。这些事件导致应用程序调用受影响的小部件,小部件对事件做出反应,并在必要时发出信号。
图 6-4。 用户动作在到达小部件之前通过 QApplication
对象,并触发驱动应用程序的信号
监听用户
为了更好地理解事件处理是如何工作的,您可以创建一个小部件,它发出一个带有字符串的信号,告诉您刚刚收到了哪个事件。widget 类叫做EventWidget
,信号叫做gotEvent(const QString &)
。通过将这个信号与一个QTextEdit
挂钩,您可以获得一个事件日志,您可以使用它来研究这些事件。
首先快速浏览一下清单 6-9 。EventWidget
有一系列的事件处理程序,下面的列表描述了每个事件处理程序的职责。这些事件处理方法是一些最常见的方法,但还有更多。在列表的每一行中,我保留了事件对象类型和事件名称,这样您就可以看到哪些事件是相关的。例如,所有焦点事件都将一个QFocusEvent
指针作为参数。
closeEvent( QCloseEvent* )
:小部件即将关闭。(你在第四章中看到了这是如何使用的。)contextMenuEvent( QContextMenuEvent* )
:请求上下文菜单。enterEvent( QEvent* )
:鼠标指针已经进入小部件。focusInEvent( QFocusEvent* )
:小工具获得焦点。focusOutEvent( QFocusEvent* )
:焦点离开小工具。hideEvent( QHideEvent* )
:小工具即将被隐藏。keyPressEvent( QKeyEvent* )
:键盘按键被按下。keyReleaseEvent( QKeyEvent* )
:释放了一个键盘键。leaveEvent( QEvent* )
:鼠标指针离开了小工具。mouseDoubleClickEvent( QMouseEvent* )
:鼠标按钮被双击。mouseMoveEvent( QMouseEvent* )
:鼠标在小工具上移动。mousePressEvent( QMouseEvent* )
:鼠标按钮被按下。mouseReleaseEvent( QMouseEvent* )
:鼠标按钮已被释放。- 小工具需要重新绘制。
resizeEvent( QResizeEvent* )
:小工具已调整大小。showEvent( QShowEvent* )
:即将显示小工具。wheelEvent( QWheelEvent* )
:鼠标滚动视图被移动。
在前面的列表中,您可以看到相关事件共享事件对象类型。例如,所有鼠标事件——比如按下、释放、移动和双击——都需要一个QMouseEvent
。
只有一个QEvent
的事件可以被认为是简单的通知。在QEvent
对象中没有携带额外的信息,所以只需要知道事件发生了。因为QEvent
是所有事件类的基类,所以共享QEvent
作为事件对象类型的事件处理程序不像鼠标事件那样相关。
一些事件处理程序被排除在列表和EventWidget
类之外。尽管缺少的处理程序并没有降低相关性,但是它们与类中使用的处理程序并没有显著的不同。
清单 6-9。EventWidget
实现了大多数事件处理程序,并为每个事件发出 gotEvent
信号。
class EventWidget : public QWidget
{
Q_OBJECT
public:
EventWidget( QWidget *parent = 0 );
signals:
void gotEvent( const QString& );
protected:
void closeEvent( QCloseEvent * event );
void contextMenuEvent( QContextMenuEvent * event );
void enterEvent( QEvent * event );
void focusInEvent( QFocusEvent * event );
void focusOutEvent( QFocusEvent * event );
void hideEvent( QHideEvent * event );
void keyPressEvent( QKeyEvent * event );
void keyReleaseEvent( QKeyEvent * event );
void leaveEvent( QEvent * event );
void mouseDoubleClickEvent( QMouseEvent * event );
void mouseMoveEvent( QMouseEvent * event );
void mousePressEvent( QMouseEvent * event );
void mouseReleaseEvent( QMouseEvent * event );
void paintEvent( QPaintEvent * event );
void resizeEvent( QResizeEvent * event );
void showEvent( QShowEvent * event );
void wheelEvent( QWheelEvent * event );
};
在继续查看事件处理程序之前,先看一下main
函数,它显示了带有日志的小部件。源代码如清单 6-10 所示。日志显示在一个QTextEdit
小部件中,gotEvent
信号连接到日志的append(const QString&)
槽。这是显示小部件和运行应用程序之前需要做的所有准备工作。
清单 6-10。 创建一个日志小工具和一个 EventWidget
并使用它们
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QTextEdit log;
EventWidget widget;
QObject::connect( &widget, SIGNAL(gotEvent(const QString&)),
&log, SLOT(append(const QString&)) );
log.show();
widget.show();
return app.exec();
}
当应用程序运行时,日志窗口显示在包含事件小部件的窗口旁边。示例日志如图 6-5 中的所示。列出所有事件,并显示某些事件的选定参数。例如,QKeyEvent
事件显示文本,而QMouseEvent
事件显示指针位置。
图 6-5。 来自 EventWidget
清单 6-11 提供了一个closeEvent
处理程序的例子。enterEvent
、leaveEvent
、showEvent
、hideEvent
和paintEvent
处理程序都只是记录事件的名称。show、hide 和 paint 事件有自己的事件对象类型。QShowEvent
和QHideEvent
类不向QEvent
类添加任何东西。QPaintEvent
确实增加了很多信息(在本章的后面你会更仔细地观察这个事件)。
清单 6-11。 一种简单的事件处理方法
void EventWidget::closeEvent( QCloseEvent * event )
{
emit gotEvent( tr("closeEvent") );
}
处理键盘事件
处理键盘活动的事件是keyPressEvent
和keyReleaseEvent
。它们看起来都很相似,所以在清单 6-12 中只显示了keyPressEvent
。因为大多数现代环境支持自动重复键,所以在看到 keyReleaseEvent
之前,您可能会得到几个keyPressEvent
。您通常不能指望看到keyReleaseEvent
——用户可能会在释放按键之前在部件之间移动焦点(使用鼠标)。
如果您需要确保您的小部件获得所有键盘事件的*,请使用grabKeyboard
和releaseKeyboard
方法。当一个小部件抓取键盘时,所有的按键事件都会发送给它,不管哪个小部件当前拥有焦点。*
清单中的事件处理程序显示了修饰键和被按下的键的文本。修改器存储为一个位掩码,几个修改器可以同时激活。
清单 6-12。 一个键盘事件的处理方法
void EventWidget::keyPressEvent( QKeyEvent * event ) { emit gotEvent( QString("keyPressEvent( text:%1, modifiers:%2 )") .arg( event->text() ) .arg( event->modifiers()==0?tr("NoModifier"):( (event->modifiers() & Qt::ShiftModifier ==0 ? tr(""): tr("ShiftModifier "))+ (event->modifiers() & Qt::ControlModifier ==0 ? tr(""): tr("ControlModifier "))+ (event->modifiers() & Qt::AltModifier ==0 ? tr(""): tr("AltModifier "))+ (event->modifiers() & Qt::MetaModifier ==0 ? tr(""): tr("MetaModifier "))+ (event->modifiers() & Qt::KeypadModifier ==0 ? tr(""): tr("KeypadModifier "))+ (event->modifiers()&Qt::GroupSwitchModifier ==0 ? tr(""): tr("GroupSwitchModifier")) ) ) ); }
处理鼠标事件
当用户试图调出上下文菜单(右键单击某个东西时出现的菜单——通常提供剪切、复制和粘贴等操作)时,就会触发上下文菜单事件。鼠标和键盘都可以触发该事件。事件对象包含请求的来源(reason
)和事件发生时鼠标指针的坐标。处理程序如清单 6-13 中的所示。如果上下文菜单事件被忽略,它将被重新解释并作为鼠标事件发送(如果可能)。
所有带有鼠标位置的事件对象都有pos()
和globalPos()
方法。pos
方法是小部件本地坐标中的位置,这有利于更新小部件本身。如果您想在事件发生的位置创建一个新的小部件,您需要使用全局坐标。位置由x
和y
坐标组成,可以通过x
、y
、globalX
和globalY
方法直接从事件对象中获取。
清单 6-13。 已请求上下文菜单。
void EventWidget::contextMenuEvent( QContextMenuEvent * event )
{
emit gotEvent( QString("contextMenuEvent( x:%1, y:%2, reason:%3 )")
.arg(event->x())
.arg(event->y())
.arg(event->reason()==QContextMenuEvent::Other ? "Other" :
(event->reason()==QContextMenuEvent::Keyboard ? "Keyboard" :
"Mouse")) );
}
上下文菜单事件携带鼠标位置,就像QMouseEvent
一样。鼠标事件有mousePressEvent
、mouseReleaseEvent
、mouseMoveEvent
和mouseDoubleClickEvent
。你可以在清单 6-14 中看到后者。处理器显示button
以及x
和y
坐标。
在处理鼠标事件时,重要的是要理解只有当鼠标按钮被按下时,移动事件才会被发送。如果您需要随时获取移动事件,您必须使用mouseTracking
属性启用鼠标跟踪。
如果你想得到所有的鼠标事件,你可以像使用键盘一样使用鼠标。为此使用方法grabMouse()
和releaseMouse()
。只是要小心,因为当鼠标被抓取时发生的错误会阻止所有应用程序的鼠标交互。规则是只在必要时抓取,尽快释放,并且永远不要忘记释放鼠标。
清单 6-14。 一个鼠标事件的处理方法
void EventWidget::mouseDoubleClickEvent( QMouseEvent * event )
{
emit gotEvent( QString("mouseDoubleClickEvent( x:%1, y:%2, button:%3 )")
.arg( event->x() )
.arg( event->y() )
.arg( event->button()==Qt::LeftButton? "LeftButton":
event->button()==Qt::RightButton?"RightButton":
event->button()==Qt::MidButton? "MidButton":
event->button()==Qt::XButton1? "XButton1":
"XButton2" ) );
}
使用鼠标滚轮
鼠标滚轮通常被认为是鼠标的一部分,但是事件有一个单独的事件对象。该对象包含事件发生时鼠标指针的位置,以及滚轮的方向和滚动的大小(delta
)。事件处理程序如清单 6-15 中的所示。
鼠标滚轮事件首先被发送到鼠标指针下的小部件。如果它没有在那里被处理,它将被传递给具有焦点的小部件。
清单 6-15。 轮子与鼠标的其他部分是分开的。
void EventWidget::wheelEvent( QWheelEvent * event )
{
emit gotEvent( QString("wheelEvent( x:%1, y:%2, delta:%3, orientation:%4 )")
.arg( event->x() )
.arg( event->y() )
.arg( event->delta() ).arg( event->orientation()==Qt::Horizontal?
"Horizontal":"Vertical" ) );
}
在EventWidget
类中实现了更多的事件处理程序。通过在小部件上尝试不同的东西,然后研究日志,您可以了解很多关于小部件的信息。
过滤事件
创建事件过滤器比继承小部件类和覆盖事件处理类更容易。一个事件过滤器是一个继承QObject
的类,它实现了eventFilter(QObject*, QEvent*)
方法。该方法使得在事件到达目的地之前拦截它们成为可能。然后可以过滤事件(允许通过或停止)。
事件过滤器可用于实现许多特殊功能,如鼠标手势和识别按键序列。它们可以用来增强小部件或改变小部件的行为,而不必对小部件进行子类化。
让我们尝试一个事件过滤器,从事件队列中删除任何数字键按压。该类的声明和实现如清单 6-16 所示。有趣的部分是eventFilter
方法,它有两个参数:指向目的地QObject
( dest
)的指针和指向QEvent
对象(event
)的指针。通过使用type
检查事件是否是按键事件,您知道event
指针可以被转换为QKeyEvent
指针。QKeyEvent
类有一个 text 方法,您可以用它来确定按下的键是否是一个数字。
如果按键来自数字键,则返回true
,表示过滤器处理了该事件。这将阻止事件到达目标对象。对于所有其他事件,将返回基类实现的值,这将导致要么由基类筛选器处理事件,要么让它通过最终的目标对象。
清单 6-16。 事件过滤类 KeyboardFilter
停止按键为数字键。
class KeyboardFilter : public QObject
{
public:
KeyboardFilter( QObject *parent = 0 ) : QObject( parent ) {}
protected:
bool eventFilter( QObject *dist, QEvent *event )
{
if( event->type() == QEvent::KeyPress )
{
QKeyEvent *keyEvent = static_cast<QKeyEvent*>( event );
static QString digits = QString("1234567890");
if( digits.indexOf( keyEvent->text() ) != −1 )
return true;
}
return QObject::eventFilter(dist, event);
}
};
为了测试事件过滤器,你可以把它安装在一个QLineEdit
(它的源代码显示在清单 6-17 )上。像任何其他对象一样创建QLineEdit
和KeyboardFilter
对象。然后在编辑器显示之前,使用installEventFilter(QObject*)
在编辑行上安装过滤器。
清单 6-17。 要使用事件过滤器,必须将其安装在 widget 上。然后,该小部件的事件通过过滤器传递。
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QLineEdit lineEdit;
KeyboardFilter filter;
lineEdit.installEventFilter( &filter );
lineEdit.show();
return app.exec();
}
尝试使用行编辑。按键被过滤,但是仍然可以使用剪贴板将数字强制输入到编辑器中。您在实现和应用事件过滤器时必须小心——可能会有难以预见的副作用。
如果您在设计过滤器时非常小心,您可以通过过滤、响应和重定向事件来增强应用程序,从而使用户的交互更加容易。例如,在绘图区域捕捉键盘事件,将它们重定向到文本编辑器,并移动焦点。这使得用户无需在输入文本之前点击文本编辑器,使得应用程序更加用户友好。
从头开始创建定制小工具
当其他方法都不起作用时,或者如果您选择遵循一种不同的方法,您可能会陷入这样的境地:您必须创建自己的小部件。创建一个定制的小部件包括实现一个信号和插槽的接口,以及一组适用的事件处理程序。
为了向你展示这是如何做到的,我将通过CircleBar
小部件来指导你(见图 6-6 )。图中所示的应用程序在水平滑块上有一个CircleBar
小部件。移动滑块会更改圆栏的值,当鼠标悬停在圆栏小工具上时旋转鼠标滚轮也是如此。
CircleBar
控件的功能是通过改变实心圆的大小来显示 0 到 100 之间的值。一个完整的圆圈表示 100,而中间的圆点表示 0。用户可以使用鼠标滚轮更改显示的值。
图 6-6。CircleBar
小部件和一个水平滑块
*main
函数,如清单 6-18 所示,设置滑块和圆形条。代码首先为保存滑块和圆形条的QVBoxLayout
创建一个基本小部件。滑动条和圆形条相互连接,因此来自其中一个的valueChanged
信号导致对另一个的setValue
调用。然后在应用程序启动前显示基本小部件。
清单 6-18。 设置 CircleBar
和滑块
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QWidget base;
QVBoxLayout *layout = new QVBoxLayout( base );
CircleBar *bar = new CircleBar;
QSlider *slider = new QSlider( Qt::Horizontal );
layout->addWidget( bar );
layout->addWidget( slider );
QObject::connect( slider, SIGNAL(valueChanged(int)), bar, SLOT(setValue(int)) );
QObject::connect( bar, SIGNAL(valueChanged(int)), slider, SLOT(setValue(int)) );
base.show();
return app.exec();
}
从main
函数中你可以看到CircleBar
小部件需要一个setValue(int)
插槽和一个valueChanged(int)
信号。为了使接口完整,还需要有一个value
方法来读取值。
因为小部件是由代码绘制的,所以paintEvent
需要重新实现。您还需要重新实现wheelEvent
,因为您想要监听鼠标滚轮的活动。我选择添加一个heightForWidth
函数,它将用于保持窗口小部件的方形,以及一个sizeHint
方法,它给了窗口小部件一个很好的起始尺寸。
清单 6-19 中的类声明总结了所有这些。
清单 6-19。CircleBar
控件类的类声明
class CircleBar : public QWidget
{
Q_OBJECT
public:
CircleBar( int value = 0, QWidget *parent = 0 );
int value() const;
int heightForWidth( int ) const;
QSize sizeHint() const;
public slots:
void setValue( int );
signals:
void valueChanged( int );
protected:
void paintEvent( QPaintEvent* );
void wheelEvent( QWheelEvent* );
private:
int m_value;
};
清单 6-20 中所示的CircleBar
类的构造器首先初始化保存在m_value
成员中的内部值。它还创建了一个新的大小策略,该策略在两个方向上都是首选的,并告诉布局管理系统监听heightForWidth
方法。
清单 6-20。CircleBar
构件
CircleBar::CircleBar( int value, QWidget *parent ) : QWidget( parent )
{
m_value = value;
QSizePolicy policy( QSizePolicy::Preferred, QSizePolicy::Preferred );
policy.setHeightForWidth( true );
setSizePolicy( policy );
}
大小策略伴随着返回首选小部件大小的heightForWidth(int)
方法和sizeHint
方法。这些方法的实现如清单 6-21 所示。heightForWidth
方法将宽度作为参数,并将想要的高度返回给布局管理器。CircleBar
类中使用的实现将以高度的形式返回给定的宽度,从而得到一个正方形小部件。
清单 6-21。 尺寸处理方法
int CircleBar::heightForWidth( int width ) const
{
return width;
}
QSize CircleBar::sizeHint() const
{
return QSize( 100, 100 );
}
处理值value()
和setValue
的方法如列表 6-22 所示。value
方法很简单——它简单地返回m_value
。在检查是否发生变化之前,setValue
方法将值限制在 0-100 的范围内。如果是,则在调用update
并发出valueChanged
信号之前更新m_value
。
通过调用update()
,重画事件被触发,这导致对paintEvent
的调用。请记住,您不能在paintEvent
方法之外绘制小部件。相反,调用update
然后从paintEvent
方法处理绘画。
清单 6-22。 移交 CircleBar
小部件的值
int CircleBar::value() const
{
return m_value;
}
void CircleBar::setValue( int value )
{
if( value < 0 )
value = 0;
if( value > 100 )
value = 100;
if( m_value == value )
return;
m_value = value;
update();
emit valueChanged( m_value );
}
在清单 6-23 中,你可以看到paintEvent
方法的实现。在看代码之前,您应该知道autoFillBackground
属性是如何工作的。只要它被设置为true
(默认),在进入paintEvent
方法之前,小部件的背景就用适当的颜色填充。这意味着我们不必担心在绘制之前清除小部件的区域。
在paintEvent
方法中计算radius
和factor
辅助变量。然后创建一个QPainter
对象来绘制小部件。先将钢笔设置为黑色,画外圆;然后笔刷设置为黑色,画内圆。钢笔用来画圆的轮廓;笔刷是用来填充的。默认情况下,两者都设置为不绘制任何内容,因此仅在绘制外圆之前设置笔会给出一个圆轮廓。
清单 6-23。 画外圆和内圆
void CircleBar::paintEvent( QPaintEvent *event )
{
int radius = width()/2;
double factor = m_value/100.0;
QPainter p( this );
p.setPen( Qt::black );
p.drawEllipse( 0, 0, width()-1, width()-1 );
p.setBrush( Qt::black );
p.drawEllipse( (int)(radius*(1.0-factor)),
(int)(radius*(1.0-factor)),
(int)((width()-1)*factor)+1,
(int)((width()-1)*factor)+1 );
}
CircleBar
小部件的最后一部分是wheelEvent
方法(见清单 6-24 )。首先,在使用setValue
更新值之前,事件被接受。
QWheelEvent
对象的delta
值表示滚动移动了多少度。大多数鼠标一次滚动 15 度,因此滚轮上的每次“点击”对应 120 度的增量。我选择将 delta 值除以 20,然后用它来改变值。我凭感觉选择了值 20——该条的大小调整得足够快,同时仍能提供足够的精度。
清单 6-24。 根据滚轮移动更新数值
void CircleBar::wheelEvent( QWheelEvent *event )
{
event->accept();
setValue( value() + event->delta()/20 );
}
定制小部件由两部分组成:对应用程序其余部分可见的属性(value
和setValue
)和事件处理程序(paintEvent
和wheelEvent
)。几乎所有的定制窗口小部件都重新实现了paintEvent
方法,而其余要重新实现的事件处理程序是通过确定哪些是实现所需功能所需要的来挑选的。
您的部件和设计者
在您创建了自己的小部件之后,您可能希望将它与 Designer 集成在一起。这样做的好处是,您不会因为使用定制的小部件而被迫离开设计器工作流。另一个好处是,如果你为其他人开发小部件,你可以让他们使用你的小部件和标准的 Qt 小部件。
将小部件与设计器集成有两种方法:一种简单的方法和一种复杂的方法。比较这两种方法,简单的方法在使用 Designer 时需要做更多的工作,而复杂的方法可以与 Designer 无缝集成。让我们从简单的方法开始。
晋级
您可以使用您在本章前面创建的ClockWidget
来测试将您的小部件与 Designer 集成的推广方式。因为它是基于一个QLabel
,所以在你正在设计的表单上画一个QLabel
。现在调出标签的上下文菜单,并选择“升级到自定义小部件”菜单项,这将调出如图 6-7 所示的对话框。该图形有一个类名—头文件名称由设计器自动猜测。
图 6-7。 将一个 QLabel
提升为一个 ClockWidget
为了能够使用 Designer 的这一特性,您必须提供一个采用QWidget
指针的构造器,并使 make 系统可以访问包含文件。这可以通过 QMake 项目文件中的INCLUDEPATH
变量来完成。
重要的是选择一个在定制部件继承树中的部件,以确保 Designer 中显示的所有属性都可以用于您的部件。用户界面编译器生成代码,用于设置设计器中标记为粗体的所有属性。在图 6-8 所示的属性框中,将设置objectName
、geometry
、text
和flat
属性。这意味着如果您升级小部件,您的小部件需要有setObjectName
、setGeometry
、setText
和setFlat
方法。如果您选择从自定义小部件的继承树中升级小部件,您可以通过继承免费获得这些方法。
图 6-8。 标记为粗体的属性将在 uic
生成的代码中设置。
提供插件
如果你花稍微多一点的时间实现一个在 Designer 中工作的插件,你可以跳过 Designer 中的提升方法。相反,您的小部件将与所有其他小部件一起出现在小部件框中。
为 Designer 创建一个插件几乎是一个复制粘贴的工作。在开始创建插件之前,您必须对小部件类声明做一个小小的修改。(对于插件,您将使用本章前面开发的CircleBar
小部件。)类声明如清单 6-25 中的所示。变化的前半部分是添加了QDESIGNER_WIDGET_EXPORT
宏,这确保了该类可以在 Qt 支持的所有平台上的插件中使用。另一半是添加一个以父代作为参数的构造器。这是从uic
生成的代码工作所需要的。
清单 6-25。 修改为 CircleBar
类
class QDESIGNER_WIDGET_EXPORT CircleBar : public QWidget
{
Q_OBJECT
public:
CircleBar( QWidget *parent = 0 );
CircleBar( int value = 0, QWidget *parent = 0 );
int value() const;
int heightForWidth( int ) const;
QSize sizeHint() const;
public slots:
void setValue( int );
signals:
void valueChanged( int );
protected:
void paintEvent( QPaintEvent* );
void wheelEvent( QWheelEvent* );
private:
int m_value;
};
现在你可以开始查看清单 6-26 中的实际插件了。plugin 类只是由QDesignerCustomWidgetInterface
类定义的接口的一个实现。所有的方法都必须实现,每个方法的任务都有严格的定义。
CircleBar
小部件的插件类叫做CircleBarPlugin
。这是命名小部件插件类的常见方式。
清单 6-26。 插件类
#ifndef CIRCLEBARPLUGIN_H
#define CIRCLEBARPLUGIN_H
#include <QDesignerCustomWidgetInterface>
class QExtensionManager;
class CircleBarPlugin : public QObject, public QDesignerCustomWidgetInterface
{
Q_OBJECT
Q_INTERFACES(QDesignerCustomWidgetInterface)
public:
CircleBarPlugin( QObject *parent = 0 );
bool isContainer() const;
bool isInitialized() const;
QIcon icon() const;
QString domXml() const;
QString group() const;
QString includeFile() const;
QString name() const;
QString toolTip() const;
QString whatsThis() const;
QWidget *createWidget( QWidget *parent );
void initialize( QDesignerFormEditorInterface *core );
private:
bool m_initialized;
};
#endif /* CIRCLEBARPLUGIN_H */
首先,小部件必须处理一个初始化标志,这是通过构造器和isInitialized()
和initialize(QDesignerFormEditorInterface*)
方法完成的。方法如清单 6-27 所示。您可以看到实现非常简单,可以在所有小部件插件类之间复制和粘贴。
清单 6-27。 移交初始化
CircleBarPlugin::CircleBarPlugin( QObject *parent )
{
m_initialized = false;
}
bool CircleBarPlugin::isInitialized() const
{
return m_initialized;
}
void CircleBarPlugin::initialize( QDesignerFormEditorInterface *core )
{
if( m_initialized )
return;
m_initialized = true;
}
如果你认为初始化标志处理很简单,你会发现清单 6-28 中的方法甚至更简单。方法isContainer()
、icon()
、toolTip()
、whatsThis()
尽可能少返回。您可以轻松地为您的小部件提供自定义图标、工具提示和这是什么文本。
清单 6-28。 返回最不可能的简单方法
bool CircleBarPlugin::isContainer() const
{
return false;
}
QIcon CircleBarPlugin::icon() const
{
return QIcon();
}
QString CircleBarPlugin::toolTip() const
{
return "";
}
QString CircleBarPlugin::whatsThis() const
{
return "";
}
includeFile()
、name()
和domXml()
方法返回从类名构建的标准化字符串。从name
和domXml
方法返回相同的类名是很重要的。请注意,该名称区分大小写。你可以在清单 6-29 中看到这些方法。
清单 6-29。 返回小部件的 XML、头文件名称和类名
QString CircleBarPlugin::includeFile() const
{
return "circlebar.h";
}
QString CircleBarPlugin::name() const
{
return "CircleBar";
}
QString CircleBarPlugin::domXml() const
{
return "<widget class=\"CircleBar\" name=\"circleBar\">\n"
"</widget>\n";
}
为了控制小部件出现在哪个小部件组中,从group()
方法返回组名。方法实现如清单 6-30 所示。
清单 6-30。 集团加盟设计师
QString CircleBarPlugin::group() const
{
return "Book Widgets";
}
为了帮助设计者创建一个小部件,你需要实现一个工厂方法,名为createWidget(QWidget*)
,如清单 6-31 所示。
清单 6-31。 创建小工具实例
QWidget *CircleBarPlugin::createWidget( QWidget *parent )
{
return new CircleBar( parent );
}
最后一步是使用Q_EXPORT_PLUGIN2
宏将插件类实际导出为插件,如清单 6-32 所示。这一行被添加到实现文件的末尾。
清单 6-32。 导出插件
Q_EXPORT_PLUGIN2( circleBarPlugin, CircleBarPlugin )
要构建一个插件,你必须创建一个特殊的项目文件,如清单 6-33 所示。清单中突出显示了重要的行。他们所做的是告诉 QMake 使用一个模板来构建一个库;然后CONFIG
行告诉 QMake 你需要designer
和plugin
模块。最后一行使用DESTDIR
变量配置构建的输出,以在正确的位置结束。
清单 6-33。 设计器插件的项目文件
TEMPLATE = lib
CONFIG += designer plugin release
DEPENDPATH += .
TARGET = circlebarplugin
HEADERS += circlebar.h circlebarplugin.h
SOURCES += circlebar.cpp circlebarplugin.cpp
DESTDIR = $$[QT_INSTALL_DATA]/plugins/designer
构建插件后,您可以通过访问 Help About Plugins 菜单项来检查 Designer 是否找到了插件。这将弹出如图图 6-9 所示的对话框。在图中,您可以看到插件已经加载,小部件已经找到。
图 6-9。 插件已经加载。
为 Designer 创建小部件插件只是简单地填写一个给定的界面。这项工作很容易,但也可能相当乏味。
总结
自定义小部件使您的应用程序与众不同。您的应用程序将执行的特殊任务通常通过一个特殊的小部件来处理。尽管如此,我还是建议您尽可能选择标准的小部件,因为应用程序的用户很难学会如何使用您的特殊小部件。
设计适合 Qt 编写应用程序方式的小部件并不难。首先,您需要找到一个要继承的小部件——起点。如果没有给定的起点,就得从QWidget
类开始。
选好合适的切入点后,你必须决定你要关注哪些事件。这有助于您决定要覆盖哪些事件处理函数。事件处理程序可以被认为是您与用户的接口。
当您决定了接口之后,您需要关注应用程序的其余部分,包括 setters、getters、signals 和 slots(以及设置大小策略和创建大小提示)。确保考虑除当前场景之外的使用场景,以使您的小部件可重用。在编写小部件时投入时间可以在未来的项目中帮助您,因为您可以避免一次又一次地重新发明轮子。
在讨论了所有这些软件开发问题之后,我必须强调小部件最重要的方面:可用性。试着从用户的角度思考,确保在把你的设计放到你的产品软件中之前,在真实用户身上测试你的设计。******
七、绘图和打印
Qt 中的一个 ll 绘画是通过QPainter
类以这样或那样的方式执行的。小部件、图片、代理——一切都使用相同的机制。该规则实际上有一个例外(直接使用 OpenGL),但是您将从QPainter
类开始。
绘制小工具
使用 Qt,你几乎可以在任何东西上绘图:小部件、图片、位图、图像、打印机、OpenGL 区域等等。所有这些 drawables 的公共基类是QPaintDevice
类。
因为小部件是一个绘画设备,所以您可以很容易地创建一个QPainter
用于在小部件上绘画;简单地将this
作为参数传递给构造器,如清单 7-1 所示。
清单 7-1。 将 this
作为参数从画图事件处理程序传递给 QPainter
构造器来设置一切。
void CircleBar::paintEvent( QPaintEvent *event )
{
...
QPainter p( this );
...
}
要为另一个 paint 设备设置 painter,只需将指向它的指针传递给 painter 构造器。清单 7-2 展示了如何设置一个点阵图的绘制器。创建了 200 像素宽和 100 像素高的位图。然后,创建了用于在像素图上绘图的画师,并设置了钢笔和画笔。钢笔是用来画你正在画的任何形状的边界的。画笔用于填充形状的内部。
在继续之前,你需要知道什么是点阵图,它和图片有什么不同。在 Qt 中有三个主要的类来表示图形:QPixmap
为在屏幕上显示而优化,QImage
为加载和保存图像而优化,QPicture
记录画师命令,并使其可以在以后重放。
提示 当针对 Unix 和 X11 时,QPixmap
类被优化为仅在屏幕上显示。它甚至可以存储在 X 服务器上(处理屏幕),这意味着应用程序和 X 服务器之间的通信更少。
清单 7-2。 在设置钢笔和画笔之前创建点阵图和画师
QPixmap pixmap( 200, 100 );
QPainter painter( &pixmap );
painter.setPen( Qt::red );
painter.setBrush( Qt::yellow );
...
清单 7-2 将钢笔和画笔设置为 Qt 的标准颜色——在本例中是红色钢笔和黄色画笔。可以通过QColor
类的构造器从红色、绿色和蓝色组件中创建颜色。您可以使用静态方法QColor::fromHsv
和QColor::fromCmyk
从色调、饱和度和值创建颜色;或者青色、品红色、黄色和黑色。Qt 还支持 alpha 通道,控制每个像素的不透明度。(在本章的后面,您将对此进行实验。)
如果你想清除画笔和画笔设置,你可以使用setPen(Qt::noPen)
和setBrush(Qt::noBrush)
调用。钢笔用来画出形状的轮廓,而刷子用来填充它们。因此,你不用画笔就可以画出轮廓,不用钢笔就可以填充形状。
绘图操作
painter 类使您能够绘制您可能需要的最基本的形状。本节列出了最有用的方法以及示例输出。首先让我们看看几个经常被用作绘图方法参数的类。
画画时,你必须告诉画家在哪里画形状。屏幕上的每个点都可以用一个 x 和一个 y 值来指定,如图图 7-1 所示。如您所见,y 轴从顶部开始,其中 y 为 0,向下到更高的值。同理,x 轴从左到右增长。当谈论一个点时,你写( x,y )。这意味着(0,0)是坐标系的左上角。
注意可以使用负坐标移动到(0,0)位置的上方和左侧。
**图 7-1。**x 值从左到右递增;y 值从顶部向下增加。
图 7-2 显示了在小部件上绘图时,小部件的坐标系如何不同于屏幕。在小部件上绘图时使用的坐标是对齐的,因此(0,0)是小部件的左上角(在设备的全局坐标系中,它不总是与(0,0)相同)。全局坐标系处理屏幕上的实际像素、打印机上的点和其他设备上的点。
图 7-2。 在小部件上绘图时,小部件的左上角为(0,0)。
屏幕上的一个点由一个QPoint
对象表示,你可以在构造器中为一个点指定 x 和 y 的值。一个点通常不足以画出什么东西;要指定一个点的宽度和高度,可以使用QRect
类。QRect
构造器接受一个 x 值、一个 y 值和一个宽度,后跟一个高度。图 7-3 显示了一个坐标系中的QRect
和QPoint
。
图 7-3。 一个 QPoint
和一个 QRect
及其 x、y、宽度和高度属性
提示与QPoint
和QRect
密切相关的有两类:QPointF
和QRectF
。它们是等效的,但是对浮点值进行操作。几乎所有接受矩形或点的方法都可以接受任何类型的矩形或点。
行
线条是最基本的形状,你可以用画师画出来。使用drawLine(QPoint,QPoint)
方法在两点之间画一条线。如果想一次加入更多的点,可以用drawPolyline(QPoint*, int)
的方法。drawLines(QVector
< QPoint
>)方法也用于一次绘制多条线,但这些线不是连续的。这三种方法在清单 7-3 中使用,结果显示在图 7-4 中。
在清单中,创建了一个 pixmap,并在创建 painter 之前用白色填充,笔被配置为绘制黑色线条。两个向量polyPoints
和linePoints
被初始化,其中linePoints
通过将polyPoints
点向右移动 80 个像素来计算。您可以通过向每个QPoint
添加偏移QPoint
来移动这些点,这将分别将 x 和 y 值相加。
注意我把polyPoints
称为矢量,因为这才是QPolygon
真正的含义。然而,QPolygon
类也提供了同时移动所有点的方法,以及计算包含所有点的矩形的方法。
为了绘制实际的线条,调用了drawLine
、drawPolyline
和drawLines
方法。比较一下drawPolyline
和drawLines
的区别。如你所见,drawPolyline
连接所有的点,而drawLines
连接每一对给定的点。
清单 7-3。 利用drawLine``drawPolyline
drawLines
绘制线条
QPixmap pixmap( 200, 100 );
pixmap.fill( Qt::white );
QPainter painter( &pixmap );
painter.setPen( Qt::black );
QPolygon polyPoints;
polyPoints << QPoint( 60, 10 )
<< QPoint( 80, 90 )
<< QPoint( 75, 10 )
<< QPoint( 110, 90 );
QVector<QPoint> linePoints;
foreach( QPoint point, polyPoints )
linePoints << point + QPoint( 80, 0 );
painter.drawLine( QPoint( 10, 10 ), QPoint( 30, 90 ) );
painter.drawPolyline( polyPoints );
painter.drawLines( linePoints );
图 7-4。 用不同的方法绘制线条;从左至右:drawLine``drawPolylines
drawLines
(两行)**
*线条是使用钢笔绘制的,因此您可以通过改变钢笔对象的属性来绘制所需的线条。一个QPen
对象最常用的两个属性是color
和width
,它们控制所画线条的颜色和宽度。
当使用drawPolyline
绘制连续线条时,能够控制线条如何连接在一起是很有用的—joinStyle
属性会有所帮助。图 7-5 显示了可用的样式:斜面、斜接和圆形。通过将您的QPen
对象的joinStyle
设置为Qt::BevelJoin
、Qt::MiterJoin
或Qt::RoundJoin
来设置合适的样式。
图 7-5。 线段有三种连接方式:斜角、斜接和圆角。
QPen
可被设置成画点划线以及完全自由的虚线。图 7-6 中显示了不同的变化。
图 7-6。 线条可以用不同的图案绘制成实线或虚线——既有预定义的图案,也有定制图案的功能。
通过将QPen
对象的style
属性设置为Qt::SolidLine
、Qt::DotLine
、Qt::DashLine
、Qt::DotDashLine
、Qt::DotDotDashLine
或Qt::CustomDashLine
来选取图案。如果您使用自定义线条,您还必须通过dashPattern
属性设置一个自定义的虚线图案(清单 7-4 显示了它是如何完成的)。列表的输出如图 7-7 所示。
dashPattern
由qreal
值的向量列表组成。这些值决定了破折号和间隙的宽度,其中第一个值是第一个破折号,然后是一个间隙,然后是一个破折号,然后是另一个间隙,依此类推。
清单 7-4。 使用预定义或自定义图案绘制线条
QPixmap pixmap( 200, 100 );
pixmap.fill( Qt::white );
QPainter painter( &pixmap );
QPen pen( Qt::black );
pen.setStyle( Qt::SolidLine );
painter.setPen( pen );
painter.drawLine( QPoint( 10, 10 ), QPoint( 190, 10 ) );
pen.setStyle( Qt::DashDotLine );
painter.setPen( pen );
painter.drawLine( QPoint( 10, 50 ), QPoint( 190, 50 ) );
pen.setDashPattern( QVector<qreal>() << 1 << 1 << 1 << 1 << 2 << 2
<< 2 << 2 << 4 << 4 << 4 << 4
<< 8 << 8 << 8 << 8 );
pen.setStyle( Qt::CustomDashLine );
painter.setPen( pen );
painter.drawLine( QPoint( 10, 90 ), QPoint( 190, 90 ) );
图 7-7。 预定义和自定义图案
方形形状
可以画方形或圆角的矩形,如图图 7-8 所示。这些方法接受代表左上角( x,y )对的一个QRect
或四个值,然后是矩形的宽度和高度。这些方法被命名为drawRect
和drawRoundRect
。
图 7-8。 圆角矩形
清单 7-5 展示了圆角矩形和方角矩形是如何绘制的。前两个矩形是使用方法调用中直接指定的坐标绘制的。坐标指定为 x,y,w,h;其中 x 和 y 指定左上角, w , h 指定矩形的宽度。
注意如果 w 或 h 小于 0,则 x,y 指定的角不是矩形的左上角。
第二对矩形是根据给定的QRect
类绘制的,该类保存矩形的坐标。在drawRoundRect
调用中,直接使用了rect
变量。在drawRect
调用中,rect
指定的矩形被平移,或者向下移动 45 个像素。这是通过使用translated(int x, int y)
方法实现的,该方法返回一个相同大小的矩形,但是移动了指定的像素数量。
绘图操作的结果如图 7-9 所示。
清单 7-5。 将矩形绘制成点阵图
QPixmap pixmap( 200, 100 );
pixmap.fill( Qt::white );
QPainter painter( &pixmap );
painter.setPen( Qt::black );
painter.drawRect( 10, 10, 85, 35 );
painter.drawRoundRect( 10, 55, 85, 35 );
QRect rect( 105, 10, 85, 35 );
painter.drawRoundRect( rect );
painter.drawRect( rect.translated( 0, 45 ) );
图 7-9。 所画的矩形
圆形形状
使用drawEllipse
方法绘制圆和椭圆(参见图 7-10 )。该方法采用一个矩形或四个值来表示 x,y ,宽度和高度(就像矩形绘制方法一样)。要画一个圆,你必须确保宽度和高度相等。
图 7-10。 使用 drawEllipse
方法绘制圆和椭圆。
画椭圆很有趣,因为你还可以画出椭圆的一部分。Qt 可以绘制三个部分(如图图 7-11 ):
drawArc
画一条弧线——圆圈周围的线条部分。drawChord
画一个圆段——弦与弦外圆弧之间的区域。drawPie
画一个扇形段——椭圆形的一部分。
所有绘制椭圆部分的方法都取一个矩形(就像drawEllipse
方法一样)。然后,它们接受一个起始角度和一个值,该值指示椭圆的一部分跨越了多少度。角度以整数表示,其中值为 1/16 度,这意味着值 5760 对应于一个完整的圆。值 0 对应三点钟,正角度逆时针移动。
图 7-11。 一条弧线、一条弦和一个饼状的圆
清单 7-6 展示了如何绘制椭圆和圆弧(结果如图图 7-12 )。正如您所看到的,形状的比例发生了变化,最右边的椭圆和圆弧实际上是圆形的(宽度等于高度)。
如源代码所示,可以通过直接使用坐标或向绘图方法传递一个QRect
值来指定绘制椭圆或圆弧的矩形。
在指定角度时,我将不同的值乘以 16,将实际角度值转换为 Qt 期望的值。
清单 7-6。 画椭圆和圆弧
QPixmap pixmap( 200, 190 );
pixmap.fill( Qt::white );
QPainter painter( &pixmap );
painter.setPen( Qt::black );
painter.drawEllipse( 10, 10, 10, 80 );
painter.drawEllipse( 30, 10, 20, 80 );
painter.drawEllipse( 60, 10, 40, 80 );
painter.drawEllipse( QRect( 110, 10, 80, 80 ) );
painter.drawArc( 10, 100, 10, 80, 30*16, 240*16 );
painter.drawArc( 30, 100, 20, 80, 45*16, 200*16 );
painter.drawArc( 60, 100, 40, 80, 60*16, 160*16 );
painter.drawArc( QRect( 110, 100, 80, 80 ), 75*16, 120*16 );
图 7-12。 所画的椭圆和圆弧
正文
Qt 提供了几种可能的方法来绘制文本(参见图 7-13 中的一些例子)。当您通过用于创建它的代码工作时,请参考该图。
图 7-13。 你可以用许多不同的方式绘制文本。
首先,您需要创建一个用于绘制的QPixmap
和一个用于绘制的QPainter
。你还必须用白色填充位图,并将画师的笔设置为黑色:
QPixmap pixmap( 200, 330 );
pixmap.fill( Qt::white );
QPainter painter( &pixmap );
painter.setPen( Qt::black );
在图的顶部绘制文本,该文本从 a `QPoint`开始。下面的源代码向您展示了`drawText`调用的使用。下面的`drawLine`类简单地用十字标记了点(你可以在顶部文本左边的图 7-13 中看到这个十字)。
QPoint point = QPoint( 10, 20 );
painter.drawText( point, "You can draw text from a point..." );
painter.drawLine( point+QPoint(-5, 0), point+QPoint(5, 0) );
painter.drawLine( point+QPoint(0, −5), point+QPoint(0, 5) );
从一个点绘制文本有它的优势——这是一种将文本放到屏幕上的简单方法。如果您需要更多的控制,您可以在矩形中绘制文本,这意味着您可以将文本水平向右、向左或居中对齐(也可以垂直向上、向下或居中)。下表总结了用于对齐的枚举:
Qt::AlignLeft
:左对齐Qt::AlignRight
:右对齐Qt::AlignHCenter
:居中水平对齐Qt::AlignTop
:顶部对齐Qt::AlignBottom
:底部对齐Qt::AlignVCenter
:垂直居中对齐Qt::AlignCenter
:垂直和水平居中对齐
在矩形内绘制文本的另一个好处是文本被裁剪到矩形,这意味着您可以限制文本使用的区域。下面的源代码绘制了一个以矩形为中心的文本:
QRect rect = QRect(10, 30, 180, 20); painter.drawText( rect, Qt::AlignCenter, "...or you can draw it inside a rectangle." ); painter.drawRect( rect );
因为您可以将文本限制为矩形,所以您还需要能够确定文本使用了多少空间。首先将矩形平移到一个新位置;您将从QApplication
对象中获得标准的QFont
。使用字体,设置一个pixelSize
来适应矩形,然后在矩形的两边绘制文本。
提示因为你在给一个QPixmap
绘画,所以使用来自QApplication
的字体。如果你在一个特定的小部件中使用的QWidget
或QPixmap
上绘画,从小部件中获取字体会更合理。
这并没有像预期的那样结束;相反,文本在底部被剪裁。字体的像素大小只定义了所有字符绘制的基线以上的大小。
` rect.translate( 0, 30 );
QFont font = QApplication::font();
font.setPixelSize( rect.height() );
painter.setFont( font );
painter.drawText( rect, QtAlignRight, "Right." );
painter.drawText( rect, QtAlignLeft, "Left." );
painter.drawRect( rect );`
要真正使文本适合矩形,使用QFontMetrics
类获得文本的精确尺寸。font metrics 类可用于确定给定文本的宽度和高度。然而,高度不依赖于任何特定的文本;它完全由字体决定。下面的代码在绘制文本之前调整用于保留文本的矩形的高度。参见图 7-13 :这一次文本非常合适。
` rect.translate( 0, rect.height()+10 );
rect.setHeight( QFontMetrics( font ).height() );
painter.drawText( rect, QtAlignRight, "Right." );
painter.drawText( rect, QtAlignLeft, "Left." );
painter.drawRect( rect );`
使用drawText
绘制文本有其局限性。例如,部分文本不能格式化,也不能分成段落。您可以使用QTextDocument
类来绘制格式化的文本(如下面的源代码所示)。
用文本文档绘制文本比直接使用drawText
稍微复杂一些。首先创建一个QTextDocument
对象,使用setHTML
用 HTML 格式的文本初始化它。设置要在其中绘制文本的矩形。将其平移到最后绘制的文本下方的新位置,然后调整高度以容纳更多文本。
然后使用setTextWidth
将矩形用于设置文本文档的宽度。在您准备绘制文本之前,您必须翻译 painter(稍后将详细介绍),因为文本文档将在(0,0)坐标处开始绘制文本。在翻译 painter 之前,保存当前状态(稍后通过调用restore
方法恢复)。因为您翻译了画师,所以当您调用drawContents
在给定的矩形内将文本绘制给给定的画师时,您也必须翻译矩形。
` QTextDocument doc;
doc.setHtml( "
A QTextDocument can be used to present formatted text "
"in a nice way.
"
It can be formatted "
"in different ways.
"
The text can be really long and contain many "
"paragraphs. It is properly wrapped and such...
rect.translate( 0, rect.height()+10 );
rect.setHeight( 160 );
doc.setTextWidth( rect.width() );
painter.save();
painter.translate( rect.topLeft() );
doc.drawContents( &painter, rect.translated( -rect.topLeft() ) );
painter.restore();
painter.drawRect( rect );`
如图图 7-13 所示,文本文档的全部内容无法放入给定的矩形。再次,有一种方法可以确定文本所需的高度。在这种情况下,使用来自QTextDocument
的size
属性的height
属性。在下面的源代码中,您使用此高度来确定绘制在呈现的文本文档下方的灰色矩形的大小。这个矩形显示了文本的实际长度。
rect.translate( 0, 160 ); rect.setHeight( doc.size().height()-160 ); painter.setBrush( Qt::gray ); painter.drawRect( rect );
注意尽管使用drawText
方法绘制文本相当容易,但是您可能想要使用QTextDocument
类来绘制更复杂的文本。这个类使您能够以一种简单的方式绘制具有各种格式和对齐方式的复杂文档。
路径
画家路径可以画出你想要的任何形状,但技巧是定义一个区域周围的路径。然后,您可以使用给定的钢笔和画笔来绘制路径。一条路径可以包含几个封闭区域;例如,可以使用路径来表示整个文本字符串。
如图图 7-14 所示的路径分三步创建。首先,创建QPainterPath
对象,并使用addEllipse
方法添加圆。这个椭圆形成一个封闭的区域。
` QPainterPath path;
path.addEllipse( 80, 80, 80, 80 );`
图 7-14。 一条小路已经被填满。
下一步是添加从整个圆的中心开始并向左上方延伸的四分之一圆。它从(100,100)开始,使用一个moveTo
调用移动到那个点。然后你用lineTo
画一条直线,然后用addArc
画一个圆弧。从(40,40)开始在矩形中绘制圆弧;也就是 160 像素的高和宽。它从 90 度开始,逆时针再跨越 90 度。然后用一条返回起点的线封闭该区域。这形成了另一个封闭区域。
注意圆弧从 90 度开始,因为 0 度被认为是中心点右侧的点,你希望它从中心正上方开始。
path.moveTo( 120, 120 ); path.lineTo( 120, 40 ); path.arcTo( 40, 40, 160, 160, 90, 90 ); path.lineTo( 120, 120 );
最后要添加的部分是形状下面的文本。这是通过设置一个大字体,然后在调用addText
时使用它来实现的。addText
的工作方式类似于drawText
,但是只允许文本从给定点开始(也就是说,矩形中不包含文本)。这就形成了一大堆构成文本的封闭区域:
` QFont font = QApplication::font();
font.setPixelSize( 40 );
path.addText( 20, 180, font, "Path" );`
当画家路径完成后,剩下要做的就是用画家来描边。在下面的代码中,您为画家配置了一支钢笔和一支画笔。然后用drawPath
方法画出实际的油漆工路径。
图 7-14 显示当区域重叠时,笔刷没有被应用。这使得通过将其他路径放入其中来创建空心路径成为可能。
` painter.setPen( Qtblack );
painter.setBrush( Qtgray );
painter.drawPath( path );`
路径可以由比前面源代码中使用的形状更多的形状组成。以下列表提到了一些可用于将形状添加到路径中的方法:
addEllipse
:添加一个椭圆或圆。addRect
:添加一个矩形。addText
:添加文本。addPolygon
:添加一个多边形。
从线、弧和其他组件构建区域时,以下方法会很有用:
moveTo
:移动当前位置。lineTo
:画一条线到下一个位置。arcTo
:画一条弧线到下一个位置。cubicTo
:画一条三次贝塞尔曲线(一条平滑的线)到下一个点。closeSubpath
:从当前位置到起点画一条直线,关闭当前区域。
路径对于表示你需要反复绘制的形状是非常有用的,但是当它们与画笔结合起来时,它们的真正潜力才会显现出来(接下来讨论)。
画笔
画笔用于填充形状和路径。到目前为止,你一直用画笔用纯色填充指定的区域。这只是可能的一部分。使用不同的图案、渐变甚至纹理,您可以用任何可以想象的方式填充形状。
创建QBrush
对象时,可以指定颜色和样式。构造器定义为QBrush(QColor, Qt::BrushStyle)
。然后使用setBrush
方法将QBrush
赋予一个QPainter
。
画笔的样式控制填充形状时如何使用颜色。最简单的样式是图案,在需要用线条或抖动阴影填充形状时使用。可用的模式和相应的枚举样式如图 7-15 所示。
图 7-15。 可用模式
一种更灵活的填充形状的方法是使用渐变画笔,这是一种基于QGradient
对象的画笔。一个渐变对象代表一种或多种颜色根据预定义的模式的混合。可用模式如图 7-16 中的所示。基于QLinearGradient
类的线性渐变定义了一个二维线性渐变。径向渐变通过QRadialGradient
实现,描述了从一个点发出的渐变,其中阴影取决于与该点的距离。圆锥形渐变,QConicalGradient
,代表从单个点发出的渐变,其中阴影取决于与该点的角度。
不同的渐变定义为两点之间的分布(锥形渐变除外,它以一个角度开始和结束)。梯度在这些点定义的范围之外继续的方式由扩展策略定义,该策略通过setSpread
方法设置。不同传播策略的结果也显示在图 7-16 中。使用垫展开 ( QGradient::PadSpread
),当到达垫时,梯度简单地停止。使用重复展开 ( QGradient::RepeatSpread
)梯度被重复。使用反射扩散 ( QGradient::ReflectSpread
)梯度重复,但方向是交替的——导致梯度每隔一次被反射。
注意扩散策略不会影响锥形渐变,因为它们定义了所有像素的颜色。
图 7-16。 不同的梯度和扩散政策
清单 7-7 显示了不同梯度是如何配置的。请注意,线性梯度是在两点之间定义的,形成一个方向。径向渐变由中心点和半径定义,而锥形渐变由中心点和起始角度定义。起始角度以度为单位指定,其中 0 度定义从中心点指向右侧的方向。
渐变也使用setColorAt
方法分配颜色。颜色被设置为范围在 0 和 1 之间的值。这些值为线性渐变定义了两点之间的一点,其中一点为 0,另一点为 1。同样,0 定义起点,1 定义径向渐变的完整指定半径。对于锥形渐变,0 指定起始角度。然后,该值沿逆时针方向增加,直到 1 指定结束角度,该角度与开始角度相同。
注意可以在不同的点设置几种颜色;设置结束颜色以清晰的方式显示效果。
清单 7-7。 设置渐变
` QLinearGradient linGrad( QPointF(80, 80), QPoint( 120, 120 ) );
linGrad.setColorAt( 0, Qtblack );
linGrad.setColorAt( 1, Qtwhite );
...
QRadialGradient radGrad( QPointF(100, 100), 30 );
radGrad.setColorAt( 0, Qtblack );
radGrad.setColorAt( 1, Qtwhite );
...
QConicalGradient conGrad( QPointF(100, 100), −45.0 );
conGrad.setColorAt( 0, Qtblack );
conGrad.setColorAt( 1, Qtwhite );`
要将其中一个渐变用作画笔,只需将QGradient
对象传递给QBrush
构造器。渐变画笔不受调用QBrush
对象的setColor
方法的影响。
创建画笔的最后一种方法是将一个QPixmap
或一个QImage
对象传递给QBrush
构造器,或者在一个QBrush
对象上调用setTexture
。这个过程使画笔使用给定的图像作为纹理,通过重复图案填充任意形状(一个例子如图图 7-17 )。
图 7-17。 一个基于纹理的笔刷
改造现实
正如您在讨论全局(设备)坐标和局部(小部件)坐标时了解到的,Qt 可以对屏幕的不同区域使用不同的坐标系。全局坐标和局部坐标的区别在于原点,即点(0,0),被移动了。用专业术语来说,这就是所谓的坐标系统转换。
注意我将设备的坐标称为全局,因为它们是在设备上工作的所有画师(以及小部件,如果设备恰好是屏幕)之间共享的。然后,每个画家都被转变成一个与其目的相关的点。其他常用的符号有物理设备坐标和逻辑局部坐标。
油漆工的坐标系也可以被转换(这种转换的例子如图 7-18 中的所示)。在图中,灰色框是相对于原始坐标系绘制的。坐标系通过以下调用进行转换:
painter.translate( 30, 30 );
结果是在黑色矩形所在的位置绘制了矩形,坐标系向右下方移动了。
**图 7-18。** *平移坐标系就是移动原点(0,0)。*
painter 类能够进行更多的翻译。坐标系可以平移、缩放、旋转和剪切(这些变换如图 7-19 、 7-20 和 7-21 所示)。
为了缩放画师,进行以下调用:
painter.scale( 1.5, 2.0 );
第一个参数是沿 *x* 轴的缩放(水平方向),第二个参数是垂直缩放(见图 7-19 )。请注意,用于绘画的钢笔也被缩放—线条的高度大于宽度。
**图 7-19。** *缩放坐标系使所有点更靠近原点(0,0)。*
旋转时,会进行以下调用:
painter.rotate( 30 );
该参数是顺时针方向旋转坐标系的度数。该方法接受浮点值,因此可以将坐标系旋转任意角度(见图 7-20 )。
**图 7-20。** *绕原点(0,0)旋转坐标系*
最后一个变换——剪切——有点复杂。发生的情况是,坐标系绕原点扭曲。为了理解这一点,请看图 7-21 和下面的调用:
painter.shear( 0.2, 0.5 );
注意, *x* 值越大, *y* 值的变化越大。同样,大的 *y* 值会导致 *x* 值的大变化。`shear`方法的第一个参数控制 *y* 值的变化量 *x* 应该给出多大的变化量,第二个参数反过来做同样的事情。例如,查看被剪切的矩形的右下角,并将其与原始的灰色框进行比较。然后比较剪切后的矩形和原始矩形的左上角。比较这两个点,可以看到根据`shear`方法参数的大小,一个比另一个移动得多。因为右上角的 *x* 和 *y* 均为非 0 值,所以该点根据参数向两个方向移动。
**图 7-21。** *相对于原点(0,0)剪切坐标系*
当你对一个画师的坐标系进行变换时,你想知道有一种方法可以恢复原来的设置。通过调用 painter 对象上的`save`,当前状态被放在一个堆栈上。要恢复上一次保存的状态,调用`restore`(当您想要应用几个从原始坐标系开始的变换时,这很方便)。给定一个指向画师对象的指针也很常见;您应该在修改绘制器之前保存状态,然后在从方法返回之前恢复绘制器。
**维持秩序**
可以通过依次执行来组合几种转换。这样做时,顺序很重要,因为所有的变换都指向原点(0,0)。例如,旋转始终意味着围绕原点旋转,因此,如果您想围绕不同的点旋转形状,您必须将旋转中心平移到(0,0),应用旋转,然后将坐标系平移回来。
让我们用下面的线在(0,0)处画一个矩形,即 70 像素宽,70 像素高:
painter.drawRect( 0, 0, 70, −70 );
现在使用下面的线将坐标系旋转 45 度(结果如图 7-22 所示):
painter.rotate( 45 );
**图 7-22。** *简单地旋转矩形使其绕原点旋转。*
如果你转而平移坐标系,使矩形(35,35)的中心成为旋转前的原点,然后再将坐标系平移到位,你最终会像图 7-23 一样。用于平移和旋转然后平移回来的代码如下:
painter.translate( 35, −35 );
painter.rotate( 45 );
painter.translate( −35, 35 );
**图 7-23。** *通过来回平移,可以围绕矩形的中心旋转。*
如果你混淆了平移的顺序,你最终会得到图 7-24 (你绕着错误的点旋转)。
**图 7-24。** *混译的顺序绕着错误的原点旋转。*
翻译的顺序对所有的翻译都很重要。缩放和剪切都同样依赖于坐标系的原点,就像旋转一样。
绘画小工具
所有 Qt 小部件都是绘画设备,所以您可以创建一个`QPainter`对象,并用它来绘制小部件。然而,这只能通过`paintEvent(QPaintEvent*)`方法来实现。
当小部件需要重画时,事件循环调用`paintEvent`方法。你需要告诉 Qt 什么时候要重画小部件,Qt 会调用你的`paintEvent`方法。你可以用两种方法来达到这个目的:`update`和`repaint`。`repaint`方法触发并立即重画,而`update`将一个更新请求放到事件队列中。后者意味着 Qt 有机会将`update`调用合并成更少的(最好是单个)对`paintEvent`的调用。这可能是好的也可能是坏的。这很糟糕,因为您可能已经创建了一个依赖于`paintEvent`被调用特定次数的小部件。这很好,因为它允许 Qt 根据运行应用程序的系统的当前工作负载来调整数量或重画。几乎在所有情况下,你都应该使用`update`。这样做时,尽量避免依赖于被调用一定次数的`paintEvent`方法。
**注意**有更多的理由不依赖于`paintEvent`像你呼叫`update`那样被频繁呼叫。例如,你的小工具可能会被完全阻挡,或者有什么东西在它前面移动,导致对`paintEvent`的呼叫变少或变多。
在你忘乎所以并开始实现全新的小部件之前,让我们来看看一个按钮是如何被修改成看起来不同的。(按钮是一个很好的起点,因为它就是为此目的而设计的。)所有按钮都继承了`QAbstractButton`类,它定义了按钮的基本机制和属性。然后这个类被继承到`QPushButton`、`QRadioButton`和`QCheckBox`中,它们实现了一个按钮的三种不同视图。
**注意**还有更多抽象的 widget 可以作为自定义 widget 的基础,包括`QAbstractScrollArea`、`QAbstractSlider`和`QFrame`。注意,尽管前两个类是抽象的,但这不是规则。`QFrame`可以作为新 widget 的基础,但也可以单独使用。
**一个新按钮**
新的 button 类并没有创建一个完全不同的按钮;当用户按下按钮时,它只是让按钮的文本变亮。按钮类被称为`MyButton`,类声明如清单 7-8 所示。
在清单中,您可以看到该类继承了`QAbstractButton`类。然后它实现一个构造器、一个`sizeHint`方法和一个`paintEvent`方法。`sizeHint`和`paintEvent`方法覆盖从祖先类继承的现有方法。这意味着它们的声明必须保持完全相同(包括将`sizeHint`方法声明为`const`)。
**清单 7-8。** *自定义按钮的类声明*
class MyButton : public QAbstractButton
{
Q_OBJECT
public:
MyButton( QWidget *parent=0 );
QSize sizeHint() const;
protected:
void paintEvent( QPaintEvent* );
};
你可以在清单 7-9 中查看构造器和`sizeHint`方法。构造器只是将父参数传递给父类。`sizeHint`方法返回小部件想要的大小。这只是给 Qt 布局类的一个提示,所以不能依赖小部件来获取这些尺寸。
尺寸由`QSize`对象表示,它有两个属性:`width`和`height`。对于按钮,这两个度量取决于要显示的文本和显示文本所用的字体。要了解给定`QFont`的尺寸,请使用`QFontMetrics`对象。所有小部件都有一个返回当前字体的`QFontMetrics`对象的`fontMetrics`属性。通过询问这个对象关于给定字符串的`width`和`height`的信息,然后在每个方向上额外增加 10 个像素作为边距,就可以得到一个合适的小部件大小。
**注意**给定字体的高度并不取决于输入的文本。相反,它考虑了字体的可能高度。大多数字体的给定文本的宽度取决于文本,因为字符的宽度不同。
**清单 7-9。** *构造器和* `sizeHint` *按钮的方法*
MyButton::MyButton( QWidget *parent ) : QAbstractButton( parent )
{
}
QSize MyButton::sizeHint() const
{
return QSize( fontMetrics().width( text() )+10, fontMetrics().height()+10 );
}
绘制按钮的任务由`paintEvent`方法负责(见清单 7-10 )。该方法从创建一个用于绘制到小部件的`QPainter`对象开始。所有的小部件都由 Qt 进行双缓冲,所以当你向画师绘图时,你实际上是在向一个用于重绘屏幕的缓冲区绘图。这意味着你不必担心闪烁。
有两种方法可以绘制小部件:直接绘制或通过样式绘制。通过使用样式,您可以使小部件的外观适应系统的其余部分。通过直接绘制到小部件,您可以完全控制。对于按钮,您将直接使用样式和文本绘制框架和背景。
每个小部件都有一个与之相关联的`QStyle`,您可以通过`style`属性访问它。这种风格通常反映了系统的设置,但是它可能已经从实例化小部件的代码中改变了。小部件本身不应该关心样式的起源或者它与当前平台的关系。
在使用该样式进行绘图之前,您需要设置一个样式选项对象(在本例中是一个`QStyleOptionButton`对象)。要使用的样式选项类取决于要绘制的样式元素。通过参考用于`drawControl`方法的 Qt 文档,您可以看到它期望哪个样式对象。
样式选项对象通过将`this`指针传递给它的`init`方法来初始化,该方法配置大多数设置。但是,您仍然需要判断按钮是被按下还是被切换。这些状态可以从由`QAbstractButton`类实现的`isDown`和`isChecked`方法中获得。如果`isDown`方法返回`true`,则按钮当前被按下。如果`isChecked`返回`true`,则按钮已被切换且当前处于选中状态(即处于打开状态)。当按钮被按下时,设置样式选项的`state`属性中的`QStyle::State_Sunken`位。对于选中的按钮,设置`QStyle::State_On`位。
**注意**使用`|=`操作符(按位或)将`state`位相加,不清除由`init`方法设置的任何位。
当样式对象被正确设置后,当前样式方法的`drawControl(ControlElement, QStyleOption*, QPainter*, QWidget*)`被调用。在调用中,您要求绘制一个`QStyle::CE_PushButtonBevel`,它将绘制按钮的所有部分,除了文本和可选图标。
`paintEvent`方法的第二部分负责将文本直接绘制到小部件上。它首先将画师的字体设置为小部件的当前字体。然后根据按钮的状态确定笔的颜色。禁用的按钮显示灰色文本,按下的按钮显示红色文本,所有其他按钮显示暗红色文本。请注意,当按钮被主动按下时,`isDown`会返回`true`,而不是当切换的按钮处于打开状态时。这意味着文本只有在按下鼠标按钮时才会变亮。
当画师的笔和字体配置好后,继续用`drawText`绘制实际文本。文本在按钮中居中,并包含在按钮所占据的实际矩形中。你没有考虑在`sizeHint`方法中添加的边距。
`paintEvent`方法接受一个`QPaintEvent`指针作为参数;在本例中选择忽略的指针。事件对象有一个名为`rect()`的成员方法,它返回一个`QRect`,指定`paintEvent`方法需要更新的矩形。对于某些小部件,您可以将绘制限制在该矩形内,以提高性能。
**清单 7-10。** *直接使用样式和文本绘制斜面*
void MyButton::paintEvent( QPaintEvent* )
{
QPainter painter( this );
QStyleOptionButton option;
option.init( this );
if( isDown() )
option.state |= QStyle::State_Sunken;
else if( isChecked() )
option.state |= QStyle::State_On;
style()->drawControl( QStyle::CE_PushButtonBevel, &option, &painter, this );
painter.setFont( font() );
if( !isEnabled() )
painter.setPen( Qt::darkGray );
else if( isDown() )
painter.setPen( Qt::red );
else
painter.setPen( Qt::darkRed );
painter.drawText( rect(), Qt::AlignCenter, text() );
}
要试用这个按钮,可以用它创建一个对话框。产生的对话框如图 7-26 中的所示(但你离它还有几步之遥)。
首先在设计器中创建新对话框。向对话框添加三个`QPushButton`小部件,并根据对话框的图形设置它们的文本属性。同样,将顶部按钮的 enabled 属性设置为`false`,将底部按钮的 checkable 属性设置为`true`。
右键单击每个按钮,并从弹出菜单中选择“升级到自定义小部件”。这将显示在图 7-25 中弹出菜单旁边显示的对话框。通过在对话框中输入`MyButton`作为自定义类名,头文件名称将(正确地)被猜测为`mybutton.h`,这将导致用户界面编译器在创建按钮时使用`MyButton`类,而不是`QPushButton`类。
**注意**因为`MyButton`没有继承`QPushButton`(它继承了`QAbstractButton`类),所以保持属性编辑器中`QPushButton`标题下的属性不变是很重要的。否则,您将会遇到编译错误。基类(`QAbstractButton`)及以上的所有属性都可以自由使用。
对话框名称设置为`Dialog`,中间按钮命名为`clickButton`,设计保存为`dialog.ui`。
**图 7-25。** *使用来自设计者*的 `MyButton` *为了显示对话框,声明一个最小的对话框类(如清单 7-11 中的和清单 7-12 中的所示)。对话框简单地从设计中设置用户界面,并将按钮的`clicked`信号连接到显示对话框的插槽。
**清单 7-11。** *一个极小对话框的标题*
class Dialog : public QDialog
{
Q_OBJECT
public:
Dialog();
private slots:
void buttonClicked();
private:
Ui::Dialog ui;
};
**清单 7-12。** *实现一个最小化的对话框*
Dialog::Dialog() : QDialog()
{
ui.setupUi( this );
connect( ui.clickButton, SIGNAL(clicked()), this, SLOT(buttonClicked()) );
}
void Dialog::buttonClicked()
{
QMessageBox::information( this, tr("Wohoo!"), tr("You clicked the button!") );
}
该对话框与一个最小的主函数相结合,产生了如图 7-26 所示的对话框。在图中,顶部的按钮被禁用,中间的按钮被按下,而底部的按钮是非活动的切换按钮。
![image](https://gitee.com/OpenDocCN/vkdoc-c-cpp-zh/raw/master/docs/fund-qt-dev/img/P0726.jpg)
**图 7-26。***`MyButton`*类行动**
***完全定制**
如果您需要创建一个全新的小部件(其行为不同于任何其他小部件),您必须直接子类化`QWidget`类。这让你可以做任何事情,但是自由也伴随着责任。所有的内部状态都必须由你来管理,所有的重画和大小暗示也是如此。
让我们先看看你想做什么。您将创建的小部件名为`CircleWidget`,它将监听鼠标事件。当按下鼠标时,会创建一个圆。只要在圆圈内按下鼠标按钮,圆圈就会变大。如果在指针位于圆圈之外时按下鼠标,圆圈将缩小直至消失,新的圆圈将在第一个圆圈消失时指针所在的位置开始增长(见图 7-27 )。
![image](https://gitee.com/OpenDocCN/vkdoc-c-cpp-zh/raw/master/docs/fund-qt-dev/img/P0727.jpg)
**图 7-27。** *圆形小部件显示的圆形*
你必须跟踪鼠标事件:按钮按下、按钮释放和指针移动。你还需要有一个计时器,随着时间的推移来扩大和缩小圆圈。最后,你必须负责重画并给 Qt 布局类一个大小提示(所有这些都可以在清单 7-13 中的类声明中看到)。
查看类声明,您可以将内容组合在一起:**
*** 基本必需品:在这里你可以找到构造器和sizeHint
。* 绘画:paintEvent
方法使用变量x
、y
、r
和color
来跟踪要画什么。* 鼠标交互:使用mousePressEvent
、mouseMoveEvent
和mouseReleaseEvent
捕捉鼠标事件。最后已知的鼠标位置保存在mx
和my
中。* 计时:timer
指向的QTimer
对象连接到超时槽。它根据mx
和my
值更新x
、y
、r
和color
。**
注意sizeHint
方法不是必须的,但是我们鼓励你为你所有的小部件实现它。
清单 7-13。 自定义小工具的类声明
class CircleWidget : public QWidget
{
Q_OBJECT
public:
CircleWidget( QWidget *parent=0 );
QSize sizeHint() const;
private slots:
void timeout();
protected:
void paintEvent( QPaintEvent* );
void mousePressEvent( QMouseEvent* );
void mouseMoveEvent( QMouseEvent* );
void mouseReleaseEvent( QMouseEvent* );
private:
int x, y, r;
QColor color;
int mx, my;
QTimer timer;
};
清单 7-14 中所示的构造器将当前圆的半径r
初始化为0
,表示没有圆。然后它配置并连接一个QTimer
对象。计时器间隔设置为 50 毫秒,这意味着圆圈每秒将更新大约 20 次(这通常足以模仿连续的运动)。
清单 7-14。 初始化自定义小工具
CircleWidget::CircleWidget( QWidget *parent ) : QWidget( parent )
{
r = 0;
timer.setInterval( 50 );
connect( &timer, SIGNAL(timeout()), this, SLOT(timeout()) );
}
sizeHint
方法是整个类中最简单的一个;它只是返回一个静态大小(见清单 7-15 )。
清单 7-15。 返回静态大小
QSize CircleWidget::sizeHint() const
{
return QSize( 200, 200 );
}
清单 7-16 展示了跟踪鼠标活动的三种方法。在仔细研究这些方法之前,重要的是要知道只有在按下鼠标按钮时才会报告鼠标移动。这意味着除非按下鼠标按钮,否则不会调用mouseMoveEvent
。
提示将mouseTracking
属性设置为true
可以得到鼠标移动报告。
mousePressEvent
和mouseMoveEvent
都根据传递给QMouseEvent
对象的坐标更新mx
和my
变量。它们由timeout
槽在决定是扩大还是缩小当前圆时使用。timeout
槽与timer
相连,因此可以通过启动和停止mousePressEvent
和mouseReleaseEvent
中的timer
来打开和关闭timeout
槽。仅当按下鼠标按钮时,计时器才会激活(在此期间,mx
和my
值有效)。
清单 7-16。 处理鼠标事件
void CircleWidget::mousePressEvent( QMouseEvent *e )
{
mx = e->x();
my = e->y();
timer.start();
}
void CircleWidget::mouseMoveEvent( QMouseEvent *e )
{
mx = e->x();
my = e->y();
}
void CircleWidget::mouseReleaseEvent( QMouseEvent *e )
{
timer.stop();
}
当定时器激活时,timeout
槽每秒钟被调用大约 20 次。插槽的任务是确定它是否会创建一个新的圆,增长当前的圆,或者缩小它。清单 7-17 展示了它是如何完成的。
如果当前半径r
为0
,则创建一个新的圆,其圆心(x
、y
)在当前鼠标位置:mx
、my
。新的颜色是随机产生的,所以每个新的圆圈都会有新的颜色。
无论是否在新的圆上工作,插槽然后通过使用勾股定理(比较mx
、my
和x
、y
之间的平方距离与半径、r
的平方)来检查mx
、my
是否在圆内。如果鼠标在现有的圆内,半径增加;如果在外面,半径减小。
当对圆的所有更改完成后,将调用 update 方法,该方法将一个 paint 事件放在 Qt 事件队列中。当到达该事件时,调用paintEvent
方法。
清单 7-17。 根据当前圆的位置和大小以及鼠标指针的位置改变圆
void CircleWidget::timeout()
{
if( r == 0 )
{
x = mx;
y = my;
color = QColor( qrand()%256, qrand()%256, qrand()%256 );
}
int dx = mx-x;
int dy = my-y;
if( dx*dx+dy*dy <= r*r )
r++;
else
r--;
update();
}
清单 7-18 中的显示了paintEvent
方法。该方法所做的只是绘制当前圆(如果r
大于 0,则由x
、y
、r
和color
定义)。因为圆的边缘有时看起来有锯齿的趋势,所以你也告诉画家用抗锯齿来柔化边缘(通过设置渲染提示)。顾名思义,它是一个提示,而不是一个有保证的操作。
提示 抗锯齿意味着形状的边缘被平滑。边缘有时会出现锯齿,因为边缘位于可用像素之间。通过计算要添加到每个像素的颜色量,可以获得更平滑的结果(取决于每个像素离边缘有多近)。
简单地绘制新的圆圈而不擦除任何东西是可行的,因为默认情况下 Qt 总是复制背景图形。因为这个小部件不打算放在其他小部件的上面,所以通常意味着纯灰色。通过将autoFillBackground
属性设置为true
,可以强制 Qt 用样式的背景色填充背景。
清单 7-18。 画圆
void CircleWidget::paintEvent( QPaintEvent* )
{
if( r > 0 )
{
QPainter painter( this );
painter.setRenderHint( QPainter::Antialiasing );
painter.setPen( color );
painter.setBrush( color );
painter.drawEllipse( x-r, y-r, 2*r, 2*r );
}
}
在讨论绘画事件时,有几个小部件属性是您应该知道的——它们可以用来进一步优化小部件绘画。您可以使用setAttribute(Qt::WidgetAttribute, bool)
方法设置这些属性。布尔参数,默认情况下是true
,表示应该设置该属性。如果通过了false
,属性被清除。您可以通过使用testAttribute(Qt::WidgetAttribute)
方法来测试一个属性是否被设置。这个不完整的列表解释了一些可用于优化小部件绘制的属性:
- 当小工具重新绘制自己时,它使用不透明的颜色绘制所有的像素。这意味着没有阿尔法混合,Qt 不需要处理背景清除。
Qt::WA_NoSystemBackground
:同Qt::WA_OpaquePaintEvent
,但更明确。没有系统背景的小部件不会被 Qt 事件初始化,所以底层图形会一直亮着,直到小部件被绘制出来。Qt::WA_StaticContents
:内容是静态的,其原点中心在左上角。当这样的小部件被放大时,只有出现在右边和下面的新矩形需要重画。收缩时,根本不需要paintEvent
。
图形视图
到目前为止,你已经通过paintEvent
管理了所有的自定义绘画。图形视图框架考虑到大多数应用程序都是围绕二维画布构建的。通过提供以优化方式处理这种场景的类,可以创建一种定制小部件的感觉,而无需实际创建一个定制小部件。
图形视图框架由三个基本组件构建而成:视图*、场景和项目。视图类QGraphicsView
是一个显示场景内容的小部件。场景QGraphicsScene
包含一组小部件,并管理与这些部件相关的事件和状态的传播。每个条目都是QGraphicsItem
的子类,代表一个单独的图形条目或一组条目。*
*基本的想法是,你创建一组项目,把它放在一个场景中,让一个视图显示它。通过侦听事件和重绘项目,您可以创建所需的用户界面。为了避免创建一组项目,Qt 附带了一系列准备好的项目。
清单 7-19 显示了一个主函数,其中一个场景用标准项目填充,并用一个视图显示。让我们从函数的顶部开始,向下进行。
首先创建一个名为scene
的QGraphicsScene
对象,并将一个QRect
传递给构造器。这个矩形用来定义场景。所有项目都应该出现在这个区域内。请注意,场景可以从非零坐标开始,甚至可以从负坐标开始。
下一步是用物品填充场景。从创建QGraphicsRectItem (QRect,QGraphicsItem*,QGraphicsScene*)
开始。构造器接受一个定义该项目的尺寸和位置的矩形,一个指向父项的QGraphicsItem
指针和一个指向父场景的QGraphicsScene
指针。使用父项目,可以将项目放置在其他项目中(稍后您将了解更多)。通过传递一个场景指针,可以将该项目添加到给定的场景中。您也可以使用从scene
对象中获得的addItem(QGraphicsItem*)
方法来完成这项工作。当矩形被添加到场景中时,你也为它设置了钢笔和画笔。
注意如果你不设置钢笔或画笔,你会以标准设置结束,这通常意味着没有画笔和黑色实线。
您创建的下一个项目是一个QGraphicsSimpleTextItem
。构造器接受一个QString
文本和两个父指针。因为构造器不允许您定位文本,所以调用setPos
方法来定位该项的左上角。
添加一个带有构造器的QGraphicsEllipseItem
,该构造器接受一个矩形和父指针。接下来是一个接受一个QPolygonF
对象和父指针的QGraphicsPolygonItem
。使用QPointF
对象的向量来初始化QPolygonF
。这些点定义了在其间绘制多边形边的点。为这两个对象设置钢笔和画笔。
当这些项目被添加到场景中时,创建一个QGraphicsView
小部件并调用setScene(QGraphicsScene*)
来告诉它显示哪个场景。然后显示视图并运行app.exec()
来启动事件循环。产生的窗口如图 7-28 中的所示。
清单 7-19。 用标准形状填充场景
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QGraphicsScene scene( QRect( −50, −50, 400, 200 ) );
QGraphicsRectItem *rectItem = new QGraphicsRectItem(
QRect( −25, 25, 200, 40 ), 0, &scene );
rectItem->setPen( QPen( Qt::red, 3, Qt::DashDotLine ) );
rectItem->setBrush( Qt::gray );
QGraphicsSimpleTextItem *textItem = new QGraphicsSimpleTextItem(
"Foundations of Qt", 0, &scene );
textItem->setPos( 50, 0 );
QGraphicsEllipseItem *ellipseItem = new QGraphicsEllipseItem(
QRect( 170, 20, 100, 75 ),
0, &scene );
ellipseItem->setPen( QPen(Qt::darkBlue) );
ellipseItem->setBrush( Qt::blue );
QVector<QPointF> points;
points << QPointF( 10, 10 ) << QPointF( 0, 90 ) << QPointF( 40, 70 )
<< QPointF( 80, 110 ) << QPointF( 70, 20 );
QGraphicsPolygonItem *polygonItem = new QGraphicsPolygonItem(
QPolygonF( points() ), 0, &scene );
polygonItem->setPen( QPen(Qt::darkGreen) );
polygonItem->setBrush( Qt::yellow );
QGraphicsView view;
view.setScene( &scene );
view.show();
return app.exec();
}
图 7-28。 带有一些标准项目的图形视图
图 7-28 和清单 7-19 展示了一些有趣的事情:
- 视图的左上角对应于场景坐标 50,50,因为
QRect
传递给了场景的构造器。 - 矩形项目被多边形和椭圆遮挡,因为场景项目是按照它们被添加到场景中的顺序绘制的。如果不喜欢可以编程控制。
- 如果您尝试自己运行该示例并缩小包含该视图的窗口,该视图将自动显示滑块,让您可以平移整个场景。
Qt 还附带了其他标准项目,下面列出了其中一些:
QGraphicsPathItem
:绘制一条画家路径。QGraphicsLineItem
:画一条单线。QGraphicsPixmapItem
:绘制一个点阵图;即位图图像。QGraphicsSvgtIem
:绘制矢量图形图像。QGraphicsTextItem
:绘制复杂文本,如富文本文档。
您可以使用图形视图自由变换形状项目,图形视图也是项目的父项进入图片的地方。如果一个项的父项被转换,子项也会以同样的方式被转换。
清单 7-20 显示了函数createItem
,它以一个父场景指针和一个 x 偏移量作为参数。然后,这两个参数用于创建一个包含另一个矩形和一个椭圆的矩形。外部矩形用灰色画笔填充;内部项目用白色填充。
该函数返回一个指向外部矩形的指针,而外部矩形又包含另外两个。这意味着指针可以用来操纵所有的形状。
清单 7-20。 一个形状包含另外两个形状
`QGraphicsItem *createItem( int x, QGraphicsScene *scene )
{
QGraphicsRectItem *rectItem = new QGraphicsRectItem(
QRect( x+40, 40, 120, 120 ),
0, scene );
rectItem->setPen( QPen(Qtblack) );
rectItem->setBrush( Qtgray );
QGraphicsRectItem *innerRectItem = new QGraphicsRectItem(
QRect( x+50, 50, 45, 100 ),
rectItem, scene );
innerRectItem->setPen( QPen(Qtblack) );
innerRectItem->setBrush( Qtwhite );
QGraphicsEllipseItem *ellipseItem = new QGraphicsEllipseItem(
QRect( x+105, 50, 45, 100 ),
rectItem, scene );
ellipseItem->setPen( QPen(Qtblack) );
ellipseItem->setBrush( Qtwhite );
return rectItem;
}`
在清单 7-21 所示的main
函数中使用了createItem
函数,其中创建了一个场景。然后,在场景显示之前,向该场景添加五个项目。每个项目都以不同的方式进行转换。由此产生的场景可以在图 7-29 中看到。当您查看应用于每个项目的转换时,请参考图和源代码。
图 7-29。 左起:原始、旋转、缩放、剪切,一次全部
item1
项目被放置在场景中,没有应用任何变换。可以看做参考项。
将item2
项平移、旋转 30 度,然后平移回其原始位置,使其围绕(0,0)点旋转。通过平移项目,使其中心点位于点(0,0)上,您可以在将其平移回原始位置之前,围绕其中心旋转它。
item3
项目也被平移,使得点(0,0)成为项目的中心。因为缩放也是相对于坐标系的中心点,所以在平移回来之前会先进行缩放。通过围绕其中心缩放项目,您可以更改形状的大小,但不能更改其位置。
第四项,item4
,翻译和复译为item2
和item3
。在平移之间,它被剪切。
第五项,item5
,被缩放,旋转,剪切,使其变形。这个项目展示了如何将所有的变换应用到一个对象上。
注意应用变换时,记住顺序很重要。以不同的顺序应用变换将产生不同的结果。
清单 7-21。 改造五项
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QGraphicsScene scene( QRect( 0, 00, 1000, 200 ) );
QGraphicsItem *item1 = createItem( 0, &scene );
QGraphicsItem *item2 = createItem( 200, &scene );
item2->translate( 300, 100 );
item2->rotate( 30 );
item2->translate( −300, −100 );
QGraphicsItem *item3 = createItem( 400, &scene );
item3->translate( 500, 100 );
item3->scale( 0.5, 0.7 );
item3->translate( −500, −100 );
QGraphicsItem *item4 = createItem( 600, &scene );
item4->translate( 700, 100 );
item4->shear( 0.1, 0.3 );
item4->translate( −700, −100 );
QGraphicsItem *item5 = createItem( 800, &scene );
item5->translate( 900, 100 );
item5->scale( 0.5, 0.7 );
item5->rotate( 30 );
item5->shear( 0.1, 0.3 );
item5->translate( −900, −100 );
QGraphicsView view;
view.setScene( &scene );
view.show();
return app.exec();
}
处理图形项目时,可以使用 Z 值来控制项目的绘制顺序。您可以使用setZValue(qreal)
方法设置每个项目。任何项目的默认 Z 值都是0
。
绘制场景时,Z 值较高的项目会出现在 Z 值较低的项目之前。对于具有相同 Z 值的项目,顺序是未定义的。
使用自定义项目进行交互
对于自定义项目,您可以使用图形视图创建您想要的行为类型。这种实现自定义形状的灵活性和简易性使得 graphics view 成为一个非常好用的工具。
本节的目的是创建一组手柄:一个中心手柄用于移动形状,两个边缘手柄用于调整形状大小。图 7-30 显示了操作中的手柄。请注意,您可以一次将手柄应用于几个形状,并且使用的形状是标准形状:QGraphicsRectItem
和QGraphicsEllipseItem
。
图 7-30。 手柄在动作
让我们开始看代码,从应用程序的main
函数开始。这展示了如何创建、配置和使用句柄。main
函数如清单 7-22 中的所示。
该函数首先创建您需要的 Qt 类:一个QApplication
、一个QGraphicsScene
,以及通过一个QGraphicsRectItem
和一个QGraphicsEllipseItem
表示的两个形状。当这些形状被添加到场景中后,是时候创建六个HandleItem
对象了——每个形状三个。
每个句柄的构造器接受以下参数:一个要操作的项目、一个场景、一种颜色和一个角色。可用的角色有TopHandle
、RightHandle
和CenterHandle
。当你创建一个CenterHandle
时,你必须传递一个指向另外两个句柄的QList
。也就是说,如果你选择其他手柄,CenterHandle
本身就能完美工作,其他两种型号也是如此。
然后,main
函数继续创建一个QGraphicsView
并设置它来显示场景。然后通过调用QApplication
对象上的exec
方法开始主循环。但是,您不能直接返回结果。因为手柄引用其他形状而不是子节点,所以首先删除手柄很重要。当QGraphicsScene
被销毁时,剩下的图形也被删除。
清单 7-22。 使用 HandleItem
类中的一个场景
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QGraphicsScene scene( 0, 0, 200, 200 );
QGraphicsRectItem *rectItem = new QGraphicsRectItem(
QRect( 10, 10, 50, 100 ),
0, &scene );
QGraphicsEllipseItem *elItem = new QGraphicsEllipseItem(
QRect( 80, 40, 100, 80 ),
0, &scene );
HandleItem *trh = new HandleItem( rectItem, &scene, Qt::red,
HandleItem::TopHandle );
HandleItem *rrh = new HandleItem( rectItem, &scene, Qt::red,
HandleItem::RightHandle );
HandleItem *crh = new HandleItem( rectItem, &scene, Qt::red,
HandleItem::CenterHandle,
QList<HandleItem*>() << trh << rrh );
HandleItem *teh = new HandleItem( elItem, &scene, Qt::green,
HandleItem::TopHandle );
HandleItem *reh = new HandleItem( elItem, &scene, Qt::green,
HandleItem::RightHandle );
HandleItem *ceh = new HandleItem( elItem, &scene, Qt::green,
HandleItem::CenterHandle,
QList<HandleItem*>() << teh << reh );
QGraphicsView view;
view.setScene( &scene );
view.show();
return app.exec();
}
既然您已经知道了句柄的外观以及该类在场景中的用法,那么是时候看看实际的类了。清单 7-23 显示了类声明。
清单以该类的前向声明开始,因为该类将包含指向其自身实例的指针。然后,它定义了不同可用角色的枚举:CenterHandle
、RightHandle
和TopHandle
。
如前所述,enum
后面的构造器包含所有预期的参数。但是,角色和句柄列表有默认值。默认角色是中心句柄,默认情况下列表为空。
从QGraphicsItem
继承时需要下面两个方法。paint 方法负责根据请求绘制形状,而boundingRect
告诉场景形状有多大。
然后,类声明继续一组受保护的方法。您可以重写这些方法,通过形状与用户进行交互。mousePressEvent
和mouseReleaseEvent
方法对鼠标按钮作出反应,而itemChange
方法可用于过滤并对项目的所有更改作出反应。您可以使用它来响应和限制小部件的移动。
私有部分结束类声明。它包含所有需要的本地状态和变量。下面的列表总结了它们的角色和用途(在本节的其余部分,您将更仔细地了解它们的用法):
m_item
:手柄作用的QGraphicsItem
。m_role
:手柄的作用。m_color
:手柄的颜色。m_handles
:作用在同一个m_item
上的其他手柄列表——需要中心手柄。m_pressed
:一个布尔值,表示鼠标按钮是否被按下。这一点很重要,因为您需要能够判断句柄是否因为用户交互或编程更改而移动。
清单 7-23。 手柄类
class HandleItem;
class HandleItem : public QGraphicsItem
{
public:
enum HandleRole
{
CenterHandle,
RightHandle,
TopHandle
};
HandleItem( QGraphicsItem *item, QGraphicsScene *scene,
QColor color, HandleRole role = CenterHandle,
QList<HandleItem*> handles = QList<HandleItem*>() );
void paint( QPainter *paint,
const QStyleOptionGraphicsItem *option, QWidget *widget );
QRectF boundingRect() const;
protected:
void mousePressEvent( QGraphicsSceneMouseEvent *event );
void mouseReleaseEvent( QGraphicsSceneMouseEvent *event );
QVariant itemChange( GraphicsItemChange change, const QVariant &data );
private:
QGraphicsItem *m_item;
HandleRole m_role;
QColor m_color;
QList<HandleItem*> m_handles;
bool m_pressed;
};
在清单 7-24 中显示的构造器在设置一个高zValue
之前简单地初始化所有的类变量。这确保了手柄出现在它们所处理的形状的前面。然后设置一个标志,通过使用setFlag
方法使形状可移动。
提示其他标志允许你选择形状(ItemIsSelectable
)或者接受键盘焦点(ItemIsFocusable
)。这些标志可以通过逻辑或运算来组合。
清单 7-24。 句柄项的构造者
HandleItem::HandleItem( QGraphicsItem *item, QGraphicsScene *scene,
QColor color, HandleItem::HandleRole role,
QList<HandleItem*> handles )
: QGraphicsItem( 0, scene )
{
m_role = role;
m_color = color;
m_item = item;
m_handles = handles;
m_pressed = false;
setZValue( 100 );
setFlag( ItemIsMovable );
}
因为这个类实际上实现了三个不同的句柄,它经常使用switch
语句来区分不同的角色(参见清单 7-25 ,其中展示了boundingRect
方法)。边框由所处理形状的边框位置定义。手柄没有自己的位置;相反,它们完全基于处理形状的位置和大小。
清单 7-25。 确定手柄的外接矩形
QRectF HandleItem::boundingRect() const
{
QPointF point = m_item->boundingRect().center();
switch( m_role )
{
case CenterHandle:
return QRectF( point-QPointF(5, 5), QSize( 10, 10 ) );
case RightHandle:
point.setX( m_item->boundingRect().right() );
return QRectF( point-QPointF(3, 5), QSize( 6, 10 ) );
case TopHandle:
point.setY( m_item->boundingRect().top() );
return QRectF( point-QPointF(5, 3), QSize( 10, 6 ) );
}
return QRectF();
}
清单 7-26 中的所示的绘制方法使用boundingRect
方法来决定在哪里以及如何绘制不同的手柄。中央手柄绘制为圆形,而顶部和右侧手柄绘制为指向上方和右侧的箭头。
注意在绘制顶部和右侧手柄时,使用center
方法找到外接矩形的中心点。
清单 7-26。 画手柄
void HandleItem::paint( QPainter *paint,
const QStyleOptionGraphicsItem *option,
QWidget *widget )
{
paint->setPen( m_color );
paint->setBrush( m_color );
QRectF rect = boundingRect();
QVector<QPointF> points;
switch( m_role )
{
case CenterHandle:
paint->drawEllipse( rect );
break;
case RightHandle:
points << rect.center()+QPointF(3,0) << rect.center()+QPointF(-3,-5)
<< rect.center()+QPointF(-3,5);
paint->drawConvexPolygon( QPolygonF(points) );
break;
case TopHandle:
points << rect.center()+QPointF(0,-3) << rect.center()+QPointF(-5,3)
<< rect.center()+QPointF(5,3);
paint->drawConvexPolygon( QPolygonF(points) );
break;
}
}
在您确定了在哪里绘制,然后绘制手柄之后,下一步是等待用户交互。清单 7-27 显示了处理鼠标按钮事件的方法,比如按下和释放。
因为您在构造器中较早地设置了ItemIsMoveable
标志,所以您所要做的就是在将事件传递给QGraphicsItem
处理程序之前更新m_pressed
变量。
清单 7-27。 处理鼠标按下和释放事件
void HandleItem::mousePressEvent( QGraphicsSceneMouseEvent *event )
{
m_pressed = true;
QGraphicsItem::mousePressEvent( event );
}
void HandleItem::mouseReleaseEvent( QGraphicsSceneMouseEvent *event )
{
m_pressed = false;
QGraphicsItem::mouseReleaseEvent( event );
}
当用户选择移动一个句柄时,itemChange
方法被调用。这种方法给你一个机会来对变化做出反应(或者甚至停止变化)(你可以在清单 7-28 中看到实现)。我删除了清单中处理不同角色移动的部分(稍后您将看到它们);清单只显示了外部框架。简单地让程序性的移动和与移动无关的改变传递给相应的QGraphicsItem
方法。如果遇到用户调用的位置更改,您会根据句柄的角色采取不同的操作。但是首先通过比较新位置和当前位置来计算实际移动。新位置通过data
参数传递,而当前位置由pos
方法给出。您还可以确定正在处理的形状的中心点,因为在处理右侧和顶部手柄时都会用到它。
清单 7-28。 处理对手柄的修改
QVariant HandleItem::itemChange( GraphicsItemChange change,
const QVariant &data )
{
if( change == ItemPositionChange && m_pressed )
{
QPointF movement = data.toPoint() - pos();
QPointF center = m_item->boundingRect().center();
switch( m_role )
{
...
}
}
return QGraphicsItem::itemChange( change, data );
}
清单 7-29 展示了如何处理用户调用的中心手柄的位置变化。通过使用moveBy
调用来移动正在处理的项目m_item
。在m_handles
列表中的所有手柄都被转换到适当的位置,因为任何右侧和顶部的手柄都必须遵循它们正在处理的形状。
清单 7-29。 中心手柄的手柄动作
switch( m_role )
{
case CenterHandle:
m_item->moveBy( movement.x(), movement.y() );
foreach( HandleItem *handle, m_handles )
handle->translate( movement.x(), movement.y() );
break;
...
}
return QGraphicsItem::itemChange( change, pos()+movement );
顶部和右侧的手柄只影响它们自己,这意味着它们不使用m_handles
列表。形状的中心点不受影响;水平方向不受顶部句柄的影响,垂直方向也不受右侧句柄的影响。
清单 7-30 和 7-31 展示了角色是如何被处理的。清单看起来非常相似;唯一的区别是他们行动的方向。
我们来看看清单 7-30 的详细内容;也就是顶把。清单以一个if
子句开始,确保形状不会太小。如果是这种情况,将当前位置作为下一个位置传递给QGraphicsItem itemChange
方法。
如果手柄形状足够大,继续限制手柄的移动方向(不允许顶部手柄水平移动)。然后,您平移正在处理的图形,使图形的中心成为坐标系的原点。这是缩放的准备工作,根据运动来缩放形状。该形状被转换回其原始位置,switch 语句被留下,而QGraphicsItemitemChange
方法被赋予该事件,但是具有受限的移动方向。
清单 7-30。 顶部手柄的操作动作
switch( m_role )
{
...
case TopHandle:
if( −2*movement.y() + m_item->sceneBoundingRect().height() <= 5 )
return QGraphicsItem::itemChange( change, pos() );
movement.setX( 0 );
m_item->translate( center.x(), center.y() );
m_item->scale( 1, 1.0-2.0*movement.y()
/(m_item->sceneBoundingRect().height()) );
m_item->translate( -center.x(), -center.y() );
break;
}
return QGraphicsItem::itemChange( change, pos()+movement );
清单 7-31。 右手柄的操作动作
` switch( m_role )
{
...
case RightHandle:
if( 2*movement.x() + m_item->sceneBoundingRect().width() ⇐ 5 )
return QGraphicsItem::itemChange( change, pos() );
movement.setY( 0 );
m_item->translate( center.x(), center.y() );
m_item->scale( 1.0+2.0*movement.x()
/(m_item->sceneBoundingRect().width()), 1 );
m_item->translate( -center.x(), -center.y() );
break;
...
}
return QGraphicsItem::itemChange ( change, pos()+movement );`
印刷
Qt 用QPrinter
类处理打印机,它代表一个特定打印机的打印作业,可以用作一个绘画设备。这意味着您可以创建一个QPainter
来绘制到通过QPrinter
表示的页面上。然后,printer 对象用于创建新页面,并告知打印机何时可以打印作业。
看看该类中的一些可用属性:
colorMode
:打印机以彩色或灰度打印。可以设置为QPrinter::Color
或QPrinter::GrayScale
。orientation
:页面可以定位为横向(QPrinter::Landscape
)或者纵向(QPrinter::Portrait
)。outputFormat
:打印机可以打印到平台自带的打印系统(QPrinter::Native
)、PDF 文档(QPrinter::PdfFormat
)或 PostScript 文档(QPrinter::PostScriptFormat
)。当打印到文件时,这在创建 PDF 和 PostScript 文档时是必需的,您必须使用setOutputFileName
设置文档的文件名。pageSize
:根据不同标准的纸张大小。包括 A4 (QPrinter::A4
)和 Letter (QPrinter::Letter
)纸张尺寸,但支持更多尺寸。详情请参考 Qt 文档。
让我们继续进行一些实际的打印。
在尝试打印时,拥有一个虚拟打印机驱动程序或打印到一个文件会非常有用——它可以节省大量纸张。
画到打印机上
向打印机绘图最直接的方法是创建一个QPainter
来直接访问QPrinter
对象。要配置QPrinter
对象,使用QPrintDialog
标准对话框(见图 7-31 ),用户可以在其中选择打印机,也可以对打印作业进行一些基本选择。
图 7-31。 出现打印机选择和配置对话框
清单 7-32 显示了创建五页打印输出的整个应用程序的源代码。打印作业中某一页的顶部如图 7-32 所示。
图 7-32。 一★页
清单 7-32 从创建QApplication
、QPrinter
和QPrintDialog
开始。然后执行该对话;如果它被接受,你将做一些印刷。
当您创建一个引用打印机对象的QPainter
并将其设置为使用黑色钢笔时,实际的打印就准备好了。然后使用一个for
循环来创建五个页面。对于每一页,在QPrinterpageRect
中画一个矩形和两条交叉的线。这是一个代表可打印区域的矩形(代表整张纸的矩形称为paperRect
)。
计算textArea
矩形的尺寸。(这个矩形的两边和顶部有半英寸的边距,底部有整整一英寸的边距。)分辨率方法给出每英寸的点数,因此0.5*printer.resolution()
得出覆盖半英寸所需的点数。在文本区域周围画一个框,然后将页码作为文本打印在同一个矩形内。
如果你不在最后一页,也就是说,这一页不等于四,调用newPage
方法。该页面打印当前页面并创建一个新的空白页以继续绘画。
清单 7-32。 画到一个 QPrinter
的物体上
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QPrinter printer;
QPrintDialog dlg( &printer );
if( dlg.exec() == QDialog::Accepted )
{
QPainter painter( &printer );
painter.setPen( Qt::black );
for( int page=0; page<5; page++ )
{
painter.drawRect( printer.pageRect() );x
painter.drawLine( printer.pageRect().topLeft(),
printer.pageRect().bottomRight() );
painter.drawLine( printer.pageRect().topRight(),
printer.pageRect().bottomLeft() );
QRectF textArea(
printer.pageRect().left() +printer.resolution() * 0.5,
printer.pageRect().top() +printer.resolution() * 0.5,
printer.pageRect().width() -printer.resolution() * 1.0,
printer.pageRect().height()-printer.resolution() * 1.5 );
painter.drawRect( textArea );
painter.drawText( textArea, Qt::AlignTop | Qt::AlignLeft,
QString( "Page %1" ).arg( page+1 ) );
if( page != 4 )
printer.newPage();
}
}
return 0;
}
将一个图形场景渲染到打印机上
使用 painter 对象在打印机上绘图可能很容易,但是如果您的整个文档都基于图形视图框架,这就没什么用了。你必须能够将你的场景渲染到打印机上,这很容易做到。
将清单 7-33 与清单 7-19 进行比较。清单 7-33 使用与清单 7-19 相同的场景,但它不是通过场景显示,而是使用render
方法将它打印到打印机。通过比较图 7-33 和图 7-28 可以比较输出。正如你所看到的,这个场景在纸上和屏幕上都表现得很好。
图 7-33。 一个打印出来的图形场景
render
方法接受四个参数。从左到右,它们是要渲染到的画师、目标矩形、源矩形和确定如何缩放的标志。在清单中,画师绘制了一个QPrinter
对象。目标矩形代表页面的整个可打印区域,而源矩形代表整个场景。缩放标志被设置为Qt::KeepAspectRatio
,这意味着场景的高宽比将保持不变。
如果你想让场景拉伸以填充目标矩形,你可以使用Qt::IgnoreAspectRatio
。另一种选择是让场景填充页面,但仍然通过传递Qt::KeepAspectRatioByExpanding
来保持其高宽比。这意味着,除非源矩形和目标矩形具有相同的部分,否则场景将超出可用页面。
清单 7-33。 向打印机渲染一个图形场景
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QGraphicsScene scene( QRect( −50, −50, 400, 200 ) );
...
QPrinter printer;
QPrintDialog dlg( &printer );
if( dlg.exec() )
{
QPainter painter( &printer );
scene.render( &painter, printer.pageRect(),
scene.sceneRect(), Qt::KeepAspectRatio );
}
return 0;
}
OpenGL
在本章的第一段,我提到了使用QPainter
类的唯一替代方法是直接使用 OpenGL。因为 OpenGL 是一种编程接口,不在本书的讨论范围之内,所以您将看到如何在不直接编写 OpenGL 代码的情况下使用 OpenGL 的硬件加速。
一个QGraphicsView
是一个给定场景的视口,但是它也包含一个可以通过viewport
属性访问的视口部件。如果您为视图提供一个QGLWidget
,图形将使用 OpenGL 绘制。
在清单 7-21 中,所需的更改仅限于清单 7-34 中高亮显示的行。代码创建一个新的QGLWidget
并将其设置为 viewport。QGraphicsView
项拥有其视口的所有权,所以不需要提供父指针。
清单 7-34。 使用 OpenGL 绘制图形场景
int main( int argc, char **argv )
{
...
QGraphicsView view;
view.setScene( &scene );
view.setViewport( new QGLWidget() );
view.show();
return app.exec();
}
要使用 OpenGL 构建 Qt 应用程序,您必须通过在项目文件中添加一行内容QT += opengl
来包含 Qt OpenGL 模块。使用 OpenGL 绘制场景和使用普通小部件绘制场景之间的区别是看不出来的——这才是重点。然而,在提供 OpenGL 硬件加速的系统上,性能会大大提高。
总结
使用QPainter
类很容易绘制,它可以用于绘制各种设备(屏幕、图像、位图和打印机)。通过缩放、旋转、剪切和平移,几乎可以画出任何可以想象的形状。
QPainter
类是创建带有绘画逻辑的定制小部件的主力。如果您想在单个文档或小部件中表示多个独立的形状,图形视图框架会很有帮助。通过创建一个QGraphicsScene
并用QGraphicsItem
对象填充它,您可以很容易地为用户创建一个交互式画布。可以使用QGraphicsView
小工具显示场景,也可以使用QPainter
打印到QPrinter
上。*****
八、文件、流和 XML
对于跨平台的应用程序来说,处理文件是一个复杂的问题,因为即使是最基本的功能也会因平台而异。例如,Unix 系统使用斜杠(/
)作为路径中的分隔符,而 Windows 平台使用反斜杠(\
)。而这仅仅是开始;您还会遇到一系列令人不安的基本差异,比如不同的行尾和编码,当您试图让您的应用程序在多个平台上运行时,每种差异都会导致各种奇怪的问题。
为了克服这个问题,Qt 提供了一系列的类来处理路径、文件和流。Qt 还处理 XML 文件——一种以可移植方式组织内容的格式。
使用路径
QDir
类是 Qt 应用程序中处理路径和驱动器的关键。当指定一个QDir
对象的路径时,斜线(/
)被用作分隔符,并自动转换为当前平台上使用的任何分隔符。允许使用驱动器号,以冒号(:
)开头的路径被解释为对嵌入到应用程序中的资源的引用。
静态方法使得轻松导航文件系统成为可能。首先,QDir::current()
返回一个引用应用程序工作目录的QDir
。QDir::home()
返回用户主目录的QDir
。QDir::root()
返回根目录,QDir::temp()
返回临时文件的目录。QDir::drives()
返回一个QFileInfo
对象的QList
,代表所有可用驱动器的根。
注意 Unix 系统被认为只有一个驱动器/
,而 Windows 机器的驱动器空间可以配置成有几个驱动器。
对象用来保存文件和目录的信息。它有许多有用的方法,下面列出了其中一些:
isDir()
、isFile()
、isSymLink()
:如果文件信息对象表示目录、文件或符号链接(或 Windows 上的快捷方式),则返回true
。dir()
和absoluteDir()
:返回一个由文件信息对象表示的QDir
对象。dir
方法可以返回一个相对于当前目录的目录,而absoluteDir
返回一个以驱动器根目录开始的目录路径。exists()
:如果对象存在,返回true
。isHidden()
、isReadable()
、isWritable()
、isExecutable()
:返回文件状态信息。fileName()
:返回不带路径的文件名作为QString
。filePath()
:以QString
的形式返回包含路径的文件名。该路径可以相对于当前目录。absoluteFilePath()
:以QString
的形式返回包含路径的文件名。路径从驱动器根目录开始。completeBaseName()
和completeSuffix()
:返回保存文件名和文件名后缀(扩展名)的QString
对象。
让我们使用这些方法创建一个应用程序,列出所有驱动器和每个驱动器根目录下的文件夹。诀窍是使用QDir::drives
找到驱动器,然后找到每个驱动器的根目录(见清单 8-1 )。
清单 8-1。 用根目录列出驱动器
#include <QDir>
#include <QFileInfo>
#include <QtDebug>
int main( int argc, char **argv )
{
foreach( QFileInfo drive, QDir::drives() )
{
qDebug() << "Drive: " << drive.absolutePath();
QDir dir = drive.dir();
dir.setFilter( QDir::Dirs );
foreach( QFileInfo rootDirs, dir.entryInfoList() )
qDebug() << " " << rootDirs.fileName();
}
return 0;
}
QDir::drives
方法返回使用foreach
迭代的QFileInfo
对象的列表。通过qDebug
打印出驱动器的根路径后,使用dir
方法检索每个根的QDir
对象。
注意要在 Windows 环境中使用qDebug
,您必须将行CONFIG += console
添加到您的项目文件中。
QDir
对象的一个优点是它们可以用来获取目录列表。通过使用filter()
方法,您可以配置对象只返回目录。然后这些目录作为来自entryInfoList
方法的QFileInfo
对象的QList
返回。这些QFileInfo
对象代表目录,但是fileName
方法仍然返回目录名。isDir
和isFile
方法可以确认文件名是目录名还是文件名。如果您认为目录是包含对其内容的引用的文件,这就更容易理解了。
setFilter( Filters )
方法可用于根据许多不同的标准过滤出目录条目。您还可以组合过滤器标准来获得您想要的条目列表。支持以下值:
QDir::Dirs
:列出名称过滤器匹配的目录。
QDir::AllDirs
:列出所有目录(不应用名称过滤器)。
QDir::Files
:列出文件。
QDir::Drives
:列出驱动器。在 Unix 系统上它被忽略。
QDir::NoSymLinks
:不列出符号链接。在不支持符号链接的平台上,它被忽略。
QDir::NoDotAndDotDot
:不列出特殊条目.
和.
..
QDir::AllEntries
:列出目录、文件、驱动器和符号链接。
QDir::Readable
:列出可读文件。必须与Files
或Dirs
结合使用。
QDir::Writeable
:列出可写文件。必须与Files
或Dirs
结合使用。
QDir::Executable
:列出可执行文件。必须与Files
或Dirs
结合使用。
QDir::Modified
:列出已修改的文件。在 Unix 系统上它被忽略。
QDir::Hidden
:列出隐藏的文件。在 Unix 系统上,它列出了以.
开头的文件。
QDir::System
:列出系统文件。
QDir::CaseSensitive
:如果文件系统区分大小写,名称过滤器应该区分大小写。
filter
方法与setNameFilters()
方法相结合,后者采用文件名匹配模式的QStringList
,比如*.cpp
。请注意,名称过滤器是一个模式列表,因此可以用一个名称过滤器过滤*.cpp
、*.h
、*.qrc
、*.ui
和*.pro
文件。
使用文件
您可以使用QDir
查找文件,使用QFileInfo
查找关于文件的更多信息。为了更进一步,实际打开、读取、修改和创建文件,你必须使用QFile
类。
让我们通过查看清单 8-2 中的来开始查看QFile
。应用程序检查文件testfile.txt
是否存在。如果是,应用程序会尝试打开它进行写入。如果允许的话,它会再次关闭文件。在这个过程中,它使用qDebug
打印状态信息。
清单中突出显示的行显示了有趣的QFile
操作。首先,在构造器中设置文件名。可以使用setFileName(const QString&)
方法设置文件名,这使得重用QFile
对象成为可能。接下来,应用程序使用exists
方法来查看文件是否存在。
最后突出显示的一行试图打开文件进行写入,因为在 Qt 支持的所有平台上对文件进行写保护很容易。如果文件成功打开,open 方法返回true
。
清单的其余部分由输出调试消息和退出主函数的代码组成(使用return
)。如果文件打开成功,请确保在退出前关闭文件。
清单 8-2。 基本 QFile
操作
#include <QFile>
#include <QtDebug>
int main( int argc, char **argv )
{
QFile file( "testfile.txt" );
if( !file.exists() )
{
qDebug() << "The file" << file.fileName() << "does not exist.";
return −1;
}
if( !file.open( QIODevice::WriteOnly ) )
{
qDebug() << "Could not open" << file.fileName() << "for writing.";
return −1;
}
qDebug() << "The file opened.";
file.close();
return 0;
}
前面的清单打开文件进行写入。打开文件时,您可以使用其他标志来控制文件的读取和修改方式:
QIODevice::WriteOnly
:打开文件进行写入。QIODevice::ReadWrite
:打开文件进行读写。QIODevice::ReadOnly
:打开文件进行读取。
上述三个标志可以与以下标志结合使用,以详细控制文件访问模式:
QIODevice::Append
:将所有写入的数据追加到文件末尾。QIODevice::Truncate
:打开文件时清空文件。QIODevice::Text
:将文件作为文本文件打开。从文件中读取时,所有行尾都被翻译成\n
。当写入文件时,行尾被转换成适合目标平台的格式(例如,Windows 上的\r\n
和 Unix 上的\n
)。QIODevice::Unbuffered
:打开文件,没有任何缓冲。
您总是可以通过调用openMode()
方法来判断给定的QFile
对象使用的是哪种模式。它返回当前模式。对于关闭的文件,它返回QIODevice::NotOpen
。
使用流
打开文件后,使用 stream 类访问它会更方便。Qt 附带了两个流类:一个用于文本文件,一个用于二进制文件。通过打开一个流来访问一个文件,您可以使用重定向操作符(<<
和>>
)在文件中写入和读取数据。使用 streams,您还可以避开平台差异,比如字节顺序和不同的行尾策略。
文本流
使用文本流,您可以像在 C++ 标准库中一样连接一个文件——但是有所不同。奇怪的是,文件是以跨平台的方式处理的,这样当您在不同的计算机之间移动应用程序和文件时,行尾和其他类似的细节就不会弄乱结果。
要为文件创建文本流,创建一个QFile
对象,然后像往常一样打开它。建议您通过读写策略传递QIODevice::Text
标志。打开文件后,将指向文件对象的指针传递给一个QTextStream
对象的构造器。QTextStream
对象现在是进出文件的流,这取决于文件是如何打开的。
清单 8-3 显示了一个main
函数,它打开一个名为main.cpp
的文件作为文本阅读。如果文件打开成功,将创建一个文本流。在函数结束时,文件被关闭。
清单 8-3。 打开文本流阅读
int main( int argc, char **argv )
{
QFile file( "main.cpp" );
if( !file.open( QIODevice::ReadOnly | QIODevice::Text ) )
qFatal( "Could not open the file" );
QTextStream stream( &file );
...
file.close();
return 0;
}
清单 8-4 显示了一个简单的循环,用于前面清单中的main
函数。循环使用atEnd
来查看是否到达了文件的末尾。如果没有,使用>>
操作符从流中读取一个QString
,然后打印到调试控制台。
执行所示循环的结果看起来不像main.cpp
文件的内容。操作符>>
一直读到遇到第一个空格。所以线路#include <QFile>
会被分成#include
和<QFile>
。因为qDebug
在每次调用后添加了一个换行符,所以示例行将在调试控制台上打印两行。
清单 8-4。 从文本流中逐字阅读
while( !stream.atEnd() )
{
QString text;
stream >> text;
qDebug() << text;
}
解决方案是要么使用 stream 对象上的readAll()
方法读取整个文件,包括文本和换行符,要么逐行读取。使用readAll()
进行读取在大多数情况下是可行的,但是因为整个文件是一次性加载到内存中的,所以它很容易耗尽整个内存。
要逐行读取文件,使用readLine()
方法,一次读取一整行。清单 8-5 显示了先前清单中的循环,但是用readLine
代替。执行这个循环会在调试控制台上给出一个结果,显示出main.cpp
文件的内容。
清单 8-5。 从文本流中逐行读取
while( !stream.atEnd() )
{
QString text;
text = stream.readLine();
qDebug() << text;
}
数据流
有时你不能依赖于使用文本文件来存储数据。例如,您可能希望支持现有的非基于文本的文件格式,或者您可能希望生成较小的文件。通过以机器可读的二进制格式存储实际数据,而不是将其转换为人类可读的文本,您可以在保存和加载方法中节省文件大小和复杂性。
当需要读写二进制数据时,可以使用QDataStream
类。然而,在使用数据流时,有两件重要的事情需要记住:数据类型和版本控制。
对于数据类型,您必须确保对>>
操作符和<<
操作符使用完全相同的数据类型。在处理整数值时,最好使用qint8
、qint16
、qint32
或qint64
,而不是可以在平台间改变大小的short
、int
和long
数据类型。
第二个问题,版本控制,涉及到确保使用相同版本的 Qt 来读写数据,因为不同版本的 Qt 之间二进制数据的编码已经改变。为了避免这个问题,可以用setVersion(int)
方法设置QDataStream
的版本。如果你想使用 Qt 1.0 的数据流格式,将版本设置为QDataStream::Qt_1_0
。创建新格式时,建议使用尽可能高的版本(对于 Qt 4.2 应用,使用QDataStream::Qt_4_2
)。
所有基本的 C++ 类型和大多数 Qt 类型——比如QColor
、QList
、QString
、QRect
和QPixmap
——都可以通过数据流序列化。为了能够序列化你自己的类型,比如自定义的struct
,你需要为你的类型提供<<
和>>
操作符。清单 8-6 显示了ColorText
结构和它的重定向操作符。该结构用于保存字符串和颜色。
提示当一个对象或数据被序列化时,意味着该对象被转换成一系列适合流的数据。有时候这种转换是很自然的(比如一个字符串已经是一系列字符);在其他情况下,它需要一个转换操作(例如,一个树结构不能以自然的方式映射到一系列数据)。当需要转换时,必须设计一个序列化方案,定义如何序列化一个结构,以及如何从序列化的数据中恢复该结构。
在这个上下文中, type 表示任何类型——类、结构或联合。通过为这种类型提供<<
和>>
操作符,您可以在不需要任何特殊处理的情况下将该类型用于数据流。如果您查看清单中的流操作符,您会看到它们操作对一个QDataStream
对象和一个ColorText
对象的引用,并返回对一个QDataStream
对象的引用。这是您必须为希望能够序列化的所有自定义类型提供的接口。该实现基于使用现有的<<
和>>
操作符来序列化所讨论的类型。还要记住将数据放在流中的顺序与您计划读回数据的顺序相同。
如果您想为一种可变大小的类型编写流操作符——例如,一个类似字符串的类——您必须首先在您的<<
操作符中将字符串的长度发送给流,以了解您需要使用>>
操作符读回多少信息。
清单 8-6。ColorText
结构及其<<
和>>
运算符
struct ColorText
{
QString text;
QColor color;
};
QDataStream &operator<<( QDataStream &stream, const ColorText &data )
{
stream << data.text << data.color;
return stream;
}
QDataStream &operator>>( QDataStream &stream, ColorText &data )
{
stream >> data.text;
stream >> data.color;
return stream;
}
既然自定义类型ColorText
已经创建,让我们尝试序列化一系列ColorText
对象:一个QList<ColorText>
。清单 8-7 向你展示了如何做到这一点。首先,创建并填充一个列表对象。然后,在以与文本流相同的方式创建数据流之前,打开文件进行写入。最后一步是使用setVersion
来确保版本设置正确。当一切都设置好后,只需使用<<
操作符将列表发送到流中并关闭文件。所有的细节都是通过直接和间接调用QList
、ColorText
、QString
、QColor
的不同层次的<<
操作符整理出来的。
清单 8-7。 保存列表中的 ColorText
项
QList<ColorText> list;
ColorText data;
data.text = "Red";
data.color = Qt::red;
list << data;
...
QFile file( "test.dat" );
if( !file.open( QIODevice::WriteOnly ) )
return;
QDataStream stream( &file );
stream.setVersion( QDataStream::Qt_4_2 );
stream << list;
file.close();
将序列化的数据加载回来就像序列化它一样简单。只需创建正确类型的目标对象;在这种情况下,使用QList<ColorText>
。打开文件进行读取,然后创建数据流。确保数据流使用正确的版本,并使用>>
操作符从数据流中读取数据。
在清单 8-8 中,您可以看到数据是从一个文件中加载的,并且新加载的列表的内容是使用foreach
循环中的qDebug
转储到调试控制台的。
清单 8-8。 加载列表中的 ColorText
项
QList<ColorText> list;
QFile file( "test.dat" );
if( !file.open( QIODevice::ReadOnly ) )
return;
QDataStream stream( &file );
stream.setVersion( QDataStream::Qt_4_2 );
stream >> list;
file.close();
foreach( ColorText data, list )
qDebug() << data.text << "("
<< data.color.red() << ","
<< data.color.green() << ","
<< data.color.blue() << ")";
XML
XML 是一种元语言,使您能够在字符串或文本文件中存储结构化数据(XML 标准的细节超出了本书的范围)。XML 文件的基本构造块是标签、属性和文本。以清单 8-9 为例。document
标签包含author
标签和显示Some text
的文本。document
标签以开始标签<document>
开始,以结束标签</document>
结束。
清单 8-9。 一个非常简单的 XML 文件
<document name="DocName">
<author name="AuthorName" />
Some text
</document>
这两个标签都有一个名为name
的属性,值为DocName
和AuthorName
。一个标签可以有任意数量的属性,从无到无限。
author
标签没有内容,一次打开和关闭。写<author />
相当于写<author></author>
。
注意关于 XML,这些信息是你最起码需要知道的。这里展示的 XML 文件甚至不是一个真正的 XML 文件——它缺少文档类型定义。您甚至还没有开始学习 XML 的名称空间和其他有趣的细节。但是现在您已经知道了足够多的知识,可以开始使用 Qt 读写 XML 文件了。
Qt 支持两种处理 XML 文件的方式:DOM 和 SAX(在下面的小节中描述)。在开始之前,您需要知道 XML 支持是 Qt 模块QtXml
的一部分,这意味着您需要在项目文件中添加一行内容QT += xml
来包含它。
家
文档对象模型(DOM)的工作原理是将整个 XML 文档表示为内存中的节点对象树。尽管解析和修改文档很容易,但整个文件是一次性加载到内存中的。
创建 XML 文件
让我们从使用 DOM 类创建一个 XML 文件开始。为了使事情变得简单,我们的目标是创建清单 8-9 中的文档。该过程分为三个部分:创建节点、将节点放在一起,以及将文档写入文件。
第一步——创建节点——如清单 8-10 所示。XML 文件的不同构建块包括代表文档的QDomDocument
对象、代表标签的QDomElement
对象和代表document
标签中文本数据的QDomText
对象。
元素和文本对象不是使用构造器创建的。相反,你必须使用QDomDocument
对象的createElement( const QString&)
和createTextNode( const QString &)
方法。
清单 8-10。 为一个简单的 XML 文档创建节点
QDomDocument document;
QDomElement d = document.createElement( "document" );
d.setAttribute( "name", "DocName" );
QDomElement a = document.createElement( "author" );
a.setAttribute( "name", "AuthorName" );
QDomText text = document.createTextNode( "Some text" );
在清单 8-10 中创建的节点不以任何方式排序。它们可以被认为是独立的对象,即使它们都是用相同的文档对象创建的。
为了创建清单 8-9 中的所示的结构,必须使用appendChild( const QDomNode&)
方法将author
元素和文本放入文档元素中,如清单 8-11 中的所示。在清单中,您还可以看到,document
标签以同样的方式附加到文档中。它构建了相同的树结构,正如在您试图创建的文件中可以看到的那样。
清单 8-11。 将 DOM 树中的节点放在一起
document.appendChild( d );
d.appendChild( a );
d.appendChild( text );
最后一步是打开一个文件,打开一个流,输出 DOM 树,这就是清单 8-12 中发生的事情。DOM 树表示的 XML 字符串是通过调用相关的QDomDocument
对象上的toString(int)
来检索的。
清单 8-12。 将一个 DOM 文档写入一个文件
QFile file( "simple.xml" );
if( !file.open( QIODevice::WriteOnly | QIODevice::Text ) )
{
qDebug( "Failed to open file for writing." );
return −1;
}
QTextStream stream( &file );
stream << document.toString();
file.close();
加载 XML 文件
知道如何创建 DOM 树只是通过 DOM 树使用 XML 所需知识的一半。您还需要知道如何将 XML 文件读入到QDomDocument
中,以及如何找到文档中包含的元素和文本。
这比你想象的要容易得多。清单 8-13 显示了从文件中获取一个QDomDocument
对象的所有代码。只需打开文件进行读取,并尝试在调用合适的文档对象的setContent
成员时使用该文件。如果它返回true
,那么可以从 DOM 树中获得 XML 数据。如果不是,则 XML 文件无效。
清单 8-13。 从文件中获取 DOM 树
QFile file( "simple.xml" );
if( !file.open( QIODevice::ReadOnly | QIODevice::Text ) )
{
qDebug( "Failed to open file for reading." );
return −1;
}
QDomDocument document;
if( !document.setContent( &file ) )
{
qDebug( "Failed to parse the file into a DOM tree." );
file.close();
return −1;
}
file.close();
可以使用documentElement()
方法从文档对象中检索 DOM 树的根元素。给定该元素,很容易找到子节点。清单 8-14 展示了如何使用firstChild()
和nextSibling()
来遍历文档元素的子元素。
子对象作为QDomNode
对象返回,即QDomElement
和QDomText
的基类。您可以通过使用isElement()
和isText()
方法来判断您正在处理什么类型的节点。还有更多类型的节点,但文本和元素节点是最常用的。
您可以使用toElement()
方法将QDomNode
转换成QDomElement
。toText()
方法做同样的事情,但是返回一个QDomText
。然后使用从QDomCharacterData
继承的data()
方法获得实际的文本。
对于 element 对象,可以从tagName()
方法中获得标签的名称。可以使用attribute(const QString &, const QString &)
方法查询属性。它接受属性的名称和一个默认值。在清单 8-14 中,默认值为“未设置”
清单 8-14。 从 DOM 树中查找数据
QDomElement documentElement = document.documentElement();
QDomNode node = documentElement.firstChild();
while( !node.isNull() )
{
if( node.isElement() )
{
QDomElement element = node.toElement();
qDebug() << "ELEMENT" << element.tagName();
qDebug() << "ELEMENT ATTRIBUTE NAME"
<< element.attribute( "name", "not set" );
}
if( node.isText() )
{
QDomText text = node.toText();
qDebug() << text.data();
}
node = node.nextSibling();
}
清单 8-14 简单地列出了根节点的子节点。如果希望能够遍历更多层次的 DOM 树,就必须使用递归函数来查找遇到的所有元素节点的子节点。
修改 XML 文件
在许多应用程序中,能够读写 DOM 树是您需要知道的全部内容。将应用程序的数据保存在自定义结构中,在保存之前将数据转换成 DOM 树,然后在加载时从 DOM 树中提取数据,这通常就足够了。当 DOM 树结构足够接近你的应用程序的内部结构时,能够动态地修改 DOM 树是很好的,这就是清单 8-15 中发生的事情。
要将清单中的代码放到一个上下文中,您需要知道在运行这段代码之前,已经从一个文件中加载了文档。执行完代码后,文档被写回同一个文件。
您使用documentElement
找到根节点,这给了您一个起点。然后使用elementsByTagName(const QString &)
方法向根节点请求所有author
标签的列表(所有tagName
属性设置为author
的元素)。
如果列表为空,则向根节点添加一个 author 元素。使用insertBefore(const QDomNode &, const QDomNode &)
将新创建的元素添加到根节点。因为您给了一个无效的QDomNode
对象作为该方法的第二个参数,所以该元素作为第一个子节点被插入。
如果列表包含作者元素,您可以向其中添加修订元素。revision 元素有一个名为count
的属性,它的值是根据 author 元素中已经存在的 revision 元素的数量计算出来的。
这就够了。因为节点已经被添加到 DOM 树中,所以只需要再次保存它就可以获得更新的 XML 文件。
清单 8-15。 修改现有的 DOM 树
QDomNodeList elements = documentElement.elementsByTagName( "author" );
if( elements.isEmpty() )
{
QDomElement a = document.createElement( "author" );
documentElement.insertBefore( a, QDomNode() );
}
else if( elements.size() == 1 )
{
QDomElement a = elements.at(0).toElement();
QDomElement r = document.createElement( "revision" );
r.setAttribute( "count",
QString::number(
a.elementsByTagName( "revision" ).size() + 1 ) );
a.appendChild( r );
}
用 SAX 读取 XML 文件
XML 的简单 API(SAX)只能用于读取 XML 文件。它通过读取文件并定位开始标签、结束标签、属性和文本来工作;和调用处理程序对象中的函数来处理 XML 文档的不同部分。与使用 DOM 文档相比,这种方法的好处是不必一次将整个文件加载到内存中。
要使用 SAX,需要使用三个类:QXmlInputSource
、QXmlSimpleReader
和一个处理程序。清单 8-16 展示了一个应用程序的main
函数,它使用 SAX 来解析一个文件。QXmlInputSource
用于在QFile
和QXmlSimpleReader
对象之间提供一个预定义的接口。
QXmlSimpleReader
是QXmlReader
类的特殊版本。简单的阅读器足够强大,可以在几乎所有情况下使用。阅读器有一个使用setContentHandler
方法分配的内容处理程序。内容处理器必须继承QXmlContentHandler
,这正是MyHandler
类所做的。设置好一切之后,只需要调用parse(const QXmlInputSource *, bool)
方法,将 XML 输入源对象作为参数传递,然后等待阅读器向处理程序报告所有值得知道的事情。
清单 8-16。 用自定义处理程序类设置 SAX 阅读器
int main( int argc, char **argv )
{
QFile file( "simple.xml" );
if( !file.open( QIODevice::ReadOnly | QIODevice::Text ) )
{
qDebug( "Failed to open file for reading." );
return −1;
}
QXmlInputSource source( &file );
MyHandler handler;
QXmlSimpleReader reader;
reader.setContentHandler( &handler );
reader.parse( source );
file.close();
return 0;
}
处理程序类MyHandler
的声明可以在清单 8-17 中看到。该类从QXmlDefaultHandler
继承而来,后者从QXmlContentHandler
派生而来。继承QXmlDefaultHandler
的好处是默认的处理程序类提供了所有方法的虚拟实现,否则你将不得不实现为存根。
当遇到问题时,读取器会调用 handler 类中的方法。您希望处理文本和标记,并知道解析过程何时开始和结束,因此已经实现了类声明中显示的方法。所有方法都返回一个bool
值,用于在遇到错误时停止解析。所有方法都必须返回true
以便读者继续阅读。
**清单 8-17。**T3T0SAX 处理程序类
class MyHandler : public QXmlDefaultHandler
{
public:
bool startDocument();
bool endDocument();
bool startElement( const QString &namespaceURI,
const QString &localName,
const QString &qName,
const QXmlAttributes &atts );
bool endElement( const QString &namespaceURI,
const QString &localName,
const QString &qName );
bool characters( const QString &ch );
};
除了startElement
之外的所有方法看起来或多或少都像清单 8-18 中的所示的方法。一个简单的文本被打印到调试控制台,然后返回true
。在endElement
(如清单所示)的情况下,也会打印一个参数。
清单 8-18。 一个简单的处理类方法
bool MyHandler::endElement( const QString &namespaceURI, const QString &localName,
const QString &qName )
{
qDebug() << "End of element" << qName;
return true;
}
清单 8-19 中的startElement
方法稍微复杂一些。首先,打印元素的名称;然后打印通过一个QXmlAttributes
对象传递的属性列表。QXmlAttributes
不是一个标准容器,所以您必须使用一个索引变量来遍历它,而不仅仅是使用foreach
宏。在方法结束之前,您返回true
来告诉读者一切都在按预期运行。
清单 8-19。startElement
方法列出了元素的属性。
bool MyHandler::startElement( const QString &namespaceURI, const QString &localName,
const QString &qName, const QXmlAttributes &atts )
{
qDebug() << "Start of element" << qName;
for( int i=0; i<atts.length(); ++i )
qDebug() << " " << atts.qName(i) << "=" << atts.value(i);
return true;
}
打印qName
而不是namespaceURI
或localName
的原因是qName
是您期望的标记名。名称空间和本地名称超出了本书的范围。
通过实现 SAX 处理程序来构建 XML 解析器并不复杂。一旦想要将 XML 数据转换成应用程序的定制数据,就应该考虑使用 SAX。因为不会一次加载整个文档,所以降低了应用程序的内存需求,这可能意味着您的应用程序运行得更快。
文件和主窗口
你在第四章中已经了解到,使用isSafeToClose
和closeEvent
方法的设置是一个很好的起点,可以让用户在关闭修改过文档的窗口时选择保存文件。现在是时候为 SDI 应用程序添加对该功能的支持了(同样的概念也适用于 MDI 应用程序)。
从清单 8-20 的开始,您可以看到对SdiWindow
类声明的更改。添加突出显示的行是为了处理加载和保存功能。
所做的更改是将菜单项“打开”、“保存”和“另存为”添加到“文件”菜单中。对类声明的修改包括四个部分:处理菜单项的动作、动作的槽、加载和保存文档到实际文件的函数loadFile
和saveFile
,以及保存当前文件名的私有变量currentFilename
。所有与保存文档有关的方法都返回一个bool
值,告诉调用者文档是否被保存。
清单 8-20。 对 SdiWindow
类进行了修改,使其能够加载和保存文件
class SdiWindow : public QMainWindow
{
Q_OBJECT
public:
SdiWindow( QWidget *parent = 0 );
protected:
void closeEvent( QCloseEvent *event );
private slots:
void fileNew();
void helpAbout();
void fileOpen();
bool fileSave();
bool fileSaveAs();
private:
void createActions();
void createMenus();
void createToolbars();
bool isSafeToClose();
bool saveFile( const QString &filename );
void loadFile( const QString &filename );
QString currentFilename;
QTextEdit *docWidget;
QAction *newAction;
QAction *openAction;
QAction *saveAction;
QAction *saveAsAction;
QAction *closeAction;
QAction *exitAction;
QAction *cutAction;
QAction *copyAction;
QAction *pasteAction;
QAction *aboutAction;
QAction *aboutQtAction;
};
创建操作,然后将它们添加到适当的菜单中,其方式与现有操作完全相同。与打开动作相关的fileOpen
方法如清单 8-21 中的所示。它使用来自QFileDialog
类的静态getOpenFileName
方法来获取文件名。如果用户没有选择文件就关闭了对话框,结果字符串的isNull
方法返回true
。在这种情况下,您不打开文件就从插槽返回。
如果检索到实际的文件名,您可以尝试使用loadFile
加载文件。但是,如果当前文档未被赋予文件名且未被更改,则该文件将被加载到当前文档中。如果当前文档有文件名或已被修改,则创建一个新的SdiWindow
实例,然后将文件加载到其中。
所有SdiWindows
在保存或加载时都有文件名,因此只有新文件没有有效的文件名。
清单 8-21。 实现插槽连接打开动作
void SdiWindow::fileOpen()
{
QString filename = QFileDialog::getOpenFileName( this );
if( filename.isEmpty() )
return;
if( currentFilename.isEmpty() && !docWidget->document()->isModified() )
loadFile( filename );
else
{
SdiWindow *window = new SdiWindow();
window->loadFile( filename );
window->show();
}
}
loadFile(const QString&)
方法用于将给定文件的内容加载到当前窗口的文档中。该方法的源代码如清单 8-22 所示。该函数试图打开文件。如果文件无法打开,将向用户显示一个消息框。如果文件被打开,会创建一个QTextStream
,并使用readAll
加载整个文件内容。然后用setPlainText
方法给文档分配新的文本。当文档被更新后,currentFilename
变量被更新,修改标志被设置为false
,窗口标题也被更新。
清单 8-22。 源代码实际加载文件内容到文档中
void SdiWindow::loadFile( const QString &filename )
{
QFile file( filename );
if( !file.open( QIODevice::ReadOnly | QIODevice::Text ) )
{
QMessageBox::warning( this, tr("SDI"), tr("Failed to open file.") );
return;
}
QTextStream stream( &file );
docWidget->setPlainText( stream.readAll() );
currentFilename = filename;
docWidget->document()->setModified( false );
setWindowTitle( tr("%1[*] - %2" ).arg(filename).arg(tr("SDI")) );
}
loadFile
相反的方法是saveFile(const QString &)
。(你可以在清单 8-23 中看到它的实现。)尽管任务不同,但这两个函数的实现看起来非常相似。概念是相同的:尝试打开文件,以纯文本形式将文档发送到流并更新currentFilename
,重置修改位,并更新窗口标题。当一个文件被实际保存时,saveFile
函数返回true
;如果文件没有保存,函数返回false
。
清单 8-23。 将文档保存到文件中的源代码
bool SdiWindow::saveFile( const QString &filename )
{
QFile file( filename );
if( !file.open( QIODevice::WriteOnly | QIODevice::Text ) )
{
QMessageBox::warning( this, tr("SDI"), tr("Failed to save file.") );
return false;
}
QTextStream stream( &file );
stream << docWidget->toPlainText();
currentFilename = filename;
docWidget->document()->setModified( false );
setWindowTitle( tr("%1[*] - %2" ).arg(filename).arg(tr("SDI")) );
return true;
}
在清单 8-24 所示的fileSaveAs
方法的实现中使用了saveFile
方法的返回值。“另存为”插槽看起来非常像开放插槽。它使用getSaveFileName
方法要求用户输入新的文件名。如果选择了一个文件名,则调用saveFile
方法尝试保存文档。
请注意,如果取消文件对话框,将返回false
,当试图保存文档时,将返回来自saveFile
方法的返回值。只有当文档实际上已经被写入文件时,saveFile
才会返回true
。
清单 8-24。 另存为动作的源代码
bool SdiWindow::fileSaveAs()
{
QString filename =
QFileDialog::getSaveFileName( this, tr("Save As"), currentFilename );
if( filename.isEmpty() )
return false;
return saveFile( filename );
}
fileSave
方法试图将文档保存到与之前相同的文件——保存在currentFilename
中的名称。如果当前文件名为空,则该文件还没有被赋予文件名。在这种情况下,调用fileSaveAs
方法,向用户显示一个文件对话框来选择文件名。它在清单 8-25 的中显示为源代码。
fileSave
方法从saveFile
或fileSaveAs
返回返回值,这取决于使用哪种方法保存文件。
清单 8-25。 保存动作的源代码
bool SdiWindow::fileSave()
{
if( currentFilename.isEmpty() )
return fileSaveAs();
else
return saveFile( currentFilename );
}
使对话框按预期运行所需的最后一个选项是,当关闭修改过的文档时,让用户从显示的警告对话框中保存文件。清单 8-26 中的显示了isSafeToClose
方法的新实现,其中突出显示了包含实际变更的行。
第一个变化是使用QMessageBox::Save
枚举值在警告对话框中添加了保存选项。另一个变化是处理 Save 按钮的情况。如果按下按钮,则呼叫fileSave
。如果文件没有保存(即返回false
,close 事件中止。这使得用户不可能在没有实际选择的情况下丢失文档(或者经历某种电源故障)。
清单 8-26。 检查是否关闭文档的源代码
bool SdiWindow::isSafeToClose()
{
if( isWindowModified() )
{
switch( QMessageBox::warning( this, tr("SDI"),
tr("The document has unsaved changes.\n"
"Do you want to save it before it is closed?"),
QMessageBox::Save | QMessageBox::Discard | QMessageBox::Cancel ) )
{
case QMessageBox::Cancel:
return false;
case QMessageBox::Save:
return fileSave();
default:
return true;
}
}
return true;
}
添加这些保存和加载功能非常适合前面介绍的 SDI 结构。通过确认文档确实已经保存(通过使用所有相关方法的返回值),您可以建立一个防水保护,使得在没有确认的情况下无法关闭未保存的文档。
总结
在不同平台上使用文件通常意味着麻烦。不兼容性存在于所有级别:文件名、目录路径、换行符、字节序等等。通过使用QDir
和QFileInfo
类,可以避免路径、驱动器和文件名的问题。
找到文件后,您可以使用QFile
打开它。Qt 有流来读写数据。如果使用QTextStream
类,可以轻松处理文本文件;如果你使用QDataStream
类,从二进制文件中序列化和读回你的数据是很容易的。想想潜在的流版本控制问题。即使您对所有的应用程序部署使用相同的 Qt 版本,您将来也会得到更多的版本。一个简单的setVersion
电话可以挽救几天的沮丧。
将数据存储为文本或自定义二进制格式的另一种方法是使用 XML。Qt 使您能够使用 DOM,它允许您将整个 XML 文档读入内存,修改它,然后将其写回文件。如果您想读取一个 XML 文件,而不必一次全部加载,可以使用 Qt 的 SAX 类。
当您使用 XML 时,您需要将行QT += xml
添加到项目文件中,因为 XML 支持是在一个单独的模块中实现的。这个模块并不包含在 Qt 的所有版本中,所以在尝试使用它之前,请确认您可以访问它。
最后,您看到了 SDI 应用程序缺失的部分。添加本章最后一节中介绍的方法,可以轻松构建支持文件加载和保存的应用程序。**
九、提供帮助
有时用户需要帮助。有了 Qt,您可以通过各种方式给他们提供他们正在寻找的指令:向导、工具提示、状态栏消息和指向产品文档的指针等等。
当考虑如何在应用程序中添加与帮助相关的功能时,请记住,这不仅仅是简单地响应 F1 键(显示应用程序帮助窗口的实际机制)。当辅助成为你整个应用程序不可或缺的一部分时,它是最有效的。
通过使用一个好的设计,清楚地反映用户当前正在做什么以及他们在这个过程中的位置,你可以极大地减少对帮助的需求。一些工具和原则包括为复杂的设置提供向导,避免或清楚地指示不同的工作模式,如插入和覆盖,以及在用户将要做一些可能破坏大量信息的事情时提醒用户。
提供大量的帮助并不能让应用程序变得容易使用;过多的帮助会使用户很难找到他们想要的信息。你需要实现的是一个易于使用的整体:相关帮助和清晰设计的结合。这就是为什么使用您的应用程序是一种乐趣。
创建工具提示
向用户添加一些额外指导的最常见的方法之一是提供工具提示,这是包含信息的小标志(见图 9-1 )。当您将鼠标指针悬停在控件上一小段时间时,它们就会出现。
图 9-1。 分组框的对话框和工具提示
可以使用setTooltip(const QString&)
方法为所有小部件分配一个工具提示,该方法接受一个字符串,该字符串可以是纯文本,也可以是使用 HTML 格式化的。为了演示工具提示,我用一些小部件组合了一个QDialog
类。清单 9-1 展示了用于设置窗口小部件和布局的构造器(参见图 9-1 查看结果)。
清单 9-1。 对话框构造器
ToolTipDialog::ToolTipDialog() : QDialog()
{
QGroupBox *groupBox = new QGroupBox( tr("Group") );
QGridLayout *gbLayout = new QGridLayout( groupBox );
QCheckBox *checkBox = new QCheckBox( tr("Check!") );
QLabel *label = new QLabel( tr("label") );
QPushButton *pushButton = new QPushButton( tr("Push me!") );
gbLayout->addWidget( checkBox, 0, 0 );
gbLayout->addWidget( label, 0, 1 );
gbLayout->addWidget( pushButton, 1, 0, 1, 2 );
QGridLayout *dlgLayout = new QGridLayout( this );
dlgLayout->addWidget( groupBox, 0, 0 );
...
}
在清单 9-2 中,设置了复选框和分组框的工具提示。复选框只有一行,而分组框文本使用标准换行符\n
分成三行。当鼠标指针悬停在分组框中包含的小部件周围和之间时,分组框工具提示会显示出来。如果您将鼠标悬停在标签、复选框或按钮上,则会显示它们各自的工具提示。
清单 9-2。 设置简单的工具提示文本
checkBox->setToolTip( tr("This is a simple tool tip for the check box.") ); groupBox->setToolTip( tr("This is a group box tool tip.\n" "Notice that it appears between " "and around the contained widgets.\n" "It is also spanning several lines.") );
提示将一个字符串断开多行不影响结果。从 C++ 编译器的角度来看,字符串"foo"
—换行符— "bar"
与字符串"foobar"
是相同的。有时,能够分解一行是很方便的,因为它可以用来增加可读性,或者只是在打印时将代码放在纸上。
创建 HTML 格式的工具提示
虽然可以用<br />
HTML 标签表示新行,但是 Qt 实际上支持许多 HTML 标签,这些标签可以使格式化工具提示更加容易。清单 9-3 展示了一些可能的格式。产生的工具提示如图 9-2 中的所示。
清单 9-3。 一个 HTML 格式的工具提示
label->setToolTip( tr("<p> It is possible to do lists.</p>" "<ul>" "<li>You can <i>format</i> text.</li>" "<li><b>Bold</b> is possible too.</li>" "<li>And the <font color='#22aaff'>color</font> and " "<font size='+2'>size</font>.</li>" "</ul>" "<p>You can do ordered lists as well.</p>" "<ol>" "<li>First.</li>" "<li>Second.</li>" "<li>Third.</li>" "</ol>") );
图 9-2。 带有列表和格式的工具提示
以下列表解释了可用于设置工具提示格式的最常见标签:
<p>
...</p>
:这个标签用来括起一个段落。段落上下有一定间距,将它们与文本的其他部分分开。<br />
:这个标签代表一个换行符。如果你决定使用 HTML 标签,<br />
可以,但是\n
不行。\n
系统只适用于没有标签的文本。<i>
...</i>
:内附文字显示为斜体。<b>
...</b>
:内附文字显示为粗体。<font color='``nnn
...</font>
:内附文字以指定颜色nnn
显示。颜色可以表示为颜色名称(如红色、绿色、黑色或白色)或前缀为#
的十六进制值。格式为#rrggbb
,其中rr
为红色值,gg
为绿色值,bb
为蓝色值。<font size=
’nnn``'>
...</font>
:内附文字以备选尺寸显示。nnn
部分可以是以+
或−
为前缀的相对大小,也可以是固定大小(整数值)。<ul>
...</ul>
:包含以项目符号为前缀的列表项。<ol>
...</ol>
:包含以数字为前缀的列表项。<li>
...</li>
:包含的文本被视为列表项。
将图像插入工具提示
另一个非常有用的标签是img
标签,用于将文件或资源中的图像插入到文本中。图 9-3 显示了一个工具提示的例子。标签的语法看起来像<img src='
nnn
'>
,其中nnn
是文件名。如果文件名以:
开头,则是指嵌入到可执行文件中的资源。清单 9-4 展示了创建图 9-3 中的示例工具提示的源代码。
图 9-3。 带有文本和图像的工具提示
清单 9-4。 包含图像的工具提示
pushButton-> setToolTip( tr("<img src=':/img/qt.png'>"
"You can also insert images into your tool tips.") );
很容易为所有的小部件提供工具提示,从而给用户提供他们需要的支持。工具提示通常用于回答诸如“这个按钮是做什么的?”以及“隐藏标尺按钮去了哪里?”当你设计一个工具提示时,尽量保持文本最少,因为这些提示经常被用来快速理解各种界面小部件。
对小工具应用多个工具提示
有时候,您会希望为一个小部件分配几个工具提示——通常是在处理模型视图和其他显示复杂文档的小部件时。在这些情况下,单个小部件用于显示几个不同的项目,其中每个项目可能需要自己的工具提示。例如,假设您有一个绘图应用程序,其中您想要使用工具提示来显示圆的直径以及矩形的宽度和高度。因为整个绘图是使用单个查看小部件显示的,所以该小部件需要根据鼠标指针的位置提供不同的工具提示。
这样做有助于理解工具提示是如何显示的。工具提示的实际出现是通过ToolTip
事件触发的。通过拦截event(QEvent*)
方法中的事件,您可以根据鼠标指针的位置来更改工具提示。
图 9-4 显示了想要的效果:四个方块都是一个小部件的一部分,但是每个方块显示不同的工具提示文本。
注意当与一个QGraphicsView
和朋友一起工作时,你可以为每个QGraphicsItem
设置工具提示——避免需要为视图小部件或场景拦截ToolTip
事件。当使用项目视图时,您可以使用模型-视图架构,通过将数据分配给Qt::ToolTipRole
来为每个项目设置工具提示。如果你想为视图提供自定义工具提示,重新实现viewportEvent(QEvent*)
方法而不是event()
。
图 9-4。 同一个小工具对不同的部分显示不同的工具提示。
让我们从截取正确的事件开始,并为四个方块中的每一个设置工具提示文本。所有事件都通过event
方法,然后其中一些被分配给不同的处理程序,比如paintEvent
、mouseMoveEvent
和keyPressEvent
方法。因为没有toolTipEvent
方法,你必须在event
方法中拦截事件。
清单 9-5 中显示了拦截的源代码。因为event
方法接收一个QEvent
对象,所以您必须使用 type 属性来确定是否接收到了一个ToolTip
事件。QEvent
类是所有专用事件类的基类,所以一旦你知道你正在处理一个工具提示,你就可以将QEvent
对象转换成QHelpEvent
对象。
注意你怎么知道ToolTip
事件是作为QHelpEvent
对象发送的?看看enum QEvent::Type
的文档;您将看到所有事件类型的列表,以及沿着这样一个事件传递的对象类型。
在事件对象被转换成一个QHelpEvent
对象后,四个区域的矩形被设置。然后根据哪个矩形包含由QHelpEvent
对象的pos()
方法返回的点来设置工具提示。
设置工具提示文本后,不要将事件标记为已接受。相反,通过调用父处理程序QWidget::event
,调用默认处理程序(因为它知道如何显示实际的工具提示)。这也是所有非ToolTip
事件发生的地方——确保一切按预期运行。
清单 9-5。 拦截所有 ToolTip
事件,并更新工具提示文本,然后将其传递给默认处理程序
bool TipZones::event( QEvent *event )
{
if( event->type() == QEvent::ToolTip )
{
QHelpEvent *helpEvent = static_cast<QHelpEvent*>( event );
QRect redRect, greenRect, blueRect, yellowRect;
redRect = QRect( 0, 0, width()/2, height()/2 );
greenRect = QRect( width()/2, 0, width()/2, height()/2 );
blueRect = QRect( 0, height()/2, width()/2, height()/2 );
yellowRect = QRect( width()/2, height()/2, width()/2, height()/2 );
if( redRect.contains( helpEvent->pos() ) )
setToolTip( tr("Red") );
else if( greenRect.contains( helpEvent->pos() ) )
setToolTip( tr("Green") );
else if( blueRect.contains( helpEvent->pos() ) )
setToolTip( tr("Blue") );
else
setToolTip( tr("Yellow") );
}
return QWidget::event( event );
}
提供这有什么帮助提示
这是什么帮助看起来很像工具提示,只是用户调用了这是什么模式,然后单击了感兴趣的小部件。如果任何小部件有“这是什么”帮助,则通过单击对话框标题栏上出现的问号按钮可以进入“这是什么”模式。在图 9-5 中可以看到问号按钮。
图 9-5。 一个标题栏带有问号按钮的对话框
“这是什么”帮助文本往往比工具提示文本稍长一些,也更详细一些,因为用户通常想知道更多关于小部件的信息。
使用setWhatsThis(const QString&)
方法设置这是什么文本,并且可以为所有小部件设置。尽管作为参数传递的字符串与作为工具提示传递的字符串非常相似,但还是有一些区别。
最重要的区别是换行符。当指定这是什么文本时,重要的是使用<br />
标签,而不是\n
字符来换行。此外,“这是什么”文本总是自动换行的,除非您明确指定不换行的段落。图 9-6 显示了带和不带自动换行的文本是什么。
为了避免换行,你必须将文本放在一个带有属性style='white-space:pre'
的段落标签中。例如,下面一行显示了图中自动换行的文本:
checkBox-> setWhatsThis( tr("This is a simple <i>What's This help</i> "
"for the check box.") );
这段源代码显示了没有换行的相同文本:
checkBox->setWhatsThis( tr("<p style='white-space:pre'>This is a simple "
"<i>What's This help</i> for the check box.</p>") );
有时防止换行是有用的,但是尽可能让 Qt 处理它。通过让 Qt 换行,文本更有可能在屏幕上正确显示。以具有非常大的字体大小设置的低分辨率屏幕为例(见图 9-6 )。您的非换行文本可能不适合屏幕。
**图 9-6。** *同样的有无换行的这是什么文字*
说到格式,这个帮助文本可以处理工具提示文本可以处理的所有标签。图 9-7 显示了演示格式和内嵌图像的“这是什么”帮助框。虽然自动换行略有不同,但结果与工具提示框相同。
**图 9-7。** *这是什么帮助项处理与工具提示文本相同的格式。*
将链接嵌入这是什么帮助提示
尽管这是什么文本通常比工具提示文本更详细,有时甚至扩展的文本余量也不够。在这些情况下,能够在文本中放置一个超链接是很有用的。该链接可以指向您喜欢的任何内容,例如,一个对话框、联机帮助中的一个部分或 Web 上的一个页面。
当点击“这是什么”文本中的链接时,一个`WhatsThisClicked`事件被发送到与“这是什么”帮助提示相关的小部件。这个事件可以在`event`方法中被拦截,就像在为小部件的不同部分提供不同提示时拦截`ToolTip`事件一样。然而,因为可能会有许多包含链接的“这是什么帮助”对话框,所以一个好的解决方案是在一个地方拦截所有的`WhatsThisClicked`事件。这个过程使您能够使用相同的机制以相同的方式处理所有链接。可以使用事件过滤器来执行事件接收。
这个想法是有一个事件过滤器,可以安装在所有提供这是什么帮助的对话框上。然后,每当单击一个链接时,filter 对象就会发出一个信号。该信号可以连接到执行适当动作(例如打开帮助页面)的中心点。
清单 9-6 显示了`LinkFilter`过滤器类的类声明。它提供了一个单击链接时发出的信号、一个构造器和`eventFilter`方法。构造器简单地将`parent`指针传递给`QObject`构造器来让 Qt 满意。
**清单 9-6。** *事件过滤类的声明*
#ifndef LINKFILTER_H
#define LINKFILTER_H
#include <QObject>
class LinkFilter : public QObject
{
Q_OBJECT
public:
LinkFilter( QObject *parent=0 );
signals:
void linkClicked( const QString &);
protected:
bool eventFilter( QObject*, QEvent* );
};
#endif // LINKFILTER_H
实际的过滤发生在清单 9-7 的中。处理所有类型为`WhatsThisClicked`的事件。`QEvent`对象被转换成一个`QWhatsThisClickedEvent`对象,通过`linkClicked`信号从该对象发出`href`属性。确保在发出信号和采取任何行动之前调用隐藏“这是什么”框的`QWhatsThis::hideText`方法。
最后,已处理的事件返回`true`,阻止任何进一步的事件处理。所有其他事件返回`false`——通知 Qt 该事件被忽略。
**清单 9-7。** *过滤事件为* `WhatsThisClicked` *事件*
bool LinkFilter::eventFilter( QObject *object, QEvent *event )
{
if( event->type() == QEvent::WhatsThisClicked )
{
QWhatsThisClickedEvent wtcEvent = static_cast<QWhatsThisClickedEvent>(event);
QWhatsThis::hideText();
emit linkClicked( wtcEvent->href() );
return true;
}
return false;
}
为了测试`LinkFilter`类,创建了一个简单的对话框类`LinkDialog`,该对话框有一个构造器和一个槽:`showLink(const QString&)`。(清单 9-8 显示了对话框的构造器。)
首先创建并安装一个`LinkFilter`作为对话框的事件过滤器。`linkClicked`信号连接到对话框的`showLink`插槽。请注意,`WhatsThisClicked`事件是通过对话框传递的,因此您可以在这里拦截对话框中所有小部件的点击链接。因为过滤器安装在对话框上,所以可以在显示对话框之前从主窗口安装过滤器。
安装过滤器后,会创建一个`QPushButton`小部件,并设置这是什么文本。要创建一个链接,`<a href='*nnn*'>`...`</a>`使用标记。`nnn`部分是作为`QWhatsThisClickedEvent`的`href`属性传递的字符串,然后通过`linkClicked`信号传递。`<a href=...>`和`</a>`部分之间的文本将显示为链接。
在构造器结束之前,按钮被放置在布局中。
**清单 9-8。** *用* `LinkFilter` *事件过滤器*设置一个对话框
LinkDialog::LinkDialog() : QDialog()
{
LinkFilter *filter = new LinkFilter( this );
this->installEventFilter( filter );
connect( filter, SIGNAL(linkClicked(const QString&)),
this, SLOT(showLink(const QString&)) );
QPushButton *button = new QPushButton( "What is this?" );
button->setWhatsThis( "This is a test link." );
QGridLayout *layout = new QGridLayout( this );
layout->addWidget( button, 0, 0 );
}
图 9-8 显示了*这是什么*文本和正在显示的链接。当用户点击链接时,触发`QWhatsThisClickedEvent`,发出`linkClicked`信号,触发`showLink`槽。该插槽的源代码如清单 9-9 所示。
**图 9-8。** *这个带链接的文字是什么*
**清单 9-9。** *使用消息框显示点击的链接*
void LinkDialog::showLink( const QString &link )
{
QMessageBox::information( this, tr("Link Clicked"), tr("Link: %1").arg( link ) );
}
插槽所做的只是显示一个带有链接字符串的消息框(见图 9-9 )。在这里,您可以添加代码来解释给定的字符串,然后采取适当的行动,而不只是显示一个消息框。
**图 9-9。** *显示链接文本的对话框*
利用状态栏
状态栏通常位于应用程序窗口的底部,通常用于显示临时消息以及关于工作模式、在当前文档中的位置、当前文件的大小等信息。显示的信息非常依赖于应用程序类型,但它是对用户有用的信息。
状态栏由一个`QStatusBar`小部件表示。当你在主窗口中使用状态栏时,你可以通过`statusBar()`方法获得对状态栏对象的引用。第一次调用该方法时会创建一个状态栏,而连续调用只会返回一个指向该状态栏的指针。
状态栏最常见的用途是显示诸如`"Loading"`、`"Saving"`、`"Ready"`、`"Done"`等消息。这些信息使用`showMessage(const QString&, int)`方法显示。例如,下面一行显示两秒钟的消息文本`"Ready"`(见图 9-10 ):
statusBar->showMessage( tr("Ready"), 2000 );
图 9-10。 一个状态栏显示一条临时消息
给showMessage
的时间是以毫秒为单位指定的(以秒为单位的时间乘以 1000 得到以毫秒为单位的时间)。如果您调用showMessage
而没有指定时间或指定零毫秒的时间,该消息将一直显示,直到您调用showMessage
替换该消息,或者直到您调用clearMessage()
删除该消息。
当不用于状态消息时,状态栏可以包含一组小部件。这些小部件的通常用途是为用户提供有用的信息,以便随时可以使用。
小部件可以正常或永久地添加到状态栏中。不同的是,普通的窗口小部件被消息覆盖,而永久的窗口小部件总是显示。小部件是从左到右添加的,但是永久小部件总是出现在普通小部件的右侧。
图 9-11 所示的状态栏显示了一个带有进度条和三个标签的状态栏。标签“N”表示当前文档没有被修改。这显示了状态栏的局限性之一:可用空间是有限的,所以信息必须以非常紧凑的格式呈现。可以为标签设置一个工具提示来解释显示的内容,但这不是一个非常直观的解决方案。
图 9-11。 一个带有进度条和三个标签的状态栏
状态栏和窗口小部件的创建如清单 9-10 所示。代码取自基于QMainWindow
的类的构造器。突出显示的行是影响状态栏的行。首先获取一个指向状态栏的指针,然后使用addPermanentWidget(QWidget*, int)
添加永久小部件,最后使用addWidget(QWidget*, int)
添加三个普通小部件。
清单 9-10。 状态栏及其小部件设置在主窗口的构造器中。
MainWindow::MainWindow() : QMainWindow()
{
...
QStatusBar *statusBar = this->statusBar();
QProgressBar *progressBar = new QProgressBar;
QLabel *mode = new QLabel( tr(" EDIT ") );
QLabel *modified = new QLabel( tr(" Y ") );
QLabel *size = new QLabel( tr(" 999999kB ") );
mode->setMinimumSize( mode->sizeHint() );
mode->setAlignment( Qt::AlignCenter );
mode->setText( tr("EDIT") );
mode->setToolTip( tr("The current working mode.") );
statusBar->addPermanentWidget( mode );
modified->setMinimumSize( modified->sizeHint() );
modified->setAlignment( Qt::AlignCenter );
modified->setText( tr("N") );
modified->setToolTip( tr("Indicates if the current document "
"has been modified or not.") );
size->setMinimumSize( size->sizeHint() );
size->setAlignment( QtAlignRight | QtAlignVCenter );
size->setText( tr("%1kB ").arg(0) );
size->setToolTip( tr("The memory used for the current document.") );
progressBar->setTextVisible( false );
progressBar->setRange( 0, 0 );
statusBar->addWidget( progressBar, 1 );
statusBar->addWidget( modified );
statusBar->addWidget( size );
...
}
请注意,小部件是以大尺寸创建的,并且设置了针对sizeHint
的minimumSize
策略。这意味着小部件不会缩小到比这个更小的尺寸。通过在添加进度条时将第二个参数设置为1
,可以让它占用剩余的可用空间。第二个参数是拉伸因子,默认为零。通过使用它,您可以确保当主窗口调整大小时,小部件保持它们的相对大小。
然后,标签在添加到状态栏之前会获得正确的文本和工具提示。请注意,永久小部件出现在右侧,即使它是在普通小部件之前添加的。这是为了让消息可以覆盖正常的小部件,同时保持永久的小部件可见。在图 9-12 中可以看到一个例子。
图 9-12。 显示消息的状态栏和永久小工具
状态栏更常见的用途之一是显示不同的工作模式。(别忘了状态栏相当小。)也尝试用其他方式显示不同的工作模式:更改鼠标指针,更改正在处理的对象的手柄外观,或者简单地更改背景颜色。仅仅在状态栏上显示一个小小的三个字母的代码是迷惑任何用户的好方法。
创建向导
当用户面对大量选项时,向导可以通过按逻辑顺序显示选项来提供帮助,并以解释文本的形式为每个选项提供额外的支持。
根据 Qt,向导是包含所有页面的QWidgetStack
;QPushButton
下一个、上一个和取消按钮的小部件;和一个保存所有组件的QDialog
。每个页面本身就是一个QWidget
,可以包含其他用于设置的小部件。
一个QWidgetStack
是一个可以容纳其他部件的特殊部件。这些小部件保存在一个堆栈中(就像在一堆卡片中一样),其中只有当前的小部件是可见的。这使得通过简单地改变堆栈的当前小部件就可以在页面中向前和向后移动。
设计向导的最好工具是 Qt Designer,但是为了展示这个概念,我将向您展示一个手工编码的版本。其首页如图图 9-13 所示。
图 9-13。 示例向导的第一页
向导只不过是应用程序其余部分的一个对话框。清单 9-11 显示了Wizard
对话框类的声明。公共接口只包含一个构造器。界面的私有部分由“下一个”和“上一个”按钮的位置组成,后面是一些指向组成对话框的不同部件的指针。
清单 9-11。 巫师类的宣言
class Wizard : public QDialog
{
Q_OBJECT
public:
Wizard();
private slots:
void doNext();
void doPrev();
private:
QPushButton *next;
QPushButton *previous;
QStackedWidget *pages;
PageOne *pageOne;
PageTwo *pageTwo;
PageThree *pageThree;
};
在向导中,我选择将所有逻辑放在Wizard
类中,因此所有页面都简单地处理视觉细节。稍后可以访问的控件,比如复选框和带有用户配置的行编辑,在页面类中成为公共成员。图 9-13 的第一页如清单 9-12 所示。
清单以类声明开始。对于第一页,只有构造器和接受规则的复选框可用,因为Wizard
类需要能够判断下一步按钮是被启用还是被禁用。
清单的另一半由构造器的实现组成,在其中创建、设置小部件,并放入布局中。QTextEdit
小部件被用作阅读器,所以在使用setHtml
设置文本之前,readOnly
属性被设置为true
。
清单 9-12。 向导的第一页
class PageOne : public QWidget
{
public:
PageOne( QWidget *parent = 0 );
QCheckBox *acceptDeal;
};
PageOne::PageOne( QWidget *parent ) : QWidget(parent)
{
QGridLayout *layout = new QGridLayout( this );
QTextEdit *textEdit = new QTextEdit;
textEdit->setReadOnly( true );
textEdit->setHtml( tr("<h1>The Rules</h1>"
"<p>The rules are to be followed!</p>") );
acceptDeal = new QCheckBox( tr("I accept") );
layout->addWidget( textEdit, 0, 0, 1, 2 );
layout->addWidget( acceptDeal, 1, 1 );
}
在您可以在向导对话框中显示第一页之前,还缺少一部分:构造器。构造器负责创建下一个、上一个和取消按钮;创建页面;并在应用布局和进行所需连接之前将它们放入堆栈中。
构建器的源代码如清单 9-13 中的所示。按照自上而下的代码,从创建布局和小部件开始。然后,在配置按钮之前,将小部件放置在布局中。“下一个”和“上一个”从一开始就被禁用,因为没有什么可返回的,并且用户必须批准规则才能继续。这些按钮连接到doNext()
和doPrev()
插槽,而取消按钮连接到关闭对话框的reject()
插槽。
当按钮连接后,页面被创建并添加到小部件堆栈中。最后一步是将第一页复选框的toggled(bool)
信号连接到下一个按钮的setEnabled(bool)
槽。
清单 9-13。 向导的构造者
Wizard::Wizard() : QDialog()
{
QGridLayout *layout = new QGridLayout( this );
QPushButton *cancel = new QPushButton( tr("Cancel") );
next = new QPushButton( tr("Next") );
previous = new QPushButton( tr("Previous" ) );
pages = new QStackedWidget;
layout->addWidget( pages, 0, 0, 1, 5 );
layout->setColumnMinimumWidth( 0, 50 );
layout->addWidget( previous, 1, 1 );
layout->addWidget( next, 1, 2 );
layout->setColumnMinimumWidth( 3, 5 );
layout->addWidget( cancel, 1, 4 );
previous->setEnabled( false );
next->setEnabled( false );
connect( next, SIGNAL(clicked()), this, SLOT(doNext()) );
connect( previous, SIGNAL(clicked()), this, SLOT(doPrev()) );
connect( cancel, SIGNAL(clicked()), this, SLOT(reject()) );
pages->addWidget( pageOne = new PageOne( pages ) );
pages->addWidget( pageTwo = new PageTwo( pages ) );
pages->addWidget( pageThree = new PageThree( pages ) );
connect( pageOne->acceptDeal, SIGNAL(toggled(bool)),
next, SLOT(setEnabled(bool)) );
}
当用户勾选该框并点击下一步按钮时,显示如图图 9-14 所示的对话框。当点击下一个按钮时,有许多事情需要处理:下一个按钮的enabled
属性不再依赖于复选框的状态,上一个按钮需要被启用,你不能忘记显示下一页。所有这些都在doNext
槽中管理。
图 9-14。 示例向导第二页
doNext
插槽的源代码如清单 9-14 所示。该方法的基础是一个switch
操作,该操作根据用户单击 Next 按钮时所在的页面来决定要做什么。因为该向导包含三个页面,所以有三种情况需要处理。当离开第一页时,处理下一个按钮的 enabled 属性的连接被断开,上一个按钮被启用。当离开第二页进入最后一页时,下一步按钮的文字变为完成,如图图 9-15 所示。
清单 9-14。 处理下一步按钮
void Wizard::doNext()
{
switch( pages->currentIndex() )
{
case 0:
previous->setEnabled( true );
disconnect( pageOne->acceptDeal, SIGNAL(toggled(bool)),
next, SLOT(setEnabled(bool)) );
break;
case 1:
next->setText( tr("Finish") );
break;
case 2:
QMessageBox::information( this, tr("Finishing"),
tr("Here is where the action takes place.") );
accept();
return;
}
pages->setCurrentIndex( pages->currentIndex()+1 );
}
图 9-15。 示例向导的最后一页
当离开最后一页时,在从插槽返回之前,使用accept
方法关闭对话框之前,会显示一个消息框。这是您通过实际操作完成向导的地方。实际的工作可以在对话框中完成,也可以在打开对话框的代码中完成。因为您在这里使用accept
并在所有其他情况下使用reject
来关闭对话框,所以您可以检查对话框结果并在对话框被接受时采取行动。
doNext
槽的最后一个任务是更新小部件堆栈的currentIndex
属性,显示下一页。因为这是为所有页面做的,所以它的代码被放在了switch
块之外。
完成向导所需的最后一部分是返回的能力,这是从清单 9-15 中所示的doPrev
槽处理的。其原理与在doNext
插槽中使用的相同:一个开关操作,根据点击按钮时显示的页面来决定做什么。
清单 9-15。 处理上一个按钮
void Wizard::doPrev()
{
switch( pages->currentIndex() )
{
case 1:
previous->setEnabled( false );
next->setEnabled( pageOne->acceptDeal->isChecked() );
connect( pageOne->acceptDeal, SIGNAL(toggled(bool)),
next, SLOT(setEnabled(bool)) );
break;
case 2:
next->setText( tr("Next") );
break;
}
pages->setCurrentIndex( pages->currentIndex()-1 );
}
正在执行的动作可以追溯到doNext
槽。当从第 1 页移动到第 0 页时,将切换的信号重新连接到下一个按钮的 enabled 属性,并禁用上一个按钮。当从第 2 页移动到第 1 页时,将“下一页”按钮的文本重置为“下一页”。
如您所见,创建向导是一项相当简单的任务。因为所有的向导都是依赖于应用程序的,所以每个向导都有大量特定于应用程序的代码。通过使用 Qt Designer 设计向导,您可以减少实现一个doNext
和一个doPrev
插槽的工作量。几乎所有其他代码都只是为了处理对话框和不同页面的外观。
协助用户
当然,您可能希望依赖为用户提供帮助的事实上的标准:F1 键。参考文档可以通过 Qt 附带的 Qt 助手获得。当您需要提供帮助时,也可以使用 Assistant 作为应用程序的帮助系统。这样做需要两个阶段:配置 Assistant,然后在应用程序中集成 Assistant。
创建帮助文档
Qt Assistant 可以呈现 HTML 文档,因此您必须使用 HTML 格式来格式化您的帮助文件,以便利用这个特性。HTML 文件和图像放在可执行文件旁边的目录中,旁边还有 Assistant 需要的另外两个文件。第一个也是最重要的文件是名为qtbookexample.adp
的辅助文档概要文件。此文件配置助手,以便使用正确的文档集并正确设置窗口标题。你可以在清单 9-16 中看到文件的内容。
Assistant 需要的第二个文件是用于在 Assistant 中自定义 about 框的about.txt
文件。你可以看到它是从adp
文件的profile
部分引用的。profile
部分配置 Assistant 的外观,用窗口标题、图标、起始页、about 菜单的文本、包含 about 框文本的文件以及其余文档的相对路径来配置。
清单 9-16。 助理文档配置文件
<!DOCTYPE DCF>
<assistantconfig version="3.2.0">
<profile>
<property name="name">qtbookexample</property>
<property name="title">Qt Book Example</property>
<property name="applicationicon">img/qt.png</property>
<property name="startpage">index.html</property>
<property name="aboutmenutext">About The Qt Book Example</property>
<property name="abouturl">about.txt</property>
<property name="assistantdocs">.</property>
</profile>
<DCF ref="index.html" icon="img/qt.png" title="Qt Book Example">
<section ref="./basics.html" title="Basics">
<section ref="./index.html" title="The first basic thing" />
<section ref="./index.html" title="The second basic thing" />
<section ref="./easystuff.html" title="Another basic topic" />
<keyword ref="./index.html">Basic Thing One</keyword>
<keyword ref="./index.html">Basic Thing Two</keyword>
<keyword ref="./easystuff.html">Another Basic Thing</keyword>
</section>
<section ref="./advanced.html" title="Advanced Topics">
<section ref="./adv1.html" title="The first advanced thing" />
<section ref="./adv2.html" title="The second advanced thing" />
<keyword ref="./adv1.html">Advanced Topic One</keyword>
<keyword ref="./adv2.html">Advanced Topic Two</keyword>
</section>
<section ref="./appendix.html" title="Appendix" />
<section ref="./faq.html" title="F.A.Q." />
</DCF>
</assistantconfig>
adp
文件的后半部分包含不同的部分和要使用的关键字。图 9-16 显示了信息如何显示在助手的目录和索引标签中。
其他选项卡会自行处理。书签由用户添加,搜索选项卡提供对从adp
文件引用的所有文件的搜索。
要使用助手测试您的adp
文件,您可以使用参数-profile
启动助手,然后参考您的个人资料。例如,assistant -profile qtbookexample.adp
用qtbookexample.adp
文档启动助手,如图图 9-16 所示。
图 9-16。 文档配置文件在助手中显示为目录树和关键字列表。
把它放在一起
要使用 Assistant 作为您的帮助文档浏览器,您需要创建一个QAssistantClient
对象。确保为整个应用程序只创建一个对象,如果同时启动几个助手实例,用户可能会感到困惑。
清单 9-17 展示了如何创建一个助理客户端对象。给构造器的第一个参数是助手可执行文件的路径。如果您假设用户安装了一个有效的 Qt 开发环境,那么您可以使用QLibraryInfo
对象来查找可执行文件。在最常见的情况下,用户没有安装 Qt,因此您必须将助手可执行文件与您的应用程序一起提供,并将其放置在相对于您的应用程序可执行文件的位置。您可以通过使用QApplication::applicationDirPath()
方法找到文件的位置。
清单 9-17。 创建和配置助手
QAssistantClient *assistantClient =
new QAssistantClient( QApplication::applicationDirPath(), qApp );
QStringList arguments;
arguments << "-profile" << "./documentation/qtbookexample.adp";
assistantClient->setArguments( arguments );
当您想要显示助手时,只需调用助手客户端对象的openAssistant()
或showPage(const QString&)
方法之一。当你的应用程序关闭时,确保在你的客户端对象上调用closeAssistant()
来关闭任何打开的助手实例。
为了能够使用QAssistantClient
类构建项目,您必须将行CONFIG += assistant
添加到您的项目文件中。
总结
提供帮助不仅仅是响应 F1 键;它是关于提供一个直观的用户界面,并在用户需要时增加支持。必须通过用户知道的渠道提供支持,这样帮助才是直观的。通过提供工具提示和对大多数小部件的帮助,可以避免很多问题。
当工具提示不再有帮助时,可以使用向导,或者您可以尝试重新设计用户界面来避免问题。后者必须永远是一个选项,但有时向导是最好的选择。
为了使信息可用,您可以使用状态栏为用户提供相同的信息,而不管用户在做什么。但是不要指望用户一直看到状态栏——如果工作模式被意外改变,用户通常不会去找状态栏;相反,当变化发生时,他们去他们在的任何地方。
帮助系统的最后一部分是在线文档。Qt Assistant 可以通过为您的文档提供一个良好的界面来帮助您。只需将您的文档编译成一组 HTML 文档,创建一个文档配置文件,并将助手用作您的帮助客户端。
十、国际化和本地化
当你为国际市场部署你的应用程序时,你必须提供本地化版本。这样做的原因远远超出了世界人口所使用的不同语言;事实上,在时间、日期和货币价值的表示上存在差异;甚至更复杂的书面语言问题,如文本应该从右边还是从左边读。
提示国际化和本地化实际上是同一个流程的两个部分。国际化就是让您的应用程序摆脱与特定位置的任何联系,使其独立于任何特定的语言或文化。本地化是下一步——采用国际化的应用程序,并使其适应具有特定语言和文化的特定位置。
在开始处理为了成功适应应用程序的不同语言和文化而必须管理的所有细节之前,先看看 Qt 提供的管理工具。
提示你知道国际化经常被写成 i18n,其中 18 是去掉的字符数吗?本地化往往可以看做 l10n(用同样的方式缩写)。
翻译应用程序
要开始,你需要一个应用程序来翻译。您将使用第四章的中的 SDI 应用程序,以及在第八章中扩展的附加特性(当添加了文件处理支持时)。在图 10-1 中可以看到应用程序的截图。因为我的母语是瑞典语,所以任务是将应用程序翻译成瑞典语。
**图 10-1。**SDI 申请
翻译以两种不同的文件格式保存:ts
和qm
。ts
文件是在开发过程中使用的,它以一种易于维护的 XML 文件格式包含了应用程序中的所有单词。qm
文件在运行时使用,包含可移植的压缩格式的短语。这个想法是在开发过程中使用ts
文件作为源文件。然后将ts
文件编译成实际应用程序使用的可分发的qm
格式。编译被称为发布翻译。
在开始翻译应用程序之前,您需要通知 Qt 您的意图。因为目标语言是在瑞典使用的瑞典语,并且该地区常用的代码是sv_SE
,所以您可以将它添加到应用程序名称的末尾:SDI_sv_SE
。
注意名称的sv_SE
部分是由 ISO 639-1 的语言代码和 ISO 3166-1 的国家代码组合而成的。应用程序名称只是应用程序的非正式名称。这种命名约定只是约定俗成的——您可以随意命名您的翻译。
要将此翻译添加到项目中,只需将下面一行添加到项目文件中:
TRANSLATIONS += sdi_sv_SE.ts
通过适当地添加新的`TRANSLATION +=`行,您可以向项目添加任意数量的翻译。您也可以通过用空格或制表符分隔来一次指定多个翻译。
提取字符串
当项目文件被一个或多个翻译更新后,是时候从应用程序中的各种`tr()`调用中提取需要翻译的字符串了。也有其他情况,但将在以后讨论。
`lupdate`工具用于提取短语——它创建或更新给定项目文件中列出的所有`ts`文件。很高兴知道,当它更新一个现有的文件时,它不会删除任何东西——所有已经完成的翻译都保持不变。因为项目文件名为`sdi.pro`,所以在命令行输入的命令是`lupdate sdi.pro`。这将从项目文件的源代码中找到的字符串创建`sdi_sv_SE.ts`文件。
虽然 Qt 附带了一个软件翻译工具,但并不是所有的翻译业务都希望使用定制工具。幸运的是,`ts`文件非常容易处理,因为它们是 XML 格式的。清单 10-1 显示了未翻译的`sdi_sv_SE.ts`文件的摘录。
**清单 10-1。** *一个未翻译的内容的例子* `ts` *文件*
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE TS><TS version="1.1">
<context>
<name>SdiWindow</name>
<message>
<location filename="sdiwindow.cpp" line="254"/>
<source>%1[*] - %2</source>
<translation type="unfinished"></translation>
</message>
<message>
<location filename="sdiwindow.cpp" line="19"/>
...
</context>
</TS>
正如您从摘录中看到的,将它转换成您的翻译丨公司喜欢的格式并返回应该不难。
语言学家:翻译的工具
Qt 与*语言学家*工具捆绑在一起,该工具为翻译人员提供了要翻译的字符串及其各自状态的方便概述:完成、未知或缺失。它还提供了一些简单的检查来确保翻译是正确的。例如,它检查原始字符串和翻译字符串中的最终标点符号是否相同。
启动语言学家产生如图 10-2 所示的用户界面。该图显示了打开翻译并翻译了几个字符串后的应用程序。
如果你仔细观察图 10-2 ,你可以看到语言界面由三个面板组成。在上下文面板中(左边)是包含字符串的类和它们各自的字符串。当前选定的字符串以其原始和翻译的形式显示在主面板中(右上角)。在短语面板中,Qt 通过查看早期的翻译和你可以加载的短语手册来推荐翻译。(这里不涉及短语书。)
**图 10-2。** *语言学家用一个新鲜的翻译文件加载了*
在语言学家中最简单的方法是从上下文面板中选择一个字符串,翻译它,然后按 Ctrl + Enter。如果四个验证器都没问题的话,这将把您带到下一个未翻译的字符串。可以从验证菜单中打开和关闭验证器。它们的功能如下:
- 加速器:如果原始字符串中有加速器,这个函数确保翻译中有加速器。
- 结尾标点:这个函数确保原文和译文的结尾标点匹配。
- 短语匹配:这个函数检查原始字符串是否匹配一个已知的短语。在这种情况下,翻译应该与已知短语的翻译相同。
- 位置标记匹配:该函数确保原始字符串中的位置标记(例如,
%1
,%2
)也存在于翻译中。
如果验证器不接受,可以保留一个翻译,但是 Ctrl + Enter 快捷键不会自动移动(确保你主动决定忽略验证器)。当一个验证程序反对一个翻译时,它会在状态栏中显示一条消息(见图 10-3 )。
图 10-3。 验证器反对该翻译,因为该翻译没有引用与源文本中相同的位置标记。
随着翻译的进行,您可以在状态栏的右侧看到您的状态。翻译完所有字符串后,破折号两边的数字将匹配。您可以随时保存您的翻译,稍后继续工作。语言学家和lupdate
不会丢失任何信息,除非你自己覆盖或删除它。
当您的翻译准备好并保存后,您必须编译或发布它,以便能够通过使用lrelease
工具在您的应用程序中使用它。只需将您的项目名称作为参数传递。对于sdi.pro
应用程序,您可以从命令行运行lrelease sdi.pro
,从您的ts
文件构建所需的qm
文件。
设置一个翻译对象
当翻译准备好并发布后,就该将它们加载到应用程序中了。因为语言是在应用程序级别设置的,所以目标是在QApplication
对象上安装一个包含正确翻译的QTranslator
对象。
在担心QTranslator
对象之前,您需要确定用户期望用哪种语言编写应用程序。这些信息可以在QLocale
类中找到。一个QLocale
对象代表一个特定的本地化区域和语言。对象知道该区域和语言的大多数本地化细节。要获得表示计算机的区域和语言的对象,可以使用名为QLocale::system
的静态方法。
这个名字在清单 10-2 中使用,通过调用installTranslator(QTranslator*)
在安装之前将翻译加载到QTranslator
对象中。正如您在清单中看到的,没有指定翻译文件的文件扩展名。如果load
调用失败,翻译器将没有任何作用,应用程序将以源代码中使用的语言显示。
清单 10-2。 翻译被加载到安装在应用程序上的翻译器中。
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QTranslator translator;
translator.load( QString("sdi_")+QLocale::system().name() );
app.installTranslator( &translator );
QTranslator qtTranslator;
qtTranslator.load( QString("qt_")+QLocale::system().name() );
app.installTranslator( &qtTranslator );
SdiWindow *window = new SdiWindow;
window->show();
return app.exec();
}
命名翻译文件没有规则。它可能被称为swedish.qm
或12345.qm
——这不重要。将地区名称与翻译器联系起来的好处是,您可以使用QLocale::system
来找到正确的语言。
提示您可以将您的qm
文件添加到一个资源文件中,以便将翻译集成到您的应用程序中。它增加了可执行文件的重量,但是减少了对其他文件的依赖。这可以使应用程序更容易部署。
Qt 字符串
如果您现在部署应用程序,那么只有部分内容会被翻译。在 Qt 打开和保存文档的标准对话框和 About Qt 对话框中,使用了嵌入在 Qt 库中的字符串。这些字符串被lupdate
遗漏了,因为它只出现在当前项目的源代码中。相反,您必须安装另一个翻译器来处理嵌入在 Qt 标准对话框中的字符串。
在开始编写添加这样一个翻译器的代码之前,先看看 Qt 提供的翻译。Qt 库包含大约 2200 个单词(你可以看到语言学家在图 10-4 中加载了 Qt 翻译)。Qt 附带了这些单词的翻译,用于将默认语言(英语)翻译成法语和德语。还包括其他语言,但是它们没有得到 Trolltech 的官方支持。所有的翻译都可以从 Qt 安装目录下的translations
子目录中获得。注意,如果您需要支持一种新的语言,您可以使用qt_untranslated.ts
文件作为起点。你也应该在网上搜索,因为许多开发者会发布他们的翻译供他人使用。
图 10-4。 一个 Qt 翻译载入语言学家
因为 Qt 字符串不是你的应用程序的一部分,你必须手动释放它。你可以通过使用语言学家打开文件并从文件菜单中释放它(如图图 10-5 所示),或者你可以将ts
文件作为参数给lrelease
而不是你的项目文件。
提示另一种方法是将你的ts
文件基于适当的 Qt 翻译。因为lupdate
从不删除任何东西,这与合并翻译是一样的,这使得发布过程更容易。
图 10-5。 您可以使用文件菜单中的发布选项发布当前翻译。
当您已经将 Qt 字符串的翻译创建或复制到项目目录中,发布它,并给结果文件一个合适的名称时,是时候将它加载到一个翻译器中并安装它了。在瑞典语的情况下,文件被称为qt_sv_SE
,加载如清单 10-3 所示。如您所见,该过程与应用程序字符串的翻译加载是相同的。
清单 10-3。 为 Qt 的字符串加载和安装翻译器
int main( int argc, char **argv )
{
QApplication app( argc, argv );
QTranslator translator;
translator.load( QString("sdi_")+QLocale::system().name() );
app.installTranslator( &translator );
QTranslator qtTranslator;
qtTranslator.load( QString("qt_")+QLocale::system().name() );
app.installTranslator( &qtTranslator );
SdiWindow *window = new SdiWindow;
window->show();
return app.exec();
}
当两个翻译器都被加载和安装后,用户界面被翻译。在图 10-6 中可以看到翻译瑞典语旁边的英文原文。
图 10-6。 英语和瑞典语的 SDI 应用
处理其他翻译案件
当您在tr
调用中包含字符串时,会发生两件事:lupdate
找到字符串并将其交给翻译器;然后字符串通过QApplication::translate
方法传递。
所以有两种特殊情况需要注意:确保lupdate
可以找到所有的字符串,并确保所有的字符串都以允许方法正确翻译的方式通过translate
。
寻找所有字符串
有时你写的代码中你的字符串不会出现在tr
调用中。在这种情况下,您可以使用宏QT_TR_NOOP
或QT_TRANSLATE_NOOP
。请看清单 10-4 中的作为例子。
这两个宏的区别在于QT_TR_NOOP
没有上下文参数。这对于texts2
中的字符串来说很好,它们不太可能与应用程序中的其他字符串混淆。然而,texts
中的琴弦很容易混淆。例如,Title
是指网页的标题还是指某个人的标题?在瑞典语中,网页标题的翻译是Överskrift
,人名标题的翻译是Befattning
——差别很大。
当字符串可能不明确时,QT_TRANSLATE_NOOP
宏就派上了用场。它使得为译者和翻译机制添加上下文成为可能。图 10-7 显示了来自清单 10-4 的字符串出现在语言学家中的样子。
清单 10-4。使用 QT_TR_NOOP
和 QT_TRANSLATE_NOOP
宏可以使 lupdate
调用之外的 字符串可见。
char *texts[] = { QT_TRANSLATE_NOOP("main","URL"),
QT_TRANSLATE_NOOP("main","Title"),
QT_TRANSLATE_NOOP("main","Publisher") };
char *texts2[] = { QT_TR_NOOP( "This is a very special string."),
QT_TR_NOOP( "And this is just as special.") };
从继承以Q_OBJECT
开始的QObject
的类中捕获的字符串被自动放置在以该类命名的上下文中。
使用来自外部的字符串很容易。只需使用应用程序对象中可用的translate
方法。如果你的字符串没有上下文,可以传递一个空字符串(0
);否则,将上下文作为第一个参数,字符串作为第二个参数。下面一行使用了来自texts
和texts2
向量的字符串:
QMessageBox::information( 0, qApp->translate("main",texts[2]), qApp-
>translate(0,texts2[1]) );
**区分字符串**
如前所述,有些字符串可能是不明确的。例如, *address* 这个词可以指邮政地址、web URL 或者计算机主内存中的内存地址。不同句子的翻译可以根据意思和上下文而有所不同。如果在一个上下文中使用了这些含义中的几个,您可以为每个字符串添加一个注释,以便翻译人员能够区分它们。
**图 10-7。** *使用* `QT_TRANSLATE_NOOP` *宏找到的字符串在上下文中找到。*
清单 10-5 展示了如何在`tr`调用中指定注释的例子。注释只是作为第二个参数发送给`tr`方法。
**清单 10-5。** *添加注释以区分不同意思的同一个词*
new QLabel( tr("Address:", "Postal address"), this );
new QLabel( tr("Address:", "Website address"), this );
当翻译器打开`ts`文件时,注释显示在要翻译的实际字符串下面。清单 10-5 中的字符串显示在图 10-8 中。
**图 10-8。** *注释显示在原字符串下方给译者看。*
**您更改了** ** * n * ** **文件**
当`translate`方法试图翻译一个字符串时,它需要得到一个精确的匹配,所以清单 10-6 中只有一个字符串有效。在`tr`调用(`line1`)中使用`+`操作符合并字符串的问题是`lupdate`不能正确地找到字符串。在`tr`调用(`line2`)之后合并字符串的问题是,词序或多或少是固定的。通过使用在`line3`赋值中显示的`arg`调用,翻译器可以自由地改变单词的顺序,并且不管`n`的值是多少,字符串都会被匹配。
**清单 10-6。** *三种建串方式:一对两错*
QString line1 = tr("You have altered " + QString::number(n) + " file(s).");
QString line2 = tr("You have altered ") + QString::number(n) + tr(" file(s).");
QString line3 = tr("You have altered %1 file(s).").arg(n);
关于`line3`赋值有一个恼人的问题:即`(s)`部分。可以让翻译器为`n`的不同值提供字符串;清单 10-7 中`line4`的代码向展示了它是如何完成的。`tr`调用有三个参数:实际的字符串、一个注释和一个用于确定字符串是单数还是复数形式的值。
**清单 10-7。** *处理复数字符串*
QString line4 = tr("You have altered %1 file.", "", n).arg(n);
当找到带有值的`tr`调用时,翻译器就有能力提供字符串的单数和复数版本。有些语言有其他特殊形式,比如*paucal*——Qt 也处理它们。`line4`的管柱如图图 10-9 所示。
**图 10-9。** *语言学家*中一个字符串的单数和复数版本
找到丢失的字符串
有时很容易忘记给`tr`或`translate`打电话;或者从`tr`、`QT_TR_NOOP`或`QT_TRANSLATE_NOOP`标记中省略一个字符串。这导致字符串在运行时不被翻译或被`lupdate`工具错过,从而在`translate`被调用时丢失。
有工具可以定位丢失的字符串。例如,Qt 4 附带了`findtr` perl 脚本。如果你在 Unix 系统上工作,你也可以使用更简单的`grep`命令`grep -n '"' *.cpp | grep -v 'tr('`。
另一种方法是在源代码中使用虚假的语言(例如,在所有字符串之前添加`FOO`,在它们之后添加`BAR`——这样普通的菜单栏就会显示为`FOOFileBAR`、`FOOEditBAR`和`FOOHelpBAR`)。这使得发现没有被翻译的字符串变得容易,因此在测试过程中所有的字符串都有可能被定位。
这两个技巧都不是万无一失的,所以你需要注意你的琴弦以及你对它们做了什么。在翻译中遗漏一个字符串会很快给你的用户传达一个糟糕的信息。
**提示**找到遗漏的`tr()`调用的一种方法是阻止 Qt 自动将`char*`字符串转换为`QString`对象,这将导致编译器在您遗漏调用`tr()`的所有时间都出错。您可以通过在项目文件中添加一行`DEFINES += QT_NO_CAST_FROM_ASCII`来禁用转换。
即时翻译
有时,您可能希望您的应用程序能够在不同的语言之间动态切换。用户应该能够选择一种语言,然后整个环境立即被翻译成所选择的语言。要尝试这样做,请看一下图 10-10 中的应用程序。只有两种语言可供选择,但是相同的解决方案适用于任何数量的语言。
**图 10-10。** *正在翻译的应用程序*
原理很简单。当用户选中一个单选按钮时,`toggled`信号连接到一个插槽。该插槽将新的翻译加载到已安装的`QTranslator`对象中,这将导致对`tr`的所有调用返回所选语言的字符串。唯一的问题是所有的`tr`调用都需要重新做一遍。在这种情况下,最好知道当一个新的翻译被加载时,一个`QEvent::LanguageChange`事件被发送给所有的`QObject`。它的工作原理是将所有的`setText`和`setTitle`调用放在一个函数中,一旦发生语言改变事件就调用那个函数。
这在理论上听起来不错,所以让我们看看实际的源代码。清单 10-8 显示了`DynDialog`类的声明,它是应用程序中使用的对话框。您需要保留对所有显示文本的小部件的引用——`languages`分组框和两个单选按钮。
**清单 10-8。***`DynDialog`*类声明**```
class DynDialog : public QDialog
{
Q_OBJECT
public:
DynDialog();
protected:
void changeEvent( QEvent* );
private slots:
void languageChanged();
private:
void translateUi();
QGroupBox *languages;
QRadioButton *english;
QRadioButton *swedish;
};
```cpp
该构造器表明该对话框旨在被动态翻译。在清单 10-9 的所示的源代码中,小部件被创建、配置并放置在布局中,但是没有一个对`setText`或`setTitle`的调用。相反,在最后调用了`translateUi`方法。
**清单 10-9。***`DynDialog`*对话框的构造器——注意没有设置文本**
DynDialog::DynDialog() : QDialog( 0 )
{
languages = new QGroupBox( this );
english = new QRadioButton( this );
swedish = new QRadioButton( this );
english->setChecked( true );
qTranslator->load( "english" );
QVBoxLayout *baseLayout = new QVBoxLayout( this );
baseLayout->addWidget( languages );
QVBoxLayout *radioLayout = new QVBoxLayout( languages );
radioLayout->addWidget( english );
radioLayout->addWidget( swedish );
connect( english, SIGNAL(toggled(bool)), this, SLOT(languageChanged()) );
connect( swedish, SIGNAL(toggled(bool)), this, SLOT(languageChanged()) );
translateUi();
}
`translateUi`方法如清单 10-10 所示。这里,用户可见的所有字符串都通过`tr`传递,然后被设置。
**清单 10-10。** *一次更新所有用户可见字符串*
void DynDialog::translateUi()
{
languages->setTitle( tr("Languages") );
english->setText( tr("English") );
swedish->setText( tr("Swedish") );
}
参考清单 10-9 中的可以看到,当用户选择另一种语言时(也就是切换其中一个单选按钮),槽`languageChanged`被调用。插槽实现如清单 10-11 所示。如您所见,`qTranslator`为不同的用户选择加载了不同的翻译器。`qTranslator`指针是一个应用全局指针,指向已安装的`QTranslation`对象。该对象被创建并安装在`main`功能中。
**清单 10-11。** *加载译文*
void DynDialog::languageChanged()
{
if( english->isChecked() )
qTranslator->load( "english" );
else
qTranslator->load( "swedish" );
}
当加载新的翻译时,`QEvent::LanguageChanged`事件被发送到所有的`QObject`实例。这个事件可以在受保护的`changeEvent`方法中被捕获,如清单 10-12 所示。一旦遇到事件,就会再次调用`translateUi`方法,使用新加载的翻译器更新所有可见文本。
**清单 10-12。** *观察* `QEvent::LanguageChanged` *事件,遇到时更新用户界面。*
void DynDialog::changeEvent( QEvent *event )
{
if( event->type() == QEvent::LanguageChange )
{
translateUi();
}
else
QDialog::changeEvent( event );
}
* * *
**提示**您可以在`changeEvent`方法中观察更多的国际化事件。当地区改变时,发送`QEvent::LocaleChange`。
* * *
为了能够构建系统,使用了一个带有行`TRANSLATIONS += english.ts swedish.ts`的项目文件。使用`lupdate`生成`ts`文件,语言学家翻译字符串,使用`lrelease`生成`qm`文件。然后运行`qmake`和`make`来构建应用程序。
### 其他注意事项
当执行应用程序的实际本地化时,有几个问题需要注意。这不仅仅是翻译文本的问题;您还必须处理不同的键入数字、显示图像、处理货币以及处理时间和日期的方式。
#### 处理文字
因为 Qt 在内部处理 Unicode 字符,所以`QString`和`QChar`类可以处理几乎任何可能的字符。但是这意味着标准库`isalpha`、`isdigit`、`isspace`等不能在所有平台上正常工作,因为它们有时在西欧或美国环境下运行。
* * *
我有时会在英文网站上注册我的街道地址时遇到麻烦,因为我居住的城镇叫做 Alingså。字母“不被认为是合法字符。
* * *
解决方案是坚持这些方法的特定于 Qt 的实现。`QChar`类包含了方法`isAlpha`、`isDigit`、`isSpace`以及更多等同于标准函数的方法。
考虑 Unicode 不仅在验证用户输入时很重要,在解析文件时也很重要。要将 Unicode `QString`转换为`char*`向量(通过`QByteArray`,可以使用`toAscii`或`toLatin1`将字符串转换为每字符 8 位的格式。结果是 ASCII 字符串或 Latin1 (ISO 8859-1)字符串。如果您想转换为当前的 8 位格式,您可以使用`toLocal8Bit`方法,该方法会转换为系统设置所指示的 8 位编码。
您也可以使用`toUtf8`将其转换为 UTF8。UTF8 格式表示许多字符,就像在 ASCII 中一样,但是通过将它们编码为多字节序列来支持所有 Unicode 字符。
绘制文字时,Qt 尊重文字的方向。有些语言是从右向左书写的,所以在定制小部件时必须考虑到这一点。最简单的方法是使用矩形而不是点来指定文本的位置。通过这种方式,Qt 可以将文本放置在用户期望的位置。
#### 图片
当谈到图像时,有两件重要的事情需要考虑:小心使用图像来交流文字游戏,避免敏感的符号。设计有效的图标是一门艺术,遵循这些规则会使它变得更加困难。
文字游戏的一个经典例子是将一棵树的日志显示为日志查看器的图标。这在英语环境中是非常合乎逻辑的,但是在瑞典语中,表示一棵树的原木的单词是 stock。这个图标可以说是代表一个股票市场交易工具——这在英语环境中是一个糟糕的文字游戏。
当涉及敏感符号时,有许多事情要避免。排在首位的是宗教符号。另一个有文化内涵的例子是红十字会(在一些国家,红新月会更常见)。避免政治和军事符号也是明智的,因为它们在不同的国家有很大的不同。关键是运用你的判断力,记住人们很容易被冒犯。
#### 数字
数字可能是一个棘手的问题——无论是打印还是解释。`QLocale`类可以处理不同的负号、小数点、组分隔符、指数字符和代表零的字符。所有这些都给了你很多出错的细节。
根据我的经验,关于数字的表示,最常见的混淆问题是用于小数点和组分隔符(将数字分成三组)的字符。以数字 1.234 和 1,234 为例。如何解读这些数字取决于你所在的国家——在一些国家,第一个数字读作*一千二百三十四*;在其他地方,它读作*一点二三四*。加两个小数更好,但不完美:1.234,00 和 1,234.00。两者都有效,但是小数点和组分隔符不同。
* * *
**提示**能够处理系统的小数点字符非常重要。不同的键盘在数字小键盘上有不同的小数点字符。不得不在数字键盘和主键盘之间移动来写一个小数点,这可能*非常*烦人。
* * *
使用`QLocale`类及其方法`toString`将数字转换成文本;使用`toFloat`、`toInt`等将字符串转换为数字。虽然这适用于处理显示给用户的数字和字符串,但是在将数字作为文本存储在文件中时,请记住坚持一种格式,因为文件可以在不同的国家之间移动(并且无论当前的语言环境如何,您仍然必须能够正确地读取数字)。
* * *
**提示**系统区域设置`QString::toDouble`和好友用于将字符串转换为数值。
* * *
清单 10-13 显示了一个使用给定的`QLocale`来转换和打印三个值的函数。给定一个`QLocale( QLocale::Swedish, QLocale::Sweden )`和一个`QLocale( QLocale::English, QLocale::UnitedStates )`的函数的输出可以在清单 10-14 中看到。注意使用的不同小数点和组分隔符。
**清单 10-13。** *使用给定的区域设置打印三个值*
`void printValues( QLocale loc )
{
QLocale::setDefault( loc );
double v1 = 3.1415;
double v2 = 31415;
double v3 = 1000.001;
qDebug() << loc.toString( v1 );
qDebug() << loc.toString( v2 );
qDebug() << loc.toString( v3 );
}`
**清单 10-14。** *使用不同的语言环境打印相同的三个值*
Swedish
"3,1415"
"31 415"
"1 000"
US English
"3.1415"
"31,415"
"1,000"
**货币**
处理货币是不借助 Qt 也要做的事情。这没什么,因为货币可以被视为一个有限精度的数字——通常是两位小数,但有时没有或只有三位。
当您向用户显示货币值时,记住一些基础知识是很重要的。首先,您可以在值(例如,280,00 SEK 或 8.75 美元)后面加上三个字母的货币代码(ISO 4217)。请注意,我根据示例中的货币使用了适当的小数点符号。(当然,您应该根据用户的偏好选择一个小数点符号。)
所有的货币都有名字。例如, *SEK* 是瑞典克朗的简称或者只是克朗(复数为克朗)。这也是可以放在被呈现的值之后的东西。
一些货币有一个标志或符号,可以用来代替在值后加上代码或名称。这个符号可以放在值的前面,也可以放在值的后面,或者作为小数点符号。例如 12.50 英镑和€12.50 欧元。有更多的符号可用于其他货币。一些符号是普遍使用的,而另一些只在使用该货币的当地市场使用。
从国际化的角度来看,我建议使用 ISO 4217 代码,因为它是中立的(代码是国际标准的一部分)并且易于处理(代码总是跟在值后面)。
#### 日期和时间
在全球范围内,日期和时间以多种不同的方式呈现,这对开发人员来说是一个困难的挑战。尽管 Qt 提供了处理复杂性的类,但是存在误解用户输入和通过输出混淆用户的风险。
让我们先来看看时间以及它是如何呈现给用户的。以文本形式表示的时间通常表示为数字时钟,两位数表示小时,两位数表示分钟。小时和分钟由冒号或简单的点分隔。这里的问题是时钟可以是 24 小时制的,时间从 0 到 23。时钟也可以是 12 小时制,其中时间从 0 到 11 运行两次。在后一种情况下,分钟后面跟 AM 或 PM,表示时间是指早上还是晚上。
你可以使用`QTime`方法`toString`和`fromString`(结合`QLocale`类的`timeFormat`方法)或者直接使用`QLocale`中的`toString`方法,以用户期望的方式处理输入和输出。请确保您不会将 12 小时制的 PM 时间解释为后面跟有一些无意义字符的 24 小时制时间。
清单 10-15 显示了一个使用给定地区打印时间的函数。结果输出如清单 10-16 所示。语言环境是`QLocale( QLocale::Swedish, QLocale::Sweden )`和`QLocale( QLocale::English, QLocale::UnitedStates )`。
**清单 10-15。** *使用不同地区的打印时间*
void printTimes( QLocale loc )
{
QLocale::setDefault( loc );
QTime t1( 6, 15, 45 );
QTime t2( 12, 00, 00 );
QTime t3( 18, 20, 25 );
qDebug() << "short";
qDebug() << loc.toString( t1, QLocale::ShortFormat );
qDebug() << loc.toString( t2, QLocale::ShortFormat );
qDebug() << loc.toString( t3, QLocale::ShortFormat );
qDebug() << "long";
qDebug() << loc.toString( t1, QLocale::LongFormat );
qDebug() << loc.toString( t2, QLocale::LongFormat );
qDebug() << loc.toString( t3, QLocale::LongFormat );
qDebug() << "default";
qDebug() << loc.toString( t1 );
qDebug() << loc.toString( t2 );
qDebug() << loc.toString( t3 );
}
**清单 10-16。** *打印时产生的字符串使用不同的区域设置*
Swedish
short
"06.15.45"
"12.00.00"
"18.20.25"
long
"kl. 06.15.45 W. Europe Daylight Time"
"kl. 12.00.00 W. Europe Daylight Time"
"kl. 18.20.25 W. Europe Daylight Time"
default
"kl. 06.15.45 W. Europe Daylight Time"
"kl. 12.00.00 W. Europe Daylight Time"
"kl. 18.20.25 W. Europe Daylight Time"
US English
short
"6:15:45 AM"
"12:00:00 PM"
"6:20:25 PM"
long
"6:15:45 AM W. Europe Daylight Time"
"12:00:00 PM W. Europe Daylight Time"
"6:20:25 PM W. Europe Daylight Time"
default
"6:15:45 AM W. Europe Daylight Time"
"12:00:00 PM W. Europe Daylight Time"
"6:20:25 PM W. Europe Daylight Time"
说到表示日期,还有其他问题要处理。月份在不同的国家有不同的名称,一周中的日子也是如此。书写日期时,不同国家的日、月、年的顺序不同。让事情变得更复杂的是,一周的第一天可以是周日,也可以是周一,这取决于你所在的位置。为了帮助您管理这些,`QLocale`类可以处理大部分问题。
通过使用来自`QDate`类的`toString`和`fromString`方法和来自`QLocale`的`dateFormat`方法,或者直接使用`QLocale`的`toString`方法,您可以正确地表示和解释日期。
为了比较区域设置`QLocale( QLocale::Swedish, QLocale::Sweden )`和`QLocale( QLocale::English, QLocale::UnitedStates )`对日期格式的影响,我使用了清单 10-17 中所示的函数。结果输出可以在清单 10-18 中看到。
**清单 10-17。** *使用不同地区打印日期*
void printDates( QLocale loc )
{
QLocale::setDefault( loc );
QDate d1( 2006, 10, 12 );
QDate d2( 2006, 01, 31 );
QDate d3( 2006, 06, 06 );
qDebug() << "short";
qDebug() << loc.toString( d1, QLocale::ShortFormat );
qDebug() << loc.toString( d2, QLocale::ShortFormat );
qDebug() << loc.toString( d3, QLocale::ShortFormat );
qDebug() << "long";
qDebug() << loc.toString( d1, QLocale::LongFormat );
qDebug() << loc.toString( d2, QLocale::LongFormat );
qDebug() << loc.toString( d3, QLocale::LongFormat );
qDebug() << "default";
qDebug() << loc.toString( d1 );
qDebug() << loc.toString( d2 );
qDebug() << loc.toString( d3 );
}
**清单 10-18。** *使用不同语言环境打印日期时产生的字符串*
Swedish
short
"12 okt 2006"
"31 jan 2006"
"6 jun 2006"
long
"torsdag 12 oktober 2006"
"tisdag 31 januari 2006"
"tisdag 6 juni 2006"
default
"torsdag 12 oktober 2006"
"tisdag 31 januari 2006"
"tisdag 6 juni 2006"
US English
short
"Oct 12, 2006"
"Jan 31, 2006"
"Jun 6, 2006"
long
"Thursday, October 12, 2006"
"Tuesday, January 31, 2006"
"Tuesday, June 6, 2006"
default
"Thursday, October 12, 2006"
"Tuesday, January 31, 2006"
"Tuesday, June 6, 2006"
注意在清单 10-14 和清单 10-18 中,默认格式都是长格式。如果我必须在长格式和短格式之间做出选择,我会认为短格式在大多数情况下更容易阅读(除非我真的需要关于工作日和时区的所有细节)。
#### 救命
Qt 附带的翻译工具捕获了您提供的大部分帮助:工具提示、状态消息和这是什么字符串,只要它们包含在`tr`调用中就可以找到。不要忘记您的在线帮助文档。您必须负责翻译您的帮助文档,并确保在用户请求帮助时显示正确的语言。不是很复杂;这只是一些你不能忘记的事情,因为 Qt 工作流没有捕捉到它。
### 总结
国际化和本地化不仅仅是翻译应用程序。您不能再依赖于许多您认为理所当然的东西:日期格式、时间格式、数字格式、用户理解的图标、合法字符等等。这个过程实际上是关于理解目标文化及其习俗。这就是为什么在全球范围内部署应用程序是一项如此艰巨的任务。
通过使用`lupdate`、`lrelease`和语言学家以及`QLocale`类,您已经取得了很大的进步。尽可能将您的文本放在`QString`和`QChar`中,以确保使用 Unicode(使您不必总是考虑字符编码)。
在部署之前,请确保在所有目标语言环境中进行测试。如果可能的话,尝试使用本地测试人员——他们可能会比你发现更多的错误。**
十一、插件
Qt 提供了丰富的编程接口,能够与许多不同的技术进行交互。这种能力使得 Qt 驱动的应用程序在不同的平台上看起来不同;图像可以以多种不同的方式存储,并可以与多种数据库解决方案交互。你可能会惊讶地发现,你可以使用一个叫做插件的 Qt 特性来创建自己的新 Qt 特性。
Qt 用来处理插件的类并不局限于扩展 Qt。使用同一套类,你还可以创建自己的插件接口,并使用自定义插件扩展自己的应用程序。这使得创建可扩展的应用程序成为可能,而不必处理过程中涉及的所有平台细节。
插件基础知识
在开始使用插件之前,您需要了解插件是如何工作的。对于 Qt 应用程序来说,插件只是类的另一个实例。可用的方法由接口类决定。一个接口类通常只包含纯虚方法,所以接口类中没有实现任何函数。然后,插件继承了QObject
类和接口类,并实现了所有具有特定功能的方法。当应用程序加载一个带有QPluginLoader
类的潜在插件时,它会得到一个QObject
指针。通过尝试使用qobject_cast
将给定的对象转换为接口类,应用程序可以判断插件是否实现了预期的接口,是否可以被视为实际的插件。
为了让QPluginLoader
正常工作,接口类必须通过使用Q_DECLARE_INTERFACE
宏声明为接口,插件必须通过使用Q_INTERFACES
宏声明它们实现了一个接口。这两个宏使您能够安全地将给定的插件匹配到正确的界面。这是 Qt 信任插件必须满足的一系列标准中的一步。下面的列表包含了 Qt 在试图加载一个插件时执行的所有检查。如果不满足任何标准,插件就不会被加载。
- 必须使用相同版本的 Qt 来构建插件和应用程序。Qt 检查大调(4)和小调(4。 2 号匹配,但修订号(4.2。 2 可有所不同。
- 插件和应用程序必须使用相同的编译器在相同的平台上为相同的操作系统构建。编译器的版本可以不同,只要它们的内部架构保持不变(例如,名称篡改)。
- 用于插件和应用程序的 Qt 库必须以相同的方式配置,并且必须在“共享”模式下编译(不能使用带有静态 Qt 的插件)。
用插件扩展 Qt
Qt 有很多可以扩展的接口。例如,您可以为样式、数据库驱动程序、文本编解码器和图像格式添加插件。如果您使用 Qtopia Core,您甚至可以使用插件来访问不同的硬件,如图形驱动程序、鼠标驱动程序、键盘驱动程序和辅助设备。
注 Qtopia Core 是一个 Qt 版,用于掌上电脑、机顶盒、手机等嵌入式系统。
Qt 的可扩展性有很多好处。首先,它让 Qt 更加耐用,因为它可以适应新技术。还可以让 Qt 更轻便,因为不需要的插件不需要部署。这也确保了你可以继续使用 Qt 的应用编程接口,即使你需要针对特殊的技术。
创建 ASCII 艺术插件
制作 Qt 插件的原则是一样的,不管插件实际提供的扩展类型是什么。为了理解如何扩展 Qt 以及 Qt、插件和应用程序之间的交互是如何工作的,我们来看看一个图像格式插件。该插件会将图像保存为 ASCII art,其中每个像素被转换为一个字符(图 11-1 中的显示了一个例子)。这是一种失传的艺术,但在 20 世纪 80 年代和 90 年代初非常普遍。
在你开始看这个插件之前,你应该看看 Qt 是如何加载和保存图片的。总体思路是使用来自QImage
类的save
和load
方法。(你可以在QImage
的构造器中指定文件名,而不是使用load
——它做同样的事情。)
QImage
类在加载图像时使用了一个QImageReader
类。QImageReader
检查是否有能够读取给定图像的QImageIOPlugin
。当一个插件被找到时,它被要求返回一个QImageIOHandler
,然后QImageReader
用它来实际读取图像。
写的时候过程差不多,但是文件格式不是从文件中确定的而是要在调用save
的时候指定。QImage
将它传递给QImageWriter
类,该类询问是否有能够以给定格式保存的QImageIOPlugin
。当找到后,QImageIOPlugin
返回一个QImageIOHandler
,QImageWriter
用它将图像写入一个设备,通常是一个文件。
图 11-1。 一幅 ASCII 艺术图像
提示图像读取器和写入器与QIODevice
对象一起工作,因此图像可以被读取或写入网络流、内存缓冲区、文件——你能想到的——因为QIODevice
是管理这些接口的类的基类。
读写两种情况如图图 11-2 所示。图中还显示了哪个部分是 Qt,哪个部分是插件。所示场景通常与 Qt 插件一起使用。一个是询问插件必须提供什么,然后返回执行实际任务的实例。在图像插件的情况下,查询QImageIOPlugin
并返回一个QImageIOHandler
。
图 11-2。 使用 Qt 读写图像步骤中涉及的类
插件
现在你已经准备好看看可以处理文本图像的 ASCII 艺术图像插件;格式叫做ti
。您还将告诉 Qt 使用ti
作为这些文本图像的首选文件扩展名。TextImagePlugin
类继承了QImageIOPlugin
类,而TextImageHandler
继承了QImageIOHandler
类(插件中没有其他东西)。
让我们开始看代码,从清单 11-1 中TextImagePlugin
的类声明开始。该接口由三种方法组成:keys
、capabilities
和create
。keys
方法返回插件支持的图像格式的QStringList
。capabilities
方法将一个QIODevice
和一个图像格式作为参数,然后返回一个值,指示插件CanRead
或CanWrite
是否将指定的格式发送到给定的设备或从给定的设备接收指定的格式。最后一个方法create
,为给定的设备和格式创建一个QImageIOHandler
。
注意如果支持增量读取,capabilities
方法可以返回值CanReadIncremental
。这意味着它可以多次读取图像,从而逐渐显示图像。ASCII 艺术图像插件从不试图实现它。
**清单 11-1。**镜像 IO 插件的类声明
class TextImagePlugin : public QImageIOPlugin
{
public:
TextImagePlugin();
~TextImagePlugin();
QStringList keys() const;
Capabilities capabilities( QIODevice *device, const QByteArray &format ) const;
QImageIOHandler *create( QIODevice *device,
const QByteArray &format = QByteArray() ) const;
};
最有趣的方法是capabilities
(如清单 11-2 中的所示),它决定了插件可以为给定格式的或设备做什么。这意味着formatQByteArray
必须包含字符串ti
或者为空,插件才能对它做任何事情。
如果格式QByteArray
为空,则必须查看QIODevice
。如果它是开放且可写的,您可以随时写入。如果它是可读的,并且插件可以从中读取(稍后将详细介绍静态的canRead
方法),你就可以从中读取。重要的是不要以任何方式影响设备(确保您只是在偷看;实际上不读、不写、不找)。
注 A QByteArray
可以看作是 Qt 对char*
的控制版本。你可以用它来携带文本,就像普通的 C 字符串一样。千万不要用QString
来做这件事(就像你可能用std::string
做过的那样),因为它会在内部转换成 Unicode,这可能会破坏你的二进制数据。
清单 11-2。 决定插件可以对给定的格式和设备做什么
QImageIOPlugin::Capabilities TextImagePlugin::capabilities( QIODevice *device,
const QByteArray &format ) const
{
if( format == "ti" )
return (QImageIOPlugin::CanRead | QImageIOPlugin::CanWrite);
if( !format.isEmpty() )
return 0;
if( !device->isOpen() )
return 0;
QImageIOPlugin::Capabilities result;
if( device->isReadable() && TextImageHandler::canRead( device ) )
result |= QImageIOPlugin::CanRead;
if( device->isWritable() )
result |= QImageIOPlugin::CanWrite;
return result;
}
那么 Qt 怎么知道要哪些格式呢?所有的图像插件都报告它们可以用keys
方法处理哪些格式。格式(在本例中是 format)被放入一个返回的QStringList
中。实现如清单 11-3 所示。
清单 11-3。 将图像文件格式放入 QStringList
QStringList TextImagePlugin::keys() const
{
return QStringList() << "ti";
}
当格式正确并且可以处理时,最后一个方法开始起作用。清单 11-4 中的create
方法创建了一个自定义TextImageIOHandler
的实例,用格式和设备对其进行配置,并返回结果。
为处理程序设置了一种格式,因此它可以处理几种格式。有许多格式几乎是相同的,因此减少源代码的大小是很有用的。
清单 11-4。 创建和配置镜像 IO 处理器
QImageIOHandler *TextImagePlugin::create( QIODevice *device,
const QByteArray &format ) const
{
QImageIOHandler *result = new TextImageHandler();
result->setDevice( device );
result->setFormat( format );
return result;
}
在进入 handler 类之前,必须告诉 Qt 这个类是插件接口的一部分。你可以通过使用Q_EXPORT_PLUGIN2
宏来做到这一点,如清单 11-5 所示。宏放在实现文件中的某个地方(不是头文件)。第一个参数是所有字符都是小写的类名,而第二个参数是实际的类名。
宏告诉 Qt 这个类是插件的接口。每个插件只能有一个接口,所以这个宏在每个插件中只能使用一次。
清单 11-5。 将类导出为插件
Q_EXPORT_PLUGIN2( textimageplugin, TextImagePlugin )
读取和写入图像
TextImagePlugin
占了插件的一半。另一半由TextImageHandler
类组成,该类执行所有繁重的工作——从设备读取和写入图像。
让我们先看看清单 11-6 中的类声明。该类继承了QImageIOHandler
类并实现了方法read
、write
和canRead
的两个变体。read
和write
方法非常简单明了,但是两个canRead
版本需要一点解释。非静态版本简单地调用静态版本。拥有静态版本的原因是从TextImagePlugin
类中的capabilities
方法更容易使用(参考清单 11-2 )。从 Qt 的角度来说,不需要静态版本。
清单 11-6。 图像 IO 处理程序的类声明
class TextImageHandler : public QImageIOHandler
{
public:
TextImageHandler();
~TextImageHandler();
bool read( QImage *image );
bool write( const QImage &image );
bool canRead() const;
static bool canRead( QIODevice *device );
};
最简单的复杂方法是write
方法,如清单 11-7 所示。它只需要很少的错误检查,只是将部分图像流式传输到一个写入指定设备的QTextStream
。device
方法返回与在TextImagePlugin
的create
方法中使用setDevice
设置的设备相同的设备(参见清单 11-4 )。它在创建文本流stream
时使用。
建立流时,会向文件中写入一个前缀。所有 ASCII 艺术图像都以一行TEXT
开始。然后将尺寸写成宽 × 高,其中x
作为分隔符。您可以从作为方法参数给出的图像中获取尺寸。前缀和维度构成了标题;剩下的就是图像数据了。
通过将每个像素的红色、绿色和蓝色值转换成平均灰度值来计算图像数据。然后,该值向下移位并屏蔽为三位,取值范围为 0–7。该值对应于每个像素的暗度,用于在map
字符串中查找字符。
map
变量是一个初始化为.:ilNAM
的char*
(包括一个初始空格)。map
字符串中的字符已被挑选,因此最低值为白色,并且随着索引的增加,每个字符变得越来越暗。在图 11-3 中可以看到源图像和产生的 ASCII 艺术。ASCII 艺术在文字处理器中使用设置为很小尺寸的等宽字体显示。
当所有图像数据写入流时,在返回true
进行成功的写操作之前,确保流的良好状态。
清单 11-7。 将图像写入设备
bool TextImageHandler::write( const QImage &image )
{
QTextStream stream( device() );
stream << "TEXT\n";
stream << image.width() << "x" << image.height() << "\n";
for( int y=0; y<image.height(); ++y )
{
for( int x=0; x<image.width(); ++x )
{
QRgb rgb = image.pixel( x, y );
int r = rgb & 0xff;
int g = (rgb >> 8) & 0xff;
int b = (rgb >> 16) & 0xff;
stream << map[ 7 - (((r+g+b)/3)>>5) & 0x7 ];
}
stream << "\n";
}
if( stream.status() != QTextStream::Ok )
return false;
return true;
}
今天的大多数字体不是等宽的,这意味着一个字符的宽度取决于字符;一辆 i 比一辆 M 需要更少的空间。另一个问题是,大多数字体的高度都比宽度大。ASCII 艺术图像插件没有考虑到这一点,所以即使使用等宽字体,结果看起来也会被拉伸。在write
方法中很难对此进行补偿,因为你永远不知道用户将使用哪种字体来查看图像。总而言之,结果并不完美,但你仍然可以看出图像显示了什么。
图 11-3。 源图像旁边是生成的 ASCII 图片
虽然写是一个简单的过程,但是读是完全相反的,因为你永远不能相信输入流是有效的。它可以包含任何内容,包括完全意想不到的内容(例如,损坏的数据或完全不同的文件格式),或者文件可能缺少数据。这意味着read
方法比write
方法更复杂。
在清单 11-8 中,你可以看到标题是如何被读取和验证的。与编写一样,它从创建一个QTextStream
开始。读取第一行,并确保它等于TEXT
。如果没有,则整个操作中止。
第一行后面的维度被匹配,并使用正则表达式过滤掉。如果表达式不匹配,或者任何维度无法转换为数字,操作将中止。现在你知道标题是好的,所以你可以开始读取图像数据。
清单 11-8。 确定你是否愿意阅读文件
bool TextImageHandler::read( QImage *image )
{
QTextStream stream( device() );
QString line;
line = stream.readLine();
if( line != "TEXT" || stream.status() != QTextStream::Ok )
return false;
line = stream.readLine();
QRegExp re( "(\\d+)x(\\d+)" );
int width, height;
if( re.exactMatch( line ) )
{
bool ok;
width = re.cap(1).toInt( &ok );
if( !ok )
return false;
height = re.cap(2).toInt( &ok );
if( !ok )
return false;
}
else
return false;
...
}
因为头是有效的,所以可以看到read
方法的后半部分(源代码如清单 11-9 所示)。阅读和写作非常相似。首先,创建一个临时的QImage
;然后读取每一行并转换成灰度。根据预期的图像宽度检查每行的长度,图像数据中不接受任何意外字符。如果整个图像读取完毕后流的状态正常,则在返回true
以指示读取成功之前,作为参数给出的图像会被更新。
清单 11-9。 从设备中读取图像,判断是否一切顺利。
bool TextImageHandler::read( QImage *image )
{
...
QImage result( width, height, QImage::Format_ARGB32 );
for( int y=0; y<height; ++y )
{
line = stream.readLine();
if( line.length() != width )
return false;
for( int x=0; x<width; ++x )
{
switch( QString(map).indexOf(line[x]) )
{
case 0:
result.setPixel( x, y, 0xffffffff );
break;
case 1:
result.setPixel( x, y, 0xffdfdfdf );
break;
case 2:
result.setPixel( x, y, 0xffbfbfbf );
break;
case 3:
result.setPixel( x, y, 0xff9f9f9f );
break;
case 4:
result.setPixel( x, y, 0xff7f7f7f );
break;
case 5:
result.setPixel( x, y, 0xff5f5f5f );
break;
case 6:
result.setPixel( x, y, 0xff3f3f3f );
break;
case 7:
result.setPixel( x, y, 0xff000000 );
break;
default:
return false;
}
}
}
if( stream.status() != QTextStream::Ok )
return false;
*image = result;
return true;
}
将图像保存为 ASCII 图片,然后读取会导致一些损失。颜色到灰度的转换和反向转换远非完美。从图 11-3 的中取出 ASCII 艺术图像,并保存回一个普通的基于像素的图像,得到如图 11-4 的所示的图像。
图 11-4。 将 ASCII 图片另存为普通图片。
TextImageHandler
的剩余部分是清单 11-10 中显示的canRead
方法。非静态方法调用静态方法。非静态方法实际上只是一个提供 Qt 期望的接口的包装器。静态方法使用peek
方法来查看文件是否以前缀TEXT
开头。如果找到了前缀,就认为文件的其余部分是正常的,并返回true
以指示处理程序可以读取该文件。
提示在设计文件格式时,给实际数据加上一个唯一的头是个好主意。这使得无需读取整个文件就可以查看该文件是否适合读取。
这里使用peek
方法很重要,因为它不会影响QIODevice
。当试图读取图像时,Qt 可以将同一个设备传递给几个插件,以确定使用哪个插件。
清单 11-10。 窥视设备以确定图像看起来是否正确。
bool TextImageHandler::canRead( QIODevice *device )
{
if( device->peek(4) == "TEXT" )
return true;
return false;
}
bool TextImageHandler::canRead() const
{
return TextImageHandler::canRead( device() );
}
建造和安装
要构建并安装一个插件,让 Qt 能够找到它,不仅仅需要运行qmake –project
。您可以使用它来创建一个起点,但是您必须广泛地修改项目文件。
清单 11-11 显示了 ASCII 艺术图像格式插件的项目文件。HEADERS
和SOURCES
行对于所有 Qt 项目都是一样的。上面的行表示您正在构建一个模板,而下面的行表示插件将被安装的位置。
从顶部开始,您将TEMPLATE
设置为lib
,这告诉 QMake 您正在构建一个库,而不是一个应用程序。下一行告诉 QMake 插件的名称:textimage
。下面是CONFIG
行,其中您指定了lib
将被用作plugin
,并且它应该以release
模式构建(没有调试信息)。顶部的最后一行是VERSION
行,用于区分不同的插件版本。在这种情况下,结果文件被命名为textimage1
。
最后两行设置了一个安装目标,它配置了运行make install
时执行的操作。这一段的第一行将target
的path
设置为$$[QT_INSTALL_PLUGINS]/imageformats
——也就是 Qt 安装目录里面的plugins/imageformats
目录。本节的第二行和项目文件的最后一行告诉 Qt 在make install
运行时安装target
。它会将插件文件复制到适当的目录,让 Qt 能够找到它。
清单 11-11。 项目文件为 TextImagePlugin
和 TextImageHandler
TEMPLATE = lib
TARGET = textimage
CONFIG += plugin release
VERSION = 1.0.0
HEADERS += textimagehandler.h textimageplugin.h
SOURCES += textimagehandler.cpp textimageplugin.cpp
target.path += $$[QT_INSTALL_PLUGINS]/imageformats
INSTALLS += target
要构建和制作这个项目,必须运行qmake
,然后运行make
。如果它没有任何问题地完成了,你可以运行make install
让这个插件对 Qt 可用。
使用插件
在你开始使用插件之前,你需要知道 Qt 是如何处理插件的。它们是由QApplication
(实际上是由它的超类— QCoreApplication
)对象加载的,所以当你使用插件时,你必须确保有一个QApplication
的实例可用。
有了一个QApplication
对象后,可以通过使用静态的supportedImageFormats
方法查询QImageReader
和QImageWriter
类,以获得支持的格式列表。读取器返回可读的图像格式,而写入器返回可写的图像格式。返回值是QByteArray
对象的QList
,它是从不同的QImageIOPlugin
对象返回的所有可用键的列表。
清单 11-12 显示了一个小的foreach
循环,它查询所有可读的图像格式并将它们打印到调试控制台。所有可以读取的格式通常也可以被编写——但是你永远不能假设这一点。
清单 11-12。 向 Qt 询问可以读取的图像格式
QApplication app( argc, argv );
foreach( QByteArray ba, QImageReader::supportedImageFormats () )
qDebug() << ba;
在读取时,Qt 通常通过查询插件的capabilities
方法来确定文件格式。这会生成对不同的canRead
方法的调用,这些方法决定特定的插件是否可以处理给定的文件。(应用程序只需要指定文件名;Qt 完成剩下的工作。如清单 11-13 所示,如果加载失败,产生的QImage
是一个空图像。如果使用QImage
的load
方法,可以从中获得返回值。如果加载了图像,该方法返回true
;如果失败,它将返回false
。
清单 11-13。 阅读一幅 ASCII 艺术图像
QImage input( "input.ti" );
if( input.isNull() )
qDebug() << "Failed to load.";
阅读的对立面——储蓄——稍微复杂一些。因为没有要查找的文件前缀,所以需要在调用save
时指定文件格式(见清单 11-14 )。在清单中,从磁盘中读取了一个png
映像。如果读取成功,图像将再次保存为ti
图像。save
调用返回一个bool
值,该值指示操作是否成功。值true
意味着它起作用了。
清单 11-14。 写一个 ASCII 艺术图像
QImage input( "input.png" );
if( input.isNull() )
qDebug() << "Failed to load.";
else
if( !input.save( "test.ti", "ti" ) )
qDebug() << "Failed to save.";
使用插件扩展你的应用程序
扩展 Qt 是一回事,但是让你自己的应用程序可扩展是另一回事。它不仅涉及实现给定的接口;你还必须设计界面,寻找插件,加载它们,然后使用它们。
这是传统上需要考虑很多很多平台问题的领域之一。有了 Qt,几乎所有这些怪癖都消失了,您可以专注于为您的用户提供模块化和可扩展的设计。
过滤图像
本章从 Qt 的图像文件格式插件开始;它继续创建一个图像过滤应用程序,其中的过滤器是作为插件提供的。在图 11-5 中可以看到一目了然的情况:过滤器位于左侧和右侧;原始图像出现在过滤后的图像上方。
图 11-5。 图像过滤应用在行动中
界面
过滤器用于获取一个图像并返回一个新图像,该新图像是给定图像的转换版本,这意味着它需要一个获取图像并返回图像的方法。因为您计划将它作为插件加载,所以应用程序无法从一开始就知道每个过滤器的名称——因此它还需要一个返回其名称的方法。
你如何将这些代码转换成一个真正的插件界面?一个 Qt 插件接口被定义为一个由纯虚拟方法组成的类。这意味着作为插件一部分的所有方法都被设为virtual
并且没有被实现。相反,它们在类声明中被标记为=0
。
结合插件接口和过滤器插件需要做什么的知识,你得到了类似于清单 11-15 中所示的FilterInterface
类。name
方法返回过滤器的名称,filter
方法过滤给定的QImage
并返回过滤结果。名字很清楚,并且很容易理解事情应该如何工作。
清单 11-15。ImageFilter
接口类
class FilterInterface
{
public:
virtual QString name() const = 0;
virtual QImage filter( const QImage &image ) const = 0;
};
在这个类可以作为插件接口使用之前,你必须通过使用清单 11-16 中显示的行告诉 Qt 它是一个接口。第一个参数是涉及的类;第二个是标识符字符串,它对于接口必须是唯一的。
清单 11-16。 声明 ImageFilter
为 Qt 的插件接口
Q_DECLARE_INTERFACE( FilterInterface,
"se.thelins.CustomPlugin.FilterInterface/0.1" )
当界面被定义后,开发可以分成两部分:插件和应用程序(界面的两边)。
实施过滤器
让我们先来看看图 11-5 中的过滤插件。这个类被称为Flip
(它的声明如清单 11-17 中的所示)。头文件包括过滤器接口类声明,因此插件知道如何根据接口规范定义类。
如清单所示,Flip
继承了QObject
和FilterInterface
。重要的是QObject
先遗传;否则元对象编译器将失败。然后,类声明以Q_OBJECT
宏开始,后跟一个Q_INTERFACES
宏,表明该类实现了FilterInterface
接口。
遵循宏声明,您将找到所需的方法。因为基类只包含纯虚方法,所以所有方法都必须在这里实现。否则,插件类不能被实例化。
清单 11-17。 过滤器的类声明 Flip
#include "filterinterface.h"
class Flip : public QObject, FilterInterface
{
Q_OBJECT
Q_INTERFACES(FilterInterface)
public:
QString name() const;
QImage filter( const QImage &image ) const;
};
name
方法的实现非常简单。因为名称在用户界面中使用,所以它以比仅仅Flip
更容易阅读的形式传递。源代码可以在清单 11-18 中看到。
清单 11-18。Flip
的全称是 "Flip Horizontally"
QString Flip::name() const
{
return "Flip Horizontally";
}
filter
方法稍微复杂一些(参见清单 11-19 中的实现源代码)。根据给定输入图像的尺寸和格式创建结果图像。则在返回结果图像之前进行翻转。
清单 11-19。filter
方法翻转给定的图像并返回结果。
QImage Flip::filter( const QImage &image ) const
{
QImage result( image.width(), image.height(), image.format() );
for( int y=0; y<image.height(); ++y )
for( int x=0; x<image.width(); ++x )
result.setPixel( x, image.height()-1-y, image.pixel( x, y ) );
return result;
}
在你完成Flip
过滤器的实现之前,你必须告诉 Qt 这个类实现了插件的接口。这是通过使用Q_EXPORT_PLUGIN2
来完成的,就像使用图像文件格式插件一样(见清单 11-20 )。
清单 11-20。 一定要告诉 QtFlip
是插件接口。
Q_EXPORT_PLUGIN2( flip, Flip )
构建Flip
插件非常类似于构建图像文件格式插件。在清单 11-21 所示的项目文件中,模板被设置为lib
,依此类推。过滤器放在应用程序目录的子目录filters/flip
中,因此filterinterface.h
文件需要在INCLUDEPATH
中。这意味着将其设置为../..
以包含该搜索路径。安装路径是../../plugins
,因此相应地设置目标的路径。
清单 11-21。 项目文件用于构建 Flip
插件
TEMPLATE = lib
TARGET = flip
CONFIG += plugin release
VERSION = 1.0.0
INCLUDEPATH += ../..
HEADERS += flip.h
SOURCES += flip.cpp
target.path += ../../plugins
INSTALLS += target
图 11-5 显示了Flip
滤镜旁边的Blur
和Darken
滤镜。这些过滤器也被实现为插件。除了返回的名称和实际的过滤算法之外,实现非常相似。
应用程序
在FilterInterface
类的另一边是使用过滤器插件的应用程序。这个应用程序很简单:它由一个使用 Designer 构建的对话框、该对话框的一个实现和一个显示该对话框的简单的main
函数组成。
对话框设计由一个QListWidget
和两个QLabel
小部件组成。根据设计者的对话框结构如图图 11-6 所示。该对话框由水平布局组成,因此列表显示在标签的左侧。(参见图 11-5 查看运行中的对话框。)
图 11-6。 对象检查器显示了 FilterDialog
的结构。
在您开始详细研究FilterDialog
类之前,您必须熟悉您将在应用程序中使用的策略。在 Qt 中使用插件时,使用 QPluginLoader
类来加载插件并创建实现插件接口的对象的实例。您找到的实例放在一个QMap
中,它将过滤器名称映射到实际的过滤器对象。然后,当用户请求应用过滤器时,该映射用于访问过滤器。
现在您已经准备好开始查看源代码了。清单 11-22 显示了FilterDialog
类的类声明,它实现了保存在ui
成员变量中的设计器对话框。成员变量filters
用于保存加载的过滤器插件。
当用户选择一个过滤器时,槽filterChanged
被调用。从构造器调用的findFilters
方法查找加载并列出插件。
清单 11-22。FilterDialog
类声明
class FilterDialog : public QDialog
{
Q_OBJECT
public:
FilterDialog( QWidget *parent=0 );
private slots:
void filterChanged( QString );
private:
void findFilters();
QMap<QString, FilterInterface*> filters;
Ui::FilterDialog ui;
};
在清单 11-23 中显示的构造器使用uic
从设计器文件中生成的setupUi
方法初始化用户界面。然后它设置一个原始图像并将QListWidget currentTextChanged
信号连接到filterChanged
插槽。
当用户界面被设置和配置后,在显式调用一次filterChanged
槽以生成结果图像之前,调用findFilters
方法。
清单 11-23。 构造器为 FilterDialog
类
FilterDialog::FilterDialog( QWidget *parent ) : QDialog( parent )
{
ui.setupUi( this );
ui.originalLabel->setPixmap( QPixmap( "source.jpeg" ) );
connect( ui.filterList, SIGNAL(currentTextChanged(QString)),
this, SLOT(filterChanged(QString)) );
findFilters();
filterChanged( QString() );
}
大多数有趣的事情都发生在findFilters
方法中。该方法的源代码可在清单 11-24 中找到。
从清单中可以看出,QPluginLoader
本身并不定位插件。相反,您使用一个QDir
对象来查找您期望插件所在的目录中的所有文件。前两行高亮显示的代码为每个找到的文件创建一个QPluginLoader
对象,并尝试创建一个插件类的实例。
如果返回的实例不为空,您尝试使用qobject_cast
方法将其转换为FilterInterface
类(这显示在最后一行突出显示的内容中)。如果FilterInterface
指针不为空,那么您已经找到了一个实际的过滤器,因此您可以将该过滤器添加到filters
地图中,并在QListWidget
中显示其名称。
如果任何突出显示的步骤导致空值,表明文件无法加载,这可能是由于几个原因:文件不包含插件,插件是使用错误的工具或错误的 Qt 版本构建的,或者插件没有实现FilterInterface
接口。在任何情况下,插件都是无效的,应用程序也不感兴趣。
清单 11-24。 找到插件,加载插件,放入列表
void FilterDialog::findFilters()
{
QDir path( "./plugins" );
foreach( QString filename, path.entryList(QDir::Files) )
{
QPluginLoader loader( path.absoluteFilePath( filename ) );
QObject *couldBeFilter = loader.instance();
if( couldBeFilter )
{
FilterInterface *filter = qobject_cast<FilterInterface*>( couldBeFilter );
if( filter )
{
filters[ filter->name() ] = filter;
ui.filterList->addItem( filter->name() );
}
}
}
}
当用户从过滤器列表中选择一个插件时,filterChanged
插槽被调用(该插槽如清单 11-25 中的所示)。如果滤镜为空,原始图像显示在filteredLabel
标签中;否则你可以使用filters
地图来找到所选的过滤器。滤镜应用于来自originalLabel
标签的图像,生成的QImage
被分配给filteredLabel
标签。
清单 11-25。 当用户从列表中选择一个时应用过滤器
void FilterDialog::filterChanged( QString filter )
{
if( filter.isEmpty() )
{
ui.filteredLabel->setPixmap( *(ui.originalLabel->pixmap() ) );
}
else
{
QImage filtered = filters[ filter ]->
filter( ui.originalLabel->pixmap()->toImage() );
ui.filteredLabel->setPixmap( QPixmap::fromImage( filtered ) );
}
}
拼图的最后一块是一个main
函数,它创建一个QApplication
对象,然后显示对话框。项目文件不受插件使用的影响,所以运行qmake -project
,然后运行qmake
和make
,就可以完成这项工作。
注意因为滤镜的源文件位于包含应用程序的目录下的子目录中,qmake -project
命令会将滤镜的源文件和应用程序的文件一起包含在项目中。在构建或添加一个-norecursive
开关到qmake
调用以阻止qmake
窥视子目录之前,确保从结果项目文件中删除过滤器的文件。
所有这些代码将把你带到图 11-5 所示的应用程序。回头看看代码的大小,很难看出这个应用程序有多强大。几乎可以无限制的扩展和修改,增加的复杂度相对较小。
合并插件和应用程序
您可能希望有插件,但也希望在应用程序可执行文件中保留一些功能(例如,出于部署原因)。发布一个可执行文件总是比发布一个可执行文件和一堆插件容易。也许一些插件是应用程序有用所必需的;例如,一个开发环境至少需要一个代码编辑器才能工作。那么将该编辑器包含在实际的应用程序可执行文件中是合乎逻辑的,即使它在内部被视为一个插件。
Qt 使您能够以一种简单的方式做到这一点,包含的插件可以使用QPluginLoader
来定位,并因此被添加到用于其余插件的同一个流中(它确实涉及到插件项目和应用程序本身的变化)。
制作静态插件
当你构建一个插件时,你构建了一个动态链接库(DLL) 。如果你在你的项目文件中添加一行CONFIG += static
,产生的库将用于静态链接。这意味着库是在链接时添加到应用程序中的,而不是在运行时动态加载的。
当适应静态链接时,Darken
插件的项目文件如清单 11-26 所示。将它与来自清单 11-21 的Flip
插件的项目文件进行比较。
清单 11-26。 项目文件为静态链接插件
TEMPLATE = lib
TARGET = darken
CONFIG += plugin release
VERSION = 1.0.0
INCLUDEPATH += ../..
HEADERS += darken.h
SOURCES += darken.cpp
target.path += ../../plugins
INSTALLS += target
CONFIG += static
链接和查找插件
对应用程序的更改可以分为三个部分。首先,您必须将库添加到项目文件中,以便在构建可执行文件时将它链接到应用程序。清单 11-27 显示了应用程序的项目文件。
突出显示的行使用添加库搜索路径的–L
命令行选项和添加库引用的–l
选项添加对静态链接库的引用。添加的搜索路径取决于用于构建库的平台。
清单 11-27。 引用静态链接插件的应用项目文件
TEMPLATE = app
TARGET =
DEPENDPATH += .
INCLUDEPATH += .
# Input
HEADERS += filterdialog.h filterinterface.h
FORMS += filterdialog.ui
SOURCES += filterdialog.cpp main.cpp
win32:LIBS += -L./filters/darken/release/ -ldarken
!win32:LIBS += -L./filters/darken -ldarken
其次,通过添加清单 11-28 中所示的代码行,确保QPluginLoader
仍然可以找到插件,即使它静态链接到应用程序。
注意,宏Q_IMPORT_PLUGIN
期望的是小写字符的类名,而不是实际的类名。这是作为插件源代码中的Q_EXPORT_PLUGIN2
宏的第一个参数给出的字符串。
清单 11-28。QPluginLoader
通知静态链接 Darken
插件的存在。
*`Q_IMPORT_PLUGIN( darken )
int main( int argc, char **argv )
`
对应用程序的第三个也是最后一个更改是在FilterDialog
类的findFilters
方法中。清单 11-29 中显示了该方法的更新版本。突出显示的行显示了对QPluginLoader::staticInstances
方法的调用,该方法返回指向所有静态链接插件的QObject
指针。然后可以使用qobject_cast
将指针转换为FilterInterface
指针;如果转换操作不返回 null,则表示找到了筛选器。
与动态加载插件相比,查找文件并加载的步骤已经被staticInstances
调用所取代。这是一个明显的变化,因为插件包含在应用程序的可执行文件中,所以不需要寻找或加载外部文件。
清单 11-29。 查询 QPluginLoader
进行静态链接过滤
void FilterDialog::findFilters()
{
foreach( QObject *couldBeFilter, QPluginLoader::staticInstances() )
{
FilterInterface *filter = qobject_cast<FilterInterface*>( couldBeFilter );
if( filter )
{
filters[ filter->name() ] = filter;
ui.filterList->addItem( filter->name() );
}
}
QDir path( "./plugins" );
foreach( QString filename, path.entryList(QDir::Files) )
{
QPluginLoader loader( path.absoluteFilePath( filename ) );
QObject *couldBeFilter = loader.instance();
if( couldBeFilter )
{
FilterInterface *filter = qobject_cast<FilterInterface*>( couldBeFilter );
if( filter )
{
filters[ filter->name() ] = filter;
ui.filterList->addItem( filter->name() );
}
}
}
}
对应用程序的更改不会改变用户的体验。在前面的例子中,唯一的区别是Darken
过滤器总是可用的,即使没有插件可以加载。
注意,实际使用过滤器的方法也没有发生变化。filterChange
方法不关心插件是如何被链接的。
一个工厂接口
比较图像过滤器的插件接口和图像文件格式的接口,有一个很小但很重要的区别:过滤器插件每个插件只能包含一个过滤器,而由于插件接口的设计方式,一个插件中可以有多种文件格式。文件格式插件可以被认为是一个文件格式工厂,所以插件为应用程序提供文件格式,而不是直接处理它们。
让插件充当工厂可能非常有用,因为使用工厂创建的实际工作类可以共享代码并相互继承。您还可以通过将插件组合成几个大插件来简化部署,而不必处理大量的小插件。通过使用智能工厂接口,甚至可以将几个不同类型的插件组合在一个插件中。
不要把FilterInterface
分成FilterPluginInterface
和FilterWorker
,你可以很容易地扩展FilterInterface
来通过一个接口处理多个过滤操作。这样做需要改变界面本身,这意味着改变所有的插件以及应用程序本身。
新界面
对接口的更改使得每个FilterInterface
可以返回几个名称,并且可以在调用filter
方法时指定过滤器。新FilterInterface
的源代码如清单 11-30 所示(与清单 11-15 和清单 11-16 所示的原始界面进行比较)。
name
方法已经被重命名为names
,并返回一个QStringList
而不是一个QString
。filter
方法被赋予了一个新的参数,指定了要使用的过滤器的名称。最后,传递给Q_DECLARE_INTERFACE
宏的标识符字符串中的版本号已经更新,表明接口已经改变,旧插件不兼容。
清单 11-30。 新的 FilterInterface
可以通过一个界面处理多个滤镜。
class FilterInterface
{
public:
virtual QStringList names() const = 0;
virtual QImage filter( const QString &filter, const QImage &image ) const = 0;
};
Q_DECLARE_INTERFACE( FilterInterface,
"se.thelins.CustomPlugin.FilterInterface/0.2" )
确定是应用程序还是插件负责确保没有无效的过滤器名称作为参数传递给filter
方法是很重要的。如果发生这种情况,插件必须做好准备(不要让整个应用程序崩溃)。
更新插件
将旧插件转换成新界面很容易。在从names
返回之前,只需将名称放入QStringList
中,然后忽略filter
方法中的过滤器名称参数。扩展一个旧插件几乎一样容易。从names
方法中返回几个名称,并通过使用过滤器名称参数来确定在filter
方法中使用哪个过滤器。
清单 11-17 到清单 11-21 中的Flip
滤镜已经扩展到支持水平和垂直翻转。
在清单 11-31 中显示的names
方法中做了小的改变。它现在返回两个QString
,每个过滤器一个。
清单 11-31。 返回几个名字使用一个 QStringList
QStringList Flip::names() const
{
return QStringList() << "Flip Horizontally" << "Flip Vertically";
}
filter
方法如清单 11-32 所示。突出显示的行显示了在哪里对filter
参数进行评估,以确定要做什么。
注意,如果给定了一个意外的过滤器名称,过滤器将执行一个垂直翻转。尽管这可能不是用户所期望的,但它将使应用程序保持运行——所以这是一个处理它的好方法,因为这个问题没有特定的解决方案。也许可以返回一个无效的QImage
,但是整个讨论都是关于一个应用程序 bug 将如何出现(所以不值得在这个问题上浪费太多精力)。更好的保证应用中没有这样的 bug!
清单 11-32。 根据 filter
参数的不同,过滤器的行为也不同。
QImage Flip::filter( const QString &filter, const QImage &image ) const
{
bool horizontally = (filter=="Flip Horizontally");
QImage result( image.width(), image.height(), image.format() );
for( int y=0; y<image.height(); ++y )
for( int x=0; x<image.width(); ++x )
result.setPixel(
horizontally?x:(image.width()-1-x),
horizontally?(image.height()-1-y):y,
image.pixel( x, y ) );
return result;
}
项目不会受到变化的影响,所以重新编译和安装产生的插件是启动和运行所必需的。
更换装载器
在应用程序端,QPluginLoader
仍然与QDir
结合使用,从FilterDialog
中的findFilters
方法中找到并加载插件。然而,对于找到的每个滤波器,可以将几个滤波器添加到QListWidget
和filtersQMap
。新的findFilters
方法如清单 11-33 所示。高亮显示的行显示返回的名称被逐个添加到地图和列表小部件中。将此清单与清单 11-29 进行比较。
清单 11-33。findFilters
方法从每个插件中添加几个滤镜。
void FilterDialog::findFilters()
{
foreach( QObject *couldBeFilter, QPluginLoader::staticInstances() )
{
FilterInterface *filter = qobject_cast<FilterInterface*>( couldBeFilter );
if( filter )
{
foreach( QString name, filter->names() )
{
filters[ name ] = filter;
ui.filterList->addItem( name );
}
}
}
QDir path( "./plugins" );
foreach( QString filename, path.entryList(QDir::Files) )
{
QPluginLoader loader( path.absoluteFilePath( filename ) );
QObject *couldBeFilter = loader.instance();
if( couldBeFilter )
{
FilterInterface *filter = qobject_cast<FilterInterface*>( couldBeFilter );
if( filter )
{
foreach( QString name, filter->names() )
{
filters[ name ] = filter;
ui.filterList->addItem( name );
}
}
}
}
}
当执行实际的过滤操作时,必须将过滤器的名称传递给filter
方法(这是从清单 11-34 中显示的filterChanged
槽中处理的——清单中突出显示了小的变化)。将该清单与清单 11-25 进行比较,看看有什么不同。
清单 11-34。 将过滤器的名称传递给 filter
方法
void FilterDialog::filterChanged( QString filter )
{
if( filter.isEmpty() )
{
ui.filteredLabel->setPixmap( *(ui.originalLabel->pixmap() ) );
}
else
{
QImage filtered = filters[ filter ]->filter( filter,
ui.originalLabel->pixmap()->toImage() );
ui.filteredLabel->setPixmap( QPixmap::fromImage( filtered ) );
}
}
通过对界面进行这些最小的改动,你可以在一个文件中打包几个插件。将这个过程的开发成本与潜在的部署问题进行比较,当您不得不管理带有一个插件的多个文件时,可能会出现这些问题。
非 Qt 插件
几乎所有的插件技术都是通过根据目标平台的标准方法创建一个 DLL 来工作的。这样的库公开了可以用函数指针解析和引用的 C 符号。甚至 Qt 也使用这种方法,但是将其包装在易于使用的类中。如果你在 Windows 平台上使用 Dependency Walker(http://www.dependencywalker.com的免费工具)打开本章前面的 ASCII 艺术图像格式插件(你可以在基于 GCC 的平台上使用objdump
工具),你可以看到两个导出的符号:qt_plugin_instance
和qt_plugin_query_verification_data
。(图 11-7 中显示了该工具的截图。)在内部,QPluginLoader
使用QLibrary
类来连接导出到 DLL 的 C 符号。
注意动态链接库也可以称为共享库(以及 DLL)。
当你想为其他应用程序或你的应用程序的早期非基于 Qt 的版本构建插件支持时,知道如何在较低的级别处理插件是很重要的。这一节向您展示了它是如何完成的,以及如何使用 Qt 来访问为其他应用程序设计的插件或使用其他工具。
图 11-7。 从依赖行者看到的一个 Qt 图片格式插件
让我们来看看您将要连接的简单库的源代码。清单 11-35 展示了sum
函数的实现。该函数所做的只是计算给定数据流的校验和。
清单 11-35。sum
功能全盛
int sum( int len, char *data )
{
int i;
int sum = 0x5a;
for( i=0; i<len; ++i )
sum ^= data[i];
return sum;
}
在 Windows 平台上,我使用清单 11-36 中显示的自定义Makefile
来构建一个 DLL。如果您使用另一个平台,您应该更改结果文件的文件扩展名(清单中显示的文件中的sum.dll
)。在 Unix 上扩展名通常是.so
,在 Mac OS 上是.dylib
。有时,如果文件用作特定应用程序的插件,则使用完全自定义的扩展名。
清单 11-36。 一个 Makefile
用于构建 dll
all: sum.dll
sum.o: sum.c
gcc -c sum.c
sum.dll: sum.o
gcc -shared o sum.dll sum.o
clean:
@del sum.o
@del sum.dll
如果您在构建 DLL 时不得不处理它的文件扩展名,那么当您尝试使用QLibrary
加载它时,Qt 会帮您解决这个麻烦。该类首先尝试加载与指定名称完全相同的库。如果失败,它会在放弃之前尝试使用特定于平台的文件扩展名。
清单 11-37 展示了如何使用QLibrary
来加载sum
DLL。库本身位于应用程序工作目录下的lib
目录中。
使用QLibrary
时的工作顺序是load
、isLoaded
、resolve
。在清单中,DLL 的文件名——没有文件扩展名——在QLibrary
对象的构造器中指定(也可以用setFileName
方法设置)。设置好文件名后,调用load
,然后用isLoaded
测试加载操作的结果。如果isLoaded
返回false
,说明出了问题,库无法加载。这个问题有几个原因:例如,可能找不到文件或文件已损坏。
当库被加载时,是时候尝试解析您想要使用的符号了。在这种情况下,调用resolve
并将字符串sum
作为参数传递。您必须将来自void*
的结果指针转换为适当的函数指针类型(在清单中,该类型为SumFunction
)。如果返回的指针为空指针,则符号无法解析;否则,可以免费使用。
清单 11-37 中成功加载的库和解析的符号的结果是字符串sum of 'Qt Rocks!' = 56
。
清单 11-37。 使用 QLibrary
加载、查找、使用 sum
typedef int (*SumFunction)(int,char*);
int main( int argc, char **argv )
{
QLibrary library( "lib/sum" );
library.load();
if( !library.isLoaded() )
{
qDebug() << "Cannot load library.";
return 0;
}
SumFunction sum = (SumFunction)library.resolve( "sum" );
if( sum )
qDebug() << "sum of 'Qt Rocks!' = " << sum( 9, "Qt Rocks!" );
return 0;
}
使用QLibrary
和让 Qt 帮你做插件的主要区别是什么?对于初学者来说,QPluginLoader
通过查看插件是在正确的平台上使用正确的工具构建的,来确保插件能够与 Qt 应用程序一起工作。QPluginLoader
也让你可以访问一个类实例,而不是一组可以用来创建类实例的 C 符号。
另一方面,QLibrary
使你能够使用没有 Qt 构建的插件。您还可以使您的 Qt 应用程序适应旧的非 Qt 规范。
当您必须使用QLibrary
时,我建议您将代码隐藏在单个类中。这样,您就可以包含该类中的复杂性,并在应用程序的其余部分保持面向对象的 Qt 风格。
总结
Qt 使得处理插件变得容易。通过继承和实现一个接口类,可以扩展 Qt 来处理定制的数据库驱动程序、图像格式,甚至窗口装饰样式。你也可以用插件来扩展你自己的应用,要么让 Qt 处理插件接口,要么通过一个底层接口。
如果你需要连接为其他应用程序制作的插件或者根据标准定义的插件,你可以使用QLibrary
类对 dll 进行底层访问。这个类使得接口几乎任何代码成为可能。
让 Qt 通过QPluginLoader
类结合Q_DECLARE_INTERFACE
、Q_EXPORT_PLUGIN
、Q_INTERFACES
宏和QObject
类来处理插件更容易。
当创建新的插件接口时,构建持久的接口是很重要的。尽可能让接口通用化,让它们像工厂一样工作。能够将几个插件放在一个插件中可以大大简化部署。
如果您计划在应用程序中使用插件,您可以为插件使用与内部功能相同的接口。只需将您希望成为应用程序一部分的基本功能转换成静态链接的插件。这样,从应用程序的角度来看,您只需要担心一个接口,并且您仍然可以将功能放在您的可执行文件中。*******
十二、并行
在编写软件时,你经常会遇到一大块工作必须执行的情况。如果在图形应用程序中书写,图形用户界面有时会冻结。幸运的是,使用线程时可以避免这种情况。
每个应用程序通常作为进程运行。在大多数现代操作系统中,几个应用程序可以同时运行,这意味着几个任务正在并行执行。这两个过程是分离的,互不相关。
在每个进程内部,可以有一个或多个线程在运行。这些线程共享资源和内存,并且需要相互了解。他们也可以合作完成任务,分担繁重的工作。这也有助于多处理器系统高效地工作,因为单个应用程序可以拆分到几个处理器上。
回到最初的问题——用户界面冻结——线程会有所帮助。通过在单独的线程中执行之前冻结应用程序的大量工作,主线程可以专注于更新和响应来自用户界面的事件。
处理器之间线程和进程的分配,以及进程和线程之间的切换,都是由底层操作系统来处理的,所以线程化是一个非常依赖于平台的话题。Qt 提供了线程和进程的公共类,以及让它们协作和共享数据的工具。然而,不同平台的执行顺序、速度和优先级都有所不同,因此在应用中实现线程化时必须格外小心。
基本穿线
让我们先来看看 Qt 的线程类,看看如何使用 Qt 开始使用线程。
重要的是要理解,一旦应用程序启动,它实际上是作为一个线程运行的,称为主线程。这意味着对QApplication::exec
方法的调用是从主线程发出的,而QApplication
对象驻留在该线程中。主线程有时被称为图形用户界面(GUI)线程,因为所有的窗口小部件和其他用户界面对象都必须由这个线程处理。
主线程通常由一个event
循环和一组在该线程中创建的对象组成。通过子类化 Qt QThread
类,您可以创建具有自己的event
循环和对象的新线程。QThread
类代表一个执行在run
方法中实现的工作的线程。通过为您的线程实现一个定制的run
方法,您已经创建了一个独立于主线程的线程,可以执行它的任务。
构建简单的线程应用程序
清单 12-1 展示了一个类的类声明,它实现了一个名为TextThread
的独立线程。您可以看出该类实现了一个单独的线程,因为它继承了QThread
类。当这样做时,也有必要实现run
方法。
线程的构造器接受一个文本字符串,然后在运行时每秒向调试控制台输出一次该文本。
清单 12-1。TextThread
类声明
class TextThread : public QThread
{
public:
TextThread( const QString &text );
void run();
private:
QString m_text;
};
清单 12-2 中的实现了TextThread
类。首先有一个全局变量stopThreads
,用于停止所有线程的执行。通过使用terminate
方法可以停止一个线程,但是这可以与让一个线程崩溃相比较。什么都不清理,不保证成功。
在构造器中,给定的文本被注意到并存储在文本线程的私有成员中。确保调用QThread
构造器,以便正确初始化线程。
在run
方法中,当stopThreads
被设置为true
时,执行进入一个循环。在循环中,在线程使用sleep
方法休眠至少一秒钟之前,使用qDebug
将文本发送到调试控制台。注意sleep
让线程等待至少指定的时间。这意味着睡眠可以持续比指定时间更长的时间,并且在调用sleep
之间花费的睡眠时间可以不同。
提示sleep
方法可以让你暂停一个线程几秒钟。用msleep
,可以用毫秒(千分之一秒)来指定休眠周期;使用usleep
,你可以用微秒(百万分之一秒)来指定睡眠时间。睡眠的可能最短持续时间由硬件和当前软件平台决定。由于这些限制,请求睡眠一微秒很可能会导致更长的睡眠时间。
清单 12-2。TextThread
类实现和全局变量 stopThreads
bool stopThreads = false;
TextThread::TextThread( const QString &text ) : QThread()
{
m_text = text;
}
void TextThread::run()
{
while( !stopThreads )
{
qDebug() << m_text;
sleep( 1 );
}
}
在清单 12-3 中,TextThread
类用于实例化两个对象,只要对话框打开,这两个对象就会启动并保持运行。当用户关闭对话框时,stopThreads
标志被设置为true
,在退出main
函数之前,您等待线程实现这一点。这种等待可能长达一秒钟,因为当标志改变时,线程可能正在休眠。
清单 12-3。 一个应用使用了 TextThread
类
int main( int argc, char **argv )
{
QApplication app( argc, argv );
TextThread foo( "Foo" ), bar( "Bar" );
foo.start();
bar.start();
QMessageBox::information( 0, "Threading", "Close me to stop!" );
stopThreads = true;
foo.wait();
bar.wait();
return 0;
}
在main
函数中,线程对象就像任何其他对象一样被创建。然后使用start
方法启动线程。当线程预计要停止时,主线程通过为每个线程调用wait
方法来等待它们。您可以通过给wait()
一个以毫秒为单位的时间限制,在特定的时间间隔后强制线程停止。否则,不传递参数会导致应用程序一直等到线程停止。当wait
调用返回时,您可以使用isFinished
或isRunning
方法来确定wait
调用是否超时,或者线程是否完成并停止执行。
强制线程终止
如果一个线程停止失败,可以调用terminate
强行结束它的执行。请记住,这很可能会导致内存泄漏和其他问题。如果你使用一个保护标志比如stopThreads
或者为每个线程实现一个stopMe
槽,你就可以强制线程停止,而不必依赖于强力方法比如terminate
。唯一不起作用的时候是线程挂起的时候——这时你正在处理一个应该解决的软件错误。
运行线程化应用
运行应用程序时,您会看到输出"Foo"
和"Bar"
成对出现,如清单 12-4 中的所示。有时顺序会改变,这样"Foo"
会出现在"Bar"
之前,反之亦然,因为sleep
调用会让线程休眠至少一秒钟,操作系统可以以不同于线程休眠时的顺序唤醒线程。
这个结果展示了使用线程时的许多陷阱之一:您永远不能假设任何事情;如果您这样做,在其他平台上的行为可能会略有不同。重要的是只依赖 Qt 文档中的保证——别无其他。
清单 12-4。TextThread
级的试运行
"Foo"
"Bar"
"Bar"
"Foo"
"Bar"
"Foo"
"Bar"
"Foo"
"Bar"
"Foo"
"Foo"
"Bar"
"Bar"
"Foo"
安全同步
有时候你需要让两个或者更多的线程关注其他线程在做什么。这被称为同步线程,这可能发生在一个线程使用另一个线程的结果时;然后,第一个线程需要等待,直到另一个线程实际上已经产生了可以处理的东西。另一个常见的场景是几个线程共享一个公共资源;它们都需要确保没有其他线程同时使用相同的资源。
为了同步线程,你可以使用一个叫做互斥的特殊锁,它可以被锁定和解锁。如果一个不同的线程试图锁定一个已经锁定的互斥体,它将不得不等待直到它被当前的持有者解锁,然后才能锁定它。据说方法阻塞直到它能被完成。锁定和解锁操作是原子的,这意味着它们被视为单个不可见的操作,在执行过程中不能被中断。这很重要,因为锁定互斥体是一个两步过程。首先,线程检查互斥锁没有被锁定;然后将它标记为锁定。如果第一个线程在检查后被中断,然后第二个线程检查并锁定互斥体,第一个线程将认为互斥体在恢复时被解锁。然后,它会将一个已经锁定的互斥体标记为已锁定,这就造成了两个线程都认为它们已经锁定了互斥体的情况。因为锁定操作是原子的,第一个线程在检查和锁定之间不会被中断,因此第二个线程将检查并找到一个锁定的互斥体。
在 Qt 中,互斥是由QMutex
类实现的。锁定和解锁的方法称为lock
和unlock
。另一种方法tryLock
,仅当互斥体不属于另一个线程时才锁定互斥体。
通过修改清单 12-1 、 12-2 和 12-3 中的应用程序,您可以确保"Foo"
和"Bar"
文本总是以相同的顺序出现。清单 12-5 显示了修改后的run
方法。添加的代码行已经突出显示。
添加的行确保每个线程在打印文本和睡眠时持有锁。在此期间,另一个线程也调用lock
,然后阻塞,直到当前持有者解锁互斥体。
必须添加if
语句,因为main
函数可能会在线程阻塞lock
调用时开始关闭。如果它不在那里,被阻塞的线程会在意识到stopThreads
是true
之前输出一次过多的文本。
清单 12-5。 新的 run
方法用互斥量进行排序
QMutex mutex;
void TextThread::run()
{
while( !stopThreads )
{
mutex.lock();
if( stopThreads ){
mutex.unlock();
return;
}
qDebug() << m_text;
sleep( 1 );
mutex.unlock();
}
}
再次运行这个例子,你会看到"Foo"
或"Bar"
每秒打印一次,并且总是以相同的顺序。这使得原始应用程序的速度减半,在原始应用程序中,"Foo"
和"Bar"
都是每秒打印一次。不能保证哪一个文本先被打印出来— bar
可能比foo
更快初始化,即使start
先被foo
调用。订单也不能保证。通过增加执行线程的系统的工作负载或缩短睡眠时间,顺序可以改变。它之所以有效,是因为解锁互斥体的线程到达lock
调用和阻塞需要不到一秒钟的时间。
提示保证线程的顺序是可能的,但是它需要两个互斥体和对run
方法的更大改变。
保护您的数据
互斥不是为了保证线程的顺序;当几个线程试图同时访问数据时,它们保护数据不被破坏。
在详细了解这一点之前,您需要了解实际问题是什么。例如,考虑表达式n += 5
。计算机可能会分三步执行:
- 从存储器中读取
n
。 - 将
5
加到数值上。 - 将值写回到存储
n
的存储器中。
如果两个线程试图同时执行该语句,顺序可能会如下所示:
- 线程 A 读取
n
的原始值。 - 线程 A 将
5
加到该值上。 - 操作系统切换到线程 b。
- 线程 B 读取
n
的原始值。 - 线程 B 将
5
加到该值上。 - 线程 B 将该值写回到存储
n
的内存中。 - 操作系统切换到线程 a。
- 线程 A 将该值写回到存储
n
的内存中。
前面描述的执行结果将是线程 A 和 B 都将值n+5
存储在内存中,并且线程 A 覆盖线程 B 写入的值。结果是n
的值不正确(它应该是n+10
,但它是n+5
)。
通过使用互斥体来保护n
,当线程 A 正在处理它时,你可以防止线程 B 到达该值,反之亦然。一个线程阻塞,而另一个线程工作,因此代码的关键部分是串行执行,而不是并行执行。通过保护类中所有潜在的关键部分不被并行访问,可以从多个线程中安全地调用这些对象。据说这个类是线程安全的。
受保护计数
让线程通过一个TextDevice
对象操作,而不是让TextThread
线程直接向qDebug
写文本。它被称为文本设备,因为它模拟了打印文本的共享设备。要使用设备打印文本,使用write
方法,它将给定的文本写入调试控制台。它还列举了所有文本,这样您就可以知道write
方法被调用了多少次。
在清单 12-6 的中可以看到TextDevice
类声明。该类包含了您所期望的内容:一个构造器,一个write
方法,一个用于枚举调用的计数器,以及一个用于保护计数器的QMutex
。
清单 12-6。TextDevice
类声明
class TextDevice
{
public:
TextDevice();
void write( const QString& );
private:
int count;
QMutex mutex;
};
TextDevice
类的实现展示了一个新技巧。清单 12-7 展示了如何使用QMutexLocker
类来锁定互斥体。互斥锁一构造就锁定互斥体,然后在互斥体被析构时解锁互斥体。
您可以选择显式调用lock
和unlock
的解决方案,但是通过使用QMutexLocker
,您可以确保互斥体被解锁,即使您在方法中途退出return
语句或者到达方法末尾时也是如此。结果是write
方法不能从不同的线程进入两次——调用将被序列化。
清单 12-7。TextDevice
类实现
TextDevice::TextDevice()
{
count = 0;
}
void TextDevice::write( const QString& text )
{
QMutexLocker locker( &mutex );
qDebug() << QString( "Call %1: %2" ).arg( count++ ).arg( text );
}
TextThread
class' run
方法与原来的清单 12-2 相比变化不大。现在调用的是write
方法而不是qDebug
。清单 12-8 中突出显示了这一变化。
m_device
成员变量是指向要使用的TextDevice
对象的指针。它是从构造器中的给定指针初始化的。
清单 12-8。TextThread::run
方法现在调用 write
,而不是直接输出到 qDebug
void TextThread::run()
{
while( !stopThreads )
{
m_device->write( m_text );
sleep( 1 );
}
}
与您在清单 12-3 中看到的相比,main
函数也做了轻微的修改。新版本创建了一个在TextThread
线程对象上传递的TextDevice
对象。新版本可以在清单 12-9 中看到,其中的变化被突出显示。
清单 12-9。 一个 TextDevice
对象被实例化并传递给 TextThread
线程对象
int main( int argc, char **argv )
{
QApplication app( argc, argv );
TextDevice device;
TextThread foo( "Foo", &device ), bar( "Bar", &device );
foo.start();
bar.start();
QMessageBox::information( 0, "Threading", "Close me to stop!" );
stopThreads = true;
foo.wait();
bar.wait();
return 0;
}
构建和执行应用程序会产生一个编号为"Foo"
和"Bar"
的文本列表(在清单 12-10 中可以看到一个例子)。输出的顺序是不确定的,但是枚举总是有效的——这要感谢保护计数器的互斥体。
清单 12-10。 一次试运行的计数 TextDevice
"Call 0: Foo"
"Call 1: Bar"
"Call 2: Bar"
"Call 3: Foo"
"Call 4: Bar"
"Call 5: Foo"
"Call 6: Bar"
"Call 7: Foo"
"Call 8: Bar"
"Call 9: Foo"
"Call 10: Bar"
"Call 11: Foo"
锁定读写
使用互斥体来保护变量有时会导致潜在的性能下降。两个线程可以同时读取共享变量的值而不锁定它,但是如果第三个线程进入场景并试图更新该变量,它必须锁定它。
为了处理这种情况,Qt 提供了QReadWriteLock
类。这个类的工作很像QMutex
,但是它提供了lockForRead
和lockForWrite
方法,而不是lock
方法。就像使用QMutex
一样,你可以直接使用这些方法,也可以使用QReadLocker
和QWriteLocker
类,它们在构造时锁定一个QReadWriteLock
,在被析构时解锁它。
让我们尝试在应用程序中使用QReadWriteLock
。您将更改TextDevice
的行为,这样计数器就不会从write
方法更新,而是从一个名为increase
的新方法更新。TextThread
对象仍将在那里调用write
,但是您将添加另一个线程类来增加计数器。这个名为IncreaseThread
的类只是每隔一段时间调用一个给定的TextDevice
对象的increase
。
让我们先看看新的TextDevice
类的类声明,如清单 12-11 所示。与清单 12-6 中的代码相比,QMutex
被一个QReadWriteLock
取代,并且在接口中加入了increase
方法。
清单 12-11。TextDevice
类声明带 QReadWriteLock
class TextDevice
{
public:
TextDevice();
void increase();
void write( const QString& );
private:
int count;
QReadWriteLock lock;
};
在清单 12-12 所示的实现中,您可以看到对TextDevice
类所做的更改。新方法increase
在改变计数器之前创建一个引用QReadWriteLock
的QWriteLocker
。更新后的write
方法在创建发送到调试控制台的文本时,在使用计数器之前,以同样的方式创建一个QReadLocker
。尽管新实现的保护功能是一个相当复杂的概念,但代码相当容易阅读和理解。
清单 12-12。TextDevice
类实现使用 QReadLocker
和 QWriteLocker
来保护 count
成员变量
TextDevice::TextDevice()
{
count = 0;
}
void TextDevice::increase()
{
QWriteLocker locker( &lock );
count++;
}
void TextDevice::write( const QString& text )
{
QReadLocker locker( &lock );
qDebug() << QString( "Call %1: %2" ).arg( count ).arg( text );
}
IncreaseThread
类与TextThread
类有许多相似之处(类声明如清单 12-13 中的所示)。因为它是一个线程,所以它继承了QThread
。构造器接受一个指向TextDevice
对象的指针来调用increase
,这个类包含一个指向这样一个设备(名为m_device
)的私有指针来保存这个指针。
清单 12-13。IncreaseThread
类声明
class IncreaseThread : public QThread
{
public:
IncreaseThread( TextDevice *device );
void run();
private:
TextDevice *m_device;
};
IncreaseThread
类的实现反映了你从类声明中学到的东西(你可以在清单 12-14 中看到代码)。在构造器中初始化m_device
,调用QThread
构造器初始化基类。
在run
方法中,每 1.2 秒调用一次m_device
的increase
方法,当stopThreads
置为true
时循环停止。
清单 12-14。IncreaseThread
类实现
IncreaseThread::IncreaseThread( TextDevice *device ) : QThread()
{
m_device = device;
}
void IncreaseThread::run()
{
while( !stopThreads )
{
msleep( 1200 );
m_device->increase();
}
}
TextDevice
类不受这些变化的影响,它与清单 12-8 中显示的类相同。main
函数也非常类似于前面的例子。唯一的变化是添加了一个IncreaseThread
对象。清单 12-15 显示了main
函数,并突出显示了添加的行。
清单 12-15。main
功能,设置了一个 TextDevice
,,,和一个 IncreaseThread
int main( int argc, char **argv )
{
QApplication app( argc, argv );
TextDevice device;
IncreaseThread inc( &device );
TextThread foo( "Foo", &device ), bar( "Bar", &device );
foo.start();
bar.start();
inc.start();
QMessageBox::information( 0, "Threading", "Close me to stop!" );
stopThreads = true;
foo.wait();
bar.wait();
inc.wait();
return 0;
}
应用程序输出可以在清单 12-16 中看到。"Foo"
和"Bar"
文本的顺序可以随时改变,计数器以稍微不同的间隔更新,因此有时您会得到具有相同计数器值的四个字符串;有时你会得到两根弦。在某些情况下,您可能最终得到一个带有一个计数器值的单个"Foo"
或"Bar"
(或者三个——如果IncreaseThread
碰巧在来自TextThread
对象的两个write
调用之间调用increase
)。
清单 12-16。TextDevice
用单独的 increase
方法运行
"Call 0: Foo"
"Call 0: Bar"
"Call 0: Foo"
"Call 0: Bar"
"Call 1: Bar"
"Call 1: Foo"
"Call 2: Bar"
"Call 2: Foo"
"Call 3: Bar"
"Call 3: Foo"
"Call 4: Bar"
"Call 4: Foo"
"Call 4: Foo"
"Call 4: Bar"
"Call 5: Bar"
"Call 5: Foo"
线程间共享资源
当访问需要序列化时,互斥锁和读写锁有利于保护共享变量和其他共享项。有时,您的线程不仅需要共享一个变量,还需要共享有限数量的资源,如缓冲区的字节数。这就是信号量的用武之地。
信号量可以看作是计数互斥量,互斥量可以看作是二元信号量。它们实际上是一回事,但是信号量是用一个值而不是一个锁位来初始化的。当您锁定一个互斥体时,您从信号量中获取一个值,这会减少信号量的值。信号量的值永远不能小于零,因此,如果一个线程试图获取比信号量更多的资源,该线程将一直阻塞,直到请求的数量可用。当您完成获取的值时,您将它释放回信号量,这增加了信号量的值。通过释放,可以增加信号量的值,使其超过信号量的初始值。
Qt 类QSemaphore
实现了信号量特性。您可以使用acquire
方法从信号量对象获取一个值,或者如果您不想在请求的值不可用时阻塞,可以使用tryAcquire
方法。如果采集成功,则tryAcquire
方法返回true
,如果请求的数量不可用,则返回false
。使用release
方法将一个值释放回信号量对象。如果想在不影响信号量的情况下知道信号量对象的值,可以使用available
方法。如果信号量表示共享资源的可用性,并且您希望向用户显示有多少资源正在被使用,这将非常方便。
在清单 12-17 中,您可以看到当信号量对象被使用时,可用值是如何变化的。在进行一系列的acquire
和release
调用之前,信号量被初始化为值10
。突出显示的行显示了对tryAcquire
的方法调用失败,因为该调用试图获取比可用的更多的内容。因为调用失败,信号量的可用值保持不变。
清单 12-17。 信号量的可用值因为对象被使用而改变。
QSemaphore s( 10 );
s.acquire(); // s.available() = 9
s.acquire(5); // s.available() = 4
s.release(2); // s.available() = 6
s.release(); // s.available() = 7
s.release(5); // s.available() = 12
s.tryAcquire(15); // s.available() = 12
陷入困境
实现线程系统的最大风险之一是死锁,当两个线程互相阻塞从而都阻塞时,就会发生死锁。因为两个线程都被阻塞,所以都不能释放被另一个线程阻塞的资源。结果是系统冻结。
注意即使只有一个线程,也会发生死锁。想象一个线程试图从一个信号量中获取一个高于可能值的值。
用来形象化这一点的一个最常见的例子是哲学家进餐的问题。图 12-1 展示了一张桌子,五个哲学家坐在上面吃饭。每个人都有一个盘子,盘子的两边都有筷子。
图 12-1。 哲学家们正准备开饭。
哲学家使用的吃饭算法分为五个步骤:
- 拿起左边的筷子。
- 获得合适的筷子。
- 吃吧。
- 松开右边的筷子。
- 松开左边的筷子。
因为所有的哲学家都一样饿,所以他们都立刻拿起左边的筷子。问题是,一个哲学家的左筷子是另一个哲学家的右筷子。因此,当他们试图获得正确的筷子时,他们都会受阻。出现了僵局,他们都饿死了。
如你所见,死锁是危险的,甚至是致命的。那么,它们是如何避免的呢?第一个任务是识别可能发生死锁的潜在危险情况。寻找竞争多个资源的线程,这些线程也在不同的时间获取这些资源。如果每个哲学家都试图在一次操作中获得两只筷子,这个问题就永远不会发生。
当发现潜在的危险情况时,必须消除它。通过不盲目地获得第二根筷子,而是尝试去获得它,可以避免一次阻碍。如果拿不到第二根筷子,松开第一根也很重要,这样可以避免挡住邻居。当错过第二根筷子并返回第一根筷子时,最好的行动是睡一会儿,让相邻的哲学家吃完饭,然后再试图获得两根筷子。这将大致转化为以下算法:
- 拿起左边的筷子。
- 试着找到合适的筷子。
- 如果两个操纵杆都已获得,继续步骤 6。
- 松开左边的筷子。
- 在继续第一步之前思考一会儿。
- 吃吧。
- 松开右边的筷子。
- 松开左边的筷子。
这种进食算法在最坏的情况下可以饿死最多三个哲学家,但至少其中两个会得到食物——避免了僵局。因为对于所有五个哲学家来说,得到两根筷子的概率是相等的,所以在现实生活中,五个哲学家会时不时地吃点东西。
生产者和消费者
信号量派上用场的一个常见线程场景是,一个或多个线程产生数据,一个或多个线程消耗数据。这些线程被称为生产者和消费者。
通常生产者和消费者共享一个用来发送信息的缓冲区。通过让一个信号量跟踪缓冲区中的空闲空间,让另一个信号量跟踪缓冲区中的可用数据,可以让使用者并行工作,直到缓冲区满了或空了(生产者或使用者必须停下来等待,直到有更多的空闲空间或数据可用)。
通过共享循环缓冲区传递数据
为了展示如何使用信号量,您将创建一个由生产者和消费者组成的应用程序。生产者将给定的文本通过循环缓冲区传递给消费者,消费者将把接收到的文本打印到调试控制台。
因为只有一个循环缓冲区,所以你已经将它实现为一组全局变量,如清单 12-18 所示。如果您计划使用几个缓冲区,显而易见的解决方案是在一个类中声明这些全局变量。然后使用缓冲区将每个生产者和消费者引用到该类的一个实例。
缓冲区由大小bufferSize
和实际缓冲区buffer
组成。因为您计划移动QChar
对象,所以缓冲区就是那种类型的。缓冲区还需要两个信号量:一个用于跟踪可用的空闲空间,另一个用于跟踪可用数据项的数量。最后,有一个名为atEnd
的标志,告诉消费者生产者将不再生产数据。
清单 12-18。 创建信号量监控的线程安全缓冲区的变量
const int bufferSize = 20;
QChar buffer[ bufferSize ];
QSemaphore freeSpace( bufferSize );
QSemaphore availableData( 0 );
bool atEnd = false;
缓冲器将从索引0
填充到bufferSize-1
,然后从0
开始增加。在将一个字符放入缓冲区之前,生产者将从freeSpace
信号量中获取。当字符被放入缓冲区时,生产者将释放给availableData
信号量。这意味着,如果没有任何东西消耗缓冲区中的数据,缓冲区将被填充,并且availableData
信号量值将等于bufferSize
,生产者将无法获得任何更多的空闲空间。
应用程序中的生产者类称为TextProducer
。它的构造器期望一个QString
作为参数,并将字符串存储在私有成员变量m_text
中。生产者的工作在清单 12-19 所示的run
方法中执行。如前所述,for
循环遍历文本,然后将QChar
对象逐个放入缓冲区,与消费者同步。当整个文本已经被发送时,atEnd
标志被设置为true
,因此消费者知道整个文本已经被发送。
清单 12-19。run
生产者类的方法
void TextProducer::run()
{
for( int i=0; i<m_text.length(); ++i )
{
freeSpace.acquire();
buffer[ i % bufferSize ] = m_text[ i ];
if( i == m_text.length()-1 )
atEnd = true;
availableData.release();
}
}
消费线程的读取顺序与填充顺序相同——从索引0
到bufferSize-1
,然后再次从0
开始。在读取之前,它试图从availableData
信号量中获取。当从缓冲区中读取一个字符后,它会释放给freeSpace
信号量,因为缓冲区的索引可以被生产者重用。
名为TextConsumer
的消费者类只实现了一个run
方法(参见清单 12-20 )。run
方法的实现非常简单。
清单 12-20。run
消费类的方法
void TextConsumer::run()
{
int i = 0;
while( !atEnd || availableData.available() )
{
availableData.acquire();
qDebug() << buffer[ i ];
i = (i+1) % bufferSize;
freeSpace.release();
}
}
当需要同步生产者和消费者以及他们对缓冲区的访问时,保持对流程发生顺序的控制是非常重要的。在数据被放入缓冲器之前,必须获取空闲空间*,并且在数据被写入缓冲器之后,必须释放可用数据。从缓冲器中取出数据也是如此——在之前获取可用数据,在之后释放空闲空间。在释放自由空间之前更新atEnd
标志也是很重要的,以避免当atEnd
标志为true
时,消费者陷入等待可用数据信号量。使用atEnd
解决方案,还必须有至少一个字节的数据要传输;不然消费者就挂了。一种解决方案是首先传输数据长度,或者最后传输数据结束标记。*
清单 12-21 显示了一个使用TextProducer
和TextConsumer
类的main
函数。它用一些虚构的拉丁文文本初始化生成器,启动两个线程,然后等待它们都完成。它们的启动顺序和等待调用的顺序是不相关的——两个线程都将使用信号量来同步自己。
清单 12-21。 一 main
功能使用 TextProducer
和 TextConsumer
类
int main( int argc, char **argv )
{
QApplication app( argc, argv );
TextProducer producer( "Lorem ipsum dolor sit amet, "
"consectetuer adipiscing elit. "
"Praesent tortor." );
TextConsumer consumer;
producer.start();
consumer.start();
producer.wait();
consumer.wait();
return 0;
}
看前面的例子,注意到有一个与acquire
和release
调用相关的性能成本。使用互斥锁和读写锁也有类似的成本,所以有时将传输的数据分成块可以提高性能。例如,将字符串作为单词而不是一个字符一个字符地发送可能会更快,这意味着在生产者线程中一次为几个字符获取空间,而不是一次一个字符,并且每次进行稍微多一点的处理。当然,这会引入性能损失,即缓冲区并不总是被充分利用,因为即使缓冲区中有空闲空间,生产者有时也会阻塞。
与竞争生产商打交道
生产者-消费者场景的一个常见版本是让几个生产者为一个消费者提供数据。例如,可以有几个工作线程为主线程提供数据。主线程是唯一可以更新用户界面的线程,因此使其成为消费者是合乎逻辑的(它也可以是生产者——一个线程可以同时是生产者和消费者)。
在将几个TextProducer
对象与清单 12-20 中的TextConsumer
类一起使用之前,您需要处理两个问题。第一个问题是atEnd
标志,它需要被转换成信号量。它将在TextProducer
构造器中被释放,并在生产者用完run
方法中的数据时被获取。在消费端,while
循环无法检查atEnd
;用atEnd.available()
代替。
第二个问题是用于写入缓冲区的索引。因为可能有几个生产者更新缓冲区,所以他们必须共享一个必须由互斥体保护的索引。
让我们看看从TextProducer
类开始更新的run
方法(参见清单 12-22 )。突出显示的行显示了共享索引变量index
和它的互斥体indexMutex
。互斥体在包含index++
的行周围被锁定和解锁。那是唯一引用和更新index
的地方。这里不能使用QMutexLocker
,因为这会锁定整个run
方法中的互斥体,并阻塞其他生产者线程。相反,互斥锁必须被锁定尽可能短的时间。
清单 12-22。TextProducer run
方法,更新为处理几个同时发生的生产者
void TextProducer::run()
{
static int index = 0;
static QMutex indexMutex;
for( int i=0; i<m_text.length(); ++i )
{
freeSpace.acquire();
indexMutex.lock();
buffer[ index++ % bufferSize ] = m_text[ i ];
indexMutex.unlock();
if( i == m_text.length()-1 )
atEnd.acquire();
availableData.release();
}
}
TextConsumer
类的run
方法只进行了少量的更新。清单 12-23 中突出显示的一行显示了在while
循环中如何使用atEnd
信号量。将此与清单 12-20 中的进行比较,其中atEnd
是一个标志。
清单 12-23。TextConsumer run
方法,更新为同时交到几个制作人手中
void TextConsumer::run()
{
int i = 0;
while( atEnd.available() || availableData.available() )
{
availableData.acquire();
qDebug() << buffer[ i ];
i = (i+1) % bufferSize;
freeSpace.release();
}
}
请注意,在比较单个生产者版本和多个生产者版本时,生产者和消费者之间使用信号量来获取可用数据和空闲空间的实际交互是不变的。
清单 12-24 显示了一个main
函数,它设置了两个生产者和一个消费者。生产者和消费者被设置和启动;然后,该函数等待它们完成,就像在单生产者版本中一样。
清单 12-24。 一个 main
函数有两个生产者和一个消费者
int main( int argc, char **argv )
{
QApplication app( argc, argv );
TextProducer p1( "this text is written using lower case characters."
"it will compete with text written using upper case characters." );
TextProducer p2( "THIS TEXT IS WRITTEN USING UPPER CASE CHARACTERS!"
"IT WILL COMPETE WITH TEXT WRITTEN USING LOWER CASE CHARACTERS!" );
TextConsumer consumer;
p1.start();
p2.start();
consumer.start();
p1.wait();
p2.wait();
consumer.wait();
return 0;
}
尽管双生产者版本的不同执行的结果有时不同,但是有一个重复的模式。清单 12-25 显示了一次执行的结果。你可以看到小写的生产者先取得控制权,大写的生产者插队,他们转移一两次,其中一个线程领先。开始的线程随时间而变化,并且主导线程变化的次数也随时间而变化。每次重复的模式是两个线程之间的分布不均匀。一个线程总是提供大多数字符。
这种模式的原因是线程被安排在失去焦点之前运行几毫秒。当缓冲区已被填满并且生产者无法获得更多的空闲空间时,任一线程都可以在空闲空间再次出现时取得领先。
清单 12-25。 所收的人物 TextConsumer
this text is writTHteIS TEXT nIS WRITTEN USING UPP ER CASE CHARACTEuRS
!IT WILL COMPEsTE WITH TEXT WiRITTEN USING LOnWER CASE CHARACTgERS!
lower case characters.it will compete with text written using upper
case characters.
跨越线程屏障的信令
到目前为止,您一直依赖共享缓冲区在线程之间传递数据。还有一个稍微贵一点(但简单得多)的解决方案:使用信号和插槽。使用它们可以避免创建和使用缓冲区;相反,您可以在整个应用程序中使用事件驱动的范例。
提示在 Qt 4.0 之前的 Qt 版本中,无法在线程间发送信号。相反,您必须依赖于在线程之间传递自定义事件。这在 Qt 4.0 中仍然支持,但是使用信号和插槽要容易得多。
在线程间传递信号和在一个线程内使用信号是有一些区别的。当在单线程应用程序或单线程中发出信号时,发出调用直接调用所有连接的插槽,发出代码一直等待,直到插槽完成。当向另一个线程中的对象发出信号时,信号被排队。这意味着发出调用将在插槽被激活之前或同时返回。
也可以在一个线程中使用排队信号。你所需要做的就是明确地告诉connect
你想要创建一个排队连接。默认情况下,connect
使用线程内的直接连接和线程间的排队连接。这是最有效的选择,因此自动设置总是有效的,但是如果您指定要排队的连接,您将获得性能。
在线程间传递字符串
让我们从本章开始回到TextThread
和TextDevice
类。不是让文本线程调用文本设备来传递文本,而是发送一个信号。信号将从文本线程传递到主线程中的文本设备。
新的TextThread
类可以在清单 12-26 中看到。突出显示的行显示了添加信号和stop
方法所做的更改。
在早期版本中,该类依赖于一个全局标志变量,该变量指示线程应该暂停执行;在这个版本中,标志m_stop
是内部的,使用stop
方法设置。
为了允许信号,添加了Q_OBJECT
宏,以及一个signals
部分和一个实际信号writeText
,带有一个QString
作为参数。
清单 12-26。TextThread
同 writeText
信号
class TextThread : public QThread
{
Q_OBJECT
public:
TextThread( const QString& text );
void run();
void stop();
signals:
void writeText( const QString& );
private:
QString m_text;
bool m_stop;
};
TextDevice
类已经变成了一个线程——它现在继承了QThread
,并且拥有与TextThread
类相同的停止机制。(类声明可以在清单 12-27 中看到。)突出显示的行显示了Q_OBJECT
宏、public slots
部分和接受QString
作为参数的实际插槽(write
)。
清单 12-27。 将 TextDevice
类声明为线程
class TextDevice : public QThread {
Q_OBJECT`
public:
TextDevice();
void run();
void stop();
public slots:
void write( const QString& text );
private:
int m_count;
QMutex m_mutex;
};`
清单 12-28 显示了TextThread
类的完整实现。这三个方法体看起来都很简单——事实也的确如此。构造器初始化私有成员,并将调用传递给QThread
构造器。stop
方法简单地将m_stop
设置为true
。run
方法由监控所述m_stop
标志的while
循环组成。只要它运行,每秒钟就会发出一次携带m_text
作为自变量的writeText
信号。
清单 12-28。TextThread
类的实现
TextThread::TextThread( const QString&text ) : QThread()
{
m_text = text;
m_stop = false;
}
void TextThread::stop()
{
m_stop = true;
}
void TextThread::run()
{
while( !m_stop )
{
emit writeText( m_text );
sleep( 1 );
}
}
TextDevice run
方法非常简单,因为该类在没有收到信号调用的情况下不执行任何工作。查看清单 12-29 中的,你可以看到这个方法简单地调用exec
进入线程的event
循环,等待信号到达。event
循环一直运行,直到quit
被调用(这是 stop 方法中唯一发生的事情)。
在同一清单中,您还可以看到write
插槽的实现。因为这个插槽可以同时被几个线程调用,所以它使用一个互斥体来保护m_count
计数器。该槽可以作为函数直接调用,也可以被发出的信号调用,所以不能因为信号被一个接一个地排队和服务就忘记这一点。
清单 12-29。write
槽和 TextDevice
类的 run
方法
void TextDevice::run()
{
exec();
}
void TextDevice::stop()
{
quit();
}
void TextDevice::write( QString text )
{
QMutexLocker locker( &m_mutex );
qDebug() << QString( "Call %1: %2" ).arg( m_count++ ).arg( text );
}
使用TextThread
和TextDevice
类很简单。请看清单 12-30 中的main
函数设置两个文本线程和一个设备的例子。
因为数据是通过信号和插槽交换的,所以不同的线程对象不需要知道彼此;它们只是通过对connect
的两个调用相互连接。当连接建立后,它们将被启动,并显示一个对话框。一旦对话框关闭,所有三个线程都将停止。然后,该函数在应用程序结束之前等待它们真正停止。
清单 12-30。 一 main
功能使用 TextThread
和 TextDevice
类
int main( int argc, char **argv )
{
QApplication app( argc, argv );
TextDevice device;
TextThread foo( "Foo" ), bar( "Bar" );
QObject::connect( &foo, SIGNAL(writeText(const QString&)),
&device, SLOT(write(const QString&)) );
QObject::connect( &bar, SIGNAL(writeText(const QString&)),
&device, SLOT(write(const QString&)) );
foo.start();
bar.start();
device.start();
QMessageBox::information( 0, "Threading", "Close me to stop!" );
foo.stop();
bar.stop();
device.stop();
foo.wait();
bar.wait();
device.wait();
return 0;
}
运行这个应用程序会得到类似于清单 12-10 中所示的结果:一个编号字符串的列表。
在线程间发送你自己的类型
无需任何额外的工作,您就可以通过排队连接发送各种类的对象,如QString
、QImage
、QVariant
等等。在某些情况下,您应该在连接中使用自己的类型。这实际上是很常见的,因为大多数应用程序都包含一个或多个自定义类型,这些类型很容易与信号一起传递。
如果你试图通过一个排队的连接传递一个自定义类型,你将会遇到运行时错误,看起来非常类似于清单 12-31 中显示的错误。由于信号及其参数的排队方式,在建立和引发连接时会出现错误。
清单 12-31。 试图通过排队连接传递自定义类型
QObject::connect: Cannot queue arguments of type 'TextAndNumber'
(Make sure 'TextAndNumber' is registed using qRegisterMetaType().)
QObject::connect: Cannot queue arguments of type 'TextAndNumber'
(Make sure 'TextAndNumber' is registed using qRegisterMetaType().)
当一个信号被排队时,它和它的参数一起排队。这意味着参数在传递到插槽之前被复制并存储在一个队列中。为了能够对一个参数进行排队,Qt 需要构造、析构和复制这样一个对象。
为了让 Qt 知道如何做到这一点,所有定制类型都需要使用qRegisterMetaType
进行注册,就像错误消息所说的那样。让我们看看现实生活中是如何做到这一点的。
首先,你需要一些背景知识,了解你想要达到的目标。在线程信号和插槽演示中,您将文本字符串从TextThread
对象发送到了TextDevice
对象。文本设备计算它接收到的字符串的数量。您将通过让TextThread
对象记录它们发送了多少条文本来扩展这一功能。然后,它们会将包含文本及其计数的TextAndNumber
对象发送到文本设备。
TextAndNumber
类是将通过排队连接传递的自定义类型,它将保存一个QString
和一个整数。清单 12-32 显示了它的类声明。
该类本身由两个构造器组成:一个不带参数;另一个接受文本和整数。元类型注册需要不带任何参数的构造器,而另一个构造器是为了方便起见而提供的——稍后在发出时会用到它。text
和number
是公开的,所以您不需要担心它们的 setter 和 getter 方法。
要将该类用作元类型,还必须提供一个公共析构函数和一个公共复制构造器。因为这个类不包含默认版本不能处理的数据,所以不需要显式实现它们。
清单最后突出显示的一行包含对Q_DECLARE_METATYPE
宏的引用。通过将类型传递给这个宏,该类型可以与QVariant
对象结合使用,这是使用qRegisterMetaType
注册它所必需的。
清单 12-32。TextAndNumber
类声明
class TextAndNumber
{
public:
TextAndNumber();
TextAndNumber( int, QString );
int number;
QString text;
};
Q_DECLARE_METATYPE( TextAndNumber );
对qRegisterMetaType
的实际调用来自main
函数,可以在清单 12-33 中的的第一个高亮行中看到。另外两条更改的线路是连接呼叫。自从您传递了QString
对象后,它们已经发生了变化,因为信号和插槽现在都有了新的参数类型。
清单 12-33。 主函数将 TextAndNumber
注册为元类型,并为新的信号和插槽建立连接
int main( int argc, char **argv )
{
QApplication app( argc, argv );
qRegisterMetaType<TextAndNumber>("TextAndNumber");
TextDevice device;
TextThread foo( "Foo" ), bar( "Bar" );
QObject::connect( &foo, SIGNAL(writeText(TextAndNumber)),
&device, SLOT(write(TextAndNumber)) );
QObject::connect( &bar, SIGNAL(writeText(TextAndNumber)),
&device, SLOT(write(TextAndNumber)) );
...
}
对TextDevice
类的更改仅限于write
槽。如清单 12-34 所示,该插槽现在接受一个TextAndNumber
对象作为参数,而不是一个QString
。它打印自己的计数器值、收到的文本和收到的数字。
清单 12-34。TextDevice
write
槽接受一个 TextAndNumber
对象作为自变量**
void TextDevice::write( TextAndNumber tan )
{
QMutexLocker locker( &m_mutex );
qDebug() << QString( "Call %1 (%3): %2" )
.arg( m_count++ )
.arg( tan.text )
.arg( tan.number );
}
TextThread
类得到了稍微多一点的改变,这可以在清单 12-35 中的方法中看到。首先,现在发出的信号带有一个TextAndNumber
参数——这里使用了前面提到的方便的构造器。另一个变化是每个文本线程现在都有一个本地计数器,它在 emit 调用中更新,并且不受任何互斥体的保护,因为它只在一个线程中使用。
清单 12-35。TextThread run
方法现在更新一个计数器并发出一个 TextAndNumber
对象而不是一个 QString
。
void TextThread::run()
{
while( !m_stop )
{
emit writeText( TextAndNumber( m_count++, m_text ) );
sleep( 1 );
}
}
运行所描述的应用程序会产生类似于清单 12-36 中所示的结果。调用由TextDevice
对象计数,而每个字符串的出现次数由每个TextThread
对象计数。如您所见,文本线程的顺序是不受控制的。
清单 12-36。 用线程本地计数器运行文本线程应用
"Call 0 (0): Foo"
"Call 1 (0): Bar"
"Call 2 (1): Bar"
"Call 3 (1): Foo"
"Call 4 (2): Foo"
"Call 5 (2): Bar"
"Call 6 (3): Bar"
"Call 7 (3): Foo"
"Call 8 (4): Foo"
"Call 9 (4): Bar"
"Call 10 (5): Foo"
"Call 11 (5): Bar"
"Call 12 (6): Foo"
"Call 13 (6): Bar"
线程、对象和规则
在关于线程间连接的小节中,您了解到了connect
调用会自动在不同线程中的对象之间创建排队连接。所有的QObject
实例都知道它们属于哪个线程——据说它们具有线程相似性。
有一些限制适用于QObject
和线程:
QObject
的子线程必须与QObject
本身属于同一个线程。- 事件驱动的对象只能在一个线程中使用。
- 所有的
QObject
必须在它们所属的QThread
被删除之前被删除。
第一条规则意味着QThread
本身不应该被用作父线程,因为它是在另一个线程中创建的。
第二条规则适用于定时器和网络套接字等机制。除了计时器或套接字的线程之外,您不能在其他线程中启动计时器或建立套接字连接,因为每个线程都有自己的event
循环。如果你计划在一个线程中使用事件,你必须调用QThread::exec
方法来启动线程的本地event
循环。
第三个规则很容易管理:让您创建的所有对象在线程的run
方法的堆栈上都有一个父对象(或祖父对象)。
理解一个QObject
可以同时在几个线程中使用是很重要的——但是 Qt 提供的大多数对象都被设计成在一个线程中使用,所以你的收获可能会有所不同。
穿线时的陷阱
Qt 的一些部分很容易在单线程中使用。这并不意味着它们不能从一个QThread
对象中使用,或者它们与线程化的应用程序不兼容;最好将所有这样的对象放在一个线程中。如果需要与其他线程交互,可以使用信号、插槽和管理相关对象的线程的方法来执行。
保存在一个线程中的对象类型包括整个 SQL 模块以及QTimer
、QTcpSocket
、QUdpSocket
、QHttp
、QFtp
和QProcess
对象。
“行为不当”的一个例子是从一个线程创建一个QFtp
对象,然后从另一个线程与之交互。这个过程可能会起作用,但它可能会导致神秘且难以调试的问题。为了避免寻找这些幽灵 bug,在使用线程时要小心。
用户界面线程
所有的窗口小部件和用户界面对象必须由主线程(调用QApplication::exec
的线程)处理。这意味着所有用户界面都将充当某种消费者——从执行实际工作的线程那里获得可视化信息。
将应用程序分成这些部分的好处是,当应用程序遇到繁重的任务时,用户界面不会冻结。相反,当处理在另一个线程中进行时,一些QAction
对象可能被禁用。当结果准备好时,它通过缓冲区、自定义事件、共享缓冲区或其他机制反馈给主线程。
文本和带有小工具的数字
为了显示一个用来自线程的数据更新的简单用户界面,您将用一个对话框替换来自TextAndNumber
应用程序的TextDevice
类。来自TextThread
生产者的数据传递是通过信号到插槽的连接完成的。运行应用如图 12-2 中所示。
图 12-2。TextDialog
在行动
*对话框类的类声明可以在清单 12-37 中看到。对话框类被称为TextDialog
,通过showText
插槽接受TextAndNumber
对象。
从类声明中可以学到更多的东西。您可以看到该对话框使用了使用 Designer 制作的设计,因为它包含一个Ui::TextDialog
成员变量。它还有一个专用插槽,用于连接名为buttonClicked
的用户接口信号。
清单 12-37。TextDialog
类声明
class TextDialog : public QDialog
{
Q_OBJECT
public:
TextDialog();
public slots:
void showText( TextAndNumber tan );
private slots:
void buttonClicked( QAbstractButton* );
private:
int count;
QMutex mutex;
Ui::TextDialog ui;
};
对话框如图图 12-2 所示,来自设计器的对象层次结构如图图 12-3 所示。列表小部件和按钮框在实际的对话框中以网格布局排列。
按钮框的关闭按钮连接到对话框的reject
槽来关闭它,而重置按钮会在源代码中连接。
图 12-3。TextDialog
对象层次
*在清单 12-38 中可以看到TextDialog
类的部分实现。您可以看到建立用户界面、将按钮盒连接到buttonClicked
插槽并初始化计数器的构造器。
清单中还显示了buttonClicked
插槽。当点击关闭和重置按钮时,该插槽被调用。通过检查抽象按钮的角色,可以确定是否单击了 Reset。在这种情况下,list 小部件将从它可能包含的任何列表项中清除。
清单 12-38。 用户界面处理部分 TextDialog
TextDialog::TextDialog() : QDialog()
{
ui.setupUi( this );
connect( ui.buttonBox, SIGNAL(clicked(QAbstractButton*)),
this, SLOT(buttonClicked(QAbstractButton*)) );
count = 0;
}
void TextDialog::buttonClicked( QAbstractButton *button )
{
if( ui.buttonBox->buttonRole( button ) == QDialogButtonBox::ResetRole )
ui.listWidget->clear();
}
TextDialog
类实现的剩余部分是showText
槽。它可以在清单 12-39 中看到,并且与清单 12-34 中显示的TextDevice
类的write
插槽几乎相同。这表明两个QThread
对象之间的通信和QThread
对象与主线程之间的通信没有区别。同样的规则适用,同样的限制仍然存在。
清单 12-39。showText
TextDialog
的插槽**
void TextDialog::showText( TextAndNumber tan )
{
QMutexLocker locker( &mutex );
ui.listWidget->addItem( QString( "Call %1 (%3): %2" )
.arg( count++ )
.arg( tan.text )
.arg( tan.number ) );
}
启动线程和显示对话框的main
函数与清单 12-33 中的相比没有太大变化,除了用TextDialog
代替了TextDevice
。对话框现在作为线程启动,但在QApplication::exec
启动前显示。当该调用返回时,TextThread
线程停止并等待来自exec
调用的返回值返回。
在图 12-2 中可以看到该应用程序的运行。注意,您可以在 list 小部件中上下移动,并独立于两个线程清除它;他们会在主线程中发生任何事情的同时继续添加条目。
处理流程
与线程密切相关的是进程,它可以由几个线程组成,但不像线程那样共享内存和资源。属于单个进程的线程共享内存和资源,并且都是同一应用程序的一部分。一个进程就是你通常所说的另一个应用程序。它有自己的内存和资源,过着自己的生活。Qt 通过QProcess
类处理进程。
如果从应用程序中启动一个进程,则通过通道(称为标准输入、标准输出和标准错误通道)与它进行通信。这些是控制台应用程序可用的通道,数据仅限于字节流。
运行 uic
要使用使用QProcess
类的进程编写文本,您将构建一个启动uic
的小应用程序。uic
应用程序是一个很好的玩法,因为如果你是 Qt 开发者,你就可以使用它(它和 Qt 捆绑在一起)。uic
应用程序产生标准输出和标准误差的输出。它还可以处理您传递给它的一些不同的参数。
使用QProcess
的应用程序由一个简单的对话框类ProcessDialog
组成(参见图 12-4 )。在清单 12-40 中可以看到类声明。突出显示的行显示了与QProcess
级可用信号相匹配的插槽范围。
清单 12-40。ProcessDialog
类声明
class ProcessDialog : public QDialog
{
Q_OBJECT
public:
ProcessDialog();
private slots:
void runUic();
void handleError( QProcess::ProcessError );
void handleFinish( int, QProcess::ExitStatus );
void handleReadStandardError();
void handleReadStandardOutput();
void handleStarted();
void handleStateChange( QProcess::ProcessState );
private:
QProcess *process;
Ui::ProcessDialog ui;
};
从QProcess
类发出的信号可用于监控已启动流程的进度或故障:
- 进程遇到了某种内部错误。
started()
:流程已经开始。finished( int code, QProcess::ExitStatus status )
:进程已经退出。readyReadStandardError()
:有数据要从标准错误通道读取。readyReadStandardOutput()
:标准输出通道有数据要读取。stateChanged( QProcess::ProcessState newState )
:流程进入了一个新的状态。
当有数据准备读取时,您可以使用readAllStandardError
方法或readAllStandardOutput
方法读取,具体取决于您感兴趣的通道。使用 set standardOutputFile
和setStandardErrorFile
,您可以将任一通道的输出重定向到一个文件。
过程状态可以在三种状态NotRunning
、Starting
和Running
之间变化。当进入NotRunning
时,你就知道这个过程已经结束或者即将结束。状态变为NotRunning
后可以接收结束信号,但错误信号一般在stateChanged
信号之前发出。
在您可以接收任何信号之前,您需要从runUic
槽开始一个新的进程。你可以在清单 12-41 的中看到插槽实现。在创建一个新的QProcess
对象和设置连接之前,非高亮显示的行禁用用户界面并清除用于显示应用程序输出的QTextEdit
小部件。
突出显示的行显示了如何初始化和启动流程。首先,在调用start
之前,参数被组装到一个QStringList
对象中。start
调用将可执行文件的名称和参数作为参数。在start
方法调用之后,就是等待信号的到来。
清单 12-41。 一个 QProcess
对象被创建、连接并启动。
void ProcessDialog::runUic()
{
ui.uicButton->setEnabled( false );
ui.textEdit->setText( "" );
if( process )
delete process;
process = new QProcess( this );
connect( process, SIGNAL(error(QProcess::ProcessError)),
this, SLOT(handleError(QProcess::ProcessError)) );
connect( process, SIGNAL(finished(int,QProcess::ExitStatus)),
this, SLOT(handleFinish(int,QProcess::ExitStatus)) );
connect( process, SIGNAL(readyReadStandardError()),
this, SLOT(handleReadStandardError()) );
connect( process, SIGNAL(readyReadStandardOutput()),
this, SLOT(handleReadStandardOutput()) );
connect( process, SIGNAL(started()),
this, SLOT(handleStarted()) );
connect( process, SIGNAL(stateChanged(QProcess::ProcessState)),
this, SLOT(handleStateChange(QProcess::ProcessState)) );
QStringList arguments;
arguments << "-tr" << "MYTR" << "processdialog.ui";
process->start( "uic", arguments );
}
当信号到达时,插槽将使输出在用于显示执行结果的QTextEdit
小部件中可见。因为几乎所有插槽看起来都一样,所以看一下handleFinish
。你可以在清单 12-42 中看到源代码。
插槽通过一个switch
语句传递枚举类型,将其转换成一个字符串。然后,它将生成的文本作为新段落以粗体追加到文本编辑中。所有粗体文本都是状态消息,而正常粗细的文本是应用程序的实际输出。
清单 12-42。handleFinish
槽实现
void ProcessDialog::handleFinish( int code, QProcess::ExitStatus status )
{
QString statusText;
switch( status )
{
case QProcess::NormalExit:
statusText = "Normal exit";
break;
case QProcess::CrashExit:
statusText = "Crash exit";
break;
}
ui.textEdit->append( QString( "<p><b>%1 (%2)</b><p>" )
.arg( statusText )
.arg( code ) );
}
运行该应用程序显示了在流程生命周期的不同阶段发出的不同信号。图 12-4 显示了成功执行的结果。发出的信号如下:
stateChanged( Starting )
started()
readyReadStandardOutput()
(几次)stateChanged( NotRunning )
finished( 0, NormalExit )
图 12-4。uic
流程成功运行并完成。顶部图像显示输出文本的顶部;底部的图像显示了同一文本的结尾。
注意您使用 append 调用将应用程序的输出添加到QTextEdit
中,这将导致每个新的文本块作为一个新段落被添加。这就是为什么截图中的输出看起来有点奇怪。
图 12-5 中的运行显示了一个因失败而退出的流程。问题是启动的uic
实例找不到指定的输入文件。发出的信号如下:
stateChanged( Starting )
started()
readyReadStandardError()
(可能几次)stateChanged( NotRunning )
finished( 1, NormalExit )
如您所见,除了输出被发送到标准错误通道而不是标准输出通道之外,唯一真正的区别是退出代码非零。这是约定,但不保证。从QProcess
对象的角度来看,执行进行得很顺利——所有问题都由启动的可执行文件处理。
图 12-5。uic
流程因出错退出;它找不到指定的输入文件。
*如果您为进程指定了一个无效的可执行文件名称,问题将会在进程启动之前出现。这导致了图 12-6 中所示的信号:
stateChanged( Starting )
error( FailedToStart )
stateChanged( NotRunning )
该故障由QProcess
对象检测,并通过error
信号报告。将不会有任何完成的信号或输出要读取,因为该过程永远不会到达Running
状态。
图 12-6。 进程无法启动,因为指定的可执行文件丢失。
外壳和方向
使用流程时有几个常见的障碍。第一个原因是命令行 shell 在将参数传递给可执行文件之前对其进行了处理。例如,在 Unix shell 中编写uic *.ui
会将所有匹配*.ui
的文件名作为参数提供给uic
。当使用QProcess
启动进程时,您必须注意它并找到实际的文件名(使用一个QDir
对象)。
第二个问题与第一个问题密切相关。管道由命令行 shell 管理。命令ls −l | grep foo
确实意味着 shell 将−l | grep foo
作为参数传递给ls
,但是如果您开始使用QProcess
,就会发生这种情况。相反,您必须将ls −l
作为一个进程运行,并将结果数据传递给另一个运行grep foo
的进程。
这就把你带到了最后一个障碍:渠道的方向。流程的标准输出是您的输入。进程所写的就是你的应用程序所读的。这也适用于标准错误通道——进程向它写入数据,以便应用程序从中读取数据。标准输入正好相反——进程从中读取数据,因此应用程序必须向其写入数据。
总结
使用线程会增加应用程序的复杂性,但会提高性能。随着多处理器系统变得越来越普遍,这一点尤其重要。
开发多线程应用程序时,必须确保不要对时间或性能做任何假设。你永远不能依赖于以一定的顺序或速度发生的事情。如果您意识到了这一点,那么开始真的很容易—只需继承QThread
类并实现run
方法。
使用QMutex
和QMutexLocker
类可以很容易地保护共享资源。如果您主要是从一个值中读取,为了获得更好的性能,更好的选择是将QReadWriteLock
与QReadLocker
和QWriteLocker
结合使用。对于大量使用的共享资源,QSemphore
是您的最佳选择。
线程化时,必须确保QObject
实例被保持在一个线程中。您可以从创建对象的线程之外的线程访问QObject
的成员。只要确保保护任何共享数据。一些QObject
衍生工具根本不打算共享:网络类、整个数据库模块和QProcess
类。图形类更挑剔——它们必须在主线程中使用。********************************
十三、数据库
D 即使是最简单的现代应用程序,数据库也是不可或缺的一部分。虽然大多数读者可能倾向于将数据库与网站和大型企业解决方案联系起来,但您可能会惊讶地发现,您还可以使用数据库来存储 Qt 应用程序中管理的数据。
Qt 为关系数据库提供了一个叫做QtSql
的模块。 SQL ,代表结构化查询语言,是一种用于操作关系数据库的语言。使用 SQL,您可以在不同的数据库引擎和应用程序之间进行通信。
Qt 支持许多最流行的数据库,包括 MySQL、Oracle、PostgreSQL、Sybase、DB2、SQLite、Interbase 和 ODBC。这些驱动可以作为插件构建,也可以集成在 Qt 中。
在这一章中,你将学习如何将 MySQL 和 SQLite 数据库与你的 Qt 应用程序集成。您可能会在更复杂的情况下使用 MySQL,当数据库很方便时使用 SQLite,但是功能齐全的数据库服务器可能会被认为是多余的。
SQL 快速介绍
在开始学习一些基本的 SQL 语句之前,您应该理解 SQL 是另一种编程语言。这本书不会教你用 SQL 开发;它只会显示最基本的信息。您还需要知道 Qt 支持的不同数据库服务器支持不同的 SQL 方言。这意味着,与 SQLite 连接相比,MySQL 连接的语句看起来可能略有不同。通过坚持最基本的原则,这些差异是可以避免的,但是请准备好阅读 SQL 手册中您选择使用的数据库引擎。
本节中使用和描述的语句已经过 MySQL 和 SQLite 的测试,所以不会有方言上的问题。
注意一条 SQL 语句也被称为查询,因为有些语句是用来查询数据库信息的。
什么是数据库?
本章的其余部分将讨论关系数据库,即表的集合。每个表都有一个名称和一组列和行。列定义了表格的结构,而行包含数据。然后,这些表通过关系连接在一起,在关系中,不同表中的列值相互链接。
每一列都有一个名称和一个类型,这使得控制内容的去向和按名称检索成为可能。您还可以控制允许的内容,以便用默认值替换NULL
值,或者您可以完全禁止NULL
值。
行包含由列定义的数据。当您使用数据库时,通常会搜索行、添加行、更新行或删除行。
您需要做的第一件事是创建一个实际的数据库,创建它的方式取决于您计划使用的数据库服务器。有关详细信息,请参阅服务器的用户手册。
在开始添加行之前,您需要使用CREATE TABLE
命令创建一个表。我们把桌子叫做names
。下面的语句创建一个表,该表有一个名为id
的整数列和两个名为firstname
和lastname
的字符串:
CREATE TABLE names (
id INTEGER PRIMARY KEY,
firstname VARCHAR(30),
lastname VARCHAR(30)
)
在语句中,您将`id`指定为`PRIMARY KEY`,这意味着在同一个表中不能有两个相同的`id`值。您可以通过`id`列来识别每一行,这可以在搜索数据时使用。
使用的类型是用于整数值的`INTEGER`和用于字符串的`VARCHAR(30)`。`VARCHAR`类型是一个可变长度的字符串。括号内的值限制了字符串的长度,因此`firstname`和`lastname`字符串必须少于或等于 30 个字符。
该语句的一个问题是,如果该表已经存在,它将失败。您可以通过添加`IF NOT EXISTS`来创建以下语句来解决这个问题:
CREATE TABLE IF NOT EXISTS names (
id INTEGER PRIMARY KEY,
firstname VARCHAR(30),
lastname VARCHAR(30)
)
该语句添加该表,或者如果该表已经存在,则忽略它。
要删除一个表,使用`DROP TABLE`命令。要删除刚刚创建的`names`表,只需执行以下命令:
DROP TABLE names
插入、查看、修改和删除数据
与数据库交互所需的最基本操作是查看、添加、修改和删除存储在表中的数据。一旦你把桌子摆好,剩下的时间你就要做这个了。这四个操作组成了有时所谓的 *CRUD* 接口(代表*创建、读取、更新*和*删除*)。
执行这些任务的 SQL 命令包括用于添加数据的`INSERT`、用于查看的`SELECT`、用于修改的`UPDATE`和用于删除的`DELETE`。所有这四项任务都将在以下章节中介绍。
**插入数据**
将一个名字插入到`names`表中很容易。使用`INSERT INTO`语句,您可以列出列名,后跟`VALUES`关键字和实际值:
INSERT INTO names (id, firstname, lastname) VALUES (1, 'John', 'Doe')
可以跳过列名,但这意味着您依赖于表中列的顺序 Murphy 告诉您,如果您依赖于它,这种情况一定会改变。虽然我将命令放在一行中,但是为了可读性,请随意将较大的查询分成多行,因为 SQL 对换行符不敏感。
当向`names`表中插入项目时,您可以让数据库自动生成`id`值,方法是告诉它在创建表时该列将`AUTOINCREMENT`。
**注意**这个特性被 SQLite 称为`AUTOINCREMENT`,被 MySQL 称为`AUTO_INCREMENT`,但是其他数据库可能根本不支持它。这意味着表创建语句可能不兼容。
**查看数据**
当您将数据放入数据库后,您需要能够检索并查看它。这是`SELECT`命令进入画面的地方。该命令可用于转储表的全部内容,但也可以指示它查找特定数据、对其排序、分组并执行计算。
让我们从询问`names`表的全部内容开始:
SELECT * FROM names
这一行返回整个`names`表,如下所示。我已经执行了额外的`INSERT`语句。`SELECT`和`FROM`之间的星号表示您对所有栏目都感兴趣。
| **id** | **名字** | **姓氏** |
| --- | --- | --- |
| `1` | `John` | `Doe` |
| `2` | `Jane` | `Doe` |
| `3` | `James` | `Doe` |
| `4` | `Judy` | `Doe` |
| `5` | `Richard` | `Roe` |
| `6` | `Jane` | `Roe` |
| `7` | `John` | `Noakes` |
| `8` | `Donna` | `Doe` |
| `9` | `Ralph` | `Roe` |
该表中显示了许多不同的姓氏,所以让我们查询数据库中所有姓氏为 Roe 的个人。为此,SELECT
语句是由和一个WHERE
子句组合而成的。id
列并没有那么有趣,所以要求使用firstname
和lastname
列,而不是使用星号:
SELECT firstname, lastname FROM names WHERE lastname = 'Roe'
下表显示了查询的结果:
| 西方人名的第一个字 | 姓 | | --- | --- | | `Richard` | `Roe` | | `Jane` | `Roe` | | `Ralph` | `Roe` |WHERE
子句包含几个比较,可以使用AND
、OR
、NOT
和括号组合起来,形成更复杂的过滤器。
请注意,上表中名字的顺序并不理想。您可以使用ORDER BY
子句来指定排序顺序:
SELECT firstname, lastname FROM names WHERE lastname = 'Roe' ORDER BY firstname
该命令的结果如下表所示(顺序已固定):
| 西方人名的第一个字 | 姓 | | --- | --- | | `Jane` | `Roe` | | `Ralph` | `Roe` | | `Richard` | `Roe` |另一个可以与SELECT
语句一起使用的子句是GROUP BY
,它将结果分组。它可以与COUNT(*)
函数结合使用,后者表示找到的行数。如果你按姓氏分组,你可以计算每个家庭的成员人数:
SELECT lastname, COUNT(*) as 'members' FROM names GROUP BY lastname ORDER BY lastname
下表显示了该命令的结果。我使用关键字AS
将计算列命名为members
。我还对lastname
一栏中的进行了排序,以便姓氏按字母顺序出现:
修改数据
更改数据库表中存储的数据是通过UPDATE
语句处理的。在与一个WHERE
子句结合之后,现在可以控制这些更改了。因为id
列对于每一行都是唯一的,所以它可以用来更改一个人的姓名。下面一行将约翰·诺克斯重命名为尼西·斯文森:
UPDATE names SET firstname = 'Nisse', lastname = 'Svensson' WHERE id = 7
在本例中,WHERE
子句用于将更新限制在id
值为7
的行。更改用逗号分隔,您可以更改firstname
和lastname
字段。
您可以使用更开放的WHERE
子句一次更新几行。下面一行更改所有行的lastname
字段,其中firstname
是 Jane 它将 Jane Doe 和 Jane Roe 重新命名为 Jane Johnson:
UPDATE names SET lastname = 'Johnson' WHERE firstname = 'Jane'
注意省略WHERE
子句会将更改应用于表中的所有行。
删除数据
DELETE
语句用于从数据库表中删除数据。它看起来非常像UPDATE
语句——通过使用一个WHERE
子句来指定要从哪个表中删除哪些行。
您可以从删除 Nisse Svensson(以前称为 John Noakes)行开始:
DELETE FROM names WHERE id = 7
就像更新一样,您可以使用不太具体的WHERE
子句一次删除几行。以下语句删除了从两个 Janes 创建的两个 Johnsons:
DELETE FROM names WHERE lastname = 'Johnson'
更多的桌子意味着更多的权力
当您使用数据库时,您通常需要几个包含同一事物不同方面信息的表。通过将JOIN
子句与SELECT
一起使用,您仍然可以通过一个查询提取您需要的信息。
通过指定一个关系来连接表——您定义了什么将两个表联系在一起。
在这里使用的数据库中,有另一个名为salaries
的薪水表。立柱为id
和annual
,均为INTEGER
型。id
列用于将薪水与names
表中的个人相关联(这是表之间的关系),而annual
列保存每个个人的年收入。该表的内容如下所示(注意,表中缺少id
的一些值):
现在您可以从names
中SELECT
并请求数据库JOIN
表names
和salaries ON
列id
。这在 SQL 中表示如下:
SELECT names.firstname, names.lastname, salaries.annual FROM names JOIN salaries ON names.id = salaries.id
该语句的结果如下所示(未在两个表中显示的行被省略):
| 西方人名的第一个字 | 姓 | 年刊 | | --- | --- | --- | | `John` | `Doe` | `1000` | | `Jane` | `Doe` | `900` | | `James` | `Doe` | `900` | | `Richard` | `Roe` | `1100` | | `Jane` | `Roe` | `1000` | | `Donna` | `Doe` | `1200` | | `Ralph` | `Roe` | `1200` |要从names
表中获取所有行,用LEFT JOIN
替换JOIN
。所有行都从第一个表(语句中左边的那个表)返回。结果是这样的:
SELECT names.firstname, names.lastname, salaries.annual FROM names LEFT JOIN salaries ON names.id = salaries.id
salaries
表中未显示的行获得值NULL
。查询结果如下表所示:
当处理包含多个表的数据库时,拥有一个规范化的结构是很重要的。正常情况下,任何信息都不应该出现一次以上。相反的一个例子是如果salaries
表包含lastname
和id
。在这种情况下,改变lastname
需要两次UPDATE
调用。
到目前为止使用的表格都非常简单,但是要记住只将数据保存在一个地方(这有时可能需要额外的id
列来将事情联系在一起)。这是一个值得花的时间,因为它使结构更容易工作。
对 SQL 的介绍仅仅触及了数据库设计和连接语句的皮毛。在实现一个复杂的数据库之前,还需要考虑更多的方面,还有许多其他的连接表和创建关系的方法。其中一些是标准化的,另一些则非常依赖于您正在使用的数据库服务器。在实现任何复杂的数据库设计之前,我建议您查阅数据库服务器的文档以及相关书籍。
计数和计算
查询数据时,数据库可以在返回数据之前对数据进行计算。在本章的前面,您已经看到了这样一个例子,使用COUNT(*)
来计算每个lastname
的家庭成员数量。
SQL 中有一系列可用的数学函数。一些最常见的包括SUM
、MIN
和MAX
,它们用于汇总一列的值或获得最小值或最大值。这些功能为您提供了一个强大的工具。在SELECT
语句中使用时,可以将这些函数与GROUP BY
子句结合起来,根据行组计算结果。
这些计算的结果可以使用普通的算术运算进行组合,例如+
、-
、*
和/
。以下语句使用SUM
函数、除法和COUNT(*)
来计算每个家庭的平均年薪:
SELECT names.lastname, SUM(salaries.annual)/COUNT(*) AS 'Average', MIN(salaries.annual) AS 'Minimum', MAX(salaries.annual) AS 'Maximum' FROM names LEFT JOIN salaries ON names.id = salaries.id GROUP BY names.lastname
因为您执行了左连接,所以没有收入的家庭成员将包含在COUNT(*)
中,但不包含在汇总和挑选最小值和最大值的函数中。这意味着那些被命名为 Doe 的人的最低工资保持在 900,但平均工资计算为 800。下表显示了该语句的完整结果:
让数据库对您的数据执行许多有趣的功能是很容易的,这既有好处也有坏处。潜在的负面后果可能是中央服务器的工作负载更重。好处是通过网络发送的数据更少,客户端代码也不太复杂。
Qt 和数据库
Qt 处理和连接数据库的类可以分为三组。第一层基于一组数据库驱动程序,这使得使用 Qt 访问不同类型的数据库服务器成为可能。
第二层处理与数据库的连接、查询及其结果,以及来自数据库服务器的错误消息。这一层基于驱动程序层,因为连接到数据库需要驱动程序。
第三层称为用户界面层,它提供了一组与 Qt 的模型视图框架一起使用的模型。
注意建议您在开发新软件而非实时版本时使用测试数据库。SQL 语句中很容易出现错误,导致整个数据库的内容变得无用。使用开发数据库而不是生产数据库(用于真实的东西)可以为您省去很多麻烦。最好的情况是,您不必从备份中恢复数据库;在最坏的情况下,它可以挽救你的工作。
连接
每个数据库连接由一个QSqlDatabase
对象表示,这些连接是通过一个驱动程序建立的。选好司机后,可以设置hostName
、databaseName
、userName
、password
等相关属性。连接建立后,您必须先open
它,然后才能使用它。
为了避免传递同一个QSqlDatabase
对象,整个QtSql
模块都有默认连接的概念。只要您一次连接到一个数据库,所有与数据库交互的类都已经知道使用哪个连接。
清单 13-1 显示了一个正在建立的到 MySQL 服务器的连接。这个过程很简单。首先,通过静态的 QSqlDatabase::addDatabase
方法,使用QMYSQL
驱动程序添加一个数据库连接。因为您只传递了一个驱动程序名,而没有传递连接名,所以它将是默认连接。
然后设置返回的QSqlDatabase
对象。设置了hostName
、databaseName
、userName
和password
的属性。然后使用open
方法打开数据库连接。如果返回false
,则连接未建立。失败的原因通过一个QSqlError
对象返回,这个对象可以通过使用lastError
方法获得。如果返回true
,则连接已经成功建立。
注意连接数据库时可以使用的属性有hostName
、databaseName
、userName
、password
、port
和connectOptions
。这些属性的内容取决于所使用的数据库驱动程序。
清单 13-1。 连接到 MySQL 服务器
`QSqlDatabase db = QSqlDatabase::addDatabase( "QMYSQL" );
db.setHostName( "localhost" );
db.setDatabaseName( "qtbook" );
db.setUserName( "user" );
db.setPassword( "password" );
if( !db.open() )
{
qDebug() << db.lastError();
qFatal( "Failed to connect." );
}`
清单 13-2 展示了如何使用QSQLITE
驱动程序连接到 SQLite 数据库。SQLite 数据库不同于 MySQL 数据库,因为它不基于服务器,所以您不需要使用用户名和密码登录数据库。相反,您只需通过databaseName
属性指定一个文件名。该文件包含数据库,并在连接成功打开时打开或创建。
清单 13-2。 连接到 SQLite 文件
QSqlDatabase db = QSqlDatabase::addDatabase( "QSQLITE" );
db.setDatabaseName( "testdatabase.db" );
if( !db.open() )
{
qDebug() << db.lastError();
qFatal( "Failed to connect." );
}
SQLite 数据库引擎的一个很好的特性是可以在内存中创建数据库。这意味着执行速度非常快,因为不需要从磁盘加载和保存到磁盘。如果希望信息在应用程序终止后仍然存在,就必须将它显式地存储到一个文件或另一个数据库中。
通过指定文件名":memory: "
,如下面的代码行所示,数据库将包含在内存中:
db.setDatabaseName( ":memory:" );
当一个`QSqlDatabase`对象代表一个不再使用的连接时,您可以使用`close`方法关闭它。任何打开的连接都会被`QSqlDatabase`析构函数自动关闭。
查询数据
当向数据库传递 SQL 查询时,使用一个`QSqlQuery`对象来表示查询和从数据库引擎返回的结果。让我们从一个简单的`SELECT`查询开始。
清单 13-3 显示了一个正在执行的查询。SQL 语句被简单地传递给一个`QSqlQuery`对象的`exec`方法。如果执行失败,`exec`方法返回`false`。失败后,查询对象的`lastError`方法包含更多关于出错的信息。因为您正在处理一个被客户端应用程序查询的服务器,所以不一定是 SQL 语句错了,也可能是连接失败、用户身份验证问题或许多其他原因。
**清单 13-3。** *准备和执行 SQL 查询*
if( !qry.exec( "SELECT firstname, lastname FROM names "
"WHERE lastname = 'Roe' ORDER BY firstname" ) )
qDebug() << qry.lastError();
如果查询的执行顺利完成,就该查看结果了。清单 13-4 展示了这是如何做到的。首先检索一个`QSqlRecord`。记录代表结果中的一行,您可以使用`count`方法获得列的总数。从`fieldName(int)`方法中可以得到返回列的名称。使用这两种方法,在第一个`for`循环中创建一个包含列名的字符串。
在`while`循环中,通过使用`next`方法从`QSqlQuery`对象请求第一个结果行。当查询对象从成功的`exec`调用返回时,当前行是空的(即`NULL`)。这表示为`isValid`是`false`。当调用`next`时,返回结果中的下一行(如果可用)。第一次调用该方法时,调用第一行。当调用试图移动到最后一个可用行之外时,返回值是`false`。
**注意**`next`方法只对`SELECT`查询有效。您可以用`isSelect`方法查看一个`QSqlQuery`对象是否是一个`SELECT`查询。
对于每一行,使用`value(int)`方法收集列中的值。`value`方法返回一个`QVariant`,因此必须使用`toString`方法将其转换为`QString`。不同的列可以有不同的值,所以没有必要使用`toString`方法。`QVariant`类有将值转换成大多数类型的方法。最常见的有`toInt`、`toDouble`、`toBool`、`toString`。
**清单 13-4。** *遍历列名和结果行*
QSqlRecord rec = qry.record();
int cols = rec.count();
QString temp;
for( int c=0; c<cols; c++ )
temp += rec.fieldName(c) + ((c<cols-1)?"\t":"");
qDebug() << temp;
while( qry.next() )
{
temp = "";
for( int c=0; c<cols; c++ )
temp += qry.value(c).toString() + ((c<cols-1)?"\t":"");
qDebug() << temp;
}
在前面的清单中,您将整个 SQL 查询作为整个字符串传递。这对于简单的查询可能有用,但是一旦开始向查询中添加用户输入,就可能会出现问题。例如,如果用户在清单 13-3 中提供了`lastname`字符串,那么如果该名称包含单引号`(')`,就会出现问题。处理浮点值也是一个问题,因为不同地区的十进制字符不同。
这些问题的解决方案是在执行查询之前的准备阶段*绑定*查询中使用的值。清单 13-5 展示了如何为一个`INSERT`查询做这件事。查询的准备是一个可选步骤,可能包括对一些数据库进行语法检查,而其他数据库将在执行时失败。如果语法检查失败,`prepare`调用将返回`false`。因为您之前已经测试过 SQL 语句,所以您不必检查它。然而,即使语句已经过测试,`exec`调用仍然可能由于数据库连接的问题而失败。
在清单 13-5 中,查询是用`prepare`方法准备的。查询中放置的不是实际值,而是占位符。占位符由以冒号(`:`)为前缀的名称组成。准备好查询后,使用`bindValue(QString,QVariant)`将一个值绑定到每个占位符。
**注意**您可以使用一个问号(`?`)作为占位符,然后使用`addBindValue(QVariant)`从左到右将值绑定到它。我建议不要使用这种方法,因为在使用带有命名占位符的代码时,这种方法更容易修改,也更不容易出错。
**清单 13-5。** *将值绑定到一个包含* `INSERT` *的查询调用*
qry.prepare( "INSERT INTO names (id, firstname, lastname) "
"VALUES (:id, :firstname, :lastname)" );
qry.bindValue( ":id", 9 );
qry.bindValue( ":firstname", "Ralph" );
qry.bindValue( ":lastname", "Roe" );
if( !qry.exec() )
qDebug() << qry.lastError();
建立多个连接
如果您需要一次使用几个数据库连接,您必须给它们命名。如果未指定连接名称,则始终使用默认连接。如果新连接是使用与以前连接相同的名称建立的,它将替换以前的连接。这也适用于默认连接。
当您使用`QSqlDatabase::addDatabase(QString,QString)`添加连接时,第一个参数是数据库驱动程序的名称(例如`QMYSQL`,而第二个可选参数是连接的名称。
当创建您的`QSqlQuery`对象时,如果您希望它使用特定的连接,您可以将一个数据库对象传递给构造器。如果需要检索连接名的`QSqlDatabase`对象,可以使用静态的`QSqlDatabase::database(QString)`方法。
把所有这些放在一起
要真正尝试使用数据库类,您将看到一个图像收集应用程序,它使您能够将标签应用于图像,然后显示带有所选标签的图像。图像和标签将存储在 SQLite 数据库中。因为数据库包含在一个文件中,所以可以认为它是应用程序的文件格式。
该应用程序由一个简单的对话框组成(见图 13-1 )。标签显示在右侧,带有任何选定标签的图像数量显示在列表下方的标签中。左半部分用于显示当前图像,以及用于在图像间移动、添加图像和添加标签的按钮。
从可用的按钮可以看出,应用程序没有实现完整的 CRUD 接口。它主要关注前两个部分:创建,比如添加标签和图像;和读取,如显示图像和标签。
**图 13-1。** *图画书应用在行动*
应用程序中使用的数据库(如图图 13-2 所示)由两个表组成:一个用于标签,一个用于图像(分别称为`tags`和`images`)。`images`表每行保存一个图像。每一行都包含一个名为`id`的`INTEGER`,用于识别每一幅图像。这些图像存储在每个`id`旁边的`BLOB`列中,称为`data`。一个`BLOB`是一个二进制大对象,它几乎意味着任何东西。应用程序将 PNG 格式的图像存储在该列中。
`tags`表由一个名为`id`的`INTEGER`列和一个名为`tag`的`VARCHAR`列组成。`id`列将标签连接到不同的图像。请注意,每个图像可以有几个标签。
**图 13-2。***`tags`*`images`*表*** **#### 应用的结构
应用程序分为两个主要部分:用户接口类和数据库接口类。用户界面使用数据库界面来访问来自`QtSql`模块的类。用户界面包含在`ImageDialog`类中,数据库界面包含在`ImageCollection`类中。
通过将使用 SQL 的代码拆分到一个特定的类中,可以避免在整个源代码中使用 SQL 字符串。将包含 SQL 的代码从其余代码中分离出来有几个原因。首先,可以详细测试这部分代码,这很重要,因为 SQL 语句中的任何语法错误都会在运行时首先被检测出来。在数据库中使用的类型和 Qt 的类之间进行转换非常方便。当您更改数据库引擎时,可能有必要检查和更新所使用的一些 SQL 语句。
#### 用户界面
用户界面在`ImageDialog`类中实现。如清单 13-6 所示,类声明的公共部分由一个构造器和一组插槽组成,每个插槽代表一个用户动作。
用户能做什么?查看类声明和图 13-1 你可以看到一些可能的用户动作。下面列出了它们及其对应的插槽:**
*** 在图像:nextClicked
和previousClicked
之间移动* 更改标签列表中的选择:tagsChanged
* 添加新图像:addImageClicked
* 添加新标签:addTagClicked
**
**将继承的任务添加到该列表中,例如能够关闭对话框以退出应用程序。
清单 13-6。 半个 ImageDialog
类声明
class ImageDialog : public QDialog
{
Q_OBJECT
public:
ImageDialog();
private slots:
void nextClicked();
void previousClicked();
void tagsChanged();
void addImageClicked();
void addTagClicked();
...
};
类声明的另一半告诉你这个应用程序是如何工作的(源代码如清单 13-7 所示)。它以四种私人支持方式开始:selectedTags
、updateImages
、updateTags
和updateCurrentImage
。你很快就会看到他们每一个人。
在这些方法之后,设计者生成的用户界面类作为ui
包含在用于跟踪图像的成员变量之前。imageIds
列表包含根据所选标签显示的图像的id
值。currentImage
是进入imageIds
列表的索引,指示哪个图像是活动的。最后,images
变量是处理数据库的ImageCollection
类的一个实例。
清单 13-7。ImageDialog
的私人半班宣言
class ImageDialog : public QDialog
{
...
private:
QStringList selectedTags();
void updateImages();
void updateTags();
void updateCurrentImage();
Ui::ImageDialog ui;
QList<int> imageIds;
int currentImage;
ImageCollection images;
};
插件和插槽
ImageDialog
是用 Designer 创建的,所以你可以先看看它(图 13-3 显示了对话框的基本设计)。除了文本属性和不同小部件的名称,唯一改变的属性是QListWidget
的SelectionMode
;它被设置为MultiSelection
。
图 13-3。 图像对话框的设计
图 13-4 显示了对话框的对象层次结构(你也可以看到不同部件的名称)。唯一不明显的是对话框本身的布局是网格布局。
图 13-4。 图像对话框的对象层次
现在让我们看看ImageDialog
类的源代码,从构造器和用户动作开始。(对话框显示之前运行的代码,构造器,可以在清单 13-8 中看到。)
它从设置从设计器文件生成的用户界面开始。当小部件就位时,它将currentImage
初始化为无效值,以确保在更新标签列表和要显示的图像之前没有图像可见。完成后,连接就完成了。每个按钮的点击信号都连接到相应的插槽。标签列表的itemSelectionChanged
信号连接到tagsChanged
插槽。
清单 13-8。ImageDialog
类的构造器
ImageDialog::ImageDialog()
{
ui.setupUi( this );
currentImage = −1;
updateTags();
updateImages();
connect( ui.previousButton, SIGNAL(clicked()), this, SLOT(previousClicked()) );
connect( ui.nextButton, SIGNAL(clicked()), this, SLOT(nextClicked()) );
connect( ui.addTagButton, SIGNAL(clicked()), this, SLOT(addTagClicked()) );
connect( ui.addImageButton, SIGNAL(clicked()), this, SLOT(addImageClicked()) );
connect( ui.tagList, SIGNAL(itemSelectionChanged()), this, SLOT(tagsChanged()) );
}
记住,updateCurrentImage
方法禁用下一个、上一个和添加标签按钮。从构造器调用的updateImages
中调用updateCurrentImage
方法。这意味着,如果单击“下一个”、“上一个”或“添加标签”按钮,就会出现一个当前图像。
查看插槽,注意其中三个相当简单(见清单 13-9 中的实现)。首先出场的是一对nextClicked
和previousClicked
。如前所述,currentImage
变量充当id
值的imageIds
列表的索引。当用户点击下一步按钮时,currentImage
值增加。如果值太大,它又从零开始。上一个按钮也是如此。该值会减少,并在需要时从列表的另一端开始。
最后一个简单的槽是tagsChanged
槽,如果标签的选择改变了,就会到达这个槽。如果它们被改变了,你需要得到一个新的图像列表。调用updateImages
会解决这个问题。
清单 13-9。 三个简单的插槽
void ImageDialog::nextClicked()
{
currentImage = (currentImage+1) % imageIds.count();
updateCurrentImage();
}
void ImageDialog::previousClicked()
{
currentImage --;
if( currentImage == −1 )
currentImage = imageIds.count()-1;
updateCurrentImage();
}
void ImageDialog::tagsChanged()
{
updateImages();
}
下一个插槽addTagClicked
,可以在清单 13-10 的中看到。当用户想要向当前图像添加标签时,调用该槽。
该插槽通过显示一个QInputDialog
来要求用户输入一个标签。如果用户指定了一个字符串,那么输入的文本将被转换为小写,并被检查以符合标签的标准。在这种情况下,这意味着它只包含字符 a-z。没有空格,没有特殊字符,没有元音字母或其他本地字符;实际的检查是使用正则表达式来执行的。
如果发现文本是一个实际的标签,要求ImageCollection
对象images
将标签添加到当前图像中。添加标签后,您需要更新标签列表并调用updateTags
。
清单 13-10。 给当前图像添加标签
void ImageDialog::addTagClicked()
{
bool ok;
QString tag = QInputDialog::getText(
this, tr("Image Book"), tr("Tag:"),
QLineEdit::Normal, QString(), &ok );
if( ok )
{
tag = tag.toLower();
QRegExp re( "[a-z]+" );
if( re.exactMatch(tag))
{
QMessageBox::warning( this, tr("Image Book"),
tr("This is not a valid tag. "
"Tags consists of lower case characters a-z.") );
return;
}
images.addTag( imageIds[ currentImage ], tag );
updateTags();
}
}
剩余的槽位addImageClicked
(如清单 13-11 中的所示),当用户想要向集合中添加新图像时使用。该插槽还将当前选定的标签应用于图像,以确保它保持可见。
该插槽做的第一件事是要求用户使用QFileDialog
选择一个 PNG 图像。当图像被拾取时,它被加载。如果加载失败,插槽的剩余部分将被中止。
如果加载成功,图像将与当前选择的标签一起添加到ImageCollection
。要获得标签,使用selectedTags
方法。添加图像后,您需要更新图像列表中的id
值。为了解决这个问题,调用updateImages
方法。
清单 13-11。 用当前标签给收藏添加图片
void ImageDialog::addImageClicked()
{
QString filename = QFileDialog::getOpenFileName(
this, tr("Open file"), QString(), tr("PNG Images (*.png)") );
if( !filename.isNull() )
{
QImage image( filename );
if( image.isNull() )
{
QMessageBox::warning( this, tr("Image Book"),
tr("Failed to open the file '%1'").arg( filename ) );
return;
}
images.addImage( image, selectedTags() );
updateImages();
}
}
如您所见,插槽相当简单。它们有时会确保用户输入在传递给ImageCollection
对象之前是有效的。当某些东西需要更新时,使用适当的支持方法。
支持方式
selectedTags
方法与插槽和支持方法一起使用,从标签列表中取出选定的标签,并将它们放入一个QStringList
(源代码见清单 13-12 )。
该方法简单地遍历列表小部件中的所有项目。如果选择了一个项目,它的文本被添加到QStringList
对象结果中,然后作为方法的结果返回。
清单 13-12。 将当前选择的标签放在列表中会很方便
QStringList ImageDialog::selectedTags()
{
QStringList result;
foreach( QListWidgetItem *item, ui.tagList->selectedItems() )
result << item->text();
return result;
}
从构造器调用的第一个支持方法是updateTags
,它在不丢失当前选择的情况下更新标签列表(源代码见清单 13-13 )。
该方法从从selectedTags
方法获取当前选择开始。然后,它向ImageCollection
对象请求一组新的标签,清除列表,并添加新的标签。当新标签就位后,该方法遍历列表项,并将更新前选择的项的selected
属性设置为true
。
清单 13-13。 更新标签列表而不丢失选择
void ImageDialog::updateTags()
{
QStringList selection = selectedTags();
QStringList tags = images.getTags();
ui.tagList->clear();
ui.tagList->addItems( tags );
for( int i=0; i<ui.tagList->count(); ++i )
if( selection.contains( ui.tagList->item(i)->text() ) )
ui.tagList->item(i)->setSelected( true );
}
当构造器更新了标签列表后,就该通过调用updateImages
方法来更新图像了。该方法负责更新imageIds
列表。如果当前显示的图像在新的id
值列表中仍然可用,它也会保留当前显示的图像。
该方法的源代码如清单 13-14 所示。它首先尝试检索当前显示图像的id
。如果没有可用的图像,则id
被设置为−1
,这是一个无效的id
。
该方法然后通过从ImageCollection
获得图像id
值的新列表来继续。该列表基于当前选择的标签。
如果先前图像的id
仍然在id
值列表中,则currentImage
索引被更新以保持显示相同的图像。如果不能显示相同的图像,则显示第一个图像(显然,如果没有图像,则不显示图像)。
因为该方法会影响currentImage
索引值,所以它会调用updateCurrentImage
方法来相应地更新用户界面。
清单 13-14。 获取一个新的图像列表 id
值,如果可能的话继续显示当前图像。
void ImageDialog::updateImages()
{
int id;
if( currentImage != −1 )
id = imageIds[ currentImage ];
else
id = −1;
imageIds = images.getIds( selectedTags() );
currentImage = imageIds.indexOf( id );
if( currentImage == −1 && !imageIds.isEmpty() )
currentImage = 0;
ui.imagesLabel->setText( QString::number( imageIds.count() ) );
updateCurrentImage();
}
清单 13-15 中的方法检查是否有当前图像。如果有,该方法从ImageCollection
对象中获取它,并通过使用imageLabel
小部件显示它。它还启用了“下一个”、“上一个”和“添加标签”按钮。
如果没有当前图像,则imageLabel
被设置为显示文本"No Image"
,并且按钮被禁用。
清单 13-15。 更新当前显示的图像并使右边的按钮可用。
void ImageDialog::updateCurrentImage()
{
if( currentImage == −1 )
{
ui.imageLabel->setPixmap( QPixmap() );
ui.imageLabel->setText( tr("No Image") );
ui.addTagButton->setEnabled( false );
ui.nextButton->setEnabled( false );
ui.previousButton->setEnabled( false );
}
else
{
ui.imageLabel->setPixmap(
QPixmap::fromImage(
images.getImage( imageIds[ currentImage ] ) ) );
ui.imageLabel->clear();
ui.addTagButton->setEnabled( true );
ui.nextButton->setEnabled( true );
ui.previousButton->setEnabled( true );
}
}
尽管支撑方法看起来很有帮助,但实际上是在其他地方进行的。所有的方法都是要求ImageCollection
对象做事情和取东西。
数据库类
ImageCollection
类,让你离数据库更近一步,负责与数据库的所有联系。它已经被实现,因此它可以使用相关类型与应用程序的其余部分进行交互。应用程序的其余部分不需要知道ImageCollection
是基于数据库的。类声明如清单 13-16 所示。
您可能会注意到有些方法被命名为getXxx
,这不是 Qt 应用程序中命名 getter 方法的常见方式。这样命名的原因是为了能够告诉应用程序的其他部分,这些方法实际上是从其他地方获取的;指示操作可能需要一些时间,具体取决于具体情况。
所有的方法都执行有限的任务,所以你应该能够从它们的名字中了解它们的作用。
清单 13-16。ImageCollection
类定义
class ImageCollection
{
public:
ImageCollection();
QImage getImage( int id );
QList<int> getIds( QStringList tags );
QStringList getTags();
void addTag( int id, QString tag );
void addImage( QImage image, QStringList tags );
private:
void populateDatabase();
};
如清单 13-17 所示,类构造器打开一个数据库连接并填充它。整个类使用默认连接,所以不需要保存一个QSqlDatabase
对象。被访问的数据库是存储在内存中的 SQLite 数据库,所以每次应用程序结束时,它的内容都会丢失。这在开发时会很方便,很容易将数据库名:memory:
替换为合适的文件名,并让数据库成为应用程序的文件格式。
populateDatabase
方法,在与构造器相同的清单中显示,试图在数据库中创建两个表。它使用了IF NOT EXISTS
子句,因为保存的文件将包含这两个表——这应该不会导致失败。
清单 13-17。 构造器和 populateDatabase
方法
ImageCollection::ImageCollection()
{
QSqlDatabase db = QSqlDatabase::addDatabase( "QSQLITE" );
db.setDatabaseName( ":memory:" );
if( !db.open() )
qFatal( "Failed to open database" );
populateDatabase();
}
void ImageCollection::populateDatabase()
{
QSqlQuery qry;
qry.prepare( "CREATE TABLE IF NOT EXISTS images "
"(id INTEGER PRIMARY KEY, data BLOB)" );
if( !qry.exec() )
qFatal( "Failed to create table images" );
qry.prepare( "CREATE TABLE IF NOT EXISTS tags (id INTEGER, tag VARCHAR(30))" );
if( !qry.exec() )
qFatal( "Failed to create table tags" );
}
使用图像标签
图像集合的一些职责包括管理标签列表和跟踪哪个标签属于哪个图像。让我们先来看看getTags
方法。它的作用是返回所有可用标签的列表。
该方法的源代码可以在清单 13-18 中看到。因为您使用默认连接,所以您创建一个查询,准备并执行它。查询本身包含一个DISTINCT
子句,因为相同的标签可能在不同的图像中出现多次。这可以确保您不会得到重复的列表。当查询被执行后,结果被放入一个返回的QStringList
中。
清单 13-18。 查询标签列表,封装在 QStringList
中,返回
QStringList ImageCollection::getTags()
{
QSqlQuery qry;
qry.prepare( "SELECT DISTINCT tag FROM tags" );
if( !qry.exec() )
qFatal( "Failed to get tags" );
QStringList result;
while( qry.next() )
result << qry.value(0).toString();
return result;
}
另一种标签管理方法是addTag
方法(见清单 13-19 ),它给给定的图像添加一个标签。使用一个id
值来指定标签属于哪个图像。该方法不检查重复的标签,因为getTags
方法会过滤掉重复的标签,所以有可能对同一张图片多次添加相同的标签。
清单 13-19。 给图像添加新标签
void ImageCollection::addTag( int id, QString tag )
{
QSqlQuery qry;
qry.prepare( "INSERT INTO tags (id, tag) VALUES (:id, :tag)" );
qry.bindValue( ":id", id );
qry.bindValue( ":tag", tag );
if( !qry.exec() )
qFatal( "Failed to add tag" );
}
图像
getIds
方法从标签的角度处理图像。它接受一个QStringList
标签,并返回至少有一个标签的图像的一个id
值列表。如果该方法没有标签,它将返回所有图像id
值。这就是为什么在清单 13-20 所示的源代码中准备了两个不同的查询。
在处理一个或多个标签的 SQL 语句中,使用了IN
子句。写x IN (1, 2, 3)
等于写x=1 OR x=2 or x=3
。因为用户界面确保标签仅由字母 a–z 组成,所以您可以安全地将它们连接在一起,并直接在 SQL 查询中使用它们。
注意你应该尽量避免在 SQL 语句中手动插入字符串;尽可能使用bindValue
。
SQL 语句以一个GROUP BY
子句结束,确保您不会得到一个以上的id
。查询结果放在返回的整数列表中。
清单 13-20。 获取给定标签集的每个 id
(如果没有给定标签,则获取每个id
)
QList< int> ImageCollection::getIds( QStringList tags )
{
QSqlQuery qry;
if( tags.count() == 0 )
qry.prepare( "SELECT images.id FROM images" );
else
qry.prepare( "SELECT id FROM tags WHERE tag IN ('" +
tags.join("','") + "') GROUP BY id" );
if( !qry.exec() )
qFatal( "Failed to get IDs" );
QList<int> result;
while( qry.next() )
result << qry.value(0).toInt();
return result;
}
在数据库中存储图像
在数据库中存储图像不是一项简单的任务,因为没有用于存储图形的数据类型。相反,您必须依赖于BLOB
类型,这是一个二进制大对象(简单地说:一大块原始数据)。
将一个QImage
对象变成一个 blob 的过程可以分为三个步骤。首先,在内存中创建一个缓冲区,并将图像保存到该缓冲区中。缓冲区然后被转换成一个QByteArray
,它被绑定到一个 SQL INSERT
查询中的一个变量。然后执行该查询。
这都是在清单 13-21 中的方法中完成的。正如您从突出显示的行中看到的,创建了一个QBuffer
对象。图像以带有QImageWriter
的 PNG 格式写入缓冲区。当缓冲区包含图像数据时,在准备INSERT
查询将图像放入数据库时,可以在bindValue
调用中使用缓冲区中的数据。
查看代码的其余部分,查询数据库中的图像数量,以便能够确定新的id
。如果您让用户从数据库中删除图像,此方法不起作用。创建表时,可以使用AUTOINCREMENT
让数据库自动分配一个新的id
。那就解决了问题。但是由于您只支持添加新的映像,即不支持删除它们,并且假设一次只有一个客户端应用程序在使用数据库,所以这个解决方案是可行的。
INSERT
的陈述非常简单明了;在查询执行之前,id
和data
被绑定到查询。当图像被插入后,给该方法的所有标签被传递给addTag
,以便它们被插入到数据库中。
清单 13-21。 将一幅图像及其标签添加到数据库中。
void ImageCollection::addImage( QImage image, QStringList tags )
{
QBuffer buffer;
QImageWriter writer(&buffer, "PNG");
writer.write(image);
QSqlQuery qry;
int id;
qry.prepare( "SELECT COUNT(*) FROM images" );
qry.exec();
qry.next();
id = qry.value(0).toInt() + 1;
qry.prepare( "INSERT INTO images (id, data) VALUES (:id, :data)" );
qry.bindValue( ":id", id );
qry.bindValue( ":data", buffer.data() );
qry.exec();
foreach( QString tag, tags )
addTag( id, tag );
}
将存储的图像从数据库放回一个QImage
对象的过程涉及相同的类。清单 13-22 向你展示了它是如何完成的。因为getImage
方法不必担心生成新的id
值或标签,所以它比addImage
方法更直接。
首先,准备并执行查询;然后从结果中提取出QByteArray
。该数组被传递给一个QBuffer
,您可以从一个QImageReader
中使用它。注意,在将缓冲区传递给图像阅读器之前,必须打开缓冲区进行读取。从图像阅读器中,您可以获得作为结果返回的QImage
对象。
清单 13-22。 来自查询,通过缓冲区,传给读者
QImage ImageCollection::getImage( int id )
{
QSqlQuery qry;
qry.prepare( "SELECT data FROM images WHERE id = :id" );
qry.bindValue( ":id", id );
if( !qry.exec() )
qFatal( "Failed to get image" );
if( !qry.next() )
qFatal( "Failed to get image id" );
QByteArray array = qry.value(0).toByteArray();
QBuffer buffer(&array);
buffer.open( QIODevice::ReadOnly );
QImageReader reader(&buffer, "PNG");
QImage image = reader.read();
return image;
}
如您所见,将数据存储为嵌入数据库的文件相当容易。因为所有 Qt 流都使用的是QIODevice
类,并且该类是QFile
和QBuffer
的基类,所以几乎可以对任何文件格式使用这个方法。
把所有东西放在一起
ImageDialog
类包含了ImageCollection
类的一个实例,所以main
函数所要做的就是创建一个QApplication
和一个ImageDialog
,显示对话框,并启动事件循环(代码如清单 13-23 所示)。现在应该都很熟悉了。
清单 13-23。main
功能
int main( int argc, char **argv )
{
QApplication app( argc, argv );
ImageDialog dlg;
dlg.show();
return app.exec();
}
使用的项目文件可以通过调用qmake –project
然后将行QT += sql
附加到结果文件来生成。图 13-5 显示了应用程序启动后的样子。
如果查看代码,您会发现大部分工作都是由数据库引擎执行的。您不必遍历您的定制数据结构来定位所有唯一的标签,只需通过查询传递适当的SELECT
语句。
说到存储信息,可以使用SQLite
作为应用程序的文件格式。有几种方法可以确保文件有效。例如,您可以有一个特殊的表,其中包含有关您的应用程序、用于写入文件的版本等信息。加载文件,然后在使用文件前检查该表。
图 13-5。 正在使用的图画书应用
模型数据库
到目前为止,您已经编写了数据库查询,然后将数据提取到列表和值中。但是也有可能以更直接的方式管理数据。因为从数据库接收的数据通常与您向用户显示的数据相同,所以使用通用 SQL 模型来完成这项工作是有意义的。Qt 提供了三种不同的模型:
QSqlQueryModel
:提供一个只读模型,用于显示给定SELECT
查询的结果QSqlTableModel
:提供可编辑的模型,显示单个表格QSqlRelationalModel
:提供一个可编辑的模型,用于显示引用其他表的单个表中的数据
这些模型就像所有其他数据库类一样工作。所以当你理解了 Qt SQL 模块是如何工作的,你也会知道如何使用这些模型。
查询模型
QSqlQueryModel
使您能够通过视图显示查询的结果(清单 13-24 向您展示了它的用法)。这个模型很容易建立:简单地创建一个QSqlQueryModel
对象,并使用setQuery
调用指定一个查询。
其余代码创建并配置一个表模型来显示查询模型。
清单 13-24。 在表格视图中显示 SQL 查询的结果
QSqlQueryModel *model = new QSqlQueryModel();
model->setQuery( "SELECT firstname, lastname FROM names" );
QTableView *view = new QTableView();
view->setModel( model );
view->show();
该查询被传递给本章开头的 SQL 简介中使用的表。得到的表格模型如图图 13-6 所示。
图 13-6。 查询模型的结果
桌子模型
使用QSqlTableModel
可以得到一个显示整个表格内容的可编辑模型。使用该类的一小段源代码如清单 13-25 所示。
使用该类时,通过使用setTable
方法选择要显示的表。如果要添加一个WHERE
子句,可以使用setFilter
方法添加条件。默认情况下没有过滤器,显示整个表。当您设置了一个过滤器和一个表后,调用select
来执行对数据库的实际查询。
调用removeColumn
时,通过传递列在表中的序号位置,可以避免显示列。在列表列中,0 是隐藏的;这对应于id
列。
清单 13-25。 设置显示 Doe 名称的表格模型
QSqlTableModel *model = new QSqlTableModel();
model->setTable( "names" );
model->setFilter( "lastname = 'Doe'" );
model->select();
model->removeColumn( 0 );
QTableView *view = new QTableView();
view->setModel( model );
view->show();
生成的表格视图如图图 13-7 所示。结果视图是可编辑的,因为模型是可编辑的。通过将视图的editTriggers
属性设置为QAbstractItemView:: NoEditTriggers
,可以防止用户编辑数据。
图 13-7。 查询模型的结果
关系表模型
QSqlRelationalTableModel
是桌子模型的更高级的化身。通过创建关系模型并指定数据库中不同表之间的关系,可以让模型从几个表中查找信息,并将它们作为一个表显示。
清单 13-26 显示了如何使用这样的关系将names
表中的id
列链接到salaries
表中的相应列。结果是显示了来自salaries
表的annual
值,而不是id
。这个关系是在清单中的setRelation(int,QSqlRelation)
调用中建立的。第一个参数是要在关系中使用的列的序号。作为第二个参数给出的QSqlRelation
接受三个参数:首先,要关联的表的名称;第二,连接表时使用的相关表中的列名;第三,要从被联接的表中获取的列的名称。在这个例子中,您基于salaries.id
连接salaries
表,并使用salaries.annual
列。正如表模型一样,您需要调用select
将数据放入模型中。
为了获得漂亮的标题,可以使用setHeaderData
方法来指定每个列标题的方向和文本。这可以在所有模型中实现,而不仅仅是关系模型。
清单 13-26。 一个关系表模型,用漂亮的标题显示姓名和年薪
QSqlRelationalTableModel *model = new QSqlRelationalTableModel();
model->setTable( "names" );
model->setRelation( 0, QSqlRelation( "salaries", "id", "annual" ) );
model->select();
model->setHeaderData( 0, Qt::Horizontal, QObject::tr("Annual Pay") );
model->setHeaderData( 1, Qt::Horizontal, QObject::tr("First Name") );
model->setHeaderData( 2, Qt::Horizontal, QObject::tr("Last Name") );
QTableView *view = new QTableView();
view->setModel( model );
view->show();
清单 13-26 的结果可以在图 13-8 中看到。注意,模型是可编辑的,所以如果您不调整视图的editTriggers
属性,用户可以编辑视图。
图 13-8。 关系表模型的结果
当您查找类似邮政编码的城市名称而不仅仅是一个数字时,关系模型非常有用。你可以使用一个QSqlRelationalDelegate
来让用户从列表中选择一个城市,而不是必须输入名字。
总结
Qt SQL 模块使得以跨平台的方式访问几乎任何可能的数据库成为可能。事实上,SQL 数据库驱动程序是插件,所以如果你需要访问一个定制的数据库,你仍然可以写一个驱动程序并使用 Qt 的类来访问它。在大多数情况下,为这样的数据库获取一个 ODBC 驱动程序并将其用作 Qt 和相关数据库之间的一个层会更容易。
当访问数据库时,使用QSqlDatabase
类来表示一个连接。数据库模块有一个默认连接,所以只要坚持一次使用一个连接,就可以避免很多额外的麻烦。
连接到数据库后,使用QSqlQuery
类将 SQL 查询传递给数据库。但是,要注意 SQL 方言——一个数据库接受的有效语句可能被另一个数据库认为无效。在发布产品之前尝试所有的 SQL 语句是很重要的,因为在编译期间不会检查它们的错误。
通过使用作为 SQL 模块一部分的 SQL 模型,您通常可以避免查询数据库并将结果转换成可以向用户显示的内容。可用的型号有QSqlQueryModel
、QSqlTableModel
和QSqlRelationalTableModel
。尽可能多地使用这些模型——它们可以为您节省大量时间和精力。******
十四、网络
Qt 支持通过传输控制协议(TCP) 和用户数据报协议(UDP) 套接字建立的基于 IP 的连接。此外,Qt 支持 HTTP 和 FTP 协议的客户端实现,这有助于创建 FTP 客户端和基于 HTTP 的下载。所有这些类都保存在 Qt 的一个单独的网络模块中。
本章首先讨论客户端协议以及如何使用它们下载数据(协议的客户端是与服务器交互时使用的代码)。您还将快速浏览一下QUrl
类,它用于处理 URL 及其不同部分。
本章的后半部分讨论了 TCP 和 UDP 套接字类,以及如何实现服务器和客户端。
使用 QtNetwork 模块
所有用于联网的 Qt 类都是QtNetwork
模块的一部分。这个模块并不是在 Qt 的所有闭源版本中都有,但是它包含在开源版本中。这意味着如果您计划在您的闭源 Qt 项目中使用它,您必须首先访问该模块。
在确保您可以访问该模块之后,您需要通过告诉 QMake 您正在使用它来将它包含在您的构建过程中(将代码行QT += network
添加到项目文件中)。
使用客户端协议
QFtp
和QHttp
类封装了 FTP 和 HTTP 协议。请记住,这两个类只实现这些协议的客户端,所以如果你想创建一个 FTP 服务器或 HTTP 服务器,你必须求助于 TCP 服务器和套接字类(在本章后面介绍)。
比较 FTP 和 HTTP 可以看出,虽然两种协议在相同的问题域中工作,但 FTP 是一种稍微复杂一些的协议。例如,FTP 协议依赖于一种状态,在这种状态下,连接被建立,然后在关闭之前被使用。另一方面,HTTP 是无状态的——它将每个请求与其他请求分开处理。
然而,从应用程序开发人员的角度来看,这两种协议的使用方式是相同的。创建一个协议对象(一个QFtp
对象或一个QHttp
对象)。当一个方法被调用时,请求的动作被异步执行,这意味着方法只返回一个请求标识符,而不是实际的结果。相反,您的应用程序必须等待一个携带结果的信号被发出。
让我们看看这在实践中是如何工作的,从开发一个 FTP 客户端开始。
创建 FTP 客户端
使用QFtp
类,您将实现一个基本的 FTP 客户端,使用户能够连接到[ftp://ftp.trolltech.com](http://ftp://ftp.trolltech.com)
,浏览目录树,并下载文件。图 14-1 显示了实际应用。
功能的限制(例如,只能连接到一个主机)简化了应用程序,但是仍然展示了如何使用QFtp
类。
**图 14-1。**FTP 客户端在行动
FTP 客户端由一个对话框组成,该对话框包含一个用于与 FTP 站点交互的QFtp
对象。QFtp
对象与应用程序异步工作,所以当你发出一个命令时,你必须等待一个信号到达——当命令被执行时,应用程序保持运行。
QFtp
类有一系列在不同事件发生时发出的信号,包括:
commandFinished(int request, bool error)
:该信号在命令结束时发出。request
参数可用于识别命令,而如果在命令执行过程中发生了error
,则error
为true
。listInfo(QUrlInfo info)
:当列出一个目录的内容时,为找到的每个文件或目录发出该信号。dataTransferProgress(qint64 done, qint64 total)
:该信号在上传和下载过程中发出。参数done
报告了total
已经完成了多少。done
和total
参数是可伸缩的,所以你不能依赖这些代表字节的参数。如果总大小未知,total
为零。
这三个信号从QFtp
对象连接到对话框构造器中对话框的三个私有槽。你可以在清单 14-1 中的类中找到插槽(它们的名字以ftp
开头)。
该类还包括从 Designer 生成的Ui::ClientDialog
类以及以Clicked
结尾的五个插槽;图 14-1 中的按钮各一个。selectionChanged
插槽连接到用于显示当前目录内容的QListWidget
发出的itemSelectionChanged
信号。
该类还包含一个下载文件时使用的QFile
指针和一个用于区分文件和目录的QStringList
。
清单 14-1。ClientDialog
类声明
class FtpDialog : public QDialog
{
Q_OBJECT
public:
FtpDialog();
private slots:
void connectClicked();
void disconnectClicked();
void cdClicked();
void upClicked();
void getClicked();
void selectionChanged();
void ftpFinished(int,bool);
void ftpListInfo(const QUrlInfo&);
void ftpProgress(qint64,qint64);
private:
void getFileList();
Ui::FtpDialog ui;
QFtp ftp;
QFile *file;
QStringList files;
};
让我们来看看这个应用程序,从用户启动应用程序并点击连接按钮开始。
设置对话框
从main
函数中创建并显示ClientDialog
(对话框的构造器如清单 14-2 所示)。它初始化指向null
的QFile
指针,配置用户界面,并进行必要的连接。然后,它禁用除连接按钮以外的所有按钮。
在整个应用程序中,按钮将被启用和禁用,以反映可用的选项。保持按钮的状态与QFtp
对象同步是很重要的,因为没有检查来看一个动作在作用于被点击的按钮的槽中是否有意义。
清单 14-2。ClientDialog
构造器初始化、连接并确保右边的按钮被启用,其余的被禁用。
FtpDialog::FtpDialog() : QDialog()
{
file = 0;
ui.setupUi( this );
connect( ui.connectButton, SIGNAL(clicked()),
this, SLOT(connectClicked()) );
connect( ui.disconnectButton, SIGNAL(clicked()),
this, SLOT(disconnectClicked()) );
connect( ui.cdButton, SIGNAL(clicked()),
this, SLOT(cdClicked()) );
connect( ui.upButton, SIGNAL(clicked()),
this, SLOT(upClicked()) );
connect( ui.getButton, SIGNAL(clicked()),
this, SLOT(getClicked()) );
connect( ui.dirList, SIGNAL(itemSelectionChanged()),
this, SLOT(selectionChanged()) );
connect( &ftp, SIGNAL(commandFinished(int,bool)),
this, SLOT(ftpFinished(int,bool)) );
connect( &ftp, SIGNAL(listInfo(QUrlInfo)),
this, SLOT(ftpListInfo(QUrlInfo)) );
connect( &ftp, SIGNAL(dataTransferProgress(qint64,qint64)),
this, SLOT(ftpProgress(qint64,qint64)) );
ui.disconnectButton->setEnabled( false );
ui.cdButton->setEnabled( false );
ui.upButton->setEnabled( false );
ui.getButton->setEnabled( false );
}
连接 FTP 服务器,列出文件
当对话框被构建时,在event
循环开始之前,它从main
函数中显示出来。当用户最终决定单击连接按钮时,事件将被发出信号的QPushButton
对象捕获,该信号连接到connectClicked
插槽。
如清单 14-3 所示,插槽相应地调用QFtp
对象。它使用connectToHost(QString)
连接到[ftp.trolltech.com](http://ftp.trolltech.com)
。在此之前,“连接”按钮被禁用,这样用户就不能尝试多次连接。更新statusLabel
的文本,让用户了解正在发生的事情。
对QFtp
对象的所有调用都是异步的,所以应用程序可以在它们被处理的同时继续运行。您可以知道命令何时完成,因为它会在完成时发出信号。
清单 14-3。 点击连接按钮后连接主机
void FtpDialog::connectClicked()
{
ui.connectButton->setEnabled( false );
ftp.connectToHost( "ftp.trolltech.com" );
ui.statusLabel->setText( tr("Connecting to host...") );
}
当connectToHost
调用完成时,QFtp
对象发出一个commandFinished(int,bool)
信号。信号连接到类的ftpFinished
插槽。槽的相关部分如清单 14-4 所示。
该槽被分成两个switch
语句。第一种处理故障(即error
为true
的情况);第二个处理已经成功完成的命令。
可以从赋予插槽的request
参数中识别已发布的命令。对QFtp
对象的所有调用都返回一个请求标识符,通过将它与request
参数进行匹配,您可以知道哪个命令已经完成。在清单所示的插槽中,有一种不同的方法。因为您一次只发出每种类型的一个命令,所以您可以依赖于currentCommand
方法,该方法返回一个枚举值,指示插槽引用哪个命令。
在点击连接按钮的情况下,结束命令是一个ConnectToHost
命令。如果呼叫失败,您可以使用消息框通知用户,然后重新启用连接按钮,以便用户可以重试。如果命令成功完成,您可以通过调用login
方法继续连接过程。它只是发出一个新命令,导致对插槽的新调用。因为该过程涉及几个异步命令,所以理解起来可能有些复杂。你可以在图 14-2 中查看流程图。
清单 14-4。ftpFinished
插槽手柄ConnectToHost``Login``Close
List
。**
void FtpDialog::ftpFinished( int request, bool error )
{
// Handle errors depending on the command causing it
if( error )
{
switch( ftp.currentCommand() )
{
case QFtp::ConnectToHost:
QMessageBox::warning( this, tr("Error"), tr("Failed to connect to host.") );
ui.connectButton->setEnabled( true );
break;
case QFtp::Login:
QMessageBox::warning( this, tr("Error"), tr("Failed to login.") );
ui.connectButton->setEnabled( true );
break;
case QFtp::List:
QMessageBox::warning( this, tr("Error"),
tr("Failed to get file list.\nClosing connection.") )
ftp.close();
break;
...
}
ui.statusLabel->setText( tr("Ready.") );
}
// React to the current command and issue
// more commands or update the user interface
else
{
switch( ftp.currentCommand() )
{
case QFtp::ConnectToHost:
ftp.login();
break;
case QFtp::Login:
getFileList();
break;
case QFtp::Close:
ui.connectButton->setEnabled( true );
getFileList();
break;
case QFtp::List:
ui.disconnectButton->setEnabled( true );
ui.upButton->setEnabled( true );
ui.statusLabel->setText( tr("Ready.") );
break;
...
}
}
}
图 14-2。 连接一个 FTP 站点包括连接主机、登录和列表几个步骤。
当登录命令完成时,您通过通知用户并重新启用 Connect 按钮来处理错误。成功的命令触发对getFileList
方法的调用,该方法检索当前目录的内容。你可以在清单 14-5 中看到实现。
getFileList
方法禁用所有按钮(记住您是连接的,所以 Connect 按钮已经被禁用)。然后,在调用QFtp
对象来list
当前目录的内容之前,它清除列表小部件dirList
和QStringList
文件。
您检查 FTP 连接的开始是LoggedIn
,因为当您想要清除dirList
时(例如,当断开连接时)调用这个方法。
当调用了QFtp::list
时,对于每个目录条目,发出一次listInfo
信号。该信号连接到清单 14-5 中getFileList
下方所示的ftpListInfo
插槽。QUrlInfo
包含了许多关于每一项的有趣信息,但是您只对name
属性感兴趣,并想知道该项是否是一个文件。如果是一个文件,将该名称添加到files
列表中(稍后您将使用该列表来决定是否启用获取文件按钮或更改目录按钮)。
清单 14-5。 通过调用 list
然后监听 listInfo
信号得到目录项列表
void FtpDialog::getFileList()
{
ui.disconnectButton->setEnabled( false );
ui.cdButton->setEnabled( false );
ui.upButton->setEnabled( false );
ui.getButton->setEnabled( false );
ui.dirList->clear();
files.clear();
if( ftp.state() == QFtp::LoggedIn )
ftp.list();
}
void FtpDialog::ftpListInfo( const QUrlInfo&info )
{
ui.dirList->addItem( info.name() );
if( info.isFile() )
files << info.name();
}
当list
命令结束时,它发出一个被ftpFinished
插槽捕获的信号。switch
语句的相关部分可以在清单 14-4 中看到。如您所见,如果一个list
命令失败,FTP 连接就会关闭。如果成功,将启用断开连接和向上按钮。
当连接关闭后,再次调用ftpFinished
槽,并且QFtp::Close
将是当前命令。当close
命令成功完成后,启用连接按钮并调用getFileList
方法。查看清单 14-5 中的方法,你会发现因为QFtp
命令不再是LoggedIn
,调用的结果是目录条目列表被清除。
从 FTP 服务器断开
当遇到失败的list
命令时,调用QFtp
对象上的close
方法,关闭连接。当用户想要断开连接时,他们点击 disconnect 按钮,这导致对清单 14-6 中所示的disconnectClicked
插槽的调用。
该插槽简单地禁用所有按钮,因此当连接被关闭时,用户不能做任何事情。然后它调用close
方法。当close
呼叫结束后,ftpFinished
插槽将启用连接按钮并清除目录条目列表。
清单 14-6。disconnectClicked
槽在用户点击断开按钮时触发。
void FtpDialog::disconnectClicked()
{
ui.disconnectButton->setEnabled( false );
ui.cdButton->setEnabled( false );
ui.upButton->setEnabled( false );
ui.getButton->setEnabled( false );
ftp.close();
}
文件还是目录?
当 FTP 连接建立后,Disconnect 和 Up 按钮被启用,并且dirList
小部件包含一个目录条目列表。为了能够下载文件或更深入地浏览目录树,用户必须在dirList
中选择一个项目。当这种情况发生时,从QListWidget
发出itemSelectionChanged
信号,并调用selectionChanged
插槽。该槽如清单 14-7 中的所示。
确定插槽中的当前选择是包含一个项目还是不包含任何项目。QListWidget
的selectionMode
属性已经被设置为SingleSelection
,所以你不能进入任何其他的选择场景。如果未选择任何项目,则“获取文件”和“更改目录”按钮都将被禁用。
如果选择了一个项目,查看所选项目的文本是否在文件QStringList
中。如果是,则启用“获取文件”按钮;否则,将启用“更改目录”按钮。
清单 14-7。 在 selectionChanged
槽中你确保右边的按钮都被启用。
void FtpDialog::selectionChanged()
{
if( !ui.dirList->selectedItems().isEmpty() )
{
if( files.indexOf( ui.dirList->selectedItems()[0]->text() ) == −1 )
{
ui.cdButton->setEnabled( ui.disconnectButton->isEnabled() );
ui.getButton->setEnabled( false );
}
else
{
ui.cdButton->setEnabled( false );
ui.getButton->setEnabled( ui.disconnectButton->isEnabled() );
}
}
else
{
ui.cdButton->setEnabled( false );
ui.getButton->setEnabled( false );
}
}
导航 FTP 服务器目录结构
当用户想要在 FTP 站点的目录之间移动时,他们使用向上和改变目录按钮。只有在目录内容列表中选择了一个目录时,用户才可以使用后者。
点击这些按钮会调用清单 14-8 中的所示的一个插槽。两个插槽的工作方式完全相同:按钮被禁用,调用QFtp
对象的cd
方法,并更新状态文本。不同的是,当按下向上按钮时,cd
调用试图移动到父目录(..),而“更改目录”按钮试图移动到一个已命名的子目录。
清单 14-8。 向上和改变目录按钮的插槽
void FtpDialog::cdClicked()
{
ui.disconnectButton->setEnabled( false );
ui.cdButton->setEnabled( false );
ui.upButton->setEnabled( false );
ui.getButton->setEnabled( false );
ftp.cd( ui.dirList->selectedItems()[0]->text() ;)
ui.statusLabel->setText( tr("Changing directory...") );
}
void FtpDialog::upClicked()
{
ui.disconnectButton->setEnabled( false );
ui.cdButton->setEnabled( false );
ui.upButton->setEnabled( false );
ui.getButton->setEnabled( false );
ftp.cd("..");
ui.statusLabel->setText( tr("Changing directory...") );
}
因为两个按钮都导致调用QFtp
对象中的同一个方法,所以两个方法都在ftpFinished
槽中的同一个switch
案例中结束。(源代码的相关部分见清单 14-9 。)无论cd
调用失败还是成功,结果动作都是一样的——调用getFileList
。这个额外的调用更新了目录内容列表并启用了相关的按钮。如果cd
命令因为您被注销或连接失败而失败,那么getFileList
调用也会失败。该故障导致 FTP 连接关闭(参见清单 14-4 )。
清单 14-9。 当一个 cd
调用完成后,当前目录的内容将被更新。
void FtpDialog::ftpFinished( int request, bool error )
{
if( error )
{
switch( ftp.currentCommand() )
{
...
case QFtp::Cd:
QMessageBox::warning( this, tr("Error"),
tr("Failed to change directory.") );
getFileList();
break;
...
}
ui.statusLabel->setText( tr("Ready.") );
}
else
{
switch( ftp.currentCommand() )
{
...
case QFtp::Cd:
getFileList();
break;
...
}
}
}
如果getFileList
调用失败,FTP 连接关闭,如清单 14-4 所示。这意味着如果一个无效的cd
调用会使 FTP 连接无效,那么连接就会被关闭,这是摆脱这种情况的最安全的方法。
下载文件
如果在目录内容列表中选择了一个文件,则“获取文件”按钮将被启用。点击此按钮将调用getClicked
插槽。清单 14-10 中的槽实现了三阶段操作。首先,它询问使用什么文件名来保存正在使用QFileDialog::getSaveFileName
下载的文件。如果它得到一个有效的文件名,它会尝试为它创建一个QFile
对象,并打开它进行写入。如果成功,它调用QFtp
对象的get
方法,传递文件名和QFile
对象作为参数。
插槽在调用get
之前也禁用所有按钮。在它调用了get
之后,它更新状态文本。
get
方法启动指定文件的下载操作。结果数据被保存到给定的QIODevice
(?? 的超类)。当QFtp
对象执行下载时,通过连接到ftpProgress
插槽的一系列dataTransferProgress
信号来报告进度(参见getClicked
插槽源代码后的清单 14-10 )。
给ftpProgress
的参数不一定代表字节;它们只显示相对大小。在某些情况下,下载文件的大小是未知的。那么total
自变量为零。如果大小已知,插槽会更新状态标签以显示进度。
注意下载和上传时都会发出dataTransferProgress
。当使用put
上传文件时,当您想要显示进度时,您可以收听与使用get
下载时相同的信号。
清单 14-10。 开始下载并显示进度
void FtpDialog::getClicked()
{
QString fileName =
QFileDialog::getSaveFileName( this, tr("Get File"),
ui.dirList->selectedItems()[0]->text() );
if( fileName.isEmpty() )
return;
file = new QFile( fileName, this );
if( !file->open( QIODevice::WriteOnly|QIODevice::Truncate ) )
{
QMessageBox::warning( this, tr("Error"),
tr("Failed to open file %1 for writing.").arg( fileName ) );
delete file;
file = 0;
return;
}
ui.disconnectButton->setEnabled( false );
ui.cdButton->setEnabled( false );
ui.upButton->setEnabled( false );
ui.getButton->setEnabled( false );
ftp.get( ui.dirList->selectedItems()[0]->text(), file );
ui.statusLabel->setText( tr("Downloading file...") );
}
void FtpDialog::ftpProgress( qint64 done, qint64 total )
{
if( total == 0 )
return;
ui.statusLabel->setText(
tr("Downloading file... (%1%)")
.arg( QString::number( done*100.0/total, 'f', 1 ) ) );
}
当get
命令结束时,由ftpFinished
插槽处理(代码如清单 14-11 所示)。当下载失败时(甚至成功时),关闭并删除QFile
对象,重新启用按钮,并更新状态标签。对selectionUpdated
的调用确保根据目录内容列表中的当前选择启用按钮。这意味着要么启用“获取文件”或“更改目录”,要么两者都不启用(但不是两者都启用)。
失败的下载和成功的下载的区别在于,当下载失败时,您在删除它之前调用QFile
对象上的remove
方法。这会将文件从磁盘中移除,这样您就不会为用户留下未完成的文件。
清单 14-11。 下载完成后管理文件
void FtpDialog::ftpFinished( int request, bool error )
{
if( error )
{
switch( ftp.currentCommand() )
{
...
case QFtp::Get:
QMessageBox::warning( this, tr("Error"), tr("Failed to get file?") );
file->close();
file->remove();
delete file;
file = 0;
ui.disconnectButton->setEnabled( true );
ui.upButton->setEnabled( true );
selectionChanged();
break;
}
ui.statusLabel->setText( tr("Ready.") );
}
else
{
switch( ftp.currentCommand() )
{
...
case QFtp::Get:
file->close();
delete file;
file = 0;
ui.disconnectButton->setEnabled( true );
ui.upButton->setEnabled( true );
selectionChanged();
ui.statusLabel->setText( tr("Ready.") );
break;
}
}
}
组装在一起
通过将图 14-1 中的所示的对话框和前面的列表与一个显示该对话框的简单的main
函数结合起来,你就有了一个完整的 FTP 客户端。它被限制在一个域中,只能浏览目录和执行下载,但是所有需要的机制都已经到位。
要构建客户机,您必须创建一个项目文件——最好使用qmake -project QT+=network
。然后您可以像往常一样使用qmake
和make
构建您的应用程序。
QFtp
类的其他应用
**QFtp
类可以用于构建 FTP 客户端应用程序之外的任务。因为get
方法下载到一个QIODevice
,你可以用它直接下载数据到一个QBuffer
设备并显示它(与你在第十三章的BLOB
栏中存储图像的方式相比)。
也可以使用与get
方法相反的put
方法上传数据。当上传和下载时,通过使用第三个可选参数到get(QString,QIODevice*,TransferType)
和put(QIODevice*,QString,TransferType)
方法来控制 FTP 连接是以二进制模式还是 ASCII 模式通信是很重要的。传送类型可以是QFtp::Binary
或QFtp::Ascii
。
如果您在QFtp
类中缺少一个方法,您可以使用带有rawCommand
方法的原始命令接口发送 FTP 服务器理解的任何命令。如果你期待一个原始命令的回复,你可以收听rawCommandReply(int,QString)
信号。
注意建议您尽可能使用现有命令。
创建 HTTP 客户端
HTTP 协议的工作方式类似于 FTP 协议,但是有所不同。最明显的一点是,当使用 FTP 连接时,您可以进行连接、移动和执行操作。使用 HTTP 时,一次执行一个请求,请求本身或多或少是独立的。
说到相似之处,QFtp
和QHttp
类都是异步的。它们也解决类似的问题——它们通过网络传输数据。
解析和验证 URL
因为 Web 是由 URL 驱动的,所以应用程序需要能够正确地将这些 URL 解析成适当的组件,以使必要的通信命令发挥作用。这就是QUrl
进入画面的地方;它使得验证一个 URL 并把它分解成你需要的组件变得很容易。
让我们先来看看图 14-3 ,它展示了一个复杂的 URL 以及它所包含的不同部分。图中零件的名称对应于QUrl
类的属性。
图 14-3。 一个网址及其组成部分
当您从用户处接收到一个 URL 时,您可以将其提供给QUrl
构造器,然后询问isValid
方法是否可以解释该 URL。这就是清单 14-12 中的getClicked
插槽所发生的情况。该对话框在图 14-4 中显示。URL 被输入到a QLineEdit
小部件中,并被传递给QUrl
对象的构造器。第二个构造器参数告诉QUrl
类要宽容。宽容的替代方案是严格的,这种模式是通过将QUrl::StrictMode
值传递给构造器来设置的。容忍模式补偿用户输入的 URL 中遇到的常见错误。
图 14-4。HttpDialog
如图所示给用户
*如果发现 URL 无效,用于显示 URL 不同部分的QLabel
小部件被设置为不显示文本。然后,在离开该方法之前会显示一个对话框。如果已经输入了一个有效的 URL,那么QLabel
小部件将使用 URL 部分进行更新。
当更新标签时,port
属性得到特殊处理。如果用户没有指定端口,那么port
属性被设置为 1,这意味着用户希望使用 HTTP 通信的默认端口:端口 80。
清单 14-12。 解析 URL 并将其拆分成单独的部分
void HttpDialog::getClicked()
{
QUrl url( ui.requestEdit->text(), QUrl::TolerantMode );
if( !url.isValid() )
{
ui.hostLabel->clear();
ui.pathLabel->clear();
ui.portLabel->clear();
ui.userLabel->clear();
ui.passwordLabel->clear();
QMessageBox::warning( this, tr("Invalid URL"),
tr("The URL '%1' is invalid.").arg( ui.requestEdit->text() ) );
return;
}
ui.hostLabel->setText( url.host() );
ui.pathLabel->setText( url.path() );
ui.portLabel->setText( QString::number(url.port()==-1 ? 80 : url.port()) );
ui.userLabel->setText( url.userName() );
ui.passwordLabel->setText( url.password() );
...
清单 14-12 中的源代码是清单 14-13 中所示的HttpDialog
类的一部分。
用户使用该对话框通过 HTTP 下载文件。用户在顶部的文本字段中输入一个 URL,然后单击 Get 按钮。按钮连接到前面所示的getClicked
槽。当 URL 被验证后,它被用来下载它所指向的文件。下载文件时,从QHttp
对象发出的信号会在对话框底部的列表小部件中列出。
每个以http
开头的插槽用于监听QHttp
对象工作时发出的不同信号。用户界面本身已经在设计器中创建,并作为ui
成员变量包含在内。最后,在下载数据时使用一个QFile
指针和QHttp
对象。
清单 14-13。HttpDialog
类声明
class HttpDialog : public QDialog
{
Q_OBJECT
public:
HttpDialog();
private slots:
void getClicked();
void httpStateChanged(int);
void httpDataSent(int,int);
void httpDataReceived(int,int);
void httpHeaderDone(const QHttpResponseHeader&);
void httpDataDone(const QHttpResponseHeader&);
void httpStarted(int);
void httpFinished(int,bool);
void httpDone(bool);
private:
Ui::HttpDialog ui;
QHttp http;
QFile *file;
};
清单 14-12 中的代码管理对话框的上半部分。有趣的事情发生在对话的下半部分(接下来讨论)。
对话内部
处理 URL 的代码处理对话框的上半部分:请求和 URL 组件分组框及其内容(参见图 14-4 )。在你看同一个对话框的下半部分,HTTP Status 分组框之前,让我们看一下它的构造器(如清单 14-14 所示)。构造器有三个任务:初始化局部变量(也就是file
),调用setupUi
创建用 Designer 设计的用户界面,以及进行使对话框工作所需的所有连接。
这些连接可以分为两组。来自getButton
的clicked
信号将用户交互连接到插槽;其余的连接将 HTTP 事件连接到插槽。
清单 14-14。 在创建所有连接之前初始化变量和用户界面
HttpDialog::HttpDialog() : QDialog()
{
file = 0;
ui.setupUi( this );
connect( ui.getButton, SIGNAL(clicked()), this, SLOT(getClicked()) );
connect( &http, SIGNAL(stateChanged(int)),
this, SLOT(httpStateChanged(int)) );
connect( &http, SIGNAL(dataSendProgress(int,int)),
this, SLOT(httpDataSent(int,int)) );
connect( &http, SIGNAL(dataReadProgress(int,int)),
this, SLOT(httpDataReceived(int,int)) );
connect( &http, SIGNAL(responseHeaderReceived(const QHttpResponseHeader&)),
this, SLOT(httpHeaderDone(const QHttpResponseHeader&)) );
connect( &http, SIGNAL(readyRead(const QHttpResponseHeader&)),
this, SLOT(httpDataDone(const QHttpResponseHeader&)) );
connect( &http, SIGNAL(requestStarted(int)),
this, SLOT(httpStarted(int)) );
connect( &http, SIGNAL(requestFinished(int,bool)),
this, SLOT(httpFinished(int,bool)) );
connect( &http, SIGNAL(done(bool)),
this, SLOT(httpDone(bool)) );
}
前面讨论的 URL 处理代码是一个名为getClicked
的槽的上半部分。在前面的构造器中,您看到了该方法是如何连接到用户界面的。当您离开清单 14-12 的中的getClicked
方法时,URL 刚刚被验证并被分割成其构建块。
当您继续在清单 14-15 中时,您使用 URL 来设置QHttp
对象的host
属性。调用setHost
并指定主机名和端口。就像显示端口一样,如果没有指定其他内容,端口 80 是默认端口。如果指定了用户名,则使用setUser
方法设置用户名及其密码。
当QHttp
对象被设置后,继续通过使用QFileDialog
类的静态方法getSaveFileName
向用户询问用于存储下载材料的文件名。如果用户取消对话框,从插槽返回;否则,继续尝试打开文件进行写入。如果失败,通过显示警告对话框通知用户并删除QFile
对象。
如果用户选择了一个可用于书写的文件名,调用QHttp
对象的get(QString,QIODevice)
方法来下载文件。最后,在执行实际下载时,禁用 Get 按钮。
清单 14-15。 使用验证过的网址开始下载
void HttpDialog::getClicked()
{
...
http.setHost( url.host(), url.port()==-1 ? 80 : url.port() );
if( !url.userName().isEmpty() )
http.setUser( url.userName(), url.password() );
QString fileName = QFileDialog::getSaveFileName( this );
if( fileName.isEmpty() )
return;
file = new QFile( fileName, this );
if( !file->open( QIODevice::WriteOnly|QIODevice::Truncate ) )
{
QMessageBox::warning( this, tr("Could not write"),
tr("Could not open the file %f for writing.").arg( fileName ) );
delete file;
file = 0;
return;
}
http.get( url.path(), file );
ui.getButton->setEnabled( false );
}
现在开始下载;如果一切顺利,你需要做的就是等待done
信号发出。如果遇到错误,布尔参数是true
,所以你希望它是false
。信号连接到列表 14-16 中所示的httpDone
插槽。如果error
参数为false
,使用close
方法关闭QFile
对象并删除文件对象。
如果下载操作遇到了问题,并且error
参数为true
,则在关闭和删除文件之前以及删除QFile
对象之前,用户会得到警告。使用remove
方法删除文件。您必须删除该文件,因为它可能包含部分下载(如果连接在下载操作过程中断开,就会发生这种情况)。
您用来警告用户问题的消息是用errorString
方法检索的,该方法返回一个错误消息。
无论下载是否成功,在离开插槽之前重新启用 Get 按钮,以便用户可以输入新的 URL 并尝试下载更多数据。
清单 14-16。 当下载完成或失败时,由 QHttp
对象发出 done
信号。该信号连接到 httpDone
插槽。
void HttpDialog::httpDone( bool error )
{
ui.statusList->addItem( QString("done( %1 )").arg( error ? "True" : "False" ) );
if( error )
{
QMessageBox::warning( this, tr("Http: done"), http.errorString() );
if( file )
{
file->close();
file->remove();
delete file;
file = 0;
}
}
if( file )
{
file->close();
delete file;
file = 0;
}
ui.getButton->setEnabled( true );
}
所有剩余的插槽只是将它们的名称和参数值输出到对话框底部的列表中。这个列表显示了QHttp
对象用来执行请求的下载的确切步骤。QHttp
物体很健谈,工作时能发出以下信号:
dataReadProgress(int done, int total)
:请求数据的一部分已被读取。参数done
和total
显示了比例,但不一定是字节数。请注意,如果总大小未知,则total
可以为零。dataSendProgress(int done, int total)
:正在发送的一部分数据已经传输。这个参数的工作方式与dataReadProgress
相同。done(bool error)
:最后一个待处理的请求已经完成。readyRead(const QHttpResponseHeader &resp)
:读取请求已完成。如果在发出请求时指定了目标设备,则不会发出此信号。requestFinished(int id, bool error)
:请求已完成。您可以从id
参数中识别请求。requestStarted(int id)
:请求已经开始。您可以从id
参数中识别请求。responseHeaderReceived(const QHttpResponseHeader &resp)
:响应头可用。stateChanged(int state)
:QHttp
对象的状态已经改变。
下载信号
知道所有信号的意思是一回事,但实际上知道会发生什么是另一回事。让我们看看两种不同的下载场景,从成功下载开始。
这一切都从发出请求开始,首先设置主机,然后开始下载:
requestStarted( 1 )
requestFinished( 1, False )
requestStarted( 2 )
stateChanged( Connecting )
stateChanged( Sending )
dataSendProgress( done: 74, total: 74 )
stateChanged( Reading )
现在开始读取,这将产生一系列的`dataReadProgress`信号(它们的参数和数量将因您的计算机而异):
responseHeaderReceived(code: 200, reason: OK, version: 1.1 )
dataReadProgress( done: 895, total: 0 )
...
dataReadProgress( done: 32546, total: 0 )
stateChanged( Closing )
stateChanged( Unconnected )
现在您已经断开连接,读取结束。对 HTTP 对象来说,剩下的就是说所有的事情都已经做了,一切都很顺利:
requestFinished( 2, False )
done( False )
在下一次尝试中,您将尝试从不存在的服务器下载文件。这意味着你甚至不会与服务器取得联系。
一切都像以前一样开始:设置主机,然后尝试下载一个文件:
requestStarted( 1 )
requestFinished( 1, False )
requestStarted( 2 )
stateChanged( Connecting )
第二个请求失败:
requestFinished( 2, True )
这也反映在`done`信号中;它的参数是`true`,表示错误:
done( True )
stateChanged( Closing )
stateChanged( Unconnected )
这里显示了两个场景,但是还有许多其他场景。在处理网络应用程序时,注意在收到正确的数据时向用户报告成功。不要试图查出所有的错案;试着找到你期待的成功。
插座
当使用`QHttp`和`QFtp`类时,您实际上依赖底层协议来处理实际的数据传输。使用的协议是 TCP,它有一个稍微不太可靠的近亲,叫做 UDP。Qt 支持这两种协议。
当直接使用 TCP 和 UDP 套接字时,您的工作水平远远低于使用 HTTP 和 FTP 时。当您使用这些技术时,您负责将发送和接收的数据转换为应用程序友好的格式,并在应用程序端处理数据。
这意味着您要做更多的工作,但也意味着对最终协议的更多控制。FTP 和 HTTP 并不总是合适的协议,因为应用领域可能已经有了一个协议。在其他情况下,使用自定义协议的好处大于花费的额外工作。应用程序的性质有时意味着使用 HTTP 或 FTP 是不可能的,或者比实现特定于应用程序的协议涉及更多的工作。
可靠性在 UDP 和 TCP 中的作用
尽管 UDP 和 TCP 通信之间存在一些差异,但大多数开发人员只需要记住他们实现可靠性的不同方法。TCP 传输的数据能否真正到达目的地至关重要。另一方面,当使用 UDP 时,您只是在相关的计算机之间传递数据,无法保证数据能够到达目的地。另外,当数据到达目的地时,TCP 协议确保数据以正确的顺序提供给应用程序。使用 UDP 发送的数据可能会乱序到达,这是应用程序必须处理的情况。
如果你想传输一段数据,并且需要传输所有的数据,TCP 是最好的。示例包括传输文件和维护远程访问计算机的会话。在这些情况下,丢失的一部分数据会使其余的数据变得无用。
在时间比可靠性更重要的情况下,UDP 对于输出数据很有用。比如视频流的时候,错过几帧总比时间漂移好。其他示例包括多人游戏,其中其他玩家的位置可能不太重要(只要不发生直接交互)。
有时,需求同时涉及 TCP 和 UDP 的属性:一种常见的情况是,对数据流的控制使用 TCP,而实际数据使用 UDP 传输。这意味着用户身份验证、控制命令等是通过有质量保证的连接来处理的,而实际数据是使用 UDP 来发送的。
服务器、客户端和对等端
历史上,计算机通信发生在服务器为客户端提供某种服务的情况下。
主人之间直接对话已经变得越来越普遍。例子包括文件共享客户端以及 VoIP 解决方案。从软件开发的角度来看,这并不难做到;您只需要创建能够处理传入和传出连接的应用程序。
**使用 Qt 创建服务器端应用**
服务器应用程序通常不需要图形用户界面;它们往往在后台运行,用户看不见。不包含用户界面模块也可以编写 Qt 应用程序。这涉及到两个变化:首先,`QApplication`对象被一个`QCoreApplication`对象取代;然后你需要在项目文件中添加一行`QT -= gui`。
生成的应用程序没有链接到任何 Qt 的用户界面类,因此无论是在运行时还是在分发时,它都将占用更少的磁盘空间和需要更少的内存。
使用 TCP 发送图像
您第一次尝试客户机-服务器解决方案时,将涉及到一个服务器应用程序,用于传输客户机请求的图像,并使最终用户可以看到这些图像。服务器从给定的目录中随机选取一个图像,并通过 TCP 将其发送给客户端。客户端应用程序使用户能够通过点击按钮来请求新图像,然后接收并显示给定的图像。
**创建服务器应用**
让我们先来看看服务器端。您将按照执行的顺序查看服务器的源代码,从`main`函数开始(如清单 14-17 所示)。
在`main`函数中,您设置了一个`Server`对象来监听端口 9876 的传入连接。这些联系可能来自任何来源。如果`listen`调用失败,告诉用户并退出。否则,通过从`QCoreApplication`对象调用`exec`方法来启动`event`循环。
**注意**如果调用`listen`时没有指定端口,`QTcpServer`类会选择一个空闲端口。您可以使用`serverPort`属性找出服务器监听哪个端口。当您不需要控制使用哪个端口时,这非常有用。
**清单 14-17。***`main`*功能尝试设置服务器。** *`int main( int argc, char **argv )
{
QCoreApplication app( argc, argv );
Server server;
if( !server.listen( QHostAddress::Any, 9876 ) )
{
qCritical( "Cannot listen to port 9876." );
return 1;
}
return app.exec();
}`
清单 14-18 中的类继承了`QTcpServer`类。使用 Qt 的 TCP server 类作为服务器实现的基础可以让你免费得到很多东西。现在,`main`函数创建一个对象实例,并在进入`event`循环之前调用`listen`。所有连接到服务器的尝试都将导致调用`incomingConnection`方法。通过重新实现方法,您可以处理连接。
**清单 14-18。** *服务器类继承了* `QTcpServer` *并重新实现了* `incomingConnection` *方法。*
class Server : public QTcpServer
{
public:
Server();
protected:
void incomingConnection( int descriptor );
};
服务器的实现几乎和类声明一样简单,因为实际的工作不是由`Server`类执行的。(你可以在清单 14-19 中看到所有的源代码。)
由于服务器会很快承受大量同时传入的连接,发送图像可能需要一段时间。为了减轻负载,可以利用线程化——为每个连接创建一个新线程。通过这样做,`Server`对象可以在服务第一个连接的同时继续前进并处理下一个连接。
当调用`incomingConnection`方法时,一个*套接字描述符*作为参数被传递。这个整数可以用来连接一个处理连接的`QTcpSocket`对象。这被传递给被创建和启动的`ServerThread`对象。通过将完成信号连接到`deleteLater`插槽,线程对象被设置为在它们完成后进行清理。`deleteLater`槽可用于`QObject`,并在到达`event`循环时删除对象实例。这使得对象可以删除自己——这通常是不可能的,因为从类方法内部删除`this`指针会导致不可预知的结果和灾难性的崩溃。
**清单 14-19。** *服务器只是为每个连接启动一个线程。*
Server::Server() : QTcpServer()
{
}
void Server::incomingConnection( int descriptor )
{
ServerThread *thread = new ServerThread( descriptor, this );
connect( thread, SIGNAL(finished()), thread, SLOT(deleteLater()) );
thread->start();
}
`Server`对象为每个传入的连接创建一个`ServerThread`对象。`thread`类由两个方法组成:`run`和`randomImage`。你可以在清单 14-20 的类声明中看到它们。
`run`方法负责执行通过给定套接字传输图像的实际任务。`randomImage`方法被`run`方法用来获取要发送的图像。
**清单 14-20。** *每个传入的连接都由一个* `ServerThread` *对象处理。*
class ServerThread : public QThread
{
public:
ServerThread( int descriptor, QObject *parent );
void run();
private:
QImage randomImage();
int m_descriptor;
};
让我们从查看`randomImage`方法开始(参见清单 14-21 )。该方法使用一个`QDir`对象在`./images`目录中查找文件。它假设该目录中的所有文件都是有效的图像。然后,它使用`qrand`函数生成一个随机数,用于选择其中一个文件。
在使用`qrand`之前,用一个种子初始化随机数发生器很重要;否则,每次都会得到相同的数列。`qsrand`调用使用自午夜以来经过的秒数作为种子。
**清单 14-21。** *从* `images` *中选择一个随机文件,用* `QImage`加载
QImage ServerThread::randomImage()
{
qsrand(QTime(0,0,0).secsTo(QTime::currentTime()));
QDir dir("images");
dir.setFilter( QDir::Files );
QFileInfoList entries = dir.entryInfoList();
if( entries.size() == 0 )
{
qDebug( "No images to show!" );
return QImage();
}
return QImage( entries.at( qrand() % entries.size() ).absoluteFilePath() );
}
实际发送图像的任务由清单 14-22 中的方法处理。相同清单中显示的构造器只是保留了对`run`方法的描述。在`run`方法中,描述符用于建立一个`QTcpSocket`对象。通过使用`setSocketDescriptor`设置套接字描述符,您可以获得一个套接字对象,该对象连接到连接到服务器的客户机。
当套接字设置好后,就该准备通过套接字传输数据了。这是一个两阶段的过程。首先创建一个`QBuffer`来写入图像。一个`QBuffer`是一个`QIODevice`(就像一个`QFile`一样),并且`QImageWriter`可以写任何一个`QIODevice`。对`QImageWriter`的`write`方法的调用留给您一个包含编码为 PNG 的图像的缓冲区。
在发送缓冲区的内容之前,您需要找到一种方法来告诉客户端预期有多少数据。这是下一步。首先创建一个`QByteArray`和一个`QStreamWriter`来写数组。将流的版本设置为`Qt_4_0`,以确保数据以一种方式编码。如果跳过这一步,使用 Qt 未来版本编译的服务器可能会与客户机不兼容。
使用流写入器将包含在`QBuffer`中的数据的大小放入字节数组中。确定大小后,将缓冲区的内容添加到字节数组中,并将所有数据写入套接字。
当数据被发送后,你不再需要套接字,所以使用`disconnectFromHost`断开它。然后在`run`方法结束之前,使用`waitForDisconnect`等待断开完成。当方法返回时,发出`finished` 信号。这个信号通过`Server`对象连接到`deleteLater`插槽,所以当数据发送后`ServerThread`对象删除自己。
**清单 14-22。***`run`*方法通过套接字发送图像数据。**
ServerThread::ServerThread( int descriptor, QObject *parent ) : QThread( parent )
{
m_descriptor = descriptor;
}
void ServerThread::run()
{
QTcpSocket socket;
if( !socket.setSocketDescriptor( m_descriptor ) )
{
qDebug( "Socket error!" );
return;
}
QBuffer buffer;
QImageWriter writer(&buffer, "PNG");
writer.write( randomImage() );
QByteArray data;
QDataStream stream( &data, QIODevice::WriteOnly );
stream.setVersion( QDataStream::Qt_4_0 );
stream << (quint32)buffer.data().size();
data.append( buffer.data() );
socket.write( data );
socket.disconnectFromHost();
socket.waitForDisconnected();
}
**创建客户端应用程序**
图像查看系统的客户端是用户将会遇到的。对他们来说,它将像其他用户应用程序一样工作,显示图 14-5 中的用户界面。该应用程序使用户能够指定服务器,下载新图像,并查看上一个图像。
在图中,服务器运行在 *localhost* (与客户机相同的计算机)上。您可以在这里输入任何计算机名或 IP 地址。当要求获取图像时,客户端将尝试建立到服务器上 9876 端口的连接,这是服务器监听的端口。如果在这个过程中出现问题(例如,没有可用的服务器),用户会看到一条错误消息。
![image](https://gitee.com/OpenDocCN/vkdoc-c-cpp-zh/raw/master/docs/fund-qt-dev/img/P1405.jpg)
**图 14-5。** *图像浏览器客户端应用*
整个应用程序由一个在`ClientDialog`类中实现的对话框组成。一个简单的`main`函数用于显示对话框并启动应用程序。`main`函数简单地创建了一个`ClientDialog`对象,在对其`QApplication`对象调用`exec`之前,先对其调用`show`方法。
清单 14-23 显示了对话框的类声明。它由一个构造器、一个用于获取图像按钮的插槽(`getClicked`)和两个用于监控 TCP 套接字的插槽(`tcpReady`和`tcpError`)构建而成。该类还包含三个私有变量:用户界面(保存在`ui`中),一个名为`socket`的`QTcpSocket`对象,以及用于跟踪下载图像时预期数据量的`dataSize`变量。
用户界面在设计器中创建(参见图 14-5 查看对话框)。用户界面的活动部分是用于输入服务器名称的`QLineEdit`,用于点击下载新图像的`QPushButton`,以及用于显示图像和状态消息的`QLabel`。
**清单 14-23。** *客户端对话框类声明*
class ClientDialog : public QDialog
{
Q_OBJECT
public:
ClientDialog();
private slots:
void getClicked();
void tcpReady();
void tcpError( QAbstractSocket::SocketError error );
private:
Ui::ClientDialog ui;
QTcpSocket socket;
int dataSize;
};
在查看套接字处理和图像下载之前,让我们从一切开始的地方开始。客户端应用程序一启动,对话框就创建好了(构造器如清单 14-24 所示)。
构造器非常简单(这是对话框如此简单的结果)。构造器所做的就是通过调用`setupUi`来初始化用户界面,将获取图像按钮连接到`getClicked`插槽,并围绕`QTcpSocket`对象进行必要的连接。
**清单 14-24。** *构造客户端对话框*
ClientDialog::ClientDialog() : QDialog()
{
ui.setupUi( this );
connect( ui.getButton, SIGNAL(clicked()), this, SLOT(getClicked()) );
connect( &socket, SIGNAL(error(QAbstractSocket::SocketError)),
this, SLOT(tcpError(QAbstractSocket::SocketError)) );
connect( &socket, SIGNAL(readyRead()),
this, SLOT(tcpReady()) );
}
从构造器执行应用程序之后,代码等待用户填写服务器名称并单击 Get Image 按钮。点击按钮会把你带到清单 14-25 中的位置。
该槽通过禁用“获取图像”按钮来开始,以防止用户在第一次下载完成之前试图开始新的下载。然后从任何先前的图像中清除`QLabel`,并显示一条消息。通过调用带有空的`QPixmap`对象的`setPixmap`来清除之前的图像。
当用户界面已经为下载做好准备时,`dataSize`变量被初始化为零,并且在`QTcpSocket`对象上调用`abort`方法,以防止先前调用的任何残余干扰。最后,调用`connectToHost`连接到指定服务器的 9876 端口。这个过程导致清单 14-18 中的对象检测到一个传入的连接,从而将一个图像发送到客户端应用程序。
**清单 14-25。** *槽位发起下载*
void ClientDialog::getClicked()
{
ui.getButton->setEnabled( false );
ui.imageLabel->setPixmap( QPixmap() );
ui.imageLabel->setText( tr("Getting image...") );
dataSize = 0;
socket.abort();
socket.connectToHost( ui.serverEdit->text(), 9876 );
}
工作时,`QTcpSocket`级通过发射信号来传达其当前状态。在客户端应用程序中,您会听到`readyRead`和`error`信号,但还有更多信号(见下表):**
*** connected()
:当一个成功的connectToHost
呼叫被发出,并且一个连接被建立。* disconnected()
:插座断开时发出。* error(QAbstractSocket::SocketError)
:发生错误时发出。该参数描述了错误的原因。* hostFound()
:主机对connectToHost
的调用已经完成,主机名已经成功查找并解析时发出。它是在connected
信号之前发出的,不能保证连接的建立——服务器仍然可以拒绝接受它。* stateChanged(QAbstractSocket::SocketState)
:套接字状态改变时发出。* readyRead()
:当数据可供读取时发出。它仅在有新数据可用时发出,因此,如果您不读取数据,直到有更多数据可用时,信号才会重新发出。**
**注意,所有这些信号都是在QTcpSocket
类继承的类中定义的。列表中的前五个是在QAbstractSocket
类中定义的,而readyRead
来自于QIODevice
类。这意味着当浏览参考文档时,你必须查找超类而不是QTcpSocket
来寻找关于信号的信息。
套接字始终处于一种状态,即使在未连接时也是如此。状态变化导致发出stateChanged
信号。客户端应用程序套接字中存在以下状态:
QAbstractSocket::UnconnectedState
:插座未连接。QAbstractSocket::HostLookupState
:套接字正在查找主机。QAbstractSocket::ConnectingState
:套接字已经查找到主机,正在尝试建立连接。QAbstractSocket::ConnectedState
:socket 连接服务器。QAbstractSocket::ClosingState
:套接字正在关闭连接。
这里列出的状态按照它们在实际应用中出现的顺序出现。套接字开始时未连接,查找主机,尝试连接,然后连接。然后套接字被关闭,最后返回为未连接。如果出现错误,套接字将返回到未连接状态,并准备好重新开始。
当讨论错误时,error
信号携带一个指定错误原因的参数,该参数由枚举类型指定。适用于 TCP 套接字的不同问题如下所列(如果您想要一个人类可读版本的错误,您可以使用errorString
方法,它返回一个描述问题的QString
):
QAbstractSocket::ConnectionRefusedError
:连接被远程主机拒绝或超时。QAbstractSocket::RemoteHostClosedError
:远程主机关闭连接。QAbstractSocket::HostNotFoundError
:找不到指定的主机。QAbstractSocket::SocketAccessError
:由于安全限制,操作无法进行。QAbstractSocket::SocketResourceError
:无法创建套接字。操作系统通常会限制同时打开的套接字的数量。QAbstractSocket::SocketTimeoutError
:套接字超时。QAbstractSocket::NetworkError
:网络导致的错误。例如,连接丢失或电缆断开。QAbstractSocket::UnsupportedSocketOperationError
:当前操作系统不支持套接字操作(可能是因为操作系统不支持 IPv6,这样的地址正在被使用)。QAbstractSocket::UnknownSocketError
:发生了无法识别的错误。
现在返回到图像下载客户端应用程序。如果一切顺利,当用户点击了获取图像按钮,并且建立了连接,QTcpSocket
对象将开始发出readyRead
信号。
这会导致调用tcpReady
槽。插槽的实现可以在清单 14-26 中看到。该插槽可以说是在两种模式下工作。如果dataSize
是零,它检查是否至少有四个字节(一个quint32
的大小)可以从套接字读取。(套接字为此提供了bytesAvailable
方法。)
当四个字节可用时,设置一个QDataStream
从套接字读取。您可以确保流使用与服务器相同的版本。如果不这样做,可能会遇到流数据被曲解的奇怪问题。当流建立后,您读取四个字节,并将它们放在dataSize
变量中。
参考清单 14-22 中的run
方法;您可以看出dataSize
变量包含了生成您所等待的图像的字节数。你所要做的就是等待那个字节数的到来。
一旦dataSize
被设置为一个值,就将其与 socket 对象的bytesAvailable
方法返回的值进行比较。继续这样做,直到你知道整个图像已经到达。
下一步是从接收到的数据创建一个QImage
对象。如您所知,图像是以 PNG 文件的形式传输的。因为 PNG 格式是压缩的,所以要传输的数据量最小。
要从数据制作图像,首先将数据读入QByteArray
。该数组放在一个QBuffer
中,您可以使用一个QImageReader
从其中读取图像。然后检查结果QImage
是否有效(也就是说,isNull
返回false
)。
如果图像有效,使用QLabel
显示;否则,显示使用QLabel
的错误信息。不管结果如何,重新启用“获取图像”按钮,以便用户可以尝试下载另一个图像。
清单 14-26。 处理收到的数据
void ClientDialog::tcpReady()
{
if( dataSize == 0 )
{
QDataStream stream( &socket );
stream.setVersion( QDataStream::Qt_4_0 );
if( socket.bytesAvailable() < sizeof(quint32) )
return;
stream >> dataSize;
}
if( dataSize > socket.bytesAvailable() )
return;
QByteArray array = socket.read( dataSize );
QBuffer buffer(&array);
buffer.open( QIODevice::ReadOnly );
QImageReader reader(&buffer, "PNG");
QImage image = reader.read();
if( !image.isNull() )
{
ui.imageLabel->setPixmap( QPixmap::fromImage( image ) );
ui.imageLabel->clear();
}
else
{
ui.imageLabel->setText( tr("<i>Invalid image received!</i>") );
}
ui.getButton->setEnabled( true );
}
只要一切按计划进行,先前讨论的都是有效的。当你与网络打交道时,你会发现事情并不总是按照你想要的方式发展。随着不如电缆连接可靠的无线连接变得越来越普遍,这种情况会更频繁地发生。
如果出现错误,就会调用清单 14-27 中所示的tcpError
插槽。该槽简单地显示了用QMessageBox::warning
描述错误的可读字符串。然后,它重新启用“获取图像”按钮,以便用户可以重试。
但是,有一个错误被忽略了:当连接被主机关闭时。您不希望为此显示错误消息,因为这是服务器传输图像时发生的情况,它会关闭连接。
清单 14-27。 tcpError
插槽
void ClientDialog::tcpError( QAbstractSocket::SocketError error )
{
if( error == QAbstractSocket::RemoteHostClosedError )
return;
QMessageBox::warning( this, tr("Error"),
tr("TCP error: %1").arg( socket.errorString() ) );
ui.imageLabel->setText( tr("<i>No Image</i>") );
ui.getButton->setEnabled( true );
}
关于图像应用的进一步思考
整个系统由客户机和服务器组成,Qt 负责连接它们的许多细节。让我们快速看一下使用的类。
看服务器;您会看到接受传入请求并打开一个QTcpSocket
进行响应的任务是由QTcpServer
类处理的。在继承了QTcpServer
的Server
类中,为每个传入请求创建一个线程,这样在应答早期连接的同时可以接受更多的传入连接。这将增加服务器的吞吐量,只要运行它的计算机有能力处理所有的连接。
风险在于服务器可能会过于频繁地连接,以至于耗尽内存。这将导致内存交换,增加处理每个连接所需的时间,从而导致更多线程同时处于活动状态,而可用的内存则更少。这不是 Qt 特有的问题,而是服务器过载时的反应方式。
客户端位于网络的另一端。使用QTcpSocket
很容易连接到主机并接收数据。因为QTcpSocket
是一个QIODevice
,所以可以使用流和其他类从套接字读取。
最后,您可以看到 Qt 简化了 TCP 连接两端的实现。剩下要实现的代码是指定要使用的协议的代码;这是您在使用 Qt 的 TCP 类时希望能够关注的代码。
使用 UDP 广播图片
虽然 UDP 的可靠性或缺乏可靠性可能会使您认为它不太适合基于网络的应用程序开发,但是您可能会惊讶地发现这种方法有几个优点。也许最值得注意的是,发送方和接收方的联系不那么紧密,这意味着可以同时向几个接收方广播数据。这是你试用QUdpSocket
类时会看到的。
这个想法是向服务器子网内的所有客户端逐行广播图像。客户端只是监听发送到预定端口(在本例中是 9988)的数据报消息。每个数据报都是一个独立的数据包,包含一行图像所需的所有数据。当接收到一行时,客户端通过添加新行来更新图像的内部副本。
因为服务器不知道客户端,而客户端只是监听端口,所以它们之间没有真正的联系。服务器可以独立于客户机启动和停止,并且可以有任意数量的客户机监听同一个服务器。
图 14-6 显示了运行中的客户端应用程序。图像没有被完全接收,并且服务器以有限的速度以随机的顺序传输线条,因此需要一段时间来完成图像。
**图 14-6。**UPC 客户端应用
您在 UDP 上使用的协议由包含一行图形数据的数据报组成。数据报包含正在广播的图像的尺寸,因此客户端可以判断它们是否需要调整大小以及当前数据报包含哪一行——y 坐标后跟该行每个像素的红色、绿色和蓝色值。图 14-7 显示了每条传输数据所使用的数据类型。该协议还确定数据通过 9988 端口发送。
提示您可能需要打开防火墙才能广播到本地网络的 9988 端口。请注意,您需要打开 UDP 端口 9988,而不是相同号码的 TCP 端口。
图 14-7。 包含一行图像的数据报的结构
创建客户端
客户端由一个小部件类组成:Listener
。它继承了QLabel
,所以可以显示文字和图片。它还包含一个用于监听传入数据报的QUdpSocket
和一个用于保存正在接收的图像的本地副本的QImage
。整个类声明可以在清单 14-28 中看到。在清单中,您可以看到该类包含一个插槽、dataPending
和一个构造器。
清单 14-28。Listener
类声明
class Listener : public QLabel
{
Q_OBJECT
public:
Listener( QWidget *parent=0 );
private slots:
void dataPending();
private:
QUdpSocket *socket;
QImage *image;
};
让我们从查看构造器开始研究实现(见清单 14-29 )。它主要做三件事:在等待第一个数据报到达时设置一个要显示的文本,将image
变量初始化为零,并设置 UDP 套接字。
UDP 套接字是QUdpSocket
类的一个实例,可以用来实现监听器和发送器。对于侦听,将套接字绑定到一个端口(在本例中为 9988)。当绑定到端口时,套接字将接收发送到该端口的数据报。当它接收到这样一个数据报时,它可以被读取,所以它发出readyRead
信号。该信号连接到Listener
级的dataPending
插槽。
清单 14-29。 监听传入的数据报
Listener::Listener( QWidget *parent ) : QLabel( parent )
{
setText( "Waiting for data." );
image = 0;
socket = new QUdpSocket( this );
socket->bind( 9988 );
connect( socket, SIGNAL(readyRead()), this, SLOT(dataPending()) );
}
在清单 14-30 中显示的dataPending
插座由一个用于清空插座的while
回路组成。里面是处理每个数据报的代码;之后是用于更新显示图像的代码。
只要套接字的hasPendingDatagrams
方法返回true
,循环就会运行。当该方法返回true
时,可以使用pendingDatagramSize
方法获得第一个未决数据报的大小。要读取数据报,使用readDatagram
方法。您可以使用这两种方法首先创建一个大小合适的QByteArray
,然后将数据报的内容读入字节数组。
当数据报在字节数组中时,继续创建一个用于从数组中读取的QDataStream
对象。还要确保调用setVersion
来确保用不同 Qt 版本编译的客户机和服务器仍然可以一起工作。一旦数据流建立起来,就该开始解释你刚刚收到的大量数据了。
根据图 14-7 中的,如果假设数据报包含数据,从读取流中的三个quint16
变量开始:width
、height
和 y
接下来就是看你有没有一个QImage
对象;如果没有,请创建一个新的。如果您有一个,请确保它的尺寸与收到的图像相对应。如果没有,删除它并创建一个具有正确尺寸的新的。
最后一步由一个for
循环组成,在这个循环中,读取每个像素的三个quint8
变量——red
、green
和blue
——然后使用setPixel
方法将相应的像素设置为该颜色。
当hasPendingDatagrams
方法不再返回true
时,清除显示的文本,显示收到的QImage
。调用resize
以确保小部件的大小对应于图像的大小。
您可以使用QImage
来保存缓冲的图像,因为您知道它使用每像素 24 位来存储图像。(这是在通过沿宽度和高度传递QImage::Format_RGB32
标志来创建QImage
对象时指定的。)方法setPixmap
需要一个QPixmap
对象,所以您必须使用静态方法QPixmap::fromImage
将QImage
转换为QPixmap
。
当未决数据报队列清空时,更新显示图像的解决方案假定您可以比数据报到达更快地处理数据报;否则,显示的图像将不会更新。一个技巧是使用一个计数器来确保每 10 行左右更新一次显示的图像。查看服务器,了解为什么在这种情况下不需要它。
清单 14-30。 处理到达的数据报
void Listener::dataPending()
{
while( socket->hasPendingDatagrams() )
{
QByteArray buffer( socket->pendingDatagramSize(), 0 );
socket->readDatagram( buffer.data(), buffer.size() );
QDataStream stream( buffer );
stream.setVersion( QDataStream::Qt_4_0 );
quint16 width, height, y;
stream >> width >> height >> y;
if( !image )
image = new QImage( width, height, QImage::Format_RGB32 );
else if( image->width() != width || image->height() != height )
{
delete image;
image = new QImage( width, height, QImage::Format_RGB32 );
}
for( int x=0; x<width; ++x )
{
quint8 red, green, blue;
stream >> red >> green >> blue;
image->setPixel( x, y, qRgb( red, green, blue ) );
}
}
setText( "" );
setPixmap( QPixmap::fromImage( *image ) );
resize( image->size() );
}
这是客户端小部件所需的所有代码。该应用程序由这个小部件和一个简单的main
函数组成,该函数显示了小部件的一个实例。
创建服务器
服务器简单地从图像test.png
发送随机行,该图像必须位于启动服务器时使用的工作目录中。该应用程序由一个执行实际广播的类(称为Sender
)和一个最小的main
函数组成。
Sender
类的声明如清单 14-31 所示。该类继承了QObject
,这意味着它没有用户界面(它会直接或间接地继承QWidget
)。该类继承了QObject
,因为它有一个槽。
broadcastLine
槽用于广播图像的单行。该类保存由image
指向的QImage
对象中的图像。广播插座是由socket
指向的QUdpSocket
。除了插槽和两个指针,该类还包含一个构造器。
清单 14-31。 服务器的类声明
class Sender : public QObject
{
Q_OBJECT
public:
Sender();
private slots:
void broadcastLine();
private:
QUdpSocket *socket;
QImage *image;
};
清单 14-32 中的所示的构造器由三部分组成。首先创建套接字;然后加载图像。如果图像没有加载,isNull
返回true
。在这种情况下,您可以使用qFatal
来报告它,这将结束应用程序。
如果图像加载正确,继续设置一个QTimer
对象。计时器的timeout
信号连接到broadcastLine
插槽。计时器的目的是限制每 250 毫秒向一条线路发送数据的速率,这意味着每秒四条线路。
清单 14-32。 开始广播
Sender::Sender()
{
socket = new QUdpSocket( this );
image = new QImage( "test.png" );
if( image->isNull() )
qFatal( "Failed to open test.png" );
QTimer *timer = new QTimer( this );
timer->setInterval( 250 );
timer->start();
connect( timer, SIGNAL(timeout()), this, SLOT(broadcastLine()) );
}
每当定时器超时,就会调用broadcastLine
。插槽的源代码显示在清单 14-33 中。当你查看代码时,回想一下图 14-7 中所示的数据报描述。
调用该槽时发生的第一件事是分配一个QByteArray
作为缓冲区。可以从图像宽度计算出数组的大小。图像和 y 坐标的尺寸消耗六个字节;然后实际数据每像素需要三个字节,所以需要6+3*image->width()
个字节。设置一个用于写入缓冲区的QDataStream
,并设置流的版本以匹配客户端使用的流的版本。
下一步是在使用qrand
决定广播哪一行之前,将图像的尺寸添加到流中。当您知道使用哪一行时,也将 y 坐标添加到流中。
注意因为你使用qrand
而没有使用qsrand
给随机数发生器一个种子,所以每次服务器运行时,图像线将以相同的伪随机顺序广播。
使用for
循环将每个像素的红色、绿色和蓝色值添加到流中。您使用pixel
方法获得QImage
的每个像素的QRgb
值。然后使用qRed
、qGreen
和qBlue
函数获得QRgb
值的红色、绿色和蓝色部分。
当给定行的所有像素值都被添加到流中时,就可以使用QUdpSocket
对象广播整个QByteArray
缓冲区了。使用writeDatagram
方法可以做到这一点,该方法试图将整个给定的字节数组作为数据报发送到给定的地址和端口。清单 14-33 中显示的代码使用QHostAddress::Broadcast
作为主机地址和端口 9988,因此数据将被发送到与服务器在同一子网中的所有客户机上的端口 9988。
清单 14-33。 广播单线
void Sender::broadcastLine()
{
QByteArray buffer( 6+3*image->width(), 0 );
QDataStream stream( &buffer, QIODevice::WriteOnly );
stream.setVersion( QDataStream::Qt_4_0 );
stream << (quint16)image->width() << (quint16)image->height();
quint16 y = qrand() % image->height();
stream << y;
for( int x=0; x<image->width(); ++x )
{
QRgb rgb = image->pixel( x, y );
stream << (quint8)qRed( rgb ) << (quint8)qGreen( rgb ) << (quint8)qBlue( rgb );
}
socket->writeDatagram( buffer, QHostAddress::Broadcast, 9988 );
}
清单 14-34 中的函数使用了Sender
类。使用QMessageBox::information
创建Sender
对象,然后显示一个对话框。当对话框打开时,Sender
对象中的QTimer
触发广播。用户一关闭对话框,main
功能就结束,Sender
对象和QTimer
一起被销毁,广播停止。这提供了一种创建易于关闭的服务器的好方法。
清单 14-34。main
广播器的功能
int main( int argc, char **argv )
{
QApplication app( argc, argv );
Sender sender;
QMessageBox::information( 0, "Info", "Broadcasting image" );
return 0;
}
关于 UDP 的最终想法
要测试 UDP 服务器和客户端,请独立地启动和停止这两个应用程序。然后,您将看到客户机和服务器是真正独立的。一旦服务器开始广播,客户端将开始接收。一旦客户端启动,它也开始接收。双方都不在乎对方是否活跃。
虽然客户机和服务器一样非常简单,但是结束图像会很有帮助,这样每个客户机都会知道它何时收到了完整的图像。
从整体来看,协议才是最重要的。现在,您一次只能广播一个图像(也许应该在每个数据报前添加一个唯一的图像标识符值,以便一次可以广播多个图像)。通过在每个数据报的末尾发送整个图像的校验和,客户端可以确保在看到整个图像时拥有正确的图像(或者可以丢弃校验和不正确的数据报)。
同样重要的是,要考虑如果网络连接关闭后又重新打开会发生什么。这对客户端接收的数据有什么影响,更重要的是,客户端如何将数据呈现给用户?因为 UDP 协议不保证任何数据到达,也不保证哪些数据或以什么顺序到达,所以在设计数据报的内容时考虑这些限制是很重要的。
总结
使用 Qt 的网络模块时,您可以选择想要控制操作的级别。如果您只需要获取文件或发出可以通过 FTP 或 HTTP 处理的请求,请使用QHttp
和QFtp
类。这些类负责许多细节,并为您提供高级操作。例如,QHttp
提供setHost
和get
。QFtp
为您提供connectToHost
、login
、get
、put
。
当使用这些类时,您可以监听done
信号,然后对布尔参数做出反应。如果是true
,则发生了错误;除此之外,一切正常。如果发生了错误,您会从errorString
获得一个文本呈现给用户。
如果您需要在较低层次上控制网络交互,Qt 提供了基于 TCP 和 UDP 的套接字类。虽然这两者之间的差异很多,超出了本书的范围,但每一个都可以大大简化:
- TCP 适合在两台计算机之间建立会话,并以可靠的方式在它们之间传输数据。数据以流的形式传输。
- UDP 适用于在计算机之间发送单独的数据包。发送方不需要知道接收方是否正在接收,接收方也不知道是否已经收到了所有发送的数据。数据以称为数据报的独立数据包的形式传输。
实现 TCP 服务器时,可以从QTcpServer
类继承。简单地重新实现incomingConnection
来处理新的连接。给定的整数参数是套接字描述符。将它传递给QTcpSocket
类的构造器,以获得一个连接到传入连接的套接字。
要设置服务器监听端口,请使用listen
方法。通过将QHostAddress::Any
指定为主机地址,服务器将接受所有传入的连接。
从套接字描述符创建的服务器和客户机都使用一个QTcpSocket
。在客户机中,使用connectToHost
来指定要连接的服务器和端口。因为QTcpSocket
继承自QIODevice
类,所以您可以设置一个QDataStream
(或QTextStream
)来通过它所代表的连接发送和接收数据。
当实现 UDP 服务器时,从创建一个QUdpSocket
开始。然后,您可以使用writeDatagram
写入套接字。当实现一个客户端时,使用同一个类QUdpSocket
,但是通过使用bind
将它绑定到一个端口。每次数据报到达套接字绑定的端口时,它都会发出一个readyRead
信号。然后,您可以使用readDatagram
读取数据报。*************
十五、构建 Qt 项目
他的书依靠 QMake 通过使用标准的项目文件来构建示例应用程序,而没有使用任何高级特性。然而,QMake 也可以用于管理高级项目和处理产生多个可执行文件、库和插件的项目。本章介绍了当您想要创建更复杂的 Qt 项目时,您将需要的一些最常见的特性。
您还将了解到 Qmake 的一个很好的替代品:Kitware 的 CMake ( [
www.cmake.org/](http://www.cmake.org/)
)。和 QMake 一样,CMake 也是一个开源的跨平台构建系统。值得讨论 CMake,因为它被作为 Qt 最著名的用户之一,KDE 项目([
www.kde.org/](http://www.kde.org/)
)的构建工具。
QMake
QMake 是 Qt 附带的构建工具。它是通用的,可以用于在 Qt 支持的所有平台上构建大多数项目。它用于从项目文件生成构建环境。它还可以为 Visual Studio 和 Xcode 创建 Makefiles 和项目文件。
QMake 项目文件
要开始使用 QMake,让它通过执行以下命令为自己创建一个项目文件:
qmake -project
QMake 将在当前目录和子目录中查找它所识别的文件,然后将它们添加到一个标准化的项目中以构建一个应用程序。
**注意**你应该只在创建新项目的时候使用`-project`选项。向现有项目添加文件时,您需要手动将它们添加到项目文件中;否则,您将丢失对项目文件所做的任何更改。
清单 15-1 显示了一个由 QMake 生成的项目文件。如您所见,以`cpp`、`h`和`ui`结尾的文件已经被识别。QMake 可以识别大多数在基于 Qt 的软件项目中使用的文件结尾,但是这三个是这个项目中唯一可用的文件扩展名。
让我们从最上面开始,详细地看一下项目文件。首先要注意的是,注释以一个散列字符(`#`)开始,它将该行的其余部分标记为注释。第一个未注释的行(不算空行)读`TEMPLATE = app`;它将变量`TEMPLATE`设置为`app`。现在`TEMPLATE`有了一个特殊的含义,因为它的值被用来决定你试图构建的项目的类型— `app`意味着你正在构建一个应用程序。(本章稍后将介绍其他模板选项。)
在分别设置`TARGET`、`DEPENDPATH`和`INCLUDEPATH`的`TEMPLATE`线之后有三条线。将`TARGET`设置为 nothing 意味着生成的可执行文件将以项目文件命名。例如,如果项目文件被命名为`superapp.pro`,那么生成的可执行文件将被命名为`superapp`(或者在 Windows 上被命名为`superapp.exe`)。如果你给`TARGET`指定一个名字而不是什么都没有,那么这个名字将代替项目文件的名字。
另外两个变量`DEPENDPATH`和`INCLUDEPATH`被设置为。,所以 QMake 知道您将项目的文件保存在当前目录中。两者的区别在于,QMake 在映射项目中的依赖关系时使用`DEPENDPATH`,而`INCLUDEPATH`被传递给编译器,告诉它在哪里寻找包含的文件。可以为这些变量添加更多的路径——只需用空格将它们隔开。
**注意**目录。(点号)指当前目录,正如目录..(两点)指包含当前目录的目录。
在指定了模板、选择了生成的可执行文件的名称并通知 QMake 头文件保存在哪里之后,就该告诉它编译什么了。这样做需要三个变量:`SOURCES`、`HEADERS`和`FORMS`。
`SOURCES`用于保存以`cpp`、`cc`或`cxx`结尾的源文件,具体取决于您的个人喜好。`HEADERS`用于头文件:`h`、`hpp`或`hxx`。最后,`FORMS`用于设计器表单:`ui`。
**清单 15-1。** *一个自动生成的项目文件*
######################################################################
# Automatically generated by qmake (2.01a) må 19\. mar 18:20:02 2007
######################################################################
TEMPLATE = app
TARGET =
DEPENDPATH += .
INCLUDEPATH += .
# Input
HEADERS += mainwindow.h otherdialog.h preferencedialog.h
FORMS += otherdialog.ui preferencedialog.ui
SOURCES += main.cpp mainwindow.cpp otherdialog.cpp preferencedialog.cpp
在项目文件中,使用了两个不同的赋值操作符:`=`和`+=`。第一个,`=`,替换现有值;后者,`+=`,增加了更多的现有价值。为了理解结果,你需要知道 QMake 是什么变量。
QMake 变量是字符串列表,可以放在一行中,用空格分开,或者分成不同的赋值。下面一行:
SOURCES += main.cpp dialog.cpp mainwindow.cpp
相当于这样:
SOURCES += main.cpp
SOURCES += dialog.cpp \
mainwindow.cpp
请注意,该赋值使用了`\`字符分布在两行上。通过用反斜杠结束一行,换行符被视为空白,并且该行被视为继续。
如果你反复使用`+=`,然后不小心使用了`=`,你很可能会碰到一些看起来很奇怪的 bug。因为`=`操作符替换了变量的内容,所有先前的值都将丢失。奇怪行为的另一个来源可能是当重复使用`+=`并且两次意外添加相同的值时。为了避免这种情况,您可以使用`*=`操作符,该操作符向变量添加一个值,但前提是该值不存在。
还有另一个操作符可以用来控制 QMake 变量的内容:`-=`。这个操作符从列表中删除值,当您想要从 Qt 中删除一个默认选项时可以使用它。例如,下面一行从生成项目中移除用户界面模块:
QT -= gui
您必须移除该模块,因为默认情况下它是`QT`变量的一部分。
**更多项目文件选项**
清单 15-1 中自动生成的项目文件中使用的变量不是唯一可用的变量。实际上,QMake 使用了 100 多个变量——太多了,本文无法一一介绍。这里列出了最有用的,而不是全部:
DEFINES
:该变量包含将用于配置项目的预处理器定义。有许多定义可以用来微调最终的 Qt 应用程序。例如,QT_NO_DEBUG_OUTPUT
用于关闭qDebug
消息,QT_DEBUG_PLUGINS
用于打开关于插件加载的调试信息。这些定义被传递给编译器,因此您可以在代码中使用它们。LIBS
:使用该变量链接库。使用-L
path
命令将路径添加到目录列表中,以搜索库。然后使用-l
library
(破折号,小写 L,库名)添加对实际库的引用。为了链接库/home/e8johan/mylib/libthelibrary.a
,项目文件行应该是LIBS += -L/home/e8johan/mylib –lthelibrary
。QMake 负责将这些标志(-L
和-l
)转换成当前使用的编译器。DESTDIR
:如果需要控制结果文件的最终位置,可以使用这个变量。例如,通过将它设置为../bin
,结果文件将被放置在与包含项目文件的目录相同的目录级别上的bin
目录中。
当您构建一个 Qt 应用程序时,您最终会得到许多中间文件。设计器用户界面由用户界面编译器编译成头文件,头文件由元对象编译器编译成 C++ 源文件,所有 C++ 源文件都编译成目标文件。将这些文件与源文件和头文件放在同一个目录中会导致混乱的局面。当然,运行make clean
将会清除它,但是您可以使用以下变量做得更好:
OBJECTS_DIR
:控制中间目标文件的放置位置。UI_DIR:
控制用户界面编译器生成的中间文件的位置。MOC_DIR
:控制元对象编译器生成的中间文件放在哪里。
一个好的策略是将目标文件放在./obj
中,将uic
文件放在./ui
中,将moc
文件放在./moc
目录中,方法是在项目文件中添加以下几行:
OBJECTS_DIR = obj UI_DIR = ui MOC_DIR = moc
注意添加完这些行之后,QMake 将尝试自动创建目录。在 Unix 平台上,通常使用目录.obj
、.ui
和.moc
,因为它们在默认情况下是隐藏的。
使用 QMake 管理资源
当将资源嵌入到可执行文件中时,创建一个从项目文件中引用的资源文件。资源可以是图标、翻译或应用程序使用的任何其他文件。(参考第四章了解更多关于资源文件格式的信息。)
注意这里说的资源是 Qt 资源,不是 Windows 资源。
资源文件通常具有文件扩展名qrc
。它们被添加到项目文件的RESOURCES
变量中,这使得资源编译器rcc
将指定的资源编译成一个中间 C++ 源文件。您可以通过使用RCC_DIR
变量来控制这些中间文件的位置。
配置 Qt
在构建过程中,有几种方法可以配置 Qt。例如,您可以控制包含 Qt 的哪些部分以及这些部分的行为方式,这使您能够构建只使用 Qt 中所需部分的应用程序,从而减少可执行文件和内存占用。在对DEFINES
变量的讨论中,您已经看到了一些可以用来做这件事的定义,但是您将在本节中看到更多。
控制包含 Qt 的哪些部分的两个主要变量是QT
和CONFIG
。QT
控制项目中包含的模块。默认包括core
和gui
。以下模块可用(取决于您使用的 Qt 版本):
core
:核心模块gui
:用户界面模块QtGui
,用于所有具有图形用户界面的应用程序network
:模块QtNetwork
,用于第十四章opengl
:模块QtOpenGL
,用于第七章sql
:模块QtSql
,用于第十三章svg
:模块QtSvg
,用于第七章xml
:模块QtXml
,用于第八章qt3support
:Qt3Support
模块,用于使 Qt 3 应用程序移植到 Qt 4 更加容易
第二个主要变量是CONFIG
变量,默认情况下通常以合理的方式设置。最常用的值如下:
thread
:如果包含的话,应用程序是支持多线程的。- 如果包含的话,Windows 应用程序将有一个控制台。例如,该控制台用于显示
qDebug
消息。 release
:以发布模式构建项目。debug
:在调试模式下构建项目。debug_and_release
:在发布和调试模式下构建项目。plugin
:构建一个插件。dll
:构建一个动态链接库,也称为共享对象。qttestlib
:为构建单元测试添加 Qt 支持库。
构建一个 QMake 项目
为 Qt 项目创建项目文件后,需要运行 QMake 来创建适当的 Makefile 或项目。最简单的方法是在与项目文件相同的目录下,在命令行界面中键入qmake
。它将使用平台默认值来生成一个合适的 Makefile。
你也可以使用 QMake 为 Visual Studio 生成一个项目文件。只需运行qmake -t vcapp
来生成这样一个文件(用vclib
替换vcapp
来构建一个库项目)。要为 Xcode 生成一个项目文件,运行qmake -spec macx-xcode
。
您还可以在 QMake 调用中添加项目文件行。例如,qmake "CONFIG+=console"
相当于将行CONFIG+=console
添加到您的项目文件中。
如果您选择使用 QMake 创建 Makefile,那么您可以使用一个简单的make
命令来构建您的项目(如果您使用的是 Visual Studio,那么可以使用nmake
)。你可以使用make clean
清理你的中间文件。稍微残酷一点的步骤是运行make distclean
,它清理所有生成的文件,包括 Makefile。您必须再次运行 QMake 来获得make
的 Makefile。
使用不同的平台
在使用平台无关的工具包(比如 Qt)时,您可能希望能够处理平台细节,这有很多原因。例如,您可能希望在不同的平台上使用不同的图标,或者拥有一段依赖于平台的自定义源代码。根据所使用的平台,QMake 使得以稍微不同的方式构建项目变得容易。
不同的平台使用一个叫做作用域的概念来处理。Qt 支持很多作用域,但最常见的是:
debug
:项目正在调试模式下构建。release
:项目正在以发布模式构建。- 这个项目是在 Windows 环境下构建的。
- 这个项目是在 Mac OS X 环境下构建的。
unix
(包括 Linux):项目正在 Unix 环境中构建。
您可以用两种不同的方式处理作用域。你可以使用括号,如图所示,在这里选择if-else
结构库:
win32 { LIBS += -lmywin32lib } else macx { LIBS += -lmymacxlib } else { LIBS += -lmyunixlib }
您可以使用:运算符组合作用域;比如macx:debug: ..
。相当于写macx { debug {
...} }
。运算符带来了指定范围的另一种方法。您可以这样设置LIBS
变量:
win32:LIBS += -lmywin32lib macx:LIBS += -lmymacxlib !win32:!macx:LIBS += -lmyunixlib
注意到!
操作符被用来反转作用域。!win32:!macx
的意思不是win32
也不是macx
。
Windows 特有的功能
如果您希望能够显示调试输出,您可以将值console
添加到CONFIG
变量中。更微妙的方法是限制对 Windows 和调试模式应用程序的更改:
win32:debug:CONFIG += console
它确保您不会为以发布模式构建的应用程序打开控制台窗口。
为 Windows 平台构建应用程序时需要注意的另一个问题是应用程序图标(Explorer 在显示可执行文件时使用的图标)。
提示你使用setWindowIcon
方法来设置应用程序窗口的图标。
Windows 上的应用程序图标由一个 Windows 资源表示(不要与 Qt 资源混淆),所以您必须创建一个 Windows 资源文件,并将其添加到 Qt 项目文件中。首先你需要创建一个文件格式为ico
的图标。有许多工具可以创建这些文件(例如 Gimp 和 Visual Studio 中的图标编辑器,但是搜索互联网会发现许多替代工具)。
创建图标后,您需要创建 Windows 资源文件,这是一个文件扩展名为rc
的文件。该文件应包含以下行。
IDI_ICON1 ICON DISCARDABLE "filename.ico"
用你的图标替换filename.ico
。要将资源文件添加到项目文件中,只需添加一行代码RC_FILE += filename.rc
,其中filename.rc
是您的 Windows 资源文件。没有必要给这一行加上前缀win32
scope,因为在它不适用的平台上它会被忽略。
OS X 特有的特征
Mac OS X 和 Qt 支持的其他平台之间的最大区别是能够在几个处理器平台上运行相同的应用程序。可用的处理器 PowerPC 和 Intel x86 有许多不同之处——最麻烦的是字节序。确保总是使用 Qt 流来加载和存储数据——不仅是文件,还有数据库、网络流和其他可以被两个处理器读写的缓冲区。该问题存在于多字节值的字节顺序中。例如,如果您不决定坚持哪种字节序,一个平台上的 32 位整数读数0x12345678
在另一个平台上会被读作0x78563412
。
在 Mac OS X 平台上配置 Qt 时,可以使用-universal
标志,这使得创建通用二进制文件成为可能。您可以使用CONFIG
变量以及ppc
和x86
值来控制支持哪些处理器。键入CONFIG += x86 ppc
创建一个通用项目,可以在任一平台上执行。
与 Windows 应用程序一样,OS X 应用程序也有应用程序图标。Mac 平台上使用的文件格式是icns
。您可以使用几个工具创建icns
文件(在互联网上搜索例子)。Apple 提供了图标编辑器,这是推荐使用的工具。在您创建了一个icns
文件之后,您需要使用一行代码ICON =
filename.icns
将它添加到您的项目文件中,其中filename.icns
是您的图标文件。
特定于 Unix 和 X11 的特性
在 Unix 系统上构建和部署通常比在 Windows 上更困难,因为 Unix 有很多种。对于每一种风格,都有几种不同的桌面环境。(桌面环境是用户看到和使用的,可以处理开始菜单、停靠、窗口样式等等。)处理所有这些组合意味着做事有几种方式,做正确的事情有许多变体。
另一个需要解决的问题是,Qt 库可能已经安装在您的目标系统上。您需要找出什么版本和在哪里,这至少可以通过两种方式来实现。一种方法是将您的应用程序静态链接到 Qt,这意味着如果 Trolltech 决定发布您的 Qt 版本的更新,将会有更大的可执行文件并且没有自动更新。
另一个选项仅适用于 Linux 系统。您可以要求系统支持 Linux 标准库(LSB ),因为 Qt 4.1 作为可选的 LSB 模块提供。请访问[
www.linuxstandardbase.org](http://www.linuxstandardbase.org)
了解更多信息。
现在简单看一下,在正确安装 Qt 应用程序后,如何将它集成到当前的桌面环境中。
提示欲了解更多信息,请访问[
www.freedesktop.org](http://www.freedesktop.org)
。
让我们看看应用程序图标是如何设置的。Unix 二进制不知道图标的概念资源。相反,桌面条目文件用于描述每个应用程序。这些文件的文件扩展名为desktop
,通常存储在$XDG_DATA_DIRS/applications
或/usr/share/applications
中。清单 15-2 中的显示了一个示例文件。
**清单 15-2。**my application 项目的示例桌面文件
[Desktop Entry] Type=Application Name=My Application Exec=myapplication %F MimeType=image/x-mydata; Icon=/install/path/myicon.png
在清单中,[Desktop Entry]
这一行告诉您接下来是一个桌面条目。接下来是Type
,它告诉你条目将描述一个应用。根据Name
的说法,这个应用程序叫做My Application
。Exec
行告诉桌面发出什么命令来启动应用程序;这种情况下就是myapplication
。如果用户试图打开一个或多个数据文件,那么%F
部分告诉桌面在哪里列出文件名。使用定义 mime 类型的MimeType
条目来处理这些数据文件和应用程序之间的连接;即应用程序处理的文件类型。
最后一行Icon
,告诉你使用哪个图标。最简单的方法是指定图标的绝对路径。如果仅指定文件名,则必须确定存储图标文件的位置,以便桌面环境可以找到它。
在 Unix 上安装应用程序时,通常支持 make target install
,这使用户能够键入make install
将应用程序文件复制到一个全局位置。QMake 使用安装集支持这一点。
一个安装集是一个带有三个子值的 QMake 变量:path
、files
和extra
。我们来看一个例子。假设您想要安装一组插件,它们位于与项目文件相关的子目录plugins
中。当应用程序安装完成后,您希望这些文件位于/usr/local/myapplication/plugins
中。如下指定,其中最后一行将插件安装集添加到 install make 目标:
plugins.files = plugins/* plugins.path = /usr/local/myapplication/plugins INSTALLS += plugins
您还希望在一个名为plugins.lst
的文件中有一个插件列表,这就是extra
子值的用途。它使您能够指定在复制文件之前要运行的命令列表。通过添加下面一行,在插件被复制到适当位置之前创建了该列表:
plugins.extra = rm -f ./plugins/plugins.lst; ls −1 ./plugins > ./plugins/plugins.lst
这一行由一个rm
命令组成,该命令删除任何现有的plugins.lst
文件,因为如果存在的话,该列表将包含在插件列表中。然后执行一个ls
命令,构建一个新的列表,通过管道传输到plugins.lst
文件中。
有一个特殊的安装集,代表 QMake figures 想要复制的文件:target
。通过指定一个路径并将其添加到INSTALLS
,QMake 负责剩下的工作:
target.path = /usr/local/myapplication INSTALLS += target
因为可以在所有平台上使用 make 作为构建系统,所以建议使用平台范围来保护安装集(特别是,extra
值中列出的命令需要适应不同的平台)。
使用 QMake 构建库
到目前为止,您一直在处理构建应用程序的项目。QMake 也可以用于构建库,包括静态库、动态库和插件(一种特殊的动态库)。要让 QMake 做到这一点,您必须将TEMPLATE
变量改为lib
。
一个图书馆项目的例子显示在清单 15-3 中。该项目使用SOURCES
和HEADERS
变量的方式与构建应用程序时相同。合并TARGET
和VERSION
来创建结果库的文件名,这是避免版本问题的常用方法。因为不同版本的库有不同的名称,所以问题得以避免。
注意使用VERSION
意味着你的库的名字会被修改。不要让这个迷惑你。
CONFIG
变量用于控制正在构建的库的类型。通过添加值dll
来构建动态库。其他可能的值有staticlib
,它构建一个静态库,还有plugin
,它用于构建插件。注意,添加值plugin
也隐含地添加了值dll
,因为插件是一个动态库。
清单 15-3。 一个建库的项目文件
`TEMPLATE = lib
TARGET = mylib
VERSION = 1.0.0
CONFIG += dll
HEADERS += mylib.h
SOURCES += mylib.cpp`
用于库的文件扩展名在不同的平台和编译器之间是不同的(这都是由 QMake 处理的)。例如,永远不要给TARGET
变量指定文件扩展名;让 QMake 来处理它。
使用 QMake 构建复杂的项目
通常,构建一个库或一个应用程序就足够了,但有时您的项目由几个部分组成,从而产生几个库和几个应用程序。QMake 也足够强大来处理这些情况。让我们看看它会是什么样子。
这里显示的项目由一个库和一个应用程序组成。库叫base
,应用叫app
。项目文件的结构如清单 15-4 所示。主项目文件complex.pro
位于基础层,还有目录bin
、lib
、app
、include
和src
。bin
和lib
目录为空。
app
目录包含应用程序的源代码和项目文件。include
目录包含库的头文件;也就是说,库和应用程序之间共享的头文件。src
目录包含库的源代码和项目文件。
两个空目录lib
和bin
,分别用于从src
的内容构建的库和从app
生成的应用程序二进制文件。
注意因为lib
和bin
目录仅用于保存构建的文件,所以您可以省略它们;QMake 会在被要求放置文件时创建它们。
清单 15-4。 复杂项目的文件和目录
| complex.pro | +---bin +---lib | +---app | | app.pro | | appwindow.cpp | | appwindow.h | | main.cpp | +---include | base.h | \---src | base.cpp | src.pro
主项目文件complex.pro
如清单 15-5 所示。它用的是TEMPLATE
,对你来说是新的。subdirs
模板用于处理放置在不同子目录下的多个项目文件。需要注意的目录在SUBDIRS
变量中列出。CONFIG
值ordered
告诉 QMake 按照它们被添加到SUBDIRS
变量的顺序构建不同目录的项目。如果未指定,则构建顺序未定义。
**清单 15-5。**complex . pro 项目文件
TEMPLATE = subdirs SUBDIRS = src app CONFIG += ordered
整个文件告诉 QMake 首先在src
目录中构建项目,然后在app
目录中构建项目。让我们继续跟随 QMake 到src
目录。
在src
目录中,QMake 找到了src.pro
项目文件(参见清单 15-6 )。根据项目文件所在的目录来命名项目文件是一种常见的策略。如果你在一个目录中运行qmake -project
,就会发生这种情况,但是你也可以手动创建项目文件。
src
目录中文件的目的是构建一个应用程序使用的库;也就是app
目录的内容。库的源代码保存在src
,头文件保存在include
,结果库放在lib
。头文件保存在include
中,因为它们在复杂项目的所有部分之间共享,而include
目录包含了按照惯例所有部分共有的头文件。
项目文件的第一部分告诉 QMake 使用TEMPLATE
变量创建一个库。然后使用TARGET
指定库的名称,使用VERSION
指定版本,并设置CONFIG
以便创建一个静态库。
这个库打算在lib
目录中结束,所以DESTDIR
变量被设置为../lib
,这是到那个目录的相对路径。
项目的头文件存储在项目全局包含目录中。您必须将该路径添加到INCLUDEPATH
和DEPENDPATH
变量中。项目的源文件和项目文件存储在同一个目录中,所以DEPENDPATH
也包含了对.
目录的引用。
设置好包含文件和项目文件的路径后,列出SOURCES
和HEADERS
。因为包含头文件的目录包含在DEPENDPATH
变量中,所以不必给它添加相对路径;QMake 无论如何都会找到的。
清单 15-6。 用于构建库的 src.pro 项目文件
`TARGET = base
VERSION = 0.1.0
CONFIG += static
DESTDIR = ../lib
INCLUDEPATH += ../include
DEPENDPATH += . ../include
SOURCES += base.cpp
HEADERS += base.h`
在 QMake 访问了src
目录后,它将继续访问app
目录和app.pro
项目文件。这个项目的目的是创建一个使用从src
项目构建的库的应用程序。
app.pro
项目文件如清单 15-7 所示。正如所料,它首先将TEMPLATE
设置为app
,表明您正在构建一个应用程序。然后,通过将TARGET
设置为app
并将DESTDIR
设置为../bin
,文件继续。这告诉 QMake 创建一个名为app
(在 Windows 上为app.exe
)的应用程序二进制文件,并将其放在bin
目录中。
下一组线设置了INCLUDEPATH
和DEPENDPATH
。包含路径被设置为同时包含.
和../include
,因为应用程序使用位于.
目录中的应用程序本地头文件和位于include
目录中的复杂项目部分全局头文件。注意,全局头文件属于库项目,所以它们不包含在DEPENDPATH
中。
接下来是LIBS
行,这是由src.pro
项目文件创建的库链接到这个项目的地方。第一个值-L../lib
告诉 QMake 库存储在lib
目录中。下一个值-lbase
,告诉 QMake 将应用程序链接到base
库。
项目文件的最后是源文件和头文件的列表。这些是应用程序项目的本地源文件。
清单 15-7。app.pro
项目立项申请
`TEMPLATE = app
TARGET = app
DESTDIR = ../bin
INCLUDEPATH += . ../include
DEPENDPATH += .
LIBS += -L../lib -lbase
SOURCES += appwindow.cpp main.cpp
HEADERS += appwindow.h`
要构建这个项目,使用命令行 shell 转到包含complex.pro
的目录。从这里运行qmake
会创建一个顶级的Makefile
。奔跑中的make
现在将依次造访src
和app
。当访问每个子目录时,从本地项目文件创建一个Makefile
,然后运行make
来构建每个子项目。
结果是先建库;然后是申请。结果文件将被放在预期的位置:在bin
和lib
目录中。
CMake 构建系统
CMake 构建系统([
www.cmake.org](http://www.cmake.org)
)是一个通用的构建系统。它并不专注于构建 Qt 应用程序;它专注于构建任何类型的应用程序。Qt 开发人员对此很感兴趣,因为 KDE 项目选择在 KDE 4 平台上使用 CMake。通用构建系统的缺点是使用 CMake 可能比使用 QMake 涉及的工作量稍多。然而,这并不意味着很难使用 CMake。该工具对 Qt 和 KDE 都有很好的支持。
虽然 CMake 和 QMake 都可以执行任何任务,但是 QMake 稍微偏向于 Qt 应用程序(尽管它在其他项目中也很有用)。另一方面,CMake 有一个 QMake 没有的特性:在源代码构建之外执行的能力,因此构建过程——及其所有中间文件——可以保存在源代码树之外。当您使用 CVS 或 Subversion 这样的版本控制系统时,这个特性非常方便。因为构建过程不把它的中间文件放在项目的源代码树中,所以它可以与所有不受版本控制的文件保持清洁。这大大降低了意外向源存储库添加中间文件的风险。
注意本文假设您使用的是最新版本的 CMake(至少是版本 2.4)。
使用 QMake 管理简单的应用程序
让我们从使用清单 15-1 中的 QMake 项目文件构建的同一个项目开始。它包括对源文件、头文件和用户界面文件的引用,以及控制 QMake 将产生什么和如何产生的配置(见清单 15-8 )。
所有的 CMake 项目都在一个名为CMakeLists.txt
的文件中描述,这个文件对应于 QMake 使用的项目文件。每个 CMake 文件都是基于一个项目的,所以文件从使用PROJECT
命令将项目名设置为basics
开始。
您可以通过SET
命令设置变量basics_SOURCES
、basics_HEADERS
和basics_FORMS
继续。这些变量像 QMake 变量一样工作;它们被设置为一个值列表。SET
命令接受一个参数列表,其中第一个参数是要设置的变量的名称。以下参数是值。
变量名都以前缀basics_
开头。(这个约定不是必须的,但是很方便。)同样的约定告诉您为源、头和表单创建变量。这对于任何使用过 QMake 的人来说都很熟悉——这就是我们的目的。
接下来的两行介绍了 CMake 对 Qt 4 的支持。首先,FIND_PACKAGE
用于定位Qt4
包。这个包被标记为REQUIRED
,这意味着如果 Qt 4 不存在,构建将会停止。然后使用INCLUDE
命令设置包含 Qt 头文件和库的目录。在INCLUDE
命令中,使用了${
variable
}语法(指变量值)。
下一步是使用刚刚包含的命令。首先,让元对象编译器使用QT4_WRAP_CPP
命令从头文件创建 C++ 源文件。第一个参数是一个变量名,它包含元对象编译器创建的 C++ 源文件的名称。
当元对象编译完成后,就可以用QT4_WRAP_UI
命令将用户界面编译成头文件了。这个命令就像QT4_WRAP_CPP
命令一样工作,产生一个变量,该变量包含对生成的文件的引用。
当使用 CMake 构建软件时,了解如何处理外部源代码是很重要的。源文件位于由CMAKE_CURRENT_SOURCE_DIR
定位的源目录中,而中间文件和整个构建系统位于保存在CMAKE_CURRENT_BINARY_DIR
中的二进制目录中。在源代码树内部构建时,这两个变量指向同一个目录;否则不会。
因为用户界面编译器生成的头文件是在编译时创建的,所以它们将位于二进制目录中。因为这些文件包含在位于源树中的源文件中,所以您必须在二进制目录和源树中查找包含文件。因此,使用INCLUDE_DIRECTORIES
命令将CMAKE_CURRENT_BINARY_DIR
添加到包含路径中。
在准备构建之前,您需要设置正确的预处理器定义来控制 Qt 库是如何构建的。Qt 定义保存在QT_DEFINITIONS
变量中,该变量使用ADD_DEFINITIONS
命令添加到构建环境中。
下一个命令ADD_EXECUTABLE
,是在应用程序中生成结果的命令。它定义了一个名为basics
的应用程序,这个应用程序是从源代码、元对象和用户界面头构建的。用户界面头不会被编译成任何东西,因为它们是头文件。但是,应用程序有必要引用它们,否则 CMake 会错过依赖它们的内容。如果可执行文件或库不直接或间接地依赖于构建系统的一部分,那么它就不会被构建。
在创建整个构建环境之前,您必须告诉 CMake 在项目文件的最后使用TARGET_LINK_LIBRARIES
命令将应用程序链接到 Qt 库。先前在INCLUDE
命令中导入了QT_LIBRARIES
变量,它包含了该项目所需的所有库的引用。
清单 15-8。 一个基本 Qt 应用程序的 CMake 项目文件
`PROJECT( basics )
SET( basics_SOURCES main.cpp mainwindow.cpp otherdialog.cpp preferencedialog.cpp )
SET( basics_HEADERS mainwindow.h otherdialog.h preferencedialog.h )
SET( basics_FORMS otherdialog.ui preferencedialog.ui )
FIND_PACKAGE( Qt4 REQUIRED )
INCLUDE( $ )
QT4_WRAP_CPP( basics_HEADERS_MOC $ )
QT4_WRAP_UI( basics_FORMS_HEADERS $ )
INCLUDE_DIRECTORIES( $ )
ADD_DEFINITIONS( $ )
ADD_EXECUTABLE( basics $
$ $ )
TARGET_LINK_LIBRARIES( basics $ )`
运行 CMake
要构建清单 15-8 所示的项目文件,你需要理解 CMake 是如何执行的。在查看命令行选项之前,先看看 CMake 提供的特性。
您可以从源代码树运行 CMake,就像 QMake 一样,将中间文件和结果留在源代码树中。还可以从远程目录运行 CMake,从而得到一个干净的源代码树。这意味着中间文件和结果文件(如应用程序和库)不会出现在源代码树中。通过保持源代码树没有这些文件,您可以将整个源代码树始终置于版本控制之下。这意味着在将源代码添加到版本控制中时,您不必清除任何不需要的文件,并且可以避免将中间文件添加到您可能会意外使用的版本控制系统中的风险。
您还可以使用 CMake 为许多不同的构建系统创建项目。不同的系统使用不同的生成器。在 Linux 和 Mac OS X 上,默认的生成器通常是有效的。这个生成器针对 GCC 系统。在 Windows 上,可能需要指定要使用的生成器。如果你和 MinGW 一起使用 Qt 的开源版本,要使用的生成器是MinGW Makefiles
。您可以使用-G
命令行选项来实现这一点——稍后将详细介绍。其他受支持的构建系统包括各种 Microsoft 编译器、Borland、Watcom、MSYS 和通用 Unix Makefiles。
当运行 CMake 时,在您的PATH
环境变量中准备好您计划使用的所有工具是很重要的,包括您的编译器和 Qt 工具(比如uic
和moc
)。这些工具通常在路径中;如果没有,CMake 会告诉你它找不到什么。
那么,你如何实际运行 CMake 呢?第一步是启动一个命令提示符,将您定向到您的项目目录(包含CMakeLists.txt
文件的目录)。在这个目录中,您可以使用下面的代码行在源代码树中构建项目:
cmake .
在 Windows 上,您可能需要使用-G
命令行选项告诉 CMake 使用 MinGW 进行构建。这将为您提供以下命令:
cmake . -G "MinGW Makefiles"
传递给 CMake 的.
指的是当前目录。它告诉 CMake 这是源目录。如果您想在源代码之外构建,这就是您告诉 CMake 要构建什么的方式。让我们像以前一样从项目目录开始,但是现在在您创建的单独目录中构建:
mkdir build cd build cmake ..
有时,您可能必须将-G "MinGW Makefiles"
添加到cmake
命令中,才能让它正常工作。通过在源代码树之外进行构建,您可以看到 CMake 创建了哪些文件以及系统是如何工作的。
可能给您带来麻烦的一个中心文件是 CMake 生成的CMakeCache.txt
。如果您想要更改生成器,您需要删除这个文件以让 CMake 重新生成构建系统。
CMake 还创建了一个CMakeFiles
目录,其中包含许多在构建过程中创建的中间文件。但是,元对象编译器和用户界面编译器生成的文件不放在这里。相反,它们被放在生成它们的文件旁边,或者,如果在源代码之外构建,则放在构建目录中的相应位置。
使用 CMake 管理资源
资源和 Qt 资源编译器的处理方式与元对象编译器和用户界面编译器相同。这些步骤包括设置一个变量,通常命名为project
_RESOURCES
,它包含项目的资源文件的名称。这个变量对应于 QMake 项目中的RESOURCES
变量。
这个变量然后被传递给宏QT4_ADD_RESOURCES
,它作为宏QT4_WRAP_CPP
和QT4_WRAP_UI
工作。这意味着最左边的参数是一个变量,用来保存其余参数的结果。结果变量一般命名为project``_RESOURCES_SOURCES
;然后将其添加到ADD_EXECUTABLE
命令的可执行文件中。
下面的列表显示了取自一个虚构项目的相关行:
`SET( foo_RESOURCES foo.qrc )
QT4_ADD_RESOURCES( foo_RESOURCES_SOURCES $ )
...
ADD_EXECUTABLE( foo ... $ ... )`
配置 Qt 模块
因为 Qt 由许多模块组成,所以能够控制使用哪些 Qt 模块是很重要的。你可以通过使用一系列的QT_USE_QT
module
和QT_DONT_USE_QT
module
变量来实现。在调用FIND_PACKAGE
定位Qt4
包之前,将这些变量设置为TRUE
(使用SET
命令)。这使得链接中使用的QT_LIBRARIES
变量包含了对所需模块的引用。
包括和排除模块的一些可用变量如下所示:
QT_DONT_USE_QTCORE
:不要链接到QtCore
模块。这个变量几乎从来不用。QT_DONT_USE_QTGUI
:不要链接到QtGui
模块。QT_USE_QT3SUPPORT
:链接到Qt3Support
模块——用于帮助将 Qt 3 应用移植到 Qt 4。QT_USE_QTASSISTANT
:联动过程中包含助手模块。QT_USE_QTDESIGNER
:在联动过程中包含设计器模块。QT_USE_QTNETWORK
:联动过程中包含QtNetwork
模块。QT_USE_QTOPENGL
:联动过程中包含QtOpenGL
模块。QT_USE_QTSQL
:联动过程中包含QtSql
模块。QT_USE_QTXML
:联动过程中包含QtXml
模块。
注意使用Qt3Support
模块时,间接链接到QtNetwork
、QtSql
和QtXml
模块。在某些平台上,有必要明确指定您正在使用这些模块。
使用不同的平台
使用 CMake 时,您会遇到与使用 QMake 时相同的特定于平台的问题。为了区分平台,有许多变量被设置为true
,这取决于当前的 make 环境。最常见的列举如下:
WIN32
:true
如果建筑在窗户上APPLE
:true
如果建在 OS XUNIX
:true
如果构建在类似 Unix 的环境中,包括 OS X 和 LinuxMINGW
:true
如果使用 MinGW 编译器构建MSYS
:true
如果在 MSYS 环境中构建MSVC
:true
如果使用微软编译器构建
要测试变量,使用IF( var )
...ELSE( var )
...ENDIF( var )
建造。如果在 Windows 上使用 MinGW 作为构建环境,你可以使用清单 15-9 中的语句来区分平台:Windows、OS X 和 Unix/X11。只需用每个系统的平台细节替换注释行。
注意 CMake 认为一个#
字符右边的所有文本都是注释。
清单 15-9。 区分可用平台
IF( MINGW ) # Windows, MinGW specifics here (i.e. Qt open source on Windows) ELSE( MINGW ) IF( APPLE ) # OS X specifics here ELSE( APPLE ) # Linux / Unix specifics here ENDIF( APPLE ) ENDIF( MINGW )
QMake 和 CMake 在平台细节方面的差异只影响您解决给定问题的方式。要解决的问题还是一样的。
当使用这里介绍的解决方案时,您需要确保添加了前面显示的适当的IF
命令。
Windows 特有的功能
在 Windows 中构建图形应用程序时,能够控制是否显示控制台非常重要。这与将console
添加到 QMake 项目的CONFIG
变量中所解决的问题相同。
注意这里介绍的特定于 Windows 的解决方案可以与 MinGW 编译器一起使用,后者是开源版 Qt for Windows 附带的编译器。如果您使用另一个编译器,您将不得不修改该编译器的解决方案。
控制控制台可用性的方法是在链接时在windows
和console
子系统选项之间切换。将下面一行添加到您的CMakeLists.txt
文件将给您一个没有控制台输出的应用程序:
SET( LINK_FLAGS -Wl,-subsystem,windows )
相反,使用控制台运行的应用程序是通过以下代码行实现的:
SET( LINK_FLAGS -Wl,-subsystem,console )
您还必须修改您的TARGET_LINK_LIBRARIES
调用,以包含LINK_FLAGS
变量,这将为您提供如下代码行:
TARGET_LINK_LIBRARIES(
project ${QT_LIBRARIES} ${LINK_FLAGS} )
另一个需要解决的问题是应用程序图标。设置应用程序图标的实际操作是使用特殊的编译器从给定的 Windows 资源文件创建一个目标文件。下面的清单显示了 Windows 资源文件appicon.rc
是如何编译成appicon.o
的。然后,该文件被添加到项目源代码中,以便以后包含在实际的二进制文件中。
`ADD_CUSTOM_COMMAND(
OUTPUT $/appicon.o
COMMAND windres.exe
-I$
-i$/appicon.rc
-o $/appicon.o )
SET(project_SOURCES $ $/appicon.o)`
注意CMake 命令可以分成几行,这就是为什么定制命令看起来很奇怪。
ADD_CUSTOM_COMMAND
用于将定制构建方法插入到 CMake 生成的 Makefile 中。它由OUTPUT
部分组成,列出了定制步骤生成的文件。在前面的清单中,输出是appicon.o
文件。第二部分是COMMAND
部分,指定要运行的实际命令。清单运行windres.exe
文件,将-I
、-i
和-o
命令行选项传递给它。
OS X 特有的特征
OS X 有一些特性,包括能够为 PowerPC 和 x86 平台使用相同的可执行二进制文件——一个通用的二进制文件。要创建这样的可执行文件,使用CMAKE_OSX_ARCHITECTURES
变量并将其设置为ppc;i386
:
SET( CMAKE_OSX_ARCHITECTURES ppc;i386 )
注意保持ppc;i386
值一致很重要。不要添加空格。
要使用 CMake 设置应用程序图标,需要构建一个应用程序包,这并不像看起来那么难(CMake 处理大部分细节)。你所要做的就是设置一些值,然后在最后的构建阶段做一些调整。这些变量如下:
MACOSX_BUNDLE_ICON_FILE
:要使用的图标文件(以icns
文件格式)。MACOSX_BUNDLE_BUNDLE_NAME
:捆绑包的名称。MACOSX_BUNDLE_COPYRIGHT
:版权信息。MACOSX_BUNDLE_INFO_STRING
:信息字符串。MACOSX_BUNDLE_GUI_IDENTIFIER
:作为 Java 风格包名的唯一标识符。这意味着看起来像一个颠倒的 web 服务器名称,例如,se.thelins.exampleApplication
就是这样一个字符串。MACOSX_BUNDLE_BUNDLE_VERSION
:版本字符串。MACOSX_BUNDLE_SHORT_VERSION_STRING
:短版本字符串。MACOSX_BUNDLE_LONG_VERSION_STRING
:长版本字符串。
在为这些字符串设置值之后,您必须告诉 CMake 在调用ADD_EXECUTABLE
命令时创建一个包,方法是将下面一行添加到CMakeLists.txt
文件中:
ADD_EXECUTABLE(
exename MACOSX_BUNDLE ... )
特定于 Unix 和 X11 的特性
对于 Unix 系统,你需要让运行make install
成为可能,所以 CMake 在安装之前必须知道要构建什么,要安装什么文件。例如,您不想将任何中间文件复制到安装目录中。
CMake 希望用户在运行 CMake 创建构建环境时指定CMAKE_INSTALL_PREFIX
变量。它可能类似于下面的代码行,其中.
。指的是CMakeLists.txt
文件,/usr/local
目录是安装目标:
cmake .. -DCMAKE_INSTALL_PREFIX=/usr/local
可以安装两种类型的文件:目标文件和现有文件。目标是构建过程的结果。它们可以是名为RUNTIME
的可执行文件,名为LIBRARY
的动态链接库,名为ARCHIVE
的静态链接库。使用ADD_EXECUTABLE
命令创建RUNTIME
目标。在本章的后面,你会学到如何创建库。
使用INSTALL
命令来指定要安装的目标和安装位置。它可能看起来像这样:
INSTALL( TARGETS
exenames RUNTIME DESTINATION bin LIBRARY DESTINATION lib )
exenames
可以是目标名称的列表,包括可执行文件和任何类型的库。RUNTIME DESTINATION
指定了RUNTIME
目标相对于安装前缀的位置。本节前面的INSTALL
命令结合cmake
命令行会将这些文件放在/usr/local/bin
目录中。LIBRARY DESTINATION
以同样的方式工作。如果需要安装静态链接库,可以使用ARCHIVE DESTINATION
指令来放置它们。您通常会从静态链接库构建可执行文件,这就是为什么我没有在前面的INSTALL
命令中为它们指定目标目录。
前面提到了目标和现有文件。现有文件可以是文档文件、图标或任何其他不是在构建过程中生成的文件。要安装这些文件,结合使用FILES
指令和INSTALL
命令。语法如下所示:
INSTALL( FILES
filesDESTINATION
directory )
在前面一行中,files
表示源代码树中的文件列表。directory
的指定与安装目标时的bin
和lib
相同。常见的目录是share/
appname
,其中appname
是应用程序的名称。
清单 15-10 显示了一个包含目标和文件的部分例子。
清单 15-10。 为安装设置文件
`SET( foo_DOCS docs/index.html docs/details.html )
...
ADD_EXECUTABLE( fooexe ... )
...
INSTALL( TARGETS fooexe
RUNTIME DESTINATION bin )
INSTALL( FILES $
DESTINATION share/foo/docs )`
使用 CMake 构建库
用 CMake 构建库真的很容易。您可以使用ADD_LIBRARY
命令,而不是像构建应用程序时那样使用ADD_EXECUTABLE
命令。要指定构建的是动态加载库还是静态库,请使用如下所示的SHARED
或STATIC
指令:
ADD_LIBRARY(
dllnameSHARED
dlldependencies) ADD_LIBRARY(
libnameSTATIC
libdependencies )
插件是一个共享库,但是建立在特定的环境中。这意味着在创建库目标之前,您必须使用ADD_DEFINITIONS
命令向构建环境添加三个预处理器定义:
ADD_DEFINITIONS( -DQT_PLUGIN ) ADD_DEFINITIONS( -DQT_NO_DEBUG ) ADD_DEFINITIONS( -DQT_SHARED ) ADD_LIBRARY(
pluginnameSHARED
plugindependencies )
添加的定义在发布模式下创建一个插件。如果不在发布模式下创建它,它将不会出现在设计器等工具中,因为它们是在发布模式下构建的。当在你的应用程序中使用插件时,规则是在发布和调试模式时要匹配应用程序和插件。
注意当然,添加的定义必须与您的 Qt 库的配置相匹配。如果你的 Qt 库是静态的,QT_SHARED
不应该被定义。
使用 CMake 管理复杂项目
应用程序项目通常由多个组件组成。通常的设计由一个或多个用于构建一个或多个应用程序的库组成。建立什么依赖什么,建立这样一个系统不是一件简单的事情。
在这一节,你将使用来自清单 15-4 的项目,但是用 CMake 代替 QMake。CMake 设置的文件和目录如清单 15-11 所示。比较这两个清单可以发现,所有的 QMake 项目文件都被替换成了CMakeLists.txt
文件。app
和bin
目录也被替换为build
目录,因为您将把构建过程放在源代码树之外。
清单 15-11。 复杂 CMake 项目中的文件和目录
| CMakeLists.txt | +---build | +---app | | CMakeLists.txt | | appwindow.cpp | | appwindow.h | | main.cpp | +---include | base.h | \---src | CMakeLists.txt | base.cpp
我们先来看一下CMakeLists.txt
,它位于项目根目录下。您可以在清单 15-12 中看到整个文件,它从定义一个名为complex
的项目开始。
项目命名之后的步骤将变量EXECUTABLE_OUTPUT_PATH
和LIBRARY_OUTPUT_PATH
初始化到PROJECT_BINARY_DIR
目录中的bin
和lib
目录。回想一下外源码构建对比内源码构建的解释:PROJECT_BINARY_DIR
代表构建根目录。如果构建在源代码内部,它将与代表源代码根目录的PROJECT_SOURCE_DIR
相同。
以下两个ADD_SUBDIRECTORIES
命令构建了src
和app
目录的内容(按此顺序):
清单 15-12。 根 CMake 文件
`PROJECT( complex )
SET( EXECUTABLE_OUTPUT_PATH $/bin )
SET( LIBRARY_OUTPUT_PATH $/lib )
ADD_SUBDIRECTORY( src )
ADD_SUBDIRECTORY( app )`
src
目录下的CMakeLists.txt
文件如清单 15-13 所示。整个文件遵循清单 15-8 中首次引入的模板,但是它的目标是一个静态库,而不是最后的应用程序。
当您使用 QMake 时,您可以设置一个依赖目录列表,在其中保存项目的源文件和头文件。因为使用 CMake 不容易做到这一点,所以您必须引用带有完整相对路径的base.h
头文件:../include
。
注意在讨论 QMake 时,依赖目录通常(但不总是)与包含文件目录相同。
因为这个库是静态的,所以假设它通过它所链接的应用程序链接到 Qt。因此你不需要在这里添加一个TARGET_LINK_LIBRARIES
命令。
LIBRARY_OUTPUT_PATH
的值从根CMakeLists.txt
文件保存到这个文件中(因为这个文件是从ADD_SUBDIRECTORIES
命令调用的),所以结果文件将被放在正确的目录中。
清单 15-13。 用于构建静态库的 CMake 文件
`SET( src_SOURCES base.cpp )
SET( src_HEADERS ../include/base.h )
FIND_PACKAGE( Qt4 REQUIRED )
INCLUDE( $ )
INCLUDE_DIRECTORIES( $/include )
QT4_WRAP_CPP( src_HEADERS_MOC $ )
ADD_DEFINITIONS( $ )
ADD_LIBRARY( base STATIC $ $ ).`
清单 15-14 显示了来自app
目录的CMakeLists.txt
文件。它很容易与清单 15-8 相比较,但是它有一些调整。
第一个是使用INCLUDE_DIRECTORIES
命令添加公共的include
目录。源文件需要这个命令来找到base.h
文件。它还将 Qt 库旁边的base
库添加到TARGET_LINK_LIBRARIES
命令中的app
目标中。
就像构建库一样,生成的可执行文件的位置由根CMakeLists.txt
文件控制。使用由EXECUTABLE_OUTPUT_PATH
指向的目录。
清单 15-14。 构建应用程序的 CMake 文件
`SET( app_SOURCES main.cpp appwindow.cpp )
SET( app_HEADERS appwindow.h )
FIND_PACKAGE( Qt4 REQUIRED )
INCLUDE( $ )
QT4_WRAP_CPP( app_HEADERS_MOC $ )
INCLUDE_DIRECTORIES( $/include )
ADD_DEFINITIONS( $ )
ADD_EXECUTABLE( app $ $ )
TARGET_LINK_LIBRARIES( app base $ )`
通过使用命令提示符进入build
目录,然后运行cmake
,引用根CMakeLists.txt
文件,您将为整个项目生成 Makefiles。运行make
现在可以构建所有的东西。在 MinGW 环境中运行它的输出显示在清单 15-15 中。可能的话,输出是彩色编码的。我突出显示了红色和紫色的线,表示一个构建的开始和这个构建的最终链接。
清单 15-15。 使用 CMake 和 MinGW 构建复杂项目
[ 14%] Generating moc_base.cxx
Scanning dependencies of target base[ 28%] Building CXX object src/CMakeFiles/base.dir/base.obj [ 42%] Building CXX object src/CMakeFiles/base.dir/moc_base.obj
Linking CXX static library ../lib/libbase.a[ 42%] "Built target base" [ 57%] Generating moc_appwindow.cxx
Scanning dependencies of target app[ 71%] Building CXX object app/CMakeFiles/app.dir/main.obj [ 85%] Building CXX object app/CMakeFiles/app.dir/appwindow.obj [100%] Building CXX object app/CMakeFiles/app.dir/moc_appwindow.obj
Linking CXX executable ../bin/app.exe [100%] "Built target app"
总结
比较 QMake 和 CMake 很困难。这两种工具几乎可以做任何事情,并且都很成熟,但是它们的侧重点不同。QMake 使得为所有平台构建基于 Qt 的软件变得非常容易。CMake 也使它变得很容易,但是因为这个工具更通用,所以需要做的工作稍微多一点。
如果你计划使用非 Qt 组件或者参与 KDE 项目,那么推荐使用 CMake。否则,我建议您使用 QMake。
您可以构建应用程序、库(共享的和静态的)和插件,但是您必须注意一些特定于平台的细节。这些细节包括 Windows 和 OS X 的应用程序图标,OS X 的通用二进制文件和软件包,以及对于 Windows 平台,你是否想要一个控制台。
十六、单元测试
随着软件复杂性的增加和开发时间的不断缩短,开发人员不断寻找新的方法来更有效地创建和开发他们的应用程序。因为测试往往是一项消耗大量分配时间表的任务,所以对如何简化测试过程进行了大量的思考就不足为奇了。
作为这项工作的结果,一个常见的策略被称为单元测试,它是关于独立测试项目的所有部分,以确保它们按照规范工作。当把这些部分放在一起时,你会知道每个部分都像预期的那样工作,使得最终的测试和调试更加容易。
以一个单位转换应用程序为例,其中有数百个单位,甚至更多的情况需要测试。通过自动测试转换引擎单元和用户界面,您可以避免大量的测试。例如,测试用户界面可以提交值、源单元和目的单元就足够了;您不必从用户界面测试所有可能的转换。所有转换的可能性都将作为转换引擎测试的一部分进行测试。如果您遇到转换问题,您可以在测试转换引擎时发现它(您可以在不涉及用户界面的情况下调试它)。
测试可以根据应用程序中接口的规范来构建,从而确保规范得到满足。有些人甚至认为,测试产生了规范,应该在编写被测试的实际代码之前编写规范。
单元测试的概念最近受到了关注,因为它是敏捷软件开发概念的基础部分。单元测试使得实现功能的代码能够被改变。只要测试通过,代码仍然可以与应用程序的其余部分一起工作。这意味着你可以在任何时候修改你的代码,并且——假设测试都通过了——应用程序将继续按预期运行。这是敏捷软件开发的关键概念之一。
提示你可以在[www.agilemanifesto.org](http://www.agilemanifesto.org)
和[www.extremeprogramming.org](http://www.extremeprogramming.org)
找到更多关于敏捷软件开发的信息。
单元测试可以被看作是对编译器和链接器的补充。这些工具可以在构建软件时发现明显的问题。内部问题——比如不起作用的堆栈、错误计算结果的函数等等——必须使用 beta 测试人员、单元测试或者(小心!)实际用户。通过使用单元测试,您可以确保您的 beta 测试人员关注重要的问题,并且您的用户不太可能在您的软件中发现错误。结果将是产品质量更好。
单元测试和 Qt
Qt 附带了一个轻量级的单元测试模块,即QtTest
模块(这可能是意料之中的,因为 Qt 鼓励构建组件)。当使用这种方法进行开发时,能够单独测试每个组件是很重要的。
测试的结构
使用QtTest
模块,每个单元测试都是从一个类中构造的,这个类必须继承QObject
类并以Q_OBJECT
宏开始。一个单元测试由几个测试用例组成,每个测试用例是一个私有槽。四个特殊插槽不被视为测试用例:
initTestCase
:初始化单元测试类,在测试用例运行前调用。cleanupTestCase
:清理单元测试,并在所有测试用例运行后被调用。init
:这个方法在每个测试用例之前运行。cleanup
:这个方法在每个测试用例之后运行。
所有其他插槽都被视为测试用例,并相应地运行。执行顺序,包括之前列出的特殊槽,可以在图 16-1 中看到。
每个测试用例的目的是测试一个类的一个或多个方面。例如,您可能会测试一个函数,使它总是执行正确的计算,或者您可能会测试一个接口,以确保对象的内部状态按预期运行。
在这两种情况下,测试常见情况和边缘情况都很重要。验证常见情况的测试可能很少,但是它们应该确保大多数使用的单元功能正常工作。测试还必须包括处理错误的用户输入。例如,当用户输入无效输入时,可能会返回空字符串或发出警告消息。边界情况确保函数实际执行,甚至在靠近面向用户的边界时(例如,确保列表的两端都是可访问的,或者用户可以在输入字段中输入任意大的值,而且数学函数可以处理其函数的所有极值点,甚至是可以传递给它的最大可能数字)。
图 16-1。 单元测试运行时的执行顺序
清单 16-1 展示了实现测试的类的基本结构,以及使用特殊单元测试main
函数运行实际测试的QTEST_MAIN
宏。main
函数宏可以放在任何地方——甚至放在与测试类不同的文件中。
清单 16-1。 一个单元测试的基本结构
class MyTestClass : public QObject
{
Q_OBJECT
private slots:
// Test cases goes here
};
...
QTEST_MAIN( DateTest )
测试用例的项目文件需要包括被测试的类、测试类和一个配置行CONFIG += qtestlib
。可以通过运行qmake -project CONFIG+=qtestlib
来创建这样一个文件。下面我们来详细看一下。
对于 Qt 来说,测试实际上只是应用程序,所以项目文件以app
模板开始(您也可以使用标准的包含和依赖路径):
TEMPLATE = app
INCLUDEPATH = .
DEPENDPATH = .
然后,为目标应用程序命名:
TARGET = mytestapp
接下来是被测试的类——包括头文件和源代码:
HEADERS += myclass.h
SOURCES += myclass.cpp
然后是测试类——头和源——以及包含`main`函数的`main.cpp`文件:
HEADERS += mytestclass.h
SOURCES += mytestclass.cpp main.cpp
最后,配置行:
CONFIG += qtestlib
**注意**测试结果输出到控制台;在 Windows 平台上,您还必须在项目文件中添加一行`CONFIG += console`。
因为测试是一个普通的应用程序,所以您需要做的就是运行`qmake && make`来构建它。然后您可以运行产生的`mytestapp`来执行测试。
测试日期
让我们使用`QtTest`模块来测试一个数据类。对于这个测试,您将使用`QDate`类,因为它有一个内部状态,因为它以某种方式向自己表示日期。它还有一个由`isValid`、`day`、`month`、`year`属性 getters 组成的接口;以及从`addDays`、`addMonths`和`addYears`方法。
那么应该测试什么呢?可以给日期添加日、月和年。添加日期可以更改日期的日、月和年。添加月份只会修改月份和年份,而添加年份只会影响`year`属性。我还喜欢测试日期是否有效(2 月 29 日在闰年有效,但在其他年份无效)。
实施测试
所有这些测试都在清单 16-2 所示的单元测试类中实现。该类继承了`QObject`并包含了`Q_OBJECT`。然后,不同的测试被实现为私有插槽。请注意,特殊的插槽已经被省略了,因为您不需要进行任何特殊的初始化或清理。
测试分为`testAddDays`、`testAddMonths`、`testAddYears`和`testValid`。前三个测试增加日、月、年;最后一项测试检查`isValid`方法是否正常工作。
**清单 16-2。***`DateTest`*类包含* `QDate` *类的测试。** *`class DateTest : public QObject
{
Q_OBJECT
private slots:
void testAddDay();
void testAddMonth();
void testAddYear();
void testValid();
};`
从底部开始,看一下`testValid`方法(它的实现如清单 16-3 所示)。测试从设置日期开始,然后测试`QVERIFY`宏,看看`isValid`方法是否返回预期值。
`QVERIFY(bool)`宏是`QtTest`模块的一部分,用于验证给定的表达式是否为`true`。如果您想在表达式为`false`时关联一个特定的错误消息,您可以使用`QVERIFY2(bool,string)`宏,它会在出现问题时打印字符串。
一旦一个测试宏失败,当前的测试用例就会被中止,所以你不必担心将来的宏会因为第一个问题而失败。如果您需要清理任何东西,请在特殊的`cleanup`槽中进行。
第一个测试检查未指定的日期是否无效,有效的日期是否有效。所以 2 月 29 日在 1980 年(闰年)有效,但在 1979 年无效。
**清单 16-3。** *测试* `Valid` *方法是否按预期工作*
void DateTest::testValid()
{
QDate date;
QVERIFY( !date.isValid() );
date = QDate( 1979, 5, 16 );
QVERIFY( date.isValid() );
date = QDate( 1980, 2, 29 );
QVERIFY( date.isValid() );
date = QDate( 1979, 2, 29 );
QVERIFY( !date.isValid() );
}
也可以使用`QVERIFY`来检查数值。例如,`QVERIFY(x==4)`检查`x`是否等于`4`。另一种选择是改为写`QCOMPARE(x,4)`。这使用了`QCOMPARE`宏来查看实际值`x`是否等于期望值`4`。好处是测试失败时返回的消息告诉您实际值和期望值。
清单 16-4 显示了运行中的`QCOMPARE`宏。显示的时间段`testAddMonths`从设置日期开始。然后,它给给定的日期加上一个月,并确保日期的月份部分得到正确更新。然后给日期加上 12 个月,看到数据的年份部分也有效。
**清单 16-4。** *添加月份并检查结果*
void DateTest::testAddMonth()
{
QDate date( 1973, 8, 16 );
QCOMPARE( date.year(), 1973 );
QCOMPARE( date.month(), 8 );
QCOMPARE( date.day(), 16 );
QDate next = date.addMonths( 1 );
QCOMPARE( next.year(), 1973 );
QCOMPARE( next.month(), 9 );
QCOMPARE( next.day(), 16 );
next = date.addMonths( 12 );
QCOMPARE( next.year(), 1974 );
QCOMPARE( next.month(), 8 );
QCOMPARE( next.day(), 16 );
}
`testAddDays`和`testAddYears`插槽看起来非常像`testAddMonths`插槽。年份测试槽简单地增加了一些年份。这是唯一的测试案例,因为添加的年数只影响返回的年份。然而,添加天数的测试有三种情况:添加一天(只影响`day`属性)、添加 31 天(影响`month`属性)和添加 366 天(影响`year`属性)。
**组装在一起**
`DateTest`类保存在`datetest.cpp`和`datetest.h`文件中。要创建一个应用程序,你必须添加一个`main`函数,它保存在清单 16-5 所示的`main.cpp`文件中。
首先包含的`QtTest`头包含来自`QtTest`模块的所有宏(包括`QVERIFY`、`QCOMPARE`等等)。下一行包括实现实际测试的类。然后,`QTEST_MAIN`宏创建一个运行测试用例的`main`函数。
**清单 16-5。** *使用* `QTEST_MAIN` *宏实现* `main` *功能。*
#include
#include "datetest.h"
QTEST_MAIN( DateTest )
这些都是从一个项目文件中引用的,该文件是通过调用`qmake –project "CONFIG+=qtestlib console"`自动生成的。`qtestlib`引用添加了对`QtTest`模块的引用,而`console`是 Windows 用户所必需的。没有它,就不会显示任何消息。结果文件如清单 16-6 所示。
**清单 16-6。** *项目文件把这一切联系在一起*
######################################################################
Automatically generated by qmake (2.01a) ti 23. jan 18:26:56 2007
######################################################################
TEMPLATE = app
TARGET =
DEPENDPATH += .
INCLUDEPATH += .
Input
HEADERS += datetest.h
SOURCES += datetest.cpp main.cpp
CONFIG += qtestlib console
当所有文件都准备好了,接下来就是构建和执行测试了。
**运行测试**
构建单元测试的结果是一个普通的应用程序。如果你在没有任何命令行参数的情况下运行该应用程序,它将产生类似于清单 16-7 的结果。输出显示了 Qt 的版本和使用的`qtestlib`的版本,后面是每个测试用例的结果。这种情况下,都得到一个`PASS`,最后的总结显示所有测试都通过了。
* * *
**提示**如果你想要彩色输出,设置环境变量`QTEST_COLORED`为 1。
* * *
**清单 16-7。** *不带任何参数运行测试*
********* Start testing of DateTest *********
Config: Using QTest library 4.2.2, Qt 4.2.2
PASS : DateTest::initTestCase()
PASS : DateTest::testAddDay()
PASS : DateTest::testAddMonth()
PASS : DateTest::testAddYear()
PASS : DateTest::testValid()
PASS : DateTest::cleanupTestCase()
Totals: 6 passed, 0 failed, 0 skipped
********* Finished testing of DateTest *********
有时测试用例会挂起。当这种情况发生时,在执行测试应用程序时使用`–v1`命令行参数是很方便的。当给出这个标志时,输出会告诉您每个测试是何时进入并通过的,因此您可以知道测试在哪里挂起。清单 16-8 中的显示了一个输出片段。
**清单 16-8。** *运行测试用* `-v1` *标志*
********* Start testing of DateTest *********
Config: Using QTest library 4.2.2, Qt 4.2.2
INFO : DateTest::initTestCase() entering
PASS : DateTest::initTestCase()
INFO : DateTest::testAddDay() entering
PASS : DateTest::testAddDay()
INFO : DateTest::testAddMonth() entering
PASS : DateTest::testAddMonth()
INFO : DateTest::testAddYear() entering
...
如果在定位挂起时仍然有问题,或者只是想确保所有测试都运行了,那么可以使用`–v2`参数,它会在每个测试进入并通过时产生测试输出(就像使用`-v1`时一样),但是它也会在每个测试宏到达时显示出来。清单 16-9 展示了这一点。每个宏都有一行告诉你它的位置——读起来像这样:`filename.ext (line) : failure location`。
**清单 16-9。** *运行测试用* `-v2` *标志*
********* Start testing of DateTest *********
Config: Using QTest library 4.2.2, Qt 4.2.2
INFO : DateTest::initTestCase() entering
PASS : DateTest::initTestCase()
INFO : DateTest::testAddDay() entering
INFO : DateTest::testAddDay() COMPARE()
datetest.cpp(10) : failure location
INFO : DateTest::testAddDay() COMPARE()
datetest.cpp(11) : failure location
INFO : DateTest::testAddDay() COMPARE()
datetest.cpp(12) : failure location
INFO : DateTest::testAddDay() COMPARE()
...
当一个测试失败时,当前测试用例立即停止。导致失败的宏将会报告出了什么问题以及它的位置,就像对`–v2`标志一样。在清单 16-10 中可以看到一个失败的例子。输出来自没有任何命令行参数的测试。
如果一个测试用例失败了,其他的仍然会运行,因此您可以获得一个完整的测试状态。
**清单 16-10。** *一次考试失败。*
********* Start testing of DateTest *********
Config: Using QTest library 4.2.2, Qt 4.2.2
PASS : DateTest::initTestCase()
PASS : DateTest::testAddDay()
FAIL! : DateTest::testAddMonth() Compared values are not the same
Actual (next.day()): 16
Expected (15): 15
datetest.cpp(43) : failure location
PASS : DateTest::testAddYear()
PASS : DateTest::testValid()
PASS : DateTest::cleanupTestCase()
Totals: 5 passed, 1 failed, 0 skipped
********* Finished testing of DateTest *********
失败的原因是`QCOMPARE`宏中的期望值在`datetest.cpp`的第 43 行被更改。
如果您想将测试限制在一个测试用例中,您可以将插槽的名称作为命令行参数传递。例如,运行`datetest testValid`只运行`testValid`测试用例。
#### 数据驱动测试
在`DateTest`中实现的测试有很多重复的代码。例如,清单 16-4 中的`testAddMonths`方法添加了一个日期并检查结果两次。`testAddDays`三次添加天数,`testValid`以同样的方式测试三个日期。
所有这些代码重复鼓励复制粘贴编程,从而导致错误。为了避免重复,您可以将测试用例设计成数据驱动的。简单地说,就是把数据放到一个通常被称为*测试向量*的表格中。然后,对表中的每一行执行相同的测试。尽管自己实现这一点可能很容易,但由于场景非常常见,因此`QtTest`模块提供了内置支持。
为了让`QtTest`模块为您处理数据服务细节,您必须实现一个特定的结构。对于每个数据驱动的测试用例槽,您需要一个同名的槽,但是以`_data`结尾,它为那个测试用例生成数据。清单 16-11 显示`testAddDays`、`testAddMonths`和`testAddYears`已经合并到`testAdd`插槽中。该插槽从`testAdd_data`插槽接收数据。对于从`testValid_data`获取数据的`testValid`插槽也是如此。可能有一个或多个数据驱动的测试用例与非数据驱动的测试用例在同一个类中,但是在这种情况下,所有的测试(或多或少)都是数据驱动的。
**清单 16-11。** *数据驱动* `DateTest` *类*
class DateTest : public QObject
{
Q_OBJECT
private slots:
void testAdd();
void testAdd_data();
void testValid();
void testValid_data();
};
新的`testValid`槽及其数据槽如清单 16-12 所示。让我们从查看`testValid_data`数据槽开始。它首先用`QTest::addColumn<type>: year, month, day`和`valid`创建四列,其中`valid`是您期望`isValid`方法为由`year`、`month`和`day`组成的日期返回的值。然后使用`QTest::newRow`方法添加数据行。每一行都有一个名称,然后使用< <操作符输入列的数据。
通过使用`QFETCH`宏获取`testValid`测试用例槽以及`year`、`month`和`day`值。请注意,`testValid`只知道有哪些列,并且有一个当前行。有多少行以及哪一行现在是活动的并不重要;`QtTest`模块确保该槽为每行数据调用一次。
`QFETCH`宏有两个参数:要获取的数据类型和要获取的列名。该值可从具有列名的变量中获得,这就是为什么您可以将`QDate`构造器中的`year`、`month`和`day`用作普通变量。
可以使用`QFETCH`宏从`value`列获得值,然后使用`QCOMPARE`甚至`QVERIFY`来检查它是否与预期值匹配。然而,代替这样做,你可以马上使用`QTEST`宏。它的工作方式类似于`QCOMPARE`,但是它接受一个列名,而不是一个期望值。然后,它将给定值与当前数据行的给定列的值进行比较。
* * *
**注意**在将`testValid`变成数据驱动测试用例的过程中,丢失了对一个空构造器的检查。
* * *
**清单 16-12。** *检查日期范围是否有效*
void DateTest::testValid()
{
QFETCH( int, year );
QFETCH( int, month );
QFETCH( int, day );
QDate date( year, month, day );
QTEST( date.isValid(), "valid" );
}
void DateTest::testValid_data()
{
QTest::addColumn
QTest::addColumn
QTest::addColumn
QTest::addColumn
QTest::newRow( "Valid, normal" ) << 1973 << 8 << 16 << true;
QTest::newRow( "Invalid, normal" ) << 1973 << 9 << 31 << false;
QTest::newRow( "Valid, leap-year" ) << 1980 << 2 << 29 << true;
QTest::newRow( "Invalid, leap-year" ) << 1981 << 2 << 29 << false;
}
`testAdd`槽的变化比`testValid`稍大。(该插槽及其附带的数据插槽可以在清单 16-13 中看到。)数据分为六列:`addDay`、`addMonth`、`addYear`、`day`、`month`和`year`。测试用例的工作方式是获取一个预先确定的日期(在本例中是 1979 年 5 月 16 日),然后向其中添加`addXxx`列。日、月和年列用于保存预期的结果。
正如您在`testAdd`槽实现中看到的,使用`QFETCH`来检索`addXxx`值。然后使用`QTEST`宏检查结果日期。在`testAdd_data`槽中创建的数据对应于在非数据驱动类的`testAddXxx`方法中执行的测试。
**清单 16-13。** *检查*`addDays``addMonths`*`addYears`*方法是否按预期工作**
void DateTest::testAdd()
{
QDate date( 1979, 5, 16 );
QFETCH( int, addYear );
QFETCH( int, addMonth );
QFETCH( int, addDay );
QDate next = date.addYears( addYear ).addMonths( addMonth ).addDays( addDay );
QTEST( next.year(), "year" );
QTEST( next.month(), "month" );
QTEST( next.day(), "day" );
}
void DateTest::testAdd_data ()
{
QTest::addColumn
QTest::addColumn
QTest::addColumn
QTest::addColumn
QTest::addColumn
QTest::addColumn
QTest::newRow( "Start date" ) << 0 << 0 << 0 << 1979 << 5 << 16;
...
}
项目的其余部分不需要更新,数据驱动版本的`DateTest`就可以工作。从命令行运行测试时看到的结果也是相似的。实际的测试用例在运行时被列出,而数据槽被忽略。
使用数据驱动测试的一个有趣的副作用是,当测试失败时,返回每行数据的名称(使错误消息更加清晰)。在清单 16-14 中你可以看到一个这样的例子。不要只是说`next.year()`值是意外的,你要知道测试用例是`testAdd(Twenty days)`。
**清单 16-14。** *当一个测试在数据驱动的测试用例中失败时,当前行的名称作为失败消息的一部分给出。*
********* Start testing of DateTest *********
Config: Using QTest library 4.2.2, Qt 4.2.2
PASS : DateTest::initTestCase()
FAIL! : DateTest::testAdd(Twenty days) Compared values are not the same
Actual (next.year()): 1979
Expected ("year"): 2979
datetest.cpp(18) : failure location
PASS : DateTest::testValid()
PASS : DateTest::cleanupTestCase()
Totals: 3 passed, 1 failed, 0 skipped
********* Finished testing of DateTest *********
下面的列表总结了转向数据驱动测试的结果:**
*** 更少的代码:您只需要实现一次测试,但是使用那个测试运行不同的情况。* 更少的代码冗余:因为测试只实现一次,所以不会重复。这也意味着如果有问题,不必修复所有测试中的错误。* 潜在的更好的失败消息:因为每个测试向量行都有一个名称,您可以清楚地看到哪个案例失败了。* 一些测试用例不能再被执行:这是一个缺点。因为测试向量总是包含数据,所以很难用它来测试一些特殊的情况(例如,一个空的构造器)。这将要求您在测试代码中有一个特例和一个表示没有数据的标志,这会使测试代码混乱。**
**最后一点可以通过将这些测试放在非数据驱动的测试用例中来解决。这不是一个限制,因为它们可以在一个类中与数据驱动测试相结合。
测试小工具
用自动化测试(比如单元测试)很难检查的一个方面是用户交互。虽然大多数小部件都有可以测试的 setters 和 getters,但是为了测试用户交互,您必须能够模拟鼠标和键盘活动。QtTest
模块可以提供帮助。
测试旋转盒
为了测试一个小部件,您将对QSpinBox
类进行测试,重点是上下改变值的能力以及最小值和最大值是否被考虑。因为这个值可以用三种不同的方式改变,所以清单 16-15 中的测试类包含了三个测试用例槽:
testKeys
:测试使用键盘交互改变数值testClicks
:使用鼠标交互改变值的测试testSetting
:使用setValue
方法改变数值的测试
测试小部件和非小部件的单元测试类之间没有区别。
**清单 16-15。**一级用于测试 QSpinBox
一级一级
class SpinBoxTest : public QObject
{
Q_OBJECT
private slots:
void testKeys();
void testClicks();
void testSetting();
};
你要考虑的第一个测试用例是testSetting
插槽,如清单 16-16 所示。在这个测试用例中,被测试的类是小部件并不重要;您只需测试 value 属性。首先创建一个QSpinBox
对象;随后,其范围被设置为 1–10。
然后,测试会尝试设置一个有效值,设置一个过小的值,最后设置一个过大的值。有效值应该保持不变,而其他两个值应该保持在指定的范围内。
清单 16-16。 使用编程接口测试 value
属性
void SpinBoxTest::testSetting()
{
QSpinBox spinBox;
spinBox.setRange( 1, 10 );
spinBox.setValue( 5 );
QCOMPARE( spinBox.value(), 5 );
spinBox.setValue( 0 );
QCOMPARE( spinBox.value(), 1 );
spinBox.setValue( 11 );
QCOMPARE( spinBox.value(), 10 );
}
清单 16-17 显示了第一个交互测试:testKeys
。测试从创建一个QSpinBox
开始,并设置与testSetting
测试相同的范围。然后,在按下向上和向下键之前,数字显示框被初始化为有效值。在每次按键之间测试这些值,因此value
属性会按预期改变。接下来的两个测试将该值设置为一个极限值,并通过按键尝试移动到允许的范围之外。在这里,您要确保value
属性不会改变。
使用QTest::keyClick(QWidget*,Qt::Key)
方法将按键发送到数字显示框。通过使用keyClick
向小部件发送一个键事件,Qt 自动为该键发送一个keyPress
事件和一个keyRelease
事件。
清单 16-17。 测试改变 value
使用键盘交互
void SpinBoxTest::testKeys()
{
QSpinBox spinBox;
spinBox.setRange( 1, 10 );
spinBox.setValue( 5 );
QTest::keyClick( &spinBox, Qt::Key_Up );
QCOMPARE( spinBox.value(), 6 );
QTest::keyClick( &spinBox, Qt::Key_Down );
QCOMPARE( spinBox.value(), 5 );
spinBox.setValue( 10 );
QTest::keyClick( &spinBox, Qt::Key_Up );
QCOMPARE( spinBox.value(), 10 );
spinBox.setValue( 1 );
QTest::keyClick( &spinBox, Qt::Key_Down );
QCOMPARE( spinBox.value(), 1 );
}
void SpinBoxTest::testClicks()
{
QSpinBox spinBox;
spinBox.setRange( 1, 10 );
spinBox.setValue( 5 );
QSize size = spinBox.size();
QPoint upButton = QPoint( size.width()-2, 2 );
QPoint downButton = QPoint( size.width()-2, size.height()-2 );
QTest::mouseClick( &spinBox, Qt::LeftButton, 0, upButton );
QCOMPARE( spinBox.value(), 6 );
QTest::mouseClick( &spinBox, Qt::LeftButton, 0, downButton );
QCOMPARE( spinBox.value(), 5 );
spinBox.setValue( 10 );
QTest::mouseClick( &spinBox, Qt::LeftButton, 0, upButton );
QCOMPARE( spinBox.value(), 10 );
spinBox.setValue( 1 );
QTest::mouseClick( &spinBox, Qt::LeftButton, 0, downButton );
QCOMPARE( spinBox.value(), 1 );
}
void SpinBoxTest::testSetting()
{
QSpinBox spinBox;
spinBox.setRange( 1, 10 );
spinBox.setValue( 5 );
QCOMPARE( spinBox.value(), 5 );
spinBox.setValue( 0 );
QCOMPARE( spinBox.value(), 1 );
spinBox.setValue( 11 );
QCOMPARE( spinBox.value(), 10 );
}
最后一个测试槽检查鼠标交互。测试与前两个测试用例相同:尝试在有效范围内移动;然后试着向外移动。你可以在清单 16-18 中的槽中看到它的实现。
testClicks
槽与testKeys
槽非常相似,除了不是按键点击,而是发送鼠标点击,鼠标点击必须对准部件上的一个点。三条突出显示的线计算了向上和向下按钮的位置。看看这些线条和图 16-2 ,它显示了正在测试的小部件。
使用QTest::mouseClick(QWidget*, Qt::MouseButton, Qt::KeyboardModifiers, QPoint)
方法将鼠标点击发送到小部件。清单中使用的参数模拟在没有任何键盘修饰键(Shift、Alternate、Ctrl 等)活动的情况下单击鼠标左键。单击的点取决于您是尝试单击向上还是向下按钮。
注意使用的点期望向上和向下按钮像在 Windows XP 风格中那样出现。更改样式或使用从右向左的布局会导致测试停止工作。
清单 16-18。 测试使用鼠标交互改变value
void SpinBoxTest::testClicks()
{
QSpinBox spinBox;
spinBox.setRange( 1, 10 );
spinBox.setValue( 5 );
QSize size = spinBox.size();
QPoint upButton = QPoint( size.width()-2, 2 );
QPoint downButton = QPoint( size.width()-2, size.height()-2 );
QTest::mouseClick( &spinBox, Qt::LeftButton, 0, upButton );
QCOMPARE( spinBox.value(), 6 );
QTest::mouseClick( &spinBox, Qt::LeftButton, 0, downButton );
QCOMPARE( spinBox.value(), 5 );
spinBox.setValue( 10 );
QTest::mouseClick( &spinBox, Qt::LeftButton, 0, upButton );
QCOMPARE( spinBox.value(), 10 );
spinBox.setValue( 1 );
QTest::mouseClick( &spinBox, Qt::LeftButton, 0, downButton );
QCOMPARE( spinBox.value(), 1 );
}
图 16-2。 一个数字显示框小工具
QTEST_MAIN
function 宏将旨在测试小部件的单元测试和测试应用程序其他方面的单元测试同等对待。项目文件也不需要更改。通过构建和运行前面显示的单元测试,您可以获得一个通过测试用例的列表。
用数据驱动小部件
您遇到了与QDate
类相同的冗余问题——QSpinBox
的单元测试包含大量重复代码。解决方案是将测试转换成数据驱动的测试,这是以完全相同的方式完成的——不管被测试的是什么类。
所有测试用例都以相似的方式转换,所以从关注testKeys
槽开始。在清单 16-19 中,插槽的新版本与testKeys_data
一起显示。
清单中显示的大部分源代码应该是清晰的。但是,突出显示的两行很重要。当添加类型为Qt::Key
的列时,如果没有将其声明为元类型,就会出现编译错误。使用Q_DECLARE_METATYPE
宏进行注册。
测试用例像所有数据驱动测试一样工作:它使用QFETCH
获取数据,并在使用QTEST
检查测试结果之前使用这些数据。
清单 16-19。 使用数据驱动测试用例测试键盘交互
``Q_DECLARE_METATYPE( Qt::Key )`
void SpinBoxTest::testKeys()
{
QSpinBox spinBox;
spinBox.setRange( 1, 10 );
QFETCH( Qt::Key, key );
QFETCH( int, startValue );
spinBox.setValue( startValue );
QTest::keyClick( &spinBox, key );
QTEST( spinBox.value(), "endValue" );
}
void SpinBoxTesttestKeys_data()
{
QTest::addColumn<Qt::Key>( "key" );
QTestaddColumn
QTest::addColumn
QTestnewRow( "Up" ) << QtKey_Up << 5 << 6;
QTestnewRow( "Down" ) << QtKey_Down << 5 << 4;
QTestnewRow( "Up, limit" ) << QtKey_Up << 10 << 10;
QTestnewRow( "Down, limit" ) << QtKey_Down << 1 << 1;
}
void SpinBoxTest::testClicks()
{
QSpinBox spinBox;
spinBox.setRange( 1, 10 );
QSize size = spinBox.size();
QPoint upButton = QPoint( size.width()-2, 2 );
QPoint downButton = QPoint( size.width()-2, size.height()-2 );
QFETCH( QString, direction );
QFETCH( int, startValue );
spinBox.setValue( startValue );
if( direction.toLower() == "up" )
QTestmouseClick( &spinBox, QtLeftButton, 0, upButton );
else if (direction.toLower() == "down" )
QTestmouseClick( &spinBox, QtLeftButton, 0, downButton );
else
QWARN( "Unknown direction - no clicks issued." );
QTEST( spinBox.value(), "endValue" );
}
void SpinBoxTesttestClicks_data()
{
QTestaddColumn
QTestaddColumn
QTest
QTestnewRow( "Up" ) << "Up" << 5 << 6;
QTestnewRow( "Down" ) << "Down" << 5 << 4;
QTestnewRow( "Up, limit" ) << "Up" << 10 << 10;
QTestnewRow( "Down, limit" ) << "Down" << 1 << 1;
}
void SpinBoxTest::testSetting()
{
QSpinBox spinBox;
spinBox.setRange( 1, 10 );
QFETCH( int, value );
spinBox.setValue( value );
QTEST( spinBox.value(), "endValue" );
}
void SpinBoxTesttestSetting_data()
{
QTestaddColumn
QTestaddColumn
QTest
QTestnewRow( "Over" ) << 11 << 10;
QTestnewRow( "Under" ) << 0 << 1;
}`
testClicks
槽类似于testKeys
槽,但是您不能添加一个列来容纳要点击的QPoint
,因为该点是在您知道被测试的小部件的大小时计算的。已经添加了一个名为direction
的列。方向可以是"Up"
或"Down"
(见清单 16-20 )。
测试用例槽按预期工作:它设置QSpinBox
,使用QFETCH
获取输入数据,根据数据执行任务,然后使用QTEST
进行评估。新的是,如果它运行在一个意想不到的方向,它使用QWARN
宏通知用户。此警告不影响测试结果;它只是在日志中发出一个警告。
清单 16-20。 使用数据驱动测试用例测试鼠标交互
void SpinBoxTest::testClicks()
{
QSpinBox spinBox;
spinBox.setRange( 1, 10 );
QSize size = spinBox.size();
QPoint upButton = QPoint( size.width()-2, 2 );
QPoint downButton = QPoint( size.width()-2, size.height()-2 );
QFETCH( QString, direction );
QFETCH( int, startValue );
spinBox.setValue( startValue );
if( direction.toLower() == "up" )
QTest::mouseClick( &spinBox, Qt::LeftButton, 0, upButton );
else if (direction.toLower() == "down" )
QTest::mouseClick( &spinBox, Qt::LeftButton, 0, downButton );
else
QWARN( "Unknown direction - no clicks issued." );
QTEST( spinBox.value(), "endValue" );
}
void SpinBoxTest::testClicks_data()
{
QTest::addColumn<QString>( "direction" );
QTest::addColumn<int>( "startValue" );
QTest::addColumn<int>( "endValue" );
QTest::newRow( "Up" ) << "Up" << 5 << 6;
...
}
textSetting
槽以类似的方式转换,此处未示出。单元测试的结果也没有改变。测试以同样的方式进行(并给出结果)。
测试信号
Qt 类在受到编程调用或用户交互的刺激时会发出信号。因为信号和插槽是 Qt 应用程序的关键组件,所以在测试过程中不能忽略它们。
您可以使用QSignalSpy
类来监听信号,而无需连接到它们。信号间谍被连接起来监听来自某个物体的某个信号。然后,spy 对象记录每个被捕获信号的参数值。
清单 16-21 显示了数据驱动的testKeys
方法扩展了信号监听功能。(最初的实现槽显示在清单 16-19 中。)
清单中突出显示的行显示了该插槽的主要新增内容。从上到下查看变化,第一行创建了一个QSignalSpy
对象,用于监视从spinBox
对象发出的valueChanged(int)
信号。信号 spy 是在数字显示框设置了起始值后创建的,以避免误捕捉信号。
注意这个测试只检查一个信号。在现实生活中,你也会包括valueChanged(QString)
信号。
当间谍被创建时,实际的测试正在被执行。测试完成后,获取新列willSignal
的值。如果该值为true
,则预期有信号。
如果一个信号是预期的,核实间谍已经捕捉到一个信号。在了解如何做到这一点之前,您必须理解QSignalSpy
继承了QList<QList<QVariant> >
。这意味着它是一个包含变量对象的列表列表。
使用count
属性检查捕获的信号数量。要从信号的第一个参数中获取值,使用takeFirst
方法获取信号的参数值列表。返回的列表的第零个索引(即信号的第一个参数)在与预期的最终值进行比较之前,使用toInt
从QVariant
转换为一个整数。
如果willSignal
告诉您没有预期的信号,请确认没有发出信号。很容易忘记检查无信号情况。如果你错过了它,并且一个信号没有改变地被发射,两个互相连接的物体将会在一个无限循环中挂起。
对测试用例数据槽的改变被限制在新的列willSignal
中,该列保存一个布尔值,告诉测试是否期望一个信号。
清单 16-21。 测试键盘交互——现在增加了额外的信号监控技能
void SpinBoxTest::testKeys()
{
QSpinBox spinBox;
spinBox.setRange( 1, 10 );
QFETCH( Qt::Key, key );
QFETCH( int, startValue );
spinBox.setValue( startValue );
QSignalSpy spy( &spinBox, SIGNAL(valueChanged(int)) );
QTest::keyClick( &spinBox, key );
QTEST( spinBox.value(), "endValue" );
QFETCH( bool, willSignal );
if( willSignal )
{
QCOMPARE( spy.count(), 1 );
QTEST( spy.takeFirst()[0].toInt(), "endValue" );
}
else
QCOMPARE( spy.count(), 0 );
}
void SpinBoxTest::testKeys_data()
{
QTest::addColumn<Qt::Key>( "key" );
QTest::addColumn<int>( "startValue" );
QTest::addColumn<int>( "endValue" );
QTest::addColumn<bool>( "willSignal" );
QTest::newRow( "Up" ) << Qt::Key_Up << 5 << 6 << true;
QTest::newRow( "Down" ) << Qt::Key_Down << 5 << 4 << true;
QTest::newRow( "Up, limit" ) << Qt::Key_Up << 10 << 10 << false;
QTest::newRow( "Down, limit" ) << Qt::Key_Down << 1 << 1 << false;
}
对另外两个测试用例槽testClicks
和testSetting
的修改几乎与对testKeys
的修改相同。最大的变化是不得不用一个startValue
列和一个测试无信号情况的新测试用例来扩展testSetting
。
对测试的更改仅限于添加一个新对象。然后使用来自QtTest
模块的标准宏检查该对象的状态。这意味着该装置的制造和使用方式与不检查信号的测试完全相同。
真实测试
到目前为止,您只测试了 Qt 附带的类的部分接口。现在你将为第十三章中的ImageCollection
类创建一个单元测试。
界面
在看单元测试类之前,让我们快速回顾一下ImageCollection
类,它用于保存图像和标签。可以添加新图像、为图像添加标签、检索所有标签、检索与一组标签匹配的图像的所有 id,以及从 id 中获取特定图像。可用的方法如下所示:
QImage getImage(int id)
:从给定的 id 中获取图像。QList <int> getIds(QStringList tags)
:检索与任何指定标签匹配的图像的 id。如果没有指定标记,该方法将返回所有 id。QStringList getTags()
:检索所有标签的列表。addTag(int id, QString tag)
:给给定图像添加标签。addImage(QImage image, QStringList tags)
:用给定的标签将图像添加到集合中。
测试
为了测试这些方法,将测试分为三个部分:一个用于测试标签,一个用于测试图像,一个用于测试来自标签关联的图像。这三个部分可以看作是单元测试类声明中的槽,如清单 16-22 所示。
该类包含一个名为pixelCompareImages
的私有成员函数。它用于确保两幅图像完全相同,一个像素一个像素。需要查看图像是否正确存储在数据库中。
清单 16-22。 单元测试类用于测试 ImageCollection
类
class ImageCollectionTest : public QObject
{
Q_OBJECT
private slots:
void testTags();
void testImages();
void testImagesFromTags();
private:
bool pixelCompareImages( const QImage &a, const QImage &b );
};
测试标签
清单 16-23 显示了testTags
测试槽的实现。执行的测试很简单,程序如下:
- 确保没有来自开始测试
getIds
的标签。 - 添加一个图像,并确保该图像在集合中—测试
addImage
。 - 向图像添加一个标签,并验证集合是否包含一个标签——测试
addTag
和getTags
。 - 向图像中再添加一个标签,并验证该集合包含两个标签—tests
addTag
和getTags
。 - 向图像中再添加一个标签,并验证该集合包含三个标签—tests
addTag
和getTags
。 - 向图像添加一个重复的标签,并验证该集合包含三个标签—tests
addTag
和getTags
。 - 向一个不存在的图像添加一个新标签,并验证该集合包含三个标签—test
addTag
和getTags
。
在清单中,您可以看到ImageCollection
对象被创建,然后测试被执行。最后一个测试之前是一个QEXPECT_FAIL
宏,这表明测试预计会失败,因为图像集合在添加标签之前无法检查图像 id 是否存在。
测试槽中的最后一行删除了图像收集使用的数据库连接。这是必要的,因为图像集合类依赖于默认连接。如果创建了一个新的图像收集对象(例如,在下一个测试用例中),如果原始连接没有被移除,QtSql
模块将警告数据库连接正在被替换。
清单 16-23。 测试标记保持能力
void ImageCollectionTest::testTags()
{
ImageCollection c;
// Make sure that the collection is empty
QCOMPARE( c.getTags().count(), 0 );
// At least one image is needed to be able to add tags
c.addImage( QImage( "test.png" ), QStringList() );
// Verify that we have one image and get the id for it
QList<int> ids = c.getIds( QStringList() );
QCOMPARE( ids.count(), 1 );
int id = ids[0];
// Add one tag, total one
c.addTag( id, "Foo" );
QCOMPARE( c.getTags().count(), 1 );
// Add one tag, total two
c.addTag( id, "Bar" );
QCOMPARE( c.getTags().count(), 2 );
// Add one tag, total three
c.addTag( id, "Baz" );
QCOMPARE( c.getTags().count(), 3 );
// Add a duplicate tag, total three
c.addTag( id, "Foo" );
QCOMPARE( c.getTags().count(), 3 );
// Try to add a tag to a nonexisting id
QEXPECT_FAIL("", "The tag will be added to the non-existing image.", Continue);
c.addTag( id+1, "Foz" );
QCOMPARE( c.getTags().count(), 3 );
// The ImageConnection adds a database that we close here
QSqlDatabase::removeDatabase( QLatin1String( QSqlDatabase::defaultConnection ) );
}
测试图像存储和检索
下一个测试用例,如清单 16-24 中的所示,检查图像存储和检索机制是否工作,并在testImages
插槽中实现。
测试过程非常简单:向数据库中添加一个图像(测试addImage
),确保它在那里(测试getIds
),检索它(测试getImage
,并将其与原始图像进行比较。
最后一个测试(已经被注释掉)试图使用无效的 id 来检索图像。这会导致调用ImageCollection
类中的qFatal
,即使你调用QTest::ignoreMessage(QString)
,应用程序也会结束。另外,ignoreMessage
可以方便地避免显示使用qDebug
或qWarning
发出的预期警告信息。
清单 16-24。 测试存储和检索图像
void ImageCollectionTest::testImages()
{
ImageCollection c;
QCOMPARE( c.getIds( QStringList() ).count(), 0 );
QImage image( "test.png" );
c.addImage( image, QStringList() );
// Verify that we have one image and get the id for it
QList<int> ids = c.getIds( QStringList() );
QCOMPARE( ids.count(), 1 );
int id = ids[0];
QImage fromDb = c.getImage( id );
QVERIFY( pixelCompareImages( image, fromDb ) );
// Will call qFatal and end the application
// QTest::ignoreMessage( QtFatalMsg, "Failed to get image id" );
// fromDb = c.getImage( id+1 );
// QVERIFY( fromDb.isNull() );
// The ImageConnection adds a database that we close here
QSqlDatabase::removeDatabase( QLatin1String( QSqlDatabase::defaultConnection ) );
}
测试图像和标签
最终的测试用例testImagesFromTags
,如清单 16-25 所示。这个测试一开始看起来很混乱,但是原则是检查每个给定标签返回的图像 id 的数量是否正确。为此,一次添加一个图像;然后调用getIds
方法,将返回的 id 数与预期结果进行比较。整个过程描述如下:
- 添加带有标签
Foo
和Bar
的图像。 - 验证
getTags
返回两个标签。 - 验证返回的
Foo
、Bar
和Baz
的 id 数;以及包含Foo
和Bar
的列表。 - 添加标签为
Baz
的图像。 - 验证
getTags
返回三个标签。 - 如果返回了
Foo
、Bar
和Baz
的 id,则验证编号。 - 添加带有标签
Bar
和Baz
的图像。 - 验证
getTags
返回三个标签。 - 验证返回的
Foo
、Bar
和Baz
的 id 数;以及包含Bar
和Baz
的列表。
为了确定每组标签的预期 id 数,重要的是要记住getIds
应该返回至少有一个给定标签的每个图像。这意味着当使用Bar
或Baz
查询图像时,所有三个图像 id 都会被返回。第一个图像包含Bar
,第二个包含Baz
,第三个包含两者。
清单 16-25。 立刻测试图像和标签
void ImageCollectionTest::testImagesFromTags()
{
ImageCollection c;
QCOMPARE( c.getIds( QStringList() ).count(), 0 );
QImage image( "test.png" );
QStringList tags;
tags << "Foo" << "Bar";
c.addImage( image, tags );
QCOMPARE( c.getTags().count(), 2 );
QCOMPARE( c.getIds( QStringList() ).count(), 1 );
QCOMPARE( c.getIds( QStringList() << "Foo" ).count(), 1 );
QCOMPARE( c.getIds( QStringList() << "Bar" ).count(), 1 );
QCOMPARE( c.getIds( tags ).count(), 1 );
QCOMPARE( c.getIds( QStringList() << "Baz" ).count(), 0 );
tags.clear();
tags << "Baz";
c.addImage( image, tags );
QCOMPARE( c.getTags().count(), 3 );
QCOMPARE( c.getIds( QStringList() ).count(), 2 );
QCOMPARE( c.getIds( QStringList() << "Foo" ).count(), 1 );
QCOMPARE( c.getIds( QStringList() << "Bar" ).count(), 1 );
QCOMPARE( c.getIds( tags ).count(), 1 );
QCOMPARE( c.getIds( QStringList() << "Baz" ).count(), 1 );
tags.clear();
tags << "Bar" << "Baz";
c.addImage( image, tags );
QCOMPARE( c.getTags().count(), 3 );
QCOMPARE( c.getIds( QStringList() ).count(), 3 );
QCOMPARE( c.getIds( QStringList() << "Foo" ).count(), 1 );
QCOMPARE( c.getIds( QStringList() << "Bar" ).count(), 2 );
QCOMPARE( c.getIds( tags ).count(), 3 );
QCOMPARE( c.getIds( QStringList() << "Baz" ).count(), 2 );
// The ImageConnection adds a database that we close here
QSqlDatabase::removeDatabase( QLatin1String( QSqlDatabase::defaultConnection ) );
}
bool ImageCollectionTest::pixelCompareImages( const QImage &a, const QImage &b )
{
if( a.size() != b.size() )
return false;
if( a.format() != b.format() )
return false;
for( int x=0; x<a.width(); ++x )
for( int y=0; y<a.height(); ++y )
if( a.pixel(x,y) != b.pixel(x,y) )
return false;
return true;
}
处理偏差
看了测试用例之后,您可能想看看测试一个为特定应用程序设计的类的结果。我们得到的教训是事情并不完美,您必须处理测试用例中的不完美之处。
当遇到调试和警告消息时,可以通过调用QTest::ignoreMessage(QString)
方法来抑制它们。很高兴知道这个方法不能用来阻止一个qFatal
消息停止单元测试应用程序。
如果测试失败,您可以通过使用QEXPECT_FAIL
宏来防止单元测试停止。宏在结果日志中被报告为XFAIL
项,但是测试用例仍然被认为是通过的。参见清单 16-26 中的示例。
不得不在ImageCollectionTest
类中进行的最令人不安的修改是避免QtSql
模块警告默认连接被替换的变通方法。这个消息可以通过使用QTest::ignoreMessage
方法删除。相反,通过在每个测试用例结束时移除默认连接,从单元测试中修复了该问题。这两种方法都表明,ImageCollection
类仅限于在每次运行使用它的应用程序时创建一次。
清单 16-26。 测试 ImageCollection
类的结果
********* Start testing of ImageCollectionTest *********
Config: Using QTest library 4.2.2, Qt 4.2.2
PASS : ImageCollectionTest::initTestCase()
XFAIL : ImageCollectionTest::testTags() The tag will be added to the
non-existing image.
imagecollectiontest.cpp(43) : failure location
PASS : ImageCollectionTest::testTags()
PASS : ImageCollectionTest::testImages()
PASS : ImageCollectionTest::testImagesFromTags()
PASS : ImageCollectionTest::cleanupTestCase()
Totals: 5 passed, 0 failed, 0 skipped
********* Finished testing of ImageCollectionTest *********
这里描述的每一个症状和方法都表明在被测试的类中需要调整一些东西。在测试时,有时可能不得不抑制意外的警告,但在一般情况下这是不必要的。
当考虑要测试什么时,重要的是尝试超出预期。通过测试代码对无效输入数据的反应,您可以创建更健壮的代码。通过不让您的代码进入未定义的状态,您使得应用程序的其余部分更容易调试。否则,错误的发现可能会被延迟,因为直到有缺陷的组件与应用程序的其余部分进行交互时,错误才变得可见。
总结
单元测试是一种确保您的软件组件满足规范的方法,这使得将项目中的测试资源集中在更有用的领域成为可能。
重要的是集中测试接口,而不是测试类的内部。测试不仅应该测试有效的和预期的数据;他们还应该通过传递意外数据来“挑衅”。这种“挑衅”有助于使您的软件组件更加健壮。
Qt 的单元测试框架,即QtTest
模块,可以通过在项目文件中添加一行代码CONFIG += qtestlib
来包含在项目中。该模块由一组用于测试的宏组成:
QCOMPARE( actual value, expected value )
:将实际值与期望值进行比较。QVERIFY( expression )
:评估表达式,如果结果为true
,则认为测试通过。QTEST( actual value, column name )
:将实际值与当前数据行的列值进行比较。
当使用QTEST
宏时,您需要通过使用数据槽为您的测试提供数据的测试向量,该数据槽与测试槽同名,但以_data
结束。数据槽通过使用静态的QTest::addColumn<type>(char*)
方法创建一组列,然后使用静态的QTest::newRow(char*)
方法添加数据行,数据是通过使用<<
操作符输入的。可以用QFETCH(type, column name)
宏或QTEST
宏从测试槽中检索数据。
测试 Qt 组件时,能够截取信号非常重要。它们通过使用QSignalSpy
类被截取和记录。
当从一个单元测试构建一个可执行文件时,使用QTEST_MAIN( test class )
宏创建main
函数。main
函数负责创建单元测试类的实例并执行测试。***
十七、附录 A:第三方工具
T 他的附录向你展示了一些第三方工具。一个大型的项目社区是围绕 Qt 构建的,这里展示的工具只是可用工具的一小部分。目的不是向您展示它们是如何使用的,而是向您展示可用工具的多样性。提到的每个项目都有很好的文档,并且很容易学习。
技术应用 Qt Widgets:Qwt
- 类别:小部件和类
- 网址:
[
qwt.sf.net](http://qwt.sf.net)
- 许可证:Qwt 许可证——LGPL 的慷慨版本
Qwt 是技术应用程序中使用的类和小部件的集合。小部件包括刻度盘、滑块、旋钮、图表、刻度和图例。提供的小部件通过插件与 Designer 很好地集成在一起。
在图 A-1 中可以看到 Qwt 的一些示例截图,其中显示了 Qwt 提供的一些表盘。这些刻度盘与滚轮和滑块结合在一起,可以很容易地指定数值。然而,Qwt 的真正威力在于它的绘图能力。您可以创建散点图、曲线图和直方图,带或不带等高线。所有这些图的数据都是通过QwtData
类或其后代提供的。通过继承QwtData
类,您可以计算要动态绘制的数据,然后将其提供给适当的绘图小部件。
**图 A-1。**Qwt 示例中的小部件和情节
wwWidgets
- 类别:小工具
- 网址:
[
www.wysota.eu.org/wwwidgets](http://www.wysota.eu.org/wwwidgets)
- 许可证:GPL
wwWidgets 库用一系列小部件补充了 Qt。这些小部件关注 Qt 从一开始就没有填充的区域——颜色选择小部件、屏幕键盘等——但也关注常见的组合小部件,如启动提示小部件。图 A-2 中显示了设计师会议的示例。这些小部件节省了时间,因为它们不必在每个新项目中都重新发明。
wwWidgets 库与 Qt 的其余部分集成得非常好——无论是在设计器中还是在构建系统中。当它被安装时,它会将自己添加到 Qt 安装中,所以您需要做的就是在您的项目文件中添加CONFIG += wwwidgets
——就像使用 Qt 模块一样简单。
图 A-2。 设计师会议中的一些 ww widgets
QDevelop
- 类别:开发环境
- 网址:
[
www.qdevelop.org](http://www.qdevelop.org)
- 许可证:GPL
QDevelop 是一个适用于 Qt 的真正的跨平台集成开发环境。它为 Qt 支持的所有平台提供了一个通用的开发环境。您可以在图 A-3 中看到一个示例会话。
QDevelop 的优势包括使用 GNU 调试器(gdb)调试应用程序的能力,处理 QMake 项目的能力,使用 ctags 提供代码完成和方法列表的能力,以及基于插件的架构。
注意要在 Windows 平台上使用 gdb,必须先安装(详情可以在 QDevelop 网站上找到)。
图 A-3。 QDevelop 在行动
ctags 对代码完成和方法列表的依赖性意味着接口可能会很慢,因为 ctags 是作为外部进程运行的,QDevelop 在这些功能实际工作之前会等待它完成。
QDevelop 确实有一个烦人的 bug。当请求帮助一个 Qt 类的成员函数时,它失败了。在寻找帮助时,您必须始终将光标放在实际的类名上。
当启动 Qt Assistant 来提供帮助时,它会作为一个单独的应用程序启动。QDevelop 将 Designer 和 Qt Assistant 作为外部应用程序与 QDevelop 应用程序并行运行。这很好,但是你必须手动地在应用程序之间来回切换。
Edyuk
- 类别:开发环境
- 网址:
[
edyuk.sf.net](http://edyuk.sf.net)
- 许可证:GPL
Edyuk 是另一个跨平台的集成开发环境,设计用于 Qt。Edyuk 是围绕透视图和插件构建的,它完全集成了设计器和 Qt Assistant,因此您可以在 Edyuk 环境中在代码视图和设计器之间切换。图 A-4 显示了显示代码透视图(顶部)和设计师透视图(底部)的屏幕截图。
项目文件支持很好,代码编辑器也是如此,但是打开项目可能会令人害怕,因为用户界面在加载时有时会没有响应。然而,开发速度很快(在我写这本书的时候),所以在你读到这本书的时候,这种情况可能已经改善了。
注意使用右下角的按钮可以显示或隐藏组成每个透视图的不同面板。这些按钮并不总是容易找到。
图 A-4。 行动中的 Edyuk 编辑
十八、附录 B:容器、类型和宏
Qt 提供了一系列宏、类型和容器,让开发人员的生活更加轻松。在比较和使用这些功能时,您可以将本章作为参考。
容器
有许多可用的容器,我把它们分成三组:序列、专用容器和关联容器。序列只是保存对象的普通列表。专用容器为保存某种内容类型或特定使用场景而优化。关联容器用于将每个数据项关联到一个键值。
以下每个部分都包含每种类型的优点和缺点—优点用加号(+)标出;弊标有减号()。
序列
QList
+开始时快速插入
+结尾快速插入
+快速索引访问
+快速迭代器访问
—在大型列表中间缓慢插入
QLinkedList
+开始时快速插入
+结尾快速插入
+在中间快速插入
+快速迭代器访问
—无索引访问
QVector
+结尾快速插入
+快速索引访问
+快速迭代器访问
+使用连续内存
开始时缓慢插入
—在中间缓慢插入
专用容器
QStringList
一个QStringList
是一个QList<QString>
,内置了字符串处理成员函数。下面的列表涵盖了其中的一些:
join
:将所有包含的字符串连接在一起,用给定的分隔符字符串分隔它们。split
:使用给定的分隔符字符串将一个QString
拆分成一个QStringList
。这个方法是QString
类的成员。replaceInStrings
:对所有包含的字符串执行搜索和替换操作。
QStack
一个QStack
是实现堆栈的列表。您使用push
方法将新项目放在堆栈的顶部。您可以使用top
方法查看顶部项目,也可以使用pop
方法获取顶部项目。
QQueue
一个QQueue
是实现队列的列表。您可以使用enqueue
方法将新项目放在队列的末尾。您使用dequeue
方法从队列的开始获取项目。您可以查看队列中的第一个项目,而不用使用head
方法将它从列表中删除。
QSet
集合是没有任何顺序或计数的键的集合。您可以使用insert
方法或<<
操作符插入新的键。然后,您可以使用contains
方法查看给定的键在集合中是否可用。要移除钥匙,使用remove
方法。
关联容器
关联容器将给定的键关联到一个值或一组值。哈希和映射的区别在于,哈希使用哈希函数对键进行排序,而映射保持键的顺序。哈希函数获取键值,并从中计算出一个称为哈希值的整数。结果是,散列可以更快地查找键,因为整数比较很快,而映射在遍历它们时更容易预测(因为它们根据键值而不是哈希值对内容进行排序)。
散列中使用的值和键必须是可赋值的(它们必须提供一个operator=
)。还必须有一个qHash
函数重载返回用作键的类型的uint
哈希值。
映射中使用的值和键必须是可赋值的,就像散列中使用的值和键一样。不是为键类型提供一个qHash
函数,而是必须有一个operator<
可用。
QHash
要将值插入到QHash
中,可以使用hash[ key ] = value
方法或调用insert( key, value )
方法。要确定散列是否包含给定的键,可以使用contains
方法。要获得给定键的值,使用value( key )
方法。
您可以使用keys
方法获得所有键的列表,使用values
方法获得所有值的列表。这两种方法都以任意顺序返回数据。通过一个QHashIterator
,你可以遍历散列中保存的所有键值对。
QMultiHash
QMultiHash
是为每个键分配几个值的散列。使用insert( key, value )
方法插入值。虽然键和值可以通过与使用QHash
相同的方法获得,但是通常使用values( key )
方法来获得与给定键相关的值的列表。
QMap/QMultiMap
QMap
和QMultiMap
的用法与QHash
和QMultiMap
完全相同。关联容器部分的介绍中描述了这些差异。
类型
Qt 提供了一系列以跨平台方式定义的类型。这意味着在所有平台上,无符号 16 位整数就是无符号 16 位整数。
Qt 还提供了一个 variant 对象,可以用来表示数据并在几种类型之间进行转换。
按大小分类
以下类型保证其内容的大小。通过在读写 Qt 流时使用它们,它们的字节序也可以跨平台边界保留。
quint8
:8 位无符号整数,范围 0–255quint16
:16 位无符号整数,范围 0–65535quint32
:32 位无符号整数,范围 0–4294967295quint64
:64 位无符号整数,范围 0–1.844674407 e19qint8
:8 位有符号整数,范围 128–127qint16
:16 位有符号整数,范围 32768–32767qint32
:32 位有符号整数,范围 2147483648–2147483647qint64
:64 位有符号整数,范围为 9.223372036 e18–9.272036 e18
不保证它的大小,qreal
仍然有用,因为它在除 ARM 之外的所有平台上都代表一个double
值。在 ARM 平台上,类型代表一个float
,因为 ARM 在双精度上有性能问题。
变体类型
QVariant
类型可以用来保存 Qt 应用程序中使用的大多数值类型。当给一个QVariant
对象赋值时,该值会自动转换成一个QVariant
。要将一个QVariant
转换成给定的类型,您必须指定您期望的类型。QtCore
模块中所有可用的类型都可以通过使用to
类型方法进行转换,其中类型是类型名。这样支持的类型有bool
、QByteArray
、QChar
、QDate
、QDateTime
、double
、int
、QLine
、QLineF
、QList<QVariant>
、QLocale
、qlonglong
、QMap<QString, QVariant>
、QPoint
、QPointF
、QRect
、QRectF
、QRegExp
、QSize
、QSizeF
、QString
、QStringList
、QTime
、uint
、
在QtGui
模块中使用的大多数其他 Qt 类型也可以和QVariant
类一起使用。要将这样的类型转换成一个QVariant
,只需将它分配给QVariant
对象。要将它们从QVariant
转换,请使用value<type>
方法。
注意如果你用的是 MSVC 6.0,需要用qvariant_cast
而不是value<type>
。
使用变体支持自定义类型
为了支持您自己的类型与QVariant
类的组合,您需要将它注册为一个QMetaType
。你可以通过使用Q_DECLARE_METATYPE(type)
宏来完成。您可以将此宏与您的类一起放在头文件中。
为了能够在所有使用QVariant
类的情况下使用您的类,您需要通过调用qRegisterMetaType<type>( const char *typeName )
的函数调用来注册它。类型名应该是类的名称;例如:
qRegisterMetaType<MySpecialType>( "MySpecialType" );
您打算以这种方式使用的所有类型都必须支持不需要任何参数的公共构造器,以及公共复制构造器和公共析构函数。
宏和功能
Qt 附带了许多宏和函数,以方便的方式提供了常见的操作。函数和宏分为三个部分:值处理函数、随机数函数和宏迭代。
对待价值观
在处理值的时候,你经常会发现自己在寻找最大值、最小值等等。所有这些比较都可以作为函数使用:
qMin( a, b )
:返回a
和b
中的较小值。qMax( a, b )
:返回a
和b
中的较大值。qBound( min, v, max )
:如果在min
和max
之间,返回值v
;否则,小于min
则返回min
,大于max
则返回max
。如果min
大于max
,则结果未定义。
qAbs
函数用于查找给定参数的绝对值。
您可以使用qRound
和qRound64
函数将qreal
值四舍五入为整数。qRound
函数返回一个int
值,这意味着不同平台的结果可能不同,因为int
的大小可能不同。这个潜在的平台相关问题由qRound64
解决,因为它返回一个在所有平台上大小相同的qint64
值。
随机值
伪随机数通过qrand
和qsrand
函数处理。随机数只是伪随机的,因为相同的种子给出相同的数字序列。这意味着使用可变值作为种子非常重要。一个常用的值是当前系统时间。
qrand
函数返回数字序列中的下一个整数,而qsrand
用于播种序列。如果你想得到一个可预测的数字序列,确保在qrand
前使用qsrand
。
从qrand
返回的值属于int
类型。这意味着它的大小会因平台而异。限制给定随机数范围的一个简单方法是结合使用模数运算符和偏移量。例如,要生成一个介于 15 和 30 之间的随机数,可以使用下面一行代码:
int value = 15 + qrand()%16;
这段代码使用%16
操作创建一个 0 到 15 之间的随机数。然后加上 15,将范围移动到 15 到 30。
迭代
当遍历一个列表时,foreach( variable, container )
宏非常方便。该宏适用于所有 Qt 容器。例如,要迭代一个QStringList
,使用下面的代码行:
foreach( QString value, valueList ) doSomething( value );
当你想永远迭代的时候,通常使用空的for
循环(for(;;)..
)。)或者一个永恒的while
循环(while(true)..
)。).为了让你的代码更容易阅读,请使用forever
宏。下面一行显示了它在实践中的样子:
forever doSomething();
如果您不希望 Qt 将foreach
和forever
关键字添加到您的全局名称空间中,请将no_keywords
值添加到项目文件中的CONFIG
变量中。您仍然可以通过Q_FOREACH
名称使用foreach
宏。foreach
宏作为Q_FOREVER
可用。
第一部分:认识 Qt
在本书的前几章,你将熟悉 Qt 的工作方式——包括使用可用的类以及创建自己的类与现有的类交互。您还将了解构建系统和一些工具,这些工具有助于让 Qt 开发人员的生活更加轻松。
第二部分:Qt 积木
T 他的部分深入看 Qt 的关键部分。这里介绍的类和技术使您能够创建和修改 Qt 构建块,并为您自己的应用程序创建定制组件。