前言:Qt是一个强大的跨平台开发工具,主要是用来做客户端开发,是非常值得学习的开发工具所以我决定开坑Qt。
1.初识Qt
Qt是一个跨平台的应用程序开发框架,主要使用C++进行开发。我们可以利用它提供的类和接口创建窗口、按钮、标签等控件,搭建一个能够和用户交互的图形界面。
比如我们希望在窗口上显示一段文字,就可以使用标签控件;希望用户点击某个位置触发操作,就可以使用按钮控件。不需要每次都从最底层去实现这些功能,Qt已经把很多常用功能封装好了。
Qt并不只是用来画窗口的,它还提供了文件处理、网络通信等功能。支持的平台也不只有Windows,还包括Linux、macOS,以及一些移动端和嵌入式平台。不过,跨平台通常是指代码可以在不同平台上复用,具体可以看到 Qt官方介绍
开始使用时,我们还会接触到Qt Creator和Qt Designer。这几个名字看起来很接近,但它们不是同一个东西:
| 名称 | 作用 |
|---|---|
| Qt | 开发框架,提供编写程序所需要的类和功能 |
| Qt Creator | 开发工具,用来编辑代码、构建、运行和调试项目 |
| Qt Designer | 界面设计工具,通过拖拽控件、修改属性来设计界面 |
Qt Creator中集成了界面设计功能,所以我们可以在同一个工具里切换代码编辑和界面设计。
我这里使用的是Qt Widgets项目,通过C++类和控件来构建界面。项目采用.pro文件配合qmake进行构建,下面也就围绕这套项目结构来介绍。
2.认识基本项目代码
创建一个项目以后,目录里会出现头文件、源文件、界面文件和项目文件。先把它们各自负责的事情分清楚,后面看代码就不会觉得它们是混在一起的。
| 文件 | 主要作用 |
|---|---|
main.cpp | 程序入口,创建应用对象和窗口,进入事件循环 |
widget.h、widget.cpp | 声明和实现我们自己的窗口类 |
widget.ui | 保存界面的布局和属性等信息 |
Project_demo.pro | 记录项目使用的模块、源文件等构建信息 |
mylabel.h、mylabel.cpp | 声明和实现后面用于观察析构过程的自定义标签类 |
2.1main函数与事件循环
先来看main.cpp:
#include"widget.h"#include<QApplication>intmain(intargc,char*argv[]){QApplicationa(argc,argv);Widget w;w.show();returna.exec();}这里的main依旧是C++程序的入口。argc和argv就是命令行参数这里在之前的文章也有过介绍,创建QApplication对象时会把它们传进去。
QApplication a(argc, argv);创建的是应用对象,它负责Qt Widgets应用所需要的一些初始化和事件处理工作。对于这样的Widgets程序,应用对象要先于窗口和其他控件创建。一个程序中也不需要给每个窗口分别创建一个QApplication。QApplication文档
接下来是Widget w;,它创建了一个窗口对象。但创建对象和把窗口显示出来是两件事,所以后面还要调用w.show();。
这里要区分两个名字:
QWidget是Qt提供的类。Widget是我们自己定义的类,它继承了QWidget。
Widget这个名字不是固定要求,只是当前项目把窗口类取成了这个名字。
最后的a.exec()是什么意思呢?
以前写控制台程序,代码从上往下执行,执行到最后就结束了。但一个图形界面程序不能刚把窗口显示出来就退出,它还需要等待用户点击、输入,以及处理窗口重绘等事情。
具体细节后面在去介绍
2.2窗口类的声明
再来看widget.h:
#ifndefWIDGET_H#defineWIDGET_H#include<QWidget>QT_BEGIN_NAMESPACEnamespaceUi{classWidget;}QT_END_NAMESPACEclassWidget:publicQWidget{Q_OBJECTpublic:Widget(QWidget*parent=nullptr);~Widget();private:Ui::Widget*ui;};#endif// WIDGET_H最外面的#ifndef、#define和#endif是头文件保护,避免同一个头文件被重复包含时引起重复定义等问题。
class Widget : public QWidget表示我们的Widget继承自QWidget。这样它就具备了窗口和控件的基本功能,前面调用的show()也是继承得到的,不需要我们自己实现一遍。
构造函数接收一个QWidget* parent,默认值是nullptr。这个参数用来指定父对象,具体在后面介绍对象树时会介绍。
最后再看Ui::Widget *ui;。
前面的namespace Ui { class Widget; }是在Ui命名空间中前置声明了另一个Widget类。但是这里Ui::Widget与我们定义的Widget是两个不同的类。前者是根据界面文件生成的辅助类,后者才是我们正在编写的窗口类。
这里先声明一个指向辅助对象的指针,具体创建和使用放在widget.cpp中。
3.界面创建与项目构建
3.1通过Designer设计界面
在Qt Creator中打开widget.ui,就可以进入界面设计界面:
左边是控件箱,中间是设计区域,右边可以查看对象以及修改属性。比如想让窗口显示Hello World,就可以拖入一个QLabel,再修改它的text属性。
这种方式不需要我们手动写出每个控件的创建代码。不过,拖拽操作也不是凭空生成界面,它的结果会保存在.ui文件中。
切换到文本方式查看,当前项目的widget.ui是这样的:
<?xml version="1.0" encoding="UTF-8"?><uiversion="4.0"><class>Widget</class><widgetclass="QWidget"name="Widget"><propertyname="geometry"><rect><x>0</x><y>0</y><width>800</width><height>600</height></rect></property><propertyname="windowTitle"><string>Widget</string></property></widget><resources/><connections/></ui>它使用的是XML格式。我虽然不会XML,也可以先对照内容看出:这里描述了一个QWidget,尺寸是800×600,窗口标题是Widget。
目前这个文件描述的还是一个空窗口,没有标签。如果我们通过Designer添加标签并保存,文件里就会多出相应控件及其属性的信息。后面代码中出现的Hello World!,则是我们通过C++代码添加的。
那XML是怎么变成C++界面的呢?
构建时,Qt的uic工具会读取widget.ui,生成ui_widget.h。里面包含Ui::Widget这样的辅助类,以及用于初始化界面的setupUi()函数。窗口的构造函数调用它,就能按照界面描述设置窗口,并创建其中定义的控件。UI文件的使用方式
也就是说,Designer帮我们完成了界面描述,生成工具再把这些描述转成程序能够使用的代码。
3.2通过代码创建标签
另一种方式就是直接写代码创建控件。
先看最简单的标签写法。下面这两行放在窗口构造函数中,就可以创建一个属于当前窗口的QLabel,并设置文字:
QLabel*label=newQLabel(this);label->setText("Hello World");使用QLabel时,需要包含它的头文件<QLabel>。QLabel是标签控件,可以用来显示文本等内容;setText()就是设置它显示的文字。
这里传入的this指向当前窗口对象,让父对象管理子对象
setText()接收的是QString。它是Qt提供的字符串类,这里的字符串字面量在默认配置下可以转换成QString,所以可以直接写成"Hello World"。后面使用Qt接口时,我们还会经常接触到这个类型。QLabel文档
当前项目为了继续观察对象的析构过程,我又把普通QLabel替换成了继承自它的MyLabel。完整的widget.cpp如下:
#include"widget.h"#include"ui_widget.h"#include<QLabel>#include<mylabel.h>Widget::Widget(QWidget*parent):QWidget(parent),ui(newUi::Widget){ui->setupUi(this);// QLabel* label = new QLabel(this);// label->setText("Hello World");MyLabel*label=newMyLabel(this);label->setText("Hello World!");}Widget::~Widget(){deleteui;}两种写法是替换关系,不要在同一个作用域中同时定义两个叫label的变量。
我们顺着构造函数看一遍:
QWidget(parent)先初始化基类部分,并把父对象参数传给基类。ui(new Ui::Widget)创建界面辅助对象。ui->setupUi(this)把界面文件描述的内容设置到当前窗口上。new MyLabel(this)创建一个标签子控件,再通过setText()设置它的内容。
这里同时使用了界面文件和手写代码:.ui负责空窗口的尺寸、标题等设置,代码负责添加标签。这两种方式可以配合使用,并不是只能选其中一种。
还有一个容易疑惑的地方:label明明是构造函数里的局部变量,为什么构造函数结束后,标签还能留在窗口上?
因为label只是一个局部的指针变量,标签对象本身是通过new创建在堆上的。构造函数结束,指针变量的生命周期结束,但它指向的对象不会因此自动销毁。
那这个对象什么时候释放呢?这就和后面的对象树有关。
这里再额外补充一点,其实也可以在栈上定义对象,但这种方式不推荐,虽然在之前C++文章里介绍过函数栈结束时会自动调用它的析构函数释放资源,但有时候可能会出现控件提前销毁的情况,所以对于Qt来说最安全的方式是通过this指针交给父对象进行管理
3.3pro文件与自动生成的文件
界面和代码都写好后,项目还需要构建才能运行。当前项目使用的Project_demo.pro,就是qmake读取的项目文件。
先看其中的模块和语言标准配置:
QT += core gui greaterThan(QT_MAJOR_VERSION, 4): QT += widgets CONFIG += c++11QT += core gui表示使用相应的Qt模块。后面的条件是在Qt主版本大于4时加入widgets模块,CONFIG += c++11则表示使用C++11相关编译配置。
再看文件列表:
SOURCES += \ main.cpp \ mylabel.cpp \ widget.cpp HEADERS += \ mylabel.h \ widget.h FORMS += \ widget.ui这几个名字就比较直观了:
SOURCES:项目中的源文件。HEADERS:项目中的头文件。FORMS:需要处理的界面文件。
.pro不是普通C++代码,里面的+=是在给对应的项目变量追加内容。把文件加入项目时,也需要让构建系统知道这些文件的存在。
具体参考文档在这里**qmake文档
把关系连起来看,就是下面这样:
构建以后,打开对应的构建目录,会看到不少原先没有手写过的文件:
比如:
ui_widget.h是根据界面文件生成的,供widget.cpp包含和使用。moc_widget.cpp是moc生成的代码,也要参与编译和链接。.o文件是编译产生的目标文件。Project_demo.exe是Windows下最终得到的可执行程序。
把这些文件和“运行后”联系在了一起,是因为点击运行时,开发工具通常会先完成必要的构建。准确来说,它们是在配置、编译和链接等阶段产生的,不是窗口程序运行时才生成。
自动生成的文件可以打开观察,但一般不要直接修改。比如改了ui_widget.h,下一次重新生成时,改动就可能被覆盖。要改变界面,应该回到widget.ui或者自己的源文件中修改。
4.对象树与对象的释放
4.1父对象与子对象
回到前面的代码:
MyLabel*label=newMyLabel(this);我们通过new创建了一个对象,却没有在窗口析构函数中写delete label;,这样会不会内存泄漏呢?
对于这里的写法,标签的释放交给了Qt的父子对象管理机制。
Qt中的QObject支持父子对象关系,QWidget及其派生控件也具备这种能力。一个对象可以有多个子对象,子对象下面还可以继续有自己的子对象,这样连接起来,就形成了对象树,有些像是一颗多叉数。
当一个父对象析构时,会删除它仍然拥有的子对象;子对象的析构又会继续清理它自己的子对象。对于窗口控件来说,父子关系还和界面上的包含关系有关,例如标签可以显示在父窗口内部。Qt对象树与所有权
所以,new MyLabel(this)不只是指定标签显示在哪个窗口里,也让当前窗口承担了这个标签的所有权。
这里要把两种关系分开:
MyLabel : public QLabel是类的继承关系。- 标签对象的
parent指向窗口对象,才是对象之间的父子关系。
窗口对象不是标签对象的“基类对象”,它们是两个独立创建出来的对象。
前面还出现了ui,那它是不是也挂在对象树上呢?
其实不是。Ui::Widget只是一个普通的界面辅助类,并不是QObject。 它虽然和我们自己的Widget名字相近,但用途不同。
因此,窗口析构函数中的这段代码:
Widget::~Widget(){deleteui;}释放的是new Ui::Widget创建的辅助对象。这里的delete ui;,并不是在删除标签。
真正清理标签的是窗口对象后续的基类析构过程。也就是我们自己的Widget析构函数执行结束后,QWidget等基类的析构流程继续处理子对象。
对象树也不是一个能管理所有内存的垃圾回收器。没有指定父对象的堆对象,仍然需要自己安排释放;普通的new int等对象,也不会因为程序使用了Qt就自动挂进对象树,有些像是类GC的意思。
4.2通过MyLabel观察析构过程
原来直接使用QLabel,它被销毁时,我们不容易从界面上看出具体过程。为了观察这一点,可以自己定义一个继承自QLabel的类,在析构函数中打印一条日志。
先看mylabel.h:
#ifndefMYLABEL_H#defineMYLABEL_H#include<QLabel>classMyLabel:publicQLabel{public:MyLabel(QWidget*parent);~MyLabel();};#endif// MYLABEL_H再看mylabel.cpp:
#include"mylabel.h"#include<iostream>#include<QDebug>MyLabel::MyLabel(QWidget*parent):QLabel(parent){}MyLabel::~MyLabel(){//std::cout << "~MyLabel" << std::endl;qDebug()<<"MyLabel 销毁";}这个类本身并没有添加复杂功能,主要就是在析构函数里多输出一句话。
构造函数的QLabel(parent)很重要。MyLabel接收到外面传进来的父对象后,还要把它继续交给基类,由基类完成相应的初始化。这样new MyLabel(this)传入的窗口对象,才真正成为这个标签的父对象。
这里的MyLabel没有写Q_OBJECT,但仍然可以使用继承来的标签功能和父子对象管理机制。这两项能力并不要求我们给每个派生类都重新写一次Q_OBJECT。
程序正常结束时,可以在输出区域看到MyLabel 销毁,说明标签对象的析构函数确实执行了。
所以,不是“控件设置了父对象,就不需要考虑生命周期了”,而是我们可以利用对象树,让父对象统一管理适合交给它负责的子对象。
4.3qDebug
最后再看析构函数中的日志:
qDebug()<<"MyLabel 销毁";使用它需要包含<QDebug>。从写法上看,它和cout类似,都可以通过<<连续输出内容,Qt中很多常用类型也能够直接交给它输出。
不过,qDebug()使用的是Qt的日志输出机制,并不是std::cout的另一个名字。调试Qt程序时,通常可以在Qt Creator的应用程序输出区域查看这些信息。这里临时创建的日志流在语句结束时提交消息,不需要再写std::endl。QDebug文档
日志有什么用呢?像这里的析构过程,单看窗口消失并不能确认某个对象到底有没有销毁,而在析构函数中打印一条信息,就能观察程序有没有执行到这个位置。
完