[譯]Flutter是如何繪製文字的

小紅星閃啊閃發表於2020-04-01

原文在這裡。能看原文的推薦看原文。

這不是一次愉悅的旅行,但是我會帶你領略Flutter文字繪製裡從未有過的精彩。第一眼看起來非常的簡單。只不過是幾個字元,對不?但是越往深挖越有難度。

在本文的最後你會學到:

  • widget、elements和繪製物件之間的關係
  • TextRichText下的深度內容
  • 定製自己的文字widget
注意:這是一篇有深度的教程,我假設讀者已經對Flutter的基礎瞭如指掌。當然,如果你非常好奇,一定要看。那麼繼續吧。
複製程式碼

開始

下載初始程式碼。

概覽Flutter Framework

作為一個Flutter開發者,你應該已經對Flutter的statelessstatefule widget頗為熟悉了,但是Flutter裡不只這些。今天我們就來學習一點第三種型別RenderObjectWidget,以及其他相關的底層類。

下面這幅圖把喊了widget的全部子類,藍色的將是本文主要關注的。

[譯]Flutter是如何繪製文字的

RenderObjectWidget是一幅藍圖。它保留了RenderObject的配置資訊,這個類會檢測碰撞和繪製UI。

這下面的圖是RenderObject的子類。最常用的是RenderBox,它定義了螢幕上的用於繪製的長方形區域。RenderParagraph就是Flutter用來繪製文字的。

[譯]Flutter是如何繪製文字的

很快你就要定製自己的文字繪製widget了!

如你所知,flutter通過把widget組織成樹形來實現佈局。在記憶體中對應的會存在一個繪製樹(render object tree)。但是widget和render object是互相不知道對方的。widget不會生成對應的render object,render object也不知道widget樹什麼時候發生了更改。

這就需要element出場了。對應於widget樹,會生成一個element樹。element會保留widget和render object的引用。element就是widget和render object的中間人一樣。他知道什麼時候生成一個render object,如何把他們放在一個樹形裡,什麼時候更新render objects,什麼時候為子widget建立新的element。

下面的一幅圖說明了Element子類,每個element都有一個對應的element。

[譯]Flutter是如何繪製文字的

一個有趣的現象,你一直都在直接操作element,但是你並沒有注意到這一點。你知道BuildContext?這只是Element的一個暱稱而已。更正式一點的說法是,BuildContextElement的抽象類。

理論準備部分到此結束,現在該動手操作了

深入Text Widget

現在我們要深入程式碼來看看到底widget,element和render object是如何運作的。我們就從Text widget開始來看看它是如何建立它的render object:RenderParagraph的。

開啟你的起始專案,執行flutter pub get來獲取依賴包。執行起來之後你會看到這樣的介面:

[譯]Flutter是如何繪製文字的

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 。當你瀏覽完程式碼你形成一個如下圖的認識:

[譯]Flutter是如何繪製文字的

進行如下的步驟:

  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的除錯裡你會看到如下的內容

[譯]Flutter是如何繪製文字的

你應該也會看到如下的stack呼叫。我在括號裡新增了widget或者element的型別。最後邊的數字是後面說明的編號

[譯]Flutter是如何繪製文字的

我們來一步一步看看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架構圖,想必你已經看過:

[譯]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是如何繪製文字的

繼續深入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。

[譯]Flutter是如何繪製文字的

自定義Render Object

為了幫助各位同學理解底層的文字佈局,我把widge、render object和幫助類偶放進了初始專案中。

[譯]Flutter是如何繪製文字的

為了方便你以後定製自己的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歡迎語句:

ᠤᠷᠭᠡᠨ ᠠᠭᠤᠳᠠᠮ ᠲᠠᠯ᠎ᠠ ᠨᠤᠲᠤᠭ ᠲᠤ ᠮᠢᠨᠢ ᠬᠦᠷᠦᠯᠴᠡᠨ ᠢᠷᠡᠭᠡᠷᠡᠢ
複製程式碼

可行的分割點:

[譯]Flutter是如何繪製文字的

我把可分割的每個子串叫做一個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.
複製程式碼

這就很接近了

[譯]Flutter是如何繪製文字的

把子串run放在不同行

現在要看看每行可以放幾個子串run。假設最長的行可以達到下圖綠色的部分:

[譯]Flutter是如何繪製文字的

如上圖,前三個子串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邏輯畫素的約束,差不多也就是下圖裡綠色線的長度:

[譯]Flutter是如何繪製文字的

設定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
複製程式碼

豎排的時候的最小和最大值基本上是這樣的:

[譯]Flutter是如何繪製文字的

在畫布上繪製文字

就快完事兒了。剩下的就是把子串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)的開始位置

下圖顯示了旋轉前後的對比:

[譯]Flutter是如何繪製文字的

這次執行app。

驚豔的效果躍然螢幕上。

[譯]Flutter是如何繪製文字的

擴充套件

如果你不願就此聽不的話。

可以修改的部分

  • 處理新行的字元
  • 讓子串支援TextSpan樹,來實現子串的樣式。也就是開發一個VerticalRichText
  • 新增碰撞檢測semantics
  • 支援Emoji和cjk 字元。讓他們也可以在豎排的時候正確的顯示
  • 如何實現一個豎排的TextField,支援文字的選擇和閃爍的游標

我準備在後面支援這些特性。你可以在這裡來檢視進度或者參與開發。

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

相關文章