Flutter富文本性能优化 — 渲染

5,038 阅读7分钟

⚠️本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

在Flutter中,文本的排版和渲染是一个非常耗时的过程,虽然Flutter在默认的情况下已经对文本排版和渲染进行了优化,但是在经过我们一系列操作后,编写出运行缓慢、渲染时间长的Flutter程序是绝对有可能的。所以,利用好Flutter的一些性能分析方法和对文本渲染的一些优化是很有必要的!

注:本文主要针对Flutter文本的渲染&编辑优化。建议在阅读本文前先了解Flutter基本的一些性能分析方法。

Flutter性能优化推荐阅读:

深入探索Flutter性能优化@jsonchao

Flutter 性能优化实践 总结@iOShuyang

文本的排版与绘制

在经过之前文章的学习后,我们可以知道RichText主要是通过构建InlineSpan树来实现图文混排的功能。对InlineSpan树的结构我们也已经很清晰,在树中,除了TextSpan,还存在着PlaceholderSpan类型的节点,而WidgetSpan又是继承于PlaceholderSpan的,PlaceholderSpan会在文字排版的时候作为占位符参与排版,WidgetSpan就可以在排版完之后得到位置信息,然后绘制在正确的地方。

4.png

RichText继承的是MultiChildRenderObjectWidget,对应的RenderObject就是负责文本的排版和渲染的RenderParagraphRenderParagraph负责文本的LayoutPaint,但RenderParagraph并不会直接的绘制文本,它最终都是调用TextPainter对象,再由TextPainter去触发Engine层中的排版和渲染。

17.png

那么文本具体的排版和绘制过程是怎么样的呢? ——知道它的原理实现过程,才能更好的优化它

我们已经知道PlaceholderSpan会在文字排版的时候作为占位符参与排版,那么editable.dart中的_layoutChildren方法就是用来收集PlaceholderSpan的信息,用于后续的文本排版。

///如果没有PlaceholderSpan(WidgetSpan),这个方法不会做任何事
List<PlaceholderDimensions> _layoutChildren(BoxConstraints constraints, {bool dry = false}) {
  if (childCount == 0) {
    _textPainter.setPlaceholderDimensions(<PlaceholderDimensions>[]);
    return <PlaceholderDimensions>[];
  }
  RenderBox? child = firstChild;
  final List<PlaceholderDimensions> placeholderDimensions = List<PlaceholderDimensions>.filled(childCount, PlaceholderDimensions.empty);
  int childIndex = 0;
  //将宽度设置为PlaceholderSpan所在段落的最大宽度,若不做限制,会溢出。
  BoxConstraints boxConstraints = BoxConstraints(maxWidth: constraints.maxWidth);
  ...
  //遍历InlineSpan树下PlaceholderSpan的所有子节点,收集它的尺寸信息(PlaceholderDimensions)
    placeholderDimensions[childIndex] = PlaceholderDimensions(
      size: childSize,
      alignment: _placeholderSpans[childIndex].alignment,
      baseline: _placeholderSpans[childIndex].baseline,
      baselineOffset: baselineOffset,
    );
    child = childAfter(child);
    childIndex += 1;
  }
  return placeholderDimensions;
}

通过paragraph.dart下的_layoutTextWithConstraints方法,将收集的PlaceholderSpan信息更新到TextPainter

void _layoutTextWithConstraints(BoxConstraints constraints) {
  //设置每个占位符(PlaceholderSpan)的尺寸,传入的PlaceholderDimensions必须与PlaceholderSpan的数量对应。
  _textPainter.setPlaceholderDimensions(_placeholderDimensions);
  //用于计算需要绘制的文本的位置
  _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
}

PlaceholderSpan信息更新到TextPainter后,我们在看到layout方法,

void layout({ double minWidth = 0.0, double maxWidth = double.infinity }) {
  ...
  //_rebuildParagraphForPaint用于判断是否需要重建文本段落。
  //_paragraph为空则意味着样式发生改变,文本需要重新布局。
  if (_rebuildParagraphForPaint || _paragraph == null) {
    //重建文本段落
    _createParagraph();
  }
  ...
  //TextBox会在Paint时被绘制。
  _inlinePlaceholderBoxes = _paragraph!.getBoxesForPlaceholders();
}

layout中调用的_createParagraph,主要来添加TextSpan和计算PlaceholderDimensions

void _createParagraph() {
  ...
  //遍历InlineSpan树,如果是TextSpan就将其添加到builder中。
  //如果是PlaceholderSpan(WidgetSpan和自定义span),就计算PlaceholderDimensions。
  final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle());
  text.build(builder, textScaleFactor: textScaleFactor, dimensions: _placeholderDimensions);
  _inlinePlaceholderScales = builder.placeholderScales;
  _paragraph = builder.build();
  _rebuildParagraphForPaint = false;
}

在计算完PlaceholderDimensions后,需要将它更新到对应的节点。

void _setParentData() {
  RenderBox? child = firstChild;
  int childIndex = 0;
  //循环遍历布局的子节点,给每一个子节点的占位符设置parentData的偏移量
  while (child != null && childIndex < _textPainter.inlinePlaceholderBoxes!.length) {
    final TextParentData textParentData = child.parentData! as TextParentData;
    //主要计算offset和scale
    textParentData.offset = Offset(
      _textPainter.inlinePlaceholderBoxes![childIndex].left,
      _textPainter.inlinePlaceholderBoxes![childIndex].top,
    );
    textParentData.scale = _textPainter.inlinePlaceholderScales![childIndex];
    child = childAfter(child);
    childIndex += 1;
  }
}

layout被调用后(计算好需要绘制的区域后),将进行paintpaint主要为两个部分:文本的绘制和占位符的绘制。·

void _paintContents(PaintingContext context, Offset offset) {
    //断言,如果最后绘制的宽高与最大宽高的约束不相同,则抛出一个异常。(断言只在debug模式下运行有效,如果在release模式运行,断言不会执行)
    debugAssertLayoutUpToDate();
    //绘制的偏移
    final Offset effectiveOffset = offset + _paintOffset;
    
    if (selection != null && !_floatingCursorOn) {
      //计算插入的文本的偏移量
      _updateSelectionExtentsVisibility(effectiveOffset);
    }
    
    final RenderBox? foregroundChild = _foregroundRenderObject;
    final RenderBox? backgroundChild = _backgroundRenderObject;
    
    //绘制child的RenderObject
    if (backgroundChild != null) {
      context.paintChild(backgroundChild, offset);
    }
    //绘制layout布局好的文本
    //调用canvas.drawParagraph()将文本绘制到指定的区域中
    _textPainter.paint(context.canvas, effectiveOffset);
​
    RenderBox? child = firstChild;
    int childIndex = 0;
    //循环遍历InlineSpan树,其中每一个TextBox都对应一个PlaceholderSpan
    while (child != null && childIndex < _textPainter.inlinePlaceholderBoxes!.length) {
      //parentData的偏移量
      final TextParentData textParentData = child.parentData! as TextParentData;
​
      final double scale = textParentData.scale!;
      //绘制占位的child
      //在pushTransform中,用了TransformLayer包裹了一层,用于对排版进行变换,主要是包含offset和scale
      context.pushTransform中,用了(
        needsCompositing,
        effectiveOffset + textParentData.offset,
        Matrix4.diagonal3Values(scale, scale, scale),
        (PaintingContext context, Offset offset) {·
          context.paintChild(
            child!,
            offset,
          );
        },
      );
      child = childAfter(child);
      childIndex += 1;
    }
​
    if (foregroundChild != null) {
      //绘制RenderObject
      context.paintChild(foregroundChild, offset);
    }
  }

在了解Flutter文本的排版和绘制后,我们会发现,在文本的排版和绘制过程中,有着许多位置计算和构建文本段落的逻辑,这是非常耗时的过程,为了程序的高性能,我们是不可能每一帧都去重新排版渲染的。当然,我们能想到,Flutter官方肯定也能想到,所以Flutter在更新文本时,会通过比较文本信息,更具文本信息的更新状态来判断下一帧是否要进行文本的重新排版渲染。

enum RenderComparison {
  //更新后的InlineSpan树与更新前完全一样
  identical,
  //更新后的InlineSpan树与更新前一样(布局一样),只是像一些点击事件发生改变
  metadata,
  //更新后的InlineSpan树与更新前存在TextSpan的样式变化,但是树的结构没有变化,布局没有改变
  paint,
  //更新后的InlineSpan树与更新前发生了布局变化,例如文本大小改变,或插入了图片...
  layout,
}

四种状态的变化情况是越来越大的,identicalmetadata的状态是不会对RenderObject渲染对象进行改变的,paint是需要重新绘制文本,layout是需要重新排版文本。了解了Flutter对文本更新状态的定义,再让我们了解下,Flutter是如何判断文本更新的状态的。

@override
RenderComparison compareTo(InlineSpan other) {
  ...
  //判断Text或子child数量是否发生变化,若发生变化则需要重新排版
  if (textSpan.text != text ||
      children?.length != textSpan.children?.length ||
      (style == null) != (textSpan.style == null)) {
    //返回文本更新状态
    return RenderComparison.layout;
  }
  RenderComparison result = recognizer == textSpan.recognizer ?
    RenderComparison.identical :
    RenderComparison.metadata;
  //比较textSpan.style
  if (style != null) {
    //style!.compareTo()用于比较样式,若只是color这些属性的修改,只需要重新绘制即可
    //若是字体大小这样属性发生变化,则需要重新进行排版
    final RenderComparison candidate = style!.compareTo(textSpan.style!);
    if (candidate.index > result.index) {
      result = candidate;
    }
    if (result == RenderComparison.layout) {
      return result;
    }
  }
  //递归比较子child节点
  if (children != null) {
    for (int index = 0; index < children!.length; index += 1) {
      final RenderComparison candidate = children![index].compareTo(textSpan.children![index]);
      if (candidate.index > result.index) {
        result = candidate;
      }
      if (result == RenderComparison.layout) {
        return result;
      }
    }
  }
  return result;
}

文本渲染优化探索

结论 —— 按段落(块)渲染文本。

文本渲染最头疼的问题就在于长文本(超十万字)的渲染,这样的长文本在渲染时往往会占用很大的内存,滚动卡顿,给用户带来极差的体验。如果你对长文本渲染没有概念,那么可以和我一起看下这个测试例子(所有测试代码均在Profile模式下运行):

代码实现如下:模拟将长文本渲染进一个Text的操作。

test1
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text("长文本渲染 — 测试"),
    ),
    body: Center(
      child: ListView(
        children: <Widget>[
          //创建一个Iterable,通过序列来动态生成元素
          Text(Iterable.generate(100000, (i) => "Hello Flutter $i").join('\n'))
        ],
      ),
    ),
  );
}

我们可以在效果图中看到,在快速滑动时,页面有明显卡顿。通过计算得到帧率在15帧左右。这在现在动不动就屏幕刷新率为144的手机中,体验十分糟糕。

1.png

优化:对于渲染时,在一个Text组件中渲染10万条文本,不如生成10万个Text组件,每个组件渲染一行文本。不要以我们的思维去理解Flutter,认为Flutter做某件事会很累。Flutter渲染一个和渲染10万个Text,在性能上没有太多的差距。

test2.gif

@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text("Flutter 长文本渲染测试"),
    ),
    body: Center(
      child: ListView(
        children: <Widget>[
            // 三个点...是dart的语法糖,用于拼接集合(List、Map...),可以将其拼接到一个ListView(Column、Row)上面
          ...Iterable.generate(100000, (i) => Text("Hello Taxze $i"))
        ],
      ),
    ),
  );
}

这样优化后,帧率基本保持在60帧。

2.png

但是,这不是较好的优化方式,属于暴力解法。如果你只需要显示文本的话,你可以使用ListView.build用来逐行动态加载文本,同时给列表指定itemExtentprototypeItem会有更高的性能,所以当我们知道列表项的高度都相同时,强烈建议指定itemExtentprototypeItem。使用prototypeItem是在我们知道列表组件高度相同,但不确定列表组件的具体高度时使用。

body: Center(
          child: ListView.builder(
            prototypeItem: const Text(''),
            itemCount: 100000,
            itemBuilder: (BuildContext context, int index) {
              return Text("Hello Taxze $index");
            },
        ),
    )

当需要像富文本这样,需要图文混排或编辑文本的功能时,那渲染的基本框架像下面这样比较好:

SingleChildScrollView(
  child: Column(
    children: <Widget>[
      ...Iterable.generate(100000, (i) => Text("Hello Taxze $i"))
    ],
  ),
)

当然,真实的业务需求中肯定不是这么简单的,一般需要我们自己魔改SingleChildScrollView,例如在SingleChildScrollView添加一些其他的参数。

富文本块结构定义

知道了文本渲染优化的一些点,那么我们再看向富文本。想要高性能的渲染富文本,那么我们同样不能将所有文本放在一个Editable下渲染。我们需要定义富文本的块状规则,将同一块样式的文本渲染在一个RichText中,将该RichText定义为一个TextLine,一个文本段落。若有图片等WidgetSpan,则将其插入在段落中。遇到单段落文本为长文本时,选择将其分行、分多个RichText渲染。段落规则定义的实现逻辑我们可以参考Quill:

//Quill文档中的一行富文本。输入一个新样式的文本时,会渲染新的一行,且完全占用该行。
class Line extends Container<Leaf?> {
    //判断该行是否嵌入其他元素,例如图片
    bool get hasEmbed {
        return children.any((child) => child is Embed);
    }
    //判断是否为最后一行·
    Line? get nextLine {
        if (!isLast) {
            return next is Block ? (next as Block).first as Line? : next as Line?;
        }
        if (parent is! Block) {
            return null;
        }
​
        if (parent!.isLast) {
            return null;
        }
        ...
    }
  @override
  void insert(int index, Object data, Style? style) {
    final text = data as String;
    //判断是否换行符,如果没有,则不需要更新段落块
    final lineBreak = text.indexOf('\n');
    if (lineBreak < 0) {
      _insertSafe(index, text, style);
      return;
    }
​
    // 如果输入一个文本超过了一行的宽度,则自动换行且继承该行样式。这样就能把TextLine变为Block
    final nextLine = _getNextLine(index);
​
    // 设置新的格式且重新布局
    _format(style);
​
    // 继续插入剩下的文本
    final remain = text.substring(lineBreak + 1);
    nextLine.insert(0, remain, style);
  }
  ...
}

具体的段落规则(插入、删除、嵌入Widget到段落、删除Widget),都需要根据自己的业务来定义。Quill的实现方式只是单做一个参考。

尾述

在这篇文章中,我们分析了Flutter文本的排版与绘制原理,且对文本的渲染进行优化分析。最后的目的都是将这些知识、优化的点结合到富文本中。在对富文本块状规则的定义时,需要结合真实的业务逻辑,避免段落规则的计算部分过于复杂,否则容易造成UI绘制时间过长。希望这篇文章能对你有所帮助,有问题欢迎在评论区留言讨论~

参考

flutter_quill

关于我

Hello,我是Taxze,如果您觉得文章对您有价值,希望您能给我的文章点个❤️,有问题需要联系我的话:我在这里 ,也可以通过掘金的新的私信功能联系到我。如果您觉得文章还差了那么点东西,也请通过关注督促我写出更好的文章——万一哪天我进步了呢?😝