Objective-C Runtime(二): 实践 监测与防护
上篇文章 介绍了一些runtime的基础知识, 这次分享一些runtime的各种黑科技玩法: 消息转发截获, isa-swizzling, method swizzling, associated object等等. 顺便研究了野指针的问题, 以及如何写一个僵尸对象(Zombie).
Unrecognized Selector
消息转发截获
这个简单了, 首先来张图:
objc_runtime_msgSend.jpeg当向对象发送消息, 沿着类的继承链找不到响应的方法时, runtime的消息转发机制会依次调用这几个方法. 这里选择第二个
forwardingTargetForSelector
来操作. 该方法返回一个对象, 该对象为消息新的接受者.
这里我们选择了第二步
forwardingTargetForSelector
来做文章。原因如下:
-
resolveInstanceMethod
需要在类的本身上动态添加它本身不存在的方法,这些方法对于该类本身来说冗余的 -
forwardInvocation
可以通过NSInvocation的形式将消息转发给多个对象,但是其开销较大,需要创建新的NSInvocation对象,并且- forwardInvocation
的函数经常被使用者调用,来做多层消息转发选择机制,不适合多次重写 -
forwardingTargetForSelecto
r可以将消息转发给一个对象,开销较小,并且被重写的概率较低,适合重写
好了. 代码:
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_getInstanceMethod
和method_getImplementation
转化为IMP
供class_addMethod
使用. 一样道理~
StubProxy
是一个桩类, 可认为它仅是一个空的模板, 也可以不在代码中定义类, 直接使用runtime的objc_allocateClassPair
和objc_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
方法做了三件事:
- 调用
objc_destructInstance()
释放对象的所有实例变量和关联对象(该方法并未回收对象本身内存).
- 调用
- isa-swizzling将该对象的类置为一个空的类对象.
- 调用
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_dealloc
的SEL而报错.
再调用- 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
刚才的- enableZombie
和my_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_ACCESS
Crash变成了友好的提示:
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)