iOS原生与JS交互之JavaScriptCore

iOS原生与JS交互之JavaScriptCore_第1张图片

说明:本文的演示项目及图片均来自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,输入数字然后按回车看看该应用的效果:

iOS原生与JS交互之JavaScriptCore_第2张图片
电影列表

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

iOS原生与JS交互之JavaScriptCore_第3张图片

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

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类型。

下面是三者的关系图:


iOS原生与JS交互之JavaScriptCore_第4张图片

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

调用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:


iOS原生与JS交互之JavaScriptCore_第5张图片

到这里我们完成了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数组。现在重新跑一下我们的程序:

iOS原生与JS交互之JavaScriptCore_第6张图片

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

结束语

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

你可能感兴趣的:(iOS原生与JS交互之JavaScriptCore)