Android全量埋點實踐

co_Re發表於2020-12-14

一、專案背景

產品又提了一堆埋點需求,有簡單的,如點選的。有複雜的,如統計頁面A到頁面B再到頁面C的。有的還是介面相關的,本來以為後臺能埋點的(我都調後臺介面了,不能後臺埋嗎?),因為無法解釋的原因,最終還是app端埋。
部分點位如下:

雖然之前通過 Tracker框架 可以實現長路徑的埋點,但因為點位太多了,業務層寫的也煩,所以就想著必須得做全量埋點。解放雙手,讓產品不用再找我們了。

全量埋點大致框架圖:
全量埋點大致框架圖

1、Tracker負責全域性事件監聽,但不做任何邏輯,由上層實現
2、Apt 註解處理器,用於在編譯時生成 extFieldMapping.csv
3、FullPointer依賴於Tracker,事件發生後,收集資料。還包括解析mapping檔案,引數->ext 對映,資料上傳等。
4、APP 業務層負責 對一些關鍵點位繫結業務引數

根據產品需求分析,我們拆分為三種大的事件。

eventType 事件型別:
1、頁面事件
2、互動事件
3、介面事件

二、頁面page事件

頁面的定義是由產品決定的,Android 端一般為Activity,Fragemnt。Activity 預設為一個頁面,Fragement預設不是頁面,需要業務層對 fragemnt 加上pageId,框架才會認為是頁面,之後此 fragment 裡的view 的點選事件,獲取到的 pageId 就是 fragment的pageId,而不是Activity 的了。而Activity也可以加pageId,有pageId會取pageId,否則會取activity.className
page 會產生四種小事件,對應 頁面 的 生命週期 lifecycle。(0 enter、1 show 、2 hide、 3 exit) 其中 enter與exit 一個頁面存活時只會觸發一次,而show與hide 會多次觸發。
下面分別對activity、fragment 來實現四種事件說明

2.1 Activity 為 page

enter 進入
對應 activity.onCreate  
show 對使用者可見
對應 activity.onResume  
hide 對使用者不可見
對應 activity.onPause  
exit 頁面退出
對應 activity.onDestory 

使用 Tracker 全域性監聽

//頁面 activity show/hide
Track.fromAnyActivity().activityOnCreated().subscribe(activity -> {
    onPageLifeCycle(activity, LIFECYCLE_ENTER);
}).activityOnResumed().subscribe(activity -> {
    onActivityShowHide(activity, true);
}).activityOnPaused().subscribe(activity -> {
    onActivityShowHide(activity, false);
}).activityOnDestroyed().subscribe(activity -> {
    onPageLifeCycle(activity, LIFECYCLE_EXIT);
    TrackTools.cleanPageParams(activity);
});

2.2 Fragment 為 page

enter 進入
對應 fragment.onCreateView 
exit 頁面退出
對應 fragment.onDestory 

因為 fragment 使用場景有多種,有ViewPage 巢狀 fragment 的,有通過 FragmentTransaction.show、hide 切換的,也可以直接add的。所以針對 fragment 的show、hide 需要多重處理。
使用 Tracker 全域性監聽

//fragment enter、exit
Track<?> fragmentTrack = Track.from(FragmentActivity.class);
fragmentTrack.fragmentOnCreateView(Fragment.class).subscribe(fragment -> {
    onPageLifeCycle(fragment, LIFECYCLE_ENTER);
}).fragmentOnDestroyed(Fragment.class).subscribe(fragment -> {
    onPageLifeCycle(fragment, LIFECYCLE_EXIT);
});
//fragment show、hide
fragmentTrack.fragmentOnHiddenChanged(Fragment.class).subscribe(fragment ->
	onFragmentShowHide(fragment, !fragment.isHidden()));
fragmentTrack.fragmentSetUserVisibleHint(Fragment.class).subscribe(fragment -> {
    if (fragment.isResumed()) {
	onFragmentShowHide(fragment, fragment.getUserVisibleHint());
    }
});
fragmentTrack.fragmentOnResumed(Fragment.class).subscribe(fragment -> {
    if (!fragment.isHidden() && fragment.getUserVisibleHint()) {
	onFragmentShowHide(fragment, true);
    }
});
fragmentTrack.fragmentOnPaused(Fragment.class).subscribe(fragment -> {
    if (!fragment.isHidden() && fragment.getUserVisibleHint()) {
	onFragmentShowHide(fragment, false);
    }
});

2.3 page 的唯一標識 pageId

如果直接拿activity、fragment的className當作頁面的id。會導致android與ios端不一致。所以我們為了方便後續BI分析資料,需要業務層對頁面setPageId,具體android端使用 @PageId("20062") 註解加在activity或fragment 上,當事件發生時,框架拿到 activity 或 fragment 物件,獲取註解中的值。而有些fragment類是通用的。
[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片儲存下來直接上傳(img-vG2csd3R-1607917412088)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/a701192b5a3d45cabf1b1af129c478b8~tplv-k3u1fbpfcp-watermark.image)]
例:一個viewPage巢狀幾個fragment,通過初始化引數展示不同的列表,產品認為它們是不同的頁面。而程式碼全在一個fragment類中,如果我們通過 class 的註解 ,無法區分不同的頁面。所以還需要有一個 setPageId(frg/act,"pageId")方法,而且有的頁面還能攜帶引數。在上面例子中,需要攜帶頁面所處的位置,即後臺可以動態控制每個tab頁面所處的位置。


@PageId(value = "21002", remarks = "我的出價-待成交")
public class MyPricePageBase {
    public int position;//頁面位置標識
    public int type;//4010介面中的type欄位
}

業務層 在 onCreate 或者其它地方 呼叫 setPageId()

    @Override
    public void onCreate(@Nullable Bundle savedInstanceState) {
    	……
        MyPricePageBase page=new MyPricePageBase();
        page.position=position;
        page.type=type;
    	TrackTools.setPageId(this, page);
    }

將pageId 註解加在bean上。並且與頁面繫結起來,框架中當頁面事件發生時,取出pageId與引數欄位。

三、view互動事件

3.1 actionType 互動事件型別

互動事件主要是view的互動事件,分為四種小事件

1、點選事件
2、重新整理事件
3、載入事件
4、切換事件

點選就是view的點選事件。重新整理是在列表中,使用者通過手勢下拉重新整理。載入就是上滑載入更多。雖然重新整理與載入更多都會觸發介面事件,但某些情況為了能區分是 自動進入頁面重新整理 還是 使用者手動重新整理,所以重新整理、載入事件也就有必要了。切換是指在 banner、大圖瀏覽,可以左右滑動切換的事件。因為場景較少,這裡開放插入事件方法,由業務層主動插入事件。

使用Tracker 全域性監聽

//全view click 事件
Track.fromAnyActivity().anyViewClick().subscribe(view -> {
    String actionId = TrackTools.getViewActionId(view);
    String pageId = TrackTools.getPageId(view);
    Object params = TrackTools.getViewParams(view);
    Map map = extManager.getExtParamsMap(actionId, params, Block.VIEWTAG);

    //插入事件...
    LogUtil.i(TAG, "anyViewClick actionId: " + actionId + " pageId=" + pageId + " map=" + map);
});
//重新整理
Track.fromAnyActivity().onRefresh(TrackManager.ANY_VIEW).subscribe(view -> {
    LogUtil.d(TAG, "onRefresh " + view);
    //插入事件
    onViewAction(view, REFRESH_ACTION);
});
//載入
Track.fromAnyActivity().onLoadMore(TrackManager.ANY_VIEW).subscribe(view -> {
    LogUtil.d(TAG, "onLoadMore " + view);
    //插入事件
    onViewAction(view, LOAD_MORE_ACTION);

});

3.2 互動事件的唯一標識 actionId

互動事件中,主要物件是view。我們需要區分是哪個view的點選。所以就需要生成view的唯一Id。而要區分某個頁面的某個view。雖然有view.getId()方法,但是考慮到專案中,不一定所有可以點選的view都有id,而且並不能保證id在一個頁面中是唯一的。可以通過viewTree 的方式。根據view的className與在父view的位置 Button[1],一直到根view的層級關係。大致長這樣
CoordinatorLayout[1]/AutoConstraintLayout[1]/SmartRefreshLayout[0]/RecyclerView[0]/AutoLinearLayout[1]
根View 可以查詢到 android.R.id.content 即可。而即便是viewTree 的方式,也無法保證在不同app版本,如果改變了佈局,view的位置發生變化。那麼viewTree就會發生變化。雖然網上有通過 “相對於同類view在父view的位置,而不是直接取在父view的位置”。但依然解決不了頁面大變的情況。
所以我們對一些重要的按鈕。(目前是重要頁面,常用功能的view),對它setViewTag。就是一個別名id,並且android與ios一致,這樣會方便BI後續分析資料。當然需要 業務層 在程式碼裡手動呼叫set,業務層需要確保在佈局變化後,tag依然一樣。用於區分此頁面的此view。框架在拿到view時,getViewTag就行。而沒有setViewTag的view,我們通過viewTree的方式。雖然會有viewTree變化的問題,只不過我們選擇忽略了。。。。

TrackTools.setTag(view,"home-search");
/**
* setTag 無引數
*/
public static View setTag(View v, String viewTag) {
	v.setTag(0xff000001, viewTag);
	return v;
}

3.3 互動事件攜帶引數

在某些情況下,view是動態的。通過介面資料配置按鈕的背景、文字,點選跳轉的路由等。例:
位置固定,但功能動態的按鈕
那麼當這種按鈕點選的時候,需要攜帶業務引數,如id,路由地址之類的。使用如下:

//定義tag類
@ViewTag(value = "home-search",remarks = "首頁-搜尋框")
public class TestTag {
    public String id;
    public String url;
}

//tag與view繫結
TestTag tag=new TestTag();
tag.id=id;
tag.url=url;
TrackTools.setTag(button,tag);

TrackTools.setTag

/**
 * setTag 並且攜帶引數
 */
public static View setTag(View v, Object tagObj) {
	ViewTag tag = tagObj.getClass().getAnnotation(ViewTag.class);
	if (tag == null || TextUtils.isEmpty(tag.value())) {
		throw new RuntimeException(tagObj.getClass() + " don't has ViewTag Annotation");
	}
	v.setTag(0xff000001, tagObj);
	return v;
}

就是通過view.setTag與物件繫結起來,在點選事件中通過view再取出物件的tag與引數。

三、介面事件

介面事件是指網路介面請求事件。當請求成功後,把 介面號(介面的唯一標識)+ 業務引數作為一個事件,有些特殊介面事件還需要 返回引數。由於每家公司都會對網路請求再封裝一層。監聽實現方式肯定無法統一,這裡只給出一個使用參考示例。

Tracker 全域性監聽 介面事件 示例
//全介面事件
Track.fromApplication().onAnyApiSuccess().subscribe(response -> {
    int code = response.getServiceCode();
    Object request=response.getRequest();
    Object result=response.getResult();

});

四、欄位與ext的對映表

從上面三個大事件可以看出,都有可能攜帶引數。

page 事件必有 lifecycle生命週期欄位,和特殊頁面的業務id
互動事件中的點選事件 可能會攜帶業務id,位置,等
介面事件 必有 請求的業務引數,部分介面會需要 返回的業務引數

而引數的名字是各種各樣的,同一個事件的不同app版本可能會增刪引數。在與資料分析部門討論後,為了方便BI後續分析。決定將每一個事件引數固定對映到 ext1…n 上,所以需要有一張 欄位與ext的對映表 即:extFieldMapping.csv

如上圖:介面事件2005中,獲取到引數 age ,實際賦值 給 ext1,id->ext2,如果這個介面有新增欄位,向後新增,而 最大 ext的列數 為欄位引數最多的那個事件。在 home-msg 點選事件中,獲取到arg3引數,賦值給 ext1。並且不同版本的 mapping 是隻增加欄位的,不能刪除欄位(因為老資料會有值)。那麼android與ios統一按上面規則使用這張表。而BI分析時,通過事件id,可以反查到ext1對應的含義。如:2005 事件中。ext1欄位的含義就是 age。

4.1 具體實現

表的格式是 .csv,這是一種簡易的表,使用文字方式讀寫,換行符\n分隔為行,英文逗號分隔為列。第一行 為列名,後面的行 為資料。使用 excel 開啟就是如上表格的樣式,也方便程式中解析處理。文字開啟如下:

actionId,remarks,ext1,ext2,ext3,ext4,ext5,ext6,ext7,ext8
//serviceCode,,,,,,,,,
2001,,actiStatus,actiStatusText,activityTag,applicantId,applicantLink,applicantName,applicantPhone,applicantReason
2002,,age,age1,agreeType,mobile,password,testMobile,username,
2003,,age,id,,,,,,
2005,,age,id,,,,,,
//pageId,,,,,,,,,
1000001,,lifecycle,,,,,,,
1000002,,lifecycle,position,,,,,,
1000003,,lifecycle,index,,,,,,
1000004,,lifecycle,,,,,,,
//viewTag,,,,,,,,,
home-msg,首頁-訊息,arg3,arg4,,,,,,
home-search,首頁-搜尋框,arg1,arg2,arg3,,,,,

表的生成 是由Android端生成的。編譯時使用 Apt(註解處理器) 生成extFieldMapping.csv檔案至app/src/main/assets(一般我們在使用Apt時,都是建立 .java檔案,其實 Apt 過程也可以做其它事,例如拿到assets目錄,讀寫檔案,然後 打包過程會把最新檔案打進app 中)
apt 獲取 assets 目錄,主要是拿到本地專案路徑

/**
* 獲取 assets 目錄
*/
private File getAssetsDirFile() {
    try {
        mFiler = processingEnv.getFiler();
        FileObject temp = mFiler.getResource(StandardLocation.CLASS_OUTPUT, "", "META-INF");
        
        String url = temp.toUri().toString();
        //url=file:/Users/wzh/ttpc/gitlab/Android-Compiler/app/build/intermediates/javac/debug/compileDebugJavaWithJavac/classes/META-INF/test.csv
        try {//mac linux
            url = url.substring(0, url.indexOf("/build/")).replace("file:", "");
        } catch (Exception e) {//windows
            url = url.substring(0, url.indexOf("/build/")).replace("file:/", "");
        }
        url = url.substring(0, url.lastIndexOf("/")) + "/app/src/main/assets";//固定在app 目錄

        File dir = new File(url);
        if (!dir.exists() || !dir.canWrite()) {
            throw new RuntimeException(dir.getAbsolutePath() + " don't exist or don't write,please check dir");
        }
        return dir;
    } catch (IOException e) {
        e.printStackTrace();
    }
    return null;
}

4.1.1 介面引數

介面引數是通過處理 介面類 註解,獲取所有介面方法,然後解析獲取 介面號與請求的bean,解析bean獲取欄位。
介面類使用 retrofit 定義方式,再封裝了業務邏輯,大致定義如下:

@TtpcApi
public interface BossApi {

    @CODE(2005)
    @POST("/testapp/")
    Observable<BaseResult<LoginBean,Object>> loginBoss(@Body LoginRequest request);

    @CODE(2002)
    @POST("/testapp/")
    HttpTask<LoginResponse,Integer> login7(@Body LoginRequest request);

}

4.1.2 page引數

而page引數的獲取則是通過解析pageId註解,pageId可以直接加在activity或fragment上,也可以加在普通bean上,如果是bean,則獲取bean的所有欄位,當做事件的引數。這也是解釋 setPageid 為什麼一定要pageId註解,也是為了表的生成。
pageId註解定義:

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface PageId {
    /**
     * PageId 值,必填
     */
    String value();
    /**
     * 備註
     */
    String remarks() default "";
}

4.1.3 view引數

view的引數也是一樣,解析ViewTag 註解的bean,獲取欄位當做引數。通過setTag(View v, String viewTag)的方式,表示沒有引數,也就不需要出現在表裡。

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ViewTag {
    /**
     * tag 值,必填
     */
    String value();
    /**
     * 備註
     */
    String remarks() default "";
}

由於表的生成是由編譯時自動生成的,不需要人為的去修改表。這樣可以減少一些人為導致的錯誤發生。由於Android與ios用的是同一張表,執行時只需讀取,解析表即可,所以ios無需生成表,我們的策略是在由安卓打正式包時,生成後的表,上傳到區域網伺服器,ios編譯時下載放到專案裡即可。

五、總結

可以看出,這套全量埋點框架的部分工作還是需要由業務層完成,通過手動setViewTag、setPageId的方式,好像並沒有減輕業務層的工作量。但好在不多,主要還是因為需要這張ext與欄位的對映表,所以業務層使用方式也得相容表的生成功能。

5.1 上線後的後續工作

在資料傳給後臺,產品提出點位需求,BI分析階段,我們需要提供某個點位或者好幾個點位,如某個按鈕的viewTree是啥,這個點應該怎麼串。

5.2 業界方案

在view與業務引數繫結這塊,網上有方案是通過後臺下發配置,指定點選時的業務引數,app解析類似 resp.result.id 語法,這樣後續工作就是填這種語法了。而view的唯一標識,沒有通過setViewTag的view。使用viewTree 的方式,無法保證在佈局改變後生成的一致。這一點也沒什麼好辦法,歡迎大家討論。

總之,沒有完美的全量方案,也沒有一勞永逸的方案。各有優缺,需要根據 app端,分析端,產品端的 需求來一起制定。謹供參考。

5.3 關於開源

全量埋點的部分,後續有需要的話會刪除部分業務邏輯開源出來。
其中 Tracker 是開源的,可以實現全域性監聽功能,通用的可以監聽全域性 viewClick。和activity/fragment 的生命週期,頁面的跳轉。而下拉重新整理,載入,各家用的都不一樣,就沒必要開源了。當然如果你需要 很長的埋點 需求,tracker 也可以實現,具體看專案介紹。

本文同步傳送於:https://juejin.cn/post/6904900874183819277

相關文章