Flutter之原理解析

3,747 阅读17分钟

Flutter 是 Google推出并开源的移动应用开发框架,主打 跨平台、高保真、高性能。开发者可以通过 Dart语言开发 App,一套代码同时运行在 iOS 和 Android平台。 Flutter提供了丰富的组件、接口,开发者可以很快地为 Flutter添加 native扩展。同时 Flutter还使用Native引擎渲染视图,这无疑能为用户提供良好的体验。


在解析原理之前,首先让我们思考以下几个关于Flutter的问题:

  1. 怎么实现跨平台的?原理是什么?
  2. 为什么说 fullter 的 render 性能接近于原生?
  3. 对比使用 web 的开发方式(RN、WEEX)有什么好处?

怎么实现跨平台的?原理是什么?

在回答这个问题之前, 我们需要先了解几个操作系统的基本原理。

UI系统

UI系统是指通过某个平台实现的一套 GUI(图形用户界面) 系统。
简单来说用户可以通过 GUI 系统使用鼠标、键盘等硬件设备与操作系统打交道。
这里所说的平时指的是操作系统,如 Android、ios 或者 wins、macOS。
各个平台UI系统的原理是相通的,也就是说无论是Android还是iOS,他们将一个用户界面展示到屏幕的流程是相似的。

下面我们来了解下UI系统的基本原理:

硬件绘图基本原理

我们知道显示器(屏幕)是由一个个物理显示单元组成,每一个单元我们可以称之为一个物理像素点,而每一个像素点可以发出多种颜色,显示器成相的原理就是在不同的物理像素点上显示不同的颜色,最终构成完整的图像。

一个像素点能发出的所有颜色总数是显示器的一个重要指标,比如我们所说的1600万色的屏幕就是指一个像素点可以显示出1600万种颜色。

为了更新显示画面,显示器是以固定的频率刷新(从GPU取数据),比如有一部手机屏幕的刷新频率是 60Hz。当一帧图像绘制完毕后准备绘制下一帧时,显示器会发出一个垂直同步信号(如VSync), 60Hz的屏幕就会一秒内发出 60次这样的信号。而这个信号主要是用于同步CPU、GPU和显示器的。一般地来说,计算机系统中,CPU、GPU和显示器以一种特定的方式协作:CPU将计算好的显示内容提交给 GPU,GPU渲染后放入帧缓冲区,然后视频控制器按照同步信号从帧缓冲区取帧数据传递给显示器显示。

操作系统绘制API的封装

由于最终的图形计算和绘制都是由相应的硬件来完成,而直接操作硬件的指令通常都会有操作系统屏蔽,应用开发者通常不会直接面对硬件,操作系统屏蔽了这些底层硬件操作后会提供一些封装后的API供操作系统之上的应用调用。

但是对于应用开发者来说,直接调用这些操作系统提供的API是比较复杂和低效的,因为操作系统提供的API往往比较基础,直接调用需要了解API的很多细节。

正是因为这个原因,几乎所有用于开发GUI程序的编程语言都会在操作系统之上再封装一层,将操作系统原生API封装在一个编程框架和模型中,然后定义一种简单的开发规则来开发GUI应用程序。

例如:
Android SDK 正是封装了Android操作系统API,提供了一个“UI描述文件XML+Java操作DOM”的UI系统。
iOS的UIKit 对View的抽象也是一样的,他们都将操作系统API抽象成一个基础对象(如用于2D图形绘制的Canvas),然后再定义一套规则来描述UI,如UI树结构,UI操作的单线程原则等。

说到这我想大家应该明白Flutter是怎么实现跨平台的了。

我们可以看到,无论是Android SDK还是iOS的UIKit 的职责都是相同的,它们只是语言载体和底层的系统不同而已。那么可不可以实现这么一个UI系统:可以使用同一种编程语言开发,然后针对不同操作系统API抽象一个对上接口一致,对下适配不同操作系统的的中间层,然后在打包编译时再使用相应的中间层代码?如果可以做到,那么我们就可以使用同一套代码编写跨平台的应用了。

而Flutter的原理正是如此,它提供了一套Dart API,然后在底层通过skia这种跨平台的绘制库(内部会调用操作系统API)实现了一套代码跨多端。

这就是Flutter实现跨平台的原理。

由于Dart API也是调用操作系统API,所以说它的性能接近原生。

注意,虽然Dart是先调用了skia,skia才会调用操作系统API,但是这仍然是原生渲染,因为skia只是操作系统API的一个封装库,所以性能不能有什么损失。

说到这大家应该已经能明白开头提的前两个问题吧!~~~

Flutter 实现原理

Fluuter官网 上,google 给出了一个非常详尽的图来解释 Flutter的原理:

让我们来分析下这张图:

  1. GPU的 VSync 信号同步到 UI线程。
  2. UI线程使用 Dart来构建抽象的视图结构。
  3. 这份视图数据结构在 GPU 线程进行图层合成。
  4. 视图数据提供给 Skia 引擎渲染为 GPU 数据。
  5. 这些数据通过 OpenGL或者 Vulkan 提供给 GPU

所以 Flutter 并不关心显示器、视频控制器以及 GPU 具体工作,它只关心向 GPU 提供视图数据,在显示器会发出一个垂直同步信号(VSync),尽可能快地在两个 VSync 信号之间计算并合成视图数据,并且把数据提供给 GPU 。

说到这大家应该对 Flutter 的原理有个基本的认识了吧。下面让我们来看下Flutter是如何被设计的。

Flutter是如何设计的

首先让我们来看一下 google 官方给出的架构图:

  • FLutter Engine
    这是一个纯 C++实现的 SDK,其中囊括了 Skia引擎、Dart运行时、文字排版引擎等。
    简单来说它就是一个 dart 运行时,可以以 JIT(动态编译) 或者 AOT(静态编译) 的方式运行 dart 代码。

  • Flutter Framework
    最上层应用,我们的应用都是围绕这层来构建,所以该层也是我们打交道最多的层。
    改层是一个纯 Dart实现的 SDK,类似于 React在 JavaScript中的作用。它实现了一套基础库, 用于处理动画、绘图和手势。并且基于绘图封装了一套 UI组件库,然后根据 Material 和Cupertino两种视觉风格区分开来。

    • 【Foundation】 在最底层,主要定义底层工具类和方法,以提供给其他层使用。
    • 【Animation】是动画相关的类,一些动画相关的都在该类中定义。
    • 【Painting】封装了 Flutter Engine 提供的绘制接口,例如绘制缩放图像、插值生成阴影、绘制盒模型边框等。
    • 【Gesture】提供处理手势识别和交互的功能。
    • 【Rendering】是框架中的渲染库。控件的渲染主要包括三个阶段:布局(Layout)、绘制(Paint)、合成(Composite)。
    • 【Widget】控件层。所有控件的基类都是 Widget,Widget 的数据都是只读的, 不能改变。
    • 【Material】&【Cupertino】这是在 Widget 层之上框架为开发者提供的基于两套设计语言实现的 UI 控件,可以帮助我们的 App 在不同平台上提供接近原生的用户体验。

RN的实现原理

  • 在 React 框架中,JSX 源码通过 React 框架最终渲染到了浏览器的真实 DOM 中。
  • React Native 框架中,JSX 源码通过 React Native 框架编译后,通过对应平台的 Bridge 实现了与原生框架的通信。
    UI 层的变更,就映射为虚拟 DOM 后进行 diff 算法,diff 算法计算出变动后的 JSON 映射文件,最终由 Native 层将此 JSON 文件映射渲染到原生 App 的页面元素上,最终实现了在项目中只需要控制 state 以及 props 的变更来引起 iOS 与 Android 平台的 UI 变更。

再来一张简易图:

对比RN

关于性能

  • 对比 RN 与 Flutter 的实现原理可以看出,RN 需要通过 js运行环境 后再与 native 通讯,而 Flutter 是通过 skia 直接调用的系统API,所以从这个层面来讲,Flutter 性能要优于 RN。
    这里给出闲鱼关于 Flutter 与 RN 性能对比的一遍文章:流言终结者- Flutter和RN谁才是更好的跨端开发方案?

关于调试

  • 因为RN代码是JS语言,所以需要在浏览器中调试。
  • Flutter可以在编辑器中直接下断点调试,相对于RN,Flutter这种调试方式比较方便,更符合原生调试习惯。

关于平台关联性

  • RN:平台关联性强,因为各个平台复杂度的原因导致,很容易出来如:在 IOS 上正常的样式,在 Android 上出现了异常react-native 的版本升级导致原本好的功能出现崩溃Android 上的性能比 IOS 差太多.
  • Flutter: 平台关联性很低, 一切的控件都是 skia 绘制上去的,所以很大程度保证了在 Android 和 IOS 上的一致性。

关于学习成本

  • RN:对前端开发小伙伴比较友好,只要有 js、html、css 基础,会使用 React 框架, 就能着手开发RN项目。
  • Flutter: 学习成本高, 需要先学习 Dart 语言,社区生态较少。

关于UI框架

-RN:可以利用原生已有的优秀UI。 -Flutter:Flutter自己实现了一套UI框架,但丢弃了原生的UI框架,也就是说 Flutter 不能使用原生的ui框架。

DART语言

由于Flutter采用dart语言进行开发,所以我们首先需要对dart语言有一个初步的了解。而Flutter为什么选择Dart语言作为唯一的开发语言呢?总的来说,其拥有如下优势:

  1. Dart可基于AOT(Ahead Of Time)编译,即编译成平台的本地代码,运行性能高。
  2. Dart也可基于JIT(Just In Time)编译,编译快速,可热加载,使开发周期加倍提升。对比JIT和AOT,各自有什么优点与缺点?
  3. Dart可以更轻松地创建以60fps运行的流畅动画和转场。
  4. Dart语法结合Java与JavaScript语法特点,几乎没有令人不适的怪异语法。

通常来说一门语言要么使用AOT编译,编译慢,开发效率低,或者使用JIT编译,在运行时编译,虽然可以热重载,但是执行效率低,而Dart在这两种之间做出了完美平衡,当开发时使用JIT编译,调试快,所见即所得,开发效率高,当发布时,使用AOT编译,编译成目标平台的本地代码,执行效率高。

DART语音基础语法

代码注释

  // 单行注册

  /*
   * 多行注释
   */

内置数据类型(图片来源CSDN)

注意:没有初始化的变量默认值为 null。数值类型变量的默认值也是 null
数值类型num有两个具体子类,分别为int和double,其中int为整数值;double则是双精度浮点数。

变量与常量

定义变量

Dart中定义变量有两种方式,一种是静态类型语言常用的方式,显式指定变量类型,另一种则是动态语言的常用方式,不指定类型,由vm自动推断。

  // 1.通过显式指定类型来定义变量
  String name = "张三";
  num age = 18;

  // 2.使用关键字var,不指定类型
  var address = "深南大道";
  var id = 100;

  /* 使用var定义变量,即使未显式指定类型,一旦赋值后类型就被固定
   * 因此使用var定义的变量不能改变数据类型
   */
  var number = 19;
  // 以下代码错误,无法运行,number变量已确定为int类型
  number = "2019";

如想动态改变变量的数据类型,应当使用dynamicObject来定义变量。

  // dynamic声明变量
  dynamic var1 = "hello";
  var1 = 19;
  print(var1);    // 19

  // Object声明变量
  Object var2 = 20;
  var2 = "Alice";
  print(var2);    // Alice

定义常量

Dart中定义常量也有两种方式,一种使用final关键字,同Java中的用法, 一个 final 变量只能赋值一次;另一种是Dart的方式,使用const关键字定义。

  // 1.使用final关键字定义常量
  final height = 10;

  // 2.使用const关键字定义常量
  const pi = 3.14;

内置类型的常用操作

数值类型

  // String 转 int
  var one = int.parse('1');

  // String 转 double
  var onePointOne = double.parse('1.1');

  // int 转 String
  String oneAsStr = 1.toString();

  // double 转 String
  String piAsStr = 3.14159.toStringAsFixed(2); // 保留两位 '3.14'

字符串

  // 1.Dart可以使用单引号或双引号来创建字符串(和 js 一样)
  var s1 = "hello";
  var s2 = 'world';

  // 2.类似Python,Dart可以使用三引号来创建包含多行的字符串(类似 es6 中的 ``)
  var multiLine1 = """你可以像这样,创建一个
  包含了多行的字符串内容
  """;

  var multiLine2 = '''你也可以使用三个单引号,创建一个
  包含了多行的字符串内容
  ''';

  // 3.类似Python,还可以在字符串字面值的前面加上`r`来创建原始字符串,则该字符串中特殊字符可以不用转义
  var path = r'D:\workspace\code';

  // 4.Dart支持使用"+"操作符拼接字符串
  var greet = "hello" + " world";

  // 5.Dart提供了插值表达式"${}",也可以用于拼接字符串
  var name = "王五";
  var aStr = "hello,${name}";
  print(aStr);    // hello,王五

  // 当仅取变量值时,可以省略花括号
  var aStr2 = "hello,$name"; // hello,王五

  // 当拼接的是一个表达式时,则不能省略花括号
  var str1 = "link";
  var str2 = "click ${str1.toUpperCase()}";
  print(str2);   // click LINK

  // 6. 与Java不同,Dart使用"=="来比较字符串的内容 (java使用equal)
  print("hello" == "world");

布尔类型

Dart中的布尔类型用法同Java,仅有false、true两个值,不能使用0、非0或者null、非null来表达falsetrue。与Java不同的是,布尔类型的默认值为null

  bool flags;
  print(flags);    // null

列表

Dart中列表操作与JavaScript中的数组相似。

  // 创建列表
  var list = [1, 2, 3];
  // 下标从0开始。使用length可以访问list的长度
  print(list[0]);
  print(list.length);

  // 可以使用add添加元素
  list.add(5);

  // 可在list字面量前添加const关键字,定义一个不可改变的 列表(编译时常量)
  var constantList = const [1, 2, 3];
  constantList[1] = 1;     // 报错

映射

又称为关联数组,相当于Java中的HashMap

  // 1.通过字面量创建Map
  var gifts = {
    'first' : 'partridge',
    'second': 'turtledoves',
    'fifth' : 'golden rings'
  };

  // 2.使用Map类的构造函数创建对象
  var pic = new Map();
  // 往Map中添加键值对
  pic['first'] = 'partridge';
  pic['second'] = 'turtledoves';
  pic['fifth'] = 'golden rings';

  // 3.获取Map的长度
  print(pic.length);

  // 4.查找Map
  pirnt(pic["first"]);
  print(pic["four"]);    // 键不存在则返回 null

函数

在Dart中,函数(或方法) 也是对象,它的类型是 Function。 这意味着,函数可以赋值给变量,也可以当做其他函数的参数。

定义函数

Dart中定义函数,基本上与Java类似

  String greet(String name){
      return "hello,$name";
  }

在Dart中,类型是可选,可以省略显式的类型,但仍然建议显式指定类型。

  greet(String name){
      return "hello,$name";
  }

函数的参数

Dart中支持两种可选参数

  • 命名可选参数
  • 位置可选参数 在Java中通常使用方法重载来实现同名方法的不同参数调用,Dart中则可以通过可选参数来实现相同效果。

命名可选参数

先来看一下命名参数,它使用花括号来定义参数列表

  // 定义一个函数,参数列表用花括号包裹
  enableFlags({bool bold, bool hidden}) {
      // do something
  }

  // 调用方式,传参时使用"参数名:值"的形式
  enableFlags(hidden:true,bold:false);

如果在定义函数时,给参数列表中的参数设置默认值,则该参数就是可选的,函数调用时可以忽略该参数,使用默认的值。

  // 定义add函数
  add({int x, int y=1, int z=0}){
      print(x + y + z;
  }

  // 调用
  add(x:18);              // 19
  add(x:18, y:2, z:10);   // 30

位置可选参数

位置可选参数使用中括号来定义参数列表,中括号中的参数是可选的

  // 定义add函数
  add(int x, [int y, int z]){
      int result = x;
      if (y !=  null){
          result = result + y;
      }

      if (z !=  null){
          result = result + z;
      }
      print(result);
  }

  // 调用
  add(18);           // 18
  add(18,12);        // 30
  add(18, 12, 15);   // 45

最后需要注意一下命名可选参数位置可选参数的区别,前者中的参数与顺序无关,无需按顺序传参,且传参数时需使用冒号;后者与顺序相关,传参必须依照顺序。

匿名函数

  // 定义匿名函数,并将其赋值给一个变量func,注意,函数体最后的花括号处必须有分号结束。
  var func = (x,y){
      return x + y;
  };

  print(func(10,11));    // 21

匿名函数与普通函数基本相同,也有参数列表,函数体,只是省去了函数名而已。

箭头函数

Dart中的箭头函数与JavaScript中的基本相同。当函数体中只包含一个语句时,我们就可以使用=>箭头语法进行缩写。

普通函数

  add(num x, num y){
      return x + y;
  }

  print(add(18,12));    // 30

箭头函数

  // 与上面的普通函数完全等价
  add(num x, num y) => x + y;

  print(add(18,12));    // 30

运算符

Dart语言中的运算符与Java中的绝大多数相同。

算术运算符

+-*/%同Java语言

Dart中又多出了一个整除运算符~/,与普通除号的区别是将相除后的结果取整返回。

类型判定运算符

操作符解释
as用于类型转换
is如果对象是指定的类型就返回 True
is!如果对象不是指定的类型返回 True

Dart中使用 as 操作符把对象转换为特定的类型,如无法转换则会抛出异常,因此在转换前最好使用is运算符进行检测。

条件表达式

Dart中也支持三目表达式
condition ? expr1 : expr2

除此外,Dart还增加了非空条件判断符??
expr1 ?? expr2
上述运算表示,如果expr1的值不等于null,则返回其值; 否则执行表达式expr2并返回其结果。

级联运算符

我们通常使用.操作符调用对象的方法,这在Dart中也是支持的,但是Dart另外增加了一种级联运算符..,用两个点表示。

级联运算符可以在同一个对象上连续调用多个方法以及访问成员变量。 使用它可以避免创建临时变量, 写出更流畅的代码。
假如类Person有三个方法,setName、setAge、save,则可如下调用:

new Person()..setName("Bob")..setAge(20)..save();

使用级联运算符调用方法,无需该方法返回对象本身即可连续的流式的调用该对象的其他方法。

条件成员访问符

条件成员访问符?.,它和.类似,但是运算符左边的对象不能为null,否则返回null,若对象不为null,则返回对象本身。

  // list1默认值为null
  List list1;
  print(list1?.length);  // null

  List list2 = [];
  print(list2?.length);  // 0

循环语句

基本循环

Dart中的基本循环语句与Java相同

  // for循环
  for(int i = 0; i < 9; i++) {
    print(i);
  }

  // while循环
  while(true){
    //do something
  }

  // do-while循环
  do{
    //do something
  } while(true);

特有循环

  var myList = ['Java','JavaScript','Dart'];

  // for...in...循环,类似Java中的增强for
  for (var it in myList ){
      print(it);
  }

  // forEach循环。其参数为一个Function对象,这里传入一个匿名函数
  myList.forEach((var it){
      print(it);
  });

  // 可以使用匿名箭头函数简写
  myList.forEach((it) => print(it));

使用循环遍历Map

  var myMap = {
  'zhangsan':'201901',
  'lisi':'201902',
  'wangwu':'201902'
  };

  // forEach遍历Map
  myMap.forEach((k, v) =>  print("$k : $v"));

  // 根据键获取值来遍历。通过keys返回Map中所有键的集合
  for(var k in myMap.keys){
      print("$k : ${myMap[k]}");
  }