Flutter 源码梳理系列(二十一.二):Path

571 阅读14分钟

Path

 本篇来学习在 PaintingContext、Canvas、SceneBuilder 中都会用到的 Path。Path 和 Canvas 一样,也是 Flutter engine 层到 Flutter framework 层的桥接,Path 提供的 API 的真正实现在 engine 层,而在 framework 层中我们可以像其它普通的 framework 层的类一样使用 Path 的 API。且 Path 也是完全同 Canvas 一样的套路,在 Path 的同名工厂构造函数中直接返回 _NativePath,我们以为自己使用的是 Flutter framework 中一个 Path 类,其实它已经跑到了 engine 层。

 和学习 Canvas 时一样,我们也暂时不用纠结于 engine 是如何实现 Path 的一众 API 的,我们只要把目光聚焦在 Path API 提供了什么即可。看到 Path 我们大脑中估计会立即浮现出:这就是一个描述路径的类吧,估计也是像其它平台一样,提供直线、曲线、虚线以及闭合路径 等功能,也确实是这样的。在 Flutter 中 Path 最主要的作用就是当我们在 Canvas 中绘制自定义路径时,可以直接用 Path 进行描述,然后另外一个就是当我们对 Canvas 进行区域裁剪时,我们也可以使用 Path 参数来指定裁剪的区域。

 Path 中的内容整体还是比较简单的,我们快速浏览即可。下面过一遍 Path 的源码,首先是看它的介绍文档。

 Path 由多个子路径(sub-paths)和一个当前点(current point)组成。

 子路径(sub-paths)由各种类型的段组成,如直线(lines)、弧线(arcs)或贝塞尔曲线(beziers)。子路径(sub-paths)可以是开放的也可以是闭合的,并且可以相互交叉。

 闭合子路径(closed sub-paths)根据当前的 fillType,围绕着平面上的一个(可能是不连续的)区域。

 当前点(current point)最初位于原点。在每次向子路径(sub-path)添加一个段的操作之后,当前点(current point)被更新到该段的末尾。

 Path 可以使用 Canvas.drawPath 绘制在画布(canvases)上,也可以使用于 Canvas.clipPath 中创建裁剪区域(clip regions)。

 下面看一下 Path 的源码。

Constructors

_NativePath 是一个针对于当前平台的 base class(base class _NativePath extends NativeFieldWrapperClass1 implements Path { // ... }),_NativePath 类实现了 Path 抽象类中的所有抽象函数,并且在 Path 的同名工厂构造函数中直接返回。

abstract class Path {
  factory Path() = _NativePath;
  
  // 创建另一个 Path 的副本。
  // 拷贝这个副本快速且不需要额外内存,除非入参 source path 或此构造函数返回的 Path 被修改。
  factory Path.from(Path source) {
    
    // 创建一个 _NativePath 变量 clonedPath,然后直接调用 _clone 函数,
    // 把入参 source 克隆到 clonedPath。
    final _NativePath clonedPath = _NativePath._();
    (source as _NativePath)._clone(clonedPath);
    
    return clonedPath;
  }
  
  // ...
}

fillType

 确定如何计算此 Path 的内部。默认值是:PathFillType.nonZero。

  PathFillType get fillType;
  set fillType(PathFillType value);

 PathFillType 是一个枚举,用来指示 Path 的填充类型,下面看下此枚举都有哪些值:

PathFillType

 确定如何计算 Path 内部的环绕规则。这个枚举被 Path.fillType 属性使用。

  • Object -> Enum -> PathFillType
nonZero

 内部由符号边交叉的 non-zero 和定义。对于给定点,如果从该点到无限远处画一条线,这条线穿过顺时针绕点的线的次数与逆时针绕点的线的次数不同,那么该点被认为在路径的内部。Nonzero-rule

evenOdd

 内部是由奇数边交叉定义的。对于给定点,如果从该点到无限远处画出的一条线穿过一条奇数条线,则将该点视为在路径的内部。Even-odd_rule

 我们可以直接看参考链接,体会下 PathFillType.nonZero 和 PathFillType.evenOdd 的区别。下面继续看 Path 的内容。

moveTo & relativeMoveTo & lineTo & relativeLineTo

 relativeXXX 与无 relative 的函数基本都是成对出现的,相比于无 relative 的函数版本,relativeXXX 就是在目标点上添加一个指定偏移,其它都是一样的。

  // 在给定坐标处开始一个新的子路径(sub-path)。
  //(可以理解为设定(x, y)为当前点,后续便从这新当前点开始画路径。)
  void moveTo(double x, double y);

  // 从当前点开始,在给定的偏移量处开始一个新的子路径(sub-path)。
  //(可以理解为更新当前点为:旧的当前点 + (dx, dy),后续便从这新当前点开始画路径。)
  void relativeMoveTo(double dx, double dy);

  // 向当前点添加一条以当前点为起点,以这入参指定点 (x, y) 为终点的线段。
  void lineTo(double x, double y);

  // 从当前点开始,添加一条直线段到距当前点给定偏移量的点。
  // (可以理解为以当前点为起点,然后以 "当前点 + (dx, dy)" 为终点的线段。)
  void relativeLineTo(double dx, double dy);

quadraticBezierTo & relativeQuadraticBezierTo

 quadraticBezierTo:添加一个二次贝塞尔曲线段,从当前点曲线到给定点(x2,y2),使用控制点(x1,y1)。

image.png

  void quadraticBezierTo(double x1, double y1, double x2, double y2);

 relativeQuadraticBezierTo:添加一个二次贝塞尔曲线段,这个曲线从当前点到从当前点偏移 (x2, y2) 的点("当前点 + (x2, y2)")之间的路径,控制点位于从当前点偏移 (x1, y1) 的位置。

 如果当前点是原点,则二者效果相同。

  void relativeQuadraticBezierTo(double x1, double y1, double x2, double y2);

cubicTo & relativeCubicTo

 cubicTo:添加一个三次贝塞尔曲线段,从当前点曲线到给定点 (x3, y3),使用控制点 (x1, y1) 和 (x2, y2)。

image.png

  void cubicTo(double x1, double y1, double x2, double y2, double x3, double y3);

 relativeCubicTo:添加一个三次贝塞尔曲线段,该曲线从当前点到距当前点偏移为 (x3, y3) 的点,使用从当前点偏移为 (x1,y1) 和 (x2,y2) 的控制点。

  void relativeCubicTo(double x1, double y1, double x2, double y2, double x3, double y3);

conicTo & relativeConicTo

 conicTo:添加一个贝塞尔曲线段,从当前点曲线到给定点 (x2, y2),使用控制点 (x1, y1) 和权重 w。如果权重大于 1,则曲线是一个双曲线;如果权重等于 1,则是一个抛物线;如果小于 1,则是一个椭圆。

image.png

  void conicTo(double x1, double y1, double x2, double y2, double w);

 relativeConicTo:添加一个贝塞尔曲线段,从当前点曲线到从当前点偏移为 (x2, y2) 的点,使用从当前点偏移为 (x1, y1) 的控制点和权重 w。如果权重大于 1,则曲线是一个双曲线;如果权重等于 1,则是一个抛物线;如果小于 1,则是一个椭圆。

  void relativeConicTo(double x1, double y1, double x2, double y2, double w);

arcTo

 如果 forceMoveTo 参数为 false,则添加一条直线段和一条弧段。

 如果 forceMoveTo 参数为 true,则开始一个由弧段组成的新子路径。

 无论哪种情况,弧段由沿着给定矩形边界的椭圆的弧组成,从 startAngle 弧度开始绕椭圆到 startAngle + sweepAngle 弧度结束,其中零弧度是通过矩形中心的水平线穿过椭圆右侧的点,正角度顺时针绕椭圆。

 如果 forceMoveTo 为 false,则添加的线段从当前点开始,终止于弧的起点。

  void arcTo(Rect rect, double startAngle, double sweepAngle, bool forceMoveTo);

arcToPoint & relativeArcToPoint

 追加最多四个圆锥曲线,通过权重描述一个半径为 radius 且沿 rotation(以度为单位,顺时针)旋转的椭圆。

 第一条曲线从路径中的上一个点开始,最后一个点以 arcEnd 结束。根据顺时针和 largeArc 确定的方向,这些曲线沿着路径前进,这样扫过的角度始终小于 360 度。

 如果两个半径都为零或路径中的上一个点是 arcEnd,则追加一个简单的直线。如果两个半径都大于零但太小而无法描述一条弧线,则将这些半径缩放以适合路径中的最后一个点。

  void arcToPoint(Offset arcEnd, {
    Radius radius = Radius.zero,
    double rotation = 0.0,
    bool largeArc = false,
    bool clockwise = true,
  });

 追加最多四个共轭曲线,它们被加权以描述一个半径为 radius 并按照 rotation(以角度表示,顺时针)旋转的椭圆。

 最后的路径点由(px,py)描述。

 第一条曲线从路径中的最后一个点开始,最后一条曲线以 arcEndDelta.dx + px 和 arcEndDelta.dy + py 结束。曲线沿着由 clockwise 和 largeArc 决定的方向进行,使得扫描角度始终小于 360 度。

 如果半径为零,或者 arcEndDelta.dx 和 arcEndDelta.dy 都为零,则追加一条简单的直线。如果两者均大于零但太小以描述一条弧时,则将半径缩放以适应路径中的最后一个点。

  void relativeArcToPoint(
    Offset arcEndDelta, {
    Radius radius = Radius.zero,
    double rotation = 0.0,
    bool largeArc = false,
    bool clockwise = true,
  });

addRect & addOval

 addRect:添加一个新的子路径(sub-path),由四条线组成,勾勒出给定的矩形。

 addOval:添加一个新的子路径(sub-path),该子路径(sub-path)由形成填充给定矩形的椭圆的曲线组成。要添加一个圆,可以将一个适当的矩形作为椭圆。可以使用 Rect.fromCircle 来轻松描述圆的中心偏移和半径

  void addRect(Rect rect);
  void addOval(Rect oval);

addArc

 添加一个新的子路径(sub-path),其中包含一个弧段,该弧段由限定的矩形边界内椭圆的边缘形成,从弧度 startAngle 开始围绕椭圆到弧度 startAngle + sweepAngle 结束,其中 0 弧度是横穿矩形中心的水平线的椭圆右侧点,正角度顺时针围绕椭圆。

image.png

image.png

  void addArc(Rect oval, double startAngle, double sweepAngle);

addPolygon

 添加一个新的子路径(sub-path),该子路径(sub-path)由连接给定点的线段序列组成。

&emsp如果 close 参数为 true,则会添加一个最终线段,连接最后一个点和第一个点。

 points 参数被解释为相对于原点的偏移量。

  void addPolygon(List<Offset> points, bool close);

addRRect

 添加一个新的子路径(sub-path),该子路径(sub-path)由组成所描述的圆角矩形所需的直线和曲线组成。

  void addRRect(RRect rrect);

addPath

 将 Path 的子路径(sub-paths)按指定的偏移量添加到此 Path 中。(可以理解为复制一遍已经绘制的路径,然后整体根据 offset 进行偏移。)

 如果指定了 matrix4,那么在通过给定的偏移量平移矩阵后,Path 将通过该矩阵进行变换。该矩阵是按列主序存储的 4x4 矩阵。

  void addPath(Path path, Offset offset, {Float64List? matrix4});

extendWithPath

 将 Path 的子路径(sub-paths)按照偏移量 offset 添加到此 Path 中。当前子路径(sub-paths)将与 Path 的第一个子路径(sub-paths)延长,并在必要时用 lineTo 连接它们。(与上面的 addPath 类似,只是最后可以会把新增的 Path 已旧的 Path 连接起来。)

 如果指定了 matrix4,那么在矩阵被偏移量平移之后,Path 将按照此矩阵进行变换。该矩阵是按列主序存储的 4x4 矩阵。

  void extendWithPath(Path path, Offset offset, {Float64List? matrix4});

close & reset

 close:关闭最后一个子路径(sub-paths),就好像从当前点(current point)到该子路径的第一个点(Path 的起点)画了一条直线一样。(即把最后一个点和第一个点直接用一条直线连接起来。)

 reset:清除 Path 对象中的所有子路径(sub-paths),将其恢复为创建时的初始状态。当前点(current point)将被重置为原点。

  void close();
  void reset();

contains & shift & transform

 contains:测试给定点(Offset point)是否在 Path 内部。(也就是说,如果该 Path 被用于 Canvas.clipPath,该点是否在 Path 的可见部分内,还是说被裁剪掉了。) Offset point 参数被解释为相对于原点的偏移量。如果 point 在 Path 内部则返回 true,否则返回 false。

 shift:返回 Path 的副本(一个新的 Path,和之前的函数那种在既有的 Path 添加新的子路径(sub-paths)是不同的。),其中每个子路径的所有线段都按给定的偏移量(Offset offset)平移。

 transform:使用给定的矩阵转换所有子路径(sub-paths)的所有线段,返回 Path 的副本。

  bool contains(Offset point);
  Path shift(Offset offset);
  Path transform(Float64List matrix4);

getBounds

 计算此 Path 的边界矩形。(可以理解为在 Path 外面画一个矩形,然后刚好把 Path 包围在里面。)

 包含仅具有相同直线上的轴对齐点的 Path 将没有 area,因此对于这样的路径,Rect.isEmpty 将返回true。考虑改为检查 rect.width + rect.height > 0.0,或者使用 computeMetrics API 来检查 Path 长度。

 对于许多更复杂的路径,bounds 可能会不准确。例如,当 Path 包含一个圆时,用于计算边界的点是圆的隐含控制点,它们形成一个围绕圆的正方形;如果应用了 transform 对圆进行变换,则该正方形会旋转,因此(轴对齐、非旋转的)边界框最终会严重高估圆覆盖的实际 area。

  Rect getBounds();

 下面是 Path 中最后一部分内容,两个比较不好理解的静态函数,我们一起来看一下。

combine

 静态函数:根据给定的操作方式(PathOperation operation)将这两条 Path 合并。

 得到的 Path 将由互不重叠的轮廓构成。在可能的情况下,曲线顺序会降低,使得立方曲线可以变为二次曲线,并且二次曲线可以变为直线

  static Path combine(PathOperation operation, Path path1, Path path2) {
    final _NativePath path = _NativePath();
    if (path._op(path1 as _NativePath, path2 as _NativePath, operation.index)) {
      return path;
    }
    
    throw StateError('Path.combine() failed.  This may be due an invalid path; in particular, check for NaN values.');
  }

 Ok,API 看完了,有哪些不理解的,或者不熟悉的可套用下面的示例代码操作一番:

import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';

void main() {
  runApp(const MyCustomPainterApp());
}

class MyCustomPainterApp extends StatelessWidget {
  const MyCustomPainterApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('CustomPainter Example'),
        ),
        body: Center(
          child: CustomPaint(
            size: MediaQuery.of(context).size,
            painter: MyPainter(),
          ),
        ),
      ),
    );
  }
}

class MyPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final Path path = Path();
    path.moveTo(50, 50);

    path.lineTo(150, 150);
    // path.relativeLineTo(100, 100);

    final Paint paint = Paint();
    paint.style = PaintingStyle.stroke;
    paint.strokeWidth = 5;
    paint.color = Colors.redAccent;

    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

Path 总结

 OK,Path 的内容到这里就看完了,首先是 Path 是一个抽象类,然后它内部的所有函数都是抽象函数,甚至于它的同名构造函数都是返回一个 NativePath(factory Path() = _NativePath;),至于 NativePath 是谁?它定义是这样的:base class _NativePath extends NativeFieldWrapperClass1 implements Path { // ...},可以看到它是完全实现了 Path 抽象函数的一个继承自 NativeFieldWrapperClass1 的类,它的内容主要通过 Flutter engine 在 native 层实现。那里面牵涉的内容比较多,我们后续再学习。目前的话只要我们理解 Path 的功能以及它的一些常见函数,然后后面学习 RenderObject 的绘制时,看到 Path 参数不晕即可。

 Path 相关的 API 都比较好理解,快速浏览即可。

  • moveTo: 移动 Path 起始点的位置到指定的坐标点。
  • relativeMoveTo: 相对移动 Path 起始点的位置到指定的坐标点。
  • lineTo: 连接当前 Path 结束点和指定坐标点,绘制直线段。
  • relativeLineTo: 相对于当前 Path 结束点的位置,绘制直线段。
  • quadraticBezierTo: 绘制二阶贝塞尔曲线。
  • relativeQuadraticBezierTo: 绘制相对位置的二阶贝塞尔曲线。
  • cubicTo: 绘制三阶贝塞尔曲线。
  • relativeCubicTo: 绘制相对位置的三阶贝塞尔曲线。
  • conicTo: 绘制圆锥曲线,需要指定 weight 参数。
  • arcTo: 通过指定矩形框和起始角度、扫描角度,绘制弧线。
  • arcToPoint: 通过指定矩形框的某一点,绘制连接当前点和目标点的弧线。
  • addRect: 向 Path 添加一个矩形。
  • addOval: 向 Path 添加一个椭圆形。
  • addArc: 向 Path 添加一个圆弧。
  • addPolygon: 向 Path 添加多边形。

参考链接

参考链接:🔗