简介:面向 Windows Forms(Winform)开发者的精简技术文档,解决在 DataGridView 表格中按单元格展示图片的常见需求。文档以 C# 示例代码贯穿,重点讲解添加 DataGridViewImageColumn 图片列、利用 CellFormatting 事件按路径动态加载图片,以及通过 ImageLayout 属性控制图片缩放效果,并提供 GetImage 方法处理文件流读取与异常的思路;同时提到在事件中先判断单元格值是否为空,避免加载不存在的路径抛出异常。适合正在做数据表格图文展示的 .NET 学习者参考。资源压缩包内共 1 个 PDF 文件,大小仅 27KB,内容紧凑、即下即用。已有 420 人浏览学习,文档末尾还总结了四个实现步骤,并给出可直接复制改写的关键代码片段,可帮助开发者快速在 Winform 项目里实现同样的图片展示功能。
1. Winform 表格里显示图片:绕不开的 CellFormatting 事件与路径转图问题
做 Winform 项目时,在 DataGridView 里显示图片是个高频需求,比如设备列表里放设备照片、订单表格里展示商品缩略图。但大多数项目里,数据源存的是图片的磁盘路径,不是可以直接丢给单元格的 Image 对象。如果你只是拖一个 DataGridViewImageColumn,然后把 DataPropertyName 指向路径字段,多半会看到空白单元格或者默认红叉占位图,图片并不会自动加载。正解是绑定数据后,在 CellFormatting 事件里把路径字符串转成 System.Drawing.Image,再赋给当前单元格的 e.Value。这篇笔记把这个方案从列类型选择、事件写法、文件流释放到常见报错完整过一遍,适合在做 Winform 桌面项目、被表格图片展示卡住的朋友直接照着改。
2. 先选对列类型:三种显示图片的思路对比与 CellFormatting 方案的取舍
2.1 DataGridView 显示图片的三种常规做法
在 DataGridView 里显示图片,严格来说不止一种办法,但每种的前提条件差别很大,选错了后面全是坑。
第一种是直接在数据源里放 Image 对象。你可以在内存里创建好 System.Drawing.Image,然后把它赋给 DataGridViewImageColumn 的单元格 Value。这种做法的前提是图片已经被加载到内存了,数据源本身持有的是对象而不是路径。实际项目里很少见,因为你总得先把图片从磁盘或数据库读出来,而且大量行都持有 Image 对象,内存占用会非常难看。
第二种是列绑定数据库里的二进制图片字段。DataGridViewImageColumn 内部支持 byte[] 类型的值,只要你从 SQL Server 这类数据库里查出的是 varbinary 字段,绑定后它会把字节数组转成图片显示。这个方案适合图片本身就存在数据库里的场景,但如果你是从文件路径加载,这条路线就用不上。
第三种就是用 DataGridViewImageColumn 绑定字符串路径字段,然后在 CellFormatting 事件里把路径替换成图片对象。这正是上面代码片段采用的思路,也是实际项目里最通用的一种。数据源里的值一直是路径字符串,绑定、排序、导出都不会受影响,只在单元格绘制前把显示值换成图片。
三种做法的对比参考这个表。
| 做法 | 列类型 | 数据源里的值 | 是否要事件处理 | 适用场景 |
|---|---|---|---|---|
| 直接赋 Image 对象 | DataGridViewImageColumn | Image 对象 | 不需要 | 内存中已持有图片对象 |
| 绑定二进制字段 | DataGridViewImageColumn | byte[] | 不需要 | 图片存数据库,或从接口返回字节数组 |
| 绑定路径 + CellFormatting 转图 | DataGridViewImageColumn | 字符串路径 | 需要 | 图片以文件形式放在磁盘上 |
正文里的示例走的是第三种,而且代码里出现了一个关键点:事件判断条件用的是dataGridview1.Columns[e.ColumnIndex].Name.Equals("Image"),也就是说图片列的名字被定义为 "Image"。后面我会单独说为什么推荐用 Name 而不是 HeaderText 做判断。
2.2 为什么选 CellFormatting:触发时机决定了它适合路径动态转图
CellFormatting 这个事件很多人用过,但未必清楚它到底什么时候触发。它的发生在单元格内容即将被绘制之前,也就是每一行、每一个单元格在显示前都会走一遍这个方法。这个特性恰恰适合做路径转图:因为每一行的图片路径都不同,你需要逐行解析并加载图片。
有人可能会想,我能不能在绑定数据源之前就把路径字段替换成图片对象?比如在 DataTable 里加一列,直接存 Image。这么做的问题是,你在数据层面就把路径换成了对象,后续如果要重新绑定、过滤、导出数据,拿到的就不再是原始路径了。而且如果数据源还涉及数据库回写,Image 对象根本没法序列化回字段里,等于把数据源改了。CellFormatting 不会改底层数据,它只影响显示。
再说一个容易被忽略的细节:CellFormatting 每次重新绘制单元格都可能触发。如果你在事件里直接 new 一个 FileStream 读图片,表格滚动一次就会反复加载图片,性能上很快就会暴露问题。所以后面第 3 章我会给出一个带缓存和判空的完整写法,而不只是项目里那几行最基本代码。
2.3 列名判断用 Name 还是 HeaderText:这里有个隐蔽的坑
代码里写的是Column.Name,这对应列在 DataGridView 里的 Name 属性,而不是列头显示的文字 HeaderText。很多人会误以为列头显示什么就该判断什么,于是写出Column.HeaderText.Equals("图片")。这在某些情况下也能跑通,但只要有人把列头改成“图片预览”“缩略图”之类的中文标题,你的事件判断立刻失效,图片列全部显示为空。
我的习惯是:在设计器里添加图片列时,把 Name 固定成英文标识,比如 "Image" 或 "PicColumn",HeaderText 随意显示中文,事件判断永远用 Name。这样就算后续 UI 改文案,代码逻辑也不会跟着崩。
另外一个细节是列索引的问题。如果你用的是 e.ColumnIndex,那要确保这个索引对应的是图片列本身。很多人为了省事写死e.ColumnIndex == 3,一旦前面的列增删,索引就错位了。用 Name 判断比用索引安全得多,这也是项目代码这么做的一个重要理由。
3. 完整落地代码:图片列创建、事件处理与 GetImage 封装
3.1 添加图片列的两种方式:设计器操作与代码创建
先解决图片列从哪来的问题。如果你喜欢在设计器里操作,拖一个 DataGridView 到窗体上,右键编辑列,添加一个 DataGridViewImageColumn,把 Name 设为 "Image",HeaderText 设成“图片”,然后把这个列的 DataPropertyName 指向数据源里存图片路径的字段名。这一套操作下来,列就有了,后面的事件处理才能接上。
如果你更习惯用代码控制,那么创建图片列的代码大致长这样:
DataGridViewImageColumn imageColumn = new DataGridViewImageColumn(); imageColumn.HeaderText = "图片"; imageColumn.Name = "Image"; imageColumn.ImageLayout = DataGridViewImageCell.ImageLayout.Zoom; dataGridView1.Columns.Add(imageColumn);代码逻辑很简单:new 一个 DataGridViewImageColumn,设置列头文字、列名、图片布局方式,最后加到 DataGridView 的 Columns 集合里。这里需要重点解释的是 ImageLayout 属性,它决定了图片在单元格里怎么摆放。
DataGridViewImageCell.ImageLayout.Zoom会把图片按比例缩放,完整显示在单元格内,不会裁剪,但可能出现上下或者左右的留白。如果选Normal,图片按原始大小绘制,超出单元格的部分会被裁掉,缩略图场景下基本不用它。还有Stretch,图片会被拉伸填满整个单元格,比例可能变形,除非你的图片本身就是固定宽高比,不然不建议用。做缩略图列表,Zoom 是最稳的选择。
至于 DataPropertyName,如果你是在代码里动态创建列,建议也顺手设置一下,否则数据绑定后这一列不知道去取哪个字段的值。
imageColumn.DataPropertyName = "ImagePath";这句的意思是把列的显示值绑定到数据源里的 ImagePath 字段。数据源可以是 DataTable、List 或者 BindingSource,只要这个字段存在,DataGridView 会自动填充。
3.2 CellFormatting 事件里判断列名、取路径、换图片
列创建好了,接下来就是核心部分,处理 CellFormatting 事件。先看项目里给出的版本,再补一个更健壮的写法。
假定你在设计器里已经给 DataGridView 挂上了事件处理,或者你在构造函数里手动注册了事件。事件方法的签名固定是 sender 和 DataGridViewCellFormattingEventArgs 两个参数,后者是关键。
private void dataGridView1_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (dataGridView1.Columns[e.ColumnIndex].Name == "Image") { string path = e.Value?.ToString(); if (!string.IsNullOrEmpty(path)) { e.Value = GetImage(path); } } }这段代码干了三件事:第一步判断当前格式化的是不是图片列,避免每一列都尝试转图片;第二步把 e.Value 取出来转成字符串,也就是图片路径;第三步调用 GetImage 读文件得到 Image 对象,重新赋给 e.Value。
这里有个细节需要说明:e.Value 可能是 DBNull,也可能是 null,所以直接用e.Value.ToString()会抛异常。上面的写法用了e.Value?.ToString(),如果值是 null,表达式结果就是 null,后续的 string.IsNullOrEmpty 判断就会拦住它,不会继续往下走。
在实际项目中,我还会多判断一个文件是否存在。路径字段里可能是脏数据,比如数据库里残留了一个已经删除的文件路径,这种情况 GetImage 里 new FileStream 会直接抛 FileNotFoundException。处理方式是这样:
private void dataGridView1_CellFormatting(object sender, DataGridViewCellFormattingEventArgs e) { if (dataGridView1.Columns[e.ColumnIndex].Name != "Image") return; string path = e.Value?.ToString(); if (string.IsNullOrEmpty(path) || !File.Exists(path)) { e.Value = null; return; } try { e.Value = GetImage(path); } catch (Exception ex) { Console.WriteLine($"加载图片失败:{path},原因:{ex.Message}"); e.Value = null; } }加上 File.Exists 之后,一方面可以拦截不存在的路径,另一方面也让你在调试时更容易区分是路径问题还是文件读取问题。e.Value 设为 null 时,DataGridViewImageCell 会显示一个默认的图片占位符,不会让整个表格崩掉。
3.3 GetImage 封装:FileStream 与 using 的正确姿势
GetImage 方法是整个流程里最容易踩坑的部分,核心是文件流的释放问题。先看项目里的原始写法:
public System.Drawing.Image GetImage(string path) { System.IO.FileStream fs = new System.IO.FileStream(path, System.IO.FileMode.Open); System.Drawing.Image result = System.Drawing.Image.FromStream(fs); fs.Close(); return result; }这段代码在功能上能跑通,但有两个隐患。第一,fs.Close() 在遇到异常时不会执行,文件流可能一直占着文件,导致后面想删除或移动图片文件时提示“文件正由另一进程使用”。第二,Image.FromStream 有一个众所周知的行为:它要求流在图片生命周期内保持打开,否则在某些情况下图片会无法显示或者报错。如果你再把 fs.Close() 写在 return 之前,等于在图片还没真正用完时就把流关了,这在 GDI+ 里偶尔会触发“参数无效”的异常。
更稳妥的写法是下面这样:
public System.Drawing.Image GetImage(string path) { using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read)) { return Image.FromStream(fs); } }using 块保证 FileStream 在方法退出时一定会被释放,即使读取过程中抛异常也会走 Dispose。注意,这里面有一个初学者容易理解错的地方:Image.FromStream 返回的 Image 对象并不依赖 fs 继续存在,前提是你没有提前调用 fs.Close()。用 using 让流在方法结束时释放,而返回的 Image 对象已经被 GDI+ 内部复制了一份数据,所以图片后续还可以正常使用和 Dispose。
如果项目里图片文件比较多,我一般还会在 GetImage 外层增加一个缓存字典,避免同一个路径反复读磁盘。这个放到第 5 章展开说。
关于参数,FileMode.Open 要求文件必须存在,和 FileMode.OpenOrCreate 不一样,后者在文件不存在时会创建一个空文件。这里如果你已经在上层用 File.Exists 判断过了,用 Open 就能把异常拦截在事件处理里。FileAccess.Read 也很重要,它明确告诉系统只需要读权限,不会因为文件被别的进程占用而请求写权限导致冲突。
4. 避坑记录:图片列不显示、文件被占用与刷新不更新的三处典型报错
4.1 图片列全部空白,没有报错
有遇到过这种状态:DataGridView 正常显示了,列也在,数据也在,但是图片列一整列都是空白。排查了一圈发现,事件方法里判断的列名不对。我在 2.3 里提过,事件里用的是Columns[e.ColumnIndex].Name.Equals("Image"),但实际列在设计器里被默认命名成了 DataGridViewImageColumn1,或者你后来把列名改成了“图片”之类的中文,Name 属性根本不是 "Image"。
原因就是事件里按 Name 找列,列的实际 Name 对不上,所以整个格式化方法每次都直接跳过,图片列当然没有值可显示,而 DataGridView 对图片列的空白值不会抛任何异常,顶多显示一个占位红叉。
解决方式不复杂:在设计器里选中图片列,把 (Name) 属性改成 Image;或者在代码里创建列时显式赋值imageColumn.Name = "Image"。改完再跑一遍,图片就出来了。这种问题不会给你任何报错提示,只能靠检查 Name 值来定位。
4.2 图片路径为空时,单元格格式化直接抛异常
有个现象是程序一加载数据就崩溃,异常信息是“未将对象引用设置到对象的实例”,定位到 CellFormatting 里的e.Value.ToString()。原因很简单,数据源里有些行的图片路径字段是 NULL,或者数据库里存的是空字符串。e.Value 为 null 时调用 ToString() 必然抛 NullReferenceException。
解决办法就是在取路径前加空值判断,或者用e.Value?.ToString()先做一次空值容忍。如果还要处理路径存在但文件已删除的情况,就再加上 File.Exists 判断。这套组合下来,只要不是权限问题,基本不会崩。
血泪经验是:写 CellFormatting 时永远假设 e.Value 可能是 null,因为表格里几十上百行数据,你根本不知道哪一行会混进来一条脏数据。
4.3 图片文件被占用,删除或移动时提示“正由另一进程使用”
这个坑通常发生在程序运行期间,你想去磁盘上删掉或替换一张图片,但系统提示文件正在被使用。罪魁祸首就是 GetImage 里 FileStream 没有正确释放。项目原始代码里用了 fs.Close(),但如果 Image.FromStream 之后图片对象还没有被 Dispose,底层文件句柄可能仍然被 GDI+ 持有,Close 并不保证立刻解锁文件。
另外一个更隐蔽的原因:你在 DataGridView 单元格里显示着这张图片,滚动表格时,之前的 Image 对象如果没有释放,文件句柄就会被多个 Image 实例同时占住。解决方式分两步:第一步,GetImage 方法里用 using 管理 FileStream;第二步,在表格重新绑定数据或窗体关闭时,主动释放旧的图片对象,这个在 4.3 里一起说。
如果你发现做了 using 之后文件还是被占用,那就得检查是不是有代码在别处直接 new 了 FileStream 并一直持有没释放。你可以尝试用任务管理器查看进程句柄,或者直接注释掉加载逻辑跑一遍,看文件能不能删掉,用排除法缩小范围。
4.4 刷新数据后图片列还是旧图,不更新
很多人在绑定新数据后,发现图片列显示的还是上一次的数据。原因是 DataGridView 的单元格在重新设置 DataSource 后,如果行数没有变化,或者绑定的字段值没有触发单元格刷新,格式化的结果会被复用。CellFormatting 虽然是每次绘制前触发,但 DataGridView 有一些内部缓存机制,尤其是在同一行位置重复使用时,可能直接延用之前的显示值。
解决方式是在重新绑定数据源后主动让表格刷新一次,最直接的是调用dataGridView1.Refresh()或者先清空 DataSource 再重新赋值。如果你是动态创建列的,还要注意事件是否重复挂载。我曾经在循环里注册了两次dataGridView1.CellFormatting += ...,结果每次刷新时事件执行两次,图片也被加载两遍,内存占用直接翻倍。
另外一个和刷新相关的小问题:如果你修改了图片文件本身,但路径没变,DataGridView 显示的还是旧图片。这是因为 CellFormatting 只在格式化时触发,而格式化可能被 DataGridView 内部缓存跳过。这种情况我通常用 Image 缓存过期策略来解决,也就是缓存字典里记录加载时间,超过一定时间就重新读取文件。
4.5 表格滚动越来越卡,内存飙升
如果你的表格有几千行数据,每一行都有图片,你会发现滚动几次之后,程序响应明显变慢,任务管理器里内存占用持续上涨。原因是每次 CellFormatting 触发时,GetImage 都从磁盘读取文件并生成一个新的 Image 对象,而这些 Image 对象如果没有被释放,会一直留在托管堆里,直到垃圾回收。滚动时单元格反复重绘,就会反复生成新对象。
解决方式有两个方向。第一个是在 CellFormatting 里做对象复用,也就是按路径缓存 Image,相同的路径不重复加载。第二个是在适当的时机释放不再使用的图片对象,比如切换数据源时把缓存清空。如果图片本身很大,建议加载后先压缩成缩略图,而不是直接把原图丢给单元格,这个我会在第 5 章给一套具体的做法。
5. 进阶:从“能显示”到“不卡顿”:缩略图缓存与后台预加载
先说明一个关键前提:CellFormatting 是同步事件,你不能在事件里直接 await 异步方法,否则会破坏格式化流程。所以异步加载的常见做法是,在数据绑定完成后,启动一个后台任务把图片提前加载到缓存字典里,然后强制刷新一次表格,让 CellFormatting 从缓存里取图。这个思路基本能解决大部分性能问题。
我一般会维护一个静态缓存字典,键是图片路径,值是缩略图对象。加载时顺手把原图缩小到单元格需要的尺寸,避免大图撑爆内存。代码框架大概是这样的:
private static Dictionary<string, Image> _imageCache = new Dictionary<string, Image>(); public Image GetThumbnail(string path, int width, int height) { if (_imageCache.TryGetValue(path, out Image cached)) return cached; using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read)) using (Image original = Image.FromStream(fs)) { Image thumb = original.GetThumbnailImage(width, height, null, IntPtr.Zero); _imageCache[path] = thumb; return thumb; } }这段代码的逻辑和参数说明一下:GetThumbnailImage 是 GDI+ 内置的方法,它会按指定的宽高生成缩略图,内部处理了缩放算法。width 和 height 建议和单元格的实际显示尺寸保持一致,或者稍微大一点用于高清屏。注意 original 也被 using 包住了,因为 GetThumbnailImage 返回的是新对象,原图可以在生成完缩略图后立刻释放。
缓存字典有一个副作用:当你重新绑定数据,或者图片文件被替换时,缓存里的旧图会一直存在。我的处理方式是在重新绑定数据源之前调用一次_imageCache.Clear(),然后顺手 Dispose 掉旧的图片对象,否则内存占用很快又上去了。另外,如果你用的是缩略图缓存,文件被占用的问题也会减轻不少,因为 FileStream 在 using 块结束时就关闭了,缩略图对象不依赖文件句柄。
至于后台预加载,我会用一个简单的 Task 在绑定数据后跑,把当前页可见行的图片路径先塞进缓存。这在行数特别多的时候效果很明显,因为用户快速滚动时,CellFormatting 能直接从缓存取图,只有没缓存到的行才会走磁盘读取。
Task.Run(() => { foreach (DataGridViewRow row in dataGridView1.Rows) { string path = row.Cells["Image"]?.Value?.ToString(); if (!string.IsNullOrEmpty(path) && !_imageCache.ContainsKey(path)) { GetThumbnail(path, 80, 60); } } dataGridView1.BeginInvoke(new Action(() => dataGridView1.Refresh())); });代码里 BeginInvoke 的作用是在后台线程加载完之后,切回 UI 线程刷新表格,否则界面不会自动更新。这里我通常把缩略图尺寸固定成 80x60,适合常见的行高和列宽。如果你用 Zoom 布局,缩略图尺寸只要比例和原图一致,缩放后就不会失真。
从那以后,我每次做 DataGridView 图片展示需求,都会强制走一遍三件事:先确认列的 Name 和事件里判断的字符串一致,再给 GetImage 加空值和文件存在判断,最后必配缓存和缩略图。这套组合看着简单,但能少踩很多隐形坑,希望帮到你。
本文还有配套的精品资源,点击获取