iOS底層學習 - Runtime之Method Swizzling黑魔法

相信你們對於Method Swizzling並不陌生,在平時的開發中多多少少都有些使用,這也是Runtime的開發應用中比較普遍的用法。可是它確確實實是個黑魔法,一有不慎,就是一座天坑。本章就來研究一下它,讓他變成白魔法編程

什麼是Method Swizzling?

  1. Method Swizzling(方法交換),顧名思義,就是將兩個方法的實現交換,即由原來的A-AImpB-BImp對應關係變成了A-BImpB-AImp設計模式

  2. 每一個類都維護一個方法Method列表,Method則包含SEL和其對應IMP的信息,方法交換作的事情就是把SELIMP的對應關係斷開,並和新的IMP生成對應關係。數組

  3. Method Swizzing是發生在運行時的,主要用於在運行時將兩個Method進行交換,咱們能夠將Method Swizzling代碼寫到任何地方,可是隻有在這段Method Swilzzling代碼執行完畢以後互換才起做用bash

  4. Method Swizzling是OC動態性的最好詮釋,深刻地去學習並理解其特性,將有助於咱們在業務量不斷增大的同時還能保持代碼的低耦合度,下降維護的工做量和難度。服務器

能夠用圖來更好的解釋一下app

交換前: 框架

交換後:

Method Swizzling相關函數API

//獲取經過SEL獲取一個方法
class_getInstanceMethod
複製代碼
//獲取一個方法的實現
method_getImplementation
複製代碼
//獲取一個OC實現的編碼類型
method_getTypeEncoding
複製代碼
//給方法添加實現
class_addMethod
複製代碼
//用一個方法的實現替換另外一個方法的實現
class_replaceMethod
複製代碼
//交換兩個方法的實現
method_exchangeImplementations
複製代碼

Method Swizzling使用注意事項

  • 1.方法交換應該保證惟一性和原子性
    • 惟一性:應該儘量在+load方法中實現,這樣能夠保證方法必定會調用且不會出現異常。
    • 原子性:使用dispatch_once來執行方法交換,這樣能夠保證只運行一次。
  • 2.必定要調用原始實現
    • 因爲iOS的內部實現對咱們來講是不可見的,使用方法交換可能會致使其代碼結構改變,而對系統產生其餘影響,所以應該調用原始實現來保證內部操做的正常運行
  • 3.方法名必須不能產生衝突
    • 這個是常識,避免跟其餘庫產生衝突。
  • 4.作好註釋和Log
    • 記錄好被影響過的方法,否則時間長了或者其餘人debug代碼時候可能會對一些輸出信息感到困惑。
  • 5.若是非無可奈何,儘可能少用方法交換
    • 雖然方法交換可讓咱們高效地解決問題,可是若是處理很差,可能會致使一些莫名其妙的bug。

典型坑點-交換方法主動調用load

第一個坑點比較簡單,就是咱們在load中交換完方法後,不作處理的話,若是再去調用load,方法IMP會又被交換回來,致使交換不成功。函數

解決的方法也比較簡單,在上述的注意事項1中已經說過,使用單例模式來交換方法,保證方法的交換隻執行一次post

典型坑點-子類無實現,交換父類方法

坑點例子

咱們能夠經過一個例子來實現,建立父類LGPerson,子類LGStudent和分類LGStudent+LG來進行方法的交換。學習

@interface LGPerson : NSObject
- (void)personInstanceMethod;
@end

@implementation LGPerson
- (void)personInstanceMethod{
    NSLog(@"person對象方法:%s",__func__);
}
@end
**************************************************************
@interface LGStudent : LGPerson

@end

@implementation LGStudent

@end
**************************************************************
複製代碼

首先進行普通的方法交換,並在VC裏面正常調用,根據打印結果,能夠發現咱們在子類交換了父類的方法後,沒有產生崩潰,而且子類的分類中交換的方法也正常執行了

@implementation LGStudent (LG)

+ (void)load{
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [LGRuntimeTool lg_betterMethodSwizzlingWithClass:self oriSEL:@selector(personInstanceMethod) swizzledSEL:@selector(lg_studentInstanceMethod)];
    });
}

- (void)lg_studentInstanceMethod{
    [self lg_studentInstanceMethod];
    NSLog(@"LGStudent分類添加的lg對象方法:%s",__func__);
}

@end

*******************************調用*******************************
- (void)viewDidLoad {
    [super viewDidLoad];
    LGStudent *s = [[LGStudent alloc] init];
    [s personInstanceMethod];
}
*******************************打印結果*******************************
2020-01-20 10:45:31.809408+0800 006---Method-Swizzling坑[81429:20470219] person對象方法:-[LGPerson personInstanceMethod]
2020-01-20 10:45:31.809568+0800 006---Method-Swizzling坑[81429:20470219] LGStudent分類添加的lg對象方法:-[LGStudent(LG) lg_studentInstanceMethod]

複製代碼

可是,若是咱們在調用的時候,父類自己再調用一下這個方法的話,就會出現崩潰,緣由也比較清楚,就是子類將此方法交換了,父類並無交換後方法的IMP,因此會出現找不到方法的崩潰

- (void)viewDidLoad {
    [super viewDidLoad];
    
    LGStudent *s = [[LGStudent alloc] init];
    [s personInstanceMethod];
    
    LGPerson *p = [[LGPerson alloc] init];
    [p personInstanceMethod];
}

複製代碼

解決方案

一句話總結就是:若是該方法本身沒有,則先給本身添加要交換的方法。以後再父類原方法IMP指向交換的方法

在交換方法的時候,先嚐試添加一下原方法到類中,並將IMP指向交換的方法

  • 若是成功了,說明該類以前沒有,那麼須要替換父類原方法的IMP到交換的方法中,這樣就行程了上面圖中的閉環
  • 若是沒有成功,說明該類中自己就有此方法,那麼直接進行交換便可
+ (void)lg_betterMethodSwizzlingWithClass:(Class)cls oriSEL:(SEL)oriSEL swizzledSEL:(SEL)swizzledSEL{
    
    if (!cls) NSLog(@"傳入的交換類不能爲空");
    // oriSEL       personInstanceMethod
    // swizzledSEL  lg_studentInstanceMethod
    
    Method oriMethod = class_getInstanceMethod(cls, oriSEL);
    Method swiMethod = class_getInstanceMethod(cls, swizzledSEL);
   
    // 嘗試添加
    // ✅對應關係:personInstanceMethod(sel) - lg_studentInstanceMethod(imp)
    BOOL success = class_addMethod(cls, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(oriMethod));
    
    if (success) {// 本身沒有 - 交換 - 沒有父類進行處理 (重寫一個)
        //✅lg_studentInstanceMethod (swizzledSEL) - personInstanceMethod(imp)
        class_replaceMethod(cls, swizzledSEL, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
    }else{ // 本身有
        method_exchangeImplementations(oriMethod, swiMethod);
    }
}
複製代碼

典型坑點-方法只有聲明,沒有實現

仍是使用上述例子,若是LGStudent有一個方法- (void)helloword只有生命,沒有實現。

就算咱們使用了上述的解決方法,添加了方法,可是因爲原方法找不到,爲nil。因此會形成死循環調用

咱們能夠經過判斷原方法是否存在,並添加一個一個空實現來解決這個坑點。以後再進行判斷原方法進行交換,這樣就能完美解決了

具體代碼以下

+ (void)lg_bestMethodSwizzlingWithClass:(Class)cls oriSEL:(SEL)oriSEL swizzledSEL:(SEL)swizzledSEL{
    
    if (!cls) NSLog(@"傳入的交換類不能爲空");
    
    Method oriMethod = class_getInstanceMethod(cls, oriSEL);
    Method swiMethod = class_getInstanceMethod(cls, swizzledSEL);
    
    if (!oriMethod) {
        ✅// 在oriMethod爲nil時,替換後將swizzledSEL複製一個不作任何事的空實現,代碼以下:
        class_addMethod(cls, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
        method_setImplementation(swiMethod, imp_implementationWithBlock(^(id self, SEL _cmd){ }));
    }
    
    ✅// 通常交換方法: 交換本身有的方法 -- 走下面 由於本身有意味添加方法失敗
    ✅// 交換本身沒有實現的方法:
    ✅//   首先第一步:會先嚐試給本身添加要交換的方法 :personInstanceMethod (SEL) -> swiMethod(IMP)
    ✅//   而後再將父類的IMP給swizzle  personInstanceMethod(imp) -> swizzledSEL
    //oriSEL:personInstanceMethod

    BOOL didAddMethod = class_addMethod(cls, oriSEL, method_getImplementation(swiMethod), method_getTypeEncoding(swiMethod));
    if (didAddMethod) {
        class_replaceMethod(cls, swizzledSEL, method_getImplementation(oriMethod), method_getTypeEncoding(oriMethod));
    }else{
        method_exchangeImplementations(oriMethod, swiMethod);
    }
    
}
複製代碼

Method Swizzling常見應用

如下swizzling方法的具體封裝,和上述代碼中同樣

無侵入埋點

在 iOS 開發中最多見的三種埋點,就是對頁面進入次數、頁面停留時間、點擊事件的埋點。這些均可以經過Method Swizzling來實現。

下面的例子中,咱們經過交換UIViewControllerviewWillAppearviewWillDisappear的方法,來實現了進入界面和退出界面的統計,並記錄了相關的類名,經過映射的關係,就能夠清楚的知道用戶的行爲了

@implementation UIViewController (logger)
+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        ✅// 經過 @selector 得到被替換和替換方法的 SEL,做爲 SMHook:hookClass:fromeSelector:toSelector 的參數傳入 
        SEL fromSelectorAppear = @selector(viewWillAppear:);
        SEL toSelectorAppear = @selector(hook_viewWillAppear:);
        [SMHook hookClass:self fromSelector:fromSelectorAppear toSelector:toSelectorAppear];
        
        SEL fromSelectorDisappear = @selector(viewWillDisappear:);
        SEL toSelectorDisappear = @selector(hook_viewWillDisappear:);
        
        [SMHook hookClass:self fromSelector:fromSelectorDisappear toSelector:toSelectorDisappear];
    });
}

- (void)hook_viewWillAppear:(BOOL)animated {
    ✅// 先執行插入代碼,再執行原 viewWillAppear 方法
    [self insertToViewWillAppear];
    [self hook_viewWillAppear:animated];
}
- (void)hook_viewWillDisappear:(BOOL)animated {
    ✅// 執行插入代碼,再執行原 viewWillDisappear 方法
    [self insertToViewWillDisappear];
    [self hook_viewWillDisappear:animated];
}

- (void)insertToViewWillAppear {
    ✅// 在 ViewWillAppear 時進行日誌的埋點
    [[[[SMLogger create]
       message:[NSString stringWithFormat:@"%@ Appear",NSStringFromClass([self class])]]
      classify:ProjectClassifyOperation]
     save];
}
- (void)insertToViewWillDisappear {
    ✅// 在 ViewWillDisappear 時進行日誌的埋點
    [[[[SMLogger create]
       message:[NSString stringWithFormat:@"%@ Disappear",NSStringFromClass([self class])]]
      classify:ProjectClassifyOperation]
     save];
}
@end
複製代碼

那麼點擊方法,咱們也能夠經過運行時方法替換的方式進行無侵入埋點。

這裏最主要的工做是,找到這個點擊事件的方法 sendAction:to:forEvent:,而後在 +load() 方法替換成爲你定義的方法。完整代碼實現以下:

UIViewController生命週期埋點不一樣的是,UIButton在一個視圖類中可能有多個不一樣的繼承類,相同 UIButton的子類在不一樣視圖類的埋點也要區別開。因此,咱們須要經過 「action 選擇器名 NSStringFromSelector(action)」 +「視圖類名 NSStringFromClass([target class])」組合成一個惟一的標識,來進行埋點記錄

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        ✅// 經過 @selector 得到被替換和替換方法的 SEL,做爲 SMHook:hookClass:fromeSelector:toSelector 的參數傳入
        SEL fromSelector = @selector(sendAction:to:forEvent:);
        SEL toSelector = @selector(hook_sendAction:to:forEvent:);
        [SMHook hookClass:self fromSelector:fromSelector toSelector:toSelector];
    });
}

- (void)hook_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
    [self insertToSendAction:action to:target forEvent:event];
    [self hook_sendAction:action to:target forEvent:event];
}
- (void)insertToSendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
    ✅// 日誌記錄
    if ([[[event allTouches] anyObject] phase] == UITouchPhaseEnded) {
        NSString *actionString = NSStringFromSelector(action);
        NSString *targetName = NSStringFromClass([target class]);
        [[[SMLogger create] message:[NSString stringWithFormat:@"%@ %@",targetName,actionString]] save];
    }
}
複製代碼

除了 UIViewControllerUIButton 控件之外,Cocoa 框架的其餘控件均可以使用這種方法來進行無侵入埋點。以 Cocoa 框架中最複雜的 UITableView 控件爲例,你可使用 hook setDelegate 方法來實現無侵入埋點。另外,對於 Cocoa 框架中的手勢事件(Gesture Event),咱們也能夠經過 hook initWithTarget:action:方法來實現無侵入埋點。

防止數組,字典等越界崩潰

這個例子我相信平時在開發中,你們都用到過,由於數組越界等是最容易形成crash的一種方式,並且通常崩潰起來比較嚴重,因此咱們必定要避免的

在iOS中NSNumber、NSArray、NSDictionary等這些類都是類簇(Class Clusters),一個NSArray的實現可能由多個類組成。因此若是想對NSArray進行Swizzling,必須獲取到其真身進行Swizzling,直接對NSArray進行操做是無效的。這是由於Method Swizzling對NSArray這些的類簇是不起做用的。

由於這些類簇類,實際上是一種抽象工廠的設計模式。抽象工廠內部有不少其它繼承自當前類的子類,抽象工廠類會根據不一樣狀況,建立不一樣的抽象對象來進行使用。例如咱們調用NSArrayobjectAtIndex:方法,這個類會在方法內部判斷,內部建立不一樣抽象類進行操做。

因此若是咱們對NSArray類進行Swizzling操做其實只是對父類進行了操做,在NSArray內部會建立其餘子類來執行操做,真正執行Swizzling操做的並非NSArray自身,因此咱們應該對其「真身」進行操做。

下面列舉了NSArrayNSDictionary本類的類名,能夠經過Runtime函數取出本類:

下面是一個常見的例子

@implementation NSArray (CrashHandle)

✅// 若是下面代碼不起做用,形成這個問題的緣由大多都是其調用了super load方法。在下面的load方法中,不該該調用父類的load方法。這樣會致使方法交換無效
+ (void)load {
    Method originMethod = class_getInstanceMethod(objc_getClass("__NSArrayI"), @selector(objectAtIndex:));
    Method swizzlingMethod = class_getInstanceMethod(objc_getClass("__NSArrayI"), @selector(wy_objectAtIndex:));
    method_exchangeImplementations(fromMethod, toMethod);
}

✅// 爲了不和系統的方法衝突,我通常都會在swizzling方法前面加前綴
- (id)wy_objectAtIndex:(NSUInteger)index {
    ✅// 判斷下標是否越界,若是越界就進入異常攔截
    if (self.count-1 < index) {
        @try {
            return [self cm_objectAtIndex:index];
        }
        @catch (NSException *exception) {
            ✅// 在崩潰後會打印崩潰信息。若是是線上,能夠在這裏將崩潰信息發送到服務器
            NSLog(@"---------- %s Crash Because Method %s ----------\n", class_getName(self.class), __func__);
            NSLog(@"%@", [exception callStackSymbols]);
            return nil;
        }
        @finally {}
    } ✅// 若是沒有問題,則正常進行方法調用
    else {
        return [self cm_objectAtIndex:index];
    }
}
**************************調用******************************
- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 測試代碼
    NSArray *array = @[@0, @1, @2, @3];
    [array objectAtIndex:3];
    //原本要奔潰的,可是沒有,打印出了信息
    [array objectAtIndex:4];
}

複製代碼

以上的兩個例子,只是開發中經常使用的,還有不少其餘的應用,就須要根據需求來不斷調整了。這些都屬於AOP面向切面編程的一個實際應用,Method Swizzling也是其在iOS開發中應用的最經常使用的一種AOP思想

參考

iOS runtime實戰應用:Method Swizzling

iOS開發·runtime原理與實踐

iOS開發高手課

相關文章
相關標籤/搜索