起因
收到业务方反馈,之前做的一个3d火焰的页面,刚打开的时候,火焰展示正常,也有动画。但是在页面停留一会,火焰的动画就会明显感到卡顿,再等会页面的tab 就无法关闭了。直接卡死了。 凭个人经验来看,这应该是内存泄漏造成的问题,但具体原因是什么,还需要具体进行分析。
查找原因
打卡Chrome的Devtools, 找到Memory面板,可以看到一个Heap Snapshot。点击面板左上角红色圆点,对当前的heap 做一个堆快照, 然后等1分钟后,再点击这个红色圆点,进行一个堆快照。像这样多执行几次,可以看到有几个snapShot, 点击一个snapShot,可以看到 上面有个summary, 点击后有个下拉选项,可以选择Compare, 就是选择对照的snapshot. 如下图

可以看到,短时间内的两个snapShot, 一个叫system/JsArrayBufferData的 constructor, 新增了28983个,造成内存短时间内上升了329M, 这种现象,页面不卡才奇怪呢。下面的这个Array 和 ArrayBuffer, 也在短时间内增了很多。
那么这个system/JsArrayBufferData到底是什么?为什么会占用这么大的内存。 搜了一下,JSArrayBufferData内存溢出问题通常与未正确管理ArrayBuffer对象有关。在JavaScript中,ArrayBuffer是一种用于表示固定长度的原始二进制数据缓冲区的对象。如果未正确管理其生命周期,就可能导致内存泄漏,进而引发内存溢出问题。
原因还要从代码中找,是不是哪些数据造成了循环引用导致内存没有释放。
这个页面的功能大致是这样的, 1. 从后台获取一个数组,数组中的每个item包含了x,y,z的坐标。前端拿到这一组坐标后,把这些坐标都绘制成点。2. 然后业务方希望的效果是,沿着这些点创建一系列的火焰,并让火焰有动画效果。3. 这需要拿到点的 坐标后,在点的周边随机生成几个大小不一的火焰,然后让火焰动起来。
回到代码层面, 是怎么做的? 调用createFiresInLineArea方法,传入point,也就是坐标点传进去。createFire方法根据坐标点,在周边随机的创建几个火焰,然后使用fire的animate方法,使火焰动起来。 animate方法是这样的:
js
const animate = () => {
if (fireMaterial.uniforms.uTime) {
fireMaterial.uniforms.uTime.value += frequency;
}
requestAnimationFrame(animate);
}
注意了,就是这个代码造成了严重的内存泄漏。为什么这样说呢? 每个火焰,都要调用这个animate方法,而这个animate方法引用了外部的fireMaterial
, 这会导致什么? fireMaterial
是一个Threejs 的 new THREE.ShaderMaterial 的对象,这里面包含大量的ArrayBuffer
, 闭包的使用,导致这个fireMaterial
永远会是可达的,也就是说永远不会被GC。1个火焰的情况下,会有一些内存。但是要知道,火焰是根据point的数量来创建的。如果1000个火焰的时候,就是1000个requestAnimationFrame
,这是造成内存急剧增长的主要原因,大量的内存没有被释放,造成了页面的卡顿。
解决
找到原因后,就从这里下手开始进行解决。 创建一个FireAnimate管理器,这里面生成一个fire的数据,每次创建火焰,都push 进数组中,也提供remove方法,可以把fire 进行remove。 最后提供一个animate方法。 也就是生成了大量的火焰点之后,再进行animate方法的调用,只调用一次requestAnimationFrame,大大降低了内存占用。
kotlin
// 全局动画管理器
class FireAnimationManager {
private static instance: FireAnimationManager
private animationId: number = 0
private fires: Set<THREE.Mesh> = new Set()
private isRunning: boolean = false
static getInstance(): FireAnimationManager {
if (!FireAnimationManager.instance) {
FireAnimationManager.instance = new FireAnimationManager()
}
return FireAnimationManager.instance
}
addFire(fire: THREE.Mesh) {
this.fires.add(fire)
if (!this.isRunning) {
this.startAnimation()
}
}
removeFire(fire: THREE.Mesh) {
this.fires.delete(fire)
if (this.fires.size === 0) {
this.stopAnimation()
}
}
private startAnimation() {
if (this.isRunning) return
this.isRunning = true
const animate = () => {
if (this.fires.size === 0) {
this.stopAnimation()
return
}
// 更新所有火焰的时间
this.fires.forEach(fire => {
const material = fire.material as THREE.ShaderMaterial
if (material.uniforms?.uTime) {
material.uniforms.uTime.value += fire.userData.frequency || 0.02
}
})
this.animationId = requestAnimationFrame(animate)
}
animate()
}
private stopAnimation() {
if (this.animationId) {
cancelAnimationFrame(this.animationId)
this.animationId = 0
}
this.isRunning = false
}
// 清理所有火焰
cleanup() {
this.stopAnimation()
this.fires.clear()
}
}
每次创建火焰, 调用这个方法 FireAnimationManager.getInstance().addFire(fire);
另外,由于这个火线是频繁变动的,导致每次都要重新清理火焰,再创建火焰,这个过程其实也是内存的释放与占用。可以使用对象池模式,每次复用已经存在的火焰,而不是重新创建火焰。
kotlin
// 复用火焰对象而不是频繁创建销毁
class FirePool {
private pool: THREE.Mesh[] = []
getFire(): THREE.Mesh {
return this.pool.pop() || this.createFire()
}
returnFire(fire: THREE.Mesh) {
this.pool.push(fire)
}
}
最后我发现代码中,还有一个监听事件,监听resize后, 调用了update方法。但是在页面卸载时并没有移除这个方法。需要在页面卸载时,移除这个监听时间,避免造成内存泄漏。