最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 书后拓展:Flutter 中一行文字到屏幕上,渲染全过程!

    正文概述 掘金(Meandni)   2020-11-24   477

    我要写的这一系列文章旨在分享一些我想要继续分享,但碍于《Flutter 开发之旅从南到北》中篇幅和话题的限制,没有深入分析的部分,读者们可以在学有余力的情况下在这里继续拓展下去。

    本文要讨论的话题是 Flutter 中的文本渲染,也假定你已经大致清楚了 Flutter 中 Widget、Element 和 RenderObject 等概念。

    正文

    在之前的文章中就有提及,Flutter 源码中除了无状态(StatelessWidget)和有状态(StatefluWidget)这两个直接继承自 Widget 的组件外,还存在其他另类的 Widget,如可以用来统一传里状态的可遗传组件 InheritedWidget用于自渲染组件的 RenderObjectWidget,关于 StatelessWidget 和 StatefluWidget 的相关内容,我相信大部分读者已经接触的足够多了,那么,今天我们就来一起探究一下 RenderObjectWidget 的奥妙,看看它是如何帮助我们渲染组件的。

    书后拓展:Flutter 中一行文字到屏幕上,渲染全过程!

    上图蓝色区域展示了 Flutter 内置的几种继承自 RenderObjectWidget 的组件,从名字就可以看出,LeafRenderObjectWidget 可用于自定义处于叶子结点组件的组件,而 SingleChildRenderObjectWidgetMultiChildRenderObjectWidget 就可以分别用来自定义具有单个或多个子组件的布局组件。

    和 StatelessWidget、StatefluWidget 一样,RenderObjectWidget 并不负真正责组件的渲染工作,它依然是 Flutter 三棵树中组件树的一员,仅持有 Flutter 渲染组件的配置信息,不同的是,它可以实现 createRenderObject 方法直接创建 RenderObject 对象,这就给了我们一种可以渲染自定义组件的方式。

    图 2 展示了 RenderObject 的所有子类,其中 RenderBox 最常被我们使用,它可以表示在组件间传递盒子约束的渲染对象,从而在屏幕中渲染出一个矩形块,在之前的文章中,我就通过使用它实现了一个自定义的居中布局组件。而 RenderBox 的子类 RenderParagraph 就是 Flutter 专门用来渲染文本的渲染对象

    书后拓展:Flutter 中一行文字到屏幕上,渲染全过程!

    依据以上描述,如果我们在 RenderObjectWidget 中使用 RenderParagraph 这个 RenderObject 对象就可以自定义一个自己的文本控件了,这就跟我们自定义居中布局组件一样简单,不一样的仅仅是,自定义文本组件使用的可能会是无子组件的 LeafRenderObjectWidget

    查看历史源码,Flutter 1.7 之前,专门用于渲染文本的 RichText 组件确实如我们预期继承自无子组件的 LeafRenderObjectWidget,而 Flutter 1.7 之后由于 RichText 也需要支持在内部嵌入 WidgetSpan 组件,实现图文混排的功能,因此改成了 MultiChildRenderObjectWidget 来实现:

    //  1.7 以前
    class RichText extends LeafRenderObjectWidget {
      // ...
    }
    
    
    //  1.7 以后
    class RichText extends MultiChildRenderObjectWidget {
      // ...
    }
    

    这对我们探究如何渲染文本并没有阻碍。最终,在 RichText 内部, RenderParagraph 这个渲染对象也就随之在 MultiChildRenderObjectWidgetcreateRenderObject 方法中生成出来了,代码如下所示:

    @override
    RenderParagraph createRenderObject(BuildContext context) {
      assert(textDirection != null || debugCheckHasDirectionality(context));
      return RenderParagraph(text,
        textAlign: textAlign,
        textDirection: textDirection ?? Directionality.of(context),
        softWrap: softWrap,
        overflow: overflow,
        textScaleFactor: textScaleFactor,
        maxLines: maxLines,
        strutStyle: strutStyle,
        textWidthBasis: textWidthBasis,
        textHeightBehavior: textHeightBehavior,
        locale: locale ?? Localizations.localeOf(context, nullOk: true),
      );
    }
    

    RenderParagraph

    将大致流程介绍完后,我们再往更深入探究,如下是 Flutter 整体架构图:

    书后拓展:Flutter 中一行文字到屏幕上,渲染全过程!

    RenderObjectWidget 作为一个普普通通的 widget,毫无疑问是处在架构图中 framework 的 Widget 层,然而它的渲染对象 RenderParagraph 就已经到了 Rendering 层了,走进 RenderParagraph 这个类,发现它就是直接继承自我们熟悉的 RenderBox:

    class RenderParagraph extends RenderBox
        with ContainerRenderObjectMixin<RenderBox, TextParentData>,
                 RenderBoxContainerDefaultsMixin<RenderBox, TextParentData>,
                      RelayoutWhenSystemFontsChangeMixin
    

    也就是说,它依然会接受盒子约束限制自己的宽高,他依然渲染的是一个矩形,只不过在矩形内部渲染的子组件是文本而已。

    当然,RenderParagraph 作为渲染对象也并不是无所不能,他内部完成渲染文本的使命还主要依靠一个 TextPainter 类型的对象 _textPainter。在 RenderParagraph 的 performLayout 方法和 paint 方法中都使用到了 _textPainter 对象来实现文本的最终绘制,部分代码如下:

    // 负责布局
    @override
    void performLayout() {
      final BoxConstraints constraints = this.constraints;
      _layoutTextWithConstraints(constraints);
    
      // 获取文本大小并布局子组件
      final Size textSize = _textPainter.size;
      size = constraints.constrain(textSize);
      // ...
    }
    
    // 负责绘制
    @override
    void paint(PaintingContext context, Offset offset) {
      _textPainter.paint(context.canvas, offset);
      // ...
    }
    

    所以说, TextPainter 才是我们下一步要继续深挖的内容。

    TextPainter

    到了 TextPainter 就处于架构图中 framework 的 Painting 层了,我们正一步一步逼近根源,在这里,Flutter 会将每种样式的文本分段构成 ui.Paragraph 对象 _paragraph, 每个 ui.Paragraph 对象又由 ParagraphBuilder 生成,ParagraphBuilder 可以接受一个 ui.ParagraphStyle 对象,主要用来配置每个 Paragraph 的最大行数、文本方向、截断方式等信息(在上层我们可以通过 TextStyle 来定义)。TextPainter 类中的 _createParagraphStyle 方法专门用来生成 ui.ParagraphStyle 对象,如下:

    ui.ParagraphStyle _createParagraphStyle([ TextDirection defaultTextDirection ]) {
      // ...
      return _text.style?.getParagraphStyle(
        textAlign: textAlign,
        textDirection: textDirection ?? defaultTextDirection,
        textScaleFactor: textScaleFactor,
        maxLines: _maxLines,
        textHeightBehavior: _textHeightBehavior,
        ellipsis: _ellipsis,
        locale: _locale,
        strutStyle: _strutStyle,
      ) ?? ui.ParagraphStyle(
        textAlign: textAlign,
        textDirection: textDirection ?? defaultTextDirection,
        maxLines: maxLines,
        textHeightBehavior: _textHeightBehavior,
        ellipsis: ellipsis,
        locale: locale,
      );
    }
    

    从这段代码可以看出,用户如果没有自定义样式,TextPainter 也会为文本设置一个默认样式。ui.Paragraph 对象 _paragraph 就由此生成,下面就是 TextPainter 中 layout 方法的部分代码(该方法会在 RenderParagraph 布局内部文本时被调用):

    void layout({ double minWidth = 0.0, double maxWidth = double.infinity }) {
      if (_paragraph == null) {
        // 创建具有特性样式的 ParagraphBuilder
        final ui.ParagraphBuilder builder = ui.ParagraphBuilder(_createParagraphStyle());
        _text.build(builder, textScaleFactor: textScaleFactor, dimensions: _placeholderDimensions);
        // 创建出 ui.Paragraph 对象
        _paragraph = builder.build();
      }
      // ...
    }
    

    最后,TextPainter 直接在 paint 方法中将生成的 _paragraph 对象传入 canvas.drawParagraph 就可以把文本在画布中渲染出来了:

    void paint(Canvas canvas, Offset offset) {
      // ...
      canvas.drawParagraph(_paragraph, offset);
    }
    

    这里,ParagraphBuilder 和 Paragraph 这两个类其实已经处于 framework 层中的最底层 Foundation 了,读者们也可以发现其中大部分的函数已经变成了在引擎层实现的空函数了。

    书后拓展:Flutter 中一行文字到屏幕上,渲染全过程!

    文本渲染引擎

    到这里,我们已经自顶向下走过了 Flutter 整个 framework 层了,由于引擎层代码主要使用 C/C++ 编写,因此没办法在 Android Studio/VSCode 直接阅读,感兴趣的读者们可以自己在本地编译一份 Flutter Engine 代码,也可以直接到官方仓库(https://github.com/flutter/engine/)在线阅读。

    Flutter 引擎层用来渲染文本的引擎叫做 LibTxt,代码集中放在 engine/third_party/txt/ 中,该库依赖了Minikin、ICU、HarfBuzz、Skia 等多个其他引擎库,内容比较庞大,我们暂不深究这一块内容。

    最佳实践

    理论的描述终究还是有点抽象,下面我们就来自己定义一个用来渲染文本的组件,其中就涉及到了对 TextPainter 和 Paragraph 的改写,

    我们要做的这个文本组件 Flutter 官方并未提供,他可以用来将传入的文本垂直展示,因为正好可以用来展示我们的中国诗词,所以我将它命名为了 PoetryText,使用方法如下:

    PoetryText(
      text: TextSpan(
        text: "床前明月光,疑似地上霜,举头望明月,低头思故乡。",
        style: TextStyle(
          color: Colors.black,
          fontSize: 30,
        ),
      ),
    )
    

    整体效果:

    书后拓展:Flutter 中一行文字到屏幕上,渲染全过程!

    如上所示,PoetryText 组件使用起来非常简单,接收一个 text 参数,传入一个特定样式的 TextSpan 即可,代码如下:

    class PoetryText extends LeafRenderObjectWidget {
      const PoetryText({
        Key key,
        this.text,
      }) : super(key: key);
    
      final TextSpan text;
    
      @override
      RenderVerticalText createRenderObject(BuildContext context) {
        return RenderVerticalText(text);
      }
    
      @override
      void updateRenderObject(
          BuildContext context, RenderVerticalText renderObject) {
        renderObject.text = text;
      }
    }
    

    PoetryText 继承自 LeafRenderObjectWidget,它的 createRenderObject 也方法返回一个我们自定义的渲染对象 RenderVerticalText,而这里的 updateRenderObject 方法主要用于 Widget 的配置更新。

    RenderVerticalText 的代码也很简单:

    class RenderVerticalText extends RenderBox {
      RenderVerticalText(TextSpan text)
          : _textPainter = VerticalTextPainter(text: text);
    
      final VerticalTextPainter _textPainter;
    
      TextSpan get text => _textPainter.text;
    
      // 设置渲染的文本内容
      set text(TextSpan value) {
        // 比较新旧文本
        switch (_textPainter.text.compareTo(value)) {
          case RenderComparison.identical:
          case RenderComparison.metadata:
            return;
          case RenderComparison.paint:
            _textPainter.text = value;
            markNeedsPaint();
            break;
          case RenderComparison.layout:
            _textPainter.text = value;
            markNeedsLayout();
            break;
        }
      }
    
      // 布局组件大小
      void _layoutText({
        double minHeight = 0.0,
        double maxHeight = double.infinity,
      }) {
        _textPainter.layout(
          minHeight: minHeight,
          maxHeight: maxHeight,
        );
      }
    
      void _layoutTextWithConstraints(BoxConstraints constraints) {
        _layoutText(
          minHeight: constraints.minHeight,
          maxHeight: constraints.maxHeight,
        );
      }
    
      // 计算盒子最小高度
      @override
      double computeMinIntrinsicHeight(double width) {
        _layoutText();
        return _textPainter.minIntrinsicHeight;
      }
    
      // 计算盒子最大高度
      @override
      double computeMaxIntrinsicHeight(double width) {
        _layoutText();
        return _textPainter.maxIntrinsicHeight;
      }
    
      double _computeIntrinsicWidth(double height) {
        _layoutText(minHeight: height, maxHeight: height);
        return _textPainter.width;
      }
    
      // 计算盒子最小宽度
      @override
      double computeMinIntrinsicWidth(double height) {
        return _computeIntrinsicWidth(height);
      }
    
      // 计算盒子最大宽度
      @override
      double computeMaxIntrinsicWidth(double height) {
        return _computeIntrinsicWidth(height);
      }
    
      // 返回从文本顶部到第一个基线的距离
      @override
      double computeDistanceToActualBaseline(TextBaseline baseline) {
        return _textPainter.height;
      }
    
      // 布局
      @override
      void performLayout() {
        _layoutTextWithConstraints(constraints);
        final Size textSize = _textPainter.size;
        size = constraints.constrain(textSize);
      }
    
      // 渲染
      @override
      void paint(PaintingContext context, Offset offset) {
        _textPainter.paint(context.canvas, offset);
      }
    }
    

    每个 RenderObject 都会经历 layout 和 paint 两个过程,如这里继承自 RenderBoxRenderVerticalText ,其中重写了一系列方法,在 performLayout()paint() 分别用来做布局和渲染两个过程。

    当然,在屏幕中渲染的任务还是主要交给了 VerticalTextPainter 类型的对象 _textPainter,它的代码如下:

    class VerticalTextPainter {
      VerticalTextPainter({TextSpan text}) : _text = text;
    
      VerticalParagraph _paragraph;
      bool _needsLayout = true;
    
      TextSpan get text => _text;
      TextSpan _text;
    
      // ...
    
      // 供 RenderVerticalText 布局时调用
      void layout({double minHeight = 0.0, double maxHeight = double.infinity}) {
        if (!_needsLayout &&
            minHeight == _lastMinHeight &&
            maxHeight == _lastMaxHeight) return;
        _needsLayout = false;
        if (_paragraph == null) {
          final VerticalParagraphBuilder builder = VerticalParagraphBuilder(null);
          _applyTextSpan(builder, _text);
          _paragraph = builder.build();
        }
        _lastMinHeight = minHeight;
        _lastMaxHeight = maxHeight;
        // 调用 _paragraph 的 layout 方法
        _paragraph.layout(VerticalParagraphConstraints(height: maxHeight));
        if (minHeight != maxHeight) {
          final double newHeight = maxIntrinsicHeight.clamp(minHeight, maxHeight);
          if (newHeight != height)
            _paragraph.layout(VerticalParagraphConstraints(height: newHeight));
        }
      }
    
      // 设置 VerticalParagraphBuilder 参数
      void _applyTextSpan(VerticalParagraphBuilder builder, TextSpan textSpan) {
        final style = textSpan.style;
        final text = textSpan.text;
        final bool hasStyle = style != null;
        if (hasStyle) {
          builder.textStyle = style;
        }
        if (text != null) {
          builder.text = text;
        }
      }
    
      // 供 RenderVerticalText 绘制时调用
      void paint(Canvas canvas, Offset offset) {
        _paragraph.draw(canvas, offset);
      }
    }
    

    该类源自 TextPainter,TextPainter 的默认的实现是将文本水平绘制,而这里我们就可以修改部分逻辑,通过传入的文本定义自己文本组件的宽高,实现垂直展示的文本组件。

    与 Flutter 源码保持一致,我们再将任务交给与 Flutter 中 Paragraph 对应的 VerticalParagraph:

    class VerticalParagraph {
      VerticalParagraph(this._paragraphStyle, this._textStyle, this._text);
    
      ui.ParagraphStyle _paragraphStyle;
      ui.TextStyle _textStyle;
      String _text;
    
      // ...
    
      List<Word> _words = [];
    
      void layout(VerticalParagraphConstraints constraints) =>
          _layout(constraints.height);
    
      void _layout(double height) {
        if (height == _height) {
          return;
        }
        int count = _text.length;
        for (int i=0; i<count; i++) {
          _addWord(i);									// 保存文本中的每个字
        }
        _calculateLineBreaks(height);		// 计算换行
        _calculateWidth();							// 计算宽度
        _height = height;
        _calculateIntrinsicHeight();		// 计算高度
      }
    
      void _addWord(int index) {
        final builder = ui.ParagraphBuilder(_paragraphStyle)
          ..pushStyle(_textStyle)
          ..addText(_text.substring(index, index + 1));
        final paragraph = builder.build();
        paragraph.layout(ui.ParagraphConstraints(width: double.infinity));
        // 将每个字都保存在一个 ui.Paragraph 对象中,并封装在 Word 放入 _words 列表
        final run = Word(index, paragraph);
        _words.add(run);
      }
    
      List<LineInfo> _lines = [];
    
      void _calculateLineBreaks(double maxLineLength) {
        if (_words.isEmpty) {
          return;
        }
        if (_lines.isNotEmpty) {
          _lines.clear();
        }
    
        int start = 0;
        int end;
        double lineWidth = 0;
        double lineHeight = 0;
        // 遍历之前保存的每一个 Word 对象
        for (int i=0; i<_words.length; i++) {
          end = i;
          final word = _words[i];
          final wordWidth = word.paragraph.maxIntrinsicWidth;
          final wordHeight = word.paragraph.height;
          // 遇到 “,”、“。” 换行,保存每行的宽度和高度,调用 _addLine 放入 _lines 列表中
          if (_text.substring(i, i + 1) == "," || _text.substring(i, i + 1) == "。") {
            lineWidth += math.max(lineWidth, wordWidth);
            _addLine(start, end+1, lineWidth, lineHeight);
            start = end + 1;
            lineWidth = 0;
            lineHeight = 0;
          } else {
            // 一行未结束,以竖直方向计算,该行的整体高度应该加上一个文字的高度
            lineHeight += wordHeight;
          }
        }
        end = _words.length;
        if (start < end) {
          _addLine(start, end, lineWidth, lineHeight);
        }
      }
    
      void _addLine(int start, int end, double width, double height) {
        final bounds = Rect.fromLTRB(0, 0, width, height);
        final LineInfo lineInfo = LineInfo(start, end, bounds);
        _lines.add(lineInfo);
      }
    
      // 宽度为各行 “诗” 宽度的总和
      void _calculateWidth() {
        double sum = 0;
        for (LineInfo line in _lines) {
          sum += line.bounds.width;
        }
        _width = sum;
      }
    
      // 高度取诗中最长的一行(以竖直方向为高)
      void _calculateIntrinsicHeight() {
        double sum = 0;
        double maxRunHeight = 0;
        for (LineInfo line in _lines) {
          sum += line.bounds.width;
          maxRunHeight = math.max(line.bounds.height, maxRunHeight);
        }
        _minIntrinsicHeight = maxRunHeight;
        _maxIntrinsicHeight = maxRunHeight;
      }
    
      // 计算完每个文字和每行诗的宽高后,
      // 就可以在这里将文本绘制到 canvas 了。
      void draw(Canvas canvas, Offset offset) {
        canvas.save();
        // 移至开始绘制的位置
        canvas.translate(offset.dx, offset.dy);
        // 绘制每一行
        for (LineInfo line in _lines) {
          // 移到绘制该行的开始处
          canvas.translate(line.bounds.width + 20, 0);
          // 遍历改行每一个 word
          double dy = 0;
          for (int i = line.textRunStart; i < line.textRunEnd; i++) {
            // 绘制每行诗中的文字 ui.Paragraph,偏移量为该字位于所在行的位置
            canvas.drawParagraph(_words[i].paragraph, Offset(0, dy));
            dy += _words[i].paragraph.height;
          }
        }
    
        canvas.restore();
      }
    }
    

    如上代码所示,便可以真正的将传入的文本绘制在系统提供给我们的 canvas 中了,其中依附下层我们需要做的仅仅是将传入的文本使用 ui.ParagraphBuilder 封装在 ui.Paragraph 对象中,然后再绘制出将该对象传给 canvas.drawParagraph() 即可。

    这样,我们自定义的 PoetryText 组件就完成了,包含的几个重要部分如下:

    • PoetryText,继承 LeafRenderObjectWidget 的 Widget。
    • RenderVerticalText,继承 RenderBox 的组件 的 RenderObject。
    • VerticalTextPainter,改写自 TextPainter。
    • VerticalParagraph,改写自 Paragraph。

    完整代码,参见:github.com/MeandNi/flu…

    延伸阅读

    Flutter.cn:api.flutter-io.cn/flutter/dar…


    我的新书《Flutter 开发之旅从南到北》终于和大家见面了!(抽奖送书啦)

    抽奖进行中....


    起源地下载网 » 书后拓展:Flutter 中一行文字到屏幕上,渲染全过程!

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元