☰
Qt QGraphicsView 实现高性能甘特图的原理与实践
2026/10/20 4:15:04 网站建设 项目流程

简介:本资源是一份基于Qt框架实现甘特图的完整C++源码工程,面向具备基础Qt开发经验的中初级桌面应用开发者,解决项目管理类软件中任务时间轴可视化与交互控制的核心需求。压缩包共12个文件(5个头文件.h用于定义数据结构与对话框接口,4个.cpp实现核心逻辑如任务增删、时间计算与图形渲染,另含1个Qt工程配置.pro、1个说明性txt及1个.user用户配置),总大小仅14KB,轻量易集成。已有3109人学习下载,反映出其在教学演示与小型项目快速落地中的实用价值。读者可直接编译运行,掌握QGraphicsView场景绘图、QDateTime时间建模、信号槽驱动视图更新、拖放式任务编辑等关键技术点,并复用模块化设计思路——如ActionStruct.h与Solving.h分别封装行为逻辑与调度算法,为后续扩展资源依赖、关键路径分析等功能提供清晰架构基础。

1. Qt 实现甘特图源码:不是控件堆砌,而是时间轴+任务槽+拖拽反馈的三重协同系统

你试过用 Qt 写甘特图吗?不是调个 QTableWidget 加几条线就叫甘特图——真正落地的工业级排程界面,得让产线调度员一眼看清「A工单在C设备上从第3天8:00干到第5天14:30」,还得支持鼠标拖动调整工期、按 Shift 多选后批量平移、右键弹出「插入空闲段」「拆分任务」「关联依赖箭头」等上下文操作。这份开源的 Qt 甘特图源码,正是基于 QGraphicsView 构建的轻量级可嵌入组件,不依赖第三方商业库(如 Qt Charts 或第三方甘特控件),纯 C++/Qt5.15+ 实现,含完整时间刻度缩放、任务条渲染、依赖关系连线、滚动同步、导出 PNG/PDF 等能力。它适合嵌入 MES 调度模块、实验室资源预约系统、项目管理 Demo 演示,或作为 Qt 自定义视图开发的教学范本。如果你正卡在「QGraphicsItem 怎么响应拖拽又不卡顿」「如何让时间轴缩放时文字不糊」「任务条被遮挡后右键菜单定位偏移」这类具体问题上,这份源码就是你该拆的第一份真实工程。


2. 从零理解甘特图核心结构:为什么必须用 QGraphicsView 而非 QWidget 堆叠?

甘特图本质是二维时空映射:横轴为连续时间(秒/分钟/小时/天),纵轴为离散资源(设备、人员、工位)。当任务数超 50 条、时间跨度达 30 天、需支持 0.5x~4x 缩放时,传统 QWidget + QPainter 绘制会迅速陷入性能黑洞——每次 resize 或 scroll 都触发全量重绘,CPU 占用飙升,拖拽延迟明显。而 QGraphicsView 的场景-视图分离架构,天然适配此类需求:任务条、时间轴、网格线、依赖箭头全部作为独立 QGraphicsItem 存于 QGraphicsScene 中,视图仅负责裁剪与变换,GPU 加速合成,滚动和缩放几乎无感。更重要的是,它提供精确的坐标映射(scenePos ↔ viewPos)、事件拦截链(mousePressEvent → mouseMoveEvent → mouseReleaseEvent 可逐层控制)、Z 值层级管理(确保时间轴在底层、任务条居中、菜单浮顶层),这是 QWidget 手动 manage 子控件永远无法优雅解决的。

2.1 时间轴模块:动态刻度生成与像素对齐策略

时间轴(TimeAxis)是整个甘特图的基准尺。源码中TimeAxisItem : public QGraphicsWidget封装了刻度逻辑,关键不在“画多少条线”,而在“何时重算刻度密度”。它不预设固定间隔(如每小时一条),而是根据当前视图宽度、最小像素间隔阈值(默认 60px)、时间跨度自动选择最优粒度:

// timeaxisitem.cpp 关键片段 void TimeAxisItem::updateScale() { const qreal viewWidth = scene()->views().first()->viewport()->width(); const qint64 totalMs = mEndTimeMS - mStartTimeMS; const qreal msPerPixel = static_cast<qreal>(totalMs) / viewWidth; // 根据像素密度选择刻度单位:毫秒→秒→分钟→小时→天 if (msPerPixel < 1000) { mTickUnit = TickUnit::Millisecond; mTickStep = 500; // 每500ms一条 } else if (msPerPixel < 60000) { mTickUnit = TickUnit::Second; mTickStep = 30; // 每30秒一条 } else if (msPerPixel < 3600000) { mTickUnit = TickUnit::Minute; mTickStep = 15; // 每15分钟一条 } else if (msPerPixel < 86400000) { mTickUnit = TickUnit::Hour; mTickStep = 2; // 每2小时一条 } else { mTickUnit = TickUnit::Day; mTickStep = 1; // 每天一条 } update(); // 触发重绘 }

提示:mTickStep并非固定步长,而是“显示密度控制阀”。例如当msPerPixel = 50000(约每像素50秒),若强行每分钟画一条线,会导致刻度文字严重重叠;而设为每15分钟一条,则文字间距 ≈ 750px,清晰可读。此逻辑避免了“缩放后刻度糊成一片”的经典翻车现场。

2.2 任务条渲染:QGraphicsRectItem 的复用与状态管理

每个任务对应一个GanttTaskItem : public QGraphicsRectItem。但源码并未为每个任务 new 一个 item——它采用对象池(Object Pool)模式缓存已创建的 task item,setTaskData()方法复用实例而非重建,极大减少内存分配。更关键的是,任务条状态(正常/选中/拖拽中/禁用)通过setPen()和setBrush()动态切换,且严格遵循 Qt 渲染规则:

// gantttaskitem.cpp void GanttTaskItem::updateAppearance() { switch (mState) { case Normal: setPen(QPen(Qt::darkGray, 1)); setBrush(QColor(173, 216, 230, 200)); // 浅蓝半透 break; case Selected: setPen(QPen(Qt::blue, 2, Qt::DashLine)); setBrush(QColor(135, 206, 250, 220)); // 亮蓝 break; case Dragging: setPen(QPen(Qt::red, 2, Qt::SolidLine)); setBrush(QColor(255, 105, 180, 180)); // 粉红高亮 break; case Disabled: setPen(QPen(Qt::gray, 1, Qt::DotLine)); setBrush(QColor(211, 211, 211, 150)); // 灰白 break; } update(); // 仅重绘自身区域,非全屏 }

注意:update()调用范围精准到 item 边界矩形,配合QGraphicsView::setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate),确保拖拽时只有任务条区域刷新,背景网格与时间轴完全不动,帧率稳定在 60fps 以上。

2.3 依赖关系连线:贝塞尔曲线与锚点绑定机制

甘特图中「任务B必须在任务A完成后启动」需可视化为带箭头的连线。源码用QGraphicsPathItem绘制三次贝塞尔曲线,起点锚定在任务A右边界中点,终点锚定在任务B左边界中点,并随任务位置变化实时更新:

// dependencylineitem.cpp void DependencyLineItem::updatePath() { if (!mFromTask || !mToTask) return; // 获取任务条在 scene 坐标系中的位置 QRectF fromRect = mFromTask->sceneBoundingRect(); QRectF toRect = mToTask->sceneBoundingRect(); QPointF startPoint = fromRect.topRight(); // A任务右上角 QPointF endPoint = toRect.topLeft(); // B任务左上角 // 构造贝塞尔控制点:水平偏移 + 垂直缓冲 QPointF ctrl1 = startPoint + QPointF(100, 0); QPointF ctrl2 = endPoint + QPointF(-100, 0); QPainterPath path; path.moveTo(startPoint); path.cubicTo(ctrl1, ctrl2, endPoint); setPath(path); // 箭头头用 addArrowHead() 方法绘制小三角形 }

关键设计:DependencyLineItem通过QGraphicsItem::ItemSendsGeometryChanges标志监听父项(任务条)位置变更,itemChange(GraphicsItemChange change, const QVariant &value)中捕获ItemPositionHasChanged事件,立即调用updatePath()。这比轮询或信号槽更高效,且避免了因任务快速拖拽导致连线瞬时错位的“黑匣子”现象。


3. 快速集成到你的 Qt 项目:三步完成嵌入与数据绑定

这份源码以模块化方式组织,无需修改主程序架构即可接入。核心是GanttView(继承自QGraphicsView)与GanttScene(继承自QGraphicsScene)两个类,其余为辅助项(时间轴、任务条、连线等)。集成过程不涉及 moc 编译异常、UI 文件冲突或信号重复连接等常见坑。

3.1 步骤一:添加源文件并配置 CMakeLists.txt

将下载包中src/gantt/目录下所有.h和.cpp文件复制到你的项目目录(如myapp/src/gantt/),然后在CMakeLists.txt中追加:

# myapp/CMakeLists.txt set(GANTT_SOURCES src/gantt/ganttview.cpp src/gantt/ganttscene.cpp src/gantt/timeaxisitem.cpp src/gantt/gantttaskitem.cpp src/gantt/dependencylineitem.cpp src/gantt/ganttexporter.cpp ) set(GANTT_HEADERS src/gantt/ganttview.h src/gantt/ganttscene.h src/gantt/timeaxisitem.h src/gantt/gantttaskitem.h src/gantt/dependencylineitem.h src/gantt/ganttexporter.h ) add_library(gantt STATIC ${GANTT_SOURCES} ${GANTT_HEADERS}) target_include_directories(gantt PUBLIC src/gantt) target_link_libraries(myapp PRIVATE gantt)

参数说明:gantt定义为静态库,避免符号冲突;PUBLIC src/gantt确保你的业务代码能#include "ganttview.h";target_link_libraries将其链接进主应用。若用 qmake,对应添加HEADERS += $$PWD/src/gantt/*.h和SOURCES += $$PWD/src/gantt/*.cpp。

3.2 步骤二:在 UI 中声明 GanttView 并初始化场景

假设你在MainWindow中嵌入甘特图,.h文件中声明成员:

// mainwindow.h #include "gantt/ganttview.h" class MainWindow : public QMainWindow { Q_OBJECT private: GanttView *m_ganttView; GanttScene *m_ganttScene; };

.cpp中初始化(推荐在setupUi()后):

// mainwindow.cpp void MainWindow::initGantt() { m_ganttScene = new GanttScene(this); m_ganttView = new GanttView(m_ganttScene, this); // 设置时间范围:2024-01-01 00:00:00 到 2024-01-31 23:59:59 QDateTime start = QDateTime::fromString("2024-01-01 00:00:00", "yyyy-MM-dd hh:mm:ss"); QDateTime end = QDateTime::fromString("2024-01-31 23:59:59", "yyyy-MM-dd hh:mm:ss"); m_ganttScene->setTimeRange(start, end); // 添加3个资源(设备) m_ganttScene->addResource("设备A"); m_ganttScene->addResource("设备B"); m_ganttScene->addResource("设备C"); // 将视图放入 centralWidget 的布局(假设已有 QVBoxLayout *mainLayout) mainLayout->addWidget(m_ganttView); }

逻辑说明:GanttScene是数据容器,GanttView是渲染窗口。setTimeRange()定义横轴总跨度;addResource()注册纵轴资源,返回资源索引(int),后续添加任务时需指定该索引。

3.3 步骤三:加载任务数据并触发渲染

任务数据以结构体形式传入,GanttTaskData包含必要字段:

struct GanttTaskData { int resourceId; // 对应 addResource() 返回的索引 QDateTime startTime; // 任务开始时间 QDateTime endTime; // 任务结束时间 QString title; // 任务标题(显示在条上) QString description; // 悬停提示文本 bool isCritical; // 是否关键路径(影响连线颜色) };

批量添加示例:

// mainwindow.cpp void MainWindow::loadSampleTasks() { QList<GanttTaskData> tasks; tasks << GanttTaskData{0, QDateTime::fromString("2024-01-05 08:00:00", "yyyy-MM-dd hh:mm:ss"), QDateTime::fromString("2024-01-07 16:00:00", "yyyy-MM-dd hh:mm:ss"), "工单#1001", "A设备加工", false}; tasks << GanttTaskData{1, QDateTime::fromString("2024-01-06 09:00:00", "yyyy-MM-dd hh:mm:ss"), QDateTime::fromString("2024-01-08 12:00:00", "yyyy-MM-dd hh:mm:ss"), "工单#1002", "B设备检测", true}; // 关键路径 tasks << GanttTaskData{2, QDateTime::fromString("2024-01-09 10:00:00", "yyyy-MM-dd hh:mm:ss"), QDateTime::fromString("2024-01-10 18:00:00", "yyyy-MM-dd hh:mm:ss"), "工单#1003", "C设备装配", false}; m_ganttScene->addTasks(tasks); // 添加依赖:工单#1002 必须在 #1001 之后开始 m_ganttScene->addDependency(0, 1); // 从索引0的任务指向索引1的任务 }

参数说明:addTasks()内部自动为每个任务创建GanttTaskItem并加入 scene;addDependency(int fromIndex, int toIndex)根据任务在 scene 内部列表中的顺序索引建立连线。注意:索引按addTasks()调用顺序递增,非 resourceId!


4. 避坑指南:五个血泪经验换来的高频报错与修复方案

甘特图集成中最容易栽跟头的地方,往往藏在 Qt 坐标系转换、事件拦截优先级、内存生命周期这些“看不见的角落”。以下是实测中反复出现的 5 类问题,按「现象 → 原因 → 解决」给出可直接抄的方案。

4.1 现象:拖拽任务条时,鼠标指针脱离任务条边缘,松手后任务跳回原位

原因:GanttTaskItem::mouseMoveEvent()中未正确调用QGraphicsItem::mouseMoveEvent(event),导致QGraphicsView的默认拖拽逻辑被阻断,QGraphicsItemGroup或QGraphicsScene无法感知位置变更。
解决:在自定义mouseMoveEvent结尾强制调用父类方法,确保 Qt 底层坐标更新链完整:

void GanttTaskItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { // ... 自己的拖拽计算逻辑 if (m_isDragging) { // 更新任务数据中的 startTime/endTime m_taskData.startTime = scenePosToDateTime(event->scenePos().x(), true); m_taskData.endTime = scenePosToDateTime(event->scenePos().x() + width(), false); } QGraphicsRectItem::mouseMoveEvent(event); // ← 关键!必须调用 }

4.2 现象:缩放时间轴后,刻度文字模糊、锯齿严重,尤其在 1.5x 或 2.25x 缩放时

原因:QPainter默认使用Qt::SmoothTransformation,但对小字号文本(如 8pt 时间标签)会过度插值导致发虚;且未启用QPainter::Antialiasing对文字路径进行抗锯齿。
解决:在TimeAxisItem::paint()开头显式设置渲染提示:

void TimeAxisItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { painter->setRenderHint(QPainter::Antialiasing, true); // 文字抗锯齿 painter->setRenderHint(QPainter::TextAntialiasing, true); // 文本专用抗锯齿 painter->setRenderHint(QPainter::SmoothPixmapTransform, false); // 关闭图片插值,刻度线是矢量 // ... 后续绘制逻辑 }

4.3 现象:右键点击任务条弹出菜单,但菜单位置总偏移 20px,且随滚动条位置变化

原因:QGraphicsView::mapToGlobal()返回的是 viewport 坐标,而QMenu::exec()需要屏幕绝对坐标;当视图存在水平/垂直滚动时,mapToGlobal()未补偿horizontalOffset()和verticalOffset()。
解决:手动计算滚动偏移并校正:

void GanttView::contextMenuEvent(QContextMenuEvent *event) { QPoint globalPos = mapToGlobal(event->pos()); // 补偿滚动偏移 globalPos.rx() += horizontalScrollBar()->value(); globalPos.ry() += verticalScrollBar()->value(); QMenu menu; menu.addAction("编辑任务"); menu.addAction("删除任务"); menu.exec(globalPos); // ← 此时位置精准 }

4.4 现象:添加大量任务(>200 条)后,首次渲染极慢(>5s),CPU 占用 100%

原因:GanttScene::addTasks()默认逐个addItem(),每次 addItem 触发 scene 重排与视图更新;且GanttTaskItem构造时默认调用update(),造成 N 次无效重绘。
解决:批量添加前禁用 scene 更新,添加后再统一刷新:

void GanttScene::addTasks(const QList<GanttTaskData> &tasks) { setItemIndexMethod(QGraphicsScene::NoIndex); // 关闭索引加速查找 for (const auto &task : tasks) { GanttTaskItem *item = new GanttTaskItem(task); item->setFlag(QGraphicsItem::ItemIsMovable, true); item->setFlag(QGraphicsItem::ItemIsSelectable, true); addItem(item); // 此时不触发重绘 } setItemIndexMethod(QGraphicsScene::BspTreeIndex); // 恢复索引 views().first()->viewport()->update(); // 强制一次全量更新 }

4.5 现象:导出 PDF 时,任务条颜色变浅、文字缩放失真,且依赖连线消失

原因:QPainter导出 PDF 使用QPaintEngine::Pdf,其对QLinearGradient笔刷支持不完整,且QGraphicsView::render()默认使用QPainter::DrawChildren模式,可能忽略QGraphicsPathItem(依赖连线)的绘制。
解决:导出时切换为QPainter::DrawEverything模式,并用纯色笔刷替代渐变:

bool GanttExporter::exportToPdf(const QString &filePath, const QRectF &exportRect) { QPdfWriter writer(filePath); writer.setPageSize(QPagedPaintDevice::A4); QPainter painter(&writer); // 关键:启用 DrawEverything,确保所有 item(包括 path)被绘制 m_ganttView->render(&painter, QRectF(), exportRect, Qt::IgnoreAspectRatio); // 若仍有问题,临时替换任务条笔刷为纯色 foreach (auto item, m_ganttScene->items()) { if (GanttTaskItem *task = dynamic_cast<GanttTaskItem*>(item)) { task->setBrush(QColor(173, 216, 230)); // 去掉 alpha 通道 } } return true; }

5. 进阶技巧:实现「跨天任务自动拆分」与「依赖冲突实时预警」

甘特图真正的价值,不在静态展示,而在动态约束检查与智能辅助。源码预留了扩展钩子,以下两个技巧可直接提升你的系统专业度:任务自动拆分应对日历工作制,依赖冲突预警避免计划硬伤。

5.1 技巧一:按工作日历自动拆分跨天任务(支持节假日屏蔽)

实际排程中,「任务耗时72小时」不等于「从周一8:00连续干到周三8:00」,需跳过周末与法定假日。源码GanttScene提供splitTaskByCalendar()接口,传入QCalendar实例与工作时段规则:

// 定义工作日历:周一至周五 8:00-12:00, 13:00-17:00,屏蔽 2024 国庆假期 QCalendar calendar; calendar.setWorkDays(Qt::Monday | Qt::Tuesday | Qt::Wednesday | Qt::Thursday | Qt::Friday); calendar.addWorkHours(QTime(8, 0), QTime(12, 0)); calendar.addWorkHours(QTime(13, 0), QTime(17, 0)); calendar.addHoliday(QDate(2024, 10, 1)); calendar.addHoliday(QDate(2024, 10, 2)); calendar.addHoliday(QDate(2024, 10, 3)); calendar.addHoliday(QDate(2024, 10, 4)); calendar.addHoliday(QDate(2024, 10, 5)); calendar.addHoliday(QDate(2024, 10, 6)); calendar.addHoliday(QDate(2024, 10, 7)); // 对任务进行拆分 GanttTaskData originalTask = {...}; // 原始跨天任务 QList<GanttTaskData> splitTasks = m_ganttScene->splitTaskByCalendar(originalTask, calendar); // 将拆分后的多段任务添加到场景 m_ganttScene->addTasks(splitTasks);

内部逻辑:splitTaskByCalendar()按工作时段切片,每段生成独立GanttTaskData,并自动设置isSplitPart = true标志。渲染时,同源拆分任务条用相同颜色但加细边框区分,鼠标悬停显示「第1/3段」提示。

5.2 技巧二:依赖冲突实时预警(红色闪烁+气泡提示)

当用户拖拽任务导致「前置任务结束时间 > 后置任务开始时间」时,系统应即时反馈。源码在GanttScene::checkDependencies()中实现 O(n²) 检查,但为避免频繁触发,采用防抖策略:

// ganttscene.h private slots: void onDragEndDebounced(); // 防抖槽函数 private: QTimer *m_dependencyCheckTimer; bool m_isCheckingDependencies; // ganttscene.cpp GanttScene::GanttScene(QObject *parent) : QGraphicsScene(parent) { m_dependencyCheckTimer = new QTimer(this); m_dependencyCheckTimer->setSingleShot(true); m_dependencyCheckTimer->setInterval(300); // 300ms 防抖 connect(m_dependencyCheckTimer, &QTimer::timeout, this, &GanttScene::onDragEndDebounced); } void GanttScene::onDragEndDebounced() { if (m_isCheckingDependencies) return; m_isCheckingDependencies = true; QList<QPair<int, int>> conflicts; // {fromIndex, toIndex} for (int i = 0; i < m_tasks.size(); ++i) { for (int j = 0; j < m_dependencies.size(); ++j) { const auto &dep = m_dependencies[j]; if (dep.fromIndex == i && dep.toIndex != i) { const auto &fromTask = m_tasks[i]; const auto &toTask = m_tasks[dep.toIndex]; if (fromTask.endTime > toTask.startTime) { conflicts.append({i, dep.toIndex}); } } } } if (!conflicts.isEmpty()) { // 触发红色闪烁动画 foreach (auto conflict, conflicts) { m_tasks[conflict.first]->startConflictAnimation(); m_tasks[conflict.second]->startConflictAnimation(); } // 显示气泡提示 showConflictTooltip(conflicts); } m_isCheckingDependencies = false; }

效果:冲突任务条持续 2 秒红色脉冲(QPropertyAnimation控制 opacity),同时顶部弹出QToolTip:“警告:任务‘工单#1001’结束时间(1月7日16:00)晚于依赖任务‘工单#1002’开始时间(1月6日09:00),请调整”。

5.3 一个必须养成的习惯:每次修改时间轴范围后,强制重置任务条 Z 值

这是最容易被忽略的玄学坑。QGraphicsItem的 Z 值决定绘制顺序,时间轴(Z=0)、任务条(Z=1)、依赖连线(Z=2)、右键菜单(Z=3)需严格分层。但当你调用setTimeRange()时,TimeAxisItem会重新构建刻度项,其 Z 值可能被重置为默认 0,导致覆盖任务条。血泪经验:只要动了时间轴,立刻补一句:

m_ganttScene->setTimeRange(newStart, newEnd); // ← 紧接着强制重置所有 item 的 Z 值 foreach (QGraphicsItem *item, m_ganttScene->items()) { if (dynamic_cast<TimeAxisItem*>(item)) item->setZValue(0); else if (dynamic_cast<GanttTaskItem*>(item)) item->setZValue(1); else if (dynamic_cast<DependencyLineItem*>(item)) item->setZValue(2); }

从那以后我每次调setTimeRange()都强制走一遍这个 Z 值重置,再没遇到过「时间轴盖住任务」的诡异现场。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询