2026/10/21 7:57:17

WPF MVVM登录窗口完整示例:从分层设计到单元测试

WPF MVVM登录窗口完整示例:从分层设计到单元测试 简介一份基于WPF MVVM模式的登录窗口完整示例围绕MVVM核心分层、数据绑定、命令驱动、PasswordBox安全绑定、RadioButton偏好设置、数据验证以及登录成功后窗口导航等关键知识点展开适合正在学习WPF桌面开发或希望规范项目结构的初中级开发者。压缩包共71个文件以31个C#源码文件、3个XAML界面文件为主并包含编译好的exe与dll动态库、运行配置文件及图片资源等整体仅258KB便于快速下载与运行对照。已有10153人学习示例解决方案组织规范可直接在Visual Studio中打开LoginDemo.sln运行调试。通过研读源码读者既能掌握MVVM模式下业务逻辑与界面解耦的具体写法也能学习事件到命令的转换、输入验证和页面导航的实现思路非常适合作为WPF入门练手或课程设计的参考模板。1. 为什么一个登录窗口也要MVVM从耦合到可测试的转变很多初学WPF的同学觉得登录窗口这么简单的界面直接在后端代码里写个按钮点击事件读两个输入框比对一下账号密码就完事了为什么要绕一大圈搞MVVM这个想法我完全理解直到我第一次在一个真实维护项目里改登录逻辑时才意识到把校验规则、登录状态、错误提示全塞进窗口后台代码改一个需求就要在界面事件里翻半天想写单元测试根本没有入口。后来我把这个登录窗口用MVVM重写了一遍结构立刻清晰了。这套WPF MVVM完整示例登录窗口要解决的问题其实就三个界面代码与业务逻辑彻底解耦、登录状态可被单元测试验证、后续加“记住密码”或“验证码”时不用重写窗口。适合正在做WPF课设或毕设登录模块的同学也适合想把旧登录代码重构为MVVM的在职开发者参考。2. 总体架构设计MVVM三层的边界划分与工程目录设计从一个只有XAML和一行事件的文件重构成View、ViewModel、Model三层最怕的是把MVVM做成“换汤不换药”也就是ViewModel里全是界面控件的直接引用那还不如不拆。下面用这个登录窗口为例讲清楚三层各自管什么、不能碰什么。2.1 View层只做两件事声明控件和绑定数据View在MVVM里的职责边界非常窄它只负责把界面长什么样描述清楚以及告诉WPF“这个控件的显示内容从哪来、这个按钮的动作由谁提供”。用在这个登录窗口上就是窗口里那两个输入框、一个密码框、一个登录按钮、一个错误提示文本全部通过绑定指向ViewModel的属性。这里有个容易混淆的点很多人觉得View要“瘦”就是什么代码都不写。实际不是这样View里可以写纯界面逻辑比如窗口大小变化时调整某个元素布局这没问题。关键是不能写“业务判断”比如“账号不为空才允许点登录”这种话只能在ViewModel里说不能在窗口后台代码里if。我在这个示例里XAML的根节点DataContext直接指向登录ViewModel的一个实例。如果以后要接依赖注入容器把DataContext的赋值挪到App启动时统一处理就行窗口本身不需要知道ViewModel是怎么new出来的。2.2 ViewModel层界面状态的管家不碰任何控件登录ViewModel是这个示例里最核心的部分。它对外暴露三样东西绑定用的属性、命令、验证逻辑。属性包括用户名、密码、错误信息、是否正在登录命令就是登录按钮绑定的那个验证逻辑指的是IDataErrorInfo或INotifyDataErrorInfo的实现用来告诉界面哪个输入框错了。ViewModel绝对不允许出现这两类东西一是对控件的直接引用比如找到密码框去取密码二是对消息框、弹窗这类界面交互的直接调用。那登录成功之后想弹个提示怎么办我一般会在ViewModel里暴露一个事件由View订阅后弹窗。或者更干净的方案是引入一个简单的对话框服务接口登录成功触发的动作由对应服务去执行。这个示例为了控制代码量用的是事件通知方案足够说明问题。2.3 Model层与登录服务唯一允许做网络和数据库操作的地方Model层在这个工程里分两部分用户数据模型和登录服务。用户数据模型就是用户名、密码、登录时间这类纯属性类登录服务负责真正校验账号密码从哪来可能是在内存里比对写死的测试账号也可能是调一个HTTP接口还可能是查数据库。这个分层有实际好处如果课程设计的验收老师问“登录成功之后跳转主窗口的逻辑写在哪儿”你可以直接指到服务接口的返回值上而不是在按钮点击事件里翻。把登录服务的接口定义成抽象接口后ViewModel不依赖具体实现写单元测试时传一个假的登录服务进去就能验证ViewModel在服务返回成功和失败时的表现是否与预期一致。下面给出这个示例的工程目录划分大家照抄即可LoginDemo/ │ App.xaml / App.xaml.cs │ MainWindow.xaml / MainWindow.xaml.cs │ ├─ Models/ │ UserModel.cs │ ILoginService.cs │ MockLoginService.cs │ ├─ ViewModels/ │ LoginViewModel.cs │ ViewModelBase.cs │ DelegateCommand.cs │ └─ Views/ LoginWindow.xaml / LoginWindow.xaml.cs哪一层放哪个文件一目了然。Views里只有窗口ViewModels里只有绑定和命令Models里只有数据与服务。单看目录没有代码的人也能说出这个项目的大致结构这就是分层设计最直接的收益。通常我还会在解决方案里单独建一个Tests项目用来放后面要讲的单元测试不过示例工程为了控制篇幅没有把测试项目加进去大家自己新建时记得加。3. 核心实现命令系统与属性通知的落地写法3.1 命令系统用DelegateCommand替代按钮点击事件WPF按钮默认有Command属性但绑定命令要求命令对象实现ICommand接口。这个接口有三个成员CanExecute决定按钮可不可点Execute执行动作CanExecuteChanged通知界面重新判断。直接在ViewModel里手动实现这三个东西很啰嗦所以标准做法是封装一个DelegateCommand有的项目里叫RelayCommand。从.NET Framework时代到.NET Core时代这个写法几乎没有变过。我用在登录示例里的命令类如下public class DelegateCommand : ICommand { private readonly Actionobject _execute; private readonly Funcobject, bool _canExecute; public DelegateCommand(Actionobject execute, Funcobject, bool canExecute null) { _execute execute; _canExecute canExecute; } public bool CanExecute(object parameter) { return _canExecute null || _canExecute(parameter); } public void Execute(object parameter) { _execute(parameter); } public event EventHandler CanExecuteChanged { add { CommandManager.RequerySuggested value; } remove { CommandManager.RequerySuggested - value; } } }一个简单的封装把WPF对ICommand的要求全兜住了。注意CanExecuteChanged的写法这里直接挂到WPF的CommandManager上好处是WPF会在界面状态可能变化时自动重新查询所有命令的可执行状态。比如用户名输入框内容变了系统会重新调用LoginCommand的CanExecute按钮的禁用状态就能自动更新。如果想手动触发也可以调用CommandManager.InvalidateRequerySuggested。3.2 属性通知INotifyPropertyChanged与防拼写错误绑定要生效属性必须实现INotifyPropertyChanged。每个ViewModel重复写这段代码太累常规做法是抽一个基类出来public abstract class ViewModelBase : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } protected bool SetPropertyT(ref T storage, T value, string propertyName) { if (Equals(storage, value)) return false; storage value; OnPropertyChanged(propertyName); return true; } }SetProperty方法里有判断值是否相等的逻辑避免每次赋值都触发界面刷新。这里有个实际经验属性名的字符串传错编译不会报错界面上那个控件就永远不刷新这是MVVM新手最容易踩的坑。现在也有用CallerMemberName特性自动取属性名的方式但它拿到的名字里带有“Property”这个单词属性是UserName的话会自动带出UserNameProperty。我的做法是直接显式传字符串结合后文的单元测试来保证不写错。3.3 数据验证用IDataErrorInfo让输入框自动显示错误登录窗口最基础的校验是“用户名不能为空”和“密码不能为空”。如果这两条写在Execute方法里面弹MessageBox用户体验不太好。合理的做法是让ViewModel实现IDataErrorInfo接口输入框通过绑定上的ValidatesOnDataErrors开启校验public class LoginViewModel : ViewModelBase, IDataErrorInfo { private string _userName; private string _password; private string _errorMessage; private bool _isLoggingIn; public string UserName { get _userName; set SetProperty(ref _userName, value, nameof(UserName)); } public string Password { get _password; set SetProperty(ref _password, value, nameof(Password)); } public string ErrorMessage { get _errorMessage; set SetProperty(ref _errorMessage, value, nameof(ErrorMessage)); } public bool IsLoggingIn { get _isLoggingIn; set SetProperty(ref _isLoggingIn, value, nameof(IsLoggingIn)); } public string this[string columnName] { get { string result null; if (columnName nameof(UserName)) { if (string.IsNullOrWhiteSpace(UserName)) result 用户名不能为空; } else if (columnName nameof(Password)) { if (string.IsNullOrWhiteSpace(Password)) result 密码不能为空; } return result; } } public string Error null; }配合XAML里输入框绑定的写法TextBox Text{Binding UserName, ValidatesOnDataErrorsTrue, UpdateSourceTriggerPropertyChanged} / PasswordBox PasswordChangedPasswordBox_PasswordChanged /第二个控件故意用的PasswordBox因为密码框的Password属性不是依赖属性不能直接绑到ViewModel上这一块在下一章专门讲。IDataErrorInfo的局限是一次只能返回一个错误如果两个输入框都为空只会提示第一个错误。如果想要更完备的多错误提示可以改用INotifyDataErrorInfo接口更复杂但能一次性罗列所有错误。课程设计用IDataErrorInfo足够验收时能说清楚两种方案的取舍即可。4. 登录业务实现异步服务对接、密码框特例与状态反馈4.1 异步登录防止UI卡死的标准写法登录如果调的是网络接口或者数据库最怕的就是点完登录按钮界面卡住。原因在于Execute里做了耗时操作UI线程被占住了。解决办法是用async/await把耗时操作丢到后台线程去执行。很多人在自己的代码里写“async void”事件处理器这没问题但如果在ViewModel的命令Execute里直接写async void异常会很难捕获。所以我让DelegateCommand直接支持异步执行或者更简单一点在Execute方法体里用async/await配合try/catch把所有异常包住public async void ExecuteLogin(object parameter) { ErrorMessage string.Empty; if (string.IsNullOrWhiteSpace(UserName) || string.IsNullOrWhiteSpace(Password)) { ErrorMessage 请填写完整登录信息; return; } IsLoggingIn true; try { var user await _loginService.LoginAsync(UserName, Password); LoginSuccessEvent?.Invoke(user); } catch (Exception ex) { ErrorMessage ex.Message; } finally { IsLoggingIn false; } }async/await在这里的真实价值是await之后的代码会回到UI线程继续执行所以LoginSuccessEvent里的界面操作不用手动切换线程。核心点在于LoginAsync本身是异步方法。模拟服务的写法如下public class MockLoginService : ILoginService { public async TaskUserModel LoginAsync(string userName, string password) { await Task.Delay(1500); // 模拟网络延迟 if (userName admin password 123456) { return new UserModel { UserName userName, LoginTime DateTime.Now }; } throw new Exception(用户名或密码错误); } }Task.Delay模拟的是真实网络或数据库延迟1.5秒足够看到登录按钮进入“登录中”的禁用状态也方便截图放进答辩PPT演示。如果验收老师问“为什么不在按钮点击事件里直接写登录逻辑”答案就是现在的登录逻辑不依赖任何具体控件换到任何窗口都能用而且能接测试。4.2 密码框的MVVM处理不用依赖属性也能传密码WPF的PasswordBox有一个很出名的问题Password属性不参与绑定。所以MVVM里大家都不直接绑它而是通过PasswordChanged事件把值转交给ViewModel。这个示例采用的方法是在窗口后台代码里写一行转发逻辑private void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e) { var passwordBox sender as PasswordBox; loginViewModel.Password passwordBox.Password; }这种写法严格来说是“妥协方案”因为ViewModel还是接触到了界面。另一种更彻底的方案是用附加属性把PasswordBox的Password包装成可绑定属性代码量多几十行但对追求纯MVVM的人来说更干净。我建议课设里用这行转发就够了理由有两条一是代码量少容易讲清楚二是PasswordBox的明文密码不出ViewModel层如果你不用绑定方式把密码留在PasswordBox内部反而更安全。4.3 登录状态反馈按钮禁用、加载指示与错误提示登录状态是用户直接感知的部分。示例里通过IsLoggingIn控制登录按钮的可用性属性变化会触发LoginCommand的CanExecute重查。XAML端的绑定如下Button Content登 录 Command{Binding LoginCommand} Width200 Height36 / TextBlock Text{Binding ErrorMessage} ForegroundRed / ProgressBar IsIndeterminateTrue Visibility{Binding IsLoggingIn, Converter{StaticResource BoolToVisConverter}} /注意Visibility绑定需要一个BooleanToVisibilityConverter这是WPF内置的转换器在资源里声明后直接用。绑定的ErrorMessage用红色前景色显示登录失败后用户能看到具体是网络错误还是账号密码错误。记住一个细节当登录失败时ErrorMessage会先显示错误IsLoggingIn变回false按钮恢复可点状态。这个顺序依赖finally块的存在。有的同学把IsLoggingIn false写在catch里也在finally里写就会出现重复赋值虽然结果一样但final写法最保险无论成功失败都恢复按钮。从那以后我写这类异步方法一律用finally恢复状态算是吃过一次“异常抛了但按钮永远置灰”的亏换来的习惯。4.4 登录成功后的跳转事件通知 vs 窗口负责切换登录成功后怎么去主窗口最简单的写法是在LoginWindow.xaml.cs里订阅LoginSuccessEventloginViewModel.LoginSuccessEvent OnLoginSuccess; private void OnLoginSuccess(UserModel user) { var mainWindow new MainWindow(); mainWindow.Show(); Close(); }注意这里把Close放在Show之后是因为先关窗口会导致新窗口没来得及显示。实质问题是跳转窗口属于View层行为ViewModel只需要发出“登录成功”这件事具体怎么跳由View决定。这就是MVVM里“事件驱动”的典型写法答辩时从这里展开讲解说明你理解了V和VM的边界是怎么划分的。我一般会在项目里用DialogService替代直接new窗口把窗口的创建集中到一个服务里这样登录成功后的跳转逻辑也能写单元测试。不过示例代码用事件方案方便同学直接看懂。两种方案在答辩时对比着讲是一个很好的加分点。5. 常见问题排查MVVM登录项目最容易翻车的五个地方5.1 绑定的属性怎么改界面都不刷新现象界面上的用户名输入框输入内容后错误提示文本没有反应或者登录成功后用户信息没有显示。原因ViewModel属性没有触发PropertyChanged事件或者属性名写错。最常见的是setter里没有调用OnPropertyChanged比如把set写成auto property其次是bind时写{Binding UserName }多了个空格居然也能绑上但收不到通知。解决先看ViewModel的属性是否继承ViewModelBase再检查setter是否合并了SetProperty调用。最有效的排查工具是打开WPF的Output窗口绑定失败时会有警告但前提是勾选了绑定错误跟踪。这个示例所有属性都走SetProperty属性通知不刷新基本就是XAML绑定路径的问题。5.2 登录按钮一直置灰或者一直可点现象用户名输入了登录按钮还是灰色的或者登录过程中按钮还能再点一下。原因CanExecute依赖的属性和命令重查机制没有联动。如果DelegateCommand里CanExecute判断的是IsLoggingIn但IsLoggingIn变化时WPF不知道要重查这个命令就会保持旧状态。解决示例里的DelegateCommand因为在CanExecuteChanged里挂了CommandManager.RequerySuggested任何属性变化都可能触发重查。如果自己写命令类没有挂这个事件就需要在IsLoggingIn的setter里手动调用LoginCommand.RaiseCanExecuteChanged()。两种方法选其一不要两个都做否则会出现重复重查。5.3 异步登录后界面卡死或异常无法恢复现象点击登录后界面无响应或者等待很久后弹出“跨线程操作无效”的报错。原因Execute方法里同步执行了登录逻辑。例如把LoginAsync写成了Task.FromResult效果跟同步一样阻塞UI线程或者登录服务里用了Thread.Sleep。解决登录服务里等待用await Task.Delay不要用Thread.Sleep。Execute方法标记async后await的后续操作自动回到UI线程不需要检查Dispatcher。如果是验证码刷新这类高频操作同样优先async/await。真正的卡死还有一个原因就是登录成功后忘记把IsLoggingIn置回false按钮一直禁用看起来像卡住了。5.4 密码框拿不到密码值现象登录服务里收到的密码永远是空字符串。原因PasswordBox.Password不是依赖属性绑定无效后台事件没写。很多人绑了Password但是不报错也不执行。解决按本文4.2的写法在PasswordChanged事件里给loginViewModel.Password赋值。有一点要注意如果窗口的DataContext在构造之后被替换了后台代码里保存的loginViewModel还是旧实例赋值会赋到旧对象上界面绑定的新实例没收到值。此时每次取Password应该从DataContext读取private void PasswordBox_PasswordChanged(object sender, RoutedEventArgs e) { var passwordBox sender as PasswordBox; var viewModel DataContext as LoginViewModel; if (viewModel ! null passwordBox ! null) { viewModel.Password passwordBox.Password; } }这种写法在DataContext被替换后依然有效。我做模拟项目时遇到过DataContext被绑定到别处导致密码丢失的情况从那以后一律改成从DataContext现取再也不直接用字段引用了。5.5 登录失败后界面没有错误提示现象账号密码错误时界面没有任何反应像没点过一样。原因登录服务抛出的异常没有被捕获。Execute里如果是同步方法异常会冒泡到WPF的Dispatcher直接Crash如果是async void且没有try/catch异常也会直接终结应用。解决把Execute方法体完整包裹在try/catch中catch块里把异常Message赋给ErrorMessage。注意不要把异常细节拼到错误提示里比如数据库连接失败时不要把连接字符串信息露给用户显示“服务暂时不可用请稍后重试”这种安全措辞原始信息记录到日志或Debug输出里。这个小细节在答辩时提到会显得有安全意识。6. 验收与答辩用一条检查清单证明你的MVVM没白做验收一套登录窗口的MVVM工程先别急着跑界面按下面的顺序检查代码每一条都是老师或评审可能问你的点先看工程的目录结构是否分了Models、ViewModels、Views这是MVVM的第一印象。然后打开LoginViewModel确认里面没有任何控件类型出现尤其不能出现PasswordBox、TextBox、MessageBox。再开LoginWindow.xaml.cs这个文件如果超过30行就要反思后台代码里除了构造、订阅事件和跳转窗口不应该再塞其他内容。功能验收过一遍空用户名时点击登录按钮按钮应该是禁用而不是弹窗输入用户名不输入密码时按钮同样禁用登录过程中按钮不可点进度条或转圈提示可见密码正确进入主窗口错误显示红色错误提示。这五条每条对应一个设计决策在答辩时可以逐个说。最后做一次单元测试运行这是最容易被忽视但最能证明MVVM价值的部分[TestMethod] public void Login_WithInvalidCredentials_ReturnsErrorMessage() { var mockService new MockLoginService(); var viewModel new LoginViewModel(mockService); viewModel.UserName admin; viewModel.Password wrong; viewModel.LoginCommand.Execute(null); Assert.IsFalse(string.IsNullOrEmpty(viewModel.ErrorMessage)); }模拟服务返回失败结果验证ViewModel的错误处理逻辑。只要ViewModel不依赖界面这种测试就能跑通。这里的关键是LoginCommand的Execute是同步执行完的Test方法不需要等待异步测试本身会很流畅。这是MVVM项目在答辩现场最好的演示材料。答辩时被人追问“为什么不用CodeBehind”千万别一句“这是MVVM的规范”糊弄过去。正确的答法是这段登录逻辑有账号校验、有异步等待、有错误处理这些都需要被独立测试。CodeBehind版本没法脱离窗口写测试MVVM版本可以把这些逻辑放到Model和ViewModel层窗口只负责把改动渲染出来。再从工程维护的角度说以后要加一个“验证码登录”标签页CodeBehind方案可能要复制一遍登录逻辑而MVVM只需要加一个验证码ViewModel复用一个登录服务。当初我给某课程设计写这套登录模块的时候先用CodeBehind写了一版功能不到两小时就通了后来导师要求拆成MVVM我又花了差不多一个晚上重写。重写过程里最大的收获不是命令怎么用而是明白了一个教训代码结构好不好不取决于项目大小而取决于这段逻辑以后会不会继续变。从那以后我每次写界面逻辑默认先往“能不能测、能不能换、能不能加功能”这三个方向过一遍。希望这篇文章能帮你把登录窗口做出一个既看得见结果、也经得住追问的完整方案。本文还有配套的精品资源点击获取