简介:面向C# Windows Forms开发者的ListView数据库绑定示例,重点演示如何将数据库字段映射到列表的列头与子项,并通过ADO.NET完成从SqlConnection连接到SqlDataAdapter填充DataTable,最后绑定到ListView的完整流程。资源共40个文件,以14个.cs源代码文件为核心,搭配.sln/.csproj工程配置、.resx资源、.settings设置、.pdb调试符号以及.accdb示例数据库,压缩包仅78KB,轻量且便于下载。已有825人学习该资源。压缩包内包含完整解决方案工程、DataClass数据操作类与备份目录,代码注释清晰、结构紧凑,适合初学C#数据开发者对照实践,快速掌握ListView与数据库协同使用的关键技巧。
1. ListView 显示数据库数据:老控件在新项目里的真实定位
一套设备状态监控界面要实时显示几千条点位记录,数据每秒都在变,界面却不能卡——这是我最早没想到会用 ListView 的场景。很多做 C# 数据开发的人一提到数据库展示,第一反应就是 DataGridView,但 ListView 在只读展示、快速刷新、低内存占用这三个点上,至今仍有不可替代的位置。标题里这个需求,本质就是:把数据库查询结果按列拆分,映射到 ListView 的行与 SubItems 上,同时把刷新卡顿、列头排序、大列表黑匣子这些问题一并解决。这篇笔记适合 WinForms 开发者、上位机开发、做管理类小工具的人照着复现。
2. 先读懂 ListView 的填充模型:SubItems、Tag 与 Details 视图的作用
2.1 一行的三个关键属性:Text、SubItems、Tag
ListView 的数据模型和 DataGridView 完全不是一回事。DataGridView 面向表格,行和列天然分开;ListView 面向列表,每一行是一个 ListViewItem,行里的第一列存在item.Text,其余列靠SubItems追加。这个差异决定了数据库字段映射的方式:第一列用new ListViewItem(row["字段A"].ToString())创建,后续字段依次item.SubItems.Add(...)。
Tag属性是很多人忽略的隐藏资产。它是object类型,官方没有规定用途,实际上最适合存数据库主键或整行实体。我一般会把id字段塞进Tag,这样双击、右键菜单、批量操作时直接e.Item.Tag就能回查数据库,不用再去拼字符串。千万不要把主键塞进一个不可见的 SubItem——ListView 没有原生的隐藏列机制,硬塞会让列头和数据错位。
2.2 View.Details 才是数据库场景的主力,LargeIcon 不是
很多人搜「c# listview largeicon」,以为 ListView 主要做图标浏览,那是文件管理器残留的印象。数据库展示场景下,真正的主场是View.Details。这个模式下 ListView 才会按列显示表头、允许拖拽列宽、支持列头点击排序,而这些正是数据列表的基本诉求。
切到 Details 之前必须做两件事:设置lv.View = View.Details,并且往lv.Columns里添加列。漏掉任何一个,界面上就只能看到一列光秃秃的数据,连表头都没有。LargeIcon 和 SmallIcon 用得上的场景是带图标的分类列表、图片预览这类视觉化浏览,和数据库结果集展示基本无关,别混着用。
2.3 ListView 与 DataGridView 的选择:只读展示与轻量刷新
选型不是越高级越好。DataGridView 强在可编辑、单元格类型丰富、数据源可以直接绑定 DataTable,但它为了这些能力付出了性能代价:行数上万时每次重绘都拖泥带水,滚动和刷新都有明显顿挫。ListView 没有单元格级别的复杂度,绘制路径短,配合BeginUpdate/EndUpdate后批量插入极快,所以只读列表场景里它反而更稳。
| 对比项 | ListView | DataGridView |
|---|---|---|
| 单元格编辑 | 不支持(需自绘) | 原生支持 |
| 直接绑定 DataTable | 不支持,需手动填充 | 支持 |
| 自绘定制 | 方便,OwnerDraw 成熟 | 复杂,单元格事件多 |
| 大数据量滚动 | 配合 VirtualMode 流畅 | 行数多时明显变慢 |
| 典型场景 | 日志、监控、设备列表 | 单据编辑、表格录入 |
一句话总结:需要编辑就 DataGridView,只是展示和刷新就用 ListView。上位机的实时状态列表、日志监视、排队信息大屏,都是 ListView 的传统优势区。
3. 把数据库结果集填进 ListView:三段可直接复用的 C# 代码
3.1 第一步:用 SqlDataAdapter 把 SQL 结果装进 DataTable
填充 ListView 之前,先把查询结果统一装进DataTable,这样后面无论是绑定还是过滤都方便。下面这段是 SQL Server 的标准写法,代码里用的using保证连接、命令、适配器全部及时释放,避免连接泄漏。
private DataTable LoadData(string sql) { var dt = new DataTable(); string connStr = ConfigurationManager.ConnectionStrings["main"].ConnectionString; using (var conn = new SqlConnection(connStr)) using (var cmd = new SqlCommand(sql, conn)) using (var da = new SqlDataAdapter(cmd)) { conn.Open(); da.Fill(dt); } return dt; }逻辑说明:SqlDataAdapter.Fill内部会执行cmd的 SQL,把结果填充到DataTable。这里显式conn.Open()是为了让后续的异常定位更直接——如果连接串有问题,Open 时立刻抛错,而不是等到 Fill 阶段报一个模糊的异常。
参数说明:ConfigurationManager.ConnectionStrings["main"]是 .NET Framework 时代最常见的配置读取方式,连接串放在 App.config 的<connectionStrings>节点里。这套代码换 MySQL 时只需要改SqlConnection为MySqlConnection、把参数符号从@换成?,其余逻辑完全一致——mysql数据库常用命令里那套查询语法在 C# 客户端这边只影响 SQL 文本本身。
3.2 第二步:遍历 DataTable 生成 ListViewItem 并绑定
拿到 DataTable 之后,核心循环就开始了。这里最容易犯的错是一条条Items.Add不加保护,导致界面疯狂重绘;正确做法是用BeginUpdate()冻结重绘,全部插入完成后EndUpdate()一次性释放。
private void BindList(ListView lv, DataTable dt) { lv.BeginUpdate(); lv.Items.Clear(); foreach (DataRow row in dt.Rows) { var item = new ListViewItem(row["dev_name"].ToString()); item.SubItems.Add(row["dev_ip"].ToString()); item.SubItems.Add(row["status"].ToString()); item.Tag = row["id"]; lv.Items.Add(item); } lv.EndUpdate(); lv.AutoResizeColumns(ColumnHeaderAutoResizeStyle.HeaderSize); }逻辑说明:new ListViewItem(row["dev_name"].ToString())创建的是行对象,同时把dev_name作为第一列文本;之后每调一次SubItems.Add就对应 Columns 里的一列。全部行添加完毕后,AutoResizeColumns按表头文本宽度调整列宽,避免内容挤成一团。
参数说明:这里三个字段dev_name、dev_ip、status必须和前面lv.Columns里添加的列一一对应,顺序错了数据就串列。item.Tag = row["id"]把数据库主键原样存进 Tag,后续增删改查要回查记录时直接取这个值,不需要再依赖行索引。BeginUpdate/EndUpdate必须成对出现,漏掉EndUpdate会让列表永远停在白屏状态。
3.3 第三步:封装通用绑定方法,列名与主键按参数传入
实际项目里一个窗体往往有好几个 ListView,或者同一个 ListView 要切换不同查询,把绑定逻辑封装成通用方法能省掉大量重复代码。下面的方法接收列名数组,自动生成列头并完成数据填充。
private void BindListByColumns(ListView lv, DataTable dt, string[] columns, string keyColumn = "") { lv.BeginUpdate(); lv.Items.Clear(); lv.Columns.Clear(); foreach (string col in columns) { lv.Columns.Add(col, 100, HorizontalAlignment.Left); } foreach (DataRow row in dt.Rows) { var item = new ListViewItem(row[columns[0]].ToString()); for (int i = 1; i < columns.Length; i++) { item.SubItems.Add(row[columns[i]].ToString()); } if (!string.IsNullOrEmpty(keyColumn)) item.Tag = row[keyColumn]; lv.Items.Add(item); } lv.EndUpdate(); }逻辑说明:columns数组既决定列头的显示文本,也决定从 DataTable 取哪几个字段,显示需求变更时只改这一个参数。keyColumn单独拎出来,是因为主键通常不需要展示,但必须挂到Tag上供逻辑层使用。列宽默认 100 像素,绑定后可以再按内容微调。
参数说明:注意lv.Columns.Clear()这行,重复绑定时不清 Columns,新列会叠加在旧列后面,数据错位到怀疑人生。columns[0]是特意保留给ListViewItem.Text的,如果字段顺序调整,第一列会变成别的字段,这是这个方法的副作用——设计时把最重要的字段放第一个。列头排序前记得把ListViewItemSorter置空或重置,否则排序状态会残留到下一次绑定。
4. ListView 填充数据避坑实录:5 个高频问题排查指南
4.1 表头只剩一列:View 没切到 Details 或 Columns 为空
现象:数据明明加载了,界面上却只有孤零零一列,或者干脆连表头都没有。
原因:最常见的是lv.View还停在默认的 LargeIcon,或者设置了 View 但忘了往lv.Columns添加任何列。详情视图下,ListView 的列数和 Columns 集合是对应的,等你Items.Add的时候已经没有列可填了。
解决:在Form_Load或初始化方法里显式设置lv.View = View.Details,然后lv.Columns.Add("设备名称", 120)、lv.Columns.Add("IP地址", 140)这样把每一列先建好。建列和填充数据分离写,排查时一眼就能看出问题在哪层。
4.2 数据一多就假死:BeginUpdate 缺位与 UI 线程查询
现象:查询 100 行没感觉,500 行界面卡几秒,5000 行直接白屏「未响应」。
原因:两层叠加。第一层是循环里每Add一个 Item,ListView 就触发一次重绘,复杂度是 O(n) 的重绘累积;第二层是查询和填充全部压在 UI 线程,数据库慢的时候界面连鼠标都拖不动。这两个问题经常一起出现,很多人只优化了循环,没把查询挪出 UI 线程,治标不治本。
解决:循环外面包上BeginUpdate(),循环结束EndUpdate(),这一步能把 500 行的填充时间从秒级降到毫秒级。查询部分用async/await配合Task.Run放到线程池,拿到 DataTable 后再回到 UI 线程绑定。注意绑定代码本身还是要在 UI 线程执行,只是耗时查询不能在这条线程上跑。
4.3 双击行取不到正确数据:别拼 SubItems,用 Tag 存主键
现象:双击列表中的记录,弹出的详情窗口偶尔是同一行、偶尔是上一行,列顺序调整之后必错。
原因:代码里用e.Item.SubItems[1].Text或e.Item.SubItems[2].Text当主键去查库,一旦列顺序改变或者中间插入新列,索引指向的字段就变了。更糟的是有人用选中行的索引selectedIndex去 DataTable 里取行——排序之后索引和数据库行完全对不上。
解决:绑定数据时就把主键写进item.Tag,双击事件里这样取:
private void Lv_DoubleClick(object sender, EventArgs e) { if (lv.SelectedItems.Count == 0) return; var item = lv.SelectedItems[0]; int id = Convert.ToInt32(item.Tag); // 用 id 去回查数据库,而不是用任何 SubItems 文本 }逻辑说明:Tag不参与显示,也不受列顺序影响,是唯一和行生命周期绑定的可靠存储位。只要数据来源有主键,一律走这条路,别再解析文本。
4.4 多次查询后列表错乱:重置不彻底,Items 和 Columns 都要清
现象:同一个 ListView 先后执行两次不同的查询,第二次结果混着第一次的列,行数也对不上。
原因:只调了Items.Clear(),没清Columns。ListView 的列集合和行集合是分开维护的,第二次查询字段不同,Columns 叠加导致 SubItems 错位。
解决:绑定方法的开头,先lv.Items.Clear(),再lv.Columns.Clear(),然后重新生成列。排序状态同样要重置:lv.ListViewItemSorter = null。我一般在通用绑定方法里固定写上这三行,顺序不能反,否则清列时偶发索引异常。另外注意VirtualMode下不能用Items.Clear(),那是第 6 章单独讲的情况。
4.5 刷新闪成 PPT:给 ListView 开双缓冲
现象:定时器每秒刷新一次列表,屏幕闪烁严重,行数多时像翻页一样,用户看几秒就眼睛疼。
原因:ListView 默认不开启双缓冲,每次Items.Clear()后重绘,旧画面还没来得及擦掉,新画面就画上来了。这种闪烁在 WinForms 的列表控件里是祖传问题。
解决:ListView 的DoubleBuffered属性是 protected,外部访问不到,常见做法是继承一个子类:
public class FastListView : ListView { public FastListView() { DoubleBuffered = true; } }逻辑说明:把窗体设计器里的 ListView 控件替换成FastListView,双缓冲立即生效。这个类什么都不用多做,只暴露 DoubleBuffered 就够了。别尝试用反射去改原控件的私有属性,那属于玄学手段,维护的人看到会想打人。
5. 把 ListView 用到生产环境:列排序、关键字过滤与上位机刷新
5.1 点击列头排序:自定义 IComparer 不能只按字符串比
ListView 的排序不是 DataGridView 那种点一下自动排,需要给ListViewItemSorter挂一个实现了IComparer的比较器。最容易被忽视的是默认的string.Compare会把数字当字符串排:第 2 行排到第 10 行前面,这在数据库数据里完全不可接受。
public class ListViewColumnSorter : IComparer { public int Column { get; set; } public SortOrder Order { get; set; } = SortOrder.Ascending; public int Compare(object x, object y) { var a = (ListViewItem)x; var b = (ListViewItem)y; string sa = a.SubItems[Column].Text; string sb = b.SubItems[Column].Text; if (long.TryParse(sa, out long na) && long.TryParse(sb, out long nb)) return (Order == SortOrder.Descending ? -1 : 1) * na.CompareTo(nb); return (Order == SortOrder.Descending ? -1 : 1) * string.Compare(sa, sb); } }逻辑说明:比较前先尝试long.TryParse,两边都能解析就按数值比较,否则退回字符串比较。这样数字列排对了,文本列也不会崩。SubItems[Column]的索引要和 Columns 的索引一致,排第几列就传第几个索引。
参数说明:Order在ColumnClick事件里切换——同列点击时 Ascending 变 Descending,换列时重置为 Ascending。排序后lv.Sort()要放在比较器赋值之后触发一次重排。注意排序状态绑定在ListViewItemSorter上,通用绑定方法里重置列表时记得置空,否则新数据会沿用旧排序规则,出现诡异顺序。
5.2 关键字过滤:内存 DataView 过滤比重查数据库更稳
做数据开发时最常见的需求是搜索框过滤,很多人第一反应是拼 SQL 重查数据库。小项目没问题,但用户每敲一个字就触发一次全表查询,数据库压力大,界面还有延迟感。更稳妥的做法是首次加载后把 DataTable 留在内存里,过滤时只对内存数据做筛选再重新绑定。
private DataView _view; private void ApplyFilter(string keyword) { if (string.IsNullOrEmpty(keyword)) { _view.RowFilter = string.Empty; } else { _view.RowFilter = $"dev_name LIKE '%{keyword}%' OR dev_ip LIKE '%{keyword}%'"; } var table = _view.ToTable(); BindListByColumns(lv, table, new[] { "dev_name", "dev_ip", "status" }, "id"); }逻辑说明:DataView.RowFilter是内存过滤,不产生新的数据库连接,速度远快于重查。ToTable()把过滤结果转成新 DataTable,再走通用绑定方法,逻辑链路和首次加载完全一致。
参数说明:LIKE '%...%'写法在 RowFilter 里是标准语法,但 keyword 里如果含单引号要转义,否则直接抛语法异常。我一般先keyword.Replace("'", "''")再拼进去。过滤后的绑定会丢掉当前选中项和滚动位置,这是这类做法的代价;如果产品不能接受,就改成增量刷新方案,别硬在过滤上找补。
5.3 上位机场景的增量刷新:每秒更新的界面不能清空重建
c#上位机里最常见的 ListView 用法是刷新仪表状态、扭矩值、端口监听结果这类实时数据。一秒刷新一次,如果每次都是Items.Clear()再全量重建,闪烁和选中态丢失会让操作员崩溃。正确的思路是增删改分开处理。
private void UpdateList(Dictionary<int, DeviceRow> latest) { var seen = new HashSet<int>(); foreach (ListViewItem item in lv.Items) { int id = (int)item.Tag; seen.Add(id); if (latest.TryGetValue(id, out var row)) { item.SubItems[1].Text = row.Ip; item.SubItems[2].Text = row.Status; latest.Remove(id); } } foreach (var kv in latest) { var item = new ListViewItem(kv.Value.Name); item.SubItems.Add(kv.Value.Ip); item.SubItems.Add(kv.Value.Status); item.Tag = kv.Key; lv.Items.Add(item); } // 可选:不在 latest 里的 id 从 lv 中移除 }逻辑说明:先遍历现有行,用Tag里的主键匹配最新数据,匹配上的直接改 SubItems 文本,不动行对象;匹配不上说明是新数据,latest里剩下的自然就是需要新增的行。整个过程中行的位置和选中状态保持不变,视觉上只是内容在跳动,而不是整表闪烁。
参数说明:跨线程调用这段逻辑时,先判断lv.InvokeRequired,为 true 就BeginInvoke把更新切回 UI 线程,绝不能在后台线程直接操作控件——这是 WinForms 的线程铁律,违反它不一定马上崩,但崩起来全是偶发玄学问题。latest用字典按主键索引,比列表 Contains 查找快一个量级,几千行时差距很明显。
6. 大数据量不卡的最后一招:VirtualMode 虚拟模式与二次封装
6.1 打开 VirtualMode 的最小事件流
数据量到几万行时,即使BeginUpdate也救不了填充耗时,因为每一行都是真实存在的控件对象。VirtualMode 的思路反过来:ListView 只负责绘制可见行,数据由你按索引提供,滚动到哪一行就触发一次取数事件。界面永远只创建屏幕范围内的几十个 ListViewItem,内存占用从 O(n) 降到 O(可见行数)。
private List<DeviceRow> _rows; private void SetupVirtualList(ListView lv) { lv.VirtualMode = true; lv.VirtualListSize = _rows.Count; lv.RetrieveVirtualItem += Lv_RetrieveVirtualItem; } private void Lv_RetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e) { var row = _rows[e.Index]; var item = new ListViewItem(row.Name); item.SubItems.Add(row.Ip); item.SubItems.Add(row.Status); item.Tag = row.Id; e.Item = item; }逻辑说明:VirtualListSize告诉 ListView 总共有多少行,它内部按需调用RetrieveVirtualItem,参数里的e.Index就是要显示的行号。事件里必须给e.Item赋值,否则列表显示空白。注意 VirtualMode 下Items.Add、Items.Clear全部不可用,刷新数据直接赋值VirtualListSize再触发Invalidate。
参数说明:VirtualMode 必须配合View.Details使用,并且列头要先建好。_rows是内存缓存,可以按主键排序;事件里按索引访问,时间复杂度是 O(1),适合几万行的日志列表和点位监控。
6.2 封装提示与验证方法
VirtualMode 不是银弹,它有代价:没有选中高亮的联动优化时,点击全选会触发大量事件;行高固定,无法像普通模式那样让行内容撑高。如果你的数据量在 2000 行以内,老老实实用第 3 章的绑定方法,别为了炫技引入虚拟模式。超过 5000 行,再考虑把_rows换成List<T>缓存,并把 RetrieveVirtualItem 事件里的对象作成封装类的属性暴露出来。
验证方法很简单:加载 5 万行数据,用性能分析工具测滚动帧率,VirtualMode 下应该稳定在 60 帧附近,内存占用不随数据量明显上涨。如果滚动还是掉帧,先看RetrieveVirtualItem里有没有做数据库查询——这个事件运行在 UI 线程,任何耗时操作都会直接卡滚动,正确做法是预先缓存好所有行数据。
我最早也迷信 DataGridView,直到一个 5 万行的日志界面把它拖到每次刷新 300ms,才真正把 ListView 的这几种姿势吃透。ListView 这条技术路线不算新,但把它和数据库查询、虚拟模式、增量刷新串起来之后,你会发现老控件解决新场景反而比换控件靠谱。希望帮到你。
本文还有配套的精品资源,点击获取