技术杂谈 | Flutter 的性能分析、工程架构与细节处理

有道技术团队
出品/ 有道智云
编辑/ Ryan
来源:有道技术团队(ID:youdaotech)

一、为何 Flutter

跨端技术众多,为何选择 Flutter?它能带来哪些优势,有哪些缺点?

先看看具体的工程效果:

Flutter 工程效果​v.qq.com

web 端效果体验:

flutter_exercise

1.1 Flutter VS 原生

无论如何,原生的运行效率毋庸置疑是最高的,但是从工程工作量的角度来对比的话,特别是快速试错和业务扩展阶段,Flutter 是目前为止比较推荐的利器。

1.2 Flutter VS Web

任何跨端的技术都是基于一码多端的思维,解决工程效率的问题,之前很多的跨端技术,例如 React Native 等都是基于web的跨端性解决方案,但是大家都知道,web 在移动端上的运行效率和 PC 上有巨大差距的,这就导致 RN 不能很有效地在移动端完成各种复杂的交互式运算(例如复杂的动画运算,交互的执行性能等),即便是引入了 Airbnb 的 Lottie 引擎依然会在低端的手机上面显得很卡顿(当然也可以使用一些自研的引擎技术来针对各端来解决,不过这样就失去了跨端的意义)。

1.3 Flutter 性能

Flutter 的编译方式和产物是决定其高效运行效率的前提,不同于 web 的跨端编译一样(web 的跨端编译大多是选择了使用 "桥" 的概念来调用编译产物,通常是使用了原生端的入口 + web 端的桥来实现),Flutter 几乎是把 dart 的源码通过不同平台的编译原理生成各平台的产物,这种"去桥"的产物正式我们所希望得到的、贴近原生运行性能的编译产物(当然,在 dart 最初设计的时候,是参考了很多前端的结构来完成的,特别从语法上面能够很明显地感受到前端的痕迹,而且最初的 dart2js 的原理也是同样"桥"的概念)。

例如 9月23号 google 发布的新 Flutter 版本中,在支持的 Windows 编译产物上,就是通过类似 Visual Studio 的编译工具(如果要将你的 Flutter 工程编译成 Windows 产物,需要提前安装一些 VS 相关的编译插件),生成了 Windows 下的工程解决方案 .sln,最终生成 dll 的调用方式,运行起来很流畅,可以下载附件中的 Release.zip 来尝试运行。(Release.zip 下载

(PS:这里所有编译工程都是通过同一套代码完成,包括上文中的 web 地址、移动端案例还有这里的 Windows 案例)

1.4 与 RN 的性能对比

以上是同样功能模块下,Flutter 和 RN 的一些数据上的对比,是从众多的数据中抽取出来比较有代表性的一组。

1.5 跨端平台的多样性

1.6 引擎

Flare-Flutter 是一款十分优秀的 Flutter 动画引擎,编译出的动画已经在 Windows、移动端、web 上亲测验证过。

1.7 语法糖

A?.B
如果 A 等于 null,那么 A?.B 为 null
如果 A 不等于 null,那么 A?.B 等价于 A.B
Animal animal = new Animal('cat');
Animal empty = null;
//animal 非空,返回 animal.name 的值 cat
print(animal?.name);
//empty 为空,返回 null
print(empty?.name);
A??B
如果 A 等于 null,那么 A??B 为 B
如果 A 不等于 null,那么 A??B 为 A

1.8 综合测评

1.9 互动应用

Flutter 生成的互动可以嵌入到任何端中使用精简的指令集进行互动,为互动场景(教学场景等带来巨大的希望),以下是直播同步互动的 demo 场景。

image

二、Flutter 业务架构

Flutter 中目前是没有现成的 mvvm 框架的,但是我们可以利用 Element 树特性来实现 mvvm。

2.1 ViewModel

abstract class BaseViewModel {
  bool _isFirst = true;
  BuildContext context;

  bool get isFirst => _isFirst;

  @mustCallSuper
  void init(BuildContext context) {
    this.context = context;
    if (_isFirst) {
      _isFirst = false;
      doInit(context);
    }
  }

  // the default load data method
  @protected
  Future refreshData(BuildContext context);

  @protected
  void doInit(BuildContext context);

  void dispose();
  
class ViewModelProvider<T extends BaseViewModel> extends StatefulWidget {
  final T viewModel;
  final Widget child;

  ViewModelProvider({
    @required this.viewModel,
    @required this.child,
  });

  static T of<T extends BaseViewModel>(BuildContext context) {
    final type = _typeOf<_ViewModelProviderInherited<T>>();
    _ViewModelProviderInherited<T> provider =
        // 查询Element树中缓存的InheritedElement
        context.ancestorInheritedElementForWidgetOfExactType(type)?.widget;
    return provider?.viewModel;
  }

  static Type _typeOf<T>() => T;

  @override
  _ViewModelProviderState<T> createState() => _ViewModelProviderState<T>();
}

class _ViewModelProviderState<T extends BaseViewModel>
    extends State<ViewModelProvider<T>> {
  @override
  Widget build(BuildContext context) {
    return _ViewModelProviderInherited<T>(
      child: widget.child,
      viewModel: widget.viewModel,
    );
  }

  @override
  void dispose() {
    widget.viewModel.dispose();
    super.dispose();
  }
}

// InheritedWidget可以被Element树缓存
class _ViewModelProviderInherited<T extends BaseViewModel>
    extends InheritedWidget {
  final T viewModel;

  _ViewModelProviderInherited({
    Key key,
    @required this.viewModel,
    @required Widget child,
  }) : super(key: key, child: child);

  @override
  bool updateShouldNotify(InheritedWidget oldWidget) => false;

2.2 DataModel

import 'dart:convert';

import 'package:pupilmath/datamodel/base_network_response.dart';
import 'package:pupilmath/datamodel/challenge/challenge_ranking_list_item_data.dart';
import 'package:pupilmath/utils/text_utils.dart';

///历史榜单
class ChallengeHistoryRankingListResponse
    extends BaseNetworkResponse<ChallengeHistoryRankingData> {
  ChallengeHistoryRankingListResponse.fromJson(Map<String, dynamic> json)
      : super.fromJson(json);

  @override
  ChallengeHistoryRankingData decodeData(jsonData) {
    if (jsonData is Map) {
      return ChallengeHistoryRankingData.fromJson(jsonData);
    }
    return null;
  }
}

class ChallengeHistoryRankingData {
  String props;
  int bestRank; //最佳排名
  int onlistTimes; //上榜次数
  int total; //总共挑战数
  List<ChallengeHistoryRankingItemData> ranks; //先给10天

  //二维码
  String get qrcode =>
      TextUtils.isEmpty(props) ? '' : json.decode(props)['qrcode'] ?? '';

  ChallengeHistoryRankingData.fromJson(Map<String, dynamic> json) {
    props = json['props'];
    bestRank = json['bestRank'];
    onlistTimes = json['onlistTimes'];
    total = json['total'];
    if (json['ranks'] is List) {
      ranks = [];
      (json['ranks'] as List).forEach(
          (v) => ranks.add(ChallengeHistoryRankingItemData.fromJson(v)));
    }
  }
}

///历史战绩的item
class ChallengeHistoryRankingItemData {
  ChallengeRankingListItemData champion; //当天最好成绩
  ChallengeRankingListItemData user;

  ChallengeHistoryRankingItemData.fromJson(Map<String, dynamic> json) {
    if (json['champion'] is Map)
      champion = ChallengeRankingListItemData.fromJson(json['champion']);
    if (json['user'] is Map)
      user = ChallengeRankingListItemData.fromJson(json['user']);
  }

2.3 View

import 'dart:convert';

import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:pupilmath/datamodel/challenge/challenge_history_ranking_list_data.dart';
import 'package:pupilmath/entity_factory.dart';
import 'package:pupilmath/network/constant.dart';
import 'package:pupilmath/network/network.dart';
import 'package:pupilmath/utils/print_helper.dart';
import 'package:pupilmath/viewmodel/base/abstract_base_viewmodel.dart';
import 'package:rxdart/rxdart.dart';

//每日挑战历史战绩
class ChallengeHistoryListViewModel extends BaseViewModel {
  BehaviorSubject<ChallengeHistoryRankingData> _challengeObservable =
      BehaviorSubject();

  Stream<ChallengeHistoryRankingData> get challengeRankingListStream =>
      _challengeObservable.stream;

  @override
  void dispose() {
    _challengeObservable.close();
  }

  @override
  void doInit(BuildContext context) {
    refreshData(context);
  }

  @override
  Future refreshData(BuildContext context) {
    return _loadHistoryListData();
  }

  _loadHistoryListData() async {
    Map<String, dynamic> parametersMap = {};
    parametersMap["pageNum"] = 1;
    parametersMap["pageSize"] = 10; //拿10天数据

    handleDioRequest(
      () => NetWorkHelper.instance
          .getDio()
          .get(challengeHistoryListUrl, queryParameters: parametersMap),
      onResponse: (Response response) {
        ChallengeHistoryRankingListResponse rankingListResponse =
            EntityFactory.generateOBJ(json.decode(response.toString()));

        if (rankingListResponse.isSuccessful) {
          _challengeObservable.add(rankingListResponse.data);
        } else {
          _challengeObservable.addError(null);
        }
      },
      onError: (error) => _challengeObservable.addError(error),
    );
  }

  Future<ChallengeHistoryRankingData> syncLoadHistoryListData(
    int pageNum,
    int pageSize,
  ) async {
    Map<String, dynamic> parametersMap = {};
    parametersMap["pageNum"] = pageNum;
    parametersMap["pageSize"] = pageSize;

    try {
      Response response = await NetWorkHelper.instance
          .getDio()
          .get(challengeHistoryListUrl, queryParameters: parametersMap);
      ChallengeHistoryRankingListResponse rankingListResponse =
          EntityFactory.generateOBJ(json.decode(response.toString()));
      if (rankingListResponse.isSuccessful) {
        return rankingListResponse.data;
      } else {
        return null;
      }
    } catch (e) {
      printHelper(e);
    }
    return null;
  }

2.4 一些基础架构

2.5 View 和 ViewModel 如何实现初始化和相互作用

2.6 Flutter 业务架构抽离

如果是统一系列的产品业务形态,还可以抽离出一套核心的架构,复用在同样的生产产品线上,例如当前产品线以教育为主,利用 Flutter 的一码多端性质,则可以把题版生产工厂、渲染题版引擎、 适配框架、 以及跨端接口的框架都抽离出来,迅速地形成可以推广复用的模板,可以事半功倍地解决掉业务上的试错成本问题,当然,其他产品性质的业务线均可如此。

三、Flutter 适配

任何框架中的 UI 适配都是特别繁重的工作,跨端上的适配更是如此,因此在同一套布局里面,各个平台的换算过程显得尤为重要,起初的时候,Flutter 中并没有提供某种诸如 dp 或者 sp 的适配方式,而且考虑到直接更改底层 Matrix 换算比例的话可能会让原本高清分辨率的手机显示不是那么清楚,而 Flutter 的宽高单位都是 num,最后编译的时候才会去对应到各个平台的单位尺寸。

为了减轻设计师的设计负担,这里通常使用一套 iOS 的设计稿即可,以375 x 667的通用设计稿为例,转换过来到android上是360 x 640 (对应1080 x 1920),这里flutter的单位也是和对应手机的像素密度有关的。

3.1 构造一个转换工具类:

//目前适配iPhone和iPad机型尺寸
import 'dart:io';
import 'dart:ui';
import 'dart:math';

import 'package:pupilmath/utils/print_helper.dart';

bool initScale = false;
//针对iOS平台的scale系数
double iosScaleRatio = 0;
//针对android平台的scale系数
// (因为所有设计稿均使用iOS的设计稿进行,所以需要转换为android设计稿上的尺寸,
// 否则无法进行小屏幕上的适配)
double androidScaleRatio = 0;
//文字缩放比
double textScaleRatio = 0;

const double baseIosWidth = 375;
const double baseIosHeight = 667;
const double baseIosHeightX = 812;

const double baseAndroidWidth = 360;
const double baseAndroidHeight = 640;

void _calResizeRatio() {
  if (Platform.isIOS) {
    final width = window.physicalSize.width;
    final height = window.physicalSize.height;
    final ratio = window.devicePixelRatio;
    final widthScale = (width / ratio) / baseIosWidth;
    final heightScale = (height / ratio) / baseIosHeight;
    iosScaleRatio = min(widthScale, heightScale);
  } else if (Platform.isAndroid) {
    double widthScale = (baseAndroidWidth / baseIosWidth);
    double heightScale = (baseAndroidHeight / baseIosHeight);
    double scaleRatio = min(widthScale, heightScale);
    //取两位小数
    androidScaleRatio = double.parse(scaleRatio.toString().substring(0, 4));
  }
}

bool isFullScreen() {
  return false;
}

//缩放
double resizeUtil(double value) {
  if (!initScale) {
    _calResizeRatio();
    initScale = true;
  }

  if (Platform.isIOS) {
    return value * iosScaleRatio;
  } else if (Platform.isAndroid) {
    return value * androidScaleRatio;
  } else {
    return value;
  }
}

//缩放还原
//每个屏幕的缩放比不一样,如果在iOS设备上出题,则题目坐标值需要换算成原始坐标,加载的时候再通过不同平台换算回来
double unResizeUtil(double value) {
  if (iosScaleRatio == 0) {
    _calResizeRatio();
  }

  if (Platform.isIOS) {
    return value / iosScaleRatio;
  } else {
    return value / androidScaleRatio;
  }
}

//文字缩放大小
_calResizeTextRatio() {
  final width = window.physicalSize.width;
  final height = window.physicalSize.height;
  final ratio = window.devicePixelRatio;
  double heightRatio = (height / ratio) / baseIosHeight / window.textScaleFactor;
  double widthRatio = (width / ratio) / baseIosWidth / window.textScaleFactor;
  textScaleRatio = min(heightRatio, widthRatio);
}

double resizeTextSize(double value) {
  if (textScaleRatio == 0) {
    _calResizeTextRatio();
  }
  return value * textScaleRatio;
}

double resizePadTextSize(double value) {
  if (Platform.isIOS) {
    final width = window.physicalSize.width;
    final ratio = window.devicePixelRatio;
    final realWidth = width / ratio;
    if (realWidth > 450) {
      return value * 1.5;
    } else {
      return value;
    }
  } else {
    return value;
  }
}

double autoSize(double percent, bool isHeight) {
  final width = window.physicalSize.width;
  final height = window.physicalSize.height;
  final ratio = window.devicePixelRatio;
  if (isHeight) {
    return height / ratio * percent;
  } else {
    return width / ratio * percent;
  }

3.2 具体使用:

这样每次如果有分辨率变动或者适配方案变动的时候,直接修改 resizeUtil 即可,但是这样带来的问题就是,在编写过程中单位变得很冗长,而且不熟悉团队工程的人会容易忘写,导致查错时间变长,代码侵入性较高,于是利用 dart 语言的扩展函数特性,为 resizeUtil 做一些改进。

3.3 低侵入式的 resizeUtil

通过扩展 dart 的 num 来构造想要的单位,这里用 dp 和 sp 来举例,在 resizeUtil 中加入扩展:

extension dimensionsNum on num {
  ///转为dp
  double get dp => resizeUtil(this.toDouble());

  ///转为文本大小sp
  double get sp => resizeTextSize(this.toDouble());

  ///转为pad文字适配
  double get padSp => resizePadTextSize(this.toDouble());

然后在布局中直接书写单位即可:

四、Flutter 中的一些坑

4.1 泛型上的坑

刚开始在移动端上使用泛型来做数据的自动解析时,使用了 T.toString 来判断类型,但是当编译成 web 的 release 版本时,在移动端正常运行的程序在web上无法正常工作:

刚开始的时候把目标一直定位在编译的方式上,因为存在 dev profile release 三种编译模式,只有在 release 上无法运行,误以为是 release 下编译有 bug,随着和 Flutter 团队的深入讨论后,发现其实是泛型在 release 模式下的坑,即在 web 版本的 release 模式下,一切都会进行压缩(包含类型的定义),所以在 release 下,T.toString() 返回的是 null,因此无法识别出泛型特征,具体的讨论链接:

Flutter application which use canvas to build self-CustomPainter cannot work on browser if i used the release mode by command "flutter run -d chrome --release" or "flutter build web". · Issue #47967 · flutter/flutter​github.com

In release mode everything is minified, the (T.toString() == "Construction2DEntity") comparison fails and you get entity null returned.
If you change the code to (T ==Construction2DEntity) it will fix your app.

最后建议,无论在何种模式下,都直接写成T==的形式最为安全。

class EntityFactory {
  static T generateOBJ<T>(json) {
    if (1 == 0) {
      return null;
    } else if (T == "ChallengeRankingListDataEntity") {
      /// 每日挑战排行榜
      return ChallengeHomeRankingListResponse.fromJson(json) as T;
    } else if (T == "KnowledgeEntity") {
      return KnowledgeEntity.fromJson(json) as T;
    }
  }

4.2 在编译成 web 产物后如何使用 iframe 来加载其他网页

对于移动端来说,webview_flutter 可以解决掉加载 web 的问题,不过编译成 web 产物后,已经无法直接使用 WebView 插件来进行加载,此时需要用到 dart 最初设计来编写网页的一些方式,即 HtmlElmentView:

import 'package:flutter/material.dart';
import 'dart:ui' as ui;
import 'dart:html' as html;

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: MyHomePage(),
    );
  }
}

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
           child: Iframe()
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: (){},
        tooltip: 'Increment',
        child: Icon(Icons.add),
      ), 
    );
  }
}
class Iframe extends StatelessWidget {
  Iframe(){
    ui.platformViewRegistry.registerViewFactory('iframe', (int viewId) {
      var iframe = html.IFrameElement();
      iframe.src='https://flutter.dev';
      return iframe;
  });
  }
  @override
  Widget build(BuildContext context) {
    return Container(
      width:400,
      height:300,
      child:HtmlElementView(viewType: 'iframe')
    );
  }

不过这种方式会带来新的底层刷新渲染问题(当鼠标移动到某个元素时,会不停地闪动刷新),目前在新的版本上已修复,有兴趣的同学可以看看:

https://github.com/flutter/fl...

4.3 Flutter 如何加载本地的 html 并且进行通信

内置 html 是很多工程的需求,很多网上的资料都是通过把本地的 html 做成数据流的方式然后加载进来,这种做法的兼容性很不好,而且编写过程中容易出现很多文件流过大无法读取的问题,其实这些做法都不是很舒适,我们应该通过 IFrameElement 来进行加载并通信,做法和前端很类似:

4.4 在 iOS 13.4 上 WebView 的手势无法正常使用

官方的 webview_flutter 在上一个版本当 iOS 升级到13.4之后会出现手势被拦截且无法正常使用的情况,换成flutter_webview_plugin后暂时解决掉该问题(目前 WebView 已经做了针对性的修复,但是还未验证),但是 flutter_webview_plugin 在 iOS 上又无法写入 user-agent,目前可以通过修改本地的插件代码进行解决:

文件位置为:

flutter/.pub-cache/hosted/pub.flutter-io.cn/flutter_webview_plugin-0.3.11/ios/Classes/FlutterWebviewPlugin.m修改内容为在146行(initWebview方法中初始化WKWebViewConfiguration后)添加如下代码if (@available(iOS 9.0, *)) {if (userAgent != (id)[NSNull null]) {self.webview.customUserAgent = userAgent;}}

关于 webview_flutter 的手势问题还在不断的讨论中:

https://github.com/flutter/fl...

五、关于布局和运算

5.1 容器 Widget 和渲染 Widget

5.2 GlobalKey

通过 GlobalKey 获取 RenderBox 来获取渲染出的控件的 size 和 position 等参数:

5.3 浮点运算

在 dart 的浮点运算中,由于都是高精度的 double 运算,当运算长度过长的时候,dart 会自动随机最后的一位小数,这样会导致每一次有些浮点运算每一次都是不确定的,这时需要手动进行精度转换,例如在计算两条线段是否共线时:

5.4 Matrix 的平移和旋转

在矩阵的换算过程中,如果使用普通的matrix.translate,会导致 rotate 之后,再进行 translate 会在旋转的基数上面做系数叠加平移运算,这样计算后得到的不是自己想要的结果,因此如果运算当中有 rotate 操作时,应当使用 leftTranslate 来保证每次运算的独立性:

六、项目优化

6.1 避免 build() 方法耗时:

6.2 重绘区域优化:

6.3 尽量避免使用 Opacity

6.4 Flutter的单线程模型

优先全部执行完 Microtask Queue 中的 Event,直到 Microtask Queue 为空,才会执行 Event Queue 中的 Event。

6.5 耗时方法放在 Isolate

Isolate 是 Dart 里的线程,每个 Isolate 之间不共享内存,通过消息通信。

Dart 的代码运行在 Isolate 中,处于同一个 Isolate 的代码才能相互访问。

七、杂谈总结

经历了对 Flutter 长期的探索和项目验证,目前对 Flutter 有自己的一些杂谈总结:

7.1

Flutter 在移动端的表现还是很不错的,在运行流畅度方面也是非常棒,经过优化过后的带大量图像运算的 app 运行在2013年的旧 Android 手机上面依然十分流畅,iOS 的流畅程度也堪比原生。

7.2

对于 web 的应用来说,Flutter 还在不断地改进,其中还有很多的坑没有解决,这里包括了移动端的 WebView 以及编程成的 web 应用,还不适合大面积的投入到 web 的生产环境中。

7.3

关于和 Native 的混编,为了避免产生混合栈应用中的内存问题和渲染问题等,建议尽量将嵌入原生的 Flutter 节点设计在叶子节点上,即业务栈跳转到 Flutter 后尽量完成结束后再回到Native栈中。

7.4

基于“去桥”的原生编译方式,Flutter 在未来各个平台上的运行应该会充满期待,目前验证的移动端应用打包成 Windows 应用后,运行表现还是很不错的,当然一些更大型的应用需要时间去摸索和完善。

7.5

语法方面,Flutter 中的 dart 正在变得越来越简单,也在借鉴一些优秀的前端框架上的语法,例如 react 等,kotlin 中也有很多相似的地方,感觉 Flutter 团队正在努力地促进大前端时代的发展。


总之,Flutter 确实带来了很多以前的跨端方案没法满足的惊喜的地方,相信不久的将来一码多端会变得越来越重要,特别是在新业务的探索成本上表现得十分抢眼。

以上是一些对 Flutter 的一些粗浅的总结,欢迎有兴趣的小伙伴一起探讨。

网易有道,与你同道,因为热爱所以选择, 期待志同道合的你加入我们。
  • END -
阅读 1.8k

有道技术团队
网易有道是中国领先的智能学习公司,致力于提供100%以用户为导向的学习产品和服务。旗下有网易有道词典...
409 声望
2.2k 粉丝
0 条评论
你知道吗?

409 声望
2.2k 粉丝
宣传栏