前言
鑒于日益強烈的精細化運營需求,網易樂得從去年開始構建大數據平臺,<<無埋點數據收集SDK>>因此立項,用于向大數據平臺提供全量,完整,準確的客戶端數據.
<<無埋點數據收集SDK>>Android端從著手,到經歷重構,逐步完善到現在已經有快一年的時間了.期間從開源社區以及同行中得到了一些很有意義的技術參考,因此在這個SDK趨于完善的今天,我們也考慮將這一路在技術上的探索經歷和收獲分享出來.
- 4月16-18日,QCon北京2017全球軟件開發大會上有同事代表Android/IOS兩端進行統一的技術分享,歡迎大家前去交流
- 我們會逐漸整理一些技術文章到這個簡書賬號“移動端數據收集和分析”
之前關于Android端的<<無埋點數據收集SDK>>使用的技術,寫了一篇文章<<Android AOP之字節碼插樁>>,這個是Android端進行一切收集的起點,我們就是用這個方法輕松拿到各種"Hook"點的.
本篇文章則接著講一下關于收集SDK內部收集邏輯的一些關鍵技術.
目錄
一、概述
1.1 SDK數據收集能力現狀
1.2 關鍵技術點概述
二、View的唯一標識(ID)
2.1 調研
2.2 利用ViewTree構建ViewID
2.3 ViewPath的生成
2.4 ViewPath的優化
三、頁面的劃分
3.1 合理劃分頁面的重要性
3.2 Android中的頁面
3.3 頁面名組成
四、無需埋點輕松收集定制的業務數據
4.1 配置示例
4.2 無埋點收集流程
4.3 數據路徑(DataPath)
五、結語
一、概述
本部分首先簡要介紹一下我們的收集方案目前可以收集到哪些數據,然后對于本文重點介紹的三個技術點進行概述.
1.1 SDK數據收集能力現狀
目前我們的SDK進行數據收集時基本有兩個能力:
a. 通用數據全量收集
通用數據指的是與業務無關的用戶行為數據,無論是電商應用還是社區應用,接入SDK后通用數據的收集上都是無差的,這些通用數據大致有:
事件 | 描述 |
---|---|
冷啟動事件 | App第一次啟動時的,版本號、設備ID、渠道、內存使用情況,磁盤使用情況等信息 |
前后臺事件 | App進入前臺或者后臺 |
頁面事件 | 頁面(Activity或Fragment)顯示(Show)/隱藏(Hide) |
控件點擊事件 | 某個控件(包括頁面上控件和彈窗中控件)被用戶點擊 |
列表瀏覽事件[可選] | 某個列表的哪些條目被用戶瀏覽了 |
位置事件[可選] | 上報用戶地理位置信息 |
其它事件 | 省略描述 |
b. 業務相關數據需求通過下發配置進行無埋點定制收集
除了上述通用數據,與具體業務相關的數據收集。拿網易貴金屬的首頁舉個例子:
假使需要在用戶點擊上圖紅框區域時,把“粵貴銀”這個交易品的ID(或者下方顯示的指數等,只要在內存中存在的數據都可以)一起報上來。
對于此種需求,數據收集SDK做到了無需埋點,不依賴開發周期,通過線上下發一些配置信息,即可即時進行數據收集。具體原理第四節敘述。
1.2關鍵技術點概述
a. View的唯一標識(ID),(詳見本文第二節)
當我們收集控件數據時碰到的第一個問題就是:如何把界面上的任何一個View與其他View區分開來.
比如:某個Button被點擊了
我們在上報數據的時候需要把這個Button和其他所有控件(比如另一個Button,另一個ImageView等)區分開來,這樣這條上報的數據才能表示"就是那個Button被點擊了一下".
這就需要為界面上的每一個控件生成一個唯一的ID. 此ID除了具有區分性,還需要用于一致性.一致性是同一個View無論界面布局如何動態變化,或者說多次進入同一頁面,此ID需要保持不變.
b. 頁面的劃分,(詳見本文第三節)
除了Activity有些Fragment也需要看作頁面,這就要求:
- 在Fragment show/hide時上報相關頁面事件.
- 頁面Fragment中發生的用戶交互事件也需要歸于此Fragment頁面,即點擊某個View需要上報頁面Fragment的信息(從View中怎么獲取Fragment信息?)
c. 無需埋點輕松收集定制的業務數據,(詳見本文第四節)
如前面所述,默認情況下數據收集SDK會收集全量的用戶交互數據,對于定制的業務收集需求,數據收集SDK也做到了無需代碼埋點,通過線上下發一些配置進行即時收集.
二、View的唯一標識(ID)
2.1 調研
用于區分界面上每個View的ID? Android系統是否提供給了我們這個ID?
確實,Android系統提供了一個ID,view.getId()即可獲得一個int型的id用于區分View,但是這個ID因為以下兩個原因卻并不能滿足我們的需要.
- 有相當一部分view是NO_ID,比如在布局文件中未指定id,或者直接在代碼里面new出來view,view.getId()返回的全部都是NO_ID
- 這個ID是不穩定的,由于這個ID其實就是每次編譯產生的R文件中的int常量,因此同一個按鈕,兩個版本編譯出來的ID很可能時不一樣的.
因此,我們只能自己動手構建我們的ID嘍,怎么構建?答案是利用所屬Page+ViewTree構建ViewID.
2.2 利用ViewTree構建ViewID
在Android的概念里,每個Window(ActivityWindow/DialogWindow/PopupWindow等)上面都生長著一棵ViewTree.而屏幕中看到的各種控件(ImageView/Button等)都是這棵ViewTree上的節點.
有Android開發環境的同學只需要打開AndroidDeviceMonitor-dump view hierarchy 就可以看到ViewTree的模樣,如下圖:
因此,我們萌生出一個想法:
利用Page+ViewTree中的位置構建ViewID.
View在ViewTree中的位置主要用兩點來確定:
- 縱向的深度
- 橫向的index
考慮這兩個因素后,我們定義一個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中,每個節點(除了首節點)有兩部分內容:
- LinearLayout,RelativeLayout,ChildView1等ViewType信息(節點View的類型)
- "[]"內的index信息,此index指示此節點是兄弟節點的第幾個
這是最初的ViewPath,用ViewPath定位view,有兩點特別重要:
- 一致性: 同一個view的ViewPath在ViewTree的動態變化中應保持不變
- 區分度: 不同view的ViewPath應該不同
按照這個最初的ViewPath定義在實踐中還不能在一致性和區分度上滿足我們的需求,后面會對ViewPath進行優化。
2.3 ViewPath的生成
上面我們由構建ViewID的需求引出了ViewPath的定義,那么當交互事件(例如:按鈕點擊)發生時,我們如何生成此控件的ViewPath?
如上一篇文章<<Android AOP之字節碼插樁>>所述,當用戶點擊某個按鈕時,我們插入OnClickListener.OnClick方法中的如下代碼將會被調用:
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]
2.4 ViewPath的優化
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的定義上難以找到在同類型兄弟節點動態變化前后保持一致的方法,但我們可以分析發生此種界面動態變化的情景:
- 使用Fragment的動態布局
Android界面的動態布局發生情景中,使用Fragment實現界面動態變化的頻率和影響控件數量還是比較大的(相對于直接addView()) - ListView(等可復用View)中同類型的itemViews。
此種情況雖然沒有發生在一個itemView前動態插入一個itemView,但是由于itemView的復用,導致itemView展示的內容和在父節點listView內的index的對應關系動態變化,因此也歸于此類。
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時屏蔽這段即可.
三、頁面的劃分
3.1 合理劃分頁面的重要性
頁面在Android中對應于Activity和部分Fragment(比如很多app首頁多tab的設計,若每個tab是使用Fragment實現的,那么這種tab一般看作一個頁面).頁面的劃分很重要,因為兩點:
- 對于頁面,需要獲取Show/Hide兩個時機,在此時機上報頁面Show/Hide事件,非頁面則不需要
- 頁面的劃分關系著用戶交互事件的所屬,例如,按鈕點擊事件上報格式如下:
事件名稱 | 所屬頁面 | ViewPath | 其他屬性 |
---|---|---|---|
ButtonClicked | MainActivity | XXX | 省略 |
表格中的"所屬頁面"即表示此次按鈕點擊事件發生在MainActivity中.將交互事件歸屬于頁面這樣對后面無論是進行路徑分析還是統計控件點擊量分布都有很大的好處.
3.2 Android中的頁面
Android中通常需要看作頁面的有Activity和Fragment(對于像全屏Dialog或者全屏的View暫不考慮).對于Activity,上節中提到的兩點都很容易辦到.
a. Activity頁面
- 從Application.ActivityLifecycleCallbacks的onActivityResumed/onActivityPaused這兩個回調方法就可以分別得到Activity頁面Show/Hide的時機,并在此時機上報相應頁面事件
- 交互歸屬的Activity頁面可以通過Context輕松獲得,例如上篇文章<<Android AOP之字節碼插樁>>提到,當按鈕點擊時,會觸發我們插樁的代碼:
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看作頁面的邏輯就要稍微復雜一些了.這里面涉及下面幾個問題:
- 哪些Fragment可以需要看作頁面?
這是需要人工決策的,機器做不了這個決定.
目前我們這個人工干預是交給用戶研究團隊,所有Fragment截圖等信息均展示在平臺上,由用研同事選擇需要看作頁面的那些,用研選擇的結果將自動化配置到SDK中. - 如何得到Fragment頁面的Show/Hide頁面事件?
由于fragment使用場景比較多樣,單單依靠OnResume/OnPause兩個回調表示fragment Show/Hide是不準確的,比如:
場景一:
首頁一個Activity承載多個Fragment Tab的情況,此時tab間切換并不會觸發Fragment的OnResume/OnPause.觸發的回調函數是onHiddenChanged(boolean hidden)
場景二:
一個ViewPager承載多個頁面的Fragment時
a.當第一個Fragment1顯示時,雖然第二個Fragment2此時尚未顯示,但是Fragment2的OnResume卻以及執行,處于resumed的狀態.
b.ViewPager頁面切換OnResume/OnPause/onHiddenChanged均未觸發,觸發的回調是setUserVisibleHint
此時判斷Fragment Show/Hide應該用setUserVisibleHint,而非OnResume/OnPause
如前一篇文章XXX,所述,我們通過插樁的方式Hook到了fragment的如下生命周期函數用于包裝成為Show/Hide事件:
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
}
}
- 如何將Fragment內部的交互歸屬到Fragment頁面,也就是說如何在交互發生時從view實例拿到Fragment頁面的名字(像之前拿到Activity頁面名字一樣)?
view可以通過context拿到Activity的信息,但是卻沒有途徑拿到fragment的引用。那么,當某個View交互發生,我們又需要獲取Fragment頁面名字的情況下,我們只能事先將Fragment頁面名寫入此View的屬性中。
做法大致如下:
a. 按照前一篇文章xxx里面的方法,在Fragment.OnCreateView方法的結尾插樁,拿到return的view(即為此Fragment的頂層視圖)
b. 判斷此Fragment是否被指定為Fragment頁面,如果是,下一步
c.遍歷以Fragment的頂層視圖為根節點的ViewTree, 將Fragment名設置到此ViewTree的每一個view上。設置方法如下所示:
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<Object> mKeyedTags;
tag的key官方推薦資源id,因此我們可以選用類似0xff000001之類的app用不到的資源id進行tag存儲以避免沖突。
d. 當需要使用Fragment名時,如下調用即可獲得:
view.getTag(0xff000001)
3.3 頁面名組成
前面講了將交互事件(比如點擊事件)歸屬到某一個頁面的方法是:
在交互事件中設置一個字段,值為頁面名稱。
頁面可以是Activity或者Activity承載的Fragment,我們的頁面名稱組成如下:
Activity類名[Activity別名][Fragment類名][Fragment別名]
說明如下:
- “[]”內的組成部分是可選的,可能有可能沒有。另外,各個組成部分之間有分隔符分割。
- 頁面名組成中,Activity的描述(類名/別名)是第一層,Fragment的描述(類名/別名)是第二層
-
別名的出現是為了解決單純依賴類名無法精確區分頁面的某些情況,比如:
在某個電商應用中,“商品詳情頁”(同一個Activity)用于展示各種商品(iphone,電視等),如果需要把“不同商品的商品詳情頁“區分成不同頁面來統計pv等指標的話,需要設置別名,如:
商品詳情頁#iphone
商品詳情頁#電視
對于別名的設置,需要程序員在業務代碼里面(如Activity.OnCreate,Fragment.onCreate等)顯式設置.
四、無需埋點輕松收集定制的業務數據
4.1 配置示例
之前提到過,數據收集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,指示目標數據在內存中的位置(可理解為“引用路徑”) |
4.2 無埋點收集流程
上節展示了用于無埋點定制業務數據收集的配置,那么SDK收到這樣的一份配置如何最終把想要的數據收集上來呢?
- 步驟一:產生原始事件。比如點擊時收集,當點擊時會觸發我們插樁的代碼,并生成原始的點擊事件
Monitor.onViewClick(view)
- 步驟二:匹配配置
在onViewClick方法中匹配下發的配置信息,看看Page,ViewPath是否與當前view匹配,EventType是否與當前事件類型匹配,若匹配則進行下一步
注:ViewPath的匹配可以有精確匹配和模糊匹配,精確匹配時一個ViewPath精確匹配唯一一個控件.模糊匹配時一個ViewPath可匹配多個控件,例如可以用用一個ViewPath模糊匹配一個列表中的所有條目. - 步驟三:按照數據路徑(DataPath)逐級反射拿到目標數據,并將找到的數據附在原始的點擊事件上進行上報。
4.3 數據路徑(DataPath)
上述步驟三進行數據收集主要是按照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解釋:
- 起點定為"item",則表示從此ListView(或者RecylerView)綁定的Adapter中當前數據item為起點取數據.
假設此ListView綁定的Adapter如下:
public class DemoAdapter extends BaseAdapter {
private ArrayList<DataItem> mDataItems;
......
}
則此處"item"代表的就是mDataItems[x] (x表示當前被點擊條目的itemId)
2."productId"是model類DataItem中表示"交易品ID"的字段名稱.
通過DataPath獲取數據:
- 當第x條目被點擊時,如果發現有匹配的配置,對于起點為"item"的DataPath,先通過view.getParent找到上層ListView實例,然后通過listView.getAdapter()獲得綁定的Adapter實例,最后通過Adapter.getItem(ListView.getPositionForView(itemView))得到數據中第x個item,即mDataItems[x]
- 反射獲取mDataItems[x]中的productId字段,即可得到第x個條目的"交易品ID",將此ID跟隨第x條目的點擊事件進行上報即可.
實例2:界面數據獲取
同樣時圖4-1所示,加入我們想在列表中條目點擊時,將條目中展示的"最新價"跟隨點擊事件上報.此處DataPath該怎么寫?
紅框所示ViewTree子樹如下:
如上圖,選中部分是列表的ItemView(RelativeLayout),可見"最新價"是由index為2的TextView所展示,由此可得,列表中條目點擊獲取"最新價"數據的DataPath如下:
this.childAt(2).mText
DataPath解釋:
- 起點為"this",表示當前被點擊的view實例(圖4-2中被選中的RelativeLayout)
- "childAt(2)"表示RelativeLayout.getChildAt(2),得到圖4-2中index為2的TextView
- "mText" 表示取出步驟2中得到TextView實例的mText字段(TextView控件顯示的文字內容存儲在mText字段內)
- 將取出的界面上顯示的"最新價"數據添加到原始點擊事件中,一起上報.
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過程中遭遇過很多比較有意思的技術問題,后續也會陸續進行整理.