浅析growingio无埋点数据采集的实现原理——剧情版
发布于 5 年前 作者 gxiong 3500 次浏览 来自 分享

1. 背景

我厂开发的小程序最近接入了付费产品growingio,号称可以实现无埋点采集用户行为,包含用户操作、页面访问、停留时间等,可直接追踪用户的使用路径。由于我厂用的__原生__小程序开发方式,所有接了他们的原生小程序的sdk。接入方式也挺简单:

import gio from 'path/to/giosdk'

gio('setConfig', ourConfig)

App({
   // xxx
})

编译后,看到network里各种搜集到的数据被上传,满心欢喜,心想:哟! 果然是付费的,省心。万事大吉,打完收工!结果,意向不到的事情发生了…

我厂只考虑微信小程序,曾用过wepy,mpvue,效果都不太理想,后来大神空降,自己撸了一套适用原生小程序的框架,优劣暂且不表,关键在于关于事件处理已经有一套封装,然后现在接了gio后就开始糟了😅。

2. 入坑

首先,其他功能测试期间,发生了莫名奇妙的问题:什么视频暂停不了、定时器停不掉、音频播放停不下来,各种匪夷所思。后仔细排查,发现是由于小程序生命周期函数onLoad执行了两次,我插!这什么鬼,在排除我们自身原因后,发现跟接了gio有关,注释gio代码,执行一次,开启就执行2次😟

其次,用户操作事件数据确有上传,但是一次点击,竟然产生了多次数据,导致很多重复。

咋办?产品的🔪还架在脖子上,砍需求,砍需求是不可能的这样子。只能去看gio sdk的源码了,看能否找到解决方案。结果还没开始,就遇到大坑,gio sdk是闭源项目,找他们的对接人又一直不提供项目源码,想想也是,比较指着卖钱呢,于是乎,只能在压缩混淆后的代码找办法了

3. 强行排查

首先采用代码反压缩,把压缩后的代码转成勉强能看的代码,其实只是格式化了下,变量名和写法仍然是压缩的表现,就像这种:

if (
    VdsInstrumentAgent.initPlatformInfo(gioGlobal.platformConfig),
    VdsInstrumentAgent.observer = t,
    VdsInstrumentAgent.pageHandlers.forEach(function (t) {
        VdsInstrumentAgent.defaultPageCallbacks[t] = function () {
            VdsInstrumentAgent.observer.pageListener(this, t, arguments)
        }
    }),
    VdsInstrumentAgent.appHandlers.forEach(function (t) {
        VdsInstrumentAgent.defaultAppCallbacks[t] = function () {
            VdsInstrumentAgent.observer.appListener(this, t, arguments)
        }
    }),
gioGlobal.platformConfig.canHook
) {
    const t = gioGlobal.platformConfig.hooks;
    t.App && !gioGlobal.growingAppInited && (App = function () {
        return VdsInstrumentAgent.GrowingApp(arguments[0])
    }, gioGlobal.growingAppInited = !0), t.Page && !gioGlobal.growingPageInited && (Page = function () {
        return VdsInstrumentAgent.GrowingPage(arguments[0])
    }, gioGlobal.growingPageInited = !0), t.Component && !gioGlobal.growingComponentInited && (Component = function () {
        return VdsInstrumentAgent.GrowingComponent(arguments[0])
    }, gioGlobal.growingComponentInited = !0), t.Behavior && !gioGlobal.growingBehaviorInited && (Behavior = function () {
        return VdsInstrumentAgent.GrowingBehavior(arguments[0])
    }, gioGlobal.growingBehaviorInited = !0)
}

简直神清气爽,😓

没办法,在说了N句卧槽后,只能按住自己躁动不安的心,强行阅读源码。

最开始很好奇为什么,只写那两行代码,竟然就能实现数据收集,事件不是要在wxml里面写bindtap之类的吗?怎么不写就能知道我点了呢?难道有什么方法知道我写的bingtap的函数,怎么实现的呢?还有onShow,onLoad生命周期函数之类,就那两行,它怎么知道什么时候执行了onShow?

4.实现原理

  1. 重写Page,App方法:
Page() {
    return VdsInstrumentAgent.GrowingPage(arguments[0]);
}
App() {
    return VdsInstrumentAgent.GrowingApp(arguments[0]);
}

VdsInstrumentAgent.GrowingPage = function (t) {
    return t._growing_page_ = !0, VdsInstrumentAgent.originalPage(VdsInstrumentAgent.instrument(t))
}

VdsInstrumentAgent.GrowingApp = function (t) {
    return t._growing_app_ = !0, VdsInstrumentAgent.originalApp(VdsInstrumentAgent.instrument(t))
}

/*
 * VdsInstrumentAgent.originalPage
 * VdsInstrumentAgent.originalApp
 * 重写前的Page和App
*/
  1. 处理Page、App的参数,如果是函数,处理函数,并且提供默认的生命周期函数
VdsInstrumentAgent.instrument = function (t) {
    for (let e in t){
        if("function" == typeof t[e]){
            t[e] = this.hook(e, t[e]);
        }
    };
    return t._growing_app_ && VdsInstrumentAgent.appHandlers.map(function (e) {
        t[e] || (t[e] = VdsInstrumentAgent.defaultAppCallbacks[e])
    }),
    t._growing_page_ && VdsInstrumentAgent.pageHandlers.map(function (e) {

        t[e] || e === gioGlobal.platformConfig.lisiteners.page.shareApp || (t[e] = VdsInstrumentAgent.defaultPageCallbacks[e])
    }),
    t;
}
  1. 处理函数时,如果函数的第一个参数存在,并且有currentTarget或者target的属性,并且函数type属性(鸭式辩型),且是需要捕获的事件(“onclick”, “tap”, “longpress”, “blur”, “change”, “submit”, “confirm”, “getuserinfo”, “getphonenumber”, “contact”),,就增加监听函数,用于捕获事件,这里便收集了用户的操作事件
hook: function (t, e) {
    return function () {t
        let i, n = arguments ? arguments[0] : void 0;
        // 收集用户操作事件
        if (n && (n.currentTarget || n.target) &&  -1 != VdsInstrumentAgent.actionEventTypes.indexOf(n.type)){
            try {
                VdsInstrumentAgent.observer.actionListener(n, t);

            } catch (t) {
                console.error(t)
            }
        }
        const o = gioGlobal.platformConfig.lisiteners.app,
            s = gioGlobal.platformConfig.lisiteners.page;
            
        
        if (
            // 非生命周期函数直接调用
            this._growing_app_ &&t !== o.appShow ?
            (i = e.apply(this, arguments)):            
            this._growing_page_ && -1 === [s.pageShow, s.pageClose, s.pageLoad, s.pageHide, s.tabTap].indexOf(t)
            ? (i = e.apply(this, arguments)) :
            this._growing_app_ || this._growing_page_ || (i = e.apply(this, arguments)),
            // 需要收集的App生命周期函数
            this._growing_app_ && -1 !== VdsInstrumentAgent.appHandlers.indexOf(t))
        {
            try {
                VdsInstrumentAgent.defaultAppCallbacks[t].apply(this, arguments)
            } catch (t) {
                console.error(t)
            }
            t === o.appShow && (i = e.apply(this, arguments))
        }
        // 需要收集的Page生命周期函数
        if (this._growing_page_ && -1 !== VdsInstrumentAgent.pageHandlers.indexOf(t)) {
            let n = Array.prototype.slice.call(arguments);
            i && n.push(i);
            try {
                VdsInstrumentAgent.defaultPageCallbacks[t].apply(this, n)
            } catch (t) {
                console.error(t)
            }
            - 1 !== [s.pageShow, s.pageClose, s.pageLoad, s.pageHide, s.tabTap].indexOf(t) ? (i = e.apply(this, arguments)) : setShareResult(i)
        }
        return i
    }
}

生命周期函数的处理,也在这里进行统一监听,defaultAppCallbacks,defaultPageCallbacks里面存的是各种监听函数

// VdsInstrumentAgent.pageHandlers
// pageHandlers: ["onLoad", "onShow", "onShareAppMessage", "onTabItemTap", "onHide", "onUnload"],
VdsInstrumentAgent.pageHandlers.forEach(function (t) {
    VdsInstrumentAgent.defaultPageCallbacks[t] = function () {
        VdsInstrumentAgent.observer.pageListener(this, t, arguments)
    }
}),
// VdsInstrumentAgent.appHandlers
// appHandlers: ["onShow", "onHide", "onError"],
VdsInstrumentAgent.appHandlers.forEach(function (t) {
    VdsInstrumentAgent.defaultAppCallbacks[t] = function () {
        VdsInstrumentAgent.observer.appListener(this, t, arguments)
    }
}),
  1. 监听器里处理事件的分发
pageListener(t, e, i) {
        const n = gioGlobal.platformConfig.lisiteners.page;

        if (        
        t.route || (t.route = this.info.getPagePath(t)),
        e === n.pageShow)
        {
            const e = getDataByPath(t, "$page.query");
            Utils.isEmpty(e) || "quickApp" !== gioGlobal.gio__platform || this.currentPage.addQuery(t, e), this.isPauseSession ? this.isPauseSession = !1 : (this.currentPage.touch(t), this.useLastPageTime && (this.currentPage.time = this.lastPageEvent.tm, this.useLastPageTime = !1), this.sendPage(t))
        } else if (e === n.pageLoad) {
            const e = i[0];
            Utils.isEmpty(e) || "quickApp" === gioGlobal.gio__platform || this.currentPage.addQuery(t, e)
        } else if (e === n.pageHide) this.growingio._observer && this.growingio._observer.disconnect();
        else if (e === n.pageClose) this.currentPage.pvar[`${this.currentPage.path}?${this.currentPage.query}`] = void 0;
        else if (e === n.shareApp) {
            let e = null,
                n = null;
            2 > i.length ? 1 === i.length && (i[0].from ? e = i[0] : i[0].title && (n = i[0])) : (e = i[0], n = i[1]), this.pauseSession(), this.sendPageShare(t, e, n)
        } else if ("onTabItemTap" === e) {
            this.sendTabClick(i[0])
        }
    }

根据生命周期函数名,处理onShow、onHide之类,并传入需要的参数。这里便处理了用户的操作路径的数据,比如:进入某个页面、退出某个页面、在哪个页面调了分享、点了tab之类。

至此,大致的运行原理已经明白了。

5. 解决问题

  1. 点击一次按钮产生多次数据:根据上面的运行原理,可知gio事件收集是根据函数的第一个参数来判断的,由于我们内部框架有事件的统一封装,粗略代码如下:
events: {
    test1: 'fnTest1',
    test2: 'fnTest2',
    test3: 'fnTest3',
    test4: 'fnTest4',
  },
  bindEvent(e){
    let id = e.target.dataset.id;
    if (id in this.events){
      this[this.events[id]].call(this, e)
    }
  },
  fnTest1(e){
    console.log(e.target.dataset.id)
  },
  fnTest2(e) {
    console.log(e.target.dataset.id)
  },
  fnTest2(e) {
    console.log(e.target.dataset.id)
  },
  fnTest3(e) {
    console.log(e.target.dataset.id)
  }

bindEvent是wxml里面统一写的事件方法,根据dataset-id来分发事件按,这里如果点击了test1,按照gio的收集原理,会搜集bindEvent,fnTest1,产生2次数据,而我们最终想要的是fnTest1。在实际情况下,由于bingEvent里还有其他封装,导致数据不止2次。知道原因,这个问题就很好解决,我们为事件对象(第一个参数)增加了一个growingIgnore属性,内部统一封装的事件对象growingIgnore = true,再修改gio,上面运行原理第3步,hook函数、收集用户事件处来过滤。

  1. 生命周期函数执行2次:跟我们内部封装和Object.assign有关,示意代码如下:
let a  ={name: 'jojo'}
let b = Object.assign(a, {age: 27})
// a === b ?

// 内部处理
App(appOptions)

Page(Object.assign(appOptions, pageOptions))

导致,调App时gio加个app标记,在Page里面也能获取到,在gio内部,执行生命周期函数时,区分不开是page还是app。上面运行原理第3步,hook函数、收集用户事件处,非生命周期函数直接调用和需要收集的Page生命周期函数,会存在2次调用。知道了原因,也就很好处理了,在原理第1步重写方法时,调app做app标记时,也要重置page标记,调page时同理:

VdsInstrumentAgent.GrowingPage = function (t) {
    return t._growing_page_ = !0, t._growing_app_ = !1, VdsInstrumentAgent.originalPage(VdsInstrumentAgent.instrument(t))
}

VdsInstrumentAgent.GrowingApp = function (t) {
    return t._growing_app_ = !0, t._growing_page_ = !1,VdsInstrumentAgent.originalApp(VdsInstrumentAgent.instrument(t))
}

至此,问题解决…这下终于打完收工了,🍎🍎🍎

回到顶部