Flutter Web 穩定版本發布至今也有一年多了,經過這一年多的發展,今天就讓我們來看看作為大前端時代的亂流,Flutter Web 究竟有什麼不同之處,本篇分享主要內容是目前 Flutter 下少有較為全面的 Web 內容。
本篇來自本人在《T 技術沙龍 - 大前端時代的挑戰與機遇(深圳場)》的線下技術分享。
一、起源與實現
說起 Flutter 的起源就很有意思,大家都知道早期 Flutter 最先支援的平台是 Android 和 iOS ,至今最核心的維護平台依然是 Android 和 iOS ,但是事實上 Flutter 其實起源於前端團隊。
Flutter 來源於前端 Chrome 團隊,起初 Flutter 的創始人和整個團隊幾乎都是來自 Web,在 Flutter 負責人 Eric 的相關訪談中, Eric 表示 Flutter 來自 Chrome 內部的一個實驗,他們把一些亂七八糟的 Web 規範去掉後,在一些內部基準測試的性能居然能提升 20 倍,因此 Google 內部就開始立項,所以 Flutter 出現了。
另外前端的同學應該知道, Dart 起初也是為了 Web 而生,事實上在 Dart 誕生至今也有 10 年了,所以可以說 Flutter 其實充滿了 Web 的基因。
但是作為從 Web 裡誕生的框架,和 React Native / Weex 不同的是,前者是先有了 Web 下的 React 和 Vue 實現之後才有的客戶端支援,而對於 Flutter 則是反過來,先有客戶端實現之後才支援 Web 平台,這裡其實可以和 Weex 做個簡單對照。
Weex 作為曾經閃耀過的跨平台框架,它同樣支援 Android 、iOS 和 Web 三個平台,在 Android 和 iOS 上 Weex 和 React Native 差異性不大,在 Web 上 Weex 則是刪減版的 Vue 支援,而由於 API 和平台差異性的問題,Weex 在 Web 上的支援體驗一直不是很好:
因為 Weex 需要依賴平台控件實現渲染,導致一個 Text 控件需要兼顧 Android 、iOS 和 Web 上原生平台接口的邏輯,從而出現各種由於耦合帶來的相容性問題。
而 Flutter 實現更為特別,透過 Skia 實現了獨立的渲染引擎之後,在 Android 和 iOS 上控件幾乎就與平台無關,所以 Flutter 上的控件可以做到獨立且不同平台上渲染一致的效果。

但是回到 Web 上又有些特殊,首先 Web 平台完全是 html / js / css 的天下,並且 Web 平台需要同時兼顧 PC 和 Mobile 的不同環境,這就讓 Flutter Web 成了 Flutter 所有平台裡「最另類又奇葩」的落地。

首先 Flutter Web 和其他 Flutter 平台一樣共用一套 Framework ,理論上絕大多數的控件實現都是通用的,當然如果要說最不相容的 API 物件,那肯定就是 Canvas 了,這其實和 Flutter Web 特殊的實現有關係,後面我們會聊到這個問題。
而由於 Web 的特殊場景,Flutter Web 在「幾經周折」之後落地了兩種不同的渲染邏輯:html 和 canvaskit ,它們的不同之餘在於:
html
好處:html 的實現更輕量級,渲染實現基本依賴於 Web 平台的各種 HTMLElement ,特別是 Flutter Web 下定義的各種
<flt-*>實現,可以說它更貼近現在的 Web 環境,所以有時候我們也稱呼它為DomCanvas,當然隨著 Flutter Web 的發展這個稱呼也發了一些變化,後續我們會詳細講到這個。問題:html 的問題也在於太過於貼近 Web 平台,這就和 Weex 一樣,貼近平台也就是耦合於平台,事實上
DomCanvas實現理念其實和 Flutter 並不貼切,也導致了 Flutter Web 的一些渲染效果在 html 模式下存在相容問題,特別是Canvas的 API 。
canvaskit
好處:canvaskit 的實現可以說是更貼近 Flutter 理念,因為它其實就是 Skia + WebAssembly 的實現邏輯,能和其他平台的實現更一致,效能更好,比如滾動列表的渲染流暢度更高等。
問題:很明顯使用 WebAssembly 帶來的 wasm 檔案會導致體積增大不少,Web 場景下其實很講究載入速度,而在這方面 wasm 能優化的空間很小,並且 WebAssembly 在相容上也是相對較差,另外 skia 還需要自帶字體庫等問題都挺讓人頭痛。
預設情況下 Flutter Web 在打包渲染時會把 html 和 canvaskit 都打包進去,然後在 PC 端使用 canvaskit 模式,在 mobile 端使用 html 模式 ,當然你也可以在打包時透過 flutter build web --web-renderer html --release 之類的配置強行指定渲染模式。
既然這裡我們講到了 Flutter Web 的打包構建,那就讓我們先從構建打包角度開始來深入介紹 Flutter Web 。
二、構建和優化
Flutter Web 雖說是和其他平台共用一個 framework ,但是它在 dart 層開始就有一套自己特殊的 engine 實現,並且這套實現是獨立於 framework 的一套特殊程式碼。
所以在 Flutter Web 打包時,會把預設的 /flutter/bin/cache/lib/_engine 變成了 flutter/bin/cache/flutter_web_sdk/lib/_engine 的相關實現,這是因為 Flutter Web 在 framework 之下的 engine 需要一套特殊的 API。
下圖右側構建是指定 web 的打包路徑,和左邊預設時的對比。

同樣下圖所示,可以看到 web sdk 裡會有如 html 、 canvaskit 這樣不同的實現,甚至會有一個特殊的 text 目錄,這是因為在 web 上對於文字的支援是個十分複雜的問題。

那到這裡我們知道了在 _engine 層面,Flutter Web 有著自己一套獨立的實現,那構建之後的產物是什麼樣的情況呢?
如下圖所示是 GSY 的一個簡單的開源範例專案,在部署到伺服器後可以看到,預設情況下在不做任何處理時, 在 PC 端打開後會使用 canvaskit 渲染,主要會有:
- 2.3 MB 的
main.dart.js; - 2.8 MB 的
canvaskit.wasm; - 1.5 MB 的
MaterialIcons-Regular.otf; - 284 kB 的
CupertinoIcons.ttf;

可以看到這些檔案佔據了 Flutter Web 編譯後產物的大部分體積,並且從大小上看確實讓人有些無法接受,因為範例專案的程式碼量並不大,結構也不複雜,這樣的體積肯定十分影響載入速度。
所以我們首先考慮在 html 和 canvaskit 兩種渲染模式中先選定一種,出於實用性考慮,結合前面的對比情況,選用 html 渲染模式在相容性和可優化上會更友好,所以這裡優化的第一步就是先指定 html 模式作為渲染引擎。
開始優化
首先可以看到 CupertinoIcons.ttf 這個向量圖標檔案,雖然預設創建專案時會透過 cupertino_icons 被添加到專案裡,但是由於我們不需要使用,所以可以在 yaml 檔案裡去除。
之後透過執行 flutter build web --release --web-renderer html 後,可以看到使用 html 模式載入後的產物很乾淨,而需要優化的體積現在主要在 main.dart.js 和 MaterialIcons-Regular.otf 上。

雖然在專案中我們會使用到 MaterialIcons 的一些向量圖標,但是每次載入都要全量載入一個 1.5 MB 的字體庫檔案顯然並不符合邏輯,所以在 Flutter 裡官方提供了 --tree-shake-icons 的命令幫助我們優化這部分的內容。
但是不幸的是,如下圖所示,在當前的 2.10 版本下該配置執行會有 bug ,而不幸中的萬幸是,在原生平台的編譯中 shake-icons 行為是可以正常執行。

所以我們可以先執行 flutter build apk ,然後透過如下命令,將 Android 上已經 shake-icons 的 MaterialIcons-Regular.otf 資源複製到已經編譯好的 web/ 目錄下。
cp -r ./build/app/intermediates/flutter/release/flutter_assets/ ./build/web/assets
再次打包後可以看到,經過優化後 MaterialIcons-Regular.otf 資源如今只剩下 3.2 kB ,那接下來就是考慮針對 2.2 MB 的 main.dart.js 進行優化處理。

要優化 main.dart.js ,我們就要講到 Flutter 裡的 deferred-components , 在 Flutter 裡可以透過把控件定義為「deferred component」來實現控件的懶載入,而這個行為在 Flutter Web 上被編譯之後就會變成多個 *part.js 文本,原理上就是對 main.dart.js 進行拆包。
舉個例子,首先我們定義一個普通的 Flutter 控件,按照正常的控件進行實現就可以。
import 'package:flutter/widgets.dart';
class DeferredBox extends StatelessWidget {
DeferredBox() {}
@override
Widget build(BuildContext context) {
return Container(
height: 30,
width: 30,
color: Colors.blue,
);
}
}
在需要的地方 import 對應控件然後添加 deferred as box 關鍵字,之後在適當時機透過 box.loadLibrary() 載入控件,最後透過 box.DeferredBox() 渲染。
import 'box.dart' deferred as box;
class MainPage extends StatefulWidget {
@override
_MainPageState createState() => _MainPageState();
}
class _MainPageState extends State<MainPage> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return FutureBuilder<void>(
future: box.loadLibrary(),
builder: (BuildContext context, AsyncSnapshot<void> snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
return box.DeferredBox();
}
return CircularProgressIndicator();
},
);
}
}
當然,這裡還需要額外在 yaml 檔案裡添加 deferred-components 來制定對應的 libraries 路徑。
deferred-components:
- name: crane
libraries:
- package:gsy_flutter_demo/widget/box.dart
回歸到上面的 GSY 範例專案中,透過相對極端的分包實現,這裡把 GSY 範例裡的每個頁面都變成一個獨立的 懶載入頁面,然後在頁面跳轉時再載入顯示,最終打包部署後如下圖所示:

可以看到拆分之後 main.dart.js 從 2.2 MB 變成了 1.6 MB ,而其他內容透過 deferred components 變成了各個 part.js 的獨立檔案,並且只在點擊時才動態下載對應的 part.js 檔案,但是此時的 main.dart.js 依舊並不小,而官方提供的能力上已經沒有太多優化的餘地。
關於
deferred-components會遇到的問題,可以參考 《一個編譯問題帶你了解 Flutter Web 的打包構建和分包實現》
在這裡可以透過前端的 source-map-explorer 工具去分析這個檔案,首先在編譯時要添加 --source-maps 命令,這樣在打包時會生成 main.dart.js 的 source map 檔案,然後就執行 source-map-explorer main.dart.js --no-border-checks 生成對應的分析圖:

這裡只展示能夠被 mapped 的部分,可以看到 700k 幾乎就是 Flutter Web 整個 framewok + engine + vm 的大小,而這部分內容其實可以優化的空間並不大,儘管會有一些如 kIsWeb 的冗餘程式碼,但是其實可以調整的內容並不多,大概有 36 處可以調整和刪減的地方,實質上打包時 Flutter Web 也都有相應的優化壓縮處理,所以這部分收益並不高。

另外,如下圖所示是兩種不同 web rendder 構建後程式碼上的差異,可以看到 html 和 canvaskit 單獨構建後的 engine 程式碼結構差異性還是很大的。

而如果你在編譯時時預設的 auto 模式,就會看到 html 和 canvaskit 的程式碼都會打包進去,所以相對的 main.dart.js 也會增加一些。

那還有什麼可以優化的地方嗎?還是有的,透過外部手段,例如透過在部署時開啟 gzip 或者 brotli 壓縮,如下圖所示 ,開始 gzip 後大概可以讓 main.dart.js 下降到 400k 左右 。

另外也有在 index.html 裡增加 loading 效果來做等待載入過程的展示,例如:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>gsy_flutter_demo</title>
<style>
.loading {
display: flex;
justify-content: center;
align-items: center;
margin: 0;
position: absolute;
top: 50%;
left: 50%;
-ms-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
}
.loader {
border: 16px solid #f3f3f3;
border-radius: 50%;
border: 15px solid;
border-top: 16px solid blue;
border-right: 16px solid white;
border-bottom: 16px solid blue;
border-left: 16px solid white;
width: 120px;
height: 120px;
-webkit-animation: spin 2s linear infinite;
animation: spin 2s linear infinite;
}
@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
}
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
</head>
<body>
<div class="loading">
<div class="loader"></div>
</div>
<script src="main.dart.js" type="application/javascript"></script>
</body>
</html>
所以大致上以上這些就是今天關於 Flutter Web 上產物體積的優化,總結起來就是:
- 去除無用的 icon 引用;
- 使用
tree-shake-icons優化引用向量圖庫; - 透過
deferred-components實現懶載入分包; - 開啟
gzip等壓縮演算法壓縮main.dart.js;
三、渲染
講完構建,最後我們聊聊渲染,Flutter Web 的渲染在 Flutter 裡是十分特殊,前面我們說過它自帶了兩種渲染模式,而我們知道 Flutter 的設計理念裡,所有的控件都是透過 Engine 繪製出來,如果這時候你去 framework 裡看 Canvas 的實現,就會發現它其實繼承的是 NativeFieldWrapperClass1 :

NativeFieldWrapperClass1 也就是它的邏輯是由不同平台的 Engine 區分實現,其中編譯後的 Flutter Web 上的 Canvas 程式碼應該是繼承如下所示的結構:

可以看到在 Flutter Web 的 Canvas 裡會根據邏輯判斷是使用 CanvasKitCanvas 還是 SurfaceCanvas ,而相對於直接使用 skia 的 CanvasKitCanvas ,更貼近 Web 平台的 SurfaceCanvas 在實現的耦合複雜度上會更高。
首先如下圖所示是 Flutter Web 裡 Canvas 的大致結構,而接下來我們要聊的主要也是集中在 SurfaceCanvas 上,為什麼 SurfaceCanvas 層級會這麼複雜,它們又是怎麼分配繪製,接下來就讓深入揭秘它們的規則。

先看例子,如下圖所示,可以看到在 html 渲染模式下, Flutter Web 是有一大堆自訂的 <flt-*> 標籤實現渲染,並且在一個長列表中,標籤會被控制在一個合適的數量,在滾動時動進行動態切換渲染。

如果這時候我們放慢去看細節,如下動圖所示,可以看到當 item 處於不可見時 <flt-picture> 裡其實並沒有內容,而當 Item 可見之後,<flt-picture> 下會有 <canvas> 標籤把文字繪製出來。

**看到一個重點沒有?在這裡的文字為什麼是由 <canvas> 標籤繪製而不是 <p> 標籤之類的呢?**這就是我們重點要講的 SurfaceCanvas 渲染邏輯。
在 Flutter Web 的 SurfaceCanvas 裡,文字繪製一般都會是以這樣的情況出現,基本都是從 picture 開始進入繪製流程:

那麼在對應的 picture.dart 的程式碼實現裡可以看到,如下關鍵程式碼所示,當 hasArbitraryPaint 為 true 時就會進入到 BitmapCanvas 的邏輯,不然就會使用 DomCanvas 。
void applyPaint(EngineCanvas? oldCanvas) {
if (picture.recordingCanvas!.renderStrategy.hasArbitraryPaint) {
_applyBitmapPaint(oldCanvas);
} else {
_applyDomPaint(oldCanvas);
}
}
那麼這裡有兩個問題:BitmapCanvas 和 DomCanvas 的區別是什麼?hasArbitraryPaint 的判斷邏輯是什麼?
1、首先 BitmapCanvas 和 DomCanvas 的最大的區別就是:
DomCanvas會透過創建標籤來實現繪製,比如文本利用p+span標籤進行渲染;BitmapCanvas會考慮優先使用canvas渲染,如果場景需要再使用標籤來實現繪製;
2、在 web sdk 裡 hasArbitraryPaint 參數預設是 false ,但是在需要執行以下這些行為時就會被設置為 true ,而這些調用上可以看出,其實大部分時候的繪製邏輯是會先進入到 BitmapCanvas 裡。

回到前面的文本問題上,在 Flutter 的文本繪製一般都是透過 drawParagraph 實現,所以理論上只要有文本存在,就會進入到 BitmapCanvas 的繪製流程,那麼目前看來這個結論符合上面 Item 裡文本是使用 canvas 繪製的預期。
那 Flutter 裡對於文本,在 BitmapCanvas 又是何時使用canvas 何時使用 p+span 標籤呢?
我們先看如下程式碼,執行後效果如下圖所示,可以看到此時的文本是直接使用 canvas 渲染的,這個結果符合我們目前的預期。
Scaffold(
body: Container(
alignment: Alignment.center,
child: Center(
child: Container(
child: Text( "v333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333",
),
),
),
),
)

接下來給這段程式碼加上一個紅色背景,執行後可以看到,此時的文本變成了 p+span 標籤,並且紅色的背景是透過 draw-rect 標籤實現,層級裡並沒有 canvas ,這又是為什麼呢?
Scaffold(
body: Container(
alignment: Alignment.center,
child: Center(
child: Container(
decoration: BoxDecoration(
color: Colors.red,
),
child: Text( "v333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333",
),
),
),
),
)

這裡就需要先講到 BitmapCanvas 的 drawRect 實現,如下關鍵程式碼所示,在 drawRect 時,如果在滿足 _useDomForRenderingFillAndStroke 這個函數條件的情況下,就會 x 透過 buildDrawRectElement的方式實現渲染,也就是使用 draw-rect 標籤而不是 canvas ,所以我們需要先分析這個函數的判斷邏輯。
@override
void drawRect(ui.Rect rect, SurfacePaintData paint) {
if (_useDomForRenderingFillAndStroke(paint)) {
final html.HtmlElement element = buildDrawRectElement(
rect, paint, 'draw-rect', _canvasPool.currentTransform);
_drawElement(
element,
ui.Offset(
math.min(rect.left, rect.right), math.min(rect.top, rect.bottom)),
paint);
} else {
setUpPaint(paint, rect);
_canvasPool.drawRect(rect, paint.style);
tearDownPaint();
}
}
如下程式碼所示,可以看到這個函數有很多的判斷條件,而得到 true 的條件就是滿足其中三大條件之一即可,下述表格裡大致描述了每個條件所代表的意義。
bool _useDomForRenderingFillAndStroke(SurfacePaintData paint) =>
_renderStrategy.isInsideSvgFilterTree ||
(_preserveImageData == false && _contains3dTransform) ||
((_childOverdraw ||
_renderStrategy.hasImageElements ||
_renderStrategy.hasParagraphs) &&
_canvasPool.isEmpty &&
paint.maskFilter == null &&
paint.shader == null);

大概流程也如圖所示,前面繪製紅色背景時並沒有添加什麼特殊配置,所以會進入到 _drawElement 的邏輯,可以看到針對不同的渲染場景,BitmapCanvas 會採取不一樣的繪製邏輯,那為什麼前面多了紅色背景就會導致文本也變成標籤呢?

這是因為在 BitmapCanvas 如果有使用標籤構建,也就是 _drawElement 的時候,就會執行一個 _closeCurrentCanvas 函數,該函數會把 _childOverdraw 設置為 true ,並且清空 _canvasPool 裡的 canvas 。
所以我們看 drawParagraph 的實現,如下所示程式碼,可以看到由於 _childOverdraw 是 true 時, 文本會採用 Element 來繪製文本。
@override
void drawParagraph(EngineParagraph paragraph, ui.Offset offset) {
····
if (paragraph.drawOnCanvas && _childOverdraw == false &&
!_renderStrategy.isInsideSvgFilterTree) {
paragraph.paint(this, offset);
return;
}
····
final html.Element paragraphElement =
drawParagraphElement(paragraph, offset);
····
}
而在 BitmapCanvas 裡,有三個操作會觸發 _childOverdraw = true 和 _canvasPool Empty :
- _drawElement
- drawImage/drawImageRect
- drawParagraph
所以先總結一下,結合前面的流程圖,我們可以簡單認為:在沒有 maskFilter(shadow) 和 shader(gradient )的情況下,只要觸發了上述三種情況,就會使用標籤繪製。
是不是感覺有點亂?
不怕,先接著繼續看新的例子,在原本紅色背景實現的基礎上,這裡給 Container 增加了 shadow 用於配置陰影,執行之後可以看到,不管是背景色或者文本又都變成了 canvas 渲染的情況。
Scaffold(
body: Container(
alignment: Alignment.center,
child: Center(
child: Container(
decoration: BoxDecoration(
color: Colors.red,
boxShadow: [
BoxShadow(
color: Colors.black54,
blurRadius: 4.0,
offset: Offset(2, 2))
],
),
child: Text(
"v333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333",
),
),
),
),
)

結合前面的流程看這是符合預期,因為此時帶有 boxShadow 參數,該參數會在繪製時透過 toPaint 方法轉化為 maskFilter ,所以在 maskFilter != null 的情況下,流程不會進入到 Element 的判斷,所以使用 canvas 。

繼續前面的例子,如果這時候我們再加一個 ColorFiltered 控件,前面表格說過,有 ShaderMask 或者 ColorFilter 的時候,sInsideSvgFilterTree 參數就會是 true ,這時候渲染就會直接進入使用 Element 繪製而無視其他條件如 BoxShadow ,從執行結果上看也是如此。
Scaffold(
body: Container(
alignment: Alignment.center,
child: Center(
child: ColorFiltered(
colorFilter: ColorFilter.mode(Colors.yellow, BlendMode.hue),
child:Container(
decoration: BoxDecoration(
color: Colors.red,
boxShadow: [
BoxShadow(
color: Colors.black54,
blurRadius: 4.0,
offset: Offset(2, 2))
],
),
child: Text(
"v333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333",
),
),
),
),
),
)

可以看到此時變成了兩個 draw-rect 和 p 標籤的繪製,為什麼會有這樣的邏輯,因為一些瀏覽器,例如 iOS 裝置上的 Safari, 它不會把 svg filter 等資訊傳遞給 canvas ,如果繼續使用 canvas 就會如 shader mask 等無法正常渲染,詳細可見 :#27600 。
繼續這個例子,如果此時不加 ColorFiltered ,而是給 Container 添加一個 transform ,執行後可以看到還是 draw-rect 和 p 標籤的實現,因為此時的 transform 是屬於 TransformKind.complex 的狀態,會導致 _contains3dTransform = true , 從而進入 Element 的邏輯。
Scaffold(
body: Container(
alignment: Alignment.center,
child: Center(
child: Container(
transform: Matrix4.identity()..setEntry(3, 2, 0.001) ..rotateX(100)..rotateY(100),
decoration: BoxDecoration(
color: Colors.red,
boxShadow: [
BoxShadow(
color: Colors.black54,
blurRadius: 4.0,
offset: Offset(2, 2))
],
),
child: Text(
"v333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333",
),
),
),
),
)


最後再來一個例子,這裡回歸到只有紅色背景和陰影的情況,在之前它執行後是使用 canvas 標籤來渲染文本,因為它的 maskFilter != null,但是這時候我們給 Text 配置上 TextDecoratoin ,執行之後可以看到背景顏色依然是 canvas ,但是文本又變成了 p 標籤的實現。
Scaffold(
body: Container(
alignment: Alignment.center,
child: Center(
child: Container(
decoration: BoxDecoration(
color: Colors.red,
boxShadow: [
BoxShadow(
color: Colors.black54,
blurRadius: 4.0,
offset: Offset(2, 2))
],
),
child: Text(
"v333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333",
style: TextStyle(decoration: TextDecoration.lineThrough),
),
),
),
),
);

這是因為前面說過 drawParagraph , 在這個函數裡有另外一個判斷條件 _drawOnCanvas , 在 Flutter Web 繪製文本時,當文本具備不為 none 的 TextDecoration 或者 fontFeatures 時,_drawOnCanvas 就會被設置為 fasle ,從而變成使用 p 標籤渲染的情況。
這也很好理解,例如 fontFeatures 是影響字形選擇的參數,如下圖所示,這些行為在 Web 上用 Canvas 繪製相對會麻煩很多,關於 fontFeatures 可以參考 《Flutter 上字體的另一種玩法:FontFeature》

那前面講了那麼多例子都是 BitmapCanvas , 那 Domcanvas 什麼時候會用到呢?
還記得前面列舉的方法嗎,需要進入 _applyDomPaint 就需要 hasArbitraryPaint == false ,換言之就是沒有文本,然後 drawRect 的時候沒有 shader( radient) 等就可以了。
依然是前面的例子,繪製一個帶有陰影的紅色方框,但是此時把文本內容去掉,執行後可以看到不是 canvas 而是 draw-rect 標籤,因為雖然此時 maskFilter != null (有 shadow),但是因為沒有文本或者 shader( gradient) ,所以單純普通的 drawRect 並不會觸發 hasArbitraryPaint == true, 所以會直接使用 Domcanvas 繪製,完全脫離了 canvas 的渲染。
Scaffold(
body: Container(
alignment: Alignment.center,
child: Center(
child: Container(
height: 50,
decoration: BoxDecoration(
color: Colors.red,
boxShadow: [
BoxShadow(
color: Colors.black54,
blurRadius: 4.0,
offset: Offset(2, 2))
],
),
),
),
),
)

所以最後總結一下:首先除了下圖所示之外的情況,大部分時候 Flutter Web 繪製都會進入到 BitmapCanvas。

結合前面介紹的例子,進入到 BitmapCanvas 之後的流程可以總結:
- 存在 ShaderMask 或者 ColorFilter 就會使用 Element ;
- 一般情況忽略
_preserveImageData,有複雜矩陣變換時也是直接使用 Element ,因為複雜矩陣變換 canvas 支援並不好; - _childOverdraw 經常和 _canvasPool.isEmpty 一起達成條件,一般有 picture 上有 _drawElement 之後就會調用
_closeCurrentCanvas設置_childOverdraw = true並且清空 _canvasPool; - 結合上述第三個條件的狀態,如果沒有 maskFilter 或者 shader ,就會使用 Element 渲染 UI ;

最後針對文本,在 drawParagraph 時還有特殊處理,關於 _childOverdraw 和 !isInsideSvgFilterTree 相關前面解釋過了,新增條件是在有 TextDecoration 或者 FontFeatures 時,也會觸發文本繪製變為 Element ,也就是 p + span 標籤的形式。

四、最後
雖然本次介紹的東西不少 ,但是 Flutter Web 在 html 渲染模式下的知識點遠不止這些,而由小窺大,以 drawRect 和文本為切入點去了解 SurfaceCanvas 就是很不錯的開始。
另外可以看到,在 Flutter Web 裡有很多的自訂的 <flt-*> 標籤,這些標籤都是透過如 html.Element.tag('flt-canvas'); 等方式創建,它們和 Flutter 裡的對應關係如下圖片所示,如果有興趣可以在 chrome 的 source 裡對應的 dart_sdk.js 查看具體實現。

作者:戀貓 de 小郭
鏈接:https://juejin.cn/post/7095294020900880420/
來源:稀土掘金
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。