輕量級非侵入式埋點方案

在發展突飛猛進的移動互聯網時代,數據扮演着極其重要的角色。埋點做爲一種最簡單最直接的用戶行爲統計方式,可以全面精確的採集用戶的使用習慣以及各功能點的迭代反饋等等,有了這些數據才能更好的驅動產品的決策設計和新業務場景的規劃。本文旨在提出一種輕量級非侵入式的埋點方案,其主要有如下三方面優點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_showbash

{
    "class": "RJTripViewController",
    "event": {
        "rj_trip_tracking": "callServiceEvent"
    }
},
複製代碼

對於場景化埋點,則須要提供一個impl類來提供相應的邏輯判斷。好比上述配置表中的rj_trip_tracking爲場景埋點的實現類,在該類中根據狀態量返回對應的埋點值,即當callServiceEvent方法執行時會去找rj_trip_tracking這個埋點impl同名類,取該類返回的埋點值記錄埋點。須要注意到是event中的key值既能夠做爲埋點值也能夠做爲impl的類名,埋點庫會首先判斷是否存在對應的類,存在即認爲是impl實現類,從該類中取具體的埋點值。反之,則認爲是固定埋點值服務器

配置表中的類名和方法名須要對應,在hook的時候會去匹配,若是發現類中不存在對應的方法,則會自動觸發斷言網絡

固定埋點

對於固定的埋點,只須要在對應的方法執行時直接記錄埋點,利用Aspectshook指定的類和方法,代碼以下所示ide

[class aspect_hookSelector:sel withOptions:AspectPositionBefore usingBlock:^(id<AspectInfo> info) {
    [events enumerateObjectsUsingBlock:^(NSString *name, NSUInteger idx, BOOL *stop) {
        NSLog(@"<RJEventTracking> - %@", ename);
    }];
} error:&error];
複製代碼

爲了便於檢測無效的埋點,還需對hook的類和方法進行匹配校驗,若類中沒有對應的方法,則拋出斷言函數

+ (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,原先代碼中經過Labeltag判斷是點擊的哪一個子項,一樣,咱們也能夠獲取到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
複製代碼

經過AOPhook方法時,能夠獲取到當前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類中添加,避免影響業務代碼

埋點庫動態添加屬性的原理也很簡單,利用runtimeobjc_setAssociatedObjectobjc_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/RylanJIN/RJ…

pod 'RJEventTracking'
複製代碼

在使用RJEventTracking的過程當中有遇到什麼問題或者優化建議歡迎留言PR,謝謝。

相關文章
相關標籤/搜索