Qt桌面项目架构实战:从MVC到MVVM的演进与模块划分
接手过一个别人留下的 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 不只是接口是一套契约MVCModel-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; }; QVectorFileItem 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 接管绘制与编辑别把所有逻辑塞进 ModelMVC 里的 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::functionvoid(); 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或者FileServiceclass 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 只能输入数字是个很老的问题答案也比较固定——用QRegularExpressionValidatorQRegularExpressionValidator *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 桌面项目架构路线提供一个可靠的起点。