iOS 無侵入埋點(diǎn)組件總結(jié)
本文轉(zhuǎn)載自微信公眾號(hào)「網(wǎng)羅開發(fā)」,作者Perry_6。轉(zhuǎn)載本文請(qǐng)聯(lián)系網(wǎng)羅開發(fā)公眾號(hào)。
一. 埋點(diǎn)方案
代碼埋點(diǎn)
由開發(fā)人員在觸發(fā)事件的具體方法里,添加多行代碼把需要上傳的參數(shù)上報(bào)至服務(wù)端。
可視化埋點(diǎn)
根據(jù)標(biāo)識(shí)來識(shí)別每一個(gè)事件, 針對(duì)指定的事件進(jìn)行取參埋點(diǎn)。而事件的標(biāo)識(shí)與參數(shù)信息都寫在配置表中,通過動(dòng)態(tài)下發(fā)配置表來實(shí)現(xiàn)埋點(diǎn)統(tǒng)計(jì)。
無埋點(diǎn)
無埋點(diǎn)并不是不需要埋點(diǎn),更準(zhǔn)確的說應(yīng)該是“全埋”, 前端的任意一個(gè)事件都被綁定一個(gè)標(biāo)識(shí),所有的事件都別記錄下來。通過定期上傳記錄文件,配合文件解析,解析出來我們想要的數(shù)據(jù), 并生成可視化報(bào)告 , 因此實(shí)現(xiàn)“無埋點(diǎn)”統(tǒng)計(jì)。
二. 方案選擇
通常業(yè)務(wù)都需要加埋點(diǎn)統(tǒng)計(jì)事件,但在每個(gè)業(yè)務(wù)類里埋點(diǎn)會(huì)導(dǎo)致每個(gè)頁面內(nèi)耦合了大量的無關(guān)業(yè)務(wù)的埋點(diǎn)代碼使得代碼不夠整潔,所以放棄了代碼埋點(diǎn)。
考慮到無埋點(diǎn)成本較高,后期解析也復(fù)雜,選擇了可視化埋點(diǎn),即通過配置事件唯一標(biāo)識(shí),設(shè)置需要埋點(diǎn)分析的業(yè)務(wù)。
2.1 實(shí)現(xiàn)可視化埋點(diǎn)核心問題
- 封裝埋點(diǎn)組件,降低耦合
- 如何實(shí)現(xiàn)后臺(tái)配置唯一標(biāo)識(shí)
- 埋點(diǎn)上報(bào)
2.2 針對(duì)第一個(gè)問題想到的方案如下:
- 每個(gè)業(yè)務(wù)頁面添加一個(gè)埋點(diǎn)類,單獨(dú)將埋點(diǎn)的方法提取到這個(gè)類中。
- 利用 Runtime 在底層進(jìn)行方法攔截,從而添加埋點(diǎn)代碼。
結(jié)合AOP的核心思想:將應(yīng)用程序中的業(yè)務(wù)邏輯同對(duì)其提供支持的通用服務(wù)進(jìn)行分離,最后采用了第2種方案。
2.3 配置唯一標(biāo)識(shí)問題
唯一標(biāo)識(shí)的組成方式主要是又 target + action 來確定, 即任何一個(gè)事件都存在一個(gè) target 與 action。在此引入 AOP 編程,AOP(Aspect-Oriented-Programming) 即面向切面編程的思想,基于 Runtime 的 Method Swizzling 能力,來 hook 相應(yīng)的方法,從而在 hook 方法中進(jìn)行統(tǒng)一的埋點(diǎn)處理。例如所有的按鈕被點(diǎn)擊時(shí),都會(huì)觸發(fā) UIApplication 的 sendAction 方法,我們 hook 這個(gè)方法,即可攔截所有按鈕的點(diǎn)擊事件。
2.3.1 唯一標(biāo)識(shí)(viewPath)的獲?。?/strong>
整個(gè) APP 的視圖結(jié)構(gòu)可以看成是一顆樹(viewTree),樹的根節(jié)點(diǎn)就是 UIWindow,樹的枝干由 UIViewController 及 UIView 組成,樹的葉節(jié)點(diǎn)都是由 UIView 組成。
那么在 viewTree 中用什么信息來表示其中任意一個(gè) view 的位置呢?很容易想到的就是使用目標(biāo) view到根之間的每個(gè)節(jié)點(diǎn)的深度(層次)組成一個(gè)路徑,而節(jié)點(diǎn)的深度(層次)是指此節(jié)點(diǎn)在父節(jié)點(diǎn)中的 index。這樣確實(shí)能夠唯一的表示此 view 了,但是有一個(gè)缺點(diǎn):它的可讀性很差。因此在此基礎(chǔ)上又增加了每個(gè)節(jié)點(diǎn)的名稱,節(jié)點(diǎn)的名稱由當(dāng)前節(jié)點(diǎn)的 view 的類名來表示。同時(shí)在開頭都添加了一個(gè)頁面名稱作為標(biāo)識(shí)。
因此,在 viewTree 中,由一個(gè) view 到根節(jié)點(diǎn)之間的每個(gè)節(jié)點(diǎn)的名稱與深度(層次)共同組成的信息構(gòu)成了此 view 的 viewPath。另外,由于在做 view 的統(tǒng)計(jì)分析時(shí),都是以頁面為單位的,因此 SDK 在生成 viewPath 時(shí),只到 view 所在的 UIViewController 級(jí)別,而非根部的 UIWindow。這樣做也在一定程度上減少了 viewPath 的長(zhǎng)度。
UITableView 和 UICollectionView 的樹級(jí)關(guān)系沒有到每個(gè)具體的 cell,避免產(chǎn)生很多無用的 id,而是將 indexpath 作為描述信息傳入。實(shí)現(xiàn)邏輯如下圖:
2.3.4 唯一標(biāo)識(shí)的作用主要分為兩個(gè)部分
- 事件的鎖定
事件的鎖定主要是靠 “事件唯一標(biāo)識(shí)符”來鎖定,而事件的唯一標(biāo)識(shí)是由我們寫入配置表中的。
- 埋點(diǎn)數(shù)據(jù)的上報(bào)。
埋點(diǎn)數(shù)據(jù)的數(shù)據(jù)又分為兩種類型: 固定數(shù)據(jù)與可變的業(yè)務(wù)數(shù)據(jù), 而固定數(shù)據(jù)我們可以直接寫到配置表中, 通過唯一標(biāo)識(shí)來獲取。而對(duì)于業(yè)務(wù)數(shù)據(jù),數(shù)據(jù)是有持有者的, 例如我們 Controller 的一個(gè)屬性值, 或者數(shù)據(jù)在 Model 的某一個(gè)層級(jí)。就可以通過 KVC 的的方式來遞歸獲取該屬性的值來取到業(yè)務(wù)數(shù)據(jù)。
2.4 埋點(diǎn)上報(bào)
自定義埋點(diǎn)上報(bào)數(shù)據(jù)類型,上報(bào)到 elastic,后臺(tái)進(jìn)行數(shù)據(jù)分析
三. 實(shí)現(xiàn)部分
3.1 SDK 架構(gòu)
3.2 技術(shù)原理
3.2.1 Method-Swizzling
OC 中的方法調(diào)用其實(shí)是向一個(gè)對(duì)象發(fā)送消息 ,利用 OC 的動(dòng)態(tài)性可以實(shí)現(xiàn)方法的交換。
- 用 method_exchangeImplementations 方法來交換兩個(gè)方法中的IMP
- 用 class_replaceMethod 方法來替換類的方法,
- 用 method_setImplementation 方法來直接設(shè)置某個(gè)方法的 IMP
3.2.2 Target-Action
按鈕的點(diǎn)擊事件,UIControl 會(huì)調(diào)用 sendAction:to:forEvent: 來將行為消息轉(zhuǎn)發(fā)到 UIApplication,再由 UIApplication 調(diào)用其 sendAction:to:fromSender:forEvent: 方法來將消息分發(fā)到指定的 target 上。
3.3 分析及實(shí)現(xiàn)
3.3.1 需要添加埋點(diǎn)統(tǒng)計(jì)的地方
- button 相關(guān)的點(diǎn)擊事件
- 頁面進(jìn)入、頁面推出
- tableView 的點(diǎn)擊
- collectionView 的點(diǎn)擊
- 手勢(shì)相關(guān)事件
3.3.2 分析
- 對(duì)于用戶交互的操作,我們使用 runtime 對(duì)應(yīng)的方法 hook 下 sendAction:to:forEvent: 便可以得到進(jìn)行的交互操作。這個(gè)方法對(duì) UIControl 及繼承 UIControl 的子類對(duì)象有效,如:UIButton、UISlider 等。
- 對(duì)于 UIViewController,hook 下 ViewDidAppear: 這個(gè)方法知道哪個(gè)頁面顯示了就足夠了。
- 對(duì)于 tableview 及 collectionview,我們 hook下setDelegate: 方法。檢測(cè)其有沒有實(shí)現(xiàn)對(duì)應(yīng)的點(diǎn)擊代理,因?yàn)? tableView:didSelectRowAtIndexPath: 及 collectionView:didSelectItemAtIndexPath: 是 option 的不是必須要實(shí)現(xiàn)的。
- 對(duì)于手勢(shì),我們?cè)趧?chuàng)建的時(shí)候進(jìn)行 hook,方法為 initWithTarget:action:。
3.3.3 實(shí)現(xiàn)原理
用運(yùn)行時(shí)方法替換方法實(shí)現(xiàn)無侵入的埋點(diǎn)方法。
實(shí)現(xiàn)原理圖:
具體實(shí)現(xiàn)方法:
創(chuàng)建一個(gè)運(yùn)行時(shí)方法替換類 HGMethodSwizzingTool,實(shí)現(xiàn)替換的方法 `swizzingForClass: originalSel: swizzingSel:``
- #import "LZMethodSwizzingTool.h"
- #import <objc/runtime.h>
- @implementation LZMethodSwizzingTool
- + (void)swizzingForClass:(Class)cls originalSel:(SEL)originalSelector swizzingSel:(SEL)swizzingSelector {
- Class class = cls;
- Method originalMethod = class_getInstanceMethod(class, originalSelector);
- Method swizzingMethod = class_getInstanceMethod(class, swizzingSelector);
- BOOL addMethod = class_addMethod(class, originalSelector, method_getImplementation(swizzingMethod), method_getTypeEncoding(swizzingMethod));
- if (addMethod) {
- class_replaceMethod(class, swizzingSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
- } else {
- method_exchangeImplementations(originalMethod, swizzingMethod);
- }
- }
- @end
這個(gè)方法利用運(yùn)行時(shí) method_exchangeImplementations 進(jìn)行交換,當(dāng)原方法被調(diào)用時(shí),就會(huì) hook 到指定的新方法去執(zhí)行。
3.3.4 埋點(diǎn)分類實(shí)現(xiàn)
1. UIViewController+Track(頁面進(jìn)入、頁面推出)
- @implementation UIViewController (Track)
- + (void)initialize {
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- SEL originalWillAppearSelector = @selector(viewWillAppear:);
- SEL swizzingWillAppearSelector = @selector(hg_viewWillAppear:);
- [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalWillAppearSelector swizzingSel:swizzingWillAppearSelector];
- SEL originalWillDisappearSel = @selector(viewWillDisappear:);
- SEL swizzingWillDisappearSel = @selector(hg_viewWillDisappear:);
- [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalWillDisappearSel swizzingSel:swizzingWillDisappearSel];
- SEL originalDidLoadSel = @selector(viewDidLoad);
- SEL swizzingDidLoadSel = @selector(hg_viewDidLoad);
- [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalDidLoadSel swizzingSel:swizzingDidLoadSel];
- });
- }
- - (void)hg_viewWillAppear:(BOOL)animated {
- [self hg_viewWillAppear:animated];
- //埋點(diǎn)實(shí)現(xiàn)區(qū)域
- [self dataTrack:@"viewWillAppear"];
- }
- - (void)hg_viewWillDisappear:(BOOL)animated {
- [self hg_viewWillDisappear:animated];
- //埋點(diǎn)實(shí)現(xiàn)區(qū)域
- [self dataTrack:@"viewWillDisappear"];
- }
- - (void)hg_viewDidLoad {
- [self hg_viewDidLoad];
- //埋點(diǎn)實(shí)現(xiàn)區(qū)域
- [self dataTrack:@"viewDidLoad"];
- }
- - (void)dataTrack:(NSString *)methodName {
- NSString *identifier = [NSString stringWithFormat:@"%@/%@",[[LZFindVCManager currentViewController] class],methodName];
- NSDictionary *eventDict = [[[LZDataTrackTool shareInstance].trackData objectForKey:@"ViewController"] objectForKey:identifier];
- if (eventDict) {
- NSDictionary *useDefind = [eventDict objectForKey:@"userDefined"];
- //預(yù)留參數(shù)配置,以后拓展
- NSDictionary *param = [eventDict objectForKey:@"eventParam"];
- __block NSMutableDictionary *eventParam = [NSMutableDictionary dictionaryWithCapacity:0];
- [param enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
- //在此處進(jìn)行屬性獲取
- id value = [LZCaptureTool captureVarforInstance:self varName:key];
- if (key && value) {
- [eventParam setObject:value forKey:key];
- }
- }];
- if (eventParam.count) {
- NSLog(@"identifier:%@-------useDefind:%@----eventParam:%@",identifier,useDefind,eventParam);
- }
- }
- }
- @end
Category 在 +openTrackSelector() 方法里使用了 HGMethodSwizzingTool 進(jìn)行方法替換,在替換的方法里執(zhí)行需要埋點(diǎn)的方法 - (void)dataTrack:(NSString *)methodName 實(shí)現(xiàn)埋點(diǎn)。這樣每個(gè) UIViewController 生命周期到了 ViewWillAppear 都會(huì)執(zhí)行埋點(diǎn)的方法。
在這里,我們是通過類名 NSStringFromClass([self class]) 來區(qū)分不同的控制器的。
2. UIControl+Track(button相關(guān)的點(diǎn)擊事件)
- @implementation UIControl (Track)
- + (void)initialize {
- static dispatch_once_t onceToken;
- dispatch_once(&onceToken, ^{
- SEL originalSelector = @selector(sendAction:to:forEvent:);
- SEL swizzingSelector = @selector(hg_sendAction:to:forEvent:);
- [LZMethodSwizzingTool swizzingForClass:[self class] originalSel:originalSelector swizzingSel:swizzingSelector];
- });
- }
- - (void)hg_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
- [self hg_sendAction:action to:target forEvent:event];
- //埋點(diǎn)實(shí)現(xiàn)區(qū)域====
- //頁面/方法名/tag用來區(qū)分不同的點(diǎn)擊事件
- NSString *identifier = [NSString stringWithFormat:@"%@/%@/%@", [target class], NSStringFromSelector(action),@(self.tag)];
- if ([target isKindOfClass:[UIView class]]) {
- UIView *view = (id)[target superview];
- while (view.nextResponder) {
- identifier =[NSString stringWithFormat:@"%@/%@",NSStringFromClass(view.class),identifier];
- if ([view.class isSubclassOfClass:[UIViewController class]]) {
- break;
- }
- view = (id)view.nextResponder;
- }
- }
- NSDictionary *eventDict = [[[LZDataTrackTool shareInstance].trackData objectForKey:@"Action"] objectForKey:identifier];
- if (eventDict) {
- NSDictionary *useDefind = [eventDict objectForKey:@"userDefined"];
- //預(yù)留參數(shù)配置,以后拓展
- NSDictionary *param = [eventDict objectForKey:@"eventParam"];
- __block NSMutableDictionary *eventParam = [NSMutableDictionary dictionaryWithCapacity:0];
- [param enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) {
- //在此處進(jìn)行屬性獲取
- id value = [LZCaptureTool captureVarforInstance:target varName:key];
- if (key && value) {
- [eventParam setObject:value forKey:key];
- }
- }];
- NSLog(@"useDefind:%@----eventParam:%@",useDefind,eventParam);
- }
- }
- // UIView 分類
- - (NSString *)obtainSameSuperViewSameClassViewTreeIndexPat
- {
- NSString *classStr = NSStringFromClass([self class]);
- //cell的子view
- //UITableView 特殊的superview (UITableViewContentView)
- //UICollectionViewCell
- BOOL shouldUseSuperView =
- ([classStr isEqualToString:@"UITableViewCellContentView"]) ||
- ([[self.superview class] isKindOfClass:[UITableViewCell class]])||
- ([[self.superview class] isKindOfClass:[UICollectionViewCell class]]);
- if (shouldUseSuperView) {
- return [self obtainIndexPathByView:self.superview];
- }else {
- return [self obtainIndexPathByView:self];
- }
- }
- - (NSString *)obtainIndexPathByView:(UIView *)view
- {
- NSInteger viewTreeNodeDepth = NSIntegerMin;
- NSInteger sameViewTreeNodeDepth = NSIntegerMin;
- NSString *classStr = NSStringFromClass([view class]);
- NSMutableArray *sameClassArr = [[NSMutableArray alloc]init];
- //所處父view的全部subviews根節(jié)點(diǎn)深度
- for (NSInteger index =0; index < view.superview.subviews.count; index ++) {
- //同類型
- if ([classStr isEqualToString:NSStringFromClass([view.superview.subviews[index] class])]){
- [sameClassArr addObject:view.superview.subviews[index]];
- }
- if (view == view.superview.subviews[index]) {
- viewTreeNodeDepth = index;
- break;
- }
- }
- //所處父view的同類型subviews根節(jié)點(diǎn)深度
- for (NSInteger index =0; index < sameClassArr.count; index ++) {
- if (view == sameClassArr[index]) {
- sameViewTreeNodeDepth = index;
- break;
- }
- }
- return [NSString stringWithFormat:@"%ld",sameViewTreeNodeDepth];
- }
- @end
找到點(diǎn)擊事件的方法 sendAction:to:forEvent:,然后在 +openTrackSelector() 方法里使用 HGMethodSwizzingTool 替換新的方法。
和 UIViewController 生命周期埋點(diǎn)不同的是,一個(gè)類中可能有許多不同的 UIButton 子類,相同的 UIButton 子類在不同的視圖中的埋點(diǎn)也要區(qū)分出來,所以我們通過 NSStringFromClass([target class]) + NSStringFromSelector(action) 來區(qū)別,即類名加方法名的格式作為唯一標(biāo)識(shí)。
tableView、collectionView、手勢(shì)的點(diǎn)擊事件與上述實(shí)現(xiàn)方法類似。
3.3.5 埋點(diǎn)配置文件
埋點(diǎn)配置文件通過唯一標(biāo)識(shí)鎖定事件,可以使用 json 文件或 plist 文件,Demo 里就隨便寫了一些測(cè)試數(shù)據(jù),LZDataTrack.json 是直接放在了項(xiàng)目資源里,實(shí)際項(xiàng)目是通過 API 從服務(wù)器下載的配置文件,以實(shí)現(xiàn)實(shí)時(shí)更新埋點(diǎn)配置。
測(cè)試 json 文件:
- {
- "Gesture":{
- "RootViewController/gestureclicked:":{
- "userDefined": {
- "action": "click",
- "pageid": "1234",
- "pageName": "首頁",
- "eventName":"點(diǎn)擊手勢(shì)"
- },
- "eventParam":{
- "spm":"a-b-c-spm",
- "pageName":"",
- "tips":""
- }
- }
- },
- "ViewController":{
- "RootViewController/viewWillAppear":{
- "userDefined": {
- "action": "show",
- "pageid": "1234",
- "pageName": "首頁",
- "eventName":"首頁展示"
- },
- "eventParam":{
- "spm":"",
- "pageName":"",
- "tips":""
- }
- },
- "SecondViewController/viewWillAppear":{
- "userDefined": {
- "action": "show",
- "pageid": "1235",
- "pageName": "靈感頁",
- "eventName":"靈感頁展示"
- },
- "eventParam":{
- "spm":"",
- "pageName":"",
- "tips":""
- }
- }
- },
- "CollectionView":{
- "ThirdViewController/0":{
- "viewcontroller":true,
- "userDefined": {
- "action": "click",
- "pageid": "12345",
- "pageName": "靈感頁",
- "eventName":"點(diǎn)擊collectionview"
- },
- "eventParam":{
- "spm":"a-b-c-spm",
- "pageName":"",
- "tips":""
- }
- }
- },
- "TableView":{
- "SecondViewController/0":{
- "viewcontroller":true,
- "userDefined": {
- "action": "click",
- "pageid": "12345",
- "pageName": "靈感頁",
- "eventName":"點(diǎn)擊tableview"
- },
- "eventParam":{
- "spm":"a-b-c-spm",
- "pageName":"",
- "tips":""
- }
- }
- },
- "Action":{
- "RootViewController/testButtonClick:/0":{
- "userDefined": {
- "action": "click",
- "pageid": "1234",
- "pageName": "首頁",
- "eventName":"點(diǎn)擊測(cè)試按鈕"
- },
- "eventParam":{
- "spm":"a-b-c-spm",
- "pageName":"",
- "tips":""
- }
- },
- "SecondViewController/UIView/UITableView/TableViewCell/testButtonClick:/0":{
- "userDefined": {
- "action": "click",
- "pageid": "1234",
- "pageName": "靈感",
- "eventName":"cell里的點(diǎn)擊測(cè)試按鈕"
- },
- "eventParam":{
- "spm":"a-b-c-spm",
- "pageName":"",
- "tips":""
- }
- }
- }
- }
總結(jié)
使用運(yùn)行時(shí)方法的替換實(shí)現(xiàn)了無侵入埋點(diǎn),但仍存在很多問題,比如唯一標(biāo)識(shí)難以維護(hù)、準(zhǔn)確性有待驗(yàn)證。目前的方式只能實(shí)現(xiàn)頁面進(jìn)、出以及點(diǎn)擊事件的埋點(diǎn)統(tǒng)計(jì),涉及到具體業(yè)務(wù)的埋點(diǎn)統(tǒng)計(jì),比如開機(jī)啟動(dòng)、需要上報(bào)參數(shù)信息等類型的埋點(diǎn)還是要依賴代碼埋點(diǎn)。所以無侵入埋點(diǎn)方案還有很大優(yōu)化空間。