cover_image

学而思网校1v1家长端在Flutter中的实践

网校移动研发团队 学而思网校技术团队
2019年11月08日 11:30

引言


“一次编写,到处运行”,这是 SUN 公司用来展示 Java 语言跨平台特性的 slogan,它意味着 Java 可以在任何平台上进行开发,编译后就可以在任何安装有 Java 虚拟机(JVM)的设备上运行。美中不足的是,Java 虚拟机在各种操作系统(Windows, Linux, macOS, ...)上有多种不同的实现,导致 Java 应用程序在不同的虚拟机和操作系统上执行时有微妙的差别,因此它可能需要在许多平台上进行充分地测试才能确保正确性和稳定性,这造就了一个坊间笑话:“一次编译,到处调试”(Write Once, Debug Everywhere)—— 摘自维基百科

最近这两年,在移动端各种跨平台的开发方案如雨后春笋般涌现,一方面是因为随着移动互联网的普及和快速发展,移动终端设备的软硬件、操作系统、开发工具链和技术社区等日趋成熟完善;另一方面,近几年传统 PC 端的技术、资源也逐步迁移到移动端上来,大家都想造轮子,然后“一统天下”。—— 摘自掘金


在移动技术领域随着移动互联网的发展,“跨平台”的技术也有了迅猛的发展

  • 从早先的基于浏览器内核的hybridApp方式 PhoneGap, Cordova

  • RN , Weex方案

  • 基于web体系的 Progressive Web App

  • 依赖超级app的小程序方案

  • 越来越火的Flutter

  • 当然还有很多在这场技术竞赛中没落可能都很少有人知道的如:Xamarin ,Ionic ......

跨平台所带来的优劣势在这里不赘述,因为他不是本文的要点,各种框架的优劣之处本文也不进行分析。
本文主要想通过引言让不太知道跨平台技术的小伙伴有一个简单的了解, 随着今年来Flutter的火热,社区的完善,各种大厂的跟进,以及国内对Flutter项目的贡献和支持,开发环境越来越好,在加之Flutter在跨平台上带来的新的框架级的支持。下面我们来看一张图:

图片

(图片来源:闲鱼分享)

Flutter基于底层Engine中的Skia库搭建起一套自渲染引擎,不需要在基于web内容的渲染,在性能上做到了媲美原生,Flutter使用的语言是dart,而dart语言本身就是跨平台语言,也正因为dartvm的特性,所以Flutter天生支持 AOT, JIT 两种编译模式,可以使我们在开发中有更好的体验,在release的运行时有更高的性能。
虽然现在Flutter如火如荼,但是很多团队依然处在观望中,更多的只是写写demo很少在工程中使用,只有心动是不够的,我们更重要的是行动,下面主要介绍一下,学而思网校的移动端团队是如何在工程中进行实践,也希望可以给观望的小伙伴一些经验,当然由于我们在Flutter上还经验尚浅,也希望各个团队小伙伴能够给我们指正和探讨。让我们一起来完善跨平台的技术栈。
开篇

对于学而思网校1v1家长端的开发过程中时间紧人员不足成了我们的瓶颈,我们也在一直关注者跨平台领域,所以我们在充分调研的基础上决定采用flutter技术做一次新的技术尝试。
Flutter在项目中的位置

从单纯的项目角度来说,这是一个全新的工程,所以我们决定基于Flutter工程来创建,但是对于整个移动端的技术积累来说,此次还要使用我们已经开发的 存储,Hybrid,网络,路由等库。那么从顶层设计上来说,我们的整体框架如下:

图片

在整个app的架构中我们主要做的工作都在橙色部分,Flutter承载了UI跨平台的主要功能,这也是Flutter框架本身的能力, 但是我们会发现,无论是新工程,还有已经存在的工程,我们都不可避免的存在混合开发的视图以及功能。无论是一个全新的flutter工程,还是现有的工程想部分转换为Flutter,都有以下的几个必经之路要做。

Flutter框架实战

一、工程化

在前期的工程化设计中,参考了我们现有的项目结构,目前我们的项目结构中 Android使用的是MVP结构,iOS 也在进行MVVM的改造。主要核心还是基于UI和逻辑层分离,但是还要有数据驱动UI视图变更的能力。而 Flutter 本身就是这样的一个设计。所以我们核心是设定好工程规范。我们制定的工程规范如下:

  • 工程代码管理

图片

  • Api目录

考虑到我们后续的工程不一定是主工程,很有可能是一个功能库,就如同组件开发中的一个独立组件,那么他所提供的对外接口我们希望统一管理维护,而主App就不存在这样的目录了。
  • Channels目录

还记得上图的应用框架图么?其中跨平台的通信层中与底层通用能力相关的部分都在这里,这里主要是基于Flutter的 Channel特性,封装了通用的各个模块都可能使用的通信交互实现。
比如:Push能力,长连接,网络,路由,存储
这些内容基本都需要与我们现有的由Native编写的功能,通过Channel通信来进行交互实现, 而这些能力,是每一个模块都会使用的,所以利用channels目录做统一管理。
  • Bases , Modules目录

Bases, Modules 这两个目录就进入到了我们业务目录的正题,业务目录都有统一的结构。
model, logic, widgets 基本遵循类 mvvm 模式,为什么是类MVVM而不是MVVM呢?是因为widget本身的特性里存在着 State这样负责绑定view并且更新的机制和能力, 所以不需要专门的vm来做这个事情,而取而代之的是,相应的 logic 里面的逻辑层。widgets制作UI描述。 
Bases与modules有一点点的不同,不同点就在于config目录 和 index.dart 的存在,因为Base里面放的是我们整个应用框架的基类, 其中的 config 主要在存放我们全局的配置,图片、String 等资源定义的管理目录不涉及到真正的业务场景。
而真正业务场景的实现我们都存放在modules中,每一个业务场景都会涉及到跳转,而我们的index.dart就担负起了入口页面的基础描述,以及路由跳转的能力。关于路由的部分会在后面做详细说明。
  • 目录的可选性可选目录是根据不同的场景需求在 “类mvvm” 架构上的松约束。

比如:我们最常见的 About ,launcher , 或者简单业务逻辑中, 就不需要严苛的将所有目录都创建,避免目录的冗余。工程化的主要目的,还是在于全员理解易于查找和协作,为后续自动化奠定基础。

2、资源代码管理

图片


Flutter的图片资源本身是文件目录的管理,目前并没有提供很好的资源管理的方式,所以我们就根据业务按照目录来区分,在业务目录中,区分这个页面所需要的2倍,3倍图。
Flutter读取图片的方式目前也不是特别友好,他不会像Android那样编译成常量, 也不会像,iOS那样提供 @"imagename" 这样简便的写法,而是要通过下面这种形式获取:
Image.asset("resources/common/bg_image_placeholder.png")

3、依赖库管理

Flutter侧 Dart提供了Pub库管理机制
Android 使用Gradle 本质上还是 maven
iOS 使用 Pod库管理
由于篇幅的原因,这里一些工程化的问题不展开讲
比如:字体,图片,配置文件 等资源有的特定图片还是有一些平台兼容性的问题 需要将个别的图片资源放到native侧去进行适配,由于篇幅的关系,在这里就不进行详细的展开说明了,因为针对于资源的管理完全可以单独的开一篇进行说明,不过目前网上的资料已经非常齐全了,大家完全可以通过google得到很多的答案。
依赖管理,这里也不讲如何搭建私服pub。以及 android ,ios, flutter 之间的依赖管理。网上均有详细的文章,我们希望多留一些篇幅给后面的内容。

二、UI层混合开发

在实际的开发过程中,虽然我们是纯粹的flutter工程,但是由于三方库,以及我们已有业务的共用模块的影响,我们的部分UI页面难免还是要使用Native实现的能力,而且Flutter目前对音视频的支持不是很好,涉及到这方面的页面我们通常会使用Native页面来进行实现,还有我们自己的Hybrid框架也是基于Native实现的。那么这就涉及到了一个必须攻克的难题,就是在混合开发由于Flutter试图栈与Native的视图栈不一致导致的“视图混合栈”的问题。
  • 一个场景

在我们实际,开发的过程中,会遇到以下这样的交互场景
Flutter Page1 -> Native Page1 -> Flutter Page2 -> Native Page1 -> Flutter Page1
这种场景下会产生什么样的问题呢?

图片

我们看到上面这张图,很抱歉截图的时候,内容有点显示变形,但是我们看重点。
  • 第一张图:是由Native侧实现的Hybrid界面, 在这个界面里有一个功能,就是点赞, 当点击点赞功能的时候,会判断是否登录,如果没有登录则跳转到登录页面

  • 第二张图:而第二张图就是使用 Flutter 开发的纯 Flutter UI 的界面

  • 第三张图:在跳转切换的这个过程中,混合栈的问题就暴露出来了。

    • 我们观察到 第三张图的两个页面的位置,flutter 页面在转场的过程中,其实是被Native的视图栈压在上面的。

  • 第四张图:就是我们解决了该问题后的效果。视图栈的顺序和转场动画都正常了。

实际上这个问题的背后影响是下面的这张图

图片


  • 解决这个问题,有两种方案,第一种是以官方为代表的,当另外一个native页面启动flutter页面时,重新的创建引擎,来解决,以ios为例,当native要切换回flutter页面时,只需要在使用FlutterViewController重新创建engine实例,显示flutter页面即可。

    • 优点:操作非常的简单,页面隔离性很好

    • 缺点:页面数据复用非常困难,因为engine的内存是无法共享的, 也正因为内存无法共享创建多个engine将会带来更大的内存消耗

  • 第二种方案:以闲鱼为代表的,共享engine的解决方案

    • 优点:最大的有点是engine的引擎共享,相比第一种方案,启动速度会更加的快速。因为engine的创建还是有些耗时的。并且各个页面之间可以共享engine存储的数据。

    • 缺点:每个flutter page的都有一个Native的ViewController或者Activity容器去承载,对于纯flutter工程来说还是有比较大的工程调整的成本

对于我们来说,对内存和性能还是有很高追求的,但是我们的工程是Flutter工程为主,native为辅,与闲鱼有所不同,所以我们在第二种方案的思路上进行了一些改造。如下图:

图片

  • 第三种方案: 通过消息驱动,在Native侧进行试图栈间的切换, 转场则使用截图方式进行动画补偿。

    • 我们通过统一跳转入口,将 native 与 flutter 相互跳转的入口统一,基于flutter 提供的channel特性做native与flutter的交互通信,利用Stack Manager来进行栈的调配从而解决混合栈的问题。而channel本身的设计是基于底层native侧类似jni的方式进行交互的,所以性能也不受影响。在基于本身我们在 Native侧也有 路由的设计,所以我们将原有的路由方案做了一下升级,兼容了flutter从而也统一了我们native,flutter相互之间的跳转, 在跳转入口中,我们利用open url的方式来进行入口的规范和行为统一,基于url的特性也具备了不错的兼容和扩展性。 后面会讲到。

    • 优点:因为我们并没有重新启动engine 所以 通过native开启的flutter页面,还是在同一个engine的基础上,满足了第二种方案的性能和数据同步的要求, 又因为对纯flutter项目为主的工程来说几乎没有迁移成本。

    • 缺点:不适合以Native为主的工程,目前在深层级的交互创建页面中还需要优化(还在优化中)。

混合栈的问题,相对合理和较好的方案,还是要通过修改 engine中的共享方式,使engine以单例的形式存在,FlutterNativeView进行多实例的创建,来达到共享内存前提下无缝切换native,flutter页面的效果。 目前我们也在做这方面的尝试,但是需要修改flutter的framework,并且官方目前也没有明确的表态这种情况使用这样的方式解决是否可行,所以我们在调研的同时也在关注彻底解决的更合理的办法。

三、路由

在原生的Native侧,我们是存在一套路由框架的,而且该原生框架, android , ios 逻辑统一并且功能上,我们已经支持:

1、内外部app之间的跳转能力

2、具备 Open URL 标准

3、支持页面拦截功能

4、其他一些安全以及业务层的封装

但是flutter有自带的router框架,经过我们的调研和实际的使用有如下的一些注意点:

在Flutter开发的页面间实现跳转的话,一切都离不开Navigator,系统提供了Navigator管理界面跳转、传参、返回等操作,但是使用起来不太方便,比如下图所示:router的注册需添加到routers中,界面跳转的话需要引入目的页的类,获取当前context,在进行跳转,获取也是一样,都比较复杂。

图片

总结下来系统的router在我们的项目中存在如下问题:

1、使用不友好,并且与页面的耦合性较强

2、不支持无context的路由跳转

3、我们现有的路由扩展机制无法在flutter侧进行扩展

4、路由协议无法遵循我们现有的路由协议

5、Native Router与Flutter Router要分开管理

基于以上的一些原因,我们决定封装一套既可以满足flutter使用的路由,又兼容目前我们原生路由协议。所以我们在原有的native基础上做了基于flutter的跨平台统一路由:DRouter 基本流程如下图:

图片

DRouter 统一了 android , ios 在路由接口上的平台差异,并且兼容了Native路由的能力,在Native 与 Flutter 侧都是用统一的api 抹平了全平台差异,也使开发人员不用关心路由对应的页面到底是flutter 还是 hybrid 或是native 这些处理都有DRouter统一管理了。由于篇幅内容原因,针对于DRouter后续会发一份更详细的文章进行说明。

四、通信机制

在DRouter的实现中,很重要的一部分功能实现是来自Flutter提供的与Native交互的通信机制Channel, 基于这种机制,我们的通用底层能力,比如存储,权限等功能都是基于这种通信方式来实现的, 下面就简单介绍一下Channel。
Channel分为 BasicMessageChannel , MethodChannel , EventChannel
这三种的Channel功能已经满足了我们日常工作中基本所有内容。

下面简单介绍一下

图片


图片

由于Native与dart之间的方法调用与消息传递是基于framework中jni部分进行底层的方法调用,这样处理就在通信上来说几乎跟原生调用是一样的。所以几乎所有的跨平台通信手段都是基于这种机制来进行实现的。
五、Crash监控
在使用 Android 和 iOS来开发应用时, 都有异常、堆栈溢出(OOM)、Error、ANR等情况会引起APP的Crash。因为在Java和Objective-C中,如果程序发生异常且没有被捕获,那么程序将会终止,但是这在Dart或JavaScript中则不会!究其原因,这和它们的运行机制有关系。Java和OC都是多线程模型的编程语言,任意一个线程触发异常且该异常未被捕获时,就会导致整个进程退出。但Dart和JavaScript不会,它们都是单线程模型,下面我们通过Dart官方提供的一张图来看看Dart大致运行原理:

图片

从上图中可以看出,Dart 在单线程中是以消息循环机制来运行的,其中包含两个任务队列,一个是 “微任务队列” microtask queue,另一个叫做“事件队列” event queue微任务队列的执行优先级高于事件队列。
外部事件任务都在事件队列中,如IO、计时器、点击、以及绘制事件等,而微任务通常来源于Dart内部,微任务较少、快,由于微任务队列优先级高,如果微任务太多,执行时间总和就越久,事件队列任务的延迟也就越久,对于GUI应用来说就会卡顿,所以需要微任务队列少、快。在事件循环中,当某个任务发生异常而没有被捕获时,程序并不会退出,当前任务的后续代码就不会再继续,也就是说,单个任务的异常不会影响其它任务执行。
Flutter异常捕获
在JAVA中,通常都是通过try/catch/finally来捕获代码块异常,其实在Dart中也是,我们看下Flutter系统中的异常捕获:源码(framework.dart)
abstract class ComponentElement extends Element {  ComponentElement(Widget widget) : super(widget);
Element _child;
@override void performRebuild() { if (!kReleaseMode && debugProfileBuildsEnabled) Timeline.startSync('${widget.runtimeType}', arguments: timelineWhitelistArguments);
assert(_debugSetAllowIgnoredCallsToMarkNeedsBuild(true)); Widget built; try { // 构建页面 built = build(); debugWidgetBuilderValue(widget, built); } catch (e, stack) { // 构建错误页面并上报 built = ErrorWidget.builder(_debugReportException(ErrorDescription('building $this'), e, stack)); } finally { _dirty = false; assert(_debugSetAllowIgnoredCallsToMarkNeedsBuild(false)); } try { _child = updateChild(_child, built, slot); assert(_child != null); } catch (e, stack) { built = ErrorWidget.builder(_debugReportException(ErrorDescription('building $this'), e, stack)); _child = updateChild(null, built, slot); }
if (!kReleaseMode && debugProfileBuildsEnabled) Timeline.finishSync(); }
@protected Widget build();
}
通过以上 framework.dart 代码可以看到,Element 是 Flutter 的核心,所有的 Wighet 是通过 Element树来渲染的,其中performRebuild()是 Wighet 是渲染的核心函数,在调用 Wighet 的 build() 控件时就使用try/catch/finally来捕获控件构建。从上面可以看出 Flutter 系统已经处理了异常捕获,并不会引起程序退出。

异常页面

再看看Catch住异常后,我们可以看到built = ErrorWidget.builder(_debugReportException(ErrorDescription('building $this'), e, stack)); 似乎构建了一个新的 Widght 给 built 对象绘制到页面,继续跟踪: 源码(framework.dart)
class ErrorWidget extends LeafRenderObjectWidget {
ErrorWidget(Object exception) : message = _stringify(exception), _flutterError = exception is FlutterError ? exception : null, super(key: UniqueKey()); // 默认错误页面 static ErrorWidgetBuilder builder = (FlutterErrorDetails details) => ErrorWidget(details.exception);
...}
我们看到这里调用了一个 static 的静态函数体,并且返回的是一个提示异常信息 Widget,既然如此,我们是否可以替换这个 Widget呢?答案是可以的,在你的 main.dart文件中的 main()方法里,代码如下:
Future main() async {  runApp(App());
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails) { // 替换错误提示页面 return ErrorPage(); };}
异常采集 但这并不能满足我们的需求,我们需要采集或者上报异常,方法有四种:
方法一、 在你的 main.dart 文件中的 main() 方法里,替换错误页面时进行采集,代码如下:
Future main() async {  runApp(App());
ErrorWidget.builder = (FlutterErrorDetails flutterErrorDetails) { // 上报异常 --> flutterErrorDetails reportError(flutterErrorDetails.toString()); // 替换错误提示页面 return ErrorPage(); };}

方法二、 有的同学可能还有印象,前面在构建默认页面时是使用_debugReportException() 函数进行封装FlutterErrorDetails对象,看源码: 源码(framework.dart)

// 默认上报FlutterErrorDetails _debugReportException(  DiagnosticsNode context,  dynamic exception,  StackTrace stack, {  InformationCollector informationCollector,}) {  final FlutterErrorDetails details = FlutterErrorDetails(    exception: exception,    stack: stack,    library: 'widgets library',    context: context,    informationCollector: informationCollector,  );  // 默认上报异常  FlutterError.reportError(details);  return details;}

在封装源码过程中调用了 FlutterError.reportError(), 再跟: 源码(assertions.dart)

class FlutterError extends Error with DiagnosticableTreeMixin implements AssertionError {  static void reportError(FlutterErrorDetails details) {    assert(details != null);    assert(details.exception != null);    if (onError != null)      onError(details);  }
static FlutterExceptionHandler onError = dumpErrorToConsole;}

与错误页面一样,最终指向了onError函数体,那么同理,我们也可以替换该上报函数,在main.dart 中main() 替换:

Future main() async {  runApp(App());
FlutterError.onError = (FlutterErrorDetails details) { // 上报异常 --> flutterErrorDetails reportError(flutterErrorDetails.toString()); };}

方法三、 用 Dart 中的 runZoned()的方法,指定一个代码执行的环境空间,在这里可以捕获所有产生的异常问题( Zone 具体请查阅官方资料,此处不再讲解)。在main.dart 中main() 替换:

Future main() async {   runZoned(() {       runApp(MyApp());   }, onError: (Object obj, StackTrace stack) {       reportError(obj.toString());   });}

另外需要注意的点:flutter中所产生的错误信息,并不属于崩溃。所以在处理crash信息上,要以错误信息来处理。而不是crash, 但是我们可以通过异常信息的规范制定,来设定自己的crash规则。进而监控crash率。

Flutter未来的想象

学而思网校的移动团队在这次的实战中体验到了跨平台技术给我们带来的好处,同时也有很多还需要继续完善的地方,在本次的文章中我们没有进行的详细性能上的展示,是因为目前我们与原生的性能对比并不是很严谨,另外性能的文章有很多内容需要单独来详细说明, 后续我们会陆续发出来,
在此次实战中通过对Flutter的了解,在跨平台领域也看到了很多大厂对未来该方向的应用的场景。也非常感谢闲鱼团队在flutter技术中的贡献,也给我们带来了很多启发
闲鱼属于行业内通过Flutter打响了自己的技术品牌,更主要的是通过Dart语言的特性与阿里内部的Faas开发服务的结合,升级了整个移动端的开发流程,将研发体系做了更深的升级 他们命名为 “云端一体化研发模式”,这个模式是什么呢?简单来讲,就是通过平台技术,让开发具备全栈的能力。

图片

(图片来源:淘宝技术分享

给我们带来的启发


随着服务中台的建立,我们也会走领域服务下沉,增加全平台的扩展性和复用性,从Dart语言本身来讲他有端到端的能力, client <-> server 这将是很不错的一个技术方向。另外一点可能是端上最关心的动态化,目前随着Android端系统的升级,基于classLoader和hook方式的动态插件化方式将需要更多专业的人员来维护版本的兼容,从系统生态的态度来讲原生的动态化的路从我的角度看来将会越来越困难。而flutter的思路将给我们带来全新的一种动态化方式的方案,通过dsl描述转为dart利用flutter渲染引擎进行渲染,将动态化的问题从native本身转化为了一套解析渲染框架的问题上这也会极大的降低对系统依赖维护的成本。而这种方式在游戏中是很常见的。
我相信很快基于flutter渲染方式的 小程序体系将会推出。性能,体验将会跟flutter本身一样真正的做到与原生的媲美, 这将会是小程序的另一种方式,目前已经有大厂的团队在进行尝试了。随着我们团队更加深入的理解,这也将是我们从hybrid方式过渡到小程序的一个新的技术课题。
如果我们全平台的移动端技术体系统一,那么我们将从技术手段中能够更好的进行业务融合。

学而思网校 - 移动端团队


我们团队非常喜欢跨平台这个技术方向 我们团队是一群年轻有想法的小伙伴,Flutter也很年轻,而跨平台的方案也不仅仅只有Flutter我们希望可以有更多的小伙伴跟我们一起学习讨论。共同成长。

▼往期精彩回顾▼
Nginx源码完结篇--进程工作模式
Nginx源码--配置文件解析
Nginx源码--启动流程概述

图片



继续滑动看下一个
学而思网校技术团队
向上滑动看下一个