Kotlin Nativeってandroid向けにも書けるんですよ
この記事は2022年7月20日に開催されたQiita Night〜2022年、Androidアプリはどう作る?〜でLTした内容の記事版です。
Qiiita様にまとめていただいたイベントレポートはこちらです。
検証に使ったプログラムはGitHubにて公開しています。
Android NDK使ってますか?
- androidエンジニアは普段kotlinもしくはJavaを用いてアプリを作成します。
- Android NDKは処理を高速化したいときやメモリ管理を手動で行いたい場合に使用します。
- linkerの設定やCmakeList.txtの準備などいくつか超えなければならないハードルがありますが、
C
もしくはC++
で書く必要があるというのが一番高いハードルではないでしょうか? - 今回はトレンドであるマルチプラットフォーム技術を用いてハードルを乗り越えてみます。
Kotlin Native for Android NDKを使う
MPPとは
- Kotlin Multiplatform Projectはkotlinで書いたコードを様々なプラットフォーム向けにビルドしてくれる仕組みです。
- Kotlin NativeはこのProjectの一部で各種プラットフォーム向けの実行バイナリを生成できます。
Kotlin Native
- 現在サポートはmacOS、iOS, tvOS, watchOS, Linux, MinGW, Android NDKに対応しています。
- Kotlin Nativeは上記のプラットフォーム向けに静的もしくは動的ライブラリを生成できます。
- Kotlin NativeはKotlin Native Runtime上で動きます。
※実際には異なりますがイメージとしてはこんな感じです
対応状況
-
IDE上で確認できる感じでは現状APIレベル21からサポートされているネイティブAPIに対応しています。
*各SourceSet分のライブラリ定義が参照できる関係上、非常に多く羅列されているので画像は拡大してご確認ください。
-
Android NDKの呼び出しをkotlin nativeで呼び出せるようにAPIをラップしたライブラリが公開されています。そのためNative Activityなどをkotlinを用いて記述できます。
*正しい呼び出し方ではないので図のコードのようにNativeAcitvityは呼べません
Kotlin Native for Android NDKを設定する
- K/NはKotlin Multiplatformの一部です。
- そのためKotlin Multiplatformとして設定を施す必要があります。
- まずソールファイルを認識させるために
SourceSet
設定します。 - K/N for Android NDK用に拡張関数が用意されているので使用します。
※Kotlin Multiplatformのディレクトリによるソース周りの設定についてはこちら
https://kotlinlang.org/docs/multiplatform-dsl-reference.html#targets
plugins {
// Kotlin Multiplatform用のgradle pluginを適用
kotlin("multiplatform")
}
kotlin {
// androidNative用に用意されているプリセット用の関数を使って定義
listOf(
androidNativeX86(),
androidNativeX64(),
androidNativeArm32(),
androidNativeArm64()
).forEach {
// 吐き出されるライブラリが静的なのか動的なのかを定義する
// 今回は動的ライブラリ
it.binaries.sharedLib()
}
sourceSets {
val androidNativeX86Main by getting
val androidNativeX64Main by getting
val androidNativeArm32Main by getting
val androidNativeArm64Main by getting
// 各アーキテクチャに同一コードを提供するためのsourceSet
// hierarchy structure周りの設定で記述は変わります
// https://kotlinlang.org/docs/multiplatform-hierarchy.html#for-multiplatform-project-authors
val androidNativeMain by creating {
androidNativeX86Main.dependsOn(this)
androidNativeX64Main.dependsOn(this)
androidNativeArm32Main.dependsOn(this)
androidNativeArm64Main.dependsOn(this)
}
}
}
K/NのSourceSetは以下の通りにandroid ABIに対応しています
android abis
アーキテクチャ | arm 32bit | arm 64bit | x86 | x64 |
---|---|---|---|---|
K/N SourceSet | androidNativeArm32 | androidNativeArm64 | androidNativeX86 | androidNativeX64 |
android abi | armeabi-v7a | arm64-v8a | x86 | x86_64 |
これでsrc/androidMain
配下に記述したkotlinコードが静的もしくは動的ライブラリにビルドされます。
ただこのままではビルドするたびに.a
ファイルもしくは.so
ファイルを手動でコピペする必要があります。
煩わしいのでgradleでビルドするたびにコピーしてあげます。
val arm32SoFolder = File(buildDir, "bin/androidNativeArm32/releaseShared")
val jniArm32Folder = File(projectDir, "../androidApp/src/main/cpp/libs/armeabi-v7a")
val arm64SoFolder = File(buildDir, "bin/androidNativeArm64/releaseShared")
val jniArm64Folder = File(projectDir, "../androidApp/src/main/cpp/libs/arm64-v8a")
val x86SoFolder = File(buildDir, "bin/androidNativeX86/releaseShared")
val jniX86Folder = File(projectDir, "../androidApp/src/main/cpp/libs/x86")
val x64SoFolder = File(buildDir, "bin/androidNativeX64/releaseShared")
val jniX64Folder = File(projectDir, "../androidApp/src/main/cpp/libs/x86_64")
val targets = listOf(
arm32SoFolder to jniArm32Folder,
arm64SoFolder to jniArm64Folder,
x86SoFolder to jniX86Folder,
x64SoFolder to jniX64Folder,
)
val nativeFiles = listOf("*.so", "*.h")
tasks.build {
// buildタスクが実行されるたびにファイルを削除する
doFirst {
targets.map { (_, into) -> fileTree(into.path) { include(nativeFiles) } }
.flatten()
.forEach { it.delete() }
}
// buildタスクの最後にheaderファイルなどをndkを使用するandroidプロジェクトのディレクトリにコピーする
doLast {
targets.forEach { (from, into) ->
copy {
from(from)
into(into)
include(nativeFiles)
}
}
}
}
tasks.clean {
// cleanタスクでも生成されたファイルが削除されるようにする
doLast {
targets.map { (_, into) -> fileTree(into.path) { include(nativeFiles) } }
.flatten()
.forEach { it.delete() }
}
}
K/Nで作成したライブラリを呼び出してみる
JNIはJavaのprimitiveな型については特に変換などを意識することなく使用できます。K/N上のkotlinコードでも同様にPrimitiveな型については変換などを意識する必要がありません。
java | jni | C++ | C++ on K/N | kotlin on K/N |
---|---|---|---|---|
boolean | jboolean | uint8_t | ${libray}_KBoolean | Boolean |
byte | jbyte | int8_t | ${libray}_KByte | Byte |
char | jchar | uint16_t | ${libray}_KChar | Char |
short | jshort | int16_t | ${libray}_KShort | Short |
int | jint | int32_t | ${libray}_KInt | Int |
long | jlong | int64_t | ${libray}_KLong | Long |
float | jfloat | float | ${libray}_KFloat | Float |
double | jdouble | double | ${libray}_KDouble | Double |
Primitiveでない型やクラスはどうなるのか?ビルドで生成されたヘッダーファイルを見ると👇のようになっています。
typedef struct {
/* User functions. */
struct {
struct {
struct {
struct {
struct {
struct {
libshared_KLong (*fibonacci)(libshared_KLong n);
} android;
} kotlin;
} ryunen344;
} com;
} root;
} kotlin;
} libshared_ExportedSymbols;
extern libshared_ExportedSymbols* libshared_symbols(void);
libshared_ExportedSymbols
からたどることで自分が記述した関数を呼び出せます
libshared_ExportedSymbols
からたどる必要があるため、通常のNativeAcitvityのような記述をAndroidManifestに行ってつかえなさそうです(未検証)
その点は考慮が必要そうです
extern "C" JNIEXPORT jlong JNICALL
Java_com_ryunen344_kotlin_android_android_NdkWrapper_fibonacciKNative(
JNIEnv *env,
jobject,
jlong count) {
// K/N symbolsを取り出す
libshared_ExportedSymbols *symbols = libshared_symbols();
// K/N側のパッケージをたどってお目当ての関数を呼び出す
return symbols->kotlin.root.com.ryunen344.kotlin.android.fibonacci(count);
}
Kotlin Native for Android NDKのパフォーマンスはどうなのよ
ここまでKotlin NativeでAndroid NDK向けにビルドする話をしましたが、パフォーマンスがあまりに悪くては全く使い物になりません。そこで計測を行いました。
ルール
- Kotlin Nativeのnew mmは不使用
- 現在Kotlin NativeにはGCが実装されようとしていますが今回は不使用
- Kotlin Nativeで.soを吐き出してそれをndkに取り込んで処理を呼び出す
- 今回はみんな大好きフィボナッチ関数で計測
- 一番Order数の多い再帰パターンで計測
- Coroutineなどを用いて処理中の実行スレッドの変更などは行わない
- tailrecを使った言語レベルの最適化は行わない
- kotlinには言語レベルでの再帰最適化手法がありますが不使用
計測結果
項数\実装 ms | kotlin | C++ | K/N |
---|---|---|---|
30 | 20.2 | 14 | 10.4 |
35 | 187.2 | 118.4 | 92.8 |
40 | 2530.8 | 1301.2 | 982.2 |
41 | 4241.6 | 2417 | 1724.2 |
42 | 6534.8 | 3737.4 | 2825.6 |
43 | 10424.2 | 6156.8 | 4571.8 |
44 | 17550.6 | 10078.8 | 7456.2 |
45 | 27410.2 | 16546.2 | 13112.2 |
46 | 44664 | 26853.4 | 20091.4 |
- 素のネイティブ実装を上回れる結果が出ちゃいました
- NDKの知識不足で最適化不足な部分がある....はず....
まとめ
素敵なところ
- IDE支援をさらに受けやすい(CLion持ってない人でもリッチな支援がもらえる)
- 強力なstdlib周りを設定なしで使える
- コアロジックに関しては慣れてるKotlinを使って記述することができる
あと一歩のところ
- Kotlin使ってるのにPointerを意識した実装が箇所箇所で発生する
- coroutine, atomicfuが対応されてない対応される見込みもない(が回避策自体はある)
ネイティブライブラリしかないけどどうしてもC++のキャッチアップもする余裕がない、という場合にどうでしょうか?
構造体互換編, CInterop編はいずれどこかで・・・・