前阵子把一个内部项目里的审批流转模块重构了一圈,最后沉淀下来一套基于 C# Winform 的表单顺序工作流程设计器。很多朋友看到演示视频后第一反应都是:这种工作流设计器不是有大把现成框架吗,为什么还要自己花力气写?说实话,我也不是一开始就想自研,但折腾过一圈现成方案之后,确实发现一个很尴尬的事实:在 Winform 桌面项目里,想找一个“轻量、可嵌入、UI 可控、按顺序流转表单”的流程配置组件,比想象中难得多。这篇文章就把我实际做出来的这套表单顺序工作流程设计器从数据模型、画布交互、表单绑定到运行时引擎完整拆开讲一遍,重点讲清楚每一步为什么这么设计,踩过哪些坑,以及哪些地方是常规文档里不会告诉你的细节。适合正在做 Winform 流程类项目的朋友参考,也可以作为一套方案选型的对照材料。
1. 为什么我不直接买一套流程引擎,而是从零写了一个顺序流程设计器
先交代需求背景。我当时面对的是一套面向企业内部的生产工单流转系统,客户提的需求非常直白:不同产品线有不同的审核步骤,每个步骤要填写对应的表单,后续流程顺序可能调整,希望让业务人员自己在界面上拖一拖、配一配,不用每次改代码。这种场景听起来简单,但真正落地时会发现它是“顺序流程为主、条件分支为辅、表单强绑定”的组合形态,很多重量级流程引擎解决的是更复杂的 BPM 问题,拿到这种场景里反而显得笨重。
1.1 初始需求场景与隐含的设计目标
我梳理了一下具体场景,大概有三类典型流程需要支持。第一类是生产工单流转,比如“质检录入 → 主管审核 → 工艺确认 → 归档”,每一步对应一张不同字段的表单。第二类是设备巡检任务,比如“任务分发 → 现场填报 → 异常上报 → 结果复核”,其中现场填报和异常上报的表单结构完全不同。第三类是内部行政审批,比如“申请人填写 → 部门主管审批 → 财务复核 → 完成”,这类流程通常要求节点顺序严格固定,表单字段要带必填校验。
这三类场景共同点很明显:流程执行顺序基本是线性的,偶尔有驳回和跳转;每个节点背后挂着一张或多张表单;流程配置页面需要让非技术人员看得懂、改得动。基于这些观察,我给自己定了几个明确的设计目标——第一,流程定义要能脱离代码配置,通过可视化设计器直接编辑并保存;第二,流程模型要轻量,序列化后就是一个可读的配置文件,能导入导出;第三,运行时引擎要跟设计器解耦,设计器只负责生成定义,引擎只负责解释执行;第四,表单配置不能写死,字段、布局、校验规则都要可配置。
如果你把目标定成“做一个通用 BPM 引擎”,那项目复杂度会瞬间爆炸,包括流程版本、并行网关、子流程、会签、超时提醒等一大堆事情。但这个项目真正的核心价值只有一句话:把顺序表单流程的配置权交给业务用户。所以后续所有设计决策都围绕这个点展开,那些用不到的重型特性一律砍掉。
1.2 现成方案踩点:为什么 winform 项目里可选方案这么少
在决定自研之前,我实际评估过四个方向,各有各的问题。第一个是 Windows Workflow Foundation(WF),它是 .NET 官方出的工作流组件,设计器可以重新托管到 Winform 里,功能很完整,但问题也不少:WF 的设计器重托管需要处理大量 XAML 细节,UI 风格跟现代 Winform 应用差距很大,改造定制成本高,而且 .NET Framework 时代的老 WF4 维护节奏早就放缓了,新项目用起来总有种包袱感。
第二个是 Workflow Core 和 Elsa 这类开源服务端工作流引擎。它们设计得很好,API 清晰,支持持久化,但定位是服务端执行,嵌入 Winform 客户端后需要额外搭一套宿主环境,流程设计与节点绑定表单这类桌面端交互仍然要自己写 UI。换句话说,引擎只是解决了执行问题,设计器部分还是要从头做,那自研设计器的成本并没有省下来。
第三个是商业控件,比如一些厂商提供的流程图编辑控件。优势是开箱即用、交互流畅,但问题是价格不低,而且内置的数据模型往往是通用流程图模型,跟表单系统、审批逻辑的耦合还是需要自己做。万一碰到需要定制连线样式、属性面板联动的场景,控件反而成为限制。
第四个是干脆不搞可视化,用树形控件或列表来配置流程。这种方案开发速度最快,但业务人员使用体验很差,因为顺序流程本身是空间化的信息,用列表表达不够直观,配置出错率也高。可视化设计器虽然前期投入多,但在配置效率和可理解性上的提升是实打实的。
1.3 自研的判断依据与实际收益
最终选择自研,核心依据就三条:技术栈统一,整个流程配置模块完全跑在 C# Winform 体系内,没有跨界集成问题;数据模型可按需裁剪,我的需求就是节点 + 连线 + 表单绑定,不需要通用 BPMN 那些复杂符号;后续维护可控,业务人员提出“这里加个平行分支”之类的需求时,改自己代码没有任何授权和封装限制。
实际做下来之后的收益也很明显。整套流程配置模块包含设计器画布、属性面板、表单绑定面板、运行时引擎和 JSON 配置持久化,累计代码量并没有想象中大,核心部分大约几千行。而且因为数据模型完全自己控制,后来客户要求流程版本升级时旧实例继续走旧流程定义,实现起来非常直接——配置里带版本号,运行实例保存一份定义快照就行。如果当时买了商业控件,这种定制需求沟通成本可能都不低。
2. 先设计数据模型,再谈画布:流程定义、节点与连接线的正确关系
很多朋友做流程图编辑器上来就画界面,结果做到一半发现节点之间的拓扑关系存不明白、序列化困难、撤销重做没法做。我的经验恰恰相反,要先花时间把数据模型设计好,UI 再难也只是表现层的问题。数据模型稳定了,后面一系列功能都会顺很多。
2.1 流程定义的三个核心类
我把流程定义抽象为三个核心对象:流程定义、流程节点、连接线。流程定义是一个根对象,包含编号、名称、版本号、节点集合、连线集合和表单绑定信息。流程节点代表流程中的一个步骤,例如开始节点、填写节点、审批节点、结束节点。连接线代表节点之间的流转方向,也就是从哪个节点到哪个节点。
下面是我实际使用的核心模型代码,精简过但保留了关键字段:
public class FlowDefinition { public string Id { get; set; } public string Name { get; set; } public int Version { get; set; } public List<FlowNode> Nodes { get; set; } = new List<FlowNode>(); public List<FlowLine> Lines { get; set; } = new List<FlowLine>(); public DateTime CreateTime { get; set; } } public class FlowNode { public string Id { get; set; } public string Name { get; set; } public FlowNodeType NodeType { get; set; } public float X { get; set; } public float Y { get; set; } public string FormId { get; set; } public Dictionary<string, string> ExtProps { get; set; } = new Dictionary<string, string>(); } public class FlowLine { public string Id { get; set; } public string FromNodeId { get; set; } public string ToNodeId { get; set; } public string Label { get; set; } public string ConditionExpression { get; set; } }FlowNodeType 用枚举定义,比如 Start、FormFill、Approval、End 等几种基础类型。字段上特别注意 X 和 Y 直接存在节点对象里,很多设计器教程会把坐标单独放一层,但在这个量级的项目里完全没必要,坐标本身就是节点属性的一部分。
2.2 为什么节点之间必须通过连线解耦,而不是直接引用下一节点
我见过不少流程配置实现,节点类里直接写一个 NextNodeId 属性,表示下一个节点是谁。这种设计写顺序流程看起来没问题,但它有四个先天毛病。第一是分支表达困难,一旦某条线需要满足条件才走,用 NextNodeId 只能实现“固定跳转”,不能表达“条件满足走 A,否则走 B”。第二是序列化容易产生环,节点互相引用时 JSON 序列化需要配置引用处理,否则要么死循环要么输出一堆重复对象。第三是画布渲染时需要额外去推导拓扑关系,无法直接遍历连线来画线。第四是之后想做流程校验、寻找入口节点、检查断链,都得多写一层逻辑。
连接线集合的出现把拓扑关系单独提出来了,这实际上是把数据结构和可视化逻辑解耦的关键一步。画布上要画连线,直接遍历 Lines;运行时引擎要推进节点,先找到当前节点发出的所有连线,再根据连线条件和节点属性决定走哪条;流程校验时检查每个节点是否可达,也是基于 Lines 做遍历。这个解耦设计让后面所有功能都变得干净。
2.3 序列化选型:JSON 配置在 Winform 项目里的实践
流程定义最终要保存成文件或者写入数据库,我选择使用 JSON,搭配 Newtonsoft.Json 进行序列化。选择 JSON 而不是 XML,核心原因是 JSON 体积小、可读性高,而且这个模型未来如果要搬到 Web 端,前端解析几乎零成本。XML 的优点是 Schema 校验成熟,但对这种配置型数据来说,JSON 的灵活性更重要。
Newtonsoft.Json 下有个容易忽略但很重要的设置:序列化时应该忽略 null 属性和默认值,不然配置里会全是"FormId":null、 "ExtProps":{} 这类噪音。另外字典类型的序列化要确认 Key 的类型可处理,我这里 ExtProps 是字符串字典,完全没有问题。保存流程定义的时候我会在根对象里放一个 schema 版本号字段,例如 SchemaVersion = 1,这样以后模型升级时可以做兼容迁移。
public static string SerializeDefinition(FlowDefinition def) { return JsonConvert.SerializeObject(def, Formatting.Indented, new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore, DefaultValueHandling = DefaultValueHandling.Ignore }); }这里有个实战细节值得提一下:序列化配置里一定要设置 Formatting.Indented。虽然压缩格式体积更小,但缩进格式能在开发环境直接肉眼检查配置内容,排查问题效率高很多。等流程定义量大到需要压缩时再切换也不迟。
3. 画布自绘方案:节点、连线和拖动的实战细节
数据模型稳定之后,UI 层就变成纯粹的表现问题了。Winform 没有 WPF 那种视觉树和绑定体系,但做流程图编辑器并不需要那些,GDI+ 自绘完全可以扛住。关键是怎么组织绘制、怎么处理命中检测、怎么让交互流畅度看起来不廉价。
3.1 用 GDI+ 自绘而不是用 UserControl 做节点的原因
初版我确实试过用 UserControl 做节点,类似拖一个圆角 Panel 上去,里面放 Label 和图标,拖动时移动控件位置。这种方案开发速度快,但很快就撞上三个问题。第一是闪烁严重,多个节点同时刷新时背景和控件边界撕裂明显,Winform 控件级的双缓冲优化效果有限。第二是缩放功能几乎没法做,控件只能改变尺寸,但内部字体、边框、锚点位置都要跟着重新计算,麻烦。第三是节点被选中时的外发光、连线聚合点等效果,需要做自定义绘制,还是绕不开 GDI+。
所以最终方案是在一个 Panel 上整体自绘,所有节点和连线都画在同一个绘图表面上。这个 Panel 开启双缓冲,重绘时按需刷新局部区域。自绘的代价是要自己实现命中检测和坐标转换,但换来的是绘制样式完全可控,画布缩放滚动的实现也简单很多。
3.2 节点拖拽、选中与属性面板的联动机制
画布的核心交互是鼠标三件套:按下、移动、释放。按下的时候做命中检测,判断命中的是节点、连线还是空白区域,并记录拖动状态;移动的时候如果正在拖动节点,就更新节点坐标并触发局部重绘;释放的时候结束拖动状态,同时通知属性面板刷新。
节点命中检测最简单的实现是遍历所有节点,判断鼠标点是否落在节点的矩形范围内。节点虽然画成圆角矩形,但命中检测用矩形范围已经足够,圆角那点偏差基本无感。连线命中检测稍微复杂一点,需要计算点到线段或贝塞尔曲线的距离,我实现时用了一个实用技巧:把曲线按参数 t 采样成 20 个点,然后计算鼠标点到这些采样点的最短距离,小于 5 像素就算选中。这个做法比解析求距离公式简单得多,效果也够用。
private void Canvas_MouseDown(object sender, MouseEventArgs e) { var canvasPt = ScreenToCanvas(e.Location); _selectedNode = HitTestNode(canvasPt); _selectedLine = _selectedNode == null ? HitTestLine(canvasPt) : null; if (_selectedNode != null) { _dragOffset = new PointF( canvasPt.X - _selectedNode.X, canvasPt.Y - _selectedNode.Y); } // 触发属性面板刷新 OnSelectionChanged(); Canvas.Invalidate(); }属性面板联动最忌讳的就是拖拽过程中高频刷新整个面板控件,容易造成 UI 卡顿和闪烁。我的做法是拖拽移动时只更新画布上的节点位置和连线路径,属性面板的坐标数值使用定时器延迟 200 毫秒刷新一次,而且只更新被选中节点的属性内容。鼠标释放后再立即做一次完整同步。这样既不会丢数据,也不会造成界面疯狂闪烁。
3.3 画布缩放与滚动:坐标反算是最大的坑
Winform 里做画布缩放最容易翻车的地方就是坐标转换。鼠标事件给出的坐标是控件客户区坐标,但绘制时如果设置了缩放变换,所有节点坐标都是逻辑画布坐标,两者之间必须转换正确。很多朋友写到这里发现“鼠标点不到节点”“拖拽时节点乱跑”,基本都是因为事件坐标没有做反变换。
GDI+ 里设置缩放的做法是修改 Graphics 的 Transform 属性,设置一个缩放矩阵。但读取鼠标位置时,需要把控件坐标转换回逻辑坐标。最简单的做法是自己维护一个画布平移偏移量和缩放系数,然后用公式手动转换,不依赖 Graphics.Transform 的反算。这套逻辑虽然多写几行,但稳定可控。
private PointF ScreenToCanvas(Point screen) { float canvasX = (screen.X - _viewOffsetX) / _zoom; float canvasY = (screen.Y - _viewOffsetY) / _zoom; return new PointF(canvasX, canvasY); }滚动我用的是 Panel 的 AutoScrollPosition,但注意它返回的是负值,使用时要取反。缩放时还要重新计算偏移量,让鼠标当前位置对应的画布坐标在缩放前后保持一致,也就是以鼠标为中心缩放,这个体验细节很影响手感。公式是:新偏移 = 鼠标控件坐标 - 旧画布坐标 * 新缩放比例。
3.4 连线交互:贝塞尔曲线、锚点吸附与防环
顺序流程的连线视觉上不需要太花哨,但也不能画成僵硬直线。我用的是三次贝塞尔曲线,起点从节点右边缘中点出发,终点到下一个节点左边缘中点,控制点根据两个节点的相对位置动态计算。如果目标节点在起始节点的右边,控制点就向中间水平延展;如果目标节点在左边,说明可能是驳回线,控制点改走向上弯曲再接过去,视觉上能明显区分正常流转和驳回路径。
锚点吸附的作用是让用户画线别画到一半松开造成断点。鼠标拖动从节点出口拉出一条临时线,移动过程中实时计算鼠标位置与所有可见节点锚点的距离,如果距离小于 15 像素,就把临时线的终点吸到该锚点上,同时给对应节点画一个高亮圈作为视觉反馈。释放鼠标时如果还在吸附范围内,就生成一条真正连线;否则取消这次连线操作。这个交互逻辑看起来小,但直接决定设计器“用起来专不专业”。
防环逻辑在保存流程定义时执行。具体做法是从开始节点出发做深度优先遍历,统计所有可达节点数量,如果出现重复访问的节点,就说明存在环,提示用户检查。顺序流程设计器默认不该存在环,允许有驳回线的场景可以做成特殊情况处理,但保存时一定要有警告。
4. 表单绑定和动态表单配置:流程配置的另外半条命
流程设计器如果只能画节点和连线,那它只是一个流程图工具,还不能叫工作流程设计器。真正让流程跑起来的是每个节点对应的表单,怎么把表单绑定到节点上,怎么配置表单字段和校验规则,这一块做不好,前面画布做得再漂亮也落不了地。
4.1 表单与节点的绑定关系设计
我这里的设计是:每个表单定义是一个独立对象,叫 FormDefinition,包含字段集合;流程节点通过 FormId 关联到一个表单定义。顺序流程的典型场景是一个节点填一张表,下一节点可能填另一张表,也可能同一张表被多个节点复用,比如发起人填写的申请单,审批节点只读展示同一张表但字段状态不同。
设计器里绑定表单的操作很简单:选中节点之后,属性面板会出现一个表单下拉框,列出所有已配置的表单定义,选中后节点会记录表单 ID。画布上节点下方还会显示一个小的表单图标和表单名称,让配置人员不用点开属性面板就能看到当前节点挂了哪张表单。
4.2 动态表单引擎:用 JSON 定义字段,运行时生成控件
表单定义我同样用 JSON 描述,每个字段包含名称、显示标题、控件类型、是否为必填、默认值、只读状态、下拉选项来源等属性。运行时设计器根据 JSON 动态生成 Winform 控件,布局用 TableLayoutPanel 按行列管理,每一行可以放 1 到 2 个字段控件。
public class FormFieldDefinition { public string FieldName { get; set; } public string DisplayName { get; set; } public string ControlType { get; set; } // TextBox, ComboBox, DateTimePicker, CheckBox public bool Required { get; set; } public string DefaultValue { get; set; } public int RowIndex { get; set; } public string OptionsJson { get; set; } }这张表从后端服务拉取,运行时逐行创建 Label 和输入控件。提交时从控件取值,写回一个 DataRow,最终保存到流程运行的数据表里。这个方案没有引入重型表单引擎,但足够支撑当前业务,而且字段配置的灵活性很高。新增一种字段控件时只需要扩展 ControlType 分支和取值逻辑。
4.3 表单校验规则的配置与执行
表单校验如果写在代码里,就违背了流程配置的初衷。我在 FormFieldDefinition 里增加了一个 ValidationRules 字段,用 JSON 表达校验规则,例如“必填”“最大长度”“正则表达式”,运行时由引擎解释执行。必填最简单,获取控件值后判空就行。正则校验要用 Regex.IsMatch。还有一个通用规则是数字范围,比如表单字段要求填写温度值小于 100,配置时加一条 MinValue=0、MaxValue=100 的规则即可。
引擎执行的代码大致是这样:
private string ValidateField(FormFieldDefinition field, Control input) { if (field.Required && string.IsNullOrWhiteSpace(input.Text)) return $"{field.DisplayName} 不能为空"; if (!string.IsNullOrEmpty(field.RegExPattern)) { var regex = new Regex(field.RegExPattern); if (!regex.IsMatch(input.Text)) return $"{field.DisplayName} 格式不正确"; } return string.Empty; }这里有一个很关键的体验问题:校验不通过时,设计器应该把出错的字段控件边框改成红色,并且把第一个错误控件滚动到可视区域。只弹 MessageBox 提示对用户不够友好,尤其表单字段多的时候用户需要自己去找是哪个字段的问题。我实现时用 Control 的 BackColor 变化来标记错误,同时记录一个错误字段列表,提交时按顺序遍历,遇到错误就聚焦第一个错误控件。
5. 运行时引擎:把设计器产物从配置变成可执行的流程
流程设计器做完,数据模型定义了,画布也能拖出流程了,但到这一步还只是完成了“配置”。要让流程真正跑起来,需要一个独立的运行时引擎来读取流程定义、推动节点流转、处理表单数据。引擎跟设计器没有 UI 关联,但共用同一套数据模型,这是整个项目里我认为最重要的架构拆分。
5.1 流程实例的状态机设计
流程实例的状态使用一个简单的状态机:Running、Pending、Waiting、Completed、Terminated。新建流程实例后状态是 Running,进入一个节点后,如果该节点需要人工操作,则状态为 Waiting,等待操作人提交表单或审批;操作完成、提交表单后引擎调用推进方法,状态回到 Running,继续走到了下一个节点。如果某个节点抛异常或人工终止,状态进入 Terminated。
每个流程实例还维护一个当前节点 ID 字段。顺序流程很简单,当前节点 ID 指向正在等待处理的节点,推进时就沿着连线集合找到下一条。
5.2 节点推进逻辑:从当前节点找到下一个节点
推进逻辑是整个运行时引擎的核心。流程实例保存当前节点 ID,Engine.Advance(instanceId) 方法先从定义里找到当前节点,再找到从该节点发出的所有连线。顺序流程里一般只有一条连线,直接沿它走到目标节点;如果存在多条连线,就检查每一条连线的 ConditionExpression,表达式求值为真的那条就是要走的路径。
表达式求值我最开始想写一个解析器,后来发现完全没必要。系统内置的 DataTable.Compute 方法可以直接计算简单的布尔表达式,例如 Age > 18 And Status == 'Approved',这已经能覆盖绝大多数条件分支场景。写法是这样:
using System.Data; private bool EvaluateCondition(string expression, DataRow row) { DataTable table = new DataTable(); DataColumn[] columns = row.Table.Columns.Cast<DataColumn>().ToArray(); foreach (var column in columns) { table.Columns.Add(column.ColumnName, column.DataType); } DataRow tempRow = table.NewRow(); foreach (var column in columns) { tempRow[column.ColumnName] = row[column.ColumnName]; } table.Rows.Add(tempRow); string expr = expression.Replace("==", "=") .Replace("&&", "And") .Replace("||", "Or"); return Convert.ToBoolean(table.Compute(expr, string.Empty)); }这里有个坑必须提醒:DataTable.Compute 的表达式语法跟 C# 不完全一样,逻辑与要用 And 而不是 &&,相等判断要用 = 而不是 ==。写配置表达式前一定要先约定规则文档,否则业务人员配出来的表达式引擎解释不了,排查起来很痛苦。
5.3 事件驱动的任务推进:为什么不需要后台定时扫描
Winform 桌面项目里推进流程有一个常见误区,就是写一个后台线程每隔几秒扫描一遍“待处理任务表”,看看有没有需要自动推进的流程。这个做法只适合极少数自动审批场景,绝大多数流程节点都需要人工参与,后台扫描不仅浪费资源,还会带来并发更新问题。
我的设计是完全事件驱动的。人工节点等待用户操作,用户在 Winform 界面上点击“提交”“同意”“驳回”按钮,事件处理器调用 Engine.Submit,内核完成当前节点数据更新、校验、推进逻辑。如果存在自动节点,比如系统自动归档、自动发送通知,该节点本身的逻辑执行完毕后直接调用 Engine.Advance 继续往下走。整个引擎没有任何定时器,谁的状态变化谁负责触发推进,简单可靠。
数据存储上我有三张核心表:流程实例表存储流程定义版本和当前节点 ID;任务表存储每个节点产生的待办任务;表单数据表存储每个表单字段的填写值,按流程实例和节点分组。这套结构简单直接,查询“某个流程当前在哪一步”只需要一条 SQL 关联。
6. 我在实际项目里反复修改过的三处:属性面板、画布性能、版本迁移
做出一个“能跑”的流程设计器只是第一步,真正让它“好用”是在后期打磨阶段。项目上线之后我反复改了三处,每一处都是真实使用中暴露的问题,这里单独拿出来讲,因为这些问题只会在长期使用中出现,新手根本预判不到。
6.1 属性面板同步时的闪烁与失控
初版属性面板用的是 Winform 自带的 PropertyGrid,拖一个控件进窗体,设置 SelectedObject 就能显示节点属性。前两周确实很爽,几乎零代码。但用到后面发现三个问题:PropertyGrid 对中文显示名的支持需要搭配 Description 特性;下拉编辑自定义表单 ID 这类属性时要写复杂的 UITypeEditor;节点对象被修改时 PropertyGrid 的刷新会闪屏,尤其是拖拽节点过程中同步坐标时。
后来我干脆不用 PropertyGrid,自己写了一个属性面板,上半部分是节点基础信息(名称、类型、表单下拉框),下半部分是根据节点类型动态生成的属性区域。这样做代码量多了一两百行,但换来了完全可控的刷新机制。我做了属性面板与选中节点的弱关联:属性面板保存的是选中节点的 ID 而不是对象引用,每次刷新时从流程定义里重新查一次节点。这样节点被删除时属性面板不会因为悬空引用而崩溃,对我这种经常在代码里动态改流程定义的人来说安全很多。
6.2 节点超过 100 个时的绘制性能优化
设计器在节点只有二三十个时流畅度完全没问题,但一旦画布中存在 100 个以上节点、200 多条连线,每次鼠标移动都触发全量重绘就会明显掉帧。我排查后发现性能瓶颈主要在三个地方:每次重绘都对所有文本执行测量计算,所有贝塞尔连线都重新计算路径,所有节点都会被重新绘制而不管是否过期。
优化方案分了三步。第一步是开启 Panel 的双缓冲,直接在构造函数里设置 DoubleBuffered = true,这个属性是 protected 的,可以通过继承 Panel 来访问。第二步是重绘时只重绘受影响的区域,鼠标拖动单个节点时先计算该节点的旧位置框和新位置框,合并成一个 Rectangle 作为重绘区域,连线跟随节点移动时只重绘该节点关联的连线。第三步是节点数量多的画布用一个后台 Bitmap 做缓存,只有节点位置或缩放级别变化时才重新绘制整个背景,鼠标悬停等高频事件只在缓存图之上绘制临时状态。
这里最容易忽略的是文本测量的开销。每次重绘如果都要用 Graphics.MeasureString 计算 Label 宽度,100 个节点就是 100 次字体测量,耗时相当可观。我的优化是节点 Text 变化时才重新测量并缓存宽度值,重绘时直接读缓存。这个优化在执行效果上立竿见影,100 个节点拖拽时的帧率能提升一倍以上。
6.3 流程版本的兼容与旧实例的继续运行
流程设计器部署到生产环境之后,业务人员一定会修改流程定义,这时候版本问题就浮现出来了。最典型的一个场景:某个工单流程已经跑到第三步,业务人员觉得第四步审批人不对,直接在设计器里改了流程并保存。如果新定义直接覆盖旧定义,正在运行的实例再推进时就会按新定义执行,大概率出现“第三步还没填完、第四步已经被改成别人审批”的混乱。
应对方案是生产环境流程定义全部带版本号,每一次修改保存都生成新版本。流程实例保存时记录它使用的 FlowDefinitionId 和 Version,并且在流程启动时把定义序列化后的 JSON 完整保存在流程实例表里。后续该实例推进时,读取的一律是实例表中存的定义副本,而不是当前最新版本。用空间换可靠性,这点代价完全值得。新实例启动时才加载最新版本定义,实现“旧实例走旧流程、新实例走新流程”的效果。
这个设计在代码上只增加了一个字段和一个优先读副本的控制分支,但运维价值极大。有过生产环境流程升级经验的朋友应该懂,这种细节才是真正避免线上事故的保险丝。
7. 再往前走一步:从顺序流程到条件分支、会签与多端联动
顺序流程设计器做到这里已经能覆盖大部分表单流转场景,但“灵活的流程配置”这几个字不能只停留在字面意思。项目上线几个月后,业务方开始提下一类需求:能不能在同一条流程里走不同分支?能不能一个节点多人会签?能不能在浏览器里也能查看流程图?我把这些扩展方向逐个拆解了一下,发现基于现有模型做扩展并不是难事。
7.1 条件分支的落地方式:连线表达式与节点分组
顺序流程加条件分支,本质上就是用 FlowLine 上的 ConditionExpression 来表达“满足条件走这条线”的逻辑。我在运行时引擎里已经实现了表达式求值,所以这一步的主要工作是画布端支持一个节点拖出多条连线,并在属性面板为每条连线配置表达式。
具体到节点类型,我新增了一个路由节点,外观类似菱形。路由节点可以有 2 到 5 条出口连线,每条连线配置一个条件表达式。运行时引擎从左到右遍历出口连线,表达式为真的那条就作为后续路径。如果没有表达式为真的连线,则走默认线,默认线就是表达式为空的哪条。这个设计在保持顺序流程模型不变的前提下,硬塞进了条件分支能力,业务配置人员理解成本也不高。
7.2 会签与或签的三种处理方式
会签需求通常来自审批场景,比如一笔报销单需要财务和部门经理都审批通过才算通过。这个需求有三种落地方式,我实际都用过,分别对应不同场景。
方式一最朴素:把这几个审批角色配置成顺序排列的几个节点,例如“财务审批 → 部门经理审批”,流程顺序上相当于是串行会签。好处是模型完全不用变,坏处是当审批人很多时流程定义的节点数会膨胀。
方式二在节点上加一个“审批人列表”配置,节点内部要求所有被指定的审批人都提交后,节点才算完成。这需要扩展运行时引擎,原本一个节点对应一条任务,改成节点维护多条子任务,全部完成后节点推进。编码上没有质变,但 UI 上需要增加子任务列表展示,流程历史记录里要能看得清每一个人分别提交了什么。
方式三最省事:不在工作流引擎层处理会签,业务表单和业务状态自己维护审批明细表,工作流只负责推进一个大阶段。这个方案适合业务方对会签逻辑有自己的特殊规则,比如“超时不审批自动通过”“一票否决”,硬在引擎里做反而把系统搞复杂。
我的经验是:如果会签规则是标准全员通过,用方式二;如果会签角色多且规则特殊,用方式三,不要试图把业务规则全部塞进工作流引擎里。引擎负责控制流程骨架,业务规则留出扩展点,这个边界要清晰。
7.3 Winform 设计器与 Web 端查看器的数据兼容
业务方往往会问一句:“有没有网页版的流程图能看?”这个问题背后的真实需求通常是领导想看流程进度,或者外部人员不在内网环境但又需要了解流程状态。我的建议是不要急着把整个设计器搬到 Web 上去,因为 Winform 在局域网桌面场景下的开发效率仍然是杀手锏,先做好两端数据格式统一就可以满足 90% 的需求。
由于流程定义本身就是 JSON 序列化的结构化数据,Web 端只需要一个只读流程图渲染器,读取同一个 FlowDefinition 对象,用 Canvas 或者 SVG 把节点和连线画出来。节点位置、节点名称、连线标签都是现成的,甚至流程实例的当前节点位置只需要标记一下当前节点 ID。整个查看器前端代码量不大,却能给业务方带来“有产品深度”的好印象。
如果真要做 Web 端完整设计器,数据模型完全不用改,前端重新实现画布交互逻辑即可。这也正是当初选择 JSON 作为序列化格式的前瞻性收益。前后端共享的不只是格式,而是整套流程定义的语言。
做这个流程设计器的过程让我再次确信一件事:C# Winform 在桌面工具链里远没有过时。它适合流程配置、内部工具、工单系统这类“数据模型明确、交互以效率和稳定为先”的桌面场景。只要守住模型和 UI 解耦这条底线,哪怕后来 Web 端冒出来,也不会伤筋动骨。最后再分享一个小经验,如果你也打算自研 Winform 流程设计器,优先把数据模型和序列化层写好,画布再炫都不如这份底子值钱。后面所有功能,包括条件分支、表单引擎、版本管理,都会因为这一步做得稳而变得异常轻松。