长期以来,移动端的开发都需要为相同的产品逻辑实现两套代码。在大多数情况下,这两套代码所描述的逻辑基本是一致的,只是用不同的编程语言在阐述,为的是部署到不同的平台上。这种模式会产生以下问题:

  • 首先,显而易见的是造成人力资源的浪费。大量可复用的逻辑没有复用,本来可以释放出来做深度探索的人力,浪费在重复的UI和基本逻辑的开发中。

  • UI上的不一致。在当前工作流中,设计图往往只有一套,并不区分iOS和Android。而在某些时候,因为要兼顾到平台特性和不同平台的实现成本,这相同的UI设计在两端展现就有可能不完全一致。

  • 测试压力增大。测试同学在测试代码的时候不仅仅要考虑兼容性,由于iOS和Android代码由不同人,不同语言在不同平台上实现,所以相同的逻辑在iOS上没有问题,在Android上就可能出错。所以经常看到测试同学用两种手机对比测试,找出不同,从而消耗大量精力。

  • 改进成本增大。首先,也是显而易见的,两倍人力,两套代码,坏味道以更快的速度产生;其次,也是更重要的,那些依赖于UI结构的工程改进,需要更大的成本来实施,就比如无埋点方案(也即全埋点方案)。无埋点简单来说就是将所有页面的PV和Action进行过滤并上报,而在具体业务中不做侵入性编程。其中有一个点就是标记某页面在整个UI树状结构中的唯一ID,当然iOS和Android由于不同的平台结构,不同的代码实现,其同一页面所处的位置就有可能不同,统一编码上报的无埋点方案就需要更多的实现成本。

基于但不仅限于以上问题,我们团队开始了跨平台的探索之旅。从基于Web的跨平台方案,比如Cordova,PhoneGap,一直到React Native,这些方案让我们看到了很多令人兴奋的进步,但基本都存在一个共同的问题,就是在UI渲染的性能上无法媲美原生。直到Flutter的推出!

Flutter是Google推出的一款UI工具包,可以通过一套代码同时在iOS和Android上构建媲美原生体验的精美应用。它使用Dart作为开发语言,不依赖原生控件,而是将自有的控件库,通过Skia图形引擎直接绘制在平台所提供的画布上。简单来说,它拥有以下特性:不依赖平台、组件库原生实现、能高速渲染复杂页面、拥有统一的CodeBase。有点像App领域的Unity引擎,或者叫专注于2D渲染的UI引擎。

下面我们在简单介绍跨平台方案的演进历史后,着重介绍我们团队在Flutter工程实践上的一些心得,以及遇到的问题和解决方案。

1.跨平台方案的演进历史

苹果的iOS-SDK发布于2008年,谷歌的Android软件开发工具包发布于2009年,这两种工具包基于不同的编程语言进行开发,厂商的商业竞争导致无法使用一个CodeBase来开发移动应用,基于开篇所述的种种原因,业界开始了对跨平台技术的探索。

1.1 基于WebView的实现方案,如Cordova

WebView是我们经常使用的,适应性极强,像Cordova这种方案也是基于WebView的封装。但这种方案的缺点在于UI完全由Web技术绘制,具有局限性,并且在性能上也不如原生代码。在这种方案中,JS为了获取本地的服务资源,如调用摄像头,GPS等,就需要通过JSBridge和本地代码进行通信。但由于次数较少,通信导致的性能下降也没有很明显。

1.2 React Native

从2015年,React Native一直火到现在,很多大厂陆续跟进。这个方案的优点在于使用平台所提供的原生组件进行绘制,在App和平台之间建立完整的通信桥梁。其实JS代码和原生代码的执行速度是很快的,这个方案的问题在于Bridge。JS访问原生的UI组件需要经过“桥接器”(图上的Bridge),当大量UI控件高速刷新的时候,就有可能导致性能问题。

1.3 Flutter的设计思想

随着跨平台技术的不断演进,Google提出了Flutter框架。

为了解决对原生控件的依赖,Flutter系统框架使用它自己的UI库,截止2018.12.4发布的1.0-Release版本,Flutter团队和社区用Dart语言写了大概200万行的代码,而里面绝大多数都是UI组件,叫Widget。这些控件最终都会被编译成对应平台的机器代码,使用Skia引擎绘制到原生提供的画布上(图中的Canvas),而Skia就是Android和Chrome的图形引擎,已经在工程上进行过大量的实践了。

这样就导致UI的绘制过程不需要经过桥接器,而是直接绘制到画布上,解决了RN存在的复杂页面渲染性能下降问题。并提供Platform Channels来访问平台硬件,比如摄像头,蓝牙和定位等服务

2.flutter的移动端跨平台应用实践

Flutter有很多优秀的特性,并且这些特性都是为了提高开发者的效率而量身定做。但不可否认,和iOS或者Android相比,Flutter还只是一个Baby,有很多不成熟的地方;另外,在编程模式方面和原生端也有不小的差异,需要一定的学习成本。

2.1 UI组件使用上的不适

首先是在UI组件使用上的不适。当我们开始写前端界面的时候,最最基本的一点是就是控件和布局。首先,Flutter中一切都是Widget,你可以理解为组件,我就不翻译了。除了我们常见的Label,ImageView,TextView,List这类的UI控件,动画,手势,布局Layout都是Widget。他们用一种神奇的child赋值方式嵌套起来。先看一段代码:

Container( constraints: BoxConstraints.expand(   height: Theme.of(context).textTheme.display1.fontSize * 1.1 + 200.0, ), padding: const EdgeInsets.all(8.0), color: Colors.teal.shade700, alignment: Alignment.center, foregroundDecoration: BoxDecoration(   image: DecorationImage(     image: NetworkImage('https://www.example.com/images/frame.png'),     centerSlice: Rect.fromLTRB(270.0, 180.0, 1360.0, 730.0),   ), ), transform: Matrix4.rotationZ(0.1), child: Text('Hello World'),)

Container就是一个布局控件,或者叫Layout Widget,其实就是一个可以设置宽高,颜色等等属性的盒子。整个Flutter的布局都可以想象成盒子套盒子的形式。而嵌套的形式,不像我们在iOS中将某个控件通过addSubview的方式添加到父控件上,而是直接将子控件设置为父控件的child

这就导致一个视觉上的问题,就是每一个child都要向后缩进,当一个个控件,布局嵌套起来的时候,处于UI树底层的元素,在开始的时候可能就已经距开头50个字符了,看上去很乱,也难以维护。

Widget build(BuildContext context) {   final Widget row = new GestureDetector(     behavior: HitTestBehavior.opaque, // 枚举值,等下改一个看看     child: new SafeArea(       top: false,       bottom: false,       child: new Padding(         padding: const EdgeInsets.only(           left: 16.0, right: 8.0, top: 8.0, bottom: 8.0),         child: new Row(           children: [             new Expanded(               child: new Padding(                 padding: const EdgeInsets...,                 child: new Column(                   crossAxisAlignment: CrossAxisAlignment.start,                   children: [                     const Text(                       'Buy this cool color',                       style: const TextStyle(                         color: const Color(0xFF8E8E93),                       ),                     ),                   ],                 ),               ),             ),           ],         ),       ),     ),   ); }

就像上面的效果,看上去确实很不优雅。其实如果真的写成这样,就很明显有坏代码的味道,也提醒我们需要进行重构。另一方面,这种组织结构很好的反映了UI树状结构的本质,在一定程度上倒逼我们对通用控件做封装,并让函数保持功能单一。

目前支持Flutter编程的IDE,如Android Studio也添加了快捷功能,让开发者方便的生成布局之类的模版代码,也支持在已有的Widget外层添加布局,并自动缩进。

2.2 数据传递和工程组织架构

Widget有了之后,我们就需要考虑数据怎么和组件交互,以及数据怎么在组件之间传递的问题了。

我们在iOS或者Android端刷新一个UI控件的基本方法就是在从接口获得数据之后,使用命令的方式,给对应组件赋值,比如更换title,颜色什么的。而Flutter从React中借鉴了大量的灵感,其中就包括响应式视图。

响应式视图中的一个重要概念就是虚拟DOM,虚拟DOM在Web视图中代表HTML文档对象模型。JavaScript用DOM提供的API来操作HTML文档。而虚拟DOM使用JS来操作DOM的抽象版本。在响应视图中,虚拟DOM是不可变的,在数据更新以后,会通过算法比较,更新虚拟DOM树,然后以最小的成本重新绘制界面。

React Native 也做类似的工作,但是是在移动应用程序当中进行的。它会操控移动平台上的原生组件而不是DOM。它构建一个UI组件的虚拟树,与原生组件进行比较,并只更新已发生更改的组件。

React Native必须通过桥接器与原生部件进行通信,因此,UI组件的虚拟树机制,可以保证需要通过桥传递的数据最小,同时还允许使用原生组件。最后,一旦更新了本地组件,平台就会将它们渲染到画布上。

Flutter中,也很相似,只不过虚拟DOM现在是真实的控件树。

下面是一个最基本的示例,来阐述响应式编程的基本思路。

counter作为存储点击次数的变量,只有在setState中改变,按钮绑定incrementCounte方法,UI控件Text绑定counter变量,这样每次点击一下按钮,都调用一次setState,从而使counter值加一,但是并不需要用命令式的方法手动刷新UI控件,setState后整个build方法会被重新执行,从而导致Text控件上的显示加一。

class _MyHomePageState extends State { int _counter = 0; void _incrementCounter() {   setState(() {     _counter++;   }); } @override Widget build(BuildContext context) {   return new Scaffold(     appBar: new AppBar(       title: new Text(widget.title),     ),     body: new Center(       child: new Column(         mainAxisAlignment: MainAxisAlignment.center,         children: [           new Text(             'You have pushed the button this many times:',           ),           new Text(             '$_counter',             style: Theme.of(context).textTheme.display1,           ),         ],       ),     ),     floatingActionButton: new FloatingActionButton(       onPressed: _incrementCounter,       tooltip: 'Increment',       child: new Icon(Icons.add),     ), // This trailing comma makes auto-formatting nicer for build methods.   ); }}

这种思路有一个大好处,就是数据绑定UI,只要绑定正确,React机制正确,数据的变换就是UI的变换,或者说数据就是UI的抽象。这样在测试的时候,我们只要测试数据的正确性,就能在更大程度上保证UI界面的正确,更大程度上减少集成测试的压力,提升单元测试的作用;另外也可以尽量避免在编写或者重构代码的过程中,因为缺少了某行刷新命令而导致的UI没有正常初始化或者更新。

Flutter自有的响应式架构已经非常系统了,但其中有一个点需要注意。

当我们需要在组件之间传递数据的时候,React设计的结构需要我们先将数据传递到父节点上,再由目标节点从父节点取这个数据。简单来说,就比如在UI树上,F节点有A,B两个子节点,那A要把一个值传给B上显示,就需要先传递给F,再由B绑定F上的这个数据来获得更新。这种模式叫做Lifting State Up,就是举高高。这种模式简单轻量,但是随着应用复杂度的提高,我们常常需要处理很多来源的数据,比如从各种接口读取的,从本地数据库读取的等等,场景诸如我们遇到的主页多tab,feed流拼接等;另外我们有时还需要保存很多全局状态,在这些状态改变之后,很多页面都需要刷新,比如是否login,主题颜色更改甚至是国际化变更语言。

为了处理复杂多页面数据管理的问题,我们找到了React的伙伴,Redux。Redux已经比较成熟了,资料众多,也是我们选择它的原因之一。Redeux有三个核心设计原则:

Single source of truth:单一数据源。整个app的state都以object tree的形式存在一个store里,就像一个大数据中心,可以在app中的任意位置访问数据,绑定监听。这样在传统编程环境中很难实现的功能,比如撤销/重做这种就很容易实现。

State is read-only:第二个设计原则为,state是只读的,也就是说state不可以直接修改,所有修改行为必须通过发送Action。因为所有State的改变都是集中且按照严格顺序发生的,所以也没有竞态条件。并且Action就是普通的Dart类,所以他们可以被记录,被序列化,被存储起来,这就使得我们可以在崩溃之前把程序最后的状态报告上来,方便调试,甚至直接复现一下。

Changes are made with pure functions:上面提到的Action只是定义一个行为,Reducer才是执行者。第三个设计原则就是,Reducer是纯函数,所谓纯函数就是没有副作用(Side Effect),只进行计算或者调用其他纯函数。他接收一个Action和一个旧的State,然后返回新的State。Reducer可以根据项目的大小分层级,App启动的时候添加一个Reducer,之后可以拆分成很多小的Reducer来管理State树的各个模块。

通过Flutter+Redux我们解决了数据在页面流转刷新的问题。好多作者用一整本书来讲Redux,我就不再赘述,欢迎小伙伴指正交流。

2.3 无反射的Json序列化

我自己是iOS开发,在iOS开发中我们使用反射机制来进行Json解析,而在Flutter中是不行的。

Flutter的Json解析使用dart:convert中内置的解码器,只要传入JSON的原始字符串给JSON.decode()方法,然后从返回的Map中取你要的数据就行了。当需要用Map生成Model对象的时候,需要在Model中添加fromJson方法,通过调用方法实现来逐一取出Map中的值填充到对象中。

class FeedModel { List imglist; int commentnum; FeedModel(this.imglist, this.commentnum)  FeedModel.fromJson(Map json)     : imglist = json['imglist'],       commentnum = json['commentnum'];}

在大型工程中,这种方式就略显笨拙,Flutter有一个工具来批量生成fromJson方法。需要我们使用一个叫json_annotation的库,并在yaml文件中引入以下配置:

dependencies: flutter:   sdk: flutter json_annotation: ^2.0.0 ...dev_dependencies:... build_runner: ^1.1.2 json_serializable: ^2.0.0 ...

json_annotation可以让你在Model类中进行标记,有点像JavaSpring,build_runner和json_serializable是两个“dev_dependencies”,就是说在正式发布的时候并不包含这两个库,他们只是用来辅助开发的工具。在引入json_annotation.dart之后,我们就可以改造一下Model类了

// feed_model.dart@JsonSerializable()class FeedModel { List imglist; int commentnum; FeedModel(this.imglist, this.commentnum)  factory FeedModel.fromJson(Map json) =>     _$FeedModelFromJson(json);}

在类的开头添加JsonSerializable标记,如果json中字段名字和类的属性名称不一致,还需要在属性前添加@JsonKey(name: ‘name_in_json’)标记。然后在控制台运行flutter packages pub run build_runner build命令,就会生成一个feed_model.g.dart的文件

// GENERATED CODE - DO NOT MODIFY BY HANDpart of 'feed_model.dart';// **************************************************************************// JsonSerializableGenerator// **************************************************************************FeedModel _$FeedModelFromJson(Map json) { return FeedModel(     json['imglist'] as List,     json['commentnum'] as int,}

这样就自动生成了一部分模版代码。虽然这非常方便,但如果我们每次在模型类中进行更改时都不需要手动地运行构建,那就更好了。为了持续地生成代码,我们需要用到watcher工具。它会监控我们项目文件的改变并在需要的时候自动编译那些必要的文件。我们可以在项目根目录下运行flutter packages pub run build_runner watch来启动watcher。这样在我们更改了Model类之后,他就能自动生成匹配的.g.dart文件了。

但也仅仅如此了。为什么这么说呢,因为我们在flutter中无法使用GSON/Jackson之类的库,这些库需要用到运行时的反射机制,而这在Flutter中是被禁用的。

为什么反射被禁用呢?FlutterUI工具包会被打包在每一个App中,也就是说,开发者只写一个HelloWorld程序,Flutter也需要在包中添加很多相应的支持库,这就导致Flutter对优化应用大小格外关注。为了优化应用大小,以及在发布版本中“摆脱”一些无用的代码,Dart支持tree shaking特性。Tree Shaking顾名思义就是摇树,就是把树上一些没用的东西摇掉,特别形象。Flutter的结构本质上就是一棵Widget树,自main方法以下,引入了很多文件,依赖了很多模块,但在实际情况中,虽然依赖了某个模块,但其实只使用其中的部分功能,通过tree shaking摇掉没用的模块功能,来删除无用代码。而运行时反射干涉了tree shaking,因为反射默认隐式地调用所有代码,这让tree shaking变得困难。工具无法知道哪些部分在运行时未使用,以至于多余的代码很难被清理掉。所以在使用反射时,应用大小不容易被优化,这个特性就被禁用了。

2.4 视频外接纹理

视频外接纹理是阿里巴巴闲鱼团队遇到的一个非常有价值的问题,在我们处理视频或者图像识别的问题时也很容易遇到。

Flutter Engine和Native之间通过Platform Channel机制进行通信,正如所有桥接机制一样,在Flutter侧需要一些Native侧高内存占用图像的时候,(比如摄像头帧,视频帧),用于图像等数据的传输必然引起内存和CPU的巨大消耗。为此,Flutter提供了一个特殊的Widget,Texture。

首先,纹理Texture在物理上指的是GPU显存中一段连续的空间,可以理解为GPU内代表图像数据的一个对象,他有一些属性,比如高度、宽度、色彩通道数量。其次,Flutter中Texture Widget就是一个可以由Native平台渲染并填充纹理的一块矩形区域。而填充纹理的方式,就是使用一个共享的PixelBuffer。Native端将摄像头获取的数据(或者视频帧之类的),直接写入到PixelBuffer中,Flutter通过copyPixelBuffer方法拿到PixelBuffer以后转成OpenGLES Texture,交由Skia绘制。

但是在实际工程中,很多情况在Native侧需要通过GPU对视频图像数据进行处理,比如美颜,也就是说在进入PixelBuffer之前已经生成GPU纹理Texture了,然后又要通过CPU计算加入到PixelBuffer中,之后再通过Flutter引擎调用,转化为Texture,GPU->CPU->GPU这就造成了浪费,并且CPU和GPU的内存交换是所有操作里面最耗时的。

为了让Flutter侧直接读取到Native侧的Texture,闲鱼团队做了一个ShareGroup的露出。

在说ShareGroup之前,需要简单说一下Flutter的线程机制。通常情况下,Flutter创建4个Runner,UI Runner、GPU Runner、IO Runner和Platform Runner,一个Runner对应一个线程,Platform Runner跑在主线程上。使用OpenGL的app在线程设计上都会有一个线程负责加载资源,一个线程负责渲染。但是为了能让负责加载的线程创建出的Texture,给负责渲染的线程使用,两个线程会共用一个EAGLContext。正如之前介绍Dart设计原理的时候提到的一样,共享资源是不安全的,而加锁的话不仅影响速度还会有死锁饥饿等问题。因此Flutter在EAGLContext的使用上使用了另一种机制:两个线程各自使用自己的EAGLContext,彼此通过ShareGroup(android为shareContext)来共享纹理数据。

而Native侧在使用OpenGL的模块时也会在自己线程下创建EAGLContext,闲鱼团队就是让ShareGroup露出给Native侧,然后在Native侧保存这个ShareGroup,当Native创建Context时,就使用这个ShareGroup进行创建。这样,利用Flutter原有的线程共享资源机制,打通了和Native线程共享资源的能力。

[注]An EAGLContext object manages an OpenGL ES rendering context—the state information, commands, and resources needed to draw using OpenGL ES. EAGLContext对象管理OpenGL ES渲染所需的上下文环境。包括OpenGL ES绘制所需的状态信息,命令和资源。

3.Flutter未来的计划

2018年12月初,Flutter发布1.0版本,Github上也增加了Stable通道。1.0版本提供了一些全新的iOS风格Widget,优化了很多Git上提出的Issue,另外接入了近20种Firebase服务(目前国内还不能用,但据说在和腾讯磋商),同时优化了性能,减少了包体积。除此之外Dart也更新到了2.1,提供更快的类型检查以及错误提示。

为了更好的使用Flutter框架,项目组也计划在2019年二月提供更好的工具链,以方便开发者将Flutter引入到现有的工程中,因为毕竟不是每个团队都有机会从头使用Flutter构建项目的。

上面提到的Add to App功能非常适合于逐渐引入Flutter到现有应用中,但有时候我们反倒需要将Android或iPhone平台的控件嵌入到Flutter应用当中。所以Flutter团队引入了AndroidView和UiKitView这两个平台级视图的widget到Flutter中,这样就可以将它们分别嵌入到指定的平台。之前我们使用一个原生页面,比如百度地图,就必须弹出一个纯原生页,上面添加UI比如按钮文字什么的,都需要iOS和Android分别用原生实现。有了平台级视图Widget,我们就可以把两端分别实现的控件包装起来(如百度地图,因为百度地图短时间不会提供Flutter插件,所以还是要两端分别接入SDK),然后在Flutter中以Widget的形式插入到视图中,然后在上面添加其他的Widget,Flutter的文字按钮之类的,以达到多端共用一套代码的目的。

除了移动端,Flutter还有一个处于实验中的内部项目-Hummingbird。他是一个基于Web实现的Flutter运行时环境。它利用了Dart语言能被编译成JavaScript的特性。这个项目让Flutter应用程序能够无需改动地运行在标准Web平台。除了Web端,Google还在开发一个新的操作系统,叫作Fuchsia,和基于Linux内核的Chrome OS以及Android不同,Fuchsia基于一个叫作Zircon的微内核。设计目标之一是可运行在众多的设备上,包括移动电话和个人计算机。而Fuchsia的用户界面和应用程序,都是用Flutter编写的。

总而言之,Flutter作为Google推出的UI引擎框架,专注但不限于移动端,在提高工作效率,创建精美的UI界面上,不断探索前进。而对于我们程序员说,可以尝试新的开发模式,接触新的设计思想,在Git上和全球开发者讨论问题,研读源码,也是很有意思的事,不是么?

作者|尉野

本文来自博客园,作者:古道轻风,转载请注明原文链接:https://www.cnblogs.com/88223100/p/Cross-platform-application-practice-of-mobile-terminal-based-on-Flutter.html