C# ListView 显示数据库数据:从 DataTable 到 VirtualMode 的性能优化实战
简介这是一套面向C#初学者的ListView控件数据展示实例源码重点演示在Windows Forms中通过ADO.NET连接Access数据库并使用ListView的详细信息视图展示字段数据。案例覆盖从数据库连接、SQL查询、DataAdapter填充到控件列设置与数据绑定的完整流程适合学习数据驱动界面开发的读者参考。压缩包共40个文件包含14个cs源代码文件、2个sln解决方案文件、2个csproj工程文件、resx资源文件、exe可执行程序及accdb数据库文件等整体约78KB。案例还附带Backup备份目录便于对照工程结构。已有825人学习下载通过该实例可掌握ListView列头与子项的设置逻辑理解ItemSelectionChanged等事件在用户交互中的用法为后续开发带排序、搜索和分页功能的业务系统打下基础。1. 为什么用 ListView 展示数据库数据先搞清楚它是表格还是树做 WinForms 上位机或进销存时经常要把 SQL Server 或 SQLite 里的记录摆到界面上。很多人第一反应是拖一个 DataGridView但在“设备列表”“历史报警”这类只读展示场景里ListView 反而更顺手它既能当表格用又能切成 LargeIcon 做图标视图还能靠 VirtualMode 扛住几十万行不卡。这篇文章就是围绕“ListView 控件显示数据库数据的 C# 源代码”这条主线把数据源准备、控件初始化、性能优化和交互封装讲透。适合那些已经会写基本 SQL但一碰到 ListView 就卡在列宽、行选中和刷新闪烁上的开发者。先给结论ListView 不是不能显示数据库而是很多人用错了它的数据填充方式。2. 准备数据源DataTable 还是实体集合决定你后面少写多少转换代码2.1 用 SqlDataAdapter 一把梭DataTable 方案的最小可跑代码最常见的做法是直接用 SqlDataAdapter 把查询结果塞进 DataTable然后遍历行生成 ListViewItem。这段代码我几乎在每个项目里都写过先看最小可跑版本private void LoadDataToListView(string connString) { string sql SELECT Id, Name, Status, CreateTime FROM DeviceInfo ORDER BY Id; using (SqlConnection conn new SqlConnection(connString)) using (SqlDataAdapter adapter new SqlDataAdapter(sql, conn)) { DataTable dt new DataTable(); adapter.Fill(dt); listView1.BeginUpdate(); listView1.Items.Clear(); foreach (DataRow row in dt.Rows) { ListViewItem item new ListViewItem(row[Id].ToString()); item.SubItems.Add(row[Name].ToString()); item.SubItems.Add(row[Status].ToString()); item.SubItems.Add(Convert.ToDateTime(row[CreateTime]).ToString(yyyy-MM-dd HH:mm:ss)); // 关键把整行数据存进 Tag后面取主键不用再查库 item.Tag row[Id]; listView1.Items.Add(item); } listView1.EndUpdate(); } }这段代码的逻辑很简单Fill 之后DataTable 的每一行对应一个 ListViewItem第一列用 ListViewItem 的构造参数后面的列用 SubItems.Add。BeginUpdate 和 EndUpdate 是必须的没有它们每加一行控件就会刷新一次几千行数据时肉眼可见的卡顿。这里有个容易踩的细节row[CreateTime]如果数据库里是 NULL直接 ToString() 会变成空字符串但 Convert.ToDateTime 会抛异常。所以读数据库值的时候一定要先判断 DBNull。我在 4.3 节会专门展开。另外item.Tag row[Id]这行很多人会漏掉这就导致后面双击行想拿数据库主键时还得通过 SubItems[0].Text 再转一次类型一旦显示格式变了比如 ID 补零主键就取错了。2.2 换成实体集合再加 BindingSourceListView 与数据解耦的正确姿势DataTable 方案好在代码短但坏处是所有逻辑都耦合在 UI 层。如果项目里用了三层架构或者想把 ListView 里的数据交给 Excel 导出、报表打印我更推荐先把数据库记录映射成实体类再通过 BindingSource 中转。ListView 本身支持数据绑定虽然不如 DataGridView 那么直观但配合自定义适配器也能跑通。先定义一个最简单的实体public class DeviceInfo { public int Id { get; set; } public string Name { get; set; } public string Status { get; set; } public DateTime CreateTime { get; set; } }然后从数据库读出 ListDeviceInfo把 ListView 的每个 Item 和实体一一对应private void BindListView(ListDeviceInfo devices) { listView1.BeginUpdate(); listView1.Items.Clear(); foreach (var d in devices) { var item new ListViewItem(d.Id.ToString()); item.SubItems.Add(d.Name ?? string.Empty); item.SubItems.Add(d.Status ?? string.Empty); item.SubItems.Add(d.CreateTime.ToString(yyyy-MM-dd HH:mm:ss)); item.Tag d; listView1.Items.Add(item); } listView1.EndUpdate(); }注意这里d.Name ?? string.Empty能避免实体属性为 null 时 SubItems.Add 收到 null 导致显示空白或异常。实体方案的好处是 Tag 里直接放整个对象双击事件里强转成 DeviceInfo 就能拿到所有字段不用再去查库。如果你用 BindingSource 做过滤还可以这样挂BindingSource bs new BindingSource(); bs.DataSource devices; // 然后通过 bs.Filter 做字符串过滤需要实体实现 ITypedList 或使用 DataView但说实话ListView 的过滤远不如 DataGridView BindingSource 顺手所以我的习惯是数据量在 1 万行以内直接用实体集合 手写循环超过 1 万行用 2.1 的 DataTable VirtualMode见第 3 章。2.3 选型对比什么时候该用哪套方案很多刚接触 C# 的读者会纠结到底用 DataTable 还是 ListT。我给一张表说清楚对比项DataTable 方案实体集合方案代码量少适合快速原型需要先定义类代码略多类型安全弱取出来是 object强编译期就能发现问题空值处理必须手动检查 DBNull只需检查 null逻辑清晰后续扩展导出、报表时还要再转一刀直接交给 Excel 组件或序列化配合 BindingSource支持较好Filter 现成支持一般过滤要自己写大数据量配 VirtualMode 方便配 VirtualMode 稍麻烦我个人在写数据库工具类或演示 Demo 时用 DataTable因为快一旦进入正式项目尤其涉及多窗体共用数据就转实体。这里的关键不是哪个“高级”而是你后面要拿这些数据做什么。只做展示DataTable 够了要做二次加工实体集合能省掉一层转换。3. 把数据灌进 ListView三列起步的完整示例与虚拟模式3.1 初始化 ListView 三件套Columns、View、FullRowSelectListView 要像表格一样显示数据库数据第一步不是写循环而是把控件本身的属性设对。很多人拖完控件就跑代码结果运行出来只有一列就是因为没设置 Columns。我通常在窗体构造或 Load 事件里做一次性初始化private void InitializeListView() { listView1.View View.Details; // 必须用 Details 才能显示列 listView1.FullRowSelect true; // 点任意列都能选中整行 listView1.GridLines true; // 画网格线更像表格 listView1.MultiSelect false; // 默认单选避免误操作 listView1.HeaderStyle ColumnHeaderStyle.Clickable; listView1.Columns.Add(ID, 80, HorizontalAlignment.Right); listView1.Columns.Add(设备名称, 160, HorizontalAlignment.Left); listView1.Columns.Add(状态, 90, HorizontalAlignment.Center); listView1.Columns.Add(创建时间, 150, HorizontalAlignment.Left); }Column 的构造函数三个参数分别是标题、宽度、对齐方式。这里有个很容易被忽略的点ColumnHeaderStyle.Clickable表示允许用户点击列头排序但如果你没有写 ColumnClick 事件处理点击列头不会产生任何效果。很多教程说“ListView 支持点击列头排序”实际上那是需要你自己写代码的控件本身并不内置排序。我见过不少项目因为这颗雷被测试提 bug用户点了列头没反应。FullRowSelect必须设置。默认情况下用户只有点第一列才能选中整行点第二列后面只是空白这个交互在数据库列表场景里非常反直觉。另外如果数据列很多超过控件宽度还要记得设置listView1.Scrollable true默认就是 true否则横向滚动条出不来。3.2 循环添加 ListViewItem慢在哪怎么改快初始化之后就是往里面填数据。最直接的写法是 foreach DataRow像 2.1 那样。但为什么你加了 5000 行会卡 2 秒除了没写 BeginUpdate还有一个更隐蔽的原因——listView1.Items.Add每调用一次控件都会重新计算边界、布局、触发一次重绘。即使你包了 BeginUpdate/EndUpdate如果每行还伴随 SubItems.Add 和字符串转换损耗依然很大。优化办法是批量构建对象最后一口气提交。常见做法是先建一个 ListViewItem 数组再通过 AddRange 一次性添加private void LoadWithAddRange(ListDeviceInfo source) { var items new ListListViewItem(source.Count); foreach (var d in source) { var item new ListViewItem(d.Id.ToString()); item.SubItems.Add(d.Name); item.SubItems.Add(d.Status); item.SubItems.Add(d.CreateTime.ToString(yyyy-MM-dd HH:mm:ss)); item.Tag d; items.Add(item); } listView1.BeginUpdate(); listView1.Items.Clear(); listView1.Items.AddRange(items.ToArray()); listView1.EndUpdate(); }这套写法在 1 万行以内性能和可读性都不错。再往上AddRange 本身也要花时间构造对象这时就该考虑虚拟模式了。还有一个影响速度的隐藏因素ListView 每行的字体、颜色如果通过item.UseItemStyleForSubItems或自定义绘制实现重绘开销会成倍增加。数据库数据展示没必要做花哨的行颜色最多用item.BackColor标个状态色。要追求性能保持默认绘制。3.3 大数据量用 VirtualModeListView 显示十万行不卡的关键当数据量超过 2 万行老老实实往 Items 里塞对象迟早会卡到用户砸键盘。ListView 有个专门的虚拟模式只显示可视区域的行滚动时通过 RetrieveVirtualItem 事件按需取数据。这才是 ListView 比 DataGridView 强的地方。先开启虚拟模式listView1.VirtualMode true; listView1.VirtualListSize 100000; // 总行数不实际创建对象 listView1.RetrieveVirtualItem OnRetrieveVirtualItem;然后在事件里按索引返回对应的 ListViewItemprivate ListDeviceInfo _virtualCache; private void OnRetrieveVirtualItem(object sender, RetrieveVirtualItemEventArgs e) { if (_virtualCache ! null e.ItemIndex 0 e.ItemIndex _virtualCache.Count) { var d _virtualCache[e.ItemIndex]; var item new ListViewItem(d.Id.ToString()); item.SubItems.Add(d.Name); item.SubItems.Add(d.Status); item.SubItems.Add(d.CreateTime.ToString(yyyy-MM-dd HH:mm:ss)); e.Item item; } else { e.Item new ListViewItem(); } }注意虚拟模式下不能使用 Items.Add 填充数据因为 Items 集合是空的所有显示都靠 RetrieveVirtualItem 按需生成。这种方式把内存占用压到最低十万行数据也能平滑滚动。但有两个坑第一虚拟模式下无法使用某些依赖 Items 集合的功能比如自动排序需要自己维护索引映射第二鼠标点击选中后如果数据源在后台刷新了必须调用listView1.VirtualListSize listView1.VirtualListSize;或重新赋值触发重绘否则界面显示的和数据源不一致。我曾经拿一台 4G 内存的旧工控机测过普通模式加 8 万行内存涨了 200 多 MB滚动有延迟切换虚拟模式后内存基本不变滚动流畅。所以凡是历史数据查询这类“只读、量大、可能还要带筛选”的场景我上来就设 VirtualMode。4. ListView 显示数据库数据的避坑指南排序、选中、刷新与类型转换4.1 点击列头排序后数据错乱ListViewItem 的 Tag 才是亲爹现象写了 ColumnClick 后按列头点了两下列表的顺序变得乱七八糟有些行还串了位置。原因ListView 自带的 ListViewItemSorter 比较的是 ListViewItem 的 Text 或 SubItems 的 Text而不是数据库里的原始值。比如“Id”列你显示成“001、002、010、011”按字符串排序就成了“001、010、011、002”。更严重的是排序后你通过 item.Index 去访问数据源索引已经对不上原始行号。解决把数据库主键或整个实体放进 Tag排序比较也基于 Tag 里的值private void listView1_ColumnClick(object sender, ColumnClickEventArgs e) { listView1.ListViewItemSorter new ListViewItemComparer(e.Column); } class ListViewItemComparer : IComparerListViewItem { private int _col; public ListViewItemComparer(int col) { _col col; } public int Compare(ListViewItem x, ListViewItem y) { // 优先用 Tag 里的实体属性做比较避免字符串排序陷阱 if (x.Tag is DeviceInfo dx y.Tag is DeviceInfo dy) { return _col switch { 0 dx.Id.CompareTo(dy.Id), 1 string.Compare(dx.Name, dy.Name, StringComparison.CurrentCultureIgnoreCase), 2 string.Compare(dx.Status, dy.Status, StringComparison.Ordinal), 3 dx.CreateTime.CompareTo(dy.CreateTime), _ 0 }; } return string.Compare(x.SubItems[_col].Text, y.SubItems[_col].Text); } }这样排序前后 Tag 始终跟随行移动后续取主键依然正确。记住凡是对 ListView 做了任何会改变行顺序的操作都不要再用 Index 当主键Tag 才是唯一的信标。4.2 刷新数据时界面闪烁BeginUpdate/EndUpdate 与双缓冲现象每次用 Timer 轮询数据库刷新状态ListView 就像屏幕在闪严重时整个窗体都在抖。原因刷新时先 Clear 再重新 Add每次操作都触发 WM_PAINT。虽然前面说过 BeginUpdate 能临时挂起重绘但 Clear 和 Add 交替执行时EndUpdate 之前控件其实还保留着旧元素视觉上会闪一下空白。解决先把旧数据和新数据都构建成数组一次 Clear 一次 AddRange并开启双缓冲listView1.BeginUpdate(); listView1.Items.Clear(); listView1.Items.AddRange(newItems.ToArray()); listView1.EndUpdate();同时在窗体构造里给 ListView 开启双缓冲// 用反射启用控件双缓冲抑制刷新闪烁 typeof(ListView).GetProperty(DoubleBuffered, System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) ?.SetValue(listView1, true);注意 DoubleBuffered 是受保护属性不能直接赋值这条反射是 WinForms 里通用的玄学操作。如果刷新频率很高比如 1 秒一次我还会加一个判断只有数据变化了才重新绑定没变化就不动界面这比任何视觉优化都省资源。4.3 从数据库读出的 DBNull 导致崩溃Convert 的统一处理现象数据库某列允许 NULL程序跑到这一行就抛 InvalidCastException或者界面显示一片空白找不到原因。原因DataRow 里的字段值是 DBNull直接赋给 string 变量或调用 ToString() 往往会拿到空字符串但调用Convert.ToInt32、DateTime.Parse却直接炸。实体方案里如果用reader.GetDateTime(3)同样会炸。解决写一个静态转换方法统一处理 DBNull 和 nullpublic static class SafeConvert { public static string ToString(object value, string defaultValue ) { return value null || value DBNull.Value ? defaultValue : value.ToString(); } public static DateTime ToDateTime(object value) { if (value null || value DBNull.Value) return DateTime.MinValue; if (DateTime.TryParse(value.ToString(), out DateTime result)) return result; return DateTime.MinValue; } public static int ToInt(object value) { if (value null || value DBNull.Value) return 0; if (int.TryParse(value.ToString(), out int result)) return result; return 0; } }然后填充 ListViewItem 时统一调用item.SubItems.Add(SafeConvert.ToString(row[Name], 未知)); item.SubItems.Add(SafeConvert.ToDateTime(row[CreateTime]).ToString(yyyy-MM-dd HH:mm:ss));这个坑在 SQLite 和 Access 数据库里特别常见因为它们的类型约束松字段可能是字符串类型的“2023-1-1”也可能是真正的日期类型。我见过同事排查了两个小时最后发现是数据库里混入了文本格式的时间。4.4 行高、图标与 LargeIcon 场景列表视图和图标视图的切换坑现象同一份数据切换 View.LargeIcon 后之前的列头、行高设置全部失效图标也不显示。原因ListView 的 Details 视图和 LargeIcon 视图是两套绘制体系。Details 模式下列头、网格线、行选中都有效而 LargeIcon 模式下它只看 ImageList 和 Item 的 TextColumns 不参与绘制。解决如果你需要两种视图切换比如设备列表支持按大图标看要处理好两个点。第一确保 ListViewItem.ImageIndex 有值item.ImageIndex d.Status 在线 ? 0 : 1;第二在 View 切换后重新设置一次 SmallImageList 和 LargeImageList否则图标可能不刷新private void SwitchView(View targetView) { listView1.View targetView; if (targetView View.LargeIcon) { listView1.LargeImageList imageList48; } else { listView1.SmallImageList imageList16; } listView1.Refresh(); }还有一个冷门问题Details 模式下ListView.Columns的宽度在设计器里调好了但 LargeIcon 用不到等切回 Details 时列宽可能会被重置。我的做法是保存一份列宽字典切换前记录切换后恢复。这些都是血泪经验不做的话用户切一次视图就骂一次。5. 给 ListView 加交互双击查看、搜索过滤与分页加载5.1 双击行拿数据库主键千万别用 SubItems[0]场景是用户双击某行设备弹出详情窗体。很多新手的写法是string id listView1.SelectedItems[0].SubItems[0].Text;这在不排序、不改显示格式时没问题但一旦你加了 4.1 的列头排序或者把第一列显示成“DEV-0001”这种业务编号SubItems[0] 就不是数据库主键了。正确做法是依赖 Tagprivate void listView1_DoubleClick(object sender, EventArgs e) { if (listView1.SelectedItems.Count 0) return; ListViewItem selected listView1.SelectedItems[0]; if (selected.Tag is DeviceInfo device) { // 直接用 device.Id 打开详情窗体 DeviceDetailForm form new DeviceDetailForm(device.Id); form.ShowDialog(this); } }如果把 Tag 存的是 DataRowView也别慌((DataRowView)selected.Tag).Row[Id]拿主键。总之界面上显示的字符串只是给人看的程序内部的主键传递必须走 Tag。5.2 在 ListView 上做增量搜索找到目标行并高亮数据库列表通常很长用户不想一页页翻。常见的做法是在窗体上方放一个 TextBox用户输入字符时自动定位第一匹配行并选中。ListView 没有内置搜索但我们可以自己做private void txtSearch_TextChanged(object sender, EventArgs e) { string keyword txtSearch.Text.Trim(); if (keyword.Length 0) return; foreach (ListViewItem item in listView1.Items) { // 默认搜索第二列设备名称 if (item.SubItems[1].Text.IndexOf(keyword, StringComparison.OrdinalIgnoreCase) 0) { item.Selected true; item.Focused true; listView1.EnsureVisible(item.Index); return; } } }这里有两个细节。第一EnsureVisible能让滚动条滚到目标行否则在十万行里搜到也看不到。第二如果启用了 VirtualMode不能直接遍历listView1.Items得遍历你的数据源集合再通过索引去选中虚拟行private void SearchInVirtualMode(string keyword) { for (int i 0; i _virtualCache.Count; i) { if (_virtualCache[i].Name.Contains(keyword)) { listView1.SelectedIndices.Clear(); listView1.SelectedIndices.Add(i); listView1.EnsureVisible(i); return; } } }虚拟模式下选中行用 SelectedIndices而不是 Items[index].Selected这也是新手容易翻车的地方。每次搜索只找第一个匹配再按 Enter 或 F3 找下一个这种交互比实时过滤几千行更省性能。5.3 真分页还是假分页2 万行以上的取舍数据量很大时除了 VirtualMode还可以做分页。分页有两种做法真分页每次只从数据库取一页比如 OFFSET 30 ROWS FETCH NEXT 20 ROWS ONLY配合页码切换。假分页一次性把所有数据读进来每次只显示其中一段翻页不做数据库查询。很多新手误以为“假分页”不算分页其实业务场景不同选择不同。如果数据表记录数不夸张5 万以内假分页配合 ListView 非常流畅因为内存完全扛得住如果数据量到了百万级或者数据库是远程的真分页更有必要否则第一次加载就要 30 秒。真分页的典型 SQLSQL ServerSELECT * FROM DeviceInfo ORDER BY Id OFFSET 0 ROWS FETCH NEXT 50 ROWS ONLY;在 C# 里只管替换 OFFSET 的值。注意分页查询必须配合稳定的 ORDER BY否则翻页时会出现重复或漏行。假分页则简单得多把全量数据放到 List 翻页时截断private void ShowPage(int pageIndex) { int pageSize 50; int skip pageIndex * pageSize; var pageData _fullList.Skip(skip).Take(pageSize).ToList(); BindListView(pageData); }我一般建议行数未知、可能有几十万行、还需要按条件筛选就做真分页加一个查询条件字符串拼到 SQL 里行数有限且用户希望切视图、做增量搜索就假分页或干脆不分页。别一上来就“为了分页而分页”那只会增加代码复杂度。6. 最后的干活技巧把通用 ListView 封装成控件省掉 80% 重复代码做了三个项目你会发现 ListView 显示数据库数据的套路完全一样Clear、循环添加、设置 Tag、处理双击。与其每次复制粘贴不如写一个通用的DataListView控件。核心思路是暴露一个数据源属性内部自动把 DataTable 或实体集合转换成 ListViewItem。下面是一个浓缩版public class DataListView : ListView { public void SetDataSource(DataTable table, string[] displayColumns) { BeginUpdate(); Items.Clear(); Columns.Clear(); for (int i 0; i displayColumns.Length; i) { Columns.Add(displayColumns[i], 100, HorizontalAlignment.Left); } foreach (DataRow row in table.Rows) { var item new ListViewItem(SafeConvert.ToString(row[0])); for (int i 1; i displayColumns.Length; i) { item.SubItems.Add(SafeConvert.ToString(row[i])); } item.Tag row; Items.Add(item); } EndUpdate(); } }有了这个基类新窗体只需要拖这个控件调用SetDataSource(dt, new[]{ID, 设备名称})就完成了。更复杂的变体可以加列宽自动分配、状态图标映射、双击事件转发。我自己的习惯是所有 ListView 的初始化属性View、FullRowSelect、DoubleBuffered都在控件构造函数里做不依赖窗体设计器这样窗体里永远看不到一堆初始化代码。这个方向值不值得投入如果你长期做 WinForms 数据管理类工具非常值得。ListView 的虚拟模式和大图标视图是 DataGridView 给不了的封装好之后新页面半小时就能交活。而如果只是单个窗体临时用直接按 2.1 的代码写就能满足不必强行上框架。最后提醒一句所有从数据库取来的数据展示前先过一遍 SafeConvert别偷懒。我把这条写进自己的代码规范后就再也没被“数据库突然多了一条空记录”这种问题半夜叫醒过。希望帮到你。本文还有配套的精品资源点击获取