Flutter 是 Google 的一套跨平台 UI 框架。目前已是 1.7 的 Release 版本。在移动端双端投入人力较大,短时间紧急需求的背景下。跨端技术会成为愈来愈多的移动端技术栈选择。铭师堂移动端团队在过去几个月,对 Flutter 技术作了一些尝试和工做。这篇文章将会对 Flutter 的基本原理和咱们在 升学e网通 APP
的工程实践作一个简单的分享。前端
Flutter framework 层的架构图以下:java
Foundation: foundation 提供了 framework 常用的一些基础类,包括但不限于:node
BindBase: 提供了提供单例服务的对象基类,提供了 Widgets、Render、Gestures等能力android
Key: 提供了 Flutter 经常使用的 Key 的基类ios
AbstractNode:表示了控件树的节点git
在 foundation 之上,Flutter 提供了 动画、绘图、手势、渲染和部件,其中部件就包括咱们比较熟悉的 Material 和 Cupertino 风格web
咱们从 dart 的入口处关注 Flutter 的渲染原理shell
void runApp(Widget app) {
WidgetsFlutterBinding.ensureInitialized()
..attachRootWidget(app)
..scheduleWarmUpFrame();
}
复制代码
咱们直接使用了 Widgets 层的能力json
负责根据咱们 dart 代码提供的 Widget 树,来构造实际的虚拟节点树redux
在 FLutter 的渲染机制中,有 3 个比较关键的概念:
根据 attachRootWidget
的流程,咱们能够了解到布局树的构造流程
attachRootWidget
建立根节点attachToRenderTree
建立 root Elementmount
方法把本身挂载到父 Element。这里由于本身是根节点,因此能够忽略挂载过程mount
会经过 createRenderObject
建立 root Element 的 RenderObject到这里,整颗 tree 的 root 节点就构造出来了,在 mount
中,会经过 BuildOwner#buildScope
执行子节点的建立和挂载, 这里须要注意的是 child 的 RenderObject 也会被 attach 到 parent 的 RenderObejct 上去
整个过程咱们能够经过下图表示
感兴趣能够参考 Element
、RenderObjectElement
、RenderObject
的源码
负责实际整个控件树 RenderObject 的布局和绘制
runApp 后会执行 scheduleWarmUpFrame
方法,这里就会开始调度渲染任务,进行每一帧的渲染
从 handleBeginFrame
和 handleDrawFrame
会走到 binding 的 drawFrame
函数,依次会调用 WidgetsBinding
和 RendererBinding
的 drawFrame
。
这里会经过 Element 的 BuildOwner
,去从新塑造咱们的控件树。
大体原理如图
在构造或者刷新一颗控件树的时候,咱们会把有改动部分的 Widget 标记为 dirty,并针对这部分执行 rebuild,可是 Flutter 会有判断来保证尽可能复用 Element,从而避免了反复建立 Element 对象带来的性能问题。
在对 dirty elements 进行处理的时候,会对它进行一次排序,排序规则参考了 element 的深度:
static int _sort(Element a, Element b) {
if (a.depth < b.depth)
return -1;
if (b.depth < a.depth)
return 1;
if (b.dirty && !a.dirty)
return -1;
if (a.dirty && !b.dirty)
return 1;
return 0;
}
复制代码
根据 depth 排序的目的,则是为了保证子控件必定排在父控件的左侧, 这样在 build 的时候,能够避免对子 widget 进行重复的 build。
在实际渲染过程当中,Flutter 会利用 Relayout Boundary机制
void markNeedsLayout() {
// ...
if (_relayoutBoundary != this) {
markParentNeedsLayout();
} else {
_needsLayout = true;
if (owner != null) {
owner._nodesNeedingLayout.add(this);
owner.requestVisualUpdate();
}
}
//...
}
复制代码
在设置了 relayout boundary 的控件中,只有子控件会被标记为 needsLayout,能够保证,刷新子控件的状态后,控件树的处理范围都在子树,不会去从新建立父控件,彻底隔离开。
在每个 RendererBinding 中,存在一个 PipelineOwner
对象,相似 WidgetsBinding 中的 BuildOwner
. BuilderOwner
负责控件的build 流程,PipelineOwner
负责 render tree 的渲染。
@protected
void drawFrame() {
assert(renderView != null);
pipelineOwner.flushLayout();
pipelineOwner.flushCompositingBits();
pipelineOwner.flushPaint();
renderView.compositeFrame(); // this sends the bits to the GPU
pipelineOwner.flushSemantics(); // this also sends the semantics to the OS.
}
复制代码
RenderBinding 的 drawFrame
实际阐明了 render obejct 的渲染流程。即 布局(layout)、绘制(paint)、合成(compositeFrame)
在布局和渲染中,咱们会观察到 Flutter 拥有一个 SchedulerBinding
,在 frame 变化的时候,提供 callback 进行处理。不只提供了帧变化的调度,在 SchedulerBinding
中,也提供了 task 的调度函数。这里咱们就须要了解一下 dart 的异步任务和线程模型。
dart 的单线程模型,因此在 dart 中,没有所谓的主线程和子线程说法。dart 的异步操做采起了 event-looper 模型。
dart 没有线程的概念,可是有一个概念,叫作 isolate, 每一个 isolate 是互相隔离的,不会进行内存的共享。在 main isolate 的 main 函数结束以后,会开始一个个处理 event queue 中的 event。也就是,dart 是先执行完同步代码后,再进行异步代码的执行。因此若是存在很是耗时的任务,咱们能够建立本身的 isolate 去执行。
每个 isolate 中,存在 2 个 event queue
event-looper 执行任务的顺序是
flutter 的异步模型以下图
每个 GUI 都离不开手势/指针的相关事件处理。
在 GestureBiding 中,在 _handlePointerEvent
函数中,PointerDownEvent
事件每处理一次,就会建立一个 HintTest
对象。在 HintTest
中,会存有每次通过的控件节点的 path。
最终咱们也会看到一个 dispatchEvent
函数,进行事件的分发以及 handleEvent
,对事件进行处理。
在根节点的 renderview 中,事件会开始从 hitTest
处理,由于咱们添加了事件的传递路径,因此,时间在通过每一个节点的时候,都会被”处理“。
@override // from HitTestDispatcher
void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
if (hitTestResult == null) {
assert(event is PointerHoverEvent || event is PointerAddedEvent || event is PointerRemovedEvent);
try {
pointerRouter.route(event);
} catch (exception, stack) {
}
return;
}
for (HitTestEntry entry in hitTestResult.path) {
try {
entry.target.handleEvent(event, entry);
} catch (exception, stack) {
}
}
}
复制代码
这里咱们就能够看出来 Flutter 的时间顺序,从根节点开始分发,一直到子节点。同理,时间处理完后,会沿着子节点传到父节点,最终回到 GestureBinding
。 这个顺序其实和 Android 的 View 事件分发 和 浏览器的事件冒泡 是同样的。
经过 GestureDector
这个 Widget, 咱们能够触发和处理各类这样的事件和手势。具体的能够参考 Flutter 文档。
Flutter 在 Widgets 之上,实现了兼容 Andorid/iOS 风格的设计。让APP 在 ui/ue 上有类原生的体验。
根据咱们本身的实践,我从 混合开发、基础库建设和平常的采坑的角度,分享一些咱们的心得体会。
咱们的 APP 主题大部分是 native 开发完成的。为了实践 Flutter,咱们就须要把 Flutter 接入到原生的 APP 里面去。而且能知足以下需求:
咱们的原生架构是多 module 组件化,每一个 module 是一个 git 仓库,使用 google git repo 进行管理。以 Android 工程为例,为了对原生开发没有影响。最瓜熟蒂落的思路就是,提供一个 aar 包。对于 Android 的视角来讲,flutter 其实只是一个 flutterview,那么咱们按照 flutter 的工程结构本身建立一个相应的 module 就行了。
咱们查看 flutter create
建立的flutter project的Andorid的 build.gradle
,能够找到几个关键的地方
app的build.gradle
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
flutter {
source '../..'
}
复制代码
这里制定了 flutter 的gradle,而且制定了 flutter 的source 文件目录。
咱们能够猜想出来,flutter相关的构建和依赖,都是 flutter 的gradle 文件里面帮咱们作的。那么在咱们本身建立的原生 module 内部,也用一样的方式去组织。就能够了。
同时,咱们能够根据本身的实际去制定 flutter 的 source 路径。也经过 repo 将原生的module 和 dart 的lib目录,分红2个git仓库。就完美实现了代码的隔离。对于原生开发来讲,后面的构建打包等持续集成都不会收到 flutter 的影响。
混合工程的架构以下:
在一个 flutter 工程中,咱们通常是使用 flutter run
命令启动一个 flutter 应用。这时候咱们就会有关注到:混合工程中,咱们进入app会先进入原生页面,如何再进入 flutter 页面。那么咱们如何使用热重载和调试功能呢。
热重载
以 Andorid 为例,咱们能够先给 app 进行 ./gradlew assembleDebug
打出一个 apk 包。
而后使用
flutter run --use-application-binary {debug apk path}
复制代码
命令。会启动咱们的原生 app, 进入特定的 flutter 入口页面,命令行会自动出现 flutter 的 hot reload。
混合工程调试
那么咱们如何进行 flutter 工程的调试呢?咱们能够经过给原生的端口和移动设备的 Observatory
端口进行映射。其实这个方法也一样适用于咱们运行了一个纯 flutter 应用,想经过相似 attach 原生进程的方式里面开始断点。
命令行启动app, 出现flutter 的hotreload 后,咱们能够看到
An Observatory debugger and profiler on Android SDK built for x86 is available at:
http://127.0.0.1:54946/
复制代码
这端。这个地址,咱们能够打开一个关于 dart 的性能和运行状况的展现页面。
咱们记录下这个端口 xxxx
而后经过 adb logcat | grep Observatory
查看手机的端口,能够看到以下输出
咱们把最后一个地址输入到手机的浏览器,能够发现手机上也能够打开这个页面
咱们能够理解成这里是作了一次端口映射,设备上的端口记录为 yyyy
在 Android Studio 中,咱们在 run -> Edit Configurations 里面,新建一个 dart remote debug
, 填写 xxxx 端口。
若是不成功,能够手动 forward 一下
adb forward tcp:xxxx tcp:yyyy
复制代码
而后启动这个调试器,就能够进行 dart 的断点调试了。
在 flutter 开发中,咱们须要常用原生的功能,具体的能够参考 官方文档, native 和 flutter 经过传递消息,来实现互相调用。
架构图以下
查看源码,能够看到 flutter 包括 4 中 Channel 类型。
BasicMessageChannel
是发送基本的信息内容的通道MethodChannel
和 OptionalMethodChannel
是发送方法调用的通道EventChannel
是发送事件流 stream
的通道。在 Flutter 的封装中,官方对纯 Flutter 的 library 定义为 Package
, 对调用了原生能力的 libraray 定义为 Plugin
。
官方同时也提供了 Plugin
工程的脚手架。经过 flutter create --org {pkgname} --template=plugin xx
建立一个 Plugin
工程。内部包括三端的 library 代码,也包括了一个 example
目录。里面是一个依赖了此插件的 flutter 应用工程。具体能够参考插件文档
在实践中,咱们能够发现 Plugin 的依赖关系以下。 例如咱们的 Flutter 应用叫 MyApp
, 里面依赖了一个 Plugin
叫作 MyPlugin
。那么,在 Andorid APP 中,库依关系以下图
可是若是咱们在建立插件工程的时候,原生部分代码,不能依赖到插件的原生 aar。这样每次编译的时候就会在 GeneratedPluginRegistrant
这个类中报错,依赖关系就变成了下图
咱们会发现红色虚线部分的依赖在插件工程中是不存在的。
仔细思考一下会发现,其实咱们在 Flutter 应用工程中使用 Plugin
的时候,只是在 pubspec.yaml
中添加了插件的依赖。原生部分是怎么依赖到插件的呢?
经过比较 flutter create xx
(应用工程) 和 flutter create --template=plugin
(插件工程) ,咱们会发如今settings.gradle
中有一些不同。应用工程中,有以下一段自动生成的 gradle 代码
gradle 会去读取一个 .flutter-plugins
文件。从这里面读取到插件的原生工程地址,include 进来并制定了 path。
咱们查看一个 .flutter-plugins
文件:
path_provider=/Users/chenglei/flutter/.pub-cache/hosted/pub.flutter-io.cn/path_provider-1.1.0/
复制代码
咱们也能够大体猜想到,flutter的 gradle 脚本里面会把本身include进来的插件工程所有依赖一遍。
从这个角度,咱们发现插件工程开发仍是有一些规则上的限制的。 从开发的角度看,必须遵循脚手架的规范编写代码。若是依赖其余的插件,必须本身写脚本解决上面的依赖问题。 从维护的角度看,插件工程仍然须要至少一个android 同窗 加一个 iOS 同窗进行维护。
因此咱们在涉及原生的 Flutter 基础库开发中,没有采用原生工程的方式。而是经过独立的 fluter package、独立的android ios module打二进制包的形式。
基于上一小节的结论,咱们开发了本身的一套 flutter 基础设置。咱们的基建大体从下面几个角度出发
咱们封装了 Channel
,开发了一个 DartBridge
框架。负责原生和 Dart 的互相调用。在此之上,咱们开发了网络库、统一跳转库等基础设施
反观 e网通
APP 在 webview 的通讯,是在消息到达另外一端后,经过统一的路由调用格式进行路由调用。对于路由提供方来讲,只识别路由协议,不关心调用端是哪一段。在必定程度上,咱们也能够把统一的路由协议理解为“跨平台”。咱们内部协议的格式是以下形式:
scheme://{"domain":"", "action":"", "params":""}
因此在 Flutter 和原生的通讯中,结合实际业务场景,咱们没有使用 MethodChannel
,而是使用了 BasicMessageChannel
, 经过这一个 channel,发送最基本的路由协议。被调用方收到后,调用各自的路由库,返回调用结果给通道。咱们封装了一套 DartBridge 来进行消息的传递。
经过阅读源码咱们能够发现,Channel 的设计很是的完美。它解耦了消息的编解码方式,在 Codec
对象中,咱们能够进行咱们的自定义编码,例如序列化为 json 对象的 JsonMessageCodec
。
var _dartBridgeChannel = BasicMessageChannel(DART_BRIDGE_CHANNEL,JSONMessageCodec());
复制代码
在实际开发中,咱们可能想要查询消息内容。若是消息的内容是获取原生的内容,例如一个学生的做业总数,咱们但愿在原生提供服务前,不阻塞本身的开发。而且在不修改业务代码的状况下获取到路由的mock数据。因此咱们在路由的内部增长了拦截器和mock服务的功能。在sdk初始化的时候,咱们能够经过对象配置的方式,配置一些对应 domain、action的mock数据。
整个 DartBridge 的架构以下
基于这个架构模型,咱们收到消息后,经过原生路由(例如 ARouter)方案,去进行相应的跳转或者服务调用。
Flutter 提供了本身的http 包。可是集成到原生app的时候,咱们仍然但愿网络这个基础操做的口子能够被统一管理。包括统一的https支持,统一的网络拦截操做,以及可能进行的统一网络监控和调优。因此在Android中,网络库咱们选择调用 OKHttp。
可是考虑到若是有新的业务需求,咱们开发了一个全新的flutter app,也但愿在不更改框架层的代码,就能够直接移植过去,而且脱离原生的请求。
这就意味着网络架构须要把 网络配置
和 网络引擎
解耦开。本着不重复造轮子的原则,咱们发现了一个很是优秀的框架:DIO
DIO 留下了一个 HttpClientAdapter
类,进行网络请求的自定义。
咱们实现了这个类,在 fetch()
函数中,经过 DartBridge
,对原生的网络请求模块进行调用。返回的数据是一个包括:
这些数据,经过 Okhttp 请求能够获取。这里有一个细节问题。在 OkHttp 中,请求到的 bytes是一个 byte[], 直接给到dart 这边,被我强转成了一个List, 由于java 中 byte的范围是 -126 - 127 ,因此这时候,就出现了乱码。
经过对比实际的dart dio请求到的相同的字节流,我发现,byte中的一些数据转换成int的时候发生了溢出,变成了负数,产生了乱码。正好是作一次补码运算,就成了正确的。因此。我在 dart 端,对数据作了一次统一的转化:
nativeBytes = nativeBytes.map((it) {
if (it < 0) {
return it + 256;
} else {
return it;
}
}).toList();
复制代码
关于 utf8 和 byte 具体的编解码过程,咱们不作赘述。感兴趣的同窗能够参考一下这篇文章
在 DartBridge
框架的基础上,咱们对接原生的路由框架封装了咱们本身的统一跳转。目前咱们的架构还比较简单,采用了仍是多容器的架构,在业务上去规避这点。咱们的容器页面其实就是一个 FlutterActivity
,咱们给容器也设置了一个 path,原生在跳转flutter的时候,实际上是跳转到了这个容器页。在容器页中,拿到咱们实际的 Flutter path 和 参数。伪代码以下:
val extra = intent?.extras
extra?.let {
val path = it.getString("flutterPath") ?: ""
val params = HashMap<String, String>()
extra.keySet().forEach { key ->
extra[key]?.let { value ->
params[key] = value.toString()
}
}
path.isNotEmpty().let {
// 参数经过 bridge 告诉flutter的第一个 widget
// 在flutter页面内实现真正的跳转
DartBridge.sendMessage<Boolean>("app", "gotoFlutter",HashMap<String,String>().apply {
put("path", path)
put("params", params)
}, {success->
Log.e("native跳转flutter成功", success.toString())
}, { code, msg->
Log.e("native跳转flutter出错", "code:$code;msg:$msg")
})
}
}
复制代码
那么,业务在原生跳往 Flutter 页面的时候,咱们每次都须要知道容器页面的path吗,很明显是不能这样的。 因此咱们在上面叙述的基础上,抽象了一个 flutter 子路由表。进行单独维护。 业务只须要跳往本身的子路由表内的 path,在 SDK内部,会把实际的path 替换成容器的 path,把路由表 path 和跳转参数总体做为实际的参数。
在 Andorid 中,我提供了一个 pretreatment
函数,在 ARouter
的 PretreatmentService
中调用进行处理。返回最终的路由 path 和 参数。
线上开关
为了保证新技术的稳定,在 Flutter 基础 SDK 中,咱们提供了一个全局开关的配置。这个开关目前仍是高粒度的,控制在进入 Flutter 页面的时候是否跳转容器页。 在开关处理的初始化中,须要提供 2 个参数
线上开关能够和 APP 现有的无线配置中心对接。若是线上出现 Flutter 的质量问题。咱们能够下发配置来控制页面跳转实现降级。
异常收集
在原生开发中,咱们会使用例如 bugly
之类的工具查看线上收集的 crash 异常堆栈。Flutter 咱们应该怎么作呢?在开发阶段,咱们常常会发现 Flutter 出现一个报错页面。 阅读源码,咱们能够发现其实这个错误的显示是一个 Widget:
在 ComponentElement
的 performRebuild
函数中有以下调用
在调用 build 方法 ctach 到异常的时候,会返回显示一个 ErrorWidget
。进一步查看会发现,它的 builder 是一个 static 的函数表达式。
(FlutterErrorDetails details) => ErrorWidget(details.exception)
它的参数最终也返回了一个私有的函数表达式 _debugReportException
最终这里会调用 onError 函数,能够发现它也是一个 static 的函数表达式
那么对于异常捕获,咱们只须要重写下面 2 个函数就能够进行 build 方法中的视图报错
ErrorWidget.builder
ErrorWidget.builder = (details) {
return YourErrorWidget();
};
复制代码
FlutterError.onError
FlutterError.onError = (FlutterErrorDetails details) {
// your log report
};
复制代码
到这一步,咱们进行了视图的异常捕获。在 dart 的异步操做中抛出的异常又该如何捕获呢。查询资料咱们获得以下结论:
在 Flutter 中有一个 Zone
的概念,它表明了当前代码的异步操做的一个独立的环境。Zone 是能够捕获、拦截或修改一些代码行为的
最终,咱们的异常收集代码以下
void main() {
runMyApp();
}
runMyApp() {
ErrorHandler.flutterErrorInit(); // 设置同步的异常处理须要的内容
runZoned(() => runApp(MyApp()), // 在 zone 中执行 MyApp
zoneSpecification: null,
onError: (Object obj, StackTrace stack) {
// Zone 中的统一异常捕获
ErrorHandler.reportError(obj, stack);
});
}
复制代码
在开发初期,咱们就内部商议定下了咱们的 Flutter 开发规范。重点在代码的组织结构和状态管理库。 开发结构咱们考虑到将来有新增多数 Flutter 代码的可能,咱们选择按照业务分模块管理各自的目录。
.
+-- lib
| +-- main.dart
| +-- README.md
| +-- business
| +-- business1
| +-- module1
| +-- business1.dart
| +-- store
| +-- models
| +-- pages
| +-- widgets
| +-- repositories
| +-- common
| +-- ui
| +-- utils
| +--comlib
| +-- router
| +-- network
复制代码
在每一个业务中,根据页面和具体的视图模块,分为了 page
和 widgets
的概念。store
中,咱们会存放相关的状态管理。repositories
中咱们要求业务把各自的逻辑和纯异步操做抽象为独立的一层。每一个业务早期能够维护一个本身的 common, 能够在迭代中不停的抽象本身的 pakcage,并沉淀到最终面向每一个人的 comlib。这样,基本能够保证在迭代中避免你们重复造轮子致使的代码冗余混乱。
在状态管理的技术选型上,咱们调研了包括 Bloc
、'redux和
mobx`。咱们的结论是
flutter-redux
的概念和设计很是的优秀,可是适合统一的全局状态管理,其实和组件的分割又有很大的矛盾。在开源方案中,咱们发现 fish-redux
很好的解决了这个问题。Bloc
的大体思路其实和 redux 有很高的类似度。可是功能仍是不如 redux 多。mobx
,代码简单,上手快。基本上搞清楚 Observables
、Actions
和Reactions
几个概念就能够愉快的开发。最终处于上手成本和代码复杂度的考虑,咱们选择了 mobx 做为咱们的状态管理组件。
到这里,我分享了一些 Flutter 的原理和咱们的一些实践。但愿能和一些正在研究 Flutter 的同窗进行交流和学习。咱们的 Flutter 在基础设施开发的同时,还剥离编写了一些 升学e网通
APP 上的页面和一些基础的 ui 组件库。在将来咱们会尝试在一些老的页面中,上线 Flutter 版本。而且研究更好的基础库、异常收集平台、工具链优化和单容器相关的内容。