Runtime在项目中的使用场景

    由于最近申请的辞职,所以不得不做好下一家面试的准备。 在iOS面试过程中Runtime, Runloop基本是必问的两个问题。 Runtime的概念性问题就不多说了, 本篇文章我会讲述一下Runtime在iOS开发中的实际使用场景。

    首先,归纳下Runtime的几个使用场景。

  1. 做用户埋点统计
  2. 处理异常崩溃(NSDictionary, NSMutableDictionary,  NSArray, NSMutableArray 的处理
  3. 按钮最小点击区设置
  4. 按钮重复点击设置
  5. 手势的重复点击处理
  6. UIButton点击事件带多参数
  7. MJRefresh封装
  8. 服务端控制页面跳转
  9. 字典转模型

一 用户埋点

        在做app运营的时候, 我们经常会需要接入一些第三方做统计, 例如友盟统计,google统计等。 例如外面需要统计某个页面用户停留的时长, 统计某个页面的展示次数。 通常我们的做法是 : 需要统计A页面停留时长的时候,我们再A页面出现(appear)的时候记录一个时间戳,页面消失(dispear)的时候用当前时间戳与之前的时间戳求出时间间隔,然后上报到分析平台。 如果统计页面展示次数, 就在每次页面出现时调用统计方法。  这样做的坏处是 代码侵入性太强维护性易读性都不太好。 假设以后要改需求, 就要进入到代码所在处进行修改。 又或者别人接手你的代码, 根本不知道已经做了哪些埋点, 需求改来改去,时间久了, 项目中全都是垃圾代码。

        此时,为了优化统计, 我们使用 Hook (钩子)的思想, 例如Runtime的 Method sweezing(方法交换)去拦截系统方法来实现共计。

        首先,我们写一个集成NSObject的工具类,实现方法交换

#import "HookTool.h"
#import 



@implementation HookTool

+(void)swizzingForClass:(Class)cls originalSel:(SEL)originalSelector swizzingSel:(SEL)swizzingSelector
{
    Class class = cls;
    Method originalMethod = class_getInstanceMethod(class, originalSelector);
    Method  swizzingMethod = class_getInstanceMethod(class, swizzingSelector);
    
    BOOL addMethod = class_addMethod(class,
                                     originalSelector,
                                     method_getImplementation(swizzingMethod),
                                     method_getTypeEncoding(swizzingMethod));
    
    if (addMethod) {
        class_replaceMethod(class,
                            swizzingSelector,
                            method_getImplementation(originalMethod),
                            method_getTypeEncoding(originalMethod));
    }else{
        
        method_exchangeImplementations(originalMethod, swizzingMethod);
    }
}
@end

       接着,我们写一个UIViewController的分类, 在Load方法中把系统方法替换掉:

#import "UIViewController+actionAnalysis.h"
#import "HookTool.h"
#import "NSDate+Convenience.h"


@implementation UIViewController (actionAnalysis)

+(void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        SEL originalAppearSelector = @selector(viewWillAppear:);
        SEL swizzingAppearSelector = @selector(user_viewWillAppear:);
        [HookTool swizzingForClass:[self class] originalSel:originalAppearSelector swizzingSel:swizzingAppearSelector];
        
        SEL originalDisappearSelector = @selector(viewWillDisappear:);
        SEL swizzingDisappearSelector = @selector(user_viewWillDisappear:);
        [HookTool swizzingForClass:[self class] originalSel:originalDisappearSelector swizzingSel:swizzingDisappearSelector];
    });
}



-(void)user_viewWillAppear:(BOOL)animated
{
    //页面出现

    [self user_viewWillAppear:animated];
}


-(void)user_viewWillDisappear:(BOOL)animated
{
    //页面消失
    
    [self user_viewWillDisappear:animated];
}

@end

          此时还有个问题, 首先你可能并不想对每个页面进行统计, 但是又不想每次添加一个统计就加一个if判断。 这个时候我们就在Xcode中加入一张plist表, plist表里面记录我们所需统计的信息

      Runtime在项目中的使用场景_第1张图片

此时,我们只需要在hook的方法中去实现统计逻辑

-(void)user_viewWillAppear:(BOOL)animated
{
    NSDictionary * pageenter = [[HookTool getConfig] objectForKey:@"page_enter_anysis"];
    if ([pageenter.allKeys containsObject:NSStringFromClass([self class])]) {
        NSLog(@"%@ 页面展示", NSStringFromClass([self class]));
    }
    
    NSDictionary * pagetime = [[HookTool getConfig] objectForKey:@"page_time_anysis"];
    if ([pagetime.allKeys containsObject:NSStringFromClass([self class])]) {
        //此处用Userdefault存储只是因为方便书写, 实际用可以用一个单例去存储中间值
        [[NSUserDefaults standardUserDefaults] setDouble:[[NSDate date] timeIntervalSince1970] * 1000 forKey:@"appeartime"];
    }

    [self user_viewWillAppear:animated];
}


-(void)user_viewWillDisappear:(BOOL)animated
{
    //页面停留时间统计
    NSDictionary * pagetime = [[HookTool getConfig] objectForKey:@"page_time_anysis"];
    if ([pagetime.allKeys containsObject:NSStringFromClass([self class])]) {
        double leaveTime = NSDate.currenMillisecondTimestamp - [[NSUserDefaults standardUserDefaults] doubleForKey:@"appeartime"];
        NSLog(@"%@ 页面的停留时间为 %lf ms", [self class], leaveTime);
    }
    
    [self user_viewWillDisappear:animated];
}
        这样的话,以后做页面时长或者页面展示的统计,就只需要维护这个plist表就行了,不需要具体改动代码。 

        点击事件统计:

        与VC的统计类似, 也是利用catagory + hook的思想来实现,  我们可以添加一个UIControl的分类。但是具体需要hook UIControl的哪个方法那 ? 点击进入UIControl的api, 我们很容易发现需要Hook的方法

- (void)sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event;

         接着我们在UIControl的分类中实现方法的交互

@implementation UIControl (actionAnalysis)

+(void)load
{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        SEL originalSelector = @selector(sendAction:to:forEvent:);
        SEL swizzingSelector = @selector(user_sendAction:to:forEvent:);
        [HookTool swizzingForClass:[self class] originalSel:originalSelector swizzingSel:swizzingSelector];
    });
}


-(void)user_sendAction:(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);
    [self user_sendAction:action to:target forEvent:event];
}

      同样的, 我们只需要在plist中添加click的统计所需的参数就可以了

Runtime在项目中的使用场景_第2张图片

利用Runtime做用户埋点的就说这么多, 文章只提供思路, 具体plist的结构,或者代码细节根据情况自己做实现就行了。另外, 由于需求变动的原因,造成代码与配置表不匹配(例如可能会出现某个method名字被改变   )从而造成埋点统计失败,   建议写一个单元测试对Plist进行测试,思路: 在单元测试中我们首先读取plist配置文件,遍历所有的页面。在一个页面内遍历所有的ControlEventIDs,对每个响应函数名进行respondsToSelector:判断。 这样可以有效减少埋点失效问题。


二   处理异常崩溃(NSDictionary, NSMutableDictionary,  NSArray, NSMutableArray 的处理

        

          在开发过程中, 有时候会出现set object for key的时候 object为Nil或者Key为Nil, 又或者初始化array, dic的时候由于数据个数与指定的长度不一致造成崩溃。 此时利用runtime对异常情况进行捕捉,提前return或者抛弃多余的长度。


Dic:

#import "NSDictionary+Safe.h"
#import 

@implementation NSDictionary (Safe)

+ (void)load {
    Method originalMethod = class_getClassMethod(self, @selector(dictionaryWithObjects:forKeys:count:));
    Method swizzledMethod = class_getClassMethod(self, @selector(na_dictionaryWithObjects:forKeys:count:));
    method_exchangeImplementations(originalMethod, swizzledMethod);
}

+ (instancetype)na_dictionaryWithObjects:(const id [])objects forKeys:(const id  [])keys count:(NSUInteger)cnt {
    id nObjects[cnt];
    id nKeys[cnt];
    int i=0, j=0;
    for (; i)aKey {
    if (!anObject || !aKey)
        return;
    [self na_setObject:anObject forKey:aKey];
}

@end


array:

#import "NSArray+Safe.h"
#import 

@implementation NSArray (Safe)

+ (void)load {
    Method originalMethod = class_getClassMethod(self, @selector(arrayWithObjects:count:));
    Method swizzledMethod = class_getClassMethod(self, @selector(na_arrayWithObjects:count:));
    method_exchangeImplementations(originalMethod, swizzledMethod);
}

+ (instancetype)na_arrayWithObjects:(const id [])objects count:(NSUInteger)cnt {
    id nObjects[cnt];
    int i=0, j=0;
    for (; i


三  按钮最小点击区设置

        “按钮太不好点中了,点击好几次才点击到”,  测试经常会有这样的抱怨, 但是此时按钮图片本身设计就很小。  此时,例如Runtime进行点击区放大, 是个挺好的解决版本(当然也要注意不需要扩大的场景: 例如去年开发一个类似猫眼电影的app, 用户选座位的View里面是一个个小的控件,此时点击区域就不能放大,不然会误点,或者点击区遮盖。)

static const void *topNameKey = @"topNameKey";
static const void *rightNameKey = @"rightNameKey";
static const void *bottomNameKey = @"bottomNameKey";
static const void *leftNameKey = @"leftNameKey";


- (void)setEnlargeEdgeWithTop:(CGFloat)top right:(CGFloat)right bottom:(CGFloat)bottom left:(CGFloat)left{
    
    objc_setAssociatedObject(self, &topNameKey, [NSNumber numberWithFloat:top], OBJC_ASSOCIATION_COPY_NONATOMIC);
    objc_setAssociatedObject(self, &rightNameKey, [NSNumber numberWithFloat:right], OBJC_ASSOCIATION_COPY_NONATOMIC);
    objc_setAssociatedObject(self, &bottomNameKey, [NSNumber numberWithFloat:bottom], OBJC_ASSOCIATION_COPY_NONATOMIC);
    objc_setAssociatedObject(self, &leftNameKey, [NSNumber numberWithFloat:left], OBJC_ASSOCIATION_COPY_NONATOMIC);
}

- (CGRect)enlargedRect
{
    NSNumber *topEdge = objc_getAssociatedObject(self, &topNameKey);
    NSNumber *rightEdge = objc_getAssociatedObject(self, &rightNameKey);
    NSNumber *bottomEdge = objc_getAssociatedObject(self, &bottomNameKey);
    NSNumber *leftEdge = objc_getAssociatedObject(self, &leftNameKey);
    if (topEdge && rightEdge && bottomEdge && leftEdge) {
        return CGRectMake(self.bounds.origin.x - leftEdge.floatValue,
                          self.bounds.origin.y - topEdge.floatValue,
                          self.bounds.size.width + leftEdge.floatValue + rightEdge.floatValue,
                          self.bounds.size.height + topEdge.floatValue + bottomEdge.floatValue);
    }
    else
    {
        return self.bounds;
    }
}

- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event
{
    CGRect rect = [self enlargedRect];
    if (CGRectEqualToRect(rect, self.bounds)) {
        return [super hitTest:point withEvent:event];
    }
    return CGRectContainsPoint(rect, point) ? self : nil;
}


四  按钮的重复点击

      这个就不多说了,详细大部分程序员都遇到过, 直接上代码

+ (void)load{
    Method originalMethod = class_getInstanceMethod([self class], @selector(sendAction:to:forEvent:));
    Method swizzledMethod = class_getInstanceMethod([self class], @selector(User_SendAction:to:forEvent:));
    method_exchangeImplementations(originalMethod, swizzledMethod);
}

#pragma mark -- 时间间隔 --
static const void *ButtonDurationTime = @"ButtonDurationTime";
- (NSTimeInterval)durationTime{
    NSNumber *number = objc_getAssociatedObject(self, &ButtonDurationTime);
    return number.doubleValue;
}
- (void)setDurationTime:(NSTimeInterval)durationTime{
    NSNumber *number = [NSNumber numberWithDouble:durationTime];
    objc_setAssociatedObject(self, &ButtonDurationTime, number, OBJC_ASSOCIATION_COPY_NONATOMIC);
   
}

- (void)User_SendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event{
  
    self.userInteractionEnabled = NO;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.durationTime * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        self.userInteractionEnabled = YES;
    });
    
    [self User_SendAction:action to:target forEvent:event];
}

五  手势的重复点击处理

      手势重复点击有个误区: 不能通过拦截 addTarget:(id)target action:(SEL)action 这个方法来实现,因为这个方法是是添加方法,即使我们交换了,在执行的时候并没有什么变化的。正确的做法是添加一个timeInterval,然后在代理里面根据timeInterval设置UITapGestureRecognizer的enable属性

#import "UITapGestureRecognizer+LOOExtension.h"
#import 

@interface UITapGestureRecognizer ()
///时间间隔
@property (nonatomic,assign) NSTimeInterval duration;

@end

static const void *UITapGestureRecognizerduration = @"GestureRecognizerduration";

@implementation UITapGestureRecognizer (LOOExtension)

#pragma mark - Getter Setter

- (NSTimeInterval)duration{
    NSNumber *number = objc_getAssociatedObject(self, &UITapGestureRecognizerduration);
    return number.doubleValue;
}

- (void)setDuration:(NSTimeInterval)duration{
    NSNumber *number = [NSNumber numberWithDouble:duration];
    objc_setAssociatedObject(self, &UITapGestureRecognizerduration, number, OBJC_ASSOCIATION_COPY_NONATOMIC);
}



/**
 添加点击事件
 
 @param target taeget
 @param action action
 @param duration 时间间隔
 */
- (instancetype)initWithTarget:(id)target action:(SEL)action withDuration:(NSTimeInterval)duration{
    
    self = [super init];
    if (self) {
        self.duration = duration;
        self.delegate = self;
        [self addTarget:target action:action];
    }
    return self;
    
}


- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer{
    self.enabled = NO;
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(self.duration * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        self.enabled = YES;
    });
    
    return YES;
}

@end


六  UIButton点击带多参数

UIButton *btn = // create the button  
objc_setAssociatedObject(btn, "firstObject", someObject, OBJC_ASSOCIATION_RETAIN_NONATOMIC);   //实际上就是KVC  
objc_setAssociatedObject(btn, "secondObject", otherObject, OBJC_ASSOCIATION_RETAIN_NONATOMIC);  
  
[btn addTarget:self action:@selector(click:) forControlEvents:UIControlEventTouchUpInside];  
  
- (void)click:(UIButton *)sender  
{  
    id first = objc_getAssociatedObject(btn, "firstObject");        //取参  
    id second = objc_setAssociatedObject(btn, "secondObject");  
    // etc.  
}  

这么使用runtime感觉有点鸡肋,至少在自己的iOS生涯中,没有必须需要这么做的时候。 其实写个子类,添加个Parameter属性岂不是更简单。

七  MJRefresh的封装

大部分程序员应该都用过MJRefresh这个工具,大部分用法都每次出现tabview初始化后, 都初始化出来一个 mj_header, mj_footer, 并且设置 header与footer后, 把mj_header与mj_footer复制给tableview.mj_header, tableview.mj_footer.  每次去重复创建Header, Footer, 这个是不能容忍的。  我们知道tableviewcollectionView都是继承自scrollView,那么我们可以在 scrollView的分类里面添加一些方法,那么我们在以后使用的时候,就不需要一遍一遍的重复写无用代码了,只需要调用scrollView分类方法就可以了。

#import "UIScrollView+JHRefresh.h"
#import 
@implementation UIScrollView (JHRefresh)
/**
 添加刷新事件
 
 @param headerBlock 头部刷新
 @param footerBlock 底部刷新
 */
- (void)setRefreshWithHeaderBlock:(void(^)(void))headerBlock
                      footerBlock:(void(^)(void))footerBlock{
    if (headerBlock) {
        
        MJRefreshNormalHeader *header= [MJRefreshNormalHeader headerWithRefreshingBlock:^{
            if (headerBlock) {
                headerBlock();
            }
        }];
        header.stateLabel.font = [UIFont systemFontOfSize:13];
        header.lastUpdatedTimeLabel.font = [UIFont systemFontOfSize:13];
       
        self.mj_header = header;
    }
    
    if (footerBlock) {
        MJRefreshBackNormalFooter *footer = [MJRefreshBackNormalFooter footerWithRefreshingBlock:^{
            footerBlock();
        }];
        footer.stateLabel.font = [UIFont systemFontOfSize:13];
        [footer setTitle:@"暂无更多数据" forState:MJRefreshStateNoMoreData];
        [footer setTitle:@"" forState:MJRefreshStateIdle];
        self.mj_footer.ignoredScrollViewContentInsetBottom = 44;
        self.mj_footer = footer;
    }
}



/**
 开启头部刷新
 */
- (void)headerBeginRefreshing{
    [self.mj_header beginRefreshing];
}


/**
 没有更多数据
 */
- (void)footerNoMoreData{
    [self.mj_footer setState:MJRefreshStateNoMoreData];
}

/**
 结束刷新
 */
- (void)endRefresh{
    
    if (self.mj_header) {
        [self.mj_header endRefreshing];
    }
    if (self.mj_footer) {
        [self.mj_footer endRefreshing];
    }
}


八  服务端控制页面跳转

      项目开发中,我们可能会有这样的需求: 根据服务端推送过来的数据规则,跳转到对应的控制器。 之前我们的做法是这样的: 前端与服务端定义好规则, 例如服务端推送  Push/Live/WatchLive/12, Push: push方式跳转 , Live指的直播模块, WatchLive指的看直播的功能, 12指的房间号, 也就是跳转到12号主播间。  但是这么做坏处就是,必须提前与服务端约定好协议, 每次运营如果加一个新的跳转, 移动端需要改代码,重新上线。扩展性很低。

    其实利用Runtime完全可以写成通用的方式来实现跳转。例如外面与服务端定义好推送规则后,服务端推送过来的数据如下:

// 这个规则肯定事先跟服务端沟通好,跳转对应的界面需要对应的参数
NSDictionary *userInfo = @{
                           @"class": @"LiveViewController",     //VC的名字
                           @"property": @{
                                        @"ID": @"123",          //参数名字为 ID , value为 123
                                        @"type": @"12"          //type为附加信息, 根据实际情况定义
                                   }
                           };

接着我们利用Runtime进行跳转

// 类名
    NSString *class =[NSString stringWithFormat:@"%@", params[@"class"]];
    const char *className = [class cStringUsingEncoding:NSASCIIStringEncoding];
    
    // 从一个字串返回一个类
    Class newClass = objc_getClass(className);
    if (!newClass)
    {
        return;   //推送的class不存在
    }
    // 创建对象
    id instance = [[newClass alloc] init];
    
    // 对该对象赋值属性
    NSDictionary * propertys = params[@"property"];
    [propertys enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
        // 检测这个对象是否存在该属性
        if ([self checkIsExistPropertyWithInstance:instance verifyPropertyName:key]) {
            // 利用kvc赋值
            [instance setValue:obj forKey:key];
        }
    }];
    
    // 获取导航控制器
    UITabBarController *tabVC = (UITabBarController *)self.window.rootViewController;
    UINavigationController *pushClassStance = (UINavigationController *)tabVC.viewControllers[tabVC.selectedIndex];
    // 跳转到对应的控制器
    [pushClassStance pushViewController:instance animated:YES];

检测属性是否存在

- (BOOL)checkIsExistPropertyWithInstance:(id)instance verifyPropertyName:(NSString *)verifyPropertyName
{
    unsigned int outCount, i;
    
    // 获取对象里的属性列表
    objc_property_t * properties = class_copyPropertyList([instance
                                                           class], &outCount);
    
    for (i = 0; i < outCount; i++) {
        objc_property_t property =properties[i];
        //  属性名转成字符串
        NSString *propertyName = [[NSString alloc] initWithCString:property_getName(property) encoding:NSUTF8StringEncoding];
        // 判断该属性是否存在
        if ([propertyName isEqualToString:verifyPropertyName]) {
            free(properties);
            return YES;
        }
    }
    free(properties);
    
    return NO;
}

九  字典转模型

 获取属性的列表的方法是字典转模型的比较核心的方法。常见的字典转模型的三方有 MJExtension,  YYModel, JsonModel等, 翻看其源码, 都会发现  Ivar *class_copyIvarList(Class cls, unsigned int *outCount)的使用

MJExtension核心代码摘录
Runtime在项目中的使用场景_第3张图片
YYModel核心代码摘录

Runtime在项目中的使用场景_第4张图片

JsonModel json字典转model 摘录

Runtime在项目中的使用场景_第5张图片


        基本上主流的json 转model 都少不了,使用运行时动态获取属性的属性名的方法,来进行字典转模型替换,字典转模型效率最高的(耗时最短的)的是KVC,其他的字典转模型是在KVC 的key 和Value 做处理,动态的获取json 中的key 和value ,当然转换的过程中,第三方框架需要做一些判空啊,镶嵌的逻辑处理, 再进行KVC 转模型.这句代码 [xx  setValue:value forKey:key];无论JsonModle,YYKIt,MJextension 都少不了[xx  setValue:value forKey:key];这句代码的,不信可以去搜,这是字典转模型的核心方法,


参考文章:

RunTime使用案例

iOS Runtime基础学习

Runtime 10种用法

iOS 万能跳转界面方法

iOS动态性(二)可复用而且高度解耦的用户统计埋点实现


你可能感兴趣的:(IOS开发知识,iOS开发小技巧)