美文网首页FlutterAndroid开发教程
Flutter实现动态化更新-技术预研

Flutter实现动态化更新-技术预研

作者: 蜗牛是不是牛 | 来源:发表于2021-11-25 20:31 被阅读0次

    Flutter实现动态化更新-技术预研

    前言:有做过完整项目的小伙伴应该都知道,随着业务的发展,app的运营需求会越来越多(比如:根据运营活动动态更换页面的UI)。这就要求我们的app要尽可能的满足市场的运营的动态化需求,通过这篇文章你将了解到:
    1. Flutter动态化的方案使用和效果对比;
    2. 针对中小型团队,该如何最小成本、最高效的实现app的动态化需求。

    动态化的常用方式和实现原理

    首先什么是动态化?即不依赖程序安装包,就能进行动态实时更新页面的技术。
    接下来列举常用的方式和原理:

    • 一般大家都会想到webview,这确实是最常用的方式,但也是动态化中最不稳定的方式;webview的体验比较差,同时需要做大量设备的兼容。
    • 基于 GPL 的 Native 增强。 GPL即通用编程语言,比如我们常见的Dart、JavaScript等,通过这些通用语言来为Native功能增强动态化能力。通俗的举个例子解释:运营者动态更改线上的js文件,Flutter应用通过网络拉取更新后动态渲染,这就是基于GPL的Native增强。
    • 基于 DSL 的 Native 增强。 DSL即专用领域语言,为了解决某些特定场景下的任务而专门设计的语言,比如xml、json、css、html。通过生成简单的DSL语言文件,通过解析协议对页面进行动态配置。

    我们整体来看Flutter的动态化生态,目前市面上并没有一个成熟的开源框架,只有国内各大互联网公司陆续开源,但也都处在急需维护的状态。当前主流框架有:

    1. 腾讯开源的 MXFlutter
    2. 58同城开源的 Flutter Fair
    3. 阿里巴巴开源的 北海Kraken

    同时我还会介绍另外两种比较通用的方式:

    1. webview增强 【植入腾讯 X5内核,模型升级改造】
    2. UI库模板化

    各大厂动态化架构对比

    Flutter Fair

    Fair是“58同城”为Flutter设计的动态化框架,通过Fair Compiler工具实现JSON配置和原生Dart源文件的自动转化,从而动态更新Widget Tree和State。

    使用介绍

    官方并没有维护pub上的Fair插件,我们需要去GitHub fork源码下来编写demo。58Fair
    准备一份配置好的JSON文件,然后直接调用FairWidget传入文件路径即可显示,非常简单。动态化需求无非就是把JSON配置文件放到线上,然后FairWidget每次都会重新拉取下来展示,从而实现动态化。

    /// 基本使用
    return Container(
        alignment: Alignment.centerLeft,
        color: Colors.white,
        constraints: BoxConstraints(minHeight: 80),
        child: FairWidget(
          name: item.id,
          path: 'assets/bundle/sample.json',
          data: {"fairProps": json.encode({'detail': details})},
        ),
    );
    

    继续跟进源码,可以看到当我们传入的文件路径是以http开头的时候,会通过网络进行拉取

    void _reload() {
      var name = state2key;
      var path = widget.path ?? _fairApp.pathOfBundle(widget.name);
      bundleType = widget.path != null && widget.path.startsWith('http')
          ? 'Http'
          : 'Asset';
      parse(context, page: name, url: path, data: widget.data).then((value) {
        if (mounted && value != null) {
          setState(() => _child = value);
        }
      });
    }
    
    /// 再通过parse()方法逐层进入decoder → bundle_provider,查看onLoad方法
    @override
    Future<Map> onLoad(String path, FairDecoder decoder,
        {bool cache = true, Map<String, String> h}) {
      bool isFlexBuffer;
      if (path.endsWith(FLEX)) {
        isFlexBuffer = true;
      } else if (path.endsWith(JSON)) {
        isFlexBuffer = false;
      } else {
        throw ArgumentError(
            'unknown format, please use either $JSON or $FLEX;\n $path');
      }
      if (path.startsWith('http')) {
        return _http(path, isFlexBuffer, headers: h, decode: decoder);
      }
      return _asset(path, isFlexBuffer, cache: cache, decode: decoder);
    }
    
    /// 重点查看以http开头的解析方法,用的是http库拉取
    Future<Map> _http(String url, bool isFlexBuffer,
        {Map<String, String> headers, FairDecoder decode}) async {
      var start = DateTime.now().millisecondsSinceEpoch;
      var response = await client.get(url, headers: headers);
      var end = DateTime.now().millisecondsSinceEpoch;
      if (response.statusCode != 200) {
        throw FlutterError('code=${response.statusCode}, unable to load : $url');
      }
      var data = response.bodyBytes;
      if (data == null) {
        throw FlutterError('bodyBytes=null, unable to load : $url');
      }
      Map map;
      map = await decode.decode(data, isFlexBuffer);
      var end2 = DateTime.now().millisecondsSinceEpoch;
      log('[Fair] load $url, time: ${end - start} ms, json parsing time: ${end2 - end} ms');
      return map;
    }
    

    看下依赖,其实都是非常旧的了,很明显维护力度不够;同时对Flutter版本也有限制,Flutter每出一个版本,58Fair官方就很可能需要做一次适配。

      fair_annotation:
        path: ../annotation
      fair_version:
        path: ../flutter_version/flutter_2_5_0
    
      flat_buffers: ^1.12.0
      url_launcher: ^5.7.2
      http: ^0.12.2
    

    最后怎么写JSON配置文件,肯定自带一套协议,跟着官方文档Api写就可以了。熟悉Flutter的同学看下面的示例代码应该能秒懂。

    {
      "className": "Center",
      "na": {
        "child": {
          "className": "Container",
          "na": {
            "child": {
              "className": "Text",
              "pa": [
                "嵌套动态组件"
              ],
              "na": {
                "style": {
                  "className": "TextStyle",
                  "na": {
                    "fontSize": 30,
                    "color": "#(Colors.yellow)"
                  }
                }
              }
            },
            "alignment": "#(Alignment.center)",
            "margin": {
              "className": "EdgeInsets.only",
              "na": {
                "top": 30,
                "bottom": 30
              }
            },
            "color": "#(Colors.redAccent)",
            "width": 300,
            "height": 300
          }
        }
      }
    }
    

    利弊分析

    1. Fair的好处:用起来很简单,性能稳定;
    2. 缺点很明显:
    • 用JSON来配置UI,就注定了它是不支持逻辑的
    • Flutter的widget太多,Fair目前也只能匹配有限的静态UI
    • 脱离Dart生态,UI都用JSON写了......;
    • 团队维护力度非常有限,很多插件都没有更新,pub也没有更新。【但其实这是所有Flutter动态化开源框架的通病 😭】

    MxFlutter

    MxFlutter 同样也是维护力度有限,目前pub并不是最新版本。GitHub地址也换了,最新的请看github.com/Tencent/mxf…
    MxFlutter通过JavaScript编写Dart,同样是通过加载线上js文件,通过引擎在运行时转化并显示,从而达到动态化效果。 官方在0.7.0版本开始接入TypeScript,引入npm生态,优化了js开发的成本,向前端生态进一步靠拢。
    很遗憾,在对比各大厂的方案时,发现MxFlutter的性价比极低,学习成本也高,而且抛弃了js生态,又抛弃Dart生态。

    所以针对MxFlutter我只对实现原理做简单剖析,不进行深入研究。

    使用介绍

    • 初始化引擎
    String jsBundlePath = await _copyBizBundelZipToMXPath();
    if (jsBundlePath != null) {
      // 启动 MXFlutter,加载JS库。
      MXJSFlutter.runJSApp(jsAppPath: jsBundlePath);
    }
    
    • 通过MXJSPageWidget传入js脚本,就能解析出来显示了。一般使用MxFlutter都是展示一整个使用MXFlutter框架编写的页面
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => MXJSPageWidget(
            jsWidgetName: "mxflutter-ts-demo",
            flutterPushParams: {
              "widgetName": "WidgetExamplesPage"
            }),
      ),
    );
    
    • 再来看看MxJsFlutter的接口定义,可以看到定义了很多协议,这就势必增加js的学习成本,同时对mxFlutter的引擎依赖度极高。而自己团队是否有能力hold住这里面的坑,是要慎重考虑的。
    abstract class MXJSFlutter {
      static MXJSFlutter _instance;
      static String _localJSAppPath;
      static String get localJSAppPath => _localJSAppPath;
    
      /// 获取对外接口类MXJSFlutter。
      /// MXFlutter的大部分接口通过MXJSFlutter来调用。
      static MXJSFlutter getInstance() {
        if (_instance == null) {
          _instance = _MXJSFlutter();
        }
        return _instance;
      }
    
      /// 由Flutter 代码启动JSApp。 可以用在先显示Flutter页面,然后Push路由跳转到JS页面。
      /// 启动JSApp之后,执行JS代码,JS代码可以主动调用Flutter显示自己的页面,也能接受Flutter的指令,显示对应页面。
      ///
      /// @param jsAppPath jsApp root path ,JS业务代码放置在一个文件夹中。jsAppPath和jsAppAssetsKey根据场景二选一。
      /// @param jsAppAssetsKey 使用pubspec.yaml里的AssetsKey配置来设置jsAppPath,默认为flutter工程下,与lib,ios同级目录的mxflutter_js_bundle/文件夹下。
      /// @param jsExceptionHandler js异常回调。方法参数见 MXJSExceptionHandler 说明。
      /// @param debugBizJSPath 目前iOS模拟器下才能使用!!!本地js目录放置路径,直接放置xxx/bundle-xxx.js文件,无需打包成bizBundle.zip。使用该参数后,jsAppPath不生效。
      /// @returns Future
      /// @throws Error if Path error
      ///
      static Future runJSApp(
          {String jsAppPath = '',
          String jsAppAssetsKey = defaultJSBundleAssetsKey,
          MXJSExceptionHandler jsExceptionHandler,
          String debugJSBundlePath = ''}) async {
        WidgetsFlutterBinding.ensureInitialized();
        MXJSFlutter.getInstance();
    
        if(jsAppPath == null || jsAppPath.isEmpty){
          jsAppPath  = await defaultJSAppUpdatePath();
        }
    
        // 检查是否需要拷贝main.zip包。
        MXBundleZipManager bundleManager = MXBundleZipManager(jsAppPath: jsAppPath);
        MXBundleZipCheckResult result = await bundleManager.checkNeedCopyMainZip();
    
        if (!result.success) {
          MXJSLog.log(
              'MXJSFlutter.runJSApp: checkAppBundleZip failed: ${result.errorMessage}');
    
          // 引擎初始化的success回调
          MXJSFlutter.getInstance().jsEngineInitCompletionHandler(
              {'success': result.success, 'errorMessage': result.errorMessage});
    
          return;
        }
    
        // 调试状态下,debugJSBundlePath不为空,则运行此目录下的js文件。
        String realJSAppPath = jsAppPath;
        if (debugJSBundlePath != null &&
            debugJSBundlePath.isNotEmpty &&
            await canDefineDebugJSBundlePath()) {
          realJSAppPath = debugJSBundlePath;
        }
    
        _localJSAppPath = realJSAppPath;
    
        // 加载main.js。
        _callNativeRunJSApp(
            jsAppPath: realJSAppPath, jsExceptionHandler: jsExceptionHandler);
      }
    
      /// 默认的 JSBundle 的热更新目录,用于放置下载的JS Bundle文件
      static Future<String> defaultJSAppUpdatePath() async {
        // 如果业务没有指定目录,则使用默认目录
        return await Utils.findLocalPath() +
            Platform.pathSeparator +
            mxJSAPPDefaultAssetsKey;
      }
    
      /// 是否允许定义debugJSBundlePath
      static Future<bool> canDefineDebugJSBundlePath() async {
        // 目前只支持场景:1)调试环境的iOS模拟器
        if (kDebugMode && Platform.isIOS) {
          DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
          IosDeviceInfo deviceData = await deviceInfoPlugin.iosInfo;
          return !deviceData.isPhysicalDevice;
        } else {
          return false;
        }
      }
    
      static _callNativeRunJSApp(
          {String jsAppPath = "", MXJSExceptionHandler jsExceptionHandler}) {
        Map<String, dynamic> args = {"jsAppPath": jsAppPath};
    
        // 设置JS Exception Handler。
        MXPlatformChannel.getInstance().setJSExceptionHandler((arguments) {
          // 如果是main.js的错误,arguments['jsFileType'] 为 0 则执行js引擎的success回调。
          if (arguments is Map && arguments['jsFileType'] == 0) {
            MXJSFlutter.getInstance().jsEngineInitCompletionHandler(
                {'success': false, 'errorMessage': arguments['errorMsg']});
          }
    
          // 回调给业务侧。
          if (jsExceptionHandler != null) {
            jsExceptionHandler(arguments);
          }
        });
    
        // 初始化MXFFICallbackManager。
        MXFFICallbackManager.getInstance();
    
        args["flutterAppEnvironmentInfo"] = flutterAppEnvironmentInfo;
        MXPlatformChannel.getInstance().invokeMethod("callNativeRunJSApp", args);
      }
    
      ///从Flutter Push一个 JS写的页面
      ///@param widgetName: "widgetName",在main.js  MyApp::createJSWidgetWithName 函数中使用
      ///MyApp::createJSWidgetWithName 通过 widgetName 来创建对应的JSWidget
      /// 通常你应该使用更高层的API MXJSPageWidget 包装类来显示JS Widget 请参考 MXJSPageWidget 的用法
      dynamic navigatorPushWithName(
          String widgetName, Key widgetKey, Map flutterPushParams,
          {String bizPath});
    
      /// 设置处理器,当JS页面加载时,定制Loading widget。
      void setJSWidgetLoadingHandler(MXWidgetBuildHandler handler);
    
      /// 设置处理器,当JS页面加载错误时,定制Error widget。
      void setJSWidgetBuildErrorHandler(MXWidgetBuildHandler handler);
    
      /// JS引擎初始化结束回调。
      void jsEngineInitCompletionHandler(dynamic arguments);
    
      /// JS引擎是否已初始化。
      bool isJSEngineInit();
    
      /// 设置JS引擎已初始化。
      void setJSEngineInit();
    
      /// JS引擎初始化结果。
      Map<String, dynamic> jsEngineInitResult();
    
      /// 重新创建MXJSFlutter,包括通道,属性。
      void resetup();
    
      /// 当前flutterApp。
      MXJSFlutterApp get currentApp;
    }
    

    Kraken

    Kraken是阿里开源的一款基于W3C标准的高性能渲染引擎。也是目前几个大厂框架内维护力度最高的库,详见GitHub。Kraken的优势在于其能够基于W3C进行开发,而且引入npm生态,支持使用Vue、React框架开发,一般前端人员都能进行开发,学习成本很低。

    使用介绍

    pubspec引入,然后直接使用Widget Kraken传入脚本的url就可以了。

    kraken: ^0.9.0
    
    Widget build(BuildContext context) {
      // 我们只需要维护js脚本就可以了
      Kraken kraken = Kraken(
          bundleURL:
              'http://kraken.oss-cn-hangzhou.aliyuncs.com/demo/guide-styles.js');
      return Scaffold(
        appBar: PreferredSize(
          preferredSize: Size.fromHeight(40),
          child: AppBar(
            centerTitle: true,
            title: new Text(
              '商品详情',
              style: Theme.of(context).textTheme.headline6,
            ),
          ),
        ),
        body: kraken,
      );
    }
    

    可以看到,重点在于我们如何去维护带有动态运营内容的js文件,这是Kraken相对于其他框架最有竞争力的点,官方api写的非常详细,基于W3C标准,能够使用Rax、Vue、React这些主流框架进行开发。

    /// Vue代码
    <template>
      <div :style="style.home">
        demo
      </div>
    </template>
    
    <script>
      const style = {
        home: {
          display: 'flex',
          position: 'relative',
          flexDirection: 'column',
          margin: '27vw 0vw 0vw',
          padding: '0vw',
          minWidth: '0vw',
          alignItems: 'center',
        },
      };
    
      export default {
        name: 'App',
        data() {
          return {
            style,
          };
        },
      };
    </script>
    

    Kraken的缺点是不支持css样式,使Vue开发的体验也相对一般。但总体而言已经很不错了,官方维护力度大,满足前端生态,使用方便,是动态化技术很不错的选择。

    Webview增强优化

    几乎所有的移动应用中,都会用到webview来作为h5的容器。通过运营平台配置生成h5,app直接显示即可。但很遗憾,webview的体验性、稳定性/兼容性有很多的问题。
    体验上加载过程白屏,加载中、出错状态没法定义等;兼容性上iOS还好,浏览器内核都是WKWebView,但是Android的设备多种多样,浏览器内核也参差不齐,所以在兼容性上经常存在问题。 为了解决以上问题,我们基于官方插件webview_flutter,做了以下方案:

    • 体验上修改webview插件为可配置透明背景,去除加载条;Flutter层开发webview的增强容器,实现可定义加载中、加载失败的视图,达到基本符合app的加载效果
    • 稳定性上,我们采取统一植入X5内核的方法

    为何采取X5内核?

    目前开源的浏览器内核sdk不多,主要有以下几个:ChromeView、Crosswalk、TbsX5。

    1. 基于Chromium内核的开源ChromeView 目前基本没有维护,另一个问题是编译出来的动态库太大,ARM-29M,x86-38M,这无疑对app体积来说是个大难题。因此放弃采用基于Chromium的ChromeView。
    2. Crosswalk同样是基于Chromium内核,同样存在上述app体积问题,因此也放弃。
    3. TbsX5 基于谷歌Blink内核,生态在国内是很成熟的,只要装有微信的手机,都支持X5。X5 提供两种集成方案:
      • 只共享微信手Q空间的x5内核(for share)
      • 独立下载x5内核(with download)

    优化体验

    WebView(
      initialUrl: 'https://www.baidu.com',
      transparentBackground: true
    )
    
    • 构建webview容器。webview背景处理为透明后,通过Stack布局,以及监听onProgress回调,赋予webview容器加载中、加载失败的效果,让用户的体验达到与原生应用类似。
    /// 我们用的是flutter_bloc进行状态管理
    Stack(
      alignment: Alignment.center,
      children: [
        WebView(
          transparentBackground: widget.transparentBackground,
          onProgress: (int progress) {
            if (progress >= 100) {
              context.read<WebViewContainerCubit>().loadSuccess(progress);
            }
          },
          onWebResourceError: (error) {
            context.read<WebViewContainerCubit>().loadError();
          },
        ),
        if (state is WebViewLoading)
          Center(
            child: widget.loadingView ?? const LoadingView(),
          ),
      ],
    )
    

    再看看bloc层,非常简单的状态切换。

    /// Cubit
    class WebViewContainerCubit extends Cubit<WebViewContainerState> {
      WebViewContainerCubit() : super(WebViewLoading());
    
      loadSuccess(int progress) {
        if (state != WebViewLoadSuccess()) {
          emit(WebViewLoadSuccess());
        }
      }
    
      loadError() {
        emit(WebViewLoadError());
      }
    }
    
    /// State
    abstract class WebViewContainerState {}
    
    class WebViewLoading extends WebViewContainerState {}
    
    class WebViewLoadSuccess extends WebViewContainerState {}
    
    class WebViewLoadError extends WebViewContainerState {}
    

    植入X5内核

    pub上也有一些webview for x5的轮子,但都是年久失修,没有持续维护,连null safely都没有支持。
    所以我们继续拓展webview_flutter库,新建webview_flutter_android_x5模块,引入X5 SDK,重点对官方的webview_flutter_android相关功能和Api进行替换开发。同时提供Api给业务层,由调用方来决定是否启用x5内核。
    植入X5需要一定的原生基础,这里不对源码进行过多讲解,有机会的话后面我直接开源一个库,把透明背景和x5内核一起弄上去。

    UI组件库模板化

    这个方式是通过UI设计预埋一些坑位,运营端通过匹配已有组件存储在接口,每次拉取后台服务确定如何展示UI。 这个是非常通用的方式,没有那么动态化,需要先把可能出现的UI先设计出来。但是最靠谱,不过在开发时需要做好UI库的封装、协议的定制,同时要非常注意降级处理,如果网络差拉不到后台数据,那页面如何做显示,这点要处理好。

    总结归纳

    回看下面这张图,框架方面毫无疑问Kraken最能应用于生产,但笔者想说的是这些框架都不成熟,想要应用于生产,团队必须有能参与开源,填坑的能力。 另外顺带提一下,腾讯QQ音乐正在准备开源的Kant也可以关注下,基于Kraken进行改造,已经应用于内部生产,值得期待。

    而webview增强,UI组件模板化则是相对靠谱的方式,一般团队都有能力维护。这两种方式,运营平台的同事就不需要再去学习新的Api,管理后台的配置动态内容的开发成本也小很多。

    本文转自 https://juejin.cn/post/7033708048321347615#heading-0,如有侵权,请联系删除。

    相关文章

      网友评论

        本文标题:Flutter实现动态化更新-技术预研

        本文链接:https://www.haomeiwen.com/subject/nseztrtx.html