iOS打点杂谈

 

本篇博客简单的介绍一下埋点以及埋点的一些功用,以及一些粗浅的认识

什么是打点?

首先简单介绍下什么是打点以及打点有什么作用,打点英文其实就是 Record 作用其实就是对想要关心的业务或者关键路径进行记录上传到特定的服务器便于后期分析。

打点分类

为了解决前端埋点的准确性、及时性、开发效率等问题,业内各家公司从不同角度,提出了多种技术方案,这些方案大体上可以归为三类:

  • 代码埋点:手动使用 recordEvent 等方式埋点
  • 声明式埋点:将代码埋点和业务逻辑解耦
  • 无痕埋点:使用 AOP 进行埋点

代码埋点是一种典型的命令式编程,因此埋点代码常常要侵入具体的业务逻辑,这使埋点代码变得很繁琐并且容易出错。因此,最直接的做法就是将埋点代码与业务逻辑解耦

比如:

if (isLoggedin) {
    [[ACYTracker instance] recordEvent:@{@"category":@"abc", @"action":@"login"}];
}

这就是一个典型的代码埋点。当然这需要业务员在自己的代码逻辑中添加相应埋点,侵入原有代码,如果再由于这些埋点时间而导致一些crash就得不偿失了。

代码埋点需要解决2个问题:

  • 声明控件的的唯一标示(bid)
  • 业务字段(运行时机才会得知)

事件标示

为了自动生成事件标识,我们需要获取每个控件自身的ID、类名以及位于所属父组件的Index等特征信息,并逐级向上遍历找到根节点。根节点一般是手动标记的,如果没有标记则默认是视图层次树的顶层节点。最后,将遍历产生的路径上所有节点的特征信息组合在一起,就是这个事件的标识。考虑到在实际布局中有可能存在一些动态插入的控件,我们允许父组件的Index有一定的误差(大概率不需要考虑)。

数据关联

采用更常见的前端数据联系的方式注入。

小结

通过自动产生事件标识并进行数据关联,我们就能够实现“无痕埋点”了,并且埋点节点可以通过配置文件动态下发,从而具备了动态部署与修复埋点的能力。但需要注意的是,这种“无痕埋点”并不能解决所有问题,当业务字段无法通过数据关联获取时(这种情况比较常见),仍然需要开发者代码埋点或声明式埋点指定业务字段。

AOP埋点

可以使用 AOP(Aspect-Oriented-Programming) 来做这件事。

AOP:通过预编译方式和运行期动态代理实现在不修改源代码的情况下给程序动态统一添加功能的一种技术。

[UIViewController aspect_hookSelector:@selector(viewWillAppear:)withOptions:AspectPositionAfter usingBlock:^(id<AspectInfo> aspectInfo, BOOL animated) {
    NSLog(@"View Controller %@ will appear animated: %tu",      spectInfo.instance, animated);
} error:NULL];

大家都知道JSPatch和Aspects有兼容性问题。经测试, 对于Aspects提供的-方法(即用实例调用),

[self.cls aspect_hookSelector:@selector(instanceTest) withOptions:AspectPositionAfter usingBlock:^(id aspects) {
    NSLog(@"aspects instanceTest");
} error:nil];

如果Aspects在后,会找不到orig_hook的方法而抛异常;反之,则不会crash,但是JSPatch会无效,也就是说只有Aspects生效。

对于Aspects提供的+方法(即使用类调用),

[MyClass aspect_hookSelector:@selector(classTest) withOptions:AspectPositionAfter usingBlock:^(id aspects) {
    NSLog(@"aspects classTest");
} error:nil];

如果Aspects在后,JSPatch和Aspects会同时生效;反之则会找不到orig_hook的方法而抛异常(+方法不调用

self.ORIGclassTest()

的话不会crash,-方法无论是否调用orig,只要Aspects在后都会crash)

所以如果某个-方法使用了AOP进行了埋点,也就不能使用JSPatch热修复了,+方法仍然可以(不过不能偷懒的使用ORIG了),请各位看官取舍,或者有什么比较好兼容方式更好。

附上二篇帖子给给为看官,写的还是挺全面的,困惑的时候可以看看:

  • http://www.jianshu.com/p/dc1deaa1b28e
  • http://www.jianshu.com/p/d5c3c2f236b8

大多数人的观点,JSPatch优先级更高,使用JSPatch一定是出现了比较严重的线上bug(敲黑板,线上)需要修复。而AOP是在开发阶段,开发阶段所有东西都是可控的,AOP完全可以通过method_swizzling搞定,开发阶段麻烦一点是可以接受的。可以把Aspect拿掉了。另一方面,开发App难免要接入各种各样的SDK,有些不是那么良心的SDK是闭源的,它在背后偷偷干了什么都不知道,对于这样的SDK,难免会遇到坑。

PS: Aspects无法hook类方法

Aspects 和 JSPatch 冲突的简单说明

Aspects 会对他所 hook 的类生成一个新类,类似如:MyClass_Aspects_,然后 hook 它的 forwardInvocation 方法,用 __aspects_forwardInvocation 去替换,然后呢;如果 JSPatch 在前,它会通过 aspect_isMsgForwardIMP 这个方法认为已经被 hook 了,从而不会加上一个 alias 的 selector,最终在 Aspects 的核心方法 __ASPECTS_ARE_BEING_CALLED__

// If no hooks are installed, call original implementation (usually to throw an exception)
if (!respondsToAlias) {
    invocation.selector = originalSelector;
    SEL originalForwardInvocationSEL = NSSelectorFromString(AspectsForwardInvocationSelectorName);
    if ([self respondsToSelector:originalForwardInvocationSEL]) {
        ((void( *)(id, SEL, NSInvocation *))objc_msgSend)(self, originalForwardInvocationSEL, invocation);
    }else {
        [self doesNotRecognizeSelector:invocation.selector];
    }
}

由于 respondsToAlias 显然有上面情况得知为 NO(没有 JSPatch 干扰的情况下,这块是 YES,因为会被加上一个 alias selector),走进去之后,这块由于 JSPatch 的 forwardInvocation 已经变为了_JPforwardInvocation 了,而不是 __aspects_forwardInvocation,所以自然走了下面的 doesNotRecognizeSelector 从而抛了异常。

经测试,将上面的 if 注释掉,二者可以兼容,不过 JSPatch 在前则会被 Aspects 覆盖,之后二者同时生效(-方法,+方法的话是谁在后,谁生效)

不过 JSPatch 一般都拥有后期修复问题,在后的概率较大。不过这只是个人的随意实验。

而且为了不改变原有代码的前提下,我们需要给每个控件定义好唯一Id,也就是BID,然后还需要一些业务字段(什么时候获取,怎么获取是个难点)

已知某企业的使用自己的一套UT,大致实现的方式是:

@implementation ACYBaseViewController  

- (void)viewDidLoad {
    [super viewDidLoad];
    
    [UT enterPage:NSStringFromClass([self class]) args:@{xx:yy}];
}    

- (void)dealloc {
    [UT leavePage:NSStringFromClass([self class]) args:@{xx:yy}];
}

@end

当然这要求在App所有的基类里写上这些通用的埋点,然后在业务点去埋对应的业务点:

- (void)onBuyButtonTapped:(UIButton *)button
{
    // do some stuff, maybe send a request to server
    [UT trackEvent:trackId args:@{xx:yy}];
    [UT trackClick:trackId args:@{xx:yy}];
}

Category方式

例如对UIButton写一个(Tracker)的category,复写对应的方法实现打点。这种方式不需要Hook,不过需要额外做一些工作,如:引入头文件,保证使用的UIButton都是category的。

NSProxy方式

这种方式只适合于delegate形式的空间,比如UITableView,UIAlertView等,下一篇要介绍的 ACYTableViewWrapper就是这种模式,且听下回分解。初貌如下:

NS_ASSUME_NONNULL_BEGIN

/**
*  Catch delegate method for `MVTableViewDataSource`
*/
@interface ACYTableViewDelegateProxy : NSProxy <UITableViewDelegate>

@property (nonatomic, weak, nullable) id target;

@end

NS_ASSUME_NONNULL_END

也就是对想要兼容的delegate方法,都从我们自定义的proxy过一遍,然后在合适的地方插入打点代码:

- (BOOL)respondsToSelector:(SEL)aSelector {
BOOL retVal = NO;

SEL selectors[] = {
    @selector(tableView:heightForRowAtIndexPath:),
    @selector(tableView:didSelectRowAtIndexPath:),
    @selector(tableView:viewForHeaderInSection:),
    @selector(tableView:viewForFooterInSection:),
    @selector(tableView:willDisplayHeaderView:forSection:),
    @selector(tableView:willDisplayFooterView:forSection:),
    @selector(tableView:heightForHeaderInSection:),
    @selector(tableView:heightForFooterInSection:),
    @selector(tableView:didEndDisplayingCell:forRowAtIndexPath:),
    @selector(tableView:willDisplayCell:forRowAtIndexPath:),
    @selector(tableView:estimatedHeightForRowAtIndexPath:),
    NULL
};

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wundeclared-selector"
if (![self.target respondsToSelector:@selector(supportEstimatedHeight)]) {
    selectors[10] = NULL;
}
#pragma clang diagnostic pop

for (SEL *p = selectors; *p != NULL; ++p) {
    if (aSelector == *p) {
        retVal = YES;
        break;
    }
}
if (!retVal) {
    retVal = [self.target respondsToSelector:aSelector];
}

return retVal;
}