Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

Motivation

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

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

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

例えば、

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

みたいなのは、

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

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

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

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

JavaScriptCoreについて

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

※ 元々Objective-CのAPIなので、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インスタンスを取得/生成したとき:

  • JValueインスタンスは、jsの値/オブジェクトへの強参照を保持している
  • JValueインスタンスは、紐付いているコンテキストを強参照で保持している
  • JValueインスタンスには、swiftで扱える型にするために、toInt32()やtoDouble()といったメソッドが用意されている

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はJavaScriptCoreがObjective-C製APIということで使えない模様)。

※ ちなみに、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-CのAPIなので、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.js.h
/*!
 *  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

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away