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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
JavaScriptCore Tutorial for iOS: Getting Started
20 mins
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
- You use Swift’s
unsafeBitCast(_:to:)
function to cast the block toAnyObject
. - Calling
setObject(_:forKeyedSubscript:)
on the context lets you load the block into the JavaScript runtime. You then useevaluateScript()
to get a reference to your block in JavaScript. - The final step is to call your block from JavaScript using
call(withArguments:)
, passing in the array ofJSValue
objects as the argument. The return value can be cast to an array ofMovie
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:
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:
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!