在闲鱼技术探究Flutter工程一体化的过程中,为了做到最好的开发体验,需要无缝衔接FaaS端代码与业务Flutter代码,一份代码既可以在FaaS部署,也可以直接引入在业务代码主工程中,使之真正做到工程一体。
为了实现这一目标我们对两部分代码通过RPC调用的方式实现了代码解耦,而工程解耦依赖于Flutter/Dart在编译过程中的Tree-Shaking机制。为了避免踩坑,我们需要了解,整个Tree-Shaking是怎么起作用的。本篇文章结合Flutter Engine源码对这一过程进行了简单的探究。
Tree Shaking是一种死代码消除(Dead Code Elimination)技术,这一想法起源于20世纪90年代的LISP。其思想是:一个程序所有可能的执行流程都可以用函数调用的树来表示,这样就可以消除那些从未被调用的函数。该算法最先被应用到Google Closure Tools中的JavaScript中,然后被应用到同样由Google编写的dart2js编译器中。在Flutter中,同样有这样的Tree Shaking机制来减小最终产出的包大小。Flutter提供了三种构建模式,针对每个不同的模式,Flutter编译器对产出的二进制文件有不同优化,Tree-Shaking机制并不会在debug模式中触发。在Profile/Release模式下编译的AOT产物中,有几个比较重要的产物可以让我们更直观地看到Tree-Shaking机制在发挥作用:
app.dill : 这就是dart代码通过build的产物,为二进制的字节码,可以通过 strings
看到里面的内容,其实就是我们dart代码的源码。
snapshot_blob.bin.d : 这个文件里面是所有参与编译的dart文件的集合,包括我们自己的业务代码、 pubspec.yaml
中定义的三方库的代码、以及我们业务代码中import进来的所有flutter或者dart原生 package
的代码。
我们写一个最简单的例子,代码如下:
代码非常简单,里面包含了一个没有被使用的 _unused
方法。下面我们在Profile模式下进行编译,通过DevTools来查看最终编译的产物,如下图所示
可以看到,在Funtions中,并没有 _unused
方法,说明在编译过程中,这段无用的代码被“摇”掉了。实际上除了Function之外,Flutter编译过程中对于引入的lib,import的dart文件都有相似的Tree-Shaking处理。下面深入代码来看看,这究竟是怎么做到。
这里借用Gityuan前辈的 flutter run
命令执行的时序图,整个编译流程会比较长,在 GenSnapshot.run()
方法会调用gensnapshot这个二进制可执行文件(对应的源码在目录thirdparty/dart/runtime/bin/gensnapshot.cc),生成机器码。
tree-shaking机制就发生在其中的编译阶段,即 CompileAll()
方法。下面我们深入到代码去一步一步探究,Flutter编译器是怎么对代码做裁剪的。
源代码路径是third_party/dart/runtime/vm/compiler/aot/precompiler.cc,读者也可以自行对照查询。
首先是必备的准备工作,需要将对象池保留到AOT编译结束,因此这里必须使用能存活那么久的句柄,使用了StackZone。
为了使用类层次结构分析 (CHA),在编译前需要确保类的层次结构稳定,同时确保查找入口点时不会因为函数的类还没有最终确定而漏掉函数。CHA是一种编译器优化,可根据对类层次结构的分析结果,将虚拟调用去虚拟化为直接调用。
下一步生成桩代码,通过 StubCode::InterpretCall
得到的code来获取它的对象池,再利用 StubCode::Build
等一系列方法系列方法获取的结果保存在object_store。收集动态函数的方法名,之后通过 AddRoots()
方法,从C++发生的分配和调用的起点添加为根, 同时通过 AddAnnotatedRoots()
方法将所有以@pragma(’vm:entry-point’)为标注的也添加为根。
之后,代码开始编译, Iterate()
是编译最为核心的地方。在这里会以上面找到的根作为目标,遍历添加该目标的调用者。
ProcessFunction ==> CompileFunction ==> PrecompileFunctionHelper ==> PrecompileParsedFunctionHelper.Compile
至此,编译完成之后开始进入Tree-Shaking阶段,对无用代码进行简化。
在上面的编译过程中,函数/类等调用信息已经进行了输出,根据这些信息,让编译器可以知道,具体哪一些是不必要的代码。这里以对Function的处理为例进行讲解:
TraceForRetainedFunctions();
在这个方法中,取得Library、Class等句柄之后,以Library为单位,对每个包内的代码进行处理,会遍历所有类中的Functions进行处理。
通过 AddTypesOf(constFunction&function)
方法,将调用到的函数添加到 functions_to_retain_
池中,同时对Function中的类型参数做了读取,通过 AddType
方法,将这些类型参数添加到对应的 typeargs_to_retain_
池和 types_to_retain_
池中,用于类型信息的TreeShaking(分别对应DropTypeArguments和DropTypeParameters)。
Class信息在同名方法
AddTypesOf(constClass&cls)
中进行处理,处理过程比较类似,这里不做赘述,感兴趣的读者可以自行查阅
FinalizeDispatchTable();
这个方法里面,会确保在执行Drop方法之前建立用于序列化调度表的条目,因为编译器后续可能会清除对Code对象的引用。同时删除调度表生成器,以确保在这之后不再尝试添加新条目。
ReplaceFunctionStaticCallEntries();
在这个方法里通过声明的匿名内部类 StaticCallTableEntryFixer
,对静态函数调用入口做了替换。
Drop
接下来,会执行一系列的Drop方法。这些方法会去掉多余的方法、字段、类、库等,如下所示:
DropFunctions();
DropFields();
DropTypes();
DropTypeParameters();
DropTypeArguments();
DropMetadata();
DropLibraryEntries();
DropClasses();
DropLibraries();
具体调用时序如下图所示:
由于这些方法的内部实现思路有很多相似之处,这里针对Function的方法 DropFunctions
为例来说明。
在该方法中,核心是通过以上提到 functions_to_retain_
池,对Function是否有根调用者进行判断, 如果池中不包含Function对象,说明这是可以舍弃的Function。之后,将剩下的Function重新写回Class,并更新Class的调用表。
在方法内部声明了drop_function函数来“摇掉”Function。
之后使用对所有的代码中的Function进行遍历, 使用上面声明的 drop_function
对无用的Function代码进行标记和删除。
重新生成类的调用表,同时对调用表中的可能存在的无用Function进行兜底删除:
最后是一些内联函数等边界情况的处理,这里不再赘述。在完成Drop阶段之后,可以被丢掉的代码已经进入了删除池中,后面进入编译的收尾阶段,进一步减小二进制文件大小。
在Tree-Shaking结束之后,进入编译收尾工作,包括代码混淆,垃圾回收等。
在该方法内进行很多重复数据删除工作;在AOT模式下,binder是在Tree Shaking之后运行的,在此期间,所有的目标都已经被编译,因此binder会用对目标的直接调用代替所有的静态调用,进一步减小了编译产物二进制文件。至此所有的编译工作完成,Tree-Shaking完成了他的使命。
在Flutter 1.20版本,通过Tree-Shaking机制移除在工程中未使用到的icon fonts,进一步缩小了包大小(100KB左右),不过该方法的实现并不在以上说明的编译阶段,而是在build_system里,对assets进行了优化。相关的PR在github.com/flutter/flutte/pull/49737可以查看。
本文主要结合Flutter Engine源代码,从编译阶段出发,探究了在过程中Tree-Shaking的运行机制。由于这样一个机制的存在,为工程解耦提供了理论基础,让工程一体化的实现更为简单,同时对我们进一步优化包大小有启发。