- 浅析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)) } [代码] 至此,问题解决…这下终于打完收工了,🍎🍎🍎
2020-07-03 - 小程序项目中蹚的坑和一些不成熟的建议(音频篇)
一、在小程序内,使用锤子系(坚果)手机 目前发现播放时长小于2秒的音频均收不到结束事件回调。目前尚未发现在其他品牌手机有此现象。 解决办法:尽量避免使用音频播放衔接上下文,如果必须使用音频衔接上下文则必须使用大于2秒的音频文件。 二、播放很短的音效(短于1秒),例如点击效果声,在很多机型下会播不出声音。 解决办法:最好不要采用比特率高于128kbps的mp3(短于1s)的文件,在很多机型下会播放短音频不出声音。换成24kbps即可。 三、在音频播放过程中退出(onHide),返回后(onShow)不能继续播放音频。 解决方法:先设置一个变量isPlaying,在播放时设为true,在onShow里延时两秒后(关键:一定要延时)判断如果isPlaying为true,即调用play()即可。其实这个延时的时间不一定是2s,只是为了保证有一些性能很差的手机能成功唤起,至于为什么延时2秒就好了,还请知道的大神不吝赐教其机制。 四:目前就只能想起这么多,后面想起来了会不定时补充的…
2019-11-05 - 基础库2.6.6(微信版本7.0.3)及其以下 cover-view的定位层级问题
本文要介绍的是关于低版本微信(如题)使用cover-view中会出现的一些情况: 问:为什么非要使用cover-view而不是view? ·官方关于cover-view的demo都是把cover-view写在video标签内,在最新的版本中官方也明确说明了是可以舍弃cover-view可以直接写view的,但是如果你们公司业务需求要求必须兼容一些老版本的微信和系统版本,或者是产品设计将图层设计在video上方且面积大于等于视频或与视频交叉显示,那么解决cover-view的所带来的的问题又将重新提上议程。这里就简单列举下我们所遇到且解决的一些问题: 以下所有使用cover-view和cover-image具有相同效果。这里有如下代码块: <cover-view class=“box”> <cover-image class=“img” src=“xxx.png”></cover-image> <cover-view class=“content”></cover-view> <cover-view> 1、普通渲染的显示隐藏问题: 我们知道,在cover-view里使用visibility:hidden;或者diaplay:none;在低版本的设备中是不生效的,唯一能指望的就是wx:if,在旧版本上wx:if会把后渲染的内容层级升到最高(新版本不会)。建议解决方法:1、将需要wx:if的内容最后渲染出来2、将需要wx:if的内容放到整个wxml的结构的最后面,这样即使是同时渲染也因为排在后面而后渲染从而显示在最上方。 2、在cover-view上使用animation动画后的层级问题:如果在普通的div中,我们假设上面img和content都使用了定位,且img的层级比content高,当我们在content上使用了animation动画,content就会遮住img,通常在普通div中我们解决的方法就是在img上加上translateZ(10px)或者translate3d(0,0,10px)将它的Z轴靠前即可显示,但是在cover-view上一旦在animation动画中改变了Z轴整个动画就会失效。所以,一到这种问题的解决方式还是控制被遮挡块的wx:if渲染时机是最后一个渲染,方法如上(控制时间延时或者放到最后一个渲染的位置上)
2019-11-18