
简介一份面向C#开发者的图形拖拽编程实践资源以Windows Forms为界面的Demo项目为载体演示如何实现类似Visio的节点拖拽、线条跟随与自定义绘图。资源共44个文件压缩包329KB以cs源码为主16个辅以dll依赖库、resx/resources资源文件、exe可执行程序及解决方案文件便于直接打开工程查看设计器界面与运行效果。项目包含VisioControl、DragControls等关键模块完整覆盖INode/ILine接口定义、RectangleNode与SolidLine等自定义图形类、Paint重绘及鼠标事件处理并体现双缓冲等性能优化思路适合希望快速上手WinForms自定义绘图与拖拽交互的中级学习者。目前已有1703人学习下载可作为OA节点拖拽、流程设计工具等场景的起点工程。 干过工业上位机或者做过工控组态软件的朋友大概率都遇到过这类需求客户想要一个类似Visio的界面能把设备图标、阀门、仪表盘从侧边栏拖到画布上然后用连线表示信号流向还能拖动调整位置。这需求听起来不复杂但真做起来拖拽两个字背后藏着一整套图形编辑器的底层逻辑。这篇我直接讲做法把C# WinForms里实现图形拖拽的完整思路拆开揉碎包含对象模型怎么设计、命中测试怎么写、连接线怎么画、缩放吸附怎么做最后附上我实际项目中踩过的坑。无论你是做上位机、流程图工具还是组态软件这套方案都可以直接参考。1. 为什么自己做拖拽式图形编辑器而不是用现成控件先聊一个很现实的问题市面上不是没有现成的Diagram库为什么还要自己写1.1 这类需求到底出现在哪些场景把需求场景摆出来你就明白为什么自绘方案更合适。我接触过的项目里这类拖拽图形编辑器主要出现在三个方向一是工业上位机里的产线组态界面操作员要从元件库拖出电机、传感器、PLC图标组成一条产线的拓扑图再通过连线标注通讯关系二是内部工具类软件里的流程编排比如测试流程编辑器、数据解析规则编排本质上就是把一堆节点拖到画布上连起来三是类似Visio的通用绘图功能但这块用现成控件相对可行因为需求就是画图本身。这三个场景里前两个都有强业务绑定。比如工业组态里拖进去的每个图形背后对应一台真实设备它要有设备ID、IP地址、通讯参数这些业务属性流程编辑器里每个节点对应一条执行逻辑连线代表数据流向。这种绑定意味着你不能只做一个普通矩形而是要做一个能代表业务对象的矩形。用现成控件时往节点里塞自定义数据的成本往往比自绘还高。1.2 为什么不能指望第三方控件通吃我并不是说第三方库一定不行而是要先看清楚代价。WinForms领域有几个开源的Diagram控件功能看着挺全但实际接入时你会遇到几个绕不开的坎。第一个坎是扩展成本。开源控件大多把节点数据结构写死了你想给节点加个设备类型字段、加一套自定义属性面板得去改它的源码或者做复杂的事件注入维护成本一下就上去了。第二个坎是展示风格。工业上位机对界面的要求往往很具体深色底、科技感描边、状态色块运行绿色、故障红色现成控件默认的Office风格想调成这种效果要改的样式表比你自己画一个还多。第三个坎最致命——连接线的路由算法。很多轻量级控件根本不提供智能路由两条线交叉得乱七八糟这在产线拓扑图里是没法接受的。所以我的建议是如果需求只是能拖能连用现成库如果图形承载了业务语义、视觉风格有定制要求、后续还可能加缩放/吸附/图层这些进阶功能老老实实自己写。自己写一套基础的拖拽图形编辑器核心代码量在1000行上下换来的是完全可控的数据结构和渲染逻辑这个性价比非常高。2. 先把图形对象模型设计好后面能省一半的返工很多新手做拖拽功能第一反应是处理MouseDown、MouseMove、MouseUp这三个事件鼠标按下去就画矩形、动了就改位置。这种写法做三个图形没问题做到第十个图形时你会发现自己陷入了一堆if-else的泥潭。真正的顺序应该是先把图形对象模型定义清楚再写交互逻辑。模型稳了拖拽只是鼠标事件往模型字段里写值而已。2.1 一个基类应该承载哪些信息仿照Visio的思路画布上所有可拖拽的元素统一抽象成一个ShapeBase基类。这个基类至少要包含以下几类信息。第一类是几何信息核心是Bounds矩形边界。为什么用Bounds而不是只存左上角坐标因为命中测试、边框绘制、缩放手柄计算全都依赖这个图形占多大地方这个整体概念。RectangleF是现成的结构体换算、相交判断都有内置方法直接用。第二类是外观信息包括画笔Pen、画刷Brush、文字内容、文字字体。这里有个经验Pen和Brush不要每次都new否则高频刷新时GDI资源会飙升我把它们设为可序列化的属性初始化时创建好重绘时只改颜色值。第三类是业务数据比如DataObject属性类型是object。这是为工业场景留的口子——图形拖到画布上外面随时可以通过它关联设备实体。另外要留一个Tag字符串字段给用户填备注用的。第四类是交互状态至少要有IsSelected和IsHovered两个布尔值。选中态决定画不画蓝色边框和缩放手柄悬停态决定要不要显示高亮描边。最后是连接锚点集合Anchors。每个图形身上要有几个接线柱连线就是从这些柱子上牵出去的。锚点位置计算我后面专门讲基类里只需要定义一个ListAnchorPoint。public class ShapeBase { public string Id { get; set; } Guid.NewGuid().ToString(); public string Name { get; set; } public RectangleF Bounds { get; set; } public Pen BorderPen { get; set; } public Brush FillBrush { get; set; } public string Text { get; set; } public Font TextFont { get; set; } public object DataObject { get; set; } public string Tag { get; set; } public bool IsSelected { get; set; } public bool IsHovered { get; set; } public ListAnchorPoint Anchors { get; set; } public virtual void Draw(Graphics g) { } public virtual bool HitTest(PointF point) { return Bounds.Contains(point); } public virtual void Move(float dx, float dy) { Bounds.Offset(dx, dy); } }2.2 派生类矩形、椭圆、菱形、图片基类定好之后派生类就非常轻松了。每个子类只需要重写Draw以及在一些特殊情况下重写HitTest。矩形是最简单的Draw里直接g.DrawRectangle加FillRectangle再居中画文字。椭圆同样但要注意命中测试——如果也直接拿矩形边界算用户点椭圆四个角也会被命中体验很怪。所以椭圆的HitTest要做一点数学判断把点坐标归一化到以中心为原点的坐标系判断(x^2)/(rx^2) (y^2)/(ry^2) 1在椭圆内部才算命中。菱形决策节点常用的HitTest更麻烦一点最实用的做法是分解成两个三角形做叉积判断或者直接用GraphicsPath把菱形路径建出来再用IsVisible(point)方法判断后者代码最简单。图片形状设备真实照片或厂商图标稍微特殊一点要存一个Image对象Draw里DrawImage并设置缩放模式。2.3 用接口把交互逻辑隔离出去这里分享一个架构层面的经验不要让ShapeBase直接依赖鼠标状态MouseState枚举我建议把是否正在拖拽拖的是左边还是右下角缩放手柄这些状态放到一个单独的EditContext类里。为什么要隔离因为图形编辑器一般有两种操作模式编辑模式和运行模式。编辑模式下鼠标拖动改位置运行模式下鼠标点击表示操作这台设备。如果状态混在Shape里切模式时就得遍历所有图形重置状态很容易漏。EditContext大概长这样持有当前选中的Shape引用、正在拖动的类型枚举None/Move/ResizeLeftTop/ResizeRightBottom/Connect、鼠标按下时的起始坐标和起始Bounds。这样鼠标事件处理程序只需要操作这个Context和具体的图形实例解耦。3. 拖拽交互的核心命中测试、坐标换算与刷新策略模型层准备好了接下来是交互层的重头戏。这部分代码写得好不好直接决定拖拽是跟手还是发飘。3.1 渲染方案为什么选GDI而不是控件数组动手写交互前先把渲染方案定了。我推荐直接在画布控件上用GDI自绘也就是重写UserControl的OnPaint在Paint事件里遍历所有Shape调用Draw。不要用拖一个Button/Panel控件当图形的方案。用控件数组的坑在于图形多的时候50个以上每个控件都要参与消息循环和布局拖拽时每一步移动都会触发多个控件的重绘和布局计算卡顿几乎是必然的。而且控件没法做旋转、缩放锚点这些复杂交互连画一根跨控件的连线都很麻烦。GDI自绘则是一个控件统一管理所有图形重绘一次画完性能好一个量级而且对图形样式的控制力是完全的。3.2 命中测试的顺序问题和边界细节命中测试是拖拽的基础。每次MouseDown都要在MouseDown事件里遍历画布的所有Shape找出光标命中的那个。这里有个容易出错的地方遍历顺序。后画出来的图形在视觉上盖在先画的上面所以命中测试要从列表末尾往前遍历后画的先命中。否则你永远选不中被覆盖在下层的图形。public ShapeBase HitTest(PointF point) { for (int i shapeList.Count - 1; i 0; i--) { if (shapeList[i].HitTest(point)) return shapeList[i]; } return null; }还有边界处理ShapeBase里我定义了一个virtual bool HitTest默认判断Bounds.Contains(point)。但实际交互里用户点击图形边缘边框附近和点击图形内部语义应该不同——边缘附近可能是想缩放内部才是想拖动。所以更精细的命中测试是分层判断public enum HitTestResult { None, Body, // 图形内部准备拖动 ResizeLeftTop, // 左上角缩放手柄 ResizeRightBottom, // 右下角缩放手柄 Anchor, // 连接锚点 }在MouseDown里依次判断先判断是否点中了某个选中的图形上的缩放手柄再判断是否点中了锚点最后才判断是否命中了图形本体。顺序不能乱。3.3 坐标换算画布坐标和屏幕坐标的对应关系做拖拽时有个隐蔽的坑如果只支持1:1显示不缩放坐标换算就是直接用但一旦做了滚轮缩放和画布偏移后面进阶部分会讲鼠标事件拿到的屏幕坐标就必须先换算成画布坐标再拿去和Shape的Bounds计算。换算公式很简单PointF screenToCanvas(Point screenPoint) { return new PointF( (screenPoint.X - viewportOffset.X) / zoomRate, (screenPoint.Y - viewportOffset.Y) / zoomRate ); }我建议在MouseDown的第一行就做这个换算后续所有拖拽逻辑全部用画布坐标计算。这一步不做等加了缩放功能再回头改定位Bug会非常痛苦。3.4 拖拽流程三步走拖拽的完整逻辑分为按下、移动、抬起三步但每一步都有要注意的细节。按下MouseDown核心是初始化EditContext记录起始鼠标坐标、被选中Shape的原始Bounds、判定拖拽类型。另外按下时如果没按Shift要把之前选中的其他图形取消选中并把当前图形设为选中。移动MouseMove第一步通过EditContext判断当前是否处于拖拽中。如果是移动类拖拽算出鼠标位移向量(dx, dy)然后遍历所有选中的图形调用Move(dx, dy)更新Bounds。如果是缩放类拖拽就根据拖拽方向重建Bounds矩形——比如拖右下角用当前鼠标点作为新的右下角左上角保持不动。移动过程中要实时重绘画布。这里有个性能优化点拖拽过程中的重绘不需要重画所有静态背景和网格线可以先画背景图到离屏Bitmap拖拽时每次Invalidate重绘底图图形层这样图形多的时候也不会闪。抬起MouseUp把EditContext清空触发一个自定义事件ShapeMoved让外部代码有机会做保存、联动更新业务数据等操作。protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); if (editContext.DragType DragType.None) return; PointF canvasPoint screenToCanvas(e.Location); float dx canvasPoint.X - editContext.StartMousePoint.X; float dy canvasPoint.Y - editContext.StartMousePoint.Y; switch (editContext.DragType) { case DragType.Move: foreach (var shape in selectedShapes) { shape.Bounds new RectangleF( editContext.StartBounds[shape.Id].X dx, editContext.StartBounds[shape.Id].Y dy, editContext.StartBounds[shape.Id].Width, editContext.StartBounds[shape.Id].Height ); } break; case DragType.ResizeRightBottom: var sb editContext.StartBounds[selectedShapes[0].Id]; selectedShapes[0].Bounds new RectangleF( sb.X, sb.Y, canvasPoint.X - sb.X, canvasPoint.Y - sb.Y ); break; } UpdateConnections(); // 连接线位置同步更新后面讲 Invalidate(); }移动时为什么按位移量而不是直接把鼠标点赋给图形位置因为多选时一个位移量可以同步应用给所有选中的图形如果是直接赋值还得记住每个图形按下时的起点多选状态下计算量更大。多选场景先按下Shift再点选多个图形这套方案完全兼容。3.5 缩放手柄的绘制哲学给选中图形画缩放手柄时我建议画两套样式鼠标悬停在手柄上时手柄放大变色提示用户这里可以拉其他时候只要一个1像素的蓝色小方块就行。这个细节非常影响专业感Visio就是这么做的。手柄的坐标计算是基于Bounds的四个角和四条边中点存储为一个ListRectangleF用枚举标记每个手柄对应的操作类型移动到某个方向。这样画的时候遍历列表画方块命中测试时遍历列表判断点是否落在某个方块内。4. 连接线撑起交互感的半条命没有连接线的流程图是不完整的。Visio里最让人上瘾的交互就是从一个图形的锚点拖出一条线松手落在另一个图形的锚点上线自动连接。这部分实现起来其实比图形拖拽更讲究。4.1 锚点与连线的数据模型锚点的本质是相对于Shape的位置。我用的定义是锚点属于某个Shape记录它在Shape Bounds中的相对位置比如左侧中点、右侧中点、顶部中点、底部中点。之所以存相对位置是因为Shape移动后锚点的世界坐标要跟着算存绝对坐标就废了。public class AnchorPoint { public string Id { get; set; } public ShapeBase OwnerShape { get; set; } public AnchorDirection Direction { get; set; } // Left, Right, Top, Bottom public PointF GetWorldPosition() { RectangleF b OwnerShape.Bounds; switch (Direction) { case AnchorDirection.Left: return new PointF(b.Left, b.Top b.Height / 2); case AnchorDirection.Right: return new PointF(b.Right, b.Top b.Height / 2); case AnchorDirection.Top: return new PointF(b.Left b.Width / 2, b.Top); case AnchorDirection.Bottom: return new PointF(b.Left b.Width / 2, b.Bottom); } } }连接线的数据模型更简单两个端点各自绑定一个锚点引用。FromAnchor和ToAnchor各自指向所属Shape和锚点方向。存储引用而不是坐标的好处是移动任一端的Shape连线自动知道端点的新位置不需要手动维护坐标。4.2 动态画线的交互细节画线交互分两个阶段。按下阶段用户先点中某个图形的锚点命中测试命中锚点区域进入画线中状态移动阶段要画一条临时线一端固定在起始锚点另一端跟着鼠标走抬起阶段判断鼠标是否落在另一个图形的锚点上如果是正式创建连接线否则取消画线。临时线的绘制在OnPaint里单独处理如果处于画线状态用虚线画从起始锚点到鼠标位置的线加一个轻微的阴影效果视觉上区分未生效和已连接。还有一个细节当鼠标悬停在可连接的锚点上时给锚点画一个放大的光环提示用户可以松手了。这个反馈极其重要没有它用户不知道松手能不能连上。4.3 连线绘制直线还是贝塞尔曲线连线用曲线还是直线是影响观感的重要决策。Visio里连线默认走直角拐弯正交路由但我们自己实现的轻型编辑器做正交路由算法的成本太高不值得。折中方案是用二次贝塞尔曲线。当起点和终点在水平方向上分离时控制点取两个端点的水平中点这样能得到一段平滑的从一侧绕出、另一侧接入的曲线在垂直方向分离时同理。判断依据是水平间距和垂直间距哪个大就沿着哪个方向取控制点。这种曲线画出来视觉上和Visio的连线神似但代码量只有十来行。private void DrawConnection(Graphics g, ConnectionLine line) { PointF start line.FromAnchor.GetWorldPosition(); PointF end line.ToAnchor.GetWorldPosition(); float horizontalDistance Math.Abs(end.X - start.X); float verticalDistance Math.Abs(end.Y - start.Y); PointF control1, control2; if (horizontalDistance verticalDistance) { float midX (start.X end.X) / 2; control1 new PointF(midX, start.Y); control2 new PointF(midX, end.Y); } else { float midY (start.Y end.Y) / 2; control1 new PointF(start.X, midY); control2 new PointF(end.X, midY); } using (GraphicsPath path new GraphicsPath()) { path.AddBezier(start, control1, control2, end); g.DrawPath(line.Pen, path); } }4.4 连线实时更新的实现连线要跟随图形的移动实时更新这是拖拽交互里最容易卡顿的地方。我用的方案是在图形的Move方法被调用后也就是OnMouseMove里更新完Bounds之后统一调用一个UpdateConnections()方法。这个方法遍历所有连接线对每条线判断它两端的锚点归属的Shape是否被移动了通过Shape.Id和移动前记录的Bounds对比如果变了就触发重绘。这样就不用在每个图形的移动代码里单独通知连线刷新集中处理的性能更好。图形上百个、连线几十条时依然流畅。另外连接线本身也需要命中测试。用户可能想点选一条线、拖动一条线的控制点调整弯曲程度。这个我建议用简化方案计算鼠标点到曲线路径的最短距离小于阈值6像素就判定命中。实现上用GraphicsPath的IsOutlineVisible方法和图形的IsVisible用法类似代码简洁可靠。5. 工业场景的进阶玩法缩放、吸附与图层如果只是拖得动、连得上做到第4章其实已经能交付了。但工业组态和流程图工具里能用和好用之间还隔着一屏滚轮缩放、边缘吸附、图层管理。这三个功能我强烈建议一开始就考虑好架构不然后面加会伤筋动骨。5.1 滚轮缩放和画布平移的实现滚轮缩放的核心是锚定鼠标所在位置。不能只改zoomRate然后直接重绘那样缩放的焦点会跑到左上角去用户得不断调整视图。正确方式是缩放前后保持鼠标指向的画布坐标点不变。公式推导简单说一下先算缩放前鼠标指向的画布坐标p_before (screen - offset) / zoomBefore缩放后设新offset让(screen - offset_new) / zoomAfter p_before成立解出offset_new即可。private void OnMouseWheel(MouseEventArgs e) { double oldZoom zoomRate; double newZoom oldZoom * (e.Delta 0 ? 1.1 : 0.9); newZoom Math.Max(0.1, Math.Min(5.0, newZoom)); // 限制缩放范围 PointF oldCanvasPoint screenToCanvas(e.Location); zoomRate newZoom; viewportOffset new PointF( e.Location.X - (oldCanvasPoint.X * newZoom), e.Location.Y - (oldCanvasPoint.Y * newZoom) ); Invalidate(); }有了这个坐标系画布平移就简单了按下鼠标中键或按住空格左键拖动时修改viewportOffset的值即可。5.2 边缘吸附让流程图整洁的关键吸附机制我本来以为是锦上添花的实际交付后被客户夸得最多的反而是它。自动对齐后整个画布上的图形排列整齐不会出现歪歪扭扭的线。实现逻辑不复杂在OnMouseMove里对移动后的每个Shape的Bounds位置做一次修正。修正规则是如果左边缘、中心点、右边缘与某个参考对象画布网格线、其他图形的对应边缘的距离小于吸附阈值一般取值4到8像素就把边缘坐标咔到对齐的位置上。private float Snap(float value, float target, float threshold) { if (Math.Abs(value - target) threshold) return target; return value; }性能上要注意图形多时每个图形都要和其他所有图形比对对齐位置复杂度是O(n^2)。图形少几十个还好上百个时建议只比对最近的两三个图形或者加一个网格索引只搜索临近网格里的图形。5.3 图层与选中优先级画布上图形是有层次关系的最直观的体现是后加的在上面选中的视图要在最上面。实现图层最简单的方式是给ShapeBase加一个ZIndex整数属性绘制前按ZIndex排序后画。但这里有个很容易被忽略的细节合并的流程里选中态的可视反馈不能影响后续事件的生命周期。也就是说被选中的图形要在所有未选中的图形都画完之后再画一遍选中框保证选中框一定在顶层。绘制流程可以设计成两遍第一遍画所有Shape本体第二遍画所有选中框和缩放手柄。另外在MouseDown里如果点中的是空白区域应该取消所有选中。这个习惯可以避免很多操作疑惑——否则用户点了画布空白上一个图形还是选中状态再拖动时就会误移动它。6. 踩坑实录与效率工具建议写到这里我把这套方案从零开始实现过程中遇到的真实问题和最终解决办法列出来都是常规教程里不会写的东西。6.1 控件闪烁问题的根源和解法自绘控件最常见的反馈是画面闪尤其是拖拽动态更新过程中。闪烁的根源是OnPaint里清屏和重绘之间Windows默认的背景擦除OnPaintBackground把整个控件刷成背景色然后绘制内容一擦一画之间就会闪。标准解法是开启双缓冲。两个层面一是设置this.DoubleBuffered trueWinForms会建立一个后台缓冲区先把所有内容画到缓冲区再一次性拷贝到屏幕二是如果需要更精细的控制可以自己创建一张离屏Bitmap在Bitmap上画完再DrawImage到屏幕。实测下来前者对大部分场景足够后者在图形特别多时能明显减少重绘耗时。6.2 GDI对象销毁的坑Pen和Brush千万别裸newGDI对象Pen、Brush、Font、GraphicsPath是托管对象但底层封装的是Windows GDI句柄不释放就是泄漏。在OnPaint里直接new Pen(...)然后不Dispose跑上一天系统句柄数会飙升最终导致内存不足无法创建GDI对象的崩溃。我的经验有两条一是定义画笔和画刷时尽量存为Shape的属性初始化时创建一次后面复用二是必须临时创建的一律用using语句包裹。特别是连线重绘时每条线创建一个Pen用using包起来用完立即释放配合GC.WaitForPendingFinalizers在某次重大刷新后手动调用一次资源回收非常稳定。6.3 处理缩放时的GDI文字模糊问题滚轮缩放后文字的清晰度容易出问题。默认的Graphics文字渲染模式在缩放比例不是整数时会出现毛边。第二个坑是在缩放模式下Font的Size属性是绝对像素如果画布缩小到50%文字也会跟着变成原来的一半大小视觉上非常小。正确做法是缩放时文字大小保持不变或者按缩放比例反向补偿。在绘制文字前先用Graphics.ScaleTransform把坐标系整体缩放再在DrawString时用一个补偿后的字体实际字体大小 设计字号 / 缩放比例。这个补偿逻辑我在项目里改动了好几次最后选了界面缩放只影响图形区域文字字号固定的方案——虽然Visio是文字跟着图形一起缩放但工业软件里固定字号更符合标注习惯客户反馈也更好。6.4 拖拽时的半透明预览与橡皮筋框选做多选框选时我推荐用橡皮筋方案按住鼠标拖动一个矩形选区松手后选中所有与选区相交的图形。绘制时用半透明蓝色填充选区的矩形存成临时变量在OnPaint里优先画。半透明填充的实现是用SolidBrush(Color.FromArgb(60, 51, 153, 255))这个alpha值是踩坑试出来的——太浅看不清太深会遮挡底下的图形。多选框选加上Ctrl/Shift组合交互体验就很接近Visio了成本也只是一次IntersectsWith遍历。7. 最后的架构建议这套代码怎么组织才能长期维护单独实现拖拽逻辑是一回事把它放进一个完整的项目里是另一回事。我在交付过多个拖着图形的项目后总结出几个架构层面的建议。第一画布控件和业务逻辑解耦。绘制和交互逻辑放在一个自定义CanvasControl里但它不直接知道电机还是阀门——所有业务信息都通过ShapeBase.DataObject传递。这样下次项目换一套业务实体CanvasControl一行不用改。第二持久化一定要做版本控制。流程图保存成XML或JSON时每条记录都带上SchemaVersion字段。拖拽编辑器迭代很快今天加个属性明天加个类型没有版本号的存档升级时就是一场灾难。第三命令模式值得提前投入。接收拖拽后用户一定会要求撤销/重做。如果一开始就封装MoveCommand、AddCommand、DeleteCommand这几个命令类后面扩展成本极低。我在第一个项目里偷懒没做结果客户提需求时改动了所有交互代码教训深刻。第四内部工具用WinForms没有心理负担。虽然WPF在视觉表现和数据绑定上更强但WinForms在工控和内部工具领域依然生命力顽强学习成本低、生态成熟如果团队本来就以WinForms为主力栈为这一个模块引入WPF得不偿失。当然如果你是新项目且UI要求高用WPF重写这套思路完全可行对象模型和交互逻辑是可以直接平移的。当年第一次做完这种编辑器时我最大的感慨是类似Visio的拖拽功能并没有想象中那么高不可攀但它的复杂度也不容小觑。把对象模型设计好、坐标换算理清楚、刷新策略做对剩下的就是耐心和细节了。希望这篇能帮你少走几段我走过的弯路。本文还有配套的精品资源点击获取