JSPatch 从入门到放弃

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)

覆盖对象方法

  1. 在defineClass里定义OC已存在的方法即可覆盖,方法名规则与调用规则一致:

    // OC
    @implementation JPTableViewController
    - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
    {
    }
    @end
    
    // JS
    defineClass("JPTableViewController", {
      tableView_didSelectRowAtIndexPath: function(tableView, indexPath) {
        ......
      },
    })
    
  2. 使用双下划线代表原OC方法名里的下划线:

    // OC
    @implementation JPTableViewController
    - (NSArray *) _dataSource {
    }
    @end
    
    // JS
    defineClass("JPTableViewController", {
      __dataSource: function() {
      },
    })
    
  3. 在方法名前面加 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里调用 UIViewControllerviewDidLoad 方法都会去到上述 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: 方法为例:

  1. UIViewController-viewWillAppear: 方法通过 class_replaceMethod() 接口指向 _objc_msgForward ,这是一个全局 IMP,OC 调用方法不存在时都会转发到这个 IMP 上,这里直接把方法替换成这个 IMP,这样调用这个方法时就会走到 -forwardInvocation:;

  2. UIViewController 添加 -ORIGviewWillAppear:-_JPviewWillAppear: 两个方法,前者指向原来的 IMP 实现,后者是新的实现,稍后会在新的实现里回调 JS 函数;

  3. 改写 UIViewController-forwardInvocation: 方法为自定义实现。一旦 OC 里调用 UIViewController-viewWillAppear: 方法,经过上面的处理会把这个调用转发到 -forwardInvocation: ,这时已经组装好了一个 NSInvocation,包含了这个调用的参数。在这里把参数从 NSInvocation 反解出来,带着参数调用上述新增加的方法 -_JPviewWillAppear:,在这个新方法里取到参数传给 JS,调用 JS 的实现函数;

整个调用过程图解如下:

过程图

最后一个问题,我们把 UIViewController-forwardInvocation: 方法的实现给替换掉了,如果程序里真有用到这个方法对消息进行转发,原来的逻辑怎么办?首先我们在替换 -forwardInvocation: 方法前会新建一个方法 -ORIGforwardInvocation:,保存原来的实现 IMP,在新的 -forwardInvocation: 实现里做了个判断,如果转发的方法是我们想改写的,就走我们的逻辑,若不是,就调 -ORIGforwardInvocation: 走原来的流程。

Demo

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,937评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,503评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,712评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,668评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,677评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,601评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,975评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,637评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,881评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,621评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,710评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,387评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,971评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,947评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,189评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,805评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,449评论 2 342

推荐阅读更多精彩内容