尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

WPF自定义控件库实践:样式重写与控件封装分离,打造可维护的GenUI架构

WPF自定义控件库实践:样式重写与控件封装分离,打造可维护的GenUI架构 简介这是一份面向WPF开发者的自定义控件库完整源码包专注于常用控件的功能封装与样式重写适合需要统一界面风格、减少重复造轮子、提升控件复用性的桌面应用项目。资源共71个文件以35个C#源文件为核心实现控件逻辑17个XAML样式资源定义模板与视觉外观另有6张PNG预览图快速查看效果、2个DLL库提供依赖支持整体约942KB结构清晰便于检索。内容不仅包含控件库主体还附带了完整的Demo演示工程覆盖日期选择器、图表、日志控件、属性编辑器、文件预览等典型场景同时带有项目配置、资源字典与样式主题文件可直接运行查看效果并派生出自定义样式。已有1089人学习下载适合具有一定WPF基础、希望深入学习控件封装技巧或快速构建界面样式体系的开发者。1. 为什么业务项目最后都长出一个控件库维护过一年以上的 WPF 项目基本都会遇到同样的症状主窗口 XAML 越来越长Button 样式改一次要翻好几个窗体公司内部几个通用控件散落在不同工程里互相引用最终只能靠复制粘贴维持运行。GenUI.WPF 这个自定义控件库最值得拆的地方是把常用控件封装和常用控件样式重写分成了两个独立工程GenControls 管行为GenStyles 管外观Demo 提供完整可运行样例。它适合上位机界面、内部管理系统这类以表单、属性编辑、日志输出为核心组成的中大型 WPF 客户端也适合刚准备把项目里重复控件抽离成独立程序集的团队。2. 拆开 GenUI.WPFGenControls 与 GenStyles 的分层先说结论自定义控件库的边界不应该按控件名称划分而应该按“行为”和“外观”划分。GenUI.WPF 在这个思路上做得比较清晰拿到源码后先看解决方案结构基本能看出每个项目存在的理由。GenUI.WPF/ ├── GenControls/ # 控件逻辑与默认模板 │ ├── Themes/ # 控件默认样式常用做法是写进 Generic.xaml │ └── Properties/ ├── GenStyles/ # 全局样式重写主题资源字典 ├── Demo/ # 可运行窗口直接当启动项目 ├── TestModels/ # 属性编辑器等控件的模拟数据 ├── dll/ # 编译期引用的本地依赖 └── GenUI.WPF.sln2.1 从解决方案文件看工程职责目录/文件职责GenControls自定义控件的逻辑实现比如依赖属性注册、事件处理、控件模板默认值GenStyles对系统控件的样式重写如 Button、TextBox、DataGrid 的 ControlTemplate 替换Demo集成测试窗口也是这个库的活文档TestModels模拟业务对象给属性编辑器这类需要数据源的控件提供测试素材dll编译期第三方依赖一般不需要手动改动GenControls 和 GenStyles 拆开的原因很直接控件逻辑不应该依赖某个具体主题。如果控件的颜色、圆角、阴影写死在代码里换主题时就要改代码重新编译。把外观剥离出去之后GenControls 只负责“这个控件如何响应输入、如何暴露数据”GenStyles 负责“这个控件在不同状态下长什么样”。2.2 packages.config 说明项目年代但不影响使用根目录的 packages.config 表明这套工程是传统 csproj 格式依赖通过 packages.config 管理目标框架大概率是 .NET Framework 4.x。拿到手之后先在 vs2022 里确认目标框架版本如果使用端是 .NET 8 的 WPF 工程引过来时编译可能不报错运行期程序集加载失败的概率却不低。常见做法是给老库建一个兼容引用层或者直接用手动方式把包引用迁移成 PackageReference。注意别在同一个 csproj 里混用 packages.config 和 PackageReferencevs2022 虽然会提示迁移但复杂依赖下容易出现重复引用。2.3 TestModels 单独建项目的意义以前见过很多人做控件库时测试数据直接写在 Demo 窗口的代码后面结果 Demo 越写越大最后比业务项目还难维护。GenUI.WPF 把 TestModels 独立出来属性编辑器、日志控件这类需要对象数据的控件可以直接绑定测试模型换数据源不需要改控件代码。这种做法和 Web 前端把 mock 数据单独放一个目录的逻辑一样本质上是让控件开发者的调试路径和控件使用者的接入路径保持一致。3. 样式重写的核心是模板不是 SetterWPF 默认控件的样式在 Windows 不同版本下有差异Aero、Aero2、Fluent 主题呈现出的按钮、下拉框、进度条都不一样。直接通过 Setter 改 Background 和 Foreground 经常不生效原因在于默认 Button 的模板里背景被绑定了特定主题画刷。真正解决这个问题的唯一办法是重写 ControlTemplate这也是 GenStyles 目录里大量 XAML 在做的事情。3.1 为什么默认样式很难直接用很多内部系统界面临时凑出来的样子多半是直接在按钮上设置背景色。但鼠标悬停时按钮颜色变不回正常态禁用状态时文字看不清这些问题都出在模板内部没有跟随状态变化。重写模板时第一步是把模板逻辑全部收进 ControlTemplate.TriggersStyle x:KeyGen.Button.Primary TargetType{x:Type Button} Setter PropertyForeground ValueWhite/ Setter PropertyBackground Value#3A6EA5/ Setter PropertyTemplate Setter.Value ControlTemplate TargetType{x:Type Button} Border x:NamerootBorder Background{TemplateBinding Background} BorderBrush#2A5A8A BorderThickness1 CornerRadius4 Padding12,6 ContentPresenter HorizontalAlignmentCenter VerticalAlignmentCenter/ /Border ControlTemplate.Triggers Trigger PropertyIsMouseOver ValueTrue Setter TargetNamerootBorder PropertyBackground Value#4A86C1/ /Trigger Trigger PropertyIsPressed ValueTrue Setter TargetNamerootBorder PropertyBackground Value#2A5A8A/ /Trigger Trigger PropertyIsEnabled ValueFalse Setter TargetNamerootBorder PropertyOpacity Value0.5/ /Trigger /ControlTemplate.Triggers /ControlTemplate /Setter.Value /Setter /Style代码里{TemplateBinding Background}是模板绑定它只跟控件自身的依赖属性绑定效果上类似{Binding RelativeSource{RelativeSource TemplatedParent}}但语法更短。触发器中通过 TargetName 指向rootBorder鼠标悬停和按下时只改变 Border 的背景不需要重新创建整个模板这是 WPF 里成本比较低的状态切换方案。3.2 不同控件的样式重写重点控件重写重点ButtonBorder ContentPresenter处理悬停、按下、禁用三态TextBox焦点边框颜色、水印文本、多行滚动条样式ComboBoxPopup 宽度绑定、下拉阴影、选中项高亮DataGrid列头模板、行高、选中行背景、隔行变色TreeView展开箭头样式、节点缩进、选中项圆角背景以 DataGrid 为例列头模板要重写 HeaderTemplate否则不同客户机器上列头高度和字体差异明显。隔行变色在 DataGrid 里用 RowStyle 加触发条件比逐行设置背景更高效。3.3 资源字典合并与主题切换样式重写完成后需要把资源字典合入使用端项目的 App.xamlApplication.Resources ResourceDictionary ResourceDictionary.MergedDictionaries ResourceDictionary Sourcepack://application:,,,/GenStyles;component/GenStyles.xaml/ ResourceDictionary Sourcepack://application:,,,/GenControls;component/Themes/Generic.xaml/ /ResourceDictionary.MergedDictionaries /ResourceDictionary /Application.Resourcespack://application:,,,/GenStyles;component/GenStyles.xaml是 WPF 包 URI 语法程序集名大小写必须和实际程序集完全一致component 后面的路径从工程根开始算。合并顺序决定覆盖关系后合并的字典优先级更高所以主题字典放在通用控件模板之后会更可预期。如果做多主题切换应该用 DynamicResource 而不是 StaticResource。StaticResource 在 XAML 加载时解析一次性能好但运行时改资源不会刷新DynamicResource 每次使用都走资源查找界面切换主题时能实时生效。GenStyles 这种主题型资源字典适合 DynamicResource而控件库内部的默认模板适合 StaticResource因为模板不经常替换。3.4 不复制模板的覆盖方式BasedOn企业项目里经常遇到只改一个细节的需求例如把主按钮圆角从 4 改成 8最怕的就是把整个模板复制一遍。用 BasedOn 可以避免这种重复Style x:KeyGen.Button.Rounded TargetType{x:Type Button} BasedOn{StaticResource Gen.Button.Primary} Setter PropertyTemplate Setter.Value ControlTemplate TargetType{x:Type Button} Border x:NamerootBorder Background{TemplateBinding Background} CornerRadius8 ContentPresenter/ /Border /ControlTemplate /Setter.Value /Setter /Style要注意 BasedOn 只继承 Style 的 Setter 和触发器一旦重写 Template原先模板里的所有触发器逻辑也要一并重新实现否则悬停效果会丢失。这是一个经常被忽略的坑改 Template 等于抛弃父级模板的状态管理。4. 控件封装的四种形态属性、日志、文件与图表样式重写负责“好不好看”控件封装解决的是“有没有捷径”。GenControls 里封装的控件几乎都是业务项目里被反复使用、每次复制粘贴都会出问题的类型。属性编辑器最容易写错日志控件最容易卡界面文件预览控件最容易在深层目录上卡死下面逐个拆解。4.1 属性编辑器DependencyProperty 反射属性编辑器本质上是一个属性网格类似 WinForms 时期的 PropertyGrid把任意对象的公开属性显示成可编辑行。这类控件如果用普通 C# 属性做数据源绑定和设计器支持都会有问题。WPF 里必须用依赖属性public class PropertyGrid : Control { public static readonly DependencyProperty SelectedObjectProperty DependencyProperty.Register( nameof(SelectedObject), typeof(object), typeof(PropertyGrid), new PropertyMetadata(null, OnSelectedObjectChanged)); public object SelectedObject { get GetValue(SelectedObjectProperty); set SetValue(SelectedObjectProperty, value); } private static void OnSelectedObjectChanged( DependencyObject d, DependencyPropertyChangedEventArgs e) { var grid (PropertyGrid)d; grid.BuildRows(e.NewValue); } }依赖属性注册名必须以 Property 结尾nameof(SelectedObject)传的就是属性名ownerType 必须是 PropertyGrid 自身。属性变化回调里调用 BuildRows这样无论是代码赋值还是绑定赋值数据源切换后界面都会自动刷新。BuildRows 用反射遍历对象属性private void BuildRows(object obj) { if (obj null) return; var rows new ListPropertyRow(); var props obj.GetType().GetProperties( BindingFlags.Public | BindingFlags.Instance); foreach (var prop in props) { var display prop.GetCustomAttributeDisplayNameAttribute(); rows.Add(new PropertyRow { Name display?.DisplayName ?? prop.Name, Value prop.GetValue(obj), PropertyType prop.PropertyType }); } ItemsControl.ItemsSource rows; }反射拿到的属性需要跳过索引器和只读集合否则显示出来没有意义。这里用DisplayNameAttribute控制显示名比直接显示属性英文名更符合内部系统习惯。属性编辑器本身只消费 SelectedObject不依赖 ViewModel因此放在支持 MVVM 的项目里也能工作用它接测试模型数据时完全不需要改控件代码。4.2 日志控件Dispatcher 调度与滚动保底日志控件最常见的崩溃来自后台线程直接修改 TextBox 内容。WPF 的 UI 元素只能在 UI 线程操作后台线程写入必须通过 Dispatcher 调度public void AppendLine(string message) { var line ${DateTime.Now:HH:mm:ss.fff} {message}; if (!Dispatcher.CheckAccess()) { Dispatcher.BeginInvoke(new Action(() AppendLine(message))); return; } logBox.AppendText(line Environment.NewLine); logBox.ScrollToEnd(); TrimIfNeeded(); }Dispatcher.CheckAccess()返回 false 说明当前线程不是 UI 线程BeginInvoke异步调度到 UI 线程执行。日志量大时用 BeginInvoke 可以让调用端不阻塞如果要求日志顺序严格用 Invoke 同步等待。日志控件另一个问题是内容无限增长TextBox 里的文本超过几万行之后输入明显变慢。TrimIfNeeded 里检测行数超过上限就截断前半部分private void TrimIfNeeded() { if (logBox.LineCount MaxLines) { logBox.Text logBox.Text.Substring( logBox.Text.IndexOf(\n, logBox.GetCharacterIndexFromLineIndex(MaxLines / 2)) 1); logBox.ScrollToEnd(); } }截断操作本身有性能开销所以触发阈值要留足余量。实际项目中一般把 MaxLines 设在 5000 左右平时不需要频繁截断。4.3 文件预览控件TreeView 递归模板文件预览控件适合建立在 TreeView 之上用 HierarchicalDataTemplate 递归显示目录结构。关键点是模板里的 ItemsSource 必须指向子项集合属性否则目录无法展开TreeView ItemsSource{Binding Children} TreeView.ItemTemplate HierarchicalDataTemplate ItemsSource{Binding Children} StackPanel OrientationHorizontal TextBlock Text{Binding Name} Margin2,0/ TextBlock Text{Binding SizeText} ForegroundGray Margin6,0/ /StackPanel /HierarchicalDataTemplate /TreeView.ItemTemplate /TreeView数据源加载目录时目录和文件需要分开处理public IEnumerableFileItem LoadChildren(DirectoryInfo dir) { foreach (var d in dir.GetDirectories()) { yield return new FileItem { Name d.Name, IsDirectory true, FullPath d.FullName }; } DirectoryInfo[] files; try { files dir.GetFiles(); } catch (UnauthorizedAccessException) { yield break; } foreach (var f in files) { yield return new FileItem { Name f.Name, IsDirectory false, SizeText ${f.Length / 1024.0:F1} KB, FullPath f.FullName }; } }深层目录建议按需加载先只取第一层节点展开时再加载下一层。最忌讳的是在初始化时递归加载整个磁盘目录几十万个子项会直接把 UI 线程卡死。UnauthorizedAccessException必须捕获系统目录里很多文件夹没有访问权限一个异常能让整个 TreeView 停止填充。4.4 Chart 与 DataPicker 的取舍GenUI.WPF 里的 Chart 控件适合做简单折线图数据显示量不大时自己画比引第三方库更轻。如果业务里涉及大数据量、缩放、实时刷新LivwCharts2 这类成熟库更划算。DataPicker 这类日期选择控件重写它的意义主要在于统一格式和样式让它和 GenStyles 里其他控件视觉一致而不是重新实现日期算法。5. 接入现有项目的引用方式与排错技巧拿到控件库源码之后接入方式决定了后面三个月维护时的心情。5.1 项目引用优先于 DLL 引用解决方案里带源码的情况下使用端工程应该引用 GenControls 和 GenStyles 项目而不是直接引用 dll 目录里的程序集。原因很简单样式重写经常要改 XAML项目引用方式下修改 GenStyles 里某个画刷颜色按 F5 重新编译就能看到效果换成 dll 引用后每次改动都要先编译控件库、复制 dll、再重新编译使用端中间环节一多就容易出现引用旧版本的问题。项目引用和 dll 引用搭配使用也常见如果团队里不同项目需要固定版本dll 目录里的产物适合做基线发布但需要单独走版本管理。5.2 设计器不渲染与资源合并顺序控件库接入后经常出现设计界面空白但运行正常的情况。优先检查三件事资源字典路径是否写错、资源键大小写是否一致、控件的默认样式是否放在 Themes/Generic.xaml 中。Generic.xaml 是 WPF 自定义控件默认样式查找的固定位置文件路径改变或文件名拼写错误都会导致设计器静默失败。提示资源字典合并顺序决定覆盖关系后合并的字典优先级更高。把应用级主题放在最后否则会被通用样式提前覆盖。5.3 资源键命名规范与验证工具在团队协作环境下资源键命名比注释可靠得多。GenStyles 里的每个资源建议按“模块.控件.用途”三段式命名例如Gen.Button.Primary、Gen.DataGrid.Header、Gen.TextBox.FocusedBorder。这种命名方式在多个字典合并后不会撞名全局搜索时结果也清晰。验证样式是否正确加载除了运行调试外最实用的工具是 Snoop。启动 Demo 程序后用 Snoop 附加进程选中界面上的任意 Button查找它的 Template 属性和样式 Source能直接定位样式是否被某个字典覆盖。追踪资源覆盖问题也比反复添加调试输出快得多——直接看可视化树里控件的实际模板和绑定的资源来源。在项目里搜资源键或者附加属性比翻几十层 XAML 快得多这也是控件库维护里最值得投入习惯的地方。本文还有配套的精品资源点击获取
返回列表