Flutter 是 Google 开发的一套全新的跨平台、开源 UI 框架,支持 iOS、Android 系统开发,并且是未来新操作系统 Fuchsia 的默认开发套件。自从 2017 年 5 月发布第一个版本以来,目前 Flutter 已经发布了近 60 个版本,并且在 2018 年 5 月发布了第一个“Ready for Production Apps”的 Beta 3 版本,6 月 20 日发布了第一个“Release Preview”版本。

初识 Flutter

Flutter 的目标是使同一套代码同时运行在 Android 和 iOS 系统上,并且拥有媲美原生应用的性能,Flutter 甚至提供了两套控件来适配 Android 和 iOS(滚动效果、字体和控件图标等等)为了让 App 在细节处看起来更像原生应用。

在 Flutter 诞生之前,已经有许多跨平台 UI 框架的方案,比如基于 WebView 的 Cordova、AppCan 等,还有使用 HTML+JavaScript 渲染成原生控件的 React Native、Weex 等。

基于 WebView 的框架优点很明显,它们几乎可以完全继承现代 Web 开发的所有成果(丰富得多的控件库、满足各种需求的页面框架、完全的动态化、自动化测试工具等等),当然也包括 Web 开发人员,不需要太多的学习和迁移成本就可以开发一个 App。同时 WebView 框架也有一个致命(在对体验 & 性能有较高要求的情况下)的缺点,那就是 WebView 的渲染效率和 JavaScript 执行性能太差。再加上 Android 各个系统版本和设备厂商的定制,很难保证所在所有设备上都能提供一致的体验。

为了解决 WebView 性能差的问题,以 React Native 为代表的一类框架将最终渲染工作交还给了系统,虽然同样使用类 HTML+JS 的 UI 构建逻辑,但是最终会生成对应的自定义原生控件,以充分利用原生控件相对于 WebView 的较高的绘制效率。与此同时这种策略也将框架本身和 App 开发者绑在了系统的控件系统上,不仅框架本身需要处理大量平台相关的逻辑,随着系统版本变化和 API 的变化,开发者可能也需要处理不同平台的差异,甚至有些特性只能在部分平台上实现,这样框架的跨平台特性就会大打折扣。

Flutter 则开辟了一种全新的思路,从头到尾重写一套跨平台的 UI 框架,包括 UI 控件、渲染逻辑甚至开发语言。渲染引擎依靠跨平台的 Skia 图形库来实现,依赖系统的只有图形绘制相关的接口,可以在最大程度上保证不同平台、不同设备的体验一致性,逻辑处理使用支持 AOT 的 Dart 语言,执行效率也比 JavaScript 高得多。

Flutter 同时支持 Windows、Linux 和 macOS 操作系统作为开发环境,并且在 Android Studio 和 VS Code 两个 IDE 上都提供了全功能的支持。Flutter 所使用的 Dart 语言同时支持 AOT 和 JIT 运行方式,JIT 模式下还有一个备受欢迎的开发利器 “热刷新”(Hot Reload),即在 Android Studio 中编辑 Dart 代码后,只需要点击保存或者“Hot Reload” 按钮,就可以立即更新到正在运行的设备上,不需要重新编译 App,甚至不需要重启 App,立即就可以看到更新后的样式。

在 Flutter 中,所有功能都可以通过组合多个 Widget 来实现,包括对齐方式、按行排列、按列排列、网格排列甚至事件处理等等。Flutter 控件主要分为两大类,StatelessWidget 和 StatefulWidget,StatelessWidget 用来展示静态的文本或者图片,如果控件需要根据外部数据或者用户操作来改变的话,就需要使用 StatefulWidget。State 的概念也是来源于 Facebook 的流行 Web 框架React,React 风格的框架中使用控件树和各自的状态来构建界面,当某个控件的状态发生变化时由框架负责对比前后状态差异并且采取最小代价来更新渲染结果。

Hot Reload

在 Dart 代码文件中修改字符串 “Hello, World”,添加一个惊叹号,点击保存或者热刷新按钮就可以立即更新到界面上,仅需几百毫秒:

Flutter原理与实践 - 美团技术团队 - 图1

Flutter 通过将新的代码注入到正在运行的 DartVM 中,来实现 Hot Reload 这种神奇的效果,在 DartVM 将程序中的类结构更新完成后,Flutter 会立即重建整个控件树,从而更新界面。但是热刷新也有一些限制,并不是所有的代码改动都可以通过热刷新来更新:

  1. 编译错误,如果修改后的 Dart 代码无法通过编译,Flutter 会在控制台报错,这时需要修改对应的代码。
  2. 控件类型从StatelessWidgetStatefulWidget的转换,因为 Flutter 在执行热刷新时会保留程序原来的 state,而某个控件从 stageless→stateful 后会导致 Flutter 重新创建控件时报错 “myWidget is not a subtype of StatelessWidget”,而从 stateful→stateless 会报错 “type ‘myWidget’ is not a subtype of type ‘StatefulWidget’ of ‘newWidget’”。
  3. 全局变量和静态成员变量,这些变量不会在热刷新时更新。
  4. 修改了 main 函数中创建的根控件节点,Flutter 在热刷新后只会根据原来的根节点重新创建控件树,不会修改根节点。
  5. 某个类从普通类型转换成枚举类型,或者类型的泛型参数列表变化,都会使热刷新失败。

热刷新无法实现更新时,执行一次热重启(Hot Restart)就可以全量更新所有代码,同样不需要重启 App,区别是 restart 会将所有 Dart 代码打包同步到设备上,并且所有状态都会重置。

Flutter 插件

Flutter 使用的 Dart 语言无法直接调用 Android 系统提供的 Java 接口,这时就需要使用插件来实现中转。Flutter 官方提供了丰富的原生接口封装:

在 Flutter 中,依赖包由Pub仓库管理,项目依赖配置在 pubspec.yaml 文件中声明即可(类似于 NPM 的版本声明 Pub Versioning Philosophy),对于未发布在 Pub 仓库的插件可以使用 git 仓库地址或文件路径:

  1. dependencies:
  2. url_launcher: ">=0.1.2 <0.2.0"
  3. collection: "^0.1.2"
  4. plugin1:
  5. git:
  6. url: "git://github.com/flutter/plugin1.git"
  7. plugin2:
  8. path: ../plugin2/

以 shared_preferences 为例,在 pubspec 中添加代码:

  1. dependencies:
  2. flutter:
  3. sdk: flutter
  4. shared_preferences: "^0.4.1"

脱字号 “^” 开头的版本表示和当前版本接口保持兼容的最新版,^1.2.3 等效于 >=1.2.3 <2.0.0^0.1.2 等效于 >=0.1.2 <0.2.0,添加依赖后点击 “Packages get” 按钮即可下载插件到本地,在代码中添加 import 语句就可以使用插件提供的接口:

  1. import 'package:shared_preferences/shared_preferences.Dart';
  2. class _MyAppState extends State<MyAppCounter> {
  3. int _count = 0;
  4. static const String COUNTER_KEY = 'counter';
  5. _MyAppState() {
  6. init();
  7. }
  8. init() async {
  9. var pref = await SharedPreferences.getInstance();
  10. _count = pref.getInt(COUNTER_KEY) ?? 0;
  11. setState(() {});
  12. }
  13. increaseCounter() async {
  14. SharedPreferences pref = await SharedPreferences.getInstance();
  15. pref.setInt(COUNTER_KEY, ++_count);
  16. setState(() {});
  17. }
  18. ...

Dart

Dart是一种强类型、跨平台的客户端开发语言。具有专门为客户端优化、高生产力、快速高效、可移植(兼容 ARM/x86)、易学的 OO 编程风格和原生支持响应式编程(Stream & Future)等优秀特性。Dart 主要由 Google 负责开发和维护,在2011 年 10 启动项目,2017 年 9 月发布第一个 2.0-dev 版本。

Dart 本身提供了三种运行方式:

  1. 使用 Dart2js 编译成 JavaScript 代码,运行在常规浏览器中(Dart Web)。
  2. 使用 DartVM 直接在命令行中运行 Dart 代码(DartVM)。
  3. AOT 方式编译成机器码,例如 Flutter App 框架(Flutter)。

Flutter 在筛选了 20 多种语言后,最终选择 Dart 作为开发语言主要有几个原因:

  1. 健全的类型系统,同时支持静态类型检查和运行时类型检查。
  2. 代码体积优化(Tree Shaking),编译时只保留运行时需要调用的代码(不允许反射这样的隐式引用),所以庞大的 Widgets 库不会造成发布体积过大。
  3. 丰富的底层库,Dart 自身提供了非常多的库。
  4. 多生代无锁垃圾回收器,专门为 UI 框架中常见的大量 Widgets 对象创建和销毁优化。
  5. 跨平台,iOS 和 Android 共用一套代码。
  6. JIT & AOT 运行模式,支持开发时的快速迭代和正式发布后最大程度发挥硬件性能。

在 Dart 中,有一些重要的基本概念需要了解:

  • 所有变量的值都是对象,也就是类的实例。甚至数字、函数和null也都是对象,都继承自Object类。
  • 虽然 Dart 是强类型语言,但是显式变量类型声明是可选的,Dart 支持类型推断。如果不想使用类型推断,可以用dynamic类型。
  • Dart 支持泛型,List<int>表示包含 int 类型的列表,List<dynamic>则表示包含任意类型的列表。
  • Dart 支持顶层(top-level)函数和类成员函数,也支持嵌套函数和本地函数。
  • Dart 支持顶层变量和类成员变量。
  • Dart 没有 public、protected 和 private 这些关键字,使用下划线 “_” 开头的变量或者函数,表示只在库内可见。参考库和可见性

DartVM 的内存分配策略非常简单,创建对象时只需要在现有堆上移动指针,内存增长始终是线形的,省去了查找可用内存段的过程:

Flutter原理与实践 - 美团技术团队 - 图2

Dart 中类似线程的概念叫做 Isolate,每个 Isolate 之间是无法共享内存的,所以这种分配策略可以让 Dart 实现无锁的快速分配。

Dart 的垃圾回收也采用了多生代算法,新生代在回收内存时采用了 “半空间” 算法,触发垃圾回收时 Dart 会将当前半空间中的 “活跃” 对象拷贝到备用空间,然后整体释放当前空间的所有内存:

Flutter原理与实践 - 美团技术团队 - 图3

整个过程中 Dart 只需要操作少量的 “活跃” 对象,大量的没有引用的 “死亡” 对象则被忽略,这种算法也非常适合 Flutter 框架中大量 Widget 重建的场景。

Flutter Framework

Flutter 的框架部分完全使用 Dart 语言实现,并且有着清晰的分层架构。分层架构使得我们可以在调用 Flutter 提供的便捷开发功能(预定义的一套高质量 Material 控件)之外,还可以直接调用甚至修改每一层实现(因为整个框架都属于 “用户空间” 的代码),这给我们提供了最大程度的自定义能力。Framework 底层是 Flutter 引擎,引擎主要负责图形绘制(Skia)、文字排版(libtxt)和提供 Dart 运行时,引擎全部使用 C++ 实现,Framework 层使我们可以用 Dart 语言调用引擎的强大能力。

分层架构

Flutter原理与实践 - 美团技术团队 - 图4

Framework 的最底层叫做 Foundation,其中定义的大都是非常基础的、提供给其他所有层使用的工具类和方法。绘制库(Painting)封装了 Flutter Engine 提供的绘制接口,主要是为了在绘制控件等固定样式的图形时提供更直观、更方便的接口,比如绘制缩放后的位图、绘制文本、插值生成阴影以及在盒子周围绘制边框等等。Animation 是动画相关的类,提供了类似 Android 系统的 ValueAnimator 的功能,并且提供了丰富的内置插值器。Gesture 提供了手势识别相关的功能,包括触摸事件类定义和多种内置的手势识别器。GestureBinding 类是 Flutter 中处理手势的抽象服务类,继承自 BindingBase 类。Binding 系列的类在 Flutter 中充当着类似于 Android 中的 SystemService 系列(ActivityManager、PackageManager)功能,每个 Binding 类都提供一个服务的单例对象,App 最顶层的 Binding 会包含所有相关的 Bingding 抽象类。如果使用 Flutter 提供的控件进行开发,则需要使用 WidgetsFlutterBinding,如果不使用 Flutter 提供的任何控件,而直接调用 Render 层,则需要使用 RenderingFlutterBinding。

Flutter 本身支持 Android 和 iOS 两个平台,除了性能和开发语言上的 “native” 化之外,它还提供了两套设计语言的控件实现 Material & Cupertino,可以帮助 App 更好地在不同平台上提供原生的用户体验。

渲染库(Rendering)

Flutter 的控件树在实际显示时会转换成对应的渲染对象(RenderObject)树来实现布局和绘制操作。一般情况下,我们只会在调试布局,或者需要使用自定义控件来实现某些特殊效果的时候,才需要考虑渲染对象树的细节。渲染库主要提供的功能类有:

  1. abstract class RendererBinding extends BindingBase with ServicesBinding, SchedulerBinding, HitTestable { ... }
  2. abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin implements HitTestTarget {
  3. abstract class RenderBox extends RenderObject { ... }
  4. class RenderParagraph extends RenderBox { ... }
  5. class RenderImage extends RenderBox { ... }
  6. class RenderFlex extends RenderBox with ContainerRenderObjectMixin<RenderBox, FlexParentData>,
  7. RenderBoxContainerDefaultsMixin<RenderBox, FlexParentData>,
  8. DebugOverflowIndicatorMixin { ... }

RendererBinding是渲染树和 Flutter 引擎的胶水层,负责管理帧重绘、窗口尺寸和渲染相关参数变化的监听。RenderObject渲染树中所有节点的基类,定义了布局、绘制和合成相关的接口。RenderBox和其三个常用的子类RenderParagraphRenderImageRenderFlex则是具体布局和绘制逻辑的实现类。

在 Flutter 界面渲染过程分为三个阶段:布局、绘制、合成,布局和绘制在 Flutter 框架中完成,合成则交由引擎负责。

Flutter原理与实践 - 美团技术团队 - 图5

控件树中的每个控件通过实现RenderObjectWidget#createRenderObject(BuildContext context) → RenderObject方法来创建对应的不同类型的RenderObject对象,组成渲染对象树。因为 Flutter 极大地简化了布局的逻辑,所以整个布局过程中只需要深度遍历一次:

Flutter原理与实践 - 美团技术团队 - 图6

渲染对象树中的每个对象都会在布局过程中接受父对象的Constraints参数,决定自己的大小,然后父对象就可以按照自己的逻辑决定各个子对象的位置,完成布局过程。子对象不存储自己在容器中的位置,所以在它的位置发生改变时并不需要重新布局或者绘制。子对象的位置信息存储在它自己的parentData字段中,但是该字段由它的父对象负责维护,自身并不关心该字段的内容。同时也因为这种简单的布局逻辑,Flutter 可以在某些节点设置布局边界(Relayout boundary),即当边界内的任何对象发生重新布局时,不会影响边界外的对象,反之亦然:

Flutter原理与实践 - 美团技术团队 - 图7

布局完成后,渲染对象树中的每个节点都有了明确的尺寸和位置,Flutter 会把所有对象绘制到不同的图层上:

Flutter原理与实践 - 美团技术团队 - 图8

因为绘制节点时也是深度遍历,可以看到第二个节点在绘制它的背景和前景不得不绘制在不同的图层上,因为第四个节点切换了图层(因为 “4” 节点是一个需要独占一个图层的内容,比如视频),而第六个节点也一起绘制到了红色图层。这样会导致第二个节点的前景(也就是 “5”)部分需要重绘时,和它在逻辑上毫不相干但是处于同一图层的第六个节点也必须重绘。为了避免这种情况,Flutter 提供了另外一个“重绘边界” 的概念:

Flutter原理与实践 - 美团技术团队 - 图9

在进入和走出重绘边界时,Flutter 会强制切换新的图层,这样就可以避免边界内外的互相影响。典型的应用场景就是 ScrollView,当滚动内容重绘时,一般情况下其他内容是不需要重绘的。虽然重绘边界可以在任何节点手动设置,但是一般不需要我们来实现,Flutter 提供的控件默认会在需要设置的地方自动设置。

控件库(Widgets)

Flutter 的控件库提供了非常丰富的控件,包括最基本的文本、图片、容器、输入框和动画等等。在 Flutter 中 “一切皆是控件”,通过组合、嵌套不同类型的控件,就可以构建出任意功能、任意复杂度的界面。它包含的最主要的几个类有:

  1. class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding,
  2. PaintingBinding, RendererBinding, WidgetsBinding { ... }
  3. abstract class Widget extends DiagnosticableTree { ... }
  4. abstract class StatelessWidget extends Widget { ... }
  5. abstract class StatefulWidget extends Widget { ... }
  6. abstract class RenderObjectWidget extends Widget { ... }
  7. abstract class Element extends DiagnosticableTree implements BuildContext { ... }
  8. class StatelessElement extends ComponentElement { ... }
  9. class StatefulElement extends ComponentElement { ... }
  10. abstract class RenderObjectElement extends Element { ... }
  11. ...

基于 Flutter 控件系统开发的程序都需要使用WidgetsFlutterBinding,它是 Flutter 的控件框架和 Flutter 引擎的胶水层。Widget就是所有控件的基类,它本身所有的属性都是只读的。RenderObjectWidget所有的实现类则负责提供配置信息并创建具体的RenderObjectElementElement是 Flutter 用来分离控件树和真正的渲染对象的中间层,控件用来描述对应的 element 属性,控件重建后可能会复用同一个 element。RenderObjectElement持有真正负责布局、绘制和碰撞测试(hit test)的RenderObject对象。

StatelessWidgetStatefulWidget并不会直接影响RenderObject的创建,它们只负责创建对应的RenderObjectWidgetStatelessElementStatefulElement也是类似的功能。

它们之间的关系如下图:

Flutter原理与实践 - 美团技术团队 - 图10

如果控件的属性发生了变化(因为控件的属性是只读的,所以变化也就意味着重新创建了新的控件树),但是其树上每个节点的类型没有变化时,element 树和 render 树可以完全重用原来的对象(因为 element 和 render object 的属性都是可变的):

Flutter原理与实践 - 美团技术团队 - 图11

但是,如果控件树种某个节点的类型发生了变化,则 element 树和 render 树中的对应节点也需要重新创建:

Flutter原理与实践 - 美团技术团队 - 图12

外卖全品类页面实践

在调研了 Flutter 的各项特性和实现原理之后,外卖计划灰度上线 Flutter 版的全品类页面。对于将 Flutter 页面作为 App 的一部分这种集成模式,官方并没有提供完善的支持,所以我们首先需要了解 Flutter 是如何编译、打包并且运行起来的。

Flutter App 构建过程

最简单的 Flutter 工程至少包含两个文件:

Flutter原理与实践 - 美团技术团队 - 图13

运行 Flutter 程序时需要对应平台的宿主工程,在 Android 上 Flutter 通过自动创建一个 Gradle 项目来生成宿主,在项目目录下执行flutter create .,Flutter 会创建 ios 和 android 两个目录,分别构建对应平台的宿主项目,android 目录内容如下:

Flutter原理与实践 - 美团技术团队 - 图14

此 Gradle 项目中只有一个 app module,构建产物即是宿主 APK。Flutter 在本地运行时默认采用 Debug 模式,在项目目录执行flutter run即可安装到设备中并自动运行,Debug 模式下 Flutter 使用 JIT 方式来执行 Dart 代码,所有的 Dart 代码都会打包到 APK 文件中 assets 目录下,由 libflutter.so 中提供的 DartVM 读取并执行:

Flutter原理与实践 - 美团技术团队 - 图15

kernel_blob.bin 是 Flutter 引擎的底层接口和 Dart 语言基本功能部分代码:

  1. third_party/dart/runtime/bin/*.dart
  2. third_party/dart/runtime/lib/*.dart
  3. third_party/dart/sdk/lib/_http/*.dart
  4. third_party/dart/sdk/lib/async/*.dart
  5. third_party/dart/sdk/lib/collection/*.dart
  6. third_party/dart/sdk/lib/convert/*.dart
  7. third_party/dart/sdk/lib/core/*.dart
  8. third_party/dart/sdk/lib/developer/*.dart
  9. third_party/dart/sdk/lib/html/*.dart
  10. third_party/dart/sdk/lib/internal/*.dart
  11. third_party/dart/sdk/lib/io/*.dart
  12. third_party/dart/sdk/lib/isolate/*.dart
  13. third_party/dart/sdk/lib/math/*.dart
  14. third_party/dart/sdk/lib/mirrors/*.dart
  15. third_party/dart/sdk/lib/profiler/*.dart
  16. third_party/dart/sdk/lib/typed_data/*.dart
  17. third_party/dart/sdk/lib/vmservice/*.dart
  18. flutter/lib/ui/*.dart

platform.dill 则是实现了页面逻辑的代码,也包括 Flutter Framework 和其他由 pub 依赖的库代码:

  1. flutter_tutorial_2/lib/main.dart
  2. flutter/packages/flutter/lib/src/widgets/*.dart
  3. flutter/packages/flutter/lib/src/services/*.dart
  4. flutter/packages/flutter/lib/src/semantics/*.dart
  5. flutter/packages/flutter/lib/src/scheduler/*.dart
  6. flutter/packages/flutter/lib/src/rendering/*.dart
  7. flutter/packages/flutter/lib/src/physics/*.dart
  8. flutter/packages/flutter/lib/src/painting/*.dart
  9. flutter/packages/flutter/lib/src/gestures/*.dart
  10. flutter/packages/flutter/lib/src/foundation/*.dart
  11. flutter/packages/flutter/lib/src/animation/*.dart
  12. .pub-cache/hosted/pub.flutter-io.cn/collection-1.14.6/lib

kernel_blob.bin和platform.dill都是由flutter_tools中的bundle.dart中调用KernelCompiler生成。

在Release模式(flutter run --release)下,Flutter会使用Dart的AOT运行模式,编译时将Dart代码转换成ARM指令:

Flutter原理与实践 - 美团技术团队 - 图16

kernel_blob.bin和platform.dill都不在打包后的APK中,取代其功能的是(isolate/vm)_snapshot(data/instr)四个文件。snapshot文件由Flutter SDK中的flutter/bin/cache/artifacts/engine/android-arm-release/darwin-x64/gen_snapshot命令生成,vm_snapshot\是Dart虚拟机运行所需要的数据和代码指令,isolate_snapshot_则是每个isolate运行所需要的数据和代码指令。

Flutter App运行机制

Flutter构建出的APK在运行时会将所有assets目录下的资源文件解压到App私有文件目录中的flutter目录下,主要包括处理字符编码的icudtl.dat,还有Debug模式的kernel_blob.bin、platform.dill和Release模式下的4个snapshot文件。默认情况下Flutter在Application#onCreate时调用FlutterMain#startInitialization来启动解压任务,然后在FlutterActivityDelegate#onCreate中调用FlutterMain#ensureInitializationComplete来等待解压任务结束。

Flutter在Debug模式下使用JIT执行方式,主要是为了支持广受欢迎的热刷新功能:

Flutter原理与实践 - 美团技术团队 - 图17

触发热刷新时Flutter会检测发生改变的Dart文件,将其同步到App私有缓存目录下,DartVM加载并且修改对应的类或者方法,重建控件树后立即可以在设备上看到效果。

在Release模式下Flutter会直接将snapshot文件映射到内存中执行其中的指令:

Flutter原理与实践 - 美团技术团队 - 图18

在Release模式下,FlutterActivityDelegate#onCreate中调用FlutterMain#ensureInitializationComplete方法中会将AndroidManifest中设置的snapshot(没有设置则使用上面提到的默认值)文件名等运行参数设置到对应的C++同名类对象中,构造FlutterNativeView实例时调用nativeAttach来初始化DartVM,运行编译好的Dart代码。

打包Android Library

了解Flutter项目的构建和运行机制后,我们就可以按照其需求打包成AAR然后集成到现有原生App中了。首先在andorid/app/build.gradle中修改:

APK AAR
修改android插件类型 apply plugin: ‘com.android.application’ apply plugin: ‘com.android.library’
删除applicationId字段 applicationId “com.example.fluttertutorial” ~applicationId “com.example.fluttertutorial”~
建议添加发布所有配置功能,方便调试 - defaultPublishConfig ‘release’

publishNonDefault true |

简单修改后我们就可以使用Android Studio或者Gradle命令行工具将Flutter代码打包到aar中了。Flutter运行时所需要的资源都会包含在aar中,将其发布到maven服务器或者本地maven仓库后,就可以在原生App项目中引用。

但这只是集成的第一步,为了让Flutter页面无缝衔接到外卖App中,我们需要做的还有很多。

图片资源复用

Flutter默认将所有的图片资源文件打包到assets目录下,但是我们并不是用Flutter开发全新的页面,图片资源原来都会按照Android的规范放在各个drawable目录,即使是全新的页面也会有很多图片资源复用的场景,所以在assets目录下新增图片资源并不合适。

Flutter官方并没有提供直接调用drawable目录下的图片资源的途径,毕竟drawable这类文件的处理会涉及大量的Android平台相关的逻辑(屏幕密度、系统版本、语言等等),assets目录文件的读取操作也在引擎内部使用C++实现,在Dart层面实现读取drawable文件的功能比较困难。Flutter在处理assets目录中的文件时也支持添加多倍率的图片资源,并能够在使用时自动选择,但是Flutter要求每个图片必须提供1x图,然后才会识别到对应的其他倍率目录下的图片:

  1. flutter:
  2. assets:
  3. - images/cat.png
  4. - images/2x/cat.png
  5. - images/3.5x/cat.png
  1. new Image.asset('images/cat.png');

这样配置后,才能正确地在不同分辨率的设备上使用对应密度的图片。但是为了减小 APK 包体积我们的位图资源一般只提供常用的 2x 分辨率,其他分辨率的设备会在运行时自动缩放到对应大小。针对这种特殊的情况,我们在不增加包体积的前提下,同样提供了和原生 App 一样的能力:

  1. 在调用 Flutter 页面之前将指定的图片资源按照设备屏幕密度缩放,并存储在 App 私有目录下。
  2. Flutter 中使用时通过自定义的WMImage控件来加载,实际是通过转换成 FileImage 并自动设置 scale 为 devicePixelRatio 来加载。

这样就可以同时解决 APK 包大小和图片资源缺失 1x 图的问题。

Flutter 和原生代码的通信

我们只用 Flutter 实现了一个页面,现有的大量逻辑都是用 Java 实现,在运行时会有许多场景必须使用原生应用中的逻辑和功能,例如网络请求,我们统一的网络库会在每个网络请求中添加许多通用参数,也会负责成功率等指标的监控,还有异常上报,我们需要在捕获到关键异常时将其堆栈和环境信息上报到服务器。这些功能不太可能立即使用 Dart 实现一套出来,所以我们需要使用 Dart 提供的 Platform Channel 功能来实现 Dart→Java 之间的互相调用。

以网络请求为例,我们在 Dart 中定义一个MethodChannel对象:

  1. import 'dart:async';
  2. import 'package:flutter/services.dart';
  3. const MethodChannel _channel = const MethodChannel('com.sankuai.waimai/network');
  4. Future<Map<String, dynamic>> post(String path, [Map<String, dynamic> form]) async {
  5. return _channel.invokeMethod("post", {'path': path, 'body': form}).then((result) {
  6. return new Map<String, dynamic>.from(result);
  7. }).catchError((_) => null);
  8. }

然后在 Java 端实现相同名称的 MethodChannel:

  1. public class FlutterNetworkPlugin implements MethodChannel.MethodCallHandler {
  2. private static final String CHANNEL_NAME = "com.sankuai.waimai/network";
  3. @Override
  4. public void onMethodCall(MethodCall methodCall, final MethodChannel.Result result) {
  5. switch (methodCall.method) {
  6. case "post":
  7. RetrofitManager.performRequest(post((String) methodCall.argument("path"), (Map) methodCall.argument("body")),
  8. new DefaultSubscriber<Map>() {
  9. @Override
  10. public void onError(Throwable e) {
  11. result.error(e.getClass().getCanonicalName(), e.getMessage(), null);
  12. }
  13. @Override
  14. public void onNext(Map stringBaseResponse) {
  15. result.success(stringBaseResponse);
  16. }
  17. }, tag);
  18. break;
  19. default:
  20. result.notImplemented();
  21. break;
  22. }
  23. }
  24. }

在 Flutter 页面中注册后,调用 post 方法就可以调用对应的 Java 实现:

  1. loadData: (callback) async {
  2. Map<String, dynamic> data = await post("home/groups");
  3. if (data == null) {
  4. callback(false);
  5. return;
  6. }
  7. _data = AllCategoryResponse.fromJson(data);
  8. if (_data == null || _data.code != 0) {
  9. callback(false);
  10. return;
  11. }
  12. callback(true);
  13. }),

SO 库兼容性

Flutter 官方只提供了四种 CPU 架构的 SO 库:armeabi-v7a、arm64-v8a、x86 和 x86-64,其中 x86 系列只支持 Debug 模式,但是外卖使用的大量 SDK 都只提供了 armeabi 架构的库。虽然我们可以通过修改引擎src根目录和third_party/dart目录下build/config/arm.gnithird_party/skia目录下的BUILD.gn等配置文件来编译出 armeabi 版本的 Flutter 引擎,但是实际上市面上绝大部分设备都已经支持 armeabi-v7a,其提供的硬件加速浮点运算指令可以大大提高 Flutter 的运行速度,在灰度阶段我们可以主动屏蔽掉不支持 armeabi-v7a 的设备,直接使用 armeabi-v7a 版本的引擎。做到这点我们首先需要修改 Flutter 提供的引擎,在 Flutter 安装目录下的bin/cache/artifacts/engine下有 Flutter 下载的所有平台的引擎:

Flutter原理与实践 - 美团技术团队 - 图19

我们只需要修改 android-arm、android-arm-profile 和 android-arm-release 下的 flutter.jar,将其中的 lib/armeabi-v7a/libflutter.so 移动到 lib/armeabi/libflutter.so 即可:

  1. cd $FLUTTER_ROOT/bin/cache/artifacts/engine
  2. for arch in android-arm android-arm-profile android-arm-release; do
  3. pushd $arch
  4. cp flutter.jar flutter-armeabi-v7a.jar
  5. unzip flutter.jar lib/armeabi-v7a/libflutter.so
  6. mv lib/armeabi-v7a lib/armeabi
  7. zip -d flutter.jar lib/armeabi-v7a/libflutter.so
  8. zip flutter.jar lib/armeabi/libflutter.so
  9. popd
  10. done

这样在打包后 Flutter 的 SO 库就会打到 APK 的 lib/armeabi 目录中。在运行时如果设备不支持 armeabi-v7a 可能会崩溃,所以我们需要主动识别并屏蔽掉这类设备,在 Android 上判断设备是否支持 armeabi-v7a 也很简单:

  1. public static boolean isARMv7Compatible() {
  2. try {
  3. if (SDK_INT >= LOLLIPOP) {
  4. for (String abi : Build.SUPPORTED_32_BIT_ABIS) {
  5. if (abi.equals("armeabi-v7a")) {
  6. return true;
  7. }
  8. }
  9. } else {
  10. if (CPU_ABI.equals("armeabi-v7a") || CPU_ABI.equals("arm64-v8a")) {
  11. return true;
  12. }
  13. }
  14. } catch (Throwable e) {
  15. L.wtf(e);
  16. }
  17. return false;
  18. }

灰度和自动降级策略

Horn 是一个美团内部的跨平台配置下发 SDK,使用 Horn 可以很方便地指定灰度开关:

Flutter原理与实践 - 美团技术团队 - 图20

在条件配置页面定义一系列条件,然后在参数配置页面添加新的字段 flutter 即可:

Flutter原理与实践 - 美团技术团队 - 图21

因为在客户端做了 ABI 兜底策略,所以这里定义的 ABI 规则并没有启用。

Flutter 目前仍然处于 Beta 阶段,灰度过程中难免发生崩溃现象,观察到崩溃后再针对机型或者设备 ID 来做降级虽然可以尽量降低影响,但是我们可以做到更迅速。外卖的 Crash 采集 SDK 同时也支持 JNI Crash 的收集,我们专门为 Flutter 注册了崩溃监听器,一旦采集到 Flutter 相关的 JNI Crash 就立即停止该设备的 Flutter 功能,启动 Flutter 之前会先判断FLUTTER_NATIVE_CRASH_FLAG文件是否存在,如果存在则表示该设备发生过 Flutter 相关的崩溃,很有可能是不兼容导致的问题,当前版本周期内在该设备上就不再使用 Flutter 功能。

除了崩溃以外,Flutter 页面中的 Dart 代码也可能发生异常,例如服务器下发数据格式错误导致解析失败等等,Dart 也提供了全局的异常捕获功能:

  1. import 'package:wm_app/plugins/wm_metrics.dart';
  2. void main() {
  3. runZoned(() => runApp(WaimaiApp()), onError: (Object obj, StackTrace stack) {
  4. uploadException("$obj\n$stack");
  5. });
  6. }

这样我们就可以实现全方位的异常监控和完善的降级策略,最大程度减少灰度时可能对用户带来的影响。

分析崩溃堆栈和异常数据

Flutter 的引擎部分全部使用 C/C++ 实现,为了减少包大小,所有的 SO 库在发布时都会去除符号表信息。和其他的 JNI 崩溃堆栈一样,我们上报的堆栈信息中只能看到内存地址偏移量等信息:

  1. *** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
  2. Build fingerprint: 'Rock/odin/odin:7.1.1/NMF26F/1527007828:user/dev-keys'
  3. Revision: '0'
  4. Author: collect by 'libunwind'
  5. ABI: 'arm64-v8a'
  6. pid: 28937, tid: 29314, name: 1.ui >>> com.sankuai.meituan.takeoutnew <<<
  7. signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
  8. backtrace:
  9. r0 00000000 r1 ffffffff r2 c0e7cb2c r3 c15affcc
  10. r4 c15aff88 r5 c0e7cb2c r6 c15aff90 r7 bf567800
  11. r8 c0e7cc58 r9 00000000 sl c15aff0c fp 00000001
  12. ip 80000000 sp c0e7cb28 lr c11a03f9 pc c1254088 cpsr 200c0030
  13. #00 pc 002d7088 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so
  14. #01 pc 002d5a23 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so
  15. #02 pc 002d95b5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so
  16. #03 pc 002d9f33 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so
  17. #04 pc 00068e6d /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so
  18. #05 pc 00067da5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so
  19. #06 pc 00067d5f /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so
  20. #07 pc 003b1877 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so
  21. #08 pc 003b1db5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so
  22. #09 pc 0000241c /data/data/com.sankuai.meituan.takeoutnew/app_flutter/vm_snapshot_instr

单纯这些信息很难定位问题,所以我们需要使用 NDK 提供的 ndk-stack 来解析出具体的代码位置:

  1. ndk-stack -sym PATH [-dump PATH]
  2. Symbolizes the stack trace from an Android native crash.
  3. -sym PATH sets the root directory for symbols
  4. -dump PATH sets the file containing the crash dump (default stdin)

如果使用了定制过的引擎,必须使用engine/src/out/android-release下编译出的 libflutter.so 文件。一般情况下我们使用的是官方版本的引擎,可以在flutter_infra页面直接下载带有符号表的 SO 文件,根据打包时使用的 Flutter 工具版本下载对应的文件即可。比如 0.4.4 beta 版本:

  1. $ flutter --version # version命令可以看到Engine对应的版本 06afdfe54e
  2. Flutter 0.4.4 channel beta https://github.com/flutter/flutter.git
  3. Framework revision f9bb4289e9 (5 weeks ago) 2018-05-11 21:44:54 -0700
  4. Engine revision 06afdfe54e
  5. Tools Dart 2.0.0-dev.54.0.flutter-46ab040e58
  6. $ cat flutter/bin/internal/engine.version # flutter安装目录下的engine.version文件也可以看到完整的版本信息 06afdfe54ebef9168a90ca00a6721c2d36e6aafa
  7. 06afdfe54ebef9168a90ca00a6721c2d36e6aafa

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

Flutter原理与实践 - 美团技术团队 - 图22

执行 ndk-stack 即可看到实际发生崩溃的代码和具体行数信息:

  1. ndk-stack -sym flutter-production-syms/06afdfe54ebef9168a90ca00a6721c2d36e6aafa/armeabi-v7a -dump flutter_jni_crash.txt
  2. ********** Crash dump: **********
  3. Build fingerprint: 'Rock/odin/odin:7.1.1/NMF26F/1527007828:user/dev-keys'
  4. pid: 28937, tid: 29314, name: 1.ui >>> com.sankuai.meituan.takeoutnew <<<
  5. signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0
  6. Stack frame #00 pc 002d7088 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine minikin::WordBreaker::setText(unsigned short const*, unsigned int) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/minikin/WordBreaker.cpp:55
  7. Stack frame #01 pc 002d5a23 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine minikin::LineBreaker::setText() at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/minikin/LineBreaker.cpp:74
  8. Stack frame #02 pc 002d95b5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine txt::Paragraph::ComputeLineBreaks() at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/txt/paragraph.cc:273
  9. Stack frame #03 pc 002d9f33 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine txt::Paragraph::Layout(double, bool) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/third_party/txt/src/txt/paragraph.cc:428
  10. Stack frame #04 pc 00068e6d /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine blink::ParagraphImplTxt::layout(double) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../flutter/lib/ui/text/paragraph_impl_txt.cc:54
  11. Stack frame #05 pc 00067da5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine tonic::DartDispatcher<tonic::IndicesHolder<0u>, void (blink::Paragraph::*)(double)>::Dispatch(void (blink::Paragraph::*)(double)) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../topaz/lib/tonic/dart_args.h:150
  12. Stack frame #06 pc 00067d5f /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine void tonic::DartCall<void (blink::Paragraph::*)(double)>(void (blink::Paragraph::*)(double), _Dart_NativeArguments*) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../topaz/lib/tonic/dart_args.h:198
  13. Stack frame #07 pc 003b1877 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine dart::NativeEntry::AutoScopeNativeCallWrapperNoStackCheck(_Dart_NativeArguments*, void (*)(_Dart_NativeArguments*)) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../third_party/dart/runtime/vm/native_entry.cc:198
  14. Stack frame #08 pc 003b1db5 /data/app/com.sankuai.meituan.takeoutnew-1/lib/arm/libflutter.so: Routine dart::NativeEntry::LinkNativeCall(_Dart_NativeArguments*) at /b/build/slave/Linux_Engine/build/src/out/android_release/../../third_party/dart/runtime/vm/native_entry.cc:348
  15. Stack frame #09 pc 0000241c /data/data/com.sankuai.meituan.takeoutnew/app_flutter/vm_snapshot_instr

Dart 异常则比较简单,默认情况下 Dart 代码在编译成机器码时并没有去除符号表信息,所以 Dart 的异常堆栈本身就可以标识真实发生异常的代码文件和行数信息:

  1. FlutterException: type '_InternalLinkedHashMap<dynamic, dynamic>' is not a subtype of type 'num' in type cast
  2. <asynchronous suspension>

Flutter 和原生性能对比

虽然使用原生实现(左)和 Flutter 实现(右)的全品类页面在实际使用过程中几乎分辨不出来:

Flutter原理与实践 - 美团技术团队 - 图23

但是我们还需要在性能方面有一个比较明确的数据对比。

我们最关心的两个页面性能指标就是页面加载时间和页面渲染速度。测试页面加载速度可以直接使用美团内部的 Metrics 性能测试工具,我们将页面 Activity 对象创建作为页面加载的开始时间,页面 API 数据返回作为页面加载结束时间。从两个实现的页面分别启动 400 多次的数据中可以看到,原生实现(AllCategoryActivity)的加载时间中位数为 210ms,Flutter 实现(FlutterCategoryActivity)的加载时间中位数为 231ms。考虑到目前我们还没有针对 FlutterView 做缓存和重用,FlutterView 每次创建都需要初始化整个 Flutter 环境并加载相关代码,多出的 20ms 还在预期范围内:

Flutter原理与实践 - 美团技术团队 - 图24

Flutter原理与实践 - 美团技术团队 - 图25

因为 Flutter 的 UI 逻辑和绘制代码都不在主线程执行,Metrics 原有的 FPS 功能无法统计到 Flutter 页面的真实情况,我们需要用特殊方法来对比两种实现的渲染效率。Android 原生实现的界面渲染耗时使用系统提供的FrameMetrics接口进行监控:

  1. public class AllCategoryActivity extends WmBaseActivity {
  2. @Override
  3. protected void onCreate(Bundle savedInstanceState) {
  4. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
  5. getWindow().addOnFrameMetricsAvailableListener(new Window.OnFrameMetricsAvailableListener() {
  6. List<Integer> frameDurations = new ArrayList<>(100);
  7. @Override
  8. public void onFrameMetricsAvailable(Window window, FrameMetrics frameMetrics, int dropCountSinceLastInvocation) {
  9. frameDurations.add((int) (frameMetrics.getMetric(TOTAL_DURATION) / 1000000));
  10. if (frameDurations.size() == 100) {
  11. getWindow().removeOnFrameMetricsAvailableListener(this);
  12. L.w("AllCategory", Arrays.toString(frameDurations.toArray()));
  13. }
  14. }
  15. }, new Handler(Looper.getMainLooper()));
  16. }
  17. super.onCreate(savedInstanceState);
  18. }
  19. }

Flutter 在 Framework 层只能取到每帧中 UI 操作的 CPU 耗时,GPU 操作都在 Flutter 引擎内部实现,所以需要修改引擎来监控完整的渲染耗时,在 Flutter 引擎目录下的src/flutter/shell/common/rasterizer.cc文件中添加:

  1. void Rasterizer::DoDraw(std::unique_ptr<flow::LayerTree> layer_tree) {
  2. if (!layer_tree || !surface_) {
  3. return;
  4. }
  5. if (DrawToSurface(*layer_tree)) {
  6. last_layer_tree_ = std::move(layer_tree);
  7. #if defined(OS_ANDROID)
  8. if (compositor_context_->frame_count().count() == 101) {
  9. std::ostringstream os;
  10. os << "[";
  11. const std::vector<TimeDelta> &engine_laps = compositor_context_->engine_time().Laps();
  12. const std::vector<TimeDelta> &frame_laps = compositor_context_->frame_time().Laps();
  13. size_t i = 1;
  14. for (auto engine_iter = engine_laps.begin() + 1, frame_iter = frame_laps.begin() + 1;
  15. i < 101 && engine_iter != engine_laps.end(); i++, engine_iter++, frame_iter++) {
  16. os << (*engine_iter + *frame_iter).ToMilliseconds() << ",";
  17. }
  18. os << "]";
  19. __android_log_write(ANDROID_LOG_WARN, "AllCategory", os.str().c_str());
  20. }
  21. #endif
  22. }
  23. }

即可得到每帧绘制时真正消耗的时间。测试时我们将两种实现的页面分别打开 100 次,每次打开后执行两次滚动操作,使其绘制 100 帧,将这 100 帧的每帧耗时记录下来:

  1. for (( i = 0; i < 100; i++ )); do
  2. openWMPage allcategory
  3. sleep 1
  4. adb shell input swipe 500 1000 500 300 900
  5. adb shell input swipe 500 1000 500 300 900
  6. adb shell input keyevent 4
  7. done

将测试结果的 100 次启动中每帧耗时取平均値,得到每帧平均耗时情况(横坐标轴为帧序列,纵坐标轴为每帧耗时,单位为毫秒):

Flutter原理与实践 - 美团技术团队 - 图26

Android 原生实现和 Flutter 版本都会在页面打开的前 5 帧超过 16ms,刚打开页面时原生实现需要创建大量 View,Flutter 也需要创建大量 Widget,后续帧中可以重用大部分控件和渲染节点(原生的 RenderNode 和 Flutter 的 RenderObject),所以启动时的布局和渲染操作都是最耗时的。

10000 帧(100 次 ×100 帧每次)中 Android 原生总平均値为 10.21ms,Flutter 总平均値为 12.28ms,Android 原生实现总丢帧数 851 帧 8.51%,Flutter 总丢帧 987 帧 9.87%。在原生实现的触摸事件处理和过度绘制充分优化的前提下,Flutter 完全可以媲美原生的性能。

总结

Flutter 目前仍处于早期阶段,也还没有发布正式的 Release 版本,不过我们看到 Flutter 团队一直在为这一目标而努力。虽然 Flutter 的开发生态不如 Android 和 iOS 原生应用那么成熟,许多常用的复杂控件还需要自己实现,有的甚至会比较困难(比如官方尚未提供的ListView.scrollTo(index)功能),但是在高性能和跨平台方面 Flutter 在众多 UI 框架中还是有很大优势的。

开发 Flutter 应用只能使用 Dart 语言,Dart 本身既有静态语言的特性,也支持动态语言的部分特性,对于 Java 和 JavaScript 开发者来说门槛都不高,3-5 天可以快速上手,大约 1-2 周可以熟练掌握。在开发全品类页面的 Flutter 版本时我们也深刻体会到了 Dart 语言的魅力,Dart 的语言特性使得 Flutter 的界面构建过程也比 Android 原生的 XML+JAVA 更直观,代码量也从原来的 900 多行减少到 500 多行(排除掉引用的公共组件)。Flutter 页面集成到 App 后 APK 体积至少会增加 5.5MB,其中包括 3.3MB 的 SO 库文件和 2.2MB 的 ICU 数据文件,此外业务代码 1300 行编译产物的大小有 2MB 左右。

Flutter 本身的特性适合追求 iOS 和 Android 跨平台的一致体验,追求高性能的 UI 交互效果的场景,不适合追求动态化部署的场景。Flutter 在 Android 上已经可以实现动态化部署,但是由于 Apple 的限制,在 iOS 上实现动态化部署非常困难,Flutter 团队也正在和 Apple 积极沟通。

美团外卖大前端团队将来也会继续在更多场景下使用 Flutter 实现,并且将实践过程中发现和修复的问题积极反馈到开源社区,帮助 Flutter 更好地发展。如果你也对 Flutter 感兴趣,欢迎加入。

参考资料

  1. Flutter 中文官网
  2. Flutter 框架技术概览
  3. Flutter 插件仓库
  4. A Tour of the Dart Language
  5. A Tour of the Dart Libraries
  6. Why Flutter Uses Dart
  7. Flutter Layout 机制简介
  8. Flutter’s Layered Design
  9. Flutter’s Rendering Pipeline
  10. Flutter: The Best Way to Build for Mobile?@GOTO conf
  11. Flutter Engine
  12. Writing custom platform-specific code with platform channels
  13. Flutter Engine Operation in AOT Mode
  14. Flutter’s modes
  15. Symbolicating-production-crash-stacks

作者简介

  • 少杰,美团高级工程师,2017 年加入美团,目前主要负责外卖 App 监控等基础设施建设工作。

招聘信息

美团外卖诚招 Android、iOS、FE 高级 / 资深工程师和技术专家,Base 北京、上海、成都,欢迎有兴趣的同学投递简历到 wukai05#meituan.com。
https://tech.meituan.com/2018/08/09/waimai-flutter-practice.html