iOS 中的 JS

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

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

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

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

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

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

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

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

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

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

<figure>
image

</figure>

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

JSCore 的原理和通信机制

JSCore 是什么?

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

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

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

<figure>
image

</figure>

JSCore 如何运行呢?

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

<figure>
image

</figure>

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

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

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

JS 和 OC 数据类型互换

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

<figure>
image

</figure>

在 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 弹层:

<figure>
image

</figure>

利用这个特性我们可以让 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 <web> component》 的 PR,大概就是利用上述思路,通过实现 W3C 的 MessageEvent 规范来让组件和 Weex 之间可以进行互相通信,同时通过 loadHTMLString 直接来渲染传入的 html 源码功能。

具体实现为:

<figure>
image

</figure>

具体思路和 Demo 可见 [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

<figure>
image

</figure>

主要代码大致如下:

// 一段显示蓝色 Hello World 的 OC 代码
  @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 的源码,发现有如下这些常用的方法:

<figure>
image

</figure>

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

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()元函数的唯一性,可以每次调用它时候,转发给一个指定函数去执行,就很优雅了。

<figure>
image

</figure>

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 上文中 Demo 源码 来进行测试。

由于我还处在 iOS 补基础阶段,可能有些地方理解不到位,欢迎一起讨论。
转载:
https://zhuanlan.zhihu.com/p/34646281

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,470评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,393评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,577评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,176评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,189评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,155评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,041评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,903评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,319评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,539评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,703评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,417评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,013评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,664评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,818评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,711评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,601评论 2 353

推荐阅读更多精彩内容

  • 用到的组件 1、通过CocoaPods安装 2、第三方类库安装 3、第三方服务 友盟社会化分享组件 友盟用户反馈 ...
    SunnyLeong阅读 14,613评论 1 180
  • 今天心情有点烦躁,可能是看了产妇跳楼的新闻,突然有点后怕,生孩子到底有多痛,上次检查是臀位,之前一直都是头位,可能...
    小佳小小阅读 132评论 0 0
  • 秋雨过后,树叶儿慢慢地黄了,花草逐渐凋零,惟有傲霜的菊花被秋雨唤醒。一盆盆形态各异的菊花不时从庭院探出头来,金黄的...
    清逸幽兰阅读 870评论 2 7
  • 斯里兰卡,一个小岛国家,印度洋上的一滴眼泪,热带雨林气候,风景很好,温度其实并没有我想象中的那么高,但是太阳却是很...
    我爱大大大太阳_aaf5阅读 264评论 0 0