第一阶段 · 第 5 课:Qt/C++ 模块化设计与工程组织
前面四课,我们学习了面向对象设计原则、依赖注入、分层架构,以及 MVC、MVP、MVVM。
这一课把这些知识落到实际工程中:一个 Qt 项目应该如何划分模块、组织头文件、管理依赖,避免改一个功能就牵连整个工程?
我们继续使用你熟悉的 Qt/C++ 设备控制项目作为例子。
一、什么是模块化设计?
假设你的项目包含红外相机、Keithley 电源、温度控制器、TCP 通信和测试界面。
一种不太好的组织方式是把所有类都放在一个目录中:
bash
src/
├── FormCamera.h
├── FormPower.h
├── CameraIR640.h
├── Power2657.h
├── TcpClient.h
├── TemperatureControl.h
├── TestManager.h
└── ...
随着项目扩大,类之间容易形成复杂的依赖关系:
- 界面直接操作底层通信。
- 相机模块引用电源模块。
- 测试逻辑直接修改界面控件。
- 修改公共头文件后,大量文件重新编译。
模块化设计的目标不是单纯把文件放进不同文件夹,而是让每个模块具有明确的职责、清晰的接口和尽可能少的依赖。
二、推荐的 Qt 工程结构
UI 模块
FormCamera、FormPower、FormTest
负责界面显示与用户交互
业务模块
CameraController、PowerController、TestService
负责设备协调、测试流程和业务规则
设备与通信模块
CameraIR640、Power2657、TcpClient、SerialPort
负责硬件操作、通信协议和数据收发
公共模块
Logger、配置、通用数据结构、基础工具
提供稳定、可复用的公共能力
这里的箭头代表常见的调用方向,不意味着底层设备代码必须知道 UI 的存在。设备状态可以通过接口、回调或 Qt 信号槽向上层传递。
三、模块设计的第一个原则:高内聚、低耦合
这两个词在工程设计中非常重要。
- 高内聚:一个模块里的功能围绕同一个职责组织。
- 低耦合:一个模块尽量不依赖其他模块的内部实现。
例如,下面这个类的职责就有些混乱:
cpp
class CameraManager
{
public:
void connectCamera();
void captureImage();
void saveImage();
void setPowerVoltage(double voltage);
void updateMainWindow();
void saveTestConfig();
};
它既负责相机,又负责电源、窗口和配置,修改任何一部分都可能影响其他功能。
可以将其拆分为:
cpp
class CameraController
{
public:
void startCapture();
};
class ImageSaver
{
public:
bool saveImage(const QString& path);
};
class PowerController
{
public:
bool setVoltage(double voltage);
};
class TestConfig
{
public:
void save();
};
但要注意:并不是类拆得越多越好。如果两个功能总是一起变化、职责紧密相关,强行拆分反而会增加复杂度。拆分的标准是职责边界,而不是每个函数都单独建一个类。
四、第二个原则:面向接口,而不是依赖具体实现
假设你的 TestService 直接包含具体相机类:
cpp
#include "CameraIR640.h"
class TestService
{
public:
void start()
{
CameraIR640 camera;
camera.capture();
}
};
这种设计有两个问题:
- TestService 被固定绑定到 CameraIR640。
- 进行单元测试时,很难用模拟相机替代真实设备。
更合理的方式是定义接口。
cpp
class ICamera
{
public:
virtual ~ICamera() = default;
virtual bool capture() = 0;
};
具体相机实现接口:
cpp
class CameraIR640 : public ICamera
{
public:
bool capture() override
{
// 实际采集逻辑
return true;
}
};
业务层依赖接口:
cpp
class TestService
{
public:
explicit TestService(ICamera* camera)
: m_camera(camera)
{
}
bool start()
{
return m_camera && m_camera->capture();
}
private:
ICamera* m_camera; // 非拥有指针
};
这样,测试时可以注入模拟对象,运行时可以注入真实相机。
cpp
class FakeCamera : public ICamera
{
public:
bool capture() override
{
return true;
}
};
在不连接硬件的情况下,就可以测试 TestService 的调用流程。
五、第三个原则:减少头文件依赖
这是 Qt/C++ 大型工程中非常实用的一项优化。
1. 不推荐:在头文件中包含大量完整定义
cpp
#include "CameraIR640.h"
#include "Power2657.h"
#include "TcpClient.h"
#include "ImageProcessor.h"
class TestService
{
// ...
};
如果这些头文件又包含其他头文件,依赖关系就会不断扩散,编译时间也可能变长。
2. 优先考虑前置声明
当头文件中只需要使用某个类的指针或引用时,可以前置声明:
cpp
// TestService.h
#pragma once
class ICamera;
class TestService
{
public:
explicit TestService(ICamera* camera);
~TestService();
bool start();
private:
ICamera* m_camera;
};
在 .cpp 中包含真正需要的头文件:
cpp
// TestService.cpp
#include "TestService.h"
#include "ICamera.h"
TestService::TestService(ICamera* camera)
: m_camera(camera)
{
}
TestService::~TestService() = default;
bool TestService::start()
{
return m_camera && m_camera->capture();
}
这样可以减少头文件之间的耦合。
有两个细节需要注意:
- 如果要继承一个类、访问它的成员,或者在头文件中按值保存一个对象,通常就需要完整类型定义。
- 如果类中保存 std::unique_ptr,而 ICamera 是不完整类型,通常应把拥有该指针的类的析构函数放到 .cpp 中定义,并在该处包含完整类型定义。
3. Qt 的 QObject 类也适用吗?
适用,但有额外注意事项。
cpp
class CameraController;
class FormCamera : public QWidget
{
Q_OBJECT
public:
explicit FormCamera(QWidget* parent = nullptr);
~FormCamera();
private:
CameraController* m_controller = nullptr;
};
当 FormCamera 需要调用 CameraController 的成员函数时,应在 FormCamera.cpp 中包含其完整头文件。
如果需要在头文件里使用 connect() 连接该类的成员函数,模板实例化可能需要完整类型,因此通常把相应连接代码放在 .cpp 中更稳妥。
六、第四个原则:合理组织 .pro 文件
对于你使用的 Qt 5.12 / qmake 工程,可以按模块组织文件。
例如:
cpp
QT += core gui widgets network
CONFIG += c++14
TEMPLATE = app
TARGET = PowerApp
INCLUDEPATH += \
$$PWD/common \
$$PWD/device \
$$PWD/communication \
$$PWD/business \
$$PWD/ui
SOURCES += \
main.cpp \
business/PowerController.cpp \
business/TestService.cpp \
device/Power2657.cpp \
communication/TcpClient.cpp \
ui/FormPower.cpp
HEADERS += \
common/Logger.h \
device/IPowerDevice.h \
device/Power2657.h \
communication/TcpClient.h \
business/PowerController.h \
business/TestService.h \
ui/FormPower.h
FORMS += \
ui/FormPower.ui
实际项目可以进一步拆分 .pri 文件:
cpp
include(common/common.pri)
include(communication/communication.pri)
include(device/device.pri)
include(business/business.pri)
include(ui/ui.pri)
例如 device/device.pri:
cpp
INCLUDEPATH += $$PWD
HEADERS += \
$$PWD/IPowerDevice.h \
$$PWD/Power2657.h
SOURCES += \
$$PWD/Power2657.cpp
这里的 $$PWD 是 qmake 当前处理文件时的目录。拆分后要留意相对路径,特别是子目录里的 .pri 文件。
如果模块需要被多个应用复用,可以进一步将它构建成独立的静态库或 DLL,而不是把所有模块都放进同一个应用工程。
七、什么时候应该拆成独立库?
可以参考下面的判断。
情况 推荐方式
仅当前应用使用的小功能 同一个工程,按目录和 .pri 拆分
多个程序共用的 TCP 通信库 独立静态库或 DLL
多型号设备共用的协议接口 独立设备模块
依赖第三方库的图像算法 独立算法模块,明确输入输出
需要单独测试、版本管理的核心业务 独立库或独立测试目标
独立库会带来接口稳定性、编译配置、链接、部署和版本管理等额外工作,所以不需要一开始就把所有模块都拆成 DLL。
八、本课总结
本课最值得记住的四点:
- 高内聚、低耦合:模块围绕明确职责组织。
- 依赖接口:业务逻辑不直接绑定具体硬件型号。
- 减少头文件依赖:合理使用前置声明,避免无意义的传递包含。
- 逐步模块化:先明确目录和接口,再根据复用、测试和独立构建需求决定是否拆库。
这也能帮助你排查大型 Qt 项目中的编译依赖、循环引用和修改一个头文件导致大量重新编译的问题。
九、课后练习
问题:TestService 只保存一个 ICamera* 成员,头文件里并不调用任何 ICamera 成员函数。为了减少编译依赖,通常怎么做?
A. 必须在头文件中包含所有相机实现类
B. 在头文件中前置声明 ICamera,在 .cpp 中包含 ICamera.h
C. 把 ICamera 改成全局变量
D. 所有类都放进同一个头文件
提交答案
下一阶段可以进入 现代 C++ 工程实践:从 RAII、智能指针、对象所有权和生命周期开始。这些知识会直接帮助你减少 Qt 工程中的内存泄漏、悬空指针和资源释放问题。