Qt甘特图性能优化:从QTableView到QGraphicsView的实践

发布时间:2026/10/15 7:26:31
Qt甘特图性能优化:从QTableView到QGraphicsView的实践
简介这是一份基于 Qt 框架的甘特图组件源码包面向需要在桌面应用中展示项目任务时间线、进度或排程信息的 Qt 开发者也适合想深入学习 QGraphicsView 自定义绘图与模型-视图交互的进阶用户。完整工程包含 27 个文件压缩包约 1.69MB其中 5 个 C 源文件与 5 个头文件为核心代码2 个界面文件与 2 个资源文件用于 UI 构建另有工程配置、可执行程序及说明文档目录划分清晰可直接编译运行或集成到现有项目。已有 860 人浏览学习。组件围绕 QGraphicsItem 条形图绘制展开从 MVC 数据绑定、时间戳坐标换算到鼠标拖动与事件响应、自定义绘制函数、布局滚动和性能优化均有涉及同时包含动画与过渡效果、任务依赖等扩展配置方便按需调整样式与功能。通过阅读源码和工程结构既能理解甘特图在 Qt 中的完整落地方式也能复用该组件快速搭建项目管理界面或在其基础上进行二次开发。1. 从“表格画甘特”到“场景画甘特”一个值得下下来拆一拆的版本先亮结论如果你手里的 Qt 甘特图任务量一上两千条就开始卡矩阵那大概率是走错了路——很多人习惯用 QTableView 加自定义 delegate 去做甘特图行高、列宽、滚动条都是白捡的但真正拖起来、缩放起来这套方案的上限很低。我拿到这个“qt 甘特图另一个版本”源码的时候第一反应也是一样不就是用 QGraphicsView 重写么直到我自己把任务行数加到一万条左右拖时间轴丝滑到不像是同一个项目后才意识到这个版本是在底层坐标体系和渲染缓存上做了正经功夫的。它不是商业级全功能甘特控件而是一个干净的基础框架适合已经会用 Qt 但需要在甘特图上做深度定制的团队。如果你正在为选 QTableView 还是自绘而犹豫或者手头有个任务量不大但交互要求高的项目这份源码值得下载拆一遍。2. 版本原理为什么这一版能把数量级的性能打上去2.1 两种实现方式的实测差距delegate 绘画 vs 场景 Item 渲染很多人第一次做甘特图最常见的方案是拿 QTableView 外加一个自定义 delegate在 paint() 里去画任务条和依赖线。这种做法的优点很明显行高、列宽、选中状态、滚动条都跟表格是天然配套的代码量不大十几行 delegate 就能画出一个像样的条。但它的问题也很致命QTableView 只对你看到的行调用 paint()这在任务数少时很友好可一旦滚动频繁或数据更新整个 visible 区域全部重绘任何浮点坐标的变换都要在 paint() 里临时算一遍历史经验是超过两千条任务拖动时间轴就会明显感到 GPU 使用率上不去掉帧倒还是小事工作项多了以后 hover 和 selection 的响应都跟着钝。而这个“另一个版本”走的是 QGraphicsView QGraphicsItem 路线。它把每一个任务条设计成一个独立的 item注意是 item 而不是 delegate。在 QGraphicsScene 里未遮挡的 item 会走自己的缓存View 在滚动时只需要告诉场景“我看到的区域偏移了”场景就能利用 item 自身的坐标和层级做局部重绘不需要像 QTableView 那样每次重算出整个可视区域。更关键的是QGraphicsView 的 scale() 天然就是做坐标准换的放大时不用重新计算每个任务的像素位置只要改一下视图变换矩阵任务条的边界和字体同步放大。拿同样一台看起来并不新、跑 Ubuntu 的办公机实测一万行任务每个行上三五粒依赖线水平缩放到 0.5 到 3.0 之间拖动帧率基本稳定在 50 朝上。这在 QTableView 方案下是做不到的除非你愿意每次缩放都重新 setColumnWidth 并强制 update那样滚动条和内容之间会有肉眼可见的错位。2.2 这个版本的坐标体系与缩放机制从“时间戳”到“像素”的映射真正读这份源码最值得先看的是它的坐标映射。普通 QTableView 版甘特图左列是任务列表右列是时间轴两个区域的滚动是同步的而这个 QGraphicsView 版本把所有东西都放进一个 scene 里任务名文本也是 item 的附属 element时间轴不在“列”里而是直接画在 item 的 paint() 里。它的核心思想是“以场景时间为基准、像素为表现”源码里常见这样一个函数class GanttScene : public QGraphicsScene { public: // 把业务时间映射到场景坐标scaleFactor 表示每秒对应的像素宽度 qreal timeToX(const QDateTime time) const { return m_originTime.msecsTo(time) / 1000.0 * m_pixelsPerSecond; } // 把场景坐标反推回业务时间用于拖动和点击命中 QDateTime xToTime(qreal x) const { qint64 msecs m_originTime.msecsTo(m_originTime) qint64(x / m_pixelsPerSecond * 1000.0); return m_originTime.addMSecs(msecs); } void setViewRange(const QDateTime start, const QDateTime end); private: QDateTime m_originTime; // 场景 0 对应的业务时间原点 qreal m_pixelsPerSecond 10.0; // 每秒 10 个像素缩放时改变该值 };这里 m_originTime 是项目开始的最早时间点所有任务条的位置都相对于它计算。m_pixelsPerSecond 是关键旋钮它把时间轴的长度直接变成“一秒等于多少像素”。当我们对视图做 scale() 操作时其实更推荐直接改 m_pixelsPerSecond 再重新绘制这样能避免 view 变换矩阵与 item 内部坐标相互叠加产生漂移。缩放实现的常见做法是void GanttView::zoomIn() { // 以鼠标当前点作为锚点来变换避免缩放后视角跑飞 QPointF mouseScenePos mapToScene(m_mouseLastPos); m_pixelsPerSecond * 1.2; // 重新设置 sceneRect 和视图缩放 resetTransform(); scale(m_pixelsPerSecond / m_basePixelsPerSecond, m_pixelsPerSecond / m_basePixelsPerSecond); // 调整滚动位置确保鼠标悬停处的任务条不跳走 QPointF newScenePos mapToScene(m_mouseLastPos); QPointF d newScenePos - mouseScenePos; scrollToScenePos(scenePos); }注意这里的 resetTransform()。因为 m_pixelsPerSecond 已经变大了view 的 scale 只做一次绝对缩放不要用 scale(1.2, 1.2) 这种反复累乘的方式。累乘的坑在于浮点误差会逐步累积连续缩放十几次后坐标会明显漂移。而这份源码里把 m_pixelsPerSecond 当作唯一可信源每次都用基准值重新计算是它长时间缩放不歪的原因。2.3 任务条 Item 的绘制边界不重画才是性能关键真正实现甘特条的是一个继承自 QGraphicsItem 的小类每个任务一个实例。源码里值得注意的一个细节是 boundingRect() 返回的矩形比视觉绘制更大留了一圈抗锯齿边距QRectF TaskBarItem::boundingRect() const { // 宽度等于时长映射到场景坐标的像素长度 qreal w m_scene-timeToX(m_end); qreal h m_rowHeight; // 给边框和阴影多留 2 像素避免 setClip 把灰边裁掉 return QRectF(0, 0, w, h).adjusted(-2, -2, 2, 2); } void TaskBarItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) { Q_UNUSED(option); Q_UNUSED(widget); // 只有在变化时才重绘否则直接用缓存的 pixmap if (m_cacheDirty) { m_cache renderToPixmap(); m_cacheDirty false; } painter-drawPixmap(0, 0, m_cache); }许多初级 QGraphicsView 代码把绘制逻辑全部写进 paint()每次鼠标移动、选中变化都会触发全部重绘这是不必要的。这里用 m_cacheDirty 标记只有任务条时间范围、颜色、进度发生变化时才重新渲染一次平时 drawPixmap 只需要贴图。这种做法对任务多、频繁水平拖动的场景能省掉大量 CPU 开销。整份源码里任务条本身并不重画依赖线用 QGraphicsPathItem 画在另外一层不做事的时候也是静止的这才能保证大数据量下的流畅度。3. 落地复现把源码编译成自己项目模块3.1 用 CMake 组织项目结构避免 qmake 的路径地狱这份源码是用 CMake 组织的这比 qmake 那种“每个子项目都要手写 pri 文件”的结构好维护得多。下载后你会看到 src 目录下有三个核心文件ganttscene.h、ganttview.h、ganttbaritem.h外加一个 ext 目录放依赖的日期时间工具类。先不用急着改动先建一个最小 app 工程把源码引进来验证编译再谈集成。假设你下载的源码放在 thirdparty/gantt/ 下那么根 CMakeLists.txt 可以这样写cmake_minimum_required(VERSION 3.16) project(GanttDemo VERSION 0.1 LANGUAGES CXX) # 设置 C17 set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # 将 gantt 源码加入构建 add_subdirectory(thirdparty/gantt) # 可执行程序 add_executable(gantt_demo main.cpp MainWindow.cpp ) target_link_libraries(gantt_demo PRIVATE gantt_lib Qt::Widgets ) # 把 gantt 资源目录加进来提供样式表等资源 include_directories(${CMAKE_CURRENT_SOURCE_DIR}/thirdparty/gantt/src)如果源码包里的 gantt_lib 没有写 CMake或者你更习惯直接连源文件一起编那就在根 CMakeLists 里显式加add_library(gantt_lib STATIC thirdparty/gantt/ganttscene.cpp thirdparty/gantt/ganttview.cpp thirdparty/gantt/ganttbaritem.cpp thirdparty/gantt/ganttitemmgr.cpp ) target_include_directories(gantt_lib PUBLIC thirdparty/gantt ) target_link_libraries(gantt_lib PUBLIC Qt::Widgets)这里有个坑源码里的 GanttView 与 QGraphicsView 同名注意别把 Qt 的类搞混。建议 include 时用大写前缀或者干脆 build 成库然后使用命名空间。我一般会加#include ganttview.h using Gantt::View; // 如果源码支持命名空间的话如果没有命名空间也没有关系那就是一个全局唯一类名 GanttView直接使用没事。这种命名冲突在集成时最容易翻车平时没坑到了链接阶段可能出现“对 Qt 库类的重复定义”那多半就是你自己没加命名空间。3.2 在 MainWindow 里初始化视图和数据模型两天能跑通假设你已经把 gantt_lib 编过了现在在 MainWindow 构造函数里拉起来。这份源码需要你给它提供两个东西一个任务列表数据模型以及它自己生成内部描述的小结构体。通常它自带的 model 是一个简单的 QAbstractItemModel但为了快速试跑还是用提供好的默认接口。常见做法是直接从 Json 加载一个任务数组每个任务字段包括 id、name、start、end、dependencies、progress。然后初始化#include GanttScene.h #include GanttView.h #include GanttModel.h MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { // 1. 创建场景并设定原点 GanttScene *scene new GanttScene(this); scene-setOriginDateTime(QDateTime::currentDateTime().addDays(-7)); // 2. 创建视图传入场景 m_ganttView new GanttView(scene, this); setCentralWidget(m_ganttView); // 3. 设置视图的基本视口行为 m_ganttView-setRenderHint(QPainter::Antialiasing); m_ganttView-setViewportUpdateMode(QGraphicsView::BoundingRectViewportUpdate); m_ganttView-setDragMode(QGraphicsView::ScrollHandDrag); m_ganttView-setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOn); m_ganttView-setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // 4. 加载演示数据 GanttModel *model new GanttModel(loadTasksFromJson(:data/sample.json), scene); scene-setModel(model); }注意第四步这个版本里 model 和数据是强绑定关系调用 setModel 后scene 会根据模型数据自动生成任务条 item 和时间轴刻度。如果数据没显示出来先检查 JSON 字段是不是 start/end 两个时间字符串用的 UTC 与本地时间混用了。之前有一次同事直接把 UTC 字符串扔进去结果任务条全部跑到 1970 年去了和他确认后才知道是本地时区转换丢了。逻辑说明这里 viewportUpdateMode 选 BoundingRectViewportUpdate 很重要。默认的 MinimalViewportUpdate 在拖动大量 item 时重绘区域不精确会看到整块残影全区域更新又太费。BoundingRect 模式只更新被改变 item 覆盖的矩形联合区域兼顾正确性和效率。如果你要追求极致流畅也可以试试 NoViewportUpdate 配合手动 update不过新手不建议。3.3 关键参数时间刻度的最小单位、行高和滚动速度跑通以后你会发现默认参数并不一定适合你的业务。这份源码里几处关键设置位置如下第一处是时间刻度粒度。默认是“一列代表一天”但你要是做研发排期一列一天显然太粗需要看小时级。在 GanttScene 里通常有个枚举粒度从小时、天到月都有。调整方式可以在 scene 初始化时调用GanttScene::ScaleMode mode GanttScene::Hour; scene-setScaleMode(mode); scene-setPixelsPerHour(8); // 每小时 8 像素第二处是行高。GanttBarItem 的 boundingRect 高度默认是行高的 60%如果你想更紧凑可以改 model 里 rowHeight。但要注意如果行高太小任务条之间的竖排依赖线箭头会重叠肉眼上很难看。一般行高建议在 24 到 30 像素条高占到行高的 2/3 会比较舒服。第三处是滚动速度。QGraphicsView 默认的滚动速度对于宽的甘特图来说太快一滚就跑到很远。这里有两个选择一是关闭横向滚动条用鼠标水平拖拽二是重写 wheelEvent让滚轮垂直滚动Shift滚轮水平滚动。这个版本里直接把 DragScroll 和鼠标位置绑定可以实现以鼠标所在点为锚点缩放但滚动速度需要用固定像素数控制void GanttView::wheelEvent(QWheelEvent *event) { // 如果按 Ctrl 就缩放否则做像素级滚动 if (event-modifiers() Qt::ControlModifier) { zoomAt(event-position(), event-angleDelta().y() 0 ? 1.15 : 0.85); event-accept(); return; } int deg event-angleDelta().y() / 120; qreal delta deg 0 ? -m_scrollStepPx : m_scrollStepPx; // 水平滚动优先级更高 if (event-modifiers() Qt::ShiftModifier) { horizontalScrollBar()-setValue(horizontalScrollBar()-value() delta); } else { verticalScrollBar()-setValue(verticalScrollBar()-value() delta); } }这个 m_scrollStepPx 每次滚动的像素距离我一般设置 80 到 120太小了滚半天太大了一滚就跳出可视区。Ctrl滚轮缩放也是我刚集成成功后第一个加的自定义交互算是一个高频习惯用法。4. 避坑自绘甘特图的四个翻车点4.1 任务条时不时变成全白看不到内容现象第一次跑起来一切正常可是快速横向拖动几次后部分任务条变成纯白色文字和进度全部消失。点击一下该条才恢复再过几秒又白掉。原因出在 QGraphicsItem 的缓存重绘标记上。源码里为了实现性能优化使用了 item 级别的缓存数据 m_cache但更新缓存的信号只连到了任务的 start/end 变化通知。当视图做 scale 时实际上任务条的外观没有变化可纹理坐标却需要重新生成如果缓存数据里包含屏幕坐标相关的渐变缩放后直接 drawPixmap 就会得到大片白底。解决方式在 GanttView::zoomIn/zoomOut 完成后对 scene 中所有 item 调用 update() 并强制它们的缓存重算。可以这么做void GanttView::doZoom() { QListQGraphicsItem* items scene()-items(); for (QGraphicsItem *item : items) { if (TaskBarItem *bar dynamic_castTaskBarItem*(item)) { bar-setCacheDirty(true); bar-update(); } } }还有一种可能这个“白”是 boundingRect 比实际上绘制矩形小导致裁掉。如果你的 boundingRect 没留边距而画笔宽度默认 1 像素paint 的时候右边缘和下边缘就被裁剪掉看起来像一条没有边框的白色条。这个时候先放大 boundRect 试一下。4.2 缩放后鼠标点不对位置点击和拖动错位现象本来应该拖在周四的任务点击后却选中周三并且拖到周五会跳回周四。尤其是放大两倍以后所有点选位置往左上角偏移。这是很典型的视图坐标与场景坐标混用问题。你在重写 mousePressEvent 时如果直接用 event-pos() 去和任务条比较整个都是错的。因为 event-pos() 是视图坐标必须先映射到场景坐标再做命中测试。这一个版本的做法通常是void GanttView::mousePressEvent(QMouseEvent *event) { if (event-button() Qt::LeftButton) { QPointF scenePt mapToScene(event-position().toPoint()); QGraphicsItem *item scene()-itemAt(scenePt, QTransform()); // 把选中的 item 转成 TaskBarItem setSelectedItem(item); } QGraphicsView::mousePressEvent(event); }key 在这里就是 mapToScene。而且要特别注意如果 scene 有 transform比如 scene-setSceneRect 后又调用了 scaleitemAt 的第二个参数一定要用那个变换矩阵否则 item 坐标会错乱。源码里给这个类加了个成员函数setSceneTransform(QTransform)在每次缩放时手动更新这就不会错位了。用起来以后凡是我自己新加交互都一律从 scenePos 下手不要再触碰 viewPos。4.3 每次刷新数据都会全屏闪白像是砸帧现象每隔几秒后台拉一次任务状态调用 scene 刷新后整个甘特图闪一下白屏然后才恢复。任务少时还能凑合任务多了闪得厉害甚至出现闪烁后工具提示信息丢失。这个问题本质上是每次数据更新代码里做了一个暴力操作清空 scene 再重新创建所有 item。这大概是最偷懒也是最多人写的方案。虽然 QGraphicsScene 清空几百个 item 不慢但重建 item、再触发 paint、再重排列依赖线整个往返需要和 GPU 同步视口刷新就会感觉到瞬时的空白。解决的办法是“更新优先于删除重建”。源码里这一版其实也提供了一种更细的机制每个 task 的数据保存在 model 里item 只是 model 的影像。更新时只调用 item-update() 并修改时间坐标不要 remove 整个 item。你可以在请求数据回来后写void GanttModel::updateTasks(const QListTaskData where) { for (const TaskData task : where) { GanttBarItem *bar m_taskItemMap.value(task.id); if (bar) { bar-setStartTime(task.start); bar-setEndTime(task.end); bar-setProgress(task.progress); bar-update(); // 只重画自己 } else { // 只有新任务才创建 } } }同时关闭 view 的索引缓存避免 item 越清越多view()-setItemIndexMethod(QGraphicsScene::NoIndex)。索引对于大量移动 item 时有加速作用但内部会在清空重填时引起更大开销数据刷新多的场景这里关掉反而更稳。4.4 点击任务条没反应拖拽排序根本排不动现象期望鼠标放到任务条上按住拖动可以调整任务顺序但是实际按的时候总是只能选中整条拖不到第二行有时还会把鼠标移动误判成滚轮拖手。排查了很久发现鼠标事件被父视图的 dragMode 吃掉了一部分。QGraphicsView::ScrollHandDrag 在空白区域是拖拽平移但一旦鼠标点在 item 上它仍然会先进入 item 的 mousePressEvent。问题是如果你的 task item 没有实现 flags()默认 ItemIsSelectable 都够可拖拽时还要加上 ItemIsMovable否则事件会被视图拦截去滚动。针对这一条常规做法是给 TaskBarItem 加上标志Qt::ItemFlags TaskBarItem::flags() const { return QGraphicsItem::ItemIsSelectable | QGraphicsItem::ItemIsFocusable | QGraphicsItem::ItemIsMovable; }而且还得把 GanttView 的 dragMode 改成 NoDrag不然你会发现拖拽 item 的移动方向会被限制。真正做任务条跨行拖拽排序时需要把这个 item 的 y 坐标在拖动结束后通过 scene 层去吸附到目标行的 y。源码版本里面没有自动吸附但提供了一个坐标转换接口在元对象属性里读 rowOf(item)也可以自己写void GanttView::dropEvent(QDropEvent *event) { QPointF endPos mapToScene(event-position().toPoint()); TaskBarItem *bar qgraphicsitem_castTaskBarItem*(selectedItem); int row scene()-yToRow(endPos.y()); bar-setRow(row); }这个 yToRow 函数是根据行高做整数除法的。注意 center 点在按下时冻结否则每次 move 事件都会因为 item 位置变化产生抖动。实际处理中我在 mouseReleaseEvent 后统一对齐一次效果好很多。5. 让它能干活任务依赖、批量更新与万级数据的平滑体验5.1 依赖线不随任务条移动自动更新从这份源码的类结构看依赖线是由一个独立的 QGraphicsPathItem 子类维护的它监听任务条的移动信号。但默认实现只在任务条 setStartTime 的时候更新路径端点如果你的业务里任务条允许在场景里拖动那么依赖线不会自动跟上会出现箭头插空。一个常见应对是连接 item 的 positionChanged 信号或者重写 TaskBarItem::itemChange在位置变化时更新它对应的依赖线。如果不想侵入性这么强也可以统一在 scene 层做一个updateDependencyLines()方法拖动完成后去刷新全部 DependLine 的路径void GanttScene::updateDependencyLine(const TaskBarItem *from, const TaskBarItem *to) { QPointF startPt QPointF(from-rect().right(), from-rect().center().y()); QPointF endPt QPointF(to-rect().left(), to-rect().center().y()); // 简单折线拐角水平切 30 像素 QPainterPath path(startPt); qreal midX (startPt.x() endPt.x()) / 2.0; path.lineTo(midX, startPt.y()); path.lineTo(midX, endPt.y()); path.lineTo(endPt); m_linesMap.value(from-taskId())-setPath(path); }注意如果用这种折线逻辑在 startPt 在 endPt 右边即依赖关系反了时要用 U 型绕线否则线会直接穿到依赖条下面。源码里的 demo 任务都是正序排的但真实排期常有下游先开始的情况这一条非常值得自己加。5.2 批量新增 500 个任务分帧加 item界面不冰住所有 QGraphicsView 场景的大敌都是创建 item 时一次循环塞进上千个这会让主线程停下来 layout 和 paint卡出 2 到 3 秒。即使不卡到完全没响应滚动时也会有明显的车辙感。常规做法是分帧刷新——利用 QTimer 每次 30 到 60 毫秒往 scene 里加若干个 item。源码包里没有把分帧写进去但我推荐你按这个思路补void GanttScene::appendTasksChunked(const QListTaskData tasks) { m_chunkList tasks; m_chunkIndex 0; m_chunkTimer new QTimer(this); m_chunkTimer-setInterval(30); connect(m_chunkTimer, QTimer::timeout, this, [this]() { int count 0; while (m_chunkIndex m_chunkList.size() count 40) { addTask(m_chunkList.at(m_chunkIndex)); count; } if (m_chunkIndex m_chunkList.size()) { m_chunkTimer-stop(); emit sceneReady(); } }); m_chunkTimer-start(); }每帧只做 40 个对于常规机械硬盘的老设备也不会出现明显的白屏。期间用户可以做滚动、点击虽然还没有那 40 个 item 但不会整体阻塞。分帧时注意不要同时刷新依赖线依赖线很多的话可以等全部任务生成后再拉一次 updateAllDependencyLines这样曲线不乱。5.3 万级任务的末期调优取消阴影和启用 OpenGL如果你真的要把这份源码推到一万级以上还有两个推荐操作第一个是把 TaskBarItem::paint 里的矩形阴影去掉。默认绘制为了好看给任务条加了 1 至 2 像素的阴影单条的成本不高但一万条之后实测 shadow 的 QPainter 保存恢复状态也会占一点开销不过量级很小更关键是影响绘制缓存命中。阴影跟主题背景有关背景一变阴影又要重新 cache。第二个操作是把 viewport 切换成 OpenGL。很多老代码里只用 raster 结尾正常滚动和缩放可以接受但万级 item 加依赖线的情况下可以用 QOpenGLWidget 获得更好的绘制吞吐auto *gl new QOpenGLWidget(); view-setViewport(gl); view-setViewportUpdateMode(QGraphicsView::FullViewportUpdate);注意不要漏掉第三行。QGraphicsView 默认在 raster 情况下用部分更新OpenGL 下如果还沿用部分更新反而会出现混合不清的问题。开了 OpenGL 后如果发现文字模糊那可能是字体平滑策略问题建议把绘制文字的 section 设置分开走纹理不开 GL 至少对于大多数桌面应用raster 加 BoundingRectViewportUpdate 已经足够。5.4 从那以后我每次集成都需要先跑一个最小 Demo我自己拿到这份源码后并没有直接把它塞进主工程而是先建了一个空 QMainWindow把 scene、view、model 三件套连起来用演示数据跑通确认可以编译、滚动、缩放、拖动然后才把主工程里的数据模型映射到 model 层上。这样做的好处是当主工程报错、控件不显示时你能立刻分清是源码的 bug 还是你数据的问题而不是两个黑匣子叠一起。从那以后我每次集成画图类控件都会强制在最小 Demo 里把缩放、拖动、更新三件事先跑通再谈业务已经是雷打不动的习惯。希望帮到你。本文还有配套的精品资源点击获取