
简介面向WinForm开发者的分页控件资源基于DataGridView封装解决了列表数据较多时分页显示与切换的常见问题。控件自带源码适合需要在WinForms项目中快速集成分页功能或学习自定义控件开发的中初级开发者。压缩包共45个文件包含cs源码文件、dll与exe可运行文件、pdb调试符号、resx/resources资源文件以及解决方案和项目文件整体大小仅96KB结构精简便于直接编译使用或二次修改。目前已有222人学习下载。通过源码可了解分页逻辑与界面事件的绑定方式并可直接复制到自己的工具箱中使用省去重复编码时间。同时包含测试项目和临时文件可作为理解控件调用方式的参考。1. 从零手写 DataGridView 分页其实没你想的那么难做 WinForms 开发的朋友迟早会遇到这么个需求DataGridView 要显示的数据越来越多几千条、几万条数据一次性绑定进去界面卡顿不说滚动查找也费劲。甲方或者产品经理张嘴就是做个分页吧然后你打开工具箱翻了半天——WinForms 原生控件里压根就没有现成的分页组件。这个时候要么引入第三方 UI 库要么撸起袖子自己写一个。我自己在维护好几个老项目都是 .NET Framework 4.x 时代的 WinForms 应用引入重型第三方控件库成本太高而且很多开源分页控件多年不更新兼容性和样式都有隐患。所以我的方案一直是自己封装一个轻量级的 DataGridView 分页控件代码量不大逻辑清晰想改样式改样式想加功能加功能完全在自己掌控之内。这篇文章就把我实践过的一套方案完整拆开讲从原理到代码到踩坑经验都有适合正在为数据列表卡顿发愁的 WinForms 开发者参考。这套方案的核心思路很简单DataGridView 只负责显示当前页的数据翻页时去数据源取对应页码的数据而不是一次性把所有数据塞给控件。2. 分页控件的整体设计思路2.1 为什么分页控件要自己封装先说说为什么要自己封装而不是直接用一个第三方分页控件。市面上确实有一些现成的分页控件比如一些商业 UI 库自带的 Grid 组件就支持分页功能。但引入这些东西有几个问题周期长、学习成本高。商业组件往往功能庞大为了一个分页功能引入整套 UI 框架有点杀鸡用牛刀的感觉。而且很多老项目用的是 .NET Framework新版控件库对 .NET Framework 的支持越来越差强行升级容易引发更多兼容问题。自封装方案则灵活得多。我可以根据项目实际情况决定分页控件的样式布局——是只显示上一页/下一页还是带页码数字还是要加每页显示条数下拉框。这些完全由我控制代码量也就一两百行维护成本极低。2.2 分页控件需要具备哪些核心功能一个合格的分页控件我认为至少要满足这几个功能点当前页数据展示这是分页的基础DataGridView 只绑定当前页的数据集合。翻页操作上一页、下一页、跳转到指定页。这是最核心的交互。页码状态展示显示当前是第几页、总共有多少页、总记录数是多少。这个信息对用户来说非常重要能让他们清楚自己浏览到了数据的哪个位置。页容量可配置常见的是每页 10 条、20 条、50 条可选。不同场景下用户对每页显示数量的需求不一样比如导出预览时希望每页多显示一些日常操作时希望每页少一点。数据源切换支持除了简单的 DataTable 分页还要能支持按条件查询后的结果分页。这点在实际项目中非常关键因为大多数业务场景都是带搜索条件的列表查询。2.3 技术选型的关键考量关于如何实现翻页主要有两种方式第一种是内存分页一次性把所有数据加载到内存里翻页时用 LINQ 的 Skip 和 Take 方法截取当前页的数据。这种方式实现起来最简单但数据量大的时候不靠谱几万条数据加载到内存还好如果是几十万条甚至上百万条内存占用和首次加载耗时都是问题。第二种是数据库分页每次翻页只从数据库查询当前页需要的记录数。比如 SQL Server 用 OFFSET FETCH 语法MySQL 用 LIMIT 语法。这种方式对大数据的支持最好但需要调整数据访问层的代码每次翻页都会发起一次数据库查询。我的建议是如果单表数据量预计在几千条以内内存分页完全够用如果数据量可能上万甚至更大直接上数据库分页。下面我会给出两种方案的完整实现方便你按需选择。3. 核心细节解析与实操要点3.1 创建分页控件用户控件我习惯将分页控件封装成一个 UserControl这样可以在多个窗体间复用。什么场景需要复用它呢比如一个管理系统里有订单列表、用户列表、产品列表每个列表的查询页面都需要分页能力这个控件就能派上用场。控件布局上我用 TableLayoutPanel 做整体容器从左到右依次放置上一页按钮、下一页按钮、页码信息标签显示第 1/10 页共 200 条、页容量下拉框。这个布局比较通用没有多余的装饰性元素符合桌面应用的界面习惯。控件对外暴露几个关键属性和事件方便使用时设置数据源和处理翻页事件。控件最核心的属性有三个DataSource分页的数据源这里用的是 DataTable 类型因为它最通用查询数据库的结果可以填充到 DataTable 中。PageSize每页显示的记录数默认 20。CurrentPage当前页码默认 1。控件对外暴露一个PageChanged事件当用户点击翻页按钮时触发该事件调用方可以在事件处理函数中根据当前页码去查询对应的数据。3.2 分页算法与页码计算逻辑分页的计算逻辑其实非常简单但有几个细节很容易忽略。总页数的计算公式是总页数 Math.Ceiling(总记录数 / 每页显示条数)这里必须用向上取整。比如总共 95 条记录每页 20 条95 除以 20 等于 4.75页码必须是 5 页因为第 5 页还有 15 条数据。用 C# 的 Math.Ceiling 方法可以得到正确结果。我最初做分页功能的时候在这里踩过坑直接用了整数除法导致最后一页的数据永远显示不出来。后来才发现需要用浮点运算再向上取整即int totalPages (int)Math.Ceiling((double)totalCount / pageSize);当前页的校验逻辑同样重要。当用户删除或筛选数据后总页数可能变小如果当前页大于总页数比如之前在第 10 页筛选后总共只有 5 页这时必须把当前页重置为总页数否则会出现空白列表的情况。我的处理方式是在每次改变数据源后统一调用一个 UpdatePageInfo 方法内部校验一遍当前页的合法性。上一个页面、下一面页面的可用状态也需要联动管理。当在当前页为第 1 页时上一页按钮应置为灰色不可点击当前页等于总页数时下一页按钮不可点击。这样用户能直观地知道是否已经到头了。3.3 用 BindingSource 简化数据绑定不少从 WinForms 入门的朋友会直接设置 DataGridView.DataSource 为一个 DataTable这种方式在工作量小的场景下没问题但在分页场景下我建议使用 BindingSource 作为中间层。BindingSource 是 WinForms 中一个非常实用的数据源封装组件它提供了定位Position、移动上一个下一个MovePrevious/MoveNext等方法。在分页场景下BindingSource 的妙处在于它可以帮助我们非常方便地管理当前数据集的游标。具体做法是将 DataSet 中的一个 DataTable 设置给 BindingSource.DataSource再将 DataGridView.DataSource 设置为这个 BindingSource。这样每次翻页时只需要重新加载 DataTable 中的内容即可DataGridView 会自动刷新界面不需要手动调用 DataGridView.Refresh() 之类的方法。3.4 页容量下拉框的实现页容量下拉框看似简单实际接合用户习惯后就能发现它的价值。比如一个用户只想快速浏览数据他会选择每页 50 条甚至 100 条减少翻页次数而一个用户需要逐条核对数据时每页 10 条会更舒服。我通常在下拉框中提供 10、20、50、100 四个选项默认选中 20。当用户切换页容量时必须将当前页重置为 1。为什么因为用户改变了每页显示数量之后如果还停留在原来的页码他看到的很可能不是自己想要的内容。比如原来每页 10 条用户翻到了第 5 页正在看第 41-50 条这时切换为每页 50 条如果仍然停在第 5 页显示的将是第 201-250 条和用户的心理预期完全不符。所以切换页容量的事件处理中重置页码这个逻辑必不可少。4. 完整代码实现与实操过程4.1 内存分页版本的实现适合中小数据量先给出使用 DataTable LINQ 内存分页的完整实现。这种方案适用于数据量不超万条级别的项目。第一步创建分页控件。新建一个用户控件 PagerControl拖入下面这些控件并设置好名称btnPrev上一页按钮btnNext下一页按钮lblPageInfo页码信息标签cboPageSize页容量下拉框第二步在控件中定义关键属性和事件public partial class PagerControl : UserControl { private DataTable _dataSource; private int _currentPage 1; private int _pageSize 20; private int _totalCount 0; private int _totalPages 1; // 页容量选项 private readonly int[] _pageSizeOptions { 10, 20, 50, 100 }; // 当前页码 public int CurrentPage { get { return _currentPage; } set { _currentPage value; } } // 每页条数 public int PageSize { get { return _pageSize; } set { _pageSize value; _currentPage 1; // 切换页容量时重置页码 } } // 数据源 public DataTable DataSource { get { return _dataSource; } set { _dataSource value; UpdatePageInfo(); } } // 页码变化事件 public event EventHandler PageChanged; public PagerControl() { InitializeComponent(); InitializePager(); } private void InitializePager() { cboPageSize.Items.AddRange(_pageSizeOptions.Select(i (object)i).ToArray()); cboPageSize.SelectedItem _pageSize; cboPageSize.SelectedIndexChanged cboPageSize_SelectedIndexChanged; btnPrev.Click btnPrev_Click; btnNext.Click btnNext_Click; } // 获取当前页数据 public DataTable GetCurrentPageData() { if (_dataSource null) return null; var pageData _dataSource.Rows.CastDataRow() .Skip((_currentPage - 1) * _pageSize) .Take(_pageSize); if (!pageData.Any()) return null; return pageData.CopyToDataTable(); } // 更新分页信息 private void UpdatePageInfo() { if (_dataSource null) { _totalCount 0; _totalPages 1; _currentPage 1; } else { _totalCount _dataSource.Rows.Count; _totalPages (int)Math.Ceiling((double)_totalCount / _pageSize); if (_totalPages 1) _totalPages 1; // 确保当前页不超出范围 if (_currentPage _totalPages) _currentPage _totalPages; if (_currentPage 1) _currentPage 1; } UpdateUI(); } // 更新控件界面状态 private void UpdateUI() { lblPageInfo.Text $第 {_currentPage}/{_totalPages} 页 共 {_totalCount} 条; btnPrev.Enabled _currentPage 1; btnNext.Enabled _currentPage _totalPages; } private void btnPrev_Click(object sender, EventArgs e) { if (_currentPage 1) { _currentPage--; OnPageChanged(); } } private void btnNext_Click(object sender, EventArgs e) { if (_currentPage _totalPages) { _currentPage; OnPageChanged(); } } private void cboPageSize_SelectedIndexChanged(object sender, EventArgs e) { if (cboPageSize.SelectedItem ! null) { _pageSize (int)cboPageSize.SelectedItem; _currentPage 1; UpdatePageInfo(); OnPageChanged(); } } private void OnPageChanged() { PageChanged?.Invoke(this, EventArgs.Empty); } }第三步在窗体的 DataGridView 中调用这个控件。假设窗体名为 FormMainpublic partial class FormMain : Form { private DataTable _allData; public FormMain() { InitializeComponent(); // 假设这里是从数据库加载了全部数据 _allData LoadDataFromDatabase(); pagerControl1.DataSource _allData; pagerControl1.PageChanged pagerControl1_PageChanged; LoadCurrentPage(); } private void LoadCurrentPage() { DataTable pageData pagerControl1.GetCurrentPageData(); dataGridView1.DataSource pageData; } private void pagerControl1_PageChanged(object sender, EventArgs e) { LoadCurrentPage(); } // 模拟从数据库加载数据 private DataTable LoadDataFromDatabase() { DataTable dt new DataTable(); dt.Columns.Add(ID, typeof(int)); dt.Columns.Add(Name, typeof(string)); dt.Columns.Add(Amount, typeof(decimal)); for (int i 1; i 1000; i) { dt.Rows.Add(i, $测试数据{i}, i * 10.5m); } return dt; } }这段代码的核心逻辑在 GetCurrentPageData 方法中它使用 LINQ 的 Skip 和 Take 方法从完整数据集中截取当前页的数据。Skip 跳过前面的记录Take 取出当前页的记录数组合起来就完成了分页截取。这里有一个值得注意的性能优化点CopyToDataTable()会复制整个 DataTable 结构并填充数据这在每页数据量不大时没有问题但如果每页数据量设置得很大比如 100 条以上频繁翻页时会有一些开销。更好的做法是在页面初始化时创建好空结构的 DataTable翻页时只做 Rows.Add 操作不过这种做法代码会复杂一些实际使用中看性能需求决定。4.2 数据库分页版本适合大数据量当数据量上到几十万条内存分页就力不从心了。这时候我们需要让数据库只返回当前页的数据。以 SQL Server 为例从 2012 版本开始支持 OFFSET FETCH 分页语法SELECT * FROM Orders ORDER BY OrderID OFFSET (pageNumber - 1) * pageSize ROWS FETCH NEXT pageSize ROWS ONLY;对应 C# 代码中在分页控件的 PageChanged 事件中重新查询数据库private void LoadCurrentPageFromDatabase() { int pageNumber pagerControl1.CurrentPage; int pageSize pagerControl1.PageSize; string sql SELECT * FROM Orders ORDER BY OrderID OFFSET Offset ROWS FETCH NEXT PageSize ROWS ONLY; SELECT COUNT(*) FROM Orders;; using (SqlConnection conn new SqlConnection(_connectionString)) using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(Offset, (pageNumber - 1) * pageSize); cmd.Parameters.AddWithValue(PageSize, pageSize); conn.Open(); using (SqlDataAdapter adapter new SqlDataAdapter(cmd)) { DataSet ds new DataSet(); adapter.Fill(ds); DataTable pageData ds.Tables[0]; int totalCount (int)ds.Tables[1].Rows[0][0]; pagerControl1.SetTotalCount(totalCount); dataGridView1.DataSource pageData; } } }注意这里我在一条 SQL 语句里同时查询了分页数据和总记录数这样只需要一次数据库交互性能会好很多。在 MySQL 中对应的语法是 LIMIT OFFSETSELECT * FROM Orders ORDER BY OrderID LIMIT PageSize OFFSET Offset;数据库分页方案的关键点是总页码数由总记录数决定而总记录数需要在分页控件外维护。所以我在分页控件中额外增加了一个 SetTotalCount 方法public void SetTotalCount(int totalCount) { _totalCount totalCount; _totalPages (int)Math.Ceiling((double)_totalCount / _pageSize); UpdateUI(); }这样调用方可以在拿到数据库返回的总数后主动更新页码信息而不需要把全量数据放入 DataSource 中。这实际上把控件从数据持有者变成了分页状态管理者职责更清晰。4.3 两种分页方案的对比与选择建议为了帮你更快地做决定我把两种方案的适用场景整理成表格对比维度内存分页数据库分页数据量范围万条以内十万条以上实现复杂度简单中等查询次数加载一次翻页零额外查询每次翻页一次查询内存占用高全量数据驻留内存低只载入当前页实时性差数据更新需重新加载好每次翻页都是最新数据适用场景字典表、小规模业务数据日志、订单流水、操作记录一个比较实用的方案是混合分页首次加载时用内存分页同时设置一个记录数阈值。如果总记录数超过阈值自动切换到数据库分页模式。不过这属于进阶玩法我这里就不展开写了有兴趣的可以在评论区交流。5. 常见问题与排查技巧实录5.1 DataGridView 绑定数据后闪烁问题这是我在实际项目中遇到最多的一个痛点。翻页时DataGridView 会先清空原来的数据再重新加载新数据这个过程会引发界面闪烁看起来非常掉档次。尤其是数据量大、机器配置较差的时候闪烁非常明显。解决办法是设置 DataGridView 的双缓冲。在窗体构造函数中加上这段代码typeof(DataGridView).InvokeMember( DoubleBuffered, System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.SetProperty, null, dataGridView1, new object[] { true });这段代码通过反射开启 DataGridView 的双缓冲机制。原理是 DataGridView 控件的 DoubleBuffered 属性是 protected 的外部无法直接访问但可以通过反射在运行时修改。双缓冲会把所有绘制操作先在内存中完成再一次性呈现到屏幕上从而消除闪烁。另一种简单做法是把dataGridView1.AutoGenerateColumns false。这样 DataGridView 不会自动生成列显示时会省去大量列宽计算和重绘操作对闪烁问题也有一定缓解作用。5.2 翻页后 DataGridView 滚动位置未重置这个坑特别隐蔽。翻页后虽然数据刷新了但 DataGridView 的垂直滚动条位置可能还停留在上一次的位置导致用户看到的不是当前页顶部的内容而是要往上滚动才能看到第一行非常影响体验。原因在于 DataGridView 默认会保持滚动条的位置。解决办法是在重新绑定数据后手动将 DataGridView 滚回顶部dataGridView1.ClearSelection(); dataGridView1.CurrentCell null; if (dataGridView1.Rows.Count 0) { dataGridView1.FirstDisplayedScrollingRowIndex 0; }这段代码放在 LoadCurrentPage 方法的末尾即可。ClearSelection 清理之前选中状态FirstDisplayedScrollingRowIndex 设为 0 让滚动条回到第一行。5.3 点击列标题排序后翻页顺序错乱这是业务场景中最容易犯的错误。用户点击 DataGridView 的列标题进行了排序然后翻页但翻页后数据顺序并没有按照排序后的顺序排列而是又回到了原始顺序。这是因为 DataGridView 的排序只是影响了界面展示的当前页数据并不会改变数据源 DataTable 中的顺序。而分页控件从 DataTable 中截取数据时是按照 DataTable 原有顺序来 Skip 和 Take 的。解决办法是在截取数据前先对 DataTable 进行排序。比如使用 DataView 来替代 DataTablepublic DataTable GetCurrentPageData() { if (_dataView null) return null; var pageData _dataView.CastDataRowView() .Skip((_currentPage - 1) * _pageSize) .Take(_pageSize) .Select(r r.Row); if (!pageData.Any()) return null; return pageData.CopyToDataTable(); }同时在 DataGridView 的 SortCompare 或 ColumnHeaderMouseClick 事件中同步更新 _dataView 的 Sort 属性。这里要处理的事件较多实际项目中需要额外注意。5.4 页容量下拉框空白的诡异问题还有一个很典型的初始化问题在窗体设计器中设置 cboPageSize 的 SelectedIndex但运行时发现下拉框显示空白。原因是在初始化顺序上如果在 cboPageSize.Items.Add 之前设置了 SelectedIndex则设置无效。解决方案有两种第一种在构造函数或 Load 事件中显式赋值cboPageSize.Items.Clear(); cboPageSize.Items.AddRange(new object[] { 10, 20, 50, 100 }); cboPageSize.SelectedIndex 1; // 默认20第二种使用 SelectedItem 而不用 SelectedIndexcboPageSize.SelectedItem 20;我倾向于使用第二种因为 SelectedItem 直接绑定值语义更清晰也不容易出现索引越界或索引为空这类问题。5.5 数据量大时查询慢需要一个进度提示当切换页容量或查询大数据量数据时数据库查询可能需要一两秒甚至更长时间。如果不做任何提示用户会以为程序卡死了或没响应。我在实际项目中会采用简单的异步加载加转圈提示的方案private async void pagerControl1_PageChanged(object sender, EventArgs e) { using (var overlay new LoadingOverlay(this)) { overlay.Show(数据加载中...); await Task.Run(() LoadCurrentPageFromDatabase()); } }LoadingOverlay 是一个半透明遮罩窗体挡住主窗体的同时显示数据加载中等提示信息避免用户在加载过程中重复点击按钮导致异常。这种方案对用户体验的提升非常明显虽然代码量不大但给人的感觉专业不少。另外需要提醒的是在异步加载时要注意控件跨线程访问的问题。DataGridView 的数据绑定必须在 UI 线程执行可以用 Invoke 方式处理this.Invoke(new Action(() { dataGridView1.DataSource pageData; UpdateUI(); }));6. 让分页控件更完善的进阶优化到这里一个基础版本的 DataGridView 分页控件已经完成了。如果你想要控件更好用还有几个方向可以继续完善。第一支持跳页功能。目前只有上一页/下一页当总页数很多时用户想直接跳到第 50 页就要点 49 次下一页按钮体验很差。可以在控件中加入一个 TextBox 和跳转按钮输入页码点击后直接跳转。实现上只需要在跳转逻辑中校验输入是否合法必须是数字且在 1 到总页数之间然后触发 PageChanged 事件即可。第二增加页码数字按钮。这个做起来要稍微复杂一些需要在控件中动态生成按钮并根据当前页码计算出显示的页码范围。比如当前在第 5 页显示 1 2 3 4 5 6 7 8 9 10 ...这种方式对用户更友好能一键跳转到附近的页面。但要注意当总页数较多时页码按钮不能全部显示需要做动态计算。第三导出功能与分页联动。如果项目里有导出 Excel 的需求导出时必须导出全量数据而不是当前页。这块最容易出错的地方是在导出时忘了区分当前页数据和全量数据导致导出结果只有一页。处理方式是在分页控件中保存一份全量数据集的引用导出时直接从全量数据中读取。在实际使用过程中我最大的体会是分页控件看着不起眼但它和 DataGridView 的绑定逻辑、事件流、异常处理、用户体验全都牵连在一起任何一个细节没处理好都会在后续使用中反复踩坑。把这些经验整理出来希望能让你的项目少走一些弯路。本文还有配套的精品资源点击获取