輕量級非侵入式埋點方案

hahadelphi發表於2021-09-09

在發展日新月異的移動網際網路時代,資料扮演著極其重要的角色。埋點作為一種最簡單最直接的使用者行為統計方式,能夠全面精確的採集使用者的使用習慣以及各功能點的迭代反饋等等,有了這些資料才能更好的驅動產品的決策設計和新業務場景的規劃。本文旨在提出一種輕量級非侵入式的埋點方案,其主要有以下三方面優勢

  • 支援動態下發埋點配置
  • 物理隔離埋點程式碼和業務程式碼
  • 外掛式的埋點功能實現

該方案透過維護一個JSON檔案來指定埋點所在的類和方法,繼而利用AOP的方式在對應的類和方法執行時動態嵌入埋點程式碼。對於需要邏輯判斷來確定埋點值的場景,提供hook方法的入參,以及所在類的屬性值讀取,根據相應的狀態值設定不同的埋點

埋點配置

埋點配置JSON表中包含需要hook的類名class和具體的事件event資訊,event中包括hook的方法和對應的埋點值。如下所示

{
    "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

{
    "class": "RJTripViewController",
    "event": {
        "rj_trip_tracking": "callServiceEvent"
    }
},

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

配置表中的類名和方法名需要對應,在hook的時候會去匹配,如果發現類中不存在對應的方法,則會自動觸發斷言

固定埋點

對於固定的埋點,只需要在對應的方法執行時直接記錄埋點,利用來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的類和方法進行匹配校驗,若類中沒有對應的方法,則丟擲斷言

+ (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];
        }];
    }];
}

最後附上原始碼地址:

pod 'RJEventTracking'

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

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/2471/viewspace-2823831/,如需轉載,請註明出處,否則將追究法律責任。

相關文章