天天看点

Android实现无痕埋点方案(View操作的事件统计和Activity与Fragment页面的数据收集)1.埋点是什么?2.为什么需要无痕埋点?3.自动无痕实现方案?4.页面事件采集5.其他

目录

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.其他

1.埋点是什么?

埋点是应用中特定的流程收集一些信息,用来跟踪应用使用的情况,后续用来进一步优化产品或者提供运营的数据支撑,包括访问数(Visits),访客数(Visitor),停留时长(Time On Site),页面浏览数(Page Views)和跳出率(Bounce Rate)。这样的信息收集可以大致分为两种:页面统计(track this virtual page view),统计操作行为(track this button by an event)。

2.为什么需要无痕埋点?

就目前而言,客户端埋点最常见的方式还是以代码埋点为主。代码埋点的方式虽然灵活多变,可以准确的获取各种数据,但是也存在不少痛点:

a.业务需求总是多变的,漏埋点或者错埋点总是无法完全避免的,这时就只能等待下个版本迭代的时候补全了。

b.增加开发与测试的工作量,不规范的埋点代码可能造成App Crash。

c.埋点代码侵入业务代码中,埋点数量的不断增加,也给后续的版本迭代与代码维护增加难度。

产品、运营在版本发布前并不能完全预知自己需要收集的数据,等到版本发布之后才发现一些重要的埋点并没有采集,只能等待下个版本补充,可能为时已晚了。这时候我们就要引入无痕埋点的方案了,接下来我将详细讲解一下Android端在无痕埋点方面的具体实现方案。

3.自动无痕实现方案?

实现无痕埋点要解决几个问题:

a.如何准备识别每个View?

b.如何监听Activity和Fragment生命周期(页面事件采集)?

3.1如何准备识别每个View?

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]

3.1.1如何定位是那个视图?

通过View所属Activity和Fragment页面,层级(deep)View相对于rootView位于第几层级,View相对于同一层级下排在第几个(index);

直接用Android Studio--Tools--Layout Inspector就可以提取你App当前页面的View Tree了,如下图:

Android实现无痕埋点方案(View操作的事件统计和Activity与Fragment页面的数据收集)1.埋点是什么?2.为什么需要无痕埋点?3.自动无痕实现方案?4.页面事件采集5.其他

通过界面视图结构可以看到Activity页面View完整ViewTree路径 ;

Android实现无痕埋点方案(View操作的事件统计和Activity与Fragment页面的数据收集)1.埋点是什么?2.为什么需要无痕埋点?3.自动无痕实现方案?4.页面事件采集5.其他

例如:我们要定位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开始计算;

3.1.2保证View的ID不受Android版本影响

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;

3.1.2尽量保证ViewGroup下新插入视图时View的ViewTree路径下的同一层级下index不变(如何保证?)

Android实现无痕埋点方案(View操作的事件统计和Activity与Fragment页面的数据收集)1.埋点是什么?2.为什么需要无痕埋点?3.自动无痕实现方案?4.页面事件采集5.其他

例如:上图我们可能在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路径所属分类信息;

3.2代码实现View获取ViewTree路径(唯一ID)

3.2.1获取Activity名字-所属页面

/**
     * 获取页面名称
     * @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;
    }
           

3.2.2获取View所属Fragment页面

对于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<group.getChildCount(); i++){
                setTagToChildView(group.getChildAt(i), elementId);
            }
        }
    }
}
           

3.2.3ViewTree完整路径拼装

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);
    }
           

3.2.4ViewTree布局文件路径

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]

3.3ListView,RecyclerView,ViewPager等可复用View优化

对于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()  
           

4.页面事件采集

对于无痕埋点,我们要采集的不止是View事件埋点,我们还要采集用户的浏览数据。针对页面采集需要将Activity和Fragment区分开来分别采集;

4.1Activity页面采集

在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生命周期监听。

4.2Fragment页面采集

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等方法中插入自己的埋点代码。

5.其他

目前的无痕埋点方案,解决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