Android實現無痕埋點方案(View操作的事件統計和Activity與Fragment頁面的數據收集)

目錄

 

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了,如下圖:

通過界面視圖結構可以看到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開始計算;

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不變(如何保證?)

例如:上圖我們可能在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

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章