Android端埋点自动采集技术原理剖析

Stella981
• 阅读 526

前言:
-更多关于数智化转型、数据中台内容请加入**阿里云数据中台交流群—数智俱乐部** (文末扫描二维码或点此加入

-阿里云数据中台官网 https://dp.alibaba.com/index

(作者:qingliang_hu)

定义

APP埋点自动采集是指用户在APP内的操作行为自动采集并上报日志,其表现在APP上的元素(按钮、图片等)的行为主要分为点击和曝光行为
其中曝光意为该元素在可视区域停留时长达到一定阈值,即标记为一次曝光行为。
本文主要定位为对Andorid端内部自动采集技术的原理剖析。

核心原理

主流的Android端的事件监听机制主要有Listener代理,Hook,AccessibilityDelegate,dispatchTouchEvent四种监听方式,下面将简要总结四种方式的具体实现。
(此处不介绍采用AspectJ框架编译期注入代码方式实现监听,主要原因在于此方式相对而言太暴力,业务侵入性太强,很难在业务方APP上进行推广和实现,感兴趣的可自行Google/Baidu。)

Listener代理
在Android中,对于事件的监听及逻辑处理主要通过对View.onClickListener中的onClick方法进行覆写,如

View saveView = findViewById(R.id.btnSave); 
saveView.setOnClickListener(new OnClickListener() {

@Override 
public void onClick(View v) {

//TO DO


});

因此,可以通过自定义监听代理类ProxyListener,实现View.OnClickListener中的onClick方法,将控件的onClickListener统一替换成ProxyListener,从而完成点击监听和日志上报。代码如下:

ProxyListener监听代理类:

public abstract class ProxyListener implements View.OnClickListener{

@Override public void onClick(View view) { // doOnClick为业务方控件点击事件的逻辑实现 doOnClick(view); sendLog(view); } protected void sendLog(View view) { //TODO:detail of sendLog(), based on Thread Runnable runnable = new Runnable() { @Overrid public void run() { //TODO:do send log } }; Thread thread = new Thread(runnable); thread.start(); } protected abstract void doOnClick(View view);

}

对于所有控件,统一替换调用监听代理类:

View saveView = findViewById(R.id.btnSave);

saveView.setOnClickListener(new ProxyListener() {
@Override
public void doOnClick(View v) {

//TO DO

}
});

hook机制
Hook机制基于java反射原理,从rootview开始,递归遍历所有的控件View对象,并hook其对应的OnClickListenr对象,将其替换成用于上报日志的监听代理类ProxyListener,从而实现动态hook。实现代码如下:

Step 1: 创建监听代理管理类,用于统一管理OnClickListenr对象的调用即实现:

public class ProxyManager {

public static void sendLog(View view){} public static class ProxyListener implements View.OnClickListener{ View.OnClickListener mOriginalListener; public ProxyListener(View.OnClickListener l) { mOriginalListener = l; } @Override public void onClick(View v) { //TODO: send log sendLog(v); if(mOriginalListener != null) { mOriginalListener.onClick(v); } } }

}

Step 2: 创建反射管理类,用于保存hook到的OnClickListener对象:

public class HookView {

public Method mHookMethod; public Field mHookField; public HookView(View view) { try { Class viewClass = Class.forName("android.view.View"); if(viewClass != null) { mHookMethod = viewClass.getDeclaredMethod("getListenerInfo"); if(mHookMethod != null) { mHookMethod.setAccessible(true); } } Class listenerInfoClass = Class.forName("android.view.View$ListenerInfo"); if(listenerInfoClass != null) { mHookField = listenerInfoClass.getDeclaredField("mOnClickListener"); } if(mHookField != null) { mHookField.setAccessible(true); } } catch (Exception e) {} }

}

Step 3: 递归深度遍历所有的控件,为其替换OnClickListenr对象

public void hookViews(View view) {

try { if(view.getVisibility() == View.VISIBLE) { if(view instanceof ViewGroup) { ViewGroup group = (ViewGroup) view; int count = group.getChildCount(); for(int i=0; i<count; i++) { View child = group.getChildAt(i); hookViews(view); } } else { if(view.isClickable()) { HookView hookView = new HookView(view); Object listenerInfo = hookView.mHookMethod.invoke(view); Object originalLinstner = hookView.mHookField.get(listenerInfo); hookView.mHookField.set(listenerInfo, new ProxyManager.ProxyListener((View.OnClickListener)originalLinstner)); } } } } catch (Exception e) {}

}

AccessibilityDelegate机制
AccessibilityService辅助功能设计的初衷是为残障人士提供对于APP操作的辅助功能,如语音或者触摸的提示,后来也被广泛应用于抢红包等后台服务中。
AccessibilityDelegate同样也是辅助功能,其辅助主体主要是APP上的具体控件View,可检测控件点击,选中,滑动,文本变化等,当该view的相关属性出现变化时,将回调AccessibilityDelegate中的sendAccessibilityEvent,具体事件类型通过AccessibilityEvent来区分。

因此,借助AccessibilityDelegate,一旦控件触发点击行为时,可用该辅助功能实现日志上报逻辑,代码如下:

创建自定义的AccessibilityDelegate,作用于每个View对象上:

public class ClickDelegate extends View.AccessibilityDelegate {

@Override public void sendAccessibilityEvent(View host, int eventType) { super.sendAccessibilityEvent(host, eventType); if(AccessibilityEvent.TYPE_VIEW_CLICKED == eventType) { sendLog(); } } public void sendLog(){} public ClickDelegate(final View rootView) { rootView.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() { @Override public void onGlobalLayout() { setDelegate(rootView); } }); } public void setDelegate(View view) { if(view.getVisibility() == View.VISIBLE) { if(view instanceof ViewGroup) { ViewGroup group = (ViewGroup) view; int count = group.getChildCount(); for(int i=0; i<count; i++) { View child = group.getChildAt(i); setDelegate(view); } } else { if(view.isClickable()) { view.setAccessibilityDelegate(this); } } } } public ClickDelegate(){}

}

其中,在ClickDelegate的构造函数中为根结点rootView添加布局监听器OnGlobalLayoutListener,实现每当界面的视图树发生变化时,通过递归遍历,对新增的控件添加自定义的AccessibilityDelegate,从而实现全局监听。

dispatchTouchEvent机制
dispatchTouchEvent方法为点击事件响应链上的具体事件分发函数,通过继承FrameLayout,即可覆写该函数,实现对于所有点击事件的监听。当然,自定义的ProxyLayout必须植入于app的控件树的根节点,从而在进行事件分发的时候,能够优先处理响应事件。

public class ProxyLayout extends FrameLayout{

public ProxyLayout(Context context, AttributeSet attrs) { super(context); } @Override public boolean dispatchTouchEvent(MotionEvent ev) { if (ev.getAction() == MotionEvent.ACTION_UP) { View rootView = this.getRootView(); ClickDelegate clickDelegate = new ClickDelegate(); clickDelegate.setDelegate(rootView); } return super.dispatchTouchEvent(ev); }

}

同时实现自动监听和自动曝光
上述4种监听机制均提供了全局监听思想,但Listener机制与Hook机制明显带有局限性,即其对应的事件监听仅仅只是点击行为,但在事件采集中,除了点击行为之外,另一核心功能点则是曝光。
曝光是当指对前控件可见状态持续性的监听记录,其对于某个具体的控件并未有任何交互动作。
因此为了实现同时监听点击和曝光,我们可在上述监听原理的基础上进行扩展,从而利用一套体系,完成对点击和开关事件的同时监听,技术实现原理如下图所示:
Android端埋点自动采集技术原理剖析

其中,ActivityLifecycleCallbacks监听当前界面的出入状态,一旦页面进入时,便开启对当前页面控件的监听。同时,页面控件监听则通过对当前视图的根结点rootview添加onGlobalLayoutListener,监听视图树的变化。一旦视图树发生变化,启用控件遍历,寻找目标埋点控件,为其添加AccessibilityDelegate用于点击监听,构建自定义ExposureView对象,用于曝光状态记录。与此同时,为了解决视图树不发生变化但仍需对控件监听情况,在onGlobalLayoutListener的实现中,添加Runnable对象EventBinding,实现控件遍历操作,并设置每隔500ms的定时机制,从而完成对于点击和曝光的全局监控。
其核心代码如下:

public void run() {

if (!mAlive) { //如果进程关闭,移除runnable mHandler.removeCallbacks(this); return; } // 判断当前页是否关闭,是否需要清除runnable final View viewRoot = mViewRoot.get(); if (null == viewRoot || mDying) { cleanUp(); return; } //寻找目标控件 pathfinder.findTargetView(viewRoot, viewVisitorMap, dataTrackSet); //移除已有的runnable mHandler.removeCallbacks(this); //新建定时器 mHandler.postDelayed(this, 500);

}

实践

下面将具体介绍如何借助AccessibilityDelegate实现自动点击和自动曝光。部分代码参考业界实现方案。

如何实现点击
针对点击行为监听采用AccessibilityDelegate机制,但在View.java中,每个View上的AccessibilityDelegate只有一个,并不是一个数组,这意味着如果有业务方也使用该功能,在可视化埋点中如果直接调用View.setAccessibilityDelegate,将产生逻辑处理覆盖,其源码如下:

public void setAccessibilityDelegate(AccessibilityDelegate delegate) {

//mAccessibilityDelegate为单一变量 mAccessibilityDelegate = delegate;

}

因此,为了解决该冲突问题,可将原有的AccessibilityDelegate对象进行保存,并在触发自己的辅助对象的回调方法时,显示调用已有对象的sendAccessibilityEvent方法。具体而言,我们需要新建TrackingAccessibilityDelegate对象并继承View.AccessibilityDelegate,实现sendAccessibilityEvent方法,其中mRealDeleage对象为该控件原有对象,在函数的最后,手动调用该delegate对象的sendAccessibilityEvent方法。代码示意:

public void sendAccessibilityEvent(View host, int eventType) {

try { if (eventType == mEventType) { fireEvent(host); } } catch (Exception e) { log.error(e); } finally { if (null != mRealDelegate) { mRealDelegate.sendAccessibilityEvent(host, eventType); } }

}

由于每次控件遍历操作均需要对目标控件进行设置AccessibilityDelegate操作,因此,为了避免相同类型的delegate重复设置问题(重复设置并未影响使用),可在开始进行delegate设置时,对View上已有的delegate对象进行类型判断,如果是我们的delegate,则无需重复判断,代码示例如下:

public boolean willFireEvent(final String eventName) {

if (getEventName() == eventName) { return true; } else if (mRealDelegate instanceof MyAccessibilityDelegate) { return ((MyAccessibilityDelegate)mRealDelegate).willFireEvent(eventName); } else { return false; }

}

willFireEvent函数返回当前AccessibilityDelegate是否为可视化埋点的delegate对象,此处,eventName为自建的MyAccessibilityDelegate对象的一个基本属性,直接对应于ut日志中的arg1,可用于标识一个MyAccessibilityDelegate对象。

整体点击实现逻辑流程图如下:
Android端埋点自动采集技术原理剖析

如何实现曝光
曝光的实现逻辑核心在于如何持续性监听某个控件的可见状态。依托于GlobalLayoutListener以及GlobalLayoutListener中添加的每隔500ms控件扫描定时器runnable,可实现对于控件的持续状态的持续性监控。
对于每个可见的控件而言,需要记录其曝光的整个生命周期,包括从开始曝光->持续曝光->结束曝光。其中,整个生命周期需要建立在基础的曝光规则之上,即达到可见面积≥50%,可见时长≥500ms才为合规的曝光。
因此,一旦控件从不可见状态转变为可见状态时,我们将记录其当前可见状态的面积和可见时间点,当当前控件树发生变化或者触发控件扫描定时器运作时,需要对已有的曝光控件的状态进行更新,具体更新规则可见如下源码:

private void checkViewState(ExposureView exposureView, boolean status) {

boolean needExposureProcess = isSatisfySize(exposureView.view); if (needExposureProcess) { switch (exposureView.lastState) { case ExposureView.INITIAL: //初始态需要处理,view的状态初始化 exposureView.lastState = ExposureView.SEEN; exposureView.beginTime = System.currentTimeMillis(); break; case ExposureView.SEEN: //当前控件依然可见,仅更新可见态控件当前的结束时间 exposureView.endTime = System.currentTimeMillis(); break; case ExposureView.UNSEEN: //不可见态,符合曝光条件,则初始化处理 exposureView.lastState = ExposureView.SEEN; exposureView.beginTime = System.currentTimeMillis(); break; default: break; } } else { switch (exposureView.lastState) { case ExposureView.INITIAL: break; case ExposureView.SEEN: //可见态,不符合界面曝光规则计算,则证明由可见态变为不可见,需要提交曝光数据 exposureView.lastState = ExposureView.UNSEEN; exposureView.endTime = System.currentTimeMillis(); break; case ExposureView.UNSEEN: //不可见态 break; default: break; } } if (exposureView.isSatisfyTimeRequired()) { if(status) { //页面切换,提交满足曝光条件的控件 addToCommit(exposureView); currentViews.remove(exposureView.tag); return; } if(exposureView.lastState == ExposureView.SEEN) { return; } else if(exposureView.lastState == ExposureView.UNSEEN) { addToCommit(exposureView); currentViews.remove(exposureView.tag); } } else if (exposureView.lastState == ExposureView.UNSEEN) { currentViews.remove(exposureView.tag); }

}

一旦曝光控件达到曝光时长及曝光面积限制,并且当前控件已从可见态转为不可见状态时,将提交缓存的曝光控件信息,调用采集SDK接口上报曝光日志。其核心逻辑实现流程图如下:

总结

自动采集和自动曝光技术实现手段较多,但每种实现类型差异也较大,需要根据具体使用场景和自有的业务特性做合适,正确的选择。本文仅介绍Android端的技术原理,IOS端的实现有异曲同工之处,敬请期待下期分享。

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
待兔 待兔
3个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Stella981 Stella981
3年前
Opencv中Mat矩阵相乘——点乘、dot、mul运算详解
Opencv中Mat矩阵相乘——点乘、dot、mul运算详解2016年09月02日00:00:36 \牧野(https://www.oschina.net/action/GoToLink?urlhttps%3A%2F%2Fme.csdn.net%2Fdcrmg) 阅读数:59593
Stella981 Stella981
3年前
KVM调整cpu和内存
一.修改kvm虚拟机的配置1、virsheditcentos7找到“memory”和“vcpu”标签,将<namecentos7</name<uuid2220a6d1a36a4fbb8523e078b3dfe795</uuid
Stella981 Stella981
3年前
Android So动态加载 优雅实现与原理分析
背景:漫品Android客户端集成适配转换功能(基于目标识别(So库35M)和人脸识别库(5M)),导致apk体积50M左右,为优化客户端体验,决定实现So文件动态加载.!(https://oscimg.oschina.net/oscnet/00d1ff90e4b34869664fef59e3ec3fdd20b.png)点击上方“蓝字”关注我
Wesley13 Wesley13
3年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
9个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这