前阵子接到一个设备管理系统的改造需求,客户的原话是:"这个表格太挤了,我在意的就那几列,你们能不能让我自己勾选要显示哪些列?"听起来很简单,但真正做起来才发现,这个"用户自定义列显示"的需求如果每个窗体都手写一遍,就是一个无底洞。也是从那次改造之后,我把这套逻辑沉淀成了一个独立的C#工具类——主窗体不再关心列显隐、列顺序、列宽怎么持久化,只需要一行方法调用,就能让DataGridView具备完整的用户自定义列能力。这篇文章就把这套工具类的设计思路、完整代码和踩坑过程分享出来,适合正在做WinForms项目、天天和DataGridView打交道、又不想每个窗体重复造轮子的朋友。
1. 为什么非要做这个工具类:一次需求改造攒下的教训
1.1 需求来了,但代码不能崩
当时项目里有十几个列表窗体,每个窗体都用了DataGridView展示业务数据。客户提的需求本质上是三件事:用户能勾选显示哪些列、能拖动调整列顺序、能改变列宽,而且这些设置关掉软件再打开要还在。
如果放在单个窗体上,这三件事并不复杂——DataGridView的ColumnVisible、DisplayIndex、Width属性都是现成的,拖一拖就实现了。真正麻烦的是"十几个窗体都要有同样能力",而且"每个窗体的默认列布局还不一样"。我第一反应是在每个窗体复制粘贴一套列显示逻辑,写到第三个窗体的时候我就意识到不对劲:光是一个列配置弹窗就要写二三百行,事件绑定、配置读写、异常处理全都要重复,后面万一要改逻辑,十几处同步修改,想想都头大。
1.2 手工实现的三大硬伤
我们当时的代码里其实已经有窗体级的自定义列设置了,但问题非常典型。
第一个问题是配置存储散落各处。有的窗体用ini文件存,有的用注册表,有的干脆只在内存里,软件重启就丢失。第二个问题是列配置和业务代码深度耦合。列表窗体里到处是dataGridView1.Columns["单价"].Visible = user.IsAdmin这种代码,列显示逻辑散落在按钮点击、用户权限判断、数据加载等各个角落,哪天想统一调整列布局,根本无从下手。第三个问题是用户设置没法迁移。用户在这台机器上辛苦调整好的列布局,换台电脑就回到了默认,客户对这个意见特别大。
这三大硬伤让我下决心做一次彻底的重构:把"用户自定义列显示"这件事整个抽到一个工具类里,所有窗体共享一套逻辑,而且主窗体调用必须简单到极致。
1.3 用工具类之后的形态对比
重构完成之后,窗体代码变成了这样:
// 重构前:手工实现,每个窗体都要写几十行 private void Form_Load(object sender, EventArgs e) { dataGridView1.DataSource = LoadOrders(); // 以下逻辑每个窗体都要复制一遍 dataGridView1.Columns["Remark"].Visible = false; dataGridView1.Columns["Creator"].Visible = false; dataGridView1.Columns["OrderId"].DisplayIndex = 2; dataGridView1.Columns["OrderId"].Width = 120; string path = Path.Combine(Application.StartupPath, "config.ini"); // 还有读写配置、弹窗勾选等一坨…… } // 重构后:一行调用 private void Form_Load(object sender, EventArgs e) { dataGridView1.ApplyColumnConfig("OrderGrid", LoadOrders()); }主窗体拿到的是一个已经按用户偏好设置好列显隐、列顺序、列宽的结果,后续所有交互都交给工具类内部处理。用同事的话说:"这已经不是省事的问题了,是根本不需要动脑了。"
1.4 工具类设计的三条底线
在动手之前,我给自己定了三条底线,后来发现这三条底线恰好就是工具类被大家接受的关键。
第一条,调用必须一行搞定。主窗体不该知道配置文件的路径、不该知道默认列定义存在哪、也不该关心用户配置和默认配置怎么合并,这些全是工具类的内部事务。第二条,工具类不依赖任何具体业务窗体。它只操作DataGridView和列名,业务列名通过参数传入,这样工具类才能跨项目复用。第三条,默认布局和用户配置必须分离。开发者在设计期拖出来的列布局是默认值,用户改完了是自定义值,两者不能互相覆盖,但用户设置优先于默认布局。
有了这三条底线,后面的结构设计就有了方向。
2. 工具类的核心结构:配置数据与两套布局的博弈
2.1 列配置的最小数据模型
既然要持久化列布局,第一步是定义数据模型。我设计了一个ColumnConfig类,属性不多,但每个属性都对应DataGridView列的一个用户可调整维度:
[Serializable] public class ColumnConfig { /// <summary>列名,对应DataGridViewColumn.Name或DataPropertyName</summary> public string Name { get; set; } /// <summary>列标题,有些业务需要在运行时动态修改表头</summary> public string HeaderText { get; set; } /// <summary>是否可见</summary> public bool Visible { get; set; } = true; /// <summary>列的显示顺序,从0开始</summary> public int DisplayIndex { get; set; } /// <summary>列宽,0表示使用默认宽度</summary> public int Width { get; set; } = 100; /// <summary>保存列配置时容器的宽度,用于DPI比例还原</summary> public int GridWidthAtSave { get; set; } /// <summary>列是否允许调整宽度</summary> public bool Resizable { get; set; } = true; /// <summary>最小列宽,防止用户把列拖没了</summary> public int MinWidth { get; set; } = 30; }GridWidthAtSave这个属性是后来补的,原因后面在第4部分踩坑里会详细讲。核心思路是:保存列宽的时候顺带把当时DataGridView的宽度也记下来,应用的时候按比例换算,这样在不同分辨率和DPI环境下,列宽给人的"视觉占比"是接近的。
2.2 默认配置与用户配置:双层的意义
工具类里最核心的机制是"默认配置 + 用户配置"双层结构。
默认配置(Default)是开发者在设计器里拖出来的列布局,或者手动在XML里定义的基准布局。它的作用是:程序一运行,即使没有任何用户配置,列表也按开发者设计的样式展示。用户配置(User)是用户实际调整后的结果,它比默认配置优先级高。
为什么要分层?我举个实际场景:项目上线后,客户说"订单号列放最前面,备注列默认隐藏",这是默认布局的调整。如果只存一套配置,一旦用户已经调整过列顺序,开发者的默认布局更新就永远无法生效,用户看到的还是旧布局。双层结构的好处是——更新默认配置时,只要用户没有改过某一项,就沿用新默认值;用户明确改过的项,则尊重用户的选择。
具体来说,应用配置的逻辑是这样的:
public static void ApplyColumnConfig(DataGridView grid, string gridName, object dataSource) { // 1. 绑定数据源,让列先自动生成 grid.DataSource = dataSource; // 2. 从嵌入资源或文件加载默认布局 List<ColumnConfig> defaults = LoadDefaultConfig(gridName); if (defaults == null || defaults.Count == 0) { // 没有默认配置,就把当前设计器状态记录下来作为默认 defaults = CaptureCurrentLayout(grid); SaveDefaultConfig(gridName, defaults); } // 3. 先把默认布局应用一遍 ApplyLayout(grid, defaults); // 4. 加载用户配置,如果存在则覆盖默认布局 List<ColumnConfig> userConfig = LoadUserConfig(gridName); if (userConfig != null && userConfig.Count > 0) { ApplyLayout(grid, userConfig, onlyOverrides: true); } }注意第4步的onlyOverrides: true,它是双层的精髓。用户配置在保存时记录了用户"改过哪些列、改成了什么值",应用时只覆盖那些确实被用户调整过的项目,而不是整份替换。这样就算默认布局更新了,用户没碰过的列会跟着新默认走,用户碰过的列保持用户设置。
2.3 配置文件怎么落盘
存储我选择了XML文件,原因是WinForms项目里XML读写简单、可读性好、调试的时候打开文件一眼能看懂,不太需要上SQLite或JSON这类重量级方案。
文件目录结构这样组织:
Application.StartupPath/ ├── ColumnConfigs/ │ ├── defaults/ │ │ ├── OrderGrid.xml // 默认布局 │ │ └── ProductGrid.xml │ └── users/ │ └── admin/ │ ├── OrderGrid.xml // 用户admin对OrderGrid的自定义布局 │ └── ProductGrid.xml按用户名分目录的好处是支持多用户隔离。我不知道你们项目是什么情况,反正我们客户那边好几台电脑共用服务器上的同一个程序,Windows账号也各不相同,我干脆用Environment.UserName区分。后面如果想做"每个角色一套布局",把admin换成角色名或者用户ID就行。
序列化和反序列化直接用的是XmlSerializer:
private static void SaveUserConfig(string gridName, List<ColumnConfig> configs) { try { string dir = Path.Combine( Application.StartupPath, "ColumnConfigs", "users", Environment.UserName); Directory.CreateDirectory(dir); string path = Path.Combine(dir, gridName + ".xml"); var serializer = new XmlSerializer(typeof(List<ColumnConfig>)); using (var fs = new FileStream(path, FileMode.Create)) { serializer.Serialize(fs, configs); } } catch (Exception ex) { // 保存失败不能影响主流程,记录日志即可 Trace.WriteLine("保存列配置失败: " + ex.Message); } }注意保存操作不能把异常抛给调用方。用户调整列布局是一个辅助功能,保存失败顶多下次打开恢复旧布局,绝不能因为写文件失败导致程序崩溃。这点我在代码里特意加了try-catch,也建议大家都这么做。
那"用户改过哪列"这个信息怎么记录呢?我用的方案是:列配置里加一个IsUserModified的标记,只有用户主动调整过的列才标记为true。监听DataGridView的事件时,把对应列标记上。这样应用用户配置时,只处理标记过的列,天然实现了"部分覆盖"。
public class ColumnConfig { // ……其他属性不变 public bool IsUserModified { get; set; } }3. 主窗体一行调用的实现原理:从绑定到应用
3.1 方法入口设计
"主窗体只需一个方法"这句话听上去很玄,实际上就是一个标准的门面模式。工具类把复杂的配置合并逻辑都藏在内部,对外只暴露一个静态方法。为了让调用更自然,我同时提供了一组扩展方法:
public static class DataGridViewColumnExtensions { /// <summary> /// 应用用户自定义列配置(推荐扩展方法写法) /// </summary> public static void ApplyColumnConfig( this DataGridView grid, string gridName, object dataSource) { DataGridViewColumnHelper.ApplyColumnConfig(grid, gridName, dataSource); } /// <summary> /// 保存当前列布局为用户配置 /// </summary> public static void SaveUserColumnConfig( this DataGridView grid, string gridName) { DataGridViewColumnHelper.SaveCurrentLayout(grid, gridName, userModifiedOnly: true); } }扩展方法的好处是调用侧几乎是无感知的:主窗体代码看起来就像DataGridView原生自带的能力一样,写起来顺手,读代码的人也一眼能明白。
3.2 主窗体三行代码接入
接入一个列表窗体,核心只需要三行代码:
// Form_Load中:加载数据并应用列配置 dataGridView1.ApplyColumnConfig("OrderGrid", LoadOrders()); // Form_FormClosing中:保存用户调整结果 dataGridView1.SaveUserColumnConfig("OrderGrid");LoadOrders()返回一个DataTable。这里有一个重要的设计取舍:为什么工具类要求外部传入已经绑定好的数据源,而不是自己从别的地方取数据?因为工具类不关心业务数据从哪来,它只关心列长什么样。传数据源进来,它就能拿到列集合,能生成默认配置,能匹配列名,但对于数据本身,它不碰也不改。
如果你希望用户调整列布局的同时不重新加载数据,也可以只传grid,不传dataSource。我额外提供了一个重载:
// 只应用配置,不重新加载数据 public static void ApplyColumnConfig(DataGridView grid, string gridName) { if (grid.DataSource == null) return; var defaults = LoadDefaultConfig(gridName); if (defaults == null) defaults = CaptureCurrentLayout(grid); ApplyLayout(grid, defaults); var userConfig = LoadUserConfig(gridName); if (userConfig != null) ApplyLayout(grid, userConfig, true); }3.3 每次调用做了什么
为了让读者完全掌握工具类的行为,我把一次ApplyColumnConfig调用的完整内部流程拆开来讲。
步骤一:绑定数据源。这一步会触发列自动生成,确保后续操作有列可循。如果你的窗体设置了AutoGenerateColumns = false,并且自己定义了列,那这一步不会覆盖你的列,工具类依然能识别。
步骤二:准备默认配置。工具类先检查ColumnConfigs/defaults/OrderGrid.xml是否存在。不存在就把当前DataGridView的列布局抓下来存成默认配置。这里有个细节:抓默认配置时要遍历的是grid.Columns,而不是数据源的字段列表,这样连设计器里设置过的列宽、列头文本也能一并记录下来。
步骤三:应用默认配置。把默认XML里的每一项设置到对应列上,包括Visible、DisplayIndex、Width、HeaderText。应用DisplayIndex时必须特别小心,不能一股脑乱设,否则DataGridView会抛出ArgumentException,这个在第4部分的踩坑环节详细说。
步骤四:加载用户配置并覆盖。读取users/{UserName}/OrderGrid.xml,如果存在,就只把其中IsUserModified = true的项覆盖上去。
步骤五:收尾。将grid的AutoGenerateColumns设为false,避免后续数据源刷新又把列重置掉;然后触发一次Refresh,保证界面立即更新。
整个流程走下来,主窗体真的就只要一行调用。工具类代码见第4部分末尾的完整文件,那里包含了全部实现。
4. 实测中的四个坑:排查链路与最终修复
4.1 AutoGenerateColumns 引发的列错乱
第一个遇到的坑是列对不上号。
现象:程序一启动,DataGridView里的列名全变成数据源的字段名了,设计器里拖出来的那些列定义全不见了。比如设计器里定义了一列叫"订单号",列名是colOrderId,DataPropertyName是OrderId,但运行后这列变成了数据库真实的OrderId字段名,列宽、列头文本全部丢失。
原因排查:DataGridView的AutoGenerateColumns默认是true,给DataSource赋值后,它会根据数据源的字段自动生成列,覆盖掉设计器里手动的列定义。
修复方案:工具类在绑定数据源之后,如果检测到列都是自动生成的,就用grid.Columns[i].DataPropertyName作为列标识,同时把工具类记录的Name统一修正为DataPropertyName。更彻底的做法是应用完配置之后显式把AutoGenerateColumns设为false,防止后续再次赋值数据源时列被重新生成。
这里我建议所有用这套工具类的窗体,在设计器里定义列时就把列的Name和DataPropertyName保持一致,例如都叫OrderId,这样工具类的列名匹配会简单很多。如果项目里已经有很多窗体列名不规范,工具类里用一个字典做映射兼容。
4.2 DisplayIndex 冲突导致程序直接抛异常
第二个坑是最让人头疼的:应用配置时程序偶发崩溃,报ArgumentException: DisplayIndex 的值无效。
原因:DataGridView要求同一行的列DisplayIndex必须是0到N-1的不重复值。如果配置里出现两条列配置都给DisplayIndex=0,或者有一条列配置给的DisplayIndex超出了当前列数范围,设置时就会抛异常。从配置文件层面看,这个错误可能来自手工编辑XML、新老版本列定义不一致(某列被删了但配置里还留着),或者两个用户配置合并出错。
修复方案:在应用配置之前,先做一次索引归一化——把要设置的列按DisplayIndex从小到大排序,再把0、1、2……依次分配给排好序的列。
private static void NormalizeDisplayIndex(List<ColumnConfig> configs) { // 过滤掉配置里引用但实际不存在的列 // 然后按DisplayIndex排序,重新分配连续索引 int index = 0; foreach (var cfg in configs.OrderBy(c => c.DisplayIndex)) { cfg.DisplayIndex = index++; } }配合一个过滤逻辑,把配置里引用了、但DataGridView中实际不存在的列过滤掉:
var validColumns = configs .Where(c => grid.Columns.Contains(c.Name)) .OrderBy(c => c.DisplayIndex); int idx = 0; foreach (var cfg in validColumns) { grid.Columns[cfg.Name].DisplayIndex = idx++; }这个方法能让任何配置源都能安全应用到DataGridView上,我已经在很多奇怪场景下验证过了。
4.3 保存时机没把握好,退出时丢配置
第三个坑是配置丢失。
现象:用户明明调整好了列布局,关掉程序再打开,一切回到解放前。一开始我怀疑是文件没写进去,翻了半天日志发现保存方法根本没被调用。
原因:我看了一下当时窗体的关闭流程,FormClosing事件里保存配置,但用户是通过右上角X按钮关的窗体,按道理FormClosing会触发。问题出在我用的保存方法是SaveCurrentLayout(grid, gridName),里面遍历grid.Columns读取列状态。而有些窗体的Closing事件里先执行了dataGridView1.DataSource = null的清理逻辑,导致遍历时列都没了,保存了一个空配置,然后这个空配置覆盖了之前正常保存的文件。
修复方案:一是约定保存逻辑放到BaseForm的FormClosing事件,并在所有业务清理代码之前执行;二是在保存方法里加保护,当grid.Columns.Count == 0时干脆跳过保存;三是保存时追加一份临时文件备份,防止覆盖坏配置后无法恢复。
public static void SaveCurrentLayout(DataGridView grid, string gridName) { if (grid == null || grid.Columns.Count == 0) return; // 先把当前布局抓下来,再写文件,保证不会因为DataSource被清空而保存空配置 var configs = CaptureCurrentLayout(grid); if (configs.Count == 0) return; SaveUserConfig(gridName, configs); }另外,Close时保存和每次列变化就保存是两种策略,我最后采用了"事件触发+延时保存"的组合。监听ColumnDisplayIndexChanged、ColumnWidthChanged、ColumnVisibleChanged、ColumnHeaderTextChanged这四类事件,触发后延迟600ms写入文件,避免用户拖动列宽时频繁写磁盘。具体实现我在完整文件里有,思路就是用一个CancellationTokenSource做防抖。
4.4 高分屏下列宽保存失真
第四个坑是在带鱼屏上测试时发现的。
现象:在开发机1080p屏幕上把列宽调整成150,换到2K屏上看明显变窄了,换到150%缩放的机器上更是比例失调。
原因分析:列宽保存时记录的是像素值,屏幕DPI不同、窗体缩放不同,同样是150像素,在1080p低缩放下占的表格宽度比例和在2K屏高缩放下完全不同。光存像素没有意义。
修复方案:保存列宽时同时记录当时DataGridView的ClientSize.Width,应用时按比例换算:
// 保存时 cfg.Width = col.Width; cfg.GridWidthAtSave = grid.ClientSize.Width; // 应用时 if (cfg.Width > 0 && cfg.GridWidthAtSave > 0) { // 按当前容器宽度等比缩放 col.Width = Math.Max(cfg.MinWidth, (int)(cfg.Width * (double)grid.ClientSize.Width / cfg.GridWidthAtSave)); }这个方案虽然不是像素级精确,但视觉比例是一致的。从用户体验上讲,用户并不关心列宽是多少像素,关心的是"这列在我屏幕上大概占这么宽",所以比例换算是对的。
5. 还能怎么扩展:配置联动导出、分组与重置
工具类成套之后,后面加需求就变得很轻松了。我陆续加了三块扩展,这里也顺带说说思路。
5.1 给DataGridView加右键菜单
用户自定义列最常见的操作入口是表格表头右键菜单,里面放一个子菜单,列出所有列,前面带勾选框:
public static void AttachColumnPickerMenu(DataGridView grid, string gridName) { var contextMenu = new ContextMenuStrip(); // 每列一个CheckBox风格的菜单项 foreach (DataGridViewColumn col in grid.Columns) { var item = new ToolStripMenuItem(col.HeaderText) { Checked = col.Visible, CheckOnClick = true, Tag = col.Name }; item.CheckedChanged += (s, e) => { var name = ((ToolStripMenuItem)s).Tag.ToString(); grid.Columns[name].Visible = ((ToolStripMenuItem)s).Checked; grid.SaveUserColumnConfig(gridName); // 立即保存 }; contextMenu.Items.Add(item); } grid.ContextMenuStrip = contextMenu; }这个右键菜单和工具类的保存逻辑天然契合——用户勾选完立刻保存,下次打开自动还原。
5.2 与Excel导出联动
客户后来提了个需求:"我在界面上勾选了哪些列,导出Excel的时候也要一样的列。"这正好可以用同一份配置。导出时拿到用户配置,过滤掉不可见列,按DisplayIndex排序,然后按顺序导出:
public static List<ColumnConfig> GetAppliedConfig(DataGridView grid, string gridName) { // 返回当前应该展示的列配置列表(已排序、已过滤) }导出模块就不需要关心用户勾选了哪些列,直接从这个方法拿列配置就行。
5.3 按角色隔离配置
如果项目里做了RBAC权限体系,列配置可以升级为按角色存一份。只需要把目录从users/{UserName}改成roles/{RoleName},或者在配置文件里增加一个RoleId字段,在加载时用当前用户角色过滤。基本思路一致,不增加工具类的复杂度。
完整工具类文件
最后,把整个工具类的完整代码放上来,包含前面所有提到的功能。代码基于.NET Framework 4.7.2 / .NET 6 WinForms都可用,引入了System.Xml.Serialization、System.IO、System.Linq、System.Threading.Tasks等常用命名空间。
using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using System.Xml.Serialization; namespace Common.WinForms { /// <summary> /// DataGridView 列配置项 /// </summary> [Serializable] public class ColumnConfig { public string Name { get; set; } public string HeaderText { get; set; } public bool Visible { get; set; } = true; public int DisplayIndex { get; set; } public int Width { get; set; } = 100; public int GridWidthAtSave { get; set; } public int MinWidth { get; set; } = 30; public bool Resizable { get; set; } = true; public bool IsUserModified { get; set; } } /// <summary> /// DataGridView 自定义列显示工具类 /// </summary> public static class DataGridViewColumnHelper { private static readonly Dictionary<string, CancellationTokenSource> _saveTokens = new Dictionary<string, CancellationTokenSource>(); #region 对外开放的静态方法 public static void ApplyColumnConfig(DataGridView grid, string gridName, object dataSource) { if (grid == null) throw new ArgumentNullException(nameof(grid)); if (string.IsNullOrEmpty(gridName)) throw new ArgumentNullException(nameof(gridName)); if (dataSource != null) { grid.DataSource = dataSource; } // 1. 加载默认配置,没有就基于当前列生成 List<ColumnConfig> defaults = LoadDefaultConfig(gridName); if (defaults == null || defaults.Count == 0) { defaults = CaptureCurrentLayout(grid); SaveDefaultConfig(gridName, defaults); } // 2. 应用默认配置 ApplyLayout(grid, defaults); // 3. 应用用户配置(如果有) List<ColumnConfig> userConfig = LoadUserConfig(gridName); if (userConfig != null && userConfig.Count > 0) { ApplyLayout(grid, userConfig, onlyUserModified: true); } // 4. 防止后续再次绑定数据源时列被自动生成 grid.AutoGenerateColumns = false; grid.Refresh(); // 5. 绑定事件,让用户操作自动标记并保存 HookEvents(grid, gridName); } /// <summary> /// 保存当前列布局为用户配置,并标记所有列均为用户修改 /// </summary> public static void SaveCurrentLayout(DataGridView grid, string gridName) { if (grid == null || grid.Columns.Count == 0) return; List<ColumnConfig> configs = CaptureCurrentLayout(grid, markAllAsUserModified: true); SaveUserConfig(gridName, configs); } /// <summary> /// 重置用户配置,回到默认布局 /// </summary> public static void ResetToDefault(string gridName) { string path = GetUserConfigPath(gridName); if (File.Exists(path)) File.Delete(path); } #endregion #region 配置的读写 private static List<ColumnConfig> LoadDefaultConfig(string gridName) { string path = Path.Combine( Application.StartupPath, "ColumnConfigs", "defaults", gridName + ".xml"); return LoadFromFile(path); } private static List<ColumnConfig> LoadUserConfig(string gridName) { string path = GetUserConfigPath(gridName); return LoadFromFile(path); } private static string GetUserConfigPath(string gridName) { return Path.Combine( Application.StartupPath, "ColumnConfigs", "users", Environment.UserName, gridName + ".xml"); } private static List<ColumnConfig> LoadFromFile(string path) { if (!File.Exists(path)) return null; try { var serializer = new XmlSerializer(typeof(List<ColumnConfig>)); using (var fs = new FileStream(path, FileMode.Open)) { return (List<ColumnConfig>)serializer.Deserialize(fs); } } catch (Exception ex) { Trace.WriteLine("读取列配置失败: " + ex.Message); return null; } } private static void SaveDefaultConfig(string gridName, List<ColumnConfig> configs) { string dir = Path.Combine(Application.StartupPath, "ColumnConfigs", "defaults"); SaveToFile(dir, gridName + ".xml", configs); } private static void SaveUserConfig(string gridName, List<ColumnConfig> configs) { string dir = Path.Combine( Application.StartupPath, "ColumnConfigs", "users", Environment.UserName); SaveToFile(dir, gridName + ".xml", configs); } private static void SaveToFile(string dir, string fileName, List<ColumnConfig> configs) { try { Directory.CreateDirectory(dir); string path = Path.Combine(dir, fileName); var serializer = new XmlSerializer(typeof(List<ColumnConfig>)); // 先写临时文件,全部成功后再覆盖正式文件,防止写一半程序崩溃导致配置文件损坏 string tempPath = path + ".tmp"; using (var fs = new FileStream(tempPath, FileMode.Create)) { serializer.Serialize(fs, configs); } File.Copy(tempPath, path, true); File.Delete(tempPath); } catch (Exception ex) { Trace.WriteLine("保存列配置失败: " + ex.Message); } } #endregion #region 布局抓取与应用 private static List<ColumnConfig> CaptureCurrentLayout(DataGridView grid, bool markAllAsUserModified = false) { var list = new List<ColumnConfig>(); foreach (DataGridViewColumn col in grid.Columns) { var cfg = new ColumnConfig { Name = col.Name, HeaderText = col.HeaderText, Visible = col.Visible, DisplayIndex = col.DisplayIndex, Width = col.Width, GridWidthAtSave = grid.ClientSize.Width, MinWidth = col.MinimumWidth, Resizable = col.Resizable != DataGridViewTriState.False }; if (markAllAsUserModified) cfg.IsUserModified = true; list.Add(cfg); } return list; } private static void ApplyLayout(DataGridView grid, List<ColumnConfig> configs, bool onlyUserModified = false) { if (configs == null) return; // 过滤掉配置里引用但实际不存在的列 var valid = configs .Where(c => grid.Columns.Contains(c.Name)) .ToList(); // 先按DisplayIndex排序,归一化索引 var sorted = valid.OrderBy(c => c.DisplayIndex).ToList(); int idx = 0; foreach (var cfg in sorted) { if (onlyUserModified && !cfg.IsUserModified) continue; grid.Columns[cfg.Name].DisplayIndex = idx++; } // 再设置其余属性 foreach (var cfg in valid) { if (onlyUserModified && !cfg.IsUserModified) continue; var col = grid.Columns[cfg.Name]; if (!string.IsNullOrEmpty(cfg.HeaderText)) col.HeaderText = cfg.HeaderText; bool wasVisible = col.Visible; col.Visible = cfg.Visible; // 只有从隐藏变为可见时才需要调整Width,否则可能抛异常 if (cfg.Visible && cfg.Width > 0) { if (cfg.GridWidthAtSave > 0 && grid.ClientSize.Width > 0) { col.Width = Math.Max(cfg.MinWidth, (int)(cfg.Width * (double)grid.ClientSize.Width / cfg.GridWidthAtSave)); } else { col.Width = Math.Max(cfg.MinWidth, cfg.Width); } } if (cfg.MinWidth > 0) col.MinimumWidth = cfg.MinWidth; if (!cfg.Resizable) col.Resizable = DataGridViewTriState.False; } } #endregion #region 用户交互事件监听与防抖保存 private static void HookEvents(DataGridView grid, string gridName) { // 防止重复挂事件 grid.Tag = "ColumnConfigHooked:" + gridName; grid.ColumnDisplayIndexChanged -= (s, e) => OnColumnChanged(grid, gridName); grid.ColumnWidthChanged -= (s, e) => OnColumnChanged(grid, gridName); grid.ColumnVisibleChanged -= (s, e) => OnColumnChanged(grid, gridName); grid.ColumnHeaderTextChanged -= (s, e) => OnColumnChanged(grid, gridName); grid.ColumnDisplayIndexChanged += (s, e) => OnColumnChanged(grid, gridName); grid.ColumnWidthChanged += (s, e) => OnColumnChanged(grid, gridName); grid.ColumnVisibleChanged += (s, e) => OnColumnChanged(grid, gridName); grid.ColumnHeaderTextChanged += (s, e) => OnColumnChanged(grid, gridName); } private static void OnColumnChanged(DataGridView grid, string gridName) { // 防抖:600ms内如果没有新的改动再真正保存 if (_saveTokens.TryGetValue(gridName, out var old)) { old?.Cancel(); old?.Dispose(); } var cts = new CancellationTokenSource(); _saveTokens[gridName] = cts; Task.Delay(600, cts.Token).ContinueWith(t => { if (t.IsCanceled || grid.IsDisposed) return; if (grid.InvokeRequired) { grid.BeginInvoke(new Action(() => { var configs = CaptureCurrentLayout(grid, markAllAsUserModified: true); SaveUserConfig(gridName, configs); })); } else { var configs = CaptureCurrentLayout(grid, markAllAsUserModified: true); SaveUserConfig(gridName, configs); } }, TaskScheduler.Default); } #endregion } /// <summary> /// DataGridView 扩展方法 /// </summary> public static class DataGridViewColumnExtensions { public static void ApplyColumnConfig(this DataGridView grid, string gridName, object dataSource) { DataGridViewColumnHelper.ApplyColumnConfig(grid, gridName, dataSource); } public static void ApplyColumnConfig(this DataGridView grid, string gridName) { DataGridViewColumnHelper.ApplyColumnConfig(grid, gridName, null); } public static void SaveUserColumnConfig(this DataGridView grid, string gridName) { DataGridViewColumnHelper.SaveCurrentLayout(grid, gridName); } public static void ResetUserColumnConfig(this DataGridView grid, string gridName) { DataGridViewColumnHelper.ResetToDefault(gridName); DataGridViewColumnHelper.ApplyColumnConfig(grid, gridName); } } }这个工具类我用了大半年,前前后后迭代了三个版本:第一版只做显隐,第二版加了列顺序和列宽,第三版才补上IsUserModified和DPI比例还原。现在公司项目里所有列表窗体的列自定义都走这一套,新窗体接入的改动量基本就是复制三行代码。如果你也正在被"每个窗体一套列布局代码"折磨,不妨试试把逻辑收敛到这个工具类里。至少对我来说,从那以后,"给表格加自定义列"这个需求就再也没让我加过班。