本文首發於 51NB 技術公衆號,原文連接 51信用卡 Android 自動埋點實踐android
隨着公司業務的發展,對業務團隊的敏捷性和創新性提出了更高的要求,而經過大數據的手段在必定程度上能夠幫助咱們實現這個願景,同時良好的數據分析能夠也幫助咱們進行更好更優的決策。對於數據自己,其處理流程主要能夠歸結爲如下幾點:編程
其中所謂的數據採集是針對特定用戶行爲或事件進行捕獲、處理,這一步驟無疑是十分重要的,由於數據採集的準確性和多樣性也會直接對後續的步驟產生影響。本文也主要是討論數據採集的幾種方式,而咱們常說的『埋點』就是數據採集領域的術語,數據採集的方式也能夠說是埋點的幾種方式。數組
目前公司內部主要使用代碼埋點的方式進行數據採集,所謂代碼埋點指的是框架
在某個事件發生時經過預先寫好的代碼來發送數據編程語言
基於預先編碼實現的代碼埋點,其優勢是:控制精準、採集靈活性強,能夠自由的選擇何時發送什麼樣的數據;但缺點也一樣十分明顯,開發、測試成本高,對於客戶端而言須要等待發版才能修改線上的埋點。函數
平常的開發過程當中,常常有同事反饋埋點的錯埋及漏埋,其根本緣由都是代碼埋點自己特色致使,這樣的狀況推進着咱們去嘗試使用其餘埋點方式。工具
無痕埋點也可稱爲無埋點或者全埋點,即在端上自動採集並上報儘量多的數據,在計算時篩選出可用的數據。其優勢是:很大程度上減小開發、測試的重複勞動,數據能夠回溯而且全面。缺點是:採集信息不夠靈活,而且數據量大。佈局
可視化埋點是經過可視化工具選擇須要收集的埋點數據,下發配置給客戶端,從而解析配置採集相應埋點的方式。其優勢是:很大程度上減小開發、測試的重複勞動,數據量可控,能夠在線上動態的進行埋點配置,無需等待 App 發版。其缺點一樣是採集信息不夠靈活,而且沒法解決數據回溯的問題。性能
分析公司經常使用的一些數據指標,咱們發現對於大部分指標而言,咱們只須要有頁面的曝光事件、控件的點擊事件等一些發送時機、內容相對固定的埋點便可,而這部分埋點,偏偏能夠比較方便的使用自動埋點(相對於代碼埋點這種手動埋點來講,無痕埋點及可視化埋點都可被稱爲自動埋點)來進行採集。測試
相對於可視化埋點來講,無痕埋點在前期不須要可視化工具進行埋點收集,SDK 開發投入較小,所以咱們進行了第一步從手動埋點到無痕埋點的迭代。
無痕埋點須要自動採集數據,所以針對頁面、控件等元素須要生成其 ID,該 ID 需儘可能具有『惟一性』和『穩定性』。『惟一性』很是好理解,由於對於任意元素而言,其 ID 應該是與其餘全部元素都不一樣的,這樣咱們才能根據 ID 惟一標識出那個咱們想要的元素,採集上來的數據纔是準確的,不重複的。而『穩定性』則是說,元素的 ID 應儘可能不受版本的變更而改變,這樣後期關聯業務含義的操做纔會更加便捷。
頁面的 ID 較容易定義,參考上文提到的『惟一性』和『穩定性』,咱們很容易就能夠想到將頁面所在類的類名做爲 ID。類名做爲 ID,首先它是相對惟一的,除了頁面複用,不存在其餘類名相同的頁面,而頁面複用的狀況能夠經過頁面標題名稱等方式進行規避;其次它是相對穩定的,只有在頁面類名被修改的狀況下 ID 纔會改變,而咱們平常開發的過程當中,除了一些頁面重大的改版以外不會輕易修改類名。在 Android 中,頁面有兩種類型 Activity 和 Fragment,Fragment 能夠鑲嵌在不一樣的 Activity 內,所以二者的 ID 定義規則有些不一樣:
ActivityClassName|額外參數
ActivityClassName[FragmentClassName]|額外參數
有了頁面的惟一 ID 生成的規則,咱們只須要在頁面曝光的時候,生成這個 ID,而後上傳便可實現頁面的 PV、UV 指標。至於頁面曝光的時機,在 Android 開發中很容易能夠找到,由於對於 Activity 和 Fragment 而言都有標準的生命週期。針對業務中 PV、UV 的定義,咱們能夠將 Activity 的 onResume()
方法,Fragment 的 onResume()
、setUserVisibleHint(boolean isVisibleToUser)
、onHiddenChanged(boolean hidden)
方法做爲曝光時機,在上述方法被回調時,調用 SDK 埋點方法,生成 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 定義,在頁面元素不發生變更的狀況下,基本可以保證『穩定性』和『惟一性』,可是頁面元素髮送動態變化,或者不一樣版本之間 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 是一個基於字節碼操做的 AOP 框架,它容許開發者自由的在一個已經編譯好的類中添加新的方法,或是修改已經存在的方法。可是和其餘的相似庫不一樣的是,Javassist 並不要求開發者對字節碼方面具備多麼深刻的瞭解,一樣的,它也容許開發者忽略被修改的類自己的細節和結構。一個簡單的修改方法體的例子以下:
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 即 Domain-specific language,翻譯爲領域特定語言,意爲在特定領域解決特定任務的語言。
上文提到的自動埋點以頁面和控件爲切入點,hook 頁面曝光和控件點擊事件,並獲取頁面及控件相關信息做爲特徵值寫入埋點。在簡單的場景下,這樣的邏輯尚可勝任,但在某些複雜的場景,好比典型的 banner 輪播、資源位曝光等,控件相同但實際內容不一樣的埋點,沒法根據控件信息來區分。對於手動埋點而言,獲取接口內的信息,而後傳入埋點就能進行區分,可是自動埋點沒法關聯這部分接口信息,因而須要 DSL 來定義簡單的規則,經過運行時的方式來獲取內存中的這部分數據,從而寫入埋點,進行更加精細的區分。
DSL 的構建與編程語言其實比較相似,想一想咱們在從新實現編程語言時,須要作那些事情;實現編程語言的過程能夠簡化爲定義語法與語義,而後實現編譯器或者解釋器的過程,而 DSL 的實現與它也很是相似,咱們也須要對 DSL 進行語法與語義上的設計。總結下來,實現 DSL 總共有這麼兩個須要完成的工做:
這部分實際上是千人千面的,咱們能夠根據本身的業務需求來不斷的迭代,可是核心思路是定義一些特殊的字符串,並對應調用各自的 API,一些簡單的語法大體有如下這些:
.
來標識對象調用,好比 test.a
表示實例 test
中的 a
字段.()
來表示方法調用,好比 test.test()
表示實例 test
中的 test()
方法調用[]
來表示數組或列表說是解釋器,其實只是一段預先寫好在 SDK 內的代碼邏輯。經過預先約定好的語法和語義,業務開發者在可視化平臺針對某個控件進行代碼編寫,而後下發這部分代碼,SDK 根據規則解析這部分代碼,而後經過反射(runtime)的方式來獲取相應的數據並寫入自動埋點。
可視化平臺在元素錄入的時候或者後期編輯的時候,能夠額外錄入事件發生時想要獲取的數據的路徑,這部份內容須要由業務開發人員根據 SDK 這邊給出的規則進行路徑的錄入。成功錄入後,生成配置文件下發至 App。SDK 在事件發生時,獲取到相應事件攜帶的數據路徑,根據 DSL 約定的規則解析路徑並獲取相應的數據,存放至埋點相應字段內上傳。
從最先的手動埋點到後續的無痕埋點,再到可視化管理平臺的搭建,以及 DSL 的實現,一步步的走來咱們能夠看到雖然相比手動埋點而言,自動埋點有許多優點,但一樣其劣勢也很是明顯,即便咱們經過一些工具、技術去不斷的優化和彌補它的不足,但他依舊不能徹底的替代手動埋點。因此結合業務自己的特色,選擇最合適的埋點採集方式纔是最正確的作法,在一些相對穩定,不常變更的頁面、控件中使用自動埋點,能夠極大的節省各個環節的時間;但若是頁面、控件自己是頻繁迭代的那自動埋點就不如手動埋點來的合適。