JSPatch用法剖析

目录

  1. JSPatch介绍
  2. JSPatch VS Wax lua
  3. JSPatch的原理和核心
  4. JSPatch Extension机制
  5. JSPatch中的实现技巧总结
  6. 遇到的问题与解决方法

一、JSPatch介绍

用途

iOS产品开发之中常常会遇到这种情况: 新版本上线后发现有个严重的bug,可能会导致crash率激增,可能会使网络请求无法发出,这时能做的只是赶紧修复bug然后提交等待漫长的AppStore审核,再盼望用户快点升级,付出巨大的人力和时间成本,才能完成此次bug的修复。

JSPatch的出现解决了这样的问题,只需要在项目中引入极小的JSPatch引擎,就可以使用JavaScript语言调用Objective-C的原生接口,获得脚本语言的能力:动态更新iOS APP,替换项目原生代码、快速修复bug。

技术核心

JSPatch核心主要是JSBinding和Objective-C中的runtime技术。一方面,它采用Apple在iOS7中发布的JavaScriptCore.framework作为Javascript引擎解析JavaScript脚本,执行JavaSript代码并与Objective-C端的代码进行桥接。另一方面则是使用Objective-C runtime中的method swizzling的方式达到使用JavaScript脚本动态替换原有Objective-C方法的目的,并利用ForwardInvocation标准消息转发机制使得在JavaScript脚本中调用Objective-C的方法成为可能。

二、JSPatch VS lua Wax

wax是可以实现动态打补丁快速修补Crash的另外一种解决方案,初衷是为了使用lua来编写iOS原生应用而诞生的一个框架。它利用lua的C语言API(可以让C代码与lua进行交互的函数集,包括读写lua全局变量的函数,调用lua函数的函数,运行lua代码片段的函数,注册C函数然后可以在lua中被调用的函数,等等)和 Objective-C 强大的runtime使lua能调用原生Objective-C接口,可以使用lua创建,继承,扩展oc类,使用lua实现oc所能实现的所有功能。

lua wax由几个部分组成:

  1. wax stdLib,是一个lua脚本库,利用前面提到的C API和Objective-C runtime向lua脚本提供与Objective-C类交互的接口;

  2. Wax Engine,提供使用Objective-C加载运行lua脚本和传递变量给lua脚本的接口;

  3. lua Compiler,即lua解释器,wax Engine调用解释器加载并编译运行lua脚本。
    Wax lua

相比于wax, JSPatch有以下的优势

  1. Javascript比lua在应用开发领域有更广泛的应用。 目前前端开发和终端开发有融合的趋势,作为扩展的脚本语言,JavaScript是不二之选。

  2. 更符合Apple的规则。iOS Developer Program License Agreement里3.3.2提到不可动态下发可执行代码,但通过苹果JavaScriptCore.framework或WebKit执行的代码除外,JS正是通过JavaScriptCore.framework执行的。

  3. 小巧。 使用系统内置的JavaScriptCore.framework,无需内嵌脚本引擎,体积小巧。而Wax需要导入c代码写的lua引擎。

  4. 不需要担心内存回收的问题。JavascriptCore.framework通过GC来对垃圾进行回收。而lua wax需要显式调用内存回收方法。

  5. 支持armv7 armv7s arm64框架。wax并不支持arm64框架。

而JSPatch也有自身的缺点:

  1. 不支持iOS6及以下,因为JSPatch依赖于iOS7及以后的JavascriptCore.framework (这点现在可以忽略,因为微信最低的版本要求已经是iOS7)

  2. 调用OC方法的性能慢于lua wax

  3. 启动JSPatch所占用的内存多于wax

三、JSPatch核心原理解析

startEngine

1
2
3
4
[JPEngine startEngine];
NSString *sourcePath = [[NSBundle mainBundle] pathForResource:@"demo" ofType:@"js"];
NSString *script = [NSString stringWithContentsOfFile:sourcePath encoding:NSUTF8StringEncoding error:nil];
[JPEngine evaluateScript:script];

使用JSPatch框架首先要调用JPEngine中的类方法startEngine,这个方法的是为了初始化JSContext,JSContext是JS脚本的运行环境。JS脚本可以调用在JSContext中预先定义的方法,方法的参数/返回值都会被JavaScriptCore.framework自动转换,OC里的NSArray,NSDictionary,NSString,NSNumber,NSBlock,[NSNull null]会分别转为JS端的Array/Object/String/Number/function/null。

那其他无法通过JavascriptCore.framework进行bridge转换的数据类型,比如自定义的类的对象,Class类型,指针,要如何在JS和OC两端进行传递呢?

JSPatch中使用了一个叫做JPBoxing的类去封装id、指针、Class类型变量,封装完以后这个Boxing对象会被放在一个NSDictionary里(NSDictionary可转化为JS中的Object类型),传递给JS代码。后面会对JPBoxing进行详细的介绍.

回到startEngine方法:

1
2
3
4
5
6
7
8
9
10
11
context[@"_OC_defineClass"] = ^(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) {
        return defineClass(classDeclaration, instanceMethods, classMethods);
    };

context[@"_OC_callI"] = ^id(JSValue *obj, NSString *selectorName, JSValue *arguments, BOOL isSuper) {
        return callSelector(nil, selectorName, arguments, obj, isSuper);
    };

context[@"_OC_callC"] = ^id(NSString *className, NSString *selectorName, JSValue *arguments) {
        return callSelector(className, selectorName, arguments, nil, NO);
    };

在这里定义的函数主要是负责处理转换从JS端传过来的参数,然后在OC端运用runtime里的方法实现生成新的类、替换旧的类、调用方法等等功能。

其中_OC_defineClass负责定义新的类或替换原有的类,_OC_callI负责调用实例方法,_OC_callC负责调用类方法。

除了这三个函数之外,startEngine中还封装了一些常用GCD方法、console.log、sizeof、Javascript异常捕获函数等等。

准备完JSContext之后,就可以加载从网络中下载的JS补丁,调用[JPEngeine evaluateScript:script]方法执行脚本。

defineClass

接下来讲解JSPatch中如何定义一个类以及怎么覆盖原方法或新增一个方法。

1
2
3
4
5
6
defineClass('JPViewController', {
  handleBtn: function(sender) {
    var tableViewCtrl = JPTableViewController.alloc().init()
    self.navigationController().pushViewController_animated(tableViewCtrl, YES)
  }
}, {})

defineClass函数可接受三个参数:

  1. 字符串:”需要替换或者新增的类名:继承的父类名 <实现的协议1,实现的协议2>”
  2. {实例方法}
  3. {类方法}

将这三个参数通过bridging传入到OC后,执行以下步骤:

  1. 使用NSScanner分离classDeclaration,分离成三部分
    • 类名 : className
    • 父类名 : superClassName
    • 实现的协议名 : protocalNames
  2. 使用NSClassFromString(className)获得该Class对象。
    • 若该Class对象为nil,则说明JS端要添加一个新的类,使用objc_allocateClassPairobjc_registerClassPair注册一个新的类。
    • 若该Class对象不为nil,则说明JS端要替换一个原本已存在的类
  3. 根据从JS端传递来的实例方法与类方法参数,为这个类对象添加/替换实例方法与类方法
    • 添加实例方法时,直接使用上一步得到class对象; 添加类方法时需要调用objc_getMetaClass方法获得元类。
    • 如果要替换的类已经定义了该方法,则直接对该方法替换和实现消息转发。
    • 否则根据以下两种情况进行判断
      • 遍历protocalNames,通过objc_getProtocol方法获得协议对象,再使用protocol_copyMethodDescriptionList来获得协议中方法的type和name。匹配JS中传入的selectorName,获得typeDescription字符串,对该协议方法的实现消息转发。
      • 若不是上述两种情况,则js端请求添加一个新的方法。构造一个typeDescription为”@@:\@****”(返回类型为id,参数值根据JS定义的参数个数来决定。新增方法的返回类型和参数类型只能为id类型,因为在JS端只能定义对象)的IMP。将这个IMP添加到类中。
  4. 为该类添加setProp:forKeygetProp:方法,使用objc_getAssociatedObjectobjc_setAssociatedObject让JS脚本拥有设置property的能力
  5. 返回{className:cls}回JS脚本。

overrideMethod方法

不管是替换方法还是新增方法,都是使用overrideMethod方法。
它接受五个参数:

  • 类名
  • 要替换的方法名
  • JS中定义的方法
  • 是否类方法
  • 方法的typeDescription

原型如下

1
static void overrideMethod(Class cls, NSString *selectorName, JSValue *function, BOOL isClassMethod, const char *typeDescription)

逻辑步骤如下

  1. 初始化:更具selectorName获取对应的Selector;typeDescription获得NSMethodSignature方法签名。
  2. 保存原有方法的IMP,添加名为@"ORIG" + selectorName的方法,IMP为原方法的IMP。
  3. 将原方法的IMP设置为消息转发
    • 若该方法的返回值为特殊的struct类型,则需要将IMP设置为(IMP)_objc_msgForward_stret
    • 否则的话将IMP设置为_objc_msgForward
  4. 保存原有转发方法forwardInvocation:的IMP,添加selectorName为@”ORIGforwardInvocation:”,IMP为原转发方法IMP的方法。
  5. 将原转发方法替换为自己的转发方法JPForwardInvocation
  6. 根据替换/添加方法的返回类型,选择不同的替换IMP(使用宏的形式定义),替换原方法。

callSelector方法

在JS端调用OC方法时,都需要通过在OC端通过callSelector方法进行方法的查找以及参数类型、返回类型的转换和处理。

该方法接受五个参数

  • 调用对象的类名
  • 被调用的selectorName
  • JS中传递过来的参数
  • JS端封装的实例对象
  • 是否调用的是super类的方法

方法的原型:

1
static id callSelector(NSString *className, NSString *selectorName, JSValue *arguments, JSValue *instance, BOOL isSuper)

逻辑步骤如下

  1. 初始化
    • 将JS封装的instance对象进行拆装,得到OC的对象;
    • 根据类名与selectorName获得对应的类对象与selector;
    • 通过类对象与selector构造对应的NSMethodSignature签名,再根据签名构造NSInvocation对象,并为invocation对象设置target与Selector
  2. 根据方法签名,获悉方法每个参数的实际类型,将JS传递过来的参数进行对应的转换(比如说参数的实际类型为int类型,但是JS只能传递NSNumber对象,需要通过[[jsObj toNumber] intValue]进行转换)。转换后使用setArgument方法为NSInvocation对象设置参数。
  3. 执行invoke方法。
  4. 通过getReturnValue方法获取到返回值。
  5. 根据返回值类型,封装成JS中对应的对象(因为JS并不识别OC对象,所以返回值为OC对象的话需封装成{className:className, obj:obj})返回给JS端。

JPForwardInvocation方法

JPForwardInvocation方法替换了原有-forwardInvocation方法的实现,使得消息转发都通过该方法,并将消息转发给JS脚本中定义的方法,通过JavascriptCore.frameWork中提供的callWithArguments方法调用JS方法达到替换原方法,添加新方法的目的。是实现替换和新增方法的核心。

它的原型与ForwardInvocation方法相同

1
static void JPForwardInvocation(id slf, SEL selector, NSInvocation *invocation)

它的内部逻辑并不复杂,主要是读取出传入的invocation对象中的所有参数,根据实际参数的类型将JSValue类型的参数转换成对应的OC类型,最后将参数添加到_TMPInvocationArguments数组以供JS调用。

那如果有一些类确实有用到这个方法进行消息转发(比如为了实现多继承),那原来的逻辑该怎么办?

JSPatch在替换-forwardInvocation:方法前会新建一个方法-ORIGforwardInvocation:,保存原来的实现IMP,在新的-forwardInvocation:实现里做了个判断,如果转发的方法是JS脚本中想改写的,就走-JPForwardInvocation:逻辑,若不是,就调用-ORIGforwardInvocation:走原来的流程。

对象的持有/转换

原作者bang在他的博文中,有较为详细的说明。下面引用了他文章中关于对象持有/转换的细节.

UIView.alloc() 通过上述消息传递后会到OC执行 [UIView alloc],并返回一个UIView实例对象给JS,这个OC实例对象在JS是怎样表示的呢?怎样可以在JS拿到这个实例对象后可以直接调用它的实例方法 (UIView.alloc().init())?

对于一个自定义id对象,JavaScriptCore会把这个自定义对象的指针传给JS,这个对象在JS无法使用,但在回传给OC时OC可以找到这个对象。对于这个对象生命周期的管理,按我的理解如果JS有变量引用时,这个OC对象引用计数就加1 ,JS变量的引用释放了就减1,如果OC上没别的持有者,这个OC对象的生命周期就跟着JS走了,会在JS进行垃圾回收时释放。
传回给JS的变量是这个OC对象的指针,如果不经过任何处理,是无法通过这个变量去调用实例方法的。所以在返回对象时,JSPatch会对这个对象进行封装。

首先,告诉JS这是一个OC对象:

1
2
3
4
5
static NSDictionary *toJSObj(id obj)
{
    if (!obj) return nil;
    return @{@"__isObj": @(YES), @"cls": NSStringFromClass([obj class]), @"obj": obj};
}

用__isObj表示这是一个OC对象,对象指针也一起返回。接着在JS端会把这个对象转为一个 JSClass 实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
var _formatOCToJS = function(obj) {
    if (obj === undefined || obj === null) return false
    if (typeof obj == "object") {
      if (obj.__obj) return obj
      if (obj.__isNull) return false  //注:这里是为了让JS能够链式调用
    }
    if (obj instanceof Array) {
      var ret = []
      obj.forEach(function(o) {
        ret.push(_formatOCToJS(o))
      })
      return ret
    }
    if (obj instanceof Function) {
      return function() {
        var args = Array.prototype.slice.call(arguments)
        return obj.apply(obj_OC_formatJSToOC(args))
      }
    }
    if (obj instanceof Object) {
      var ret = {}
      for (var key in obj) {
        ret[key] = _formatOCToJS(obj[key])
      }
      return ret
    }
    return obj
  }

接着看看对象是怎样回传给OC的。上述例子中,view.setBackgroundColor(require(‘UIColor’).grayColor()),这里生成了一个 UIColor 实例对象,并作为参数回传给OC。根据上面说的,这个 UIColor 实例在JS中的表示是一个 JSClass 实例,所以不能直接回传给OC,这里的参数实际上会在 c 函数进行处理,会把对象的 .obj 原指针回传给OC。

整个对象的持有/转换的流程图如下:

convertpng

四、JSPatch Extension机制

如何在JSPatch中预定义C API供JS调用

上面已经介绍过JSPatch是运用Objective-C runtime和JSBinding技术来在JS中调用Objective-C的方法,但是C API是没法通过runtime技术来获取的。一开始的时候我想使用dlsym函数通过函数名来获取对应的函数指针,通过JS脚本传入C函数的函数名来进行函数调用。但实际上还需要预先定义一个相同类型的函数指针才能调用,做不到完全的动态调用。而且还有一个问题就是像CGRectMake这种,实质上是内联函数,并没有对应的函数地址。更关键的是,没有办法获取C函数的签名,而JS中调用函数是没有具体类型的,传递到OC是以JSValue对象的形式,必须通过转换才能调用对应的C函数。最后的解决方法便是预先在JSContext中提供JS方法和C函数的桥接方法。

这里以定义CGRectMake()来作为例子,如果想在JS中使用CGRectMake()函数,则需要在JPEngine启动的时候,将CGRectMake预定义在JSContext之中。

而且有一点要注意的,CGRectMake返回的并不是一个对象,而是一个struct类型的变量。struct类型是无法返回到JS环境的,所以要转换成NSDictionary的形式。

Extension中需要定义对应的方法来将struct转换成NSDictionary

1
2
3
4
5
6
7
8
9
+ (NSDictionary *)dictOfStruct:(void *)structData typeString:(const char*)type
{
    if (strcmp(type@encode(CGRect)) == 0) {
        CGRect *rect = structData;
        return @{@"x": @(rect->origin.x), @"y": @(rect->origin.y), @"width": @(rect->size.width)@"height": @(rect->size.height)};
    }
    //下面接着定义其他类型的Struct
    return nil;
}

这样就可以在startEngine中定义CGRectMake方法了,具体如下

1
2
3
4
context[@"CGRectMake"] = ^id(JSValue *xJSValue *yJSValue *widthJSValue *height){
        CGRect rect = CGRectMake([x toDouble], [y toDouble], [width toDouble], [height toDouble]);
        return [JPEngine dictOfStruct:&rect typeString:@encode(CGRect)];
    };

在JS中就可以如此调用桥接函数

1
var frame = CGRectMake(0, 0, 200, 200)

但是如果返回的值是一个指针或者参数值为指针要如何解决?

这时候就需要一个Boxing对象对指针和Class这些在JS中无法使用的变量类型进行装箱(box);在JS中调用OC或C方法后,传递回到Objective-C端的再进行拆箱(unbox)。

Boxing对象的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@interface JPBoxing : NSObject
@property (nonatomic) id obj;
@property (nonatomic) void *pointer;
@property (nonatomic) Class cls;
- (id)unbox;
- (void *)unboxPointer;
- (Class)unboxClass;
@end

@implementation JPBoxing

#define JPBOXING_GEN(_name, _prop, _type) \
+ (instancetype)_name:(_type)obj  \
{   \
    JPBoxing *boxing = [[JPBoxing alloc] init]; \
    boxing._prop = obj;   \
    return boxing;  \
}

JPBOXING_GEN(boxObj, obj, id)
JPBOXING_GEN(boxPointer, pointer, void *)
JPBOXING_GEN(boxClass, cls, Class)

- (id)unbox
{
    if (self.obj) return self.obj;
    return self;
}
- (void *)unboxPointer
{
    return self.pointer;
}
- (Class)unboxClass
{
    return self.cls;
}
@end

注意到unbox里的一个return self的写法,这里是一个trick。因为前面介绍到的formatJSToOC函数的定义如下

1
id formatJSToOC(JSValue *jsval)

这个函数需要负责处理JS到OC端的类型转换,但是如果变量类型是指针或者Class类型的话就和无法和id类型写在同一个处理函数里。所以如果是JPBoxing中的obj为nil,则说明是非id类型,直接返回这个JPBoxing。外部得到的这个JPBoxing对象,则再进行相应类型拆箱。

使用这个Boxing类,调用Extension中的C API时,对指针拆箱,再调用实际的C方法; 返回时,对JS中无法使用的类型进行装箱后再返回; 根据这个机制便可实现对大部分C API的封装。下面以UIGraphicsGetCurrentContext()为例:

jpboxing1
jpboxing2

效果如下:

jpboxing3

使用JPExtesnion扩展机制对C API和Struct进行扩展

在上一节,我对如何在JSPatch中调用C API进行了介绍。 但是面对大量的C API,需要一个满足以下需求的扩展机制:

  1. 可模块化加载
  2. js脚本可动态加载
  3. 可以在extension中添加struct类型

以下是JPExtension协议的定义,所有的C API扩展都需要继承JPExtension协议

1
2
3
4
5
6
7
8
@protocol JPExtensionProtocol <NSObject>
@optional
- (void)main:(JSContext *)context;

- (size_t)sizeOfStructWithTypeName:(NSString *)typeName;
- (NSDictionary *)dictOfStruct:(void *)structData typeName:(NSString *)typeName;
- (void)structData:(void *)structData ofDict:(NSDictionary *)dict typeName:(NSString *)typeName;
@end

开发者可在- (void)main:(JSContext * )context中添加C API,C API会被添加到JS所在的执行环境中。而后面的三个方法从方法名可以知道,extension中如果要定义struct的话则需要实现这三个方法。因为JS中是无法定义和使用c struct的,所以需要提供相应的互相转换方法(struct与NSDictionary互相转换),具体实现以CGAffineTransform为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (size_t)sizeOfStructWithTypeName:(NSString *)typeName
{
    if ([typeName rangeOfString:@"CGAffineTransform"].location != NSNotFound) {
        return sizeof(CGAffineTransform);
    }
    return 0;
}

- (NSDictionary *)dictOfStruct:(void *)structData typeName:(NSString *)typeName
{
    if ([typeName rangeOfString:@"CGAffineTransform"].location != NSNotFound) {
        CGAffineTransform *trans = (CGAffineTransform *)structData;
        return [JPCGTransform transDictOfStruct:trans];
    }
    return nil;
}

- (void)structData:(void *)structData ofDict:(NSDictionary *)dict typeName:(NSString *)typeName
{
    if ([typeName rangeOfString:@"CGAffineTransform"].location != NSNotFound) {
        [JPCGTransform transStruct:structData ofDict:dict];
    }
}

实现了这三个方法后,JPEngine会将实现了这三个方法extesnion放入_structExtension内。当在JS中调用含有相关struct的方法时,JSEngine会遍历整个_structExtension,找到相应的转换方法。

根据JPExtension协议,模块化加载就变得非常简单:

1
2
3
4
5
6
7
- (void)main:(JSContext *)context
{
    NSArray *extensionArray = @[[JPCGTransform instance], [JPCGContext instance],
                                            [JPCGGeometry instance], [JPCGBitmapContext instance],
                                            [JPCGColor instance], [JPCGImage instance], [JPCGPath instance]];
    [JPEngine addExtensions:extensionArray];
}

在JS脚本则可以这样调用:

1
2
3
4
(function init() {
    var extensionArr = [require('JPCoreGraphics').instance(), require('JPUIKit').instance()]
    require('JPEngine').addExtensions(extensionArr)
})()

当然,为了提高项目的性能,你也可以只调用你需要的模块。

C API定义时需要注意的问题

C API中,有大量的参数或者是返回类型都是指针,包括像CGContextRef这种也是指针,而OC对象在JS环境中也是无法使用的。上面的章节已经提到了从OC端返回给JS端时必须用一个封装对象(JPBoxing)来将指针和对象封装起来。JPExtension提供了以下API来封装OC中的对象和指针成JPBoxing和将JPBoxing对象。

1
2
3
4
- (void *)formatPointerJSToOC:(JSValue *)val;
- (id)formatPointerOCToJS:(void *)pointer;
- (id)formatJSToOC:(JSValue *)val;
- (id)formatOCToJS:(id)obj;

C API封装实例:

1
2
3
4
5
6
7
8
9
10
context[@"UIGraphicsGetCurrentContext"] = ^id() {
        CGContextRef c = UIGraphicsGetCurrentContext();
        return [self formatPointerOCToJS:c];
    };

context[@"UIGraphicsBeginImageContext"] = ^void(NSDictionary *sizeDict) {
        CGSize size;
        [JPCGGeometry sizeStruct:&size ofDict:sizeDict];
        UIGraphicsBeginImageContext(size);
    };

注意到UIGraphicsGetCurrentContext()中返回的是一个CGContextRef类型,所以添加这个扩展API的时候需要将返回类型改为id类型,并将CGContextRef指针封装在JPBoxing中。而UIGraphicsBeginImageContext()需要的是一个CGSize参数,这时候需要在JS端传入一个{x:100, y:100}的Javascript object,这个object会在OC中被转换为NSDictionary.

C API的返回值也需要判断返回值的类型来进行不同的封装,当返回的结果是JavascriptCore.Framework所不支持转换的类型(NSArray,NSDictionary,NSString,NSNumber,NSBlock),则需要通过formatOCToJS:方法来封装返回。而且返回类型是NSArray,NSDictionary,NSString时,如果你直接返回,JavascriptCore会将返回值转换为JS中的Array,Object,String,你就无法再使用OC的方法。如果你想在JS中使用这三种类型的方法,也需要用formatOCToJS:方法进行封装。

在JSPatch中的操作内存与&取地址运算符

与C语言不同,Javascript不能显式的声明一个指向某块内存的指针,也没有&取地址运算符,Javascript是根据参数是引用类型还是基本类型决定传递引用还是传参。但是指针与取地址在C语言以及OC中都会时常被用到。比如如下的情况:

1
2
3
4
......
NSString *str = @"littleliang";
[invocation setArgument:&str atIndex:2];
......

JPMemory扩展解决了这个问题,其中封装了内存操作中常用的一些常用的c函数。包括mallocmemsetfreememcpymemncpymemmove

而对&取地址运算符,JPMemory扩展也进行了函数封装,在JS补丁中可以对调用getpointer方法获取对象的指针、指针的指针,针对上述的代码,现在便可以以以下的形式调用。

1
2
3
4
......
var str = require('NSString').stringWithString('littleliang')
invocation.setArgument_atIndex(getpointer(str)2)
......

getpointer的底层源码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
- (void *)getPointerFromJS:(JSValue *)val
{
    void **p = malloc(sizeof(void *));
    if ([[val toObject] isKindOfClass:[NSDictionary class]]) {
        if ([[val toObject][@"__obj"] isKindOfClass:[JPBoxing class]]) {
            void *pointer = [(JPBoxing *)[val toObject][@"__obj"] unboxPointer];
            if (pointer != NULL) {
                *p = pointer;
            }else {
                id jpobj = [(JPBoxing *)[val toObject][@"__obj"] unbox];
                *p = (__bridge void *)jpobj;
            }
        }else {
            id obj = [val toObject][@"__obj"];
            *p     = (__bridge void *)obj;
        }
        return p;
    }else {
        NSAssert(NO, @"getpointer only support pointer and id type!");
        return NULL;
    }
}

而通过JPMemory中的pval或添加pvalWithXXX便可获得指针所指的对象或XXX类型的变量。

1
2
3
4
5
6
7
8
9
10
11
context[@"pval"]    = ^id(JSValue *jsVal) {
        void *m = [self formatPointerJSToOC:jsVal];
        id obj = *((__unsafe_unretained id *)m);
        return [self formatOCToJS:obj];
    };

context[@"pvalWithBool"] = ^id(JSValue *jsVal) {
        void *m = [self formatPointerJSToOC:jsVal];
        BOOL b = *((BOOL *)m);
        return [self formatOCToJS:[NSNumber numberWithBool:b]];
    };

include函数

在JSPatch最新的更新中,支持了在JS中调用include方法。可以在一个JS文件中加载其他JS文件,包括补丁脚本、第三方脚本。

使用方法如下:

1
2
3
4
5
(function init() {
    var extensionArr = [require('JPInclude').instance()]
    require('JPEngine').addExtensions(extensionArr)
    include('another.js')
})()

在我自己的分支中,include函数支持加载选项。默认加载选项是兼容方式加载(为满足支持OC,会通过正则表达式替换部分函数的调用方法),而第三方库是不需要被改变的。第二个参数是加载选项,默认是0或者不传入第二个参数,加载第三方库是1。

1
2
3
(function init() {
    include('thridparty.js', 1)
})()

五、JSPatch中的实现技巧

GCD的实现

JSPatch采用的是预先在JSContext中封装了对GCD的调用,才能在JS中使用GCD,其代码如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
__weak JSContext *weakCtx = context;
  context[@"dispatch_after"] = ^(double time, JSValue *func) {
      JSValue *currSelf = weakCtx[@"self"];
      dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(time * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
          JSValue *prevSelf = weakCtx[@"self"];
          weakCtx[@"self"] = currSelf;
          [func callWithArguments:nil];
          weakCtx[@"self"] = prevSelf;
      });
  };
  context[@"dispatch_async_main"] = ^(JSValue *func) {
      JSValue *currSelf = weakCtx[@"self"];
      dispatch_async(dispatch_get_main_queue(), ^{
          JSValue *prevSelf = weakCtx[@"self"];
          weakCtx[@"self"] = currSelf;
          [func callWithArguments:nil];
          weakCtx[@"self"] = prevSelf;
      });
  };
  context[@"dispatch_sync_main"] = ^(JSValue *func) {
      if ([NSThread currentThread].isMainThread) {
          [func callWithArguments:nil];
      } else {
          dispatch_sync(dispatch_get_main_queue(), ^{
              [func callWithArguments:nil];
          });
      }
  };
  context[@"dispatch_async_global_queue"] = ^(JSValue *func) {
      JSValue *currSelf = weakCtx[@"self"];
      dispatch_async(dispatch_get_global_queue(0, 0), ^{
          JSValue *prevSelf = weakCtx[@"self"];
          weakCtx[@"self"] = currSelf;
          [func callWithArguments:nil];
          weakCtx[@"self"] = prevSelf;
      });
  };

其中有三点需要注意:

  1. 在block里是不能直接使用context的,因为会造成循环引用。所以在这里有两个处理方式,要么是使用__weak修饰符,要么就是使用JavascriptCore.framework提供的api
    [JSContext currentContext]

  2. 在调用JSContext的callWithArguments:实例方法时,需要先保存JSContext中的实例对象self,调用完之后再重新赋值回去。否则在调用完JS方法后,self会变成nil

  3. 还有一点就是在dispatch_sync_main这个方法里,作者对代码所在的运行线程进行了一个判断,如果已经在主线程中就直接执行这个block,防止了死锁的发生。

处理JS脚本的异常

如果JS脚本出现了异常的话,在OC这边是不会知道的,需要使用JavaScriptCore.framwork中的exceptionHandler才能捕获这个异常,具体代码如下

1
2
3
4
context.exceptionHandler = ^(JSContext *con, JSValue *exception) {
        NSLog(@"%@", exception);
        NSAssert(NO, @"js exception: %@", exception);
    };

使用#pragma来抑制warning

作者使用#pargama宏来对一些warning进行了抑制,详细的介绍可以看参考Matt Thomson写的一篇关于clang diagnostics的文章,里面提供了一个网站详细地记录了抑制各种warning的写法。

使用宏来预定义IMP函数

由于要替换原有的函数实现,所以要预先定义好各种返回类型的IMP函数。如果全部写出来的话,将会耗费大量篇幅来写差不多的函数实现,这里作者使用了宏来进行替换,具体代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#define JPMETHOD_IMPLEMENTATION(_type, _typeString, _typeSelector) \
    JPMETHOD_IMPLEMENTATION_RET(_type, _typeString, return [[ret toObject] _typeSelector]) \

#define JPMETHOD_IMPLEMENTATION_RET(_type, _typeString, _ret) \
static _type JPMETHOD_IMPLEMENTATION_NAME(_typeString) (id slf, SEL selector) {    \
    JSValue *fun = getJSFunctionInObjectHierachy(slf, selector);    \
    JSValue *ret = [fun callWithArguments:_TMPInvocationArguments];  \
    _ret;    \
}   \

#define JPMETHOD_IMPLEMENTATION_NAME(_typeString) JPMethodImplement_##_typeString

#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wunused-variable"

#define JPMETHOD_RET_ID \
    id obj = formatJSToOC(ret); \
    if ([obj isKindOfClass:[NSNull class]]) return nil;  \
    return obj;

#define JPMETHOD_RET_STRUCT(_methodName)    \
    id dict = formatJSToOC(ret);   \
    return _methodName(dict);

JPMETHOD_IMPLEMENTATION_RET(void, v, nil)
JPMETHOD_IMPLEMENTATION_RET(id, id, JPMETHOD_RET_ID)
JPMETHOD_IMPLEMENTATION_RET(CGRect, rect, JPMETHOD_RET_STRUCT(dictToRect))
JPMETHOD_IMPLEMENTATION_RET(CGSize, size, JPMETHOD_RET_STRUCT(dictToSize))
JPMETHOD_IMPLEMENTATION_RET(CGPoint, point, JPMETHOD_RET_STRUCT(dictToPoint))
JPMETHOD_IMPLEMENTATION_RET(NSRange, range, JPMETHOD_RET_STRUCT(dictToRange))
......

六、遇到的问题与解决方法

在了解JSPatch的过程中,也遇到过一些比较棘手的问题,这里总结一下。

1. 在JSPatch中初始化UIWebView导致HTML无法渲染

这个问题最初是由一个JSPatch用户在issue中提出。具体的表现是当在JSPatch中声明一个UIWebView,并对一个URL调用loadRequest方法后,无法渲染该HTML页面。
图片
后来经过查资料发现在JSContext中初始化UIWebView就会出现这个问题

我猜测是UIWebView在初始化的过程中会初始化一个全局的JSContext对象,但是JSPatch也有用到JSContext,这其中造成了某种冲突。导致在UIWebView初始化JSContext失败。

解决方法:

1、在JSPatch之外启动调用一次UIWebViewinit方法。

1
2
3
4
5
6
7
8
9
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    UIWebView *view = [[UIWebView alloc]init];   //Useless. Just to load the UIWebview framework.
    view.frame = CGRectZero;

    [JPEngine startEngine];
   //.......

    return YES;
}

2、在JSPatch中显式地创建UIWebView的JSContext

1
2
3
4
5
6
defineClass('UIWebView',{
	 loadRequest :function(request){
	 	self.valueForKeyPath("documentView.webView.mainFrame.javaScriptContext")  		
	 	self.ORIGloadRequest(request)
 }
})

2. 无法调用参数为id * 类型的方法

在上面的章节中我介绍了我是如何在JS中调用一个参数是指针的方法:

  1. 通过JPMemoeryExtension中的malloc声明一个指针或者调用getpointer去获得一个对象的地址。
  2. 调用方法后使用pval方法来获得指针所指的对象。

这种方法调用像NSInvocation中的- (void)getReturnValue:(void * )retLoc- (void)setArgument:(void * )argumentLocation atIndex:(NSInteger)idx;以及一些C API没有任何问题。

但是如果调用NSString中的- (BOOL)writeToFile:(NSString * )path atomically:(BOOL)useAuxiliaryFile encoding:(NSStringEncoding)enc error:(NSError * * )error则会发现传进去的指针参数所指向的NSError对象在回到JS环境的时候已经被释放了。

实际上NSError * 参数在编译器中会被解释成NSError \–__ autoreleasing*类型,该对象生成后将会被加入到autoreleasing- pool中,开发者无法控制他的释放时机。随后,我对各个关键点进行打断点对这个自动释放对象进行跟中,发现这个对象一回到JSContext的执行环境就会被释放。

解决方法:

解决这个问题的思路是在调用完参数为id * 类型的方法后(callSelector中调用完[invocation inoke]后),对该自动释放对象进行强引用,使得这个对象在回到JS环境的时候依然存在。这里我使用了一个NSDictonry作为临时自动释放对象的内存池,当生成一个自动释放对象后,将其添加到内存池中(key为其内存地址),使得这个自动释放对象被内存池强引用。保证了在JS环境中可以访问到该对象,不需要该对象时再手动释放该对象(remove掉内存池的对象)。具体逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
case '^': {
                if ([valObj isKindOfClass:[JPBoxing class]]) {
                    void *value = [((JPBoxing *)valObj) unboxPointer];
                    if (argumentType[1] == '@') {
                        memset(value, 0, sizeof(id));
                        [_markArray addObject:valObj];
                    }
                    [invocation setArgument:&value atIndex:i];
                    break;
                }
            }

当参数类型是指针的时候进行一个判断,如果指针所指的是id类型。则将JPBoxing加入到一个_markArray中。

1
2
3
4
5
6
7
8
9
10
11
12
13
//...
[invocation invoke];
    if ([_markArray count] > 0) {
        for (JPBoxing *box in _markArray) {
            void *pointer = [box unboxPointer];
            id obj = *((__unsafe_unretained id *)pointer);
            if (obj) {
                @synchronized(_TMPMemoryPool) {
                    [_TMPMemoryPool setObject:obj forKey:[NSNumber numberWithInteger:[obj hash]]];
                }
            }
        }
    }

如果_markArray大小不为零,则将数组中的JPBoxing中指针所指对象加入到内存池中(key为对象的hashCode),使得自动释放对象被强引用。

1
2
3
4
5
6
7
8
9
context[@"releaseTmpObj"] = ^void(JSValue *jsVal) {
        if ([[jsVal toObject] isKindOfClass:[NSDictionary class]]) {
            void *pointer =  [(JPBoxing *)([jsVal toObject][@"__obj"]) unboxPointer];
            id obj = *((__unsafe_unretained id *)pointer);
            @synchronized(_TMPMemoryPool) {
                [_TMPMemoryPool removeObjectForKey:[NSNumber numberWithInteger:[obj hash]]];
            }
        }
    };

JSPatch脚本中调用releaseTmpObj方法手动释放该临时对象。

七、总结

JSPatch中我的总结如下:

1、 为支持C API的扩展,增加了对指针和Class变量的封装以及拆解。因为JS中并不能使用以及声明一个指针,而在C API中,指针作为参数是非常常见的,所以需要将指针封装在一个JPBoxing中,并以{“__ className”:”JPBoxing”,”__ obj”:[JPBoxing boxPointer:pointer]}的形式回传回JS。下次调用的时候再将__obj中的指针拆解出来。

2、 支持对第三方库的调用。在include方法中增加了对第三方js库调用的支持。

3、 添加了sizeof方法。通过遍历加载到JS中的Extension中实现JPExtension协议的- (size_t)sizeOfStructWithTypeName:(NSString * )typeName方法,使得在JS端可以通过传入一个字符串的形式:sizeof('CGRect')来获取不同strut类型变量的大小,配合JPMemory扩展中的方法可以malloc出一块指定大小的内存,传入到某些C API中。

4、 添加JPMemory扩展,Javascript中是无法操作具体的内存也没有指针的概念,但是通过JPMemory扩展,可以让JSPatch拥有操作与访问内存的功能,以及获取对象的指针、指针的指针的功能。

5、 添加了保存的__autoreleasing对象机制,使自动释放对象在返回到JS运行环境的时候不会被自动释放,在JS脚本可以继续访问。使用一个NSDictonry作为临时自动释放对象的内存池,当生成一个自动释放对象后,将其添加到内存池中(key为其内存地址),使得这个自动释放对象被内存池强引用。保证了在JS环境中可以访问到该对象,不需要的时候再手动释放该对象(remove掉内存池的对象)。

维护:

1、 修复了在JS中传递nil参数时造成的崩溃。JavaScript中并没有nil这个类型的参数,只有undefined以及null类型。桥接的时候JavascriptCore.framework会将js中的null类型转换为OC中的[NSNull null]类型,所以在调用方法时必须加以判断,将[NSNull null]转换为nil。为了防止真需要传递[NSNull null]参数,在JS中设置了一个名为nsnull的全局变量,开发者如要传递[NSNull null]则可以使用nsnull。

2、 修复了使用JSPatch判断struct时造成的误判。原作者bang原本使用的@encode()方法产生的C字符串来遍历加载到JS中的所有extension的- (size_t)sizeOfStructWithTypeEncode:(NSString * )typeName方法,@encode产生结果是这样的形式:{CGPoint=dd},并使用

1
typeEncode rangeOfString:@"CGPoint"].location != NSNotFound;

进行匹配。但事实上这样的方法是会导致CGPoint被误判为CGRect,因为CGRect进行@encode的结果为{CGRect={CGPoint=dd}{CGSize=dd}}。而后bang提议改为

1
[typeEncode rangeOfString:@"CGPoint"].location == 1;

但是这样会与sizeof功能有所冲突,而且在随后的测试中我发现@encode(NSRange)的结果为{_NSRange=QQ},所以检测location==1也是不可以行的。最后我通过提取出typeEncode中第一个Struct的名字解决了这一问题

3、 修复了JSPatch添加Extension时造成无限循环加载的问题。

代码提交情况: 1916++ / 348—commit log

未来计划

JSPatch功能上已能满足hot fix动态修复的基本需求。接下来的工作重点主要添加脚本的传输和脚本加密的机制,防止未授权的第三方对脚本进行串改;集成进项目中测试实际性能和内存占用情况,改善性能;根据业务需求继续添加常用扩展API。

(持续更新中…)

Comments