长列表虚拟加载 解决 离屏渲染问题

Quick Start

  1. 直接看解决方案请看标题:skeleton component
  2. 点击获取Demo代码片段
  3. 项目实践代码
  4. 看对比效果,请直接下载附件中的视频文件

长列表离屏渲染问题(渲染长列表,附带着分页加载数据)在前端是一个很常见的问题。Android开发中,Google有RecyclerView方案解决。web开发中,社区也有很多虚拟列表等方案来解决此方案,比如React中较为常用的是react-virtualized和react-tiny-virtual-list。

在小程序中呢,它有自己的开发规范,似乎并没有直接与web社区互通,于是官方自己推出了小程序长列表组件recycle-view,但在使用过程中,由于适用场景的局限性,以及组件自身的瓶颈,在业务场景中,部分Android机型快速滑动有白屏问题。

通过看源码,小程序recycle-view核心的思路是只渲染显示在屏幕的数据。(这个可以借鉴,前端的长列表渲染优化思路,本质都是渲染视窗内可见的数据)不过呢,它的实现是监听 scroll 事件,并且重新计算需要渲染的数据,不需要渲染的数据留一个空的 div 占位元素。

小程序recycle-view是怎么计算当前视窗需要加载哪些数据呢?它的一个瓶颈是,列表中每一项的高度必须固定。然后将列表位置和数据列表对应的一项对应起来。根据滑动的位置,来计算当前需要加载哪条数据,然后加载过来。

原理很不错,但是看源码的实现有些复杂,且在微信开发社区开发者的反馈并不乐观。笔者并没有耐心细读研究源码。但这个过程中,了解到一个更简单的思路,主要借用了IntersectionObserver API来实现监听数据项是否进入或者离开屏幕可见区域。同时采样【骨架】组件在占位来不及加载的组件,这样就避免了白屏问题,同时由于骨架组件只不过是一个简单的占位结构,并不真正加载实际复杂的数据项,所以骨架损耗的性能远远小于实际的列表项。

背景

项目中商品列表最初实现方案就是HTML Lists, 问题也很明显,有如下:

  1. 列表数据变大时,首次 setData 的时候耗时高 。
  2. 渲染出来的列表 DOM 结构多,每次 setData 都需要创建新的虚拟树、和旧树diff 操作耗时都比较高。
  3. 渲染出来的列表 DOM 结构多,占用的内存高,造成页面卡顿。

小程序开发社区有很多开发者都有类似的问题,比如

请教大家长列表优化方案

列表数据过多白屏,mpvue无法使用长列表,怎么解决这个问题?

长列表页滚动过快会白屏卡顿

【长列表卡顿】-小程序列表渲染过长,页面卡顿,事件相应延迟?

官方推出了小程序长列表组件recycle-view,跟官方走,准没错。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SjJ0pJ11-1673762306861)(https://km.woa.com/asset/36213ed1db5c4032934c3208a1d91869?height=107&width=105)]
事实并非如此。

在使用过程中,遇到很多诡异的bug,并提了issue,比如
https://github.com/wechat-miniprogram/recycle-view/issues/65
同时,小程序开发文档有提到:

在滚动过程中,为了避免频繁出现白屏,会多渲染当前屏幕的前后2个屏幕的内容。

但这并没有彻底解决快速滑动白屏的问题。
后面咨询小程序相关的开发同学,对方说:

长列表是有白屏问题的,所以一般建议item比较简单的可以使用。比如像微信的通信录。

不过细细想这也是很难避免的。这是一个【trade-off】的问题,要想渲染的性能高,就想办法只渲染视窗内可见的列表,在滑动的过程中,上下两页数据来不及渲染,会有白屏。所以就牺牲一点点性能,多加载前后两页的数据。但如果滑动速度更快呢?是不是要加载前后四页的数据?更快更的滑动速度呢,极限情况下就是全部加载完。

有没有其他方法?

有没有其他思路,可以优化这个问题呢?

使用懒加载行不行

将超出屏幕一定部分的列表内的组件进行不渲染的处理(也就是用wx:if卸载掉组件),当到达渲染临界点时再开始渲染;保证每次少量的数据展示。

2. 如何监听数据在不在屏幕可见范围呢?除了官方recycle-view那种监听scroll事件,有没有更优雅更简单的方式呢?(web 中的 Intersection Observer API或许更优雅,对应小程序版本是IntersectionObserver API

3. 如何避免白屏体验呢?白屏主要滑动太快是来不及渲染造成的,来不及渲染往往是因为要渲染的内容过于复杂,甚至其中的图片需要请求url,其时延自然很长。那如果我们用一个非常简单的样式骨架结构去占位呢?

所以就有了下面的skeleton component

skeleton component

项目实践

先贴上实现:

src/wxcomponents/skeleton

上面是在项目中的实践,这里准备一个Demo,前提是电脑安装了微信开发工具,点击获取代码片段:
https://developers.weixin.qq.com/s/GkaFgomR7bdW

原理图

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z3EZoEj7-1673762306861)(https://km.woa.com/asset/3283d6e34a11414b952aaa3a1a2e4043?height=411&width=132)]

skeleton.wxml

<view class="list-item" id="list-item-{{skeletonId}}" style="min-height: {{itemHeight}}px;">
<block wx:if="{{showSlot}}">
    <slot></slot>
</block>
</view>

逻辑判断 wx:if="{{showSlot}} 来决定是否展示。

wx:if 也是惰性的,如果在初始渲染条件为 false,框架什么也不做,在条件第一次变成真的时候才开始局部渲染。

这里不使用hidden,原因请见:wx:if vs hidden

skeleton.js

核心逻辑为:

                    this.extData.listItemContainer = this.createIntersectionObserver();
                const offsetTop = wx.getSystemInfoSync().windowHeight * 0.4;
                this.extData.listItemContainer.relativeToViewport({ top: -offsetTop + this.data.showNum * h, bottom: this.data.showNum * h })
                    .observe(`#list-item-${this.data.skeletonId}`, (res) => {
                        const { intersectionRatio } = res;
                        if (intersectionRatio === 0) {
                            console.log('【卸载】', this.data.skeletonId, '超过预定范围,从页面卸载');
                            this.setData({
                                showSlot: false
                            });
                        } else {
                            console.log('【进入】', this.data.skeletonId, '达到预定范围,渲染进页面');
                            this.setData({
                                showSlot: true
                            });
                        }
                    });

也就是使用了IntersectionObserver API

DOM数对比

使用skeleton后渲染之后,DOM数结构是这样的。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OzjSnydT-1673762306861)(https://km.woa.com/asset/8c160bfec2644f44b0133d4ee2931013?height=815&width=992)]

经测试,如果商品列表共22个,可见的列表个数是5个,由于

        //超过可见区域的数量
    showNum: {
        type: Number,
        value: 2
    }

showNum 默认为2个,所以会多加载可见区域前后2个数据项,所以完整加载的数据项为 5 + 2*2 = 9个,远小于正常的22个,因为性能消耗大大降低。

这里也可以将showNum进一步减小为1,将会进一步提升加载速度。

优化视频

优化后体验视频(见附件)

  1. 没有白屏
  2. 白屏列表出现隐藏耗时较少
  3. 列表滑动过程中体验比较流畅

对比视频(见附件)

若无法预览,请下载
【视频对比-优化前.mkv】
【视频对比-优化后.mkv】
进行对比观看。

可以看出,虽然列表滑动过程中,带来的优化效果不是很明显。但是,隐藏半屏列表的提升效果十分明显。

为了对比出效果,将列表数量增加到了124个,基本上是现网正常数量的4-7倍。

可以看到,现网版本优化前,快速滑动也会有白屏。而且半屏列表消失的时间实在太长,简直无法容忍。毕竟渲染了大量了DOM树结构,大概有120个左右。

从优化后的视频看出,半屏列表消失的时间很快,因为它只渲染了可见的列表加上前后可见列表前后几个的数量,不超过15个。

所以,优化后的DOM销毁卸载速度大大提升,滑动列表时的体验也有一定提升。

Trace分析

原始方案:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Un5HkKDj-1673762306862)(https://km.woa.com/asset/4b73c488d9af4d94a8035a807b8d0666?height=409&width=851)]

滑动过程中,平均的再次渲染时间为3.652s

优化后的方案:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ca7kT0qs-1673762306862)(https://km.woa.com/asset/da9087dbb0d24ca0b383ac9bdfde1925?height=350&width=818)]

滑动过程中,平均的再次渲染时间为1.981s

有大幅度的提升。

后续优化思路

setData 优化

过长的list需要做二维数组,因为setData一次只能设置1024kb的数据量,如果过大的时候,就会报错。

setData最好不要一次性传很大的数据。小程序基于双线程机制,逻辑层和渲染层是分开的,所以当调用setData的时候,涉及到逻辑层和渲染层之间的数据传输,如果一次性setData的数据过大,就会出现卡顿现象,甚至报错。

解决方案:分批setData,减少一次setData的数量。不要一次性setData list,而是把每一页一批一批地set Data到这个list中去。

具体实现:利用小程序setData方法支持的数据路径的写法。定义一个二位数组,每次得到的新一页的一维数组,通过数据路径写法加到这个二位数组中去,结构大概是:[[page1List],[page2List],[page3List]……]

目前问题:
由于项目中数据处理逻辑相当复杂,比如分页处理,去重等,改成二维数据的方式拉取涉及的代码很多,暂不考虑这块优化。

他山之石

1. virtual-scroller(未来 Web 高层级 API之一)

为表诚意,先献上官方github地址:

WICG/virtual-scroller

其中有段话不谋而合:

The idea of a virtual scroller is to provide a scrolling “viewport” onto some content, allow extremely large numbers of elements to exist, but maintain high performance by only paying the cost for those that are currently visible. Traditionally, we say that the non-visible content is virtualized.

翻译过来就是

虚拟滚动器的概念是在某些内容上提供一个滚动的“视口”,允许存在大量的元素,但是只需要为当前可见的元素付出代价就可以保持高性能。传统上,我们说不可见的内容是虚拟化的。

virtual-scroller是未来 Web 平台的一个潜在特性,是 layered API 项目的一部分,用于将 JavaScript 对象集映射到 DOM 节点上,并且只渲染当前可见的 DOM 节点,其余部分为“虚拟”的。

2. React相关

react-window

react-virtualized的轻量快速版本。

react-virtualized

实现原理同virtual-scroller,react-virtualized作为一个长期维护的库,其使用量很大,相关实现细节更完善。

3. Flutter相关

flutter平台也有类似的长列表离屏渲染问题,它是怎么解决的呢?

ListView

ListView.

标准的 ListView 构造函数适用于短列表,对于具有大量列表项的长列表,需要用 ListView.builder 构造函数来创建。

与标准的 ListView 构造函数需要一次性创建所有列表项不同的是, ListView.builder 构造函数只在列表项从屏幕外滑入屏幕时才去创建列表项。

SliverList

如果不了解SliverList,参见
Flutter Widget Guide — SliverList Widget in 5 mins or less

以上两个组件会判断子组件是否是在可视区内,在可视区内就渲染,不在就销毁组件,以节省性能。

4. Android方面

Android中的RecyclerView 个人感觉还是很不错的。它的缓存复用做得很不错。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-r7FAbxve-1673762306862)(https://km.woa.com/asset/822243b8877948ea912da469a5edcf3e?origin_url=https%3A%2F%2Fuser-gold-cdn.xitu.io%2F2018%2F8%2F20%2F1655326cbe18dc01%3Fimageslim)]

5. 其他

Angular Material 的 Virtual Scroll

Polymer的 iron-list

iOS的UITableView

tips:

style="min-height: {{itemHeight}}px;"

可能会导致监听频繁刷新。

参考

小程序长列表组件recycle-view

小程序长列表优化–罂粟1995

https://juejin.im/entry/5bf495d76fb9a049f23c5eac

Flutter Widget Guide — SliverList Widget in 5 mins or less

IntersectionObserver API

wx:if vs hidden

web 中的 Intersection Observer API

解决小程序渲染复杂长列表,内存不足问题

RecyclerView缓存原理,有图有真相

特别感谢

最后,声明下本文思路受启发于

作者:宗仔GEG
文章:解决小程序渲染复杂长列表,内存不足问题

文中Demo也来源于此文。

特别感谢~

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值