Qt5 进阶【13】桌面 Qt 项目架构设计:从 MVC/MVVM 到模块划分

目标读者:已经看完前面多期(信号槽、多线程、文件、网络、数据库、模型视图、插件化等),正在做中小型桌面项目,开始明显感觉"项目结构扛不住了"的 Qt/C++ 开发者。

示例环境:Qt 5.12/5.15 + Qt Creator + QMake。

目录

[一、问题背景:当 MainWindow 变成"上帝类"](#一、问题背景:当 MainWindow 变成“上帝类”)

[1. UI 层充斥数据库访问、网络请求和业务逻辑](#1. UI 层充斥数据库访问、网络请求和业务逻辑)

[2. 改一个字段,要从 UI 层一路改到底层](#2. 改一个字段,要从 UI 层一路改到底层)

[3. 模块间循环依赖严重](#3. 模块间循环依赖严重)

[二、核心概念:从 MVC/MVP/MVVM 到"够用的分层"](#二、核心概念:从 MVC/MVP/MVVM 到“够用的分层”)

[1. 不要被名词吓住:先搞清"谁干什么"](#1. 不要被名词吓住:先搞清“谁干什么”)

[2. 分层结构:四层足够应付大部分桌面项目](#2. 分层结构:四层足够应付大部分桌面项目)

[3. 使用接口 + 信号槽解耦模块](#3. 使用接口 + 信号槽解耦模块)

[4. 简单依赖注入:构造注入 + 小工厂](#4. 简单依赖注入:构造注入 + 小工厂)

三、代码实战:小型任务管理系统的架构重构

[1. 工程说明](#1. 工程说明)

[2. 反例版本(简要):所有逻辑在 MainWindow 里](#2. 反例版本(简要):所有逻辑在 MainWindow 里)

[3. 模型层:Task 实体](#3. 模型层:Task 实体)

[4. 数据访问层:Storage 接口 + 内存/文件实现](#4. 数据访问层:Storage 接口 + 内存/文件实现)

[4.1 Storage 接口](#4.1 Storage 接口)

[4.2 内存实现(用于快速演示和测试)](#4.2 内存实现(用于快速演示和测试))

[4.3 文件实现(JSON 文件持久化)](#4.3 文件实现(JSON 文件持久化))

[5. 服务层:TaskService(业务逻辑)](#5. 服务层:TaskService(业务逻辑))

[6. 基础设施层:Logger](#6. 基础设施层:Logger)

[7. UI 层:MainWindow(只做展示与交互)](#7. UI 层:MainWindow(只做展示与交互))

[8. AppContext + main.cpp](#8. AppContext + main.cpp)

四、实战中的坑与优化

[1. 过度引入复杂模式(全套 DDD)](#1. 过度引入复杂模式(全套 DDD))

[2. 所有模块都用单例,全局状态混乱](#2. 所有模块都用单例,全局状态混乱)

[3. 信号槽连接到处散落](#3. 信号槽连接到处散落)

[五、小结:一个"够用"的 Qt 桌面架构模板](#五、小结:一个“够用”的 Qt 桌面架构模板)


一、问题背景:当 MainWindow 变成"上帝类"

做 Qt 桌面项目,一开始往往是这样的:

  • 新建一个 QMainWindow
  • 拖几个按钮、表格、输入框;
  • 把所有逻辑都写在 mainwindow.cpp 里。

比如做一个"任务管理"小工具:

  • 添加任务:在按钮槽里直接操作数据库;
  • 删除任务:在表格的右键菜单槽里直接执行 DELETE
  • 修改任务:在编辑框的槽里直接更新数据库,再刷新表格;
  • 后来要同步到服务器,又在 MainWindow 里 new 了一个 QNetworkAccessManager

写着写着, MainWindow 变成了一个包含 UI + 业务 + 数据库 + 网络的大杂烩:

  • mainwindow.h 引入半个项目的头文件(QtSql, QtNetwork, 各种 model/struct);
  • mainwindow.cpp 一千多行起步,各种槽函数互相调用;
  • 改一个字段名,既要改 SQL,又要改 UI、还要改网络 JSON 解析。

典型的痛点有几个:

1. UI 层充斥数据库访问、网络请求和业务逻辑

看到这样的代码,你应该会有点熟悉:

cpp 复制代码
void MainWindow::on_addTaskButton_clicked()
{
    if (ui->nameEdit->text().isEmpty()) {
        QMessageBox::warning(this, tr("错误"), tr("任务名称不能为空"));
        return;
    }

    // 业务逻辑 + 数据封装
    Task t;
    t.name = ui->nameEdit->text();
    t.description = ui->descEdit->toPlainText();
    t.priority = ui->priorityCombo->currentIndex();
    t.createdAt = QDateTime::currentDateTime();
    t.completed = false;

    // 持久化逻辑
    QSqlDatabase db = QSqlDatabase::database();
    QSqlQuery query(db);
    query.prepare("INSERT INTO tasks(name, description, priority, completed, created_at) "
                  "VALUES(:name, :desc, :pri, :comp, :created)");
    query.bindValue(":name", t.name);
    // ... 绑定其他字段
    if (!query.exec()) {
        QMessageBox::critical(this, tr("数据库错误"), query.lastError().text());
        return;
    }

    // UI 更新逻辑
    int row = ui->taskTable->rowCount();
    ui->taskTable->insertRow(row);
    ui->taskTable->setItem(row, 0, new QTableWidgetItem(t.name));
    // ...
}

一眼看过去:

  • UI 校验;
  • 业务字段组装;
  • 直接操作数据库;
  • 最后更新表格。

所有东西搅在一起。一旦任务表结构变化,或者改成用文件/网络持久化,你就得挖开这一坨重新改。

2. 改一个字段,要从 UI 层一路改到底层

比如某天产品提了一个很"合理"的需求:

"任务需要有标签(tags),一个任务可以有多个标签。"

在这种 MainWindow 大杂烩结构下,你一般要改:

  1. 数据库表结构:加 tags 字段;
  2. 所有 SQL:INSERTSELECTUPDATE 都要加上/改掉;
  3. UI:增加输入框/多选框,并在添加/编辑任务时处理它;
  4. 导出/导入:如果项目里有 JSON/配置导出功能,也要改;
  5. 可能还有网络同步 API 的字段要一起调整。

最要命的是,这些逻辑散落在不同函数里 ------你只能全工程搜索 "tags""description" 之类的关键字,一个个对。

3. 模块间循环依赖严重

随着项目长大,从一个 MainWindow 分裂出多个模块:

  • TaskManager
  • UserManager
  • StatisticsPanel
  • SyncManager 等。

但每个模块都想"顺手"访问别人的状态/方法,结果:

  • TaskManager 包含了 UserManager 的头文件;
  • SyncManager 又要包含 TaskManager
  • 想抽一个 TaskService 出来,又被各种 UI include 包围。

前期为了"写得快",没想过边界,后期就会感受到循环依赖带来的痛苦。

这一期,我们就用一篇文章 + 一个完整的工程示例,把下面这几个问题解决掉:

  • 如何在 Qt Widgets 项目里实际落地 MVC/MVP/MVVM 思路(不教条);
  • 如何划清 UI / 业务 / 数据访问 / 基础设施的层次;
  • 如何用"接口 + 信号槽"解耦模块;
  • 如何为以后扩展预留空间,又不过度设计。

二、核心概念:从 MVC/MVP/MVVM 到"够用的分层"

1. 不要被名词吓住:先搞清"谁干什么"

在桌面 Qt 应用里,常见模式可以简单理解为:

  • MVC
    • Model:数据/业务模型;
    • View:界面展示;
    • Controller:处理用户输入,调用 Model。
  • MVP
    • Model:数据/业务模型;
    • View:只负责显示/简单用户输入;
    • Presenter:同时依赖 Model 和 View,负责调度逻辑。
  • MVVM
    • Model:数据模型;
    • View:界面;
    • ViewModel:暴露适合绑定的属性和命令(在 Qt Widgets 下常见于配合 Model/View 和属性系统的用法)。

在传统 Qt Widgets 项目里,不必死抠名字,更实际的做法是:

层次来想:UI 层 / 业务服务层 / 数据访问层 / 基础设施层。

可以把这四层粗略映射为:

  • UI 层 ≈ View;
  • 业务服务层 ≈ Presenter / ViewModel / Controller(看你怎么理解);
  • 数据访问层 ≈ Repository / DAO;
  • 基础设施层 ≈ 技术支撑(日志、配置、网络底座等)。

2. 分层结构:四层足够应付大部分桌面项目

​(1)UI 层(src/ui)​

  • 只负责与用户交互:按钮、表格、对话框;
  • 只能通过接口 + 信号槽调用业务服务层;
  • 不允许出现场景:
    • QSqlDatabase / SQL 字符串;
    • 具体 HTTP URL / JSON 字段名;
    • 文件格式细节(例如 XML 结构)。

​(2)业务服务层(src/services)​

  • 封装完整的业务用例:添加任务、删除任务、切换完成状态、按条件查询任务等;
  • 对上:暴露清晰接口(ITaskService)给 UI;
  • 对下:依赖数据访问层接口(IStorage),不关心是 SQLite/JSON 文件/内存;
  • 可以通过信号槽向 UI 发通知(例如任务列表更新)。

​(3)数据访问层(src/data 或 src/repositories)​

  • 定义数据存取接口(IStorage / ITaskRepository),实现可能是:
    • MemoryStorage(内存版);
    • FileStorage(JSON/XML/INI 文件版);
    • SqliteStorage(数据库版);
    • 甚至是 HttpStorage(远程 API)。
  • 对业务层隐藏所有持久化细节。

​(4)基础设施层(src/infrastructure)​

  • 日志:Logger
  • 配置:ConfigManager
  • 网络底座封装:NetworkClient
  • 其他通用工具类。

这四层之间的依赖方向应尽量是单向的

bash 复制代码
UI  →  Service  →  Data  →  Infrastructure

或者:

bash 复制代码
UI  →  Service
    ↘
      Infrastructure

绝不允许反向依赖,比如:

  • 数据层 include 某个 QDialog;
  • 服务层直接依赖具体 UI 控件类型;

否则很快就会回到"耦成一团"的状态。

3. 使用接口 + 信号槽解耦模块

以 TaskService 为例:

cpp 复制代码
class ITaskService
{
public:
    virtual ~ITaskService() = default;

    virtual void addTask(const Task &task) = 0;
    virtual void deleteTask(int id) = 0;
    virtual QList<Task> allTasks() const = 0;
};

class TaskService : public QObject, public ITaskService
{
    Q_OBJECT
public:
    explicit TaskService(IStorage *storage, QObject *parent = nullptr);

    void addTask(const Task &task) override;
    void deleteTask(int id) override;
    QList<Task> allTasks() const override;

signals:
    void taskAdded(const Task &task);
    void taskDeleted(int id);
    void taskListChanged();
};

UI 层只持有一个 ITaskService* 指针,不关心它背后有没有发信号。

UI 既可以用"直接调用 + 返回值"的方式,也可以订阅服务层的信号做响应。

4. 简单依赖注入:构造注入 + 小工厂

桌面应用一般不需要上复杂 IOC 容器,最实用的是:

  • 利用构造注入,在 main 函数或 AppContext 中把依赖拼起来;
  • 使用简单工厂统一构建服务实例,方便替换实现。

例子:

cpp 复制代码
class AppContext
{
public:
    static AppContext &instance();

    ITaskService *taskService() const { return m_taskService; }

private:
    AppContext();
    ~AppContext();

    ITaskService *m_taskService = nullptr;
    IStorage *m_storage = nullptr;
};

main.cpp

cpp 复制代码
int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    MainWindow w(AppContext::instance().taskService());
    w.show();

    return app.exec();
}

UI 层完全不需要 new TaskService,也不关心它具体连的是 MemoryStorage 还是 FileStorage,都在 AppContext 里统一管理。


三、代码实战:小型任务管理系统的架构重构

下面我们用一个完整的 QtCreator 工程来展示从"反例"到"重构后"的过程。

1. 工程说明

工程名:TaskManagerArchDemo

目录结构示例:

bash 复制代码
TaskManagerArchDemo/
├── CMakeLists.txt
└── src/
    ├── main.cpp
    ├── appcontext.h
    ├── appcontext.cpp
    ├── models/
    │   ├── task.h
    │   └── task.cpp
    ├── data/
    │   ├── istorage.h
    │   ├── memorystorage.h
    │   ├── memorystorage.cpp
    │   ├── filestorage.h
    │   └── filestorage.cpp
    ├── services/
    │   ├── itaskservice.h
    │   ├── taskservice.h
    │   └── taskservice.cpp
    ├── infrastructure/
    │   ├── logger.h
    │   └── logger.cpp
    └── ui/
        ├── mainwindow.h
        ├── mainwindow.cpp
        └── mainwindow.ui

可以直接在 Qt Creator 中以 CMake 项目打开编译运行。代码篇幅太多不再粘贴,需要的可以找我要,之后会上传到资源。


2. 模型层:Task 实体

src/models/task.h

src/models/task.cpp

模型层只关心数据结构和基本序列化,不涉及 UI、数据库、文件等。


3. 数据访问层:Storage 接口 + 内存/文件实现

3.1 Storage 接口

src/data/istorage.h

3.2 内存实现(用于快速演示和测试)

src/data/memorystorage.h

src/data/memorystorage.cpp

3.3 文件实现(JSON 文件持久化)

src/data/filestorage.h

src/data/filestorage.cpp


4. 服务层:TaskService(业务逻辑)

src/services/itaskservice.h

src/services/taskservice.h

src/services/taskservice.cpp


5. 基础设施层:Logger

src/infrastructure/logger.h

src/infrastructure/logger.cpp


6. UI 层:MainWindow

src/ui/mainwindow.h

src/ui/mainwindow.cpp


7. AppContext + main.cpp

src/appcontext.h

src/appcontext``.cpp

src/main.cpp


四、实战中的坑与优化

1. 过度引入复杂模式(全套 DDD)

有的项目一上来就照书画葫芦:

  • 每个模块都要划分"领域层""应用层""仓储层""防腐层";
  • 动辄几十个接口和类;
  • 实际业务就一个简单任务管理。

结果是:

  • 代码量蹭蹭往上涨;
  • 团队成员搞不清这么多层的边界;
  • 开发效率反而更慢。

经验

  • 中小型桌面项目完全没必要全套 DDD;
  • 分层 + 接口隔离 + 模块边界做好,就足以应付大部分需求;
  • 真正需要 DDD 的是"跨团队、大规模、多年演进的大系统"。

2. 所有模块都用单例,全局状态混乱

常见写法:

cpp 复制代码
class Global {
public:
    static Global& instance() {
        static Global g;
        return g;
    }
    TaskService taskService;
    FileStorage storage;
    // ...
};

然后项目里到处:

cpp 复制代码
Global::instance().taskService.addTask(...);

问题:

  • 所有东西都变成"全局变量",生命周期混乱;
  • 单元测试无法隔离状态;
  • 多线程容易踩坑(未正确加锁)。

建议

  • 日志这类真正全局的横切关注点可以单例;
  • 业务服务、数据访问尽量通过 AppContext 或构造注入管理;
  • 在测试环境可以替换 AppContext 内部的实现,而不是全局单例唯一一份。

3. 信号槽连接到处散落

如果你在很多地方写:

cpp 复制代码
connect(taskService, &TaskService::taskAdded,
        mainWindow, &MainWindow::onTaskAdded);
// ...
connect(taskService, &TaskService::taskAdded,
        statisticsPanel, &StatisticsPanel::refreshChart);
// ...

久而久之,你根本不知道一个信号会触发多少槽,调试起来很头大。

做法

  • 尽量让信号的"订阅关系"集中在少数几个地方(比如 AppContext 或组装模块);
  • 同一个信号不要在项目各处零散 connect,尤其跨模块;
  • 可以考虑用一个简单的"事件总线(EventBus)"或"中介者(Mediator)"来管理跨模块信号转发。

五、小结:一个"够用"的 Qt 桌面架构模板

这期的工程示例,实际上给出了一套可以直接复用的基础模板:

  1. 分层清晰

    • src/ui:MainWindow 等界面类,只关心展示与交互;
    • src/services:TaskService 等业务服务,对 UI 暴露干净的接口;
    • src/data:Storage 等数据访问,对服务隐藏实现细节;
    • src/models:Task 等业务模型,不带 UI 和持久化逻辑;
    • src/infrastructure:Logger、配置等基础设施。
  2. 依赖方向单一

    • UI 依赖 Service 接口;
    • Service 依赖 Storage 接口和基础设施;
    • Storage 依赖具体技术组件(文件/SQLite/网络)。
  3. 扩展简单

    • 加"任务标签"字段,只需修改 Task 模型 + Storage 实现 + 少量 UI;
    • 替换存储方式(内存 → 文件 → 数据库)只需调整 AppContext;
    • 后续如果要加网络同步,只需新建一个 SyncService,订阅 TaskService 信号即可。
  4. 开发节奏平衡

    • 不追求"满嘴模式词",只强调能解决真实问题的实践;
    • 不鼓励为教学而教学的架构编码,所有抽象都有实际用处。

你可以直接把本期的工程结构照搬到自己的项目中,把原先堆在 MainWindow 里的逻辑,一点点往服务层和数据层迁移。哪怕刚开始只是把数据库操作搬到 Storage 里,都是一个很大的进步。

等你习惯了这样的写法,再回头看以前"所有逻辑写在 UI 里"的代码,就会直观地感受到:清晰的架构,不只是"好看",而是能让你在项目第三年、第四年还能安心维护、不怕改需求的底气。

相关推荐
zhangx1234_2 小时前
C语言 数据在内存中的存储
c语言·开发语言
星空露珠2 小时前
速算24点检测生成核心lua
开发语言·数据库·算法·游戏·lua
老蒋每日coding2 小时前
Python3基础练习题详解,从入门到熟练的 50 个实例(一)
开发语言·python
java干货2 小时前
微服务:把一个简单的问题,拆成 100 个网络问题
网络·微服务·架构
历程里程碑2 小时前
Linux15 进程二
linux·运维·服务器·开发语言·数据结构·c++·笔记
lly2024062 小时前
网站主机提供商:如何选择最适合您的服务
开发语言
HAPPY酷2 小时前
构建即自由:一份为创造者设计的 Windows C++ 自动化构建指南
开发语言·c++·ide·windows·python·策略模式·visual studio
工一木子2 小时前
Java 的前世今生:从 Oak 到现代企业级语言
java·开发语言
xiaoye-duck2 小时前
C++ string 底层原理深度解析 + 模拟实现(上)——面试 / 开发都适用
c++·面试·stl