简介:面向 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 QSet<int>& collapsedIds) { m_flat.clear(); m_visibleRows.clear(); // 1) 先按父子关系拍平,父节点后紧跟子节点,这是甘特图的常规排布 std::function<void(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::Widgets,Qt6 换成 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 一行命令就能接上,把一次拖拽包成MoveTaskCommand,redo()里写库并刷新,undo()里写回旧值。后悔药这个东西,用户第一次误拖之后就会开始找,提前做好比事后补省事得多。
4. 参数与性能:万级任务怎么还能拖得动
4.1 一张参数表,把调错的症状也写清楚
大部分“甘特图不好用”的抱怨最后都能归到几个参数上。把典型值和调错的症状放在一起,比对文档有用:
| 参数 | 典型值 | 影响 | 调错的症状 |
|---|---|---|---|
| pixelsPerDay | 28~36(日视图) | 一天占多少像素 | 太小条点不中,太大一屏看不到一个月 |
| rowHeight | 24~28 | 行高,须与左侧树一致 | 滚动几百行后两边错位 |
| headerHeight | 26(单行)/ 52(两行) | 刻度区高度 | 两行刻度挤成一行,标签重叠 |
| snapMinutes | 30 / 60 | 拖拽吸附粒度 | 设 1 分钟,改期改出零碎时间点 |
| minBarWidth | 8~10 | 最短条宽 | 设 0,里程碑节点消失 |
| barHeightRatio | 0.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()在文本密集的刻度区调用频繁,缓存成 QHash<QString,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_pixelsPerDay,m_pixelsPerDay是 double,乘完转 int 截断,每个刻度都往下取整 0.7 像素,一百个刻度就攒出 70 像素的偏差。
解决:像素坐标全程用 double,只在drawRect的最后一步转QRectF,不要中途落到 int;确实需要整数网格的地方(比如画竖向刻度线),用qRound而不是隐式截断。这条和前面TimeAxis里llround的选择是同一件事的两面。
5.2 绘制与索引类
坑三:折叠父任务之后,依赖箭头指向一片空白。
现象:点父节点左边的折叠三角,子任务都收起来了,但指向这些子任务的箭头还在,端点悬在空行上,看着像一根断线。
原因:rebuild()只重建了m_visibleRows,没有清理“两个端点都可见”的过滤条件。箭头的矩形是按m_rowOfId拿行号算的,被折叠的行行号还在,于是照样画得出来。
解决:drawLinks里加一道判断,源和目的任一任务不在可见集合里就跳过。可见集合用QSet<int>维护,在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(QVector<TaskChange>),接收方开一个事务全写完再刷新。这套模式在几百条任务同时调整时差别非常明显,我见过因为没做批处理,一次“全部顺延”卡了四秒的工程。
导出这块也有个取巧做法:不要用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 章的性能参数,会顺很多。希望帮到你。
本文还有配套的精品资源,点击获取