资讯动态

别再往QWidget里塞业务逻辑了!3个理由改用MVVM

发布时间:2026/8/12 15:18:19 来源:尧图企业网站定制
从C到工业级Aether项目精讲 · 第5篇Qt项目最常见的死法UI和业务逻辑混在一起。一个按钮点下去从界面取值、校验、调接口、更新UI全塞在一个onClick里。改UI要重写一半业务逻辑改业务要动所有UI文件。代码写了两年项目变成一团乱麻。上一篇我们把IoC容器搭好了服务都注册完了。那业务逻辑往哪放我的答案是ViewModel。这一篇不讲理论直接拆Aether的MVVM框架源码看完你就知道怎么干了。一、MVC不行吗为什么非要MVVMQt本身就有MVC的底子。比如QAbstractModel QTreeView/QTableViewModel负责数据View负责显示。但真实项目里这个模式撑不住。看个对比维度传统MVCQt原生MVVMAether方案交互粒度Model→View批量刷新属性级精准推送视图逻辑View里写大量逻辑ViewModel全权代理测试方式依赖QWidget难测纯C对象单元测试随便写绑定方式手动connect声明式绑定一行代码多人协作UI和逻辑互相踩各改各的接口清晰MVC的Model更像数据源不是页面状态。一个页面有用户输入、校验结果、加载状态、错误提示。这些都不是业务数据而是UI状态。MVC没地方放这些东西最后就塞进QWidget里了。MVVM的思路很简单给每个页面配一个代言人。这个代言人叫ViewModel它不碰UI只管理页面状态和行为。View只知道一件事把ViewModel的属性显示出来把用户的操作传给ViewModel。二、ViewModel页面的代言人先看Aether的ViewModel基类。整个框架全靠它撑。classViewModel:publicQObject{Q_OBJECTpublic:explicitViewModel(QObject*parentnullptr);virtual~ViewModel()default;// 属性读写QVariantgetProperty(constQStringpropertyName)const;voidsetProperty(constQStringpropertyName,constQVariantvalue);// 批量更新一次发一个信号省性能voidbeginBatchUpdate();voidendBatchUpdate();signals:// 属性变更信号。DataBinding全指望它voidpropertyChanged(constQStringpropertyName,constQVariantvalue);protected:// 子类注册属性voidregisterProperty(constQStringpropertyName,constQVariantdefaultValueQVariant());private:QHashQString,QVariantm_properties;// O(1)查找};核心就三样QHash存属性、propertyChanged发信号、子类注册。没有UI没有QWidget纯粹的C对象。那怎么定义属性有个宏// 用宏定义属性自动生成getter/setter#defineVIEWMODEL_PROPERTY(Type,name,Name)\public:\Typename()const{\returngetProperty(#name).valueType();\}\voidset##Name(constTypevalue){\if(name()!value){\setProperty(#name,QVariant::fromValue(value));\emit name##Changed();\emitpropertyChanged(#name,QVariant::fromValue(value));\}\}// 用起来就这样classUserViewModel:publicViewModel{Q_OBJECTpublic:VIEWMODEL_PROPERTY(QString,userName,UserName)// 自动生成QString userName() 和 void setUserName(const QString)// 自动发 propertyChanged 信号VIEWMODEL_PROPERTY(int,age,Age)};你可能会问这不就是个带信号的Q_PROPERTY吗为什么自己写一套因为Q_PROPERTY在运行时靠字符串反射查找性能有损耗而且不支持批量更新、中间件这些高级功能。Aether的ViewModel用QHash直接查O(1)配合中间件管道还能做属性拦截。后面会讲到。三、DataBinding连接ViewModel和View的关键有了ViewModel还得把ViewModel和QWidget连起来。手写connect太累了。Aether提供了DataBinding工具类一行代码搞定。3.1 单向绑定ViewModel → QLabelViewModel变了Label自动更新。// ViewModel有个statusText属性DataBinding::bindOneWay(vm,statusText,ui-labelStatus);// vm的statusText一变label自动刷新// 不需要手写connect实现原理很简单voidDataBinding::bindOneWay(ViewModel*vm,constQStringpropertyName,QLabel*label){if(!label||!checkBinding(vm,propertyName))return;// 初始化值label-setText(vm-getProperty(propertyName).toString());// 监听属性变更自动更新控件QObject::connect(vm,ViewModel::propertyChanged,label,[label,propertyName](constQStringname,constQVariantvalue){if(namepropertyName){label-setText(value.toString());}});}就是帮你写了个connect。但别小看这一层。它让View代码从怎么更新变成了声明我要绑定什么。3.2 双向绑定ViewModel ↔ QLineEdit用户输入ViewModel自动更新ViewModel变输入框也自动更新。// 双向绑定QLineEdit ←→ ViewModel的userNameDataBinding::bind(vm,userName,ui-lineEditName);// 绑定之后没你的事了。// 用户打字 → vm.userName自动更新// vm.setUserName(xxx) → 输入框自动显示这才是MVVM的灵魂数据驱动UI而不是UI驱动数据。3.3 命令绑定按钮 ↔ Command按钮点击不能用双向绑定它是个动作不是个属性。Aether用Command模式// 定义一个CommandautologinCmdnewFunctionalCommand([this](){// 执行登录逻辑doLogin();},[this]()-bool{// 什么时候按钮可用return!m_userName.isEmpty()!m_password.isEmpty();});// 绑定到按钮DataBinding::bindCommand(loginCmd,ui-btnLogin);// 按钮自动 enabled/disabled点一下自动执行run()Command基类还支持中间件管道可以插日志、插鉴权、插权限检查。这个后面专门有一篇讲。3.4 状态绑定属性驱动控件状态// bool属性驱动控件是否可用DataBinding::bindEnabled(vm,isFormValid,ui-btnSubmit);// bool属性驱动控件是否可见DataBinding::bindVisible(vm,isLoading,ui-progressBar);有没有发现一个模式所有UI行为都由数据驱动。你不写 “if XXX then setEnabled(true)”你只需要声明 “btnSubmit.enabled isFormValid”剩下的框架搞定。四、实战登录页面的MVVM改造光说不练假把式。看一个真实例子登录页。❌ 原始写法50行UI和逻辑混在一起// LoginWidget.cpp 。常见Qt写法voidLoginWidget::on_loginButton_clicked(){QString userui-userEdit-text();QString passui-passEdit-text();if(user.isEmpty()||pass.isEmpty()){ui-errorLabel-setText(用户名或密码不能为空);ui-errorLabel-setVisible(true);return;}if(pass.length()6){ui-errorLabel-setText(密码不能少于6位);ui-errorLabel-setVisible(true);return;}ui-loginButton-setEnabled(false);ui-loadingLabel-setVisible(true);m_service-login(user,pass,[this](boolok,QString msg){ui-loginButton-setEnabled(true);ui-loadingLabel-setVisible(false);if(ok){emitloginSuccess();}else{ui-errorLabel-setText(msg);ui-errorLabel-setVisible(true);}});}问题在哪5条不同的逻辑缠在一起取输入、校验、控制按钮状态、控制loading、调接口。每个if都在操作UI控件。改个UI比如把QLineEdit换成密码控件你得在这个函数里改三处。✅ MVVM写法30行ViewModel 20行View先写ViewModel只关注状态不碰UI// LoginViewModel.hclassLoginViewModel:publicViewModel{Q_OBJECTpublic:VIEWMODEL_PROPERTY(QString,userName,UserName)VIEWMODEL_PROPERTY(QString,password,Password)VIEWMODEL_PROPERTY(QString,errorMsg,ErrorMsg)VIEWMODEL_PROPERTY(bool,isLoading,IsLoading)LoginViewModel(ILoginService*service,QObject*parentnullptr);Command*loginCommand()const{returnm_loginCmd;}private:ILoginService*m_service;Command*m_loginCmd;};// LoginViewModel.cpp 。纯业务逻辑没有一行UI代码LoginViewModel::LoginViewModel(ILoginService*service,QObject*parent):ViewModel(parent),m_service(service){registerProperty(userName,);registerProperty(password,);registerProperty(errorMsg,);registerProperty(isLoading,false);m_loginCmdnewFunctionalCommand([this](){setErrorMsg();// 清上次错误setIsLoading(true);// 显示加载状态m_service-login(userName(),password(),[this](boolok,QString msg){setIsLoading(false);if(!ok)setErrorMsg(msg);elseemitloginSuccess();});},// 按钮可用条件[this]()-bool{return!userName().isEmpty()!password().isEmpty()password().length()6!isLoading();});// 属性一变重新检查按钮状态connect(this,ViewModel::propertyChanged,m_loginCmd,[this](){m_loginCmd-updateCanExecute();});}ViewModel做完View就是声明式绑定了// LoginView.cpp 。纯UI组装没有一行业务逻辑LoginView::LoginView(LoginViewModel*vm,QWidget*parent):QWidget(parent){// 建UI用Qt Designer或代码随便auto*userEditnewQLineEdit(this);auto*passEditnewQLineEdit(this);auto*loginBtnnewQPushButton(登录,this);auto*errLabelnewQLabel(this);auto*loadingnewQProgressBar(this);// 绑定全部用声明式绝对不写setText/getTextDataBinding::bind(vm,userName,userEdit);DataBinding::bind(vm,password,passEdit);DataBinding::bindOneWay(vm,errorMsg,errLabel);DataBinding::bindVisible(vm,isLoading,loading);DataBinding::bindCommand(vm-loginCommand(),loginBtn);// 不需要改任何绑定换控件类型时改这一行即可}对比结果原始写法50行改UI要改业务代码改业务要碰UI代码MVVM写法30行ViewModel 20行View完全解耦ViewModel可以单独做单元测试。不需要QWidget不需要new LoginView想测登录逻辑直接测ViewModelTEST(LoginTest,empty_user_should_disable_button){LoginViewModelvm(mockService);vm.setUserName();vm.setPassword(123456);EXPECT_FALSE(vm.loginCommand()-canExecute());// 不需要启动QApplication不需要new QWidget}这才是你想要的测试体验吧五、再看一眼绑定内部DataBinding的bind方法核心代码其实很短BindingHandle*DataBinding::bind(ViewModel*vm,constQStringpropertyName,QLineEdit*edit,constBindingOptionsoptions,BindingSession*session){if(!edit||!checkBinding(vm,propertyName))returnnullptr;// ViewModel → View监听propertyChanged写给控件constautoapplyToView[edit](constQVariantvalue){QSignalBlockerblocker(edit);// 防死循环edit-setText(value.toString());};// View → ViewModel控件变化写回ViewModelconstautopullFromView[vm,propertyName,edit](){vm-setProperty(propertyName,edit-text());};connectVmToView(vm,propertyName,edit,applyToView,session);connectViewToVm(edit,vm,options,pullFromView);returnregisterPullHandle(session,edit,pullFromView);}QSignalBlocker是关键。双向绑定最怕死循环ViewModel变→写控件→控件发信号→ViewModel再变→再写控件。用blocker临时屏蔽控件的信号只在ViewModel→View时禁掉回写完美破局。六、这能解决什么问题你可能会想“我项目小不需要这么麻烦。”说几个真实场景场景1UI重构。客户说要把QLineEdit换成QComboBox选用户。传统写法你得翻onClick改取值逻辑。MVVM换控件绑定行从bind改成bindIndex完事。ViewModel一行不动。场景2添加新页面。同一份数据要在两个页面展示。传统写法两个onClick重复逻辑。MVVM共用一个ViewModel实例两个View绑定同一份数据自动同步。场景3单元测试。你测过Qt业务逻辑吗new一个QWidget启动QApplication模拟点击……MVVM的ViewModel是纯C对象不需要QApplication不需要UI事件构造出来直接测。场景4切换主题/国际化。ViewModel只管数据不管怎么显示。主题、语言这些东西在View层处理和业务逻辑彻底分开。改UI样式不可能影响到业务代码。到这里Aether的MVVM核心骨架已经拆完了ViewModel管状态DataBinding管连接Command管动作。三个组件覆盖了页面开发的全部场景。但这只是基础款。下一篇进入Aether最骚的操作中间件管道。你可以在属性变更前做校验、在命令执行前做鉴权、在数据写入后做日志……完全插拔不入侵一行业务代码。下篇预告“Qt命令模式的终极进化中间件管道让代码可插拔。撤销/重做/日志/鉴权一切皆是中间件”你的Qt项目在用哪种架构踩过什么坑评论区聊聊我每条都会看。觉得有用点个在看让更多人看到也鼓励我继续写下去。系列目录第1篇[从60万行C项目说起我为什么又造了一个Qt轮子]已发布第2篇[Aether项目结构拆解插件化架构的3个核心问题]已发布第3篇[C插件系统从零实现加载、解析、生命周期管理]已发布第4篇[手写IoC容器Laravel风格的C依赖注入]已发布第5篇别再往QWidget里塞业务逻辑了Qt项目就该用MVVM本文第6篇Qt命令模式的终极进化中间件管道。预告

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价