【Dev Club 分享第四期】JSPatch 成长之路
发布于 1 年前 作者 Bugly_Tony 160053 次浏览 来自 技术

Dev Club 是一个交流移动开发技术,结交朋友,扩展人脉的社群,成员都是经过审核的移动开发工程师。每周都会举行嘉宾分享,话题讨论等活动。

本期,我们邀请了腾讯WXG iOS开发工程师——bang 陈振焯,为大家分享**《JSPatch成长之路》**。

如何加入 Dev Club?

移动端开发经验 >= 2 年,微信扫描下方群管理微信二维码,备注姓名-公司(或产品) 申请加入。

外部群二维码


分享内容简介: JSPatch 是 iOS 上的动态更新框架,只需要引入小小的引擎文件,就可以用 JS 调用和替换任意 OC 方法。目前被普遍用于实时修复 bug,已有超过2500个 APP 接入,本次分享介绍 JSPatch 发展过程中遇到的问题和解决思路。 (此内容已在 GMTC 线下分享过,本次重新整理为线上分享)

内容大体框架:

  1. 起步:介绍 JSPatch 的诞生和当时碰到的难题
  2. 发展:介绍 JSPatch 如何补全周边功能变得更好用
  3. 下一步:介绍 JSPatch 下一步的计划

分享人介绍:

bang 陈振焯 广州研发部 目前负责开发微信读书iOS端,博客 http://blog.cnbang.net


下面是本期分享内容整理


大家好,我是 bang,目前在广州研发部做微信读书 iOS 端,今天分享的主题是《JSPatch 成长之路》。

我在去年5月发布了 JSPatch (https://github.com/bang590/JSPatch) 这个开源库,现在广泛应用于 iOS 的热修复,今天分享一下 JSPatch 过去一年以来的成长。

分享共分为三个部分:

  1. 起步 —— 介绍JSPatch的诞生和当时碰到的难题
  2. 发展 —— 介绍JSPatch如何补全周边功能变得越来越好用
  3. 下一步 —— 介绍JSPatch下一步的计划

一、起步

先说下起步阶段。当时碰到的一个问题是:APP 线上 bug 修复周期长,成本高,版本发布出去后,发现一个 bug,要修复这个 bug 就必须得另外发一个版本,也就是要经历:测试——打包——发布——审核——用户下载,这一系列过程,成本非常高,最后还很难让所有用户都升级上来。

当时业界已有一个解决方案,叫 waxPatch,它是在APP里嵌入 lua 引擎,然后通过 OC 的 runtime 接口在 lua 里调用和替换 OC 方法,这样就可以下发 lua 脚本替换原生代码,动态修复 bug。

waxPatch: https://github.com/mmin18/WaxPatch

这是个不错的方案,但当时的 waxPatch 存在很多缺陷:

首先是 wax 已经多年不维护了,导致不支持一些 block/64 位等新特性,此外当时 wax 还有文档不足,测试不足,线程不安全,难以调试等坑。

于是开始探求更好的解决方案。很容易想到如果用 JavaScript 做这个事情的话,相对 lua 原生就有一些优势:

  1. iOS 里已内置 JavascriptCore 引擎,无需再另外嵌入。
  2. JS 在终端应用广泛,很多混合开发内嵌 H5 页面就是用 JS。
  3. 符合苹果审核规则,苹果在文档里说明不可以下载可执行的代码,由 JavascriptCore 执行的除外

那么有没有人试过这样做呢?用 Javascript 调用和替换 OC 方法,当然是有的。

  • 在当时有一个开源库 JavascriptBridge (https://github.com/kishikawakatsumi/JavaScriptBridge), 它可以用 JS 调用 OC 接口。 不过它用的是 JavascriptCore 原生的接口做的,需要事先在 OC 里定义好要调用的接口,没有事先定义的不能调,这导致它的实现很臃肿,因为要在 OC 定义大量的方法。此外它也不能替换 OC 方法,实用性很低。

  • 当时国外还有一个热补丁服务叫 rollout (http://rollout.io)。 它是一个服务平台,底层也是用 JS 调用和替换 OC 方法去实时修复 bug,不过它不是开源的,只能在这个平台上用,另外它的 JS 写法是比较复杂的,看看这个例子就知道,这导致它不得不在平台上做一些便捷的功能,把一些常用的操作封装起来,减少使用者写代码。

总的来说,当时并没有一个更好的方案,于是想自己造个。

当时期望做到的效果是这样的:

我在 JS 写 UIView.allOC(), 然后传给 JavascriptCore 执行,JavascriptCore 把我要调用的信息,这里类名是 UIView,类方法名是 alloc 传递给 OC,OC runtime 就可以找到这个类和方法进行调用。这是最基本的一个语句调用。

实际上当时实现这个最基本的调用就遇到一个槛,在 JS 里这条语句根本无法执行:

要让这条语句在 JS 环境中可以执行,在 JS 的语言规则下,UIView 必须是一个对象,alloc 必须这个对象的一个方法。

也就是说要像这样定义后才可以执行:

UIView 必须是一个对象这点没问题,在调用前定义就可以,但 UIView 的方法必须在调用前定义就很糟糕,这意味着如果你想调用任意 UIView 的方法,你就需要提前把所有 UIView 的方法都找出来,每一个方法都要预先定义好。

也就是说在使用UIView之前,需要先去 OC 把UIView所有方法找出来,然后构建UIView对象,每个方法都在这个对象里生成对应的函数,然后你才可以调用UIView的任意方法。

JSPatch 在开发时就尝试过这种方案,后来发现这些对象的方法太多了,仅 NSObject 基类的实例方法就有830个,类方法有118个,这导致在JS生成的对应的对象占用内存极高,NSObject就占了1.3M,UIView占2M。这根本不可用。

对此我还进行了一些优化尝试,例如去除掉里面的下划线开头的私有方法,在 JS 构造继承链共用基类方法。但这些优化都没多少效果,占用内存依旧很高。当时就觉得不太可能实现。

实际上当时我陷入了一个思维定势,做终端久了,思维停留在 iOS 的 OC 世界,写代码必须遵守语言的规则,上述的困难也是在遵守 JS 语言规则这个前提下碰到的。

如果有方法不遵守语言规则呢?实际上在 JS 界,有个很常用的伎俩,就是预编译:

也就是我们写的脚本不直接拿给 JS 引擎执行,而是进行一些转换后才执行,在现代框架这个用法很常见, react/vue 都用了,甚至还有像 coffieScript 这样把 JS 完全换成另一种语言的做法。

想到这一点,刚才的问题就很好解决了。

只需要把所有函数调用在执行前都替换一遍,变成去调用一个固定的 __c 函数, 这个 __c 函数模拟了 ruby/lua 等语言的元方法机制,对方法调用进行转发。

还是以调用 UIViewalloc 方法为例,这个语句在执行之前会被替换为调 UIView 的 __c 方法,把 “alloc” 作为字符串参数穿进去,在 __c 方法里判断调用者 UIView 是不是 OC 对象。如果是,就把类名和传进来的方法名传到 OC 层进行调用,如果不是,就调用回 JS 这个对象的方法。

这样做简洁高效地解决了前面的问题,不需要去 OC 遍历每个类的方法,不需要存储这些方法,就可以调用任意 OC 方法,只需要给 JS 基类定义一个 __c 方法就可以了。正则替换后无论调用 OC 的什么方法,都不会有语法错误,因为都变成调用这个 __c 方法,在这个 __c 方法里做处理去 OC 层调用相应的方法就行了。

使用这种方案后内存的占用下降了 99%,甚至更多,也使 JSPatch 的使用成为可能,这是 JSPatch 最核心的一点。

解决这个核心问题后,后面就是细化功能了,JSPatch 发布以后一直在完善,包括最基本的调用和替换OC方法,还有支持64位,支持 block,支持包括 c指针/Class/结构体等类型,支持 c函数的调用等,这里面的细节原理我觉得看文章会比较清晰,相关文章都可以在 github wiki (https://github.com/bang590/JSPatch/wiki) 上找到,这里就不多说了。

二、发展

接下来说说 JSPatch 是怎样进行进一步发展的。

在完善 JSPatch 的同时,我也在想,如何把 hotfix 做得更好。

主要有两个思路:

  1. 降低使用门槛
  2. 提高使用效率

我们一个点一个点来看对这两个问题是怎么做的。

首先 JSPatch 在易用性上一直坚持着一个理念,就是 keep it simple and tiny,用中文说就是保持精巧,保持好用。JSPatch 从开源到现在一年多,增加和完善了很多功能,但它的使用方式和接口都没有变过,一直以来都是只有三个文件,拖入项目直接可以使用,也会很谨慎地新增接口,不会影响到旧接口的使用,不会出现同一份代码在旧版本能用,在新版本不能用的情况,在易用性上降低使用门槛。

另一个问题是安全问题

JSPatch 可以调用和替换任意 OC 方法,权限很大,如果在传输过程中被第三方攻击,替换了下发的代码去执行,会对用户和 APP 本身造成很大伤害。如果每个接入 JSPatch 的人都要考虑这个安全问题,接入门槛就会很高,也可能会因为考虑不周全导致 APP 处于危险状态。

对此当时详细考虑了安全策略。对这种情况:

  • 最简单的方案是直接对脚本加密,后台使用固定密钥加密脚本后下发,客户端使用同样的密钥解密。这种方案的优点是简单,缺点是这个密钥必须存在客户端,黑客很容易破解拿到这个密钥,然后通过传输过程第三方攻击,下发同样用这个密钥加密的恶意代码,就没有安全可言了。

  • 第二个方案是让脚本通过 https 传输,这个方案的优点是安全性高,只要客户端对证书进行过足够的验证,就能很好地保证安全性。缺点是这个方案门槛高,部署繁琐,需要购买证书,对一些中小 APP 来说可能难以接受,并且如果用户手机信任了一些恶意证书,也还是存在被攻击的危险。

  • 第三个方案是使用RSA签名验证。 整个流程是这样:

第一步服务端计算脚本文件的MD5值,用存在服务端的私钥对这个MD5值进行加密,然后把这个MD5值和脚本一起打包下发给客户端。客户端拿到脚本和加密后的MD5值,用存在客户端的公钥进行解密,拿到服务端计算的MD5值,本地再计算一遍脚本文件的MD5值,对比这两个值是否一致,若一致则表示传输过程中没有被篡改。

如果第三方要截获请求下发恶意脚本,第三方必须用私钥加密这个恶意脚本的MD5值一起下发,才能通过验证执行,只要服务端不被攻破,第三方就没有私钥,也就无法进行篡改。

可以看到这第三个方案门槛低,通用性高,部署简单,安全性也高,对服务端和客户端都没有什么特殊要求。

我把这一套安全方案做成一个组件,叫 JPLoader,也开源在 JSPatch 项目上(https://github.com/bang590/JSPatch/tree/master/Loader), 需要部署 JSPatch 的同学可以直接使用这套组件,解决安全性问题,客户端的工作就完成了,只剩下后端的工作。

前面把安全性问题解决了,只剩下后端的工作,但搭建后台对使用者来说也是挺麻烦的事,特别是作为 iOS 开发者,在中小公司自己搭后台麻烦,在大公司要后台帮你搭也不容易,这又会导致使用 JSPatch 的门槛还是很高。

于是在想这部分工作能不能也帮使用者省了呢?

对此我搭建了 JSPatch 平台 (http://JSPatch.com), 让使用 JSPatch 的人不需要搭建后台,直接通过平台下发补丁代码。

这个平台几个月前已经开放注册,现在所有人都可以使用。

在搭建这个平台时,碰到一个问题值得分享一下,就是如何支持高并发?

由于 JSPatch 的补丁特性,补丁需要及时推送给用户,也就是说至少需要在每次启动时向服务端请求询问 APP 是否有新的补丁,有的话下发执行。这里询问的请求量是很高的,单个 APP 可以控制,但平台要面对多个 APP,累计起来的请求数量会非常多,并发会很高,怎样支撑这样的高并发?

正常来说这样一个系统整体设计大致是这样的:

平台用户把脚本放到平台服务端,服务端的数据库保存着脚本的各种信息和内容,APP 客户端向平台发起请求询问是否有新脚本,平台服务端接收到请求后通过 CGI 处理请求参数,根据 APPkey 等参数从数据库拿出这个 APP的信息,然后组装数据告诉APP客户端有没有新脚本。

这里的询问请求至少时 APP 每次启动都要发一次请求,才能保证脚本的更新能尽快下发。请求量大时,这里从数据库取出数据很容易成为整个系统的瓶颈,CGI 处理请求参数和组装数据也要耗不少资源。

对此我改用了另一种方式:

平台用户上传脚本到平台服务器时,服务端除了把 APP 信息存在 DB 外,同时会另外上传一份 JSON 静态文件到静态云服务器,JSON 里保存了当前补丁的版本,而这个静态资源的文件名是由 APPkey/APP 版本号组成的。

例如这里脚本补丁版本号是10,这个JSON静态文件的内容就是 {v:10}。可以想象静态文件的访问路径就是:

http://JSPatch.com/{APPkey}/{APP_version}.json

然后 APP 客户端不再向平台服务端发请求,而是向这个静态资源服务器发请求,根据 APPKey 和 APP 版本直接请求到这个 JSON 文件,里面带的版本号信息就可以告诉 APP 脚本是否有更新。

整个流程就变成了:

APP 向静态服务器询问是否有新补丁,静态服务器直接返回预先设置好的 JSON,就结束了。

这样 APP 永远不会跟平台服务器打交道,只需跟静态资源服务器打交道,静态资源的高并发处理起来就简单得多,成本也低很多,现在有很多静态资源云存储,直接接入就可以了,以这些云存储的能力,支持多高的并发都没有问题,用户量多大的 APP 接入都可以支撑到。就是这样 JSPatch 平台解决了高并发问题,可以投入使用。

接下来在开发效率上,有一个问题是转换代码效率低。

我们用 JSPatch 修复 bug 时时以方法为单位进行替换的,若原方法有上百行,你的需求只是修改其中一两行代码,你也要把这上百行代码人工翻译成 JS 才行。对此我开发了JSPatch Convertor 这个工具,可以自动把 OC 代码转为 JSPatch 代码,提升开发效率。

这个工具也开源在 github 上(https://github.com/bang590/JSPatchConvertor), 支持了大部分语法特性,但目前还做不到支持所有特性,像私有变量/静态变量/宏这些还不支持,所以转换后需要人工修改,但还是很大地提高了使用 JSPatch 的效率。

总结下来,在降低使用门槛上,JSPatch 保证了易用性,封装了安全方案,提供了 JSPatch 平台让使用者可以直接接入,另外还有完善的文档和解析文章保证使用无障碍。提高使用效率上,做了 JSPatch Convertor 自动转换代码,也内置了一些扩展方便直接调用一些常用的 C函数。

经过不断发展,JSPatch 可以说是 iOS hotfix 的最佳解决方案。

目前大部分应用都已经接入使用,据不完全统计至少有 2500 个 APP 接入,经过了的大用户量的考验。

三、下一步

接下来说说下一步的计划,JSPatch 在 hotfix 上已经做得不错,目前下一步打算推动使用 JSPatch 开发功能模块。

JSPatch 做这个事情跟 React Nativeweex 这类方案比起来,会有一些优势:

  • 首先 React Native 和 weex 都是从前端出发扩展到终端,是前端方案的延伸,他们的体系对于前端来说更熟悉,对于终端来说,意味着要重新学习前端的一套知识,学习成本较高,而 JSPatch 是从终端出发,编码体系也差不多是直译 OC,学习成本较低。

  • 第二点是 ReactNative 和 Weex 是比较大型的框架,环境配置都很复杂,也会增大不少安装包的大小,如果说只想扩展实现一两个小功能,接入这么大型的框架不合适。而 JSPatch 前面也说了,属于微型框架,只有三个文件,也无需环境配置。

  • 第三点是 ReactNative 和 Weex 的组件都是要一个个封装好,难以复用现有的 OC 组件,并且他们都是大型框架,在未成熟阶段框架本身实现上的坑会很多,而 JSPatch 可以直接复用所有 OC 现有组件,并且只是薄薄的转接层,坑会较少。

但 JSPatch 要用于开发功能,有两个问题:

  1. 开发效率较低
  2. 运行效率较低。

在开发效率上,我做了两件事去提高,第一个是 JSPatchX 代码补全插件 (https://github.com/bang590/JSPatchX)。

写 JSPatch 代码时并不像 OC 那样有代码补全,在调用 OC 长长的方法时效率会很低,而且用 JSPatch 写功能时,不像 hotfix 那样有对应的 OC 代码,也无法使用前面说的 JSPatchConvertor 进行转换。于是做了 JSPatchX 去弥补这个缺陷,可以在 XCode 自动提示补全 JSPatch 代码。

另一个是 Playground 即时刷新范例 (https://github.com/bang590/JSPatch/tree/master/Demo/iOSPlayground)

可以实时预览 JSPatch 脚本执行的结果,无需像原生代码那样每一次修改都要 build 重启才能看到效果,这也是脚本语言的优势。使用者可以仿照这个 playground 的实现,在开发功能时在自己的页面实现这样的即时刷新,这样一定程度上提高了开发效率。

接下来看看运行效率。

JSPatch 写功能时运行效率低,于是着手进行优化,第一步是确定瓶颈,发现运行速度最慢的在于在 JS 调用 JS 上定义的新方法。

例如这里新定义了一个dribbbleView类,里面有个新方法renderItem,在 JS 里调用这个新方法时,速度很慢。

分析下这个调用过程:

主要问题在于这个新定义的方法与 OC 挂钩,这一次普通的调用,需要在 JS 和 OC 之间不断来回通信,不断进行参数转换,经过这9个步骤后才能成功调用。

对此我通过一些手段做了优化,把这样的方法直接放在 JS 环境上,在 JS 调用这个方法时无需再与 OC 通信,整个调用流程就变成了只有两步:

经过这个优化后,这样的方法调用性能最高提高 700 倍,这才使 JSPatch 写功能变成一件靠谱的事。

除此之外还做了一些其他优化,包括提升新增 property 性能,提供跟定义 OC 类一样的纯 JS 类定义接口,自动转换参数类型等,具体优化细节可以在这篇文章(http://blog.cnbang.net/tech/3123/) 上看到。

我用 JSPatch 写了个 Dribbble 客户端 demo (https://github.com/bang590/JSPatch/tree/master/Demo/DribbbleDemo)  在 iPhone5C 上测试过,滑动性能没有问题。

最后,可以从这个脑图看出 JSPatch 的现状,周边设施仍在继续建设中。

我今天的分享就到这里,谢谢。

问答环节:

Q1: JSPatch 的底层原理跟 ReactNative 是不是差不多呢?有受到其启发么?

JSPatch 的原理跟 ReactNative 是完全不一样的,JSPatch 是 OC 方法调用和替换的一层转接,ReactNative 并不会去调用和替换 OC 方法,它有自己的一套通信规则。

Q2: 本身基于OC runtime 对 Swift 的项目如何支持?

Swift 相关问题在 wiki 里有提到:

  1. 只支持调用继承自 NSObject 的 Swift 类
  2. 继承自 NSObject 的 Swift 类,其继承自父类的方法和属性可以在 JS 调用,其他自定义方法和属性同样需要加 dynamic 关键字才行。
  3. 若方法的参数/属性类型为 Swift 特有(如 Character / Tuple),则此方法和属性无法通过 JS 调用。
  4. Swift 项目在 JSPatch 新增类与 OC 无异,可以正常使用。

Swift 的原生类目前没找到替换的方法,动态调用倒是可以实现。

Q3: JSPatch 运行一次就会把JS转换为 OC 缓存起来?那我们可以利用它去做一些重复调用的事情?甚至用来开发?它的效率和原生相近吧?

会缓存一些 methodSignature,但还是得通过反射 (className->class->imp) 去找到要调用的方法,效率会比原生低。但一般程序的瓶颈不会在语言这里。

Q4: 对于 JSPatch 资源更新服务平台还是表示一些担忧,如果被别人攻破了,岂不是很多 APP 都受牵连了?

JSPatch 平台就算平台被人黑了,也无法对平台上的 APP 下发恶意代码。只要使用者用了自定义的 RSA 密钥就可以了,只有使用者有私钥,每次发布脚本都要使用这个私钥,平台不会保存它,详情可见:http://JSPatch.com/DOCs/rsa

Q5: 现在 iOS 加快了审核速度,好像现在是24小时内审核上线。那现在 JSPatch 前景还会好么?

审核只是一个环节,测试/打包/发布/用户下载,这些其他环节还是不可少,并且最大的问题还是是用户下载更新不可控。

Q6: Swift 属于静态编译类型,是不是可以利用类似 c函数替换的方法呢?像 fishhook 这样的工具

fishhook 需要编译时确定要替换的函数指针,并不能在运行时替换任意 c函数

Q7: 我看网上的一些介绍说 JSPatch 对小的 bug 修复好点,大的还是提交新的版本,但是我看您介绍使用静态资源服务器管理.应该不存在数据量大,并发的问题.这个您怎么看?

他指的大的 bug 应该是要写很多代码才能修复的 bug 吧?这点应该跟 JSPatch 开发效率问题有关,对于大量的代码他不想原生 OC 写一套修复,再用 JSPatch 写一套,跟数据量和并发应该没什么关系。

Q8: 为何 JSPatch 上面,QQmail 没有接入?有什么顾虑吗?

因为QQ邮箱在 JSPatch 出现之前已接入 lua,刚出现时 JSPatch 还不是很成熟,团队当时想同时使用两种方案作对比,时间久了也没有再切换过来了。

Q9: 有没有可能进一步提升 JSPatch convertor 的能力。最终发到直接打开 Xcode 项目,寻找依赖,通过语法语义分析等,将 OC 转换为 JS

可以做到的,不过这事要投入很大精力,之前有搞过一个 demo,直接用 OC 写 Patch,然后在执行前转换成 JSPatch 代码,有一个开源库 JSTalk (https://github.com/ccgus/JStalk) 有基本的 OC->JS 的转换,但要做到好用还有很多工作。

Q10: 请问如果我的 APP 引入了 JSPatch, 但是产生 crash 的代码并不是通过 JS 写的, 而是原生的 OC 代码, 那么 JSPatch 可以通过下发 JS 脚本修复这种 crash 吗, 如果可以的话, 原理是怎样的?

可以,原理就是把导致 crash 出现的方法替换掉,OC 调用那个方法时转成调用 JSPatch 里写的替换的方法,就不会 crash 了

Q11: 有没有意识到 JSPatch 的性能瓶颈最终都取决于 JavascriptCore 的性能?所以低端机永远是低性能,有没有想过借鉴 JSX 做点事情呢?

JavascriptCore 的性能并没有问题,性能瓶颈不是 JavascriptCore,目前来看瓶颈会是 OC 与 JS 通信时大对象的参数转换,但这是可以避免的

Q12: JSPatch 效率怎么样啊

效率可以试试上文说的 dribbbleDemo

Q13: 调试 JSPatch 时能不能打断点,如何定位到 JS 的 crash 堆栈

可以断点,文档有写:https://github.com/bang590/JSPatch/wiki/JS-断点调试

如果大家对本次分享还有问题,请按以下格式在DEV社区(dev.qq.com)发问答帖

发帖格式:【bang@DEV Club 你问我答】 “问题”

最后,欢迎大家关注 JSPatch 公众号:JSPatchDev,会即时推送 JSPatch 最新信息以及相关技术文章:

查看 Dev Club 往期分享汇总