无埋点核心技术:iOS Hook 在字节的实践经验

作者:字节移动技术——段文斌

前言

众所周知,字节跳动的推荐在业内处于领先水平,而精确的推荐离不开大量埋点,常见的埋点采集方案是在响应用户行为操作的路径上进行埋点。但是由于App通常会有比较多界面和操作路径,主动埋点的维护成本就会非常大。所以行业的做法是无埋点,而无埋点实现需要AOP编程。

一个常见的场景,比如想在UIViewController出现和消失的时刻分别记录时间戳用于统计页面展现的时长。要达到这个目标有很多种方法,但是AOP无疑是最简单有效的方法。Objective-C的Hook其实也有很多种方式,这里以Method Swizzle给个示例。

@interface UIViewController (MyHook)

@end

@implementation UIViewController (MyHook)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        /// 常规的 Method Swizzle封装
        swizzleMethods(self, @selector(viewDidAppear:), @selector(my_viewDidAppear:));
        /// 更多Hook
    });
}

- (void)my_viewDidAppear:(BOOL)animated {
  /// 一些Hook需要的逻辑
  
  /// 这里调用Hook后的方法,其实现其实已经是原方法了。
  [self my_viewDidAppear: animated];
}

@end

接下来我们探讨一个具体场景:

UICollectionView或者UITableView是iOS中非常常用的列表UI组件,其中列表元素的点击事件回调是通过delegate完成的。这里以UICollectionView为例,UICollectionViewdelegate,有个方法声明,collectionView:didSelectItemAtIndexPath:,实现这个方法我们就可以给列表元素添加点击事件。

我们的目标是Hook这个delegate的方法,在点击回调的时候进行额外的埋点操作。

方案迭代

方案1 Method Swizzle

通常情况下,Method Swizzle可以满足绝大部分的AOP编程需求。因此首次迭代,我们直接使用Method Swizzle来进行Hook。

@interface UICollectionView (MyHook)

@end

@implementation UICollectionView (MyHook)

// Hook, setMyDelegate:和setDelegate:交换过
- (void)setMyDelegate:(id)delegate {
    if (delegate != nil) {
        /// 常规Method Swizzle
        swizzleMethodsXXX(delegate, @selector(collectionView:didSelectItemAtIndexPath:), self, @selector(my_collectionView:didSelectItemAtIndexPath:));

    }

    [self setMyDelegate:nil];
}

- (void)my_collectionView:(UICollectionView *)ccollectionView didSelectItemAtIndexPath:(NSIndexPath *)index {
  /// 一些Hook需要的逻辑

  /// 这里调用Hook后的方法,其实现其实已经是原方法了。
  [self my_collectionView:ccollectionView didSelectItemAtIndexPath:index];
}

@end

我们把这个方案集成到今日头条App里面进行测试验证,发现没法办法验证通过。

主要原因今日头条App是一个庞大的项目,其中引入了非常多的三方库,比如IGListKit等,这些三方库通常对UICollectionView的使用都进行了封装,而这些封装,恰恰导致我们不能使用常规的Method Swizzle来Hook这个delegate。直接的原因总结有以下两点:

  1. setDelegate传入的对象不是实现UICollectionViewDelegate协议的那个对象

img

如图示,setDelegate传入的是一个代理对象proxy,proxy引用了实际的实现UICollectionViewDelegate协议的delegate,proxy实际上并没有实现UICollectionViewDelegate的任何一个方法,它把所有方法都转发给实际的delegate。这种情况下,我们不能直接对proxy进行Method Swizzle

  1. 多次setDelegate

img

在上述图例中,使用方存在连续调用两次setDelegate的情况,第一次是真实delegate,第二次是proxy,我们需要区别对待。

代理模式和NSProxy介绍

使用proxy对原对象进行代理,在处理完额外操作之后再调用原对象,这种模式称为代理模式。而Objective-C中要实现代理模式,使用NSProxy会比较高效。详细内容参考下列文章。

这里面UICollectionViewsetDelegate传入的是一个proxy是非常常见的操作,比如IGListKit,同时App基于自身需求,也有可能会做这一层封装。

UICollectionViewsetDelegate的时候,把delegate包裹在proxy中,然后把proxy设置给UICollectionView,使用proxydelegate进行消息转发。

img

方案2 使用代理模式

方案1已经无法满足我们的需求了,我们考虑到既然对delegate进行代理是一种常规操作,我们何不也使用代理模式,对proxy再次代理。

代码实现

  • 先Hook UICollectionViewsetDelegate方法
  • 代理delegate

简单的代码示意如下

/// 完整封装了一些常规的消息转发方法
@interface DelegateProxy : NSProxy

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

@end

/// 为 CollectionView delegate转发消息的proxy
@interface BDCollectionViewDelegateProxy : DelegateProxy

@end

@implementation BDCollectionViewDelegateProxy <UICollectionViewDelegate>

- (void)collectionView:(UICollectionView *)collectionView didSelectItemAtIndexPath:(NSIndexPath *)indexPath {
    //track event here
    if ([self.target respondsToSelector:@selector(collectionView:didSelectItemAtIndexPath:)]) {
        [self.target collectionView:collectionView didSelectItemAtIndexPath:indexPath];

    }
}

- (BOOL)bd_isCollectionViewTrackerDecorator {
    return YES;
}

// 还有其他的消息转发的代码 先忽略
- (BOOL)respondsToSelector:(SEL)aSelector {
    if (aSelector == @selector(bd_isCollectionViewTrackerDecorator)) {
        return YES;
    }

    return [self.target respondsToSelector:aSelector];
}


@end

@interface UICollectionView (MyHook)

@end

@implementation UICollectionView (MyHook)

- (void) setDd_TrackerProxy:(BDCollectionViewDelegateProxy *)object {
    objc_setAssociatedObject(self, @selector(bd_TrackerProxy), object, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (BDCollectionViewDelegateProxy *) bd_TrackerProxy {
    BDCollectionViewDelegateProxy *bridge = objc_getAssociatedObject(self, @selector(bd_TrackerProxy));

    return bridge;
}

// Hook, setMyDelegate:和setDelegate:交换过了
- (void)setMyDelegate:(id)delegate {
    if (delegate == nil) {
        [self setMyDelegate:delegate];
        return
    }

    // 不会释放,不重复设置
    if ([delegate respondsToSelector:@selector(bd_isCollectionViewTrackerDecorator)]) {
         [self setMyDelegate:delegate]; 
         return;
    }

    BDCollectionViewDelegateProxy *proxy = [[BDCollectionViewDelegateProxy alloc] initWithTarget:delegate];
    [self setMyDelegate:proxy];
    self.bd_TrackerProxy = proxy;

}

@end

模型

下图实线表示强引用,虚线表示弱引用。

情况一

如果使用方没有对delegate进行代理,而我们使用代理模式

  • UICollectionView,其delegate指针指向DelegateProxy
  • DelegateProxy,被UICollectionView用runtime的方式强引用,其target弱引用真实Delegate

img

情况二

如果使用方也对delegate进行代理,我们使用代理模式

  • 我们只需要保证我们的DelegateProxy处于代理链中的一环即可

img

从这里我们可以看出,代理模式有很好的扩展性,它允许代理链不断嵌套,只要我们都遵循代理模式的原则即可。

到这里,我们的方案已经在今日头条App上测试通过了。但是事情远还没有结束。

踩坑之旅

目前的还算比较可以,但是也不能完全避免问题。这里其实不仅仅是UICollectionView的delegate,包括:

  • UIWebView
  • WKWebView
  • UITableView
  • UICollectionView
  • UIScrollView
  • UIActionSheet
  • UIAlertView

我们都采用相同的方法来进行Hook。同时我们将方案封装一个SDK对外提供,以下统称为MySDK。

第一次踩坑

某客户接入我们的方案之后,在集成过程中反馈有必现Crash,下面详细介绍一下这一次踩坑的经历。<

评论 2
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值