【原】iOS動態性(五)一種可複用且解耦的用戶統計實現(運行時Runtime)

聲明:本文是本人 編程小翁 原創,轉載請註明。html

爲了達到更好的閱讀效果,強烈建議跳轉到這裏查看文章。git

iOS動態性是個人關於iOS運行時的系列文章,由淺入深,從理論到實踐。本文是第5篇。有興趣能夠看看我以前的文章。github

用戶行爲統計(User Behavior Statistics, UBS)一直是移動互聯網產品中必不可少的環節,也俗稱埋點。在保證移動端流量不會受較大影響的前提下,PM們老是但願埋點覆蓋面越廣越好。目前常規的作法是將埋點代碼封裝成工具類,但凡工程中須要埋點(如點擊事件、頁面跳轉)的地方都插入埋點代碼。一旦項目愈來愈複雜,你會發現埋點的代碼散落在程序的各個角落,不利於維護以及複用。本文旨在探討利用iOS的運行時機制實現一種可復、解耦、容易維護的用戶統計方案。探討畢竟是探討,歡迎到留言討論。本文雖有些長倒是用心之做,但願你有耐心看完。express

注:本文須要一些iOS的Runtime基礎編程

該方案的完成將會用到如下知識:服務器

  • Method Swizzling(Hook)
  • 單元測試

1、常規埋點作法

接着開頭的話題,咱們先回顧一下主流的埋點是怎麼作的。我粗糙地將埋點分爲兩種:一、頁面統計,包括頁面停留時間、頁面進入次數;二、交互事件統計,包括單擊、雙擊、手勢交互等。網絡

1)常規頁面統計埋點

以統計頁面進入次數爲例,最簡單粗暴的作法是在全部頁面的viewDidAppear:以及viewDidDisappear:中分別埋點,將本身對應的pageID上傳給服務端。代碼大概長醬紫:app

@implementation HomeViewController
//...other methods
- (void)viewDidAppear:(BOOL)animated
{
    [super viewWillAppear:animated];
    [WUserStatistics sendEventToServer:@"PAGE_EVENT_HOME_ENTER"];
}

- (void)viewDidDisappear:(BOOL)animated
{
    [super viewDidDisappear:animated];
    [WUserStatistics sendEventToServer:@"PAGE_EVENT_HOME_LEAVE"];
}
@end

+[WUserStatistics sendEventToServer:]封裝網絡請求,將ID上傳給服務器。上述方案有如下弊端:函數

一、複用性差。這部分埋點代碼很難給其餘項目複用
二、工做量大。尤爲當頁面較多時,須要修改的代碼較多
三、引入「髒代碼」,不易維護工具

第3點提到的「髒代碼」意思是用戶行爲分析這種業務其實跟主業務沒太大關係,不該該保持如此高的耦合度,由於這些代碼會干擾咱們對項目主業務的維護。這個我我的見解。

2)常規交互事件埋點

常規作法通常在交互事件的selector中獲取該事件的ID並上傳給服務端,代碼大概長醬紫:

- (IBAction)onFavBtnPressed:(id)sender
{
    [WUserStatistics sendEventToServer:@"CTRL_EVENT_HOME_FAV"];
    //...do other things
}

稍微大一點的APP若是採用這種方式,那諸如此類的埋點代碼將遍地都是。它的缺點參考頁面統計埋點部分,其複用性基本爲零,也就是在新項目中根本沒法複用埋點代碼。

小總結一下,採用常規的作法雖然直觀方便,但在可複用性、可維護性等方面有所欠缺。在我看來,藉助運行時能夠很好地避開這些缺點。

2、Method Swizzling、Hook與代碼注入

因爲Runtime知識不屬於本文的重點,這裏只簡單介紹。
在iOS中,咱們能夠在運行時替換兩個方法的實現,達到「勾住」某個方法並注入代碼的目的。具體作法是:

重載類的「+(void)load」方法,在程序加載到內存時利用Runtime的method_exchangeImplementations等接口將方法(設爲M)的實現互相交換。當方法M被調用時就會被勾住(Hook),執行咱們的方法。

這種技術也稱爲Method Swizzling,屬於面向切面編程(Aspect-Oriented Programming)的一種實現。

替換兩個方法的實現,代碼通常長醬紫:

@interface WHookUtility : NSObject
+ (void)swizzlingInClass:(Class)cls originalSelector:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector;
@end

@implementation WHookUtility

+ (void)swizzlingInClass:(Class)cls originalSelector:(SEL)originalSelector swizzledSelector:(SEL)swizzledSelector
{
    Class class = cls;
    Method originalMethod = class_getInstanceMethod(class, originalSelector);
    Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
    
    BOOL didAddMethod =
    class_addMethod(class,
                    originalSelector,
                    method_getImplementation(swizzledMethod),
                    method_getTypeEncoding(swizzledMethod));
    
    if (didAddMethod) {
        class_replaceMethod(class,
                            swizzledSelector,
                            method_getImplementation(originalMethod),
                            method_getTypeEncoding(originalMethod));
    } else {
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }
}
@end

這個WHookUtility工具類下文會用到。好比如今咱們要勾住UIViewControllerviewWillAppear:方法,能夠這樣作:

@implementation UIViewController (userStastistics)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        SEL originalSelector = @selector(viewWillAppear:);
        SEL swizzledSelector = @selector(swiz_viewWillAppear:);
        [WHookUtility swizzlingInClass:[self class] originalSelector:originalSelector swizzledSelector:swizzledSelector];
    });
}
#pragma mark - Method Swizzling
- (void)swiz_viewWillAppear:(BOOL)animated
{
    //插入須要執行的代碼
    NSLog(@"我在viewWillAppear執行前偷偷插入了一段代碼");
    //不能干擾原來的代碼流程,插入代碼結束後要讓原本該執行的代碼繼續執行
    [self swiz_viewWillAppear:animated];
}
@end

更多關於Runtime、method swizzling、面向切面編程的介紹請參考這裏

3、基於運行時的埋點方案

爲了便於下文敘述,先引入一個簡單的項目,共有兩個頁面(HomeViewControllerDetailViewController),以下:

需求是

  1. 統計兩個頁面的展現與離開次數
  2. 統計收藏、分享單擊事件的次數
  3. 對現有工程代碼影響越小越好

1)統計兩個頁面的展現與離開次數

這部分應該比較直觀了,摒棄掉在每一個controller中埋點的方式,咱們對UIViewController添加category從而Hook到viewWillAppear:viewWillDisappear:。在這兩個方法中注入埋點代碼:

這時候問題來了,項目中每一個頁面都會有本身的頁面事件編號(pageEventID),此處的埋點代碼如何知道要發送什麼pageEventID給服務端呢?輕鬆祭出if-else神器:

- (NSString *)pageEventID:(BOOL)bEnterPage
{
    NSString *selfClassName = NSStringFromClass([self class]);
    NSString *pageEventID = nil;
    if ([selfClassName isEqualToString:@"HomeViewController"]) {
        pageEventID = bEnterPage ? @"EVENT_HOME_ENTER_PAGE" : @"EVENT_HOME_LEAVE_PAGE";
    } else if ([selfClassName isEqualToString:@"DetailViewController"]) {
        pageEventID = bEnterPage ? @"EVENT_DETAIL_ENTER_PAGE" : @"EVENT_DETAIL_LEAVE_PAGE";
    }
    //else if (<#expression#>)...
}

固然,咱們能夠有更優雅的方式,好比用一個配置表替代上面一長串的if判斷,這樣不管頁面數怎麼增長,代碼始終是那麼一小段。咱們新建一個WGlobalUserStatisticsConfig.plist的配置表來存放每一個頁面在進入以及離開時的pageEventID,結構以下:

所以,頁面進出統計中獲取pageEventID的代碼始終是如下這幾句:

- (NSString *)pageEventID:(BOOL)bEnterPage
{
    NSDictionary *configDict = [self dictionaryFromUserStatisticsConfigPlist];
    NSString *selfClassName = NSStringFromClass([self class]);
    return configDict[selfClassName][@"PageEventIDs"][bEnterPage ? @"Enter" : @"Leave"];
}

- (NSDictionary *)dictionaryFromUserStatisticsConfigPlist
{
    NSString *filePath = [[NSBundle mainBundle] pathForResource:@"WGlobalUserStatisticsConfig" ofType:@"plist"];
    NSDictionary *dic = [NSDictionary dictionaryWithContentsOfFile:filePath];
    return dic;
}

效果以下:

以上就是完成了頁面進出統計的埋點,而且達到了咱們的第三點預期:對現有代碼基本無影響。經過Method Swizzling的方式現有的工程甚至不須要import任何文件!後期代碼變更時須要維護的僅僅是plist配置表。

2)統計收藏、分享單擊事件的次數

與上一節思路一致,要作到解耦顯然須要經過category+hook來實現。本文demo中收藏跟分享都是UIButton類型,能夠考慮添加UIButton的catogory。但更好的方式是添加UIControl的category,這樣可讓埋點代碼覆蓋到全部UIControl的子類中去,好比button、switch、segment等,提升複用性。
既然要hook,那就要清楚到底要hookUIControl的哪(幾)個方法,只有部分方法是知足埋點需求的,最好是所hook的方法能提供target、actionName等信息。這是個嘗試的過程。
UIControl的方法列表有如下:

經過觀察方法名和參數,咱們有理由懷疑是倒數第二個,因其攜帶了很多貌似有價值的信息:

- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event;

因而寫出測試代碼看看:

@implementation UIControl (userStastistics)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        SEL originalSelector = @selector(sendAction:to:forEvent:);
        SEL swizzledSelector = @selector(swiz_sendAction:to:forEvent:);
        [WHookUtility swizzlingInClass:[self class] originalSelector:originalSelector swizzledSelector:swizzledSelector];
    });
}

#pragma mark - Method Swizzling
- (void)swiz_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event;
{
    //插入埋點代碼
    [self performUserStastisticsAction:action to:target forEvent:event];
    [self swiz_sendAction:action to:target forEvent:event];
}

- (void)performUserStastisticsAction:(SEL)action to:(id)target forEvent:(UIEvent *)event;
{
    NSLog(@"\n***hook success.\n[1]action:%@\n[2]target:%@ \n[3]event:%ld", NSStringFromSelector(action), target, (long)event);
}
@end

Log以下圖:

能夠看到,經過category+method swizzling的方式在沒有修改現有工程任何代碼的狀況下已經成功Hook到全部點擊事件,在Hook代碼中咱們知道了一個點擊事件的target也就是ViewController,也知道了點擊事件的響應函數名,知道了點擊的TouchSet。這些信息已經能知足埋點需求了。
與頁面統計埋點相似,咱們一樣採用plist配置表的方式避免一大長串的if-else判斷:
單擊事件配置表結構.png
有了這張配置表就很容易獲得某次單擊事件的事件ID(ControlEventID):

NSString *actionString = NSStringFromSelector(action);//獲取SEL string
NSString *targetName = NSStringFromClass([target class]);//viewController name
NSDictionary *configDict = [self dictionaryFromUserStatisticsConfigPlist];
eventID = configDict[targetName][@"ControlEventIDs"][actionString];

事實上,我把某個頁面單元的全部事件ID分紅了兩類:頁面事件ID(PageEventIDs,頁面的進出等)、交互事件ID(ControlEventIDs,單擊、雙擊、手勢等)。分類有助於下文使用單元測試(Unit Test)進行自動化後期維護。

埋點效果如圖:

到這裏先作了階段性的總結,本文提出的思路有如下優越性:

  • 與工程代碼基本解耦,避免引入「髒代碼」
  • 即便後期工程代碼發生重構,須要修改的僅僅是plist配置表
  • 維護配置表比維護散落在工程各個角落的代碼簡單

4、基於單元測試的後期維護

俗話說,創業難守業更難。前面的思路基本能夠完成初步的埋點需求。可是在實際項目中代碼重構是很頻繁的。這意味着在多人協做開發、代碼重構頻繁的項目中響應事件方法甚至頁面名稱均可能被改掉,形成事件ID獲取不到致使埋點失效。
代碼變更的狀況無非如下幾種(這裏只介紹響應事件發生改變的狀況):

一、響應事件方法名稱改變或者刪除

好比收藏事件原先是onFavBtnPressed:,以後被改爲onFavouriteBtnPressed:。代碼發生變更可是plist配置表中因爲開發人員疏忽忘記同步修改了。這種疏忽在開發壓力大進度趕的狀況下是有很大機率發生的。因爲代碼與配置表不匹配將致使eventID爲nil。在這種狀況下單元測試就頗有必要了,使用完備的測試用例能在發版前檢測到這種不匹配狀況從而避免埋點失效。
在單元測試中咱們首先讀取plist配置文件,遍歷全部的頁面。在一個頁面內遍歷全部的ControlEventIDs,對每一個響應函數名進行respondsToSelector:判斷:

單測代碼以下:

- (void)testIfUserStatisticsConfigPlistValid
{
    NSDictionary *configDict = [self dictionaryFromUserStatisticsConfigPlist];
    XCTAssertNotNil(configDict, @"WGlobalUserStatisticsConfig.plist加載失敗");
    
    [configDict enumerateKeysAndObjectsUsingBlock:^(NSString *  _Nonnull key, id  _Nonnull obj, BOOL * _Nonnull stop) {
        XCTAssert([obj isKindOfClass:[NSDictionary class]], @"plist文件結構可能已經改變,請確認");
        NSString *targetPageName = key;
        Class pageClass = NSClassFromString(targetPageName);
        id pageInstance = [[pageClass alloc] init];
        
        //一個pageDict對應一個頁面,存放pageID,全部的action及對應的eventID
        NSDictionary *pageDict = (NSDictionary *)obj;
        
        //頁面配置信息
        NSDictionary *pageEventIDDict = pageDict[@"PageEventIDs"];
        
        //交互配置信息
        NSDictionary *controlEventIDDict = pageDict[@"ControlEventIDs"];
        
        XCTAssert(pageEventIDDict, @"plist文件未包含PageID字段或者該字段值爲空");
        XCTAssert(controlEventIDDict, @"plist文件未包含EventIDs字段或者該字段值爲空");
        
        [pageEventIDDict enumerateKeysAndObjectsUsingBlock:^(NSString *  _Nonnull key, id  _Nonnull value, BOOL * _Nonnull stop) {
            XCTAssert([value isKindOfClass:[NSString class]], @"plist文件結構可能已經改變,請確認");
            XCTAssertNotNil(value, @"EVENT_ID爲空,請確認");
        }];
        
        [controlEventIDDict enumerateKeysAndObjectsUsingBlock:^(NSString *  _Nonnull key, id  _Nonnull value, BOOL * _Nonnull stop) {
            XCTAssert([value isKindOfClass:[NSString class]], @"plist文件結構可能已經改變,請確認");
            NSString *actionName = key;
            SEL actionSel = NSSelectorFromString(actionName);
            XCTAssert([pageInstance respondsToSelector:actionSel], @"代碼與plist文件函數不匹配,請確認:-[%@ %@]", targetPageName, actionName);
            
            //EVENT_ID不能爲空
            XCTAssertNotNil(value, @"EVENT_ID爲空,請確認");
        }];
    }];
    
}

咱們來測試一下,若是把HomeViewControlleronFavBtnPressed:改爲onMyFavBtnPressed:後單元測試的結果就是:

這種改變給單測輕鬆捕捉到了,

只要XCTAssert的log夠詳細,維護起來其實至關輕鬆的。

上圖中的log已經明確指出-[HomeViewController onFavBtnPressed:]方法發生了改變。

二、代碼中新增了響應事件

這種狀況常見於新版本中有新的埋點需求。若是代碼中新增了響應事件而且該響應事件是在PM要求的埋點列表中,可是plist有可能會漏掉該事件。這種狀況是比較棘手的。上一種狀況是基於plist列表去校驗代碼,這裏就要反過來,根據代碼去校驗plist是否有缺失。但問題來了,一個項目中響應函數每每是很是多的,並非任何響應函數都須要埋點。須要埋點的響應函數與其餘響應函數並無區別。
對於這種狀況,一種方式是增強code review避免忘記往配置表中添加埋點(這簡直就是廢話);一種是:要求埋點響應函數的方法名中包含約定的字符串,好比收藏事件的方法名爲onFavBtnPressed_UA:表示這個事件是須要埋點的。而後在單元測試中使用運行時APIclass_copyMethodList取出標記了_UA的全部函數,隨後到plist中校驗是否存在。不存在則表示測試用例不經過,提示開發人員校驗。

代碼略。若是對單元測試不熟悉,能夠參考單元測試

小總結:
合理的單元測試能夠爲本文方案的後期維護減輕至關大的負擔,測試用例的完備性很重要,須要用心設計考慮周全。

5、結語

以上就是結合運行時所設計出的用戶統計思路所有內容。應該說該方案的可複用性與解耦程度都是不錯的,既適合於新建的工程,也適合於已經建立的工程。利用Method Swizzling把埋點代碼集中管理其實也是合理的,有利於專人開發、跟蹤及維護。固然以上思路只考慮簡單的情形,更復雜的狀況就須要變通了,但整體思路就是如此。

本文demo地址,記得star噢!


  • 喜歡本文能夠點一下喜歡關注我,或者留個言示個愛(拋媚眼中)
  • 不喜歡能夠留言提建議,我必虛心接受
  • 歡迎轉載
相關文章
相關標籤/搜索