本文首發於 51NB 技術公眾號,原文連結 51信用卡 Android 自動埋點實踐
背景
隨著公司業務的發展,對業務團隊的敏捷性和創新性提出了更高的要求,而通過大資料的手段在一定程度上可以幫助我們實現這個願景,同時良好的資料分析可以也幫助我們進行更好更優的決策。對於資料本身,其處理流程主要可以歸結為以下幾點:
- 資料採集
- 資料上報
- 資料儲存
- 資料分析
- 資料展示
其中所謂的資料採集是針對特定使用者行為或事件進行捕獲、處理,這一步驟無疑是十分重要的,因為資料採集的準確性和多樣性也會直接對後續的步驟產生影響。本文也主要是討論資料採集的幾種方式,而我們常說的『埋點』就是資料採集領域的術語,資料採集的方式也可以說是埋點的幾種方式。
現狀、痛點
目前公司內部主要使用程式碼埋點的方式進行資料採集,所謂程式碼埋點指的是
在某個事件發生時通過預先寫好的程式碼來傳送資料
基於預先編碼實現的程式碼埋點,其優點是:控制精準、採集靈活性強,可以自由的選擇什麼時候傳送什麼樣的資料;但缺點也同樣十分明顯,開發、測試成本高,對於客戶端而言需要等待發版才能修改線上的埋點。
日常的開發過程中,經常有同事反饋埋點的錯埋及漏埋,其根本原因都是程式碼埋點本身特點導致,這樣的情況推動著我們去嘗試使用其他埋點方式。
業內情況
無痕埋點
無痕埋點也可稱為無埋點或者全埋點,即在端上自動採集並上報儘可能多的資料,在計算時篩選出可用的資料。其優點是:很大程度上減少開發、測試的重複勞動,資料可以回溯並且全面。缺點是:採集資訊不夠靈活,並且資料量大。
視覺化埋點
視覺化埋點是通過視覺化工具選擇需要收集的埋點資料,下發配置給客戶端,從而解析配置採集相應埋點的方式。其優點是:很大程度上減少開發、測試的重複勞動,資料量可控,可以線上上動態的進行埋點配置,無需等待 App 發版。其缺點同樣是採集資訊不夠靈活,並且無法解決資料回溯的問題。
階段一:無痕埋點
分析公司常用的一些資料指標,我們發現對於大部分指標而言,我們只需要有頁面的曝光事件、控制元件的點選事件等一些傳送時機、內容相對固定的埋點即可,而這部分埋點,恰恰可以比較方便的使用自動埋點(相對於程式碼埋點這種手動埋點來說,無痕埋點及視覺化埋點均可被稱為自動埋點)來進行採集。
相對於視覺化埋點來說,無痕埋點在前期不需要視覺化工具進行埋點收集,SDK 開發投入較小,因此我們進行了第一步從手動埋點到無痕埋點的迭代。
無痕埋點技術實現
無痕埋點需要自動採集資料,因此針對頁面、控制元件等元素需要生成其 ID,該 ID 需儘量具備『唯一性』和『穩定性』。『唯一性』非常好理解,因為對於任意元素而言,其 ID 應該是與其他所有元素都不同的,這樣我們才能根據 ID 唯一標識出那個我們想要的元素,採集上來的資料才是準確的,不重複的。而『穩定性』則是說,元素的 ID 應儘量不受版本的變動而改變,這樣後期關聯業務含義的操作才會更加便捷。
頁面ID規則
頁面的 ID 較容易定義,參考上文提到的『唯一性』和『穩定性』,我們很容易就可以想到將頁面所在類的類名作為 ID。類名作為 ID,首先它是相對唯一的,除了頁面複用,不存在其他類名相同的頁面,而頁面複用的情況可以通過頁面標題名稱等方式進行規避;其次它是相對穩定的,只有在頁面類名被修改的情況下 ID 才會改變,而我們日常開發的過程中,除了一些頁面重大的改版之外不會輕易修改類名。在 Android 中,頁面有兩種型別 Activity 和 Fragment,Fragment 可以鑲嵌在不同的 Activity 內,因此兩者的 ID 定義規則有些不同:
- Activity,ID 規則為
ActivityClassName|額外引數
- Fragment,ID 規則為
ActivityClassName[FragmentClassName]|額外引數
頁面PV、UV
有了頁面的唯一 ID 生成的規則,我們只需要在頁面曝光的時候,生成這個 ID,然後上傳即可實現頁面的 PV、UV 指標。至於頁面曝光的時機,在 Android 開發中很容易可以找到,因為對於 Activity 和 Fragment 而言都有標準的生命週期。針對業務中 PV、UV 的定義,我們可以將 Activity 的 onResume()
方法,Fragment 的 onResume()
、setUserVisibleHint(boolean isVisibleToUser)
、onHiddenChanged(boolean hidden)
方法作為曝光時機,在上述方法被回撥時,呼叫 SDK 埋點方法,生成 ID 然後上傳埋點。
- Activity
- Fragment
控制元件ID規則
相對於頁面而言,控制元件的 ID 定義規則要更加複雜。起初我們會想到用『R.id』,在編譯時 Android aapt 會給每個寫在 xml 裡的控制元件生成一個唯一 ID,但是從 aapt 的生成規則來看,這個 ID 並不是固定不變的,在資原始檔發生變化的時候,id 也可能會出現變化,也就是不同版本的相同控制元件的 ID 是有可能不同的。根據 ID 需要具備的『唯一性』和『穩定性』來看,這個 ID 具備『唯一性』,但『穩定性』非常差,因此這個方案不可行。
緊接著我們想到,每個介面所有的控制元件根據其父子關係可以繪製出頁面的檢視樹,從控制元件本身出發,根據控制元件的類名加上其所處層級的位置等特徵資訊,並逐級的向上遍歷,直至找到根節點位置,這樣我們就能得到一個控制元件在該檢視樹中的一個控制元件路徑;反過來說,根據這個控制元件路徑,我們就能在這個檢視樹中唯一確定一個控制元件。下圖是一個簡單的 ViewTree 模型:
根據上文所述控制元件路徑生成規則,對於 Button 而言,其路徑為:FrameLayout[0]/LinearLayout[1]/Button[0]
,在一個頁面中,這個路徑就可以幫我們唯一定位到這個 Button,但是對於不同的頁面而言,還是存在不同的控制元件相同的路徑的情況,因此控制元件 ID 的生成規則應為:『頁面 ID: 控制元件路徑』。
上文頁面 ID 的生成規則中我們說到,對於 Android 來說,頁面有 Activity 和 Fragment 兩種,因為一個 Activity 可以包含不同的 Fragment,所以控制元件如果是存在於 Fragment 中的,則頁面 ID 需要為其所在的 Fragment 的頁面 ID,如果不在 Fragment 中,則包含 Activity 的頁面 ID 即可,那麼如何能夠從控制元件本身的例項獲取到其所在的 Activity 或者 Fragment。對於 Activity 而言比較簡單,我們可以通過如下程式碼實現:
對於 Fragment 則相對比較麻煩,我們只能事先將 Fragment 對應的頁面 ID 和控制元件本身繫結,即通過打 tag 的方式,在 Fragment 的 OnViewCreated 方法中,拿到 Fragment 容器中的根 View,並打上 Fragment 的頁面 ID,然後遍歷該 View,為其所有的子控制元件都打上標記,核心程式碼如下:
所以當我們拿到一個 View 的例項時,我們先看是否能拿到這個 tag 對應的頁面 ID,如果拿不到再去找其所屬的 Activity,然後得到頁面 ID,隨後根據它本身的控制元件路徑,拼湊出控制元件的 ID,核心程式碼如下:
控制元件ID的優化
基於我們上述的控制元件 ID 定義,在頁面元素不發生變動的情況下,基本能夠保證『穩定性』和『唯一性』,但是頁面元素髮送動態變化,或者不同版本之間 UI 進行改版的情況下,我們的控制元件 ID 就會變得不夠穩定,比如以下情況:
在插入一個 FrameLayout 之後,我們 Button 的控制元件路徑就變成了 FrameLayout[0]/LinearLayout[2]/Button[0]
,與之前的 ID 相比,已經發生了改變,變得不那麼『穩定』了,於是我們做了以下的優化:
-
優化1:將兄弟節點中的位置,變成相同型別控制元件的位置。優化後的控制元件路徑為:
FrameLayout[0]/LinearLayout[1]/Button[0]
,即使在插入 FrameLayout 後,其路徑仍舊不變,相較之前會更加穩定一些。但如果插入的是 LinearLayout,或者整個頁面的 UI 進行了重構,控制元件路徑依舊會發生改變。 -
優化2:因為不同的系統版本或手機廠商,會對頁面的根 View 做一定的處理,所以我們需要遮蔽掉這種情況,對於我們而言,我們只關心我們自定義的那部分佈局,即通過 setContentView 傳入的佈局。我們可以通過判斷控制元件 ID 是否等於
android.R.id.content
來獲取我們自定義的佈局的根 View,並將其作為我們控制元件路徑的起點。 -
優化3:在 Android 中,除了
R.id
和控制元件路徑之外,還有一個比較常用的可以作為控制元件 ID 的特徵資訊,那就是開發者寫在佈局檔案中,關聯控制元件的 Resource ID。Resource ID 是開發者自己定義的關聯 View 的標識,在一個頁面當中,理論上是唯一的(為什麼說是理論上,因為還是存在有多個相同 Resource ID 的情況,比如動態的 add 多個 layout,且包含了相同的 Resource ID,但這種情況非常少),並且在頁面的重構過程中,Resource ID 也一般不會修改,因此用 Resource ID 來作為控制元件 ID 是非常合適的。但並不是所有的控制元件都有 Resource ID,我們可以先嚐試去獲取這個 ID,假如 Resource ID 存在,則使用 Resource ID 來作為控制元件 ID,假如 Resource ID 不存在,則降級使用控制元件路徑作為控制元件 ID。核心程式碼如下:
控制元件的點選、長按指標
有了控制元件 ID 的生成規則,控制元件的點選和長按指標我們就能很方便的進行統計,因為在 Android 中,控制元件的點選和長按都有非常標準的回撥函式,即 onClick(View v)
和 onLongClick(View v)
方法。在回撥函式中呼叫 SDK 封裝好的方法,傳入被點選控制元件的 View 物件,通過 View 物件本身的特徵資訊,得到這個控制元件的唯一 ID,然後上傳埋點,即可統計出我們想要的控制元件相關的點選、長按指標。
- 點選
- 長按
程式碼插樁
通過上文的描述,我們得到了頁面和控制元件的 ID 的定義規則,也知道了只需要在相應的回撥函式中寫入 SDK 程式碼獲得我們想要的物件,就能夠計算出我們想要的指標,那麼如何才能自動的往我們現有的工程中寫入獲得物件的程式碼。
在指定的切點插入指定的程式碼,這個業務場景可能很多同學都非常熟悉,我們常用 AOP 的方式來解決這類問題,將所有的程式碼插樁邏輯集中在一個 SDK 內處理,這樣可以最大程度的不侵入業務。
Javassist
Javassist 是一個基於位元組碼操作的 AOP 框架,它允許開發者自由的在一個已經編譯好的類中新增新的方法,或是修改已經存在的方法。但是和其他的類似庫不同的是,Javassist 並不要求開發者對位元組碼方面具有多麼深入的瞭解,同樣的,它也允許開發者忽略被修改的類本身的細節和結構。一個簡單的修改方法體的例子如下:
gradle 外掛
Javassist 需要操作已經編譯好的類,Android 的打包流程從下圖可以瞭解,我們可以在 Java 編譯器編譯完工程程式碼,.class 檔案轉成 dex 之前使用 Javassist 來進行我們需要的程式碼插樁工作。
瞭解過 gradle 外掛的同學可能知道,在 Android Gradle Plugin 版本在 1.5.0 及以上,我們可以使用官方提供的最新的 Transform API,在打包編譯時 .class 打包成 dex 之前對 class 檔案進行處理。具體的自定義外掛過程不在贅述,我們只需要定義一個自己的 Transform,繼承系統的 Transform,重寫 transform 方法即可。
在 transform 方法的第二個引數裡,我們可以獲取到工程內所有的原始碼編譯出來的 .class 檔案以及所有依賴的 jar 包,我們挨個遍歷所有的 .class 檔案,以及解壓縮所有的 jar 包,拿到 jar 包內的 .class 檔案,即可實現對所有的檔案進行程式碼插樁的需求,核心程式碼如下:
拿到 .class 檔案之後,我們會按照上述 Javassist 的工作流程進行程式碼插樁:
- 先根據類名得到
CtClass
物件 - 再根據我們想要尋找的切入點,頁面就找
onResume()
方法,控制元件就找onClick(View view)
方法 - 然後根據方法名和引數型別,得到
CtMethod
物件 - 呼叫
CtMethod
物件的編輯方法體的 API,在原始方法體之前插入就呼叫insertBefore
,之後就呼叫insertAfter
,傳入需要插入的程式碼塊 - 呼叫
CtClass
的writeFile()
方法,儲存這次編輯
將專案中所有的原始檔遍歷一邊後,我們就完成了整個專案程式碼的插樁,在我們想要的切入點(頁面的曝光、控制元件的點選等回撥函式),就成功的插入了相應捕獲頁面、控制元件物件的程式碼,在頁面曝光或者控制元件點選時,就能夠獲得相應的物件,生成唯一 ID 並上報相應的埋點事件,完成整一個無痕埋點的流程了。
階段二:視覺化管理後臺
完成階段一的無痕埋點之後,我們可以通過接入一個 SDK 來輕鬆的實現頁面曝光、控制元件點選等指標的資料獲取,但是通過上文我們可以知道,我們定義的 ID 其實對於業務方(產品、運營、BI 等非業務開發人員)而言是不友好的,他們無法根據 ID 中的類名、Resource ID 等特徵資訊來關聯到埋點具體的業務含義,因此我們需要通過一些工具來幫助他們將埋點元素 ID 和具體的業務含義進行關聯,甚至是跨平臺(Android、iOS 的自動埋點 ID 是不一致的)的關聯。
從另外一個角度來說,有了這樣的視覺化管理後臺,我們還可以通過下發配置表的方式來收集想要的埋點,這其實就是我們開篇說的視覺化埋點。所以有了這樣的管理後臺並基於自動埋點的資料採集方式,我們可以根據具體的業務場景,靈活的選擇是無痕埋點(全量採集)還是視覺化埋點(根據配置表定向採集)。
一個簡單的使用者操作視覺化管理後臺的時序圖如下:
從圖中我們可以知道,視覺化管理後臺的核心內容就是上傳手機介面截圖及控制元件相關資訊,可以讓使用者在後臺對相關的頁面、控制元件與自定義的業務 ID 進行繫結並在後臺生成配置,介面實際效果如下:
在上圖的視覺化管理平臺中,主要有這麼幾大塊內容,最上方是當前和管理後臺建立連線的裝置資訊,左下方是當前介面已經繫結過自定義業務 ID 的埋點後設資料,右下方是手機當前介面在管理平臺上的對映,並標記出介面內所有可埋點的控制元件,已繫結過自定義業務 ID 的控制元件標記綠色,未繫結的標記紅色,這樣使用者就可以非常方便的選擇自己想要的控制元件進行操作。
要實現上圖這樣的效果,我們只需要遍歷當前頁面,並上傳所有可被埋點的控制元件資訊,對於目前我們想要實現的資料指標而言,我們只關心控制元件的點選和長按事件,換句話說就是我們只需要找到當前頁面內所有的可被點選或長按的控制元件即可。
上報控制元件資訊
對於需要上報的控制元件需要滿足以下幾個條件:
- 可被點選或長按
- 在當前介面可見
對於控制元件是否可被點選或長按,我們沒法直接通過系統的 API 來獲取,但是通過原始碼我們可以看到,View 內部還是有私有變數來儲存點選或長按的監聽器的,在 API14 之前的 mOnClickListener 物件和 API14 之後的 mListenerInfo 物件,均可用來判斷當前 View 物件是否被設定了點選監聽函式,我們可以通過反射來拿到這些物件,並進行判斷,長按的判斷也同理,核心程式碼如下:
處理完可被點選或長按的條件後,我們要判斷控制元件在當前介面是否可見,因為我們需要在截圖上把控制元件全選出來,如果控制元件本身是不可見的也被圈出來,使用者就會比較迷茫。通過一定的調研,我們發現滿足以下幾點條件,即表示該控制元件在螢幕內可見:
-
判斷 View 本身可見性屬性
View 本身可見性屬性比較容易判斷,我們只需要判斷
View.isShown()
並且View.getVisibility() == View.VISIBLE
即可。 -
判斷 View 所處的位置是否在當前螢幕內
一個 Activity 載入了多 Fragment 的情況下,可能會出現控制元件本身可見性屬性達標,但實際並不在螢幕內的情況。這種情況我們根據
View.getLocationOnScreen(int[] outLocation)
,然後通過判斷outLocation[0]
,是否大於等於 0 且小於等於螢幕寬度,就能判斷控制元件是否在當前螢幕內。 -
判斷控制元件是否被其他控制元件完全遮擋
遍歷所有與該控制元件有關聯的控制元件(同層控制元件、父控制元件、父控制元件的同層控制元件等),通過
View.getGlobalVisibleRect(Rect viewRect)
來得到控制元件所對應的 Rect 資訊,然後通過Rect.contains(Rect r)
來判斷兩個控制元件對應的 Rect 是否完全包含即可。
控制元件符合上述的可被點選或長按且在當前介面可見這兩個條件,其資訊就會被並上傳至管理後臺,使用者就可以對這個控制元件進行編輯,繫結自定義的業務 ID,管理後臺得到控制元件與自定義業務 ID 的關聯關係後,即可生成配置表,並下發至 App。這樣採集上來的埋點就會帶上自定義業務 ID,使用者在後續的資料使用過程中就可以非常方便的檢視相應的業務指標。
視覺化管理後臺核心的邏輯就是上述的客戶端和管理後臺建立連線並上傳相應資訊,其他配置的生成、下發等都非常容易處理,就不在贅述。
階段三:埋點DSL
文章開頭我們有提到過,無論是無痕埋點還是視覺化埋點,都是基於自動化採集埋點的方式來做的,在這樣的採集方式下,我們無法通過埋點攜帶更多的資訊,這也是我們面臨的一個痛點。基於這樣的需求之下,我們考慮可以用DSL來解決這個問題。
什麼是DSL
DSL 即 Domain-specific language,翻譯為領域特定語言,意為在特定領域解決特定任務的語言。
哪些場景下需要用到DSL
上文提到的自動埋點以頁面和控制元件為切入點,hook 頁面曝光和控制元件點選事件,並獲取頁面及控制元件相關資訊作為特徵值寫入埋點。在簡單的場景下,這樣的邏輯尚可勝任,但在某些複雜的場景,比如典型的 banner 輪播、資源位曝光等,控制元件相同但實際內容不同的埋點,無法根據控制元件資訊來區分。對於手動埋點而言,獲取介面內的資訊,然後傳入埋點就能進行區分,但是自動埋點無法關聯這部分介面資訊,於是需要 DSL 來定義簡單的規則,通過執行時的方式來獲取記憶體中的這部分資料,從而寫入埋點,進行更加精細的區分。
如何實現DSL
DSL 的構建與程式語言其實比較類似,想想我們在重新實現程式語言時,需要做那些事情;實現程式語言的過程可以簡化為定義語法與語義,然後實現編譯器或者直譯器的過程,而 DSL 的實現與它也非常類似,我們也需要對 DSL 進行語法與語義上的設計。總結下來,實現 DSL 總共有這麼兩個需要完成的工作:
- 設計語法和語義,定義 DSL 中的元素是什麼樣的,元素代表什麼意思
- 實現直譯器,對 DSL 解析,最終通過反射(runtime)來執行
設計語法和語義
這部分其實是千人千面的,我們可以根據自己的業務需求來不斷的迭代,但是核心思路是定義一些特殊的字串,並對應呼叫各自的 API,一些簡單的語法大致有以下這些:
- 用
.
來標識物件呼叫,比如test.a
表示例項test
中的a
欄位 - 用
.()
來表示方法呼叫,比如test.test()
表示例項test
中的test()
方法呼叫 - 用
[]
來表示陣列或列表
實現直譯器
說是直譯器,其實只是一段預先寫好在 SDK 內的程式碼邏輯。通過預先約定好的語法和語義,業務開發者在視覺化平臺針對某個控制元件進行程式碼編寫,然後下發這部分程式碼,SDK 根據規則解析這部分程式碼,然後通過反射(runtime)的方式來獲取相應的資料並寫入自動埋點。
平臺配套
視覺化平臺在元素錄入的時候或者後期編輯的時候,可以額外錄入事件發生時想要獲取的資料的路徑,這部分內容需要由業務開發人員根據 SDK 這邊給出的規則進行路徑的錄入。成功錄入後,生成配置檔案下發至 App。SDK 在事件發生時,獲取到相應事件攜帶的資料路徑,根據 DSL 約定的規則解析路徑並獲取相應的資料,存放至埋點相應欄位內上傳。
總結
從最早的手動埋點到後續的無痕埋點,再到視覺化管理平臺的搭建,以及 DSL 的實現,一步步的走來我們可以看到雖然相比手動埋點而言,自動埋點有許多優勢,但同樣其劣勢也非常明顯,即使我們通過一些工具、技術去不斷的優化和彌補它的不足,但他依舊不能完全的替代手動埋點。所以結合業務本身的特點,選擇最合適的埋點採集方式才是最正確的做法,在一些相對穩定,不常變動的頁面、控制元件中使用自動埋點,可以極大的節省各個環節的時間;但如果頁面、控制元件本身是頻繁迭代的那自動埋點就不如手動埋點來的合適。
作者介紹
- 李傳志,51信用卡客戶端基礎組 Android 開發工程師,2017 年加入 51信用卡,目前主要負責端上資料埋點、效能監控等相關基礎建設工作。