浅谈 Flutter 编译原理

1,716 阅读11分钟

公众号名片 作者名片

前言

熟练使用 Flutter 开发 app 的人员,对各种 widget 的使用肯定已经信手拈来了,但往往对 Flutter 是如何编译、相应产物是什么却知之甚少。

本文就来了解一下 Flutter 编译的相关知识

一、Flutter 架构层

Flutter 架构主要分为三层:

1. Framework 层

基于 Dart 实现,主要包含

  • Material Design(Google),Cupertino (iOS)两种风格的 Widgets。
  • 文本/图片/按钮 等 Widgets。
  • 渲染/动画/绘制/手势 等 Widgets。
  • 核心基础类/方法,主要指 Flutter 仓库下的 Flutter package,以及 sky_engine 仓库下的 io、async、ui(dart:ui 库提供了 Flutter 框架和引擎之间的接口) 等 package。

2. Engine 层

基于 C++ 实现,主要包含

  • Skia 开源的二维图形库,提供了适用于多种软硬件平台的通用 API。
  • Dart 主要包含 Dart Runtime,Garbage Collection(GC),如果是 Debug 模式的话,还包括 JIT ( Just In Time )支持。Release 和 Profile 模式下,是 AOT(Ahead Of Time)编译成了原生的 arm 代码,并不存在 JIT 部分。
  • Text 文本渲染,其渲染层次如下:衍生自 minikin 的 libtxt 库(用于字体选择,分隔行);HartBuzz 用于字形选择和成型;Skia 作为渲染 / GPU 后端,在 Android 和 Fuchsia 上使用 FreeType 渲染,在 iOS 上使用 CoreGraphics 来渲染字体。

3. Embedder 层

Embedder 是一个嵌入层,即把 Flutter 嵌入到各个平台上去,这里做的主要工作包括渲染Surface 设置,线程设置,以及插件等。从这里可以看出,Flutter 的平台相关层很低,平台(如 iOS)只是提供一个画布,剩余的所有渲染相关的逻辑都在 Flutter 内部,这就使得它具有了很好的跨端一致性。

基于上面的架构了解,要想搞懂 Flutter 到底是怎么编译的,我们首先就要从 Engine 层下手。 我们知道编程语言要达到可运行的目的需要进行编译,如 Android 开发中的 .java -> .class -> .dex ,我们通过编写 .java 文件,经过层层编译解释,最终变为可被机器识别的机器语言。那我们在日常 Flutter 开发中使用 Dart 语言进行开发的 .dart 文件一路上都经历了什么风霜雨雪,最终被机器所认可呢。

二、Flutter 编译模式

上文中提到一个概念 “编译模式” ,简单来讲就是:

  1. 把一段源程序翻译成机器码
  2. 让机器开始干活,运行这段机器码

不同的编译模式,主要区别在于 when、where、how 进行上面两步操作,这往往就导致了同一段代码 “生效” 的速度不同。那么根据需求、硬件环境选择合适的编译模式就能提高我们程序的效率,但是什么样的编译模式才是合适的呢,我们先来看看常见的编译模式都有哪些。

一般来说,编译模式分为 JITAOT 两种

1. JIT

JIT 全称 Just In Time(即时编译),意思就是可以在运行时将源代码进行编译和执行。比较典型的例子就是 v8 引擎,它可以即时编译并运行 JavaScript。一般来讲,支持 JIT 的语言都可以支持自省函数。

优势:使用 JIT 模式进行编译,可以动态下发和执行代码,不需要考虑用户所使用的机器架构,从而达到动态为用户提供丰富内容的目的。

劣势:运行时编译必然会消耗时间和内存,给用户带来的直接感受就是应用启动慢。

2. AOT

AOT 全称 Ahead Of Time(提前编译),意思就是提前将源代码编译成机器码,用户的机器在运行时直接执行对应的机器码。比较典型的例子就是 C/C++ ,LLVM 或 GCC 通过编译并生成 C/C++ 的二进制代码,然后这些二进制代码通过用户安装并取得执行权限后,才可以通过进程进行加载执行。

优势:提前编译好的二进制代码,加载和执行的速度都会非常快。所以编程语言运行速度排行榜靠前的都是 C、C++、Rust 这些编译类语言,这样的速度可以在密集计算场景下给用户带来非常好的体验,比如大型游戏的引擎渲染和逻辑执行。

劣势:编译需要区分用户机器的架构,生成不同架构的二进制代码,除了架构,二进制代码本身也会让用户下载的安装包比较大。二进制代码一般需要取得执行权限才可以执行,所以无法在权限比较严格的系统中进行动态更新,如 iOS。

虽然我们会通过 JIT 和 AOT 两种编译模式来区分语言的类型,但实际上,很多语言并不是只使用 JIT 或者 AOT 其中一种的,通常它们会混用这两种模式,来达到最大的性能优化(Java 就是这种,所以 Java 有时也会被称为半编译半解释型语言)。

因为 Flutter 使用 Dart 作为编程语言,所以如果要理解 Flutter 的编译模式,我们先来看 Dart 的编译模式。

3. Dart 编译模式

1. Dart VM

类似 JVM 一样,Dart 也有对应 Dart VM,我们可以理解为 Dart 虚拟机。它为 Dart 提供了执行环境,除了采用解释执行或者 JIT 编译,还可以使用 Dart 虚拟机的 AOT 管道将 Dart 代码编译为机器代码,然后运行在 Dart VM 的精简版,称之为预编译运行时(precompiled runtime)环境,该环境不包含任何编译器组件,且无法动态加载 Dart 源代码。

2. Dart 编译模式

Dart VM 有多种方式来执行代码:

  • **Script :**最普通的 JIT 模式,可以直接在虚拟机中执行 Dart 源码,像解释型语言一样使用,在命令行直接调用 dart xxx.dart 执行 dart 源代码即这种模式。
  • **Kernel Snapshots :**之前也叫做Script Snapshots,采用 JIT 模式,和 Script 模式不同的是,这种模式执行的是 Kernel AST 的二进制数据,这里不包含解析后的类和函数,编译后的代码,所以它们可以在不同的平台之间移植。Dart Kernel 是 Dart 程序的中间语言,是一种紧凑的二进制目标文件格式,支持单独编译和链接,是程序转换的基础设施。通过执行 dart --snapshot-kind=kernel --snapshot=xx.snapshot xx.dart 生成。
  • JIT **Application Snapshots :**JIT 模式,这种模式执行的是解析后的类和数据,所以需要区分架构,但是相应的这种数据运行起来会更快。通过执行 dart --snapshot-kind=app-jit --snapshot=xx.snapshot xx.dart 生成。
  • **AOT Application Snapshots :**AOT 模式,在这种模式下,Dart 源码会被提前编译成特定平台的二进制文件。

总结一下 Dart 的编译模式

模式编译模式是否区分架构打包大小动态化启动时间
ScriptJIT较小最慢
Kernel SnapshotsJIT最小较慢
JIT Application SnapshotsJIT较大较快
AOT Application SnapshotsAOT最大最快

3. Flutter 编译模式

Flutter 完全采用了 Dart,按道理来说编译模式应该一致,但是事实并非如此。原因是 Flutter 开发中要考虑到 Android 和 iOS 平台的生态差异,所以在 Dart 编译模式的基础上进行了一些改动:

  • **Script :**同 Dart Script 模式一致,虽然 Flutter 支持,但暂时没有使用,因为影响启动速度。
  • **Kernel Snapshot **:Dart 的 bytecode 模式,bytecode 模式是不区分架构的。Kernel Snapshot 在 Flutter 项目内也叫 Core Snapshot。bytecode 模式可以归类为 AOT 编译。
  • **Core JIT **:Dart 的一种二进制模式,将指令代码和 heap 数据打包成文件,然后在 vm 和 isolate 启动时载入,直接标记内存可执行,可以说这是一种 AOT 模式。Core JIT 也被叫做 AOTBlob。
  • **AOT Assembly :**Dart 的 AOT 模式。直接生成汇编源代码文件,由各平台自行汇编。

可以看到,Flutter 将 Dart 的编译模式复杂化了,多了不少概念,为了理解这些概念,我们从 Flutter 应用开发的各个阶段来解读。

4. 不同阶段下的 Flutter 编译模式

Flutter 支持三种模式编译 app,分别是

**Debug 模式:**对应了 Dart 中的 JIT 模式,在此模式下,支持真机和模拟器,断点开启,服务扩展开启,针对快速开发和运行周期进行了编译优化(执行速度、包体积大小、部署并未优化),调试工具可以连接到进程里,热重载功能。

**Release 模式:**对应了 Dart 中的 AOT 模式,在此模式下只支持真机,关闭了所有断点和调试信息,禁止调试,优化了启动速度、执行速度及包体积,无法使用热重载功能。

**Profile 模式:**与 Release 模式基本相似,但是保留了一部分调试能力,用来分析 app 的性能,但是只能在真机上使用,因为模拟器的性能分析不具有真实性。一些服务扩展是启用的。例如,支持 performance overlay。

三、Flutter编译过程

1. Flutter run

对 Flutter 的编译模式有个初步的了解后,我们来看一下 Flutter 的编译过程。以 Android 为例,当我们点击 Android Studio 中的运行按钮,默认情况下执行了 Flutter run 命令,当 Flutter run 命令后面不带任何参数默认采用的是 debug 模式,我们可以通过给 Flutter run 命令添加参数来改变对应的编译模式,如:Flutter run -release 来运行 release 模式。

Flutter run 过程涉及多个 Flutter 相关命令,其包含关系如下所示:

Flutter 命令的整个过程位于目录 Flutter/packages/flutter_tools/,整个 Flutter run 的过程主要包含以下几个核心功能:

  1. Flutter build apk:通过 gradle 来构建 APK
    • Flutter build aot:构建 AOT 编译产物
      • frontend_server:前端编译器生成 kernel 文件
      • gen_snapshot:将 dart 代码编译成 AOT 产物
    • Flutter build bundle:将相关文件放入 flutter_assets 目录
  2. 通过 adb install 安装 APK
  3. 通过 adb am start 启动应用

整个流程详细执行,如图所示:

其中,我们较为关心的就是这两个步骤及相关编译产物:

  • frontend_server:前端编译器生成 kernel 文件
  • gen_snapshot :将 dart 代码编译成 AOT 产物

2. frontend_server命令

KernelCompiler.compile() 过程等价于如下命令:

可见,通过 dart 虚拟机启动 frontend_server.dart.snapshot,将 dart 代码转换成 app.dill 形式的 kernel 文件。

frontend_server 执行流程图

frontend_server 前端编译器将 dart 代码转换为 AST,并生成 app.dill 文件,其中 bytecode 生成过程默认是关闭的。

3. gen_snapshot 命令

GenSnapshot.run 具体命令根据前面的封装,针对 Android 和 iOS 平台各有不同:

针对 Android:

针对 iOS:

上述命令主要是将 dart kernel 转换为机器码,对应流程图为

此处 gen_snapshot 是一个二进制可执行文件,主要作用是将 dart 代码生成 AOT 二进制机器码。

四、Flutter编译产物

大致了解了 Flutter 编译的过程后,我们就可以知道 Flutter 在不同平台、不同模式下的编译产物是什么:

1. iOS - release 模式:

跟原生 APP 结构差别不大,Frameworks ⽂件夹主要多了 App.framework 和 Flutter.framework。App.framework ⾥ flutter_assets ⽂件夹存放了 Flutter ⾥引⽤到的资源⽂件。

2. Android - release 模式:

跟原生 APP 结构差别不大,不同的是 assets ⾥多个 flutter_assets ⾥⾯存放的是 Flutter 引⽤的资源⽂件,lib ⾥多了 libapp.so 和 libflutter.so。

3. iOS - debug 模式:

App.framework ⽂件夹⾥多了 isolate_snapshot_data,kernel_blob.bin,vm_snapshot_data 同时 App 这个⼆进制⽂件只有33 KB,但是 release 模式下有 8.5MB。

4. Android - debug 模式:

flutter_assets 下多了 isolate_snapshot_data,kernel_blob.bin,vm_snapshot_data。 lib下少了 libapp.so。

产物总结

  • Flutter APP 最终会包含两个库,⼀个是 dart 代码编译⽽来的 app 库,⼀个是引擎代码编译来的 Flutter 库
  • 为了实现 hot reload,debug 模式下,dart 代码会⽣成⾄少3个⽂件,这三个⽂件就是每次编译动态⽣成,运⾏的时候可以替换以便实现 hot reload。⽽ release 模式下这3个⽂件会并⼊ app 库。Flutter 库在 debug 和 release 模式下⼤⼩也是不⼀样的
  • isolate_snapshot_data:⽤于加速 isolate 启动,业务⽆关代码
  • kernel_blob.bin:业务代码产物
  • vm_snapshot_data:⽤于加速 Dart VM 启动的产物,业务⽆关代码
  • 原⽣的资源⽂件和 Flutter 的资源⽂件是隔离的

五、what can we do?

那么,在了解 Flutter 的相关编译原理后,我们可以做哪些事情呢?

  1. 结合平台特性对 Flutter 相关产物内容进行体积优化,从而减小包体积;
  2. 从 Flutter 方向进行模块拆分、编译速度优化、减少打包时间;
  3. 探索 Flutter 动态化及 Flutter for Web 等更多可能性

当然,这些进一步的探索需要我们更多的学习与实践。

更多精彩请关注我们的公众号「百瓶技术」,有不定期福利呦!