☰
WinForm分页控件源码与SQL分页查询实战
2026/10/28 3:43:17 网站建设 项目流程

简介:面向Windows Forms开发者的分页控件实例包,围绕大量数据浏览场景,演示如何把Winform分页控件与SQL数据库结合,通过数据绑定、页码切换和分页查询减轻内存压力、提升界面响应,适合课程设计、管理系统列表模块及控件选型参考。包内共65个文件,以20个C#源码和8个resx资源文件为主体,辅以可执行程序、动态库、解决方案、项目配置及说明文档等,压缩包约218KB,体积轻便,既可直接运行观察交互,也便于拆解分页控件设计与数据库访问逻辑。已有837人学习下载。读者可重点参考ADO.NET连接、SQL查询范围限制、上一页/下一页导航、每页条数调整、当前页与总页数显示和基础异常处理等实现思路,理解DataGridView或自定义分页控件的接入方式,并据此扩展搜索、排序与性能优化。

1. 一个翻页要等三秒的列表页,问题往往不在控件

某内部管理系统的订单列表页,两千多条数据,点一下“下一页”界面要僵三秒,任务管理器里进程内存还一直往上涨。团队的直觉是换个更“高级”的 winform 分页控件,前后试了四个,卡顿原样复现。问题从来不在控件长相,而在“分页”这件事被拆错了位置:控件只管页码,数据库只管取数,中间那一层没人负责。这份 winform 分页控件源码的价值就在这里——它把页码状态机和数据访问边界划清楚了,附带一份 SQL 数据库分页查询的对接示例,前端点第几页、后端 OFFSET 跳几行,能一一对上。适合正在做数据列表页、被 DataGridView 整页重绘和全量加载拖住的 .NET 桌面开发者。

2. 拆开分页控件:PageSize、CurrentPage 与 TotalCount 的状态机

拿到一份控件源码,我第一个动作不是拖到窗体上看效果,而是翻它的公开成员。公开成员的数量基本决定了这个控件能用多久:暴露得越少,耦合越浅,换表、换库、换项目时改动越小。这份控件在这点上做得比较克制,对外只有四个属性、一个事件,剩下的全在内部消化。

2.1 四个核心属性与一个事件契约

分页控件本质上是一个状态机,状态就四个值。理解它们的归属关系,比记住控件名字重要得多。

属性类型谁负责维护常见误用
PageSizeint页面初始化时设定,用户可通过下拉框改改完不把 CurrentPage 重置为 1
CurrentPageint控件内部维护,外部只读和数据层 0 基索引混用,翻页错位一条
TotalCountint数据层算完回填给控件每次翻页都重算一次,首屏白白多一次 COUNT
PageCountint控件由 TotalCount 与 PageSize 推导外部手工赋值,和 TotalCount 对不上号

事件只有一个,翻页意图统一从这里出去:

// 分页控件对外暴露的唯一事件参数,字段刻意做得很少 public class PageChangedEventArgs : EventArgs { public int PageIndex { get; } // 1 基页码,控件保证 >= 1 public int PageSize { get; } // 当前每页条数 public PageChangedEventArgs(int pageIndex, int pageSize) { PageIndex = pageIndex; PageSize = pageSize; } }

这里有一处设计取舍值得说明:控件只保证 PageIndex 不小于 1,不保证它一定小于等于 PageCount。原因是数据可能在你翻页的间隙被别的事务删掉,控件算出来的 PageCount 是上一轮的快照,硬钳制反而会吃掉“当前页已空”这个信号。正确的处理是让数据层返回空集,页面收到空集之后回退一页再请求一次。这条规则后面在排查章节还会再提一次,它是分页控件最高频的翻车点。

2.2 为什么页码要由控件管、数据由页面管

很多项目在早期图省事,把 SqlDataAdapter、DataTable 甚至连接字符串直接塞进控件里,做成“拖上去填个表名就能用”的形态。短期确实快,代价是三个:换一张表要改控件源码重新编译;换数据库方言(比如从 SQL Server 换到 MySQL)整个控件报废;单元测试无从下手,因为控件里带着真实连接。

这份源码走的是另一条路,职责切得很干净:

  • 控件负责页码合法性、首页/上一页/下一页/末页/跳转框的按钮可用状态、页码文本渲染;
  • 页面负责拿到(pageIndex, pageSize)之后去数据库取数,并把 TotalCount 回填给控件。

这个切法还有一个隐性收益:状态机逻辑可以在 WPF 或 ASP.NET 里照搬。WinForm 控件本体当然搬不过去,但“当前页 + 每页条数 + 总数 → 按钮状态”这套推导是纯逻辑,抽出来就是一个几十行的小类。我见过有团队把这段逻辑复制到三个前端上,改一次 bug 得改三处,就是因为当初没做这层抽离。

2.3 把控件编译进项目:目录结构与引用方式

这类资源通常是一份 C# 源码包,落地方式有两种,选哪种取决于你有几个项目要用。

单个项目用,直接拖源码进项目最省事,改起来也方便,不用管 DLL 版本同步。多个项目共用,就单独建一个类库工程编译成 DLL,主工程引用 DLL,避免同一份控件在多个工程里各自演化出不同版本。

推荐的目录结构如下,控件本体、事件参数、样式资源分开放,后面想换皮肤或换事件模型都只动一个文件:

<!-- PagerControls.csproj 的骨架,TargetFramework 按你的主工程来 --> <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net48</TargetFramework> <UseWindowsForms>true</UseWindowsForms> <RootNamespace>Controls.Pager</RootNamespace> </PropertyGroup> <ItemGroup> <Compile Include="PagerBar.cs" /> <!-- 控件本体 --> <Compile Include="PageChangedEventArgs.cs" /> <Compile Include="PagerStyle.cs" /> <!-- 颜色、字体集中在这里 --> </ItemGroup> </Project>

首次放进窗体时,我会做三件事:把控件的Dock设为Bottom,让它永远贴在列表下方不参与布局计算;把PageSize的可选值限定为 20/50/100 三档,而不是让用户随便填;把PageChanged事件在Form.Load之后订阅,而不是在构造函数里订阅。最后这条不是洁癖,构造函数里订阅会在设计器打开窗体时也触发一次回调,设计期去连数据库,Visual Studio 直接卡在那里让你怀疑人生。

3. 接 SQL 数据库:三种分页查询写法的取舍

控件的页码从 1 开始,SQL 的 OFFSET 从 0 开始,两者之间隔着一个减法,这是分页对接里最基础也最容易出错的一步。更麻烦的是,不同数据库版本对分页语法的支持差异很大,选错写法要么报语法错,要么在大数据量下慢到没法用。

3.1 OFFSET FETCH 的写法与它失效的场景

SQL Server 2012 之后支持OFFSET ... FETCH NEXT,写法最接近分页的语义,也是我现在的默认选择:

-- ① 总数与当前页分两条语句,总数只在搜索条件变化时执行 SELECT COUNT(1) FROM dbo.OrderHeader WHERE (@Keyword = N'' OR OrderNo LIKE @Keyword + '%'); -- ② 当前页。排序键必须包含唯一列,否则翻页会出现重复行 SELECT OrderId, OrderNo, CustomerName, Amount, CreatedAt FROM dbo.OrderHeader WHERE (@Keyword = N'' OR OrderNo LIKE @Keyword + '%') ORDER BY CreatedAt DESC, OrderId DESC OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;

@Offset由(pageIndex - 1) * pageSize算出来,@PageSize直接用控件的 PageSize。第二条语句里的ORDER BY CreatedAt DESC, OrderId DESC是刻意加了二级排序键的:CreatedAt 是秒级精度,同一秒内下的单排序不唯一,数据库对“不唯一排序”的返回顺序没有任何承诺,翻到第 2 页时第 1 页出现过的行可能再出现一次。这个现象在测试环境数据量小的时候几乎看不出来,上了生产就是用户投诉“列表里有重复单”。

失效场景有两个要提前知道。一是OFFSET值很大时(比如几十万行之后),数据库仍然要把前面的行扫一遍再丢弃,页数越深越慢,这是所有基于偏移量的分页的通病,跟控件无关。二是 SQL Server 2008 及更早版本不认这个语法,只能换 ROW_NUMBER 方案。

3.2 ROW_NUMBER() 兜底与 COUNT 查询的分离

老版本数据库或某些国产库上,用ROW_NUMBER()包一层是通用解法:

-- 用 ROW_NUMBER 造一个连续行号,再按行号区间切片 SELECT * FROM ( SELECT OrderId, OrderNo, CustomerName, Amount, CreatedAt, ROW_NUMBER() OVER (ORDER BY CreatedAt DESC, OrderId DESC) AS Rn FROM dbo.OrderHeader WHERE (@Keyword = N'' OR OrderNo LIKE @Keyword + '%') ) t WHERE t.Rn > @Offset AND t.Rn <= @Offset + @PageSize;

这个写法比 OFFSET FETCH 多一层子查询,执行计划也更难优化,唯一优势是兼容性。我一般只在必须兼容旧版本时才用,新项目不选它。

真正需要重点设计的是 COUNT 查询的时机。翻页时数据本身变了,但总数绝大多数情况下不变。如果你的搜索框内容没动、筛选条件没动,TotalCount 完全没必要每翻一页重算一次。常见的做法是在页面里缓存一个_lastConditionKey,把关键字、时间区间、状态下拉框的值拼成一个字符串做比对,只有它变了才重新执行 COUNT。这一条在百万级表上能直接把翻页响应从 800ms 压到 60ms 以内。

3.3 参数化、超时与连接释放

把控件和数据库接起来的那段代码,我见过太多人写成字符串拼接,"WHERE OrderNo LIKE '%" + txtKeyword.Text + "%'",测试阶段风平浪静,上线之后一个带单引号的搜索词就能让整个列表页报错。参数化不是可选项:

// 数据访问层:一条连接、两条命令、明确指定参数类型 public PagedTable QueryPage(string keyword, int pageIndex, int pageSize) { // 页码是 1 基,转成 OFFSET 用的 0 基偏移 int offset = (pageIndex - 1) * pageSize; using (var conn = new SqlConnection(_connString)) using (var cmd = new SqlCommand(SqlText, conn)) { // 明确 Size,避免参数被推断成 nvarchar(4000) 影响执行计划复用 cmd.Parameters.Add("@Keyword", SqlDbType.NVarChar, 100).Value = keyword ?? string.Empty; cmd.Parameters.Add("@Offset", SqlDbType.Int).Value = offset; cmd.Parameters.Add("@PageSize", SqlDbType.Int).Value = pageSize; cmd.CommandTimeout = 15; // 默认 30 秒,列表页等不了这么久 conn.Open(); using (var reader = cmd.ExecuteReader()) { var table = new DataTable(); table.Load(reader); // 结果集是两条 SELECT,Load 会自动取第一个结果集 return new PagedTable { Rows = table, TotalCount = ReadTotal(reader) }; } } }

using两层嵌套不是装饰,SqlConnection不释放会迅速耗尽连接池,表现是程序跑十几分钟后开始抛“超时时间已到,但是尚未从池中获取连接”。CommandTimeout调小则是为了兜住慢查询:列表页用户最多愿意等三秒,与其让他干等三十秒,不如早点报错让他收窄搜索条件。

参数类型明确写NVarChar, 100这件事,平时看不出差别,一旦表的 OrderNo 列建有索引,参数被推断成NVarChar(4000)就会触发隐式转换,索引直接失效,全表扫描。这类问题用 SQL Server Profiler 抓一次执行计划就能看见,属于典型的“不报错但慢十倍”。

4. 绑到 DataGridView:把翻页做成一次数据搬运

控件把页码吐出来之后,剩下的活全在窗体这一侧。这里的目标只有一个:翻页时只搬运当前页的数据,不要让 DataGridView 持有全量数据再自己去“显示前 20 行”。后者是新手最容易掉进去的坑,因为表面上也能翻页,实际上每次翻页都在内存里查一遍全表,数据量一大就原形毕露。

4.1 BindingSource 与 DataTable 的配合

DataGridView 直接绑 DataTable 也能用,但一旦涉及翻页刷新、行选中状态保持、单元格格式化,中间加一层 BindingSource 会省掉大量麻烦:

// 窗体字段:BindingSource 只创建一次,翻页时替换它内部的数据源 private readonly BindingSource _bs = new BindingSource(); private string _keyword = string.Empty; private int _totalCount = 0; private void InitGrid() { // 列在 Design 阶段配好,这里只负责绑定,避免每次翻页重建列 grid.AutoGenerateColumns = false; grid.DataSource = _bs; } private void RenderPage(PagedTable result) { _bs.DataSource = result.Rows; // 只有当前页的行 _bs.ResetBindings(false); // 通知网格整体换源,false 表示不重读属性 grid.ClearSelection(); }

ResetBindings(false)这个参数很关键。传true会让 BindingSource 重新枚举所有数据项的属性描述符,数据量大时明显卡顿;传false只通知“数据源换了”,网格整体重绘一次。翻页场景传false就够了。

另外一个必须守住的原则:AutoGenerateColumns设成false,列在 Design 阶段配好。如果保持true,每次换 DataSource 都会按 DataTable 的列结构重建列,用户之前拖过的列宽、设过的排序全部丢失,翻十页列宽就重置十次。

4.2 异步加载不让界面假死

同步查询配上几百毫秒的响应,界面就会白屏卡死,用户以为程序崩了,连点几下下一页,队列里堆了一串请求,最后回来的顺序还可能是乱的。异步化是必做项,但异步化本身也有讲究:

private CancellationTokenSource _cts; private async void pager_PageChanged(object sender, PageChangedEventArgs e) { // 上一次请求还没回来就再点,先取消,避免结果乱序覆盖 _cts?.Cancel(); _cts = new CancellationTokenSource(); var token = _cts.Token; pager.Enabled = false; // 请求期间禁点,给用户明确反馈 try { // Task.Run 把同步的 ADO.NET 调用挪到线程池,UI 线程不阻塞 var result = await Task.Run( () => _repo.QueryPage(_keyword, e.PageIndex, e.PageSize), token); if (token.IsCancellationRequested) return; // 已被更新的请求取代,丢弃 _totalCount = result.TotalCount; pager.TotalCount = _totalCount; // 回填总数,控件自行算页数 RenderPage(result); } catch (OperationCanceledException) { // 主动取消,不是错误,什么都不做 } catch (Exception ex) { MessageBox.Show("加载失败:" + ex.Message); } finally { pager.Enabled = true; } }

这段代码里有三处值得展开。Task.Run包住同步的 ADO.NET 调用,是因为SqlCommand的同步方法没有真正的异步版本可用(ExecuteReaderAsync底层仍然是阻塞的),包一层线程池至少能保证 UI 线程空闲。取消令牌配合_cts?.Cancel()解决的是连点问题:用户连点五次下一页,只有最后一次的结果会被渲染,前四次的结果即使回来了也会被IsCancellationRequested拦掉。pager.Enabled = false则是廉价但有效的防抖,比什么节流算法都直接。

4.3 搜索条件变化时如何重置页码

搜索框和分页控件联动,是最容易出现“搜完之后列表空白”的地方。用户在第三页输入关键字点搜索,控件还停留在第 3 页,而新条件下的结果可能只有一页,数据层老老实实返回空集,页面就白了。

正确的顺序是把状态重置和数据请求绑在一起:

private void btnSearch_Click(object sender, EventArgs e) { _keyword = txtKeyword.Text.Trim(); // 关键一步:条件变了,页码必须先回到第 1 页,再触发请求 // ResetPage(1) 内部会更新 CurrentPage 并触发一次 PageChanged pager.ResetPage(1); }

如果控件没有提供ResetPage这样的方法,那就手工赋值pager.CurrentPage = 1之后再显式调用一次加载方法。要避免的写法是先触发加载、再改页码,那样会连发两次请求,第一次的结果还可能后到并覆盖掉正确的数据。这类时序问题在本地测不出来,因为本地数据库响应快,两个请求的到达顺序恰好是对的;上了生产网络一抖动,用户就会看到“搜出来的结果和关键字对不上”这种灵异现象。

5. 排查:分页控件最常翻车的四处地方

下面这四条,是我在几个列表页项目里真实踩过的,按出现频率排。每条都按“看到什么现象、根因在哪、怎么改”的顺序写,照着比对基本能定位。

5.1 删完最后一页的数据,列表变成空白

现象:第 5 页只有 1 条数据,删掉之后页面空白,页码显示还停在第 5 页,点上一页能恢复,但用户不知道要点。

原因:删除成功后只刷新了当前页数据,没有回头检查 TotalCount 变化后的 PageCount。新的 PageCount 变成 4,而 CurrentPage 还是 5,数据层按偏移量查出来是空集。控件不钳制页码(前面说过这是刻意设计),空集就原样渲染了。

解决:删除成功后重新取一次总数并比较页码,越界就回退一页:

// 删除成功后调用:先算新的页数,越界则回退 var total = _repo.Count(_keyword); pager.TotalCount = total; if (pager.CurrentPage > pager.PageCount && pager.PageCount > 0) { pager.CurrentPage = pager.PageCount; // 回退到最后一页 } LoadCurrentPage();

5.2 总记录数和列表条数对不上

现象:控件显示“共 1,204 条”,实际把所有页翻完只有 1,199 条,差几条。

原因:COUNT 查询和分页查询的 WHERE 条件写得不一样。典型的漏写是 COUNT 里少了某个筛选条件,或者分页查询里多加了AND IsDeleted = 0而 COUNT 里没加。两个语句分开写、分开放,时间一长必然走岔。

解决:把 WHERE 子句抽成一个字符串常量,两条 SQL 都用它拼接,从结构上杜绝不一致。改完之后我一般会跑一次对账:把所有页拉一遍累加行数,和 COUNT 结果比对,差值为 0 才算过。这个动作在数据量不大时手工点几页就能验证,值得每次都做。

5.3 排序字段不唯一,翻页出现重复行

现象:第 1 页和第 2 页出现同一个订单号,刷新一次可能又没有了。

原因:ORDER BY 只写了 CreatedAt 这种非唯一列。数据库对并列行的返回顺序不做保证,两次查询的执行计划只要有一点差异,并列行的先后顺序就会变,偏移量切片自然就切重了。

解决:所有分页查询的 ORDER BY 必须追加一个唯一列收尾,通常是主键。ORDER BY CreatedAt DESC, OrderId DESC这样写,排序结果就是确定的,翻多少页都不会重。顺带一提,加了唯一列之后,这条复合排序最好有对应索引,否则每翻一页都要额外排一次序。

5.4 窗体关闭后异步回调还在刷 UI

现象:列表页还没加载完就关掉窗体,程序抛ObjectDisposedException,提示“无法访问已释放的对象”。

原因:await之后回来更新控件,但窗体已经 Dispose 了。异步方法里没有对窗体生命周期做任何判断。

解决:窗体关闭时取消挂起的请求,回调里先判IsDisposed:

protected override void OnFormClosing(FormClosingEventArgs e) { _cts?.Cancel(); // 取消所有挂起请求 _cts?.Dispose(); base.OnFormClosing(e); } // 回调里加一道判断,双保险 if (IsDisposed || Disposing) return;

这件事的教训是:所有跨await的 UI 更新,都要假设窗体随时可能被关掉。不加判断不是“偶发异常”,是“早晚异常”。

6. 深页性能与验证:把 OFFSET 换成键集分页的时机

分页控件的页码是无上限的,但数据库的 OFFSET 有实际成本上限。翻到第 5000 页、每页 20 条时,OFFSET 99980意味着数据库要扫过前面近十万行再丢弃,一条本来 20ms 的查询能跑成 2 秒。这个拐点在哪,取决于你的表和索引,没有普适数字,但可以用一个固定动作测出来。

我的验证方法是打开统计信息,把同一个查询在第 1 页和第 N 页各跑一次,比对逻辑读次数:

SET STATISTICS IO ON; SET STATISTICS TIME ON; -- 分别执行第 1 页和第 5000 页的查询,看 logical reads 的差距 SELECT OrderId, OrderNo, CreatedAt FROM dbo.OrderHeader ORDER BY CreatedAt DESC, OrderId DESC OFFSET 99980 ROWS FETCH NEXT 20 ROWS ONLY;

如果第 5000 页的逻辑读是第 1 页的几十倍,说明偏移量分页已经成为瓶颈,该考虑键集分页了。键集分页不传页码,改传“上一页最后一行的排序键”,SQL 变成这样:

-- @LastCreatedAt / @LastOrderId 是上一页最后一行的值 SELECT TOP (@PageSize) OrderId, OrderNo, CreatedAt FROM dbo.OrderHeader WHERE (CreatedAt < @LastCreatedAt) OR (CreatedAt = @LastCreatedAt AND OrderId < @LastOrderId) ORDER BY CreatedAt DESC, OrderId DESC;

它每次只扫 20 行,页数再深耗时也不变,代价是不能跳页、只能上一页下一页。落到产品上,通常的折中是前 50 页用偏移量、允许跳页,超过之后提示“请使用搜索缩小范围”,而不是硬撑着一个能跳一万页的控件。

还有一个容易被忽略的验证动作:把 PageSize 改成 1 和 1000 各跑一遍。PageSize 为 1 时能暴露排序键不唯一导致的重行,PageSize 为 1000 时能暴露 COUNT 与分页条件不一致导致的总数偏差。这两个极端值跑通了,中间值基本不会出问题。

从那以后我每次接手一个带分页的列表页,都会先跑一遍“第 1 页、最后一页、删光最后一页、连点五次下一页”这四个动作,然后再看代码。这四步花不了五分钟,却能提前拦住上面几乎所有的坑。希望帮到你。

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

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

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

立即咨询