使用 Qt 获取 UDP 数据并显示成图片(2)

本文首发于 BriFuture 的 个人博客

在我的前一篇文章 使用 Qt 获取 UDP 数据并显示成图片 中,我讲了如何用 Python 模拟发送数据,如何在 Qt 中高效的接收 UDP 数据包并将数据解析出来。然而此前的文章在分别显示 RGB 通道、R 通道、G 通道、B 通道这四组通道的图片时仍然会出现处理速度过慢的问题。

前面说过编写的程序至少会用到 3 个线程来分别处理 UI、socket 数据、数据解析,因为不这样做没法在时限内处理完接收到的数据,写第一篇博客的时候,我以为是单纯的使用 new 在堆中分配内存导致程序运行效率低,后来确实通过预分配对象内存解决了部分问题,但是还有一些会影响程序运行速度的问题没有解决,也没有深究,今天重新编写代码的时候,为了分配数据到 4 幅图片上(分别是 RGB 通道、R 通道、G 通道、B 通道),发现运行速度还是不够,影响运行速度的原因有几个:

  1. 运行程序的模式(Debug 和 Release 两种模式)
  2. Qt 的事件循环机制(之前反复怀疑过,不过最后还是发现短时间内大量调用信号很容易导致处理速度过慢)
  3. 低效的内存复制操作(如 QByteArray 的 assign 赋值操作和过多、过于复杂的程序流程)

接下来看看这几个导致程序运行速度不够的原因:

1. Qt Debug 模式和 Release 模式的差异

在 QtCreator 中运行程序,如果是以 Debug 模式运行的话,速度是要比 Release 模式低一些的。以前编写 Qt 程序,数据量一般不大,对于性能都没有要求,即使程序代码不够优化,但在用户使用过程中一般不会感受到运行卡顿,所以一直都没发现 Debug 模式和 Release 模式的性能有差异。

不过其实也能猜到性能有差异的大概原因:Debug 模式下会在最终生成的代码里面插入很多额外的代码用于调试,但是 Release 生成的代码是不会插入这些调试用的代码的,最明显的差异就是 Debug 模式生成的可执行文件比 Release 模式生成的可执行文件要大得多。

Debug 模式下运行程序,实际 FPS 和期望的 FPS 有 6 帧的差距,差距产生的原因是处理速度不够,导致最终生成图片的速度慢了。

Release 模式下即使是原始数据包的期望 FPS 到了 77 帧,实际的 FPS 也可以达到 77 帧,也就是说在处理过程中没有出现处理速度跟不上接受数据的速度。

2. Qt 的事件循环机制

当我们使用 Qt 程序的时候,经常会在主函数 main 里写出类似下面这样的代码:

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
	MainWindow mw;
	mw.show();
	return a.exec();
}

这样我们的程序应用的生命周期就是 QApplication 所定义的,当我们使用 QObject::moveToThread 方法将某个 QObject (子类)对象移到其他的子线程中的时候,子线程也有独立于主线程的相应的事件派发机制。

QObject 的多线程使用方法很巧妙,利用信号&槽机制或者是 QMetaObject::invokeMethod 方法就可以让要执行的耗时函数在子线程中执行,但是如果是直接调用耗时函数,那么就会在当前的线程中执行耗时操作,导致线程阻塞。

在线程之间传递数据,如果是用信号&槽的机制,那么可能你都不需要考虑线程间的数据同步问题,但信号&槽机制是要依靠 Qt 的事件循环机制的,如果事件不能正常分发(dispatch),那么子线程中的槽函数就不会被调用。

关于 Qt 的事件循环机制和线程机制,推荐看一看官方 wiki,《线程、事件与QObject》 或者也有对应的英文原文 Threads Events QObjects

如果频繁的调用信号,在 Qt 的事件循环中,因为前一次耗时的任务没有完成,导致对应的槽函数无法执行,最终导致处理速度跟不上。

因此对于实时性要求高的程序,Qt 的事件循环机制可能不会是你的首选,你更有可能去做的是在 Qt 的一个子线程中运行循环代码,忽略掉该子线程中的事件循环以提高程序的性能。

3. 低效的内存复制操作

在接收到原始字节数据之后,最重要也是最麻烦的就是解析数据。包括识别自定义协议数据的头部信息,将数据包中的图像数据复制到缓冲区,并将缓冲区中的数据以图片的形式显示出来。接下来分享几个高效处理数据的几个小技巧:

  1. 使用 QByteArray 存储原始数据包时,先调用 resize 预分配内存,然后使用 memcpy 直接对内存数据进行操作,这样做效率是最高的,但它也是比较繁琐的。
QByteArray data;
data.resize(PacketSize); // PacketSize 是预先定义好的数据字节数
// 可以简单的认为 rawData 就是从 UDP 端口中接收到的数据,
// ValidDataSize 也是预先定义好的数据字段的长度
memcpy(rawData.data(), data.data(), ValidDataSize);

// 上面的代码要比直接使用赋值操作 = 高效
data = rawData;
  1. 如果接收到的数据可以明确是有序的,可以用数据分别表示相应的序号,再从数组中取数据,我最开始存储 LineDataObj (用于表示图片的一行数据)的时候,用 QMap 存储行号和指针,利用 QMap 的查找功能减少了查找或排序的时间,但是缺点是 QMap 会随着其内部的数据量增大变得缓慢,如果只需要缓存数据,建议直接使用数组存取,这样的运行效率最高。
// 在类中声明一个 map
QMap<int line, LineDataObj *> map;

// 在方法体中使用 map 查找是否有对应的行数据
if(map.contains(line)) {
	// 如果有对应的行数据对象,直接将数据写入到行对象数据上
	...
} else {
	// 如果没有,则插入一条记录
	map.insert(line, lineDataObj);
}

// 处理完一行数据后,可以将该行数据从 map 中移除掉
map.remove(line);

可以发现,map 就是用来判断是否有对应行数据对象,然后处理结束后移除保存的行号,这并没有达到缓存数据的目的,反而再插入和移除的过程中浪费了过多时间。但如果用一个数组当做缓存区就会快很多,因为我们减少了查找和移除记录的时间:

QVector<LineDataObj *> linePool;
// LinePoolSize 是预定义的池大小
linePool.reserve(LinePoolSize);
for(int i = 0; i < LinePoolSize; i++) {
	linePool.append(new LineDataObj());
}

// 数组大小是有限的,行号却是不断增加的,因此要设置一个起始行,保证在长时间执行程序后不会出现数组越界的问题
int diffLine = line - startLine;
// 进行处理
linePool[line].setData(...);
  1. 尽量保持清晰而且简单的结构。我之前写代码总想着考虑到所有情况,最终却总是没法尽善尽美只有根据情况放低预期,我觉得不必一开始就非要把代码的层次结构划分的特别详细,根据实际情况使用合理的程序结构(当然每个人可能有不同的看法,但少即是多的原则确实给了我很大的启发)。

我之前编写程序时,除了有一个 LineDataObj 用来表示行对象,还有一个 RawDataObj 表示原始的数据包对象。处理的流程多:1. 接受原始数据包 => 2. 将数据包填充到 RawDataObj 中并解析数据包的行号,RGB 类型 => 3. 根据 RawDataObj 的属性确定对应的 LineDataObj => 4. 当 LineDataObj 存储到一定数目时生成图像。

这个流程很直观也很容易想到,但是 RawDataObj 这个数据对象其实没必要使用,因为它增加了一次不必要的内存数据复制。这完全可以给 LineDataObj 类增加几个静态方法,判断出数据包的行号和 RGB 类型,然后将数据部分写入到 LineDataObj 的数据字段中。这样做不仅可以减少内存读写的次数,而且可以在一个对象中申请大段内存,保存整行的数据,最后写入到图片时,只用将这个区域赋值到图片中即可。

4. 高效地显示图片

最后分享一下如何在 Qt 中高效的显示图片。一般用 Qt 显示图片可以用 QLabel:

QLabel label;
QImage image;
// 执行一些读取图片的操作,再显示在 QLabel 上
label.setPixmap(QPixmap::fromImage(image));

但是用 QPixmap::fromImage 会从 image 的内存区域中复制一份数据到 Pixmap 中,这样的操作并不高效。我们可以使用 QImage::scanLine 方法获取它对应的内存区域,直接对内存进行操作,显示的时候不用 QPixmap::fromImage,我们要直接将内存中的修改显示到界面中,这样我们要定义一个类(不妨让它继承 QLabel),重写 paintEvent 方法:

void PictureImage::paintEvent(QPaintEvent *event)
{
    Q_UNUSED(event);
    if(m_index == uchar(-1)) {
        return;
    }
//    this->painter.drawImage(target, *m_image);
    QPainter p(this);
	// target 在构造函数中定义:
	// target = QRectF(0.0, 0.0, PictureImage::ImageWidth, PictureImage::ImageHeight);
    p.drawImage(target, *m_images[m_index]);
}

p.drawImage(target, image) 这样就可以将图片更新到界面中,并且它会被 QPixmap 的 fromImage 方法要高效。

用 Python 发送模拟数据遇到的问题

之前说过,模拟数据是用 Python 代码编写的,这个代码发送模拟数据的效率可以高达 100M/s,下面的截图是我在自己的笔记本(i5 8200U@1.8G)上运行的结果:

但是令我感到特别奇怪的是,有一段时间同样的代码在我的 amd ryzen 1500x@3.5G 台式机上只能达到 50M/s 的速度。我一度怀疑是英特尔和 AMD 的处理器单核性能有差异,但按道理不应该有这么大的速度差异。而且最近几天它又在我的台式机上能够跑到 100M/s 的速度。

参考

线程、事件与QObject

posted @ 2019-03-23 22:51  brifuture  阅读(1900)  评论(0编辑  收藏  举报