ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

WinForm图层绘图系统:双缓冲+Alpha橡皮擦+多格式保存

WinForm图层绘图系统:双缓冲+Alpha橡皮擦+多格式保存 简介这是一份面向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 显示不影响逻辑 }主窗体维护ListLayer _layers初始化时创建第一层private void InitializeLayers() { _layers new ListLayer(); // 创建初始图层尺寸与窗体客户区一致 var initialSize this.ClientSize; _layers.Add(new Layer { Image new Bitmap(initialSize.Width, initialSize.Height) }); _currentLayerIndex 0; // 当前激活图层索引 }注意Bitmap尺寸必须严格匹配窗体ClientSize否则缩放或重绘时会出现黑边或裁剪。项目中Form1_Resize事件会重建所有图层位图确保尺寸同步。2.2 绘制流程从鼠标按下到像素落位的四步链用户画一条线背后发生的是捕获起点MouseDown记录_startPoint并锁定当前图层实时预览MouseMove在临时Graphics基于窗体CreateGraphics()上画虚线不写入任何图层位图落笔确认MouseUp时获取_endPoint调用DrawLineToCurrentLayer(_startPoint, _endPoint)写入图层该方法获取当前层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中定义_eraserBrushprivate 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中的合成逻辑但输出到新Bitmapprivate 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中添加appSettingsadd keyDpiAwareness valuePerMonitorV2//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中明确指定TargetFrameworknet472/TargetFramework UseWPFfalse/UseWPF UseWindowsFormstrue/UseWindowsForms !-- 启用高 DPI 感知 -- AutoGenerateBindingRedirectstrue/AutoGenerateBindingRedirects GenerateBindingRedirectsOutputTypetrue/GenerateBindingRedirectsOutputTypeUseWindowsFormstrue/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占用非托管资源必须显式释放。本项目遵循三条铁律Graphics对象必须用using包裹如using (var g Graphics.FromImage(bmp)) { ... }Bitmap在不再需要时立即Dispose()图层删除、窗体关闭时遍历释放全局对象如_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()触发合成。它不炫技但稳如磐石客户从没抱怨过标注延迟或内存泄漏。希望帮到你。本文还有配套的精品资源点击获取
返回列表