建流程圖編輯器:場景、圖元與交互設(shè)計詳解)
1. 為什么說流程圖編輯器才是QGraphicsView的正確打開方式1.1 先搞明白QGraphicsView到底比普通控件強在哪我在不少項目里見過同一種用法把QGraphicsView當(dāng)成了一個高級QLabel加載一張圖片然后fitInView()一下就完事了。如果你也停留在這一步那確實體會不到這個框架的設(shè)計意圖。QGraphicsView真正的價值在于它內(nèi)部有一套完整的圖元Item場景Scene管理機制。你可以把它理解成一塊無限大的畫板每個節(jié)點、每條連線都是一個可以獨立處理鼠標(biāo)事件、碰撞檢測、坐標(biāo)變換的對象而不是一張扁平的位圖。做流程圖編輯器時這塊畫板天然就適合承載節(jié)點、連線、錨點、選區(qū)這一整套圖形交互邏輯。Qt 6.5在QGraphicsView這塊的API基本延續(xù)了經(jīng)典設(shè)計但也對坐標(biāo)變換和高DPI渲染做了不少底層修正因此在6.5上做開發(fā)不用再像Qt 5時代那樣頻繁處理一些莫名其妙的分辨率適配問題。本文我以一個簡易流程圖編輯器為例把這套框架從搭建場景到序列化保存完整過一遍適合已經(jīng)寫過一點Qt界面、但沒深入用過Graphics View框架的讀者。1.2 一個迷你流程圖的組成拆解先想清楚我們要做出來的東西長什么樣。拿我最常用的一個內(nèi)部工具為例它需要支持從左側(cè)工具欄拖出開始、處理、判斷、結(jié)束四種節(jié)點節(jié)點可以整體拖動可以編輯標(biāo)題從節(jié)點邊緣的連接點拖出連線連接到另一個節(jié)點的連接點支持框選、刪除、撤銷重做可以保存為JSON文件下次打開還能原樣恢復(fù)這個需求列表一出來你就能發(fā)現(xiàn)純靠QPainter在QWidget上自己畫不是不行但一旦要處理連線跟著節(jié)點移動而實時更新點選到一條彎曲連線框選手勢這些交互自己實現(xiàn)的工作量會指數(shù)級上升。QGraphicsView把這些底層事件分發(fā)和坐標(biāo)轉(zhuǎn)換全包了我們只需要聚焦在業(yè)務(wù)邏輯上。所以本文的核心思路是用QGraphicsScene管理數(shù)據(jù)用QGraphicsItem描述節(jié)點和連線用QGraphicsView做交互入口這三層各司其職缺一不可。2. 場景、圖元與視圖三層架構(gòu)的實戰(zhàn)理解2.1 從零搭建Graphics View框架在Qt 6.5里新建一個基于QMainWindow的項目后最簡單的初始化代碼如下// MainWindow構(gòu)造函數(shù)中 scene_ new QGraphicsScene(this); view_ new QGraphicsView(scene_, this); view_-setRenderHint(QPainter::Antialiasing); view_-setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate); view_-setDragMode(QGraphicsView::RubberBandDrag); setCentralWidget(view_);這里有三個關(guān)鍵選項我分別解釋一下Antialiasing流程圖里大量使用曲線、圓角矩形和斜線不開抗鋸齒的話邊緣鋸齒感非常明顯尤其在4K屏上一眼過去全是毛邊這是體驗分水嶺。BoundingRectViewportUpdate希望拖動圖元時視圖不要整塊重繪而是按臟矩形局部刷新。選這個模式在節(jié)點數(shù)量較少時足夠流暢如果圖元數(shù)量過萬再考慮MinimalViewportUpdate。RubberBandDrag這是框選模式直接給視圖開了默認(rèn)的橡皮筋框選交互配合后面講的setFlag(QGraphicsItem::ItemIsSelectable)就能用。一個容易忽略的細(xì)節(jié)是場景坐標(biāo)系的初始范圍。很多新手發(fā)現(xiàn)節(jié)點拖到很遠(yuǎn)的空白處之后找不到了就是因為場景默認(rèn)范圍是無限擴張的。我一般在初始化時給一個足夠大的sceneRectscene_-setSceneRect(-2000, -2000, 4000, 4000);這樣既不影響自由布局也給后續(xù)的迷你地圖或縮放定位留了基準(zhǔn)。2.2 自定義流程節(jié)點圖元從QGraphicsItem開始自定義節(jié)點是編輯器最重要的圖元。我通常把它拆成兩層外形繪制和數(shù)據(jù)綁定但都放在同一個QGraphicsObject子類里。關(guān)鍵在于怎么設(shè)計它的boundingRect和繪制邏輯。先看一個標(biāo)準(zhǔn)節(jié)點類的最小實現(xiàn)class FlowNodeItem : public QGraphicsObject { Q_OBJECT public: enum NodeType { Start, Process, Decision, End }; FlowNodeItem(NodeType type, const QString title, QGraphicsItem *parent nullptr); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; // 給外部訪問的引腳信息 QListQPointF inputPortPositions() const; QListQPointF outputPortPositions() const; protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; private: NodeType type_; QString title_; QRectF rect_; };boundingRect要返回整個圖元占用的矩形范圍包括陰影、邊框?qū)挾?。流程圖節(jié)點常見尺寸是160x70左右判斷節(jié)點可以稍寬一點比如160x90。繪制時用QPainter畫圓角矩形、標(biāo)題文字、端口小圓圈void FlowNodeItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option); Q_UNUSED(widget); painter-setRenderHint(QPainter::Antialiasing); // 節(jié)點背景 QColor bgColor; switch (type_) { case Start: bgColor QColor(#D5F5E3); break; case Process: bgColor QColor(#AED6F1); break; case Decision: bgColor QColor(#F9E79F); break; case End: bgColor QColor(#F5B7B1); break; } painter-setBrush(bgColor); painter-setPen(QPen(Qt::darkGray, 1.5)); if (isSelected()) { painter-setPen(QPen(QColor(#2E86C1), 2.5)); } painter-drawRoundedRect(rect_, 8, 8); // 標(biāo)題文字 painter-setPen(Qt::black); painter-drawText(rect_.adjusted(5, 5, -5, -5), Qt::AlignCenter, title_); // 輸入/輸出端口錨點這些點要留給連線計算用 painter-setBrush(Qt::white); painter-setPen(QPen(Qt::black, 1)); for (const QPointF p : inputPortPositions()) { painter-drawEllipse(p, 5, 5); } for (const QPointF p : outputPortPositions()) { painter-drawEllipse(p, 5, 5); } }端口位置的計算是個重點。以輸出端口為例左右各放一個豎直方向排在節(jié)點中部輸入端口在左側(cè)輸出端口在右側(cè)判斷節(jié)點的話左右兩端都可以輸出。inputPortPositions和outputPortPositions要在返回QPointF時調(diào)用mapToScene或者直接返回本地坐標(biāo)由外界再映射。我傾向于在節(jié)點內(nèi)部返回場景坐標(biāo)因為連線需要直接使用。2.3 圖元的數(shù)據(jù)模型別把業(yè)務(wù)數(shù)據(jù)塞進(jìn)Item里這是我認(rèn)為新手最容易踩的坑。早期我圖省事把所有字段全塞進(jìn)FlowNodeItem結(jié)果遇到重新打開工程文件和撤銷重做需求時發(fā)現(xiàn)要么序列化邏輯散落得到處都是要么一個節(jié)點對象承擔(dān)了顯示和業(yè)務(wù)雙重職責(zé)代碼越改越亂?,F(xiàn)在的做法是單獨維護一個FlowDataModelstruct FlowNodeData { QString nodeId; FlowNodeItem::NodeType type; QString title; QPointF position; QJsonObject extraProps; }; struct FlowEdgeData { QString edgeId; QString sourceNodeId; int sourcePortIndex; QString targetNodeId; int targetPortIndex; };FlowNodeItem只負(fù)責(zé)把FlowNodeData渲染出來并負(fù)責(zé)拖拽移動后的坐標(biāo)回寫。其他所有邏輯包括連線匹配、刪除、保存都操作FlowNodeData和FlowEdgeData而不是直接操作Item。這樣設(shè)計帶來的直接好處是你需要保存的數(shù)據(jù)結(jié)構(gòu)和UI狀態(tài)天然分離。做撤銷重做時只需對數(shù)據(jù)做快照做JSON序列化時直接遍歷數(shù)據(jù)列表輸出不用從圖形Item身上反推。否則等流程圖復(fù)雜到幾百個節(jié)點時再拆模型就痛苦了。3. 錨點與連線流程圖編輯器最繞的設(shè)計決策3.1 連接端子Port的數(shù)據(jù)結(jié)構(gòu)設(shè)計節(jié)點與節(jié)點之間靠什么連接很多教程里直接用節(jié)點中心點連線那是拓?fù)鋱D的做法流程圖里更接近真實的做法是端口到端口源節(jié)點的一個輸出端口連到目標(biāo)節(jié)點的一個輸入端口。我按端口索引來標(biāo)識而不是按端口對象。理由很簡單端口坐標(biāo)是動態(tài)的節(jié)點一移動就得重新計算存坐標(biāo)沒有意義端口也沒有全局唯一ID存對象在序列化時無法還原。所以每條邊記錄的是sourceNodeId - sourcePortIndex targetNodeId - targetPortIndexsourcePortIndex代表輸出端口列表里的第幾個targetPortIndex代表輸入端口列表里的第幾個。這樣節(jié)點定義只要保證端口數(shù)量和順序固定序列化就絕對穩(wěn)定。端口坐標(biāo)的暴露我給兩個方法QPointF FlowNodeItem::outputPortPos(int index) const { QRectF r rect_; if (index 0) return mapToScene(QPointF(r.width(), r.height() / 2.0)); // 判斷節(jié)點可能有第二輸出口 return mapToScene(QPointF(r.width(), r.height() / 2.0 12)); } QPointF FlowNodeItem::inputPortPos(int index) const { QRectF r rect_; return mapToScene(QPointF(0, r.height() / 2.0 index * 12)); }這里一定要用mapToScene讓返回坐標(biāo)在場景坐標(biāo)系下因為連線屬于場景層級不歸屬于任何節(jié)點。3.2 連線怎么畫從QGraphicsPathItem到動態(tài)更新連線的繪制方式有兩種流派一種是直接繼承QGraphicsPathItem重寫paint另一種是自定義QGraphicsItem保存路徑并自己畫。我實踐下來推薦后者因為需要在路徑變化時通知視圖刷新且想自定義箭頭和命中邏輯。連線類大致長這樣class FlowEdgeItem : public QGraphicsItem { public: FlowEdgeItem(FlowNodeItem *src, int srcPort, FlowNodeItem *dst, int dstPort); void updatePath(); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; private: QPainterPath path_; QPointF srcPoint_; QPointF dstPoint_; };updatePath()里最核心的是貝塞爾曲線的控制點計算。流程圖的連線一般不畫直線而是用三階貝塞爾讓連線看起來像繞了一下可讀性更好。控制點根據(jù)源點和終點的橫向距離動態(tài)決定void FlowEdgeItem::updatePath() { srcPoint_ sourceNode_-outputPortPos(sourcePort_); dstPoint_ targetNode_-inputPortPos(targetPort_); QPainterPath path(srcPoint_); qreal dx qMax(qAbs(dstPoint_.x() - srcPoint_.x()), 60.0); QPointF c1(srcPoint_.x() dx, srcPoint_.y()); QPointF c2(dstPoint_.x() - dx, dstPoint_.y()); path.cubicTo(c1, c2, dstPoint_); path_ path; prepareGeometryChange(); }那個qMax(..., 60)非常關(guān)鍵。如果兩個節(jié)點靠得很近而橫向距離極小控制點會擠在一起曲線拉不出弧度視覺上看起來像一條折角直線。固定一個最小控制距離后線條才會始終帶一點平滑彎曲。prepareGeometryChange()必須在路徑更新、boundingRect變化前調(diào)用否則視圖刷新的臟矩形范圍還是舊的就會出現(xiàn)殘影或線頭斷裂。3.3 連線命中檢測為什么不能用默認(rèn)方式連接線太細(xì)用戶很難點選到。QGraphicsItem的默認(rèn)shape()是線的路徑本身寬度1個像素點擊命中范圍幾乎不可用。這就需要重寫shape()給線加上命中“胖度”QPainterPath FlowEdgeItem::shape() const { QPainterPathStroker stroker; stroker.setWidth(12.0); return stroker.createStroke(path_); }QPainterPathStroker會把路徑沿線擴展出一個寬12像素的帶狀區(qū)域點擊接近線附近都能選中。12是經(jīng)驗值太小不好點太大會導(dǎo)致線之間互相遮擋框選時容易誤選。另外選中后要把線的顏色改重一些、線寬加粗避免選完自己都分不清選中了哪條。真正坑人的是另一個問題線應(yīng)該在節(jié)點下層還是上層如果節(jié)點在連線上層拖節(jié)點的時候線會被節(jié)點蓋住一部分觀感沒問題但連線如果設(shè)了ItemIsSelectable且層級在節(jié)點上方用戶在節(jié)點上拖動時可能誤觸到線。我的做法是連線永遠(yuǎn)在節(jié)點底層通過構(gòu)造時給連線設(shè)置較低Z值edgeItem-setZValue(-1);這樣節(jié)點永遠(yuǎn)在上層鼠標(biāo)拖拽節(jié)點時事件優(yōu)先派發(fā)給節(jié)點。4. 交互設(shè)計讓編輯器用起來順手的細(xì)節(jié)4.1 拖拽新建節(jié)點與框選從工具欄拖出節(jié)點我用的是一種比較輕的方案在工具欄的QListWidget里設(shè)置setDragEnabled(true)給每個項存一個Qt::UserRole里面記錄節(jié)點類型。然后在視圖上重寫dragEnterEvent和dropEventvoid FlowView::dropEvent(QDropEvent *event) { const QMimeData *mime event-mimeData(); if (mime-hasFormat(application/x-flow-node)) { QByteArray data mime-data(application/x-flow-node); int type data.toInt(); FlowNodeItem *node createNodeByType(static_castFlowNodeItem::NodeType(type)); QPointF scenePos mapToScene(event-position().toPoint()); node-setPos(scenePos); scene_-addItem(node); bindDataModel(node); // 生成FlowNodeData并追加到模型列表 event-acceptProposal(); } }注意Qt 6里的event-position()返回QPointF但它是視圖坐標(biāo)要用mapToScene轉(zhuǎn)換到場景坐標(biāo)。很多從Qt 5遷移過來的老代碼還在用event-pos()6.x里會編譯警告甚至出錯??蜻x這塊直接用的RubberBandDrag模式已經(jīng)能工作。但你如果想要只有框選到節(jié)點主體才算選中建議給節(jié)點重寫shape()時收窄范圍只返回圓角矩形本身而不是默認(rèn)矩形范圍。否則框選時只要碰到外邊距陰影也算選中容易把一堆不想選的節(jié)點捎帶上。4.2 節(jié)點移動時的連線跟隨節(jié)點移動后連線不能留在原地這是編輯器最基本的手感要求。實現(xiàn)方式有兩種在FlowNodeItem::mouseMoveEvent里手動通知所有關(guān)聯(lián)連線調(diào)用updatePath()利用場景的changed信號遍歷所有連線刷新第一種更精確第二種實現(xiàn)簡單但可能造成無謂刷新。我選擇的是在節(jié)點移動事件里集中通知void FlowNodeItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { QGraphicsObject::mouseMoveEvent(event); // 更新數(shù)據(jù)模型中的坐標(biāo) if (dataCallback_) dataCallback_(pos()); // 通知關(guān)聯(lián)連線刷新 emit nodeMoved(); // 如果本節(jié)點是拖動目標(biāo)也要同步更新 for (FlowEdgeItem *edge : connectedEdges_) { edge-updatePath(); } }這里有個細(xì)節(jié)QGraphicsObject::mouseMoveEvent內(nèi)部已經(jīng)調(diào)用了setPos所以不要再重復(fù)設(shè)置位置否則會出現(xiàn)抖動。更新數(shù)據(jù)模型的位置信息時最好在鼠標(biāo)松開后只發(fā)一次防止每次移動都觸發(fā)序列化或撤銷記錄。4.3 右鍵菜單與刪除操作的坑右鍵菜單是編輯器標(biāo)配但QGraphicsView的contextMenuEvent默認(rèn)只處理視口。我的經(jīng)驗是直接在節(jié)點圖元里處理右鍵事件因為右鍵菜單內(nèi)容往往依賴具體節(jié)點void FlowNodeItem::contextMenuEvent(QGraphicsSceneContextMenuEvent *event) { QMenu menu; QAction *editAction menu.addAction(編輯標(biāo)題); QAction *deleteAction menu.addAction(刪除節(jié)點); QAction *selected menu.exec(event-screenPos()); if (selected editAction) { emit editRequested(this); } else if (selected deleteAction) { emit deleteRequested(this); } }刪除節(jié)點時有個非常隱蔽的坑必須先刪除關(guān)聯(lián)連線再刪除節(jié)點本身。如果順序反了連線的sourceNode_或targetNode_指針會變成懸垂指針在updatePath()里調(diào)用outputPortPos()時直接崩潰。我習(xí)慣封裝一個deleteNode(node)函數(shù)void MainWindow::deleteNode(FlowNodeItem *node) { // 收集所有關(guān)聯(lián)邊 QListFlowEdgeItem* edges node-connectedEdges(); for (FlowEdgeItem *edge : edges) { scene_-removeItem(edge); delete edge; model_.edges.removeAll(edge-edgeData()); } scene_-removeItem(node); delete node; model_.nodes.removeAll(node-nodeData()); }QGraphicsItem的刪除還有個細(xì)節(jié)scene_-removeItem(item)只把item從場景移除不會delete對象必須手動delete否則內(nèi)存泄漏。如果你用delete item而item仍在場景里則會在銷毀時觸發(fā)斷言崩潰。順序一定是先remove再從場景刪除必要時加QPointer保護。5. 持久化與撤銷從Demo到工具的分水嶺5.1 用JSON序列化整個流程圖一個流程圖編輯器如果只能畫不能存那永遠(yuǎn)是玩具。Qt的QJsonDocument和QJsonObject足以滿足這個需求。我把序列化分成三步節(jié)點、連線、視圖狀態(tài)。QJsonObject FlowEditorModel::toJson() const { QJsonObject root; QJsonArray nodesArray; for (const FlowNodeData nd : nodes_) { QJsonObject obj; obj[id] nd.nodeId; obj[type] static_castint(nd.type); obj[title] nd.title; obj[x] nd.position.x(); obj[y] nd.position.y(); nodesArray.append(obj); } root[nodes] nodesArray; QJsonArray edgesArray; for (const FlowEdgeData ed : edges_) { QJsonObject obj; obj[id] ed.edgeId; obj[source] ed.sourceNodeId; obj[sourcePort] ed.sourcePortIndex; obj[target] ed.targetNodeId; obj[targetPort] ed.targetPortIndex; edgesArray.append(obj); } root[edges] edgesArray; return root; }反序列化時要特別注意節(jié)點順序。因為邊緣數(shù)據(jù)里引用的是節(jié)點ID如果節(jié)點還沒創(chuàng)建完就開始建連線查找ID會失敗。正確順序是清空場景和舊數(shù)據(jù)遍歷nodes數(shù)組創(chuàng)建所有節(jié)點建立一個QHashQString, FlowNodeItem*的ID映射表遍歷edges數(shù)組用映射表找到兩端節(jié)點創(chuàng)建連線恢復(fù)視圖縮放中心點這個順序只要亂一步就會遇到連線全丟或連線錯位的詭異現(xiàn)象。5.2 簡版撤銷/重做的實現(xiàn)思路做完整Command模式需要不少篇幅這里我提供一個夠用的快照式方案在每次結(jié)構(gòu)性操作添加節(jié)點、刪除節(jié)點、添加連線、刪除連線、拖動結(jié)束之前把整個流程圖的JSON快照壓入撤銷棧。void MainWindow::pushUndoState() { undoStack_.push(editorModel_.toJson()); if (undoStack_.size() 50) undoStack_.removeFirst(); redoStack_.clear(); }撤銷void MainWindow::undo() { if (undoStack_.isEmpty()) return; QJsonObject current editorModel_.toJson(); redoStack_.push(current); QJsonObject target undoStack_.pop(); loadFromJson(target); }這個方案有個前提拖動節(jié)點時不能每一步都push否則一個拖動動作會生成幾十條歷史記錄撤銷一下退一個像素體驗極差。所以前面代碼里我專門用dataCallback_只在鼠標(biāo)松開時同步坐標(biāo)并在同一個回調(diào)里push當(dāng)前快照??煺帐匠蜂N的缺點是節(jié)點多時內(nèi)存占用偏高但流程圖編輯器通常節(jié)點數(shù)在幾十到幾百這個量級JSON也就幾百KB完全夠用。真要追求內(nèi)存極致優(yōu)化再換Command模式也不遲。6. 踩坑實錄與性能優(yōu)化6.1 高DPI縮放下的渲染問題Qt 6默認(rèn)啟用了高DPI縮放但QGraphicsView里如果圖元沒有正確處理設(shè)備像素比會出現(xiàn)文字模糊或線條粗細(xì)不一。多數(shù)情況下你只需要在main函數(shù)里設(shè)置QApplication::setHighDpiScaleFactorRoundingPolicy(Qt::HighDpiScaleFactorRoundingPolicy::PassThrough);然后讓所有圖元繪制時使用painter-setRenderHint(QPainter::Antialiasing)即可。更隱蔽的一個坑是當(dāng)你用scale縮放視圖時QGraphicsItem的pen寬度也會被縮放。流程圖縮放后線寬變粗或變細(xì)視覺上很破壞觀感。解決辦法是讓圖元忽略視圖縮放重新計算合適線寬void FlowEdgeItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { qreal scaleFactor painter-transform().m11(); QPen pen(QColor(#34495E), 2.0 / scaleFactor); painter-setPen(pen); painter-drawPath(path_); }這樣不管視圖放大多少倍線寬始終是視覺上的2像素。6.2 大量圖元時的性能怎么兜底如果把流程圖編輯器擴展到幾百上千個節(jié)點你會立刻感到拖動和縮放卡頓。我的優(yōu)化優(yōu)先級是開啟ItemIgnoresTransformations標(biāo)志對節(jié)點上的文本標(biāo)簽和端口小圓圈設(shè)置該標(biāo)志避免它們隨縮放頻繁重繪變換。關(guān)閉不必要的繪制在paint里判斷option-levelOfDetailFromTransform(painter-transform())縮放過小時不繪制文字只畫色塊。局部刷新確認(rèn)setViewportUpdateMode不是FullViewportUpdate否則一次拖動全屏重繪算法再好也白搭。避免頻繁update()整場景連線updatePath后調(diào)用update(boundingRect())而不是scene_-update()。實測在2000個節(jié)點、3000條邊的情況下上述優(yōu)化能把交互幀率從個位數(shù)拉到30FPS以上作為流程圖編輯器完全可接受。6.3 我的建議先把交互模型想清楚再動手寫代碼寫到最后我想說點項目層面的體會。第一次做這類編輯器很容易被QGraphicsView的萬能感帶著走想到什么加什么最后代碼變成一團亂麻。我吃過大虧之后現(xiàn)在習(xí)慣開工前先畫一張交互狀態(tài)表比如空場景左鍵拖拽是框選節(jié)點上左鍵拖拽是移動節(jié)點按Ctrl加左鍵是追加選中在連線上雙擊是刪除連線。這些規(guī)則先定死后面實現(xiàn)就是按規(guī)則填代碼。另外建議把數(shù)據(jù)模型這個模塊放在第一位。哪怕第一版只支持三個節(jié)點也要先定義好FlowNodeData和FlowEdgeData讓所有操作走模型再反映到視圖。這是整個項目后期能不能持續(xù)擴展的分水嶺比任何渲染技巧都重要。這套方案我已經(jīng)在自己的內(nèi)部工具里用了一年多從最初只能畫節(jié)點到現(xiàn)在支持條件分支、子流程跳轉(zhuǎn)底層框架幾乎沒動過。你現(xiàn)在照著做一版簡易編輯器后面想加什么功能都會順手很多。