Flutter Web の安定版がリリースされてから1年以上が経過しました。この1年余りの発展を経て、本日は大フロントエンド時代の異端児とも言える 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 の3プラットフォームをサポートしていました。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 の世界であり、PC と Mobile の異なる環境にも対応する必要があります。これにより、Flutter Web は Flutter の全プラットフォームの中で「最も異色で奇妙な」存在となりました。

まず、Flutter Web は他の Flutter プラットフォームと同様に共通の Framework を共有しており、理論上、大多数のコントロール実装は共通です。ただし、最も互換性のない API オブジェクトと言えば、間違いなく Canvas でしょう。これは Flutter Web の特殊な実装に関係しており、後ほど詳しく説明します。
Web の特殊なシナリオのため、Flutter Web は「紆余曲折」を経て、最終的に2つの異なるレンダリングロジックを採用しました。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 の2つのレンダリングモードのうち、一つを選択することを検討します。実用性を考慮し、前述の比較を踏まえると、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 ではこの設定を実行するとバグが発生します。幸いなことに、ネイティブプラットフォームのビルドでは 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 を追加して、対応するライブラリのパスを指定する必要もあります。
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 ファイルになり、クリック時にのみ動的にダウンロードされます。しかし、main.dart.js は依然として小さくはなく、公式が提供する機能では最適化の余地はあまりありません。
deferred-componentsに関連する問題については、『あるコンパイル問題から Flutter Web のビルドパッケージングと分割バンドル実装を理解する』 を参照してください。
ここで、フロントエンドの source-map-explorer ツールを使用してこのファイルを分析できます。まず、ビルド時に --source-maps オプションを追加して、main.dart.js のソースマップファイルを生成します。次に、source-map-explorer main.dart.js --no-border-checks を実行して分析図を生成します。

ここではマッピング可能な部分のみ表示しています。700k はほぼ Flutter Web 全体の framework + engine + vm のサイズです。この部分の最適化余地は大きくありません。kIsWeb のような冗長コードはいくつかありますが、実際に調整できる箇所は限られており、約36箇所の調整・削減可能な点があります。ビルド時には Flutter Web が適宜最適化・圧縮処理を行うため、この部分の効果は高くありません。

また、下図は異なる web renderer でビルドした後のコードの差異を示しています。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 の成果物サイズ最適化に関する主な内容です。まとめると以下のようになります。
- 不要なアイコン参照を削除する。
tree-shake-iconsを使用してベクターアイコンライブラリの参照を最適化する。deferred-componentsを使用して遅延読み込み分割バンドルを実装する。gzipなどの圧縮アルゴリズムを有効にしてmain.dart.jsを圧縮する。
三、レンダリング
ビルドについて説明した後、最後にレンダリングについて説明します。Flutter Web のレンダリングは Flutter の中で非常に特殊です。前述の通り、2つのレンダリングモードを内蔵しています。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-*> タグでレンダリングを実現しています。長いリストでは、タグの数が適切な数に制御され、スクロール時に動的に切り替えられてレンダリングされます。

このとき、スローモーションで詳細を見ると、以下のアニメーションのように、アイテムが非表示の時は <flt-picture> 内にコンテンツがありませんが、アイテムが表示されると、<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);
}
}
ここで2つの疑問があります。BitmapCanvas と DomCanvas の違いは何か?hasArbitraryPaint の判断ロジックは何か?
まず、
BitmapCanvasとDomCanvasの最大の違いは以下の通りです。DomCanvasはタグを作成して描画を実現します。例えば、テキストはp+spanタグを使用してレンダリングします。BitmapCanvasは可能な限りcanvasを使用してレンダリングすることを優先し、必要に応じてタグを使用して描画します。
web sdk では
hasArbitraryPaintパラメータはデフォルトでfalseですが、以下のような動作を実行する必要がある場合にtrueに設定されます。これらの呼び出しから、大部分の描画ロジックはまずBitmapCanvasに入ることがわかります。

前述のテキストの問題に戻ると、Flutter のテキスト描画は通常 drawParagraph で実現されます。そのため、テキストが存在する限り、理論上は BitmapCanvas の描画フローに入ります。 この結論は、上記のアイテム内のテキストが 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 関数の条件を満たす場合、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 になるには、以下の3つの主要条件のうちいずれかを満たせばよいです。以下の表は各条件が何を意味するかを大まかに示しています。
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 では、以下の3つの操作が _childOverdraw = true と _canvasPool Empty をトリガーします。
_drawElementdrawImage/drawImageRectdrawParagraph
まとめると、前述のフローチャートと合わせて、シンプルに考えれば、maskFilter(shadow)や shader(gradient)がない場合、上記3つのいずれかがトリガーされると、タグを使用して描画されると言えます。
少し混乱してきましたか?
大丈夫です。新しい例を続けて見てみましょう。先ほどの赤い背景の実装をベースに、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 がある場合、isInsideSvgFilterTree パラメータが true になり、レンダリングは BoxShadow などの他の条件を無視して直接 Element を使用します。実行結果もその通りです。
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",
),
),
),
),
),
)

2つの 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",
),
),
),
),
)


最後にもう一つ例を挙げます。赤い背景と影だけの場合に戻ります。以前は maskFilter != null のため、テキストのレンダリングには canvas タグが使用されていました。しかし、ここで Text に TextDecoration を設定すると、実行後は背景色は依然として 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 は false に設定され、p タグを使用したレンダリングになります。
これは理解しやすいでしょう。例えば fontFeatures は字形選択に影響するパラメータであり、下図のように、これらの動作を Web 上で Canvas を使って描画するのは比較的面倒です。fontFeatures の詳細は 『Flutter におけるフォントの別の遊び方:FontFeature』 を参照してください。

これまで多くの例を BitmapCanvas で見てきましたが、DomCanvas はいつ使われるのでしょうか?
先ほど列挙したメソッドを思い出してください。_applyDomPaint に入るには、hasArbitraryPaint == false である必要があります。つまり、テキストがなく、drawRect 時に shader(gradient) などがない場合です。
同じ例で、影のある赤い四角を描画しますが、テキストコンテンツを削除します。実行後、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がクリアされる。- 上記3つ目の条件の状態と組み合わせて、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 のソース内の dart_sdk.js で具体的な実装を確認できます。

著者:恋猫 de 小郭
リンク:https://juejin.cn/post/7095294020900880420/
出典:稀土掘金
著作権は著者に帰属します。商業転載は著者の許可を得てください。非商業転載は出典を明記してください。