第四篇:runtime的實際應用場景——黑魔法(Method Swizzling)

  • 時間:2018-10-31 23:08 作者:意一yiyi 來源:意一yiyi 閱讀:128
  • 掃一掃,手機訪問
摘要:目錄一、什么是黑魔法二、黑魔法的實際應用場景?1、從全局上為導航欄增加返回按鈕?2、從全局上防止button的暴力點擊?3、刷新tableView、collectionView時,自動判斷能否顯示暫無數據提醒圖本篇主要講解runtime的實際應用場景:黑魔法。是對方法應用的一個例子。一、什么是黑魔法

目錄

一、什么是黑魔法
二、黑魔法的實際應用場景
?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;// 上一次點擊的時間戳@[email protected] 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方法的原生實現, 為它添加自動判斷能否顯示暫無數據提醒圖的功能。核心實現如下圖,代碼如下:

[email protected] UITableView (YY_PromptImage)/// 提醒圖的名字@property (nonatomic, copy) NSString *yy_promptImageName;/// 點擊提醒圖的回調@property (nonatomic, copy) void(^yy_didTapPromptImage)(void);/// 不使用該分類里的這套判定規[email protected] (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;// 提醒[email protected] (nonatomic, strong) UIImageView *yy_promptImageView;@[email protected] 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
  • 全部評論(0)
最新發布的資訊信息
【系統環境|服務器應用】Discuz發布帖子時默認顯示第一個主題分類的修改方法(2019-12-09 00:13)
【系統環境|軟件環境】Android | App內存優化 之 內存泄漏 要點概述 以及 處理實戰(2019-12-04 14:27)
【系統環境|軟件環境】MySQL InnoDB 事務(2019-12-04 14:26)
【系統環境|軟件環境】vue-router(單頁面應用控制中心)常見用法(2019-12-04 14:26)
【系統環境|軟件環境】Linux中的Kill命令(2019-12-04 14:26)
【系統環境|軟件環境】Linux 入門時必學60個文件解決命令(2019-12-04 14:26)
【系統環境|軟件環境】更新版ThreeJS 3D粒子波浪動畫(2019-12-04 14:26)
【系統環境|軟件環境】前臺開發WebStorm常用快捷鍵,火速收藏!(2019-12-04 14:25)
【系統環境|軟件環境】微博H5登錄和發微博組件(2019-12-04 14:25)
【系統環境|軟件環境】5分鐘談前臺面試,小伙伴都驚呆了(2019-12-04 14:23)
手機二維碼手機訪問領取大禮包
返回頂部
澳洲幸运10精准人工计划