简介这是一个基于.NET WinForm平台实现的批量图片压缩工具完整源代码面向C#桌面应用初学者、图像处理入门开发者以及需要快速处理大量图片的IT从业者。项目围绕WinForm事件驱动模型构建通过OpenFileDialog选择目录、ProgressBar展示进度并依靠System.Drawing下的Bitmap、Graphics、Image类完成图片加载与编码涵盖JPEG有损压缩与PNG无损压缩的核心思路。压缩包共24个文件大小仅68KB以6个cs源代码文件为主体配合exe可执行程序、resx/resources资源文件、pdb调试符号及项目解决方案文件目录结构清晰便于直接阅读和调试。已有803人学习下载。源码中还可以看到Directory与FileInfo实现文件遍历、foreach逐文件处理、异常捕获与多线程性能优化等实用写法既能帮助理解WinForm桌面应用的整体开发流程又能作为稍作改造即可上手的轻量工具脚本适合用来巩固理论并提升实际编码能力。1. 批量图片压缩不是改个质量值那么简单这份WinForm源码把半小时重复劳动变成一次点击运营丢过来两百张商品图要求压到单张200KB以内。手工一张张压四十分钟搭进去还干不了别的。这份WinForm批量图片压缩工具源码解决的就是这个场景选定一个根目录程序自动递归扫描所有图片按你设定的质量、尺寸和输出格式批量处理进度可见、随时可取消、中途断了还能续。主体是C#写的桌面程序压缩核心走System.Drawing的GDI编码器质量、最长边、输出格式全部参数化。适合做内部工具、处理设计素材或者想直接抄一套「扫描→异步处理→进度回传→错误恢复」完整流水线的开发者。它不是一百行教学Demo文件结构和边界情况都是按能长期用的标准写的。2. WinForm界面与异步任务压缩两百张图不卡窗口的线程设计2.1 控件划分把「选目录、设参数、看进度」拆成四个独立区域主窗体按职责拆成四块顶部是源目录选择中间是压缩参数区质量滑块、最长边、目标格式、输出目录下面挂待处理文件列表底部是进度条加开始/取消按钮。这个划分看着简单但很多人做工具时容易把所有逻辑揉进一个Form文件里加一个参数要翻半天改一个坑要牵连三处。正确做法是把处理逻辑全部塞进独立类窗体只负责收参数、发起任务、展示状态。public partial class MainForm : Form { private CompressOptions _options new CompressOptions(); private CancellationTokenSource _cts; public MainForm() { InitializeComponent(); // 经验起点JPEG质量85最长边1920 trackQuality.Value _options.Quality 85; txtMaxSide.Text _options.MaxSide.ToString(); cmbFormat.SelectedIndex 0; // 0JPG 1PNG 2原格式 } }这段代码解决的是「默认参数从哪来」的问题。CompressOptions是个纯数据类字段包括SourceDir、OutputDir、Quality、MaxSide、TargetFormat。把默认值集中在构造函数里比散落在各个事件处理器里好排查得多。cmbFormat的SelectedIndex建议映射到枚举而不是用魔法数字后面想加格式时不用改页面逻辑和索引对照表。文件列表超过几百条时ListView的普通填充模式会明显卡顿。几千个文件往Items里塞滚动和刷新都有延迟。用VirtualMode可以绕开这个坑ListView只渲染可见行滚动时通过RetrieveVirtualItem事件按索引临时取数据。listViewFiles.VirtualMode true; listViewFiles.RetrieveVirtualItem (s, e) { e.Item new ListViewItem(Path.GetFileName(_files[e.ItemIndex])); e.Item.SubItems.Add(_files[e.ItemIndex]); }; // 数据源变化时通知刷新 listViewFiles.VirtualListSize _files.Count; listViewFiles.Invalidate();VirtualMode下不能再直接Add、Remove Item所有对列表的修改都要先改数据源再调VirtualListSize和Invalidate让控件重新拉取可见区域的数据。这个模式适合文件数不确定的批量场景几百张和几万张都能平稳跑。2.2 用Task.Run跑压缩、用Progress回传进度为什么不再用BackgroundWorkerBackgroundWorker在.NET Framework时代是标准答案但它的DoWork、ProgressChanged、RunWorkerCompleted三段式事件写起来啰嗦取消机制还要手动判断e.Cancelled。换成async/await加Progress 代码线性很多阅读和排错都轻松。private async void btnStart_Click(object sender, EventArgs e) { SetBusy(true); _cts new CancellationTokenSource(); var files ImageScanner.Scan(_options.SourceDir); var progress new Progressint(value { progressBar.Maximum files.Count; progressBar.Value value; lblStatus.Text $已处理 {value}/{files.Count}; }); try { await Task.Run(() _compressor.ProcessAll(files, _options, progress, _cts.Token)); lblStatus.Text 全部完成; } catch (OperationCanceledException) { lblStatus.Text 已取消; } finally { SetBusy(false); } }Progress 在构造时会捕获当前SynchronizationContext也就是UI线程上下文所以回调直接跑在UI线程上不需要手写Invoke。这是新手写后台任务最容易翻车的地方直接在后台线程里改progressBar.Value要么抛跨线程异常要么靠CheckForIllegalCrossThreadCallsfalse把报错压掉后者属于饮鸩止渴真出事连排查方向都没有。Task.Run把CPU密集的压缩工作丢到线程池界面自然保持响应。Parallel.ForEach在这里有一个边界必须提醒图片解码非常吃内存八张两千万像素原图同时解压开内存轻松过1GB。我一般把并行度限制在3而不是直接填ProcessorCount。Parallel.ForEach(files, new ParallelOptions { MaxDegreeOfParallelism 3, CancellationToken _cts.Token }, file _compressor.CompressOne(file, _options));ParallelOptions里的CancellationToken能让Parallel.ForEach在取消时尽快抛OperationCanceledException配合外层try/catch干净退出。还有一句老生常谈async void只用在事件处理器里普通方法不要这么写否则异常会直接砸到进程头上连catch的机会都没有。扫描文件的ImageScanner核心就一段Directory.EnumerateFilespublic static class ImageScanner { private static readonly HashSetstring _exts new( new[] { .jpg, .jpeg, .png, .bmp, .tif, .tiff }, StringComparer.OrdinalIgnoreCase); public static Liststring Scan(string rootDir) { return Directory.EnumerateFiles(rootDir, *.*, SearchOption.AllDirectories) .Where(f _exts.Contains(Path.GetExtension(f))) .ToList(); } }这里有个容易忽略的天坑如果输出目录和源目录指向同一个文件夹扫描会把程序自己刚生成的压缩文件也收进队列越压越多永远跑不完。要么强制输出到独立目录要么扫描时显式排除输出目录。这个判断必须在入口就做掉别指望用户在界面上记得住。3. 压缩引擎与质量参数GDI的80和85能差出近三倍体积3.1 编码器获取与质量参数的真实影响System.Drawing核心是GDI压缩JPEG要先拿ImageCodecInfo再用EncoderParameters喂质量值。质量参数不是线性的80到85通常只多占5%体积但60掉到55色块和噪点肉眼可见90以上再往上提体积涨得明显画质收益却几乎为零。所以85是通用起点照片类素材甚至75就够。public void CompressOne(string sourcePath, CompressOptions options) { using (var image Image.FromFile(sourcePath)) { // 先处理EXIF旋转否则压缩完照片可能横过来 ApplyOrientation(image); // 按最长边等比缩放小于目标尺寸的不放大 var ratio Math.Min( options.MaxSide / (double)image.Width, options.MaxSide / (double)image.Height); ratio Math.Min(ratio, 1.0); int newWidth Math.Max(1, (int)(image.Width * ratio)); int newHeight Math.Max(1, (int)(image.Height * ratio)); using (var bitmap new Bitmap(newWidth, newHeight)) using (var g Graphics.FromImage(bitmap)) { g.InterpolationMode InterpolationMode.HighQualityBicubic; g.SmoothingMode SmoothingMode.HighQuality; g.PixelOffsetMode PixelOffsetMode.HighQuality; g.Clear(Color.White); // 透明图先垫白底避免存JPG变黑 g.DrawImage(image, 0, 0, newWidth, newHeight); var codec GetCodec(ImageFormat.Jpeg); var eps new EncoderParameters(1); eps.Param[0] new EncoderParameter(Encoder.Quality, options.Quality); bitmap.Save(CreateOutputPath(sourcePath, options), codec, eps); } } }四个关键参数说明InterpolationMode.HighQualityBicubic是照片缩放质量与速度的平衡点NearestNeighbor快但锯齿明显SmoothingMode和PixelOffsetMode影响缩放边缘的平滑度照片素材建议都开HighQualityClear(Color.White)处理的是PNG透明通道后面避坑章节细说CreateOutputPath负责生成输出路径不覆盖原图。GetCodec的写法是遍历ImageCodecInfo.GetImageEncoders()按FormatId匹配JPEG、PNG、BMP的Guid。这个查表开销不大但几百张图每张都查一遍属于浪费缓存到静态字段里一次性查完就行。private static ImageCodecInfo _jpegCodec; private static ImageCodecInfo GetCodec(ImageFormat format) { if (format ImageFormat.Jpeg _jpegCodec ! null) return _jpegCodec; foreach (var codec in ImageCodecInfo.GetImageEncoders()) { if (codec.FormatID format.Guid) { if (format ImageFormat.Jpeg) _jpegCodec codec; return codec; } } throw new NotSupportedException($未找到 {format} 编码器); }3.2 格式转换决策与输出文件命名策略工具支持JPG转JPG、PNG转JPG、BMP转JPG/PNG三条常见路线。默认输出命名是「原名_compressed.新扩展名」覆盖原图是危险操作——万一压缩参数设得太狠原图就没了。更稳妥的做法是输出到独立目录或者强制加后缀。原格式目标格式体积效果注意点JPGJPG降50%-80%质量低于60会出现色块PNGJPG降幅最大透明区域垫白底需确认可接受BMPPNG无损体积可控适合界面截图类素材BMPJPG降幅最狠文字边缘可能有锯齿文件名冲突也必须处理目标目录已有同名文件时默认追加(1)、(2)或者按「时间戳_序号」生成。直接用File.Exists判断然后覆盖会把之前压好的成果冲掉而且如果上一个批次还没跑完就重复执行状态文件会先把旧文件标记成已完成新文件根本不会生成。还有个替换策略值得提有时候缩小尺寸比降低质量更划算。一张4000像素宽的照片缩到1920宽体积可能直接从2MB掉到400KB画质损失比把质量从90降到70小得多。所以工具的MaxSide参数和Quality参数是配合使用的只调一个往往会失望。4. 批量任务的进度管理与中断恢复压到一半断电也不用从头再来4.1 进度上报的节流每张图都Report会让UI卡成PPT一千张图每张都Report一次UI线程会被消息塞满进度条表现成一卡一卡往前跳。常见做法是节流时间间隔超过50毫秒或者已经处理完最后一张才真正上报一次。private long _lastReportMs; private int _completed; private void ReportThrottled(Progressint progress, int total) { var now Environment.TickCount64; if (now - _lastReportMs 50 _completed total) return; _lastReportMs now; progress.Report(_completed); }这个节流逻辑放在Compressor内部UI层只管接收值更新进度条。Environment.TickCount64是毫秒级单调时钟比DateTime.Now省事也不受系统时间跳变影响。50毫秒间隔对一千张图最多产生几十次UI回调完全在UI线程的承受范围内。还有一种更激进的策略只在处理完十张或者一百张时上报一次。代价是进度条跳动粒度变大而且是文件处理时间不均匀的批量任务用户看着会怀疑卡死。按时间节流比按数量节流体验更平滑推荐用时间节流。4.2 断点续压用状态文件跳过已处理的文件批量压三百张大图压到一半程序崩了或者用户点了取消再跑一遍全量重来非常浪费。断点续压实现不复杂每成功处理一张就把完整路径写进状态文件下次启动时加载集合处理前判断跳过。private HashSetstring LoadState(string stateFile) { if (!File.Exists(stateFile)) return new HashSetstring(); return File.ReadAllLines(stateFile).ToHashSet(); } private void SaveState(string stateFile, HashSetstring doneSet) { File.WriteAllLines(stateFile, doneSet); } public void ProcessAll(Liststring files, CompressOptions options, Progressint progress, CancellationToken token) { var stateFile Path.Combine(options.OutputDir, .compress_state.txt); var doneSet LoadState(stateFile); var failed new Liststring(); foreach (var file in files) { token.ThrowIfCancellationRequested(); if (doneSet.Contains(file)) { _completed; continue; } try { CompressOne(file, options); doneSet.Add(file); } catch (Exception ex) { failed.Add(${file}\t{ex.Message}); } _completed; ReportThrottled(progress, files.Count); SaveState(stateFile, doneSet); } if (failed.Count 0) File.WriteAllLines(Path.Combine(options.OutputDir, .compress_failed.txt), failed); }每张都写状态文件看着笨但对几百张图的场景多几次小文件IO完全可以接受换来的是崩溃后最多损失一张。失败列表单独落盘批次结束后能清楚看到哪些文件挂掉了、挂在哪一步。这是典型的「日志即状态」设计调试时比在控制台翻历史舒服得多。状态文件的一个副作用要记住如果改了质量、最长边或者输出格式旧的状态文件必须清掉或者换文件名否则用旧参数压过的文件会被跳过新参数等于没生效。我一般把参数哈希写进状态文件名比如.compress_state_q85_m1920.txt参数一变文件名就变天然隔离不同配置的批次。取消和断点的配合也要想清楚CompressOne内部正在执行的GDI操作无法被Token立即打断Parallel.ForEach只会在当前文件处理完后检查取消标记。所以点取消后当前这张图会压完才退出界面提示「已取消」但后台可能还忙一两秒这属于正常现象不是程序没响应。5. 实战避坑图片压缩工具最常见的六个翻车点5.1 PNG透明底压成JPG后变成黑底现象带透明通道的PNG转成JPG透明区域变成纯黑色抠图素材直接没法用。原因JPEG不支持Alpha通道GDI用透明黑0,0,0,0填充被丢弃的通道解码后显示为黑色而不是白色。解决DrawImage之前先Graphics.Clear(Color.White)垫白底或者给选项加一个背景色配置。白色是通用默认电商图有时想要灰底、蓝底把颜色做成参数传进来就行。using (var g Graphics.FromImage(bitmap)) { g.Clear(Color.White); // 透明区域先垫底 g.DrawImage(image, 0, 0, newWidth, newHeight); }5.2 相机照片压缩后旋转了90度现象手机或相机拍的JPG压缩后竖图变横图或者反过来看着特别奇怪。原因EXIF Orientation标签记录拍摄时设备的方向但很多传感器是固定朝向旋转信息只存在元数据里。GDI的DrawImage按原始像素直接绘制不会自动应用Orientation标签。解决读取PropertyItem(0x0112)拿到方向值用RotateFlip把像素真正转过来再进压缩流程。private static void ApplyOrientation(Image image) { if (!image.PropertyIdList.Contains(0x0112)) return; var prop image.GetPropertyItem(0x0112); var orientation BitConverter.ToUInt16(prop.Value, 0); switch (orientation) { case 6: image.RotateFlip(RotateFlipType.Rotate90FlipNone); break; case 3: image.RotateFlip(RotateFlipType.Rotate180FlipNone); break; case 8: image.RotateFlip(RotateFlipType.Rotate270FlipNone); break; } }注意RotateFlip是原地修改对象调用后原像素方向已经变了不能再重复调用否则二次处理时图片会再次旋转。状态文件只记录路径不记录处理状态正好规避了这个问题——同一张图不会跑两遍。5.3 大图直接OutOfMemoryException现象处理100MB以上的TIFF或者边长8000像素的全景图程序直接崩溃内存占用飙到1GB多。原因GDI解码会把整张图加载进内存8000×6000×4字节接近192MB再叠加缩放目标Bitmap内存瞬间翻倍。并行度再开高一点直接触发系统内存压力。解决处理前先看源文件字节数超过阈值比如50MB直接跳过并记日志同时CompressOne外面套try/catch单张失败不影响批次。var srcInfo new FileInfo(sourcePath); if (srcInfo.Length options.MaxInputBytes) { _failed.Add(${sourcePath}\t超过大小阈值跳过); return; }5.4 压完的文件比原图还大现象质量设85压一张本来就是质量90以上压缩出来的小尺寸JPG输出体积反而涨了10%。原因JPEG再次编码必然有损如果原图已经是高压缩率文件二次编码会保留原图的噪点颗粒体积不降反升。解决压缩后对比新旧文件大小新的更大就保留原文件。这个判断放在Save之后、记录状态之前。var dstInfo new FileInfo(outputPath); if (dstInfo.Length srcInfo.Length) File.Copy(sourcePath, outputPath, true);还有一种更省事的策略文件本身小于某个阈值比如100KB直接跳过不压批量工具跑起来更快状态文件里也能看出哪些文件是主动跳过的。5.5 中文路径下Image.Save报GDI错误现象文件夹名带中文或特殊字符时Image.Save(string)偶尔抛「GDI中发生一般性错误」但换英文路径就好。原因GDI的Save(string)重载在底层走的是ANSI版本API遇到多字节路径在部分环境组合下不稳定。这是System.Drawing的经典玄学问题之一。解决改成Save到FileStream路径由托管代码处理绕开GDI的字符串转换层。using (var fs new FileStream(outputPath, FileMode.Create, FileAccess.Write)) { bitmap.Save(fs, codec, eps); }这个改法同时也解决了另一个隐患FileStream由using托管即使Save中途抛异常文件句柄也会被释放不会留下来锁住文件导致后续覆盖失败。5.6 点取消后线程还在后台跑现象界面显示「已取消」但任务管理器里CPU占用还是接近满核过了一两分钟才消停。原因CompressOne内部正在执行的GDI操作无法被Token立即打断Parallel.ForEach只会在当前文件处理完后检查取消标记然后抛OperationCanceledException。解决取消只是协作信号真正退出发生在当前文件处理完毕后。要更及时的响应可以在CompressOne内部循环里检查token。另外取消后要Dispose掉CancellationTokenSource不然会有句柄泄漏。public void Cancel() { _cts?.Cancel(); // 等待当前批次的Task结束后再置空避免竞态 _cts?.Dispose(); _cts null; }6. 进阶用法把压缩内核抽成命令行模式让批量任务定时跑界面只是壳真正值钱的是Compressor内核。把入口拆成两种模式带--batch参数走命令行否则启动窗体。配合系统自带的任务计划程序就能实现每天凌晨自动压缩某个上传目录早上到岗素材已经压好等着用。static void Main(string[] args) { if (args.Length 0 args[0] --batch) { var options CompressOptions.FromJson(compress.json); var files ImageScanner.Scan(options.SourceDir); new Compressor().ProcessAll(files, options, null, CancellationToken.None); } else { Application.Run(new MainForm()); } }CompressOptions加一个FromJson静态方法用System.Text.Json反序列化配置文件命令行模式和界面模式共用同一份参数。参数文件比写死在代码里好维护得多换目录、调质量、加排除规则只改JSON不动代码。再把状态文件路径、失败日志路径也放进配置定时任务跑完早上打开日志就能看到哪些文件失败了、失败原因是什么。把这个模式往前推一步命令行返回值还可以按退出码区分结果0表示全部成功1表示部分失败2表示取消。任务计划程序就能根据退出码决定要不要发通知整个流程从「手动工具」升级成「无人值守流水线」。从那以后我每次写这类工具都强制走一遍同样的动作先确认参数可配置而不是写死在代码里再确认打断和断点机制存在最后才碰界面。这三件事做了工具才能从「自己用」变成「放心交给别人长期用」。希望帮到你。本文还有配套的精品资源点击获取