[譯]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,支持文本的選擇和閃爍的光標

我準備在後面支持這些特性。你能夠在這裏來查看進度或者參與開發。

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

相關文章
相關標籤/搜索