接手过一个别人留下的 Qt 桌面项目。MainWindow.cpp 六千多行,按钮的槽函数里直接写数据库查询,UI 线程上跑网络请求,一个 QTableWidget 塞进去几万行数据,拖动滚动条都能感觉到明显的迟滞。代码不是不能跑,而是没人敢改——你永远不知道改一个信号连接,会牵出多少个隐含的依赖。我相信不少人都经历过类似阶段:项目一开始只有三五个窗口,一个类两千行还能 hold 住,等业务越加越多,代码腐化速度会远超你的重构速度。
今天这篇,我想把自己在 Qt 桌面项目架构设计上的完整思考梳理一遍,从 MVC 到 MVVM,再到模块划分。这不是一篇纯理论科普,更多是我在实际项目里踩过坑、走过弯路之后沉淀下来的实操经验。如果你正在维护一个"能跑但难改"的 Qt 项目,或者准备从零搭一个稍微有点规模的桌面应用,这篇内容应该对你有用。
1. 先说说"为什么要架构":一份六千行的 MainWindow.cpp 让我睡不着
很多人最开始写 Qt 项目,习惯特别直接:需要什么控件,拖一个到界面上,双击按钮生成槽函数,然后在槽函数里写业务逻辑。这个套路在小工具项目里完全没问题,因为整个程序的复杂度就那么一点,signal 和 slot 之间的对应关系一目了然。
但项目一旦越过某个规模阈值,这套"快速开发"的打法就会反噬。
1.1 从信号槽堆砌到界面卡顿:架构缺失的第一张多米诺骨牌
我接手那个六千行 MainWindow 的项目时,先做了一件事:把所有槽函数列出来,整理它们的调用关系。整理到第三天我放弃了,因为很多槽函数内部还会调用其他私有函数、弹模态对话框、直接执行 SQL、组装 Excel 导出报表。信号槽连接本身是松耦合的,但如果每个槽函数里塞满了跨层逻辑,这种松耦合反而成了灾难——你根本不知道哪个信号会导致什么样的副作用。
典型的症状包括:
- 界面卡顿。耗时操作直接放在 UI 线程,比如在槽函数里执行
QSqlQuery::exec()或者发送 HTTP 请求,界面整个冻住。 - 数据一致性难保证。多个窗口都可以修改同一份数据,但每个窗口各自用自己的方式读写,缺少统一的数据状态管理。
- 业务逻辑无法复用。同样的校验规则在两个窗口里各写一遍,而且还不完全一致,改了一处忘了另一处。
- 无法测试。业务逻辑跟
QWidget强绑定,想写单元测试都不知道从哪下手。
这几个问题一旦同时出现,项目就进入了"改一个 bug 引入两个新 bug"的恶性循环。你会发现自己加班越来越多,但产品质量越来越差。
1.2 架构目标不是"好看",是这四个能力
后来我慢慢意识到,架构设计不是为了在代码评审时画出漂亮的 UML 图,而是为了换取四个非常实际的能力:
- 可维护性:新增一个功能时,你能明确知道该去改哪个文件、哪一层,而不是全局搜索然后凭感觉下手。
- 可测试性:核心业务逻辑不依赖具体界面,可以在没有 UI 的环境下跑单元测试。
- 可替换性:换数据库、换第三方 SDK、换 UI 风格时,影响面被限制在一个模块内部。
- 可扩展性:加新功能时,不需要动已有模块的核心代码,优先通过新增代码来扩展。
打个比方:架构就像房子的承重墙和管线布置。装修(按钮样式、字体颜色)随时可以改,但承重墙不能随便砸,水电管线要预留好走向。没有这层设计,房子一开始住着没事,等你想加个卫生间或者改个房间格局,只能把墙刨开重来。
2. MVC 在 Qt 里的本来面目:Model 不只是接口,是一套契约
MVC(Model-View-Controller)可能是桌面上最常见的架构思想。Qt 对 MVC 有非常深的融入,最典型的就是 Model/View 框架。很多人用过QListWidget、QTableWidget,但没用过真正的 Model/View 分离写法,因为QListWidget这类便捷类把 Model 和 View 揉在了一起。
2.1 Model 的五个关键接口:先搞懂 View 到底问你要什么
Qt 的 Model 抽象,核心是QAbstractItemModel。你自定义一个 Model 时,真正必须实现的接口其实不多,但每一个都决定了 View 能不能正常工作。以QAbstractTableModel为例,我日常用到的主要是这些:
class FileListModel : public QAbstractTableModel { Q_OBJECT public: explicit FileListModel(QObject *parent = nullptr); int rowCount(const QModelIndex &parent = QModelIndex()) const override; int columnCount(const QModelIndex &parent = QModelIndex()) const override; QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; QVariant headerData(int section, Qt::Orientation orientation, int role) const override; Qt::ItemFlags flags(const QModelIndex &index) const override; private: struct FileItem { QString fileName; QString filePath; qint64 fileSize; }; QVector<FileItem> m_files; };先说data(),这是 View 跟 Model 对话的核心接口。View 在绘制每个单元格时都会问data(index, role),你要根据role返回不同的内容:
Qt::DisplayRole:单元格显示的文字。Qt::DecorationRole:单元格里的小图标。Qt::TextAlignmentRole:文字对齐方式。Qt::ForegroundRole/Qt::BackgroundRole:前景色、背景色。
最容易犯的错,是只在DisplayRole里返回所有信息,然后某些列又想显示图标、又想显示富文本,结果在data()里堆了一堆if。我的建议是:data()只负责按角色提供数据,不要在这里面做复杂的业务计算。计算逻辑应该在 Model 内部的数据更新阶段提前完成,data()越简单越好。
然后是flags()。很多拖拽、编辑功能不生效,问题就出在这个函数上。它返回这一项支持哪些操作:可选、可编辑、可拖拽、可放置。默认实现只返回Qt::ItemIsSelectable | Qt::ItemIsEnabled,你不重写它,View 就是只读的。
2.2 用 Delegate 接管绘制与编辑:别把所有逻辑塞进 Model
MVC 里的 Controller 在 Qt 中被弱化,真正承担"交互控制"角色的是 Delegate。典型的场景:表格里有一个进度列,我想用一个QProgressBar显示任务百分比。
设计思路是:
- 自定义一个
ProgressDelegate,继承QStyledItemDelegate。 - 重写
paint(),在单元格里绘制进度条。 - 如果需要支持编辑,重写
createEditor()、setEditorData()、setModelData()。
class ProgressDelegate : public QStyledItemDelegate { Q_OBJECT public: using QStyledItemDelegate::QStyledItemDelegate; void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override { int progress = index.data(Qt::DisplayRole).toInt(); QStyleOptionProgressBar progressOption; progressOption.rect = option.rect.adjusted(2, 2, -2, -2); progressOption.minimum = 0; progressOption.maximum = 100; progressOption.progress = progress; progressOption.text = QStringLiteral("%1%").arg(progress); progressOption.textVisible = true; QApplication::style()->drawControl(QStyle::CE_ProgressBar, &progressOption, painter); } };这样做的最大好处是:Model 里存的仍然是干净的int数据,Delegate 只负责"画起来像进度条"。将来你不想用进度条了,改 Delegate 就行,Model 和业务数据完全不用动。这就是"关注点分离"的价值。
2.3 拖拽支不支持,答案在 flags() 里
"Qt5 无法拖拽文件"这个问题非常典型,几乎每个月都能看到人问。实际上,把外部文件拖进一个QListView或者QTableView,需要同时满足几个条件,少一个都不行:
- View 开启接受拖放:
view->setAcceptDrops(true); - Model 的
flags()返回包含Qt::ItemIsDropEnabled: - 如果支持内部移动,还要
Qt::ItemIsDragEnabled。 - Model 重写
dropMimeData()、mimeData()、mimeTypes()。
很多人只做了第一步,然后发现拖进来没反应,就以为框架不支持。实际上答案在flags()里。下面是一个比较完整的dropMimeData()实现,用来接收文件列表:
bool FileListModel::dropMimeData(const QMimeData *data, Qt::DropAction action, int row, int column, const QModelIndex &parent) { if (action == Qt::IgnoreAction) return true; if (!data->hasUrls()) return false; int beginRow = row; if (beginRow == -1) beginRow = rowCount(parent); for (const QUrl &url :>class UserViewModel : public QObject { Q_OBJECT Q_PROPERTY(QString userName READ userName WRITE setUserName NOTIFY userNameChanged) Q_PROPERTY(QString statusText READ statusText NOTIFY statusTextChanged) public: QString userName() const { return m_userName; } QString statusText() const { return m_statusText; } void setUserName(const QString &name) { if (m_userName == name) return; m_userName = name; emit userNameChanged(); } public slots: void saveUser() { // 在这里调用 Service 层保存,不直接操作数据库 setStatusText(QStringLiteral("保存成功")); } signals: void userNameChanged(); void statusTextChanged(); private: QString m_userName; QString m_statusText; };在 QWidget 里用这个 ViewModel,可以手动连接属性变化信号到控件更新:
UserViewModel *viewModel = new UserViewModel(this); connect(viewModel, &UserViewModel::userNameChanged, this, [this, viewModel]() { ui->nameEdit->setText(viewModel->userName()); }); connect(ui->nameEdit, &QLineEdit::textEdited, viewModel, &UserViewModel::setUserName); connect(ui->saveButton, &QPushButton::clicked, viewModel, &UserViewModel::saveUser);这个模式放到 QML 里会更简洁,因为 QML 属性绑定直接支持viewModel.userName这种写法。但在 QWidget 项目里,用connect手动同步也完全够用,关键是"界面代码不直接操作数据源"这一条纪律。
我见过不少人觉得 Q_PROPERTY 是给 QML 用的,QWidget 项目不需要。这个观点在纯小工具项目里没错,但一旦你的界面有多处地方需要同步显示同一个数据,有没有 NOTIFY 信号的差距就出来了。没信号时你要手动在各个地方调用刷新函数,有信号时只需要让界面订阅属性变化即可。
3.2 命令从哪来:信号就是最朴素的命令对象
MVVM 里的 Command(命令),在 Qt 里没有专门的内置类,但信号天然就是命令的一种表达方式。按钮被点击,发出clicked信号,View 层的槽函数把这个交互转发给 ViewModel 的处理方法。
// View 层 connect(ui->submitButton, &QPushButton::clicked, this, &OrderWindow::onSubmitButtonClicked); void OrderWindow::onSubmitButtonClicked() { m_orderViewModel->submitOrder(); }这里m_orderViewModel->submitOrder()是 ViewModel 对外暴露的公共接口。View 不关心它内部是调 Service 还是调网络,只关心用户点击按钮这件事被传达到了。
如果你需要更完整的命令封装(比如支持启用/禁用状态、支持撤销),可以自己写一个轻量的Command类:
class Command : public QObject { Q_OBJECT public: using Callback = std::function<void()>; Command(Callback callback, QObject *parent = nullptr) : QObject(parent), m_callback(std::move(callback)) {} public slots: void execute() { if (m_callback) m_callback(); } private: Callback m_callback; };这只是一个简化版,真实项目里可以加canExecute()信号来控制按钮的可用状态。但核心思路不变:命令是"要做什么"的意图,ViewModel 是"怎么做"的实现。
3.3 表单场景用 QDataWidgetMapper,列表场景再用 View 那套
很多人分不清什么时候用 Model/View,什么时候用 MVVM。实际上这俩不是互斥的。QDataWidgetMapper就是把 Model 数据映射到表单控件的现成工具,非常契合"单条记录编辑"的场景。
QDataWidgetMapper *mapper = new QDataWidgetMapper(this); mapper->setModel(m_userModel); mapper->addMapping(ui->nameEdit, 0); mapper->addMapping(ui->emailEdit, 1); mapper->addMapping(ui->ageSpinBox, 2); mapper->toFirst();这个组件的逻辑是:QDataWidgetMapper监听 Model 的当前行变化,自动把对应字段写到控件;控件编辑完成后,通过submit()或setSubmitPolicy()写回 Model。表单场景下,你不需要手工给每个控件写一堆"读取/写入"代码,省下来的工作量非常可观。
不过要注意,QDataWidgetMapper和自定义 ViewModel 属于两条不同的路线。前者以 Model 为中心,属于 MVC 的延伸;后者以 ViewModel 为中心,是纯粹的 MVVM。我实际项目里的用法是:列表、树这类"多行数据浏览"用 Model/View;单条数据编辑、多控件联动、状态流转复杂的界面,用 ViewModel。
3.4 MVC 还是 MVVM:我的选择标准
每次聊到这个话题都有人纠结。我的选择标准其实就四条:
| 判断维度 | 倾向 MVC | 倾向 MVVM |
|---|---|---|
| 数据形态 | 列表、树、表格为主 | 表单、状态联动、流程多为 |
| 团队背景 | C++/QWidget 为主,信号槽熟练 | 有 QML 经验,或希望长期用 QML |
| 测试要求 | 核心是数据层,只需测 Model | 希望业务状态脱离 UI 可测 |
| 项目复杂度 | 中小工具,界面逻辑不复杂 | 大型应用,界面状态多、联动多 |
我的经验是:别为架构而架构。一个小工具硬上 MVVM,光 ViewModel 的封装成本就可能超过业务代码本身。反过来,一个业务状态极其复杂的项目,如果用 MVC 硬扛,后期维护成本会非常感人。架构选型要跟着复杂度走,而不是跟着流行词走。
4. 模块划分:依赖方向和边界比目录分层更重要
很多 Qt 项目也做了模块划分——把所有文件按"UI、Core、Utils"分了目录,但实际改代码时还是很痛苦。原因很简单:目录分层只是表面工作,真正的模块划分要看依赖方向。
4.1 单向依赖的三种典型分层:界面、业务、数据
一个健康的 Qt 桌面项目,依赖方向应该是单向的:
- 界面层(Widgets/Windows):放各种窗口、对话框、自定义控件。这一层允许依赖下面两层。
- 业务层(Services/Managers):放具体业务逻辑,比如订单服务、用户服务。这一层不依赖界面层。
- 数据层(Models/Repositories):放数据源访问,数据库、网络、文件。
关键约束:下面两层不能反向依赖上面的界面层。也就是说,Service 里不应该出现QMessageBox::information()这种代码,Repository 里不应该关心数据最终是在哪个表格里展示。
举个例子,"用户点击保存按钮"这个动作的分层处理:
- 界面层:按钮点击信号 → 调用
UserService::saveUser(userData)。 - 业务层:校验数据合法性 → 调用
UserRepository::update(userData)→ 返回结果。 - 数据层:执行
UPDATESQL 语句。
如果校验失败,业务层怎么通知界面?不要直接弹窗,而是返回一个结果对象,或者把错误状态写入 ViewModel。界面层根据这个状态决定要不要弹窗。这样换了一套界面(比如从 QWidget 换到 QML),业务层和数据层完全不用动。
4.2 模块之间的通信:信号总线与轻量服务定位器
模块之间必然需要通信。最直观的做法是 A 模块直接持有 B 模块的指针,调用 B 的公共方法。这个做法在模块数量少的时候没问题,模块多了以后容易变成互相引用的网状结构。
更稳妥的做法,是让模块之间通过信号和事件通信。Qt 的信号槽本身就是跨对象松耦合通信的好工具:
// 全局事件总线,可以是一个从 QObject 派生的单例 class EventBus : public QObject { Q_OBJECT public: static EventBus *instance(); signals: void orderCreated(const QString &orderId); void orderStatusChanged(const QString &orderId, int status); };A 模块下单成功后发送orderCreated信号,B、C 模块分别连接这个信号做自己的处理。A 绝对不需要知道 B 和 C 的存在。
但我要提醒一句:全局事件总线不要滥用。如果整个项目所有通信都走总线,最后你会发现根本没法追踪一条业务链路,代码像蜘蛛网一样。我的建议是:只把"跨模块、一对多"的事件放总线上,模块内部一对一的调用直接用普通函数或信号连接就好。
4.3 物理边界:什么时候该拆出去一个静态库或插件
目录分层是逻辑边界,静态库 / 动态库是物理边界。物理边界能强制依赖方向,因为链接器会报错。但拆库是有成本的,所以我一般参考以下标准来做决策:
- 这个模块是否可能被多个应用程序复用?
- 这个模块的改动频率是否独立于其他模块?
- 这个模块是否包含需要单独测试的复杂逻辑?
如果三个条件里至少满足两个,才考虑拆库。别为了"看起来专业"把一个只有三个文件的模块拆成一个工程。
Qt 里拆静态库很简单,一个subdirs模板的工程搞定:
TEMPLATE = subdirs SUBDIRS += \ core \ data \ ui每个子项目用TEMPLATE = lib或TEMPLATE = app。这样在 IDE 里就是一个多级工程,模块关系一目了然。
物理边界的另一个形态是插件化。Qt 的QPluginLoader支持运行时加载插件,非常适合"主程序 + 功能扩展"的架构。比如一个多格式解析工具,每种格式的解析器做成一个插件,新增格式不需要重新编译主程序。这个属于高阶玩法,等基础架构稳定了再考虑也不迟。
5. 架构视角下的高频翻车现场:拖拽、路径、输入限制和视频播放
平时逛技术社区,总能看到一些高频问题。单独看每个问题好像都是某个 API 不会用,但在项目里它们反复出现,往往说明架构层面有缺口。我从架构视角来分析几个典型场景。
5.1 拖拽文件进列表:flags() 和 acceptDrops 的配合
前面已经讲了flags()和dropMimeData()的实现。这里我想补充一个容易忽略的点:拖拽接收到的文件列表,后续怎么处理,这个逻辑应该放在哪一层?
如果只是把mimeData->urls()里的路径塞进 Model,那还不算完。真实的业务逻辑可能是"导入这些文件并解析",或者"把图片上传到服务器"。这些后续动作不应该写在dropMimeData()里,因为dropMimeData()是数据模型层的接口,它只负责"接受数据并更新模型"。
更合理的设计是:dropMimeData()只把文件路径存入 Model,然后发一个业务信号出去,比如filesDropped(const QStringList &paths),让业务层的 Service 去做后续解析、上传、统计。这样数据模型仍然保持纯粹,业务变化时不需要改 Model 代码。
5.2 QString 显示路径的乱码:编码问题要在最外层解决
"Qt5 QString 显示地址"相关的问题,本质上不是QString的问题,而是"外部编码到 QString 再到外部编码"的转换链问题。跨平台处理文件路径时,特别容易踩坑:
- 从
QDir拿到的路径是 UTF-8 编码的QString,没问题。 - 从 Windows API、旧版第三方库拿到的可能是 GBK/ANSI 编码的
char*。 - 如果直接用
QString::fromStdString()去转,编码对不上,显示出来就乱码。
架构层面的建议是:把"编码转换"收敛到一个文件服务里统一处理。比如写一个PathService或者FileService:
class FileService : public QObject { Q_OBJECT public: static QString fromLocalPath(const std::string &path) { return QString::fromLocal8Bit(path.c_str()); } static QString fromUtf8Path(const QByteArray &path) { return QString::fromUtf8(path); } static QString normalizePath(const QString &path) { return QDir::cleanPath(path); } };所有模块获取路径时都经过这个组件的统一转换。这样万一将来要调整编码策略,或者换一种路径规范,只需要改一个文件,而不是在几十个窗口里找散落各处的toStdString()。
5.3 QMediaPlayer 播放视频:媒体模块的封装思路
QMediaPlayer 播放视频是 Qt 一个入门级演示,但放到实际项目里,你会发现直接操作它并不够:
- 播放状态和 UI 状态的联动:播放、暂停、停止时按钮状态要切换。
- 进度条拖动和播放进度更新,这两个方向要协调。
- 失败处理:文件不存在、解码器不支持,要给出用户能看懂的提示。
- 多媒体的音量、播放速度、倍速播放这些操作也需要统一入口。
我一般会把播放器封装成一个独立组件,比如VideoPlayerWidget,对外只暴露少量接口:
class VideoPlayerWidget : public QWidget { Q_OBJECT public: explicit VideoPlayerWidget(QWidget *parent = nullptr); void loadFile(const QString &filePath); void play(); void pause(); void stop(); void setVolume(int volume); qint64 duration() const; signals: void playbackStateChanged(int state); void positionChanged(qint64 position); void durationChanged(qint64 duration); void playbackFailed(const QString &errorString); private: QMediaPlayer *m_player; QVideoWidget *m_videoWidget; };这样做的意义是:业务模块只需要依赖VideoPlayerWidget这个稳定接口,而不依赖QMediaPlayer的具体实现。将来 Qt 6 升级、后端解码器变化、或者换成自研播放内核,受影响的代码被限制在VideoPlayerWidget内部。这就是我在前面说的"可替换性"。
5.4 lineEdit 只能输入数字:验证逻辑放哪层最合理
"Qt5 设置 lineEdit 只能输入数字"是个很老的问题,答案也比较固定——用QRegularExpressionValidator:
QRegularExpressionValidator *validator = new QRegularExpressionValidator( QRegularExpression("[0-9]+"), this); ui->portEdit->setValidator(validator);但如果项目里有多个窗口都需要"只允许输入数字"的输入框,最稳妥的做法不是在每个窗口里各写一遍setValidator,而是做成一个可复用的输入约束工具,或者封装一个NumberEdit控件。
更重要的是:UI 上的格式校验只是"防手滑",它替代不了业务层的合法性校验。用户可以不经过你的界面,直接调 Service 接口或 API,这时候如果业务层不校验数据,数据照样能带着非法值入库。所以我的习惯是:界面层用 Validator 提供即时反馈,业务层用独立的校验逻辑兜底,两个层面对数据的期望保持一致。
6. 把这套架构落到实处的最后几点建议
前面聊了不少方法和思路,最后分享一些落地过程中的真实经验,包括我踩过的坑和调整过的心得。
6.1 我踩过的一个信号嵌套坑
早期我维护过一个模块划分"看起来没问题"的项目:界面层依赖业务层,业务层依赖数据层,依赖方向是单向的。但实际改起来还是很费劲,最后排查发现,问题出在信号链路太长。
流程大概是这样的:A 界面一个属性变化 → 发出信号 → B 业务层收到后做格式转换,产生新信号 → C 数据层收到后更新数据库,又发一个"数据已更新"的信号 → 回传到 A 界面刷新。每个环节看起来都合理,合在一起却变成了一条隐形的长链路。
最难受的是排错。某个字段没刷新,你需要在 A、B、C 三个模块里分别打日志,确认信号到底断在哪个环节。后来我把这个链路砍掉,改成了"数据变化统一由数据层发出通知,界面层直接订阅数据层状态"。虽然模块还是三层,但信号跳数从四跳降到了两跳,问题瞬间清晰了很多。
这个经历给我的启发是:模块分层和信号跳数是两回事。分层再漂亮,信号链路过深照样难排查。设计通信方式时,多问问自己:这个信号真的需要经过这几层吗?能不能让最终关心状态的那一层直接订阅状态来源?
6.2 过度模块化的反面案例
另一个反例是模块拆得太碎。有个项目里一个"用户管理"功能拆了五个模块:界面模块、业务模块、数据模块、公共模型模块、工具函数模块。每个模块都只有一个类,类只有几百行。看起来架构非常"标准",实际用起来很痛苦——改一个字段类型,五个工程的代码都要动,编译一次要等半天。
模块划分的粒度应该跟"业务域"对齐,而不是跟"类"对齐。用户管理作为一个业务域,界面+业务+数据可以做成一个模块,内部用命名空间或子目录分隔层次。只有当某个子模块真的会被其他业务域复用时,才考虑把它提升为独立模块。
一个很朴素但有效的检验标准:改一个功能时,你需要同时打开几个工程文件?如果是两个以上,很可能拆得过于碎了。
6.3 渐进式重构:让历史项目在三个月内"活"过来
最后聊一个实操性最强的问题:手上已经有一个结构混乱的存量项目,怎么在不推翻重来的前提下,逐步把架构理顺?
我的建议是分四步走:
先解决最痛的问题。如果界面卡顿是最大槽点,优先把耗时操作迁移到工作线程,用
QtConcurrent::run()或者QThreadPool都可以。这个改动不需要动架构,收益却立竿见影。找一个业务域做试点。比如整个系统里最常改动的那块业务,把它从界面代码中剥离,做成一个独立的 Service 类。这个过程中你会建立"哪些操作属于 UI,哪些属于业务"的判断直觉。
用 Model/View 替换便捷控件。项目里一定有
QTableWidget直接操作数据的地方,先挑一个最混乱的页面,换成QTableView+ 自定义 Model。熟练之后,你再去处理其他页面就轻车熟路了。为核心业务补单元测试。等你把某个 Service 抽出来之后,马上为它写测试。只有在没有 UI 依赖的环境下测试通过,你才真正体会到"业务逻辑独立于界面"的好处,也才会更有动力继续重构。
三个月的时间,足够把一个"一改就崩"的存量项目,逐步改造成"能欢迎新需求"的可持续开发状态。核心原则是小步快跑,每步都要有明确收益,不要憋大招。
架构设计不是一锤子买卖,它是项目生命周期里持续演化的过程。今天你搭好的结构,明天可能因为新需求而调整。但只要依赖方向是清晰的、层次边界是明确的、关键通信链路是收敛的,这个项目就有持续维护下去的基础。希望这篇内容能给你的 Qt 桌面项目架构路线提供一个可靠的起点。