简介:一份基于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 Action<object> _execute; private readonly Func<object, bool> _canExecute; public DelegateCommand(Action<object> execute, Func<object, 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 SetProperty<T>(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, ValidatesOnDataErrors=True, UpdateSourceTrigger=PropertyChanged}" /> <PasswordBox PasswordChanged="PasswordBox_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 Task<UserModel> 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}" Width="200" Height="36" /> <TextBlock Text="{Binding ErrorMessage}" Foreground="Red" /> <ProgressBar IsIndeterminate="True" 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里订阅LoginSuccessEvent:
loginViewModel.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,我又花了差不多一个晚上重写。重写过程里最大的收获不是命令怎么用,而是明白了一个教训:代码结构好不好,不取决于项目大小,而取决于这段逻辑以后会不会继续变。从那以后我每次写界面逻辑,默认先往“能不能测、能不能换、能不能加功能”这三个方向过一遍。希望这篇文章能帮你把登录窗口做出一个既看得见结果、也经得住追问的完整方案。
本文还有配套的精品资源,点击获取