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

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

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

正文

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

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

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

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

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 了,读者们也可以发现其中大部分的函数已经变成了在引擎层实现的空函数了。

image-20201001200742678

文本渲染引擎

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

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

最佳实践

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

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

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

整体效果:

如上所示,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,它的代码如下:

import 'package:flutter/painting.dart';
import 'package:steppe_up/dartui/vertical_paragraph.dart';
import 'package:steppe_up/dartui/vertical_paragraph_builder.dart';
import 'package:steppe_up/model/vertical_paragraph_constraints.dart';

class VerticalTextPainter {
VerticalTextPainter({TextSpan text}) : _text = text;

VerticalParagraph _paragraph;
bool _needsLayout = true;

TextSpan get text => _text;
TextSpan _text;

set text(TextSpan value) {
if (_text == value) return;
_text = value;
_paragraph = null;
_needsLayout = true;
}

double _applyFloatingPointHack(double layoutValue) {
return layoutValue.ceilToDouble();
}

double get minIntrinsicHeight {
return _applyFloatingPointHack(_paragraph.minIntrinsicHeight);
}

double get maxIntrinsicHeight {
return _applyFloatingPointHack(_paragraph.maxIntrinsicHeight);
}

double get width {
return _applyFloatingPointHack(_paragraph.width);
}

double get height {
return _applyFloatingPointHack(_paragraph.height);
}

Size get size {
return Size(width, height);
}

double _lastMinHeight;
double _lastMaxHeight;

// 供 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:

import 'dart:math' as math;
import 'dart:ui' as ui;

import 'package:flutter/painting.dart';
import 'package:steppe_up/model/line_info.dart';
import 'package:steppe_up/model/text_run.dart';
import 'package:steppe_up/model/text_zi.dart';
import 'package:steppe_up/model/vertical_paragraph_constraints.dart';
import 'package:steppe_up/util/line_breaker.dart';

class VerticalParagraph {
VerticalParagraph(this._paragraphStyle, this._textStyle, this._text);

ui.ParagraphStyle _paragraphStyle;
ui.TextStyle _textStyle;
String _text;

double _width;
double _height;
double _minIntrinsicHeight;
double _maxIntrinsicHeight;

double get width => _width;

double get height => _height;

double get minIntrinsicHeight => _minIntrinsicHeight;

double get maxIntrinsicHeight => _maxIntrinsicHeight;

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 组件就完成了,包含的几个重要部分如下:

完整代码,参见:https://github.com/MeandNi/flutter_poetry_text

延伸阅读

Flutter.cnhttps://api.flutter-io.cn/flutter/dart-ui/dart-ui-library.html


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

关注公众号「Meandni」,及时阅读最新前沿技术动态,不至于落后时代。

扫一扫,关注「Meandni」

文章作者: Joker
文章链接: https://meandni.com/2020/11/24/e009/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Joker's Blog
支付宝打赏
微信打赏