基于QGraphicsView的Qt甘特图:排产任务拖拽与时间轴换算

发布时间:2026/10/26 4:03:34
基于QGraphicsView的Qt甘特图:排产任务拖拽与时间轴换算

简介:围绕 Qt 甘特图实现的一份可运行工程源码,面向具备一定 Qt/C++ 基础、需要在桌面端项目管理或任务调度界面中嵌入时间轴图表的开发者。工程主体便于研究任务条与时间轴的映射、QGraphicsView/QGraphicsItem 图形组织、MVC 数据绑定、鼠标交互与自定义 paint 绘制,也可作为二次开发起点扩展依赖关系、里程碑和滚动布局。压缩包共 27 个文件,约 1.69MB,以 5 个 cpp 与 5 个 h 源码为核心,辅以 ui 界面文件、ini 配置、exe 可执行样例及 sln/vcxproj 等 Visual Studio 工程文件,兼顾阅读、编译与对照运行。已有 861 人学习,适合希望跳出标准控件、参考一套独立甘特图组件实现思路的开发者查阅与集成。

1. 从排产需求说起:为什么还要再写一版 qt 甘特图

排产模块上线前一周,产品提了个需求:任务条能拖着改工期,改完左边任务树和右边时间轴同时跟着动。我第一反应是找个现成的甘特图库,试了两个都不太合适——一个把排产规则写死在控件里,另一个要求按它的模板重排整套数据结构。最后还是用 QGraphicsView 自己搭了一套,也就是这份 qt 甘特图另一个版本:左侧 QTreeView 放任务树,右侧 QGraphicsView 画时间刻度和任务条,中间一层 GanttModel 做桥。

它适合手上有排产、项目进度、施工计划这类需求,又不想被第三方控件绑死的开发者。整套代码量不大,但时间轴换算、拖拽提交、依赖箭头这几块该有的坑都踩过一遍,拿来当二次开发的底子比从零开始省事。

2. 整体架构:三层拆分与目录结构怎么落地

先说我为什么没走纯 QWidget 自绘。QWidget 派生一个甘特区,重写 paintEvent 确实能画,但命中测试要自己算矩形,滚动要自己接 QScrollBar,局部刷新要自己算脏区,一旦加上“拖边缘改工期”“悬停高亮”“依赖箭头跟着任务条走”这些交互,代码会迅速膨胀成一坨 if-else。QGraphicsView 的好处是场景坐标、item 命中、视口裁剪都是现成的,你只需要关心“每个任务条在场景里的哪个位置、画成什么样”。

代价也很明确:item 数量上去以后,渲染和布局会变成性能瓶颈,这一点后面避坑章会专门讲。常见做法是任务条超过两千条时,只给可见区的行创建 item,滚出视野的直接 removeItem,用一个行号到 item 的缓存池复用。

2.1 四层结构:数据、模型、图元、视图

我把整个控件拆成四层,层与层之间只靠信号和常量交互,不允许图元直接去读数据文件。

层主要类职责
数据层GanttTask单条任务的纯数据结构,可拷贝、可序列化
模型层GanttModel建树、增删改、行号重排、数据变更信号
图元层GanttBarItem/GanttLinkItem/GanttHeaderItem在场景坐标里画任务条、箭头、刻度尺
视图层GanttView/GanttScene/GanttTreeView坐标换算、缩放、滚动同步、布局重排

目录结构按这四层切开,好处是新人接手时能一眼看出改哪块:

gantt/ ├── core/ │ ├── gantttask.h 任务数据单元,含依赖 id 列表 │ ├── ganttmodel.h/.cpp 任务树 + 增删改 + 变更信号 │ └── ganttdependency.h 依赖关系的轻量描述 ├── items/ │ ├── ganttbaritem.h/.cpp 任务条,含拖动与改期交互 │ ├── ganttmilestoneitem.h/.cpp 里程碑菱形 │ ├── ganttlinkitem.h/.cpp 依赖箭头,正交折线 │ └── ganttheaderitem.h/.cpp 三级时间刻度尺 ├── view/ │ ├── ganttview.h/.cpp QGraphicsView 派生,坐标换算中心 │ ├── ganttscene.h/.cpp 布局重排,行号分配 │ └── gantttreeview.h/.cpp 左侧任务树,与甘特区滚动同步 └── ganttwidget.h/.cpp 组合控件,对外唯一入口

对外只暴露GanttWidget一个类,构造完塞进GanttModel就能用。内部怎么换图元、怎么改布局,调用方不用关心。这一点在多人协作里很关键,我见过把GanttView直接暴露出去的项目,半年后没人敢动里面的坐标逻辑。

2.2 任务数据模型:字段怎么定才够用

GanttTask是整个项目的底座,字段定错了后面全是补丁。下面是我实际用的版本:

// core/gantttask.h class GanttTask { public: // 任务形态:普通条 / 里程碑菱形 / 汇总条(父任务滚动计算得到) enum Type { Task, Milestone, Summary }; QString id; // 全局唯一,依赖关系靠它关联,不要用行号 QString parentId; // 空串表示根级任务 QString name; QDateTime start; // 统一存本地时间,跨时区在入口处转换 QDateTime end; int progress = 0; // 0~100,绘制进度填充用 Type type = Task; QColor color; // 空色时按 type 走默认配色 QStringList dependsOn; // 前置任务 id 列表 int row = 0; // 布局时写入,决定 Y 坐标 bool collapsed = false; // 父任务折叠后子任务不分配行号 };

几个字段的选择理由值得说一下。id用字符串而不是自增整数,是因为数据源往往来自数据库或接口,自增 id 在本地新建任务时会产生冲突;字符串 id 可以由上层生成 UUID,导入导出也不会串位。dependsOn存 id 列表而不是指针,避免序列化和删除任务时的悬空指针。row不进数据源,它只是布局过程的中间产物,每次rebuildLayout都会重算,所以不需要持久化。

milestone的 start 和 end 通常相等,渲染时走菱形分支;Summary的起止时间由子任务聚合而来,用户不能直接拖,这个约束在mousePressEvent里判断。

2.3 模型层:信号设计决定了刷新效率

模型层最重要的不是增删改接口,而是信号的粒度。如果只发一个modelReset,那每改一条任务工期,整个甘特区就要全量重排,几百条任务就会明显掉帧。我的做法是拆成三个信号:

class GanttModel : public QObject { Q_OBJECT public: // 一次性灌入数据,内部按 parentId 建树并重算 row void setTasks(const QVector<GanttTask> &tasks); // 拖拽结束后唯一允许改时间的入口 void updateTaskRange(const QString &id, const QDateTime &newStart, const QDateTime &newEnd); QVector<GanttTask *> children(const QString &parentId) const; GanttTask *task(const QString &id) const; signals: void taskChanged(const QString &id); // 单条变更,局部重绘 void layoutInvalidated(); // 增删/折叠,需要全量重排 void dependencyChanged(const QString &id);// 依赖变化,重算箭头路径 };

updateTaskRange里先改数据,再判断新旧时间是否跨行、是否影响父任务的汇总时间,最后决定发taskChanged还是layoutInvalidated。这个判断看起来琐碎,但它让“拖动一条任务”的平均耗时从几十毫秒降到一两毫秒。参数说明:id必须存在,找不到时直接 return 并打一条警告,不要抛异常——拖动过程中中断会让界面卡在半更新状态。

3. 时间轴映射:从 QDateTime 到像素坐标的换算链路

甘特图所有玄学都集中在这一层。左边树上的行是离散的,右边时间轴是连续的,两者对齐的唯一依据就是“某个时间点对应场景里的哪个 x 坐标”。这个换算函数写错一个符号,表现就是任务条整体偏移半天、跨月时错位、缩放后文字压线。

3.1 比例系数 pxPerMs:缩放的本质

我不推荐用QGraphicsView::scale()来做甘特图的缩放。原因很实在:view 缩放会把字体、线宽、圆角一起放大,缩到 150% 时任务条上的任务名糊成一团,1px 的边框变成 1.5px 显示成灰度。正确做法是保持 view 的变换矩阵为单位阵,改“一天占多少像素”这个比例。

// view/ganttview.h class GanttView : public QGraphicsView { public: qreal pxPerDay() const { return m_pxPerDay; } void setPxPerDay(qreal v); qreal dateToX(const QDateTime &dt) const; QDateTime xToDate(qreal x, bool snapToDay = true) const; private: qreal m_pxPerDay = 36.0; // 默认一天 36 像素 QDateTime m_timelineStart; // 时间轴左端,通常是项目最早开始日 QDateTime m_timelineEnd; };

m_pxPerDay的合理区间我一般限制在 8 到 240 之间。低于 8 像素时日期标签根本排不下,高于 240 时一屏只能看三四天,滚动条会变得非常敏感。缩放操作通常挂在wheelEvent上,按住 Ctrl 滚动时按 1.15 的倍率增减,同时以鼠标当前位置为锚点,让锚点下的日期在缩放前后保持在同一个屏幕 x 上,否则用户会觉得图在乱跑。

3.2 双向换算:dateToX 与 xToDate

这两个函数是整个控件的核心,写成一行也不为过,但细节必须抠:

// view/ganttview.cpp qreal GanttView::dateToX(const QDateTime &dt) const { // 统一转 UTC 再算毫秒差,绕开夏令时导致的 23/25 小时制 const qint64 ms = m_timelineStart.toUTC().msecsTo(dt.toUTC()); return ms / 86400000.0 * m_pxPerDay; } QDateTime GanttView::xToDate(qreal x, bool snapToDay) const { const qint64 ms = qRound64(x / m_pxPerDay * 86400000.0); QDateTime dt = m_timelineStart.toUTC().addMSecs(ms).toLocalTime(); if (snapToDay) { // 甘特图基本按天粒度,过了中午就算下一天,视觉上更符合直觉 if (dt.time() >= QTime(12, 0)) dt = dt.addDays(1); dt.setTime(QTime(0, 0)); } return dt; }

逻辑说明:dateToX返回的是场景坐标,不是屏幕坐标,因为场景坐标不随滚动变化,任务条的setPos可以直接用。xToDate的snapToDay参数在拖动预览时传 false(要跟手),在松手提交时传 true(要落整日)。参数86400000.0是一天的毫秒数,写成浮点是为了避免整数除法把小数部分截断,这个坑我在早期版本踩过,表现是长工期任务条的右边缘总是差一两个像素。

中间那个“过了中午算下一天”的规则不是标准,是我按使用习惯定的。排产场景里没人会把任务开始时间设成 14:30,用户拖动时心里想的是“往后挪一天”,如果按四舍五入到最近整点,拖动半格不动会让人以为卡了。

3.3 刻度尺:三级粒度的绘制阈值

时间刻度尺是GanttHeaderItem一个横跨整个场景宽度的图元,固定贴在视口顶部(通过setFlag(ItemIgnoresTransformations)配合手动定位,或者干脆放在独立的 header view 里)。我选的是后者:把刻度尺放在一个和甘特区共享水平滚动条的独立 QWidget 上,滚动同步比图元方案稳定得多。

绘制时分三层:上层年、中层月、下层日。日的标签在pxPerDay < 18时自动隐藏,改成每 7 天画一条周分隔线,否则“1 2 3 4 5”会挤成一团黑块。周末底色用一个浅灰填充矩形,判断方式是date.dayOfWeek() >= 6。这些阈值不是拍的,是实测出来的:36px 一天时,“10-24”这样的五位标签刚好放得下,再窄就得截断。

// items/ganttheaderitem.cpp void GanttHeaderItem::paint(QPainter *p, const QStyleOptionGraphicsItem *, QWidget *) { const QDate first = m_view->xToDate(0).date(); const QDate last = m_view->xToDate(m_view->sceneRect().right()).date(); // 底层:周末底色,先画,避免盖住文字 for (QDate d = first; d <= last; d = d.addDays(1)) { if (d.dayOfWeek() >= 6) { const qreal x = m_view->dateToX(QDateTime(d, QTime(0, 0))); p->fillRect(QRectF(x, 0, m_view->pxPerDay(), height()), QColor(0, 0, 0, 12)); } } // 中层:月分隔线与月名,只在每月 1 号画 // 上层:日标签,pxPerDay >= 18 才画 }

参数说明:m_view是弱引用,刻度尺不拥有视图;height()是刻度尺自身高度,通常设 48px,上层 20px、中层 16px、下层 12px。QRectF(x, 0, pxPerDay, height)里宽度故意取一整天,这样周末底色是连续的色带而不是一格一格的。

4. 任务条绘制与拖拽交互:一次拖动的完整事件链路

这一章讲从鼠标按下到数据落库的完整链路。很多甘特图 demo 能画不能拖,或者能拖但撤销栈里塞了上百条中间状态,问题都出在事件处理的分段策略上。

4.1 GanttBarItem 的 boundingRect 与 paint

任务条的几何尺寸由时间换算直接决定,boundingRect必须和paint里的绘制范围严格一致,否则会出现残影和点击热区错位。

// items/ganttbaritem.cpp QRectF GanttBarItem::boundingRect() const { const qreal x1 = m_view->dateToX(m_displayStart); const qreal x2 = m_view->dateToX(m_displayEnd); // 最小宽度兜底:工期为零的里程碑也要能点中 const qreal w = qMax(x2 - x1, 6.0); return QRectF(x1, 0, w, kRowHeight - 4); } void GanttBarItem::paint(QPainter *p, const QStyleOptionGraphicsItem *opt, QWidget *) { const QRectF r = boundingRect().adjusted(0, 2, 0, -2); // 1. 底色:圆角矩形 p->setPen(Qt::NoPen); p->setBrush(m_task->color.isValid() ? m_task->color : defaultColor(m_task->type)); p->drawRoundedRect(r, 3, 3); // 2. 进度:内部再叠一层深色,宽度按 progress 比例 if (m_task->progress > 0) { QRectF pr = r; pr.setWidth(r.width() * m_task->progress / 100.0); p->setBrush(QColor(0, 0, 0, 40)); p->drawRoundedRect(pr, 3, 3); } // 3. 文字:只在条够宽时画,否则画到条的右侧外部 if (r.width() > 48) { p->setPen(contrastColor(m_task->color)); p->drawText(r.adjusted(6, 0, -4, 0), Qt::AlignVCenter | Qt::AlignLeft, m_task->name); } else { p->setPen(QColor(60, 60, 60)); p->drawText(QPointF(r.right() + 6, r.center().y() + 5), m_task->name); } }

逻辑说明:m_displayStart/m_displayEnd是“当前显示用的时间”,拖动预览时它和m_task->start不一致,松手后才同步。这个分离设计是拖动流畅的关键。参数kRowHeight是行高常量,一般 28 到 32,QRectF上下各收 2px 是为了行与行之间留缝。文字颜色用contrastColor按背景亮度取黑或白,避免深色任务条上写黑字看不见——这种细节不做,用户第一眼就会觉得控件“不专业”。

4.2 拖动三段式:press 记状态、move 只改显示、release 才提交

拖拽逻辑最忌讳在mouseMoveEvent里直接改模型。那样撤销栈会被塞满中间态,而且每次移动都触发一次布局重排,性能直接崩。

// items/ganttbaritem.cpp void GanttBarItem::mousePressEvent(QGraphicsSceneMouseEvent *e) { if (m_task->type == GanttTask::Summary) { e->ignore(); return; } // 汇总条不给拖 m_pressX = e->pos().x(); m_origStart = m_task->start; m_origEnd = m_task->end; m_displayStart = m_origStart; m_displayEnd = m_origEnd; const qreal w = boundingRect().width(); if (e->pos().x() < kEdgeGrip) m_mode = ResizeLeft; // 左边 8px 改开始 else if (e->pos().x() > w - kEdgeGrip) m_mode = ResizeRight; // 右边 8px 改结束 else m_mode = Move; // 中间整体平移 e->accept(); } void GanttBarItem::mouseMoveEvent(QGraphicsSceneMouseEvent *e) { const qreal dxPx = e->pos().x() - m_pressX; const int days = qRound(dxPx / m_view->pxPerDay()); // 吸附到整天 switch (m_mode) { case Move: m_displayStart = m_origStart.addDays(days); m_displayEnd = m_origEnd.addDays(days); break; case ResizeLeft: { QDateTime s = m_origStart.addDays(days); if (s >= m_displayEnd) s = m_displayEnd.addDays(-1); // 不允许反向 m_displayStart = s; break; } case ResizeRight: { QDateTime en = m_origEnd.addDays(days); if (en <= m_displayStart) en = m_displayStart.addDays(1); m_displayEnd = en; break; }} prepareGeometryChange(); // 尺寸要变,必须先通知场景 update(); e->accept(); } void GanttBarItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *) { if (m_displayStart != m_origStart || m_displayEnd != m_origEnd) { // 只在这里进模型,一次拖动 = 一条撤销命令 m_model->updateTaskRange(m_task->id, m_displayStart, m_displayEnd); } m_mode = None; }

逻辑说明:m_mode用三个枚举区分平移和两端拉伸,判定阈值kEdgeGrip取 8px,太窄用户抓不住,太宽平移区域就没了。mouseMoveEvent里qRound而不是qFloor,是为了让拖动跟手,否则用户要拖过半格才动。最关键的prepareGeometryChange()必须在改m_displayStart/End之后、update()之前调用,漏了它场景缓存的包围盒不更新,快速拖动会留下一条条的残影。松手时只在数值真的变了才提交,避免单击也往撤销栈塞一条空命令。

4.3 依赖箭头:正交折线比贝塞尔好维护

依赖箭头的画法有两种:贝塞尔曲线和正交折线。曲线好看,但两条箭头交叉时很难看清走向,而且控制点难调。我用的是正交折线:从前置任务右中点向右伸出一小段,竖直走到目标行,再水平连到后继任务左中点。

// items/ganttlinkitem.cpp QPainterPath GanttLinkItem::buildPath() const { const QPointF a = m_from; // 前置任务右中点(场景坐标) const QPointF b = m_to; // 后继任务左中点 const qreal stub = 10.0; // 出发后先走一小段,避免贴着任务条倒角 QPainterPath p(a); if (b.x() >= a.x() + 2 * stub) { // 常规情况:目标在右侧,标准 Z 形 const qreal midX = a.x() + stub; p.lineTo(midX, a.y()); p.lineTo(midX, b.y()); p.lineTo(b.x(), b.y()); } else { // 目标在左侧(返工/倒挂依赖),走下方的避让折线 const qreal downY = qMax(a.y(), b.y()) + kRowHeight * 0.5; p.lineTo(a.x() + stub, a.y()); p.lineTo(a.x() + stub, downY); p.lineTo(b.x() - stub, downY); p.lineTo(b.x() - stub, b.y()); p.lineTo(b.x(), b.y()); } return p; }

逻辑说明:返回的路径直接交给QGraphicsPathItem::setPath。stub是出发段长度,取 10px,太小会和任务条圆角重叠。分支判断b.x() >= a.x() + 2 * stub处理的是“后继任务排在前置任务左边”的情况,现实中确实会出现(数据录入错误或计划倒挂),不做兜底的话箭头会直接穿过任务条。箭头端部的三角形在paint里用p->translate到路径终点再旋转绘制,角度由最后一段线段的atan2得出。

依赖箭头的刷新时机也讲究:只在dependencyChanged信号里重建路径,不要在paint里现算,否则每次重绘都要遍历依赖图。任务条拖动过程中箭头不跟手,松手后一起跳,这个取舍我认了——跟手版的代价是每帧都要重算所有相关箭头的路径,几百条依赖时鼠标直接卡住。

5. 避坑记录:五个把甘特图拖垮的细节

这一章是我在几个项目里踩出来的,每一条都对应一个能复现的现象,不是理论推演。按渲染、滚动、数据提交三类分开写,方便对照排查。

5.1 渲染与缩放:字糊、线粗、残影

踩坑一:缩放后文字模糊、边框变粗。现象是用户把甘特图放大到 150% 看细节,任务名开始发虚,1px 边框变成两三个像素的灰边,圆角也毛了。原因很直接:用了QGraphicsView::scale()做缩放,view 的变换矩阵作用到所有图元上,字体和线宽被一起放大,Qt 的抗锯齿在非整数缩放下又没法完美渲染。解决方式是把缩放换成正比例系数m_pxPerDay,view 始终保持单位矩阵,改比例后重新setPos并prepareGeometryChange。这样字体始终按原始 DPI 渲染,缩放多少都清晰。

踩坑二:快速拖动后留下一条条残影。现象是按住任务条猛拖,松手后画面上还残留着几个半透明的旧位置,切换窗口才消失。原因是改了图元的几何尺寸却没通知场景:boundingRect依赖m_displayStart/End,这两个值变了但prepareGeometryChange()没调,场景索引里还记着旧包围盒,局部刷新只刷新了旧区域。解决方式是在任何影响boundingRect的成员变更前调用prepareGeometryChange(),位置平移用setPos不用改包围盒。这条我写在代码规范里,新人入职必看。

5.2 滚动同步与交互:树和甘特区打架

踩坑三:左侧任务树和右侧甘特区滚动不同步,或者一滚就抖。现象是拖动左边树的滚动条,右边甘特区慢半拍,偶尔还会自己弹回去,滚动条像在打架。原因是两个滚动条互相connect(valueChanged),A 变化触发 B,B 变化又触发 A,形成回环,浮点误差让值来回震荡。解决方式是用QSignalBlocker包住同步写入的那一侧:

// view/gantttreeview.cpp void GanttTreeView::syncScrollTo(int value) { // 阻断信号,避免"我改你、你改我"的回环抖动 const QSignalBlocker blocker(verticalScrollBar()); verticalScrollBar()->setValue(value); }

参数说明:QSignalBlocker是 RAII,离开作用域自动恢复信号,比手动blockSignals(true/false)安全,异常路径也不会漏。另外左侧树的滚动范围要按行数 × 行高算,不要用默认的scrollBar()->maximum(),因为树的行高可能被样式表改过。

踩坑四:任务条悬停高亮在滚动后错位。现象是鼠标悬停时高亮的任务条和实际鼠标位置差了一行。原因是用了viewport()->mapToGlobal再去和场景坐标比较,滚动条一动全局坐标和场景坐标的偏移就变了,但高亮图元没跟着更新。解决方式是全程在场景坐标里算,用mapToScene(e->pos())拿鼠标位置,再用scene()->items(scenePos)做命中测试,彻底不碰全局坐标。这条经验适用于所有 QGraphicsView 项目。

5.3 时间与数据提交:差一天和撤销栈爆炸

踩坑五:跨月或跨夏令时的任务工期总是差一天。现象是设了 3 月 1 日到 3 月 31 日,渲染出来少一格;换了时区或者碰上夏令时切换,差值还会变。原因是直接用了QDateTime::daysTo,两个本地时间相减时,夏令时那天只有 23 小时,daysTo返回的整数天就和直觉不一致。解决方式统一在换算层转 UTC:toUTC().msecsTo()拿到精确毫秒,再除以 86400000 得到浮点天数,需要整数时qRound而不是qFloor。数据入口处也做一次规整,把所有时间戳的时分秒归零,避免上游传进来带时分的脏数据。

踩坑六(附带):撤销栈里塞了几百条中间状态。现象是拖一次任务条,按 Ctrl+Z 要按几十下才能回到原位。原因是在mouseMoveEvent里直接调用模型接口。解决方式就是第 4 章讲的三段式:press 缓存原始值,move 只改显示变量,release 比对后提交一次。配套地,模型的updateTaskRange里用QUndoCommand包一层,命令的redo和undo都调用同一个内部设置函数,保证来回切换状态一致。

6. 二次开发改造:加列、换渲染、接数据源

拿到这份代码后最常见的三个改造需求是:左侧树加自定义列(负责人、工时、状态)、任务条按类型换渲染样式、从 JSON 或数据库接数据源。这三件事都不需要动核心换算逻辑,改的都是外围。

加列最直接的方式是给GanttTreeView换一个自定义的QAbstractItemModel,columnCount返回你需要的列数,data()里按role == Qt::DisplayRole分发字段。注意行高要和甘特区的kRowHeight保持一致,否则左右两边对不齐,这个对齐是用户最容易一眼看出来的问题。

换渲染有两种做法:一是在GanttBarItem::paint里按m_task->type分支,加个Bracket类型画成方括号表示汇总;二是派生新的图元类,在GanttScene::rebuildLayout里按类型工厂创建。前者改动小,后者扩展性好,任务类型超过五种我就建议走工厂。

接数据源时,把外部数据先转成QVector<GanttTask>,再一次性调setTasks,不要在setTasks内部逐条发信号。我一般会加一个GanttJsonLoader,负责日期字符串解析和dependsOn的合法性校验——引用了不存在的前置任务 id 时,直接跳过并记日志,不要让界面崩在箭头绘制上。

验证改造是否成功,我有一套固定动作:造一份 500 条任务、20 条依赖的测试数据,缩放从最小拉到最大走一遍看文字有没有压线,随机拖十条任务确认撤销栈深度是 10,再滚动到底部检查有没有残影。这套动作五分钟能跑完,能挡掉八成低级问题。

从那以后我每次改甘特图的绘制或交互代码,都会强制走一遍这套验证再提交,血泪经验是有一回只改了个颜色,顺手动了boundingRect的收边值,结果线上滚动时任务条全部错位半行。希望帮到你。

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