打造基於Clang LibTooling的iOS自動打點系統CLAS(一)

1. 手動打點的弊端

在不少ios工程師的平常工做中,不但要對接產品提出的功能性需求,還會收到產品出於數據統計分析需求目的而提出的附帶的隱形需求:統計打點。大多數公司的基礎框架層都會對統計打點功能作高級封裝,工程師只須要在某個操做被觸發的時候在處理的方法內加入一行函數調用便可完成,例如:前端

- (void)btnCloseClicked:(id)sender {
    [MCCStatistic logEvent:@"詳情頁-關閉按鈕-點擊"];
    [self.navigationController popViewController];
}

這個看起來簡單無比的工做,實際上作起來倒是無聊透頂,並且極易出錯。常常會出現App上線後發現有些統計點沒有打上、打錯了地方或者打出了錯別字。漏打點會形成數據分析失真,而打錯點則不但會形成數據失真還會形成數據永久污染,而這些錯誤都須要從新發版才能解決。然而就算從新發版,仍然會有必定比例的用戶不升級,由此形成在幾個月甚至長達半年的時間裏持續性地對數據分析的準確性產生影響。ios

這種手動方式還存在另一個更嚴重的問題:打點的代碼散落分佈在整個工程內,使得打點數據維護起來異常困難。好比若是想知道當前項目內實際打點狀況的彙總,與產品的打點列表進行交叉比對,看是否有遺漏、不一致或者能夠刪除的統計點,這對於工程師來講就是個異常頭疼的難題了。大的項目常常不是由一兩個工程師維護的,各個不一樣的模塊由不一樣的團隊分別負責,彙總這些打點會浪費工程師大量的時間和精力,並且仍然不能保證徹底準確,由於按照上面的方法,若是想找出項目內全部的打點,只能靠搜索打點函數調用的方式去進行,一個工程可能會有幾千個統計點分佈在上千個文件內,稍不留神就會遺漏或出錯,由此產生的彙總的可信度也會大打折扣。編程

2. 手動打點的改進

可能有些同窗會想到用切面編程(AOP)的方式來處理打點。這種方案我在這裏就不贅述了,網上有很多成熟的方案,有興趣的同窗能夠參考。AOP方案功能簡單強大,可以將散落的打點代碼聚合在一塊兒方便維護,可是對於遺忘打點或者打點錯誤這種狀況除了從新發版依然一籌莫展,畢竟打點的文案是在編譯前就肯定的。固然這個問題也能夠解決。把須要打點的文案所有集中在一個配置文件裏,而後給每個統計點起一個獨一無二的常量名字,在AOP的代碼裏只須要以查表的方式獲取真正的文案,這樣配置文件即可以從線上進行熱更新。這種方案看起來很美好,然而操做起來同樣很煩人。給每個頁面或者事件起個名字同樣是個耗時耗力無聊透頂的工做,一樣也容易出錯,這看起來和直接埋點沒有什麼本質區別,惟一的優勢就是能夠把項目或者模塊內全部的點集中到一塊兒來維護更直觀些而已。框架

3. 自動打點方案C.L.A.S.

雖然上面的方案並不完美,可是它給了咱們很大的啓發:有沒有辦法自動爲每一個點擊事件生成一個獨一無二的名字呢?這個問題看起來很難,可是能夠換一種思考方式,若是咱們有辦法爲特定的方法自動生成一個名字並插入打點代碼,那剛纔的AOP方案就已經很好了。爲此咱們就須要藉助Clang所提供的黑科技了。爲了不最終方案過分複雜,咱們在這裏進行了一些條件限定:函數

  1. 適應項目內80%的打點需求
  2. 對現有代碼邏輯無侵入
  3. 對現有編譯工具鏈無侵入

最初,咱們曾經考慮過直接建立一個Clang的Plugin,在內存中對AST直接進行修改,達到動態插入代碼的目的。可是這條路困難重重,AST在Clang官方的定義是通過語法分析器處理後的不可變(immutable)的結果,動態修改AST會形成SourceLocation錯亂,最終致使Codegen在生成IR的時候崩潰。咱們在嘗試了數次以後最終放棄了這個看似「直接」的想法。工具

通過一番權衡後,咱們肯定了基於Clang的LibTooling建立的前端工具對OC源代碼進行分析和插入的方案,將結果寫入中間文件再發送給Clang進行編譯。這個方案咱們後面稱做CLAS,能夠由下圖描述:性能

enter image description here

輸入的.m文件經由CLAS分析和重寫到臨時文件,再傳入Clang進行正常的編譯流程。由於全部對源代碼的改動都發生在臨時文件層面,源文件不會發生任何改動,同時咱們也沒有對Clang的編譯過程作任何干預,因此這個方案能夠理解爲一個對OC源代碼進行特殊預處理的Preprocessor。有了如何插入代碼的工具,那麼爲每個方法起一個響亮而惟一的名字就看起來很簡單了。由於每遇到一個OC的方法,均可以使用OC的類名+擴展名(Category)+方法名(selector)的方式來得到一個惟一的標識,絕對不會重複,不然編譯的時候Clang就會報語法錯誤。優化

插入的打點代碼原則上要保證對性能儘量小的損耗,全局會維護一張Hash表,用來維護名字 --- 打點文案之間的映射關係。這樣作能夠用盡量小的內存大幅提升查詢時間,由於絕大部分名字並無對應的打點文案。這張Hash表由App內置一份,每次發版前由開發人員內置到Bundle內,同時每次App啓動也會嘗試更新這張Hash表支持動態更新映射關係。而插入代碼的具體位置,定位在方法的左大括號後面,與大括號保持同一行,並使用{}進行包圍。這樣能夠保證不破壞下面所提的Debug信息的行數,避免須要從新生成Debug信息的工做量。例如:設計

- (int)calWithA:(int)a andB:(int)b {     {/*插入代碼的位置*/}
    a = a * 2 + b - 3;
    return a;
}

這個方案最大的難題在於在哪些方法上插入代碼。全量插入固然是最簡單粗暴的方法,將項目內的.m文件內全部方法所有打點。這樣作好處很明顯,若是漏打了哪一個方法,能夠經過線上更新的方式補打,可是同時這樣作的壞處也很明顯,不少方法永遠不會須要打點卻被插入了一段毫無用處的代碼影響執行效率,由於被插入代碼的方法,每次執行時都要先去查表看看當前的名字是否有映射的打點文案,若是有則發送打點,不然忽略,雖然查Hash表理論上是個很快的操做,可是若是發生在一些頻繁調用的方法上依然會對系統性能產生負面的影響。爲了不這個問題,咱們能夠規定須要打點的方法只能出如今ViewController、View以及Manager(若是你用MVVM也能夠是ViewModel)裏面,而且排除不太可能須要打點的方法(例如ViewWillLayoutSubviews等),這種規範能夠經過代碼審覈來約束工程師。固然命名規範原本也應該在成熟的項目內強制實施,保證代碼可讀性和質量。若是有些方法寫在了ViewController裏面卻被頻繁調用而且不須要打點,爲了避免影響性能,能夠在方法起始處經過指定__attribute__((clas_ignore))屬性進行強制跳過。這種方式與Clang的__attribute__((always_inline))類似。例如:code

__attribute__((clas_ignore))
- (void)func {
    ....
}

有了這些咱們能夠大幅縮小插入代碼的範圍,減小插入代碼對App性能所形成的影響。

4. CLAS的缺點

就像任何一種方案都有缺點同樣,CLAS也存在着一些明顯的缺點:

沒法適用於條件打點
插入的代碼可能會形成編譯失敗
插入範圍過大
編譯出的文件包含與源文件不符的Debug信息
插入代碼致使二進制體積變大

條件打點通常會出如今邏輯複雜或者內容動態的界面上,好比一個按鈕的點擊事件,在某些狀況下是A,另一些狀況是B,又或者打點的觸發取決於當時場景的條件判斷,這樣動態變化的打點是沒法經過CLAS來完成的。打點的事件不跟隨條件變化的打點咱們稱之爲_靜態打點_。App內大約80%的打點的場景是屬於這種靜態打點的場景,CLAS也是爲靜態打點設計和服務的。

插入範圍過大咱們在3裏面已經討論過了,並有了一些優化的方法。插入代碼可能形成編譯失敗是由於插入的代碼可能須要引用一些在當前.m文件裏沒有引用的其餘頭文件致使編譯過程失敗,這個能夠經過配置CLAS插入用戶指定的#include#import來解決。Debug信息不符的問題比較棘手,由於.m被修改爲臨時文件並經過Clang編譯出.o文件,生成的Debug Symbols是與臨時文件(.clas.m)的信息相符的,與源文件並不相符,這個就須要咱們在生成dSYMs的時候,把全部的臨時文件信息替換爲原始文件信息,爲了達到這個目的,咱們須要修改LLVM的dsymutil替換系統原生的dsymutil。咱們會在接下來的文章裏詳細講解咱們如何構建一套完整的CLAS工具鏈的。

由於插入了大量代碼,編譯後的二進制體積必然會有所增大,因此原則上插入的代碼應該是功能內聚的,一到兩條語句爲佳,避免在插入代碼裏直接構造含有複雜邏輯和功能的語句。例如:

{ [MCStatistik logEvent:@"%__FUNC_NAME__%"]; }

這裏出現了一個%__FUNC_NAME__%看似的怪異名字,這是CLAS所支持的變量替換,以%開始和結束,在插入代碼的時候會自動替換爲對應的值。例如%__FUNC_NAME__%在插入代碼的時候會自動替換爲當前插入位置的函數名。

待續...

在接下來的幾篇文章裏,咱們會詳細介紹如何從零開始一步一步地構建一個基於Clang LibTooling的編譯器前端工具CLAS,敬請期待!

相關文章
相關標籤/搜索