iOS原生与JS交互之JavaScriptCore

说明:本文的演示项目及图片均来自JavaScriptCore Tutorial for iOS: Getting Started

这不仅仅是一篇译文,更多的是我通过学习该教程的心得。我会以通俗易懂的方式让你迅速了解以下知识点:

  • JavaScriptCore框架的组件。
  • 如何用iOS代码(这里用swift)调用JavaScript方法。
  • 如何用JavaScript代码调用iOS原生代码

这篇教程不需要你是JS高手,但如果有兴趣可以去这里学习这门语言。

开始吧

巧妇难为无米之炊,我们先下载这篇教程的初始项目 。解压之后会有3个文件夹,这里我一一说明:

  • Web: 里面的HTML和CSS实现的web应用,正是我们接下来需要用iOS实现的。
  • Native: 我们的iOS项目,我们接下来的所有操作都在这个项目里面。
  • js: 项目中需要用到的js代码文件

Showtime是一款从iTuns搜索电影的应用,你可以通过输入电影价格来筛选相应价位的电影。我们先打开Web/index.html,输入数字然后按回车看看该应用的效果:

电影列表

OK,现在我们打开Native/Showtime的Xcode项目,run一下看是什么效果:


输入数字按回车之后没什么反应,别急下面就是我们的核心内容了。

JavaScriptCore

JavaScriptCore框架提供了一个能访问WebKit的JS代码的引擎。最初,它只是应用到Mac上的,而且还是纯C的API。但是iOS 7和OS X 10.9后它已经能用到iOS上而且还包装成了一套友好的OC接口 。该框架使得OC和JS代码之间能相互操作。

首先,我们来看看JavaScriptCore的三大核心组件:JSVirtualMachine、JSContext、和 JSValue

JSVirtualMachine

JSVirtualMachine类提供了一个能执行javaScript代码的虚拟机。 通常我们不需要直接与此类打交道,但它有一个重要用法:JavaScript代码的并发执行。因为在单个JSVirtualMachine中,是不能同时执行多个线程的, 要支持并行性,您必须多开几个虚拟机。

JSVirtualMachine的每个实例对象都有自己的堆和垃圾回收器, 虚拟机的垃圾收集器将不知道如何处理来自不同堆的值, 所以你不能在虚拟机之间传递对象。

JSContext

JSContext(上下文)实例创建一个JavaScript代码的执行环境。就像我们用Quartz2D画图时需要一个画图的Context环境一样。JSContext是一个全局对象,类似网页开发里面的窗口对象。 与虚拟机不同的是,你可以任意的在上下文之间传递对象(当然它们必须在同一个虚拟机中)。

JSValue

JSValue是你经常处理的主要数据类型:它可以表示任何可能的JavaScript value(包括对象、函数等)。 JSValue的实例将绑定在它所在的JSContext对象中。所有JSContext里面的对象都是JSValue类型。

下面是三者的关系图:


概念说到这里,是时候上代码了!

调用JavaScript方法

继续回到我们的Xcode项目,找到并打开MovieService.swift。该类的功能是从iTunes获取并处理搜索到的电影数据。我们的任务是将类里面的方法进行完整的功能实现:

  • loadMoviesWith(limit:onComplete:) 获取电影数据。
  • parse(response:withLimit:) 通过JavaScript代码处理电影数据。

MovieService类中找到loadMoviesWith(limit:onComplete:) ,将方法内容换成下面的代码:

func loadMoviesWith(limit: Double, onComplete complete: @escaping ([Movie]) -> ()) {
  guard let url = URL(string: movieUrl) else {
    print("Invalid url format: \(movieUrl)")
    return
  }
  
  URLSession.shared.dataTask(with: url) { data, _, _ in
    guard let data = data, let jsonString = String(data: data, encoding: String.Encoding.utf8) else {
      print("Error while parsing the response data.")
      return
    }
    
    let movies = self.parse(response: jsonString, withLimit: limit)
    complete(movies)
  }.resume()
}

上面的代码不做过多解释,就是用原生的URLSession加载数据,你可以打印出来看看加载的内容。接下来我们通过JS代码解析我们的数据,首先我们在MovieService上方导入JavaScriptCore框架:

import JavaScriptCore

接下来,我们用懒加载的方式在MovieService里面定义一个JSContext属性(我直接在代码里写注释):

// 0 
// 提供执行JS代码的上下文
lazy var context: JSContext? = {
  let context = JSContext()
  
  // 1
  // 获取common.js文件的路径
  guard let
    commonJSPath = Bundle.main.path(forResource: "common", ofType: "js") else {
      print("Unable to read resource files.")
      return nil
  }
  
  // 2
  // JSContext实例通过调用evaluateScript(...)来执行js代码,
  // 其主要作用是将js代码处理成全局的对象和函数放到JSContext中。
  do {
    let common = try String(contentsOfFile: commonJSPath, encoding: String.Encoding.utf8)
   // 这里忽略的返回值是 JSValue类型。
    _ = context?.evaluateScript(common)
  } catch (let error) {
    print("Error while processing script file: \(error)")
  }
  
  return context
}()

通过创建JSContext对象,现在我们可以调用JavaScript方法了。我们继续在MovieService类中找到** parse(response:withLimit:)**方法,并将以下代码插入其中:

func parse(response: String, withLimit limit: Double) -> [Movie] {
  // 1
  guard let context = context else {
    print("JSContext not found.")
    return []
  }
  
  // 2
  let parseFunction = context.objectForKeyedSubscript("parseJson")
  guard let parsed = parseFunction?.call(withArguments: [response]).toArray() else {
    print("Unable to parse JSON")
    return []
  }
  
  // 3
  let filterFunction = context.objectForKeyedSubscript("filterByLimit")
  let filtered = filterFunction?.call(withArguments: [parsed, limit]).toArray()

  // 4
   guard let movieDics = filtered as? [[String : String]] else {
      print("不可用的数据!")
      return []
   }
   let movies = movieDics.map { (dic) in
      return Movie(title: dic["title"]!, price: dic["price"]!, imageUrl: dic["imageUrl"]!)
   }
   return movies
}

我们一步一步看上面的代码:

  1. 判断JSContext是否成功创建(还有common.js是否成功导入)。

  2. 首先JSContext对象通过objectForKeyedSubscript(_ key: Any!) -> JSValue!方法在Context对象内部查找对应的属性或方法,这里的key值parseJson对应的是方法(你可以打开common.js查找到对应的方法),再通过返回值parseFunction(JSValue类型)用call来实现parseFunction函数的调用,返回值依然是JSValue类型。这里有必要先看下common.js里面parseJson函数的调用:

 var parseJson = function(json) {
    var data = JSON.parse(json);
    var movies = data['feed']['entry'];
    return movies.map(function(movie) {
        // 需要稍作说明的是这里的返回值是包含了三个属性的匿名对象
        return {
        title: movie['im:name']['label'],
        price: Number(movie['im:price']['attributes']['amount']).toFixed(2),
        imageUrl: movie['im:image'].reverse()[0]['label']
        };
     });
};

为了方便理解我们可以把parseJson函数的返回值可以看成是包含了title、price、 imageUrl属性的JS对象数组,由于是在JSVirtualMachine虚拟机里面调用的,目前它的类型还是JSValue。最后我们调用JSValue的toArray()方法来实现原生数组的转换。

  1. 和parseFunction使用方法一样。
  2. 纯原生操作:将字典数组转化成Movie对象数组

现在我们run一下我们的项目,Duang Duang Duang:


到这里我们完成了JS的调用并且实现了我们APP功能,回顾一下我们做了什么:首先我们创建了一个JSContext对象,然后加载了common.js代码到JSContext对象中,再通过key值在上下文中查找相对应的parseJson函数并调用它,接着将得到的值转换成原生数据类型,最后转换成我们所需要的Movie对象数组。整个过程代码量很少,也相当简单。为了加强理解,你可以在common.js文件中添加一些自定义的方法或属性,然后通过在MovieService类中进行调用或访问。例如:
在common.js文件中添加如下测试代码

var aBool = true;
var aStr = '我爱你中国';
var aDic = {"age":10}

function sum(num1, num2){
    return num1 + num2;
}
// js没有重载的概念下面的会覆盖上面的函数
function sum(num1, num2, num3){
    return num1 + num2 + num3;
}

在MovieService类的parse方法的标签//3 和 //4 中间添加如下测试代码,跑起来观察打印结果:

let aBool = context.objectForKeyedSubscript("aBool").toBool()// true
let aStr = context.objectForKeyedSubscript("aStr").toString()// 我爱你中国
let aDic = context.objectForKeyedSubscript("aDic").toDictionary()// "age":10
print("abool : \(aBool) \nStr : \(aStr) \naDic : \(aDic) \n")

// 这里的sum1并不会等于3,js没有函数重载的概念
let sum1 = context.objectForKeyedSubscript("sum")?.call(withArguments: [1,2]).toInt32()
let sum2 = context.objectForKeyedSubscript("sum")?.call(withArguments: [1,2,3]).toInt32()
print("\(sum1) \(sum2)")// 0 6

JavaScript调用iOS原生代码

这里有两种方式实现JavaScript在运行时调用原生代码:
第一种方式是将我们需要将暴露给JS调用的方法定义成blocks。blocks将自动桥接成JavaScript方法。 但是有一个小问题:这种方法只适用于Objective-C block,而不适用于Swift闭包。 为了解决这个问题我们需要按照下面两点去做:

  • 在swift闭包前面加上@convention(block)属性,使其桥接成OC的block。
  • 在将block映射成JavaScript方法之前,需要将block转换为AnyObject。

下面我们先删掉测试代码,让我们代码更加清爽。然后我们跳到Movie.swift这个文件,并添加下面的方法到Movie中:

static let movieBuilder: @convention(block) ([[String : String]]) -> [Movie] = { object in
  return object.map { dict in
    
    guard
      let title = dict["title"],
      let price = dict["price"],
      let imageUrl = dict["imageUrl"] else {
        print("unable to parse Movie objects.")
        fatalError()
    }
    
    return Movie(title: title, price: price, imageUrl: imageUrl)
  }
}

上面定义的闭包所做的就是将JS对象(dictionary)数组转换成Movie实例。注意:这里我们将闭包添加了**@convention(block) **属性。

接下来我们跳到** MovieService.swiftparse(response:withLimit:)**,我们将标签 //4下面的代码换成:

// 1
let builderBlock = unsafeBitCast(Movie.movieBuilder, to: AnyObject.self)

// 2
context.setObject(builderBlock, forKeyedSubscript: "movieBuilder" as (NSCopying & NSObjectProtocol)!)
let builder = context.evaluateScript("movieBuilder")

// 3
guard let unwrappedFiltered = filtered,
  let movies = builder?.call(withArguments: [unwrappedFiltered]).toArray() as? [Movie] else {
    print("Error while processing movies.")
    return []
}

return movies

代码说明:

  1. 调用swift的unsafeBitCast(_:to:)将我们预先声明的block转换成AnyObject

  2. 先通过调用setObject(_:forKeyedSubscript:)方法将block载入到JS runtime中,然后再通过调用evaluateScript() 获取block在JS runtime中的函数引用(通过这个引用可以调用该block)。

  3. 和之前调用call的方式一样,获取到JSValue的数组,最后转换成Movie数组。不同的是执行block的时候已经在block代码块里面讲字典转换成了Movie对象,最后只需要简单的调用toArray()就可以得到Movie数组了。

说明:之前我们调用JS函数的时候是先通过context.objectForKeyedSubscript(函数名)拿到函数再调用的,而这里我们其实也可以通过context.objectForKeyedSubscript("movieBuilder")拿到block。但由于context.evaluateScript("movieBuilder")这个方法在执行完JS代码之后会将名称为movieBuilder的block以JSValue的形式返回回来,这样我们也可以直接使用这种方式。

现在我们run一下我们的APP,效果应该是一样的。到这里我们完成了第一种JS调用原生dai'm代码的方式。回顾一下我们做了什么:首先我们创建了一个能将字典数据转换成Movie对象的swift闭包,并将其转换成OC的block,然后将block转换成AnyObject对象并桥接到JSContext对象中,这样就完成了原生代码暴露到JavaScript runtime中以供其调用**。

最后我们来看另外一种JavaScript runtime调用原生代码的方式:

JSExport Protocol

在JavaScript中使用原生代码的另一种方法是使用JSExport协议。 首先你得创建一个JSExport的协议,并声明要暴露给JavaScript的属性和方法。
对于你导出来的每个原生类,JavaScriptCore将在相应的JSContext实例中创建一个原型(prototype)。 但是JavaScriptCore框架也是有选择性的创建:默认情况下,类的任何方法或属性都不会暴露给JavaScript,因此你必须指定需要导出的内容。 JSExport的规则如下:

  • 如果导出的是实例方法,JavaScriptCore将创建一个相应的JavaScript原型对象函数。

  • 类的属性将作为JavaScript原型的访问器属性导出。

  • 对于类方法,框架将创建一个JavaScript构造函数。

我们的任务是将** Movie.swift 暴露出来,先跳到 Movie**类的上方创建一个JSExport协议:

import JavaScriptCore

@objc protocol MovieJSExports: JSExport {
  var title: String { get set }
  var price: String { get set }
  var imageUrl: String { get set }
  
  static func movieWith(title: String, price: String, imageUrl: String) -> Movie
}

这里我们指定了一些暴露给JS的属性和一个创建Movie实例的类方法。后者相当重要,因为JavaScriptCore不能桥接构造器。

现在我们将Movie类实现的所有代码替换掉,使其遵循JSExport协议并实现:

class Movie: NSObject, MovieJSExports {
  
  dynamic var title: String
  dynamic var price: String
  dynamic var imageUrl: String
  
  init(title: String, price: String, imageUrl: String) {
    self.title = title
    self.price = price
    self.imageUrl = imageUrl
  }
  
// 该类方法就是调用Movie的构造器函数
  class func movieWith(title: String, price: String, imageUrl: String) -> Movie {
    return Movie(title: title, price: price, imageUrl: imageUrl)
  }
}

完成这些之后,我们看看怎样在JavaScript中是怎样调用Movie的。我们打开Resources文件夹里面的additions.js文件,相关代码已经写好:

var mapToNative = function(movies) {
  return movies.map(function (movie) {
    return Movie.movieWithTitlePriceImageUrl(movie.title, movie.price, movie.imageUrl);
  });
};

上面的方法将传入的数组的每个元素创建成Movie实例。值得注意的是Movie.movieWithTitlePriceImageUrl(movie.title, movie.price, movie.imageUrl)这个方法和我们之前创建的类方法名是不一样的:这是因为JS没有命名参数,所以需要将参数名以驼峰命名法的形式加到方法名的后面(这里的命名相当严谨,如有差错将不会正确调用)。

现在我们打开MovieService.swift文件,我们将懒加载的context实现做如下调整:

lazy var context: JSContext? = {

  let context = JSContext()
  
  guard let
    commonJSPath = Bundle.main.path(forResource: "common", ofType: "js"),
    let additionsJSPath = Bundle.main.path(forResource: "additions", ofType: "js") else {
      print("Unable to read resource files.")
      return nil
  }
  
  do {
    let common = try String(contentsOfFile: commonJSPath, encoding: String.Encoding.utf8)
    let additions = try String(contentsOfFile: additionsJSPath, encoding: String.Encoding.utf8)
    
    context?.setObject(Movie.self, forKeyedSubscript: "Movie" as (NSCopying & NSObjectProtocol)!)
    _ = context?.evaluateScript(common)
    _ = context?.evaluateScript(additions)
  } catch (let error) {
    print("Error while processing script file: \(error)")
  }
  
  return context
}()

这里将additions.js的代码加载到JSContext对象中以供使用。另外还使得Movie原型在这个上下文中可用。

最后,我们将**parse(response:withLimit:) **方法实现稍作调整:

func parse(response: String, withLimit limit: Double) -> [Movie] {
  guard let context = context else {
    print("JSContext not found.")
    return []
  }
  
  let parseFunction = context.objectForKeyedSubscript("parseJson")
  guard let parsed = parseFunction?.call(withArguments: [response]).toArray() else {
    print("Unable to parse JSON")
    return []
  }
  
  let filterFunction = context.objectForKeyedSubscript("filterByLimit")
  let filtered = filterFunction?.call(withArguments: [parsed, limit]).toArray()
  
// 调整的地方
  let mapFunction = context.objectForKeyedSubscript("mapToNative")
  guard let unwrappedFiltered = filtered,
    let movies = mapFunction?.call(withArguments: [unwrappedFiltered]).toArray() as? [Movie] else {
    return []
  }
  
  return movies
}

我们将之前使用闭包的方式换成在JavaScript runtime中使用mapToNative()创建Movie数组。现在重新跑一下我们的程序:

任务完成了,我们来总结一下如何使用JSExport的:首先我们创建一个JSExport协议并将需要暴露给JS的属性和方法进行声明,然后将Movieh和additon.js加载到JSContext中,最后用additon.js中的方法完成原生代码的调用。这里我们没有声明实例方法,那么实例方法怎么调用?另外假设协议里面有方法的重载JS是怎么调用?这些你可以自己去实践一下。这里提示一下:原生代码的函数转换成JavaScript调用的时候是用驼峰法方法名+With+参数名的方式**。

结束语

至此,我们已经完成了iOS原生与JavaScript代码的交互,这里有完整的项目代码。如果你想了解更多关于JavaScriptCore的知识,可以看看WWDC相关教程。谢谢您的阅读,如有问题,欢迎交流!

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

推荐阅读更多精彩内容

  • 本文由我们团队的 纠结伦 童鞋撰写。 写在前面 本篇文章是对我一次组内分享的整理,大部分图片都是直接从keynot...
    知识小集阅读 15,195评论 11 172
  • OC与JS交互之JavaScriptCore 本文摘抄自:https://hjgitbook.gitbooks.i...
    大冲哥阅读 1,014评论 0 1
  • 写在前面 本篇文章是对我一次组内分享的整理,大部分图片都是直接从keynote上截图下来的,本来有很多炫酷动效的,...
    等开会阅读 14,359评论 6 69
  • 跟原生开发相比,H5的开发相对来一个成熟的框架和团队来讲在开发速度和开发效率上有着比原生很大的优势,至少不用等待审...
    大冲哥阅读 1,820评论 0 7
  • 注:本文copy自http://www.jianshu.com/p/ac534f508fb0,纯属当笔记使用。 概...
    BookKeeping阅读 729评论 1 3