ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WinForm中DataGridView分页控件从选型到落地:策略、封装与避坑指南

WinForm中DataGridView分页控件从选型到落地:策略、封装与避坑指南 简介这是面向WinForm开发者的DataGridView分页控件封装了表格数据的分页逻辑可直接放到.NET桌面项目中解决数据量较多时表格浏览和操作不便的问题。控件整体设计较简洁调用方式不复杂适合需要快速分页的中初级开发者也可作为理解控件封装思路的学习样本。压缩包采用RAR格式共45个文件包含10个C#源码、5个DLL动态库、5个PDB调试文件、多个RESX资源文件以及可直接运行的EXE演示程序整体大小仅96KB属于典型轻量级控件包。源码目录按控件项目与测试项目组织既有DataGridView分页控件的实现代码也有用于验证效果的测试窗体。已有223人浏览学习资源附带完整源码可自行修改分页样式、按钮布局与数据源绑定方式并借助PDB文件进行调试跟踪对快速搭建WinForm后台管理列表或熟悉分页控件开发都有帮助。1. 为什么说 DataGridView 天生不带分页你需要的其实是一套分页策略把 winform 的 datagridview 分页控件当成一个拖到窗体就能用的现成组件是刚接触 C# 桌面端最容易踩的误判。DataGridView 本身只负责“把数据摊开显示”翻页、计数、切片这些逻辑都得由外部代码提供网上开源的所谓分页控件多半只做了底部那一排按钮数据层怎么拆、总数怎么拿、排序怎么办全留给你自己填。这篇文章把我自己在 WinForm 项目里反复用的一整套落地路径拆开讲先选分页策略再定接口然后封装翻页 UI最后把数据库端分页 SQL 和 Dapper 接进去结尾用 VirtualMode 解决大数据量卡顿。无论你的表是几千行还是几百万行照着这个思路都能落出一套不半夜响电话的分页方案。2. 分页方案先选型客户端分页、数据库端分页还是 API 分页2.1 三种分页的数据流与适用边界分页控件只是皮真正的分页策略才是里子。我在 WinForm 项目里见过最多的做法有三种。第一种是客户端分页启动时一把梭把整张表 Load 进 DataTable然后用 Skip/Take 切页第二种是数据库端分页每次翻页只向数据库要当前页那几十行第三种是 API 分页WinForm 通过 HTTP 访问后端接口由接口返回标准分页结构。客户端分页最省事但那是有代价的。几千行的配置表用它很舒服加载快、切页零延迟用户会觉得“这系统真快”。但只要表上了十万行或者行里带几个大字段内存和首次加载时长立刻开始教你做人。数据库端分页刚好相反每次翻页都发一条带 OFFSET/FETCH 的 SQL网络往返多一些但内存占用恒定数据量翻十倍也扛得住。API 分页本质上和数据库端分页是一条路只是多了一层序列化和网络传输适合项目里已经存在 WCF/WebAPI 中间层的团队。我的选型原则很简单直连数据库就无脑选数据库端分页走 API 就让后端按标准分页响应返回客户端分页只用来兜底小数据量场景。WinForm 不像 Web 有天然的分页组件真正成熟的项目里都是自己定一套分页约定再配合一个轻量用户控件而已。2.2 分页引擎的接口设计:把“总数”和“当前页数据”拆开不管选哪种分页策略核心都要先定一个分页结果类型。这个类型是所有后续代码的地基我一般这样写:public class PagedResultT { public int TotalCount { get; set; } public int PageIndex { get; set; } public int PageSize { get; set; } public IListT Rows { get; set; } public int PageCount (int)Math.Ceiling(TotalCount / (double)PageSize); }逻辑说明:TotalCount 是过滤后的总行数,不是表总行数;PageIndex 从 1 开始,不要学数组从 0 开始,否则 SQL 里 OFFSET 计算容易绕晕。PageCount 用 Ceiling 向上取整,才能保证最后不满一页的数据也有一个页码。PageSize 在仓储层构造时就要校验大于 0,别等 SQL 报错才排查。参数说明:TotalCount 和 Rows 必须成对出现,否则界面上的“共 N 页”就是瞎编的。有了这个结果类型,分页仓储的接口就固定成两个方法:public interface IPagedRepositoryT { Taskint CountAsync(object filter); TaskPagedResultT GetPageAsync(int pageIndex, int pageSize, string orderBy, object filter); }逻辑说明:CountAsync 和 GetPageAsync 分开,是因为数据库端分页天然需要两条 SQL,一条取总数、一条取数据。接口层面不写死排序逻辑,把 orderBy 作为参数传进来,这样 DataGridView 列头点击排序时才能复用同一套分页逻辑。参数说明:filter 用一个匿名对象或者 Dictionary 传递,比拼 SQL 字符串再参数化靠谱得多。3. 封装 PagerControl:翻页按钮、页码算法与 DataGridView 绑定3.1 用户控件的 UI 骨架与最小事件分页控件做成 UserControl 最合适,方便拖到多个窗体复用。我通常放一排按钮——首页、上一页、页码区、下一页、末页,再加一个 ComboBox 显示“第 X / Y 页”。界面美化不用花太多心思,把按钮 FlatStyle 设为 Flat 或者 System,再统一一下 BackColor,就和默认样式的 DataGridView 很配了。public partial class PagerControl : UserControl { public int PageIndex { get; private set; } 1; public int PageSize { get; set; } 20; public int PageCount { get; private set; } 1; public int TotalCount { get; private set; } public event EventHandler PageIndexChanged; public PagerControl() { InitializeComponent(); } public void SetPageInfo(int totalCount, int pageIndex, int pageSize) { TotalCount totalCount; PageSize pageSize; PageCount (int)Math.Ceiling(totalCount / (double)pageSize); PageIndex Math.Min(pageIndex, Math.Max(1, PageCount)); RenderPager(); } }逻辑说明:SetPageInfo 是外部数据源喂给控件的唯一入口。PageIndex 在这里被钳制到 1 到 PageCount 之间,页面数据量变化时也不会出现“当前页超出总页数”的空窗。RenderPager 负责按当前状态重绘所有按钮和页码。参数说明:PageSize 一旦在窗体内固定就不要在每次翻页时改,否则用户会看到页大小忽大忽小。翻页事件只暴露一个 PageIndexChanged,外部订阅它去做数据查询:private void btnPrev_Click(object sender, EventArgs e) { if (PageIndex 1) return; PageIndex--; PageIndexChanged?.Invoke(this, EventArgs.Empty); } private void btnNext_Click(object sender, EventArgs e) { if (PageIndex PageCount) return; PageIndex; PageIndexChanged?.Invoke(this, EventArgs.Empty); }逻辑说明:按钮的禁用状态也在 RenderPager 里统一处理。首页在第一页时禁用,末页在最后一页时禁用,中间页码按钮如果不满足可见范围就整体隐藏。参数说明:事件用空参数最简单,订阅方通过读取控件上的 PageIndex 属性拿当前页,不用自定义 EventArgs 也能跑通。3.2 页码按钮生成算法:两端省略不乱跳页码按钮不可能全都摆出来,100 页放 100 个按钮既不美观也没人点。我一般只显示当前页附近的 5 个页码,超出用省略号表示,但省略号不做点击,只占位。private IEnumerableint GetVisiblePageNumbers(int visibleCount 5) { int start Math.Max(1, PageIndex - visibleCount / 2); int end Math.Min(PageCount, start visibleCount - 1); start Math.Max(1, end - visibleCount 1); for (int i start; i end; i) { yield return i; } }逻辑说明:先按 PageIndex 居中计算窗口,再修正边界。比如当前页是 1 而总页数是 10,窗口会缩到 1 到 5;当前页是 3,窗口是 1 到 5 不动;当前页是 8,窗口是 6 到 10。这个算法能保证窗口的首尾永远不越界,也不会出现“显示 2 到 6,但 1 页没了”的尴尬。参数说明:visibleCount 设 3 或 5 都行,但必须取奇数,否则当前页在窗口里不在正中,视觉上会偏。RenderPager 里用一个 FlowLayoutPanel 动态创建按钮,每次重绘前 Clear 再 Add。动态创建按钮不需要炫技,直接 new Button 设置 Tag 存页码就行,要点是点击后执行:private void OnPageNumberClicked(object sender, EventArgs e) { if (sender is Button btn btn.Tag is int page) { if (page PageIndex) return; PageIndex page; PageIndexChanged?.Invoke(this, EventArgs.Empty); } }3.3 把分页结果绑定到 DataGridView 的两种姿势拿到 PagedResult 之后,绑定方式有讲究。最省事的做法是直接赋给 DataSource,DataGridView 会自动生成列,适合快速看到效果。但对于生产项目,我更推荐使用 BindingList ,因为它在数据增删时会自动通知控件刷新。public void BindPage(PagedResultOrderDTO result) { pagerControl.SetPageInfo(result.TotalCount, result.PageIndex, result.PageSize); var bindingList new BindingListOrderDTO(result.Rows.ToList()); orderDataGridView.DataSource bindingList; }逻辑说明:每次翻页都 new 一个新的 BindingList,而不是复用旧的,否则 DataGridView 会因为增量更新而闪烁甚至错位。参数说明:OrderDTO 必须是无参构造函数可用的普通类,并且属性是 public set,否则 BindingList 无法创建行。列头的 AutoGenerateColumns 建议关掉,手动配好 DataPropertyName,这样列宽、格式都是可控的,不会被字段顺序打乱。4. 数据库端分页落地:SQL 写法、Dapper 接入与排序白名单4.1 SQL Server 分页的两种写法怎么选数据库端分页在 SQL Server 上就两条路。老项目常用 ROW_NUMBER() OVER:SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY OrderDate DESC) AS __rn, * FROM Orders ) AS t WHERE __rn BETWEEN start AND end逻辑说明:内层先按 OrderDate 排序并生成行号 start 和 end,BETWEEN 取区间。这个写法兼容 SQL Server 2005 以上的所有版本。参数说明:start (pageIndex - 1) * pageSize 1,end pageIndex * pageSize,BETWEEN 是闭区间,别把 start 直接写成 (pageIndex-1)*pageSize,那是 OFFSET 的算法。SQL Server 2012 起有了专门的 OFFSET/FETCH,写法清爽得多:SELECT * FROM Orders ORDER BY OrderDate DESC OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY;逻辑说明:OFFSET 是跳过的行数,FETCH NEXT 是取多少行,语义比 ROW_NUMBER 直白。参数说明:OFFSET (pageIndex - 1) * pageSize,FETCH 就是 pageSize,不需要再加减 1。需要特别注意的是,OFFSET/FETCH 前面必须有 ORDER BY,这是语法强制的,没写会直接报错。两个写法在小数据量下性能差别不大,ROW_NUMBER 的排序自由度更高,OFFSET/FETCH 的 SQL 更短更易读。我的习惯是:项目还在用老版本 SQL Server 就用 ROW_NUMBER,新项目一律 OFFSET / FETCH。索引方面,无论哪种写法,ORDER BY 的字段最好有索引,否则越翻到后面越慢。4.2 用 Dapper 实现分页仓储与参数化数据访问层我用 Dapper,热词里提到的依赖注入在这里顺理成章。把 IDbConnection 注册进容器,仓储类构造函数接收它,翻页方法就干干净净:public class OrderPagedRepository : IPagedRepositoryOrderDTO { private readonly IDbConnection _conn; public OrderPagedRepository(IDbConnection conn) { _conn conn; } public async Taskint CountAsync(object filter) { return await _conn.ExecuteScalarAsyncint( SELECT COUNT(1) FROM Orders WHERE Status status, filter); } public async TaskPagedResultOrderDTO GetPageAsync( int pageIndex, int pageSize, string orderBy, object filter) { string safeOrderBy SortFieldValidator.Ensure(orderBy); int offset (pageIndex - 1) * pageSize; var rows await _conn.QueryAsyncOrderDTO( $SELECT * FROM Orders WHERE Status status ORDER BY {safeOrderBy} OFFSET offset ROWS FETCH NEXT pageSize ROWS ONLY, new { offset, pageSize, status ((dynamic)filter).status }); return new PagedResultOrderDTO { TotalCount await CountAsync(filter), PageIndex pageIndex, PageSize pageSize, Rows rows.ToList() }; } }逻辑说明:CountAsync 和 GetPageAsync 都接收同一个 filter 对象,保证总数和数据是同一个过滤条件。safeOrderBy 不能直接拼接前端传的字符串,必须先过白名单,这是防注入的底线。参数说明:offset 和 pageSize 必须参数化,不能拼进 SQL;Status 条件来自匿名对象,和 Dapper 的参数映射严格对名字,大小写不敏感但名字不能错。如果需要依赖注入,常见的做法是这样注册:services.AddSingletonIDbConnection(sp new SqlConnection(config.GetConnectionString(Default))); services.AddScopedIPagedRepositoryOrderDTO, OrderPagedRepository();逻辑说明:IDbConnection 注册成 Singleton 有风险,如果仓储是 Scoped,连接生命周期跟着请求走没问题;但 WinForm 是长生命周期应用,建议用一个连接工厂而不是 Singleton 持有一个连接,否则连接长时间开着会被数据库端断开。参数说明:AddSingleton 本身没禁止,只是我要提醒你,WinForm 里最稳妥的是每次开个新连接,用完就 Dispose,别把连接对象当单例一直揣着。4.3 排序白名单与 COUNT 的性能取舍ORDER BY 的字段如果允许用户通过列头点击动态传,那 SQL 注入的开口就在这。白名单校验是最实用的方案:public static class SortFieldValidator { private static readonly HashSetstring _allowed new( new[] { OrderId, OrderDate, CustomerName, Amount }, StringComparer.OrdinalIgnoreCase); public static string Ensure(string orderBy) { if (string.IsNullOrWhiteSpace(orderBy)) return OrderId DESC; var parts orderBy.Trim().Split( ); if (!_allowed.Contains(parts[0])) return OrderId DESC; var direction parts.Length 1 parts[1].Equals(ASC, StringComparison.OrdinalIgnoreCase) ? ASC : DESC; return ${parts[0]} {direction}; } }逻辑说明:只放行白名单里的列名,方向只认 ASC/DESC,其他任何输入都回退默认排序。这块不能偷懒用正则过滤,黑名单永远挡不住变体写法。参数说明:Contains 用 StringComparer.OrdinalIgnoreCase,保证大小写不敏感,不然用户拖一列“amount”就直接被回退默认排序了。COUNT(1) 在大表上确实慢,但绝大多数业务表在百万行以下,加上过滤条件的索引后 COUNT(1) 秒回,不用过度优化。如果表真的几千万行了,可以考虑用 sys.dm_db_partition_stats 拿估算总数,但那是另一个话题,分页控件层面保持 COUNT 的准确性更难得。5. 分页控件避坑:翻过车才知道的几个常见坑5.1 没有稳定 ORDER BY,分页越翻越乱现象:第一页和第二页都出现了同一条数据,某条数据却一直不出现;或者翻到最后一页数据对不上。原因:OFFSET/FETCH 依赖一个稳定有序的结果集。ORDER BY 后面跟的字段如果不是唯一的,数据库在并行扫描时无法保证相同排序下每次返回的行序一致,于是出现“上下页重叠”。这是我接手的项目里翻车率最高的坑,而且代码逻辑上完全看不出来,玄学味道最重。解决:ORDER BY 必须带一个唯一键兜底。比如ORDER BY OrderDate DESC, OrderId DESC,即使业务字段相同,OrderId 也能把顺序钉死。白名单校验里也要强制追加主键,不能让调用方只排一个非唯一字段。5.2 删除最后一条记录后,当前页变成空页现象:用户在最后一页删了几条数据,删完点“下一页”,界面一片空白,页码按钮却还亮着。原因:删除后 TotalCount 变小,PageCount 跟着变小,但控件的 PageIndex 还保留着原来的值。此时 PageIndex 已经大于 PageCount,查询自然返回空结果。解决:重新绑定分页结果的那条代码里,对 PageIndex 做一次钳制:int safePageIndex Math.Min(currentPageIndex, Math.Max(1, pageCount));那行pagerControl.SetPageInfo里已经有这个逻辑了,关键是查询前要把钳制后的页码拿回来,再传给仓储。删数据之后刷新分页,顺序永远是“先取总数 → 算安全页码 → 再查当前页数据”。5.3 排序与分页打架:点列头只排了当前页现象:DataGridView 的列头默认可以点击排序,点了之后列表确实按字段排了,但翻到下一页,顺序又变回原来的默认排序,两页之间的数据还互相穿插。原因:DataGridView 自带的排序是基于 DataSource 的,它只对当前绑定到 UI 的这几十行生效。分页查询在数据库端,下一次翻页又重新执行 SQL,客户端排序结果直接被覆盖。解决:禁用 DataGridView 自带的列头排序,在 ColumnHeaderMouseClick 事件里自己触发重新查询:orderDataGridView.ColumnHeaderMouseClick (sender, e) { if (e.ColumnIndex 0) return; string columnName orderDataGridView.Columns[e.ColumnIndex].DataPropertyName; string direction _sortDirection ASC ? DESC : ASC; _sortExpression ${columnName} {direction}; LoadPage(pagerControl.PageIndex); };逻辑说明:排序条件保存到字段 _sortExpression,翻页时传给分页仓储,由 SQL 完成真正的排序。参数说明:列头点击要切换升降序,所以用一个 _sortDirection 记录上一次方向。排序白名单在这里也复用,ColumnHeaderMouseClick 里传的 DataPropertyName 先过一遍校验再拼进 SQL。5.4 BindingSource 的 Filter 叠加分页后计数错乱现象:有人图省事给 BindingSource 设置 Filter,界面上确实过滤出了想要的行,但分页控件显示的总数还是过滤前的,翻页也会翻到过滤掉的数据。原因:BindingSource.Filter 是客户端内存过滤,它只作用于已经绑定到 DataGridView 的当前页数据。总数来自 SQL 的 COUNT,两条路径各走各的,结果必然对不上。解决:不要在分页方案里用 BindingSource.Filter。过滤条件要传到仓储层,拼进 WHERE 子句,COUNT 和数据查询用同一个 filter 对象,这样总数和数据天然一致。如果你已经在项目里用 Filter 写了 300 行逻辑,迁到仓储层的成本确实不低,但这是分页正确性的必经之路,没有后悔药可吃。5.5 页码跳转控件把 TotalCount 为 0 的场景漏掉现象:表里一条数据都没有的时候,分页控件显示“第 1 / 1 页”看起来还算正常,但有的实现直接显示“第 1 / 0 页”,或者点击翻页按钮直接异常。原因:PageCount 计算时,TotalCount 为 0,被 0 除或者 Ceiling 出一个负数,边界没处理。解决:SetPageInfo 里特判总数为 0:if (totalCount 0) { PageCount 1; PageIndex 1; btnFirst.Enabled btnPrev.Enabled btnNext.Enabled btnLast.Enabled false; RenderPager(); return; }逻辑说明:总数 0 时,页面保留在第 1 页但所有翻页按钮禁用,DataGridView 显示空表。这样用户不会疑惑,也不会触发越界查询。参数说明:禁用按钮要在 RenderPager 之前,否则 Render 里根据 PageIndex/PageCount 的计算仍可能把按钮重新点亮。6. 进阶:用 VirtualMode 让分页控件喂饱大数据量表格分页做到这一步,几万行的表已经轻松应对。但有一种场景分页也吃力——用户希望滚动条代表全量数据,而不是只代表当前页。此时 DataGridView 的 VirtualMode 是唯一出路。它不给你数据,而是在需要显示某个单元格时回调 CellValueNeeded,你只要返回对应行的值即可。这样内存里只保留少量页的数据,滚动条却按全量总行数绘制。orderDataGridView.VirtualMode true; orderDataGridView.RowCount totalCount; orderDataGridView.CellValueNeeded DataGridView1_CellValueNeeded;回调里维护一个页缓存:private OrderDTO[] _pageCache Array.EmptyOrderDTO(); private int _cacheStartRow; private void DataGridView1_CellValueNeeded(object sender, DataGridViewCellValueEventArgs e) { int local e.RowIndex - _cacheStartRow; if (local 0 || local _pageCache.Length) { int targetPage e.RowIndex / pagerControl.PageSize 1; LoadPageIntoCache(targetPage); local e.RowIndex - _cacheStartRow; if (local 0 || local _pageCache.Length) return; } e.Value orderDataGridView.Columns[e.ColumnIndex].DataPropertyName switch { OrderId _pageCache[local].OrderId, OrderDate _pageCache[local].OrderDate.ToString(yyyy-MM-dd), Amount _pageCache[local].Amount, _ null }; } private void LoadPageIntoCache(int pageIndex) { var result _repo.GetPageAsync(pageIndex, pagerControl.PageSize, _sortExpression, _filter) .GetAwaiter().GetResult(); _cacheStartRow (pageIndex - 1) * pagerControl.PageSize; _pageCache result.Rows.ToArray(); }逻辑说明:滚动条拉到任何位置,RowIndex 会触发对应的 CellValueNeeded,该回调判断目标行不在缓存内时,就按行号反推页码加载那一页。这样用户拖到第 5000 行,也只加载那一页的数据,内存占用完全不随数据量增长。参数说明:RowCount 必须在打开 VirtualMode 之后设置,设置 RowCount 本身就会触发大量 CellValueNeeded,所以要先挂事件再给值。缓存未命中时的同步等待是无奈之举,生产环境建议用异步预取加进度提示,顺带在 StatusStrip 里更新状态栏和进度条,体验会好很多。VirtualMode 不只是分页控件的延伸,它本身就是一种分页——以“延迟加载”为手段的分页。我自己的经验是:先让普通分页流程跑通,再切 VirtualMode 优化交互,两步走比一步到位稳得多。这个方案整整用了三年,期间踩过的坑基本都集中在上面的避坑清单里;生产环境里把那几条边界条件处理干净,分页这块就再也不需要半夜爬起来看日志了。希望帮到你。本文还有配套的精品资源点击获取
返回列表