🧵

NPM パッケージを Swift から呼び出す

2022/07/03に公開

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 を使ってみることにします

最終的なコード
https://github.com/p1atdev/JSBridge

筆者の環境

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 -yyarn init -y などを実行して package.json を生成します

package.json
{
  "name": "bridge",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
}

Webpack

JavaScriptCore では、JS コードを文字列として受け取って実行することになります
そのため、NPM パッケージを利用したい場合は、一つのコードにまとめてあげる必要が出てきます

そこで使うのが、お馴染みの webpack です
Web フロントエンド開発ではほぼ webpack が出てきて、複数の JS ライブラリを一つのファイルにバンドルしたりしています
ここでも、Web と同じように一つのファイルにまとめることが目的なので、いつもと同じように使うことで解決できます

インストール

npm
npm install -D webpack webpack-cli

または

yarn
yarn add -D webpack webpack-cli

ビルドにしか使用しないため、-D オプションをつけて、ビルド時に Webpack 自身を含めて生成しないようにします

設定

webpack.config.js を作成します
webpack init を実行してしまうと、余計なものが入ってくるので自分で書くのがおすすめです

こんな感じにします

webpack.config.js
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 の方にビルドスクリプトを追記します

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 ではないので注意(一敗)

https://lodash.com/

インストールします

npm
npm install lodash

または

yarn
yarn add lodash

今回は -D オプションはつけません

JS 側の実装

index.js を作成し、まずこんな感じにします
(このファイル名は、webpack.config.js で合わせればなんでもいいと思う)

index.js
import * as lodash from "lodash"

// このクラス名もあとで使うことになる
export class Bridge {
    // lodash の数字の配列の和を返す関数をラップ
    static sum(array) {
        return lodash.sum(array)
    }
}

コメントの通り、Bridge クラスの sum という関数で lodashsum をラップして呼び出しています

バンドル

Webpack で一つのファイルに書き出します

npm
npm run build

または

yarn
yarn build

を実行すると、dist というディレクトリが新たに作成され、中に Bridge.bundle.js が生成されています

Bridge.bundle.js はこの後すぐに使います

Swift から呼び出す準備

JS ファイルをプロジェクトに追加

先ほど作った Bridge.bundle.js をプロジェクトに追加します


Copy items if needed にチェックを入れ忘れないように

JavaScriptCore

JavaScriptCore を使うためには、JSVirtualMachineJSContext が必要です
JSVirtualMachine は JS を実行することができる仮想環境で、JSContext はその仮想環境で実行される JS とのやりとりをするためのオブジェクトです

JSContext は一度生成したら使いまわしたいので、新たにクラスを作ってそこで管理することにします

新たに JSBridge.swift というファイルを作成し、まずは以下のようにします

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() を呼び出すには以下のようにします

JSBridge.swift
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 を作ります
本筋ではないのでそのままコードを載せます

ContentView.swift
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 でランダムで数字を生成し、calcSumJSBridgecalcSum を呼んでいます

これを実行するとこのような感じになります


これを撮影していた時では withAnimation を使っていたので若干動きが異なります

とりあえず Swift から NPM パッケージを呼び出すことができました

JS 側から Swift を呼び出してみる

JS の方から Swift の関数を呼んでみます
と言っても、自由に呼べるはずもないので、JS 側で実行したい Swift を VM に差し込む感じです

JSBridge を以下のようにします

JSBridge.swift
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 側で呼び出すには以下のようにします

index.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 側の修正

JSBridgemessage を確認するために ContentView を少し修正します

ContentView.swift
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 を呼び出すのにかかった時間を調べてみます

ContentView.swift
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 アプリ上で動かすことができるのが強いと思います

今回使ったコード

https://github.com/p1atdev/JSBridge

GitHubで編集を提案

Discussion