簡單介紹一下 AOP
無痕埋點最重要的技術是將埋點代碼從業(yè)務代碼中剝離,放到獨立的模塊中的技術。寫業(yè)務的同學只需按照正常的設計思路編寫業(yè)務代碼,寫埋點的同學通過 AOP 技術往業(yè)務代碼中插入埋點代碼。
AOP 全稱叫 Aspect-Oriented Programming,中文名叫面向切面編程。網(wǎng)絡上有關于面向切面編程的名詞解釋。通俗一點,即可使用下圖來說明。
如果希望某個函數(shù)的實現(xiàn)邏輯如下:
而 代碼塊 1
和 代碼塊 2
之間的邏輯毫無干系,是兩塊獨立的邏輯,那么同時寫在一個文件的一個函數(shù)中會造成代碼臃腫,不易維護。
如果能將兩個代碼分離,編寫在不同文件中,即可簡化邏輯,增加代碼的可閱讀性:
上圖為編碼時的代碼邏輯圖,兩份代碼塊寫在兩個不同的方法中。當使用 AOP 技術后,軟件在運行時就會將兩份邏輯合并到一個方法中,變成當初期望的邏輯的樣子。
在 iOS 平臺中,AOP 一般使用基于 ObjC 動態(tài)性質以及消息轉發(fā)的功能實現(xiàn)的。ObjC 的動態(tài)性就是所謂的 Runtime。利用 Runtime 可以對一個類的某個方法進行修改,從而實現(xiàn)運行時的邏輯變化。而消息轉發(fā)則是對面向對象的方法調用的攔截,增加自己特定的處理。Runtime 配合消息轉發(fā)就能實現(xiàn) iOS 平臺上基本的 AOP 需求。
目前,能夠完成 iOS 的 AOP 的庫有:ReactiveCocoa、RxSwift、Aspects 等等。RAC 和 Rx 系列則是為了響應式編程而出現(xiàn)的,他們功能強大,但也很重。如果僅僅只是為了無痕埋點,引入響應式變成則需要很大的成本,也是大材小用。Aspects 則是專門為 AOP 設計的,底層學習蘋果 KVO 實現(xiàn)機制,可以以對象為顆粒進行 AOP。曾經(jīng)我們的項目引入了這個庫,但如果業(yè)務已經(jīng)實現(xiàn)了 - (void)forwardInvocation:(NSInvocation *)invocation
方法,又被 Aspects 進行 AOP 后,業(yè)務的消息轉發(fā)機制就會被覆蓋,造成不可預計的后果*。
*關于 Aspects 的 bug:
在去年使用時,發(fā)現(xiàn) Aspects 與 JSPatch(都使用了
- forwardInvocation:
方法)不兼容。主要表現(xiàn)是當 JSPatch 先生效,Aspects 后生效的情況下,JSPatch 的消息轉發(fā)機制斷裂,無法完成補丁修復,甚至 Crash。而我們在業(yè)務上也用到了
- forwardInvocation:
方法,即使沒有引入 JSPatch,也依舊會出現(xiàn)同樣的問題。在 Issue 里查找了一番,發(fā)現(xiàn)是
class_replaceMethod
的 bug (Merge),但至今都沒有修復這個問題(已并入 master,但是在 tag 1.4.2 之后并入的,而 CocoaPods 上最新發(fā)布的版本是 1.4.1)。
所以在不引入臃腫的三方庫外,如何既安全又準確地使用 AOP 進行無痕埋點,就是本文即將討論的內容。
大部分的無痕埋點實現(xiàn)方案的弊端
在丟棄 Aspects 后,就尋找無痕埋點的解決方案。百度搜索 iOS 無痕埋點
關鍵字,得到的結果幾乎一樣。貼上搜索結果第一篇的地址:簡書 - iOS無痕埋點方案分享探究 by: SandyLoo。
在此先非常感謝作者的分享,此方案給我提供了幾個版本的穩(wěn)定的無痕埋點。
但是在某次埋點 SDK 重構的過程中,發(fā)現(xiàn)了此方案的多處弊端,由于網(wǎng)絡上大部分的無痕埋點方案都與此大同小異,所以就用此例子來分析這系列的方案存在的隱患和缺陷。
方案基本介紹
無痕埋點主要是記錄用戶的操作,而用戶的操作無非就是按鈕的點擊和列表的選擇,所以無痕埋點的需求即是對用戶點擊的響應方法進行 AOP 處理,插入對應埋點方法。
在 UIKit
提供的用戶交互接口里,主要可以分為兩大類:
- Delegate 類(
UITableView
,UICollectionView
的點擊事件,特點是方法名定死,使用weak
屬性持有響應對象) - Target-Action 類(
UIControl
,UIGestureRecognizer
的回調事件,特點是方法名可自定義,方法參數(shù)可有可無,使用weak
屬性持有響應對象,支持多個響應者)
此方案也對這幾種接口提供了不同的 AOP 代碼。
1. UITableView 與 UICollectionView
這兩種對象歸結到第一類中(下文主要講解 UITableView
,UICollectionView
同理就不解釋),業(yè)務通過實現(xiàn) - tableView:didSelectRowAtIndex:
方法來捕獲用戶點擊事件。此方法的方法簽名(由返回值類型和參數(shù)類型編碼而成)因 UITableViewDelegate
的定義而被定死,所以可以很好的完成 AOP 代碼。
- 使用 runtime 對
-[UITableView setDelegate:]
進行方法交換,插入 delegate 的捕獲代碼。 - 當捕獲到 delegate 對象時(一般為 ViewController),獲取該對象的類。
- 構建臨時方法名:
aop_tableView:didSelectRowAtIndex:
,判斷 2 中的類是否有這個方法。 - 如果有,說明此類被處理過,則不繼續(xù)。
- 如果沒有,將預先寫好的 static 函數(shù),通過 runtime 構建新的
Method
實例(方法名是 3 中的方法名),添加到類中。 - 將 5 添加的方法和原方法進行方法交換。
其中步驟 5 中預先寫好的 static 函數(shù)大致如下:
static void AOP_tableView_didSelectRowAtIndex(id<UITableViewDelegate> self, SEL _cmd, UITableView *tableView, NSIndexPath *indexPath) {
// 先調用業(yè)務代碼
SEL origSel = @selector(aop_tableView:didSelectRowAtIndex:);
[self performSelector:origSel withObject:tableView withObject:indexPath];
// 再埋點
[[Tracker sharedTracker] trackEvent:@"xxxxx"];
}
分析一下此方案的弊端:
缺陷 1: 繼承導致重復埋點
如果某個業(yè)務代碼如下:
業(yè)務代碼如果出現(xiàn)了某種代碼執(zhí)行順序:
- 子類實例化
- 父類實例化
則會出現(xiàn)如下情況:
- 捕獲到子類的對象,發(fā)現(xiàn)沒有經(jīng)過 AOP,則進行 AOP 處理,產生如下代碼:
子類對象被 AOP捕獲到父類,發(fā)現(xiàn)沒有經(jīng)過 AOP (步驟 1 是在子類處理,所以父類無法檢測),則進行 AOP 處理,產生如下代碼:
父類對象被 AOP此時,如果 1 實例化出來的子類對象還存在,或者在這之后實例化了新的子類對象,對應的埋點代碼邏輯會執(zhí)行兩次,邏輯如下:
最終子類會執(zhí)行兩次埋點邏輯
缺陷 2: 如果業(yè)務手動執(zhí)行 tableView:didSelectRowAtIndex:
也會觸發(fā)埋點
手動執(zhí)行應當是代碼產生的,而非用戶真實點擊。即使正常開發(fā)不會這么做,但是如果真的這么做了,就會產生一次不必要的埋點數(shù)據(jù)。
缺陷 3: 如果業(yè)務使用了 _cmd 參數(shù),可能取到錯誤的 SEL
上述文章中做了處理,不會有這種問題。但網(wǎng)絡上依舊有使用
performSelector
方法或通過聲明方法然后使用中括號語法來調用原方法代碼,這種方式會導致傳遞給業(yè)務的_cmd
參數(shù)是 AOP 的SEL
,也就是上文的aop_tableView:didSelectRowAtIndexPath:
。如果業(yè)務方用到了這個_cmd
參數(shù),則會出現(xiàn)和預期不一樣的數(shù)據(jù)。
2. UIGestureRecognizer
手勢的回調接口是 target-action,通過添加 target(回調對象) 和 action(對應的回調方法) 對,來完成手勢觸發(fā)的回調。手勢可以歸結到上述分類中的第二類。
和 UITableView
相比,最大的差異是方法名需要動態(tài)獲取,所以需要一個新的 AOP 邏輯:
- 使用 runtime 對
-[UIGestureRecognizer initWithTarget:action:]
進行方法交換,插入捕獲target
和action
的代碼。 - 捕獲到
action
時,添加特殊前綴,得到aop_action
,并判斷target
的類是否擁有aop_action
方法。 - 如果有則說明此
target
對應的類已做 AOP 處理。 - 如果沒有,則通過預先寫好的 static 函數(shù)和
aop_action
創(chuàng)建一個Method
,添加到 target 的類中。 - 將 4 添加的
aop_action
方法和原action
方法進行方法交換。
其中步驟 4 中的方法大致如下:
static void AOP_gestureRecognizerAction(id self, SEL _cmd, UIGestureRecognizer *sender) {
// 調用原方法
NSString *sel = [@"aop_" stringByAppendingString:NSStringFromSelector(_cmd)];
[self performSelector:NSSelectorFromString(sel) withObject:sender];
// 埋點
[[Tracker sharedTracker] trackEvent:@"xxxxx"];
}
缺陷
手勢的缺陷將與 UIControl
的缺陷一并介紹。
3. UIControl
UIControl
和手勢類似,也是使用 target-action 接口來回調控件狀態(tài)變化事件。但這里需要和手勢分開介紹。
與手勢不同的是,UIControl
并不是通過動態(tài)添加方法來完成無痕埋點,而是直接攔截系統(tǒng)方法(調用 action 方法的方法)來完成埋點。具體如下:
- 使用 runtime 對
-[UIControl sendAction:to:forEvent:]
進行方法交換,插入捕獲發(fā)送事件的代碼。 - 捕獲到發(fā)送事件時,埋點。
UIGestureRecognizer 和 UIControl 的缺陷
缺陷1: 沒有攜帶 sender 參數(shù)的 action 可能會導致手勢埋點閃退
由于手勢預先寫好了函數(shù),而函數(shù)的參數(shù)列表包含了手勢本身(也就是通常的 sender 參數(shù)),但是業(yè)務寫的方法不一定都包含 sender 參數(shù),所以這里會有隱患。
缺陷2: 手勢埋點會遺漏使用 addTarget:action: 方法添加的回調
上述文章中只 hook 了初始化方法,并沒有 hook add 方法,所以如果業(yè)務使用 init 方法創(chuàng)建對象,再用 add 方法添加回調,埋點就會遺漏。
但這不是大問題。
缺陷3: 當業(yè)務同時綁定手勢和控件到同一個方法時可能會導致閃退
如果手勢和按鈕綁定到同一個 action 時,并且手勢和按鈕都被當做 sender 參數(shù)傳入到 action 中。當按鈕點擊時,就會觸發(fā)手勢埋點,如果手勢埋點取了手勢的
view
就會閃退:static void AOP_gestureRecognizerAction(id self, SEL _cmd, UIGestureRecognizer *sender) { // 用戶點擊了按鈕,但是還是走了手勢的埋點,并且 sender 是個按鈕對象 // 調用原方法 NSString *sel = [@"aop_" stringByAppendingString:NSStringFromSelector(_cmd)]; [self performSelector:NSSelectorFromString(sel) withObject:sender]; // 取 View 來構建埋點參數(shù) UIView *view = sender.view; // 此處會閃退,因為傳進來可能是 UIButton UIViewController *controller = sender.nextResponse; [[Tracker sharedTracker] trackEvent:@"xxxxx"]; }
手勢和按鈕綁定到同一個 action 的業(yè)務場景舉例:ActionSheet
如果你自己實現(xiàn)了 ActionSheet,你可能會在 ActionSheet 上方陰影部分添加手勢來隱藏 ActionSheet ,同時你也會增加一個取消按鈕,來隱藏 ActionSheet。這兩個綁定的 action 可能會是同一個方法。
缺陷4: 如果缺陷 2 不閃退,UIControl 觸發(fā)事件會導致兩次埋點
即使缺陷 3 沒有導致閃退,UIControl 的埋點在
- sendAction:to:forEvent:
方法中,手勢的埋點在 action 方法中。用戶點擊了按鈕,先執(zhí)行- sendAction:to:forEvent:
的埋點,然后執(zhí)行業(yè)務的 action,也就會執(zhí)行手勢的埋點。這就導致產生了一次垃圾數(shù)據(jù)
缺陷5: 如果業(yè)務手動調用 action 會導致不必要的手勢埋點
如果業(yè)務主動調了一次 action,并非用戶實際操作,手勢埋點替換 action 的方式下也會被埋點。這樣也產生了一次垃圾數(shù)據(jù)。
缺陷6: 移除 target-action 后埋點依舊有效
移除 target-action 時并不能恢復被 runtime 改造的 action 方法,所以埋點依舊生效。
無痕埋點方案實現(xiàn)
為了讓業(yè)務代碼安全無誤的執(zhí)行,我們必須盡可能地不修改業(yè)務代碼,也就是不會對業(yè)務代碼進行 Runtime 處理。
在遵循此原則下,我們設計了如下的無痕埋點解決方案。
Delegate 系列無痕埋點
以 UITableView
介紹 Delegate 的無痕埋點。
在沒有無痕埋點的情況下,Controller
和 UITableView
的持有關系如下:
為了盡可能的不修改 View Controller 的內容,我們?yōu)?delegate 那條虛線添加了一個中間對象 Proxy:
同時為了防止 Proxy 銷毀,我們使用關聯(lián)對象讓 TableView 強引用 Proxy。
為何不是 Controller 強持有 Proxy?
因為一個 table view 只能有一個 delegate,但是一個 controller 可以成為多個 table view 的 delegate
為何 Proxy 是弱引用 View Controller 而不是強引用?
因為不管是 VC 持有 Proxy 還是 TableView 持有 Proxy,只要 Proxy 持有 VC,就會產生循環(huán)引用。
而大體的步驟如下:
- 使用 runtime 對
-[UITabelView setDelegate:]
進行方法交換,替換 setter。 - 創(chuàng)建 Proxy 對象。
- 將 delegate 傳遞給 Proxy 對象。
- 將 Proxy 當做新的 delegate 參數(shù)傳遞給原方法
- 將 TableView 使用關聯(lián)對象強持有 Proxy
而 Proxy 要做的工作:
- 攔截
- tableView:didSelectRowAtIndexPath:
方法,并做埋點,同時將此事件轉發(fā)給 View Controller - 由于攔截了 delegate,就會攔截所有 delegate 方法,所以 Proxy 要模擬 View Controller 對
UITableViewDelegate
協(xié)議中的幾個方法的響應情況。 - 對于 View Controller 實現(xiàn)的方法,需要將事件轉發(fā)給 View Controller。
Proxy 的代碼大致如下:
// self.delegate 就是實際的 View Controller
// 攔截此 delegate 方法
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
if ([self.delegate respondsToSelector:@selector(tableView:didSelectRowAtIndexPath:)]) {
[self.delegate tableView:tableView didSelectRowAtIndexPath:indexPath];
}
// 埋點
}
// 使用以下四個方法來模擬 delegate 其他方法的響應以及轉發(fā)這些方法
- (BOOL)conformsToProtocol:(Protocol *)aProtocol {
BOOL conforms = [super conformsToProtocol:aProtocol];
if (!conforms) {
conforms = [self.delegate conformsToProtocol:aProtocol];
}
return conforms;
}
- (BOOL)respondsToSelector:(SEL)aSelector {
BOOL hasResponds = [super respondsToSelector:aSelector];
hasResponds |= [self.delegate respondsToSelector:aSelector];
return hasResponds;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)selector {
NSMethodSignature *methodSignature = [super methodSignatureForSelector:selector];
if (!methodSignature) {
if ([self.delegate respondsToSelector:selector]) {
methodSignature = [(NSObject *)self.delegate methodSignatureForSelector:selector];
}
}
return methodSignature;
}
- (void)forwardInvocation:(NSInvocation*)invocation {
[invocation invokeWithTarget:self.delegate];
}
經(jīng)過以上處理,ViewController 的代碼無需任何改動,通過中間對象來攔截 - tableView:didSelectRowAtIndexPath:
方法來實現(xiàn)埋點。遵循盡可能不修改業(yè)務代碼的原則。
Target-Action 系列無痕埋點
UIGestureRecognizer 和 UIControl 的埋點實現(xiàn)方案相同,此處僅介紹 UIButton
的無痕埋點。
在沒有無痕埋點的情況下,UIViewController 和 UIButton 的持有關系如下:
同樣,為了不修改 View Controller ,我們也添加了一個對象。但基于 Target-Action 是個集合的關系,這個對象并非中間對象,而是附屬對象:
大體步驟如下:
- 使用 runtime 對
- addTarget:action:
方法交換,插入捕獲代碼 - 當捕獲到時,創(chuàng)建 Action 對象
- 將
[target class]
和action
記錄到 Action 對象中(當做埋點參數(shù)) - 調用原方法,將 target 和 action 添加進 UIButton
- 調用原方法,將 Action 和
action:
添加進 UIButton
Action 對象實現(xiàn)大致如下:
- (void)action:(UIControl *)sender {
// 埋點
}
接下來,當按鈕產生事件后,會依次執(zhí)行 View Controller 的代碼和 Action 的代碼,Action 則可以通過步驟 3 記錄的數(shù)據(jù)來完成埋點。
為了防止 Action 對象銷毀,我們需要拿其中一個對象關聯(lián)住 Action,但是用 View Controller 還是 UIButton 來持有需要進行一系列的場景模擬。
當 UIButton 持有 Action 時:
Button 先于 View Controller 銷毀
Action 銷毀,兩處 target 持有關系斷裂
View Controller 先于 Button 銷毀
Action 沒銷毀,且依舊響應 UIButton 的點擊事件(非預期效果)
當 View Controller 持有 Action 時:
Button 先于 View Controller 銷毀
Action 沒銷毀,兩處 target 持有關系斷裂
View Controller 先于 Button 銷毀
Action 銷毀,兩處 target 持有關系斷裂
通過模擬,發(fā)現(xiàn)使用 View Controller 持有 Action 對象更合適
接下來還有一些細節(jié)要處理:
- View Controller 可以接受多個 Button 的點擊事件,所以關聯(lián)對象的 Key 需要動態(tài)生成一個唯一 Key。可以通過
VC類名.控件類名.方法名.ControlEvent名
來生成 Key。 - UIControl 支持 remove 操作,所以也要 hook remove 方法,刪除 Action 對象。
- UIGestureRecognizer 有
- initWithTarget:action:
方法,也需要被 hook,然后按照- addTarget:action:
同樣的方式處理 - Action 對象可以根據(jù)自己的埋點需求,通過屬性來存儲埋點時需要的參數(shù)。我們用 Action 記錄了 VC 的類名以及方法名稱,供生成埋點唯一 code。
解決的缺陷
現(xiàn)在再回來分析之前的缺陷是否還存在:
缺陷1(解決): 沒有攜帶 sender 參數(shù)的 action 可能會導致手勢埋點閃退
埋點和業(yè)務走兩套 target-action,不會因為是否包含 sender 參數(shù)導致出錯
缺陷2(解決): 手勢埋點會遺漏使用 addTarget:action: 方法添加的回調
不管是手勢還是控件,都可以埋到
addTarget:action:
的方法
缺陷3(解決): 當業(yè)務同時綁定手勢和控件到同一個方法時可能會導致閃退
手勢埋點和控件埋點的 Action 對象完全獨立,互不干擾。自然不會因為 sender 參數(shù)類型不對導致閃退
缺陷4(解決): 如果缺陷 2 不閃退,UIControl 觸發(fā)事件會導致兩次埋點
手勢埋點和控件埋點的 Action 對象完全獨立,互不干擾。所以手勢觸發(fā)時不會執(zhí)行按鈕埋點,按鈕觸發(fā)時不會執(zhí)行手勢埋點。
缺陷5(解決): 如果業(yè)務手動調用 action 會導致不必要的埋點
手勢埋點和控件埋點均沒有修改業(yè)務代碼,所以業(yè)務如果自己調用了 action 也不會觸發(fā)埋點。
缺陷6(解決): 移除 target-action 后埋點依舊有效
增加對 remove 的監(jiān)聽,實現(xiàn)埋點和 target-action 同步增刪。
總結
經(jīng)過分析,最終產生的無痕埋點方案安全高效,沒有大量的方法替換的操作,對象銷毀后 AOP 環(huán)境也會跟著銷毀,并且適應各種業(yè)務場景。
以上就是 iOS 端無痕埋點解決方案 AOP 部分的實現(xiàn)。
再接下來的篇幅中,將會介紹無痕埋點如何生成一個唯一事件標識,以及如何在無痕埋點中攜帶一些業(yè)務數(shù)據(jù)。