目标读者:已经看完前面多期(信号槽、多线程、文件、网络、数据库、模型视图、插件化等),正在做中小型桌面项目,开始明显感觉"项目结构扛不住了"的 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 大杂烩结构下,你一般要改:
- 数据库表结构:加
tags字段; - 所有 SQL:
INSERT、SELECT、UPDATE都要加上/改掉; - UI:增加输入框/多选框,并在添加/编辑任务时处理它;
- 导出/导入:如果项目里有 JSON/配置导出功能,也要改;
- 可能还有网络同步 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 桌面架构模板
这期的工程示例,实际上给出了一套可以直接复用的基础模板:
-
分层清晰
src/ui:MainWindow 等界面类,只关心展示与交互;src/services:TaskService 等业务服务,对 UI 暴露干净的接口;src/data:Storage 等数据访问,对服务隐藏实现细节;src/models:Task 等业务模型,不带 UI 和持久化逻辑;src/infrastructure:Logger、配置等基础设施。
-
依赖方向单一
- UI 依赖 Service 接口;
- Service 依赖 Storage 接口和基础设施;
- Storage 依赖具体技术组件(文件/SQLite/网络)。
-
扩展简单
- 加"任务标签"字段,只需修改 Task 模型 + Storage 实现 + 少量 UI;
- 替换存储方式(内存 → 文件 → 数据库)只需调整 AppContext;
- 后续如果要加网络同步,只需新建一个 SyncService,订阅 TaskService 信号即可。
-
开发节奏平衡
- 不追求"满嘴模式词",只强调能解决真实问题的实践;
- 不鼓励为教学而教学的架构编码,所有抽象都有实际用处。
你可以直接把本期的工程结构照搬到自己的项目中,把原先堆在 MainWindow 里的逻辑,一点点往服务层和数据层迁移。哪怕刚开始只是把数据库操作搬到 Storage 里,都是一个很大的进步。
等你习惯了这样的写法,再回头看以前"所有逻辑写在 UI 里"的代码,就会直观地感受到:清晰的架构,不只是"好看",而是能让你在项目第三年、第四年还能安心维护、不怕改需求的底气。