改不完的 Bug,写不完的矫情。公众号 杨正友 现在专注移动基础开发 ,涵盖音视频和 APM,信息安全等各个知识领域;只做全网最 Geek 的公众号,欢迎您的关注!本文相关代码Github地址autotrace_app_click,有帮助的话Star一波吧。
一. SDK业务背景
你在开发中是否遇到过这样的场景,当点击同一个dialog
或者button
的时候,如果暴击多次,该dialog
或button
的被点击行为会被瞬间执行多次,这时候有小伙伴可能要想了,我可以做一个view
时间戳呀,让它延迟生效。
可是你们有木有想过一个问题,这么做?是不是会绑定view
?如果工程里面有10000个点击事件需要处理,那岂不是要改10000行代码,有没有一种优雅的方式能实现如下需求呢?
- 1.1 可以设置全局控制点击行为的开关
- 1.2 可以动态控制点击时间戳
- 1.3 对
butterknife
,kotlin
,lambada
表达式,dialog
,xml
点击事件高度支持 - 1.4 对不需要防重复点击的事件,可以通过注解进行额外逻辑处理
- 1.5 可以借助沪江
aop
思想,也可以自己编写transform plugin
实现 - 1.6 将打点事件带到线上,线上分析用户行为
小朋友,你是否有很多问号?
本文介绍的内容会详细解释以上问题,并在最后给解答。稳住,别慌~
二. SDK全局点击控制原理
2.0.1 全局监控activity
生命周期
- 在app的
onCreate()
方法初始化SDK
,然后在ActivityLifeCycleCallBack
注册,这样就能监控activity
所有生命周期方法了。
2.0.2 获取activity
对应的根视图rootview
- 我们在该回调事件里面的对应生命周期方法如:
onActivityPaused(Activity activity)
,通过activity.findViewById(android.R.id.content)
方法可以拿到整块区域所对应的rootview
,其实就是framelayout
2.0.3 树形递归根视图rootview
,织入埋点代码
- 2.0.3.1 自定义
OnClickListener
派生类WrapperOnClickListener
,实现OnClickListener
接口 - 2.0.3.2 逐层递归遍历
rootview
,判断当前view
是否设置了mOnClickListener
对象
- 2.0.3.2.1 如果已经设置了
mOnClickListener
并且mOnClickListener
不是我们自定义的WrapperOnClickListener
类型,则通过WrapperOnClickListener
代理当前view
设置的mOnClickListener
- 2.0.3.3 在
WrapperOnClickListener
的onClick
方法里会先调用view
的原有mOnClickListener
处理逻辑
- 调用埋点代码,实现"插入"埋点,达到自动埋点效果。
三. SDK埋点信息介绍
public interface ITrackClickEvent { /** * 控件的类型 */ String CANONICAL_NAME = "$element_type"; /** * 控件的id,即android:id属性指定的值 */ String VIEW_ID = "$element_id"; /** * 控件显示的文本信息 */ String ELEMENT_CONTENT = "$element_content"; /** * 当前控件所属的 Activity 页面 */ String ACTIVITY_NAME = "$activity"; /** * 点击空间行为事件名称 */ String APP_CLICK = "$AppClick"; String APP_VERSION = "$app_version"; String APP_NAME = "$app_name"; String SCREEN_HEIGHT = "$screen_height"; String SCREEN_WIDTH = "$screen_width"; String ELEMENT_POSITION = "$element_position"; String ELEMENT_ID = "$element_id"; String ELEMENT_ELEMENT = "$element_element"; String MODEL = "$model"; String LIB_VERSION = "$lib_version"; String OS = "$os"; String OS_VERSION = "$os_version"; String MANUFACTURER = "$manufacturer"; String LIB = "$lib"; }
四. SDK风险点介绍
4.1 DataBinding
绑定的函数的点击事件是无法采集的
DataBinding
框架给Button
设置OnClickListener
对象动作稍微晚于onActivityResumed
回调方法,DataBinding
还没来得及给我们Button
对象设置mOnClickListener
对象,我们再遍历RootView
的时,当前View
不满足hasObClickListener
的判断条件,因此没有去代理mOnClickListener
对象,给出的解决方案是给DataBinding
框架一点延迟事件处理设置mOnClickListener
对象操作
new Handler().postDelayed(new Runnable() { @Override public void run() { delegateViewsOnClickListener(activity, activity.findViewById(android.R.content)); } },300);
4.2 mOnClickListener
是无法采集MenuItem
的点击事件
我们通过android.R.content
获取的RootView
不包含Activity
标题栏,其实也就是不包含MenuItem
所对应的父容器的,自然当我们遍历RootView
是无法获取MenuItem
控件的,因此也无法代理mOnClickListener
对象,间接导致MenuItem
点击事件无法触发。我们可以借助DecorView
来处理,那么什么是DecorView
呢
官方解释是这样的:
The DecorView is the view that actually holds the window’s background drawable. Calling getWindow().setBackgroundDrawable() from your Activity changes the background of the window by changing the DecorView‘s background drawable. As mentioned before, this setup is very specific to the current implementation of Android and can change in a future version or even on another device.
我的理解是: DecorView
是整个Window
最顶层View
,他有且只有一个字孩子LinearLayout
,其实就包括了通知栏,标题栏,内容显示栏,LinearLayout
里面包括两个FrameLayout
,第一个是标题栏显示的Title,第二个FrameLayout
才是我们所说的android.R.content
。所以针对我么上面提到的无法采集MenuItem
点击事件的问题,我们只需要将activity.findViewById(android.R.content)
换成 activity.getWindow().getDecorView()
就可以采集到MenuItem
的点击事件了
new Handler().postDelayed(new Runnable() { @Override public void run() { delegateViewsOnClickListener(activity, activity.getWindow().getDecorView()); } },300); }
但是上报的时候,我们发现却没有Button文本信息,这是为什么呢?element_content
这个字段没问题呀,后面我查了一下,MenuView.ItemView
的派生类是ActionMenuItemView
,而非View
,所以这里需要强转一下
else if (view instanceof ActionMenuItemView) { text = ((ActionMenuItemView) view).getText().toString(); }
4.3 无法采集Button
点击,在OnClickListener
里动态创建一个Button
,然后通过addView
添加到页面上,这个动态添加的Button
无法采其点击事件
ViewGroup rootView = findViewById(R.id.rootView); AppCompatButton button = new AppCompatButton(this); button.setText("动态创建的button"); button.setOnClickListener(v -> { }); rootView.addView(button);
当点击这个动态创建的 rootView
,当前方案是无法采集点击事件的。为啥会这样呢?这是因为我们在Activity
的onResume
之前去遍历整个rootView
并代理其mOnClickListener
对象的,如果在onResume
动态创建View
当时肯定无法被遍历到的,后来我没没有再次遍历,所以它的mOnClickListener
对就没有被代理过,因此点击控件是没有效果的,那么该怎么办呢?用OnGlobalLayoutListener
来解决,什么是OnGlobalLayoutListener
?官方解释是这样的:
Interface definition for a callback to be invoked when the global layout state or the visibility of views within the view tree changes.
我的理解是: 当一个View
视图树发生改变的时候,我没给当前的View
设置了OnGlobalLayoutListener
监听器,就能回调 onGlobalLayout()
方法,基于这个原理我们可以给我们的Activity
的RootView
注册一个这样的监听器,这样就能实时观察视图树布局的变化呢,我们重新遍历一次RootView
,然后找到那些没有被代理过的OnGlobalLayoutListener
对象View
进行代理即可解决上面的问题
private ViewTreeObserver.OnGlobalLayoutListener onGlobalLayoutListener; @Override public void onActivityCreated(@NonNull final Activity activity, @Nullable Bundle savedInstanceState) { onGlobalLayoutListener = new ViewTreeObserver.OnGlobalLayoutListener() { @Override public void onGlobalLayout() { delegateViewsOnClickListener(activity, SensorsDataHelper.getRootViewFromActivity(activity, false)); } }; } @Override public void onActivityResumed(@NonNull final Activity activity) { SensorsDataHelper .getRootViewFromActivity(activity, true) .getViewTreeObserver() .addOnGlobalLayoutListener(onGlobalLayoutListener); } @Override public void onActivityStopped(@NonNull Activity activity) { /* * 移除顶层Activity的监听 */ SensorsDataHelper.getRootViewFromActivity(activity, false) .getViewTreeObserver() .removeOnGlobalLayoutListener(onGlobalLayoutListener); } // -----------------省------------------------- }