简介:这是一套面向WinForm初学者与中级开发者的实用分页控件实战资源,聚焦数据库海量数据分页展示这一典型性能痛点,帮助开发者快速集成可复用、带SQL后端支持的分页功能。资源包共65个文件,含20个C#核心逻辑文件(如PagerControl.cs、Form1.cs等)、8个本地化资源文件(.resx)、2个解决方案工程文件(.sln/.csproj)及配套编译输出(.dll/.exe)、调试符号(.pdb)和配置文件(.settings/.xml),整体仅218KB,轻量易导入。已有836人学习下载,资源结构清晰,包含完整UI窗体、分页控件自定义实现、SQL数据绑定逻辑及启动入口,无需额外依赖即可运行调试。读者可直接复用控件源码、理解OFFSET/FETCH分页查询写法、掌握DataGridView分页事件处理与状态同步机制,并参考其工程组织方式优化自身项目架构。
1. WinForm 分页控件真能“开箱即用”?别被 Demo 欺骗:它解决的是 DataGridView 绑定 SQL 查询结果时,翻页卡顿、内存暴涨、SQL 重复执行、页码跳转失灵这四大高频翻车现场
你写了个 WinForm 程序,用SqlDataAdapter.Fill(dataset)把几万行订单数据塞进DataGridView,加个BindingSource做分页——结果一翻到第 50 页,界面直接假死;换种写法,每次翻页都重查全表再Skip().Take(),CPU 占满 80%,SQL Server 日志里全是重复慢查询;更糟的是,用户点“第 100 页”,控件报错Index was out of range,后台日志却显示SELECT COUNT(*)返回了 9876 行……这不是控件不好,是绝大多数人没搞清:WinForm 的分页控件不是 UI 装饰,而是 SQL 层与 UI 层之间的状态协调器。它必须同时管住三件事:SQL 查询的精准分片(不是靠 C# 内存过滤)、总页数的原子性获取(避免 COUNT(*) 和实际数据不一致)、以及翻页动作与数据库连接生命周期的解耦。本文面向已能写基础 ADO.NET 的开发者,不讲BindingSource基础用法,只聚焦一个目标:用最小侵入方式,把你的现有 SQL 查询无缝接入一个真正可控、可调试、不翻车的分页流程。你会看到,核心代码不到 20 行,但每行都踩过血坑。
2. 为什么不用 BindingSource + DataTable 做分页?先拆穿三个“看起来很美”的幻觉
2.1 幻觉一:“BindingSource 支持 PageSize,设置一下就自动分页”
这是最典型的认知偏差。BindingSource.PageSize只控制UI 层可见行数,底层DataTable仍加载全部数据。验证方法极简单:在Form_Load中写:
var dt = new DataTable(); using (var adapter = new SqlDataAdapter("SELECT * FROM Orders", conn)) { adapter.Fill(dt); // 这里已把全部 10 万行读入内存! } bindingSource.DataSource = dt; bindingSource.PageSize = 20; dataGridView1.DataSource = bindingSource;提示:用 Windows 性能监视器看 .NET Memory 工作集,加载瞬间飙升;用 SQL Server Profiler 抓包,只会看到一条全表扫描语句。
PageSize在这里纯属“障眼法”。
2.2 幻觉二:“用 LINQ Skip/Take 在内存里分页,代码简洁”
常见写法:
var allData = GetOrdersFromDb(); // 返回 List<Order> var paged = allData.Skip((page - 1) * pageSize).Take(pageSize).ToList();问题在于:GetOrdersFromDb()若返回 10 万行,Skip(999 * 20)需遍历前 19980 个对象才取下 20 个——时间复杂度 O(n),且所有对象长期驻留 GC Heap。实测 5 万行数据翻页延迟从 40ms 涨到 1200ms,GC 压力陡增。这不是分页,是“内存筛沙”。
2.3 幻觉三:“用存储过程封装分页逻辑,一劳永逸”
比如写个sp_GetOrdersPaged @PageNo, @PageSize,内部用ROW_NUMBER() OVER (ORDER BY OrderId)。看似完美,但埋下三个雷:
- 排序字段必须唯一:若
ORDER BY Status,多行同值时ROW_NUMBER()排序不稳定,同一页数据反复跳变; - COUNT(*) 与分页查询分离:存储过程里
SELECT COUNT(*)和SELECT ... ROW_NUMBER()是两次独立查询,若中间有 INSERT/DELETE,页码会错乱; - 参数化困难:动态 WHERE 条件(如
@SearchKey)需拼 SQL,极易引入注入风险,且无法利用查询计划缓存。
注意:这三个幻觉共同指向一个事实——真正的分页控件,必须让 SQL 层承担分片责任,UI 层只负责状态同步,二者通过明确契约(而非隐式绑定)协作。
3. 手把手实现:一个仅依赖 System.Data 的轻量分页控件(无第三方 NuGet)
3.1 核心设计原则:三接口一契约
我们不造轮子,只定义清晰边界:
- IPageableQuery:描述“如何生成分页 SQL”,含
GetCountSql()和GetPageSql(int page, int pageSize); - IPageState:管理当前页码、总页数、每页条数等状态,支持
GoToPage(int)、Next()、Prev(); - IPagedResult:封装查询结果
List<T>和TotalCount,供 UI 消费; - 契约:
GetPageSql()必须返回带ORDER BY的确定性排序,且GetCountSql()与GetPageSql()的WHERE条件完全一致(连参数名都不能差)。
3.2 关键代码:分页 SQL 生成器(支持 SQL Server / MySQL / SQLite)
public static class SqlPagingBuilder { public static (string countSql, string pageSql) BuildForSqlServer( string baseSelect, string fromClause, string whereClause, string orderBy, int pageSize) { var countSql = $"SELECT COUNT(*) FROM {fromClause} {whereClause}"; // 关键:用 OFFSET-FETCH,要求 SQL Server 2012+,且 ORDER BY 必须存在 var pageSql = $@" {baseSelect} FROM {fromClause} {whereClause} ORDER BY {orderBy} OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY"; return (countSql, pageSql); } public static (string countSql, string pageSql) BuildForMySql( string baseSelect, string fromClause, string whereClause, string orderBy, int pageSize) { var countSql = $"SELECT COUNT(*) FROM {fromClause} {whereClause}"; // MySQL 5.7+ 支持 LIMIT offset, size var pageSql = $@" {baseSelect} FROM {fromClause} {whereClause} ORDER BY {orderBy} LIMIT @Offset, @PageSize"; return (countSql, pageSql); } }逻辑说明:
OFFSET-FETCH(SQL Server)和LIMIT offset, size(MySQL)是数据库原生分页,性能远超ROW_NUMBER();@Offset计算为(currentPage - 1) * pageSize,由调用方传入;orderBy必须包含主键(如OrderId),确保排序稳定。
3.3 分页状态管理器:避免手动计算页码的硬编码
public class PageState : INotifyPropertyChanged { private int _currentPage = 1; private int _pageSize = 20; private int _totalCount; private int _totalPages; public int CurrentPage { get => _currentPage; set { if (value < 1) value = 1; if (value > _totalPages && _totalPages > 0) value = _totalPages; if (_currentPage != value) { _currentPage = value; OnPropertyChanged(); OnPageChanged?.Invoke(this, EventArgs.Empty); } } } public int TotalCount { get => _totalCount; set { _totalCount = value; _totalPages = (int)Math.Ceiling((double)_totalCount / _pageSize); OnPropertyChanged(nameof(TotalPages)); } } public int TotalPages => _totalPages; public int PageSize => _pageSize; public event EventHandler? OnPageChanged; public event PropertyChangedEventHandler? PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); }参数说明:
CurrentPage的 setter 自动做越界校验(页码不能小于 1,也不能超过TotalPages);TotalCount的 setter 重新计算TotalPages并触发 UI 更新;INotifyPropertyChanged是BindingSource绑定页码的基础。
3.4 数据访问层:一次查询完成计数与分页(防数据不一致)
public class PagedQueryExecutor { private readonly string _connectionString; private readonly IDbConnection _connection; public PagedQueryExecutor(string connectionString) { _connectionString = connectionString; _connection = new SqlConnection(connectionString); // 或 MySqlConnector.MySqlConnection } public async Task<IPagedResult<T>> ExecuteAsync<T>( IPageableQuery query, PageState state, Func<IDataReader, T> mapper, CancellationToken ct = default) { // 步骤1:先查总数(关键!必须与分页查询条件完全一致) var countSql = query.GetCountSql(); var totalCount = await QueryScalarAsync<int>(countSql, query.GetParameters(), ct); // 步骤2:更新状态(触发 UI 页码重绘) state.TotalCount = totalCount; // 步骤3:查当前页数据 var pageSql = query.GetPageSql(state.CurrentPage, state.PageSize); var parameters = query.GetParameters(); parameters.Add(new SqlParameter("@Offset", (state.CurrentPage - 1) * state.PageSize)); parameters.Add(new SqlParameter("@PageSize", state.PageSize)); var list = await QueryListAsync<T>(pageSql, parameters, mapper, ct); return new PagedResult<T>(list, totalCount); } private async Task<T> QueryScalarAsync<T>(string sql, IEnumerable<IDbDataParameter> parameters, CancellationToken ct) { using var cmd = _connection.CreateCommand(); cmd.CommandText = sql; foreach (var p in parameters) cmd.Parameters.Add(p); await _connection.OpenAsync(ct); try { var result = await cmd.ExecuteScalarAsync(ct); return (T)(result ?? default(T)); } finally { await _connection.CloseAsync(); } } private async Task<List<T>> QueryListAsync<T>( string sql, IEnumerable<IDbDataParameter> parameters, Func<IDataReader, T> mapper, CancellationToken ct) { var list = new List<T>(); using var cmd = _connection.CreateCommand(); cmd.CommandText = sql; foreach (var p in parameters) cmd.Parameters.Add(p); await _connection.OpenAsync(ct); try { using var reader = await cmd.ExecuteReaderAsync(ct); while (await reader.ReadAsync(ct)) { list.Add(mapper(reader)); } } finally { await _connection.CloseAsync(); } return list; } }逻辑说明:
ExecuteAsync是原子操作——先COUNT(*),立即更新PageState.TotalCount,再查分页数据;mapper是Func<IDataReader, T>,将DataReader映射为业务对象(如Order),避免DataTable冗余;@Offset和@PageSize参数在GetPageSql()后动态追加,保证 SQL 安全。
4. 避坑指南:WinForm 分页控件落地时的 4 个致命细节
4.1 现象:翻页后 DataGridView 显示空白,但PageState.CurrentPage正确
原因:DataGridView.DataSource直接绑定了List<T>,而List<T>不支持INotifyCollectionChanged,UI 不感知数据变更。
解决:改用BindingList<T>或BindingSource中转。正确写法:
// 错误:dataGridView1.DataSource = result.Data; // List<Order> 不触发刷新 // 正确: var bindingList = new BindingList<Order>(result.Data); bindingSource.DataSource = bindingList; // bindingSource 已绑定到 dataGridView14.2 现象:点击“下一页”后,页码跳到 2,但数据显示第 1 页内容
原因:PageState.CurrentPage更新后,未触发PagedQueryExecutor.ExecuteAsync()重查数据,UI 状态与数据状态脱节。
解决:在PageState.OnPageChanged事件中调用查询。完整链路:
pageState.OnPageChanged += async (s, e) => { var result = await executor.ExecuteAsync(query, pageState, reader => MapOrder(reader)); bindingList.Clear(); foreach (var item in result.Data) bindingList.Add(item); // 更新页码标签:lblPageInfo.Text = $"第 {pageState.CurrentPage} 页,共 {pageState.TotalPages} 页"; };4.3 现象:SQL Server 报错 “The ORDER BY clause is invalid in views, inline functions, derived tables, subqueries, and common table expressions…”
原因:OFFSET-FETCH要求ORDER BY子句必须存在,且不能在子查询中使用(如SELECT * FROM (SELECT ...) t)。
解决:确保GetPageSql()返回的 SQL 是顶层SELECT,且ORDER BY字段明确、可索引。例如:
// 错误:var pageSql = "SELECT * FROM (SELECT OrderId, CustomerName FROM Orders) t ORDER BY OrderId OFFSET ..."; // 正确:var pageSql = "SELECT OrderId, CustomerName FROM Orders ORDER BY OrderId OFFSET ...";4.4 现象:搜索框输入关键词后分页,但切换页码时搜索条件丢失
原因:IPageableQuery实例被复用,但WHERE条件未随搜索文本动态更新。
解决:将搜索逻辑封装进IPageableQuery实现类,每次搜索后重建实例。示例:
public class OrderQuery : IPageableQuery { private readonly string _searchKey; public OrderQuery(string searchKey = "") => _searchKey = searchKey; public string GetCountSql() => $"SELECT COUNT(*) FROM Orders WHERE (@SearchKey = '' OR CustomerName LIKE @SearchKey)"; public string GetPageSql(int page, int pageSize) => $"SELECT OrderId, CustomerName, Amount FROM Orders WHERE (@SearchKey = '' OR CustomerName LIKE @SearchKey) ORDER BY OrderId OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY"; public List<IDbDataParameter> GetParameters() { var p = new List<IDbDataParameter>(); p.Add(new SqlParameter("@SearchKey", $"%{_searchKey}%")); return p; } } // 搜索时:query = new OrderQuery(txtSearch.Text);5. UI 层集成:用标准 WinForm 控件搭出专业分页栏(无自定义控件)
5.1 布局设计:用 TableLayoutPanel 实现响应式分页栏
拖一个TableLayoutPanel到窗体,设ColumnCount=5,RowCount=1,列宽设为AutoSizeMode(内容自适应)。从左到右依次放:
Label:显示“共 X 条记录”;Button:首页(|<<);Button:上一页(<);NumericUpDown:页码输入框(Minimum=1,Maximum=9999);Button:下一页(>),末页(>>);Label:显示“第 X 页,共 Y 页”。
提示:
NumericUpDown的ValueChanged事件直接赋值pageState.CurrentPage,INotifyPropertyChanged会自动触发查询;按钮 Click 事件调用pageState.Next()等方法。
5.2 事件绑定:让按钮与 PageState 真正联动
// 首页按钮 btnFirst.Click += (s, e) => pageState.CurrentPage = 1; // 上一页按钮 btnPrev.Click += (s, e) => pageState.CurrentPage--; // 页码输入框 numPage.ValueChanged += (s, e) => pageState.CurrentPage = (int)numPage.Value; // 下一页按钮 btnNext.Click += (s, e) => pageState.CurrentPage++; // 末页按钮 btnLast.Click += (s, e) => pageState.CurrentPage = pageState.TotalPages; // PageState 状态变更时,同步更新 UI 控件 pageState.PropertyChanged += (s, e) => { if (e.PropertyName == nameof(PageState.CurrentPage)) { numPage.Value = pageState.CurrentPage; lblPageInfo.Text = $"第 {pageState.CurrentPage} 页,共 {pageState.TotalPages} 页"; } else if (e.PropertyName == nameof(PageState.TotalPages)) { numPage.Maximum = pageState.TotalPages; lblTotal.Text = $"共 {pageState.TotalCount} 条记录"; } };5.3 加载与错误处理:给用户确定的反馈
private async void LoadDataAsync() { try { // 显示加载状态 Cursor = Cursors.WaitCursor; btnSearch.Enabled = false; lblStatus.Text = "正在加载..."; var result = await executor.ExecuteAsync(query, pageState, MapOrder); // 更新 UI bindingList.Clear(); foreach (var item in result.Data) bindingList.Add(item); lblStatus.Text = $"加载成功,显示 {result.Data.Count} 条"; } catch (Exception ex) { MessageBox.Show($"数据加载失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); lblStatus.Text = "加载失败,请检查数据库连接"; } finally { Cursor = Cursors.Default; btnSearch.Enabled = true; } }注意:
LoadDataAsync()在PageState.OnPageChanged和搜索按钮 Click 中调用,确保任何状态变更都触发真实查询;Cursors.WaitCursor和lblStatus提供即时反馈,避免用户狂点按钮。
6. 进阶技巧:让分页控件支持异步加载、缓存与搜索高亮
6.1 异步加载优化:避免 UI 线程阻塞的 2 个关键点
WinForm 默认在 UI 线程执行async方法,但若ExecuteAsync内部未正确await,仍会卡死。必须确认两点:
- 数据库连接必须用
OpenAsync()/ExecuteReaderAsync():同步方法(如Open())会阻塞线程; - Mapper 函数不能含同步 IO:
MapOrder(reader)只做字段映射,不调用File.ReadAllText()等;
验证方法:在ExecuteAsync开头加Debug.WriteLine($"Thread: {Thread.CurrentThread.ManagedThreadId}");,翻页时应输出不同线程 ID(如 1, 4, 5),证明未卡主线程。
6.2 搜索结果缓存:避免重复查询相同关键词
对高频搜索词(如CustomerName LIKE '%张%'),用MemoryCache缓存IPagedResult<Order>,有效期 5 分钟:
private readonly IMemoryCache _cache = new MemoryCache(new MemoryCacheOptions { SizeLimit = 100, CompactionPercentage = 0.1 }); public async Task<IPagedResult<Order>> GetCachedResultAsync(string key, PageState state) { var cacheKey = $"{key}_{state.CurrentPage}_{state.PageSize}"; if (_cache.TryGetValue(cacheKey, out IPagedResult<Order> cached)) return cached; var result = await ExecuteAsync(new OrderQuery(key), state, MapOrder); _cache.Set(cacheKey, result, TimeSpan.FromMinutes(5)); return result; }提示:缓存 Key 必须包含
CurrentPage和PageSize,否则第 1 页缓存会被第 2 页覆盖;SizeLimit=100防止内存泄漏。
6.3 搜索关键词高亮:在 DataGridView 中标出匹配文字
WinForm 原生不支持单元格内高亮,但可用CellPainting事件手动绘制:
private void dataGridView1_CellPainting(object sender, DataGridViewCellPaintingEventArgs e) { if (e.ColumnIndex == 1 && e.RowIndex >= 0 && !string.IsNullOrEmpty(searchKey)) // CustomerName 列 { e.PaintBackground(e.ClipBounds, true); var cellValue = e.FormattedValue?.ToString() ?? ""; var index = cellValue.IndexOf(searchKey, StringComparison.OrdinalIgnoreCase); if (index >= 0) { // 绘制普通文本(灰色) TextRenderer.DrawText(e.Graphics, cellValue.Substring(0, index), e.CellStyle.Font, e.CellBounds, Color.Gray, TextFormatFlags.Left); // 绘制高亮文本(黄色背景+黑色字) var highlightRect = TextRenderer.MeasureText(e.Graphics, cellValue.Substring(index, searchKey.Length), e.CellStyle.Font); TextRenderer.DrawText(e.Graphics, searchKey, e.CellStyle.Font, new Rectangle(e.CellBounds.X + TextRenderer.MeasureText( e.Graphics, cellValue.Substring(0, index), e.CellStyle.Font).Width, e.CellBounds.Y, highlightRect.Width, highlightRect.Height), Color.Black, Color.Yellow, TextFormatFlags.Left); e.Handled = true; } } }逻辑说明:
CellPainting在每次单元格重绘时触发;TextRenderer.MeasureText计算文本宽度,精确定位高亮区域;e.Handled = true阻止默认绘制,避免文字重叠。
我写这个分页控件的初衷,是解决自己项目里第 3 次因BindingSource.PageSize导致客户投诉“系统卡死”的问题。后来发现,真正难的不是代码,而是想清楚:分页不是 UI 功能,而是数据契约——SQL 层承诺返回哪几行,UI 层承诺展示哪一页,中间用PageState做状态仲裁。现在我的 WinForm 项目里,所有分页都基于这套模式,上线半年零分页相关 Bug。如果你也正被翻页卡顿折磨,不妨从SqlPagingBuilder的 20 行代码开始,亲手验证一次“数据库原生分页”的威力。希望帮到你。
本文还有配套的精品资源,点击获取