24/7 twenty-four seven

iOS/OS X application programing topics.

JavaScriptだけでiOSのUIを書いてみる

この投稿は iOS Advent Calendar 2013 - Qiita の22日目の記事です。

iOS 7から新しく追加されたJavaScriptCore.frameworkを使ってJavaScriptだけでUIを書いてみましょう。

JavaScriptCore.frameworkの基本 (Objective-C -> JavaScript)

まずJavaScriptCore.frameworkの基本的な使い方は次のようになります。

JSContext *context = [[JSContext alloc] init];
[context evaluateScript:@"a = 10;"];

JSValue *value = context[@"a"];
NSLog(@"%d", value.toInt32); // => 10

↑ まずJavaScriptの実行環境としてJSContextのインスタンスを作成します。
contextのevaluateScript:メソッドにスクリプトを渡すと実行されます。
そこで作成したオブジェクトはcontextからキーを指定して取り出すことができます。


このようにObjective-CからJavaScriptを呼び出すのは簡単です。

JavaScriptからObjective-Cを使う

JavaScriptからObjective-Cを呼び出すのはさらに少し準備が必要です。
JavaScriptから呼び出せるメソッドをあらかじめJSExportというプロトコルにしたがって公開しておきます。


例えば、JavaScriptでUIWindowのオブジェクトを操作したいという場合は次のようなプロトコルをあらかじめ定義します。

@protocol JSUIWindow <JSExport>

@property (nonatomic) CGRect frame;
@property (nonatomic) UIColor *backgroundColor;

+ (id)new;
- (void)makeKeyAndVisible;

@end


↓ そして上記のプロトコルに適合するUIWindowのサブクラスを定義します。

@interface JSUIWindow : UIWindow <JSUIWindow>

@end

@implementation JSUIWindow

@end


先ほどのJSUIWindowクラスのクラスオブジェクトをJSContextに登録します。

JSContext *context = [[JSContext alloc] init];
context[@"JSUIWindow"] = [JSUIWindow class];


これで、JavaScriptからJSUIWindowのメソッドを呼び出すことができるようになりました。
先ほどの手順でnewメソッドをJavaScriptから使えるように公開してあるのでJavaScriptからJSUIWindowをインスタンス化できます。

JSContext *context = [[JSContext alloc] init];
context[@"JSUIWindow"] = [JSUIWindow class];
[context evaluateScript:@"var window = JSUIWindow.new();"];

JSValue *value = context[@"window"];
NSLog(@"%@", value.toObject); // => <JSUIWindow: 0x8e2ce40; baseClass = UIWindow; frame = (0 0; 0 0); hidden = YES; gestureRecognizers = <NSArray: 0x8e2d3e0>; layer = <UIWindowLayer: 0x8e2cf60>>


ここまでやってみて、少し、いやかなり面倒だと思われたのではないでしょうか。
いちいちサブクラスを定義することなく直接UIKitの標準クラスをJavaScriptから扱えるようにならないものでしょうか。

標準クラスをJSExportに適合させる

実はObjective-CのランタイムAPIを使えば実行時にクラスをプロトコルに適合させることができます。

class_addProtocol([UIWindow class], @protocol(JSUIWindow));

JSContext *context = [[JSContext alloc] init];
context[@"UIWindow"] = [UIWindow class];
[context evaluateScript:@"var window = UIWindow.new();"];

JSValue *value = context[@"window"];
NSLog(@"%@", value.toObject); // => <UIWindow: 0x8b7ea00; frame = (0 0; 0 0); hidden = YES; gestureRecognizers = <NSArray: 0x8b7efa0>; layer = <UIWindowLayer: 0x8b7eb20>>

↑ 上記のコードは実行時にランタイムAPIの`class_addProtocol`を使ってUIWindowクラスをJSUIWindowプロトコルに適合させています。
contextに登録するクラスオブジェクトもUIWindowクラスです。作成されたインスタンスもUIWindowの直接のインスタンスであることがわかります。


ここまでできると、プロトコルの宣言も無くせないだろうかと考えるのですが、結論からいうとそれは不可能でした。
ランタイムAPIを使えば実行時にプロトコルを作成することもできるのですが、そうやって作成したプロトコルをJSContextに登録しても使えないか、クラッシュしてしまいました。

ただ、本家のWebKitのほうにもこの挙動はバグではないかということで報告されているようです。
Bug 122501 – Dynamically generated JSExport protocols added to a class results in a crash


↓ いちおう試したコードを載せておきます。

Class cls = [UIWindow class];
SEL sel = @selector(new);
Method method = class_getClassMethod(cls, sel);
const char *types = method_getTypeEncoding(method);

Protocol *proto = objc_allocateProtocol("JSUIWindow");
protocol_addProtocol(proto, objc_getProtocol("JSExport"));
protocol_addMethodDescription(proto, sel, types, YES, NO);
objc_registerProtocol(proto);

class_addProtocol(cls, proto);

JSContext *context = [[JSContext alloc] init];
context[@"UIWindow"] = cls;
[context evaluateScript:@"var window = UIWindow.new();"];

JSValue *value = context[@"window"];
NSLog(@"%@", value); => undefined


というわけで、現状ではあらかじめプロトコルを定義しておき、実行時に適合させることでSDKから提供されているクラスについてもJavaScriptから扱うことができるようになります。

JavaScriptだけでUIを書いてみる

↓ そのようにして、JavaScriptだけで記述したコードで作られた画面がこちらです。

20131223022848


全体のコードは下記になります。
一番下のapplication:didFinishLaunchingWithOptions:メソッドのところを読むと、JavaScriptだけで画面が書かれてるのがわかります。

引数のあるメソッドをJavaScriptから呼び出すときはコロンを取り除いて、キャメルケースに結合した名前にします。
(`JSExportAs`というマクロを使って別名を定義することもできます。)
またCGRectなど一部の構造体はハッシュを使って`framex = 10;` や `frame = {x: 20, y: 80, width: 200, height: 80};`のように簡単に設定することができるようになっています。


この例ではJavaScriptはハードコーディングしていますが、外部ファイルから読み込むようにしてもいいでしょう。
準備が少し大変ですが、JavaScriptのコンパイルする必要がないといったスクリプト言語の特性をうまく使うと、便利な場合も多いのではないでしょうか。

#import "AppDelegate.h"

@import JavaScriptCore;
@import ObjectiveC;

@protocol JSNSObject <JSExport>

+ (id)new;

@end

@protocol JSUIView <JSExport>

@property (nonatomic) CGRect frame;
@property (nonatomic) UIColor *backgroundColor;

+ (id)new;
- (void)addSubview:(UIView *)view;

@end

@protocol JSUIWindow <JSExport>

@property (nonatomic) CGRect frame;
@property (nonatomic) UIColor *backgroundColor;
@property (nonatomic) UIViewController *rootViewController;

+ (id)new;
- (void)makeKeyAndVisible;

@end

@protocol JSUILabel <JSExport>

@property (nonatomic) CGRect frame;
@property (nonatomic) UIColor *backgroundColor;
@property (nonatomic) UIColor *textColor;
@property (nonatomic) NSString *text;

+ (id)new;
- (void)addSubview:(UIView *)view;
- (void)sizeToFit;

@end

@protocol JSUIScreen <JSExport>

@property (nonatomic, readonly) CGRect bounds;

+ (UIScreen *)mainScreen;
- (void)makeKeyAndVisible;

@end

@protocol JSUIColor <JSExport>

+ (UIColor *)whiteColor;
+ (UIColor *)redColor;
+ (UIColor *)blueColor;

@end

@protocol JSUIViewController <JSExport>

@property (nonatomic) UIView *view;
@property (nonatomic) UINavigationItem *navigationItem;
@property (nonatomic) UITabBarItem *tabBarItem;

+ (id)new;
- (UIView *)view;

@end

@protocol JSUITabBarController <JSExport>

@property(nonatomic, copy) NSArray *viewControllers;

+ (id)new;

@end

@protocol JSUINavigationController <JSExport>

@property (nonatomic) NSArray *viewControllers;

+ (id)new;

@end

@protocol JSUINavigationItem <JSExport>

@property (nonatomic) NSString *title;

@end

@protocol JSUITabBarItem <JSExport>

+ (id)alloc;
- (id)initWithTabBarSystemItem:(UITabBarSystemItem)systemItem tag:(NSInteger)tag;

@end

@implementation AppDelegate

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
{
    class_addProtocol([NSObject class], @protocol(JSNSObject));
    class_addProtocol([UIView class], @protocol(JSUIView));
    class_addProtocol([UILabel class], @protocol(JSUILabel));
    class_addProtocol([UIWindow class], @protocol(JSUIWindow));
    class_addProtocol([UIViewController class], @protocol(JSUIViewController));
    class_addProtocol([UINavigationController class], @protocol(JSUINavigationController));
    class_addProtocol([UINavigationItem class], @protocol(JSUINavigationItem));
    class_addProtocol([UITabBarController class], @protocol(JSUITabBarController));
    class_addProtocol([UITabBarItem class], @protocol(JSUITabBarItem));
    class_addProtocol([UIScreen class], @protocol(JSUIScreen));
    class_addProtocol([UIColor class], @protocol(JSUIColor));
    
    JSContext *context = [[JSContext alloc] init];
    context[@"NSObject"] = [NSObject class];
    context[@"UIView"] = [UIView class];
    context[@"UILabel"] = [UILabel class];
    context[@"UIWindow"] = [UIWindow class];
    context[@"UIViewController"] = [UIViewController class];
    context[@"UINavigationController"] = [UINavigationController class];
    context[@"JSUINavigationItem"] = [UINavigationItem class];
    context[@"UITabBarController"] = [UITabBarController class];
    context[@"UITabBarItem"] = [UITabBarItem class];
    context[@"UIScreen"] = [UIScreen class];
    context[@"UIColor"] = [UIColor class];
    
    [context evaluateScript:
     @"var window = UIWindow.new();"
     @"window.frame = UIScreen.mainScreen().bounds;"
     @"window.backgroundColor = UIColor.whiteColor();"
     @""
     @"var navigationController1 = UINavigationController.new();"
     @"var viewController1 = UIViewController.new();"
     @"viewController1.navigationItem.title = 'Make UI with JS';"
     @""
     @"var view = UIView.new();"
     @"view.backgroundColor = UIColor.redColor();"
     @"view.frame = {x: 20, y: 80, width: 200, height: 80};"
     @""
     @"var label = UILabel.new();"
     @"label.backgroundColor = UIColor.blueColor();"
     @"label.textColor = UIColor.whiteColor();"
     @"label.text = 'This is label.';"
     @"label.sizeToFit();"
     @""
     @"var frame = label.frame;"
     @"frame.x = 10;"
     @"frame.y = 10;"
     @"label.frame = frame;"
     @""
     @"view.addSubview(label);"
     @"viewController1.view.addSubview(view);"
     @""
     @"navigationController1.viewControllers = [viewController1];"
     @""
     @"var tabBarItem = UITabBarItem.alloc();"
     @"tabBarItem = tabBarItem.initWithTabBarSystemItemTag(1);"
     @"viewController1.tabBarItem = tabBarItem;"
     @""
     @"var tabBarController = UITabBarController.new();"
     @"tabBarController.viewControllers = [navigationController1];"
     @""
     @"window.rootViewController = tabBarController;"
     @"window.makeKeyAndVisible();"
     ];
    
    return YES;
}

@end