ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

WinForms通用分页控件设计与实现:从卡顿到300ms的优化实践

WinForms通用分页控件设计与实现:从卡顿到300ms的优化实践 简介一份基于 WinForm 的分页控件源代码主要面向 Windows 窗体开发人员用于快速实现 DataGridView 数据分页显示与切换减少重复搭建分页逻辑的工作量。控件封装了数据加载、页码导航、页面大小调整等常见功能内置简单的测试示例便于直接集成或二次扩展。资源包共45个文件体积约96KB包含10个cs源码文件、5个dll、5个pdb以及resx/resources资源文件和项目工程文件源码与编译输出并存适合阅读和改造。压缩包内附有完整的解决方案与测试窗体通过Form1和DGVPaging等模块可以快速了解控件结构。当前已有222人学习浏览作为轻量级分页控件示例适合初学者掌握分页原理也适合有经验的开发者参考封装思路。 最近在改一个进销存项目的查询页面主表数据四十万条出头DataGridView一次性加载要卡两三秒点一次查询界面就白屏一会儿。原本一直想找现成的分页控件试了几个要么年头太久、要么样式改不动最终决定自己封装一个通用的WinForms分页控件。花了一个周末把核心代码理清楚之后同样的页面在300ms内完成翻页加载内存占用也降下来了。这篇文章就把这个控件的设计思路、核心代码以及我踩过的坑完整记录下来给正在做WinForms管理系统、后台工具、桌面数据展示类项目的朋友一个参考。1. 为什么WinForms没有自带分页以及我为什么不用第三方分页控件1.1 原生DataGridView的局限很多刚开始用WinForms的朋友都会有这个疑问DataGridView既然叫Grid为什么不能像Excel那样自带底部翻页功能答案很简单DataGridView的本质是一个数据网格控件它负责的是“把给定数据展示成表格”这件事至于数据从哪来、一次取多少、怎么翻页都不在它的职责范围内。你可以给DataSource赋一个几百行的DataTable也可以赋一个几十万行的DataTable它都会全部给你渲染出来。数据少的时候看不出差别数据量一上来问题就非常明显滚动卡顿、内存暴涨、单元格绘制变慢严重的直接无响应。这种设计其实是从WinForms诞生之初就定型了的。那个年代的数据库查询场景普遍简单桌面客户端直接连库、一次性取数开发者对性能的敏感度也不如现在。放到今天来看如果你做一个CRM、进销存、订单管理这类系统主表数据动辄十几万行甚至上百万行再走一次性加载的老路性能上必然吃不消。所以分页这个功能不能指望DataGridView自己带必须由开发者在外部实现。1.2 第三方控件和自研的取舍既然原生控件不支持那是不是去NuGet上找一个开源的DataGridView分页控件或者付费的商业控件就行了这个方案我并不是说完全不行而是说在项目里要谨慎评估。商业控件比如DevExpress、ComponentOne功能确实很全分页、排序、筛选、图表全都有但引入它们意味着要给整个项目增加一份不小的授权成本而且它们的分页行为往往是和整套控件库的皮肤、布局体系绑定在一起的后期想改某个细节可能要翻很久的文档才能搞定。开源分页控件我也试过几个最典型的问题就是“很久不更新”和“只支持旧版.NET Framework”硬要引入反而成了技术债。我最终选择自研主要还是看重三点一是核心代码完全可控页面样式可以随意调整不依赖任何第三方dll二是功能可以精简化不需要向用户展示花哨的皮肤和复杂的配置项只要“首页、上一页、下一页、末页、页码跳转、记录总数、每页条数”这几样就够了三是这个控件做完之后可以打包成成熟的通用组件后续几个项目复用成本几乎为零。对于大多数内部管理系统来说自研一个分页控件的成本并不高但投入产出比很可观。2. 分页控件设计一个DTO加一个UserControl就够了2.1 PagerInfo分页状态的模型动手写代码之前我习惯先把数据模型理清楚。分页控件最核心的不是界面上的那几个按钮而是“当前处在哪一页、每页显示多少行、一共有多少行、总共有多少页”这套状态数据。我用的方案是定义一个PagerInfo类把所有分页需要的状态都封进去类似于Web开发里的PageResult或者PageRequestpublic class PagerInfo { public int PageIndex { get; set; } // 当前页码从1开始 public int PageSize { get; set; } // 每页记录数 public int RecordCount { get; set; } // 总记录数 // 总页数根据总记录数和每页条数实时计算 public int PageCount { get { if (PageSize 0) return 0; return (RecordCount PageSize - 1) / PageSize; } } }这个类本身不依赖任何UI控件它就是一个纯粹的状态载体。这样设计的最大好处是业务层和数据访问层不需要关心控件长什么样只要拿到PagerInfo就知道该查哪一页、应当显示多少页。你在很多分页控件源码里会看到他们把当前页、总页数、每页条数散落在控件内部的各个属性里查询数据时要东拼西凑才能把查询参数凑齐这种设计在项目变大之后维护起来非常痛苦。所以我强烈建议哪怕你不做用户控件只是在一个窗体里临时做分页也要先把PagerInfo这样的类抽出来。2.2 DPager用户控件导航栏的职责划分状态模型有了下一步就是界面。我用了UserControl作为载体做一个名为DPager的导航控件。它的控件布局从左到右依次是“首页”“上一页”“下一页”“末页”四个按钮中间一段文本显示“第 X / Y 页”再往后是一个跳转页码的TextBox和“跳转”按钮最右侧显示“共 N 条”。成品看起来很朴素但内部职责划分得很清楚DPager只负责维护PagerInfo的状态和按钮可用状态并通过事件通知外部“页码发生了变化”至于外部拿到新的页码之后怎么查数据库、怎么绑定DataGridViewDPager完全不关心。这样做的好处是解耦。以后万一你不想用DataGridView想换ListView或者自定义表格控件来展示数据DPager一行都不用改只要在事件处理里换成新的绑定逻辑就行。反过来如果你要在一个页面上放多个分页表格同样可以各自绑定一个DPager实例互不干扰。这也是我在实际项目中踩过“一锅端”的坑之后才领悟到的设计分页控件能不能复用关键不在于界面写得多好看而在于职责边界画得清不清楚。2.3 内存分页还是数据库分页谈完模型和界面还要先定一个方向这个分页到底是前端做内存分页还是每一次翻页都去数据库查询网上的很多简版Demo用的是内存分页——先把全表数据一次性查出来放进DataTable然后用LINQ的Skip和Take去切页面。这种写法在数据量小、数据来源固定的时候确实简单但如果数据量过万把整表数据拉进内存本身就是性能瓶颈数据库和网络IO都会被你白白消耗一遍。真实的业务系统里我建议默认都走数据库分页也就是每次翻页只取当前页需要的几十行数据。有一种情况我建议你还是用内存分页数据总量在几百行以内或者数据是程序运行时临时生成的比如从配置文件、内存集合里组装出来的一条字典数据这时候为了分页再去连数据库反而多此一举。所以分页控件的设计不能限死一条技术路线最好的做法是让PagerInfo只承载状态把数据获取逻辑留在调用方手里——你想用内存分页还是数据库分页由你在事件处理函数里自己决定。我的DPager就是这个思路所以在两边场景里都能用。3. 手写核心代码绑定、翻页和刷新3.1 用户控件的完整实现下面直接给出DPager用户控件的核心代码。先看事件声明和UI刷新方法。PageChanged事件是DPager和外部通信的桥梁每当用户点击翻页按钮或者跳转页码时DPager内部修改PagerInfo.PageIndex然后触发PageChanged事件外部订阅这个事件后重新加载数据。public partial class DPager : UserControl { public event EventHandler PageChanged; private PagerInfo _pagerInfo; public PagerInfo PagerInfo { get { return _pagerInfo; } set { _pagerInfo value; UpdateUI(); } } public DPager() { InitializeComponent(); _pagerInfo new PagerInfo { PageIndex 1, PageSize 20 }; } // 外部在拿到数据之后把总记录数回填进来并刷新界面 public void SetRecordCount(int recordCount) { if (_pagerInfo null) return; _pagerInfo.RecordCount recordCount; UpdateUI(); } // 更新按钮可用状态、页码文本 private void UpdateUI() { if (_pagerInfo null) return; // 确保当前页在合法范围内 if (_pagerInfo.PageIndex 1) _pagerInfo.PageIndex 1; if (_pagerInfo.PageCount 0 _pagerInfo.PageIndex _pagerInfo.PageCount) _pagerInfo.PageIndex _pagerInfo.PageCount; string pageInfo _pagerInfo.PageCount 0 ? 第 0 / 0 页 : $第 {_pagerInfo.PageIndex} / {_pagerInfo.PageCount} 页; lblPageInfo.Text pageInfo; lblRecordCount.Text $共 {_pagerInfo.RecordCount} 条; // 第一页时首页和上一页不可点最后一页时末页和下一页不可点 btnFirst.Enabled _pagerInfo.PageIndex 1; btnPrev.Enabled _pagerInfo.PageIndex 1; btnNext.Enabled _pagerInfo.PageIndex _pagerInfo.PageCount; btnLast.Enabled _pagerInfo.PageIndex _pagerInfo.PageCount; txtJumpPage.Text _pagerInfo.PageIndex.ToString(); } // 统一处理翻页逻辑外部只要传入目标页 public void GoToPage(int pageIndex) { if (_pagerInfo null) return; if (_pagerInfo.PageCount 0) return; // 无数据时不翻页 if (pageIndex 1) pageIndex 1; if (pageIndex _pagerInfo.PageCount) pageIndex _pagerInfo.PageCount; if (_pagerInfo.PageIndex ! pageIndex) { _pagerInfo.PageIndex pageIndex; UpdateUI(); PageChanged?.Invoke(this, EventArgs.Empty); } } private void btnFirst_Click(object sender, EventArgs e) { GoToPage(1); } private void btnPrev_Click(object sender, EventArgs e) { GoToPage(_pagerInfo.PageIndex - 1); } private void btnNext_Click(object sender, EventArgs e) { GoToPage(_pagerInfo.PageIndex 1); } private void btnLast_Click(object sender, EventArgs e) { GoToPage(_pagerInfo.PageCount); } private void btnJump_Click(object sender, EventArgs e) { int targetPage; if (int.TryParse(txtJumpPage.Text.Trim(), out targetPage)) { GoToPage(targetPage); } else { MessageBox.Show(请输入有效的页码数字。, 提示); } } }强调两个细节。第一翻页时我没有直接改PageIndex然后立刻抛事件而是统一走GoToPage方法先把页码边界校验做掉再决定要不要触发PageChanged。这样用户连续点击“下一页”也不会在最后一页时疯狂触发查询。第二SetRecordCount和PagerInfo属性赋值都会调用UpdateUI这样每次数据加载完成后只需要把总记录数回填进来界面上的页码、按钮状态就会自动同步。外部拿不到PageCount也没关系伸手调用SetRecordCount就完事了。3.2 主窗体中如何接入主窗体里的接入方式非常简单。首先在窗体上放一个DataGridView和一个DPager然后在窗体Load里订阅事件并加载第一页数据private void DataForm_Load(object sender, EventArgs e) { dPager1.PageSize 15; dPager1.PageChanged dPager1_PageChanged; LoadData(); } private void dPager1_PageChanged(object sender, EventArgs e) { LoadData(); } private void LoadData() { int totalCount 0; DataTable dt GetPageData(dPager1.PagerInfo.PageIndex, dPager1.PagerInfo.PageSize, out totalCount); // 回填总记录数DPager会自动刷新页码显示 dPager1.SetRecordCount(totalCount); // 绑定到DataGridView dataGridView1.DataSource dt; dataGridView1.ClearSelection(); }GetPageData是数据访问层的方法返回一页的DataTable并且把总记录数通过out参数带回来。这里有一个很多新手容易犯的错拿到dt之后必须再调一次SetRecordCount(totalCount)因为只有总记录数才能算出总页数。如果你只在初始时赋值一次翻到后面会发现页码显示一直是“第 X / 1 页”翻页按钮全都变成灰色。3.3 数据库端的分页查询写法数据库分页查询这里以SQL Server为例最常用的是ROW_NUMBER()配合BETWEEN的分页方式CREATE PROCEDURE [dbo].[usp_GetPageData] PageIndex INT, PageSize INT, TotalCount INT OUTPUT AS BEGIN SET NOCOUNT ON; SELECT TotalCount COUNT(*) FROM dbo.MyTable; SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY Id DESC) AS RowNum, * FROM dbo.MyTable ) AS T WHERE RowNum BETWEEN (PageIndex - 1) * PageSize 1 AND PageIndex * PageSize; END注意排序字段很重要。如果你的表里没有合适的索引ROW_NUMBER() OVER (ORDER BY Id DESC)这个排序本身就会很慢。我的经验是排序字段尽量用主键或者经常用来排序的索引列分页查询的性能才能有保证。在调用端用SqlCommand执行存储过程拿到总记录数和当前页数据即可。4. 我踩过的坑事件重复绑定、DataTable和跨线程问题4.1 事件重复绑定导致翻两页第一个坑是我在实际项目里排查了半天才发现的。页面里有几个查询条件用户每次点击“查询”按钮我会重新绑定一次dPager1.PageChanged dPager1_PageChanged结果就出现了诡异的现象每次点击“下一页”数据会连续跳两页而且SQL日志里能看到同一条查询执行了两遍。原因其实很简单事件重复订阅。每次绑定事件都在原有的事件调用列表上追加一个处理函数所以第一次点击“下一页”时dPager1_PageChanged被调用了两次每一次都会执行一次LoadData()数据自然就跳了两页。解决办法有两个一是只在Load事件里绑定一次不要在查询按钮里重复绑定二是在绑定之前先解绑dPager1.PageChanged - dPager1_PageChanged; dPager1.PageChanged dPager1_PageChanged;第二个方案更保险哪怕你写了多遍也不会重复订阅。后来我把这个写法统一用在了所有需要动态绑定事件的场景里算是养成了一个习惯。4.2 DataTable作为DataSource的内存释放问题第二个坑和内存有关。刚开始做分页时我每次加载完数据都直接dataGridView1.DataSource dt;翻几页之后任务管理器里内存占用就直接往上涨。后来仔细想了下DataGridView在重新设置DataSource之后旧的DataTable并不是立即释放的。如果DataGridView还持有旧对象的引用垃圾回收器就没法把它标记为可回收对象。我现在习惯在重新绑定之前先断开旧的数据源dataGridView1.DataSource null; dataGridView1.Rows.Clear(); dt.Dispose(); dataGridView1.DataSource dt;当然这里有个前提如果你的分页查询每次返回的是全新的DataTable断开旧数据源之后内存会明显更平稳。如果非要用同一个DataTable反复Fill那该涨还是会涨这种用法在长时间运行的WinForms服务端程序里要格外小心。4.3 异步加载时更新控件必须走Invoke第三个坑比较经典就是跨线程更新控件。分页查询如果放在后台线程里做查询完成后直接写dPager1.SetRecordCount(1000)十有八九会抛InvalidOperationException原因是WinForms的UI控件只能在创建它的线程上更新。这个其实不算分页控件的坑而是WinForms开发的通病。但只要涉及分页和后台查询几乎一定会碰到。我的处理方式是先封装一个安全的UI更新方法在异步回调里调用private void SafeSetRecordCount(int count) { if (dPager1.InvokeRequired) { dPager1.Invoke(new Actionint(SafeSetRecordCount), count); } else { dPager1.SetRecordCount(count); } }同样的逻辑也用在DataGridView绑定上。这些代码没有多深的技术含量但是能在半夜加班排查时帮你省下不少头发。5. 分页控件性能优化与扩展想法5.1 数据量大时的三个优化方向分页控件本身做不了太多性能优化真正的瓶颈往往在数据库端和DataGridView的渲染方式上。第一个优化思路是让数据库分页查询始终走索引尤其是ORDER BY的字段。我之前给一个表加过非聚集索引之后同样一个分页存储过程从2秒缩短到了200ms差距非常明显。第二个思路是把总记录数的COUNT(*)查询结果缓存起来因为大多数场景下用户连续翻页时总记录数不会变只有查询条件变化时才需要重新统计。第三个思路是启用DataGridView的虚拟模式如果确实有“不分页但还想展示大数据量”的极端需求虚拟模式配合CellValueNeeded事件可以做到只加载可见区域的数据内存占用比直接绑DataTable低很多。5.2 把搜索条件并进分页模型现在的DPager只管分页状态但真实项目中分页往往是和搜索条件绑在一起的。我现在的做法是在调用方维护一个查询参数对象把它作为GetPageData的输入之一每次翻页时把当前查询条件一并传给数据访问层。比如private SearchCondition _condition; private void btnSearch_Click(object sender, EventArgs e) { _condition new SearchCondition { StartDate dtpStart.Value, EndDate dtpEnd.Value, Keyword txtKeyword.Text.Trim() }; dPager1.GoToPage(1); // 回到第一页再查询 LoadData(); }LoadData里再把这个_condition传给存储过程。这样每次翻页都会带着同样的搜索条件不会出现“翻页后条件丢失、查出了全表数据”的低级事故。这个细节看起来不起眼但我在多个项目里都见过翻页后搜索条件失效的bug根源就是这个翻页事件和数据加载逻辑没有共用同一个查询条件上下文。5.3 后续可以做的扩展分页控件做到这一步已经能覆盖大多数场景了如果还想继续扩展可以考虑加一个“每页条数”下拉框支持10条、20条、50条、100条切换切换之后自动回到第一页并重新加载。也可以加上列排序的保持逻辑让用户在点击列头排序之后仍然停留在当前页而不是跳回第一页。如果你把DPager、PagerInfo、数据访问层封装成独立的dll下次有新项目时直接引用就能省掉一批重复工作。只要设计上保持“状态模型与UI解耦、事件驱动数据刷新”这两个原则后续扩展都会很顺。再分享一个使用习惯我在做这个分页控件时顺手把默认的PageSize设成了15或者20。很多管理系统页面里20条一页是比较舒服的密度翻页次数不会太多每页渲染速度也很快。如果你是给领导做报表页面可以试试把每页条数调到50配合数据库索引和必要的缓存体验会比默认值更好。分页这件事归根到底就是在“数据量”和“用户体感”之间找一个平衡点。本文还有配套的精品资源点击获取
返回列表