Qt/C++ 模块化设计与工程组织

第一阶段 · 第 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();
    }
};

这种设计有两个问题:

  1. TestService 被固定绑定到 CameraIR640。
  2. 进行单元测试时,很难用模拟相机替代真实设备。
    更合理的方式是定义接口。
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。

八、本课总结

本课最值得记住的四点:

  1. 高内聚、低耦合:模块围绕明确职责组织。
  2. 依赖接口:业务逻辑不直接绑定具体硬件型号。
  3. 减少头文件依赖:合理使用前置声明,避免无意义的传递包含。
  4. 逐步模块化:先明确目录和接口,再根据复用、测试和独立构建需求决定是否拆库。
    这也能帮助你排查大型 Qt 项目中的编译依赖、循环引用和修改一个头文件导致大量重新编译的问题。
    九、课后练习
    问题:TestService 只保存一个 ICamera* 成员,头文件里并不调用任何 ICamera 成员函数。为了减少编译依赖,通常怎么做?

A. 必须在头文件中包含所有相机实现类

B. 在头文件中前置声明 ICamera,在 .cpp 中包含 ICamera.h

C. 把 ICamera 改成全局变量

D. 所有类都放进同一个头文件

提交答案

下一阶段可以进入 现代 C++ 工程实践:从 RAII、智能指针、对象所有权和生命周期开始。这些知识会直接帮助你减少 Qt 工程中的内存泄漏、悬空指针和资源释放问题。

相关推荐
Rabitebla1 小时前
【Linux底层原理】 工具篇(二):Vim 编辑器基础操作与简单配置
linux·c++·编辑器·vim
阿里嘎多学长1 小时前
2026-10-23 GitHub 热点项目精选
开发语言·程序员·github·代码托管
Lhan.zzZ1 小时前
Qt:动态库模块框架设计
开发语言·c++·qt
quantdash_cc1 小时前
如何从多只股票的历史行情构建收益率矩阵?Python 与 QuantDash 实战教程
开发语言·python·数据分析·pandas·量化交易·股票数据·quantdash
wuminyu1 小时前
JVM规范学习总结-JVM规范基本概念
java·linux·c语言·jvm·c++
Demon--hx2 小时前
jsoncpp 核心概念梳理:命名空间、Value 容器与格式化输出
c++
不是万能的豆学长5 小时前
filetidy 文件整理助手:一键归类 + 规范重命名(Python 零依赖单文件工具)
大数据·开发语言·python·技术美术
懂点Tech10 小时前
ctfshow MISC入门图片篇(信息附加):misc41解题思路
开发语言·python
catchadmin11 小时前
PHP 8.7 协程新提案 让跨协程代码共享请求身份
开发语言·php