首页 / 资讯中心 / 文章详情

WinForm DataGridView 分页控件实战:从卡死到流畅翻页

WinForm DataGridView 分页控件实战:从卡死到流畅翻页 ★ FEATURED ARTICLE
简介这是一份面向WinForm开发者的DataGridView分页控件源码资源适合需要在桌面应用中快速实现表格数据分页展示的初中级.NET程序员。控件由作者自行封装将分页逻辑与DataGridView绑定调用方式简单可直接嵌入现有项目省去从零编写分页代码的麻烦。压缩包共45个文件约96KB包含10个cs源码文件、5个dll程序集、4个resx资源文件及2个csproj工程文件另有pdb调试符号、exe可执行程序与sln解决方案等覆盖从源码到可运行示例的完整结构。其中test示例工程与DGVPaging控件工程相互独立便于对照理解分页控件的接入方式与设计思路。目前已有223人学习下载读者可获取完整源码、工程配置与示例调用代码快速掌握分页控件的实现原理并直接复用到自己的WinForm项目中。1. 从一次卡死说起WinForm 里 DataGridView 分页到底该怎么落地去年帮一个做设备点检的团队看一个 WinForm 项目主界面就一个 DataGridView绑了六万多条历史记录。开发同学图省事直接把 DataTable 整个塞给 DataSource结果窗口一打开就白屏鼠标转圈十几秒切个 Tab 再切回来又卡一次。这不是机器差是 DataGridView 默认会把所有行都实例化并参与布局行数一上去UI 线程基本就废了。后来我们把它改成服务端分页每页只取 50 条翻页响应压到几十毫秒内存也稳了。这就是分页控件存在的意义它不改变数据总量只改变「同一时刻界面上有多少行」。对 WinForm 来说分页不是锦上添花而是数据量过千之后必须做的保命操作。这份资源围绕的就是一个可复用的 DataGridView 分页控件适合正在用 WinForm 做管理端、报表端、设备台账的开发者尤其是那些数据源来自数据库、接口或本地大表又不想自己从零写翻页逻辑的人。下面我按「它怎么组织、怎么接数据、怎么避坑、怎么调优」的顺序拆开讲。2. 分页控件的结构拆解从数据源到界面刷新2.1 分页的三种模式与选型理由在 WinForm 里做 DataGridView 分页常见做法有三类选错了后面全是返工。第一种是内存分页。一次性把全部数据读进 DataTable 或 List翻页时只把当前页那一段赋给 DataSource。优点是实现快翻页不查库缺点是数据量一大首次加载就爆内存而且数据实时性差。适合数据量稳定在几千条以内、且不常变的场景比如配置项列表。第二种是服务端分页。每次翻页都带pageIndex和pageSize去查数据库只取当前页。优点是内存占用恒定数据实时缺点是要改 SQL还要额外查一次总数。适合几万到几百万条的主数据表也是我一般推荐的做法。第三种是虚拟模式VirtualMode。DataGridView 自己开VirtualMode true通过CellValueNeeded事件按需喂数据。它不关心你数据从哪来只在你滚动到某一行时才问你要值。优点是滚动流畅、内存极低缺点是不能直接用 DataSource 绑定排序、编辑都要自己接管代码量最大。这份分页控件走的是第二种为主、第一种兜底的路线对外暴露统一的翻页接口内部根据你传入的数据提供者决定是查库还是切内存。选它的理由很直接——大多数 WinForm 业务系统的瓶颈都在「一次拉太多」而不是「翻页太频繁」。2.2 控件核心成员与数据流一个能用的分页控件至少要有这几块页码状态、每页条数、总记录数、翻页按钮组、以及一个「取第 N 页数据」的回调。下面是我常用的一个精简骨架去掉了皮肤和动画只保留逻辑。public class PagerController { // 当前页码从 1 开始避免和数据库 OFFSET 的 0 基混淆 public int PageIndex { get; private set; } 1; // 每页条数默认 50太小翻页累太大失去分页意义 public int PageSize { get; set; } 50; // 总记录数由数据提供者回填 public int TotalCount { get; private set; } // 总页数向上取整注意除零 public int PageCount TotalCount 0 ? 1 : (TotalCount PageSize - 1) / PageSize; // 取数回调输入页码和页大小输出当前页数据和总数 private readonly Funcint, int, (IListobject rows, int total) _fetch; public PagerController(Funcint, int, (IListobject, int) fetch) { _fetch fetch ?? throw new ArgumentNullException(nameof(fetch)); } // 刷新当前页绑定到 DataGridView public void Bind(DataGridView grid) { var (rows, total) _fetch(PageIndex, PageSize); TotalCount total; grid.DataSource rows; // 只喂当前页 grid.ClearSelection(); } public void Next() { if (PageIndex PageCount) return; // 边界保护防止越界查询 PageIndex; } public void Prev() { if (PageIndex 1) return; PageIndex--; } }逻辑说明_fetch是唯一的数据入口把「怎么取数」和「怎么翻页」解耦。Bind每次只把当前页的rows赋给 DataSourceDataGridView 永远只看到PageSize条。参数上PageSize建议 20 到 100低于 20 用户点得烦高于 200 又接近全量加载。PageCount里对TotalCount 0做了保护否则除零会直接抛异常这是新手最容易翻车的地方。2.3 把控件接到真实数据源上面是骨架真正落地要把它接到数据库。以常见的 SQL 查询为例服务端分页的关键是「查当前页」和「查总数」两条语句分开走。// 假设 conn 是已打开的 SqlConnection PagerController pager null; (IListobject rows, int total) FetchPage(int pageIndex, int pageSize) { int offset (pageIndex - 1) * pageSize; // 页码转偏移量 // 第一条只取当前页ORDER BY 必须稳定否则翻页会重复或丢行 var sqlPage SELECT Id, Name, Status, CreatedAt FROM DeviceLog ORDER BY Id DESC OFFSET offset ROWS FETCH NEXT size ROWS ONLY; var list new Listobject(); using (var cmd new SqlCommand(sqlPage, conn)) { cmd.Parameters.AddWithValue(offset, offset); cmd.Parameters.AddWithValue(size, pageSize); using (var reader cmd.ExecuteReader()) { while (reader.Read()) { list.Add(new { Id reader.GetInt32(0), Name reader.GetString(1), Status reader.GetString(2), CreatedAt reader.GetDateTime(3) }); } } } // 第二条查总数条件要和上面完全一致否则页数对不上 using (var cmd new SqlCommand(SELECT COUNT(1) FROM DeviceLog, conn)) { int totalCount (int)cmd.ExecuteScalar(); return (list, totalCount); } } pager new PagerController(FetchPage); pager.Bind(dataGridView1);逻辑说明OFFSET ... FETCH NEXT是 SQL Server 2012 以后的标准分页写法MySQL 用LIMIT offset, sizeSQLite 用LIMIT size OFFSET offset思路一致。参数上ORDER BY必须选一个唯一或近似唯一的列比如主键否则同一时刻翻两页可能看到重复数据。COUNT语句的WHERE条件要和分页查询保持一致很多人只改了一边结果总页数虚高翻到最后一页是空的。提示如果数据表很大COUNT(1)本身也会慢可以考虑用近似总数或缓存总数但要在界面上说明「约 N 条」避免用户较真。3. 翻页交互与状态同步按钮、页码与键盘操作3.1 按钮组的绑定与禁用逻辑分页控件光有逻辑不够界面上得让用户知道「现在在哪、能不能翻」。常见做法是放四个按钮首页、上一页、下一页、末页再加一个「第 X / Y 页」的标签。绑定的时候每次Bind之后要同步刷新按钮的Enabled状态。void RefreshButtons() { btnFirst.Enabled pager.PageIndex 1; btnPrev.Enabled pager.PageIndex 1; btnNext.Enabled pager.PageIndex pager.PageCount; btnLast.Enabled pager.PageIndex pager.PageCount; lblPage.Text $第 {pager.PageIndex} / {pager.PageCount} 页共 {pager.TotalCount} 条; } // 按钮事件里统一走「改状态 - 重新绑定 - 刷新按钮」 private void btnNext_Click(object sender, EventArgs e) { pager.Next(); pager.Bind(dataGridView1); RefreshButtons(); }逻辑说明Enabled的判断依据是PageIndex和PageCount的比较第一页时「首页/上一页」灰掉最后一页时「下一页/末页」灰掉。参数上lblPage的文案把总数也带上用户心里有底。这里有个细节Bind和RefreshButtons的顺序不能反否则按钮状态会慢一拍看起来像卡了。3.2 每页条数切换与页码重置用户经常想「一页看 100 条」所以每页条数最好做成下拉框。切换PageSize时页码必须重置回第 1 页否则会出现「第 5 页只剩 3 条」的尴尬。private void cmbPageSize_SelectedIndexChanged(object sender, EventArgs e) { if (pager null) return; pager.PageSize int.Parse(cmbPageSize.SelectedItem.ToString()); // 关键页大小变了原来的页码可能越界强制回第一页 pager.ResetToFirst(); pager.Bind(dataGridView1); RefreshButtons(); }逻辑说明ResetToFirst就是把PageIndex置回 1需要在PagerController里补一个公开方法。参数上下拉框一般给 20、50、100、200 四档再多用户也选不过来。这个重置动作是血泪经验——不重置的话用户从 200 条切到 20 条还停在第 10 页实际已经超出总页数界面直接空白。3.3 键盘与滚轮的边界处理DataGridView 默认支持方向键和滚轮但在分页模式下滚轮滚到底不应该自动翻页否则用户会误以为数据丢了。常见做法是拦截Scroll事件或者干脆把ScrollBars设为Vertical并接受「本页内滚动」。如果确实想做「滚到底自动加载下一页」那本质是无限滚动不是分页两者不要混在一个控件里。另外dataGridView1.ClearSelection()在每次Bind后调用能避免翻页后残留上一页的选中行导致用户误操作。这个细节很小但不做的话批量删除类功能很容易删错数据。4. 避坑与排查分页控件最常见的五个翻车点4.1 翻页后数据重复或丢失现象从第 1 页翻到第 2 页发现某条记录在第 1 页和第 2 页都出现了或者中间少了一条。原因ORDER BY用的列不唯一比如按Status排序而Status只有几个值数据库在分页时对相同值的行没有稳定顺序两次查询返回的顺序可能不同。解决ORDER BY必须带上唯一列通常是主键比如ORDER BY Status, Id。如果业务上必须按时间排就用ORDER BY CreatedAt DESC, Id DESC兜底。4.2 总页数算出来多一页现象总数 100 条每页 50 条界面显示 3 页翻到第 3 页是空的。原因PageCount用了TotalCount / PageSize 1当整除时多算了一页。解决用向上取整公式(TotalCount PageSize - 1) / PageSize并对TotalCount 0单独返回 1。这个公式在整数运算下是安全的不会引入浮点误差。4.3 翻页时界面闪烁现象每次点下一页DataGridView 整个重绘闪得眼睛疼。原因直接替换DataSource会触发完整的重绘和布局。解决翻页前调用SuspendLayout()绑定后ResumeLayout()如果还闪可以开双缓冲通过反射设置DoubleBuffered true或者自定义一个继承 DataGridView 的类在构造函数里开双缓冲。注意双缓冲对大数据量有轻微内存代价但通常可接受。4.4 数据源是 List 时排序失效现象内存分页模式下点列头排序结果只排了当前页。原因DataSource是ListT时DataGridView 的自动排序只作用于已绑定的那一段数据不是全量。解决要么改用BindingSource包一层并自己实现排序要么在排序事件里对全量数据排完再重新分页。服务端分页则把排序字段传给 SQL让数据库排。4.5 异步查询时页码错乱现象快速连点下一页界面停在第 3 页但数据是第 5 页的。原因多次异步查询并发返回后发的请求先回来覆盖了先发的结果。解决给每次查询带一个自增的requestId回调时只接受最新requestId的结果或者翻页期间禁用按钮等本次绑定完成再放开。前者体验更好后者实现更简单。注意如果用的是async/await记得在Bind里await查询并在await前后检查PageIndex是否已被用户改掉否则一样会错位。5. 进阶技巧把分页控件做成可复用组件5.1 用泛型回调替代 object 装箱前面骨架里用了IListobject实际项目里更推荐泛型避免值类型装箱和强制转换。public class PagerControllerT { public int PageIndex { get; private set; } 1; public int PageSize { get; set; } 50; public int TotalCount { get; private set; } public int PageCount TotalCount 0 ? 1 : (TotalCount PageSize - 1) / PageSize; private readonly Funcint, int, (IListT rows, int total) _fetch; public PagerController(Funcint, int, (IListT, int) fetch) _fetch fetch; public void Bind(DataGridView grid) { var (rows, total) _fetch(PageIndex, PageSize); TotalCount total; grid.DataSource rows; } public void ResetToFirst() PageIndex 1; public void Next() { if (PageIndex PageCount) PageIndex; } public void Prev() { if (PageIndex 1) PageIndex--; } }逻辑说明泛型版把T交给调用方决定DeviceLog、Order、User都能用同一个控件。参数上_fetch的返回类型是元组C# 7.0 以上都支持老项目可以改成out int total。这样一改DataGridView的列自动生成也能正确识别属性类型日期列不会变成字符串。5.2 缓存总数减少 COUNT 查询如果数据变化不频繁可以把TotalCount缓存几秒避免每次翻页都查一次总数。private int _cachedTotal -1; private DateTime _cacheTime DateTime.MinValue; private readonly TimeSpan _cacheTtl TimeSpan.FromSeconds(5); int GetTotalCount(SqlConnection conn) { if (_cachedTotal 0 DateTime.Now - _cacheTime _cacheTtl) return _cachedTotal; using (var cmd new SqlCommand(SELECT COUNT(1) FROM DeviceLog, conn)) { _cachedTotal (int)cmd.ExecuteScalar(); _cacheTime DateTime.Now; return _cachedTotal; } }逻辑说明_cacheTtl控制缓存时长5 秒是个折中值既能挡住连续翻页的重复查询又不会让用户看到太旧的总数。参数上如果业务要求强一致就把 TTL 设为 0退化成每次都查。这个技巧在数据量百万级、COUNT 本身要几百毫秒时特别有用。5.3 验证分页正确性的三个检查点写完控件别急着交付我一般会走这三步验证检查点操作预期边界页翻到第 1 页和第 N 页按钮正确禁用数据非空页大小切换从 50 切到 20页码重置为 1总数不变排序后翻页点列头排序再翻页顺序稳定无重复无丢失这三步能挡住八成以上的低级 bug。尤其是排序后翻页很多人只测了默认顺序一排序就露馅。5.4 一个我踩过的坑早些年我图省事把PageSize直接绑到 DataGridView 的PageSize属性上以为是一回事。结果那个属性是打印分页用的跟数据分页毫无关系翻页逻辑完全没生效还查了半天。从那以后我每次接分页控件都强制先确认「页码状态、取数回调、按钮刷新」这三样是不是各自独立、各司其职再去看界面。这个习惯帮我省了不少后悔药。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站