小程序端会话场景下长列表实现
发布于 2 年前 作者 lei92 5206 次浏览 来自 分享

1 前言

腾讯云医小程序中有医生和患者聊天的场景,在处理该场景的列表过程中遇到两个问题: 一是下拉加载历史消息时需要在容器顶部进行衔接导致的界面抖动问题;二是大量的会话内容导致的长列表问题。

问题一:插入历史消息带来的抖动问题是因为在已有dom的前面插入dom。如果能够在已有dom的后面插入新增dom并且在视觉上看起来是在顶部插入的则可以解决该问题。前端开发中聊天场景的体验优化一文中给出的方案是transform:rotate(180deg);。另外flex-direction:reverse也是可以做到的。

由于会话场景的一些其他特点如列表初始化时定位在底部(新消息在底部),本文的实现采用了transform:rotateX(180deg)方式处理进行处理。由于只需要在垂直方向进行翻转,所以在实现时使用rotateX代替了rotate。

下面简易demo说明该样式应用后的效果

  .container {
    height: 100px;
    overflow: auto;
  }
  .item {
    width: 100px;
    border: 1px solid black;
    text-align: center;
  }
  /*关键*/
  .x_reverse {
    transform: rotateX(180deg);
  }
<div class="container x_reverse">
  <div id="item-1" class="x_reverse item">数据项-1</div>
   ...
  <div id="item-9" class="x_reverse item">数据项-9</div>
</div>

添加.x-reverse样式前后的初始状态对比
翻转前

翻转后

问题二: 长列表问题。
我们先在h5端看下大量的dom会有哪些问题,如下demo验证

<button id="button">button</button><br>
<ul id="container"></ul>  
document.getElementById('button').addEventListener('click',function(){
    let now = Date.now();  
    const total = 10000;  
    let ul = document.getElementById('container');    
    for (let i = 0; i < total; i++) {
        let li = document.createElement('li');
        li.innerText = Math.random()
        ul.appendChild(li);
    }
  })

在chrome的开发者工具performance栏下记录点击button后的运行过程,可以看到包含脚本运行在内的整个运行过程中 Rendering部分占用时间最多(包含Recalculate StyleLayoutUpdate Layer Tree)。当列表项数越多并且列表项结构越复杂的时候,会在Recalculate Style和Layout阶段消耗大量的时间,所以有必要减少列表项的同时渲染。

小程序的架构决定着小程序端该问题相较于h5端更为突出。在微信小程序官方文档 -> 指南 -> 性能与体验部分提到一些点如:setData数据大小、WXML节点数等原因都会影响到小程序的性能。以及图片资源的主要性能问题在于大图片和长列表图片上,这两种情况都有可能导致 iOS 客户端内存占用上升,从而触发系统回收小程序页面。显然在长列表场景下如果一次性将所有的数据全部加载出来就会有WXML节点过多,setData数据量过大的问题、图片资源过度等问题。

这些问题不仅仅是列表在初始化的时候存在,如在插入新数据(unshift)需要将整个数组进行传递,以及更新列表项数据时diff时间也会增大。

微信小程序官方提供了recycle-view组件来解决等高列表项的情况。但是对于会话场景下消息的高度是不等的,因此我们得自己实现一套符合这种特性的长列表组件。

2 接入前后对比

2.1 视频效果对比

对比腾讯云医小程序会话接入长列表组件前后的效果,优化前滚动过程中有卡顿的感觉,并且在发送消息的时候,消息输入框进入到列表中的延迟能够比较明显的感受到,优化后滚动较丝滑,并且发送消息没有明显的延迟。
接入前:https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/after-chat.mp4"
接入后:https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/before-chat.mp4"

对比腾讯云医小程序->群发助手下的患者列表初始化和选中时接入长列表组件前后的对比

接入前 :https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/group-send-before.mp4
接入后:https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john/group-send-after.mp4"

2.2 数据对比

这里对比下群发助手接入前后的setData(发起到回到)时间的对比

初始化用时对比

选中item用时对比

上面两张图的横坐标是数据条数,纵坐标是setData时间,可以看到无论是初始化还是选中操作二者的轨迹都是相似的

明显的看到接入前,setData的时间随着数据量的增大越来越大,接入后则没有这个问题。显然,接入后通信的数据量,diff时间,浏览器渲染时间都会较少。

3 基础实现

关于长列表实现的基本思路是只渲染可视区域及其附近的几屏数据,但是由于小程序端和h5端架构的差异导致二者在具体实现上存在差异。

3.1 如何模拟滚动条?

h5实现长列表的常规思路

<div id="list-container">
    <div id="list-phantom"></div>
    <div id="list">
      <!-- item-1 -->
      <!-- ... -->
      <!-- item-n -->
    </div>
</div>
  • #list-container 滚动容器
  • 通过引入#list-phantom来占位,高度为列表项高度之和用于撑开容器形成滚动条
  • #list用来装载列表数据

当有新的列表项添加后,则更新#list-phantom高度从而达到模拟滚动条的目的,然后通过监听#list-container的scroll事件在其回调中根据scrollTop来计算出现在可视区域的内容。

浏览器是多进程多线程架构,浏览器中打开一个tab页时可以认为是打开一个渲染进程,渲染进程中包含了GUI渲染线程(包括了html、css解析,dom树构建等工作)和js引擎线程等等。我们知道GUI渲染线程和JS引擎线程是互斥的,js引擎发起界面更新到渲染完成是同步的。

而小程序架构的通信是异步的,比如逻辑层setData发起通信到渲染层,通信过程中渲染层依然在执行的。如果按照h5的思路去计算,逻辑层计算的结果到达渲染层后就已经不是正确的结果了即界面中的数据和滚动条的位置是对不上的。

为了保证滚动条的位置和数据项所在位置是正确对应,起初的想法是列表项消失后通过一个同等高度的div元素进行代替,这样做带来的问题是依然会产生大量的dom元素。进一步的想法是通过对列表数据进行分组并且每个分组在界面中会存在一个真实的dom(称为分组dom)来包裹该分组内的所有列表项,并且认为每个分组算是一屏数据,当每个分组从界面中消失时,分组dom不会删除,只会删除内部的列表项,并且将消失的列表项高度之和赋值给该分组dom。这样解决了滚动条高度的问题,并且不需要计算具体哪些列表项数据需要被加载出来,只需要知道加载哪(些)个分组即可。

分组的想法既简化了计算又保证了数组项和滚动条的位置是正确对应的。

高度的获取和赋值是在wxs里面做的,由于wxs是在渲染层执行的,相比在逻辑层减少了通信的成本。

下面给出简易(伪)代码来描述这段过程

  • 视图层
<scroll-view 
             clearingids="{{clearingGroupIds}}"
             renderingids="{{renderedGroupIds}}"
             change:clearingids="{{module.clearingHandle}}"
             change:renderingids="{{module.renderingHandle}}"
             class="list-wrapper x_reverse">
  <!-- 分组dom -->
  <view class="piece-container" 
        wx:for="groups" 
        wx:for-item="group"
        id="piece-container-{{group.id}}">
       <view class="x_reverse" wx:for="group.data">
         {{item.content}}
       </view>    
  </view>
</scroll-view>
  • 逻辑层
// 分组数据结构(二维数组)
groups:[ { 
            id: 1, // 分组id 
            data:[{ content:{a:'a'} },...] 
          }
          ,...],

// 当前需要渲染的分组
renderingids:[],
// 需要移除的分组
clearingGroupIds:[]

wxs 更新分组dom高度,用法参考官方文档WXS响应事件

module.exports = {
  clearingHandle:function(clearingGroupIds, oldV, ownerInstance){
    clearingGroupIds.forEach(function(groupId){    
      // 1. 根据 groupId 找到对应的分组dom
      // 2. 获取分组dom高度
      // 3. 设置分组dom样式:height
    })    
  },
  renderingHandle: function (renderingGroupIds, oldGroup, ownerInstance) {
    renderingGroupIds.forEach(function(groupId){    
        // 1. 根据 groupId 找到对应的分组dom
        // 2. 移除height样式
      })
    }
}

3.2 如何知道渲染哪些数据

当有新的数据需要渲染到列表中时,首先是对数据进行分组,然后通过小程序提供的IntersectionObserver能力对分组dom进行监听,在其回调中判断该dom是否进入scroll-vew从而来更新正在渲染的分组和需要移除的分组。

  // 滚动容器domId
  const containerId = '#scroll-container-xxx'
  
  // 创建监听
 _createObserver(groupIds = []) {
   groupIds.forEach(groupId => {     
     const observer = wx.createIntersectionObserver(this).relativeTo(containerId);
     observer.observe(domId, this._observerCallback);
   })
 }
 
 // 监听回调
 _observerCallback(result) {
    // 1. 根据result拿到domId然后解析拿到groupId(domId包含了groupId信息
    // 2. 判断当前分组是否在视口内,如果不在视口内直接返回
    // 3. 如果分组在视口内,则计算需要渲染的分组ids和需要移除的分组ids  
    // 4. 通信至视图层,渲染目标分组数据和移除失效的分组数据(
    //    4.1 移除的优先级不高,不应该阻塞渲染目标分组,因此可以通过debounce/throttle处理)
    //    4.2 短时间内多次setData会导致通信通道阻塞,比如可以将setData放在队列中处理,一个一个来(中间可能有些失效则可以跳过
}

总结:基于2.1和2.2已经可以完成基本的雏形,另外有些其他的点需要优化

4 优化

4.1 unshift带来的问题

在小程序中通常将列表数据存储到数组中,由于小程序setData的数据量越小越好,更新数组时通常不会将整个数组对象进行setData,而只是更新数组对象的某个属性,如下:

// 在数组尾部插入数据时 小程序支持下面方式
this.setData({
  [array[array.length]]: newObj
})

// 更新数组中某项的属性时
this.setData({
  [array[0].a]: 'a'
})

如果要向数组顶部插入数据,做不到只传递新增的数据

array.unshift({})
this.setData({array})  // => 缺点是 逻辑层到渲染层会传递整个数组对象

本文的背景是要解决会话场景下的长列表问题,对于会话即存在插入历史消息的场景,又存在插入新消息的场景,相当于我们数组两端都需要有插入数据的能力。需要对数据进行push/unshift操作。但是前面提到unshift效果不好。因此本文通过两个数组,一个数组存放历史消息,一个数组存放新消息,并在dom结构上也增加了对应的结构。

dom结构如下

<scroll-view class="x_reverse">
  <view class="next-item-wrapper">
    <!--多了一层-->
    <view class="x_reverse">
      <!--新消息区域-->
      <view wx:for=“new-groups” wx:for-item="group">
        <view wx:for="group.data">
           {{item.content}}
        </view>   
      </view>
    </view>
  </view>
  <view class="history-item-wrapper">
    <!--历史消息区域-->
      <view wx:for=“his-groups” wx:for-item="group">
        <view class="x_reverse" wx:for="group.data">
           {{item.content}}
        </view>   
      </view>
  </view>
</scroll-view>

区域定义:

  • 历史消息区域:初始化的消息以及插入的历史消息
  • 新消息区域:列表初始化完成之后新来的消息

制定了如下规则

  • 分组id越大表示分组的消息越久远,分组id越小表示分组的消息越新
  • 历史分组id从1开始递增,新消息区域分组id从0开始递减
  • 新消息区域自身未做任何的翻转,就像正常的列表一样,有新的消息或者新的分组push就行
  • 历史消息区域的分组受到翻转的影响,在历史消息分组中push新的消息或者新的分组表现为插入历史消息

其原理如下图

与上面dom结构对应的数据结构如下

class fuse {
    constructor() {
        // 存储历史消息
        this.histGroups = []; // groupId >= 1
        // 存储新消息
        this.newGroups = []; // groupId <= 0
    }
    
    // 插入新消息
    push(listGroups){
      this.newGroups.push(...listGroups)
    }
    
    // 插入历史消息
    unshift(listGroups){
      this.histGroups.push(...listGroups)
    }
}

4.2 白屏问题

4.2.1 白屏现象的解释

滚动过程中长列表组件会进行setData操作以更新视口区域的数据,在快速滚动的情况下,假设此时逻辑层的计算结果是需要渲染第3屏幕的数据,但是由于从逻辑层通信到视图层是需要时间,这段时间中第三屏的界面可能已经滚动到视口外,此时的渲染是无效的,用户看到的可能已经是第8屏的数据,但是这个时间点第8屏幕的数据并没有渲染,这就会导致白屏现象的出现。

如果我们能根据屏幕滚动的速率和通信的时间去预测下一帧哪一屏出现在视口区域,那么就可以避免白屏问题。显然这是个难题,因为你不知道用户什么时候会调整滚动的速度,并且setData的时间也受限于很多因素。因此小程序架构下长列表组件带来的白屏问题是无解的。但可以通过预加载上下几屏的数据等一些其他优化方案降低白屏出现的几率以及给出一些骨架效果来缓解用户的焦虑。

4.2.2 骨架效果模拟

由于WXML节点过多也会影响长列表性能,因此否定了渲染真实dom来实现骨架,目前是通过图片作为背景通过在垂直方向平铺的方式来模拟骨架效果。

这种方式对于列表项是等高的场景是完美的解决方案,对于列表项非等高的场景可能会看到背景有被’截断‘情况。不过实际体验来看在快速滚动的情况下,这种’截断‘被看到的概率是偏低的,从实际效果来看是可以接受的。

等高列表项(患者列表 ):https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john//video-1.mp4
非等高列表项(会话 ):https://baike-med-1256891581.file.myqcloud.com/yidian/production/article-john//video-2.mp4

4.3 图片高度异步确定带来的麻烦

加载图片资源需要经过网络,属于异步加载,因此img标签的高度的确定也是异步的。假设一种场景,当前分组中的图片资源尚未加载完成,由于滚动的发生需要将该分组中的列表项移除,显然这个时候给分组dom设置的高度是不准确的,当下一次重新渲染该分组时,图片重新加载到完成后,该分组的高度会发生生变化,此时会发生界面的跳动,该如何处理呢?

通过添加滚动锚定特性处理。滚动锚定是指当前视区上面的内容突然出现的时候,浏览器自动改变滚动高度,让视区窗口区域内容固定,就像滚动效果被锚定一样。因此通过设置滚动锚定特性可以解决界面跳动的问题

也可以通过动画的过渡效果来缓解跳动现象,这依赖于height相关的样式属性,因此需要给分组dom设置相关的样式值。

可以显示的给分组dom设置height样式:比如可以在图片加载完成后通知长列表组件去更新分组dom的高度,当高度设置了css3过渡动画,就会以动画形式展开。

也可以通过给分组dom设置min-height/max-height代替height,并给min-height/max-height设置css3动画。上面使用height方式存在一个问题,分组的高度只有在增高的前提下才会被感知,没有降低的可能性;而通过min-height/max-height组合(min-height:0,max-height:height + 1000px),分组高度的增加和降低都会被感知到

本文的实现是:滚动锚定 + min-height/max-height

下面是更新min-height/max-height的核心代码,通过监听 renderingids & clearingids属性的变化,在change回到中处理相关逻辑。

<scroll-view 
      clearingids="{{clearingGroupIds}}"
      renderingids="{{renderedGroupIds}}"
      change:clearingids="{{chat.clearingHandle}}"
      change:renderingids="{{chat.renderingHandle}}" />

wxs

// 分组消失时 设置mix-height/max-height = 实际高度
clearingHandle: function (clearingGroupIds, oldV, ownerInstance) {
  clearingGroupIds.forEach(function (groupId) {
    // 获取分组dom
    var pieceContainer = ownerInstance.selectComponent('#piece-container-' + groupId)
    var res = pieceContainer.getComputedStyle(['height'])
    pieceContainer.setStyle({ 'min-height': res.height, 'max-height': res.height })
})
// 分组重新渲染时
// min-height设置为0,实际的高度由分组中的列表项撑开
renderingHandle: function(renderingGroupIds, oldV, ownerInstance) {
  renderingGroupIds.forEach(function (groupId) {
    // 获取分组dom
    var pieceContainer = ownerInstance.selectComponent('#piece-container-' + groupId)
    var res = pieceContainer.getComputedStyle(['height'])
    // 高度大于一瓶 足够视口区域的内容发挥了
    var maxHeight = parseInt(res.height) + 1000 + 'px'
      pieceContainer.setStyle({ 'min-height': '0' })
      pieceContainer.setStyle({ 'max-height': maxHeight })
  })
}

事实上最完美的方式是在上传图片的时候记录图片的宽高比例等信息,在渲染时计算好img标签高度,而不是依赖图片的加载结果,这样可以保证img标签高度是同步确定的。退一步的做法是可以在图片第一次加载完成后缓存宽高,再次渲染的时候显示的设置img标签宽高。

5 其他

5.1 由于翻转带来的其他副作用

  • ios下transform:rotate会导致z-index无效

Safari 3D transform变换z-index层级渲染异常的研究–张鑫旭。在Safari浏览器下,此Safari浏览器包括iOS的Safari,iPhone上的微信浏览器,以及Mac OS X系统的Safari浏览器,当我们使用3D transform变换的时候,如果祖先元素没有overflow:hidden/scroll/auto等限制,则会直接忽略自身和其他元素的z-index层叠顺序设置,而直接使用真实世界的3D视角进行渲染。

5.2 根据groupNums计算待渲染/移除的分组id

本文实现的长列表组件提供了groupNums属性,该属性用来指定每个分组包含多个列表项。上文说到我们在IntersectionObserver监听的回调中来计算需要渲染的下一屏分组id。

如果长列表组件不存在删除元素的操作,那么假设当前进入视口的分组id是x,并且总是额外显示上一屏和下一屏的分组。那么当x是边缘分组时,目标分组就是[x,x+1] 或 [x-1,x];当x不是边缘分组的情况,目标分组是[x-1, x, x+1]

由于本文实现的长列表组件提供了删除中间列表项的方法,假设x,x-1,x+1这三个分组都被删除只剩下1一个列表项,那么按照上述计算方式计算返回的分组渲染出来后实际上可能还不够一屏。这个时候我们需要利用groupNums这个指标进行计算,比如当分组在中间时,得确保有3 * groupNums个列表项被渲染出来。

5.3 scroll-view底部回弹区域setData时跳动问题

问题:滑动页面到底部,使其出现橡皮筋效果,处于橡皮筋效果时SetData数据,会使页面跳动一下,处于橡皮筋效果时SetData会使页面跳动闪屏

解决方案:关闭橡皮筋效果即可

示例代码:

<scroll-view enhanced="{{true}}" bounces="{{false}}" />

5.4 一条消息的布局

问题:当滚动区域只有少数列表项,这些列表项高度之和小于滚动容器高度时,由于对滚动容器应用了翻转样式,此时列表项会布局在底部(应该在顶部)

解决方案:通过包裹在一个div内,应用如下样式解决

示例代码:

<scroll-view class="x_reverse">
  <view class="all-container">
    <view class="next-item-wrapper">...</view>
    <view class="history-item-wrapper">...</view>
  </view>
</scroll-view>
.all-container {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  height: auto;
  min-height: 100%;
}

5.5 自动弹出加载更多组件

问题:以加载历史消息为例,当消息滚动到顶部下拉开始加载历史消息时,如果只是设置showLoadMore为true,视觉上会看不到loadmore组件(原因是scroll-view设置了滚动锚定),需要再次向下拉一次,才能把该组件拉入到视区内。显然这样的体验不够好,如果拉到顶部开始加载历史消息时,该组件自动出现在用户的视觉内效果会好些。

示例代码(old):

<scroll-view class="x_reverse">
  <view class="all-container">
    <view class="next-item-wrapper">...</view>
    <view class="history-item-wrapper">...</view>
  </view>
  
  <view class="x_reverse">
    <load-more wx-if={{showLoadMore}}/>
  </view>
</scroll-view>

解决方案:通过两个变量loadingDone&loading来维护该组件,loading为true时显示上面的组件,loadingDone为true时显示内部的组件

示例代码(new):

<block>
  <!--正在加载,显示这里-->
  <load-more wx-if={{loading}}/>
  <scroll-view class="x_reverse">
    <view class="all-container">
      <view class="next-item-wrapper">...</view>
      <view class="history-item-wrapper">...</view>
    </view>

    <view class="x_reverse">
      <!--没有更多数据了,显示这里-->
      <load-more wx-if={{loadingDone}}/>
    </view>
  </scroll-view>
</block>

5.6 计算reccordIndex

在不删除中间列表项的情况下,传递的recordIndex是准确的,通过数学关系在wxs中实时进行计算

<list-item
  recordIndex="{{chat.calculateIndex(group, groupNums, index, renderedHistorySum)}}"
/>

wxs

// index 当前列表项在当前分组的索引
// groupNums 单个分组列表项数
// renderedHistoryGroups是历史区域的列表项数
// group 用于获取groupId
calculateIndex: function (group, groupNums, index, renderedHistorySum) {
  if (group.id > 0) { // 历史区域
    return renderedHistorySum - ((group.id - 1) * groupNums + index) - 1
  }

  return renderedHistorySum + (-group.id) * groupNums + index
}
observers: {
    'renderedHistoryGroups.**'() {
        let renderedHistorySum = 0;
        const { renderedHistoryGroups, groupNums } = this.data;
        if (renderedHistoryGroups.length) {
            const { data: endGroupData } = getEndElement(renderedHistoryGroups);
            renderedHistorySum = (renderedHistoryGroups.length - 1) * groupNums + endGroupData.length;
        }
        this._setDataWrapper({ renderedHistorySum });
    },
},

5.7 抽象节点

列表项组件是通过抽象节点注入给长列表组件的

6 总结

下面是基于文中所述实现的目录,所有逻辑层代码放在behavior中以共享,normal-scroll针对普通场景的长列表,而chat-scroll针对会话场景的长列表。

2 回复

小程序scroll-view翻转后 scroll-into-view的替代方案 这个连接进不去了 这个问题找了好久 在这才找到了相关信息 为啥打不开了呀

图片挂了

点赞评论吐口水先~

回到顶部