前言:
- 更多对于数智化转型、数据中台内容请退出 阿里云数据中台交换群—数智俱乐部(文末扫描二维码或点此退出)
- 阿里云数据中台官网 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 机制显著带有局限性,即其对应的事件监听仅仅只是点击行为,但在事件采集中,除了点击行为之外,另一外围性能点则是曝光。
曝光是当指对前控件可见状态持续性的监听记录,其对于某个具体的控件并未有任何交互动作。
因而为了实现同时监听点击和曝光,咱们可在上述监听原理的根底上进行扩大,从而利用一套体系,实现对点击和开关事件的同时监听,技术实现原理如下图所示:
其中,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 对象。
整体点击实现逻辑流程图如下:
如何实现曝光
曝光的实现逻辑外围在于如何持续性监听某个控件的可见状态。依靠于 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 端的实现有殊途同归之处,敬请期待下期分享。
数据中台是企业数智化的新基建,阿里巴巴认为数据中台是集方法论、工具、组织于一体的,“快”、“准”、“全”、“统”、“通”的智能大数据体系。目前正通过阿里云数据中台解决方案对外输入,包含批发、金融、互联网、政务等畛域,其中外围产品有:
- Dataphin,一站式、智能化的数据构建及治理平台;
- Quick BI,随时随地 智能决策;
- Quick Audience,全方位洞察、全域营销、智能增长;
- Quick A+,跨多端全域利用体验剖析及洞察的一站式数据化经营平台;
官方站点:
数据中台官网 https://dp.alibaba.com