🦨

Swift SDKsを使ってAndroidでSwiftコードを動かす

2024/12/18に公開

Swift SDKsとは?

Swift 6.0から、swift sdkコマンドが追加されていることはご存知でしょうか?

Swift SDKsはSE-0387で提唱されたクロスコンパイルの仕組みで、異なるランタイム向けのビルドを簡単に行うことができます。
公式サイトではmacOSからLinux用の実行ファイルをビルドするための仕組みが紹介されています。

本記事では、この仕組みを使ってAndroidでSwiftコードを動かす最小の例を紹介します。

事前準備

現在、Android用のSwift SDKは公式からは提供されていないため、以下のリポジトリで配布されているものを使用します。

https://github.com/finagolfin/swift-android-sdk

(本記事が説明する内容もほとんどREADME.mdと同じです。)

Swift SDKsを使う際、Swift 6.0においては、SDKをビルドしたSwiftのバージョンと使用するSwiftのバージョンを揃える必要があります[1]
そのため、Xcodeに内蔵されているSwiftは基本バージョンが揃わない[2]ので利用できず、手動でSwiftをインストールする必要があります。

本記事で利用するSwiftツールチェーンは6.0.2です。以下のSwiftとSwift SDKを使用します。

pkgを使ってSwiftツールチェーンをインストールしたあと、以下のコマンドでAndroid用SDKをインストールします。

swift sdk install \
    https://github.com/finagolfin/swift-android-sdk/releases/download/6.0.2/swift-6.0.2-RELEASE-android-24-0.1.artifactbundle.tar.gz \
    --checksum d75615eac3e614131133c7cc2076b0b8fb4327d89dce802c25cd53e75e1881f4

Androidプロジェクトの作成

テンプレートはEmpty Activityにして、Androidプロジェクトを作成します。

JNI用クラスの用意

今回はJNIを使用してSwiftコードを呼び出します。
Kotlin側でcom.exampleパッケージを用意して、JNI呼び出し用のクラスを宣言します。

MySwiftLib.kt
package com.example

class MySwiftLib {
    external fun swiftHello(name: String): String

    companion object {
        init {
            System.loadLibrary("AppModule")
        }
    }
}

今回は、MySwiftLibクラスのメンバ関数としてswiftHelloを用意し、その実装をJNIを経由してSwiftで行います。
生成するSwiftライブラリの名前はlibAppModule.soという想定にします。

JNI用クラスを利用するコードを書いておく

現代のテンプレートではMainActivityクラスにJetpack ComposeによるUI実装があると思います。
適当な箇所をMySwiftLibを使うコードに変えておきます。

MainActivity.kt
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    val lib = MySwiftLib()
    Text(
        text = lib.swiftHello(name),
        modifier = modifier
    )
}

Swiftパッケージのセットアップ

適当なディレクトリでswift package init --type libraryします。
今回は成果物のバイナリを手でAndroidプロジェクトに組み込むため、場所はどこでも良いです。

パッケージ定義

Package.swiftを編集して、以下のようにします。

Package.swift
// swift-tools-version: 6.0

import PackageDescription

let package = Package(
    name: "AppModule",
    products: [
        .library(
            name: "AppModule",
            type: .dynamic,
            targets: ["AppModule"]
        ),
    ],
    targets: [
        .target(
            name: "AppModule"
        ),
    ]
)

重要な点はAppModuleというライブラリ名と、type: .dynamicの指定です。
先程Kotlinコードで指定した名前と同じにして、かつダイナミックライブラリとしてビルドすることでJNIから呼び出せるようにします。

SwiftでJNI関数を実装する

実行したいSwiftコードを記述します。
今回は文字列を受け取って文字列を返す関数です。以下の形で実装します。

AppModule.swift
import jni

@_cdecl("Java_com_example_MySwiftLib_swiftHello")
public func swiftHello(env: UnsafeMutablePointer<JNIEnv?>?, obj: jobject, name: jstring) -> jstring {
    let nameCStr = env!.pointee!.pointee.GetStringUTFChars(env, name, nil)!
    let name = String(cString: nameCStr)

    let result = "Hello, \(name)! from Swift"

    return result.withCString { resultCStr in
        env!.pointee!.pointee.NewStringUTF(env, resultCStr)!
    }
}

import jniは、Android用SDKに含まれているパッケージです。Android NDKにおけるjni.hそのものであり、C及びC++用のヘッダーファイルです。
SwiftにはC++ interoperabilityがありますが、jni.hをC++として読み込むとビルドできませんでした。Swiftが非対応の依存がありうまく利用できなかったため、今回はCとして使います。

引数のobj: jobjectはこの関数を呼び出しているレシーバオブジェクト、name: jstringはJava文字列オブジェクトを表しています。

GetStringUTFCharsあたりでJavaの文字列からC言語用の文字列ポインタを取り出してSwift文字列に変換しています。
その後、加工したSwift文字列をC文字列として展開したあとNewStringUTFでJavaの文字列に変換しています。

@_cdeclでは、この関数をC言語の呼び出し規約に従って出力するよう指示しています。
引数はその出力する際の名前で、JNIはこの名前を使ってネイティブ実装から目的の関数を探します。

このへんのお作法はCでJNIを使う際と全く同じであるため、詳細は割愛します。

Swiftライブラリをビルド

以下のコマンドでSwiftパッケージをビルドします

swift build \
    --swift-sdk aarch64-unknown-linux-android24 \
    --toolchain /Library/Developer/Toolchains/swift-6.0.2-RELEASE.xctoolchain

ポイントは、Swift SDKの指定とツールチェーンの指定です。
今回インストールしたAndroid用SDKの名前がaarch64-unknown-linux-android24なので、それを使う指示をします。
事前準備の章で述べたように、SDKとツールチェーンのバージョンを合わせる必要があります。準備したツールチェーンのパスを指定します。

ビルドに成功すると、.build/debug/libAppModule.soができてると思います。

ライブラリをAndroidプロジェクトに配置

Androidプロジェクト構成のことはよくわかっていないのですが、JNI用の動的ライブラリは
app/src/main/jniLibs/<ABI>ディレクトリの中に配置すると勝手にアプリケーションバンドルに転送してくれるみたいです。<ABI>にはarm64-v8aなどの文字列が入ります。

先程ビルドしたSwiftライブラリをそこに配置します。

cp <Swiftパッケージルート>/.build/debug/libAppModule.so \
    <Androidプロジェクトルート>/app/src/main/jniLibs/arm64-v8a/

またSwiftライブラリはSwiftの標準ライブラリに依存しているため、標準ライブラリの.soも配置する必要があります。
Android用のSwift標準ライブラリはSwift SDKに含まれているようでした。

cp ~/.swiftpm/swift-sdks/swift-6.0.2-RELEASE-android-24-0.1.artifactbundle/swift-6.0.2-release-android-24-sdk/android-27c-sysroot/usr/lib/aarch64-linux-android/24/*.so \
    <Androidプロジェクトルート>/app/src/main/jniLibs/arm64-v8a/

過剰なものもありますが丸ごとコピーして配置します。
配置した結果、次のようなディレクトリツリーになると思います。

実行

ここまでで実際にAndroidアプリを動かしてみると、クラッシュしてエラーがでてしまいました。

java.lang.UnsatisfiedLinkError: dlopen failed: empty/missing DT_HASH/DT_GNU_HASH in "/data/app/~~ObOIVvQB0bspEsAqPa64dg==/com.gmail.side.junktown.swiftonandroid-hg6vqhNArV35Tf7XbvP-cA==/base.apk!/lib/arm64-v8a/libdispatch.so" (new hash type from the future?)

これについてはよくわかっていないのですが、Androidツールチェーンがなにか悪さしていることが原因のようです。
build.gradle.ktsandroidブロックに以下の記述を追加すると対応できます。

build.gradle.kts
android {
    ...
    packaging {
        jniLibs.keepDebugSymbols.add("*/arm64-v8a/libdispatch.so")
    }
}

無事、動作が確認できました。

おわりに

Swift SDKsを使うことで、簡単にAndroid用のバイナリを出力することができました。
とはいえ、JNIをSwiftから使うのは大変面倒ですし、gradleとのインテグレーションもまだ適当です。

今回は、基礎を学ぶためにswift-javaを使わずあえて素のままのSwiftを使いました。
swift-javaに含まれるJavaKitを使うと、マクロによってJNIが簡単に利用できそうな雰囲気があります。ドキュメントが少なく使い方がよくわかっていないので、いつか調べてみたいと思っています。

脚注
  1. うろ覚えですが今後改善予定のはず ↩︎

  2. 微妙にずれたものが出荷されがちで、同じバージョン番号でもOSS版Swiftとは異なっている ↩︎

Discussion