iOS动态性(五)一种可复用且解耦的用户统计实现(运行时Runtime)

dongleivip 9年前
   <p>iOS动态性是我的关于iOS运行时的系列文章,由浅入深,从理论到实践。本文是第5篇。有兴趣可以看看我之前的文章。</p>    <p>用户行为统计(User Behavior Statistics, UBS)一直是移动互联网产品中必不可少的环节,也俗称埋点。在保证移动端流量不会受较大影响的前提下,PM们总是希望埋点覆盖面越广越好。目前常规的做法是将埋点代码封装成工具类,但凡工程中需要埋点(如点击事件、页面跳转)的地方都插入埋点代码。一旦项目越来越复杂,你会发现埋点的代码散落在程序的各个角落,不利于维护以及复用。本文旨在 <strong>探讨</strong> 利用iOS的运行时机制实现一种可复、解耦、容易维护的用户统计方案。探讨毕竟是探讨,欢迎到留言讨论。本文虽有些长却是用心之作,希望你有耐心看完。</p>    <p>注:本文需要一些iOS的Runtime基础</p>    <p>该方案的完成将会用到以下知识:</p>    <ul>     <li>Method Swizzling(Hook)</li>     <li>单元测试</li>    </ul>    <h2>一、常规埋点做法</h2>    <p>接着开头的话题,我们先回顾一下主流的埋点是怎么做的。我粗糙地将埋点分为两种:1、页面统计,包括页面停留时间、页面进入次数;2、交互事件统计,包括单击、双击、手势交互等。</p>    <p>1)常规页面统计埋点</p>    <p>以统计页面进入次数为例,最简单粗暴的做法是在所有页面的 viewDidAppear: 以及 viewDidDisappear: 中分别埋点,将自己对应的pageID上传给服务端。代码大概长酱紫:</p>    <pre>  <code class="language-objectivec">@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</code></pre>    <p>+[WUserStatistics sendEventToServer:] 封装网络请求,将ID上传给服务器。上述方案有以下弊端:</p>    <p>1、复用性差。这部分埋点代码很难给其他项目复用</p>    <p>2、工作量大。尤其当页面较多时,需要修改的代码较多</p>    <p>3、引入“脏代码”,不易维护</p>    <p>第3点提到的“脏代码”意思是用户行为分析这种业务其实跟主业务没太大关系, 不应该保持如此高的耦合度 ,因为这些代码会干扰我们对项目主业务的维护。这个我个人看法。</p>    <p>2)常规交互事件埋点</p>    <p>常规做法一般在交互事件的selector中获取该事件的ID并上传给服务端,代码大概长酱紫:</p>    <pre>  <code class="language-objectivec">- (IBAction)onFavBtnPressed:(id)sender  {      [WUserStatistics sendEventToServer:@"CTRL_EVENT_HOME_FAV"];      //...do other things  }  </code></pre>    <p>稍微大一点的APP如果采用这种方式,那诸如此类的埋点代码将遍地都是。它的缺点参考页面统计埋点部分,其复用性基本为零,也就是在新项目中根本无法复用埋点代码。</p>    <p>小总结一下,采用常规的做法虽然直观方便,但在 <strong>可复用性、可维护性</strong> 等方面有所欠缺。在我看来,借助运行时可以很好地避开这些缺点。</p>    <h2>二、Method Swizzling、Hook与代码注入</h2>    <p>由于Runtime知识不属于本文的重点,这里只简单介绍。在iOS中,我们可以在运行时替换两个方法的实现,达到“勾住”某个方法并注入代码的目的。具体做法是:</p>    <p>重载类的“+(void)load”方法,在程序加载到内存时利用Runtime的 method_exchangeImplementations 等接口将方法(设为M)的实现互相交换。当方法M被调用时就会被勾住(Hook),执行我们的方法。</p>    <p>这种技术也称为 Method Swizzling ,属于面向切面编程(Aspect-Oriented Programming)的一种实现。</p>    <p>替换两个方法的实现,代码一般长酱紫:</p>    <pre>  <code class="language-objectivec">@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  </code></pre>    <p>这个 WHookUtility 工具类下文会用到。比如现在我们要勾住 UIViewController 的 viewWillAppear: 方法,可以这样做:</p>    <pre>  <code class="language-objectivec">@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  </code></pre>    <p>更多关于Runtime、method swizzling、面向切面编程的介绍请参考这里</p>    <h2>三、基于运行时的埋点方案</h2>    <p>为了便于下文叙述,先引入一个简单的项目,共有两个页面( HomeViewController , DetailViewController ),如下:</p>    <p><img src="https://simg.open-open.com/show/1ccf061ed5aebd079f5352c5dc3e3702.gif"></p>    <p>需求是</p>    <ol>     <li>统计两个页面的展示与离开次数</li>     <li>统计收藏、分享单击事件的次数</li>     <li><strong>对现有工程代码影响越小越好</strong></li>    </ol>    <p>1)统计两个页面的展示与离开次数</p>    <p>这部分应该比较直观了,摒弃掉在每个controller中埋点的方式,我们对UIViewController添加category从而Hook到 viewWillAppear: 与 viewWillDisappear: 。在这两个方法中注入埋点代码:</p>    <p><img src="https://simg.open-open.com/show/5473ce2f3996cd169ddfead4c11f2842.jpg"></p>    <p>这时候问题来了,项目中每个页面都会有自己的页面事件编号(pageEventID),此处的埋点代码如何知道要发送什么pageEventID给服务端呢?轻松祭出 if-else 神器:</p>    <pre>  <code class="language-objectivec">- (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#>)...  }</code></pre>    <p>当然,我们可以有更优雅的方式,比如用一个配置表替代上面一长串的 if 判断,这样无论页面数怎么增加,代码始终是那么一小段。我们新建一个 WGlobalUserStatisticsConfig.plist 的配置表来存放每个页面在进入以及离开时的pageEventID,结构如下:</p>    <p><img src="https://simg.open-open.com/show/a6b05fa2b36eaf8315be1a64b0a95606.png"></p>    <p>因此,页面进出统计中获取pageEventID的代码始终是以下这几句:</p>    <pre>  <code class="language-objectivec">- (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;  }</code></pre>    <p>效果如下:</p>    <p><img src="https://simg.open-open.com/show/c1cf5143ebc8a1859de6608b6c331d9a.gif"></p>    <p>以上就是完成了页面进出统计的埋点,并且达到了我们的第三点预期:对现有代码基本无影响。 通过Method Swizzling的方式现有的工程甚至不需要 import 任何文件! 后期代码变动时需要维护的仅仅是plist配置表。</p>    <p>2)统计收藏、分享单击事件的次数</p>    <p>与上一节思路一致,要做到解耦显然需要通过category+hook来实现。本文demo中收藏跟分享都是UIButton类型,可以考虑添加UIButton的catogory。但更好的方式是添加UIControl的category,这样可以让埋点代码覆盖到所有UIControl的子类中去,比如button、switch、segment等,提高复用性。</p>    <p>既然要hook,那就要清楚到底要hook UIControl 的哪(几)个方法,只有部分方法是满足埋点需求的,最好是所hook的方法能提供target、actionName等信息。这是个尝试的过程。</p>    <p>UIControl 的方法列表有以下:</p>    <p><img src="https://simg.open-open.com/show/5d14b7f3ce3e38e39da247b394b7b768.png"></p>    <p>通过观察方法名和参数,我们有理由怀疑是倒数第二个,因其携带了不少貌似有价值的信息:</p>    <pre>  <code class="language-objectivec">- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event;</code></pre>    <p>于是写出测试代码看看:</p>    <pre>  <code class="language-objectivec">@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</code></pre>    <p>Log如下图:</p>    <p><img src="https://simg.open-open.com/show/762f7f4fbc1c95e142b572ac284e34e5.png"></p>    <p>可以看到,通过category+method swizzling的方式在没有修改现有工程任何代码的情况下已经成功Hook到所有点击事件,在Hook代码中我们知道了一个点击事件的 target 也就是ViewController,也知道了点击事件的响应函数名,知道了点击的 TouchSet 。这些信息已经能满足埋点需求了。</p>    <p>与页面统计埋点类似,我们同样采用plist配置表的方式避免一大长串的 if-else 判断:</p>    <p><img src="https://simg.open-open.com/show/b64c2cbbe9306a3490cf46bfc29d02c2.png"></p>    <p>有了这张配置表就很容易得到某次单击事件的事件ID(ControlEventID):</p>    <pre>  <code class="language-objectivec">NSString *actionString = NSStringFromSelector(action);//获取SEL string  NSString *targetName = NSStringFromClass([target class]);//viewController name  NSDictionary *configDict = [self dictionaryFromUserStatisticsConfigPlist];  eventID = configDict[targetName][@"ControlEventIDs"][actionString];</code></pre>    <p>事实上,我把某个页面单元的所有事件ID分成了两类: <strong>页面事件ID</strong> (PageEventIDs,页面的进出等)、 <strong>交互事件ID</strong> (ControlEventIDs,单击、双击、手势等)。分类有助于下文使用单元测试(Unit Test)进行自动化后期维护。</p>    <p>埋点效果如图:</p>    <p><img src="https://simg.open-open.com/show/80c9dd7c452521c6cb7d403c090b6526.gif"></p>    <p>到这里先做了阶段性的总结,本文提出的思路有以下优越性:</p>    <ul>     <li>与工程代码基本解耦,避免引入“脏代码”</li>     <li>即使后期工程代码发生重构,需要修改的仅仅是plist配置表</li>     <li>维护配置表比维护散落在工程各个角落的代码简单</li>    </ul>    <h2>四、基于单元测试的后期维护</h2>    <p>俗话说,创业难守业更难。前面的思路基本可以完成初步的埋点需求。但是在实际项目中代码重构是很频繁的。这意味着在多人协作开发、代码重构频繁的项目中响应事件方法甚至页面名称都可能被改掉,造成事件ID获取不到导致埋点失效。代码变动的情况无非以下几种(这里只介绍响应事件发生改变的情况):</p>    <p>1、响应事件方法名称改变或者删除</p>    <p>比如收藏事件原先是 onFavBtnPressed: ,之后被改成 onFavouriteBtnPressed: 。代码发生变动但是plist配置表中由于开发人员疏忽忘记同步修改了。这种疏忽在开发压力大进度赶的情况下是有很大概率发生的。由于代码与配置表不匹配将导致eventID为nil。在这种情况下单元测试就很有必要了,使用完备的测试用例能在发版前检测到这种不匹配情况从而避免埋点失效。</p>    <p>在单元测试中我们首先读取plist配置文件,遍历所有的页面。在一个页面内遍历所有的ControlEventIDs,对每个响应函数名进行 respondsToSelector: 判断:</p>    <p><img src="https://simg.open-open.com/show/aaae36be1f98e3416dd64e94aaa28cd4.png"></p>    <p>单测代码如下:</p>    <pre>  <code class="language-objectivec">- (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为空,请确认");          }];      }];        }</code></pre>    <p>我们来测试一下,如果把 HomeViewController 的 onFavBtnPressed: 改成 onMyFavBtnPressed: 后单元测试的结果就是:</p>    <p><img src="https://simg.open-open.com/show/54a7e049615f71c0ee02d54cf7988fd2.png"></p>    <p>这种改变给单测轻松捕捉到了,</p>    <p>只要XCTAssert的log够详细,维护起来其实相当轻松的。</p>    <p>上图中的log已经明确指出 -[HomeViewController onFavBtnPressed:] 方法发生了改变。</p>    <p>2、代码中新增了响应事件</p>    <p>这种情况常见于新版本中有新的埋点需求。如果代码中新增了响应事件并且该响应事件是在PM要求的埋点列表中,但是plist有可能会漏掉该事件。这种情况是比较棘手的。上一种情况是基于plist列表去校验代码,这里就要反过来,根据代码去校验plist是否有缺失。但问题来了,一个项目中响应函数往往是非常多的,并不是任何响应函数都需要埋点。需要埋点的响应函数与其他响应函数并没有区别。</p>    <p>对于这种情况,一种方式是加强code review避免忘记往配置表中添加埋点(这简直就是废话);一种是:要求埋点响应函数的方法名中包含约定的字符串,比如收藏事件的方法名为 onFavBtnPressed_UA: 表示这个事件是需要埋点的。然后在单元测试中使用运行时API class_copyMethodList 取出标记了 _UA 的所有函数,随后到plist中校验是否存在。不存在则表示测试用例不通过,提示开发人员校验。</p>    <p>代码略。如果对单元测试不熟悉,可以参考 <a href="/misc/goto?guid=4959670270041920431" rel="nofollow,noindex">单元测试</a></p>    <p>小总结:合理的单元测试可以为本文方案的后期维护减轻相当大的负担,测试用例的完备性很重要,需要用心设计考虑周全。</p>    <h2>五、结语</h2>    <p>以上就是结合运行时所设计出的用户统计思路全部内容。应该说该方案的可复用性与解耦程度都是不错的,既适合于新建的工程,也适合于已经创建的工程。利用Method Swizzling把埋点代码集中管理其实也是合理的,有利于专人开发、跟踪及维护。当然以上思路只考虑简单的情形,更复杂的情况就需要变通了,但总体思路就是如此。</p>    <p>本文 <a href="/misc/goto?guid=4959670270133121784" rel="nofollow,noindex">demo地址</a> ,记得star噢!</p>    <p>来源: <a href="/misc/goto?guid=4959670270214632732" rel="nofollow,noindex">编程小翁</a></p>