之前做一个内部工具的时候,需要在C# Winform界面里实现流程图编辑器的一小块核心能力:节点能在画布上自由拖动,节点之间可以通过鼠标拉出一条连线,线要跟随节点移动,最后能保存重新打开。说白了就是一个迷你版流程编排器。当时网上能找到的现成方案不多,最后我自己用GDI+手工画了一套,代码量不大,但涉及到坐标、状态管理、命中检测这些细节,踩了不少坑。这篇文章把这些经验和关键代码整理出来,适合在不依赖第三方控件的Winform项目里需要类似画布编辑功能的朋友参考。
1. 需求拆解与方案选型
1.1 流程图编辑器的核心交互到底是什么
这个需求看上去只是“画一根线”,但真正做起来,核心是三个交互闭环:
- 节点可以被选中、拖动,拖动时所有关联连线必须动态跟随。
- 用户从某个输出端口按下鼠标,拖到另一个输入端口松开,完成连线创建。
- 连线可以被选中、删除,图结构可以保存到磁盘并重新加载。
光这三点就牵扯出不少细节:节点和端口的关系怎么建模、鼠标按下时如何判断点在端口上还是节点上、拉线过程中临时线怎么画、节点移动后线为什么不能断、点击一条多弯曲线时怎么判断命中了它。
我当时的做法是:数据模型和渲染分离。程序里维护一个节点列表和一个连线列表,控件在OnPaint里根据这两个列表全量重绘。鼠标事件负责增删改这两个列表,以及维护操作过程中的临时状态。这个思路很简单,但后面所有功能都能往里塞。
1.2 为什么选择GDI+自绘而不是现成控件
找方案的时候,其实先考虑过现成的第三方控件,比如各种商业图表库。但想了几个问题之后还是决定自己画:
第一,项目是Winform历史代码,不想为了一个编辑器引入一套重量级依赖。第二,流程图节点的样式、端口位置、连线的业务含义都和具体场景强相关,用现成控件往往要花大量时间学习它的自定义机制,还不如自己写。第三,编辑器核心代码其实只有几百行,GDI+画矩形、画直线、画贝塞尔曲线都是基本功,没有太高门槛。
对比一下几个方案的取舍:
| 方案 | 优点 | 缺点 |
|---|---|---|
| 第三方商业控件 | 功能全,交付快 | 授权成本高,定制受限制,体积大 |
| WPF重写 | 矢量渲染、绑定方便 | 项目技术栈不兼容,迁移成本极高 |
| GDI+自绘 | 代码可控、零依赖、灵活 | 所有交互细节都要自己处理 |
如果项目是全新启动且允许选型,WPF做这类画布确实更顺手。但如果就是Winform存量项目,GDI+自绘是性价比最高的方案。
1.3 交互状态机:用三个状态管住鼠标
鼠标操作最怕多种操作互相干扰。比如用户在端口上按下鼠标,他可能是想连线,也可能只是想拖动节点;在连线上按下鼠标,可能想选中,也可能想平移画布。
我用一个简单的枚举把操作状态固定下来:
csharp复制private enum CanvasState
{
Idle,
DraggingNode,
Connecting,
MovingLink
}
状态之间只有固定几条转换路径:
- Idle下按在Output端口上,进入Connecting。
- Idle下按在节点主体上,进入DraggingNode。
- Idle下按在连线上,进入MovingLink(拖动连线调整端点,或用于选中)。
- 任何状态下松开鼠标或按Esc,都回到Idle。
这样写出来的MouseDown、MouseMove、MouseUp三个方法非常清晰,不用到处判断“是不是正在干什么”。代码可读性也高很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据结构与序列化设计
2.1 节点和端口怎么建模才合理
节点、端口、连线三个对象,我分别定义。节点先定了基础字段:
csharp复制public class NodeItem
{
public int Id { get; set; }
public string Title { get; set; }
public Rectangle Rect { get; set; }
public List<Connector> Inputs { get; set; } = new List<Connector>();
public List<Connector> Outputs { get; set; } = new List<Connector>();
public bool IsSelected { get; set; }
public PointF GetInputAnchor(int index)
{
int count = Inputs.Count;
float y = Rect.Top + Rect.Height * (index + 1f) / (count + 1f);
return new PointF(Rect.Left, y);
}
public PointF GetOutputAnchor(int index)
{
int count = Outputs.Count;
float y = Rect.Top + Rect.Height * (index + 1f) / (count + 1f);
return new PointF(Rect.Right, y);
}
}
关键点在于锚点是动态计算的,不是存好的坐标。节点Rect一变,所有连线的端点自然跟着变,这也是后面拖动节点时连线不会断的核心原因。端口不单独存Point,而是存“它是第几个输入/输出端口”,绘制时算出来。
Connector的定义比较简单:
csharp复制public class Connector
{
public string Name { get; set; }
public bool IsInput { get; set; }
}
判断端口方向时,用IsInput区分。连线对象保存四个东西:起始节点、起始输出端口索引、目标节点、目标输入端口索引。
csharp复制public class ConnectionItem
{
public NodeItem FromNode { get; set; }
public int FromPortIndex { get; set; }
public NodeItem ToNode { get; set; }
public int ToPortIndex { get; set; }
public bool IsSelected { get; set; }
}
为什么不直接存FromNode和FromPort对象?因为序列化时,对象引用关系不好处理。存“节点Id + 端口索引”这种组合,后续做JSON保存和加载都非常自然。
2.2 合法连接校验与循环检测
允许用户随意连线之前,先得定义“哪些连接是合法的”。我实现了三个基本校验,不满足就不允许创建连线:
- 起点必须是Output端口,终点必须是Input端口。
- 同一个节点不能自己连自己。
- 已经存在的相同连接不允许重复添加。
如果是做流程引擎的设计器,还要额外做环路检测。比如A连B、B连C、C再连A,这种闭环在某些场景里就是死循环。检测方法是从目标节点出发,沿着已有的连线做BFS,看能不能回到源节点:
csharp复制private bool WouldCreateCycle(NodeItem from, NodeItem to)
{
var visited = new HashSet<NodeItem>();
var queue = new Queue<NodeItem>();
queue.Enqueue(to);
while (queue.Count > 0)
{
var current = queue.Dequeue();
if (current == from) return true;
if (!visited.Add(current)) continue;
foreach (var link in _links)
{
if (link.FromNode == current)
queue.Enqueue(link.ToNode);
}
}
return false;
}
如果只是纯可视化展示,不执行流程逻辑,是否做环路检测看产品需求。但校验代码加上去成本很低,建议先加上,后续需要时省得改交互逻辑。
2.3 用JSON保存和恢复图结构
保存时我不直接序列化NodeItem和ConnectionItem,而是转到一组扁平DTO,这样结构干净,不受运行时字段影响:
csharp复制public class GraphDto
{
public List<NodeDto> Nodes { get; set; }
public List<LinkDto> Links { get; set; }
}
public class NodeDto
{
public int Id { get; set; }
public string Title { get; set; }
public int X { get; set; }
public int Y { get; set; }
public int Width { get; set; }
public int Height { get; set; }
}
public class LinkDto
{
public int FromNodeId { get; set; }
public int FromPortIndex { get; set; }
public int ToNodeId { get; set; }
public int ToPortIndex { get; set; }
}
保存很简单:
csharp复制public void SaveToFile(string path)
{
var dto = new GraphDto
{
Nodes = _nodes.Select(n => new NodeDto
{
Id = n.Id,
Title = n.Title,
X = n.Rect.X,
Y = n.Rect.Y,
Width = n.Rect.Width,
Height = n.Rect.Height
}).ToList(),
Links = _links.Select(l => new LinkDto
{
FromNodeId = l.FromNode.Id,
FromPortIndex = l.FromPortIndex,
ToNodeId = l.ToNode.Id,
ToPortIndex = l.ToPortIndex
}).ToList()
};
File.WriteAllText(path, JsonSerializer.Serialize(dto, new JsonSerializerOptions
{
WriteIndented = true
}));
}
加载时先重建节点列表,再根据Id找回节点引用重建连线。这里有个小坑:加载回来的端口索引可能越界,比如旧文件里保存的端口数是4,但现在节点定义只有2个端口。所以加载代码务必做边界检查,索引超出范围的连线直接丢弃,避免打开文件时崩掉。
3. 绘制与命中检测实现
3.1 双缓冲绘制框架与渲染顺序
Winform里自绘控件,最头疼的问题就是闪烁。解决闪烁的常规操作是启用双缓冲:
csharp复制public class CanvasControl : Control
{
public CanvasControl()
{
SetStyle(ControlStyles.AllPaintingInWmPaint
| ControlStyles.UserPaint
| ControlStyles.OptimizedDoubleBuffer, true);
DoubleBuffered = true;
}
protected override void OnPaintBackground(PaintEventArgs pevent)
{
// 不处理背景擦除,避免白闪
}
}
这套设置之后,OnPaint里的所有绘制操作都会先画到内存缓冲,再一次性呈现到屏幕。对于这个规模的自绘场景完全够用,没必要再去手工维护Bitmap。
绘制顺序很重要,画错一层就会被盖住。我实际使用的顺序是:
- 背景网格。
- 所有连线。
- 所有节点背景和边框。
- 节点标题。
- 输入端子和输出端子。
- 选中态高亮。
连线画在节点下面,这样线条从节点边缘延伸出来,视觉上比较自然。选中高亮放在最后,确保选中状态不会被其他元素遮挡。
3.2 贝塞尔曲线控制点计算与箭头方向
两个端口之间的连线,最简单的画法是拉一条直线。但直线在节点一左一右的情况下容易和别的节点重叠,观感也比较生硬。我用了三次贝塞尔曲线,效果平滑很多。
三次贝塞尔需要两个控制点。控制点位置决定了曲线的弯曲方向,这里值得认真对待:
csharp复制private PointF[] GetCurvePoints(PointF start, PointF end)
{
float dx = end.X - start.X;
float dy = end.Y - start.Y;
// 绝大多数连接是左右方向,控制点往水平方向偏移
if (Math.Abs(dx) > Math.Abs(dy))
{
float offset = Math.Max(Math.Abs(dx) * 0.5f, 40f);
PointF c1 = new PointF(start.X + offset, start.Y);
PointF c2 = new PointF(end.X - offset, end.Y);
return new[] { start, c1, c2, end };
}
else
{
// 上下重叠的场景,控制点改为纵向偏移
float offset = Math.Max(Math.Abs(dy) * 0.5f, 40f);
PointF c1 = new PointF(start.X, start.Y + offset);
PointF c2 = new PointF(end.X, end.Y - offset);
return new[] { start, c1, c2, end };
}
}
这个判断条件解决了一个很实际的问题:如果两个节点上下叠放,横向距离很小,这时候强行用水平控制点,曲线会变成接近直线的怪异形状;改用纵向控制点后,曲线自然沿着上下方向拐弯。
绘制时用GraphicsPath或者直接调用DrawBezier:
csharp复制using (var path = new GraphicsPath())
{
var pts = GetCurvePoints(start, end);
path.AddBezier(pts[0], pts[1], pts[2], pts[3]);
g.DrawPath(pen, path);
// 箭头画在终点
DrawArrowHead(g, pts[3], pts[2], pen.Brush);
}
箭头方向是另一个容易出问题的地方。我最初把三角函数符号写反,画出来的箭头指向起点,测试时一眼没看出来,直到连续拉了几条线才发现方向全反了。箭头方向应该从倒数第二个控制点指向终点:
csharp复制private void DrawArrowHead(Graphics g, PointF tip, PointF from, Brush brush)
{
float angle = (float)Math.Atan2(tip.Y - from.Y, tip.X - from.X);
float size = 10f;
PointF left = new PointF(
(float)(tip.X - size * Math.Cos(angle - 0.5)),
(float)(tip.Y - size * Math.Sin(angle - 0.5)));
PointF right = new PointF(
(float)(tip.X - size * Math.Cos(angle + 0.5)),
(float)(tip.Y - size * Math.Sin(angle + 0.5)));
g.FillPolygon(brush, new[] { tip, left, right });
}
注意:箭头是实心小三角,不是两条线组成的V字形。实心三角在视觉上辨识度高很多,尤其当很多线交叉的时候。
3.3 端口和连线的高精度命中检测
端口命中检测比较直接,遍历所有节点的输入和输出端子,计算鼠标点到锚点的距离,小于阈值就算命中。
csharp复制private bool TryHitPort(Point pt, out NodeItem node, out int portIndex, out bool isInput)
{
const float threshold = 10f;
foreach (var n in _nodes)
{
for (int i = 0; i < n.Inputs.Count; i++)
{
PointF anchor = n.GetInputAnchor(i);
if (Distance(pt, anchor) <= threshold)
{
node = n;
portIndex = i;
isInput = true;
return true;
}
}
for (int i = 0; i < n.Outputs.Count; i++)
{
PointF anchor = n.GetOutputAnchor(i);
if (Distance(pt, anchor) <= threshold)
{
node = n;
portIndex = i;
isInput = false;
return true;
}
}
}
node = null;
portIndex = -1;
isInput = false;
return false;
}
阈值取10像素左右比较合适。太小了用户很难精确点中,太大会误触邻近端口。这个数值是手感问题,可以在调试时调整为常量。
连线命中检测稍微复杂一些,因为贝塞尔曲线不是直线。我的做法是把曲线采样成若干个小段,再逐段计算点到线段的距离:
csharp复制private bool HitTestConnection(Point pt, ConnectionItem link, float threshold)
{
var start = link.FromNode.GetOutputAnchor(link.FromPortIndex);
var end = link.ToNode.GetInputAnchor(link.ToPortIndex);
var pts = GetCurvePoints(start, end);
for (int i = 0; i < 20; i++)
{
float t = i / 20f;
float t2 = (i + 1) / 20f;
var p1 = BezierAt(pts, t);
var p2 = BezierAt(pts, t2);
if (PointToSegmentDistance(pt, p1, p2) <= threshold)
return true;
}
return false;
}
private float PointToSegmentDistance(PointF p, PointF a, PointF b)
{
float dx = b.X - a.X;
float dy = b.Y - a.Y;
if (dx == 0 && dy == 0) return Distance(p, a);
float t = ((p.X - a.X) * dx + (p.Y - a.Y) * dy) / (dx * dx + dy * dy);
t = Math.Max(0, Math.Min(1, t));
float cx = a.X + t * dx;
float cy = a.Y + t * dy;
return Distance(p, new PointF(cx, cy));
}
采样20段对于屏幕显示精度完全足够,计算量也不大。即使画布上有几百条连线,每次鼠标移动只做一次遍历,性能毫无压力。
4. 鼠标交互全流程实现
4.1 从输出端口拉出临时连线
创建连线的过程我设计成三段式:按下、拖拽、松开。
MouseDown时先做端口命中检测。只有命中Output端口才进入Connecting状态,Input端口不触发连线创建:
csharp复制protected override void OnMouseDown(MouseEventArgs e)
{
Focus();
if (e.Button == MouseButtons.Left)
{
if (TryHitPort(e.Location, out var node, out var portIndex, out bool isInput))
{
if (!isInput)
{
_state = CanvasState.Connecting;
_pendingFromNode = node;
_pendingFromPortIndex = portIndex;
_pendingEnd = e.Location;
Capture = true;
}
return;
}
// 先判断是否点击到已有连线
foreach (var link in _links)
{
if (HitTestConnection(e.Location, link, 6f))
{
_selectedLink = link;
_state = CanvasState.MovingLink;
return;
}
}
// 再判断是否点击到节点主体
foreach (var node in _nodes)
{
if (node.Rect.Contains(e.Location))
{
_dragOffset = new Point(e.Location.X - node.Rect.X, e.Location.Y - node.Rect.Y);
_dragNode = node;
_state = CanvasState.DraggingNode;
return;
}
}
}
base.OnMouseDown(e);
}
这里有个细节:Capture = true。如果不设置鼠标捕获,拖拽过程中鼠标一旦移出控件区域,控件就收不到MouseMove和MouseUp事件,临时连线会停在边缘,用户松手也没反应。加了捕获之后,只要按着左键,事件就会持续送达,即使鼠标已经跑到画布外面。
MouseMove里根据状态分别处理:
csharp复制protected override void OnMouseMove(MouseEventArgs e)
{
switch (_state)
{
case CanvasState.Connecting:
_pendingEnd = e.Location;
Invalidate();
break;
case CanvasState.DraggingNode:
if (_dragNode != null)
{
_dragNode.Rect = new Rectangle(
e.Location.X - _dragOffset.X,
e.Location.Y - _dragOffset.Y,
_dragNode.Rect.Width,
_dragNode.Rect.Height);
Invalidate();
}
break;
case CanvasState.MovingLink:
if (_selectedLink != null)
{
// 拖动连线端点的逻辑
Invalidate();
}
break;
}
base.OnMouseMove(e);
}
MouseUp时完成连线创建:
csharp复制protected override void OnMouseUp(MouseEventArgs e)
{
if (e.Button == MouseButtons.Left)
{
if (_state == CanvasState.Connecting)
{
if (TryHitPort(e.Location, out var node, out var portIndex, out bool isInput)
&& isInput
&& node != _pendingFromNode
&& !WouldCreateCycle(_pendingFromNode, node))
{
_links.Add(new ConnectionItem
{
FromNode = _pendingFromNode,
FromPortIndex = _pendingFromPortIndex,
ToNode = node,
ToPortIndex = portIndex
});
}
_pendingFromNode = null;
_pendingFromPortIndex = -1;
}
_dragNode = null;
_selectedLink = null;
_state = CanvasState.Idle;
Capture = false;
Invalidate();
}
base.OnMouseUp(e);
}
画Connecting状态下的临时线时,我用虚线,终点就是鼠标当前位置。这样用户能清楚看到自己正在创建一条连接,而不是等松手才知道结果。
4.2 拖动节点时连线如何保持跟随
节点拖动时,连线不需要额外更新,因为连线端点锚点是动态计算的。节点Rect一变,下一次OnPaint时所有连接线的起点终点自动变化。
但这带来一个视觉上的问题:如果拖动节点时每次都整画布重绘,节点多的时候会有些许卡顿。我采用的优化是对节点移动前后的区域做交集合并后再局部刷新:
csharp复制private void InvalidateNodeAndLinks(NodeItem node)
{
var dirty = node.Rect;
foreach (var link in _links)
{
if (link.FromNode == node || link.ToNode == node)
{
var start = link.FromNode.GetOutputAnchor(link.FromPortIndex);
var end = link.ToNode.GetInputAnchor(link.ToPortIndex);
int margin = 20;
int x = (int)Math.Min(start.X, end.X) - margin;
int y = (int)Math.Min(start.Y, end.Y) - margin;
int w = (int)Math.Abs(end.X - start.X) + margin * 2;
int h = (int)Math.Abs(end.Y - start.Y) + margin * 2;
dirty = Rectangle.Union(dirty, new Rectangle(x, y, w, h));
}
}
Invalidate(dirty);
}
实操下来,20以下节点数量全量Invalidate完全没问题。到100个节点以上,这个局部刷新方案能明显降低GDI+绘制压力。
4.3 选中、删除、取消与键盘快捷键
选中逻辑我做了两级区分:单击节点或连线时,清除其他选中态,只高亮当前对象;按住Ctrl再点击多个节点,可以多选。
删除操作走键盘Delete键。但Winform里的Control默认没有焦点收不到键盘事件,所以MouseDown第一行必须调用Focus()。这个坑很隐蔽,我一开始没加,Delete键怎么按都没反应,查了半天才发现焦点不在画布上。
取消临时连线用Esc键:
csharp复制protected override void OnKeyDown(KeyEventArgs e)
{
if (e.KeyCode == Keys.Delete)
{
_links.RemoveAll(l => l.IsSelected);
_nodes.RemoveAll(n => n.IsSelected);
Invalidate();
}
else if (e.KeyCode == Keys.Escape && _state == CanvasState.Connecting)
{
_pendingFromNode = null;
_pendingFromPortIndex = -1;
_state = CanvasState.Idle;
Invalidate();
}
base.OnKeyDown(e);
}
删除节点时,还要把它关联的所有连线一起删掉,否则界面上会出现一些一头悬空的线:
csharp复制private void DeleteNode(NodeItem node)
{
_links.RemoveAll(l => l.FromNode == node || l.ToNode == node);
_nodes.Remove(node);
}
5. 高频问题与性能优化实录
5.1 常见问题排查速查表
做这个功能踩坑不少,很多问题第一次出现时毫无头绪,记录一下排查思路:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 画面闪烁严重 | 默认背景擦除和重绘叠加 | 启用双缓冲,重写OnPaintBackground为空实现 |
| 拖动节点后连线断掉 | 连线端点缓存了旧坐标 | 锚点动态计算,不存坐标 |
| 拖出控件范围后松开没反应 | 丢失鼠标事件 | MouseDown时设置Capture = true |
| Delete键没反应 | 画布控件没有焦点 | MouseDown时调用Focus() |
| 曲线弯折方向怪异 | 控制点固定水平或固定垂直 | 根据dx/dy相对大小选控制点方向 |
| 点击连线困难 | 命中阈值太小 | 阈值调到6-8像素,采样20段 |
| 保存后打开连线错乱 | 端口索引越界 | 加载时对端口索引做边界检查 |
这里面最典型的还是“拖出画布没反应”。当时我以为是自己命中检测写错了,查了很久,最后才发现是没设置鼠标捕获。这个细节在实际交互中非常关键,建议一开始就写进模板里。
5.2 局部失效重绘与运行卡顿优化
如果画布上节点数量小,GDI+性能不是问题。但当节点超过200个、连线超过几百条时,全量重绘的成本就比较明显了。尤其是拖动节点时,每帧都重新遍历绘制所有曲线,能感觉到掉帧。
优化的核心思路是缩小重绘区域。Invalidate可以传一个Rectangle参数,只刷新这个区域。GDI+会自动裁剪绘制范围,配合双缓冲,效果很明显。
此外,绘制连线时可以做一个简单的视口裁剪:先算出线的外包矩形,如果和当前失效区域没有交集,直接跳过绘制。这个判断消耗很小,但能避免大量无效的DrawBezier调用。
另一个容易忽略的性能点是用Pen对象。每次OnPaint都new Pen,在频繁重绘时GC压力很大。我在控件初始化时创建一组固定Pen,用不同颜色和宽度区分正常、选中、临时状态,绘制时直接复用。这个做法简单但收益很直观。
5.3 高DPI缩放与模糊问题适配
Winform在高DPI下,如果程序没有声明DPI感知,系统会做缩放位图拉伸,画出来的东西会变模糊。现在笔记本都是125%、150%缩放,不处理的话观感很差。
在Program.cs入口处设置:
csharp复制[STAThread]
static void Main()
{
Application.SetHighDpiMode(HighDpiMode.PerMonitorV2);
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new MainForm());
}
SetHighDpiMode需要在创建任何窗口之前调用。如果项目是.NET Framework 4.7.2以下,这个方法可能不存在,需要改成在app.manifest里声明DPI感知。
实际自绘时,我先把DPI缩放系数取出来,绘制前调用ScaleTransform:
csharp复制float scale = DeviceDpi / 96f;
e.Graphics.ScaleTransform(scale, scale);
这样所有绘制坐标都按96DPI逻辑坐标写,系统自动根据DPI放大。如果节点坐标存放的已经是物理像素,就不要加这个变换,否则坐标会重复缩放。
6. 项目复盘与后续扩展
6.1 做这个功能时我最想穿越回去改掉的三个问题
第一个问题是没一开始就做撤销重做。做到一半发现,连线连错了只能手动删,节点误拖了位置没后悔药。后来补了一套基于JSON快照的撤销栈,虽然能用,但设计上有点临时。现在再做类似功能,我会在数据模型稳定后第一时间加入Undo/Redo,趁地基建好再往上盖楼。
第二个问题是没有预留画布平移和缩放。项目后期想要支持放大查看细节,发现所有坐标都是直接映射到屏幕像素的,坐标和尺寸都没法和DPI解耦。如果一开始就把画布逻辑坐标和视图坐标分开,后面加缩放会轻松很多。
第三个问题是早期对端口锚点的计算太随意。最初用的是固定像素坐标,比如左边距10px、右边距10px,节点一拉伸端口就错位。后来改成“端口按顺序均分分布在节点侧边”的动态计算方式,一切才稳定下来。
6.2 从连线功能到完整设计器的扩展方向
这个流程节点连线功能做完之后,其实就是半个流程图编辑器。再往后扩展,有一些优先级比较高的方向:
- 撤销重做:用操作命令栈替代全量快照,能节省内存,也更容易做批量操作的统一回滚。
- 画布平移和缩放:引入逻辑坐标和物理坐标的映射,自绘控件的终极形态都要走这一步。
- 节点类型插件化:让节点的输入端口数量、输出端口数量、属性面板由业务层定义,画布只负责渲染和交互。
- 连线上加标签:比如流转条件、数据字段映射,本质上就是给ConnectionItem加一个业务数据字段,绘制时在曲线中点附近画一个小标签框。
- 迷你地图和缩放控制条:节点多的时候,一个缩略图可以快速定位,很实用。
我后来在另一套代码里复用这个画布思路,把数据模型抽成了泛型接口,节点和连线都变成可序列化的基类,具体业务类型只是继承并加字段。这样画布本身保持了通用性,新业务接入成本很低。
做这类自绘画布,核心不在于画得多么炫酷,而在于数据模型拆分得够不够干净、交互状态管得够不够清晰。把锚点动态计算、鼠标捕获、DPI缩放这几个基础问题处理好,Winform里做流程编辑器其实没有想象中那么难。最后再提醒一句:开发过程中务必频繁保存测试文件,很多诡异问题重启程序后就能看到是因为加载逻辑写错了,而不是绘制逻辑。
