【小程序代码自查】小程序闪退-内存泄露导致
背景用户经常出现闪退的情况,并提示内存不足。根据用户操作场景,猜测页面存在内存泄露。 内存泄露是什么?内存泄露是程序运行过程中产生的内存变量会一直存在,不会被垃圾回收机制检测到,导致一直不会被销毁,内存占用会越来越大。 比如说: 我们在运行小程序的时候会产生一个页面,小程序会给这个页面创建一个实例,当这个页面销毁的时候,这个实例应该会被销毁。 但是如果我们有个定时器(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我们在自己项目里面测试会比较麻烦,一开始可能会有干扰,所以我这边弄了个代码片段,先校验一下这个方法是否可行,如果可行再加到自己的项目里面。 小程序代码片段