iOS 全埋点-UITaleView和UICollectionView的点击事件(4)

写在前面

传送门:

前面的系列章节可以查看上面连接,本章节主要是介绍 iOS全埋点序列文章(4)UITaleView和UICollectionView的点击事件

前言

$AppClick事件采集中,还有两个比较特殊 的控件。

  • UITableView
  • UICollectionView
    这两个控件的点击事件,一般指的是点击 UITableViewCellUICollectionViewCell。而 UITableViewCellUICollectionViewCell都是直接继承自UIView类,而不是UIControl类,因此,我们之前实现$AppClick事件全埋点的两个方案均不适用于UITableView和UICollectionView控件。

关于实现UITableView和UICollectionView控 件$AppClick事件的全埋点,常见的方案有三种。

  1. 方法交换
  2. 动态子类
  3. 消息转发

这三种方案各有优缺点。下面,我们以 UITableView控件为例,分别介绍如何使用这三种 方案实现$AppClick事件的全埋点。

支持UITableView控件

方案一:方法交换

众所周知,如果需要处理UITableView的点击操作,需要先设置 UITableViewdelegate属性,并实现UITableViewDelegate协议的- tableView:didSelectRowAtIndexPath:方法。因此,我们也很容易想到使用 Method Swizzling交换-tableView:didSelectRowAtIndexPath:方法来实现 UITableView控件$AppClick事件的全埋点

初始思路
首先,我们使用Method Swizzling交换UITableView- setDelegate:方法;然后,获取实现UITableViewDelegate协议的delegate对象,在得到delegate对象之后,交换delegate对象的- tableView:didSelectRowAtIndexPath:方法;最后,在交换后的方法中触发 $AppClick事件,从而实现UITableView控件$AppClick事件全埋点。

新建一个UITableView的类别CountData

UItableView+CountData.m

#import "UITableView+CountData.h"
#import "NSObject+Swizzler.h"
#import <objc/message.h>
#import <objc/runtime.h>
#import "SensorsAnalyticsSDK.h"

@implementation UITableView (CountData)

+ (void)load { 
    [UITableView sensorsdata_swizzleMethod:@selector(setDelegate:) withMethod:@selector(CountData_setDelegate:)];
}

/*
 *  UITableView的delegate对象是在程序运行时设置的,其有可能是UItableView对象本身,也有可能是UIviewController或者其他对象。因此需要给delegate对象动态地添加需要交换的方法,然后与原来的tableView:didSelectRowAtIndexPath:方法进行交换。
 */

- (void)CountData_setDelegate:(id<UITableViewDelegate>)delegate {
    [self CountData_setDelegate:delegate];
    [self CountData_swizzleDidSelectRowAtIndexPathMethodWithDelegate:delegate];
}


//添加交换方法
static void CountData_tableViewDidSelectRow(id object,SEL selector,UITableView *tableView,NSIndexPath *indexPath) {
    SEL destinationSelector = NSSelectorFromString(@"CountData_tableView:didSelectRowAtIndexPath:");
    //发送消息,调用原始的tableView:didSelectRowAtIndexPath:方法实现   
    ((void (*)(id,SEL,id,id))objc_msgSend)(object,destinationSelector,tableView,indexPath);   
    [[SensorsAnalyticsSDK sharedInstance]AppClickWithTableView:tableView didSelectRowAtIndexPath:indexPath properties:nil];
}

#pragma mark- 私有方法,负责给delegate对象添加一个方法并进行交换
-(void)CountData_swizzleDidSelectRowAtIndexPathMethodWithDelegate:(id)delegate {
   //获取delegate对象的类
    Class delegateClass = [delegate class];
    NSLog(@"获取当前对象的类型名字为---%@",NSStringFromClass([delegate class]));
    //方法名
    SEL sourceSelector = @selector(tableView:didSelectRowAtIndexPath:);
    //当delegate对象中没有实现方法tableView:didSelectRowAtIndexPath:,直接返回
    if (![delegate respondsToSelector:sourceSelector]) {
        NSLog(@"没有实现tableView:didSelectRowAtIndexPath方法");
        return;
    }
    
    SEL destinationSelector = NSSelectorFromString(@"CountData_tableView:didSelectRowAtIndexPath:");
    //当delegate对象已经存在了CountData_tableView:didSelectRowAtIndexPath:,说明已经交换,可以直接返回
    if ([delegate respondsToSelector:destinationSelector]) {
        return;
    }
    
    Method sourceMethod = class_getInstanceMethod(delegateClass, sourceSelector);
    const char *encoding = method_getTypeEncoding(sourceMethod);
    //当类中已经存在相同的方法时,则会添加方法失败。当时前面已经判断过方法是否存在。因此,此处一定会添加成功
    if (!class_addMethod([delegate class], destinationSelector,(IMP)CountData_tableViewDidSelectRow, encoding)) {
        
        return;
    }
    
    //方法添加之后,进行方法交换
    [delegateClass sensorsdata_swizzleMethod:sourceSelector withMethod:destinationSelector];
}

@end

方案二:动态子类

初始思路
在运行时,给实现了UITableViewDelegate协议的- tableView:didSelectRow-AtIndexPath:方法的类创建一个子类,让该子类的对象变成我们自己创建的子类的对象。同时,在创建的子类中动态添加- tableView:didSelectRowAtIndexPath:方法。那么,当用户点击UITableViewCell控件时,就会先运行自己创建的子类中的- tableView:didSelectRow-AtIndexPath:方法。我们在实现该方法的时候,先调用delegate原来的方法实现,再触发$AppClick事件,即可实现 UITableView控件$AppClick事件全埋点。

创建一个动态添加子类的工具类:TableViewDynamicDelegate

TableViewDynamicDelegate.h声明如下:

#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface TableViewDynamicDelegate : NSObject

+ (void)proxyWithTableViewDelegate:(id <UITableViewDelegate>)delegate;

@end

NS_ASSUME_NONNULL_END

TableViewDynamicDelegate.m声明如下:

#import "TableViewDynamicDelegate.h"
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#import "SensorsAnalyticsSDK.h"

/// Delegate 的子类前缀
static NSString *const kSensorsDelegatePrefix = @"cn.countData.";
// tableView:didSelectRowAtIndexPath: 方法指针类型
typedef void (*TableDidSelectImplementation)(id, SEL, UITableView *, NSIndexPath *);

@implementation TableViewDynamicDelegate

+ (void)proxyWithTableViewDelegate:(id <UITableViewDelegate>)delegate  {
   
    SEL originalSelector = NSSelectorFromString(@"tableView:didSelectRowAtIndexPath:");
    //当Delegate中没有实现tbaleView:didSelectRowAtIndexPath:方法时,直接返回
    if (![delegate respondsToSelector:originalSelector]) {
        NSLog(@"没有实现tbaleView:didSelectRowAtIndexPath:方法");
        return;
    }
    //动态创建一个新类
    Class originalClass =  object_getClass(delegate);
    NSString *originalClassName = NSStringFromClass(originalClass);
    
    //判断这个delegate对象是否已经动态创建的类时,无须重复设置,直接返回
    if([originalClassName hasPrefix:kSensorsDelegatePrefix])  {
        return;
    }
    
    NSString *subClassName = [kSensorsDelegatePrefix stringByAppendingString:originalClassName];
    Class subclass = NSClassFromString(subClassName);
    if (!subclass) {
        //注册一个新的子类,其父类为originalclass
        subclass = objc_allocateClassPair(originalClass, subClassName.UTF8String, 0);
        //获取TableViewDynamicDelegate中的tableView:didSelectRowAtIndexPath指针
        Method method = class_getInstanceMethod(self, originalSelector);
        //获取方法实现
        IMP methodIMP = method_getImplementation(method);
        //获取方法类型的编码
        const char *types = method_getTypeEncoding(method);
        //在subClass中添加 tableView:didSelectRowAtIndexPath: 方法
        if(!class_addMethod(subclass, originalSelector,methodIMP , types)) {
            NSLog(@"方法已经存在");
        }
        
        /*删除动态生成的前缀,动态添加方法(sensorsdata_class)*/ 

        // 获取 TableViewDynamicDelegate 中的 sensorsdata_class 方法指针
        Method classMethod = class_getInstanceMethod(self, @selector(sensorsdata_class));
        // 获取方法实现
        IMP classIMP = method_getImplementation(classMethod);
        //获取方法的类型编码
        const char *classTypes = method_getTypeEncoding(classMethod);
        //在subclass中添加class方法
        if (!class_addMethod(subclass, @selector(class), classIMP, classTypes)) {
            NSLog(@"添加方法失败");
        }
        //子类和原始类的大小必须一致,不能有更多的ivars或者属性
        //如果不同会导致设置新的子类时,会重新设置内存,导致重写了对象的isa指针
        if (class_getInstanceSize(originalClass) != class_getInstanceSize(subclass)) {
            return;
        }
        //将delegate对象设置为新的子类对象
        objc_registerClassPair(subclass);
    }
    if (object_setClass(delegate, subclass)) {
        NSLog(@"创建成功");
    }
}

//删除自动创建类名的私有方法
- (Class)sensorsdata_class {
    // 获取对象的类
    Class class = object_getClass(self);
    // 将类名前缀替换成空字符串,获取原始类名
    NSString *className = [NSStringFromClass(class) stringByReplacingOccurrencesOfString:kSensorsDelegatePrefix withString:@""];
    // 通过字符串获取类,并返回
    return objc_getClass([className UTF8String]);
}

- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
    //第一步:获取原始的类
    Class cla = object_getClass(tableView.delegate);
    NSString *className = [NSStringFromClass(cla) stringByReplacingOccurrencesOfString:kSensorsDelegatePrefix withString:@""];
    Class originalClass = objc_getClass([className UTF8String]);
    
    //第二步:调用开发者自己实现的方法
    SEL originalSelector = NSSelectorFromString(@"tableView:didSelectRowAtIndexPath:");
    Method originalMethod = class_getInstanceMethod(originalClass, originalSelector);
    IMP originalIMP = method_getImplementation(originalMethod);
    if (originalIMP) {
       ((TableDidSelectImplementation)originalIMP)(tableView.delegate,originalSelector,tableView,indexPath);
    }
    
    //第三步:埋点
    [[SensorsAnalyticsSDK sharedInstance]AppClickWithTableView:tableView didSelectRowAtIndexPath:indexPath properties:@{@"$app_click":@"动态创建类事件"}];
    
}

最后调用:修改UITableView+CountData.m文件中的-CountData_setDelegate:方法,添加调用TableViewDynamicDelegate类的+proxyWithTableViewDelegate方法`

+ (void)load {   
    [UITableView sensorsdata_swizzleMethod:@selector(setDelegate:) withMethod:@selector(CountData_setDelegate:)];
}

- (void)CountData_setDelegate:(id<UITableViewDelegate>)delegate {
//    方案2 动态子类   
    [self CountData_setDelegate:delegate];
    //设置delegate的动态子类
    [TableViewDynamicDelegate proxyWithTableViewDelegate:delegate];    
}

方案三:消息转发

在iOS应用开发中,自定义类一般需要继承自NSObject类或者NSObject 子类。但是,NSProxy类不是继承自NSObject类或者NSObject子类,而是一 个实现了NSObject协议的抽象基类。

当然,在大部分情况下,使用NSObject类也可以实现消息转发,实现 方式与NSProxy类相同。但是,大部分情况下使用NSProxy类更为合适。

理由如下

  1. NSProxy类实现了包括NSObject协议在内基类所需的基础方法。
  2. 通过NSObject类实现的代理类不会自动转发NSObject协议中的方 法。
  3. 通过NSObject类实现的代理类不会自动转发NSObject类别中的方 法,例如上面调用实例中的-valueForKey:方法,如果是使用NSObject类实 现的代理类,会抛出异常。

步骤如下:

步骤一:创建CountDataDelegateProxy类 (继承自NSProxy类),实现UITableViewDelegate协议。然后添加一个类 方法+proxywithTableViewDelegate:。

CountDataDelegateProxy.h 声明如下:

#import <Foundation/Foundation.h>
#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface CountDataDelegateProxy : NSProxy

@property(nonatomic,weak) id delegate;
+(instancetype) proxywithTableViewDelegate:(id<UITableViewDelegate>)delegate;

@end

NS_ASSUME_NONNULL_END

CountDataDelegateProxy.m 声明如下:

#import "CountDataDelegateProxy.h"
#import "SensorsAnalyticsSDK.h"
@implementation CountDataDelegateProxy

+ (instancetype)proxywithTableViewDelegate:(id<UITableViewDelegate>)delegate {
    CountDataDelegateProxy *proxy = [CountDataDelegateProxy alloc];
    proxy.delegate = delegate;
    return  proxy;
}

- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel {
    //返回delegate对象的方法签名
    return [(NSObject *)self.delegate methodSignatureForSelector:sel];
}

- (void)forwardInvocation:(NSInvocation *)invocation {    
    [invocation invokeWithTarget:self.delegate];
    if (invocation.selector == @selector(tableView:didSelectRowAtIndexPath:)) {
        invocation.selector = NSSelectorFromString(@"countDatatableView:didSelectRowAtIndexPath:");
        [invocation invokeWithTarget:self];
    }
}

-(void)countDatatableView:(UITableView *)tableView didSelectRowAtIndexPath:(nonnull NSIndexPath *)indexPath {
    
    [[SensorsAnalyticsSDK sharedInstance]AppClickWithTableView:tableView didSelectRowAtIndexPath:indexPath properties:@{@"$app_click":@"NSProxy的委托代理"}];
}

@end

步骤二:为了可以同时支持UICollectionView控件,我们直接在UIScrollView中扩展countData_delegareProxy属性。

创建UIScrollView的类别CountData,并在头文件中添加属性声明。

CountDataDelegateProxy.h 声明如下:

#import <UIKit/UIKit.h>
#import "CountDataDelegateProxy.h"

NS_ASSUME_NONNULL_BEGIN

@interface UIScrollView (CountData)

@property (nonatomic,strong) CountDataDelegateProxy *countData_delegareProxy;

@end

NS_ASSUME_NONNULL_END

UIScrollView+CountData.m声明如下:

#import "UIScrollView+CountData.h"
#import <objc/runtime.h>

@implementation UIScrollView (CountData)

- (void)setCountData_delegareProxy:(CountDataDelegateProxy *)countData_delegareProxy {
    objc_setAssociatedObject(self, @selector(setCountData_delegareProxy:), countData_delegareProxy, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (CountDataDelegateProxy *)countData_delegareProxy {
    return objc_getAssociatedObject(self, @selector(countData_delegareProxy));
}
@end

步骤三:修改UITableView+CountData.m文件中的-CountData_setDelegate:方法,添加调用TableViewDynamicDelegate类的+proxyWithTableViewDelegate方法`

- (void)CountData_setDelegate:(id<UITableViewDelegate>)delegate {

    /*方案3 NSProxy 消息转发*/

    self.countData_delegareProxy = nil;
    if (delegate) {
        CountDataDelegateProxy *proxy = [CountDataDelegateProxy proxywithTableViewDelegate:delegate];
        self.countData_delegareProxy = proxy;
        [self CountData_setDelegate:proxy];
    }else {
        [self CountData_setDelegate:nil];
    }
}

总结

对于UITableView控件$AppClick事件全埋点的三种方案,它们各有优缺点,读者可以根据实 际情况选择相应的方案。

方案一:方法交换

优点:简单、易理解;Method Swizzling属于 成熟技术,性能相对来说较高。

缺点:对原始类有入侵,容易造成冲突。

方案二:动态子类

优点:没有对原始类入侵,不会修改原始类 的方法,不会和第三方库冲突,是一种比较稳定的方案。

缺点:动态创建子类对性能和内存有比较大 的消耗。

方案三:消息转发

优点:充分利用消息转发机制,对消息进行 拦截,性能较好。

缺点:容易与一些同样使用消息转发进行拦 截的第三方库冲突

扩展

获取控件内容

为了能获取更复杂的UIView的显示内容,该方法需要修改成支持通过 递归遍历获取子控件的显示内容。

定义UIView的分类,布局页面等用到的控件的分类

UIView+TextContentData.h 声明如下:

#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface UIView (TextContentData)

@property (nonatomic,copy,readonly) NSString *elementContent;
@property (nonatomic,strong,readonly) UIViewController *myViewController;

@end

@interface UIButton (TextContentData)

@end

@interface UISwitch (TextContentData)

@end

@interface UILabel (TextContentData)

@end

NS_ASSUME_NONNULL_END

UIView+TextContentData.m 声明如下:

#import "UIView+TextContentData.h"

@implementation UIView (TextContentData)

- (NSString *)elementContent {
    // 如果是隐藏控件,不获取控件内容
    if (self.isHidden || self.alpha == 0) { return nil; }
    // 初始化数组,用于保存子控件的内容
    NSMutableArray *contents = [NSMutableArray array];
    for (UIView *view in self.subviews) {
        // 获取子控件的内容
        // 如果子类有内容,例如UILabel的text,获取到的就是text属性
        // 如果子类没有内容,就递归调用该方法,获取其子控件的内容
        NSString *content = view.elementContent;
        if (content.length > 0) {
            // 当该子控件有内容时,保存在数组中
            [contents addObject:content];
        }
    }
    // 当未获取到子控件内容时,返回nil。如果获取到多个子控件内容时,使用"-"拼接
    return contents.count == 0 ? nil : [contents componentsJoinedByString:@"-"];
}

- (UIViewController *)myViewController {
    UIResponder *responder = self;
    while ((responder = [responder nextResponder])) {
        if ([responder isKindOfClass:[UIViewController class]]) {
            return (UIViewController *)responder;
        }
    }
    return  nil;
}
@end

@implementation  UIButton (TextContentData)

- (NSString *)elementContent {
    return self.titleLabel.text ?: super.elementContent;
}

@end


@implementation UISwitch (TextContentData)

- (NSString *)elementContent {
    return self.on ? @"checked":@"unchecked";
}

@end

@implementation UILabel (TextContentData)

- (NSString *)elementContent {
    
    return self.text ?: super.elementContent;
}

@end

最后页面采集信息增加字段$element_content

代码如下所示:

-(void)AppClickWithTableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)index properties:(NSDictionary<NSString *,id> *)properties  {
    
    NSMutableDictionary *event = [NSMutableDictionary dictionary];
    // 设置事件名称
    event[@"event"] = @"TableView的点击事件";
    // 设置事件发生的时间戳,单位为毫秒
    event[@"time"] = [NSNumber numberWithLong:NSDate.date.timeIntervalSince1970 * 1000];
    NSMutableDictionary *eventProperties = [NSMutableDictionary dictionary];
    // 添加预置属性
    [eventProperties addEntriesFromDictionary:self.automaticProperties];
    // 添加自定义属性
    [eventProperties addEntriesFromDictionary:properties];
    //判断是否位被动启动状态
    if(self.isLaunchedPassively) {
        //添加应用程序状态属性
        eventProperties[@"$app_state"] = @"background";
    }
    
    if (tableView) {
        // TODO:获取用户点击的UITableViewCell控件对象
        UITableViewCell *cell = [tableView cellForRowAtIndexPath:index];
        // TODO:设置被用户点击的UITableViewCell控件上的内容($element_content)
        eventProperties[@"$element_content"] = cell.elementContent;
    }
    // 设置事件属性
    event[@"properties"] = eventProperties;
   
    [self printEvent:event];
}

最后支持UICollectionView控件和UITableView的实现原理相似,同样可以使用以上三种方案去实现。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,026评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,655评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,726评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,204评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,558评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,731评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,944评论 2 314
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,698评论 0 203
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,438评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,633评论 2 247
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,125评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,444评论 3 255
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,137评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,103评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,888评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,772评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,669评论 2 271