当前位置: 首页 > 科技观察

JSPatch实现原理详解(二)

时间:2023-03-14 14:58:40 科技观察

本文针对JSPatch在近期的不断完善和完善,对JSPatch实现原理详解(一)进行补充。SpecialStruct先说_objc_msgForward。上一篇文章提到为了让替换方法使用forwardInvocation,将其指向一个不存在的IMP:class_getMethodImplementation(cls,@selector(__JPNONImplementSelector)),其实这个实现是多余的,如果class_getMethodImplementation不能找到class/selector对应的IMP,会返回IMP_objc_msgForward,所以更直接的方式是把所有要替换的方法都指向_objc_msgForward,省去找方法的时间。那么还有一个问题,如果替换方法的返回值是一些struct,使用_objc_msgForward(或者之前的@selector(__JPNONImplementSelector))会崩溃。几经周折,我找到了解决办法:对于某些结构和某些结构,必须使用_objc_msgForward_stret而不是_objc_msgForward。为什么要使用_objc_msgForward_stret?我发现一篇文章解释了objc_msgSend_stret和objc_msgSend之间的区别)。很明显,原理是一样的。这是C的一些底层机制的原因,我简单回顾一下:当大多数CPU执行C函数时,前几个参数会被放入寄存器。对于obj_msgSend,前两个参数固定为self/_cmd,它们会放在寄存器中,执行完***后返回值也会保存在寄存器中。取这个寄存器的值就是返回值:-(int)method:(id)arg;r3=selfr4=_cmd,@selector(method:)r5=arg(onexit)r3=returnedint普通返回值(int/pointer)很小,放在寄存器里没问题,但是有些struct太大,寄存器放不下,所以还有一种方法就是一开始就申请一块内存,把指针保存在寄存器里,并将值返回到指针指向的内存中写入数据,所以寄存器需要为这个指针腾出空间,self/_cmd在寄存器中的位置会发生变化:-(structst)method:(id)arg;r3=&struct_var(incaller'sstackframe)r4=selfr5=_cmd,@selector(method:)r6=arg(onexit)returnvaluewrittenintostruct_varobjc_msgSend不知道self/_cmd的位置变了,所以应该改用另一个方法objc_msgSend_stret。原理大概是这样的。都说某些架构、某些结构有问题,那到底是什么问题呢?iOS架构下non-arm64有这个问题,但是对于什么样的struct需要经过上面的流程,使用xxx_stret代替原来的方法,并没有明确的规定,OC也没有提供接口,只揭秘这个在一个奇妙的界面上,于是就有了这么神奇的判断:if([methodSignature.debugDescriptionrangeOfString:@"isspecialstructreturn?YES"].location!=NSNotFound)NSMethodSignature的debugDescription上是否显示特殊struct只能通过这个字符串来判断.所以最后的处理是,在non-arm64下,如果是特殊的struct,就去_objc_msgForward_stret,否则就去_objc_msgForward。内存泄漏先说下上篇文章遗留下来的一个问题。获取NSInvocation的返回值时,为什么在取参数的时候会崩溃idarg;[invocationgetReturnValue:&arg];这是因为&arg传入-getArgument:atIndex:方法后,arg指向返回的对象,但是不持有,不会对返回的对象引用+1,但是因为idarg相当于ARC下的__strongidarg,而arg是强类型的局部变量,所以退出函数release时会调用一次domain关闭了,在没有hold住对象的情况下调用release,导致对象再次被release,从而crash。只要把arg类型改成__unsafe_unretained,表示只指向对象不持有,退出作用域时不释放就好了:__unsafe_unretainedidarg;[invocationgetReturnValue:&arg];也可以使用__bridge转换让局部变量持久化有一个返回对象,所以这样做是没有问题的:idreturnValue;void*result;[invocationgetReturnValue:&result];returnValue=(__bridgeid)result;doublerelease问题是解决了,但是这里还有一个大坑:内存泄漏。有一天,有人在githubissue上提到对象生成后没有释放。几经排查,发现还是NSInvocationgetReturnValue的问题出在这里。当NSInvocation调用alloc时,返回的对象不会被释放,造成内存泄漏。只有返回的对象才交出内存管理权,外部对象可以为其释放:idreturnValue;void*result;[invocationgetReturnValue:&result];if([selectorNameisEqualToString:@"alloc"]||[selectorNameisEqualToString:@"new"]){returnValue=(__bridge_transferid)result;}else{returnValue=(__bridge_id)result;}我不明白为什么,从开源的Cocotron中NSInvocation的实现来看,NSInvocation并没有持有返回的对象,而且调用方式也是直接使用msgSend也没有什么特别之处,让人怀疑这是ARC的bug。'_'JSPatch的处理使用下划线'_'连接OC方法的多个参数之间的区间:-(void)setObject:(id)anObjectforKey:(id)aKey;<==>setObject_forKey()那么如果OC方法名中包含'_',则有歧义:-(void)set_object:(id)anObjectforKey:(id)aKey;<==>set_object_forKey()无法知道set_object_forKey对应的selector是否为set_object:forKey:或设置:对象:forKey:。需要为此设置一个规则,在JS中用其他字符代替OC方法名中的_。JS的命名规则除了字母和数字,只有$和_,貌似用$代替了,但是效果很难看:-(void)set_object:(id)anObjectforKey:(id)aKey;-(void)_privateMethod();<==>set$object_forKey()$privateMethod()所以尝试另一种方法,使用两个下划线__代替:set__object_forKey()__privateMethod()但是用两个下划线代替有问题,OC方法名参数添加一个末尾的下划线不会匹配-(void)setObject_:(id)anObjectforKey:(id)aKey;<==>setObject___forKey()其实setObject___forKey()匹配的对应选择器是setObject:_forKey:。虽然有这个坑,但是因为很少看到这么奇葩的命名方式,所以感觉问题不大。使用$也会导致OC方法名称包含$字符。代码值最后用双下划线.__表示。#p#JPBoxing在使用JSPatch的过程中发现,JS无法调用NSMutableArray/NSMutableDictionary/NSMutableString的方法来修改这些对象的数据,因为JavaScriptCore在从OC返回到JS/String的时候,将它们转换成了JS的Array/Object,当它被返回,它将脱离与原始对象的连接。这种转换在JavaScriptCore中是强制性的,不能选择。对象返回JS后返回OC后如果想调用此对象的方法,必须阻止JavaScriptCore的转换。唯一的办法就是不直接返回这个对象,而是封装这个对象。JPBoxing执行以下操作:@interfaceJPBoxing:NSObject@property(nonatomic)idobj;@end@implementationJPBoxing+(instancetype)boxObj:(id)obj{JPBoxing*boxing=[[JPBoxingalloc]init];boxing.obj=obj;returnboxing;}把NSMutableArray/NSMutableDictionary/NSMutableString对象作为JPBoxing的成员保存在JPBoxing实例对象上,返回给JS。JS拿到JPBoxing对象的指针,返回给OC时,通过对象成员就可以得到原来的NSMutableArray/NSMutableDictionary/NSMutableString对象。类似于装箱/拆箱操作,这可以防止这些对象被JavaScriptCore转换。其实只有变量NSMutableArray/NSMutableDictionary/NSMutableString这三个类需要调用它们的方法来修改对象中的数据。对于不可变的NSArray/NSDictionary/NSString就不用这么干了,直接将它们转换成对应的JS类型使用会更方便,但是JSPatch为了规则简单,允许NSArray/NSDictionary/NSString是以封装的方式返回,避免调用OC方法返回对象时需要关心返回的是可变对象还是不可变对象。***整个规则非常清楚:NSArray/NSDictionary/NSString及其子类的行为与其他NSObject对象相同。你在JS上得到的只是它的对象指针,你可以调用它们的OC方法。如果你想使用这三个来将一个对象转换成对应的JS类型,使用额外的.toJS()接口来转换。JPBoxing也封装了对C指针和Class类型的参数和返回值的支持。指针和类作为成员存储在JPBoxing对象上并返回给JS,使得JSPatch支持OC<->JS所有数据类型的互传。nil的处理区分NSNull/nil为“空”,JS有null/undefined,OC有nil/NSNull,JavaScriptCore是这样处理这些参数传递的:从JS到OC,直接传递null/undefined给OC,传递的就是nil,如果一个包含null/undefined的Array传给OC,它会被转换为NSNull。从OC到JS,nil会转为null,NSNull像普通的NSObject一样返回一个指针。在JSPatch的过程中,参数是通过数组从JS传给OC的,这样所有传给OC的null/undefined都会变成NSNull,而真正的NSNull对象也是NSNull,与JS无法区分。问题是,需要有某种方法来区分两者。我考虑过在JS中用一个特殊的对象来表示nil,而null/undefined只是用来表示NSNull。后来觉得NSNull是一个很少手动传递的变量,而null/undefined和OCnil很常见。带来极大的不便。所以反过来,在JS中用一个特殊的变量nsnull来表示NSNull,其他的null/undefined来表示nil,这样传入OC就可以区分nil和NSNull。具体使用方法:@implementationJPObject+(void)testNil:(id)obj{NSLog(@"%@",obj);}@endrequire("JPObject").testNil(null)//output:nilrequire("JPObject").testNil(nsnull)//output:NSNull这种方式有一个小坑,就是说明NSNull.null()作为参数调用时,到达OC后会变成nil:require("JPObject").testNil(require("NSNull").null())//output:nil只需要注意这个就用nsnull代替,OC返回的NSNull在传回来的时候还是可以识别的。链式调用的第二个问题是nil在JS中用null/undefined表示。导致无法用nil调用方法,链式调用的安全性无法保证:@implementationJPObject+(void)returnNil{returnnil;}@end[[JPObjectreturnNil]hash]//it'sOKrequire("JPObject")。returnNil().hash()//crash的原因是null/undefined在JS中不是对象,不能调用任何方法,包括我们给所有对象添加的__c()方法。解决方案曾经觉得解决这个问题的唯一方法就是回到上面,用一个特殊的对象来表示nil。但是用一个特殊的对象来表示nil,结果就是js判断是否为nil的时候会很啰嗦://假设用一个_nil对象变量来表示OC()返回的nil//没有特殊处理后的问题if(!obj||obj==_nil){//判断对象是否为nil,还要额外判断是否等于_nil}这种用法不能接受,继续寻找解决方案,发现true/false在JS中是一个对象,可以调用方法。如果用false表示nil,可以调用方法,可以直接通过if(!obj)判断是否为nil,所以沿着这个方向,解决使用False的问题意味着nil带来的各种坑差不多彻底解决这个问题。具体实现细节我就不多说了。说“差不多***”是因为还有一个小坑,给OC上参数类型为NSNumber*的方法传false,OC会得到nil而不是NSNumber对象:@implementationJPObject+(void)passNSNumber:(NSNumber*)num{NSLog(@"%@",num);}@endrequire("JPObject").passNSNumber(false)//输出:如果OC方法的参数类型为BOOL,则为nil,否则为true传进来的/0就可以了,这个小坑无伤大雅。顺便说一句,神奇JS中false的this不再是原来的false,而是另一个布尔对象,太特殊了:Object.prototype.c=function(){console.log(this===false)};false.c()//outputfalse#p#新添加的方法将OC中未定义的方法添加到JS中的类中。实现上有两个变化:1.原来的方法流程是将新添加的方法指向一个静态的IMP,脱离了方法替换的流程。这样做的好处是调用new方法时不需要经过forwardInvocation的过程,提高了性能。但缺点是它不能遵循与方法替换相同的过程,需要额外的代码。二是参数。数量有限。由于不能通过va_list的可变参数来定义(详见上一篇文章),所以需要为每个参数号单独定义一个方法。原实现中定义了5个方法,所以add方法最多只能支持5个参数。权衡一下,为了去掉参数个数的限制,最好把forwardInvocation的过程改成和replacement方法一样。2.ProtocolJSPatch现在支持Protocol,所以在添加Protocol中定义的方法时,参数类型会按照Protocol中的定义来实现,Protocol的定义方式和OC上的写法一致:defineClass("JPViewController:UIViewController",{alertView_clickedButtonAtIndex:function(alertView,buttonIndex){console.log('clickedindex'+buttonIndex)}})实现起来比较简单,先解析出Protocol名称,当JS定义的方法在原来当有类找不到时,通过objc_getProtocol和protocol_copyMethodDescriptionListruntime接口获取Protocol对应的方法。如果匹配,则根据方法的定义进行方法替换的过程。扩展目前的JSPatch还有两个问题:JS不能动态调用C函数,只能在代码中手动将每一个要调用的C函数封装成一个JS函数,就像几个dispatch函数的实现一样。struct类型只支持四种原生的NSRange/CGRect/CGSize/CGPoint,其他struct类型不能在OC/JS之间传递。这两个问题不能一下子解决。C函数需要一个一个添加,structs需要手动一个一个转换成NSDictionary。将这些直接写在JSPatch中是不合适的,所以需要以扩展的形式来支持这些额外的需求。在扩展接口的设计上,我想象的效果是:接口清晰,各个扩展独立存在,互不影响,不影响JPEngine的正常使用,尽量少暴露接口JP引擎。在添加动态加载的基础上,扩展可能会为JS全局变量添加很多接口,最终的扩展接口只有在实际使用的时候才能加载:@protocolJPExtensionProtocol@optional-(void)main:(JSContext*)context;-(size_t)sizeOfStructWithTypeEncoding:(NSString*)typeEncoding;-(NSDictionary*)dictOfStruct:(void*)structDatatypeEncoding:(NSString*)typeEncoding;-(void)structData:(void*)structDataofDict:(NSDictionary*)dicttypeEncoding:(NSString*)typeEncoding;@end@interfaceJPExtension:NSObject+(instancetype)实例;-(void*)formatPointerJSToOC:(JSValue*)val;-(id)formatPointerOCToJS:(void*)pointer;-(id)formatJSToOC:(JSValue*)val;-(id)formatOCToJS:(id)obj;@end@interfaceJPEngine:NSObject+(void)addExtensions:(NSArray*)extensions;...@end所有扩展都需要继承JPExtension,-main:方法将在加载扩展时执行。你可以在-main:方法中为当前的JSContext添加一个JS方法。要支持自定义的struct类型,需要实现JPExtensionProtocol的三个struct相关的方法(参考Robert的实现),JSPatch会根据ty转换参数peEncoding获得从扩展到扩展的struct<->NSDictionary转换。所有扩展都必须继承自JPExtension。基类提供了几个OC<->JS参数处理的方法,在添加JS方法传递参数时使用。JPEngine增加接口+addExtensions:用于加载扩展,其他接口不变。效果上基本实现了我的设想:接口还算清晰,struct接口麻烦一点,不过看了例子应该很容易理解。每个扩展都是独立的,你可以添加你想要支持的struct类型,添加JS方法,互不影响。JPEngine接口没有变化,参数处理和JPBoxing也没有暴露。它们都封装在JPExtension方法中。以后如果有其他的扩展需求,可以直接在JPExtension中添加。以接口+addExtensions:的形式添加扩展,不仅可以在OC上使用,在JS上也可以动态加载:require("JPEngine").addExtensions(require("JPCGTransform").instance())