目錄
3.1.2儘量保證ViewGroup下新插入視圖時View的ViewTree路徑下的同一層級下index不變(如何保證?)
3.3ListView,RecyclerView,ViewPager等可複用View優化
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