【小程序代码自查】小程序闪退-内存泄露导致
发布于 3 年前 作者 gangqian 2048 次浏览 来自 分享

背景

用户经常出现闪退的情况,并提示内存不足。根据用户操作场景,猜测页面存在内存泄露。

内存泄露是什么?

内存泄露是程序运行过程中产生的内存变量会一直存在,不会被垃圾回收机制检测到,导致一直不会被销毁,内存占用会越来越大。

比如说:

我们在运行小程序的时候会产生一个页面,小程序会给这个页面创建一个实例,当这个页面销毁的时候,这个实例应该会被销毁。

但是如果我们有个定时器(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);
  }
})

通过上面我们可以知道一般会有上面四种情况导致内存泄露。

  1. 将对象挂载到全局对象上,页面写在没有清楚
  2. 通过暴露内部函数给外部对象,导致存在作用域的引用,页面卸载没有清楚内部函数
  3. 存在定时执行的函数存在对页面实例的引用,页面销毁没有清除定时器
  4. 通过延时执行的函数循环调用,并存在对页面实例的引用,页面销毁没有停止调用。

第一第二种情况会比较少出现,目前暂时还没考虑如何去排查。

第三第四种都会对页面实例存在调用,所以我们在页面实例销毁之后对页面实例上的属性进行监听,如果一直存在调用则会有问题。

具体实现代码:

// 检查页面卸载后对页面实例调用
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

我们在自己项目里面测试会比较麻烦,一开始可能会有干扰,所以我这边弄了个代码片段,先校验一下这个方法是否可行,如果可行再加到自己的项目里面。

小程序代码片段


1 回复

无法在体验小程序版本vConsole里面查看weakSet。

本地开发者里面小程序内存使用是正常的,但是上线之后查看性能发现一直增长。

回到顶部