QT5音视频应用开发经验总结

一. 背景

相信很多人接触C++客户端编程是从MFC或Win32 API开始的,这针对简单的Demo或小的工具程序是问题不大的。 但如果你想开发一款界面美观、产品级的应用客户端,那现在选择MFC或Win32 API开发的话可能就不太合适了。 因为这不仅对开发人员的技术要求高,更重要的是,很多UI美化的工作一般需要通过自绘控件技术才能达到。 代码复杂,难维护不说,还有一个致命的限制是只能运行在Windows操作系统上,无法跨平台。

如果你的客户端需要实现一套源代码,同时支持Windows、Linux、Mac OS、ARM嵌入式Linux等多个操作系统时,那目前可选择的UI开发框架就不多了。 就目前国内的招聘需求来看,基本就只有QT了(其实,wxWidgets也是一个C++、跨平台的、开源的UI开发框架,但国内使用wxWidgets开发的好像不多)。

当然,关于跨平台客户端应用(不包括移动端APP)的开发,目前基本有两个趋势:

1. 纯C++开发的客户端

典型的就是基于QT开发,C++程序员一般会选择,而在工控嵌入式领域,基本就是唯一的选择。 由于QT已经发展了很多年(QT6已经出来了,但目前市场上主要还是使用QT5),内部各种基础库、UI控件都比较完善,也支持类似前端的CSS样式控制,相对MFC来说,总体开发算是比较方便的。

当然,如果不需要跨平台,只希望在Windows上运行的话,那除了MFC,也可以选择WPF去开发,但其需要使用C#语言,适合熟悉.Net开发技术的程序员。

2. 前端Web和C++混合开发的客户端

目前主流的是基于Electron开发,UI界面和逻辑可以使用H5、NodeJS去实现,底层核心的模块可以通过C++来开发,封装为Node模块供上层调用。 当然,也可以选择使用多进程架构,UI部分使用Electron开发,而核心功能在后台进程中运行,两者之间通过WebSocket进行通信,实现整体应用功能。

近几年来,WebAssembly技术发展的也比较快,这也是支持通过C++/Rust开发核心模块,可以直接将编译生成的wasm模块集成到前端页面中调用的一种技术,虽然目前没有大规模应用,但也是一个值得关注的技术,主流的浏览器都已经支持这项技术。

要说明的是,QT5也支持类似的混合开发技术,即QML技术,相对于传统基于Widget开发而言,对硬件配置要求会高些,大部分的嵌入式平台应该是不支持的。

本文主要讨论、总结一下基于QT5开发音视频客户端的一些技术,偏向QT传统的Widget开发技术,不涉及QML技术。 其实,主要的还是QT5自身的开发内容,和音视频技术的关联并不大,值得注意的就是视频渲染的部分,特别是视频上有叠加背景透明的文字、图标、按钮等需求的场景。

另外,在嵌入式平台上,视频渲染和x86 平台下的差异也比较大,例如,基于海思芯片的视频渲染需要用到底层的FrameBuffer技术,而不是简单获取视频窗口的句柄,并设置给音视频引擎的视频渲染器就可以的。

二. QT的核心概念和工作机制

QT作为一个成熟的C++跨平台UI开发框架,内部包含了很多值得学习的软件开发和架构设计的知识,这里挑选了一些QT中比较核心的概念和重要工作机制来总结一下自己对QT的理解。

  • QT的元对象系统
  • QT的事件循环和信号槽机制
  • QT的事件过滤机制
  • QObject对象的线程亲和性和生命周期管理
  • QT字符集编码和中文乱码问题

1. QT的元对象系统

QT最为大家印象深刻的是它的信号槽机制。 这个机制,有效减少了C++中类与类之间的耦合,很方便就可以建立起控件操作与处理函数的对应关系,极大方便了UI应用程序的开发。 即使不是UI程序的开发,这种降低模块间耦合的技术也是非常值得借鉴的,例如,在WebRTC的代码中,就看到了使用类似信号槽的技术。

要注意的是,QT的信号槽机制并不是C++提供的,而是QT自己创造的。其底层实现的核心其实就是QT的元对象系统机制,主要包括QObject类、 Q_OBJECT宏和元对象编译器MOC。

QT的元对象系统提供了一种类似JAVA反射的技术,通过它,让QT可以自己实现对C++语言的扩展,提供像信号槽这样的“语法糖”。 QT提供了MOC编译器(Meta-Object Compiler)支持元对象系统的实现,MOC编译器认识这些QT发明的“语法糖”,然后将我们写的代码翻译为C++的代码。 编写QT代码时,程序员只需要按照QT提供的语法去写就可以了,至于怎么翻译为C++代码,可以完全不用去管。

在QT程序代码进行编译的时候,第一步就是要通过MOC编译器去将QT的代码翻译处理一遍,生成真正的C++源代码,然后才能提供给C++编译器进行编译和链接,生成可执行程序。

在我们自己的代码中,是可以利用QT的元对象能力的,其实就是反射的能力。 在QT中,只要一个类是继承QObject类,其在类中添加了Q_OBJECT宏,那么这个类就具备了元对象的能力,这个类是需要经过MOC编译器处理生成真正的C++类,才能被C++编译器编译的。

QT元对象反射能力的一个简单应用是在自定义的Widget类中加载QSS样式文件的过程中,通过元对象可以方便提取出所在类的类名,这样就不用把类名以字符串的形式写死在代码里了,提高了这段代码的可复用性。

void VideoWidget::LoadQssFile()
   const QMetaObject  *mObj = this->metaObject();
   QString clsName = mObj->className();
   qDebug() << clsName;
   QString qssPrefix = ":/qss/";
   QString qssFileName = qssPrefix + clsName;  //QString qssFileName = qssPrefix + "VideoWidget";
   QString qssPath = qssFileName + ".qss";
   QFile file(qssPath);
   if (file.open(QFile::ReadOnly)) {
       QString styleSheet = file.readAll();
       file.close();
       qDebug() << styleSheet;
       this->setStyleSheet(styleSheet);
}

QT的元对象系统可以认识C++的基本数据类型,但不认识用户自定义的类型,如类、结构体、枚举等。如果在信号和槽函数的形参中用这些类型的参数时,那么在通过connect函数连接信号和槽前,需要自己通过qRegisterMetaType("xx")函数将其注册到元对象系统中。

如果自定义的类型需要放入到QVariant中处理,那么需要使用Q_DECLARE_METATYPE(Type)宏进行注册。

类的成员变量及其对应的set和get函数,可以通过Q_PROPERTY()宏注册到元对象系统中,形成对象的属性系统,这样,就可以通过该类对象获取到对应的元对象进行相关属性的读写了。而通过Q_INVOKABLE修饰的成员函数,也会被注册到元对象系统中,然后,通过QMetaObject::invokeMethod()函数就可以调用注册的成员函数了。

通过上述QT提供的宏和函数,一个类的元对象就会被逐步构建出来。通过QObject对象的指针,可以获取该类的元对象,从而去利用其提供的反射能力。

下面这个例子就可以遍历查看该对象有哪些属性:

const QMetaObject *metaobject = this->metaObject();
int count = metaobject->propertyCount();
for (int i = 0; i < count; ++i) {
    QMetaProperty metaproperty = metaobject->property(i);
    const char *name = metaproperty.name();
    QVariant value = this->property(name);
    qDebug() << name << value;
}

其实,在实际的开发中,需要我们手动处理元对象的机会并不是很多,更常用的是下面介绍的信号槽和事件处理机制。

2. QT的事件循环和信号槽机制

事件循环机制对大部分C++程序员来说,并不陌生。

我们平时的开发中,经常会涉及到类似这样的机制,如从网络上接收消息,然后查找消息和处理函数的Map表,执行对应的消息处理函数。

在学习MFC时也必须去了解Windows操作系统的事件循环。

本质上,事件循环、消息循环、数据驱动基本讲的都是类似的东西。

当然,这里我们讲到的QT事件循环和信号槽机制还是有本质差别的。

其实,事件循环是操作系统的一种底层工作机制,是让操作系统能够处理外围硬件设备的输入、输出的重要技术。

操作系统的事件循环机制粗略可以这样去理解:

用户操作鼠标、键盘等时,对应硬件驱动程序会触发中断信号,操作系统可以将这些底层硬件的中断转换为操作系统内定义的事件,然后加入到操作系统的事件队列中,由应用程序通过操作系统提供的API从事件循环中获取事件,然后分发到自己程序内编写的事件处理函数中进行处理,从而让用户的操作得到执行(如何执行就看事件处理函数怎么写的)。

而QT的事件循环机制,其实是操作系统事件循环机制的一种应用层封装。

QT的事件循环机制会将操作系统的事件转换为QT内部自定义的事件对象QEvent,并将QEvent分发给每个QObject对象的event()函数去进一步分发处理。 当然,在QObject子对象的event()函数中,也可以根据QEvent触发对应的信号,以调用对应的槽函数。这里,我们定义的槽函数本质上就是事件处理函数。 所以,我们可以知道,QPushButton按钮控件上的鼠标点击信号其实是操作系统鼠标事件的一种封装而已。

其实,我们也是可以利用QT的事件过滤机制在QWidget控件对象处理事件前,提前截获到相关事件,然后添加相关的处理,例如,我们可以给QLabel控件添加它原本不支持的鼠标点击事件。

在我们平时写QT程序时,大多数时间是在使用QT的信号槽机制,只会在一些特殊需要的场景才会去直接处理底层事件。 那么,QT的信号槽机制和QT的事件循环机制有什么关系呢?

其实,我们自己程序中定义的信号,和硬件底层事件没有任何关系,完全是一种软件上定义的程序行为。

单线程环境下,信号槽本质上是QT提供的一种回调函数机制(同步调用),和事件循环没太大的关系,但依赖QT的元对象系统。 而在多线程下的信号触发机制是需要依赖事件循环的(异步调用)。

猜测在多线程环境下,触发信号时,会将信号转换为QEvent投放到目标线程的事件队列中进行分发,而槽函数能够被跨线程调用,也是依赖了MOC提供的机制(即信号、槽、被标记成Q_INVOKABLE的类成员函数,是可以跨线程调用的,另外,通过QMetaObject::invokeMethod函数也可以跨线程调用被Q_INVOKABLE修饰的类成员函数)。

要实现信号和槽的关联,我们需要通过QObject提供的connect函数建立两者的关系,这种信号和槽连接的过程,其实是在构建一个信号-槽的Map表。 槽函数是要被调用的回调函数,由程序员自己根据业务需要实现。 信号是一种索引,触发信号,本质上就是根据信号生成的索引,查找信号-槽的Map表,找到对应槽函数,调用回调函数的过程。 当然根据信号接收者所在线程的异同,有异步调用和同步调用之分。

QT的信号槽机制很强大,也很好用,但开发时还是有一些要注意的地方,特别是在多线程结合时,更加应该小心。

一个类要启用QT的信号槽机制,那么这个类必须继承QObject类或其子类(如QWidget、QFrame等),且在类的开始部分加上Q_OBJECT,然后借助MOC编译器生成相关的实现(生成的代码在编译目录下的moc_xxx.cpp中,C++编译器其实是编译这个文件)。

自定义的信号,写在signals:下面,而槽函数写在public/protected/private slots:下面(不同的访问修饰符代表了谁可以和该槽函数连接),代码中需要触发信号时,直接使用emit触发(看上去像信号函数的调用一样,可以传递具体的信号参数)。 当然,信号只有声明,没有函数定义的;而槽函数不一定是通过信号触发调用,也可以直接像普通的成员函数一样被手工调用。 但是要注意的是,在定义槽函数时一定要注意不能再次发射同样的信号,否则会无限循环,导致程序死锁。

我们其实比较要注意的是通过connect函数连接信号和槽的地方。

在QT5中,支持两种形式的connect写法:

传统的信号槽连接写法,类似这样的:connect(sender, SIGNAL(signal_fun(int, bool)), receiver, SLOT(slot_fun(int, bool)));

信号写在SIGNAL()内,槽函数写在SLOT()内,注意信号和槽函数的参数类型和个数需要一致,当然信号的参数个数可以多于槽函数的参数个数,但最好不要这样做。 另外要注意的是,这里的信号和槽函数里不能写形参变量名,只能保留参数类型。 如果信号触发,但槽函数没有执行时,要仔细检查一下,这里有没有写错。

当然,如果信号和槽函数的形参里有自定义的类型时,在connect前必须先通过qRegisterMetaType("xx")函数将其注册到元对象系统中,才能保证信号和槽连接成功。

可以看到,传统的信号槽连接写法还是有不少要小心的地方的,关键是写错了MOC编译时可能并不会报错,但运行时槽函数却不执行,增加了排错的时间。

基于上述这些问题,QT5引进了一种新的信号和槽connect写法,主要是将信号和槽函数部分替换为类成员函数的写法,而且编译时可以增加检查报错,减少了出错的机率。 另外,这种写法下,槽函数支持C++的lambda函数,而且槽函数也不必一定要写到public/protected/private slots:下面,增加了灵活性。

另外,需要了解的是,信号是可以连接到另一个信号上的,实现信号级联触发。 一个信号可以被多个槽函数连接(槽函数的执行顺序时随机的,且不可控制),同样,一个槽函数也可以被多个信号触发。 总体而言,QT的信号槽机制还是比较灵活的。

最重要的,也是最容易出错的是,多线程环境下的信号槽连接问题。

在QT中,所有的Widget控件对象都必须在UI线程(主线程)中创建和操作,不能在后台工作线程中创建和操作,否则程序会崩溃,但后台线程可以发送信号给UI线程中的对象。

例如,我们在网络线程上收到一条消息,需要将消息的内容显示到界面上,就可以在网络线程收消息后,触发一个信号到UI线程(猜测可能根据信号生成了一个QEvent事件,并投放到UI线程的事件队列中),UI线程内有一个主事件循环不断进行事件分发,从而可以调用到对应控件的槽函数,执行消息内容显示到控件上(注意,这种情况下槽函数是在UI线程中执行的,而不是网络线程)。

我们已经知道只有继承QObject的类才可以启用QT的信号槽机制,但要特别注意的是,QObject类对象是有线程亲和性的,也就是对象在哪个线程上创建,这个对象就属于哪个线程。

如果connect时,没用指定连接类型(即使用默认的Auto Connection模式),且信号的发出者(注意是触发信号时所在的线程,不是信号所属对象的线程)和接收者不在同一线程(实际采用的是Queued Connection模式),那么其实是将信号投递到接收者所在线程的事件循环中(不管这个线程有没有开启事件循环,都可以投递),但如果这个线程不存在事件循环,那么槽函数就不会得到执行。通过继承QThread方式实现线程类时,就会导致这个线程不会启用事件循环。

另外,在QT提供的工具类中(如数据库QSqlDatabase),有些是存在这样的限制的,要求创建对象的线程和槽函数执行的线程是同一线程(其实,Widget控件都要求在UI线程上创建和槽函数调用)。

QT中,对象的线程亲和性可以通过QThread的moveToThread()函数进行改变,这样就可能导致创建对象的线程和对象的实际绑定线程不一致的情况。

多线程下,使用信号槽时还是要仔细分析清楚,否则可能会导致各种异常问题的出现。

3. QT的事件过滤机制

我们已经知道,事件循环机制是QT依赖的一个核心底层工作机制,主要由操作系统提供。

大多时候,隐藏在背后的事件循环和最常用的信号槽机制,可以满足我们多数的开发需求。 但有时候可能因为特殊的需求,QT的信号槽机制无法直接满足我们的需求时,就可能需要利用更底层的事件处理机制来实现一些特殊的功能。 例如,在QLabel上如果想要响应鼠标点击事件时,由于QLabel控件自身并没有提供对应的信号,要实现这一功能,我们就需要依赖QT的事件过滤机制来实现。

在UI程序的开发中,我们需要处理的事件,大部分来自于鼠标和键盘产生的事件,另外一部分来自于窗口系统,如窗口的重绘事件等。当然,定时器超时信号也是经常要处理的。

那对于一个鼠标或键盘事件,在QT内部大致是怎样的处理流程呢?又如何实现事件的过滤呢?

上文我们提到,QT的事件循环机制其实是对操作系统事件循环的一种封装,主要封装在QEventLoop中,更上层的封装则在QCoreApplication中。 在QT的main函数中,我们需要通过调用QApplication::exec()函数来启用主事件循环(QApplication是继承QCoreApplication的,实际会调用QCoreApplication::exec())。

在QCoreApplication::exec()中会去创建一个QEventLoop对象,而在QEventLoop::exec()函数中会通过QEventLoop::processEvents()函数循环调用操作系统底层的事件分发接口去获取操作系统事件,封装为QEvent,这个过程中也会绑定事件的目标QObject对象(即事件的接收者)。

上述和操作系统相关的事件,其事件队列其实是属于操作系统;而在QT内部,也有自己的事件队列,用于存放通过QCoreApplication::postEvent()投递的自定义事件(注意,通过QCoreApplication::sendEvent()发送的事件不会进入事件队列,而是直接进入事件处理流程,即调用QCoreApplication::notify()函数)。

在QT中,通常会先处理QT自身的事件,然后再处理操作系统的事件。

QCoreApplication::notify()函数是QT内部所有事件处理的入口。完整的函数声明是: bool QCoreApplication::notify(QObject receiver, QEvent event); 可以看到其形参中,包含了事件的接收者和封装事件本身的QEvent对象。

从事件队列中获取到的事件会通过QCoreApplication::notify()函数进入该事件的处理流程,大致如下:

  • 判断QApplication是否被安装了事件过滤器(全局事件过滤器),如果是,则先调用QApplication事件过滤器的eventFilter()函数,根据返回值判断事件是否需要继续处理;如果否,则进入下一步;
  • 判断事件的receiver是否被安装了事件过滤器,如果是,则先调用receiver对象事件过滤器的eventFilter()函数,根据返回值判断事件是否需要继续处理;如果否,则进入下一步;
  • 最后,调用receiver->event()函数进行事件处理,这里其实也是进一步分发事件给我们上层的事件处理器(event handler,一般是xxxEvent函数,如mousePressEvent函数等),当然,事件处理器中可以根据事件类型触发对应信号,从而执行对应的槽函数。这一步,会根据event()的返回值,判断事件是否处理结束,如果没结束,则需要获取receiver的父窗口,将这个事件继续传给父窗口继续处理,直到顶层窗口为至(顶层窗口没父窗口,一般这个窗口在Windows任务栏上会有图标)。

从上述的QT事件处理过程,我们可以看到,在这个事件传递给目标对象的event()函数之前,可以进行拦截过滤,以帮忙我们实现一些特殊的功能。

要给一个QObject对象安装事件过滤器,写法是:monitoredObj->installEventFilter(filterObj);

其中,monitoredObj是被安装了事件过滤器的QObject对象,而filterObj则是monitoredObj的事件过滤器,传给monitoredObj的事件会优先传给filterObj的eventFilter()函数处理。

可以看出,事件过滤器filterObj通常是要重载实现eventFilter()函数的,在其中就可以实现我们自己想要的功能,当然也可以通过返回值来控制该事件是否要继续传递。

要注意的一点是:monitoredObj和filterObj要在同一线程内,事件过滤器才会生效。

当然,这个monitoredObj对象可以被安装多个事件过滤器,他们会按照安装的顺序形成一个事件过滤链,在处理事件时,也是按照这个顺序逐个调用对应的eventFilter()函数进行处理,根据eventFilter的返回值可以决定是否要继续往后传递。

另外,在Windows系统上,可以通过重载实现nativeEvent()函数,来截获处理所有Windows系统的事件。

这其实就是QT的事件过滤机制。

4. QObject对象的线程亲和性和生命周期管理

在QT中,QObject、QWidget、QCoreApplication都是非常重要的类。

QT自身提供的类基本都是继承于QObject类的,这样才能使用QT的信号槽能力。而UI开发涉及的控件类,则都是继承于QWidget的,它提供了窗口相关很多控制能力,而QWidget也是继承于QObject的。 QCoreApplication则实现了QT的事件循环,代表了QT的整个应用,当然QCoreApplication也是继承QObject的。

可见,QObject是QT中最根本的基类了。

而QObject对象有一个非常重要的特性,那就是QObject对象的线程亲和性。 这个特性在开发多线程的QT程序时,是非常重要的。如果理解不到位,可能会引起各种各样的问题。

我们知道,线程可以设置CPU核的亲和性(即线程绑定在某个CPU核上运行),而QObject对象的线程亲和性是指在哪个线程上创建QObject对象,则该对象就和哪个线程进行绑定。 上文讲到多线程下的信号槽机制时,我们提到QObject对象的线程亲和性会影响到信号和槽的连接方式(是Direct Connection还是Queued Connection),也决定了槽函数最终会在哪个线程上执行。

需要注意的是,QWidget对象必须在UI主线程创建和操作,不能在其他后台工作线程。

另外,QObject对象的线程亲和性是可以改变的,在一个线程上创建的QObject对象,可以通过QObject::moveToThread()函数将该对象绑定到另外一个线程上。

在QThread的使用方式上,就建议使用这种方式,而不是传统的通过继承QThread,并重写run函数来实现线程。 因为通过继承QThread方式创建的线程不会开启事件循环,会导致给在这个线程上创建的QObject对象发信号时,槽函数得不到执行的问题。

而正确的使用方式是,在一个线程上(通常是主线程)创建QObject对象和QThread对象,然后将QObject对象通过QObject::moveToThread()函数移入到QThread对象上,此时,这个线程是启用事件循环的,其他线程的QObject对象可以正常给这个QObject对象发送信号,槽函数也能够在这个线程上正常执行。

下面我们可以谈谈QObject对象的另一个重要话题,对,就是QT中QObject对象的生命周确管理问题。 这里,我们特别强调是QT中QObject对象,而不是普通的C++对象。

C++中对象的生命周期管理是一个非常重要的话题,因为C++需要程序员自己手动管理内存,而这也是C++程序经常容易出现内存问题的重要原因。特别是多线程环境下如何正确管理好对象的生命周期,更是C++程序开发中的一个难点,稍有不甚就会出现内存泄漏、程序崩溃等严重问题。

从C++11开始,智能指针(shared_ptr、weak_ptr、unique_ptr)的正确使用,可以在一定程度上缓解这类问题,但也会引入其他一些问题(例如,智能指针自身的线程安全性等),比起带垃圾回收的编程语言,C++程序员在写代码时心中还是要始终绷紧这根弦。

在QT程序开发中,我们常用遇到的是父子窗口控件对象的管理问题。好在,QT提供了一定的内在机制,帮我们管理了这方面的问题。 在QT代码中,我们常常会看到代码明明有很多new出来的对象,但却很少看到delete对象的语句。 原因就在于QT通过父窗口的对象管理了所有其子窗口/控件对象的生命周期。其实现原理并不复杂,但却很大方便了我们写代码,减少了一点心智负担。

在完全通过代码而不是使用ui designer来实现QT程序时(特别是使用布局的时候),要注意父子对象类成员变量的声明顺序,要将父窗口声明在子窗口的前面,否则在子窗口对象析构时,可能会导致程序崩溃的问题。原因在于,C++的类成员变量的析构顺序与其声明顺序是相反的,在使用QT布局器时,布局内的子对象析构时会去解绑父对象,如果这时父对象不存在,子对象解绑时会Crash。

另外,在QT中,需要自己删除QObject对象实例时,最好不要用delete来直接删除,而是建议使用QObject::deleteLater()函数来实现延迟删除。后者通常是线程安全的,QT内部会在事件循环上投递一个QDeferredDeleteEvent事件,并在事件循环上去执行delete操作。当然,如果你确定删除对象时和删除之后,该对象不会被使用,那么也是可以自己手动delete对象的,这样可以更精确地控制内存。

补充一点:

QT的窗口在被关闭时,默认不会delete该窗口对象,只是将窗口隐藏了。如果希望QT窗口在被关闭时自动能够delete掉自己,可以在该窗口的构造函数中设置这个的属性: setAttribute (Qt::WA_DeleteOnClose);

5. QT字符集编码和中文乱码问题

在QT程序开发过程,中文字符乱码的问题绝对是困扰很多程序员的一个难题,还经常容易产生各种概念的混淆。

要梳理清楚这个问题,首先我们需要理清相关的字符集编码标准及编码格式,然后要搞清楚操作系统、编译器、源代码文本编辑器、文本文件、网络数据、UI界面、QT字符串、控制台等各个环节对中文字符串的处理方式,否则很容易出现在这里正常,在另外一个地方就乱码的问题。

讲到字符集编码,我们肯定比较熟悉的是ASCII码,这对于只有英文、数字和简单符号的字符串处理是最简单和高效的,每个字符只需要1个字节存储编码,最多只能编码128个字符。 显然,对于中文,ASCII码肯定是不行的,因为我们汉字的个数肯定是远远超过128个的。

为了解决中文的编码问题,先后出现了不同的中文编码标准,如ANSI、GB2312、GBK、GB18030、Big5。

上文提到了ANSI编码, 其实,它并不是一种真正的字符编码标准。在Windows操作系统上,统一使用ANSI来处理本地化字符编码问题;不同语言环境的Windows,ANSI代表不同的本地字符编码,在Windows内部通过代码页来确定使用什么字符编码标准(GBK的代码页是936,而GB18030的代码页为54936)。例如,在中文Windows操作系统中,简体中文实际使用GBK编码,而繁体中文则使用Big5进行编码;在英文Windows操作系统下,ANSI对应的就是ASCII编码了。

对于简体中文汉字编码,GB2312和GBK都是采用双字节编码的,同时兼容ASCII码,只是编码的汉字个数是不一样的;而最新的GB18030编码则能够容纳更多的汉字,同时编码格式也改为变长。 这三种中文汉字编码标准是向下兼容的,但和世界其他国家的文字编码标准不是通用的,为了解决世界不同国家字符集的统一编码问题,就需要使用Unicode编码。

这里要注意一下字符集编码标准和编码格式的概念区分,特别是对于UNICODE而言,经常容易将Unicode编码标准与UTF-8、UTF-16、UFT-32等编码格式混淆。 其实,Unicode编码标准只是定义了字符和其索引(Unicode码)的映射关系,是一张很大的Map表,包含了世界上不同文字编码的字符集,不同的文字被分配到表中不同的区域。 而UTF-8、UTF-16、UFT-32则是计算机中具体的字符编码和存储的方案。对于同一个Unicode的编码字符,不同的编码格式会得到不同的二进制字节流。

UTF-8是变长编码,对于一个中文汉字,一般需要使用3个字节进行编码,和ASCII兼容,一个英文字符只需要1个字节编码;而UTF-16编码一个中文汉字或一个英文字符都至少需要2个字节(对于超出2个字节表示范围的汉字,需要使用4个字节来编码,具体怎么编码和解码请另查相关资料),这对于英文字符来说是有点浪费的,不兼容ASCII码;UFT-32属于定长编码,编码一个中文汉字或一个英文字符始终都需要4个字节,这种编码虽然能够完整编码所有中文汉字,但却非常浪费存储空间,特别是对于英文字符而言的空间浪费更加明显,实际比较少会去使用。

现在我们可以看到,中文字符的编码是需要多个字节来表示的,而这就存在一个多字节的字节顺序问题,如果字符编码和解码以不同的字节顺序处理,那么肯定是会出现乱码的。 这也是为什么我们在文本编辑器中会经常看到UTF-8和UTF-8 with BOM的原因,其中BOM就是用来表示字节顺序的。 UTF-16LE和UTF-16BE也是类似的原因,Windows中默认的Unicode编码实际是UTF-16LE。

不像Windows那么复杂,在Linux系统上,一般统一使用UTF-8编码方式。可见,操作系统本身有自己默认的字符集编码选择的。

不同的编译器,对源代码中字符串的内部处理也是可能存在差别的。例如,GCC编译器一般不会对源文件里的字符串做转码,而MSVC编译器会根据不同的源文件编码格式进行转码,文件中 char * 字符串一律转成本地化的 ANSI 多字节编码, wchar_t * 字符串一律转成 Unicode(UTF-16LE)。

在 C++11 中,支持在字符串前加前缀的方式,指定字符串使用什么编码方式:

  • char * 对应 UTF-8 编码字符串(代码中字符串前加u8修饰,如u8"字符串"),封装类为 std::string
  • 新增 char16_t * 对应 UTF-16 编码字符串(代码中字符串前加u修饰,如 u"字符串"),封装类为 std::u16string
  • 新增 char32_t * 对应 UTF-32 编码字符串(代码中字符串前加U修饰,如 U"字符串"),封装类为 std::u32string

而QT的QChar 和 QString的内部统一使用UTF-16编码存储字符和字符串。

另外一个要考虑的是,我们在保存源代码文件时,源代码编辑器会选择什么样的字符编码格式进行保存文件内容呢?

其实,不同的IDE或文本编辑器,都有自己默认的字符编码格式,且不同的编辑器之间也没有保持一致。例如,我们使用VS保存的含中文的源代码文件,通过QT Creator打开时,会存在乱码的问题。这其实就是因为VS和QT Creator的代码编辑器使用不同的字符编码标准保存和读取源代码导致的。默认情况下,VS一般使用ANSI对应的本地字符编码(GBK)来保存和读取源代码,而QT Creator则默认的是UTF-8。字符的编解码格式不匹配,当然无法显示出正确的字符。这种情况下,可以在QT Creator上根据提示选择GB2312来正确显示出中文内容。

在QT Creator中,源代码中的“苏州”这个字符串实际会采用什么字符编码标准呢? 如果在源代码文件中,加入 #pragma execution_character_set("utf-8") ,这条语句是告诉编译器以UTF-8方式编码字符串内容,而不加这条语句时,默认就会GBK编码。

在QT Creator中,通过下面的这段代码,可以感受到字符串的真正编码方式:

#pragma execution_character_set("utf-8")
QByteArray encodedString = "苏州";  
QTextCodec *codec = QTextCodec::codecForName("utf-8"); //这里设置"GBK" 或 "GB18030"都会导致输出乱码
QString string = codec->toUnicode(encodedString);
qDebug() << string;
QString string2 = "苏州"; //中文字符存储到QString时,自动会转换为Unicode(UTF-16)编码
//QTextCodec *codec2 = QTextCodec::codecForName("GB18030"); 
//QTextCodec *codec2 = QTextCodec::codecForName("GBK");
QTextCodec *codec2 = QTextCodec::codecForName("utf-8");
QByteArray encodedString2 = codec2->fromUnicode(string2); //encodedString2的内容为UTF-8编码
qDebug() << encodedString2;
qDebug() << string2;

在编程时,如果需要从网络收文本数据或者读取文本文件(如配置文件,JSON等),也需要知道网络数据和文件内容使用了什么字符编码方式,否则也很容易出现乱码。

如果UI程序需要支持多国语言,在UI界面上显示的文字内容一般需要使用tr()函数来处理,配合QT的自动翻译机制来完成。

其大致流程是:首先,扫描源代码中的tr函数生成 ts 文件;然后,由 Qt Linguist(Qt语言家)处理生成 qm 翻译文件;最后,源代码里加载这个 qm 翻译文件。

三. 基于QWidget开发应用程序的一些经验总结

目前,使用QT开发带UI的应用程序,主要有两个方向:一个是传统的基于QWidget的开发模式,另一个是QT5开始引进的QML开发模式。

QML引入了类似Web前端的技术,主要用于开发UI界面部分,在样式效果控制上应该要强于QSS。 但QML技术毕竟是一个新的技术,对硬件的要求还是有点高的,且整体成熟度应该是没有QWidget好的,另外,对C++开发人员来说,要求去熟悉Web前端的技术,也带来了一定的学习成本。

本文主要是总结一些QWidget开发相关的经验,主要有如下几个方面:

  • 窗口属性及其控制
  • QSS样式
  • 窗口布局
  • 视频渲染窗口
  • 异步的UI操作和更新
  • 日志及其重定向
  • 常用的QT类
  • QT开发环境的一些小技巧

1. 窗口属性及其控制

在UI开发过程中,我们经常要考虑如下一些问题:

  • 程序主窗口大小和位置是否可以被用户改变
  • 程序主窗口是否需要进行屏幕分辨率的自适应
  • 程序主窗口大小改变时,内部控件如何跟随改变
  • 一个窗口是否需要设置父窗口
  • 一个窗口内的控件布局方式如何选择
  • 每一个窗口、窗口内的每一个控件需要做哪些样式控制,以达到产品设计的效果
  • 基于UI Designer拖拉式创建窗口控件还是完全自己在代码中控制
  • 窗口与窗口叠加时,如何保证窗口层级关系,实现背景透明效果
  • 需不需使用事件过滤机制实现自定义功能
  • 窗口上有没有耗时的操作,如何避免程序假死现象,提高用户操作体验
  • 窗口需要响应什么事件
  • 窗口内控件的焦点如何控制

上述很多问题,和QT的窗口属性有关系,我们只有了解QT的常用窗口属性及其作用,才能达到自己的目的。

QT内部提供了常见的窗口样式,如果这些默认的窗口样式不能满足我们的产品设计要求时,我们可能就要使用自定义Widget来实现了。

自定义Widget,我们可以将之看作是一块画布,我们通过合适的窗口属性和QSS样式可以完全自己控制Widget自身窗口及其内部控件的每一个细节。自定义的Widget一般会继承于QWidget,也可以选择QFrame,它们相当于是其内部控件的容器,也就是窗口和其控件一般是父子窗口关系。

要了解的是,QWidget是所有窗口和控件的基类,提供和窗口相关的能力;其自身继承于QObject,也具备了QT的信号槽和事件响应及过滤等能力。

如果我们的程序需求是不让用户改变窗口大小和移动位置,我们可以将窗口属性设置为无边框的,一般工控软件或嵌入式软件有这方面的需求。

窗口属性可以使用setWindowFlags函数来设置。 我们常用的几个重要窗口属性有:

  • Qt::FramelessWindowHint: 隐藏标题栏,并且去掉窗口的边框,窗口不能移动和缩放
  • Qt::CustomizeWindowHint:隐藏标题栏,不会去掉窗口的边框,窗口不能移动,但可以缩放
  • Qt::Tool:工具窗口,如果有父窗口,会始终显示在该父窗口上;如果该窗口没有父窗口,就会始终显示在其他窗口之上(即为顶层窗口,相当于使用了Qt::WindowStaysOnTopHint),该窗口在Windows任务栏上没有图标(没有焦点)
  • Qt::Window: 表明它是一个普通窗口,不管它是否有父窗口,都具有窗口的边框、标题栏,如果有父窗口,会始终显示在该父窗口上
  • Qt::Dialog: 表明它是一个对话框窗口,有边框和标题栏,但标题栏上没有最小化、 最大化按钮,如果有父窗口,会始终显示在该父窗口上
  • Qt::WindowStaysOnTopHint:使窗口始终处于最顶部(全局置顶),这个属性要谨慎使用,避免出现在切换应用窗口时,仍停留在其他应用窗口上面,影响用户体验

一个QWidget是窗口还是窗口上的控件,和是否有父窗口无关,只与是否有窗口属性标记类型(Qt::Window或Qt::Dialog)有关。只有QWidget对象设置了Qt::Window或Qt::Dialog窗口属性,那么它就是一个窗口,否则就属于控件了。

没有父窗口的窗口都属于顶层窗口,而这样的窗口会默认被设置为Qt::Window,在Windows任务栏上会有对应的图标出现。 通常一个应用程序只会让程序的主窗口成为顶层窗口,而其他的窗口都要设置父窗口,成为子窗口或控件。

另外,做过MFC开发的都知道,对话框有模态和非模态之分。 在QT中,所有窗口都可以设置模态和非模态属性(Qt::WA_ShowModal),要注意的是:在窗口显示前需要先设置这个属性,否则不会生效。

用户对UI操作的两个重要输入设备是鼠标和键盘,我们在QT开发过程中,无法避免要去处理鼠标事件。

鼠标事件不同于键盘事件的是,鼠标事件是带有坐标的(可以获取绝对的屏幕坐标,也可以获取到相对接收这个事件窗口的相对坐标)。

在QT中,有两个坐标系,一个是屏幕坐标系,一个是窗口坐标系。坐标原点都在左上角,X轴向右增长,Y轴向下增长。

屏幕坐标系是绝对坐标系,顶层窗口的位置就是在屏幕坐标系中定位的;而窗口中的子窗口或控件窗口位置是相对于其父窗口的窗口坐标系来定位的,属于相对坐标。

QEvent::globalPos() 获取的是屏幕坐标点,相对于屏幕左上角的原点;QWidget::pos() 获取的是控件相对于其父窗口左上角的相对坐标。 相对坐标和绝对坐标也是可以相互转换的,也可以选择不同的窗口作为参考,进行相对坐标之间的转换。

知道了窗口的左上角坐标和窗口的宽高,我们就知道这个窗口的位置和占据的空间大小了。 在通过move函数移动窗口时,一般可以根据移动前后的两个绝对坐标点来算出要移动的距离,而移动窗口其实就是将窗口的左上角移动到指定的坐标点上。

其实,在QT程序开发过程中,设置窗口的位置和大小,加上后面讲到QSS样式控制(如添加背景图片、控制字体、控制窗口边框等),我们就可以实现出比较美观的UI界面。

除了鼠标,键盘也是用户操作UI的主要方式,而只有获得焦点的窗口才可以接收键盘的输入。

我们可以通过鼠标点击、键盘Tab键的方式来让某个窗口或控件来获得焦点,而QT也可以设置不支持焦点的窗口/控件,通过设置 setFocusPolicy(Qt::NoFocus) 即可。

在一个窗口中,通常会存在多个控件,而同一时刻,只能用户操作的那个控件是处于焦点状态的,而其他的控件都没有获取到焦点。

我们可以多次使用 setTabOrder() 人为设置一个窗口内通过Tab键在各个控件间获得焦点的顺序,加上在窗口初始时,通过 setFocus() 指定让某个控件获得焦点,就可以比较方便实现对窗口内的控件焦点进行控制。

在一个UI界面窗口上,我们通常会存在多个子窗口/控件,而这些子窗口/控件之间其实是存在一个上下层级关系的。

在QT中,每个窗口内部都有一个子Widget栈,按照创建子窗口/控件的顺序压入栈中,而子窗口/控件的显示顺序则是遵循栈顶的子窗口/控件显示在最上层,而栈底的则显示在最下层。

如果子窗口/控件之间存在位置重叠,那么栈底层的子窗口/控件会被上层的遮住。当然,如果要将底层的子窗口/控件显示到最顶层,可以使用QT提供的raise()来实现。

QT程序开发过程中,窗口的事件处理是比较重要的。 常用的窗口事件有:

  • 鼠标事件:mousePressEvent、mouseReleaseEvent、mouseMoveEvent、wheelEvent、enterEvent、leaveEvent等
  • 键盘事件:keyPressEvent、keyReleaseEvent
  • 焦点事件:focusInEvent、focusOutEvent
  • 绘图事件:paintEvent
  • 窗口事件:moveEvent、resizeEvent、closeEvent、showEvent、hideEvent等

关于窗口绘图要说明的是:

除了窗口自身变化(如:窗口从隐藏到显示、尺寸改变、内容变化等)会自动触发窗口重绘外,我们自己在代码中也可以手动触发窗口重绘。

QT提供了update()和repaint()两个方法让我们触发绘制事件paintEvent,但这两者的运行过程是存在较大差别的。

repaint()是强制重绘命令,被调用时,窗口需要立刻执行重绘,而如果在重绘事件的处理函数中继续调用repaint(),则会引起死循环;

而update()被调用后,窗口并不会立刻重绘,而是投递了一个paintEvent事件到UI线程的事件队列中,等到事件被分发时才能执行窗口的重绘,而且如果短时间内多次调用update(),QT内部会自动合并重绘请求,只产生一个paintEvent事件,而不是很多个paintEvent事件,避免窗口绘制消耗大量的CPU资源。

通常,建议优先使用update()来重绘窗口。

如果窗口需要自定义重绘,则重写窗口类的paintEvent(QPaintEvent *event)来实现。

在窗口上绘制内容,可以想象成画家在窗口上画画。这窗口就是一张画布,当然,还需要画家用画笔或画刷来进行绘画。

QT中,画家用QPainter来表示,而画笔用QPen来表示,画刷用QBrush来表示。 在画布和画刷上,我们可以设置不同的属性,实现不同绘制效果; 而画家具有不同的绘画能力,可以画直线、矩形、圆等各种几何形状,也会可以绘制图片等,绘制的内容都会呈现在窗口上。

2. QSS样式

开发一款UI美观的QT应用,离不开产品的精心设计和美工的切图(切图的图片一般选择png格式,可以保留背景透明信息)。

一般借助PhotoShop的切片工具可以很方便完成切图工作;而我们需要的窗口会控件位置信息可以让美工在PSD格式图上标注出来,也可以借助第三方的工具(如蓝湖)来自己测量。

有了切图图片、控件的位置和窗口大小信息后,我们就可以借助QSS来美化我们的窗口和控件外观了。

QSS的语法类似于Web前端开发中的CSS,可以比较方便地实现对窗口和控件的各种样式控制,例如:设置按钮背景图片、Label上文字的字体/大小/颜色/是否加粗、边框线条的粗细/颜色/是否倒圆角等。

在QT中,我们主要通过两种方式去设置窗口或控件的样式。

一种是静态样式,将确定的,基本不变的样式,可以独立到.qss样式文件中,在窗口初始化时,加载并统一设置相关控件的样式。

另一种是动态样式,就是控件的样式不是一成不变的,会根据用户操作产生某种动态变化的效果时,这就需要我们在代码中根据逻辑动态调用setStyleSheet函数设置相应控件的样式了。

当然,有些样式在静态的样式文件中也可以实现,如鼠标移到某个控件上时,控件自动改变背景颜色等,这其实是利用了类似CSS中的伪状态特性。

一条QSS的样式由两部分组成:一是选择器,指定了哪些控件会受到影响,另一部分是指定了样式的属性和值,表示这些控件的哪些样式属性会受到影响。

如:QPushButton { color: red } QPushButton表示选择器,指定了所有的QPushButton或者是QPushButton的子类的color属性会受到影响。

QSS支持的选择器,主要有如下几种:

  • 通配选择器: * ; 匹配所有的控件
  • 类型选择器: QPushButton ; 匹配所有QPushButton和其子类的实例
  • 属性选择器: QPushButton[flat="false"] ; 匹配所有flat属性是false的QPushButton实例(属性可以是自定义的)
  • 类选择器: .QPushButton ; 匹配所有QPushButton的实例,但并不匹配其子类
  • ID选择器: #myButton ; 匹配所有id为myButton的控件实例,这里的id实际上就是objectName指定的值,比较常用
  • 后代选择器: QDialog QPushButton ; 所有QDialog中包含的QPushButton,不管QDialog是QPushButton的直接父窗口还是间接父窗口
  • 子选择器: QDialog > QPushButton ; 所有QDialog中包含的QPushButton,其中要求QPushButton的直接父窗口是QDialog
  • 子控件选择器:QComboBox::drop-down ; 子控件选择器主要应用在复合控件(外观由几个部分组成)上,如QComboBox等
  • 伪状态选择器:QComboBox:hove ; 与CSS中的类似,是以冒号开头的一个选择表达式,这里:hover表示当鼠标经过时候的状态

如果多个控件使用相同的样式时,可以在一条样式语句中一起设置,以","分割。

例如: this->setStyleSheet("#lineEdit1, #lineEdit2 { background-color: green; }"); ,表示将id分别为lineEdit1和lineEdit1的两个输入框的背景颜色都设置为绿色。

子控件选择器可以与上面其他的选择器一起联合使用,实现对复合控件的部分外观进行控制。

例如: QComboBox#myQComboBox::drop-down { image: url(dropdown.png) } ,这个样式控制的是:为id等于myQComboBox的QComboBox的下拉箭头指定图片,而不是为QComboBox本身指定图片。

子控件选择器还可以和伪状态一起使用,描述所选择的复合控件中的子控件的状态。

例如: QComboBox::drop-down:hover { background-color:red; } ,表示当鼠标经过QComboBox的下拉箭头的时候,该下拉箭头的背景颜色变成红色。

伪状态可以用一个感叹号表示否,例如,:hover表示鼠标经过,而:!hover表示鼠标没有经过的状态。

几个伪状态可以同时一起使用,例如: QCheckBox:hover:checked { color: white } ,表示当鼠标经过一个选中的QCheckBox的时候,设置其文字的前景颜色为白色。

QSS常用的样式属性主要有:

  • 背景颜色
  • 背景图片
  • 文字的字体、大小、颜色、粗体
  • 边框线条的粗细、颜色、线条样式(实线、虚线)、倒圆角
  • 边框的四边间隙(参考CSS的盒子模型)
  • ...

我们在应用QSS样式时,需要了解一些样式如何生效的规则,要特别注意的是父窗口的样式设置可能会对子窗口/控件的样式产生影响。 主要是理解QSS样式的冲突和级联规则。

QSS的冲突主要是解决如果同一个控件被多个QSS语句控制时(控制相同的样式属性,但值不一样),选择使用哪个样式语句的问题。选择的基本原则也很简单,就是看哪个选择器能够更精确选择到某个具体的控件对象,就选择应用哪个样式语句,其他的忽然;如果选择器是相同的,那么后面的样式语句会覆盖前面的样式语句,即最后一条样式语句生效。

例如:

QPushButton#okButton { color: gray } ;这条语句会生效
QPushButton { color: red }
QPushButton:hover { color: white } ;这条语句会生效
QPushButton { color: red }
QPushButton:hover { color: white }  
QPushButton:enabled { color: red } ;这条语句会生效

而QSS的级联就是定义父窗口的样式如何对子窗口/控件的样式产生影响。基本的规则是一个控件最终的样式效果是它直接父窗口和所有间接父窗口上设置的所有样式综合叠加的结果;如果级联过程中父窗口和控件自身的样式产生冲突,那么优选应用控件自己的样式。

例如:父窗口指定了所有QPushButton的背景颜色,而某个QPushButton对象自己又设置了:hover伪状态下的背景颜色,其实际效果就是在鼠标没有移动到这个按钮上时,其背景颜色是父窗口指定的颜色,而当鼠标在按钮上时,背景颜色会变为这个QPushButton自己指定的颜色。

为了避免父窗口设置背景图片影响其内部的控件,我们可以通过画刷来绘制窗口的背景图片,这样不会有QSS的级联问题。

setAutoFillBackground(true); //设置窗体自动填充背景,不加此设置,背景图片可能显示不出来
QPixmap pixmap(":/images/background.png"); // 图片已经加入了资源文件qrc中
QPalette palette;
palette.setBrush(backgroundRole(), QBrush(pixmap));
setPalette(palette);

3. 窗口布局

在UI程序的开发过程中,如何管理和控制窗口中各个控件的位置和大小是非常重要的话题。 在程序架构设计之初,我们就应该要考虑是选择手动布局还是利用QT的自动布局器实现控件布局。这当然要结合产品的实际需求去综合考虑。

前面我们提到在嵌入式这种专用设备上,一般都是会提前确认好应用主窗口大小的,不会让用户改变窗口大小和移动窗口位置。 这种场景下,最简单的、易理解的方式就是使用手动布局,在代码中直接使用setGeometry函数设置窗口的位置和大小。

当然,为了避免在代码中到处写"魔术数字",我们可以将窗口中所有控件的位置和大小存放到map表中,然后在窗口初始化时,通过循环遍历map表统一设置皆可。

如果要适配几种特定的显示分辨率,那么只要针对每种分辨率定义一张这种表,在代码中检测系统分辨率自动选择哪张表进行初始化即可。

这种方式,可以和产品UI设计保持一致(图片不会拉伸),美工针对不同的分辨率要多切几份图,也增加尺寸标注工作。

当然,上述这种方式的缺陷也是很明显的,那就是不能自适应任意的屏幕分辨率,不能让用户随意改变窗口的大小。 如果要做到后面这种效果,就必须借助QT提供的布局器来完成了,当然这就需要去学习QT布局器的使用了,特别是要理解QT的各自窗口尺寸变化策略。

QT的布局器主要有:

  • 水平布局器:QHBoxLayout
  • 垂直布局器:QVBoxLayout
  • 网格布局器:QGridLayout
  • 表单布局器:QFormLayout

在布局器中,除了放置控件,空白位置可以加弹簧空白条QSpacerItem来辅助布局。

弹簧空白条也有水平和垂直两种。 当然,布局器内可以嵌套其他的布局器,通常一个窗口只有一个主布局器(主布局器初始化时需要传入所在窗口的QWidget指针),而一个主布局器内可以有多个子布局器(子布局器初始化时无需传指针)。

使用布局器可以直接在UI Designer中拖拽完成(相关代码由UIC自动生成),也可以在代码中自己编程实现(要注意对象的声明顺序)。

相对而言,使用QT的布局器比起直观的手动布局,对UI开发人员的要求会高一些。

在拿到产品设计的高保真图时,你需要在自己的脑海里构思出哪些控件放到什么样的布局器中,不同布局器之间的嵌套关系如何,如何应对后续UI设计稿的变更,现有的布局器组合是否继续适用,然后窗口改变时,窗口内控件的整体布局会有什么样的变化等等。

将布局器用好的提前是,能够理解布局器的基本工作原理,重点是理清几个控件尺寸的关系,明白伸展因子和尺寸策略的内在含义。

  • 每个控件有3个尺寸:最小尺寸、最大尺寸、建议尺寸(QWidget::sizeHint());最小尺寸和最大尺寸限制了控制随窗口大小改变时的拉伸范围,而建议尺寸是QT内部根据需要显示的内容自动计算的,无需程序员控制
  • 每个控件可以设置自己在窗口大小变化时,以什么样的策略应对(QWidget::sizePolicy()),是保持不变(QSizePolicy::Fixed),是尽量拉伸(QSizePolicy::Expanding),还是被动拉伸(QSizePolicy::Preferred)等
  • 每个控件或布局器可以设置自己在窗口大小变化时占据的伸缩比例(先根据一排或一列上各个控件的水平或垂直伸展因子的总和,然后用各个控件自己的伸展因子除以各控件的伸展因子总和,得出占据控件的比例);伸展因子设置为0,就会先以建议尺寸给控件或布局器分配空间,不优先参与拉伸(除非最后还有剩余的空间,才会去尝试拉伸);如果一排或一列控件的伸展因子都为0,最后效果就是所有控件平均分配空间;控件默认的伸展因子为 0
  • 在窗口初始化时,布局器根据窗口内每个控件自己的尺寸策略QWidget::sizePolicy() 和 建议尺寸QWidget::sizeHint(),给各个控件分配相应的窗口空间
  • 在窗口大小改变时,布局器会按照各自控件的伸展因子计算出比例,然后结合各控件的尺寸策略,重新分配新的窗口空间给各个控件

在实际使用时,建议伸展因子优先设置给子布局器,尺寸策略优先设置给控件,更有利于安排空间布局。

另外,QT还提供一个窗口分裂器QSplitter,它的作用是将一个窗口分裂为几个窗口,然后让用户可以通过鼠标拖拉的方式手动改变各部分窗口的大小。

例如,在QT Creator中,你可以调整代码编辑器区域和侧边栏及控制台输出区域的大小。

4. 视频渲染窗口

在音视频相关的应用开发中,在UI界面上显示视频的需求是不可避免的,而且在视频上通常也会有叠加文字和图标按钮等需求。

在实际开发过程中发现,你可能会发现按照常规的方法将QLabel、QPushButton直接放在视频窗口内,很多效果就无法实现,甚至控件自身也无法正常显示。

试错后得出的解决方案是:

利用分层的思想,将视频内容和视频上的文字、按钮等分为2层,即在视频窗口上面叠加一个背景透明的窗口,在这个背景透明的窗口上放置文字和按钮等控件(注意,控件自身不透明,但可以给控件设置背景透明的图片来美化控件外观),另外,要让这个两个窗口连成一体,无论窗口移动还是大小改变都一起变化的话,需要设置视频窗口为背景透明窗口的父窗口,并设置背景透明窗口的属性为Qt::Window或Qt::Dialog,使用Qt::Tool也可以,但无法获取键盘焦点。

需要特别注意的是:

  • 视频无法渲染在背景透明的窗口上(同时设置了 Qt::WA_TranslucentBackground和Qt::FramelessWindowHint,视频可以渲染在设置了Qt::FramelessWindowHint的窗口上,只设置Qt::WA_TranslucentBackground,不设置Qt::FramelessWindowHint,窗口背景透明不会生效),但在视频窗口上可以叠加背景透明的窗口(该窗口上的控件是不透明的);
  • 视频窗口和视频窗口是可以重叠的(因为这是两个普通的窗口),但这两个视频窗口之间不能有背景透明的窗口(也就是,视频窗口本身不能是一个背景透明窗口内的控件,因为视频不能渲染在背景透明的窗口之上)。

另外,在x86 PC平台上,通常需要获取视频窗口的句柄(winId()),然后将窗口句柄设置给音视频引擎的渲染器进行视频渲染(内部一般都是GPU渲染),无需上层应用使用贴图方式绘制(效率低,耗CPU)。

在海思嵌入式平台上,视频渲染需要使用了海思的HiFB相关功能,其本质也是利用分层思想,将视频和QT UI界面分别绘制在不同的FrameBuffer上。

这种方式无法按窗口句柄的方式去显示视频,视频的位置和UI窗口的对应需要手动控制(上层将窗口位置通知给底层的音视频引擎),同时在需要显示视频的位置将控件背景颜色设置为特定颜色(ColorKey),才能让UI窗口表现出类似背景透明窗口的效果,让视频正常显示出来。

5. 异步的UI操作和更新

试想一下,在UI界面上点击了一个按钮,在这个按钮的槽函数中,需要执行一个非常耗时的操作(例如,加载一个2G的视频文件,且需要解析出所有I帧在文件中的位置和对应的时间点信息),那UI的用户体验会怎么样?

如果我们真在槽函数里执行这个非常耗时的操作,那么整个UI程序就可能会表现出假死现象,在这期间,用户无法进行其他操作了,这样的用户体验一般是无法被用户接受的。

但需求在那里,你无法绕过。这时,上述的同步操作肯定是不行的,那就只能采用异步调用的方案去实现。

基本的思路就是启动一个后台工作线程,将耗时的操作放在这个后台工作线程中进行。

这里,工作线程可以使用QThread,也可以使用C++的std::thread,甚至是操作系统的线程API。

为了不节省自己发明轮子的时间,可以优先考虑使用QThread,这样我们就可以利用QT的信号槽机制,实现UI操作与后台线程的无缝协作。

当然,前提是正确利用好QThread,推荐使用moveToThread方式而不是继承方式,因为前者可以开启线程的事件循环,保证槽函数能够在这个线程上执行,具体可以参考上文提及的信号槽机制和QObject对象的线程亲和性等内容。

如果异步操作比较多时,可以考虑通过线程池并发执行。当然,这些并发的操作最好是状态或数据无关的,如果又存在先后依赖关系,必须进行状态的同步。

6. 日志及其重定向

在软件开发过程中,少不了需要调试程序的行为是否符合预期。 除了开发过程中,使用IDE的调试器进行断点单步调试外,最常用的估计是输出程序的执行日志,来分析程序的行为是否符合预期的。 这在软件通过测试后发布到生产环境下也是适用的。

C++有很多开源的日志库,很多C++程序员也完全可以自己写,同样,QT作为一个大而全的类库,肯定也少不了日志模块功能。

在QT中,输出日志主要是使用qDebug,当然还有其他几个如qInfo、qWarning、qCritical、qFatal。

注意:qFatal打印完日志,程序会自动结束。

默认情况下,qDebug的日志是输出到控制台的,这对于非控制台的UI程序来说,脱离了QT Creator单独运行时,就无法查看日志内容了。

要做到这一点,需要使用 qInstallMessageHandler() 来注册写日志的函数指针,这样使用qDebug输出日志时就会进入到这个回调函数来处理日志字符串。我们可以在这个函数里将日志内容写入到日志文件中 ,也可以将之通过网络发送出去,当然也可以同步再输出到控制台里(注意,需要保存qInstallMessageHandler()返回的默认日志处理函数指针,在QT中,这个默认的日志处理函数就是向控制台输出日志)。

知道了上述内容,我们自己封装一个日志管理类。

另外,如果我们想让控制台输出彩色的日志,可以在输出日志内容前,指定颜色指令。

qDebug() << "\033[32m" <<"Hello!"; 会输出绿色的日志内容,这个颜色指令对后续的日志输出都生效;如果要取消这个指令回到默认,可以执行 qDebug() << "\033[0m";

在QT的.pro工程文件中,加入QT_NO_DEBUG_OUTPUT禁用qDebug的输出,但不影响qInfo、qWarning、qCritical、qFatal的输出。

7. 常用的QT类

QT除了是一个跨平台的UI开发框架,也是一个比较健全的C++类库。

在QT程序开发中,使用一些高频的QT类可以提高我们的开发效率,毕竟QT帮我们封装了很多常用功能的库,不需要开发人员自己去封装发明轮子,特别是对于跨平台的开发尤为明显。

这里,主要介绍一下开发过程中经常会用到的一些QT类:

  • QString: 处理字符串,内部使用UTF-16来编码存储字符串内容,可以和C++的std::string相互转换,内部提供了很多字符串操作函数,支持通过arg格式化生成字符串,使用频率比较高
  • QThread: 封装的线程类,优先采用moveToThread方式使用线程,这种方式下,线程会自动开启事件循环,方便QT信号槽的使用
  • QEvenLoop: 事件循环,可以通过QEventLoop模拟同步调用
  • QByteArray:字节数组,存储二进制字节流,可以用于存储接收到的网络数据或读取的文件内容等,可以配合QString使用
  • QVector:数组,类似C++的std::vector - QList:列表/链表,类似C++的std::list
  • QStringList:字符串列表
  • QMap:类似C++的std::map,基于红黑树实现(元素内部有排序)
  • QHash:类似C++的std::unordered_map,基于散列实现(元素内部无排序)
  • QVariant: 类似C++17中的std::variant,可以存放其他各种各样的数据类型,自定义的类型需要先通过Q_DECLARE_METATYPE()宏注册到QT元对象系统中
  • QTimer: 定时器,支持单次触发功能(QTimer::singleShot),也可以使用QObject自带的定时器功能(调用 startTimer() 函数就启动了,而每一次的超时都会发出 QTimerEvent 事件,这样你可以重写timerEvent() 函数来处理定时事件,停止定时器需调用killTimer()),需要注意一下定时器的时间精度
  • QDateTime:日期相关,可以字符串和日期时间相互转换,也可以毫秒数和日期时间相互转换,还可以1970经过的秒数和日期时间相互转换等
  • QTime: 当前时间
  • QFile:文件读写
  • QDir:目录操作
  • QTextCodec: 文本编码格式转换相关
  • QTextStream:文本流处理,可以对IO设备(stdin、stdout),QString,QByteArray等类进行方便的读写操作
  • QDataStream: 搭配QByteArray,可以实现网络传输数据的序列化和反序列化
  • QSettings:ini配置文件读写
  • QWebSocket:WebSocket客户端
  • QTcpSocket:TCP Socket封装类
  • QUdpSocket:UDP Socket封装类
  • QJsonObject、QJsonDocument、QJsonValue、QJsonParseError:JSON读写相关类
  • QNetworkAccessManager、QNetworkRequest、QNetworkReply:HTTP请求相关类
  • QNetworkInterface:网卡硬件
  • QSqlDatabase、QSqlQuery:SQL相关类
  • QRegExp、QRegExpValidator:正则表达式相关
  • QWebEngineView: 浏览器内核封装,注意发布时要附带QT比较多的文件(实际是chrome内核依赖的),文件也比较大
  • QMovie:显示gif动画,发布时要带上 :exe文件同目录下建立文件夹 imageformats,把qgif.dll放进去
  • ...

当然,QT还有很多的类库,上述这些只是平时QT程序开发中出现频率相对较高的基础工具类,而要用好这些类还是要花点时间去熟悉的,这方面的资料应该是不欠缺的。

8. QT开发环境的一些小技巧

QT Creator是QT自带的IDE,可以满足我们日常的QT开发需求。 日常开发中,熟记一些常用的快捷键对于提高开发效率是非常有帮助的。

在VS中安装QT提供的插件,是可以在VS中进行QT程序开发的,但在不建议同一个项目在VS和QT Creator中混合使用,特别是在代码中有中文时,因为这两个IDE的代码编辑器使用了不同的文件编码格式,容易出现乱码问题(需要手动选择正确的字符编码才能正确显示出中文)。

另外,在QT程序中,有可能需要使用到VS编译器生成的库时,需要注意选择使用有对应VS版本的QT安装包,否则可能会出现二进制兼容性问题,导致编译出错或程序运行崩溃等。

QT和VS编译器常见组合是:Qt5.7+VS2013、Qt5.9+VS2015、Qt5.12+VS2017,如果没有我们需要的VS版本,可能需要自己来编译QT了。

QT Creator的使用小技巧:

  • F2: 声明/定义切换
  • F4: 头文件/源文件切换
  • Ctrl + B:编译
  • Ctrl + R:运行
  • Ctrl + F:搜索(当前文件内搜索)
  • Ctrl + Shift + F:高级搜索(全工程内搜索)
  • F1: 查询QT文档
  • Ctrl + /:注释/取消注释
  • Ctrl + i:代码自动对齐
  • F5: 调试
  • F9: 设置或取消断点
  • 在头文件的函数声明上右键菜单中选择Refactor,可以在源文件只快速生成对应的函数实现,也可以快速重命名函数
  • 在QT的类名上右键的Refactor中可以方便选择并插入需要重载的基类虚函数
  • 设置文本编辑器的文件编码为UTF-8,并启用UTF-8 with BOM(选择:如果编码是UTF-8则添加),源文件上加上 #pragma execution_character_set("utf-8") ,可以避免很多中文乱码问题
  • 在选项的文本编辑器上可以启用显示文件字符编码,也可以在文件编辑器中选择特定的字符编码重新保存文件,避免每次要选择到正确的字符编码时代码才能正确显示中文的问题
  • QT Creator工程编译不支持中文路径

关于QT的.pro工程文件,在其中我们可以设置一些参数,常用的有:

  • 在QT += 中启用QT内部的模块,多个模块间用空格分开,例如:QT += core gui network webenginewidgets
  • TARGET可以指定要生成的二进制文件名称
  • TEMPLATE控制QT程序是什么类型的,是可执行的app、动态库还是静态库
  • SOURCES += 中指定需要编译的源文件
  • HEADERS += 中指定需要编译的头文件
  • FORMS += 中指定需要编译的.ui文件
  • RESOURCES += 中指定需要编译的.qrc资源文件
  • DEFINES += QT_NO_DEBUG_OUTPUT #相当于define QT_NO_DEBUG_OUTPUT,在整个项目中生效,禁用qdebug打印输出
  • CONFIG += warn_off #关闭编译警告提示 眼不见为净
  • DESTDIR = bin #指定编译生成的可执行文件到bin目录
  • 在Qt5中,可以在pro文件中标记版本号和应用的ico图标:
VERSION = 2021.11.19
RC_ICONS = app.ico
  • 支持编译条件判断
#根据操作系统位数判断加载
win32 { 
    contains(DEFINES, WIN64) { 
        DESTDIR = $${PWD}/../../bin64 
    else { 
        DESTDIR = $${PWD}/../../bin32