多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

C# Winform可视化流程设计器:从硬编码到配置化的工作流改造

C# Winform可视化流程设计器:从硬编码到配置化的工作流改造 做桌面客户端的兄弟应该都有这种感觉需求方眼里的“流程很简单”落到代码里就是一场噩梦。最早我接一个工单流转项目流程图就是“填单 → 组长审批 → 经理审批 → 归档”看起来连实习生都能画出来可客户在验收前连续改了三次节点一会儿要加一个会签角色一会儿要把审批顺序对调一会儿又要跳过某个节点直达下一步。如果流程是直接写死在按钮点击事件里的每改一次就要改代码、重新编译、分发安装包整个团队跟着一起加班到深夜。后来我把这套逻辑彻底抽出来做成一个 C# Winform 的表单顺序工作流程设计器核心思路是用可视化配置替代硬编码流程把“表单”和“顺序执行”这两个核心要素组合起来让流程变化只改配置、不动代码。这篇文章就把这个设计器从需求拆解到架构设计、再到核心代码实现完整复盘一遍适合正在做 OA 审批、工单流转、质检登记、设备巡检这类工具系统的读者参考。1. 为什么需要自研流程设计器从写死代码到可视化配置1.1 写死流程的典型痛点先说一个我早期真实的项目状态。那时候接了一个小型工单系统需求不复杂流程就写在button_Click事件里private void btnSubmit_Click(object sender, EventArgs e) { if (currentUser.IsManager) { // 经理直接审批通过 SaveOrder(order); SetOrderStatus(order, 已归档); } else { // 先提交给组长 SetOrderStatus(order, 待组长审批); SendNotify(组长); } }看着挺清晰但客户一改需求就崩了。比如“工单金额超过 5000 元需要先走经理审批否则组长审批即可”这种条件分支刚加进去还凑合等到“超过 5000 且客户是VIP客户还要加一道财务确认”事件里就全是if嵌套代码变成了谁也看不懂的意大利面条。写死流程真正的痛点有三个第一是流程与业务代码耦合流程规则散落在各个按钮事件和判断语句里没有全局模型改一个节点像拆炸弹第二是改流程必须重新发布桌面程序用户遍布多个办公室每次更新安装包都是运营事故第三是客户看不到流程全貌业务人员没法自己验证流程逻辑只能反复找开发确认“现在走到哪一步了”。1.2 顺序流程的真实业务场景很多人一听到“工作流”就联想到 JBPM、Activiti 那些重量级框架其实大多数企业内部系统的流程根本用不到那么复杂。我梳理过自己经手的项目真正高频出现的都是表单顺序流程请假审批员工填单 → 主管审批 → 经理审批 → 人事备案工单流转创建工单 → 技术员处理 → 组长验收 → 归档质检登记录入样品 → 检测 → 审核结果 → 出具报告设备巡检扫码开始 → 填写巡检项 → 异常上报 → 主管确认这类流程的共同特征是节点之间有固定的先后顺序每个节点基本上就是打开一张表单、填写或审批、然后点“下一步”。偶尔有跳转也是基于表单某个字段值的简单判断比如“检测结果不合格就跳到复检节点”。这些场景用不到并行网关、子流程、会签计数器那套重型机制一个轻量的顺序引擎完全够用而且更容易让客户理解。1.3 为什么不直接用现成的 BPMN 框架我也认真评估过引入开源工作流引擎的方案比如 Workflow Core、Elsa 这类库。它们功能确实强大可视化设计器、节点库、持久化一应俱全但放到 Winform 场景里存在几个现实问题一是学习成本高。团队成员要理解 BPMN 的网关、泳道、边界事件光概念培训就得一到两周。二是集成方式重。这些库大多面向 Web 或者宿主服务在桌面应用里嵌入设计器需要做大量适配。三是序列化结构复杂。生成的流程定义 XML 动辄上百行出了问题客户看不懂我们自己排查也头疼。自研的顺序流程引擎需求边界非常清晰节点就是“表单 跳转条件”流程就是“一个有向无环的顺序链”。按这个边界去做设计器和引擎加起来两到三周就能跑通后续维护成本极低。这个“够用就好”的取舍是整篇设计的起点。2. 架构拆解引擎、设计器与配置存储三件套2.1 三个核心模块的职责划分整个设计器我拆成了三个互相独立的模块它们只通过流程配置模型交互界面和引擎之间完全解耦模块职责核心关注点WorkflowDesigner 设计器可视化画布、节点拖拽、连线、属性编辑、保存和加载配置交互体验、序列化正确性WorkflowEngine 引擎加载流程配置、推进节点、执行条件跳转、抛出流转事件状态机、稳定性、可测试性FormHost 表单宿主根据流程节点配置动态加载对应的 Winform 窗体负责字段读写、校验与业务窗体解耦、字段映射这个划分最大的好处是设计时和运行时共用同一套流程定义模型。你在设计器上画出来的节点保存成 JSON运行时引擎读同一份 JSON加载对应的表单窗体按顺序推进。设计器不需要知道引擎怎么跑引擎也不需要关心画布上的点线面。2.2 流程节点的数据模型节点模型是整套设计的地基我把它定义成下面这个样子public enum StepNodeType { Start, // 开始节点 Form, // 表单填写节点 Approve, // 审批节点 Condition, // 条件判断节点 End // 结束节点 } public class StepNode { public string NodeId { get; set; } // 全局唯一ID public string Name { get; set; } // 节点显示名称 public StepNodeType NodeType { get; set; } public string FormKey { get; set; } // 要加载的表单标识 public Liststring NextNodeIds { get; set; } // 下一跳节点ID列表 public string ConditionExpression { get; set; } // 条件表达式可选 public int PositionX { get; set; } // 画布上的坐标X public int PositionY { get; set; } // 画布上的坐标Y } public class WorkflowDefinition { public string Name { get; set; } public string Version { get; set; } public ListStepNode Nodes { get; set; } }这里有一个反直觉但很实用的设计我把条件判断也做成了一种节点。有些人会倾向于在“表单节点”上加条件表达式让一个节点决定下一个节点是谁。但我实测下来独立的条件节点可视化更清晰业务人员在画布上一眼就能看出“什么情况走哪条线”。后面的例子里你会看到请假流程超过 3 天就走经理审批就是靠这种条件节点实现的。2.3 配置持久化方案的取舍流程配置最终要存下来我分别在两个项目里用到了两种方式各有适用场景。JSON 文件方案适合单机或局域网共享文件夹部署var json JsonSerializer.Serialize(definition, options); File.WriteAllText(workflow_leave.json, json);好处是配置天然可读、可复制、可放到 Git 里面做版本管理坏处是多人同时改容易冲突而且没有权限控制。数据库方案适合集中管理我建议至少建两张表表名字段说明WF_DefinitionId, Name, Version, ContentJson, UpdateTime流程主体ContentJson 存整个流程定义的 JSONWF_InstanceId, DefId, CurrentNodeId, Status, BizDataJson, CreateTime运行时实例BizDataJson 存表单业务数据数据库方案解决了两件事一是多个客户端能读到同一份最新配置二是给了流程实例一个持久化容器程序重启后还能从断点继续。实际项目中我通常把两张表都建上JSON 文件负责设计器的离线编辑数据库负责运行时的配置分发和实例管理。如果客户端和数据库在同一局域网读取流程定义可以做个简单的内存缓存避免每次流转都查库。3. 表单与流程绑定设计器真正的难点3.1 表单控件的统一注册与管理画节点、连线做久了就会发现流程设计器里最复杂的不是画布而是表单与节点的绑定。每个节点要打开什么窗体、窗体上的字段怎么和流程数据打通这个问题不解决设计器就是个空壳。我采取的做法是给表单上的控件统一加上Tag属性把它作为字段标识。比如一个“请假单”窗体上txtLeaveDays的Tag设为LeaveDayscboLeaveType的Tag设为LeaveType。加载窗体时用一个工具方法把控件按 Tag 收集到一个字典里public static Dictionarystring, Control CollectFields(Control parent) { var result new Dictionarystring, Control(); foreach (Control c in parent.Controls) { if (!string.IsNullOrEmpty(c.Tag?.ToString())) { result[c.Tag.ToString()] c; } // 递归处理容器控件比如 Panel、GroupBox if (c.HasChildren) { var childFields CollectFields(c); foreach (var kvp in childFields) result.TryAdd(kvp.Key, kvp.Value); } } return result; }注意一定要递归遍历容器控件。Winform 里GroupBox、Panel、TabControl都是容器如果只遍历顶层控件字段收集会漏掉一大半。这个坑我踩过好几次测试表单的时候总有几个字段读写没反应排查半天才发现是控件套在了容器的第二层。3.2 引擎与表单交互的接口约定有了字段字典引擎如何驱动任意表单窗体我定义了一个IFormNode接口所有业务表单窗体都实现它public interface IFormNode { string FormKey { get; } void LoadData(Dictionarystring, object bizData); // 回显数据 bool ValidateForm(out string errorMsg); // 校验数据 Dictionarystring, object CollectData(); // 收集数据 void SetReadOnly(bool readOnly); // 审批节点设为只读 }引擎拿到一个节点先根据FormKey在窗体类型字典里找到对应的Type反射创建窗体然后把它变成IFormNode来操作。这样做的好处是业务窗体对引擎零感知它只是老老实实实现自己的IFormNode至于自己是被设计器拖上去的、还是被流程引擎直接打开的完全不关心。这种面向接口的设计让新表单接入成本降到最低写个窗体、实现接口、注册到字典完事。字段类型上有一个小细节要注意CollectData返回的字典值最好统一转成字符串或者用强类型包一层。因为流程条件表达式计算、日志展示、跨窗体传递字符串是最方便的中间格式。等到真的要做数字比较的时候再在条件解析器里转成decimal。3.3 条件表达式让流程不再只会走直线纯顺序流程是“一条道走到黑”现实业务不接受这种死板。所以我给条件节点加了一个表达式字段最经典的需求就是“请假天数大于 3 天走经理审批否则主管直接通过”。表达式解析我用的是最轻量的方案把表单字段值替换进表达式然后交给DataTable.Compute计算。核心代码就几行public static bool Evaluate(string expression, Dictionarystring, object bizData) { var exp expression; foreach (var kvp in bizData) { if (decimal.TryParse(kvp.Value?.ToString(), out var num)) { // 数字字段直接替换 exp exp.Replace({ kvp.Key }, num.ToString()); } else { // 字符串字段加引号 exp exp.Replace({ kvp.Key }, kvp.Value ); } } var dt new DataTable(); var result dt.Compute(exp, ); return (bool)result; }配置的时候业务人员只需要在条件节点的表达式框里写{LeaveDays} 3这种易读的文本。DataTable.Compute支持加减乘除、比较运算符对于“金额大于 5000 且部门不是人事部”这种需求也可以先拆成两个条件节点串联不受能力限制。这里要提醒一句DataTable.Compute不支持、||这样的逻辑运算符。第一次用的时候我直接在表达式里写{Amount} 5000 {Level} VIP结果抛异常。解决办法就两个要么拆成多个条件节点要么自己写一个十几行的简单解析器把逻辑运算也做进去。多数情况下拆节点就够了而且流程图画出来更直观。4. 核心实现引擎推进与画布交互4.1 流程引擎的状态机与推进逻辑引擎是整套系统的幕后大脑。我把每个流程实例的状态定义为五种public enum WorkflowStatus { Waiting, // 等待启动 Running, // 运行中 Passed, // 已通过 Rejected, // 已驳回 Cancelled // 已取消 }引擎推进的核心方法长这样public class WorkflowEngine { private readonly WorkflowDefinition _definition; private IWorkflowRepository _repository; public async TaskStepNode RunAsync(string instanceId, string currentNodeId, Dictionarystring, object bizData, bool approved) { var instance _repository.Load(instanceId); var currentNode _definition.Nodes.First(n n.NodeId currentNodeId); if (!approved) { // 驳回回到上一个审批节点这里简化为直接回退到开始节点 instance.Status WorkflowStatus.Rejected; instance.CurrentNodeId _definition.Nodes .First(n n.NodeType StepNodeType.Start).NodeId; _repository.Save(instance); return null; } StepNode nextNode null; // 条件节点按表达式跳转 if (currentNode.NodeType StepNodeType.Condition) { var targetId ExpressionEvaluator.Evaluate(currentNode.ConditionExpression, bizData) ? currentNode.NextNodeIds[0] : currentNode.NextNodeIds[1]; nextNode _definition.Nodes.First(n n.NodeId targetId); } else { // 普通节点取第一跳 var nextId currentNode.NextNodeIds.FirstOrDefault(); if (nextId null) { instance.Status WorkflowStatus.Passed; _repository.Save(instance); return null; } nextNode _definition.Nodes.First(n n.NodeId nextId); } instance.CurrentNodeId nextNode.NodeId; instance.Status WorkflowStatus.Running; _repository.Save(instance); return nextNode; } }引擎设计的原则是纯逻辑、无 UI。它只负责输入当前节点、业务数据、审批结果输出下一个节点所有状态的变更都持久化到仓储里。正因为引擎不依赖窗体、不依赖画布我可以在单元测试里直接写几个节点定义来验证流转逻辑这一点务必坚持否则后面扩展条件分支时一定会被各种 UI 状态干扰。4.2 画布拖拽、连线和节点编辑设计器的画布我做成了一个自定义控件底层就是一个Panel节点是一个个UserControl。拖拽的核心逻辑是处理鼠标事件的坐标换算private void canvas_MouseDown(object sender, MouseEventArgs e) { if (e.Button MouseButtons.Left) { _draggingNode GetNodeAt(e.Location); _lastPoint e.Location; } } private void canvas_MouseMove(object sender, MouseEventArgs e) { if (_draggingNode ! null) { var dx e.X - _lastPoint.X; var dy e.Y - _lastPoint.Y; _draggingNode.Location new Point( _draggingNode.Location.X dx, _draggingNode.Location.Y dy); _lastPoint e.Location; canvas.Invalidate(); // 触发重绘更新连线 } } private void canvas_MouseUp(object sender, MouseEventArgs e) { _draggingNode null; }连线用 GDI 绘制在画布的Paint事件里完成。两个节点之间用一条直线或简单的贝塞尔曲线连接曲线两端锚定在节点中心点。绘制的时候我会额外加一个输出锚点和输入锚点这样连线的方向感更强节点多了不会看岔眼。保存设计器内容时有一个特别容易踩的坑不要把节点控件本身直接序列化。控件上有大量 Winform 运行时属性序列化会引入一堆无用信息甚至循环引用。我采用的做法是先把画布上的控件遍历一遍转换成StepNodeDTO 列表再序列化这个列表。加载时反序列化 DTO再根据坐标重新生成控件。这个“界面对象”和“数据模型”分离的思路是设计器稳定运行的关键。4.3 多线程执行与 UI 更新的协作Winform 的流程引擎在实战中经常会遇到“后台处理节点逻辑同时更新界面”的需求比如审批通过后自动调用第三方接口校验、自动发送提醒消息。这些操作放在 UI 线程里会卡界面用户一拖动窗口就转圈体验很差。我用Task.Run把引擎的推进逻辑丢到后台执行然后通过SynchronizationContext回到 UI 线程private async void btnNext_Click(object sender, EventArgs e) { btnNext.Enabled false; try { var nextNode await Task.Run(() { return _engine.RunAsync(_instanceId, _currentNodeId, _bizData, true); }); if (nextNode ! null) { await Task.Run(() Thread.Sleep(200)); // 模拟异步审批通知发送 OpenNodeForm(nextNode); } else { MessageBox.Show(流程已全部完成); } } catch (Exception ex) { LogHelper.WriteError(ex); } finally { btnNext.Enabled true; } }关键点是任务完成后所有 UI 操作都发生在await之后的上下文里这个上下文默认就是 UI 线程因此可以直接访问控件。如果你用的是ContinueWith而不是await记得传入TaskScheduler.FromCurrentSynchronizationContext()否则回调跑在线程池线程上一旦操作控件就会抛“跨线程访问”异常。这个异常是 Winform 多线程开发最常见的三大坑之一我后面会专门整理排查表。5. 实操记录一个请假审批流程从配置到跑通5.1 搭建设计器主界面现在从头演示一遍我新建了一个 Winform 项目目标框架选.NET 8.0旧项目用.NET Framework 4.7.2也完全适用。窗体左侧放一个工具箱ListBox里面列出四种节点类型开始、表单、条件、结束中间是画布Panel背景色设成淡灰色右侧放PropertyGrid选中节点后直接编辑属性。工具箱到画布的交互方式是从ListBox拖拽一个节点类型到画布DragDrop事件里根据类型创建对应的NodeControl。private void canvas_DragDrop(object sender, DragEventArgs e) { var nodeType (StepNodeType)e.Data.GetData(typeof(StepNodeType)); var pos canvas.PointToClient(new Point(e.X, e.Y)); var control new NodeControl { Node new StepNode { NodeId Guid.NewGuid().ToString(N), NodeType nodeType, Name nodeType.ToString(), PositionX pos.X, PositionY pos.Y }, Location pos }; canvas.Controls.Add(control); }NodeControl是一个自定义 UserControl上面显示节点名称和一个小图标。双击节点可以打开表达式编辑框单击节点右侧属性面板会绑定到它的Node属性。5.2 配置一个三节点请假流程配置过程完全可视化。我从工具箱依次拖出“开始”“表单”“条件”“表单”“审批”“结束”节点先做一个最简单的版本开始节点发起流程LeaveForm 表单节点填写请假单包含LeaveDays字段Condition 条件节点表达式为{LeaveDays} 3ManagerApprove 表单节点条件成立时走经理审批End 结束节点点“保存”按钮后流程定义序列化成 JSON文件内容大概是{ Name: 请假审批流程, Version: 1.0.0, Nodes: [ { NodeId: n1, Name: 开始, NodeType: 0, NextNodeIds: [n2] }, { NodeId: n2, Name: 填写请假单, NodeType: 1, FormKey: LeaveForm, NextNodeIds: [n3] }, { NodeId: n3, Name: 是否超过3天, NodeType: 3, ConditionExpression: {LeaveDays} 3, NextNodeIds: [n4, n5] }, { NodeId: n4, Name: 经理审批, NodeType: 2, FormKey: ManagerApproveForm, NextNodeIds: [n6] }, { NodeId: n5, Name: 主管审批, NodeType: 2, FormKey: SupervisorApproveForm, NextNodeIds: [n6] }, { NodeId: n6, Name: 结束, NodeType: 4 } ] }这个 JSON 就是设计器和引擎之间的桥梁。特意强调一下配置里每个节点的NextNodeIds存的是节点 ID 列表而不是名字。名字可以重复ID 必须唯一否则连线一旦重名就会串位。这个习惯我从一开始就养成了后面流程复杂到三十几个节点时ID 唯一性保证了整个系统的稳定性。5.3 运行时执行与断点调试运行时启动一个“启动流程”按钮程序读取请假审批流程的 JSON实例化WorkflowEngine然后从开始节点推进。为了能直观看到每一步的流转我在引擎里定义了一个事件public event EventHandlerWorkflowEventArgs StatusChanged; public class WorkflowEventArgs : EventArgs { public StepNode CurrentNode { get; set; } public StepNode NextNode { get; set; } public WorkflowStatus Status { get; set; } }UI 上挂一个日志文本框订阅这个事件每走一步就把节点流转记录打印出来[14:02:01] 流程启动开始节点 [14:02:05] 打开表单填写请假单 [14:02:31] 表单提交请假天数5 [14:02:31] 条件节点判断{LeaveDays} 3 → True [14:02:31] 跳转至经理审批 [14:02:46] 审批通过 [14:02:46] 流程结束这个事件日志帮了大忙。客户说“流程走错了”我不用一行一行读代码直接把日志导出来看节点轨迹是条件判断错了还是表单数据传丢了一眼定位。调试阶段我还会在条件节点后面临时加一个MessageBox.Show(表达式计算结果)确认表达式的运算结果是否符合预期调试完再删掉。6. 常见问题与排查技巧实录6.1 高频问题速查表这个表是基于我自己和身边同事在类似项目中踩过的坑整理出来的值得截图收藏现象根本原因排查与解决拖节点到画布没反应工具箱没有设置允许拖拽或 DragDrop 事件没订阅检查ListBox.AllowDrop true和canvas.AllowDrop true再确认DragEnter里设置了e.Effect DragDropEffects.Move连线位置错乱节点移动时只更新了控件 Location没有同步Node.PositionX/Y在NodeControl的Move事件里同步更新Node的坐标并调用父画布Invalidate()JSON 反序列化报错把画布上的 UserControl 直接序列化了引入了控件递归引用只序列化StepNodeDTO 列表不序列化控件本体序列化时设置ReferenceHandler.IgnoreCycles跨线程访问控件异常在Task或线程池中直接操作了 UI 控件用await让回调回到 UI 上下文或Invoke((Action)(() ...))包裹控件操作流程卡在某个节点不往下跳NextNodeIds为空或者节点 ID 指向了不存在的节点在引擎初始化时做一次静态校验遍历所有节点确认每个非结束节点的 NextNodeIds 都能在定义中找到表单字段读不到值Tag没设置或者控件在容器内部没有被递归遍历使用前面写的递归CollectFields方法并检查表单控件是否真的设置了Tag条件表达式的字段值总是为空CollectData时没有把对应控件加入字典给字段控件设置Tag后最好在LoadData里做一次字典 Key 存在性校验抛异常时快速提示DataTable.Compute 不支持逻辑运算符、6.2 设计器的实操心得与避坑第一节点坐标一定要考虑 DPI 缩放。很多办公电脑默认 125% 或 150% 缩放如果画布不处理 DPI拖出来的节点位置会偏保存后再加载位置又错位。我的处理方式是在设计器窗体启动时调用AutoScaleDimensions相关设置或者在保存坐标时除以当前 DPI 缩放比例加载时再乘回去。这个细节不做高分屏用户会第一时间吐槽。第二属性面板用 PropertyGrid 非常划算。一开始我打算自己写属性编辑界面给每个节点类型定制表单后来发现完全没必要。PropertyGrid可以直接绑定StepNode对象自动显示所有公开属性Name、FormKey、ConditionExpression、NextNodeIds都能编辑还支持下拉选择枚举。用这种系统组件省掉了大量界面代码我自己只写了一个TypeConverter让NextNodeIds在面板里显示得友好一点。第三保存时多做一步完整性校验。节点没有出口、条件节点缺少两个跳转目标、结束节点还有后续节点这些情况都要在设计器里就拦截。我写了一个ValidateDefinition方法在用户点击“保存”按钮时走一遍有错就弹窗提示并定位到问题节点。没有这层校验坏配置被引擎加载时往往要跑起来才会报错排查成本高得多。6.3 几个值得继续扩展的方向这个设计器做到能用的程度只需要三步节点拖拽、顺序推进、表单绑定。但如果项目要继续深入有几个方向是我实际体会中最值得投入的驳回重做现在简化实现是驳回直接回到开始节点扩展成“回到上一个审批节点”需要为实例记录节点轨迹栈。实现不复杂把RunAsync里的节点变化历史存下来即可。定时提醒节点流转时开启一个Timer超过 24 小时没有操作就自动提醒这个需求在审批场景里几乎是刚需。流程版本管理配置保存时记录版本号实例启动时锁定版本避免流程中途升级导致节点错乱。敏感代码保护设计器里写的表达式、表单映射都是核心业务资产发布前可以用混淆工具处理程序集降低被直接反编译拿走的概率。我个人在实际操作中的体会是这个设计器最划算的投入点是表单控件统一注册。只要把IFormNode接口和Tag约定做好后面的流程引擎、条件节点、设计器交互都会顺势变得非常顺。如果你的项目里有一堆静态表单和反复修改的流程逻辑不妨先从最小可用的版本做起一个画布、一个引擎、两个表单跑通之后你自然知道下一步该加什么。最后分享一个小技巧表单字段的Tag命名统一用“实体字段名”比如LeaveDays、ApplicantName配置条件表达式时直接写Tag名即可这样配置人员和表单开发之间不需要翻译直接看代码就能对上号。
返回列表