☰
WinForm分页控件实战:SQL分页查询与存储过程性能优化指南
2026/10/16 10:03:58 网站建设 项目流程

简介:这是一份面向 Winform 开发者的分页控件源码包,针对数据量大时一次性加载导致的界面卡顿、内存占用过高问题,提供可直接使用或二次开发的解决方案。资源以自定义 PagerControl 为核心,配套 Form1、Form2、StartForm 等示例窗体,演示了 ADO.NET 连接 SQL Server、按页查询、总页数计算以及“上一页/下一页”导航的完整实现,适合需要快速集成数据分页功能的 .NET 桌面应用开发人员。

压缩包仅 218KB,共 65 个文件,包含 20 个 C# 源码文件(cs)、8 个窗体资源文件(resx)、项目工程文件(sln/csproj),以及可执行程序(exe)、GIF 效果预览图和说明文本,打开工程即可查看运行效果或提取控件复用。从内容预览可看到 PagerControl.designer.cs、UpgradeReport 升级报告等文件,结构清晰。资源已有 837 人学习下载,对理解 Winform 分页控件的设计实现和数据访问分层有直接参考价值。

1. 这个 winform 分页控件让我少写了 500 行翻页逻辑

之前帮某公司做订单管理桌面端,客户 Excel 里有六万多条销售记录,要求导进程序里做筛选和翻页。一开始图省事,整张 DataTable 丢给 DataGridView,绑定完界面直接卡死,点一下滚动条三秒才有反应,那种情况下我完全反驳不了“桌面端落后”这种话。后来换用这套带 SQL 数据库的 winform 分页控件,半小时配完:数据库只取当前页 20 条,翻页顺滑,内存占用稳定,从卡到能用的距离确实只差一个分页控件。

这话听着像广告,但真正跑过一遍你会认同:控件本身不复杂,真正决定快不快的是你给它配的 SQL 查询和分页参数。下面就从控件怎么配开始讲,把分页 SQL、存储过程写法、以及五个最容易翻车的细节一次讲清楚,给正在做进销存、订单管理、后台管理类桌面项目、数据源是 SQL Server 的人做个参考。

2. 分页方案选型:原生 DataGridView 为什么搞不定,SQL 分页凭什么快

2.1 原生 DataGridView 的三大痛点

先说原生控件。DataGridView 本身没有“分页”概念,你给它什么 DataTable 它就显示什么,上万行数据进来后,第一波压力在内存:每行都会生成 DataRow、Grid 行对象、单元格集合,实际内存占用往往能到数据本身的十几倍,界面线程还要忙着做布局计算,Measure 和 Paint 满天飞,UI 直接假死。这个不是 WinForm 不行,是它把所有行都当成可见行处理了。

第二个痛点很多人不留意:DataGridView 的排序、筛选默认只对当前绑定的 DataTable 生效。你要是把全表数据一次性拽出来,内存翻车;你要是每次只取一页,点列头排序只会把当前页这 20 条排一遍,用户以为整表有序,其实是幻觉。第三个痛点是行高计算和滚动条定位在几万行数据下会频繁重算,用户拖着滑块都拖不稳。这三个问题叠加起来,结论很明确:列表界面必须分层,界面层只负责显示当前页,排序和筛选交给 SQL 层。

2.2 控件与数据库的协作模型

这套 winform 分页控件的设计思路很清晰:控件只维护界面状态,即当前页索引、每页条数、总记录数、总页数,再把“当前页变化”这个事件抛出来,真正的数据加载由你在外部完成。控件定义好契约,你按契约取数,常见做法是暴露 PageIndex、PageSize、TotalCount 这些属性,再给一个 SetTotalCount 方法和 PageIndexChanged 事件,你在事件里重新执行一条分页查询,回填到 DataGridView 即可。

示意代码如下,先把契约看明白:

// 控件状态契约,不同版本命名有差异,思路一致 pager.PageIndex = 1; // 当前页码 pager.PageSize = 20; // 每页行数 pager.SetTotalCount(totalCount); // 传入数据库统计的总记录数 pager.PageIndexChanged += Pager_PageIndexChanged; // 翻页时触发 private void Pager_PageIndexChanged(object sender, EventArgs e) { LoadCurrentPage(); // 外部实现真正的取数逻辑 }

这段代码的逻辑说明:SetTotalCount 内部会用总记录数和页大小算出总页数,同时把页码钳制在合法范围内;PageIndexChanged 是驱动核心,用户按下翻页按钮时,控件只改页码再抛事件,不替你做查询。参数说明:PageIndex 从 1 开始,PageSize 根据业务设 20、50、100 都行,两个属性在 LoadCurrentPage 里会被重新读取,所以顺序是先设状态再触发加载。

2.3 为什么我直接选 SQL 分页而不是内存分页

内存分页也叫假分页,程序启动时一次性把全部数据拉进内存,后面翻页只是切换 DataTable 里的行,响应确实快,但 SQL Server 白加载了整表数据,内存也一直白占着,数据量到几十万行时连启动都费劲。真分页则是每次翻页都向数据库要“第 N 页那 20 行”,网络和数据库有开销,但换来的是稳定的内存水位和持续的响应速度,配合索引后,20 行数据就是 20 行的 IO 开销。

当数据源是 SQL Server 时,我基本不纠结直接走 SQL 分页,尤其是订单、报表这类有明确排序规则的场景。这里心里要有预期:查总记录数和查当前页数据两次查询必然存在,控件帮不了你,后面调优时盯着这两个查询做文章就够了。

3. WinForm 接入分页控件:绑定流程与三个 SQL 分页参数

3.1 拖控件与布局

把分页控件从工具箱拖到窗体底部,设置 Dock=Bottom,DataGridView 设置 Fill 占满剩余区域。我习惯再用一个 FlowLayoutPanel 把“首页、上一页、下一页、末页、页码数字、每页条数下拉框”排成一行,方便统一管理间距。顺序有个讲究:先设 DataGridView 的 Dock=Fill,再设分页控件 Dock=Bottom,否则后设的分页控件会把表格底部遮住,这个坑我第一次接控件时就踩过。

如果你从工具箱拖不出来,先检查项目目标框架和控件安装路径,常见做法是把控件 DLL 手动加入工具箱,右键选择项后浏览到对应文件。加完别急着写代码,先在设计器里把属性名和事件名记下来,不同版本的控件命名差异不小,包内 Demo 会写得非常清楚,直接对照。

3.2 核心绑定代码:总记录数 + 当前页数据

核心绑定逻辑固定两块:先查总记录数并回填给控件,再查当前页数据并绑定。下面是一份可直接改成自己表名的代码:

private void LoadCurrentPage() { int pageIndex = pager.PageIndex; int pageSize = pager.PageSize; // 第一步:统计总记录数 string countSql = "SELECT COUNT(*) FROM SalesOrder WHERE Status = @Status"; int totalCount = Convert.ToInt32(DbHelper.ExecuteScalar( countSql, new SqlParameter("@Status", status))); pager.SetTotalCount(totalCount); // 第二步:取当前页数据 string dataSql = @" SELECT t.* FROM ( SELECT ROW_NUMBER() OVER(ORDER BY OrderDate DESC) AS RowId, * FROM SalesOrder WHERE Status = @Status ) t WHERE t.RowId BETWEEN @StartRow AND @EndRow"; SqlParameter[] dataParams = { new SqlParameter("@Status", status), new SqlParameter("@StartRow", (pageIndex - 1) * pageSize + 1), new SqlParameter("@EndRow", pageIndex * pageSize) }; DataTable dt = DbHelper.ExecuteDataTable(dataSql, dataParams); dgvOrder.DataSource = dt; }

逻辑说明:先查 COUNT,把总数回填给分页控件,让控件算出总页数、刷新页码按钮状态;再执行带 ROW_NUMBER 的分页查询,子查询里先按 OrderDate 降序排好编上行号,外层用 BETWEEN 截取当前页范围。参数说明:@StartRow 的计算方式是 (pageIndex - 1) * pageSize + 1,20 条一页时第 3 页的起点是 41;@EndRow 是 pageIndex * pageSize,终点是 60。如果控件属性异常被改成 0 或负数,要先钳制再算,否则会出现起始行小于 1 的脏参数。

提示:有些控件在做数据绑定时会自动改 PageIndex,事件里别死循环地重入 LoadCurrentPage,建议加一个 isBinding 标志位防抖。

3.3 三种 SQL 分页方案对比及选择

ROW_NUMBER 方式是目前的主流,但不少老项目还在用 TOP + NOT IN,我整理了一个对比表,方便你按自己的数据库版本选:

分页方案核心写法优点典型坑
TOP + NOT IN先取前 N 条主键,再用 NOT IN 排除老版本兼容性好数据量大时 NOT IN 子查询代价高
ROW_NUMBER排序后编行号,BETWEEN 取范围主流稳定,索引友好必须包一层子查询,排序字段要建索引
OFFSET-FETCHORDER BY 后直接 OFFSET 跳行语义最清晰,SQL Server 2012+大页码深翻页时偏移量大

我一般这样选:SQL Server 2012 以上且页码不会翻到几百页的,用 OFFSET-FETCH 省一层子查询;老库或者不确定版本,用 ROW_NUMBER 最稳。两者对排序字段的要求一致,ORDER BY 后面必须跟唯一或近似唯一的字段,否则翻页时可能出现同一行在相邻页重复出现的情况,我在项目里通常把 OrderDate 和主键拼在一起排序。

4. 带 SQL 数据库的分页存储过程:完整骨架与调用参数

4.1 为什么把分页查询封装成存储过程

频繁翻页下,每次点击都发一条动态拼接的 SQL,SQL Server 无法稳定复用执行计划,统计信息也用不充分。封装成存储过程后,参数化、执行计划复用、权限控制一次到位,C# 侧的代码也会干净很多。另外一个实际好处是,总数统计和分页查询能放进同一个批处理里,用一条命令两次结果集拿回来,省一次数据库往返。

不是强制要求,但如果这个列表页会被多个窗体复用,我更建议直接建存储过程,后面加字段、调索引都只改库不改程序。下面给一个带筛选条件的分页存储过程骨架,过滤参数以订单状态为例,你把表名和字段换成自己的即可。

4.2 通用分页存储过程骨架

CREATE PROCEDURE [dbo].[usp_GetSalesOrderPage] @PageIndex INT, @PageSize INT, @Status INT = NULL AS BEGIN SET NOCOUNT ON; DECLARE @StartRow INT = (@PageIndex - 1) * @PageSize + 1; DECLARE @EndRow INT = @PageIndex * @PageSize; -- 第一个结果集:总记录数 SELECT COUNT(*) AS TotalCount FROM dbo.SalesOrder WHERE (@Status IS NULL OR Status = @Status); -- 第二个结果集:当前页数据 SELECT * FROM ( SELECT ROW_NUMBER() OVER(ORDER BY OrderDate DESC, OrderId DESC) AS RowId, * FROM dbo.SalesOrder WHERE (@Status IS NULL OR Status = @Status) ) AS t WHERE t.RowId BETWEEN @StartRow AND @EndRow ORDER BY t.RowId; END

逻辑说明:过程分三段,先算当前页的起始行和结束行,再返回总数,最后返回当前页数据。WHERE 条件里用了 @Status IS NULL OR Status = @Status,这样前台不选状态时传 NULL 就能跳过筛选,避免为每一种条件单独写一个存储过程。参数说明:@PageIndex 从 1 开始,@PageSize 决定每页数据量,@Status 可空;排序放在 OrderDate 和 OrderId 上,OrderId 那个主键字段用来保证排序稳定性。

注意:ORDER BY 和 WHERE 条件变了,执行计划就要跟着变,给筛选字段建合适的索引比优化 SQL 写法更有效。

4.3 C# 侧调用存储过程的写法

C# 侧调用时,要把两个结果集分开读取,顺序不能乱。常见做法是读到第一个结果集的 TotalCount,再切到第二个结果集加载 DataTable:

using var conn = new SqlConnection(connStr); using var cmd = new SqlCommand("usp_GetSalesOrderPage", conn); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@PageIndex", pager.PageIndex); cmd.Parameters.AddWithValue("@PageSize", pager.PageSize); // 状态为-1时传NULL,走全量查询 cmd.Parameters.AddWithValue("@Status", status == -1 ? DBNull.Value : (object)status); conn.Open(); using var reader = cmd.ExecuteReader(); // 第一个结果集是总记录数 int totalCount = 0; if (reader.Read()) { totalCount = Convert.ToInt32(reader["TotalCount"]); } pager.SetTotalCount(totalCount); // 切到第二个结果集,加载当前页数据 DataTable dt = new DataTable(); if (reader.NextResult()) { dt.Load(reader); } if (totalCount == 0 || dt.Rows.Count == 0) { dgvOrder.DataSource = null; return; } dgvOrder.DataSource = dt;

逻辑说明:ExecuteReader 返回的是包含两个结果集的 DataReader,先 Read 第一个结果集拿总数,再调用 NextResult 切到下一个,最后用 DataTable.Load 加载当前页数据。这里有个容易写错的地方:DataTable.Load 默认读取当前结果集,如果直接调用 Load,读到的是 COUNT 那一格值,所以必须先 NextResult。参数说明:DBNull.Value 传给存储过程后,对应 @Status 就是 NULL,落在前面写的可空筛选逻辑里。

5. 避坑清单:分页控件接入数据库时的 5 个真实翻车现场

5.1 最后一页删空之后页码没回退

现象:用户删除了当前页最后一条记录,再点下一页,页面空白,或者 SQL 报出起始行大于总行数的错误。

原因:数据删除后总记录数减少,但分页控件还停留在旧的 PageIndex 上。比如原来 5 页,删完只剩 4 页,控件还在第 5 页,查出来的结果自然为空。

解决:删除、导入这类会改变数据量的操作之后,重新走一遍绑定流程,并且补一段页码钳制代码:

pager.SetTotalCount(totalCount); if (pager.PageIndex > pager.PageCount) { pager.PageIndex = Math.Max(1, pager.PageCount); }

逻辑说明:先让控件算出新的总页数,再把页码压回合法范围。这里 Math.Max 是为了防止总记录数为 0 时 PageCount 变成 0,最终页码停在 1 而不是 0。

5.2 总记录数 COUNT 查询把翻页拖慢

现象:点下一页按钮之后要等两三秒才刷出来,SQL Server 的 CPU 在翻页瞬间飙高。

原因:COUNT 查询没有走索引,或者 WHERE 条件里的字段没有合适的索引,每次翻页都在做全表扫描。数据量到几十万行时,这个问题会被放大得很明显。

解决:给筛选和排序字段建覆盖索引,让 COUNT 和 ROW_NUMBER 都受益:

CREATE NONCLUSTERED INDEX IX_Order_Status_Date ON dbo.SalesOrder(Status, OrderDate DESC) INCLUDE (OrderId);

逻辑说明:Status 用于 WHERE 等值过滤,OrderDate 用于排序,OrderId 是排序稳定键。加了这组索引后,COUNT 可以走索引扫描而不是全表扫描,分页查询也不会反复回表取整行数据。数据量特别大时,还可以在序列层做总数缓存,5 到 10 秒内不重复查 COUNT,但那已经是另一层优化了。

5.3 翻页后 DataGridView 焦点和滚动条复位

现象:翻到下一页后,滚动条跳回顶部,用户正在编辑的单元格失焦,键盘操作也跟着失灵。

原因:重新给 DataGridView 设置 DataSource 后,控件内部的视图状态会重置,当前选中行、滚动位置全部回到初始值。

解决:绑定前记录当前行号,绑定完成后恢复选中;再用 SuspendLayout 和 ResumeLayout 包裹,减少重绘次数:

int rowIndex = dgvOrder.CurrentCell?.RowIndex ?? 0; dgvOrder.SuspendLayout(); dgvOrder.DataSource = dt; dgvOrder.ClearSelection(); if (rowIndex < dgvOrder.Rows.Count) { dgvOrder.Rows[rowIndex].Selected = true; } dgvOrder.ResumeLayout();

逻辑说明:记录旧行号是为了让用户在翻页后仍能看到相似的选中状态,ClearSelection 防止新旧数据叠加出多个选中行。这个处理不复杂,但能明显提升体验,我在项目里几乎每个列表页都会加。

5.4 AutoSizeColumnsMode 设置后翻页列宽乱跳

现象:每次翻页列宽都不一样,表格像在“呼吸”,横向滚动条也跟着变。

原因:把 AutoSizeColumnsMode 设成 AllCells 后,DataGridView 会根据当前页的单元格内容重新计算列宽,数据不同,宽就不同。

解决:把 AutoSizeColumnsMode 改成 Header 或者 Fill,或者干脆在 Designer 里固定每列 Width。如果一定要按内容自适应,只在首次绑定后调用一次 AutoResizeColumns,翻页时不再触发任何列宽计算。

5.5 每页条数下拉框触发两次数据加载

现象:把每页条数从 20 改成 50,页面先是跳到第 1 页,随后又跳到第 3 页,SQL 被执行了两遍。

原因:下拉框的 SelectedIndexChanged 事件里改了 PageSize,而 PageSize 改变又触发了 PageIndexChanged 翻页事件,两个事件叠在一起导致查询重复。

解决:引入一个页面状态标志位,或者把每页条数的更新放到下拉框收起之后再处理,常用做法是设个防抖开关:

private bool isUpdating; private void cboPageSize_SelectedIndexChanged(object sender, EventArgs e) { if (isUpdating) return; isUpdating = true; pager.PageSize = Convert.ToInt32(cboPageSize.SelectedItem); pager.PageIndex = 1; LoadCurrentPage(); isUpdating = false; }

逻辑说明:进入事件先置位 isUpdating,处理完再复位,这样 PageSize 变化触发的翻页事件在重入时会直接被挡掉。注意把 this.BeginInvoke 或组合事件处理也纳入考虑,有些第三方控件还会因为赋值顺序不同而出现角度不一致的问题,遇到先试着把 PageIndex 复位到 1,再触发加载。

6. 把分页查询收敛成 PagedQueryBuilder:顺带一次可靠验证

6.1 参数化的查询构建器

当项目里有多个列表页都要分页时,重复写 ROW_NUMBER 子查询是一件很低效的事情。我习惯把分页查询收敛成一个小的构建器,输入查询主体、筛选条件、排序字段,输出 COUNT 语句和分页数据语句。下面是一个能落地的骨架:

public class PagedQuery { public string CountSql { get; set; } public string DataSql { get; set; } public SqlParameter[] Parameters { get; set; } public static PagedQuery Build( string selectSql, string whereSql, string orderBy, int pageIndex, int pageSize) { int startRow = (pageIndex - 1) * pageSize + 1; int endRow = pageIndex * pageSize; return new PagedQuery { CountSql = $"SELECT COUNT(*) FROM ({selectSql}) t WHERE {whereSql}", DataSql = $@" SELECT * FROM ( SELECT ROW_NUMBER() OVER(ORDER BY {orderBy}) AS RowId, t.* FROM ({selectSql}) t WHERE {whereSql} ) x WHERE x.RowId BETWEEN {startRow} AND {endRow}" }; } }

逻辑说明:CountSql 把查询主体包一层做 COUNT,DataSql 把查询主体作为派生表再套 ROW_NUMBER,两边的 whereSql 完全一致,保证页码对应。用法上,selectSql 传入“SELECT * FROM SalesOrder”这类主体语句,whereSql 是已经参数化的条件,orderBy 必须是白名单内置字段。这里有个边界要守住:whereSql 和 orderBy 绝不能直接拼用户输入,建议做成枚举映射,服务端只接受预设好的字段名,否则拼出来的就是注入风险。

6.2 验证方法:如何判断分页真的吃透了

封装完之后,我会直接用 SQL Server Management Studio 跑一次真实分页,命令很简单:

SET STATISTICS TIME ON; SET STATISTICS IO ON; EXEC dbo.usp_GetSalesOrderPage @PageIndex = 10, @PageSize = 20, @Status = NULL;

观察输出里的逻辑读和 CPU 时间:健康的分页查询,逻辑读应该远小于全表数据量,如果逻辑读达到几万,说明索引没被用上。再把执行计划调出来,看到 Index Scan 就要回头查索引,看到 Key Lookup 说明覆盖索引没建完整。

从那以后,我每次接入任何 winform 分页控件,都会先在数据库里执行一遍 SET STATISTICS IO ON,再看一眼执行计划,确认 COUNT 和分页查询都走索引后才交付功能。这个习惯帮我挡掉了不少临上线前才冒出来的性能投诉,也希望帮到你。

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

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

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

立即咨询