背景
用户经常出现闪退的情况,并提示内存不足。根据用户操作场景,猜测页面存在内存泄露。
内存泄露是什么?
内存泄露是程序运行过程中产生的内存变量会一直存在,不会被垃圾回收机制检测到,导致一直不会被销毁,内存占用会越来越大。
比如说:
我们在运行小程序的时候会产生一个页面,小程序会给这个页面创建一个实例,当这个页面销毁的时候,这个实例应该会被销毁。
但是如果我们有个定时器(setInterval),定时器里面对这个页面实例存在引用,那这个页面实例就不会被销毁,因为有被用到。
当存在内存泄露的情况,用户长期使用我们的小程序会导致小程序占用的内存越来越大,最后会导致小程序闪退(被微信强制销毁)
排查内存泄露用到的工具-weakSet
先简单描述一下weakSet,让大家有个简单的认识,详细需要去看下文档。
weakSet 是一个可以存储唯一变量的集合,和Set不一样的是,weakSet存储的变量都是弱引用,就是不会影响垃圾回收,如果存储的变量被回收了,在这个集合里面就找不到。
所以weakSet不能被遍历,也没有长度的概念。但是我们可以通过控制台打印weakset的指向,知道里面有多少个元素。如下图:
通过展开,我们可以知道里面是哪个页面的实例,但是我们在控制台展开就意味着我们对这个页面实例存在引用,则无法被垃圾回收。所以在执行垃圾回收之前需要清空控制台的输出。
如何确定页面是否存在内存泄露
如果页面存在内存泄露则不会销毁页面实例。我们只需要判断页面实例有没有被销毁即可。
我们在一开始就把页面实例加到weakSet里面,当执行多次跳转页面之后,会存在多个页面实例,最后回到首页,触发小程序的垃圾回收。
如果不存在内存泄露,那weakSet集合里面只会存在两个页面实例(当前页面实例+返回回来的页面实例),比如下图的页面A和页面B。
如果存在内存泄露,那weakSet集合里面会存在多个页面实例(当前页面实例+存在内存泄露的页面实例*n),比如下图的页面A、页面B、页面C和页面D.
具体如下图:
如何主动触发小程序的垃圾回收
小程序没有api可以让我们触发小程序的垃圾回收,我们目前可以通过开发者工具的performance面板或memory的垃圾回收(collect garbage 垃圾桶图标)按钮。
触发垃圾回收之后的结果如图:
这个需要手动触发才可以,我们在测试的时候需要手动点击,无法自动触发,所以我们想了个方案自动触发垃圾回收。
通过给内存塞很多数据,然后将这些数据标为无用的,当内存达到500m左右小程序就会触发垃圾回收。这个办法会导致我们内存一段时间激增,建议尽量在跳转页面的时候不要开启,只有在最后页面跳转回首页才进行。
// 主动触发垃圾回收
setInterval(()=>{
if(!global.startGC){
return
}
let a = []
for (let i = 0; i < 10000000; i++) {
a.push({ name: "pling", age: Math.random() * 10000 })
}
console.log("length", a.length)
a = []
}, 3000)
如何定位页面内存泄露的原因
内存泄露的情况举例:
global.list = []
Page({
// ...
onLoad() {
// ... 省略其他代码
// 将页面实例挂载到全局对象,如没有清理,则页面实例会一直不被销毁
global.list.push(this)
// 存在Interval计时器,则会一直存在对页面实例的引用
setInterval(() => {
console.log("test", this.data)
}, 5000);
// 通过settimeout的循环调用,实现了类似于interval的效果也会导致页面实例不会被销毁
this.testLoop()
const that = this
function test(){
console.log(that.data)
}
// 将内部函数挂载到全局变量,则会导致函数的作用域链都会存在引用,不会被销毁
global.logThis = test
},
testLoop(){
setTimeout(() => {
this.testLoop()
}, 10000);
}
})
通过上面我们可以知道一般会有上面四种情况导致内存泄露。
- 将对象挂载到全局对象上,页面写在没有清楚
- 通过暴露内部函数给外部对象,导致存在作用域的引用,页面卸载没有清楚内部函数
- 存在定时执行的函数存在对页面实例的引用,页面销毁没有清除定时器
- 通过延时执行的函数循环调用,并存在对页面实例的引用,页面销毁没有停止调用。
第一第二种情况会比较少出现,目前暂时还没考虑如何去排查。
第三第四种都会对页面实例存在调用,所以我们在页面实例销毁之后对页面实例上的属性进行监听,如果一直存在调用则会有问题。
具体实现代码:
// 检查页面卸载后对页面实例调用
Page({
data: {
test: "111"
},
onLoad() {
global.pageSet.add(this)
setInterval(() => {
console.log("test", this.__wxExparserNodeId__, this.data.test)
}, 5000);
},
// ....
onUnload(){
console.log("unload");
const that = this
// 获得可以枚举的属性列表
const keys = Object.keys(that)
// 加入data 因为data 不是可以枚举的属性
keys.push("data")
console.log(keys);
keys.map(key=>{
// 获得原本的属性描述
const property = Object.getOwnPropertyDescriptor(that, key)
// 保留原有的值
const origin = that[key];
// 获得属性的get方法 有可能没有
const getter = property && property.get
// 获得属性的set方法 有可能没有
const setter = property && property.set
const isFunction = typeof origin === "function"
// 如果是function的话 需要绑定this
if(isFunction){
origin.bind(that)
}
const newThis = {}
// 拦截属性
Object.defineProperty(that, key, {
get: function(){
console.log(`调用了this.${key}的getter`);
// 有getter 调用getter
if(getter){
return getter.call(that)
}
return newThis[key] || origin
},
set: function(newVal){
console.log(`调用了this.${key}的setter`);
if(setter){
return setter.call(that, newVal)
}
newThis[key] = newVal
}
})
})
}
})
测试demo
我们在自己项目里面测试会比较麻烦,一开始可能会有干扰,所以我这边弄了个代码片段,先校验一下这个方法是否可行,如果可行再加到自己的项目里面。