前言
JSPatch
作为一个轻量级的热修复方案曾经解决了很多App的热修复痛点,甚至一度风靡,大有席卷热修复领域之势,然而因为触犯了苹果爸爸的龙颜而被封杀,导致很多企业投鼠忌器,不敢再冒天下之大不韪去明修栈道暗度陈仓。虽说JSPatch
的结局令人惋惜,但是作为一个iOS开发工程师,里面仍然有很多精髓思想值得我们学习。今天主要是想分析一下JSPatch
的dealloc的问题。
何为dealloc忧伤?
相信曾经接入过JSPatch
的同学都有这样的需求:如果某一个已发行的线上版本中发现dealloc中有些资源未进行释放处理,又或者释放处理有问题,那么就需要重写dealloc方法,这种需求本来无可厚非,然而当时的JSPatch
在这个问题上处理的有些力不从心:
- 1、JS替换dealloc方法后,调用到dealloc时会把self 包装成weakObject传给JS,会出现crash;
- 2、如果不包装当前对象,则可以成功执行到JS上替换的dealloc方法,但是没有调用原生dealloc方法,此对象不释放,造成了内存泄漏
后来,作者 Bang 想出了一个解决方案:
- 1、调用dealloc时self不包装成weakObject,而是包装成assignObject传给JS
1 | if ([slf class] == slf) { |
- 2、调用
ORIGdealloc
时因为selectorName改变,ARC不认这是dealloc方法,所以需要欺骗ARC
1 | if (deallocFlag) { |
当时看到作者的解决手法着实令人拍案叫绝!美中不足的是,这个方案仍然未能彻底解决JSPatch
的dealloc问题,为什么呢?因为这个解决方案有一个遗留问题:JS的dealloc方法和OC里面的dealloc方法会同时被调用:1
2
3
4
5@implementation JPViewController
- (void)dealloc {
NSLog(@"dealloc from OC");
}
@end
1 | defineClass('JPViewController', { |
会输出:1
2dealloc from JS
dealloc from OC
试想:倘若OC的dealloc方法有错误,我只想通过通过JSPatch热修复之后只执行JS的dealloc方法,并不想执行OC的dealloc,同时又能正确地释放对象内存。而现在JSPatch的热修复之后,OC的dealloc还得执行,那原来dealloc里面的错误岂不是还得再犯?所以谓之曰dealloc之忧伤
dealloc忧伤的分析和解决
那么我们来分析一下为什么JSPatch的dealloc的问题如此棘手。
weak包装为什么会crash?
作者一开始的做法:调用到dealloc时会把self包装成weakObject传给JS,其目的主要是为了避免JS对self强引用,从而导致dealloc无法被调用。然而,该方案会crash
1 | objc[54707]: Cannot form weak reference to instance (0x7f9d46f073d0) of class JPViewController. It is possible that this object was over-released, or is in the process of deallocation. |
看下堆栈,挂到了weak_register_no_lock
函数里面1
2
3
4
5
6
7
8
9
10
11
12
13* thread #1, queue = 'com.apple.main-thread', stop reason = signal SIGABRT
frame #0: 0x000000010d1fc1a6 libsystem_kernel.dylib`__abort_with_payload + 10
frame #1: 0x000000010d1f686e libsystem_kernel.dylib`abort_with_payload_wrapper_internal + 89
frame #2: 0x000000010d1f6815 libsystem_kernel.dylib`abort_with_reason + 22
frame #3: 0x000000010945f923 libobjc.A.dylib`_objc_fatalv(unsigned long long, unsigned long long, char const*, __va_list_tag*) + 108
frame #4: 0x000000010945f84c libobjc.A.dylib`_objc_fatal(char const*, ...) + 127
frame #5: 0x00000001094738fd libobjc.A.dylib`weak_register_no_lock + 291
frame #6: 0x0000000109473eca libobjc.A.dylib`objc_storeWeak + 480
frame #7: 0x0000000107fceb7e JSPatchDemo`-[JPBoxing setWeakObj:](self=0x0000600000053770, _cmd="setWeakObj:", weakObj=0x00007fa258004b60) at JPEngine.h:0
* frame #8: 0x0000000107fce73f JSPatchDemo`+[JPBoxing boxWeakObj:](self=JPBoxing, _cmd="boxWeakObj:", obj=0x00007fa258004b60) at JPEngine.m:32
frame #9: 0x0000000107fdd1c4 JSPatchDemo`JPForwardInvocation(assignSlf=0x00007fa258004b60, selector="forwardInvocation:", invocation=0x0000600000076380) at JPEngine.m:657
frame #10: 0x0000000109d4ce08 CoreFoundation`___forwarding___ + 760
frame #11: 0x0000000109d4ca88 CoreFoundation`__forwarding_prep_0___ + 120
跟踪下runtime源码的weak_register_no_lock
函数,从中发现了如下端倪:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22// 判断被引用对象是否允许弱引用
BOOL (*allowsWeakReference)(objc_object *, SEL) =
(BOOL(*)(objc_object *, SEL))
object_getMethodImplementation((id)referent,
SEL_allowsWeakReference);
if ((IMP)allowsWeakReference == _objc_msgForward) {
return nil;
}
// 从对象是否允许弱引用判断是否该对象正在释放
deallocating = ! (*allowsWeakReference)(referent, SEL_allowsWeakReference);
...
if (deallocating) {
if (crashIfDeallocating) {
_objc_fatal("Cannot form weak reference to instance (%p) of "
"class %s. It is possible that this object was "
"over-released, or is in the process of deallocation.",
(void*)referent, object_getClassName((id)referent));
} else {
return nil;
}
}
可以看到,weak_register_no_lock
函数先检查了该对象是否正在释放中,如果正在释放中,直接通过_objc_fatal
abort掉程序,从而crash。冤有头,债有主,我们得到的crash信息正是这里的_objc_fatal引起的。从中可以看出,当一个OC对象的dealloc函数被触发调用的时候,是不允许对其进行弱引用的。
简单验证一下,在dealloc函数中弱引用自己:1
2
3
4-(void)dealloc{
__weak __typeof(self) weakSelf = self;
NSLog(@"%@",weakSelf);
}
同样会引发crash:1
objc[56044]: Cannot form weak reference to instance (0x7f8fc750aad0) of class JPViewController. It is possible that this object was over-released, or is in the process of deallocation.
从道理上这也可以说的通,弱引用一个即将被释放的对象除了白白耗费资源和引入潜在的危险之外,还有什么意义呢?
若使用assignObject,为什么不会crash?如何能避免调用OC的dealloc?
首先assign只是一个指针指向,它本身不参与OC的内存管理,所以不会发生前面的crash,但是不适当的访问也很容易造成野指针crash,所以需要格外小心。
如何能避免调用OC的dealloc?在解决这个问题之前,不妨先了解下OC对象的dealloc释放过程。我们知道,OC的ARC代码中不允许调用[super dealloc]
,编译器会在编译的时候,自动帮我们的代码插入[super dealloc]
我们写一个类叫XXObject,来跟踪下dealloc的调用过程1
2
3
4
5@implementation XXObject
-(void)dealloc{
NSLog(@"XXObject dealloc");
}
@end
1 | - (void)dealloc { |
1 | void _objc_rootDealloc(id obj) |
1 | inline void objc_object::rootDealloc() |
1 | id object_dispose(id obj) |
1 | void *objc_destructInstance(id obj) |
不难发现,OC对象的dealloc函数只是处理我们写的释放逻辑,而真正的对象清理工作是由runtime来完成的,在objc_destructInstance
中可以看到:
- 如果类存在c++给析构函数,就执行C++析构函数;
- 移除和释放通过runtime关联到对象上的关联对象;
- 清除对象的SideTable的weak_table,即弱引用表
既然这里OC的dealloc逻辑已经被JS的dealloc逻辑所覆盖,那么OC的dealloc逻辑就应该被扔掉,之前因为我们不太了解OC的dealloc内幕,总觉得编译器在里面做了很多不为人知的神奇工作,所以束手无策,但是我们现在已经了解到,对象的清理核心在objc_destructInstance
里面,即使OC的dealloc不会被调用,也不会绝对影响到OC对象的清理,我们可以采取某种手段绕过去这个OC的dealloc这个障碍:调用完JS的dealloc方法后,跳过OC对象的dealloc方法,直接调用父类的dealloc方法。虽然在ARC中,受制于编译器,无法写[super dealloc]
这样的代码,但是我们还有runtime
打开文件JSPatch.mm
,修改JPForwardInvocation
函数的最后几行:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18/// 修改前
if (deallocFlag) {
slf = nil;
Class instClass = object_getClass(assignSlf);
Method deallocMethod = class_getInstanceMethod(instClass, NSSelectorFromString(@"ORIGdealloc"));
void (*originalDealloc)(__unsafe_unretained id, SEL) = (__typeof__(originalDealloc))method_getImplementation(deallocMethod);
originalDealloc(assignSlf, NSSelectorFromString(@"dealloc"));
}
/// 修改后
if (deallocFlag) {
slf = nil;
Class instClass = object_getClass(assignSlf);
Class instSuperClass = [instClass superclass];
Method deallocMethod = class_getInstanceMethod(instSuperClass, NSSelectorFromString(@"dealloc"));
void (*originalDealloc)(__unsafe_unretained id, SEL) = (__typeof__(originalDealloc))method_getImplementation(deallocMethod);
originalDealloc(assignSlf, NSSelectorFromString(@"dealloc"));
}
修改完毕后,用以下代码验证一下结果:1
2
3
4// OC
-(void)dealloc{
NSLog(@"oc dealloc");
}
1 | // JS |
1、先看下输出,确实OC的dealloc函数没有再调用
1 JSPatchDemo[60463:20377984] JSPatch.log: js dealloc2、再用Instrument检查下是否有内存泄露,可以看到该对象是得到正确释放的
如果同时hook父类和子类的dealloc方法,会发生什么呢?
1 | @implementation JPBaseViewController |
1 | defineClass('JPBaseViewController', { |
结果还是发生了crash,原因是堆栈溢出…
1 | JSPatchDemo[91945:50530228] JSPatch.log: JPViewController JS dealloc |
如上图,一直不停地执行JPViewController JS dealloc
按照上个步骤修改完的代码来分析一下原因:1
2
3
4
5
6
7
8if (deallocFlag) {
slf = nil;
Class instClass = object_getClass(assignSlf);
Class instSuperClass = [instClass superclass];
Method deallocMethod = class_getInstanceMethod(instSuperClass, NSSelectorFromString(@"dealloc"));
void (*originalDealloc)(__unsafe_unretained id, SEL) = (__typeof__(originalDealloc))method_getImplementation(deallocMethod);
originalDealloc(assignSlf, NSSelectorFromString(@"dealloc"));
}
当子类JPViewController
的dealloc被调用的时候,我们是找到了父类的dealloc的IMP,以originalDealloc(assignSlf, NSSelectorFromString(@"dealloc"));
的方式去调用父类的dealloc方法。
由于父类JPBaseViewController
的dealloc方法也被hook掉了,所以最后又会转发到上述的代码里,然而代码Class instClass = object_getClass(assignSlf);
执行完后,Class仍然是JPViewController
,其父类仍然是JPBaseViewController
,所以构成了一个死循环,导致最后堆栈溢出…
解决方案首先想到在这里用一个while循环,直接暴力处理dealloc的继承链,但是如果中间有一个dealloc方法被hook成JS代码块,那么就还得处理JS代码块的执行,显然这个处理方式工作量很巨大,而且考虑因素很多。
如果能保存dealloc的执行层级,那么就可以避免死循环了,但是看函数的参数也不大可能具备这个条件:static void JPForwardInvocation(__unsafe_unretained id assignSlf, SEL selector, NSInvocation *invocation)
。
最后就只能依托于assignSlf
了,如果能在这个对象本身上面做点文章就好了
绞尽脑汁,想到了加1行代码:1
2
3
4
5
6
7
8
9
10
11
12if (deallocFlag) {
slf = nil;
Class instClass = object_getClass(assignSlf);
Class instSuperClass = [instClass superclass];
// 每次获取到父类时,就利用runtime把assignSlf的类设置为instSuperClass
object_setClass(assignSlf, instSuperClass);
Method deallocMethod = class_getInstanceMethod(instSuperClass, NSSelectorFromString(@"dealloc"));
void (*originalDealloc)(__unsafe_unretained id, SEL) = (__typeof__(originalDealloc))method_getImplementation(deallocMethod);
originalDealloc(assignSlf, NSSelectorFromString(@"dealloc"));
}
如上面的代码块所示:每次获取到父类时,就利用runtime把assignSlf的类设置为其父类instSuperClass,这样就打破了死循环的条件。
验证下结果:1
2JSPatchDemo[93127:50640251] JSPatch.log: JPViewController JS dealloc
JSPatchDemo[93127:50640251] JSPatch.log: JPBaseViewController JS dealloc
确实都被调用到了。
可能会有人怀疑说,这样修改对象的类,会不会存在crash的风险呢?普通情况下,风险肯定有,比如以下代码会直接crash:
1 | - (void)crashTest{ |
1 | 0 CoreFoundation 0x0000000109b901ab __exceptionPreprocess + 171 |
在上述代码中,test方法是JPViewController
特有的,其父类并没有这个方法,所以会crash
但是经过验证,JSPatch并没有crash,可以正常调用:1
2
3
4
5
6
7
8
9@implementation JPViewController
- (void)dealloc{
NSLog(@"JPViewController OC dealloc");
}
-(void)test{
NSLog(@"JPViewController test");
}
@end
1 | dealloc:function(){ |
输出:1
2
3JSPatchDemo[94941:50784057] JSPatch.log: JPViewController JS dealloc
JSPatchDemo[94941:50784057] JPViewController test
JSPatchDemo[94941:50784057] JSPatch.log: JPBaseViewController JS dealloc
这是为什么呢?主要有两点原因:
1、OC本身的dealloc方法已经被Hook成JS代码块,所以OC的dealloc方法基本上不可能被执行到(切记不要在JS的dealloc代码块中调用self.ORIGdealloc()方法,否则会画蛇添足,反而crash)
2、JS的dealloc代码块在执行的时候,
if (deallocFlag)
这部分代码还未执行到,所以JS的dealloc
里面的self肯定是JPViewController
,当if (deallocFlag)
执行的时候,已经是一心一意利用runtime做最后的资源释放,不可能再去调用自己定义的方法了,所以肯定不会crash,所以这个地方的object_setClass
可以放心使用。
总结
可以看到,最后的解决方案非常简单,只是一个简单的“跳过”动作,外加一个修改自己的“所属类”动作。但是在很长一段时间内,我却对该问题束手无策。直到后来详细拜读了runtime源码,才突然醍醐灌顶,恍然大悟!
虽然JSPatch因为苹果爸爸的政策问题已不再被我们所关注,但是这其中包含的精髓仍然值得我们花时间去学习。殊不知,与JSPatch原理很接近的其它库,例如Aspects,难道不存在这样的问题吗?