WinForms DataGridView分页控件:原理、实现与性能优化

简介:一款专门用于Winform开发的DataGridView分页控件,面向需要在管理信息系统、订单管理、报表查询等界面中实现表格数据分页展示的桌面应用开发者。控件整体采用简洁设计,将分页导航与DataGridView整合在一起,开发者只需绑定数据源即可完成页码切换,适合从入门到中级的C#程序员在项目中直接引用,也方便通过修改源码定制分页按钮布局与显示样式。资源压缩包共45个文件,体积仅96KB,内部以cs源码文件为核心,同时包含dll编译程序集、pdb调试符号、resx/resources资源文件、exe演示程序和sln/csproj工程文件,可在Visual Studio中直接打开、编译并调试。目前已有223人下载学习。通过内置的测试窗体可以直观查看控件运行效果;源码中附带Designer设计器文件,有助于理解自定义控件在设计器界面的初始化逻辑,以及分页按钮事件、当前页跟踪、数据源切换等核心实现,整体轻量、代码量小,适合作为入门自定义控件开发或日常分页需求的参考。

1. DataGridView分页控件:WinForms开发里绕不开的那道坎

接过一个内部订单管理系统的维护需求,业务方说“数据一多就卡,翻页还不如用Excel”。打开代码看了一眼,几十万行的订单表被一次性塞进DataTable,再整体赋给DataGridView.DataSource,启动要等十几秒,点个筛选就让界面假死。这种场景在WinForms开发里太常见了。DataGridView本身不带分页能力,所谓分页控件,就是封装了页码导航、每页大小、总记录数统计,并把当前页数据从数据源里截取出来再绑定的一个组合控件。它解决的不是“有没有分页按钮”,而是“数据量大了之后,内存、查询压力和操作体验怎么平衡”。适合正在做桌面管理系统、维护老WinForms项目,或者被老板要求给DataGridView“加分页功能”的开发者,也适合想搞明白分页底层边界逻辑的新手。

2. 分页模式先选对:内存分页与数据库分页的边界和取舍

2.1 数据量决定方案:几千行和几十万行完全是两套做法

分页控件只是壳,真正决定性能的是数据从哪来。很多新手把分页实现成“先全部查出来,再在内存里Skip/Take”,这在数据量小时没问题,一旦超过某个量级就会翻车。

常见做法是,数据量在几千行、且查询条件基本固定时,内存分页足够。一次性地把数据表加载到DataTable,然后用LINQ的Skip和Take按页截取。它的优点是SQL简单、翻页瞬间完成,因为没有第二次数据库交互;缺点是一次性加载的时间和内存都累积在启动或筛选那一刻。

数据库分页是另一套逻辑:每次翻页只从数据库取当前页的行,配合一个COUNT统计总数。典型的SQL Server写法用OFFSET FETCH:

-- 分页查询:取第 2 页,每页 20 行 SELECT OrderID, CustomerName, OrderAmount, OrderDate FROM dbo.Orders WHERE Status = N'已确认' ORDER BY OrderID OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY; -- 总数统计:单独执行,用于计算总页数 SELECT COUNT(*) FROM dbo.Orders WHERE Status = N'已确认';

这段SQL的要害在于ORDER BY不能省。OFFSET只负责跳过,如果排序不稳定,翻页结果会重复或漏行。以OrderID这种唯一列做排序是最稳的。参数化之后,OFFSET的数值应使用变量而不是拼字符串,否则会有注入风险。

两种情况的分层很明显,我做了一个简单判断表:

维度内存分页数据库分页
数据量建议1万行以内无上限
翻页速度加载后最快依赖查询效率
首次加载慢,要拖全量快,只取首页
与筛选联动每次要重查全量只查当前页
实现复杂度低中,需处理排序和统计
典型场景小字典表、配置表订单、日志、流水

我一般把1万行当临界点,但不是硬规则。如果用户机器内存小、数据行宽,超过两三千行就已经能感觉到启动卡顿。反过来,如果数据就是几百行,非要用OFFSET FETCH,那种性能差异根本感知不到,属于过度设计。

提示:OFFSET FETCH 中必须用 ORDER BY 且排序字段尽量唯一,否则翻页结果不稳定。

2.2 自研还是用现成控件:从维护成本看怎么选

市场上确实有现成的分页控件,封装了按钮和页码样式,拖进去就能用。但我在实际项目中吃过依赖的亏:某个第三方控件只支持某个固定框架版本,升级之后样式崩了还联系不上作者。

自研一个分页控件,本质是UserControl里放几个Button和Label,再把事件抛出去。核心代码量不大,但换来的控制力是实打实的:按钮样式、每页条数选择器、前后缀信息都能自己定义,查询SQL也完全在自己的业务层手里。

我的选型判断标准是三条:第一,控件是否允许自定义翻页触发的数据源加载逻辑;第二,是否依赖固定数据源类型(有的控件只认DataTable,有的支持BindingSource);第三,团队里有没有人能接手改。任何一条不满足,就值得自研。这三条尤其对老项目重要,因为老项目里DataGridView经常已经在用BindingSource做数据源,现成控件未必兼容。

自研的另一个理由是,分页控件必须知道自己“当前在第几页、总共有几页”,这本质是个状态机。现成控件往往把状态藏在内部,出了问题没法调试,而自己写的每一行状态变化都看得见,排障时间能缩短很多。

2.3 控件接口设计:属性、事件、方法先定边界

写控件之前,先把对外的接口定清楚,避免写到后面不知道状态该放哪。

我的习惯是PageIndex从1开始,因为最终用户看到的就是“第1页”,内部再用(PageIndex - 1) * PageSize换算偏移量。PageSize默认20,可配置。TotalCount由外部在查询结束后调用SetTotalCount传入。

需要暴露的属性、事件和方法,我整理成了一张简表:

成员类型说明
PageIndex属性当前页码,从1开始
PageSize属性每页行数,默认20
TotalCount属性总记录数,由外部设置
TotalPages属性总页数,由TotalCount计算而来
PageChanged事件页码变化后触发,外部在这里重新查数
SetTotalCount(int)方法外部查询总数后调用,内部处理越界
GoToPage(int)方法跳转到指定页,内部有边界保护
FirstPage/PrevPage/NextPage/LastPage方法四个导航操作

事件设计中,PageChanged是核心。注意不要在控件内部直接去查数据库,控件只负责“我翻到第几页了,你去拿数据”,数据加载全部放在订阅方。这样控件与ORM、连接字符串完全解耦,换数据库也不影响控件本身。

边界保护同样关键。当总记录数为0时,TotalPages应为0而不是1,页码按钮要全部禁用;当外部传入的新TotalCount导致当前页超出范围时,要自动回退到末页,这些逻辑如果不提前想清楚,后面使用时会冒出各种越界异常。

事件触发的顺序也值得特意说明。GoToPage内部先改PageIndex,再触发PageChanged。订阅方在PageChanged里读取到的PageIndex已经是新页码,不需要自己再+1或-1。反过来,在PageChanged里无限调用GoToPage会造成递归,所以GoToPage里page等于当前PageIndex时直接return的判断,也是防递归的一道闸。

3. 实现一个可复用的DataGridView分页控件:翻页逻辑与绑定全流程

3.1 控件骨架:组合UserControl的布局

开始动手写代码。我创建一个Windows窗体用户控件库项目,在这个库里建一个名为PaginationControl的UserControl。

界面上我放五个按钮:首页、上一页、下一页、末页;左侧放一个Label显示“第 x / y 页(共 z 条)”;如果想更顺手,右上角可以放一个ComboBox用于切换每页条数。布局走TableLayoutPanel,三列分别放Label、按钮组、ComboBox。

控件的核心代码骨架如下:

public partial class PaginationControl : UserControl { // 当前页码,从1开始,外部只读 public int PageIndex { get; private set; } = 1; // 每页显示行数,默认为20,外部可修改 public int PageSize { get; set; } = 20; // 总记录数,由外部在查询总数后传入 public int TotalCount { get; private set; } // 总页数:向上取整,这是最容易算错的地方 public int TotalPages => TotalCount == 0 ? 0 : (TotalCount + PageSize - 1) / PageSize; // 翻页后触发的事件,订阅方在这里执行数据查询 public event EventHandler? PageChanged; public PaginationControl() { InitializeComponent(); UpdateButtonState(); } // 外部在总数统计完成后调用,自动修正越界页码 public void SetTotalCount(int total) { TotalCount = total; if (PageIndex > TotalPages && TotalPages > 0) { PageIndex = TotalPages; } UpdateButtonState(); lblInfo.Text = $"第 {PageIndex} / {TotalPages} 页,共 {TotalCount} 条"; } // 跳转到指定页,带边界保护 public void GoToPage(int page) { if (page < 1 || page > TotalPages) { return; } if (page == PageIndex) { return; } PageIndex = page; UpdateButtonState(); lblInfo.Text = $"第 {PageIndex} / {TotalPages} 页,共 {TotalCount} 条"; PageChanged?.Invoke(this, EventArgs.Empty); } public void FirstPage() => GoToPage(1); public void PrevPage() => GoToPage(PageIndex - 1); public void NextPage() => GoToPage(PageIndex + 1); public void LastPage() => GoToPage(TotalPages); private void UpdateButtonState() { btnFirst.Enabled = PageIndex > 1; btnPrev.Enabled = PageIndex > 1; btnNext.Enabled = PageIndex < TotalPages; btnLast.Enabled = PageIndex < TotalPages; } }

这里几个细节需要说明。PageIndex用private set,外部不能随意改,只能通过GoToPage或事件驱动,这能避免出现“显示第3页但数据源是第1页”的错位。PageSize是普通set,我允许外部改,但改了之后需要手动重新加载,控件不自动触发,防止在DataGridView正在编辑时被意外刷新。

TotalPages的计算是用(TotalCount + PageSize - 1) / PageSize,也就是向上取整。如果总数为45,每页20行,正确结果是3页而不是2页,这个公式比Math.Ceiling更直接,且不涉及double转换。TotalCount为0时我特意让TotalPages返回0而不是1,这样按钮状态里下一页和末页都会禁用,不会再出现“点下一页触发查询并返回空表”的尴尬。

GoToPage里做了两层保护:页码小于1或大于总页数直接return,等于当前页也return。第二层return容易被忽略,但它能防止翻页后被SetTotalCount强制校正页码时重复触发PageChanged事件,这个事件一旦重复触发,界面的DataGridView会反复重新绑定,用户会看到闪烁。

3.2 与DataGridView对接:查询、统计、绑定一条链

控件写完,接下来是页面里的配套代码。在窗体上拖入一个DataGridView和这个PaginationControl,然后订阅PageChanged事件:

private void PaginationControl_PageChanged(object? sender, EventArgs e) { LoadCurrentPage(); } private void LoadCurrentPage() { // 关闭自动生成列,避免来回刷新时列被重建 dataGridView1.AutoGenerateColumns = false; // 从数据源查询当前页 string keyword = txtKeyword.Text.Trim(); int pageIndex = paginationControl.PageIndex; int pageSize = paginationControl.PageSize; var (pageData, total) = QueryOrders(pageIndex, pageSize, keyword); // 先更新总数,再绑定数据,顺序不能反 paginationControl.SetTotalCount(total); dataGridView1.DataSource = pageData; } private (DataTable, int) QueryOrders(int pageIndex, int pageSize, string keyword) { using var conn = new SqlConnection(ConnectionString); using var cmd = new SqlCommand("GetOrdersByPage", conn); cmd.CommandType = CommandType.StoredProcedure; cmd.Parameters.AddWithValue("@PageIndex", pageIndex); cmd.Parameters.AddWithValue("@PageSize", pageSize); cmd.Parameters.AddWithValue("@Keyword", keyword); var totalParam = cmd.Parameters.Add("@TotalCount", SqlDbType.Int); totalParam.Direction = ParameterDirection.Output; using var da = new SqlDataAdapter(cmd); var dt = new DataTable(); da.Fill(dt); // Fill 内部已经执行了命令,输出参数在此之后才是最终值 int total = totalParam.Value is int v ? v : 0; return (dt, total); }

这段绑定的顺序有个关键点:SetTotalCount必须在DataSource赋值之前调用。因为SetTotalCount内部会修正越界页码,如果先绑定了数据再设置总数,万一当前页已经越界,DataGridView显示的是空表,设置总数后页码被强制改到末页,但数据源还没来得及重新加载,界面就出现了“末页但空数据”的状态,只有再翻一次页才能恢复。

提示:SetTotalCount 应当在 DataSource 赋值之前调用,避免页码修正与已绑定数据不一致。

QueryOrders里我用了存储过程,参数化是标配。注意AddWithValue在数据量为大整数和日期类型时有类型推断风险,更严谨的做法是明确指定SqlDbType。OUTPUT参数的取值要在执行完Fill之后才可靠,因为Fill内部已经完成了命令执行,这时候再读输出参数才是最终值。

3.3 存储过程与分页SQL:OFFSET FETCH和ROW_NUMBER的选择

如果项目用的是SQL Server 2012以上版本,OFFSET FETCH读起来最直观:

CREATE PROCEDURE [dbo].[GetOrdersByPage] @PageIndex INT, @PageSize INT, @Keyword NVARCHAR(50), @TotalCount INT OUTPUT AS BEGIN SET NOCOUNT ON; SELECT @TotalCount = COUNT(*) FROM dbo.Orders WHERE (@Keyword = N'' OR CustomerName LIKE N'%' + @Keyword + N'%'); SELECT OrderID, CustomerName, OrderAmount, OrderDate FROM dbo.Orders WHERE (@Keyword = N'' OR CustomerName LIKE N'%' + @Keyword + N'%') ORDER BY OrderID OFFSET (@PageIndex - 1) * @PageSize ROWS FETCH NEXT @PageSize ROWS ONLY; END;

如果数据库是更老的SQL Server 2008版本,没有OFFSET FETCH,就要用ROW_NUMBER窗口函数包一层:

;WITH Paged AS ( SELECT OrderID, CustomerName, OrderAmount, OrderDate, ROW_NUMBER() OVER (ORDER BY OrderID) AS RowNum FROM dbo.Orders WHERE (@Keyword = N'' OR CustomerName LIKE N'%' + @Keyword + N'%') ) SELECT OrderID, CustomerName, OrderAmount, OrderDate FROM Paged WHERE RowNum BETWEEN (@PageIndex - 1) * @PageSize + 1 AND @PageIndex * @PageSize ORDER BY RowNum;

这两个写法分别在两个项目里用过。OFFSET FETCH写法优雅、执行计划更清晰,但要注意OFFSET后的表达式在版本兼容性上必须用括号包起来。ROW_NUMBER写法的优势是兼容老库,缺点是多一层CTE,数据量大时如果ORDER BY字段没有索引,性能会明显下滑。无论哪种写法,ORDER BY都必须基于唯一列(主键最合适),否则两个相同值的行可能在不同页里重复读出。

3.4 InitializeComponent里的事:常用控件的name别搞错

代码里用到的btnFirst、btnNext这些控件,要在Designer里先布好局并设置好各自name。最容易犯的错是把按钮Click事件直接和GoToPage绑定到同一个事件处理函数,结果点了“下一页”和点了“末页”没有区分。正确做法是每个按钮单独一个Click处理函数,里面只调对应方法,比如:

private void btnFirst_Click(object sender, EventArgs e) => FirstPage(); private void btnPrev_Click(object sender, EventArgs e) => PrevPage(); private void btnNext_Click(object sender, EventArgs e) => NextPage(); private void btnLast_Click(object sender, EventArgs e) => LastPage();

这种写法也叫“一行事件处理”,目的不是省代码,而是让每个按钮的语义清晰,将来如果有人要加“页码输入框跳转”,只需要在同一个GoToPage里加入口即可。

4. 避坑与排查:DataGridView分页控件上线后的五处翻车现场

分页控件本身状态简单,但一旦和DataGridView、BindingSource、数据库查询组合在一起,各种边界问题就出来了。下面五条来自我实际维护过的项目,每一条都是先看到现象,再定位原因,最后改掉。

4.1 翻页后选中行丢了:DataGridView重建数据源导致选择状态重置

现象:用户在第一页勾选了几行,翻到第二页再点回第一页,勾选状态全没了。如果业务需要“跨页批量处理”,这个现象直接没法用。

原因:PageChanged事件触发后,LoadCurrentPage里重新给DataSource赋了一个新DataTable对象。DataGridView把它们当成全新行,之前记录的SelectedRows和CurrentCell都是基于旧行的引用,新数据源里根本没有那些行的实例,于是选择状态被全部清空。

解决:翻页前记住用户勾选的关键列值(通常是主键ID),数据重新加载后再对照主键恢复选中状态。我在ToolStrip上放了一个“记住选择”的开关,开启状态下使用如下逻辑:

private HashSet<int> _checkedOrderIds = new(); // 翻页前:收集当前勾选的主键 private void BeforePageChanged() { _checkedOrderIds.Clear(); foreach (DataGridViewRow row in dataGridView1.Rows) { if (row.Selected && row.Cells["OrderID"].Value is int id) { _checkedOrderIds.Add(id); } } } // 翻页后:按主键恢复选中 private void RestoreSelection() { foreach (DataGridViewRow row in dataGridView1.Rows) { if (row.Cells["OrderID"].Value is int id && _checkedOrderIds.Contains(id)) { row.Selected = true; } } }

关键点是BeforePageChanged必须在页码变化前的用户操作里执行,比如我在按钮Click里先调用它,再调用PaginationControl的对应方法。不要试图在PageChanged事件内部区分“翻页前”还是“翻页后”,因为那个事件触发时页码已经变了,旧数据已经不在控件里。

4.2 总记录数显示错误:不是Count(*)查错,是查的位置不对

现象:数据明明有2000条,分页控件却显示“共1页”,而且点下一页没反应。

原因:LoadCurrentPage里取总数用的是pageData.Rows.Count,也就是当前页的行数。当前页20条,总页数算出来自然就是1。这是个非常隐蔽的低级错误,因为PageData.Rows.Count在只有一页时和总数看起来一样,一旦超过一页就露馅。

解决:总数必须用单独的SELECT COUNT(*)结果填充。我在存储过程里用OUTPUT参数返回Count,而不是在C#里查完再塞。注意在调用SetTotalCount之后、绑定DataSource之前,先判断是不是需要修正页码:

paginationControl.SetTotalCount(total); if (paginationControl.PageIndex != previousPageIndex) { // 页码被控件内部修正了,说明发生了越界,重新加载一次 LoadCurrentPage(); return; } dataGridView1.DataSource = pageData;

这段代码看起来有点绕,实际场景是这样:用户在第100页查看数据,另一个人删掉了一批记录,总页数降到了80。用户点下一页时,SetTotalCount发现当前PageIndex=100超出新的总页数80,会把PageIndex改成80。这时候如果不重新查一次,页面上显示的是“第100页”的要求,绑定的却是“第80页的旧数据”,等用户看清楚就懵了。所以加一个修正后的重新加载逻辑,属于分页控件的自我纠错。

4.3 快速翻页卡到怀疑人生:同步查询和全量截取是元凶

现象:连续点“下一页”四次,按钮像PPT一样一顿一顿,第四次点击后界面白屏几秒。

原因:在翻页事件里做了三件浪费性能的事。一是用了内存分页,每次翻页都先把全表10万行Load进DataTable,再Skip/Take。二是数据库分页SQL里没有索引,OFFSET跳过的行数越多越慢。三是查询在UI线程同步执行,数据库阻塞UI。

解决:按场景分层做。数据量真到了万级以上,内存分页就是错的,改成数据库分页是第一步。然后给排序列加索引,最后把加载改成异步:

private async void PaginationControl_PageChanged(object? sender, EventArgs e) { // 先清空当前数据源,避免用户看到旧页数据停留在界面上 dataGridView1.DataSource = null; var pageIndex = paginationControl.PageIndex; var pageSize = paginationControl.PageSize; var keyword = txtKeyword.Text.Trim(); var connStr = _connectionString; // 用 Task.Run 把数据库查询放到线程池,UI 线程不再阻塞 var (pageData, total) = await Task.Run(() => QueryOrders(connStr, pageIndex, pageSize, keyword)); // 防止用户已经翻页导致返回的数据不是当前页 if (pageIndex != paginationControl.PageIndex) return; paginationControl.SetTotalCount(total); dataGridView1.DataSource = pageData; }

这里有个竞态细节:用户快速点了两次下一页,第一次查询可能比第二次查询晚返回,导致页面显示的是第一页数据。我在返回后先判断pageIndex != paginationControl.PageIndex,发现页码已经改变就丢弃这次结果,只保留最后一次请求的结果。这就是“只响应最新请求”的翻页准则。

4.4 排序后翻页数据重复又缺失:ORDER BY不稳定

现象:用户点击DataGridView列头排序,再翻页,发现某些订单重复出现,另外一些订单怎么都看不到。

原因:排序后分页SQL没有按用户选择的列排序,或者ORDER BY的列有重复值。SQL Server在OFFSET FETCH时如果排序键不唯一,两行相同排序值的先后顺序是不确定的,翻页到了下一页可能又把上一页的尾部数据带过来了。

解决:分页的排序必须“唯一键兜底”。即在任何用户排序列后面都要追加一个唯一列:

SELECT OrderID, CustomerName, OrderAmount, OrderDate FROM dbo.Orders ORDER BY CustomerName ASC, OrderID ASC OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY;

如果是动态排序,用SqlParameter把排序列名白名单化后拼进去,一定不要直接拿用户传入的字符串拼ORDER BY。我在实际项目里维护了一个“列名映射表”,把界面列头和数据库列名对应关系写死在代码里,用户只传列头索引,程序再映射回数据库列名,这样既防止注入,也能保证排序值一定存在。

判断一个排序键是否够用的办法很简单:查一下这列数据有没有重复值,换成唯一键后再重新翻页,观察首尾两行是否和上一页衔接上。测试脚本不算复杂,但很多人会忘记这个验证步骤。

提示:动态排序字段一定要做白名单映射,不能直接拼接用户输入。

4.5 最后一页翻过头:总页数是0但页码却还在跳

现象:查询条件过滤后没有任何数据,此时分页控件显示“第 0 / 0 页”,点下一页抛ArgumentException。

原因:我在控件设计里处理了TotalCount为0时TotalPages返回0,但调用方代码写的是GoToPage(TotalPages)或者直接用PageIndex做参数去查询,当PageIndex等于0时,OFFSET变成了负数,数据库会报错。

解决:在GoToPage里把页码下限保护从1开始,同时对外层查询也要加一道防线:

if (paginationControl.TotalPages == 0) { dataGridView1.DataSource = null; paginationControl.SetTotalCount(0); return; }

判断标准以TotalPages==0为准,而不是以TotalCount==0为准。因为TotalCount为0时TotalPages可能被保护逻辑改成过0,TotalPages更能反映“当前有没有可显示的页”。把这道防线放在LoadCurrentPage最前面,就能避免空结果还触发查询。

这五类问题覆盖了从状态恢复、统计基准到性能、排序一致性和空边界的完整链路。分页控件真正做到“看着简单、用着不翻车”,这些细节比控件本身更值钱。

5. 让分页控件更进一步:大数据量下的虚拟模式、缓存与防抖

当分页控件已经稳定运行,下一步是把它变快。三个方向比较常用:DataGridView虚拟模式、已读页缓存、翻页防抖。

虚拟模式是DataGridView特有的机制。把VirtualMode设为true,然后接管CellValueNeeded事件,DataGridView只在某个单元格被绘制时向你索要数据,配合分页查询几乎完美。核心代码是:

dataGridView1.VirtualMode = true; dataGridView1.RowCount = paginationControl.PageSize; dataGridView1.CellValueNeeded += (s, e) => { // _currentPageRows 是当前页数据,按行索引和列索引取值 e.Value = _currentPageRows[e.RowIndex][e.ColumnIndex]; };

注意虚拟模式禁用了一部分自动编辑功能,适合只读展示场景,比如报表和查询结果。如果你的分页表格还需要自由编辑、新增行,虚拟模式会引入额外复杂度,这时候更合理的做法是缓存页数据。

已读页缓存是针对频繁回到前几页的场景,比如用户在前三页来回对比。用一个字典把已经加载过的页码存起来,命中缓存就免去一次数据库查询:

private Dictionary<int, DataTable> _pageCache = new(); private int _totalCount; private (DataTable, int) GetPageDataWithCache(int pageIndex, int pageSize, string keyword) { if (_pageCache.TryGetValue(pageIndex, out var cached)) { return (cached, _totalCount); } var (pageData, total) = QueryOrders(pageIndex, pageSize, keyword); _totalCount = total; _pageCache[pageIndex] = pageData; return (pageData, total); }

缓存的代价是内存,所以只缓存确定会回溯的页,并且数据变更时要全清空。很多项目把它做成LRU,限制缓存页数。

翻页防抖是我个人的习惯。用户在快速点下一页时,连续触发两次PageChanged会让两个查询并发返回,前文已经有竞态判断。更好的做法是给翻页操作加一个节流,300毫秒内的重复点击直接忽略,这样既省数据库压力,也不至于让界面闪烁。

private DateTime _lastPageChangeTime = DateTime.MinValue; public void GoToPage(int page) { // 如果距离上一次翻页不足300毫秒,直接忽略 if ((DateTime.Now - _lastPageChangeTime).TotalMilliseconds < 300) { return; } _lastPageChangeTime = DateTime.Now; // 原有翻页逻辑 }

这段节流要放在控件内部,因为外部无法统一控制多个按钮的点击频率。

从前做这个分页控件,第一版以为只要把按钮和页码算对就行,结果上线两周,每周都有用户报“翻页慢了”“选中的丢了”“排序后又乱了”。后来我把控件内部状态、外部查询、边界保护拆成三个独立部分,每改一条都先跑一遍边界用例,再没出过大问题。从那以后我每次接手带DataGridView的老项目,都会先问一句:这条分页链路上,排序键唯一吗,总数是单独统计的吗,空结果集能不能平稳显示?这三问过了,再谈其它优化。希望帮到你。

本文还有配套的精品资源,点击获取

相关新闻

图表设计方法论:从信息架构到视觉编码的完整链路

图表设计方法论:从信息架构到视觉编码的完整链路

1. 从"diagram-design"这个标题能读出什么第一次看到"diagram-design"这个标题&#xff0c;我脑子里蹦出来的第一个念头是&#xff1a;这大概率不是一个单纯的"画图工具推荐"&#xff0c;而是一套关于图表设计方法论的沉淀。为什么这么说&#x…

2026/10/20 5:45:16 阅读更多 →
数字验证码识别实战:基于OpenCV的传统图像处理流水线解析

数字验证码识别实战:基于OpenCV的传统图像处理流水线解析

验证码识别这东西&#xff0c;放在早几年还是件挺有门槛的事&#xff0c;现在虽然深度学习框架满天飞&#xff0c;但对“纯数字验证码”这类场景&#xff0c;传统图像处理路线依然有它不可替代的位置&#xff1a;不需要标注数据、不需要训练环境、单机跑起来快得飞起。这篇文章…

2026/10/20 5:45:16 阅读更多 →
WPF播放YUV裸流:D3DImage与GPU渲染性能优化实践

WPF播放YUV裸流:D3DImage与GPU渲染性能优化实践

简介&#xff1a;一份面向WPF开发者的D3D视频渲染示例工程&#xff0c;演示如何在WPF界面中借助硬件加速高效呈现YUV视频。项目由渲染核心库与演示应用两部分组成&#xff0c;围绕颜色空间转换、纹理创建与后台线程渲染展开&#xff0c;适合需要处理视频播放、画面预览或高性能…

2026/10/20 5:44:15 阅读更多 →

最新新闻

artcraft类创作工具技术架构与交互设计实战指南

artcraft类创作工具技术架构与交互设计实战指南

1. 从"artcraft"这个名字说起&#xff1a;一个被低估的创作工具定位问题第一次看到"artcraft"这个词&#xff0c;我脑子里蹦出来的第一反应是"艺术"加"手艺"的组合。这不是一个随便拼出来的名字&#xff0c;它暗示了一种很具体的产品气…

2026/10/20 6:27:33 阅读更多 →
从自由发挥到受控执行:大模型Skill机制实战

从自由发挥到受控执行:大模型Skill机制实战

1. 先弄明白&#xff1a;Skill到底解决的是什么问题1.1 大模型能力的边界在哪里如果你用过几款主流的大模型对话工具&#xff0c;就会发现一个奇怪的现象&#xff1a;让模型帮你"解释一段代码""写个正则表达式"很容易&#xff0c;但让它"按团队规范自…

2026/10/20 6:27:33 阅读更多 →
JavaFX+SSM架构实战:桌面客户端与数据服务端的高效整合

JavaFX+SSM架构实战:桌面客户端与数据服务端的高效整合

简介&#xff1a;这是一份融合 JavaFX 与 SSM 框架的 ERP 系统完整项目&#xff0c;面向需要将桌面 GUI 与企业级后端整合开发的 Java 学习者、毕业设计或项目实战人群&#xff0c;既能学习界面设计&#xff0c;也能掌握经典后端框架的落地方式。资源共包含 2 个文件&#xff1…

2026/10/20 6:27:33 阅读更多 →
Python与API文本分析实战:从文本清洗到工程化部署

Python与API文本分析实战:从文本清洗到工程化部署

AI应用的潜力&#xff0c;有一大半藏在文本里。文本分析这个方向&#xff0c;我自己前前后后折腾过不少方案&#xff0c;从早期的正则规则、词频统计&#xff0c;到开源模型微调&#xff0c;再到现在用 Python 接各类大模型 API 做文本分析&#xff0c;最大的感受是&#xff1a…

2026/10/20 6:27:33 阅读更多 →
IPv4报文头部详解:字段、分片与网络排查核心知识

IPv4报文头部详解:字段、分片与网络排查核心知识

做网络排查做得久了&#xff0c;你会发现一个特别有意思的现象&#xff1a;很多人能把TCP的三次握手、四次挥手讲得天花乱坠&#xff0c;可一旦你把抓包工具里的一帧数据展开&#xff0c;让他逐字节解释一下IPv4头部&#xff0c;他反而会卡壳。TCP、UDP这些传输层协议太出名&am…

2026/10/20 6:27:33 阅读更多 →
STM32 bxCAN实战:从物理层到过滤器配置与调试排障

STM32 bxCAN实战:从物理层到过滤器配置与调试排障

做嵌入式这几年&#xff0c;UART、I2C、SPI都是老朋友&#xff0c;但真正让我觉得“像个通信工程师”的&#xff0c;是开始啃STM32的CAN外设之后。原因很简单&#xff1a;在汽车、机器人和工业现场&#xff0c;CAN总线是默认的“老大哥”&#xff0c;而它在微控制器上的硬件实现…

2026/10/20 6:26:33 阅读更多 →

日新闻

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

CLRC663芯片Pin-to-Pin替代实战:从评估到量产全记录

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
ESP32舵机控制指南:原理、多路驱动与避坑实战

ESP32舵机控制指南:原理、多路驱动与避坑实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →
位移法实现一阶低通滤波:单片机ADC采样降噪实战

位移法实现一阶低通滤波:单片机ADC采样降噪实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 1:01:30 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/19 17:32:41 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 12:16:16 阅读更多 →