乐趣区

入坑-Flutter-前你需要知道的一些-XXX

扳指一算,这已经是 Flutter 出道的第三个年头了。这两年来,用它写过几个类似计算器、手电筒、罗盘之类小到不能再小的应用,也算是对其有一点点拙见,本文纯属爽完后的瞎掰,不涉及过多的代码,只对准备入坑或者想要入坑的童鞋简单地聊一聊 Flutter,包括它的基础架构、设计原理、应用场景以及未来发展等等。

What is Flutter?

Flutter 是什么?套用目前 Flutter 官网的原话来讲就是:

Flutter is Google’s UI toolkit for building beautiful, natively compiled applications for mobile, web, and desktop from a single codebase.

这句话有几个关键点:“UI toolkit”、“natively compiled”和“single codebase”。

首先,Google 将 Flutter 定义为 UIToolkit,即偏向于 UI 的工具;其次,Flutter 可以提供近似于原生的效果,最最最重要的是可以将一份代码用作于 mobile、web 以及 desktop”,这几乎是所有前端开发者梦寐以求的功能。想象一下,你只需编写一份代码就可以将其运行于移动端、网页以及桌面端,这相当于直接打通目前流行的几大前端:Web、Android、IOS、Linux、MacOS、Windows,想想都炒鸡美好!截止本文发布为止,Flutter 已经更新至 1.7,其在移动端的两大平台 Android 和 IOS 上的表现非常好,目前 Web 端也处于测试阶段,相信很快就可以发布,而桌面端相信在不远的将来也很快就可以看到。

官方给出的 Flutter 整体架构图如下:

主要将其分为三部分,最底下的 Platform Specific 用于兼容不同的平台特性,在可预见的将来,这一块的逻辑将会随着支持的平台增多而有所改动。其次是处于中间的 Engine,由 C/C++ 实现,主要分为两个功能:一是用于与上层和下层通信;二是用于图形图像的 Render 渲染。最后是最上层的 Framenwork,由 Dart 实现,主要是一些 UI 相关的封装实现,这一块几乎都是在造 Dart Related 的轮子没什么好说的。

Flutter 的设计原理其实并不复杂,特别是对于做过手游或地图类需要大量绘制场景的童鞋来说,Flutter 与这些手游的原理几乎没区别。Android 手机的开发者选项里有一个叫做“显示布局边界”的选项:

所谓布局边界,就是 Android 应用界面布局上每一个 View 的边界,首先我们需要知道的是在 Android 中所有的 View 都是“矩形”的,即便在界面上它呈现的是不同的图像,比如一个圆、一颗星。不管这些 View 显示的是何种图形,它都有一个矩形的边界,你可以把 Android 的 UI 界面看做是一面墙,而这些一个个的 View 就像是墙上挂着的相框,每个相框都是矩形的,尽管它们装裱着各不相同的图片。

打开“显示布局边界”,你就可以在屏幕上看到界面上一个个 View 的“边框”:

大部分情况下,我们使用这个功能来查看界面的布局状况以便查找一些布局上的问题。同样,我们也可以用该功能查看任意安装在手机上的应用当前界面布局状态,比如像闲鱼这样的:

但是,你并不是总能查看到某些应用界面的布局边界,比如高德地图的界面:

即便在该地图界面上有很多元素,但是它们并不是像上面的闲鱼 APP 那样由许多 View 堆砌成。再举个栗子,比如王者荣耀这样的游戏:

除了 SystemUI 的边界外,你更是看不到任何 View 的边界存在。

这里爱哥将上述两种类型的应用统称为“绘制”类应用,为什么这类绘制类应用不使用系统 Framework 提供的 View 组件呢?因为需求不符,既不符合技术上的需求,也不符合产品上的需求。技术上你可以尝试下在某个布局里添加几百个 Button 并频繁更新看看什么效果;至于产品上嘛,emmmmmmm……你可以想象下 ImageView 上设置各个角度的鲁班一号序列帧(当然实际上是用 U3D)在地图界面上跑的效果。所以,这些对绘制要求极高的应用大多会采用自己绘制的方式来实现界面。而像 Andriod 或 IOS 这些平台原生提供的 UI 控件本质上也不过是一堆绘制封装好便于重复使用的元素而已,像一些 GL 实现的游戏,就有大量封装的界面元素组件可以直接进行移植使用。如果我们把某一平台设备所展示的界面看成是一张画布,那么界面上的元素也就是这张画布上的图像而已,假如我们提供一种可能,将这张画布从一个平台设备“撕”下来放置到另一个平台设备上,那么我们至少可以在用户的角度实现跨平台。刚才我们说过,像 Andriod 或 IOS 这些平台原生提供的 UI 控件本质上也不过是一堆绘制封装好便于重复使用的元素而已。一种理想的状况是将其中之一的这些元素移植到另一方,那么到底是 Android to IOS 还是 IOS to Android 呢?显然两者都不可能,两者发展至今都于各自平台上积累了大量的原生逻辑,且与各自平台的耦合度都很高,强行实现无异于对整个 UI 层面的重写,如果重写,那又何必只局限于两个平台而不兼容更多平台呢?

软件架构一大光辉照耀的主体思想是提取共性,有共性才有被设计的可能,以不同平台的按钮控件为例,我们是否能在 Button(Android)、UIButton(Apple)、<button>(Web)等等按钮控件上找到共同之处呢?答案是当然可以,但是不现实。假如我们将一个平台的 Button 提取移植到另一个平台的难度系数是 1,那么再次移植到另一个平台的难度系数就会变成 2,随着不同平台的增多,难度系数也会递增。更何况 UI 层是易变层,分分钟都有可能被修改,难度会指数递增。因此,我们需要更加底层的设计。就像上面所说,界面就像一张画布,我们其实只需要将这张画布整张移植即可,至于你想在这张画布上画些什么,你随意。所以,我们需要在各自平台更底层上找共性。在软件层面,我们在显示器界面能看见各种不同的图像主要依赖于图形渲染引擎,如各浏览器上的渲染器、IOS 平台基于 OpenGL 的 CG、CA、CI 框架还有刚推出被吐槽得厉害的 Metal、Android 平台上的 2D 部分绘制的 Skia、3D 部分绘制的 OpenGLES 和 较新的 Vulkan。假如可以在各个平台上都使用相同的渲染引擎,至少我们可以先实现画布上的统一,SO……Google 选择了一直被 Android 使用的 Skia。(PS:这里将渲染引擎比作“画布”其实不太准确,画布更准确的描述应该是各自平台的 Surface 或 Layer Container,但是爱哥找不到一个更容易让普通人理解的名词鸟……)

选择 Skia 其实除了其十多年于 Android 平台的稳定使用外,还有主流浏览器都支持 Skia,更重要的是 Skia 是 Google 的亲儿子(虽然是付钱领养的)。因此这里我们只需要在 IOS 平台也使用 Skia 即可完成至少 Web、Android、IOS 三平台的通用。至于为什么不直接使用 OpenGL,我觉得主要原因在于不管是哪个平台其实都以普通应用程序的 2D 呈现为主,而 Skia 的 2D 绘制也非常成熟稳定,直接使用 OpenGL 来实现 2/3D 的绘制虽然也阔以,但是显得没必要,再说,迈一小步摔倒的概率也小于迈一大步摔倒的概率是不是?至于未来 Flutter 不会不会对 GL 提供更好的应用层面支持,官方的回答如下:

Today we don’t support for 3D via OpenGL ES or similar. We have long-term plans to expose an optimized 3D API, but right now we’re focused on 2D.

搞定了画布,剩下来的基本就是堆各种元素了,也就是 Flutter 架构上面的那一块,这是个体力活,鉴于本文篇幅就不多说了。

以上只是对 Flutter 的一个整体设计思路口嗨了一下,实际上除了图形渲染这块外,Flutter 还有许多细节需要实现。但是你可以看到的是,无论是设计思路还是 Google 对其官方的描述,Flutter 都更偏向于一个 UIToolkit 而非可以直接侵入原生系统的框架。因此我们可以简单地这么说:Flutter 是一个可以制作跨平台 UI 的工具。

Why is Flutter?

为什么是 Flutter?从软件开发平台分化开始,各路大神在跨平台上的努力就没有停止过,这里我将跨平台按实现原理分为两类:第一类是自己虚拟化一个运行环境的,比如 Java 这样的运行在虚拟机里的;第二类是利用现成的跨平台环境进行二次封装的,比如各种利用浏览器 Core 配合 JavaScript 封装的框架。前者不仅可以几乎完全屏蔽平台相关性的影响而对外提供统一的接口,而且在运行效率上也相对接近原生;而后者最大限度地利用了现有的跨平台方案,不足的是对于不同平台实现逻辑的执行效率依然差别很大,而且某些情况下还需要根据平台的不同提供不同的 API,比如 Atom 的开发框架 ElectronJS 就会在其 API 文档中提供针对不同平台提供不同的 API 实现逻辑:

而 Flutter 则结合了这两者。一方面它需要为 Dart 代码的执行提供虚拟化环境;另一方面它直接选用更底一层的图形化渲染引擎方案直接实现 UI 逻辑。虽然其没有像 ElectronJS 那样赤裸裸地针对不同平台提供不同的 API 接口,但是其 UIToolkit 的定义却给我们修改平台原生代码提供了极大的便利。其 Project 目录的组织可以很好地体现这一点:

在 Android Studio 中,你可以直接右键根目录或者 android/ios 目录直接将原生的 android 或 ios 项目从新的 Android Studio 窗口 或 Xcode 中直接打开进行修改:

因此,你可以看到,Google 现阶段对 Flutter 的定义其实非常清晰:A UI Toolkit,仅仅是一个 UIToolkit 而已。很多小伙伴看到这里会觉得很遗憾,但是我认为这是一种妥协,而且拿捏得恰到好处。为什么说是一种妥协?就拿 ElectronJS 或 React Native 来说,根据不同平台调用不同 API 有意义吗?毫无意义……还不如直接让我调用平台的原生 API;其次,想直接沁入原生系统?Android 亲兄弟还好说,其它平台的哪有这么容易?比起让库克或纳德拉开放操作系统的源代码给劈柴哥做适配或者劈柴哥让库克或纳德拉帮忙适配 Flutter,我更愿意相信母猪会上树。因此,现阶段只让 Flutter 作为一个 UIToolkit 而将其它的平台相关逻辑交还给各自平台实现,非常完美。而且目前我们可以看到的是,Google 在推进 Flutter 与自家 Android 或 Chrome 的深度融合依然在作不断的努力,相信在不远的将来我们可以看到 Flutter 实现更好的解决方案。

与一些其它跨平台方案不同的是,Flutter 近乎原生的渲染更加优秀,以闲鱼中实现的界面为例,几乎与原生没有差别:

综上所述,Flutter 更是一种折衷的方案,如我所说,即可以让你在一定程度上实现跨平台的代码又恰到好处。

How to use Flutter.

怎么使用 Flutter?这里爱哥并不会搞什么手把手教你开发 Flutter,毕竟 Flutter 的中英文官方文档都很完善,而且网上大把铺天盖地的从入门到放弃教程。这里我阐述的“怎么使用”更倾向于在何种情况下使用。为了说明这个问题,我们需要知道 Flutter 真正的体验效果怎么样,这种体验不仅包括开发人员关心的性能以及上手难度,还包括面向用户的第一感觉。为此,我于 Flutter、Android 和 IOS 三个平台写了两个 Demo 共六份代码作为对比:

  1. Timer for Android
  2. Timer for Flutter
  3. Timer for IOS
  4. Player for Android
  5. Player for Flutter
  6. Player for IOS

Timer 是一个计时器,其修改于创建 Flutter 项目时的默认代码,该代码实现了一个点击按钮则增加一次计数的逻辑:

使用该默认项目代码的原因是想控制更少的变量因素,既然 Flutter 已经有了一个默认的,我们只需在 Android 和 IOS 平台依葫芦画瓢做个一模一样的 Demo 即可,这样我们可以最大限度地保证 Flutter 性能不会因我瞎几把改代码而造成下降。不过为了简便操作,爱哥将其修改为点击一次按钮则按每 100 毫秒的间隔累加数字直到数字为 100,该过程理论需要耗费 100ms x 100 = 10000ms = 10s 的时间:

这里仅仅只是将默认项目代码中 _MyHomePageState 类的 _incrementCounter 方法实现逻辑修改为由 Stream 周期性地 setState:

void _incrementCounter() {//    setState(() {
//      _counter++;
//    });
  Stream<int>.periodic(Duration(milliseconds: 100), (x) => x + 1)
      .take(100)
      .forEach((int count) async {setState(() {_counter = count;});
  });
}

需要注意的是,为了屏蔽平台 API 逻辑带来的鸡毛般的影响,这里周期性的实现并没有使用各自平台下的 Timer API,而是在 Flutter 中使用更底一级的 Stream,在 Android 和 IOS 中则使用各自平台下的 Thread:

var tick = 0
Thread(Runnable {while (tick < 100) {
        tick++
        this@MainActivity.runOnUiThread {count.text = tick.toString() }
        Thread.sleep(100)
    }
}).start()
@objc func createThread() {Thread(target: self, selector: #selector(doCount), object: nil).start()}

@objc func doCount(){
    for i in 1 ... 100 {
        DispatchQueue.main.async {self.count.text = i.description}
        usleep(100000)
    }
}

最后,因为 Dart 区分不同的 mode,在 debug mode 下,像 assert 之类的代码会对执行效率产生一定的影响,因此对于 Android 平台的项目爱哥均会生成 Release 包进行测试,而 IOS 嘛……因为穷,付不起 99 刀也不想折腾 XX 侠之类的三方平台,所以 IOS 平台全程就使用 debug 模式作为 Andriod 平台的一个参考了。

另外一个 Player 则是一个播放器,因为 Timer 主要是测试 Flutter 的默认项目与 Android 和 IOS 平台的对比,为了进一步放大下性能测试,引入了这个播放器 Demo,用它来播放一个 4K 视频,看看三个平台下的性能差异。同样地为了降低各自平台的影响,三个平台上我都使用了其自带的播放框架,Flutter 上用的自带的 VideoPlayer、IOS 上用的 AVPlayer、Android 上则使用的 VideoView。

设备这块,鉴于莫得钱,翻来覆去找了两个差不多的老机子:一部 iPhone 6s Plus、一部 Google Pixel。前者陪我征战两三载并依旧服役中,后者准新机才入手不久。

OK!万事具备,现在我们开始生成各种包,对于 IOS 平台我们直接运行安装到手机;而对于 Android 平台我们则将其发布成正式包,Flutter for Android 的正式包发布我们参考官方文档 Build and release for Android。

需要注意的是,Flutter 首次 Build Android 平台的 Release 包会非常耗时,与原生的秒 Build 还是差距很大,这主要还是因为 Flutter 毕竟属于三方依赖构建需要更多时间:

首先我们来看 Timer:

从左到右依次为 Android 原生、Flutter for Android、IOS 原生以及 Flutter for IOS(如无特殊说明,下面的图例均按照此顺序排列)。虽然 Flutter 为我们提供了一个 Flutter Performance 来监控性能:

但是该工具无法作用于 Android 和 IOS 原生平台,缺乏对比性。因为 Flutter 最终编译运行后本质依然是 Android 或 IOS 平台应用,所以这里爱哥直接使用 Android 官方提供的 Profiler 以及 XCode 的 Instruments 相关工具进行性能测量,主要是简单记录下各平台下应用运行的 CPU 和内存消耗。

首先粉墨登场的是 IOS 平台,包括一个原生 IOS Timer 和一个 Flutter for IOS Timer,为了屏蔽刚启动应用带来的性能影响,在应用启动后等待一段时间 CPU 和内存等趋于稳定时再开始测试:

首先我们看原生与 Flutter 在 iPhone 上的渲染表现,上图是直接从录屏中截取排列生成,没有经过任何后期校色处理,默认情况下,Flutter 的默认背景色略微偏洋红,爱哥猜测是因为默认的 Flutter 项目使用的是一个 Material 的蓝色主色调:

primarySwatch: Colors.blue

更准确地说是天蓝色(看标题栏和 FAB 的颜色),即色环中靠近绿色的那部分蓝色,可能是 Material 的设计师们觉得给背景一个带补色的白色系可以增强视觉冲击力(因为爱哥也喜欢这样设计我的应用 UI 所以这么猜),所以默认的 Flutter 项目的背景色就带了淡淡的洋红,可能开发者觉得这有点脱裤子放屁,但是其实在设计师眼里,任何细微的颜色值变动都可以影响用户的主观体验,事实也如此。不过如果你觉得没必要,可以自行将背景修改为白色:

backgroundColor: Colors.white

不过这里让我觉得费解的是,即便如此,Flutter 渲染出来的“白色”依然比 IOS 原生的更灰一点:

可能是因为色彩空间不同,也有可能是 Flutter 的渲染引擎在搭载亲儿子 CA/CG 渲染框架的 iPhone 上水土不服吧……除此之外,即便是闲置状态下,Flutter 也需要比原生 IOS 多得多的内存消耗,并且默认会在 iPhone 上启动 13 个线程。点击 FAB 按钮开始启动计数:

在这 10s 左右的时间里,Flutter 的 CPU 使用一度逼近 40%,而 IOS 原生只接近 3%,内存方面 IOS 原生几乎没有变化,而 Flutter 则多耗费了将近 20M 的内存。但是值得庆幸的是,得益于 Dart 优秀的异步框架设计,Stream 并不会在此过程生成新的线程。不过终归到底,因为 Flutter 生成的 IOS 并非正式包,会对此有性能上的影响,所以 IOS 平台上我们仅作参考,重点还是结合 Android 平台的表现进行对比:

令人高兴的是,Android 平台上并没有像 IOS 平台上那样的色差,两者渲染表现一致,大概是都是使用 Skia 的原因吧:

性能方面,Android 原生的 CPU 使用率波动较大,最高的时候达到 15%,而 Flutter 则相对优秀,峰值不过 10%;而对于内存使用方面,Flutter 则比原生消耗多出一倍左右,上面我们也说到 Flutter 毕竟是作为三方依赖组件,因此在很长一段时间内,内存耗费的问题可能都不会有太好的解决方案。

Timer 这个 Demo 的测试就到此为止,我们可以看到 Flutter 于 Android 平台的表现几乎与原生没有差别,而在 IOS 下,虽然没的钱开苹果大会员,但是在 debug 模式下至少给我的体验是与原生 IOS 没差别的,但是性能上嘛 emmmmmm……等我充值了苹果大会员再补测一波 Release 的。但是不管是 IOS 还是 Android 平台,Flutter 生成的应用内存占用都比原生高出很多,如我所说这个可能在很长一段时间里都会存在,因此你需要在引入 Flutter 到正式项目时做一点取舍。在 Flutter 平台上爱哥并没有使用 Dart 下真正意义上的线程 Isolate 来实现计数功能,原因在于 Dart 的异步框架更推崇 Future 和 Stream 而非直接 Create Isolate。最后,我们再来看看 4K 播放的表现。

这类爱哥扒了一个 LG 用于宣传 4K 显示所用的短片,该短片总大小 250mb 左右,时长不到 2min,我们截取其中的 50s 来对比看看 4K 播放下的性能。

首先是 Android 平台:

得益于 EXOPlayer,Flutter 在播放时体验相当优秀,Android 原生因为直接使用的 VideoView,在刚开始播放时有些许卡顿,后续表现还凑合。性能方面,Flutter 依然是老问题,不管是 CPU 还是内存都非常高,Android 原生因为 VideoView 中 MediaPlayer 依赖于对底层 PlayerDriver 的调用,CPU 和内存使用的显示可能有所偏差,本文不再展开讨论。无论如何,就论给用户的体验来讲,Flutter 应用的流畅度已经与原生无异甚至由于原生应用。

最后,我们来体验下 IOS 平台:

总结

总得来说,经过两年时间的打磨,Flutter 这个 Google 推出的“UIToolkit”已经在其本职功能上做得相当完善。但是还有以下几点问题:

首先,能够应用到生产环境的面还不够宽阔。以 Flutter 目前的表现,其完全胜任于大部分 UI 层面的场景,但是对于需要于平台相关的特性进行频繁交互时,不适合使用 Flutter 来实现,尽管 Flutter 提供了 Channel 来与原生代码进行交互,但是目前来看并不方便,因此如果你打算将 Flutter 作用于你的生产环境,爱哥更推崇闲鱼那样的混合开发方案,即于重要的或与平台特性交互较多的界面保持使用原生开发,而次要的纯 UI 渲染的界面使用 Flutter 开发。除此之外,游戏类特别是需要 3D 渲染的游戏暂不支持且也没必要使用到 Flutter。

其次,上面我们也看到了,Flutter 之于原生,主要的性能差异还是在内存消耗上,Flutter 相比而言会比原生暂用更多甚至非常多的内存,因此如果你的应用对这一块比较敏感,比如针对低端 One 之类的设备开发的应用,暂时也不太适合使用到 Flutter。而且,作为一个三方组件,Flutter 对包大小的增加也是不可避免的:

以上是 Android 由原生和 Flutter 生成的包大小对比,即便去掉不同 CPU 架构的 so,包的大小也势必比原生大一些,当然在这个手机 ROM 动不动就几十上百 G 的今天,这点增幅并不算什么。

最后,则是生态问题,这包含两部分。其一是技术生态,Flutter 并没有像原生平台那样众多成熟的框架组件,开发起来会有一定的效率阻碍。其二则是产品生态,目前使用 Flutter 开发的成熟产品还很少,这导致开发者赖以生存的一些广告收益 SDK 也几乎没有针对 Flutter 提供直接支持。

退出移动版