目錄
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