先交代一下背景:年初接了一个OA审批类的项目,其中一块核心需求是让业务人员自己配置审批流程和表单,而不是每次改动都找开发改代码。调研了一圈市面上的工作流组件,要么贵得离谱,要么跟项目技术栈绑定太死,要么就是重得杀鸡用牛刀。最后决定基于 C# WinForm 自己写一个轻量级的工作流表单设计器,支持流程节点拖拽绘制、表单字段可视化配置、流程与表单联动绑定。这篇文章就把这个项目的功能设计、核心实现思路和踩掉的坑完整梳理一遍,给正在做类似需求的同行一个参考。
这套设计器最终实现的功能大概是这样:左侧是流程节点工具箱和表单控件面板,中间是流程图绘制画布,支持开始、审批、条件、抄送、结束等节点的拖拽放置,节点之间可以连线绘制流转路径;右侧是选中节点的属性面板,可以配置审批人角色、条件分支规则、绑定的表单字段和字段权限。表单设计器部分支持文本框、下拉框、日期、附件、数字等常见控件拖拽布局,每个控件可以绑定到流程节点,实现"不同节点看到不同字段、不同编辑权限"的效果。整体项目用 VS2015 环境开发,框架基于 .NET Framework 4.5,纯 GDI+ 自行绘制流程图和表单布局,没有依赖任何第三方图形控件。
1. 为什么放着现成的第三方组件不用,偏要自己写设计器
1.1 业务需求的真实起点
项目最初提的需求其实很简单:一张请假单,从提交人发起,经过部门经理审批、人事复核,最后归档结束。但随着业务部门把报销、用章、采购申请全部往这个系统里搬,需求迅速膨胀成了"任意流程任意表单"的通用平台。这时候再靠硬编码页面已经撑不住了,必须有一个能让实施人员可视化配置流程和表单的工具。
当时面临三条路:一是采购成熟商业工作流产品,功能全,但价格高、二次开发受限、部署方式不灵活;二是用开源工作流引擎(比如 ccflow、RoadFlow 这类),引擎本身可用,但要嵌入现有业务系统还得做大量适配,而且它们的表单设计器大多基于 Web,跟我们 WinForm 的桌面端架构格格不入;第三条路就是自研一个轻量级的可视化管理工具,只解决"流程画得出来、表单配得出来、跑得起来"这三个核心问题。
我选了第三条路。原因很实际:项目本身对流程引擎的要求并不高,不需要复杂的会签、或签、子流程嵌套,主要是线性审批和简单条件分支,自研设计器加一个轻量级推进引擎完全够用,而且整个设计器工具以后可以直接打包给实施人员使用,不占用系统运行时的资源。真实项目里,80% 的流程需求都是这种"串行审批加少量分支"的模式,过度设计才是最大的风险。
1.2 自研方案与第三方组件的取舍对比
| 对比维度 | 商业表单工作流组件 | 开源Web工作流引擎 | 自研WinForm设计器 |
|---|---|---|---|
| 采购/使用成本 | 高,按人头或按部署收费 | 免费,但后续维护靠自己 | 一次性开发成本,复用率高 |
| 架构匹配度 | 需要适配桥接 | 多为Web架构,与桌面端不搭 | 原生WinForm,完美融合 |
| 二次开发灵活度 | 受厂商接口限制 | 较高,但源码复杂 | 完全可控,想改就改 |
| 图表形式要求 | 多为Web流程图 | 多在网页中展示 | 桌面端流程图,打印导出方便 |
| 实施门槛 | 需要专人培训 | 需要熟悉其配置方式 | 内部工具,按自己习惯设计 |
这个对比表不是写代码时才想的,是在立项阶段就给客户汇报过的。我的想法是:工作流表单设计器本质上是给实施人员和管理员用的"生产力工具",它的核心价值不是把流程引擎做得多么复杂,而是把常规的审批流程配置时间从按天计算压缩到按小时计算。自研设计器哪怕前期投入一周时间,只要后面能节省每个项目的实施成本,这笔账就是划算的。
2. 设计器的整体架构与核心数据结构设计
2.1 三个核心模块的职责划分
整个设计器按功能拆成了三个相对独立的模块:流程图绘制模块、表单设计模块、属性配置与序列化模块。这三个模块虽然是三个不同的界面区域,但必须共享同一套数据模型,否则后面做节点与表单绑定的时候会很难受。
流程图绘制模块负责在画布上渲染节点(矩形或圆角矩形框)和有向连线,处理拖拽放置、连线交互、节点选中、框选移动等操作。表单设计模块维护一个"画布表单区",业务人员从控件面板拖文本框、日期选择器等到表单画布上自由摆放,表单画布就是一个可视化的表单布局工具。属性配置与序列化模块统一管理所有对象的属性(节点属性、连线属性、控件属性等),并把整个设计成果序列化为可落盘的文件数据,运行时的引擎从这个文件里读取流程和表单定义。
这三个模块之间通过一个DesignerDocument对象串联。这个对象持有流程节点列表、连线列表、表单控件列表以及节点与控件的绑定关系。所有界面操作最终都是对DesignerDocument的增删改查,画布重绘时也只是根据内存中的数据重新GDI+绘制。
2.2 流程节点与表单字段的数据结构
这是我的核心设计部分。先给出一段能直接参考的节点定义:
// 节点类型 public enum NodeType { StartNode, // 开始节点 ApproveNode, // 审批节点 ConditionNode, // 条件分支节点 CopyNode, // 抄送节点 EndNode // 结束节点 } // 流程节点实体 public class FlowNode { public string NodeId { get; set; } // 全局唯一ID public string Name { get; set; } // 节点显示名称 public NodeType NodeType { get; set; } // 节点类型 public RectangleF Bounds { get; set; } // 节点在画布上的位置和尺寸 public List<string> ApproverRoles { get; set; } // 审批角色集合,审批节点用到 public string ConditionExpression { get; set; } // 条件表达式,条件节点用到 public List<FieldBinding> FieldBindings { get; set; } // 绑定的表单字段权限 }表单控件这边的结构是这样的:
// 表单控件实体 public class FormControl { public string ControlId { get; set; } // 全局唯一ID public string Label { get; set; } // 字段标题 public string FieldName { get; set; } // 数据字段名 public ControlType CtrlType { get; set; }// 控件类型 public RectangleF Bounds { get; set; } // 控件在表单画布上的位置和大小 public bool IsRequired { get; set; } // 是否必填 } // 节点与表单字段的绑定关系 public class FieldBinding { public string ControlId { get; set; } // 表单控件ID public bool IsEditable { get; set; } // 当前节点下该字段是否可编辑 public bool IsVisible { get; set; } // 当前节点下该字段是否可见 }为什么把节点和表单字段的绑定拆成一个单独的FieldBinding列表挂到流程节点上?这是从实际业务里逼出来的设计。报销单在发起节点需要填写金额和事由,到了部门经理节点金额和事由变成只读,同时新增一个"审批意见"字段;到了财务节点,金额重新变成可编辑,还要追加"付款方式"字段。每个节点对同一批表单字段的可见性和可编辑性是不同的。如果表单控件上设计一套全局的权限规则,根本表达不了这种多节点差异化需求。把绑定关系挂在流程节点上最直接,运行时引擎只要拿到当前节点,查一下它的FieldBindings就知道要渲染哪些字段、字段是否可编辑。
顺带一个经验:节点的连线不能只存"从哪到哪",还要在连线上挂一个ConditionExpression属性。条件分支的两个出口连线各自挂不同的条件表达式,运行时引擎判断走向时遍历当前节点的出口连线,逐条判断条件真假,走第一条为真的连线。这样条件分支的实现成本极低,画出来也直观。
3. 流程图拖拽绘制的核心实现过程
3.1 节点绘制与命中测试的实现细节
画布上的节点绘制我用的是 GDI+ 的GraphicsPath加线性渐变填充,节点外框圆角矩形,标题栏颜色按节点类型区分:开始节点绿色、审批节点蓝色、条件节点橙色、抄送节点灰色、结束节点红色。这样业务人员一眼就能从颜色上分辨节点类型,视觉友好度很高。
绘制代码核心片段如下:
using System.Drawing.Drawing2D; private void DrawFlowNode(Graphics g, FlowNode node) { GraphicsPath path = new GraphicsPath(); int radius = 6; RectangleF rect = node.Bounds; // 构造圆角矩形路径 path.AddArc(rect.X, rect.Y, radius * 2, radius * 2, 180, 90); path.AddArc(rect.Right - radius * 2, rect.Y, radius * 2, radius * 2, 270, 90); path.AddArc(rect.Right - radius * 2, rect.Bottom - radius * 2, radius * 2, radius * 2, 0, 90); path.AddArc(rect.X, rect.Bottom - radius * 2, radius * 2, radius * 2, 90, 90); path.CloseFigure(); // 按节点类型填充不同颜色 Color startColor = Color.Transparent; Color endColor = Color.Transparent; switch (node.NodeType) { case NodeType.StartNode: startColor = Color.FromArgb(168, 220, 168); endColor = Color.FromArgb(76, 175, 80); break; case NodeType.ApproveNode: startColor = Color.FromArgb(187, 222, 251); endColor = Color.FromArgb(33, 150, 243); break; case NodeType.ConditionNode: startColor = Color.FromArgb(255, 224, 178); endColor = Color.FromArgb(255, 152, 0); break; } using (LinearGradientBrush brush = new LinearGradientBrush( rect, startColor, endColor, LinearGradientMode.Vertical)) { g.FillPath(brush, path); } using (Pen pen = new Pen(Color.FromArgb(80, 80, 80), 1.2f)) { g.DrawPath(pen, path); } // 绘制节点名称 using (SolidBrush textBrush = new SolidBrush(Color.Black)) { StringFormat sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center }; g.DrawString(node.Name, this.Font, textBrush, rect, sf); } }命中测试是指鼠标点击画布时判断点在了哪个节点或连线上。节点命中直接用RectangleF.Contains(point)就能判断,非常简单。但连线命中就有讲究了,流程图画完之后连线密密麻麻,鼠标要能精确点中一条线其实很不容易。我的实现是:把每条连线当成一条"虚拟的粗线段",用点到线段的距离判断是否命中。具体做法是给每条线段构造一个隐形的矩形区域,矩形宽度固定为 12 像素,只要鼠标点落在矩形内就算命中该连线。这个做法比纯粹求点到线段距离要简单,而且实测后手感不错。
3.2 连线拖拽交互的完整交互设计
连线的交互是流程图设计器里最容易让用户觉得"不跟手"的部分。我最终采用的是"节点边缘锚点+拖动预览线"的方案:每个节点四周有 4 个锚点(上、下、左、右),鼠标按下节点锚点时开始拖动,拖拽过程中画一条从锚点指向鼠标位置的虚线预览线,松开鼠标时判断是否落在目标节点的锚点范围,如果命中就在这两个锚点之间生成一条贝塞尔曲线连线。
这里有个很关键的设计决策:松开鼠标判定目标节点时,不能只判断鼠标位置是否在目标节点的矩形内部,还要判断落点是否在目标节点有效锚点附近。否则用户从左侧锚点拖到右侧节点的中间位置,生成的连线会非常丑,各种穿模。实际代码里我做了就近锚点吸附处理:鼠标落到目标节点矩形内任意位置,自动吸附到离鼠标最近的那个锚点。
private void Canvas_MouseMove(object sender, MouseEventArgs e) { if (_draggingLine != null) { // 更新预览线终点 _draggingLine.TargetPoint = e.Location; // 判断是否悬停在可接入的节点上 FlowNode hoverNode = HitTestNode(e.Location); if (hoverNode != null && hoverNode.NodeId != _draggingLine.SourceNodeId) { _draggingLine.TargetNodeId = hoverNode.NodeId; _draggingLine.TargetAnchor = GetNearestAnchor(hoverNode, e.Location); } else { _draggingLine.TargetNodeId = null; } canvas.Invalidate(); } }连线绘制我用的是三次贝塞尔曲线,起点和终点方向根据锚点位置决定。从下锚点连出的线,初始方向向下;连入上锚点的线,收尾方向向上。这样画出来的连线比直线美观很多,交叉线重叠时也能靠曲率拉开层次。另一个细节:新拖拽出的连线默认名字是"流转",用户选中连线后可以在右侧属性面板里改名或配置条件表达式,这个属性和节点属性共用同一个属性网格,交互上不用额外维护一套,非常省事。
3.3 画布缩放与滚动视口的实现
设计器最后还必须支持大流程的缩放和滚动。流程图一旦超过四五十个节点,固定大小的画布根本无法操作。我的方案是在面板上放一个自定义DesignerCanvas控件,开启AutoScroll,通过一个ScaleFactor变量控制所有绘制坐标的缩放比例。实现时所有坐标存储都保留逻辑坐标,仅在Paint事件中通过Transform矩阵整体缩放:
private void DesignerCanvas_Paint(object sender, PaintEventArgs e) { e.Graphics.SmoothingMode = SmoothingMode.AntiAlias; e.Graphics.ResetTransform(); e.Graphics.ScaleTransform(ScaleFactor, ScaleFactor); // 设置滚动偏移 e.Graphics.TranslateTransform(this.AutoScrollPosition.X / ScaleFactor, this.AutoScrollPosition.Y / ScaleFactor); // 绘制网格 DrawBackgroundGrid(e.Graphics); // 绘制连线 foreach (var line in _document.Lines) DrawFlowLine(e.Graphics, line); // 绘制节点 foreach (var node in _document.Nodes) DrawFlowNode(e.Graphics, node); }有个必须注意的坑:由于逻辑坐标被整体缩放,鼠标命中测试时的坐标也要先做逆变换。我就是忘了在MouseDown里除一下ScaleFactor,一开始缩放 120% 时节点总是点不中,排查了半天才发现是坐标变换没对应上。正确做法是封一个PointF LogicFromScreen(Point p)方法,统一处理滚动偏移和缩放,所有鼠标事件里的坐标都先过一遍这个方法再参与命中测试。
4. 表单设计器与流程节点的绑定逻辑
4.1 表单控件面板的设计思路
流程节点画好之后,画布下方可以切换到表单设计视图。这个视图类似一个简单的画布型表单设计器,左侧是可拖拽控件面板,包含单行文本框、多行文本、数字输入、日期选择、下拉框、复选框、附件上传等常用控件,拖到中间表单画布后生成对应的控件实例,控件之间可以自由摆放位置、调整大小、对齐分布。
表单画布的控件渲染是 WinForm 原生控件叠加还是纯 GDI+ 绘制?这个问题我纠结了很久,最后选了纯 GDI+ 绘制,因为原生控件在拖拽、对齐、吸附时的行为不一致,而且保存和重载时需要同步控件状态,非常繁琐。纯绘制方案的数据就是FormControl对象列表,每个对象存坐标、尺寸、标题、字段名等元数据,运行时引擎再根据这些元数据动态生成真实的输入控件。设计器里只负责"布局和配置",不负责真实数据交互,这个思路让设计器模块和运行时模块彻底解耦。
表格控件在表单设计器里是最难处理的一块。为了不过度设计,第一版我采用了"明细子表"的简化方案:一个明细子表控件在表单画布上就是一个带"添加明细行"功能的区域,运行时渲染为一个DataGridView,列结构在属性面板中配好。这个方案极大降低了实现复杂度,业务上应对报销单多行明细、请假单多段行程足够用了。
4.2 节点到表单字段的绑定关系映射
表单设计器和流程设计器之间如何联动,是整个项目里最容易被低估的技术点。我的做法是:选中流程节点时,右侧属性面板除了显示基础属性,还显示一个"表单字段权限"分组框,列出当前表单全部控件,每个控件旁边有可见性和可编辑性两个复选框。
这个操作背后做的其实是维护FlowNode.FieldBindings列表:节点选中时加载绑定列表,任何勾选变化后立即写入内存,点击保存按钮时把所有节点的绑定关系一起序列化。这里我设计了一个默认规则:新节点创建时,如果表单控件已经存在,默认全部字段可见、全部可编辑,用户按实际业务去调整。新添加的表单控件,在已存在的流程节点上默认是不可见不可编辑,需要用户逐个节点去配置。这个默认规则在实际配置流程时最省事,因为多数场景下"新增字段默认只能发起人填写"。
4.3 审批角色与字段权限的协同配置
表单字段的可见性和可编辑性之外,还有一个隐藏需求:审批环节经常要限制审批人只能看到与自己相关的字段,或者某个节点允许指定角色才能编辑某些敏感字段。这部分的实现我归到了"协同配置"里:流程节点属性面板中有审批人角色配置,可以多选角色;字段权限列表里每个字段右侧除了可见、可编辑复选框外,还有一个"仅限角色"下拉框,可以选择某个角色。
运行时引擎实际执行时,先判断当前节点绑定的审批人角色是否包含当前登录人的角色,如果不包含,该节点直接跳过;如果包含,再进入字段权限列表,按当前用户角色过滤字段的可见性和可编辑性。这个双重校验的设计保证了即使未来有人绕过界面直接构造请求,敏感字段也不会泄露给无权限角色。
5. 流程定义的序列化与运行时推进引擎
5.1 流程定义文件的序列化方案
设计器的最终产物是一份流程定义文件,包含流程图节点、连线、条件分支表达式以及表单布局和字段权限绑定关系。序列化格式我用的是 XML,虽然 JSON 更轻便,但 XML 在定义多级嵌套结构时自然可读,而且 .NET 自带的XmlSerializer可以直接控制序列化过程,出问题方便排查。文件后缀定义成.flow,本质就是一个XML文件。
序列化的核心类是DesignerDocument,整个文件结构大概是这样的:
[XmlRoot("FlowDefinition")] public class DesignerDocument { [XmlElement("FlowName")] public string FlowName { get; set; } [XmlArray("Nodes")] [XmlArrayItem("Node")] public List<FlowNode> Nodes { get; set; } [XmlArray("Lines")] [XmlArrayItem("Line")] public List<FlowLine> Lines { get; set; } [XmlArray("FormControls")] [XmlArrayItem("Control")] public List<FormControl> FormControls { get; set; } }这里提醒一句:FlowLine里不仅要有SourceNodeId和TargetNodeId,我额外存了SourceAnchor和TargetAnchor(锚点方向),否则重新加载文件时贝塞尔曲线起止方向就丢了,连线会全部变成奇怪的形状。这个细节在第一次存盘加载测试时就暴露了,属于典型的不存界面状态导致的数据不完整问题,一定要在设计阶段就规避。
5.2 轻量级运行时引擎的推进逻辑
设计器做出来的文件要能被运行时引擎读取执行。我没有引入重型的工作流引擎,而是自己写了一个只有几百行的FlowRuntime类,核心功能就三个:定位当前节点、推进到下一步、判断条件分支走向。
public class FlowRuntime { public FlowNode CurrentNode { get; private set; } public void Start(DesignerDocument doc) { CurrentNode = doc.Nodes.First(n => n.NodeType == NodeType.StartNode); } public FlowNode GetNextNode() { // 找出当前节点的所有出口连线 var outLines = _document.Lines.Where(l => l.SourceNodeId == CurrentNode.NodeId); foreach (var line in outLines) { // 无条件限制的连线直接走 if (string.IsNullOrWhiteSpace(line.ConditionExpression)) { return _document.Nodes.First(n => n.NodeId == line.TargetNodeId); } // 有条件限制的连线要判断表达式结果 bool match = EvaluateCondition(line.ConditionExpression); if (match) return _document.Nodes.First(n => n.NodeId == line.TargetNodeId); } return null; } }EvaluateCondition方法本质是一个简易表达式求值器,支持{{amount}} > 500、{{dept}} == "财务部"这类简单表达式。我在运行时引擎中内置了一个字典,key 是表单字段名,value 是当前表单提交的值,先做字符串替换把{{字段名}}替换成实际值,再交给DataTable.Compute去求值(这是 .NET 自带的功能,表达式求值够用且省事)。别小看这个简易方案,实际请假单、报销单这些业务里,条件分支绝大多数就是金额比较、部门判断、天数判断这几类,完全不需要上重量级规则引擎。
这个轻量级引擎还包含一个"当前节点事件"机制:进入节点时触发NodeEntered事件,运行时 UI 根据CurrentNode.FieldBindings动态渲染当前表单字段,重新实例化输入控件,赋值初始数据,调整只读状态。离开节点时触发NodeLeaving事件,校验必填字段、保存表单数据快照,然后推进到下一个节点。整个过程跟设计器完全是两套代码,唯一沟通途径就是序列化出的流程定义文件。
6. 实际开发过程中踩过的坑与优化经验
6.1 画布刷新性能的优化
第一次绘制完流程图后,我直接在MouseMove事件里调用了画布的Invalidate(),结果节点一多鼠标移动就明显卡顿,尤其流程到四五十个节点后掉帧严重。排查发现原因很蠢:Invalidate()触发Paint后,全画布所有节点和连线全部重绘,MouseMove高频触发导致每秒钟几十次全量重画。
优化方案是我自己写的脏矩形刷新机制,简单有效:只把被鼠标拖动影响的节点或连线所在的区域标记为无效区域,然后调用Invalidate(region)。GDI+ 的Invalidate(Rectangle)只重绘指定区域,效果立竿见影。对鼠标拖拽预览线这种高频操作,我还加了一个"双缓冲画布"技巧,把静态节点和连线先绘制到一个预渲染的Bitmap上,MouseMove 时只在这个位图上再叠加动态预览线,避免了逐帧重算所有节点坐标。
6.2 连线点击数不中问题与吸附策略
连线命中难是一个非常影响体验的细节。最初连线的可见宽度是 2 像素,点中它的概率低得让人抓狂。我把"命中矩形"的宽度放宽到了 14 像素之后好了很多,但用户点击时仍然经常误命中旁边的连线。后来我加了一套优先级策略:同一个鼠标位置命中多条连线时,取离鼠标最近的那一条。判断规则简单粗暴:计算鼠标位置到各条连线的折线距离,距离最小的优先选中。贝塞尔曲线求最近点比较麻烦,我用了折线近似法:把一条贝塞尔曲线均匀采样 10 个点,连成折线,再算鼠标到折线段的最小距离,精度足够用而且速度很快。
6.3 表单字段与节点绑定关系错乱问题
开发过程中有一段时间表单字段和节点绑定关系经常错乱,典型症状是:给节点 A 配置了字段可见性,保存重新加载后,节点 B 上的绑定数据出现在了节点 A 上。查了很久后来发现是序列化时忘记给FieldBinding里的ControlId和节点NodeId做唯一性约束,表单控件重新加载后 ID 变了但FieldBinding里还在引用旧的控件 ID,运行时匹配不上就出现了数据串位。
修复方式是我在保存时增加了一个 ID 校验步骤:遍历所有节点的FieldBindings,凡是引用的控件 ID 不存在于当前FormControls列表中的,一律清理并重新绑定到默认状态。另外,ControlId的生成规则也改成了全局递增且永不重复,不像以前用 GUID 导致每次加载都变。这类问题在自研工具里很容易出现,因为没有现成框架帮忙保障数据的完整性,必须在序列化边界做防御性校验。
6.4 设计器随业务演进的功能裁剪
自研设计器最大的风险不是做不出来,而是越做越复杂。我最初的计划里有很多"高级功能":支持子流程嵌套、支持会签或签、支持超时自动提醒、表单字段级条件表达式联动……但真正跟业务方聊下来,这些功能 90% 根本用不到。最后我坚定地砍掉了子流程嵌套和复杂会签,只保留了串行审批、单条件分支、节点级字段权限、抄送节点这四个核心能力。
实际交付后,业务部门最常用的配置操作是:新增审批节点、设置审批人角色、调整字段权限、设置条件分支金额阈值。四个能力覆盖了全部实际流程的 95% 以上。剩下那 5% 的复杂需求,比如跨部门联合审批,通过配置两个连续审批节点也能模拟出来。自研工具最忌讳的就是面子工程,做一堆花哨功能却没人用,反而是每个功能都高频使用,这个工具才真正有价值。
7. 界面美化与打包交付的实践补充
这节单独拿出来说,是因为很多自研工具做完功能就交付了,界面丑得没法看,实施人员用起来心里抵触。WinForm 界面美化我用了两个低成本方案:一是给主窗体和各面板设置统一的主题色和字体规范,整个设计器的工具栏、按钮、面板配色统一走一套 Color 常量表,不用到处硬编码颜色值;二是用TableLayoutPanel和SplitContainer合理布局,避免控件一多界面就乱掉。我没有引入任何第三方皮肤组件,纯靠布局细节和配色就达到了内部工具可接受的视觉水平。
打包部署方面,环境是 VS2015,项目目标框架 .NET Framework 4.5。给实施人员交付时我用的是 InstallShield Limited Edition(VS2015 自带的打包工具)做安装程序。这里有个值得记的坑:开发时本机装了 .NET Framework 4.8,但目标客户机器只有 4.5,结果有些运行时报错在生产环境才暴露。教训是打包前用TargetFramework严格约束目标环境,并写一个部署前检查脚本,检测客户机器 .NET 版本,版本不达标时直接弹窗提示,而不是等程序跑起来才报错。
另外,因为设计器会生成.flow文件,我特意做了一层轻量的配置信息加密,把 XML 做了 DES 加密,保存和加载时自动解密。这不是高安全性措施,主要是防止实施人员不小心用文本编辑器打开改动后把流程定义改坏了,加密加校验和能拦住大部分手误操作。运行时引擎的加载程序也要做配套解密流程,保持和设计器的加解密逻辑完全一致。
8. 实测效果与后续扩展方向
目前这套设计器已经跑在项目现场,实际配置一个包含 8 个节点、15 个表单字段的报销审批流程,大约耗时 50 分钟。对比之前的硬编码方式:出需求、排开发、测试、发布,一个流程至少两天。这个效率提升对实施团队来说是质变。当然,自研工具也暴露了一些在当前架构下解决得不算完美的问题:比如复杂条件分支的可视化配置还是不够直观,节点字段权限列表在字段特别多时滚动浏览效率低,这些我都计划在下一版本中优化。
如果后续要把这套工具延伸,优先级我会这样排:第一是增加流程版本的灰度发布支持,让流程修改不影响正在运行的实例;第二是增加流程耗时统计和效能看板,给管理层提供审批效率数据;第三是考虑把表单设计器抽离成独立的控件库,以后任何 WinForm 项目集成表单设计能力都能直接复用。自研工作流表单设计器这事,做到"够用、好用、能交付"就已经赢了,贪多求全反而会拖垮整个项目。
我个人在这些天的开发里体会最深的一点是:自研这种内部工具,一定要先想清楚"给谁用、解决什么问题、哪个功能最重要",然后其他所有花里胡哨的想法都往后排。做流程设计器不是炫技,不是展示你能画多好看的箭头,而是让业务配置人员用最小的学习成本完成流程编排。把这一点想明白了,设计方案就顺了,代码实现也只是时间问题。