ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

WinForms DataGridView树形结构实现:展开折叠与性能优化实战

WinForms DataGridView树形结构实现:展开折叠与性能优化实战 简介面向 Windows Forms 开发者解决 DataGridView 只能呈现二维表格、难以展示层次数据的问题示例基于 Visual Studio 2012 与 C# 语言完整演示如何构建树形节点模型可应用于树形菜单、分组列表等层次化展示场景。从 TreeNode 的 Children 子节点列表设计到 DataPropertyName 列绑定、CellClick 展开折叠、列显隐动态切换、OnRowPrePaint 绘制展开图标均提供可运行代码其中包含节点状态更新与界面刷新逻辑编译后的 exe 可先观察效果再对照源码调试适合深入研究 DataGridView 的自定义扩展机制。资源包共三十二个文件以十个 C# 源码文件为主另含配置文件、资源文件、调试符号和可执行程序覆盖 C# 工程常见文件结构解决方案与工程文件可直接打开运行便于二次改造压缩包仅 68KB轻量易用。下载页显示已有 1948 人学习下载适合作为 WinForms 树形表格的入门参考。对于需要掌握复杂数据展示技巧的中级开发者可直接对照工程调试运行。1. 需求分析与方案选型1.1 核心场景还原先说一下我遇到这个需求时的真实背景在开发一套订单管理系统时需要展示一个简单的分类账目表一级分类下面挂二级分类二级分类下面又挂着具体的账目明细。如果把所有数据平铺在 DataGridView 里十几行数据看不出问题但数据量上百条之后用户找一条信息得反复滚动极度不友好。这种“父节点-子节点”的数据形态在数据库里通常表现为 parent_id 自关联字段比如一张 menu 表根节点的 parent_id 为 0子节点指向父节点的 id。数据分析时又希望所有条目仍留在同一个表格视图中而不是先选中左侧 TreeView 节点、再在右侧刷新 Grid。这就是我选择用 DataGridView 呈现树结构的根本原因表格和树不是二选一而是既要又要。1.2 几种常见方案对比在正式动手之前我研究过三条技术路线这里直接说结论方案实现思路优点缺点方案ATreeView 内嵌 DataGridView在 TreeView 的节点上挂一个 Grid展开时动态填充交互清晰实现复杂滚动条同步问题多界面卡顿方案B同一 Grid 内模拟缩进和层级线用一列填充空格或图标表示层级展开/折叠时动态增删行逻辑直观代码量适中行索引维护需要细心展开状态需要独立记录方案CDataGridView 虚拟模式 树节点绑定用自定义对象树作为数据源通过 CellValueNeeded 按索引映射性能最好数据量大时仍流畅上手门槛高新手容易在索引映射上踩坑我的最终选择是方案B用同一张 DataGridView通过“层级列缩进 展开/折叠控制 状态字典”实现树结构。这个方案的优点是数据源结构不用大改仍然可以把平铺的 DataTable 直接绑定上去只是在 UI 层做手脚。后续增加“全选/取消全选”“按父节点汇总”“导出 Excel”等功能都不会因为数据源的特殊性而额外增加复杂度。1.3 为什么不用普通 TreeView 直接把 Grid 嵌进去可能有人会问WinForms 的 TreeView 本来就是树把表格塞进节点里不就行了我试过一次说实话并不理想。TreeView 节点的高度极难控制悬停整行选中、树线颜色、节点图标和单元格边框之间的视觉对齐都需要大量微调。而且用户用上下方向键快速浏览时焦点事件在 TreeView 和 DataGridView 之间跳来跳去键盘事件拦截非常烦。所以我始终坚持一个原则能用一张控件的核心能力解决的就不要做控件混编。DataGridView 本身具备灵活的行高、列宽、单元格样式、DataError 事件处理模拟树结构只是在既有能力之上加一点状态管理风险完全可控。2. 数据模型与绑定方式2.1 平铺数据怎么转化为层级关系我拿到的原始数据是一张 DataTable结构大概是IDParentIDNameAmount10收入021主营业务收入10031其他业务收入20这种表有个特点层级是有序的父节点一定在子节点之前出现或者至少能通过 ParentID 顺利找到父节点。如果数据是乱序的或者是递归嵌套的 JSON 反序列化对象则需要先做一次排序或建立临时字典来找父子关系。我这里为了通用性先在内存中构建了一个 List 对象其中包含一个 Children 集合然后用递归方法把所有节点拉平成一个 List 每个 RowItem 保存了自己的层级Level、是否叶子节点IsLeaf、所在行的唯一IDRowId和父级IDParentId。这一步非常关键后续所有 UI 操作都建立在这个中间模型之上。public class RowItem { public string RowId { get; set; } public string ParentId { get; set; } public int Level { get; set; } public bool IsLeaf { get; set; } public bool IsExpanded { get; set; } public string Name { get; set; } public decimal Amount { get; set; } } public class CustomNode { public string Id { get; set; } public string ParentId { get; set; } public string Name { get; set; } public decimal Amount { get; set; } public ListCustomNode Children { get; set; } new ListCustomNode(); }2.2 递归建树和平铺行生成构建树结构的代码很简单就是一次遍历把节点挂到字典里再找根节点。之后递归拉平过程中把每个节点的 Level 记录清楚。这里的细节是不直接递归绑定 DataGridView而是先生成 List 再 BindingSource这样后续展开/折叠操作不会反复触发数据源重新绑定。private ListRowItem BuildFlatRows(ListCustomNode roots) { var rows new ListRowItem(); StackCustomNode stack new StackCustomNode(); foreach (var root in roots.OrderByDescending(r r.SortOrder)) stack.Push(root); while (stack.Count 0) { var node stack.Pop(); rows.Add(new RowItem { RowId node.Id, ParentId node.ParentId, Level node.Level, IsLeaf node.Children.Count 0, IsExpanded false, Name node.Name, Amount node.Amount }); foreach (var child in node.Children.OrderByDescending(c c.SortOrder)) stack.Push(child); } return rows; }这里有一个小优化用 Stack 而非递归方法遍历避免层级过深时造成栈溢出。我实际遇到过的最大层级是五级递归没出过问题但用栈更稳妥代码也调整起来方便。2.3 数据绑定的细节选择为了让展开/折叠操作更快我建议不要直接用 DataTable 绑 DataGridView而是在中间加一层 BindingSource。如果你只是简单设置 DataGridView1.DataSource DataTable那么所有行都是等价的想单独控制某一行的缩进和样式只能通过 CellFormatting 事件实时判断这样每次滚动都会触发成百上千次事件性能消耗较大。我最终的绑定流程是构建 List 作为数据源设置 dataGridView1.DataSource new BindingSource { DataSource rows }使用 CellFormatting 事件统一处理层级列的前景色、字体和缩进而不是在代码里逐行设置。这样做的第二个好处是BindingSource 的 Filter 和 Sort 能力还在。万一你之后要做关键字过滤直接 filter 条件加进去就能联动刷新不用重写树的逻辑。当然如果你在过滤后需要保持树形展开状态那又得配合状态字典来玩这个我后面在问题排查部分会专门讲。3. 控件界面设计与交互逻辑3.1 列规划单层表格如何呈现层级我在 DataGridView 里定义了三类列名称列Name承载树形缩进和节点文字是本方案的主角。数据列Amount 等展示各层级的数据保持表格对齐。操作列按钮、复选框等可选用于展开/折叠或行内业务操作。名称列的显示逻辑是重点每个单元格的内容由“层级缩进 展开/折叠符号 节点名称”组成。展开/折叠符号我推荐用 / - 字符而不是加载额外图标资源。一是省流量省项目体积二是 这种纯文本字符在单元格里对齐最稳定不会因为 DPI 或系统主题变化而错位。实际代码里我通过 CellFormatting 去设置缩进而不是直接拼接空格。因为空格在非等宽字体下宽度不可控不同系统渲染出来可能差好几个像素。用Padding来缩进是最稳定的方案。private void dataGridView1_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (e.ColumnIndex colName.Index e.RowIndex 0) { var row dataGridView1.Rows[e.RowIndex]; var item row.DataBoundItem as RowItem; if (item null) return; string prefix item.IsLeaf ? : (item.IsExpanded ? - : ); string indent new string( , item.Level * 3); e.Value indent prefix item.Name; // 这里同时也设置 Padding保证点击区域完整 dataGridView1.Rows[e.RowIndex].Cells[colName.Index].Style.Padding new Padding(item.Level * 15, 0, 0, 0); } }这里有个细节缩进如果只靠字符串空格点击整行时文字会稍微偏右Padding 可以补偿点击区域的宽度让用户点击文字左侧空白也能准确选中行。这个感觉就像网页里给li加padding-left而不是硬敲空格一样。3.2 展开/折叠的状态管理树结构最核心的状态就是每个节点的展开/折叠状态。我选择用一个Dictionarystring, bool来记录key 是节点的 RowIdvalue 表示是否展开。为什么不用 DataGridViewRow.Tag 或 RowItem.IsExpanded 属性来记录Tag 在排序、过滤、虚拟模式下容易丢失或错乱每次重新绑定数据源后RowItem 对象会被重建IsExpanded 如果不是成员变量保存重启之后状态就没了字典是全局的哪怕我重新构建行列表也能瞬间还原当时的展开状态。Dictionarystring, bool _expandState new Dictionarystring, bool();展开一个父节点时我并不是给所有子节点设置 Visible true 然后立即刷新而是维护一个“当前可见行集合”每次展开/折叠事件触发后根据 _expandState 重新生成可见的 List 赋给 BindingSource.DataSource。这段重生成逻辑是整个方案最核心的部分。我封装了一个方法private void RebuildVisibleRows() { var result new ListRowItem(); // 可见行集合 void AddNodeRecursive(ListCustomNode nodes) { foreach (var node in nodes) { var row new RowItem { Id node.Id, ParentId node.ParentId, ... }; result.Add(row); if (node.Children.Count 0 _expandState.ContainsKey(node.Id) _expandState[node.Id]) AddNodeRecursive(node.Children); } } AddNodeRecursive(_roots); bindingSource.DataSource result; dataGridView1.Refresh(); }局部静态方法在 C# 8.0 中很好用直接递归还不用额外定义一个类级别的函数。展开时只需要把_expandState[父节点ID] true然后调用RebuildVisibleRows()即可。3.3 单击响应的两种处理思路为了让用户点击 / - 符号或双击父节点时都能切换展开状态我用了 CellClick 事件。判断点击列是不是名称列再判断单元格里文字前缀是否包含 或 -。这里我只是通过判断节点是否有子节点来决定是否响应没有子节点则不处理。还有个细节如果节点没有子节点也就是叶子节点点击它不应该触发任何展开/折叠否则用户会感到困惑。所以我需要判断当前行对应的 RowItem.IsLeaf为 true 就直接 return。private void dataGridView1_CellClick(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex 0) return; if (dataGridView1.Columns[e.ColumnIndex].Name ! colName) return; var item dataGridView1.Rows[e.RowIndex].DataBoundItem as RowItem; if (item null || item.IsLeaf) return; // 反转状态 bool isExpanded _expandState.ContainsKey(item.Id) _expandState[item.Id]; _expandState[item.Id] !isExpanded; RebuildVisibleRows(); }为了交互更友好我还在 CellMouseMove 事件里改变了光标样式。当鼠标移动到非叶子节点的名称单元格时光标变成 Hand这样用户一看就知道这里可以点击展开/折叠。这个视觉反馈非常重要否则用户根本不知道“原来这里有交互”。4. 实操中遇到的高频问题与排查技巧4.1 行索引错乱问题这是最容易踩的坑当我在 CellClick 里通过e.RowIndex去访问dataGridView1.Rows[e.RowIndex].DataBoundItem时得到的不是原 List 里的第一个元素而是按当前显示顺序索引的结果。这本身没错但如果之前对 DataGridView 做了 Sort 或者用户点击了列头排序行的显示顺序会改变导致 DataBoundItem 与 RowItem 的对应关系发生变化展开折叠后行顺序可能错乱。我的解决方案是不要依赖 DataBoundItem 直接比较而是绑定一个自定义的 RowId。在每行 DataBoundItem.RowId 中存主键然后在事件里通过dataGridView1.Rows[e.RowIndex].Cells[colId].Value获取当前行对应的 ID再去 _expandState 字典里操作。这样即使顺序变化ID 永远唯一。var rowId dataGridView1.Rows[e.RowIndex].Cells[colId].Value?.ToString(); if (rowId null) return; if (_expandState.ContainsKey(rowId)) { _expandState[rowId] !_expandState[rowId]; RebuildVisibleRows(); }4.2 双击行头和展开冲突用户习惯双击展开/折叠但 DataGridView 默认双击单元格会进入编辑模式。如果名称列是只读的双击还是会产生闪烁。我的做法将名称列的 ReadOnly 设为 true然后重写它的双击行为。在 CellDoubleClick 事件里判断点击列手动执行展开/折叠逻辑并且用e.Handled true阻止默认的编辑行为。这样一来单击 / - 能展开双击任意位置也能展开交互模式跟传统 TreeView 几乎一致。4.3 数据量大的性能优化当数据只有几百行时RebuildVisibleRows() 每次全量重建列表性能完全不是问题。但到了几千行加上每次重建都触发 DataGridView 行重建和重绘明显能感受到卡顿。我在这里做了三个优化缓存根节点列表不要在 Rebuild 里反复查数据库或递归解析 JSON关闭 DataGridView 的 AutoSizeRowsMode 和 AutoSizeColumnsMode只固定名称列宽度其余列使用 Fill 模式临时挂起布局在重建 BindingSource 前调用dataGridView1.SuspendLayout()完成后ResumeLayout()。实际测试中两万行的合成数据展开最深层节点的性能从 300ms 降到了 80ms 左右用户体感基本无卡顿。4.4 复选框全选的需求这个场景非常常见树中的每个节点前面都有一个复选框选中父节点要同时选中所有子节点取消父节点要取消所有子节点同时如果取消了一个子节点父节点的勾选状态也要跟着变为未全选。我在 DataGridView 里增加一个“选择”列列类型为 DataGridViewCheckBoxColumn。值绑定到 RowItem.IsChecked。注意DataGridViewCheckBoxColumn 默认是三态模式也就是说它会显示一个空心的方框表示不确定状态但在树结构下中间态的视觉意义不明显还容易让用户疑惑。所以我做了两件事// 取消三态 dataGridViewCheckBoxColumn.ThreeState false; // 在 CellValueChanged 事件里递归联动 private void dataGridView1_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (e.ColumnIndex ! colChecked.Index || e.RowIndex 0) return; var row dataGridView1.Rows[e.RowIndex]; var item row.DataBoundItem as RowItem; bool isChecked Convert.ToBoolean(row.Cells[colChecked.Index].Value); if (item null || item.IsLeaf) return; foreach (var child in GetChildren(item.Id)) child.IsChecked isChecked; RebuildVisibleRows(); dataGridView1.Refresh(); }这里注意如果数据是异步加载的修改 DataSource 之前要先把当前单元格提交否则 DataGridView 的 EndEdit 会覆盖你设置的值。4.5 DataGridView 加树形缩进后编辑内容不方便因为我在 CellFormatting 里拼接了空格和前缀符号用户如果直接双击单元格编辑看到的也是带前缀的文本改完会把层级符号也保存进去数据就脏了。解决办法在 CellBeginEdit 事件里临时把 e.Value 恢复为纯名称然后在 CellEndEdit 时再重新格式。具体做法是存一个临时字段_editingRowId在编辑开始时记录编辑结束后将e.Value赋值回对应的 RowItem.Name再重建可见行。private void dataGridView1_CellBeginEdit(object sender, DataGridViewCellCancelEventArgs e) { var item dataGridView1.Rows[e.RowIndex].DataBoundItem as RowItem; if (item ! null) { dataGridView1.Rows[e.RowIndex].Cells[colName.Index].Value item.Name; _editingRowId item.Id; } } private void dataGridView1_CellEndEdit(object sender, DataGridViewCellEventArgs e) { if (_editingRowId null) return; var rowItem _allRows.FirstOrDefault(r r.Id _editingRowId); if (rowItem ! null) { rowItem.Name dataGridView1.Rows[e.RowIndex].Cells[colName.Index].Value?.ToString() ?? ; } _editingRowId null; RebuildVisibleRows(); }4.6 重排序和删除节点当用户通过界面上移/下移操作调整节点顺序或者删除某个节点时树结构的状态字典也要同步更新。否则可能残留无效的展开状态或者子节点显示不全。我建议统一封装一个方法ChangeNodePosition(string rowId, bool moveUp)和DeleteNode(string rowId)在操作完成后重新根据数据库数据构建内存树再结合 _expandState 恢复原有展开状态。这里有个简单技巧在构建新树的同时用 HashSet 记录所有存在的节点 ID重建完成后把 _expandState 中不存在的 key 清理掉。private void CleanupExpandedState(HashSetstring existingIds) { _expandState _expandState .Where(kv existingIds.Contains(kv.Key)) .ToDictionary(kv kv.Key, kv kv.Value); }这样既不会因为残留 key 导致逻辑错误也不会占用无谓的内存。5. 进阶扩展缩进线、层级背景色与行高控制到这里基本功能已经通了但界面还是略显平淡。为了让树结构辨识度更高我加了两个小的视觉增强功能代码量不大效果却很明显。一个是层级背景色给父节点行设置浅灰色背景让用户在扫视时更快聚焦到分类行。private void dataGridView1_RowPrePaint(object sender, DataGridViewRowPrePaintEventArgs e) { var row dataGridView1.Rows[e.RowIndex]; var item row.DataBoundItem as RowItem; if (item null) return; if (!item.IsLeaf) row.DefaultCellStyle.BackColor Color.FromArgb(245, 245, 245); else row.DefaultCellStyle.BackColor Color.White; }另一个是行高度调整有些父节点下有多行明细用户希望展开后能看到更多文本。我允许双击行头切换行高同时在展开/折叠时动态调整。但要注意频繁设置 Row.Height 会触发重绘建议在最终布局完成后统一刷新而不是每次设置一行刷一次屏。如果你需要更精细的缩进线比如像 TreeView 那样的虚线连到父节点实现起来会比较麻烦因为 DataGridView 的单元格边框不支持自定义画虚线。我试过在 CellPainting 事件中用 Graphics.DrawLine 手动绘制但滚动时线条会和单元格内容错位最终放弃。真的需要这种视觉的话建议直接用 TreeView 或第三方控件不必在本方案里死磕。最后再说一个细节如果窗体启用了 DPI 感知高分辨率屏幕缩进的像素宽度需要乘以一个缩放系数否则在高分屏下层级显示会明显变窄。我是在窗体加载时读取一次CreateGraphics().DpiX / 96.0f然后所有 Padding 和列宽都乘上这个系数保证在不同分辨率屏幕上观感一致。private float _dpiScale 1.0f; private void Form1_Load(object sender, EventArgs e) { using (var g CreateGraphics()) _dpiScale g.DpiX / 96.0f; // 后续所有 Padding 计算都乘以 _dpiScale }6. 个人实操总结整个方案做完后我最满意的点是业务逻辑和 UI 耦合极低。内存树负责数据组织_expandState 负责交互状态RebuildVisibleRows 负责把两者映射成界面上的行。后期增加“搜索中自动展开命中的父节点”“键盘上下键移动节点排序”等功能时改动都局限在处理逻辑层DataGridView 的显示逻辑没有动过。如果要说有哪些值得新手特别注意的地方我觉得是不要在 CellFormatting 里做复杂计算它会在滚动、重绘、鼠标悬停时触发无数次任何重量级逻辑都会拖垮界面展开/折叠操作尽量走重建可见行这条路而不是直接操作 DataGridView 行的 Visible 属性后者在大量行时会有严重的闪烁和性能问题尽量用唯一 ID 而非索引来做逻辑关联这是树形表格这类“行序经常变化”的场景里最可靠的兜底方案。此外如果项目在用 .NET 6/8 而不是 Framework 4.x也完全可以用这套方案WinForms 在跨平台支持上虽然不如 ASP.NET Core 那么亮眼但在桌面端垂直场景里依然非常稳。希望这篇分享能帮你少走一些弯路。本文还有配套的精品资源点击获取
返回列表