JSPatch
- 简介
例如线上 APP 有一段代码出现 bug 导致 crash:
@implementation JPTableViewController
...
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
NSString *content = self.dataSource[[indexPath row]]; //可能会超出数组范围导致crash
JPViewController *ctrl = [[JPViewController alloc] initWithContent:content];
[self.navigationController pushViewController:ctrl];
}
...
@end
可以通过下发这样一段 JS 代码,覆盖掉原方法,修复这个 bug:
//JS
defineClass("JPTableViewController", {
//instance method definitions
tableView_didSelectRowAtIndexPath: function(tableView, indexPath) {
var row = indexPath.row()
if (self.dataSource().length > row) { //加上判断越界的逻辑
var content = self.dataArr()[row];
var ctrl = JPViewController.alloc().initWithContent(content);
self.navigationController().pushViewController(ctrl);
}
}
- JSPatch优势
- JSPatch缺点
-
JSPatch 平台速度和稳定性如何?
- SDK接入
pod 'JSPatchPlatform'
pod install
即可。JSPatchPlatform.framework
拖入项目中,勾选 "Copy items if needed",并确保 "Add to target" 勾选了相应的 target。libz.dylib
和 JavaScriptCore.framework。
AppDelegate.m
里载入文件,并调用 +startWithAppKey:
方法,参数为第一步获得的 AppKey。接着调用 +sync
方法检查更新。例子:- 常见问题
decompress error, md5 didn't match
错误(真机无论是否打开都没问题):- SDK API
-application:didFinishLaunchingWithOptions:
开头处调用。+startWithAppKey:
并不会询问后台 patch 更新,必须调用 +sync
方法。+sync
就会请求一次后台,对于实时性要求不高的 APP,只需在 -application:didFinishLaunchingWithOptions:
处调用一次,这样用户会在启动时去同步 patch 信息。对于实时性要求高的 APP,可以在 -applicationDidBecomeActive:
处调用这个接口,这样会在每次用户唤醒 APP 时去同步一次后台,请求次数会增多,但有 patch 更新时用户会及时收到。NSLog()
打出,若你的 APP 有自己的日志系统,希望把 log 打在你的日志系统里,可以在调用 +startWithAppKey 之前调用这个接口:[JSPatch setLogger:^(NSString *msg) {
//msg 是 JSPatch log 字符串,用你自定义的logger打出
YOUR_APP_LOG(@"%@", msg);
}];
+startWithAppKey:
方法,测试完成后需要删除。typedef NS_ENUM(NSInteger, JPCallbackType){
JPCallbackTypeUnknow = 0,
JPCallbackTypeRunScript = 1, //执行脚本
JPCallbackTypeUpdate = 2, //脚本有更新
JPCallbackTypeUpdateDone = 3, //已拉取新脚本
JPCallbackTypeCondition = 4, //条件下发
JPCallbackTypeGray = 5, //灰度下发
};
举例:
[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"
}];
+sync:
之前调用,详见 自定义 RSA 密钥。+setupDevelopment
的客户端生效。DEBUG
时设置,详见 开发预览。- 使用范例
首先项目必须接入 JSPatch SDK,并关联 AppKey,线上版本必须带有这个 SDK。
假设已接入 JSPatch SDK 的某线上 APP 发现一处代码有 bug 导致 crash:
上述代码中取数组元素处可能会超出数组范围导致 crash,对此我们写了如下 JS 脚本准备替换上述方法修复这个 bug:
注意在 JSPatch 平台的规范里,JS脚本的文件名必须是 main.js
。接下来就看如何把这个 JS 脚本下发给所有用户。
测试
在上线之前需要对脚本进行本地测试,看看运行是否正常。SDK 提供了方法 +testScriptInBundle
用于发布前的测试:
调用这个方法后,JSPatch 会在当前项目的 bundle 里寻找 main.js 文件执行,效果与最终线上用户下载脚本执行一样,测试完后就可以准备上线这个脚本。
注意 +testScriptInBundle
不能与 +startWithAppKey:
一起调用,+testScriptInBundle
只用于本地测试,测试完毕后需要去除。
添加版本
进入 JSPatch 平台后台,在我的 APP 里选择这个 APP,点击添加版本。填入当前线上 APP 的版本号,可以在项目 TARGETS -> General -> version 上可以找到:
注意这里版本号必须一致,JSPatch 平台会只针对这个版本号下发对应的 JS 脚本,若版本号对应不上,客户端也就请求不到相应的 JS 脚本。
添加JS脚本
点击进入刚添加的版本,上传 main.js
即可。
上传可以直接全量下发,也可以选择 开发预览 或 灰度或条件下发,也可以使用自定义 RSA key 对脚本进行加密签名。
上传完成后,对应版本的 APP 会请求下载这个脚本保存在本地,以后每次启动都会执行这个脚本。至此线上 bug 修复完成。
修改/删除JS脚本
若后续需要对这个脚本进行修改,可以重新上传新的脚本,APP 客户端会在请求时发现脚本已更新,下载最新脚本覆盖原来的,下次启动时执行。
- 接入扩展
JPDispatch: 提供完整GCD接口
JPLocker: 提供@synchronized接口
JPNumber: 包装 NSNumber
JPProtocol: 提供@protocol接口
JPSpecialInit: 特殊类 UIWebview 和 NSCalendar 的初始化
pod 'JSPatchPlatform'
pod 'JSPatchPlatform/Extensions'
pod 'JSPatchPlatform/JPCFunction'
pod install
即完成接入。- 安全问题
服务端:
- 计算 JS 文件 MD5 值。
- 用 RSA 私钥对 MD5 值进行加密,与JS文件一起下发给客户端。
客户端:
- 拿到加密数据,用 RSA 公钥解密出 MD5 值。
- 本地计算返回的 JS 文件 MD5 值。
- 对比上述的两个 MD5 值,若相等则校验通过,取 JS 文件保存到本地。
- 自定义RSA秘钥
openssl
,再执行以下三句命令,生成 PKCS8 格式的 RSA 公私钥,执行过程中提示输入密码,密码为空(直接回车)就行。openssl >
genrsa -out rsa_private_key.pem 1024
pkcs8 -topk8 -inform PEM -in rsa_private_key.pem -outform PEM –nocrypt
rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem
rsa_private_key.pem
和 rsa_public_key.pem
这两个文件。这里生成了长度为 1024 的私钥,长度可选 1024 / 2048 / 3072 / 4096 ...。+setupRSAPublicKey:
设置自定义的 RSA Public Key,注意应该在 +sync
之前调用,因为 +sync
可能会下载到脚本,这时已经要用 RSA key 去验证了。\n
,例://rsa_public_key.pem
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApgeqKYKPVFk1dk2JGrKv
EaSqqXxU2S1x32xn2M2jWK/lz7YOPRFcPhH8UgBgpUQGqbW2ooOrtlE0Ur6WHOgZ
HvozA71xKEgpQhLbX8ourcyC638zfEQJ3aUezjy5ADzlIAWr3ayBYmLBYj4OkRRG
bffxwA+i16jNVFWJFzgCrRs44cpn+nX0VsNrNjntt59J3xIhMGE+eQ2K9WDwYmv4
sw8+3MsW++z2Uornmi9v2atZnBKd/dBsGz05d++NBks7b2ot/TAiMRnit+VNTZrs
1rYQOcoCJlMUK4GDkK6bdKAPfVcD5vy2PAxDA84P2txcSkFozmZABcVvSyASB6Bn
MQIDAQAB
-----END PUBLIC KEY-----
[JSPatch setupRSAPublicKey:@"-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEApgeqKYKPVFk1dk2JGrKv\nEaSqqXxU2S1x32xn2M2jWK/lz7YOPRFcPhH8UgBgpUQGqbW2ooOrtlE0Ur6WHOgZ\nHvozA71xKEgpQhLbX8ourcyC638zfEQJ3aUezjy5ADzlIAWr3ayBYmLBYj4OkRRG\nbffxwA+i16jNVFWJFzgCrRs44cpn+nX0VsNrNjntt59J3xIhMGE+eQ2K9WDwYmv4\nsw8+3MsW++z2Uornmi9v2atZnBKd/dBsGz05d++NBks7b2ot/TAiMRnit+VNTZrs\n1rYQOcoCJlMUK4GDkK6bdKAPfVcD5vy2PAxDA84P2txcSkFozmZABcVvSyASB6Bn\nMQIDAQAB\n-----END PUBLIC KEY-----"];
下发脚本时在发布脚本界面勾选 使用自定义RSA Key
选项,会出现文件上传框,选择本地的 rsa_private_key.pem
文件,与脚本一同上传,JSPatch 平台会使用这个上传的 Private Key 对脚本 MD5 值进行加密,再下发给客户端。若客户端经过上述第二步设置了对应的 Public Key,就会用设置的 Public Key 对脚本进行验证,验证通过后运行脚本,否则不会运行。
rsa_private_key.pem
只是一次性使用,不会保存在服务端,所以只有通过用户自己保存的 rsa_private_key.pem
文件才可以针对 APP 下发脚本,即使 JSPatch 平台或者七牛云被黑,第三方也无法对你的 APP 下发恶意脚本(可以下发,但验证不过,不会执行),保证安全性。rsa_private_key.pem
请妥善保管,避免泄露。- 开发预览
+sync
之前调用 setupDevelopment
方法,建议只在 debug 模式下开启:[JSPatch startAppWithKey:@""];
#ifdef DEBUG
[JSPatch setupDevelopment];
#endif
[JSPatch sync];
开发预览
,就可以在 debug 模式下测试这个补丁。测试完成后可以选择全量下发或灰度/条件下发,下发给现网用户。- 灰度与条件下发
SDK 1.2
版本开始支持脚本的灰度与条件下发。userId==10000876
, iOS>9.0&&isMale==1
。条件语句里用到的 key/value 需要事先在 APP 里通过 +setupUserData:
设置,支持设置多个字段,用 NSDictionary 表示,例如可以设置当前登录的用户ID以及性别:
//_userId = @"1000876"
//_isMale = @(1)
[JSPatch setupUserData:@{@"userId": _userId, @"isMale": _isMale}];
这样在下发脚本时填入条件 userId==1000876
后,这个脚本就只对这个用户生效,如果填入 isMale==0
则对这个用户不生效,对其他在 SDK 设置了 @"isMale": @"0"
的用户生效。
条件语句规则
- 支持符号
&&
||
==
!=
>=
<=
>
<
,意思跟程序里一样。 - 用比较符号时
>=
<=
>
<
会把值转为数值进行对比。例如userId>200000
,即使客户端调用+setupUserData:
接口时设置的 userId 字段是字符串,也会转为数值进行对比。 - 使用
==
!=
符号时,会以字符串形式判断是否相等,例如1.0 == 1
结果是 NO。 - 等式的值不需要引号,字符串也不需要,例如:
location!=guangdong
- 支持多个条件,例如:
userId!=31242&&location==guangdong&&name==bang
- 若多个条件里同时有
&&
和||
,&&
的优先级较高。例如userId<200000||location==guangdong&&name==bang
,会先分别计算userId<200000
和location==guangdong&&name==bang
的结果,再进行||
运算。
在发布脚本1时设条件为 userId==1000876
,某设备A设置了 @{@"userId": @"1000876"}
命中了这个条件,执行了这个脚本1。设备B设置了 @{@"userId": @"2000876"}
没有命中。
接着在后台修改条件为 userId>=2000000
,设备A并不符合这个条件,但因为之前的条件命中过,所以设备A不会再受这个改变影响,继续执行脚本1。设备B命中了这个条件,也执行了脚本1。
此外若想撤销条件全量发布,提交空条件即可。
iOS
和 isPad
,分别表示 iOS 版本号和是否iPad,不需要设置就可以拿这两个字段用于条件判断。例如只针对 iOS8 的 iPad 下发,可以直接写这个条件:iOS>=8.0&&iOS<9.0&&iPad==1
。
注意 iOS 版本号只会精确到两位,例如 9.2.1 会记录成 9.2,iOS==9.2
会命中 9.2.x 版本。
注意事项
+setupUserData:
接口要在+sync:
接口之前调用。- 对于
SDK 1.1
及以下版本会无视任何条件和灰度值,直接全量接收。
- 在线参数
参数名:name
参数值:bang
。+application:didFinishLaunchingWithOptions:
里调用 +updateConfigWithAppKey:
方法,传入 appKey
,APP 就会在调用处发请求获取刚才设置的在线参数。+getConfigParams
拿到所有参数,也可以通过 +getConfigParam:
接口拿到单个参数,例如:NSDictionay *configs = [JSPatch getConfigParams];
//configs == @{@"name": @"bang"}
NSString *name = [JSPatch getConfigParam:@"name"];
//name == bang
+updateConfigWithAppKey:
的请求返回时进行一些操作,可以通过 + setupUpdatedConfigCallback:
接口设置 callback:[JSPatch setupUpdatedConfigCallback:^(NSDictionary *configs, NSError *error) {
NSLog(@"%@ %@", configs, error);
}];
为了避免重复请求浪费资源,默认 +updateConfigWithAppKey:
接口请求时间间隔至少为30分钟,也就是30分钟内多次调用 +updateConfigWithAppKey:
只会请求一次。若想 APP 对在线参数响应更实时,可以通过 +setupConfigInterval:
接口修改这个间隔值。
- 在线参数功能与 JSPatch 脚本下发功能独立,互不影响。
- 在线参数的计费方式同样按请求次数计算,每调用一次
+updateConfigWithAppKey:
方法算一次请求。
- 实时监控(new)
[JSPatch sync]
时重新拉取,重试 3 次失败后,会上报失败数据,在实时监控这里也可以看到每一条失败数据以及对应的错误码,方便排查问题。- CLI API
POST http://jspatch.com/Apps/uploadPatch
@params email 登录邮箱
@params password 登录密码
@params appKey APP唯一键值
@params appVersion APP版本号
@params gray (可选)灰度策略,值为1-9,代表10%-90%
@params condition (可选)条件下发
@params patch[] 补丁文件
@params rsaKey (可选)rsa private密钥文件
//失败返回
@return {errMsg: ''}
//成功返回
@return {succ: 1, patchVersion: {$patchVersion}}
curl -F 'email=test@qq.com' -F 'password=test1234' -F 'appKey=2ba21d234fa69915' -F 'appVersion=2.0' -F 'gray=4' -F 'patch[]=@main.js' http://jspatch.com/Apps/uploadPatch
POST http://jspatch.com/Apps/updatePatch
@params email 登录邮箱
@params password 登录密码
@params appKey APP唯一键值
@params appVersion APP版本号
@params gray (可选)修改灰度策略,值为1-9,代表10%-90%
@params condition (可选)修改条件下发规则
@params all (可选)修改为全量下发
//失败返回
@return {errMsg: ''}
//成功返回
@return {
succ: 1,
patch: {
patchID: 5804,
gray: 3,
condition:null,
isDev:0
}
}
curl -F 'email=test@qq.com' -F 'password=test1234' -F 'appKey=2ba21d234fa69915' -F 'appVersion=2.0' -F 'condition=userId=21' http://jspatch.com/Apps/updatePatch
- 常见问题
- 不能用
NSLog('xx')
,应该用console.log('xx')
- get property 记得加括号,例如
self.navigationItem()
,而不是self.navigationItem
- 私有成员变量要用
self.valueForKey()
和self.setValue_forKey()
接口存取。 - block 里不能直接使用
self
+testScriptInBundle
接口执行脚本看有没有问题,(详情参照使用范例),若没达到预期效果,可以一步步调试,第一步请在 main.js
开头打 console.log('run success')
,确定 XCode 控制台有输出这条 log,确定脚本有被执行到,再进行其他调试。一般调试使用 console.log()
就足够,若有更多需求可以用 Safari断点调试。appKey
和 版本号
没有错误。2016-04-27 19:04:42.212 ... JSPatch: runScript
2016-04-27 19:04:42.399 ... JSPatch: evaluated script, length: 28
2016-04-27 19:04:42.399 ... JSPatch: request http://7xkfnf.com1.z0.glb.clouddn.com/6d2fddf24c5d8af2/1.0?v=1461755082.399732
2016-04-27 19:04:42.621 ... JSPatch: request success {
v = 2;
}
这两句表示请求到了当前版本补丁版本号,这里 url 里的 6d2fddf24c5d8af2
是 appkey
,后续跟的 1.0
是 App 版本号,可以检查下这两个值是否正确。若 url 不正确或者脚本没有正确上传,这里会返回 error = "Document not found"
。
--
2016-04-27 19:09:43.798 ... JSPatch: updateToVersion: 2
2016-04-27 19:09:43.798 ... JSPatch: request file http://7xkfnf.com1.z0.glb.clouddn.com/6d2fddf24c5d8af2/1.0/file2
2016-04-27 19:09:43.900 ... JSPatch: request file success, data length:3072
2016-04-27 19:09:43.908 ... JSPatch: updateToVersion: 2 success
这几句表示检测到的补丁版本号比本地版本更新,去下载补丁文件,下载后会立即执行,到这一步应该就没问题了。若这个版本的补丁之前已经下载过,就不会再下载。
新建 APP 版本 -> 上传补丁 -> 删除APP版本 -> 新建同一个APP版本 -> 上传补丁
,会中缓存逻辑,导致请求到的脚本是删除 APP 之前上传的旧补丁。这时只需要再上传一次补丁更新版本号就可以了。decompress error, md5 didn't match
错误(真机无论是否打开都没问题):