Day 0 前言

题外话

关于Qt是什么,Qt难不难,学起来容易不容易,应不应该学,网上有各种各样的讨论,本书不打算出版,所以不会在这种无聊的事情上浪费字数。既不会吹,也懒得黑。就我个人的经历感受看,一个人在工作种选择哪种语言,选择哪种技术,通常是身不由己的。你身处这个位置,被要求使用某种技术,不管你会不会,你喜不喜欢,你都要想会,要么你就走人。而从另一个方面来说,一种语言,一个框架,说到底是一个给人使用的工具,不管是上古的Cobel,Fortran还是现在火的不得了的Go,Rust(比如,最近稀宗同志就公然宣称C++不安全,要求改用Rust😓),本质上和一个钳工手里的锤子,电工手里的试电笔没有区别。我从未见过一个电工因为手里拿的是某世的试电笔就觉得自己可以傲视那些拿着杂牌试电笔的电工了,但是这种情况在程序员里面却十分常见,令我多年来都感到即有趣又费解。毕竟,这仅仅是工具,又不是名牌跑车皮包首饰啥的。

追根溯源,网络上关于语言的争论很多是培训机构在捣鬼,而一个企业从一种技术栈切换到另一种技术栈,一般来说也绝对不是纯粹的技术的原因,更多的是政治上的考量。更多的不再讨论。作为一名工程师,当前的要务是踏踏实实做好自己要做的事情,学习当下需要的技能。

本书是介绍如何使用C++/Qt来从头开发一个接近真实的软件产品,和其他使用一个虚构出来的小软件不同,本书讨论的是曾经存在过的一个真实的产品,涉及到硬件,涉及到周边,并且已经开发成功了。只是因为政策,商业等原因,而最终倒闭,产品也就无用了。作为开发者,在学习和使用Qt的过程中也触碰了很多坑,犯过很多错误。

作为一个产品,让它默默无闻的消亡实在太可惜了,我将其总结了以下,剔除掉其中的敏感内容,仅保留和Qt程序设计相关的内容,回顾一下开发过程,希望能够对后来者有所帮助。

本书中,我会按照开发的顺序来回顾这个开发过程,从头开始构建这一产品。

关于本书内容

本书假定读者能够比较熟练的掌握现代C++的基本概念,有一定的开发经验,对Qt的基本概念也有基本的了解和实践。因此本书原则上不会对Qt的基础内容做太多的讨论,最多只是在用到时简单的说一下。

这里更关注的是一些稍微“高级”一点的内容。比如:

  • Model/View框架和Delegate
  • GraphicsView框架
  • Qt对象模型
  • 并发和对线程模型
  • 串口和网络访问
  • “现代”C++技术

在目前市面上能够见到的大多数书籍中,上述内容要么不常提及(比如QGraphicsView),要么过于老旧(比如并发和对线程模型),而本书则更关注这些方面,在探讨一个产品原型的设计过程中,尽可能的覆盖上述方方面面。

关于Qt学习

如果用户是Qt的新手,建议的一本入门参考书是《Qt 5.9 C++开发指南》,王维波,栗宝娟,侯春望等,人民邮电出版社,2018.5。这本书不管是纸件还是电子件,都很容易找到,很基础,适合入门者。虽然它有点老,但是对新手是足够了的。

当然,有一定基础之后,最好的学习手段是研究Qt自带的例子。阅读,理解,对照这接口文档,试着自己再独立实现一遍,相信会有很大的收获。至于CSDN,StackOverflow等,个人不是很建议。因为里面的东西良莠不齐,它适合在开发过程中的求助,而不是学习材料。

Qt应该做什么

和现代流行的GUI开发框架/工具比较起来,Qt,这里特指C++/Widget,其实是开发效率相当低效的技术。在开发效率上远远不如基于浏览器的一票技术,在界面美观性上与WPF相比也相距甚远。那么,作为开发者,我们选择Qt的目的是什么?这是每个CTO需要格外考虑的问题。作为程序员,也需要考虑走这条路,付出和收获是否匹配。如果程序员没有清醒的认识,选择了这条路,而又仅仅满足于拖拉两个按钮,双击鼠标写一个Click函数,

关于C++学习

进入2010年之后,C++的发展变化可谓是日新月异。从C++11开始,到C++14,C++17,C++20,C++23,三年一个小版本,六年一个大版本,令人眼花缭乱目不暇接。而Qt自Qt6.0之后,感觉也在尽量跟上C++的步伐。虽然从结果上看,Qt6.X并没有最初宣称的那样采用C++20,但是也是尽量提供与标准C++兼容的接口功能,这一点在Qt的容器类和多线程类中表现十分明显。我们会在后面详细介绍。

关于C++的参考书,个人推荐Marc Gregoire的《Professional C++,5th edition》,这是一本从头讲述C++20的,个人感觉比著名的C++ Primer更适合有一定基础的人,也更新一些。这本书的英文电子件也容易找到,另外中文版也已出版发行。

另外就是Scott Meyer的《Effect C++,3rd edition》和《Effective Modern C++》两本书。这两本书都已出版了多年,不管是中文版,英文版,纸件,电子件,都很容易获得。

如果对模板感兴趣,就是著名的《C++ Templates》第二版了。

如果对C++高级点的东西有兴趣,可以进一步看看《C++函数式编程》,这本书中英文都不难获得,再就是《C++20 高级编程》,罗望著。注意,是罗望写的那本,不是另外一本挂着“高级”名字的书。这两本书都不是教科书,更多的是开阔眼界,让你知道C++还能这样用的书。

本书的软件是应用软件,所以基本上不会涉及到玩模板甚至模板元编程的花活,基本上不会自己定义模板数据结构。STL容器库甚至也尽量不使用——这类东西编译起来实在是太耗时间。Qt本身是一个相当完备的框架,除了std的algorithm,绝大部分常用的东西都可以在里面找到。总的来说,但凡能够不用stl的,我们就不会使用。

本书我们使用Qt6.X版本(真实产品使用的是Qt5.12,既然是作为范例,当然是使用最新的啦),例子在Windows 10/11下编译通过,工具链使用Visual Studio 2022版本。Qt6使用的实际上是VS2019,我们会在第一章中介绍如何只安装Visual Studio 2022来使用Qt,避免在电脑中安装一堆的Visual Studio版本。

业务背景介绍

首先开始介绍一下这个项目的背景。医院诊断数字化是这些年来的一个重要的趋势,在此基础之上的AI辅助病理诊断在这些年也得到了越来越多的关注。这个项目就是AI辅助血液病诊断的一部分。

临床血细胞检验诊断学是临床检验诊断学的一个重要分支,是以各种血细胞检验计数为主要研究手段,以血液、骨髓、淋巴组织的疾病和相关疾病为研究对象的科学。在各种检验计数中,以血液和骨髓涂片细胞形态学为基础。血液和骨髓涂片的细胞形态结构清晰,各种生理与病理变化特点比较明确,标本制备渐变,显微镜观察形象直观,很多疾病通过在显微镜下观察各种血细胞的形态和数量可以得到初步印象或诊断及分类,并为进一步的检查提供依据和思路。

说的通俗一点,就是在显微镜下观察处理过的血液或骨髓涂片,根据血细胞长什么样子来进行分类,再根据各种血细胞的比例来给出诊断建议。

  • 血液病理学,【美】贾菲等编,陈刚,李小秋译,北京科学技术出版社,2013.12
  • 临床检验诊断学图谱,王建中,人民卫生出版社,2012.9

我们首先看一下在以前,纯人工时代,血液科做血细胞形态分析的病理医生的工作流程是什么样子的。

首先,门诊会对病人做血液采样。就血液病而言,血液可以粗略分成外周血和骨髓血两种。所谓外周血,可以理解为我们通常意义上的静脉抽血。而骨髓血,则是对病人做骨髓穿刺,得到的组织样本。我们直到骨髓是人体的造血器官,因此自然而然的,骨髓中的白细胞,淋巴细胞等的密度要比外周血中的要高出许多

医生拿到样本之后要做涂片,即将血液涂抹在载玻片上。这个过程根据样本的不同,目的的不同,医院的不同,也都会有不同的方式。传统的涂片方式,是将一滴血液滴在玻片上,用另一张玻片在上面轻轻缓慢的刮过去,将其摊薄,这一过程叫”推片”。在B站上有这方面的视频,有兴趣的读者可以去寻找。其他的样本,还会有一些其他的方式。例如,新生儿检测的羊水,有一种叫滴片的做法,是将标本从高处(一米五)滴到玻片上,利用液滴下落过程的力量将其摊开。对于骨髓,国外还有一种做法叫滚片,即将采集到的骨髓标本(是一根火柴棍粗细的组织)在玻片上滚动,将骨髓”粘”在玻片上。不同的做法,会造成玻片上样本的不同特点。现在,对外周血,已经有很成熟的推片机,可以得到十分均匀一致的标本。而对于骨髓,即使是推片,目前也基本上是手工做——据说主要是因为数量的原因——毕竟,不是每个病人都会没事做骨髓穿刺这种操作的。

完成涂片之后,要将样本进行培养和染色。不管是血细胞还是染色体,培养和染色都会对最终的结果识别产生很大的影响。不同的染色方案会将血细胞染成不同的颜色,而培养的药液和环境,则会极大的影响染色体分裂。这些都会严重影响最终的识别结果。

最终完成的玻片,经过晾干之后,会交给病理医生来分析。以外周血白细胞为例,医生在显微镜下观察。首先使用低倍显微镜(10倍)做全局观察,寻找一个合适的区域——有专门的规则——来计数。然后将显微镜切换到百倍,将镜油——国内都是使用百倍油镜,国外少数也使用干镜,这时就不涉及滴油了——滴到要观察的玻片区域,并切换到百倍镜,然后对焦,在镜下移动寻找白细胞并计数。按照WHO的标准,对外周血,需要在一个连续区域内寻找200个有效细胞,并做分类统计。如果你去医院,就会发现每个医生旁边有个计数器,医生一只手操作显微镜,一只手在按计数器。想想就直到这个工作是很累很低效的。即便我国医院一半对外周血分类下降到100个,比WHO的标准已经减少了一半,但是仍然是一个很复杂的工作,那么实际上的工作质量如何也就可以想象了。我们曾经遇到有人理直气壮地宣称自己看一个玻片只要一分半时间,对此我们也只能心知肚明是怎么回事了。

传统方法最大的问题其实是诊断结果无法重复和回溯。进入数字化时代后,有了所谓的显微镜图文系统,在显微镜上安装了数码相机,医生会挑选几张典型的视野拍照记录,粘贴到检验报告中,但是问题依然没有解决——他看的是哪个区域,看到了哪些细胞,细胞的分类对不对,和标准是否一致——都是无法复查的。这也造成了同一个细胞在不同的医院,甚至不同的医生,就会归到不同的分类中

我们曾经在某三甲医院请两位专家背靠背对同一批白细胞做分类,最终他们两人的分类一致性还不到80%。

随着数字图像技术的发展,细胞形态分析等基于显微图像的分析也逐渐走向数字化。在国内最早采用的应该是染色体分类。它与细胞形态分析类似,也是对血液或骨髓或羊水进行培养处理,寻找其中的染色体,并做每个染色体的分类。染色体分类要比血细胞形态学分类要复杂的多,一个人有46条染色体,在镜下这些染色体可能还会纠缠在一起,需要识别每条染色体,进行分类,寻找染色体异常或缺失等情况。它结合了显微镜数字图像拍摄和图像处理等领域,国外产品(如蔡司,徕卡等)在国内占有主流地位,其软件成为事实上的标准。近年来国内也逐渐出现了替代产品,更进一步辅以AI技术,实现自动分析,分类,而降低人员的负担。

我们要开发的产品是一个通用的显微扫描系统,它是显微图像诊断系统的一部分,实现自动化的扫描工作,完成从上片,选区,扫描,上传的自动化处理而不需要人干预。

扫描子系统是我们这个项目要开发的内容。它的职责,简单来说,就是要控制显微镜扫描设备,完成图像的选区,拍摄,打包,上传工作。整个系统流程如下所示:

1
2
3
4

graph LR
A1([开始]) --> A2([连接设备]) --> A3([玻片检测]) --> A4([取片]) --> A5([扫描玻片]) --> A6([上传数据]) --> A7([结束])
A6 --还有玻片--> A4

关于本书中的代码

命名约定

变量的命名也是一个长久的争论的话题,每个人都有自己的习惯,这种习惯往往是从一开始养成的,很难被修改的。本文基本的代码是我个人的使用风格,它其实是多种风格的混杂,主要的目的是让自己能够一眼看出这个是个什么东西,什么作用域。

这里的命名约定主要的目的是为了区分类属性和函数局部变量。其他的实际上我并不很在意。

  • 宏都是全大写字母和下划线(如果有)组成,一般不包含小写字母。例如,RETVAL_LOG_IF(...)

  • 全局常量定义大写字母开头的驼峰方式或全大写字母加下划线的方式,例如,const int IMAGE_WIDTH = 2448;

  • 类和结构都是大写字母开头的驼峰表示法,用于接口的抽象类会在类名前加I

  • 枚举类型以E开头,枚举成员以e开头,除非是约定俗成的情况。

  • 类方法和全局函数都是小写字母开头的驼峰表示法

    • 部分私有方法可能会用下划线开头
    • 属性封装,除非特殊情况,get一般会省略,而set一定会加上。比如,QString slideNo() constvoid setSlideNo(const QString&)。这个例外我们后面在讨论配置类实现的时候会见到。
    • 部分专用于调试的函数可能会用dbg开头
  • 类和结构的属性(不管私有还是共有),原则上都是下划线加小写字母开头,是驼峰还是连字方式不做限制

  • Creator生成的ui,如果是需要访问,一般都会改名,使用有意义的名称,名称会使用小写字母开头的驼峰方式。因为它们的访问一定要使用ui->,所以无论如何都不会和局部变量混淆

  • 由于个人习惯问题,部分类型的属性会使用匈牙利方式,比如,QAction会使用act开头,按钮类会使用btn开头,所有的signal会用sig开头,响应他们的slot会用onSig开头。像这种来自界面的东西,一旦定下来是不会改变的,也就根本不会存在所谓“匈牙利命名法不好”这种莫名其妙的断言了。

说到底,所有的一切是为了让自己和别人看的清楚就可以了。

调试宏

本书中有一些常用的宏,定义在dbgutil.h中。主要是流程控制和调试打印使用。

最常用的是TRACE(),它就是对qDebug()的封装,只是增加了__func__的输出。当然现在已经是C++20了,有兴趣的读者也可以用std::source_location来改写。这个想达到宏的水平是很难的,这个问题我在写自己的日志系统的时候曾经专门研究过,有时间的话也会包含进来。

1
2
#define TRACE()    qDebug() << "[" << __func__ << "(...)]"
#define TSHOW(a) #a":=" << a << ", "

再就是一些流程控制的宏,例如RETVAL_LOG_IF()

1
2
3
4
5
6
#define RETVAL_LOG_IF(condition, val, msg) do{  \
if( condition){ \
TRACE() << msg; \
return val; \
} \
}while(0)

定义这些宏的目的是为了能够在一行中写满简单的流程控制,这样代码更清晰易懂。如果有时间,也会考虑引入一些现代C++的错误处理的范式进来。

另外一个比较有用的调试宏是DEC_TRACER()。它利用RAII技术来在函数进入和退出时打印调试信息,篇幅太长,具体参见代码。

其他

本书中的代码是原型演示的代码,为节省篇幅,在书中的代码删除了绝大部分的异常和错误处理的代码。为了便于理解,在代码库中的代码也没有考虑很多的异常情况。在实际使用中,错误检查和异常捕获是很重要的一环。