简介这是一份面向WinForm开发者的DataGridView分页控件源码资源针对桌面应用中表格数据量大、需要分页展示的常见需求提供了一套可直接集成到项目中的轻量级分页方案。资源包共45个文件约96KB以cs源码文件为主配合resx与resources资源文件、csproj项目文件及sln解决方案另含编译生成的dll、pdb、exe等运行与调试产物整体结构完整便于直接打开解决方案查看与调试。控件封装了分页逻辑与DataGridView的绑定展示使用方式较为简单适合初、中级.NET桌面开发者在实际项目中参考复用也可作为学习自定义控件设计的示例。目前已有223人学习下载读者可从中获取分页控件的完整实现思路、界面设计器代码与项目组织方式快速理解分页与数据绑定的配合关系减少自行摸索的时间。1. 一个 WinForm 表格卡到转圈问题往往不在数据量接手一个老桌面项目时最容易被低估的坑就是 DataGridView 直接绑全量数据。几百行时看不出问题几千行开始滚动发涩上万行连窗口都要等几秒才响应。这个分页控件的定位很明确给 WinForm 的 DataGridView 补上服务端分页能力让表格只拿当前页那几十条翻页、改每页条数、跳页都由控件统一调度。适合正在维护 WinForm 管理后台、还在用 DataAdapter 一把梭绑数据的开发者也适合想把分页逻辑从窗体代码里抽出来复用的熟手。它不改变 DataGridView 的渲染方式只接管“取哪一页数据”这件事所以接入成本低改造边界清楚。2. 控件结构与分页链路一次翻页到底经过了谁2.1 三个核心角色分页条、数据源适配器、状态容器这类控件通常拆成三块。分页条负责 UI包含首页、上一页、下一页、末页、页码输入框和每页条数下拉数据源适配器负责把“第几页、每页多少条”翻译成具体查询状态容器保存当前页、总记录数、总页数避免每次翻页都重新算一遍。三者之间用事件解耦分页条只发“页码变了”的信号适配器收到后去取数取完再回填表格。// 分页状态容器只存分页元数据不碰 DataTable public class PagerState { public int CurrentPage { get; set; } 1; // 当前页码从 1 开始 public int PageSize { get; set; } 20; // 每页条数 public int TotalCount { get; set; } 0; // 总记录数由适配器回填 public int TotalPages (TotalCount PageSize - 1) / PageSize; // 向上取整 }这段代码的关键在TotalPages的向上取整写法。很多人用TotalCount / PageSize直接整除结果 21 条、每页 20 条时算出 1 页第二页永远翻不到。(a b - 1) / b是整数运算里最稳的向上取整不需要转 double也不会有精度问题。CurrentPage和PageSize的默认值建议在控件初始化时从配置文件读别写死在窗体里后面调每页条数时不用改代码。2.2 翻页事件链路从按钮点击到表格刷新一次完整的翻页链路是这样的用户点“下一页”分页条把CurrentPage加一触发PageChanged事件窗体订阅这个事件调用适配器的LoadPage(page, size)适配器拼出分页 SQL 或调用带分页参数的存储过程拿回当前页数据和总记录数最后把 DataTable 赋给 DataGridView 的 DataSource同时把总记录数回写给分页条更新页码显示。// 分页条触发翻页窗体负责调度 private void pager_PageChanged(object sender, PageChangedEventArgs e) { // e.PageIndex 是目标页码e.PageSize 是当前每页条数 var result _adapter.LoadPage(e.PageIndex, e.PageSize); if (result null || result.Rows.Count 0) { // 空页保护比如删完最后一页数据后回退到上一页 if (e.PageIndex 1) { pager.CurrentPage e.PageIndex - 1; return; } } grid.DataSource result; pager.TotalCount _adapter.LastTotalCount; // 适配器在查询时顺带回填总数 }这里有两个容易翻车的点。第一空页保护必须做否则用户删掉最后一页的最后一条数据后界面会停在一个空白页页码还显示正常体验很怪。第二LastTotalCount由适配器在查询时一并返回不要单独再发一条COUNT(*)两次查询之间数据可能变化页码和总数会对不上。常见做法是在同一条 SQL 里用COUNT(*) OVER()窗口函数或者存储过程里先查总数再查分页数据保证同一事务快照。2.3 数据源适配器的两种实现SQL 分页与内存分页适配器一般提供两种模式。SQL 分页适合数据量大、数据库支持OFFSET FETCH或ROW_NUMBER()的场景内存分页适合数据已经全部在本地、只是不想一次性渲染的场景比如从 CSV 或本地缓存读出来的数据。两种模式对外接口一致切换时只改适配器实现窗体代码不动。public interface IPageAdapter { DataTable LoadPage(int pageIndex, int pageSize); int LastTotalCount { get; } } // SQL 分页实现以 SQL Server 为例 public class SqlPageAdapter : IPageAdapter { private readonly string _connStr; private readonly string _baseSql; // 不带分页的原始查询如 SELECT * FROM Orders WHERE Status1 public int LastTotalCount { get; private set; } public DataTable LoadPage(int pageIndex, int pageSize) { var dt new DataTable(); using (var conn new SqlConnection(_connStr)) using (var cmd new SqlCommand()) { cmd.Connection conn; // 用窗口函数一次拿到总数和当前页避免两次查询不一致 cmd.CommandText $ WITH Paged AS ( SELECT *, COUNT(*) OVER() AS TotalRows, ROW_NUMBER() OVER (ORDER BY Id) AS RowNum FROM ({_baseSql}) t ) SELECT * FROM Paged WHERE RowNum BETWEEN Start AND End; cmd.Parameters.AddWithValue(Start, (pageIndex - 1) * pageSize 1); cmd.Parameters.AddWithValue(End, pageIndex * pageSize); conn.Open(); new SqlDataAdapter(cmd).Fill(dt); } LastTotalCount dt.Rows.Count 0 ? Convert.ToInt32(dt.Rows[0][TotalRows]) : 0; dt.Columns.Remove(TotalRows); // 辅助列不暴露给表格 dt.Columns.Remove(RowNum); return dt; } }_baseSql是不带分页的原始查询适配器只负责在外面套分页壳这样业务筛选条件可以原样传进来不用在适配器里拼 WHERE。TotalRows和RowNum是辅助列取完必须从 DataTable 里删掉否则 DataGridView 会多出两列用户看着莫名其妙。ORDER BY Id里的排序字段要可配置不能写死否则按时间排序的需求来了还得改适配器。3. 把控件跑起来从零接入的完整步骤3.1 环境准备与控件引用这类控件通常是单个类库项目编译出一个 DLL或者直接把源码文件拖进现有项目。推荐后者改起来方便不用来回编译引用。需要的环境是 .NET Framework 4.5 以上WinForm 项目数据库驱动按实际用的库来SQL Server 用System.Data.SqlClientMySQL 用MySql.Data。如果项目是 .NET Core 或 .NET 5 的 WinFormSystem.Data.SqlClient要换成Microsoft.Data.SqlClient命名空间不同其余代码基本不用动。接入步骤不复杂把分页条控件拖到窗体上放在 DataGridView 下方在窗体构造函数里初始化适配器传入连接字符串和基础查询订阅PageChanged事件首次加载时手动触发一次第一页。注意首次加载不要依赖控件自动触发很多分页控件在TotalCount为 0 时不会发事件导致界面空白得在Form_Load里显式调一次LoadPage(1, pageSize)。3.2 最小可运行示例一个订单列表页假设有一个订单表字段是 Id、OrderNo、Amount、Status、CreatedAt要做成分页列表。基础查询是SELECT * FROM Orders WHERE Status 1每页 20 条。public partial class OrderListForm : Form { private readonly IPageAdapter _adapter; private const int DefaultPageSize 20; public OrderListForm() { InitializeComponent(); // 连接字符串从配置文件读别硬编码 var connStr ConfigurationManager.ConnectionStrings[MainDb].ConnectionString; _adapter new SqlPageAdapter(connStr, SELECT * FROM Orders WHERE Status 1); pager.PageSize DefaultPageSize; pager.PageChanged Pager_PageChanged; } private void OrderListForm_Load(object sender, EventArgs e) { // 首次加载显式取第一页不依赖控件自动触发 LoadPage(1); } private void Pager_PageChanged(object sender, PageChangedEventArgs e) { LoadPage(e.PageIndex); } private void LoadPage(int pageIndex) { var dt _adapter.LoadPage(pageIndex, pager.PageSize); grid.DataSource dt; pager.TotalCount _adapter.LastTotalCount; pager.CurrentPage pageIndex; } }这段代码是整个接入的核心骨架。LoadPage方法把取数、绑表、回写总数三件事收在一处翻页和首次加载都走同一条路径不会出现两套逻辑不一致的问题。pager.PageSize在构造函数里设一次用户在下拉框改每页条数时控件内部会重置到第一页并触发事件窗体不用额外处理。注意grid.DataSource dt每次都是新 DataTable不要试图复用旧表做增量更新分页场景下全量替换更简单也更不容易出错。3.3 每页条数切换与页码跳转的边界处理每页条数切换时当前页码要重置为 1否则用户在第 5 页把每页条数从 20 改成 50总页数可能只剩 2 页第 5 页就是非法页码。页码跳转输入框要做范围校验输入 0、负数、超过总页数的值都要拦下来回退到合法范围。这些边界处理建议放在分页条控件内部不要散落在窗体代码里否则每个用到分页的窗体都得重复写一遍。// 分页条内部的页码校验输入非法时回退到最近合法值 private void txtJump_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode ! Keys.Enter) return; if (!int.TryParse(txtJump.Text, out int target)) { txtJump.Text _state.CurrentPage.ToString(); return; } // 夹在 1 到总页数之间总页数为 0 时按 1 处理 target Math.Max(1, Math.Min(target, Math.Max(1, _state.TotalPages))); _state.CurrentPage target; OnPageChanged(target, _state.PageSize); txtJump.Text target.ToString(); }Math.Max(1, _state.TotalPages)这个写法是为了处理总记录数为 0 的情况此时TotalPages算出来是 0直接Math.Min(target, 0)会把页码压成 0再触发查询就是非法页码。先保证下限是 1再夹上限顺序不能反。回车触发而不是失焦触发是因为失焦触发在用户点其他按钮时容易误触发查询回车更符合桌面端的操作习惯。4. 避坑与排查分页控件最容易翻车的五个地方4.1 翻到最后一页显示空白页码却正常现象是点末页后表格空白但页码显示正确。原因通常是总记录数在两次查询之间变了比如别的用户删了数据TotalPages还是旧值实际查询返回空。解决办法是在LoadPage里判断返回空且当前页大于 1 时自动回退一页重新查同时用返回的最新总数刷新页码。这个保护逻辑要放在适配器调用之后、绑表之前别等到界面已经刷成空白再补救。4.2 排序字段写死导致按时间排序失效现象是用户点了“按创建时间排序”表格顺序没变。原因是分页 SQL 里ROW_NUMBER() OVER (ORDER BY Id)把排序写死了外层的排序被窗口函数覆盖。解决是把排序字段做成适配器参数ORDER BY {sortColumn} {direction}并且对字段名做白名单校验防止 SQL 注入。白名单里只放允许排序的列用户传别的值就回退到默认 Id 排序。4.3 每页条数改了但总数没刷新现象是切换每页条数后页码显示的总页数还是按旧条数算的。原因是切换条数时只重置了页码没有重新查总数。解决是在PageSizeChanged事件里强制走一次完整查询把LastTotalCount更新后再刷新页码显示。别图省事只改PageSize不查库总数和页数会对不上。4.4 大数据量下 COUNT 查询拖慢首屏现象是数据量到百万级后首次加载要等好几秒。原因是COUNT(*) OVER()在大表上全表扫描。解决办法是给筛选条件和排序字段建联合索引或者把总数查询改成近似值加“加载更多”模式。如果业务能接受总数显示成“1000”实际翻页用游标分页性能会好很多。这个取舍要看业务场景管理后台一般能接受精确总数面向用户的列表可以放宽。4.5 窗体关闭后事件还在触发导致异常现象是快速关闭窗体时偶尔报“对象已释放”。原因是异步查询还没返回窗体已经 Dispose回调里访问已释放的控件。解决办法是在窗体FormClosing里取消订阅PageChanged或者在回调里先判断IsDisposed再操作控件。桌面端同步查询一般不会遇到但一旦改成异步加载就会冒出来提前防住省得后面排查。5. 二次开发把分页控件改造成适合自己项目的形态拿到这类控件后最值得先改的是适配器层而不是 UI 层。UI 层各家项目风格差异大改起来费时且容易引入回归适配器层是纯逻辑改完用几个边界用例一测就知道对不对。我一般会先给适配器加一个CanLoad方法在真正查询前校验页码和条数是否合法非法直接返回空表并重置状态把防御逻辑收在一处。然后给IPageAdapter加一个异步版本LoadPageAsync返回TaskDataTable窗体里用await调用避免大数据量查询时界面假死。异步改造时注意SqlConnection要用using包住别在await前后跨线程复用同一个连接。验证改造是否成功我习惯用三个用例总记录数为 0、总记录数正好是每页条数的整数倍、总记录数比整数倍多一条。这三个用例能覆盖向上取整、空页保护、末页边界的大部分问题。再补一个“查询过程中删数据”的并发用例手动在查询前删掉几条看页码是否自动回退。这套用例跑通基本可以放心往生产环境放。还有一个具体技巧把分页条的总数显示做成可配置有的页面需要“共 1234 条”有的只需要“第 3/62 页”。用一个枚举控制显示模式比在每个窗体里手动隐藏标签省事。改的时候只动分页条的RefreshDisplay方法适配器和窗体都不用碰。血泪经验是早期我图省事把分页逻辑直接写在窗体里后来同一个项目有七个列表页每个都复制一遍改一个边界条件要改七处漏一处就出 bug。从那以后我每次接分页需求都强制先把适配器接口定下来窗体只做调度哪怕前期多写一个类后面改起来也是改一处生效一处。希望帮到你。本文还有配套的精品资源点击获取