Day 5 扫描驱动桩开发

关于扫描仪

显微扫描设备是稀缺且昂贵的设备,并且扫描过程繁复(厂商提供的驱动,必须按照低倍-高倍的顺序进行,且百倍油镜一定会滴油,滴油之后如果再要做十倍扫描就需要再将油擦去),因此,前期的软件开发中,使用真实设备是很低效的工作。我们需要通过打桩的方式来实现开发前期对设备的替代。

首先定义接口IScanDevice。它是对提供的显微镜的接口的封装。我们在Adaptors项目中增加一个类IScanDevice,它为抽象接口类:

1
2
3
4
5
6
7
8
9
10
11
12
class ADAPTORS_EXPORT IScanDevice
{
public:
IScanDevice() = default;
virtual int Initialize() = 0;
virtual int GetSlidePreview(std::vector<PicInfor>* pic_list, ScanInfo* scan_info, std::string& qrcode) = 0;
virtual int LowScanMoving(ScanInfo* scan_info, int row_focus=3, int col_focus=3) = 0;
virtual int HighScanMoving(std::vector<std::pair<std::pair<int,int>, std::pair<int,int>>> points, int scan_method, bool is_oil, ScanInfo* scan_info) = 0;
virtual void GetRealtimePics(std::vector<PicInfor>* pic_list) = 0;
virtual int NormalStatusCheck() = 0;
virtual void ReturnSlide() = 0;
};

这是一个经过精简的接口,删除了和本书内容无关的内容。

  • Initialize(): 连接显微镜设备,通知设备做初始化。程序需要首先调用这个接口连接扫描仪设备,并进行设备初始化。此函数仅需要执行一次,此后在整个软件生命周期内,硬件始终可用。

  • GetSlidePreview(): 拍摄玻片的全局图和标签图。显微镜上有一个附属相机,它可以拍摄玻片的全景图(以后称之为预览图或定位图)和标签部分的照片。这个接口是同步接口,完成拍摄之后才会返回,在输出参数pic_list中依次保存了定位图和标签图的图像数据。

  • LowScanMoving(): 通知显微镜开始低倍连续扫描。将用定位图中像素坐标表示的扫描区域传给扫描仪,扫描仪会计算出要扫描的行列数并返回。然后扫描仪会启动工作线程进行扫描,并将扫描得到的照片数据保存在缓冲区中。开始扫描后,我们可以调用GetRealtimePics()函数来获取已经拍摄的照片。

  • HighScanMoving():通知显微镜开始高倍扫描。其工作模式与低倍扫描一样。

  • GetRealtimePics():获取扫描仪保存在缓冲区中的拍摄的照片。

  • NormalStatusCheck():扫描仪状态检查

  • ReturnSlide():通知显微镜退片

以后我们从IScanDevice中派生出具体的扫描仪类来。当前先创建一个扫描仪桩类ScanDeviceStub临时使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class ADAPTORS_EXPORT ScanDeviceStub : public IScanDevice
{
public:
friend class ScanDeviceStubTester;
explicit ScanDeviceStub();
virtual ~ScanDeviceStub();
virtual int Initialize() override;
virtual int GetSlidePreview(std::vector<PicInfor>* pic_list, ScanInfo* scan_info, std::string& qrcode) override;
virtual int LowScanMoving(ScanInfo* scan_info, int row_focus=3, int col_focus=3) override;
virtual int HighScanMoving(std::vector<std::pair<std::pair<int,int>, std::pair<int,int>>> points, int scan_method, bool is_oil, ScanInfo* scan_info) override;
virtual void GetRealtimePics(std::vector<PicInfor>* pic_list) override;
virtual int NormalStatusCheck() override;
virtual void ReturnSlide() override;

private:
// Stub独有接口, 用于单元测试时使用.
void setStatus(int status);
void setSrcPath(const QString& path);
void setInitializeResult(int result);
void setCellType(int cell_type);
void setScanMethod(int method);
void setLowScanCount(int columns, int rows);
void setHighScanCount(int columns, int rows);
std::vector<std::pair<int,int>>& getImagePosList();
QString getImagePath() const;
// 数据
struct Implementation;
QScopedPointer<Implementation> _impl;
};
opedPointer<Implementation> _impl;
};

注意类ScanDeviceStub的私有域中的结构Implementation和属性_impl的定义。这是C++中十分常见的一种模式,称为Pimpl模式。简单来说,就是将类的具体实现隐藏到一个私有类Implementation里面,并且,这个类不是在头文件中定义的,而是在.cpp文件中。如果你看Qt的头文件,会发现这种模式十分常见(它一般是通过Q_DECLARE_PRIVATE宏来定义的)。那么,为什么要这么做,主要是考虑到几个原因:

  • 首先,这样隐藏了类的内部实现,避免了在头文件中暴露内部类的属性,对类的访问只能通过提供的公共接口进行。
  • 其次,修改Impl类的数据不会影响二进制文件的兼容性
  • 再次,在头文件中只需要包含声明需要的头文件,不需要包含实现所需的头文件。这样使得头文件更简洁,并且不需要频繁改动,在编译时就不容易触发重新编译,从而加快了编译速度
  • 最后一点,对Qt独有的特点是,Qt的容器的独特设计。与stl中的容器不同,Qt的容器利用了写时复制技术,当容器内的对象的长度是void *的时候,具有最高的内存利用率。当然,对于我们这里的界面类,指针ui的存在已经破坏了这一点(当然你也可以把ui也塞到_impl里面去,只是这样访问层级就太多了),但是作为一个习惯,在类设计的时候这样做将是一个很好的编程习惯。当你习惯了之后,你会发现在类里面直接加入属性,不管是私有还是公共,怎么写怎么不舒服:-)。

我们通过图像数据回放的方式来构造这个桩的实现。在代码实现之前,我们先准备一下单元测试。

编写单元测试

对代码做单元测试是一种了良好的编程习惯。按照TDD的说法,单元测试应该在编写代码之前就开始写,并且第一个版本应该返回失败。这个有些太绝对了,我们尽力而为吧。

首先在项目UnitTest里面添加一个测试子项目UnitTest_Adaptors_ScanDeviceStub。以后我们的命名方式都是如此:UnitTest_项目名称_类名,测试类名称为ScanDeviceStubTester,其命名约定是在类名的后面加上Tester。在理论上,应该为每个类做单元测试,每个类定义一个测试项目。

  1. 首先,在项目UnitTest上面,右键鼠标,选择新增子项目:

  1. 选择项目类型为Qt Test Project

  1. 设置项目名称为UnitTest_Adaptors_ScanDeviceStub

  2. 指定测试类名称为ScanDeviceStubTester

  3. 将项目Adaptors添加到UnitTest_Adaptors_ScanDeviceStub中。在Qt中执行单元测试一般有两种做法。一种是直接包含要测试的源代码,但是这种做法在代码复杂时十分容易遇到包含路径等各方面的冲突问题,得不偿失;另一种做法是引用被测代码所在的DLL库。我们使用这种做法。此外,我们还需要将测试类作为被测试的类的友元添加到被测试的类的定义中。这样我们可以把一些专门为测试开发的接口全部放到私有部分里面,从而不会被产品代码错误使用。

  4. 在测试项目中添加thirdpart.pri和公共包含路径。注意,因为单元测试项目的目录层级变了,我们的include语句也要随之变化。最终,UnitTest_Adaptors_ScanDeviceStub.pro文件中新增了如下的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
QT += testlib
QT += gui
CONFIG += qt warn_on depend_includepath testcase

TEMPLATE = app

SOURCES += tst_scandevicestubtester.cpp

# 下面是增加的内容:
INCLUDEPATH += $$PWD/../common/include
DEPENDPATH += $$PWD/../common
include(../../thirdpart.pri)

#Adaptors库
win32:CONFIG(release, debug|release): LIBS += -L$$OUT_PWD/../../Adaptors/release/ -lAdaptors
else:win32:CONFIG(debug, debug|release): LIBS += -L$$OUT_PWD/../../Adaptors/debug/ -lAdaptors
INCLUDEPATH += $$PWD/../../Adaptors
DEPENDPATH += $$PWD/../../Adaptors

实现ScanDeviceStub

接下来我们实现这个类。在书中我们只讨论几个接口,详细的内容请参看程序源码。

定义Implementation

首先我们定义类的私有类型Implementation。我们前面说过,它是通过数据回放的方式来模拟扫描活动的,在数据结构里面定义了和这一行为相关的属性:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct ScanDeviceStub::Implementation
{
QString _src_path{R"(D:\DataStore\TestData\scanstub\2024-02-19-092454)"};
int _low_columns{4}, _low_rows{3}; // LowScanMoving()时准备的图像数量
int _high_matrix_columns{5}, _high_matrix_rows{5}; // HighScanMoving()时连续扫描时的扫描行数列数
int _high_single_count{12}; // HighScanMoving()做非连续扫描时的图像数量
int _status{DCM_INIT_ERROR}; // 扫描仪的当前状态
int _high_cell_type{0}; // 高倍扫描的类型. 0:白细胞,1:红细胞,2:巨核细胞
int _high_scan_method{ScanMethod::DCM_SCAN_SINGLE};

int _cur_view_idx; // GetRealtimePics()时的索引号
QString _image_path; // 在启动扫描后准备的图像位置
std::vector<std::pair<int,int>> _image_pos_list; // 在启动扫描后准备的图像索引

int _initialize_time{2000}; // 初始化活动耗时(毫秒)
int _preview_time{1000}; // 预览图拍摄耗时(毫秒)
int _expect_initialize{DCM_INIT_OK};
int _expect_preview{DCM_GET_PREVIEW_ERROR};
QString _qrcode{"SAMPLE_001-CASE_001-P"}; // 返回的二维码
};

代码的注释很清晰了,对照后面的实现很容易理解。

初始化

我们要实现的第一个接口是初始化。它是使用扫描仪时要做的第一个活动。扫描软件启动后,首先调用设备驱动程序进行设备的初始化。初始化命令只需要调用一次,设备驱动软件内部会执行一系列的活动。如果初始化成功,软件会返回DCM_INIT_OK,否则会返回DCM_INIT_ERROR等。测试桩当然不需要这么复杂,简单地返回成功或失败就可以了。

1
2
3
4
5
6
7
int ScanDeviceStub::Initialize()
{
TRACE() << "开始设备初始化";
QThread::msleep(_impl->_initialize_time);
_impl->_status = _impl->_expect_initialize;
return _impl->_expect_initialize;
}

其中,两个属性_initialize_time_expect_initialize是提供的定制参数,在单元测试时可以在调用函数前修改它们的值,以控制函数的行为。

这个函数实在是太简单了,简单地都不值得我们去测试它

为了简单,我们的桩不去做多余的活动,比如调用Initialize()失败时不允许调用其他扫描功能的限制。

拍摄预览图

GetSlidePreview()方法用于拍摄预览图,它返回一个PicInforvector,第一个是玻片的标签照片,第二个是玻片的整体照片(以后成为预览图,或者定位图)。在ScanInfor中的startx, starty, width, height包含了硬件支持的高倍扫描范围——并不是所有的玻片范围都可以做高倍扫描的。它的第三个参数qrcode返回的是对标签图中的二维码的解析结果。如果解析失败,就是一个空的std::string。在一个信息化比较完备正规的医院中,玻片的标签上通常会有病人信息的二维码或条码,里面至少会包含了样本号和样本类型信息。扫描过程中会用到这些信息。

下面是函数的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
QStringList filePathList{
QString("%1/qrcode.jpg").arg(_impl->_src_path),
QString("%1/preview.jpg").arg(_impl->_src_path)
};

for(auto file_path: filePathList)
{
auto img = cv::imread(file_path.toStdString());
auto length = img.rows * img.step;

PicInfor infor;
infor.pictureType = PictureType::DCM_PICTURETYPE_COLOR;
infor.format = 1;
infor.width = img.cols;
infor.height = img.rows;
infor.matPic = new uchar[length];
std::memcpy(infor.matPic, img.data, length);
pic_list->push_back(infor);
}
scan_info->x = 450;
scan_info->y = 590;
scan_info->width = 1800;
scan_info->height = 1095;
qrcode = _impl->_qrcode.toStdString();
setStatus(DCM_GET_PREVIEW_OK);
QThread::msleep(_impl->_preview_time);
return _impl->_status;

真实的扫描仪是从相机中获取图像数据,并保存在PicInfor结构数组中传给客户端。在我们的桩实现中,从硬盘上读取之前保存下来的图片,并编码后返回。

测试GetSlidePreview()

我们编写测试用例来测试GetSlidePreview()。在ScanDeviceStubTester中增加一个函数test_preview()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void ScanDeviceStubTester::test_preview()
{
//QSKIP("Skip preview() test");
ScanDeviceStub scaner;
std::vector<PicInfor> pic_list;
ScanInfo scan_infor;
std::string qrcode;

auto ret = scaner.GetSlidePreview(&pic_list, &scan_infor, qrcode);
QCOMPARE(ret, DCM_GET_PREVIEW_OK);
QCOMPARE(pic_list.size(), 2);
QCOMPARE(scan_infor.x, 450);
QCOMPARE(scan_infor.y, 590);
QCOMPARE(scan_infor.width, 1800);
QCOMPARE(scan_infor.height, 1095);
QCOMPARE(qrcode, "SAMPLE_001-CASE_001-P");
for(int i=0; i<2; ++i)
{
auto mat = cvtPicToMat(pic_list.at(i));
QCOMPARE(mat.cols, 2592);
QCOMPARE(mat.rows, 1944);
}
}

其中,cvtPicToMat是我们在Adaptor中提供的一个辅助函数,用于根据PicInfor生成cv::Mat数据。


备注
上面测试代码中的QSKIP()是QTEST中的一个宏,它用于指示跳过这个测试函数执行。

低倍扫描测试

接下来我们实现LowScanMoving()GetRealtimePics()两个函数,以支持低倍扫描。
扫描仪的扫描工作模式为:

  • 用户首先调用LowScanMoving()HighScanMoving()启动扫描任务
  • 扫描仪会校验参数,如果正确,就会返回成功,同时启动一个工作线程来执行扫描,并将拍摄的到的照片保存在缓冲区中。当扫描结束后,会将状态码设置为DCM_LOW_SCANMOVING_FINISHEDDCM_HIGH_SCANMOVING_FINISHED以表明扫描结束了。
  • 客户端需要使用GetRealtimePics()来不断进行查询,以获取缓冲区中的照片,使用NormalStatusCheck()来检查扫描状态。

首先看LowScanMoving()函数。在Implementation中,_low_columns_low_rows分别保存了低倍扫描要返回的图片的行列数——我们也可以使用void setLowScanCount(int columns, int rows)来修改这两个值——并根据它来构造要返回的照片文件的标识列表,保存到Implementation结构中的_image_pos_list中,同时,_image_path设置为返回的图片的保存目录,_cur_view_idx则是一个游标指针,指明下一个要返回的图片的序列号。

每次调用LowScanMoving()函数,都会将状态重置。这个函数不支持重入,当然,不过对测试来说没有影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int ScanDeviceStub::LowScanMoving(ScanInfo *scan_info, int row_focus, int col_focus)
{
Q_UNUSED(row_focus)
Q_UNUSED(col_focus)
scan_info->column = _impl->_low_columns;
scan_info->row = _impl->_low_rows;
_impl->_image_pos_list.clear();
for(int row=0; row<_impl->_low_rows; ++row)
{
for(int col=0; col<_impl->_low_columns; ++col)
{
int scan_col = row%2==0 ? col : _impl->_low_columns-col-1;
_impl->_image_pos_list.emplace_back(scan_col, row);
}
}
_impl->_cur_view_idx = 0;
_impl->_image_path = QString("%1/lower").arg(_impl->_src_path);
setStatus(DCM_LOW_SCANMOVING_START);
return DCM_LOW_SCANMOVING_START;
}

GetRealtimePics()则是根据游标_cur_view_idx_image_pos_list中获取图像的标识,并读取图像文件而已:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void ScanDeviceStub::GetRealtimePics(std::vector<PicInfor> *pic_list)
{
RETURN_LOG_IF(_impl->_cur_view_idx>=_impl->_image_pos_list.size(), QString("没有新的图像了!"));

int report_count = 1;
for(int id=0; id<report_count; ++id)
{
auto pos = _impl->_image_pos_list.at(_impl->_cur_view_idx++);
QString file_path = QString("%1/%2_%3.jpg").arg(_impl->_image_path).arg(pos.first).arg(pos.second);
auto mat = cv::imread(file_path.toStdString());
RETURN_LOG_IF(mat.empty(), QString("读取图像%1无效!").arg(file_path));
auto length = mat.step * mat.rows;
PicInfor pic;
pic.point = pos;
pic.width = mat.cols;
pic.height = mat.rows;
pic.pictureType = mat.channels()==1 ? PictureType::DCM_PICTURETYPE_GRAY : PictureType::DCM_PICTURETYPE_COLOR;
pic.validFlag = 1;
pic.format = 1;
std::memcpy(pic.matPic, mat.data, length);
pic_list->push_back(pic);
if(_impl->_cur_view_idx>=_impl->_image_pos_list.size())
{
setStatus(_impl->_status==DCM_LOW_SCANMOVING_START ? DCM_LOW_SCANMOVING_FINISHED : DCM_HIGH_SCANMOVING_FINISHED);
TRACE() << "扫描结束了!";
return ;
}
}
}

我们编写测试用例来测试它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
void ScanDeviceStubTester::test_low_scan_normal()
{
//QSKIP("Skip normal low scan test");
const int COLUMNS = 2;
const int ROWS = 4;
ScanDeviceStub scaner;
scaner.setLowScanCount(COLUMNS,ROWS); // 指定扫描2列四行
ScanInfo scan_info;

auto ret = scaner.LowScanMoving(&scan_info);
QCOMPARE(scan_info.column, COLUMNS);
QCOMPARE(scan_info.row, ROWS);
QCOMPARE(ret, DCM_LOW_SCANMOVING_START);

auto pic_pos_list = scaner.getImagePosList();
QCOMPARE(pic_pos_list.size(), COLUMNS*ROWS);
// 检查是否是蛇形扫描
QCOMPARE(pic_pos_list.at(0), std::make_pair(0,0));
QCOMPARE(pic_pos_list.at(1), std::make_pair(1,0));
QCOMPARE(pic_pos_list.at(2), std::make_pair(1,1));
QCOMPARE(pic_pos_list.at(3), std::make_pair(0,1));
QCOMPARE(pic_pos_list.at(4), std::make_pair(0,2));
QCOMPARE(pic_pos_list.at(5), std::make_pair(1,2));

std::vector<PicInfor> pic_list;
int index=0;
while(index<COLUMNS * ROWS)
{
ret = scaner.NormalStatusCheck();
QCOMPARE(ret, DCM_LOW_SCANMOVING_START);
scaner.GetRealtimePics(&pic_list);
auto& pic = pic_list.at(index);
index++;
}
ret = scaner.NormalStatusCheck();
QCOMPARE(ret, DCM_LOW_SCANMOVING_FINISHED);
QCOMPARE(pic_list.size(), COLUMNS*ROWS);

scaner.GetRealtimePics(&pic_list);
QCOMPARE(pic_list.size(), COLUMNS*ROWS);
}

同样的方式,我们可以实现HighScanMoving()函数和其他的方法。


用于测试桩的数据包可以从这里获取:

链接:https://pan.baidu.com/s/1DKXdyXRjeIT5ko02spOLzQ
提取码:hexJ

界面集成

接下来我们将连接设备功能集成到界面中。我们仍然将Scanner的创建活动放到工厂函数中:

1
2
3
4
5
6
7
std::unique_ptr<IScanDevice> AlgorighmFactory::makeScanDevice()
{
#ifdef USE_STUB
return std::make_unique<ScanDeviceStub>();
#else
#endif
}

以后,当要切换到真实产品环境时,我们只需要通过预编译宏来控制它创建其他的派生类就可以了。和配置类一样,我们将所有全局设备的创建都放到main()函数中,并通过裸指针传递给需要它的类。

其中,USE_STUB是一个宏定义,我们先临时将其放到thirdpart.pri里面。因为几个工程都会包含thirdpart.pri,我们将其作为保存所有公共环境的地方。

1
DEFINES += USE_STUB

ScannerMainWindow类定义:

1
2
3
4
5
6
7
8
9
10
11
12
class ScannerMainWindow : public QMainWindow
{
Q_OBJECT
public:
ScannerMainWindow(IConfiger* configer, IScanDevice* scanner, QWidget *parent = nullptr);
~ScannerMainWindow();
private:
Ui::ScannerMainWindow *ui;
struct Implementation;
QScopedPointer<Implementation> _impl;
};

实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct ScannerMainWindow::Implementation
{
IConfiger* _configer;
IScanDevice* _scanner;
Implementation(IConfiger* configer, IScanDevice* scaner)
: _configer{configer}, _scanner{scaner}
{}
};

ScannerMainWindow::ScannerMainWindow(IConfiger *configer, IScanDevice *scanner, QWidget *parent)
: QMainWindow(parent)
, ui(new Ui::ScannerMainWindow)
, _impl{new Implementation(configer, scanner)}
{
ui->setupUi(this);
//setWindowState(Qt::WindowMaximized);
}

同步修改main()函数:

1
2
3
4
5
6
7
8
9
10
int main(int argc, char *argv[])
{
QApplication a(argc, argv);
auto fp = QString("%1/config.ini").arg(QCoreApplication::applicationDirPath());
auto configer = AlgorighmFactory::makeConfiger(fp);
auto scanner = AlgorighmFactory::makeScanDevice();
...
ScannerMainWindow w(configer.get(), scanner.get());
...
}

实现连接操作

真实的连接设备操作耗时大约在30-40秒之间,这是一个相当漫长的结果,并且在这个过程中除了肉眼能看到机械设备在运动外,设备驱动软件未反馈任何信息。如果不做特别处理,很容易让人以为软件死机了。同时,在连接成功之前,我们不应该做任何其他的操作,因此它天生就应该是模态的。我们将连接设备的操作放到独立线程中运行,同时使用一个模式对话框来控制用户的操作,在对话框中显示进度。

ScannerMainWindow中增加一个slot函数onActConnect,并在构造函数中将其connectactConnecttrigger上。然后实现它:

虽然我们使用Qt设计器来设计界面,但是控件的事件处理函数建议还是手工定义和连接,不要让设计器自动提供。日后界面维护起来这是很麻烦的事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void ScannerMainWindow::onActConnect()
{
showMessage(tr("Begin Connect ..."));
ui->actConnect->setEnabled(false);

auto dlg = new QProgressDialog(this, Qt::FramelessWindowHint);
dlg->setModal(true);
dlg->setRange(0,0);
dlg->setMinimumWidth(600);
dlg->setCancelButton(nullptr);
dlg->setLabel(nullptr);
dlg->setAttribute(Qt::WA_TranslucentBackground, true);

QFutureWatcher<int> watcher;
connect(&watcher, &QFutureWatcher<int>::finished, dlg, &QProgressDialog::close);
auto future = QtConcurrent::run(&IScanDevice::Initialize, _impl->_scanner);
watcher.setFuture(future);
dlg->exec();
watcher.waitForFinished();

if(auto result=future.result(); result==DCM_INIT_OK)
{
showMessage(tr("Scaner connected."));
ui->actConnect->setEnabled(false);
ui->actSingleScan->setEnabled(true);
ui->actBatchScan->setEnabled(true);
ui->actStopScan->setEnabled(false);
}
else
{
QMessageBox::critical(this, tr(""), tr(""));
return;
}
}

上面代码中,我们创建了一个QProgressDialog对话框,并将其中的文本和按钮都去掉,只保留进度条。因为我们无法获取Initialize()的实际进度,我们将进度的范围设置为(0,0),这样它在运行时会显示一个不断闪动的进度条。

然后启动线程运行扫描仪初始化,并使用一个QFutureWatcher来跟踪它,当线程结束后,QFutureWatcher会发送finished信号,我们将其关联到QProgressDialog::close()上,以便自动关闭对话框。

QtConcurrent::run()返回QFuture对象,通过它的result()方法获取到线程函数的返回值以判断是否成功。

说明:Qt中的界面异步和同步方式

对于界面开发,当处理耗时任务的时候,为了避免界面长时间无响应,我们需要使用异步技术来确保界面的更新。

一种做法是基于事件队列的做法,将工作嵌入到当前的事件队列中。最常见的比如QTimer,它适用于任何具有EventLoop的对象,当然也包括主线程。比如,下面的代码:

1
2
3
QTimer::singleShot(1000, [](){
qDebug() << "Run time in thread: " << QThread::currentThreadId();
});

超时处理方法会在调用线程中执行。这种做法通常比较适用于简单的任务。同时,因为任务在主线程中执行,其实占用了主线程的负载,一般仅用于诸如定时等简单场景。更普遍的是使用多线程技术。

Qt提供了多种多线程的技术:

  • 我们可以使用QThread,它是Qt提供的底层API。它提供了最丰富完整的线程控制。我们可以从QThread派生出自己的类,并重载实现其run。但是,更好的做法是定义自己的工作量并使用moveToThread()将它移动到线程中,然后调用线程利用signal-slot机制和对象通信,实现各种复杂的交互。

  • 可以使用线程池来处理需要多次创建并执行的任务,Qt提供了QThreadPoolQRunnable。在Qt提供了QtConcurrent之后,QRunnable实际上使用的价值已经很低了。

  • Qt还在QtConcurrent中提供了更高层的封装,比如mapfilter等方法。它们适合对数据集合做并发的处理,使用场景比较受限,我们在用到是再讨论。

  • Qt还在QtConcurrent中提供了run()task()。从某种意义上,它们有些类似于C++标准库的std::async()std::packaged_task.它们也是在线程中执行,Qt提供了两个版本,一个版本运行在系统线程池中,另一个版本让用户指定线程池。它们更像是QRunnable的更简便的替代产品,适合处理大量的没有什么交互场景的情况。我们在代码中会大量使用QtConcurrent::run来创建临时性的工作线程。QtConcurrent::task是相对较新的方法,它是对run的增强,可以提供更多的线程控制。

  • 另外,在Qt6.0中还增加了QPromise,它基本类似于std::promise,并且QtConcurrent::run也扩展支持了QPromise的版本,相对于基本的run,它支持更多的功能,在某些场景下更为方便。

Qt文档Multithreading Technologies in Qt有一个各种多线程技术的对比。

在大多数情况情况下,主线程启动工作线程后,需要查询工作线程的工作状态,要等待线程结束。根据主线程是否阻塞等待,以及任务完成后通知主线程的手段,我们分成几种:

同步方式

主动方式指的是主线程启动工作线程后,要等待任务完成后才能继续走下去。根据等待的方法,可以分成阻塞和非阻塞方式。
阻塞方式,比如QFutureQFutureWatcherQFutureSynchronizer都有waitForFinished()方法。我们后面的连接设备中会使用阻塞方式。但是,如果是在主线程中阻塞等待了,就会导致界面失去响应了。所以,我们简单的阻塞通常只是在工作线程中阻塞等待其他的工作线程,而不会在主线程中简单的阻塞。

非阻塞方式下,主线程为了获取任务状态,要通过周期性查询任务是否处理完成。例如下面的代码段,主线程启动任务后,每100ms处理以下事件。

qDebug() << "onEventProcess at threadId=" << QThread::currentThreadId();
auto task = [](){
    qDebug() << "Begin run long task, thread=" << QThread::currentThreadId();
    QThread::sleep(10);
    qDebug() << "long task finished";
};
auto future = QtConcurrent::run(task);
while(!future.isFinished())
{
    QApplication::processEvents(QEventLoop::AllEvents, 100);
}

异步方式

主线程启动工作县城后,继续执行,任务执行完毕后,子线程通知主线程去处理。根据我们使用的多线程方式不同,可以有几种不同的方式。

使用moveToThread

定义一个工作类Worker

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Worker : public QObject
{
Q_OBJECT
public:
explicit Worker(QObject *parent = nullptr) : QObject(parent)
{ qDebug() << "Construct Worker at " << QThread::currentThreadId();}
~Worker()
{ qDebug() << "Destruct Worker at " << QThread::currentThreadId();}
public slots:
void doWork(){
qDebug() << __func__ << ": doWork at thread " << QThread::currentThreadId();
QThread::sleep(5);
qDebug() << __func__ << ":doWork finished";
emit sigWorkFinished();
}
signals:
void sigWorkFinished();

private:
};

MainWindow中触发:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void MainWindow::onBtnDoWork()
{
qDebug() << "onDoWork() in Main Thread: " << QThread::currentThreadId();
auto thread = new QThread;
auto worker = new Worker();

connect(this, &MainWindow::sigDoWork, worker, &Worker::doWork);
connect(worker, &Worker::sigWorkFinished, [worker](){
qDebug() << __func__ << ": work finished at" << QThread::currentThreadId();
worker->deleteLater();
});

worker->moveToThread(thread);
thread->start();
emit sigDoWork();
}

使用QEventLoop

对于Worker,也可以使用QEventLoop

1
2
3
4
5
6
7
8
9
10
11
12
13
void MainWindow::onBtnEventProcess()
{
qDebug() << "Begin" << __func__ ;
auto thread = new QThread;
auto worker = new Worker();
QEventLoop loop;
connect(this, &MainWindow::sigDoWork, worker, &Worker::doWork);
connect(worker, &Worker::sigWorkFinished, worker, &QObject::deleteLater);
worker->moveToThread(thread);
thread->start();
emit sigDoWork();
loop.exec();
}

两种方法对多线程来说没有太大的区别。实际上,QEventLoop更常见的用处是在主线程中调用一个长耗时操作的情况。对于多线程,这种做法未免有些多余了。

使用QFutureWatcher

对于使用QtConcurrent::run来创建工作线程的情况,我们可以用QFutureWatcher来捕获发出的finished信号。此时不需要我们像在前面的Worker类中一样手工发出结束信号。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void MainWindow::onBtnFuture()
{
auto task = [](){
qDebug() << __func__ << ": task at thread " << QThread::currentThreadId();
QThread::sleep(5);
qDebug() << __func__ << ":task finished";
};

auto watcher = new QFutureWatcher<void>();
connect(watcher, &QFutureWatcher<void>::finished, [](){
qDebug() << "task has finished !";
});
watcher->setFuture(QtConcurrent::run(task));
}

在本项目中,我们会根据情况使用不同的同步技术。

Qt中的各种多线程技术的对比,请参见 Qt中的多线程技术选型