首页 / 资讯中心 / 文章详情

Android Path绘制核心:moveTo、lineTo、quadTo、cubicTo、arcTo详解

Android Path绘制核心:moveTo、lineTo、quadTo、cubicTo、arcTo详解 ★ FEATURED ARTICLE
自定义View是Android开发里少数几个看起来门槛不高、真动手就露怯的领域。很多人第一次画图脑子里想的是不就是画条线吗结果代码跑起来屏幕上要么一片空白要么图形跑到屏幕外面去了要么曲线画出来跟预期差了十万八千里。问题往往不在画笔Paint上而是卡在Path的几个基础绘制方法上——moveTo、lineTo、quadTo、cubicTo、arcTo。这五个方法构成了Android 2D绘图的骨架理解它们的坐标系语义、参数含义和连接规则比背一堆API文档有用得多。这篇内容面向的是已经能跑起一个自定义View、但对Path绘制细节还比较模糊的开发者。我会把这五个方法拆开讲透每个方法都配上可直接运行的实例代码、参数推导过程和实测效果说明同时把那些文档里不会写、只有踩过才知道的坑一并交代清楚。读完之后你应该能对着任意一个图形需求快速判断该用哪个方法、参数怎么算、边界怎么控。1. 先把坐标系这件事钉死在讲任何绘制方法之前必须先把Android Canvas的坐标系讲清楚因为后面所有参数的计算都建立在这个基础之上。我见过太多人画曲线画歪了回头一查是把数学课本上的y轴方向直接搬过来了。1.1 y轴向下这是所有困惑的源头Android的Canvas坐标系原点(0,0)在View的左上角x轴向右为正y轴向下为正。这一点和我们在数学课上习惯的笛卡尔坐标系完全相反。在数学坐标系里y增大是往上走在Android里y增大是往下走。这个差异带来的直接影响是当你写lineTo(100, 100)的时候终点在起点的右下方而不是右上方。画一条从左下到右上的斜线你需要的是起点y值大、终点y值小。很多人在纸上推演得好好的一写代码方向就反了根源就在这里。我建议你在调试阶段养成一个习惯在onDraw里先画一个十字参考线把View的中心点和四边中点标出来这样每次画歪了能立刻定位是哪个坐标算错了。代码很简单// 在onDraw里临时加上调试完删掉 Paint guidePaint new Paint(); guidePaint.setColor(Color.RED); guidePaint.setStrokeWidth(1); canvas.drawLine(0, getHeight() / 2f, getWidth(), getHeight() / 2f, guidePaint); canvas.drawLine(getWidth() / 2f, 0, getWidth() / 2f, getHeight(), guidePaint);1.2 Path是笔不是图形另一个需要提前建立的心智模型是Path记录的是绘制指令序列而不是最终的图形对象。你调用moveTo、lineTo本质上是在给一支虚拟的笔下达抬笔移动到某点落笔画到某点的指令。Path内部维护着一个当前点current point后续的绘制方法都从这个当前点出发。这个模型解释了很多现象。比如你连续调用两次lineTo第一条线的终点会自动成为第二条线的起点因为笔没有抬起来。而moveTo的作用就是抬笔把当前点挪到新位置但不产生任何可见的线条。理解当前点这个概念是理解后面所有方法连接行为的关键。提示Path的当前点是一个隐式状态API没有直接提供getter。如果你需要知道当前点在哪只能自己在代码里维护一份记录或者用PathMeasure间接推算。2. moveTo与lineTo直线绘制的两个基本动作这两个方法最简单但也最容易被想当然地误用。它们的组合几乎能画出所有多边形和折线是Path绘制的起点。2.1 moveTo的本质是抬笔定位moveTo(float x, float y)做的事情只有一件把当前点移动到(x, y)不画任何东西。它的返回值是void调用后Path内部状态更新但视觉上没有任何变化。单独一个moveTo是没有意义的它必须和后续的绘制方法配合。它的典型用途有三个确定起始点在画任何图形前先把笔放到起点。断开连续线条画完一段折线后用moveTo跳到新位置开始画另一段避免两段之间被一条多余的线连起来。重置绘制状态在某些复杂图形里用moveTo回到一个已知点简化后续坐标计算。这里有个新手常犯的错误以为moveTo之后必须马上lineTo。实际上moveTo之后可以接任何绘制方法包括quadTo、cubicTo、arcTo它们都会以moveTo设定的点为起点。2.2 lineTo的连续性与断线技巧lineTo(float x, float y)从当前点画一条直线到(x, y)并把当前点更新为(x, y)。连续调用会形成折线这是它的默认行为。画一个三角形的代码如下Path path new Path(); path.moveTo(200, 100); // 顶点 path.lineTo(100, 300); // 左下 path.lineTo(300, 300); // 右下 path.close(); // 闭合回顶点注意最后的close()它会从当前点画一条线回到Path的起始点第一次moveTo的位置形成一个闭合图形。如果不调用close()三角形会缺一条边。close()在填充fill模式下尤其重要因为未闭合的Path在填充时系统会自动用一条虚拟线闭合但描边stroke模式下这条线是不显示的容易造成填充和描边不一致的困惑。如果你要画两个不相连的三角形必须在第二个三角形开始前调用moveToPath path new Path(); // 第一个三角形 path.moveTo(200, 100); path.lineTo(100, 300); path.lineTo(300, 300); path.close(); // 第二个三角形必须moveTo断开 path.moveTo(500, 100); path.lineTo(400, 300); path.lineTo(600, 300); path.close();漏掉第二个moveTo的后果是第一个三角形的闭合点会和第二个三角形的起点之间多出一条线图形粘在一起。这个坑我在早期项目里踩过不止一次尤其是循环批量生成图形的时候特别容易忘。2.3 一个实用的坐标计算套路手写坐标容易出错尤其是图形需要居中、需要按比例缩放的时候。我的做法是先定义图形的逻辑坐标比如以0到1的相对值表示再在绘制时映射到实际像素。这样图形逻辑和屏幕尺寸解耦适配不同分辨率时不用改绘制代码。float w getWidth(); float h getHeight(); // 逻辑坐标(0.5, 0.2)映射到实际像素 float px 0.5f * w; float py 0.2f * h; path.moveTo(px, py);这个套路在画曲线时价值更大因为曲线的控制点坐标往往不是整数用相对值表达更直观也更容易调。3. quadTo一个控制点的二次贝塞尔曲线直线画完了接下来是曲线。quadTo是二次贝塞尔曲线只需要一个控制点是入门曲线绘制的最佳起点。3.1 参数结构与曲线形状的对应关系quadTo(float x1, float y1, float x2, float y2)有四个参数前两个是控制点坐标后两个是终点坐标。起点是Path的当前点不需要在参数里指定。曲线的形状由控制点拉扯决定曲线从起点出发朝着控制点的方向弯曲但不经过控制点最终到达终点。控制点离起点和终点的连线越远曲线弯曲得越厉害。这个不经过控制点是贝塞尔曲线的核心特性也是最多人误解的地方——很多人以为曲线会穿过控制点画出来发现不是那么回事。用生活化的比喻想象起点和终点是两根钉子控制点是一块磁铁。曲线是一根有弹性的绳子被磁铁吸引着往控制点方向鼓起来但绳子本身不会碰到磁铁。3.2 手把手画一条对称的弧线假设我们要在View中央画一条向上拱起的弧线起点在左中终点在右中控制点在正上方。代码和坐标推导如下float w getWidth(); float h getHeight(); Path path new Path(); path.moveTo(w * 0.1f, h * 0.5f); // 起点左侧中间 path.quadTo(w * 0.5f, h * 0.1f, // 控制点正上方偏上 w * 0.9f, h * 0.5f); // 终点右侧中间 canvas.drawPath(path, paint);控制点的y值取h * 0.1f比起点和终点的h * 0.5f小因为y轴向下y值小意味着位置高所以曲线向上拱。如果你把控制点y改成h * 0.9f曲线就会向下凹。这个方向判断是新手最容易搞反的地方记住y小在上就不会错。曲线的最高点顶点并不在控制点的y值处而是在起点、控制点、终点三者构成的抛物线的极值点。对于对称的情况起点和终点y相同顶点y值约等于(起点y 控制点y) / 2。这个近似值在调参时很有用能帮你快速估算曲线实际能鼓多高。3.3 quadTo在真实UI里的典型用法二次贝塞尔曲线在实际项目里出现频率很高几个典型场景聊天气泡的尾巴气泡底部那个小尖角用quadTo画两条对称曲线比用直线拼更自然。波浪线动画多个quadTo首尾相接形成连续的波浪配合属性动画平移就能做出流动效果。下拉刷新时的弧形指示器弧线的弯曲程度随下拉距离变化本质是动态调整控制点位置。以波浪线为例核心思路是把一个完整波长拆成上拱和下凹两段每段一个quadTofloat waveWidth 200; // 一个波长 float waveHeight 50; // 波峰到中线的距离 float midY h / 2f; Path path new Path(); path.moveTo(0, midY); // 上拱 path.quadTo(waveWidth / 4f, midY - waveHeight, waveWidth / 2f, midY); // 下凹 path.quadTo(waveWidth * 3f / 4f, midY waveHeight, waveWidth, midY);这里控制点的x取在每段的四分之一处是因为二次贝塞尔曲线在参数t0.5时的点其x坐标约等于起点、控制点、终点x的平均值。把控制点放在段中点能让波峰出现在段的中央视觉上更对称。4. cubicTo两个控制点的三次贝塞尔曲线cubicTo是quadTo的升级版用两个控制点能画出更复杂的S形曲线。它的参数更多但逻辑一脉相承。4.1 两个控制点如何分工cubicTo(float x1, float y1, float x2, float y2, float x3, float y3)有六个参数前四个是两个控制点后两个是终点。起点依然是当前点。两个控制点的分工可以这样理解第一个控制点主要影响曲线出发时的方向和弯曲程度第二个控制点主要影响曲线到达时的方向和弯曲程度。当两个控制点位于起点终点连线的同侧时曲线呈C形位于两侧时曲线呈S形。这个特性让cubicTo特别适合画平滑的连接线。比如流程图里两个节点之间的连线如果希望线条从节点A水平出发、水平进入节点B就把第一个控制点放在A的正右方、第二个控制点放在B的正左方曲线自然就平滑了。4.2 画一条标准S形曲线的参数推导S形曲线的关键是两个控制点分居连线两侧。假设起点在左下、终点在右上我们要一条先下凹再上拱的S形float w getWidth(); float h getHeight(); Path path new Path(); path.moveTo(w * 0.1f, h * 0.7f); // 起点左下 path.cubicTo( w * 0.3f, h * 0.9f, // 控制点1起点右下方把曲线往下拉 w * 0.7f, h * 0.1f, // 控制点2终点左上方把曲线往上拉 w * 0.9f, h * 0.3f // 终点右上 );控制点1在起点下方y更大曲线出发时向下弯控制点2在终点上方y更小曲线到达时从上方下来。两个控制点一上一下形成S形。如果你把两个控制点都放在连线上方曲线就变成单纯的C形上拱。调参时的经验法则控制点离起点终点连线越远该侧的弯曲越夸张控制点沿连线方向的投影位置决定了弯曲最剧烈的位置。想让曲线在靠起点的地方弯得急就把控制点1往起点方向收想让它在中段平缓就把两个控制点都往中间放。4.3 cubicTo与quadTo的选型判断什么时候用quadTo什么时候用cubicTo我的判断标准很直接场景特征推荐方法理由单方向弯曲形状简单quadTo参数少调参快一个控制点够用需要S形或双向弯曲cubicTo两个控制点才能表达反向弯曲需要精确控制出发/到达方向cubicTo两个控制点分别管两端切线方向性能敏感的大量曲线quadTo计算量略小Path内部处理更轻从数学上说cubicTo能表达的形状是quadTo的超集任何quadTo能画的曲线cubicTo都能画把两个控制点放在同一点即可退化。但参数多意味着调参成本高能用quadTo解决的绝不上cubicTo这是我多年下来的习惯。注意cubicTo的两个控制点如果重合曲线退化为二次贝塞尔如果控制点和端点重合曲线退化为直线。这些退化情况在动画插值时偶尔会出现要留意视觉上的突变。5. arcTo圆弧绘制的参数陷阱arcTo是这五个方法里参数最复杂、坑最多的一个。它画的是椭圆弧参数涉及一个矩形边界框和起始角度、扫过角度还有forceMoveTo这个容易忽略的布尔参数。5.1 椭圆边界框与角度的关系arcTo(RectF oval, float startAngle, float sweepAngle)的三个核心参数oval一个矩形圆弧内切于这个矩形形成的椭圆。注意是内切椭圆不是矩形本身。startAngle起始角度单位是度0度在三点钟方向顺时针为正。sweepAngle扫过的角度正数顺时针负数逆时针。角度系统是arcTo最大的坑。数学课上0度在x轴正方向、逆时针为正而Android这里0度虽然也在三点钟方向x轴正方向但顺时针为正。这意味着你写startAngle90起点在六点钟方向正下方而不是十二点钟方向。这个差异导致无数人画圆弧时方向反了。画一个从三点钟开始、顺时针扫90度的四分之一圆弧RectF oval new RectF(100, 100, 500, 500); // 400x400的边界框 Path path new Path(); path.arcTo(oval, 0, 90);这段代码画出的弧线起点在边界框右侧中点终点在边界框底部中点弧线在右下方。如果你想要左上方的弧线起始角度应该是180扫过90度。5.2 forceMoveTo参数决定连接行为arcTo有一个重载版本带forceMoveTo参数arcTo(RectF oval, float startAngle, float sweepAngle, boolean forceMoveTo)。这个参数决定了圆弧和Path当前点之间是否连线。forceMoveTo true相当于先moveTo到圆弧起点再画弧。圆弧和之前的路径不相连。forceMoveTo false从当前点画一条直线到圆弧起点再画弧。圆弧和之前的路径相连。默认的两参数版本行为等同于forceMoveTo false。这个默认值经常导致意外你在Path里画了一段线然后调用arcTo结果发现线和弧之间多了一条直线就是因为默认会连接。Path path new Path(); path.moveTo(100, 100); path.lineTo(300, 100); // 默认forceMoveTofalse会从(300,100)连线到圆弧起点 path.arcTo(new RectF(300, 100, 500, 300), 180, 90);如果不想连线必须显式传truepath.arcTo(new RectF(300, 100, 500, 300), 180, 90, true);这个细节在画圆角矩形、扇形、进度环时特别关键。画进度环时通常希望每段弧独立不互相连线所以要用true画圆角矩形的圆角时希望圆角和直边平滑连接反而要用false。5.3 用arcTo画圆角矩形的完整实例圆角矩形是arcTo最经典的实战场景。思路是四条直边用lineTo四个圆角用arcTo交替进行。以左上角圆角为例边界框是(left, top, left2r, top2r)起始角度180度扫过90度。float left 100, top 100, right 500, bottom 400; float r 50; // 圆角半径 Path path new Path(); // 从左上角圆角结束处开始顶部直边起点 path.moveTo(left r, top); // 顶部直边 path.lineTo(right - r, top); // 右上角圆角边界框(right-2r, top, right, top2r)起始270度 path.arcTo(new RectF(right - 2 * r, top, right, top 2 * r), 270, 90); // 右侧直边 path.lineTo(right, bottom - r); // 右下角圆角起始0度 path.arcTo(new RectF(right - 2 * r, bottom - 2 * r, right, bottom), 0, 90); // 底部直边 path.lineTo(left r, bottom); // 左下角圆角起始90度 path.arcTo(new RectF(left, bottom - 2 * r, left 2 * r, bottom), 90, 90); // 左侧直边 path.lineTo(left, top r); // 左上角圆角起始180度 path.arcTo(new RectF(left, top, left 2 * r, top 2 * r), 180, 90); path.close();四个圆角的起始角度分别是270、0、90、180对应右上、右下、左下、左上顺时针递增。这个角度序列是固定的记住它就能套用到任意尺寸的圆角矩形。实际项目里当然可以直接用addRoundRect但手写一遍能让你彻底理解arcTo的角度逻辑遇到非标准圆角比如只有两个角是圆的时就知道怎么改了。6. 五个方法的组合与常见故障排查单独掌握每个方法只是第一步真实项目里往往是多个方法混用。这一节讲组合技巧和排查思路。6.1 当前点状态是排查的第一线索Path出问题时第一个要查的就是当前点在哪。因为所有绘制方法都依赖当前点当前点错了后面全错。排查方法在每次关键绘制调用后用PathMeasure或者手动记录的方式确认当前点位置。一个实用的调试技巧是分段绘制把复杂Path拆成几段每段用不同颜色的Paint画出来看哪一段的位置不对。颜色区分能让你一眼看出问题出在哪个方法上。// 分段调试不同段用不同颜色 Paint p1 new Paint(); p1.setColor(Color.RED); p1.setStyle(Paint.Style.STROKE); // 第一段用红色画第二段用蓝色画以此类推6.2 填充模式下的自相交问题当Path有自相交线条交叉时填充结果依赖Path.FillType。默认是WINDING交叉区域是否填充取决于路径方向EVEN_ODD则是交叉区域交替填充。画复杂图形比如五角星、环形时如果填充结果和预期不符先检查FillType。path.setFillType(Path.FillType.EVEN_ODD);五角星是典型例子用lineTo依次连接五个顶点中间会自相交WINDING模式下中间五边形被填充EVEN_ODD模式下中间五边形是空的。想要空心五角星就用EVEN_ODD。6.3 常见故障对照表现象可能原因排查方向图形完全不可见坐标超出View边界 / Paint未设置STROKE检查坐标范围确认Paint样式曲线方向反了y轴方向理解错误确认控制点y值与预期上下关系多出意外的连线缺少moveTo / arcTo的forceMoveTo为false检查断点处是否抬笔圆弧起点位置不对角度系统顺时针为正重新核对startAngle填充结果异常FillType设置不当尝试切换WINDING/EVEN_ODD图形被裁剪未考虑View的padding坐标计算加入padding偏移这张表覆盖了我实际遇到过的绝大多数Path绘制问题按这个顺序排查基本能在几分钟内定位。6.4 性能上的几个实测结论关于这五个方法的性能我做过一些简单对比结论供参考lineTo最快quadTo次之cubicTo再次arcTo因为涉及椭圆参数化相对最慢。但在单个Path里几十个方法的量级下差异可以忽略。真正的性能瓶颈在onDraw里的对象创建。RectF、Paint这些对象应该在构造时创建好不要在onDraw里new否则每帧都触发GC滑动时明显卡顿。如果Path内容不变用canvas.drawPath每次重绘没问题如果Path每帧都变动画考虑用PathMeasure做插值或者把Path缓存成Bitmap。// 错误示范onDraw里new对象 Override protected void onDraw(Canvas canvas) { Paint paint new Paint(); // 每帧new性能杀手 RectF rect new RectF(); // 同上 // ... } // 正确做法成员变量复用 private final Paint paint new Paint(); private final RectF rect new RectF();这个细节看起来基础但我在代码评审里见过太多次尤其是从教程里抄来的代码教程为了简洁往往在onDraw里直接new照抄进项目就是隐患。7. 从这五个方法延伸出去的实战思路掌握了基础方法之后真正的能力体现在组合运用上。这里分享几个我常用的延伸套路。7.1 用PathMeasure做路径动画PathMeasure能把Path拉直成一条线按长度取点这是做路径动画的核心工具。比如让一个小球沿着cubicTo画的曲线运动PathMeasure measure new PathMeasure(path, false); float length measure.getLength(); float[] pos new float[2]; // 在动画进度progress(0~1)处取点 measure.getPosTan(length * progress, pos, null); canvas.drawCircle(pos[0], pos[1], 10, ballPaint);这个套路在加载动画、引导线、轨迹绘制里非常常用。getPosTan的第三个参数还能拿到该点的切线方向用来让运动物体自动朝向做箭头沿路径移动的效果特别方便。7.2 曲线控制点的动态调整quadTo和cubicTo的控制点坐标可以是变量配合属性动画就能做出呼吸波动效果。比如让波浪线的控制点y值随时间正弦变化float offset (float) Math.sin(animProgress * Math.PI * 2) * 30; path.quadTo(waveWidth / 4f, midY - waveHeight offset, waveWidth / 2f, midY);这种动态控制点是很多高级UI动效的底层原理理解了贝塞尔曲线的控制点语义这类效果就是调参问题不再是黑魔法。7.3 复杂图形的拆解思路遇到一个复杂图形我的拆解流程是先判断整体轮廓由几段直线、几段曲线、几段圆弧组成。直线段用lineTo单弯曲线用quadToS形用cubicTo标准圆弧用arcTo。确定每段的起点终点确保上一段的终点等于下一段的起点除非需要moveTo断开。圆弧段单独计算边界框和角度边界框的宽高比决定椭圆的扁圆程度。最后用close()闭合检查FillType。这个流程走下来再复杂的图形也能拆成可执行的方法调用序列。我画过最复杂的一个图形是一个带缺口和内部镂空的徽章拆下来是12段直线、4段圆弧、2段曲线按这个流程一步步来没有返工。7.4 一个容易忽略的细节抗锯齿与描边宽度最后提一个和绘制方法无关但影响观感的点。曲线和圆弧在低分辨率下容易出现锯齿记得给Paint开抗锯齿paint.setAntiAlias(true);另外描边模式下线条有宽度lineTo画的线是以路径为中心向两侧各扩展半个线宽。这意味着如果你画一个紧贴View边界的矩形描边会被裁掉一半。解决办法是在坐标计算时预留线宽的一半作为内边距或者用canvas.clipRect配合调整。这个细节在画边框、进度条时经常被忽略导致边缘看起来缺了一块。我在实际项目里处理Path绘制最大的体会是这五个方法的API本身十分钟就能看完但真正把它们用对、用出效果靠的是对坐标系、当前点状态、角度系统这几个隐性规则的反复确认。每次画新图形前先在纸上把关键点的坐标和角度标出来比直接在代码里试错快得多。尤其是arcTo的角度纸上标一遍能省掉半小时的调试。
阅读完成 · 觉得有帮助?
咨询建站