淘宝特价版作为集团内运用Flutter技术构建的应用之一,其用户规模已超过一亿。目前,我们的应用首页、详情页、店铺页、个人中心页以及短视频和评价等二级页面均采用Flutter技术进行开发。
然而,在实际应用中,我们经常面临性能问题。Flutter本质上是一个UI渲染框架,它通过异步机制实现子线程渲染UI,并借助Skia库确保跨平台渲染的一致性。然而,子线程的执行和渲染,以及动态库的打包策略,并非对所有场景都适用,这会导致页面打开性能的下降和交互时长的增加。以app启动为例,动态库的加载(dynamic binding)会直接影响启动时间;页面启动时,主线程虽然已经开始加载页面,但UI渲染却需要等待Flutter的子线程完成,这在低端设备上会导致短暂的白屏现象,虽然此时fps可能被人为提高,但页面的可交互性仍然受到影响。
尽管Flutter存在性能瓶颈,但作为重度使用Flutter进行研发的团队,我们通过一系列优化策略成功解决了性能问题。本文将详细介绍我们在基础链路各Flutter页面上的优化策略和实践经验。
首页最初完全采用Flutter结合DXFlutter(一个面向Flutter的UI动态化框架)进行开发,业务实现方面一切顺利。但在发版测试时,我们发现首页的启动性能比上一个版本下降了1秒。这一问题的出现是必然的,因为Flutter的动态库需要延迟加载和绑定,而DXFlutter的大量模板逻辑也会显著消耗性能。
面对这一难题,团队内部产生了分歧:是继续全部使用Flutter,但优化引擎和DXFlutter,还是回退到Native实现。如果回退Native,首页及搜索的分类tab技术方案都需要切换回native,这将带来巨大的成本。最终,我们根据特价版的现状和经验,决定采用折中的方案:app启动时,首页推荐等部分采用Native实现,而搜索模块的其他tab分类继续使用Flutter。这样既能保持搜索业务的研发模式,又能避免Flutter带来的启动性能损耗。
然而,这一方案也带来了新的技术挑战:我们使用的Flutter混合栈FlutterBoost仅支持页面级的混合,不支持页面内模块级的混合。由于Flutter采用单window的设计策略,模块级混合必然会遇到Flutter页面生命周期的管理及渲染窗口尺寸的一致性问题。
第一个问题源于Flutter的单引擎特性。当模块切换时,新模块的显示需要连接引擎重新触发渲染,否则页面会出现空白或不可交互的情况。这个问题在FlutterBoost中早已得到解决。
第二个问题是单window设计导致的。Flutter页面上弹出的Native页面会导致页面布局问题。
尽管如此,模块级混合在技术上是可行的。在AliFlutter正物、来一等同学的协助下,我们迅速开发出了可以容纳Native、Flutter以及其他类型如WebView的模块级混合容器。如下图所示:
我们通过一个FlutterWrapperVC,基于FlutterBoost解决了单引擎渲染问题,根据模块的可见性切换FlutterEngine和虚拟机,确保当前可见的模块能够正常渲染并执行底层的Flutter代码;然后通过强制修正Window大小解决了单window问题,从而解决了布局问题。最后,我们也考虑到了模块的复用性,将这一能力组件化,并封装到LTaoUIKit中。
基于这一方案,首页的启动性能至少提升了1秒。更重要的是,首页的研发如同围棋中的双眼,为后续的优化打下了坚实的基础。推荐页基于native dxcontainer实现后,可以直接复用淘宝等成熟应用的优化经验。
随后,我们通过RT优化、DX模板优化、图标本地化、图片压缩等多管齐下的策略,进一步提升了首页的启动性能。
同时,我们对首页的启动链路进行了分步治理,并建立了较为体系化的治理体系:
接下来,我们将探讨页面均为Flutter实现的优化策略,这也是大多数Flutter开发者常遇到的问题。以特价版详情页为例,我们在之前的优化基础上,再次优化了100多毫秒,具体数据如下:
Android(vivo y67): 80~100msiOS(iPhone6): 120~200msFlutter页面常见的性能瓶颈有哪些?从Flutter的机制来看,它是一个能够较好解决“多端一致问题”的“UI渲染框架”。尽管提供了通过bridge访问native的机制,但Flutter bridge的性能极差,涉及到线程切换、字符编码等问题。因此,我们使用Flutter时应避免直接通过Flutter来解决IO等资源访问的工作,这些工作应尽量放在native侧。
显然,app一张页面的启动,往往涉及到请求服务端准备渲染数据。同时,从路由跳转到通过Engine初始化Native的VC或者activity,再到Engine构造Rasterrizer,都涉及到Engine层面的线程等待。这些时间其实可以做不少事情,我们可以将服务端数据请求放在这个阶段,这就是我们希望做的“数据预取”。
实际上,“数据预取”在淘宝上已经大规模使用,但在Flutter页面上所显示的优越性会更强,因为Flutter页面的多线程切换太多了,很容易就掉入channel bridge的陷阱。例如,我们的详情页最初也使用了数据预取,但数据预取调用是从Flutter发起的,性能提升并不明显。为什么?以下是从Flutter发起一个mtop请求的流程图,可以从中一窥究竟:
上图中的UI线程是指Flutter的ui线程,并非系统主线程。请求从“开始”处开始,经过多次线程切换和数据encode/decode,最终到达目的地。这一过程造成的性能损耗相当可观,具体分析如下:
首先,一旦设备cpu紧张,Flutter的请求/数据返回会迟迟无法送达到native或者Flutter。其次,当数据量大的情况下,数据encode和decode也会耗费更多时间。最后,页面打开时经常遇到Engine和Native之间谁先启动的问题。例如,VC启动了,但此时Flutter Engine可能还没有准备好,导致message丢失,双方都不知道。这个问题在FlutterBoost中遇到不少。为了解决这些问题,我们采取了以下策略:
数据预取在Native侧发起,在页面路由构造Native VC/Activity时,立即发起mtop等请求。mtop返回的数据优先不通过channel bridge返回给Flutter层,而是通过ffi机制供Flutter直接读取。Native侧必须暂存数据,但为了避免长久引用造成资源泄漏,采用LRU策略缓存数据(iOS不能用NSCache,原因嘛,猜猜看)。考虑到有些页面数据可以持久化存储,供下次使用,我们构造了多级缓存策略。将上述能力全部组件化,供其他业务复用。详细的设计如下:
首先,基于ffi和native侧数据预取,优化后的数据请求链路如下:
右上角之所以还有channel bridge,是为了解决Native请求返回慢于Flutter页面渲染的情形下的数据刷新问题。
其次,我们构建了多级缓存策略和缓存失效及复用策略,以支持部分页面数据的持久化复用,提升首屏渲染性能:
以缓存复用策略为例,我们支持以下策略:
激进型:第二次请求直接使用上一次缓存数据,不再马上刷新数据,待缓存自然过期后刷新。该策略适用于页面数据不常变的情形。正常型:第二次请求可使用上一次缓存,但仍需请求并马上刷新数据。该策略比较普适,适合数据变化不频繁的情形。保守型:第二次请求不可使用上一次缓存,需请求最新数据。适合强实时性的页面数据渲染。最后,我们将这些能力做了封装,例如iOS侧,我们以单独的SDK集成:
目前,基础链路如详情页、我的页、店铺页、mini详情页等都采用了这个方案进行优化,启动性能均有了显著的提升。
此外,还有很多其他的优化实践。有些是淘系已经实践过的,有些是特价版根据Flutter的特点有所改动的。这里不详细展开,仅就我们使用过的列举一些:
详情页从首页截图。资源压缩及本地预置:如首页dx模板预置,Json压缩及预置,图片压缩及预置。数据提前异步加载。如我的页面数据,其实在用户登陆的时候就会异步加载并缓存下来,然后通过上面的缓存更新策略来更新。优化服务端RT,精简协议。其他。实际上,我们的优化策略更多在上层应用上做了优化,UC那边在Flutter Engine层面做了优化,后期可以考虑使用他们的引擎,相信页面打开性能会更上一层楼。
同时,上面的ffi及数据预取也可以做得更激进一些。如通过Dart2Native的方式,完全实现Json数据的encode和decode本地实现,容器访问的本地实现,估计还能提升至少50ms的时间。