🌉

JNIを使う(2) Gradleを用いたビルド

2024/12/03に公開

この記事は「大分高専 Advent Calendar 2024」3日目の記事です。

はじめに

前回は、コマンドライン上でJNIを使ったプログラムをビルドしました。しかしコマンドライン上でのビルドは手順が多く、コードを変更する度に毎回手動でビルドするのは大変です。そこで、今回はJavaもC/C++もどちらもビルドできるGradleを使って、JNIのビルドを自動化してみようと思います。

プロジェクト構成

今回は:cppというプロジェクトにネイティブのC++コード、:javaというプロジェクトにJavaコードを格納し、自動でビルドさせます。

プロジェクト構成
$ ./gradlew -q projects

Projects:

------------------------------------------------------------
Root project 'jni_practice'
------------------------------------------------------------

Root project 'jni_practice'
+--- Project ':cpp'
\--- Project ':java'

cpp側のビルドスクリプト設定

まず、:cppプロジェクトのビルドスクリプトを作成します。

build.gradle.kts
plugins {
    id("cpp-library")
}

group = "com.example"
version = "1.0-SNAPSHOT"

tasks.withType<CppCompile> {
    dependsOn(":java:classes")
}

library {
    linkage = listOf(Linkage.SHARED)

    privateHeaders {
        // for jni.h
        val javaHome = System.getProperty("java.home")
        from("$javaHome/include")

        // OS-specific include directories
        val os = System.getProperty("os.name").toLowerCase()
        when {
            os.contains("win") -> from("$javaHome/include/windows")
            os.contains("mac") -> from("$javaHome/include/darwin")
            os.contains("nix") || os.contains("nux") -> from("$javaHome/include/linux")
            else -> throw GradleException("Unsupported operating system for JNI headers")
        }

        // For generated headers
        val javaProject = project(":java")
        from("${javaProject.layout.buildDirectory.get()}/generated/sources/headers/java/main")
    }
}

Java側のソースコードから生成されるヘッダファイルが必要なので、CppCompileタスクを:java:classesタスクに依存させています。
また、linkageにLinkage.SHAREDを指定して、共有ライブラリを作成するようにしています。
また、インクルードパスについては、まずfrom("$javaHome/include")jni.h用のパスを追加しています。OS固有のヘッダーは、使っているOS毎にインクルード先を変化させる処理を入れています。最後に、:javaプロジェクトによって生成されるネイティブメソッドの関数プロトタイプが列挙されるヘッダーへのパスは、Projectlayout.buildDirectory.get()でビルドディレクトリを取得し、指定しています。

java側のビルドスクリプト設定

次に、:javaプロジェクトのビルドスクリプトを作成しましょう。

build.gradle.kts
plugins {
    id("java")
    id("application")
}

group = "com.example"
version = "1.0-SNAPSHOT"

repositories {
    mavenCentral()
}

dependencies {
    testImplementation(platform("org.junit:junit-bom:5.10.0"))
    testImplementation("org.junit.jupiter:junit-jupiter")
}

application{
    mainClass = "com.example.hello_world.Main"
}

tasks.named<JavaExec>("run") {
    dependsOn(":cpp:assembleRelease")
    val cLib = project(":cpp")
    jvmArgs = listOf("-Djava.library.path=${cLib.layout.buildDirectory.get()}/lib/main/release")
}

tasks.test {
    useJUnitPlatform()
}

runタスクを、:cpp:assembleReleaseタスクに依存させることで、必ず:cppが共有ライブラリを出力した後にjavaコードが実行されることを保証しています。また、jvmArgsプロパティに:cppライブラリが生成した共有ライブラリへのパスを入れ、実行時に共有ライブラリを解決できるようにしています。

classesタスクによるJNI用ヘッダファイルの生成と実装

javaコードにネイティブメソッドを定義したら、:java:classesタスクを実行し、JNI用のヘッダファイルを生成しましょう。ヘッダファイルの名前は、デフォルトではネイティブメソッドを持つクラスへの完全修飾パスのスネークケースとなっています。
ヘッダファイルが出来たら、C++コードでヘッダファイルをインクルードし、関数プロトタイプを実装すればOKです。先ほどのビルドスクリプト設定のお陰で、生成されたヘッダファイルはビルド時に自動的に解決されるようになっています。

JniClass.cpp(例)
#include "com_example_hello_world_JniClass.h"

JNIEXPORT void JNICALL Java_com_example_hello_1world_JniClass_theNativeMethod(JNIEnv *, jobject)
{
    printf("Hello JNI World\n");
}

(因みに、ここでhello_1worldと謎の1が入っているのが不思議ですが、どうやら後置の1がエスケープ文字になっている模様。com.example.hello_world.JniClass -> com_example_hello_1world_JniClass

実行

  1. ネイティブメソッドの定義
  2. それによるヘッダファイル生成
  3. C++側でヘッダファイルを読み込み、実装

このステップが完了したら、実際にプログラムを実行しましょう。

./gradlew run

試しに前回のコードを実行してみると、

出力結果
> Task :java:run
Hello JNI World

きちんとC++とjavaが連携されていますね🎉

おわりに

今回は、煩雑なJNIのビルドを、Gradleを使って自動化してみました。コマンドライン上でのビルドは骨が折れるので、改めてビルドツールの有り難みを感じますね…。

Discussion