【原】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噢!


  • 喜欢本文能够点一下喜欢关注我,或者留个言示个爱(抛媚眼中)
  • 不喜欢能够留言提建议,我必虚心接受
  • 欢迎转载
相关文章
相关标签/搜索