在發展突飛猛進的移動互聯網時代,數據扮演着極其重要的角色。埋點做爲一種最簡單最直接的用戶行爲統計方式,可以全面精確的採集用戶的使用習慣以及各功能點的迭代反饋等等,有了這些數據才能更好的驅動產品的決策設計和新業務場景的規劃。本文旨在提出一種輕量級非侵入式的埋點方案,其主要有如下三方面優點git
該方案經過維護一個JSON
文件來指定埋點所在的類和方法,繼而利用AOP
的方式在對應的類和方法執行時動態嵌入埋點代碼。對於須要邏輯判斷來肯定埋點值的場景,提供hook
方法的入參,以及所在類的屬性值讀取,根據相應的狀態值設置不一樣的埋點github
埋點配置JSON
表中包含須要hook
的類名class
和具體的事件event
信息,event
中包括hook
的方法和對應的埋點值。以下所示json
{ "version": "0.1.0", "tracking": [ { "class": "RJMainViewController", "event": { "rj_main_tracking": [ "tripTypeViewChangedWithIndex:", "tripLabClickWithLabKey:" ], "user_fp_slide_click": "clickNavLeftBtn", "user_fp_reflocate_click": "clickLocationBtn" } }, { "class": "RJTripHistoryViewModel", "event": { "user_mytrip_show": "tableView:didSelectRowAtIndexPath:" } }, { "class": "RJTripViewController", "event": { "rj_trip_tracking": "callServiceEvent" } } ] } 複製代碼
簡單來講就是原本埋點須要手動在該方法寫入埋點代碼來記錄埋點值,如今經過AOP
的方式物理隔離埋點代碼和業務代碼,避免埋點的邏輯侵入污染業務邏輯。埋點包括固定埋點和須要邏輯判斷的場景化埋點,固定埋點以下所示數組
{ "class": "RJTripHistoryViewModel", "event": { "user_mytrip_show": "tableView:didSelectRowAtIndexPath:" } } 複製代碼
RJTripHistoryViewModel
爲類名,tableView:didSelectRowAtIndexPath:
爲須要hook
的該類中的方法,而user_mytrip_show
則是具體的埋點值,也就是當RJTripHistoryViewModel
中的tableView:didSelectRowAtIndexPath:
方法執行的時候記錄埋點值user_mytrip_show
bash
{ "class": "RJTripViewController", "event": { "rj_trip_tracking": "callServiceEvent" } }, 複製代碼
對於場景化埋點,則須要提供一個impl
類來提供相應的邏輯判斷。好比上述配置表中的rj_trip_tracking
爲場景埋點的實現類,在該類中根據狀態量返回對應的埋點值,即當callServiceEvent
方法執行時會去找rj_trip_tracking
這個埋點impl
同名類,取該類返回的埋點值記錄埋點。須要注意到是event
中的key
值既能夠做爲埋點值也能夠做爲impl
的類名,埋點庫會首先判斷是否存在對應的類,存在即認爲是impl
實現類,從該類中取具體的埋點值。反之,則認爲是固定埋點值服務器
配置表中的類名和方法名須要對應,在
hook
的時候會去匹配,若是發現類中不存在對應的方法,則會自動觸發斷言markdown
對於固定的埋點,只須要在對應的方法執行時直接記錄埋點,利用Aspects來hook
指定的類和方法,代碼以下所示網絡
[class aspect_hookSelector:sel withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> info) { [events enumerateObjectsUsingBlock:^(NSString *name, NSUInteger idx, BOOL *stop) { NSLog(@"<RJEventTracking> - %@", ename); }]; } error:&error]; 複製代碼
爲了便於檢測無效的埋點,還需對hook
的類和方法進行匹配校驗,若類中沒有對應的方法,則拋出斷言ide
+ (void)checkValidWithClass:(NSString *)class method:(NSString *)method { SEL sel = NSSelectorFromString(method); Class c = NSClassFromString(class); BOOL respond = [c respondsToSelector:sel] || [c instancesRespondToSelector:sel]; NSString *err = [NSString stringWithFormat:@"<RJEventTracking> - no specified method: %@ found on class: %@, please check", method, class]; NSAssert(respond, err); } 複製代碼
場景化埋點主要爲同一事件可是在多種狀態或邏輯下不一樣埋點的狀況,好比同是聯繫客服的操做,在各類訂單類型以及訂單狀態下所設置的埋點是不一樣的。這個狀況下,埋點庫經過提供一個protocol
由埋點impl
類來實現,根據不一樣的邏輯判斷,返回對應的埋點值函數
@protocol RJEventTracking <NSObject>
- (NSString *)trackingMethod:(NSString *)method instance:(id)instance arguments:(NSArray *)arguments;
@end
複製代碼
好比上文的rj_trip_tracking
類須要遵循RJEventTracking
協議,並根據相關邏輯判斷返回對應的埋點值
埋點實現類的類名須要與埋點配置
JSON
中的event
裏的key
保持一致,由於埋點庫會經過檢測是否有同名的類來實現插件式的埋點規則。另外,一個impl
能夠對應多個method
方法
根據狀態量來肯定埋點值。仍是聯繫客服埋點的例子,根據訂單種類和訂單狀態來返回對應的埋點值,首先定義JSON
表中同名的impl
類,並遵循RJEventTracking
協議
#import "RJEventTracking.h" NS_ASSUME_NONNULL_BEGIN @interface rj_trip_tracking : NSObject <RJEventTracking> @end NS_ASSUME_NONNULL_END 複製代碼
在.m文件中實現自定義埋點的協議方法trackingMethod:instance:arguments:
#import "rj_trip_tracking.h" @implementation rj_trip_tracking - (NSString *)trackingMethod:(NSString *)method instance:(id)instance arguments:(NSArray *)arguments { id dataManager = [instance property:@"dataManager"]; NSInteger orderStatus = [[dataManager property:@"orderStatus"] integerValue]; NSInteger orderType = [[dataManager property:@"orderType"] integerValue]; if ([method isEqualToString:@"callServiceEvent"]) { if (orderType == 1) { if (orderStatus == 1) { return @"user_inbook_psgservice_click"; } else if (orderStatus == 2) { return @"user_finishbook_psgservice_click"; } } else { return @"user_psgservice_click"; } } return nil; } @end 複製代碼
在協議方法中,能夠獲取當前的實例(在這個示例下爲RJTripViewController
)和入參數組。訂單的類型和狀態是存儲在RJTripViewController
中的dataManager
屬性中的,因此能夠經過埋點庫封裝好的property:
方法來獲取屬性值,並根據屬性值返回對應的埋點名稱
@interface NSObject (RJEventTracking)
- (id)property:(NSString *)property;
@end
複製代碼
屬性值讀取的實現爲
- (id)property:(NSString *)property { return [NSObject runMethodWithObject:self selector:property arguments:nil]; } 複製代碼
其中的原理很簡單,就是將getter
方法封裝到NSInvocation
中並invoke
讀取返回值便可
+ (id)runMethodWithObject:(id)object selector:(NSString *)selector arguments:(NSArray *)arguments { if (!object) return nil; if (arguments && [arguments isKindOfClass:NSArray.class] == NO) { arguments = @[arguments]; } SEL sel = NSSelectorFromString(selector); NSMethodSignature *signature = [object methodSignatureForSelector:sel]; if (!signature) { return nil; } NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature]; invocation.selector = sel; invocation.arguments = arguments; [invocation invokeWithTarget:object]; return invocation.returnValue_obj; } 複製代碼
須要根據JSON
中設置的所hook
方法的入參來肯定埋點名稱的狀況。好比在訂單列表中點擊所有,進行中,待支付,待評價,已完成等菜單項時分別埋點。被hook
的方法爲tripLabClickWithLabKey:
其參數爲UILabel
,原先代碼中經過Label
的tag
判斷是點擊的哪一個子項,一樣,咱們也能夠獲取到Label
的入參而後據此判斷。因爲參數只有一個,因此能夠直接取arguments
第一個值
#import "rj_main_tracking.h" #import <UIKit/UIKit.h> static NSString *order_types[5] = { @"user_order_all_click", @"user_order_ongoing_click", @"user_order_unpay_click", @"user_order_unmark_click", @"user_order_finish_click" }; @implementation rj_main_tracking - (NSString *)trackingMethod:(NSString *)method instance:(id)instance arguments:(NSArray *)arguments { if ([method isEqualToString:@"tripLabClickWithLabKey:"]) { UILabel *label = arguments[0]; if (!label || label.tag > 4) { return nil; } return order_types[label.tag]; } else if ([method isEqualToString:@"tripTypeViewChangedWithIndex:"]) { return @"xx_ryan_jin"; } } @end 複製代碼
經過AOP
來hook
方法時,能夠獲取到當前hook
方法所對應的實例對象和入參,在調用協議方法時,直接傳給協議實現類
和讀取屬性值相似,也是在不一樣場景下同一事件不一樣埋點名稱的狀況,但獲取的狀態量不是當前實例對象的,而是某個方法的返回值,這種狀況下能夠經過埋點庫提供的方法調用函數來實現
@interface NSObject (RJEventTracking)
- (id)performSelector:(NSString *)selector arguments:(nullable NSArray *)arguments;
@end
複製代碼
好比獲取某個頁面的視圖類型,而這個視圖類型存儲於單例對象中
[RJViewTypeModel sharedInstance].viewType
複製代碼
該場景下則根據viewType的類型,來返回相應的埋點名稱
- (NSString *)trackingMethod:(NSString *)method instance:(id)instance arguments:(NSArray *)arguments { NSString *labKey = [instance property:@"labKey"]; id viewTypeModel = [NSClassFromString(@"RJViewTypeModel") performSelector:@"sharedInstance" arguments:nil]; NSInteger viewType = [[viewTypeModel property:@"viewType"] integerValue]; if (viewType == 0) { if ([labKey isEqualToString:@"rj_view_begin_add"]) { return @"user_fp_book_on_click"; } if ([labKey isEqualToString:@"rj_view_end_add"]) { return @"user_fp_book_off_click"; } } if (viewType == 1) { if ([labKey isEqualToString:@"rj_view_begin_add"]) { return @"user_fr_on_click"; } if ([labKey isEqualToString:@"rj_view_end_add"]) { return @"user_fr_off_click"; } } return nil; } 複製代碼
須要額外添加邏輯判斷的場景,好比在訂單詳情頁須要統計用戶進入頁面的查看行爲,可是詳情頁的類型須要在網絡請求後才能獲取,並且該網絡請求會定時觸發,因此埋點hook
的方法會走屢次,該狀況下,須要添加一個屬性用來標記是否已記錄埋點 。故而埋點庫須要提供動態添加屬性的功能
@interface NSObject (RJEventTracking)
- (id)extraProperty:(NSString *)property;
- (void)addExtraProperty:(NSString *)property defaultValue:(id)value;
@end
複製代碼
在埋點實現impl
類裏面,添加額外的屬性來標記是否已記錄過埋點
@implementation user_orderdetail_show - (NSString *)trackingMethod:(NSString *)method instance:(id)instance arguments:(NSArray *)arguments { if ([instance extraProperty:@"isRecorded"]) { return nil; } [instance addExtraProperty:@"isRecorded" defaultValue:@(YES)]; return @"user_orderdetail_show"; } @end 複製代碼
使用addExtraProperty:defaultValue:
來給當前實例動態添加屬性,而extraProperty:
方法則用來獲取實例的某個額外屬性。若是isRecorded
返回YES
表明已經記錄過該埋點,返回nil
值來忽略該次埋點
上面示例中添加的isRecorded屬性是由於埋點的需求,和業務邏輯無關,因此比較合理的方式是在埋點的插件
impl
類中添加,避免影響業務代碼
埋點庫動態添加屬性的原理也很簡單,利用runtime
的objc_setAssociatedObject
和objc_getAssociatedObject
方法來綁定屬性到實例對象
- (id)extraProperty:(NSString *)property { return objc_getAssociatedObject(self, NSSelectorFromString(property)); } - (void)addExtraProperty:(NSString *)property defaultValue:(id)value { objc_setAssociatedObject(self, NSSelectorFromString(property), value, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } 複製代碼
埋點JSON
配置表能夠由服務器提供接口,客戶端在每次啓動時經過接口獲取最新埋點配置表,從而達到動態下發的目的,客戶端拿到JSON
後,讀取埋點信息並生效
[RJEventTracking loadConfiguration:[[NSBundle mainBundle] pathForResource:@"RJUserTracking" ofType:@"json"]]; 複製代碼
讀取的代碼以下所示,主要邏輯爲遍歷埋點中的類和hook
的方法,並檢測是固定埋點仍是場景化埋點,對於場景化埋點的狀況查詢是否有對應的埋點impl
實現類。固然,還需檢測JSON
配置表的合法性,每一個類和其中的方法是否匹配
+ (void)loadConfiguration:(NSString *)path { NSData *data = [NSData dataWithContentsOfFile:path]; if (!data) { return; } NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableLeaves error:nil]; NSString *version = dict[@"version"]; NSArray *ts = dict[@"tracking"]; [ts enumerateObjectsUsingBlock:^(NSDictionary *obj, NSUInteger idx, BOOL *stop) { Class class = NSClassFromString(obj[@"class"]); NSDictionary *ed = obj[@"event"]; NSMutableDictionary *td = [NSMutableDictionary dictionaryWithCapacity:0]; [ed enumerateKeysAndObjectsUsingBlock:^(NSString *key, id obj, BOOL *stop) { NSMutableArray *tArr = [NSMutableArray arrayWithCapacity:0]; [tArr addObjectsFromArray:[obj isKindOfClass:[NSArray class]] ? obj : @[obj]]; [tArr enumerateObjectsUsingBlock:^(NSString *m, NSUInteger idx, BOOL *stop) { if ([td.allKeys containsObject:m]) { NSMutableArray *ms = [td[m] mutableCopy]; if (![ms containsObject:key]) [ms addObject:key]; td[m] = ms; } else { td[m] = @[key]; } }]; }]; [td enumerateKeysAndObjectsUsingBlock:^(NSString *kmethod, NSArray <NSString *> *tArr, BOOL *stop) { SEL sel = NSSelectorFromString(kmethod); NSError *error = nil; [self checkValidWithClass:obj[@"class"] method:kmethod]; [class aspect_hookSelector:sel withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> info) { [tArr enumerateObjectsUsingBlock:^(NSString *name, NSUInteger idx, BOOL *stop) { NSString *ename = name; id<RJEventTracking> t = [NSClassFromString(name) new]; if (t && [t respondsToSelector:@selector(trackingMethod:instance:arguments:)]) { ename = [t trackingMethod:kmethod instance:info.instance arguments:info.arguments]; } if ([ename length]) { NSLog(@"<RJEventTracking> - %@", ename); } }]; } error:&error]; [self checkHookStatusWithClass:obj[@"class"] method:kmethod error:error]; }]; }]; } 複製代碼
最後附上源碼地址: github.com/rjinxx/RJEv…
pod 'RJEventTracking' 複製代碼
在使用RJEventTracking的過程當中有遇到什麼問題或者優化建議歡迎留言PR,謝謝。