美文网首页runtime.runloop
iOS - runtime的一些运用场景

iOS - runtime的一些运用场景

作者: 恍然如梦_b700 | 来源:发表于2020-07-10 00:27 被阅读0次

    首先,归纳下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 <objc/runtime.h>
    
    @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表里面记录我们所需统计的信息


    image.png

    此时,我们只需要在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的统计所需的参数就可以了


    image.png

    利用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 <objc/runtime.h>
     
    @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 <NSCopying> [])keys count:(NSUInteger)cnt {
        id nObjects[cnt];
        id nKeys[cnt];
        int i=0, j=0;
        for (; i<cnt && j<cnt; i++) {
            if (objects[i] && keys[i]) {
                nObjects[j] = objects[i];
                nKeys[j] = keys[i];
                j++;
            }
        }
        
        return [self na_dictionaryWithObjects:nObjects forKeys:nKeys count:j];
    }
     
    @end
     
    @implementation NSMutableDictionary (Safe)
     
    + (void)load {
        Class dictCls = NSClassFromString(@"__NSDictionaryM");
        Method originalMethod = class_getInstanceMethod(dictCls, @selector(setObject:forKey:));
        Method swizzledMethod = class_getInstanceMethod(dictCls, @selector(na_setObject:forKey:));
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }
     
    - (void)na_setObject:(id)anObject forKey:(id<NSCopying>)aKey {
        if (!anObject || !aKey)
            return;
        [self na_setObject:anObject forKey:aKey];
    }
     
    @end
    
    

    array:

    #import "NSArray+Safe.h"
    #import <objc/runtime.h>
     
    @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<cnt && j<cnt; i++) {
            if (objects[i]) {
                nObjects[j] = objects[i];
                j++;
            }
        }
        
        return [self na_arrayWithObjects:nObjects count:j];
    }
    @end
     
    @implementation NSMutableArray (Safe)
     
    + (void)load {
        Class arrayCls = NSClassFromString(@"__NSArrayM");
        
        Method originalMethod1 = class_getInstanceMethod(arrayCls, @selector(insertObject:atIndex:));
        Method swizzledMethod1 = class_getInstanceMethod(arrayCls, @selector(na_insertObject:atIndex:));
        method_exchangeImplementations(originalMethod1, swizzledMethod1);
        
        Method originalMethod2 = class_getInstanceMethod(arrayCls, @selector(setObject:atIndex:));
        Method swizzledMethod2 = class_getInstanceMethod(arrayCls, @selector(na_setObject:atIndex:));
        method_exchangeImplementations(originalMethod2, swizzledMethod2);
    }
     
    - (void)na_insertObject:(id)anObject atIndex:(NSUInteger)index {
        if (!anObject)
            return;
        [self na_insertObject:anObject atIndex:index];
    }
     
    - (void)na_setObject:(id)anObject atIndex:(NSUInteger)index {
        if (!anObject)
            return;
        [self na_setObject:anObject atIndex:index];
    }
     
    @end
    

    三 按钮最小点击区设置

    按钮太不好点中了,点击好几次才点击到”, 测试经常会有这样的抱怨, 但是此时按钮图片本身设计就很小。 此时,例如Runtime进行点击区放大, 是个挺好的解决版本

    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 <objc/runtime.h>
     
    @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, 这个是不能容忍的。 我们知道tableview和collectionView都是继承自scrollView,那么我们可以在 scrollView的分类里面添加一些方法,那么我们在以后使用的时候,就不需要一遍一遍的重复写无用代码了,只需要调用scrollView分类方法就可以了。

    #import "UIScrollView+JHRefresh.h"
    #import <MJRefresh.h>
    @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核心代码摘录


    20180503143111683.png

    YYModel核心代码摘录


    20180503143407891.png

    JsonModel json字典转model 摘录


    20180503143454842.png

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

    参考:
    https://blog.csdn.net/SandyLoo/article/details/80174890

    相关文章

      网友评论

        本文标题:iOS - runtime的一些运用场景

        本文链接:https://www.haomeiwen.com/subject/tivecktx.html