DevSupportios runtime专题

Objective-C Runtime(二): 实践 监测与防护

2017-05-01  本文已影响659人  4d1487047cf6

上篇文章 介绍了一些runtime的基础知识, 这次分享一些runtime的各种黑科技玩法: 消息转发截获, isa-swizzling, method swizzling, associated object等等. 顺便研究了野指针的问题, 以及如何写一个僵尸对象(Zombie).

Unrecognized Selector

消息转发截获

这个简单了, 首先来张图:

objc_runtime_msgSend.jpeg
当向对象发送消息, 沿着类的继承链找不到响应的方法时, runtime的消息转发机制会依次调用这几个方法. 这里选择第二个forwardingTargetForSelector来操作. 该方法返回一个对象, 该对象为消息新的接受者.

这里我们选择了第二步forwardingTargetForSelector来做文章。原因如下:

好了. 代码:

id forwardingTargetForSelector(id self, SEL _cmd, SEL aSelector) {
    NSLog(@"Unrecognized selector %@ sent to %@, ***forwarding to Stub", NSStringFromSelector(aSelector), self);
    StubProxy *stub = [[StubProxy alloc] init];
    if (![stub respondsToSelector:aSelector]) {
        class_addMethod([stub class], aSelector, (IMP)someMethodIMP, "v@:");
    }
    return stub;
}

void someMethodIMP(id self, SEL _cmd) {
    NSLog(@"*** someMethodIMP prevent the crash. *** ");
}

这里方法写成了C语言的格式, 其实是一样的. 所有实例方法都隐含了self_cmd参数, 最终OC形式的方法也会转化成类似形式. 如写成OC形式的方法, 可以调用runtime的class_getInstanceMethodmethod_getImplementation转化为IMPclass_addMethod使用. 一样道理~

StubProxy是一个桩类, 可认为它仅是一个空的模板, 也可以不在代码中定义类, 直接使用runtime的objc_allocateClassPairobjc_registerClassPair函数去动态创建并注册类, 只需把

StubProxy *stub = [[StubProxy alloc] init];

换成

Class StubProxy = objc_allocateClassPair([NSObject class], "StubProxy", 0);
objc_registerClassPair(StubProxy);
class_addMethod(StubProxy, aSelector, (IMP)someMethodIMP, "v@:");
id stub = [[StubProxy alloc] init];

然后再APP开始运行的地方(如AppDeleage的didFinishLaunchingWithOptions回调)加上代码:

//get target class
id targetClass = objc_getClass("MyViewController");

//override the forwardingTargetForSelector method of NSObject
class_addMethod([targetClass class], @selector(forwardingTargetForSelector:), (IMP)forwardingTargetForSelector, "@@:@");

这里"MyViewController"是一个你需要加上unrecognized selector 崩溃防护的类, 这里使用class_addMethod函数动态为该类添加上forwardingTargetForSelector方法, 该方法把无法识别的selector消息转发至一个Stub类的对象, 该对象为这个selector动态添加一个函数实现, 这个函数怎么实现就自定义了, 可以为空, 返回0, 或者打印个日志, 随你所好. 该函数对应Demo里的void someMethodIMP().

由此, 当出现Unrecognized selector时, 原本的Crash

CrashCrusher[65488:28134311] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', 
  reason:-[MyViewController someMethod]: unrecognized selector sent to instance 0x7fd81a50db20'
  ...(call stack)
libc++abi.dylib: terminating with uncaught exception of type NSException

将变成了友好的

CrashCrusher[65353:28112308] Unrecognized selector someMethod sent to <MyViewController: 0x7fd4ece0f390>, ***forwarding to Stub
CrashCrusher[65353:28112308] *** someMethodIMP prevent the crash. *** 

顺着这种思路, 可以封装一下API, 接受一个NSString类型的类名, 对相应的类进行unrecognized selector的crash防护. 这里仅作简单的Demo, 就不封装了.

Zombie

当遇到野指针访问不恰当内存时,系统发送SIGSEGV信号,出现EXC_BAD_ACCESS错误而崩溃。

Xcode在Debug模式下可开启NSZombieEnabled,当对象被释放时,runtime系统通过isa-swizzling把该对象替换成一个Zombie对象,当往该对象发送消息时,Zombie对象将输出一个message sent to deallocated instance的log,随后发送SIGKILL信号终止程序。Log(Message from debugger: Terminated due to signal 9)

可以看出在开启Zombie情况下,比起令人头大的EXC_BAD_ACCESS野指针崩溃,Zombie给开发者提供了更友好的“崩溃”方式,并且提供相关日志来追溯bug。

由于僵尸对象的存在导致内存的过度消耗的问题,苹果并不在Release模式下提供该功能。

这并不能阻止我们自己去实现一个Zombie啊~lol

下面利用runtime写一个自定义的zombie对象

首先,

isa-swizzling

什么是isa-swizzling? 先看下

typedef struct objc_object {
    Class isa;
} *id;

每个OC对象结构里的第一项, 就是一个名为isa的Class类型变量, Class为类对象结构体的指针类型.

typedef struct objc_class {
    Class isa                             ;
    Class super_class                     ;
    const char *name                      ;
    long version                          ;
    long info                             ;
    long instance_size                    ;
    struct objc_ivar_list *ivars          ;
    struct objc_method_list **methodLists ;
    struct objc_cache *cache              ;
    struct objc_protocol_list *protocols  ;
} *Class;

对象的isa指针指向它的类对象.

从代码的定义可以看出, Class类型也是id类型的一个特例. (认识到这点很重要, 不要理所当然得认为Class就只是类类型, id就只是对象类型)

Class类型强制转换为id类型将损失"精度"(或者说,可见度? 明白我意思就行😆).

id类型里, 仅对变量isa可见.

所谓isa-swizzling, 就是把一个对象的isa改为指向另外一个类!

可供操作的runtime方法是:

Class object_setClass(id obj, Class cls);

obj为被swizzled的对象, cls为新的isa值.

method swizzling

我们要在对象被回收时把它置换成另一个对象,想到了method swizzling掉NSObject的dealloc方法。

关于dealloc

当对象的引用计数降为0时, 系统向被释放的对象发送-dealloc消息.
dealloc方法做了三件事:

它的最终代码是这样的

static id _object_dispose(id anObject) 
{
    if (anObject==nil) return nil;

    objc_destructInstance(anObject);
    
    anObject->initIsa(_objc_getFreedObjectClass ()); 

    free(anObject);
    return nil;
}

关于dealloc更详细的分析可看大神的这篇文章.

我们的目的是把原对象isa-swizzle成一个Zombie对象, 这个Zombie仍保留于内存中, 以监测野指针. 所以用来swizzle的dealloc方法是这样的:

- (void)my_dealloc {
    //after method swizzling, the `self` here refers to the Object to be dealloc-ed but not the CCZombie instance itself
    
    //if the class of object-to-be-dealloced is not enabled to be a zombie, call the original dealloc
    CCZombie *zombie = [CCZombie sharedZombie];
    if (![zombie->_classesThatEnablesZombie containsObject:[self class]]) {
        return [self my_dealloc];
    }
    
    //release all instance variables and associated objects the object references
    objc_destructInstance(self);
    
    //store the isa's original name
    NSString *originClassName = NSStringFromClass([self class]);
    objc_setAssociatedObject(self, OrigClassNameKey, originClassName, OBJC_ASSOCIATION_COPY_NONATOMIC);
    
    //isa-swizzling
    Class zombieClass = objc_getClass("CCZombie");
    object_setClass(self, zombieClass);
    
    //TODO: set a more customized class name, like CCZombie_<OrigClassName>?
    
    //TODO: implement a cache mechanism for the zombies
    
    //no free() called here
}

思路:

可见, 除了存储类名以便后来的识别外, 这个自定义的dealloc方法与原来的dealloc不同之处则在于少了free()回收内存的一步. (毕竟只是想把被释放的对象变成僵尸嘛)

注意这里被isa-swizzled的dealloc是[NSObject dealloc], 因为任何的dealloc调用最终都会调用到根类(即NSObject)的dealloc.

回想在MRC情况下, 所有重写的dealloc最终都得写上[super dealloc]; 而ARC下, 编译器自动插入了这一步.

回到method swizzling来.
可定义一个开启zombie的方法:

- (void)enableZombie {
    if (!_isZombieEnabled) {
        //add the swizzled method to NSObject before swizzling, since CCZombie is not a category of NSObject
        Method myDeallocMethod = class_getInstanceMethod([self class], @selector(my_dealloc));
        BOOL result = class_addMethod([NSObject class], @selector(my_dealloc), method_getImplementation(myDeallocMethod), method_getTypeEncoding(myDeallocMethod));
        if (result) {
            //method swizzling in NSObject
            Method myDeallocMethod = class_getInstanceMethod([NSObject class], @selector(my_dealloc));
            Method origDeallocMethod = class_getInstanceMethod([NSObject class], @selector(dealloc));
            method_exchangeImplementations(origDeallocMethod, myDeallocMethod);
        }
    }
}

注意: 由于这里不是在method swizzling的常见场景Category中, 所以需要一开始先把my_dealloc方法加入到NSObject类里, 然后再进行swizzling. 否则, 被释放的对象将会由于找不到my_deallocSEL而报错.

再调用- enableZombie方法后开启Zombie机制后, 所有对象的dealloc方法都会最终跳到这个my_dealloc中来; 在my_dealloc中在判断对象是走原dealloc还是被置换后的dealloc; 被置换的dealloc最终不会调用free()释放内存; 由此实现Zombie.

CCZombie

CCZombie是一个自定义的僵尸类, 可设置一些开启僵死服务的接口:

@interface CCZombie : NSObject
+ (void)enableZombie;
+ (void)addClassToZombieService:(NSString *)className;
@end

static void* OrigClassNameKey = "OrigClassNameKey";
@implementation CCZombie {
    BOOL _isZombieEnabled;
    NSMutableArray<Class> *_classesThatEnablesZombie;
}

+ (instancetype)sharedZombie {
    static CCZombie* sharedInstance = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        sharedInstance = [[self alloc] init];
    });
    return sharedInstance;
}

- (instancetype)init {
    if (self = [super init]) {
        _isZombieEnabled = NO;
        _classesThatEnablesZombie = [[NSMutableArray alloc] init];
        return self;
    }
    return nil;
}

+ (void)enableZombie {
    [[self sharedZombie] enableZombie];
}

+ (void)addClassToZombieService:(NSString *)className {
    Class cls = objc_getClass([className UTF8String]);
    CCZombie *zombie = [self sharedZombie];
    [zombie->_classesThatEnablesZombie addObject:cls];
}
...

@end

刚才的- enableZombiemy_dealloc方法也定义在该类中.

这样对某个类开启zombie就变得很简便了, 例如:

[CCZombie enableZombie];
[CCZombie addClassToZombieService:@"Son"];
[CCZombie addClassToZombieService:@"UIView"];

这些代码可写在App启动时, 如AppDelegate的didFinishLaunching回调里.

向野指针发送消息示例

在VC里定义一个点击事件:

- (IBAction)onBtnTestWildPointer:(id)sender {
    Son *__strong strongSon = [[Son alloc] init];
    Son *__unsafe_unretained son = strongSon;
    NSLog(@"release %@", son);
    strongSon = nil;
    [son performSelector:@selector(isMarried)];
    [son performSelector:@selector(someMethodThatExist)];
    [son performSelector:@selector(someMethodThatDoesNotExist)];
    
    UIView *__unsafe_unretained view = [[UIView alloc] init];
    [view setNeedsDisplay];
}

strongSon = nil;后, Son对象被释放, 调用被swizzled的dealloc方法, son被isa-swizzle成僵尸对象. 同理View对象; 向其发送的所有消息, 都将发送CCZombie对象中.

因此, 在CCZombie类中又重写forwardingTargetForSelector方法, 截获该消息, 并转发给一个桩类:

- (id)forwardingTargetForSelector:(SEL)aSelector {
    NSLog(@"[%@ %@] message sent to deallocated instance %@", objc_getAssociatedObject(self, OrigClassNameKey), NSStringFromSelector(aSelector), self);
    StubProxy *stub = [[[StubProxy alloc] init] autorelease];
    if (![stub respondsToSelector:aSelector]) {
        Method method = class_getInstanceMethod([stub class], sel_registerName("someMethodUsedToPreventCrash"));
        class_addMethod([stub class], aSelector, method_getImplementation(method), method_getTypeEncoding(method));
    }
    return stub;
}

跟上面unrecognized selector处理是一样道理.

例如向一个被释放的UIView对象发送setNeedsDisplay方法, 由原来的EXC_BAD_ACCESSCrash变成了友好的提示:

CrashCrusher[67769:28599054] [UIView setNeedsDisplay] message sent to deallocated instance <CCZombie: 0x7fbce7c083a0>
CrashCrusher[67769:28599054] *** <StubProxy: 0x60800000ef40> prevent the crash. *** 

这就达到了野指针防护的目的.

kvo

另外一中特殊的野指针情况, KVO.

如果observer先于被观察对象释放了的时, 被观察对象对Observer的不安全弱引用变成了野指针. 是的,EXC_BAD_ACCESS如果被发送了KVO消息.
这种情况也可用到刚才的Zombie机制来防护.

[CCZombie addClassToZombieService:@"Observer"];

例如在VC里定义一个属性, 并KVO它

- (void)viewDidLoad {
    [super viewDidLoad];
    //...
    Observer *observer = [[Observer alloc] init];
    self.someProperty = @"orignal value";
    [self addObserver:observer forKeyPath:@"someProperty" options:NSKeyValueObservingOptionNew|NSKeyValueObservingOptionOld context:nil];
}

-viewDidLoad方法结束后, observer对象被释放, 变成CCZombie对象,
这时如果发生一个点击事件触发了KVO

- (IBAction)triggerKVO:(id)sender {
    self.someProperty = @"new value";
}

这时, 一个kvo消息observeValueForKeyPath:ofObject:change:context:将发送至CCZombie对象. 然后

CrashCrusher[68012:28642070] name:NSInternalInconsistencyException, 
reson:<CCZombie: 0x60000001d6d0>: An -observeValueForKeyPath:ofObject:change:context: message was received but not handled.

居然 SIGABRT Crash掉了!

这是为何, 为何该消息不像[UIView setNeedsDisplay]之类的消息一样被Zombie转发并处理掉了呢?

原因很简单:

因为这里定义的CCZombie类是继承于NSObject类的. 它自然也是拥有了observeValueForKeyPath:ofObject:change:context:等NSObject的方法. 所以不会进入到消息转发流程.

所以, 只需要在Zombie里重写该方法就搞定了:

// CCZombie.m

- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context {
    NSLog(@"KVO message sent to deallocated instance %@(%@)", objc_getAssociatedObject(self, OrigClassNameKey), self);
    NSLog(@"Observe keypath [%@] change in %@, old:%@, new:%@", keyPath, object, change[NSKeyValueChangeOldKey], change[NSKeyValueChangeNewKey]);
}

这样, 当向已被释放的观察者发送KVO时, 也会给出相当的"友情提示"了.

CrashCrusher[68119:28657439] KVO message sent to deallocated instance Observer(<CCZombie: 0x600000013260>)
CrashCrusher[68119:28657439] Observe keypath [someProperty] change in <ViewController: 0x7ff5fec0e250>, old:orignal value, new:new value

Demo

待上传

写在最后

这篇主要从runtime的角度探究Crash防护的问题, 顺便研究和学习了一些常见的runtime实践. 还研究了一下僵尸对象的问题.

随着zombie的增长必定消耗越来越多的内存, 这里没有说到关于zombie缓存的问题, 这个问题回头有空研究研究, 再封装一下这个Zombie. 待更.

除了野指针之外, Crash还有很多其它原因.

参考:

ARC下dealloc过程及.cxx_destruct的探究
大白健康系统--iOS APP运行时Crash自动修复系统
Clang 5 documentation OBJECTIVE-C AUTOMATIC REFERENCE COUNTING (ARC)

上一篇下一篇

猜你喜欢

热点阅读