说明:本文的演示项目及图片均来自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
}
我们一步一步看上面的代码:
判断JSContext是否成功创建(还有common.js是否成功导入)。
首先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()方法来实现原生数组的转换。
- 和parseFunction使用方法一样。
- 纯原生操作:将字典数组转化成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.swift的parse(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
代码说明:
调用swift的unsafeBitCast(_:to:)将我们预先声明的block转换成AnyObject。
先通过调用setObject(_:forKeyedSubscript:)方法将block载入到JS runtime中,然后再通过调用evaluateScript() 获取block在JS runtime中的函数引用(通过这个引用可以调用该block)。
和之前调用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相关教程。谢谢您的阅读,如有问题,欢迎交流!