Android無埋點資料收集SDK關鍵技術

陶菜菜89發表於2019-03-04

前言

  鑑於日益強烈的精細化運營需求,網易樂得從去年開始構建大資料平臺,<<無埋點資料收集SDK>>因此立項,用於向大資料平臺提供全量,完整,準確的客戶端資料.
  <<無埋點資料收集SDK>>Android端從著手,到經歷重構,逐步完善到現在已經有快一年的時間了.期間從開源社群以及同行中得到了一些很有意義的技術參考,因此在這個SDK趨於完善的今天,我們也考慮將這一路在技術上的探索經歷和收穫分享出來.

  1. 4月16-18日,QCon北京2017全球軟體開發大會上有同事代表Android/IOS兩端進行統一的技術分享,歡迎大家前去交流
  2. 我們會逐漸整理一些技術文章出來

  之前關於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. 業務相關資料需求通過下發配置進行無埋點定製收集
  除了上述通用資料,與具體業務相關的資料收集。拿網易貴金屬的首頁舉個例子:

Android無埋點資料收集SDK關鍵技術
圖1-1 無埋點收集業務資料示例

  假使需要在使用者點選上圖紅框區域時,把“粵貴銀”這個交易品的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因為以下兩個原因卻並不能滿足我們的需要.

  1. 有相當一部分view是NO_ID,比如在佈局檔案中未指定id,或者直接在程式碼裡面new出來view,view.getId()返回的全部都是NO_ID
  2. 這個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的模樣,如下圖:

Android無埋點資料收集SDK關鍵技術
圖2-1 ViewTree概念圖

因此,我們萌生出一個想法:

利用Page+ViewTree中的位置構建ViewID.

View在ViewTree中的位置主要用兩點來確定:

  • 縱向的深度
  • 橫向的index

考慮這兩個因素後,我們定義一個ViewPath:

ViewPath:當前view到ViewTree根節點的一條路徑,用於在ViewTree中唯一定位當前view。路徑中的每個節點包含兩部分資訊,即節點View型別資訊,以及節點View在兄弟中的index。

如下圖,是一個簡單的ViewTree模型(簡單到深度只有兩層,每層只有兩三個控制元件)

Android無埋點資料收集SDK關鍵技術
圖2-2 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?
  如上一篇文章<>所述,當使用者點選某個按鈕時,我們插入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呢?

Android無埋點資料收集SDK關鍵技術
圖2-3 Android介面動態性變化情景1

此時按照原始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的定義上難以找到在同型別兄弟節點動態變化前後保持一致的方法,但我們可以分析發生此種介面動態變化的情景:

  1. 使用Fragment的動態佈局
      Android介面的動態佈局發生情景中,使用Fragment實現介面動態變化的頻率和影響控制元件數量還是比較大的(相對於直接addView())
  2. ListView(等可複用View)中同型別的itemViews。
      此種情況雖然沒有發生在一個itemView前動態插入一個itemView,但是由於itemView的複用,導致itemView展示的內容和在父節點listView內的index的對應關係動態變化,因此也歸於此類。

2中所說“ListView等可複用View”造成的問題後面會有優化,此處針對1中的情景討論。1中情景發生時如下圖:

Android無埋點資料收集SDK關鍵技術
圖2-4 使用Fragment造成介面動態性的情景

  上圖中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

上面情景中的問題可用下圖表達:

Android無埋點資料收集SDK關鍵技術
圖2-5 可複用View的ViewPath區分性優化

  如上圖中,內容條目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一般看作一個頁面).頁面的劃分很重要,因為兩點:

  1. 對於頁面,需要獲取Show/Hide兩個時機,在此時機上報頁面Show/Hide事件,非頁面則不需要
  2. 頁面的劃分關係著使用者互動事件的所屬,例如,按鈕點選事件上報格式如下:
事件名稱 所屬頁面 ViewPath 其他屬性
ButtonClicked MainActivity XXX 省略

  表格中的"所屬頁面"表示此次按鈕點選事件發生在MainActivity中.將互動事件歸屬於頁面這樣對後面無論是進行路徑分析還是統計控制元件點選量分佈都有很大的好處.

3.2 Android中的頁面

  Android中通常需要看作頁面的有Activity和Fragment(對於像全屏Dialog或者全屏的View暫不考慮).對於Activity,上節中提到的兩點都很容易辦到.

a. Activity頁面

  1. 從Application.ActivityLifecycleCallbacks的onActivityResumed/onActivityPaused這兩個回撥方法就可以分別得到Activity頁面Show/Hide的時機,並在此時機上報相應頁面事件
  2. 互動歸屬的Activity頁面可以通過Context輕鬆獲得,例如上篇文章<>提到,當按鈕點選時,會觸發我們插樁的程式碼:
    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 mKeyedTags;
      tag的key官方推薦資源id,因此我們可以選用類似0xff000001之類的app用不到的資源id進行tag儲存以避免衝突
        d. 當需要使用Fragment名時,如下呼叫即可獲得:
    view.getTag(0xff000001)複製程式碼

3.3 頁面名組成

前面講了將互動事件(比如點選事件)歸屬到某一個頁面的方法是:

在互動事件中設定一個欄位,值為頁面名稱。

頁面可以是Activity或者Activity承載的Fragment,我們的頁面名稱組成如下:

Activity類名[Activity別名][Fragment類名][Fragment別名]複製程式碼

說明如下:

  1. “[]”內的組成部分是可選的,可能有可能沒有。另外,各個組成部分之間有分隔符分割。
  2. 頁面名組成中,Activity的描述(類名/別名)是第一層,Fragment的描述(類名/別名)是第二層
  3. 別名的出現是為了解決單純依賴類名無法精確區分頁面的某些情況,比如:
    在某個電商應用中,“商品詳情頁”(同一個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找到目標資料.

Android無埋點資料收集SDK關鍵技術
圖4-1 DataPath示例

示例1:列表資料獲取
  上圖中顯示是一個列表,紅框中是列表的第一個條目.那麼,如果我們想要在列表中條目點選時,將列表展示的交易品ID(或者合作方ID)等不在介面上顯示而又存在於記憶體中的資料跟隨點選事件上報.此處DataPath該怎麼寫?

item.productId複製程式碼

  DataPath解釋:

  1. 起點定為"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獲取資料:

  1. 當第x條目被點選時,如果發現有匹配的配置,對於起點為"item"的DataPath,先通過view.getParent找到上層ListView例項,然後通過listView.getAdapter()獲得繫結的Adapter例項,最後通過Adapter.getItem(ListView.getPositionForView(itemView))得到資料中第x個item,即mDataItems[x]
  2. 反射獲取mDataItems[x]中的productId欄位,即可得到第x個條目的"交易品ID",將此ID跟隨第x條目的點選事件進行上報即可.

例項2:介面資料獲取
  同樣時圖4-1所示,加入我們想在列表中條目點選時,將條目中展示的"最新價"跟隨點選事件上報.此處DataPath該怎麼寫?
  紅框所示ViewTree子樹如下:

Android無埋點資料收集SDK關鍵技術
圖4-2 列表Item ViewTree子樹結構

  如上圖,選中部分是列表的ItemView(RelativeLayout),可見"最新價"是由index為2的TextView所展示,由此可得,列表中條目點選獲取"最新價"資料的DataPath如下:

this.childAt(2).mText複製程式碼

  DataPath解釋:

  1. 起點為"this",表示當前被點選的view例項(圖4-2中被選中的RelativeLayout)
  2. "childAt(2)"表示RelativeLayout.getChildAt(2),得到圖4-2中index為2的TextView
  3. "mText" 表示取出步驟2中得到TextView例項的mText欄位(TextView控制元件顯示的文字內容儲存在mText欄位內)
  4. 將取出的介面上顯示的"最新價"資料新增到原始點選事件中,一起上報.

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過程中遭遇過很多比較有意思的技術問題,後續也會陸續進行整理.

相關文章