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

上篇文章 介绍了一些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)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267

推荐阅读更多精彩内容