
简介一个面向 WinForm 开发者的 DataGridView 分页控件资源适合需要在列表展示场景中快速实现分页功能的初学者或中级开发者控件由作者自行封装将 DataGridView 与分页逻辑整合为一体使用起来比较简单。压缩包基于 .rar 格式共 45 个文件约 96KB结构中同时包含一个可直接运行的测试项目和一个独立的控件库项目文件类型以 C# 源码cs、项目配置csproj、sln、编译输出dll、exe、pdb以及界面资源配置resources、resx为主附带少量临时文件与说明文本整体轻量、便于查阅。目前已有 223 人学习下载适合作为 WinForm 自定义控件入门的参考示例尤其适合后台数据管理、查询列表等桌面程序开发场景。资源附带的源码和可执行文件可帮助读者理解分页控件的实现思路例如项目组织方式、资源引用与编译输出特点。借助测试项目可快速运行查看效果便于在此基础上扩展主题样式、调整按钮事件或按需二次开发减少重复造轮子的时间。1. 提到 WinForm 的 DataGridView 分页控件很多 C/S 项目的人都在用它兜底提到 WinForm 的 DataGridView 分页控件做 C/S 内部管理系统的人应该不陌生。直接 new 一个 DataGridView把几十万行的订单表往 DataSource 里一塞界面就像黑匣子一样卡住用户打开窗体要等半分钟滚动一下标题栏都要转圈。分页控件要解决的就是这件事把“一次全加载”改成“只取当前页”让表格始终只面对几十行数据翻页、跳页、调整每页行数都交给一个专门的控件条。它适合进销存、后台管理这类 winform 项目案例也适合数据量从数万到百万级的工单、日志、流水查询界面。下面直接拆方案从数据层到控件层一步步落到能复现的代码上。2. 先把分页拆成三层数据层、翻页条、表格各管什么事做分页控件的第一步不是去画界面而是先把“谁查数、谁翻页、谁显示”三件事分开。很多翻车做法是把分页逻辑写死在窗体里点击事件里直接查 DataTable再手动改 Label。一次两次可以等到第二个窗体也要分页只能复制粘贴然后项目就失控了。一个能复用的 DataGridView 分页控件应该按三层来组织数据层只负责按页码和页大小取数并返回总数翻页条只负责维护页码状态DataGridView 只负责显示。2.1 先选型内存分页还是数据库分页如果数据量只有几千行比如字典表、配置表其实不需要真的分页。常见做法是首次加载把整个 DataTable 拉回来用 List .Skip(...).Take(...) 或 BindingSource.Filter 做内存分页。这样做的好处是翻页不产生数据库往返体验是秒开缺点是数据越多内存占用越大。到了几万行以上尤其是表里有 long 型字符串字段、或者要 join 多张表一次性加载的等待时间和内存占用会让客户直接投诉。另一个极端是几十上百万行的流水数据数据库分页是常规选择。到底怎么选可以按这张表拍板数据规模建议方案说明数千行以内内存分页首屏一次取回翻页时无网络往返数万到百万级数据库 OFFSET / LIMIT 分页每页只取当前页数据内存和响应都稳定百万级以上且只翻下一页键集分页WHERE id lastId避开大 offset避免越翻越慢数据库分页有两个和业务强相关的点必须想清楚。第一排序必须稳定ORDER BY 的列要能唯一确定顺序。常见做法是拿主键或者 create_time 加主键做复合排序否则第二页可能和第一页出现重复行用户会以为控件抽风。第二翻页时必须带着查询条件去分页不只是 SELECT * FROM table而是 WHERE 条件一致的情况下再 OFFSET总数也按同样的 WHERE 统计。否则筛完条件后页大小错乱状态条显示的数字全是假的。2.2 数据层主推 OFFSET 分页SQL 怎么写SQL Server 2012 以上用 OFFSET FETCH这是最直观的语法SELECT id, order_no, customer_name, create_time FROM dbo.orders WITH (NOLOCK) ORDER BY id DESC OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY;offset 的算法是 (page - 1) * pageSize因为 OFFSET 是从 0 开始算的。注意这里必须配合 ORDER BY否则 SQL 语法直接报错而且没有排序的分页本身就是伪分页。总数统计一般是单独一条 COUNT()把同一份 WHERE 条件复制过去。之前见过一种省一次往返的写法在取数 SQL 里加一列 COUNT() OVER() AS TotalCount这样每行都带着总数数据量小时性能没问题但会让结果集每行都多一次窗口计算。我一般建议用 SqlConnection 的 QueryMultiple把取数和 COUNT 合在一次往返里发过去既干净又不会在大表上重复扫描两次。如果项目还在用 SQL Server 2008 / 2008 R2OFFSET FETCH 不可用常见做法是换 ROW_NUMBER();WITH p AS ( SELECT id, order_no, customer_name, create_time, ROW_NUMBER() OVER (ORDER BY id DESC) AS rn FROM dbo.orders ) SELECT * FROM p WHERE rn BETWEEN start AND end;其中 start (page-1)pageSize 1end pagepageSize。这段老 SQL 的缺点是子查询要先把所有符合条件的数据标上序号再截取数据量很大时会比 OFFSET 慢。老库没有条件必须兼容新项目不建议为了兼容老库而全盘使用它。2.3 翻页条是个状态机不是一堆 if翻页条看似简单点击“下一页”就是把页码加一。但真正能让控件稳定工作的是把它当状态机管理。当前状态由 CurrentPage、TotalPages 两个变量决定按状态去更新按钮的可用性首页、上一页可点的条件CurrentPage 1下一页、末页可点的条件CurrentPage TotalPagesTotalPages 0 时所有翻页按钮禁用页码显示为 0为什么强调状态机因为分页控件的边界都藏在状态里。比如删掉最后一页的所有数据后TotalPages 从 5 变成 4此时 CurrentPage 还是 5如果不对状态做归位处理点击上一页、下一页都会算错位置。又比如 TotalPages 0 时页面没有任何数据很多初版实现会让上一页按钮仍然可点点一下还触发了一次无意义的查询。把按钮可用性集中在一个 UpdateButtons() 方法里任何状态变更后都调用它这套控件的骨架才算立住。2.4 组合优于继承把 DataGridView 包进 UserControl常见的控件形态有两种。第一种是继承 DataGridView把分页逻辑加进子类里。好处是设计器里拖过来就是个表格分页方法直接写在类里但坏处也很明显一旦另一个页面想用 ListView 或第三方表格这套逻辑全部要重写。第二种是组合一个 UserControl 里放一个 DataGridView底部再放一排翻页按钮对外暴露 DataSource 和事件。我一般用组合。组合之后 DataGridView 的原生属性不受影响比如列宽 AutoSizeColumnsMode、DefaultCellStyle、CellBorderStyle这些在 winform 控件属性大全里都能配。分页条和表格可以各自维护自己的生命周期翻页条发事件表格收数据谁也不依赖谁。这套结构在 winform 项目案例里最经得起业务改需求今天表格要加一列明天分页条要改成倒序翻页改的边界都很清楚。从 winform 之 listview 的角度看如果哪天产品要求换成 ListView 展示分页条的 UserControl 外壳不用动只换内部表格实例就行这就是组合比继承灵活的地方。3. 手写一个 DataGridView 分页控件UserControl 骨架与核心代码前面把三层逻辑拆开之后落地到代码就顺了。这章直接给一个能编译的 UserControl 骨架控件对外只暴露三个东西PageSize 属性、SetPageInfo() 方法、PageChanged 事件。外部窗体负责查数据然后调用 SetPageInfo() 把总数告诉控件剩下的翻页边界都由控件自己管。3.1 控件骨架DataGridView 加底部翻页栏先看字段和构造函数部分public partial class PagingDataGridView : UserControl { private DataGridView dgv new DataGridView(); private Panel bottomBar new Panel(); private Button btnFirst new Button() { Text 首页 }; private Button btnPrev new Button() { Text 上一页 }; private TextBox txtPage new TextBox() { Width 48, TextAlign HorizontalAlignment.Center }; private Button btnNext new Button() { Text 下一页 }; private Button btnLast new Button() { Text 末页 }; private ComboBox cboPageSize new ComboBox() { Width 72, DropDownStyle ComboBoxStyle.DropDownList }; private Label lblInfo new Label() { AutoSize true }; public int PageSize { get; set; } 20; public int CurrentPage { get; private set; } 1; public int TotalRecords { get; private set; } public int TotalPages { get; private set; } public event EventHandlerPagingEventArgs PageChanged; public PagingDataGridView() { dgv.Dock DockStyle.Fill; bottomBar.Height 40; bottomBar.Dock DockStyle.Bottom; Controls.Add(dgv); Controls.Add(bottomBar); bottomBar.Controls.Add(btnFirst); bottomBar.Controls.Add(btnPrev); bottomBar.Controls.Add(txtPage); bottomBar.Controls.Add(btnNext); bottomBar.Controls.Add(btnLast); bottomBar.Controls.Add(cboPageSize); bottomBar.Controls.Add(lblInfo); // 实际项目建议把 bottomBar 内部再套一个 FlowLayoutPanel按钮顺序和间距更好控 } }这段骨架有几个设计意图要说。PageSize 是普通属性可以在属性窗口直接设默认值但注意它不会自动触发查询真正改变页大小是在 cboPageSize 下拉事件里。cboPageSize 的 DropDownStyle 设成 DropDownList 是为了防止用户乱输列表里放 10、20、50、100 几档。CurrentPage 只读外部不要直接给它赋值强制跳页用 LoadPage()这样控件的状态始终由自己维护。布局上的坑在 winform 里很常见直接把按钮 Add 进 bottomBar 时不指定位置默认都叠在左上角。建议用 FlowLayoutPanel 作为 bottomBar 的容器把按钮按顺序排进去再用 Padding 控制间距。这里为保持代码可读性先省略动手复现时建议用 FlowLayoutPanel 而不是手算 Location。3.2 核心状态逻辑LoadPage 和 SetPageInfo接下来是控件的心脏部分public void LoadPage(int page) { if (page 1) return; if (TotalPages 0 page TotalPages) return; CurrentPage page; PageChanged?.Invoke(this, new PagingEventArgs(CurrentPage, PageSize)); UpdateButtons(); } public void SetPageInfo(int totalRecords) { TotalRecords totalRecords; TotalPages (TotalRecords PageSize - 1) / PageSize; if (TotalPages 0) CurrentPage 0; else if (CurrentPage TotalPages) CurrentPage TotalPages; UpdateButtons(); } private void UpdateButtons() { bool canPrev CurrentPage 1; bool canNext CurrentPage TotalPages; btnFirst.Enabled canPrev; btnPrev.Enabled canPrev; btnNext.Enabled canNext; btnLast.Enabled canNext; txtPage.Text TotalPages 0 ? 0 : CurrentPage.ToString(); lblInfo.Text $第 {CurrentPage} / {TotalPages} 页共 {TotalRecords} 条; }LoadPage 是控件对外的唯一入口。第一行做基础越界检查允许 page1 在 TotalPages 为 0 时进入因为首次加载必须靠它触发。第二行在 TotalPages 已存在时做上限检查防止请求超出总页数。PageChanged 事件在这里触发事件参数里带 CurrentPage 和 PageSize外部拿到参数后去数据库查数据。UpdateButtons 放在事件触发之后保证外部哪怕同步执行返回后按钮状态也是新的。SetPageInfo 里的两个边界处理是血泪经验。一是取整公式少写一个括号都会错(TotalRecords PageSize - 1) / PageSize 才能得到向上取整的页数如果写成 TotalRecords / PageSize101 条数据每页 20 条会被算成 5 页实际需要 6 页。二是 CurrentPage 越界归位删除最后一页数据后当前页可能超出总页数这里强制拉回最后页如果全部删空则归零。这一小段逻辑解决了大量所谓“分页控件邪门问题”。3.3 翻页事件与页大小下拉按钮事件和下拉框行为private void OnFirstClick(object sender, EventArgs e) LoadPage(1); private void OnPrevClick(object sender, EventArgs e) LoadPage(CurrentPage - 1); private void OnNextClick(object sender, EventArgs e) LoadPage(CurrentPage 1); private void OnLastClick(object sender, EventArgs e) LoadPage(TotalPages); private void OnPageSizeChanged(object sender, EventArgs e) { if (!int.TryParse(cboPageSize.SelectedItem?.ToString(), out int size)) return; PageSize size; // 页大小变化后必须回到第一页否则当前页码可能直接超出新总页数 LoadPage(1); } private void OnGoClick(object sender, EventArgs e) { if (int.TryParse(txtPage.Text.Trim(), out int p)) LoadPage(p); }事件参数类型单独放一个小文件public class PagingEventArgs : EventArgs { public int CurrentPage { get; } public int PageSize { get; } public PagingEventArgs(int currentPage, int pageSize) { CurrentPage currentPage; PageSize pageSize; } }这里要留意 OnPageSizeChanged 里 LoadPage(1) 的递归风险。如果 PageSize 的 setter 里也去触发事件改页大小时会一路触发多次 PageChanged。常见做法是 PageSize 属性不做任何副作用只有 cboPageSize 的 SelectedIndexChanged 里显式解析并调用 LoadPage(1)这样事件链只有一条。onGoClick 里的 int.TryParse 不能省页码文本框允许用户乱输直接转型会抛异常这也是避免把系统做崩的细节。控件参数整理成一张表方便后面接业务时对照成员类型说明PageSizeint每页行数默认 20下拉框可改CurrentPageint当前页码只读由控件内部维护TotalRecordsint总记录数外部查完数据后通过 SetPageInfo 写入TotalPagesint总页数根据 TotalRecords 和 PageSize 自动计算PageChangedevent需要加载某页时触发参数带页码和页大小这章先停留在控件本身下一步把它接到真实窗体里跑起来。4. 把分页控件接进业务窗体数据访问层、事件绑定与异步加载4.1 数据访问层QueryMultiple 同时取列表和总数控件写好了剩下的就是外部窗体去响应 PageChanged 事件查数据并喂回来。先写一个典型的 Repository 方法用 Dapper 只是因为样例更短你喜欢 SqlSugar 或原生 SqlDataAdapter 都行原则一样。如果是依赖注入的 winform 项目把这个 Repository 注册进容器再注入到窗体即可。public class OrderRepository { private readonly SqlConnection _conn; public OrderRepository(string connStr) { _conn new SqlConnection(connStr); } public (ListOrder List, int Total) GetOrders(string keyword, int page, int pageSize) { var sql SELECT id, order_no, customer_name, amount FROM dbo.orders WITH (NOLOCK) WHERE (keyword OR order_no LIKE % keyword %) ORDER BY id DESC OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY; SELECT COUNT(*) FROM dbo.orders WITH (NOLOCK) WHERE (keyword OR order_no LIKE % keyword %);; using var multi _conn.QueryMultiple(sql, new { keyword keyword ?? , offset (page - 1) * pageSize, pageSize }); var list multi.ReadOrder().ToList(); var total multi.ReadFirstint(); return (list, total); } }这段 SQL 的要点在 OFFSET 参数。page 是从 1 开始的而 OFFSET 从 0 开始所以必须写成 (page - 1) * pageSize这是新手最容易写错的一行。WHERE 里加了个 keyword 的空串判断是为了让“无筛选条件”和“有筛选条件”走同一条 SQL避免拼接字符串踩注入。另一个要点是 QueryMultiple 的用法两条 SQL 用分号隔开一次往返拿到两个结果集一次读列表一次读总数。相比先查列表再单独 COUNT少一次网络往返在 C/S 内网里收益不明显但代码结构更干净。LIKE %keyword% 这种写法在几百万行的表上会全表扫示例里能跑生产环境建议换成全文索引这不是分页控件的锅但要说清楚。4.2 窗体内接线异步加载状态栏与进度条一起更新窗体侧的核心是把 PageChanged 事件接到查询方法上。注意事件是同步触发的如果直接在事件处理器里执行 SQL界面会卡翻页时用户能看到窗体半天不响应。常见做法是丢到 Task.Run 里异步执行。public partial class OrderForm : Form { private readonly OrderRepository _repo; private int _loadSeq; public OrderForm(OrderRepository repo) { InitializeComponent(); _repo repo; pagingGrid.PageChanged OnPageChanged; pagingGrid.PageSize 30; pagingGrid.LoadPage(1); } private async void OnPageChanged(object sender, PagingEventArgs e) { int currentSeq _loadSeq; statusLabel.Text $正在加载第 {e.CurrentPage} 页...; progressBar.Style ProgressBarStyle.Marquee; try { var (list, total) await Task.Run(() _repo.GetOrders(txtKeyword.Text.Trim(), e.CurrentPage, e.PageSize)); if (currentSeq ! _loadSeq) return; // 旧请求晚到直接丢弃 pagingGrid.DataSource list; pagingGrid.SetPageInfo(total); statusLabel.Text $共 {total} 条第 {e.CurrentPage} / {pagingGrid.TotalPages} 页; } catch (Exception ex) { statusLabel.Text 加载失败请检查数据库连接; MessageBox.Show(ex.Message, 查询出错, MessageBoxButtons.OK, MessageBoxIcon.Warning); } finally { progressBar.Style ProgressBarStyle.Blocks; } } }异步里最值得说的不是 async/await而是 _loadSeq 竞态保护。快速连续翻页时PageChanged 可能触发两次第 2 页的请求先发出第 3 页的请求后发出但数据库返回顺序不保证。如果没有序号保护第 2 页的晚到数据可能覆盖第 3 页的显示。这段代码每次进入事件先取一个自增序号返回后对比当前序号不一致就丢弃。状态栏与进度条是 winform 里最直观的反馈方式加载前把进度条设为 Marquee 表示忙碌加载完改回 Blocks 并把进度停在最终位置。DataSource 赋值的时机也很关键。直接在 UI 线程上给一个已经显示的 DataGridView 赋值会触发整列重新计算用户会看到表格闪烁和滚动条跳顶。可以先调用 dgv.SuspendLayout() 和 ResumeLayout()但更常用的做法是在控件内部把 dgv.DataSource 的赋值包成方法这里为了简化把表格属性直接暴露了。如果不想暴露内部 dgv可以在控件上提供一个 SetDataSource() 方法内部再做挂起和恢复。4.3 条件查询和翻页的协作查询按钮只做一件事带筛选条件的分页最大的坑是“查完新条件后页码环境还是旧的”。比如用户在第 5 页输入关键词点查询新结果只有 2 页如果不去重置页码就会请求一个越界页得到空数据。查询按钮里不需要直接查数据只需把页码重置到 1private void btnQuery_Click(object sender, EventArgs e) { pagingGrid.LoadPage(1); }LoadPage(1) 会触发 PageChanged事件处理器里读取的 txtKeyword.Text 就是当前输入框的最新内容所以要传给 Repository 的关键字也自然没错。这里还有一个细节不要在查询按钮里既重置页码又手动调一次查询方法否则 PageChanged 被触发两次数据库被反复打。包括修改 PageSize 的场景控件内部已经做了 LoadPage(1)窗体侧不要再重复处理。注意如果业务要求翻页时保持已经筛选的条件关键字必须从控件外部读取比如窗体字段不要缓存到分页控件内部。分页控件只关心页码和页大小不关心业务筛选这是它复用的前提。5. 避坑与排查分页控件最常见的 5 个翻车场景分页控件写出来了真正开始用时才是踩坑的开始。这章不列代码把最常见的 5 个翻车场景按现象、原因、解决写清楚。5.1 初始状态显示空白点翻页按钮无响应现象窗体加载后表格空白上一页、下一页按钮都是灰色怎么点都没反应。原因控件的 TotalPages 还是 0LoadPage 里的越界检查直接把请求挡掉了。很多初学者在构造函数里只做了 PageChanged 绑定没有先调用 LoadPage(1)而 LoadPage 第一行 if (TotalPages 0 ...) 在 TotalPages 为 0 时直接 return等于事件永远不触发。解决LoadPage 的判断要区分两种情况TotalPages 0 时按正常边界检查TotalPages 0 时允许加载第 1 页作为首次加载。把判断改成if (page 1) return; if (TotalPages 0 page TotalPages) return;然后在窗体的 Load 事件里调用 LoadPage(1)。也可以让 LoadPage 判断目标页为 1 时直接放行。5.2 总页数算出来总少一页现象共 101 条记录每页 20 条界面上显示总页数 5但第 5 页只有 1 条数据且单击“下一页”还能翻到空白页。原因整数除法丢余数。TotalPages TotalRecords / PageSize 在 C# 里是向下取整101 / 20 5真实页数应该是 Ceiling(101 / 20) 6。解决用向上取整公式TotalPages (TotalRecords PageSize - 1) / PageSize;。如果 TotalRecords 是 long先检查 PageSize 不为 0避免除零。这个公式是分页控件的默认动作不要在业务里再自己算一遍。5.3 删除数据后页码越界表格空白现象列表共 3 页在第 3 页删掉最后一条数据刷新后表格空白翻页按钮状态全乱。原因删除后 TotalRecords 变成 41TotalPages 从 3 变成 2但 CurrentPage 还停在 3。下次点击下一页时请求的页码已经超过总页数数据层查不到数据控件状态和数据库不一致。解决在 SetPageInfo 里做越界归位。if (TotalPages 0 CurrentPage TotalPages) CurrentPage TotalPages; if (TotalPages 0) CurrentPage 0;。这样删除后自动回落到新的最后一页。如果业务更希望删除后跳到首页也可以在这里统一改成 CurrentPage 1二选一但必须在 SetPageInfo 内完成。5.4 快速翻页时表格显示的数据和当前页码对不上现象用户连续点击下一页界面最终显示的表格数据不是当前页应该有的数据。原因异步查询返回顺序不确定。第一次请求第 2 页第二次请求第 3 页第 3 页数据先返回显示随后第 2 页数据也返回又覆盖了表格此时页码显示第 3 页数据却是第 2 页的用户直接懵。解决用自增序号做竞态保护如第 4 章示例里的 _loadSeq。每次触发 PageChanged 时递增一个整型变量异步返回后比较该值是否仍是当前值不是就丢。这是 winform 异步分页最常见的隐性 bug代码量少但一定要加。5.5 DataGridView 刷新时闪烁滚动条跳到顶部现象每次翻页后表格先白一下再出来数据头两列能看到明显的斑马纹残影如果之前滚动过翻页后滚动条回到最顶端用户要重新拉。原因直接给已显示的 DataGridView 换 DataSource 会触发整列重置和全部单元格失效重绘闪烁由此而来。滚动条回到顶部是因为换了一个新的 DataSource滚动条的位置取决于新的行数这是分页整页替换的必然结果。解决在控件里给 DataGridView 开双缓冲默认情况下 DataGridView 是支持 DoubleBuffered 的但属性是 internal需要用反射开启typeof(DataGridView) .GetProperty(DoubleBuffered, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) ?.SetValue(dgv, true);跨页时的 SuspendLayout / ResumeLayout 能减少重绘次数但治不了滚动条跳顶。要保留滚动位置需要在刷新前记录当前页的第一行主键值刷新后找到该行再把 CurrentCell 定位过去这个做起来繁琐属于加分项不是分页控件的必备功能。6. 进阶玩法键集分页和界面美化让分页控件更像产品分页控件能做到翻页、能跳页其实只是及格线。真到几十万上百万行的流水数据时OFFSET 分页越往后越慢因为数据库要把前面所有的行都跳过。这种场景我一般会换键集分页不用页码只保留“下一页”和“上一页”用每次返回的最后一个主键值作为游标。SELECT TOP (20) id, order_no, customer_name, amount FROM dbo.orders WITH (NOLOCK) WHERE (lastId 0 OR id lastId) ORDER BY id DESC;下一页传入上一次的最后一个 id上一页则反向查询。这个方案翻到最后也不会变慢因为走主键索引直接定位但它不适合需要自由跳页的界面页码框、首页末页都要去掉。我一般把 DataGridView 的 VirtualMode 也放在这个层级里考虑如果产品更希望像 Excel 一样滚动浏览而不是点页码可以用 VirtualMode 配合 CellValueNeeded 按需取行它与键集分页是两种不同交互的取舍。多数客户还是习惯“共多少条第几页”的认知所以键集分页更适合内部日志、消息流水这类有明确“只看最新”诉求的功能。界面美化这块winform 界面美化常见做法是统一分页条和表格的观感把 cboPageSize 的 DropDownWidth 调出来页码文本框加一个 KeyPress 事件只允许数字按钮里首页、上一页、下一页、末页底部对齐表格侧把 DefaultCellStyle 里的背景色、选择色、GridColor 配成套色再用一次双缓冲把闪烁压下去整体观感就会接近现在很多商用进销存的样子。之前在项目里遇到过把分页条做成独立 Dll 的情况后来维护成本很高因为业务每次都靠改事件参数绕过控件最后还是把事件收口成了 PageChanged 一个口子。我做分页控件吃过最大的亏是初期把重心全放在 DataGridView 的列样式上觉得像页码控件一样靠界面外表就能撑起来。真正上线跑了一周才发现数据层查询、总页数取整、异步竞态这些看不见的部分才是决定控件能不能长期用下去的命门。后来每个分页场景我都会先确认排序键、去重策略和删除场景再动手写界面。分页不是“把数据换一页”它是有状态的导航器状态管住了控件才真正落地。希望帮到你。本文还有配套的精品资源点击获取