Motivation
かつてSIOSocketなるSocket.IOのiosクライアントがありました。
"かつて"というのも、このクライアントは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。
これは端的に言うと、JavascriptとObjective-C(Swift)をブリッジしてくれるObjective-C APIです。2013年からあって、Mac/iOS両方で使えます。
※ 元々Objective-CのAPIなので、Swiftでは使いにくい部分がままあります。追ってその点についても触れていければと思います。
JavascriptCoreを使うには
frameworkとして提供されているので、Linked Frameworks and Libraries のところからいつもの様に追加して、使いたいところで import JavaScriptCore
してあげれば使えるようになります。
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の関係図
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. クロージャを使う
まず基本ルールとして、
- JSValueのキャプチャは避ける -> 代わりに引数として渡す
- 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
JavaScriptCore Written by Nate Cook — January 19th, 2015
JavaScriptCore Changes for Swift
JavaScriptCore.framework の普通な使い方 #cocoa_kansai
SIOSocketをSwift製にする上で気づいた点など
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を引き出す
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に登録する
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] というキャプチャは使えない
最初、
let onLoad: @convention(block) () -> Void = { [unowned socket] in
としていたのですが、unowned
がエラーになってしまい、weak
に変えたらコンパイル通りました。恐らくswiftにはあって、Objective-Cにはないからだと思います。多分。
callWithArguments()スゲェ
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
↑ サンプルを作るうえで一部ソースを使わせていただきました。ありがとうございました。