iOS一点通

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 facebook 使用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时,需要进行一些修改

  1. 属性值修改,只需添加 @objc 即可。
  2. JSPatch 调用的方法只具有 @objc 即可,不需要 dynamic。
  3. JSPatch 重写的方法需要具备 @objc 和 dynamic 性质。

注:
原因是Objective-C 和 Swift 在底层使用的是两套完全不同的机制,Cocoa 中的 Objective-C 对象是基于运行时的,支持动态派发 (Dynamic Dispatch,在运行调用时再决定实际调用的具体实现)。而 Swift 为了追求性能,如果没有特殊需要的话,是不会在运行时再来决定这些的。也就是说,Swift 类型的成员或者方法在编译时就已经决定,而运行时便不再需要经过一次查找,而可以直接使用。
当Swift想使用运行时,则需加上@objc、dynamic。

  1. @objc
    Objective-C调用Swift中的类、属性和方法等声明时,前面需加上 @objc 修饰符
  2. dynamic
    表明是动态调用。因为添加 @objc 修饰符并不意味着这个方法或者属性会变成动态派发,Swift 依然可能会将其优化为静态调用。如果需要使用 Objective-C 运行时特性的话,则需要添加修饰符 dynamic。

2)注意

  1. Struct 结构体不能使用JSPatch。只支持调用继承自 NSObject 的 Swift 类。
    因为Struct是值类型,当值传递时,它会copy传递的值。无法使用runtime。
    而Class是引用类型,当值传递时,是传递对已有instance的引用。
  2. 继承自 NSObject 的 Swift 类,其继承自父类的方法和属性可以在 JS 调用,其他自定义方法和属性同样需要加 dynamic 关键字才行。
  3. 若方法的参数/属性类型为 Swift 特有(如 Character / Tuple),则此方法和属性无法通过 JS 调用。
  4. 使用时必须选择接入 JSPatch 平台。
  5. 官方公告有概率审核不通过。

3)在被审核禁止后,做了哪些事,可以继续使用JSPatch?

  1. 被禁止原因:
    风险点1:APP接入JSPatch。无法保证每个 APP 接入方式都是安全的,容易传输过程被替换,被黑客中间人攻击,成为 iOS 上的一个漏洞。
    解决:统一安全接入。在传输过程中配置RSA密钥对。(对使用者负责)
    风险点2:SDK接入JSPatch。一些像地图/推送类 SDK 接入 JSPatch 后覆盖大量 APP,若这些 SDK 后台被攻破,可以对这些 APP 下发恶意脚本,造成大面积危害。
    解决:SDK已经安全接入。接入SDK后的APP也安全。(对使用者负责)
    官方建议开发者不能自行接入,需要统一接入 JSPatch 平台。
  2. 通过苹果审核:
    通过做了简单的类名修改混淆后可通过审核。

4)Swift使用方法

(JSPatch平台版)

  1. 项目中添加 JSPatchPlatform.framework。
  2. 添加依赖框架 JavaScriptCore.framework。
  3. 生成并配置RSA密钥对,在jspatch服务端下发补丁时使用私钥进行签名,在客户端收到补丁时使用公钥验证,防止攻击者通过篡改或伪造补丁对客户端进行攻击。
  4. 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

  1. 启动运行。
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脚本

  1. 文件名必须是main.js
  2. 使用 defineClass() 覆盖 Swift 类时,类名应为 项目名.原类名,举例:
defineClass("JSPatchExample.ViewController", {})

  1. 调用自定义方法时,采用 方法名:function(参数) {},举例:
setData:function() {
        console.log("js点击");
        self.setTitleString("js点击");
    }

  1. 完整例子:
    错别字修改:
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)源码解读

  1. 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);
};

  1. 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)
          }
        }]
      })()
    }
  }

补充:

  1. JSPatch 基础用法

参考:

  1. 初探JSPatch
上一篇下一篇

猜你喜欢

热点阅读