我要写的这一系列文章旨在分享一些我想要继续分享,但碍于《Flutter 开发之旅从南到北》中篇幅和话题的限制,没有深入分析的部分,读者们可以在学有余力的情况下在这里继续拓展下去。
本文要讨论的话题是 Flutter 中的文本渲染,也假定你已经大致清楚了 Flutter 中 Widget、Element 和 RenderObject 等概念。
在之前的文章中就有提及,Flutter 源码中除了无状态(StatelessWidget)和有状态(StatefluWidget)这两个直接继承自 Widget 的组件外,还存在其他另类的 Widget,如可以用来统一管理状态的可遗传组件 InheritedWidget
、 用于自渲染组件的 RenderObjectWidget。
关于 StatelessWidget 和 StatefluWidget 的相关内容,我相信大部分读者已经接触的足够多了,那么,今天我们就来一起探究一下 RenderObjectWidget
的奥妙,看看它是如何帮助我们渲染组件的。
RenderObjectWidget
作为一个普普通通的 widget,毫无疑问是处在架构图中 framework 的 Widget 层,然而它的渲染对象 RenderParagraph
就已经到了 Rendering 层了,走进 RenderParagraph
这个类,发现它就是直接继承自我们熟悉的 RenderBox:
class RenderParagraph extends RenderBoxwith ContainerRenderObjectMixin
也就是说,它依然会接受盒子约束限制自己的宽高,他依然渲染的是一个矩形,只不过在矩形内部渲染的子组件是文本而已。
当然,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
就处于架构图中 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 整个 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,
),
),
)
整体效果:
如上所示,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 两个过程,如这里继承自 RenderBox
的 RenderVerticalText
,其中重写了一系列方法,在 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 _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 _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 _lines = [];void _calculateLineBreaks(double maxLineLength) {if (_words.isEmpty) {return;
}if (_lines.isNotEmpty) {
_lines.clear();
}int start &#61; 0;int end;double lineWidth &#61; 0;double lineHeight &#61; 0;// 遍历之前保存的每一个 Word 对象for (int i&#61;0; i<_words.length> end &#61; i;final word &#61; _words[i];final wordWidth &#61; word.paragraph.maxIntrinsicWidth;final wordHeight &#61; word.paragraph.height;// 遇到 “&#xff0c;”、“。” 换行&#xff0c;保存每行的宽度和高度&#xff0c;调用 _addLine 放入 _lines 列表中if (_text.substring(i, i &#43; 1) &#61;&#61; "&#xff0c;" || _text.substring(i, i &#43; 1) &#61;&#61; "。") {
lineWidth &#43;&#61; math.max(lineWidth, wordWidth);
_addLine(start, end&#43;1, lineWidth, lineHeight);
start &#61; end &#43; 1;
lineWidth &#61; 0;
lineHeight &#61; 0;
} else {// 一行未结束&#xff0c;以竖直方向计算&#xff0c;该行的整体高度应该加上一个文字的高度
lineHeight &#43;&#61; wordHeight;
}
}
end &#61; _words.length;if (start _addLine(start, end, lineWidth, lineHeight);
}
}void _addLine(int start, int end, double width, double height) {final bounds &#61; Rect.fromLTRB(0, 0, width, height);final LineInfo lineInfo &#61; LineInfo(start, end, bounds);
_lines.add(lineInfo);
}// 宽度为各行 “诗” 宽度的总和void _calculateWidth() {double sum &#61; 0;for (LineInfo line in _lines) {
sum &#43;&#61; line.bounds.width;
}
_width &#61; sum;
}// 高度取诗中最长的一行(以竖直方向为高)void _calculateIntrinsicHeight() {double sum &#61; 0;double maxRunHeight &#61; 0;for (LineInfo line in _lines) {
sum &#43;&#61; line.bounds.width;
maxRunHeight &#61; math.max(line.bounds.height, maxRunHeight);
}
_minIntrinsicHeight &#61; maxRunHeight;
_maxIntrinsicHeight &#61; maxRunHeight;
}// 计算完每个文字和每行诗的宽高后后&#xff0c;// 就可以在这里将文本绘制到 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 &#43; 20, 0);// 遍历改行每一个 worddouble dy &#61; 0;for (int i &#61; line.textRunStart; i // 绘制每行诗中的文字 ui.Paragraph&#xff0c;偏移量为该字位于所在行的位置
canvas.drawParagraph(_words[i].paragraph, Offset(0, dy));
dy &#43;&#61; _words[i].paragraph.height;
}
}
canvas.restore();
}
}
如上代码所示&#xff0c;便可以真正的将传入的文本绘制在系统提供给我们的 canvas 中了&#xff0c;其中依附下层我们需要做的仅仅是将传入的文本使用 ui.ParagraphBuilder 封装在 ui.Paragraph 对象中&#xff0c;然后再绘制出将该对象传给 canvas.drawParagraph()
即可。
这样&#xff0c;我们自定义的 PoetryText 组件就完成了&#xff0c;包含的几个重要部分如下&#xff1a;
完整代码&#xff0c;参见&#xff1a;https://github.com/MeandNi/flutter_poetry_text
Flutter.cn&#xff1a;https://api.flutter-io.cn/flutter/dart-ui/dart-ui-library.html
Flutter 中三棵重要的树(渲染过程、布局约束、应用视图的构建等)
相关资源可点击「阅读全文」查看我的博客原文。
我的新书《Flutter 开发之旅从南到北》终于和大家见面了&#xff01;(抽奖送书啦)
抽奖进行中....