iOS開發·runtime原理與實踐: 方法交換篇(Method Swizzling)(iOS“黑魔法”,埋點統計,禁止UI控制元件連續點選,防奔潰處理)

陳滿iOS發表於2018-05-04

本文Demo傳送門:MethodSwizzlingDemo

iOS開發·runtime原理與實踐: 方法交換篇(Method Swizzling)(iOS“黑魔法”,埋點統計,禁止UI控制元件連續點選,防奔潰處理)

摘要:程式設計,只瞭解原理不行,必須實戰才能知道應用場景。本系列嘗試闡述runtime相關理論的同時介紹一些實戰場景,而本文則是本系列的方法交換篇。本文中,第一節將介紹方法交換及注意點,第二節將總結一下方法交換相關的API,第三節將介紹方法交換幾種的實戰場景:統計VC載入次數並列印,防止UI控制元件短時間多次啟用事件,防奔潰處理(陣列越界問題)。

1. 原理與注意

原理

Method Swizzing是發生在執行時的,主要用於在執行時將兩個Method進行交換,我們可以將Method Swizzling程式碼寫到任何地方,但是隻有在這段Method Swilzzling程式碼執行完畢之後互換才起作用。

iOS開發·runtime原理與實踐: 方法交換篇(Method Swizzling)(iOS“黑魔法”,埋點統計,禁止UI控制元件連續點選,防奔潰處理)

用法

先給要替換的方法的類新增一個Category,然後在Category中的+(void)load方法中新增Method Swizzling方法,我們用來替換的方法也寫在這個Category中。

由於load類方法是程式執行時這個類被載入到記憶體中就呼叫的一個方法,執行比較早,並且不需要我們手動呼叫。

注意要點

  • Swizzling應該總在+load中執行
  • Swizzling應該總是在dispatch_once中執行
  • Swizzling在+load中執行時,不要呼叫[super load]。如果多次呼叫了[super load],可能會出現“Swizzle無效”的假象。
  • 為了避免Swizzling的程式碼被重複執行,我們可以通過GCD的dispatch_once函式來解決,利用dispatch_once函式內程式碼只會執行一次的特性。

2. Method Swizzling相關的API

  • 方案1
class_getInstanceMethod(Class _Nullable cls, SEL _Nonnull name)
複製程式碼
method_getImplementation(Method _Nonnull m) 
複製程式碼
class_addMethod(Class _Nullable cls, SEL _Nonnull name, IMP _Nonnull imp, 
                const char * _Nullable types) 
複製程式碼
class_replaceMethod(Class _Nullable cls, SEL _Nonnull name, IMP _Nonnull imp, 
                    const char * _Nullable types) 
複製程式碼
  • 方案2
method_exchangeImplementations(Method _Nonnull m1, Method _Nonnull m2) 
複製程式碼

3. 應用場景與實踐

3.1 統計VC載入次數並列印

  • UIViewController+Logging.m
#import "UIViewController+Logging.h"
#import <objc/runtime.h>

@implementation UIViewController (Logging)

+ (void)load
{
    swizzleMethod([self class], @selector(viewDidAppear:), @selector(swizzled_viewDidAppear:));
}

- (void)swizzled_viewDidAppear:(BOOL)animated
{
    // call original implementation
    [self swizzled_viewDidAppear:animated];
    
    // Logging
    NSLog(@"%@", NSStringFromClass([self class]));
}

void swizzleMethod(Class class, SEL originalSelector, SEL swizzledSelector)
{
    // the method might not exist in the class, but in its superclass
    Method originalMethod = class_getInstanceMethod(class, originalSelector);
    Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
    
    // class_addMethod will fail if original method already exists
    BOOL didAddMethod = class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));
    
    // the method doesn’t exist and we just added one
    if (didAddMethod) {
        class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
    }
    else {
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }
    
}
複製程式碼

3.2 防止UI控制元件短時間多次啟用事件

需求

當前專案寫好的按鈕,還沒有全域性地控制他們短時間內不可連續點選(也許有過零星地在某些網路請求介面之前做過一些控制)。現在來了新需求:本APP所有的按鈕1秒內不可連續點選。你怎麼做?一個個改?這種低效率低維護度肯定是不妥的。

方案

給按鈕新增分類,並新增一個點選事件間隔的屬性,執行點選事件的時候判斷一下是否時間到了,如果時間不到,那麼攔截點選事件。

怎麼攔截點選事件呢?其實點選事件在runtime裡面是傳送訊息,我們可以把要傳送的訊息的SEL 和自己寫的SEL交換一下,然後在自己寫的SEL裡面判斷是否執行點選事件。

實踐

UIButton是UIControl的子類,因而根據UIControl新建一個分類即可

  • UIControl+Limit.m
#import "UIControl+Limit.h"
#import <objc/runtime.h>

static const char *UIControl_acceptEventInterval="UIControl_acceptEventInterval";
static const char *UIControl_ignoreEvent="UIControl_ignoreEvent";

@implementation UIControl (Limit)

#pragma mark - acceptEventInterval
- (void)setAcceptEventInterval:(NSTimeInterval)acceptEventInterval
{
    objc_setAssociatedObject(self,UIControl_acceptEventInterval, @(acceptEventInterval), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

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

#pragma mark - ignoreEvent
-(void)setIgnoreEvent:(BOOL)ignoreEvent{
    objc_setAssociatedObject(self,UIControl_ignoreEvent, @(ignoreEvent), OBJC_ASSOCIATION_ASSIGN);
}

-(BOOL)ignoreEvent{
    return [objc_getAssociatedObject(self,UIControl_ignoreEvent) boolValue];
}

#pragma mark - Swizzling
+(void)load {
    Method a = class_getInstanceMethod(self,@selector(sendAction:to:forEvent:));
    Method b = class_getInstanceMethod(self,@selector(swizzled_sendAction:to:forEvent:));
    method_exchangeImplementations(a, b);//交換方法
}

- (void)swizzled_sendAction:(SEL)action to:(id)target forEvent:(UIEvent*)event
{
    if(self.ignoreEvent){
        NSLog(@"btnAction is intercepted");
        return;}
    if(self.acceptEventInterval>0){
        self.ignoreEvent=YES;
        [self performSelector:@selector(setIgnoreEventWithNo)  withObject:nil afterDelay:self.acceptEventInterval];
    }
    [self swizzled_sendAction:action to:target forEvent:event];
}

-(void)setIgnoreEventWithNo{
    self.ignoreEvent=NO;
}

@end
複製程式碼
  • ViewController.m
-(void)setupSubViews{
    
    UIButton *btn = [UIButton new];
    btn =[[UIButton alloc]initWithFrame:CGRectMake(100,100,100,40)];
    [btn setTitle:@"btnTest"forState:UIControlStateNormal];
    [btn setTitleColor:[UIColor redColor]forState:UIControlStateNormal];
    btn.acceptEventInterval = 3;
    [self.view addSubview:btn];
    [btn addTarget:self action:@selector(btnAction)forControlEvents:UIControlEventTouchUpInside];
}

- (void)btnAction{
    NSLog(@"btnAction is executed");
}

複製程式碼

3.3 防奔潰處理:陣列越界問題

需求

在實際工程中,可能在一些地方(比如取出網路響應資料)進行了陣列NSArray取資料的操作,而且以前的小哥們也沒有進行防越界處理。測試方一不小心也沒有測出陣列越界情況下奔潰(因為返回的資料是動態的),結果以為沒有問題了,其實還隱藏的生產事故的風險。

這時APP負責人說了,即使APP即使不能工作也不能Crash,這是最低的底線。那麼這對陣列越界的情況下的奔潰,你有沒有辦法攔截?

思路:對NSArray的objectAtIndex:方法進行Swizzling,替換一個有處理邏輯的方法。但是,這時候還是有個問題,就是類簇的Swizzling沒有那麼簡單。

類簇

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

因為這些類簇類,其實是一種抽象工廠的設計模式。抽象工廠內部有很多其它繼承自當前類的子類,抽象工廠類會根據不同情況,建立不同的抽象物件來進行使用。例如我們呼叫NSArray的objectAtIndex:方法,這個類會在方法內部判斷,內部建立不同抽象類進行操作。

所以如果我們對NSArray類進行Swizzling操作其實只是對父類進行了操作,在NSArray內部會建立其他子類來執行操作,真正執行Swizzling操作的並不是NSArray自身,所以我們應該對其“真身”進行操作。

下面列舉了NSArray和NSDictionary本類的類名,可以通過Runtime函式取出本類:

類名 真身
NSArray __NSArrayI
NSMutableArray __NSArrayM
NSDictionary __NSDictionaryI
NSMutableDictionary __NSDictionaryM

實踐

好啦,新建一個分類,直接用程式碼實現,看看怎麼取出真身的:

  • NSArray+CrashHandle.m
@implementation NSArray (CrashHandle)

// Swizzling核心程式碼
// 需要注意的是,好多同學反饋下面程式碼不起作用,造成這個問題的原因大多都是其呼叫了super load方法。在下面的load方法中,不應該呼叫父類的load方法。
+ (void)load {
    Method fromMethod = class_getInstanceMethod(objc_getClass("__NSArrayI"), @selector(objectAtIndex:));
    Method toMethod = class_getInstanceMethod(objc_getClass("__NSArrayI"), @selector(cm_objectAtIndex:));
    method_exchangeImplementations(fromMethod, toMethod);
}

// 為了避免和系統的方法衝突,我一般都會在swizzling方法前面加字首
- (id)cm_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];
    }
}
複製程式碼

這裡面可能有個誤會,- (id)cm_objectAtIndex:(NSUInteger)index {裡面呼叫了自身?這是遞迴嗎?其實不是。這個時候方法替換已經有效了,cm_objectAtIndex這個SEL指向的其實是原來系統的objectAtIndex:的IMP。因而不是遞迴。

  • ViewController.m
- (void)viewDidLoad {
    [super viewDidLoad];
    
    // 測試程式碼
    NSArray *array = @[@0, @1, @2, @3];
    [array objectAtIndex:3];
    //本來要奔潰的
    [array objectAtIndex:4];
}
複製程式碼

執行之後,發現沒有崩潰,並列印了相關資訊,如下所示。

iOS開發·runtime原理與實踐: 方法交換篇(Method Swizzling)(iOS“黑魔法”,埋點統計,禁止UI控制元件連續點選,防奔潰處理)

相關文章