
简介面向 Windows FormsWinform开发者的精简技术文档解决在 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 对象DataGridViewImageColumnImage 对象不需要内存中已持有图片对象绑定二进制字段DataGridViewImageColumnbyte[]不需要图片存数据库或从接口返回字节数组绑定路径 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 或 PicColumnHeaderText 随意显示中文事件判断永远用 Name。这样就算后续 UI 改文案代码逻辑也不会跟着崩。另外一个细节是列索引的问题。如果你用的是 e.ColumnIndex那要确保这个索引对应的是图片列本身。很多人为了省事写死e.ColumnIndex 3一旦前面的列增删索引就错位了。用 Name 判断比用索引安全得多这也是项目代码这么做的一个重要理由。3. 完整落地代码图片列创建、事件处理与 GetImage 封装3.1 添加图片列的两种方式设计器操作与代码创建先解决图片列从哪来的问题。如果你喜欢在设计器里操作拖一个 DataGridView 到窗体上右键编辑列添加一个 DataGridViewImageColumn把 Name 设为 ImageHeaderText 设成“图片”然后把这个列的 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 Dictionarystring, Image _imageCache new Dictionarystring, 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 加空值和文件存在判断最后必配缓存和缩略图。这套组合看着简单但能少踩很多隐形坑希望帮到你。本文还有配套的精品资源点击获取