微信小程序性能,行为收集探针实现

6,372 阅读11分钟

小程序与普通网页开发的区别

​小程序的主要开发语言是 JavaScript ,小程序的开发同普通的网页开发相比有很大的相似性。对于前端开发者而言,从网页开发迁移到小程序的开发成本并不高,但是二者还是有些许区别的。

​网页开发渲染线程和脚本线程是互斥的,这也是为什么长时间的脚本运行可能会导致页面失去响应,而在小程序中,二者是分开的,分别运行在不同的线程中。网页开发者可以使用到各种浏览器暴露出来的 DOM API,进行 DOM 选中和操作。而如上文所述,小程序的逻辑层和渲染层是分开的,逻辑层运行在 JSCore 中,并没有一个完整浏览器对象,因而缺少相关的DOM API和BOM API。这一区别导致了前端开发非常熟悉的一些库,例如 jQuery、 Zepto 等,在小程序中是无法运行的。同时 JSCore 的环境同 NodeJS 环境也是不尽相同,所以一些 NPM 的包在小程序中也是无法运行的。

​网页开发者需要面对的环境是各式各样的浏览器,PC 端需要面对 IE、Chrome、QQ浏览器等,在移动端需要面对Safari、Chrome以及 iOS、Android 系统中的各式 WebView 。而小程序开发过程中需要面对的是两大操作系统 iOS 和 Android 的微信客户端,以及用于辅助开发的小程序开发者工具,小程序中三大运行环境也是有所区别的

运行限制

基于安全考虑,小程序中不支持动态执行 JS 代码,即:

不支持使用 eval 执行 JS 代码 不支持使用 new Function 创建函数

​网页开发者在开发网页的时候,只需要使用到浏览器,并且搭配上一些辅助工具或者编辑器即可。小程序的开发则有所不同,需要经过申请小程序帐号、安装小程序开发者工具、配置项目等等过程方可完成。

小程序运行机制

小程序启动

小程序启动会有两种情况,一种是「冷启动」,一种是「热启动」。

热启动:假如用户已经打开过某小程序,然后在一定时间内再次打开该小程序,此时无需重新启动,只需将后台态的小程序切换到前台,这个过程就是热启动;

冷启动:用户首次打开或小程序被微信主动销毁后再次打开的情况,此时小程序需要重新加载启动,即冷启动。 小程序没有重启的概念。

前台/后台状态

当用户点击右上角胶囊按钮关闭小程序,或者按了设备 Home 键离开微信时,小程序并没有直接销毁,而是进入了后台状态;

当用户再次进入微信或再次打开小程序,小程序又会从后台进入前台。

小程序销毁

需要注意的是:只有当小程序进入后台一定时间,或者系统资源占用过高,才会被真正的销毁。

当小程序进入后台,客户端会维持一段时间的运行状态,超过一定时间后(目前是5分钟)小程序会被微信主动销毁。 当小程序占用系统资源过高,可能会被系统销毁或被微信客户端主动回收。 在 iOS 上,当微信客户端在一定时间间隔内(目前是 5 秒)连续收到两次及以上系统内存告警时,会主动进行小程序的销毁,并提示用户 「该小程序可能导致微信响应变慢被终止」。 建议小程序在必要时使用 wx.onMemoryWarning 监听内存告警事件,进行必要的内存清理。

小程序更新机制

未启动时更新

开发者在管理后台发布新版本的小程序之后,如果某个用户本地有小程序的历史版本,此时打开的可能还是旧版本。微信客户端会有若干个时机去检查本地缓存的小程序有没有更新版本,如果有则会静默更新到新版本。总的来说,开发者在后台发布新版本之后,无法立刻影响到所有现网用户,但最差情况下,也在发布之后 24 小时之内下发新版本信息到用户。用户下次打开时会先更新最新版本再打开。

启动时更新

小程序每次冷启动时,都会检查是否有更新版本,如果发现有新版本,将会异步下载新版本的代码包,并同时用客户端本地的包进行启动,即新版本的小程序需要等下一次冷启动才会应用上。

如果需要马上应用最新版本,可以使用 wx.getUpdateManager API 进行处理。

小程序探针开发难点与重点

  • 无法直接拦截/监听请求

    微信请求统一通过微信API完成 ,请求模块已被微信方封装,且小程序的运行环境不是浏览器对象,不像web应用那样重写封装很自如。

  • 三种运行环境的监控兼容性保证

    • Android 上,js运行环境是 X5 内核
    • iOS 上,js 运行环境是 JavaScriptCore
    • 开发工具上, j s运行环境是 nwjs(chrome内核)
  • 用户行为无法直接监听

    小程序逻辑层运行时无法获取DOM和BOM,无法像传统网页开发一样使用DOM事件API,无法全局监听事件.

  • sdk需轻量

    小程序包大小有限制,单包最大为2M,分包情况下,不能超过8M,所以sdk需轻量

  • 数据收集量大,尽量减少性能损耗

    需要设计缓存池,制定上报策略

  • 不影响业务(基本需求)

探针缓存池与上报策略

探针收集到的数据主要分为两种,一种是基本数据,还有一种是事件特性数据.特性数据在下面关键事件中将会提到

基础数据

基本数据是每条上报日志都包含的数据。其中一部分,在初始化探针后就获取到,并且不会改变.这部分数据,业务相关的由用户配置,其余数据由探针内部生成或者调用wx.getSystemInfoSync API获取

另一部分,随着用户行为,比如页面切换、登陆,或者环境变化,如网络变化时,将会改变.

network数据通过 wx.getNetworkType 与 wx.onNetworkStatusChange获取 title部分在下面的关键事件有讲到

事件特性数据

上报策略

探针内部将会缓存对应日志,防止小程序Storage清空时,遗失数据.

数据上报只要上报,就将缓存的日志清空,防止上报失败导致缓存的日志越积越多

探针关键事件捕获

关键事件类型

改写App config

对于App类主要改写config上的"onShow", "onHide", "onError", 'onLaunch'这几个生命周期 缓存钩子函数 给config上的方法挂上钩子,对config中未配置对应生命周期,加上默认生命周期回调 对config包含了"onShow", "onHide", "onError",'onLaunch'生命周期函数,执行完原方法后再调用钩子函数

  • 启动事件(start)

    小程序启动,获取小程序启动场景值.重写App的config,通过onLaunch触发. 获取小程序启动场景值scene,页面路径path,页面search,通过页面路径与 __wxConfig对象获取页面title.

  • 退出到后台(pause)

    小程序切换到后台,重写App的config,通过onHide触发

  • 切回前台(resume)

    小程序从后台唤醒,获取切回小程序场景值scene.重写App的config,通过onShow触发(第一次触发onShow除外)

  • 异常捕获

    由于小程序的全局监听方法wx.onError只有2.1.2及以上才支持,为了兼容,需要重写App的config,通过onError触发.

改写Page config

这一部分与改写App config大同小异,主要看事件的获取

  • 页面停留(page_stay)

    onHide与onUnload时触发,获取用户在当前页面停留的时间.对于分享转发页面导致onHide触发的场景,不进行页面停留上报.

  • 页面切换(page)

    每次切换页面(onShow)时触发,获取当前页面路径,参数,title

  • 页面初次渲染时长

    页面首次打开或销毁后首次打开,页面渲染所花费的时间,重写Page的config,通过onReady触发.

  • 页面分享(share)

    用户分享转发页面时触发,通过重写Page的config,onShareAppMessage触发.由于页面分享会触发当前页面的onShow,onHide生命周期,为了数据准确,通过设置变量isPause来甄别.

用户行为捕获

由于用户行为总是与事件相关,对于事件,小程序无法直接监听dom事件,这里采取的方案是对App、Page、Component、Behavior的config进行改写,判断,判断config上的属性是否为函数,并且函数的形参是否为事件源,如果是事件源,说明该函数与用户行为现关联

对于Component、Behavior只需对其config.method上的方法进行hook 通过形参是否具有currentTarget属性判断当前是否为事件函数

对于不存在自定义事件属性的点击事件,认定为点击事件,对于存在的,认定为自定义事件

  • 点击事件(click)

    由于小程序的逻辑层与渲染层是分开的,逻辑层运行在JSCore中,没有完整的浏览器对象,缺少dom与bom相关api,无法在body上设置全局的点击事件监听方法.

    为了实现事件的监听,探针通过改写Page 、Component和Behavior的config,对config上的所有属性进行区分,判断当前属性是否为函数,并且该函数触发时,形参上是否具有currentTargey属性来区分形参是否为事件对象,以此监听页面事件.对于tap与longpress事件,探针认定为点击事件.

    类型触发条件
    tap手指触摸后马上离开
    longpress手指触摸后,超过350ms再离开,如果指定了事件回调函数并触发了这个事件,tap事件将不被触发

  • 自定义事件(log)

    直接在事件函数内调用探针暴露的自定义事件上报方法会导致业务代码与探针耦合度过高.

    探针结合事件的监听通过在绑定了事件的小程序标签上添加自定义属性,来实现自定义事件的上报.

    由于事件触发时的事件源经微信内部封装过,自定义属性的获取目前只支持数据属性data-xxx的形式获取,所以在非手动调用时,可以在触发点击事件的小程序标签上增加data-event 与 data-log来添加低耦合的自定义事件代码.

改写wx对象实现api事件捕获

  • api事件(api)

    覆写wx对象,对wx.request方法的config进行重写,获取api(数据接口地址)、api_method(数据接口请求方式)、api_status(数据接口响应状态码)、api_response_time(数据接口响应时间(ms))、api_response_content_length (数据接口响应内容长度(byte))

    小程序的api基本都挂载在全局对象wx上,直接修改wx上面的属性,将会报错,直接赋值失败(小程序内部对此做出了限制)

    thirdScriptError 
     sdk uncaught third Error 
     Cannot set property request of #<Object> which has only a getter 
     TypeError: Cannot set property request of #<Object> which has only a getter
    

    替代方案

    使用Object.getOwnPropertyDescriptors获取到wx对象的属性描述符,将微信对象重新赋值为空对象,循环属性描述符,判断当前描述符的键是否为request,并进行改造request属性描述符,其他情况使用Object.defineProperty方法定义属性

    wx对象属性描述符

    由于for in循环获取不到Symbol类型的键,为了兼容wx对象将来引入Symbol作为wx对象键的情景,使用Object.getOwnPropertySymbols方法获取到属性描述符中的Symbol,再重新定义属性

    这一块代码太多了,不好截图,直接上代码吧

    // 重写wx.request
      rewriteWxRequest() {
        const that = this;
        
        // return 
        // 重写wx对象start
        const descriptorObj = Object.getOwnPropertyDescriptors(wx);
        let oldWx = this.oldWx = wx;
        wx = {};
        for (let i in descriptorObj) {
          if (i === 'request') {
            const desObj = descriptorObj[i];
            let oldGet = desObj.get;
            desObj.get = function(...args){
              let oldRequest = oldGet.apply(this, args);
              return function(params){
                const {
                  url,
                  method = 'GET',
                  success = function(){},
          
                } = params;
                // 检查API请求是否在忽略的url中
                const ignoreUrls = that.conf.api_ignore_urls;
                if (url && isIgnoreApi(url, ignoreUrls)) {
                  return oldRequest.call(this, params);
                }
                // 处理自定义 api url trim func
                let apiTrimUrl = null;
                if (that.conf.api_property_cb) {
                  try {
                    apiTrimUrl = that.conf.api_property_cb(url) || null;
                  } catch (e) {
                    apiTrimUrl = null;
                  }
                }
    
                const timeStamp = Date.now();
                const apiData = {
                  api: apiTrimUrl || cutAPIUrl(url),
                  api_method: method.toUpperCase(),
                  api_status: undefined,
                  api_response_time: 0,
                  api_response_content_length: 0,
                }
                return oldRequest.call(this, {
                  ...params,
                  success (res) { // 成功回调
                    try {
                      const {
                        data,
                        statusCode
                      } = res
                      apiData.api_status = statusCode;
                      apiData.api_response_time = Date.now() - timeStamp;
                      if (data) {
                        let AB = {};
                        if(typeof ArrayBuffer !== undefined) {
                          AB = ArrayBuffer;
                        }
                        if (data instanceof AB && data.byteLength !== undefined) {
                          apiData.api_response_content_length = data.byteLength;
                        } else {
                          if (typeof data === 'string') {
                            apiData.api_response_content_length = data.length || 0;
                          } else {
                            apiData.api_response_content_length = JSON.stringify(data).length || 0;
                          }
                        }
                      } else {
                        apiData.api_response_content_length = 0;
                      }
                      that.reportApi(apiData)
                    } catch (e) {
                      that.consoleErr(e);
                    }
                    success.call(this, res);
                  },
                })
              }
            }
            Object.defineProperty(wx, i, desObj)
          } else {
            Object.defineProperty(wx, i, descriptorObj[i])
          }
        }
        // 对微信将来引入Symbol的情况进行兼容,防止丢失以Symbol为键的情况
        if (Object.getOwnPropertySymbols && typeof Object.getOwnPropertySymbols === 'function') {
          Object.getOwnPropertySymbols(descriptorObj).forEach(val => {
            Object.defineProperty(wx, val, descriptorObj[val])
          })
        }
        // 重写wx对象end
      }
    

待优化

  • 错误异常无法定位到源码
  • 目前只支持原生框架和mpvue框架,并且不能适用微信第三方插件
  • 自定义事件无法像web探针,在任意标签上添加