【最佳实践】京东小程序-LBS业务场景的性能提升

一、前言

1.1 京东LBS门详业务介绍

京东LBS门详目前已经支持了仓网、药急送、天选、小时达POP多种业务,并且具备了多端的能力,一套代码可以在京东app、健康app、微信小程序中运行,一定程度上研发效率的提升能够更加快速的支持业务迭代。

随着业务需求猛增、各种AB场景线上测试,交互复杂度提升,所以对门详的整体交互体验,小程序加载速度、列表的滚动性能以及业务数据层面都有更高的要求,因此作为前端研发团队,我们也迎来了一些新的挑战。

1.2 面临的挑战

基于以上的业务与用户体验的要求,门详急需解决的问题如下:

1.2.1 页面加载速度缓慢:在中高端机型,实测从点击到店头渲染数据在1.2s左右【iphone12,小米11青春版】;

1.2.2 门详列表滑动性能差:

1)和美团外卖、京东到家App原生相比,我们货架层面不能支持手势滑动;

2)列表滑动多屏卡死,商品列表加载不流畅;

① 曝光埋点监听后,DOM元素的查找方式耗时,阻塞UI线程,导致滑动丢帧

② 和美团外卖进行比较,我们的列表没有联动加载的功能,给用户不好的体验

③ 列表中的分类及商卡数据联动逻辑复杂,不易维护

④ 列表内存占用高,不释放

⑤ 二级列表偶现闪动问题

1.2.3 门详小购物车展开卡顿:加车超过20个品,在中高端机型,出现滑动的明显卡顿问题,且打开购物车缓慢,渲染内容缓慢,在iphone12机型上首次渲染需要2s左右;加车动画卡顿;

1.2.4 门详埋点数据不准确:曝光埋点的上报时机和数据产品要求口径不一致

为更好,更快速的支撑业务诉求,上述的问题解决也迫在眉睫。经过四期的性能优化迭代,我们取得了一定的优化成果。

二、实践结果

该部分首先重点介绍一下总结果概览,然后详细阐述一下页面启动和交互结果。

2.1 结果概览

当我们识别到上述的四类问题之后,进行了整体的问题的分析,识别出的需要高优优化的环节分别是:启动耗时、交互体验、能耗优化以及埋点;整体优化完成上线后,我们验证的结果如下:

① 门详加载速度整体提升了约50%

同一网络环境下,同款机型优化前后的数据对比如下:

② 小程序健康度评分在90左右;

③ 整体的小程序崩失率减少了**10%**左右;

④ 埋点可用率在**95%**以上;

2.2 启动结果

① 同5G环境,同门店,机型是iphone14pro,优化前后的效果对比:左侧是优化前右侧是优化后

② 同5G环境,同门店,机型是iphone14pro,优化后和原生做个对比:左侧是京东到家app原生右侧是优化后的京东门详小程序

2.3 交互结果

左侧是京东到家原生app,右侧是门详的效果

从上述结果可以看出,在中高端机型上,门详的打开速度达到了秒开的效果。接下来详细介绍门详性能优化的分析历程以及解决方案。

三、启动耗时

下图是小程序的启动过程:

影响启动的四个关键因素:

1.引擎预热耗时:引擎初始化与小程序框架注入耗时;

2.小程序信息接口耗时:获取小程序权限、基本配置信息、版本等信息,耗时相对固定;

3.小程序加载耗时:跟业务包大小而变化,包含读取业务代码注入到小程序执行环境以及原生UI初始化耗时;

4.业务初始化耗时:根据业务初始化逻辑与首屏网络接口耗时而变化;

3.1 小程序信息接口异步化功能

问题:启动小程序会同步获取小程序信息接口,小程序打开过程存在耗时。

目标:减少小程序信息接口请求耗时。

行动:

1.通过将小程序信息同步接口信息提前缓存;

2.打开小程序优先使用缓存信息,将同步接口异步化处理;

结果:接口改为并行后,平均数据减少158ms左右。

3.2 小程序按需加载功能

问题:随着业务增长,小程序二进制包达到6MB+(包解压之后),JS注入执行已经成为耗时主要原因。

目标:降低启动时业务注入时的代码量,从而降低注入耗时。

行动:

1.编译改造:将渲染层、逻辑层注入内容只保留业务公共框架的部分,页面的JS代码剥离到对应页面中;

2.启动改造:启动时只注入公共框架与当前页面相关的代码,减少注入额外内容;

3.改变包大小【具体操作可见3.3部分门详包瘦身】

结果:优化前:6MB+ 数据,优化后:注入2.7MB数据, 使用小米11手机测试启动性能,未使用按需加载功能的启动时间为:1200ms左右,使用按需加载功能的启动时间是:800ms左右,性能显著提升。

3.3 门详包瘦身

问题:门详包的大小会直接影响小程序的加载时长

目标:降低包大小,从Release代码压缩包1.2M降低到0.6M以下

行动:

1.分析包体积的构成,检查不合理的模块,移除冗余无用的模块;

通过打包工具,查看包中内容,分析得到包体积较大的模块是 vendors 和 common 模块。

vendors: node_modules 除 Taro 外的公共依赖;

common: 项目中业务代码公共逻辑;

taro: node_modules 中 Taro 相关依赖;

runtime: Webpack 运行时入口;

2.逐个模块优化

① 下线掉无用业务模块;

② 优化 vendors 模块,明显可以看到一些不合理的包被编译进来了,比如 Axios,小程序的请求一般用jd.request,理论上用不到Axios,此时我们可以打开 package-lock.json文件看下,是哪个包依赖了这个模块。例如:@plato这个包应该是在微信域的地址组件,在京东小程序里使用APP原生组件,所以可以采用端区分的方式process.env.TARO_ENV进行剔除。

重复上述步骤, 检查 pako lego jsencrypt inversify ,将无用的依赖剔除,优化后的结果,由783KB下降到318KB

③ 优化 common 模块,包括:

common.jxss:经检查,我们的样式文件中有不少icon使用的是base64,会增加包体积,此处我们可以采用雪碧图方案或者网络图进行替换。

common.js:对存量代码进行优化,减少重复代码。此处我们可以借助eos进行代码扫描,检查到重复代码块进行代码合并。

3.制定包瘦身规范,长期控制包大小增量;

① 预防为主

在开发初期就要注重代码和资源文件的优化,避免不必要的代码和文件冗余,禁止重复造轮子;

资源引入规范:使用到三方功能,需要将必要的功能拆解后独立引用,媒体资源引入,需以CDN方式,不允许直接放入项目本地;

多端区分规范:合理使用process.env.TARO_ENV​ 进行代码剔除,不要引入无用的模块;

优化构建工具:使用或开发构建工具来优化最终包的体积,如webpack 、terser 等工具;

② 防治结合:

定期对小程序进行体积检查和优化,移除无用代码和资源,更新第三方库到更小体积的版本,以及优化图片和视频资源;

定期审查和优化项目依赖,移除不必要或重复的库和框架;

在代码review审查过程中检查新增代码对包体积的影响,避免重复的轮子入库;

③ 综合治理:

建立反馈机制,当包体积超标时,能够及时通知相关人员;

借助eos扫描,关注代码重复率,重复率高则需要及时制定整改方案;

结果:经过上述过程后,包大小从1.2M降低到0.49M。

3.4 应用启动耗时减少:(首屏渲染 、 接口预请求)

问题:由于我们是基于LBS的门详业务,首页接口请求完成后,才能进行页面的整体渲染,所以首页主接口请求的耗时时长也成为我们loading时间长的原因之一;另外接口和地址获取串行的逻辑也加长了整体的数据链路;

目标:业务加载时长在中高端机型降低到500ms以内

行动:

① 将主接口的请求进行提前 -> 主接口预热

一般来说小程序首屏的可交互时间都会受代码包注入执行时间影响,在代码执行完毕后才能开始业务网络请求,请求数据返回后才能开始真正的内容渲染,为了解决这一痛点,京东小程序Clips是一种通过三线程架构实现的特色能力,可以在小程序启动过程中注入一个轻量化代码片段,提前做初始化数据,网络请求等非UI功能,当小程序主环境准备好后,可直接将数据送给逻辑层,实现小程序业务预热能力。

通过cilp文件发起预热请求的功能,以及在小程序实例化后对该请求结果的监听;达到首页的接口请求在我们小程序解析的同时,进行了数据的请求,实例化小程序后,在首页实例完成,准备请求首页数据时,如果预热接口已经返回,则直接用预热的结果,从而达到减少主接口请求的耗时。

② 将地址逻辑前置 -> 实例小程序的同时,获取地址信息

结果:串行逻辑修改成并行之后,业务从开始loading 到出现渲染内容,整体时长在iphone12上可以达到350ms左右。

③ 全局渲染优化

主要原因:

① 跳转协议、地址信息、主接口数据都是以Recoil状态值的方式存在,且颗粒度细碎,大面积多次造成全屏重绘。

② 页面渲染时,楼层多,细粒度状态触发频繁,导致最终整体DOM呈现时间晚,白屏等待时间久。

分析解决:

① 状态归类合并,区别状态和变量

② 分批渲染楼层,局部渲染

实现方式:

采用全局Context状态管理的方式,重新改造了页面数据监听方案;从而达到减少多次渲染重绘的效果;

最终实现效果如下图:

上图中左侧是优化前,使用Recoil状态绑定组件,改变即变更的渲染方式。

上图中右侧是优化后,使用React Context归类合并后的数据源,通过改变Context值,一次性触发数据更新,并分批渲染。

新的Context将Recoil的状态改成值的方式,同时通过观察者模式给予组件订阅变量更新的能力,尽管Recoil的状态绑定更方便使用,但是其缺乏变量值的管理,因此可以采用Context弥补这一缺失的能力。

Recoil用法的建议:

1、使用Recoil状态的地方,一定是JSX中用到的值(状态变更则触发组件刷新),不要在逻辑中使用的变量也使用状态值;

2、Recoil的颗粒度可以根据独立的业务模块设定,减少频繁无效的设置值,对象变更提前做好数据比对;

3、组件只需要改变状态,不需要依赖状态时:推荐写法:const setName = useSetRecoilState(nameAtom);

3.5 整体启动过程对比示意图如下:

四、交互体验

4.1 门详整体滑动优化

以下是美团外卖、京东到家app门详和门详小程序的滑动效果对比分析:

通过分析得出以下3个关键交互体验问题:

① 手势体验问题;

② 商品列表列表留白,分类切换不流畅、二级列表偶现闪动的问题;

③ 内存占用高;

4.1.1 整个货架手势的支持

前期我们做了支持手势的调研:

方案对比 movable-view 方案 jds 动态设置样式方案 scroll-view嵌套
调研结果 拖动效果卡 部分功能暂时不支持,无法获取自定义组件实例 在内层容器到顶部,内层切换滚动属性时,会顿一下

根据调研结果综合评估:我们采用了****scroll-view嵌套的方案,ScrollView嵌套场景下,我们要进行手势的交互,以及跟手的处理,所以我们面临了一些挑战:

① 现存代码使用了大量的touch事件拦截,手势重构成本高;

② 页面存在多层吸顶和吸底动画交互效果,需要有合理的替代方案;

原有支持自动吸顶,我们通过动态设置外层scrollTop替换原动画方案

③ 多层scroll-view嵌套,带来了多处手势冲突问题;

在Taro3中小程序逻辑层实现了一套事件系统,包括事件触发和事件冒泡。在小程序模板中绑定的事件都是以bind的形式。

一般情况下,这套在逻辑层实现的小程序事件系统是可以正常工作的,事件回调能正确触发、冒泡、停止冒泡。小程序原生模板中绑定的catchtouchmove事件除了可以阻止回调函数冒泡触发外,还能阻止视图的滚动穿透,但是Taro的事件系统是做不到的。

Taro为我们提供的解决方案:可以为View组件增加catchMove属性:

// 这个 View 组件会绑定 catchtouchmove 事件而不是 bindtouchmove
<View catchMove={catchMove}></View>


基于以上情况,我们采用动态控制catchmove的方式,来实现是否向上进行事件冒泡通知外层ScrollView。

根据用户手势上推和下滑,控制内层ScrollView的scrollY是否可以滚动。

我们在Taro组件中,动态设置catchMove来控制是否向上冒泡,但是在一些场景发现一个问题,已经完成渲染的dom树在切换catchMove时,DOM元素在视图内不可见。后经过与Taro团队沟通,在设置catchMove 时,Taro会使用不同的静态模版 ,为了解决该问题我们选择将catchMove 绑定在原生小程序组件上,采用原生和Taro混用解决这个问题。

封装小程序原生组件:
<view id="container"  catchtouchmove="{{catchMove}}">
  <slot></slot>
</view>


原生组件嵌套Taro组件:
<native-container catchMove={catchMove}>
  嵌套Taro组件
</native-container>


经过我们不断的调优,各种机型的测试,最后优化前视频和优化后对比如下:

4.1.2 列表流畅度优化

中低端机型测试:在门详商品列表中浏览xxx个品后,页面出现卡屏卡死现象;

所以针对上述问题,我们进行了列表的流畅度优化:

曝光元素查找方式优化

实践发现曝光createIntersectionObserver回调获取元素的方式严重影响页面的滑动流畅度。以下给出两种写法:

const categroyNodeObserver = createIntersectionObserver(Taro.getCurrentInstance().page, { observeAll: true })
categroyNodeObserver.observe('#app >>> .className', result => {
    // 1、通过class获取列表再遍历查找对应元素
    const vnodes = document.getElementsByClassName('className') as unknown as HTMLCollectionOf<Element>[];
    const target = vnodes.find(vnode => vnode.uid == result.id)

    // 2、通过id直接获取元素
    const target = document.getElementById(result.id);
})


通过对比:第2种获取元素的耗时在个位数毫秒级,第一种耗时高达上百毫秒。

数据结构优化 - 树结构分类扁平化处理

商品货架分类是一个嵌套树结构,最多有三级,如下图扁平化前黄色区域所示。

在代码逻辑中,多处需要获取末级分类进行逻辑处理,比如:① 点击1级分类,需要找到其下的第一个3级分类进行接口请求;② 点击2级分类,需要找到其下的第一个3级分类进行接口请求;以上两种情况还有例外:如果没有3级分类,就会变成使用2级分类进行接口请求。如下图所示:

未扁平化的数据结构向后查找下一个分类最大时间复杂度O(n);数据结构扁平化后,将嵌套结构改成单层链式结构,如上图扁平化后绿色区域,后续可以直接在新的数据结构上处理,向后查找下一个分类最大时间复杂度O(3)。

扁平化递归算法:

普通递归,随着分类数据的增加,递归调用栈很多变量开辟了内存空间未被释放,所以要用到尾递归。

尾递归是一种特殊的递归,它的特点是在函数的最后一步调用自身,而不是在调用后还有其他操作。尾递归可以有效地避免栈溢出的风险,因为它不需要保存每次调用的上下文,只需要保留一个栈帧即可。尾递归也可以提高递归的性能,因为它减少了函数调用的开销。

4.1.3 支持列表补足功能

现有设计方案中,多分类商品无联动加载逻辑,滑动时有顿挫感,不流畅。

因此,我们在分类扁平化的基础上,将多分类商品进行连续请求处理,占满屏幕,如下图右侧效果。

列表优化过程:

① 新的加载流程如下:

商品列表滑动过程中,分类的联动选中,我们的做法是:在上述流程图中的组装数据环节,在分类末尾位置增加标记位,一条1px的线,然后利用createIntersectionObserver,监听该元素上推消失和下拉露出的时机,联动选中对应的1,2,3 级分类。

② 列表分页加载方式选择

触底加载时机试验对比

监听Loading的露出来作为下一页的请求回调时机,如下图:

③ 列表分页渲染层级优化

现状:采用递归的方式 + CustomWrapper 进行渲染列表。有效解决每次setData都是全数据耗时的情况。同时引入一些弊端,其中主要的问题是,头部排序条的position: sticky,在多分类联动商卡页数较多的时候,层级嵌套多层后,sticky的元素会被推出可视区域外。

为了实现setData时,Taro可以增量给小程序传递数据,我们回到React底层diff原理,考虑diff可以通过key值优化,于是在Render Row每行元素上增加位置key值,结果:Taro即可进行自动diff,更新单页数据,这样DOM结构由原先的嵌套结构变成一层。解决了上述sticky的问题,也提升渲染性能。

加载10页,每页setData大约消耗时间:

4.1.4 列表内存占用过高

通过Chrome://inspect性能检测工具,做了内存快照比对发现主要原因有两点:

① Taro侧: 经过内存快照对比工具,发现Taro侧有两个问题,一是清空列表前后 Taro 生成的 react fiber 树(th对象)一直持有缓存数据,二是每次setData的数据没有释放掉,导致内存持续增长。

② 小程序引擎侧:在自定义组件更新时,diff前后virtualTree计算出需要更新的节点,对于 jd:if 和 jd:for 标记的节点没有识别并清理掉。

经过和引擎侧和Taro侧多次沟通联调,最终解决上述两个问题。Taro侧问题是升级到3.5.1解决,引擎侧是商城App 12.2.4 版本修复。

4.2 门详二级页面/购物车卡片/门详弹窗优化

随着业务增多,门详二级页面的需求量也在不断迭代,需要支撑业务进行更多品,更多功能的展露,例如加价购、搭配购买等等;所以我们针对各种二级页面和弹窗卡片也进行了性能上的体验优化;从中主要解决了以下几类问题:

① 列表页面的整体列表抖动问题的修复;

② 二级页面的渲染时序优化,组件插入方式变更;

③ 小车卡片上大量数据加载的扁平化处理,分页加载功能的支持;

④ 弹窗功能的整体统一;

二级页面的详细优化后续我们会继续发文。

五、能耗优化

实测:门详商品列表滑动50页左右,手机明显发烫,耗电严重,所以高能耗也是我们需要高优解决的问题。

具体分析步骤如下:

① 手机静止不动,手机发热、发烫;

② 列表不断滚动,手机发热、发烫;

5.1 静止状态下的消耗

① 布局与渲染:说明有dom内容一直在变。如倒计时、banner轮播.

② 媒体与动画:说明有css在一直执行动画。如呼吸动画、loading.

③ javascript:如倒计时事件.

基于以上,我们针对站内的倒计时,页面的Banner轮播 做了隐藏停止的处理;动画效果的css样式由gif图进行了替换;最后达成效果如下:

能耗的结果:

静止情况:能耗由高降为低。

在滑动情况下:我们做了滚动防抖和图片大小优化,滑动瞬时能耗降低了20%,在滑动70页后开始发热,在dom节点数量以及节点深度上还有优化的空间,后续我们会继续分享。

我们总结了能耗优化的一些方向,并放到了我们的开发规范中:

影响能耗的类型 要求
css动画类 animation xxx 1s infinite; 用完移除,不要在后台一直执行动画
setInterval 倒计时 结合requestanimation优化
swiper组件类 视口外停止轮播 加长轮播间隔时间
image组件类 根据视口图片大小加载适当尺寸的图片
scroll-view 滚动类 避免节点过多 避免节点层级过深
onScroll回调 回调事件防抖 避免复杂逻辑
元素监听类 监听及时移除 监听回调事件避免做复杂逻辑

六、埋点治理

我们在9月份最后一个版本上线了全站的埋点方案的整改。其中包含底层埋点(PV CLICK EP)埋点参数的逻辑优化,以及所有业务上报埋点的机制与时机处理。整改之前,埋点存在部分问题:

① 曝光埋点的上报时机有误,不可用;

② PV埋点和点击埋点缺少规范,业务添加埋点不规范,成本高;

③ 曝光请求未聚合,导致频繁发起埋点请求;

整改之后,我们输出了门详的整体埋点开发规范;重新整理了所有曝光埋点的上报时机与整体方案;目前门详埋点可用率达到95%以上;为业务数据提供了更加准确的方向。

七、总结与展望

以上是我们近期优化的阶段性成果,不仅得到了业务和产品同学的认可,而且线上用户反馈门详加载慢、卡顿的问题占比降低了约15%。

用户体验提升是需要长期坚持,技术同学需要具备一定的工匠精神,持续探索,我们在规划中的事情还有:

① 能耗层面的继续探索与优化;

虚拟列表的支持:目前门店列表支持多分类的加载联动,且在同一个分类下进行了组件复用与回收;下一步,我们将继续进行跨分类的组件复用与回收,将门详列表的回收渲染机制形成统一性的解决方案。

嵌套层级过深导致的性能消耗:进一步减少门详组件嵌套的问题,从内到外进行整体组件嵌套层级的优化,减缓手机发热发烫的问题。

② 优化方案同步微信域;

③ 共建能力的支持;

提供门详组件的共建能力,将门详组件与业务进行解耦。

④ 小程序对H5的转化;

⑤ 近原生体验的提升:视频能力的支持;

⑥ 小程序侧手势的支持;

期待我们在高效支撑业务迭代的同时,可以给用户带来更极致的体验!!

要特别感谢以下团队和部门:

京喜与新业务研发部-京东小程序研发团队,感谢你们在性能优化方面给予的大力支持。

Taro团队,你们的支持对我们至关重要。

LBS业务产品组-小时购前台产品团队,你们的努力让我们的产品更加完善。

同城研发部-质量提升团队,你们的专业测试保证了我们服务的高质量。

同城研发部-到家平台研发组,感谢你们在整理工作中的协助。

你们的帮助对我们的成功至关重要,再次表示衷心的感谢!

作者:京东零售 姜微、邓树海、章文顺、王冰洋

来源:京东云开发者社区 转载请注明来源

热门相关:我的黑月光女友   现代隐士高手   唐枭   隐身侍卫   在遗忘的时光里重逢