解析 Chameleon 小程序的运行时性能问题

335 阅读8分钟

实验版本:

  1. "chameleon": "1.0.0"

一、实验验证阶段:模拟稍大数据量下数据变更时场景

实验采用了4000条简单数据类型。点击按钮后,数据源变化为3000条另外的新数据。

下面放出直接实验的代码:


<template>
  <view>
    <!-- 条件与循环渲染 -->
    <view>
      <view
        c-for="{{array}}"
        c-for-index="idx"
        c-for-item="itemName"
        c-key="city"
        class="cell"
      >
        <text> {{idx}}: {{itemName.city}} {{itemName.name}}</text>
      </view>
    </view>
    <!-- 事件绑定 -->
    <view c-bind:tap="changeShow" class="tabButton"><text>切换展示</text></view>
  </view>
</template>

methods = {
    changeShow() {
        if (this.showlist) {
          this.array = this.data2
        } else {
          this.array = this.data1
        }
        this.showlist = !this.showlist;
    }
},
created() {
  let data = []
  for (var i = 0; i < 4000; i++) {
    data.push({
      city: '上海' + i,
      name: 'Jack' + i + 100
    })
  }
  this.data1 = data

  let data2 = []
  for (var i = 0; i < 3000; i++) {
    data2.push({
      city: '北京' + i,
      name: 'Jack' + i + 300
    })
  }
  this.data2 = data2
  this.array = data
}

实验中这种切换时的顿挫感是非常明显的。即使是微信原生也会有可感知的毫秒级的顿挫,但 Chameleon 相比微信原生而言,顿挫感快提升到秒级了。

下面使用微信原生的框架代码来实现同样的功能,并测试其性能。

结论

小程序的 Performance 目前还不如看出渲染的卡顿指标,但在真机下似乎原生 App 端实现了渲染的检测。实际对渲染的影响非常大,实测可以掉到个位数的帧率。

二、性能瓶颈研究

2.1、罪魁祸手

使用 Performance 调试器从调用栈自底向上来看,很快就能定位到问题出在 toJs 函数上。

  1. toJS 直接堵塞了将近整整 6s。
  2. 其次就是 deepEq 函数,堵塞了有 100 ms。

事实上 deepEq 数据虽然对比才 100ms,然后这作为底层框架而言,已经是个不小的问题了,而 toJS 的性能简直可以用 “灾难” 来形容,太夸张了。

2.2、toJs 与 Mobx

要说清楚 toJS 首先我们得先来聊聊什么是 Mobx,以及 Mobx 与 Chameleon 的关系由来。

MobX 是一个经过战火洗礼的库,它通过透明的函数响应式编程,使得状态管理变得简单和可扩展。MobX背后的哲学很简单:

任何源自应用状态的东西都应该自动地获得。

其中包括UI、数据序列化、服务器通讯,等等。Mobx 可以与很多其他框架配合,如 React。

下面使用代码说明,Mobx 神奇的依赖收集代码。其中 autoRun 和 observable 是 Mobx提供的库 Api。observable 负责观察数据,autoRun 负责响应监听行为。

const obj = observable({
    a: 1,
    b: 2
})

autoRun(() => {
    console.log(obj.a)
})

obj.b = 3 // 什么都没有发生
obj.a = 2 // observe 函数的回调触发了,控制台输出:2

我们发现这个函数非常智能,用到了什么属性,就会和这个属性挂上钩,从此一旦这个属性发生了改变,就会触发回调,通知你可以拿到新值了。没有用到的属性,无论你怎么修改,它都不会触发回调,这就是神奇的地方。

这里还要引申一个 Mobx 重要的接口 reaction。 reaction 的第一个参数叫做 数据函数。第二个参数叫做效果 函数。

const todos = observable([
    {
        title: "Make coffee",
        done: true,
    }
]);

const reaction2 = reaction(
    () => todos.map(todo => todo.title), //对 length 和 title 的变化作出反应
    titles => console.log("reaction 2:", titles.join(", ")) // 变化时就打印 titles 的数据
);

todos[0].title = "Make tea"
// 输出:
// reaction 2: Make tea, find biscuit, explain reactions

() => todos.map(todo => todo.title) 就是 数据函数,当返回的 map 后的 title 数据不一致时就会触发效果函数。

这里不再对 Mobx 的代码展开了,回到 toJS 与 Mobx 的关系。不过要说清楚,还得再讲一下 Chameleon runtime 在 启动时干了什么!

2.3、Chameleon runtime 启动时

在 Chameleon-runtime 的 MiniRuntimeCore 小程序运行时核心的 start 函数中。 可以找到如下相关代码,这段代码会在小程序初始化阶段运行:

const disposer = reaction(dataExprFn, sideEffect, options)
context.__cml_ob_data__ = observable(context.__cml_data__)

function dataExprFn() {
     let properties = context.__cml_originOptions__[self.propsName]
     let propKeys = enumerableKeys(properties)
     // setData 的数据不包括 props
     const obData = deleteProperties(context.__cml_ob_data__, propKeys)
     return toJS(obData)
}

function sideEffect(curVal, r = {}) {
     let diffV
     if (_cached) {
       diffV = diff(curVal, cacheData)
       emit('beforeUpdate', context, curVal, cacheData, diffV)
     } else {
       _cached = true
       diffV = curVal
     }
     if (type(context.setData) === 'Function') {
       context.setData(diffV, walkUpdatedCb(context))
     }
     cacheData = { ...curVal }
}

为了更好的看清代码,做了些许删减。 下面来解释一下这段代码。

  1. 小程序在初始化阶段会调用 reaction 对我们的Page 里的 data 做数据监听并自动响应,这里响应函数其实是一种副作用,所以这里命名 sideEffect。而响应的条件是 dataExprFn。
  2. 当我们的页面 data 变化时,触发 dataExprFn,由于 Mobx 会将观察者数据 observable 化,变为可观察对象。所以 dataExprFn 其实干的事情是 将 observable 化的 data 再 js 化,如果 js 化的数据和之前不一样,就调用 sideEffect 副作用。
  3. 而 sideEffect 干的事情是 对 js 化的 data 数据做 diff,如果 数据 diff 后不一样,就做 setData。

好的故事讲完了,性能问题 dataExprFn 和 sideEffect 各自占一个,因为 deepEq 函数在 sideEffect diff 的时候会调用。 我们先来看看问题最突出的 toJS 函数,为什么 toJS 的性能问题会这么突出呢。

2.4、Chameleon runtime 的 toJS 函数

export default function toJS(source, detectCycles = true, __alreadySeen = [], needPxTransfer = true) {
  if (isObservable(source)) {
      if (isObservableArray(source)) {
          var res = cache([]);
          var toAdd = source.map(function (value) { return toJS(value, detectCycles, __alreadySeen); });
          res.length = toAdd.length;
          for (var i = 0, l = toAdd.length; i < l; i++)
              res[i] = toAdd[i];
          return res;
      }
    }
}

这里同样做了大量删减,事实上 toJS 会对多种数据类型做不一样的处理,但这里只看 Array类型 吧,可以看到 toJS 呈现了递归调用,尤其是还有 isObservable 和 isObservableArray ,以及遍历 toJS处理。 导致递归将性能问题大幅放大。

事实上事后回过头来看,dataExprFn 只是原封不动将 Observable 转 js 数据类型,再做出响应。其实只是因为使用了 Mobx 不得不必须做这么一层转换层,要牺牲性能大的性能,其实这里完全是可以优化得到。

参考 Mobx 底层的 symbol proxy reflect 三剑客,我这里也只能建议 Chameleon 剥离 Mobx 的依赖,独立实现一套更轻量化的数据状态管理框架。

2.5、Chameleon runtime 的 diff 函数

回到性能问题的第二个根源,deepEq 函数

export default function diff(current, old) {
  let out = {}
  prefill(current, old)  // 填补新老数据的属性差异
  iDiff(current, old, '', out)  // 算出出现变化差异的数据,存到 out 中
  return out
}

其中 deepEq 函数 在 Mobx 的 comparer.structural 中调用,用来对数据进行深度比较。

function prefill(current, old) {
    if (comparer.structural(current, old)) return

    if (type(current) === 'Object' && type(old) === 'Object') {
        for (let key in old) {
              const curVal = current[key]
              if (curVal === undefined) {
                  current[key] = ''
              } else {
                  prefill(curVal, old[key])
              }
        }
    }
}

这里又删了一部分代码,不难发现又是基于深度遍历的递归算法,那么 comparer.structural 如果有性能问题,也会被大幅放大,哎,又是 mobx 的性能问题。

2.6 增强实验无限放大问题

当我们把数据的深度变得稍微深一点时,小程序甚至可以直接死机,大家可以试试,事实上这个数据并没有很极端,大家的业务场景中很有可能会出现这样深度的场景。

三、梳理运行时过程和其他

3.1 梳理运行时过程

Chameleon 修改 data 后会发生如下调用栈,红色和黄色就是本次性能瓶颈的时间点:

不妨再加入微信小程序运行时的过程,我们可以观察的更加清晰:

下面可以看到微信官网的底层介绍

然后再结合这部分,我们再把过程重新梳理一遍:

如此来看,不难发现一些高性能场景或者复杂场景的页面下,不妨使用微信原生,Chameleon 的运行时架构决定了这里会有一定程度的开销,即便 Chameleon 的下个版本修复了我上述所说的性能问题。

四、一些改进建议

4.1 Batch-Update 机制

比如这段代码,竟然触发了 2次 reactionRunner 方法,要知道目前这个方法是性能问题比较大的函数,更诡异的是 Reaction.runReaction 底层会执行 2次 Reaction.track 方法, 而 Reaction.track 中的 toJs 函数 是此次延迟的最大原因 。

也就是说 array 中有 400 条数据的话,因为 data 变化了2次, 一次是 this.array ,一次是 this.showlist, 所以 我会 toJS 两次 400 条的 Obserable 数据,然后因为 Reaction 底层会执行 2次 toJS 方法,所以 原本的 400 条数据被以 1600 的量被 toJS 化。

4.2 提前 Diff 化

考虑到 Mobx 底层toJs不太容易替换

可以在 dataExprFn 那一层就把 Diff 需要更新的数据算出来,这样直接 toJs 的话,可以减少很多不必要的计算量。但依然治标不治本。。。

4.3 替换 Mobx 的依赖

可以自定义一个更高效的基于小程序的 Mobx 库解决上面的问题,制作一套更高效地 Observatable -> js 的算法