iOS 開發:『Runtime』詳解(二)Method Swizzling

行走少年郎發表於2019-07-12
  • 本文首發於我的個人部落格:『不羈閣』
  • 文章連結:傳送門
  • 本文更新時間:2019年07月12日13:21:26

本文用來介紹 iOS 開發中『Runtime』中的黑魔法 Method Swizzling。通過本文,您將瞭解到:

  1. Method Swizzling(動態方法交換)簡介
  2. Method Swizzling 使用方法(四種方案)
  3. Method Swizzling 使用注意
  4. Method Swizzling 應用場景 4.1 全域性頁面統計功能 4.2 字型根據螢幕尺寸適配 4.3 處理按鈕重複點選 4.4 TableView、CollectionView 異常載入佔點陣圖 4.5 APM(應用效能管理)、防止崩潰

文中示例程式碼在: bujige / YSC-Runtime-MethodSwizzling


我們在上一篇 iOS 開發:『Runtime』詳解(一)基礎知識 中,講解了 iOS 執行時機制(Runtime 系統)的工作原理。包括訊息傳送以及轉發機制的原理和流程。

從這一篇文章開始,我們來了解一下 Runtime 在實際開發過程中,具體的應用場景。

這一篇我們來學習一下被稱為 Runtime 執行時系統中最具爭議的黑魔法:Method Swizzling(動態方法交換)


1. Method Swizzling(動態方法交換)簡介

Method Swizzling 用於改變一個已經存在的 selector 實現。我們可以在程式執行時,通過改變 selector 所在 Class(類)的 method list(方法列表)的對映從而改變方法的呼叫。其實質就是交換兩個方法的 IMP(方法實現)。

上一篇文章中我們知道:Method(方法)對應的是 objc_method 結構體;而 objc_method 結構體 中包含了 SEL method_name(方法名)IMP method_imp(方法實現)

// objc_method 結構體
typedef struct objc_method *Method;

struct objc_method {
    SEL _Nonnull method_name;                    // 方法名
    char * _Nullable method_types;               // 方法型別
    IMP _Nonnull method_imp;                     // 方法實現
};
複製程式碼

Method(方法)SEL(方法名)IMP(方法實現)三者的關係可以這樣來表示:

在執行時,Class(類) 維護了一個 method list(方法列表) 來確定訊息的正確傳送。method list(方法列表) 存放的元素就是 Method(方法)。而 Method(方法) 中對映了一對鍵值對:SEL(方法名):IMP(方法實現)

Method swizzling 修改了 method list(方法列表),使得不同 Method(方法)中的鍵值對發生了交換。比如交換前兩個鍵值對分別為 SEL A : IMP ASEL B : IMP B,交換之後就變為了 SEL A : IMP BSEL B : IMP A。如圖所示:

iOS 開發:『Runtime』詳解(二)Method Swizzling


2. Method Swizzling 使用方法

假如當前類中有兩個方法:- (void)originalFunction;- (void)swizzledFunction;。如果我們想要交換兩個方法的實現,從而實現呼叫 - (void)originalFunction; 方法實際上呼叫的是 - (void)swizzledFunction; 方法,而呼叫 - (void)swizzledFunction; 方法實際上呼叫的是 - (void)originalFunction; 方法的效果。那麼我們需要像下邊程式碼一樣來實現。


2.1 Method Swizzling 簡單使用

在當前類的 + (void)load; 方法中增加 Method Swizzling 操作,交換 - (void)originalFunction;- (void)swizzledFunction; 的方法實現。

#import "ViewController.h"
#import <objc/runtime.h>

@interface ViewController ()

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    [self SwizzlingMethod];
    [self originalFunction];
    [self swizzledFunction];
}


// 交換 原方法 和 替換方法 的方法實現
- (void)SwizzlingMethod {
    // 當前類
    Class class = [self class];
    
    // 原方法名 和 替換方法名
    SEL originalSelector = @selector(originalFunction);
    SEL swizzledSelector = @selector(swizzledFunction);
    
    // 原方法結構體 和 替換方法結構體
    Method originalMethod = class_getInstanceMethod(class, originalSelector);
    Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
    
    // 呼叫交換兩個方法的實現
    method_exchangeImplementations(originalMethod, swizzledMethod);
}

// 原始方法
- (void)originalFunction {
    NSLog(@"originalFunction");
}

// 替換方法
- (void)swizzledFunction {
    NSLog(@"swizzledFunction");
}

@end
複製程式碼

列印結果: 2019-07-12 09:59:19.672349+0800 Runtime-MethodSwizzling[91009:30112833] swizzledFunction 2019-07-12 09:59:20.414930+0800 Runtime-MethodSwizzling[91009:30112833] originalFunction

可以看出兩者方法成功進行了交換。


剛才我們簡單演示瞭如何在當前類中如何進行 Method Swizzling 操作。但一般日常開發中,並不是直接在原有類中進行 Method Swizzling 操作。更多的是為當前類新增一個分類,然後在分類中進行 Method Swizzling 操作。另外真正使用會比上邊寫的考慮東西要多一點,要複雜一些。

在日常使用 Method Swizzling 的過程中,有幾種很常用的方案,具體情況如下。

2.2 Method Swizzling 方案 A

在該類的分類中新增 Method Swizzling 交換方法,用普通方式

這種方式在開發中應用最多的。但是還是要注意一些事項,我會在接下來的 3. Method Swizzling 使用注意 進行詳細說明。

@implementation UIViewController (Swizzling)

// 交換 原方法 和 替換方法 的方法實現
+ (void)load {
    
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        // 當前類
        Class class = [self class];
        
        // 原方法名 和 替換方法名
        SEL originalSelector = @selector(originalFunction);
        SEL swizzledSelector = @selector(swizzledFunction);
        
        // 原方法結構體 和 替換方法結構體
        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
        
        /* 如果當前類沒有 原方法的 IMP,說明在從父類繼承過來的方法實現,
         * 需要在當前類中新增一個 originalSelector 方法,
         * 但是用 替換方法 swizzledMethod 去實現它 
         */
        BOOL didAddMethod = class_addMethod(class,
                                            originalSelector,
                                            method_getImplementation(swizzledMethod),
                                            method_getTypeEncoding(swizzledMethod));
        
        if (didAddMethod) {
            // 原方法的 IMP 新增成功後,修改 替換方法的 IMP 為 原始方法的 IMP
            class_replaceMethod(class,
                                swizzledSelector,
                                method_getImplementation(originalMethod),
                                method_getTypeEncoding(originalMethod));
        } else {
            // 新增失敗(說明已包含原方法的 IMP),呼叫交換兩個方法的實現
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

// 原始方法
- (void)originalFunction {
    NSLog(@"originalFunction");
}

// 替換方法
- (void)swizzledFunction {
    NSLog(@"swizzledFunction");
}

@end
複製程式碼

2.3 Method Swizzling 方案 B

在該類的分類中新增 Method Swizzling 交換方法,但是使用函式指標的方式。

方案 B 和方案 A 的最大不同之處在於使用了函式指標的方式,使用函式指標最大的好處是可以有效避免命名錯誤。

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

typedef IMP *IMPPointer;

// 交換方法函式
static void MethodSwizzle(id self, SEL _cmd, id arg1);
// 原始方法函式指標
static void (*MethodOriginal)(id self, SEL _cmd, id arg1);

// 交換方法函式
static void MethodSwizzle(id self, SEL _cmd, id arg1) {
    
    // 在這裡新增 交換方法的相關程式碼
    NSLog(@"swizzledFunc");
    
    MethodOriginal(self, _cmd, arg1);
}

BOOL class_swizzleMethodAndStore(Class class, SEL original, IMP replacement, IMPPointer store) {
    IMP imp = NULL;
    Method method = class_getInstanceMethod(class, original);
    if (method) {
        const char *type = method_getTypeEncoding(method);
        imp = class_replaceMethod(class, original, replacement, type);
        if (!imp) {
            imp = method_getImplementation(method);
        }
    }
    if (imp && store) { *store = imp; }
    return (imp != NULL);
}

@implementation UIViewController (PointerSwizzling)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        [self swizzle:@selector(originalFunc) with:(IMP)MethodSwizzle store:(IMP *)&MethodOriginal];
    });
}

+ (BOOL)swizzle:(SEL)original with:(IMP)replacement store:(IMPPointer)store {
    return class_swizzleMethodAndStore(self, original, replacement, store);
}

// 原始方法
- (void)originalFunc {
    NSLog(@"originalFunc");
}

@end
複製程式碼

2.4 Method Swizzling 方案 C

在其他類中新增 Method Swizzling 交換方法

這種情況一般用的不多,最出名的就是 AFNetworking 中的_AFURLSessionTaskSwizzling 私有類。_AFURLSessionTaskSwizzling 主要解決了 iOS7 和 iOS8 系統上 NSURLSession 差別的處理。讓不同系統版本 NSURLSession 版本基本一致。

static inline void af_swizzleSelector(Class theClass, SEL originalSelector, SEL swizzledSelector) {
    Method originalMethod = class_getInstanceMethod(theClass, originalSelector);
    Method swizzledMethod = class_getInstanceMethod(theClass, swizzledSelector);
    method_exchangeImplementations(originalMethod, swizzledMethod);
}

static inline BOOL af_addMethod(Class theClass, SEL selector, Method method) {
    return class_addMethod(theClass, selector,  method_getImplementation(method),  method_getTypeEncoding(method));
}

@interface _AFURLSessionTaskSwizzling : NSObject

@end

@implementation _AFURLSessionTaskSwizzling

+ (void)load {
    if (NSClassFromString(@"NSURLSessionTask")) {
        
        NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration ephemeralSessionConfiguration];
        NSURLSession * session = [NSURLSession sessionWithConfiguration:configuration];
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wnonnull"
        NSURLSessionDataTask *localDataTask = [session dataTaskWithURL:nil];
#pragma clang diagnostic pop
        IMP originalAFResumeIMP = method_getImplementation(class_getInstanceMethod([self class], @selector(af_resume)));
        Class currentClass = [localDataTask class];
        
        while (class_getInstanceMethod(currentClass, @selector(resume))) {
            Class superClass = [currentClass superclass];
            IMP classResumeIMP = method_getImplementation(class_getInstanceMethod(currentClass, @selector(resume)));
            IMP superclassResumeIMP = method_getImplementation(class_getInstanceMethod(superClass, @selector(resume)));
            if (classResumeIMP != superclassResumeIMP &&
                originalAFResumeIMP != classResumeIMP) {
                [self swizzleResumeAndSuspendMethodForClass:currentClass];
            }
            currentClass = [currentClass superclass];
        }
        
        [localDataTask cancel];
        [session finishTasksAndInvalidate];
    }
}

+ (void)swizzleResumeAndSuspendMethodForClass:(Class)theClass {
    Method afResumeMethod = class_getInstanceMethod(self, @selector(af_resume));
    Method afSuspendMethod = class_getInstanceMethod(self, @selector(af_suspend));

    if (af_addMethod(theClass, @selector(af_resume), afResumeMethod)) {
        af_swizzleSelector(theClass, @selector(resume), @selector(af_resume));
    }

    if (af_addMethod(theClass, @selector(af_suspend), afSuspendMethod)) {
        af_swizzleSelector(theClass, @selector(suspend), @selector(af_suspend));
    }
}

- (void)af_resume {
    NSAssert([self respondsToSelector:@selector(state)], @"Does not respond to state");
    NSURLSessionTaskState state = [self state];
    [self af_resume];
    
    if (state != NSURLSessionTaskStateRunning) {
        [[NSNotificationCenter defaultCenter] postNotificationName:AFNSURLSessionTaskDidResumeNotification object:self];
    }
}

- (void)af_suspend {
    NSAssert([self respondsToSelector:@selector(state)], @"Does not respond to state");
    NSURLSessionTaskState state = [self state];
    [self af_suspend];
    
    if (state != NSURLSessionTaskStateSuspended) {
        [[NSNotificationCenter defaultCenter] postNotificationName:AFNSURLSessionTaskDidSuspendNotification object:self];
    }
}
複製程式碼

2.5 Method Swizzling 方案 D

優秀的第三方框架:JRSwizzleRSSwizzle

JRSwizzle 和 RSSwizzle 都是優秀的封裝 Method Swizzling 的第三方框架。

  1. JRSwizzle 嘗試解決在不同平臺和系統版本上的 Method Swizzling 與類繼承關係的衝突。對各平臺低版本系統相容性較強。JRSwizzle 核心是用到了 method_exchangeImplementations 方法。在健壯性上先做了 class_addMethod 操作。

  2. RSSwizzle 主要用到了 class_replaceMethod 方法,避免了子類的替換影響了父類。而且對交換方法過程加了鎖,增強了執行緒安全。它用很複雜的方式解決了 What are the dangers of method swizzling in Objective-C? 中提到的問題。是一種更安全優雅的 Method Swizzling 解決方案。


總結:

在開發中我們通常使用方案 A,或者方案 D 中的第三方框架 RSSwizzle 來實現 Method Swizzling。在接下來 3. Method Swizzling 使用注意 中,我們還講看到很多的注意事項。這些注意事項並不是為了嚇退初學者,而是為了更好的使用 Method Swizzling 這一利器。而至於方案的選擇,無論是選擇哪種方案,我認為只有最適合專案的方案才是最佳方案。


3. Method Swizzling 使用注意

Method Swizzling 之所以被大家稱為黑魔法,就是因為使用 Method Swizzling 進行方法交換是一個危險的操作。Stack Overflow 上邊有人提出了使用 Method Swizzling 會造成的一些危險和缺陷。更是把 Method Swizzling 比作是廚房裡一把鋒利的刀。有些人會害怕刀過於鋒利,會傷到自己,從而放棄了刀,或者使用了鈍刀。但是事實卻是:鋒利的刀比鈍刀反而更加安全,前提是你有足夠的經驗。

Method Swizzling 可用於編寫更好,更高效,更易維護的程式碼。但也可能因為被濫用而導致可怕的錯誤。所以在使用 Method Swizzling 的時候,我們還是要注意一些事項,以規避可能出現的危險。

下面我們結合還有其他博主關於 Method Swizzling 的博文、 以及 Stack Overflow 上邊提到的危險和缺陷,還有筆者的個人見解,來綜合說一下使用 Method Swizzling 需要注意的地方。

  1. 應該只在 +load 中執行 Method Swizzling。

程式在啟動的時候,會先載入所有的類,這時會呼叫每個類的 +load 方法。而且在整個程式執行週期只會呼叫一次(不包括外部顯示呼叫)。所以在 +load 方法進行 Method Swizzling 再好不過了。

而為什麼不用 +initialize 方法呢。

因為 +initialize 方法的呼叫時機是在 第一次向該類傳送第一個訊息的時候才會被呼叫。如果該類只是引用,沒有呼叫,則不會執行 +initialize 方法。 Method Swizzling 影響的是全域性狀態,+load 方法能保證在載入類的時候就進行交換,保證交換結果。而使用 +initialize 方法則不能保證這一點,有可能在使用的時候起不到交換方法的作用。

  1. Method Swizzling 在 +load 中執行時,不要呼叫 [super load];

上邊我們說了,程式在啟動的時候,會先載入所有的類。如果在 + (void)load方法中呼叫 [super load] 方法,就會導致父類的 Method Swizzling 被重複執行兩次,而方法交換也被執行了兩次,相當於互換了一次方法之後,第二次又換回去了,從而使得父類的 Method Swizzling 失效。

  1. Method Swizzling 應該總是在 dispatch_once 中執行。

Method Swizzling 不是原子操作,dispatch_once 可以保證即使在不同的執行緒中也能確保程式碼只執行一次。所以,我們應該總是在 dispatch_once 中執行 Method Swizzling 操作,保證方法替換隻被執行一次。

  1. 使用 Method Swizzling 後要記得呼叫原生方法的實現。

在交換方法實現後記得要呼叫原生方法的實現(除非你非常確定可以不用呼叫原生方法的實現):APIs 提供了輸入輸出的規則,而在輸入輸出中間的方法實現就是一個看不見的黑盒。交換了方法實現並且一些回撥方法不會呼叫原生方法的實現這可能會造成底層實現的崩潰。

  1. 避免命名衝突和引數 _cmd 被篡改。
  1. 避免命名衝突一個比較好的做法是為替換的方法加個字首以區別原生方法。一定要確保呼叫了原生方法的所有地方不會因為自己交換了方法的實現而出現意料不到的結果。 在使用 Method Swizzling 交換方法後記得要在交換方法中呼叫原生方法的實現。在交換了方法後並且不呼叫原生方法的實現可能會造成底層實現的崩潰。

  2. 避免方法命名衝突另一個更好的做法是使用函式指標,也就是上邊提到的 方案 B,這種方案能有效避免方法命名衝突和引數 _cmd 被篡改。

  1. 謹慎對待 Method Swizzling。

使用 Method Swizzling,會改變非自己擁有的程式碼。我們使用 Method Swizzling 通常會更改一些系統框架的物件方法,或是類方法。我們改變的不只是一個物件例項,而是改變了專案中所有的該類的物件例項,以及所有子類的物件例項。所以,在使用 Method Swizzling 的時候,應該保持足夠的謹慎。

例如,你在一個類中重寫一個方法,並且不呼叫 super 方法,則可能會出現問題。在大多數情況下,super 方法是期望被呼叫的(除非有特殊說明)。如果你是用同樣的思想來進行 Method Swizzling ,可能就會引起很多問題。如果你不呼叫原始的方法實現,那麼你 Method Swizzling 改變的越多程式碼就越不安全。

  1. 對於 Method Swizzling 來說,呼叫順序 很重要。

+ load 方法的呼叫規則為:

  1. 先呼叫主類,按照編譯順序,順序地根據繼承關係由父類向子類呼叫;
  2. 再呼叫分類,按照編譯順序,依次呼叫;
  3. + load 方法除非主動呼叫,否則只會呼叫一次。

這樣的呼叫規則導致了 + load 方法呼叫順序並不一定確定。一個順序可能是:父類 -> 子類 -> 父類類別 -> 子類類別,也可能是 父類 -> 子類 -> 子類類別 -> 父類類別。所以 Method Swizzling 的順序不能保證,那麼就不能保證 Method Swizzling 後方法的呼叫順序是正確的。

所以被用於 Method Swizzling 的方法必須是當前類自身的方法,如果把繼承父類來的 IMP 複製到自身上面可能會存在問題。如果 + load 方法呼叫順序為:父類 -> 子類 -> 父類類別 -> 子類類別,那麼造成的影響就是呼叫子類的替換方法並不能正確調起父類分類的替換方法。原因解釋可以參考這篇文章:南梔傾寒:iOS界的毒瘤-MethodSwizzling

關於呼叫順序更細緻的研究可以參考這篇博文:玉令天下的部落格:Objective-C Method Swizzling


4. Method Swizzling 應用場景

Method Swizzling 可以交換兩個方法的實現,在開發中更多的是應用於系統類庫,以及第三方框架的方法替換。在官方不公開原始碼的情況下,我們可以藉助 Runtime 的 Method Swizzling 為原有方法新增額外的功能,這使得我們可以做很多有趣的事情。


4.1 全域性頁面統計功能

需求:在所有頁面新增統計功能,使用者每進入一次頁面就統計一次。

如果有一天公司產品需要我們來實現這個需求。我們應該如何來實現?

先來思考一下有幾種實現方式:

第一種:手動新增

直接在所有頁面新增一次統計程式碼。你需要做的是寫一份統計程式碼,然後在所有頁面的 viewWillAppear: 中不停的進行復制、貼上。

第二種:利用繼承

建立基類,所有頁面都繼承自基類。這樣的話只需要在基類的 viewDidAppear: 中新增一次統計功能。這樣修改程式碼還是很多,如果所有頁面不是一開始繼承自定義的基類,那麼就需要把所有頁面的繼承關係修改一下,同樣會造成很多重複程式碼,和極大的工作量。

第三種:利用分類 + Method Swizzling

我們可以利用 Category 的特性來實現這個功能。如果一個類的分類重寫了這個類的方法之後,那麼該類的方法將會失效,起作用的將會是分類中重寫的方法。

這樣的話,我們可以為 UIViewController 建立一個 Category,在分類中重寫 viewWillAppear:,在其中新增統計程式碼,然後在所有的控制器中引入這個 Category。但是這樣的話,所有繼承自 UIViewController 類自身的 viewWillAppear: 就失效了,不會被呼叫。

這就需要用 Method Swizzling 來實現了。步驟如下:

  1. 在分類中實現一個自定義的xxx_viewWillAppear: 方法;
  2. 利用 Method Swizzling 將 viewDidAppear: 和自定義的 xxx_viewWillAppear: 進行方法交換。
  3. 然後在 xxx_viewWillAppear: 中新增統計程式碼和呼叫xxx_viewWillAppear:實現; 因為兩個方法發生了交換,所以最後實質是呼叫了 viewWillAppear: 方法。
  • 程式碼實現:
#import "UIViewController+Swizzling.h"
#import <objc/runtime.h>

@implementation UIViewController (Swizzling)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];
        
        SEL originalSelector = @selector(viewWillAppear:);
        SEL swizzledSelector = @selector(xxx_viewWillAppear:);
        
        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
        
        BOOL didAddMethod = class_addMethod(class,
                                            originalSelector,
                                            method_getImplementation(swizzledMethod),
                                            method_getTypeEncoding(swizzledMethod));
        
        if (didAddMethod) {
            class_replaceMethod(class,
                                swizzledSelector,
                                method_getImplementation(originalMethod),
                                method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

#pragma mark - Method Swizzling

- (void)xxx_viewWillAppear:(BOOL)animated {
    
    if (![self isKindOfClass:[UIViewController class]]) {  // 剔除系統 UIViewController
        // 新增統計程式碼
        NSLog(@"進入頁面:%@", [self class]);
    }
    
    [self xxx_viewWillAppear:animated];
}

@end
複製程式碼

4.2 字型根據螢幕尺寸適配

需求:所有的控制元件字型必須依據螢幕的尺寸等比縮放。

照例,我們先來想想幾種實現方式。

第一種:手動修改

所有用到的 UIFont 的地方,手動判斷,新增適配程式碼。一想到那個工作量,不忍直視。

第二種:利用巨集定義

在 PCH 檔案定義一個計算縮放字型的方法。在使用設定字型時,先呼叫巨集定義的縮放字型的方法。但是這樣同樣需要修改所有用到的 UIFont 的地方。工作量依舊很大。

//巨集定義
#define UISCREEN_WIDTH ([UIScreen mainScreen].bounds.size.width)

/**
 *  計算縮放字型的方法
 */
static inline CGFloat FontSize(CGFloat fontSize){
    return fontSize * UISCREEN_WIDTH / XXX_UISCREEN_WIDTH;
}
複製程式碼

第三種:利用分類 + Method Swizzling

  1. 為 UIFont 建立一個 Category。
  2. 在分類中實現一個自定義的 xxx_systemFontOfSize: 方法,在其中新增縮放字型的方法。
  3. 利用 Method Swizzling 將 systemFontOfSize: 方法和 xxx_systemFontOfSize: 進行方法交換。
  • 程式碼實現:
#import "UIFont+AdjustSwizzling.h"
#import <objc/runtime.h>

#define XXX_UISCREEN_WIDTH  375

@implementation UIFont (AdjustSwizzling)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];

        SEL originalSelector = @selector(systemFontOfSize:);
        SEL swizzledSelector = @selector(xxx_systemFontOfSize:);

        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);

        BOOL didAddMethod = class_addMethod(class,
                                            originalSelector,
                                            method_getImplementation(swizzledMethod),
                                            method_getTypeEncoding(swizzledMethod));

        if (didAddMethod) {
            class_replaceMethod(class,
                                swizzledSelector,
                                method_getImplementation(originalMethod),
                                method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

+ (UIFont *)xxx_systemFontOfSize:(CGFloat)fontSize {
    UIFont *newFont = nil;
    newFont = [UIFont xxx_systemFontOfSize:fontSize * [UIScreen mainScreen].bounds.size.width / XXX_UISCREEN_WIDTH];
    
    return newFont;
}

@end
複製程式碼

注意:這種方式只適用於純程式碼的情況,關於 XIB 字型根據螢幕尺寸適配,可以參考這篇博文: 小生不怕:iOS xib檔案根據螢幕等比例縮放的適配


4.3 處理按鈕重複點選

需求:避免一個按鈕被快速多次點選。

還是來思考一下有幾種做法。

第一種:利用 Delay 延遲,和不可點選方法。

這種方法很直觀,也很簡單。但就是工作量很大,需要在所有有按鈕的地方新增程式碼。很不想承認:在之前專案中,我使用的就是這種方式。

- (void)viewDidLoad {
    [super viewDidLoad];
    
    UIButton *button = [[UIButton alloc]initWithFrame:CGRectMake(100, 100, 100, 100)];
    button.backgroundColor = [UIColor redColor];
    [button addTarget:self action:@selector(buttonClick:) forControlEvents:UIControlEventTouchUpInside];
    [self.view addSubview:button];
}

- (void)buttonClick:(UIButton *)sender {
    sender.enabled = NO;
    [self performSelector:@selector(changeButtonStatus:) withObject:sender afterDelay:0.8f];
    
    NSLog(@"點選了按鈕");
}

- (void)changeButtonStatus:(UIButton *)sender {
    sender.enabled = YES;
}
複製程式碼

第二種:利用分類 + Method Swizzling

  1. UIControlUIButton 建立一個 Category。
  2. 在分類中新增一個 NSTimeInterval xxx_acceptEventInterval; 的屬性,設定重複點選間隔
  3. 在分類中實現一個自定義的 xxx_sendAction:to:forEvent: 方法,在其中新增限定時間相應的方法。
  4. 利用 Method Swizzling 將 sendAction:to:forEvent: 方法和 xxx_sendAction:to:forEvent: 進行方法交換。
  • 程式碼實現:
#import "UIButton+DelaySwizzling.h"
#import <objc/runtime.h>

@interface UIButton()

// 重複點選間隔
@property (nonatomic, assign) NSTimeInterval xxx_acceptEventInterval;

@end


@implementation UIButton (DelaySwizzling)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];

        SEL originalSelector = @selector(sendAction:to:forEvent:);
        SEL swizzledSelector = @selector(xxx_sendAction:to:forEvent:);

        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);

        BOOL didAddMethod = class_addMethod(class,
                                            originalSelector,
                                            method_getImplementation(swizzledMethod),
                                            method_getTypeEncoding(swizzledMethod));

        if (didAddMethod) {
            class_replaceMethod(class,
                                swizzledSelector,
                                method_getImplementation(originalMethod),
                                method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

- (void)xxx_sendAction:(SEL)action to:(id)target forEvent:(UIEvent *)event {
    
    // 如果想要設定統一的間隔時間,可以在此處加上以下幾句
    if (self.xxx_acceptEventInterval <= 0) {
        // 如果沒有自定義時間間隔,則預設為 0.4 秒
        self.xxx_acceptEventInterval = 0.4;
    }
    
    // 是否小於設定的時間間隔
    BOOL needSendAction = (NSDate.date.timeIntervalSince1970 - self.xxx_acceptEventTime >= self.xxx_acceptEventInterval);
    
    // 更新上一次點選時間戳
    if (self.xxx_acceptEventInterval > 0) {
        self.xxx_acceptEventTime = NSDate.date.timeIntervalSince1970;
    }
    
    // 兩次點選的時間間隔小於設定的時間間隔時,才執行響應事件
    if (needSendAction) {
        [self xxx_sendAction:action to:target forEvent:event];
    }
}

- (NSTimeInterval )xxx_acceptEventInterval{
    return [objc_getAssociatedObject(self, "UIControl_acceptEventInterval") doubleValue];
}

- (void)setXxx_acceptEventInterval:(NSTimeInterval)xxx_acceptEventInterval{
    objc_setAssociatedObject(self, "UIControl_acceptEventInterval", @(xxx_acceptEventInterval), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSTimeInterval )xxx_acceptEventTime{
    return [objc_getAssociatedObject(self, "UIControl_acceptEventTime") doubleValue];
}

- (void)setXxx_acceptEventTime:(NSTimeInterval)xxx_acceptEventTime{
    objc_setAssociatedObject(self, "UIControl_acceptEventTime", @(xxx_acceptEventTime), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

@end
複製程式碼

參考博文:大斑馬小斑馬:IOS 防止UIButton 重複點選


4.4 TableView、CollectionView 異常載入佔點陣圖

在專案中遇到網路異常,或者其他各種原因造成 TableView、CollectionView 資料為空的時候,通常需要載入佔點陣圖顯示。那麼載入佔點陣圖有沒有什麼好的方法或技巧?

第一種:重新整理資料後進行判斷

這應該是通常的做法。當返回資料,重新整理 TableView、CollectionView 時候,進行判斷,如果資料為空,則載入佔點陣圖。如果資料不為空,則移除佔點陣圖,顯示資料。

第二種:利用分類 + Method Swizzling 重寫 reloadData 方法。

以 TableView 為例:

  1. 為 TableView 建立一個 Category,Category 中新增重新整理回撥 block 屬性、佔點陣圖 View 屬性。
  2. 在分類中實現一個自定義的 xxx_reloadData 方法,在其中新增判斷是否為空,以及載入佔點陣圖、隱藏佔點陣圖的相關程式碼。
  3. 利用 Method Swizzling 將 reloadData 方法和 xxx_reloadData 進行方法交換。
  • 程式碼實現:
#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface UITableView (ReloadDataSwizzling)

@property (nonatomic, assign) BOOL firstReload;
@property (nonatomic, strong) UIView *placeholderView;
@property (nonatomic,   copy) void(^reloadBlock)(void);

@end

/*--------------------------------------*/

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

@implementation UITableView (ReloadDataSwizzling)


+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];

        SEL originalSelector = @selector(reloadData);
        SEL swizzledSelector = @selector(xxx_reloadData);

        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);

        BOOL didAddMethod = class_addMethod(class,
                                            originalSelector,
                                            method_getImplementation(swizzledMethod),
                                            method_getTypeEncoding(swizzledMethod));

        if (didAddMethod) {
            class_replaceMethod(class,
                                swizzledSelector,
                                method_getImplementation(originalMethod),
                                method_getTypeEncoding(originalMethod));
        } else {
            method_exchangeImplementations(originalMethod, swizzledMethod);
        }
    });
}

- (void)xxx_reloadData {
    if (!self.firstReload) {
        [self checkEmpty];
    }
    self.firstReload = NO;
    
    [self xxx_reloadData];
}


- (void)checkEmpty {
    BOOL isEmpty = YES; // 判空 flag 標示
    
    id <UITableViewDataSource> dataSource = self.dataSource;
    NSInteger sections = 1; // 預設TableView 只有一組
    if ([dataSource respondsToSelector:@selector(numberOfSectionsInTableView:)]) {
        sections = [dataSource numberOfSectionsInTableView:self] - 1; // 獲取當前TableView 組數
    }
    
    for (NSInteger i = 0; i <= sections; i++) {
        NSInteger rows = [dataSource tableView:self numberOfRowsInSection:i]; // 獲取當前TableView各組行數
        if (rows) {
            isEmpty = NO; // 若行數存在,不為空
        }
    }
    if (isEmpty) { // 若為空,載入佔點陣圖
        if (!self.placeholderView) { // 若未自定義,載入預設佔點陣圖
            [self makeDefaultPlaceholderView];
        }
        self.placeholderView.hidden = NO;
        [self addSubview:self.placeholderView];
    } else { // 不為空,隱藏佔點陣圖
        self.placeholderView.hidden = YES;
    }
}

- (void)makeDefaultPlaceholderView {
    self.bounds = CGRectMake(0, 0, self.frame.size.width, self.frame.size.height);
    XXXPlaceholderView *placeholderView = [[XXXPlaceholderView alloc] initWithFrame:self.bounds];
    __weak typeof(self) weakSelf = self;
    [placeholderView setReloadClickBlock:^{
        if (weakSelf.reloadBlock) {
            weakSelf.reloadBlock();
        }
    }];
    self.placeholderView = placeholderView;
}

- (BOOL)firstReload {
    return [objc_getAssociatedObject(self, @selector(firstReload)) boolValue];
}

- (void)setFirstReload:(BOOL)firstReload {
    objc_setAssociatedObject(self, @selector(firstReload), @(firstReload), OBJC_ASSOCIATION_ASSIGN);
}

- (UIView *)placeholderView {
    return objc_getAssociatedObject(self, @selector(placeholderView));
}

- (void)setPlaceholderView:(UIView *)placeholderView {
    objc_setAssociatedObject(self, @selector(placeholderView), placeholderView, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (void (^)(void))reloadBlock {
    return objc_getAssociatedObject(self, @selector(reloadBlock));
}

- (void)setReloadBlock:(void (^)(void))reloadBlock {
    objc_setAssociatedObject(self, @selector(reloadBlock), reloadBlock, OBJC_ASSOCIATION_COPY_NONATOMIC);
}

@end
複製程式碼

參考博文:賣報的小畫家Sure:零行程式碼為App新增異常載入佔點陣圖


4.5 APM(應用效能管理)、防止程式崩潰

  1. 通過 Method Swizzling 替換 NSURLConnection , NSURLSession 相關的原始實現(例如 NSURLConnection 的構造方法和 start 方法),在實現中加入網路效能埋點行為,然後呼叫原始實現。從而來監控網路。
  2. 防止程式崩潰,可以通過 Method Swizzling 攔截容易造成崩潰的系統方法,然後在替換方法捕獲異常型別 NSException ,再對異常進行處理。最常見的例子就是攔截 arrayWithObjects:count: 方法避免陣列越界,這種例子網上很多,就不再展示程式碼了。

參考資料


最後

寫 Method Swizzling 花費了整整兩週的時間,其中查閱了大量的 Method Swizzling 相關的資料,但得到的收穫是很值得的。同時希望能帶給大家一些幫助。

下一篇,我打算 Runtime 中 Category(分類)的底層原理。

文中如若有誤,煩請指正,感謝。


iOS 開發:『Runtime』詳解 系列文章:

尚未完成:

  • iOS 開發:『Runtime』詳解(五)Crash 防護系統
  • iOS 開發:『Runtime』詳解(六)Objective-C 2.0 結構解析
  • iOS 開發:『Runtime』詳解(七)KVO 底層實現

相關文章