JSPatch的简单整理

简单整理了这个是由于某些需要,其实作者文档有很详细的介绍,包括原理。大部分也都是直接摘录下来的。

bang590/JSPatch

JSPatch 是一个 iOS 动态更新框架,只需在项目中引入极小的引擎,就可以使用 JavaScript 调用任何 Objective-C 原生接口,获得脚本语言的优势:为项目动态 添加模块 ,或 替换项目原生代码动态修复 bug。

1. 流程

主要的过程是,在JP平台(或者自己后台)发布补丁JS文件,选择下发方式(全量、灰度、开发等)。客户端接入JPSDK(或者使用自己服务的下载更新逻辑),自动更新下载最新的js执行。

2. 原理

1). 基础原理

通过 Objective-C Runtime 在运行时,可以通过类名/方法名反射得到相应的类和方法。

  • 也可以替换某个类的方法为新的实现.

  • 还可以新注册一个类,为类添加方法.

基本原理就是:JS 传递字符串给 OC,OC 通过 Runtime 接口调用和替换 OC 方法

2).JS接口实现

首先看下如何用JS调用OC方法的

require('UIView');
var view = UIView.alloc().init();

在js中这样的语法是会报错的,作者的做法是在加载我们的补丁main.js
文件之前先在JSPatch.js中加载了一个匿名自执行函数。主要作用就是用于替换这些接口。

通过正则把所有方法调用都改成调用 __c() 函数>

给 JS 对象基类 Object 的 prototype 加上 __c成员,这样所有对象都可以调用到 __c,根据当前对象类型判断进行不同操作.

具体做法:在OC加载main.js的时候,修改了js代码,调用了这个匿名函数并利用正则增加了__c的调用

 NSString *formatedScript = [NSString stringWithFormat:
 @";(function(){try{%@}catch(e){_OC_catch(e.message, e.stack)}})();",
  [_regex stringByReplacingMatchesInString:script options:0 range:NSMakeRange(0, script.length) withTemplate:_replaceStr]];

所以在main.js执行的时候 已经转化为如下,allocinit被转化为了字符串。

;(function(){
    try{require('UIView');
    var view = UIView.__c("alloc")().__c("init")();
}
catch(e){
    _OC_catch(e.message, e.stack)
}
})();

3). 消息传递

在JS接口完成之后,要做的就是传递给OC,使用的JavaScriptCore的接口。在JPEngine启动的时候,定义好对应的执行block。在执行js中的definClass方法替换的时候就会调用runtime,实现方法替换。

context[@"_OC_defineClass"] = ^(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) 
{
    return defineClass(classDeclaration, instanceMethods, classMethods);
};

context[@"_OC_defineProtocol"] = ^(NSString *protocolDeclaration, JSValue *instProtocol, JSValue *clsProtocol) 
{
    return defineProtocol(protocolDeclaration, instProtocol,clsProtocol);
 };

4). 方法替换

这里没有简单的使用class_replaceMethod来进行方法的替换,因为考虑到参数的原因。
当调用一个 NSObject 对象不存在的方法时,并不会马上抛出异常,而是会经过多层转发,层层调用对象的 -resolveInstanceMethod:;

-forwardingTargetForSelector:;

-methodSignatureForSelector:, -forwardInvocation: 等方法.

在第三个阶段 -forwardInvocation: 是会有一个 NSInvocation 对象,这个 NSInvocation 对象保存了这个方法调用的所有信息,包括 Selector 名,参数和返回值类型,最重要的是有所有参数值,可以从这个 NSInvocation 对象里拿到调用的所有参数值。

具体实现,以替换 UIViewController 的 -viewWillAppear: 方法为例:

  1. 把UIViewController的 -viewWillAppear: 方法通过 class_replaceMethod() 接口指向 _objc_msgForward,这是一个全局 IMP,OC 调用方法不存在时都会转发到这个 IMP 上,这里直接把方法替换成这个 IMP,这样调用这个方法时就会走到 -forwardInvocation:
  1. 为UIViewController添加 -ORIGviewWillAppear: 和 -_JPviewWillAppear: 两个方法,前者指向原来的IMP实现,后者是新的实现,稍后会在这个实现里回调JS函数。
  1. 改写UIViewController的 -forwardInvocation: 方法为自定义实现。一旦OC里调用 UIViewController 的 -viewWillAppear: 方法,经过上面的处理会把这个调用转发到 -forwardInvocation: ,这时已经组装好了一个 NSInvocation,包含了这个调用的参数。在这里把参数从 NSInvocation 反解出来,带着参数调用上述新增加的方法 -JPviewWillAppear: ,在这个新方法里取到参数传给JS,调用JS的实现函数。整个调用过程就结束了。
调用过程
调用过程

3. 使用

详细的JSPatch使用方式在文档中都有具体的介绍,基本涵盖了OC使用过程中需要用到的大部分情况。

JSPatch 基础用法

以下是比较常用的几种:


1. require

在使用Objective-C类之前需要调用 require('className’):

require('UIView, UIColor')
var view = UIView.alloc().init()
var red = UIColor.redColor()

或者直接在使用时才调用 require() :

require('UIView').alloc().init()

2. 调用方法

  • 调用类方法
var redColor = UIColor.redColor();
  • 调用实例方法
var view = UIView.alloc().init();
view.setNeedsLayout();
  • 获取/修改 Property 等于调用这个 Property 的 getter / setter 方法,获取时记得加 ():
view.setBackgroundColor(redColor);
var bgColor = view.backgroundColor();
  • 方法名转换 多参数方法名使用 _ 分隔:
var indexPath = require('NSIndexPath').indexPathForRow_inSection(0, 1);

3. defineClass

defineClass(classDeclaration, [properties,] instanceMethods, classMethods)

@param classDeclaration: 字符串,类名/父类名和Protocol
@param properties: 新增property,字符串数组,可省略
@param instanceMethods: 要添加或覆盖的实例方法
@param classMethods: 要添加或覆盖的类方法
  • 覆盖实例方法
// 覆盖viewDidAppear
defineClass("HYWebViewController", {
    viewDidAppear: function(animated) {
    self.super().viewDidAppear(animated);
    console.log("JS---viewDidAppear---jspatch test");
      },
 });
  • 在方法名前加 ORIG 即可调用未覆盖前的 OC 原方法:
// JS
defineClass("HYWebViewController", {
  viewDidLoad: function() {
     self.ORIGviewDidLoad();
  },
})
  • 类方法
// JS
defineClass("JPTableViewController", {
  //实例方法
}, {
  //类方法
  shareInstance: function() {
    ...
  },
})
  • 覆盖 Category 方法与覆盖普通方法一样。
  • 使用 self.super() 接口代表 super 关键字,调用 super 方法
  • 动态新增 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()
  },
})
  • Protocol
    可以在定义时让一个类实现某些 Protocol 接口,写法跟 OC 一样:
defineClass("JPViewController: UIViewController<UIScrollViewDelegate, UITextViewDelegate>", {
})

这样做的作用是,当添加 Protocol 里定义的方法,而类里没有实现的方法时,参数类型不再全是 id,而是自动转为 Protocol 里定义的类型:

// objc
@protocol UIAlertViewDelegate <NSObject>
...
- (void)alertView:(UIAlertView *)alertView clickedButtonAtIndex:(NSInteger)buttonIndex;
...
@end
// js
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)
  }
})

4. 特殊类型

  • Struct

JSPatch原生支持 CGRect / CGPoint / CGSize / NSRange 这四个 struct 类型,用 JS 对象表示:

// Obj-C
UIView *view = [[UIView alloc] initWithFrame:CGRectMake(20, 20, 100, 100)];
[view setCenter:CGPointMake(10,10)];
[view sizeThatFits:CGSizeMake(100, 100)];
CGFloat x = view.frame.origin.x;

NSRange range = NSMakeRange(0, 1);
// JS
var view = UIView.alloc().initWithFrame({x:20, y:20, width:100, height:100})
view.setCenter({x: 10, y: 10})
view.sizeThatFits({width: 100, height:100})

var x = view.frame().x
var range = {location: 0, length: 1}
  • Selector

在JS使用字符串代表 Selector:

//Obj-C
[self performSelector:@selector(viewWillAppear:) withObject:@(YES)];
//JS
self.performSelector_withObject("viewWillAppear:", 1)

5. nil

JS 上的 null 和 undefined 都代表 OC 的 nil,如果要表示 NSNull, 用 nsnull 代替,如果要表示 NULL, 也用 null 代替

6. NSArray / NSString / NSDictionary

NSArray / NSString / NSDictionary 不会自动转成对应的JS类型,像普通 NSObject 一样使用它们:

//Obj-C
@implementation JPObject
+ (NSArray *)data
{
  return @[[NSMutableString stringWithString:@"JS"]]
}
+ (NSMutableDictionary *)dict
{
    return [[NSMutableDictionary alloc] init];
}
@end
// JS
require('JPObject')
var ocStr = JPObject.data().objectAtIndex(0)
ocStr.appendString("Patch") // 当成oc对象使用

var dict = JPObject.dict()
dict.setObject_forKey(ocStr, 'name')
console.log(dict.objectForKey('name')) //output: JSPatch

如果要把 NSArray / NSString / NSDictionary 转为对应的 JS 类型,使用 .toJS() 接口:

// JS
var data = require('JPObject').data().toJS()
//data instanceof Array === true
data.push("Patch") // 可以作为js对象使用

var dict = JPObject.dict()
dict.setObject_forKey(data.join(''), 'name')
dict = dict.toJS()
console.log(dict['name'])    //output: JSPatch

7. Block

block 传递

当要把 JS 函数作为 block 参数给 OC时,需要先使用 block(paramTypes, function) 接口包装:

// Obj-C
@implementation JPObject
+ (void)request:(void(^)(NSString *content, BOOL success))callback
{
  callback(@"I'm content", YES);
}
@end
// JS
require('JPObject').request(block("NSString *, BOOL", function(ctn, succ) {
  if (succ) log(ctn)  //output: I'm content
}))

这里 block 里的参数类型用字符串表示,写上这个 block 各个参数的类型,用逗号分隔。NSObject 对象如 NSString *, NSArray 等可以用 id 表示,但 block 对象要用 NSBlock 表示。

从 OC 返回给 JS 的 block 会自动转为 JS function,直接调用即可:

// Obj-C
@implementation JPObject
typedef void (^JSBlock)(NSDictionary *dict);
+ (JSBlock)genBlock
{
  NSString *ctn = @"JSPatch";
  JSBlock block = ^(NSDictionary *dict) {
    NSLog(@"I'm %@, version: %@", ctn, dict[@"v"])
  };
  return block;
}
+ (void)execBlock:(JSBlock)blk
{
}
@end
// JS
var blk = require('JPObject').genBlock();
blk({v: "0.0.1"});  //output: I'm JSPatch, version: 0.0.1

若要把这个从 OC 传过来的 block 再传回给 OC,同样需要再用 block() 包装,因为这里 blk 已经是一个普通的 JS function,跟我们上面定义的 JS function 没有区别:

// JS
var blk = require('JPObject').genBlock();
blk({v: "0.0.1"});  //output: I'm JSPatch, version: 0.0.1
require('JPObject').execBlock(block("id", blk));

总结:JS 没有 block 类型的变量,OC 的 block 对象传到 JS 会变成 JS function,所有要从 JS 传 block 给 OC 都需要用 block() 接口包装。

block 里使用 self 变量

在 block 里无法使用 self 变量,需要在进入 block 之前使用临时变量保存它:

defineClass("JPViewController", {
  viewDidLoad: function() {
    var slf = self;
    require("JPTestObject").callBlock(block(function(){
      //`self` is not available here, use `slf` instead.
      slf.doSomething();
    });
  }
}

限制

从 JS 传 block 到 OC,有两个限制:

A. block 参数个数最多支持6个。(若需要支持更多,可以修改源码)
B. block 参数类型不能是 double。

另外不支持 JS 封装的 block 传到 OC 再传回 JS 去调用(原因见 issue #155):

- (void)callBlock:(void(^)(NSString *str))block {
}
defineClass('JPTestObject', {
    run: function() {
        self.callBlock(block('NSString*', function(str) {
            console.log(str);
        }));
    },
    callBlock: function(blk) {
        //blk 这个 block 是上面的 run 函数里 JS 传到 OC 再传过来的,无法调用。
        blk("test block");   
    }
});

8. 加载动态库

对于 iOS 内置的动态库,若原 APP 里没有加载,可以通过以下方式动态加载,以加载 SafariServices.framework 为例:

var bundle = NSBundle.bundleWithPath("/System/Library/Frameworks/SafariServices.framework");
bundle.load();

加载后就可以使用 SafariServices.framework 了。

9. 调试

可以使用 console.log() 打印一个对象,作用相当于 NSLog(),会直接在 XCode 控制台打出。

console.log() 支持任意参数,但不支持像 NSLog 这样 NSLog(@"num:%f", 1.0) 的拼接:

var view = UIView.alloc().init();
var str = "test";
var num = 1;
console.log(view, str, num)
console.log(str + num);   //直接在JS拼接字符串

也可以通过 Safari 的调试工具对 JS 进行断点调试,详见 JS 断点调试

4.JPSDK集成相关API

    
    // 若需要替换自己的打印格式
     [JSPatch setLogger:^(NSString *msg) {
        // msg 是 JSPatch log 字符串,用你自定义的logger打出
        YOUR_APP_LOG(@"%@", msg);
    }];
     
    
    /*
      发布前测试脚本 +testScriptInBundle 会寻找main.js执行
      不能同时调用 +startWithAppKey: 方法
     */
     [JSPatch testScriptInBundle];
    
    
    /**
      JSPatch 执行过程中的事件回调
      //执行脚本   //脚本有更新  //已拉取新脚本 //条件下发   //灰度下发
     */
     [JSPatch setupCallback:^(JPCallbackType type, NSDictionary *data, NSError *error) {
         switch (type) {
              case JPCallbackTypeUpdate: {
                    NSLog(@"updated %@ %@", data, error);
              break;
        }
              case JPCallbackTypeRunScript: {
                  NSLog(@"run script %@ %@", data, error);
              break;
        }
              default:
              break;
    }
}];
    
    
    /**
        定义用户属性 控制条件下发 必须在sync前调用 
     */

        [JSPatch setupUserData:@{
             @"userId": @"100867",
             @"location": @"guangdong"
        }];
        
        
    /**
        自定义 RSA key,在 +sync: 之前调用,
     */
    [JSPatch setupRSAPublicKey:];
    
    /**
     发布时 选择开发预览 只会对这部分设备有效
     */
#ifdef DEBUG
    [JSPatch setupDevelopment];
#endif


    // 检查更新 若频率高可在-applicationDidBecomeActive:
    [JSPatch sync];
        

5.简单的替换 新增测试

// 覆盖viewDidLoad
defineClass('RootViewController', {
            viewDidLoad: function(){
            // ORIG 即可调用未覆盖前的 OC 原方法:
            // self.ORIGviewDidLoad();
            self.super().viewDidLoad();
            console.log("JS---viewDidLoad---viewDidLoad");

            // 要使用一个类前
            require('NSUserDefaults');
            // 或者是直接使用 var firstLoad = require('NSUserDefaults').standardUserDefaults();
            var firstLoad = NSUserDefaults.standardUserDefaults();
            require('NSString');
            var saveVersion = NSString.alloc().init();
            saveVersion = firstLoad.objectForKey("YENT_Version_Flag");
          
            require('HYUtil');
            var currentVersion = HYUtil.getCurrentVersion();
            self.labelTitle().setText("首页");
            
            self.setRequestUrl(HYUtil.getServerURL("/main.html#example/index/index"
));
            self.tabbar().setSelectedIndex(0);
            self.loadRequest();
            
            require('HYString');
            if(HYString.isValid(saveVersion))
            {
              //非第一次安装
              var iSaveVersion = saveVersion.stringByReplacingOccurrencesOfString_withString(".","").integerValue();
              var iCurrentVersion = currentVersion.stringByReplacingOccurrencesOfString_withString(".","").integerValue();
              //判断版本是否更新
              if (iCurrentVersion>iSaveVersion)
             {
                  firstLoad.setObject_forKey(currentVersion,"YENT_Version_Flag");
                  firstLoad.synchronize();

                  self.initGuide();
              }
              else
              {
                  self.initRoot();
              }
            }
            else
            {
                //第一次安装
                firstLoad.setObject_forKey(currentVersion,"YENT_Version_Flag");
                firstLoad.synchronize();
                
                self.initGuide();
             }
            
            // 新增测试
            self.showMsg();


      },
  });

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

推荐阅读更多精彩内容