多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

WPF完全实战指南:从布局、依赖属性到MVVM与现代UI

WPF完全实战指南:从布局、依赖属性到MVVM与现代UI 1. XAML布局与依赖属性入门时最容易连环踩坑的两件事先说说我自己的感受。带过不少新人也看过很多初学WPF的人写的第一个项目十有八九会卡在两个地方一个是布局另一个是依赖属性。XAML这东西看起来就是XML加了一些标签写起来也很像HTML可真到用的时候很多人会发现为什么我摆了半天的控件一运行就乱成一团为什么我改了一个属性界面毫无反应。这其实不是WPF的问题而是我们还没建立正确的思维模型。1.1 Hello World不是重点窗口结构才是大部分教程都会让你先跑一个Hello World。这当然没错但我想说的是跑通Hello World之后你应该做的第一件事是把你自动生成的MainWindow.xaml从头到尾读一遍。我第一次学WPF时就直接把里面那行Grid删了换成一个StackPanel结果窗口缩放时控件位置很尴尬后来才明白每种布局容器都有自己的适用场景。WPF的布局容器核心就是那几种Grid、StackPanel、DockPanel、WrapPanel、Canvas。Grid是使用频率最高的因为它允许你像表格一样划分行和列而且支持比例尺寸Star、绝对尺寸Pixel和自适应尺寸Auto三种模式。初学时最容易犯的错就是什么东西都用Canvas去定位用绝对坐标写界面。这样看着是排好了但窗口一变大小整个界面就散架。真正应该优先用Grid做整体框架用StackPanel做垂直排列用DockPanel做边缘停靠只有遇到绘图、贴片这种需要精确定位的场景才用Canvas。另外一个隐藏很深的坑是Margin和Padding的区别。Margin是控件外部的间距Padding是控件内部的留白。很多新人把这两个混着用结果界面间距莫名其妙的改来改去也调不对。记住一句话Margin管的是这个控件和邻居的距离Padding管的是这个控件的内容和它的边框的距离。1.2 依赖属性为什么CLR属性在WPF里不够用很多第一次接触WPF的人会好奇一件事情为什么不能直接像WinForms那样给控件写一个普通的string属性然后在XAML里赋值答案很简单因为普通的CLR属性没法支持WPF的绑定、动画、样式这些核心机制。这里就要说一个关键概念依赖属性。它跟普通属性的区别在哪依赖属性不是把值存在对象本身的一个字段里而是放在一个全局的键值表里。你可以理解为每个依赖属性都有一个身份证号DependencyProperty注册时生成的全局唯一标识WPF框架会根据这个身份证号去一个公共的地方查属性值。这样做的好处是框架可以统一管理属性的变更通知、样式继承、数据绑定还能节省大量内存因为不是每个控件实例都必须持有自己全部属性的值。初学阶段你不需要手写太多依赖属性但需要对它有基本认知因为你会看到大量代码里面出现{Binding Path...}、PropertyChanged这些机制底层全是依赖属性在工作。当你发现自己写了一个普通属性然后XAML绑定了半天没反应十有八九就是因为你没有把它实现成依赖属性。还有一点值得注意依赖属性是有优先级体系的。比如你通过本地值设了一个背景色又通过Style设置了一个背景色最终显示的是本地值。这套优先级初学不用背全但要知道为什么我明明在Style里写了背景色运行起来却不是我想要的颜色这类问题可能不是代码错了而是优先级在发挥作用。2. 从数据绑定说起绑定Mode、DataContext与通知机制如果你问一个用过几年WPF的人什么是他对WPF最深的印象十个人里有九个会回答数据绑定。数据绑定这个东西优点和坑一样多。用好了它能让你的代码量减少一半界面和逻辑真正分离用不好你会陷入为什么我明明赋值了界面不刷新的泥潭。2.1 绑定的两段式语法先定源再定路径WPF里最基本的绑定写法是TextBlock Text{Binding UserName} /这里有两层含义。第一层是绑定源是谁——默认是当前控件的DataContext第二层是绑定源的哪个属性——就是路径Path上面的写法省略了Path完整写法是{Binding PathUserName}。很多初学者不理解DataContext是干嘛的。我打个比方DataContext就是一块黑板你在控件上写{Binding UserName}相当于说去找黑板上的UserName标签至于黑板是谁贴上去的你不用管。WPF的绑定会沿着控件树向上找DataContext如果你给窗口设置了一个DataContext那么窗口里所有控件的默认数据源都是它。这条规则非常重要它也是MVVM的基础。但是这个向上找的机制也会带来一些奇怪的坑。比如你给某个Grid设置了DataContext里面有一个按钮你给按钮的Command绑定了一个命令却发现按钮点击没反应。原因可能是你在按钮的某个父级容器上又重新设置了DataContext导致命令绑定找的地方不对。排查这类问题的时候可以在VisualTree上找到那个控件看看它的DataContext到底是谁。2.2 ModeOneWay、TwoWay、OneTime到底怎么选绑定模式是初学阶段必须吃透的一组概念。我之前见过太多新人不管什么属性都写ModeTwoWay理由是这样最安全两边都同步。但在某些场景下这反而会带来不必要的性能开销甚至导致奇怪的行为。简单总结一下OneWay源变化推送到目标。适合只展示、不修改的场景比如列表项里的文本。TwoWay源和目标双向同步。适合输入控件比如TextBox的Text属性。OneTime只在初始化时绑定一次后续不再更新。适合那些启动后不会变的数据可以稍微提升性能。OneWayToSource目标变化反推回源用的相对少但某些场景很实用。一个最典型的例子是TextBox绑定ViewModel的属性。如果你不写ModeTwoWay用户在文本框里输入的内容不会更新到你后台的属性上。因为TextBox.Text的默认绑定是OneWay用户输入改变的是控件的Text却没有把变化推给源。这一点几乎每个新手都会遇到。如果你希望用户每敲一个字符就触发一次后台逻辑还需要在绑定上添加UpdateSourceTriggerPropertyChanged默认的LostFocus是等文本框失去焦点才把值推回去。有些场景你希望实时搜索那就必须设置成PropertyChanged。这个细节在写搜索框的时候尤为明显。2.3 INotifyPropertyChanged忘记它就是绑定失效的开始很多人写的ViewModel是这样的public class MainViewModel { public string UserName { get; set; } }然后绑定{Binding UserName}运行发现界面显示的永远是默认值。为什么因为你的属性变化时WPF根本不知道。绑定的本质是订阅属性变更通知如果你的类没有实现INotifyPropertyChanged不主动广播变化界面就不会刷新。正确的写法是让ViewModel继承INotifyPropertyChanged并在每个属性的setter里调用PropertyChanged事件public class MainViewModel : INotifyPropertyChanged { private string _userName; public string UserName { get _userName; set { if (_userName ! value) { _userName value; OnPropertyChanged(nameof(UserName)); } } } public event PropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged(string propertyName) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }这里有几个经验。第一建议在setter里先判断值是否真的变化了再触发通知可以避免大量不必要的界面刷新第二触发通知的属性名建议用nameof(...)如果你手写字符串以后重构改名就会漏改界面会莫名其妙不更新。第三如果你的属性是计算属性比如全名 名 姓那么当名或姓变化时你还要额外触发一个OnPropertyChanged(nameof(FullName))否则界面上那个显示全名的控件不会刷新。这个级联通知的问题是实际开发中很常见的隐性bug。集合也有对应的通知版本ObservableCollectionT。如果你给ItemsControl绑定了一个普通List往里面Add一条数据界面不会出现新条目因为List不通知变化。只有用ObservableCollection并且通过它的Add/Remove方法修改集合界面才会自动刷新。3. ModernUI改造为什么默认控件会被一眼认出以及如何做出质感WPF做出来的界面如果只是往窗口上堆一堆默认控件效果基本就是旧时代老软件的观感。这其实不是WPF的锅而是框架刚发布时的审美标准是Windows 2000之后的那个时代。现在大家都习惯了Win10/11那种圆角、柔和阴影、深浅主题的视觉风格再用WPF写的默认界面就会显得很突兀。好在WPF的控件模板机制给了我们很大的改造空间。3.1 控件的皮与骨分离模板机制的理解在WPF里控件的骨是它的逻辑行为和属性集合而皮是它的视觉外观——模板ControlTemplate。你可以把一个Button的模板完全替换掉让它看起来像一个自定义卡片同时仍然保留Button的点击、悬停、键盘触发这些逻辑。这一点非常有意思也正是ModernUI改造的根基。举个例子默认Button有个特别明显的特征带边框、带背景色、鼠标悬停会有系统默认的浅蓝。如果我们想做一个圆角、带悬浮阴影的按钮方法是重写它的TemplateButton Content登录 Button.Template ControlTemplate TargetTypeButton Border x:Nameborder Background{TemplateBinding Background} CornerRadius6 Padding12,6 ContentPresenter HorizontalAlignmentCenter VerticalAlignmentCenter/ /Border ControlTemplate.Triggers Trigger PropertyIsMouseOver ValueTrue Setter TargetNameborder PropertyBackground Value#FF5B8CFF / /Trigger /ControlTemplate.Triggers /ControlTemplate /Button.Template /Button这里有几个关键点。TemplateBinding是模板内控件与模板持有者属性之间的绑定比如上面把Button的Background属性传到内部的Border上。ContentPresenter负责显示Button的Content内容你写什么文字或控件它都会显示在那个位置。Trigger用于定义鼠标悬停时背景变化的状态切换。这种模板重写的能力让你可以像换皮肤一样把整套控件风格重构一遍。如果你不想自己手写每个控件的模板也可以找一找现成的开源控件库比如HandyControl和MaterialDesignInXAML。这两个库在WPF社区里很常用前者偏国产风格、组件丰富后者是完全遵循Material Design风格的库。需要提醒的是引用第三方控件库可能会引入依赖问题比如MaterialDesignInXAML对版本和主题资源要求比较高第一次配置的时候要按照文档走别跳步骤。3.2 窗口的现代化园角、阴影、标题栏与透明背景除了控件模板窗口本身的样式也是ModernUI改造的重点。默认窗口是一个带标准标题栏的矩形顶部还有系统菜单按钮一眼看上去就很框架默认。想让窗口看起来更现代常见的做法是设置WindowStyleNone去掉系统标题栏设置AllowsTransparencyTrue和BackgroundTransparent让窗口支持透明背景自己实现标题栏区域拖拽移动、关闭、最大化最小化这些逻辑通过代码或命令处理。不过这里我第一个要提醒的是WindowStyleNone加AllowsTransparencyTrue会关闭硬件的某些渲染加速能力如果窗口开得太频繁或者机器性能一般拖动窗口时可能会出现卡顿。我在老笔记本上遇到过很明显的拖拽掉帧后来用普通窗口配合WindowChrome类解决了。WindowChrome可以在保留系统窗口特性的同时自定义外观是性能和外观折中的更好方案。窗口圆角的实现方式一般是给根节点内容外面包一层带CornerRadius的Border同时窗口本身保持透明。这样四个角落就是圆角效果视觉上比直角柔和很多。小技巧是圆角值和背景色要保持统一如果窗口背景和Border背景不一致边缘会出现白边或发虚。阴影怎么加现代UI很依赖阴影来制造层次感。最简单的方式是使用DropShadowEffectBorder BackgroundWhite CornerRadius8 Border.Effect DropShadowEffect BlurRadius20 ShadowDepth2 Opacity0.2 / /Border.Effect /BorderBlurRadius控制模糊程度越大越柔和ShadowDepth控制阴影偏移太大会显得飘Opacity控制阴影透明度太浓会发灰。这个需要反复调每个项目感觉都不一样。3.3 图标、字体与间距低门槛高回报的细节很多人以为ModernUI一定要写复杂的自定义控件其实不是。有时候把按钮上的文字换成图标把间距拉开把字体调整为微软雅黑或HarmonyOS Sans整个界面的感觉立马就不一样了。我习惯用字体图标库来替代图片资源比如FontAwesome。把字体文件放到项目里通过TextBlock的Text指定Unicode字符再设置FontFamily为对应字体就能显示出图标。这样做的好处是图标不会像图片那样失真随便改颜色体积还小。要注意的是某些字体图标在某些操作系统上会显示成方框或空白那是因为系统缺少对应的字体文件。如果你的目标机器没有安装那个字体你需要把.ttf文件打包进程序而不是只在开发机上看到效果就完事了。排版这块间距统一是很重要的细节。一个窗口里的元素外边距有时候是15有时候是8看起来就会很乱。我会在项目里预先定义一组统一间距的全局资源比如SmallSpacing8、MediumSpacing16、LargeSpacing24然后在布局中统一引用。虽然没有强制规则但统一间距会让界面显得更加规整和专业。4. MVVM三层的落地从绑定到命令再到项目结构聊到WPF绕不开的话题就是MVVM。Model-View-ViewModel这三个单词背起来很容易真正用起来却会困惑我的代码到底应该放在哪一层什么时候ViewModel会变成一个大杂烩为什么别人的MVVM项目结构清爽我的却乱成一团4.1 ViewModel的边界不是放不下就塞这里MVVM的核心思想是让视图层View不直接写业务逻辑而是通过绑定和命令去驱动ViewModel里的属性和方法。这样做的直接好处是界面和逻辑可以分开测试、分开维护。你甚至可以在没有界面的情况下单独给ViewModel写单元测试。但很多初学者会走向另一个极端把所有东西都放在ViewModel里页面一多ViewModel变得非常冗长。比如有一个用户管理页面你会发现ViewModel里面有加载用户列表的代码、有弹窗提示的代码、有校验输入的逻辑、有保存到数据库的逻辑甚至还有操作日志。这不是MVVM的错而是缺少更细粒度的拆分。我建议的划分方式是Model纯数据模型。对应数据库表或API返回的数据结构不包含任何界面逻辑。ViewModel一个页面状态和行为的中转站。它把Model里的数据加工成界面可以展示的形态把界面操作转换成命令或方法。View只负责布局和绑定不写业务逻辑不操作数据库不直接new一个Model然后赋值。这么做能理清思路。但要注意ViewModel不要依赖UI类型。比如不要在ViewModel里引用MessageBox、不要直接操作Dispatcher。如果你发现一个ViewModel需要弹窗应该通过一个消息服务或者对话框服务来间接实现。这会让你的项目在后期可维护性上获益很多。4.2 命令系统为什么Click事件不用了以及如何实现RelayCommand以前WinForms里按钮响应就是Click事件button1_Click(object sender, EventArgs e)然后在里面写代码。WPF虽然也保留了Click事件但MVVM模式下更推荐用Command。原因其实和绑定一样事件是直接连线的它把按钮的点击和代码里的一个方法强绑定在一起而Command是一个可以被绑定的操作对象你在XAML里写Command{Binding LoginCommand}到运行时再去找ViewModel里的LoginCommand属性。这样同样的命令可以被多个控件复用命令的可用状态能不能执行也可以通过CanExecute来控制。下面是一个最精简的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的实现方式有多种上面借用了CommandManager.RequerySuggested它会在某些UI状态改变之后自动重新查询命令的可用状态。但也有时候你需要手动调用CommandManager.InvalidateRequerySuggested()来强制刷新。第二如果你的命令需要传入参数可以把Actionobject作为execute然后在Execute里把parameter转成需要的类型。第三如果命令需要支持泛型可以再写一个RelayCommandT实现并不复杂但会让调用代码更清晰。关于CanExecute有一个真实的反模式很多人在CanExecute里写的判断逻辑太多导致每次界面刷新都要执行大量计算拖慢性能。更合理的做法是CanExecute只做轻量级判断数据变化需要刷新命令状态时主动调用CommandManager.InvalidateRequerySuggested()。我在项目里会封装一个RaiseCanExecuteChanged()方法在属性变更通知时一并调用。4.3 真实的数据驱动场景HTTPClient加载列表、树形表格与异步处理光说概念没意思我拿一个实际场景来说明MVVM的落地过程。假设现在要开发一个用户管理页面从远程接口加载用户列表展示在DataGrid中支持选中某行在详情区显示该用户的更多信息还要在界面上展示加载状态。第一步Model层。定义用户数据结构public class UserInfo { public int Id { get; set; } public string Name { get; set; } public string Department { get; set; } }第二步ViewModel层。需要一个ObservableCollectionUserInfo来绑定列表需要两个命令加载数据的LoadCommand、选中行的SelectedItemCommand。加载数据的逻辑放在一个异步方法里因为HTTP请求不能阻塞UI线程。public class MainViewModel : INotifyPropertyChanged { public ObservableCollectionUserInfo Users { get; set; } new ObservableCollectionUserInfo(); private bool _isLoading; public bool IsLoading { get _isLoading; set { _isLoading value; OnPropertyChanged(nameof(IsLoading)); } } private UserInfo _selectedUser; public UserInfo SelectedUser { get _selectedUser; set { _selectedUser value; OnPropertyChanged(nameof(SelectedUser)); } } public RelayCommand LoadCommand { get; set; } public MainViewModel() { LoadCommand new RelayCommand(async () { IsLoading true; try { using (var client new HttpClient()) { var data await client.GetFromJsonAsyncListUserInfo(https://example.com/api/users); Users.Clear(); foreach (var item in data) { Users.Add(item); } } } finally { IsLoading false; } }); } }注意几个细节。第一异步加载期间要让UI有反馈这就是IsLoading的用途XAML里可以绑定一个加载中遮罩或进度条。第二await之后如果要操作ObservableCollection它已经在UI线程上恢复了上下文不需要手动处理线程切换。第三异常处理要放在最后兜底至少不能让程序崩掉。这里我用finally保证加载状态一定被重置。DataGrid绑定层级数据时遇到树形表格需求常见做法是使用HierarchicalDataTemplate。它允许你递归地指定子节点的模板和绑定。比如一个部门树每个部门下面有员工可以这样写TreeView ItemsSource{Binding Departments} TreeView.ItemTemplate HierarchicalDataTemplate ItemsSource{Binding Employees} TextBlock Text{Binding DepartmentName} / /HierarchicalDataTemplate /TreeView.ItemTemplate /TreeView这就是MVVM里视图层只声明结构不写逻辑的典型体现。至于DataGrid里展示多级数据我更习惯先把数据压平成行每行带一个父级Id再通过分组或行合并来实现树形效果。这样逻辑在ViewModel层面处理好界面上只做展示后续维护会轻松很多。5. 从Expression Blend到设计器WPF进阶路线上的几个实用补充标题里提到了完全指南我也聊聊进阶路线上容易被忽视的一些内容。比如Expression Blend比如设计时数据比如项目结构。5.1 Expression Blend和设计时数据可视化调试XAML的技巧Expression Blend曾经是微软为WPF和Silverlight专门推出的界面设计工具不过现在已经淡出主流视野了。但你所不知道的是Visual Studio本身内置了一个设计器其实也继承了类似的能力比如在设计视图里你可以拖拽控件、调整属性还能看到绑定关系的预览效果。很多人写XAML从来不看设计器直接编译运行效率低不少。我建议在学习阶段多利用设计器它能立刻反馈出你写的布局对不对。还有一个非常实用但很多人不知道的技巧设计时数据。你在XAML里给DataContext设置一个“设计时实例”就能在设计器里预览绑定结果而运行时这个值会被真正的ViewModel覆盖。做法是Window.DataContext local:DesignTimeViewModel / /Window.DataContext或者更精准地在d:前缀下设置d:DataContext{d:DesignInstance local:PreviewViewModel, IsDesignTimeCreatableTrue}这个技巧在调样式时很管用你不用F5跑起来就能看到列表绑定效果长什么样。我一直用这个功能来调整DataTemplate的间距和字体大小省去了大量启动程序的等待时间。5.2 项目结构划分从单文件到模块化的成长路径最后想聊聊项目结构的成长。刚开始学习的时候很多人会把所有代码塞在一个MainWindow.xaml.cs里这没问题练手而已。可当功能逐渐变多大约什么阶段开始应该重构呢我的建议是当你发现一个文件里出现了两个以上不相关的职责时就该拆了。一个比较合理的目录结构可以是这样- Views - MainWindow.xaml - UserListView.xaml - UserDetailView.xaml - ViewModels - MainViewModel.cs - UserListViewModel.cs - UserDetailViewModel.cs - Models - UserInfo.cs - ApiResult.cs - Services - HttpService.cs - DialogService.csViews放窗口和用户控件ViewModels放对应的绑定逻辑Models放数据结构Services放网络、对话框、文件读写这类可复用的服务。这种结构一开始看起来好像比全塞一起要麻烦但真正项目到了几万个文件、多人协作的时候规范清晰的结构能帮大忙。关于WPF的路子其实远不止这些比如自定义控件、附加属性依赖、渲染管线、性能优化、与Win32互操作等都是值得深入的方向。但万变不离其宗先把XAML、绑定、依赖属性和MVVM这套基础打牢之后的每个进阶方向都是在这个地基上盖楼。我个人在实际学习时的体会是不要一口气吞太多概念试着拿一个具体的小项目从头做到尾边做边查效果比看十本教程都好。
返回列表