iOS原生與JS交互之JavaScriptCore

說明:本文的演示項目及圖片均來自JavaScriptCore Tutorial for iOS: Getting Started

這不僅僅是一篇譯文,更多的是我通過學(xué)習(xí)該教程的心得。我會以通俗易懂的方式讓你迅速了解以下知識點:

  • JavaScriptCore框架的組件。
  • 如何用iOS代碼(這里用swift)調(diào)用JavaScript方法。
  • 如何用JavaScript代碼調(diào)用iOS原生代碼

這篇教程不需要你是JS高手,但如果有興趣可以去這里學(xué)習(xí)這門語言。

開始吧

巧婦難為無米之炊,我們先下載這篇教程的初始項目 。解壓之后會有3個文件夾,這里我一一說明:

  • Web: 里面的HTML和CSS實現(xiàn)的web應(yīng)用,正是我們接下來需要用iOS實現(xiàn)的。
  • Native: 我們的iOS項目,我們接下來的所有操作都在這個項目里面。
  • js: 項目中需要用到的js代碼文件

Showtime是一款從iTuns搜索電影的應(yīng)用,你可以通過輸入電影價格來篩選相應(yīng)價位的電影。我們先打開Web/index.html,輸入數(shù)字然后按回車看看該應(yīng)用的效果:

電影列表

OK,現(xiàn)在我們打開Native/Showtime的Xcode項目,run一下看是什么效果:


輸入數(shù)字按回車之后沒什么反應(yīng),別急下面就是我們的核心內(nèi)容了。

JavaScriptCore

JavaScriptCore框架提供了一個能訪問WebKit的JS代碼的引擎。最初,它只是應(yīng)用到Mac上的,而且還是純C的API。但是iOS 7和OS X 10.9后它已經(jīng)能用到iOS上而且還包裝成了一套友好的OC接口 。該框架使得OC和JS代碼之間能相互操作。

首先,我們來看看JavaScriptCore的三大核心組件:JSVirtualMachine、JSContext、和 JSValue

JSVirtualMachine

JSVirtualMachine類提供了一個能執(zhí)行javaScript代碼的虛擬機(jī)。 通常我們不需要直接與此類打交道,但它有一個重要用法:JavaScript代碼的并發(fā)執(zhí)行。因為在單個JSVirtualMachine中,是不能同時執(zhí)行多個線程的, 要支持并行性,您必須多開幾個虛擬機(jī)。

JSVirtualMachine的每個實例對象都有自己的堆和垃圾回收器, 虛擬機(jī)的垃圾收集器將不知道如何處理來自不同堆的值, 所以你不能在虛擬機(jī)之間傳遞對象。

JSContext

JSContext(上下文)實例創(chuàng)建一個JavaScript代碼的執(zhí)行環(huán)境。就像我們用Quartz2D畫圖時需要一個畫圖的Context環(huán)境一樣。JSContext是一個全局對象,類似網(wǎng)頁開發(fā)里面的窗口對象。 與虛擬機(jī)不同的是,你可以任意的在上下文之間傳遞對象(當(dāng)然它們必須在同一個虛擬機(jī)中)。

JSValue

JSValue是你經(jīng)常處理的主要數(shù)據(jù)類型:它可以表示任何可能的JavaScript value(包括對象、函數(shù)等)。 JSValue的實例將綁定在它所在的JSContext對象中。所有JSContext里面的對象都是JSValue類型。

下面是三者的關(guān)系圖:


概念說到這里,是時候上代碼了!

調(diào)用JavaScript方法

繼續(xù)回到我們的Xcode項目,找到并打開MovieService.swift。該類的功能是從iTunes獲取并處理搜索到的電影數(shù)據(jù)。我們的任務(wù)是將類里面的方法進(jìn)行完整的功能實現(xiàn):

  • loadMoviesWith(limit:onComplete:) 獲取電影數(shù)據(jù)。
  • parse(response:withLimit:) 通過JavaScript代碼處理電影數(shù)據(jù)。

MovieService類中找到loadMoviesWith(limit:onComplete:) ,將方法內(nèi)容換成下面的代碼:

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加載數(shù)據(jù),你可以打印出來看看加載的內(nèi)容。接下來我們通過JS代碼解析我們的數(shù)據(jù),首先我們在MovieService上方導(dǎo)入JavaScriptCore框架:

import JavaScriptCore

接下來,我們用懶加載的方式在MovieService里面定義一個JSContext屬性(我直接在代碼里寫注釋):

// 0 
// 提供執(zhí)行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實例通過調(diào)用evaluateScript(...)來執(zhí)行js代碼,
  // 其主要作用是將js代碼處理成全局的對象和函數(shù)放到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
}()

通過創(chuàng)建JSContext對象,現(xiàn)在我們可以調(diào)用JavaScript方法了。我們繼續(xù)在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("不可用的數(shù)據(jù)!")
      return []
   }
   let movies = movieDics.map { (dic) in
      return Movie(title: dic["title"]!, price: dic["price"]!, imageUrl: dic["imageUrl"]!)
   }
   return movies
}

我們一步一步看上面的代碼:

  1. 判斷JSContext是否成功創(chuàng)建(還有common.js是否成功導(dǎo)入)。

  2. 首先JSContext對象通過objectForKeyedSubscript(_ key: Any!) -> JSValue!方法在Context對象內(nèi)部查找對應(yīng)的屬性或方法,這里的key值parseJson對應(yīng)的是方法(你可以打開common.js查找到對應(yīng)的方法),再通過返回值parseFunction(JSValue類型)用call來實現(xiàn)parseFunction函數(shù)的調(diào)用,返回值依然是JSValue類型。這里有必要先看下common.js里面parseJson函數(shù)的調(diào)用:

 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函數(shù)的返回值可以看成是包含了title、price、 imageUrl屬性的JS對象數(shù)組,由于是在JSVirtualMachine虛擬機(jī)里面調(diào)用的,目前它的類型還是JSValue。最后我們調(diào)用JSValue的toArray()方法來實現(xiàn)原生數(shù)組的轉(zhuǎn)換。

  1. 和parseFunction使用方法一樣。
  2. 純原生操作:將字典數(shù)組轉(zhuǎn)化成Movie對象數(shù)組

現(xiàn)在我們run一下我們的項目,Duang Duang Duang:


到這里我們完成了JS的調(diào)用并且實現(xiàn)了我們APP功能,回顧一下我們做了什么:首先我們創(chuàng)建了一個JSContext對象,然后加載了common.js代碼到JSContext對象中,再通過key值在上下文中查找相對應(yīng)的parseJson函數(shù)并調(diào)用它,接著將得到的值轉(zhuǎn)換成原生數(shù)據(jù)類型,最后轉(zhuǎn)換成我們所需要的Movie對象數(shù)組。整個過程代碼量很少,也相當(dāng)簡單。為了加強(qiáng)理解,你可以在common.js文件中添加一些自定義的方法或?qū)傩裕缓笸ㄟ^在MovieService類中進(jìn)行調(diào)用或訪問。例如:
在common.js文件中添加如下測試代碼

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

function sum(num1, num2){
    return num1 + num2;
}
// js沒有重載的概念下面的會覆蓋上面的函數(shù)
function sum(num1, num2, num3){
    return num1 + num2 + num3;
}

在MovieService類的parse方法的標(biāo)簽//3 和 //4 中間添加如下測試代碼,跑起來觀察打印結(jié)果:

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沒有函數(shù)重載的概念
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調(diào)用iOS原生代碼

這里有兩種方式實現(xiàn)JavaScript在運行時調(diào)用原生代碼:
第一種方式是將我們需要將暴露給JS調(diào)用的方法定義成blocks。blocks將自動橋接成JavaScript方法。 但是有一個小問題:這種方法只適用于Objective-C block,而不適用于Swift閉包。 為了解決這個問題我們需要按照下面兩點去做:

  • 在swift閉包前面加上@convention(block)屬性,使其橋接成OC的block。
  • 在將block映射成JavaScript方法之前,需要將block轉(zhuǎn)換為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)數(shù)組轉(zhuǎn)換成Movie實例。注意:這里我們將閉包添加了**@convention(block) **屬性。

接下來我們跳到** MovieService.swiftparse(response:withLimit:)**,我們將標(biāo)簽 //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. 調(diào)用swift的unsafeBitCast(_:to:)將我們預(yù)先聲明的block轉(zhuǎn)換成AnyObject

  2. 先通過調(diào)用setObject(_:forKeyedSubscript:)方法將block載入到JS runtime中,然后再通過調(diào)用evaluateScript() 獲取block在JS runtime中的函數(shù)引用(通過這個引用可以調(diào)用該block)。

  3. 和之前調(diào)用call的方式一樣,獲取到JSValue的數(shù)組,最后轉(zhuǎn)換成Movie數(shù)組。不同的是執(zhí)行block的時候已經(jīng)在block代碼塊里面講字典轉(zhuǎn)換成了Movie對象,最后只需要簡單的調(diào)用toArray()就可以得到Movie數(shù)組了。

說明:之前我們調(diào)用JS函數(shù)的時候是先通過context.objectForKeyedSubscript(函數(shù)名)拿到函數(shù)再調(diào)用的,而這里我們其實也可以通過context.objectForKeyedSubscript("movieBuilder")拿到block。但由于context.evaluateScript("movieBuilder")這個方法在執(zhí)行完JS代碼之后會將名稱為movieBuilder的block以JSValue的形式返回回來,這樣我們也可以直接使用這種方式。

現(xiàn)在我們run一下我們的APP,效果應(yīng)該是一樣的。到這里我們完成了第一種JS調(diào)用原生dai'm代碼的方式。回顧一下我們做了什么:首先我們創(chuàng)建了一個能將字典數(shù)據(jù)轉(zhuǎn)換成Movie對象的swift閉包,并將其轉(zhuǎn)換成OC的block,然后將block轉(zhuǎn)換成AnyObject對象并橋接到JSContext對象中,這樣就完成了原生代碼暴露到JavaScript runtime中以供其調(diào)用**。

最后我們來看另外一種JavaScript runtime調(diào)用原生代碼的方式:

JSExport Protocol

在JavaScript中使用原生代碼的另一種方法是使用JSExport協(xié)議。 首先你得創(chuàng)建一個JSExport的協(xié)議,并聲明要暴露給JavaScript的屬性和方法。
對于你導(dǎo)出來的每個原生類,JavaScriptCore將在相應(yīng)的JSContext實例中創(chuàng)建一個原型(prototype)。 但是JavaScriptCore框架也是有選擇性的創(chuàng)建:默認(rèn)情況下,類的任何方法或?qū)傩远疾粫┞督oJavaScript,因此你必須指定需要導(dǎo)出的內(nèi)容。 JSExport的規(guī)則如下:

  • 如果導(dǎo)出的是實例方法,JavaScriptCore將創(chuàng)建一個相應(yīng)的JavaScript原型對象函數(shù)。

  • 類的屬性將作為JavaScript原型的訪問器屬性導(dǎo)出。

  • 對于類方法,框架將創(chuàng)建一個JavaScript構(gòu)造函數(shù)。

我們的任務(wù)是將** Movie.swift 暴露出來,先跳到 Movie**類的上方創(chuàng)建一個JSExport協(xié)議:

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的屬性和一個創(chuàng)建Movie實例的類方法。后者相當(dāng)重要,因為JavaScriptCore不能橋接構(gòu)造器。

現(xiàn)在我們將Movie類實現(xiàn)的所有代碼替換掉,使其遵循JSExport協(xié)議并實現(xiàn):

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
  }
  
// 該類方法就是調(diào)用Movie的構(gòu)造器函數(shù)
  class func movieWith(title: String, price: String, imageUrl: String) -> Movie {
    return Movie(title: title, price: price, imageUrl: imageUrl)
  }
}

完成這些之后,我們看看怎樣在JavaScript中是怎樣調(diào)用Movie的。我們打開Resources文件夾里面的additions.js文件,相關(guān)代碼已經(jīng)寫好:

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

上面的方法將傳入的數(shù)組的每個元素創(chuàng)建成Movie實例。值得注意的是Movie.movieWithTitlePriceImageUrl(movie.title, movie.price, movie.imageUrl)這個方法和我們之前創(chuàng)建的類方法名是不一樣的:這是因為JS沒有命名參數(shù),所以需要將參數(shù)名以駝峰命名法的形式加到方法名的后面(這里的命名相當(dāng)嚴(yán)謹(jǐn),如有差錯將不會正確調(diào)用)。

現(xiàn)在我們打開MovieService.swift文件,我們將懶加載的context實現(xiàn)做如下調(diào)整:

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:) **方法實現(xiàn)稍作調(diào)整:

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()
  
// 調(diào)整的地方
  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()創(chuàng)建Movie數(shù)組。現(xiàn)在重新跑一下我們的程序:

任務(wù)完成了,我們來總結(jié)一下如何使用JSExport的:首先我們創(chuàng)建一個JSExport協(xié)議并將需要暴露給JS的屬性和方法進(jìn)行聲明,然后將Movieh和additon.js加載到JSContext中,最后用additon.js中的方法完成原生代碼的調(diào)用。這里我們沒有聲明實例方法,那么實例方法怎么調(diào)用?另外假設(shè)協(xié)議里面有方法的重載JS是怎么調(diào)用?這些你可以自己去實踐一下。這里提示一下:原生代碼的函數(shù)轉(zhuǎn)換成JavaScript調(diào)用的時候是用駝峰法方法名+With+參數(shù)名的方式**。

結(jié)束語

至此,我們已經(jīng)完成了iOS原生與JavaScript代碼的交互,這里有完整的項目代碼。如果你想了解更多關(guān)于JavaScriptCore的知識,可以看看WWDC相關(guān)教程。謝謝您的閱讀,如有問題,歡迎交流!

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容

  • 本文由我們團(tuán)隊的 糾結(jié)倫 童鞋撰寫。 寫在前面 本篇文章是對我一次組內(nèi)分享的整理,大部分圖片都是直接從keynot...
    知識小集閱讀 15,282評論 11 172
  • OC與JS交互之JavaScriptCore 本文摘抄自:https://hjgitbook.gitbooks.i...
    大沖哥閱讀 1,028評論 0 1
  • 寫在前面 本篇文章是對我一次組內(nèi)分享的整理,大部分圖片都是直接從keynote上截圖下來的,本來有很多炫酷動效的,...
    等開會閱讀 14,504評論 6 69
  • 跟原生開發(fā)相比,H5的開發(fā)相對來一個成熟的框架和團(tuán)隊來講在開發(fā)速度和開發(fā)效率上有著比原生很大的優(yōu)勢,至少不用等待審...
    大沖哥閱讀 1,859評論 0 7
  • 注:本文copy自http://www.lxweimin.com/p/ac534f508fb0,純屬當(dāng)筆記使用。 概...
    BookKeeping閱讀 742評論 1 3