Qt实现甘特图源码:从零手写高性能自定义控件

📅 发布时间:2026/9/10 1:13:37
Qt实现甘特图源码:从零手写高性能自定义控件
简介一份基于Qt的甘特图实现源码面向熟悉C并希望进阶自定义控件开发的程序员适用于需要将任务时间表、依赖关系与进度管理可视化的桌面项目。压缩包共12个文件包含5个头文件、4个C源文件、Qt工程文件、编译配置及一个说明文本整体仅14KB目录紧凑便于通读。源码以QGraphicsView/QGraphicsScene搭建可交互画布利用QPainter完成矩形任务条的绘制结合QDateTime处理任务起止日期与相对时间同时内置任务添加、修改对话框并实现基于时间约束的求解模块可输出排程结果到文本。对于想学习如何将业务数据映射为图形元素、掌握Qt事件处理与信号槽联动或参考小型项目模块化划分的开发者这份代码提供了思路清晰的范例。资源已有3096人学习适合作为自定义QT控件与甘特图组件的参考实现。 说实话第一次被要求“用Qt写一个甘特图控件”的时候我脑子里第一反应是这玩意儿市面上不是有一堆商业控件吗直接买不就行了但在实际做项目时你会发现商业控件要么太重、要么授权费一套下来够买台开发机了而且遇到定制需求比如在甘特图里再叠加一层业务状态色块、按星期自动折叠列、鼠标框选批量修改计划时间反而要花更多时间去逆向别人的封装。所以最后我的方案是基于Qt Widgets自己画一个甘特图控件也就是标题里说的“qt实现甘特图源码”。这篇文章我会把整个实现思路、核心代码片段的计算逻辑、以及我在实际开发中踩过的坑完整列出来。能做什么简单说就是支持任务条展示、时间轴缩放、拖拽调整任务起止时间、进度状态着色、周末列高亮这些基础能力完全够覆盖项目管理、排产计划、影视制作周期管理这类场景。适合想自己掌控控件细节的Qt开发者参考也适合刚接触Qt绘图、想搞懂“自定义控件到底怎么画”的入门选手。1. 整体设计与思路拆解1.1 为什么不用QGraphicsView而是直接继承QWidget很多人看到“甘特图”三个字第一反应就是用QGraphicsView框架啊里面有个现成的scene-view模型鼠标事件、碰撞检测、图元移动全都封装好了这不是省事吗我确实先试过QGraphicsView。实际用下来发现一个问题甘特图的业务数据往往是几千条任务起步每条任务又可能对应多个子阶段如果每条任务都搞一个QGraphicsRectItem、一堆QGraphicsTextItem挂在scene上图元数量轻松破万。QGraphicsScene在item数量上去之后平移、缩放时的性能衰减非常明显而且内存占用也不小。我最终选的路子是直接继承QWidget重写paintEvent去做全部绘制。核心思想是把甘特图当成一张“无限宽”的画布我只需要把当前视口能看到的那一小块区域画出来就行。这就是经典的“视口裁剪”策略数据量再大单帧绘制的元素数量也只跟屏幕像素有关跟任务总数无关。用生活化的比喻就是你不需要把整卷地图一次性画在墙上只需要准备一支笔和一把尺子走到哪画到哪。用户拖滚动条我就重新计算“当前窗口对应的时间范围”和“当前窗口能装下哪些任务”然后把这一小块内容画出来。这套方案的好处有三个绘制性能可控单帧复杂度 可见行数 × 每行可见元素数和总数据量解耦。内存占用低不需要为每条任务维护一个GUI对象只需要维护一个业务数据列表。定制自由度高想画什么直接QPainter画想画进度块、里程碑菱形、依赖箭头、周末底色都是几十行代码的事完全不需要受框架约束。1.2 数据结构设计Model和View解耦甘特图的数据结构我建议不要和控件代码耦合在一起。哪怕你暂时只是在项目里用一次也请拆开。我这边定义的TaskItem结构大概是这样的struct TaskItem { int id; int parentId; // 支持简单的父子分组 QString name; QDateTime startTime; QDateTime endTime; double progress; // 0.0 ~ 1.0 QColor baseColor; // 任务条颜色 int sortOrder; // 行排序权重 };这里要解释下为什么需要parentId和sortOrder。真实的甘特图场景任务往往有层级关系项目 - 阶段 - 子任务有层级就要有分组行的概念。sortOrder是让控件可以按业务顺序排行而不是按建表先后排。你可以在外部维护QVector 也可以直接放QAbstractTableModel里这个问题不大。接下来的关键点在于控件只负责绘制传入的任务列表不负责业务运算。这就像电视只负责显示画面不负责决定播什么剧。你要改任务时间、增删任务都去改外部数据源然后调用控件的update()刷新界面即可。1.3 坐标系统所有交互的基石甘特图的绘制本质就是两个坐标映射时间 - X像素用于画任务条、时间线、网格线时间 - 行像素用于定位任务行时间转X像素的核心公式长这样int TaskView::timeToX(const QDateTime time) const { qint64 msecs m_startTime.msecsTo(time); qreal msecsPerPixel m_dayWidth * 24 * 3600 * 1000.0; return int(msecs / msecsPerPixel); }这里m_startTime是视口最左侧对应的时间起点m_dayWidth是“一天有多少像素宽”。当用户拖动滚动条时本质上就是在改m_startTime。当用户放大/缩小时本质上就是在改m_dayWidth。反方向的xToTime函数也很重要鼠标点击、框选、拖拽任务条都要靠它把像素坐标翻译回时间QDateTime TaskView::xToTime(int x) const { qreal msecsPerPixel m_dayWidth * 24 * 3600 * 1000.0; qint64 offset qint64(x * msecsPerPixel); return m_startTime.addMSecs(offset); }这两个函数是整个控件的心脏后续所有的绘制、交互、滚动条同步都建立在这两个转换之上。强烈建议在写其他代码之前先把这两个函数写好并做一轮单元测试测试用例包括跨天、跨月、跨年、时间带毫秒等边界情况。2. 时间轴与任务条渲染原理2.1 周历头部的绘制策略甘特图最常见的时间粒度是“天”所以头部通常要显示日期和星期。我做的是双行表头第一行显示周次或月份第二行显示具体的每一天。这里有个细节问题跨年的时候某一天究竟属于哪一周、哪个月比如12月31日是周三那它属于第几周如果直接在代码里用date.weekNumber()不同地区算法结果可能不一样。我这边统一用ISO 8601标准每周从周一开始每年第一周至少包含4天。这个规则可以直接用Qt现成的APIint weekNumber date.weekNumber(year); // 输出年份需要另存跨年时会变 int dayOfWeek date.dayOfWeek(); // Qt里周一返回1头部绘制的伪逻辑获取视口可见的时间范围 startTime 和 endTime。从startTime.date()开始逐天遍历到endTime.date()。对每一天如果是当前周的起始日周一在这一格左侧画一条加粗的周分隔线并绘制“第X周 / YYYY”文字。判断日期是否属于周末周六、周日如果是给这一列的整条纵带绘制淡灰色底色。这里有一个比较关键的代码因为逐天遍历一年的天数也就365次循环完全不需要担心性能。真正值得注意的是缩放级别很低时比如m_dayWidth小到1像素甚至更小每天的格子宽度不足8像素“周一”“周末”这类文本都不用画了只画网格线。2.2 任务条绘制的核心细节画任务条不难但“画得好”有几个小细节我后来才体会到。第一任务条主体我画的是圆角矩形。圆角半径不要太大3-4像素足够了太大会导致任务条宽度很窄时看起来像个胶囊很怪。第二任务条内部要画进度。进度不是另外贴一个进度条而是在同一个矩形内部按progress比例绘制两种颜色的区块。比如baseColor是蓝色进度部分用更深的蓝色未完成部分用浅灰色。绘制代码大致是QRectF fillRect itemRect.adjusted(2, 2, -2, -2); qreal fillWidth fillRect.width() * task.progress; // 先画底层的未完成区 painter-setBrush(QColor(220, 220, 220)); painter-drawRoundedRect(fillRect, 3, 3); // 再在左侧绘制完成区 if (fillWidth 0) { QRectF doneRect(fillRect.left(), fillRect.top(), fillWidth, fillRect.height()); painter-setBrush(task.baseColor.darker(115)); painter-drawRoundedRect(doneRect, 3, 3); }这里有个很实用的经验当fillWidth小于4像素时drawRoundedRect的圆角会让填充色块看起来像一个小圆点反而不直观。我后来改成了fillWidth小于4像素时直接用drawRect画直角矩形视觉效果立刻清晰了。第三任务名称文字的绘制。任务条宽度足够超过80像素时在任务条内部居中绘制名称宽度不足时把文字绘制在任务条右侧防止信息丢失。文字颜色需要和背景色做对比度校验深色任务条上用白字浅色上用深色字。这个虽然是个小点但实际显示效果差很多。第四里程碑型任务startTime endTime要画成菱形而不是矩形。判断就一行绘制就是旋转坐标系统后画一个正方形即可。2.3 网格与底色视觉层次不能省甘特图的行列背景不是白纸一张需要有层次感。我这边画了三层最底层全局白色或浅灰色。中间层周末列使用淡蓝色让用户一眼扫过去就知道哪些天是非工作日。最上层任务行的隔行变色斑马纹方便眼睛定位。中间层和上层都是在绘制任务条之前先统一绘制一整块再在之上画任务条。这样能避免每条任务绘制时反复计算背景色性能和代码结构都更好。斑马纹怎么画记一个行号计数器偶数行画一个半透明背景矩形奇数行不画。注意这个背景矩形要覆盖整行包括左侧的空白区域这样才有一整行的视觉分隔效果。3. 交互与性能优化3.1 滚动条联动改startTime而不是移动内容很多人第一次实现甘特图滚动的时候会走一个弯路给整个控件套一个QScrollArea然后把绘制内容放到一个超大的QPixmap上通过滚动区域滚动这个大图。缺点很明显——内存占用大、重绘性能差、缩放后模糊。正确做法是让自定义控件自己管理QScrollBar滚动条变化时只更新当前视口对应的m_startTime然后调用update()触发局部重绘。这个滚动过程没有移动任何图元、没有重新布局只是算了一个新时间然后重画可见区域所以性能非常好。滚动条的值范围和步长计算是重点void TaskView::updateScrollBar() { qint64 totalMSecs m_totalStartTime.msecsTo(m_totalEndTime); qint64 visibleMSecs m_dayWidth * viewportWidth * dayMsecs; m_scrollBar-setRange(0, int(totalMSecs - visibleMSecsStep)); m_scrollBar-setPageStep(visibleMSecsStep); }这里的m_totalStartTime和m_totalEndTime是整个项目的总时间范围不是视口时间范围。滚动条拖到最左边时m_startTime等于m_totalStartTime拖到最右边时视口的右边界刚好等于m_totalEndTime。我在实际项目里遇到过一个频率极高的坑visibleMSecs的计算结果如果大于整型上限setRange会被截断导致滚动条跳来跳去。解决方案是把滚动条的范围单位定义为“天”或“半天”而不是“毫秒”精度完全够用绕开了溢出问题。3.2 缩放以鼠标位置为锚点用户把鼠标放在某个任务上滚动滚轮他期望的是“我指向的这个时间点缩放后还在我的鼠标下方”。如果把整个画布中心作为锚点用户滚着滚着任务就跑偏了体验非常糟。锚点缩放的核心公式让鼠标下对应的那个时间点在缩放前后保持同一个X坐标。代码逻辑void TaskView::zoomAtMouse(int mouseX, double scaleFactor) { QDateTime anchorTime xToTime(mouseX); m_dayWidth qBound(minDayWidth, m_dayWidth * scaleFactor, maxDayWidth); // 重新计算m_startTime使得anchorTime仍然位于mouseX处 qint64 newOffset timeToX(anchorTime); m_startTime anchorTime.addMSecs(-qint64(newOffset * msecsPerPixel)); updateScrollBar(); update(); }这个过程我建议压测一下。我当时的测试数据是1万个任务缩放过程中帧率能不能稳定在30fps以上。实测结论是只要paintEvent里不创建大量临时对象、不调用昂贵的字体测量完全能做到。3.3 双缓冲与增量绘制Qt的QWidget默认其实已经开启了双缓冲你在paintEvent里画什么最终都会一次性刷到屏幕上。但这不代表你的paintEvent就可以随便写。我总结了几条重要的性能红线不要在paintEvent里new QPen、QBrush、QFont。把这些对象缓存成成员变量绘制时直接用。这个优化立竿见影能省掉大量构造函数开销。绘制前根据visibleRegion裁剪。QPainter::setClipRect(viewport rect)是基础但还可以进一步任务条不在可见行区间就直接跳过。文本绘制是性能杀手。QPainter::drawText每一次调用都要走布局引擎几百条任务加背景、边框、文本全画一遍耗时能到几十毫秒。优化方法缩放较小的级别下省略任务名称不画平移过程中可以延迟绘制文本等操作停止后再重绘。慎用Antialiasing。绘制网格线和背景时关闭抗锯齿只在绘制任务条和文字时开启。这样边缘质量影响不大但性能提升非常明显。增量绘制也有个简单的落地方法拖拽任务条时如果只是改变了一个任务的位置不需要整块重绘可以用update(prevRect.united(newRect))只刷新变化区域。这一招在拖动多个任务、框选多个任务时能明显减少闪烁。4. 常见问题与排查技巧实录4.1 任务条文字重叠和截断这是我自己用的时候第一个发现的问题。当两条任务在同一行时比如父子任务用了同一行或者一个任务跨天较长、相邻任务也不是顺序排列文字会重叠。根本原因是绘制文字的位置完全由任务条rect决定但任务条之间逻辑上允许重叠。解决方案绘制完所有任务条之后再单独遍历一次任务列表计算当前行上每个任务条占用的矩形区域如果两个区域有交集就只绘制索引较小的那个任务的文字。这个方案能解决90%的重叠问题剩下的10%是视觉上任务条本身就叠得不可读这种属于业务数据的冲突应该由外部业务处理控件不用管。截断问题就简单些除了上面说的宽度小于阈值时不画文字外还可以用QFontMetrics::elidedText把过长的文字截成省略号。4.2 跨年周显示错误这是我踩得最疼的一个坑。开发完周历头部的那个周末我拿2023年1月1日周日做了下测试结果头部第二行显示“2023年 第52周”但用户希望看到的是“2023年 第1周”。原因就是ISO 8601规则下2023年1月1日虽然属于2023年但它所在的周其实是2022年的最后一周。解决办法用QDate::weekNumber()时注意第二个输出参数——它表示“当前日期所在周属于哪一年”。绝大多数情况下应该按这个输出的年份显示而不是按日期的年份显示int year 0; int week date.weekNumber(year); headerText QString(%1 第%2周).arg(year).arg(week);测试用例记得覆盖1月1日、12月31日、闰年、跨年周这些边界。4.3 Windows上QPainter绘制偶现“残影”在Windows上跑的时候我遇到过一种诡异情况拖动滚动条后甘特图的上半部分偶尔留着旧内容的残影。排查了很久最后发现是我忘了在paintEvent最开头调用QPainter::fillRect(rect(), Qt::white)——当视口只发生局部更新时旧像素没有被完全覆盖就会残留。这个问题在macOS和Linux上不明显因为Wayland和macOS的合成器会主动处理全窗口更新。但在Windows上就暴露了。解决方案很简单paintEvent里第一步就先把整个控件rect填充底色再画其他内容。4.4 外部数据更新的刷新时机甘特图这种控件外部数据源变化非常频繁比如定时轮询数据库、网络请求返回新排期。你不可能每次数据变化都全量刷一遍界面。我的做法是对外暴露一个简单的接口setTasks(QVectorTaskItem tasks)内部标记一下数据版本号再调用update()。paintEvent里判断version是否和上次相同。扩展一点如果某个任务的时间变了但数量没变可以用update(QRect)只刷新那一个任务在视口范围内对应的区域效率更高。但这一招的前提是你已经把“时间 - 像素”的坐标系统设计得足够清晰否则很容易算出错误的刷新区域导致画面花掉。5. 源码工程结构与实操建议这里放一下我最后交付的源码文件结构给大家做一个目录层面的参考GanttDemo/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── MainWindow.h/.cpp │ ├── TaskModel.h # 任务数据源内部存TaskItem列表 │ ├── TaskView.h/.cpp # 甘特图主控件继承QWidget │ ├── TaskRulerWidget.h/.cpp # 顶部时间轴控件独立成组件 │ ├── TaskTreeWidget.h/.cpp # 左侧任务列表表格QTreeView 自定义model │ └── TaskDelegate.h/.cpp # 任务行的Excel风格编辑器委托为什么把左侧任务列表和右侧甘特图拆成两个控件因为左侧的表格用QTreeView实现可以白嫖选中、排序、多级展开这些成熟能力右侧的甘特图专注画时间相关的图形。两个控件之间通过“同一份外部数据”和“行高同步”来保持一致。这件事听起来简单但做起来有个细节QTreeView的行高和甘特图控件的行高要统一并且当左侧展开/折叠时右侧的任务行位置要跟着变化。最简单的同步方案在外部数据加载完之后遍历任务列表生成一个QVector 把每个任务的行位置和矩形区域缓存下来两个控件都从这份缓存里读行坐标就不存在不同步的问题。展开/折叠时刷新这份缓存两个控件同时update。CMakeLists.txt里如果直接用Qt 5.15依赖就三行find_package(Qt5 COMPONENTS Widgets REQUIRED) target_link_libraries(ganttdemo PRIVATE Qt5::Widgets)如果是Qt 6.x把Qt5改成Qt6即可QPainter、QDateTime这套API层面几乎没有变化。6. 一些个人经验和踩坑心得最后分享一点我在这类自定义控件开发里沉淀下来的通用体会。第一自定义控件的调试要善用“快照打印”。你没法在paintEvent里断点慢慢看画面但可以在关键位置把本帧的startTime、m_dayWidth、可见任务数qDebug打印出来跑几个操作拖动、缩放之后基本就能定位是坐标转换问题还是绘制顺序问题。我碰到过很多次“画面错位”的bug最后都是靠打印startTime和第一行任务的x坐标找到的。第二QDateTime的方便是双刃剑。msecsTo、addMSecs这类API写起来很顺手但注意它背后是64位毫秒时间戳跨年、跨月运算没问题但如果有大量任务在paintEvent里反复调用msecsTo性能会比较感人。我建议在数据加载阶段就把每个TaskItem的startTime/endTime预先换算成“相对项目开始时间的天数double”绘制时全部用double做四则运算又快又不会有时区问题。第三如果这个甘特图控件以后要复用一定要把缩放上限、行高、头部高度这些参数全部提取成可配置项。我当时是写死在代码里的后来第二个项目要改成“按小时”粒度显示排产数据所有尺寸都得重调。好在那时候代码结构还算清晰花了一天时间全部改完了。如果一开始就提取一个setScaleRange(minDayWidth, maxDayWidth)和setRowHeight(int),后面会省很多事。甘特图这个控件说难不难说简单里面的细节是真的多。但我觉得这正是Qt自定义控件开发的乐趣所在。希望这篇源码级的拆解能帮你绕过我踩过的那些坑如果你也在项目中实现了类似的控件欢迎交流你在交互细节上的取舍。本文还有配套的精品资源点击获取