WinForm DataGridView分页控件实现原理与避坑指南
简介这是一份由个人开发者编写的Winform DataGridView分页控件资源主要面向需要在Windows窗体应用程序中实现表格数据分页展示的C#编程人员。控件把常用分页逻辑统一封装使用起来相当简单并附带了完整源码方便读者直接引用或按需调整。资源采用rar压缩包发布共包含45个文件整体大小仅有96KB主要文件类型包括C#源文件、项目工程文件、编译生成的动态链接库与调试符号文件以及界面资源文件等整体目录能够清楚区分控件库与测试工程两部分目前已有223人学习/下载。压缩包内除了控件实现本身还提供了一个可运行的测试示例包含完整的窗体界面与程序入口读者可以先运行该示例查看实际分页效果再结合源码理解数据绑定、页码切换、数据刷新等功能模块的编写方式。无论是希望快速获得分页功能的项目开发者还是想了解控件封装思路的初学者都能从源码中获得实用参考。1. WinForm 里做分页最该先想清楚的一件事先给结论DataGridView 本身没有分页能力所谓“分页控件”本质是用一个 UserControl 把「数据源切片」和「翻页按钮」封装起来。你搜“winform datagridview 分页控件”能找到一堆源码包和封装好的 DLL但大多数下载下来直接拖进去用都会翻车——要么跟数据库耦合死要么只能在 BindingSource 上转圈换一个项目就得改底层。真正值得下载的是那种把「数据查询」和「UI 交互」拆开的分页控件实现你拿到的是一套能塞进现有项目的分页机制而不是一个只能跑 Demo 的花架子。适合谁正在用 WinForm DataGridView 做管理系统、进销存、后台工具被几万行数据卡到界面假死的开发者或者想把分页做成公共控件、减少重复劳动的 .NET 从业者。这篇我按「先立原理、再给实现、最后讲坑」的顺序拆文中代码可以直接抄。2. 分页控件的核心原理先搞清楚 Index 是怎么算出来的2.1 为什么不要在内存里分页很多新手写分页第一步是DataTable.Select()或者LINQ.Skip().Take()把全表查出来再在内存里切。这种做法在小数据量下没问题但一旦数据到十万行内存占用和首次查询耗时都会翻倍增长。我做过的项目中最典型的一次翻车是一张单据表 30 万行用内存分页Fill之后内存涨了 200 多 MB界面卡了 5 秒。正确的分页控件应该把分页参数下推到 SQL 层也就是OFFSET Offset ROWS FETCH NEXT PageSize ROWS ONLYSQL Server 2012或者LIMIT Offset, PageSizeMySQL。控件本身不知道数据从哪来它只负责算出Offset和PageSize然后通过委托或事件把这两个参数交给外部查询方法最后把查询结果重新绑定到 DataGridView。控件内部不持有DataTable只持有当前页数据集和一个页码状态。2.2 核心状态变量CurrentPage、PageSize、PageCount一个分页控件最核心的状态只有三个CurrentPage当前页、PageSize每页行数、TotalCount总记录数。PageCount可以由(int)Math.Ceiling(TotalCount / (double)PageSize)算出来不需要单独存储。默认值建议这样设状态默认值说明CurrentPage1起始页PageSize20每页行数很多管理系统默认 20 合理TotalCount0由外部查询方法返回PageCount0由 TotalCount 与 PageSize 计算页码计算有几条边界规则必须写死当TotalCount 0时PageCount 0按钮全部禁用。当CurrentPage PageCount时强制回退CurrentPage PageCount防止数据删除后页码越界。当上一页执行到第 1 页时「上一页」按钮必须禁用避免用户狂点。末页的Offset可能不足一页SQL 层不用关心直接查FETCH NEXT PageSize即可查多查少由数据库自己处理。2.3 公共控件的接口设计用委托而不是继承我建议分页控件用UserControl承载 UI对外暴露一个事件和一个属性。事件负责请求数据属性负责接收数据总量public partial class PaginationControl : UserControl { // 请求数据的事件控件把页码状态传出去外部根据参数查询 public event Actionint currentPage, int pageSize DataRequested; // 外部查询完成后把总记录数回填进来 public int TotalCount { get _totalCount; set { _totalCount value; UpdatePageCount(); UpdateUI(); } } public int CurrentPage _currentPage; public int PageSize _pageSize; private int _currentPage 1; private int _pageSize 20; private int _totalCount 0; private int _pageCount 0; public void LoadFirstPage() { _currentPage 1; DataRequested?.Invoke(_currentPage, _pageSize); } }逻辑说明DataRequested是控件与外部唯一的沟通管道。控件翻页时触发事件把currentPage和pageSize抛出去外部拿到后执行 SQL 查询再把查询结果绑定到 DataGridView最后把TotalCount赋值给控件。控件绝不直接访问数据库这样换数据库、换 ORM 都不影响控件本身。参数说明Actionint, int比EventHandler更简洁外部订阅时不需要构造EventArgs。LoadFirstPage()是给窗体加载时调用的避免外部自己把CurrentPage置 1。接下来是翻页按钮的触发逻辑。上一页、下一页、首页、末页四个按钮共用同一个方法只是参数不同private void btnPrev_Click(object sender, EventArgs e) { if (_currentPage 1) return; _currentPage--; DataRequested?.Invoke(_currentPage, _pageSize); } private void btnNext_Click(object sender, EventArgs e) { if (_currentPage _pageCount) return; _currentPage; DataRequested?.Invoke(_currentPage, _pageSize); } private void btnFirst_Click(object sender, EventArgs e) { if (_currentPage 1) return; _currentPage 1; DataRequested?.Invoke(_currentPage, _pageSize); } private void btnLast_Click(object sender, EventArgs e) { if (_currentPage _pageCount) return; _currentPage _pageCount; DataRequested?.Invoke(_currentPage, _pageSize); }逻辑说明每个按钮末尾都调DataRequested但前面做了边界拦截。比如末页按钮在_currentPage _pageCount时直接 return这是因为很多用户会连续点击末页按钮如果不拦截_currentPage会越界到PageCount 1导致查询出空页。翻页按钮禁用逻辑我建议放在UpdateUI()里统一处理不在按钮点击事件里反复判断。2.4 页码按钮的动态生成逻辑固定写四个按钮不够用一般控件都会在中间放一组页码按钮。动态生成页码按钮的规则一般是当前页左右各显示 2 个页码。第一页和最后一页始终显示。中间用省略号占位。private void UpdatePageLinks() { flowLayoutPanel1.Controls.Clear(); for (int i 1; i _pageCount; i) { // 只显示第一页、最后一页、当前页前后2页 if (i 1 || i _pageCount || (i _currentPage - 2 i _currentPage 2)) { var btn new Button { Text i.ToString(), Width 30, Height 25, Tag i }; btn.Click PageLink_Click; flowLayoutPanel1.Controls.Add(btn); } } } private void PageLink_Click(object sender, EventArgs e) { var btn sender as Button; if (btn null) return; int page (int)btn.Tag; if (page _currentPage) return; _currentPage page; DataRequested?.Invoke(_currentPage, _pageSize); }逻辑说明flowLayoutPanel1是控件里放置页码按钮的容器用流式布局自动排列。Tag属性存页码点击时取出并触发数据请求。注意我没有处理省略号是因为当数据量小PageCount 10时全部页码都显示更直观数据量大时只显示当前页附近的页码配合首末页按钮已经足够用户操作了。参数说明_currentPage - 2和_currentPage 2是窗口半径你可以改成 3 或 4看界面宽度。如果 PageCount 很大比如 1000 页这个方案生成的按钮数最多是 9 个左右不会撑爆布局。3. 把分页控件接进 WinForm从 SQL 到 DataGridView 完整链路3.1 数据访问层的写法不要忘记 COUNT(*)分页查询必须执行两条 SQL一条查总数一条查当前页数据。这两条 SQL 建议放在同一个方法里用事务或同一连接执行避免数据不一致。我一般用一个QueryPageT方法统一处理public class ProductRepository { private readonly string _connectionString; public ProductRepository(string connectionString) { _connectionString connectionString; } public (ListProduct list, int total) QueryPage(int pageIndex, int pageSize) { using var conn new SqlConnection(_connectionString); conn.Open(); var totalSql SELECT COUNT(*) FROM Products; var dataSql SELECT ProductId, ProductName, Price, Stock FROM Products ORDER BY ProductId OFFSET Offset ROWS FETCH NEXT PageSize ROWS ONLY; var offset (pageIndex - 1) * pageSize; using var totalCmd new SqlCommand(totalSql, conn); var total (int)totalCmd.ExecuteScalar(); using var dataCmd new SqlCommand(dataSql, conn); dataCmd.Parameters.AddWithValue(Offset, offset); dataCmd.Parameters.AddWithValue(PageSize, pageSize); var list new ListProduct(); using var reader dataCmd.ExecuteReader(); while (reader.Read()) { list.Add(new Product { ProductId reader.GetInt32(0), ProductName reader.GetString(1), Price reader.GetDecimal(2), Stock reader.GetInt32(3) }); } return (list, total); } }逻辑说明OFFSET的值是(pageIndex - 1) * pageSize这个公式必须和控件里的事件参数保持一致。如果控件传进来的是currentPage 1那OFFSET就是 0从第一行开始取。注意ORDER BY这里不是可选项——SQL Server 的OFFSET...FETCH要求必须有ORDER BY否则直接报错。排序字段建议选主键或唯一索引保证翻页时数据顺序稳定。参数说明AddWithValue在VARCHAR列上可能有隐式转换问题但分页参数是INT类型没有这个风险。如果你用的是 MySQL把OFFSET和FETCH换成LIMIT PageSize OFFSET Offset即可其他逻辑不变。3.2 窗体内的绑定代码事件订阅与刷新窗体和 DataGridView 所在的位置是分页控件和数据库之间的桥梁。窗体内需要做的事有三件订阅DataRequested事件、在事件处理里调用仓储层、把结果绑定到 DataGridView。public partial class MainForm : Form { private readonly ProductRepository _repository; public MainForm() { InitializeComponent(); _repository new ProductRepository(connectionString); paginationControl1.DataRequested PaginationControl1_DataRequested; LoadData(); } private void PaginationControl1_DataRequested(int currentPage, int pageSize) { LoadData(currentPage, pageSize); } private void LoadData(int page 1, int pageSize 20) { var (list, total) _repository.QueryPage(page, pageSize); dataGridView1.DataSource new BindingListProduct(list); paginationControl1.TotalCount total; label1.Text $当前第 {page} 页 / 共 {paginationControl1.PageCount} 页; } }逻辑说明LoadData是窗体内唯一的加载入口同时承担首次加载和翻页加载两种职责——首次加载走默认参数翻页时通过事件参数传入。查询完成后先绑定DataSource再把TotalCount回填给分页控件控件内部会重新计算页数并刷新按钮状态。这里顺序很重要如果你先赋值TotalCount再绑定 DataGridView界面上会出现一瞬的页码闪烁。参数说明BindingListProduct是 WinForm 里最稳的绑定容器它支持DataGridView的增删改同步。如果直接用ListTDataGridView 只有第一次绑定会生效后续DataSource赋值如果指向同一个对象界面不会刷新。3.3 查询条件变化时如何重置页码很多系统里分页不是孤立的上方往往有文本框和查询按钮。用户改了查询条件点击查询页码必须重置回第 1 页。不重置的话用户在第 5 页修改条件查询结果可能只有 2 页控件会被卡在一个不存在的页码上。private void btnSearch_Click(object sender, EventArgs e) { paginationControl1.ResetToFirstPage(); LoadData(1, paginationControl1.PageSize); } // 在分页控件里加一个公开方法 public void ResetToFirstPage() { _currentPage 1; UpdateUI(); }逻辑说明ResetToFirstPage只是把当前页重置为 1并刷新按钮状态不触发数据请求。外部查询按钮里先调用它再自己调用LoadData(1, ...)查数据。分两步做的原因是查询条件变化后总记录数未知控件在拿到新TotalCount之前无法正确计算页码所以不触发事件更安全。注意如果你在查询按钮里直接调用LoadData不要在LoadData内部同时调用ResetToFirstPage否则会出现双重刷新ToolStrip 状态栏会一闪而过。3.4 子标题ComboBox 改变 PageSize 的联动处理分页控件上一般还会放一个ComboBox提供每页 10 / 20 / 50 / 100 行的切换。切换后必须立即重新查询因为OFFSET的计算依赖PageSize不重新查询的话当前页的数据位置会错乱。private void comboBoxPageSize_SelectedIndexChanged(object sender, EventArgs e) { _pageSize int.Parse(comboBoxPageSize.SelectedItem.ToString()); _currentPage 1; // 切换每页行数后回到第一页 UpdateUI(); DataRequested?.Invoke(_currentPage, _pageSize); }逻辑说明SelectedIndexChanged里拿到新的PageSize后强制回到第 1 页原因是原第 5 页在PageSize 20时对应第 81~100 行切换成PageSize 50后第 5 页对应第 201~250 行中间整整跳过了 150 行。用户会疑惑“数据怎么变了”。回到第 1 页是行业通用做法也符合用户预期。参数说明ComboBox的SelectedIndexChanged在控件初始化时也会触发一次所以注意在InitializeComponent之后立即给_pageSize赋默认值 20再把ComboBox.SelectedIndex设为 1避免初始化时触发两次查询。4. 分页查询的性能优化COUNT 慢、排序不稳、大数据量翻页4.1 精确 COUNT 与估算 COUNT 的选择COUNT(*)在大表上可能非常慢。如果表有 100 万行SELECT COUNT(*)可能需要几百毫秒到一秒而分页数据本身的查询可能只有几十毫秒。用户翻页的体验会被COUNT拖慢这是很多分页控件“感觉卡”的根源。常见的优化方案有几种如果业务允许“共 N 页”显示为“共约 N 页”可以用sys.dm_db_partition_stats里的行数估算SQL Server 上的响应是毫秒级。如果总数变化不频繁可以在内存/分布式缓存里缓存COUNT结果设置 30 秒过期。如果查询条件很复杂比如多个 LIKE 筛选上述方案都不可用只能在表设计上做取舍比如给筛选字段加覆盖索引。我一般做的折中当表行数小于 10 万时直接用COUNT(*)大于 10 万时如果查询条件里只有等值筛选就尝试缓存。下面是 SQL Server 的估算 COUNT 写法SELECT SUM(row_count) AS EstimatedRowCount FROM sys.dm_db_partition_stats WHERE object_id OBJECT_ID(Products) AND index_id 2;逻辑说明index_id 2表示堆或聚集索引的分区统计。这个值反映的是表级别的行数估算不考虑 WHERE 条件所以只适合无筛选条件的分页场景。只要界面上“共 N 页”允许有一定误差这个方案能把分页接口的整体耗时降到 20ms 以内。4.2 深分页的性能陷阱OFFSET 越大越慢OFFSET 100000 ROWS FETCH NEXT 20 ROWS ONLY在前 10 页很快但翻到第 5000 页时数据库要扫描并丢弃前 10 万行耗时可能是第一页的几十倍。这不是分页控件的问题是 SQL 写法的固有问题。解决方案是 Keyset Paging键集分页也叫 Seek Method。利用上一页最后一条记录的排序键直接跳到下一页的起点var dataSql SELECT TOP (PageSize) ProductId, ProductName, Price, Stock FROM Products WHERE ProductId LastProductId ORDER BY ProductId;逻辑说明LastProductId是上一页最后一条记录的ProductId。这种写法不需要OFFSET数据库直接从索引的指定位置开始扫描翻到百万页也只需要扫描一页的数据量。代价是跳页功能失效“跳到第 500 页”没法算起点只能上一页下一页。所以 Keyset Paging 适合「无限滚动」或「只翻上下页」的列表页不适合带页码跳转的管理端。做进分页控件时可以给控件增加一个PageMode属性支持PageNumber和Keyset两种模式。Keyset 模式下控件不再计算OFFSET而是把上一页最后一条记录的主键值通过事件参数传出去。控件内部记录下一页的主键锚点翻页时把它塞进事件。4.3 排序字段不唯一导致的翻页重复这是一个非常隐蔽的坑ORDER BY CreateTime如果CreateTime有重复值SQL Server 的OFFSET...FETCH不保证每次查询顺序完全一致。用户翻到第 2 页可能看到第 1 页最后几行的重复数据也可能漏掉部分行。解决方式是排序字段追加一个唯一键ORDER BY CreateTime DESC, ProductId DESC逻辑说明CreateTime作为第一排序键ProductId作为决胜排序键。只要ProductId是主键整个排序就是确定性的。所有用到分页的查询都应该检查排序字段是否唯一。这是我做分页控件时反复踩过的一个坑尤其是按创建时间排序的订单列表很容易犯。4.4 绑定大数据量时的 UI 卡顿用虚拟模式即使做了分页单页 100 行数据绑定到 DataGridView 通常不会卡。但你可能会遇到一个问题用户把PageSize设置为 1000BindingList一次性绑定 1000 行界面刷新还是会掉帧。WinForm 对 1000 行以内的绑定渲染其实还好真正卡的是绑定过程中反复触发的单元格事件。建议把dataGridView1的以下属性设置为属性值作用AutoSizeColumnsModeFill禁止自动调整列宽减少布局计算RowHeadersWidthSizeModeDisableResizing禁用行头宽度自动调整DoubleBufferedtrue开启双缓冲减少闪烁DoubleBuffered这个属性默认是 protected外部设置不了得用一个小技巧开启public static void EnableDoubleBuffered(DataGridView dgv) { typeof(DataGridView).GetProperty(DoubleBuffered, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) .SetValue(dgv, true); }逻辑说明反射设置DoubleBuffered是 WinForm 开发里的常规操作。这个属性在基类Control上是 protected普通代码无法直接赋值但反射可以绕过访问限制。开启后 DataGridView 的滚动和刷新会明显变流畅尤其是在高分屏和远程桌面环境下。5. 分页控件实战避坑数据重复、翻页失效与事件风暴5.1 翻页后 DataGridView 滚动位置不还原现象用户翻到第 2 页新数据加载后DataGridView 的垂直滚动条还停在第 1 页滚动到底部的位置用户看到的不是第 2 页顶部。原因DataSource重新赋值后DataGridView 的FirstDisplayedScrollingRowIndex还保留着旧位置。解决绑定完数据后强制把滚动位置复位dataGridView1.ClearSelection(); dataGridView1.FirstDisplayedCell dataGridView1.Rows[0].Cells[0];逻辑说明FirstDisplayedCell设置后DataGridView 会把第一行滚到可视区域顶部。代码必须放在DataSource赋值之后否则Rows集合还是空的。ClearSelection()可以避免绑定后默认选中第一行产生高亮闪烁。5.2 下拉框 PageSize 切换时触发两次查询现象窗体加载时ComboBox的SelectedIndexChanged事件连续触发两次分页接口被调用两次日志里出现两条重复的查询记录。原因InitializeComponent里把ComboBox.SelectedIndex设为 0 是一个触发源如果初始化之前_pageSize还没赋值事件里会读到默认值 0进而触发一次DataRequested。之后窗体的LoadData又主动查了一次。解决在窗体构造函数里先给_pageSize赋默认值再设置ComboBox.SelectedIndex最后再订阅事件。或者用一个布尔位_isInitializing做开关初始化期间事件直接 return。我习惯用后者逻辑更明确private bool _isInitializing true; public MainForm() { InitializeComponent(); _isInitializing false; } private void comboBoxPageSize_SelectedIndexChanged(object sender, EventArgs e) { if (_isInitializing) return; // 正常处理逻辑 }逻辑说明_isInitializing是一个一次性开关。InitializeComponent执行期间触发的所有事件都会被拦掉构造函数执行完后开关关闭事件恢复正常。这种方式比调整事件订阅顺序更直观代码阅读者一眼能看出意图。5.3 用户点击末页按钮后数据总量刚好减少现象用户在第 10 页末页此时另一个人删除了两条记录总页数从 10 变成 9。用户点击末页按钮控件触发currentPage 10的查询返回空列表界面白屏。原因控件里的_currentPage与数据库里的实际总页数不一致。控件无法感知其他客户端引起的数据变化。解决在控件的事件触发前做一次校验但这里有个矛盾——校验本身也要查数据库。一个务实的做法是查询结果为空时自动回退页码并重试一次var (list, total) _repository.QueryPage(page, pageSize); if (list.Count 0 page 1) { var maxPage (int)Math.Ceiling(total / (double)pageSize); if (page maxPage) { LoadData(maxPage, pageSize); return; } }逻辑说明查询为空且当前页大于 1 时说明页码越界。此时根据total计算出真实的最大页码再递归加载。注意这里要防止死循环——如果maxPage等于当前page说明数据库异常不加处理会无限递归。所以我一般会加一个条件只有maxPage page时才递归。5.4 事件风暴快速点击翻页导致重复请求现象用户快速连点「下一页」按钮控件瞬时触发了 5 次查询而这 5 次查询可能因为网络延迟乱序返回最后界面上显示的不是最新的那一页。原因按钮点击事件没有做节流ADO.NET 的异步查询返回顺序不保证。解决简单粗暴的做法是翻页期间禁用所有翻页按钮查询完成后统一启用。如果你用async/await做异步分页用CancellationTokenSource取消旧请求private CancellationTokenSource _cts; private async void PaginationControl1_DataRequested(int currentPage, int pageSize) { _cts?.Cancel(); _cts new CancellationTokenSource(); try { var (list, total) await _repository.QueryPageAsync(currentPage, pageSize, _cts.Token); dataGridView1.DataSource new BindingListProduct(list); paginationControl1.TotalCount total; } catch (OperationCanceledException) { // 旧请求被新请求取消直接忽略 } }逻辑说明每次触发新查询前取消上一次未完成的查询。这个模式在 WinForm 里天然可用因为 UI 线程只有一个await恢复时如果OperationCanceledException被捕获就不会出现旧数据覆盖新界面的问题。注意async void用于事件处理器是允许的不要用async Task替换否则异常会丢失。5.5 分页后无法编辑单元格现象给 DataGridView 绑定了分页数据后用户双击单元格进入编辑状态输入内容后焦点一移开数据恢复成旧值。原因分页控件每翻一次页都会重新DataSource未保存的行内编辑内容被替换是正常的。但如果用户根本还没翻页编辑内容就丢失通常是BindingListT里的实体类没有实现INotifyPropertyChanged或者ProductId是只读列。解决实体的属性 setter 里通知界面刷新public class Product : INotifyPropertyChanged { private string _productName; public string ProductName { get _productName; set { if (_productName ! value) { _productName value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ProductName))); } } } public event PropertyChangedEventHandler? PropertyChanged; }逻辑说明DataGridView在编辑单元格后会检查实体是否实现INotifyPropertyChanged只有实体通知了属性变化界面才会接受新值。否则它认为数据源没变化回滚到原值。这是 WinForm 数据绑定的老规矩但很多人只卡在绑定这一层不知道实体还要实现接口。6. 进阶技巧把分页控件做成可复用的 NuGet 包6.1 用属性配置代替硬编码默认值分页控件已经写完接下来要考虑复用。第一个要优化的是把PageSize、PageSizeOptions、ShowTotalLabel等变成公开属性让使用方在窗体设计器里直接配置。[Category(分页)] [Description(每页显示行数)] [DefaultValue(20)] public int PageSize { get _pageSize; set { if (value 0) return; _pageSize value; UpdateUI(); } } [Category(分页)] [Description(每页行数下拉选项)] public int[] PageSizeOptions { get; set; } { 10, 20, 50, 100 };逻辑说明Category属性让这些配置在 Visual Studio 属性面板里归到「分页」组下DefaultValue声明默认值后窗体设计器在序列化时会自动省略默认值不会污染InitializeComponent。使用方在窗体上拖入控件后可以直接在属性面板改每页行数不用改代码。6.2 控件内部支持当前页高亮页码按钮动态生成后当前页按钮和其他按钮没有视觉区分用户不知道自己在第几页。一个小技巧当前页按钮用不同背景色并禁用点击private void UpdatePageLinks() { flowLayoutPanel1.Controls.Clear(); for (int i 1; i _pageCount; i) { if (ShouldShowPage(i)) { var btn new Button { Text i.ToString(), Width 30, Height 25, Tag i }; if (i _currentPage) { btn.BackColor Color.DodgerBlue; btn.ForeColor Color.White; btn.Enabled false; } else { btn.Click PageLink_Click; } flowLayoutPanel1.Controls.Add(btn); } } }逻辑说明Enabled false的按钮不触发Click事件所以当前页按钮不需要额外做点击拦截。背景色高亮让用户一眼看到当前位置。这里不推荐用图片做高亮WinForm 的按钮在 Windows 主题下用FlatStyle配合BackColor更稳定不会出现 XP 样式残影。6.3 打包成 NuGet 并让团队内部复用控件稳定后下一步是打包。你不需要发布到 nuget.org只要在团队内部用本地目录源或私有 NuGet 服务器就可以。打包步骤分三步把分页控件项目和 4.4 节的DoubleBuffered工具类放到同一个类库项目。设置AssemblyName和PackageId建议命名YourCompany.WinForm.Controls。生成 NuGet 包后放到共享文件夹或私有源。打包前的.csproj关键配置Project SdkMicrosoft.NET.Sdk PropertyGroup TargetFrameworknet6.0-windows/TargetFramework UseWindowsFormstrue/UseWindowsForms PackageIdYourCompany.WinForm.Controls/PackageId Version1.0.0/Version PackageOutputPath..\packages/PackageOutputPath /PropertyGroup ItemGroup EmbeddedResource IncludePaginationControl.bmp / /ItemGroup /Project逻辑说明UseWindowsForms是 WinForms 类库项目的必要开关。PackageOutputPath指定包输出目录团队里其他项目只要把该目录配置为 NuGet 源就能直接Install-Package引用。控件默认是public的类库项目内的UserControl需要确认访问修饰符是 public否则引用项目看不到它。参数说明TargetFramework用net6.0-windows还是net48取决于团队现有项目的运行环境。如果是老项目还在 .NET Framework 4.8用net48更合适如果新项目已迁到 .NET 6/8直接用net6.0-windows。千万别打一个net8.0-windows的包去给 .NET Framework 4.8 的项目用装不上。6.4 团队内不装包直接用源码如果不想引入 NuGet 包管理流程最直接的做法是把分页控件的.cs文件复制到目标项目里手动引用。这样做的代价是后续更新麻烦但胜在立竿见影。我会在源码包内附一个README.md注明以下事项控件依赖的System.Windows.Forms命名空间。PaginationControl.bmp是设计时工具箱图标删了不影响编译。不启用PaginationControl_DataRequested事件订阅控件不会触发任何查询。我在实际项目中的习惯是控件源码直接放进项目等稳定迭代两周后再考虑打包成 NuGet。因为控件在没有积累真实业务场景时急着打包往往会漏掉边界情况比如删除数据后的页码回退就是我在第一个项目里被用户投诉后才补的逻辑。附言分页控件写了这么多年最深的教训是控件本身只是壳真正的复杂度永远在数据一致性上。从那以后我每次接新的分页需求都会强制自己先回答三个问题排序字段是不是唯一的、总页数变化后页码会不会越界、快速点击翻页会不会造成旧数据覆盖新页面。这三个问题回答清楚了分页控件基本不会出大乱子。希望这篇拆解能帮到你也欢迎你把踩到的其他坑补充给我。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →