【杂谈】BI系统的前端性能优化

  近一年,由于笔者团队的一些变化,笔者开始承担一个BI系统的前端应用的维护和迭代,一年中,围绕着这个BI系统,发生了不少令人啼笑皆非和醍醐灌顶的故事。最近,终于有时间把它们一点点的沉淀写来,以文字的形式呈现出来。

  首先,简单介绍下笔者维护的这个BI系统,和常规的BI(Business Intelligence)系统一样,笔者的BI系统同样可以分为三层:

数据层:这一层其实主要是ETL的过程,即将业务数据库的数据通过抽取(Extract)、转换(transform)、加载(Load)到新的数据库中,这一层主要是解决数据一致性和数据可用性的问题,还要查询时间等问题其实也需要在这一层解决;

业务层:针对不同的业务场景,对数据进行不同的操作和处理,常见的如解决实时数据和离线数据的同步问题、清洗数据中的噪点、提供多种维度组合的数据查询操作、保证接口一致性等;

应用层:在这一层主要就是解决用户各式各样的交互,为用户提供结果分析、趋势分析等各种功能。

  根据上文所述,很容易就能想到,前端的主要工作场景是在应用层,主要就是完成与用户的各种交互。

  但是,笔者的BI系统因为需要服务于各种业务场景,而每个业务场景都有且业务的特殊性,这些特殊性也要求我们在交互上需要具备更多的灵活性,也正是基于这些灵活性,给前端应用的复杂度带来了很大的挑战,加之工程的一些常见问题如工期有限、技术栈不对口等各种因素的共同作用,最后也有了笔者的文题:BI系统的前端性能优化

  

  言归正传,笔者先简单阐述下笔者的团队当时遇到的问题:

  作为一个PC前端应用,正常页面加载从12s到40s(视页面复杂度)不等,加载时会产生120+个请求(其中静态资源请求约90+,总大小约30MB,且大量请求存在着重复请求的情况),且即使完全加载后,页面滚动时仍存在大量的卡顿,页面稳定时时帧率只有10帧左右...

  而笔者在面对这茫茫10w+行代码的前端工程时,起初优化更是不知从何做起...面对着茫茫多的槽点和可优化点,千头万绪之余,还是得形成一套策略。

  也许聪明的你看到这里会心生疑窦:资源多就做合并,资源大就做压缩,这些不是显而易见可以优化的地方吗?

  诚然,优化的地方是很多的,但是笔者的团队,存在迭代周期紧人力有限的具体背景,且在PC上,笔者依据自己的经验做出了它并不是最核心的性能问题的判断。在这样一个具体的场景下,笔者的团队需要找到能够显著解决性能问题的突破口。

  

  合成层:渲染性能的双刃剑

  其实摆在笔者面前最大的两个问题是:1、TTI(首次可交互时间)过长;2、页面滚动卡顿(FPS过低);

  相较于TTI(页面加载过程如上图所示)牵涉着大量的请求与js逻辑的执行,加之刚接触整个系统不久,直接优化会有蛮大的难度。所以,笔者转向了去探究页面FPS过低的原因。首当其冲的,笔者就尝试去搜索代码中是否有对onScroll或者mouseMove等会由于scroll而触发的事件的处理,猜测是这方面的原因,但是却没有,在排除了可能是执行js引起的可能性之后,笔者从而转向了从浏览器渲染的角度去重新分析这个问题。

   

  为了更深入的理解问题的本质,笔者在以往对浏览器渲染过程的基础上,更进一步的来到了进程与线程的层面(上图为浏览器渲染的一帧),以期望能够发现问题。在了解了浏览器渲染线程的相关知识后,剩下的就是一步步排除呢:

  首先,用户输入行为为scroll;

  接着,rAF(requestAnimationFram)在代码中并没有调用,所以可以排除,因而也可以排除由rAF引发的reCalc styles和layout;

  其次,parseHTML、reCalc styles、Layout、update layer tree、paint这几个阶段,都由于滚动操作并不包含触发该几个阶段的操作(通过chrome dev tools中的【rendering】->【paint flashing】也验证这一点,值得一提的是,滚动条会重新paint);

  最后,排除了所有的不可能之后,就只剩下真相了:只能是合成及光栅化的阶段引起的。

  

  让我们来整理下在这个timing浏览器在做什么:以上图为例,我们假设上图中黑色的为页面的实际结构,而灰色的为视口也即我们的浏览器窗口,此时,浏览器在之前的paint阶段已经完成了对需要进行的绘制调用的记录,并把他们存储在SkPicture的数据结构中,等待光栅化阶段的调用;而在合成阶段,图层的信息会被转换成Graphics Layer所要求的数据结构,供合成器线程(Compositor thread)对位图进行合成,然后Tile worker(s)就会进行光栅化(Rasterize),将物理像素绘制在屏幕上,完成绘制;而主线程(Main Thread)则会视该帧(每帧的时间约16.67ms)的剩余时间去执行rIC(requestIdleCallback)队列中的任务,未被执行的任务将会继续处于queueing状态。

  回到我们的场景中,由于用户的scroll行为,虽然layout与paint阶段并没有执行,但它们之前执行是生成的图层信息依然存储于内存(也可能是显存,这取决于具体使用的渲染方式)中,在之前的帧中,因为某些元素并没有进入视口(viewport),所以在合成时进行了裁剪,最后光栅化到屏幕上。而当滚动行为发生时,就会重复合成 -> 光栅化的过程,而如果这个过程不能正常执行(即不能在单帧周期内执行完成),就会引起掉帧的情况,而更进一步如果情况进一步恶化,外部的Input event不断触发,但是合成 -> 光栅化的过程的延迟越来越多,就会出现笔者所碰到的“卡顿”的现象了。

  在确认了问题出在合成->光栅化阶段之后,我们在详细的看下这个阶段具体发生了些什么,又该如何优化。

  我们结合上图来看一下早在Paint阶段,就已经处理好的数据,经历了哪些过程,最后呈现在屏幕上的:

  首先,主线程(Main thread)会将绘制记录(SkPicture records)提交到合成器线程(Compositor thread);

  然后,合成器线程将它们分发给Tile worker(s)(因为不同的环境和浏览器的线程数并不一致,所以可能是1个,可能是多个),让它们进行光栅化,在这个阶段,我们会根据是否位于视口等信息去裁剪图层,去掉不必要的渲染,再将剩下的数据进行渲染,送往GPU,不过因渲染的方式不同,也会有两种渲染方式:

    一种是基由Skia软件光栅器(Skia rasterizer)将数据绘制成位图(bitmap),因为这个过程在CPU进行的,我们一般称之为软件渲染

    另一种则是借助Ganesh(集中基于Skia openGL的渲染器)将数据转换为位图,因为这个过程是在GPU进行的,我们一般称之为硬件渲染

  最后,生成好的位图被上传至GPU,GPU将它接收到的纹理(texture)再进行合成,绘制在屏幕上。  

  

  简述完整个阶段,我们不难发现,在用户滚动页面的时候,会频繁的触发光栅化的过程,应该就是在这个Timing出了问题。另外,我们都知道,在HTML5时代,我们引入了硬件渲染(或者说硬件加速),来提高页面渲染的速度,也即是上述的硬件渲染方式,那是不是这个问题也可以通过硬件加速的方式去解决呢?说着笔者打开了Chrome dev tools中的Layers页签,详细查看了一下页面的合成层,幸好Chrome在HTML5时代之初就提供了开发者可以观测合成层的方式。而经过笔者对Layers的分析,也最终确认了问题所在:由于对合成层的使用不当,导致一个dom结构上并不特别复杂(3000+dom)的页面存在150+个合成层,而这些合成层大部分在滚动的时候,都需要重新计算!

  既然问题已经确定,剩下的就很常规了。经历过移动端性能优化的同学都知道可以通过transform:translate3d(0,0,0)来开启硬件加速,提升页面渲染效率,其中的更深的原因就是利用了合成层是基于硬件的渲染,提高了处理的效率。笔者也采取了类似的方式:

will-change:transform;

  对,就这一行代码,让原本一个运行时帧率只有15帧左右的页面,提高到了60帧!合成层也减少了约1/3,到了100层左右(虽然这个结果并不算理想)。。思路也很简单,针对性的对一些明显可以提升为一层的合成层添加了这个属性,让浏览器将它们合并,从而达到了减少合成层,进而达到降低渲染负载的目的。在笔者的场景中,笔者主要对滚动容器添加了这个一行样式,将滚动容器内的元素大部分和合成在了一个合成层之中,从而达到降低渲染负载的目的。值得一提的是,有别于移动端通过将提升合成层避免一些效率低下的软件渲染,这里的主要思路是通过触发浏览器的合成层策略,调整了软件渲染和硬件渲染的规模。

  但笔者采取的合并合成层的方法并非银弹,应该说合成层并非解决渲染性能问题的银弹。要知道,虽然软件渲染会比较慢,但是整体的计算容积和规模一般都会大于硬件渲染,而硬件渲染的计算能力总体来说也并非无限(当然,通常在PC端不会出现该问题),超过了硬件能够承载的计算规模依然会出现问题,在笔者团队庆幸了仅凭一行代码(其实也并不是一行,是给很多个class都添加了这同一行代码)就完成了渲染性能优化的之余,其实也在回归测试中发现了新的问题:在运用合并合成层的策略之后,我们发现在windows平台上的页面的某些区域(在屏幕固定的某些矩形区域)会出现渲染模糊的状况,更有甚者,一个完整的字,如果正巧落在这个区域的边界,则会出现左边区域模糊,右边区域清晰的奇异现象,在关闭合成层合并之后又得到了恢复(当然,卡顿也一起回来了)。

  是哪里出了问题?笔者重新翻阅了相关的文献,浏览器渲染的大部分过程几乎是没有平台差异的,除了在一处:

  In the hardware accelerated architecture, compositing happens on the GPU via calls to the platform specific 3D APIs (D3D on Windows; GL everywhere else). The Renderer’s compositor is essentially using the GPU to draw rectangular areas of the page (i.e. all those compositing layers, positioned relative to the viewport according to the layer tree’s transform hierarchy) into a single bitmap, which is the final page image.

  经过翻阅文献发现,在GPU进行合成时,因为平台的不同,而会调用D3D(在windows上)或GL(在其他平台上)的API,然后因为相关资料的匮乏和笔者所面对场景的特殊性,笔者最后只能做出一个推测:很有可能是windows平台的Chrome在GPU进行图层合并时,由于调用的api不同,而产生的这个模糊的现象,而模糊其实是一种降级策略,了解灰阶渲染的同学应该知道,为了保证物理渲染的实时性,在渲染不能在规定时间(一帧内)完成时,会做出舍弃某些计算过程(比如精度渲染)的操作,从而导致了在windows上的这个现象。

  其实,回顾整个优化过程,其实笔者并没有对整个页面的结构做太多的调整,仅仅只是在“业务迭代频繁,人力有限”的境况下,尽可能的把资源花在了刀刃上,以期达到显著的优化,但其实从工程化的角度上来讲,笔者这里利用了合成层的特性,只是将问题暂时的解决了了(就好像是把压垮骆驼的最后一根稻草给拿了起来),想要根治渲染性能的问题,最终还是需要通过调整页面结构,治理页面过多的合成层(让骆驼不要有过多的负担)。

  果不其然,随着业务的迭代,原本就复杂的合成层境况,又引发了新的问题:“页面在滚动时某些已经完成了渲染的区域变成了大片的白块。”而且,更令人吊诡的是,同样的布局方式在另一个页面确实正常的,笔者详细比较了两个页面的布局结构,最后发现,除了一个页面(滚动正常)是在一个div内滚动,一个页面(滚动附带白屏)是在body滚动(此时的滚动容器是视口)之外,两个页面并无不同,且笔者还在其他浏览器中尝试复现该问题,也没有得到复现(safari和firefox)都不能复现该问题,一时问题的解决陷入僵局,情急之下笔者还给chromium团队提了issue寻求帮助,不过几番沟通后无果,最后还是得靠自己想办法。

  回到问题中来,因为存在一个明显无问题的样本,所以笔者开启了对比模式,纵然整个页面的合成层众多,但是最终还是发现了有性能问题的页面的合成层与无问题的页面的不同,进而通过减少该合成层,滚动白屏的问题得以解决。

  但笔者觉得这并不能算作结束,如果每次都以这样一种被动的方式去解决问题,对未来的项目的稳定性是存在着大量不可预知的风险的,基于这样的考虑,笔者在项目迭代的间隙,开始了页面结构的梳理工作,问题就是这样:如果你一直尝试去堵住它,它永远得不到解决,反而是积极面对,会收到意想不到的效果。

  经过笔者对页面结构的分析发现:大量的合成层出现主要有以下几个原因:

  1.布局方式引起的合成层骤增的必然性:因为笔者维护的BI系统依托于一个react-grid-layout(RGL)的拖拽布局库来完成用户比较灵活的可定制化布局,虽然RGL提供了比较完善的拖拽布局功能,但是由于其布局的方式是以绝对定位(position: absolute)去模拟的grid布局来实现的,而绝对定位本来就是引起提升合成层的原因之一,特别是在后代具备合成层后代的情况下,这种情况还会被放大;

  2.合成层的理解缺失增加了问题出现的可能性:因为历史原因,在笔者的团队接手该BI系统时,前端应用已几经易手,不同的人维护带来了不同的代码风格,不同的人对CSS模型的理解也带来了不同的样式书写方式,后人不断通过新的样式覆盖前人的代码,又受限于迭代周期的紧张以一种近似于补墙的策略去修复问题,不可避免的在增加整个系统的不稳定性,且受限于前人对合成层、TSC(the stacking context)及BFC(block formatting context)等概念的理解,也加深了这种隐患(一个拥有数千行样式代码的项目里竟然没有reset.css也是一件很令人诧异的事情);

  3.DOM的绝对数量的增涨最终成了压死骆驼的最后一根稻草:笔者相信,在项目完成之初,如果就有如此严重的性能问题,肯定也会引起相关同事的注意,说明在项目开始时,虽然存在种种隐患,但是问题并没有浮出水面,但是随着业务逻辑的逐渐复杂,用户配置的页面逐渐复杂,页面的绝对DOM数量也日益增多,最终达到了软件/硬件的瞬时承载极限,同时也成为了压死骆驼的最后一根稻草。

  

   Redux:状态管理器之殇

  在解决了最主要渲染性能问题(之所以说他是最主要的问题的原因在于滚动卡顿会影响用户操作页面的整个流程)之后,在繁忙业务的间隙,笔者便开始着手探寻加载缓慢的原因,毕竟加载时长及加载时的掉帧现象依然存在,而且它们是在用户一进入页面就会发现的问题,极大的影响了用户的体验,当然,笔者团队也做了顺着做了很多必要的优化:

  1.针对某些静态资源过大过多的问题,进行了资源压缩(基于webpack),并削减了一些重复静态资源的请求;

  2.针对数据请求的重复性,同样受限于项目体系的复杂度和时间成本,采用了引入“增强的ajax”(其实就是自己基于axios进行了封装,添加了业务场景定制的缓存策略),对幂等的请求进行了内存级缓存,降低了请求数。

在团队持续进行这些优化的同时,笔者这一次深入到了10w行代码中去,尝试去找到页面加载时的性能问题,在抽丝剥茧的过程中,同时也发现了很多工程问题:

  1.过于独立的模块化开发给应用的基础一致性带来了过于庞大的维护成本:其实在这个模块化开发变成了软件开发的普遍共识的时代,模块化本应是一件毋庸置疑的基础方案,但是在笔者的场景中,却是这样一种情况:

  如图所示,我们假设系统中存在着如Module A、B、C、D等多个功能独立的模块,在笔者的系统中,这四个模块会各自实现自己的异步请求权限验证状态管理utils异常处理以及视图??可能视图层还好说,但是如果连ajax这种剥离了业务属性的通用功能模块都由各个业务模块自己实现(具体来说就是笔者的系统引入了多个ajax包,而且即使是同一个包,还会有不同的封装...),后期的维护成本是难以估计的,也间接增加了整个项目的体量,更有甚者,在引入了状态管理器之后,由于模块之间的“完全独立”,模块之间的通信依然依赖props的传递和命令式的调用,以避免模块解耦之后可能存在的状态依赖?可能前人也在项目开发后期发现了问题,于是在项目中又开始广泛的使用HOC来解决通用性的问题,但模块之间命令式的通信机制已经形成,对减少维护成本的成效上杯水车薪。

  2.相关框架的理解缺失及频繁的人员变动放大了维护的困难程度:接着,笔者遭遇了在排查渲染性能问题时碰到的类似的问题,由于前人对React的理解存在的问题,让项目中出现了一些神奇的代码:

  在一个引入了eslint的项目中,通过一些反工程化的手段去屏蔽一些规则,笔者看到的时候,是颇有些瞠目结舌的。虽然某种程度上说,react在class component时代确实存在这样的“设计问题”(然后他们团队也借着hooks的到来解决了这个“问题”),但是这些包含某些隐患的代码就切实的正在线上运行;代码又几经易手,秉持着通过qa验证的代码是没有问题的前提下,后人自然也就更不敢轻易修改这种“高危代码”,于是历史又再度上演,不过这次是以HOC的方式去打补丁,问题被隐藏在了层层HOC之下,也放大了维护的困难程度。

  3.系统本身的复杂性变成了问题无法解决的遮羞布:在人员的频繁更替基础上,项目本身具备的复杂度最终成为了为性能问题开脱的借口,而横向又无竞品,加上承载着大量的业务,最后性能问题就一直就这么流转到了笔者的手中。。

  在满怀叹息这样一个承载在数十条业务线BI需求的项目内部令人唏嘘不已的同时,笔者也逐渐剥开了层层迷雾,借助chrome dev tools的performance提供的能力,逐渐抵达了加载性能问题的核心,而一切依然要从状态管理器redux说起。。

  笔者的系统中有这样一个逻辑,为了保证每个页面都是按需加载的,所以当页面初始化的时候,每个页面会去请求每个页面的组件信息及组件的静态资源,等请求到组件的信息及组件静态资源之后,会开始初始化,然后发送请求,渲染组件数据。我们会把所有组件信息统一存放在componentConfig List里,托管在redux中,在页面初始化的时候,它是一个空数组,随着组件信息的加载,会逐步填充,一切看起来似乎都没什么问题...

  组件流程如上图所示,初步看上去似乎也没什么问题,但实际运行的时候,却出现了令人意料之外的状况:每当有组件信息获取到之后,这个list就会更新,然后,这个list整个作为了props传递了所有组件。。对的,给了所有组件!而且不管他们是否需要,而且这还是在已经使用redux的基础上。。然后时间轴上看,就变成了这样:

   每次一组件信息的获取都会导致所有组件的更新!而且,前人在引入了immutablejs之后,每次更新都会是确定无疑的“新数据”,更有甚者,在尝试修改状态时,又采取了一种匪夷所思的代码方式将immutable对象toJS,再操作新的对象之后,fromJS之后再托管在redux中,简单来说代码就变成了这样:

  原本为了降低react diff复杂度的immutablejs在笔者的场景下变成了diff的额外负担。。。啼笑皆非之余,问题还是得面对的,接着笔者完成了对问题的分析:

  首先,页面初始化时会请求组件信息,每当组件信息请求到之后,就会以props传递到页面的所有组件,进而触发组件的渲染,又受immutablejs不正确使用的问题;

  接着,越来越多的组件信息完成了加载,然后触发所有组件渲染的情况越来越多,进而将PC原本充足的计算资源给耗费完了。。

  最后,由于大量js执行的task阻塞在主线程中,导致页面渲染不能正常进行,帧率受到影响,加载本身也受到影响,一直等到主线程从茫茫多的render中恢复之后,一切才逐渐恢复正常,但最终整个执行过程消耗了接近约24s(数据来源于chrome dev tools的performance工具)的时间。。

  

  不过,在充分知道了问题的本质之后,想要修改就比较简单了,整体思路也比较简单:

  

  首先,笔者修改组件通信的机制,不再使用全量的组件配置作为props进行传递,降低重复render触发的次数;

  接着,因为组件间确实存在着少量的相互依赖,为了解决这个问题,我们在redux中添加了相关的涉及组件交互的action,来满足组件之间的交互;

  最后,对于那段最令人啼笑皆非的immutablejs的代码,笔者在权衡了改动范围和成本的基础上,做出了以immer替换immutablejs的判断,正好因地制宜的使用“类原生”的api的immer去“适配”使用原生api的业务场景。

  其实回顾整个过程,虽然整个系统仿佛充满着各式各样的问题,几乎每次遇到问题时,都会凸显出系统级重构的必要性,但开始是鉴于项目代码量庞大,对整个系统机制的不熟悉,不敢改;而后面又由于业务迭代的紧凑性,始终没办法大规模重构,只能尽可能在不更多加重系统潜在问题的基础上,找到局部的最优解。虽然截止日前,笔者的的想法还未完全实现,但再次采集数据已经发现,整个过程消耗时间已经降低到4s,也算是终于实现了去年和pm同学访谈时提及的页面初始化太慢的问题。

  总的来说,藉由这段持续时间很长的优化,一方面让笔者对react及redux有了更深的了解,笔者也是鲜有机会,深入到react内部去一步步跟进setState的执行路径,一步步去观测状态的流转、变化;另一方面,也让笔者更深入的认识到工程化的巨大意义。。从框架的选择、项目的构建、工具的使用、模块的分布以及各式各样的打包策略等等等等,它们都在默默地规范着你我的行为,最终触进项目的可维护性及扩展性。。

  不知不觉就写了这么多,其实回过头来看干货没多少,都是些再熟悉不过的知识,只是将它们有机的组合起来,放在一个现实的应用场景中,竟然会有这样的问题。。再联想到最近的疫情情况,也不禁让人想到狄更斯那句:

  “这是一个最好的时代,这是一个最坏的时代;这是一个智慧的时代,这是一个愚蠢的时代;这是一个信仰的时代,这是一个怀疑的时代;这是一个光明的季节,这是一个黑暗的季节;这是希望之春,这是失望之冬;人们面前应有尽有,人们面前一无所有;这里直通天堂,这里直堕地狱。”

  

  写在最后,其实对于业务场景通常是C端的笔者团队而言,BI系统复杂的逻辑是具有很大的挑战性的,不过可能由于在现阶段性能问题在笔者的脑海中留下了过于深刻的印象,所以在全文中也谈了很多,特别在碰到这种很有“业务场景”特性的问题时,业内并没有什么很好的参考,就连常用的性能分析工具lighthouse(虽然它诚恳的给出了0分...)也并无太多可以参考的建议。。也无形中增加了笔者问题的复杂度,好在在这个“最好的时代”,我们有充足的信息和经验分享渠道,去链接每一个人他们心中的想法。

参考资料

https://aerotwist.com/blog/the-anatomy-of-a-frame/

http://www.chromium.org/developers/design-documents/graphics-and-skia

原文地址:https://www.cnblogs.com/mfoonirlee/p/12560179.html