調(diào)度組件)
簡介一套完整的Qt甘特圖組件源碼GanttMel-master 項目面向需要在桌面應(yīng)用中展示任務(wù)時間線、項目進(jìn)度或進(jìn)行項目調(diào)度的Qt開發(fā)者及相關(guān)學(xué)習(xí)者。資源包共27個文件主要包含C源代碼cpp/h、Qt界面文件ui、Visual Studio工程文件sln/vcxproj以及可執(zhí)行程序exe和說明文檔md整體僅約1.69MB便于快速下載與編譯研究。已有859人學(xué)習(xí)/瀏覽特別適合用來研究QGraphicsView框架下的自定義繪圖、模型-視圖-控制器MVC數(shù)據(jù)綁定、時間軸坐標(biāo)轉(zhuǎn)換、事件處理與動畫過渡、布局滾動與資源管理等關(guān)鍵機(jī)制的工程化實現(xiàn)。通過閱讀源碼可掌握如何重寫paint()方法繪制任務(wù)條形、將日期時間映射為像素坐標(biāo)、監(jiān)聽鼠標(biāo)鍵盤事件實現(xiàn)拖動與縮放并能將該組件遷移或直接集成到現(xiàn)有Qt項目管理界面中同時工程附帶可執(zhí)行程序便于對照查看運行效果也可基于此版本繼續(xù)擴(kuò)展里程碑、任務(wù)依賴等高級功能。 做Qt上位機(jī)或者項目管理系統(tǒng)總會遇到一個繞不開的需求甘特圖。Qt官方?jīng)]有一套開箱即用的甘特圖控件第三方庫又普遍偏輕量遇到真實業(yè)務(wù)里的拖拽改期、里程碑、任務(wù)依賴、跨泳道移動基本都得自己動手。我第一次做這功能時圖省事基于QTableWidget加委托繪制上線跑了半年問題越積越多最后忍無可忍用QGraphicsView體系重寫了一套這就是標(biāo)題里說的“另一個版本”。這篇記錄的就是整個重寫過程從第一版為什么扛不住到第二版怎么拆架構(gòu)、寫交互、調(diào)性能再到打包發(fā)布時踩過的坑給同樣在Qt里做甘特圖或同類自定義控件的同學(xué)做個參考。如果你只是想畫個靜態(tài)展示的條狀圖用QTableWidget甚至直接在paintEvent里畫都夠用。但一旦涉及大量任務(wù)、拖動、縮放這類高頻交互那套方案會讓你越改越難受。下面我把兩版的差異、關(guān)鍵數(shù)據(jù)結(jié)構(gòu)和實際踩坑細(xì)節(jié)攤開講清楚。1. 第一版甘特圖被淘汰的原因QTableWidget方案的三個硬傷1.1 數(shù)據(jù)量一上去刷新就肉眼可見地卡第一版實現(xiàn)很直接QTableWidget每個任務(wù)占一行固定列寬表示一天任務(wù)條通過QStyledItemDelegate在單元格里畫出來。任務(wù)在幾十條以內(nèi)時看不出問題一旦到了幾百條任務(wù)、跨三四年時間軸卡頓立刻就來了。QTableWidget是按“單元格”管理的控件滾動、縮放、編輯任意一個單元格都可能觸發(fā)整片視口重繪你的任務(wù)條繪制邏輯會被反反復(fù)復(fù)調(diào)用而其中大部分其實根本不在可見區(qū)域。更要命的是第一版把“日期”映射到了“列寬”想做按小時排程就得把列寬縮到很小可任務(wù)名稱又要占據(jù)一列寬度整張表就會變得非常怪異。后來我意識到用表格的行列結(jié)構(gòu)去模擬時間軸本質(zhì)上就是拿錯誤的數(shù)據(jù)模型硬湊視圖需求越往后越疼。1.2 拖拽改期和跨泳道移動事件處理繞到懷疑人生第一版的需求還沒那么復(fù)雜只要求任務(wù)條能拖動改期、能從一個泳道挪到另一個泳道。QTableWidget上做這件事要么重寫viewportEvent要么在單元格里塞定時器加鼠標(biāo)跟蹤自己去判斷鼠標(biāo)是不是拖到了邊緣、要不要自動滾動、當(dāng)前懸停的是哪一行。尤其當(dāng)任務(wù)條只占單元格的一部分時hitTest邏輯非常別扭鼠標(biāo)事件還會被ItemDelegate截走處理優(yōu)先級亂七八糟。我記得當(dāng)時為了做“拖到視口邊緣自動滾動”這個功能前后改了兩個星期。每次滾動都觸發(fā)update()然后update()又觸發(fā)繪制繪制里又去做命中判斷整個事件循環(huán)像一團(tuán)亂麻。最終雖然能跑但代碼里全是補(bǔ)丁任何一個新需求進(jìn)來都會牽一發(fā)動全身。1.3 重新選型時的方案對比第二次動手之前我認(rèn)真列了一遍候選方案包括繼續(xù)在QWidget子類里自繪、用QCustomPlot硬畫、以及徹底轉(zhuǎn)QGraphicsScene。對比下來結(jié)論相當(dāng)明確方案繪制方式交互復(fù)雜度大數(shù)據(jù)量表現(xiàn)適用場景QTableWidget Delegate單元格重繪難做拖拽/縮放數(shù)千條以上吃力簡單排程、只讀展示QWidget::paintEvent全量自繪中等滾屏需自己優(yōu)化全量重繪時會卡靜態(tài)圖表QGraphicsScene/ViewItem局部更新拖拽、命中、局部刷新都很順手幾千到幾萬可控制需高頻交互的自定義圖QCustomPlot圖層化數(shù)據(jù)曲線不適合做任務(wù)條拖拽大數(shù)組曲線很優(yōu)秀波形、頻譜等數(shù)據(jù)圖QCustomPlot畫波形和頻譜確實強(qiáng)我也在同一套上位機(jī)軟件里拿它做了串口數(shù)據(jù)的時域波形和頻域分析但拿它做甘特圖這種需要逐任務(wù)響應(yīng)鼠標(biāo)事件的場景遠(yuǎn)不如QGraphicsScene自然。場景框架本身提供了item級緩存、碰撞檢測和局部更新相當(dāng)于把最難的渲染調(diào)度問題先解決了一半。2. 第二版甘特圖的骨架設(shè)計場景、泳道、任務(wù)條怎么分層2.1 三個核心類的劃分與職責(zé)第二版架構(gòu)我分成三層最外層是GanttView繼承QGraphicsView負(fù)責(zé)處理縮放、滾動、坐標(biāo)換算中間層是QGraphicsScene只做item管理和事件轉(zhuǎn)發(fā)最內(nèi)層是若干個QGraphicsObject派生類包括時間軸TimeRulerItem、泳道背景LaneItem、任務(wù)條TaskItem、依賴箭頭DependencyItem。這里最關(guān)鍵的一個決策是業(yè)務(wù)數(shù)據(jù)模型和視圖item徹底分離。我在數(shù)據(jù)層定義一個Task結(jié)構(gòu)體里面只存任務(wù)id、父任務(wù)id、名稱、開始日期、持續(xù)天數(shù)、進(jìn)度、所屬泳道等字段完全不存任何像素坐標(biāo)。視圖層的TaskItem持有Task的指針或副本所有位置信息在paint和布局時臨時計算。struct Task { int id; int parentId; QString name; QDate startDate; int durationDays; int progress; // 0~100 int laneId; QListint dependOn; };這樣做的好處是縮放時間軸時任務(wù)條的位置全部根據(jù)新的dayWidth重新計算不用去改數(shù)據(jù)保存項目時直接把Task序列化成JSON就行不用從item一個字段一個字段往回?fù)浮?.2 日期-像素坐標(biāo)換算甘特圖的“計量單位”要統(tǒng)一甘特圖所有繪制和交互的基礎(chǔ)就是日期和像素之間的換算。我在GanttView里維護(hù)兩個核心變量m_beginDate時間軸起點日期和m_dayWidth每個像素對應(yīng)的天數(shù)縮放時改變。qreal GanttView::dateToX(const QDate date) const { return m_beginDate.daysTo(date) * m_dayWidth; } QDate GanttView::xToDate(qreal x) const { return m_beginDate.addDays(static_castint(x / m_dayWidth)); }這里有個細(xì)節(jié)值得注意換算時統(tǒng)一用“天”做單位不要混用小時、月。如果需求里有精確到小時的排程你可以把基準(zhǔn)時間改成分鐘但整個視圖里所有換算都必須走同一個函數(shù)絕不允許在業(yè)務(wù)代碼里到處直接寫x date.dayOfYear() * 18這種裸計算。我第一版就吃過這種虧三處代碼分別用日、周、月做軸最后拼在一起完全對不上。任務(wù)條寬度就是durationDays * m_dayWidth起點就是dateToX(startDate)。數(shù)據(jù)發(fā)生變化時只需要調(diào)用一次TaskItem::updateGeometry()所有位置都能同步。2.3 任務(wù)條繪制細(xì)節(jié)進(jìn)度、里程碑、依賴關(guān)系怎么畫TaskItem繼承QGraphicsObject重寫boundingRect()和paint()。boundingRect()里除了任務(wù)條本身的矩形還向外擴(kuò)幾個像素不然抗鋸齒產(chǎn)生的邊緣會被裁剪掉看起來像毛邊。繪制任務(wù)條主體的代碼大致是這樣void TaskItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option); Q_UNUSED(widget); const QRectF barRect boundingRect().adjusted(1, 1, -1, -1); painter-setPen(QPen(QColor(#3B82F6), 1.0)); painter-setBrush(QColor(#EFF6FF)); painter-drawRoundedRect(barRect, 4.0, 4.0); // 進(jìn)度填充 QRectF progressRect barRect; progressRect.setWidth(barRect.width() * m_task.progress / 100.0); painter-setBrush(QColor(#3B82F6)); painter-drawRoundedRect(progressRect, 4.0, 4.0); // 里程碑畫成菱形 if (m_task.durationDays 0) { painter-setBrush(QColor(#F59E0B)); painter-drawPolygon(milestonePoints()); } }進(jìn)度、里程碑、依賴箭頭的顏色和形狀盡量做成可配置項不要在繪制代碼里散落幾十個魔法值。QPen、QBrush這類對象創(chuàng)建開銷也不小高頻繪制時能提為成員就提為成員能緩存就緩存。依賴關(guān)系我用獨立的DependencyItem做負(fù)責(zé)在父任務(wù)尾部畫一條線連到子任務(wù)頭部帶箭頭。這個item不用QGraphicsPathItem重寫而是直接在paint里用QPainterPath畫貝塞爾曲線好處是保存、更新、命中檢測比較統(tǒng)一后續(xù)要加“依賴類型”FS、SS、FF也好擴(kuò)展。3. 把交互做順手拖拽、縮放、編輯器的實現(xiàn)與防坑3.1 任務(wù)條拖拽移動、改期、跨泳道拖拽是甘特圖交互的核心。TaskItem重新實現(xiàn)三個鼠標(biāo)事件注意所有坐標(biāo)盡量用scene坐標(biāo)不要用視圖坐標(biāo)否則滾動時會產(chǎn)生偏移。void TaskItem::mousePressEvent(QGraphicsSceneMouseEvent *event) { m_dragOffsetX event-scenePos().x() - scenePos().x(); m_originalStartDate m_task.startDate; m_originalLaneId m_task.laneId; setCursor(Qt::ClosedHandCursor); event-accept(); } void TaskItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { QPointF scenePos event-scenePos(); qreal newX scenePos.x() - m_dragOffsetX; int dayOffset m_view-xToDate(newX).daysTo(m_originalStartDate); if (!m_snapToDay) { dayOffset qRound(scenePos.x() / m_view-dayWidth()); dayOffset m_originalStartDate.daysTo(m_view-beginDate()) dayOffset; } QDate newStart m_originalStartDate.addDays(dayOffset); setStartDate(newStart, /* notifyData */ false); // 跨泳道判斷 if (LaneItem *lane m_view-laneAtY(scenePos.y())) { setY(lane-contentTop() m_task.laneId * lane-height()); m_task.laneId lane-laneId(); } event-accept(); } void TaskItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { setCursor(Qt::ArrowCursor); if (m_task.startDate ! m_originalStartDate || m_task.laneId ! m_originalLaneId) { emit taskChanged(m_task); } event-accept(); }拖拽過程中我先不修改底層數(shù)據(jù)庫只更新item顯示等鼠標(biāo)釋放那一刻確認(rèn)確實有變化再發(fā)信號出去。這里有個很微妙的點拖拽事件里如果每次都把數(shù)據(jù)寫到持久層會造成大量無意義的保存操作還不能撤銷。做成“釋放時統(tǒng)一提交”之后配合重做棧就很舒服??缬镜酪苿游也捎脤崟r吸附在mouseMoveEvent里查鼠標(biāo)當(dāng)前位置下的LaneItem只要換行了就更新y坐標(biāo)。泳道的y用固定行高乘泳道序號計算不依賴泳道item內(nèi)部復(fù)雜的padding這樣即使某條泳道高度不同也能正確落位。3.2 時間縮放與滾動聯(lián)動縮放邏輯放在GanttView::wheelEvent里用Ctrl滾輪觸發(fā)因為甘特圖本身用滾輪滾動很頻繁不能占用普通滾輪事件。void GanttView::wheelEvent(QWheelEvent *event) { if (event-modifiers() Qt::ControlModifier) { const double factor event-angleDelta().y() 0 ? 1.25 : 0.8; const double newDayWidth qBound(2.0, m_dayWidth * factor, 40.0); if (qFuzzyCompare(newDayWidth, m_dayWidth)) return; QPointF anchor mapToScene(event-position().toPoint()); QDate anchorDate xToDate(anchor.x()); m_dayWidth newDayWidth; layoutItems(); // 讓所有任務(wù)條按新寬度重排 updateTimeRuler(); // 讓鼠標(biāo)所在位置對應(yīng)的日期保持不動避免縮放后視角亂跳 qreal newAnchorX dateToX(anchorDate); horizontalScrollBar()-setValue( static_castint(newAnchorX - anchor.x())); event-accept(); return; } QGraphicsView::wheelEvent(event); }以鼠標(biāo)位置為錨點這個細(xì)節(jié)特別重要。不加錨點時每次縮放當(dāng)前視野就會跳走用戶需要反復(fù)拖動滾動條才能回到原來在看的區(qū)域體感很差。加完錨點之后縮放手感接近地圖App用戶會很自然地把注意力放在鼠標(biāo)指向的那個任務(wù)上??s放時任務(wù)條的刷新策略我采用的是重新計算全局dayWidth后遍歷現(xiàn)有item調(diào)用prepareGeometryChange()再批量update()。不要用view-scale()去整體縮放畫布那樣雖然位置能變但文字、線條會模糊而且item的boundingRect和實際顯示區(qū)域會錯位。3.3 “點兩下改名”和內(nèi)部編輯器的焦點控制在視圖內(nèi)直接編輯任務(wù)名我用的是“雙擊一個TaskItem后在任務(wù)條上方臨時創(chuàng)建一個QLineEdit”的方案。直接scene()-addWidget()彈出來的編輯框在Qt 5.15上經(jīng)常遇到輸入法彈出、焦點亂跳的問題特別是中文輸入場景下很容易崩。后來我改成讓編輯框公用一個QGraphicsProxyWidget雙擊時移動到目標(biāo)item位置并顯示編輯完隱藏而不是每次銷毀重建。還有一個坑是TaskItem雙擊進(jìn)入編輯狀態(tài)后鼠標(biāo)拖拽邏輯會和編輯框焦點沖突。我的處理是進(jìn)入編輯時給那個TaskItem掛一個Qt::WidgetWithChildren不響應(yīng)鼠標(biāo)的flag退出編輯再恢復(fù)避免用戶在編輯框里選中文字時誤觸發(fā)任務(wù)條拖拽。這套邏輯雖然小但踩過坑的人都會知道有多煩。4. 性能優(yōu)化幾千個任務(wù)節(jié)點不卡的做法4.1 渲染瓶頸核心避免無效更新很多人覺得甘特圖卡是因為QPainter畫得慢其實真正的問題往往是無效更新。在QGraphicsScene里scene-update()是全場景重繪任務(wù)只有幾十條還好任務(wù)一多直接觸發(fā)海量item的paint調(diào)用。我改成這樣幾個策略視圖級設(shè)置setViewportUpdateMode(QGraphicsView::MinimalViewportUpdate)讓場景只重畫變化區(qū)域。任務(wù)條移動、縮放時只調(diào)用該item的update()和相關(guān)區(qū)域絕不全局scene-update()。QGraphicsItem::setCacheMode(QGraphicsItem::DeviceCoordinateCache)對靜態(tài)背景和泳道陰影很有用但對頻繁變化的進(jìn)度填充反而會增加緩存失效開銷所以只給背景層用。有一個崩潰隱患必須單獨提醒絕對不要在paint()里調(diào)用update()或者任何會觸發(fā)布局重算的函數(shù)這會讓Qt進(jìn)入重繪遞歸輕則閃爍重則直接棧溢出崩潰。我第一次重寫時在一個公共繪制函數(shù)里加了刷新調(diào)用結(jié)果程序一打開窗口就崩排查了一整天才發(fā)現(xiàn)是遞歸重繪。4.2 可見區(qū)域裁剪和任務(wù)條復(fù)用性能的另一個大頭是裁剪。甘特圖時間軸跨度經(jīng)常好幾年任務(wù)可能有幾千條但屏幕上一屏只能顯示幾十條。處理辦法很直接GanttView在滾動結(jié)束、縮放結(jié)束時計算當(dāng)前視口對應(yīng)的日期范圍只讓這個范圍內(nèi)的任務(wù)條保持可見范圍外的TaskItem::setVisible(false)。void GanttView::updateVisibleRange() { const QRectF visibleRect mapToScene(viewport()-rect()).boundingRect(); const QDate begin xToDate(visibleRect.left()); const QDate end xToDate(visibleRect.right()); for (TaskItem *item : m_taskItems) { const bool visible item-startDate() end item-endDate() begin; item-setVisible(visible); } }幾千條任務(wù)的數(shù)據(jù)量下全部創(chuàng)建TaskItem加入場景也就幾MB內(nèi)存完全不必做復(fù)雜的虛擬化懶加載幾萬條以上再考慮按可見范圍動態(tài)創(chuàng)建和銷毀。用setVisible(false)隱藏的好處是切回時不用重新構(gòu)造對象滾動起來不會有“突然冒出空位”的錯覺。實際測下來一個1500行任務(wù)的甘特圖在縮放、拖拽、滾動三種操作下都能穩(wěn)定60幀左右性能已經(jīng)完全夠用。4.3 與qcustomplot頻譜繪制的橫向?qū)Ρ冉?jīng)驗我在這個上位機(jī)項目里另一塊負(fù)責(zé)串口數(shù)據(jù)采集后顯示時域波形再用kissfft做時域轉(zhuǎn)頻域最后交給qcustomplot繪制頻譜圖。這兩塊功能用到的優(yōu)化經(jīng)驗和甘特圖是共通的qcustomplot里大數(shù)據(jù)量曲線要用setData()批量更新而不是一條一條addData()QGraphicsScene里則要避免全場景update。兩者的核心思路都是“最小化重繪區(qū)域”。如果你在做類似的Qt項目把甘特圖、波形顯示、圖像處理這些功能拆分成獨立模塊每個模塊只管自己的繪制和交互互相之間只用信號槽通信后期會省很多事。尤其是和halcon這類第三方SDK集成時底層處理線程和UI層徹底分開才不會出現(xiàn)“一刷新就卡死”的玄學(xué)問題。5. 收尾與發(fā)布打包、崩潰排查與再擴(kuò)展5.1 windeployqt發(fā)布和運行時崩潰問題項目開發(fā)完用windeployqt打包發(fā)布到?jīng)]有安裝Qt環(huán)境的機(jī)器上最常見的報錯就是This application failed to start because no Qt platform plugin could be initialized. Reinstalling the application may fix this problem.這個報錯的根源基本都出在platforms目錄缺失或版本不匹配。發(fā)布目錄里必須存在platforms/qwindows.dll而且這個dll的位數(shù)、編譯套件要跟exe一致。我剛發(fā)布時用mingw版Qt打了一個包又用msvc版補(bǔ)了一個dll結(jié)果32位和64位混在一起雙擊直接彈這個錯誤。另外windeployqt不會自動帶上qcustomplot.dll這類第三方庫需要手動復(fù)制或者用windeployqt --no-translations之后再補(bǔ)拷。建議打包完成后用一個干凈的虛擬機(jī)或未裝Qt的電腦做最后驗證不要想當(dāng)然。5.2 崩在鼠標(biāo)事件里的常見原因重寫過程中我遇到的崩潰絕大多數(shù)集中在拖拽事件和item增刪時段。最典型的一個場景任務(wù)條在mouseMoveEvent里觸發(fā)了對某個item的刪除釋放鼠標(biāo)時Qt內(nèi)部還在遍歷場景的事件分發(fā)列表于是懸空指針直接崩。解決辦法是刪除item時不要手動delete要scene-removeItem(item)之后再deleteLater()讓事件循環(huán)安全回收。另一個容易崩的點是遍歷scene-items()的過程中修改場景結(jié)構(gòu)。比如在拖拽回調(diào)里調(diào)用scene-addItem()而外層正好在items()返回的列表上迭代容器失效就會崩。所有需要批量修改item的操作統(tǒng)一先收集到一個臨時列表迭代完成后再統(tǒng)一操作。另外給TaskItem里的業(yè)務(wù)對象用QPointer包裹能很大程度避免“數(shù)據(jù)被釋放但item還在引用”的野指針問題。5.3 后續(xù)擴(kuò)展方向第二版做完穩(wěn)定后我已經(jīng)把“任務(wù)依賴”“里程碑”“多泳道”“進(jìn)度顯示”“拖拽改期”“Ctrl滾輪縮放”都跑通了。后續(xù)如果繼續(xù)擴(kuò)展可以在這個基礎(chǔ)上加子任務(wù)折疊、關(guān)鍵路徑高亮、導(dǎo)入導(dǎo)出MS Project格式、撤銷重做棧。數(shù)據(jù)模型和視圖分離的設(shè)計讓這些功能都有比較清晰的落點不會像第一版一樣想加一個功能就得把所有繪制代碼重寫一遍。關(guān)于開發(fā)環(huán)境我個人的建議是做這種重度信號槽交互、經(jīng)常需要調(diào)試崩潰的項目還是用Qt Creator最順手信號槽斷點、對象樹檢視都是神器。VS Code配合Qt Designer也能寫編輯體驗好但排查Qt內(nèi)部信號槽調(diào)用鏈時效率明顯低一截。如果沒特殊偏好主開發(fā)用Qt Creator日常寫純C邏輯再用VS Code兩者配合會舒服很多。這次重寫給我最深的體會是甘特圖這種組件表面的功能只是畫條狀圖真正的難度都在交互和性能上。第一次用QTableWidget本質(zhì)上是拿表格思維硬套時間軸需求第二次換到QGraphicsScene等于先把渲染和交互的地基鋪好后面每加一個功能都輕松得多。如果你也正在Qt里被類似的自繪控件折磨不妨早點考慮場景框架這條路雖然剛開始會有一段學(xué)習(xí)成本但長遠(yuǎn)看非常值得。本文還有配套的精品資源點擊獲取