目录
1.埋点是什么?
2.为什么需要无痕埋点?
3.自动无痕实现方案?
3.1如何准备识别每个View?
3.1.1如何定位是那个视图?
3.1.2保证View的ID不受Android版本影响
3.1.2尽量保证ViewGroup下新插入视图时View的ViewTree路径下的同一层级下index不变(如何保证?)
3.2代码实现View获取ViewTree路径(唯一ID)
3.2.1获取Activity名字-所属页面
3.2.2获取View所属Fragment页面
3.2.3ViewTree完整路径拼装
3.2.4ViewTree布局文件路径
3.3ListView,RecyclerView,ViewPager等可复用View优化
4.页面事件采集
4.1Activity页面采集
4.2Fragment页面采集
5.其他
埋点是应用中特定的流程收集一些信息,用来跟踪应用使用的情况,后续用来进一步优化产品或者提供运营的数据支撑,包括访问数(Visits),访客数(Visitor),停留时长(Time On Site),页面浏览数(Page Views)和跳出率(Bounce Rate)。这样的信息收集可以大致分为两种:页面统计(track this virtual page view),统计操作行为(track this button by an event)。
就目前而言,客户端埋点最常见的方式还是以代码埋点为主。代码埋点的方式虽然灵活多变,可以准确的获取各种数据,但是也存在不少痛点:
a.业务需求总是多变的,漏埋点或者错埋点总是无法完全避免的,这时就只能等待下个版本迭代的时候补全了。
b.增加开发与测试的工作量,不规范的埋点代码可能造成App Crash。
c.埋点代码侵入业务代码中,埋点数量的不断增加,也给后续的版本迭代与代码维护增加难度。
产品、运营在版本发布前并不能完全预知自己需要收集的数据,等到版本发布之后才发现一些重要的埋点并没有采集,只能等待下个版本补充,可能为时已晚了。这时候我们就要引入无痕埋点的方案了,接下来我将详细讲解一下Android端在无痕埋点方面的具体实现方案。
实现无痕埋点要解决几个问题:
a.如何准备识别每个View?
b.如何监听Activity和Fragment生命周期(页面事件采集)?
View的ID要保证唯一性,稳定性;
a.唯一性
唯一性保证每个View拥有唯一的ID,能够快速找到对应View;
实际在layout布局文件呢中View可以通过view.getId()获取唯一值,在R.java会为res的资源建立唯一ID,aapt打包资源时会生成resources.arsc描述文件,描述id和res下资源的对应关系;由于aapt生成资源的ID规则在不同的SDK工具版本下可能不一样,没法保证不会发生变化;在代码中new新的View时可能不会为view特意指定ID,view.getId()的结果都是NO_ID;
b.稳定性
稳定性保证ID不能随意变动,具有一定通用性;
可以采用Page+ViewTree的方式,Page分Activity和Fragment两种页面形式:
ActivityID规则:ActivityClassName:ViewTree
MainActivity:LinearLayout[0]/FrameLayout[0]/FitWindowsLinearLayout[0]/ContentFrameLayout[0]/LinearLayout[0]/LinearLayout[0]/AppCompatTextView[2]
FragmentID规则:ActivityClassName[FragmentClassName]:ViewTree
MainActivity[TwoFragment]:LinearLayout[0]/FrameLayout[0]/FitWindowsLinearLayout[0]/ContentFrameLayout[0]/LinearLayout[0]/FrameLayout[0]/LinearLayout[1]/AppCompatTextView[0]
通过View所属Activity和Fragment页面,层级(deep)View相对于rootView位于第几层级,View相对于同一层级下排在第几个(index);
直接用Android Studio--Tools--Layout Inspector就可以提取你App当前页面的View Tree了,如下图:
通过界面视图结构可以看到Activity页面View完整ViewTree路径 ;
例如:我们要定位TextView2的ViewTree路径:
TextView2父视图为RelativeLayout2,RelativeLayout2父视图为Root;
Root是跟视图 ,同一层级只有一个,则为Root;
RelativeLayout2为Root子视图,deep层级为1,同一层级下位置为1,则为Root/RelativeLayout[1];
TextView2为RelativeLayout2子视图,deep层级为2,同一层级下的位置为1,Root/RelativeLayout[1]/TextView[1];
TextView1的ViewTree路径为Root/RelativeLayout[1]/TextView[0];
Root,RelativeLayout,TextView指的是View的控件的类名;
'/'表示ViewTree的层级;
Root:指的是跟路径,通常指的是setContentView(layoutId)跟视图;
deep和index从0开始计算;
MainActivity:LinearLayout[0]/FrameLayout[0]/FitWindowsLinearLayout[0]/ContentFrameLayout[0]/LinearLayout[0]/LinearLayout[0]/AppCompatTextView[2]
View的ID结构构成,ActivityClassName(MainActivity):窗口视图(状态栏+内容视图-容器LinearLayout[0]/FrameLayout[0]/FitWindowsLinearLayout[0]/ContentFrameLayout[0])/通过setContentView(layoutId)自定义要显示内容视图ViewTree;
通常ActivityClassName和通过setContentView(layoutId)自定义要显示内容视图是不会受Android版本影响;Activity要显示的窗口视图受Android版本不同视图层级和结构可能发生变化;
AppCompatActivity
@Override
public void setContentView(@LayoutRes int layoutResID) {
getDelegate().setContentView(layoutResID);
}
AppCompatDelegate
private static AppCompatDelegate create(Context context, Window window,
AppCompatCallback callback) {
final int sdk = Build.VERSION.SDK_INT;
if (BuildCompat.isAtLeastN()) {
return new AppCompatDelegateImplN(context, window, callback);
} else if (sdk >= 23) {
return new AppCompatDelegateImplV23(context, window, callback);
} else if (sdk >= 14) {
return new AppCompatDelegateImplV14(context, window, callback);
} else if (sdk >= 11) {
return new AppCompatDelegateImplV11(context, window, callback);
} else {
return new AppCompatDelegateImplV9(context, window, callback);
}
}
不同Android版本AppCompatDelegate实现类
@Override
public void setContentView(int resId) {
ensureSubDecor();
ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
contentParent.removeAllViews();
LayoutInflater.from(mContext).inflate(resId, contentParent);
mOriginalWindowCallback.onContentChanged();
}
通过以上代码我们会发现不同Android版本Activity会使用不同Activity代理实现setContentView(layoutId)方法实现内容视图的显示,最终我们添加setContentView()要显示的视图放在什么形式的父视图上是受到Android版本影响的,无法保证ViewTree的唯一性;
ViewGroup contentParent = (ViewGroup) mSubDecor.findViewById(android.R.id.content);
Android所有版本的通过setContentView(layoutId)自定义要显示内容视图都会添加到ID为android.R.id.content的父视图上,可以判断View的id为android.R.id.content视图和它父视图不作为ViewTree的一部分;
精简以后的ViewTree:MainActivity:LinearLayout[0]/LinearLayout[0]/AppCompatTextView[2]
ActivityClassName(MainActivity):通过setContentView(layoutId)自定义要显示内容视图ViewTree;
例如:上图我们可能在Root跟视图下插入一个View视图,可以是和其他Root视图已经存在的视图类型相同(RelativeLayout)也可能不同(FrameLayout);
这种情况下怎么保证index尽量保持不变呢;
是否不可以考虑Root下索引位置使用同一类型的视图所在的位置呢;
LinearLayout1的deep层级为1,index为0,ViewTree路径为Root/LinearLayout[0];
LinearLayout2的deep层级为1,index为1,ViewTree路径为Root/LinearLayout[1];
FrameLayout的deep层级为1,index为0,ViewTree路径为Root/FrameLayout[0];
RelativeLayout的deep层级为1,index为0,ViewTree路径为Root/RelativeLayout[0];
这样可以保证同一层级下index尽量保证不变;
若插入的是同一类型View,实际开发中统计埋点信息路径和APP版本挂钩,下一版本开发时需要开发时重新统计变动ViewTree路径,重新定义ViewTree路径所属分类信息;
/**
* 获取页面名称
* @param view
* @return
*/
public static Activity getActivity(View view){
Context context = view.getContext();
while (context instanceof ContextWrapper){
if (context instanceof Activity){
return ((Activity)context);
}
context = ((ContextWrapper) context).getBaseContext();
}
return null;
}
对于Fragment下显示的View,需要在代码中手动绑定View的Tag属性和Fragment名字,方便获取View视图所属页面的Fragment;
设置Fragment下所有的View属性Tag为Frament页面的名称;
/**
* Fragment基类,重写onViewCreated()方法
*/
public class BaseFragment extends Fragment {
@Override
public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
//设置Fragment下View所属的页面Fragment,绑定View的Tag属性和页面Fragment页面名称
String fragmentId = this.getClass().getSimpleName();
view.setTag(ViewPathUtil.FRAGMENT_NAME_TAG, fragmentId);
//设置Fragment下所有的View属性Tag为Fragment页面的名称
setTagToChildView(view, fragmentId);
}
private void setTagToChildView(View fragmentView, String elementId){
fragmentView.setTag(ViewPathUtil.FRAGMENT_NAME_TAG, elementId);
if(fragmentView instanceof ViewGroup){
ViewGroup group = (ViewGroup)fragmentView;
for(int i=0; i
ActivityID规则:ActivityClassName:ViewTree
FragmentID规则:ActivityClassName[FragmentClassName]:ViewTree
//设置Fragment下View的Tag对应的key
public static final int FRAGMENT_NAME_TAG = 0xff000001;
/**
* 获取view的页面唯一值
* @return
*/
public static String getViewPath(Activity activity,View view){
//获取View所属Fragment
String pageName = (String)view.getTag(FRAGMENT_NAME_TAG);
//Activity下View
if(TextUtils.isEmpty(pageName)){
pageName = activity.getClass().getSimpleName();
}else{
Activity-Fragment下的View
pageName = activity.getClass().getSimpleName()+"["+pageName+"]";
}
//View所属布局文件ViewTree路径
String vId = getViewId(view);
return pageName+":"+ vId;//MD5Util.md5(vId);
}
a.getChildIndex(parentView,sonView):方法保证获取索引时获取的同一层级下同一类型View(例如:TextView)索引顺序,而不是同一层级下所有View索引顺序;
if (elName.equals(viewName)){
//表示同类型的view
if (el == view){//当前查询路径的视图View
return index;
}else {
index++;(同一类型index+1,index起始为0)
}
}
b.getViewId(View currentView)拼装View在布局文件的ViewTree路径
检测到父视图的ID是android.R.id.content则不在继续拼装,保证不受Android版本的影响,只获取我们定义布局文件View的路径;
父视图的类型(例如:LinearLayout),放在子视图的前面;
/**
* 获取view唯一id,根据xml文件内容计算
* @param currentView
* @return
*/
private static String getViewId(View currentView){
StringBuilder sb = new StringBuilder();
//当前需要计算位置的view
View view = currentView;
ViewParent viewParent = view.getParent();
while (viewParent!=null && viewParent instanceof ViewGroup){
ViewGroup tview = (ViewGroup) viewParent;
if(((View)view.getParent()).getId() == android.R.id.content){
sb.insert(0,view.getClass().getSimpleName());
break;
}else{
int index = getChildIndex(tview,view);
sb.insert(0,"/"+view.getClass().getSimpleName()+"["+(index==-1?"-":index)+"]");
}
viewParent = tview.getParent();
view = tview;
}
Log.e("Path", sb.toString());
return sb.toString();
}
/**
* 计算当前 view在父容器中相对于同类型view的位置
*/
private static int getChildIndex(ViewGroup viewGroup,View view){
if (viewGroup ==null || view == null){
return -1;
}
String viewName = view.getClass().getName();
int index = 0;
for (int i = 0;i < viewGroup.getChildCount();i++){
View el = viewGroup.getChildAt(i);
String elName = el.getClass().getName();
if (elName.equals(viewName)){
//表示同类型的view
if (el == view){
return index;
}else {
index++;
}
}
}
return -1;
}
输出结果完整路径结果:
MainActivity:LinearLayout/LinearLayout[0]/AppCompatTextView[0]
MainActivity[OneFragment]:LinearLayout/FrameLayout[0]/LinearLayout[0]/AppCompatTextView[0]
对于ListView,RecyclerView,ViewPager之类对的可复用View,我们以ListView为例,一个屏幕完整只能显示5个itemView,那么ListView实际上只包含5个child,而如果此时我们有50个item数据要显示,那么5个itemView与50个item数据是无法一一对应的,对于埋点来说,我们肯定 是希望区分每个itemView,那么有什么办法呢?
我们来分析一下这些可复用的View是否有用来区分自己itemView位置的属性嘛?答案肯定是显而易见的,这些可复用的View都可以通过获取itemView的position属性来区分每个itemView的位置。所以我们针对可复用的View的index可以做一下优化:
index:该itemView在其parent所处的position。
具体各个常用的可复用View获取position的方式:
ListView:ListView.getPositionForView(itemView)
RecyclerView:RecyclerView.getChildAdapterPosition(itemView)
ViewPager:ViewPager.getCurrentItem()
对于无痕埋点,我们要采集的不止是View事件埋点,我们还要采集用户的浏览数据。针对页面采集需要将Activity和Fragment区分开来分别采集;
在Application应用程序类提供监听Activity生命周期监听方法registerActivityLifecycleCallbacks,我们可以通过生命周期回调方法完成相应Activity页面数据的信息采集;
public void initActivityLifeCycle(){
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
sendLog(activity, "onActivityCreated");
}
@Override
public void onActivityStarted(Activity activity) {
sendLog(activity, "onActivityStarted");
}
@Override
public void onActivityResumed(Activity activity) {
sendLog(activity, "onActivityResumed");
}
@Override
public void onActivityPaused(Activity activity) {
sendLog(activity, "onActivityPaused");
}
@Override
public void onActivityStopped(Activity activity) {
sendLog(activity, "onActivityStopped");
}
@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
sendLog(activity, "onActivitySaveInstanceState");
}
@Override
public void onActivityDestroyed(Activity activity) {
sendLog(activity, "onActivityDestroyed");
}
});
}
public void sendLog(Activity activity, String method){
Log.d(activity.getClass().getSimpleName(), method);
}
输出日志:
06-15 09:39:01.646 14972-14972/fan.fragmentdemo D/MainActivity: onActivityStarted
06-15 09:39:01.646 14972-14972/fan.fragmentdemo D/MainActivity: onActivityResumed
这种方式比较简单、而且稳定,但是这个注册方法支持Android4.0系统,所以针对4.0以下的系统我们得额外去Hook Instrumentation实例,去重写里面callActivityOnCreate、callActivityOnStart、callActivityOnResume等生命周期方法,所以针对4.0以下可以采用Hook方式实现Activity生命周期监听。
Activity提供两种Fragment:
android/support/v4/app/Fragment
android/app/Fragment
v4的Fragment比较容易,我们通过((FragmentActivity) activity).getSupportFragmentManager()方法可以拿到FragmentManager,然后在FragmentManager调用registerFragmentLifecycleCallbacks()来监听每个v4的Fragment的生命周期方法回调:
private void registerFragmentLifeCycle(Activity activity) {
if (!(activity instanceof FragmentActivity)) {
return;
}
FragmentManager fm = ((FragmentActivity) activity).getSupportFragmentManager();
if (fm == null) {
return;
}
fm.registerFragmentLifecycleCallbacks(new FragmentManager.FragmentLifecycleCallbacks() {
@Override
public void onFragmentPreAttached(@NonNull FragmentManager fm, @NonNull Fragment f, @NonNull Context context) {
super.onFragmentPreAttached(fm, f, context);
sendLog(f, "onFragmentPreAttached");
}
@Override
public void onFragmentAttached(@NonNull FragmentManager fm, @NonNull Fragment f, @NonNull Context context) {
super.onFragmentAttached(fm, f, context);
sendLog(f, "onFragmentAttached");
}
// @Override
// public void onFragmentPreCreated(@NonNull FragmentManager fm, @NonNull Fragment f, @Nullable Bundle savedInstanceState) {
// super.onFragmentPreCreated(fm, f, savedInstanceState);
// sendLog(f, "onFragmentPreCreated");
// }
@Override
public void onFragmentCreated(@NonNull FragmentManager fm, @NonNull Fragment f, @Nullable Bundle savedInstanceState) {
super.onFragmentCreated(fm, f, savedInstanceState);
sendLog(f, "onFragmentCreated");
}
@Override
public void onFragmentActivityCreated(@NonNull FragmentManager fm, @NonNull Fragment f, @Nullable Bundle savedInstanceState) {
super.onFragmentActivityCreated(fm, f, savedInstanceState);
sendLog(f, "onFragmentActivityCreated");
}
@Override
public void onFragmentViewCreated(@NonNull FragmentManager fm, @NonNull Fragment f, @NonNull View v, @Nullable Bundle savedInstanceState) {
super.onFragmentViewCreated(fm, f, v, savedInstanceState);
sendLog(f, "onFragmentViewCreated");
}
@Override
public void onFragmentStarted(@NonNull FragmentManager fm, @NonNull Fragment f) {
super.onFragmentStarted(fm, f);
sendLog(f, "onFragmentStarted");
}
@Override
public void onFragmentResumed(@NonNull FragmentManager fm, @NonNull Fragment f) {
super.onFragmentResumed(fm, f);
sendLog(f, "onFragmentResumed");
}
@Override
public void onFragmentPaused(@NonNull FragmentManager fm, @NonNull Fragment f) {
super.onFragmentPaused(fm, f);
sendLog(f, "onFragmentPaused");
}
@Override
public void onFragmentStopped(@NonNull FragmentManager fm, @NonNull Fragment f) {
super.onFragmentStopped(fm, f);
sendLog(f, "onFragmentStopped");
}
@Override
public void onFragmentSaveInstanceState(@NonNull FragmentManager fm, @NonNull Fragment f, @NonNull Bundle outState) {
super.onFragmentSaveInstanceState(fm, f, outState);
sendLog(f, "onFragmentSaveInstanceState");
}
@Override
public void onFragmentViewDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) {
super.onFragmentViewDestroyed(fm, f);
sendLog(f, "onFragmentViewDestroyed");
}
@Override
public void onFragmentDestroyed(@NonNull FragmentManager fm, @NonNull Fragment f) {
super.onFragmentDestroyed(fm, f);
sendLog(f, "onFragmentDestroyed");
}
@Override
public void onFragmentDetached(@NonNull FragmentManager fm, @NonNull Fragment f) {
super.onFragmentDetached(fm, f);
sendLog(f, "onFragmentDetached");
}
}, true);
}
public void sendLog(Fragment f, String method){
Log.d(f.getClass().getSimpleName(), method);
}
而对于android/app/Fragment方式比较麻烦了,并没有提供监听生命周期回调的监听方法,这里就只能用插桩的方法,自定义Plugin,利用Gradle编译期间用户ASM等库进行插入操作,扫描所有的android/app/Fragment方法,在onCreateView、onViewCreated、onResume等方法中插入自己的埋点代码。
目前的无痕埋点方案,解决View的事件监听,View的ID唯一性,View事件等数据采集;页面Activity和Fragment数据收集;
a.精准的业务数据采集还是比较困难,需要手动代码埋点更精确;
b.版本迭代导致布局文件结构变化时,直接影响View的ID的稳定性,新版本及时更新View的ID对应描述;
c.可以实现后台可视化配置,后台下发配置,精准打捞目标埋点,减少数据冗余,节省系统资源;
d.基本实现无需手动埋点,解决前期数据统计不完全,或者忘记手动埋点的问题;
参考:
http://tech.dianwoda.com/2019/04/02/dian-wo-da-androidwu-hen-mai-dian-shi-xian-xiang-jie/
https://juejin.im/post/5dae95c4f265da5bb7466357#heading-2