Introduction to JavaScriptCore with iOS
iOS for some reason provides an excellent integration with javascript via JavaScriptCore. Let’s have some fun with it.
Set up
So if we have a function defined in js
// main.js
function greeting() {
return "Hello from JS!"
}
First we need to set up the js context:
JSVirtualMachine *_vm;
JSContext *_context;
_vm = [[JSVirtualMachine alloc] init];
_context = [[JSContext alloc] initWithVirtualMachine:_vm];
Then we need to load our javascript
- (void)evaluate:(NSString *)filename {
NSURL *sourceURL = [[[NSBundle mainBundle] bundleURL] URLByAppendingPathComponent:filename];
NSError *error = nil;
NSString *script = [NSString stringWithContentsOfURL:sourceURL
encoding:NSUTF8StringEncoding
error:&error];
if (error != nil) NSLog(@"Error: %@", [error description]);
[_context evaluateScript:script withSourceURL:sourceURL];
}
[self evaluate:@"main.js"];
Calling javascript from native
Calling js from native is requires calling evaluateScript
which returns a value of type JSValue
.
JSValue
provides several convenience utilities to map the data back to native types.
JSValue *result = [_context evaluateScript:@"greeting()"];
NSLog(@"%@", [result toString]); // Hello from JS!
Another way to perform the same operation is by first getting the reference to the greeting
and then invoking it
JSValue *greetingFn = _context[@"greeting"];
JSValue *result = [greetingFn callWithArguments:[NSArray array]];
NSLog(@"%@", [result toString]); // Hello from JS!
Keeping reference to JSValue
Since JSValue
has a strong reference to its JSContext
. So it is not safe to keep a strong reference to JSValue
outside of its immediate scope. If we have a need to keep the reference arround, then the solution is to use JSManagedValue
JSManagedValue *jsv;
jsv = [JSManagedValue managedValueWithValue:_context[@"greeting"]];
[_vm addManagedReference:jsv withOwner:self];
JSValue *greetingFn = [jsv value];
[greetingFn callWithArguments:[NSArray array]];
[_vm removeManagedReference:jsv withOwner:self];
Calling native from javascript
So, if wish to have a print()
that is actually implemented on the native side using with NSLog
function greeting() {
print("Hello from JS!")
}
The simplest way is to use a block. The runtime does all the heavy lifting of mapping data from native to js.
_context[@"print"] = ^(NSString *message) {
NSLog(@"JS:Log: %@", message);
};
JSValue *greetingFn = _context[@"greeting"];
[greetingFn callWithArguments:[NSArray array]];
Then to add something like console.log
we need to first create a console
object and have a log
function as a property on it.
function greeting() {
console.log("Hello from JS!")
}
JSValue *console = [JSValue valueWithNewObjectInContext:_context];
_context[@"console"] = console;
console[@"log"] = ^(NSString *message) {
NSLog(@"JS:Log: %@", message);
};
In case we wisth to inject some native data type into js we need to use the JSExport
. The idea is to expose a type interface as protocol JSExport
and then provide the implementation is a NSObject
subclass.
@protocol JSConsole <JSExport>
- (NSString *)version;
- (void)log:(NSString *)message;
@end
@interface Console : NSObject <JSConsole>
@end
@implementation Console
+ (NSInteger)version {
return @"1.0";
}
- (void)log:(NSString *)message {
NSLog(@"JS:Log: %@", message);
}
@end
Chess game
Enough theory, lets now make a chess random vs random game using chess.js.
Since most of the chess implementation is going to be provided with chess.js
we just need to create a Chess
instance and provide a playRandomMove()
to update the state when invoked.
// main.js
const chess = new Chess();
function playRandomMove() {
if (chess.isGameOver()) {
return chess.ascii();
}
const moves = chess.moves();
const move = moves[Math.floor(Math.random() * moves.length)];
chess.move(move);
return chess.ascii();
}
@interface ChessController () {
JSVirtualMachine *_vm;
JSContext *_context;
}
@end
@implementation ChessController
- (instancetype)init {
self = [super init];
if (self) {
_vm = [[JSVirtualMachine alloc] init];
_context = [[JSContext alloc] initWithVirtualMachine:_vm];
}
return self;
}
- (void)evaluate:(NSString *)filename {
NSURL *sourceURL = [[[NSBundle mainBundle] bundleURL] URLByAppendingPathComponent:filename];
NSError *error = nil;
NSString *script = [NSString stringWithContentsOfURL:sourceURL
encoding:NSUTF8StringEncoding
error:&error];
if (error != nil) NSLog(@"Error: %@", [error description]);
[_context evaluateScript:script withSourceURL:sourceURL];
}
- (void)setUp {
[_context setExceptionHandler:^(JSContext *context, JSValue *exception) {
NSLog(@"JS:Error: %@", exception);
}];
[self evaluate:@"chess.js"];
[self evaluate:@"main.js"];
}
- (NSString *)playRandomMove {
JSValue *moveFn = _context[@"playRandomMove"];
JSValue *result = [moveFn callWithArguments:[NSArray array]];
NSString *board = [result toString];
return board;
}
@end
Next for the native UI we only require a simple text view that renders the latest chess board.
@interface ViewController () {
ChessController *_chessCtrl;
UITextView *_textVw;
}
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
CGSize winSize = self.view.frame.size;
CGFloat size = MIN(winSize.width, winSize.height);
CGRect frame = CGRectMake(0, 0, size, size);
_textVw = [[UITextView alloc] initWithFrame:frame];
[_textVw setCenter:[self.view center]];
[_textVw setEditable:NO];
[_textVw setFont:[UIFont monospacedSystemFontOfSize:20
weight:UIFontWeightBold]];
[self.view addSubview:_textVw];
_chessCtrl = [[ChessController alloc] init];
[_chessCtrl setUp];
[NSTimer scheduledTimerWithTimeInterval:1
target:self
selector:@selector(playRandomMove)
userInfo:nil
repeats:YES];
}
- (void)playRandomMove {
[_textVw setText:[_chessCtrl playRandomMove]];
}
@end