
简介面向Windows Forms开发者的分页控件实现资源整合SQL数据库数据绑定与分页查询适合需要在Winform项目中快速集成高效分页功能的初中级开发者。资源包含完整的C#源码、窗体设计文件、资源文件及可运行示例程序核心控件PagerControl支持页码导航、每页条数调整、总页数计算等常用交互并演示了ADO.NET连接SQL数据库、编写分页查询语句、处理翻页事件等关键流程。压缩包共65个文件以cs源码、resx界面资源、resources与cache编译中间文件为主另有dll、exe、pdb、gif等类型整体仅218KB结构紧凑便于直接参考或移植。已有837人学习下载适合作为Winform分页功能开发的起步模板也可据此扩展排序、搜索等自定义逻辑。1. DataGridView 一次加载 10 万行卡到 3 秒这个带 SQL 的 WinForms 分页控件怎么救场做 WinForms 报表或后台管理界面时最典型的翻车场景就是 DataGridView 直接绑定一个几十万行的 DataTable。界面打开要卡几秒随便滚动一下风扇就响用户点列头排序整个窗体直接假死。这个问题不是电脑配置能解决的本质是数据加载方式选错了客户端根本不需要一次性拿全量数据它只需要当前页那十几行。而这个标题里的分页控件就是把「按页取数据」这件事封装成一个可复用的 UI 组件再接上 SQL Server 的查询让列表类窗体不再为数据量发愁。适合正在维护老 WinForms 项目、或者新项目选型时不想引入重型框架的桌面开发从业者目标是让 DataGridView 在十万行数据下也能秒开秒翻。2. 先选对分页姿势SQL 物理分页为什么比 DataTable 全量加载更适合 WinForms2.1 内存分页与物理分页的本质差异一张 30 万行的表能说明白先厘清一个常见误区很多人以为分页是前端的事把全部数据读进 DataTable然后用dt.Rows.CastDataRow().Skip(...).Take(...)切当前页。这在几千行数据时没问题但数据量到 10 万以上就开始暴露问题。内存分页的消耗有三层第一层是 SQL 层SELECT * FROM 表会把所有行的所有列都传回客户端网络传输和 SQL Server 的查询开销全花在无用的数据上第二层是内存层DataTable 全量驻留内存按每行 200 字节估算30 万行就是 60MB 左右的托管堆占用释放不及时还会触发 GC 频繁回收第三层是 DataGridView 渲染层即使只 Display 前几十行控件内部也会对绑定源做索引遍历操作响应明显变慢。SQL 物理分页的思路刚好相反SQL Server 端只返回当前页所需的 RowCount 行数据客户端永远只持有少量数据。代价是多一条COUNT(1)查询来获取总记录数但 COUNT 走索引代价极低通常不到 10 毫秒。这里直接给一个对比表格方便选型时判断对比项DataTable 全量加载 内存分页SQL 端物理分页网络传输全量行浪费严重仅当前页行客户端内存随数据量线性增长基本恒定首次打开速度大表明显卡顿稳定在百毫秒级总页数计算从 DataTable.Rows.Count 算需要额外 COUNT排序复杂度客户端排序容易和分页打架排序写进分页 SQL适用数据量建议 5000 行以下任意量级都稳2.2 为什么「分页控件」和「查询逻辑」必须解耦我最早做分页时走了一段弯路把查询方法直接写死在控件代码里控件里用new SqlConnection(...)拼 SQL。结果就是换一个数据库连接串要改控件源码换一个查询条件也要改控件源码最后每个窗体都复制一份控件再改得面目全非。正确的结构是控件只负责「页码状态管理」和「UI 呈现」数据怎么查由外部窗体决定。控件内部维护当前页、每页行数、总页数这些状态用户点下一页时控件抛出一个事件事件参数里带上新的页码窗体拿到页码后自己去执行 SQL 查询再把结果 DataTable 赋给控件的 DataSource 属性。这个模式不需要引入任何框架一个自定义 UserControl 就能实现核心思路就两条控件不持有数据库连接和 SQL 语句它只关心页码状态窗体和数据访问层通过事件回调对接数据绑定后控件负责刷新按钮可用状态和页码文本这样控件本身成为纯 UI 组件可以丢进工具箱反复复用数据访问层单独写一个通用方法所有窗体共用。接下来的第 3 章先讲控件怎么实现第 4 章再讲 SQL 查询方法怎么接上两者通过事件这个接口咬合。3. 手写分页控件核心属性、事件、按钮状态一个都不少3.1 控件核心属性和 UI 布局PageSize、CurrentPage、TotalCount 三个状态一个都不能少先规划 UI 布局。常见做法是底部放一排控件从左到右依次是btnFirst首页、btnPrev上一页、lblPageInfo显示“第 x / y 页”、btnNext下一页、btnLast末页、lblPageSize每页、cmbPageSize每页行数下拉框。有些场景还会加一个页码输入框txtJumpPage和btnJump跳转适合总页数很多的报表。布局用 TableLayoutPanel 固定到底部 Dock窗体只留顶部和中间数据区代码省心。自定义控件用 UserControl 实现核心状态就三个属性PageSize每页行数、CurrentPage当前页码从 1 开始、TotalCount总记录数来自数据库的 COUNT 结果。TotalPage不单独存字段用计算属性实时算TotalPage (int)Math.Ceiling((double)TotalCount / PageSize)。这样 TotalCount 或 PageSize 变化时总页数自动跟着变不用额外刷新也避免手工维护状态时出现「记录数 100每页 30总页数算成 3」这种四舍五入误差。控件数据源不自己建 DataTable直接暴露一个公共属性DataSource外部把查询结果赋进来控件内部更新 DataGridView。一个最小可用的控件属性定义如下using System; using System.ComponentModel; using System.Windows.Forms; public partial class SqlPager : UserControl { private int _currentPage 1; private int _pageSize 20; private int _totalCount 0; // 当前页码从 1 开始设置后自动更新按钮状态和页码文本 [Browsable(true), DefaultValue(1)] public int CurrentPage { get _currentPage; set { _currentPage value 1 ? 1 : value; // 不允许出现第 0 页 UpdateButtonState(); UpdatePageInfo(); } } // 每页行数修改时必须重置当前页为第 1 页 [Browsable(true), DefaultValue(20)] public int PageSize { get _pageSize; set { if (_pageSize value) return; _pageSize value 1 ? 1 : value; _currentPage 1; // 每页行数变了页码肯定要重新从第 1 页开始 OnPageSizeChanged(EventArgs.Empty); UpdateButtonState(); UpdatePageInfo(); } } // 总记录数由外部在查询后赋值 [Browsable(true), DefaultValue(0)] public int TotalCount { get _totalCount; set { _totalCount value 0 ? 0 : value; if (_currentPage TotalPage) _currentPage TotalPage; // 末页边界兜底 UpdateButtonState(); UpdatePageInfo(); } } public DataTable DataSource { get; set; } // 翻页时抛给窗体的通知事件参数里带上新页码 public event EventHandler CurrentPageChanged; public SqlPager() { InitializeComponent(); cmbPageSize.Items.AddRange(new object[] { 10, 20, 50, 100, 200 }); cmbPageSize.SelectedItem 20; } private int TotalPage (int)Math.Ceiling((double)TotalCount / PageSize); }这个定义里有一个细节值得留意PageSize的 setter 里把_currentPage强制重设为 1。这个行为是刻意的因为每页行数变化意味着分页分段方式完全变了继续停留在原页码没有意义反而容易出现「第 5 页已超出总页数」的边界错误。TotalCount的 setter 也做了同样的兜底保证外部先赋 TotalCount 再赋数据时不会出现页码越界。3.2 翻页按钮事件只发通知不碰数据库按钮事件是最容易写歪的地方。很多人在控件的按钮 Click 里直接写SqlConnection然后查库这就又把控件和数据层耦合回来了。正确做法是按钮点击后只修改CurrentPage属性然后触发CurrentPageChanged事件让窗体去响应。窗体那边怎么查、查哪个表、带不带 WHERE都和控件无关。翻页按钮的事件统一挂在一个方法里处理private void btnFirst_Click(object sender, EventArgs e) { GoToPage(1); } private void btnPrev_Click(object sender, EventArgs e) { GoToPage(CurrentPage - 1); } private void btnNext_Click(object sender, EventArgs e) { GoToPage(CurrentPage 1); } private void btnLast_Click(object sender, EventArgs e) { GoToPage(TotalPage); } private void GoToPage(int page) { // 核心页码先做边界钳制再判断是否真的需要翻页 int target Math.Min(Math.Max(page, 1), TotalPage); if (target CurrentPage) return; // 已在目标页不发事件 CurrentPage target; // 通知窗体重新查数据 CurrentPageChanged?.Invoke(this, EventArgs.Empty); UpdateButtonState(); UpdatePageInfo(); } private void UpdateButtonState() { btnFirst.Enabled CurrentPage 1; btnPrev.Enabled CurrentPage 1; btnNext.Enabled CurrentPage TotalPage; btnLast.Enabled CurrentPage TotalPage; } private void UpdatePageInfo() { lblPageInfo.Text $第 {CurrentPage} / {TotalPage} 页共 {TotalCount} 条记录; }注意GoToPage里的一个细节if (target CurrentPage) return。这行能挡住重复点击导致的重复查询比如已经在第 1 页时连点「上一页」或点击当前页页码事件不会重复触发查询次数直接减半。UpdateButtonState里同时控制首页和上一页、下一页和末页的 Enabled这是为了让 UI 給用户明确反馈第一页时左侧按钮置灰最后一页时右侧按钮置灰不需要用户点了之后才发现没反应。页码显示里把「共 N 条记录」也带上实际使用中这个信息很受欢迎用户打开报表第一眼看到的就是总数据量不用再到处找汇总数。UpdateButtonState 必须在 TotalCount、PageSize 和 CurrentPage 三个 setter 里都调用一次这样才能保证外部任何状态变化都能即时反映到 UI 上。3.3 每页大小下拉框与跳转输入框两个容易被忽略的交互细节每页行数下拉框cmbPageSize的 SelectedIndexChanged 事件里要把新的值赋给PageSize属性再触发数据重新加载。但有一个交互细节很容易漏如果用户选择的是「自定义」数量而不是下拉框预设值SelectedIndexChanged 和 TextChanged 会同时触发导致 PageSize 被设置两次。所以事件里要加一个去重判断private void cmbPageSize_SelectedIndexChanged(object sender, EventArgs e) { if (cmbPageSize.SelectedItem null) return; if (int.TryParse(cmbPageSize.SelectedItem.ToString(), out int newSize)) { if (newSize ! PageSize) { PageSize newSize; PageSizeChanged?.Invoke(this, EventArgs.Empty); } } } private void txtJumpPage_KeyDown(object sender, KeyEventArgs e) { // 按回车直接跳转比点跳转按钮更顺手 if (e.KeyCode Keys.Enter) { if (int.TryParse(txtJumpPage.Text.Trim(), out int page) page 1 page TotalPage) { GoToPage(page); } else { txtJumpPage.Clear(); // 输入非法页码时清空而不是直接报错 } e.SuppressKeyPress true; // 防止 Enter 被传到父窗体触发默认按钮 } }这里有一个常见的翻车点很多人会给每个下拉框选项设置一个自定义值比如「全部」用 -1 表示然后分页查询时发现 PageSize 传了 -1SQL 直接报错。我的习惯是「全部」这类选项不在分页控件里做而是由窗体层面控制想显示全部就换一个不带分页的查询方法不要试图让 PageSize 支持负数这种伪需求。跳转输入框的另一个坑是用户输入 0 或超大数据。0 会触发 GoToPage 的钳制逻辑回到第 1 页但用户感受是「我输入 0 想回到第一页却不知道成没成」。所以在 TryParse 失败、页码越界时直接清空输入框再配合 UpdatePageInfo 更新页码文本用户一眼就能看到当前实际页。这个清空的行为比弹 MessageBox 温和得多更适合高频操作场景。4. 打通 SQL Server通用分页查询方法让控件真正「带库跑」4.1 最稳的分页 SQL 写法OFFSET-FETCH 与 ROW_NUMBER 二选一控件做好了下一步就是把数据库接上。SQL Server 2012 及以上版本里官方推荐的分页写法有两种一种是OFFSET ... ROWS FETCH NEXT ... ROWS ONLY另一种是ROW_NUMBER()窗口函数子查询。OFFSET-FETCH 写起来最直白SELECT 列1, 列2, 列3 FROM 表名 ORDER BY 主键列 -- 这一句必须有OFFSET-FETCH 不允许省略 ORDER BY OFFSET Offset ROWS FETCH NEXT PageSize ROWS ONLY;这套语法从 SQL Server 2012 开始支持简洁、可读性强执行计划也比 ROW_NUMBER 的子查询嵌套少一层。参数里Offset (PageIndex - 1) * PageSizePageSize PageSize。如果项目还在维护 SQL Server 2008 或更老的环境那就只能退回 ROW_NUMBER 写法SELECT * FROM ( SELECT 列1, 列2, 列3, ROW_NUMBER() OVER (ORDER BY 主键列) AS RowNum FROM 表名 ) AS T WHERE T.RowNum BETWEEN StartRow AND EndRow;两种写法在十万行量级下性能差距极小选型时只需看 SQL Server 版本。版本支持 OFFSET-FETCH我优先推荐它理由不仅仅是写法简洁更重要的是执行计划里少一层嵌套子查询的中间结果集数据量大时内存占用也更低一些。俩都要注意一个共性问题在ORDER BY上若没有索引深页码查询会越来越慢这个在第 5 章避坑里单独讲。4.2 通用数据访问方法传入 SQL 和参数返回 DataTable 与总记录数查询必须参数化这是和数据库交互的底线。拼接 SQL 这种事在分页场景尤其危险因为 WHERE 条件往往来自用户输入一旦拼进去就是注入面。另外为了不让每页大小变成不可控的参数PageSize也走参数化。封装一个通用方法它接收offset和pageSize两个分页参数、一条排序 SQL拼在分页查询里加上公共 WHERE 条件和对应参数列表然后返回 DataTable 和总记录数供控件直接赋值。using System; using System.Data; using System.Data.SqlClient; public static class SqlPageHelper { /// summary /// 分页查询统一入口 /// /summary /// param nameconnectionString数据库连接串/param /// param nametableName表名或视图名/param /// param nameselectColumns需要返回的列逗号分隔/param /// param namewhereSqlWHERE 子句不带 WHERE 关键字无过滤条件时传 null/空串/param /// param nameorderBySql排序字段要带上 ASC/DESC 方向/param /// param namepageIndex页码从 1 开始/param /// param namepageSize每页行数/param /// param namewhereParametersWHERE 子句对应的 SqlParameter 数组/param /// param nametotalCount总记录数out 返回/param public static DataTable GetPageData( string connectionString, string tableName, string selectColumns, string whereSql, string orderBySql, int pageIndex, int pageSize, SqlParameter[] whereParameters, out int totalCount) { int offset (pageIndex - 1) * pageSize; if (offset 0) offset 0; string countSql string.IsNullOrWhiteSpace(whereSql) ? $SELECT COUNT(1) FROM {tableName} : $SELECT COUNT(1) FROM {tableName} WHERE {whereSql}; string pageSql string.IsNullOrWhiteSpace(whereSql) ? $SELECT {selectColumns} FROM {tableName} ORDER BY {orderBySql} OFFSET Offset ROWS FETCH NEXT PageSize ROWS ONLY : $SELECT {selectColumns} FROM {tableName} WHERE {whereSql} ORDER BY {orderBySql} OFFSET Offset ROWS FETCH NEXT PageSize ROWS ONLY; var parameters new ListSqlParameter(); if (whereParameters ! null) parameters.AddRange(whereParameters); parameters.Add(new SqlParameter(Offset, SqlDbType.Int) { Value offset }); parameters.Add(new SqlParameter(PageSize, SqlDbType.Int) { Value pageSize }); using (var conn new SqlConnection(connectionString)) { conn.Open(); // 第一次查询COUNT拿总记录数用于计算总页码 using (var cmdCount new SqlCommand(countSql, conn)) { if (whereParameters ! null) cmdCount.Parameters.AddRange(whereParameters); int count (int)cmdCount.ExecuteScalar(); totalCount count; } // 第二次查询当前页数据 using (var cmdPage new SqlCommand(pageSql, conn)) { cmdPage.Parameters.AddRange(parameters.ToArray()); var adapter new SqlDataAdapter(cmdPage); var dt new DataTable(); adapter.Fill(dt); return dt; } } } }4.3 在窗体里串起来绑定数据源、刷新按钮状态、处理翻页数据访问方法有了控件有了现在需要一个窗体把两者粘起来。这个窗体的职责是构造查询参数、调用SqlPageHelper.GetPageData、把返回的 DataTable 赋给SqlPager.DataSource和DataGridView.DataSource。为了便于统一处理把「加载某一页数据」的逻辑收进一个LoadPageData()方法翻页事件里只要重算页码范围再调用它就行public partial class MainForm : Form { private string _keyword ; // 搜索关键字来自文本框 public MainForm() { InitializeComponent(); pager.CurrentPageChanged (s, e) LoadPageData(); pager.PageSizeChanged (s, e) LoadPageData(); LoadPageData(); } private void LoadPageData() { string whereSql ; SqlParameter[] whereParams null; // 有搜索关键字时拼过滤条件参数化是关键禁止直接拼接 if (!string.IsNullOrWhiteSpace(_keyword)) { whereSql ProductName LIKE kw; whereParams new SqlParameter[] { new SqlParameter(kw, SqlDbType.NVarChar, 100) { Value % _keyword % } }; } DataTable dt SqlPageHelper.GetPageData( connectionString: ConfigurationManager.ConnectionStrings[main].ConnectionString, tableName: ProductInfo, selectColumns: Id, ProductName, CategoryId, Price, Stock, whereSql: whereSql, orderBySql: Id ASC, pageIndex: pager.CurrentPage, pageSize: pager.PageSize, whereParameters: whereParams, totalCount: out int totalCount); pager.TotalCount totalCount; // 先更新总记录数按钮状态随之刷新 pager.DataSource dt; // 控件持有数据源引用 gridView.DataSource dt; // 网格绑定当前页 } }这里有一个顺序问题值得反复强调必须先赋TotalCount再赋DataSource。因为TotalCount的 setter 里会做当前页越界检查如果当前页码超出总页数它会立刻修正CurrentPage。如果顺序反了先绑定 DataSource 再赋 TotalCount用户可能在删除数据后停留在「已不存在的页码」点下一页会查到空数据UI 上还看不出原因。关于查询性能OFFSET-FETCH在十万行、有索引排序的情况下翻页查询本身通常在 10~50 毫秒级别COUNT(1)在存在统计信息时也很快。整套查询走的连接是短连接每次翻页都打开关闭连接。WinForms 桌面应用场景下这种短连接方式足够用如果数据库在同一局域网冷连接开销也只在一次 TCP 握手到几毫秒之间不值得为此专门维护长连接池。5. 分页控件接入实战避坑翻页失效、记录数翻倍、页码跳变的四条血泪经验5.1 翻页以后 DataGridView 选中行「神秘消失」接入分页最常遇到的“玄学”问题是第一次加载数据时选中行正常翻页回来之后之前选中的行就不见了勾选框的状态也全部清了。这个现象很迷惑甚至容易被误会成数据丢了一页。现象数据在行也在但选中行和勾选状态在每次重新绑定后全部丢失。原因DataGridView 绑定新的 DataTable 后所有行对象都是全新创建的之前的SelectedRows集合引用的行对象已经不在当前网格里了。分页场景下每次翻页都重新赋值 DataSource等于每次都换了一张新表旧行对象自然全部失效。解决用业务主键而不是行号记录选中项。翻页前遍历 DataGridView 获取选中行的主键值存进一个HashSetobject翻页后重新绑定时再遍历当前页的行把主键在集合里的行重新设为选中。我一般把这段逻辑放在LoadPageData()里先收集、后绑定、再恢复顺序不能反。选主键做匹配还有个好处主键在同一张表里是稳定的即使翻页后顺序变化也不影响匹配。严格避免用行号row.Index做恢复依据因为行号在新 DataTable 里是从 0 开始重新编号的。5.2 首页加载就 5 秒无索引的 ORDER BY 凭什么让 SQL 全表扫分页控件接好了第一次点下一页发现首页打开要 5 秒翻到第 50 页要 8 秒越深越慢。这不是数据访问代码的问题是 SQL 执行计划的问题。现象首页加载慢深页码查询尤其慢而且耗时随页码递增。原因OFFSET-FETCH必须先按ORDER BY排序再跳过 offset 行取 next。如果ORDER BY的字段上没有索引SQL Server 只能做全表排序每翻一页都重排一次深页码还要把前面所有页的数据都扫完再丢掉属于典型的深分页性能陷阱。解决给排序列加上索引。单列排序建普通非聚集索引就够了多列排序时索引列的顺序要和ORDER BY保持一致。另外一个配合做法是把排序字段固定在主键上比如ORDER BY Id ASC主键本身就带聚集索引查询最稳页码越深耗时也不会像无索引那样线性恶化。如果业务要求按时间排序就在时间列上建索引同时考虑把OFFSET-FETCH换成基于游标值的 keyset 分页——每次记住上一页最后一条记录的时间下次从大于等于该值的位置开始取。keyset 方式的深度翻页性能基本恒定但实现复杂适合数据量真正到百万级再考虑。5.3 绑定 DataTable 后序号列「永远从 1 开始」有人习惯在查询 SQL 里加一列序号用ROW_NUMBER() OVER (ORDER BY ...)生成行号然后作为第一列展示「序号」。在分页场景里这列会变成「每页都从 1 开始」而不是全表的连续序号。现象每页第一行的序号都是 1第二行都是 2看起来像序号没排对。原因页码查询只返回当前页那PageSize行ROW_NUMBER()是在当前这个结果集内部生成的OFFSET 跳过多少行它不知道自然从 1 重新计数。解决不要在 SQL 里算序号在客户端算。绑定 DataGridView 后在 CellFormatting 事件或者遍历填充时用(CurrentPage - 1) * PageSize rowIndex 1计算真正的序号。这样页码变化序号自动连续还能省掉 SQL Server 端一次窗口计算的开销。附加提醒加了序号列以后不能让用户对这个列排序否则序号意义会混乱一般做法是把这个列的 SortMode 设为DataGridViewColumnSortMode.NotSortable。5.4 翻页带条件搜索WHERE 拼接时的两个翻车点分页加搜索是管理后台最常见的地狱场景。用户输入关键字点查询翻几页之后发现总记录数怎么变来变去或者点下一页莫名回到第 1 页。现象总记录数翻倍或比实际少翻页后查询条件失效显示全表数据。原因两个典型错误。第一个是 COUNT 和分页查询的 WHERE 条件不一致常见于拼接 SQL 时一个带LIKE过滤、另一个忘了带第二个是搜索条件存进了局部变量或方法参数而翻页事件是通过事件单独触发的局部变量已经被垃圾回收或重置翻页时拿到的是空条件。解决把查询条件提升为窗体的字段比如_searchKeyword而不是在方法内部重新从文本框读值。所有查询入口统一走这一个字段构造 WHERE 和参数列表把 COUNT 和分页查询放在同一个方法里用同一组条件生成 SQL确保任何改动都同时作用于两条 SQL。调试阶段如果发现总记录数和明细页行数对不上优先检查这个两处 WHERE 是否逐字一致、参数值是否一致。这四条是实际接入分页控件时摔倒最多的位置。前两条偏 SQL 执行计划后两条偏 WinForms 绑定机制每一条在跑通最小 Demo 之前都不明显但数据量一上来或者用户一操作就开始发作。建议新手在接入前就把第 5.1 和第 5.4 的解决方法写进代码框架里别等到测试反馈再补。6. 进阶玩法异步翻页不卡界面记住翻页前的选中行6.1 异步加载await 一下主界面不再假死分页查询虽然快但碰上复杂视图、大表 JOIN 或网络延迟高的环境翻页瞬间 UI 还是会顿一下。解决办法是让LoadPageData()变成异步方法用async/await搭配Task.Run把数据库操作挪到线程池。WinForms 的消息循环在 await 之后会自动切回 UI 线程不需要手动 Invoke这是框架帮我们处理的便利点。private async void LoadPageData() { pager.Enabled false; // 加载期间禁用翻页按钮防止用户连续点击触发并发 try { // Task.Run 里跑数据库查询避免阻塞 UI 线程 var queryTask Task.Run(() SqlPageHelper.GetPageData( connectionString: _connString, tableName: ProductInfo, selectColumns: Id, ProductName, Price, Stock, whereSql: _whereSql, orderBySql: Id ASC, pageIndex: pager.CurrentPage, pageSize: pager.PageSize, whereParameters: _whereParams, totalCount: out int total)); DataTable dt await queryTask; pager.TotalCount queryTask already completed 后取 total 值的问题 // 注意out 参数不能跨 await 捕获需要在 Task 内部先存值 // 实际写法见下方补充说明 } finally { pager.Enabled true; } }这里有一个 C# 的坑out参数不能直接用在异步 lambda 或 Task.Run 里作为跨线程返回值。常见处理是先把查询结果和总记录数封装成一个小的结果类Task 返回这个类实例await 之后取出来赋给控件。这是唯一需要绕一下的地方其余逻辑和同步版本完全一致。异步版本还有一个额外收益如果用户在查询执行期间切换页码旧查询的结果回来了会被新查询覆盖不会出现数据错乱因为 DataSource 赋值始终发生在 await 之后、控件状态最新的那一刻。6.2 记住翻页前的选中行把主键集合保存下来第 5.1 讲了选中行丢失的原理和初步恢复方案这里给一个实际可用的完整写法。翻页前收集选中主键翻页后按主键恢复选中再滚动到选中行附近用户操作体验接近桌面原生网格。private HashSetint _selectedIds new HashSetint(); // 翻页前调用把当前选中行的主键 Id 收集起来 private void CollectSelectedIds() { _selectedIds.Clear(); foreach (DataGridViewRow row in gridView.SelectedRows) { int id Convert.ToInt32(row.Cells[Id].Value); _selectedIds.Add(id); } } // 数据绑定完成后调用恢复选中并定位 private void RestoreSelectedRows() { if (_selectedIds.Count 0) return; foreach (DataGridViewRow row in gridView.Rows) { int id Convert.ToInt32(row.Cells[Id].Value); if (_selectedIds.Contains(id)) { row.Selected true; row.Visible true; } } }CollectSelectedIds必须在翻页前调用也就是放在翻页事件触发最早的入口RestoreSelectedRows放在LoadPageData()里 DataSource 赋值之后。要是集合为空说明用户没选中或选中行不在当前页直接跳过恢复逻辑反而更干净。我最后想分享一个习惯从最早那个版本把 SQL 写死在控件里、换一张表就要复制一份代码的项目到现在这个「控件只认页码事件、数据层只认参数列表」的结构最大感悟是把分页控件当成一个纯 UI 状态机来写它好用的地方不在代码量而在边界处理想得够细。读者照着这篇文章把控件建出来之后建议再多做一步把CurrentPageChanged和PageSizeChanged两个事件里断点观察翻页、改每页大小、搜索三次操作看看控件的状态是不是每次都按照「先钳制页码、再发事件、最后刷新按钮」这个顺序走的。顺序对了后面所有窗体都只是传参数的问题。希望帮到你。本文还有配套的精品资源点击获取