简介:一款基于C# WinForm开发的批量图片压缩工具,支持将图片精确压缩到指定大小(KB),并提供完整源码与可直接运行的exe文件。资源包共2000个文件,约62.65MB,主要包含cs工程源码、dll依赖库、xml配置文件以及可视化界面相关资源,其中exe文件免安装双击即可使用;对于需要处理大量图片且对存储空间有严格要求的Windows用户,可快速上手完成批量压缩,而C#开发者则可直接查阅源码,了解图像压缩算法与WinForm界面设计的具体实现。该压缩方案在控制文件体积的同时尽可能保留画质,批量处理能力显著提升工作效率;源码允许自由修改与扩展,便于定制压缩策略或集成到现有项目,也可作为学习C#桌面应用开发的参考资料。目前已有237人学习下载,适合日常图片管理、网站素材优化、存储空间规整及WinForm工具开发实践等场景。
1. 图片压缩到指定大小,为什么 WinForm 反而是最顺手的方案
做桌面工具这件事,很多人第一反应是 Python 或者 Electron,但真落到「双击就能用、发给同事不装环境、处理几百张图不带卡」这三个诉求上,C# WinForm 几乎是性价比最高的选择。它不需要打包运行时,.NET Framework 在 Windows 上天然存在,用 Visual Studio 发布一个 Release 版 exe,目标机器只要不是精简到极致的 LTSC,基本都能直接跑。再加上 System.Drawing 命名空间里现成的 Image、Bitmap、Encoder 和 ImageCodecInfo,读写 JPEG、PNG、BMP 的代码量比 Python 调 Pillow 还要少,而且内存管理是托管机制,批量处理时只要注意 Dispose,就不会出现 Python 那种长时间运行后内存只涨不降的问题。
这个项目标题里的核心诉求很直接:把图片压到指定大小,比如 200KB、500KB,而且是「批量」。批量意味着要处理文件遍历、并发或串行的取舍、UI 刷新不卡顿;指定大小意味着不能靠肉眼调质量参数,而是要通过二分法或增量法反复编码,直到输出文件体积逼近目标值。市面上现成的压缩工具不少,但要么是命令行,要么是网页上传(有隐私风险),要么是收费软件弹广告。自己用 WinForm 写一个,源码可控,exe 可以直接分发,还能顺带把拖拽、进度条、压缩率统计这些体验做到位。
本文按我实际写这类工具的顺序展开:先讲压缩的核心原理和裁剪逻辑,再给完整的批量处理代码,然后是界面与耗时优化的关键点,最后落在几个容易踩的坑和验证方法上。如果你正在写类似的图片处理工具,或者想给内部团队做一个免安装的小工具,这篇文章可以直接照着改。
2. 指定大小压缩的原理:编码参数、缩放与二分逼近
2.1 JPEG 质量因子为什么不能一次定死
图片文件体积由三个因素决定:像素尺寸、编码格式、编码质量参数。PNG 是无损压缩,体积跟图像内容的复杂度强相关,很难通过参数精确控制输出大小;JPEG 是有损压缩,可以通过 Quality 参数(0 到 100)调整压缩强度,但同样的 Quality 在不同图片上产出的体积差异极大。一张纯色截图 Quality=80 可能只有 30KB,一张噪点很多的照片 Quality=80 可能直接到 500KB。所以「指定大小」这件事,本质上是一个逆向求解问题:找到合适的 Quality,使得编码后的字节数落进目标区间。
常见的做法是二分查找。假设目标大小是 200KB,初始 Quality 取 50,编码后得到文件大小。如果偏大,就把 Quality 下调一半;如果偏小,就上调。每轮迭代把搜索区间缩小一半,通常在 8 到 10 轮内收敛到 ±5% 的误差。这个收敛速度足够快,因为 JPEG Quality 和文件大小并不呈简单线性关系,但单调性是可以保证的:Quality 越高,体积越大(编解码器的具体实现可能有细微差异,但大方向不反)。
2.2 System.Drawing 里压缩一张图片的完整链路
在 .NET 里压缩 JPEG 的标准做法是:读取原图得到 Bitmap,创建 EncoderParameters 设置质量参数,然后通过 Image.Save 方法,指定 JPEG 的 ImageCodecInfo 和 EncoderParameter 保存到内存流或文件流。关键点在于,EncoderParameter 的 Value 是个 int 数组,Disposition 要设置成 EncoderParameterValueType.ValueTypeAsLong,否则在某些 .NET Framework 版本上不生效。
先把最小可用的单张压缩函数写出来:
using System; using System.Drawing; using System.Drawing.Imaging; using System.IO; public static class ImageCompressor { // 获取 JPEG 编码器(系统内置,不需要额外引用) private static ImageCodecInfo GetJpegCodec() { var codecs = ImageCodecInfo.GetImageEncoders(); foreach (var codec in codecs) { if (codec.FormatID == ImageFormat.Jpeg.Guid) return codec; } return null; } // 按质量因子压缩图片到指定大小(KB),返回压缩后的图片对象 public static Image CompressToSize(Image source, long targetKB, int maxAttempts = 12) { int minQuality = 1; int maxQuality = 100; int quality = 70; // 初始猜测值 byte[] resultBytes = null; for (int i = 0; i < maxAttempts; i++) { using (var ms = new MemoryStream()) { var encoderParams = new EncoderParameters(1); encoderParams.Param[0] = new EncoderParameter( System.Drawing.Imaging.Encoder.Quality, quality); source.Save(ms, GetJpegCodec(), encoderParams); byte[] bytes = ms.ToArray(); long sizeKB = bytes.Length / 1024; if (Math.Abs(sizeKB - targetKB) <= targetKB * 0.02) { resultBytes = bytes; break; } if (sizeKB > targetKB) { maxQuality = quality - 1; } else { minQuality = quality + 1; } if (maxQuality < minQuality) { // 防止区间越界,取最后一次较优的结果 resultBytes = bytes; break; } quality = (minQuality + maxQuality) / 2; } } if (resultBytes == null) return null; using (var ms = new MemoryStream(resultBytes)) { return Image.FromStream(ms); } } }这段代码的逻辑是:先猜一个质量 70,编码后和目标差在 2% 以内就直接返回;否则根据体积大小收缩质量区间,再折半继续。22 * 1024是为了把目标 KB 转成字节做比较。MemoryStream的作用是避免反复写临时文件,直接拿二进制数组判断大小,这样在批量场景下能显著减少磁盘 IO。
这个版本有个明显的坑:Image.FromStream(ms)返回的 Image 对象,其底层流必须保持打开状态,否则后续保存会抛ArgumentException。上面代码把ms包在 using 里,但返回新 Image 后,原来的 ms 已经被释放了,所以这种做法其实有隐患,正确的做法是把字节数组整体保留,需要保存时直接File.WriteAllBytes,或者把流留在外部管理。
2.3 缩放到合适分辨率再压,效果完全不同
很多场景下,用户要的「小于 200KB」不是硬压质量,而是因为图片本身是 4000x3000 的原始照片,就算 Quality=1 也可能超过 200KB。这时候压缩质量因子已经失去了意义,必须先缩小分辨率。这就是标题里「压缩软件」和「指定大小」之间容易被忽略的一个中间步骤:分辨率裁剪。
常见做法是设定一个最大边长,比如 1920 或 2560,先把图片等比缩放,再进入二分质量循环。因为分辨率降低后,像素总量减少,同样的质量因子下体积会大幅下降。而且对于大多数使用场景(网页上传、聊天发送、工单附件),1920px 的边长完全够用。缩放插值方式里,HighQualityBicubic是视觉效果和性能的均衡点,HighQuality和Bicubic的区别在高倍缩小时比较明显,批量处理建议直接用HighQualityBicubic。
public static Bitmap ResizeToMaxEdge(Image source, int maxEdge) { int newWidth, newHeight; if (source.Width > source.Height) { newWidth = maxEdge; newHeight = (int)(source.Height * (double)maxEdge / source.Width); } else { newHeight = maxEdge; newWidth = (int)(source.Width * (double)maxEdge / source.Height); } var bmp = new Bitmap(newWidth, newHeight); using (var g = Graphics.FromImage(bmp)) { g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic; g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.HighQuality; g.PixelOffsetMode = System.Drawing.Drawing2D.PixelOffsetMode.HighQuality; g.DrawImage(source, 0, 0, newWidth, newHeight); } return bmp; }Graphics.DrawImage是缩放的实际执行者,InterpolationMode决定重采样算法,HighQualityBicubic适合缩小,而放大场景更适合NearestNeighbor(但会糊,所以放大不是这个工具的目标)。注意返回的 Bitmap 必须由调用方负责 Dispose,如果直接把原图 Dispose 了,这个新 Bitmap 依然可以独立使用。
3. 批量压缩核心实现:文件遍历、任务队列与进度报告
3.1 拖拽文件夹还是指定目录?两种输入方式都做
批量压缩软件的第一体验是输入。我一般会同时支持两种:点击按钮打开FolderBrowserDialog选择目录,以及把文件或文件夹拖到窗口上自动解析。拖拽的代码很简单,只要在窗体的AllowDrop = true之后处理DragEnter和DragDrop事件。拖入内容可能是文件也可能是文件夹,统一收集到 List 里,文件夹用递归遍历收集图片扩展名。
private void FormMain_DragEnter(object sender, DragEventArgs e) { if (e.Data.GetDataPresent(DataFormats.FileDrop)) { e.Effect = DragDropEffects.Copy; } } private void FormMain_DragDrop(object sender, DragEventArgs e) { var paths = (string[])e.Data.GetData(DataFormats.FileDrop); _fileList.Clear(); foreach (var path in paths) { if (File.Exists(path)) { if (IsSupportedImage(path)) _fileList.Add(path); } else if (Directory.Exists(path)) { GetImagesFromDir(path, _fileList); } } UpdateListView(); } private bool IsSupportedImage(string path) { var ext = Path.GetExtension(path).ToLowerInvariant(); return ext == ".jpg" || ext == ".jpeg" || ext == ".png" || ext == ".bmp"; } private void GetImagesFromDir(string dir, List<string> result) { // 常见做法:搜当前目录所有图片,可选是否递归子目录 foreach (var ext in new[] { "*.jpg", "*.jpeg", "*.png", "*.bmp" }) { result.AddRange(Directory.GetFiles(dir, ext)); } }Directory.GetFiles有重载可以传入SearchOption.AllDirectories,但如果目录层级很深且文件数量大,用一次通配符枚举反而慢,因为每次枚举都要访问文件系统。更好的方式是按扩展名多次调用GetFiles,然后合并结果,这样能利用操作系统的文件索引。注意IsSupportedImage只认扩展名,如果用户有名字是.jpg但实际内容不是图片的文件,会在后续编码时抛异常,所以压文件前最好做一次裸格式探测。
3.2 BackgroundWorker 还是 Task.Run?批量任务的线程选择
WinForm 里做批量处理,最忌讳的就是在 UI 线程里循环压缩图片,那样窗口会进入「未响应」状态。老项目常见做法是BackgroundWorker,它的优点是事件机制自带线程亲和性,ReportProgress可以直接更新 ProgressBar,不需要额外写Invoke。但新代码我更倾向于Task.Run+IProgress<T>,因为IProgress<T>的内部实现就是 SynchronizationContext,用起来更简洁,而且便于将来扩展成异步等待。
下面是批量任务的核心框架:
private async void BtnBatchCompress_Click(object sender, EventArgs e) { if (_fileList.Count == 0) return; btnBatch.Enabled = false; progressBar.Maximum = _fileList.Count; progressBar.Value = 0; var progress = new Progress<CompressProgress>(p => { progressBar.Value = p.Completed; lblStatus.Text = $"正在处理 {p.CurrentFile}({p.Completed}/{_fileList.Count})"; }); await Task.Run(() => { Parallel.ForEach(_fileList, new ParallelOptions { MaxDegreeOfParallelism = Environment.ProcessorCount / 2 }, file => { try { CompressSingleFile(file, _targetKB); } catch (Exception ex) { // 记录失败信息,不中断整体任务 } finally { ((IProgress<CompressProgress>)progress).Report( new CompressProgress { Completed = Interlocked.Increment(ref _completedCount), CurrentFile = file }); } }); }); btnBatch.Enabled = true; MessageBox.Show("批量压缩完成"); }Parallel.ForEach能让多核 CPU 同时处理多张图片,但要注意两点:一是 JPEG 编码本身是 CPU 密集操作,线程数不建议超过物理核心数,否则线程上下文切换反而变慢;二是System.Drawing的Bitmap对象不是线程安全的,不同线程处理不同文件没有问题,但共享同一个Image对象或在多个线程里同时调用 GDI+ 的相同静态方法,有过偶发的崩溃报告。所以每个 Task 里只操作自己加载的 Bitmap,输出路径按规则生成,避免写冲突。
Interlocked.Increment是必要的,因为 lambda 表达式里多个线程同时读改写_completedCount会产生竞态,普通++不是原子的。Progress<T>的回调默认会让 UI 在同步上下文里执行,所以直接在 lambda 里赋值给 ProgressBar 是安全的。
3.3 输出文件命名策略:不覆盖原图是底线
批量压缩最危险的操作是覆盖原文件。如果用户压完发现效果不满意,原图已经没了,那就只能从回收站找。常见做法是输出到原目录下的compressed子目录,或者加上_compressed后缀。如果用户明确勾选了「覆盖原文件」,也要先写到临时文件再替换,避免压缩过程中进程崩溃导致原文件残缺。
private void CompressSingleFile(string inputFile, long targetKB) { string ext = Path.GetExtension(inputFile); string outputDir = Path.Combine(Path.GetDirectoryName(inputFile), "compressed"); Directory.CreateDirectory(outputDir); string fileName = Path.GetFileNameWithoutExtension(inputFile); string outputFile = Path.Combine(outputDir, fileName + "_" + targetKB + "kb" + ext); using (var src = Image.FromFile(inputFile)) { int maxEdge = _maxEdge; // 从设置界面读取,比如 1920 using (var resized = ResizeToMaxEdge(src, maxEdge)) { using (var compressed = CompressToSize(resized, targetKB)) { if (compressed != null) { compressed.Save(outputFile, ImageFormat.Jpeg); } } } } }这里有个细节:如果原图是 PNG 且带透明通道,压缩成 JPEG 后透明区域会变成黑色。这是 JPEG 格式本身不支持透明导致的。如果要保留透明,只能存 PNG,但 PNG 的体积控制又很困难。所以这个工具的目标格式统一是 JPEG,用户如果拖入 PNG,底层处理的透明信息会被丢弃,应该在 UI 上明确提示这一点。或者输出格式跟随原格式,PNG 用 Quantization 压缩,但这属于高级功能,不是第一版该做的。
4. UI 卡顿、内存泄漏与进度显示:WinForm 批量工具的 3 个必调参数
4.1 设置界面:目标大小、最大边长、质量下限三个参数缺一不可
界面不需要花哨,但参数要设置合理。我一般会在界面上放三个输入控件:目标大小(KB)、最大边长(px)、最小质量(1-100)。第三个参数是真正决定压缩失败与否的关键。
当分辨率已经缩小到最大边长,质量也降到了最小质量,但文件体积仍然大于目标值时,工具不能无限继续降质,否则会得到一张满是马赛克的废图。这时候应该终止该文件的压缩,把原始文件保留,并在结果列表里标记为「无法达到指定大小」。合理的默认值是最小质量 30,低于 30 的 JPEG 图片已经开始出现明显的色块和振铃效应,除非用户对体积有硬性要求,否则不建议再低。
// 参数表:合理的默认值与调整范围 // 目标大小:100KB ~ 2048KB,默认 200KB // 最大边长:1280 ~ 4096,默认 1920 // 最小质量:1 ~ 80,默认 30 // 最大尝试次数:8 ~ 16,默认 12最大尝试次数的意义在于:如果目标大小是 200KB,但图片是 8000x6000 的高清图,即使质量降到 1 也可能输出 300KB,此时二分法最多只能把质量降到区间下限,再多试几次也没有意义,反而浪费 CPU。所以循环里的终止条件除了体积达标,还要判断质量区间是否已经坍塌。
4.2 ProgressBar 更新频率太高会导致 UI 刷新卡顿
批量处理几百张图片时,如果每处理一张就更新一次 ProgressBar,UI 线程会被大量消息淹没。在低配机器上,ProgressBar.Value的每次赋值都会触发重绘,频繁赋值会导致界面闪烁甚至卡顿。常见做法是节流:只有当完成数量比上次记录多出 1% 或 5 张时,才更新界面。
private int _lastReportedCount = 0; private void ReportProgress(int completed, int total) { int threshold = Math.Max(total / 100, 1); // 每 1% 更新一次 if (completed - _lastReportedCount >= threshold || completed == total) { progressBar.Value = completed; lblStatus.Text = $"已完成 {completed}/{total}"; _lastReportedCount = completed; } }这个省略掉中间态的简单判断,实际效果比想象中好。对于 1000 张图,只更新 100 次 UI,几乎不会产生可见的卡顿。另外 ProgressBar 的Style建议设为Continuous,不要用Marquee,因为 Marquee 是无限滚动样式,不适用于有明确总量的任务。
4.3 System.Drawing 的 Dispose 陷阱:为什么压缩几十张后内存会暴涨
System.Drawing 是 GDI+ 的托管封装,Bitmap、Graphics、Image都持有非托管资源,不调用 Dispose 就不会被 GC 立刻回收。在批量循环里,如果每张图都 new Bitmap 而忘了 Dispose,内存占用会持续增长,最终抛OutOfMemoryException。这个异常在 GDI+ 里很常见,而且常常发生在内存还有余量的情况下,因为 GDI+ 的可用虚拟内存碎片化了。
规范做法是两个层面:一是 using 包住所有 IDisposable 对象,二是当文件数量特别大时,主动调用GC.Collect()和GC.WaitForPendingFinalizers()。虽然主动调 GC 通常被认为不是好实践,但在这个场景下是有效手段,因为在单次压缩循环里,临时大对象(Byte[]) 很容易进入第 2 代堆,不及时回收就会等满 60 秒才触发一次。比较折中的做法是每处理完 20 张图调用一次。
if (count % 20 == 0) { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); }另外,在压缩前先用Bitmap.GetPixel读取一两个点,可以做格式探测,也能让 GDI+ 提前抛出格式不支持的错误,而不是在编码阶段报InvalidOperationException。这样做的好处是批量处理时的异常更容易定位,不至于一句「图片出错」让用户猜。
5. 双击 exe 直接可用:发布配置 3 项必改
5.1 目标平台选 x64 还是 AnyCPU
在 Visual Studio 里发布 WinForm 项目时,默认的 Platform Target 是 AnyCPU。但对于图像处理工具,我建议强制设为 x64。原因很简单:x64 进程拥有更大的虚拟地址空间,GDI+ 在 32 位进程里更容易碰到地址空间碎片化的问题,尤其是批量处理大尺寸图片时,一个 Bitmap 可能占数十 MB,内存频繁分配释放后碎片化会显著。x64 下这个概率低非常多。如果有某些旧机器仍是 32 位系统,再考虑 AnyCPU 或单独编译一个 x86 版本。
具体操作:项目属性 -> 生成 -> 平台目标改为 x64。注意同时把「首选 32 位」选项的勾去掉,这个选项在 .NET Framework 项目里默认会勾上,导致 AnyCPU 模式在 64 位系统上跑 32 位进程。
5.2 自包含发布还是 Framework 依赖
.NET Framework 4.8 是 Windows 10/11 自带的,所以如果你的目标是互联网用户,那就用「Framework 依赖」发布,exe 只有几百 KB,双击运行即可。如果你喜欢用.NET 6/8写 WinForm,那就得用dotnet publish -c Release -r win-x64 --self-contained true生成自包含版本,但 exe 体积会到 60MB 以上。标题里说「exe导出文件双击即可使用」,常见做法是.NET Framework版本发布加上PublishTrimmed(仅对 Core 有效)。
如果项目源码使用的是 .NET Framework 4.7.2,那么发布步骤是:右键项目 -> 发布 -> 选择文件夹 -> 配置文件里设置「部署模式:框架依赖」-> 发布。生成后的 exe 还依赖同目录下的 .exe.config 文件,不能只拷 exe 单文件。如果想真正打包成单文件,需要借助 ILMerge 或 Costura.Fody,但这类工具的兼容性坑不少,一般内部使用直接压缩整个发布目录发给对方即可。
5.3 配置文件的用户设置:如何记住上次的参数
ApplicationSettings是 WinForm 内置的设置持久化机制。在项目属性 -> 设置里可以定义TargetKB、MaxEdge、MinQuality等字段,界面加载时读取,关闭时保存。这样用户第二次打开软件时不用重新填参数。
private void FormMain_Load(object sender, EventArgs e) { txtTargetKB.Text = Settings.Default.TargetKB.ToString(); txtMaxEdge.Text = Settings.Default.MaxEdge.ToString(); txtMinQuality.Text = Settings.Default.MinQuality.ToString(); } private void FormMain_FormClosing(object sender, FormClosingEventArgs e) { Settings.Default.TargetKB = int.Parse(txtTargetKB.Text); Settings.Default.MaxEdge = int.Parse(txtMaxEdge.Text); Settings.Default.MinQuality = int.Parse(txtMinQuality.Text); Settings.Default.Save(); }Settings.Default.Save()会把这些值写进用户目录下的 user.config 文件里,如果后续想增加「最近使用的目录」或「输出格式」等记忆功能,用这个机制可以零成本扩展。注意int.Parse在没有校验时可能抛异常,如果输入框里被填入非数字字符,整个窗体就关闭不了,需要在解析前做int.TryParse。我会习惯在Validating事件里做输入校验,并设置ErrorProvider提示,而不是等关闭时才拦截。
6. 验证压缩效果:一个校验脚本与三类边界图
工具做完不是运行一下没报错就完了,图片压缩这种功能,必须用一组边界图片做验证。
第一类是纯色大图,比如 4000x3000 的白色背景图,这种图 JPEG 编码后体积极小,用它验证工具能否正确处理「目标体积大于原始体积」的情况。此时不应该强行把图片放大到目标大小,而是直接输出原始质量不变,否则就是画蛇添足。
第二类是高清噪声照片,比如夜景、星空纹理的 JPEG,这种图片压缩率低,很容易触发质量下限。验证目标是:当质量降到最小阈值后仍然超过目标大小时,工具是给出提示,还是悄悄输出超标的文件。推荐处理方式是输出该文件但不覆盖原图,并在日志里指出「未达标」。
第三类是带透明通道的 PNG 和超大分辨率 BMP。BMP 是未压缩格式,一个 5000x3000 的 BMP 可能有 43MB,压缩成 JPEG 之后能压到几百 KB,这是最容易让用户产生惊喜的场景,但也在测试时最容易触发 x64 内存问题。
验证大小的脚本可以用 PowerShell 一行命令完成,遍历输出目录下所有文件并列出超过目标值 5% 的文件:
$targetKB = 200 $dir = "C:\output" Get-ChildItem $dir -Recurse -Include *.jpg | Where-Object { $_.Length -gt ($targetKB * 1024 * 1.05) } | Select-Object FullName, @{Name="SizeKB";Expression={[math]::Round($_.Length/1024,1)}}这个命令不做任何压缩,只做验证。-Include *.jpg配合-Recurse需要注意,它必须与Get-ChildItem的路径参数一起用,如果直接Get-ChildItem $dir -Recurse -Filter *.jpg会更快一点。两种写法在文件量几千时差别不大,但-Filter在文件系统层就过滤掉了,内存占用更小。
验证通过后,再确认原目录结构:compressed子目录和原图分开存放,文件名带上了目标大小标识。这个命名习惯在批量处理时非常好用,用户一眼就能看出这张图是为哪个目标压缩的。假如压完发现目标大小调高了,直接重新压一遍,文件名会自动带新的大小后缀,不会覆盖之前的结果。工具的完成度,往往就体现在这种细节上。
本文还有配套的精品资源,点击获取