likes
comments
collection
share

【Flutter开发技巧】我开发了一个Flutter支持Web项目本地缓存启动服务的插件:Misty!

作者站长头像
站长
· 阅读数 37

“我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情

前言

最近 Asscre 遇到一个需求,就是把我们的 Web 项目在 Flutter 中静态启动,提升用户的体验的同时(其实产品想要的是小程序的体验),能使用我们现有的 Web 技术栈解决。

第一个想到的就是集成第三方的小程序SDK。

众所周知,我们国内的小程序开发框架基本上都是各个厂商为适配自己的需求定制的,但我们想要实现一些特殊的需求,官方没有适配的时候,只能“看别人的工期”;同时,各个厂商的小程序引擎SDK基本都是闭源的,所以导致我们想在Flutter项目中集成小程序只能自己造轮子( 其实市面上有解决方案,但,我想,web的问题为啥不能用web解决 )。

于是, Asscre 就在想如何利用现有的VueFlutter技术栈实现类似的体验,保留 Web 开放性的同时,能很好的实现Flutter 和 Web 的原生交互。

这个的想法不断的在脑海中回响不断,最终形成 Misty 的雏形。

【Flutter开发技巧】我开发了一个Flutter支持Web项目本地缓存启动服务的插件:Misty!

思考

我们以 Vue 开发的角度来思考,如何把打包后的 dist 资源包在 Flutter 上本地运行起来,同时可以提供给 WebView 使用,并且良好的支持 FlutterWeb 原生交互(譬如手势、事件通知等)。

在 Flutter 中启动 Web 服务

此处借鉴了 local_server_webview

Web项目本地缓存和版本管理

  • 把打包好的 dist 文件下载到本地。
Dio().download(queueItem.zipUrl, zipPath).then((resp) {
  if (resp.statusCode != 200) {
    _log('下载ls 压缩包失败  err:${resp.statusCode} zipUrl:${queueItem.zipUrl}');
    throw Exception('下载ls 压缩包失败  err:${resp.statusCode}');
  }
  return unarchive(queueItem, zipPath);
})
  • archive包把下载好的dist资源解压到项目目录中
Directory saveDirct =
    LocalServerConfiguration.getCurrentZipPathSyncDirectory(item.zipUrl);
final zipFile = File(downPath);
if (!zipFile.existsSync()) {
  throw Exception('Local server 下载包文件路径不存在:$downPath');
}
List<int> bytes = zipFile.readAsBytesSync();
Archive archive = ZipDecoder().decodeBytes(bytes);

try {
  for (final file in archive) {
    final filename = file.name;
    if (file.isFile) {
      final data = file.content as List<int>;
      String filePath = '${saveDirct.path}/$filename';
      File(filePath)
        ..createSync(recursive: true)
        ..writeAsBytesSync(data);
      item.filePath.add(filename);
    } else {
      Directory('${saveDirct.path}/$filename').create(recursive: true);
    }
  }
  item.loadState = LoadStateType.success;
  // 清理之前的缓存
  File oldfile = File(downPath);
  if (oldfile.existsSync()) {
    oldfile.deleteSync();
  }
  • 通过初始化传入资源配置信息,根据包版本判断是否需要更新
MistyStartModel mistyStartOption = MistyStartModel(
  baseHost: '',
  options: [
    Option(
      key: 'misty-app-one',
      open: 1,
      priority: 0,
      version: '202208161155',
    ),
  ],
  basics: Basics(
    common: Common(
      compress: '/common.zip',
      version: '202208151527',
    ),
  ),
  assets: [
    {
      'misty-app-one': '/misty-app-one/misty-app.zip',
    },
  ],
);
  • options: 为配置的对应webPath的开关下载优先级版本号
  • basics: 为统一资源的配置,比如common,所有web通用的js、json资源等,便统一下载,避免重复
  • assets: 为option对应的key的压缩包地址

启动 Web 服务

  • 默认启动的是localhost(127.0.0.1)
_server = await HttpServer.bind(InternetAddress.loopbackIPv4, port);

Web 资源拦截和管理

  • WebView 事件监听
_server.listen(_responseWebViewReq, onError: (e) => log(e, name: _logKey));
  • 根据拦截的请求(包括html,js,css等),返回对应资源的bytes data即替换
  • 比如index.html,在获取给到webview后,会继续解析得到各js,css资源的请求,只要不是绝对路径也会被拦截
void _responseWebViewReq(HttpRequest request) async {
  _getResponseData() async {
    try {
      String name;
      String? mime;
      String path = request.requestedUri.path;
      String component = path.split('/').toList().last;
      // 拿到文件名, [observe.fetchRespondsSources] 是获取对应缓存资源的数据
      var data = observe?.fetchRespondsSources(component);
      if (data == null) {
        // 有可能有 http://127.0.0.1:1234/test 这种形式的链接,所以尝试拿index.html文件来加载
        if (!component.contains('.')) {
          data = observe?.fetchRespondsSources('index.html');
        }
        if (data != null) {
          name = basename('index.html');
          mime = lookupMimeType(name);
        } else {
          // 找不到本地文件,使用网络下载拿到原始数据
          var nowUri = request.requestedUri;
          var baseDomain = LocalServerCacheBinderSetting().baseDomain;
          var baseUri = Uri.parse(baseDomain);
          // 替换为原始url
          nowUri = nowUri.replace(
              scheme: baseUri.scheme, host: baseUri.host, port: baseUri.port);
          // dio请求,responseType 必须是bytes
          var res = await Dio().getUri(nowUri,
              options: Options(responseType: ResponseType.bytes));
          data = res.data;
          name = basename(nowUri.path.split('/').toList().last);
          mime = lookupMimeType(name);
        }
      } else {
        // 根据文件名拿到对应的MimeType
        name = basename(component);
        mime = lookupMimeType(name);
      }
      request.response.headers.add('Content-Type', '$mime; charset=utf-8');
      return data;
    } catch (e) {
      observe?.requestServerFailure(request.requestedUri.toString(), e);
      rethrow;
    }
  }

  try {
    final data = await _getResponseData();
    request.response.add(data);
  } catch (e) {
    request.response.statusCode = 404;
    observe?.requestServerFailure(request.requestedUri.toString(), e);
    log('[local server request] Error${e.toString()}', name: _logKey);
  } finally {
    // 最后要关闭 response
    request.response.close();
  }
}

Flutter 与 Web 的原生交互处理

通过 FlutterWeb 约定事件

WebView(
  key: webKey,
  initialUrl: _innerUrl,
  onWebViewCreated: (controller) async {
    // 获取WebView的controller提供用户使用
    MistyHandler().setWebViewController(controller);
  },
  javascriptMode: JavascriptMode.unrestricted,
  navigationDelegate: (NavigationRequest request) {
    // 捕获 Web 的事件交互
    NavigationHandler.fwToFlutter(context, request.url);
    return NavigationDecision.navigate;
  },
  javascriptChannels: <JavascriptChannel>{
    _javascriptChannel(context),
  },
// 监听来至 Web 的信息
JavascriptChannel _javascriptChannel(BuildContext context) {
  return JavascriptChannel(
    name: 'MistyCallFlutter',
    onMessageReceived: (JavascriptMessage msg) {
      // 通知监听相关信息的用户
      MistyEventController().onEventMessage(msg.message);
    },
  );
}

Misty 中,处理了很多相关事情,有兴趣的小伙伴可以进入 Misty 了解。

使用 Misty

1. 导入(pubspec.yaml)

  dependencies:
    misty: <latest_version>

2. 启动本地web服务

  MistyStartModel mistyStartOption = MistyStartModel(
    baseHost: 'https://mistyapp.oss-cn-hangzhou.aliyuncs.com',
    options: [
      Option(
        key: 'misty-app-one',
        open: 1,
        priority: 0,
        version: '202208161155',
      ),
      Option(
        key: 'misty-app-two',
        open: 1,
        priority: 0,
        version: '202208151527',
      ),
    ],
    basics: Basics(
      common: Common(
        compress: '/common.zip',
        version: '202208151527',
      ),
    ),
    assets: [
      {
        'misty-app-one': '/misty-app-one/misty-app.zip',
      },
      {
        'misty-app-two': '/misty-app-two/misty-app.zip',
      },
    ],
  );

  Misty.start(mistyStartOption);

3. 使用

  1. 打开程序

Misty.openMisty(context, url);
  1. Flutter 调用 Js

MistyHandler().callJs('欢迎使用Misty!');

Js 挂载 事件

function flutterCallJs(param : any) {
    console.log(param);
}
    
window.flutterCallJs = flutterCallJs;
  1. Js 调用 Flutter

window.MistyCallFlutter.postMessage('getDataFormFlutter');
/// 监听来自Web的消息
MistyEventController().addEventListener((event) {
  print(event);
});

上述两个例子中分别引用的是:

Misty’s 官方demo 帮助你快速了解如何集成属于你自己的 Flutter 类小程序功能.

项目设计规划

  • ✅ Web 资源管理器 (版本管理,资源下载管理)
  • ✅ WebView 资源和网络代理
  • ✅️ Flutter 与 Web 项目原生交互
  • Misty UI框架,帮助快速搭建 Misty 程序

Misty 的基本架构已经完善,目前在实际项目中运行稳定,开源出来是想跟大家一起分享,同时能了解到大家对 FlutterWeb 在本地缓存之间的想法💡,互相交流,共同进步。

最后

欢迎小伙伴们提 PR

Asscre 会持续更新 Misty,为大家提供一款开箱即用的 Flutter web项目的本地缓存解决方案.

我是 Asscre 。 感谢你的阅读,如果觉得对你有所帮助和启发,请点赞👍 + 关注。

微信公众号关注: Asscre。 让我们一起成长。