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

  • 本文首發於我的個人博客:『不羈閣』
  • 文章鏈接:傳送門
  • 本文更新時間: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。如圖所示:


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.3 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.2 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 相關的資料,但得到的收穫是很值得的。同時希望能帶給大家一些幫助。

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


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

尚未完成:

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章