一、概述
随着客户端不断增加UI页面,运行程序的“体积”越来越大,初始化这些页面的时间也越来越长。
1、单个进程架构的“瓶颈”
在正式介绍Qt多进程架构开发前,先看看单个进程架构下客户端开发所遇到的“瓶颈“。在单个进程的架构中,为了避免初始化某个模块的UI页面导致UI主线程卡顿,所有模块的UI页面都会在客户端启动后、启动画面结束前完成,这样启动耗时会不断增加。或者,可以尽可能的延迟初始化,但会增加代码的复杂度。
2、多进程架构带来的好处
比较独立的模块其实可以考虑单独起一个进程,可以运行时拆卸和装配,代码也更解耦。多进程架构就可以通过设计一个主进程作为宿主、承载多个子进程的UI,来解决上述问题。主进程UI可以设计得非常轻量化,加载出主窗口后立马展示出来,各个子页面根据用户点击延时加载。这样即使子页面初始化非常耗时,也不会造成主进程的UI卡顿。Windows平台,多进程架构已非常常见,比如网易云音乐:
二、实现
本文主要针对Windows平台,使用Qt、C++实现,对多进程架构进行初步的探讨。由于Qt的跨平台特性,也可以扩展到linux、mac、ios等。
1、启动进程
多进程开发,首先是要启动另一个进程,Qt封装了一个类QProcess
专门用于操作进程,如下
auto* subProcess = new QProcess(this);
subProcess->start("C:/Windows/System32/SnippingTool.exe");
subProcess->waitForFinished();
Windows上,也可以使用Win32 API来启动进程,如下:
QString cmd = "C:/Windows/System32/SnippingTool.exe";
STARTUPINFO si = { sizeof(si) };
PROCESS_INFORMATION pi;
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = true;
bool bRet = CreateProcess(
nullptr,
(LPWSTR)cmd.toStdWString().c_str(),
nullptr,
nullptr,
FALSE,
CREATE_NEW_CONSOLE,
nullptr,
nullptr, &si, &pi);
效果如下:
(演示了启动系统自带截图程序)
2、窗口嵌入
启动一个外部进程后,如果这个进程有窗体(可以通过FindWindow
来获取其句柄),我们就可以将其窗口嵌入到主进程窗体中。示例代码如下:
auto* videoProcess = new QProcess(this);
videoProcess->start("VideoWidget.exe");
videoProcess->waitForFinished(2000);
WId wid = (WId)FindWindow(QStringLiteral("Qt5QWindowIcon").toStdWString().c_str()
, QStringLiteral("VideoWidget").toStdWString().c_str());
if (wid == 0)
return;
auto* window = QWindow::fromWinId(wid);
if (window)
{
window->setFlags(window->flags() | Qt::CustomizeWindowHint | Qt::WindowTitleHint);
auto* widget = createWindowContainer(window, this->ui.widget, Qt::Widget);
auto* pLayout = new QVBoxLayout(ui.widget);
pLayout->addWidget(widget);
ui.widget->setLayout(pLayout);
}
}
效果视频
注意: 上述视频中子进程的页面并非一出来就嵌入到主进程窗体中,而是过会儿再嵌入,这个问题在下面我会先解释,然后再提出我的解决思路。
上述示例代码中有一些关键点,先逐个解释下。
WId即WinID,指窗体的句柄。
2.1 QProcess不能使用临时变量
QProcess若使用临时变量,那么函数执行完毕(上述整段代码所在作用域)VideoWidget.exe
进程也就退出了。
2.2 进程启动后无法立即获取到WId
上述代码中有一行videoProcess->waitForFinished(2000)
,如果省略这一步骤,直接去FindWindow
会发现返回的WId
为0。
这很好理解,毕竟被启动的进程的页面初始化也需要时间,这也解释了为什么示例程序中子进程页面是过一会儿再嵌入主程序。
但坑就坑在WId
为0的页面通常也是能获取到的,下面一条就做了解释。
另外,这个等待时间也有待商榷,并不是所有页面都需要等待2000ms,而且这个操作阻塞了主进程。
解决思路:
将启动子进程的操作放到异步线程去执行,使用信号槽通知主进程加载嵌入页面。
connect(this, SIGNAL(signalStartVideoWidget()), this, SLOT(slotCreateProcess()));
auto* videoProcess = new QProcess(this);
QtConcurrent::run([=]()
{
videoProcess->start("VideoWidget.exe");
videoProcess->waitForFinished(2000);
emit signalStartVideoWidget();
});
这里使用QtConcurrent::run来创建简单的异步线程,这样我们解决了waitForFinished
卡主进程的问题。
关于怎么让子进程的页面一出来就嵌入到主进程窗体中这个问题,目前有以下思路,首先在子进程的页面构造函数中设置无边框
setWindowFlags(Qt::FramelessWindowHint | Qt::Window);
然后,调整页面显示的位置,位置信息可以通过进程通信获得。
关于waitForFinished
传入的时间问题,可以根据后续讨论中将WId存储到数据库,主线程定时检查或者进程间通信来解决。
2.3 FindWindow返回值未必符合期望
FindWindow
的两个参数,一个是类名,一个窗口标题栏名称。同样类名、窗口标题栏名称的进程可能打开很多,即使没有书写正确,FindWindow
也可能从当前系统运行的进程中找出了一个错误的窗体(比如WId
为0)。
因此,如果要真正在项目中应用多进程窗口嵌入,需要保证嵌入的确实是想要的那个窗体,最靠谱的办法还是直接获取窗体的WId
。
解决思路:
获取准确的WId,可以在子进程内加上将自己的WId写入数据库的逻辑,然后主进程从数据库中获取子进程的WId。
2.4 主程序退出时要退出子进程
使用QProcess启动子进程,可设置父控件为主进程所在页面,即new QProcess(this)
,利用Qt控件析构时自动析构子控件的机制退出子进程,或者手动退出(不推荐)。否则主程序退出后,会发现子进程还开着。
注意: 使用异步线程来启动子进程,QProcess需要在主线程new出来,否则主线程页面析构时还是不会自动析构子进程。
2.5 使用Spy++获取窗体信息
除了可以嵌入自定义的进程,也可以启动外部进程,比如系统截图进程。问题的关键在于获取到FindWindow
所需的传参,只要利用的是VS中的Spy++工具,在vs菜单“工具”中打开之后如图所示:
Spy++位置
使用视频
3、进程通信
要进行多进程架构下开发,需要了解进程间如何通信,下面介绍一些可行的方案及其优缺点。
3.1 命令行参数启动
使用QProcess的函数start()启动进程,可以传入QStringList使得被启动进程启动后获取到一些信息。
QStringList可以通过QJsonDocument转换得到,这样就能传递格式更灵活、更丰富的信息。
示例代码:
/* 主进程信息写入 */
auto* mainProcess = new QProcess(this);
QtConcurrent::run([=]()
{
QJsonObject json;
json.insert("Name", "Test");
json.insert("Password", "123456");
QJsonDocument document;
document.setObject(json);
QStringList arguments;
arguments << document.toJson(QJsonDocument::Compact);
mainProcess->start("Test.exe", arguments);
mainProcess->waitForFinished(2000);
emit signalTestProcess();
});
/* 子进程信息展示 */
QStringList cmdLineArgs = QCoreApplication::arguments();
const auto document = QJsonDocument::fromJson(cmdLineArgs[1].toLocal8Bit().data());
auto object = document.object();
ui.labelDeviceName->setText(object["Name"].toString());
ui.labelDevicePassword->setText(object["Password"].toString());
QCoreApplication::arguments()拿到的QStringList第0个QString是进程名称,第1个才是传参信息,实际项目中最好遍历判断下是否存在key再读取。这个方法只能在进程启动时传入,对于传入参数并无严格的限制。对应到项目中,可以传入子进程必须的一些信息。
3.2 共享内存
Qt提供了QSharedMemory类和QSystemSemaphore类用于访问共享内存。
QSharedMemory
QSharedMemory可以访问共享内存区域,以及多线程和进程的共享内存区域。QSharedMemory读写内存时,可以使用lock()实现同步;同步完成,必须使用unlock()为共享内存区域解锁。QSharedMemory可以使用attach()访问共享内存,可以通过指定参数来设置共享内存的访问模式。有以下两种模式:
模式 | 作用 |
---|---|
QSharedMemory::ReadOnly | 只能通过只读模式访问共享内存 |
QSharedMemory::ReadWrite | 可以通过读写模式访问共享内存 |
QSharedMemory拥有进程并提供可以返回共享内存区域指针的成员函数。在共享内存区域,成员函数constData()可以通过void类型返回进程正在使用的内存区域指针。创建共享时,QSharedMemory可以以字节为单位分配共享内存区域,还可以通过第二个参数设置函数attach()提供的模式。
QSharedMemory可以设置特定共享内存的固定键。函数setNativeKey()可以设置共享内存对象的键,该函数使用从属平台的共享内存的键进行相关设置。相反,使用函数setKey()可以设置与独立与平台的键。函数setKey()创建与平台本地键(Native Key)映射的键。
示例代码:
数据提供方
/* 定义并设置标志名 */
sharememory.setKey("share");
......
void Widget::WriteShareMemory()
{
/* 将共享内存与主进程分离 */
if(sharememory.isAttached())
{
sharememory.detach();
}
/* 将进程中要共享的数据拷贝到共享内存中 */
QBuffer buffer;
QDataStream out(&buffer);
buffer.open(QBuffer::ReadWrite);
buffer.write("hello QT!");
int size = buffer.size();
/* 创建共享内存 */
if(!sharememory.create(size))
{
qDebug() << sharememory.errorString();
return ;
}
/* 将共享内存上锁 */
sharememory.lock();
char *dest = reinterpret_cast<char *>(sharememory.data());
const char *source = reinterpret_cast<const char *>(buffer.data().data());
memcpy(dest, source, qMin(size, sharememory.size()));
/* 将共享内存解锁 */
sharememory.unlock();
}
数据使用方:
/* 定义并设置与共享内存提供方一致的标志名 */
sharememory.setKey("share");
......
void Widget::ReadShareMemory()
{
/* 将共享内存与主进程绑定,使主进程可以访问共享内存的数据 */
if(!sharememory.attach())
{
qDebug() << "cann't attach sahrememory!";
}
QBuffer buffer;
/* 将共享内存上锁 */
sharememory.lock();
/* 从共享内存中取数据 */
buffer.setData((char*)sharememory.constData(),sharememory.size());
buffer.open(QBuffer::ReadWrite);
buffer.readAll();
/* 使用完后将共享内存解锁 */
sharememory.unlock();
/* 将共享内存与进程分离 */
sharememory.detach();
qDebug() << buffer.data().data();
}
QSystemSemaphore
QSystemSemaphore类用于访问系统共享资源,以实现独立进程间的通信。
QSystemSemaphore可以提供普通系统的信号量。信号量使用互斥体,而互斥体只可以使用1次锁定(Block)。因此,QSemaphore类不能对有效资源使用多线程,而QSystemSemaphore类可以再多进程或多线程中实现。
QSystemSemaphore与QSemaphore类不同,可以访问多进程。这表示QSystemSemaphore是一个重量级的类。因此,使用单一线程或进程时,建议使用QSemaphore。获得资源前,成员函数acquire()始终阻塞。函数release()用于释放资源,且该函数可以设置参数。该函数的参数>1时,释放资源。
3.3 Windows 消息
发送消息
a、自定义类型和接收窗体
包含所需库,定义发送的自定义类型、接收消息的窗体标题。自定义类型可以处理消息过多情况下,对消息的区分,如果不需要也可以去掉。
示例代码:
#ifdef Q_OS_WIN
#pragma comment(lib, "user32.lib")
#include <qt_windows.h>
#endif
const ULONG_PTR CUSTOM_TYPE = 10000;
const QString c_strTitle = "ReceiveMessage";
b、发送数据
do{…}while
用来忽略本窗口,当然自身也可以接受自身的消息。
示例代码:
void onSendMessage()
{
HWND hwnd = NULL;
/* 忽略自己 */
//do
//{
LPWSTR path = (LPWSTR)c_strTitle.utf16(); //path = L"SendMessage"
hwnd = ::FindWindowW(NULL, path);
//} while (hwnd == (HWND)effectiveWinId());
if (::IsWindow(hwnd))
{
QString filename = QStringLiteral("进程通信-Windows消息");
QByteArray data = filename.toUtf8();
COPYDATASTRUCT copydata;
/* 用户定义数据 */
copydata.dwData = CUSTOM_TYPE;
/* 数据大小 */
copydata.lpData = data.data();
/* 指向数据的指针 */
copydata.cbData = data.size();
HWND sender = (HWND)effectiveWinId();
::SendMessage(hwnd, WM_COPYDATA, reinterpret_cast<WPARAM>(sender), reinterpret_cast<LPARAM>(©data));
}
}
接收消息
a、设置标题
标题、自定义类型与 发送消息 设置的标题一致,否则就会找不到窗体。
setWindowTitle("ReceiveMessage");
b、重写nativeEvent
bool nativeEvent(const QByteArray &eventType, void *message, long *result)
{
MSG *param = static_cast<MSG *>(message);
switch (param->message)
{
case WM_COPYDATA:
{
COPYDATASTRUCT *cds = reinterpret_cast<COPYDATASTRUCT*>(param->lParam);
if (cds->dwData == CUSTOM_TYPE)
{
QString strMessage = QString::fromUtf8(reinterpret_cast<char*>(cds->lpData), cds->cbData);
QMessageBox::information(this, QStringLiteral("提示"), strMessage);
*result = 1;
return true;
}
}
}
return QWidget::nativeEvent(eventType, message, result);
}
3.4 D-Bus
D_BUS是一种低开销、低延迟的进程间通信机制。Qt提供了QtDBus模块,QtDBus模块使用D-Bus协议,把信号与槽机制(Signal and Slot)扩展到进程级别,使得开发者可以在一个进程中发出信号,可以再其他进程定义槽来响应其他进程发出的信号。
目前QtDBus主要是用于Linux平台,如果要应用于Windows平台需要经过一定处理,相关内容比较多,本文暂不展开说明。
官方文档:https://doc.qt.io/qt-5/qtdbus-index.html
3.5 TCP/IP
其实就是将同一机器上面的两个进程一个当做服务器,一个当做客户端,二者通过网络协议进行交互。Qt对其进行了封装,并提供了两个层次的API,包括应用程序级的QNetworkAccessManager, QFtp等和底层的QTcpSocket, QTcpServer, QSslSocket等。
3.6 Qt COmmunications Protocol
QCOP 是 Qt 内部的一种通信协议,这种协议用于不同的客户之间在同一地址空间内部或者不同的进程之间的通信。目前,这种机制还只在 Qt 的嵌入式版本中提供。
为实现这种通信机制,Qt 中包括了由 QObject 类继承而来的 QCopChannel 类,该类提供了诸如 send()、isRegistered() 等静态函数,它们可以在脱离对象的情况下使用。为了在 channel 中接收通信数据,用户需要构造一个 QCopChannel 的子类并提供 receive() 函数的重载函数,或者利用 connect() 函数与接收到的信号相联系。值得一提的是,在 Qt 系统中,只提供了 QCOP 协议机制和用于接收消息的类,而如何发送消息则没有提供相应的类供用户使用。
在基于 Qt 的桌面系统 Qtopia(QPE)中,则提供了相应的发送类:QCopEnvelope。用户可以通过该类利用 channel 向其他进程发送消息。该类将通过 QCopChannel 发送 QCop 消息的过程进行了封装,用户只需要调用该类中的相关函数就可以方便地实现进程之间的通信。