简介面向Winform开发者的分页控件示例包解决大数据量列表一次性加载带来的内存占用与界面卡顿问题适用于需要为DataGridView等控件快速加分页能力的初中级开发者。包内包含PagerControl自定义控件及设计器代码、完整WinPager解决方案以及StartForm、Form1等多个演示窗体所有窗体均配有C#源码、resx资源文件与可视化设计文件可直接编译运行和修改整合。压缩包共65个文件总大小218KB以20个cs源码、8个resx界面资源、4个resources资源文件为主附带DLL、exe和工程配置文件结构紧凑清晰。已有835人学习下载。读者可从中掌握分页控件的核心实现思路、SQL Server数据库分页查询的OFFSET/FETCH写法以及将页码切换、每页条数调整等逻辑封装为可复用控件的完整示例适合作为日常开发中轻量高效的分页参考。 做过WinForm开发的朋友十有八九都跟DataGridView打过交道。列表一多翻页就成了绕不开的活。网上的分页控件不少但要么太重要么绑死某个第三方框架要么封装得过于“黑盒”出了问题连排查都无从下手。折腾过几轮之后我自己干脆写了一个基于UserControl的分页控件配合SQL数据库做数据库端分页用到现在差不多五年几个项目来回复用稳定性和开发效率都让我很满意。这篇就把这个控件的完整设计思路、代码实现和踩过的坑一次性讲清楚希望对正在做WinForm项目或者准备做数据列表的你有点参考价值。这个控件的核心用途其实就一句话把“翻页、跳页、每页条数切换、总页数计算”这些重复劳动统一收口业务代码只需要传入一个查询方法然后拿回当前页数据绑定到DataGridView就行。适合谁用适合那些不想在这个模块上反复造轮子、但又希望分页逻辑完全可控的.NET开发者。下面我尽量按实际做项目的顺序来讲从设计思路到代码再到排坑实录整个流程都能直接照搬。1. 为什么WinForm项目总需要自己做分页控件1.1 直接拉全量数据是最容易踩的坑很多新手项目上来就是一句SELECT * FROM Orders然后全丢给DataGridView数据量一旦过万界面直接卡成幻灯片。更难受的是WinForm的DataGridView在数据量大的时候内存占用非常夸张哪怕只是绑定一万行也会明显感觉到窗体拖动、列排序都变慢。分页不是可选优化而是WinForm列表场景下的刚需。但“需要分页”和“怎么做分页”是两回事。数据库端分页每次只取当前页的数据把几十万行的计算压力留在数据库里前端永远只面对几十条记录这体验完全不是一个级别。所以要写的这个控件必须走数据库端分页绝不能图省事在前台做内存分页。1.2 分页控件解决的不只是“翻页”这一个动作仔细想想一个列表页面真正要处理的动作包括点击首页、上一页、下一页、末页跳转到指定页切换每页条数刷新当前页以及当数据总条数变化时同步更新总页数和页码状态。这些逻辑如果每个窗体都写一遍不仅代码冗余还容易漏状态。举个例子当你在第3页删除了最后一条数据总页数从3变成2这时候界面还停留在第3页的话点下一页就是空数据这种bug特别容易在线上暴露。把状态管理统一收口到控件里业务窗体只需要告诉控件“我的总记录数是多少”“我的当前页数据是什么”控件自己维护页码状态、按钮的Enable、页码文本框的校验这才是分页控件的真正价值。它本质上是一个状态机而不只是一排按钮。1.3 为什么不用第三方现成控件NuGet上确实有不少现成分页控件比如DevExpress的PagerControl、一些开源分页组件功能看着很全。但实际试下来发现几个问题要么跟项目已有的UI风格冲突还要额外买授权要么配置项太多光理解那几个事件和属性的含义就要翻半天文档最麻烦的是一旦碰到数据库的分页方言差异或者特殊查询条件比如多条件组合筛选黑盒控件的扩展点往往不够用最后还是得自己拿SQL出来拼。自己做UserControl的好处就是完全透明代码在自己手里想改逻辑随时改也方便针对项目里的数据库版本去调分页SQL。所以我才坚持把分页控件做成项目里的公共组件而不是依赖第三方。2. 分页控件的整体设计先把状态和数据流理清楚2.1 控件的界面与交互状态先看这个控件长什么样。我一般是放一排按钮首页、上一页、下一页、末页中间一个用来输入目标页码的TextBox后面显示“/ 共N页”右侧是每页条数的选择ComboBox默认10、20、50、100再往右显示“共M条”。界面要简洁但状态逻辑必须严谨。这里的核心状态就四个当前页CurrentPage、每页条数PageSize、总记录数TotalCount、总页数TotalPage。前两个是可变状态总记录数由查询结果决定总页数是派生值。控件要做的就是在这些状态发生变化时调用一个外部查询委托并刷新所有按钮的可用状态。按钮状态的规则也不复杂当前页是1时首页和上一页不可用当前页等于总页数或总页数为0时下一页和末页不可用总页数为0时除了刷新按钮其他翻页按钮全部禁用。这些规则要在每次状态变更后统一执行一次不要散落在各个点击事件里否则后期维护就是灾难。2.2 数据流怎么走这个控件不能自己去访问数据库否则就把它写死了。正确做法是让调用方注入一个查询委托。委托的输入是当前页页码和每页条数输出是当前页的DataTable和总记录数。这样做的好处是业务层想怎么组合条件就怎么组合条件分页控件不需要关心SQL里有没有条件筛选。具体来说我会定义一个事件或者Func委托public Funcint, int, (DataTable PageData, int TotalCount) DataLoader;属性名称和返回值类型可以根据项目习惯调整但核心思路就是让控件持有一个“加载数据”的方法引用。用户在界面上点击翻页时控件内部调用这个委托拿到数据之后触发一个PageDataLoaded事件把DataTable传给窗体窗体再绑定到DataGridView。2.3 页码索引与边界处理页码从0开始还是从1开始是分页控件第一个要统一的问题。数据库分页SQL里书写的习惯各不相同有的用OFFSET有的用ROW_NUMBER()但无论哪一种推荐控件内部统一按“用户视角从1开始”处理在传给SQL时再换算成偏移量。原因很简单界面上用户看到的页码永远是从1开始的如果内部也顺着用0TextBox里的页码校验和显示逻辑容易绕晕。边界处理还有个常见问题用户在第2页设置了每页50条下拉框切换到100条后当前页码可能已经超出最大页数。我的规则是每页条数变更后重新计算总页数如果当前页大于总页数就把当前页调整为总页数如果总页数是0当前页直接置为1。顺序不能反否则会出现输入条件时闪现越界页码的情况。3. 实操写一个基于SQL数据库的通用分页控件3.1 分页SQL怎么写两种主流方案对比既然要配合SQL数据库分页SQL就是重头戏。如果用的是SQL Server 2012及以上版本最推荐的方式是OFFSET...FETCH NEXT写法很直观SELECT * FROM Orders ORDER BY OrderID OFFSET (PageIndex - 1) * PageSize ROWS FETCH NEXT PageSize ROWS ONLY;其中PageIndex和PageSize用SqlParameter传入避免拼接SQL带来的注入风险。这种方式简洁、性能也不错2008及更老的版本不支持那就只能退一步用ROW_NUMBER()嵌套写法SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (ORDER BY OrderID) AS RowNum FROM Orders ) AS t WHERE RowNum BETWEEN (PageIndex - 1) * PageSize 1 AND PageIndex * PageSize;两种方案我都在项目里用过个人建议是能上OFFSET FETCH就别用ROW_NUMBER()前者可读性好执行计划也更稳定。但要注意ORDER BY必须写否则分页结果没有意义每次翻页顺序都会乱。如果项目用的是MySQL或达梦这类数据库SQL方言会有差异比如MySQL用LIMIT PageSize OFFSET Offset达梦则类似Oracle的ROWNUM或OFFSET写法。所以控件的数据访问层建议单独抽一个方法由不同数据库的实现类去覆盖。3.2 控件核心代码实现先定义UserControl界面至少包含这些控件btnFirst、btnPrev、btnNext、btnLast、txtPageIndex、lblTotalPage、cboPageSize、lblTotalCount。布局不再细说和普通工具栏类似。关键代码分为几块。第一块是公开属性和委托public partial class PagerControl : UserControl { public int PageSize { get; private set; } 20; public int CurrentPage { get; private set; } 1; public int TotalCount { get; private set; } public int TotalPage TotalCount 0 ? 0 : (TotalCount PageSize - 1) / PageSize; public Funcint, int, QueryResult DataLoader { get; set; } public event EventHandlerDataTable PageDataLoaded; protected virtual void OnPageDataLoaded(DataTable table) { PageDataLoaded?.Invoke(this, table); } }QueryResult是一个简单的返回对象包含DataTable PageData和int TotalCount两个属性也可以用Tuple但自定义类更清晰。TotalPage用计算公式(TotalCount PageSize - 1) / PageSize这属于向上取整的标准写法能够避免小数页的问题。加载数据的核心方法private void LoadData() { if (DataLoader null) return; try { var result DataLoader(CurrentPage, PageSize); TotalCount result.TotalCount; OnPageDataLoaded(result.PageData); UpdateUI(); } catch (Exception ex) { MessageBox.Show($数据加载失败{ex.Message}, 错误, MessageBoxButtons.OK, MessageBoxIcon.Error); } }不管用户点哪个按钮最终都调整CurrentPage然后统一走LoadData()。这是这个控件不混乱的关键。UpdateUI用来更新按钮和页码显示private void UpdateUI() { btnFirst.Enabled CurrentPage 1; btnPrev.Enabled CurrentPage 1; btnNext.Enabled CurrentPage TotalPage; btnLast.Enabled CurrentPage TotalPage; txtPageIndex.Text TotalPage 0 ? 0 : CurrentPage.ToString(); lblTotalPage.Text $/ {TotalPage} 页; lblTotalCount.Text $共 {TotalCount} 条; txtPageIndex.Enabled TotalPage 0; }切换每页条数的ComboBox时要先把TotalCount保留因为切换到新的PageSize后TotalPage会随之变化。核心一点修改PageSize之后要重新计算并可能修正CurrentPage再重新加载数据。代码可以这样写private void cboPageSize_SelectedIndexChanged(object sender, EventArgs e) { if (int.TryParse(cboPageSize.SelectedItem.ToString(), out int pageSize) pageSize 0) { PageSize pageSize; if (CurrentPage TotalPage) CurrentPage Math.Max(1, TotalPage); LoadData(); } }翻页按钮的事件很机械就不贴了但要注意页码TextBox的输入校验。我一般只允许输入正整数并且输入后按回车触发跳页。如果输入的数字大于总页数直接修正为总页数小于1则修正为1。3.3 在业务窗体里怎么接这个控件业务窗体使用很简单。首先在窗体的Load事件里赋值委托并初始化private void FrmOrders_Load(object sender, EventArgs e) { pagerControl.DataLoader GetPageData; pagerControl.PageDataLoaded (s, table) { dataGridView1.DataSource table; }; pagerControl.PageSize 20; pagerControl.RefreshData(); }GetPageData是窗体的一个方法结合项目里的筛选条件生成SQLprivate QueryResult GetPageData(int pageIndex, int pageSize) { int offset (pageIndex - 1) * pageSize; using (var conn new SqlConnection(_connStr)) using (var cmd new SqlCommand()) { cmd.Connection conn; cmd.CommandText SELECT OrderID, OrderNo, CustomerName, OrderDate FROM Orders WHERE CustomerName LIKE kw ORDER BY OrderID OFFSET Offset ROWS FETCH NEXT PageSize ROWS ONLY; SELECT COUNT(*) FROM Orders WHERE CustomerName LIKE kw;; cmd.Parameters.AddWithValue(kw, $%{txtKeyword.Text.Trim()}%); cmd.Parameters.AddWithValue(Offset, offset); cmd.Parameters.AddWithValue(PageSize, pageSize); conn.Open(); var dt new DataTable(); using (var reader cmd.ExecuteReader()) { dt.Load(reader); int totalCount 0; if (reader.NextResult() reader.Read()) totalCount reader.GetInt32(0); return new QueryResult { PageData dt, TotalCount totalCount }; } } }这里用的是批次查询一条命令里同时返回分页数据和总记录数避免两次网络往返。Grid只显示当前页数据总条数通过QueryResult.TotalCount回传给控件。需要注意一个细节OFFSET参数不能直接写成(PageIndex - 1) * PageSize原因很简单SQL Server在执行计划参数化时对表达式计算的处理有时不够准确推荐先把offset用C#算好再传参执行计划重用率更高。但更重要的是不要在SQL里拼任何页码永远用参数化查询否则既有注入风险也会导致每次页码不同都会生成新的执行计划性能下降明显。4. 真实项目中的坑与排查技巧4.1 数据量大时翻页越来越慢我遇到过最典型的性能问题不是分页SQL本身而是ORDER BY的字段没有索引。比如按OrderDate排序但表的主键是OrderID结果翻到靠后的页时数据库需要先扫描并排序前面所有数据再跳过偏移量。这种情况换成OFFSET也一样OFFSET越大查询越慢根源是排序开销。建议是给排序字段建索引或者让排序字段尽量和聚簇索引一致。如果必须按非索引字段排序可以尝试用主键先筛出当前页的ID列表再用WHERE OrderID IN (...)取回完整行在很多场景下反而更快。但这种方法不适合ListView或树形结构具体取舍还是要看查询计划。4.2 总页数计算不对或者翻页时数据重复出现总页数不对最常见的原因是COUNT(*)的查询条件和分页数据的查询条件不一致。比如分页查询按某个筛选条件过滤了但COUNT(*)漏掉了这个条件总条数比实际多页数自然就多了。所以建议在构造分批SQL时把WHERE条件拆成一个公共片段分页查询和COUNT查询都复用同一段条件不要手写两遍。翻页时数据重复一般是排序字段不唯一导致的。举个例子如果OrderDate有大量重复值只按OrderDate排序数据库返回的行的相对顺序是不确定的翻页时可能某一条记录前一页出现了、后一页也出现。解决办法是排序时叠加一个唯一键比如ORDER BY OrderDate, OrderID保证排序结果完全稳定。4.3 多条件组合查询时控件“不刷新”了很多人在窗体里给查询按钮加点击事件点完查询后发现分页控件还停留在之前的页码。原因就是控件的CurrentPage没有重置。实际上每次触发查询条件变更都应该让分页控件回到第1页否则用户在第5页设置了关键字后点查询看到的是第5页的数据但总页数已经变了很容易误以为没刷新。我后来在控件里加了一个ResetToFirstPage()方法内部把CurrentPage置为1再调用LoadData()。业务窗体的查询按钮调用这个接口而不是直接调用RefreshData()。这是个非常小的设计但能避免一大堆莫名其妙的“数据没刷新”bug。4.4 跨窗体复用时的字体大小和布局锁定分页控件在多个窗体复用时偶尔会遇到DPI变化导致按钮宽度不够或者高度被压缩的问题。WinForm的经典坑在于如果窗体允许自动缩放UserControl里的某个TextBox宽度在两倍DPI下会变形。我的做法是尽量使用TableLayoutPanel来做控件布局并且把控件的Anchor属性设好。如果项目还要支持窗体缩放建议在UserControl的Resize事件里用百分比重新计算按钮宽度而不是依赖绝对像素值。另外一点经验把所有交互控件的名称统一前缀比如pager_btnFirst、pager_txtPageIndex多人协作时就不会跟窗体里的业务控件搞混。这不算技术问题但能大幅减少沟通成本。4.5 数据库为Oracle/达梦/MySQL时怎么适配虽然标题写的是SQL数据库但实际项目难免碰到其他数据库。我的做法是把分页SQL单独放到一个“数据访问策略”类里每个数据库类型一个实现控件本身完全不感知数据库类型。比如MySQL的分页可以写成SELECT ... FROM Orders WHERE ... ORDER BY OrderID LIMIT PageSize OFFSET Offset;达梦数据库在不同版本里写法也有差别有的兼容Oracle的ROWNUM有的支持OFFSET语法。这块没有什么银弹建议写之前先用数据库客户端手工验证SQL再把验证通过的语句移植到代码里。千万不要迷信“统一分页API”底层方言差异永远跑不掉。5. 一点个人经验每当我接手新项目看到一堆列表窗体各自写着不同的分页逻辑心里都会先皱一下眉头。分页控件的价值不在于它有多炫酷而在于它能让整个项目的高频操作收敛成同一个接口、同一套行为。我这版控件从最初一个简单UserControl演变成现在这种带委托注入和数据库适配策略的结构中间也砍过很多不实用的配置项。最后再分享一个小技巧给每个用到分页控件的窗体增加一个统一的RestorePagerState方法用来在窗体重新显示时恢复上一次的页码和每页条数。很多用户希望“我翻到第6页关掉详情弹窗回来还停留在第6页”默认情况下WinForm控件状态会因为重新绑定而丢失。这个功能实现不难但非常能提升实际使用体验。分页这东西看着简单真正做好需要把边界状态和用户心态都考虑进去希望这篇能帮你少走一段弯路。本文还有配套的精品资源点击获取