原文鏈接:無(wú)侵入的埋點(diǎn)方案如何實(shí)現(xiàn)菩貌?
前言:
原文中介紹了iOS開發(fā)常見的埋點(diǎn)方式:代碼埋點(diǎn)、可視化埋點(diǎn)和無(wú)埋點(diǎn)拉队。其中具體的區(qū)別我會(huì)整理在此篇文章的最后秆乳。
我們可以把可視化埋點(diǎn)和無(wú)埋點(diǎn)歸類為無(wú)侵入埋點(diǎn)
,它的主要實(shí)現(xiàn)原理就是通過(guò)運(yùn)行時(shí)方法替換進(jìn)行埋點(diǎn)
纪铺。
使用無(wú)侵入埋點(diǎn)的方式相速,配合事件唯一標(biāo)志
,可以區(qū)分和記錄項(xiàng)目中絕大多數(shù)的事件鲜锚。
正文:
在iOS開發(fā)中和蚪,埋點(diǎn)對(duì)代碼的侵入是非常嚴(yán)重的止状。而且在早起做埋點(diǎn)的工作時(shí),往往都是采用最原始的方式---手寫代碼在需要埋點(diǎn)的代碼處攒霹。記錄和分析的工作要借助一些第三方工具(例如我們之前做埋點(diǎn)使用的友盟)怯疤。手寫代碼埋點(diǎn)的過(guò)程十分痛苦,版本迭代的時(shí)候催束,舊的埋點(diǎn)代碼也很難維護(hù)和更新集峦,非常痛苦。
使用運(yùn)行時(shí)方法替換事件的實(shí)現(xiàn)抠刺,可以很大程度降低對(duì)代碼的侵入塔淤,下面簡(jiǎn)單介紹一下生命周期
、點(diǎn)擊事件
速妖、cell點(diǎn)擊事件
高蜂、手勢(shì)事件
的method_exchange方式。
你也可以直接查看這個(gè) BuryDemo 中的代碼罕容。
首先备恤,先寫一個(gè)工具類用來(lái)方法的 hook:
+ (void)hookClass:(Class)classObject fromSelector:(SEL)fromSelector toSelector:(SEL)toSelector {
Class cls = classObject;
Method fromMethod = class_getInstanceMethod(cls, fromSelector);
Method toMethod = class_getInstanceMethod(cls, toSelector);
if (class_addMethod(cls, fromSelector, method_getImplementation(toMethod), method_getTypeEncoding(toMethod))) {
class_replaceMethod(cls, toSelector, method_getImplementation(fromMethod), method_getTypeEncoding(fromMethod));
} else {
method_exchangeImplementations(fromMethod, toMethod);
}
}
執(zhí)行這個(gè)方法會(huì)將目標(biāo) 類
中的 兩個(gè)方法
進(jìn)行交換。
上面的方法中需要傳入三個(gè)參數(shù):
1 fromSelector 和 toSelector锦秒,這是兩個(gè)被交換的方法選擇器SEL露泊。
1 classObject,這是上面兩個(gè)方法選擇器SEL所在的類旅择。
這個(gè)方法的執(zhí)行流程:
1 通過(guò)方法選擇器獲取類中的實(shí)例方法 class_getInstanceMethod惭笑。
2 判斷類中 fromSelector 所對(duì)應(yīng)的方法是否存在,如果不存在就創(chuàng)建一個(gè) class_addMethod生真。
3 如果創(chuàng)建成功沉噩,調(diào)用 class_replaceMethod 方法將 fromSelector 替換成 toSelector。
4 如果創(chuàng)建失敗柱蟀,調(diào)用 method_exchangeImplementations 交換上面兩個(gè)方法川蒙。
這是一個(gè)標(biāo)準(zhǔn)的流程。這段代碼可以保存使用产弹。
1 監(jiān)聽頁(yè)面創(chuàng)建和銷毀派歌、停留時(shí)間等
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL fromSelectorAppear = @selector(viewWillAppear:);
SEL toSelectorAppear = @selector(yy_viewWillAppear:);
[YYHook hookClass:self fromSelector:fromSelectorAppear toSelector:toSelectorAppear];
SEL fromSelectorDisappear = @selector(viewWillDisappear:);
SEL toSelectorDisappear = @selector(yy_viewWillDisappear:);
[YYHook hookClass:self fromSelector:fromSelectorDisappear toSelector:toSelectorDisappear];
});
}
- (void)yy_viewWillAppear:(BOOL)animated {
[self yy_viewWillAppear:animated];
NSLog(@"%@ 啟動(dòng)", NSStringFromClass([self class]));
}
- (void)yy_viewWillDisappear:(BOOL)animated {
[self yy_viewWillDisappear:animated];
NSLog(@"%@ 銷毀", NSStringFromClass([self class]));
}
監(jiān)聽頁(yè)面的創(chuàng)建和銷毀只要 hook 住控制器的viewWillAppear:
和viewWillDisappear:
方法即可。其中可以記錄頁(yè)面的打開次數(shù)和停留時(shí)間等等痰哨。
甚至你可以通過(guò)查看控制器是否被銷毀判斷頁(yè)面是否發(fā)生內(nèi)存泄漏胶果,很多檢測(cè)內(nèi)存泄漏的第三方庫(kù)大概就是這個(gè)監(jiān)聽生命周期的原理。
2 點(diǎn)擊事件
iOS中有很多控件的基類均是UIControl斤斧,例如UISwitch開關(guān)早抠、UIButton按鈕、UISegmentedControl分段控件撬讽、UISlider滑塊蕊连、UITextField文本字段控件悬垃、UIPageControl分頁(yè)控件等等。
我們可以通過(guò)監(jiān)聽 UIControl 中的 sendAction:to:forEvent:
方法做很多事甘苍。
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL fromSelector = @selector(sendAction:to:forEvent:);
SEL toSelector = @selector(yy_sendAction:to:forEvent:);
[YYHook hookClass:self fromSelector:fromSelector toSelector:toSelector];
});
}
- (void)yy_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
[self yy_sendAction:action to:target forEvent:event];
NSLog(@"點(diǎn)擊buryTag:%@", self.buryTag);
}
這里我只對(duì) UIButton 進(jìn)行了測(cè)試尝蠕,可以成功監(jiān)聽到 UIButton 的點(diǎn)擊事件。但是這里也遇到了 唯一標(biāo)志
的問(wèn)題载庭。因?yàn)橐粋€(gè)頁(yè)面中可能會(huì)有很多個(gè) button 看彼,那么你在埋點(diǎn)的時(shí)候如何區(qū)分到底是點(diǎn)擊了哪個(gè) button 呢?
原文中老師給出的方案是通過(guò) 控件的視圖樹結(jié)構(gòu)
來(lái)作為唯一標(biāo)志囚聚。通過(guò)視圖的 superview
和 subviews
的屬性系宫。
但是到此為止只解決了不同頁(yè)面中的 button 的區(qū)分牲阁,但是同頁(yè)面的 button 依舊是同一索引,解決這個(gè)問(wèn)題捂襟,我們可以在剛剛的分類中添加一個(gè)屬性標(biāo)簽:
@property (nonatomic, copy) NSString *buryTag;
- (NSString *)buryTag {
return objc_getAssociatedObject(self, @selector(buryTag));
}
- (void)setBuryTag:(NSString *)buryTag {
objc_setAssociatedObject(self, @selector(buryTag), buryTag, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
通過(guò)這個(gè)字符串屬性沃暗,你可以標(biāo)記你想要的信息作為標(biāo)志符漩勤。
注意:分類中添加屬性需要runtime動(dòng)態(tài)關(guān)聯(lián)
3 cell點(diǎn)擊事件
UITableView 是日常開發(fā)中最為常用的控件焕妙,其中監(jiān)聽cell的點(diǎn)擊事件盯腌,我們需要通過(guò) hook setDelegate 方法來(lái)實(shí)現(xiàn)。
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL fromSelector = @selector(setDelegate:);
SEL toSelector = @selector(yy_setDelegate:);
[YYHook hookClass:self fromSelector:fromSelector toSelector:toSelector];
});
}
- (void)yy_setDelegate:(id<UITableViewDelegate>)delegate {
[self yy_setDelegate:delegate];
SEL fromSelector = @selector(tableView:didSelectRowAtIndexPath:);
SEL toSelector = @selector(yy_tableView:didSelectRowAtIndexPath:);
// 檢查 Controller 中是否實(shí)現(xiàn)了 tableView:didSelectRowAtIndexPath: 代理方法
if (![self conformSel:fromSelector inClz:[delegate class]]) {
return;
}
// [YYHook hookClass:[delegate class] fromSelector:fromSelector toSelector:toSelector];
// Method method = class_getInstanceMethod([self class], toSelector);
// class_replaceMethod([delegate class], toSelector, method_getImplementation(method), method_getTypeEncoding(method));
Method method = class_getInstanceMethod([self class], toSelector);
/**
1 給 Controller 添加替換方法 yy_tableView:didSelectRowAtIndexPath:
2 把 Controller 中添加的方法實(shí)現(xiàn)在此分類中
*/
if (class_addMethod([delegate class], toSelector, method_getImplementation(method), method_getTypeEncoding(method))) {
[YYHook hookClass:[delegate class] fromSelector:fromSelector toSelector:toSelector];
}
}
- (void)yy_tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[self yy_tableView:tableView didSelectRowAtIndexPath:indexPath];
/**
這個(gè)方法聲明在 tableView 所在的 Controller 中毒返!
所以通過(guò) [self class] 獲取的是 controller 名稱
*/
NSString *controller = NSStringFromClass([self class]);
NSLog(@"在%@租幕,點(diǎn)擊第%ld個(gè)cell", controller, indexPath.row);
}
#pragma mark --- tools
- (BOOL)conformSel:(SEL)sel inClz:(Class)class {
unsigned int count = 0;
Method *methods = class_copyMethodList(class, &count);
for (int i = 0; i < count; i++) {
Method method = methods[i];
NSString *selString = [NSString stringWithUTF8String:sel_getName(method_getName(method))];
if ([selString isEqualToString:NSStringFromSelector(sel)]) {
return YES;
}
}
return NO;
}
思路解釋:
監(jiān)聽 cell的點(diǎn)擊事件 最終目的肯定是hook didSelectRowAtIndexPath 方法舷手。
1 首先 hook setDelegate 方法拧簸,頁(yè)面調(diào)用這個(gè)方法則說(shuō)明實(shí)現(xiàn)了 UITableViewDelegate 代理,接下來(lái)我們就可以從中 hook didSelectRowAtIndexPath 方法了男窟。
2 因?yàn)?UITableViewDelegate 中的 didSelectRowAtIndexPath 方法并不是強(qiáng)制要求實(shí)現(xiàn)的盆赤。所以在 hook 它之前要先判斷頁(yè)面有沒(méi)有實(shí)現(xiàn)這個(gè)代理方法。
3 在頁(yè)面中添加替換方法 yy_tableView:didSelectRowAtIndexPath:歉眷,但是方法實(shí)現(xiàn)寫在此分類中牺六。
4 交換兩個(gè)方法。
思考:
1 如果先進(jìn)行方法交換汗捡,再利用 class_replaceMethod 直接替換頁(yè)面中的 yy_tableView:didSelectRowAtIndexPath: 是否可行淑际?
4 手勢(shì)事件
對(duì)于iOS中的手勢(shì)事件,我們可以 hook initWithTarget:action: 方法來(lái)實(shí)現(xiàn)無(wú)侵入埋點(diǎn)扇住。但是這其中也會(huì)有一些需要注意的地方春缕,直接看代碼吧。
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
SEL fromSelector = @selector(initWithTarget:action:);
SEL toSelector = @selector(yy_initWithTarget:action:);
[YYHook hookClass:self fromSelector:fromSelector toSelector:toSelector];
});
}
- (instancetype)yy_initWithTarget:(id)target action:(SEL)action {
SEL fromSelector = action;
SEL toSelector = @selector(yy_action:);
UIGestureRecognizer *originGesture = [self yy_initWithTarget:target action:action];
// 1 過(guò)濾 target 和 action 為null的情況
if (!target || !action) {
return originGesture;
}
// 2 過(guò)濾 系統(tǒng)類 調(diào)用的 initWithTarget:action: 方法
NSBundle *mainB = [NSBundle bundleForClass:[target class]];
if (mainB != [NSBundle mainBundle]) {
return originGesture;
}
// 3
Method method = class_getInstanceMethod([self class], toSelector);
if (class_addMethod([target class], toSelector, method_getImplementation(method), method_getTypeEncoding(method))) {
[YYHook hookClass:[target class] fromSelector:fromSelector toSelector:toSelector];
}
NSLog(@"---->>>target: %@", [target class]);
NSLog(@"----<<<action: %@", NSStringFromSelector(action));
self.clazzName = NSStringFromClass([target class]);
self.actionName = NSStringFromSelector(action);
return originGesture;
}
- (void)yy_action:(UIGestureRecognizer *)gesture {
[self yy_action:gesture];
NSLog(@"點(diǎn)擊了%@方法艘蹋,位于:%@", gesture.actionName, gesture.clazzName);
}
在 hook initWithTarget:action: 的時(shí)候需要注意一些問(wèn)題锄贼,因?yàn)?initWithTarget:action: 會(huì)被很多系統(tǒng)類調(diào)用,而且還有很多 target 為 null的情況女阀。如果這里不做過(guò)濾會(huì)嚴(yán)重影響性能宅荤。
因?yàn)槭謩?shì)事件往往會(huì)添加給我們自定義的控件屑迂,所以我這里直接通過(guò)target過(guò)濾了所有系統(tǒng)類。
接下去的操作步驟基本和 hook didSelectRowAtIndexPath 方法的思路一樣冯键。
同樣的惹盼,你可以在分類中給手勢(shì)添加兩個(gè)屬性,用來(lái)作為唯一標(biāo)志惫确。
上面幾種情況的事件監(jiān)控只是給大家提供思路逻锐,具體的應(yīng)用還需要做大量的測(cè)試工作。
你們可以通過(guò) BuryDemo 做一些改進(jìn)雕薪。
最后:
本篇開頭有提到過(guò)主要的代碼埋點(diǎn)有三種方式:代碼埋點(diǎn)昧诱、可視化埋點(diǎn)
、無(wú)埋點(diǎn)所袁。其中可視化埋點(diǎn)和無(wú)埋點(diǎn)都屬于無(wú)侵入埋點(diǎn)的方案盏档。埋點(diǎn)的技術(shù)目前還處于初級(jí)階段,怎么安全又全面的統(tǒng)計(jì)用戶的行為也是一個(gè)很大的課題燥爷。
對(duì)此蜈亩,原文中提到使用 Clang AST的接口,在構(gòu)建時(shí)遍歷 AST 前翎,通過(guò)定義的規(guī)則將所需要的埋點(diǎn)代碼直接加進(jìn)去或許也是一種可行的方式稚配。
最后的最后,希望大家一同進(jìn)步港华。加油道川!~