关于双列瀑布流布局的优化思考

8,102 阅读8分钟

 导语

在前端领域,经常会遇到瀑布流布局的开发,最近整理了下相关的使用场景和解决方案,其中包含了简单算法 DP,前端基础知识,业务场景的思考。

什么是瀑布流布局

瀑布流又称瀑布流式布局,是一种比较流行的页面布局方式,英文名称为:Masonry Layouts 。与传统的分页显示不同,视觉上表现为参差不齐的多栏布局,最早由 Pinterest 首先运用。

特别是在移动端,双列瀑布流的应用更加常见,在展现呈现每个元素能够以自身的情况合理占据空间,每个元素宽高不一致,左右依次调整排列,最终占据最小的屏幕高度,配合无限加载的设计,无论从用户使用心理的考虑、展示的美观,用户体验等方面考虑,瀑布流都是一种相当优秀的布局方式。

以腾讯课堂APP的瀑布流为例:

使用场景

根据瀑布流的优缺点,我们不难得出在什么情况下选择瀑布流是合理的选择:

  • 内容以图片为主的时候,瀑布流是更好的选择。图片占用空间比较大,并且大脑理解的速度相比理解文字要快,短时间内可以扫过的内容很多,所以如果用分页显示的话用户务必会频繁的翻页,影响沉浸式的体验,而瀑布流可以解决这个问题。
  • 信息与信息之间相对独立时,瀑布流是更好的选择。如果信息关联性强,用户务必会进行大量的回溯操作去查看之前或者之后的信息,相反,如果信息相对独立的话,可以使用瀑布流,让用户同时接受来自不同地方的信息。
  • 信息与信息之间相对独立时,瀑布流是更好的选择。瀑布流给人的直观印象,就是同时显示的信息与用户搜索的匹配度大致一样,而分页显示的直观印象则是越靠上的信息被认为与用户的搜索越匹配。因此,当信息与搜索匹配度没有明显区分度时,可以采用瀑布流。
  • 用户目的性不强的时候,瀑布流是更好的选择。如果用户有特定需要查找的信息,分页查找定位更方便,而当目的性较弱的时候,瀑布流可以增加用户停留的时间和意想不到的收获。

这里引用了一篇文章的总结,瀑布流能够有效引导用户利用碎片化的时间,尽可能获得最大化的用户留存和使用时间。

如何实现瀑布流布局

结合前人的总结,目前实现瀑布流方式有 multi-column , grid , Flexbox 三种,实现方案各有不同,这里就不给大家具体说明了,各位不了解的请自行Google。从兼容性及易用性综合考虑,还是推荐使用 Flexbox的布局方案。

一般来说HTML结构如下:(以微信小程序为例)

<view class="container">  
 <view class="column-container">    
   <template        
     is="item-card"      
     wx:key="{{item.id}}"     
     wx:for="{{left}}"        
     data="{{...item}}" />    
 </view>   
 <view class="column-container">     
   <template     
     is="item-card"     
     wx:key="{{item.id}}"    
     wx:for="{{right}}"     
     data="{{...item}}" />   
 </view>
</view>

上面的代码中,container 代表瀑布流容器,负责滚动和触发无限加载;column-container 是列容器,item-card 是其中的每一项卡片。

相应的 CSS 设置如下:

.container {     
  display: flex;   
   flex-direction: row;   
   justify-content: space-between;   
   align-items: flex-start;   
   margin-top: 12px;    
   > .column-container {    
     flex: 1 1 0;    
     margin: 4px;    
     display: flex;    
     flex-direction: column;    
     justify-content: flex-start;    
     align-items: center;    
  }   
}

瀑布流容器的 flex 设置横向布局,列容器为纵向布局。

对应的数据元组也分为下面这些,couponList 是总数据,left 是分配到左边的一列的数据,right 是分配右边一列的数据。具体优化分配方式是后续分析的重点,这里先按照下表进行分析。

Page({  
  data: {  
  couponList: [], 
   left: [],  
   right: [], 
 },
}

直到这里,我们才真正进入本文的重点:怎么做一个高性能,高体验的H5双列瀑布流?

这里我们先选定一个使用场景,技术实现上选用 Flexbox 实现布局,数据加载方面要求无限向下滚动加载,能够方便大家更加关注具体的业务背景,也降低作为作者介绍优化的范围,便于讲述。如果有其他场景,可以在留言区里大家一起讨论,在这里就不做大而全的讲述了。

准确来说,在双列瀑布流的使用场景中,围绕元素卡片高度是否固定,顺序是否严格固定,可以分为元素高度分化场景、顺序分化场景,具体如下:

元素高度分化场景:

  • A1场景:每个元素高度固定;

  • A2场景:每个元素高度不固定,但是可以数据类型估算自身相对于屏幕宽度的百分比高度;

  • A3场景:元素高度不固定,且无法预估高度,只能等渲染之后才可以确定高度;

顺序分化场景:(结合无限加载为前提)

  • B1场景:元素的相对顺序严格一致

  • B2场景:元素的相对顺序宽泛性一致

下面我们就具体的场景来一一分析,并优化其中的实现细节。

  • A1 场景之下,数据排列方式就可以简单做成左右交替排列,这也是最简单一种方式。

    let i = 0;
    while (i <couponList.length) {   
      left.push(couponList[i++]);  
      if (i < couponList.length) {   
        right.push(couponList[i++]);  
      }
    }
    
  • A2场景下,需要在给左右两列分配元素时,要根据当前高度差来动态分配,简单来说就是哪一列短,就分配到对应的那一列。同时这种方式也能够满足B1的场景。

    function computeRatioHeight (data) {  
      // 计算当前元素相对于屏幕宽度的百分比的高度 
      // 设计稿的屏幕宽度  
      const screenWidth = 375;  
      //设计稿中的元素高度,也可以前端根据类型约定  
      const itemHeight = data.height;   
      return Math.ceil(screenWidth / itemHeight * 100);
    }
    function formatData(data) {
      let diff = 0; 
      const left = []; 
      const right = []; 
      let i = 0; 
      while(i < data.length) {  
        if (diff <= 0) {    
          left.push(data[i]);   
          diff += computeRatioHeight(data[i]);  
        } else {    
          right.push(data[i]);   
          diff -= computeRatioHeight(data[i]); 
        }    
        i++; 
     } 
     return { left, right }
    }
    
  • A3 场景下是相对难处理的,我们无法预知真实渲染后的高度,那么在参差不齐的情况,我们无法科学的进行排列的,这种情况常见于图片瀑布流场景,由于图片高宽信息缺失或者不准确,需要img标签自然展开,这种情况下建议转换成A2情况,例如预先获取图片真实高宽,当然这么做有一定的性能损耗。

    function getImgInfo(url) { 
     return new Promise((resolve, reject) => { 
       // 创建对象   
       const img = new Image();  
       // 改变图片的src  
       img.src = img_url;  
       // 判断是否有缓存  
       if (img.complete) {   
         resolve({ width: img.width, height: img.height });  
       } else {     
         img.onload = () => {   
            resolve({ width: img.width, height: img.height }); 
         };  
       } 
      })
    }
    

进阶优化

误差矫正

在 A2 场景中,每个卡片的高度并不能像预想的高度去精确渲染,特别是在移动端 H5 中使用 Rem 单位、适配不同的设备类型的场景中,计算的精度差,渲染的像素误差,都会给计算左右高度差时带来误差一定的误差,在无限滚动的基础上,这种误差会持续累积,最终导致布局策略的失败。因此需要对左右高度差在每次加载数据后进行矫正。

这里采用的方式比较简单,可以在左右列容器的尾部增加一个高度为0px的隐藏锚点元素,每次渲染结束后获取锚点元素的 offsetTop 的值,更新左右两侧的高度差。

下面是 HTML 结构:

<view class="container">  
 <view class="column-container">    
  <template     
     is="item-card"     
     wx:key="{{item.id}}"     
     wx:for="{{left}}"     
     data="{{...item}}" />   
   <view class="hidden-archer" id="left-archer" />  
 </view>  
 <view class="column-container">   
   <template     
     is="item-card"     
     wx:key="{{item.id}}"     
     wx:for="{{right}}"     
     data="{{...item}}" />   
   <view class="hidden-archer" id="right-archer" />  
  </view>
</view>

下面是小程序的更新差值的代码:

this.setData({    
   left: [...left, ...leftData],   
   right: [...right, ...rightData],   
   diffValue: diffValue + nextDiff,  
 }, () => {      
   // 更新左右间距差   
   const query = wx.createSelectorQuery();   
   console.log('计算高度差');    
   query.select('#left-archer').boundingClientRect();   
   query.select('#right-archer').boundingClientRect();   
   const { diffValue } = this.data;    
   query.exec((res) => {      
   console.log(res[0].top - res[1].top, diffValue);     
   this.setData({     
     diffValue: res[0].top - res[1].top,   
   });   
 }); 
});

leftData, rightData 是新增的排列数据,diffValue 是左右两列高度差值,事实证明 diffValue 和 左右锚点的高度差值存在误差(如下图),需要通过这种手段矫正下。

图片 2.png

通过DP算法获取最优排列

在 A2 场景下,通过计算高度差向高度低的一列添加元素,实际并不是完美方案,因为在极端场景下,例如最后一个元素过高,会导致底部左右的高度差过大,甚至超过一个常见元素的高度,一方面没有合理使用屏幕高度,另外一方面巨大的高度差也会给用户体验带来负面影响。

为了解决这种问题,我们引入简单的 DP算法来解决这个问题。假如已知所有待排列元素的高度,就可以计算出这些元素的真实占据的高度-记为总高度 H,假如不考虑卡片不可分割的特性,将两个列容器想想成联通的两个水柱,那么其元素总高度 H / 2 就是其最佳占据高度,由于很难出现左右排列高度一致的情况,因此获取最靠近 H / 2 的排列高度即为最佳排列高度,进而转换成背包问题就是在 H / 2 容量的背包里,如何放置尽可能使用其空间体积的题目,下面就按照这个思路来解决如何获取最优的问题。

resetLayoutByDp: function (couponList) {  
  const { left, right, diffValue } = this.data;   
  const heights = couponList.map(item => (item.height / item.width * 160 + 77));  
  const bagVolume = Math.round(heights.reduce((sum, curr) => sum + curr, diffValue) / 2);  
  let dp = [];  
  //......省略部分代码 
  // 具体的DP算法可以自行查阅相关资料  
  const rightIndex = dp[heights.length - 1][bagVolume].indexes;  
  const nextDiff = heights.reduce((target, curr, index) => {   
    if (rightIndex.indexOf(index) === -1) {    
     target += heights[index];  
    } else {     
    target -= heights[index];   
    }    
    return target;  
  }, 0);   
  const rightData = rightIndex.map(item => couponList[item]);   
  const leftData = couponList.reduce((target, curr, index) => {   
    if (rightIndex.indexOf(index) === -1) {    
     target.push(couponList[index]);   
    }    
    return target;  
  }, []);  
  this.setData({    
    left: [...left, ...leftData],  
    right: [...right, ...rightData],   
    diffValue: diffValue + nextDiff,  
  }, () => {     
    // 更新左右间距差  
    const query = wx.createSelectorQuery();   
    console.log('计算高度差');   
    query.select('#left-archer').boundingClientRect();   
    query.select('#right-archer').boundingClientRect();   
    const { diffValue } = this.data;    
    query.exec((res) => {     
      console.log(res[0].top - res[1].top, diffValue);   
      this.setData({      
        diffValue: res[0].top - res[1].top,    
      });  
    });  
  });
}

优化列容器中的排列

在实际业务场景中,常常会对排列顺序有要求,常见于广告和推荐的算法中,这里前端也可以做一些优化。这里的手段主要列容器内部的排序和不同列容器的相同元素的置换,尽可能保证高优先级的元素出现靠前的位置。

最终的效果演示如下: