拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

C# WinForm 图层绘图板实战:从画布模型到橡皮擦与保存

C# WinForm 图层绘图板实战:从画布模型到橡皮擦与保存

简介:这是一份面向C#初学者与Winform桌面开发爱好者的绘图程序源码包,基于.NET Framework构建,重点解决图层化绘图与图形编辑的入门实践问题。程序支持新建图层并单独显示或隐藏,可在不同图层上绘制线条、矩形、圆形等基础图形,配合颜色选择器与可调大小的橡皮擦,完成从绘制到修正的完整流程,并支持将作品保存到磁盘以便后续查看或继续编辑。压缩包共46个文件,约529KB,以cs源码、resx与resources资源文件、config配置、sln解决方案及exe可执行文件为主,另含少量zbak备份与cache缓存文件,结构完整可直接编译运行。目前已有45人学习下载。通过阅读与调试这套源码,读者可以掌握Winform中Graphics绘图、图层管理、颜色对话框调用、橡皮擦实现及文件保存等核心思路,适合作为课程设计、练手项目或二次开发的参考模板。

1. 从零手搓一个 C# WinForm 图层绘图板:为什么“图层”才是分水岭

很多人第一次用 C# WinForm 做绘图程序,画线、画矩形、画圆、选颜色、橡皮擦、保存,一天就能拼出来。但一旦加上“图层”两个字,代码结构立刻分叉:有人把每个图层做成一个Bitmap叠着画,有人用Graphics直接往同一个画布上怼,结果一删图层整张图全乱。图层不是炫技,它解决的是“单独显示某个图层、单独编辑某个图层、互不干扰”的真实需求——做上位机界面标注、简易 CAD 草图、教学演示板的人都绕不开。这篇笔记按我实际写过的路径,从画布模型讲到橡皮擦的坑,再到保存与图层合成,新手能照着敲,熟手能对照边界参数。核心词 C#、WinForm、绘图、图层、橡皮擦会自然贯穿,不堆砌。

2. 图层绘图板的画布模型:Bitmap 叠加还是 Graphcis 重绘

2.1 两种图层实现路线的取舍

做图层绘图,绕不开一个根本选择:每个图层到底存什么。常见做法有两种。

第一种是“位图叠加”:每个图层持有一个和画布等大的Bitmap,绘制时先画到当前图层的Bitmap上,显示时把所有可见图层的Bitmap按顺序DrawImage到控件上。优点是实现直观,橡皮擦好做——橡皮擦本质就是用背景色或透明色在图层位图上画线。缺点是内存随图层数线性增长,一个 1920×1080 的 32 位位图约 8MB,十个图层就 80MB,图层多了会明显吃内存。

第二种是“矢量对象列表”:每个图层存一个List<Shape>,每个Shape记录类型、坐标、颜色、线宽。显示时遍历列表用Graphics.DrawLine/DrawRectangle/DrawEllipse重绘。优点是内存小、可无限缩放、可做撤销重做;缺点是橡皮擦不能简单擦像素,得实现“擦除”语义(删除对象或加白色对象),而且每次重绘要遍历全部对象,对象上万后拖动会卡。

我一般会选位图叠加,因为标题里明确要“橡皮擦”和“保存”,位图路线最直接,保存时直接存 PNG 就行。矢量路线适合需要后期编辑每个图形的场景,但橡皮擦会变成玄学。下面代码全部按位图叠加走。

2.2 图层类的字段设计与最小实现

先定义图层类。关键字段:Bitmap Bitmap、string Name、bool Visible、float Opacity。Opacity用ColorMatrix实现,不是必须,但做“单独显示某个图层”时半透明预览很有用。

public class Layer { public string Name { get; set; } public Bitmap Bitmap { get; set; } // 该图层自己的像素缓冲 public bool Visible { get; set; } = true; public float Opacity { get; set; } = 1.0f; // 0~1,1 为完全不透明 public Layer(string name, int width, int height) { Name = name; // 必须用 Format32bppArgb,否则透明背景会变黑 Bitmap = new Bitmap(width, height, PixelFormat.Format32bppArgb); using (var g = Graphics.FromImage(Bitmap)) { g.Clear(Color.Transparent); // 透明底,叠加时才不会盖住下层 } } }

逻辑说明:Format32bppArgb是重点,默认的Format32bppRgb没有 alpha 通道,Clear(Color.Transparent)会变成黑色,叠加时下层全被盖住。参数上,width/height建议和绘图控件客户区一致,缩放窗口时要么重建位图,要么用固定逻辑尺寸加缩放矩阵,后者更稳。

2.3 画布控件的双缓冲与图层合成

绘图控件继承Panel或直接Control,开双缓冲防闪烁。OnPaint里按顺序合成所有可见图层。

public class CanvasControl : Control { public List<Layer> Layers { get; } = new List<Layer>(); public int ActiveLayerIndex { get; set; } = 0; public CanvasControl() { // 双缓冲,避免拖动时闪烁 SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.OptimizedDoubleBuffer | ControlStyles.UserPaint | ControlStyles.ResizeRedraw, true); DoubleBuffered = true; } protected override void OnPaint(PaintEventArgs e) { e.Graphics.Clear(Color.White); // 画布底色 foreach (var layer in Layers) { if (!layer.Visible) continue; if (layer.Opacity >= 1.0f) { e.Graphics.DrawImage(layer.Bitmap, Point.Empty); } else { // 用 ColorMatrix 控制整体透明度 var cm = new ColorMatrix { Matrix33 = layer.Opacity }; using (var ia = new ImageAttributes()) { ia.SetColorMatrix(cm); e.Graphics.DrawImage(layer.Bitmap, new Rectangle(Point.Empty, layer.Bitmap.Size), 0, 0, layer.Bitmap.Width, layer.Bitmap.Height, GraphicsUnit.Pixel, ia); } } } } }

逻辑说明:SetStyle里AllPaintingInWmPaint和OptimizedDoubleBuffer一起开,才能把绘制全部收进OnPaint并双缓冲。Opacity小于 1 时用ImageAttributes加ColorMatrix,Matrix33控制 alpha 缩放。参数注意:DrawImage的目标矩形和源矩形要一致,否则会拉伸;如果画布支持缩放,这里要乘缩放矩阵。

3. 线条矩形圆的绘制与颜色选择:从鼠标事件到图形落笔

3.1 鼠标按下移动抬起的三段式状态机

绘图的核心是鼠标事件状态机。MouseDown记起点并开始绘制,MouseMove实时预览,MouseUp定稿。预览不能直接画到图层位图上,否则会留下拖影,常见做法是画到控件的临时层(在OnPaint最后画一个预览图形)。

private Point _start, _end; private bool _drawing; private DrawTool _tool = DrawTool.Line; // 枚举:Line/Rectangle/Ellipse private Color _currentColor = Color.Black; private int _lineWidth = 2; protected override void OnMouseDown(MouseEventArgs e) { if (e.Button != MouseButtons.Left) return; _start = _end = e.Location; _drawing = true; } protected override void OnMouseMove(MouseEventArgs e) { if (!_drawing) return; _end = e.Location; Invalidate(); // 只重绘,不落笔 } protected override void OnMouseUp(MouseEventArgs e) { if (!_drawing) return; _drawing = false; _end = e.Location; CommitShape(); // 真正画到当前图层位图 Invalidate(); }

逻辑说明:Invalidate()触发OnPaint,预览图形在OnPaint里根据_drawing和_start/_end画。CommitShape才把图形写进Layers[ActiveLayerIndex].Bitmap。参数上_lineWidth建议 1~10,太粗的线在橡皮擦时不好处理。

3.2 把图形提交到当前图层位图

CommitShape用Graphics.FromImage拿到当前图层位图的绘图表面,按工具类型画。

private void CommitShape() { if (Layers.Count == 0) return; var bmp = Layers[ActiveLayerIndex].Bitmap; using (var g = Graphics.FromImage(bmp)) { g.SmoothingMode = SmoothingMode.AntiAlias; // 抗锯齿,线条更顺 using (var pen = new Pen(_currentColor, _lineWidth)) { var rect = NormalizeRect(_start, _end); // 处理反向拖动 switch (_tool) { case DrawTool.Line: g.DrawLine(pen, _start, _end); break; case DrawTool.Rectangle: g.DrawRectangle(pen, rect); break; case DrawTool.Ellipse: g.DrawEllipse(pen, rect); break; } } } } private Rectangle NormalizeRect(Point a, Point b) { // 鼠标可以从右下往左上拖,必须归一化,否则宽高为负画不出来 return new Rectangle( Math.Min(a.X, b.X), Math.Min(a.Y, b.Y), Math.Abs(a.X - b.X), Math.Abs(a.Y - b.Y)); }

逻辑说明:SmoothingMode.AntiAlias让线条和圆更平滑,代价是稍慢,实时预览时可以关掉、定稿时开。NormalizeRect是血泪经验,不处理反向拖动,矩形和圆直接消失。参数上Pen的StartCap/EndCap默认是平头,需要圆头可设LineCap.Round。

3.3 颜色选择与线宽联动

颜色用ColorDialog,线宽用NumericUpDown或ComboBox。选完颜色后要同步更新预览画笔,否则用户会以为没生效。

private void btnColor_Click(object sender, EventArgs e) { using (var dlg = new ColorDialog()) { dlg.FullOpen = true; // 直接展开自定义颜色区 if (dlg.ShowDialog() == DialogResult.OK) { _currentColor = dlg.Color; // 同步按钮背景,给用户即时反馈 btnColor.BackColor = _currentColor; } } }

逻辑说明:FullOpen = true省去用户再点一次“规定自定义颜色”。参数上如果要做“最近使用颜色”,可以维护一个List<Color>并画在自定义面板上,但这不是必须。注意ColorDialog返回的可能是KnownColor,保存配置时用ToArgb()更稳。

4. 橡皮擦与图层单独显示:两个最容易翻车的功能

4.1 橡皮擦的三种实现与选择

橡皮擦在位图叠加路线里有三种做法。第一种:用背景色画线,简单但只能擦纯色背景,透明底会露馅。第二种:用Graphics.CompositingMode = SourceCopy配合透明色,直接把像素 alpha 置 0,真正擦成透明。第三种:维护一个“擦除掩码”,合成时扣掉,复杂但可撤销。

我一般用第二种,因为图层是透明底,擦完能露出下层,符合图层直觉。

private void EraseAt(Point p) { var bmp = Layers[ActiveLayerIndex].Bitmap; using (var g = Graphics.FromImage(bmp)) { // SourceCopy 让画笔颜色直接覆盖像素,包括 alpha g.CompositingMode = CompositingMode.SourceCopy; using (var pen = new Pen(Color.Transparent, _eraserSize)) { pen.StartCap = LineCap.Round; pen.EndCap = LineCap.Round; g.DrawLine(pen, p, p); // 点擦 } } }

逻辑说明:CompositingMode.SourceCopy是关键,默认的SourceOver会把透明色和原像素混合,擦不干净。LineCap.Round让擦除轨迹圆润。参数上_eraserSize建议 5~50,太小擦得慢,太大容易误伤。注意:如果图层位图不是Format32bppArgb,SourceCopy加透明色会变成黑色,这是最常见的翻车点。

4.2 图层单独显示与可见性切换

“单独显示某个图层”有两种交互:一是切换Visible,二是“独显”模式——只显示当前图层,其他临时隐藏。后者用一个_soloLayerIndex字段,OnPaint时优先判断。

private int _soloLayerIndex = -1; // -1 表示非独显模式 protected override void OnPaint(PaintEventArgs e) { e.Graphics.Clear(Color.White); for (int i = 0; i < Layers.Count; i++) { var layer = Layers[i]; bool shouldDraw = _soloLayerIndex >= 0 ? i == _soloLayerIndex : layer.Visible; if (!shouldDraw) continue; e.Graphics.DrawImage(layer.Bitmap, Point.Empty); } }

逻辑说明:独显模式不改Visible字段,退出独显后原可见性自动恢复,避免用户手动改乱。参数上_soloLayerIndex用 -1 表示关闭,比用null在 WinForm 里更省事。图层列表用ListBox或CheckedListBox,勾选控制Visible,双击进入独显。

4.3 图层增删与顺序调整

新建图层时插到当前图层上方,删除时至少保留一个。顺序调整用List的RemoveAt/Insert。

private void AddLayer() { var layer = new Layer($"图层{Layers.Count + 1}", canvas.Width, canvas.Height); Layers.Insert(ActiveLayerIndex + 1, layer); ActiveLayerIndex++; RefreshLayerList(); } private void RemoveLayer(int index) { if (Layers.Count <= 1) return; // 至少留一个 Layers.RemoveAt(index); ActiveLayerIndex = Math.Max(0, index - 1); RefreshLayerList(); }

逻辑说明:插入位置用ActiveLayerIndex + 1,符合“新图层在当前之上”的直觉。删除后ActiveLayerIndex要夹紧,否则越界。参数上RefreshLayerList负责同步 UI,注意刷新时不要触发SelectedIndexChanged的递归。

5. 避坑与排查:图层绘图板最常见的 5 个翻车现场

5.1 现象:叠加后下层图层被黑色盖住

原因:图层位图用了Format32bppRgb,没有 alpha 通道,Clear(Color.Transparent)实际变成黑色。解决:创建位图时显式指定PixelFormat.Format32bppArgb,并确认Clear用的是Color.Transparent。

5.2 现象:橡皮擦擦完是黑色而不是透明

原因:CompositingMode没设成SourceCopy,或者位图格式不对。解决:擦除前设g.CompositingMode = CompositingMode.SourceCopy,画笔颜色用Color.Transparent,并检查位图是 32 位 ARGB。

5.3 现象:拖动鼠标画矩形,从右下往左上拖没反应

原因:Rectangle宽高为负,GDI+ 不绘制。解决:用NormalizeRect把起点终点归一化,取Math.Min和Math.Abs。

5.4 现象:图层多了以后拖动明显卡顿

原因:每次MouseMove都Invalidate全画布,且所有图层都重绘。解决:预览阶段只重绘脏区域,或用Region限制;图层数超过 10 个时考虑只合成可见图层,并避免在OnPaint里做ColorMatrix这种重操作。

5.5 现象:保存的 PNG 背景是黑的

原因:保存时直接Bitmap.Save了某个图层,而该图层透明区域在查看器里显示为黑。解决:保存前新建一个白底Bitmap,把所有可见图层合成上去再存,或者明确保存为带 alpha 的 PNG 并告知用户。

6. 保存、合成与一个提升手感的进阶技巧

保存分两种:保存整个画布(合成所有可见图层)和保存单个图层。合成保存是刚需,因为用户要的是最终图。

private void SaveMerged(string path) { using (var output = new Bitmap(canvas.Width, canvas.Height)) using (var g = Graphics.FromImage(output)) { g.Clear(Color.White); // 白底,避免透明区域在部分查看器里变黑 foreach (var layer in Layers) { if (!layer.Visible) continue; g.DrawImage(layer.Bitmap, Point.Empty); } output.Save(path, ImageFormat.Png); } }

逻辑说明:先Clear(Color.White)给一个不透明底,再按顺序叠加。如果用户要透明背景,把Clear换成Color.Transparent并确保输出位图是 ARGB。参数上ImageFormat.Png无损,适合线条图;要更小体积可用 JPEG,但线条边缘会有压缩噪点。

进阶技巧说一个提升手感的:用离屏缓冲做实时预览。预览图形不要每次都在OnPaint里重画,而是维护一个和画布等大的_previewBitmap,MouseMove时先清空预览层、画上当前图形,OnPaint最后把预览层叠上去。这样预览和定稿的绘制代码可以复用,减少“预览和最终结果不一致”的玄学问题。

private Bitmap _previewBitmap; private void UpdatePreview() { if (_previewBitmap == null || _previewBitmap.Size != canvas.Size) { _previewBitmap?.Dispose(); _previewBitmap = new Bitmap(canvas.Width, canvas.Height, PixelFormat.Format32bppArgb); } using (var g = Graphics.FromImage(_previewBitmap)) { g.Clear(Color.Transparent); if (_drawing) { using (var pen = new Pen(_currentColor, _lineWidth)) { var rect = NormalizeRect(_start, _end); if (_tool == DrawTool.Line) g.DrawLine(pen, _start, _end); else if (_tool == DrawTool.Rectangle) g.DrawRectangle(pen, rect); else if (_tool == DrawTool.Ellipse) g.DrawEllipse(pen, rect); } } } }

逻辑说明:预览层尺寸变化时重建,避免拉伸。OnPaint里在合成完所有图层后DrawImage(_previewBitmap, Point.Empty)。参数上预览层可以关抗锯齿换速度,定稿时再开。

我自己写这类程序最大的教训是:先把图层位图的像素格式和合成模式定死,再写任何绘制代码。我最早那版就是随手new Bitmap(w, h),结果橡皮擦和透明叠加全崩,排查了一下午才发现是Format32bppRgb在作祟。后来养成习惯,图层类构造函数里第一行就写死Format32bppArgb,省下无数后悔药。希望帮到你。

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

返回列表