Flutter单元测试报错“Error: Not found: 'dart:ui'”

开发环境:macOS Big Sur (11.6.2)/Flutter 2.8.1/Android Studio Atrctic Fox (2020.3.1 Patch 4)

参照 将Flutter module集成到Android项目(Android Studio Arctic Fox 2020.3.1/Flutter 2.8.1) 建立项目,在执行 Flutter 单元测试代码的时候报错

继续阅读Flutter单元测试报错“Error: Not found: 'dart:ui'”

Flutter 2.5 更新详解

Flutter 2.5 正式版已于上周正式发布!这是一次重要的版本更新,也是 Flutter 发布历史上各项统计数据排名第二的版本。我们关闭了 4600 个 Issue,合并了 3932 个 PR,它们分别来自 252 个贡献者和 216 个审核者。回顾去年 -- 我们收到来自 1337 个贡献者提交的 21072 个 PR,其中有 15172 个被合并。在详述本次更新的内容之前,我们想强调,Flutter 的首要工作始终是高质量交付开发者们所需要功能。

Flutter 2.5 带来了一些重要的性能和工具改进,以帮助开发者们追踪应用中的性能问题。同时,加入了一些新的功能,包括对 Android 的全屏支持、 对 Material You (也称 v3) 的更多支持、对文本编辑的更新以支持切换键盘快捷键、在 Widget Inspector 中查看 widget 详情、在 Visual Studio Code 项目中添加依赖关系的新支持、从 IntelliJ / Android Studio 的测试运行中获得测试覆盖率信息的新支持,以及一个更贴近 Flutter 应用在真实的使用场景下的应用模板等。这个版本充满了令人兴奋的新更新,让我们开始介绍吧!

该版本进行了一些性能上的改进:首先是一项用于从离线训练运行中连接 Metal 着色器预编译的 PR (#25644),这将最坏情况下的光栅化时间减少了 2/3 (如我们的基准测试所示),将第 99 百分位的帧时间减少了一半。我们在减少 iOS 卡顿方面取得了持续性的进展,这也是在这条道路上迈出的另一步。然而,着色器预热只是卡顿的一个来源。在该版本以前,处理来自网络、文件系统、插件或其他 isolate 的异步事件可能导致动画中断,这是另一个卡顿的来源。在该版本中我们对 UI Isolate 的事件循环的调度策略 (#25789) 进行了改进,现在帧处理优先于其他异步事件的处理,在我们的测试中,其导致的卡顿已经被消除。

继续阅读Flutter 2.5 更新详解

Dart中函数调用是值传递还是引用传递?

最近看到有人面试的时候被问到了 “Dart中函数调用是值传递还是引用传递?”

初看感觉跟Java应该是相同的,结果搜索到的链接里面言之凿凿的说是传值操作,顿时感觉有些迷茫。

针对大对象,如果进行拷贝传值,会诱发大量的内存复制操作,这种情况是不可想象的。而基本类型,比如整数,字符,传递地址又是得不偿失的性能损失,明明通过寄存器就可以直接传递参数,却非要从内存中读取一遍,是完全没有必要的。

Dart中,基本数据类型传值,类传引用

类对象传值

输出结果为:

基本类型传值

上面的代码,其实转换成 Java 也是一样的结果。

但是深入思考,在 Flutter 中,上面的结论并不全面,对于异构模型而言,取决于内存架构。

如果是非一致内存架构,则CPU跟GPU之间只能进行拷贝操作。

即使是一致内存架构,也分为物理一致还是软件一致,如果仅仅是软件一致,比如PCIE独立显卡,依旧是只能进行拷贝操作,当然拷贝是显卡驱动完成的。只有物理,驱动都完全支持的情况下,才会出现物理意义上的引用传值的情况。有时候,即是两者都支持,GPU对虚拟内存的支持也是一个决定因素。

还是蛮复杂的。

Dart VM是如何运行你的代码的

Dart VM有多种方式去运行Dart代码,比如:

  • JIT模式运行源码或者Kernal binary
  • 通过snapshot方式:AOT snapshot 和 AppJIT shanpshot

两者的主要区别在于VMDart源码转换成可执行代码的时机和方式。

VM中的任何Dart代码都是运行在隔离的isolate当中,isolate具有自己的内存(堆)和线程控制的隔离运行环境。VM可以同时具有多个isolate执行Dart的代码,但不同的isolate之间不能直接共享任何的状态,只能通过消息端口来进行通信。 我们所说的线程和isolate之间的关系其实有点模糊,而且isolate也比较依赖VM是怎样嵌入到应用程序当中的。线程和isolate有下面这样的一些限制:

  • 一个线程一次只能进入一个isolate,如果线程要进入另一个isolate就要先退出当前的
  • 一次只能有一个mutator线程和isolate相关联,mutator是用来执行Dart代码和调用VM API的线程

所以一个线程只能进入一个isolate执行Dart代码,退出之后才能进入另一个isolate。 不同的线程也能进入同一个isolate,但不能同时。

当然除了拥有一个mutator线程之外,isolate还可以有多个helper线程,比如:

  • 后台JIT编译线程
  • GC线程
  • 并发的GC标记线程

VM内部使用了线程池(dart::ThreadPool)来管理系统的线程,而且内部是基于 dart::ThreadPool::Task 的概念去构建的,而不是直接使用的系统线程。例如,GC的过程就是生成一个 dart::ConcurrentSweeperTask 丢给VM的线程池去处理,而不是使用一个专门的线程来做垃圾回收,线程池可以选择一个空闲的线程或者在没有空闲线程的时候新建一个线程来处理这个任务。类似的,消息循环的处理也并没有使用一个专门的event loop线程,而是在有新消息的时候产生一个 dart::MessageHandlerTask 给线程池。

执行源码

你可以在命令行下直接给Dart的源码去执行,例如:


事实上Dart 2 VM之后就不再支持直接运行Dart源码了,VM使用了一种Kernel binaries(也就是 dill 文件)包含了序列化的 Kernel ASTs。所以源代码要先经过通用前端  common front-end (CFE) 处理成Kernel AST,而CFE是用Dart写的,可以给VM/dart2js/Dart Dev Compiler这些不同的Dart工具使用。

前端编译

那么为了保持直接执行Dart源码的便捷性,所以有一个叫做kernel serviceisolate,负责将Dart源码处理成KernelVM再将Kernel binary拿去运行。

但是CFE和用户的Dart代码是可以在不同的设备上执行,例如在Flutter当中,就是将Dart代码编译成Kernel,和执行Kernel的过程个隔离开来,编译Dart源码的步骤放在了用户的开发机上,执行Kernel放在了移动设备上,Flutter tools负责从开发机上将Kernel binary发送到移动设备上。

flutter tool并不能自己解析Dart源码,它使用了一个叫frontend_server的处理,frontend_server实际上就是CFE的封装和Flutter上特定的Kernel-to-Kernel的转换。frontend_server编译Dart源码到Kernel文件,flutter tools将它同步到执行设备上。Flutterhot reload也正是依赖frontend_server的,frontend_serverhot reload的过程中能够重用之前编译中的CFE状态,只重编已经更改了的部分。

Kernel binary装载

只有Kernel binary能够被VM加载,并解析创建各种对象。不过这个过程是懒加载的,只有被使用到的库和类的信息才会被装载。每一个程序的实体都会保留指向对应Kernel binary的指针,在需要的时候可以去加载更多的信息。

类的信息只有在被使用的过程中(例如:查找类的成员,或新建对象)才会被完全反序列化出来,从Kernel binary读取类的成员信息,但是函数只会反序列化出函数签名信息,函数体只有在被调用运行的时候才会进一步反序列化出来。

Kernel binary加载了足够多的信息供运行时成功解析和调用方法之后,就会去解析和调用到main函数了。

函数编译

程序运行的最初所有的函数主体都不是实际可执行的代码,而是一个占位符,指向LazyCompileStub,它只是简单的要求运行时系统为当前的函数生成可执行的代码,然后尾部调用新生成的代码。

首次编译函数时,是通过未优化编译器来完成的。

未优化编译器通过两个步骤来生成机器码:

  1. 对函数主体的序列化AST进行遍历,以生成函数主体的控制流程图CFGCFG由填充了中间语言IL指令的基本块组成。这里使用的IL指令类似于基于堆栈的虚拟机的指令:从堆栈中获取操作数,执行操作,然后将结果压入同一堆栈。
  2. CFG使用一对多的低级IL指令直接生成机器码:每条IL指令扩展为多条机器指令

这个过程中还没有执行优化,未优化编译器的目标是快速的生成可执行指令。这也意味着不会尝试静态解析任何未从Kernel binary文件中加载的调用,所以调用的编译是动态完成的。VM在这个时候还不能使用任何基于vitual table或者interface table的调度,而是使用inline caching实现动态调用的。

inline caching的核心是在调用的时候缓存对应方法解析的结果,VM使用的inline caching机制包括:

  • 一个调用的特殊缓存,将接收的类映射到方法,如果接收者具有匹配的类型则调用方法,缓存还会有一些辅助信息,比如:调用频次计数器,跟踪特定类型出现的频次。
  • 一个共享的stub,实现方法调用的快速路径,stub在给定的缓存中查找是否有和接收者匹配的类型,如果找到了增加相应的频次计数器,并且尾部调用缓存的方法;否则,stub调用系统的查找解析逻辑,如果解析成功就更新缓存,并且后续的调用使用对应缓存的方法。

下图说明了inline cacheanimal.toFace()调用时的关系和状态,使用Dog实例调用两次,Cat实例调用一次:

未优化的编译器足以执行所有的Dart代码,只是它的执行速度会慢一些,所以呢VM还需要实现自适应的优化编译路径,自适应的优化是采用程序运行时的信息去驱动优化策略。未优化的代码在运行时会收集以下信息:

  • Inline caches过程中每一个方法调用接受的类型信息
  • 执行计数器收集的热点代码区

当某个函数的执行计数器达到某个阈值,这个函数就会提交给后台优化编译器进行优化。

优化编译

优化编译的方式和未优化编译有点类似,通过遍历序列化的Kernel AST为正在优化的函数构建未优化的IL,不同的是与其直接将IL转换为机器码,优化编译器会将未优化的IL转换成基于static single assignment (SSA)的优化IL。基于SSAIL根据收集到的类型信息,经典的优化手段和Dart的特殊优化:比如,inlining, range analysis, type propagation, representation selection, store-to-load and load-to-load forwarding, global value numbering, allocation sinking, etc。最后,使用线性扫描寄存器分配器和简单的一对多的IL指令,将优化的IL降低为机器码。

编译完成之后后端编译器请求mutator线程进入一个安全点(safepoint)并且将优化的代码attaches到对应的调用函数上,下次调用该函数的时候就能直接使用优化的代码。

需要注意的是,由优化编译器生成的代码是基于运行时收集到的特定信息完成的,例如一个接受动态类型的函数调用,只接收到某个特定的类型,就会被转换成直接的调用,然后检查接收到的类型是否一致。但是在程序的执行过程中,有可能接收到的类型是其他的。

反优化

优化代码是基于运行时信息对输入做了一些假设而产生的,如果在后续的运行过程中输入和假设不匹配,它就要防止违反这些假设,并且能够在违反的情况能够恢复正常运行。这个过程就叫着反优化:只要优化版本遇到无法处理的情况,它就会将执行转移到未优化函数的匹配点并继续运行。未优化的版本不做任何假设,可以处理所有可能的输入。

VM通常会在反优化后放弃优化的版本,然后在以后使用更新的类型反馈再次对其进行优化。VM防止违反优化假设一般有两种方式:

  • Inline checks (e.g. CheckSmi, CheckClass IL instructions)验证输入是否符合优化。例如,将动态调用转换为直接调用时,编译器会在直接调用之前添加这些检查。在此类检查中发生的反优化称为eager deoptimization,因为它很容易在 check 的时候被检测出来。
  • 全局保护程序,指令运行时在更改优化代码所依赖的内容时丢弃优化代码。例如,优化编译器可能发现某些类C从未扩展过,并在类型传播过程中使用了此信息。但是,随后的动态代码加载或类最终确定可能会引入C的子类-使得假设无效。这个时候,运行时需要查找并丢弃所有在C没有子类的假设下编译的优化代码。运行时可能会在执行堆栈上找到一些现在无效的优化代码,在这种情况下,受影响的frames将被标记,并且在执行返回时将对其进行反优化。这种反优化也称为延迟反优化:因为它会延迟到控制权返回到优化代码为止。

运行 Snapshots

VM有能力序列化isolate堆上的对象为二进制的snapshot文件,并且可以使用snapshot重新创建相同状态的isolate.

snapshot针对启动速度做了相应的优化,本质上是要创建的对象的列表和他们之间关系。相对于解析Dart源码并逐步创建VM内部的数据结构,VM可以将isolate所必须的数据结构全部打包在snapshot中。

但最初snapshot是不包括机器码的,在后来开发AOT编译的时候就加上去了,开发AOT编译和带机器码的snapshot是为了允许VM在一些无法JIT的平台上运行。带代码的snapshot几乎和普通的snapshot的工作方式是一样的,只是它带有一个代码块,这部分是不需要反序列化的,代码块可以直接map进堆内存。

运行 AppJIT snapshots

AppJIT snapshot可以减少大型Dart应用(比如:dartanalyzer 或者 dart2js)的JIT预热时间,在小型应用和VM使用JIT编译的时间差不多。

AppJIT snapshots其实是VM使用一些模拟的数据来训练程序,然后将生成的代码和VM内部的数据结构序列化而生成的,然后分发这个snapshot而不是源码或者Kernel binaryVM使用这个snapshot仍然可以在实际运行的过程中发现数据不匹配训练时而启用JIT

运行 AppAOT snapshots

AOT snapshot最初是为了无法进行JIT编译的平台而引入的,但也可以用来优化启动速度。无法进行JIT就意味着:

  1. AOT snapshot必须包含在应用程序执行期间可以调用的每个功能的可执行代码
  2. 可执行代码不能基于运行时的数据进行任何的假设

为了满足这些要求,AOT编译过程中会进行全局静态分析(type flow analysis or TFA),以从已知的入口点确定应用程序的哪些部分是被使用的,分配了哪些类以及类型是如何在程序中传递的。所有这些分析都是保守的,因为必须要保证正确性,有可能会牺牲一点性能,这跟JIT不太一样,JIT生成的代码还可以通过反优化来回到未优化的代码上运行。然后所有可达的代码块都将被编译成机器码,不会再进行任何的类型推测的优化。编译完所有的代码块之后,就可以获得堆的快照了。

然后,可以使用预编译的运行时来运行生成的snapshot,该运行时是Dart VM的特殊变体,其中不包括诸如JIT和动态代码加载工具之类的组件。

Switchable Calls

即使进行了全局和局部分析,AOT编译的代码仍可能包含无法静态虚拟化的调用操作。为了弥补这种情况,运行时使用了类似JIT过程中的inline cache,在这里叫着switchable callsJIT部分上面讲过了,inline cache主要包括两部分,一个缓存对象(通常是 dart::UntaggedICData )和一个VM的调用(例如:InlineCacheStub),在JIT模式下运行时只会更新 cache 的缓存,但是在AOT中,运行时可以根据inline cache的状态选择替换缓存和要调用的VM函数路径。

所有的动态调用最初都是unlinked状态,首次调用时会触发UnlinkedCallStub的调用,它又会调用DRT_UnlinkedCall去 link 当前的调用点。 如果DRT_UnlinkedCall尝试将调用点的状态切换为monomorphic,在这个状态下调用就会被替换成直接调用,它通过一个特殊的入口进入方法,并且在入口处验证类型。

在上图的例子中,当 obj.method() 首次执行时,obj 是 C 的实例,那么 obj.method 就会被解析成 C.method,下一次出现同样的调用就会直接调用到 C.method,跳过方法查找的过程。但是进入 C.method 仍然是通过一个特殊的入口进入的,验证 obj 是 C 的实例;如果不是的话,DRT_MonomorphicMiss 就会被调用尝试去进入下一个状态。C.method 有可能仍然是调用的目标函数,例如,obj 是类D的实例,D继承C并且没有overrideC.method。在这种情况下,我们检查是否可以进入single target状态,由 SingleTargetCallStub 实现(也可查看 dart::UntaggedSingleTargetCache)。

AOT编译过程中,大部分类会在继承结构的深度优先遍历过程分配一个 ID,如果类C具有D0..Dn这些子类,而且都没有override C.method,那么C.:cid <= classId(obj) <= max(D0.:cid, ..., Dn.:cid)表示 obj.method 会被解析成 C.method。在这种情况下,与其进行单态类(monomorphic状态)的比较,我们可以使用类的 ID 范围去检查C的所有子类。

换言之,调用的时候会使用线行扫描inline cache, 类似JIT模式(查看ICCallThroughCodeStubdart::UntaggedICData 以及 dart::PatchableCallHandler::DoMegamorphicMiss)

当然,如果线性数组中的检查数量超过阈值,将切换为使用类似字典的数据结构。(查看 MegamorphicCallStubdart::UntaggedMegamorphicCache 以及 dart::PatchableCallHandler::DoMegamorphicMiss)

参考链接


macOS Big Sur(11.6.2/11.7.1) 编译Flutter engine

准备同步代码:

生成配置代码同步配置文件:

内容如下:

注意,上面的 name 字段不能变更,否则会在同步代码的时候报错

执行代码同步命令(国内需要配置代理):

从源代码开始构建:

一般情况下我们使用的是官方版本的引擎,如果需要调试 libflutter.so 里面的符号,可以在flutter_infra页面直接下载带有符号表的SO文件,根据打包时使用的Flutter工具版本下载对应的文件即可。

比如2.8.1版本:

拿到引擎版本号后在https://console.cloud.google.com/storage/browser/flutter_infra_release/flutter/890a5fca2e34db413be624fc83aeea8e61d42ce6/ 看到该版本对应的所有构建产物,下载android-arm-release、android-arm64-release和android-x86目录下的symbols.zip。

参考链接


Flutter的编译模式

原文

使用过 Flutter 构建 APP 的人可能有个疑惑,Flutter 的编译产物有时候是一个共享库,有时候是几个文件,比较难于理解,本文主要介绍 Flutter 中的编译模式。

编译模式分类

源代码需要编译才能运行,一般来讲编译模式分为 JIT 和 AOT 两大类

JIT

全称为 Just In Time(即时编译),比较典型的就是 V8 JS引擎,它能够即时的编译和运行 JavaScript 代码。你只要输入 JavaScript 的字符串源码,V8 就能编译和运行这段代码。通常来说,支持 JIT 的语言一般能够支持自省函数(eval),在运行时动态的执行源码。

所以 JIT 模式有个显然的优点,你可以直接将代码分发给用户,而不用考虑用的机器架构。有这个,就可以为用户提供丰富和动态的内容。

但缺点也是比较明显的,大量的字符串源码,将花费 JIT 编译器大量的时间和内存来编译和执行,会让用户感觉应用启动缓慢。

AOT

全称为 Ahead Of Time(事前编译), 典型的例子就是像 C/C++ 需要被编译成特殊的二进制,才可以通过进程加载和运行。

AOT 的优势就是速度快,通过事先编译好的二进制代码,加载和执行的速度都会非常快,在密集计算或者图形渲染的场景下能够获得比较好的用户体验。

但 AOT 也有一些缺点,编译源代码的时候,需要注意用户的设备架构。对于不同的构架需要生成不同的二进制代码,也就会增加应用需要下载的程序包大小。而且二进制代码需要获得执行权限,所以无法在权限比较严格的系统(比如iOS)中动态更新。

Dart 的编译模式

Flutter 使用 Dart 作为开发语言,自然和 Dart 的编译模式脱离不了关系,所以我们先来看一下 Dart 的编译模式。

Script: 最常见的 JIT 模式,就像 Node.js 那样,可以在命令行直接执行 Dart 源代码

Script Snapshot: 不同于 Script 模式,Script Snapshot 模式载入的是已经 token 化的 dart 源码,提前做了编译期间的 Lexer 步骤, 也是属于 JIT 模式

Application Snapshot: JIT 模式,这种模式来源于 dart vm直接载入源码后 dump 出数据。dart vm 通过这种数据启动会更快。不过这种模式是区分架构的,在 X64 下生成的无法给 IA_32 用

AOT: AOT 模式,dart 源代码会被编译成汇编文件,汇编再经过汇编器生成不同架构下的二进制代码

用表格总结一下:

Dart’s compilation patterns
Dart’s compilation patterns

Flutter 的编译模式

Flutter 程序完全用 Dart, 理论上 Flutter 的编译模式应该和 Dart 的一致,事实上因为 Android 和 iOS 生态的差异,Flutter 衍生了不同的编译模式。

Script: 和 Dart 的 Script 模式一样,但是没有开启使用

Script Snapshot: 也是和 Dart 的 Script Snapshot 模式一样,也没有开启使用

Kernel Snapshot: Dart 的 bytecode 模式,在某种程度上类似 JVM。在 Flutter 项目中也被叫做 Core Snapshot,它是和设备架构无关的

Core JIT: 一种编译后的二进制格式,程序的数据和指令被打包成特殊的二进制,在运行时加载。事实上Core JIT 也被叫做 AOT Blobs, 是 AOT 的一种

AOT Assembly: 和 Dart 的 AOT 模式一样

所以在 Flutter 里会更复杂一点,我们结合 Flutter 的各个开发阶段来解读一下。

开发阶段

开发 Flutter APP 的时候,我们需要 HOT Reload 方便 UI 快速成型,同时也需要比较高的性能来进行视图渲染,所以 Flutter 在 Debug 下使用了 Kernel Snapshot 编译模式,会生成如下产物:

isolate_snapshot_data: 加速 isolate 启动的数据,和业务无关

vm_snapshot_data加速 Dart VM 启动的数据,和业务无关

kernel_blob.bin业务代码产物

compilation mode in debug stage
compilation mode in debug stage

发布阶段

在生产阶段,应用需要非常快的速度。所以 Flutter 使用了 AOT 编译模式,但是在不同的平台下,还是有些不一样的

compilation patterns in release stage
compilation patterns in release stage

在 iOS 平台由于 App Store 审核条例不允许动态下发可执行二进制代码,所以在 iOS 上,除了 JavaScript,其他语言都使用 AOT 编译。

但是在 Android 平台上,Core JIT 和 AOT Assembly 都支持了,在 Core JIT 模式下会生成四个产物:isolate_snapshot_data/vm_snapshot_data/isolate_snapshot_instr/vm_snapshot_instr

vm_snapshot_instr 和 isolate_snapshot_instr 是 Dart VM 和 isolate 的指令,在载入后,直接将该块内存执行即可。

isolate_snapshot_data 和 vm_snapshot_data 是 Dart VM 和 isolate 的启动数据,主要是为了加速启动速度。

Flutter Engine 支持情况

Flutter Engine 包含了 Dart 的运行时,Flutter 应用的编译产物必须和 Engine 匹配才行,Engine 在不同的阶段提供了不同的支持

通过 gen_snapshot -h 也可以查看到,Engine 对编译模式的支持,事实上 Dart 源码就是经过 gen_snapshot 去处理的。

AOT snapshots 也可以使用 obfuscated 选项开启混淆, --save-obfuscation-map=<filename> 保存符号 mapping 文件。

参考链接


macOS Big Sur(11.6.2) Android Studio (version 2020.3) 执行 flutter doctor 报错

在执行 flutter doctor --android-licenses 的时候报错:

原因为 Java 版本太高,Flutter 1.22.5 只能使用 Java 1.8版本:

对于错误

原因:

Android Studio(4.1)后,安装插件的位置发生了变化,但是flutter doctor还是去原先的位置找,导致的安装过插件还报错。

解决方法:

更简单的方式是执行

升级到 flutter 2.8.1 以及之后的版本即可。

参考链接


贝壳跨端之路——从 0 到 1 接入 Flutter 实战

一门新技术的兴起,必定是为了要解决现有技术解决不了的难题。跨端技术也遵循这一规律。传统的纯原生开发已经满足不了日益增长的业务需求,而跨端技术的产生就是为了要解决这一难题,具体表现在动态化内容需求增大和业务需求变化快、开发成本变大这两大方面。

跨端技术的变革

跨端最开始起源于浏览器。在 App 内使用 Native 的 Webview 来加载 Html5 页面,这是移动端最开始的跨端实践。接着,为了使 Html5 页面能够更多地使用底层 SDK 或者补充性功能,Hybird 开始流行起来。此后,各种各样的跨端框架也随之流行而来, 例如 PhoneGap、AppCan。

2015 年 4 月,Facebook 发布了 React Native,主张“Learn once, write everywhere”,采用 Javascript 开发 + 原生渲染技术,类似的还有 Weex 和快应用。同年 Flutter 也发布了,不过当时关注的人较少,直到 2018 年的 1.0 版本发布,关注和使用的人才多了起来。

贝壳决定建设跨端能力

2018 年,由于贝壳公司内部业务发展较快、需求较多,贝壳开始做平台化能力建设,但因缺乏跨端能力,贝壳开始考虑跨端能力方面的建设。综合了团队构成、业务特点、团队经验以及业界的动态等因素,贝壳成立了 Flutter 架构小组(以下统称小组)。

之后,赵宏伟带着小组成员开启了贝壳跨端探索之旅。那么在这场旅途中,贝壳在接入 Flutter 的过程中都进行了哪些探索呢?为此,InfoQ 邀请到了 Flutter 架构小组负责人赵宏伟以及小组成员肖鹏、裴伟来跟大家一起聊聊贝壳的 Flutter 接入实践。

相比其他跨端框架,Flutter 虽有一定的优势,但刚开始,贝壳并没有直接就选用 Flutter。

“当时组内准备考虑一些跨平台的方案做一些小的尝试和调研。后来,到 2018 年底,Flutter1.0 发布,小组开始对跨端方案进行了深度调研。起初,组内是对 RN 和 Flutter 做调研,FE 团队(贝壳前端团队)负责 Weex 的调研和使用。”赵宏伟回忆到。

经过调研之后,Flutter 架构小组首先放弃了 RN。原因如下:

1.渲染通信路径长。React Native 的逻辑层和 UI 的描述层都在 js;

2.React Native 跨端技术的实现思路是适配,两个移动端(iOS 和 Android)仍然存在很多组件差异;

3.React Native 的页面层级问题;

4.动态化需求不多;

5.有一些公司宣布放弃 React Native,以及 React 许可的一些问题,不确定性较多。

对于 Weex,贝壳前端同学在某些业务上做了部分实验,得到的结论是「效果一般」,没有对他们整个前端的行为有多大改变,因此也放弃了 Weex。而对于 Flutter,首先 Flutter 主打的自渲染,UI 到逻辑都是自身一套;其次 Flutter 基于对应平台独立编译,更适合对应平台的运行,并且支持多平台;最后,Flutter 支持 JIT 和 UT 模式,既能兼顾开发效率,也能兼顾运营效率。

基于以上调研与考虑,贝壳最终选择了 Flutter。

选定跨端框架,接下来就是为接入做准备了。我们了解到贝壳在接入 Flutter 的过程中,整个项目经历了 3 个阶段。

正式开启跨端之旅

据赵宏伟透露,第一个阶段他们的目标是快速落地验证。在此阶段中,他们也遇到了两个难题。第一是如何保证既能进行 Flutter 开发,又不影响非 Flutter 开发人员,并且官方的既有工程集成方案对他们的工程侵入性较大,如果按照最原始的方式打包效率也比较低,因此他们设计了自己的集成方案; 第二是 Flutter 层面需要组件化,小组内部也设计了组件化方案来解决快速落地问题。

在以上问题得到解决之后,他们在部分业务进行快速实验,“当时的业务场景下反馈还不错。”赵宏伟说。接下来就来到第二阶段。在这一阶段中,他们更全面注重开发和用户体验。因此,他们将建设分为了三部分。

第一部分是 Flutter 在持续集成方面建设;

第二部分是业务开发核心基础库能力建设(标准化);

第三部分是容灾降级及稳定性方面的建设。

完成了以上三部分的建设,项目就到了第三阶段,贝壳 Flutter 架构组开始进行多端一体化能力建设。2020 年,小组针对 Flutter for web 做了一些研究,并做出了贝壳 Flutter For web 的容灾降级系统。

“今年(2021 年)我们一直在研究 Flutter 多端一体化方案, 官方也在 2.0 中主推了 web 方向的应用,目前主要是在优化 Flutter For web 的应用体验,包括加载和部署优化,内存优化,三端一体化的核心基础补充以及监控埋点能力补充。我们内部的监控系统一部分 Web 页面也是使用 Flutter 来开发的。”赵宏伟说。

截止到采访时,贝壳 80%(24 款)的 App 已经接入了 Flutter。

当提到这么多 App 接入 Flutter 之后带给贝壳的收益时,赵宏伟兴奋地说:“效率,这个是我们最直观的感觉,整个 2020 年贝壳所有业务线都参与过 Flutter,一致的感受都是效率,以前两个人开发,现在一个人就可以完成。”

除了最直观的效率上的提升,贝壳还做了一整套的标准化 UI 组件(UI 组件 160+,业务组件 30+),这是属于他们的技术创新。并且参与此项目的成员在 Flutter 容器、Aop 编译、监控、UI 自动化和动态化等相关研究中还积累了大量的技术经验,进一步提升了个人技术能力。此外,据小组成员萧鹏描述,他们还将这些 UI 自动化的创新技术整理成文章进行了输出,把创新技术分享给更多有需要的开发者们。

Flutter 的成功接入,虽然让贝壳在跨端能力方面的建设得到了很大的提升,但有些问题,目前小组仍没有较好的解决方案,首先就是内存问题。当提到内存时,赵宏伟略显苦恼。“除了容器自身的内存,还有图片的一些边界场景和释放,以及一些三方图片库存在图片释放和图片过大的 Crash 问题。”赵宏伟说。

针对这一问题,贝壳采用了共享引擎和图片纹理方案来管理 Flutter 图片缓存,虽然得到了很大的改善,但当业务场景中出现多容器时,容器消耗内存仍然不小。再者是 Platform View 和渲染问题,Flutter 在 Platform View 上有一部分内存和适配问题。据了解,这些问题需要深入到引擎内部,更改成本较高,贝壳便没有过多的干预,而是选择了积极适配新版。最后是动态化问题,在 iOS 侧,小组内部没有比较好的动态化方案,目前只保证维护好性能和低成本。

除了以上的一些问题,据赵宏伟透露,贝壳在引入 Flutter 之后,还产生了一些新问题。主要体现在以下几点:

一、集成问题

首先是在第一阶段的集成方案不够完善,RD 反馈调试较为困难,QA 反馈打包时间长,错误率高。其次是配置问题,因小组内部在 Flutter SDK 上做了定制,所以配置起来就比较困难;再加上多个项目的 Flutter SDK 版本不一致,开发人员需要手动组合 Flutter tools 和 Flutter SDK,这一过程增加了成本;还有在打包的时候,需要先将项目在制定打包机上打包,再在主包上打包,这个时间就比较长。于是,他们就重新做了一版集成方案,并开发了 Flutter SDK 自动化集成更新方案,目前在 Flutter、Dart 编译部分就如同一个 target 任务,和其他代码一起进行并发编译,大大提高了效率。

二、包大小问题

在 C 端落地时候,贝壳更加关注包大小。贝壳当时接入的 Flutter1.12.13,其包大小一度达到 30MB,业务方紧急要求瘦身,因此 Flutter 架构小组通过改造 Flutter 编译器以及 Flutter Engine 对 Flutter 产物数据段做了分离压缩,可以内置,可以远程下发,并去掉符号表和无用文件,将包体积从 30MB 缩小到 17MB。在后续的规划中,赵宏伟说:“我们会结合 Flutter for Web 方案,在远程下发失败后动态转到 for Web 页面,提高页面打开成功率,保证业务正常流转。”

当在使用 Flutter for Web 时,Flutter 会将所有的业务打包成一个整包,但对于单独访问一个页面的场景,加载就会比较慢,为解决这一问题,小组对整个包进行了拆分,并把资源上传到 CDN 来解决加载问题。

三、异常监控问题

在做异常监控时,在瘦身模式下,获取的堆栈是没有办法被解析的,很多都是系统堆栈问题,没有办法进一步解决,并且很多都是没用的堆栈,无法将问题细致化。针对这一问题,贝壳做了一套后端的分流解析服务。这套服务首先是将 Flutter Exception 进行符号化,然后对堆栈进行分析,去掉无用和白名单内的堆栈行后进行归因,如果归不到业务方,再采取页面分流的方式,来达到异常监控的效果。

四、图片导致的内存问题

Flutter 本身内存方面消耗比较严重,而自己又是一套独立的图片缓存,因此,在大图片场景下,加上 Native 的图片内存占用,整体图片导致的内存问题比较多。面对这一问题,贝壳采用了外接纹理方案,让 Flutter 和 Native 共享一份图片缓存,以及在大图场景下使用外接纹理方案进行边界处理,这对内存都做了比较好的优化。

除了以上的一些问题,还有一个比较大的挑战。“在引入 Flutter 之后,当面对产品经理的提出的一些交互效果,Flutter 本身技术能力又无法支持时,就需要小组内部去找其他的解决方案来解决,可能会考虑原生,或同时使用原生安卓来做。”小组成员裴伟补充道。

虽然产生了以上这些新问题,但好在小组内部也提出了相应的解决方案。同时,贝壳也一直在寻找更优的解决方法。

贝壳 Flutter 的成功接入也让贝壳开发团队的开发效率得到了跨级别的提升。但在此过程中,贝壳也遭遇到过一个“灵魂拷问”—— 效率 or 体验?

一个永恒的话题—— 要效率,还是要体验?

 一款产品,一个 App 被生产出来,就一定是为了满足用户的某种需求,被人们使用的,因此用户体验要好。而对一个公司而言,一个高效率的组织形式也非常重要,高效的开发可为公司解决人力、财力等各方面的问题。当问到在接入 Flutter 的过程中,贝壳是如何权衡效率和体验的问题时,赵宏伟的回答是比较明确的。

“像直播、视频、地图这一类的,不建议使用 Flutter,其他的在使用 Flutter 时,该提效的就提效,该注重体验就注重体验。当然每个 App 也有自己的着重方向,要根据自己的场景去舍弃效率或者体验。”赵宏伟表示。

在面对贝壳的业务场景时,贝壳引入 Flutter 的关键目标就是要解决开发效率问题。因此,为了进一步提高开发效率,贝壳也做了接下来的打算。首先是研究多端一体化,即将 Flutter 能够应用到 web 上,包括加载、部署优化、内存优化,以及三端一体化的核心基础补充和监控埋点能力补充。目前也在研究中。

其次是在应用上,贝壳内部会有一些 Native 和 Html5 的业务要做。Html5 主要面向站外,贝壳希望能够用一套代码解决三端需求,这样提效会更进一步。赵宏伟接着说:“我们可能会考虑 Flutter 在 VR 渲染上的一些应用,不过目前还在设想阶段。”

除此之外,贝壳对新的 OS,比如 鸿蒙,在兼容模式下也做了适配,并开始着手做了一些准备。“当然也要配合公司的产品策略,是否大范围的适配跟进。”赵宏伟补充到。

写在最后

本次的 Flutter 接入实战,不仅解决了贝壳的“效率提升”问题,还见证了贝壳从 0 到 1 的跨端能力建设。从最初不够完善的基础建设,到引入 Flutter 过程中所遇到的各种困难,贝壳都在实践过程中努力寻找解决方案并构建了一些属于贝壳的创新技术。

参考链接


贝壳Flutter瘦身实践

开源地址

背景

贝壳找房内部大部分的 App 都已经接入了 Flutter,而且公司在跨端方案的选择上在大力发展 Flutter 生态体系,越来越多的团队也在使用 Flutter,Flutter 虽然能带来高人效和高性能的体验,同时也导致包体积增加,包体积的增加会给我们的推广增大难度,所以我们迫切需要一套针对 Flutter 的通用瘦身方案。

现状:

以一个空工程为例,Flutter 产物主要包含两部分,App.framework 和 Flutter.framework 这两个库,这两个库达到了 16M,对我们的包体积优化会带来不小的压力,所以我们立项了 Thin-Flutter 项目,主要是为了所有 App 提供一套 Flutter 通用的瘦身方案。(由于安卓侧有比较多的手段来实现瘦身,所以本篇文章主要针对 iOS)

对于包大小问题,Flutter 官方也在持续跟进优化:

  • Flutter V1.2 开始支持Android App Bundles,支持 Dynamic Module 下发。

  • Flutter V1.12 优化了2.6% Android 平台 Hello World App 大小(3.8M -> 3.7M)。

  • Flutter V1.17 通过优化Dart PC Offset存储以减少 StackMap 大小等多个手段,再次优化了产物大小,实现18.5%的缩减

  • Flutter V1.20 通过Icon font tree shaking移除未用到的 icon fonts,进一步优化了应用大小。

  • Flutter V2.2.2 并没有明显的措施

我们以贝壳 flutter 产物为例(Flutter SDK : 1.22.4)

App.framework 总大小 20.8M

Flutter.framework 总大小 7.7M

我们先来分析 Flutter 的产物构成,通过对编译命令优化后,产物如下(Release 模式):

App.framework:其中两个文件占比较大,一个是 App 可执行文件,另一个是 flutter_assets. App 可执行文件是 Dart 侧业务代码 AOT 编译的产物,会随着业务量的增多而变大,flutter_assets 包含图片、字体等资源文件。

Flutter.framework:  引擎产物,大小是固定的,但是初始占比比较大。这部分能优化的空间很小,主要是通过裁剪引擎不需要的功能,减少体积。编译引擎时可以选择性编译 skia 和 boringssl,收益大概只有几百 K。

经过对比,iOS 和 Flutter 代码量增长对于包体积的影响是有很大区别的,由于 Flutter 的 Tree Shaking 机制,未被引用的代码都会被裁剪掉,这个机制 iOS 里是没有的,那么这个机制所造成的影响就是 Flutter 包体积在初期会极速增加,到一个临界点包体积的增加会趋于平缓。

贝壳瘦身方案

一、方案调研:

包体积瘦身方法论,大概就三种,要么删减,要么压缩,要么挪走,对于删减 Flutter 自带有 tree-sharking 机制,也就是没有用到的代码会自动裁剪,所以删减不会有太明显的效果,对于压缩,各个团队都会不定时压缩图片,所以不能作为主方案,那么想要有明显的瘦身效果,最好的方面很明显是挪了。下面是一些常用的瘦身方案:

  • 通过打包命令删减

—split-debug-info 可以分离出 debug info

—strip 去除无用符号

—dwarf_stack_trace 表示在生成的动态库文件中,不使用堆栈跟踪符号

—obfuscate 表示混淆,通过减少变量名/方法名的方式减小代码体积

  • 减少显示类型转换操作

as  String/Bool 等等,这类操作会导致 App.framework 体积显著增加,主要是他会增加类型检测及抛出异常的处理逻辑。

  • Flutter 引擎删减及符号化分离

Flutter 引擎中包括了 Dart、skia、boringssl、icu、libpng 等多个模块,其中 Dart 和 skia 是必须的,其他模块如果用不到倒是可以考虑裁掉,能够带来几百 k 的瘦身收益。业务方可以根据业务诉求自定义裁剪。

  • 无用代码及无用资源删除

这个不用过多解释,这种直接删代码删资源的方案是最常见的,但投入回报比并不高。

除了给各个业务线分配瘦身指标之外,通过其他方式达到了非常不错的瘦身效果,主要包括以下几方面:

  1. 去除符号化文件

  2. Flutter 产物数据段及资源文件动态下发

  3. 其他方式:包括去除无用文件、无用资源等

二、具体实现:

贝壳希望有一套长期有效的瘦身方案,以及监控体系,所以贝壳的瘦身方案包括两方面,一是包大小分析及监控,二是通用的,对业务同学无感知的瘦身方案。

1.监控

为了让 Flutter 包大小结构更加一目了然,我们将 Flutter 包大小进行了线上可视化。

首先,我们对 flutter_tools 了修改,在打包过程中我们会收集各个 Flutter 组件中二进制和资源的大小并写入文件(这里的实现我们参考了 flutter_tools 中 analyze_size.dart 的代码),打包完成后会将包大小分析文件上传至服务器;然后,我们在后端对上传的包大小文件进行分析,并将各个组件对应到相应的业务线;最终,我们将分析过后的包大小文件在前端进行展示。除了展示各组件和业务线的大小之外,我们还提供了 Flutter 包大小的对比功能,这样就可以更清晰的看到各个组件和业务线的前后大小变化。

有了包大小的分析,我们就可以根据各业务线和组件的不同情况制定不同的瘦身目标。

与此同时,我们可以对包大小的变化有一个长期的监控,可以及时发觉增量大的组件或者业务方,及时做出调整。

2.瘦身方案

2.1.去除符号化文件

贝壳通过 podspec 注入命令的方式,将 debug 符号信息剥离到指定目录,但这样会产生一个新的问题,Flutter 侧 error 无法解析,因此,我们在编译的同时将符号文件和 uuid 唯一标识绑定上传后端归档,在 App 的 Flutter 页面发生异常时,动态获取当前运行 app App.framework 组件的 uuid 标识,连同异常堆栈上报,后端根据 uuid 匹配符号文件,并解析异常堆栈,这部分可以瘦身 1.3M 左右。

以下是 Flutter error 的解析流程图:

由各个业务方梳理无用页面及无用资源文件、图片压缩等等,其中无用代码及无用资源删减有 2M 的收益,图片压缩有 800k 的收益。

我们完成剪裁以后,还需要进一步研究其他瘦身手段,比如动态下发。

2.2Flutter 产物数据段及资源的动态下发

是否可以将两个动态库 App.framework 和 Flutter.framework 全部动态下发?答案是不行的,原因是由于 iOS 系统的限制,可执行文件是不可以动态下发的。

那我们进一步分析,哪些东西是可以动态下发的,哪些东西是不可以动态下发的。参考图 1,首先 flutter_assets 和 icudtl.dat 是资源文件,不存在权限的问题,所以可以动态下发,那么除了这些资源文件,其他部分是否可以动态下发呢?我们接着看。

实现环境:Flutter SDK 2.2.2(目前 Flutter 官方已经发布了 Flutter 2 版本,贝壳也已经适配了 Flutter2.2.2 版本,因此后面基于 Flutter2.2.2 版本进行分析)首先我们看下 Flutter 是如何编译出产物的。

App.framework 的可执行文件经过编译命令优化后,主要由以下四部分构成:

kDartIsolateSnapshotData //代表 Dart 堆的初始状态,并包含 isolate 专属的信息。

kDartVmSnapshotData //代表 isolate 之间共享的 Dart 堆 (heap) 的初始状态。有助于更快地 启动 Dart isolate,但不包含任何 isolate 专属的信息。

kDartIsolateSnapshotInstructions //包含由 Dart isolate 执行的 AOT 代码。

kDartVmSnapshotInstructions //包含 VM 中所有 Dart isolate 之间共享的通用例程的 AOT 指令。这种快照的体积通常非常小,并且大多会包含程序桩 (stub)。

首先了解下什么是 isolate,DartVM 采用了所谓快照的方式,即 JIT 运行时编译后的基本结构与 AOT 编译的基本结构相同。将类信息、全局变量、函数指令直接以序列化的方式存在磁盘中,称为 Snapshot(快照)。同一个进程里可以有很多 isolate,但两个 isolate 的堆区是不能共享的,所以官方设计了 VM isolate,也就是 kDartVmSnapshot,用来多个 isolate 之间的交互。kDartVmSnapshot 分为指令段和数据段,对应上面的 kDartVmSnapshotData 和 kDartVmSnapshotInstructions,内置在 App.framework 里。具体关系如图:

而 isolate 对应的就是 kDartIsolateSnapshot,同样也分为指令段和数据段,对应上面的 kDartIsolateSnapshotData 和 kDartIsolateSnapshotInstructions。

官方文档解释 From the VM's perspective, this just needs to be loaded in memory with READ permissions and does not need WRITE or EXECUTE permissions. Practically this means it should end up in rodata when putting the snapshot in a shared library.

iOS 系统是不允许动态下发可执行二进制代码的,但 kDartIsolateSnapshotData 和 kDartVmSnapshotData 两个数据段的加载是不受系统限制的,所以我们要针对这两部分(上图黄色部分),制定具体的分离方案以及加载方案。

2.2.1、如何分离数据段并回写到磁盘

从上图可以看出 gen_snapshot 为 Dart 编译器,编译后的产物 snapshot_assembly.S 文件再根据不同的平台,编译出不通平台的产物。

分离数据段分为两部分:

  1. 将数据段回写到磁盘上,放入云端服务器动态下发。

  2. 将数据段从 App.framework 中剔除,达到瘦身效果

我们从 gen_snapshot 入手,将数据段产物剥离出来。具体实现如下:

经过验证,release 模式下走的是 CreateAndWritePrecompiledSnapshot 编译流程,因此我们将其改造,将数据段回写到磁盘,需要重写 CreateAppAOTSnapshotAsAssembly 方法。至于回写文件,我们发现 debug 模式下会使用 WriteFile 方法写入文件,这里仿照 debug 模式,将回传的数据段写入./ios/Flutter/Resource/路径下。

Dart_CreateAppAOTSnapshotAsAssembly 具体实现为在 dart_api_impl.cc 文件,gen_snapshot 编译器会将 dart 代码编译为 snapshot_assembly.S 文件,而 snapshot_assembly.S 文件实际上就包含了

kDartIsolateSnapshotData //数据段

kDartVmSnapshotData //数据段

kDartIsolateSnapshotInstructions //代码段

kDartVmSnapshotInstructions //代码段

这几部分。那么我们找到如何将数据段和代码段写入 snapshot_assembly.S 文件,把数据段分离出来不就可以了吗?我们在 FullSnapshotWriter 里发现了整个 snapshot_assembly.S 的写入过程。

到这一步我们已经将数据段回写到磁盘上了,Resource 下成功写入了两个数据段产物,如下图:

2.2.2、将数据段从可执行文件中剔除

同理,找到写入数据段的位置,将其剔除,我们本着改动量最小的原则,分析原有写入逻辑,发现源码里已经将不同符号类型的数据归类,那么顺着原有逻辑,在写入符号的时候,将数据段类型剔除即可。具体源码如下:

通过 nm 命令验证 App 文件中是否只剩下代码段:

对比剥离之前:

数据段剥离之后,也就完成了我们瘦身的目的,但是 App 运行时,没有数据段是不行的,会造成 App 崩溃,因此我们还需要一套完善的方案,来保证数据段从远端下发之后,安全的被加载。

2.2.3、如何加载分离后的数据段

我们来看下加载流程

Flutter 引擎启动的时候,会创建 DartVM,同时加载可执行文件中的代码段和数据段。具体方法可追溯到 ResolveVMData、ResolveVMInstructions、ResolveIsolateData、ResolveIsolateInstructions 等四个方法,分别加载了数据段与代码段,而这四个方法都指向了同一个方法,也就是 SearchMapping 方法,如下:

从 SearchMapping 方法中可以判断,加载顺序为,先从 settings.vm_snapshot_data 或 settings.isolate_snapshot_data 加载,若不存在则从 settings.vm_snapshot_data_path 或 settings.isolate_snapshot_data_path 读取,再然后从 settings.application_library_path 中加载。

那么如果我们篡改 settings.vm_snapshot_data_path 和 settings.isolate_snapshot_data_path 的指向,是否可以将我们本地的数据段正确加载呢?答案是肯定的。

从上面的加载流程图里可以看出来 Setting 类的初始化是在 FlutterDartProject 类里。我们在 FlutterDartProject 重设数据段路径、flutter_assets 路径和 icu_data 国际化文件路径。

首先我们在 App 启动时将分离产物下载到沙盒内的指定路径下:Document/flutter_resource/Resource/*

然后将 setting 类中 path 指定到此路径下。

设置完成以后,DartVM 启动所需要的各种资源与二进制就可以正常加载了。

工程化落地

有了初步的瘦身方案,具体落地还需要很多配套措施,比如持续集成,私有云及监控体系。

工程化落地主要包括三部分:

第一部分:定制 Flutter SDK

一、将 Flutter.framework 文件和 gen_snapshot 文件进行归档,同时需要制作 dSYM 符号表文件。

iOS 提供了两个工具,一个是用于 Flutter.framework 的规定及符号表导出,另一个是用于 gen_snapshot 文件的归档,他们位于 engine/src/flutter/sky/tools/create_ios_framework.py 和 engine/src/flutter/sky/tools/create_macos_gen_snapshots.py。

最终如图所示

ios-release 文件夹就是我们我最终改造完的产物,接下来就是定制 sdk 了。

实际上 Flutter sdk 里会根据不同的平台,不同的 build model 选择不同的编译器和 Flutter engine,如下图所示:

我们只需要把刚刚归档出来的 ios-release 替换 Flutter sdk 里的 ios-release 文件夹,之后 release 模式下打 iOS 产物,App.framework 就会是剥离出数据段的产物。

二、结合 flutterw 部署定制 sdk

由于目前公司 Flutter sdk 存在多个版本,比如 1.12.13、1.22.4 等,因此我们开发了 Flutter sdk 自动化管理工具 flutterw,可以根据项目的不同配置,切换不同的 Flutter sdk,包括官方 sdk,并且自动同步官方新版本。因此我们借助 flutterw 的能力,部署定制的 Flutter sdk,在有瘦身需求的项目里配置 sdk 版本即可。

首先发布定制 sdk:

结果如图:

接着在对应的项目中配置相应的 flutter sdk 版本,如下图:

三、改造 xcode_backend.sh 编译脚本,将数据段、资源包等压缩

编译之后产物被压缩成 flutter_resource.zip,同时为了标识产物的唯一性,将可执行文件的 uuid 作为唯一标识,每次下载完成之后需要先对比 uuid 是否一致。若不一致则更新产物。

第二部分:上传产物平台或内置压缩

到这一步我们准备了两种方案:

内置压缩方案:

也就是将数据段和资源包统一压缩内置在 App.framework 内,应用安装启动后自动解压放在指定位置。

动态下发方案:

对于其他小体量 App,可以采取远程下发的方案,也就是将 flutter_resource.zip 和 uuid_app.txt 上传到 s3 平台(资源服务器),同时在阿波罗平台(配置平台)增加新版本配置。应用启动后下载的方案。

方案对比:

两种方案对比之下,动态下发的瘦身效果最好,但成功率没有内置压缩高,内置压缩方案由于只增加了一个解压环节,因此成功率较高。不同的 APP 可以根据自己的需求采用不同的方案。

第三部分:产物管理

若使用远端下载方案,App 启动会首先拉取远端产物,并将版本信息生成缓存,校验 md5 通过后即可加载,当发现有新版本产物则拉取新版本产物并替换。至于内置压缩方案,则根据 App.framework 的 UUID 来判断是否是正确的产物。

内置压缩方案成功率达到了 99.99%,极小部分失败原因在于内存空间不足。

对于远程下发方案,App 启动后下载相关资源并解压,成功率会受到网络因素影响,增加了重试逻辑之后成功率如下:

尽管下载成功率达到了 99.4%但对于 C 端这种大体量的 app 来说,仍然会影响大量的用户,因此在 C 端使用的是成功率更高的内置压缩方案。

收益

通过动态下发这种方式,虽然可以显著的减小 Flutter 包体积,但是也会带来其他问题,比如由于网络原因导致产物下载失败。因此我们提供了更加安全可靠的方式,将这些文件压缩然后内置在 app 包内。

动态下发方案:

压缩内置方案:

优势:

1.通用的解决方案,任何接入 Flutter 的 APP 都可以用

2.只需集成一次,无需定时优化

3.随着 Flutter 业务的增多,瘦身效果也会更明显

劣势:

剥离出的产物需要通过网络下发,下载成功率取决于网络状况、内存空间等等因素制约。所以后续规划中,会结合 Flutter2web 来缓解由于下载失败,导致 Flutter 页面无法打开的情况。

符号化剥离及混淆:

注:Thin 模式就是数据段及资源动态下发或内置压缩的模式

总体瘦身:

经过上述方案的优化,Flutter 侧瘦身总大小达到了 7M 左右。

而经过各个业务方共同的努力,贝壳找房 app 包大小终于达标。以当时的 V2.47 版本为例:

“iPhone6-iPhoneX 系列”机型安装大小 149.2-149.8M,下载大小 110.5M

“iPhone11-12”机型安装大小 139.4M,下载大小仅 53M

Flutter engine 的改造源码目前已经开源,如果想尝试贝壳方案的同学可以按照开源文档接入。

开源地址:GitHub - LianjiaTech/flutter_beike_engine

后续规划

基于以上的优势劣势,贝壳致力于更加高标准的目标, 那么有没有其他办法在不影响成功率的情况下最大程度的增加瘦身比例呢?答案是有的,那就是结合 Flutter for web 来做兜底方案。具体方案如下:

我们知道,Flutter 在三端一体化做了大量的工作,Flutter 页面可以很好的被转换为 web 页面,我们可以借助这个特性,在编译发版包的时候,同时将 Flutter 工程编译为 Web 产物并部署在远端,当 App 启动后 Flutter 产物由于种种原因最终都无法下载成功的时候,自动打开对应的 web 页面。

关于 Fluttter for web 容灾降级更详细内容可参考:

https://mp.weixin.qq.com/s/zIeU0z-4P5Pd9THVybnDFQ

参考链接