简介:一款专门用于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的老项目,都会先问一句:这条分页链路上,排序键唯一吗,总数是单独统计的吗,空结果集能不能平稳显示?这三问过了,再谈其它优化。希望帮到你。
本文还有配套的精品资源,点击获取