简介:面向需要在桌面端实现项目排期与任务进度可视化的Qt开发者,这个“qt 甘特图另一个版本”给出了一套不同于常见标准实现的甘特图源码参考,适合项目管理界面原型、教学示例及中小型工具二次开发,初中级开发者也能按工程结构逐步阅读。压缩包共27个文件、约1.69MB,包含5个cpp和5个h核心源码、2个ui界面文件、2个ini配置文件以及2个exe可执行程序,另带sln、vcxproj等Visual Studio工程文件,便于直接运行、对照编译和拆解模块。已有861人学习下载。实现围绕QGraphicsView与QGraphicsItem组织任务条形,覆盖时间轴换算、模型与视图分离、鼠标拖拽交互、自定义paint绘制、滚动与布局等关键点;读者可据此理解任务数据绑定和刷新流程,并进一步扩展依赖关系、里程碑、进度动画或配色样式,将组件嵌入现有Qt项目。
1. 从一张车间排产表说起:qt 甘特图控件在桌面端的位置
车间那台工控机上跑着的排产界面,十有八九最后都要画成横条图:左边一列任务名,右边一条时间轴,任务条横着拉过去,中间几根箭头表示前后置关系。这时候去找 Web 端甘特组件,接进来要么得嵌浏览器内核,要么得迁就人家的数据格式,反而更绕。qt 甘特图这类自绘控件的价值就在这儿——不依赖任何前端运行时,一个 QWidget 贴进现有 Qt 客户端就能用,数据从本地 SQLite 或内存 list 读出来直接上屏。这份资源给的是甘特图控件的另一个版本,适合两类人:手上已有 Qt 桌面项目、需要在里面塞一块排程视图的开发者;以及想拆一个完整自绘控件、看时间轴映射和命中测试到底怎么写的同学。下面从工程结构一路拆到能下手改的那几行代码。
2. 技术选型与工程结构:场景图与纯自绘两条路怎么选
2.1 QGraphicsView 场景图与单 QWidget 自绘的取舍
甘特图控件在 Qt 里有两条主流路线,选错了后面每一步都在还债。第一条是 QGraphicsView 加自定义 QGraphicsItem:任务条是一个 item,依赖线是另一个 item,缩放交给QGraphicsView::scale(),命中测试直接itemAt()。这条路前期快,尤其是需要框选、拖动、右键菜单这些标准交互时,Qt 已经把事件分发和 Z 序都做好了。问题出在量级上——任务条超过两三千个之后,场景的 BSP 索引维护成本会明显起来,每次setPos()都可能触发重排;再加上每根依赖线都是一个持有 QPainterPath 的 item,内存也跟着涨。
第二条是单 QWidget 自绘:paintEvent里自己按顺序画底纹、网格、任务条、依赖线、文字,滚动交给 QScrollArea 或者自己维护偏移量。这条路前期慢,命中测试要自己写,滚动条要自己接,但一旦跑通,可控性完全不一样——你能精确决定只画可视区那几十行,能缓存 QFontMetrics 避免每帧构造,能在拖拽时只重绘脏矩形。
| 维度 | QGraphicsView 场景图 | 纯 QWidget 自绘 |
|---|---|---|
| 前期开发速度 | 快,标准交互开箱可用 | 慢,事件和滚动都要自己接 |
| 千级任务条性能 | 场景索引开销上来后掉帧 | 只画可视区,基本线性 |
| 命中测试 | itemAt()直接拿 | 需要自己写矩形/线段判定 |
| 自定义折线路由 | 要写一个独立 item | 在 paintEvent 里随手画 |
| 打印与导出 | 可用render() | 直接画到 QImage 上 |
| 调试难度 | item 层级多了容易绕 | 一条绘制链,肉眼看得到 |
给这类控件选型,我一般先看两个数:任务条峰值数量和是否需要复杂框选。峰值在几百、交互花样多,走场景图;峰值上千、以看和拖为主,走自绘。这份资源的结构是围绕单画布自绘组织的,下面按这个路子拆,理解成本反而低。
2.2 目录结构:数据、视图、绘制、交互拆成四层
自绘控件最容易写成一坨,所有逻辑塞进一个GanttWidget.cpp,两三千行之后谁都不敢动。比较稳的切法是按职责切四层:model 只管数据和派生量,view 管窗口部件组合,render 管纯绘制函数,interact 管坐标到对象的映射和拖拽状态机。目录大致长这样:
GanttWidget/ ├── gantt.pro # qmake 工程,可直接编成静态库 ├── CMakeLists.txt # CMake 接法,方便塞进已有工程 ├── src/ │ ├── model/ │ │ ├── ganttdata.h # GanttTask / GanttLink 结构体定义 │ │ ├── ganttmodel.h/.cpp # 增删改查、父子任务扁平化、时间范围计算 │ │ └── ganttmodel_p.h # 内部行索引缓存 │ ├── view/ │ │ ├── ganttwidget.h/.cpp # 对外入口,组合表头、画布、滚动条 │ │ ├── ganttheaderview.h/.cpp# 顶部时间刻度条,独立 QWidget │ │ └── ganttcanvas.h/.cpp # 自绘画布,paintEvent 都在这里 │ ├── render/ │ │ ├── ganttrenderer.h/.cpp # 任务条、进度、里程碑的绘制函数 │ │ └── ganttlinkrouter.h/.cpp# 依赖箭头折线路由 │ ├── interact/ │ │ ├── gantthittest.h/.cpp # 坐标 -> 行/列/任务条 的判定 │ │ └── ganttdraghandler.h/.cpp # 拖拽改期、边缘拉伸状态机 │ └── util/ │ └── gantttimeaxis.h # 时间 <-> 像素 的纯函数,无状态 └── demo/ └── main.cpp # 造 200 条假数据的演示程序几个文件的分工值得说清楚,不然改起来容易放错地方:
| 文件 | 职责 | 不该放什么 |
|---|---|---|
ganttmodel.cpp | 数据增删改、父子折叠后的可见行索引、整体起止时间 | 任何绘制代码 |
ganttcanvas.cpp | 事件接收、脏区计算、调用 renderer | 业务字段计算 |
ganttrenderer.cpp | 纯函数式绘制,输入 QPainter + 数据 + 映射 | 读写成员状态 |
gantttimeaxis.h | timeToX/xToTime/ 刻度步长 | 缓存 UI 对象 |
gantthittest.cpp | 给定 (x, y) 返回命中的行号和任务 ID | 修改数据 |
把gantttimeaxis.h抽成无状态纯函数这一点特别关键。表头要算刻度位置,任务条要算起止像素,依赖线要算锚点,拖拽要反算日期——这四处如果各写一套换算,稍微缩放一下就对不齐,这种玄学问题排查起来能耗掉一整天。
2.3 接进已有工程:qmake 与 CMake 两种写法
这份资源两种构建方式都留了。老项目多半是.pro,直接 include 进去就行:
# gantt.pro 片段:编成静态库,主工程直接 link QT += core gui widgets CONFIG += c++11 staticlib TEMPLATE = lib INCLUDEPATH += $$PWD/src HEADERS += \ src/model/ganttdata.h \ src/model/ganttmodel.h \ src/view/ganttwidget.h \ src/view/ganttcanvas.h \ src/render/ganttrenderer.h \ src/render/ganttlinkrouter.h \ src/interact/gantthittest.h \ src/util/gantttimeaxis.h SOURCES += \ src/model/ganttmodel.cpp \ src/view/ganttwidget.cpp \ src/view/ganttcanvas.cpp \ src/render/ganttrenderer.cpp \ src/render/ganttlinkrouter.cpp \ src/interact/gantthittest.cppCONFIG += staticlib会产出.a/.lib,主工程里LIBS += -L$$PWD/lib -lgantt就能用。要是嫌静态库麻烦,把TEMPLATE = lib去掉,直接把这几个.cpp加进主工程的SOURCES,效果一样,代价是编译时间。
新一点的工程用 CMake:
# CMakeLists.txt:同时兼容 Qt5 和 Qt6 cmake_minimum_required(VERSION 3.16) project(GanttWidget LANGUAGES CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets) find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets) add_library(gantt STATIC src/model/ganttmodel.cpp src/view/ganttwidget.cpp src/view/ganttcanvas.cpp src/render/ganttrenderer.cpp src/render/ganttlinkrouter.cpp src/interact/gantthittest.cpp ) target_include_directories(gantt PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/src) target_link_libraries(gantt PUBLIC Qt${QT_VERSION_MAJOR}::Widgets)find_package(QT NAMES Qt6 Qt5 ...)这段是 Qt5/Qt6 双兼容的标准写法,先试 Qt6 再退 Qt5,取到的版本号存进QT_VERSION_MAJOR变量供后面拼 target 名。AUTOMOC ON必须开,因为GanttWidget、GanttCanvas这些类里都有Q_OBJECT,漏了会在链接阶段报一堆undefined reference to vtable for ...,第一次踩这坑的人往往会去怀疑头文件包含顺序。
Qt5 和 Qt6 之间还有几处小差异要处理,代码里一般会加条件编译:
#include <QtGlobal> #if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) // Qt5 里 QVector 和 QList 是两种类型,接口略有差异 using GanttTaskList = QVector<GanttTask>; #else // Qt6 起 QVector 已并入 QList,直接用 QList 即可 using GanttTaskList = QList<GanttTask>; #endif这个别名看着不起眼,但能把数据层里所有QVector<GanttTask>的声明统一掉,将来升 Qt6 只改这一处。另外QFontMetrics::width()在 Qt6 被换成了horizontalAdvance(),Qt5.11 之后其实就有了,用horizontalAdvance()写能两边都过。
3. 核心数据模型与时间轴映射:一个任务怎么变成屏幕上那根条
3.1 GanttTask 该有哪些字段
数据模型定得糙,后面所有绘制和交互都在补窟窿。一个够用的任务结构体至少要覆盖这几件事:身份、时间区间、层级关系、显示样式、进度状态。
// src/model/ganttdata.h #pragma once #include <QString> #include <QDateTime> #include <QColor> struct GanttTask { int id = -1; // 全局唯一,绘制和命中测试都靠它 int parentId = -1; // -1 表示顶层任务,用于折叠 QString name; // 左侧树列显示的文字 QDateTime start; // 计划开始,精确到秒即可 QDateTime finish; // 计划结束,注意这是"含"还是"不含"要统一 int progress = 0; // 0-100,进度条填充比例 QColor barColor = QColor("#4A90D9"); bool milestone = false; // 里程碑画成菱形而不是横条 bool collapsed = false; // 仅父任务有意义 QString owner; // 责任人,用于泳道分组 }; // 依赖关系:FS 完成-开始 / SS 开始-开始 / FF 完成-完成 / SF 开始-完成 enum class LinkType { FS, SS, FF, SF }; struct GanttLink { int fromId = -1; int toId = -1; LinkType type = LinkType::FS; QColor color = QColor("#8C8C8C"); };finish这个字段的语义必须先定死。常见做法是把它当作「不含」的右开区间,即从 3 月 1 日到 3 月 3 日的任务实际占 3 月 1 日、2 日两天。另一种是闭区间,占三天。两种都行,但整份代码里只能有一种,混着用必然出现「拖完之后少一天」这种问题,而且极难定位——你会盯着addDays(1)反复怀疑自己。
parentId用扁平 id 而不是嵌套QList<GanttTask> children,是为了折叠和展开好算:所有任务放在一个线性列表里,另外维护一份「可见行索引表」,折叠某个父任务时只重建索引表,不动原始数据。嵌套结构在折叠时要递归遍历,还容易在拖拽跨层级移动时出环。
模型类要提供的最小接口大致是这些:
// src/model/ganttmodel.h(节选) class GanttModel : public QObject { Q_OBJECT public: // 增删改,每次变更后内部会重算可见行索引和整体时间范围 void addTask(const GanttTask &t); void removeTask(int id); void updateTask(int id, const GanttTask &t); // 折叠 / 展开父任务,只影响 visibleRows void setCollapsed(int id, bool collapsed); // 绘制和交互高频调用,必须是 O(1) 或 O(log n) const GanttTask *taskById(int id) const; int rowOfTask(int id) const; // 可见行号,-1 表示被折叠隐藏 const QVector<int> &visibleRows() const; // 行号 -> 任务 id // 整体时间范围,用于算滚动区宽度和初始刻度 QDateTime rangeStart() const; QDateTime rangeFinish() const; signals: void dataChanged(); // 数据变了,重绘 + 重算滚动范围 void layoutChanged(); // 行数变了,滚动条要跟着调 };visibleRows()返回的是行号到任务 id 的映射,这个设计决定了后面所有绘制循环的形态:paintEvent只遍历可见行,rowAt(y)只需一次除法就能定位,拖拽时也只需重绘受影响的那几行。如果每次绘制都去遍历全量任务再判断父级折叠状态,任务上千后每帧都在做无用功。
重算时间范围时要注意一个细节——父任务的时间段应该由子任务包络自动算出来,而不是让用户手填。做法是在addTask/updateTask里向上递归找父节点,用qMin/qMax更新父任务的start/finish。这样拖动子任务时父任务条会自动伸缩,视觉上才有「汇总」的感觉。递归深度一般不会超过五六层,性能不用担心。
3.2 时间轴映射:timeToX 和 xToTime 是整套代码的地基
甘特图所有视觉元素的位置都由这两个函数决定:给定一个时间点和当前缩放比例,算出它在画布上的 x 像素;反过来给定 x 像素,反算出对应时间。写成无状态纯函数,参数里显式传基准时间,不做任何成员变量依赖。
// src/util/gantttimeaxis.h #pragma once #include <QDateTime> #include <QtGlobal> // 一个 GanttTimeAxis 描述"某段可视时间"到"某段像素"的线性映射 struct GanttTimeAxis { QDateTime base; // 映射原点:x=0 对应的时间 qreal pxPerSecond = 1.0; // 缩放核心:每秒占多少像素 // 时间 -> 像素,qreal 保留小数,避免高频缩放后的累积误差 inline qreal timeToX(const QDateTime &t) const { const qint64 ms = base.msecsTo(t); return ms / 1000.0 * pxPerSecond; } // 像素 -> 时间,用 qRound64 保证同一像素反复反算结果稳定 inline QDateTime xToTime(qreal x) const { const qint64 ms = qRound64(x / pxPerSecond * 1000.0); return base.addMSecs(ms); } // 判断一个时间区间是否落在可视区内,用于绘制裁剪 inline bool visible(const QDateTime &s, const QDateTime &f, qreal w) const { return timeToX(f) >= 0 && timeToX(s) <= w; } };三个参数的含义要点一下。base是映射原点,一般取整个项目的最早开始时间再往前留几天余量,取定之后在滚动过程中保持不变,这样滚动就是单纯改QScrollArea的偏移,不用重算所有坐标。pxPerSecond是缩放的唯一来源——日视图大概是80.0 / 86400,周视图再除以 7,所有视图级别都归结为这一个浮点数,不存在两套缩放系数需要同步。
timeToX用base.msecsTo(t)拿到毫秒差,再乘pxPerSecond / 1000。这里没用QDateTime::toMSecsSinceEpoch()相减,是因为msecsTo内部已经处理了时区和夏令时偏移,跨时区数据混进来时不会突然多出一小时。反过来xToTime里那个qRound64是血泪经验:早期版本直接截断,缩放比例小的时候,同一个像素反算出来的时间在边界上来回跳,拖拽时任务条会左右抖一个像素。
刻度步长也是一组纯函数,按当前pxPerSecond决定该画时、日、周、月还是季:
// 根据当前缩放,选择"最小可读"的刻度单位 enum class TickUnit { Hour, Day, Week, Month, Quarter, Year }; inline TickUnit pickTickUnit(qreal pxPerSecond) { const qreal pxPerDay = pxPerSecond * 86400.0; if (pxPerDay >= 720) return TickUnit::Hour; // 一天占 720px 以上,画到小时 if (pxPerDay >= 60) return TickUnit::Day; // 60px 以上,一天一格 if (pxPerDay >= 12) return TickUnit::Week; // 12px 以上,一周一格 if (pxPerDay >= 4) return TickUnit::Month; if (pxPerDay >= 1.5) return TickUnit::Quarter; return TickUnit::Year; }这些阈值不是拍脑袋定的,是按「一格至少能放下刻度文字」倒推的。日视图一格 60px 左右刚好放得下03-15,再窄就得改成隔天显示一个刻度,视觉上会很乱。实践里更稳的做法是先量一次QFontMetrics::horizontalAdvance("00-00"),再拿这个宽度加 16px 内边距去比阈值,这样换字体、改字号都不会崩。
表头和画布必须共用同一个GanttTimeAxis实例。很多版本在这里翻车:表头自己算了一套刻度位置,画布算另一套,缩放之后任务条和上面的日期整体错开几个像素,看着像 bug 又不像 bug。只要保证表头和画布的base和pxPerSecond是同一个对象里的同一份值,滚动时一起平移,对齐就是天然成立的。
3.3 行布局与折叠:yForRow、rowAt 和可见行索引
纵向的映射比横向简单得多,行高固定,就是乘除法。但折叠功能会把它变复杂——如果直接用任务在原始列表里的下标当行号,折叠之后中间会空出一段。
// src/view/ganttcanvas.cpp(节选) static constexpr int kRowHeight = 28; // 单行高度,含 1px 分隔线 static constexpr int kHeaderHeight = 44; // 顶部表头高度,画布内容从这之下开始 // 行号 -> 该行顶部的 y 像素(画布坐标系,已含滚动偏移) inline int yForRow(int row, int scrollY) const { return kHeaderHeight + row * kRowHeight - scrollY; } // y 像素 -> 行号,-1 表示落在表头或空白区 inline int rowAt(int y, int scrollY) const { const int rel = y - kHeaderHeight + scrollY; if (rel < 0) return -1; const int row = rel / kRowHeight; return row < m_model->visibleRows().size() ? row : -1; }这两个函数本身没什么技术含量,关键在于「行号」这个概念的来源必须是visibleRows()的下标,而不是任务在原始数组里的位置。折叠实现上就是重建这份索引:
// 重建可见行索引:父任务折叠时,跳过其所有后代 void GanttModel::rebuildVisibleRows() { m_visibleRows.clear(); // 先把任务按 parentId 分组,避免每行都线性扫一遍 QHash<int, QVector<int>> childrenOf; for (const GanttTask &t : m_tasks) childrenOf[t.parentId].append(t.id); // 深度优先展开,遇到 collapsed 的父节点整棵子树跳过 std::function<void(int)> walk = [&](int pid) { for (int cid : childrenOf.value(pid)) { m_visibleRows.append(cid); const GanttTask *t = taskById(cid); if (t && !t->collapsed) walk(cid); } }; walk(-1); // -1 是虚拟根节点 }childrenOf这个临时哈希表把「找子节点」从 O(n) 降到 O(1),整体重建是 O(n)。2000 条任务重建一次大概零点几毫秒,折叠展开时同步重建、发layoutChanged()信号让滚动条重算范围就够了,不用做增量。
有个坑要提前说:折叠父任务时如果当前选中项在它子树里,选中状态会指向一个不可见的行。处理方式是在setCollapsed里检查选中 id 是否还在visibleRows里,不在就清空选中并发信号。不做这一步,后面拖拽代码拿到一个rowOfTask() == -1的值,会直接在yForRow(-1)上算出负坐标,画到表头上面去。
滚动范围的维护也是同一处:verticalScrollBar()->setRange(0, visibleRows().size() * kRowHeight - viewportHeight()),横向则是timeToX(rangeFinish()) + 右边距。这两个数值都依赖模型状态,所以在dataChanged和layoutChanged两个信号里都要重算一次,漏一个就会出现「加了任务但滚不到底」的怪现象。
4. 绘制与交互:任务条、依赖箭头、拖拽改期怎么落地
4.1 paintEvent 的分层骨架与脏区控制
自绘画布的paintEvent一定不能写成「从头到尾全画一遍」。可视区里可能只有十几行任务,但时间轴横跨三五年,不裁剪的话每帧都在画屏幕外的东西。标准骨架是按层画,每层都做视口裁剪。
// src/view/ganttcanvas.cpp void GanttCanvas::paintEvent(QPaintEvent *e) { QPainter p(this); // 抗锯齿只对曲线和圆角有用;网格线关掉更锐利 p.setRenderHint(QPainter::Antialiasing, true); p.setRenderHint(QPainter::TextAntialiasing, true); // 只处理本次需要重绘的矩形,滚动时通常是一条窄带 const QRect dirty = e->rect(); p.setClipRect(dirty); const int scrollY = verticalScrollBar()->value(); const QVector<int> &rows = m_model->visibleRows(); // 算出可视区覆盖的行范围,避免遍历全量任务 const int firstRow = qMax(0, (dirty.top() - kHeaderHeight + scrollY) / kRowHeight); const int lastRow = qMin(rows.size() - 1, (dirty.bottom() - kHeaderHeight + scrollY) / kRowHeight); drawBackground(p, dirty); // 周末底纹、今日高亮线 drawGrid(p, firstRow, lastRow, scrollY); // 横线 + 纵向刻度线 drawTaskBars(p, firstRow, lastRow, scrollY); drawLinks(p, firstRow, lastRow, scrollY); // 依赖线画在任务条之上 drawRowText(p, firstRow, lastRow, scrollY); }firstRow/lastRow这两个值决定了整帧的工作量。用dirty而不是整个rect()来算,是因为拖拽时update()传进去的往往只是一个任务条大小的矩形,这时候只需要重画一到两行,滚动条拖动时才是整屏重绘。这一个小改动在千级任务的项目里能把拖拽帧率从二十几拉到满帧。
drawLinks放在任务条之后画,是为了让箭头压在条子上,视觉上更像流程。但如果箭头颜色和任务条颜色接近,会糊成一团,所以依赖线一般用浅灰加 1px 线宽,或者干脆用虚线。
还有两个属性值得开:
// 构造函数里 setAttribute(Qt::WA_OpaquePaintEvent, true); // 告诉系统这块区域不透明,省一次背景填充 setAttribute(Qt::WA_NoSystemBackground, true); setMouseTracking(true); // 不按键也要收到 mouseMoveEvent,用于 hover 高亮WA_OpaquePaintEvent的前提是paintEvent里确实把dirty区域每个像素都画到了。如果drawBackground里有透明区域没填,就会看到上一帧的残影,这种问题在滚轮快速滚动时特别明显。
4.2 任务条、进度和里程碑的具体画法
任务条的视觉就三块:圆角底条、进度填充、右侧文字。里程碑是菱形。这些写成纯绘制函数,输入QPainter和算好的矩形,不碰任何成员变量。
// src/render/ganttrenderer.cpp void GanttRenderer::drawTaskBar(QPainter &p, const QRectF &bar, const GanttTask &task, bool selected) { if (task.milestone) { // 里程碑:以 bar 中心为顶点画菱形,边长取 bar 高度 const QPointF c = bar.center(); const qreal r = bar.height() / 2.0; QPolygonF diamond{ QPointF(c.x(), c.y() - r), QPointF(c.x() + r, c.y()), QPointF(c.x(), c.y() + r), QPointF(c.x() - r, c.y()) }; p.setPen(QPen(task.barColor.darker(130), 1)); p.setBrush(task.barColor); p.drawPolygon(diamond); return; } const qreal radius = qMin(4.0, bar.height() / 3.0); // 1) 底色:任务条本体 p.setPen(selected ? QPen(QColor("#1A1A1A"), 2) : QPen(task.barColor.darker(120), 1)); p.setBrush(task.barColor); p.drawRoundedRect(bar, radius, radius); // 2) 进度:左侧按 progress% 填充,用更深的同色系 if (task.progress > 0) { QRectF prog = bar; prog.setWidth(bar.width() * qBound(0, task.progress, 100) / 100.0); p.setPen(Qt::NoPen); p.setBrush(task.barColor.darker(140)); // 裁剪成和底条一样的圆角,否则左侧圆角会被进度盖成直角 QPainterPath clip; clip.addRoundedRect(bar, radius, radius); p.save(); p.setClipPath(clip); p.drawRect(prog); p.restore(); } // 3) 文字:宽度够写在条内,不够就贴到条的右侧外面 const QString label = QString("%1 %2%").arg(task.name).arg(task.progress); p.setPen(bar.width() > p.fontMetrics().horizontalAdvance(label) + 12 ? QColor("#FFFFFF") : QColor("#333333")); const QRectF textRect = bar.width() > 80 ? bar.adjusted(6, 0, -6, 0) : QRectF(bar.right() + 6, bar.top(), 300, bar.height()); p.drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, label); }进度填充那段用QPainterPath裁剪,是因为直接drawRect会把圆角底条左边的两个圆角盖成直角,看着很脏。这个细节几乎所有自绘甘特图都会漏掉,改起来就三行。
文字颜色的判断用horizontalAdvance而不是硬编码宽度,换字号之后依然成立。条子太窄时文字甩到右侧外面,这条要配合lastRow的裁剪一起看,否则最右列的任务文字会盖到滚动条上。
selected状态用外描边而不是改底色,这样进度信息还在,用户能看清选中的是哪个。描边宽度 2px 时要注意QRectF会被撑大,如果需要精确对齐,得先adjusted(1, 1, -1, -1)再画。
4.3 依赖箭头的四种类型与折线路由
依赖线是甘特图里最容易画丑的部分。FS(完成后开始)最常见,箭头从上游任务条右端出发,到下游任务条左端结束。SS、FF、SF 三种的起止锚点不同,需要一个统一的锚点函数。
// src/render/ganttlinkrouter.cpp // 按依赖类型取出起点和终点锚点 static void anchors(const QRectF &from, const QRectF &to, LinkType type, QPointF &a, QPointF &b, int &outDir, int &inDir) { switch (type) { case LinkType::FS: // 上游右端 -> 下游左端 a = QPointF(from.right(), from.center().y()); outDir = +1; b = QPointF(to.left(), to.center().y()); inDir = -1; break; case LinkType::SS: // 上游左端 -> 下游左端 a = QPointF(from.left(), from.center().y()); outDir = -1; b = QPointF(to.left(), to.center().y()); inDir = -1; break; case LinkType::FF: // 上游右端 -> 下游右端 a = QPointF(from.right(), from.center().y()); outDir = +1; b = QPointF(to.right(), to.center().y()); inDir = +1; break; case LinkType::SF: // 上游左端 -> 下游右端 a = QPointF(from.left(), from.center().y()); outDir = -1; b = QPointF(to.right(), to.center().y()); inDir = +1; break; } } // 折线路由:出点水平拉一段 -> 竖直走 -> 水平进目标,拐角处画箭头 QPainterPath GanttLinkRouter::route(const QRectF &from, const QRectF &to, LinkType type, qreal stub) { QPointF a, b; int outDir = 1, inDir = -1; anchors(from, to, type, a, b, outDir, inDir); QPainterPath path(a); const QPointF a1(a.x() + outDir * stub, a.y()); // 出点先横拉 stub 像素 const QPointF b1(b.x() + inDir * stub, b.y()); // 入点也横拉 stub path.lineTo(a1); // 两端 stub 的 x 如果已经前后错开,直接走中点竖直段 const qreal midX = (a1.x() + b1.x()) / 2.0; if ((outDir > 0 && midX >= a1.x()) || (outDir < 0 && midX <= a1.x())) { path.lineTo(midX, a1.y()); path.lineTo(midX, b1.y()); } else { // 目标在上游左边,得绕出去再回来,走一个"回形"折线 const qreal detour = qMin(a1.x(), b1.x()) - stub; path.lineTo(detour, a1.y()); path.lineTo(detour, b1.y()); } path.lineTo(b1); path.lineTo(b); return path; }stub这个参数控制出点横拉的固定长度,一般取 12 到 16 像素。它存在的意义是让箭头不会紧贴着任务条边缘出发,视觉上有呼吸感。但stub不能太大,两个任务靠得很近时,两段 stub 会重叠,折线退化成一条竖线。
midX那段判断处理的是「下游任务在上游任务左边」这种反常情况——排程数据里经常出现,原因可能是下游任务被手工改早了,或者 SS 类型的依赖本来就是这样。不加这个分支,折线会直接穿过两个任务条,视觉上完全没法看。
箭头本身用一个小三角填充:
// 在终点 b 处按 inDir 方向画箭头 static void drawArrowHead(QPainter &p, const QPointF &tip, int dir, const QColor &c) { constexpr qreal s = 5.0; // 箭头半高 QPolygonF head; head << tip << QPointF(tip.x() - dir * s * 1.6, tip.y() - s) << QPointF(tip.x() - dir * s * 1.6, tip.y() + s); p.setPen(Qt::NoPen); p.setBrush(c); p.drawPolygon(head); }依赖线数量是 O(任务数) 量级,不会成为性能瓶颈,但绘制顺序有讲究:先画所有线,再统一画箭头,这样箭头不会被后面画的线盖住。或者给每条线单独save/restore画笔也行,只是多几次状态切换。
4.4 拖拽改期与滚轮缩放:命中测试和缩放锚点
交互部分的核心是两件事:给定鼠标坐标,判断点到了什么;以及拖拽过程中把像素位移换算回时间。
命中测试放在独立文件里,返回一个描述对象,把「行号、任务 id、落在条子的哪个部位」一次性带出来——左边缘用于拉伸开始时间,右边缘用于拉伸结束时间,中间用于整体平移。
// src/interact/gantthittest.cpp HitResult GanttHitTest::hit(const QPoint &pos, int scrollY) const { HitResult r; r.row = rowAt(pos.y(), scrollY); if (r.row < 0) return r; // 落在表头或空白 const int taskId = m_model->visibleRows().at(r.row); const GanttTask *t = m_model->taskById(taskId); if (!t) return r; const qreal x1 = m_axis.timeToX(t->start); const qreal x2 = m_axis.timeToX(t->finish); const QRectF bar(x1, yForRow(r.row, scrollY) + 4, qMax<qreal>(x2 - x1, 8.0), kRowHeight - 8); if (!bar.contains(pos.x(), pos.y())) return r; // 行对了但没点到条子 r.taskId = taskId; // 边缘 5px 视为拉伸区,中间为平移区;条子太窄时全部当平移 constexpr qreal kEdge = 5.0; if (bar.width() > kEdge * 3) { if (pos.x() - bar.left() <= kEdge) r.part = HitPart::LeftEdge; else if (bar.right() - pos.x() <= kEdge) r.part = HitPart::RightEdge; else r.part = HitPart::Body; } else { r.part = HitPart::Body; } return r; }bar的高度取kRowHeight - 8,上下各留 4px 空白,这样行与行之间的任务条不会挨在一起,点击时有明确的空隙。边缘判定加bar.width() > kEdge * 3这个条件,是为了防止极短任务(比如一天的任务在月视图下只有两三像素宽)被左右两个边缘区完全吃掉,导致点哪儿都是拉伸、没法整体拖。
拖拽状态机分按下、移动、松开三步,关键是按下时把原始时间和鼠标起点都存下来,移动时基于增量算,而不是基于上一次的位置累加——累加会引入误差,拖久了能偏出好几天。
// src/interact/ganttdraghandler.cpp void GanttDragHandler::onPress(const QPoint &pos) { m_hit = m_canvas->hitTest(pos); if (m_hit.taskId < 0) return; const GanttTask *t = m_model->taskById(m_hit.taskId); m_originStart = t->start; // 记住按下瞬间的原始值 m_originFinish = t->finish; m_pressPos = pos; m_dragging = true; } void GanttDragHandler::onMove(const QPoint &pos) { if (!m_dragging) return; // 像素位移 -> 毫秒位移,再吸附到整天上,避免出现 23:59:59 这种值 const qreal dxPx = pos.x() - m_pressPos.x(); const qint64 dMs = qRound64(dxPx / m_axis.pxPerSecond * 1000.0); const qint64 dayMs = 86400000LL; const qint64 snap = qRound64(double(dMs) / dayMs) * dayMs; GanttTask t = *m_model->taskById(m_hit.taskId); switch (m_hit.part) { case HitPart::Body: // 整体平移,工期保持不变 t.start = m_originStart.addMSecs(snap); t.finish = m_originFinish.addMSecs(snap); break; case HitPart::LeftEdge: // 拉伸开始,结束不动 t.start = m_originStart.addMSecs(snap); if (t.start >= t.finish) t.start = t.finish.addDays(-1); // 最小工期 1 天 break; case HitPart::RightEdge: t.finish = m_originFinish.addMSecs(snap); if (t.finish <= t.start) t.finish = t.start.addDays(1); break; default: return; } m_model->updateTask(t.id, t); // 触发 dataChanged,画布只重绘受影响行 }吸附到整天用qRound64(dMs / dayMs),四舍五入而不是截断。截断的话往右拖要拖过完整一天才动,手感像有延迟;四舍五入拖半格就跳,更跟手。需要精细到小时的场景(比如排班),把这个常量换成3600000LL即可。
滚轮缩放最容易翻车的地方是锚点——用户希望缩放时鼠标指着的时间点不动。实现步骤是:缩放前先记录鼠标位置对应的时间,改完pxPerSecond之后重新算这个时间应该在哪个 x,然后把横向滚动条偏移调整到两者一致。
void GanttCanvas::wheelEvent(QWheelEvent *e) { if (!(e->modifiers() & Qt::ControlModifier)) { // 不按 Ctrl 就正常滚动 QWidget::wheelEvent(e); return; } const qreal factor = e->angleDelta().y() > 0 ? 1.15 : 1.0 / 1.15; const qreal newScale = qBound(0.0002, m_axis.pxPerSecond * factor, 0.5); // 1) 缩放前,鼠标位置对应哪个时间 const int mouseX = e->position().x() + horizontalScrollBar()->value(); const QDateTime anchor = m_axis.xToTime(mouseX); // 2) 应用新比例 m_axis.pxPerSecond = newScale; refreshScrollRange(); // 3) 让 anchor 这个时间重新落回鼠标下方 const qreal newX = m_axis.timeToX(anchor); horizontalScrollBar()->setValue(qRound(newX - e->position().x())); update(); e->accept(); }qBound的上下限按实际需要调。上限 0.5 像素每秒大概是日视图再放大六七倍,下限 0.0002 能压到年视图看五年跨度。refreshScrollRange()必须在设滚动条值之前调,否则新值会被旧范围钳掉,表现为放大到一定程度就滚不动了。
5. 避坑与排查:自绘甘特图最容易翻车的五个地方
5.1 坐标与对齐类问题
现象:缩放之后表头上的日期和下面的任务条整体错开,越往右偏得越多。
原因是表头和画布各自维护了一套刻度计算,一个是基于QDateTime逐格累加,一个是基于线性映射乘出来的。逐格累加在跨月、跨年时会因为每月天数不同产生累积误差,而线性映射不会,两套结果几百像素之后就对不上。解决办法是把刻度位置也纳入GanttTimeAxis:表头遍历刻度时间点时,位置一律调axis.timeToX(tickTime)拿,不在表头里做任何自己的加法。我改过的版本里,表头文件删掉了一半代码,只留下一句for (每个刻度) painter.drawText(timeToX(刻度), ...)。
现象:高 DPI 屏幕上任务条边缘发糊,1px 网格线变成 2px 灰带。
两个原因叠在一起。一是paintEvent里用了整型矩形,坐标被提前截断;二是抗锯齿在小数坐标上把 1px 线摊到了两个物理像素上。处理方式是:几何计算全程用QRectF/QPointF保留小数,只在最终drawLine时对网格线做qRound(x) + 0.5的半像素偏移,并单独关掉网格线的抗锯齿。任务条的圆角矩形保留抗锯齿,那是需要平滑的。
提示:在 4K 屏上开发时,最好拿一张任务条特别密集的图对比一下开启和关闭
Qt::AA_EnableHighDpiScaling的差异,肉眼能直接看出来对不齐。
5.2 性能与绘制类问题
现象:任务加到八百条之后,拖动横向滚动条明显卡顿,任务越多越慢。
这基本可以断定paintEvent里在遍历全量任务。自绘画布不会自动帮你裁剪,visibleRows()有八百个它就老老实实画八百个,哪怕屏幕里只有十五行。改法是按e->rect()反算firstRow/lastRow,只遍历这段。同一份数据我实测过一次,八百条任务、可视区十五行,改之前单帧二十多毫秒,改之后不到两毫秒,滚动条终于跟手了。
现象:滚动时画面上偶尔闪过上一帧的残影,快速滚动时尤其明显。
通常是开了WA_OpaquePaintEvent但paintEvent没有覆盖满dirty区域。这个属性等于向系统承诺「这块我全画了,你不用擦」,承诺没兑现就会看到脏数据。检查顺序是:先看drawBackground有没有对dirty做fillRect,再看各层绘制有没有因为裁剪条件过严而漏画。为了排查,可以临时把setClipRect(dirty)换成setClipRect(rect())跑一遍,如果残影消失,就说明是裁剪区域算出界了。
5.3 时间与数据类问题
现象:把任务往右拖三格,松开后开始时间变成前一天 23:00。
这是时区和夏令时混用的典型症状。QDateTime在没有显式setTimeSpec()的情况下默认本地时间,addDays()在夏令时切换那天加减的是 23 或 25 小时。解决思路是:存储层统一用 UTC 或QDate,展示层才转本地时间;日期加减一律用QDate::addDays()而不是QDateTime::addDays(),因为QDate不受时钟调整影响。这条我在项目里吃过一次亏,查了快一天才发现问题是「时间本身没错,是解释方式错了」。
现象:折叠父任务之后,再点空白处程序崩溃,堆栈指向visibleRows().at(row)。
rowAt()返回了一个基于旧行数的下标,而visibleRows()已经被重建变短了。这类问题的根源都是「行号缓存」和「可见行索引」不同步。稳妥做法是在HitResult里不光存行号,也存任务 id,后续所有逻辑以 id 为准,行号只用于算 y 坐标,用之前再查一次rowOfTask(id),返回 -1 就直接丢弃这次事件。
注意:任何修改数据的操作——折叠、删除、拖拽改期、批量导入——都必须走模型接口并发
dataChanged()/layoutChanged(),不要在外面直接改QVector里的元素,否则画布和滚动条拿到的还是旧状态。
6. 二次开发改造:把它接进自己项目时我一般动这几处
这份控件拿来直接用的情况很少,绝大多数是要往里塞业务语义。按改动量从小到大排,我一般从这三处下手。
第一处是数据适配层。控件内部的GanttTask不会和你数据库里的字段一一对应,写一个转换函数比改控件结构省事得多。比如从QSqlQuery读出来转成任务列表:
// 把业务表映射成控件需要的最小字段集,字段名差异都在这里消化 GanttTaskList loadFromQuery(QSqlQuery &q) { GanttTaskList out; while (q.next()) { GanttTask t; t.id = q.value("plan_id").toInt(); t.parentId = q.value("parent_id").isNull() ? -1 : q.value("parent_id").toInt(); t.name = q.value("plan_name").toString(); // 数据库存 UTC,展示转本地,避免夏令时带来的日期偏移 t.start = q.value("start_at").toDateTime().toLocalTime(); t.finish = q.value("end_at").toDateTime().toLocalTime(); t.progress = q.value("progress").toInt(); t.milestone= q.value("is_milestone").toBool(); t.owner = q.value("owner").toString(); out.append(t); } return out; }第二处是渲染扩展点。GanttRenderer里的几个draw*函数都是公开的,加自定义标记(比如超期任务画红框、关键路径加粗)不用改画布,写一个包装函数在处理完原逻辑后补两笔即可。第三处是交互钩子:GanttCanvas拖拽结束时发一个taskEdited(int id, QDateTime s, QDateTime f)信号,界面层接住这个信号去做校验和持久化,控件本身不碰数据库。
导出这块值得单独提,因为工控和项目汇报场景经常要往文档里贴图。用render()直接画到大尺寸QImage上,注意临时把pxPerSecond调大、按内容宽度算出目标尺寸,导完再恢复:
// 导出整张甘特图为 PNG,按内容范围算尺寸,不受当前视口限制 bool exportToPng(GanttCanvas *canvas, const QString &path) { const QSize oldSize = canvas->size(); const qreal oldScale = canvas->axis().pxPerSecond; // 1) 按任务数算出完整内容高度,不做视口裁剪 const int rows = canvas->model()->visibleRows().size(); const int w = qCeil(canvas->axis().timeToX(canvas->model()->rangeFinish())) + 80; const int h = kHeaderHeight + rows * kRowHeight + 40; canvas->resize(w, h); // 触发一次完整布局 canvas->setExportMode(true); // 内部标记:忽略滚动偏移,从 y=0 开始画 QImage img(w, h, QImage::Format_ARGB32_Premultiplied); img.fill(Qt::white); QPainter p(&img); canvas->render(&p); // 走同一套 paintEvent,输出和屏幕一致 p.end(); canvas->setExportMode(false); canvas->resize(oldSize); // 恢复现场,否则界面会跳一下 canvas->setPxPerSecond(oldScale); return img.save(path, "PNG"); }这段里最容易忘的是恢复现场那两行。不恢复的话,导出完界面尺寸就变了,用户会觉得点了下导出结果窗口被拉大。setExportMode(true)是我后来加的一个开关,作用是让paintEvent忽略滚动偏移、从第一行开始画,否则导出的图只有当前可视区那一段。
验证改造是否安全,我一般用两招。一是造一份 1000 条任务、200 条依赖的假数据跑一遍折叠展开和连续缩放,观察内存有没有持续上涨——QPainterPath和QFontMetrics如果每帧新建,内存曲线会很明显地锯齿上升。二是把pxPerSecond压到最小再拉到最大,看任务条和表头有没有错位,这一轮能筛掉绝大多数坐标缓存问题。
从那以后我每次改这类自绘控件,都强制先用假数据把量级拉满跑一遍再上真数据,宁可多花二十分钟造假数据,也不想在客户现场对着卡顿的界面猜是哪个循环没裁剪。希望帮到你。
本文还有配套的精品资源,点击获取