前言
鑒于日益強(qiáng)烈的精細(xì)化運(yùn)營(yíng)需求,網(wǎng)易樂(lè)得從去年開(kāi)始構(gòu)建大數(shù)據(jù)平臺(tái),<<無(wú)埋點(diǎn)數(shù)據(jù)收集SDK>>因此立項(xiàng),用于向大數(shù)據(jù)平臺(tái)提供全量,完整,準(zhǔn)確的客戶(hù)端數(shù)據(jù).
<<無(wú)埋點(diǎn)數(shù)據(jù)收集SDK>>Android端從著手,到經(jīng)歷重構(gòu),逐步完善到現(xiàn)在已經(jīng)有快一年的時(shí)間了.期間從開(kāi)源社區(qū)以及同行中得到了一些很有意義的技術(shù)參考,因此在這個(gè)SDK趨于完善的今天,我們也考慮將這一路在技術(shù)上的探索經(jīng)歷和收獲分享出來(lái).
- 4月16-18日,QCon北京2017全球軟件開(kāi)發(fā)大會(huì)上有同事代表Android/IOS兩端進(jìn)行統(tǒng)一的技術(shù)分享,歡迎大家前去交流
- 我們會(huì)逐漸整理一些技術(shù)文章到這個(gè)簡(jiǎn)書(shū)賬號(hào)“移動(dòng)端數(shù)據(jù)收集和分析”
之前關(guān)于Android端的<<無(wú)埋點(diǎn)數(shù)據(jù)收集SDK>>使用的技術(shù),寫(xiě)了一篇文章<<Android AOP之字節(jié)碼插樁>>,這個(gè)是Android端進(jìn)行一切收集的起點(diǎn),我們就是用這個(gè)方法輕松拿到各種"Hook"點(diǎn)的.
本篇文章則接著講一下關(guān)于收集SDK內(nèi)部收集邏輯的一些關(guān)鍵技術(shù).
目錄
一、概述
1.1 SDK數(shù)據(jù)收集能力現(xiàn)狀
1.2 關(guān)鍵技術(shù)點(diǎn)概述
二、View的唯一標(biāo)識(shí)(ID)
2.1 調(diào)研
2.2 利用ViewTree構(gòu)建ViewID
2.3 ViewPath的生成
2.4 ViewPath的優(yōu)化
三、頁(yè)面的劃分
3.1 合理劃分頁(yè)面的重要性
3.2 Android中的頁(yè)面
3.3 頁(yè)面名組成
四、無(wú)需埋點(diǎn)輕松收集定制的業(yè)務(wù)數(shù)據(jù)
4.1 配置示例
4.2 無(wú)埋點(diǎn)收集流程
4.3 數(shù)據(jù)路徑(DataPath)
五、結(jié)語(yǔ)
一、概述
本部分首先簡(jiǎn)要介紹一下我們的收集方案目前可以收集到哪些數(shù)據(jù),然后對(duì)于本文重點(diǎn)介紹的三個(gè)技術(shù)點(diǎn)進(jìn)行概述.
1.1 SDK數(shù)據(jù)收集能力現(xiàn)狀
目前我們的SDK進(jìn)行數(shù)據(jù)收集時(shí)基本有兩個(gè)能力:
a. 通用數(shù)據(jù)全量收集
通用數(shù)據(jù)指的是與業(yè)務(wù)無(wú)關(guān)的用戶(hù)行為數(shù)據(jù),無(wú)論是電商應(yīng)用還是社區(qū)應(yīng)用,接入SDK后通用數(shù)據(jù)的收集上都是無(wú)差的,這些通用數(shù)據(jù)大致有:
| 事件 | 描述 |
|---|---|
| 冷啟動(dòng)事件 | App第一次啟動(dòng)時(shí)的,版本號(hào)、設(shè)備ID、渠道、內(nèi)存使用情況,磁盤(pán)使用情況等信息 |
| 前后臺(tái)事件 | App進(jìn)入前臺(tái)或者后臺(tái) |
| 頁(yè)面事件 | 頁(yè)面(Activity或Fragment)顯示(Show)/隱藏(Hide) |
| 控件點(diǎn)擊事件 | 某個(gè)控件(包括頁(yè)面上控件和彈窗中控件)被用戶(hù)點(diǎn)擊 |
| 列表瀏覽事件[可選] | 某個(gè)列表的哪些條目被用戶(hù)瀏覽了 |
| 位置事件[可選] | 上報(bào)用戶(hù)地理位置信息 |
| 其它事件 | 省略描述 |
b. 業(yè)務(wù)相關(guān)數(shù)據(jù)需求通過(guò)下發(fā)配置進(jìn)行無(wú)埋點(diǎn)定制收集
除了上述通用數(shù)據(jù),與具體業(yè)務(wù)相關(guān)的數(shù)據(jù)收集。拿網(wǎng)易貴金屬的首頁(yè)舉個(gè)例子:

假使需要在用戶(hù)點(diǎn)擊上圖紅框區(qū)域時(shí),把“粵貴銀”這個(gè)交易品的ID(或者下方顯示的指數(shù)等,只要在內(nèi)存中存在的數(shù)據(jù)都可以)一起報(bào)上來(lái)。
對(duì)于此種需求,數(shù)據(jù)收集SDK做到了無(wú)需埋點(diǎn),不依賴(lài)開(kāi)發(fā)周期,通過(guò)線上下發(fā)一些配置信息,即可即時(shí)進(jìn)行數(shù)據(jù)收集。具體原理第四節(jié)敘述。
1.2關(guān)鍵技術(shù)點(diǎn)概述
a. View的唯一標(biāo)識(shí)(ID),(詳見(jiàn)本文第二節(jié))
當(dāng)我們收集控件數(shù)據(jù)時(shí)碰到的第一個(gè)問(wèn)題就是:如何把界面上的任何一個(gè)View與其他View區(qū)分開(kāi)來(lái).
比如:某個(gè)Button被點(diǎn)擊了
我們?cè)谏蠄?bào)數(shù)據(jù)的時(shí)候需要把這個(gè)Button和其他所有控件(比如另一個(gè)Button,另一個(gè)ImageView等)區(qū)分開(kāi)來(lái),這樣這條上報(bào)的數(shù)據(jù)才能表示"就是那個(gè)Button被點(diǎn)擊了一下".
這就需要為界面上的每一個(gè)控件生成一個(gè)唯一的ID. 此ID除了具有區(qū)分性,還需要用于一致性.一致性是同一個(gè)View無(wú)論界面布局如何動(dòng)態(tài)變化,或者說(shuō)多次進(jìn)入同一頁(yè)面,此ID需要保持不變.
b. 頁(yè)面的劃分,(詳見(jiàn)本文第三節(jié))
除了Activity有些Fragment也需要看作頁(yè)面,這就要求:
- 在Fragment show/hide時(shí)上報(bào)相關(guān)頁(yè)面事件.
- 頁(yè)面Fragment中發(fā)生的用戶(hù)交互事件也需要?dú)w于此Fragment頁(yè)面,即點(diǎn)擊某個(gè)View需要上報(bào)頁(yè)面Fragment的信息(從View中怎么獲取Fragment信息?)
c. 無(wú)需埋點(diǎn)輕松收集定制的業(yè)務(wù)數(shù)據(jù),(詳見(jiàn)本文第四節(jié))
如前面所述,默認(rèn)情況下數(shù)據(jù)收集SDK會(huì)收集全量的用戶(hù)交互數(shù)據(jù),對(duì)于定制的業(yè)務(wù)收集需求,數(shù)據(jù)收集SDK也做到了無(wú)需代碼埋點(diǎn),通過(guò)線上下發(fā)一些配置進(jìn)行即時(shí)收集.
二、View的唯一標(biāo)識(shí)(ID)
2.1 調(diào)研
用于區(qū)分界面上每個(gè)View的ID? Android系統(tǒng)是否提供給了我們這個(gè)ID?
確實(shí),Android系統(tǒng)提供了一個(gè)ID,view.getId()即可獲得一個(gè)int型的id用于區(qū)分View,但是這個(gè)ID因?yàn)橐韵聝蓚€(gè)原因卻并不能滿(mǎn)足我們的需要.
- 有相當(dāng)一部分view是NO_ID,比如在布局文件中未指定id,或者直接在代碼里面new出來(lái)view,view.getId()返回的全部都是NO_ID
- 這個(gè)ID是不穩(wěn)定的,由于這個(gè)ID其實(shí)就是每次編譯產(chǎn)生的R文件中的int常量,因此同一個(gè)按鈕,兩個(gè)版本編譯出來(lái)的ID很可能時(shí)不一樣的.
因此,我們只能自己動(dòng)手構(gòu)建我們的ID嘍,怎么構(gòu)建?答案是利用所屬Page+ViewTree構(gòu)建ViewID.
2.2 利用ViewTree構(gòu)建ViewID
在Android的概念里,每個(gè)Window(ActivityWindow/DialogWindow/PopupWindow等)上面都生長(zhǎng)著一棵ViewTree.而屏幕中看到的各種控件(ImageView/Button等)都是這棵ViewTree上的節(jié)點(diǎn).
有Android開(kāi)發(fā)環(huán)境的同學(xué)只需要打開(kāi)AndroidDeviceMonitor-dump view hierarchy 就可以看到ViewTree的模樣,如下圖:

因此,我們萌生出一個(gè)想法:
利用Page+ViewTree中的位置構(gòu)建ViewID.
View在ViewTree中的位置主要用兩點(diǎn)來(lái)確定:
- 縱向的深度
- 橫向的index
考慮這兩個(gè)因素后,我們定義一個(gè)ViewPath:
ViewPath:當(dāng)前view到ViewTree根節(jié)點(diǎn)的一條路徑,用于在ViewTree中唯一定位當(dāng)前view。路徑中的每個(gè)節(jié)點(diǎn)包含兩部分信息,即節(jié)點(diǎn)View類(lèi)型信息,以及節(jié)點(diǎn)View在兄弟中的index。
如下圖,是一個(gè)簡(jiǎn)單的ViewTree模型(簡(jiǎn)單到深度只有兩層,每層只有兩三個(gè)控件)

按照之前給的定義,上圖中控件1,2,3,4的ViewPath如下
控件1ViewPath: RootView/LinearLayout[0] index為1表示此節(jié)點(diǎn)是兄弟節(jié)點(diǎn)中第一個(gè)控件
控件4ViewPath: RootView/LinearLayout[0]/ChildView1[0]
控件2ViewPath: RootView/RelativeLayout[1]
控件3ViewPath: RootView/LinearLayout[2]
上述給出的ViewPath中,每個(gè)節(jié)點(diǎn)(除了首節(jié)點(diǎn))有兩部分內(nèi)容:
- LinearLayout,RelativeLayout,ChildView1等ViewType信息(節(jié)點(diǎn)View的類(lèi)型)
- "[]"內(nèi)的index信息,此index指示此節(jié)點(diǎn)是兄弟節(jié)點(diǎn)的第幾個(gè)
這是最初的ViewPath,用ViewPath定位view,有兩點(diǎn)特別重要:
- 一致性: 同一個(gè)view的ViewPath在ViewTree的動(dòng)態(tài)變化中應(yīng)保持不變
- 區(qū)分度: 不同view的ViewPath應(yīng)該不同
按照這個(gè)最初的ViewPath定義在實(shí)踐中還不能在一致性和區(qū)分度上滿(mǎn)足我們的需求,后面會(huì)對(duì)ViewPath進(jìn)行優(yōu)化。
2.3 ViewPath的生成
上面我們由構(gòu)建ViewID的需求引出了ViewPath的定義,那么當(dāng)交互事件(例如:按鈕點(diǎn)擊)發(fā)生時(shí),我們?nèi)绾紊纱丝丶腣iewPath?
如上一篇文章<<Android AOP之字節(jié)碼插樁>>所述,當(dāng)用戶(hù)點(diǎn)擊某個(gè)按鈕時(shí),我們插入OnClickListener.OnClick方法中的如下代碼將會(huì)被調(diào)用:
Monitor.onViewClick(view);
上面,入?yún)iew即為當(dāng)前被點(diǎn)擊的view,獲取此view的ViewPath偽代碼如下:
public static ViewPath getPath(View view) {
do {
//1. 構(gòu)造ViewPath中于view對(duì)應(yīng)的節(jié)點(diǎn):ViewType[index]
ViewType=view.getClass().getSimpleName();
index=view在兄弟節(jié)點(diǎn)中的index;
ViewPath節(jié)點(diǎn)=ViewType[index];
}while ((view=view.getParent())instanceof View);//2. 將view指向上一級(jí)的節(jié)點(diǎn)
}
構(gòu)造出來(lái)的ViewPath如下面例子所示:
DecorView/LinearLayout[0]/FrameLayout[0]/ActionBarOverlayLayout[0]/ContentFrameLayout[0]/FrameLayout[0]/LinearLayout[0]/ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]
2.4 ViewPath的優(yōu)化
a. 一致性?xún)?yōu)化1
情景:
在圖2-2 ViewTree模型圖中,如果像下面圖中所示,在控件2和3中動(dòng)態(tài)插入一個(gè)FrameLayout呢?

此時(shí)按照原始ViewPath的定義,我們來(lái)看看控件3的ViewPath發(fā)生了哪些變化?
ViewTree動(dòng)態(tài)變化前: RootView/LinearLayout[2]
ViewTree動(dòng)態(tài)變化后: RootView/LinearLayout[3]
優(yōu)化:
ViewPath節(jié)點(diǎn)中index的含義從“兄弟節(jié)點(diǎn)的第幾個(gè)”優(yōu)化為:“相同類(lèi)型兄弟節(jié)點(diǎn)的第幾個(gè)”
優(yōu)化后,發(fā)生圖2-3所示界面布局動(dòng)態(tài)變化時(shí),控件3的ViewPath變化為:
ViewTree動(dòng)態(tài)變化前: RootView/LinearLayout[1] index為1表示此節(jié)點(diǎn)是兄弟節(jié)點(diǎn)中第二個(gè)LinearLayout
ViewTree動(dòng)態(tài)變化后: RootView/LinearLayout[1]
可以看出,此處優(yōu)化使控件3的ViewPath在ViewTree動(dòng)態(tài)插入除了LinearLayout之外其它任何類(lèi)型時(shí)都保持前后一致。
b. 一致性?xún)?yōu)化2
情景:
在圖2-2 ViewTree模型圖中,如果像下面圖中所示,在控件2和3中動(dòng)態(tài)插入一個(gè)LinearLayout時(shí),控件3的ViewPath能否繼續(xù)保持前后一致?
按照上述情景,控件3ViewPath的變化如下:
ViewTree動(dòng)態(tài)變化前: RootView/LinearLayout[1] index為1表示此節(jié)點(diǎn)是兄弟節(jié)點(diǎn)中第二個(gè)LinearLayout
ViewTree動(dòng)態(tài)變化后: RootView/LinearLayout[2] 前面插入一個(gè)LinearLayout導(dǎo)致此節(jié)點(diǎn)變?yōu)樾值芄?jié)點(diǎn)中第三個(gè)LinearLayout了
問(wèn)題
上述情景指的其實(shí)是一個(gè)問(wèn)題:ViewTree中同類(lèi)型兄弟節(jié)點(diǎn)動(dòng)態(tài)變化(插入/移除/移位)影響ViewPath的一致性
- ViewPath節(jié)點(diǎn)中的index,在同類(lèi)型(ViewType相同,例如都是LinearLayout)兄弟節(jié)點(diǎn)動(dòng)態(tài)加入/刪除時(shí),當(dāng)前節(jié)點(diǎn)的index無(wú)法在變化前后保持一致。
- “一致性?xún)?yōu)化1”中的優(yōu)化可以抵御不同類(lèi)型兄弟節(jié)點(diǎn)的影響,卻對(duì)同類(lèi)型兄弟節(jié)點(diǎn)的影響無(wú)可奈何。
從ViewPath的定義上難以找到在同類(lèi)型兄弟節(jié)點(diǎn)動(dòng)態(tài)變化前后保持一致的方法,但我們可以分析發(fā)生此種界面動(dòng)態(tài)變化的情景:
- 使用Fragment的動(dòng)態(tài)布局
Android界面的動(dòng)態(tài)布局發(fā)生情景中,使用Fragment實(shí)現(xiàn)界面動(dòng)態(tài)變化的頻率和影響控件數(shù)量還是比較大的(相對(duì)于直接addView()) - ListView(等可復(fù)用View)中同類(lèi)型的itemViews。
此種情況雖然沒(méi)有發(fā)生在一個(gè)itemView前動(dòng)態(tài)插入一個(gè)itemView,但是由于itemView的復(fù)用,導(dǎo)致itemView展示的內(nèi)容和在父節(jié)點(diǎn)listView內(nèi)的index的對(duì)應(yīng)關(guān)系動(dòng)態(tài)變化,因此也歸于此類(lèi)。
2中所說(shuō)“ListView等可復(fù)用View”造成的問(wèn)題后面會(huì)有優(yōu)化,此處針對(duì)1中的情景討論。1中情景發(fā)生時(shí)如下圖:

上圖中FragmentA,FragmentB,FragmentC的頂層視圖控件全部是LinearLayout(同類(lèi)型),此時(shí)這三個(gè)Fragment加入的順序將造成ViewPath在此處各種不一致,從而導(dǎo)致ViewPath在動(dòng)態(tài)變化前后不能保持一致(如前面:ViewTree動(dòng)態(tài)變化前后控件3ViewPath的變化所示)。
優(yōu)化:
在ViewPath節(jié)點(diǎn)中,使用Fragment的名字替換ViewType
優(yōu)化后,發(fā)生圖2-4所示界面布局動(dòng)態(tài)變化時(shí),控件3的ViewPath變化為:
ViewTree動(dòng)態(tài)變化前: RootView/FragmentB[0] index為0表示此節(jié)點(diǎn)是兄弟節(jié)點(diǎn)中第一個(gè)FragmentB
ViewTree動(dòng)態(tài)變化后: RootView/FragmentB[0]
如上,此次優(yōu)化使得,在頂層視圖ViewType相同的Fragment動(dòng)態(tài)添加/刪除到ViewTree時(shí),ViewPath在變化前后保持一致。
c. 針對(duì)可復(fù)用View的優(yōu)化
情景
以最常使用的ListView為例,假設(shè)有一ListView滿(mǎn)屏只顯示3個(gè)條目,那么此ListView可能只有3個(gè)子控件(ItemView),而此ListView上滑之后可以顯示100項(xiàng)內(nèi)容。
這3個(gè)ItemView與100項(xiàng)內(nèi)容是一對(duì)多的對(duì)應(yīng)關(guān)系,而且映射并無(wú)可靠規(guī)律。
此時(shí),我們希望ViewPath可以區(qū)分這100項(xiàng)顯示的內(nèi)容條目,而非僅僅區(qū)分3個(gè)ItemView。
上面情景中的問(wèn)題可用下圖表達(dá):

如上圖中,內(nèi)容條目1和4都是用itemView1來(lái)呈現(xiàn)的,按照之前的ViewPath定義,圖2-5中各個(gè)內(nèi)容條目的ViewPath如下:
內(nèi)容條目1: ListView/ItemView[0] index為0表示此節(jié)點(diǎn)是兄弟節(jié)點(diǎn)中第一個(gè)ItemView
內(nèi)容條目4: ListView/ItemView[0]
內(nèi)容條目2: ListView/ItemView[1]
內(nèi)容條目3: ListView/ItemView[2]
可以看出內(nèi)容條目1和4的ViewPath區(qū)分不開(kāi)。此種問(wèn)題可以總結(jié)為:
顯示內(nèi)容與ViewTree中的控件不是一一對(duì)應(yīng)的情況造成基于ViewTree的ViewPath區(qū)分度不夠
- 可復(fù)用View,比如:ListView,RecyclerView,Spinner等,呈現(xiàn)出來(lái)子View的數(shù)目和實(shí)際子View的數(shù)目未必一致
- ViewPager設(shè)置緩存頁(yè)面數(shù)為1,第二頁(yè)顯示時(shí),第二個(gè)頁(yè)面頂級(jí)View其實(shí)是ViewPager的第一個(gè)ChildView。此種情況也會(huì)造成顯示內(nèi)容(第二頁(yè))與ViewTree中的控件(第一個(gè)ChildView)不對(duì)應(yīng)的情況。
因此我們對(duì)于ViewPath作如下優(yōu)化:
ViewPath節(jié)點(diǎn)的index取內(nèi)容的第幾項(xiàng),而非第幾個(gè)ItemView。
優(yōu)化:
優(yōu)化后圖2-5中各個(gè)內(nèi)容條目的ViewPath如下:
內(nèi)容條目1: ListView/ItemView[0] index為0表示此節(jié)點(diǎn)是ListView顯示的第一個(gè)內(nèi)容條目
內(nèi)容條目4: ListView/ItemView[3]
內(nèi)容條目2: ListView/ItemView[1]
內(nèi)容條目3: ListView/ItemView[2]
可見(jiàn),之前ViewPath無(wú)法區(qū)分的內(nèi)容條目1和4現(xiàn)在可以區(qū)分開(kāi)了。各種可復(fù)用View取內(nèi)容的第幾項(xiàng)的代碼方法如下:
ListView,Spinner等AdapterView------------ListView.getPositionForView(itemView)
RecyclerView------------------------------------RecyclerView.getChildAdapterPosition(itemView)
ViewPager----------------------------------------ViewPager.getCurrentItem()
d. ViewPath起點(diǎn)優(yōu)化
ViewPath從ContentView為起點(diǎn),而非DecorView
- DecorView : Window上的根視圖,ViewTree中的根,最頂層視圖
- ContentView: 客戶(hù)端程序員定義的所有視圖的父節(jié)點(diǎn),如Actvity中常見(jiàn)的setContentView(view)
一個(gè)實(shí)際中的ViewPath如下:
DecorView/LinearLayout[0]/FrameLayout[0]/ActionBarOverlayLayout[0]/ContentFrameLayout[0]/FrameLayout[0]/LinearLayout[0]/ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]
上面的“ContentFrameLayout[0]”這個(gè)節(jié)點(diǎn)代表的就是ContentView,程序員在xml或者代碼里面構(gòu)建的View都在ContentView中。
從DecorView到“ContentFrameLayout[0]”的這一段Path是Android系統(tǒng)Framework層決定的,理論上應(yīng)該是一致的,但是由于碎片化等原因可能ViewPath的這一段發(fā)生變化.在實(shí)踐中,我們也發(fā)現(xiàn)確實(shí)有一些Rom發(fā)生了此類(lèi)情況,但是比率很小.
為了屏蔽這種可能造成同一個(gè)View在不同設(shè)備上產(chǎn)生ViewPath不同的情況,ViewPath的起點(diǎn)定義在ContentView比較好.如上面的ViewPath可優(yōu)化為:
ContentView/FrameLayout[0]/LinearLayout[0]/ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]#mybutton
做法:
構(gòu)造每一個(gè)ViewPath節(jié)點(diǎn)時(shí)可以取view.getId(),看看id的packageId部分是不是系統(tǒng)的(系統(tǒng)資源id以16進(jìn)制的0x01,0x00開(kāi)頭),如果是,生成ViewPath時(shí)屏蔽這段即可.
三、頁(yè)面的劃分
3.1 合理劃分頁(yè)面的重要性
頁(yè)面在Android中對(duì)應(yīng)于Activity和部分Fragment(比如很多app首頁(yè)多tab的設(shè)計(jì),若每個(gè)tab是使用Fragment實(shí)現(xiàn)的,那么這種tab一般看作一個(gè)頁(yè)面).頁(yè)面的劃分很重要,因?yàn)閮牲c(diǎn):
- 對(duì)于頁(yè)面,需要獲取Show/Hide兩個(gè)時(shí)機(jī),在此時(shí)機(jī)上報(bào)頁(yè)面Show/Hide事件,非頁(yè)面則不需要
- 頁(yè)面的劃分關(guān)系著用戶(hù)交互事件的所屬,例如,按鈕點(diǎn)擊事件上報(bào)格式如下:
| 事件名稱(chēng) | 所屬頁(yè)面 | ViewPath | 其他屬性 |
|---|---|---|---|
| ButtonClicked | MainActivity | XXX | 省略 |
表格中的"所屬頁(yè)面"即表示此次按鈕點(diǎn)擊事件發(fā)生在MainActivity中.將交互事件歸屬于頁(yè)面這樣對(duì)后面無(wú)論是進(jìn)行路徑分析還是統(tǒng)計(jì)控件點(diǎn)擊量分布都有很大的好處.
3.2 Android中的頁(yè)面
Android中通常需要看作頁(yè)面的有Activity和Fragment(對(duì)于像全屏Dialog或者全屏的View暫不考慮).對(duì)于Activity,上節(jié)中提到的兩點(diǎn)都很容易辦到.
a. Activity頁(yè)面
- 從Application.ActivityLifecycleCallbacks的onActivityResumed/onActivityPaused這兩個(gè)回調(diào)方法就可以分別得到Activity頁(yè)面Show/Hide的時(shí)機(jī),并在此時(shí)機(jī)上報(bào)相應(yīng)頁(yè)面事件
- 交互歸屬的Activity頁(yè)面可以通過(guò)Context輕松獲得,例如上篇文章<<Android AOP之字節(jié)碼插樁>>提到,當(dāng)按鈕點(diǎn)擊時(shí),會(huì)觸發(fā)我們插樁的代碼:
Monitor.onViewClick(view)
入?yún)iew即為我們點(diǎn)擊的view,通過(guò)view.getContext()我們一般就可以得到此View所屬的Activity,偽代碼如下:
//從View中利用context獲取所屬Activity的名字
public static String getActivityName(View view) {
Context context = view.getContext();
if (context instanceof Activity) {
//context本身是Activity的實(shí)例
return context.getClass().getSimpleName().;
} else if (context instanceof ContextWrapper) {
//Activity有可能被系統(tǒng)"裝飾",看看context.base是不是Activity
Activity activity = getActivityFromContextWrapper((ContextWrapper) context);
if (activity != null) {
return activity.getClass().getSimpleName();
} else {
//如果從view.getContext()拿不到Activity的信息(比如view的context是Application),則返回當(dāng)前棧頂Activity的名字
return currentActivityName;
}
}
return "";
}
b. fragment頁(yè)面
相對(duì)于Activity,將某些Fragment看作頁(yè)面的邏輯就要稍微復(fù)雜一些了.這里面涉及下面幾個(gè)問(wèn)題:
- 哪些Fragment可以需要看作頁(yè)面?
這是需要人工決策的,機(jī)器做不了這個(gè)決定.
目前我們這個(gè)人工干預(yù)是交給用戶(hù)研究團(tuán)隊(duì),所有Fragment截圖等信息均展示在平臺(tái)上,由用研同事選擇需要看作頁(yè)面的那些,用研選擇的結(jié)果將自動(dòng)化配置到SDK中. - 如何得到Fragment頁(yè)面的Show/Hide頁(yè)面事件?
由于fragment使用場(chǎng)景比較多樣,單單依靠OnResume/OnPause兩個(gè)回調(diào)表示fragment Show/Hide是不準(zhǔn)確的,比如:
場(chǎng)景一:
首頁(yè)一個(gè)Activity承載多個(gè)Fragment Tab的情況,此時(shí)tab間切換并不會(huì)觸發(fā)Fragment的OnResume/OnPause.觸發(fā)的回調(diào)函數(shù)是onHiddenChanged(boolean hidden)
場(chǎng)景二:
一個(gè)ViewPager承載多個(gè)頁(yè)面的Fragment時(shí)
a.當(dāng)?shù)谝粋€(gè)Fragment1顯示時(shí),雖然第二個(gè)Fragment2此時(shí)尚未顯示,但是Fragment2的OnResume卻以及執(zhí)行,處于resumed的狀態(tài).
b.ViewPager頁(yè)面切換OnResume/OnPause/onHiddenChanged均未觸發(fā),觸發(fā)的回調(diào)是setUserVisibleHint
此時(shí)判斷Fragment Show/Hide應(yīng)該用setUserVisibleHint,而非OnResume/OnPause
如前一篇文章XXX,所述,我們通過(guò)插樁的方式Hook到了fragment的如下生命周期函數(shù)用于包裝成為Show/Hide事件:
onResume()
onPause()
onHiddenChanged(boolean hidden)
setUserVisibleHint(boolean isVisibleToUser)
使用這幾個(gè)回調(diào)包裝成適用于各種情景的FragmentShow/Hide事件的偽代碼如下:
//此回調(diào)發(fā)生,則證明是場(chǎng)景一中使用情景,
onHiddenChanged(boolean hidden) {
hidden == true ------FragmentShow
hidden == false------FragmentHide
}
//場(chǎng)景二中ViewPager頁(yè)面切換時(shí)觸發(fā)Fragment的此回調(diào),
setUserVisibleHint(boolean isVisibleToUser) {
if (fragment.isResumed()) {//只有resumed狀態(tài)的fragment適用此情景
isVisibleToUser == true ------FragmentShow
isVisibleToUser == false------FragmentHide
}
}
//上述使用情景之外的一般場(chǎng)景
OnResume/OnPause{
//fragment沒(méi)有被hide,并且UserVisibleHint為可見(jiàn)的情景
if (!fragment.isHidden() && fragment.getUserVisibleHint()) {
OnResume ------ FragmentShow
OnPause ------ FragmentHide
}
}
- 如何將Fragment內(nèi)部的交互歸屬到Fragment頁(yè)面,也就是說(shuō)如何在交互發(fā)生時(shí)從view實(shí)例拿到Fragment頁(yè)面的名字(像之前拿到Activity頁(yè)面名字一樣)?
view可以通過(guò)context拿到Activity的信息,但是卻沒(méi)有途徑拿到fragment的引用。那么,當(dāng)某個(gè)View交互發(fā)生,我們又需要獲取Fragment頁(yè)面名字的情況下,我們只能事先將Fragment頁(yè)面名寫(xiě)入此View的屬性中。
做法大致如下:
a. 按照前一篇文章xxx里面的方法,在Fragment.OnCreateView方法的結(jié)尾插樁,拿到return的view(即為此Fragment的頂層視圖)
b. 判斷此Fragment是否被指定為Fragment頁(yè)面,如果是,下一步
c.遍歷以Fragment的頂層視圖為根節(jié)點(diǎn)的ViewTree, 將Fragment名設(shè)置到此ViewTree的每一個(gè)view上。設(shè)置方法如下所示:
view.setTag(0xff000001, fragmentName);
注意:View類(lèi)有兩個(gè)名為setTag的方法:
public void setTag(final Object tag)
此方法,類(lèi)內(nèi)部用一Object對(duì)象存儲(chǔ)tag,protected Object mTag = null;。listAdapter中常用于設(shè)置holder。我們此處用的不是這個(gè),不會(huì)于此用法沖突
public void setTag(int key, final Object tag)
此方法,類(lèi)內(nèi)部有一稀疏數(shù)組存儲(chǔ)tag,private SparseArray<Object> mKeyedTags;
tag的key官方推薦資源id,因此我們可以選用類(lèi)似0xff000001之類(lèi)的app用不到的資源id進(jìn)行tag存儲(chǔ)以避免沖突。
d. 當(dāng)需要使用Fragment名時(shí),如下調(diào)用即可獲得:
view.getTag(0xff000001)
3.3 頁(yè)面名組成
前面講了將交互事件(比如點(diǎn)擊事件)歸屬到某一個(gè)頁(yè)面的方法是:
在交互事件中設(shè)置一個(gè)字段,值為頁(yè)面名稱(chēng)。
頁(yè)面可以是Activity或者Activity承載的Fragment,我們的頁(yè)面名稱(chēng)組成如下:
Activity類(lèi)名[Activity別名][Fragment類(lèi)名][Fragment別名]
說(shuō)明如下:
- “[]”內(nèi)的組成部分是可選的,可能有可能沒(méi)有。另外,各個(gè)組成部分之間有分隔符分割。
- 頁(yè)面名組成中,Activity的描述(類(lèi)名/別名)是第一層,F(xiàn)ragment的描述(類(lèi)名/別名)是第二層
-
別名的出現(xiàn)是為了解決單純依賴(lài)類(lèi)名無(wú)法精確區(qū)分頁(yè)面的某些情況,比如:
在某個(gè)電商應(yīng)用中,“商品詳情頁(yè)”(同一個(gè)Activity)用于展示各種商品(iphone,電視等),如果需要把“不同商品的商品詳情頁(yè)“區(qū)分成不同頁(yè)面來(lái)統(tǒng)計(jì)pv等指標(biāo)的話,需要設(shè)置別名,如:
商品詳情頁(yè)#iphone
商品詳情頁(yè)#電視
對(duì)于別名的設(shè)置,需要程序員在業(yè)務(wù)代碼里面(如Activity.OnCreate,Fragment.onCreate等)顯式設(shè)置.
四、無(wú)需埋點(diǎn)輕松收集定制的業(yè)務(wù)數(shù)據(jù)
4.1 配置示例
之前提到過(guò),數(shù)據(jù)收集SDK可以通過(guò)配置下發(fā)即時(shí)收集定制的數(shù)據(jù),那么在Android端這個(gè)是怎么做到的呢?
首先,看一下下發(fā)的配置樣例:
//第一部分:描述
PageName:MainActivity
ViewPath:DecorView/.../ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]
EventType:ViewClick
//第二部分:數(shù)據(jù)路徑(當(dāng)描述符合時(shí),按照此路徑取數(shù)據(jù))
DataPath:this.context.demoList[5]
上面例子翻譯成數(shù)據(jù)需求就是:
1. 當(dāng)頁(yè)面(MainActivity)
2. 中的控件(DecorView/.../ViewPager[0]/ButtonFragment[0]/AppCompatButton[0])
3. 發(fā)生點(diǎn)擊事件(ViewClick)時(shí)
4. 按照路徑(this.context.demoList[5])取出數(shù)據(jù)
5. 并附加到點(diǎn)擊事件上面一起上報(bào)
按照這個(gè)描述,我們還可以描述如下等等各種數(shù)據(jù)需求:
當(dāng)(某頁(yè)面)發(fā)生事件(Show)時(shí),按照路徑(xxx)取出數(shù)據(jù),并附加到頁(yè)面Show事件上面一起上報(bào)
總結(jié)下描述的組成部分,如下:
| 第一層 | 第二層 | 含義 |
|---|---|---|
| 描述部分 | 頁(yè)面 | 限定頁(yè)面 |
| ViewPath | 限定按鈕 | |
| EventType | 限定時(shí)機(jī)(點(diǎn)擊/前臺(tái)/PageShow) | |
| 數(shù)據(jù)路徑 | 一種DSL,指示目標(biāo)數(shù)據(jù)在內(nèi)存中的位置(可理解為“引用路徑”) |
4.2 無(wú)埋點(diǎn)收集流程
上節(jié)展示了用于無(wú)埋點(diǎn)定制業(yè)務(wù)數(shù)據(jù)收集的配置,那么SDK收到這樣的一份配置如何最終把想要的數(shù)據(jù)收集上來(lái)呢?
- 步驟一:產(chǎn)生原始事件。比如點(diǎn)擊時(shí)收集,當(dāng)點(diǎn)擊時(shí)會(huì)觸發(fā)我們插樁的代碼,并生成原始的點(diǎn)擊事件
Monitor.onViewClick(view)
- 步驟二:匹配配置
在onViewClick方法中匹配下發(fā)的配置信息,看看Page,ViewPath是否與當(dāng)前view匹配,EventType是否與當(dāng)前事件類(lèi)型匹配,若匹配則進(jìn)行下一步
注:ViewPath的匹配可以有精確匹配和模糊匹配,精確匹配時(shí)一個(gè)ViewPath精確匹配唯一一個(gè)控件.模糊匹配時(shí)一個(gè)ViewPath可匹配多個(gè)控件,例如可以用用一個(gè)ViewPath模糊匹配一個(gè)列表中的所有條目. - 步驟三:按照數(shù)據(jù)路徑(DataPath)逐級(jí)反射拿到目標(biāo)數(shù)據(jù),并將找到的數(shù)據(jù)附在原始的點(diǎn)擊事件上進(jìn)行上報(bào)。
4.3 數(shù)據(jù)路徑(DataPath)
上述步驟三進(jìn)行數(shù)據(jù)收集主要是按照DataPath的描述進(jìn)行(例如示例中提到的"this.context.demoList[5]"),DataPath是一種我們用于收集定制數(shù)據(jù)而定義的一種DSL.含義如下:
a. 含義
DataPath: 指向要收集的目標(biāo)數(shù)據(jù)的一條引用路徑,解析此路徑并逐級(jí)反射最終拿到目標(biāo)數(shù)據(jù).
DataPath寫(xiě)法中的一些關(guān)鍵字(符):
| 關(guān)鍵字(符) | 含義 |
|---|---|
| . | 表示對(duì)象所屬關(guān)系,如:a.b 表示實(shí)例a中的字段b |
| .() | 表示公有方法調(diào)用,如:a.b() 表示調(diào)用實(shí)例a中的方法b.注意:方法入?yún)⒖梢允荄ataPath指向的Object |
| [] | 數(shù)組/線性表的index. 注意:此index可以是常量數(shù)字,也可以是一個(gè)DataPath指向的數(shù)字 |
| this | DataPath字符串的起點(diǎn),表示起點(diǎn)為當(dāng)前實(shí)例(當(dāng)前View) |
| item | DataPath字符串的起點(diǎn),表示起點(diǎn)為當(dāng)前View父節(jié)點(diǎn)中AdapterView adapter中當(dāng)前條目. 常用于列表中的數(shù)據(jù)獲取 |
| parent | DataPath節(jié)點(diǎn)中的關(guān)鍵字,用于表示當(dāng)前view的parentView.效果同view.getParent(),使用此關(guān)鍵字可減少視圖引用中的反射 |
| childAt(x) | DataPath節(jié)點(diǎn)中的關(guān)鍵字,用于表示當(dāng)前view的第x個(gè)childView.效果同view.getChildAt(x),使用此關(guān)鍵字可減少視圖引用中的反射 |
b. 應(yīng)用示例
下面用兩個(gè)例子說(shuō)明如何從DataPath找到目標(biāo)數(shù)據(jù).

示例1:列表數(shù)據(jù)獲取
上圖中顯示是一個(gè)列表,紅框中是列表的第一個(gè)條目.那么,如果我們想要在列表中條目點(diǎn)擊時(shí),將列表展示的交易品ID(或者合作方ID)等不在界面上顯示而又存在于內(nèi)存中的數(shù)據(jù)跟隨點(diǎn)擊事件上報(bào).此處DataPath該怎么寫(xiě)?
item.productId
DataPath解釋?zhuān)?/em>
- 起點(diǎn)定為"item",則表示從此ListView(或者RecylerView)綁定的Adapter中當(dāng)前數(shù)據(jù)item為起點(diǎn)取數(shù)據(jù).
假設(shè)此ListView綁定的Adapter如下:
public class DemoAdapter extends BaseAdapter {
private ArrayList<DataItem> mDataItems;
......
}
則此處"item"代表的就是mDataItems[x] (x表示當(dāng)前被點(diǎn)擊條目的itemId)
2."productId"是model類(lèi)DataItem中表示"交易品ID"的字段名稱(chēng).
通過(guò)DataPath獲取數(shù)據(jù):
- 當(dāng)?shù)趚條目被點(diǎn)擊時(shí),如果發(fā)現(xiàn)有匹配的配置,對(duì)于起點(diǎn)為"item"的DataPath,先通過(guò)view.getParent找到上層ListView實(shí)例,然后通過(guò)listView.getAdapter()獲得綁定的Adapter實(shí)例,最后通過(guò)Adapter.getItem(ListView.getPositionForView(itemView))得到數(shù)據(jù)中第x個(gè)item,即mDataItems[x]
- 反射獲取mDataItems[x]中的productId字段,即可得到第x個(gè)條目的"交易品ID",將此ID跟隨第x條目的點(diǎn)擊事件進(jìn)行上報(bào)即可.
實(shí)例2:界面數(shù)據(jù)獲取
同樣時(shí)圖4-1所示,加入我們想在列表中條目點(diǎn)擊時(shí),將條目中展示的"最新價(jià)"跟隨點(diǎn)擊事件上報(bào).此處DataPath該怎么寫(xiě)?
紅框所示ViewTree子樹(shù)如下:

如上圖,選中部分是列表的ItemView(RelativeLayout),可見(jiàn)"最新價(jià)"是由index為2的TextView所展示,由此可得,列表中條目點(diǎn)擊獲取"最新價(jià)"數(shù)據(jù)的DataPath如下:
this.childAt(2).mText
DataPath解釋?zhuān)?/em>
- 起點(diǎn)為"this",表示當(dāng)前被點(diǎn)擊的view實(shí)例(圖4-2中被選中的RelativeLayout)
- "childAt(2)"表示RelativeLayout.getChildAt(2),得到圖4-2中index為2的TextView
- "mText" 表示取出步驟2中得到TextView實(shí)例的mText字段(TextView控件顯示的文字內(nèi)容存儲(chǔ)在mText字段內(nèi))
- 將取出的界面上顯示的"最新價(jià)"數(shù)據(jù)添加到原始點(diǎn)擊事件中,一起上報(bào).
c. DataPath注意事項(xiàng):
1.混淆.
由于DataPath本質(zhì)上描述的時(shí)內(nèi)存中的"引用路徑",并且按照DataPath取數(shù)據(jù)時(shí)用了反射的方法,因此DataPath應(yīng)該描述的是混淆之后的"引用路徑".
雖然DataPath可能受到混淆的影響,但是
* 用于存儲(chǔ)數(shù)據(jù)的model類(lèi)通常是不被混淆的.如我們之前的item關(guān)鍵字直接將起點(diǎn)設(shè)置為列表?xiàng)l目的model類(lèi)對(duì)象,不受混淆影響.
* 通過(guò)關(guān)鍵字parent/childAt(x)可以在視圖的引用中不受混淆影響
* 接口的方法通常不受混淆影響.因此在DataPath中多用接口方法調(diào)用
因此開(kāi)發(fā)在配置DataPath時(shí)應(yīng)盡量用上述不被混淆影響的字段及方法.但是,如果真的用到了混淆過(guò)的字段怎么辦.我們的方案是:
數(shù)據(jù)報(bào)警
比如版本1上配置的DataPath "a.b",在升級(jí)新版本2后不再適用,則新版本2按照"a.b"收集時(shí)將收集不到,產(chǎn)生報(bào)警信息到后臺(tái).后臺(tái)收到大量此種信息會(huì)提醒開(kāi)發(fā)為新版本配置適用新版本的DataPath.
2.代碼變化導(dǎo)致引用路徑變化,從而致使之前配置的DataPath失效.
與代碼中埋點(diǎn)相比,線上配置進(jìn)行收集數(shù)據(jù)與代碼的變化是并行的,無(wú)關(guān)的.這就有可能造成原有代碼修改導(dǎo)致DataPath失效.其實(shí)如果客戶(hù)端架構(gòu)設(shè)計(jì)合理,功能迭代更多是在進(jìn)行代碼的擴(kuò)展,而非修改,這種導(dǎo)致DataPath失效的情況應(yīng)該會(huì)大大降低的.
但是無(wú)論如何:
配置的DataPath擺脫不了與版本的相關(guān)性
對(duì)于此種問(wèn)題我們依然是通過(guò)前面提到的"數(shù)據(jù)報(bào)警"進(jìn)行監(jiān)控及避免的.
五、結(jié)語(yǔ)
綜上,本文介紹了數(shù)據(jù)收集邏輯中3個(gè)比較關(guān)鍵的點(diǎn)(ViewID/Page/DataPath),結(jié)合上一篇文章的(AOP原理),Android端無(wú)埋點(diǎn)數(shù)據(jù)收集技術(shù)上比較關(guān)鍵的點(diǎn)皆以總結(jié)完畢.
當(dāng)然實(shí)現(xiàn)SDK過(guò)程中遭遇過(guò)很多比較有意思的技術(shù)問(wèn)題,后續(xù)也會(huì)陸續(xù)進(jìn)行整理.