美文网首页程序员iOS
第四篇:runtime的实际应用场景——黑魔法(Method S

第四篇:runtime的实际应用场景——黑魔法(Method S

作者: 意一ineyee | 来源:发表于2017-08-16 16:55 被阅读85次

目录

一、什么是黑魔法
二、黑魔法的实际应用场景
 1、从全局上为导航栏添加返回按钮
 2、从全局上防止button的暴力点击
 3、刷新tableView、collectionView时,自动判断是否显示暂无数据提示图

本篇主要讲解runtime的实际应用场景:黑魔法。是对方法应用的一个例子。

一、什么是黑魔法


黑魔法其实就是指我们在运行时(更具体的说是在编译结束到方法真正被调用之前这段空档期)改变一个方法的实现,它没那么神秘,就这么简单。

举个例子,比方说我们想在每个ViewController加载完成后都打印一下它的名字,有三种方案:

  • 方案一:在每个ViewController的viewDidLoad方法里打印当前控制器的名字。但这明显不实际,工作量太大了。

  • 方案二:采用基类的方式从全局上为ViewController的viewDidLoad方法添加打印当前控制器名字的功能,即写一个继承自UIViewController的基类BaseViewController,在基类的viewDidLoad方法里打印当前控制器的名字,然后让项目中所有的ViewController都继承自BaseViewController。这种方案貌似可行,但是当我们创建UINavigationControllerUITabBarControllerUITableViewControllerUICollectionViewController等这些控制器时,人家还是直接继承自UIViewController的,所以为了保证效果,我们还需要为它们再分别创建相应的基类,这同样会出现大量重复的代码;同时这种方式也不利于项目之间迁移共用的代码,比方说我们把写好的各种基类拖进了另外一个项目想要共用我们之前写过的代码,但发现项目里所有的ViewController都是直接继承自UIViewController的,那我们要想达到效果的话,就得把项目里所有的ViewController都改成继承自BaseViewController,这工作量可以说相当大了。

  • 方案三:使用黑魔法从全局上为ViewController的viewDidLoad方法添加打印当前控制器名字的功能,即替换系统viewDidLoad方法的原生实现,为它增加一个打印当前控制器名字的功能。代码如下:

#import "UIViewController+MethodSwizzling.h"
#import <objc/runtime.h>

@implementation UIViewController (MethodSwizzling)

// 把方法的替换操作写在类的+load方法里来,来保证替换操作肯定执行了
+ (void)load {

    // 用dispatch_once来保证方法的替换操作只执行一次
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        // 获取方法的选择子
        SEL originalSelector = @selector(viewDidLoad);
        SEL swizzledSelector = @selector(yy_viewDidLoad);
        
        // 获取实例方法
        Method originalMethod = class_getInstanceMethod(self, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(self, swizzledSelector);
        
        // 获取方法的实现
        IMP originalIMP = method_getImplementation(originalMethod);
        IMP swizzleIMP = method_getImplementation(swizzledMethod);
        
        // 获取方法的参数和返回值信息
        const char *originalTypeEncoding = method_getTypeEncoding(originalMethod);
        const char *swizzledTypeEncoding = method_getTypeEncoding(swizzledMethod);
        
        // 先尝试添加方法,因为如果原生方法根本没实现的话,是交换不成功的
        BOOL didAddMethod = class_addMethod(self, originalSelector, swizzleIMP, swizzledTypeEncoding);
        
        if (didAddMethod) {// 原生方法没实现,此时originalSelector已经指向新方法,我们把swizzledSelector指向原生方法,为的下面新方法还要调用一下原生方法,避免丢掉原生方法的实现
            
            class_replaceMethod(self, swizzledSelector, originalIMP, originalTypeEncoding);
        } else {// 原生方法实现了,直接交换两个方法

            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

- (void)yy_viewDidLoad {
    
    // 调用一下方法的原生实现,避免丢掉方法的原生实现而导致不可预知的bug。这里不会产生死循环,因为此时yy_viewDidLoad已经指向系统的原生方法viewDidLoad了
    [self yy_viewDidLoad];
    
    NSLog(@"===========>%@", [self class]);
}

@end

不过使用黑魔法一定要慎重,不能滥用,否则可能出现你不可预知的bug,有下面几点需要注意:

  • 方法的替换操作一定要写在类的+load方法里。OC中,runtime会自动触发每个类的两个方法,+load方法会在某个类第一次被加载或引入的时候触发且只被触发一次(也就是说只要你动态加载或静态引入了某个类,App启动时这个类的+load方法就会被触发,并不是非要你等到你显性的创建某个类时它才会被触发,而且即便你创建了某个类的一百个实例,它的+load方法也只会在最开始加载或引入的时候触发一次),+initialize方法在类的类方法或实例方法被调用的时候触发。如果我们把方法的替换操作写在+initialize方法里,就不能保证替换操作肯定执行了,因为一个类的方法可能一个都没被调用。所以我们要把方法的替换操作写在类的+load方法里来,来保证替换操作肯定执行了。

  • 方法的替换操作一定要写在dispatch_once。虽然说+load方法本身就只会被触发一次,但是我们无法避免某些情况下程序员自己主动调用了+load方法,这样就可能导致已经交换了实现的两个方法又把实现换回来了,因此我们要用dispatch_once来保证方法的替换操作只执行一次。

  • 在新方法的实现里可以判断一下触发了该方法的类是不是当前类,因为有可能是当前类类簇里的子类触发的,我们并不想改掉类簇里子类对该方法的实现,只想当前类的。(例如下面的button和tableView就有类簇,OC中大量使用了类簇,我们常用的NSString、NSArray、NSDictionary等都采用类簇的形式实现。)

  • 在新方法的实现里一定要记得调用一下方法的原生实现(除非你非常确定不需要调用方法的原生实现),因为如果不调用一下的话,就有可能因为丢掉方法的原生实现而导致不可预知的bug。

二、黑魔法的实际应用场景


黑魔法的实际应用场景主要就是:

  • 当我们发现系统方法的原生实现无法满足我们的某些需求时,我们就可以替换掉系统方法的原生实现,为其添加一些我们的定制化需求。

  • 我们使用别人的三方库,库里有些方法无法满足我们的需求或者有bug,我们也最好使用黑魔法来处理,而不是直接去该三方库的源码,因为你改了源码后一旦更新了三方库,问题就又出来了

下面仅仅是举三个我在实际开发中用到黑魔法的例子,只要我们理解了黑魔法其实就是指我们在运行时(更具体的说是在编译结束到方法真正被调用之前这段空档期)改变一个方法的实现这一概念,就可以按自己的开发需求灵活的运用它了。

1、从全局上为导航栏添加返回按钮

开发中,我们几乎总是要为一个ViewController添加一个返回按钮,添加的方案也有很多种:

  • 方案一:在每个ViewController的viewDidLoad方法里为导航栏添加返回按钮。

  • 方案二:采用基类的方式从全局上为每个控制器的导航栏添加返回按钮,即写一个继承于UIViewController的基类BaseViewController,在基类的viewDidLoad方法里为导航栏添加返回按钮。

  • 这两种方案的不足之处其实我们在第一部分的时候已经分析过了,所以此处我们直接采用方案三:使用黑魔法替换掉系统viewDidLoad方法的原生实现,为它增加一个为导航栏添加返回按钮的功能。代码如下:

#import "UIViewController+YY_NavigationBar.h"
#import <objc/runtime.h>

@implementation UIViewController (YY_NavigationBar)

+ (void)load {
    
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        SEL originalSelector = @selector(viewDidLoad);
        SEL swizzledSelector = @selector(yy_viewDidLoad);
        
        Method originalMethod = class_getInstanceMethod(self, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(self, swizzledSelector);
        
        IMP originalIMP = method_getImplementation(originalMethod);
        IMP swizzleIMP = method_getImplementation(swizzledMethod);
        
        const char *originalTypeEncoding = method_getTypeEncoding(originalMethod);
        const char *swizzledTypeEncoding = method_getTypeEncoding(swizzledMethod);
        
        BOOL didAddMethod = class_addMethod(self, originalSelector, swizzleIMP, swizzledTypeEncoding);
        
        if (didAddMethod) {
            
            class_replaceMethod(self, swizzledSelector, originalIMP, originalTypeEncoding);
        } else {
            
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

- (void)yy_viewDidLoad {
    
    [self yy_viewDidLoad];
    
    if (self.navigationController.viewControllers.count > 1) {// 控制器数量超过两个才添加
        
        self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithTitle:@"返回" style:(UIBarButtonItemStylePlain) target:self action:@selector(yy_leftBarButtonItemAction:)];
    }
}

- (void)yy_leftBarButtonItemAction:(UIBarButtonItem *)leftBarButtonItem {
    
    [self.navigationController popViewControllerAnimated:YES];
}

@end
2、从全局上防止button的暴力点击

开发中,我们经常会添加button的点击事件,因此防止button的暴力点击就显得很有必要,否则很容易出现bug。考虑一下方案:

  • 方案一:在第一次点击了button之后立马禁掉button的userInteractionEnabled,然后等点击事件处理完再打开button的userInteractionEnabled
- (IBAction)buttonAction:(UIButton *)button {
    
    // 禁掉button的userInteractionEnabled
    button.userInteractionEnabled = NO;
    
    // 执行button的点击的事件,这里假设事件在3s后结束
    NSLog(@"11111111111");
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        
        // 点击事件处理完再打开button的userInteractionEnabled
        button.userInteractionEnabled = YES;
    });
}

这样做确实可以防止button的暴力点击,但是有一个麻烦事儿在于我们要为项目里所有button的点击事件都分别添加这样的处理,而且由于不同button的点击事件不一样,我们还没办法把其中的公共部分给提取出来,所以这种方案工作量太大,可以放弃。

  • 方案二:使用黑魔法替换系统sendAction:to:forEvent:方法的实现,从全局上防止button的暴力点击。

方案一虽然被我们放弃了,但它的实现思路还是可取的,它的实现思路其实就是:第一次点击button的时候,让button响应事件,然后后面如果出现对button的暴力点击,则不让button响应事件

根据这一实现思路,我们可以通过判断这一次点击button和上一次点击button的时间间隔,来决定此次点击是否被认定为暴力点击,如果被认定为暴力点击则不让button处理事件,否则让button正常处理事件,这个时间间隔由我们自己设定

此外,我们知道所有继承自UIControl的类都能响应事件,而当它们处理事件时都会触发sendAction:to:forEvent:方法,因此我们可以用黑魔法替换掉这个方法的原生实现,为它新增一小点功能----即我们上面所陈述的实现思路。

#import "UIButton+YY_PreventViolentClick.h"
#import <objc/runtime.h>

#define kTwoTimeClickTimeInterval 1.0// 两次点击的时间间隔,用来确定后一次点击是否被认定为暴力点击

@interface UIButton ()

@property (nonatomic, assign) NSTimeInterval yy_lastTimeClickTimestamp;// 上一次点击的时间戳

@end

@implementation UIButton (YY_PreventViolentClick)

+ (void)load {
    
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        SEL originalSelector = @selector(sendAction:to:forEvent:);
        SEL swizzledSelector = @selector(yy_sendAction:to:forEvent:);
        
        Method originalMethod = class_getInstanceMethod(self, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(self, swizzledSelector);
        
        IMP originalIMP = method_getImplementation(originalMethod);
        IMP swizzleIMP = method_getImplementation(swizzledMethod);
        
        const char *originalTypeEncoding = method_getTypeEncoding(originalMethod);
        const char *swizzledTypeEncoding = method_getTypeEncoding(swizzledMethod);
        
        BOOL didAddMethod = class_addMethod(self, originalSelector, swizzleIMP, swizzledTypeEncoding);
        
        if (didAddMethod) {
            
            class_replaceMethod(self, swizzledSelector, originalIMP, originalTypeEncoding);
        } else {
            
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

- (void)yy_sendAction:(SEL)action to:(nullable id)target forEvent:(nullable UIEvent *)event  {
    
    if ([[self class] isEqual:[UIButton class]]) {// 防止替换掉UIButton类簇里子类方法的实现
        
        // 获取此次点击的时间戳
        NSTimeInterval currentTimeClickTimestamp = [[NSDate date] timeIntervalSince1970];
        
        if (currentTimeClickTimestamp - self.yy_lastTimeClickTimestamp < kTwoTimeClickTimeInterval) {// 如果此次点击和上一次点击的时间间隔小于我们设定的时间间隔,则判定此次点击为暴力点击,什么都不做
            
            return;
        } else {// 否则我们判定此次点击为正常点击,button正常处理事件
            
            // 记录上次点击的时间戳
            self.yy_lastTimeClickTimestamp = currentTimeClickTimestamp;
            
            [self yy_sendAction:action to:target forEvent:event];
        }
    }else {
        
        [self yy_sendAction:action to:target forEvent:event];
    }
}

- (void)setYy_lastTimeClickTimestamp:(NSTimeInterval)yy_lastTimeClickTimestamp {
    
    objc_setAssociatedObject(self, @"yy_lastTimeClickTimestamp", @(yy_lastTimeClickTimestamp), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSTimeInterval)yy_lastTimeClickTimestamp {
    
    return [objc_getAssociatedObject(self, @"yy_lastTimeClickTimestamp") doubleValue];
}

@end
3、刷新tableView、collectionView时,自动判断是否显示暂无数据提示图

当我们遇到请求数据为空时,就需要为tableView和collectionView添加一个暂无数据的提示图。考虑一下方案(tableView和collectionView类似,下面以tableView为例):

  • 方案一:老早以前,那会还没学习runtime,我的解决办法是在每个viewController的numberOfSectionsInTableView:这个方法里判断请求到的数据是不是空,如果是空的话就显示暂无数据的提示图,否则就不显示。这种方案很明显的不足就是要在每个用到tableView的控制器里都写同样的代码,代码重复,工作量太大。

  • 方案二:现在学习了runtime,我们就可以优化处理方案了。首先我们考虑下为什么要在numberOfSectionsInTableView:方法里处理暂无数据的提示图,是因为每次刷新数据的时候我们都需要调用reloadData方法,而reloadData之后我们才需要处理暂无数据的提示图,所以我们只能去找reloadData之后肯定会被触发的方法来做这个操作,于是我们就找到了numberOfSectionsInTableView:方法。现在有了runtime,我们就可以把这个问题归结为系统的reloadData方法无法满足我的需求,我们可以用黑魔法改变reloadData方法的原生实现, 为它增加自动判断是否显示暂无数据提示图的功能。核心实现如下图,代码如下:

-----------UITableView+YY_PromptImage.h-----------

@interface UITableView (YY_PromptImage)

/// 提示图的名字
@property (nonatomic, copy) NSString *yy_promptImageName;
/// 点击提示图的回调
@property (nonatomic, copy) void(^yy_didTapPromptImage)(void);

/// 不使用该分类里的这套判定规则
@property (nonatomic, assign) BOOL yy_dontUseThisCategory;

@end


-----------UITableView+YY_PromptImage.m-----------

#import "UITableView+YY_PromptImage.h"
#import <objc/runtime.h>

@interface UITableView ()

// 已经调用过reloadData方法了
@property (nonatomic, assign) BOOL yy_hasInvokedReloadData;

// 提示图
@property (nonatomic, strong) UIImageView *yy_promptImageView;

@end

@implementation UITableView (YY_PromptImage)

+ (void)load {
    
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        
        SEL originalSelector = @selector(reloadData);
        SEL swizzledSelector = @selector(yy_reloadData);
        
        Method originalMethod = class_getInstanceMethod(self, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(self, swizzledSelector);
        
        IMP originalIMP = method_getImplementation(originalMethod);
        IMP swizzleIMP = method_getImplementation(swizzledMethod);
        
        const char *originalTypeEncoding = method_getTypeEncoding(originalMethod);
        const char *swizzledTypeEncoding = method_getTypeEncoding(swizzledMethod);
        
        BOOL didAddMethod = class_addMethod(self, originalSelector, swizzleIMP, swizzledTypeEncoding);
        
        if (didAddMethod) {
            
            class_replaceMethod(self, swizzledSelector, originalIMP, originalTypeEncoding);
        } else {
            
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

- (void)yy_reloadData {
    
    if ([[self class] isEqual:[UITableView class]] && !self.yy_dontUseThisCategory) {// 防止替换掉UITableView类簇里子类方法的实现
        
        [self yy_reloadData];
        
        if (self.yy_hasInvokedReloadData) {// 而是只在请求数据完成后,调用reloadData刷新界面时才处理提示图的显隐
        
            [self yy_handlePromptImage];
        } else {// tableView第一次加载的时候会自动调用一下reloadData方法,这一次调用我们不处理提示图的显隐

            self.yy_hasInvokedReloadData = YES;
        }
    } else {
        
        [self yy_reloadData];
    }
}


#pragma mark - private method

// 提示图的显隐
- (void)yy_handlePromptImage {
    
    if ([self yy_dataIsEmpty]) {
        
        [self yy_showPromptImage];
    }else {
        
        [self yy_hidePromptImage];
    }
}

// 判断请求到的数据是否为空
- (BOOL)yy_dataIsEmpty {
    
    // 获取分区数
    NSInteger sections = 0;
    if ([self.dataSource respondsToSelector:@selector(numberOfSectionsInTableView:)]) {// 如果外界实现了该方法,则读取外界提供的分区数
        
        sections = [self numberOfSections];
    } else {// 如果外界没实现该方法,系统不是会自动给我们返回一个分区嘛
        
        sections = 1;
    }
    
    if (sections == 0) {// 分区数为0,说明数据为空
        
        return YES;
    }
    
    
    // 分区数不为0,则需要判断每个分区下的行数
    for (int i = 0; i < sections; i ++) {
        
        // 获取各个分区的行数
        NSInteger rows = [self numberOfRowsInSection:i];
        
        if (rows != 0) {// 但凡有一个分区下的行数不为0,说明数据不为空
            
            return NO;
        }
    }
    
    
    // 如果所有分区下的行数都为0,才说明数据为空
    return YES;
}

// 显示提示图
- (void)yy_showPromptImage {
    
    if (self.yy_promptImageView == nil) {
        
        self.yy_promptImageView = [[UIImageView alloc] initWithFrame:self.backgroundView.bounds];
        self.yy_promptImageView.backgroundColor = [UIColor clearColor];
        self.yy_promptImageView.contentMode = UIViewContentModeCenter;
        self.yy_promptImageView.userInteractionEnabled = YES;
        
        if (self.yy_promptImageName.length == 0) {
            
            self.yy_promptImageName = @"YY_PromptImage";
        }
        self.yy_promptImageView.image = [UIImage imageNamed:self.yy_promptImageName];
        
        UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(yy_didTapPromptImage:)];
        [self.yy_promptImageView addGestureRecognizer:tapGestureRecognizer];
    }
    
    self.backgroundView = self.yy_promptImageView;
}

// 隐藏提示图
- (void)yy_hidePromptImage {
    
    self.backgroundView = nil;
}

// 点击提示图的回调
- (void)yy_didTapPromptImage:(UITapGestureRecognizer *)tapGestureRecognizer {
    
    if (self.yy_didTapPromptImage) {
        
        self.yy_didTapPromptImage();
    }
}


#pragma mark - setter, getter

- (void)setYy_hasInvokedReloadData:(BOOL)yy_hasInvokedReloadData {
    
    objc_setAssociatedObject(self, @"yy_hasInvokedReloadData", @(yy_hasInvokedReloadData), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (BOOL)yy_hasInvokedReloadData {
    
    return [objc_getAssociatedObject(self, @"yy_hasInvokedReloadData") boolValue];
}

- (void)setYy_promptImageView:(UIImageView *)yy_promptImageView {
    
    objc_setAssociatedObject(self, @"yy_promptImageView", yy_promptImageView, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (UIImageView *)yy_promptImageView {
    
    return objc_getAssociatedObject(self, @"yy_promptImageView");
}

- (void)setYy_promptImageName:(NSString *)yy_promptImageName {
    
    objc_setAssociatedObject(self, @"yy_promptImageName", yy_promptImageName, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSString *)yy_promptImageName {
    
    return objc_getAssociatedObject(self, @"yy_promptImageName");
}

- (void)setYy_didTapPromptImage:(void (^)(void))yy_didTapPromptImage {
 
    objc_setAssociatedObject(self, @"yy_didTapPromptImage", yy_didTapPromptImage, OBJC_ASSOCIATION_COPY);
}

- (void (^)(void))yy_didTapPromptImage {
    
    return objc_getAssociatedObject(self, @"yy_didTapPromptImage");
}

- (void)setYy_dontUseThisCategory:(BOOL)yy_dontUseThisCategory {
 
    objc_setAssociatedObject(self, @"yy_dontUseThisCategory", @(yy_dontUseThisCategory), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (BOOL)yy_dontUseThisCategory {
 
    return [objc_getAssociatedObject(self, @"yy_dontUseThisCategory") boolValue];
}

@end

相关文章

网友评论

  • SoaringHeart:如果点击返回按钮需要做一些需求判断,如何处理?
    意一ineyee:@蓝梦星魂 - (void)leftBarButtonItemAction:(UIBarButtonItem *)leftBarButtonItem {

    // 自定义事件, 例如提交信息

    // 提交信息完成后调用
    [super leftBarButtonItemAction:leftBarButtonItem];

    // 或直接写
    [self.navigationController popViewControllerAnimated:YES];
    }
    SoaringHeart:@意一yiyi 自定义操作后面调用 [super leftBarButtonItemAction:] 这点不太明白
    意一ineyee:暂时这么处理的
    /**
    * leftBarButtonItem 事件
    *
    * (1) 默认实现为 pop 回上一层
    * (2) 如需添加自定义操作, 则重写该方法完成自定义操作, 并在自定义操作后面调用 [super leftBarButtonItemAction:]
    * (3) 如完全是自定义操作(即不 pop 回上一层), 则重写该方法即可
    * (4) 如果是多 barButtonItem 的情况, 需要重写该方法, 在方法里做判断做不同的操作
    */
    - (void)leftBarButtonItemAction:(UIBarButtonItem *)leftBarButtonItem;

本文标题:第四篇:runtime的实际应用场景——黑魔法(Method S

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