TVML explorations

So you looked at Apple’s very brief example of how to set up a TVML app. What’s next?

First, a side note: if you jump into TVML expecting a general UI framework, you’ll be disappointed. TVML is a toolkit for making backend-driven media players and storefronts that look basically like Apple’s. If you want to do anything else, you’ll probably want a native app.

This post is more about hosting TVML and TVJS than TVML itself. It looks at:

  • Communicating between native code and TVJS, including asynchronously
  • Not using the web for everything
  • How do you handle events in an XML page anyway?

In the sample project, there is a tagged revision corresponding to each section of this article. The tag step0 is functionally equivalent to Apple’s basic example. The tag step1 adds the material discussed in section 1, etc.

1. Talking to native code

JavaScriptCore makes this very easy since Mac OS X 10.9 and iOS 7, but I found the interweb quite unhelpful. The core of the easy bridging interface is the JSExport protocol. The idea is that you expose methods and properties in a protocol that conforms to JSExport, and when your object is wrapped in a JSValue these methods and protocols are automatically exposed to JavaScript, with type conversion mostly handled for you.

This approach doesn’t give you the same amount of flexibility as the lower-level JSObject interface, but the convenience is a worthwhile tradeoff in most cases. If you feel you need a more idiomatic JavaScript interface, your best choice is probably to implement a façade in JavaScript rather than drop to lower-level API. (I speak from experience.)

For starters, let’s add an interface to log to the native console log, for the convenience of us Xcode-oriented developers.

@import JavaScriptCore;

@protocol LoggerExport <JSExport>

- (void)log:(NSString *)message;

@end

@interface Logger : NSObject <LoggerExport>

- (instancetype)initWithJSContext:(JSContext *)jsContext;

@end
#import "Logger.h"

@implementation Logger

- (instancetype)initWithJSContext:(JSContext *)jsContext
{
    return [super init];
}

- (void)log:(NSString *)message
{
    NSLog(@"[TVJS] %@", message);
}

@end

So simple, it’s boring. The JSContext parameter isn’t used, but from experience I suggest it’s a good idea to associate your bridged interface instances with a specific context.

To hook it up, we use TVApplicationControllerDelegate’s appController:evaluateAppJavaScriptInContext: method, which fires before the JS file specified in the controller context is executed.

- (void)appController:(TVApplicationController *)appController
evaluateAppJavaScriptInContext:(JSContext *)jsContext
{
    jsContext[@"logger"] =
        [[Logger alloc] initWithJSContext:jsContext];
}

In JavaScript, the log: method is now globally available as logger.log().

For documentation on the types you can receive and return in JSExport methods, see the extensive comments in JSValue.h. You can even return blocks to JavaScript, but receiving JavaScript functions as arguments is more complex. We’ll deal with that in the next section.

2. Callbacks and local files

For a meatier example, let’s build a resource loader which lets your TVJS load files from your bundle as well as the web. This is less flexible, but makes prototyping easier if you don’t have an ATS-compliant web server handy. It certainly makes sample code easier.

The JavaScript interface we want to build will be designed to handle both HTTPS and local files in a uniform way, and will look something like this:

var task = resourceLoader.load("vnd.myapp.local:alertTemplate.tvml",
                               function(status, result) {
    if (result == 200) {
        // Success, result is the data
    } else {
        // Failure, result is an error message
    }
});
// Loading can be cancelled with task.cancel()

It isn’t as, er, fully-featured as XMLHTTPRequest, but maybe you ain’t gonna need that.

Here is the Objective-C interface:

@protocol ResourceTaskExport <JSExport>

- (void)cancel;

@end

@protocol ResourceLoaderExport <JSExport>

JSExportAs(load,
- (id<ResourceTaskExport>)loadResourceAtURL:(NSString *)urlString
                          completionHandler:(JSValue *)jsHandler
);

@end

The JSExportAs() macro allows us to rename the method from JavaScript’s perspective, as long as the method has at least one parameter. (For Swift, I suspect the best way to achieve this is to declare your JSExport subprotocol in an Objective-C header.)

The other interesting part of this is how to call the completion handler. In principle, it’s easy:

[jsHandler callWithArguments:@[ @(status), result ]];

However, calling various native APIs from a JavaScript callback invoked this way will cause crashes. The easiest way to work around this is to use setTimeout(), the JavaScript equivalent to -performSelector:withObject:afterDelay:. In JavaScript, it would look like this:

setTimeout(jsHandler, 0 /* the delay */, status, result);

In Objective-C, this translates to:

[jsContext[@"setTimeout"]
    callWithArguments:@[ jsHandler, @0, @(status), result ]];

Because of JavaScript’s highly mutable environment, I like to play it safe by stashing references to any global object I use. The end result looks like this:

@property (readonly, strong, nonatomic) JSValue *setTimeoutFunction;

...

- (instancetype)initWithJSContext:(JSContext *)jsContext
{
    if ((self = [super init])) {
        _setTimeoutFunction = jsContext[@"setTimeout"];
    }
    return self;
}

- (void)callJSCallback:(JSValue *)callback
         withArguments:(NSArray *)arguments
{
    NSMutableArray *argArray =
        [NSMutableArray arrayWithArray:arguments];
    [argArray insertObject:@0 atIndex:0];
    [argArray insertObject:callback atIndex:0];
    [self.setTimeoutFunction callWithArguments:argArray];
}

The rest of the resource loader is a trivial wrapper around NSURLSession and I won’t go through it here. It’s tagged as step2 in the sample repository. Its major weakness is that it assumes all resources are UTF-8 encoded strings; I don’t have a good solution for dealing with binary resources, but they’re unlikely to be very interesting in TVML anyway.

When returning objects locally, like the ResourceTasks in the example, it’s important to keep in mind that their lifetime will be controlled by the JavaScript garbage collector, but the garbage collector has no idea how much memory they represent. This means it’s potentially dangerous to give JavaScript an object that has strong references to a large object graph.

If I were to write a real TVML app, I would probably go for two custom URL schemes: one which always loads files locally, and one which switches to an HTTPS base URL in release builds. The former would be used for static content – most importantly, network error handling – and the latter for prototyping.

3. Event handling

So far, I’ve avoided writing any actual TVML, but there’s one thing that confused me there, although it would probably be obvious to someone with more web dev experience. How do I know what the user selected in my alert? The answer is DOM events.

First we need a way to distinguish between the buttons. We can use any XML attribute we want, but id is traditional.

<button id="ok_button">
    <text>I kind of like it</text>
</button>
<button id="cancel_button">
    <text>Get me out of here!</text>
</button>

Then, in JavaScript, we add a listener for the "select" event to our DOM document:

document.addEventListener("select", function(event) {
    targetID = event.target.getAttribute("id");
    if (targetID) {
        logger.log("User selected " + targetID);
    }
});

Other events you’re likely to care about are "highlight", which is the TVML equivalent of a hover event, and "play" for the play/pause button.

Instead of an id, you could have an action attribute which contains a JavaScript snippet, or a url attribute which specifies a new page to load. The step3 revision in the example repo uses both to build a small app with content populated from an existing JSON web service. TVML’s design assumes you’d rather do transformations like this in the back end, but you don’t have to.

By combining the techniques above, you can have all the disadvantages of distributing a static code blob combined with the disadvantages of a web app. Er, yay?

This entry was posted in Cocoa, Code. Bookmark the permalink.

One Response to TVML explorations

  1. Pingback: iOS PodCast Episode #92 | Dinesh Ram Kali.

Leave a Reply

Your email address will not be published. Required fields are marked *