(Not) Dynamically Exporting Objective-C Classes To JavaScriptCore
It’s not possible, but it’s interesting to know why!
One of the first things I wanted to try when the Objective-C API was added to JavaScriptCore in iOS 7 was dynamically exporting Objective-C classes to utilize them in JS. Part of the motivation behind this was to avoid the verbose requirement of extending and implementing the empty JSExport protocol that normally exposes certain properties and methods of a native class. Nigel Brooke at Steamclock also suggested that this was a possibility, so I started on my way.
What we want to do is, given a class name we want to expose to JS, traverse the method and property information to build a new protocol (extending JSExport) with the same methods and properties. This will export all of the methods and properties to JS. Then, we tell the runtime that this class implements this protocol and we should be good to go. If you haven’t played with the Objective-C runtime before, it isn’t too difficult to deal with, although you will be working with C and manual memory management. I’ll go through the code necessary to do all of this in small chunks.
First let’s make a new protocol that extends JSExport, assuming that we
have a Class class
variable that we want to
export:
const char *protocolName = class_getName(class);
Protocol *protocol = objc_allocateProtocol(protocolName);
protocol_addProtocol(protocol, objc_getProtocol("JSExport"));
Now lets build up a list of methods from the class that we’ll expose to JS. Note that instance and class methods are done separately. First we get a list of methods and how many there are, and then we enumerate that list and use the information about the method to add it to the protocol:
NSUInteger methodCount, classMethodCount;
Method *methods, *classMethods;
methods = class_copyMethodList(class, &methodCount);
for (NSUInteger methodIndex = 0; methodIndex < methodCount; ++methodIndex) {
Method method = methods[methodIndex];
protocol_addMethodDescription(protocol, method_getName(method), method_getTypeEncoding(method), YES, YES);
}
Here we’re doing the same for class methods:
classMethods = class_copyMethodList(object_getClass(class), &classMethodCount);
for (NSUInteger methodIndex = 0; methodIndex < classMethodCount; ++methodIndex) {
Method method = classMethods[methodIndex];
protocol_addMethodDescription(protocol, method_getName(method), method_getTypeEncoding(method), YES, NO);
}
Now let’s do it for properties. They’re almost exactly the same except that we also need to fetch and use each property’s attributes (the name and type encoding) to add it to the protocol.
NSUInteger propertyCount;
objc_property_t *properties;
properties = class_copyPropertyList(class, &propertyCount);
for (NSUInteger propertyIndex = 0; propertyIndex < propertyCount; ++propertyIndex) {
objc_property_t property = properties[propertyIndex];
NSUInteger attributeCount;
objc_property_attribute_t *attributes = property_copyAttributeList(property, &attributeCount);
protocol_addProperty(protocol, property_getName(property), attributes, attributeCount, YES, YES);
free(attributes);
}
And the most important part, adding the new protocol to the class:
objc_registerProtocol(protocol);
class_addProtocol(class, protocol);
You can find the full implementation I made to do this in a
gist in
the -exportClass:toContext:
method. (I skipped a bit
here.) But! This doesn’t work. In fact it used
to crash. At this point
I was disappointed, but determined to find out why. First we’ll need the
JavaScriptCore
source.
Diving into the source far enough will bring you to
objCCallbackFunctionForMethod
, the function that creates
a JSObjectRef pointing to the native method of an Objective-C class. All
it really does, though, is pass some method-specific information to
objCCallbackFunctionForInvocation
which handles the real
work. But there’s something peculiar about this function call:
objCCallbackFunctionForInvocation(context, invocation, isInstanceMethod ? CallbackInstanceMethod : CallbackClassMethod, isInstanceMethod ? cls : nil, _protocol_getMethodTypeEncoding(protocol, sel, YES, isInstanceMethod))
What’s _protocol_getMethodTypeEncoding
? Visiting the
definition gives us:
// Forward declare some Objective-C runtime internal methods that are not API.
const char *_protocol_getMethodTypeEncoding(Protocol *, SEL, BOOL isRequiredMethod, BOOL isInstanceMethod);
Interesting! Here JavaScriptCore is relying on a private Objective-C
runtime function in order to properly expose Objective-C methods. Let’s
jump to the runtime implementation, which is also open source, to see
how this might be different from the public
protocol_getMethodDescription
and why JavaScriptCore
needs to use it. I’ll note that iOS 7 isn’t available on Apple Open
Source yet, but 10.9 is, so that’s where we’ll go. In
objc-runtime-new.mm
we find the implementation of
_protocol_getMethodTypeEncoding
with a comment that
reads: “Returns nil if the compiler did not emit any extended @encode
data.” Even more interesting! So the compiler will emit extended @encode
data that we don’t normally get when we use
protocol_getMethodDescription
.
In fact, when we use both of these we can see exactly how the returned values are different:
// Given this definition:
@protocol Tester
@required
- (BOOL)testDictionary:(NSDictionary *)dictionary error:(NSError **)error;
@end
struct objc_method_description description = protocol_getMethodDescription(@protocol(Tester), @selector(testDictionary:error:), YES, YES);
NSLog(@”%s”, description.types);
// Outputs c32@0:8@16^@24
const char *descriptionString = _protocol_getMethodTypeEncoding(@protocol(Tester), @selector(testDictionary:error:), YES, YES);
NSLog(@”%s”, descriptionString);
// Outputs c32@0:8@”NSDictionary”16^@24
There’s a full gist you can run here.
So this is why we need to have our JSExport protocols defined at compile
time, and if you try to use
_protocol_getMethodTypeEncoding
on a protocol that was
created dynamically you still won’t get the extended type info you need.
I’d like to say that knowing this allows us to work around the lack of
this extra type information in order to export native classes like we
first wanted to. Unfortunately, that extra type information is crucial
to how JavaScriptCore marshals values into and out of the virtual
machine, and there are obvious downsides to trying to get JavaScriptCore
to ignore the missing information (I tried, just in case).
If we look back at the JSC source we can now see how this extra type
information is used to marshal values.
objCCallbackFunctionForInvocation
loops over
this
extra information in order to properly specify how JS types should be
marshalled to Objective-C
types
as well as how many
arguments
there are for a given method.
You may have noticed that
objCCallbackFunctionForInvocation
is also called to
create JSObjectRefs for Objective-C blocks. In that case it uses the
also-private _Block_signature
function
declared here
to return the type encoding of the block’s signature. Although this
function is private, the ABI is public and it’s possible to access the
signature directly, something that I first learned from Rob Rix’s
Obstruct project.
I hope that, despite this disappointing ending, you’ve learned a bit more about the internals of JavaScriptCore and the Objective-C runtime.