美文网首页iOS技术点我爱编程iOS-swift
iOS原生与JS交互之JavaScriptCore

iOS原生与JS交互之JavaScriptCore

作者: coderJoey | 来源:发表于2017-05-12 08:56 被阅读0次

    说明:本文的演示项目及图片均来自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相关教程。谢谢您的阅读,如有问题,欢迎交流!

    相关文章

      网友评论

        本文标题:iOS原生与JS交互之JavaScriptCore

        本文链接:https://www.haomeiwen.com/subject/pacntxtx.html