通过打包脚本来解决 Flutter Web 首屏加载速度过慢和缓存更新不及时的问题。 思路在 之前的文章 里提过,就是进行大文件切片并行下载,以及资源文件的文件名哈希化。 实践中还需要对部分特殊的资源引用做处理。

原理

Flutter Web 目前(3.16.5)的编译成品是一个 SPA(单页应用),通过挂载 JS 脚本到宿主 HTML 的 body 上来渲染页面。 和其他类似前端框架不同的是,它基本不使用原生的控件,而是在屏幕上放了一块画布,所有的UI绘制,动画,页面滚动等都是自己的引擎来实现的。

成也萧何败也萧何,这样做的好处就是屏蔽了各端的差异,保持了体验上的高一致性;坏处则是无法享受浏览器自身的优化, 在部分场景下有明显的性能差距等。且不论是哪种渲染器,都需要携带渲染引擎,因此即使是一个空 Demo,挂载脚本也至少有2M多的大小。

本文通过打包脚本进行一些资源加载流程的改进,以优化体验。

整体流程

  1. 编译(指定 HTML render,关闭 PWA)
  2. 复制 /build/web 到指定目录,删除不需要的文件
  3. 拆分 main.dart.js 文件
  4. 改造 index.html 中并行下载和挂载使用的方法
  5. 字体文件添加 hash 后缀,更改清单文件中的文件名
  6. 图片等一般资源文件添加 hash 后缀,更新对应的清单文件
  7. 通过 rootBundle 读取的资源文件,不会从清单中读取,添加版本号后缀

具体步骤

整个脚本文件就不放上来了,摘录一些主要的方法以供参考。 下文中 dart 方法执行时所在的根目录,均为编译打包后复制到指定目录的 /build/web

1. 编译

1
flutter build web --web-renderer html --pwa-strategy none --release

2. 清理无用文件

编译成品中不会用到的,需要删除的文件和目录有:

1
2
3
4
./canvaskit/                   // html 渲染模式下无用
./assets/NOTICES               // 版权声明
./last_build_id                // 这个 id 有时候不会变,还是版本号靠谱
./flutter_service_worker.js    // 用于提供PWA功能,已为空文件

3. 拆分 main.dart.js

包含了业务逻辑和渲染引擎,下载它是首屏加载缓慢的元凶。
此处平均拆分为几个小js文件,数量可以指定,本文一般选择 6 个。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
Future splitMainJs() async {
  final mainJsSrc = targetPath + '/main.dart.js';
  final mainJs = File(mainJsSrc);
  final mainJsContent = await mainJs.readAsString();
  // 取文件哈希值的前四位
  mainJsHash = mainJsContent.hashCode.toRadixString(16).substring(0, 4);
  // 按长度拆分,pieceNum 为分片的数量
  final atomLen = mainJsContent.length ~/ pieceNum;
  for (var i = 0; i < pieceNum; i++) {
    final start = i * atomLen;
    final len = i == pieceNum - 1 ? mainJsContent.length - start : atomLen;
    final part = mainJsContent.substring(start, start + len);
    final partFile = File(mainJsSrc.replaceFirst(
        'main.dart.js', 'main_${mainJsHash}_${i + 1}.js'));
    await partFile.writeAsString(part);
  }
  await mainJs.delete();
}

4. 改造 index.html

这里提前规定了被打包项目的 HTML 中,body 的第一个 <script> 块为挂载方法, 因此直接取了第一个。默认可用,如果前面添加了其他方法需要自己确定位置来替换。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
Future modifyIndexHtml() async {
  final indexHtmlSrc = targetPath + '/index.html';
  final indexHtml = File(indexHtmlSrc);
  final indexHtmlContent = await indexHtml.readAsString();

  // 替换原有的加载方法
  final bodyStart = indexHtmlContent.indexOf('<body>');
  final start = indexHtmlContent.indexOf('<script>', bodyStart);
  final end = indexHtmlContent.indexOf('</script>', bodyStart) + 9;
  final oldScript = indexHtmlContent.substring(start, end);
  final newHtmlContent =
      indexHtmlContent.replaceFirst(oldScript, getNewLoadScript());
  indexHtml.writeAsString(newHtmlContent);
}

String getNewLoadScript() {
  // 下载分片,合并挂载的 JS 方法
  return '''
  <script>
    var pieceLs = [];
    for (var i = 0; i < $pieceNum; i++) {
      pieceLs.push('main_${mainJsHash}_' + (i + 1) + '.js');
    }

    function downloadSplitJs(url) {
      return new Promise((resolve, reject) => {
        const xhr = new XMLHttpRequest();
        xhr.open("get", url, true);
        xhr.onreadystatechange = () => {
          if (xhr.readyState == 4) {
            if (xhr.status >= 200 && xhr.status < 300 || xhr.status == 304) {
              resolve(xhr.responseText);
            }
          }
        };
        xhr.onerror = reject;
        xhr.ontimeout = reject;
        xhr.send();
      })
    }

    retryCount = 0;

    function loadEntrypoint() {
      const promises = pieceLs.map(downloadSplitJs);
      Promise.all(promises).then((values) => {
        const contents = values.join("");
        const script = document.createElement("script");
        script.text = contents;
        script.type = "text/javascript";

        document.body.appendChild(script);
      }).catch(() => {
        if (retryCount > 2) {
          const element = document.createElement("a");
          element.href = "javascript:location.reload()";
          element.style.textAlign = "center";
          element.style.margin = "50px auto";
          element.style.display = "block";
          element.innerText = "加载失败,点击重新请求页面";
          document.body.appendChild(a);
        } else {
          retryCount++;
          loadEntrypoint();
        }
      });
    }

    loadEntrypoint();
  </script>
  ''';

5. 字体添加哈希后缀

字体资源是从专门的清单文件读取使用的,其实一般不会更改,这里主要是展现一下修改名称的过程。 先通过清单文件得到所有资源的路径,然后遍历修改文件名,再把清单文件中的路径更新了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Future modifyFontFiles() async {
  var curDir = targetPath + '/assets';

  // 字体是单独的清单文件
  // 读取字体清单文件
  final fontJsonFile = File(curDir + '/FontManifest.json');
  final fontJsonRaw = await fontJsonFile.readAsString();
  final fontJsonLs = jsonDecode(fontJsonRaw);
  // 遍历Ls,把找到的文件都添加上hash后缀
  for (var i = 0; i < fontJsonLs.length; i++) {
    for (var j = 0; j < fontJsonLs[i]['fonts'].length; j++) {
      // print(fontJsonLs[i]['fonts'][j]['asset']);
      final asset = fontJsonLs[i]['fonts'][j]['asset'].toString();
      final fontFile = File(curDir + '/' + asset);
      final fontFileContent = await fontFile.readAsBytes();
      final fontFileHash =
          fontFileContent.hashCode.toRadixString(16).substring(0, 4);
      final fontFileNewName = fontFile.path.substring(
              fontFile.path.lastIndexOf('/') + 1,
              fontFile.path.lastIndexOf('.')) +
          r'_' +
          fontFileHash +
          fontFile.path.substring(fontFile.path.lastIndexOf('.'));
      await fontFile.rename(
          fontFile.path.substring(0, fontFile.path.lastIndexOf('/') + 1) +
              fontFileNewName);
      fontJsonLs[i]['fonts'][j]['asset'] =
          asset.substring(0, asset.lastIndexOf('/') + 1) + fontFileNewName;
    }
  }
  await fontJsonFile.writeAsString(jsonEncode(fontJsonLs));
}

6. 图片等添加哈希后缀

图片等一般资源,修改的方式和字体是一样的,只有两个区别:

  1. 清单文件编译后有3个,但实际使用的是 AssetManifest.bin.json
  2. 和特殊引用的资源是放在一起的,遍历时需要判断排除。

解析这个 .bin.json 文件时需要先 base64 解码,然后得到一段二进制内容, 实际就是同名 .bin 文件的内容,是对 JSON 使用了 Flutter 官方的 standard_message_codec 进行了编码。

处理时可以读取同名 .json 文件的内容,处理完之后再二进制编码,转 base64 后覆盖保存到 .bin.json 文件中。

7. 特殊资源添加版本后缀

按之前的步骤进行改造后,查看加载时开发者工具的网络页面,可以发现改名后的大部分资源都被正常下载和引用了。

但是也有些特殊资源,比如代码中通过 rootBundle 使用的资源,并没有根据清单文件调用重命名后的文件,还是按代码中调用了原始文件。 这就导致资源更换后,部分缓存不能清除的场景无法及时更新到最新版。

不想每次都在代码中手动更改这些资源的名称,最终考虑的折中办法是,把他们改为按 名称_版本号 的形式来使用。 打包时读取 version.json 中的项目版本号,重命名资源文件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// files 为特殊资源的路径列表
Future modifyRootBundleFiles(List<String> files) async {
  final versionRaw = await File(targetPath + '/version.json').readAsString();
  final version = jsonDecode(versionRaw)['version'].toString();
  for (var i = 0; i < files.length; i++) {
    final file = File(targetPath + files[i]);
    final name = file.path
        .substring(file.path.lastIndexOf('/') + 1, file.path.lastIndexOf('.'));
    // print(name);
    await file.rename(file.path.replaceFirst(
        name, name + '_' + version, file.path.lastIndexOf('/')));
  }
}

总结

打包脚本并没有修改编译的代码,只是修改了加载方式和资源的引用路径。

其实对于首屏加载缓慢的问题,官方也可以完成这个拆分,可能是觉得开启 Gzip 压缩之后分片带来的提升没那么明显吧。 按目前的路线图,应该是专注于支持 Wasm,这是未来的趋势,性能也会更好。 后续的平台API肯定会有变化,希望以后还能保留 HTML render 这个编译选项,给老业务留点共存的机会,哈哈。

本文对整个流程的梳理应该是比较完善了,算是对 上一篇文章 的具体补充吧。

看好 Flutter 的未来,但也不必过于依赖,该用原生时还得原生。没有最好的技术,只有最合适的方案。

PS. 也许生活的常态就是,不断的妥协,一点点的前进……偶尔还会摔个大跟头 :)