Flutter文字渲染模块总结(一)

2021-11-12 18:17:03 浏览数 (1)

1.文字渲染概述

1.1 字体存储

​ 把文字渲染到屏幕上主要是通过加载字体获得字形(Glyph)纹理,然后通过字体测量计算出字体左上角的位置和宽高,然后再把纹理贴到2D方块中。字体的存储主要有两种方式:

其一是位图字体,这是比较早起的纹理存储方式,主要是把字形存储到一张大纹理中,然后加载字体的时候主要是加载这张大纹理,如下图所示:

​ 这种方式的优点就是,字体被预先渲染好 ,效果会很高,但是你的程序会被限制在一个固定的分辨率,如果你对这些文字进行放大的话你会看到文字的像素边缘。每次想使用不同的字体时,你不得不重新生成位图字体。

​ 另一种更加灵活的方式就是矢量字体,其主要是通过一些数学公式(贝塞尔曲线),类似于矢量图像,根据需要的字体大小来生成纹理,可以很好的适配不同的分辨率,而没有任何质量损失。比如现在用的比较多的TrueType,这这方式字体加载就是将字形矢量路径绘制出来,得到字形对应的纹理,如下图所示:

​ 在渲染时,会动态生成需要用到的字符的字形位图并缓存起来,不同字号的字符需要不同的位图。这样字形的解析和渲染就会非常耗时,一般都会通过缓存机制进行优化, 比如Skia的文字绘制有两种方式:

  1. 文字绘制过程需要将文字解析为路径,然后绘制路径,缓存路径
  2. 将文字解析为Mask(32*32的A8图片),然后绘制模板,缓存模板

1.2. 渲染过程

​ 有了纹理,还需要确定文字方块的位置和大小信息,这些信息主要是通过字形的metrics信息来确定的,字形的metrics信息在文字排版的时候也会用到,主要的参数如下图所示:

当我们需要绘制一个字形的时候,首先需要找到左上角的的坐标,计算方式如下所示:

代码语言:javascript复制
float xpos = x   bearingX * scale;
float ypos = y - (size.height - bearingY) * scale;
float width = size.width * scale;
float height = size.height * scale;

`

可以看出g会渲染到baseline以下的位置,所以height和bearingY不相等,这样y坐标就会往下移。比如渲染如下文字

它的方块信息如下所示:

2. Flutter文字渲染模块

Flutter文字渲染相关的模块比较核心的主要有包含两种种类型:

  • 支持混排的富文本RichText
  • 支持编辑的EditableText

2.1 RichText组件

RichText可以实现不同风格的Text放到一起渲染,还可支持图文混排,可以看一下它的用法:

​ 可以看到RichText主要是通过串联不同InlineSpan,实现不同风格的文字或者图文混排效果,目前InlineSpan主要包括两种,TextSpan和PlaceHolderSpan,继承关系如下图所示:

​ WidgetSpan继承至PlaceholderSpan,PlaceholderSpan会在文字排版的时候作为占位符参与排版,所以WidgetSpan可以在排版完之后得到准确的位置信息,将字节点绘制到正确的位置。

​ RichText继承至MultiChildRenderObjectWidget,对应的RenderObject是RenderParagraph,RenderParagraph最核心的两个逻辑主要是排版和渲染,而RenderParagraph的Layout和Paint过程最终都会调用到TextPainter对象,然后再由TextPainter触发Engine层最终的排版和渲染,整体框架如下图所示:

2.1.1 Layout

Layout主要分成三步:

  1. LayoutChildren

这个过程主要是收集PlaceholderSpan节点的信息,后面Text的排版过程需要用到

  1. LayoutText

这一步主要是文字排版,首先需要把刚才的placeholder信息更新到TextPainter

代码语言:javascript复制
//render_paragraph.dart
void _layoutTextWithConstraints(BoxConstraints constraints) {
  _textPainter.setPlaceholderDimensions(_placeholderDimensions);
  _layoutText(minWidth: constraints.minWidth, maxWidth: constraints.maxWidth);
}

然后再看具体的TextPainter里面LayoutText过程:

  1. SetParentData

这里主要是把上一步排版后的placeholder的box信息更新的对应的child节点,主要需要计算offset和scale信息

2.1.2 Paint

Paint过程主要包括两个部分,文字渲染和占位Widget渲染,还有前后的裁剪处理,下面只贴出渲染部分

​ textPainter的paint的方法就是直接调canvas.drawParagraph,然后就是渲染占位的child,这里会同意用一个TransformLayer包一层,进行排版结果的变换,主要包括offset和scale信息,然后传人闭包里面绘制各自的child。

2.1.3 标脏逻辑

​ 由于文字的排版和渲染是个非常耗时的过程,不可能每一帧都重新执行Layout和Paint,而Flutter本身也会有针对重新排版和绘制的优化方式,所以可以通过控制Layout和Paint的标脏逻辑来进行优化。Flutter关于文字的标脏逻辑主要是通过在更新文字信息的时候进行比较,通过不同的比较结果确定是否下一帧的动作,比较结果主要有以下几种情况:

代码语言:javascript复制
enum RenderComparison {
  /// The two objects are identical (meaning deeply equal, not necessarily  
  /// [dart:core.identical]).
  identical,

  /// The two objects are identical for the purpose of layout, but may be different
  /// in other ways.
  ///
  /// For example, maybe some event handlers changed.
  metadata,

  /// The two objects are different but only in ways that affect paint, not layout.
  ///
  /// For example, only the color is changed.
  ///
  /// [RenderObject.markNeedsPaint] would be necessary to handle this kind of
  /// change in a render object.
  paint,

  /// The two objects are different in ways that affect layout (and therefore paint).
  ///
  /// For example, the size is changed.
  ///
  /// This is the most drastic level of change possible.
  ///
  /// [RenderObject.markNeedsLayout] would be necessary to handle this kind of
  /// change in a render object.
  layout,
}

​ 可以看出这四种情况变化的剧烈层度也是逐渐增加的,前两个结果对于RenderObject都是无变化,后面两个一个是需要重新绘制,一个是需要重新排版,当然重新排版意味着重新绘制。来看一下它的比较过程:

中间比较两个style变化,不同的变化会产生不同的结果,比较过程如下图所示:

比如如果只是颜色信息的更改则只需要重新绘制,如果是其它字体信息的变更,则可能需要重新排版。

​ 可以看到如果只是颜色或者装饰的修改,只需要重绘即可,而如果是其它,比如字体大小,字体类型的变更则需要重新排版。通过上述标脏逻辑来实现渲染和排版的优化。

2.2 EditableText组件

​ Flutter的EditableTextWidget组件可能是所有Widget中最复杂的一个组件,包含了手势和键盘的交互,以及文本的编辑。先看一下其核心模块排版和渲染过程,不过因为EditableText不支持富文本的方式排版,所以其排版过程只是单纯的文字版本,所以只需要关注渲染这一块,当然还有交互。

2.2.1 Paint

这里面内容绘制主要包括四个部分,如下图所示:

  1. Caret绘制

光标绘制核心主要是坐标的计算,通过手势转换成文字排版的字型坐标,然后生成rect信息,最后结合alpha动画可以实现光标的闪烁。

  1. Selection绘制

选中区域的绘制核心也是手势交互的时候计算出字形Selection区域,然后找到selection对于的box进行绘制即可

  1. Text绘制

canvas.drawParagraph(_paragraph, offset)

  1. FloatingCaret

这里主要是也是光标Rect的计算,会根据Drag事件实时调整位置。

2.2.2 交互

  1. 手势识别

手势识别主要有两种:

一是Tap获取光标的位置,这一步需要将touch的屏幕坐标转换到字形的坐标,这里面代码比较复杂先不展示,计算步骤主要分如下几步:

1.根据Tap位置计算glyph坐标,需要基于排版结果

2.从当前glyph坐标向前或者向后搜索,找到第一个TextBox

3.根据TextBox左上角的坐标生成光标Rect,再绘制

二是LongPress获取选中区域,这一步主要是根据touch的屏幕坐标找到最近的一个单词(如果是英文),也需要基于排版信息。

核心逻辑主要在Engine层的文字排版。

  1. 键盘输入

3. 目前存在的问题

  1. 不可以同时支持编辑和图文混排

RichText只支持图文混排,不知道编辑;EditableText只支持编辑,不知道混排,目前官方并没有一个组件即可支持编辑,同时也可以支持混排

​ 主要是因为EdtiableText支持对应的RenderObject只普通TextSpan的输入,如果要支持混排则需要加入WidgetSpan,通过魔改一下,其实应该是可以做到编辑加混排,需要改一下Layout和Paint过程,当然配套的插件也需要更改,在我准备去尝试的时候,发现已经有大佬魔改出一个版本,有兴趣的可以试试。

https://github.com/Fearimdly/rich_text_field

  1. 缺乏一些更底层(low level)的接口

Flutter目前很多LibTxt的接口并没有开放出来,比如类似Android的LineBreaker,目前只支持整体段落的排版,所有有些效果没办法高效实现。

比如:

用文本填充非矩形形状

在非线性路径上书写文本

Android有drawTextOnPath这样的接口可以实现,Skia也提供了这样的接口,但目前Flutter并未开放出来。

​ 另外如果一个段落中每个字符都有一个固定的坐标,这种情况下Flutter要实现只能是为每一个字符都提供一个TextPainter,执行Layout和Paint,这样如果文字较多势必会非常耗时,官方类似这样的Issues讨论有很多,目前还是有很多问题亟待解决。

https://github.com/flutter/flutter/issues/35994

https://github.com/flutter/flutter/issues/16477

最后:上述所有内容只涉及到Framwork层的代码,但其实核心的文字排版和渲染的实现都在Engine层,大概看了一下,排版过程没怎么看懂,目前正在配置Engine调试,等后面通过Debug搞懂真正的排版逻辑再更新。

0 人点赞