美文网首页DevSupportios runtime专题
Objective-C Runtime(二): 实践 监测与防护

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

作者: 4d1487047cf6 | 来源:发表于2017-05-01 17:56 被阅读659次

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

    Unrecognized Selector

    消息转发截获

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

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

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

    • resolveInstanceMethod 需要在类的本身上动态添加它本身不存在的方法,这些方法对于该类本身来说冗余的
    • forwardInvocation可以通过NSInvocation的形式将消息转发给多个对象,但是其开销较大,需要创建新的NSInvocation对象,并且- forwardInvocation的函数经常被使用者调用,来做多层消息转发选择机制,不适合多次重写
    • 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方法做了三件事:

      1. 调用objc_destructInstance()释放对象的所有实例变量和关联对象(该方法并未回收对象本身内存).
      1. isa-swizzling将该对象的类置为一个空的类对象.
      1. 调用free()回收该对象的内存.

    它的最终代码是这样的

    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
    }
    

    思路:

    • 一开始先判断对象是否加入了Zombie防护机制, 如果未加入, 则调用原始的dealloc方法. 如果是, 下一步;
    • 调用objc_destructInstance析构对象;
    • 使用associated object函数把原类名存储于对象中;
    • isa-swizzling把对象类设置为Zombie

    可见, 除了存储类名以便后来的识别外, 这个自定义的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)

    相关文章

      网友评论

      • Null先森的内存地址:大神。你把所有开启保护的对象,最后dealloc的时候isa-swizzle到一个zombie的类型的实例。遮掩只是防止它出现野指针。但是改对象的内存并不会真正的被释放?
        Null先森的内存地址:@shuiyouren 我觉得最后可以将那些僵尸对象放进一个缓存池里去,具体缓存池的大小看情况设定,当缓存超过一个阈值的时候或是收到内存警告的时候,才去释放掉一些缓存。就像是对一些dealloc的对象做延迟释放吧。还会存在一个问题,就是从缓存中释放那些僵尸对象之后,这段时间内还是有可能会发生野指针的。只是概率会小很多。
        _Artillery:大佬 什么时候出续集呢
        shuiyouren:对象本身并没有释放,需要进行缓存;

      本文标题:Objective-C Runtime(二): 实践 监测与防护

      本文链接:https://www.haomeiwen.com/subject/zmzozttx.html