【iOS】方法交换(Method Swizzling)

文章目录


前言

上文讲到了iOS的消息发送机制,在消息机制中我们了解到了SEL、IMP等方法知识,由此延伸到iOS黑魔法方法交换,本篇着重讲解iOS的方法交换的应用场景与原理

一、原理与注意

我们在消息机制中说到了我们可以通过SEL方法选择器查找Method方法,从而得到对应的IMP,方法交换的实质就是交换SELIMP从而改变方法的实现

Method Swizzing是发生在运行时的,主要用于在运行时将两个Method进行交换,我们可以将Method Swizzling代码写到任何地方,但是只有在这段Method Swilzzling代码执行完毕之后互换才起作用。

用法

先给要替换的方法的类添加一个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函数内代码只会执行一次的特性,防止方法的重复交换,使方法sel的指向又恢复成原来的imp的问题

Method Swizzing涉及的相关API

通过SEL获取方法Method

  • class_getInstanceMethod:获取实例方法
  • class_getClassMethod:获取类方法
  • method_getImplementation:获取一个方法的实现
  • method_setImplementation:设置一个方法的实现
  • method_getTypeEncoding:获取方法实现的编码类型
  • class_addMethod:添加方法实现
  • class_replaceMethod:用一个方法的实现,替换另一个方法的实现,即aIMP 指向 bIMP,但是bIMP不一定指向aIMP
  • method_exchangeImplementations:交换两个方法的实现,即 aIMP -> bIMP, bIMP -> aIMP

二、应用场景与实践

1.统计VC加载次数并打印

UIViewController+Logging.m

bash 复制代码
#import "UIViewController+Logging.h"
#import "objc/runtime.h"
@implementation UIViewController (Logging)

+ (void)load {
    static dispatch_once_t onceToken;
            dispatch_once(&onceToken, ^{
                swizzleMethod([self class], @selector(viewDidAppear:), @selector(swizzled_viewDidAppear:));
            });
//    [self swizzleMethod:[self class] andO:@selector(viewDidAppear:) andS:@selector(swizzled_viewDidAppear:)];
}


- (void)swizzled_viewDidAppear:(BOOL)animated
{
    //此处为实现原来的方法
//    [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);
    
    // 如果添加成功则让原方法的imp指向新方法
    BOOL didAddMethod = class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));
    
    // the method doesn't exist and we just added one
    if (didAddMethod) {
        // 然后让新方法的imp指向原方法
        class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
    }
    else {
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }

}

// 方法交换必须设计为类方法
//+(void)swizzleMethod:(Class)class andO:(SEL)originalSelector andS:(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);
//    
    // 如果添加成功则让原方法的imp指向新方法
    BOOL didAddMethod = class_addMethod(class, originalSelector, method_getImplementation(swizzledMethod), method_getTypeEncoding(swizzledMethod));
    
    // the method doesn't exist and we just added one
    if (didAddMethod) {
        // 然后让新方法的imp指向原方法
        class_replaceMethod(class, swizzledSelector, method_getImplementation(originalMethod), method_getTypeEncoding(originalMethod));
    }
    else {
        method_exchangeImplementations(originalMethod, swizzledMethod);
    }
//    method_exchangeImplementations(originalMethod, swizzledMethod);
//
//}
@end

我们这里即可以设置C类型的交换函数,也可以实现类方法实现的交换方法

同时在+load这个类方法中只能调用类方法,不能调用实例方法,也就是说我们的swizzleMethod如果要在+load中调用不能是实例方法

2.防止UI控件短时间多次激活事件

需求:

我们不想让按钮短时间内被多次点击该如何做呢?

比如我们想让APP所有的按钮1秒内不可连续点击

方案:

给按钮添加分类,并且添加一个需要间隔多少时间的属性,实行事件的时候判断间隔是否已经到了,如果不到就会拦截点击事件,就是不会触发点击事件

操作:

在自己写的交换方法中判断是否需要执行点击事件,这里记得仍然会调用原来的方法,只是增加了判断逻辑

实践:

由于UIButtonUIControl的子类,因而根据UIControl新建一个分类即可

  • UIControl+Limit.h
bash 复制代码
#import <UIKit/UIKit.h>

NS_ASSUME_NONNULL_BEGIN

@interface UIControl (Limit)
@property (nonatomic, assign)BOOL UIControl_ignoreEvent;
@property (nonatomic, assign)NSTimeInterval UIControl_acceptEventInterval;

@end

NS_ASSUME_NONNULL_END
  • UIControl+Limit.m
bash 复制代码
#import "UIControl+Limit.h"
#import "objc/runtime.h"

@implementation UIControl (Limit)

- (void)setUIControl_acceptEventInterval:(NSTimeInterval)UIControl_acceptEventInterval {
    objc_setAssociatedObject(self, @selector(UIControl_acceptEventInterval), @(UIControl_acceptEventInterval), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
- (NSTimeInterval)UIControl_acceptEventInterval {
    return [objc_getAssociatedObject(self, _cmd) doubleValue];
}

-(void)setUIControl_ignoreEvent:(BOOL)UIControl_ignoreEvent{
    objc_setAssociatedObject(self, @selector(UIControl_ignoreEvent), @(UIControl_ignoreEvent), OBJC_ASSOCIATION_ASSIGN);
}

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

+(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.UIControl_ignoreEvent){
        NSLog(@"btnAction is intercepted");
        return;}
    if(self.UIControl_acceptEventInterval>0){
        self.UIControl_ignoreEvent=YES;
        [self performSelector:@selector(setIgnoreEventWithNo)  withObject:nil afterDelay:self.UIControl_acceptEventInterval];
    }
    [self swizzled_sendAction:action to:target forEvent:event];
}

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

@end
  • ViewController.m
bash 复制代码
    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.UIControl_ignoreEvent=NO;
    btn.UIControl_acceptEventInterval = 3;
    [self.view addSubview:btn];
    [btn addTarget:self action:@selector(btnAction)forControlEvents:UIControlEventTouchUpInside];

3.防崩溃处理:数组越界问题

需求:

众所周知如果我们对NSArray进行操作,但是没有进行防越界处理,很有可能在读取数组的时候发生越界问题。

我们前面说到了App即使不能功能也不能crash,这就需要我们对数组进行兜底操作

思路

NSArrayobjectAtIndex:方法进行Swizzling,替换一个有处理逻辑的方法。但是,这时候还是有个问题,就是类簇的Swizzling没有那么简单。

类簇:

在iOS中NSNumberNSArrayNSDictionary等这些类都是类簇(Class Clusters),一个NSArray的实现可能由多个类组成。所以如果想对NSArray进行Swizzling,必须获取到其真身 进行Swizzling,直接对NSArray进行操作是无效的。这是因为Method Swizzling对NSArray这些的类簇是不起作用的

因此我们应该对其真身进行操作,而非NSArray自身

下面列举了NSArray和NSDictionary本类的类名,可以通过Runtime函数取出本类

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

有时候会根据数组长短不同,NSArray的真身也会不同,例如如下数组的真身就不是NSArrayI

真身就是NSConstantArray

实践:

NSArray+crash.m

bash 复制代码
#import "NSArray+crash.h"
#import "objc/runtime.h"
@implementation NSArray (crash)


+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class cls = objc_getClass("NSConstantArray");
        if (cls) {
            Method fromMethod = class_getInstanceMethod(cls, @selector(objectAtIndex:));
            Method toMethod = class_getInstanceMethod(cls, @selector(cm_objectAtIndex:));
            if (fromMethod && toMethod) {
                method_exchangeImplementations(fromMethod, toMethod);
            } else {
                NSLog(@"Swizzle failed: methods not found.");
            }
        } else {
            NSLog(@"Swizzle failed: class not found.");
        }
    });
}


- (id)cm_objectAtIndex:(NSUInteger)index {
    if (index >= self.count) {
        // 越界处理
        NSLog(@"Index %lu out of bounds, array count is %lu.", (unsigned long)index, (unsigned long)self.count);
        return nil;
    } else {
        // 正常访问,注意这里调用的是替换后的方法,因为实现已经交换
        return [self cm_objectAtIndex:index];
    }
}

ViewController.m

bash 复制代码
- (void)viewDidLoad {
    [super viewDidLoad];
    NSArray *array = @[@0, @1, @2, @3];
    NSLog(@"%@", [array objectAtIndex:3]);
    //本来要奔溃的
    NSLog(@"%@", [array objectAtIndex:4]);
}

4.防KVO崩溃

有许多的第三方库,比如 KVOController 用更优的API来规避这些crash,但是侵入性比较大,必须编码规范来约束所有人都要使用该方式。有没有什么更优雅,无感知的接入方式?

我们这里可以考虑建立一个哈希表,用来保存观察者、keyPath的信息,如果哈希表里已经有了相关的观察者,keyPath信息,那么继续添加观察者的话,就不载进行添加,同样移除观察的时候,也现在哈希表中进行查找,如果存在观察者,keypath信息,那么移除,如果没有的话就不执行相关的移除操作。

下面是核心的swizzle方法:

原函数 swizzle后的函数
addObserver:forKeyPath:options:context: cyl_crashProtectaddObserver:forKeyPath:options:context:
removeObserver:forKeyPath: cyl_crashProtectremoveObserver:forKeyPath:
removeObserver:forKeyPath:context: cyl_crashProtectremoveObserver:forKeyPath:context:
bash 复制代码
- (void)cyl_crashProtectaddObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(nullable void *)context{

   if (!observer || !keyPath || keyPath.length == 0) {
       return;
   }
   
   @synchronized (self) {
       NSInteger kvoHash = [self _cyl_crashProtectHash:observer :keyPath];
       if (!self.KVOHashTable) {
           self.KVOHashTable = [NSHashTable hashTableWithOptions:NSPointerFunctionsStrongMemory];
       }
       
       if (![self.KVOHashTable containsObject:@(kvoHash)]) {
           [self.KVOHashTable addObject:@(kvoHash)];
           [self cyl_crashProtectaddObserver:observer forKeyPath:keyPath options:options context:context];
           [self cyl_willDeallocWithSelfCallback:^(__unsafe_unretained id observedOwner, NSUInteger identifier) {
               [observedOwner cyl_crashProtectremoveObserver:observer forKeyPath:keyPath context:context];
           }];
           __unsafe_unretained typeof(self) unsafeUnretainedSelf = self;
           [observer cyl_willDeallocWithSelfCallback:^(__unsafe_unretained id observerOwner, NSUInteger identifier) {
               [unsafeUnretainedSelf cyl_crashProtectremoveObserver:observerOwner forKeyPath:keyPath context:context];
           }];
       }
   }

}

- (void)cyl_crashProtectremoveObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath context:(void *)context {
   //TODO:  加上 context 限制,防止父类、子类使用同一个keyPath。
   [self cyl_crashProtectremoveObserver:observer forKeyPath:keyPath];

}

- (void)cyl_crashProtectremoveObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath{
   //TODO:  white list
   if (!observer || !keyPath || keyPath.length == 0) {
       return;
   }
   @synchronized (self) {
       if (!observer) {
           return;
       }
       NSInteger kvoHash = [self _cyl_crashProtectHash:observer :keyPath];
       NSHashTable *hashTable = [self KVOHashTable];
       if (!hashTable) {
           return;
       }
       if ([hashTable containsObject:@(kvoHash)]) {
           [self cyl_crashProtectremoveObserver:observer forKeyPath:keyPath];
           [hashTable removeObject:@(kvoHash)];
       }
   }

}
  • 添加观察者 (cyl_crashProtectaddObserver:forKeyPath:options:context:)

参数校验 :首先检查传入的 observerkeyPath 是否为空或无效。
线程安全 :使用 @synchronized 块确保线程安全。
哈希表初始化 :如果 KVOHashTable 不存在,则初始化一个新的 NSHashTable 以存储观察者哈希。
避免重复添加 :计算当前观察者和 keyPath 的哈希值,并检查此哈希是否已存在于哈希表中。如果不存在,则添加到哈希表并执行原生的 KVO 添加观察者方法。
销毁时自动移除:注册回调以确保在观察者或被观察对象销毁时自动移除观察者。

  • 移除观察者 (cyl_crashProtectremoveObserver:forKeyPath:context: 和 cyl_crashProtectremoveObserver:forKeyPath:)

参数校验 :检查 observerkeyPath 的有效性。
线程安全 :使用 @synchronized 块确保线程安全。
安全移除:如果哈希表存在并且包含相应的观察者哈希,则从哈希表中移除该哈希,并调用原生的 KVO 移除观察者方法。

总结

这篇文章主要总结了Method Swizzling的各种应用场景,例如防止按钮被多次点击,进行hook操作以及数组与KVO的兜底操作,应用场景非常广泛,值得深入学习

参考博客:
iOS Crash防护系统-IronMan
iOS KVO 崩溃防护笔记

相关推荐
用户091 天前
SwiftUI Charts 函数绘图完全指南
ios·swiftui·swift
YungFan1 天前
iOS26适配指南之UIColor
ios·swift
权咚2 天前
阿权的开发经验小集
git·ios·xcode
用户092 天前
TipKit与CloudKit同步完全指南
ios·swift
小溪彼岸2 天前
macOS自带截图命令ScreenCapture
macos
法的空间2 天前
Flutter JsonToDart 支持 JsonSchema
android·flutter·ios
2501_915918412 天前
iOS 上架全流程指南 iOS 应用发布步骤、App Store 上架流程、uni-app 打包上传 ipa 与审核实战经验分享
android·ios·小程序·uni-app·cocoa·iphone·webview
TESmart碲视2 天前
Mac 真正多显示器支持:TESmart USB-C KVM(搭载 DisplayLink 技术)如何实现
macos·计算机外设·电脑
00后程序员张2 天前
iOS App 混淆与加固对比 源码混淆与ipa文件混淆的区别、iOS代码保护与应用安全场景最佳实践
android·安全·ios·小程序·uni-app·iphone·webview
Magnetic_h3 天前
【iOS】设计模式复习
笔记·学习·ios·设计模式·objective-c·cocoa