天天看點

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