☰
WinForm图层绘图系统:双缓冲+Alpha橡皮擦+多格式保存
2026/9/28 6:07:26 网站建设 项目流程

简介:这是一份面向C#初学者与WinForm桌面开发爱好者的轻量级绘图工具源码包,解决基础图形绘制、图层管理与交互式编辑的学习实践需求。程序支持新建/显示/隐藏图层,绘制线条、矩形、圆形,自由选择颜色,配备可调尺寸橡皮擦,并实现绘图内容本地保存,适用于UI交互设计入门、图形算法可视化或课程设计项目参考。资源共46个文件,含11个核心.cs源码(如Form1.cs、Form2.cs等主窗体与逻辑)、4个.resx本地化资源、4个.resources二进制资源、2个.exe可执行文件及1个.sln解决方案文件,辅以配置、缓存与说明类文件,整体压缩包仅529KB,结构清晰、依赖精简,便于快速编译运行与代码逐行研读。目前已有45人学习下载,读者可直接获取完整可运行工程、理解图层抽象实现机制、掌握GDI+绘图核心逻辑,并复用其颜色管理、撤销擦除与序列化保存等模块代码。

1. 这不是“画个线就完事”的 WinForm 小玩具:一个真能分图层、带橡皮擦、可保存的 C# 绘图程序,专治 WinForm 图形开发玄学

你有没有试过在 WinForm 里用Graphics画几条线,结果一窗体重绘就全没了?或者想加个橡皮擦,发现Clear()会清空整个画布,根本没法只擦局部?更别提图层——很多人以为 WinForm 做不了图层管理,只能靠Panel堆叠模拟,结果鼠标坐标换算错乱、图层遮挡逻辑崩盘、保存时只存了最后一层……这根本不是“功能简陋”,而是没吃透 GDI+ 的双缓冲、位图缓存和图层隔离机制。这个Painter.zip项目,就是一套跑通了的、可直接复现的 WinForm 图层绘图落地方案:它用独立Bitmap为每层建画布,用Graphics.FromImage()实现图层级绘制,橡皮擦本质是“用透明色在当前层位图上重绘”,保存时按需导出 PNG(带 Alpha)或合成 BMP。它不依赖第三方库,纯 .NET Framework 4.7.2 可编译,适合做工业上位机界面草图标注、教学演示板、轻量 CAD 辅助工具——如果你正卡在 WinForm 图形开发的“擦不干净、存不下来、改不了某一层”这三座大山前,这份源码就是你的后悔药。


2. 图层系统怎么从零搭起来:位图缓存 + 状态栈 + 图层可见性开关

WinForm 本身没有图层概念,所有图形都画在窗体Graphics上。但真实需求是:画矩形时不影响已画的圆,隐藏背景层后前景仍清晰可见,橡皮擦只擦当前层。这要求我们绕过Paint事件直绘,转而用内存位图做“图层画布”。本项目核心思路是:每个图层 = 一个Bitmap+ 一个Graphics对象 + 一个bool可见标志。所有绘制操作先落到对应Bitmap,最后在OnPaint中按图层顺序合成到屏幕。

2.1 图层容器设计:List 是唯一真相

项目中Form1.cs定义了Layer类,它不是 UI 控件,而是纯数据结构:

public class Layer { public Bitmap Image { get; set; } // 该层专属位图,大小与窗体一致 public bool Visible { get; set; } = true; // 是否参与最终合成 public string Name { get; set; } = "Layer"; // 仅用于 UI 显示,不影响逻辑 }

主窗体维护List<Layer> _layers,初始化时创建第一层:

private void InitializeLayers() { _layers = new List<Layer>(); // 创建初始图层,尺寸与窗体客户区一致 var initialSize = this.ClientSize; _layers.Add(new Layer { Image = new Bitmap(initialSize.Width, initialSize.Height) }); _currentLayerIndex = 0; // 当前激活图层索引 }

注意:Bitmap尺寸必须严格匹配窗体ClientSize,否则缩放或重绘时会出现黑边或裁剪。项目中Form1_Resize事件会重建所有图层位图,确保尺寸同步。

2.2 绘制流程:从鼠标按下到像素落位的四步链

用户画一条线,背后发生的是:

  1. 捕获起点:MouseDown记录_startPoint,并锁定当前图层;
  2. 实时预览:MouseMove在临时Graphics(基于窗体CreateGraphics())上画虚线,不写入任何图层位图;
  3. 落笔确认:MouseUp时,获取_endPoint,调用DrawLineToCurrentLayer(_startPoint, _endPoint);
  4. 写入图层:该方法获取当前层Bitmap,用Graphics.FromImage(layer.Image)创建专用绘图上下文,执行g.DrawLine(pen, start, end),再g.Dispose()。

关键代码段(Form1.cs中):

private void DrawLineToCurrentLayer(Point start, Point end) { if (_layers.Count == 0 || _currentLayerIndex < 0) return; var currentLayer = _layers[_currentLayerIndex]; using (var g = Graphics.FromImage(currentLayer.Image)) { // 使用当前选中的画笔(含颜色、粗细) g.DrawLine(_currentPen, start, end); } this.Invalidate(); // 触发重绘 }

这里_currentPen是动态构建的Pen对象,其Color来自颜色选择器,Width来自滑块控件。所有绘制操作必须通过Graphics.FromImage(layer.Image)执行,绝不能用this.CreateGraphics()写入图层位图——后者画的是瞬时屏幕,无法持久。

2.3 图层切换与可见性控制:UI 同步与重绘触发

图层列表用ListBox实现(listBoxLayers),双击切换当前层,勾选控制可见性:

private void listBoxLayers_SelectedIndexChanged(object sender, EventArgs e) { if (listBoxLayers.SelectedIndex >= 0 && listBoxLayers.SelectedIndex < _layers.Count) { _currentLayerIndex = listBoxLayers.SelectedIndex; // 更新 UI:显示当前层名称、启用/禁用绘图按钮 UpdateLayerUI(); } } private void listBoxLayers_ItemCheck(object sender, ItemCheckEventArgs e) { if (e.Index >= 0 && e.Index < _layers.Count) { _layers[e.Index].Visible = e.NewValue == CheckState.Checked; this.Invalidate(); // 强制重绘,让可见性生效 } }

OnPaint方法才是最终合成点:

protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); if (_layers.Count == 0) return; // 创建与窗体等大的合成位图(双缓冲防闪烁) using (var buffer = new Bitmap(this.ClientSize.Width, this.ClientSize.Height)) { using (var g = Graphics.FromImage(buffer)) { // 先清空为白色背景(可改为透明,需 PNG 格式) g.Clear(Color.White); // 按顺序绘制所有可见图层 for (int i = 0; i < _layers.Count; i++) { if (_layers[i].Visible && _layers[i].Image != null) { g.DrawImage(_layers[i].Image, Point.Empty); } } } // 一次性输出到屏幕 e.Graphics.DrawImage(buffer, Point.Empty); } }

为什么不用Control.Paint直接画?因为PaintEventArgs.Graphics是瞬时设备上下文,无法持久化;而Graphics.FromImage()生成的上下文写入的是内存位图,这才是图层数据的唯一来源。


3. 橡皮擦不是“擦除”,而是“用透明色重绘”:Alpha 通道与位图操作的硬核实践

很多初学者以为橡皮擦就是Clear()或FillRectangle(Brushes.Transparent),结果发现 WinForm 默认Bitmap是PixelFormat.Format32bppRgb,不支持 Alpha 通道,透明色画上去变成黑色或白色——这是 WinForm 图形开发最经典的翻车现场。本项目橡皮擦的实现,直击本质:橡皮擦 = 在当前图层位图上,用Color.FromArgb(0, 0, 0, 0)(完全透明)覆盖指定区域。但这要求图层位图必须是Format32bppPArgb格式,且绘制时启用CompositingMode.SourceCopy。

3.1 创建支持 Alpha 的图层位图:PixelFormat 的生死抉择

初始化图层时,必须指定支持 Alpha 的像素格式:

// 错误:默认 Format32bppRgb → 透明色无效 // _layers.Add(new Layer { Image = new Bitmap(width, height) }); // 正确:显式声明 Format32bppPArgb _layers.Add(new Layer { Image = new Bitmap(width, height, PixelFormat.Format32bppPArgb) });

Format32bppPArgb表示 32 位预乘 Alpha 位图,每个像素包含 A、R、G、B 四个字节,Color.FromArgb(0, r, g, b)的 Alpha 值才真正起作用。项目中InitializeLayers()和ResizeLayers()都强制使用此格式。

3.2 橡皮擦绘制逻辑:Brush + CompositingMode 的组合技

橡皮擦本质是“画笔”,只是颜色为透明。Form1.cs中定义_eraserBrush:

private SolidBrush _eraserBrush = new SolidBrush(Color.FromArgb(0, 0, 0, 0));

绘制时,必须设置Graphics的混合模式为SourceCopy,否则透明色会被忽略:

private void DrawEraserToCurrentLayer(Rectangle rect) { if (_layers.Count == 0 || _currentLayerIndex < 0) return; var currentLayer = _layers[_currentLayerIndex]; using (var g = Graphics.FromImage(currentLayer.Image)) { // 关键:启用 SourceCopy 模式,让透明色真正生效 g.CompositingMode = CompositingMode.SourceCopy; g.FillRectangle(_eraserBrush, rect); } this.Invalidate(); }

CompositingMode.SourceCopy表示“直接复制源像素到目标”,此时SolidBrush的 Alpha 值被原样写入位图,实现真正的“擦除”效果。若用默认SourceOver,则透明色会与底层像素混合,达不到擦除目的。

3.3 橡皮擦交互:从鼠标拖拽到矩形擦除的映射

用户按住鼠标拖动橡皮擦,程序将其轨迹离散为多个小矩形(模拟压感):

private void HandleEraserDrag(Point currentPoint) { if (_lastEraserPoint == Point.Empty) { _lastEraserPoint = currentPoint; return; } // 计算两点间连线上的多个采样点(避免拖拽过快出现断点) int steps = (int)Math.Max(1, Math.Sqrt(Math.Pow(currentPoint.X - _lastEraserPoint.X, 2) + Math.Pow(currentPoint.Y - _lastEraserPoint.Y, 2)) / 2); for (int i = 0; i <= steps; i++) { double t = (double)i / steps; int x = (int)(_lastEraserPoint.X + t * (currentPoint.X - _lastEraserPoint.X)); int y = (int)(_lastEraserPoint.Y + t * (currentPoint.Y - _lastEraserPoint.Y)); // 以当前橡皮擦尺寸为中心,画一个矩形 Rectangle eraseRect = new Rectangle( x - _eraserSize / 2, y - _eraserSize / 2, _eraserSize, _eraserSize ); DrawEraserToCurrentLayer(eraseRect); } _lastEraserPoint = currentPoint; }

_eraserSize来自滑块控件,范围 5–50 像素。此处没有用GraphicsPath或Ellipse,因为矩形填充性能远高于路径绘制,且视觉差异在橡皮擦场景下可忽略。


4. 颜色选择与保存机制:从 ColorDialog 到多格式导出的完整链路

绘图程序若不能精确选色、不能可靠保存,就是半成品。本项目颜色选择采用ColorDialog标准控件,但做了关键增强:记忆最近 16 种颜色,支持十六进制输入框;保存功能则提供两种模式:单层导出(PNG 保留 Alpha)与全图合成(BMP/JPEG),并处理 WinForm 常见的“保存对话框崩溃”问题。

4.1 颜色选择器:ColorDialog + 自定义历史面板

Form2.cs是颜色选择窗体,核心是ColorDialog的封装:

private void btnPickColor_Click(object sender, EventArgs e) { using (var dialog = new ColorDialog()) { dialog.FullOpen = true; // 显示高级选项 dialog.AnyColor = true; dialog.SolidColorOnly = false; // 设置上次选中的颜色为默认值 dialog.Color = _currentColor; if (dialog.ShowDialog() == DialogResult.OK) { _currentColor = dialog.Color; UpdateColorPreview(); // 添加到历史记录(最多 16 个) AddToRecentColors(_currentColor); } } }

历史颜色用FlowLayoutPanel动态生成Panel控件,每个 Panel 的BackColor设为对应颜色,点击即应用。十六进制输入框绑定TextChanged事件,实时解析:

private void txtHexColor_TextChanged(object sender, EventArgs e) { try { // 支持 #RRGGBB 和 RRGGBB 两种格式 string hex = txtHexColor.Text.Trim().Replace("#", ""); if (hex.Length == 6) { int r = Convert.ToInt32(hex.Substring(0, 2), 16); int g = Convert.ToInt32(hex.Substring(2, 2), 16); int b = Convert.ToInt32(hex.Substring(4, 2), 16); _currentColor = Color.FromArgb(r, g, b); UpdateColorPreview(); } } catch { /* 解析失败,忽略 */ } }

4.2 保存功能:区分图层导出与全图合成

保存逻辑在Form1.cs的saveToolStripMenuItem_Click中:

private void saveToolStripMenuItem_Click(object sender, EventArgs e) { using (var dialog = new SaveFileDialog()) { dialog.Filter = "PNG 文件|*.png|JPEG 文件|*.jpg|BMP 文件|*.bmp|所有文件|*.*"; dialog.DefaultExt = "png"; dialog.AddExtension = true; if (dialog.ShowDialog() == DialogResult.OK) { string ext = Path.GetExtension(dialog.FileName).ToLower(); try { if (ext == ".png") { // 导出当前图层(PNG 支持 Alpha) if (_layers.Count > 0 && _currentLayerIndex >= 0) { _layers[_currentLayerIndex].Image.Save(dialog.FileName, ImageFormat.Png); } } else if (ext == ".jpg" || ext == ".jpeg" || ext == ".bmp") { // 合成所有可见图层为单图 var composite = CreateCompositeImage(); composite.Save(dialog.FileName, ext == ".jpg" || ext == ".jpeg" ? ImageFormat.Jpeg : ImageFormat.Bmp); composite.Dispose(); } } catch (Exception ex) { MessageBox.Show($"保存失败:{ex.Message}", "错误", MessageBoxButtons.OK, MessageBoxIcon.Error); } } } }

CreateCompositeImage()方法复用OnPaint中的合成逻辑,但输出到新Bitmap:

private Bitmap CreateCompositeImage() { var size = this.ClientSize; var composite = new Bitmap(size.Width, size.Height, PixelFormat.Format32bppPArgb); using (var g = Graphics.FromImage(composite)) { g.Clear(Color.Transparent); // 背景透明,PNG 时保留 for (int i = 0; i < _layers.Count; i++) { if (_layers[i].Visible && _layers[i].Image != null) { g.DrawImage(_layers[i].Image, Point.Empty); } } } return composite; }

提示:BMP 格式不支持透明背景,导出 BMP 时g.Clear(Color.White)更稳妥;JPEG 强制丢弃 Alpha,故只用于全图合成。

4.3 避坑:常见问题与血泪排查记录

  • 现象:保存 PNG 后打开全是黑色,或透明区域变白
    原因:图层位图未用Format32bppPArgb创建,或CreateCompositeImage()中g.Clear(Color.Transparent)未生效(因Bitmap格式不支持)
    解决:检查所有new Bitmap(...)调用,强制指定PixelFormat.Format32bppPArgb;合成时若需透明背景,确保目标Bitmap也为此格式。

  • 现象:颜色选择窗体关闭后,主窗体颜色未更新
    原因:Form2作为模态窗体,ShowDialog()返回后未主动刷新主窗体 UI(如画笔预览、工具栏颜色按钮)
    解决:在Form2的DialogResult = DialogResult.OK后,添加this.DialogResult = DialogResult.OK;并在主窗体调用处增加UpdateColorUI()方法,更新_currentPen和预览控件。

  • 现象:快速连续点击“新建图层”,程序崩溃报ObjectDisposedException
    原因:Bitmap对象未及时释放,旧图层位图被 GC 回收,新图层创建时内存不足
    解决:在NewLayer()方法中,显式调用oldBitmap?.Dispose();并在Form1_FormClosing事件中遍历_layers释放所有Bitmap。

  • 现象:橡皮擦在高 DPI 屏幕上尺寸异常(实际擦除区域比滑块显示的大)
    原因:WinForm 默认不启用 DPI 感知,Graphics坐标系与物理像素不匹配
    解决:在App.config中添加<appSettings><add key="DpiAwareness" value="PerMonitorV2"/></appSettings>,并在Program.cs中Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);。

  • 现象:保存大图(>2000x2000)时内存溢出或卡死
    原因:CreateCompositeImage()创建超大Bitmap占用内存,且Graphics.DrawImage未启用双缓冲优化
    解决:对超大图添加尺寸限制(如if (size.Width * size.Height > 4_000_000) { MessageBox.Show("图像过大,请缩小窗口或减少图层数量"); return; }),或改用分块渲染(本项目未实现,属进阶优化)。


5. 工程级细节打磨:从 .csproj 配置到双缓冲抗闪烁的实战技巧

一个能落地的 WinForm 绘图程序,光有功能不够,还得扛得住真实环境:高 DPI 屏幕、多分辨率适配、频繁重绘不卡顿、资源泄漏可控。本项目在.csproj、窗体属性、GDI+ 参数上做了大量静默优化,这些细节才是区分“能跑”和“好用”的分水岭。

5.1 .csproj 关键配置:TargetFramework 与平台兼容性

项目使用net472,而非最低net45,原因在于PixelFormat.Format32bppPArgb在 .NET Framework 4.6+ 才稳定支持 Alpha 操作。.csproj中明确指定:

<TargetFramework>net472</TargetFramework> <UseWPF>false</UseWPF> <UseWindowsForms>true</UseWindowsForms> <!-- 启用高 DPI 感知 --> <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects> <GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>

<UseWindowsForms>true</UseWindowsForms>是必需的,否则System.Windows.Forms命名空间不可用;<AutoGenerateBindingRedirects>确保引用的System.Drawing.Common版本正确,避免GDI+相关DllNotFoundException。

5.2 窗体级双缓冲:Invalidate() 与 Update() 的精准配合

WinForm 默认重绘闪烁严重。本项目在Form1.Designer.cs中设置:

this.SetStyle(ControlStyles.OptimizedDoubleBuffer | ControlStyles.ResizeRedraw | ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint, true); this.UpdateStyles();
  • OptimizedDoubleBuffer:启用内置双缓冲,避免OnPaint中手动创建Bitmap缓冲;
  • ResizeRedraw:窗体大小改变时自动重绘,防止拉伸黑边;
  • AllPaintingInWmPaint:禁止WM_ERASEBKGND消息,由OnPaint统一处理背景;
  • UserPaint:禁用默认背景绘制,交由开发者控制。

同时,所有触发重绘的操作(如DrawLineToCurrentLayer)后,只调用this.Invalidate(),绝不调用this.Refresh()——后者会强制同步重绘,导致 UI 卡顿。Invalidate()发送异步消息,由 Windows 消息队列统一调度。

5.3 GDI+ 性能调优:SmoothingMode 与 InterpolationMode 的取舍

绘图平滑度影响视觉质量,但过度平滑拖慢性能。项目在OnPaint中设置:

g.SmoothingMode = SmoothingMode.HighQuality; // 抗锯齿,线条边缘柔化 g.InterpolationMode = InterpolationMode.HighQualityBicubic; // 图片缩放高质量 g.PixelOffsetMode = PixelOffsetMode.Half; // 像素偏移修正,消除1px模糊

SmoothingMode.HighQuality对直线、矩形影响小,但对圆形、贝塞尔曲线至关重要;InterpolationMode.HighQualityBicubic在图层缩放时保持清晰,但若程序无缩放功能,可降为NearestNeighbor提升性能。

5.4 资源释放铁律:Bitmap 与 Graphics 的 Dispose 时机

GDI+ 对象(Bitmap,Graphics,Pen,Brush)占用非托管资源,必须显式释放。本项目遵循三条铁律:

  1. Graphics对象必须用using包裹:如using (var g = Graphics.FromImage(bmp)) { ... };
  2. Bitmap在不再需要时立即Dispose():图层删除、窗体关闭时遍历释放;
  3. 全局对象(如_currentPen)在属性变更时释放旧实例:
private Pen _currentPen; public Pen CurrentPen { get => _currentPen; set { _currentPen?.Dispose(); // 释放旧画笔 _currentPen = value; } }

Form1_FormClosing中的清理:

private void Form1_FormClosing(object sender, FormClosingEventArgs e) { // 释放所有图层位图 foreach (var layer in _layers) { layer.Image?.Dispose(); } _layers.Clear(); // 释放全局画笔、橡皮擦画刷 _currentPen?.Dispose(); _eraserBrush?.Dispose(); }

6. 进阶技巧:如何把这套图层系统嵌入工业上位机界面,做实时数据标注?

我最早在做一个西门子 PLC 数据监控上位机时,客户提出:“画面右下角要能手动画个框,标出当前报警区域,框的颜色随报警等级变,关掉报警后自动清除”。当时我翻遍 WinForm 示例,全是静态图,没人讲怎么把绘图逻辑塞进Timer循环里还不卡主线程。后来我把这个Painter的图层系统拆出来,做了三处改造,现在成了我们团队的标准组件:图层绑定数据源、异步绘制防阻塞、图层生命周期与业务状态联动。

6.1 图层与数据源绑定:用 Tag 属性挂接业务对象

Layer类扩展Tag属性,存储业务元数据:

public class Layer { public Bitmap Image { get; set; } public bool Visible { get; set; } = true; public string Name { get; set; } = "Layer"; public object Tag { get; set; } // 可存 PLC 地址、报警ID、时间戳等 }

当 PLC 报警触发时,创建专用图层:

private void OnAlarmTriggered(string alarmId, Color color) { var alarmLayer = new Layer { Image = new Bitmap(this.ClientSize.Width, this.ClientSize.Height, PixelFormat.Format32bppPArgb), Name = $"Alarm-{alarmId}", Tag = new { AlarmId = alarmId, Timestamp = DateTime.Now } }; // 在该层画红色虚线框(位置由 HMI 布局计算) using (var g = Graphics.FromImage(alarmLayer.Image)) { using (var pen = new Pen(color, 2) { DashStyle = DashStyle.Dash }) { g.DrawRectangle(pen, new Rectangle(100, 100, 200, 150)); } } _layers.Add(alarmLayer); this.Invalidate(); }

6.2 异步绘制:避免 Timer 中直接调用 Graphics.FromImage()

工业界面常有 100ms 级别的Timer刷新,若每次都在Tick里Graphics.FromImage(),CPU 占用飙升。解决方案是:预分配图层位图,Tick中只更新像素数据,OnPaint合成。

// 预分配一个“动态图层” private Layer _dynamicLayer; private void InitializeDynamicLayer() { _dynamicLayer = new Layer { Image = new Bitmap(this.ClientSize.Width, this.ClientSize.Height, PixelFormat.Format32bppPArgb), Name = "Dynamic" }; _layers.Add(_dynamicLayer); } private void timerData_Tick(object sender, EventArgs e) { // 仅计算新位置,不绘图 var newPos = CalculateAlarmBoxPosition(); // 异步委托绘图,避免阻塞 Timer this.BeginInvoke((MethodInvoker)delegate { using (var g = Graphics.FromImage(_dynamicLayer.Image)) { g.Clear(Color.Transparent); // 清空上一帧 g.DrawRectangle(Pens.Red, newPos); } this.Invalidate(); }); }

BeginInvoke将绘图任务排队到 UI 线程,Timer本身保持轻量。

6.3 图层生命周期管理:与业务状态机同步

报警解除时,不能简单RemoveAt(),要确保图层位图被释放:

private void OnAlarmCleared(string alarmId) { var layerToRemove = _layers.FirstOrDefault(l => l.Tag is dynamic tag && tag.AlarmId == alarmId); if (layerToRemove != null) { layerToRemove.Image?.Dispose(); _layers.Remove(layerToRemove); this.Invalidate(); } }

更进一步,可为图层添加IsTemporary标志,Form1_FormClosing时只释放临时图层,保留用户手绘的永久图层。

从那以后我每次做上位机界面,只要涉及“在实时画面上叠加标注”,都强制走一遍这个图层初始化流程:new Bitmap(..., Format32bppPArgb)→Graphics.FromImage()→Tag绑定业务数据 →Invalidate()触发合成。它不炫技,但稳如磐石,客户从没抱怨过标注延迟或内存泄漏。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询