WinForms DataGridView树形结构实现:展开折叠与性能优化实战
2026/9/7 5:41:22 网站建设 项目流程

简介:面向 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 几种常见方案对比

在正式动手之前,我研究过三条技术路线,这里直接说结论:

方案实现思路优点缺点
方案A:TreeView 内嵌 DataGridView在 TreeView 的节点上挂一个 Grid,展开时动态填充交互清晰实现复杂,滚动条同步问题多,界面卡顿
方案B:同一 Grid 内模拟缩进和层级线用一列填充空格或图标表示层级,展开/折叠时动态增删行逻辑直观,代码量适中行索引维护需要细心,展开状态需要独立记录
方案C:DataGridView 虚拟模式 + 树节点绑定用自定义对象树作为数据源,通过 CellValueNeeded 按索引映射性能最好,数据量大时仍流畅上手门槛高,新手容易在索引映射上踩坑

我的最终选择是方案B:用同一张 DataGridView,通过“层级列缩进 + 展开/折叠控制 + 状态字典”实现树结构。这个方案的优点是数据源结构不用大改,仍然可以把平铺的 DataTable 直接绑定上去,只是在 UI 层做手脚。后续增加“全选/取消全选”“按父节点汇总”“导出 Excel”等功能,都不会因为数据源的特殊性而额外增加复杂度。

1.3 为什么不用普通 TreeView 直接把 Grid 嵌进去

可能有人会问:WinForms 的 TreeView 本来就是树,把表格塞进节点里不就行了?我试过一次,说实话并不理想。TreeView 节点的高度极难控制,悬停整行选中、树线颜色、节点图标和单元格边框之间的视觉对齐都需要大量微调。而且用户用上下方向键快速浏览时,焦点事件在 TreeView 和 DataGridView 之间跳来跳去,键盘事件拦截非常烦。

所以我始终坚持一个原则:能用一张控件的核心能力解决的,就不要做控件混编。DataGridView 本身具备灵活的行高、列宽、单元格样式、DataError 事件处理,模拟树结构只是在既有能力之上加一点状态管理,风险完全可控。

2. 数据模型与绑定方式

2.1 平铺数据怎么转化为层级关系

我拿到的原始数据是一张 DataTable,结构大概是:

IDParentIDNameAmount
10收入0
21主营业务收入100
31其他业务收入20

这种表有个特点:层级是有序的,父节点一定在子节点之前出现,或者至少能通过 ParentID 顺利找到父节点。如果数据是乱序的,或者是递归嵌套的 JSON 反序列化对象,则需要先做一次排序或建立临时字典来找父子关系。

我这里为了通用性,先在内存中构建了一个 List 对象,其中包含一个 Children 集合,然后用递归方法把所有节点拉平成一个 List ,每个 RowItem 保存了自己的层级(Level)、是否叶子节点(IsLeaf)、所在行的唯一ID(RowId)和父级ID(ParentId)。这一步非常关键,后续所有 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 List<CustomNode> Children { get; set; } = new List<CustomNode>(); }

2.2 递归建树和平铺行生成

构建树结构的代码很简单,就是一次遍历把节点挂到字典里,再找根节点。之后递归拉平,过程中把每个节点的 Level 记录清楚。这里的细节是:不直接递归绑定 DataGridView,而是先生成 List 再 BindingSource,这样后续展开/折叠操作不会反复触发数据源重新绑定。

private List<RowItem> BuildFlatRows(List<CustomNode> roots) { var rows = new List<RowItem>(); Stack<CustomNode> stack = new Stack<CustomNode>(); 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 事件实时判断,这样每次滚动都会触发成百上千次事件,性能消耗较大。

我最终的绑定流程是:

  1. 构建 List 作为数据源;
  2. 设置 dataGridView1.DataSource = new BindingSource { DataSource = rows };
  3. 使用 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 可以补偿点击区域的宽度,让用户点击文字左侧空白也能准确选中行。这个感觉,就像网页里给lipadding-left,而不是硬敲空格一样。

3.2 展开/折叠的状态管理

树结构最核心的状态就是每个节点的展开/折叠状态。我选择用一个Dictionary<string, bool>来记录,key 是节点的 RowId,value 表示是否展开。为什么不用 DataGridViewRow.Tag 或 RowItem.IsExpanded 属性来记录?

  • Tag 在排序、过滤、虚拟模式下容易丢失或错乱;
  • 每次重新绑定数据源后,RowItem 对象会被重建,IsExpanded 如果不是成员变量保存,重启之后状态就没了;
  • 字典是全局的,哪怕我重新构建行列表,也能瞬间还原当时的展开状态。

Dictionary<string, bool> _expandState = new Dictionary<string, bool>();

展开一个父节点时,我并不是给所有子节点设置 Visible = true 然后立即刷新,而是维护一个“当前可见行集合”,每次展开/折叠事件触发后,根据 _expandState 重新生成可见的 List ,赋给 BindingSource.DataSource。

这段重生成逻辑是整个方案最核心的部分。我封装了一个方法:

private void RebuildVisibleRows() { var result = new List<RowItem>(); // 可见行集合 void AddNodeRecursive(List<CustomNode> 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 行重建和重绘,明显能感受到卡顿。

我在这里做了三个优化:

  1. 缓存根节点列表,不要在 Rebuild 里反复查数据库或递归解析 JSON;
  2. 关闭 DataGridView 的 AutoSizeRowsMode 和 AutoSizeColumnsMode,只固定名称列宽度,其余列使用 Fill 模式;
  3. 临时挂起布局:在重建 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(HashSet<string> 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 的显示逻辑没有动过。

如果要说有哪些值得新手特别注意的地方,我觉得是:

  1. 不要在 CellFormatting 里做复杂计算,它会在滚动、重绘、鼠标悬停时触发无数次,任何重量级逻辑都会拖垮界面;
  2. 展开/折叠操作尽量走重建可见行这条路,而不是直接操作 DataGridView 行的 Visible 属性,后者在大量行时会有严重的闪烁和性能问题;
  3. 尽量用唯一 ID 而非索引来做逻辑关联,这是树形表格这类“行序经常变化”的场景里最可靠的兜底方案。

此外,如果项目在用 .NET 6/8 而不是 Framework 4.x,也完全可以用这套方案,WinForms 在跨平台支持上虽然不如 ASP.NET Core 那么亮眼,但在桌面端垂直场景里依然非常稳。希望这篇分享能帮你少走一些弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询