暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

iOS热修复的二三事

技术对话 2023-03-24
3058

热修复是一个老生常谈的话题,在iOS中,关于热修复有过巅峰、有过低谷,在这里将会介绍一下热修复的发展历程,以及各个阶段中的代表框架及实现原理。

背景

在移动应用功能越来越多、越来越复杂的背景下,应用出现功能异常或不可用的情况也会随之增多。这时候又有什么方法可以快速解决线上的问题呢?第一、在一开始功能设计的时候就设计降级方案,但随之开发成本和测试成本都会双倍增加;第二、每个功能加上开关配置,这样治标不治本,当开关关掉的时候,就意味着用户无法使用该功能。这时候热修复就是解决这种问题的最佳之选,既能修复问题,又能让用户无感知,两全其美,但热修复的历程真的会这么顺利吗?

这篇文章将会介绍iOS热修复的发展历程,以及各个阶段中的代表框架及实现原理,同时将对Apple对热修复巅峰之作JSPatch进行封杀的原因进行解析。

一、概念及发展

主要介绍一下热修复和热更新的概念及原因,同时简单梳理一下iOS热修的发展历程。

1、热修复 VS 热更新

热修复和热更新都是耳熟能详的词,但它们具体代表着什么?它们之间又有什么区别?可能大家也不是很清楚,这里将介绍一下它们的概念和区别。

1.1、热修复和热更新是什么❓

热修复:让应用能够在无需重新安装的情况实现更新,帮助应用快速建立动态修复能力。

热更新:让应用无需重新下载安装一种软件更新方式,帮助应用快速更新迭代功能。

1.2、它们的区别又是什么❓

主要区别在于目标不同

热修复主要是对线上问题、bug进行修复,是为了解决特定用户的具体问题,具有针对性

热更新主要是对对线上功能进行更新、迭代,是为了解决不用审核直接上线功能的问题,具有普遍性

从释义上来看,两种概念都是通过一定的技术来达到动态更新的能力。从区别上来看,它们也只是应用的目标不同。但实际上,双方又是你中有我我中有你的状态。它们的技术实现是可以互用的,热修复的技术能用来热更新,热更新的技术也能用来热修复,其实从某种意义上来说,两者可以合二为一。

2、原因

因为苹果的审核机制,我们修复 bug 的时候要经过如下过程:

可以看出,这时间还是挺漫长的,而且当年苹果审核周期平均为一个星期,导致修复bug的时间成本太高,因此热修复的出现解决了一大痛点。

3、时间线

这里主要介绍热修复及类热修复框架发展的时间线,每个时间都有属于自己的代表性事件,具体发展如下:

  • 2008年,iOS的web容器UIWebView发布,从此可以在iOS上加载H5网页,通过H5编写业务代码可以实现热修、跨端等功能。2014年,基于H5最新标准、性能更高、更轻量的WKWebView发布,标志这iOS第一代web容器正式退出。

  • 2015年,JSPatch正式诞生并开源,可以使用js调用OC的原生接口,开启了iOS热修复的新时代,当时的绝大部分应用都接入了JSPatch来用于热修复,甚至连高德地图SDK也都接入了。

  • 2015-2016年,RN和weex相继发布并开源,通过下发js,调用提前注册的原生组件,通过原生组件完成渲染。这两种框架主要用于跨平台,由于他们的下发js更新的特性,同时也具备了热修复的能力。

  • 2017年,苹果一封邮件警告,让纯热修复进入了黑暗时代,所有接入JSPatch、Rollout等框架的应用必须尽快移除,否则将会面临无法审核通过的风险。苹果的一封邮件,让热修复由盛转衰,再无巅峰的可能。

  • 2018-2020年,这段时间,热修复相关技术基本沉寂,基本上各大厂都是自己干自己的,并无开源的主流框架,期间有基于Lua、Ruby等语言开发的小框架出现,也没掀起大风浪。

  • 2021年,出现了基于自研虚拟机,使用纯原生代码修改的热修复框架SOT,由于框架较新、使用较少,可能需要踩的坑比较多,也代表着热修复新技术的发展。

二、代表框架

将每个时代的代表框架或技术拿出来介绍并对比一下,宏观看一下iOS热修的更新迭代。

1、UIWebView vs WKWebview


UIWebviewWKWebview
系统iOS2iOS8
优点可跨平台、发布更新快性能高、支持更多特性
缺点性能差、数据通讯复杂、笨重不能捕获请求

这两个框架是苹果在不同时期推出的两种用来加载H5网页的web容器,由于H5的特性,可以动态的修改代码并发布,所以就有了早期的热修复,通过H5编写业务代码,出现问题后重新下发修复问题。

UIWebview和WKWebview两者有着比较大的区别,总体来说WKWebview更加轻量、更加优秀,支持更多特性。它们的加载过程也有不同,如下图:

可以看出WKWebview加入了证书校验、跳转策略的校验,同时加入是否允许载入操作。

2020年4月起App Store将不再接受使用UIWebView的新App上架,2020年12月起将不再接受使用UIWebView的App更新。

2、JSPatch ⭐️⭐️⭐️

这里简单的介绍一下JSPatch,后面将对其进行详细分析。

2.1、什么是JSPatch?

JSPatch 是一个开源项目,只需要在项目里引入极小的引擎文件,就可以使用 JavaScript 调用任何 Objective-C 的原生接口,替换任意 Objective-C 原生方法。目前主要用于下发 JS 脚本替换原生 Objective-C 代码,实时修复线上 bug。

2.2、JSPatch如何调用?

可以看出JSPatch调用很简单,导入-启动-调用,非常的轻量,并且功能强大。

3、React Native VS Weex

React Native(简称RN)和Weex大家都应该耳熟能详了,这里就只做一下简单的介绍。

3.1、口号

React Native:Learn once, write anywhere

Weex:Write Once, Run Everywhere

一种是学习一种语言可以写任何平台的程序,另一种是写一次代码可以在任何平台运行。二者谁优谁劣,大家可以自己去评判。

3.2、注册原生组件

二者的实现都是基于原生组件的,所以就需要提前注册组件才能进行调用。

{
    @"button" : @(UIAccessibilityTraitButton),
    @"text" : @(UIAccessibilityTraitStaticText),
    @"search" : @(UIAccessibilityTraitSearchField),
    @"image" : @(UIAccessibilityTraitImage),
    ...
}

RN提前将前端标签进行绑定,在后续页面绘制的过程中通过绑定的值获取原生组件进行渲染。

{    
    [self registerComponent:@"div" withClass:NSClassFromString(@"WXComponent") withProperties:nil];
    [self registerComponent:@"text" withClass:NSClassFromString(@"WXTextComponent") withProperties:nil];
    [self registerComponent:@"image" withClass:NSClassFromString(@"WXImageComponent") withProperties:nil];
    ...
}

Weex将前端标签所对应的原生组件类进行注册,绘制过程中直接获取对应的原生组件类进行渲染。

3.3、原理

二者的实现原理基本一致,如下图:

  • 将React或Vue文件进行编译打包成js文件,并上传至服务端。

  • 当客户端启动时,获取服务端的js资源,通过提前导入的RN或Weex引擎进行解析。

  • 客户端拿到引擎解析后的js文件,通过系统的JavaScriptCore框架进行原生交互。

  • 通过bridge进行原生和js之间调用和回调。

注:RN和Weex的实现原理是一致的,Weex是站在RN的肩膀上的,两者的渲染引擎都是Yoga,实现热更新和热修复的核心原理也是一样的。

4、DynamicCocoa

这个框架是滴滴公司准备开源的iOS原生热修复框架,可以实现通过原生代码进行修复,它也有一句口号是 Write Cocoa, Run Dynamically。意思是通过原生代码实现热修复功能。

实现原理如下:

  • 开发者通过原生代码进行bug修复,通过源码打出patch,并上传服务端。

  • 客户端获取到patch,解析成js代码,通过JavaScriptCore进行交互,实现热修功能。

DynamicCocoa即将开源之际,Apple的封杀大棒挥下,导致其开源无果,烂尾至今,实属悲情。

5、SOT

沉浸iOS热修复框架。

全语言支持能过审更安全能加固
SOT把hotfix,动态化技术提升到了一个新的高度,基于自研虚拟机,能满足苹果全部原生开发语言的热更需求,包括Objective-c,Swift,c, c++,支持几乎所有的语言特性。SOT并没有使用遭苹果封禁的dlopen(), dlsym(), method_exchangeImplementations()等敏感接口,不影响过审。不像使用脚本语言的方案可以随意调用API,SDK工具需要有原项目代码才能生成合法的补丁,只有合法补丁才能被APP加载,杜绝了被黑客利用的可能性。除了能够热更,虚拟机还可用于加固混淆APP,只需标记对应函数,SDK工具会把原生代码转换成SOT虚拟机的字节码,运行于虚拟机中,极大增加逆向难度。
  • 通过自研虚拟机来实现热修复,这也是热修的终极渠道。

三、JSPatch

详细分析一下iOS热修的巅峰之作,介绍一下它的实现原理,探索一下Apple对其封杀的原因。

1、文件结构

文件作用方法
JPEngine.hJSPatch引擎头文件,定义开始引擎、调用补丁、异常处理等方法+ (void)startEngine; + (JSValue *)evaluateScriptWithPath:(NSString *)filePath; + (JSValue *)evaluateScript:(NSString *)script;
JPEngine.mjs中的定义函数实现,添加/替换、调动方法等核心实现static id formatOCToJS(id obj) static id formatJSToOC(JSValue *jsval) invokeVariableParameterMethod
JSPatch.jsjs语法解释规则,按照此规则写映射为OC代码的js代码global.require global.defineClass _methodFunc

可以看出JSPatch非常的轻量,3个文件就能实现iOS中最强大的热修复功能,可以调用任何iOS原生的代码。

2、代码对比

- (void)configView {
    UIView *view = [[UIView alloc] init]:
    view.frame = CGRectMake(0, 0, 100, 100) ;
    view.backgroundColor = [UIColor redColor];
    view.alpha = 0.5;
    [self.view addSubview:view];
}

Objective-C代码,初始化一个view,设置位置、颜色、透明度,添加到父视图上。

require("UIView, UIColor");
defineClass("BaseViewController", {
    configViewfunction({
        var view = UIView.alloc().init();
        view.setFrame(CGRectMake(00100100) ) ;
        view.setBackgroundColor(UIColor.redColor());
        view.setAlpha(.5);
        self.view().addSubview(view);
    }
}, {});

JSPatch代码,获取到需要使用的类,生成同名方法,通过链式调用各个方法。

  • 通过iOS原生代码和JSPatch的脚本代码对比,可以看出二者都很简单易懂,但也有些许差别,patch代码是使用js的风格进行代码翻译,以便下发后更好的进行patch解析。

JSPatch提供了一个原生代码一键转patch代码的工具,大家有兴趣可以看一下。http://jspatch.com/Tools/convertor

3、基本原理

JSPatch 能做到通过 JS 调用和改写 OC 方法最根本的原因是 Objective-C 是动态语言,OC 上所有方法的调用/类的生成都通过 Objective-C 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);
class_addMethod(cls, selector, implement, typedesc);
objc_registerClassPair(cls);

通过上面几种方法,可以为原生添加和修改任何方法,而且都可以通过传入字符串来实现,因此可以得出基本原理是JS 传递字符串给 OC,OC 通过 Runtime 接口调用和替换 OC 方法。

4、方法调用

4.1、require

从2模块的JSPatch代码看,第一行就是调用require()
方法,那该方法怎么实现的呢?

require('UIVIew')
为例:

var require = function(clsName)
  if (!global[clsName]
{
      global[clsName] = {
          __clsName: clsName
     }
  }
  return global[clsName]
}

require
就是在js全局作用域上创建一个同名变量,变量指向一个对象,对象为:

{
      __clsName'UIVIew'
}

最后UIView
初始化的代码可以更改为:

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

4.2、消息转发

在 OC 执行 JS 脚本前,通过正则把所有方法调用都改成调用__c()
函数,再执行这个 JS 脚本。

所以UIView
初始化的代码会被转变成:

UIView.alloc().init()  ->  UIView.__c('alloc')().__C('init')()

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

Object.defineProperty(Object.prototype, '__c', {valuefunction(methodName{
    return function){
        var args = Array.prototype.slice.call(arguments )
            return _methodFunc(self.__obj, self.__clsName, methodName, args, self.__isSuper)
    }
}})

_methodFunc()
就是把相关信息传给OC,OC用 Runtime 接口调用相应方法。

4.3、调用原生方法

在上一步过程中,调用到了_methodFunc()
方法,该方法通过替换特殊字符,将类名、方法名、参数等传至OC侧。该方法里会调用_OC_callI
_OC_callC
方法。

var ret = instance ? _OC_callI(instance, selectorName, args, isSuper):
                     _OC_callC(clsName, selectorName, args)

__OC_callI
_OC_callC
两个方法均为引擎启动时注册到js上下文中的方法,前者是调用实例方法,后者是调用类方法。

context[@"_OC_callI"] = ^id(JSValue *obj, NSString *selectorName, JSValue *arguments, BOOL isSuper) {
    return callSelector(nil, selectorName, arguments, obj, isSuper);
};
context[@"_OC_callC"] = ^id(NSString *className, NSString *selectorName, JSValue *arguments) {
    return callSelector(className, selectorName, arguments, nil, NO);
};

5、方法替换

5.1、OC消息转发机制

由上图消息转发流程图可以看出,系统给了3次机会让我们来拯救。

第一步,在resolveInstanceMethod方法里通过class_addMethod方法来动态添加未实现的方法;

第二步,在forwardingTargetForSelector方法里返回备用的接受者,通过备用接受者里的实现方法来完成调用;

第三步,系统会将方法信息打包进行最终的处理,在methodSignatureForSelector方法里可以对自己实现的方法进行方法签名,通过获取的方法签名来创建转发的NSInvocation对象,然后再到forwardInvocation方法里进行转发。

方法替换就利用第三步的转发进行替换。

5.2、实现
  • 首先把当前类的function方法通过class_replaceMethod()
    接口指向_objc_msgForward
    (全局IMP)。

  • 为当前类添加ORIGfunction和_JPfunction两个方法,前者指向原来方法的IMP,后者为新增的方法,后面会在这回调js方法。

  • 重写当前类的forwardInvocation方法,一旦用户调用function,经过上面处理后,就会调用至forwardInvocation方法中,在该方法中获取到参数。

  • 获取到参数后,将参数传至新增的_Jpfunction方法,在这个新方法里取到参数传给JS,调用JS的实现函数。

最终可以实现方法的替换,并还能保留原有实现,极大提高热修复的便利性。

6、新增方法

新增方法也是热修中会遇到的问题,在defineClass
方法中可以进行新增方法。

defineClass
定义的方法会经过 JS 包装,变成一个包含参数个数和方法实体的数组传给OC,OC会判断如果方法已存在,就执行替换的操作,若不存在,就调用 class_replaceMethod()
新增一个方法,通过传过来的参数个数和方法实体生成新的 Method。

static NSDictionary *defineClass(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) {
    //...
    Class cls = NSClassFromString(className);
    if(!cls) {
        //类不存在时,创建新类
        Class superCls = NSClassFromString(superClassName);
        cls = objc_allocateClassPair(superCls, className.UTF8String, 0);
        objc_registerClassPair(cls);
    }
    //...
    if (class_respondsToSelector(currCls, NSSelectorFromString(selectorName))) {
        //替换
        overrideMethod(currCls, selectorName, jsMethod, !isInstance, NULL);
    } else {
        //新建
        class_replaceMethod(cls, selector, msgForwardIMP, typeDescription);
    }
    //...
}

7、property

JSPatch也可以调用和添加新的属性,新属性通过原生的runtime的关联接口实现。

已存在的属性,和调用普通 OC 方法一样,调用这个对象的 get/set 方法就行。

// OC
@property (nonatomic, copy) NSString *str;
@property (nonatomic, assign) BOOL succ;
// JS
self.setSucc(1);
var str = self.str();

不存在的属性,需要运用runtime的关联函数,动态的添加属性。

// OC
class_addMethod(cls, @selector (getProp: ), (IMP)getPropIMP, "@@:@");
class_addMethod(cls, @selector(setProp: forKey: ), (IMP)setPropIMP, "v@:@@");
// JS
var data = self.getProp('data')
self.setProp_forKey(data, 'data')

四、总结

对比一下几个主流框架,总结一下iOS热修复的发展历程,并展望一下后续规划。

关于Weex,ReactNative,JSPatch

首先他们三者都是基于JS来进行热修复的,但是RN,Weex和JSPatch有一个最大的不同是,如果Native没有提供可以供JS调用的方法接口的话,那么在RN和Weex界面怎么也无法实现Native的一些方法的。

但是JSPatch不同,虽然它也是一套基于JSCore的bridge,但是它的引擎是基于OC Runtime的,可以实现各种需求,即使预先Native没有暴露出来的接口,都可以添加方法实现需求,也可以更改已经实现的方法。

从热修复/热更新的能力来看,RN和Weex的能力仅仅只是中等能力,而JSPatch是几乎无所不能,Runtime都实现的,它都能实现。

所以从各个框架的能力上看,RN和Weex都不能改变Native原生代码,也无法动态调用Native系统私有API。所以苹果审核允许RN和Weex通过。而JSPatch是基于runtime的,可以调用任何API,这也是Apple对其如此忌惮的原因,进而遭到封杀。

支付SDK应用限制

由于支付SDK是对外提供支付能力的载体,是需要宿主接入的,要是接入较为安全的RN和Weex,则会导致包体积增大,需要各种对接各种bridge;要是接入JSPatch的话,则会让宿主应用有被下架的风险,同时也给宿主应用带来线上运行风险,相当于在宿主不知情的情况下为其开通了热修复能力。因此基于这些考虑,热修复在支付SDK上是不太适用的。

总结

iOS热修复技术从最初的webView到最近的SOT,技术的发展越来越快。同时我们也应该注意到,热修复在提供修复问题的便利性时,也让自己的APP进入一种不安全状态,这是一把双刃剑,如何用好它,则需要细细考量。

以后的iOS热修复发展,会进入瓶颈期,但整体趋向于虚拟机技术,在语言上也会有其他的选择,例如Ruby、Lua等,因此,会出现更加多样性的技术框架。

所以,iOS热修复一路走来,有过巅峰,有过低谷,而今已经趋于平静,由于苹果的审核大棒,已再难恢复往日荣光,但热修复并不会就此消失,而是静待新技术的出现。

-- End --

点击下方的公众号入口,关注「技术对话」微信公众号,可查看历史文章,投稿请在公众号后台回复:投稿


文章转载自技术对话,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论