☰
Android PathMeasure实战:从贝塞尔曲线到轨迹动画的完整指南
2026/10/17 1:13:37 网站建设 项目流程

前一阵子接了个加购动画需求,要求商品图标从列表位置“飞”进底部购物车,还得是一条自然弧线,速度还不能忽快忽慢。我当时第一反应是用 ValueAnimator 同时改 x、y 两个属性,再套个二次函数公式,结果调了半天,换台机型表现又不一样,后来老老实实回去翻 Android 的 Path 和 PathMeasure 才把问题彻底解决。这篇就是我完整练习 Path + PathMeasure 动画后沉淀下来的记录,除了解释原理,还包含三条可直接运行的动画 Demo(线条描出、小球沿贝塞尔曲线运动、加载圆环组合效果)和几个真实踩过的坑,适合已经会自定义 View 基础操作、想进一步做轨迹类动画的 Android 开发者参考。

做这类动画,最怕的就是"看着原理简单,一落代码全是意外"。所以我尽量把每个 API 的边界条件和心理模型讲清楚,再附上完整可跑代码,你直接新建一个 View 粘贴进去就能看到效果。

1. 为什么做动画做到"沿轨迹运动"就卡住了

1.1 属性动画的短板:只改数值,回答不了"方向在哪"

Android 原生属性动画很强,但它解决的是"数值随时间变化"这件事。比如 ObjectAimator 让一个 View 的 translationX、translationY 从 0 变到 100,这本质是两个独立轴上的线性插值。一旦你要求"物体沿着一条曲线走",并且还要让物体的头部始终朝向曲线的切线方向,仅仅靠修改 x、y 是不行的,因为这两个值之间没有路径的上下文关联。

自己写贝塞尔公式也可以,比如三阶贝塞尔曲线:

  • B(t) = (1-t)^3 * P0 + 3*(1-t)^2t * P1 + 3(1-t)*t^2 * P2 + t^3 * P3

但这里有个隐藏问题:t 是参数,不是弧长。同样增加 0.01 的 t,在曲线平缓的地方对应的实际距离很大,在弯道处对应的实际距离又很小,表现出来就是小球"忽快忽慢"。要修正还得再去求弧长积分,或者用二分法做参数重映射,这就把简单问题复杂化了。PathMeasure 正是为了解决"给定路径,按距离取值"而存在的。

1.2 这次练习我给自己定的三个小目标

我这次没有一上来就写一个大而全的动画框架,而是拆成了三个递进的小案例:

  1. 用 getSegment 做"线条一笔一画被描出来"的动画
  2. 用 getPosTan 让一个小球沿着贝塞尔曲线运动,并实时计算朝向
  3. 用 getMatrix + 多轮廓遍历做加载圆环、轨迹跟随、多段路径分配

这三个案例基本覆盖了 PathMeasure 最常用的几个 API:getLength、getSegment、getPosTan、getMatrix、nextContour。练完以后,你会发现做购物车加购动画、手势轨迹复现、自定义加载动画这类需求都会顺畅很多。

1.3 练习前的最低配置

  • 熟悉自定义 View 的基本流程:onMeasure、onDraw、Paint 的基本用法
  • 知道 ValueAnimator 的基础回调方式
  • 不需要引入任何第三方库,系统 API 从 Android 1.0 就有了,兼容性没有问题

我会把 Path 基础也快速过一遍,如果你对 Path 已经滚瓜烂熟,可以直接跳到第三章往后看。

2. Path基础先过一遍:坐标系、方向与常用方法

2.1 View坐标系与路径方向的因果关系

写 Path 之前必须清楚坐标系:View 的坐标系原点在左上角,x 轴向右增大,y 轴向下增大。这和平常在数学课本里的坐标系不一样,所以"向上"是 y 减小,画抛物线拱起时,控制点的 y 值反而要比两端小。

路径的方向也很重要。PathMeasure 根据你调用 moveTo、lineTo、cubicTo 的先后顺序来确定路径方向,getPosTan 返回的切线方向也遵循这个顺序。比如同样一条弧线,你可以顺时针画也可以逆时针画,方向不同会直接影响小球头部的朝向和动画扫过的方向。如果你发现物体头朝反了,第一反应应该是检查路径是不是反着画的。

2.2 高频API速查表

方法作用典型场景
moveTo(x, y)把画笔起点移动到指定坐标路径起点
lineTo(x, y)从当前点画直线到目标点折线、多边形
quadTo(cx, cy, x, y)二阶贝塞尔曲线,一个控制点简单抛物线、弧线
cubicTo(c1x, c1y, c2x, c2y, x, y)三阶贝塞尔曲线,两个控制点复杂曲线、缓动轨迹
close()连接当前点和起点形成闭合闭合图形
addCircle、addArc、addRect直接添加标准几何轮廓圆环、圆角矩形
offset(dx, dy)整体平移路径复制路径后偏移绘制
addPath(path)把另一条路径合并进来组合图形

里面最常用的是 cubicTo,因为两个控制点给了你足够的调整余地:第一个控制点决定起点端的切线方向,第二个控制点决定终点端的切线方向。你可以把控制点想象成"拉绳子两端的手",控制点离得越远,曲线越贴近该端的切线方向。

2.3 绘制Path时Paint的配合项

只定义了 Path 还不够,画在屏幕上的样式由 Paint 决定:

  • 如果想画线条本身,Paint.Style 要设成 STROKE 或 FILL_AND_STROKE,如果误用 FILL,填充逻辑会按"隐式闭合"处理,很容易出现一个你完全没预料到的多边形
  • STROKE 模式下一定要设置 strokeWidth
  • 画笔的 Cap 和 Join 建议设成 ROUND,线条端点和拐角看起来更圆润
  • setPathEffect 配合 DashPathEffect 还能实现虚线效果,这个后面讲到蚂蚁线时会再提

有一点需要养成习惯:View 的 Canvas 默认会裁剪到 View 边界,如果你的路径超出了 View 宽高,超出的部分不会显示。所以路径的坐标最好基于 onSizeChanged 里的宽高动态计算,而不是写死一个像素值。

3. PathMeasure核心方法逐一看,每个坑都说清楚

3.1 PathMeasure是什么,forceClosed参数到底影响什么

PathMeasure 可以理解成一把"沿着路径走的游标卡尺",你能问它三个问题:这条路总长多少、到某个距离处坐标是多少、到某个距离处切线方向是什么。

它的构造函数很直接:

PathMeasure measure = new PathMeasure(); measure.setPath(path, false);

也可以一步到位:

PathMeasure measure = new PathMeasure(path, false);

第二个参数 forceClosed 是个极其容易踩坑的地方。它表示:计算路径长度时,是否把终点到起点的直线距离也算进去。

注意,它只影响测量结果,不影响实际绘制出的图形。举个例子:你画了一段开口向上的 arc,forceClosed 传 true 后,getLength() 返回值会包含"终点连回起点"这一小段直线的长度,但屏幕上并不会真的画出这条线。

如果用它做描边动画,动画最后会突然多出一段"位移判断"——因为总长变长了,但实际画出来的路径却没有对应的那一截,表现就是线条走位突然拧一下。我的经验是:除非你确定路径本身是闭合的,否则 forceClosed 一律传 false。

3.2 getLength:一段话毁掉动画

getLength() 返回当前测量路径的长度,单位是"当前坐标系的单位",在 Canvas 里通常就是像素。

有个特别隐蔽的坑:如果 Path 里有多个轮廓,比如你先 addCircle 又 addRect,那么 getLength() 只返回第一个轮廓的长度。很多新手在"一个 Path 里放两条线"的情况下直接拿 getLength() 当总长,结果动画只画完一半就停了。

这个问题的解法有两个:要么一个 Path 只放一条轮廓,简单干净;要么用 nextContour() 遍历所有轮廓并手动累加总长度,后面我会专门讲。

3.3 getPosTan:取位置、取方向

这是沿路径运动的灵魂方法:

float[] pos = new float[2]; float[] tan = new float[2]; boolean ok = measure.getPosTan(distance, pos, tan); if (ok) { float x = pos[0]; float y = pos[1]; float angle = (float) (Math.atan2(tan[1], tan[0]) * 180.0 / Math.PI); }
  • distance 是你从路径头走到第几个像素的距离,不是进度百分比
  • pos 返回路径上该距离对应的屏幕坐标
  • tan 返回该点处的切线方向向量,可以当作 dx、dy 来用

angle 通常用 Math.atan2(tan[1], tan[0]) 计算,它能处理 tan[0] 等于 0 的情况(也就是垂直方向),不会有除零问题。如果你发现拿到的角度画出来是反的,先检查路径方向。

这个方法也有边界:distance 小于 0 或大于 getLength() 时,方法返回 false,而且 pos、tan 里的内容不会被更新。所以做动画时每帧都要把 distance clamp 到 [0, length]。

3.4 getSegment:描边动画的命根子

getSegment 用于从路径上截取一段子路径:

boolean ok = measure.getSegment(startD, stopD, dst, true);
  • startD、stopD 是起止距离
  • dst 是把截取结果放进去的目标 Path
  • startWithMoveTo 表示是否在截取起点处执行 moveTo

startWithMoveTo 传 true 是最安全的,它会保证截取出来的子路径从起点独立开始。如果传 false,截取出来的片段会尝试接着 dst 当前内容的末尾继续连,一旦 dst 里已经有其他轮廓,就会凭空多出一条连线。

还有个更容易犯的错:dst 每次不会自动清空,多次调用 getSegment 会把结果叠加在一起。所以每次调用前,务必先 dst.reset()。

3.5 getMatrix与多轮廓遍历

如果不想手动算 tan 再转角度,PathMeasure 还提供了 getMatrix:

Matrix matrix = new Matrix(); measure.getMatrix(distance, matrix, PathMeasure.POSITION_MATRIX_FLAG | PathMeasure.TANGENT_MATRIX_FLAG);

第一个 flag 表示让 Matrix 带上路径位置,第二个 flag 表示让 Matrix 带上切线旋转角度。两个 flag 一起传,拿到的矩阵就可以直接用于在对应位置绘制一个有朝向的物体。

多轮廓遍历的固定写法:

float totalLength = 0; PathMeasure pm = new PathMeasure(path, false); do { totalLength += pm.getLength(); } while (pm.nextContour());

注意 nextContour() 在成功跳转到下一个轮廓时返回 true,越界返回 false。遍历完要用相同逻辑重新从第一段开始取值,或者干脆每个轮廓单独建一个 PathMeasure,后者在业务代码里更省心智。

4. 案例一:getSegment做线条描出动画

4.1 从需求到拆解:让线条"自己长出来"

很多加载动画的效果是一条线从无到有慢慢"画"出来,类似手写笔迹。如果直接让动画进度从 0 到 1 去控制某种"绘制百分比",你很难用一个简单数值表示"画到哪了"。但换成 PathMeasure 就非常直白:

  • 先测量整条路径的长度 length
  • 让动画进度 progress 从 0 到 1
  • 每帧截取 [0, length * progress] 这一段画到屏幕上

因为 distance 是沿路径的真实长度,所以这样动画的视觉速度是均匀的,不会出现"弯道加速直道减速"。

4.2 完整代码:一个可直接运行的描边View

public class DrawAlongPathView extends View { private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint ghostPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Path path = new Path(); private final Path drawPath = new Path(); private final PathMeasure measure = new PathMeasure(); private ValueAnimator animator; private float progress; public DrawAlongPathView(Context context) { this(context, null); } public DrawAlongPathView(Context context, AttributeSet attrs) { super(context, attrs); paint.setStyle(Paint.Style.STROKE); paint.setStrokeWidth(dp2px(3)); paint.setStrokeCap(Paint.Cap.ROUND); paint.setStrokeJoin(Paint.Join.ROUND); paint.setColor(0xFF3F7FFF); ghostPaint.set(paint); ghostPaint.setAlpha(40); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); // 尺寸确定后再构造路径,避免dp/px换算在真机上变形 path.reset(); float startX = w * 0.2f; float startY = h * 0.8f; path.moveTo(startX, startY); path.cubicTo(startX, h * 0.5f, w * 0.5f, h * 0.5f, w * 0.8f, h * 0.8f); path.cubicTo(w * 0.9f, h * 0.5f, w * 0.5f, h * 0.2f, w * 0.85f, h * 0.15f); measure.setPath(path, false); startAnimator(); } private void startAnimator() { if (animator != null) { animator.cancel(); } animator = ValueAnimator.ofFloat(0f, 1f); animator.setDuration(2200); animator.setInterpolator(new DecelerateInterpolator()); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { progress = (float) animation.getAnimatedValue(); invalidate(); } }); animator.start(); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); float length = measure.getLength(); float stop = Math.min(length, length * progress); drawPath.reset(); measure.getSegment(0f, stop, drawPath, true); canvas.drawPath(drawPath, paint); // 浅色底线,方便观察当前画到哪了 canvas.drawPath(path, ghostPaint); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); if (animator != null) { animator.cancel(); } } private float dp2px(float dp) { return dp * getResources().getDisplayMetrics().density; } }

4.3 手感优化:插值器、描边风格与常见效果

把 ValueAnimator 默认的线性插值换成 DecelerateInterpolator,线条会呈现出"落笔快、收笔慢"的感觉,过程更像手写。如果想要反过来,可以用 AccelerateInterpolator。两种都试一下,体验完全不同。

  • strokeCap = ROUND:线头圆润,不会在起点和终点出现刺眼的矩形断口
  • strokeJoin = ROUND:曲线拐角处平滑过渡
  • paint.setStrokeWidth(dp2px(3)):细线条在部分机型上会有锯齿感,抗锯齿开启后,配合 2~4dp 的线宽显示效果做好

别小看这些细节,直接把上面的代码跑一遍,再对比一下把 strokeCap 去掉的样子,观感差距非常大。

4.4 跳过的坑:第一帧多出一段线、最后收尾一条直线

在做这个 Demo 的时候我踩了四个坑:

第一个坑是 stopD 超过 getLength()。动画 progress 快接近 1 时,浮点计算可能产生一个比 length 大 0.001 的值,getSegment 对超界输入非常敏感,有时会导致截取结果乱跳。我在代码里用 Math.min(length, length * progress) 做了夹取,稳一点。

第二个坑是 drawPath 没有 reset。getSegment 会把截取出的子路径追加到 dst 末尾,如果你复用同一个 Path,第一帧还好,后面几帧越来越复杂,画面直接"糊"掉。正确做法是每帧先 reset()。

第三个坑是 forceClosed 传 true。前面已经说了,它会让总长多出一段"隐式闭合"距离,最后画笔会莫名其妙地在收尾处拉一条直线。

第四个坑比较隐蔽:startWithMoveTo 传 false。我的 drawPath 是每帧 reset 过的,所以这个问题在单段 Path 里不会出现。但如果你让一个 View 同时绘制多条路径,并且把多条路径塞进同一个 dst,startWithMoveTo 传 false 就会把本来不相邻的线段 "手拉手" 用直线连起来,画面上多出一条诡异的对角线。

5. 案例二:getPosTan让小球沿贝塞尔曲线跑

5.1 场景:加购动画、导弹轨迹、引导光点

加购动画最常见的形态是:一个小圆点从屏幕某个位置出发,沿着一条有弧度的曲线飞进购物车。这种需求用 Path + PathMeasure 实现非常合适,因为你只需要定义一条路径,然后让动画进度驱动一个点在上面走。

5.2 构造贝塞尔曲线:quadTo还是cubicTo

二阶贝塞尔只需要一个控制点,适合"起点、终点、一个拱点"这种简单场景。三阶贝塞尔有两个控制点,可以分别控制起点端和终点端的切线姿态,实际做加购动画时我更常用它。

比如从屏幕左下角飞到右上角,想让路径先明显上扬再回落:

Path path = new Path(); path.moveTo(startX, startY); path.cubicTo(w * 0.3f, h * 0.1f, w * 0.7f, h * 0.9f, endX, endY);

控制点的 x、y 如何选择,直接决定曲线的"拱形"效果。你可以先随便填一版,跑起来后用 PathMeasure.getLength() 输出总长度,再通过调控制点坐标观察弧线弧度,比在纸上硬算快得多。

5.3 核心代码:getPosTan + 角度旋转

public class DotOnPathView extends View { private final Paint dotPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Paint pathPaint = new Paint(Paint.ANTI_ALIAS_FLAG); private final Path path = new Path(); private final PathMeasure measure = new PathMeasure(); private final float[] pos = new float[2]; private final float[] tan = new float[2]; private final float dotRadius; private ValueAnimator animator; private float progress; public DotOnPathView(Context context) { this(context, null); } public DotOnPathView(Context context, AttributeSet attrs) { super(context, attrs); dotRadius = dp2px(10); pathPaint.setStyle(Paint.Style.STROKE); pathPaint.setStrokeWidth(dp2px(2)); pathPaint.setColor(0xFFCCCCCC); dotPaint.setColor(0xFFFF5A5A); } @Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); float startX = w * 0.15f; float startY = h * 0.8f; float endX = w * 0.85f; float endY = h * 0.2f; path.reset(); path.moveTo(startX, startY); path.cubicTo(w * 0.3f, h * 0.1f, w * 0.7f, h * 0.9f, endX, endY); measure.setPath(path, false); startAnimator(); } private void startAnimator() { if (animator != null) { animator.cancel(); } animator = ValueAnimator.ofFloat(0f, 1f); animator.setDuration(1800); animator.setInterpolator(new LinearInterpolator()); animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { progress = (float) animation.getAnimatedValue(); invalidate(); } }); animator.start(); } @Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); canvas.drawPath(path, pathPaint); float length = measure.getLength(); float distance = Math.min(length, length * progress); boolean ok = measure.getPosTan(distance, pos, tan); if (!ok) { return; } float degrees = (float) (Math.atan2(tan[1], tan[0]) * 180.0 / Math.PI); canvas.save(); canvas.translate(pos[0], pos[1]); canvas.rotate(degrees); // 在路径位置画一个主圆,并用一个小圆点表示“朝向” dotPaint.setStyle(Paint.Style.FILL); dotPaint.setColor(0xFFFF5A5A); canvas.drawCircle(0, 0, dotRadius, dotPaint); dotPaint.setColor(0xFF3F7FFF); canvas.drawCircle(dotRadius, 0, dotRadius * 0.5f, dotPaint); canvas.restore(); } @Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); if (animator != null) { animator.cancel(); } } private float dp2px(float dp) { return dp * getResources().getDisplayMetrics().density; } }

绘制顺序是典型的"先平移后旋转":canvas.translate 到路径点,再 canvas.rotate 到切线角度,然后绘制物体的本地坐标系内容。如果你把顺序搞反,物体会绕着 View 原点转圈,位置全错。

5.4 实战的坑清单

  • 如果物体是对称图形(纯圆形),可以不旋转。旋转主要用于有朝向的贴图,比如箭头、小飞机、购物车图标
  • tan 向量不够"单位化"?不用担心,PathMeasure 内部返回的 tan 本身就是归一化方向,直接用 atan2 即可
  • 动画结束时 distance 一定要 clamp 到 [0, length],否则 getPosTan 返回 false,小球会在最后一帧"卡死"在起点
  • 如果多个动画同时存在,每个动画的 progress 要独立保存,不要共用一个字段

6. 案例三:getMatrix与多轮廓路径的组合玩法

6.1 加载圆环:从无到有逐渐画出来

加载动画里经常出现"圆环进度从 0 到 100%"。实现方式和案例一几乎一致,区别在于路径本身是一个圆弧:

Path ringPath = new Path(); RectF rectF = new RectF(-radius, -radius, radius, radius); ringPath.addArc(rectF, -90f, 360f); // 从12点方向开始画整圆

addArc 的第一个参数是圆弧所在矩形,第二个参数是起始角度,第三个参数是扫过角度。角度 0 对应三点钟方向,所以想让起点在顶部就用 -90 作为起始角度。

绘制时:

PathMeasure ringMeasure = new PathMeasure(ringPath, false); float ringLength = ringMeasure.getLength(); Path drawRing = new Path(); drawRing.reset(); ringMeasure.getSegment(0, ringLength * progress, drawRing, true); canvas.translate(getWidth() / 2f, getHeight() / 2f); canvas.drawPath(drawRing, ringPaint);

注意 addArc 出来的路径中心在 (0,0),所以绘制前要先把画布平移到 View 中心。如果你还想让圆环整体转动,可以在 drawRing 后做 canvas.rotate(-progress * 360f, 0, 0),注意旋转中心是原点。

6.2 轨迹跟随效果:把"过去一段"画出来

想让运动的小球后面带一小段轨迹,不需要记录历史点,用 getSegment 直接截取"当前位置往回倒一段弧长":

float tailLength = ringLength * 0.12f; float stop = ringLength * progress; float start = Math.max(0f, stop - tailLength); Path tailPath = new Path(); tailPath.reset(); ringMeasure.getSegment(start, stop, tailPath, true); canvas.drawPath(tailPath, tailPaint);

每次截取的都是当前帧对应的"尾巴区间",所以跟着小球转。想要渐隐效果的话,可以用一个 List 保存最近 N 帧的位置坐标,每个点用不同 alpha 绘制,或者直接用 SweepGradient 做渐变,这个就看你具体需求了。

6.3 多轮廓路径:一个Path里多个图形怎么分配动画

有个很常见的需求:一个 Path 里放了两个轮廓,比如一条横线加一个圆,希望进度条先画完横线再画圆。如果你只用 getLength(),会发现动画总是只显示其中一个轮廓,另一个死活不出现。

原因前面提过:getLength() 默认只返回第一个轮廓的长度。正确做法是先遍历所有轮廓累加总长度,再按照累计进度逐段截取:

private List<Float> collectContourLengths(Path path) { List<Float> lengths = new ArrayList<>(); PathMeasure pm = new PathMeasure(path, false); do { lengths.add(pm.getLength()); } while (pm.nextContour()); return lengths; } private void drawMultiContour(Canvas canvas, Path path, List<Float> contourLengths, float progress) { float total = 0; for (float len : contourLengths) { total += len; } float target = total * progress; PathMeasure pm = new PathMeasure(path, false); Path dst = new Path(); for (int i = 0; i < contourLengths.size(); i++) { if (target <= 0) { break; } float contourLen = contourLengths.get(i); float drawLen = Math.min(contourLen, target); dst.reset(); pm.getSegment(0f, drawLen, dst, true); canvas.drawPath(dst, paint); target -= drawLen; if (!pm.nextContour()) { break; } } }

这段代码虽然能跑,但我实际做项目时更建议把每个轮廓拆成独立的 Path 对象,分别创建 PathMeasure。因为"一个 Path 多轮廓 + 进度分配"的组合,在动画过程中排查问题会很麻烦,你不知道当前 measure 状态到底停在第几个轮廓上。能用一个 Path 一个轮廓,就不要硬塞。

7. 练完这一套之后,我沉淀的几条实操体会

7.1 重建心智模型:Path、PathMeasure、ValueAnimator各管什么

练完以后我最大的收获是把这三者的分工理清了:

  • Path 管"形状":定义路径长什么样
  • PathMeasure 管"丈量":把形状转换成长度、坐标、方向
  • ValueAnimator 管"时间":驱动进度从 0 到 1
  • onDraw 管"渲染":把每一帧的实际状态画出来

遇到新的轨迹动画需求,先问自己三个问题:路径是什么?要沿着路径取位置还是截取片段?进度用什么驱动?只要把这三个答案写清楚,代码结构基本就成型了,后续只是细节调整。

7.2 性能与资源:invalidate、对象复用、动画生命周期

自定义 View 的性能问题,十个里有八个出在 onDraw 里创建对象。PathMeasure 的 getPosTan 需要 float[2] 数组,getMatrix 需要 Matrix,这些都应该作为成员变量复用,不要在 onDraw 里 new。

invalidate() 如果没有带参数,会刷新整个 View 区域。如果你只需要刷新一个小球的位置,可以用 invalidate(Rect) 传一个小区域,降低重绘面积。这个优化在 View 很大、动画区域很小的情况下收益明显。

另外,ValueAnimator 一定要在 onDetachedFromWindow 里 cancel。View 被移除后动画还在跑,不仅白白消耗 CPU,到 onDraw 里拿空状态还会出错。上面代码里我都加了 cancel。

7.3 真机调试的几个实用小技巧

  • 路径坐标尽量基于 onSizeChanged 动态计算,别用写死的像素值。同样的 dp,在不同 dpi 屏幕上对应的 pixel 完全不同
  • 调试贝塞尔曲线时,可以在 onDraw 里临时把控制点用圆点画出来,可视化调整控制点位置
  • 用 measure.getLength() 把路径总长打印出来,对判断"进度是否走完"很有帮助
  • 有些机型硬件加速对复杂的 PathEffect 支持并不完美,如果你发现虚线或阴影效果异常,可以在 View 上关掉硬件加速做交叉验证

7.4 一点额外的福利:用PathEffect做"蚂蚁线"

顺带分享一个和 Path 强相关的小技巧。如果你想在已知路径上做移动虚线效果,可以用 DashPathEffect 的 phase 参数:

paint.setPathEffect(new DashPathEffect(new float[]{10, 10}, phase)); canvas.drawPath(path, paint);

把 phase 用 ValueAnimator 从 0 均匀减小,虚线就会沿着路径方向流动,看起来像"蚂蚁走路"。这个效果配合 PathMeasure 做起点终点控制,可以做出很多有节奏感的加载动画。

我练完最深的感受是:Path 和 PathMeasure 这套组合并不难,难的是很多人不知道"路径长度"和"路径参数"之间的区别,以及 forceClosed、startWithMoveTo 这些参数带来的隐式变化。你不需要记住所有 API 的默认值,但一定要清楚每个参数到底影响什么。把这几个 demo 跑一遍,再回去看项目里那些轨迹动画需求,思路会清楚很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询