ARTICLE DETAIL

资讯详情

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

WinForms DataGridView列配置工具:一行搞定列显隐、顺序、宽度记忆

WinForms DataGridView列配置工具:一行搞定列显隐、顺序、宽度记忆 做WinForms开发久了几乎每个带表格的项目都会遇到同一个头疼问题用户对列显示的要求一直在变。今天说要隐藏创建时间列明天觉得状态列太窄后天又要调整顺序。尤其是上位机、信息管理系统这类项目一张业务表动辄二十几个字段如果每个窗体都写一堆列配置代码后期需求一改就是全局返工。我自己维护过一套设备管理系统光是列显示的需求就改了七次最后实在受不了一列一列硬编码抽了一个工具类出来——主窗体只需要调用一个方法自定义列显示、列顺序、列宽、显隐记忆全部搞定。这篇文章就把这个工具类的完整设计思路和核心源码拆开讲清楚项目里正被DataGridView列配置折磨的朋友可以直接抄走。1. 先理清楚需求用户自定义列显示到底在解决什么问题1.1 一个再常见不过的表格场景举个例子一个设备管理页面数据实体长这样Id、设备名称、IP地址、所属区域、状态、最后心跳时间、内部编码。实际使用中Id和内部编码是给程序看的用户根本不想看到而“状态”这一列如果不打个中文标签而是显示0和1用户直接懵掉。这时候代码里就会出现这种反复堆叠的初始化逻辑dataGridView1.AutoGenerateColumns false; dataGridView1.Columns.Clear(); var colId new DataGridViewTextBoxColumn(); colId.Name Id; colId.DataPropertyName Id; colId.HeaderText Id; colId.Visible false; dataGridView1.Columns.Add(colId); var colName new DataGridViewTextBoxColumn(); colName.Name Name; colName.DataPropertyName Name; colName.HeaderText 设备名称; colName.Width 120; dataGridView1.Columns.Add(colName);单个窗体这么写还能忍一旦整个系统有十几个列表页每页都复制粘贴一遍改一个字段名就要全局搜索替换。更烦人的是User A和User B看到的列还不一样你不可能为每个人的偏好单独写一套硬编码。这时候“把列显示规则从代码里剥离出来”就成了刚需。1.2 为什么要选择“工具类 配置文件”而不是写死在窗体里我一开始想过两个替代方案但很快排除了。方案一是写一个窗体基类让所有列表窗体继承。思路是基类里统一处理列初始化子类只需要告诉基类“我有哪些列”。但WinForms窗体继承是个重操作项目里有些窗体已经有自己的基类强行再插一层继承会破坏现有结构而且继承意味着你的业务窗体被框架绑死了后面想独立某个页面得把基类逻辑全部搬走。方案二是做用户控件把DataGridView包一层对外暴露一大堆属性。这个也能用但它本质上还是把表格“占住”了如果一个页面上除了表格还有其他复杂布局控件替换成本就高。最终我选择的是一个独立的静态工具类配合XML配置文件。工具类负责“读配置、建列、绑事件、存回配置”业务窗体只需要在绑定数据之前调一次方法。这个方案的好处在于主窗体代码量减少到一行列逻辑集中在一个类里维护不同页面可以用不同的配置文件互不干扰用户调整列宽、列顺序、显隐后自动保存下次启动原样恢复不破坏任何现有窗体继承结构想用就用不想用就删掉一行调用1.3 技术选型反射、特性、XML存储各自的价值这个工具类的核心技术组合是“反射 特性 XML”。每一样都不是新技术但组合在一起效果很好。反射用来拿到实体类的公开属性。实体里新增一个字段工具类自动发现不需要改任何列初始化代码。这里唯一的成本是反射有性能损耗但我们可以把属性缓存起来实际运行中只反射一次后面全走缓存。特性用来声明默认列规则。我设计了ColumnDisplayAttribute直接在实体属性上标注“列标题叫什么、默认是否可见、默认宽度、默认顺序”。这比在工具类里单独维护一份映射字典要直观得多因为字段和规则放在同一个地方看实体定义就能知道列长什么样子。XML用来做配置持久化。DataGridView的列设置PropertyName、HeaderText、Visible、DisplayIndex、Width本身就是一个天然的结构化数据XML序列化之后可读、可改、可备份。用户手动改坏了一个值也能在记事本里修回来。这里没选Json倒不是Json不行而是XmlSerializer对WinForms项目来说零依赖、开箱即用老项目里也常见碰到.NET Framework 4.x环境也不会出兼容问题。2. 核心实现拆解从配置实体到一键应用2.1 列配置实体 ColumnConfig 与序列化包装既然要保存列信息第一步是把“一列”抽象成一个配置类。字段设计得很直白PropertyName对应实体属性名HeaderText是列头显示文本Visible控制是否显示DisplayIndex控制显示顺序Width控制列宽。public class ColumnConfig { public string PropertyName { get; set; } public string HeaderText { get; set; } public bool Visible { get; set; } public int DisplayIndex { get; set; } public int Width { get; set; } }需要注意的是XmlSerializer序列化List的时候要求有一个外层根对象不能直接拿ListColumnConfig开刀否则会报一个“缺少根元素”的异常。所以我又包了一层ColumnSettingList当作XML的根节点[XmlRoot(ColumnSettings)] public class ColumnSettingList { [XmlElement(Column)] public ListColumnConfig Columns { get; set; } }这样生成的XML文件结构就是一棵清晰的树ColumnSettings Column PropertyNameName/PropertyName HeaderText设备名称/HeaderText Visibletrue/Visible DisplayIndex0/DisplayIndex Width120/Width /Column /ColumnSettings用户如果愿意甚至可以自己在记事本里改这个文件程序重启后就能看到效果。这种“配置文件即界面规则”的体验比硬编码舒服太多了。2.2 用特性定义列的默认显示规则普通的PropertyNotify等特性大家都熟这里我自定义了一个专门描述列显示规则的特性[AttributeUsage(AttributeTargets.Property)] public class ColumnDisplayAttribute : Attribute { public string Header { get; set; } public bool IsVisible { get; set; } public int Width { get; set; } public int DisplayIndex { get; set; } }实体类应用这个特性时规则一目了然public class DeviceInfo { public string Id { get; set; } [ColumnDisplay(Header 设备名称, IsVisible true, Width 120)] public string Name { get; set; } [ColumnDisplay(Header IP地址, IsVisible true, Width 150)] public string IpAddress { get; set; } [ColumnDisplay(Header 状态, IsVisible true, Width 80)] public string Status { get; set; } [ColumnDisplay(Header 最后心跳, IsVisible true, Width 160)] public DateTime LastHeartbeat { get; set; } public string InternalCode { get; set; } }这里有一条策略要强调打了ColumnDisplay特性的属性默认显示没打特性的属性默认隐藏。这样像Id、InternalCode这类内部字段就不会被误展示用户想显示再通过配置文件打开。这个默认策略很关键否则每次都要记着给内部字段标IsVisible false容易漏。2.3 反射读取属性与列集合的映射策略拿到实体类型后工具类要做的事就是把实体的公开属性映射为DataGridView列。映射有几个细节会影响最终体验使用DataPropertyName绑定属性名这样DataGridView才会按属性取值填充单元格列名Name直接复用属性名方便后面保存配置时反查设置AutoGenerateColumns false避免系统自动生成双份列反射结果要缓存避免每次Apply都重新扫一遍元数据。我实现了一个极简的缓存字典用类型当Keyprivate static readonly DictionaryType, PropertyInfo[] PropertyCache new DictionaryType, PropertyInfo[](); private static PropertyInfo[] GetProperties(Type type) { if (!PropertyCache.TryGetValue(type, out var props)) { props type.GetProperties(BindingFlags.Public | BindingFlags.Instance); PropertyCache[type] props; } return props; }型了多次应用同一个实体类性能开销可以忽略不计。2.4 Apply 方法完整实现与调用顺序说明核心的Apply方法我给的签名是一个泛型静态方法调用方只需要传实体类型和配置文件路径。完整实现如下public static class ColumnUIConfigurator { public static void ApplyT(DataGridView dgv, string configPath null) where T : class { if (dgv null) throw new ArgumentNullException(nameof(dgv)); dgv.AutoGenerateColumns false; dgv.Columns.Clear(); var configs LoadConfig(configPath); if (configs null || configs.Count 0) { configs BuildDefaultConfigT(); if (!string.IsNullOrEmpty(configPath)) { SaveConfig(configPath, configs); } } configs NormalizeConfig(configs, typeof(T)); ApplyConfigToGrid(dgv, configs); } }执行顺序上我建议先调用Apply再给DataGridView赋值DataSource。因为方法内部会把列集合全部重建如果先绑定数据再清理列在极端情况下会出现一次闪烁。AutoGenerateColumns已经被关掉所以先清列不会有任何副作用。BuildDefaultConfig从特性生成默认配置时会顺手做一次顺序归一化保证DisplayIndex不重复private static ListColumnConfig BuildDefaultConfigT() where T : class { var configs new ListColumnConfig(); var props GetProperties(typeof(T)); int fallbackIndex 0; foreach (var prop in props) { var attr prop.GetCustomAttributeColumnDisplayAttribute(); var cfg new ColumnConfig { PropertyName prop.Name, HeaderText attr?.Header ?? prop.Name, Visible attr?.IsVisible ?? false, Width attr?.Width ?? 0, DisplayIndex attr?.DisplayIndex ?? fallbackIndex }; if (attr null || attr.DisplayIndex 0) { cfg.DisplayIndex fallbackIndex; } configs.Add(cfg); fallbackIndex; } return configs .OrderBy(c c.DisplayIndex) .Select((c, index) { c.DisplayIndex index; return c; }) .ToList(); }ApplyConfigToGrid是真正把配置落成DataGridView列的地方。遍历配置时按DisplayIndex从小到大Add列在网格里的视觉顺序就是正确的private static void ApplyConfigToGrid(DataGridView dgv, ListColumnConfig configs) { foreach (var config in configs.OrderBy(c c.DisplayIndex)) { var col new DataGridViewTextBoxColumn { Name config.PropertyName, DataPropertyName config.PropertyName, HeaderText config.HeaderText, Visible config.Visible, SortMode DataGridViewColumnSortMode.Automatic }; if (config.Width 0) { col.Width config.Width; } else { col.AutoSizeMode DataGridViewAutoSizeColumnMode.Fill; } dgv.Columns.Add(col); } }宽度为0的列自动进入Fill模式可以均匀填满剩余空间配置里留了余地开发者可以指定任意宽度。3. 主窗体只写一个方法如何做到优雅调用3.1 改造前每个窗体重复十几行列配置改造前的代码我已经在开头展示过了一个页面几十行几个页面就是几百行。再加上后面要做的“用户调整列后记住配置”还得额外加ColumnWidthChanged、ColumnDisplayIndexChanged这些事件每个窗体再来一遍工作量直接爆炸。实际上我在做这个工具类之前项目里已经出现了两个窗体列逻辑不一致的BugA窗体的新增字段显示了B窗体的没显示。原因就是两边代码是复制粘贴的后来改了一边忘了另一边。这种问题一个工具类就能根治。3.2 改造后一行调用外加一个可选的格式化委托改造后主窗体的代码变成这样private readonly string _configPath Path.Combine(Application.StartupPath, configs, device_grid.xml); private void MainForm_Load(object sender, EventArgs e) { ColumnUIConfigurator.ApplyDeviceInfo(dataGridView1, _configPath); ListDeviceInfo deviceList LoadDeviceList(); dataGridView1.DataSource deviceList; }主窗体只需要做两件事把实体类型告诉工具类把配置文件路径告诉工具类。所有列创建、显隐、排序、宽度恢复的逻辑全部下沉。如果需求里要求“状态列不要显示0/1要显示在线/离线”还可以给Apply方法扩展一个格式化回调参数。我在后续版本里加了一个可选委托参数用来做单元格显示格式的处理public static void ApplyT(DataGridView dgv, string configPath null, Funcstring, object, object formatCell null) where T : class { if (formatCell ! null) { dgv.CellFormatting (s, e) { if (e.RowIndex 0 || e.ColumnIndex 0) return; var colName dgv.Columns[e.ColumnIndex]?.Name; if (colName null) return; var formatted formatCell(colName, e.Value); if (!Equals(formatted, e.Value)) { e.Value formatted; e.FormattingApplied true; } }; } // 后续逻辑不变 }调用方这样写ColumnUIConfigurator.ApplyDeviceInfo(dataGridView1, _configPath, (colName, value) { if (colName Status value ! null) { return Convert.ToInt32(value) 1 ? 在线 : 离线; } return value; });这里就是C#泛型委托的典型应用。工具类本身不关心你的业务格式规则你通过一个委托把规则传进来实现界面表现与业务数据的解耦。3.3 用户调整列宽和顺序后怎么自动落盘工具类不能只负责“读”配置还必须负责“写”配置。用户拖动列宽、调整显示顺序的操作应该在合适时机自动保存下来。我的做法是维护一个DataGridView到配置路径的映射字典在每次Apply之后挂载两个事件private static readonly DictionaryDataGridView, string ConfigPathMap new DictionaryDataGridView, string(); private static void BindSaveEvents(DataGridView dgv, string configPath) { // 先解除旧的防止重复订阅 UnbindSaveEvents(dgv); ConfigPathMap[dgv] configPath; dgv.ColumnWidthChanged OnColumnWidthChanged; dgv.ColumnDisplayIndexChanged OnColumnDisplayIndexChanged; } private static void UnbindSaveEvents(DataGridView dgv) { if (ConfigPathMap.ContainsKey(dgv)) { dgv.ColumnWidthChanged - OnColumnWidthChanged; dgv.ColumnDisplayIndexChanged - OnColumnDisplayIndexChanged; ConfigPathMap.Remove(dgv); } }保存逻辑很简单把当前网格里每一列的状态反写回ColumnConfig列表private static void OnColumnWidthChanged(object sender, DataGridViewColumnEventArgs e) { var dgv sender as DataGridView; if (dgv null || !ConfigPathMap.TryGetValue(dgv, out var path)) return; SaveConfigFromGrid(dgv, path); } private static void OnColumnDisplayIndexChanged(object sender, DataGridViewColumnEventArgs e) { var dgv sender as DataGridView; if (dgv null || !ConfigPathMap.TryGetValue(dgv, out var path)) return; SaveConfigFromGrid(dgv, path); } private static void SaveConfigFromGrid(DataGridView dgv, string configPath) { var configs new ListColumnConfig(); foreach (DataGridViewColumn col in dgv.Columns) { configs.Add(new ColumnConfig { PropertyName col.Name, HeaderText col.HeaderText, Visible col.Visible, DisplayIndex col.DisplayIndex, Width col.Width }); } SaveConfig(configPath, configs); }这样做了之后用户体验是连贯的程序第一次启动按默认配置显示用户拖了列宽、挪了顺序关闭再打开界面还是他最后调整的样子。3.4 泛型与委托在这里的真正价值泛型让工具类适配任意实体类型实体数量再多工具类代码都不需要动。委托让工具类可以接受调用方的业务规则比如格式化单元格而不是把规则写死在工具类内部。这两个C#特性结合起来工具类的通用性才真正落地。如果你的场景里列显示逻辑还涉及多语言、不同角色权限也可以在这个基础上继续扩展。比如根据当前登录用户的角色在Apply之前对配置做一次过滤制造角色不显示“成本”列维护角色不显示“内部编码”列。这些逻辑可以全部收敛到工具类里主窗体依旧一行调用。4. 实操记录在真实项目里遇到的坑与排查方法4.1 常见问题排查速查表我在几个项目里用这个工具类遇到过不少细节问题。整理了一份排查速查表先给结论问题现象可能原因解决方案配置读取了但列顺序乱了DisplayIndex重复或为负数保存前统一做归一化按排序后重新赋值用户调了列宽重启后还是老样子保存事件没挂上或配置路径不一致检查事件订阅是否被重复解除确认configPath是绝对路径列全部消失了配置里的PropertyName与实体属性不匹配LoadConfig返回后按实体属性做一次过滤无效项丢弃同一个窗体重复调用Apply导致列重复方法开头没有清空列集合Apply里必须执行dgv.Columns.Clear()网格滚动卡顿、保存磁盘频繁ColumnWidthChanged事件触发太频繁每次都写文件给保存操作加防抖或者只在调整结束时保存程序启动提示XML根元素缺失配置文件被手动改坏或路径指向了空文件LoadConfig里try/catch读失败就回退默认配置并重建4.2 列顺序事件里的递归循环问题这是最容易踩的坑。在OnColumnDisplayIndexChanged里做保存本身没有问题因为读取列属性不会触发事件。但如果在保存回调里顺手做了“归一化”操作比如把col.DisplayIndex重新赋值就会再次触发ColumnDisplayIndexChanged形成递归。我在早期版本里就遇到过保存函数里对列进行排序修正结果一运行就递归几十次界面直接卡死。解决方案是加一个_isLoading标志位。只读配置时置true保存时也置true事件回调里发现标志位为true就直接返回。所有赋值列的代码都在标志位保护下执行private static bool _isLoading; private static void OnColumnDisplayIndexChanged(object sender, DataGridViewColumnEventArgs e) { if (_isLoading) return; var dgv sender as DataGridView; if (dgv null || !ConfigPathMap.TryGetValue(dgv, out var path)) return; SaveConfigFromGrid(dgv, path); }事件里只做读取和序列化绝不写回列属性。4.3 配置文件损坏或版本升级时的容错处理实体类不是一成不变的今天加个“固件版本”字段明天删个“备注”字段配置文件里可能还留着旧字段。我做了两层容错。第一层在LoadConfig把反序列化包在try/catch里读到非法XML不崩溃直接返回null让调用方走默认配置重建流程。第二层在NormalizeConfig拿到配置后和当前实体属性做一次“属性名交叉验证”。实体里不存在的属性名配置项直接丢弃实体里新增了但配置里没有的属性按默认特性补进来。这样即使升级了实体类老配置文件也不会导致界面异常。private static ListColumnConfig NormalizeConfig(ListColumnConfig configs, Type entityType) { var validProps new HashSetstring(GetProperties(entityType).Select(p p.Name)); var result configs .Where(c validProps.Contains(c.PropertyName)) .ToList(); var existingNames new HashSetstring(result.Select(c c.PropertyName)); foreach (var prop in GetProperties(entityType)) { if (!existingNames.Contains(prop.Name)) { var attr prop.GetCustomAttributeColumnDisplayAttribute(); result.Add(new ColumnConfig { PropertyName prop.Name, HeaderText attr?.Header ?? prop.Name, Visible attr?.IsVisible ?? false, Width attr?.Width ?? 0, DisplayIndex result.Count }); } } return result .OrderBy(c c.DisplayIndex) .Select((c, index) { c.DisplayIndex index; return c; }) .ToList(); }4.4 性能优化反射缓存与事件防抖反射缓存已经说过了属性信息和特性信息只要类型不变结果就是稳定的缓存到字典里能省掉大量元数据扫描时间。如果你的列表页同时在十几个窗口打开这项优化尤其重要。事件防抖是另一个被低估的优化。用户按住列分隔线拖动的过程中ColumnWidthChanged会连续触发几十次每次都去做XML序列化写盘低配机上会有明显卡顿。我实测下来加一个500毫秒的防抖计时器把“连续变化事件”聚合成“一次保存动作”磁盘压力和UI流畅度都有明显改善。具体做法是每次事件触发时重启一个Timer只有用户停顿了500毫秒才真正执行保存。全局一个Timer就够因为正常使用场景下不太可能同时拖两个表格。5. 后续扩展这个工具类还能走多远5.1 把配置存储从 XML 换成 Json 或数据库工具类的核心逻辑被分成了“读配置”“建列”“保存配置”三个环节。后续如果项目里要求把用户配置存到数据库表或者换成Newtonsoft.Json的json文件只需要替换LoadConfig和SaveConfig两个方法Apply方法内部完全不用动。这就是把“存储格式”和“列映射逻辑”拆开的收益。5.2 增加列显隐设置对话框有用户反馈说配置文件虽然能改但让普通用户去改XML太勉强。更好的方案是给工具类加一个“列设置对话框”用一个CheckedListBox列出所有列勾选决定显示/隐藏上下移动调整顺序确定后把结果写回配置文件再刷新一次网格。这个对话框完全可以复用Apply方法——先把列配置序列化成一个临时列表在对话框里编辑确认后调用重建列的方法。5.3 扩展排序与分组记忆列显示记忆之外排序状态也是一个很常见的需求。DataGridView默认允许用户点击列头排序但程序重启后排序状态会丢失。扩展思路是在ColumnUIConfigurator里增加SortColumn和SortOrder两个配置字段监听SortCompare或者Sorted事件保存后在下一次Apply时按保存的列名和方向做一次排序。这个扩展对用户体感提升非常明显尤其是列表行数上千时用户每次打开都要重新点一次列头真的会很烦躁。5.4 在 WinForms MVVM 分层中的位置有朋友可能会问这个工具类能不能和MVVM模式一起用。可以直接放到View层里因为DataGridView本身就是UI控件列配置属于界面表现逻辑。ViewModel不关心列怎么排列它只需要提供ObservableCollectionT绑定数据源。工具类接管“把实体映射成界面列”这部分职责反而让ViewModel更干净。注意不要在工具类里写任何业务计算它的职责边界就是“列显示怎么配置、怎么保存、怎么恢复”。我在实际使用中有一种体会这个工具类真正解决的并不是“技术难度”问题需求变更频率才是根源。反射、泛型、XML序列化这些技术点单独拆开大家都懂难的是把它们组合起来把“列显示需要改代码”变成“列显示只需要改配置”。项目里第一次出现用户自定义列显示需求时很多人的第一反应是手写列初始化直到需求改了七八次之后才回头做封装这很正常我自己也经历过。最后分享一个小技巧配置文件的存储路径尽量放在固定的数据目录别放临时目录实体类升级时旧的配置文件保留一份备份万一新配置有问题还能对照排查。如果这篇文章里写的工具类能帮你少加几个夜班那也不算白写。
返回列表