JSPatch简介
JSPatch 是一个开源项目(Github链接),只需要在项目里引入极小的引擎文件,就可以使用 JavaScript 调用任何 Objective-C 的原生接口,替换任意 Objective-C 原生方法。目前主要用于下发 JS 脚本替换原生 Objective-C 代码,实时修复线上 bug。已超过 3500 个 App 在使用,成为 App 标配功能。
基础原理
1、Objective-C 方面
Objective-C语言是一门动态语言,它将很多静态语言在编译和链接时期做的事放到了运行时来处理。这种动态语言的优势在于:代码时更具灵活性,我们可以把消息转发给我们想要的对象,或者随意交换一个方法的实现等。这种特性意味着需要一个运行时系统来执行编译的代码,它让所有的工作可以正常的运行。这个运行时系统即 Objc Runtime。Objc Runtime 其实是一个Runtime库,它基本上是用C和汇编写的,这个库使得C语言有了面向对象的能力。
要理解 Runtime 库,首先要了解 Objective-C 类与对象基础数据结构。类是由 Class 类型来表示的,它实际上是一个指向 objc_class 结构体的指针,定义可在 objc/runtime.h 中看到:
在这个定义中,这里只关注3个字段
1、ivars :存放属性链表,记录类实例的所有属性定义。
2、methodLists :存放法树链表,记录类实例的所有方法实现指针。
3、cache:用于缓存最近使用的方法。一个接收者对象接收到一个消息时,它会根据 isa 指针去查找能够响应这个消息的对象。在实际使用中,这个对象只有一部分方法是常用的,很多方法其实很少用或者根本用不上。这种情况下,如果每次消息来时,我们都是 methodLists 中遍历一遍,性能势必很差。这时 cache 就派上用场了。在我们每次调用过一个方法后,这个方法就会被缓存到 cache 列表中,下次调用的时候 Runtime 就会优先去 cache 中查找,如果 cache 没有命中,才去 methodLists 中查找方法。这样大大提高了调用的效率。
同时 objc/runtime.h 中还提供了大量的 API 来操作类与对象。类的操作方法大部分是以 class 为前缀的,而对象的操作方法大部分是以 objc 或 object_ 为前缀。这里我们只关注方法操作函数,如下:
1、class_addMethod:如果本类中包含一个同名的实现,则函数会返回 NO。如果要修改已存在实现,可以使用 method_setImplementation。
2、class_replaceMethod:该函数的行为可以分为两种:如果类中不存在 name 指定的方法,则类似于 class_addMethod 函数一样会添加方法;如果类中已存在 name 指定的方法,则类似于 method_setImplementation 一样替代原方法的实现。
3、method_setImplementation:重置方法实现。
4、method_exchangeImplementations:交换方法实现。
在 Objective-C 中调用一个方法,其实是向一个对象发送消息,查找消息的唯一依据是 SEL 的名字。每个类都有一个方法列表 methodLists ,存放着 SEL 的名字和 IMP 方法实现的映射关系如图示。IMP 类似函数指针,指向具体的 Method 实现。
利用 Runtime 可以实现在运行时偷换 SEL 对应的方法实现 Method 或重置 IMP 方法实现,达到 hook 的目的。
以上也就是大名鼎鼎的黑魔法原理(Method Swizzling),常见的用法
当然 Object-C 还支持动态创建对象,动态添加方法,动态添加属性(Object-C 中当类注册完成后无法动态添加属性,但可以用关联方法来模拟属性功能,属性的本质是 get 和 set 方法)
2、JavaScriptCore 方面
前端开发的同学应该知道,浏览器核心模块主要是渲染引擎和 JavaScript 引擎两部分组成。前者用于处理页面布局,渲染及 DOM 结构等,后者用于 JavaScript 的解析、执行及 DOM 交互等。JavaScriptCore 是一种 JavaScript 引擎,主要为 webkit 提供脚本处理能力(其主要以 safari 浏览器为代表)。除此之外,还有著名的 Jscript(IE), SpiderMonkey(firefox)和V8(chrome)。它提供了以下主要功能:
1、Objective-C –> JavaScript (即在 Objective-C 语言环境里执行 JavaScript 代码段、方法,创建 JavaScript 变量及变量操作等等)执行 JavaScript 代码的方法:首先引入 JavaScriptCore.h,然后通过 JSContext 创建 JS 运行环境,再通过 evaluateScript 来执行结果
需要注意 Objective-C 和 JS 数据类型之间的转换表:
2、JavaScript –> Objective-C(即在 JavaScript 语言环境里调用 Objective-C 公开给 JavaScript 的方法)。有 JSExport 协议和 Block 两种方式
3、内存管理和线程封装(主要是需要注意引用和线程使用冲突)
当 JS 对象引用到 Object-C 对象(继承了 JSExport 协议),而 Object-C 对象又引用到 JS 对象 时就会发生循环用(很少见的场景,即使真存在,也可以通过架构设计的方式来避免)
这个时候就需要使用到 JSManagerValue 包装一下
实际代码如下:
至于线程冲突就涉及到 JSVirtualMachine 的理解:其实每一个 JSVirtualMachine 都管理着一个 JavaScript 虚拟机(JSContext 的载体),它运行在 Object-C 中的一个独立线程队列,相同的 JSVirtualMachine 共用同一个线程队列,不同的 JSVirtualMachine 当然也就处于不同的线程队列,它们之间无法进行数据通讯,只能通过 Object-C 来做通讯中转,所以会出现线程冲突问题。理解了这个关键点,解决冲突问题就容易了。
通常初始化 JSContext 环境都会加载在一个 JSVirtualMachine 虚拟机,即使不指定 JSVirtualMachine 对象,也会默认加载一个,如下图示:
3、Object-C 和 JavaScript 之间的桥接
JSPatch 中两者之间交互依托前面的数据类型转换表,使用最简单的字符串传递方式交互信息达到动态化的目的。一句话总结:JS 传递字符串给 OC,OC 通过 Runtime 接口调用和替换 OC 方法,这是最基础的原理。如下图示:
详细的打怪涨经验方式,还是去参考 bang 神的文档,芝麻开门:
https://github.com/bang590/JSPatch/wiki/JSPatch-%E5%AE%9E%E7%8E%B0%E5%8E%9F%E7%90%86%E8%AF%A6%E8%A7%A3
服务端原理
JSPatch 需要使用者有一个后台可以下发和管理脚本,并且需要处理传输安全等部署工作,JSPatch 平台帮你做了这些事,提供了脚本后台托管,版本管理,保证传输安全等功能,让你无需搭建一个后台,无需关心部署操作。但还是需要了解一些服务端原理的。下载 JS 脚本只是简单的 get 请求,这里要研究的是其中传输安全,灰度下发和回滚机制。
1、安全机制
这里就直接引用 bang 神的原文,如下图示:
1、服务端计算出脚本文件的 MD5 值,作为这个文件的数字签名。
2、服务端通过私钥加密第 1 步算出的 MD5 值,得到一个加密后的 MD5 值。
3、把脚本文件和加密后的 MD5 值一起下发给客户端。
4、客户端拿到加密后的 MD5 值,通过保存在客户端的公钥解密。
5、客户端计算脚本文件的 MD5 值。
6、对比第 4/5 步的两个 MD5 值(分别是客户端和服务端计算出来的 MD5 值),若相等则通过校验。
只要通过校验,就能确保脚本在传输的过程中没有被篡改,因为第三方若要篡改脚本文件,必须计算出新的脚本文件 MD5 并用私钥加密,客户端公钥才能解密出这个 MD5 值,而在服务端未泄露的情况下第三方是拿不到私钥的。
JSPatch 平台是用 PHP 实现的,这里笔者用 Node.js 仿照流程来模拟基础实现原理。
运行效果如下:
这里为了看效果并没有对 data 进行加密,实际生产环境中使用时,脚本需要进行版本管理,提交 PR-Review,通过以后才能通过服务获取,而脚本内容也是需要进行 RSA 加密的,安全第一嘛。再配合上苹果的 ATS 要求所有APP域名都支持HTTPS传输(此要求 delay 了),至此已经实现了安全传输机制。
2、灰度下发机制
灰度下发涉及到数据上传,分析等,甚至有的公司都已经做到了大数据挖掘的程度,JSPatch 平台支持按用户数量、按条件(常被用来做新功能发布或线上调试)灰度下发,其中按条件还支持后台动态配置条件,功能很强大。详细的可以参考 http://www.jspatch.com/Docs/rule
3、回滚机制
这部分 JSPatch 平台并没有详细说明,但目前实践中大部分都是简单粗暴地重传:即已下发脚本出 bug 了,就再出一个 fixed patch,重新下发。但这种方式对于日活千万上亿的 APP,是不能容忍的。这里可以提供一种使用基于 git 版本开源项目管理的方式:每一次脚本下发都提交 PR-Review,Review 通过以后 merger 再下发,一旦出错直接 git revert。Native 端缓存最新版本和上一个版本的 patch 补丁,共两份,当检测到回滚发生(服务端下发的版本标识小于 Native 端版本)时,把上一个版本的 patch 标识为最新,出错的 patch 标识为历史版本,完成回滚操作。
后记
使用 JSPatch 已有半年多时间了,从中收获到很多,也踩过不少坑,比如:无法替换 main 函数之前执行的类方法 +(void)load; +(void)initialize; 等,无法调用被 hook 住的源方法,但都一一趟过了,总的来说还是一个非常强大商业化工具。有感兴趣的小伙伴还是强烈推荐多多阅读 bang 神的 Wiki,传送门:https://github.com/bang590/JSPatch/wiki
希望本文能对准备接入或者学习 JSPatch 的开发人员有所帮助。