WinForm DataGridView分页控件封装:从原理到实战的完整指南
简介面向WinForm开发者的DataGridView分页控件源码由作者自行封装实现主要解决C#桌面项目中DataGridView数据多、翻页不便的问题。控件使用比较简单不需要额外依赖复杂库适合有一定.NET基础、希望快速集成或学习分页实现逻辑的读者。RAR压缩包整体只有96KB共包含45个文件其中10个cs源文件负责核心逻辑sln/csproj为Visual Studio工程组织文件resx/resources用于界面资源配置dll/exe为编译输出pdb为调试符号另有一些临时生成文件和项目配置文件。源码与可执行程序一并打包既能直接运行体验也可对照源码阅读。目前已有223人学习下载。包内目录结构清晰分为分页控件主体、测试工程和公共属性配置测试工程中带有窗体界面与程序入口可以直观看到分页控件如何被创建、绑定数据以及触发翻页。整体封装层次比较完整便于将控件提取到其他WinForm项目也方便按需修改按钮样式、页码显示或数据源类型。1. 为什么说 WinForm 的 DataGridView 分页控件值得自己做一套DataGridView 是 WinForm 开发里用得最多的表格控件但用过的人都知道它天生不带分页。数据量一上去直接绑定几千行界面就开始卡顿滚动条拖起来像在拉牛车。于是做企业管理系统的人基本都会遇到同一个需求给自己项目里的 DataGridView 套一个分页控件。市面上的第三方控件要么收费要么年头太久跟新环境不兼容与其找轮子还不如自己封装一个几十分钟就能做出来而且完全可控。这个标题讲的就是这套东西适合正在做进销存、后台管理、报表系统的 .NET 开发者不管你是刚入门还是写了好几年把分页控件的实现逻辑吃透日常开发能省不少事。分页控件听起来像个新功能其实本质就一句话把 DataGridView 绑定的数据源按页切块一次只显示当前页大小的行数。真正需要设计的部分是页码导航、数据切块策略和与数据库的配合方式。接下来我会从控件骨架、分页原理、数据访问层写法、常见坑到进阶技巧全部过一遍照着抄就能用。2. DataGridView 分页控件的原理与选型先搞懂分页切在哪里再动手写控件2.1 分页的本质显示层切块还是数据库切块先明确一个概念分页到底是在哪里切的。很多人会误以为分页就是在 DataGridView 前面挡一道把传进来的数据揉碎了展示这种思路只适合数据量小的场景。真正要区分的是两个方向内存分页和数据库分页。内存分页指把全量数据一次性查出来放在 DataTable 或 List 里然后用 Skip 和 Take 的 LINQ 语法按页取数。这种做法逻辑简单写起来很快但数据量超过几万行之后取数本身和 UI 渲染都会变慢。数据库分页则是在 SQL 语句里直接写页码和每页大小数据库只把需要的那一页返回给客户端。这才是几十万行数据的正解。WinForm 分页控件你能不能做好关键看你选择了哪一条路。一般我的建议是控件本身做成一个用户控件内部支持传入页码和页大小至于数据是内存里切还是数据库里切由调用方决定控件只管把当前要显示的数据 Bind 到 DataGridView并把分页状态总页数、当前页、总记录数显示在导航条上。判定标准就一条总行数在 5000 行以内用内存分页完全可以接受响应速度几乎无感超过这个量级请直接走数据库分页。不要在一开始就纠结哪个高级在数据量没上来之前内存分页的维护成本最低。2.2 分页导航条的组成与用户习惯写分页控件之前先想清楚导航条上要放哪些东西。我见过很多项目把导航条做得很花哨有翻页动画、有页码下拉框结果现场用户根本不会用。分页导航的核心就那么几样首页、上一页、下一页、末页中间至少要有数字页码最后放一个当前页/总页数的文本显示和页码跳转输入框。这个组合是所有老管理系统里最常见的习惯做好它业务人员上手零成本。有不少开发者会在这里犯一个错把页码做成下拉框。看起来方便实际用起来很别扭因为用户往往记得自己要翻到第几页却记不住页码是哪一个选项输入框跳转反而更直接。导航条的选型还可以细化到一个点页面中间的页码按钮怎么排。比如一共 50 页你不能把 1 到 50 全部排出来对齐都会很痛苦。通用做法是显示有限个页码比如当前页前后各扩展 2 个首尾页固定显示中间用省略号代替。这个逻辑看着简单写的时候要注意边界当前页在 1、2 页时前面不能出现负数页码当前页在倒数第二页时后面扩展的页码不能超过总页数。这个细节是后面避坑章节的重点之一别忽略。2.3 为什么用 UserControl 而不是直接改写 DataGridView有人在设计时可能会想能不能直接继承 DataGridView给它加一个分页属性技术上能实现但我强烈不建议。DataGridView 继承链上的职责已经很重把分页导航揉进去会破坏原本的显示职责后期维护非常痛苦。更好的做法是做一个独立的 UserControl里面放一个 DataGridView 和导航条区域对外提供属性、方法和事件让调用方像使用普通控件一样使用它。这个控件的对外接口我一般设计成这样PageSize 属性控制每页行数PageIndex 属性表示当前页码TotalCount 字段用来记录总记录数BindData 方法接收数据源PageIndexChanged 事件在外面挂载翻页后的数据刷新逻辑。调用方不需要关心导航条内部是怎么生成页码的只要告诉控件“我要从数据库查第 3 页的数据”控件就会更新表格显示。用 UserControl 还有个好处是按钮布局可以随项目风格调整比如有的系统喜欢把翻页按钮放在表格上方有的放在下方你只需要改一个 UserControl 内部的布局所有用了这个控件的地方都会跟着变不用一个窗体一个窗体去改。3. 写一个可复用的 WinForm DataGridView 分页控件从属性设计到核心代码3.1 控件的属性、事件与字段设计动手写之前先把控件的类骨架搭好。我习惯把分页控件命名为 PagerControl放在项目公共类库里后续任何窗体都能引用。控件里核心的字段和属性如下。public partial class PagerControl : UserControl { // 数据源与表格引用 private DataTable _dataSource; // 当前页要绑定的数据 private DataGridView _grid; // 目标表格控件引用 private int _pageIndex 1; // 当前页码默认从第 1 页开始 private int _pageSize 20; // 每页显示行数默认 20 行 private int _totalCount 0; // 总记录数 // 对外事件翻页时触发用于让外部重新查询数据 public event EventHandler PageIndexChanged; // 属性当前页码 public int PageIndex { get { return _pageIndex; } set { _pageIndex value; } } // 属性每页行数 public int PageSize { get { return _pageSize; } set { _pageSize value; } } // 属性总记录数设置后自动刷新总页数显示 public int TotalCount { get { return _totalCount; } set { _totalCount value; UpdatePageInfo(); } } // 属性总页数只读 public int PageCount { get { if (_pageSize 0) return 1; return (_totalCount _pageSize - 1) / _pageSize; } } public PagerControl() { InitializeComponent(); } }这段代码定义了分页控件的基本数据契约。TotalCount 属性赋值后会自动刷新页面信息PageCount 的计算用的是向上取整公式(totalCount pageSize - 1) / pageSize 是经典写法避免了 Math.Ceiling 的浮点数转换。PageIndexChanged 事件是控件与外部窗体沟通的桥梁翻页按钮点击后只触发事件真正的数据查询由外部窗体完成。3.2 翻页按钮逻辑首页、上一页、下一页、末页导航条上放四个按钮点击后的逻辑核心是边界判断。写这个逻辑时最容易翻车的地方是当当前页已经在首页时上一页按钮还能不能点。答案是不能点了会导致 PageIndex 变成 0 甚至负数查询直接出错。private void btnFirst_Click(object sender, EventArgs e) { if (_pageIndex ! 1) { _pageIndex 1; UpdatePagerStatus(); OnPageIndexChanged(); } } private void btnPrev_Click(object sender, EventArgs e) { if (_pageIndex 1) { _pageIndex--; UpdatePagerStatus(); OnPageIndexChanged(); } } private void btnNext_Click(object sender, EventArgs e) { if (_pageIndex PageCount) { _pageIndex; UpdatePagerStatus(); OnPageIndexChanged(); } } private void btnLast_Click(object sender, EventArgs e) { if (_pageIndex ! PageCount) { _pageIndex PageCount; UpdatePagerStatus(); OnPageIndexChanged(); } } private void OnPageIndexChanged() { PageIndexChanged?.Invoke(this, EventArgs.Empty); }这段代码的关键点在于每个按钮触发翻页前都要先判断边界条件判断不通过就什么都不做。btnFirst 和 btnLast 不是每次点击都有效只有当前页和目标页相差时才更新状态。注意每次修改 _pageIndex 后都调用了 UpdatePagerStatus 方法这个方法负责刷新按钮的可用状态和页码显示稍后会给实现。OnPageIndexChanged 使用空传播运算符调用事件外部窗体在 PageIndexChanged 事件里执行新的数据查询。3.3 数字页码动态生成与省略号逻辑中间的数字页码看着不难写起来要注意边界情况。我的实现思路是维护一个 List把需要显示的页码全部算出来排入列表然后循环生成按钮。省略号不需要特殊对待直接生成一个不可点击的 Label。private Listint GetPagerItems() { Listint items new Listint(); int pageCount PageCount; int current _pageIndex; items.Add(1); // 首页永远显示 if (pageCount 7) // 总页数少时全显示 { for (int i 2; i pageCount; i) items.Add(i); } else { // 当前页前后各扩展 2 页 int start Math.Max(2, current - 2); int end Math.Min(pageCount - 1, current 2); if (start 2) items.Add(-1); // -1 表示省略号 for (int i start; i end; i) items.Add(i); if (end pageCount - 1) items.Add(-2); // 尾部省略号 } if (pageCount 1) items.Add(pageCount); // 末页永远显示 return items; }这个逻辑的精髓在于把页码和省略号统一抽象为一个整数列表-1 和 -2 分别代表前部省略号和后部省略号。pageCount 大于 7 时才启用省略号逻辑等于或小于 7 时全部展开。getPagerItems 不用去考虑按钮的实际创建和间距只负责告诉外面“这一页应该显示哪几个页码”职责单一后续如果要改成下拉框模式也只需要替换这个方法的调用方。创建按钮这一步就简单了循环 items 列表遇到负数就创建 Label 显示“…”遇到正数就创建 Button 并绑定点击事件。按钮的点击事件里把 _pageIndex 改成该按钮对应的页码然后刷新表格数据。3.4 数据绑定方法与表格联动的完整链路控件写到这里还差最后一步把数据真正显示到 DataGridView 上。数据绑定方法一般由外部窗体调用传入当前页的 DataTable。public void BindData(DataTable dt, string gridName) { if (_grid null) { _grid this.Controls.Find(gridName, true)[0] as DataGridView; } _dataSource dt; _grid.DataSource null; _grid.DataSource dt; UpdatePagerStatus(); } private void UpdatePagerStatus() { lblPageInfo.Text string.Format(第 {0} 页 / 共 {1} 页, _pageIndex, PageCount); btnPrev.Enabled _pageIndex 1; btnNext.Enabled _pageIndex PageCount; btnLast.Enabled _pageIndex PageCount; btnFirst.Enabled _pageIndex 1; }这段代码解决了两个问题一是通过控件名字查找到外部窗体的 DataGridView避免在设计期写死引用二是先清空 DataSource 再重新赋值否则 DataGridView 在绑定新数据源时会因为旧数据源中已有默认视图而出现刷新异常。BindData 方法接收的 dt 就是已经按页码切好的当前页数据控件本身不做切分。这里要提醒一句在 UpdatePagerStatus 里btnNext 和 btnLast 的判断条件要一致btnFirst 和 btnPrev 的判断条件要一致。我在实际项目中见过有人把 btnLast 写成 _pageIndex PageCount 1结果最后一页时按钮还亮着点击后页码直接越界。边界判断的逻辑统一写在一个方法里能避免这类问题。4. 数据访问层的分页写法SQL 端配合控件的三种方案与选择4.1 先说清楚为什么客户端控件管不了大数据量控件写得再好数据访问层不配合也是白搭。如果你用内存分页那当用户点下一页时你重新把 10 万行数据从数据库查一遍再在内存里 Skip 掉 20 行耗时和带宽都受不了。所以当数据量明显超过 1 万行时一定要让数据库只返回当前页数据。客户端分页控件此时扮演的角色就是“传参员”把页码 PageIndex 和每页大小 PageSize 传给数据访问层数据访问层把当前页的数据封装成 DataTable 返回再交给 BindData 方法显示。数据访问层的分页写法主要有三种ROW_NUMBER 分页、OFFSET FETCH 分页、临时表分页。日常开发中前两种足够第三种一般只在复杂统计报表中才用得到。下面逐个说明。4.2 ROW_NUMBER 分页适用范围最广的经典写法ROW_NUMBER 是 SQL Server 2005 以后就有的功能也是我见到最多老项目在用的分页方式。它的核心思路是先按排序字段给记录编号再取出编号落在当前页范围内的记录。-- 查询第 2 页每页 20 行 DECLARE PageIndex INT 2; DECLARE PageSize INT 20; SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY CreateTime DESC) AS RowNum, * FROM Product ) AS T WHERE RowNum BETWEEN (PageIndex - 1) * PageSize 1 AND PageIndex * PageSize;这里 ROW_NUMBER() OVER 的 ORDER BY 决定了分页的排序规则业务上必须有一个确定的排序字段否则会出现同一份数据在多次查询时顺序不一致的情况。BETWEEN 后面的两个表达式分别对应本页起始行号和结束行号第 2 页 20 行时就是 21 到 40 行。这个写法的缺点是子查询会把全表数据都编号数据量大时效率一般但好在不需要额外建临时表适合中大规模数据。4.3 OFFSET FETCH 分页简洁且性能稳定OFFSET FETCH 是 SQL Server 2012 引入的语法比 ROW_NUMBER 更简洁。它的逻辑更贴近人类的自然思维跳过多少行取多少行。-- 查询第 3 页每页 20 行 DECLARE PageIndex INT 3; DECLARE PageSize INT 20; DECLARE OffsetRows INT (PageIndex - 1) * PageSize; SELECT * FROM Product ORDER BY CreateTime DESC OFFSET OffsetRows ROWS FETCH NEXT PageSize ROWS ONLY;OFFSET 后面是跳过的行数(页号 - 1) 乘以每页行数正好是前面页数占用的总行数。FETCH NEXT 后面的值就是当前页要取的行数。这段 SQL 比 ROW_NUMBER 少了子查询层在执行计划上往往更优。要注意的是 OFFSET FETCH 必须和 ORDER BY 一起使用数据库不允许没有排序的分页操作因为那样它自己也不知道该从哪一页取起。我在项目里一般会用这个方案作为推荐项前提是数据库版本不低于 SQL Server 2012。老版本数据库只能退回 ROW_NUMBER。4.4 一次查出数据与总数避免总页数更新不及时分页控件显示总页数时需要知道总记录数任何分页查询都绕不开这个问题。理想的做法是用一个批次执行两条 SQL第一条查总记录数第二条查当前页数据不要分两次往返连接数据库。public DataTable GetProductPage(int pageIndex, int pageSize, out int totalCount) { string sql SELECT COUNT(*) FROM Product; SELECT * FROM Product ORDER BY CreateTime DESC OFFSET OffsetRows ROWS FETCH NEXT PageSize ROWS ONLY;; using (SqlConnection conn new SqlConnection(connectionString)) using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(OffsetRows, (pageIndex - 1) * pageSize); cmd.Parameters.AddWithValue(PageSize, pageSize); conn.Open(); using (SqlDataReader reader cmd.ExecuteReader()) { reader.Read(); totalCount reader.GetInt32(0); // 第一条结果集是总数 reader.NextResult(); DataTable dt new DataTable(); dt.Load(reader); // 第二条结果集是当前页数据 return dt; } } }这段代码用了 SqlDataReader 的 NextResult 方法顺序读取两个结果集避免开两次连接。totalCount 通过 out 参数传出调用方拿到后直接赋给分页控件的 TotalCount 属性总页数就自动刷新了。这里最关键的是必须先把 Reader 移到第一个结果集才读总数然后 NextResult 跳过第一个结果集去读第二个顺序一旦颠倒第二个结果集会读不到。AddWithValue 在传参时的数据库类型推断可能不准确如果遇到参数类型报错建议改成 SqlParameter 显式指定 DbType。5. 分页控件集成时的避坑与排查五个高频事故的处理记录5.1 页码越界导致数据库查询异常现象用户停留在第 10 页此时有人在别的终端删除了大量数据总记录数从 200 条降到 150 条总页数从 10 页变成 8 页。用户再点下一页控件仍然把 PageIndex 传到数据层SQL 里的 OFFSET 计算出来的偏移量超过总行数数据库不报错但返回空 DataTable界面上表格什么都看不到。更严重的情况是有些封装的数据访问层在 offset 超过总行数时会抛出异常。原因分页控件内部的 PageCount 是根据外部传进来的 TotalCount 计算的但用户在翻页过程中数据源的真实行数可能因为多端操作而发生变化控件不知道这个变化。解决在每次翻页操作触发数据查询前先重新查询一次总记录数让 TotalCount 刷新再判断当前 PageIndex 是否已经大于新的 PageCount。如果是就把 PageIndex 重置为新的 PageCount不执行下一页查询。int freshTotal GetTotalCount(); // 重新查询总数 pagerControl.TotalCount freshTotal; if (pagerControl.PageIndex pagerControl.PageCount) { pagerControl.PageIndex pagerControl.PageCount; } pagerControl.OnPageIndexChanged(); // 实际项目中会封装成公开方法这段逻辑放在外部窗体的翻页事件处理开头而不是放在控件内部。因为只有外部调用方才清楚自己数据总数的来源。5.2 筛选条件和分页状态不同步现象用户在查询条件里输入了“类型 A”查询结果有 300 条共 15 页。用户翻到第 7 页突然清空了筛选条件重新查询结果有 500 条共 25 页。此时分页控件还停留在第 7 页但筛选条件和页码之间已经错位。更隐蔽的场景是在筛选条件下翻到某一页再点击查询按钮时SQL 的 WHERE 条件变了但 OFFSET 还是按旧条件的页码计算的返回的数据在逻辑上缺失了一段。原因分页状态是全局的但筛选条件变化后业务上应该重置分页状态很多项目漏掉了这个重置动作。解决在所有筛选条件变化触发的查询方法里统一加上两句把 PageIndex 重置为 1把 TotalCount 清空为 0再执行查询。把这个重置动作写进一个公共查询方法禁止在查询逻辑里直接绕过分页控件去取数。5.3 跨线程更新 DataGridView 导致界面卡死或闪烁现象点击下一页按钮UI 线程直接去数据库执行查询查询耗时 3 秒以上。这期间界面无法拖动鼠标在按钮上一直显示转圈图标体验很差。有些项目为了避免卡顿把查询放到后台线程但直接在后台线程里赋值给 _grid.DataSource抛出了跨线程操作异常或者不报错但界面黑掉。原因WinForm 的控件必须在创建它的线程上更新后台线程直接操作控件违反了这个规则。另外即使是 UI 线程查询长时间阻塞也会有界面冻结的问题。解决用 async/await 配合 Task.Run 把数据库查询放到线程池查询完成后再通过 await 回到 UI 线程继续绑定数据。private async void btnNext_Click(object sender, EventArgs e) { btnNext.Enabled false; // 防止重复点击 int pageIndex pagerControl.PageIndex; int pageSize pagerControl.PageSize; DataTable dt await Task.Run(() GetProductPage(pageIndex, pageSize, out int totalCount)); pagerControl.TotalCount totalCount; pagerControl.BindData(dt, dataGridView1); btnNext.Enabled true; }异步方法里查数据耗时再长也不会阻塞 UI 线程用户至少可以拖动窗体。要注意 await 后面的代码已经回到 UI 上下文所以 BindData 是安全的。还有一点如果用户快速连续点击下一页按钮要禁用等查询完成后再恢复否则会出现页码跳变发出两个请求后第二次返回的数据覆盖了第一次页面显示错乱。5.4 DataTable.AsEnumerable 在 .NET Framework 下缺引用现象按照网上的内存分页教程写了 var rows dt.AsEnumerable().Skip(...).Take(...).CopyToDataTable()编译时却报错说 DataTable 没有 AsEnumerable 方法。原因AsEnumerable 扩展方法定义在 System.Data.DataSetExtensions 程序集里.NET Framework 项目默认没有引用这个程序集。解决在项目引用里添加 System.Data.DataSetExtensions或者干脆避开这个方法直接用 DataTable.Select 加循环或者用 CopyToDataTable 之前确保 using System.Data。如果是 .NET Core / .NET 5 项目这个问题几乎不存在因为框架已经默认包含。还有一种规避方案是不要用 AsEnumerable改用 DataView 的方式过滤但 DataView 的排序逻辑又得自己写代码量上去了。所以我一般会优先加引用保留 LINQ 写法的简洁性。5.5 最后一页只剩少量数据时显示异常现象总共 101 条数据每页 20 条总页数是 6。点第 6 页时只应显示 1 条数据但界面偶尔会显示 21 条或者出现一条空白行。排查半天发现是内存分页的 Take 方法参数写成了固定页大小没有用总数减去前面页数的差值。原因内存分页时取当前页数据量的公式是当前页行数 min(PageSize, TotalCount - (PageIndex - 1) * PageSize)。如果直接用 PageSize 固定 Take最后一条记录之后越界LINQ 会返回空集合而不是自动纠正。解决在切分数据时写一个公共方法内部同时计算这一页的实际行数与数据库分页的 OFFSET FETCH 行为保持一致。数据库分页天然不会取多因为 FETCH NEXT 在剩余行不足时按实际剩余行返回。内存分页必须手动模拟这个逻辑否则最后一页显示空表格或错乱行。6. 把分页控件做成通用组件数据量大时的虚拟模式与查询联动技巧分页控件写到这里已经能用了但想让它在真实项目里扛得住更大的数据量还可以再做两件事启用 DataGridView 的 VirtualMode以及把查询条件和分页导航做成一条链路。虚拟模式是什么简单说就是让 DataGridView 只有在显示某一行时才去数据源里取那一行的数据而不是一次性把所有行都加载到内存。这对动辄几万行的表格有非常明显的效果滚动条顺滑度提升一个档次。启用虚拟模式需要在 DataGridView 上设置 VirtualMode true并实现 CellValueNeeded 事件。我的习惯是让分页控件对外提供一个 TotalRows 属性数据访问层给控件传一个“取单行数据”的回调委托。当用户滚动时 DataGridView 请求第 100 行回调就执行一次轻量查询或从缓存字典里取。注意 VirtualMode 和分页并不冲突分页把总数据切成页VirtualMode 保证每一页内的行加载不卡顿两者叠加效果最好。public void BindVirtualData(Funcint, DataRow rowLoader, int totalRows) { _grid.VirtualMode true; _grid.RowCount totalRows; _grid.CellValueNeeded (sender, e) { DataRow row rowLoader(e.RowIndex); if (row ! null) e.Value row[e.ColumnIndex]; }; }这个回调式设计的好处是数据源本身不依赖 DataTable 全量加载只要按行号取数。实际项目里 rowLoader 内部可以从缓存字典查找也可以走一次索引查询。要注意的是 VirtualMode 下 DataGridView 不会自己维护 RowCount 之外的任何数据属性所有取值逻辑全部落在 CellValueNeeded 里所以必须保证这个事件始终被订阅。查询联动是另一个实用技巧把搜索框、查询按钮和分页控件放进同一个用户控件里。外部只管设置查询条件源控件内部统一处理“点击查询时重置页码并刷新数据”的逻辑。这样业务窗体写代码时只需一行初始化不会出现上一节说的筛选条件和分页状态不同步问题。我在做一个库存查询模块时就踩过这个坑当时一个查询条件改了但页码没重置数据从第 11 页开始查查了十分钟才发现问题。后来把所有查询入口统一收敛到一个方法开头固定把 PageIndex 重置为 1这个毛病再没出现过。希望帮到你把自己的分页控件做扎实后面维护老系统时真的能省下大量时间。本文还有配套的精品资源点击获取