FlutterWeb初体验

1,287 阅读9分钟

FlutterWeb初体验

[toc]

背景

因为最近业务需求的变动,在APP的某一部分页面会经常性发生变动,一般情况下来说,这种不稳定的页面不应该由原生来承担,修改发版的成本太大了,最合理的做法是由H5来承担,由原生提供必要的bridge来调用原生方法,但是由于种种历史债务,还是没有如此实现,经历了痛苦的发版以及等待审核后,我在想flutterWeb是不是可以解决这个问题?

想法

页面进入流程

screenshot-20211210-211936.png

项目架构想法

整个项目转为支持FlutterWeb

整个项目转为flutterweb,可以打包成web文件直接部署在服务器,而app依旧打包成apk和ipa,但是在路由监听处留下开关,当有页面需要紧急修复或者紧急更改的情况下,下发配置,跳转的时候根据路由配置跳转WebView或者原生页面。

抽离出某个模块,单个模块支持web

抽离出一个module,由一个壳工程引用,这个壳工程用于把该module打包成web;同时该模块依然被app工程引用,作为一个功能模块,而部署的时候只部署了这个模块的web产物。

因为目前app集成了一定数量的原生端的第三方sdk,直接支持flutterweb工程量较大,所以先尝试第二个方法。

壳工程结构图

1924616-18f5d8ee85f0f330.png

其中

flutter_libs 是基础的lib库,封装了基础的网络请求,持久化存储,状态管理等基础,壳工程和app工程也会引用

ly_income是功能module,也是我们主要开发需求的模块,它会被壳工程引用作为web的打包内容,也会被app工程引用作为原生的页面展示。

实践

打包问题处理

因为是新建的项目工程,打包成flutterWeb并不会有那么多障碍。

开启web支持

执行 flutter config查看目前的配置信息,如果看到

Settings:
  enable-web: true
  enable-macos-desktop: true

那就是已经开启了,如果还没,可以使用flutter config --enable-web开启配置

打包模式选择

而flutterWeb打包也有两种模式可以选择:html模式和CanvasKit模式

它们两者各自的特别是:

html模式

flutter build web --web-renderer html

当我们采用html渲染模式时,flutter会采用HTML的custom element,CSS,Canvas和SVG来渲染UI元素

优点是:体积比较小

缺点是:渲染性能比较差,跨端一致性可能不受保障

CanvasKit模式

flutter build web --web-renderer canvaskit

当我们采用canvaskit渲染模式时,flutter将 Skia 编译成 WebAssembly 格式,并使用 WebGL 渲染。应用在移动和桌面端保持一致,有更好的性能,以及降低不同浏览器渲染效果不一致的风险。但是应用的大小会增加大约 2MB。

优点是:跨端一致性受保障,渲染性能更好

缺点是:体积比较大,load页面时间会更久

跨域问题处理

之前一直是做app开发,跨域这个词只听过,还没见识过。

了解跨域

跨域是指浏览器的不执行其他网站脚本的,由于浏览器的同源策略造成,是对JavaScript的一种安全限制

说白点理解,当你通过浏览器向其他服务器发送请求时,不是服务器不响应,而是服务器返回的结果被浏览器限制了。

而什么是同源策略的同源

同源指的是协议、域名、端口 都要保持一致

www.123.com:8080/index.html (http协议,www.123.com 域名、8080 端口 ,只要这三个有一项不一样的都是跨域,这里不一一举例子)

www.123.com:8080/matsh.html(…

www.123.com:8081/matsh.html(…

注意:localhost 和127.0.0.1 虽然都指向本机,但也属于跨域。

而跨域的解决方法也暂时不适用我:

  1. JSONP方式 (我们项目的请求都是post请求)
  2. 反向代理,ngixn (ngixn小白)
  3. 配置浏览器 (好像不太适用,应该,大概,也许,可能,或许)
  4. 项目配置跨域 (因为只是尝试项目,需要后台和运维支持的话,需要跨部门沟通,太麻烦了)

摘自网络 什么是跨域,侵删歉

常规做法
  1. 本地调试的时候修改代码,支持跨域请求

    在上图红框中添加代码--disable-web-security

1924616-e444ef62f7776b1e.png

1924616-fddf6a72c3a43965.png

然后删除以下两个文件,执行flutter doctor生成新的一份,再尝试run起来,你会发现浏览器已经支持跨域了,你可以很开心地在浏览器run接口了。但是仅支持本地调试!!!

  1. ngixn做转发,但是这个... 我没有怎么用过ngixn,而且需要在周末做完调研给出可行性报告,也没有时间去学习,先搁置,后续再拿起来看看
  2. 后端和运维同学帮忙调试跨域,因为是尝试而已,没有必要用到其他部门的资源,先搁置,后续如果可实际应用,再要求他们协助。
骚操作

保命前提:

  1. 这个其实就是配置转发的做法,但是这块我没什么经验,时间紧任务重所以就先这么尝试做了
  2. 其实这个就是类似于openfeign之类的想法,但是我并不知道后台开发的FeignClient,而且也有点危险,还是调用开发的接口更加稳妥
  3. 纯个人做法,肯定还会有更好的方法,但是这个是我当时最快的达成方案,勿喷。

如果说我要求不了后台服务做跨域,那可不可以我自己要求我自己做跨域呢?

比如:

我请求我的服务器,我的服务器再去请求后台服务,我访问后台服务跨域而已,我的服务器访问后台服务可不跨域,我的服务器跨域又咋样,自己的东西随便拿捏。

  1. 新建一个springboot项目
  2. 搭建一个controller,参数是url全路径以及参数json字符串,配置好header之后请求后台服务并返回信息
@CrossOrigin
@RestController
@RequestMapping("api/home")
public class GatewayController {

    @PostMapping("/gatewayApi")
    public String gatewayApi(@RequestParam("url") String url, @RequestParam("params") String json) {
        try {
            JSONObject jsonObject = JSONObject.parseObject(json);
            JSONObject result = doPost(jsonObject, url);
            if (result != null) {
                return result.toString();
            } else {
                return errMsg().toString();
            }
        } catch (Exception e) {
            return errMsg(e.getMessage()).toString();
        }
    }
}
  1. 配置跨域信息
@SpringBootConfiguration
public class WebGlobalConfig {

    @Bean
    public CorsFilter corsFilter() {

        //创建CorsConfiguration对象后添加配置
        CorsConfiguration config = new CorsConfiguration();
        //设置放行哪些原始域
        config.addAllowedOriginPattern("*");
        //放行哪些原始请求头部信息
        config.addAllowedHeader("*");
        //暴露哪些头部信息
        config.addExposedHeader("*");
        //放行哪些请求方式
        config.addAllowedMethod("GET");     //get
        config.addAllowedMethod("PUT");     //put
        config.addAllowedMethod("POST");    //post
        config.addAllowedMethod("DELETE");  //delete
        //corsConfig.addAllowedMethod("*");     //放行全部请求

        //是否发送Cookie
        config.setAllowCredentials(true);

        //2. 添加映射路径
        UrlBasedCorsConfigurationSource corsConfigurationSource =
                new UrlBasedCorsConfigurationSource();
        corsConfigurationSource.registerCorsConfiguration("/**", config);
        //返回CorsFilter
        return new CorsFilter(corsConfigurationSource);
    }
}
  1. 打包后部署到服务器
  2. module里的接口不再请求后台服务,而是请求我的服务器,因为只是转发,所以没有改动任何数据结构,只需要请求地址改动下
  3. 可以跨域了

与原生交互问题

设想中web的页面可以有三种方式:

  1. 集成在app里面作为原生页面,这个的交互没什么好说的。
  2. 打包成web项目,通过webview进行加载,那需要额外处理持久化信息的获取与写入,以及与原生页面的跳转交互
  3. 只有url,测试人员可以通过url路径传参之类的切换账号,方便测试

针对业务来说,页面的加载流程应该是这样的:

screenshot-20211210-211914.png

不同场景做不同的操作
原生

通过持久化工具类获取用户基础信息,然后读取接口判断身份,根据身份去做不同展示,点击跳转时间也是直接的通过路由跳转

通过webview加载

通过js交互,从原生模块拿到用户基础信息(存疑,是否直接读接口?,这样避免对原生api的依赖,如果有需求修改的话可以尽量不依赖),然后读取接口判断身份,根据身份不同去做不同展示,如果是dialog之类的交互可以直接实现,如果是跳转页面之类的,可以通过js交互进行原生操作

通过url加载的

通过url的参数串获取到对应的用户id,读取接口获取用户信息,其他操作如上,但是页面没有跳转之类的交互

实现
从链接上面获取参数

比如url为:```xxx.yyy.zzz/value

要如何拿到value值?

因为项目里刚好使用了Get做状态管理,而刚好Get已经实现了这一块,世间上的事情就是这么刚好。(好像navigator2已经支持这个了,不过还没仔细看过)

  1. 配置路由表

    
    class RouterConf {
      static const String appIncomeArgs = '/app/inCome/:fromApp';
      static const String appIncome = '/app/inCome/';
      static List<GetPage> _getPages = [];
      static List<GetPage> get getPages {
        _getPages = [
          GetPage(name: appIncomeArgs, page: () => const StoreKeeperInComePage()),
        ];
        return _getPages;
      }
    }
    
    

    这里appIncome配置了两个路由名

    但是实际使用时以没带**:fromApp为准的,fromApp我觉得可以理解成一个占位符,也就是fromApp=value**

  2. 获取对应的value

    在base类里面定义一个bool值,在init的回调里面去做获取操作

      bool ifFromApp = false;
      Map<String, String?> _args = Get.parameters;
      if (_args.isNotEmpty && _args.containsKey('fromApp')) {
          String? _fromAppFlag = Get.parameters['fromApp'];
          if ((_fromAppFlag?.isNotEmpty ?? false)) {
            ifFromApp = _fromAppFlag == "1";
          }
        }
    
根据不同情景做操作

以在webview打开为例,在页面加载时通过js交互获取用户信息,拿到用户信息后替换cache类里缓存的id,token之类的,因为拦截器里面会读取这些值用于拼接通用参数

  @override
  void onReady() {
    if (ifFromApp) {
      initUserInfo();
      js.context['getUserInfoCallback'] = getUserInfoCallback;
    }else{
      _loadInterface();
    }

    super.onReady();
  }

  void initUserInfo() {
    js.context.callMethod("callFlutterMethod", [
      json.encode({
        "api": "getUserInfo",
        "data": {
          "name": 'getUserInfo',
          "needCallback": true,
          "needToken": true,
          "callbackName": 'getUserInfoCallback',
          "callbackArgs": 'info'
        },
      })
    ]);
  }
  
  void getUserInfoCallback(msg, info) {
    Map<String, dynamic> _args = {};
    if (info != null) {
      if (info is String) {
        _args = jsonDecode(info);
      } else {
        _args = info;
      }
      if (_args.containsKey("info")) {
        dynamic _realInfo = _args['info'];
        if (_realInfo is String) {
          _args = jsonDecode(_realInfo);
        } else {
          _args = _realInfo;
        }
      }
      if (_args.containsKey('name')) {
        debugPrint(' _args[name]---------${_args['name']}');
        CacheManager.instance.oName = _args['name'];
      }
      if (_args.containsKey('uId')) {
        debugPrint(' _args[uId]---------${_args['uId']}');

        CacheManager.instance.userId = _args['uId'];
      }
      if (_args.containsKey('oId')) {
        debugPrint(' _args[oId]---------${_args['oId']}');
        CacheManager.instance.userOId = _args['oId'];
      }
      if (_args.containsKey('token')) {
        debugPrint(' _args[token]---------${_args['token']}');

        CacheManager.instance.userToken = _args['token'];
      }
      if (_args.containsKey('headImg')) {
        debugPrint(' _args[headImg]---------${_args['headImg']}');
        CacheManager.instance.headImgUrl = _args['headImg'];
      }
      state.userName = CacheManager.instance.oName;
      state.userHeaderImg = CacheManager.instance.headImgUrl;
      _loadInterface();
    }
  }

每次都做这个判断是真的恶心,应该把这些东西抽离出来,通过中间件去实现,避免页面上耦合了这个判断。

接下去就是正常的请求接口渲染页面的流程了。

与原生的交互

这里借鉴的是这位大佬的文章 flutterweb与flutter的交互 侵删歉

唯一需要注意的就是在web项目里面增加一个js

1924616-af650f09d9300f88.png 在app里面也要做一点操作:

class NativeBridge implements JavascriptChannel {
  BuildContext context; //来源于当前widget, 便于操作UI
  Future<WebViewController> _controller; //当前webView 的 controller

  NativeBridge(this.context, this._controller);

  // api 与具体函数的映射表,可通过 _functions[key](data) 调用函数
  get _functions => <String, Function>{
        "getUserInfo": _getUserInfo,
        "incomeDetail": _incomeDetail,
        "incomeHistory": _incomeHistory,
      };

  @override
  String get name =>
      "nativeBridge"; // js 通过 nativeBridge.postMessage(msg); 调用flutter

  // 处理js请求
  @override
  get onMessageReceived => (msg) async {
        // 将收到的string数据转为json
        Map<String, dynamic> message = json.decode(msg.message);
        // 异步是因为有些api函数实现可能为异步,如inputText,等待UI相应
        // 根据 api 字段,调用具体函数
        final data = await _functions[message["api"]](message["data"]);
      };

  //拿token
  _getUserInfo(data) async {
    handlerCallback(data);
  } //拿token

  _incomeDetail(data) async {
    Get.toNamed(RouterConf.OLD_STOREKEEPER_INCOME_LIST);
  }

  _incomeHistory(data) async {
    Get.toNamed(RouterConf.STORE_KEEPER_INCOME_HISTORY);
  }

  handlerCallback(data) async {
    LoginModel? _login = await UserManager.getLoginModel();
    UserInfoModel? _user = await UserManager.getUserInfo();
    String? _name = _user?.resultData?.organization?.organizationName;
    String? _uId = _user?.resultData?.user?.userId?.toString() ?? "";
    String? _oId =
        _user?.resultData?.organization?.organizationId?.toString() ?? "";
    String? _token = _login?.resultData?.xAUTHTOKEN;
    String? _img = _user?.resultData?.user?.portraitUrl;
    _img = ImgSize.getImgUrlThumbnail(_img);
    Map<String, dynamic> _infos = {
      "name": _name,
      "uId": _uId,
      "oId": _oId,
      "token": _token,
      "headImg": _img,
    };

    if (data['needCallback']) {
      var args = data['callbackArgs'];
      if (data['needToken']) {
        args = "'${data['callbackArgs']}','${jsonEncode(_infos)}'";
      }
      doCallback(data['callbackName'], args);
    }
  }

  doCallback(name, args) {
    _controller.then((value) => value.evaluateJavascript("$name($args)"));
  }
}

在webview里面设置channels:

 javascriptChannels: <JavascriptChannel>[
        NativeBridge(context, widget.controller!.future)
      ].toSet(),

结尾

目前来说好像这个方案是可行的,把一个app页面通过网页跑起来确实是挺爽的,但是慢也是真的慢,

也可能因为我的服务器是丐版中的丐版,加载起来是真的慢:

1924616-5860a92dde710996.png

1924616-71b2de0857dcbc1e.png

但是挺好玩的,虽然代码很烂,但是开心就是了。