
做WinForms开发的人早晚会遇到那么一天DataGridView里的数据越来越多从几千行到几万行一加载就卡成幻灯片拖动滚动条像灌了铅。我就在一个进销存系统里被这事折磨过后来老老实实给DataGridView加了分页才算把性能救回来。这篇文章就把我在项目里落地分页的完整思路、代码和踩过的坑写出来给还在硬扛全量加载的朋友一个参考。这篇文章适合刚接触DataGridView的初级开发者也适合在维护老项目、想优化报表性能的中级开发者。内容围绕“为什么要分页”“分页的本质是什么”“最简单的分页方案怎么写”“分页之后又冒出哪些坑”这几个问题展开全程以WinForms C#为主代码会贴出来关键思路也会讲透。1. 为什么DataGridView一加载几千行就卡成PPT1.1 默认行为你不是在画表格是在建一座城市很多人觉得DataGridView绑定一个DataTable就跟Excel打开CSV一样简单。实际上差别很大。Excel是延迟渲染只有你看得见的区域才会绘制单元格。而DataGridView在没有分页、没有虚拟模式的状态下会把DataSource里的数据全部加载到界面一边绑定一边创建对应数量的行对象。我看过一个有点夸张但很说明问题的对比同样一张一万行的表在Excel里滚来滚去很流畅在DataGridView里翻页都翻不动。原因并不在于DataGridView多差而在于它把每一行都当成了完整的控件容器每行都有单元格对象、样式对象、事件关联行一多内存和布局计算就开始爆炸。实际项目里还有个隐藏问题数据不是孤立的。我用过的那个进销存系统商品表关联了供应商、分类、库存、价格多张表主查询已经写了四个LEFT JOIN。直接绑一万行DataGridView要同时处理上万行数据的绘制和用户排序操作主线程根本忙不过来界面就是常说的“假死”。1.2 卡顿的根因渲染压力和数据加载互相叠加分页解决的其实不是渲染问题而是数据加载压力。可能有人会反驳我只需要显示20行绑个20行的DataTable不就行了对这就是分页的思路——让DataGridView一次只需要面对20行数据。那为什么很多人一开始不这么做因为分页改造看起来“多了一步”查询数据库的时候要传页码、传每页条数用户翻页又要重新查一次要是查询逻辑复杂还得考虑怎么保证翻页数据不重复、不遗漏。所以拖着不改直到卡得实在没法用。我用一个比喻来解释不分页的DataGridView等于让一个服务员一次性记住全部100桌客人的菜单一旦菜式变多他脑子就乱了。分页之后服务员只需要记住当前这10桌翻一页再换一批信息反而处理得又快又准。1.3 分页粒度怎么定一页20行不是拍脑袋“每页显示20行”在很多管理系统里成了默认值但它其实应该根据业务来定。我的建议是先看你业务上“一屏最高效的信息量”是多少再考虑单行高度。如果一张单据明细一行只有35px高屏幕可视区域大约600px一屏能看17行左右。那每页显示20行是个合适的值。如果行里有换行、图片、比较高的自定义内容一页还定20行用户翻页时看到的永远只有半屏数据反而增加了点击次数。另外要考虑查询返回速度。如果主表的查询本身就要2秒你把pageSize设成10理论上单页查询应该变快但如果SQL写得不带分页每次都把全量数据取出来再内存分页那pageSize设多少都没用。后面会细说服务端分页和用户态分页的区别。2. 第一道选择题服务端分页还是用户态分页2.1 服务端分页数据量大的时候必须走的路服务端分页也叫数据库分页或物理分页核心思路是SQL里只查询当前页的数据。SQL Server用OFFSET和FETCHMySQL用LIMITOracle用ROWNUM。比如每页20行第3页就是从第41条到第60条SELECT * FROM Product ORDER BY ProductId OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY;每条查询只返回20条记录DataGridView绑定后自然只有20行。这样的好处是内存占用极低数据量到几十万、上百万都能支持。缺点是每次翻页都要重新执行一次SQL如果ORDER BY的字段没有索引页数越往后OFFSET跳过的行数越多查询会越来越慢。在进销存系统里商品表的数据量我当时统计过是18万条左右。这个量级如果用内存全量加载光DataTable就要占掉一两百MB内存所以服务端分页是唯一合理的选择。2.2 用户态分页数据量小、逻辑复杂时的“土办法”用户态分页也叫内存分页或逻辑分页思路是程序启动时一次把整张表Load到内存之后翻页只是从DataTable里截取当前页对应的行。DataTable allData LoadAllData(); // 查一次 DataTable pageData allData.Clone(); foreach (DataRow row in allData.Select($RowIndex {start} AND RowIndex {end})) { pageData.ImportRow(row); }用户态分页适合数据量在几千行以内、但业务逻辑特别复杂的场景。比如页面里有多条件筛选、计算列、临时性增删改数据在内存里操作最方便用户怎么翻页都不会产生额外的数据库压力。不过我不建议把用户态分页用在大表上。我见过有人把一张五万行的库存流水表整个Load进DataTable做内存分页结果光Load就要三四秒翻页虽然快但首次加载的等待已经足够暴露问题了。2.3 为什么不建议靠BindingSource的Filter和Position硬撑BindingSource有一个Position属性可以理解成“当前指针”很多人拿它当分页用bindingSource.Position 1;这样做的确能让DataGridView跟着移动但本质上数据源还是全量数据只是显示的位置变了。所有的行依然全部加载在内存里性能瓶颈根本没解决。它更适合做主从表的联动不适合做真正的分页。2.4 我的选型判断标准我后来定了一个很简单的判断标准数据量超过1万行或者主查询需要关联四张表以上就无脑选服务端分页。数据量小、又需要大量内存操作才走用户态分页。但这篇文章想先讲清楚最容易被大家接受、也最方便初学者上手的内存分页。因为它的代码量少、不需要改SQL、调试直观做完之后你就理解了分页的完整逻辑。理解了这套逻辑再去写服务端分页会轻松很多本质是同一套状态管理只是数据来源变了。3. 内存分页从零搭我给DataGridView做的完整分页控件3.1 界面布局其实不需要第三方分页控件WinForms没有自带分页组件很多人第一反应是去找第三方库。但分页控件真没必要引包我直接在Form底部放了一个TableLayoutPanel一行放下“首页”、“上一页”、“下一页”、“末页”四个Button加上一个页码Label、一个跳页TextBox和一个确认Button就是一套完整分页条。这样做的最大好处是逻辑完全可控。第三方分页控件通常自带一些花哨的样式和事件但项目里真正需要的就是那四个按钮加页码信息自己做不超过50行布局代码出了问题也好定位。3.2 分页状态四件套页码、页大小、总行数、总页数写分页之前必须先把状态定义清楚。我总结了四个变量缺一不可private int currentPage 1; // 当前页码从1开始 private int pageSize 20; // 每页行数 private int totalRecords 0; // 总行数 private int totalPages 0; // 总页数totalPages的计算是很多人第一次写分页最容易出错的地方。它应该是totalPages (totalRecords pageSize - 1) / pageSize;这个公式的含义是向上取整。如果totalRecords是101、pageSize是20按这个公式得到的是6页但如果你用totalRecords / pageSize得到的是5页第101条数据就凭空消失了。我当时就因为这个低级错误让用户数据少显示了一页排查了好久才发现是整除问题。3.3 翻页的核心Clone ImportRow别用DataTable.Select返回数组内存分页里最核心的就是从总表里截取当前页的数据。很多人第一版会这么写DataRow[] rows dt.Select($RowIndex {start} AND RowIndex {end}); dataGridView.DataSource rows.CopyToDataTable();如果能保证dt里有个RowIndex列这样做也能跑但有个坑CopyToDataTable()要求DataRow[]里的行结构和数组本身不为空一旦当前页没有数据比如删光了、搜索后结果为空就直接抛异常。我用的方案是先Clone再ImportRow。Clone会复制表结构但不会复制数据ImportRow会把一行数据完整地导入过去且保留原来的列值。所以我给总表加了一个自增的RowIndex因为筛选后的行号可能不是连续的private void BindPage() { int start (currentPage - 1) * pageSize; int end Math.Min(start pageSize, allData.Rows.Count); DataTable pageTable allData.Clone(); for (int i start; i end; i) { pageTable.ImportRow(allData.Rows[i]); } dataGridView.DataSource pageTable; UpdatePageStatus(); }这里有一个很重要的细节ImportRow之后不光是显示列正确行里的数据类型、主外键信息也会保留后面操作单元格值不会莫名其妙的类型不匹配。3.4 首页、上一页、下一页、末页每个按钮的边界条件我写的四个按钮在点击前都要做边界判断不然用户点到第1页还往前翻程序会出丑陋的数组越界。private void btnFirst_Click(object sender, EventArgs e) { currentPage 1; BindPage(); } private void btnPrev_Click(object sender, EventArgs e) { if (currentPage 1) { currentPage--; BindPage(); } } private void btnNext_Click(object sender, EventArgs e) { if (currentPage totalPages) { currentPage; BindPage(); } } private void btnLast_Click(object sender, EventArgs e) { currentPage totalPages; BindPage(); }还有一个容易被忽略的边界场景用户在每页20条的状态下单页还剩3条结果把pageSize改成50这时候当前页是第6页但按新pageSize算下来总共只有3页currentPage就会越界。所以我每次切换pageSize之后都要检查一下private void cboPageSize_SelectedIndexChanged(object sender, EventArgs e) { pageSize int.Parse(cboPageSize.SelectedItem.ToString()); if (currentPage totalPages) { currentPage totalPages; } BindPage(); }跳页文本框的逻辑是同样的思路用户输入一个大于totalPages的数时我直接把currentPage置为totalPages不要让用户看到一条多余的错误提示。3.5 页大小切换后索引越界一个很容易漏掉的细节可能有人觉得pageSize是下拉框里选出来的固定值怎么可能会越界。但在业务里用户完全有可能先把表格筛选出少量数据再把pageSize从20改成50。筛选前totalPages是200页筛选后totalPages变成4页如果currentPage还是5BindPage里计算出来的start就已经超出总行数了。我在BindPage里加了一个兜底if (totalPages 0) { dataGridView.DataSource null; return; } if (currentPage totalPages) { currentPage totalPages; }这个判断放在每次BindPage的开头可以把所有入口的情况都覆盖掉。后来中心思想就一句话分页控件写的不是翻页逻辑而是“如何保证currentPage永远合法”。4. 搜索、排序、勾选状态分页之后冒出来的三大坑4.1 搜索结果和分页组合先筛选后分页别先分页后筛选内存分页一旦和搜索框组合顺序就变得至关重要。正确的做法是先用搜索条件从allData里筛出结果集再对这个结果集做分页。我见过同事把筛选写在了分页之后结果用户搜“苹果”点下一页后下一页显示的是所有商品而不再是筛选结果。原因就是筛选发生在pageTable上这个PageTable本来就是当前页的20条根本筛不出其他页里的苹果。正确的流程private DataTable GetFilteredData(string keyword) { DataTable filtered allData.Clone(); DataRow[] rows allData.Select($ProductName LIKE %{keyword}%); foreach (DataRow row in rows) { filtered.ImportRow(row); } return filtered; }然后分页状态也要重新计算totalRecords filteredData.Rows.Count; totalPages (totalRecords pageSize - 1) / pageSize; currentPage 1; BindPage();这里要提醒一句直接用字符串拼接SQL的LIKE条件在用户输入单引号时会报错最好是参数化查询或者先把单引号替换掉。在项目里用DataTable.Select时我都是先处理一下特殊字符再丢进去。4.2 排序失效DataGridView.Sort在分页数据上根本不起作用内存分页还有一个让很多人抓狂的问题用户点列头想排序结果只是当前页这20行在排序翻下一页又恢复原样。因为DataGridView的Sort只能对它当前绑定的DataSource起作用不可能去排整个allData。我的解决方案是在需要排序的时候直接用DataView包装总表再对游标列排序然后重新分页。DataView view allData.DefaultView; view.Sort StockCount DESC; allData view.ToTable(); BindPage();但这样做有一个潜在开销每次排序都要把整个allData重新生成一份如果allData有几万行这会比较慢。折中方案是排序只针对筛选后的结果集操作而不是对全表操作。这样大多数场景下数据量已经缩小到几百行排序性能完全够用。4.3 跨页勾选数据分页后CheckBox列状态归零的常见处理进销存系统里最常见的一个需求是用户要批量审核一批订单先在第1页勾了5条翻到第2页再勾3条结果切换Page或者翻页时之前勾选的状态全没了用户崩溃你也崩溃。要说彻底解决内存分页里最稳妥的思路是在勾选事件发生时把当前行的唯一主键记录到一个全局的HashSet里。private HashSetint selectedIds new HashSetint(); private void dataGridView_CellValueChanged(object sender, DataGridViewCellEventArgs e) { if (e.RowIndex 0) return; DataGridViewRow row dataGridView.Rows[e.RowIndex]; bool isChecked Convert.ToBoolean(row.Cells[Check].Value); int id Convert.ToInt32(row.Cells[Id].Value); if (isChecked) { selectedIds.Add(id); } else { selectedIds.Remove(id); } }每次BindPage之后DataGridView重新绑定数据源我再根据selectedIds把这一页里被勾过的行重新勾上for (int i 0; i dataGridView.Rows.Count; i) { int id Convert.ToInt32(dataGridView.Rows[i].Cells[Id].Value); dataGridView.Rows[i].Cells[Check].Value selectedIds.Contains(id); }需要注意的是绑定后立即赋值可能在DataGridView还没完全刷新完时失败我通常在BindPage最后调用一个FillCheckState方法或者用BeginInvoke延迟执行。4.4 翻页后CurrentCell为空DataGridView的焦点陷阱有用户反馈翻页之后按上下方向键没有反应鼠标点某个单元格第一次点只是选中要点第二次才真正进入编辑。这是DataGridView的CurrentCell在数据源变化后变成null导致的。解决方案很简单BindPage最后把当前单元格设到一个合法位置if (dataGridView.Rows.Count 0) { dataGridView.CurrentCell dataGridView.Rows[0].Cells[1]; dataGridView.Rows[0].Selected true; }这里我建议不要选第0列因为如果第0列是复选框或主键列用户按回车时可能触发奇怪的行为。选第1列或者你希望用户关注的业务列是最稳的。5. 大表的终极解药分页之外DataGridView还藏着VirtualMode5.1 VirtualMode解决的是渲染压力不是查询压力如果你连一次全量Load都不想等内存分页就不够用了。DataGridView还有一个VirtualMode开启之后它不会主动创建所有行而是等界面需要显示某一行时才触发一个CellValueNeeded事件让你提供该行的数据。我举个例子。普通模式下你给DataGridView绑定一万行界面先创建一万个行对象。开启VirtualMode后DataSource不是必须的了DataGridView在滚动时只请求当前可见的那二十几行的数据代码里再配合内存表的行索引去取值。适用场景是你在内存里已经准备好了数据但不想让界面一次性渲染。比如你已经把十万行流水查出来放在List里又想保证界面流畅VirtualMode是正解。5.2 VirtualMode和分页怎么共存我通常选虚拟模式代替分页有人会问既分页又开VirtualMode是不是双重保险理论上可以但实际项目中如果数据已经在内存且通过VirtualMode渲染分页的意义就小了很多。VirtualMode本身已经让界面不卡了再分页反而增加翻页逻辑的复杂度。我的习惯是数据量超过五万行并且每次查询的耗时能接受比如两秒内就直接开VirtualMode不再做分页UI。分页UI保留在小数据量、业务需要一页一页审核的场景里。虚拟模式和分页的本质都是“控制DataGridView一次处理多少数据”只是控制维度不同VirtualMode控制渲染数量分页控制数据源数量。5.3 开启VirtualMode的正确姿势设置VirtualMode为true之后千万别直接绑DataSource否则会报错。要配套写CellValueNeeded事件dataGridView.VirtualMode true; dataGridView.RowCount fullData.Count;然后在事件里按索引取值private void dataGridView_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e) { if (e.RowIndex 0 || e.RowIndex fullData.Count) return; DataRow row fullData.Rows[e.RowIndex]; e.Value row[e.ColumnIndex] DBNull.Value ? : row[e.ColumnIndex].ToString(); }这个方案最别扭的地方是列头和单元格都没法自动绑定得手动映射列名。如果表结构经常变维护起来会很累。但换来的是几十万行都不卡值不值就看业务了。5.4 顺手记两个渲染优化小技巧即便不做分页、不开虚拟模式下面两个设置也能让DataGridView提速不少dataGridView.AutoSizeColumnsMode DataGridViewAutoSizeColumnsMode.DisplayedCells。这个模式只按当前显示的区域计算列宽而不是等所有行的数据都填充完才去算。dataGridView.RowHeadersWidthSizeMode DataGridViewRowHeadersWidthSizeMode.DisableResizing。禁用行头拖拽减少布局计算。这两个设置改起来很简单但很多人压根没注意过它们和性能的关系。一个DataGridView卡顿有时候不是数据查询慢而是渲染和布局计算占用主线程太多时间。6. 我在项目里踩过的坑后台加载、翻页闪烁、数据合计6.1 后台加载与翻页跨线程操作问题要命很多人在用分页之前会先想到“数据加载这么慢我放到后台线程去查”。思路是好的但WinForms的控件不能在后台线程直接赋值。实际项目里我用的模式是async/awaitprivate async void LoadDataButton_Click(object sender, EventArgs e) { btnLoad.Enabled false; try { allData await Task.Run(() LoadAllDataFromDb()); totalRecords allData.Rows.Count; totalPages (totalRecords pageSize - 1) / pageSize; currentPage 1; BindPage(); } finally { btnLoad.Enabled true; } }Task.Run跑的是耗时查询await之后回到UI线程再绑定界面这样用户不会看到界面假死还能显示一个加载动画。6.2 翻页时闪烁翻页时DataGridView会重新绑定DataSource界面会闪一下产生一种“整块刷新”的廉价感。我试过直接设置DoubleBuffered但DataGridView的这个属性是protected需要在自定义控件里暴露出来。后来我用的一个简单方案是翻页期间先用SuspendLayout暂停布局绑定完成后再ResumeLayout并Refresh。虽然不能完全消除闪烁但明显改善dataGridView.SuspendLayout(); dataGridView.DataSource pageTable; dataGridView.ResumeLayout();6.3 分页之后你还得算合计行业务里常见的场景是列表底部有一行合计显示金额总和。分页之后每页各有一个合计用户看第1页以为是全部合计翻到第2页发现数字变了立刻就会提需求“我要看所有数据的总和。”处理方式有两个一是单独写一个聚合SQL比如SUM、COUNT一次查出全部结果放在总和标签里二是内存分页时遍历allData计算。我当时是把合计放在分页条旁边随着筛选条件同步更新这样用户任何时候看到的总数都是正确口径不会因为翻页而怀疑数据。7. 最后补一刀服务端分页的SQL怎么写才不踩坑前面说过数据量大时一定要用服务端分页。这里补充一个SQL Server和MySQL都通用的注意事项分页查询一定要有稳定的ORDER BY不然翻页过程中数据可能出现重复或漏数据。SQL Server的写法是SELECT ProductId, ProductName, StockCount FROM Product ORDER BY ProductId OFFSET PageSize * (CurrentPage - 1) ROWS FETCH NEXT PageSize ROWS ONLY;MySQL的写法是SELECT ProductId, ProductName, StockCount FROM Product ORDER BY ProductId LIMIT PageSize OFFSET PageSize * (CurrentPage - 1);有几点要特别提醒OFFSET的值是“跳过的行数”不是页码。如果当前页是第3页、每页20条OFFSET就是40不是3。ORDER BY的字段最好有索引否则OFFSET越大扫描的行越多第100页的查询速度会明显慢于第1页。如果业务需要按商品名称排序而名称没有唯一索引还要在ORDER BY里加上一个辅助排序字段比如主键防止排序不稳定导致同一行出现在两页里。这套思路放进项目后18万行的商品表再也没卡过翻页之后界面秒开用户也不再抱怨“点一下卡三秒”。在写这一堆东西的时候我脑子里一直有一个画面DataGridView就像一扇窗户分页是帮你决定每一眼该往窗外看多少风景的工具。工具不是越复杂越好够用、可控、能维护才是真本事。希望这篇文章能帮你把自己的分页工具磨得顺手一点。