JavaScriptCore Tutorial for iOS: Getting Started

In this JavaScriptCore tutorial you’ll learn how to build an iOS companion app for a web app, reusing parts of its existing JavaScript via JavaScriptCore. By József Vesza.

Leave a rating/review
Save for later
Share
You are currently viewing page 2 of 3 of this article. Click here to view the first page.

Exposing Native Code

One way to run native code in the JavaScript runtime is to define blocks; they’ll be bridged automatically to JavaScript methods. There is, however, one tiny issue: this approach only works with Objective-C blocks, not Swift closures. In order to export a closure, you’ll have to perform two tasks:

  • Annotate the closure with the @convention(block) attribute to bridge it to an Objective-C block.
  • Before you can map the block to a JavaScript method call, you’ll need to cast it to an AnyObject.

Switch over to Movie.swift and add the following method to the class:

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)
  }
}

This closure takes an array of JavaScript objects (represented as dictionaries) and uses them to construct Movie instances.

Switch back to MovieService.swift. In parse(response:withLimit:), replace the return statement with the following code:

// 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. You use Swift’s unsafeBitCast(_:to:) function to cast the block to AnyObject.
  2. Calling setObject(_:forKeyedSubscript:) on the context lets you load the block into the JavaScript runtime. You then use evaluateScript() to get a reference to your block in JavaScript.
  3. The final step is to call your block from JavaScript using call(withArguments:), passing in the array of JSValue objects as the argument. The return value can be cast to an array of Movie objects.

It’s finally time to see your code in action! Build and run. Enter a price in the search field and you should see some results pop up:

That’s more like it!

javascriptcore tutorial

With only a few lines of code, you have a native app up and running that uses JavaScript to parse and filter results! :]

Using The JSExport Protocol

The other way to use your custom objects in JavaScript is the JSExport protocol. You have to create a protocol that conforms to JSExport and declare the properties and methods, that you want to expose to JavaScript.

For each native class you export, JavaScriptCore will create a prototype within the appropriate JSContext instance. The framework does this on an opt-in basis: by default, no methods or properties of your classes expose themselves to JavaScript. Instead, you must choose what to export. The rules of JSExport are as follows:

  • For exported instance methods, JavaScriptCore creates a corresponding JavaScript function as a property of the prototype object.
  • Properties of your class will be exported as accessor properties on the prototype.
  • For class methods, the framework will create a JavaScript function on the constructor object.

To see how the process works in practice, switch to Movie.swift and define the following new protocol above the existing class declaration:

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
}

Here, you specify all the properties you want to export and define a class method to construct Movie objects in JavaScript. The latter is necessary since JavaScriptCore doesn’t bridge initializers.

It’s time to modify Movie to conform to JSExport. Replace the entire class with the following:

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
  }
  
  class func movieWith(title: String, price: String, imageUrl: String) -> Movie {
    return Movie(title: title, price: price, imageUrl: imageUrl)
  }
}

The class method will simply invoke the appropriate initializer method.

Now your class is ready to be used in JavaScript. To see how you can translate the current implementation, open additions.js from the Resources group. It already contains the following code:

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

The above method takes each element from the input array, and uses it to build a Movie instance. The only thing worth pointing out is how the method signature changes: since JavaScript doesn’t have named parameters, it appends the extra parameters to the method name using camel case.

Open MovieService.swift and replace the closure of the lazy context property with the following:

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
}()

No big changes here. You load the contents of additions.js into your context. By using setObject(_:forKeyedSubscript:) on JSContext, you also make the Movie prototype available within the context.

There is only one thing left to do: in MovieService.swift, replace the current implementation of parse(response:withLimit:) with the following code:

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
}

Instead of the builder closure, the code now uses mapToNative() from the JavaScript runtime to create the Movie array. If you build and run now, you should see that the app still works as it should:

javascriptcore tutorial

Congratulations! Not only have you created an awesome app for browsing movies, you have done so by reusing existing code — written in a completely different language!

Now that’s what I call seamless user experience!

javascriptcore tutorial
József Vesza

Contributors

József Vesza

Author

Over 300 content creators. Join our team.