ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从MVC到MVVM:数据绑定、ViewModel分层与跨平台工程实践

从MVC到MVVM:数据绑定、ViewModel分层与跨平台工程实践 1. MVVM到底在解决什么问题1.1 从MVC说起MVVM的出现背景早些年写界面程序大家普遍用的是MVCModel-View-Controller这套模式本身没毛病但落到实际的桌面客户端开发里越写越别扭。View和Controller的边界太模糊了写着写着Controller就把View的控件拿过来直接操作View里也塞满了业务判断Model被两头拉扯。项目小的时候还能凑合一旦业务复杂起来改一个需求要连带着翻好几个文件单元测试基本没法写因为逻辑全和UI控件纠缠在一起根本没法脱离界面去跑。MVVMModel-View-ViewModel就是在这样的背景下被微软的John Gossman提出来的最初是为了配合WPF的数据绑定能力。它的核心思路是把界面View、数据Model和中间的状态与行为ViewModel彻底拆开View只管显示和收集用户输入ViewModel负责把Model转换成View可以直接消费的状态并且通过数据绑定这个“管道”让两端自动同步。说白了就是UI不再直接操作数据数据也不再关心自己是怎么被画出来的。1.2 MVVM的三层职责划分很多刚接触MVVM的人会被三个名词绕晕我用大白话拆一遍。Model层负责业务数据和数据访问。它就是你的实体类、数据库访问、网络请求这些“不穿衣服”的东西完全不认识Button、TextBox长什么样。View层就是界面本身XAML、XML或者Qt里的QML凡是用户看得见摸得着的都属于这一层。ViewModel层是整个架构的枢纽它把Model的数据“翻译”成View能绑定的属性同时把View上的用户操作接收过来转化成对Model的调用。这三层里View和ViewModel之间是通过数据绑定和命令关联的View完全不知道Model的存在ViewModel也不知道View的存在两边都只跟抽象约定打交道。这带来的直接好处是什么是测试。你可以在不启动界面的情况下new一个ViewModel给它喂数据验证它的状态和输出对不对。对做客户端的人来说这种“能测UI逻辑”的能力在以前是想都不敢想的。1.3 一条数据流的完整生命周期理解MVVM的核心是理解数据在三条链路里怎么流动。第一条是“Model到View”的展示链路Model数据被加载后赋值给ViewModel里的属性属性通过通知机制告诉View“我变了”View自动刷新。第二条是“View到Model”的操作链路用户点击按钮View把点击事件转交给ViewModel里的命令命令执行业务逻辑并调用Model的方法。第三条是“View到ViewModel再到View”的联动链路用户在界面上输入文字通过双向绑定直接写回ViewModel的属性属性一变其他依赖这个属性的UI部分也跟着更新。这三条链路全部由绑定机制驱动你在代码里几乎看不到“textBox1.Text xxx”这种直接赋值。这也是MVVM被诟病“学习曲线陡峭”的原因——很多人第一次接触时觉得小题大做等真正把这条链路理顺之后才会发现代码组织方式完全上了一个台阶。2. MVVM的核心机制数据绑定与命令2.1 数据绑定UI和数据的单向/双向通道数据绑定是MVVM的命根子没有它MVVM就是空中楼阁。绑定分为单向和双向单向绑定是指数据源变了UI自动跟着变适合展示类信息双向绑定是指UI变了写回数据源数据源变了又刷新UI适合输入类控件。以WPF为例一个输入框的绑定长这样TextBox Text{Binding UserName, UpdateSourceTriggerPropertyChanged} /属性名后面的UpdateSourceTrigger值得单独说。默认情况下TextBox的绑定在失去焦点时才把值写回源但用户更习惯边输入边触发校验这时候把触发时机改成PropertyChanged每次按键都会同步到ViewModel。这个细节在实际体验上差别巨大很多人说MVVM输入卡顿或校验不及时一半以上是栽在这里。绑定还有一个方向问题——你总得告诉框架数据往哪流ModeOneWay表示只展示不写回ModeTwoWay表示双向ModeOneTime表示只在加载时绑定一次。做大数据量列表展示时把能改OneTime的地方都改成OneTime渲染性能会有肉眼可见的提升这是优化老手的基本操作。2.2 命令系统把事件变成可测试的逻辑数据绑定解决了“属性怎么同步”但用户的“动作”怎么办按钮点击、右键菜单、拖拽操作这些都是事件传统写法是直接在事件处理器里写逻辑。MVVM的做法是引入命令Command把“用户想做什么”抽象成一个对象。命令接口一般就三个成员CanExecute这个动作现在能不能执行、Execute执行这个动作、以及CanExecuteChanged通知。界面上按钮绑定了命令之后按钮的可用状态完全由命令里的CanExecute决定业务上不用再手动去搞button.Enabled true这种代码。这里我想多说一句命令的CanExecute是一个非常容易被低估的设计。我见过很多项目把按钮置灰的逻辑散落在各处比如“没有选中项时删除按钮不可用”用代码判断往往要写好几个地方而且容易漏。用命令之后只需要在属性变化时触发CanExecuteChanged让按钮自己重新问一次“我能不能点”整个状态管理就收敛到了命令内部逻辑清晰还不容易出bug。2.3 INotifyPropertyChanged属性通知的底层原理MVVM里有一个绕不开的接口叫INotifyPropertyChanged它做的事情很简单属性值变了就对外广播一个“某某属性变了”的事件。绑定引擎收到这个事件后去查找哪些UI元素依赖这个属性然后更新它们。问题在于这个接口如果手动实现代码非常啰嗦private string _userName; public string UserName { get { return _userName; } set { if (_userName ! value) { _userName value; OnPropertyChanged(nameof(UserName)); } } }每个属性都写一遍一个ViewModel几十个属性光样板代码就能把人写吐。所以实际项目中几乎都会封装一个ViewModel基类用SetProperty方法把赋值和通知合并在一起public class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected bool SetPropertyT(ref T field, T value, [CallerMemberName] string propertyName null) { if (EqualityComparerT.Default.Equals(field, value)) return false; field value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); return true; } }有了这个基类属性定义就变成一行private string _userName; public string UserName { get _userName; set SetProperty(ref _userName, value); }注意我用了CallerMemberName编译器会自动把调用处的属性名填进来这样即使属性后面改了名字通知的属性名也不会因为手打字符串而出错。这种小细节在重构频繁的项目里能省下不少排查时间。3. MVVM落地实操从零搭建一个完整的MVVM项目3.1 项目结构规划与分层空谈概念没有意义我直接用一个实际场景演示怎么从零搭一个MVVM项目。假设我们要做一个简单的用户登录窗口带用户名输入、密码输入、登录按钮和状态提示。项目结构我建议这样分src/ ├── Models/ │ └── User.cs ├── ViewModels/ │ ├── ViewModelBase.cs │ ├── RelayCommand.cs │ └── LoginViewModel.cs ├── Views/ │ └── LoginView.xaml └── Services/ └── IAuthService.cs很多人第一步就会纠结ViewModel放哪个文件夹Service要不要单独建一层我的建议是小项目别过度设计四五个文件夹足够。Models放实体ViewModels放所有VMViews放所有窗口和控件Services放接口和实现。等业务量大到某个文件夹超过十几个文件时再按模块拆分也不迟。3.2 ViewModel基类与命令基类的实现前面已经写了ViewModelBase现在补上命令基类。这里说的RelayCommand是MVVM里最通用的命令实现本质就是把一个委托包装成命令public class RelayCommand : ICommand { private readonly Action _execute; private readonly Funcbool _canExecute; public RelayCommand(Action execute, Funcbool canExecute null) { _execute execute; _canExecute canExecute; } public bool CanExecute(object parameter) _canExecute null || _canExecute(); public void Execute(object parameter) _execute(); public event EventHandler CanExecuteChanged { add { CommandManager.RequerySuggested value; } remove { CommandManager.RequerySuggested - value; } } }这个实现里有几个点值得解释。CanExecuteChanged事件我直接挂到了WPF的CommandManager.RequerySuggested上这个事件会在UI交互比如点击、焦点变化时自动触发让绑定到命令的按钮机制自己去刷新可用状态。如果你想手动控制刷新时机也可以改成普通事件然后在合适的时机调用CommandManager.InvalidateRequerySuggested()强制刷新。带泛型参数的命令版本也值得准备一个因为很多操作需要传入参数比如列表项操作。写法差不多把Action换成ActionTFuncbool换成FuncT, bool就行。我一般在项目里固定放三个命令类无参、带参、以及带异步支持的AsyncRelayCommand后面那个在讲异步时再展开。3.3 一个登录页面的完整MVVM实现先定义Model。用户实体很简单就是登录成功后要展示的用户名public class User { public string UserName { get; set; } public string DisplayName { get; set; } }再定义一个认证服务接口让ViewModel依赖抽象而不是具体实现public interface IAuthService { TaskUser LoginAsync(string userName, string password); }接着是核心的LoginViewModelpublic class LoginViewModel : ViewModelBase { private readonly IAuthService _authService; private string _userName; private string _password; private string _statusMessage; private bool _isBusy; public string UserName { get _userName; set { if (SetProperty(ref _userName, value)) LoginCommand?.RaiseCanExecuteChanged(); } } public string Password { get _password; set { if (SetProperty(ref _password, value)) LoginCommand?.RaiseCanExecuteChanged(); } } public string StatusMessage { get _statusMessage; set SetProperty(ref _statusMessage, value); } public bool IsBusy { get _isBusy; set SetProperty(ref _isBusy, value); } public RelayCommand LoginCommand { get; } public LoginViewModel(IAuthService authService) { _authService authService; LoginCommand new RelayCommand(ExecuteLogin, CanExecuteLogin); } private bool CanExecuteLogin() { return !string.IsNullOrWhiteSpace(UserName) !string.IsNullOrEmpty(Password) !IsBusy; } private async void ExecuteLogin() { IsBusy true; StatusMessage 正在登录...; try { var user await _authService.LoginAsync(UserName, Password); StatusMessage $欢迎你{user.DisplayName}; } catch (Exception ex) { StatusMessage $登录失败{ex.Message}; } finally { IsBusy false; LoginCommand.RaiseCanExecuteChanged(); } } }这个ViewModel有几个地方是经验沉淀出来的。第一UserName和Password的setter里在值变化后调用了LoginCommand.RaiseCanExecuteChanged()保证输入内容变化时按钮的可用状态立刻刷新。第二用IsBusy在登录期间锁住按钮防止用户反复提交。第三ExecuteLogin里把异常捕获并转成用户可读的状态消息而不是让异常直接冒泡到UI线程导致崩溃。对应的View绑定就非常直白TextBox Text{Binding UserName, UpdateSourceTriggerPropertyChanged} / PasswordBox PasswordChangedPasswordBox_PasswordChanged / Button Content登录 Command{Binding LoginCommand} / TextBlock Text{Binding StatusMessage} /注意密码框有一点绕WPF的PasswordBox出于安全考虑不提供绑定依赖属性所以要手动在后台代码里把密码转发给ViewModel。这算MVVM里为数不多“需要破例”的地方我一般的做法是在PasswordChanged事件里写一行((LoginViewModel)DataContext).Password passwordBox.Password;这种例外是框架限制导致的不必为了教条而强行不用代码后台。3.4 服务定位与依赖注入的引入时机在上面的例子里LoginViewModel的构造函数接收了一个IAuthService参数。那这个参数从哪来这就是依赖注入DI要解决的问题。最简单的做法是不用任何框架在创建ViewModel的地方手动newvar vm new LoginViewModel(new HttpAuthService());小项目这么做没问题但服务一多手动管理依赖就会乱。WPF社区里常用的做法是引入一个轻量级DI容器比如Microsoft.Extensions.DependencyInjectionApp启动时注册所有服务ViewModel通过构造函数自动解析。这样做的价值在项目中期才体现出来——你想给某个服务加缓存实现时只改注册代码所有使用方完全不用动。依赖注入的引入时机我建议看项目体量如果只有两三个ViewModel手动装配完全够用一旦超过五六个或者服务之间有互相依赖就越早引入越好。别在项目初期纠结用什么高级框架先把分层做好后面替换成本很低。4. 主流平台MVVM实践对照4.1 WPF/WinForm微软生态的MVVM玩法WPF是MVVM的原生主场。它从诞生起就内置了完整的数据绑定、命令、模板、样式系统本质上就是为MVVM准备的。在WPF里做MVVM你只需要关注ViewModel怎么写View的绑定在XAML里完成框架替你处理了绝大部分苦力工作。WinForm的情况比较特殊它是控件驱动的旧框架没有原生数据绑定管道。社区里常见的做法是引入第三方MVVM框架比如之前我接触过的一些团队在WinForm上用MVVMLight的移植版或者CommunityToolkit.Mvvm通过给控件手动挂事件、在事件处理器里转发给命令来实现。效果打个折扣但至少逻辑能分离。这里分享一个我对WinForm项目的判断如果项目是从零开始的新的桌面项目优先考虑WPF或者后来兴起的跨平台UI方案。如果必须基于老WinForm代码继续迭代硬套MVVM不如做“分步改造”——先把业务逻辑从窗体代码里抽到独立的服务类把窗体的数据访问改成属性驱动再逐步引入绑定和命令。一口气重构在WinForm里风险太高。4.2 Android官方推荐的MVVM组合Android从2017年左右开始官方推荐MVVM架构Jetpack组件里的ViewModel、LiveData、DataBinding以及后来的StateFlow组合起来就是一套非常完整的MVVM实践。Android里的ViewModel和桌面端的ViewModel在设计上有一些差异。Android的ViewModel是生命周期感知组件它在配置变更比如旋转屏幕时不会被销毁数据因此得以存活这是为移动端场景专门设计的生命周期机制。LiveData或者StateFlow充当了数据绑定的角色——ViewModel暴露可观察的数据界面订阅这些数据并更新UI。class LoginViewModel(private val authRepository: AuthRepository) : ViewModel() { private val _userName MutableStateFlow() val userName: StateFlowString _userName.asStateFlow() private val _uiState MutableStateFlowLoginUiState(LoginUiState.Idle) val uiState: StateFlowLoginUiState _uiState.asStateFlow() fun onUserNameChanged(input: String) { _userName.value input } fun login() { viewModelScope.launch { _uiState.value LoginUiState.Loading val result authRepository.login(_userName.value, _password.value) _uiState.value LoginUiState.Success(result) } } }Android MVVM里我特别想提醒的是“状态提升”State Hoisting思路UI状态被集中封装成不可变的UiState对象界面根据状态决定显示什么而不是每个属性单独暴露。这样界面渲染只有一条决策链状态越多优势越明显。4.3 QtC世界的MVVM变体Qt的推荐做法里很多人习惯用Model/View框架但它和MVVM并不完全是一回事。Qt社区里最贴近MVVM的是QML加Qt Quick这套组合QML对应ViewC类通过继承QObject并暴露属性给QML访问天然支持属性绑定这一层就相当于ViewModel。Qt里做MVVM的核心机制是信号槽和属性绑定。C端的类通过Q_PROPERTY宏暴露可绑定的属性属性变化时发出信号QML端用onXxxChanged或者在布局里直接绑定到属性名改动一处界面自动联动class LoginViewModel : public QObject { Q_OBJECT Q_PROPERTY(QString userName READ userName WRITE setUserName NOTIFY userNameChanged) Q_PROPERTY(QString statusMessage READ statusMessage WRITE setStatusMessage NOTIFY statusMessageChanged) public: Q_INVOKABLE void login(); signals: void userNameChanged(); void statusMessageChanged(); };Qt MVVM实操中最大的坑是数据同步线程模型。C里耗时操作跑在工作线程完成后要跨线程更新属性稍不小心UI线程就被卡顿甚至崩溃。我建议所有耗时操作通过信号跨线程返回再用QMetaObject::invokeMethod把UI更新调度回主线程别在子线程里直接改属性。4.4 不同平台MVVM实现对比表平台View层ViewModel载体绑定机制典型坑点WPFXAMLINotifyPropertyChanged依赖属性绑定绑定失效时不报错静默WinForm窗体控件普通类事件转发框架不原生支持绑定AndroidCompose/XMLJetpack ViewModelLiveData/StateFlow生命周期泄漏QtQMLQObject派生类属性系统信号槽跨线程更新UI这张表想表达的核心信息是MVVM是思想而不是具体API它在不同平台的实现差异很大但底层的“数据驱动UI、逻辑可测试”这个内核是通用的。你在一套平台上真正理解了MVVM换平台时只是重新学习绑定语法而已。5. MVVM常见问题与排查技巧实录5.1 绑定不生效八成是这几个原因做MVVM一定会遇到“界面死活不更新”的情况。根据我的经验90%的绑定不生效可以归因到以下几类。一是属性没有通知。ViewModel的属性本身变化了但没有触发PropertyChanged事件绑定引擎不知道数据变了。检查方法是看属性定义里是否用了SetProperty如果你的代码是_userName value然后没有调用通知那页面不可能刷新。二是通知了错误的属性名。当视图绑定了UserName但通知写成了Username少一个N绑定引擎找不到匹配静默失败。三是DataContext没有设置。ViewModel没有挂到界面的数据上下文上绑定找不到来源这种情况在控制台会有绑定错误输出但很多人忽略它。四是绑定的属性路径写错比如控件绑到了ViewModel的一个子对象的属性上但子对象是null也会导致没有反应。排查绑定问题的推荐工具是调试时看一眼输出窗口。WPF的绑定错误会打印到调试输出里格式大致是BindingExpression path error: Xxx property not found看到这个信息基本就能定位。我在编码时习惯先关掉“仅我的代码”让绑定错误全部显示出来不然有些错误被吞掉排查效率非常低。5.2 ViewModel内存泄漏的排查与规避MVVM架构里有一个企业级项目一定会踩的坑ViewModel和View互相引用导致对象无法被垃圾回收。典型的场景是这样的View构造ViewModel并为其挂上事件订阅比如订阅了某个全局消息或服务的通知事件但View关闭时忘了取消订阅。由于事件发布者持有订阅者引用你的ViewModel甚至整个View都不会被释放内存占用越来越高。我在排查这类泄漏时的经验是关掉界面的生命周期里主动调用ViewModel的Cleanup方法把事件订阅全部解除。另一个常见泄漏源头是命令绑定导致的事件持有。某些框架里按钮的Command绑定会让我方持有View如果我在View的代码里手动订阅了CommandManager的RequerySuggested而不取消也会形成引用链。我的经验是ViewModel里的事件尽量用弱事件模式WeakEvent或者统一在Cleanup里退订双保险。5.3 过度MVVM化架构洁癖的成本MVVM是一个工具不是信仰。我在项目里见过最离谱的做法是连一个简单的提示框弹窗都要走命令加服务一个只读的Label显示固定文本也要建一个ViewModel属性一个窗口里十几个控件每个都要绑定一个命令。这种过度设计让简单的功能写起来复杂了三倍团队成员每次改需求都要在五六个文件之间来回跳。有一个判断标准可以帮你拿捏分寸如果一个操作只是纯粹的UI行为比如调整窗口大小、收起展开某个面板不涉及业务逻辑和数据处理那完全可以直接写事件处理没必要套命令。如果一个属性只是界面内部状态、不需要持久化也不需要被其他逻辑消费那也没必要放进ViewModel。MVVM的价值在于让业务逻辑变得可测试和可维护而不是让所有UI代码都禁止出现在View层。5.4 异步操作的竞态问题处理MVVM里大量涉及异步操作比如登录、加载列表、上传文件。一个隐蔽的问题叫“竞态条件”用户先后触发了两次异步操作第一次的结果还没回来第二次的结果先到了界面显示的数据是新旧混杂的。标准做法是引入“请求序号”机制。每次发起异步操作时递增一个计数器异步结果回来时检查序号是否还是自己发起操作时的序号如果不是就丢弃结果。这种方法在WPF和Android里都适用代码也不复杂private int _requestId; private async void ExecuteLoad() { var currentId _requestId; IsBusy true; try { var result await _service.LoadAsync(); if (currentId ! _requestId) return; // 过期结果直接丢弃 Items result; } finally { IsBusy false; } }这个问题在旧的项目里几乎没人处理因为传统事件驱动开发中异步回调也是同样的问题但MVVM的封装让异步操作变得更多更密集所以更值得重视。特别是做移动端或者数据刷新频繁的桌面应用竞态处理一定要做到位。6. 踩坑三四年后我对MVVM的个人体会6.1 什么时候不该用MVVM聊了这么多MVVM的好处我也想泼点冷水。不是所有项目都适合上MVVM。如果你的项目只是一个几千行代码的小工具界面总共两三个窗口业务逻辑简单到一眼看穿强行引入MVVM只会增加代码量和理解成本反而拖慢开发速度。我的判断标准很简单如果这个项目的UI逻辑需要写单元测试或者未来大概率会不断扩充业务比如从单窗口长成多模块或者团队不止一个人同时改界面和逻辑那MVVM能给你带来明显收益。如果只是个人写个内部工具、生命周期只有几个月那保持轻量就好。架构是为业务服务的不是为了好看。6.2 团队协作中MVVM的约定大于配置MVVM没有统一的官方标准不同人写出来的MVVM风格差异可以非常大。有人喜欢把所有逻辑塞进ViewModel有人习惯在ViewModel里再拆业务服务层有人命令全部用AsyncRelayCommand有人只用普通RelayCommand配合async void。团队协作时这些问题不统一代码review成本会直线上升。我的建议是项目一开始就定一份简单的MVVM约定文档内容包括ViewModel命名规则后缀统一用ViewModel、命令统一在构造器里初始化、所有耗时操作必须走异步命令不允许裸用async void、服务接口一律放Services层。这些约定写清楚之后新人上手成本会低很多代码风格也不会五花八门。6.3 最后分享一个分组校验的小技巧MVVM里表单校验是个绕不开的需求。我做过一个还算好用的方案ViewModel基类里维护一个字典记录每个属性的错误信息校验时分组执行验证规则把错误消息汇总到界面统一展示。public class ValidationViewModelBase : ViewModelBase { private readonly Dictionarystring, string _errors new(); public bool HasErrors _errors.Count 0; protected void ValidateProperty(string propertyName, Funcbool rule, string errorMessage) { if (rule()) { _errors.Remove(propertyName); } else { _errors[propertyName] errorMessage; } } public bool ValidateAll() !_errors.Any(); }界面只需要在按钮命令执行前先调ValidateAll()不符合就提示错误并终止操作。这套方案比挨个控件校验要集中、可控而且能被单元测试覆盖。团队里后来的人加新校验时只需要在ViewModel里多写一行规则代码不用去动界面这也是MVVM带来的日常便利。MVVM这条路我走了好几年从最开始觉得它纯粹是增加工作量到后来在大型项目里尝到甜头再到现在能比较清晰地判断什么场景该用什么程度的架构最大的体会是架构模式的真正价值不是让代码“看起来高级”而是让未来的自己和同事改代码时少一点提心吊胆。如果你正在一个逻辑纠缠不清的界面项目里挣扎不妨从拆一个最简单的窗口开始用数据绑定的方式把逻辑挪进ViewModel里试试。那一步迈出去后面的路会顺很多。
返回列表