茶茶的小屋

不管生活有多不容易,都要守住自己的那一份优雅。

iOS-APP-运行时防Crash工具XXShield练就

前言

正在运行的 APP 突然 Crash,是一件令人不爽的事,会流失用户,影响公司发展,所以 APP 运行时拥有防 Crash 功能能有效降低 Crash 率,提升 APP 稳定性。但是有时候 APP Crash 是应有的表现,我们不让 APPCrash 可能会导致别的逻辑错误,不过我们可以抓取到应用当前的堆栈信息并上传至相关的服务器,分析并修复这些 BUG。

所以本文介绍的 XXShield 库有两个重要的功能:

  1. 防止Crash
  2. 捕获异常状态下的崩溃信息

类似的相关技术分析也有 网易iOS App运行时Crash自动防护实践

目前已经实现的功能

  1. Unrecoginzed Selector Crash
  2. KVO Crash
  3. Container Crash
  4. NSNotification Crash
  5. NSNull Crash
  6. NSTimer Crash
  7. 野指针 Crash

1 Unrecoginzed Selector Crash

出现原因

由于 Objective-C 是动态语言,所有的消息发送都会放在运行时去解析,有时候我们把一个信息传递给了错误的类型,就会导致这个错误。

解决办法

Objective-C 在出现无法解析的方法时有三部曲来进行消息转发。
详见Objective-C Runtime 运行时之三:方法与消息

  1. 动态方法解析
  2. 备用接收者
  3. 完整转发

1 一般适用与 Dynamic 修饰的 Property
2 一般适用与将方法转发至其他对象
3 一般适用与消息可以转发多个对象,可以实现类似多继承或者转发中心的概念。

这里选择的是方案二,因为三里面用到了 NSInvocation 对象,此对象性能开销较大,而且这种异常如果出现必然频次较高。最适合将消息转发到一个备用者对象上。

这里新建一个智能转发类。此对象将在其他对象无法解析数据时,返回一个 0 来防止 Crash。返回 0 是因为这个通用的智能转发类做的操作接近向 nil 发送一个消息。

代码如下


#import <objc/runtime.h>

/**
 default Implement
 @param target trarget
 @param cmd cmd
 @param ... other param
 @return default Implement is zero
 */
int smartFunction(id target, SEL cmd, ...) {
    return 0;
}

static BOOL __addMethod(Class clazz, SEL sel) {
    NSString *selName = NSStringFromSelector(sel);
    
    NSMutableString *tmpString = [[NSMutableString alloc] initWithFormat:@"%@", selName];
    
    int count = (int)[tmpString replaceOccurrencesOfString:@":"
                                                withString:@"_"
                                                   options:NSCaseInsensitiveSearch
                                                     range:NSMakeRange(0, selName.length)];
    
    NSMutableString *val = [[NSMutableString alloc] initWithString:@"i@:"];
    
    for (int i = 0; i < count; i++) {
        [val appendString:@"@"];
    }
    const char *funcTypeEncoding = [val UTF8String];
    return class_addMethod(clazz, sel, (IMP)smartFunction, funcTypeEncoding);
}

@implementation XXShieldStubObject

+ (XXShieldStubObject *)shareInstance {
    static XXShieldStubObject *singleton;
    if (!singleton) {
        static dispatch_once_t onceToken;
        dispatch_once(&onceToken, ^{
            singleton = [XXShieldStubObject new];
        });
    }
    return singleton;
}

- (BOOL)addFunc:(SEL)sel {
    return __addMethod([XXShieldStubObject class], sel);
}

+ (BOOL)addClassFunc:(SEL)sel {
    Class metaClass = objc_getMetaClass(class_getName([XXShieldStubObject class]));
    return __addMethod(metaClass, sel);
}

@end

我们这里需要 Hook NSObject的 - (id)forwardingTargetForSelector:(SEL)aSelector 方法启动消息转发。
很多人不知道的是如果想要转发类方法,只需要实现一个同名的类方法即可,虽然在头文件中此方法并未声明。


XXStaticHookClass(NSObject, ProtectFW, id, @selector(forwardingTargetForSelector:), (SEL)aSelector) {
    // 1 如果是NSSNumber 和NSString没找到就是类型不对  切换下类型就好了
    if ([self isKindOfClass:[NSNumber class]] && [NSString instancesRespondToSelector:aSelector]) {
        NSNumber *number = (NSNumber *)self;
        NSString *str = [number stringValue];
        return str;
    } else if ([self isKindOfClass:[NSString class]] && [NSNumber instancesRespondToSelector:aSelector]) {
        NSString *str = (NSString *)self;
        NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];
        NSNumber *number = [formatter numberFromString:str];
        return number;
    }
    
    BOOL aBool = [self respondsToSelector:aSelector];
    NSMethodSignature *signatrue = [self methodSignatureForSelector:aSelector];
    
    if (aBool || signatrue) {
        return XXHookOrgin(aSelector);
    } else {
        XXShieldStubObject *stub = [XXShieldStubObject shareInstance];
        [stub addFunc:aSelector];
        
        NSString *reason = [NSString stringWithFormat:@"*****Warning***** logic error.target is %@ method is %@, reason : method forword to SmartFunction Object default implement like send message to nil.",
                            [self class], NSStringFromSelector(aSelector)];
        [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeUnrecognizedSelector];
        
        return stub;
    }
}
XXStaticHookEnd

这里汇报了 Crash 信息,出现消息转发一般是一个 logic 错误,为必须修复的Bug,上报尤为重要。


2 KVO Crash

出现原因

KVOCrash总结下来有以下2大类。

  1. 不匹配的移除和添加关系。
  2. 观察者和被观察者释放的时候没有及时断开观察者关系。

解决办法

尼古拉斯赵四说过 :赵四
对比到程序世界就是,程序世界没有什么难以解决的问题都是不可以通过抽象层次来解决的,如果有,那就两层。
纵观程序的架构设计,计算机网络协议分层设计,操作系统内核设计等等都是如此。

问题1 : 不成对的添加观察者和移除观察者会导致 Crash,以往我们使用 KVO,观察者和被观察者都是直接交互的。这里的设计方案是我们找一个 Proxy 用来做转发, 真正的观察者是 Proxy,被观察者出现了通知信息,由 Proxy 做分发。所以 Proxy 里面要保存一个数据结构 {keypath : [observer1, observer2,...]} 。


@interface XXKVOProxy : NSObject {
    __unsafe_unretained NSObject *_observed;
}

/**
 {keypath : [ob1,ob2](NSHashTable)}
 */
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSHashTable<NSObject *> *> *kvoInfoMap;

@end

我们需要 Hook NSObject的 KVO 相关方法。


- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context;

- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;

  1. 在添加观察者时
    addObserver
  1. 在移除观察者时

removeObserver

问题2: 观察者和被观察者释放的时候没有断开观察者关系。
对于观察者, 既然我们是自己用 Proxy 做的分发,我们自己就需要保存观察者,这里我们简单的使用 NSHashTable 指定指针持有策略为 weak 即可。

对于被观察者,我们使用 iOS 界的毒瘤-MethodSwizzling
一文中到的方法。我们在被观察者上绑定一个关联对象,在关联对象的 dealloc 方法中做相关操作即可。


- (void)dealloc {
    @autoreleasepool {
        NSDictionary<NSString *, NSHashTable<NSObject *> *> *kvoinfos =  self.kvoInfoMap.copy;
        for (NSString *keyPath in kvoinfos) {
            // call original  IMP
            __xx_hook_orgin_function_removeObserver(_observed,@selector(removeObserver:forKeyPath:),self, keyPath);
        }
    }
}


3 Container Crash

出现原因

容器在任何编程语言中都尤为重要,容器是数据的载体,很多容器对容器放空值都做了容错处理。不幸的是 Objective-C 并没有,容器插入了 nil 就会导致 Crash,容器还有另外一个最容易 Crash 的原因就是下标越界。

解决办法

常见的容器有 NS(Mutable)Array , NS(Mutable)Dictionary, NSCache 等。我们需要 hook 常见的方法加入检测功能并且捕获堆栈信息上报。

例如


XXStaticHookClass(NSArray, ProtectCont, id, @selector(objectAtIndex:),(NSUInteger)index) {
if (self.count == 0) {
    
    NSString *reason = [NSString stringWithFormat:@"target is %@ method is %@,reason : index %@ out of count %@ of array ",
                        [self class], XXSEL2Str(@selector(objectAtIndex:)), @(index), @(self.count)];
    [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeContainer];
    return nil;
}

if (index >= self.count) {
    NSString *reason = [NSString stringWithFormat:@"target is %@ method is %@,reason : index %@ out of count %@ of array ",
                        [self class], XXSEL2Str(@selector(objectAtIndex:)), @(index), @(self.count)];
    [XXRecord recordFatalWithReason:reason userinfo:nil errorType:EXXShieldTypeContainer];
    return nil;
}

return XXHookOrgin(index);
}
XXStaticHookEnd

但是需要注意的是 NSArray 是一个 Class Cluster 的抽象父类,所以我们需要 Hook 到我们真正的子类。

这里给出一个辅助方法,获取一个类的所有直接子类:

+ (NSArray *)findAllOf:(Class)defaultClass {
    
    int count = objc_getClassList(NULL, 0);
    
    if (count <= 0) {
        
        @throw@"Couldn't retrieve Obj-C class-list";
        
        return @[defaultClass];
    }
    
    NSMutableArray *output = @[].mutableCopy;
    
    Class *classes = (Class *) malloc(sizeof(Class) * count);
    
    objc_getClassList(classes, count);
    
    for (int i = 0; i < count; ++i) {
        
        if (defaultClass == class_getSuperclass(classes[i]))//子类
        {
            [output addObject:classes[i]];
        }
        
    }
    
    free(classes);
    
    return output.copy;
    
}

// 对于NSarray :

//[NSarray array] 和 @[] 的类型是__NSArray0
//只有一个元素的数组类型 __NSSingleObjectArrayI,
// 其他的大部分是//__NSArrayI,



// 对于NSMutableArray :
//[NSMutableDictionary dictionary] 和 @[].mutableCopy__NSArrayM



// 对于NSDictionary: :

//[NSDictionary dictionary];。 @{}; __NSDictionary0
// 其他一般是  __NSDictionaryI

// 对于NSMutableDictionary: :
// 一般用到的是 __NSDictionaryM

4 NSNotification Crash

出现原因

在 iOS8 及以下的操作系统中添加的观察者一般需要在 dealloc 的时候做移除,如果开发者忘记移除,则在发送通知的时候会导致 Crash,而在 iOS9 上即使移忘记除也无所谓,猜想可能是 iOS9 之后系统将通知中心持有对象由 assign 变为了weak

解决办法

所以这里两种解决办法

  1. 类似 KVO 中间加上 Proxy 层,使用 weak 指针来持有对象
  2. 在 dealloc 的时候将未被移除的观察者移除

这里我们使用 iOS 界的毒瘤-MethodSwizzling
一文中到的方法。


5 NSNull Crash

出现原因

虽然 Objecttive-C 不允许开发者将 nil 放进容器内,但是另外一个代表用户态 的类 NSNull 却可以放进容器,但令人不爽的是这个类的实例,并不能响应任何方法。

容器中出现 NSNull 一般是 API 接口返回了含有 null 的 JSON 数据,
调用方通常将其理解为 NSNumber,NSString,NSDictionary 和 NSArray。 这时开发者如果没有做好防御 一旦对 NSNull 这个类型调用任何方法都会出现 unrecongized selector 错误。

解决办法

我们在 NSNull 的转发方法中可以判断上面的四种类型是否可以解析。如果可以解析直接将其转发给这几种对象,如果不能则调用父类的默认实现。


XXStaticHookClass(NSNull, ProtectNull, id, @selector(forwardingTargetForSelector:), (SEL) aSelector) {
    static NSArray *sTmpOutput = nil;
    if (sTmpOutput == nil) {
        sTmpOutput = @[@"", @0, @[], @{}];
    }
    
    for (id tmpObj in sTmpOutput) {
        if ([tmpObj respondsToSelector:aSelector]) {
            return tmpObj;
        }
    }
    return XXHookOrgin(aSelector);
}
XXStaticHookEnd

6. NSTimer Crash

出现原因

在使用 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo 创建定时任务的时候,target 一般都会持有 timer,timer又会持有 target 对象,在我们没有正确关闭定时器的时候,timer 会一直持有target 导致内存泄漏。

解决办法

同 KVO 一样,既然 timer 和 target 直接交互容易出现问题,我们就再找个代理将 target 和 selctor 等信息保存到 Proxy 里,并且是弱引用 target。
这样避免因为循环引用造成的内存泄漏。然后在触发真正 target 事件的时候如果 target 置为 nil 了这时候手动去关闭定时器。


XXStaticHookMetaClass(NSTimer, ProtectTimer,  NSTimer * ,@selector(scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:),
                      (NSTimeInterval)ti , (id)aTarget, (SEL)aSelector, (id)userInfo, (BOOL)yesOrNo ) {
    if (yesOrNo) {
        NSTimer *timer =  nil ;
        @autoreleasepool {
            XXTimerProxy *proxy = [XXTimerProxy new];
            proxy.target = aTarget;
            proxy.aSelector = aSelector;
            timer.timerProxy = proxy;
            timer = XXHookOrgin(ti, proxy, @selector(trigger:), userInfo, yesOrNo);
            proxy.sourceTimer = timer;
        }
        return  timer;
    }
    return XXHookOrgin(ti, aTarget, aSelector, userInfo, yesOrNo);
}
XXStaticHookEnd
@implementation XXTimerProxy

- (void)trigger:(id)userinfo  {
    id strongTarget = self.target;
    if (strongTarget && ([strongTarget respondsToSelector:self.aSelector])) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
        [strongTarget performSelector:self.aSelector withObject:userinfo];
#pragma clang diagnostic pop
    } else {
        NSTimer *sourceTimer = self.sourceTimer;
        if (sourceTimer) {
            [sourceTimer invalidate];
        }
        NSString *reason = [NSString stringWithFormat:@"*****Warning***** logic error target is %@ method is %@, reason : an object dealloc not invalidate Timer.",
                            [self class], NSStringFromSelector(self.aSelector)];
        
        [XXRecord recordFatalWithReason:reason userinfo:nil errorType:(EXXShieldTypeTimer)];
    }
}

@end

7. 野指针 Crash

出现原因

一般在单线程条件下使用 ARC 正确的处理引用关系野指针出现的并不频繁, 但是多线程下则不尽然,通常在一个线程中释放了对象,另外一个线程还没有更新指针状态 后续访问就可能会造成随机性 bug。

之所以是随机 bug 是因为被回收的内存不一定立马被使用。而且崩溃的位置可能也与原来的逻辑相聚很远,因此收集的堆栈信息也可能是杂乱无章没有什么价值。
具体的分类请看Bugly整理的脑图。
x

更多关于野指针的文章请参考:

  1. 如何定位Obj-C野指针随机Crash(一)
  2. 如何定位Obj-C野指针随机Crash(二)
  3. 如何定位Obj-C野指针随机Crash(三)

解决办法

这里我们可以借用系统的NSZombies对象的设计。
参考buildNSZombie

解决过程

  1. 建立白名单机制,由于系统的类基本不会出现野指针,而且 hook 所有的类开销较大。所以我们只过滤开发者自定义的类。

  2. hook dealloc 方法 这些需要保护的类我们并不让其释放,而是调用objc_desctructInstance 方法释放实例内部所持有属性的引用和关联对象。

  3. 利用 object_setClass(id,Class) 修改 isa 指针将其指向一个Proxy 对象(类比系统的 KVO 实现),此 Proxy 实现了一个和前面所说的智能转发类一样的 return 0的函数。

  4. 在 Proxy 对象内的 - (void)forwardInvocation:(NSInvocation *)anInvocation 中收集 Crash 信息。

  5. 缓存的对象是有成本的,我们在缓存对象到达一定数量时候将其释放(object_dispose)。

存在问题

  1. 延迟释放内存会造成性能浪费,所以默认缓存会造成野指针的Class实例的对象限制是50,超出之后会释放,如果这时候再此触发了刚好释放掉的野指针,还是会造成Crash的,

  2. 建议使用的时候如果近期没有野指针的Crash可以不必开启,如果野指针类型的Crash突然增多,可以考虑在 hot Patch 中开启野指针防护,待收取异常信息之后,再关闭此开关。


收集信息

由于希望此库没有任何外部依赖,所以并未实现响应的上报逻辑。使用者如果需要上报信息 只需要自行实现 XXRecordProtocol 即可,然后在开启 SDK 之前将其注册进入 SDK。
在实现方法里面会接收到 XXShield 内部定义的错误信息。
开发者无论可以使用诸如 CrashLytics,友盟, bugly等第三库,或者自行 dump堆栈信息都可。

@protocol XXRecordProtocol <NSObject>

- (void)recordWithReason:(NSError * )reason userInfo:(NSDictionary *)userInfo;

@end

使用方法

示例工程


git clone git@github.com:ValiantCat/XXShield.git
cd Example
pod install 
open XXShield.xcworkspace

Install

    
  pod "XXShield"
    

Usage


/**
 注册汇报中心
 
 @param record 汇报中心
 */
+ (void)registerRecordHandler:(id<XXRecordProtocol>)record;

/**
 注册SDK,默认只要开启就打开防Crash,如果需要DEBUG关闭,请在调用处使用条件编译
 本注册方式不包含EXXShieldTypeDangLingPointer类型
 */
+ (void)registerStabilitySDK;

/**
 本注册方式不包含EXXShieldTypeDangLingPointer类型
 
 @param ability ability
 */
+ (void)registerStabilityWithAbility:(EXXShieldType)ability;

/**
 ///注册EXXShieldTypeDangLingPointer需要传入存储类名的array,暂时请不要传入系统框架类
 
 @param ability ability description
 @param classNames 野指针类列表
 */
+ (void)registerStabilityWithAbility:(EXXShieldType)ability withClassNames:(nonnull NSArray<NSString *> *)classNames;


ChangeLog

ChangeLog

单元测试

相关的单元测试在示例工程的Test Target下,有兴趣的开发者可以自行查看。并且已经接入 TrivisCI保证了代码质量。

Bug&Feature

如果有相关的 Bug 请提 Issue

如果觉得可以扩充新的防护类型,请提 PR 给我。

作者

ValiantCat, 519224747@qq.com
个人博客
南栀倾寒的简书

License

XXShield 使用 Apache-2.0 开源协议.


扫描二维码,在手机上阅读!
阅读更多

iOS界的毒瘤-MethodSwizzling

# 为什么有这篇博文

不知道何时开始iOS面试开始流行起来询问什么是 Runtime,于是 iOSer 一听 Runtime 总是就提起 MethodSwizzling,开口闭口就是黑科技。但其实如果读者留意过C语言的 Hook 原理其实会发现所谓的钩子都是框架或者语言的设计者预留给我们的工具,而不是什么黑科技,MethodSwizzling 其实只是一个简单而有趣的机制罢了。然而就是这样的机制,在日常中却总能成为万能药一般的被肆无忌惮的使用。

很多 iOS 项目初期架构设计的不够健壮,后期可扩展性差。于是 iOSer 想起了 MethodSwizzling 这个武器,将项目中一个正常的方法 hook 的满天飞,导致项目的质量变得难以控制。曾经我也爱在项目中滥用 MethodSwizzling,但在踩到坑之前总是不能意识到这种糟糕的做法会让项目陷入怎样的险境。于是我才明白学习某个机制要去深入的理解机制的设计,而不是跟风滥用,带来糟糕的后果。最后就有了这篇文章。

Hook的对象

在 iOS 平台常见的 hook 的对象一般有两种:

  1. C/C++ functions
  2. Objective-C method

对于 C/C+ +的 hook 常见的方式可以使用 facebook 的 fishhook 框架,具体原理可以参考深入理解Mac OS X & iOS 操作系统 这本书。
对于 Objective-C Methods 可能大家更熟悉一点,本文也只讨论这个。

最常见的hook代码

相信很多人使用过 JRSwizzle 这个库,或者是看过 http://nshipster.cn/method-swizzling/ 的博文。
上述的代码简化如下。


+ (BOOL)jr_swizzleMethod:(SEL)origSel_ withMethod:(SEL)altSel_ error:(NSError**)error_ {

    Method origMethod = class_getInstanceMethod(self, origSel_);
    if (!origMethod) {
        SetNSError(error_, @"original method %@ not found for class %@", NSStringFromSelector(origSel_), [self class]);
        return NO;
    }

    Method altMethod = class_getInstanceMethod(self, altSel_);
    if (!altMethod) {
        SetNSError(error_, @"alternate method %@ not found for class %@", NSStringFromSelector(altSel_), [self class]);
        return NO;
    }

    class_addMethod(self,
                    origSel_,
                    class_getMethodImplementation(self, origSel_),
                    method_getTypeEncoding(origMethod));

    class_addMethod(self,
                    altSel_,
                    class_getMethodImplementation(self, altSel_),
                    method_getTypeEncoding(altMethod));

    method_exchangeImplementations(class_getInstanceMethod(self, origSel_), class_getInstanceMethod(self, altSel_));
    return YES;

在Swizzling情况极为普通的情况下上述代码不会出现问题,但是场景复杂之后上面的代码会有很多安全隐患。

MethodSwizzling泛滥下的隐患

Github有一个很健壮的库 RSSwizzle(这也是本文推荐Swizzling的最终方式) 指出了上面代码带来的风险点。

  1. 只在 +load 中执行 swizzling 才是安全的。

  2. 被 hook 的方法必须是当前类自身的方法,如果把继承来的 IMP copy 到自身上面会存在问题。父类的方法应该在调用的时候使用,而不是 swizzling 的时候 copy 到子类。

  3. 被 Swizzled 的方法如果依赖与 cmd ,hook 之后 cmd 发送了变化,就会有问题(一般你 hook 的是系统类,也不知道系统用没用 cmd 这个参数)。

  4. 命名如果冲突导致之前 hook 的失效 或者是循环调用。

上述问题中第一条和第四条说的是通常的 MethodSwizzling 是在分类里面实现的, 而分类的 Method 是被Runtime 加载的时候追加到类的 MethodList ,如果不是在 +load 是执行的 Swizzling 一旦出现重名,那么 SEL 和 IMP 不匹配致 hook 的结果是循环调用。

第三条是一个不容易被发现的问题。
我们都知道 Objective-C Method 都会有两个隐含的参数 self, cmd,有的时候开发者在使用关联属性的适合可能懒得声明 (void *) 的 key,直接使用 cmd 变量 objc_setAssociatedObject(self, _cmd, xx, 0); 这会导致对当前IMP对 cmd 的依赖。

一旦此方法被 Swizzling,那么方法的 cmd 势必会发生变化,出现了 bug 之后想必你一定找不到,等你找到之后心里一定会问候那位 Swizzling 你的方法的开发者祖宗十八代安好的,再者如果你 Swizzling 的是系统的方法恰好系统的方法内部用到了 cmd ...~_~(此处后背惊起一阵冷汗)。

Copy父类的方法带来的问题

上面的第二条才是我们最容易遇见的场景,并且是99%的开发者都不会注意到的问题。下面我们来做个试验


@implementation Person

- (void)sayHello {
    NSLog(@"person say hello");
}

@end

@interface Student : Person

@end

@implementation Student (swizzle)

+ (void)load {
    [self jr_swizzleMethod:@selector(s_sayHello) withMethod:@selector(sayHello) error:nil];
}

- (void)s_sayHello {
    [self s_sayHello];

    NSLog(@"Student + swizzle say hello");
}

@end

@implementation Person (swizzle)

+ (void)load {
    [self jr_swizzleMethod:@selector(p_sayHello) withMethod:@selector(sayHello) error:nil];
}

- (void)p_sayHello {
    [self p_sayHello];
    
    NSLog(@"Person + swizzle say hello");
}

@end

上面的代码中有一个 Person 类实现了 sayHello 方法,有一个 Student 继承自 Person, 有一个Student 分类 Swizzling 了原来的 sayHello, 还有一个 Person 的分类也 Swizzling 了原来的 sayhello 方法。

当我们生成一个 Student 类的实例并且调用 sayHello 方法,我们期望的输出如下:

"person say hello"
"Person + swizzle say hello"
"Student + swizzle say hello"

但是输出有可能是这样的:

"person say hello"
"Student + swizzle say hello"

出现这样的场景是由于在 build Phasescompile Source 顺序子类分类在父类分类之前。

我们都知道在 Objective-C 的世界里父类的 +load 早于子类,但是并没有限制父类的分类加载会早于子类的分类的加载,实际上这取决于编译的顺序。最终会按照编译的顺序合并进 Mach-O 的固定 section 内。

下面会分析下为什么代码会出现这样的场景。

最开始的时候父类拥有自己的 sayHello 方法,子类拥有分类添加的 s_sayHello 方法并且在 s_sayHello 方法内部调用了 sel 为 s_sayHello 方法。

但是子类的分类在使用上面提到的 MethodSwizzling 的方法会导致如下图的变化

由于调用了 class_addMethod 方法会导致重新生成一份新的Method添加到 Student 类上面 但是 sel 并没有发生变化,IMP 还是指向父类唯一的那个 IMP。
之后交换了子类两个方法的 IMP 指针。于是方法引用变成了如下结构。
其中虚线指出的是方法的调用路径。

单纯在 Swizzling 一次的时候并没有什么问题,但是我们并不能保证同事出于某种不可告人的目的的又去 Swizzling 了父类,或者是我们引入的第三库做了这样的操作。

于是我们在 Person 的分类里面 Swizzling 的时候会导致方法结构发生如下变化。

我们的代码调用路径就会是下图这样,相信你已经明白了前面的代码执行结果中为什么父类在子类之后 Swizzling 其实并没有对子类 hook 到。

这只是其中一种很常见的场景,造成的影响也只是 Hook 不到父类的派生类而已,也不会造成一些严重的 Crash 等明显现象,所以大部分开发者对此种行为是毫不知情的。

对于这种 Swizzling 方式的不确定性有一篇博文分析的更为全面玉令天下的博客Objective-C Method Swizzling

换个姿势来Swizzling

前面提到 RSSwizzle 是另外一种更加健壮的Swizzling方式。

这里使用到了如下代码

   RSSwizzleInstanceMethod([Student class],
                            @selector(sayHello),
                            RSSWReturnType(void),
                            RSSWArguments(),
                            RSSWReplacement(
                                            {
                                                // Calling original implementation.
                                                RSSWCallOriginal();
                                                // Returning modified return value.
                                                NSLog(@"Student + swizzle say hello sencod time");
                                            }), 0, NULL);

    RSSwizzleInstanceMethod([Person class],
                            @selector(sayHello),
                            RSSWReturnType(void),
                            RSSWArguments(),
                            RSSWReplacement(
                                            {
                                                // Calling original implementation.
                                                RSSWCallOriginal();
                                                // Returning modified return value.
                                                NSLog(@"Person + swizzle say hello");
                                            }), 0, NULL);

由于 RS 的方式需要提供一种 Swizzling 任何类型的签名的 SEL,所以 RS 使用的是宏作为代码包装的入口,并且由开发者自行保证方法的参数个数和参数类型的正确性,所以使用起来也较为晦涩。 可能这也是他为什么这么优秀但是 star 很少的原因吧 :(。

我们将宏展开


    RSSwizzleImpFactoryBlock newImp = ^id(RSSwizzleInfo *swizzleInfo) {
        void (*originalImplementation_)(__attribute__((objc_ownership(none))) id, SEL);
        SEL selector_ = @selector(sayHello);
        return ^void (__attribute__((objc_ownership(none))) id self) {
            IMP xx = method_getImplementation(class_getInstanceMethod([Student class], selector_));
            IMP xx1 = method_getImplementation(class_getInstanceMethod(class_getSuperclass([Student class]) , selector_));
            IMP oriiMP = (IMP)[swizzleInfo getOriginalImplementation];
                ((__typeof(originalImplementation_))[swizzleInfo getOriginalImplementation])(self, selector_);
            //只有这一行是我们的核心逻辑
            NSLog(@"Student + swizzle say hello");
            
        };
        
    };
    [RSSwizzle swizzleInstanceMethod:@selector(sayHello)
                             inClass:[[Student class] class]
                       newImpFactory:newImp
                                mode:0 key:((void*)0)];;

RSSwizzle核心代码其实只有一个函数



static void swizzle(Class classToSwizzle,
                    SEL selector,
                    RSSwizzleImpFactoryBlock factoryBlock)
{
    Method method = class_getInstanceMethod(classToSwizzle, selector);

    __block IMP originalIMP = NULL;


    RSSWizzleImpProvider originalImpProvider = ^IMP{

        IMP imp = originalIMP;
        
        if (NULL == imp){

            Class superclass = class_getSuperclass(classToSwizzle);
            imp = method_getImplementation(class_getInstanceMethod(superclass,selector));
        }
        return imp;
    };
    
    RSSwizzleInfo *swizzleInfo = [RSSwizzleInfo new];
    swizzleInfo.selector = selector;
    swizzleInfo.impProviderBlock = originalImpProvider;

    id newIMPBlock = factoryBlock(swizzleInfo);
    
    const char *methodType = method_getTypeEncoding(method);
    
    IMP newIMP = imp_implementationWithBlock(newIMPBlock);

    originalIMP = class_replaceMethod(classToSwizzle, selector, newIMP, methodType);
}

上述代码已经删除无关的加锁,防御逻辑,简化理解。

我们可以看到 RS 的代码其实是构造了一个 Block 里面装着我们需要的执行的代码。

然后再把我们的名字叫 originalImpProviderBloc 当做参数传递到我们的block里面,这里面包含了对将要被 Swizzling 的原始 IMP 的调用。

需要注意的是使用 class_replaceMethod 的时候如果一个方法来自父类,那么就给子类 add 一个方法, 并且把这个 NewIMP 设置给他,然后返回的结果是NULL。

originalImpProviderBloc 里面我们注意到如果 imp 是 NULL的时候,是动态的拿到父类的 Method 然后去执行。

我们还用图来分析代码。

最开始 Swizzling 第一次的时候,由于子类不存在 sayHello 方法,再添加方法的时候由于返回的原始 IMP 是 NULL,所以对父类的调用是动态获取的,而不是通过之前的 sel 指针去调用。

如果我们再次对 Student Hook,由于 Student 已经有 sayHello 方法,这次 replace 会返回原来 IMP 的指针, 然后新的 IMP 会执被填充到 Method 的指针指向。

由此可见我们的方法引用是一个链表形状的。

同理我们在 hook 父类的时候 父类的方法引用也是一个链表样式的。

相信到了这里你已经理解 RS 来 Swizzling 方式是:

如果是父类的方法那么就动态查找,如果是自身的方法就构造方法引用链。来保证多次 Swizzling 的稳定性,并且不会和别人的 Swizzling 冲突。

而且 RS 的实现由于不是分类的方法也不用约束开发者必须在 +load 方法调用才能保证安全,并且cmd 也不会发生变化。

其他Hook方式

其实著名的 Hook 库还有一个叫 Aspect 他利用的方法是把所有的方法调用指向 _objc_msgForward 然后自行实现消息转发的步骤,在里面自行处理参数列表和返回值,通过 NSInvocation 去动态调用。

国内知名的热修复库 JSPatch 就是借鉴这种方式来实现热修复的。

但是上面的库要求必须是最后执行的确保 Hook 的成功。 而且他不兼容其他 Hook 方式,所以技术选型的时候要深思熟虑。

什么时候需要Swizzling

我记得第一次学习 AO P概念的时候是当初在学习 javaWeb 的时候 Serverlet 里面的 FilterChain,开发者可以实现各种各种的过滤器然后在过滤器中插入log, 统计, 缓存等无关主业务逻辑的功能行性代码, 著名的框架 Struts2 就是这样实现的。

iOS 中由于 Swizzling 的 API 的简单易用性导致开发者肆意滥用,影响了项目的稳定性。
当我们想要 Swizzling 的时候应该思考下我们能不能利用良好的代码和架构设计来实现,或者是深入语言的特性来实现。

一个利用语言特性的例子

我们都知道在iOS8下的操作系统中通知中心会持有一个 __unsafe_unretained 的观察者指针。如果观察者在 dealloc 的时候忘记从通知中心中移除,之后如果触发相关的通知就会造成 Crash。

我在设计防 Crash 工具 XXShield 的时候最初是 Hook NSObjec 的 dealloc 方法,在里面做相应的移除观察者操作。后来一位真大佬提出这是一个非常不明智的操作,因为 dealloc 会影响全局的实例的释放,开发者并不能保证代码质量非常有保障,一旦出现问题将会引起整个 APP 运行期间大面积崩溃或异常行为。

下面我们先来看下 ObjCRuntime 源码关于一个对象释放时要做的事情,代码约在objc-runtime-new.mm第6240行。


/***********************************************************************
* objc_destructInstance
* Destroys an instance without freeing memory. 
* Calls C++ destructors.
* Calls ARC ivar cleanup.
* Removes associative references.
* Returns `obj`. Does nothing if `obj` is nil.
**********************************************************************/
void *objc_destructInstance(id obj) 
{
    if (obj) {
        // Read all of the flags at once for performance.
        bool cxx = obj->hasCxxDtor();
        bool assoc = obj->hasAssociatedObjects();

        // This order is important.
        if (cxx) object_cxxDestruct(obj);
        if (assoc) _object_remove_assocations(obj);
        obj->clearDeallocating();
    }

    return obj;
}


/***********************************************************************
* object_dispose
* fixme
* Locking: none
**********************************************************************/
id 
object_dispose(id obj)
{
    if (!obj) return nil;

    objc_destructInstance(obj);    
    free(obj);

    return nil;
}

上面的逻辑中明确了写明了一个对象在释放的时候初了调用 dealloc 方法,还需要断开实例上绑定的观察对象, 那么我们可以在添加观察者的时候给观察者动态的绑定一个关联对象,然后关联对象可以反向持有观察者,然后在关联对象释放的时候去移除观察者,由于不能造成循环引用所以只能选择 __weak 或者 __unsafe_unretained 的指针, 实验得知 __weak 的指针在 dealloc 之前就已经被清空, 所以我们只能使用 __unsafe_unretained 指针。


@interface XXObserverRemover : NSObject {
    __strong NSMutableArray *_centers;
    __unsafe_unretained id _obs;
}
@end
@implementation XXObserverRemover

- (instancetype)initWithObserver:(id)obs {
    if (self = [super init]) {
        _obs = obs;
        _centers = @[].mutableCopy;
    }
    return self;
}

- (void)addCenter:(NSNotificationCenter*)center {
    if (center) {
        [_centers addObject:center];
    }
}

- (void)dealloc {
    @autoreleasepool {
        for (NSNotificationCenter *center in _centers) {
            [center removeObserver:_obs];
        }
    }
}

@end

void addCenterForObserver(NSNotificationCenter *center ,id obs) {
    XXObserverRemover *remover = nil;
    static char removerKey;
    @autoreleasepool {
        remover = objc_getAssociatedObject(obs, &removerKey);
        if (!remover) {
            remover = [[XXObserverRemover alloc] initWithObserver:obs];
            objc_setAssociatedObject(obs, &removerKey, remover, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
        }
        [remover addCenter:center];
    }
    
}
void autoHook() {
    RSSwizzleInstanceMethod([NSNotificationCenter class], @selector(addObserver:selector:name:object:),
                            RSSWReturnType(void), RSSWArguments(id obs,SEL cmd,NSString *name,id obj),
                            RSSWReplacement({
        RSSWCallOriginal(obs,cmd,name,obj);
        addCenterForObserver(self, obs);
    }), 0, NULL);
    
}

需要注意的是在添加关联者的时候一定要将代码包含在一个自定义的 AutoreleasePool 内。

我们都知道在 Objective-C 的世界里一个对象如果是 Autorelease 的 那么这个对象在当前方法栈结束后才会延时释放,在 ARC 环境下,一般一个 Autorelease 的对象会被放在一个系统提供的 AutoreleasePool 里面,然后AutoReleasePool drain 的时候再去释放内部持有的对象,通常情况下命令行程序是没有问题的,但是在iOS的环境中 AutoReleasePool是在 Runloop 控制下在空闲时间进行释放的,这样可以提升用户体验,避免造成卡顿,但是在我们这种场景中会有问题,我们严格依赖了观察者调用 dealloc 的时候关联对象也会去 dealloc,如果系统的 AutoReleasePool 出现了延时释放,会导致当前对象被回收之后 过段时间关联对象才会释放,这时候前文使用的 __unsafe_unretained 访问的就是非法地址。

我们在添加关联对象的时候添加一个自定义的 AutoreleasePool 保证了对关联对象引用的单一性,保证了我们依赖的释放顺序是正确的。从而正确的移除观察者。

参考

  1. JRSwizzle
  2. RSSwizzle
  3. Aspect
  4. 玉令天下的博客Objective-C Method Swizzling
  5. 示例代码

友情感谢

最后感谢 骑神 大佬修改我那蹩脚的文字描述。


扫描二维码,在手机上阅读!
阅读更多

豫ICP备17024504号-1

shijiebei 365bet manbetx 188bet xinshui caipiao 95zz tongbaoyule beplay 88bifa 18luck betway bwin hg0088 aomenjinshayulecheng ca88 shenbotaiyangcheng vwin w88 weide