踐:GDI+節(jié)點(diǎn)連線與坐標(biāo)變換詳解)
簡介這是一份基于FlowChart.Net改寫的Winform流程圖示例面向需要在桌面項(xiàng)目中快速引入節(jié)點(diǎn)連線功能的開發(fā)者無論用于內(nèi)部工具還是畢業(yè)設(shè)計(jì)都能快速上手。功能設(shè)計(jì)克制僅保留繪制節(jié)點(diǎn)、創(chuàng)建連線等核心交互適合小項(xiàng)目直接二次開發(fā)或?qū)W習(xí)流程圖繪制原理。壓縮包共59個(gè)文件約2.48MB以cs源碼、dll依賴庫、xml配置/說明、resx資源文件為主其中dll主要為MindFusion圖表庫等運(yùn)行時(shí)依賴xml多為程序集配置與注釋文檔另含可直接運(yùn)行的exe及相關(guān)調(diào)試文件解壓后即可用Visual Studio打開解決方案查看結(jié)構(gòu)。已有4775人學(xué)習(xí)下載在同類型輕量流程圖中關(guān)注度較高。資源內(nèi)包含F(xiàn)lowNode、FlowLink、FlowMain等核心類以及FrmCreateNode、FrmCreateLink兩個(gè)創(chuàng)建窗體可幫助理解節(jié)點(diǎn)編輯、連線交互與界面布局的實(shí)現(xiàn)思路FrmCreateNode和FrmCreateLink演示了彈窗交互與數(shù)據(jù)傳遞方式FlowHelper則提供輔助計(jì)算方法整體結(jié)構(gòu)清晰便于按需修改。基于MindFusion組件庫也適合希望了解第三方流程圖控件用法的開發(fā)者參考。1. 為什么最后選了自繪1.1 現(xiàn)成控件庫都有點(diǎn)“笨重”先說結(jié)論Winform 下做流程圖市面上不是沒有現(xiàn)成方案像 NodeRed、YAD、Netron 這些控件庫我都試過功能確實(shí)全節(jié)點(diǎn)拖拽、連線、縮放、序列化全給你包好了。但真正用在項(xiàng)目里你會(huì)發(fā)現(xiàn)兩個(gè)問題一是上手成本真的高光是搞懂它的事件模型和渲染管線就得折騰好幾天文檔還不一定全二是樣式改起來太痛苦客戶一句“節(jié)點(diǎn)能不能換個(gè)圓角風(fēng)格”你可能得翻半天源碼才能找到繪制入口。我之前幫朋友做一個(gè)排產(chǎn)系統(tǒng)的工序流程圖模塊需求其實(shí)不復(fù)雜節(jié)點(diǎn)就是工序連線就是前后依賴關(guān)系能拖、能選、能刪、能保存界面干凈就行。但試了一圈現(xiàn)成庫之后反而堅(jiān)定了自繪的決心。一個(gè)只有二三十個(gè)節(jié)點(diǎn)的輕量級(jí)場景用 GDI 自己畫比引一個(gè)幾百 KB 的第三方庫要可控得多。1.2 自繪的邊界與適合場景自繪要畫到什么程度得先想清楚邊界。我這個(gè)方案定位是“簡單”二字支持節(jié)點(diǎn)拖拽、連線繪制、單選多選、刪除、縮放、平移數(shù)據(jù)序列化自己定不做自動(dòng)布局、不做縮略圖、不做撤銷重做。這個(gè)邊界很重要你一旦自繪所有功能都要自己實(shí)現(xiàn)范圍失控會(huì)變成無底洞。適合自繪的典型場景我總結(jié)過節(jié)點(diǎn)數(shù)量在 200 個(gè)以內(nèi)不需要頻繁大批量重繪流程結(jié)構(gòu)相對(duì)固定不需要復(fù)雜的自動(dòng)布局算法需要高度定制節(jié)點(diǎn)外觀和交互邏輯不想引入第三方依賴發(fā)布包體積可控。如果你的需求是那種幾百上千個(gè)節(jié)點(diǎn)、需要自動(dòng)排列、折疊展開、跨平臺(tái)的大規(guī)模流程圖那還是別自繪了直接上成熟方案更靠譜。自繪真正舒服的區(qū)間就是中小規(guī)模的業(yè)務(wù)流程圖、狀態(tài)機(jī)編輯器、節(jié)點(diǎn)配置面板這一類。2. 數(shù)據(jù)層設(shè)計(jì)先想清楚再動(dòng)筆2.1 Node、Link 兩個(gè)類就夠了流程圖最核心的數(shù)據(jù)結(jié)構(gòu)就是兩個(gè)節(jié)點(diǎn)和連線。不要一上來就參考 Visio 那套復(fù)雜的對(duì)象模型對(duì)于簡單場景各定義一個(gè)類就夠用。節(jié)點(diǎn)類我建議這樣設(shè)計(jì)public class FlowNode { public string Id { get; set; } Guid.NewGuid().ToString(N); public string Text { get; set; } ; public Rectangle Bounds { get; set; } // 邏輯坐標(biāo)下的節(jié)點(diǎn)區(qū)域 public Color BackColor { get; set; } Color.FromArgb(64, 158, 255); public bool IsSelected { get; set; } }連線類的核心是記錄兩個(gè)節(jié)點(diǎn)的 ID而不是記錄坐標(biāo)。坐標(biāo)是繪制時(shí)實(shí)時(shí)算的如果你把坐標(biāo)存下來拖動(dòng)節(jié)點(diǎn)后連線就斷開了。public class FlowLink { public string Id { get; set; } Guid.NewGuid().ToString(N); public string SourceNodeId { get; set; } public string TargetNodeId { get; set; } public string Label { get; set; } ; public bool IsSelected { get; set; } }這里有個(gè)容易被忽略的點(diǎn)連線的命中測試判斷鼠標(biāo)點(diǎn)沒點(diǎn)中這條線最好在 Link 里提供一個(gè)方法而不是在控件的事件里寫一坨 switch 邏輯。我用的方式是先把連線轉(zhuǎn)成一條 GraphicsPath然后用IsOutlineVisible做命中判斷這樣以后如果要支持曲線樣式改一個(gè)方法就行。2.2 坐標(biāo)系統(tǒng)與縮放換算這是整個(gè)自繪流程圖里最大的坑沒有之一。Winform 的PaintEventArgs里給的是設(shè)備坐標(biāo)也就是屏幕上實(shí)際的像素位置。而你的節(jié)點(diǎn)數(shù)據(jù)存的是邏輯坐標(biāo)。一旦你做了縮放和平移兩套坐標(biāo)之間的換算必須統(tǒng)一處理否則會(huì)出現(xiàn)“鼠標(biāo)點(diǎn)在一個(gè)位置節(jié)點(diǎn)在另一個(gè)位置”的詭異現(xiàn)象。我的做法是設(shè)置一個(gè)視圖變換參數(shù)public class ViewTransform { public float Scale 1.0f; public PointF Offset PointF.Empty; public PointF ScreenToLogic(Point screen) { return new PointF( (screen.X - Offset.X) / Scale, (screen.Y - Offset.Y) / Scale); } public Point LogicToScreen(PointF logic) { return new Point( (int)(logic.X * Scale Offset.X), (int)(logic.Y * Scale Offset.Y)); } }繪制的時(shí)候把整個(gè)畫布都包在Graphics.TranslateTransform和Graphics.ScaleTransform里這樣 OnPaint 里面畫的坐標(biāo)全是邏輯坐標(biāo)不需要每個(gè)節(jié)點(diǎn)自己換算一遍。而鼠標(biāo)事件里拿到的屏幕坐標(biāo)要先通過ScreenToLogic轉(zhuǎn)成邏輯坐標(biāo)再做命中測試。注意縮放和平移的變換只影響繪制邏輯千萬別直接改節(jié)點(diǎn)的 Bounds否則鼠標(biāo)拖拽的起點(diǎn)和終點(diǎn)會(huì)隨著縮放倍率變得混亂。3. 交互層拖、連、選、刪3.1 連線繪制與貝塞爾曲線連線是流程圖的視覺骨架畫得是否順眼直接影響整體感受。我用的是三次貝塞爾曲線而不是直愣愣的直線。兩個(gè)節(jié)點(diǎn)之間從源節(jié)點(diǎn)的右邊中點(diǎn)出發(fā)到目標(biāo)節(jié)點(diǎn)的左邊中點(diǎn)收尾兩個(gè)控制點(diǎn)分別向水平方向偏移一個(gè)固定值private GraphicsPath BuildLinkPath(FlowNode src, FlowNode dst) { Point start new Point(src.Bounds.Right, src.Bounds.Top src.Bounds.Height / 2); Point end new Point(dst.Bounds.Left, dst.Bounds.Top dst.Bounds.Height / 2); int dx Math.Max(30, Math.Abs(end.X - start.X) / 2); Point c1 new Point(start.X dx, start.Y); Point c2 new Point(end.X - dx, end.Y); GraphicsPath path new GraphicsPath(); path.AddBezier(start, c1, c2, end); return path; }控制點(diǎn)偏移量dx用Math.Max(30, 兩節(jié)點(diǎn)水平距離的一半)這個(gè)公式節(jié)點(diǎn)靠近時(shí)曲線不會(huì)太平節(jié)點(diǎn)遠(yuǎn)離時(shí)曲線弧度和大多數(shù)流程圖的觀感接近。實(shí)測下來這個(gè)經(jīng)驗(yàn)公式在大多數(shù)場景下都成立。箭頭繪制有兩種方案一種是畫在終點(diǎn)位置但方向跟隨曲線切線另一種是等分路徑長度找點(diǎn)。我選用前者private void DrawArrow(Graphics g, GraphicsPath path, Pen pen) { // 取路徑末尾的切線方向 PathData pd path.PathData; PointF lastPoint pd.Points[pd.Points.Length - 1]; PointF prevPoint pd.Points[pd.Points.Length - 2]; float angle (float)(Math.Atan2(lastPoint.Y - prevPoint.Y, lastPoint.X - prevPoint.X)); int size 10; PointF p1 new PointF( lastPoint.X - size * Math.Cos(angle - 0.4f), lastPoint.Y - size * Math.Sin(angle - 0.4f)); PointF p2 new PointF( lastPoint.X - size * Math.Cos(angle 0.4f), lastPoint.Y - size * Math.Sin(angle 0.4f)); g.DrawLine(pen, lastPoint, p1); g.DrawLine(pen, lastPoint, p2); }用Math.Atan2計(jì)算方向角正負(fù) 0.4 弧度是一個(gè)比較自然的張開角度箭頭的兩個(gè)側(cè)邊線不用再單獨(dú)填充多邊形線條風(fēng)格和連線保持一致看上去更利落。3.2 常用的三種命中測試節(jié)點(diǎn)和連線都畫完之后交互的第一步就是命中測試。我維護(hù)了三個(gè)狀態(tài)hoverNode、hoverLink、selectedNodes。鼠標(biāo)移動(dòng)時(shí)不斷更新前兩個(gè)狀態(tài)用于改變光標(biāo)形狀和繪制高亮點(diǎn)擊時(shí)更新選中集合。核心命中測試分三類節(jié)點(diǎn)命中遍歷所有節(jié)點(diǎn)判斷邏輯坐標(biāo)點(diǎn)在哪個(gè)節(jié)點(diǎn)的 Bounds 里。因?yàn)楣?jié)點(diǎn)數(shù)量少幾十個(gè)直接線性遍歷就好不需要空間索引連線命中先遍歷所有連線把連線轉(zhuǎn)成 GraphicsPath再用IsOutlineVisible判斷注意這個(gè)方法的第三個(gè)參數(shù)需要傳入畫筆寬度否則精確度會(huì)很差空白區(qū)域以上都沒有命中的情況此時(shí)點(diǎn)擊空白處應(yīng)清空選中狀態(tài)。這里有個(gè)細(xì)節(jié)IsOutlineVisible在縮放較大的時(shí)候比如放大到 150%鼠標(biāo)稍微偏出線條就選不中。我實(shí)測的解決辦法是把命中檢測用的畫筆寬度固定為 8 像素不跟隨縮放變換這樣手感會(huì)穩(wěn)很多。鼠標(biāo)按下和移動(dòng)的核心邏輯protected override void OnMouseDown(MouseEventArgs e) { PointF logic _transform.ScreenToLogic(e.Location); FlowNode hit HitTestNode(logic); if (hit ! null) { if (!hit.IsSelected) { _selectedNodes.Clear(); hit.IsSelected true; _selectedNodes.Add(hit); } _dragMode DragMode.MoveNode; _dragStart logic; _nodeOriginalBounds hit.Bounds; } else if (HitTestLink(logic, out FlowLink link)) { link.IsSelected true; } else { ClearSelection(); } Invalidate(); }拖動(dòng)的時(shí)候別直接用鼠標(biāo)坐標(biāo)減起始坐標(biāo)的差值去改節(jié)點(diǎn)位置應(yīng)該記下按下時(shí)的原始 Bounds再用當(dāng)前鼠標(biāo)位置減去按下時(shí)的位置得到偏移量把原始 Bounds 平移這個(gè)偏移。否則第二次拖動(dòng)時(shí)節(jié)點(diǎn)會(huì)先跳一下。3.3 刪除邏輯與界面體驗(yàn)刪除節(jié)點(diǎn)時(shí)要把與它相關(guān)的連線一并刪除否則會(huì)留下“懸空”的連線視覺上很丑數(shù)據(jù)上也是臟數(shù)據(jù)。private void DeleteSelected() { HashSetstring nodeIds new HashSetstring(_selectedNodes.Select(n n.Id)); _links.RemoveAll(l nodeIds.Contains(l.SourceNodeId) || nodeIds.Contains(l.TargetNodeId)); _nodes.RemoveAll(n nodeIds.Contains(n.Id)); _selectedNodes.Clear(); Invalidate(); }我還在節(jié)點(diǎn)上做了一個(gè)“拖出來一條線就開始連線”的交互鼠標(biāo)從一個(gè)節(jié)點(diǎn)上按下后拖到空白區(qū)域時(shí)進(jìn)入DragMode.Link狀態(tài)此時(shí)畫布上會(huì)實(shí)時(shí)繪制一條從源節(jié)點(diǎn)到當(dāng)前鼠標(biāo)位置的預(yù)覽線松手時(shí)如果鼠標(biāo)落在某個(gè)節(jié)點(diǎn)上就創(chuàng)建一條新連線。這個(gè)交互不算復(fù)雜但對(duì)于用戶體驗(yàn)的提升很明顯類似的功能在眾多專業(yè)流程編輯器中都是標(biāo)配。4. 渲染細(xì)節(jié)畫得好看才是生產(chǎn)力4.1 繪制順序和雙緩沖繪制順序很關(guān)鍵順序反了會(huì)出現(xiàn)連線蓋住節(jié)點(diǎn)文字、選中高亮被節(jié)點(diǎn)蓋住的情況。我按這個(gè)順序來底層網(wǎng)格背景所有連線選中的連線用高亮顏色畫一遍所有節(jié)點(diǎn)選中的節(jié)點(diǎn)外圈加一圈描邊節(jié)點(diǎn)文字拖動(dòng)中的預(yù)覽連線。OnPaint里面要設(shè)置好繪制質(zhì)量protected override void OnPaint(PaintEventArgs e) { Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; g.TextRenderingHint System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; g.TranslateTransform(_transform.Offset.X, _transform.Offset.Y); g.ScaleTransform(_transform.Scale, _transform.Scale); // ... 按順序繪制 }Winform 的控件默認(rèn)DoubleBuffered是 false直接在上面畫會(huì)出現(xiàn)明顯的閃爍。我是直接在構(gòu)造函數(shù)里強(qiáng)制開啟雙緩沖這比手動(dòng)創(chuàng)建 BufferGraphics 要省心很多SetStyle(ControlStyles.AllPaintingInWmPaint | ControlStyles.UserPaint | ControlStyles.OptimizedDoubleBuffer, true);4.2 節(jié)點(diǎn)外觀和圓角矩形節(jié)點(diǎn)的默認(rèn)外觀是一個(gè)圓角矩形GDI 里沒有直接畫圓角矩形的 API需要自己構(gòu)建 GraphicsPath。我把這個(gè)方法放在一個(gè)靜態(tài)類里因?yàn)楫嫻?jié)點(diǎn)、畫選中框、畫拖拽預(yù)覽都要用public static GraphicsPath CreateRoundedRect(Rectangle rect, int radius) { int r Math.Min(radius, rect.Height / 2); GraphicsPath path new GraphicsPath(); path.AddArc(rect.X, rect.Y, r * 2, r * 2, 180, 90); path.AddArc(rect.Right - r * 2, rect.Y, r * 2, r * 2, 270, 90); path.AddArc(rect.Right - r * 2, rect.Bottom - r * 2, r * 2, r * 2, 0, 90); path.AddArc(rect.X, rect.Bottom - r * 2, r * 2, r * 2, 90, 90); path.CloseFigure(); return path; }繪制時(shí)加一個(gè) 1px 的邊框讓節(jié)點(diǎn)有立體感。選中狀態(tài)時(shí)邊框用橙色或高亮藍(lán)色加粗這樣用戶一眼就能看出當(dāng)前的選中對(duì)象。5. 常見問題與排查技巧實(shí)錄5.1 剛踩過的幾個(gè)坑我把開發(fā)過程中碰到的典型問題列成了一張表這些問題在大部分 Winform 自繪場景里都有參考價(jià)值問題現(xiàn)象原因解決辦法雙擊面板閃爍拖拽縮放時(shí)界面劇烈閃爍沒有開啟雙緩沖SetStyle(OptimizedDoubleBuffer, true)鼠標(biāo)點(diǎn)擊位置偏移放大 150% 后點(diǎn)不中節(jié)點(diǎn)命中測試沒做坐標(biāo)換算使用ScreenToLogic統(tǒng)一轉(zhuǎn)換連線箭頭方向不對(duì)箭頭指向了終點(diǎn)旁邊的區(qū)域直接用最后一個(gè)點(diǎn)算角度抖動(dòng)用倒數(shù)第二個(gè)點(diǎn)和最后一個(gè)點(diǎn)的差值算角度文字模糊節(jié)點(diǎn)文字邊緣毛刺默認(rèn)文本渲染模式設(shè)置TextRenderingHint ClearTypeGridFit縮放后連線粗細(xì)變放大之后連線粗得像根棍子畫筆寬度跟隨縮放構(gòu)建筆寬時(shí)除以Scale縮放后連線的筆寬問題是我加縮放功能之后最先暴露出來的。畫筆寬度乘以倍數(shù)之后整個(gè)畫面會(huì)顯得頭重腳輕?,F(xiàn)在的做法是畫連線時(shí)用_transform.Scale做一次除法讓屏幕上看連線的像素寬度基本恒定觀感會(huì)好很多。5.2 實(shí)際排錯(cuò)思路從現(xiàn)象倒推遇到“鼠標(biāo)選中連線選中不到”這類問題我的排查思路是先確認(rèn)坐標(biāo)換算有沒有問題再用一個(gè)簡單的臨時(shí)辦法驗(yàn)證在 OnPaint 里把連線的 GraphicsPath 直接用紅色畫出來如果紅色路徑和實(shí)際能選中的范圍一致那就說明問題出在命中測試的畫筆寬度上而不是繪制問題。5.3 性能優(yōu)化能撐住多大場景測了三種典型場景20 個(gè)節(jié)點(diǎn)、80 個(gè)節(jié)點(diǎn)、200 個(gè)節(jié)點(diǎn)。在普通配置的電腦上拖動(dòng)、縮放、刷新基本都保持在 60fps 左右200 個(gè)節(jié)點(diǎn)時(shí)用雙緩沖依舊穩(wěn)定但如果節(jié)點(diǎn)的文字或連線上面帶很多裝飾元素幀率會(huì)明顯下降。我自己優(yōu)化后的做法是給OnPaint里判斷IsVisible可見區(qū)域之外的對(duì)象直接跳過繪制雖然這個(gè) demo 里作用不明顯但代碼結(jié)構(gòu)是從大項(xiàng)目里帶過來的習(xí)慣以后真要擴(kuò)展也能兜住。5.4 進(jìn)階擴(kuò)展的幾個(gè)思路這個(gè)簡單版做完之后擴(kuò)充的方向其實(shí)非常多序列化把FlowNode和FlowLink序列化為 JSON保存流程定義反序列化時(shí)重新構(gòu)建節(jié)點(diǎn)集合多選框選按住鼠標(biāo)在空白區(qū)域拖出一個(gè)矩形批量選中內(nèi)部節(jié)點(diǎn)自動(dòng)布局對(duì)有層級(jí)關(guān)系的流程用TopologicalSort做排序再按層計(jì)算節(jié)點(diǎn)坐標(biāo)導(dǎo)出圖片用Bitmap創(chuàng)建一張大畫布然后調(diào)用控件的DrawToBitmap撤銷重做用命令模式把每次操作記錄進(jìn)棧我這個(gè)版本為了控制復(fù)雜度就沒有加。5.5 最后一點(diǎn)心得在我實(shí)際做這個(gè)項(xiàng)目之前也想過直接用控件庫是不是更省事。但做完之后最大的收益反而不是“省了第三方依賴”而是對(duì)整個(gè) GDI 繪制體系有了更深入的理解?,F(xiàn)在再去做 Winform 里的各種自定義控件比如儀表盤、甘特圖、思維導(dǎo)圖都是完全同一條技術(shù)路線數(shù)據(jù)層和繪制層分離、坐標(biāo)換算清晰、命中測試穩(wěn)定。以后凡是遇到這種“輕量級(jí)可視化編輯器”的需求我基本上不需要查資料就能直接動(dòng)手。如果硬要挑一個(gè)建議的話我建議你把ScreenToLogic和LogicToScreen這套坐標(biāo)換算單獨(dú)做在一個(gè)類里寫單元測試不要在畫布代碼里順手寫。這個(gè)看似簡單的函數(shù)是整個(gè)自繪流程圖的命門它一旦出錯(cuò)所有交互都會(huì)變得難以排查。我自己的版本里這個(gè)換算邏輯跑了三個(gè)項(xiàng)目都沒怎么改過這就是把它獨(dú)立出來的價(jià)值。本文還有配套的精品資源點(diǎn)擊獲取