简介:面向Winform开发者的分页控件实战资源,演示如何结合SQL数据库实现大量数据的高效分页浏览,避免一次性加载数据造成内存压力与界面卡顿。包内共65个文件,压缩后约218KB,以C#源码(20个.cs)与窗体资源(8个.resx)为主体,同时包含exe可执行程序、dll动态库、配置文件、升级报告及gif示意图等;目录划分了bin、obj、Debug、Release等标准工程结构,并附有项目说明文本与程序集信息,便于直接打开工程查看或集成复用。示例工程围绕PagerControl分页控件展开,涉及StartForm、Form1、Form2多个窗体,覆盖选择分页控件、连接SQL数据库、数据绑定、处理分页事件、通过OFFSET/FETCH NEXT获取当前页数据、利用索引与存储过程优化性能、错误处理等关键环节,并实现了上一页/下一页等完整导航逻辑,同时附带最新Asp.Net源码下载链接,便于拓展学习。已有836人学习下载,适合需要快速实现分页功能或深入理解Winform与SQL数据交互的中初级开发者。
1. 别急着用 DataGridView 自带分页:带 SQL 数据库的 WinForm 分页控件到底解决什么
在 WinForm 项目里做数据列表,最常见的手法就是把 DataTable 直接丢给 DataGridView,然后 DataGridView 的假分页看起来挺像回事。但等你一张表到了几十万行,用户敲一下筛选条件,界面能卡到标题栏都拖不动。真正好用的分页控件,不是把数据藏在控件里慢慢翻,而是让 SQL 数据库只返回当前页那一小撮数据。
我这个方案的做法很清楚:自己做一个通用分页控件,配合 SQL 端的 OFFSET FETCH 或 ROW_NUMBER 分页查询,一个方法接一个事件,把它塞进任何 WinForm 项目里都能跑。适合谁?适合手里攒了一批 WinForm 老项目的维护者,也适合新项目不想重复写翻页逻辑的开发者。读完你能得到一套完整可复制的代码思路,包括控件自绘、SQL 写法、参数化查询和几个我踩过的大坑。
2. 分页控件的两个基础:先定数据契约,再写 SQL 分页
2.1 数据契约先行:先定 PageChanged 事件和加载方法
写分页控件之前,我先说一个血泪经验:千万别先画界面再定接口。分页控件最难的部分不是画几个按钮,而是它和业务窗体之间的数据通道。如果通道没定好,后面每接一个页面都要改控件源码,这控件就成了维护负担。
我一般会在控件里定义一个 PageChanged 事件,事件参数带上当前页码和每页行数:
public class PageChangedEventArgs : EventArgs { public int CurrentPage { get; } public int PageSize { get; } public PageChangedEventArgs(int currentPage, int pageSize) { CurrentPage = currentPage; PageSize = pageSize; } }事件参数类只有两个只读属性,构造时赋值,后面没人能改。这个做法的好处是:业务窗体只需要订阅 PageChanged,在事件处理方法里重新查数据库、重新绑定,完全不用关心控件内部怎么画页码。
有了事件之后,控件还需要暴露几个公开属性:PageSize(每页行数)、RecordCount(总行数,由外部赋值)、PageCount(总页数,由 RecordCount 和 PageSize 计算得到)、CurrentPage(当前页)。这些属性的唯一作用就是告诉控件的绘制逻辑该在哪个位置显示什么数字。
外部调用的最小流程应该是:窗体加载时先查总记录数,把它赋给控件的 RecordCount,再把第一页数据查出来绑定到 DataGridView。用户点页码按钮触发 PageChanged,窗体在里面重新查当前页数据并刷新列表。这样控件的职责边界非常清晰——它只负责“显示页码”和“通知外部翻页”,数据查询永远是窗体的责任。
2.2 SQL 分页的三种写法:ROW_NUMBER、OFFSET FETCH 和临时表
有了事件契约,下一步就是让 SQL 数据库只吐出当前页的数据。这是整个方案的核心,很多翻车现场就出在这里。我按 SQL Server 的兼容性和性能,把三种写法都给出来,按你自己的数据库版本选就行。
第一种是 ROW_NUMBER 写法,SQL Server 2005 以上都能用:
DECLARE @PageIndex INT = 3; DECLARE @PageSize INT = 10; SELECT * FROM ( SELECT t.*, ROW_NUMBER() OVER (ORDER BY t.CreateTime DESC) AS RowNum FROM dbo.Orders AS t ) AS p WHERE p.RowNum BETWEEN (@PageIndex - 1) * @PageSize + 1 AND @PageIndex * @PageSize;逻辑说明:内层查询给每行按 CreateTime 倒序编一个行号,外层根据页码截取行号区间。BETWEEN 的两端要仔细算:第 3 页每页 10 行,取的是行号 21 到 30。参数说明:@PageIndex 从 1 开始,不是从 0,这个约定一定要在控件里统一。ORDER BY CreateTime 后面的字段选择很关键,下面第 5 章会专门讲字段不唯一时出现的翻页错乱。
第二种是 OFFSET FETCH 写法,SQL Server 2012 以上的版本推荐用它:
DECLARE @PageIndex INT = 3; DECLARE @PageSize INT = 10; SELECT * FROM dbo.Orders ORDER BY CreateTime DESC OFFSET (@PageIndex - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY;逻辑说明:先按排序字段定好顺序,然后跳过前面 2 页共 20 行,再取接下来 10 行。OFFSET 和 FETCH 的顺序不能反,很多人在写的时候把 FETCH NEXT 放前面,直接报语法错。参数说明:OFFSET 的运算结果必须是整数表达式,所以在 C# 里传参数时直接传 (@PageIndex - 1) * @PageSize 和 @PageSize 两个独立参数,比让 SQL 自己算一整条表达式更容易排查问题。
第三种是临时表写法,适合排序条件极其复杂的场景:
DECLARE @PageIndex INT = 3; DECLARE @PageSize INT = 10; SELECT ROW_NUMBER() OVER (ORDER BY t.CreateTime DESC) AS RowNum, t.* INTO #temp_paged FROM dbo.Orders AS t; SELECT * FROM #temp_paged WHERE RowNum BETWEEN (@PageIndex - 1) * @PageSize + 1 AND @PageIndex * @PageSize; DROP TABLE #temp_paged;逻辑说明:先把编好号的全量数据放进临时表,再按行号截取当前页。临时表方案的好处是,如果后面还要基于相同结果集做统计,可以直接复用。代价是每次翻页都要建临时表,在高并发环境里会加重 tempdb 负担,所以我只在排序字段特别复杂的时候用,平时不碰它。参数说明:这个写法里 RowNum 的 BETWEEN 区间和第一种写法完全一致,控件端逻辑可以复用。
2.3 总记录数查询:分页控件的另一半
一个完整的分页控件,光有当前页数据是不够的。你总得知道一共有多少页,不然用户翻到最后一页都不知道还有没有下一页。这个总记录数决定了 PageCount 的计算,直接影响控件的页码显示。
SELECT COUNT(1) AS RecordCount FROM dbo.Orders WHERE CreateTime >= '2024-01-01';逻辑说明:这是一个独立的查询,和分页查询是两条 SQL,不是一条。很多人想一条 SQL 同时返回数据和总数,用窗口函数嵌套去做,结果数据量大之后性能反而恶化。常规做法是分开查,一条简单的 COUNT 走索引扫描,一条分页查按需返回,各干各的活。参数说明:WHERE 条件必须和分页查询里的 WHERE 条件保持一致,不然总页数和实际数据对不上,用户会在最后一页看到空白列表。
这套“事件契约 + 分页 SQL + 总记录数查询”的三个基础立住之后,控件本身的绘制和交互就变得很纯粹了,这就是第 3 章要做的事。
3. 自绘分页控件:把页码区域做成可复用 UserControl
3.1 控件骨架:继承 UserControl,把属性当配置项
分页控件的物理形态是一个 UserControl,它内部由若干 Button、Label 和一个 TextBox 拼装而成。直接拖最原始的 Button 控件肯定是能用的,但你会发现样式特别生硬,后来我干脆全部自绘,这样每个项目的主题色、圆角、字体都能统一走一套配置。
最小骨架是这样:
public partial class PagerControl : UserControl { private Button btnFirst; private Button btnPrev; private Button btnNext; private Button btnLast; private TextBox txtJump; private Label lblInfo; public int PageSize { get; set; } = 20; public int CurrentPage { get; private set; } = 1; public int RecordCount { get; set; } public int PageCount => RecordCount == 0 ? 0 : (int)Math.Ceiling((double)RecordCount / PageSize); public event EventHandler<PageChangedEventArgs> PageChanged; public PagerControl() { InitializeControls(); } }逻辑说明:CurrentPage 只有属性没有公开 setter,外部代码不能直接把页码改掉,只能通过 MoveToPage 方法或点击按钮来翻页。这样的好处是翻页逻辑全部收敛在控件内部,不会出现业务代码把 CurrentPage 改成一个负数的情况。PageCount 是只读计算属性,RecordCount 和 PageSize 有任何一边变化,页码都会自动重算。参数说明:PageSize 默认值我设 20,这个值和大多数表格的视觉密度比较匹配,如果你们的列表字段多,可以改成 15,翻页次数少一点,每行更宽一点。
3.2 计算页码:当前页前后各放几页这个算法要写对
页码区域是所有分页控件里最容易写错的地方。最典型的错误是:总页数 100 页,控件把 1 到 100 全部画出 100 个数字按钮,铺满整整一排。正确做法是只展示当前页前后各两页,再加首尾页和一个省略号。
private List<int> GetVisiblePageNumbers() { List<int> pages = new List<int>(); int start = Math.Max(1, CurrentPage - 2); int end = Math.Min(PageCount, CurrentPage + 2); if (start > 1) { pages.Add(1); if (start > 2) pages.Add(-1); } for (int i = start; i <= end; i++) { pages.Add(i); } if (end < PageCount) { if (end < PageCount - 1) pages.Add(-2); pages.Add(PageCount); } return pages; }逻辑说明:这段代码里 -1 和 -2 是省略号占位符,绘制阶段遇到负数就画成“…”。关键点在于边界判断:如果当前页是第 1 页,start 被 Math.Max 拉回 1,前面不会出现多余的省略号;如果当前页是第 100 页,end 被 Math.Min 拉回 100,后面也不会画多余的东西。参数说明:左右各 2 页这个数字是项目里调出来的平衡值,如果你用 3 个,小屏上会显得拥挤,用 1 个又会让用户失去空间感。
有了页码列表之后,真正的交互逻辑就落在 MoveToPage 方法里:
public void MoveToPage(int page) { if (page < 1) page = 1; if (page > PageCount) page = PageCount; if (page == CurrentPage) return; CurrentPage = page; PageChanged?.Invoke(this, new PageChangedEventArgs(CurrentPage, PageSize)); Invalidate(); }逻辑说明:MoveToPage 做两层边界保护,然后判断页码是否真的变化,变化了才触发事件和重绘。这个保护极其重要,用户快速连点“下一页”按钮时,事件会被压缩成有效的那几条,不会产生重复查询。Invalidate 调用让整个控件重绘,页码高亮和按钮状态都会刷新。
3.3 绘制按钮与界面美化:FlatStyle、颜色与字体
控件界面我做成了自绘风格,全部按钮用 FlatStyle.Flat 去掉默认的 Windows 立体边框,再根据状态切换颜色。这么做的好处是整体观感能贴合现代 WinForm 的扁平化改造,也就是常说的 WinForm 界面美化,不用再引入第三方 UI 组件库。
protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g = e.Graphics; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias; foreach (var page in GetVisiblePageNumbers()) { Rectangle rect = GetPageButtonRect(page); bool isCurrent = page == CurrentPage; Color bg = isCurrent ? Color.FromArgb(79, 122, 199) : Color.FromArgb(245, 245, 245); Color fg = isCurrent ? Color.White : Color.FromArgb(64, 64, 64); using (SolidBrush brush = new SolidBrush(bg)) { g.FillRectangle(brush, rect); } string text = page < 0 ? "..." : page.ToString(); TextRenderer.DrawText(g, text, this.Font, rect, fg, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); } }逻辑说明:OnPaint 里先把之前画的内容清掉,然后遍历可显示页码,逐个绘制背景色和文字。用 TextRenderer.DrawText 而不是 g.DrawString,是为了让文字在高 DPI 缩放的屏幕上依然清晰。参数说明:当前页背景色用的 RGB(79,122,199) 是一种偏商务的蓝色,如果你在暗色主题的项目里,可以把这段颜色收敛成控件属性,让使用方自定义,而不是写死在内部。
提示:自绘按钮不需要复杂的圆角路径或阴影,那会给 WinForm 的刷新带来额外开销。保持简洁的矩形加颜色变化,翻页响应快,视觉上也不会比第三方控件差。
4. 把控件接到 SQL 数据库:从连接串到数据填充
4.1 最小接入:SqlConnection 加参数化查询填充 DataGridView
控件本身不碰数据库,这是设计原则。但为了让新手能一次跑通,我给出一个完整的业务窗体接入代码。这个窗体里放了一个 DataGridView 和我们的 PagerControl,窗体加载时先查总数,再查第一页。
private void LoadPage(int pageIndex, int pageSize) { string connStr = "Server=localhost;Database=SalesDB;Integrated Security=True;"; string countSql = "SELECT COUNT(1) FROM dbo.Orders;"; string pageSql = @" SELECT * FROM dbo.Orders ORDER BY CreateTime DESC OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY;"; using (SqlConnection conn = new SqlConnection(connStr)) { conn.Open(); using (SqlCommand cmdCount = new SqlCommand(countSql, conn)) { pager.RecordCount = (int)cmdCount.ExecuteScalar(); } using (SqlCommand cmdPage = new SqlCommand(pageSql, conn)) { cmdPage.Parameters.AddWithValue("@Offset", (pageIndex - 1) * pageSize); cmdPage.Parameters.AddWithValue("@PageSize", pageSize); using (SqlDataAdapter adapter = new SqlDataAdapter(cmdPage)) { DataTable dt = new DataTable(); adapter.Fill(dt); dataGridView1.DataSource = dt; } } } }逻辑说明:这段代码先查 COUNT,再查当前页,两个查询共用一个连接,减少了建立连接的开销。分页查询用 SqlParameter 而不是拼字符串,这个事不能偷懒。OFFSET 参数传的是 (pageIndex - 1) * pageSize 的整数结果,不是表达式本身,这样 SQL 执行计划更容易命中缓存的参数化计划。参数说明:如果项目里 ORM 用的是 EF Core,这个逻辑本质一样,区别在于把 SQL 换成 LINQ 的 Skip/Take,但总记录数的 COUNT 还是要单独发一次查询,ORM 不会帮你自动做。
4.2 多表联查:JOIN 之后的分页 SQL 要怎么写
实际项目里极少只查一张表。订单关联客户、商品关联分类,这都是常见场景。多表 JOIN 之后的分页,重点在于不要把分页子查询做成全表扫描。
DECLARE @PageIndex INT = 1; DECLARE @PageSize INT = 20; SELECT t.OrderId, t.OrderNo, c.CustomerName, c.Phone FROM ( SELECT o.Id AS OrderId, o.OrderNo, o.CustomerId, o.CreateTime, ROW_NUMBER() OVER (ORDER BY o.CreateTime DESC) AS RowNum FROM dbo.Orders AS o WHERE o.Status = 1 ) AS t INNER JOIN dbo.Customers AS c ON t.CustomerId = c.Id WHERE t.RowNum BETWEEN (@PageIndex - 1) * @PageSize + 1 AND @PageIndex * @PageSize ORDER BY t.CreateTime DESC;逻辑说明:核心技巧是先在子查询里只查主表并编号,然后再 JOIN 其他表。如果反过来先 JOIN 再分页,SQL 引擎要对两张表的笛卡尔积排序编号,数据量一大就非常慢。这样的写法保证 ROW_NUMBER 只作用在主表的最小投影上,JOIN 只发生在当前页那一小撮数据上。参数说明:这里的 @PageIndex 和 @PageSize 依然走参数化,Status 条件在子查询内层过滤,不会把过滤后的总行数算错。
这种写法有个细节要留意:如果子查询里 SELECT 了 o.CustomerId,但最终结果不需要这个字段,也要保留它,因为 JOIN 的关联条件要用。等外层 JOIN 完成后再在外层投影里把不需要的字段去掉。
4.3 用 Dapper 接入:依赖注入和更薄的调用层
老项目用 SqlDataAdapter 很顺手,但新项目很多已经用 Dapper 了。Dapper 的好处是映射到实体类、配合依赖注入,代码比 DataTable 时代干净一个量级。
public class OrderRepository { private readonly IDbConnection _conn; public OrderRepository(IDbConnection conn) { _conn = conn; } public PagedResult<OrderInfo> GetOrderPage(int pageIndex, int pageSize) { int offset = (pageIndex - 1) * pageSize; var sql = @" SELECT o.Id, o.OrderNo, o.CreateTime, c.CustomerName FROM ( SELECT o.Id, o.OrderNo, o.CreateTime, o.CustomerId, ROW_NUMBER() OVER (ORDER BY o.CreateTime DESC) AS RowNum FROM dbo.Orders AS o ) AS t INNER JOIN dbo.Customers AS c ON t.CustomerId = c.Id WHERE t.RowNum BETWEEN @Start AND @End;"; var list = _conn.Query<OrderInfo>(sql, new { Start = offset + 1, End = pageIndex * pageSize }).ToList(); int total = _conn.ExecuteScalar<int>("SELECT COUNT(1) FROM dbo.Orders;"); return new PagedResult<OrderInfo>(list, total); } }逻辑说明:Dapper 的匿名对象参数替代了 SqlParameter 的手工拼装,代码短了很多。COUNT 查询依然单独执行,没有和分页查询混在一起。PagedResult 是一个简单的泛型包装类,里面包含 List 和 TotalCount,这个包装可以直接被 PagerControl 消费。参数说明:Start 和 End 的计算方式要和 SQL 里的 ROW_NUMBER 区间保持一致,Start 是 offset+1,End 是 pageIndex*pageSize,少了任何一边都会导致首尾行丢失。
依赖注入的场景下,IDbConnection 从构造函数注入,仓储类可以被窗体共享。这样分页控件的事件处理器里只需要调用 repo.GetOrderPage 再绑定到 DataGridView,UI 层不再出现 SQL 字符串。Dapper 的这些优势挺明显,值得在成熟项目里逐步切过去。
5. 分页控件避坑清单:这五个坑我替你先踩了
5.1 翻页后 DataGridView 滚到了中层数据,用户找不到刚才看的那一行
现象:用户在 DataGridView 里看第 5 行,点下一页,数据刷新了,但滚动条停在新数据的中间位置,视觉上特别跳脱。
原因:DataGridView 绑定新数据源后,第一行不一定是显示在视图内的那一行。它的垂直滚动位置保持旧状态,有时会直接落在新数据的中间甚至底部。
解决:翻页绑定完成之后手动把视图拉回顶部。
dataGridView1.DataSource = dt; if (dataGridView1.Rows.Count > 0) { dataGridView1.FirstDisplayedScrollingRowIndex = 0; dataGridView1.CurrentCell = dataGridView1.Rows[0].Cells[0]; }这行代码的作用是把可视区域的第一个数据行固定为第 0 行。注意要在 DataSource 赋值之后调用,不能提前。绑定之前控件里还是旧数据,调用 FirstDisplayedScrollingRowIndex 会报错。
5.2 SQL Server 版本不满足 OFFSET FETCH,线上半夜报错
现象:开发环境跑得飞快,部署到客户的服务器上一点下一页就提示语法错误,错误信息接近 “Incorrect syntax near 'OFFSET'”。
原因:OFFSET FETCH 是 SQL Server 2012 才引入的语法。大量老客户的服务器还用 SQL Server 2008 R2 甚至更早版本。开发机是 2022,测试环境是 2016,都查不出这个问题,只有生产环境的旧版本暴露出来。
解决:要么在连接串层面限制不现实,要么直接退回到 ROW_NUMBER 写法。ROW_NUMBER 从 2005 年就在用了,兼容性最稳。如果你必须用 OFFSET,可以在发布前先执行 SELECT @@VERSION 检查目标服务器版本,然后再决定用哪套分页 SQL。我的做法是默认写 ROW_NUMBER 版本,只有确定目标全是 2012 以上才换 OFFSET。
5.3 PageSize 被当成字符串拼接,输入“20;DELETE”直接翻车
现象:用户在页码跳转框输入了包含分号的字符串,程序直接报 SQL 异常,严重的直接把数据删了。
原因:跳转文本框的校验没做好,回车事件里直接把 TextBox.Text 拼进 SQL。这种拼串方式在数据量小的时候也能跑,但一旦遇到恶意输入就是安全事故。
解决:先做输入校验,再走参数化。校验逻辑放在 TextBox 的 KeyPress 事件里就够,只允许数字和回车键。
private void txtJump_KeyPress(object sender, KeyPressEventArgs e) { if (e.KeyChar == (char)Keys.Enter) { e.Handled = true; if (int.TryParse(txtJump.Text.Trim(), out int targetPage)) { MoveToPage(targetPage); } txtJump.Clear(); } else if (!char.IsDigit(e.KeyChar) && e.KeyChar != (char)Keys.Back) { e.Handled = true; } }逻辑说明:Enter 键触发翻页,数字以外的字符直接被 Handled 屏蔽,Backspace 用来删字。int.TryParse 兜底,即使输错了也不会进 SQL。参数说明:这个校验只挡非法字符,真正的安全底线永远在 SQL 参数化那一层,前端校验是用户体验问题,参数化是安全问题,两层都不能省。
5.4 事件重复订阅,翻几页之后页面越来越卡
现象:第一次翻页正常,第三四次翻页之后明显变慢,每次翻页要等两三秒,操作越多越卡。
原因:窗体初始化时订阅了 PageChanged 事件,但窗体在某个业务分支里被重复实例化或重新绑定时再次订阅。每个订阅器都会触发一次数据库查询,事件链越滚越长。
解决:订阅之前先取消,或者使用单例窗体避免重复实例化。简洁的做法是这样:
pager.PageChanged -= Pager_PageChanged; pager.PageChanged += Pager_PageChanged;这个模式叫“先退订再订阅”,保证事件链里永远只有一个处理器实例。尤其是在选项卡或嵌套容器里,窗体被频繁重建的坑最容易踩这个雷。
5.5 总记录数每次全表 COUNT,大表单页要等两秒
现象:单表 500 万行,总记录数查询就要 1.5 秒,用户点一页等两秒,体验非常糟糕。
原因:COUNT(1) 这条 SQL 没有针对性的索引优化。在没有合适的覆盖索引时,SQL Server 要扫描整张表去统计行数。很多开发者在数据量小的时候没感知,等到线上数据膨胀才暴露。
解决:给计数查询的 WHERE 条件字段建合适的非聚集索引,让 COUNT 走索引统计而不是全表扫描。如果你的过滤条件不固定,那就缩小统计口径,用软删除标记和固定时间窗去做 COUNT,而不是每页都统计全表。分页控件本身不背这个锅,但它的 PageChanged 事件频率会把这个性能问题放大十倍,所以这里一定要重视。
6. 进阶:大数据量下的翻页体验与界面联动技巧
6.1 滚动加载:让鼠标滚轮也参与翻页
传统分页控件靠点击按钮翻页,用户在大表格里滚动鼠标看完当前页,还得移鼠标去找“下一页”按钮。一个值得加的小功能是:当鼠标滚轮滚动到 DataGridView 底部时,自动加载下一页。这其实就是近年网页端流行的无限滚动在 WinForm 的投影。
private void dataGridView1_MouseWheel(object sender, MouseEventArgs e) { if (e.Delta < 0) { int lastRow = dataGridView1.FirstDisplayedScrollingRowIndex + dataGridView1.DisplayedRowCount(false); if (lastRow >= dataGridView1.RowCount - 1) { pager.MoveToPage(pager.CurrentPage + 1); } } }逻辑说明:滚轮向下滚一轮就检查当前可视区域的最后一行索引,如果已经到达数据末尾,就自动翻到下一页。这个逻辑并不会误触发,因为用户还没滚到底时不会满足条件。参数说明:这个功能适合数据流式浏览的场景,但它会改变用户对“翻页”的预期,如果团队里有人不喜欢,就做成控件属性 AutoLoadNextPage 默认关闭。
6.2 虚拟模式与状态栏进度条联动
前面所有方案都是把当前页数据复制进 DataGridView,如果单页数据本身就有 1 万行,内存和绘制都有压力。这种情况可以开 DataGridView 的 VirtualMode,同时配合状态栏显示“正在加载第 X 页”的进度提示,也就是热搜里常说的 C# WinForm 状态栏与进度条联动。
虚拟模式的核心是只给 DataGridView 提供当前可见行区域的数据,滚动时才填充。配合分页控件时,一个实用的联动方案是:翻页开始时把 ToolStripStatusLabel 文本改成“正在加载…”,然后用 BackgroundWorker 在后台查询当前页,查询完成后再把状态栏恢复成“共 N 条 / 第 X 页”。这样即使分页查询耗时 500 毫秒,用户也始终有反馈,不会觉得程序卡死了。
代码写到位并不难,但虚拟模式本身是个黑匣子,CellValueNeeded 事件里经常出各种奇怪问题,调试成本不小。我给的建议是:数据量在 1 万行以下,用普通绑定模式加滚动加载就够了;单页超过 1 万行,再上虚拟模式,别为了炫技提前复杂化。
我自己的习惯是把这套分页控件当成 WinForm 项目里的标配基础设施,新开项目先实例化一个,然后把事件接上,后面所有列表页都复用同一套逻辑。数据库版本确认用 ROW_NUMBER 稳一点,用户输入校验严格一点,事件记得先退订再订阅,这三点做到位,翻车概率会小很多。希望帮到你。
本文还有配套的精品资源,点击获取