DataGridView分页控件实战:从内存分页到SQL分页与虚拟模式优化
简介面向WinForm开发者的DataGridView分页控件解决了表格数据量较大时一次性加载导致的界面卡顿与浏览不便问题。控件将分页逻辑与界面展示封装为一体调用简单开发者只需设置数据源即可快速获得带页码导航的表格分页效果适合需要在中大型桌面应用中实现高效数据浏览的C#程序员。压缩包共45个文件约96KB包含DGVPaging.cs源码、控件设计器文件、编译生成的dll及pdb调试符号同时附带一个测试工程内含Form1.cs演示页面方便直接运行查看效果。资源已通过rar打包上传项目结构清晰可参照测试工程快速掌握控件的挂载方式并将源码嵌入自有项目按需调整。目前已有223人学习下载小而精的封装适合初中级开发者理解分页控件的设计思路也可为自定义表格扩展提供参考。1. DataGridView分页控件别把分页当成“翻页”那么简单在WinForms项目里给DataGridView加分页最常见的做法是拖一个DataGridView再放上“上一页/下一页”按钮后台把全表数据Load到内存里每页取一段。数据量到了几十万行时你会发现加载一分钟内存飙升好几百兆翻一页卡三秒——这是典型的“假分页”症状。真正的分页控件尤其是在WinForms这个老技术栈里难点不只在于“切页”还要回答三个问题数据从哪儿来全量内存还是按页查询、界面状态怎么保持选中行、滚动位置、排序、以及分页组件和GridView之间怎么解耦。针对不同数据量方案完全不同。这篇文章基于我长期在WinForms项目里的落地经验从最简单的内存分页讲起再逐步深入到SQL分页和虚拟模式。适合那些已经有一定WinForms基础、想在自己的工具型项目或管理系统里做出不卡、可维护的分页能力的开发者。2. 分页方案选型内存分页与数据源分页怎么选分页控件永远不是第一个要写的东西分页策略才是。很多开发者上来就写“上一页”“下一页”按钮然后在Button_Click里用DataSet.Tables[0].Select(RowID between ...)这种基于索引的切片看似灵活实际上是在用数据库能力代替应用层逻辑。我更习惯先回答一个问题这个页面到底能有多少行数据。不同的回答会把你推向完全不同的代码结构。下面两种情况我见过无数翻车现场这里直接给出选择边界。2.1 内存分页适合数据量在几万行以内的内部工具内存分页就是把数据库结果一次性拉到List 然后用Skip/Take切页。它的核心逻辑只有三行int pageIndex 2, pageSize 20; var pageData allRecords .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToList();这段代码有个隐藏前提allRecords已经全部躺在内存里。在几千行的字典表里这种方式毫无问题而且内存分页有一个非常实用的优势排序、筛选都发生在本地用户的交互几乎零延迟。翻页、搜索、按列排序全部在内存里完成不产生新的数据库往返。适合内存分页的场景通常是业务方拿着Excel来维护配置或者一次最多显示三五千行。我在一个内部排课工具里一次加载全校课程两万行——勉强能扛住但翻页时内存占用接近200MB再快也显得臃肿。这种量级下内存分页其实已经站在悬崖边上。内存分页还有一个容易被忽略的细节它要求你的数据源是稳定快照。如果业务表在运行期间被其他进程改了你的内存列表不会同步翻页时看到的是“旧数据”。所以一旦涉及并发写入或实时报表就不用考虑内存分页了。对比项内存分页数据源分页实现难度低几行Skip/Take中需要配合SQL语句内存占用随数据量线性增长恒定只有当前页数据首次加载慢要拉全量快按页拉取排序/筛选本地完成体验好每次翻页都要重新查询数据一致性快照数据可能过期每次实时查询一致性高推荐数据量5万行以内10万行以上2.2 数据源分页让数据库只返回当前页几乎不增加应用内存当数据量达到十万行以上内存分页的短板就非常明显了。常见做法是让分页控件直接和SQL配合每次翻页都带着PageIndex、PageSize和总行数去数据库换数据。这样DataGridView里永远只有当前页的几十行内存占用恒定加载速度也快。在SQL Server里OFFSET/FETCH是比较自然的写法SELECT * FROM Orders ORDER BY OrderId OFFSET (pageIndex - 1) * pageSize ROWS FETCH NEXT pageSize ROWS ONLY;注意OFFSET/FETCH要求ORDER BY必须是唯一且稳定的列否则翻页会乱。尤其不能依赖没有索引的排序列。有一个常见错误是用生成时间排序但同一秒内有多条记录排序不稳定第二页和第一页就出现重复数据。数据源分页需要多一次COUNT查询来拿总行数这个要放到同一连接里或放到同一批次避免两次往返。很多分页控件把总行数的查询独立开来结果数据库被无谓地打了两个来回。后面我会在第四章给出一个三层分页查询的完整参数清单。2.3 分界点什么时候从内存分页切到数据源分页从业界经验看内存分页的安全线大约在两万到五万行。超过五万行除非服务器内存特别充裕否则我建议直接改数据源分页。一个常用的判断标准是如果第一次加载时间超过3秒或者GC结束后的内存占用超过300MB就说明数据已经不适合全量拉取。但也有例外。如果你开发的是一个只在局域网内部使用的台账工具用户每次只查一个月的数据那么即使那个月有一百万行也可以通过查询条件把结果先收敛到几千行再做内存分页。这种“业务预筛选 内存分页”的组合是另一种可靠落地路径。实际经验里最让人头疼的是“表本身超过百万行但用户一次查全部还要求分页”。这种需求没有银弹必须走数据源分页并且分页控件要支持“直接跳到指定页”而不是傻傻地一页一页翻否则用户会翻到崩溃。3. 封装自己的DataGridView分页控件从零开始的完整代码既然标题是“分页控件”那我们就按控件来做。我通常用UserControl组合DataGridView和导航条对外暴露统一的PageData()方法。下面是具体做法。3.1 控件的基本结构一个UserControl 一组公开属性设计分为两部分UI和契约。UI上一个DataGridView底部一排按钮首页、上一页、下一页、末页、一个页次文本框、一个总数标签、一个每页行数下拉框。公开属性最少包括PageSize、PageIndex、TotalCount、DataSource。一个核心建议不要在控件内部写死数据源类型。我一般会定义PageChanged事件主窗体负责提供数据和总行数。这样控件既能用在内存分页也能用在数据库分页。下面是一个精简版的控件布局和属性定义public partial class PagedDataGridView : UserControl { private int _pageIndex 1; private int _pageSize 20; private int _totalCount 0; private DataTable _allData; public int PageIndex { get { return _pageIndex; } set { _pageIndex value; } } public int PageSize { get { return _pageSize; } set { _pageSize value; } } public int TotalCount { get { return _totalCount; } set { _totalCount value; } } public event EventHandler PageChanged; public PagedDataGridView() { InitializeComponent(); } public void SetDataSource(DataTable fullData) { _allData fullData; if (fullData ! null) _totalCount fullData.Rows.Count; BindCurrentPage(); } }这段代码里SetDataSource接收一个完整的DataTable这在内存分页场景下很直接。TotalCount在外部用SQL COUNT设置时SetDataSource也可以不传全量数据只传当前页。为了兼容两种模式我会在3.3小节提供一个重载版本。3.2 翻页逻辑与UI绑定避开重新创建BindingSource的坑核心的BindCurrentPage方法负责从全量数据中切出一页并绑定到DataGridView上。注意绑定前一定要先把DataSource设为null否则每次翻页DataGridView会去合并旧数据出现行错位和闪烁。private void BindCurrentPage() { if (_allData null) return; // 内存分页从全量表切片 DataTable pageTable _allData.Clone(); int startRow (_pageIndex - 1) * _pageSize; int endRow Math.Min(startRow _pageSize - 1, _allData.Rows.Count - 1); for (int i startRow; i endRow; i) { pageTable.ImportRow(_allData.Rows[i]); } dataGridView1.DataSource null; dataGridView1.DataSource pageTable; // 刷新导航条状态 int totalPages (int)Math.Ceiling((double)_totalCount / _pageSize); lblPageInfo.Text _pageIndex / totalPages; btnPrev.Enabled _pageIndex 1; btnNext.Enabled _pageIndex totalPages; btnFirst.Enabled _pageIndex 1; btnLast.Enabled _pageIndex totalPages; }这里有几个关键点Clone() ImportRow() 是DataTable切片的常用方式比Select() CopyToDataTable()快而且保留了列结构。Math.Min防止最后一页越界。先把DataSource置null再赋新值这样可以强制DataGridView完全刷新避免旧数据残留和行状态错乱。翻页后选中行会丢失这几乎是所有分页控件最被吐槽的点后面第五章会专门讲怎么恢复。按钮的事件很简单private void btnFirst_Click(object sender, EventArgs e) { _pageIndex 1; BindCurrentPage(); OnPageChanged(); } private void btnPrev_Click(object sender, EventArgs e) { if (_pageIndex 1) { _pageIndex--; BindCurrentPage(); OnPageChanged(); } } private void btnNext_Click(object sender, EventArgs e) { int totalPages (int)Math.Ceiling((double)_totalCount / _pageSize); if (_pageIndex totalPages) { _pageIndex; BindCurrentPage(); OnPageChanged(); } } private void btnLast_Click(object sender, EventArgs e) { int totalPages (int)Math.Ceiling((double)_totalCount / _pageSize); _pageIndex totalPages; BindCurrentPage(); OnPageChanged(); } private void OnPageChanged() { PageChanged?.Invoke(this, EventArgs.Empty); }按钮逻辑之外有一个地方容易被忽略如果在页次文本框里直接输入页码需要在KeyDown事件里拦截Enter键并校验输入范围。否则用户输入99超过总页数BindCurrentPage会因为startRow越界直接抛异常。private void txtPageIndex_KeyDown(object sender, KeyEventArgs e) { if (e.KeyCode Keys.Enter) { int inputPage; if (int.TryParse(txtPageIndex.Text, out inputPage)) { int totalPages (int)Math.Ceiling((double)_totalCount / _pageSize); if (inputPage 1 inputPage totalPages) { _pageIndex inputPage; BindCurrentPage(); OnPageChanged(); } else { MessageBox.Show(页码超出范围, 提示); } } e.SuppressKeyPress true; } }这段代码里有一个细节e.SuppressKeyPress true它把Enter键的“叮”声一并消掉了是WinForms控件里一个很实用的小习惯。3.3 支持外部数据源分页只喂当前页而不喂全量数据当数据量特别大时不能把全表数据交给SetDataSource。我会增加一个重载让外部传入当前页数据同时单独设置总行数public void SetPageData(DataTable currentPageTable, int totalCount) { _totalCount totalCount; _allData null; // 标记为数据源分页模式 dataGridView1.DataSource null; dataGridView1.DataSource currentPageTable; UpdateNavigationStatus(); }这个重载的意义在于当控件处于数据源分页模式时用户点击“下一页”PageChanged事件通知外部窗体窗体再去执行数据库查询然后把查询结果通过SetPageData传回来。控件内部永远只有当前一页的数据内存占用被压到最低。内部需要用一个bool字段来区分两种模式比如_isFullDataMode这样BindCurrentPage和SetPageData不会互相干扰。你可以用这个字段决定翻页时是走内存切片还是直接触发PageChanged让外部去查库。4. 与数据库协同把分页下沉到SQL而不是全表加载控件本身能切页还不够实际项目里最关键的一步是让分页查询在数据库层面完成。这一章讲清楚SQL怎么写、参数怎么传、总行数怎么拿以及为什么我在第三章节要留一个外部数据源入口。4.1 为什么换成SQL分页后应用卡顿会消失很多开发者说“我加了分页控件还是卡”原因往往是分页控件只负责切页后台仍是全表查询。真正把分页下沉到SQLDataGridView每次只拿几十行网络传输、内存占用、界面渲染都大幅下降。卡顿会消失是因为“没拿到那么多数据”而不是“控件写得好”。4.2 一条可复用的分页存储过程与参数清单拿SQL Server举例最稳定且兼容性好的写法是ROW_NUMBER()而不是直接OFFSET/FETCH。原因有两点第一老版本数据库或低版本兼容级别可能不支持OFFSET第二ROW_NUMBER可以配合多列排序条件且当我们同时取COUNT和当前页数据时可以做到同一条SQL返回两个结果集。下面是一个分页存储过程的骨架CREATE PROCEDURE [dbo].[Paging_GetOrderList] PageIndex INT, PageSize INT, WhereClause NVARCHAR(200) AS BEGIN DECLARE StartRow INT (PageIndex - 1) * PageSize 1; DECLARE EndRow INT PageIndex * PageSize; WITH PagedOrders AS ( SELECT ROW_NUMBER() OVER (ORDER BY OrderId) AS RowNum, * FROM Orders WHERE (WhereClause OR OrderStatus WhereClause) ) SELECT * FROM PagedOrders WHERE RowNum BETWEEN StartRow AND EndRow; SELECT COUNT(*) AS TotalCount FROM Orders WHERE (WhereClause OR OrderStatus WhereClause); END这个写法的核心是把当前页数据查询和COUNT查询放在同一个存储过程里客户端一次调用就能拿到两个结果集当前页数据和总行数。一次往返比分开两个查询少了一半的数据库连接时间。在C#里调用时几个关键参数需要注意参数名类型说明PageIndexINT当前页码从1开始存储过程里做了偏移换算PageSizeINT每页行数建议放下拉框让用户选10/20/50/100WhereClauseNVARCHAR业务过滤条件注意拼接时防止SQL注入C#侧的关键代码public PagingResult GetOrderList(int pageIndex, int pageSize, string orderStatus) { using (SqlConnection conn new SqlConnection(_connectionString)) using (SqlCommand cmd new SqlCommand(Paging_GetOrderList, conn)) { cmd.CommandType CommandType.StoredProcedure; cmd.Parameters.AddWithValue(PageIndex, pageIndex); cmd.Parameters.AddWithValue(PageSize, pageSize); cmd.Parameters.AddWithValue(WhereClause, (object)orderStatus ?? DBNull.Value); SqlDataAdapter da new SqlDataAdapter(cmd); DataSet ds new DataSet(); da.Fill(ds); PagingResult result new PagingResult(); result.CurrentPageTable ds.Tables[0]; result.TotalCount Convert.ToInt32(ds.Tables[1].Rows[0][0]); return result; } }这里有一个很隐蔽的坑使用AddWithValue时如果orderStatus为null直接传null会被当作默认值而不是“不过滤”。所以我上面用了(object)orderStatus ?? DBNull.Value把null转为DBNull存储过程里WhereClause 的分支就能正确走全表逻辑。4.3 一定要把“总行数”和“当前页”放在同一次查询里极端情况下如果在用户翻页的瞬间数据库里新增了几条记录导致总行数变化而当前页数据已经按旧的总行数算好就会出现“最后一页没数据”或“跳页不准”这种奇怪现象。把COUNT和当前页数据放在同一条SQL里至少能保证是在同一时刻快照下得到的结果虽然做不到绝对一致但能显著降低这类问题。另一种常见做法是单独先查一次COUNT然后每次翻页只查数据。这在数据量不敏感的小工具里可以接受但在报表系统里用户会盯着右下角的总页数看一旦总页数在翻页过程中变了他们一定会报bug。5. 常见问题与避坑翻页后滚轮失效、选中行丢失、性能不升反降不管控件写得多干净落到真实项目里总会遇到一些恶心问题。这章我从WinForms的实际行为里挑了4个高频坑每一条都是现象、原因、解决三步。5.1 翻页后选中行丢失甚至选不中任何行现象点击下一页后DataGridView没有选中任何行用户需要再点一次单元格才能触发选中事件如果用BindingSource做数据源还会出现多选状态残留。原因翻页时把DataSource设置为null再赋新值导致DataGridView的内部选中状态被清空。而如果直接赋值新DataTable控件还会尝试继续沿用旧的SelectedRows索引但新表行数变少索引越界后就表现为“选不中”。解决翻页后手动选中新表的第一行或者记录上一页第一行的主键值在新页数据中ScrollIntoView。我一般在BindCurrentPage最后加上两行if (dataGridView1.Rows.Count 0) { dataGridView1.Rows[0].Selected true; dataGridView1.CurrentCell dataGridView1.Rows[0].Cells[0]; }这个做法能让用户感知到“当前页第一条被选中”视觉上很明确。如果你希望恢复翻页前的选中行得在翻页前保存当前选中行的主键字段翻页后用Rows.Cast ().FirstOrDefault(r r.Cells[Id].Value.ToString() savedId)去重新定位。主键字段名要作为控件属性开放给外部配置不能写死。5.2 翻页时滚轮失效或页面滚动条跳动现象翻页后鼠标滚轮滚动DataGridView无反应或者页面内容总是从底部开始需要转很多下才能看到第一行。原因DataGridView绑定到新数据后FirstDisplayedScrollingRowIndex还停留在上一页的滚动位置。WinForms在这个场景下不会自动重置滚动位置于是新数据的第一行反而被滚到可视区域之外。解决在BindCurrentPage里加一行dataGridView1.FirstDisplayedScrollingRowIndex 0;这一行放在DataSource赋值之后看起来简单却能解决大量“分页后滚动位置乱跳”的投诉。另外一个细节是如果列太多出现横向滚动条同理也要把FirstDisplayedScrollingColumnIndex重置为0。5.3 数据源分页模式下次页查询慢且性能不升反降现象换成数据源分页后每翻一页还是卡顿甚至比全表加载还要慢。原因每个按钮点击都切换Database查询如果数据库里没有针对ORDER BY和WHERE条件的索引OFFSET/FETCH会从头扫描全表。比如ORDER BY OrderId没建主键索引等于是全量扫描排完再切页每次翻页的代价跟首次加载完全一样。解决给排序列加索引。如果排序字段是OrderId必须先有主键如果排序字段是CreateTime就要建Completion非聚集索引。更重要的是COUNT查询也要走索引可以在索引里包含过滤字段。CREATE INDEX IX_Orders_CreateTime ON Orders(CreateTime) INCLUDE(OrderStatus);另外一个容易翻车的点存储过程里的WhereClause用了拼接字符串导致参数化查询失效。这个坑我在4.2节的示例里特意避开了用WhereClause OR OrderStatus WhereClause而不是动态拼接就是为了让查询计划能被缓存。5.4 DataSet没有关闭连接翻页几次后数据库连接池耗尽现象程序运行一段时间后所有分页按钮点着点着就报“连接池已满”或“Timeout expired”。原因SqlDataAdapter的Fill方法会自动打开连接并关闭但如果在Fill之前自己手动打开了连接那么要自己负责关闭。很多人看到SqlConnection没加using或者Fill之后没有finally里Close连接就被一直占着。解决像4.2节的代码那样所有数据库操作都用using包裹SqlConnection。即使你觉得连接池会自动回收也要显式using。另外存储过程返回两个结果集Fill方法只能填完第一个第二个结果集需要单独NextResult所以不要用da.Fill(ds)一次处理两个表。5.5 翻页时整个界面卡死连按钮都按不动现象点击“下一页”后窗体无响应要等好几秒才恢复期间鼠标变成旋转圆环。原因数据库查询在UI线程同步执行查询期间消息循环被阻塞。这在内存分页时几乎不会出现但数据源分页模式下每次翻页都可能涉及跨机器的数据库往返。解决把翻页查询放到后台线程或者用async/await。WinForms不太擅长直接跨线程改控件但可以结合BackgroundWorker或者使用await的Task.Run生成然后回到UI线程绑定数据。private async void btnNext_Click(object sender, EventArgs e) { btnNext.Enabled false; try { _pageIndex; var result await Task.Run(() _dbService.GetOrderList( _pageIndex, _pageSize, _statusFilter)); SetPageData(result.CurrentPageTable, result.TotalCount); } catch (Exception ex) { MessageBox.Show(ex.Message); } finally { btnNext.Enabled true; } }这样处理后界面不会卡死但需要额外关注两个细节第一翻页期间要禁用导航按钮防止用户连点把页码跳乱第二Task.Run里不要访问任何UI控件所有数据绑定都回到UI线程执行。6. 进阶把异步分页和虚拟模式结合在一起如果你已经把前面的分页控件跑通接下来最值得做的优化是给DataGridView开启VirtualMode。虚拟模式跟分页控件配合能做到只创建当前页用到的行对象连DataGridView内部的行缓存都省掉。尤其在大数据量报表场景下效果比单纯换数据源更明显。开启虚拟模式后DataGridView不再持有完整的数据副本而是通过CellValueNeeded事件按需索取单元格值。这意味着分页控件不再需要设置DataSource只要在事件里从当前页的DataTable中按行索引取数即可。private void dataGridView1_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e) { if (_currentPageTable ! null e.RowIndex _currentPageTable.Rows.Count) { e.Value _currentPageTable.Rows[e.RowIndex][e.ColumnIndex].ToString(); } }这里有一个需要重新认识的点虚拟模式下的DataGridView.RowCount要先设成当前页的行数否则事件不会触发。dataGridView1.VirtualMode true; dataGridView1.RowCount _currentPageTable.Rows.Count;我自己的习惯是分页控件里默认开启VirtualMode再配合异步查询。翻页时后台拿当前页数据拿到后只更新RowCount和通知DataGridView刷新不做繁琐的DataSource绑定。这种做法把DataGridView本身的渲染压力也降了下来翻页流畅度能上一个台阶。不过虚拟模式不是没有代价。首先是选中行、单元格编辑、列排序这些功能需要自己维护状态工作量会显著增加其次DataGridView的默认排序行为在虚拟模式下会失效如果你的业务需要点击列头排序得自己写比较逻辑。所以我的建议是数据量50万行以下不值得上虚拟模式正常分页控件已经够了超过这个量级再考虑把虚拟模式作为压轴优化。最后分享一个第一次独立做分页控件时犯的错当时为了显示“总页数”我在PageChanged事件里循环查询了所有页的总行数结果界面加载时要等几秒。后来才明白总行数从来都应该是查询结果的一部分而不是控件去猜。从那以后任何分页控件的设计我都坚持“数据源提供TotalCount控件只负责展示和切页”这个原则。希望你也能从这套方案里找到适合自己项目的路径少走那些我踩过的坑。希望帮到你。本文还有配套的精品资源点击获取