资讯详情

资讯详情

基于Qt Graphics View框架的画图工具实现详解

如果你在Qt里画过点东西大概率有过这种体验用QPainter在paintEvent里硬画每次刷一下状态栏所有图形就得重新走一遍绘制流程想在图形上做选中、拖动、擦除又得自己维护一套点击命中的数据结构。今天要聊的是用Qt 5.9的Graphics View框架写一个最简单但五脏俱全的画图小工具。Graphics View把每个图形当成独立的“图元对象”场景负责管理这些对象视图只负责显示交互逻辑比纯QPainter那套清爽太多。这篇文章会把工程搭建、鼠标画线的实现、铅笔路径、橡皮擦以及几个常见大坑全部拆开讲适合刚接触Qt、想做课程设计或白板工具的读者也适合读过Graphics View文档但依然不知道从哪下手的同学。1. 为什么画图这件事我推荐你选Graphics View而不是硬画1.1 别急着写paintEvent先想想你的需求是什么很多人在Qt里画图的第一个念头就是重写paintEvent在QPainter里画直线、画矩形。如果只是显示一张固定图片这样做没问题。可一旦需求变成“用户要自己画”麻烦就来了你画的每一个图形不只是显示出来之后可能还要被擦掉、移动、选中、改颜色、改大小。如果全在paintEvent里画你得自己记录每条线的坐标、每块矩形的属性还要在mousePressEvent里自己判断点到了哪条线上。这个工作量会随着图形数量增长越来越失控。Graphics View的思路完全不同。它是把每个画出来的东西包装成一个对象叫QGraphicsItem。你往场景里丢一个QGraphicsLineItem这条线就自己管理自己的位置、笔刷、选中状态。场景QGraphicsScene负责把一堆图元组织起来而QGraphicsView就相当于一个镜头帮你把场景内容显示出来顺便处理鼠标键盘事件。用这个框架画的本质变成“往场景添加对象”而不是“往画布上涂抹像素”。1.2 三个核心类用生活例子一次说清刚开始接触Graphics View的人看到QGraphicsScene、QGraphicsView、QGraphicsItem三个类容易懵。我常用一个比喻场景是一张无限大的办公桌图元是放在桌上的文件、笔和便利贴视图是你拿在手里的放大镜。你在放大镜里看到的只是办公桌的一部分当他们说“场景坐标”指的是文件在办公桌上的实际位置而“视图坐标”是放大镜下看到的位置。两者要通过mapToScene、mapFromScene这类函数转换。这个比喻带出两个关键点场景不一定等于窗口大小。你可以给场景设置一个sceneRect限定绘制范围视图可以根据需要滚动、缩放。鼠标事件拿到的event-pos()是视图坐标画图时要先转成场景坐标否则视图一滚动或缩放你画的位置全乱套。理解了这三个类再看Graphics View的官方文档就不会头大。文档里说的“项”“场景”“视图”的职责边界放到办公桌例子里其实都是常识。2. 最小可运行工程5分钟把画布跑起来2.1 工程文件pro配置我用的是Qt 5.9这个版本在不少学校教材和企业老项目里还很常见。新建一个纯C项目工程文件大概长这样QT core gui greaterThan(QT_MAJOR_VERSION, 4): QT widgets TARGET DrawBox TEMPLATE app CONFIG c11 SOURCES \ main.cpp \ mainwindow.cpp \ drawview.cpp HEADERS \ mainwindow.h \ drawview.hQt 5.9默认支持C11CONFIG c11这里写不写问题都不大但我习惯显式加上规范一些。如果你的Qt Creator创建的是QWidget Application模板工程文件里这套配置已经自动生成基本不用改。2.2 主窗口里放一个自定义View我建议不要直接在主窗口里拖一个QGraphicsView控件而是继承QGraphicsView写一个DrawView类。原因很简单鼠标事件需要重写工具栏按钮需要切换绘图模式如果全部塞在MainWindow里主窗口会越来越臃肿。把画布的行为封装进DrawView主窗口只负责搭界面、调接口结构清楚得多。先看drawview.h#ifndef DRAWVIEW_H #define DRAWVIEW_H #include QGraphicsView #include QPointF #include QColor #include QPainterPath class QGraphicsScene; class QGraphicsItem; class DrawView : public QGraphicsView { Q_OBJECT public: enum ShapeType { Line, Rect, Ellipse, Pen, Eraser }; explicit DrawView(QWidget *parent nullptr); void setShapeType(ShapeType type); void setPenColor(const QColor color); void setPenWidth(int width); protected: void mousePressEvent(QMouseEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mouseReleaseEvent(QMouseEvent *event) override; private: QGraphicsScene *m_scene; QGraphicsItem *m_currentItem; QPainterPath m_penPath; QPointF m_startPos; QPointF m_lastPos; ShapeType m_shapeType; QColor m_penColor; int m_penWidth; bool m_isDrawing; void removeItemsInsideRect(const QRectF rect); }; #endif构造函数里要做三件关键的事创建场景、设置场景矩形、告诉视图使用抗锯齿渲染。DrawView::DrawView(QWidget *parent) : QGraphicsView(parent), m_currentItem(nullptr), m_shapeType(Line), m_penColor(Qt::black), m_penWidth(2), m_isDrawing(false) { m_scene new QGraphicsScene(this); m_scene-setSceneRect(0, 0, 1200, 800); m_scene-setBackgroundBrush(Qt::white); setScene(m_scene); setRenderHint(QPainter::Antialiasing); }setSceneRect(0, 0, 1200, 800)的意思是场景的逻辑范围是1200x800用户画图超出这个范围后视图不会自动滚出这块区域相当于把画图限制在一张A4纸上对小白来说更好理解。背景刷成白色是因为直接继承QGraphicsView时默认背景是灰的画出来视觉上不够“画布”。main.cpp很简单做一个标准入口就行#include QApplication #include mainwindow.h int main(int argc, char *argv[]) { QApplication app(argc, argv); MainWindow window; window.show(); return app.exec(); }到这里一个白底、有画布、能显示的空窗口就出来了。下一步才是画图的核心。3. 画直线、矩形和圆的实现核心就三招3.1 鼠标事件三件套按下、移动、抬起用鼠标画图无非是抓住三个时机按下鼠标左键记录起点移动鼠标更新图形松开鼠标结束一次绘制。Graphics View框架的好处是这些事件已经在QGraphicsView里定义好了我们只需要重写对应函数。绘制过程中最核心的一个思路是按下鼠标时先创建一个临时图元移动鼠标时不断修改这个图元的数据松开鼠标时保留这个图元最终的状态。很多初学者会想着每移动一次鼠标就重新创建一个新的图元然后把旧的删掉。这样做也能跑但没必要而且会有闪烁和资源释放不及时的问题。更优雅的做法是——图元创建一次然后只是更新它的数据。直线绘制就是最典型的例子void DrawView::mousePressEvent(QMouseEvent *event) { if (event-button() ! Qt::LeftButton) { QGraphicsView::mousePressEvent(event); return; } m_startPos m_lastPos mapToScene(event-pos()); m_isDrawing true; if (m_shapeType Pen) { m_penPath QPainterPath(m_startPos); QGraphicsPathItem *pathItem new QGraphicsPathItem; pathItem-setPath(m_penPath); pathItem-setPen(QPen(m_penColor, m_penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); m_scene-addItem(pathItem); m_currentItem pathItem; } QGraphicsView::mousePressEvent(event); }这里mapToScene(event-pos())就是把鼠标在窗口控件上的位置转换成场景坐标。这一步非常重要如果直接用event-pos()在视图没有滚动和缩放时勉强能对上可一旦视图移动过坐标就错位了。凡是画不准位置的九成都是栽在这。3.2 移动时别删了重建直接改图元数据继续看mouseMoveEvent。这个函数是画图过程中被调用最频繁的鼠标每移动一个像素都可能触发一次。性能好不好就看这里做了什么void DrawView::mouseMoveEvent(QMouseEvent *event) { QPointF pos mapToScene(event-pos()); if (m_isDrawing) { switch (m_shapeType) { case Line: { QGraphicsLineItem *item qgraphicsitem_castQGraphicsLineItem *(m_currentItem); if (item) item-setLine(QLineF(m_startPos, pos)); break; } case Rect: { QGraphicsRectItem *item qgraphicsitem_castQGraphicsRectItem *(m_currentItem); if (item) item-setRect(QRectF(m_startPos, pos).normalized()); break; } case Ellipse: { QGraphicsEllipseItem *item qgraphicsitem_castQGraphicsEllipseItem *(m_currentItem); if (item) item-setRect(QRectF(m_startPos, pos).normalized()); break; } case Pen: { m_penPath.lineTo(pos); QGraphicsPathItem *item qgraphicsitem_castQGraphicsPathItem *(m_currentItem); if (item) item-setPath(m_penPath); break; } case Eraser: { QRectF eraserRect(pos.x() - 10, pos.y() - 10, 20, 20); removeItemsInsideRect(eraserRect); break; } default: break; } } m_lastPos pos; QGraphicsView::mouseMoveEvent(event); }直线用setLine更新起点终点矩形和圆形用setRect更新左上角和右下角。注意QRectF(m_startPos, pos).normalized()里的normalized()它能把用户从右下往左上拖出的矩形转成标准的、左上到右下的矩形。没有这一步你从右下角往左上角画矩形时矩形会变成空心的奇怪形状甚至“消失”因为在QRectF里width和height不能是负数。为什么说“别删了重建”因为删除图元再创建涉及到场景的维护、内存分配、图元索引更新做一次两次没事鼠标拖动过程中每秒可能触发几百次累计开销不小。而setLine、setRect这样的接口只修改几何数据内部会通知视图重绘该区域效率高得多。实测下来这样直接改数据的方式画图过程非常跟手。3.3 铅笔跟手绘用QPainterPath记录轨迹直线、矩形、圆形都很规矩。但有时候用户想要随手涂鸦这东西在Graphics View里用QPainterPath就能实现。思路比普通形状还简单鼠标按下时创建一条空的QPainterPath起始点是当前坐标鼠标移动时用lineTo把新的点追加进去每追加一次就把这个path设置给QGraphicsPathItem。void DrawView::mousePressEvent(QMouseEvent *event) { if (event-button() ! Qt::LeftButton) { QGraphicsView::mousePressEvent(event); return; } m_startPos m_lastPos mapToScene(event-pos()); m_isDrawing true; if (m_shapeType Pen) { m_penPath QPainterPath(m_startPos); QGraphicsPathItem *pathItem new QGraphicsPathItem; pathItem-setPath(m_penPath); pathItem-setPen(QPen(m_penColor, m_penWidth, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); m_scene-addItem(pathItem); m_currentItem pathItem; } QGraphicsView::mousePressEvent(event); }鼠标移动时Pen分支不断执行m_penPath.lineTo(pos)并更新路径图元。你可能会问为什么不让场景的item自动处理这些问题原因在于鼠标按下、移动、抬起这些事件如果只发生在视图上场景里的图元并不会自动响应。我们的画板不需要图形被移动、被选中只需要“生成图形”这个动作所以直接在视图层把事件消费掉反而最简单。有些教程里会推荐在scene的子类里重写mousePressEvent然后自己获取所有图元判断是否点中某个。那样面对交互复杂的编辑器场景更合适。咱们这个画图工具目标就是尽快画出来所以把逻辑放在View层是合理的。不过这里有一个值得注意的细节QPen的样式里我给铅笔选择了RoundCap和RoundJoin。这意味着线条端点和拐角是圆角手写感觉更自然。如果使用默认的FlatCap画出来的线条拐角会非常生硬尤其在快速涂鸦时你会发现字体笔画之间有硌手的棱角。3.4 抬起鼠标图形就这样留在场景里mouseReleaseEvent是整个事件链条的收尾。直线、矩形、圆形、铅笔在移动过程中已经不断更新自己的状态所以在松开鼠标时其实什么都不用额外做只要把m_currentItem归空告诉系统当前没有正在绘制的图形然后调用基类的事件处理void DrawView::mouseReleaseEvent(QMouseEvent *event) { if (event-button() ! Qt::LeftButton) { QGraphicsView::mouseReleaseEvent(event); return; } if (m_shapeType Eraser) { QRectF rect(m_startPos, m_lastPos).normalized() .adjusted(-4, -4, 4, 4); removeItemsInsideRect(rect); } m_currentItem nullptr; m_isDrawing false; QGraphicsView::mouseReleaseEvent(event); }有人会问为什么不在这里把item加进场景因为我们已经在mousePressEvent里addItem了。从按下到拖动再到抬起这个图元从头到尾都存在于场景中只是几何数据在变。这才符合“最小实现”的原则不需要额外维护“完成图形列表”。4. 把主窗口组装起来颜色线宽都要能用4.1 工具栏和动作连接有了DrawView主窗口就只是壳。我用QToolBar放几个QAction分别对应直线、矩形、圆、手写和橡皮MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent) { m_view new DrawView(this); setCentralWidget(m_view); QToolBar *toolBar addToolBar(QStringLiteral(工具栏)); toolBar-setMovable(false); QAction *lineAction toolBar-addAction(QStringLiteral(直线)); QAction *rectAction toolBar-addAction(QStringLiteral(矩形)); QAction *ellipseAction toolBar-addAction(QStringLiteral(圆)); QAction *penAction toolBar-addAction(QStringLiteral(手写)); QAction *eraserAction toolBar-addAction(QStringLiteral(橡皮)); connect(lineAction, QAction::triggered, this, [this]() { m_view-setShapeType(DrawView::Line); }); connect(rectAction, QAction::triggered, this, [this]() { m_view-setShapeType(DrawView::Rect); }); connect(ellipseAction, QAction::triggered, this, [this]() { m_view-setShapeType(DrawView::Ellipse); }); connect(penAction, QAction::triggered, this, [this]() { m_view-setShapeType(DrawView::Pen); }); connect(eraserAction, QAction::triggered, this, [this]() { m_view-setShapeType(DrawView::Eraser); }); }用lambda表达式连接信号代码简洁不需要为每个按钮写槽函数。Qt 5.9对C11的lambda支持已经很成熟可以放心用。4.2 加一个颜色选择和线宽选择光能画黑色线条总觉得差点意思。我给工具栏补上颜色选择和线宽选择不用复杂设置一个QColorDialog加上一个QComboBox就够了QAction *colorAction toolBar-addAction(QStringLiteral(颜色)); QComboBox *widthCombo new QComboBox(toolBar); widthCombo-addItems(QStringList() 1 2 4 8); toolBar-addWidget(widthCombo); connect(colorAction, QAction::triggered, this, [this]() { QColor color QColorDialog::getColor(Qt::black, this); if (color.isValid()) m_view-setPenColor(color); }); connect(widthCombo, QOverloadint::of(QComboBox::currentIndexChanged), this, [this, widthCombo](int index) { int width widthCombo-itemText(index).toInt(); m_view-setPenWidth(width); });DrawView里对应的set函数很简单就是给成员变量赋值void DrawView::setPenColor(const QColor color) { m_penColor color; } void DrawView::setPenWidth(int width) { m_penWidth width; }这里要提醒一下当前正在绘制的图形不会立刻应用新颜色和新线宽只有下一次绘制才开始使用。如果你希望中途切换颜色后已完成的图形也跟着变色就得在setPenColor里遍历场景所有图元逐个设置画笔。这样做能满足部分“后期调整”需求但对画板工具来说常规行为就是“只影响后续绘制”别把它当PS的图层调整功能来用。4.3 场景坐标与视图坐标不一致时容易踩坑我在前面反复强调mapToScene这里再多说几句。QGraphicsView的viewport里鼠标事件给你的event-pos()是控件坐标视图坐标。如果视图位置是(0,0)滚动条也在原点视图坐标和场景坐标恰好重合。但只要用户滚动过滚动条或者我们实现了缩放这两个坐标就完全不一样。你可以自己做个小实验在工程刚跑起来时画一条线然后滚动视图到别处再画第二条线看看两条线的位置关系。错误坐标处理下第二条线不会出现在你鼠标点击的位置而是出现在场景的原点附近或其他诡异位置。出现这种现象不叫“Qt有bug”而是坐标转换遗漏。Graphics View里所有图元的坐标都是场景坐标所以写入的坐标必须是mapToScene后的结果。这条规则同样适用于读取如果你想知道鼠标指向了哪个图元QGraphicsScene::items(QPointF)接受的也必须是场景坐标。5. 橡皮擦最容易翻车的一个功能5.1 最简单的可用方案删除经过区域的图元橡皮擦在画图工具里看似简单但Graphics View并没有内置“擦除轨迹”的机制。我试过几种方案最后用的最稳定的是鼠标按住拖动时把鼠标经过位置附近的一小块矩形范围内的图元从场景中删除。void DrawView::removeItemsInsideRect(const QRectF rect) { const QListQGraphicsItem * items m_scene-items(rect); for (QGraphicsItem *item : items) { if (item m_currentItem) continue; if (item-type() QGraphicsLineItem::Type || item-type() QGraphicsRectItem::Type || item-type() QGraphicsEllipseItem::Type || item-type() QGraphicsPathItem::Type) { m_scene-removeItem(item); delete item; } } }这段代码的逻辑是拿到与擦除矩形有交集的图元列表逐个删除。为什么用m_scene-items(rect)而不是自己遍历所有图元判断坐标因为QGraphicsScene内部对图元做了空间索引items(rect)比自己遍历快得多。这也体现Graphics View的优势——大多数通用功能已经帮你考虑过了。删除时不需要遍历所有图元是因为_items里已经过滤得很干净了。你可能注意到我在循环里跳过了m_currentItem这是为了防止擦除时把当前正在操作的图元误删。橡皮模式下m_currentItem通常是nullptr但写一下这个判断总归稳妥。5.2 为什么橡皮擦擦不干净坐标系背锅很多人在橡皮擦上遇到“擦不掉”的问题其实多半不是删除逻辑的问题而是矩形区域计算不对。比如MouseMoveEvent里用了event-pos()而不是mapToScene(event-pos())导致橡皮擦实际上在一个错误的位置寻找图元。你看到鼠标从这里划过程序却在另一个地方寻找目标看起来就像橡皮擦失灵。另一种情况是擦除区域太窄。鼠标移动很快时相邻两个mouseMoveEvent的位置之间可能相隔几十像素如果你只在单点附近构造一个小矩形就会漏掉中间的一段。解决方法是记录上一次的鼠标位置m_lastPos在计算橡皮擦区域时把从lastPos到当前位置的整个路径都包含进去case Eraser: { QRectF eraserRect QRectF(m_lastPos, pos).normalized() .adjusted(-10, -10, 10, 10); removeItemsInsideRect(eraserRect); break; }这样鼠标快速划过一条直线时中间不会被漏掉。注意它是基于“上一次鼠标位置”到“当前位置”构建的所以能覆盖完整轨迹。还有一个隐蔽的坑删除图元后如果视图缓存没有及时更新屏幕上可能会残留一些像素痕迹。出现这个情况通常是因为没有触发重绘。QGraphicsScene::removeItem本身会通知变化的区域重绘但如果你用的是旧版Qt或者自己实现了某些缓存机制可以在删除后调用viewport()-update()强制刷新。6. 图片导出、撤销和滚轮缩放这几个需求早晚要碰6.1 把画布导出成图片三行核心代码画完不导出来等于白画。Graphics View导出图片非常直接用QGraphicsScene::render配合QPainter就能实现void MainWindow::saveImage() { QImage image(m_view-scene()-sceneRect().size().toSize(), QImage::Format_ARGB32); image.fill(Qt::white); QPainter painter(image); painter.setRenderHint(QPainter::Antialiasing); m_view-scene()-render(painter); image.save(QStringLiteral(canvas.png)); }这里的关键是image的大小必须和场景的sceneRect匹配这样才能把整个画布完整导出包括那些被滚动条挡住看不到的部分。如果你只想导出“当前可见区域”可以改用QGraphicsView::render但通常画图工具的需求是把整张画布导出所以用scene()-render更合适。导出的图片质量与视图显示无关因为render是直接用场景里的矢量数据绘制的不会依赖控件的显示分辨率。这意味着即使在屏幕上画出来有锯齿感导出PNG时开了抗锯齿依然能得到平滑的图片。6.2 撤销功能的最小实现思路画图工具没有撤销用户迟早会崩溃。Graphics View没有内建Undo框架但如果你想做一个最简版本思路并不复杂每次鼠标抬起时把最终完成的图元指针保存到一个QList里。撤销时从列表尾部取出最后一个图元从场景中删除即可。// MainWindow里维护一个列表 QListQGraphicsItem * m_doneItems; // DrawView里在mouseReleaseEvent中发出signal emit drawingFinished(m_currentItem); // MainWindow恢复撤销 void MainWindow::undo() { if (m_doneItems.isEmpty()) return; QGraphicsItem *item m_doneItems.takeLast(); m_view-scene()-removeItem(item); delete item; }这个方案的代价是乒乓式撤销多次后内存里的图元会反复创建和删除相对简陋。但作为课程设计或内部工具够用了。系统级Undo框架QUndoStack虽然更完善但对这种小型项目来说往往陷入“过设计”。我的意见是先把最简版本跑通等确实需要多步回退和重做时再迁移到QUndoStack。6.3 滚轮缩放重写wheelEvent就完事Graphics View默认是不支持滚轮缩放的必须自己重写wheelEventvoid DrawView::wheelEvent(QWheelEvent *event) { double factor 1.15; if (event-angleDelta().y() 0) scale(factor, factor); else scale(1.0 / factor, 1.0 / factor); }但缩放之后你会发现坐标转换的问题更加突出了。因为视图缩放时scene坐标和view坐标的比例变了mapToScene的结果与缩放前完全不同。所以我把坐标转换函数放在绘制事件里而不是在构造时就锁定这样每次都能拿到正确的最新坐标。滚轮缩放还会带来的一个问题是橡皮擦的区域大小看起来会变化。缩放比例变大时20x20像素的橡皮擦实际覆盖的场景区域变小了。如果你希望橡皮擦的实际擦除面积固定需要在擦除时除以当前缩放比例qreal scaleFactor transform().m11(); QRectF eraserRect(pos.x() - 10 / scaleFactor, pos.y() - 10 / scaleFactor, 20 / scaleFactor, 20 / scaleFactor);这个小坑我是等到用户说“放大之后橡皮擦变小了”才发现的属于典型的“开发时没想到缩放联动”的问题。写在这里提前帮你排掉。7. 实用技巧与避坑清单我把常见的坑整理成一张速查表方便你排查现象最常见原因解决办法画的图形没出现在鼠标位置用了event-pos()而没有mapToScene统一使用mapToScene(event-pos())画的矩形从右下往左上拖时消失QRectF宽高为负数使用QRectF(start, end).normalized()画笔线条拐角生硬QPen默认FlatCap设置Qt::RoundCap和Qt::RoundJoin橡皮擦没反应擦除区域用了视图坐标换算场景坐标并覆盖移动路径快速拖动时橡皮擦断断续续只处理了单点区域用上一次位置到当前位置构建矩形放大后橡皮擦变小没有考虑缩放比例擦除矩形除以当前缩放值导出图片白底缺失图片没有fill(Qt::white)导出前先填充背景色工具栏切换无反应信号连接失败检查lambda是否捕获了正确的this除了表里的这些还有两个我自己的习惯值得分享。第一个图元创建后一定要记得设置QPen的颜色和宽度。很多人先在构造函数里创建item忘记给item设置画笔结果画出来全是黑色细线还以为是Graphics View不支持彩色。其实QGraphicsLineItem默认画笔是黑色的1像素线你需要主动把当前颜色和线宽设置进去。第二个图形创建完成后如果没有特殊需求不建议给图元设置ItemIsMovable标志。画板工具里用户是在“创作新图形”不是在“拖动旧图形”。如果设了可移动标志用户画完一条线想画第二条时鼠标落在线条上会把旧线条拖走体验很怪。QGraphicsView自带的拖拽行为是为编辑器设计的和画板场景有差异。代码里基类事件处理这一块有人喜欢在重写的mousePressEvent里直接return不调用QGraphicsView的父类实现最后发现左键点击后场景里的图形无法选中、拖拽功能异常。虽然我们这个画板不依赖父类行为但保留QGraphicsView::mousePressEvent(event)调用是个好习惯防止未来扩展功能时遇到莫名其妙的状态不同步。这个画板项目我从需求到跑通大概花了一个晚上。之后陆续加过撤销、导出、缩放每一步都是在最小代码量的基础上做增量。根据我的经验Graphics View不是你理解所有源码后才能用而是先跑通一条最简单的链路——按下画点、拖动成型、松开保留然后在这条链路上去查文档、加功能。这套路径比从官方示例的复杂源码入手顺畅得多。如果你也卡在Graphics View的“入门十分钟精通却看文档好几遍”的阶段照着上面的代码建一个工程亲手跑一遍很快就能建立起感觉。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →