资讯动态

Qt甘特图自绘方案:QWidget+QPainter实现万级任务拖拽

发布时间:2026/10/26 4:04:36 来源:尧图企业网站定制
简介面向 Qt 桌面应用开发者与项目管理界面实现者的甘特图组件源码包定位为区别于常见标准实现的定制版本适合需要快速搭建任务时间线、进度条与交互式排期视图的中级开发者参考。压缩包共 27 个文件约 1.69MB以 5 个 cpp 与 5 个 h 文件构成核心绘图和业务逻辑配合 ui 界面文件、ini 配置、qrc 资源、sln/vcxproj 工程及 exe 示例便于直接编译体验与二次开发。已有 861 人学习说明其在 Qt 甘特图实践中具有一定参考价值。代码围绕 QGraphicsView/QGraphicsItem 组织任务条形涉及时间轴与时间戳映射、MVC 数据绑定、鼠标事件处理、自定义 paint 绘制、滚动布局及样式配置等环节读者可据此理解任务起止时间、完成状态与界面元素的对应关系并借鉴其模块划分和扩展思路将组件集成到现有 Qt 项目中。1. 一份不依赖 QGraphicsView 的 qt 甘特图到底替谁干活翻过好几个 qt 甘特图 实现之后我把工程里那套图元场景版本卸了换成这份“另一个版本”纯 QWidget 加 QPainter 自绘没有 QGraphicsScene没有上万个小图元时间轴换算、行索引、依赖箭头三件事各管一段。它解决的不是“画得好看”而是拖拽改期、折叠父任务、万级任务滚动这三类真会卡死的场景。适合手里已经有一棵任务树、需要把计划排到天甚至半小时粒度、又不想为了一个控件引入整套图形框架的桌面端开发者。代价也明确缩放动画、复杂框选这些得自己补下面把边界一并说清。2. 拆开源码三个坐标系与一条扁平索引2.1 选型为什么放弃 QGraphicsView 图元方案甘特图长得像图形本质却是表格。横轴是时间纵轴是行号任务条永远被约束在某一行里行高固定列宽由时间刻度决定。这个约束在图元场景里反而成了负担每条任务要做矩形图元每个箭头要做 path 图元折叠父任务时得批量 setVisible滚动时表头要和视图同步 transform任何一个环节没对齐行就会错位半个像素。纯自绘的账算得过来。绘制入口只有一个 paintEvent我在里面按固定顺序调五个函数谁盖谁一眼看得清命中测试自己写一个坐标点先除行高得到行号再用时间轴反算出时刻两条算式搞定不用 scene()-items(pos) 做图元遍历滚动同步就是把 scrollY 塞进表头的绘制偏移里不需要两个视图互相追。对比一下两种做法的实际差别维度QGraphicsView 图元方案QWidget QPainter 自绘任务量 500 以内舒服动画、缩放基本白送要自己写能接受任务量 5000 以上图元管理开销明显滚动易掉帧只画可见行稳定行高不统一天然支持需要改成行高数组折叠/展开批量 setVisible易漏重建一次可见行索引命中测试交给场景自己写二十行复杂框选、动画现成得补我一般建议任务数长期在两千以内、后续要加缩放动画和自由拖拽画布就用图元方案任务数会往上走、行高统一、交互只到“拖条改期 拉边改工期”这个程度自绘更省心。这份源码是后者。2.2 任务模型用 UTC 秒存储用扁平索引定位模型层只做两件事存任务和依赖以及把树拍平成一份可见行索引。任务结构刻意做小凡是可以算出来的比如结束时间、父任务汇总区间都不存避免两份数据打架。// gantttypes.h 任务与依赖的最小结构 enum class TaskKind { Task, Summary, Milestone }; struct GanttTask { int id -1; int parentId -1; // -1 表示根节点 QString name; QDateTime start; // 统一按 UTC 存显示时再转本地 QDateTime finish; int progress 0; // 0~100 TaskKind kind TaskKind::Task; bool collapsed false; // 仅 Summary 有意义 QColor barColor; // 留空则按 kind 取默认色 }; struct GanttLink { int fromId -1; int toId -1; enum Type { FS, SS, FF, SF } type FS; // 完成-开始 / 开始-开始 / … };start和finish用 QDateTime 存但内部一律取toSecsSinceEpoch()参与计算这是后面避坑章节要展开的关键点只要参与像素换算的量是本地时间跨时区或跨夏令时的时候任务条一定会飘。parentId而不是子节点数组是因为任务数据通常来自数据库或 Excel父节点引用的形式更好增量更新拍平的时候做一次 DFS 就行。拍平的结果是三个容器m_flat保存所有任务含被折叠隐藏的m_visibleRows保存当前可见任务在m_flat里的下标m_rowTopCache保存每一可见行的顶部 y 坐标。// 重建可见行与行顶缓存折叠、排序、增删任务后都要走一遍 void GanttModel::rebuild(const QSetint collapsedIds) { m_flat.clear(); m_visibleRows.clear(); // 1) 先按父子关系拍平父节点后紧跟子节点这是甘特图的常规排布 std::functionvoid(int) walk [](int parentId) { for (const GanttTask t : m_children.value(parentId)) { const int row m_flat.size(); m_flat.append(t); m_rowOfId.insert(t.id, row); if (!(t.kind TaskKind::Summary collapsedIds.contains(t.id))) m_visibleRows.append(row); walk(t.id); // 折叠也要递归只是不加进可见行 } }; walk(-1); // 2) 行顶缓存固定行高所以是等差数列直接累加即可 m_rowTopCache.resize(m_visibleRows.size()); int y 0; for (int i 0; i m_visibleRows.size(); i) { m_rowTopCache[i] y; y m_rowHeight; } }m_rowOfId这张哈希表是给箭头用的知道任务 id 要立刻拿到行号否则每画一条依赖都要遍历一次任务列表任务上万时这块就是性能瓶颈。行高固定是整份实现的前提红利rebuild里那段等差数列换成变高行的话就得改成前缀和同时firstVisibleRow()的除法也要换成二分。2.3 时间轴一个双精度比例尺解决所有换算时间轴的职责只有一个秒与像素互相转换。整个类不到三十行但每一个类型选择都有原因。class TimeAxis { public: // startSec/endSec 是视图可见区间的 UTC 秒viewWidth 是绘图区宽度不含左侧树 void setRange(qint64 startSec, qint64 endSec, int viewWidth) { m_startSec startSec; m_endSec endSec; m_scale (endSec startSec viewWidth 0) ? double(viewWidth) / double(endSec - startSec) : 0.0; } double xOf(qint64 sec) const { return double(sec - m_startSec) * m_scale; } qint64 secOf(double x) const { return m_startSec qint64(std::llround(x / m_scale)); } private: qint64 m_startSec 0; qint64 m_endSec 0; double m_scale 0.0; // 像素/秒日视图下大约 0.0003 量级 };三处细节值得抠。第一m_scale必须双精度按天视图算一天 86400 秒对 30 像素比例尺是 3.47e-4用 float 存再乘几百万秒误差会放大到几个像素。第二secOf用llround而不是qint64(x / m_scale)后者对负数向零取整视图左边界之外的点会整体偏一格。第三xOf里先做减法再乘不要写成sec * m_scale - m_startSec * m_scale后者在时间戳是 1.7e9 量级时两个大数相乘再相减会把双精度的有效位吃光。刻度生成按枚举粒度分层时、日、周、月、季五个级别每个级别给一对函数一个算出区间内所有刻度线的时刻一个生成刻度标签。标签文本不要每帧重新拼 QDateTime 格式串这是 paintEvent 里最容易忽略的开销两行刻度一次重绘上百个标签格式化占的时间比画矩形还多。常见做法是用 QHash 缓存键是“粒度 秒”值直接存 QString 或者 QStaticText。2.4 绘制管线五层顺序不能乱paintEvent里只做分发具体绘制拆成五个私有函数顺序是有讲究的void GanttWidget::paintEvent(QPaintEvent* e) { QPainter p(this); const QRect clip e-rect(); // 只处理这块脏区别整屏重画 p.fillRect(clip, m_theme.bg); drawGrid(p, clip); // 1. 横线、竖线、周末底色 drawHeader(p, clip); // 2. 顶部两行刻度随水平滚动平移 drawBars(p, clip); // 3. 任务条只遍历可见行 drawLinks(p, clip); // 4. 依赖箭头必须压在条上面 drawTodayLine(p, clip); // 5. 今日线最后画永远最上层 }箭头要在任务条之后画否则从条边缘出发的折线会被条盖掉一截看起来像断头今日线要在全部内容之后通常用一个半透明的红色竖条加顶部小三角压在任何元素上都不影响读取。周末底色属于背景层用QColor(0,0,0,8)这种极淡的填充别用实色不然任务条压上去对比度会掉。依赖箭头的造型有正交折线和贝塞尔两种源码里给的是三次贝塞尔两端水平出入视觉效果比方折线温和也不容易和任务条的重叠区打架QPainterPath GanttWidget::linkPath(const GanttLink l) const { const QRectF a barRect(l.fromId); // 源条矩形模型坐标已转屏幕 const QRectF b barRect(l.toId); QPointF p1, p2; switch (l.type) { case GanttLink::FS: p1 {a.right(), a.center().y()}; p2 {b.left(), b.center().y()}; break; case GanttLink::SS: p1 {a.left(), a.center().y()}; p2 {b.left(), b.center().y()}; break; case GanttLink::FF: p1 {a.right(), a.center().y()}; p2 {b.right(), b.center().y()}; break; case GanttLink::SF: p1 {a.left(), a.center().y()}; p2 {b.right(), b.center().y()}; break; } // 控制点水平外推距离随两点间距变化短箭头不会缩成一团 const double dx qMax(12.0, qAbs(p2.x() - p1.x()) * 0.4); QPainterPath path(p1); path.cubicTo(p1 QPointF(dx, 0), p2 - QPointF(dx, 0), p2); return path; }dx那个下限 12 像素不是随便定的两个任务贴得很近时控制点距离趋近 0贝塞尔会退化成直线甚至往回收箭头看起来像被截断。给个下限之后近距离的箭头会自然拱起来一点指向依然清楚。箭头端点还要补一个 6×6 的小三角方向和出入边一致这个细节在密集依赖里对可读性影响很大。3. 接进自己的工程从编译到拖拽落库3.1 工程接入CMake 和 qmake 两条路源码目录是自包含的没有额外第三方依赖只用到 QtCore 和 QtWidgets。CMake 下按静态库挂进去最省事注意有 Q_OBJECT 的类必须开 AUTOMOC否则编译能过、运行时报 vtable 未定义。# CMakeLists.txt 片段把 gantt 目录整体编成静态库 add_library(gantt STATIC gantt/ganttwidget.cpp gantt/ganttmodel.cpp gantt/timeaxis.cpp gantt/ganttdelegate.cpp ) target_include_directories(gantt PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/gantt) # Qt5 用 Qt5::WidgetsQt6 换成 Qt6::Widgets其余代码不用动 find_package(Qt5 COMPONENTS Widgets REQUIRED) target_link_libraries(gantt PUBLIC Qt5::Widgets) # 有 Q_OBJECT 必须开漏了会出现 undefined reference to vtable set_target_properties(gantt PROPERTIES AUTOMOC ON)qmake 工程更简单源码里带了一个.pri一行 include 就够QT core gui widgets include($$PWD/gantt/gantt.pri) # 里面是 HEADERS / SOURCES 那一套如果宿主工程同时用 Qt5 和 Qt6 编译把QDateTime::toSecsSinceEpoch()这类接口包一层宏就行源码里已经做了不需要自己改。3.2 初始化刻度、行高、吸附粒度三件事先定死控件构造完之后不要急着塞数据先把视图参数定下来。这几个值一旦定错后面调起来是连锁反应行高和左侧树对不上滚动就永远错半行吸附粒度设成 1 分钟拖一次改期能滚出几十条历史记录。GanttWidget* g new GanttWidget(this); g-setScaleType(GanttWidget::ScaleDay); // 刻度粒度时/日/周/月/季 g-setRowHeight(26); // 必须与左侧 QTreeView 的 rowHeight 一致 g-setHeaderHeight(52); // 两行刻度单行刻度给 26 就够 g-setSnapMinutes(30); // 拖拽吸附排产常用 30 或 60 g-setBarHeightRatio(0.62); // 条高占行高比例0.6~0.7 比较好点中 g-setMinBarWidth(8); // 一天工期的里程碑不会被画成一条线 // 视图区间用 UTC 秒控件内部自己处理显示时区 g-setRange(QDateTime(QDate(2025, 3, 1), QTime(0, 0), Qt::UTC), QDateTime(QDate(2025, 6, 1), QTime(0, 0), Qt::UTC)); g-setPixelsPerDay(28); // 日视图下 28~36 之间手感最好setRowHeight和左侧树的对齐关系是硬约束。不少工程是左边 QTreeView 右边甘特图并排两边滚动条联动如果一边 26 一边 24滚到第五百行就差了整整 400 像素看上去像两个不相干的控件。稳妥做法是把这个值抽成一个常量两边引用同一个。setMinBarWidth存在的理由是里程碑一个零工期的节点换算出来宽度是 0用户根本点不中也没法拖。给它一个 8 像素下限绘制时画成菱形命中测试时按矩形算体验才闭环。3.3 灌数据先建树再连依赖最后一次重建数据入口有三个addTask、removeTask、addLink。三者的共同点是都不立刻重绘只是把模型标脏等一次rebuild()统一提交。这一点在处理几百条任务时差别巨大如果每加一条就 update 一次界面会闪成幻灯片。GanttModel* m g-model(); // 1) 父任务Summary 类型的起止时间由子任务汇总但初始也要给个合法区间 GanttTask root; root.id m-nextId(); root.name QStringLiteral(总装计划); root.kind TaskKind::Summary; root.start QDateTime(QDate(2025, 3, 1), QTime(0, 0), Qt::UTC); root.finish QDateTime(QDate(2025, 5, 30), QTime(0, 0), Qt::UTC); m-addTask(root); // 2) 子任务parentId 指向上面的 root层级深度不限 GanttTask a; a.id m-nextId(); a.parentId root.id; a.name QStringLiteral(工装准备); a.start QDateTime(QDate(2025, 3, 3), QTime(8, 0), Qt::UTC); a.finish QDateTime(QDate(2025, 3, 12), QTime(18, 0), Qt::UTC); a.progress 40; m-addTask(a); // 3) 依赖FS 是最常见的“前一个完成、后一个开始” GanttLink l; l.fromId a.id; l.toId b.id; l.type GanttLink::FS; m-addLink(l); // 4) 一次性提交重拍平、重算可见行、只重绘一次 m-rebuild(collapsedIds); g-refresh();addTask里对 id 的处理是自增而不是让调用方保证唯一这是为了避免外部数据源比如数据库自增主键和控件内部 id 冲突。如果要和业务侧 id 对齐用setExternalId()挂一个映射别直接改id字段那是行索引的基础。依赖关系这里有个容易忽略的点addLink不做环检测。业务上的依赖环是数据错误不是绘图错误控件层直接拒绝会让调用方很难查。常见做法是在rebuild之前跑一次拓扑排序有环就通过信号抛出去把环上的 id 列表一起带上让上层决定是提示还是自动断开。3.4 交互绑定拖拽期间只改预览松手才落库拖拽改期是这类控件被用得最多的功能也是最容易把数据搞乱的地方。源码里的处理原则是按下鼠标时记下原始区间和命中区域移动过程中只更新一个m_dragPreview结构并局部刷新松开鼠标才发出taskDateChanged信号。void GanttWidget::mousePressEvent(QMouseEvent* e) { m_hit hitTest(e-pos()); // Header / Row / ResizeLeft / ResizeRight / Move if (m_hit HitArea::None || m_hit HitArea::Row || m_hit HitArea::Header) { QWidget::mousePressEvent(e); return; } m_dragTaskId m_flat[m_visibleRows[rowAt(e-pos().y())]].id; m_dragOrigin barRect(m_dragTaskId); // 原始矩形用于算位移 m_dragPressX e-pos().x(); m_dragSec secAtPos(e-pos().x()); // 按下点对应的时刻吸附用 setCursor(m_hit HitArea::Move ? Qt::ClosedHandCursor : Qt::SizeHorCursor); } void GanttWidget::mouseReleaseEvent(QMouseEvent* e) { if (m_dragTaskId 0 m_dragPreview.dirty) { // 只有真正改动过才发信号避免单击任务条也触发一次写库 emit taskDateChanged(m_dragTaskId, m_dragPreview.start, m_dragPreview.finish); } resetDragState(); }taskDateChanged的接收方要做什么决定了整条链路的健壮性。落库成功就不用管落库失败必须调revertTask(id)把视图退回原值否则界面显示的和数据库里存的会长期不一致下一次刷新又跳回去用户会以为是控件在乱跳。这种“界面先行、数据回滚”的模式在排产类工具里几乎是标配。配套的撤销栈用 QUndoStack 一行命令就能接上把一次拖拽包成MoveTaskCommandredo()里写库并刷新undo()里写回旧值。后悔药这个东西用户第一次误拖之后就会开始找提前做好比事后补省事得多。4. 参数与性能万级任务怎么还能拖得动4.1 一张参数表把调错的症状也写清楚大部分“甘特图不好用”的抱怨最后都能归到几个参数上。把典型值和调错的症状放在一起比对文档有用参数典型值影响调错的症状pixelsPerDay28~36日视图一天占多少像素太小条点不中太大一屏看不到一个月rowHeight24~28行高须与左侧树一致滚动几百行后两边错位headerHeight26单行/ 52两行刻度区高度两行刻度挤成一行标签重叠snapMinutes30 / 60拖拽吸附粒度设 1 分钟改期改出零碎时间点minBarWidth8~10最短条宽设 0里程碑节点消失barHeightRatio0.6~0.7条高占行高比例接近 1 时相邻行视觉粘连scaleType按排产周期切刻度粒度三个月计划用日视图横轴拉出去几千像素linkStyle贝塞尔 / 正交箭头造型密集依赖下正交折线互相穿越scaleType的切换逻辑值得单说很多实现是用户手动切结果一换周期就找不到任务。更省事的做法是按可见区间自动选跨度小于 3 天给小时3 天到 8 周给日8 周到半年给周再往上给月或季。自动选再加一个手动覆盖开关两头都照顾到。4.2 只画可见区间固定行高带来的性能红利五千行任务全部走一遍绘制一帧就是五千次矩形填充加五千次文本绘制滚动条一动就掉帧。省下来的办法很朴素只画落在视口里的行。行高固定这件事在这里显出价值定位首行不需要二分一次除法就够。// 视口顶部对应的可见行下标行高固定直接除法O(1) int GanttWidget::firstVisibleRow(int viewportTop) const { const int y viewportTop m_scrollY - m_headerHeight; if (y 0) return 0; return qBound(0, y / m_rowHeight, int(m_rowTopCache.size())); } void GanttWidget::drawBars(QPainter p, const QRect clip) { const int first firstVisibleRow(clip.top()); const int last qMin(int(m_rowTopCache.size()) - 1, firstVisibleRow(clip.bottom()) 1); for (int i first; i last; i) { const GanttTask t m_flat[m_visibleRows[i]]; const QRectF r barRect(t.id); if (!r.intersects(clip)) continue; // 水平方向也在视口外跳过 drawOneBar(p, t, r); } }加上r.intersects(clip)这一层过滤很关键纵向可见不代表横向可见视图缩放到月视图时大部分任务条都在当前水平滚动区之外这一句能再省掉一大半绘制。实测五千行任务、日视图、一屏三十行的场景一帧的绘制调用从五千降到三十上下滚动基本感觉不到延迟。还有三个配合项。一是setAttribute(Qt::WA_OpaquePaintEvent)告诉系统这块区域自己会填满省掉一次背景擦除。二是网格线关掉抗锯齿任务条开——网格是纯水平和垂直线抗锯齿只会让它发虚还更慢。三是字体度量结果缓存QFontMetrics::horizontalAdvance()在文本密集的刻度区调用频繁缓存成 QHashQString,int 能省下可观的 CPU。4.3 命中测试与拖拽节流别让重绘跟着鼠标跑命中测试的返回粒度决定了交互手感。只返回“第几行”是不够的还得区分点在条的左边缘、右边缘还是中间三者分别是改开始、改结束、整体平移。enum class HitArea { None, Header, Row, ResizeLeft, ResizeRight, Move }; HitArea GanttWidget::hitTest(const QPoint pos) const { if (pos.y() m_headerHeight) return HitArea::Header; const int row firstVisibleRow(pos.y()); if (row m_visibleRows.size()) return HitArea::None; const QRectF r barRect(m_flat[m_visibleRows[row]].id); // 往外放宽 3 像素边缘难点的老问题能缓解不少 if (!r.adjusted(-3, -3, 3, 3).contains(pos)) return HitArea::Row; if (pos.x() r.left() kResizeHandle) return HitArea::ResizeLeft; if (pos.x() r.right() - kResizeHandle) return HitArea::ResizeRight; return HitArea::Move; }kResizeHandle取 6 像素是权衡结果太小点不中太大会把短工期任务条整个吃掉中间没有可拖区域。任务条宽度小于 3 倍句柄宽度时比如两天的短任务改成整条只能平移拉边改期走右侧属性面板这是取舍。拖拽过程中的刷新一定要局部。整体update()会让整屏重画鼠标一动就是一次全量绘制卡顿感就是这么来的。做法是只刷新旧矩形和新矩形的并集再补一条竖线提示拖到哪个时刻void GanttWidget::mouseMoveEvent(QMouseEvent* e) { if (m_dragTaskId 0) return; const int dx e-pos().x() - m_dragPressX; QRectF preview m_dragOrigin; switch (m_hit) { case HitArea::Move: preview.translate(dx, 0); break; case HitArea::ResizeLeft: preview.setLeft(preview.left() dx); break; case HitArea::ResizeRight: preview.setRight(preview.right() dx);break; default: return; } // 吸附到 snapMinutes 的整数倍避免产生 08:07 这种时间点 preview snapRect(preview); const QRect dirty preview.toAlignedRect().united(m_dragPreviewRect.toAlignedRect()) .adjusted(-4, -4, 4, 4); m_dragPreviewRect preview; update(dirty); // 只刷这两块不整屏重画 }snapRect的实现要注意方向平移时按起点吸附、宽度不变拉右边时按终点吸附、起点不变。两种都写成“整体吸附”会导致平移时工期莫名其妙多一天这是很典型的翻车点。4.4 高 DPI 与像素对齐一条线为什么会糊成两像素Qt5 上要显式开高 DPI 缩放Qt6 默认开。开了之后画 1 像素的网格线如果坐标落在整数上画笔宽度 1 会跨在两个物理像素之间渲染出来是两条半透明的线看起来又粗又灰。解法是把线的坐标偏移半个逻辑像素p.drawLine(QLineF(x 0.5, top, x 0.5, bottom))。文本不用管但任务条的圆角矩形要留意QPainterPath::addRoundedRect在缩放比例 1.25、1.5 这类非整数比下圆角半径最好也乘上devicePixelRatioF()再取整否则四个角的弧线粗细不一致细看很明显。任务条内部的进度填充深色部分按 progress 截断用的是整数宽度这里要向下取整而不是四舍五入否则进度 0 的任务也会画出 1 像素的深色条视觉上像全都有进度。5. 避坑与排查五条踩过的记录5.1 时间和刻度类的两个坑坑一任务条整体右移一小时只在特定月份出现。现象三月份排的计划显示正确切到六月份的数据所有任务条都往右偏了一格刻度导出的表格数据却是对的。原因模型里用的是本地时间QDateTime::currentDateTime()这类构造参与像素换算时取toSecsSinceEpoch()而显示时又按本地时区反解。碰上夏令时切换的那一周本地时间和 UTC 的偏移量从 8 变成别的值有些地区实行夏令时同一个字符串时间对应的秒数就变了条自然漂。解决所有存进模型的 QDateTime 一律显式带Qt::UTC界面上要显示“08:00 开工”时再toLocalTime()转。TimeAxis顶层再兜一道如果发现传进来的 QDateTime 的 timeSpec 不是 UTC打一条 qWarning 并自动转换别指望每个调用点都记得。坑二缩放之后任务条和刻度线对不上越往右偏得越多。现象视图缩放到周粒度前几条任务还压在正确日期上滚到右边任务条已经比它的日期线偏出几十像素。原因换算用了int累加。有人图省事写成x m_leftMargin dayIndex * m_pixelsPerDaym_pixelsPerDay是 double乘完转 int 截断每个刻度都往下取整 0.7 像素一百个刻度就攒出 70 像素的偏差。解决像素坐标全程用 double只在drawRect的最后一步转QRectF不要中途落到 int确实需要整数网格的地方比如画竖向刻度线用qRound而不是隐式截断。这条和前面TimeAxis里llround的选择是同一件事的两面。5.2 绘制与索引类坑三折叠父任务之后依赖箭头指向一片空白。现象点父节点左边的折叠三角子任务都收起来了但指向这些子任务的箭头还在端点悬在空行上看着像一根断线。原因rebuild()只重建了m_visibleRows没有清理“两个端点都可见”的过滤条件。箭头的矩形是按m_rowOfId拿行号算的被折叠的行行号还在于是照样画得出来。解决drawLinks里加一道判断源和目的任一任务不在可见集合里就跳过。可见集合用QSetint维护在rebuild里顺手填查一次是 O(1)。注意别用m_rowOfId是否存在来判断那张表包含隐藏行。坑四五千行任务滚动条一拖就卡住半秒。现象任务数上千之后按住滚动条连续拖动界面明显跟不上手任务条一顿一顿地跳。原因paintEvent里对每一行都做了完整绘制包括看不见的行同时全局开了抗锯齿连网格线和行分隔线都吃了一轮抗锯齿。解决按前面 4.2 的做法接上可见行过滤网格线关闭抗锯齿只给任务条的圆角和箭头路径开。另外把QWidget::setUpdatesEnabled(false)包在批量重建外面重建完再打开避免rebuild过程中每一次模型变更都触发一次update。这三步做完同样的数据量拖动基本跟手。5.3 交互与数据同步坑五拖动改期之后界面上的条又弹回原位。现象拖完松手任务条跳到新位置闪了一下又回到老地方数据库里的值也确实没变。原因taskDateChanged的接收方是异步的在后台线程里落库落库成功后调用了一次reloadAll()从库里重新拉全量数据。可此时写库还没提交或者事务被回滚了读到的还是旧值视图自然回滚。解决两条路选一条。要么写库同步完成之后再刷视图刷的时候带上刚改的 id 做局部更新别全量重拉要么走“视图先行 失败回滚”先相信用户的操作写库失败再调revertTask(id)。混用两种最糟既乐观更新又在后台重拉必然打架。我一般选后者配合 QUndoStack用户还能自己撤销。排查这一类问题的顺序记住三步先看信号有没有发出来打日志或断点再看接收方的槽是不是排队连接导致时序错位最后看写库的返回值有没有被忽略。九成的“弹回原位”都出在第二步。6. 再往前一步把甘特图接进真实排产流程拖拽能用之后真正决定这套控件能不能留下来的是它跟业务数据的接口够不够窄。我的习惯是只暴露三个信号taskDateChanged改期、taskProgressChanged进度回写、linkCreated新建依赖其余的都不要往外抛。控件只管展示和交互关键路径计算、资源冲突检测、工期汇总这些全放在业务层这样换控件的时候业务代码不用动。一个具体技巧批量修改时不要逐条发信号。选十行任务统一顺延三天逐条发就是十次写库加十次重绘中途任何一条失败数据就停在半新半旧的状态。做法是控件侧提供一个beginBatch()/endBatch()包住批量期间只累积修改项endBatch()里一次性发出batchChanged(QVectorTaskChange)接收方开一个事务全写完再刷新。这套模式在几百条任务同时调整时差别非常明显我见过因为没做批处理一次“全部顺延”卡了四秒的工程。导出这块也有个取巧做法不要用QWidget::grab()截屏那样只能导当前可见区域滚动条外的内容全丢。正确姿势是新建一个离屏 QImage宽度按“全部任务的起止区间 × pixelsPerDay”算高度按“可见行数 × 行高 表头”然后把绘制管线的五个函数原样调用一遍只是把 QPainter 的目标换成这张图。同一份绘制代码出两套输出不用维护两份逻辑。QImage GanttWidget::exportImage(double pixelsPerDay, int rowHeight) const { const qint64 span m_model-maxSec() - m_model-minSec(); const int w int(span / 86400.0 * pixelsPerDay) kLeftPanelWidth 2 * kMargin; const int h m_model-visibleRowCount() * rowHeight m_headerHeight 2 * kMargin; QImage img(w, h, QImage::Format_ARGB32_Premultiplied); img.fill(m_theme.bg); QPainter p(img); p.setRenderHint(QPainter::TextAntialiasing, true); // 复制一份临时状态避免影响屏幕上的滚动位置和缩放级别 GanttWidget tmp(*this); tmp.applyExportGeometry(pixelsPerDay, rowHeight, QSize(w, h)); tmp.paintAll(p); // 内部就是那五个 draw 函数不经过 paintEvent return img; }paintAll和paintEvent分开是关键前者接受外部 QPainter 和显式尺寸后者只做脏区裁剪和分发。写的时候忍住不要把逻辑塞进paintEvent否则导出这块迟早要重写一遍。最后说个教训。早些年做这类控件我总想一次把交互做全拖拽、框选、缩放、右键菜单一起上结果每个功能都半生不熟用户最先踩到的坑反而是最基础的“拖完弹回去”。后来改成先只做拖拽改期一条链路从按下、移动、吸附、发信号、落库、失败回滚、撤销一条走通再往上加功能问题少了一大半。从那以后我每次接一个新控件都强制先跑通一条最短的写入链路再谈界面。这份 qt 甘特图 源码的节奏也是这么排的先看第 2 章的坐标换算和第 3 章的拖拽落库跑通之后再回头看第 4 章的性能参数会顺很多。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑