Skip to content

直播聊天区列表优化策略

背景

在 2C 直播间中,聊天区是一个高频交互的地方,随着用户数量和消息频率的增加,聊天内容不断堆积,对直播间的其他区域会产生负担。同时,随着聊天区体验的优化,加入了更多的动态效果和渲染元素,这也带来了额外的性能消耗。若不加控制,这可能会对系统稳定性产生影响,甚至导致黑屏或崩溃。

优化策略

  1. 消息列表管理
    1. 限制消息长度:每当聊天消息超过 80 条时,删除历史 20 条,避免过多历史消息占用内存和影响渲染性能。
    2. 批量消息处理:若 300ms 内发送多条消息,则合并为一条展示,减少重复渲染,提升渲染效率。
  2. 列表渲染优化
    1. 唯一 ID 作为 key:将消息列表的渲染 key 从默认的索引值改为每条消息的唯一 ID,避免因消息顺序变化导致的不必要的 DOM 更新。
    2. 渲染控制:针对只看自己发言这种类似清屏切换场景,将 v-if 优化为 v-show,减少不必要的 DOM 删除和重建,提高渲染效率。
  3. 滚动条互动优化
    1. 节流处理:监听聊天区滚动条互动时,使用 300ms 节流,避免频繁触发计算,减轻页面渲染压力。
  4. 表情包优化
    1. 动画格式转换:将 Lottie 动画改为更轻量的 GIF 动图,减轻 GPU 渲染负担。
    2. 展示逻辑优化:对于可视区域外的动图,切换为静态图片显示,只有在进入可视区域时才加载和展示动图,避免不必要的动画计算。
  5. 资源加载优化
    1. 资源缓存:通过 URL.createObjectURL(new Blob([data])) 缓存表情资源,减少重复请求。并及时 URL.revokeObjectURL 释放
    2. LRU 缓存策略:对加载的表情资源使用 LRU(Least Recently Used)缓存策略,限制缓存长度为 20,避免过多的内存占用。
  6. 设备性能
    1. 动态调整渲染策略:低端设备 GIF 展示图片

优化历程之 Lottie 如何换为 GIF

Lottie 优势与问题

  • Lottie 优势:Lottie 动画作为矢量图形,其清晰度高且文件体积相对较小。在相同清晰度的情况下,Lottie 具有明显的资源大小优势。并且,Lottie 支持倍速播放、暂停以及从最后一帧继续播放等控制,这对于交互性较强的表情包非常有用。
  • 性能问题:多个 Lottie 动画同时渲染时,尤其是在低性能设备上,容易导致内存占用过大或 GPU 渲染过载,从而造成浏览器崩溃或系统卡顿

基于 Lottie方案进行的优化尝试

  1. 并发控制和 IntersectionObserver:为了解决 Lottie 动画的并发问题,采用了并发控制和 IntersectionObserver 来判断表情包是否在可视区域内。虽然这种方案减少了不必要的渲染,但是在某些情况下,仍然会遇到表情包长时间“加载中”的问题,尤其是在消息发送过快、快速滑动进度条或频繁切换我的发言等交互下。

  2. 表情包加载延迟 快速滑动或频繁切换视图时,IntersectionObserver 可能导致表情包加载逻辑变得不够灵敏,尤其在大量表情包需要加载时,表情包可能处于“加载中”状态,导致用户体验不佳。快速连续的交互(如大量快速发送消息)会导致加载和渲染逻辑无法及时跟上,造成卡顿或表情包未及时渲染。 加载中的问题,产品无法接受,于是方案变更

解决方案:从 Lottie 到 GIF

GIF 面临的问题

  • 停留在最后一帧: 表情包只播放一次,如何只停留在最后一帧,而不是循环播放,通过调研,UI 可以在设计的时候设置是否循环,从而解决这个问题
  • GIF 资源瓶颈:资源过大 500kb ,随即调整尺寸并进行压缩和抽帧,控制在 50kb 以内

优化和调整

  • GIF 动图的按需加载:即使采用了 GIF 动图,仍然可以通过在可视区内加载动图,可视区域外为图片的策略,从而减少资源浪费和提高页面性能。
  • GIF 的缓存与优化:对于经常使用的表情包,进行缓存,避免每次加载时都进行网络请求,使用本地缓存或者通过 URL.createObjectURL() 来优化加载过程。

代码

可视区内

js
export const isInViewport = (element: any) => {
  return new Promise((resolve) => {
    const observer = new IntersectionObserver((entries) => {
      const isIntersecting = entries.some((entry) => entry.intersectionRatio > 0);
      resolve(isIntersecting);
      observer.unobserve(element);
      observer.disconnect();
    }, {
      rootMargin: '0px',
      threshold: 0,
    });
    observer.observe(element);
  });
};

并发控制

js
export async function asyncPool(poolLimit: number, array: any, iteratorFn: any) {
  let ret: any = []; // 存储所有的异步任务
  let executing: any = []; // 存储所有正在执行的任务
  for (const item of array) {
    const p = Promise.resolve().then(() => iteratorFn(item, array));
    // 调用iteratorFn函数创建异步任务
    ret.push(p);
    // 保存新的异步任务

    if (poolLimit <= array.length) {
      // 当poolLimit小于等于总任务数量时,进行并发控制
      const e: any = p.then(() => executing.splice(executing.indexOf(e), 1));
      // 当任务完成后,从正在执行的任务队列中移除任务,腾出一个空位
      executing.push(e);
      // 加入正在执行的异步任务

      if (executing.length >= poolLimit) {
        await Promise.race(executing);
        // 有任务执行完成之后,进入下一次循环
      }
    }
  }
  return Promise.all(ret); // 所有任务完成之后返回
}

LRU 缓存控制

js
export default class LRUCache {
  /** 缓存容量 */
  public cache = new Map<string, any>();
  /** 存储访问频次  */
  public freq = new Map<string, any>();
  public capacity: number = 20;
  public storeEvictionResult: boolean = false;
  constructor(config: { capacity: number;  storeEvictionResult?: boolean }) {
    const { capacity, storeEvictionResult = false } = config;
    this.capacity = capacity;
    this.storeEvictionResult = storeEvictionResult;
  }

  public get(key: string) {
    if (!this.cache.has(key)) { return; }

    // 更新访问频次
    this.updateFreq(key);

    return this.cache.get(key);
  }

  public put(key: string, value: any) {
    // 如果键已经存在,则更新值和频次
    if (this.cache.has(key)) {
      this.cache.set(key, value);
      this.updateFreq(key);
      return;
    }

    // 如果缓存已满,则淘汰最久未使用的项
    let result = null;
    if (this.cache.size >= this.capacity) {
      result = this.evict();
    }

    // 添加新的键值对并初始化频次
    this.cache.set(key, value);
    this.freq.set(key, 1);
    if (this.storeEvictionResult && result) {
      return result;
    }
  }
  public remove(key: string) {
    this.cache.delete(key);
    this.freq.delete(key);
  }
  private updateFreq(key: string) {
    let currentFreq = this.freq.get(key);
    this.freq.set(key, currentFreq + 1);
  }

  private evict() {
    let leastFreqKey;
    let leastFreq = Infinity;

    // 找到访问频次最低的键
    for (let [key, freq] of this.freq) {
      if (freq < leastFreq) {
        leastFreq = freq;
        leastFreqKey = key;
      }
    }
    if (!leastFreqKey) {
      return;
    }
    if (this.storeEvictionResult) {
      const result = {
        key: leastFreqKey,
        value: this.cache.get(leastFreqKey),
      };
      this.remove(leastFreqKey);
      return result;
    }
    this.remove(leastFreqKey);
    return ;
  }
}

不完全图片资源增加缓存

js

  private async getData() {
    const { id, gif, cache } = this.options;
    const value = CACHE_GIF.get(id);
    if (value) {
      return value;
    }
    try {
      const { data } = await axios.get(gif, {
        timeout: 5000,
        responseType: 'blob',
      });
      const target = new Blob([data]);
      if (cache && data) {
        CACHE_GIF.put(id, target);
      }
      return target;
    } catch (e) {
      error(loggerModules.pages, e);
    }
    return '';
  }

const data: any = await this.getData();
this.uniqueAnimationSrc = URL.createObjectURL(data);

    /** 销毁很重要防止内存泄漏 */
  private destroyURL() {
    if (this.uniqueAnimationSrc) {
      URL.revokeObjectURL(this.uniqueAnimationSrc);
      this.uniqueAnimationSrc = '';
    }
  }

在 MIT 许可下发布