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 ResourceTask
s 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?
Pingback: iOS PodCast Episode #92 | Dinesh Ram Kali.