当前位置: 首页 > Web前端 > JavaScript

58RN页面二开计划与实践

时间:2023-03-27 11:07:39 JavaScript

本文中的metro-code-split工具已经开源,支持RN解包和动态导入。欢迎大家入手。https://github.com/wuba/metro...以下为正文。今天和大家分享的主题是《 58RN 页面秒开方案与实践》。先自我介绍一下,我叫姜宏伟。2015年加入58,2016年开始往RN方向探索,这几年也推动了很多RN性能方案的落地。在落地的过程中,经常被问到的一个问题是:做性能优化,耗时减少了,但是对业务有什么好处呢?第一次,我真的被困住了。带着这个问题,我做了一个实验,计算出首屏时间和访问流失率之间的关系。我发现了一个有趣的模式。首屏时间每减少1秒,访问流失率降低6.9%。回过头来看预测,实际效果真的有6.9%那么好吗?以6.9%的营收数据,推动了多项业务的落地。总体而言,预测的流失率收入实际上与实际流失率收入相似,但存在差异。具体来说,表现好的页面比预期的回报差,表现差的页面比预期的回报好。这也很好理解。对于性能好的页面,流失率已经很低,进一步优化的空间不大。当我们知道性能优化带来的好处会有所差异化时,我们自然会更加关注那些还没有被秒实现的页面。我们设计了几个指标,包括流失率、首次屏幕时间和每秒收入。流失率和首屏时间是事后指标。二次打开收益指标是一个先行指标,它会告诉你如果你的页面实现二次打开,你的流失率会降低多少?我们希望通过这一系列的指标来驱动业务绩效的优化。同时,我们也会为商家提供一些低成本甚至零成本的优化方案,帮助商家节省和优化成本。指标驱动的业务,业务选型计划,提高收入的计划,这是我们设想的一种收入驱动的模式。首屏时间采集方案本次将围绕方案和指标进行分享。先说指标。最重要的指标是首次屏幕时间。首屏时间计算出来后,真正计算的就是流失率和二次打开的收入。因此,本次分享分为以下三个部分。第一部分是关于首屏时间的采集方案,第二部分会比较具体。最后对性能优化方案进行总结和期待。我们先看一个页面的加载过程,大概有5个阶段:0ms:用户输入410ms:第一次内容绘制FCP668ms:业务组件DidUpdate784ms:最大内容绘制LCP928ms:可视区域加载完成2nd,3,4、5个时间点都可以定义为首屏时间。首屏时间定义不同,耗时也不同,差距非常大。因此,我们需要先选择一个指标作为首屏时间的定义。对于首次筛选时间,我们选择了LCP指标。为什么?首先,因为LCP是最大的内容绘制,此时页面的主要元素其实已经显示出来了。二是因为LCP可以实现非侵入式采集,不需要业务手动埋点。第三,因为LCP是W3C的草案,这是一个重要的原因。你告诉别人你的第一屏指标是LCP,别人就明白了,不用过多解释。为了让大家更好的理解LCP算法的实现,下面给大家铺垫一下。简而言之,LCP是渲染时您可以看到的最大元素。但是这里有个问题,比如我们第二张图片的最大元素和第五张图片的最大元素不是同一个元素。不同的元素有不同的渲染时间,LCP也不同。也就是说,如果一个页面有多个LCP,应该上报哪个LCP?应报告最终收敛的LCP值,即加载可见区域时的LCP值。LCP是一个Web标准,但是在RN中并没有实现。应该如何实施?总体来说,实现大致分为5步:当用户进入时,Native线程记录Start时间戳。Start时间戳由Native线程注入到JS上下文中。JS线程监听页面上渲染元素的布局事件。由JS线程在页面渲染时计算,不断更新LCP值。JS线程计算结束时间戳并报告最终的LCP值。此时,最终上报的LCP=EndTime-StartTime。难点在于如何收敛LCP,即如何判断可见区域是否满载。我们采用的规则是,当所有元素加载完毕,底部的元素也加载完毕后,再加载视口。元素有一个调用周期,先调用render,再调用layout。只调用render的元素是没有被加载的元素。调用过render和调用过layout的元素就是已经加载的元素。可以判断一个元素是否加载完成,也可以判断可见区域是否加载完成。性能优化方案在说具体方案之前,先说说我们对性能优化的总体思路。在做任何性能优化之前,我们都需要先分析性能结构,然后找到性能瓶颈,并根据瓶颈提出具体的优化方案。一个RN应用的性能结构,整体上分为两部分,Native部分和JS部分。更具体地说,它可以分为6个部分。下面是一个未优化的、复杂的、动态更新的RN应用的耗时结构:版本请求200ms资源下载470msNative初始化350msJS初始化380ms业务请求420ms业务渲染460ms一般来说,以上6这种结构可以分为三个瓶颈。动态更新瓶颈,占比29%。初始化瓶颈占32%。业务耗时瓶颈占比39%。瓶颈一:动态更新互联网产品具有快速试错的特点,需要业务快速迭代。为了支持业务的快速迭代,这就需要应用能够动态更新。对于动态更新,必须发送请求,这会降低性能,例如Web。如果像Native一样内置资源,性能会好很多,但是如何动态更新呢?动态更新和性能似乎是一对矛盾,有什么取舍的地方吗?我们首先想到的方案是通过内置资源来提升页面性能,通过静默更新来动态更新。用户第一次进来时,因为已经有内置资源,所以不会再请求,直接渲染页面即可。同时,Native线程会并行静默更新,询问服务器是否有最新版本,如果有则下载bundle并更新缓存。这样下次用户进来的时候,就可以使用上次缓存的资源,直接渲染页面,并行静默更新页面。以此类推,每次用户进入,都没有请求,直接渲染页面即可。设计静默更新时需要注意一个小细节。用户每次使用上次缓存的资源,不是最新的在线资源。所以存在严重bug的版本被用户缓存,无法更新的风险。为此,我们设计了强制更新功能。静默更新成功后,Native线程通知JS线程,业务根据具体情况决定是否强制更新到最新版本。资源内置+静默更新的方案也有一些缺点:增加了App的体积。对于超级应用来说,体积已经很大了,再增大就很难了。新版本的覆盖率较低。72小时新版本覆盖率在60%左右,相对web方案来说还是比较低的。版本碎片化严重。多个内置版本和多个动态更新将导致版本碎片化并推高维护成本。所以我们做了一些改进。使用资源预加载而不是资源内置。这在很大程度上避免了包大小、覆盖和碎片化的问题。静默更新还是保留更新可能的BUG版本。资源预加载这个话题其实已经讨论得很烂了。我只从“权利”的角度来分析。谁应该拥有预加载权?是RN框架,还是具体业务?给框架权限,框架可以预加载所有页面的资源,但是这样显然效率很低。对于平台级的app来说,一个app有几十个甚至上百个RN应用,其中大部分预加载的资源都没有被用户使用,造成了浪费。把权限给业务,让具体的业务一个一个加载,很麻烦。信息就是权利,谁拥有信息,谁就拥有权利。一开始,框架没有任何有用的信息,但业务可以根据业务数据知道跳转到特定页面的比例,所以调用预加载的权利应该交给业务。当用户使用过某个RN应用时,框架知道这个信息,应该把权限交给框架。框架可以在App启动后预请求版本。对于动态更新瓶颈,我们采用资源预加载和静默更新的方案。未优化的2280毫秒时间减少到1610毫秒,下降了29%。瓶颈二:框架初始化瓶颈首先我们分析一下框架初始化慢的原因。JS线程和Native线程异步通信,每次通信通过Bridge进行序列化和反序列化。在通信之前,由于不在同一个Context中,JS线程和Native线程并不知道对方的存在。因为Native不知道JS会使用哪个NativeModule,Native需要初始化所有的NativeModule,而不是按需初始化,这就是初始化性能慢的原因。在RN的新架构中,有计划用同步JSI通信代替异步Bridge通信,从而实现按需初始化。但是现在还没有实现按需初始化的功能,所以我们还需要对框架初始化进行优化。我们给出的思路是解包内置和框架预执行。我们的app是一个hybridapp,首页没有使用RN。因此,App启动后,可以先执行RN内置包,初始化所有的NativeModule。当用户真正进入RN页面时,性能自然会快很多。该方案最大的难点在于拆包。如何将一个完整的bundle包正确拆解成内置包和动态更新包?我们一开始就踩了一个坑,希望能帮助大家避免。原来我们用的是google的diff-match-patch算法,会比较新旧文本的差异,生成补丁文件。同样可以使用diff-match-patch算法比较业务包和内置包的差异,生成patch动态更新包。但是,patch其实是一个“文本补丁”,“文本补丁”不能单独执行。不能满足先执行内置包再动态更新包的要求。后来我们改造metro实现正确解包,从而实现框架预加载。一个完整的bundle由若干个模块组成,如何区分一个模块是内置包还是动态更新包?内置模块的路径或者ID有一个特点就是在node_modules/react/xxx或者node_modules/react-native/xxx路径下。可以预先记录所有内置模块的ID,打包时过滤掉所有内置模块,生成只包含业务模块的动态更新包。metro解压出来的动态更新包是一个“代码补丁”,可以直接执行,可以满足先执行内置包,再执行动态更新包的要求。其中一个细节是需要在内置包中加入一行require(InitializeCore)代码来调用内置包中定义的模块。通过添加这行代码,第一屏的时间可以减少大约90毫秒。对于框架初始化瓶颈,我们采用解包内置和框架预执行的解决方案。耗时从之前从未优化过的1610ms减少到1300ms,整体下降了43%。瓶颈三:业务请求瓶颈动态更新瓶颈,框架耗时瓶颈优化后我们来看业务瓶颈。业务瓶颈主要由两部分组成:业务请求和业务渲染。请求比较容易优化,所以我们先优化业务请求瓶颈。优化业务请求其实有很多常见的解决方案。业务数据缓存是在上一页预加载下一页的业务数据。但是,并不是每个应用都适合做缓存,也不是每个应用的数据都适合在上一页预加载。因此,我们需要一个更通用的解决方案。仔细一看,Init部分和业务请求部分是串行的,能改成并行吗?我们的思路是用Native代替JS,用户进入页面直接并行请求业务数据。具体方案如下。Native下载的资源文件会同时包含Biz业务包和原始业务请求的URL。原始URL中会包含动态的业务参数,这些参数会按照事先约定的规则进行转换。例如,58.com/api?user=${user}将转换为58.com/api?user=GTMC。Native并行执行Biz包渲染页面,发起URL请求获取业务数据。JS端直接调用PreFetch(cb)获取Native端请求的数据。对于业务请求瓶颈,我们采用并行加载业务数据的方式。未优化的1300毫秒时间减少到985毫秒,整体减少了57%。应用上述解决方案,大多数页面都可以在几秒钟内打开。还有性能优化的空间吗?代码执行瓶颈RN页面渲染慢的另一个原因是RN需要执行一个完整的JS文件,即使JS中有不需要执行的代码。我们来看一个案例。一个页面包含3个tab,用户进来只会看到1个tab,理论上只需要执行一个tab的代码。但实际上,另外2个不可见标签的代码也会被下载执行,拖慢性能。RN代码延迟加载和延迟执行以提高性能的能力,类似于Web中的DynamicImport。RN官方不提供动态导入,所以我们决定自己做。目前动态导入demo在RN0.64版本已经跑通。业务初始化时,只能执行Biz业务包,跳转到Foo和Bar这两个动态页面时会动态下载对应的chunk动态包。退出已经进入的动态页面再进入,不会再次下载,会使用原来的缓存直接渲染动态页面。RN的动态导入实现,我们参考了TC39规范。业务只需要写一行代码import("./Foo")即可实现代码懒加载和懒执行。其余的工作都在框架层和平台层完成。runtime运行时,业务执行import("./Foo")后,框架层会判断./Foo路径对应的模块是否已经安装。如果没有install,它会通过./Foo路径找到对应chunk包的url地址,然后下载并执行chunk,最终渲染FooComponent。Chunk包的URL是一个CDN地址。显然,上传CDN和记录Path与URL关系的工作不是在运行时完成的,而是在编译时完成的。在平台层的编译过程中,Biz包中会保存Path与URL的关系表,以便Runtime通过Path找到对应的URL。这个过程大致分为5个部分。项目:一个项目由若干个文件组成,文件之间会存在相互依赖关系。Graph:每个文件都会生成一个对应的模块,所有模块及其依赖关系组成一个图。模块:“颜色”动态模块的集合以区分它们。Bundles:将多个模块的集合打包成多个bundle。CND:将包上传到CDN。最关键的一步是给动态模块集合上色。Decomposedcoloring:一个Graph的着色可以分解成几个基本情况,这些基本情况的着色方案已经确定。动态图:着色完成后,会记录动态模块“green”和“blue”的根路径Paths,并以其bundle的CDNURL地址组成动态图。PathtoURL:动态地图会被打包到“白”的Biz业务包中,所以运行时调用import()时,可以通过Path找到对应的URL。上面的很多细节都没有讨论。关注实现细节的同学可以关注我们的开源工具metro-code-split。metro-code-split:https://github.com/wuba/metro...基于metro,支持DLL解包,支持RNDynamicImport。总结与展望通过对性能结构的分析,我们发现了3种性能瓶颈,并提出了不同的优化方案。下图是我们秒开计划的合集。该图列出了(预期的)收益、有效范围和有效场景。希望对大家的技术选型有所帮助。在最新版本中,新RN架构的很多功能已经成熟,我们正在积极探索中。最令人惊讶的是Hermes引擎,它已经在iOS和Android中可用。Hermes引擎与原生JSCore引擎最大的不同在于,Hermes在编译时会将JS文件预编译编译成字节码文件,这样运行时就可以直接使用字节码文件执行,可以大大降低JS的执行消耗。小时。经过测试,我们发现一个需要140毫秒的页面可以减少到40毫秒,下降了80%。我们在为业务提供性能优化方案的同时,也需要关注业务的落地。为了让更多的业务秒开,我们通过非侵入式采集,秒级采集了流失率、首屏时长、收入等指标。在我们的实践中,这种将技术优化需求与业务收益挂钩的方式更容易被业务接受,也更容易推广。最后希望我们的秒开计划和收益驱动的实践能给大家带来启发,谢谢。