鑑於日益強烈的精細化運營需求,網易樂得從去年開始構建大數據平臺,<<無埋點數據收集SDK>>所以立項,用於向大數據平臺提供全量,完整,準確的客戶端數據.
<<無埋點數據收集SDK>>Android端從着手,到經歷重構,逐步完善到如今已經有快一年的時間了.期間從開源社區以及同行中獲得了一些頗有意義的技術參考,所以在這個SDK趨於完善的今天,咱們也考慮將這一路在技術上的探索經歷和收穫分享出來.javascript
以前關於Android端的<<無埋點數據收集SDK>>使用的技術,寫了一篇文章Android AOP之字節碼插樁,這個是Android端進行一切收集的起點,咱們就是用這個方法輕鬆拿到各類"Hook"點的.
本篇文章則接着講一下關於收集SDK內部收集邏輯的一些關鍵技術.java
1、概述
1.1 SDK數據收集能力現狀
1.2 關鍵技術點概述
2、View的惟一標識(ID)
2.1 調研
2.2 利用ViewTree構建ViewID
2.3 ViewPath的生成
2.4 ViewPath的優化
3、頁面的劃分
3.1 合理劃分頁面的重要性
3.2 Android中的頁面
3.3 頁面名組成
4、無需埋點輕鬆收集定製的業務數據
4.1 配置示例
4.2 無埋點收集流程
4.3 數據路徑(DataPath)
5、結語android
本部分首先簡要介紹一下咱們的收集方案目前能夠收集到哪些數據,而後對於本文重點介紹的三個技術點進行概述.程序員
目前咱們的SDK進行數據收集時基本有兩個能力:數組
a. 通用數據全量收集
通用數據指的是與業務無關的用戶行爲數據,不管是電商應用仍是社區應用,接入SDK後通用數據的收集上都是無差的,這些通用數據大體有:緩存
事件 | 描述 |
---|---|
冷啓動事件 | App第一次啓動時的,版本號、設備ID、渠道、內存使用狀況,磁盤使用狀況等信息 |
先後臺事件 | App進入前臺或者後臺 |
頁面事件 | 頁面(Activity或Fragment)顯示(Show)/隱藏(Hide) |
控件點擊事件 | 某個控件(包括頁面上控件和彈窗中控件)被用戶點擊 |
列表瀏覽事件[可選] | 某個列表的哪些條目被用戶瀏覽了 |
位置事件[可選] | 上報用戶地理位置信息 |
其它事件 | 省略描述 |
b. 業務相關數據需求經過下發配置進行無埋點定製收集
除了上述通用數據,與具體業務相關的數據收集。拿網易貴金屬的首頁舉個例子:架構
假使須要在用戶點擊上圖紅框區域時,把「粵貴銀」這個交易品的ID(或者下方顯示的指數等,只要在內存中存在的數據均可以)一塊兒報上來。
對於此種需求,數據收集SDK作到了無需埋點,不依賴開發週期,經過線上下發一些配置信息,便可即時進行數據收集。具體原理第四節敘述。app
a. View的惟一標識(ID),(詳見本文第二節)
當咱們收集控件數據時碰到的第一個問題就是:如何把界面上的任何一個View與其餘View區分開來.iphone
好比:某個Button被點擊了
咱們在上報數據的時候須要把這個Button和其餘全部控件(好比另外一個Button,另外一個ImageView等)區分開來,這樣這條上報的數據才能表示"就是那個Button被點擊了一下".ide
這就須要爲界面上的每個控件生成一個惟一的ID. 此ID除了具備區分性,還須要用於一致性.一致性是同一個View不管界面佈局如何動態變化,或者說屢次進入同一頁面,此ID須要保持不變.
b. 頁面的劃分,(詳見本文第三節)
除了Activity有些Fragment也須要看做頁面,這就要求:
c. 無需埋點輕鬆收集定製的業務數據,(詳見本文第四節)
如前面所述,默認狀況下數據收集SDK會收集全量的用戶交互數據,對於定製的業務收集需求,數據收集SDK也作到了無需代碼埋點,經過線上下發一些配置進行即時收集.
用於區分界面上每一個View的ID? Android系統是否提供給了咱們這個ID?
確實,Android系統提供了一個ID,view.getId()便可得到一個int型的id用於區分View,可是這個ID由於如下兩個緣由卻並不能知足咱們的須要.
所以,咱們只能本身動手構建咱們的ID嘍,怎麼構建?答案是利用所屬Page+ViewTree構建ViewID.
在Android的概念裏,每一個Window(ActivityWindow/DialogWindow/PopupWindow等)上面都生長着一棵ViewTree.而屏幕中看到的各類控件(ImageView/Button等)都是這棵ViewTree上的節點.
有Android開發環境的同窗只須要打開AndroidDeviceMonitor-dump view hierarchy 就能夠看到ViewTree的模樣,以下圖:
所以,咱們萌生出一個想法:
利用Page+ViewTree中的位置構建ViewID.
View在ViewTree中的位置主要用兩點來肯定:
考慮這兩個因素後,咱們定義一個ViewPath:
ViewPath:當前view到ViewTree根節點的一條路徑,用於在ViewTree中惟必定位當前view。路徑中的每一個節點包含兩部分信息,即節點View類型信息,以及節點View在兄弟中的index。
以下圖,是一個簡單的ViewTree模型(簡單到深度只有兩層,每層只有兩三個控件)
按照以前給的定義,上圖中控件1,2,3,4的ViewPath以下
控件1ViewPath: RootView/LinearLayout[0] index爲1表示此節點是兄弟節點中第一個控件
控件4ViewPath: RootView/LinearLayout[0]/ChildView1[0]
控件2ViewPath: RootView/RelativeLayout[1]
控件3ViewPath: RootView/LinearLayout[2]複製代碼
上述給出的ViewPath中,每一個節點(除了首節點)有兩部份內容:
這是最初的ViewPath,用ViewPath定位view,有兩點特別重要:
按照這個最初的ViewPath定義在實踐中還不能在一致性和區分度上知足咱們的需求,後面會對ViewPath進行優化。
上面咱們由構建ViewID的需求引出了ViewPath的定義,那麼當交互事件(例如:按鈕點擊)發生時,咱們如何生成此控件的ViewPath?
如上一篇文章<
Monitor.onViewClick(view);複製代碼
上面,入參view即爲當前被點擊的view,獲取此view的ViewPath僞代碼以下:
public static ViewPath getPath(View view) {
do {
//1. 構造ViewPath中於view對應的節點:ViewType[index]
ViewType=view.getClass().getSimpleName();
index=view在兄弟節點中的index;
ViewPath節點=ViewType[index];
}while ((view=view.getParent())instanceof View);//2. 將view指向上一級的節點
}複製代碼
構造出來的ViewPath以下面例子所示:
DecorView/LinearLayout[0]/FrameLayout[0]/ActionBarOverlayLayout[0]/ContentFrameLayout[0]/FrameLayout[0]/LinearLayout[0]/ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]複製代碼
a. 一致性優化1
情景:
在圖2-2 ViewTree模型圖中,若是像下面圖中所示,在控件2和3中動態插入一個FrameLayout呢?
此時按照原始ViewPath的定義,咱們來看看控件3的ViewPath發生了哪些變化?
ViewTree動態變化前: RootView/LinearLayout[2]
ViewTree動態變化後: RootView/LinearLayout[3]複製代碼
優化:
ViewPath節點中index的含義從「兄弟節點的第幾個」優化爲:「相同類型兄弟節點的第幾個」
優化後,發生圖2-3所示界面佈局動態變化時,控件3的ViewPath變化爲:
ViewTree動態變化前: RootView/LinearLayout[1] index爲1表示此節點是兄弟節點中第二個LinearLayout
ViewTree動態變化後: RootView/LinearLayout[1]複製代碼
能夠看出,此處優化使控件3的ViewPath在ViewTree動態插入除了LinearLayout以外其它任何類型時都保持先後一致。
b. 一致性優化2
情景:
在圖2-2 ViewTree模型圖中,若是像下面圖中所示,在控件2和3中動態插入一個LinearLayout時,控件3的ViewPath可否繼續保持先後一致?
按照上述情景,控件3ViewPath的變化以下:
ViewTree動態變化前: RootView/LinearLayout[1] index爲1表示此節點是兄弟節點中第二個LinearLayout
ViewTree動態變化後: RootView/LinearLayout[2] 前面插入一個LinearLayout致使此節點變爲兄弟節點中第三個LinearLayout了複製代碼
問題
上述情景指的實際上是一個問題:ViewTree中同類型兄弟節點動態變化(插入/移除/移位)影響ViewPath的一致性
- ViewPath節點中的index,在同類型(ViewType相同,例如都是LinearLayout)兄弟節點動態加入/刪除時,當前節點的index沒法在變化先後保持一致。
- 「一致性優化1」中的優化能夠抵禦不一樣類型兄弟節點的影響,卻對同類型兄弟節點的影響迫不得已。
從ViewPath的定義上難以找到在同類型兄弟節點動態變化先後保持一致的方法,但咱們能夠分析發生此種界面動態變化的情景:
2中所說「ListView等可複用View」形成的問題後面會有優化,此處針對1中的情景討論。1中情景發生時以下圖:
上圖中FragmentA,FragmentB,FragmentC的頂層視圖控件所有是LinearLayout(同類型),此時這三個Fragment加入的順序將形成ViewPath在此處各類不一致,從而致使ViewPath在動態變化先後不能保持一致(如前面:ViewTree動態變化先後控件3ViewPath的變化所示)。
優化:
在ViewPath節點中,使用Fragment的名字替換ViewType
優化後,發生圖2-4所示界面佈局動態變化時,控件3的ViewPath變化爲:
ViewTree動態變化前: RootView/FragmentB[0] index爲0表示此節點是兄弟節點中第一個FragmentB
ViewTree動態變化後: RootView/FragmentB[0]複製代碼
如上,這次優化使得,在頂層視圖ViewType相同的Fragment動態添加/刪除到ViewTree時,ViewPath在變化先後保持一致。
c. 針對可複用View的優化
情景
以最常使用的ListView爲例,假設有一ListView滿屏只顯示3個條目,那麼此ListView可能只有3個子控件(ItemView),而此ListView上滑以後能夠顯示100項內容。
這3個ItemView與100項內容是一對多的對應關係,並且映射並沒有可靠規律。
此時,咱們但願ViewPath能夠區分這100項顯示的內容條目,而非僅僅區分3個ItemView。
上面情景中的問題可用下圖表達:
如上圖中,內容條目1和4都是用itemView1來呈現的,按照以前的ViewPath定義,圖2-5中各個內容條目的ViewPath以下:
內容條目1: ListView/ItemView[0] index爲0表示此節點是兄弟節點中第一個ItemView
內容條目4: ListView/ItemView[0]
內容條目2: ListView/ItemView[1]
內容條目3: ListView/ItemView[2]複製代碼
能夠看出內容條目1和4的ViewPath區分不開。此種問題能夠總結爲:
顯示內容與ViewTree中的控件不是一一對應的狀況形成基於ViewTree的ViewPath區分度不夠
- 可複用View,好比:ListView,RecyclerView,Spinner等,呈現出來子View的數目和實際子View的數目未必一致
- ViewPager設置緩存頁面數爲1,第二頁顯示時,第二個頁面頂級View實際上是ViewPager的第一個ChildView。此種狀況也會形成顯示內容(第二頁)與ViewTree中的控件(第一個ChildView)不對應的狀況。
所以咱們對於ViewPath做以下優化:
ViewPath節點的index取內容的第幾項,而非第幾個ItemView。
優化:
優化後圖2-5中各個內容條目的ViewPath以下:
內容條目1: ListView/ItemView[0] index爲0表示此節點是ListView顯示的第一個內容條目
內容條目4: ListView/ItemView[3]
內容條目2: ListView/ItemView[1]
內容條目3: ListView/ItemView[2]複製代碼
可見,以前ViewPath沒法區分的內容條目1和4如今能夠區分開了。各類可複用View取內容的第幾項的代碼方法以下:
ListView,Spinner等AdapterView------------ListView.getPositionForView(itemView)
RecyclerView------------------------------------RecyclerView.getChildAdapterPosition(itemView)
ViewPager----------------------------------------ViewPager.getCurrentItem()複製代碼
d. ViewPath起點優化
ViewPath從ContentView爲起點,而非DecorView
- DecorView : Window上的根視圖,ViewTree中的根,最頂層視圖
- ContentView: 客戶端程序員定義的全部視圖的父節點,如Actvity中常見的setContentView(view)
一個實際中的ViewPath以下:
DecorView/LinearLayout[0]/FrameLayout[0]/ActionBarOverlayLayout[0]/ContentFrameLayout[0]/FrameLayout[0]/LinearLayout[0]/ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]複製代碼
上面的「ContentFrameLayout[0]」這個節點表明的就是ContentView,程序員在xml或者代碼裏面構建的View都在ContentView中。
從DecorView到「ContentFrameLayout[0]」的這一段Path是Android系統Framework層決定的,理論上應該是一致的,可是因爲碎片化等緣由可能ViewPath的這一段發生變化.在實踐中,咱們也發現確實有一些Rom發生了此類狀況,可是比率很小.
爲了屏蔽這種可能形成同一個View在不一樣設備上產生ViewPath不一樣的狀況,ViewPath的起點定義在ContentView比較好.如上面的ViewPath可優化爲:
ContentView/FrameLayout[0]/LinearLayout[0]/ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]#mybutton複製代碼
作法:
構造每個ViewPath節點時能夠取view.getId(),看看id的packageId部分是否是系統的(系統資源id以16進制的0x01,0x00開頭),若是是,生成ViewPath時屏蔽這段便可.
頁面在Android中對應於Activity和部分Fragment(好比不少app首頁多tab的設計,若每一個tab是使用Fragment實現的,那麼這種tab通常看做一個頁面).頁面的劃分很重要,由於兩點:
事件名稱 | 所屬頁面 | ViewPath | 其餘屬性 |
---|---|---|---|
ButtonClicked | MainActivity | XXX | 省略 |
表格中的"所屬頁面"即表示這次按鈕點擊事件發生在MainActivity中.將交互事件歸屬於頁面這樣對後面不管是進行路徑分析仍是統計控件點擊量分佈都有很大的好處.
Android中一般須要看做頁面的有Activity和Fragment(對於像全屏Dialog或者全屏的View暫不考慮).對於Activity,上節中提到的兩點都很容易辦到.
a. Activity頁面
Monitor.onViewClick(view)複製代碼
入參view即爲咱們點擊的view,經過view.getContext()咱們通常就能夠獲得此View所屬的Activity,僞代碼以下://從View中利用context獲取所屬Activity的名字
public static String getActivityName(View view) {
Context context = view.getContext();
if (context instanceof Activity) {
//context自己是Activity的實例
return context.getClass().getSimpleName().;
} else if (context instanceof ContextWrapper) {
//Activity有可能被系統"裝飾",看看context.base是否是Activity
Activity activity = getActivityFromContextWrapper((ContextWrapper) context);
if (activity != null) {
return activity.getClass().getSimpleName();
} else {
//若是從view.getContext()拿不到Activity的信息(好比view的context是Application),則返回當前棧頂Activity的名字
return currentActivityName;
}
}
return "";
}複製代碼
b. fragment頁面
相對於Activity,將某些Fragment看做頁面的邏輯就要稍微複雜一些了.這裏面涉及下面幾個問題:
onResume()
onPause()
onHiddenChanged(boolean hidden)
setUserVisibleHint(boolean isVisibleToUser)複製代碼
使用這幾個回調包裝成適用於各類情景的FragmentShow/Hide事件的僞代碼以下://此回調發生,則證實是場景一中使用情景,
onHiddenChanged(boolean hidden) {
hidden == true ------FragmentShow
hidden == false------FragmentHide
}
//場景二中ViewPager頁面切換時觸發Fragment的此回調,
setUserVisibleHint(boolean isVisibleToUser) {
if (fragment.isResumed()) {//只有resumed狀態的fragment適用此情景
isVisibleToUser == true ------FragmentShow
isVisibleToUser == false------FragmentHide
}
}
//上述使用情景以外的通常場景
OnResume/OnPause{
//fragment沒有被hide,而且UserVisibleHint爲可見的情景
if (!fragment.isHidden() && fragment.getUserVisibleHint()) {
OnResume ------ FragmentShow
OnPause ------ FragmentHide
}
}複製代碼
view.setTag(0xff000001, fragmentName);複製代碼
注意:View類有兩個名爲setTag的方法:public void setTag(final Object tag)複製代碼
此方法,類內部用一Object對象存儲tag,protected Object mTag = null;。listAdapter中經常使用於設置holder。咱們此處用的不是這個,不會於此用法衝突public void setTag(int key, final Object tag)複製代碼
此方法,類內部有一稀疏數組存儲tag,private SparseArray前面講了將交互事件(好比點擊事件)歸屬到某一個頁面的方法是:
在交互事件中設置一個字段,值爲頁面名稱。
頁面能夠是Activity或者Activity承載的Fragment,咱們的頁面名稱組成以下:
Activity類名[Activity別名][Fragment類名][Fragment別名]複製代碼
說明以下:
商品詳情頁#iphone
商品詳情頁#電視複製代碼
對於別名的設置,須要程序員在業務代碼裏面(如Activity.OnCreate,Fragment.onCreate等)顯式設置. 以前提到過,數據收集SDK能夠經過配置下發即時收集定製的數據,那麼在Android端這個是怎麼作到的呢?
首先,看一下下發的配置樣例:
//第一部分:描述
PageName:MainActivity
ViewPath:DecorView/.../ViewPager[0]/ButtonFragment[0]/AppCompatButton[0]
EventType:ViewClick
//第二部分:數據路徑(當描述符合時,按照此路徑取數據)
DataPath:this.context.demoList[5]複製代碼
上面例子翻譯成數據需求就是:
1. 當頁面(MainActivity)
2. 中的控件(DecorView/.../ViewPager[0]/ButtonFragment[0]/AppCompatButton[0])
3. 發生點擊事件(ViewClick)時
4. 按照路徑(this.context.demoList[5])取出數據
5. 並附加到點擊事件上面一塊兒上報複製代碼
按照這個描述,咱們還能夠描述以下等等各類數據需求:
當(某頁面)發生事件(Show)時,按照路徑(xxx)取出數據,並附加到頁面Show事件上面一塊兒上報複製代碼
總結下描述的組成部分,以下:
第一層 | 第二層 | 含義 |
---|---|---|
描述部分 | 頁面 | 限定頁面 |
ViewPath | 限定按鈕 | |
EventType | 限定時機(點擊/前臺/PageShow) | |
數據路徑 | 一種DSL,指示目標數據在內存中的位置(可理解爲「引用路徑」) |
上節展現了用於無埋點定製業務數據收集的配置,那麼SDK收到這樣的一份配置如何最終把想要的數據收集上來呢?
Monitor.onViewClick(view)複製代碼
上述步驟三進行數據收集主要是按照DataPath的描述進行(例如示例中提到的"this.context.demoList[5]"),DataPath是一種咱們用於收集定製數據而定義的一種DSL.含義以下:
a. 含義
DataPath: 指向要收集的目標數據的一條引用路徑,解析此路徑並逐級反射最終拿到目標數據.
DataPath寫法中的一些關鍵字(符):
關鍵字(符) | 含義 |
---|---|
. | 表示對象所屬關係,如:a.b 表示實例a中的字段b |
.() | 表示公有方法調用,如:a.b() 表示調用實例a中的方法b.注意:方法入參能夠是DataPath指向的Object |
[] | 數組/線性表的index. 注意:此index能夠是常量數字,也能夠是一個DataPath指向的數字 |
this | DataPath字符串的起點,表示起點爲當前實例(當前View) |
item | DataPath字符串的起點,表示起點爲當前View父節點中AdapterView adapter中當前條目. 經常使用於列表中的數據獲取 |
parent | DataPath節點中的關鍵字,用於表示當前view的parentView.效果同view.getParent(),使用此關鍵字可減小視圖引用中的反射 |
childAt(x) | DataPath節點中的關鍵字,用於表示當前view的第x個childView.效果同view.getChildAt(x),使用此關鍵字可減小視圖引用中的反射 |
b. 應用示例
下面用兩個例子說明如何從DataPath找到目標數據.
示例1:列表數據獲取
上圖中顯示是一個列表,紅框中是列表的第一個條目.那麼,若是咱們想要在列表中條目點擊時,將列表展現的交易品ID(或者合做方ID)等不在界面上顯示而又存在於內存中的數據跟隨點擊事件上報.此處DataPath該怎麼寫?
item.productId複製代碼
DataPath解釋:
public class DemoAdapter extends BaseAdapter {
private ArrayList<DataItem> mDataItems;
......
}複製代碼
則此處"item"表明的就是mDataItems[x] (x表示當前被點擊條目的itemId)2."productId"是model類DataItem中表示"交易品ID"的字段名稱.
經過DataPath獲取數據:
實例2:界面數據獲取
一樣時圖4-1所示,加入咱們想在列表中條目點擊時,將條目中展現的"最新價"跟隨點擊事件上報.此處DataPath該怎麼寫?
紅框所示ViewTree子樹以下:
如上圖,選中部分是列表的ItemView(RelativeLayout),可見"最新價"是由index爲2的TextView所展現,由此可得,列表中條目點擊獲取"最新價"數據的DataPath以下:
this.childAt(2).mText複製代碼
DataPath解釋:
c. DataPath注意事項:
1.混淆.
因爲DataPath本質上描述的時內存中的"引用路徑",而且按照DataPath取數據時用了反射的方法,所以DataPath應該描述的是混淆以後的"引用路徑".
雖然DataPath可能受到混淆的影響,可是
* 用於存儲數據的model類一般是不被混淆的.如咱們以前的item關鍵字直接將起點設置爲列表條目的model類對象,不受混淆影響.
* 經過關鍵字parent/childAt(x)能夠在視圖的引用中不受混淆影響
* 接口的方法一般不受混淆影響.所以在DataPath中多用接口方法調用複製代碼
所以開發在配置DataPath時應儘可能用上述不被混淆影響的字段及方法.可是,若是真的用到了混淆過的字段怎麼辦.咱們的方案是:
數據報警
好比版本1上配置的DataPath "a.b",在升級新版本2後再也不適用,則新版本2按照"a.b"收集時將收集不到,產生報警信息到後臺.後臺收到大量此種信息會提醒開發爲新版本配置適用新版本的DataPath.
2.代碼變化致使引用路徑變化,從而導致以前配置的DataPath失效.
與代碼中埋點相比,線上配置進行收集數據與代碼的變化是並行的,無關的.這就有可能形成原有代碼修改致使DataPath失效.其實若是客戶端架構設計合理,功能迭代更可能是在進行代碼的擴展,而非修改,這種致使DataPath失效的狀況應該會大大下降的.
可是不管如何:
配置的DataPath擺脫不了與版本的相關性
對於此種問題咱們依然是經過前面提到的"數據報警"進行監控及避免的.
綜上,本文介紹了數據收集邏輯中3個比較關鍵的點(ViewID/Page/DataPath),結合上一篇文章的(AOP原理),Android端無埋點數據收集技術上比較關鍵的點皆以總結完畢. 固然實現SDK過程當中遭遇過不少比較有意思的技術問題,後續也會陸續進行整理.