iOS 中的 JS

Categories:  Native-JS

最近主要在研究 iOS 中的 JS 这一块内容,打算从为什么不能单纯地搞前端、JSCore 的原理和通信机制、OC 底层 Runtime 原理、如何通过 JS 任意修改 iOS 的运行结果 这 4 部分来阐述,旨在让前端和 iOS 开发同学更加了解跨端开发的原理,同时了解他俩结合起来做哪些意想不到的事情。

为什么不能单纯地搞前端?

毕业工作以来,经历过移动端 H5 到 RN 开发,到去年的 Weex 开发和最近的 iOS 开发,越来越发现:

仅靠前端技术难以满足移动端的用户需求或体验要求

为什么单纯弄前端会效果不好?

可能大家 H5 同学很有感触,有时候需要做一个端上照片上传功能,通过 JS 去实现往往效果会大打折扣,同时也很难达到业务方需要的顺滑体验,要是此时 Native 同学说我写好了一个 Bridge,只需在客户端里调用Bridge.uploadImg()这个方法就可直接用 Native 的上传功能,听到这句话你肯定会长舒一口气轻松地去写代码了。

还有将端上 Webview 由 UIWebView 更换成 WKWebView,起到的效果也会比自己优化很久的 H5 顺滑滚动来得快和好。

那么潮流前端同学一般怎么弄移动端需求呢?

他们一般会借 Native 端的能力,譬如用 Weex 或者 RN 来开发页面,让其也有 Native 效果,假如有页面在微信或者支付宝,还可以升级成小程序变成内置程序一样,客户端中还可以借助端上容器优化这一块,让其可以离线我们的 H5 页面,并通过桥提供很多 Native 功能来拓展能力。

这里借力的的桥梁其实就是 Bridge,让两者不在是一个孤岛,而是相互助力,我理解它可以做这些事情:

接下来通过JSCore 的原理和通信机制这一小节给出上述的解决方案。

JSCore 的原理和通信机制

JSCore 是什么?

大家都知道浏览器内核的模块主要是由渲染引擎JS 引擎组成,其中 JSCore 就是一种 JS 引擎

Apple 通过将 WebKit 的 JS 引擎用 OC 封装,提供了一套 JS 运行环境以及 Native 与 JS 数据类型之间的转换桥梁,常用于 OC 和 JS 代码之间的相互调用,这也意味着他可以脱离渲染单独去执行 JS。

JSCore 主要包括如下这些 classes、协议、类结构:

JSCore 如何运行呢?

可以通过如下这张 JSCore 的框架结构图和上述描述来看懂各个模块是怎么运行的。

从上图我们可以看到一个这样的过程:

在 Native 应用中我们可以开启多个线程来异步执行我们不同的需求,也就意味着我们可创建多个 JSVirtualMachine 虚拟机(运行资源提供者),同时相互隔离不影响,这样我们就可以并行地执行不同 JS 任务

在一个 JSVirtualMachine 中还可以关联多个 JSContext (JS 执行环境上下文),并通过 JSValue(值对象) 来和 Native 进行数据传递通信,同时可以通过 JSExport (协议) ,将 Native 中遵守此解析的类的方法和属性转换为 JS 的接口供其调用。

JS 和 OC 数据类型互换

从上小节,可以知道 JSValue 可以用来让 JS 和 OC 之间无障碍的数据转换,主要原理是 JSValue 上面提供了如下方法,便于双方各种类型进行转换。

在 iOS 里面执行 JS 代码

我们可以通过evaluateScript在 JSCore 中执行一段 JS 脚本,利用这个特性我们可以来做一些多端逻辑统一的事情。

  // 执行一段 JavaScript 脚本
  -(JSValue *)evaluateScript:(NSString *)script;

比如业务中 3 端(iOS、Android、H5)有一段相当复杂的但原理一样算价逻辑,一般做法是 3 端用各自语言自己写一套,这样做不但麻烦、效率低而且逻辑不一定统一,同时用 OC 去实现复杂计算逻辑也没有 JS 这么灵活高效。

这里就可以利用执行 JS 代码这个特性,将这个逻辑抽成一个 JS 方法,只需要传入特定的入参,直接返回价格,这样的话,3 端可以同时使用这个逻辑,还可以放到远端进行动态更新维护。

大概这样实现:

  // 在 iOS 里面执行 JS
  JSContext *jsContext = [[JSContext alloc] init];
  [jsContext evaluateScript:@"var num = 500"];
  [jsContext evaluateScript:@"var computePrice = function(value){ return value * 2 }"];
  JSValue *value = [jsContext evaluateScript:@"computePrice(num)"];
  int  intVal = [value  toInt32];
  NSLog(@"计算结果为 %d", intVal);

运算结果为:

2018-03-16 20:20:28.006282+0800 JSCoreDemo[4858:196086] ========在 iOS 里面执行 JS 代码========
2018-03-16 20:20:28.006517+0800 JSCoreDemo[4858:196086] 计算结果为 1000

我认为还可以在正则校验动画函数3D 渲染建模等这些数据计算方面来使用它。

在 iOS 里面调用 JS 中方法

说完在 iOS 中执行 JS 代码,接下来给大家介绍下,如何在 iOS 中调用 H5 中的 JS 方法。

比如我们 H5 中有一个全局方法叫做 nativeCallJS,我们可以通过执行环境的上下文 jsContext[@"nativeCallJS"] 获取该方法并进行调用,类似这样:

 // Html 中有一个 JS 全局方法
  <script type="text/javascript">
    var nativeCallJS = function (parameter) {
      alert(parameter);
    };
  </script>
  // 在 iOS 运行 JS 方法
  JSContext *jsContext = [webView valueForKeyPath:@“documentView.webView.mainFrame.javaScriptContext”];
  JSValue *jsMethod = jsContext[@"nativeCallJS"];
  jsMethod callWithArguments:@[ @"Hello JS, I am iOS" ]];

最终我们的运行结果就可以看到 Native 执行到了 H5 的 Alter 弹层:

利用这个特性我们可以让 iOS 获取到一些 H5 的信息来处理一些他想处理的东西,譬如先将信息在全局中暴露出来,通过调用方法获取到 使用的版本号、运行的环境信息、端主动处理逻辑(清除缓存、控制运行)等这些事情。

在 JS 里面调用 iOS 中方法

其实对于前端同学使用得最多的应该是这个,通过 JS 调用一些端上能力来弥补 H5 上的不足。

这里需要和@"documentView.webView.mainFrame.javaScriptContext"这个 webview 相关特性结合起来,将 H5 调用的方法用 Block 以jsCallNative(调用方法名)为名传递给 JSCore 上下文。

比如我们 H5 中有一个按钮的点击回调是 去调用客户端的一个方法,同时在方法中输出传入参数,大致是这样实现:

 // Html中按钮点击调用一个OC方法
  <button type="button"
      onclick="jsCallNative('Hello iOS', 'I am JS');">调用OC代码</button>
  //Block 以”jsCallNative"为名传递给JavaScript上下文
  JSContext *jsContext = [webView valueForKeyPath:
            @"documentView.webView.mainFrame.javaScriptContext"];
   jsContext[@"jsCallNative"] = ^() {
       NSArray *args = [JSContext currentArguments];
       for (JSValue *obj in args) {
           NSLog(@"%@", obj);
       }
    };

最终输出是这样:

2018-03-16 20:51:25.590749+0800 JSCoreDemo[4970:219245] ========在 JS 里面调用 iOS 中方法========
2018-03-16 20:51:25.591155+0800 JSCoreDemo[4970:219245] Hello iOS
2018-03-16 20:51:25.591370+0800 JSCoreDemo[4970:219245] I am JS

这个特性真正让 H5 可以享受到很多端上的特性,比如Native 方式的跳转、Native 底层能力(震动、录音、拍照)、扫码、获取设备信息、分享、设置导航栏、调用 Native 封装组件等这些功能,此处大家可以联系 Hybrid 开发模式。

通过 JSExport 暴露 iOS 方法属性给 JS

这个特性可能 H5 的同学不是很清楚,但是对于 Native 同学,我认为非常有用。

通过 JSExport 可以很方便地将 iOS 对象的属性方法暴露给 JS 环境,让其使用起来像 JS 对象一样方便。

比如我们 OC 中有一个 Person 的类,包含两个属性和一个方法,此处通过让fullName方法使用 JSExport 协议暴露出去,这样在 JS 中是可以直接去调用的。

  @protocol PersonProtocol <JSExport>
  -(NSString *)fullName;
  @end

  @interface Person : NSObject <PersonProtocol>
  @property (nonatomic, copy) NSString *firstName;
  @property (nonatomic, copy) NSString *lastName;
  @end

  @implementation Person
  @synthesize firstName, lastName;
  (NSString *)fullName {
    return [NSString stringWithFormat:@"%@ %@",   self.firstName, self.lastName];
  }
  @end
  // 通过 JSExport 暴露 iOS 方法属性给 JS
  Person *person = [[Person alloc] init];
  jsContext[@"p"] = person;
  person.firstName = @"Fei";
  person.lastName = @"Zhu";
  NSLog(@"========通过 JSExport 暴露 iOS 方法属性给 JS========");
  [jsContext evaluateScript:@"log(p.fullName());"];
  [jsContext evaluateScript:@"log(p.firstName);"];

最终运行结果为:

  2018-03-16 20:51:17.437688+0800 JSCoreDemo[4970:219193] ========通过 JSExport 暴露 iOS 方法属性给 JS========
  2018-03-16 20:51:17.438100+0800 JSCoreDemo[4970:219193] Fei Zhu
  2018-03-16 20:51:17.438388+0800 JSCoreDemo[4970:219193] undefined

为什么p.firstName运行后是undefined呢? 因为在这里没有将其暴露到 Native 环境中,所以就获取不到了。

这里我们可以利用的更多的是在编程便捷性上面,让 OC 和 JS 直接可以相互调用。

在 iOS 里面处理 JS 异常

稍微成熟一点的公司都会有前端页面的异常监控系统,发现 JS 执行异常可以直接通知开发以防止线上事故的发生。

通过 JSCore 中的 exceptionHandler 可以很好的解决这个问题,当 JS 运行异常时候,会回调 JSContext 的 exceptionHandler 中设置的 Block,这样我们可以在 Block 回调里面将我们的错误上传到监控平台。

比如这个例子,我运行一个返回a+1的函数,平时我们在 Chrome console 可以看到报错Can't find variable: a,这里运行也会一样:

   // 当JavaScript运行时出现异常
   // 会回调JSContext的exceptionHandler中设置的Block
   JSContext *jsContext = [[JSContext alloc] init];

   jsContext.exceptionHandler = ^(JSContext *context, JSValue  *exception) {
        NSLog(@"JS Error: %@", exception);
    };

  [jsContext evaluateScript:@"(function errTest(){ return a+1; })();"];

最后输出报错为:

2018-03-17 11:28:07.248778+0800 JSCoreDemo[15007:632219] ========在iOS里面处理 JS 异常========
2018-03-17 11:28:07.252255+0800 JSCoreDemo[15007:632219] JS Error: ReferenceError: Can't find variable: a

JS 和端相互通信

最近给 Weex 提交了一个[《More enhanced about component》](https://github.com/apache/incubator-weex/pull/1047) 的 PR,大概就是利用上述思路,通过实现 W3C 的 MessageEvent 规范来让组件和 Weex 之间可以进行互相通信,同时通过 loadHTMLString 直接来渲染传入的 html 源码功能。

具体实现和效果为:

具体思路可见[WEEX-233][iOS]

JSPatch

让我们更深一步来思考上述思路可否再次进行扩展,能否通过 JS 直接来干预 iOS 代码的运行呢?答案是可以的,下面我想整理一下我对 JSPatch 的理解。

假如 iOS 想不发版改 bug

假如线上 APP 有一段代码出现 bug 导致 crash,可能 Native crash 会比 H5 问题严重很多,前者可以立马发布,后者可能需要修改好提交 Apple 商店数日才上线,然后可能更新率还上不去,很麻烦的。

这里就可以通过 JSPatch 这种类似的方案,下发一段代码覆盖掉原来有问题的方法,这样可以很快修复这个 bug。

可以通过一个简单的例子来看上述过程。

用 OC 写了一个蓝色的Hello World, 我们可以通过下发一段 JS 代码将原来蓝色的字体修改成红色并修改文字,将 JS 代码下发代码删除后,又可以恢复原来的蓝色Hello World

JS 下发前JS下发后 JS 删除后

主要代码大致如下:

  // 一段显示蓝色Hello World 的代码
  @implementation ViewController
    - (void)viewDidLoad {
        [super viewDidLoad];
        [self simpleTest];
    }
    - (void)simpleTest {
        self.label.text = @"Hello World";
        self.label.textColor = [UIColor blueColor];
    }
  @end
  // 一段符合 JSPatch 规则的JS覆盖代码
  require('UIColor');
  defineClass('ViewController', { simpleTest : function() {
    self.label().setText("你的蓝色 Hello World 被我改成红色了");

    var red = UIColor.redColor();
    self.label().setTextColor(red);
  },
  })

这里是如何做到的呢?首先需要介绍下 JSPatch:

JSPatch 是一个 iOS 动态更新框架,通过引入 JSCore,就可以使用 JS 调用任何原生接口,可以为项目动态更新模块、替换原生代码动态修复 Bug。

也即 JS 传递字符串给 OC,OC 通过 Runtime 接口调用和替换 OC 方法。

为什么可以通过 JS 调用任何原生接口呢? 首先可以了解下 OC 底层 Runtime 的原理。

Runtime

OC 语言中大概 95% 都是 C 相关的写法,为何当时苹果不直接使用 C 来写 iOS 呢?其中一个很大的原因就是 OC 的动态性,有一个很强大的 Runtime (一套 C 语言的 API,底层基于它来实现),核心是消息分发,Runtime 会根据消息接收者是否能响应该消息而做出不同的反应

也许上述会比较生涩,简单说就是OC 方法的实现和调用指针的关系是在运行时才决定的,而非编译期,这样的话,我们可以在运行期做些事情更改原来的实现,达到热修复的目的。

OC 方法的调用不像 JS 这种语言,直接array.push(foo)函数调用即可,他是通过消息机制来进行调用的,比如如下这个将foo插入到数组中的第 5 位:

  [array insertObject:foo atIndex:5];

在底层比这个实现更加生涩,他通过objc_msgSend这个方法将消息搭配选择器进行发送出去:

  objc_msgSend(array, @selector(insertObject:atIndex:), foo, 5);

运行时发消息给对象,消息是如何映射到方法的 ?

简单来说就是,一个对象的 class 保存了方法列表,是一个字典,key 为 selectors,IMPs 为 value,一个 IMP 是指向方法在内存中的实现,selector 和 IMP 之间的关系是在运行时才决定的,非编译时。

  - (id)doSomethingWithInt:(int)aInt{}
  id doSomethingWithInt(id self, SEL _cmd, int aInt){}

通过看了下Runtime 的源码,发现有如下这些常用的方法

通过上述这些方法就可以做很多意想不到的事情,比如动态的变量控制、动态给一个对象增加方法、可以把消息转发给想要的对象、甚至可以动态交换两个方法的实现

JSPatch && Runtime

正是由于 OC 语言的动态性,上所有方法的调用/类的生成都通过 OC 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);
  objc_registerClassPair(cls);
  class_addMethod(cls, selector, implement, typedesc);

JSPatch 正是利用如上这些好的特性来实现他的热修复功能。

JSPatch 中 JS 如何调用 OC

此处 JSPatch 中的 JS 是如何和任意修改 OC 代码联系起来的呢?大概原理如下:

1.JSPatch 在实现中是通过 Require 调用,在 JS 全局作用域上创建一个同名变量,变量指向一个对象,对象属性 __clsName 保存类名,同时表明这个对象是一个 OC Class,通过调用require(“UIView"),我们就可以使用UIView去调用他上面对应方法了。

  UIView = require(UIView");

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

2.在 JSCore 执行前,OC 方法的调用均通过新增 Object(JS) 原型方法__c(methodName)完成调用,假如直接调用的话,需要 JS 遍历当前类的所有方法,还要循环找父类的方法直到顶层,无疑是很耗时的,通过__c()元函数的唯一性,可以每次调用它时候,转发给一个指定函数去执行,就很优雅了。

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

3.处理好 JS 接口问题后,接下来只需要借助前面 JSCore 的知识就可以做到 JS 和 OC 之间的消息互传了,也即在在_c 函数中通过 JSContex 建立的桥接函数,用  Runtime 接口调用相应方法完成调用

  • JS 中的转发
 var _methodFunc = function(instance, clsName, methodName, args,  isSuper) {
     ....
     var ret =  _OC_callC(clsName, selectorName, args)
     return _formatOCToJS(ret)
  }
  • OC 中的处理
  context[@"_OC_callC"] = ^id(NSString *className, NSString     *selectorName, JSValue *arguments) {
  return callSelector(className, selectorName, arguments, nil, NO);
 };

感触

上面大概就是如何通过 JS 任意修改 OC 运行结果的一个原理,虽然 JSPatch 大部分功能被禁用了,但是其中 JS 操作 OC 的思路真的很棒

《iOS 中的 JS》 其实是在上周象声汇的一个分享,将其进行整理成文章,分享给对跨端感兴趣的同学,同时尽量用接地气的方式,希望只有客户端基础或者前端基础的同学也可以看懂,同时也可以 Clone Github 中 Demo 源码 来进行测试。

由于还处在 iOS 补基础阶段,可能有些地方理解不到位,欢迎一起讨论

Buy Me a Coffee !
Read More

勿拖延

【2018-02-19】有点儿题文不相关,一直想写一篇关于生活的文章,总结下 2017,并计划下 2018,从春节前拖延到大年初四,应该说2017很喜欢拖延,故起名`勿拖延`,希望 2018 年遇事不拖延,想到就去做。