JSPatch 可以让你用 JavaScript 书写原生 iOS APP。只需在项目引入极小的引擎,就可以使用 JavaScript 调用任何 Objective-C 的原生接口,获得脚本语言的优势:为项目动态添加模块,或替换项目原生代码动态修复 bug。
一、JSPatch 的基础使用
在了解了JSPatch的基本概念之后,我们来看一下JSPatch的使用方式。
1. require
在JSPatch中使用OC类之前都需要先调用 require('ClassName')
:
require('UIView')
var view = UIView.alloc().init()
这就类似于OC中的 import
,并且这里可以使用逗号分隔来一次性导入多个类:
require('UIView, UIColor')
var view = UIView.alloc().init()
var red = UIColor.redColor()
或者直接在使用时才调用 require()
require('UIView').alloc().init()
2. 调用OC方法
调用类方法
var redColor = UIColor.redColor();
调用对象方法
var view = UIView.alloc().init();
view.setNeedsLayout();
参数传递
跟OC一样传递参数:
var view = UIView.alloc().init();
var superView = UIView.alloc().init()
superView.addSubview(view)
属性
获取/修改属性值等于调用这个属性的 getter/setter 方法:
view.setBackgroundColor(redColor);
var bgColor = view.backgroundColor();
方法名转换
多参数方法名使用 _
分隔:
var indexPath = require('NSIndexPath').indexPathForRow_inSection(0, 1);
如果原OC方法名里包含下划线,则在JS使用双下划线代替:
// OC: [JPObject _privateMethod];
JPObject.__privateMethod()
3. defineClass
API
@param classDeclaration: 字符串,类名/父类名和Protocol
@param properties: 新增property,字符串数组,可省略
@param instanceMethods: 要添加或覆盖的实例方法
@param classMethods: 要添加或覆盖的类方法
defineClass(classDeclaration, [properties,] instanceMethods, classMethods)
覆盖对象方法
-
在defineClass里定义OC已存在的方法即可覆盖,方法名规则与调用规则一致:
// OC @implementation JPTableViewController - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { } @end
// JS defineClass("JPTableViewController", { tableView_didSelectRowAtIndexPath: function(tableView, indexPath) { ...... }, })
-
使用双下划线代表原OC方法名里的下划线:
// OC @implementation JPTableViewController - (NSArray *) _dataSource { } @end
// JS defineClass("JPTableViewController", { __dataSource: function() { }, })
-
在方法名前面加
ORIG
即可调用未覆盖前的OC原方法:// OC @implementation JPTableViewController - (void)viewDidLoad { } @end
// JS defineClass("JPTableViewController", { viewDidLoad: function() { self.ORIGviewDidLoad(); ...... }, })
覆盖类方法
defineClass()
的第三个参数就是要添加或者覆盖的类方法,规则与覆盖对象方法一致:
// OC
@implementation JPTestObject
+ (void)shareInstance
{
}
@end
// JS
defineClass("JPTableViewController", {
//实例方法
}, {
//类方法
shareInstance: function() {
...
},
})
动态新增 Property
可以在 defineClass()
的第二个参数为类新增property,格式为字符串数组,使用时与OC的property接口一致:
defineClass("JPTableViewController", ['data', 'totalCount'], {
init: function() {
self = self.super().init()
self.setData(["a", "b"]) //添加新的 Property (id data)
self.setTotalCount(2)
return self
},
viewDidLoad: function() {
var data = self.data() //获取 Property 值
var totalCount = self.totalCount()
},
})
私有成员变量
使用 valueForKey()
和 setValue_forKey()
获取/修改私有成员变量:
// OC
@implementation JPTableViewController {
NSArray *_data;
}
@end
// JS
defineClass("JPTableViewController", {
viewDidLoad: function() {
var data = self.valueForKey("_data") //get member variables
self.setValue_forKey(["JSPatch"], "_data") //set member variables
},
})
添加新方法
可以给一个类随意添加OC未定义的方法,但所有的参数类型都是id类型:
// OC
@implementation JPTableViewController
- (void)viewDidLoad
{
NSString* data = [self dataAtIndex:@(1)];
NSLog(@"%@", data); //output: Patch
}
@end
// JS
var data = ["JS", "Patch"]
defineClass("JPTableViewController", {
dataAtIndex: function(idx) {
return idx < data.length ? data[idx]: ""
}
})
注意:如果新增的方法属于Protocol里的方法,则需要在defineClass的类声明参数里指定实现的Protocol。
Protocol
可以在定义时让一个类实现某些Protocol方法,写法与OC一样:
defineClass("JPViewController: UIViewController<UIScrollViewDelegate, UITextViewDelegate>", {
})
这样做的作用是,当添加 Protocol 里定义的方法,而类里没有实现的方法时,参数类型不再全是 id
,而是自动转为 Protocol 里定义的类型:
@protocol UIAlertViewDelegate <NSObject>
...
- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex;
...
@end
defineClass("JPViewController: UIViewController <UIAlertViewDelegate>", {
viewDidAppear: function(animated) {
var alertView = require('UIAlertView')
.alloc()
.initWithTitle_message_delegate_cancelButtonTitle_otherButtonTitles(
"Alert",
self.dataSource().objectAtIndex(indexPath.row()),
self,
"OK",
null
)
alertView.show()
}
alertView_clickedButtonAtIndex: function(alertView, buttonIndex) {
console.log('clicked index ' + buttonIndex)
}
})
二、JSPatch 的基础原理
JSPatch 能做到通过 JS 调用和改写 OC 方法最根本的原因是 OC 是动态语言,OC 上所有方法的调用/类的生成都通过 OC Runtime 在运行时进行,我们可以通过类名/方法名反射得到相应的类和方法:
Class class = NSClassFromString("UIViewController");
id viewController = [[class alloc] init];
SEL selector = NSSelectorFromString("viewDidLoad");
[viewController performSelector:selector];
也可以替换某个类的方法为新的实现:
static void newViewDidLoad(id slf, SEL sel) {}
class_replaceMethod(class, selector, newViewDidLoad, @"");
还可以注册一个新的类,并为其添加方法:
Class cls = objc_allocateClassPair(superCls, "JPObject", 0);
objc_registerClassPair(cls);
class_addMethod(cls, selector, implement, typedesc);
理论上你可以在运行时通过类名/方法名调用到任何 OC 方法,替换任何类的实现以及新增任意类。所以 JSPatch 的基本原理就是:JS 传递字符串给 OC,OC 通过 Runtime 接口调用和替换 OC 方法。这是最基础的原理,实际实现过程还有很多怪要打,接下来看看具体是怎样实现的。
方法调用原理
require('UIView')
var view = UIView.alloc().init()
view.setBackgroundColor(require('UIColor').grayColor())
view.setAlpha(0.5)
引入 JSPatch 后,可以通过以上 JS 代码创建了一个 UIView 实例,并设置背景颜色和透明度,涵盖了 require 引入类,JS 调用接口,消息传递,对象持有和转换,参数转换这五个方面,接下来逐一看看具体实现。
1. require
调用 require('UIView')
后,就可以直接使用 UIView
这个变量去调用相应的类方法了,require 做的事很简单,就是在JS全局作用域上创建一个同名变量,变量指向一个对象,对象属性 __clsName 保存类名,同时表明这个对象是一个 OC Class。
var _require = function(clsName) {
if (!global[clsName]) {
global[clsName] = {
__clsName: clsName
}
}
return global[clsName]
}
所以调用 require('UIView')
后,就在全局作用域生成了 UIView
这个变量,它指向一个这样一个对象:
{
__clsName: "UIView"
}
2. JS 调用接口
与 OC 那样的消息转发机制不同的是,JS 在调用没有定义的属性或者变量时会立马抛出异常。所以若要让JS里 UIView.alloc()
这句调用不出错,唯一的方法就是给 UIView
添加 alloc
方法,不然是不可能调用成功的。
__c()元函数
在 OC 执行 JS 脚本之前,通过正则把所有方法调用都改成调用 __c()
函数,再执行这个 JS 脚本:
UIView.alloc().init()
->
UIView.__c('alloc')().__c('init')()
给 JS 对象基类 Object 加上 __c
成员,这样所有对象都可以调用到 __c
,根据当前对象类型判断进行不同操作:
Object.defineProperty(Object.prototype, '__c', {value: function(methodName) {
if (!this.__obj && !this.__clsName) return this[methodName].bind(this);
var self = this
return function(){
var args = Array.prototype.slice.call(arguments)
return _methodFunc(self.__obj, self.__clsName, methodName, args, self.__isSuper)
}
}})
_methodFunc()
就是把相关信息传给OC,OC用 Runtime 接口调用相应方法,返回结果值,这个调用就结束了。
var _methodFunc = function(instance, clsName, methodName, args, isSuper, isPerformSelector) {
var selectorName = methodName
if (!isPerformSelector) {
methodName = methodName.replace(/__/g, "-")
selectorName = methodName.replace(/_/g, ":").replace(/-/g, "_")
var marchArr = selectorName.match(/:/g)
var numOfArgs = marchArr ? marchArr.length : 0
if (args.length > numOfArgs) {
selectorName += ":"
}
}
var ret = instance ? _OC_callI(instance, selectorName, args, isSuper):
_OC_callC(clsName, selectorName, args)
return _formatOCToJS(ret)
}
3. 消息传递
搞清楚 JS 接口调用问题之后来看看 JS 和 OC 是怎样互传消息的。这里用到了 JavaScriptCore ,OC 在启动 JSPatch 引擎时会创建一个 JSContext
实例,JSContext
是 JS 脚本的运行环境,可以给 JSContext
添加方法,JS 就可以直接调用到这个方法了:
JSContext *context = [[JSContext alloc] init];
context[@"hello"] = ^(NSString *msg) {
NSLog(@"Hello %@", msg);
};
[_context evaluateScript:@"hello('word')"]; //output Hello word
JS 通过调用 JSContext
定义的方法把数据传给 OC,OC通过返回值回传给JS。调用这种方法,它的参数/返回值 JavaScriptCore 都会自动转换,OC 里的 NSArray,NSDictionary,NSString,NSNumber,NSBlock 会分别转为 JS 的数组/对象/字符串/数字/函数类型。
4. 对象持有/转换
经过以上一系列描述之后,我们可以知道 UIView.alloc()
这个类方法的调用是怎样执行的了:
a.
require('UIView')
这句话在 JS 全局作用域生成了 UIView 这个对象,它有个属性叫 __clsName,表示这代表一个 OC 类。b. 调用 UIView 这个对象的
alloc()
方法,会去到__c()
函数,在这个函数里判断到调用者__clsName
属性,知道它是一个 OC 类,把方法名和类名传递给 OC 完成调用。
调用类方法的执行过程基本是这样,但是对象方法呢?事实上,UIView.alloc()
会返回一个 UIView 实例对象给 JS,这个 OC 实例对象在 JS 里又是怎样表示的呢?怎样可以在 JS 拿到这个实例对象后可以直接调用它的对象方法 UIView.alloc().init()
?
对于一个自定义id对象,JavaScriptCore 会把这个自定义对象的指针传给 JS,虽然这个对象在 JS 里没法直接使用,但是在回传给 OC 时,OC 可以找到这个对象。对于这个对象的生命周期,如果 JS 有变量引用则引用计数加1,JS 变量的引用释放就减1。OC 上没有特别的持有者则这个对象的生命周期就跟着 JS 走了,也会在 JS 进行垃圾回收时释放。那么根据上面的描述,如果要在 JS 里调用这个对象的某个示例方法,只需要在 __c()
函数里把这个对象指针以及它要调用的方法名回传给 OC 就行了,所以现在就只剩下一个问题:怎样在 __c()
函数里判断调用者是一个OC对象指针?
JSPatch的解决方案是在 OC 把对象返回给 JS 之前,先把它包装成一个 NSDictionary:
static NSDictionary *_wrapObj(id obj) {
return @{@"__obj": obj};
}
让 OC 对象作为这个 NSDictionary 的一个值,这样在 JS 里这个对象就变成:
{__obj: [OC Object 对象指针]}
这样就可以通过判断对象是否有 __obj
属性得知这个对象是否表示 OC 对象指针,在 __c
函数里若判断到调用者有 __obj
属性,取出这个属性,跟调用的实例方法一起传回给 OC,就完成了实例方法的调用。
5. 类型转换
JS 把要调用的类名/方法名/对象传给 OC 后,OC 调用类/对象相应的方法是通过 NSInvocation 实现的,要想能顺利调用到方法并取得返回值,则要做两件事:
a. 取得要调用的 OC 方法各参数类型,把 JS 传来的对象转为对应的类型进行调用;
b.根据返回值类型取出返回值,包装为对象传回给 JS;
例如开头例子的 view.setAlpha(0.5)
, JS 传递给 OC 的是一个 NSNumber
,OC 需要通过要调用 OC 方法的 NSMethodSignature
得知这里参数要的是一个 float
类型值,于是把 NSNumber
转为 float
值再作为参数进行 OC 方法调用。
方法替换原理
JSPatch 可以用 defineClass
接口任意替换一个类的方法,其基本实现原理介绍如下:
1. 基础原理
在 OC 上,每个类都是以下面这样一个结构体保存的:
struct objc_class {
struct objc_class * isa;
const char *name;
….
struct objc_method_list **methodLists; /*方法链表*/
};
其中 methodLists 方法链表里存储的是 Method 类型的对象:
typedef struct objc_method *Method;
typedef struct objc_ method {
SEL method_name;
char *method_types;
IMP method_imp;
};
Method 保存了一个方法的全部信息,包括 SEL 方法名,type 各参数和返回值类型,IMP 该方法具体实现的函数指针。
通过 Selector
调用方法时,会从 methodLists
链表里找到对应 Method 进行调用,这个 methodLists
上的元素是可以动态替换的,可以把某个 Selector
对应的函数指针 IMP 替换成新的,也可以拿到已有的某个 Selector
对应的函数指针 IMP,让另一个 Selector
跟它对应,Runtime 提供了一些接口做这些事,以替换 UIViewController
的 -viewDidLoad:
方法为例:
static void viewDidLoadIMP (id slf, SEL sel) {
JSValue *jsFunction = …;
[jsFunction callWithArguments:nil];
}
Class cls = NSClassFromString(@"UIViewController");
SEL selector = @selector(viewDidLoad);
Method method = class_getInstanceMethod(cls, selector);
//获得viewDidLoad方法的函数指针
IMP imp = method_getImplementation(method)
//获得viewDidLoad方法的参数类型
char *typeDescription = (char *)method_getTypeEncoding(method);
//新增一个ORIGViewDidLoad方法,指向原来的viewDidLoad实现
class_addMethod(cls, @selector(ORIGViewDidLoad), imp, typeDescription);
//把viewDidLoad IMP指向自定义新的实现
class_replaceMethod(cls, selector, viewDidLoadIMP, typeDescription);
这样就把 UIViewController
的 -viewDidLoad
方法给替换成我们自定义的方法,APP里调用 UIViewController
的 viewDidLoad
方法都会去到上述 viewDidLoadIMP
函数里,在这个新的 IMP 函数里调用 JS 传进来的方法,就实现了替换 viewDidLoad
方法为 JS 代码里的实现,同时为 UIViewController
新增了个方法 -ORIGViewDidLoad
指向原来 viewDidLoad
的 IMP,JS 可以通过这个方法调用到原来的实现。
简单的方法替换就这样简单的实现了,但是这么简单的前提是这个方法没有参数。如果这个方法有参数,怎样把参数传给我们新的 IMP 函数呢?例如 UIViewController
的 -viewDidAppear:
方法,调用者会传一个 Bool 值,我们需要在自己实现的 IMP 上拿到这个值,怎样才能拿到?当然,如果只是针对一个方法写 IMP,是可以直接拿到这个参数值的:
static void viewDidAppear (id slf, SEL sel, BOOL animated) {
[function callWithArguments:@(animated)];
}
但是我们要的是实现一个通用的 IMP,任意方法,任意参数都可以通过这个 IMP 中转,拿到方法的所有参数回调给 JS 的实现。
2. va_list 实现(32位)
static void commonIMP(id slf, SEL sel, ...)
va_list args;
va_start(args, slf);
NSMutableArray *list = [[NSMutableArray alloc] init];
NSMethodSignature *methodSignature = [[slf class] instanceMethodSignatureForSelector:sel];
NSUInteger numberOfArguments = methodSignature.numberOfArguments;
id obj;
for (NSUInteger i = 2; i < numberOfArguments; i++) {
const char *argumentType = [methodSignature getArgumentTypeAtIndex:i];
switch(argumentType[0]) {
case 'i':
obj = @(va_arg(args, int));
break;
case 'B':
obj = @(va_arg(args, BOOL));
break;
case 'f':
case 'd':
obj = @(va_arg(args, double));
break;
…… //其他数值类型
default: {
obj = va_arg(args, id);
break;
}
}
[list addObject:obj];
}
va_end(args);
[function callWithArguments:list];
}
这样无论方法参数是什么,有多少个,都可以通过 va_list的一组方法一个个取出来,组成 NSArray 在调用 JS 方法时传回。很 “完美地解决” 了参数的问题。
3. ForwardInvocation 实现(64位)
当调用一个 NSObject 对象不存在的方法时,并不会马上抛出异常,而是会经过多层转发,层层调用对象的:-resolveInstanceMethod:
--> -forwardingTargetForSelector:
--> -methodSignatureForSelector:
--> -forwardInvocation:
等方法,其中最后 -forwardInvocation:
是会有一个 NSInvocation
对象,这个 NSInvocation
对象保存了这个方法调用的所有信息,包括 Selector
名,参数和返回值类型,最重要的是有 所有参数值 ,可以从这个 NSInvocation
对象里拿到调用的所有参数值。所以我们可以想办法让每个需要被 JS 替换的方法调用最后都调到 -forwardInvocation:
,就可以解决无法拿到参数值的问题了。
具体实现,以替换 UIViewController
的 -viewWillAppear:
方法为例:
把
UIViewController
的-viewWillAppear:
方法通过class_replaceMethod()
接口指向_objc_msgForward
,这是一个全局 IMP,OC 调用方法不存在时都会转发到这个 IMP 上,这里直接把方法替换成这个 IMP,这样调用这个方法时就会走到-forwardInvocation:
;为
UIViewController
添加-ORIGviewWillAppear:
和-_JPviewWillAppear:
两个方法,前者指向原来的 IMP 实现,后者是新的实现,稍后会在新的实现里回调 JS 函数;改写
UIViewController
的-forwardInvocation:
方法为自定义实现。一旦 OC 里调用UIViewController
的-viewWillAppear:
方法,经过上面的处理会把这个调用转发到-forwardInvocation:
,这时已经组装好了一个NSInvocation
,包含了这个调用的参数。在这里把参数从NSInvocation
反解出来,带着参数调用上述新增加的方法-_JPviewWillAppear:
,在这个新方法里取到参数传给 JS,调用 JS 的实现函数;
整个调用过程图解如下:
最后一个问题,我们把 UIViewController
的 -forwardInvocation:
方法的实现给替换掉了,如果程序里真有用到这个方法对消息进行转发,原来的逻辑怎么办?首先我们在替换 -forwardInvocation:
方法前会新建一个方法 -ORIGforwardInvocation:
,保存原来的实现 IMP,在新的 -forwardInvocation:
实现里做了个判断,如果转发的方法是我们想改写的,就走我们的逻辑,若不是,就调 -ORIGforwardInvocation:
走原来的流程。