NPM パッケージを Swift から呼び出す
JavaScriptCore というものをご存知でしょうか?
JavaScriptCore は 純正で提供されているフレームワークで、 これを使うと Swift や Objective-C から JavaScript を実行することができるようになります
もちろん、iOS アプリからでも実行できます
詳細: JavaScriptCore | Apple Developer Documentation
結論から言うと、Webpack で JS コードをバンドルし、この JavaScriptCore で Swift と JavaScript を連携することで、Swift から NPM パッケージを間接的に使うことができるようになります
そもそもなぜ Swift から JavaScript を呼び出す必要があるのか?
一番大きな理由はやはりライブラリの量の差です。JS と Swift を比較すると、利用者もライブラリも圧倒的に JS の方が多いです。
そのため、特定のことをしたいというときに、Swift だとライブラリがないため自分で実装する必要があるが、JS であればライブラリがある、という状況がたまに発生します
そこで、JS のライブラリを Swift から呼び出して使うことができれば実装の手間を減らすことができます
実装
今回は Swift から JavaScript ライブラリである、lodash
を使ってみることにします
最終的なコード
筆者の環境
macOS Ventura 13.0 Beta
Xcode 14.0.0. beta-2
Node 16.13.1
Yarn 1.22.19
前提知識
- Swift の基本文法、SwiftUI の基本知識
- JavaScript の基本的な知識
- Webpack の雰囲気
Swift プロジェクトの作成
Xcode から新規プロジェクトを作成します
今回は Multiplatform の App を選択しましたが別にどれでも問題ないと思います
JSBridge
という名前でプロジェクトを作成しました
SwiftUI アプリの初期状態です
JS のセットアップ
JS の作業ディレクトリを作成します
今回は、先ほど作成した JSBridge
フォルダの直下に JS
フォルダを作成しました
ここのディレクトリでは Xcode ではなく VSCode などを使って作業すると良いです
.gitignore
の設定
後々node_modules
フォルダが生成されることになるので、先にルートに .gitignore
を作成しておきます
gitignore.io で生成すると楽です
npm init
npm init -y
や yarn init -y
などを実行して package.json
を生成します
例
{
"name": "bridge",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
}
Webpack
JavaScriptCore では、JS コードを文字列として受け取って実行することになります
そのため、NPM パッケージを利用したい場合は、一つのコードにまとめてあげる必要が出てきます
そこで使うのが、お馴染みの webpack です
Web フロントエンド開発ではほぼ webpack が出てきて、複数の JS ライブラリを一つのファイルにバンドルしたりしています
ここでも、Web と同じように一つのファイルにまとめることが目的なので、いつもと同じように使うことで解決できます
インストール
npm install -D webpack webpack-cli
または
yarn add -D webpack webpack-cli
ビルドにしか使用しないため、-D
オプションをつけて、ビルド時に Webpack 自身を含めて生成しないようにします
設定
webpack.config.js
を作成します
webpack init
を実行してしまうと、余計なものが入ってくるので自分で書くのがおすすめです
こんな感じにします
var path = require("path")
module.exports = {
entry: { Bridge: "./index.js" }, // この `Bridge` という名前は自由に変えても良いけどあとで使うので忘れないように
output: {
path: path.resolve(__dirname, "dist"),
filename: "[name].bundle.js",
library: "[name]",
libraryTarget: "var",
},
}
package.json
の方にビルドスクリプトを追記します
{
"name": "bridge",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21"
},
"devDependencies": {
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0"
},
"scripts": {
"build": "webpack --progress --color"
}
}
build
のオプションは各自見やすいように変えて良いと思います
これでセットアップは完了です
NPM パッケージのインストール
各自の使いたい NPM パッケージをインストールします
なんでも良いのですが、今回は lodash
を使うことにしました
(実際はわざわざ Swift から lodash
を呼び出して使う価値なんてないですが、わかりやすいと思うので)
lodash
は JS 用の便利関数詰め合わせパックみたいなものです
loadash
ではないので注意(一敗)
インストールします
npm install lodash
または
yarn add lodash
今回は -D
オプションはつけません
JS 側の実装
index.js
を作成し、まずこんな感じにします
(このファイル名は、webpack.config.js
で合わせればなんでもいいと思う)
import * as lodash from "lodash"
// このクラス名もあとで使うことになる
export class Bridge {
// lodash の数字の配列の和を返す関数をラップ
static sum(array) {
return lodash.sum(array)
}
}
コメントの通り、Bridge
クラスの sum
という関数で lodash
の sum
をラップして呼び出しています
バンドル
Webpack で一つのファイルに書き出します
npm run build
または
yarn build
を実行すると、dist
というディレクトリが新たに作成され、中に Bridge.bundle.js
が生成されています
Bridge.bundle.js
はこの後すぐに使います
Swift から呼び出す準備
JS ファイルをプロジェクトに追加
先ほど作った Bridge.bundle.js
をプロジェクトに追加します
Copy items if needed
にチェックを入れ忘れないように
JavaScriptCore
JavaScriptCore を使うためには、JSVirtualMachine
と JSContext
が必要です
JSVirtualMachine
は JS を実行することができる仮想環境で、JSContext
はその仮想環境で実行される JS とのやりとりをするためのオブジェクトです
JSContext は一度生成したら使いまわしたいので、新たにクラスを作ってそこで管理することにします
新たに JSBridge.swift
というファイルを作成し、まずは以下のようにします
import SwiftUI
import JavaScriptCore
class JSBridge: ObservableObject {
private let vm = JSVirtualMachine()
private let context: JSContext
init() {
let js = loadJSFile(fileName: "Bridge.bundle") // 拡張子jsはあとでつけるので含めない(swiftちょっとキモい)
self.context = JSContext(virtualMachine: self.vm)
self.context.evaluateScript(js)
}
}
private func loadJSFile(fileName: String) -> String? {
// JSファイルを読み込む
// ディレクトリとかは気にしなくていい
let text = try? String(contentsOf: Bundle.main.url(forResource: fileName, withExtension: "js")!)
return text
}
loadJSFile()
で Bridge.bundle.js
ファイルを読み込みます
そして、仮想環境の JSContext
を作成し、読み込んだ JS コードを実行します
JS で書いた Bridge
クラスの sum()
を呼び出すには以下のようにします
func calcSum(numbers: [Int]) -> Int {
var sum = 0
let module = self.context.objectForKeyedSubscript("Bridge") // webpack.config.jsで指定したもの
let bridge = module?.objectForKeyedSubscript("Bridge") // 自分で書いたクラス名
// Bridgeクラスのsumという関数を呼び出す
// 引数にnumbersを渡す
if let result = bridge?.objectForKeyedSubscript("sum").call(withArguments: [numbers]) {
// JSValueからInt32に変換して、Intにする
sum = Int(result.toInt32())
}
// 完了
return sum
}
context.objectForKeyedSubscript()
を使って、JS のクラスや変数、関数などを取得することができます
this
を取得したりすることもできますが、今回は割愛します
関数を取得した場合は、call()
を使って呼び出すことができ、その際に引数を配列で渡すことができます
返り値は成功した場合は JSValue
ですが、失敗した場合は nil
です
JSValue
は JS の値を表すオブジェクトですが、そのままでは扱いづらいので、toInt32()
などのメソッドを使って Int32
などに変換して使います
直接 Int
に変換することはできないので、一度 Int32
にしてから Int
にしてます
また、この calcSum()
は @escaping
を使わない同期関数になっていますが、今回の処理程度なら十分高速に処理できるため必要ありません
実行
UI
まずはサクッと UI を作ります
本筋ではないのでそのままコードを載せます
struct ContentView: View {
@ObservedObject var bridge = JSBridge()
@State var numbers: [Int] = []
@State var sum: Int = 0
var body: some View {
VStack(spacing: 20) {
Button {
generateRandomNums()
} label: {
Text("生成")
}
HStack {
ForEach(numbers, id: \.self) { num in
Text("\(num)")
}
}
.frame(minHeight: 40)
Button {
calcSum()
} label: {
Text("計算")
}
Text("合計: \(sum)")
}
}
func generateRandomNums() {
numbers = []
(0..<Int.random(in: 1..<10)).forEach { num in
numbers.append(Int.random(in: 1..<10))
}
}
func calcSum() {
self.sum = bridge.calcSum(numbers: numbers)
}
}
generateRandomNums
でランダムで数字を生成し、calcSum
で JSBridge
の calcSum
を呼んでいます
これを実行するとこのような感じになります
これを撮影していた時では withAnimation
を使っていたので若干動きが異なります
とりあえず Swift から NPM パッケージを呼び出すことができました
JS 側から Swift を呼び出してみる
JS の方から Swift の関数を呼んでみます
と言っても、自由に呼べるはずもないので、JS 側で実行したい Swift を VM に差し込む感じです
JSBridge
を以下のようにします
class JSBridge: ObservableObject {
private let vm = JSVirtualMachine()
private let context: JSContext
+ @Published var message = ""
init() {
let js = loadJSFile(fileName: "Bridge.bundle") // 拡張子jsはあとでつけるので含めない(swiftちょっとキモい)
self.context = JSContext(virtualMachine: self.vm)
self.context.evaluateScript(js)
+ // 関数をインジェクト
+ self.injectFunction()
}
+ private func injectFunction() {
+ // JS側から呼び出せる関数を登録する
+ let sendMessage: @convention(block) (String) -> Void = { message in
+ DispatchQueue.main.async {
+ self.message = message
+ NSLog("Message from JS: \(message)")
+ }
+ }
+
+ // sendMessage という名前で登録
+ self.context.setObject(sendMessage, forKeyedSubscript: "sendMessage" as NSString)
+ }
func calcSum(numbers: [Int]) -> Int {
var sum = 0
let module = self.context.objectForKeyedSubscript("Bridge") // webpack.config.jsで指定したもの
let bridge = module?.objectForKeyedSubscript("Bridge") // 自分で書いたクラス名
// Bridgeクラスのsumという関数を呼び出す
// 引数にnumbersを渡す
if let result = bridge?.objectForKeyedSubscript("sum").call(withArguments: [numbers]) {
// JSValueからInt32に変換して、Intにする
sum = Int(result.toInt32())
}
// 完了
return sum
}
}
injectFunction()
内で定義している sendMessage
では、渡された文字列を message
に代入しログに出力します
これを sendMessage
という名前で VM に登録しています
JS 側で呼び出すには以下のようにします
import * as lodash from "lodash"
export class Bridge {
static sum(array) {
+ // インジェクトされているか検証
+ if (typeof sendMessage === "function") {
+ // メッセージを送る
+ sendMessage("Hello from JS!")
+ }
return lodash.sum(array)
}
}
一応 sendMessage
がちゃんと関数かどうか確かめてから呼び出しています
これ以降は sum()
を呼び出すたびに sendMessage
も呼び出されるはずです
UI 側の修正
JSBridge
の message
を確認するために ContentView
を少し修正します
struct ContentView: View {
@ObservedObject var bridge = JSBridge()
@State var numbers: [Int] = []
@State var sum: Int = 0
var body: some View {
VStack(spacing: 20) {
Button {
generateRandomNums()
} label: {
Text("生成")
}
HStack {
ForEach(numbers, id: \.self) { num in
Text("\(num)")
}
}
.frame(minHeight: 40)
Button {
calcSum()
} label: {
Text("計算")
}
Text("合計: \(sum)")
+ Text("メッセージ: \(bridge.message)")
}
}
func generateRandomNums() {
numbers = []
(0..<Int.random(in: 1..<10)).forEach { num in
numbers.append(Int.random(in: 1..<10))
}
}
func calcSum() {
self.sum = bridge.calcSum(numbers: numbers)
}
}
これを実行すると、
ちゃんと JS からメッセージが渡されたことが確認できました
NSLog
で出力されるログを確認するには、コンソールを使います。(/System/Applications/Utilities/Console.app
)
検索欄でプロセス名(今回は JSBridge)で検索
しっかりとログにも出力されていることが確認できます
実行速度
先ほど、この程度の処理なら十分高速だと言いましたが、どれくらい高速なのか軽く調べてみることにします
UI 側で bridge.calcSum
を呼び出すのにかかった時間を調べてみます
struct ContentView: View {
@ObservedObject var bridge = JSBridge()
@State var numbers: [Int] = []
@State var sum: Int = 0
+ @State var time: String = "-"
var body: some View {
VStack(spacing: 20) {
Button {
generateRandomNums()
} label: {
Text("生成")
}
HStack {
ForEach(numbers, id: \.self) { num in
Text("\(num)")
}
}
.frame(minHeight: 40)
Button {
calcSum()
} label: {
Text("計算")
}
Text("合計: \(sum)")
Text("メッセージ: \(bridge.message)")
+ Text("実行時間: \(time) ms")
}
}
func generateRandomNums() {
numbers = []
(0..<Int.random(in: 1..<10)).forEach { num in
numbers.append(Int.random(in: 1..<10))
}
}
func calcSum() {
+ let startTime = Date()
+
self.sum = bridge.calcSum(numbers: numbers)
+
+ let endTime = Date()
+
+ let timeElapsed = endTime.timeIntervalSince(startTime)
+
+ time = String(floor(Double(timeElapsed * 100000)) / 100)
}
}
timeIntervalSince
で返る TimeInterval
型は秒数を表す型ですが、そのまま表示させるとめちゃくちゃ長い小数になるので、小数第 2 位までのミリ秒に変換しています
これを実行するとこのような感じ
最初の実行だけ 1〜2ms かかっていますが、そのあとは 1ms 未満の実行時間となっています。(JS 側で大した処理をやってないというのもあるとは思いますが)
きちんとした計測方法ではないとは思うので、ベンチマークとかにはならないでしょうが、普通に使う分には十分くらい高速だと思います
まとめ
JavaScriptCore を使うと Swift から JS コードを実行したり呼び出したり、逆に JS から Swift の関数を呼び出すこともできます
Webpack を使って JS をバンドルすることで、NPM パッケージ、JS ライブラリを Swift で使うことができるようになります
ネイティブの iOS や macOS アプリ上で動かすことができるのが強いと思います
今回使ったコード
Discussion