How to Optimize Flutter Web and How Flutter Web work in Html Renderer

GSYTech
13 min readMay 10, 2022

--

At present, there are few researches in Flutter Web, and this article will share that How to Optimize Flutter Web and How Flutter Web work in Html Renderer.

First of all, Flutter Web using the same framework as other Flutter platforms. Theoretically, most framework implementations are universal, But there are also some incompatible API, such as Canvas . we will talk about this later.

At the moment, Flutter Web had two Renderer —html and canvaskit :

Html renderer is more lightweight, and basically depends on Web Platform’s API, using custom html tag like <flt-*> . But the problem with html is that it is depends on web platform, it lead to Flutter needs to do a lot of compatible logic on the Flutter Web.

Canvaskit seems to fit the design logic of Flutter more better, because it using Skia + WebAssembly and this will improve performance. But using the wasm file brought by WebAssembly will lead to size become too big. In addition, WebAssembly is poor in compatibility, such as skia also needs to load its own font library.

By default, both html and canvaskit will be packaged in Web release, and then the canvaskit mode will be used on the PC side and the html mode will be used on the mobile side.

Of course, you can also specify the rendering mode through the configuration of flutter build web --web-renderer html --release .

1. Building and Optimization

Although Flutter Web shares framework with other platforms, it has its own special engine implementation from Dart:

When Flutter web is packaged, the default code form/flutter/bin/cache/lib/_enginewill becomes toflutter/bin/cache/flutter_web_sdk/lib/_engine.

As shown in below, we can see that there will be different implementations such as html and canvaskit in the web SDK on the right.

Next, let’s start packaging the Flutter Web. As shown below, it is a simpl project of Flutter. After being deployed to the server, you can see that canvaskit is used for rendering after opening on the desktop, mainly including:

  • 2.3 MB main.dart.js
  • 2.8 MB canvaskit.wasm
  • 1.5 MB MaterialIcons-Regular.otf
  • 284 kB CupertinoIcons.ttf

It can be seen that the size is unacceptable, because sample project is not large and the structure is not complex.

Therefore, we first consider to select one of the rendering Engine in html and canvaskit. I recommend using html renderer here, because we can easier to control it .

First you can see CupertinoIcons.ttf above, although it will be created through cupertino_icons by default when Flutter project created, but since we don’t need to use it, it can be removed from the yaml.

After running flutter build web --release --web-renderer html , you can see that the products loaded in HTML mode are very clean, and the way will to be optimized is now mainly in main.dart.js and MaterialIcons-Regular.otf .

Although we will use some vector icons of MaterialIcons-Regular.otf in the project, it is obviously illogical to load a 1.5 MB font library file in full each time. So the command of --tree-shake-icons provided in Flutter to help optimize this when packing.

Unfortunately, as shown in below, there will be bugs in this configuration under the current version 2.10. Fortunately, the shake-icons behavior can be executed normally in the app platform.

So we can run flutter build apk, and then use the following command to convert the MaterialIcons-Regular.otf:

cp -r ./build/app/intermediates/flutter/release/flutter_assets/ ./build/web/assets

Now you can see that after optimization, MaterialIcons-Regular.otf is only 3.2 KB.

To optimize main.dart.js, we will talk about deferred-component. In Flutter, the lazy loading of Widgets can be realized by defining the Widget as deferred-components, and this behavior will become multiple *part.js after being compiled on Flutter web.

For example, first of all, we define an normal Flutter Widget.

import 'package:flutter/widgets.dart';
class DeferredBox extends StatelessWidget {
DeferredBox() {}
@override
Widget build(BuildContext context) {
return Container(
height: 30,
width: 30,
color: Colors.blue,
);
}
}

Add import by using keyword deferred as box, and then box.loadLibrary() to loads the Widgets, and finally through box.DeferredBox() rendering.

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();
},
);
}
}

In this way, each page in the example is transformed into an independent lazy loading page, and then the part will be loaded when the page open. After the final packaging and deployment, it is shown in the following figure:

You can see the main.dart.js changed from 2.2 MB to 1.6 MB, But it is still not small, and there is not any way to optimization Directly.

Here, you can analyze this file through the source-map-explorer from npmjs.com. First, add the --source-maps during compilation, so that source map will be generated during packaging, and then execute source-map-explorer main.dart.js --no-border-checks to generates the analysis chart:

Here only show the parts that can be mapped. We can see that 700k is almost the size of the whole framewok + engine + VM of Flutter web.

Is there anything else that can be optimized? There are still some external means, such as enabling gzip or brotli compression during deployment, as shown in below. After starting gzip, you can probably make main.dart.js drops to about 400k.

Summarized as follows:

  • Remove useless icon references;
  • Use tree-shake-icons
  • Use deferred-components
  • Open gzip

Now you can try to publish your Flutter Web with better experience.

2.Html Renderer

The rendering of Flutter Web is very special in Flutter. As we said earlier that it comes with two rendering modes, and we know that in the design of Flutter, all Widget are drawn through Canvas. If you look at the implementation of Canvas in the framework at this time, you will find that it actually inherits NativeFieldWrapperClass1:

NativeFieldWrapperClass1 mean That its logic is implemented by Engine of different platforms. The Canvas code on Flutter web like this:

It can be seen that in the Canvas of Flutter web, whether to use CanvasKitCanvas or SurfaceCanvas will be judged according to logic. Compared with the CanvasKitCanvas of skia, the SurfaceCanvas closer to the Web platform will have higher coupling complexity.

First of all, as shown in below, it is the general structure of Canvas in Flutter Web. Next, we will mainly focus on SurfaceCanvas. Why SurfaceCanvas are so complex and how they are allocated and drawn.

Take a look at the example first, As shown in below. It can be seen that in HTML rendering mode, Flutter web has a lot of customized <flt-*> tags to rendering, and in a long list, the tags will be controlled in an appropriate number to dynamically switch rendering when scrolling.

If we slow down to look at the details at this time, as shown in below, we can see that when the item is invisible, there is actually no content in <flt-picture>, and when the item is visible, there will be a <canvas> label under <flt-picture> to draw the text.

Why is the text here drawn by <canvas> instead of <p> and so on? This is the SurfaceCanvas rendering logic we focus on.

In the surfacecanvas of fluent web, text drawing generally occurs in this way. Basically, it starts from the picture and enters the drawing process:

In the SurfaceCanvas of Flutter Web, text drawing generally occurs in this way. Basically, it starts from the picture and enters the drawing process:

As shown in the following key code, when hasArbitraryPaint is true, it will enter the logic of BitmapCanvas, otherwise DomCanvas will be used.

void applyPaint(EngineCanvas? oldCanvas) {
if (picture.recordingCanvas!.renderStrategy.hasArbitraryPaint) {
_applyBitmapPaint(oldCanvas);
} else {
_applyDomPaint(oldCanvas);
}
}

So here are two questions: what is the difference between BitmapCanvas and DomCanvas? What is the judgment logic of hasArbitraryPaint?

1.First, the difference between BitmapCanvas and DomCanvas is:

  • DomCanvas will draw by creating Html Element. For example, Text is rendered with p + span .
  • BitmapCanvas will give priority to canvas rendering. If the scene needs to be rendered with Html Element again.

2. In the web SDK, the parameter ofhasArbitraryPaint is false by default, but it will be set to true when the following behaviors need to be performed. As show from these calls below, in fact, most of the painting logic will enter BitmapCanvas first.

Text rendering in Flutter is generally realized through drawParagraph, so theoretically, as long as there is text, it will enter the rendering process of BitmapCanvas.

When does Flutter use canvas and p+span for Text in BitmapCanvas?

Let’s first look at the following code. After running is shown in below. You can see that the text at this time is directly rendered by canvas.

Scaffold(
body: Container(
alignment: Alignment.center,
child: Center(
child: Container(
child: Text( "v333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333",
),
),
),
),
)

Next, add a red background to Container. After running, you can see that the text becomes p+span, and the red background is realized through the draw-rect. There is no canvas. Why?

Scaffold(
body: Container(
alignment: Alignment.center,
child: Center(
child: Container(
decoration: BoxDecoration(
color: Colors.red,
),
child: Text( "v333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333333",
),
),
),
),
)

Here we need to talk about the drawRect implementation of BitmapCanvas first.

As shown in the following key code, when drawRect is satisfied the function _useDomForRenderingFillAndStroke , the rendering will be into buildDrawRectElement, so the draw-rect is used instead of 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();
}
}

As shown in the following code, we can see that this function has many judgment conditions, and the condition to get true is to meet one of the three conditions. The meaning of each condition is roughly described in the following table.

bool _useDomForRenderingFillAndStroke(SurfacePaintData paint) =>
_renderStrategy.isInsideSvgFilterTree ||
(_preserveImageData == false && _contains3dTransform) ||
((_childOverdraw ||
_renderStrategy.hasImageElements ||
_renderStrategy.hasParagraphs) &&
_canvasPool.isEmpty &&
paint.maskFilter == null &&
paint.shader == null);

The general process is also shown in the below. So there is no special configuration added when drawing the red background with Container, so it will enter _drawElement when drawRect, we can see that BitmapCanvas will adopt different drawing logic for different rendering scenes.

But why does more red background in front lead to the text becoming a label?

This is because if BitmapCanvas is built with HtmlElement when _drawElement, and then _closeCurrentCanvas function will be called, this will set the_childOverdraw true and cleared _canvasPool.

So let’s look at the implementation of drawParagraph. The code shown below can be seen When _childOverdraw is true, the text will be drawn with 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);

····
}

In BitmapCanvas, three operations will be triggered _childOverdraw = true and_canvasPool Empty

  • _drawElement
  • drawImage/drawImageRect
  • drawParagraph

So now we can simply think that: without maskfilter (shadow) and shader (gradient), as long as the above three situations are triggered, HtmlElement drawing will be used.

Does it feel a little messy?

Not afraid. Let’s continue to look at the new example. Based on the original implementation of red background, shadow is added to the Container to configure shadow. After running, you can see that both background color and text become canvas rendering.

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",
),
),
),
),
)

Combined with the previous process, because there is a boxShadow parameter, which will be converted into maskFilter through the toPaint method during painting, so in maskFilter != null , the process will not enter the judgment of Element, so canvas is used.

Continuing with the previous example, if we add a ColorFiltered control at this time, as mentioned in the previous table, when there is a ShaderMask or ColorFilter, the sInsideSvgFilterTree parameter will be true. At this time, the rendering will directly use Element to draw and ignore other conditions, such as BoxShadow. The same is true from the running results.

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",
),
),
),
),
),
)

You can see UI will be drawing into draw-rect andp. why there is such logic? For example, Safari on iOS devices will not pass svg filter to canvas. If you continue to use canvas, it will fail to render normally, such as shader mask. more details: #27600 .

Continue with this example. If you do not add ColorFiltered but add a transform to the Container, you can see the Widget drawing by draw-rectand p after running, because the transform at this time belongs to TransformKind.complex .

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",
),
),
),
),
)

Finally, let’s take another example. Here we return to the case where there is only red background and shadow.

Before that it uses the canvas to render text because of its maskFilter != null , now we configure TextDecoratoin for Text. After running, we can see that the background color is still canvas, but the Text has becomep .

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),
),
),
),
),
);

This is because as mentioned earlier, drawParagraph has another judgment condition in this function_drawOnCanvas: when drawing text on Flutter web, the text has TextDecoration or fontFeatures that are not none,_drawOnCanvas will be set to fasle, which becomes the case of rendering with p.

For example, fontFeatures are parameters that affect font selection. As shown in below, these behaviors are relatively troublesome to draw with Canvas on the web

But when will Domcanvas be used?

Do you remember the methods listed above? You need got hasArbitraryPaint == false if you want to entry _applyDomPaint, such as no text, and then there is no shader ( gradient) during drawRect .

As in the previous example, draw a red box with shadow, but remove the text content.

After running, you can see that it is not canvas but draw-rect, because maskFilter != null(with shadow) exits, but there is no text or shader(gradient), so simple drawRect will not trigger hasArbitraryPaint == true, so it will directly use Domcanvas to paint.

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))
],
),
),
),
),
)

finally, you can get the conclusion shown in below.

Combined with the examples introduced above, the process after entering bitmapcanvas can be summarized as follows:

结合前面介绍的例子,进入到 BitmapCanvas 之后的流程可以总结:

  • If ShaderMask or ColorFilter exists, Element will be used.
  • Generally ignored _preserveImageData also uses Element directly when there is complex matrix transformation, because the canvas support of complex matrix transformation is not good.
  • _After _drawElement , you will get_childOverdraw = true and _canvasPool.isEmpty. if without maskFilter and Shader, Element will be used

In addition, there are some special processing in drawParagraph.

3.Additional

Although there are a lot of things introduced this time, the knowledge of Fluter Web in HTML rendering mode is far more than these. It is a good start to understand SurfaceCanvas from a small perspective and from drawRect and Text.

In addition, you can see that there are many custom <flt-*> tags in Flutter Web. These are through html.Element.tag('flt-canvas');The corresponding relationship between them and the Flutter is shown in the following picture.

If you are interested about this, you can explore them in Chrome with dart_sdk.js.

--

--