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

作者 : 开心源码 本文共13430个字,预计阅读时间需要34分钟 发布时间: 2022-05-12 共213人阅读

目录

一、什么是黑魔法
二、黑魔法的实际应用场景
?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

说明
1. 本站所有资源来源于用户上传和网络,如有侵权请邮件联系站长!
2. 分享目的仅供大家学习和交流,您必须在下载后24小时内删除!
3. 不得使用于非法商业用途,不得违反国家法律。否则后果自负!
4. 本站提供的源码、模板、插件等等其他资源,都不包含技术服务请大家谅解!
5. 如有链接无法下载、失效或广告,请联系管理员处理!
6. 本站资源售价只是摆设,本站源码仅提供给会员学习使用!
7. 如遇到加密压缩包,请使用360解压,如遇到无法解压的请联系管理员
开心源码网 » 第四篇:runtime的实际应用场景——黑魔法(Method Swizzling)

发表回复