iOS無痕埋點方案分享探究
前言
當前網際網路行業的競爭已經是非常激烈了, “功能驅動”的時代已經過去了, 現在更加註重軟體的細節, 以及使用者的體驗問題。 說到使用者體驗,就不得不提到使用者的操作行為。 在我們的軟體中,我們會到處進行埋點, 以便提取到我們想要的資料,進而分析使用者的行為習慣。 通過這些資料,我們也可以更好的分析出使用者的操作趨勢,從而在使用者體驗上把我們的app做的更好。
隨著公司業務的發展,資料的重要性日益體現出來。 資料埋點的全面性和準確性尤為重要。 只有拿到精準並詳細的資料, 後面的分析才有意義。 然後隨著業務的不斷變化, 埋點的動態性也越來越重要。為了解決這些問題, 很多公司都提出自己的解決方案, 各中解決方案中,大體分為以下三種:
-
程式碼埋點
由開發人員在觸發事件的具體方法裡,植入多行程式碼把需要上傳的引數上報至服務端。
-
視覺化埋點
根據標識來識別每一個事件, 針對指定的事件進行取參埋點。而事件的標識與引數資訊都寫在配置表中,通過動態下發配置表來實現埋點統計。
-
無埋點
無埋點並不是不需要埋點,更準確的說應該是“全埋”, 前端的任意一個事件都被繫結一個標識,所有的事件都別記錄下來。 通過定期上傳記錄檔案,配合檔案解析,解析出來我們想要的資料, 並生成視覺化報告供專業人員分析 , 因此實現“無埋點”統計。
由於考慮到“無埋點”的方案成本較高,並且後期解析也比較複雜,加上view_path的不確定性(具體可以參考: iOS%E7%AB%AF%E7%9A%84%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E7%8E%B0/" target="_blank" rel="nofollow,noindex">網易HubbleData無埋點SDK在iOS端的設計與實現 )。所以本文重點分享一個 視覺化埋點 的簡單實現方式。
視覺化埋點
首先,視覺化埋點並非完全拋棄了程式碼埋點,而是在程式碼埋點的上層封裝的一套邏輯來代替手工埋點,大體上架構如下圖:

image
不過要實現視覺化埋點也有很多問題需要解決,比如事件唯一標識的確定,業務引數的獲取,有邏輯判斷的埋點配置項資訊等等。接下來我會重點圍繞唯一標識以及業務引數獲取這兩個問題給出自己的一個解決方案。
唯一標識問題
唯一標識的組成方式主要是又 target + action 來確定, 即任何一個事件都存在一個target與action。 在此引入AOP程式設計,AOP(Aspect-Oriented-Programming)即面向切面程式設計的思想,基於 Runtime 的 Method Swizzling能力,來 hook 相應的方法,從而在hook方法中進行統一的埋點處理。例如所有的按鈕被點選時,都會觸發UIApplication的sendAction方法,我們hook這個方法,即可攔截所有按鈕的點選事件。

image
這裡主要分為兩個部分 :
-
事件的鎖定
事件的鎖定主要是靠 “事件唯一識別符號”來鎖定,而事件的唯一標識是由我們寫入配置表中的。
-
埋點資料的上報。
埋點資料的資料又分為兩種型別: 固定資料 與 可變的業務資料 , 而固定資料我們可以直接寫到配置表中, 通過唯一標識來獲取。而對於業務資料,我是這麼理解的: 資料是有持有者的, 例如我們Controller的一個屬性值, 又或者資料再Model的某一個層級。 這麼的話我們就可以通過KVC的的方式來遞迴獲取該屬性的值來取到業務資料, 程式碼後面會有介紹。
整體程式碼示例
由於iOS中的事件場景是多樣的, 在此我以UIControl, UITablview(collectionView與tableView基本相同), UITapGesture, UIViewController的PV統計 為例,介紹一下具體思路。
-
UIViewController PV統計
頁面的統計較為簡單,利用Method Swizzing hook 系統的viewDidLoad, 直接通過頁面名稱即可鎖定頁面的展示程式碼如下:
@implementation UIViewController (Analysis) +(void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ SEL originalDidLoadSelector = @selector(viewDidLoad); SEL swizzingDidLoadSelector = @selector(user_viewDidLoad); [MethodSwizzingTool swizzingForClass:[self class] originalSel:originalDidLoadSelector swizzingSel:swizzingDidLoadSelector]; }); } -(void)user_viewDidLoad { [self user_viewDidLoad]; //從配置表中取引數的過程 1 固定引數2 業務引數(此處引數被target持有) NSString * identifier = [NSString stringWithFormat:@"%@", [self class]]; NSDictionary * dic = [[[DataContainer dataInstance].data objectForKey:@"PAGEPV"] objectForKey:identifier]; if (dic) { NSString * pageid = dic[@"userDefined"][@"pageid"]; NSString * pagename = dic[@"userDefined"][@"pagename"]; NSDictionary * pagePara = dic[@"pagePara"]; __block NSMutableDictionary * uploadDic = [NSMutableDictionary dictionaryWithCapacity:0]; [pagePara enumerateKeysAndObjectsUsingBlock:^(id_Nonnull key, id_Nonnull obj, BOOL * _Nonnull stop) { id value = [CaptureTool captureVarforInstance:self withPara:obj]; if (value && key) { [uploadDic setObject:value forKey:key]; } }]; NSLog(@"\n 事件唯一標識為:%@ \npageid === %@,\npagename === %@,\n pagepara === %@ \n", [self class], pageid, pagename, uploadDic); } }
-
UIControl 點選統計。
主要通過hook sendAction:to:forEvent: 來實現, 其唯一識別符號我們用 targetname/selector/tag來標記,具體程式碼如下:
@implementation UIControl (Analysis) +(void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ SEL originalSelector = @selector(sendAction:to:forEvent:); SEL swizzingSelector = @selector(user_sendAction:to:forEvent:); [MethodSwizzingTool swizzingForClass:[self class] originalSel:originalSelector swizzingSel:swizzingSelector]; }); } -(void)user_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event { [self user_sendAction:action to:target forEvent:event]; NSString * identifier = [NSString stringWithFormat:@"%@/%@/%ld", [target class], NSStringFromSelector(action),self.tag]; NSDictionary * dic = [[[DataContainer dataInstance].data objectForKey:@"ACTION"] objectForKey:identifier]; if (dic) { NSString * eventid = dic[@"userDefined"][@"eventid"]; NSString * targetname = dic[@"userDefined"][@"target"]; NSString * pageid = dic[@"userDefined"][@"pageid"]; NSString * pagename = dic[@"userDefined"][@"pagename"]; NSDictionary * pagePara = dic[@"pagePara"]; __block NSMutableDictionary * uploadDic = [NSMutableDictionary dictionaryWithCapacity:0]; [pagePara enumerateKeysAndObjectsUsingBlock:^(id_Nonnull key, id_Nonnull obj, BOOL * _Nonnull stop) { id value = [CaptureTool captureVarforInstance:target withPara:obj]; if (value && key) { [uploadDic setObject:value forKey:key]; } }]; NSLog(@" \n唯一識別符號為 : %@, \n event id === %@,\ntarget === %@, \npageid === %@,\npagename === %@,\n pagepara === %@ \n", identifier, eventid, targetname, pageid, pagename, uploadDic); } }
-
TableView (CollectionView) 的點選統計。
tablview的唯一標識, 我們使用 delegate.class/tableview.class/tableview.tag的組合來唯一鎖定。 主要是通過hook setDelegate 方法, 在設定代理的時候再去互動 didSelect 方法來實現, 具體的原理是 具體程式碼如下:
@implementation UITableView (Analysis) +(void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ SEL originalAppearSelector = @selector(setDelegate:); SEL swizzingAppearSelector = @selector(user_setDelegate:); [MethodSwizzingTool swizzingForClass:[self class] originalSel:originalAppearSelector swizzingSel:swizzingAppearSelector]; }); } -(void)user_setDelegate:(id<UITableViewDelegate>)delegate { [self user_setDelegate:delegate]; SEL sel = @selector(tableView:didSelectRowAtIndexPath:); // 初始化一個名字為delegate.class/tableview.class/tableview.tag 的selector SEL sel_ =NSSelectorFromString([NSString stringWithFormat:@"%@/%ld", [self class], self.tag]); // 將生成的selector的方法 加入的 delegate類中, 並且該方法的實現(IMP)指向當前類user_tableView:didSelectRowAtIndexPath: 方法的實現 class_addMethod([delegate class], sel_, method_getImplementation(class_getInstanceMethod([self class], @selector(user_tableView:didSelectRowAtIndexPath:))), nil); //判斷是否有實現,沒有的話新增一個實現 if (![self isContainSel:sel inClass:[delegate class]]) { IMP imp = method_getImplementation(class_getInstanceMethod([delegate class], sel)); class_addMethod([delegate class], sel, imp, nil); } // 將swizzle delegate method 和 origin delegate method 交換 [MethodSwizzingTool swizzingForClass:[delegate class] originalSel:sel swizzingSel:sel_]; } //判斷頁面是否實現了某個sel - (BOOL)isContainSel:(SEL)sel inClass:(Class)class { unsigned int count; Method *methodList = class_copyMethodList(class,&count); for (int i = 0; i < count; i++) { Method method = methodList[i]; NSString *tempMethodString = [NSString stringWithUTF8String:sel_getName(method_getName(method))]; if ([tempMethodString isEqualToString:NSStringFromSelector(sel)]) { return YES; } } return NO; } // 由於我們交換了方法, 所以在tableview的 didselected 被呼叫的時候, 實質呼叫的是以下方法: -(void)user_tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { //通過唯一標識的規則, 找到原來的方法 (即tableView:didSelectRowAtIndexPath: 方法) SEL sel = NSSelectorFromString([NSString stringWithFormat:@"%@/%ld", [tableView class], tableView.tag]); if ([self respondsToSelector:sel]) { //以下是對方法的呼叫以及傳參,performSelector 方法底層實現與此相似 IMP imp = [self methodForSelector:sel]; void (*func)(id, SEL,id,id) = (void *)imp; func(self, sel,tableView,indexPath); } //配置表中, 事件唯一標識即為key, 通過key 取value, 取到了就說明該事件配置的有埋點上傳 NSString * identifier = [NSString stringWithFormat:@"%@/%@/%ld", [self class],[tableView class], tableView.tag]; NSDictionary * dic = [[[DataContainer dataInstance].data objectForKey:@"TABLEVIEW"] objectForKey:identifier]; if (dic) { NSString * eventid = dic[@"userDefined"][@"eventid"]; NSString * targetname = dic[@"userDefined"][@"target"]; NSString * pageid = dic[@"userDefined"][@"pageid"]; NSString * pagename = dic[@"userDefined"][@"pagename"]; NSDictionary * pagePara = dic[@"pagePara"]; UITableViewCell * cell = [tableView cellForRowAtIndexPath:indexPath]; __block NSMutableDictionary * uploadDic = [NSMutableDictionary dictionaryWithCapacity:0]; [pagePara enumerateKeysAndObjectsUsingBlock:^(id_Nonnull key, id_Nonnull obj, BOOL * _Nonnull stop) { NSInteger containIn = [obj[@"containIn"] integerValue]; //通過containIn 引數判斷資料持有者,後續會有解釋 id instance = containIn == 0 ? self : cell; id value = [CaptureTool captureVarforInstance:instance withPara:obj]; if (value && key) { [uploadDic setObject:value forKey:key]; } }]; NSLog(@"\n 事件的唯一標識為 %@, \n event id === %@,\ntarget === %@, \npageid === %@,\npagename === %@,\n pagepara === %@ \n", identifier,eventid, targetname, pageid, pagename, uploadDic); } }
-
gesture方式新增的的點選統計。
gesture的事件,是通過 hook initWithTarget:action: 方法來實現的, 事件的唯一標識依然是target.class/actionname來鎖定的, 程式碼如下:
@implementation UIGestureRecognizer (Analysis) + (void)load { static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ [MethodSwizzingTool swizzingForClass:[self class] originalSel:@selector(initWithTarget:action:) swizzingSel:@selector(vi_initWithTarget:action:)]; }); } - (instancetype)vi_initWithTarget:(nullable id)target action:(nullable SEL)action { UIGestureRecognizer *selfGestureRecognizer = [self vi_initWithTarget:target action:action]; if (!target && !action) { return selfGestureRecognizer; } if ([target isKindOfClass:[UIScrollView class]]) { return selfGestureRecognizer; } Class class = [target class]; SEL originalSEL = action; NSString * sel_name = [NSString stringWithFormat:@"%s/%@", class_getName([target class]),NSStringFromSelector(action)]; SEL swizzledSEL =NSSelectorFromString(sel_name); BOOL isAddMethod = class_addMethod(class, swizzledSEL, method_getImplementation(class_getInstanceMethod([self class], @selector(responseUser_gesture:))), nil); if (isAddMethod) { [MethodSwizzingTool swizzingForClass:class originalSel:originalSEL swizzingSel:swizzledSEL]; } self.name = NSStringFromSelector(action); return selfGestureRecognizer; } -(void)responseUser_gesture:(UIGestureRecognizer *)gesture { NSString * identifier = [NSString stringWithFormat:@"%s/%@", class_getName([self class]),gesture.name]; SEL sel = NSSelectorFromString(identifier); if ([self respondsToSelector:sel]) { IMP imp = [self methodForSelector:sel]; void (*func)(id, SEL,id) = (void *)imp; func(self, sel,gesture); } NSDictionary * dic = [[[DataContainer dataInstance].data objectForKey:@"GESTURE"] objectForKey:identifier]; if (dic) { NSString * eventid = dic[@"userDefined"][@"eventid"]; NSString * targetname = dic[@"userDefined"][@"target"]; NSString * pageid = dic[@"userDefined"][@"pageid"]; NSString * pagename = dic[@"userDefined"][@"pagename"]; NSDictionary * pagePara = dic[@"pagePara"]; __block NSMutableDictionary * uploadDic = [NSMutableDictionary dictionaryWithCapacity:0]; [pagePara enumerateKeysAndObjectsUsingBlock:^(id_Nonnull key, id_Nonnull obj, BOOL * _Nonnull stop) { id value = [CaptureTool captureVarforInstance:self withPara:obj]; if (value && key) { [uploadDic setObject:value forKey:key]; } }]; NSLog(@"\n事件的唯一標識為 %@, \n event id === %@,\ntarget === %@, \npageid === %@,\npagename === %@,\n pagepara === %@ \n", identifier, eventid, targetname, pageid, pagename, uploadDic); } } @end
配置表結構
首先那, 配置表是一個json資料。 針對不同的場景 (UIControl , 頁面PV, Tabeview, Gesture)都做了區分, 用不同的key區別。 對於 "固定引數" , 我們之間寫到配置表中,而對於業務引數, 我們之間寫清楚引數在業務內的名字, 以及上傳時的 keyName, 引數的持有者。 通過Runtime + KVC來取值。 配置表可以是這個樣子:(僅供參考)
說明: json最外層有四個Key, 分別為 ACTION PAGEPV TABLEVIEW GESTURE, 分別對應 UIControl的點選, 頁面PV, tableview cell點選, Gesture 單擊事件的引數。 每個key對應的value為json格式,Json中的keys, 即為唯一識別符號。 識別符號下的json有兩個key : userDefine指的 固定資料, 即直接取值進行上報。 而pagePara為業務引數。 pagePara對應的value也是一個json, json的keys, 即上報的keys, value內的json包含三個引數: propertyName 為屬性名字, containIn 引數只有0 ,1 兩種情況, 其實這個引數主要是為tabview cell的點選取參做區別的,因為點選cell的時候, 上報的引數可能是被target持有,又或者是被cell本身持有 。 當containIn = 0的時候, 取引數時就從target中取值,= 1的時候就從cell中取值。 propertyPath 是一般備選項, 因為有時候從instace內遞迴取值的時候,可能會出現在不同的層級有相同的屬性名字, 此時 propertyPath就派上用處了。 例如有屬性 self.age 和 self.person.age , 其實如果需要self.person.age, 就把 propertyPath的值設為 person/age, 接著在取值的時候就會按照指定路徑進行取值。
{ "ACTION": { "ViewController/jumpSecond": { "userDefined": { "eventid": "201803074|93", "target": "", "pageid": "234", "pagename": "button點選,跳轉至下一個頁面" }, "pagePara": { "testKey9": { "propertyName": "testPara", "propertyPath":"", "containIn": "0" } } } }, "PAGEPV": { "ViewController": { "userDefined": { "pageid": "234", "pagename": "XXX 頁面展示了" }, "pagePara": { "testKey10": { "propertyName": "testPara", "propertyPath":"", "containIn": "0" } } } }, "TABLEVIEW": { "ViewController/UITableView/0":{ "userDefined": { "eventid": "201803074|93", "target": "", "pageid": "234", "pagename": "tableview 被點選" }, "pagePara": { "user_grade": { "propertyName": "grade", "propertyPath":"", "containIn": "1" } } } }, "GESTURE": { "ViewController/controllerclicked:":{ "userDefined": { "eventid": "201803074|93", "target": "", "pageid": "123", "pagename": "手勢響應" }, "pagePara": { "testKey1": { "propertyName": "testPara", "propertyPath":"", "containIn": "0" } } } } }
取參方法
@implementation CaptureTool +(id)captureVarforInstance:(id)instance varName:(NSString *)varName { id value = [instance valueForKey:varName]; unsigned int count; objc_property_t *properties = class_copyPropertyList([instance class], &count); if (!value) { NSMutableArray * varNameArray = [NSMutableArray arrayWithCapacity:0]; for (int i = 0; i < count; i++) { objc_property_t property = properties[i]; NSString* propertyAttributes = [NSString stringWithUTF8String:property_getAttributes(property)]; NSArray* splitPropertyAttributes = [propertyAttributes componentsSeparatedByString:@"\""]; if (splitPropertyAttributes.count < 2) { continue; } NSString * className = [splitPropertyAttributes objectAtIndex:1]; Class cls = NSClassFromString(className); NSBundle *bundle2 = [NSBundle bundleForClass:cls]; if (bundle2 == [NSBundle mainBundle]) { //NSLog(@"自定義的類----- %@", className); const char * name = property_getName(property); NSString * varname = [[NSString alloc] initWithCString:name encoding:NSUTF8StringEncoding]; [varNameArray addObject:varname]; } else { //NSLog(@"系統的類"); } } for (NSString * name in varNameArray) { id newValue = [instance valueForKey:name]; if (newValue) { value = [newValue valueForKey:varName]; if (value) { return value; }else{ value = [[self class] captureVarforInstance:newValue varName:varName]; } } } } return value; } +(id)captureVarforInstance:(id)instance withPara:(NSDictionary *)para { NSString * properyName = para[@"propertyName"]; NSString * propertyPath = para[@"propertyPath"]; if (propertyPath.length > 0) { NSArray * keysArray = [propertyPath componentsSeparatedByString:@"/"]; return [[self class] captureVarforInstance:instance withKeys:keysArray]; } return [[self class] captureVarforInstance:instance varName:properyName]; } +(id)captureVarforInstance:(id)instance withKeys:(NSArray *)keyArray { id result = [instance valueForKey:keyArray[0]]; if (keyArray.count > 1 && result) { int i = 1; while (i < keyArray.count && result) { result = [result valueForKey:keyArray[i]]; i++; } } return result; } @end
結尾
以上是自己的一些想法與實踐, 感覺目前的無痕埋點方案都還是不是很成熟, 不同的公司會有不同的方案, 但是可能大部分還是用的程式碼埋點的方式。 程式碼埋點的侵入性,維護性成本比較大, 尤其是當埋點特別多的時候, 有時候自己幾個月前寫的埋點程式碼,突然需要改,自己都要找半天才能找到。 並且程式碼埋點很致命的一個問題是無法動態更新, 即每次修改埋點,必須重新上線, 有時候上線後產品經理突然跑過來問:為什麼埋點資料不太正常那, 此時你突然發現有一句埋點程式碼寫錯了, 這個時候你要麼承認錯誤,承諾下次加上。要麼趕快緊急上線解決。 通過以上方式,可以實現埋點的動態追加。 配置表可以通過服務端下載, 每次下載後就存在本地, 如果配置表有更新,只需要重新更新配置表就可以解決 。 方案中可能很多細節還需要完善,例如selector方法中存在業務邏輯判斷,即一個識別符號無法唯一的鎖定一個埋點。 這種情況目前用配置表解決的成本較大, 並且業務是靈活的不好控制。 所以以上方案也只是涵蓋了大部分場景, 並非所有場景都適用,具體大家可以根據業務情況來決定使用範圍。
最後, 大家如果有什麼建議,歡迎簡信給我。 我們一起來探討完善這個一個方案。