The turbulence of the big frontend era: take you to understand the most comprehensive Flutter Web

The turbulence of the big frontend era: take you to understand the most comprehensive Flutter Web

It has been more than a year since the stable version of Flutter Web was released. After more than a year of development, let's take a look at what makes Flutter Web different as a turbulence in the big frontend era. The main content shared in this article is currently a relatively comprehensive Web content rarely seen under Flutter.

Last updated 5/9/2022 7:12 AM
恋猫de小郭
25 min read
Category
Flutter
Tags
Flutter

It has been over a year since the stable release of Flutter Web. After more than a year of development, let's take a look at what makes Flutter Web different as a disruptive force in the era of the big front-end. The main content of this article is about the relatively comprehensive Web content under Flutter that is rarely seen.

This article comes from my offline technical sharing at the "T Technology Salon - Challenges and Opportunities in the Big Front-End Era (Shenzhen Session)."

1. Origin and Implementation

When it comes to the origin of Flutter, it's quite interesting. Everyone knows that early Flutter first supported Android and iOS, and to this day, the core maintenance platforms are still Android and iOS, but in fact, Flutter originated from a front-end team.

Flutter came from the front-end Chrome team. Initially, Flutter's founders and the entire team were almost all from the Web. In interviews with Eric, the head of Flutter, Eric mentioned that Flutter originated from an experiment inside Chrome. After removing some messy Web specifications, internal benchmark tests showed a performance improvement of up to 20 times. So Google started the project internally, and thus Flutter was born.

Additionally, front-end developers should know that Dart was originally designed for the Web. In fact, it has been 10 years since Dart was created, so it can be said that Flutter is full of Web genes.

However, as a framework born from the Web, unlike React Native/Weex, the former had React and Vue implementations on the Web before client support was developed. For Flutter, it's the opposite: client implementation came first, then Web platform support. Here, we can make a simple comparison with Weex.

As a once-shining cross-platform framework, Weex also supports Android, iOS, and Web platforms. On Android and iOS, Weex and React Native are not very different. On the Web, Weex is a stripped-down version of Vue support. However, due to API and platform differences, the Weex experience on the Web has never been great:

Because Weex relies on platform controls for rendering, a Text control needs to accommodate the logic of native platform interfaces on Android, iOS, and Web, leading to various compatibility issues caused by coupling.

Flutter's implementation is more unique. By using Skia to create an independent rendering engine, controls on Android and iOS are almost independent of the platform. Therefore, controls in Flutter can be independent and render consistently across different platforms.

But back to the Web, it's somewhat special. First, the Web platform is completely dominated by html/js/css, and the Web platform needs to consider both PC and Mobile environments. This makes Flutter Web the "most eccentric and unconventional" implementation among all Flutter platforms.

First, Flutter Web shares a common Framework with other Flutter platforms. In theory, most control implementations are universal. Of course, if there is one of the least compatible API objects, it is definitely Canvas. This has to do with Flutter Web's special implementation, which we will discuss later.

Due to the special scenario of the Web, after several twists and turns, Flutter Web implemented two different rendering logics: html and canvaskit. Their differences are as follows:

html

  • Advantages: The html implementation is lighter, and rendering basically relies on various HTMLElements of the Web platform, especially the various <flt-*> implementations defined under Flutter Web. It can be said to be closer to the current Web environment, so sometimes we also call it DomCanvas. Of course, with the development of Flutter Web, this term has also changed somewhat, which we will discuss in detail later.

  • Problems: The problem with html is that it is too close to the Web platform, similar to Weex. Being close to the platform means being coupled to the platform. In fact, the DomCanvas implementation concept is not very suitable for Flutter, leading to some Flutter Web rendering effects having compatibility issues in html mode, especially the Canvas API.

canvaskit

  • Advantages: The canvaskit implementation can be said to be closer to Flutter's philosophy, because it is essentially Skia + WebAssembly implementation logic, which can be more consistent with other platforms and have better performance, such as smoother scrolling list rendering.

  • Problems: Obviously, using WebAssembly will increase the size of the wasm file significantly. In Web scenarios, loading speed is very important, and the space for wasm optimization is very small. Moreover, WebAssembly compatibility is relatively poor, and skia also requires its own font library, among other issues.

By default, when packaging rendering, Flutter Web packages both html and canvaskit together, then uses canvaskit mode on PC and html mode on mobile. Of course, you can also force the rendering mode during packaging using configurations like flutter build web --web-renderer html --release.

Since we have talked about Flutter Web's packaging and building, let's start by diving into Flutter Web from the build and packaging perspective.

2. Build and Optimization

Although Flutter Web shares a common framework with other platforms, it has its own special engine implementation starting from the dart layer. This implementation is a set of special code independent from the framework.

Therefore, when packaging Flutter Web, the default /flutter/bin/cache/lib/_engine becomes the relevant implementation of flutter/bin/cache/flutter_web_sdk/lib/_engine, because the engine under the framework for Flutter Web requires a special set of APIs.

The right side of the image below shows the packaging path for web, compared to the default on the left.

Similarly, as shown below, the web sdk contains different implementations such as html and canvaskit, and even a special text directory, because text support on the Web is a very complex issue.

So now we know that at the _engine level, Flutter Web has its own independent implementation. What does the built output look like?

As shown in the image below, this is a simple open-source example project of GSY. After deployment to the server, default settings without any processing will use canvaskit rendering on PC, mainly including:

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

You can see that these files occupy most of the volume of the Flutter Web compiled output, and the size is indeed quite unacceptable. The example project has a small amount of code and a simple structure, so this volume certainly affects loading speed.

So we first consider choosing one of the two rendering modes, html and canvaskit. For practicality, based on the previous comparison, choosing the html rendering mode is more friendly for compatibility and optimization. So the first step in optimization is to specify the html mode as the rendering engine.

Start Optimization

First, we see the vector icon file CupertinoIcons.ttf. Although the default project creation will add it through cupertino_icons, since we don't need to use it, we can remove it from the yaml file.

After running flutter build web --release --web-renderer html, you can see that the output after loading with html mode is very clean, and the volume that needs optimization is mainly concentrated on main.dart.js and MaterialIcons-Regular.otf.

Although we use some vector icons from MaterialIcons in the project, it is illogical to load a full 1.5 MB font library file every time. Therefore, Flutter officially provides the --tree-shake-icons command to help optimize this part.

However, unfortunately, as shown in the image below, this configuration has a bug in the current 2.10 version. The good news is that in native platform compilation, shake-icons behavior can be executed normally.

So we can first run flutter build apk, and then use the following command to copy the MaterialIcons-Regular.otf resource that has been shake-icons from Android to the already compiled web/ directory.

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

After repackaging, you can see that after optimization, the MaterialIcons-Regular.otf resource is now only 3.2 kB. Next, we consider optimizing the 2.2 MB main.dart.js.

To optimize main.dart.js, we need to talk about deferred-components in Flutter. In Flutter, controls can be defined as "deferred components" to achieve lazy loading. When compiled on Flutter Web, this becomes multiple *part.js texts, essentially splitting the main.dart.js.

For example, first, we define a normal Flutter control and implement it as usual.

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

Where needed, import the corresponding control and add deferred as box keyword. Then at the appropriate time, load the control with box.loadLibrary() and render it with 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();
      },
    );
  }
}

Of course, you also need to add deferred-components in the yaml file to specify the corresponding libraries paths.

deferred-components:
  - name: crane
    libraries:
      - package:gsy_flutter_demo/widget/box.dart

Returning to the GSY example project above, by using relatively extreme subcontracting, each page in the GSY example is turned into a separate lazy-loaded page that is loaded and displayed when navigating. After final packaging and deployment, as shown below:

After splitting, main.dart.js went from 2.2 MB to 1.6 MB, and other content was turned into separate part.js files through deferred components, only dynamically downloading the corresponding part.js file when clicked. However, main.dart.js is still not small, and the official capabilities have little room for further optimization.

For issues encountered with deferred-components, refer to A compilation issue reveals the packaging, building, and subcontracting implementation of Flutter Web

We can use the front-end source-map-explorer tool to analyze this file. First, add the --source-maps command during compilation to generate the source map file for main.dart.js. Then execute source-map-explorer main.dart.js --no-border-checks to generate the corresponding analysis chart:

Here only shows the parts that can be mapped. You can see that 700k is almost the size of the entire Flutter Web framework + engine + VM. There is not much room for optimization in this part. Although there may be some redundant code like kIsWeb, there are actually only about 36 places that can be adjusted and removed. Essentially, Flutter Web also has corresponding compression optimization during packaging, so the benefits are not high.

Additionally, as shown below, there are differences in the code after building with two different web rendders. You can see that the engine code structure after separate builds of html and canvaskit is quite different.

If you use the default auto mode during compilation, both html and canvaskit code will be packaged, so main.dart.js will be correspondingly larger.

Is there anything else that can be optimized? Yes, through external means, such as enabling gzip or brotli compression during deployment. As shown below, enabling gzip can reduce main.dart.js to about 400k.

You can also add a loading effect in index.html to show the waiting process, for example:

<!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>

So roughly, these are today's optimizations for Flutter Web production volume. Summarized as:

  • Remove unused icon references;
  • Use tree-shake-icons to optimize vector icon library references;
  • Implement lazy loading subcontracting through deferred-components;
  • Enable compression algorithms like gzip to compress main.dart.js;

3. Rendering

After talking about building, let's finally discuss rendering. Flutter Web's rendering is very special in Flutter. As mentioned earlier, it comes with two rendering modes. We know that in Flutter's design philosophy, all controls are drawn through the Engine. If you go to see the implementation of Canvas in the framework, you will find that it actually inherits NativeFieldWrapperClass1:

NativeFieldWrapperClass1 means its logic is implemented separately by different platform Engines. The compiled Canvas code on Flutter Web should inherit the structure shown below:

In Flutter Web's Canvas, it determines whether to use CanvasKitCanvas or SurfaceCanvas based on logic. Compared to CanvasKitCanvas, which directly uses skia, SurfaceCanvas, which is closer to the Web platform, has higher coupling complexity.

First, as shown below, this is the general structure of Canvas in Flutter Web. Next, we will mainly focus on SurfaceCanvas. Why is the SurfaceCanvas hierarchy so complex, and how do they allocate drawing? Let's dive into their rules.

Let's look at an example first. As shown below, in html rendering mode, Flutter Web uses a bunch of custom <flt-*> tags to implement rendering. In a long list, the tags are controlled to an appropriate number, and dynamic switching rendering occurs during scrolling.

If we slow down to look at the details, as shown in the animation below, you can see that when an item is not visible, the <flt-picture> actually has no content. When the item becomes visible, a <canvas> tag appears under <flt-picture> to draw the text.

Did you spot a key point? Why is the text drawn by a <canvas> tag instead of a <p> tag? This is the key point of the SurfaceCanvas rendering logic we want to discuss.

In Flutter Web's SurfaceCanvas, text drawing generally appears like this, basically starting from a picture:

In the corresponding picture.dart code, as shown in the key code below, when hasArbitraryPaint is true, it enters the logic of BitmapCanvas; otherwise, it uses DomCanvas.

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

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

  1. First, the biggest difference between BitmapCanvas and DomCanvas is:
  • DomCanvas will create tags to implement drawing, e.g., text using p + span tags for rendering;
  • BitmapCanvas will consider using canvas rendering first; if the scene requires it, tags are used for drawing;
  1. In the web sdk, the hasArbitraryPaint parameter defaults to false, but it is set to true when certain behaviors are executed. Looking at these calls, it can be seen that most of the time the drawing logic enters BitmapCanvas first.

Back to the previous text problem: In Flutter, text drawing is generally implemented through drawParagraph, so theoretically, as long as text exists, it will enter the BitmapCanvas drawing process. So far, this conclusion matches the expectation that the text in the item above is drawn using canvas.

How does Flutter determine when to use canvas and when to use p+span tags for text in BitmapCanvas?

Let's first look at the code below. After running, the effect is shown below. You can see that the text is directly rendered using canvas, which matches our current expectation.

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

Next, add a red background to this code. After running, you can see that the text now becomes p+span tags, and the red background is implemented through a draw-rect tag. There is no canvas in the hierarchy. Why is that?

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

Here we need to first talk about the drawRect implementation of BitmapCanvas. As shown in the key code below, when drawRect meets the condition of the _useDomForRenderingFillAndStroke function, it will be rendered via buildDrawRectElement, i.e., using a draw-rect tag instead of canvas. So we need to analyze the judgment logic of this function.

@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 code below, this function has many conditions. Getting true means meeting one of the three major conditions. The table below roughly describes what each condition represents.

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

The process is roughly as shown in the diagram. When drawing the red background earlier, no special configuration was added, so it enters the _drawElement logic. You can see that for different rendering scenarios, BitmapCanvas adopts different drawing logic. Why did adding a red background cause the text to also become tags?

This is because when BitmapCanvas uses tag construction, i.e., _drawElement, it executes a function called _closeCurrentCanvas, which sets _childOverdraw to true and clears the _canvasPool of canvases.

So let's look at the implementation of drawParagraph, as shown in the code below. You can see that when _childOverdraw is true, the text is drawn using 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 trigger _childOverdraw = true and _canvasPool Empty:

  • _drawElement
  • drawImage/drawImageRect
  • drawParagraph

So to summarize: combining the previous flowchart, we can simply think: Without maskFilter (shadow) and shader (gradient), as long as the above three situations are triggered, tags will be used for drawing.

Does it feel a bit messy?

Don't worry, let's continue with a new example. Based on the original red background implementation, add a shadow to the Container for shadow configuration. After running, you can see that both the background color and text now use 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",
            ),
          ),
        ),
      ),
    )

This is consistent with the expectation from the previous flowchart, because now with the boxShadow parameter, it is converted to maskFilter through the toPaint method during drawing. So when maskFilter != null, the process does not enter the Element judgment, thus using canvas.

Continuing with the previous example, if we add a ColorFiltered control, as mentioned in the table earlier, when there is ShaderMask or ColorFilter, the isInsideSvgFilterTree parameter will be true. At this point, rendering directly uses Element drawing, ignoring other conditions such as BoxShadow. The result is also as shown.

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

Now it becomes two draw-rect and p tags for drawing. Why is there such logic? Because some browsers, such as Safari on iOS devices, do not pass svg filter information to canvas. If canvas is still used, shader masks, etc., may not render correctly. Details can be found at: #27600.

Continue this example. If you don't add ColorFiltered, but add a transform to the Container, after running you can see that it is still implemented with draw-rect and p tags. Because this transform belongs to TransformKind.complex, causing _contains3dTransform = true, thus entering Element logic.

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, another example. Here, revert to only the red background and shadow. Previously, after running, it used canvas tag to render text because maskFilter != null. But now if we configure TextDecoration for Text, after running, you can see that the background color is still canvas, but the text becomes a p tag implementation.

 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 of the drawParagraph function mentioned earlier. In this function, there is another judgment condition _drawOnCanvas. When drawing text in Flutter Web, if the text has a TextDecoration that is not none or has fontFeatures, _drawOnCanvas is set to false, thus becoming a case of using p tag rendering.

This is also understandable. For example, fontFeatures are parameters that affect glyph selection. As shown below, these behaviors are relatively cumbersome to draw on the Web using Canvas. For more on fontFeatures, refer to Another Fun Use of Fonts in Flutter: FontFeature.

We have discussed many examples of BitmapCanvas. When is DomCanvas used?

Remember the method mentioned earlier? To enter _applyDomPaint, you need hasArbitraryPaint == false, meaning no text exists, and when drawRect has no shader (gradient), etc.

Still the previous example, draw a red square with a shadow, but remove the text content. After running, you can see that it is not canvas but a draw-rect tag. Because even though maskFilter != null (with shadow), without text or shader (gradient), a simple drawRect does not trigger hasArbitraryPaint == true, so it directly uses DomCanvas for drawing, completely detached from canvas rendering.

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

So to summarize: First, except for the situations shown in the diagram below, most of the time Flutter Web drawing will enter BitmapCanvas.

Combining the examples introduced earlier, the process after entering BitmapCanvas can be summarized as:

  • If there is ShaderMask or ColorFilter, use Element;
  • Generally ignore _preserveImageData. With complex matrix transformations, also directly use Element, because canvas support for complex matrix transformations is not good;
  • _childOverdraw often achieves the condition together with _canvasPool.isEmpty. Generally, after _drawElement on a picture, _closeCurrentCanvas is called to set _childOverdraw = true and clear _canvasPool;
  • Combining the state of the third condition above, if there is no maskFilter or shader, use Element to render UI;

Finally, for text, there is special handling in drawParagraph. The conditions related to _childOverdraw and !isInsideSvgFilterTree have been explained earlier. The new condition is that when there is TextDecoration or FontFeatures, text drawing also changes to Element, i.e., the form of p + span tags.

4. Conclusion

Although this article covers quite a bit, the knowledge points of Flutter Web in html rendering mode go far beyond this. Starting from a small point like drawRect and text to understand SurfaceCanvas is a very good start.

Additionally, you can see in Flutter Web there are many custom <flt-*> tags. These tags are created through methods like html.Element.tag('flt-canvas');. Their correspondence with Flutter is shown in the image below. If interested, you can view the specific implementation in the dart_sdk.js file in Chrome's source.

Author: The Cat Who Loves Moonlight

Link: https://juejin.cn/post/7095294020900880420/

Source: Rare Earth Nuggets

Copyright belongs to the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.

Keep Exploring

Related Reading

More Articles