前言
文字是记录语言的书写符号系统,是形、音、义的统一体,是人类最重要的辅助性 交际工具。作为一个Flutter开发者,我们都知道可以通过Text()
这个文本组件将文字显示出来。但是这其中的Flutter的字体是怎么组成的?Flutter文本是怎么构建的?Render Tree
是怎样绘制文本的.....作为本专栏(整个专栏都在与文本打交道)的第一篇文章,让我们从这些原理细节讲起。希望能对你认识Flutter的文本渲染有所帮助。
注:本文的目的在于让大家了解Flutter中的基本文本知识,快速的带大家了解渲染流程,但并未很深入的分析Flutter文本渲染的原理。
字体基础理论通用部分
在整个网络世界中,大家可以将字体理解为一个数字文件,它是一个包含特定大小、粗细和样式的文件。它定义了每个字的形状、大小和图形。
例如Bariol_Regular.otf。.otf
是字体文件格式。
有了字体格式后,我们会碰到相同的字体大小却有不同的显示布局这个问题。因为每一个字体格式都定义了它自己的参考大小,每一个字符都是基于这个大小设计的。所以即使设置同样的字体大小,也会有不同的布局。
在Flutter中文本由哪些部分组成?
Baseline
- 在Flutter中,每一个字符都会在
Baseline
(基线)上。有了这个基线后,就算是不同大小的文字也可以处于同一水平线上。Baseline
是非常重要的,因为可以通过它测量文本和元素之间的垂直距离。其他还有Middleline
、Bottomline
、Topline
。
- Baseline的算法公式推导有兴趣的朋友可以自行搜索。
Text Spacing
- 文字间距是指一段文本中每个文字之间插入的空间。
- 在Flutter中可以通过
TextStyle
下的wordSpacing
设置单词与单词之间的间距,通过letterSpacing
设置字符与字符之间的间距。
Weight
-
Weight是指字体笔画的粗细,在Flutter中通过
fontWeight
设置。常见的有:
normal
、bold
,其他还有FontWeight.w100
...等粗细值
1 2 3 |
TextStyle( fontWeight: FontWeight.bold ), |
TextSpan
在Flutter中,我们经常会使用Text()
这个组件,但是我们通过阅读Text()
的源码后就可以知道,它的build
方法返回的就是RichText
组件。所以它会呈现为TextSpan
。Span指的是字符之间的行距。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
@override Widget build(BuildContext context) { ... Widget result = RichText( ... text: TextSpan( style: effectiveTextStyle, text: data, children: textSpan != null ? <InlineSpan>[textSpan!] : null, ), ); ... return result; } |
Height
在Flutter中,定义了一个TextStyle.height
,用于给呈现文本的TextSpan
一个准确的行高。
1 |
TextStyle(height: 1) |
但是我们需要注意,每一种字体格式都定义了自己的字体度量默认高度,这也是为什么即使设置了相同的字体高度,也会有不同的TextSpan
的高度。
让我们来看下这个例子:
红色是Flutter默认的字体,蓝色是Bariol_Regular字体,绿色是Bellota-Regular字体,看看他们在相同height
下不同的框高度。
-
- 默认height
- height: 1.0
- height:0.8
这个例子也很好的验证了:
- 即使使用一样的fontSize,每种字体也都有不同的高度
- 每一种字体都有不同的基线。
那么关于Flutter的字体组成我们也可以得到一个结论:使用多种字体大概率会因为基线的不同导致布局不协调!
Flutter中是如何绘制文本的?
通过Paragraph
,Flutter
最后绘制文本时都是通过Paragraph
完成的!
1 2 3 |
// Paragraph paragraph:文本对象 // Offset offset:文本绘制的位置 void drawParagraph(Paragraph paragraph, Offset offset) |
举个例子: 通过drawParagraph
绘制一段文字
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
import 'dart:ui' as ui; class TextPainter extends CustomPainter { //创建段落构建器 ParagraphBuilder paragraphBuilder = ParagraphBuilder( ParagraphStyle(fontWeight: FontWeight.bold, fontSize: 16)) ..pushStyle(ui.TextStyle(color: Colors.black)) ..addText('通过drawParagraph绘制的 Hello Taxze'); @override void paint(Canvas canvas, Size size) { //设置段落宽度 ParagraphConstraints paragraphConstraints = ParagraphConstraints(width: size.width); //计算绘制的文本位置及尺寸 Paragraph paragraph = paragraphBuilder.build() ..layout(paragraphConstraints); //绘制 canvas.drawParagraph(paragraph, const Offset(40.0, 50.0)); } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => false; } |
使用:
1 2 3 4 5 6 7 |
@override Widget build(BuildContext context) { return Scaffold( ... body: SizedBox.expand(child: CustomPaint(painter: TextPainter())), ); } |
SizedBox.expand
包裹CustomPaint
是为了给ParagraphConstraints(width: size.width)
一个size
。你也可以用其他的组件包裹它。
关于Flutter使用CustomPaint
绘制文字的实践较为复杂,若要讲清楚绘制的主要知识点,则需要另开一篇文章来讲述。若对这个部分感兴趣的朋友可以阅读下这篇文章:Flutter学习:使用CustomPaint绘制文字 — @菠萝橙子丶
Flutter是如何把一段长文字转变成段落的?
你有没有想过,Flutter是如何把一段长文字生成下面的这样一个段落的呢?
这张效果图的代码:
1 2 3 4 5 6 7 |
Container( color: Colors.red, width: 200, height: 100, margin: EdgeInsets.all(30), child: Text( "通过drawParagraph绘制的 Taxze Hello....")), |
那么其中的自动换行是怎么实现的呢?
我们知道,段落指的就是一段文本,我们要给每个字符一个合适的大小和位置。那么Flutter是如何计算这些参数的呢?
在前文说到过,Flutter最后绘制文本时都是通过Paragraph
完成的。Flutter就是通过Paragraph.layout
来计算这些参数,而且ParagraphBuilder
给每个字符都在渲染前分配了一个偏移量。通过Paragraph
可以知道所有占位符的位置和尺寸大小。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
class TextPosition { //创建一个表示字符串中特定位置的对象。 const TextPosition({ required this.offset, this.affinity = TextAffinity.downstream, }) : assert(offset != null), assert(affinity != null); //举个例子:有一个“Hello”字符,offset = 0表示光标在字符H之前,offset = 5表示光标在字符o之后。 final int offset; final TextAffinity affinity; @override bool operator ==(Object other) { if (other.runtimeType != runtimeType) return false; return other is TextPosition && other.offset == offset && other.affinity == affinity; } @override int get hashCode => Object.hash(offset, affinity); @override String toString() { return 'TextPosition(offset: $offset, affinity: $affinity)'; } } |
Text()背后的大哥有哪些?
--文本的渲染流程
从之前讲述的知识点,Text()
组件它的build
方法返回的就是RichText
,但是Flutter
最后绘制文本时又都是通过Paragraph
完成的!那么其中的完整的一个流程是怎么样的呢?话不多说,先上图!
组件层
如图所示,每当我们使用Text
组件时,它实际上创建的是RichText
组件。但是RichText
和Text
不同的是,Text
将String
作为参数,而RichText
将InlinSpan
作为参数(或者说是TextSpan
)。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const Text(String this.data) //通过Text.rich构造函数传给RichText const Text.rich(InlineSpan this.textSpan) RichText( ... text: TextSpan( style: effectiveTextStyle, text: data, children: textSpan != null ? <InlineSpan>[textSpan!] : null, ), ) //TextSpan继承于InlineSpan class TextSpan extends InlineSpan implements HitTestTarget, MouseTrackerAnnotation {} |
因RichText
接收TextSpan
,而每一个TextSpan
都有更多的子TextSpan
,这些子TextSpan
会 继承父TextSpan
的样式。例如:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
RichText( text: TextSpan( style: Theme.of(context) .textTheme .bodyText1 ?.copyWith(fontSize: 24), children: [ TextSpan( text: 'Taxze ', ), TextSpan(text: 'blog', style: TextStyle(color: Colors.blue)), TextSpan( text: ' Flutter', ), TextSpan(text: '稀土掘金', style: TextStyle(color: Colors.blue)), ])) |
不过,RichText
本身是MultiChildRenderObjectWidget
的子类。它们之间有这样的继承关系:
1 2 |
class RichText extends MultiChildRenderObjectWidget {} abstract class MultiChildRenderObjectWidget extends RenderObjectWidget {} |
而MultiChildRenderObjectWidget
产生的MultiChildRenderObjectElement
则是这样的关系:
1 2 |
class MultiChildRenderObjectElement extends RenderObjectElement {} abstract class RenderObjectElement extends Element {} |
RichText
实际上是需要一个InlineSpan
,而InlineSpan
可以是TextSpan
或者是WidgetSpan
。对WidgetSpan有兴趣的朋友,可以参考官方的文档WidgetSpan。
到这里为止,我们可以将RichText
(包括RichText)之前的所有划分为组件层,那么我们现在就要进入渲染层了。
渲染层
我们已经知道了RichText
会创建一个渲染对象— RenderParagraph
,那么RenderParagraph
是干什么的呢?
RichText
是MultiChildRenderObjectWidget
的子类,它会把MultiChildRenderObjectElement
往下传递,但是此时MultiChildRenderObjectElement
没有渲染,它还没有什么作用。这个时候RichText
会给它一个RenderParagraph
,RenderParagraph
会收到RenderPadding
的指令,这个时候MultiChildRenderObjectElement
就准备好了一切,就可以开始工作了。
这样解释可能有点抽象,那么我们来看下这个例子:
1 2 3 4 |
body: Container( alignment: Alignment.center, child: Text("Taxze Hello"), , ) |
很简单的一个小例子,它的结构也很清晰:
当Flutter把三棵树都构建完后:
那么当我们改变文本时,又会发生什么呢?
最先改变的当然是组件层:
我们会有一个 “新” 的组件树。不过你真的认为都是新的吗?Flutter会充分利用现有的元素,让我们来看下这个名为canUpdate
的方法吧。
1 2 3 4 |
static bool canUpdate(Widget oldWidget, Widget newWidget) { return oldWidget.runtimeType == newWidget.runtimeType && oldWidget.key == newWidget.key; } |
通过这个方法,Flutter可以检查一个老的组件的Type
和key
,并把它和新的组件进行比较。如果它们都相同的话,就不需要更新。
所以就算更新后,Container
更新之后它还是存在的,而且我们没有给它一个Key,所以OldContainer
和NewContainer
是完全相同的。Align、Text、以及RichText它们的Type和Key都没有变化,重新构建它们没有什么意义,所以它们都不会有更新。
到这里,我猜你肯定会问,都没有更新,那么文本是如何改变的呢?
那么我们就要讲到组件中的属性了。组件除了具有Type和Key之外,还有属性。属性的改变会使RenderParagraph
显示新的文本。
不过关于文本的更改渲染到现在我们都是在纸上谈兵,那么我们现在就来用一个简单的例子去验证之前的结论。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
bool _isFirst = true; @override Widget build(BuildContext context) { return Scaffold( floatingActionButton: FloatingActionButton( child: const Icon(Icons.swap_horiz), onPressed: () { setState(() { _isFirst = !_isFirst; }); }, ), body: _isFirst ? first() : second()); } } Widget first() => Container( alignment: Alignment.center, child: const Text("Taxze First"), ); Widget second() => Container( alignment: Alignment.center, child: const Text("Taxze Second"), ); |
非常简单的一个例子,点击按钮更改显示文字。当我们点下按钮时,文本改变后,所有的组件都会重用,Flutter只会重建RenderPadding
。
绘制层
在渲染层中,我们最后发生文本变化都在RenderParagraph
上,不过RenderParagraph
并不会直接的绘制文本,而是会创建一TextPainter
来管理绘制的工作。
不过,TextPainter
做的事和它的名字完全不一样,你以为就是它来绘制文本的吗?No~
实际上,它只是负责管理绘制的事,但它自己不会去绘制(当老板)。
基础层
到现在为止,你会发现,讲了那么多,但是还是没有那个ta去真正的绘制文本,就好像之前的所有的组件都在当中间商,把活外包了出去。
到了Flutter的最底层,你会发现有一个ParagraphBuilder
和Paragraph
,在前面关于Flutter如何绘制文本中,我们也提到了Flutter
最后绘制文本时都是通过Paragraph
完成的,而TextPainter
是负责创建ParagraphBuilder
的,但是当你翻看Paragraph
类的源码时,你会发现,大部分的函数都是空函数,原来这哥们也没干活啊!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
@pragma('vm:entry-point') class Paragraph extends NativeFieldWrapperClass1 { @pragma('vm:entry-point') Paragraph._(); bool _needsLayout = true; double get width native 'Paragraph_width'; double get height native 'Paragraph_height'; double get longestLine native 'Paragraph_longestLine'; double get minIntrinsicWidth native 'Paragraph_minIntrinsicWidth'; double get maxIntrinsicWidth native 'Paragraph_maxIntrinsicWidth'; double get alphabeticBaseline native 'Paragraph_alphabeticBaseline'; ... } |
引擎层
当Paragraph
和ParagraphBuilder
这两个类都将绘制的工作交给了Flutter Engine
后,我们也要将视线放到SkParagraph
上了,在以前Flutter Engine
处理文本绘制的库是LibText
。后面切换成了SkParagraph
,但是也实现了和Libtext
相同的API。对于Flutter引擎在这篇文章中只做一个简单的说明,若对引擎感兴趣的朋友可以自己编译FlutterEngine进行学习,或者在线阅读。
--更详细更深入的Flutter文本渲染原理有兴趣的朋友可以阅读这篇文章
解决Flutter文本基线不对齐的问题
经常在各大Flutter交流群中看到有哥们问这样的问题:Row中,两个文本没有对齐,这怎么处理呀?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
Center( child: Row( children: [ ColoredBox( color: Colors.amber, child: Text.rich(TextSpan(children: [ TextSpan(text: "¥999", style: TextStyle(fontSize: 28)), TextSpan(text: ".9", style: TextStyle(fontSize: 14)), ])), ), ColoredBox( color: Colors.red, child: Text.rich(TextSpan(children: [ TextSpan(text: "123", style: TextStyle(fontSize: 12)), ])), ), ], ), ) |
其实处理这个问题很简单,只需要给Row加上:
1 2 |
textBaseline: TextBaseline.alphabetic, crossAxisAlignment: CrossAxisAlignment.baseline, |
关于更多有关文本的布局问题大家可以查看官方这篇文档。
尾述
在这篇文章中,我们知道了文本是由什么组成的,Flutter是怎样将文本显示到屏幕上的。但是这也只是Flutter关于文本的一小部分,关于文本的编辑...等内容将会在后续的文章中继续探索。希望这篇文章能对你有所帮助,有问题欢迎在评论区留言讨论~
参考&推荐阅读
Flutter Text Rendering — @Jonathan Sande
书后拓展:Flutter 中一行文字到屏幕上,渲染全过程! — @MeandNi
Flutter 小技巧之玩转字体渲染和问题修复 — @恋猫de小郭
Flutter学习:使用CustomPaint绘制文字 — @菠萝橙子丶