什么?掘金文章里居然能跑 flutter app?!

5,923 阅读2分钟

先上效果:

(这是我用 flutter 写的一个 k 线图组件 demo,利用 flutter 动画系统实现丝滑过渡。)

(flutter 动画 demo)

(flame 2d 游戏,需要键盘控制)

点进来的你已经知道了,这是用码上掘金跑的 flutter web app,但为了能在码上掘金跑 flutter 还有一些坑要踩。

首先,如何在码上掘金里跑 flutter web app?

一个简单的想法是把编译好的 js 代码复制到码上掘金里运行,但我们要复制哪些代码呢?这就不得不先研究下 flutter web 是怎么跑起来的。

Flutter Web 是怎么跑起来的

打开编译后的 index.html,你会发现里面只有几行 js 代码,没有任何实体标签:

<script>
// The value below is injected by flutter build, do not touch.
const serviceWorkerVersion = "1381134275";
</script>
<!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script>
<script>
window.addEventListener('load', function(ev) {
  // Download main.dart.js
  _flutter.loader.loadEntrypoint({
    serviceWorker: {
      serviceWorkerVersion: serviceWorkerVersion,
    },
    onEntrypointLoaded: function(engineInitializer) {
      engineInitializer.initializeEngine().then(function(appRunner) {
        appRunner.runApp();
      });
    }
  });
});
</script>

可以看得出,这些代码的目的是加载 main.dart.js,流程大概是 loader.loadEntrypoint(),然后在 onEntrypointLoaded 回调里 initializeEngine(),得到 appRunner 再调用 runApp() app 就跑起来了。但我们要需要知道更具体的细节,继续看 flutter.js

先看 loadEntrypoint

  class FlutterLoader {
    async loadEntrypoint(options) {
      const { serviceWorker, ...entrypoint } = options || {};

      // A Trusted Types policy that is going to be used by the loader.
      const flutterTT = new FlutterTrustedTypesPolicy();

      // The FlutterServiceWorkerLoader instance could be injected as a dependency
      // (and dynamically imported from a module if not present).
      const serviceWorkerLoader = new FlutterServiceWorkerLoader();
      serviceWorkerLoader.setTrustedTypesPolicy(flutterTT.policy);
      await serviceWorkerLoader.loadServiceWorker(serviceWorker).catch(e => {
        // Regardless of what happens with the injection of the SW, the show must go on
        console.warn("Exception while loading service worker:", e);
      });

      // The FlutterEntrypointLoader instance could be injected as a dependency
      // (and dynamically imported from a module if not present).
      const entrypointLoader = new FlutterEntrypointLoader();
      entrypointLoader.setTrustedTypesPolicy(flutterTT.policy);
      // Install the `didCreateEngineInitializer` listener where Flutter web expects it to be.
      this.didCreateEngineInitializer =
        entrypointLoader.didCreateEngineInitializer.bind(entrypointLoader);
      return entrypointLoader.loadEntrypoint(entrypoint);
    }
  }

  _flutter.loader = new FlutterLoader();

loadEntrypoint 一部分是在加载 ServiceWorker,熟悉 flutter web 的可能会知道 ServiceWorker 是可选的,且只在 https 下可用,所以我们不需要关心这部分,关键的代码是另一个 FlutterEntrypointLoaderloadEntrypoint(),直接找到具体实现:

async loadEntrypoint(options) {
  const { entrypointUrl = `${baseUri}main.dart.js`, onEntrypointLoaded } =
    options || {};

  return this._loadEntrypoint(entrypointUrl, onEntrypointLoaded);
}

_loadEntrypoint(entrypointUrl, onEntrypointLoaded) {
  const useCallback = typeof onEntrypointLoaded === "function";

  if (!this._scriptLoaded) {
    this._scriptLoaded = true;
    const scriptTag = this._createScriptTag(entrypointUrl);
    if (useCallback) {
      // Just inject the script tag, and return nothing; Flutter will call
      // `didCreateEngineInitializer` when it's done.
      console.debug("Injecting <script> tag. Using callback.");
      this._onEntrypointLoaded = onEntrypointLoaded;
      document.body.append(scriptTag);
    } else {
      // Inject the script tag and return a promise that will get resolved
      // with the EngineInitializer object from Flutter when it calls
      // `didCreateEngineInitializer` later.
      return new Promise((resolve, reject) => {
        console.debug(
          "Injecting <script> tag. Using Promises. Use the callback approach instead!"
        );
        this._didCreateEngineInitializerResolve = resolve;
        scriptTag.addEventListener("error", reject);
        document.body.append(scriptTag);
      });
    }
  }
}

参数 onEntrypointLoaded 就是 index.html 传进来的,可以看到这个参数其实是可选的,这里主要做一件事:注入 <script> 加载 entrypointUrl,也就是 main.dart.js。到了这里基本可以得到一个结论:

只要把 main.dart.js 跑起来就可以了。

main.dart.js 里的代码复制到 js 编辑框里运行,flutter app 直接就跑起来了,大功告成!不对,怎么出现错误提示“接口请求失败”,保存刷新后发现代码并没有保存。

image.png

开发者工具里可以看到一个失败的请求,意思是请求体太大,被服务端拒绝了。

代码无法保存到码上掘金,就没有什么用了,经过一番研究我找到两个解决方案。

方案一:用外部链接加载 main.dart.js

这是最简单的,只要把 main.dart.js 丢到服务器上,在设置里添加 script 依赖资源就可以把 flutter app 跑起来。文章开头的例子就是用这个方法跑起来的,我这里用的是腾讯云 cos。

image.png

但不是每个人都有服务器,上传服务器也是个麻烦事,还需要注意 script 会被浏览器缓存。

所以最好还是把代码保存到码上掘金里。

方案二:main.dart.js 压缩后转成 base64 放到码上掘金 js 里,运行时解压

main.dart.js 编译出来一般大小 1.6MB,gzip 压缩后可以降到 500KB,不确定服务端能接收多大的请求体,姑且认为是 1MB,压缩后的代码应该是可以成功保存的。

为此,我特地写了一个码上掘金用来压缩代码,直接生成可自解压运行的代码。如果你想亲自体验可以用这个编译好的 main.dart.jsfinanical-charts-demo.dart.js

将生成好的代码复制到另一个码上掘金里即可运行,且保存没有问题:

简单介绍一下代码压缩实现原理:

  1. <input> 选择 js 文件,然后用 FileReader.readAsArrayBuffer(file) 读取选择的文件,得到 ArrayBuffer 格式的数据
  2. ArrayBuffer 数据转成 Uint8Array bytes = new Uint8Array(fileReader.result)
  3. 用 fflate 库的 compress 函数压缩数据 compress(bytes, (_, compressed) => {...})
  4. 压缩后的数据转成 blob blob = new Blob([compressed]) 然后用 FileReader.readAsDataURL(blob) 转成 dataUrl,也就是 base64 字符串。

解压过程:

  1. 用 fetch 把 dataUrl 转成 ArrayBuffer await fetch(data).arrayBuffer()
  2. 用 fflate 库的 decompress 函数解压缩数据 decompress(bytes, (_, decompressed) => {...})
  3. 用 TextEncoder 把二进制数据解码成字符串,这时就得到了未解压的 js 代码,eval 运行即可 eval(new TextDecoder().decode(decompressed))

使用 CDN 加速 canvaskit 加载

默认参数下编译出来的 flutter web app 会用 unpkg.com 加载 canvaskit,但 unpkg.com 在国内访问速度很不理想,所以我们最好用 CDN 加载 canvaskit。编译时指定 canvaskit url:

flutter build web --web-renderer canvaskit --dart-define=FLUTTER_WEB_CANVASKIT_URL=${canvaskit_url}

一些限制

就我所知,目前 flutter web 没法指定 assets 的 base url,意味着不能用 CDN 加在本地 assets,只能用 http 协议网络加载。不过这不是什么问题,码上掘金一般只是做 demo 用,不会有人真的想在掘金文章里跑一个完整的 flutter app 吧。😑

一些畅想

虽然我们已经能在码上掘金上运行 flutter app,但这要我们在本地把代码编译好才行。有没有可能直接在码上掘金里写 flutter,直接浏览器里运行?

还真有可能,码上掘金支持自定义语法,官方模板里就有 dart,只是提供的是 dart 运行器,不能帮我们编译出 js 代码,但只要能用服务端实现 flutter web 代码编译,把编译后的代码 eval 运行就能实现。