浅析growingio无埋点数据采集的实现原理——剧情版
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之类的吗?怎么不写就能知道我点了呢?难道有什么方法知道我写的bindtap的函数,怎么实现的呢?还有onShow,onLoad生命周期函数之类,就那两行,它怎么知道什么时候执行了onShow?
4.实现原理
重写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
*/
[代码]
处理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;
}
[代码]
处理函数时,如果函数的第一个参数存在,并且有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)
}
}),
[代码]
监听器里处理事件的分发
[代码]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. 解决问题
点击一次按钮产生多次数据:根据上面的运行原理,可知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。在实际情况下,由于bindEvent里还有其他封装,导致数据不止2次。知道原因,这个问题就很好解决,我们为事件对象(第一个参数)增加了一个growingIgnore属性,内部统一封装的事件对象growingIgnore = true,再修改gio,上面运行原理第3步,hook函数、收集用户事件处来过滤。
生命周期函数执行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))
}
[代码]
至此,问题解决…这下终于打完收工了,🍎🍎🍎