直播聊天区列表优化策略
背景
在 2C 直播间中,聊天区是一个高频交互的地方,随着用户数量和消息频率的增加,聊天内容不断堆积,对直播间的其他区域会产生负担。同时,随着聊天区体验的优化,加入了更多的动态效果和渲染元素,这也带来了额外的性能消耗。若不加控制,这可能会对系统稳定性产生影响,甚至导致黑屏或崩溃。
优化策略
- 消息列表管理
- 限制消息长度:每当聊天消息超过 80 条时,删除历史 20 条,避免过多历史消息占用内存和影响渲染性能。
- 批量消息处理:若 300ms 内发送多条消息,则合并为一条展示,减少重复渲染,提升渲染效率。
- 列表渲染优化
- 唯一 ID 作为 key:将消息列表的渲染 key 从默认的索引值改为每条消息的唯一 ID,避免因消息顺序变化导致的不必要的 DOM 更新。
- 渲染控制:针对只看自己发言这种类似清屏切换场景,将
v-if
优化为v-show
,减少不必要的 DOM 删除和重建,提高渲染效率。
- 滚动条互动优化
- 节流处理:监听聊天区滚动条互动时,使用 300ms 节流,避免频繁触发计算,减轻页面渲染压力。
- 表情包优化
- 动画格式转换:将 Lottie 动画改为更轻量的 GIF 动图,减轻 GPU 渲染负担。
- 展示逻辑优化:对于可视区域外的动图,切换为静态图片显示,只有在进入可视区域时才加载和展示动图,避免不必要的动画计算。
- 资源加载优化
- 资源缓存:通过
URL.createObjectURL(new Blob([data]))
缓存表情资源,减少重复请求。并及时URL.revokeObjectURL
释放 - LRU 缓存策略:对加载的表情资源使用 LRU(Least Recently Used)缓存策略,限制缓存长度为 20,避免过多的内存占用。
- 资源缓存:通过
- 设备性能
- 动态调整渲染策略:低端设备 GIF 展示图片
优化历程之 Lottie 如何换为 GIF
Lottie 优势与问题
- Lottie 优势:Lottie 动画作为矢量图形,其清晰度高且文件体积相对较小。在相同清晰度的情况下,Lottie 具有明显的资源大小优势。并且,Lottie 支持倍速播放、暂停以及从最后一帧继续播放等控制,这对于交互性较强的表情包非常有用。
- 性能问题:多个 Lottie 动画同时渲染时,尤其是在低性能设备上,容易导致内存占用过大或 GPU 渲染过载,从而造成浏览器崩溃或系统卡顿
基于 Lottie方案进行的优化尝试
并发控制和 IntersectionObserver:为了解决 Lottie 动画的并发问题,采用了并发控制和
IntersectionObserver
来判断表情包是否在可视区域内。虽然这种方案减少了不必要的渲染,但是在某些情况下,仍然会遇到表情包长时间“加载中”的问题,尤其是在消息发送过快、快速滑动进度条或频繁切换我的发言等交互下。表情包加载延迟 快速滑动或频繁切换视图时,
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 = '';
}
}