WinForms DataGridView分页控件从零手写:内存与数据库分页方案
2026/9/8 13:38:22 网站建设 项目流程

简介:面向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.Cast<DataRow>() .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 中调用这个控件。假设窗体名为 FormMain:

public 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 OFFSET:

SELECT * 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 来替代 DataTable:

public DataTable GetCurrentPageData() { if (_dataView == null) return null; var pageData = _dataView.Cast<DataRowView>() .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 而不用 SelectedIndex:

cboPageSize.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 的绑定逻辑、事件流、异常处理、用户体验全都牵连在一起,任何一个细节没处理好,都会在后续使用中反复踩坑。把这些经验整理出来,希望能让你的项目少走一些弯路。

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

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

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

立即咨询