九、Flutter 小实践

2,266 阅读5分钟

一、Flutter 之图像绘制原理

二、Widget、Element、RenderObject

三、Flutter UI 更新流程

四、build 流程分析

五、layout 流程分析

六、Paint 绘制(1)

七、Paint 绘制(2)

八、composite 流程分析

1、自定义布局

案例描述 根据点击的位置弹一个提示框,例如

提示框的位置主要有四种情况:

以上图的的圆点(即屏幕的中间点)为参考位置

水平方向

显示在参考物的左边:参考物的中间点位置位于圆点的右侧,即提示框显示在参考物的左边

显示在参考物的右边:参考物的中间点位置位于圆点的左侧,即提示框显示在参考物的右边

垂直方向

显示在参考物的下方:参考物的中间点位置位于圆点的上方,即提示框显示在参考物的下方

显示在参考物的上方:参考物的中间点位置位于圆点的下方,即提示框显示在参考物的上方

自定义提示框布局步骤 (1)点击事件触发时获取参考物的位置

showTip(BuildContext context) async 
  //获取点击源
    final RenderBox box = customPopupKey.currentContext.findRenderObject();
  
    final Offset target = box.localToGlobal(box.size.center(Offset.zero));
    final RenderBox overlay = Overlay.of(context).context.findRenderObject();
  }

(2)判断 提示框的位置,居左,居右,居上,居下

PopupDirection popupDirection; 

  if (preferVertical) {
    // 是否是垂直方向显示
    if (target.dy > overlay.size.center(Offset.zero).dy) {
      popupDirection = PopupDirection.up;
    } else {
      popupDirection = PopupDirection.down;
    }
  } else {
    // 水平方向显示
    if (target.dx < overlay.size.center(Offset.zero).dx) {
      popupDirection = PopupDirection.right;
    } else {
      popupDirection = PopupDirection.left;
    }
}

(3)重写 performLayout 函数 在performLayout 函数中返回对应节点的大小设置,返回该 renderObejct 的offset, 即位置信息, 在该例子中使用了 flutter 提供的 CustomSingleChildLayout 进行自定义布局

  delegate: _CustomSingleChildDelegate(
      target: target,
      targetBoxSize: box.size,
      offsetDistance: offsetDistance,
      preferVertical: preferVertical,
      popUpDirection: popupDirection),
  child: Container(
    padding: contentPadding,
    decoration: ShapeDecoration(
      color: bgColor,
      shape: CustomShapeBorder(
          popupDirection: popupDirection,
          targetCenter: target,
          borderRadius: _defaultBorderRadius,
          arrowBaseWidth: arrowBaseWidth,
          arrowTipDistance: arrowTipDistance,
          borderColor: borderColor,
          borderWidth: borderWidth),
    ),
    child: Text(
      message,
      style: TextStyle(
          fontSize: Adapt.px(textSize),
          color: textColor,
          decoration: TextDecoration.none),
    ),
  ),
)

(4)CustomSingleChildLayout 对应的 renderObject 重写了 performLayout方法

void performLayout() {
  size = _getSize(constraints);
  if (child != null) {
    final BoxConstraints childConstraints = delegate.getConstraintsForChild(constraints);
    assert(childConstraints.debugAssertIsValid(isAppliedConstraint: true));
    child.layout(childConstraints, parentUsesSize: !childConstraints.isTight);
    final BoxParentData childParentData = child.parentData;
    childParentData.offset = delegate.getPositionForChild(size, childConstraints.isTight ? childConstraints.smallest : child.size);
  }
}

其设置节点的 offset 是通过调用 delegate.getPositionForChild 方法,这个delegate 是由调用方自定义的,如 demo 中传入的 _CustomSingleChildDelegate,在这个类总,我们重写了 getPositionForChild 方法,根据自己的需求完成节点位置的布局

(5)_CustomSingleChildDelegate

class _CustomSingleChildDelegate extends SingleChildLayoutDelegate {

  @override
  Offset getPositionForChild(Size size, Size childSize) {
    return customPositionDependentBox(
      size: size,
      childSize: childSize,
      target: target,
      offsetDistance: offsetDistance,
      preferVertical: preferVertical,
    );
  }

  Offset customPositionDependentBox({
    @required Size size,
    @required Size childSize,
    @required Offset target,
    @required bool preferVertical,
    double offsetDistance = 0.0,
    double margin = 30.0,
  }) {
    // VERTICAL DIRECTION
    double x;
    double y;

    // 在点击位置的垂直方向显示
    if (preferVertical) {
      final bool fitsBelow = popUpDirection == PopupDirection.down;

      if (fitsBelow) {
        // 显示在底部
        y = min(target.dy + 5, size.height - margin);
      } else {
        // 显示在上方
        y = max(target.dy - childSize.height - 5, margin);
      }

      // 水平方向处理
      if (size.width - margin * 2.0 < childSize.width) {
        x = (size.width - childSize.width) / 2.0;
      } else {
        final double normalizedTargetX =
            target.dx.clamp(margin, size.width - margin);
        final double edge = margin + childSize.width / 2.0;
        if (normalizedTargetX < edge) {
          x = margin;
        } else if (normalizedTargetX > size.width - edge) {
          x = size.width - margin - childSize.width;
        } else {
          x = normalizedTargetX - childSize.width / 2.0;
        }
      }
    } else {
      // 在触发源的水平方向显示处理
      final bool fitsLeft = popUpDirection == PopupDirection.left;
      if (fitsLeft) {
        // 左边
        x = min(target.dx - childSize.width - targetBoxSize.width / 2 - 10,
            size.width - margin);
      } else {
        // 右边
        x = max(target.dx + targetBoxSize.width / 2 + 10, margin);
      }
      // 水平显示时垂直方向的处理
      if (size.height - margin * 2.0 < childSize.height) {
        y = (size.height - childSize.height) / 2.0;
      } else {
        final double normalizedTargetY =
            target.dy.clamp(margin, size.height - margin);
        final double edge = margin + childSize.height / 2.0;
        if (normalizedTargetY < edge) {
          y = margin;
        } else if (normalizedTargetY > size.height - edge) {
          y = size.height - margin - childSize.height;
        } else {
          y = normalizedTargetY - childSize.height / 2.0;
        }
      }
    }
    return Offset(x, y);
  }
}

2、自定义绘制

在上述例子中,我们完成了自定义布局,但是该例子的提示框是带有一个三角形,而且三角形的尖角方向可能随着提示框的位置而不同,可能是向上,向下,也有可能是向左,向右,这个三角形是可以根据需求自定义绘制的。

(1) 通过重新描绘提示框的边框实现

Container(
  padding: contentPadding,
  decoration: ShapeDecoration(
    color: bgColor,
    shape: CustomShapeBorder(
        popupDirection: popupDirection,
        targetCenter: target,
        borderRadius: _defaultBorderRadius,
        arrowBaseWidth: arrowBaseWidth,
        arrowTipDistance: arrowTipDistance,
        borderColor: borderColor,
        borderWidth: borderWidth),
  ),
  child: Text(
    message,
    style: TextStyle(
        fontSize: Adapt.px(textSize),
        color: textColor,
        decoration: TextDecoration.none),
  ),
)

(2) 自定义提示框的边框

/**
 * 绘制提示框边框,提示框尖角
 */
class CustomShapeBorder extends ShapeBorder {
  final Offset targetCenter;
  final double arrowBaseWidth;
  final double arrowTipDistance;
  final double borderRadius;
  final Color borderColor;
  final double borderWidth;
  final PopupDirection popupDirection;

  CustomShapeBorder(
      {this.popupDirection,
      this.targetCenter,
      this.borderRadius,
      this.arrowBaseWidth,
      this.arrowTipDistance,
      this.borderColor,
      this.borderWidth});

  @override
  EdgeInsetsGeometry get dimensions => new EdgeInsets.all(10.0);

  @override
  Path getInnerPath(Rect rect, {TextDirection textDirection}) {
    return new Path()
      ..fillType = PathFillType.evenOdd
      ..addPath(getOuterPath(rect), Offset.zero);
  }

  @override
  //绘制边框
  Path getOuterPath(Rect rect, {TextDirection textDirection}) {
    double topLeftRadius, topRightRadius, bottomLeftRadius, bottomRightRadius;

    Path _getLeftTopPath(Rect rect) {
      return new Path()
        ..moveTo(rect.left, rect.bottom - bottomLeftRadius)
        ..lineTo(rect.left, rect.top + topLeftRadius)
        ..arcToPoint(Offset(rect.left + topLeftRadius, rect.top), //绘制圆角
            radius: new Radius.circular(topLeftRadius))
        ..lineTo(rect.right - topRightRadius, rect.top)
        ..arcToPoint(Offset(rect.right, rect.top + topRightRadius), //绘制圆角
            radius: new Radius.circular(topRightRadius),
            clockwise: true);
    }

    Path _getBottomRightPath(Rect rect) {
      return new Path()
        ..moveTo(rect.left + bottomLeftRadius, rect.bottom)
        ..lineTo(rect.right - bottomRightRadius, rect.bottom)
        ..arcToPoint(Offset(rect.right, rect.bottom - bottomRightRadius),
            radius: new Radius.circular(bottomRightRadius), clockwise: false)
        ..lineTo(rect.right, rect.top + topRightRadius)
        ..arcToPoint(Offset(rect.right - topRightRadius, rect.top),
            radius: new Radius.circular(topRightRadius), clockwise: false);
    }

    topLeftRadius = borderRadius;
    topRightRadius = borderRadius;
    bottomLeftRadius = borderRadius;
    bottomRightRadius = borderRadius;

    switch (popupDirection) {
      //

      case PopupDirection.down:
        return _getBottomRightPath(rect)
          ..lineTo(
              min(
                  max(targetCenter.dx + arrowBaseWidth / 2,
                      rect.left + borderRadius + arrowBaseWidth),
                  rect.right - topRightRadius),
              rect.top)
          ..lineTo(targetCenter.dx, rect.top - arrowTipDistance) // 向下箭头
          ..lineTo(
              max(
                  min(targetCenter.dx - arrowBaseWidth / 2,
                      rect.right - topLeftRadius - arrowBaseWidth),
                  rect.left + topLeftRadius),
              rect.top) //  // 向下箭头

          ..lineTo(rect.left + topLeftRadius, rect.top)
          ..arcToPoint(Offset(rect.left, rect.top + topLeftRadius),
              radius: new Radius.circular(topLeftRadius), clockwise: false)
          ..lineTo(rect.left, rect.bottom - bottomLeftRadius)
          ..arcToPoint(Offset(rect.left + bottomLeftRadius, rect.bottom),
              radius: new Radius.circular(bottomLeftRadius), clockwise: false);

      case PopupDirection.up:
        return _getLeftTopPath(rect)
          ..lineTo(rect.right, rect.bottom - bottomRightRadius)
          ..arcToPoint(Offset(rect.right - bottomRightRadius, rect.bottom),
              radius: new Radius.circular(bottomRightRadius), clockwise: true)
          ..lineTo(
              min(
                  max(targetCenter.dx + arrowBaseWidth / 2,
                      rect.left + bottomLeftRadius + arrowBaseWidth),
                  rect.right - bottomRightRadius),
              rect.bottom)

          // 向上箭头
          ..lineTo(targetCenter.dx, rect.bottom + arrowTipDistance)
          ..lineTo(
              max(
                  min(targetCenter.dx - arrowBaseWidth / 2,
                      rect.right - bottomRightRadius - arrowBaseWidth),
                  rect.left + bottomLeftRadius),
              rect.bottom)
          ..lineTo(rect.left + bottomLeftRadius, rect.bottom)
          ..arcToPoint(Offset(rect.left, rect.bottom - bottomLeftRadius),
              radius: new Radius.circular(bottomLeftRadius), clockwise: true)
          ..lineTo(rect.left, rect.top + topLeftRadius)
          ..arcToPoint(Offset(rect.left + topLeftRadius, rect.top),
              radius: new Radius.circular(topLeftRadius), clockwise: true);

      case PopupDirection.left:
        return _getLeftTopPath(rect)
          ..lineTo(
              rect.right,
              max(
                  min(targetCenter.dy - arrowBaseWidth / 2,
                      rect.bottom - bottomRightRadius - arrowBaseWidth),
                  rect.top + topRightRadius))
          ..lineTo(rect.right + arrowTipDistance, targetCenter.dy) // 向左箭头
          ..lineTo(
              rect.right,
              min(targetCenter.dy + arrowBaseWidth / 2,
                  rect.bottom - bottomRightRadius))
          ..lineTo(rect.right, rect.bottom - borderRadius)
          ..arcToPoint(Offset(rect.right - bottomRightRadius, rect.bottom),
              radius: new Radius.circular(bottomRightRadius), clockwise: true)
          ..lineTo(rect.left + bottomLeftRadius, rect.bottom)
          ..arcToPoint(Offset(rect.left, rect.bottom - bottomLeftRadius),
              radius: new Radius.circular(bottomLeftRadius), clockwise: true);

      case PopupDirection.right:
        return _getBottomRightPath(rect)
          ..lineTo(rect.left + topLeftRadius, rect.top)
          ..arcToPoint(Offset(rect.left, rect.top + topLeftRadius),
              radius: new Radius.circular(topLeftRadius), clockwise: false)
          ..lineTo(
              rect.left,
              max(
                  min(targetCenter.dy - arrowBaseWidth / 2,
                      rect.bottom - bottomLeftRadius - arrowBaseWidth),
                  rect.top + topLeftRadius))

          // 向右箭头
          ..lineTo(rect.left - arrowTipDistance, targetCenter.dy)
          ..lineTo(
              rect.left,
              min(targetCenter.dy + arrowBaseWidth / 2,
                  rect.bottom - bottomLeftRadius))
          ..lineTo(rect.left, rect.bottom - bottomLeftRadius)
          ..arcToPoint(Offset(rect.left + bottomLeftRadius, rect.bottom),
              radius: new Radius.circular(bottomLeftRadius), clockwise: false);

      default:
        throw AssertionError(popupDirection);
    }
  }

  @override
  void paint(Canvas canvas, Rect rect, {TextDirection textDirection}) {
    Paint paint = new Paint()
      ..color = borderColor
      ..style = PaintingStyle.stroke
      ..strokeWidth = borderWidth;

    canvas.drawPath(getOuterPath(rect), paint);
  }

  @override
  ShapeBorder scale(double t) {
    return new CustomShapeBorder(
        popupDirection: this.popupDirection,
        targetCenter: this.targetCenter,
        borderRadius: this.borderRadius,
        arrowBaseWidth: this.arrowBaseWidth,
        arrowTipDistance: this.arrowTipDistance,
        borderColor: this.borderColor,
        borderWidth: this.borderWidth);
  }
}

Object > Diagnosticable > DiagnosticableTree > Widget > RenderObjectWidget > SingleChildRenderObjectWidget > CustomSingleChildLayout

3、性能优化

(1) devTool 工具

工欲善其事必先利其器,可以借助 devTool 打开 timeline 查看相关的绘制渲染情

或者在 Android Studio 可以调出 Flutter Inspector

(2) 在main方法中设置以下属性值

void main() {
  debugProfileBuildsEnabled = true; // 查看需要重绘的widget
  debugProfilePaintsEnabled = true; // 查看需要重绘的 renderObject
  debugPaintLayerBordersEnabled = true; // 查看需要重绘的 layer信息
  debugRepaintRainbowEnabled = true;
  runApp(MyApp());
}

(3) 减少 rebuild 范围

class _MyHomePageState extends State<MyHomePage> {
  String name = '';
  int count = 0;
  @override
  void initState() {
    super.initState();
    Timer.periodic(
        Duration(milliseconds: 1000),
            (timer) => {
          setState(() {
            count++;
          })
        });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        body: Container(
      child: Row(
        children: <Widget>[
          Container(
            child: Row(
              children: <Widget>[Text('哈哈哈1'), Text('哈哈哈3')],
            ),
          ),
          Text('哈哈哈2${count}')
        ],
      ),
    ));
  }
}

优化前:

widget 每次 rebuild 都是 从根节点 Scaffold 开始遍历,但实际在这个demo 中只会实时更新 一个 text 文本

优化后: 将需要实时更新的 widget 抽离成一个组件

class TextCom extends StatefulWidget {
  @override
  State<StatefulWidget> createState() {
    return _TextComState();
  }
}

class _TextComState extends State<TextCom> {
  int count = 0;
  @override
  void initState() {
    super.initState();
    Timer.periodic(
        Duration(milliseconds: 1000),
        (timer) => {
              setState(() {
                count++;
              })
            });
  }

  @override
  Widget build(BuildContext context) {
    return Text('哈哈哈2${count}');
  }
}

重新查看 reBuild 范围,发现缩小到这个 TextWidget 组件,如下图中的 TextCom

(2) RepaintBoundary 将需要实时更新的图层隔离成一个单独的图层,在重绘时不影响其他图层的绘制