[译]Flutter是如何绘制文本的

原文在这里。能看原文的推荐看原文。html

这不是一次愉悦的旅行,可是我会带你领略Flutter文本绘制里从未有过的精彩。第一眼看起来很是的简单。只不过是几个字符,对不?可是越往深挖越有难度。android

在本文的最后你会学到:git

  • widget、elements和绘制对象之间的关系
  • TextRichText下的深度内容
  • 定制本身的文本widget
注意:这是一篇有深度的教程,我假设读者已经对Flutter的基础了如指掌。固然,若是你很是好奇,必定要看。那么继续吧。

开始

下载初始代码。github

概览Flutter Framework

做为一个Flutter开发者,你应该已经对Flutter的statelessstatefule widget颇为熟悉了,可是Flutter里不仅这些。今天咱们就来学习一点第三种类型RenderObjectWidget,以及其余相关的底层类。编程

下面这幅图把喊了widget的所有子类,蓝色的将是本文主要关注的。canvas

RenderObjectWidget是一幅蓝图。它保留了RenderObject的配置信息,这个类会检测碰撞和绘制UI。api

这下面的图是RenderObject的子类。最经常使用的是RenderBox,它定义了屏幕上的用于绘制的长方形区域。RenderParagraph就是Flutter用来绘制文本的。架构

很快你就要定制本身的文本绘制widget了!app

如你所知,flutter经过把widget组织成树形来实现布局。在内存中对应的会存在一个绘制树(render object tree)。可是widget和render object是互相不知道对方的。widget不会生成对应的render object,render object也不知道widget树何时发生了更改。less

这就须要element出场了。对应于widget树,会生成一个element树。element会保留widget和render object的引用。element就是widget和render object的中间人同样。他知道何时生成一个render object,如何把他们放在一个树形里,何时更新render objects,何时为子widget建立新的element。

下面的一幅图说明了Element子类,每一个element都有一个对应的element。

一个有趣的现象,你一直都在直接操做element,可是你并无注意到这一点。你知道BuildContext?这只是Element的一个昵称而已。更正式一点的说法是,BuildContextElement的抽象类。

理论准备部分到此结束,如今该动手操做了

深刻Text Widget

如今咱们要深刻代码来看看到底widget,element和render object是如何运做的。咱们就从Text widget开始来看看它是如何建立它的render object:RenderParagraph的。

打开你的起始项目,运行flutter pub get来获取依赖包。运行起来以后你会看到这样的界面:

lib/main.dart,滚动到最下面找到TODO:Start your project journey here这一行:

child: Align(
  alignment: Alignment.bottomCenter,
  child: Text( // TODO: Start your journey here
    Strings.travelMongolia,

widget树里包含了一个Align widget和一个子widget Text 。当你浏览完代码你造成一个以下图的认识:

进行以下的步骤:

  1. Command+click(或者是PC的话Control+click)Text跳转到这个widget的源码里。主要Text是一个无状态widget。
  2. 向下滚动到build方法。这个方法返回什么?是一个RichText widget。Text只是RichText的一个假装而已。
  3. Command+click RichText来到它的源码部分。主要RichText是一个MultiChildRenderObjectWidget。为何是多个child?在Flutter 1.7以前的版本里,它其实叫作LeafRenderObjectWidget,没有子节点,可是如今RichText支持widget spans了。
  4. 滚动到creteRenderObject方法,这里就是建立RenderParagraph的地方。
  5. return RenderParagraph那一行打一个断点。
  6. 在调试模式下再次运行代码

在Android Studio的调试里你会看到以下的内容

你应该也会看到以下的stack调用。我在括号里添加了widget或者element的类型。最后边的数字是后面说明的编号

咱们来一步一步看看RenderParagraph是如何建立的。

  1. 点击SingleChildRenderObjectElement.mount。你就在Alignwidget对应的element里了。在你的layout里,TextAlign的子widget。因此,传到了updateChild方法里的widget.childText widget。
  2. 点击Element.updateChild,在一个长长的方法以后,你的Text widget,被称为newWidget,传入了inflateWidet方法。
  3. 点击Element.inflateWidget。inflate一个widget指的是从这个widget建立一个element。就如你所见Element newChild = newWidget.createElement()。这个时候你还在Align element里,可是你就要单步调试到你刚刚建立的Text element的mount方法里了。
  4. 点击ComponentElement.mount。你如今就在Text elemnt里了。Component element(好比StatelessElement)不会直接建立render object,可是他们会建立其余的element,让这些elemnt去建立render object。
  5. 下面就是几个调用栈的方法了。点击ComponentElement.performRebuild。找到built = build()那一行。这里,同窗们,就是Text widget的build方法被调用的地方。StatelessElement使用了一个setter给本身添加了一个BuildContext参数的引用。那个built变量就是RichText
  6. 点击Element.inflateWidget。这时newWidget是一个RichText,而且它用来建立了MultiChildRenderObjectElement。你还在Text element,不过你就要进入RichText element的mount方法了。
  7. 点击RenderObjectElement.mount。你会惊喜的发现widget.createRenderObject(this)。终于,这就是建立RenderParagraph的地方。参数this就是MultiChildRenderObjectElement
  8. 点击RichText.createRenderObject。注意MultiChildRenderObjectElement就是BuildContext

累了么?这还只是开始,既然你在一个断点上了,那就去喝点水休息片刻吧。后面还有不少精彩内容。

Text Render Object

Flutter架构图,想必你已经看过:

咱们以前看到的内容都在Widget层,接下来咱们就要进入RenderingPaintingFoundation层了。即便咱们要进入这些底层的内容,其实他们仍是很简单的。由于目前还不须要处理多个树的状况。

你还在那个断点上吗?Command+click RenderParagraph,到他的源码看看。

  • RenderParagraph是继承自RenderBox的。也就是说这个render object是一个方形,而且已经具备了绘制内容的固有的高度和宽度。就render paragraph来讲,内容就是文本。
  • 它还会处理碰撞检测。
  • performLayoutpaint方法也颇有趣。

你有没有注意到RenderParagraph并无处理文本绘制的工做,而是交给了TextPainter?在类的上方找到_textPainter。Command+click TextPainter,咱们离开Rendering层,到Painting层来看看。

你会发现什么呢

  • 有一个很重要的ui.Paragraph类型的类成员:_paragraphuidart:ui库里面的类的通用前缀。
  • layout方法。你是没法直接初始化Paragraph类的。你必需要使用一个ParagraphBuilder的类来初始化它。这须要一个默认的对所有文字有效的样式。这个样式能够根据TextSpan树里的样式来修改。调用TextSpan.build()会给ParagraphBuilder对象添加样式。
  • 你会发现paint方法其实很是简单。TextPainter把文本都交给了canvas.drawParagraph()。若是进入这个方法的定义,你会发现它其实调用了paragraph._paint

这时候,你已经来到了Flutter的Foundation层。在TextPainter类里,Comand+click下面的类:

  • ParagraphBuilder: 它添加文字和样式,可是具体的工做都交给了native层。
  • Paragraph:并无什么值得看的。全部的都交给native层处理了。

如今能够中止app的运行了。刚刚看到的均可以总结到一幅图了里面:

继续深刻Flutter的文本引擎

这里,你就要离开Dart的底盘进入native文本绘制引擎了。你不能在command+click了,可是代码都在githubg的Flutter代码库里。文本引擎叫作LibTxt

咱们不会在这部分耗费太多时间,不够若是你喜欢。能够去src目录看。如今咱们来看看叫作Paragraph.dart的native类,它把绘制工做都交给了txt/paragraph_text.cc, 点击连接。

当你有空的时候你能够看看LayoutPaint方法,可是如今咱们来看看这些引入的内容:

#include "flutter/fml/logging.h"
#include "font_collection.h"
#include "font_skia.h"
#include "minikin/FontLanguageListCache.h"
#include "minikin/GraphemeBreak.h"
#include "minikin/HbFontCache.h"
#include "minikin/LayoutUtils.h"
#include "minikin/LineBreaker.h"
#include "minikin/MinikinFont.h"
#include "third_party/skia/include/core/SkCanvas.h"
#include "third_party/skia/include/core/SkFont.h"
#include "third_party/skia/include/core/SkFontMetrics.h"
#include "third_party/skia/include/core/SkMaskFilter.h"
#include "third_party/skia/include/core/SkPaint.h"
#include "third_party/skia/include/core/SkTextBlob.h"
#include "third_party/skia/include/core/SkTypeface.h"
#include "third_party/skia/include/effects/SkDashPathEffect.h"
#include "third_party/skia/include/effects/SkDiscretePathEffect.h"
#include "unicode/ubidi.h"
#include "unicode/utf16.h"

从这里你会看到LibTxt是如何处理文本的。它是基于不少的其余的库的,这里有一些有趣的:

  • Minikin 用于文本的测量和布局
  • ICU 帮助Minikin,把文字分红多行
  • HarfBuzz 帮助Minikin选择正确的字体的形状
  • Skia 在画布上绘制文本和相关的样式

你看的越多就愈加现正确渲染文本须要多少的东西。我都尚未介绍到行距字形集双向文本的问题。

咱们已经学习的足够深刻了,如今咱们要把这些内容用起来了。

建立一个自定义文本widget

咱们要作一些也许你以前历来没有作过的事情。你要自定义一个文本widget了。不是像往常同样的组合起来一些widget,而是建立render object,由它来使用Flutter底层api来绘制文本。

Flutter原本是不容许开发人员来自定义文本布局的,可是Flutter很负责任的作出了修改。

如今咱们的app看起来还不错。可是,若是能支持蒙语就更好了。传统的蒙语很是的不一样。它是从上到下的书写的。Flutter的标准文本widget仅支持水平的书写方式,因此咱们要定制一个能够从上到下书写,从左到右排列的widget。

自定义Render Object

为了帮助各位同窗理解底层的文本布局,我把widge、render object和帮助类偶放进了初始项目中。

为了方便你之后定制本身的render object,我来解释一下我都作了什么。

  • vertical_text.dart:这是VerticalText widget。我从RichText的代码开始写的。我删掉了基本上全部的代码,把它改为了LeafRenderObjectWidget,它没子节点。它会建立RenderVerticalText对象。
  • render_vertical_text.dart: 写这个的时候,把RenderParagraph删掉一部分,以后加入了宽度和高度的测量。它使用了VerticalTextPainter而不是TextPainter
  • vertical_text_painter.dart:我是从TextPainter开始的,而后把不须要的内容所有删除了。我也交换了宽度和高度的计算,删掉了TextSpan支持的复杂的文本样式部分。
  • vertical_paragraph_constraint.dart:我使用了height来作为约束,代替了以前的width
  • vertical_paragraph_builder.dart: 这个部分是从ParagraphBuilder开始。删除了一切不别要的代码。添加了默认的样式并在build方法里返回VerticalParagraph,而不是以前的Paragraph
  • line_breaker.dart:这个是用来代替Minikin的LineBreaker类的。这个类没有在dart里面暴露出来。

计算和测量文本

文本都须要自动换行。要作到这一点你须要找字符串里的一个合适的地方来分割成行。就如前文所述,在写做本文的时候Flutter并无暴露出Minikin/ICU的LineBreake类,可是按照一个空格或者一个词来风格也是一个可接受的方案。

好比这个app欢迎语句:

ᠤᠷᠭᠡᠨ ᠠᠭᠤᠳᠠᠮ ᠲᠠᠯ᠎ᠠ ᠨᠤᠲᠤᠭ ᠲᠤ ᠮᠢᠨᠢ ᠬᠦᠷᠦᠯᠴᠡᠨ ᠢᠷᠡᠭᠡᠷᠡᠢ

可行的分割点:

我把可分割的每一个子串叫作一个run。后面会用TextRun来表示每一个run。

lib/model目录,建立一个文件text_run.dart,把下面的文件粘贴进去:

import 'dart:ui' as ui show Paragraph;

class TextRun {
  TextRun(this.start, this.end, this.paragraph);

  // 1
  int start;
  int end;

  // 2
  ui.Paragraph paragraph;
}

解释一下上面的代码:

  1. 这些是每一个子串run的索引。start索引是包含关系,end是不包含的。如:[start, end)。
  2. 你会为每一个子串run建立一个“paragraph”,这样你就能够得到测量到的size。

dartui/vertical_paragraph.dart里把下面的代码添加到VerticalParagraph,记住import TextRun

// 1
List<TextRun> _runs = [];

void _addRun(int start, int end) {

  // 2
  final builder = ui.ParagraphBuilder(_paragraphStyle)
    ..pushStyle(_textStyle)
    ..addText(_text.substring(start, end));
  final paragraph = builder.build();

  // 3
  paragraph.layout(ui.ParagraphConstraints(width: double.infinity));

  final run = TextRun(start, end, paragraph);
  _runs.add(run);
}

一下内容须要注意:

  1. 你会分别存储字符串里的每一个单词
  2. 在创建paragraph以前添加文本和样式
  3. 你必须在得到测量数据以前调用layout方法。我把width赋值给infinity来确保这子串run只有一行。

_calculageRuns方法里添加以下的代码:

// 1
if (_runs.isNotEmpty) {
  return;
}

// 2
final breaker = LineBreaker();
breaker.text = _text;
final int breakCount = breaker.computeBreaks();
final breaks = breaker.breaks;

// 3
int start = 0;
int end;
for (int i = 0; i < breakCount; i++) {
  end = breaks[i];
  _addRun(start, end);
  start = end;
}

// 4
end = _text.length;
if (start < end) {
  _addRun(start, end);
}

解释以下:

  1. 不须要对子串run屡次计算
  2. 这是我在util目录添加的换行类。这些breaks变量是一列换行的索引的位置
  3. 从文本里面的每一个幻皇建立子串的run
  4. 处理字符串里的最后一个词

如今的代码还不足以在屏幕上显示出什么东西。可是在_layout方法后面添加一个print语句:

print("There are ${_runs.length} runs.");

运行这个app。你应该在console里面看到打印出来的信息:

There are 8 runs.

这就很接近了

把子串run放在不一样行

如今要看看每行能够放几个子串run。假设最长的行能够达到下图绿色的部分:

如上图,前三个子串run能够放进去,可是第四个就要放在一个新行里了。

要编程的方式达到这个效果你须要知道每一个子串run有多长。辛亏这些都存在TextRunparagraph属性里了。

这时须要一个类来存放每行的数据。在lib/model目录下建立一个文件line_info.dart。把下面的代码粘贴进去:

import 'dart:ui';

class LineInfo {
  LineInfo(this.textRunStart, this.textRunEnd, this.bounds);

  // 1
  int textRunStart;
  int textRunEnd;

  // 2
  Rect bounds;
}

dartui/vertical_paragraph.dartVerticalParagraph类,添加下面的代码。记住import LineInfo:

// 1
List<LineInfo> _lines = [];

// 2
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);
}

解释:

  1. 这个列表的长度就是行数
  2. 在这个时候你并无旋转任何字符串,因此widthheight还都是指水平方向的

以后,在_calculateLineBreaks里添加以下代码:

// 1
if (_runs.isEmpty) {
  return;
}

// 2
if (_lines.isNotEmpty) {
  _lines.clear();
}

// 3
int start = 0;
int end;
double lineWidth = 0;
double lineHeight = 0;
for (int i = 0; i < _runs.length; i++) {
  end = i;
  final run = _runs[i];

  // 4
  final runWidth = run.paragraph.maxIntrinsicWidth;
  final runHeight = run.paragraph.height;

  // 5
  if (lineWidth + runWidth > maxLineLength) {
    _addLine(start, end, lineWidth, lineHeight);
    start = end;
    lineWidth = runWidth;
    lineHeight = runHeight;
  } else {
    lineWidth += runWidth;

    // 6
    lineHeight = math.max(lineHeight, run.paragraph.height);
  }
}

// 7
end = _runs.length;
if (start < end) {
  _addLine(start, end, lineWidth, lineHeight);
}

解释以下:

  1. 这个方法必须在子串run计算以后运行
  2. 在不一样的约束下从新布局这些行是OK的
  3. 循环每一个子串run,检查测量数据
  4. Paragraph也有width参数,可是这是约束的宽度,不是测量宽度。由于你把double.infinity做为约束,宽度就是无限的。使用maxIntrinsicWidth或者longestLine会得到子串run的宽度。更多看这里
  5. 找到宽度的和。若是超出了最大值,那么开始一个新行
  6. 当前高度老是同样的,可是在以后你给每一个子串run用了不一样的样式,取最大值能够适用于全部子串run。
  7. 把最后一个子串run做为最后一行

_layout方法的最后加一个print语句看看到此为止的代码是否能够正确运行:

print("There are ${_lines.length} lines.");

来一个hot restart(或者直接从新运行)。你会看到:

There are 3 lines.

这就是你指望的。由于在main.dart里,VerticalText widget有一个300逻辑像素的约束,差很少也就是下图里绿色线的长度:

设置size

系统想要知道widget的size,可是以前你没有足够的数据。如今已经测量了这些行,你能够计算size了。

VerticalParagraph类的——calclateWidth方法里添加以下代码:

double sum = 0;
for (LineInfo line in _lines) {
  sum += line.bounds.height;
}
_width = sum;

为何我说添加高度来获取宽度。由于,width是你给外界的一个值。外界用户看到的是竖排的行。height值是你用在内部的。

这个高度是在有足够高度的时候实际能够得到高度值。在_calculateInstrinsicHeight方法里添加以下代码:

double sum = 0;
double maxRunWidth = 0;
for (TextRun run in _runs) {
  final width = run.paragraph.maxIntrinsicWidth;
  maxRunWidth = math.max(width, maxRunWidth);
  sum += width;
}

// 1
_minIntrinsicHeight = maxRunWidth;

// 2
_maxIntrinsicHeight = sum;

解释以下:

  1. 以前,宽度和高度值由于旋转的关系混在一块儿了。你不但愿任何的一个单词被剪掉,因此widget的最小高度也要保证最长的行能够彻底显示出来。
  2. 若是这个widget把全部的内容都显示在一个最长的竖行里,代码看起来是这样的:
print("width=$width height=$height");
print("min=$minIntrinsicHeight max=$maxIntrinsicHeight");

再次运行代码你会看到以下的输出

width=123.0 height=300.0
min=126.1953125 max=722.234375

竖排的时候的最小和最大值基本上是这样的:

在画布上绘制文本

就快完事儿了。剩下的就是把子串run都绘制出来了。拷贝以下代码并放进draw方法里:

canvas.save();

// 1
canvas.translate(offset.dx, offset.dy);

// 2
canvas.rotate(math.pi / 2);

for (LineInfo line in _lines) {

  // 3
  canvas.translate(0, -line.bounds.height);

  // 4
  double dx = 0;
  for (int i = line.textRunStart; i < line.textRunEnd; i++) {

    // 5
    canvas.drawParagraph(_runs[i].paragraph, Offset(dx, 0));
    dx += _runs[i].paragraph.longestLine;
  }
}

canvas.restore();

解释以下:

  1. 移动到开始的位置
  2. 把画布旋转90度。之前的top如今是right。
  3. 移动到行开始的地方。y值都是负的,这样就会把每行都往上移动,也就是在旋转以后的画布上往右移动了
  4. 每次话一个单词(子串run)
  5. offset就是每一个单词(子串run)的开始位置

下图显示了旋转先后的对比:

此次运行app。

惊艳的效果跃然屏幕上。

扩展

若是你不肯就此听不的话。

能够修改的部分

  • 处理新行的字符
  • 让子串支持TextSpan树,来实现子串的样式。也就是开发一个VerticalRichText
  • 添加碰撞检测semantics
  • 支持Emoji和cjk 字符。让他们也能够在竖排的时候正确的显示
  • 如何实现一个竖排的TextField,支持文本的选择和闪烁的光标

我准备在后面支持这些特性。你能够在这里来查看进度或者参与开发。

以下是一些我找到的特别好的文章:

相关文章
相关标签/搜索