mitolab's diary

東南アジアで頑張って生きてる人のブログ

socket.io(SIOSocket)をネタにJavascriptCoreに入門してみる with Swift

Motivation

かつてSIOSocketなるSocket.IOiosクライアントがありました。

"かつて"というのも、このクライアントはObjective-Cで書かれているのですが、2015年の3月にSwift製公式クライアントが発表されたことにより、お役御免で開発STOPとなってしまいました。

ただ、僕はこのライブラリ面白いなーと思ったところがあって、それはminifyしたjsのコードと少しのソースで、Objective-C製ライブラリの如く使えるようにしていたところです。

例えば、

io.on('join', function (args) {});

みたいなのは、

[self.socket on: @"join" callback: ^(SIOParameterArray *args){}];

こんなふうに書けていました。

こういうのを、少しの労力で作れるなら、javascript界(以後jsと書きます)の膨大な資産をios界でも気軽に使いまわせるのでは?と思い興味を持っていました。

ということで、今回はこのSIOSocketをSwiftで換装してみて、時が来たら同様のことができるように前知識を蓄えておこう、というコンテンツになります。

JavaScriptCoreについて

今回のようにjsのコードをベースにしたswiftクライアントを作る上で欠かせないのがJavaScriptCore.framework。 これは端的に言うと、JavascriptObjective-C(Swift)をブリッジしてくれるObjective-C APIです。2013年からあって、Mac/iOS両方で使えます。

※ 元々Objective-CAPIなので、Swiftでは使いにくい部分がままあります。追ってその点についても触れていければと思います。

JavascriptCoreを使うには

frameworkとして提供されているので、Linked Frameworks and Libraries のところからいつもの様に追加して、使いたいところで import JavaScriptCore してあげれば使えるようになります。

JavascriptCoreImport.png

Swift上でjsのコードを実行する

基本的な例を挙げてみます。

// 1. コンテキストを作成
let context:JSContext = JSContext()

// 2. javascript(ここではfactorial function)をコンテキストに渡して評価する
context.evaluateScript("var factorial = function(n) { if(n<0){return;}if(n===0){return 1;}return n*factorial(n-1); }")

// 3. コンテキストには既にfactorialメソッドが登録されているので、それを呼ぶ
let result: JSValue = context.evaluateScript("factorial(3)")

// 4. 返ってきた値をよしなに変換して使う
print(result.toInt32())

また、3の手順は、以下のように呼ぶこともできます。

let factorial:JSValue = context.objectForKeyedSubscript("factorial")
let result:JSValue = factorial.callWithArguments([3])

以下、各用語や概念の補足です。

JSContext

1つのJSContextインスタンス(コンテキストと呼ぶ)を生成したとき:

  • コンテキストはグローバル変数で、jsでいうwindowオブジェクトに相当する
  • コンテキストを通して、jsに変数を登録したり、jsの実行結果を取得したりできる
  • コンテキストを通して取得/生成したJSValue(後述)は、コンテキストに強参照を持つ

JSVirtualMachine

1つのJSVirtualMachineインスタンス(vmと呼ぶ)を生成したとき:

  • vm上に複数のコンテキストを持つことができる
  • vm上に存在する複数のコンテキスト間で変数を共有できるが、vmをまたがる共有はできない(それぞれのJSVirtualMachineはそれぞれのガベージコレクターとヒープをもって管理しているため)
  • 1つのvmにつき同時に実行できるスレッドは1つ(= jsを複数同時に実行したいなら複数vmを立ち上げる必要がある)

JSValue

1つのJSValueインスタンスを取得/生成したとき:

JSVirtualMachine, JSContext, JSValueの関係図

vmImage.png

WWDC2013のセッションスライドに少し加筆しました

func evaluateScript(script: String!) -> JSValue!

評価の結果返り値があれば、ここで取得することもできます。ここでは返り値は特に指定していないので、undefinedになります。

func objectForKeyedSubscript(key: AnyObject!) -> JSValue!

コンテキスト上にある変数にアクセスすることができます。上記の場合はfactorial変数を取り出しています。これは、Objective-Cであれば、

context["factorial"] というふうにsubscriptをつけることで取り出せたのですが、swiftだと上記のようにメソッドを呼ばなければいけない上に、ドット記法(例えばwindow.onload)で取り出すのもできないようです。

callWithArguments(arguments: [AnyObject]!) -> JSValue!

先に取り出したfactorial関数に引数を渡すことができます。面白いのは、引数が配列になっていて、且つAnyObject!になっていること。つまり、JavascriptCoreさんが、

  • 配列のindex順にjsのfunctionに渡してくれる
  • swift側の型とjs側の型を自動的に変換してくれる

ということになります。

js上でswiftのコードを実行する

大きく分けて2種類の方法があります。

1. クロージャを使う

まず基本ルールとして、

  1. JSValueのキャプチャは避ける -> 代わりに引数として渡す
  2. JSContextのキャプチャは避ける -> 代わりにJSContext.currentContext()を使う

と、WWDC2013セッションのビデオでは言っていましたが、普通にキャプチャすると強参照になってリークしやすくなるので、weakを使いましょうということと認識しました(後述しますがunownedはJavaScriptCoreObjective-CAPIということで使えない模様)。

※ ちなみに、objcのブロックとswiftのキャプチャの違いは、

をご参照ください。

では、クロージャを使った例を示します。

let context = JSContext()
let say: @convention(block) String -> String = { str in
    return "say \(str)!"
}
context.setObject(unsafeBitCast(say, AnyObject!.self), forKeyedSubscript: "say")
print(context.evaluateScript("say('hello')"))

// もちろんこのようにも実行できる
let sayFunc = context.objectForKeyedSubscript("say")
print(sayFunc.callWithArguments(["hello2"]))

上記は、jsのコードにswifitで書いたfunctionを渡し、jsのコードとして実行するサンプルです。 あまり見かけない単語が幾つか出てきています。

@convention(block)

AppleのドキュメントのClosuresの部分をみると、Objective-CのブロックとSwiftクロージャは互換性があるので、@convention(block)アトリビュートをつければ使えるよとのこと。

元々、JavaScriptCore.frameworkがObjective-C APIなので、このような処理をする必要が出ています。

unsafeBitCast

上記@convention(block)アトリビュートによって、sayオブジェクトはObjective-Cのブロックとしてコンパイル時には扱われますが、そのままだと、context.setObject~のところで、エラーが出てしまいます。objectをAnyObject!として渡さなければならないからです。なのでここはエラーをさけるため、unsafeBitCastを使っています。

<<余談>>

元々Objective-CAPIなので、Objective-Cで書くととても楽です。特にblockのところはObjective-Cだと、このように書けます。

JSContext *context = [[JSContext alloc] init];
context[@"say"] = ^(NSString *str) {
    return [NSString stringWithFormat:@"say %@!", str];
}
NSLog(@"%@", [context evaluateScript:@"say('hello')"]);

Swiftより断然直感的ですね...。

2. JSExportを使う

ここは今回使っていないので、説明は以下の参考リンクをご参照ください。

JacaScriptCoreの参考リンク

Integrating JavaScript into Native Apps - Apple WWDC 2013 Java​Script​Core Written by Nate Cook — January 19th, 2015 JavaScriptCore Changes for Swift JavaScriptCore.framework の普通な使い方 #cocoa_kansai

SIOSocketをSwift製にする上で気づいた点など

SIOSocket.swiftのソース

https://github.com/mitolog/SIOSocket-swift

todo:

  • テスト書いて、実際にすべてのargumentがおくれるか確認(文字列パラメータしかチェックしてないッス...)
  • JSExportを使ってみる
  • UI適当すぎ

socket.ioを使うには、UIWebView()を使う必要がある

socket.ioのクライアントAPIのページに書いてあるように、socket.ioをスタンドアローンで使う場合、ioオブジェクトはwindowのプロパティとして配置されます。

なので前提として、

  • UIWebView()のJSContextを使う
  • UIWebView()にhtmlをロードする
  • ロードが完了(window.onload)してから、ioオブジェクトを取り出す

必要があります。

SIOSocketでは、以下のように実現しています。

1. UIWebViewを生成し、JSContextを引き出す (SIOSocket.swift_84行目)

socket.javascriptWebView = UIWebView()
            if let ctx = socket.javascriptWebView.valueForKeyPath("documentView.webView.mainFrame.javaScriptContext") {
                socket.javascriptContext = ctx as! JSContext
            } else {
                response(nil)
                return nil
            }

via stack overflow - Why use JavaScriptCore in iOS7 if it can't access a UIWebView's runtime?

2. window.onloadコールバックをjsに登録する(SIOSocket.swift_97行目~206行目)

let onLoad: @convention(block) () -> Void = {
    /* some code here */
}
socket.javascriptContext.setObject(unsafeBitCast(onLoad, AnyObject!.self), forKeyedSubscript: "swift_onloadCallback")
            socket.javascriptContext.evaluateScript("window.onload = swift_onloadCallback;")
            socket.javascriptWebView.loadHTMLString("<html/>", baseURL: nil)

jsに渡すクロージャで[unowned self] というキャプチャは使えない(SIOSocket.swift_97行目)

最初、

let onLoad: @convention(block) () -> Void = { [unowned socket] in

としていたのですが、unownedがエラーになってしまい、weakに変えたらコンパイル通りました。恐らくswiftにはあって、Objective-Cにはないからだと思います。多分。

callWithArguments()スゲェ(SIOSocket.swift_108行目)

let io = context.objectForKeyedSubscript("io")
let swiftSocket = io.callWithArguments([
    hostUrl, [
      "reconnection": reconnectAutomatically,
      "reconnectionAttempts": attemptLimit == -1 ? "Infinity" : attemptLimit.description,
      "reconnectionDelay": Int(withDelay) * SIOSocketConsts.MSEC_PER_SEC,
      "reconnectionDelayMax": Int(maximumDelay) * SIOSocketConsts.MSEC_PER_SEC,
      "timeout": Int(timeout) * SIOSocketConsts.MSEC_PER_SEC,
      "transports": withTransports
      ]
    ])

これだけのパラメータを軽々と自動的に変換してくれるので、感動でした。SIOSocketはこの部分元々以下のように書いていたのですが、上のようにするほうが楽だしスマートな気がします。

/*!
 *  socket.io client constructor format.
 */
static NSString *socket_io_js_constructor(NSString *hostURL, BOOL reconnection, NSInteger attemptLimit, NSTimeInterval reconnectionDelay, NSTimeInterval reconnectionDelayMax, NSTimeInterval timeout, NSArray *transports) {
  NSString *constructorFormat = @"io('%@', {  \
      'reconnection': %@,                     \
      'reconnectionAttempts': %@,             \
      'reconnectionDelay': %d,                \
      'reconnectionDelayMax': %d,             \
      'timeout': %d,                          \
      'transports': [ '%@' ]                  \
  });";

  return [NSString stringWithFormat: constructorFormat,
      hostURL,
      reconnection? @"true" : @"false",
      (attemptLimit == -1)? @"Infinity" : @(attemptLimit),
      (int)(reconnectionDelay * MSEC_PER_SEC),
      (int)(reconnectionDelayMax * MSEC_PER_SEC),
      (int)(timeout * MSEC_PER_SEC),
      [transports componentsJoinedByString:@"', '"]
  ];
}

JSContextにアクセスするスレッドはどこでもいい

元々のSIOSocketでは、onloadコールバックでioオブジェクトを取得する際に利用したスレッドを保持しておき、そのスレッド上でのみJSContextにアクセスしていましたが、WWDC2013のビデオを見てみたら、JSContextはスレッドセーフとのこと。実際に、元々performSelectorでスレッドを指定して実行していた箇所を外してみたら、うまく動いたので問題ないと思います。

ただし、JSVirtualMachineの補足のところで書いたように、1つのvmにつき同時に実行できるスレッドは1つ(= jsを複数同時に実行したいなら複数vmを立ち上げる必要がある)らしいので、そこは要注意です。

jsを文字列リテラルとして埋め込む場合は、エスケープに気をつけろ

当初は、staticなglobal変数としてsocket.io.jsを読み込んでいました。その際は、以下のエスケープを施しました。

\ -> \\
" -> \"
' -> \'
tab -> \t
line feed -> \n

エスケープ文字列に関しては、AppleのリファレンスSpecial Characters in String Literalsの部分を参考に。

換装しての感想

JavaScriptCoreに関しては、WWDC2013のセッションビデオを見るのが一番理解が進みましたので、超オススメです。

socket.ioの場合、windowプロパティのonloadをキャッチする必要があったので、UIWebViewからJSContextを引き出せる部分が結構キモだなと思いました。

socket.ioみたく、UIを伴わないjsで且つサイズが小さいものだと、swift製クライアントを作る必要性はあるのかな〜と感じました(Underscore.jsとか?)。

ただぶっちゃけ、objcで書いてbridging-headerで読み込んで使ったほうが楽かもしれない。

あと、他にJavaScriptCoreを使ったライブラリがあったら教えていただきたいです。

References

[Node.js] Socket.ioで双方向通信チャットアプリを構築 〜 JSおくのほそ道 #005 ↑ サンプルを作るうえで一部ソースを使わせていただきました。ありがとうございました。

socket.ioクライアントAPI