FlutterWeb初體驗 FlutterWeb初體驗 背景

FlutterWeb初體驗

[toc]

背景

因爲最近業務需求的變動,在APP的某一部分頁面會經常性發生變動,一般情況下來說,這種不穩定的頁面不應該由原生來承擔,修改發版的成本太大了,最合理的做法是由H5來承擔,由原生提供必要的bridge來調用原生方法,但是由於種種歷史債務,還是沒有如此實現,經歷了痛苦的發版以及等待審覈後,我在想flutterWeb是不是可以解決這個問題?

想法

頁面進入流程

st=>start: 開始
op=>operation: 點擊某個頁面
cond=>condition: 通過abTest或者開關服務
sub1=>subroutine: 進入原生模塊
sub2=>subroutine: 進入Webview頁面
io=>inputoutput: 輸入輸出框
e=>end: 進入頁面查看信息
st->op->cond
cond(yes)->sub2->e
cond(no)->sub1->e


項目架構想法

整個項目轉爲支持FlutterWeb

整個項目轉爲flutterweb,可以打包成web文件直接部署在服務器,而app依舊打包成apk和ipa,但是在路由監聽處留下開關,當有頁面需要緊急修復或者緊急更改的情況下,下發配置,跳轉的時候根據路由配置跳轉WebView或者原生頁面。

抽離出某個模塊,單個模塊支持web

抽離出一個module,由一個殼工程引用,這個殼工程用於把該module打包成web;同時該模塊依然被app工程引用,作爲一個功能模塊,而部署的時候只部署了這個模塊的web產物。

因爲目前app集成了一定數量的原生端的第三方sdk,直接支持flutterweb工程量較大,所以先嚐試第二個方法。

殼工程結構圖

<img src="https://upload-images.jianshu.io/upload_images/1924616-18f5d8ee85f0f330.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" alt="image.png" style="zoom:50%;" />

其中

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的一種安全限制

說白點理解,當你通過瀏覽器向其他服務器發送請求時,不是服務器不響應,而是服務器返回的結果被瀏覽器限制了。

而什麼是同源策略的同源

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

http://www.123.com:8080/index.html (http協議,www.123.com 域名、8080 端口 ,只要這三個有一項不一樣的都是跨域,這裏不一一舉例子)

http://www.123.com:8080/matsh.html(不跨域)

http://www.123.com:8081/matsh.html(端口不一樣,跨域)

注意:localhost 和127.0.0.1 雖然都指向本機,但也屬於跨域。

而跨域的解決方法也暫時不適用我:

  1. JSONP方式 (我們項目的請求都是post請求)
  2. 反向代理,ngixn (ngixn小白)
  3. 配置瀏覽器 (好像不太適用,應該,大概,也許,可能,或許)
  4. 項目配置跨域 (因爲只是嘗試項目,需要後臺和運維支持的話,需要跨部門溝通,太麻煩了)

摘自網絡 什麼是跨域,侵刪歉

常規做法
  1. 本地調試的時候修改代碼,支持跨域請求

    在上圖紅框中添加代碼--disable-web-security

    <img src="https://upload-images.jianshu.io/upload_images/1924616-e444ef62f7776b1e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" alt="image.png" style="zoom:50%;" />

    <img src="https://upload-images.jianshu.io/upload_images/1924616-fddf6a72c3a43965.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" alt="image.png" style="zoom:50%;" />

    然後刪除以下兩個文件,執行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路徑傳參之類的切換賬號,方便測試

針對業務來說,頁面的加載流程應該是這樣的:

st=>start: 開始
op=>operation: 進入收益頁面
cond=>operation: 通過接口或者持久化獲取用戶信息
role=>condition: 通過接口獲取用戶身份是否爲B角色
sub1=>subroutine: 展示身份A的頁面
sub2=>subroutine: 展示身份B的頁面
e=>end: 展示對應的信息頁面
st->op->cond->role
role(yes)->sub2->e
role(no)->sub1->e
不同場景做不同的操作
原生

通過持久化工具類獲取用戶基礎信息,然後讀取接口判斷身份,根據身份去做不同展示,點擊跳轉時間也是直接的通過路由跳轉

通過webview加載

通過js交互,從原生模塊拿到用戶基礎信息(存疑,是否直接讀接口?,這樣避免對原生api的依賴,如果有需求修改的話可以儘量不依賴),然後讀取接口判斷身份,根據身份不同去做不同展示,如果是dialog之類的交互可以直接實現,如果是跳轉頁面之類的,可以通過js交互進行原生操作

通過url加載的

通過url的參數串獲取到對應的用戶id,讀取接口獲取用戶信息,其他操作如上,但是頁面沒有跳轉之類的交互

實現
從鏈接上面獲取參數

比如url爲:```http://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

  1. 獲取對應的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

<img src="https://upload-images.jianshu.io/upload_images/1924616-af650f09d9300f88.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" alt="image.png" style="zoom:50%;" />

在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頁面通過網頁跑起來確實是挺爽的,但是慢也是真的慢,

也可能因爲我的服務器是丐版中的丐版,加載起來是真的慢:

<img src="https://upload-images.jianshu.io/upload_images/1924616-5860a92dde710996.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" alt="image.png" style="zoom:50%;" />

<img src="https://upload-images.jianshu.io/upload_images/1924616-71b2de0857dcbc1e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240" alt="image.png" style="zoom:50%;" />

但是挺好玩的,雖然代碼很爛,但是開心就是了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章