iOS_swift项目热修复
2020-11-04 本文已影响0人
彩色大猩猩
1.业界常用方案
1)主要分为两类
接入成本 | 名称 | 思路 |
---|---|---|
成本低 | JSPatch、MangoFix、Rollout.io、DynamicCocoa | 利用runtime 1)找到这个函数。 2)找到之后修改、替换这个函数。 |
成本高 | MLN、Weex、React Native、Hybrid、flutter | 一套代码(js)多端运行,类似前端页面的方式热修复热更新,不需走苹果审核。 |
注:
成本低: 引入极小的引擎文件,即系统内置的 JavaScriptCore.framework,只对出问题的函数打补丁。
成本高: 需要修改整个项目框架,使用JavaScript/Dart/lua语言开发所有功能。
2)详情
框架 | 是否支持swift | 是否通过苹果审核 | 接入成本 | 语言 | 公司 | 备注 |
---|---|---|---|---|---|---|
JSPatch | YES | YES | 低 | js | 阿里 | 平台版收费 |
MangoFix | NO | YES | 低 | DSL(类似OC) | – | 完美对接OC项目 |
Rollout.io | YES | YES | 低 | js | 美国一家公司 | 需要翻墙 |
DynamicCocoa | – | – | 低 | oc自动生成js | 滴滴 | 未开源 |
MLN | – | YES | 高 | lua脚本 | 陌陌 | 需要添加lua引擎 |
Weex | – | YES | 高 | js | – | 使用vue.js |
React Native | – | YES | 高 | js | 使用React。React:用于构建用户界面的 JavaScript 库 | |
Hybrid | – | YES | 高 | js | – | – |
flutter | – | YES | 高 | Dart | – | – |
2.JSPatch
JSPatch 是阿里的一个开源项目(Github链接),只需要在项目里引入极小的引擎文件JavaScriptCore.framework,就可以使用 JavaScript 调用任何 Objective-C 的原生接口。利用 Runtime 进行属性、方法修改,替换任意原生方法。达到通过下发 JS 脚本替换原生 Objective-C 代码,实时修复线上 bug。
1)Swift项目使用JSPatch时,需要进行一些修改
- 属性值修改,只需添加 @objc 即可。
- JSPatch 调用的方法只具有 @objc 即可,不需要 dynamic。
- JSPatch 重写的方法需要具备 @objc 和 dynamic 性质。
注:
原因是Objective-C 和 Swift 在底层使用的是两套完全不同的机制,Cocoa 中的 Objective-C 对象是基于运行时的,支持动态派发 (Dynamic Dispatch,在运行调用时再决定实际调用的具体实现)。而 Swift 为了追求性能,如果没有特殊需要的话,是不会在运行时再来决定这些的。也就是说,Swift 类型的成员或者方法在编译时就已经决定,而运行时便不再需要经过一次查找,而可以直接使用。
当Swift想使用运行时,则需加上@objc、dynamic。
- @objc
Objective-C调用Swift中的类、属性和方法等声明时,前面需加上 @objc 修饰符- dynamic
表明是动态调用。因为添加 @objc 修饰符并不意味着这个方法或者属性会变成动态派发,Swift 依然可能会将其优化为静态调用。如果需要使用 Objective-C 运行时特性的话,则需要添加修饰符 dynamic。
2)注意
- Struct 结构体不能使用JSPatch。只支持调用继承自 NSObject 的 Swift 类。
因为Struct是值类型,当值传递时,它会copy传递的值。无法使用runtime。
而Class是引用类型,当值传递时,是传递对已有instance的引用。 - 继承自 NSObject 的 Swift 类,其继承自父类的方法和属性可以在 JS 调用,其他自定义方法和属性同样需要加 dynamic 关键字才行。
- 若方法的参数/属性类型为 Swift 特有(如 Character / Tuple),则此方法和属性无法通过 JS 调用。
- 使用时必须选择接入 JSPatch 平台。
- 官方公告有概率审核不通过。
3)在被审核禁止后,做了哪些事,可以继续使用JSPatch?
- 被禁止原因:
风险点1:APP接入JSPatch。无法保证每个 APP 接入方式都是安全的,容易传输过程被替换,被黑客中间人攻击,成为 iOS 上的一个漏洞。
解决:统一安全接入。在传输过程中配置RSA密钥对。(对使用者负责)
风险点2:SDK接入JSPatch。一些像地图/推送类 SDK 接入 JSPatch 后覆盖大量 APP,若这些 SDK 后台被攻破,可以对这些 APP 下发恶意脚本,造成大面积危害。
解决:SDK已经安全接入。接入SDK后的APP也安全。(对使用者负责)
官方建议开发者不能自行接入,需要统一接入 JSPatch 平台。 - 通过苹果审核:
通过做了简单的类名修改混淆后可通过审核。
4)Swift使用方法
(JSPatch平台版)
- 项目中添加 JSPatchPlatform.framework。
- 添加依赖框架 JavaScriptCore.framework。
- 生成并配置RSA密钥对,在jspatch服务端下发补丁时使用私钥进行签名,在客户端收到补丁时使用公钥验证,防止攻击者通过篡改或伪造补丁对客户端进行攻击。
- Swift项目,由于JSPatch平台版 JSPatchPlatform.framework 里的 “Header”文件定义了与热修复类、方法相同的宏,导致 Swift 无法直接桥接。需要定义一个Object-C的桥接对象,进行桥接。
桥接文件:Patch.h
#import <JSPatchPlatform/JSPatch.h>
NS_ASSUME_NONNULL_BEGIN
@interface Patch : NSObject
/**
开始配置热修复
*/
+ (void)start;
start
/**
同步补丁
*/
+ (void)sync;
/**
本地测试补丁
*/
+ (void)testScript;
@end
NS_ASSUME_NONNULL_END
桥接文件:Patch.m
#import "Patch.h"
#define appKey @"xxxx"
#define publicKey @"-----BEGIN PUBLIC KEY-----xxxx-----END PUBLIC KEY-----"
@implementation Patch
+ (void)start {
[JSPatch startWithAppKey:appKey];
[JSPatch setupRSAPublicKey:publicKey];
}
+ (void)sync {
[JSPatch sync];
}
+ (void)testScript {
[JSPatch testScriptInBundle];
}
@end
- 启动运行。
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// 从平台远程下载脚本并运行
Patch.start()
Patch.sync()
// 脚本本地测试,本地测试和远程下载不可共存
// Patch.testScript()
return true
}
func applicationDidBecomeActive(_ application: UIApplication) {
// 如果想要每次唤醒App都能同步更新补丁,不用等到用户下次启动App。在此方法中调用
Patch.sync()
}
5)JavaScript脚本
- 文件名必须是main.js
- 使用 defineClass() 覆盖 Swift 类时,类名应为 项目名.原类名,举例:
defineClass("JSPatchExample.ViewController", {})
- 调用自定义方法时,采用 方法名:function(参数) {},举例:
setData:function() {
console.log("js点击");
self.setTitleString("js点击");
}
- 完整例子:
错别字修改:
defineClass("JSPatchExample.ViewController", {
setData:function() {
console.log("js点击");
self.setTitleString("js点击");
}
})
点击cell引起数组越界奔溃修复:
defineClass("JSPatchExample.CrashViewController", {
collectionView_didSelectItemAtIndexPath:function(collectionView,indexPath) {
console.log("js修改index不要越界123");
var index = 1;
self.dataArr = ["first element","sencond element"];
if (index > 0 && index < self.dataArr.length) {
console.log(self.dataArr.slice(6));
console.log("JSPatch66666")
}
}
})
6)源码解读
- JPEngine.m中
JSContext创建js与ios交互的上下文。传入一个方法名,js中就可以调用这个方法,执行block中的这段代码。参数列表也是从js中调用方法的时候传入,oc这边接收。
JSContext *context = [[JSContext alloc] init];
context[@"_OC_defineClass"] = ^(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) {
return defineClass(classDeclaration, instanceMethods, classMethods);
};
- JSPatch.h中
// 定义了全局的 defineClass 方法为 function(declaration, properties, instMethods, clsMethods) { }
global.defineClass = function(declaration, properties, instMethods, clsMethods) {
var newInstMethods = {}, newClsMethods = {}
if (!(properties instanceof Array)) {
clsMethods = instMethods
instMethods = properties
properties = null
}
if (properties) {
properties.forEach(function(name){
if (!instMethods[name]) {
instMethods[name] = _propertiesGetFun(name);
}
var nameOfSet = "set"+ name.substr(0,1).toUpperCase() + name.substr(1);
if (!instMethods[nameOfSet]) {
instMethods[nameOfSet] = _propertiesSetFun(name);
}
});
}
var realClsName = declaration.split(':')[0].trim()
// 初始化了两个方法的字典对象
_formatDefineMethods(instMethods, newInstMethods, realClsName)
_formatDefineMethods(clsMethods, newClsMethods, realClsName)
var ret = _OC_defineClass(declaration, newInstMethods, newClsMethods)
var className = ret['cls']
var superCls = ret['superCls']
_ocCls[className] = {
instMethods: {},
clsMethods: {},
}
if (superCls.length && _ocCls[superCls]) {
for (var funcName in _ocCls[superCls]['instMethods']) {
_ocCls[className]['instMethods'][funcName] = _ocCls[superCls]['instMethods'][funcName]
}
for (var funcName in _ocCls[superCls]['clsMethods']) {
_ocCls[className]['clsMethods'][funcName] = _ocCls[superCls]['clsMethods'][funcName]
}
}
_setupJSMethod(className, instMethods, 1, realClsName)
_setupJSMethod(className, clsMethods, 0, realClsName)
return require(className)
}
遍历要求覆盖的方法。把新方法中的实现和相关参数,关联到了老方法中。也就是生成了一个方法名和老方法一样,但是执行函数不一样的方法。
// 把新方法中的实现和相关参数,关联到了老方法中。也就是生成了一个方法名和老方法一样,但是执行函数不一样的方法,这里生成一个字典,在oc中再去拿到相应值去处理。
var _formatDefineMethods = function(methods, newMethods, realClsName) {
// 遍历我们要求覆盖的方法
for (var methodName in methods) {
if (!(methods[methodName] instanceof Function)) return;
(function(){
var originMethod = methods[methodName]
newMethods[methodName] = [originMethod.length, function() {
try {
// oc转js ,arguments在js中代表被传递的参数,这里是为了把参数转化为js数组
var args = _formatOCToJS(Array.prototype.slice.call(arguments))
var lastSelf = global.self
global.self = args[0]
// 把类名作为全局变量保存下来
if (global.self) global.self.__realClsName = realClsName
// 删除第0个参数,也就是self。因为在执行的过程中,第一个参数是消息接收的对象,现在需要复制这个方法,所以,不需要第一个参数,因为调用的对象可能就不再是self了。
args.splice(0,1)
// 复制了originMethod的方法和属性,只是更新了参数,然后返回方法名。
var ret = originMethod.apply(originMethod, args)
global.self = lastSelf
return ret
} catch(e) {
_OC_catch(e.message, e.stack)
}
}]
})()
}
}
补充:
参考: