简介:一套演示C# WinForms中ListView控件绑定数据库数据的完整源码包,面向.NET初学者及需要在数据展示界面中快速落地的开发者。案例基于ADO.NET连接Access数据库(含.accdb数据文件),清晰展示从建立连接、执行SQL查询、通过DataAdapter填充DataTable,到配置ListView列头并完成数据绑定的全过程,同时覆盖项选中事件的处理思路,可快速理解ListView在详细信息视图下的列、项与子项映射关系。压缩包共40个文件,核心为14个.cs源码文件,另含.sln解决方案、.resx界面资源、.exe可运行程序及Access数据库文件,便于直接打开工程调试或对照界面设计;整体仅78KB,轻量易用。已有825人学习下载。通过学习该工程,可梳理DataClass、MyMeans.cs等辅助类的职责,并借助Backup备份目录对比不同版本改动,适合作为数据管理类WinForms项目的起步参考模板。
1. 数据开发工具里,为什么偏偏用 ListView 显示数据库数据
做数据开发免不了要写查询小工具。字段可能变、表可能换,今天查订单明天查流水,界面要的就是“一把梭”——把 SQL 查出来的结果直接平铺在列表里。很多人第一反应是拖一个 DataGridView 上去,但数据开发工具里 ListView 往往更顺手:它原生不支持绑定数据源,反而让你老老实实把 DataTable 转成行,代码不绕,1 万行数据也能快速滚起来,不容易把界面卡死。这个标题要解决的就是这样一件事:怎么写一段 C# 源代码,让 ListView 控件稳定、高效地显示数据库查询结果。适合 WinForms 下写数据开发小工具、报表预览和上位机数据展示的开发者;如果你还惦记着单元格级编辑,看完第 4 章再决定要不要换 DataGridView 也不迟。
2. 选 ListView 而不是 DataGridView:View 模式、SubItems 结构与性能边界
ListView 在 WinForms 里常被当成“老控件”,但数据开发恰恰是它的主场。它不是黑匣子,数据怎么进去、怎么显示,每一行都能自己控制。这一章先把三个核心概念讲透:View 模式、Items/SubItems 结构、VirtualMode 的边界。理解了这三样,后面写绑定代码就是流水线作业。
2.1 View=Details:数据表展示的唯一正确姿势
ListView 有 5 种 View 模式,但只有 Details 会显示列头和网格线。很多人把 ListView 拖到窗体上,不设置 View,默认是 LargeIcon,然后问为什么没有列——因为这个控件默认是图标模式,列头只在 Details 下存在。设置 View.Details 是第一步,而且要在添加 ColumnHeader 之前做。
| View 模式 | 特点 | 数据表场景 |
|---|---|---|
| Details | 多列、列头、网格线,可排序 | 适用 |
| LargeIcon | 大图标,适合文件/图片选择 | 不适用 |
| SmallIcon | 小图标流式排列 | 不适用 |
| List | 单列列表,无列头 | 不适用 |
| Tile | 大图标带多行说明,依赖系统主题 | 不适用 |
我一般固定用 View.Details + FullRowSelect + GridLines 三件套。FullRowSelect 决定鼠标点任意单元格时选中整行,做只读查询时这是默认选项;GridLines 决定是否显示行间横线,查出来的数据带网格线更容易对齐看。另一个容易被忽略的是 HideSelection,设为 false 后焦点离开列表,选中行仍然高亮,否则用户查完数据一不留意就以为没点中。
2.2 Items 与 SubItems:把数据库“一行一列”映射到 ListView
ListView 的行叫 ListViewItem,列叫 ListViewSubItem,但有个反直觉的细节:SubItems 集合里的第 0 项就是 Item 本身。所以第一列的值要传给 ListViewItem 构造函数,第二列起才 Add 到 SubItems。我第一次写绑定代码就在这翻车:从 0 开始循环 Add,结果第一列重复、最后一列被吞。正确写法是列循环从 j=1 开始。
这个结构直接影响绑定逻辑:Columns 定义表头,Items 决定行数,SubItems 决定每行的第 1..n 列。还有 ListViewItem.Tag,这是一个万能口袋,我会把主键值塞进去,双击行时取出来做下钻查询,比去 SubItems 里找 ID 列快得多。注意 Tag 是 object 类型,取出来时记得转成原来的类型。
2.3 和 DataGridView 的一次认真对比:内存、速度与适用边界
并不是说 DataGridView 不好,而是它更重。做数据开发工具时,如果只做“查出来看看、复制几行、导出 Excel”,ListView 的加载速度和滚动体验要舒服不少。
| 对比项 | ListView | DataGridView |
|---|---|---|
| 绑定 DataSource | 不支持,手动转 ListViewItem | 原生支持 |
| 1 万行加载与滚动 | 顺畅,内存占用低 | 默认较卡,需开 VirtualMode 或关自动列宽 |
| 单元格编辑 | 需自己实现 | 原生支持 |
| 点列头排序 | 需要写 IComparer | 内置 |
| 单元格合并与样式 | 弱 | 强 |
| 适合场景 | 查询展示、工具界面、上位机 | 表格编辑、复杂报表 |
如果你的界面里全是只读查询和下载,ListView 是更稳的选择;如果要做成 Excel 那种可编辑表格,别硬上 ListView,第 5 章给的双击编辑只是一个轻量妥协方案,复杂编辑直接换 DataGridView。
2.4 VirtualMode 的适用边界:几万行该选谁
普通模式下 5 万行数据,构造 5 万个 ListViewItem 对象要花一两秒,滚动也不跟手。这时候有两种路:SQL 分页一次取 500 行,或者开 VirtualMode。VirtualMode=true 时,ListView 只向你要当前可视区域的行,通过 RetrieveVirtualItem 事件按需提供,10 万行也能秒开。
代价是代码复杂度上来了:不能再直接操作 Items 集合,Sort、Find 等很多方法也会受限制。数据开发工具里我更推荐先做分页,因为 SQL 端 OFFSET 一次只取几百行,内存和滚动都轻松;VirtualMode 更适合结果集必须整体留在客户端的场景,比如读取一个同步接口返回的固定数据。判断标准很简单:数据能分页就分页,不能分页再上 VirtualMode。
3. 把 DataTable 绑进 ListView:从连接串到通用绑定方法的完整代码
这一章把标题里的“源代码”落到能编译的工程里。我按写查询工具的常规顺序来:先搭 ListView UI,再写数据库查询,最后写通用绑定方法。以下代码基于 SQL Server 的 SqlClient,换 MySQL 或 Oracle 时把命名空间和驱动换掉,绑定 ListView 这段逻辑完全不用动。
3.1 先定 ListView 属性:FullRowSelect、GridLines 与列头
在窗体 Load 事件里先做这些设置:
listView1.View = View.Details; // 必须:切到表格视图 listView1.FullRowSelect = true; // 点任意单元格选整行 listView1.GridLines = true; // 显示表格线 listView1.MultiSelect = false; // 查询工具单行选中更清晰 listView1.HideSelection = false; // 失焦后仍保留选中色 listView1.Scrollable = true; // 行多时保证滚动条存在View.Details 不设置,列头永远不出现,这是最常见的“空列表”原因。MultiSelect 看场景,如果允许用户复制多行数据再导出,设 true;只做下钻查询就设 false,避免选中状态混乱。HideSelection 必须设 false,否则列表失去焦点时选中行变灰白,用户会误以为刚才没点中。
3.2 查询数据库:SqlDataAdapter.Fill 还是 DataReader 逐行
查询部分我直接用 SqlDataAdapter,因为它能一次把结果装进 DataTable,方便后续绑定和二次筛选。只读展示场景下这是最小可行写法:
private DataTable QueryData(string sql) { string connStr = "Server=127.0.0.1;Database=DemoDB;User Id=sa;Password=123456;" + "Pooling=True;Min Pool Size=1;Max Pool Size=50;Connection Timeout=5;"; using (SqlConnection conn = new SqlConnection(connStr)) using (SqlDataAdapter adapter = new SqlDataAdapter(sql, conn)) { DataTable dt = new DataTable(); adapter.Fill(dt); return dt; } }参数说明:连接字符串不要写死在代码里,放到 App.config 或配置中心,换环境时只改配置不动代码。Pooling=True 表示启用连接池,Min Pool Size=1 让进程启动时预建一个连接,Max Pool Size=50 防止连接数暴涨。Connection Timeout=5 很关键,数据库假死时界面最多等 5 秒,而不是默认的 15 秒。
这里有个很多人不知道的细节:Fill 方法如果检测到连接是关闭状态,会自己 Open,执行完再恢复关闭状态,所以 using 块结束后连接必然释放。如果数据量到了几十万行且只需要逐条流式处理,就改用 SqlDataReader 配合 while (reader.Read()),省内存;但 ListView 本身扛不住几十万行 Item,所以数据开发工具里 DataAdapter 基本够用。
3.3 核心绑定循环:ListViewItem 与 SubItems 的逐列填充
绑定是全文的核心,看懂这一段,其余都是变体。我通常写成独立方法,方便在多个窗口复用:
private void BindList(ListView lv, DataTable dt) { lv.BeginUpdate(); try { lv.Items.Clear(); for (int i = 0; i < dt.Rows.Count; i++) { DataRow row = dt.Rows[i]; ListViewItem item = new ListViewItem(Convert.ToString(row[0])); for (int j = 1; j < dt.Columns.Count; j++) { item.SubItems.Add(Convert.ToString(row[j])); } item.Tag = row["id"]; // 存主键,双击行时取出来下钻 lv.Items.Add(item); } } finally { lv.EndUpdate(); } }关键参数说明:列循环从 j=1 开始,因为第 0 列已经传给 ListViewItem 构造函数了,再 Add 一次会让列错位。所有值都包一层 Convert.ToString 而不是直接传 object,是为了让 DBNull 变成空字符串而不是类型转换异常。ListViewItem 和 SubItems 的 Text 都是 string 类型,直接传 object 数组进去会被隐式 ToString,但遇到复杂的自定义类型可能输出类名,统一转换最保险。
BeginUpdate 和 EndUpdate 必须成对,而且 EndUpdate 要放在 finally 里。如果绑定中途抛出异常,EndUpdate 不执行,控件会一直处于挂起重绘的状态,表现为怎么刷新界面都不重绘,只能关窗体重新开。这个坑我在 4.1 还会细说。
3.4 通用化改造:动态列头、列宽与 SQL 别名
上面的版本要求窗体上已经手动建好 Columns。数据开发工具里字段经常变,更实用的是根据 DataTable 自动生成列头。改造后的方法长这样:
private void BindListAuto(ListView lv, DataTable dt) { lv.BeginUpdate(); try { lv.Items.Clear(); lv.Columns.Clear(); for (int c = 0; c < dt.Columns.Count; c++) { lv.Columns.Add(dt.Columns[c].ColumnName, 120); } foreach (DataRow row in dt.Rows) { ListViewItem item = new ListViewItem(Convert.ToString(row[0])); for (int c = 1; c < dt.Columns.Count; c++) { item.SubItems.Add(Convert.ToString(row[c])); } lv.Items.Add(item); } if (lv.Columns.Count > 0) lv.Columns[lv.Columns.Count - 1].Width = -2; } finally { lv.EndUpdate(); } }列宽统一定 120 只是起步,最后一列设成 -2 表示自动填充 ListView 剩余宽度,窗口拉宽时最后一列跟着变宽,不会留一大片空白。-1 是自动按内容调整宽度,但内容很多时性能差,只读查询不建议用。
这里有个实用技巧:数据库字段名通常是 id、user_name 这种,直接当列头不好看。在 SQL 里用别名解决,SELECT id AS 编号, user_name AS 用户名,DataTable 的 ColumnName 就变成了中文,列头自动显示中文。别在绑定代码里做中文映射,维护起来太痛苦。
3.5 异步加载:数据慢的时候给用户一个台阶
如果 SQL 本身要跑几秒,界面会直接卡死。WinForms 里的标准解法是 async/await 加 Task.Run,把数据库查询移到线程池,UI 线程不阻塞。代码很简单:
private async void btnLoad_Click(object sender, EventArgs e) { btnLoad.Enabled = false; listView1.Items.Clear(); listView1.Items.Add(new ListViewItem("查询中...")); try { DataTable dt = await Task.Run(() => QueryData(CurrentSql)); BindListAuto(listView1, dt); } catch (Exception ex) { MessageBox.Show("查询失败:" + ex.Message); } finally { btnLoad.Enabled = true; } }await Task.Run 执行完后,代码会自动回到 UI 线程上下文,所以 BindListAuto 可以直接操作 ListView,不会触发跨线程异常。CurrentSql 是界面上一个 TextBox 里的查询语句,让用户自己输入 SQL,这是数据开发工具最常见的形态。加载提示先显示“查询中...”,BindListAuto 里的 Items.Clear 会把它清掉,不用额外处理。数据库查询本质是 IO 密集型任务,较真的话应该用 ExecuteReaderAsync,但 Task.Run 在这个场景下简单可靠,不值得为了理论最优增加复杂度。
4. 避坑排查:绑定数据后翻车的 5 个典型场景
下面 5 个问题按出现频率排序,都是我实际踩过或帮别人排查过的。每一条都按“现象 → 原因 → 解决”写,对照检查就行。
4.1 现象一:加载就闪屏,行数越多越严重
数据到 2000 行以上,添加行时界面像放幻灯片,上下滚动也是肉眼可见的闪烁。核心原因有两个:一是每 Add 一个 ListViewItem,控件就触发一次重绘,循环里加几百次就闪几百次;二是 ListView 的 DoubleBuffered 属性是 protected,默认没开双缓冲,WinForms 控件没有自动双缓冲能力。
解决就是 BeginUpdate/EndUpdate 配对,再加一段反射强制开双缓冲:
typeof(ListView).GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) ?.SetValue(listView1, true);这段代码在窗体 Load 时执行一次。注意 BeginUpdate 要在 Items.Clear 之前调用,EndUpdate 放 finally,保证任何异常情况下都能恢复重绘。如果开了双缓冲还闪,检查是不是每次循环里都改了 Font 或 BackColor 属性,这类属性修改也会触发重绘。
4.2 现象二:查出了数据却显示空白
SQL 执行有返回,但 ListView 一片空白,或者只有第一列、后面全是空白。按顺序查四个地方:View 是不是 Details;有没有 Add ColumnHeader;Columns 数量是不是少于 DataTable 的列数;绑定循环是不是用了列名而列名拼错了。
前两个是最常见的,把 ListView 拖到窗体上就直接写绑定代码,忘记设置 View 或者忘记建列。第三个问题的现象很有迷惑性:数据其实是进去了,只是 SubItems 比 Columns 多时,多出来的列没有表头、显示为空白。第四个别忽视,row["user_name"]这类取值如果列名写错,运行时抛 ArgumentException,如果异常没被捕获,你会看到程序卡住但界面还是空的。解决很简单:统一用列索引取值,或者确认 SQL 列名与代码一致。
4.3 现象三:日期、金额、NULL 显示得乱七八糟
日期显示成2024/1/5 0:00:00,金额没有千分位,NULL 显示成空字符串分不清是“没数据”还是“没取到”。原因是 SubItems.Add 直接拿 object 调用 ToString,日期格式取决于系统区域设置,数字不带格式,DBNull.ToString() 返回空串导致所有 NULL 看起来一样。
解决思路是绑定前对常见类型做格式化,不要在 SQL 里拼字符串格式化,保持数据层干净:
object val = row[c]; if (val is DBNull) item.SubItems.Add(""); else if (val is DateTime) item.SubItems.Add(((DateTime)val).ToString("yyyy-MM-dd HH:mm")); else if (val is decimal || val is double || val is float) item.SubItems.Add(Convert.ToDecimal(val).ToString("N2")); else item.SubItems.Add(Convert.ToString(val));日期按业务需要选择yyyy-MM-dd还是带时间;金额用 N2 补两位小数。如果你的查询结果里还有 bool 类型,建议加一层判断,把 True/False 转成“是/否”,直接显示英文看着不像数据工具该有的样子。
4.4 现象四:后台线程碰 ListView,直接抛异常
用了 Task 或 BackgroundWorker 之后,在后台线程里调用listView1.Items.Add,抛 InvalidOperationException“线程间操作无效”。原因很简单:控件只能在创建它的 UI 线程上访问,后台线程改集合属于非法跨线程操作,ListView 没做线程安全。
两个解法。优先用 async/await,第 3.5 节的写法让 UI 线程自己继续执行绑定,根本不跨线程。如果代码结构必须用 BackgroundWorker 或 Timer,就用 BeginInvoke 把操作调度回 UI 线程:
listView1.BeginInvoke(new Action(() => { BindListAuto(listView1, dt); }));BeginInvoke 是异步投递,不会阻塞后台线程;Invoke 是同步等待,需要拿返回值才用。接受不到返回值时一律用 BeginInvoke,否则后台线程会被 UI 操作拖慢,数据库查询反而成了排队等界面。
4.5 现象五:查询越跑越慢,最后报连接池已满
连续查询几十次后响应越来越慢,最终报“连接池已满”或超时。这属于最常见的“看着像 SQL 问题,其实是连接没释放”。SqlConnection 没有被 Dispose,连接池里的物理连接一直被占用,池满后新请求只能排队等空闲连接,表现就是越来越慢直到超时。
排查方法:把连接字符串临时改成Pooling=False,如果问题消失,确认是连接对象泄漏。解决就是所有 SqlConnection、SqlCommand、SqlDataReader 全部用 using 包裹,任何一层都不放过。SqlDataAdapter 内部会释放自己的 Command,但外部传入的 Connection 必须自己负责。另一个善后手段:
string connStr = "Server=127.0.0.1;Database=DemoDB;User Id=sa;Password=123456;" + "Pooling=True;Min Pool Size=1;Max Pool Size=100;Connection Timeout=5;";把 Max Pool Size 调到 100,Min Pool Size 设为 1。想确认当前连接数,SQL Server 执行select * from sys.dm_exec_connections,MySQL 看show processlist,如果连接数只增不减,代码里一定有没释放的连接。这个坑在“数据库连接池”这类场景下特别典型,很多团队调了半天 SQL 才发现是连接泄漏。
5. 进阶:验证耗时、双击现场编辑与最后的收尾习惯
工具能跑起来只是第一步。这一章给两个收尾动作:用 Stopwatch 量化列表绑定耗时,以及实现双击单元格现场编辑。做完这两件事,这个查询工具才算真正顺手。
5.1 每次改动先用 Stopwatch 量一次耗时
绑定代码改完,先别急着宣布优化成功,用数字说话:
var sw = System.Diagnostics.Stopwatch.StartNew(); BindListAuto(listView1, dt); sw.Stop(); this.Text = $"加载 {dt.Rows.Count} 行,耗时 {sw.ElapsedMilliseconds} ms";把耗时直接写进窗体标题栏,每次改动后点一次查询就能对比。1 万行数据在 100ms 以内,说明代码没问题;超过 500ms,先检查有没有漏掉 BeginUpdate/EndUpdate;超过 3 秒,说明页面还在创建大量 ListViewItem,这时候别折腾控件了,去 SQL 里做分页,或者考虑 VirtualMode。
5.2 双击现场编辑:TextBox 覆盖方案的最简实现
数据开发工具偶尔要改个别字段。ListView 的 LabelEdit 只能改第一列文本,要任意列都能改,常见做法是放一个隐藏的 TextBox 盖到单元格上:
private void listView1_MouseDoubleClick(object sender, MouseEventArgs e) { ListViewItem item = listView1.GetItemAt(e.X, e.Y); if (item == null) return; int colIndex = GetColumnAt(e.X); if (colIndex < 0) return; Rectangle r = item.SubItems[colIndex].Bounds; txtEditor.Bounds = r; txtEditor.Text = item.SubItems[colIndex].Text; txtEditor.Visible = true; txtEditor.Focus(); }GetColumnAt 遍历 ColumnHeader 的 Left 和 Width,判断鼠标横坐标落在哪一列。TextBox 的 Leave 事件里把修改写回 ListView,再写 UPDATE 语句回数据库。这个方案能应付轻量修改,但排序、格式校验都得自己写。如果编辑需求超过两列,我的建议是别硬扛,换 DataGridView 更划算——这不算打脸,是场景决定控件。
做数据开发工具这几年最大的心得是:优先选轻控件,克制加功能。ListView 显示数据库数据这条路,适合 80% 的只读查询场景,代码可控、性能直观、出了问题容易排查。希望你照着这套代码搭出来的第一个工具就能少踩几个坑。
本文还有配套的精品资源,点击获取