阿里妹导读:Flutter设计之初是不考虑Web生态的,原因很简单:两种技术设计理念不同,强行融合很可能让彼此都丧失了优势。但是业界又有很多团队在做这种尝试,说明需求是存在的。今天,阿里无线开发专家门柳就来手把手教如何实现Flutter和Web生态的对接?
先说结论:
不要对接!不要对接!不要对接!
开个玩笑,以上仅代表个人观点,大家也知道这种“三体式警告”根本没有用的,我自己也研究如何对接,说不定做完后就觉得“真香”了。
为什么要对接?
首先讨论一下为什么要把Flutter对接到Web生态。
Flutter现在是一个炙手可热的跨平台技术,能够一套代码运行在Android、iOS、PC、IoT以及浏览器上,被认为是下一代跨平台技术。相比于Weex和ReactNative可以很好地解决多平台一致性问题,原生渲染性能相近,上层没有JS那么厚的封装层次,整体性能会略好一些。
但是大部分兴冲冲去学Flutter的人疑惑的第一个问题就是:为什么Flutter要用Dart?一个全新的语言意味着新的学习成本,难道JS不香吗?JS不香不是还有TypeScript吗!事实上Flutter抛弃的岂止是JS这门语言,也抛弃了HTML和CSS,设计了一套解耦得更好的Widget体系,Flutter抛弃的是整个Web,致力于打造一个新的生态,但是这个生态无法复用Web生态的代码和解决方案。尤其是之前所有跨平台方案Hybrid、ReactNative、Weex都是对接Web生态的,这让Flutter显得有些格格不入,也让大部分前端开发者望而却步。
下面是我整理出来的,前端开发者使用Flutter的各方面成本:
因为Flutter的开发模式和前端框架比较像(可以说就是抄的React),所以框架的学习成本并不高,稍微高一些的是Dart语言的学习成本,另外还要学习如何用Widget组装UI,虽然很多布局Widget设计得和CSS很像,灵活度还是差了很多。要想在真实项目中用起来,还要改造整个工具链,以“NativeFirst”的视角做开发,开发Flutter和开发原生应用的链路是比较像的,和开发前端页面有较大差异。最高的还是生态成本,前端生态的积累无论是代码还是技术方案都很难复用,这是最痛的一点,生态也是Flutter最弱的一环。
无论是为了先进的技术理念还是出于商业私心,先不管Flutter为什么抛弃Web生态,现实问题是最大的UI开发者群体是前端,最丰富的生态是Web生态,我觉得Web技术也是开发UI最高效的方式。如果能在上层使用Web技术栈开发,在底层使用Flutter实现跨平台渲染,不是可以很好的兼顾开发效率、性能和跨平台一致性吗?还能复用Web技术栈大量的技术积累。
可能这些理由也不够充分,暂且先照着这个假设继续分析,最后再重新讨论到底该不该对接。
关于Flutter和Web生态的对接涉及两个方面:
从Web到Flutter。就是使用Web技术栈来开发,然后对接到Flutter上实现跨平台渲染。对Web来说是解决性能和跨平台一致性问题,对Flutter来说是解决生态复用问题。从Flutter到Web。就是官方已经实现的WebsupportforFlutter,把已经用Dart开发好的App编译成HTML/JS/CSS然后运行在浏览器上,可以用于降级和外投场景。
如何实现“从Web到Flutter”?
首先分析一下Flutter的架构图,看看可以从哪里下手。
Flutter可以分为Framework和Engine两部分,Engine部分比较底层也比较稳定了,最好不要动,需要改的是用Dart实现的Framework。要想对接Web生态的话,JS引擎肯定是要引入的,至于是否保留DartVM有待讨论。图中最上面Material和Cupertino两个UI库前端是不需要的,前端有自己的。关键是Widget这部分,是替换成HTML/CSS的方式写UI,还是继续保留Widget但是把语言换成JS,不同方案给出的解法也不一样。
有不少方案可以实现对接,业界有挺多尝试的,我总结了下面三种方式:
TS魔改:用JS引擎替换掉DartVM,用JS/TS重新实现FlutterFramework(或者直接dart2js编译过来)。JS对接:引入JS引擎同时保留DartVM,用前端框架对接FlutterFramework。C++魔改:用JS引擎替换掉DartVM,用C++重新实现FlutterFramework。
TS魔改
TS魔改就是完全抛弃掉DartVM,用TypeScript重新实现一遍用Dart写的FlutterFramework。
为啥是TS而不是JS?这不是因为TS是个大热门嘛,而且向下兼容JS,现在几乎所有时髦的框架都要用TS重写了。
这种方案的出发点是“如果能把Flutter的Dart换成JS就好了”,最容易想到的路就是把Dart翻译成TS,或者直接用dart2js把代码编译成js,但是编译出来的代码包含很多dart:ui之类的库的封装,生成的包也挺大的,也比较难定制需要导出的接口,不如干脆用TS重写一遍,工具链更熟悉一些,还可以加一些定制。
理论上讲翻译之后Flutter绝大部分功能都依然支持,可以复用各种npm包,还可以动态化,但是丧失了AOT能力,JS语言的执行性能应该是不如Dart的。而且所有节点的布局运算都发生在JS,底层只需要提供基础的图形能力就好了,就好像是基于CanvasAPI写了一套UI框架,性能未必有现存前端框架的性能高。
此外最大的问题是如何与官方Flutter保持一致,假如现在是从v1.13版本翻译过来的,以后官方升级到了v1.15要不要同步更新?这个过程没啥技术含量,而且需要持续投入,做起来比较恶心。
另外还需要考虑上层是用Widget的方式写UI,还是用前端熟悉的HTML+CSS。如果依然用Widget的话,那大部分前端组件还是用不了的,UI还是得重写一遍。反正要重写的话,成本也没降下来,那就用Dart重写呗……直接用官方原版Flutter也避免每次更新都要翻译一遍Dart代码。所以既然选择了对接前端生态,那就要对接CSS,不然就没有足够的价值。然而CSS和Widget的对接也是很繁琐的过程,而且存在完备性问题。
JS对接
翻译代码的方式不够优雅,那就保留Dart,把JS/CSS对接到Widget上面不就好了?
当然可以,这种方式是仅把Flutter当做了底层的渲染引擎,上层保持前端框架的写法,仅把渲染部分对接到Flutter。现存的很多前端框架都把底层渲染能力做了抽象,可以对接到不同渲染引擎上,如Vue/Rax同时支持浏览器和Weex,用同样的方式,可以再支持一个Flutter。
这种方式对前端框架的兼容性比较好,但是链路太长了,业务代码调用前端框架接口做渲染,一顿操作之后发出了渲染指令,这个渲染指令要基于通信的方式传给FlutterFramework,这中间涉及一次JS到C++再到Dart的跨语言转换,然后再接收到渲染指令之后还要转成相应的Widget树,从CSS到Widget的转换依然很繁琐。而且Widget本身是可以带有状态的,本身就是响应式更新的,在更新时会重新生成widget并diff,如果在前端更新UI的话,前端框架在js里diff一次vdom,传到Flutter之后又diff一次widget。
如果要绕过Widget直接对接图中的Rendering这一层,可以绕过widgetdiff但是得改FlutterFramework的渲染链路,既然要改FlutterFramework那为什么不直接用TS魔改呢,还绕过了JS到Dart的通信,又回到了第一种方案。
总结来说,这个方案的优点是:实现简单、能最大化保留前端开发体验,缺点是:渲染链路长、通信成本高、响应式逻辑冲突、CSS转Widget不完备等。
C++魔改
想要干掉DartVM,就需要用其他语言重新实现用Dart开发的Framework,用JS/TS可以,用C++当然可以,最硬核的方式就是用C++重新实现Flutter的Framework,然后接入JS引擎,通过binding把C++接口透出到JS环境,上层应用还是用JS做开发。
把Framework层下沉到C++之后,不仅会有更好的性能,也能支持更多语言。原本FlutterFramework是在DartVM之上的,必须依赖DartVM才能运行,所以对Dart有强依赖;用C++重新实现之后,JS引擎是在C++版Framework之上的,框架本身并不依赖JS引擎,还可以对接其他各种语言,如对接了JVM之后可以支持Java和Kotlin,对接回DartVM可以继续支持Dart。
这个方案可以增强性能,也能保持和Flutter的一致性,但是改造成本和维护成本都相当高。C++的开发效率肯定不如Dart,当Flutter快速迭代之后如何跟进是很大的问题,如果跟进不及时或者实现不一致那很可能就分化了。从CSS到Widget的转换也是不得不面对的问题。
几种方案对比
把上面几种方案画在同一张图里是这个样子的:
图中实线部分表示了跨语言的通信,太过频繁会影响性能,虚线部分表示了其他对接可能性。
从下到上,FlutterEngine是不需要动的,这一层是跨平台的关键。Framework则有三种语言版本,JS/TS、Dart、C++,性能是C++版本最好,成本是Dart版本最低。然后还需要向上处理HTML/CSS和Widget的问题,可以直接对接一个前端框架,也可以直接在C++层实现(不然需要透出的binding接口就太多了,用通信的方式也太过频繁了)。
如何实现“从Flutter到Web”?
这个功能官方已经实现了,可以把使用Dart开发的App编译成WebApp运行在浏览器上,官方文档以介绍用法和API为主,我这里简单分析一下内部具体的实现方案。
实现原理
结合Flutter的架构图来看,要实现Web到Flutter需要改造的是上层Framework,要实现Flutter到Web需要改造的则是底层Engine。
Framework对Engine的核心依赖是dart:ui,这是库是在Engine里实现的,抽象出了绘制UI图层的接口,底层对接skia的实现,向上透出Dart语言的接口。这样来看,对接方式就比较简单了:
使用dart2js把Framework编译成JS代码。基于浏览器的API重新实现dart:ui,即dart:web_ui。
把Dart编译成JS没什么问题,性能可能会有一点影响,功能都是可以完全保留的,关键是dart:web_ui的实现。在原生Engine中,dart:ui依赖skia透出的SkCanvas实现绘制,这是一套很底层的图形接口,只定义了画线、画多边形、贴图之类的底层能力,用浏览器接口实现这一套接口还是很有挑战的。上图可以看到Web版Engine是基于DOM和Canvas实现的,底层定义了DomCanvas和BitmapCanvas两种图形接口,会把传来的layertree渲染成浏览器的Elementtree,但是节点上仅包含了position,transform,opacity之类的样式,只用到CSS很小的一个子集,一些更复杂的绘制直接用2Dcanvas实现。
存在的问题
我编译了一个还算复杂的demo试了一下,性能很不理想,滑动不流畅,有时候图片还会闪动。生成出来的js代码有1.1MB(minify之后,未gzip),节点层次也比较深,我评估这个页面用前端写不会超过KB,节点数可以少一半以上。
另外再看一下Flutter仓库的issue,过滤出platfrom-web相关的,可以看到大量:文字编辑失效、找不到光标、ListView在ios上不可滚动、checkbox/button行为不正常、安卓滚动卡顿图片闪烁、字体失效、某些机型视频无法播放、文字选中后无法复制、无法调试……感觉flutterforweb已经陷入泥潭,让人回想起前端当年处理各种浏览器兼容性的噩梦。
这些性能和兼容性问题,核心原因是浏览器未暴露足够的底层能力,以及浏览器处理手势、用户输入和方式和Flutter差异巨大。
实现FlutterEngine需要的是底层的图形接口和系统能力,虽然canvas提供了相似的图形接口,如果全部用canvas实现的话很难处理可访问性、文本选择、手势、表单等问题,也会存在很多兼容性问题。所以真实方案里用的是Canvas+DOM混合的方式,封装层次太高了,渲染链路太长。就好像FlutterFramework里进行了一顿猛如虎的操作之后,节点生成好了、布局算好了、绘制属性也处理好了,就差一个画布画出来了,然后交到浏览器手里,又生成一遍Element,再算一遍布局,在处理一遍绘制,最终才交给了底层的图形库画出来。
再比如长页面的滚动,浏览器里只要一条CSS(overflow:scroll)就可以让元素可滚动,手势的监听以及页面的滚动以及滚动动画都是浏览器原生实现的,不需要与JS交互,甚至不需要重新layout和paint,只需要