Android NDKでAPIレベルによる条件分岐を実現する
Android SDKのAPIを使っていると、たまに「Androidの新しいバージョンではこのAPIを使って、古いバージョンではこっちのAPIを使って実装しよう…」みたいな場面がある。コードにするとこんな感じだ:
if (Build.VERSION.SDK_INT >= 30) { /* 新しいAPIを使った処理 */ }
else { /* 古いAPIを使った処理 */ }
これをネイティブコードでやろうとするとハマる。自分が書かなくてもツールが自動生成したコードなどでハマる。そういうわけで、今回はそんな問題を解決する、Android NDKを使ったネイティブAndroid開発でSDKバージョンを指定する方法について書こうと思う。
SDKバージョンのおさらい
(これは主にAndroid NDKについて、どこにも書かれていないようなことを説明するためのエントリーであって、この節で書くAndroid SDKの話は余談と前提知識の確認のためでしかないので、読み飛ばしても問題ない。後から出てきた話を見て意味がわからなくなったら、ここを改めて読み直せばいい。)
Android SDKでJava/Kotlinのみで開発している場合、ビルド指示を記述するbuild.gradle
で、対象Androidプラットフォームのバージョンを指定するというのは、Android開発者には一般的な知識として知られていると思う。Android Studio Dolphin (stable)で何も考えずにEmpty Activityの新規プロジェクトを作成すると、app/build.gradle
には次のような内容が含まれる:
android {
namespace 'com.example.myapplication'
compileSdk 33
defaultConfig {
applicationId "com.example.myapplication"
minSdk 24
targetSdk 33
このcompileSdk
, minSdk
, targetSdk
というのが、それぞれAndroidプラットフォームの「コンパイル時のバージョン」「最低動作バージョン」「ターゲットバージョン」ということになる。いや、この説明は明らかにおかしい。「ターゲットバージョン」は何の説明にもなっていない。ここでは名は重要ではなく、その値を指定することの意味が重要だ。
分かりやすいcompileSdk
から説明しよう。この値は、そのモジュールをビルドする時、どのAndroidバージョンのフレームワークAPI、つまりAndroid SDKに含まれるplatforms/android-xx/android.jar
をAndroid APIのシンボル解決に利用するかを指示するものだ。新しいプラットフォームのAPIは新しいプラットフォームのandroid.jar
にしか含まれていないことを考えると、このcompileSdk
の効果は明白だろう。アプリケーションのビルド時(コードのコンパイル時)には意味があるが、実行時には何ら意味のある情報ではなく、AndroidManifest.xml
にもこのバージョン情報は反映されない。
minSdk
とtargetSdk
で指定されたバージョンは、そのアプリケーションのAndroidManifest.xml
に含まれる<uses-sdk>
要素の属性minSdkVersion
とtargetSdkVersion
として反映される(ユーザーが作成するAndroidManifest.xml
には必要なく、Gradleがビルド時に自動生成するAndroidManifest.xml
に自動的に反映する)。Androidプラットフォーム(アプリケーションの実行環境)では、アプリケーションのAndroidManifest.xml
で指定されたtargetSdkVersion
によって挙動が変わることがある。また、minSdkVersion
の条件を満たさなければ、そのアプリケーションは実行できないとされる(AOSPのプラットフォーム実装を改造すれば実行できる可能性はある)。
Android SDKで新しいプラットフォームAPIが追加されるたびに、「このtargetSdk
を指定したら挙動がこう変わる」という説明が書かれている項目が出てくるが、大抵は「モダンなAndroidの挙動」を含意する。重要なのは、Google Play Storeで公開できるアプリケーションの条件として毎年のように新規アプリケーション登録に要求されるtargetSdk
の数値が引き上げられるという慣習があることだ。「新しいAPIは使いたいのでcompileSdk
は最新にしたいが、同じ値をtargetSdk
に指定すると挙動が変わるし、このアプリケーションはまだその準備(対応)が出来ていない」といった状況では、これらを使い分けることになる。また、あるアプリケーションやライブラリをビルドするとき、そのtargetSdk
はdependencies
で指定するライブラリのtargetSdk
よりも新しくなければならない。逆にいえば、ライブラリでtargetSdk
を無用に高いバージョンに設定すると、利用するアプリケーションのtargetSdk
も無意味に引きずり上げられることになってしまう。もしアプリケーション側にその準備が出来ていなかったらどうなるだろうか…?
ちなみに以前のAndroidプロジェクトのbuild.gradle
ではcompileSdkVersion
, targetSdkVersion
, minSdkVersion
が使われていた(つまりこの頃はAndroidManifest.xml
の識別子と同じだった)。これらはAGP = Android Gradle PluginのAPIでは関数で、build.gradle
ならtargetSdkVersion 33
のようにそのまま数字を続けて書けるのだけど(引数として解釈される)、build.gradle.kts
だとtargetSdkVersion(33)
のように書かなければならず体裁が悪い。targetSdk
だとInt
型のプロパティなのでbuild.gradle.kts
でもtargetSdk = 33
のように記述できるし型チェックもある。一方でプレビュー版のAPI tiramisu
などを指定しないといけない場面ではString
型のtargetSdkPreview
が必要になって、いずれにしろ不格好にはならざるを得なかった様子が伺える。
Android NDK: 完全に別物のエコシステム
さて本題に入ろう。Android NDKは、C/C++のツールチェインを使ってネイティブコードをビルドする。Androidのアプリケーションはネイティブコードのexecutableではなく、Java/KotlinのコードからコンパイルされたDalvikバイトコードで実装された所定のAPIを入口として、Android APIの仕組みに則ってDalvikバイトコードのVMがZygoteプロセススレッドを生成し、コードをロードし、エントリーポイントから実行する仕組みだ。ネイティブコードはDalvikバイトコードのVMがサポートするJNIの仕組みでしか呼び出せないし、必然的に共有ライブラリ (*.so
) としてビルドされる。
ネイティブコードもapkやaabにパッケージされるが、注意点として、仮想マシンコードとは異なりネイティブコードはターゲットCPUごとにビルドされたものが含まれていなければならない。2022年時点ではAndroid NDKはarm-linux-androideabi
(32bit), aarch64-linux-android
(64bit), i686-linux-android
(32bit)、x86_64-linux-android
(64bit) をサポートしている。これらはあくまでclangやgcc(古いNDKの場合)などで使われるtripletの識別子であり、build.gradle
のabiFilters
などで指定するときにはarmeabi-v7a
, arm64-v8a
, x86
, x86_64
という識別子が使われる。またサードパーティでs390などがサポートされることもある。
Android NDKにおけるネイティブコードのビルドについては、5〜6年前までは定着した方法が無かった(ndk-buildというMakefileベースのやっつけツールが公式にはあって、それが使われたり使われなかったりだった)のだけど、CMakeがサポートされるようになって、それがAGPのビルドにシームレスに統合されるようになった(./gradlew build
だけでビルドが完結するようになった)ことで利用が快適になった。2022年時点ではほぼCMake一択で良いだろう。ハードウェア開発者などがAOSPからビルドしなければならない場合はまた別の話になるが、アプリケーション開発者とは課題が異なるのでここでは議論しない(本稿はSDKバージョンに関連する話題を中心に論じる)。
NDK platform API: where target SDK matters
Android NDKでは、JVMツールチェインのAndroid SDK APIとは全く異なるAPIを提供していて、それらはC/C++コードで利用できる。ネイティブAPIには、たとえばNativeActivityやNdkBinderのようなAndroid SDKのAPIにある機能をネイティブでもある程度使えるようにしたものもあるし、OpenGLESやVulkanやAAudioのようにネイティブで実現すべき機能を担うAPIもあるし、そもそもALSA(tinyalsa)のようにLinuxに由来するAPIもある。本稿の文脈で重要なのは、これらはプラットフォームにインストールされているライブラリであり、Androidのバージョンによっては利用できないAPIがあるということだ。たとえばNdkBinderやNative MIDI API (AMidi) はAPI Level 29 (Android 10) 以降でしか使えない(後述するが実際にはNdkBinderにはAPI Level 30以降でしか使えない問題がある)。これらを扱うことを考えると、ネイティブコードのビルドにもcompileSdkVersion
に相当する情報が重要だということだ。
2022年11月現在、Android Studio DolphinからFramingoまで含めて、AGPではCMakeを使ったネイティブビルドの呼び出しでAndroidバージョン情報をまともに扱えていない。AGPからCMakeのビルドに唯一渡される情報はANDROID_PLATFORM
のみで、この内容はminSdk
に相当する。この変数はAGPが自動的に指定して渡すものであって、われわれアプリケーション開発者が変更することは想定されていない。
minSdk
はアプリケーションの最低動作バージョンを指定するためのものであって、同じコードベースで「最新のAndroid端末では最新のAPIを使ったこのコードをコンパイルして使いたい」ということがあるかもしれない。これはAndroid SDK APIの世界では日常茶飯事なので理解しやすいだろう。ではcompileSdkVersion
はどう渡せばよいのだろう? 答えは「(公式には)無い」である。
Androidネイティブコードアプリ開発の最前線へようこそ。
われわれ(アプリ開発者)はこの最前線でどんな罠にハマるか、ひとつ例を挙げてみよう。NDKには「間違って入り込んだ」インクルードファイルがある。筆者は自分のアプリのServiceとクライアントアプリを接続するためにNdkBinderを使っており、そのためにaidl(.exe)をツールとして使っているが、その自動生成コードがこの間違ったファイルを参照してしまう。aidlの生成コードはandroid/binder_auto_utils.h
などのヘッダファイルを参照しているが、これはCMakeでのビルド時にはNDKの対象sysroot以下の/usr/include
(toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/include
など)から参照される。この中にあるヘッダファイルがminSdk 29
だとビルドエラーを起こす:
(.../sysroot)/usr/include/android/binder_auto_utils.h:263:32: error: 'AStatus_getDescription' is unavailable: introduced in Android 30
minSdk 30
以上の値を指定するとこの問題は発生しないのだけど、minSdk 30
はいくら何でも「やりすぎ」だ。ユーザーベースが2022年11月時点で全Android端末の40%くらいしかいない。minSdk 29
だってかなり「切り捨てている」ほうだが、それでも60%くらいはいる。compileSdk
を指定できれば解決する話だが、前述の通りその方法は「無い」。
__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__
と実行時プラットフォーム判定
このNDKのインクルードファイルは「間違って存在している」ものだという話は、issuetrackerでNDKチームとやり取りしたことからわかったことで、本当はAndroid SDKのplatforms/android-XX/optional/libbinder_ndk_cpp/
から解決されるべきものらしい。筆者はこの話を見て初めて「Android SDKのplatformsディレクトリにもネイティブコード用のヘッダファイルが含まれている」ことを知ったのだけど、その設計(変更)には納得できる。プラットフォームAPIなのだからプラットフォームによって変わるのは自然なことだ。
妥当だということはわかったけど、compileSdk
を指定できなくて困っていることに変わりはない。筆者のシンプルな理解でいえば、AGPがCMakeのビルドを呼び出すときに、platforms/android-XX/optional/libbinder_ndk_cpp
ディレクトリを-I
オプションのひとつとして自動的に含めるようにすべきだ。不幸なことに、Android NDKチームはCMakeを含むNDKのビルドツールやヘッダファイルだけを、Android SDKチームはプラットフォームAPIだけを扱い、AGPはAndroid Studioチームの担当…といった感じで分散しているようなので、この辺の問題の解決には多層の連携が必要になり、効率的には進捗するのは難しいだろう。
でも回避策はある。AGPが呼び出すCMakeでは-D__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__
というオプションを指定できる(-D
はCMake外部変数の定義で、CMakeのコマンドライン呼び出しのオプションはbuild.gradle
ではexternalNativeBuild { cmake { arguments ... } }
で指定できる)。このオプションが有効になっていると「存在しないシンボルを参照していてもエラーにしない」ことができる。リンカーがundefined reference to XXX
をレポートしない = コンパイル時エラーを報告せず全て実行時エラーという恐ろしいことになるので、単独ではとても有効にはできないが、さいわい-Werror=unguarded-availability
というオプションも追加すれば、通常のリンクエラーはいつもどおりエラーとして報告されるようになる。
この__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__
は、基本的に__builtin_available()
というマクロと合わせて使われている。使われ方はこんな感じだ:
#ifdef __ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__
if (__builtin_available(android 30, *)) {
#else
if (__ANDROID_API__ >= 30) {
#endif
__builtin_available(android 30, *)
のように書かれていると、プラットフォームバージョン(つまりNDKではminSdk
)の条件を満たしていなければ通らないコードにしたいところだけど、実際にはコンパイルはされるので存在しないAPIを参照してエラーになる。これが__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__
も指定されていると、この部分が動的に解決できるシンボル扱いになる。Android 30以降では問題なく実行でき、Android 29では実行時エラーになる(が__builtin_available()
の条件を満足せずelse節に行く)ということだ。__builtin_available()
によってその条件判定結果のスコープを「ガードされた」シンボル参照を含むコードブロックとして扱うので-Werror=unguarded-availability
オプションが指定されていてもエラー扱いにはならない(と筆者は理解しているが、理論的には違うかもしれない。訂正情報があれば歓迎したい)。ちなみに、NDKに含まれているほうのNdkBinderのヘッダファイルのバージョンでビルドが失敗するのは #ifdef __ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__
がきちんと書かれていないせいだ。
この仕組みを使えば、Android SDK APIでif (Build.VERSION.SDK_INT >= 30)
のように書く実行環境によるバージョン判定をC/C++コードでも実現できるだろう。
この__builtin_available()
というマクロはclang独自のものなので、gccなど他のツールチェインを使っているとコンパイルが通らないのだけど、NDK r23以降ではclangしかサポートされていないので、古いNDKを使っていない限りは問題は生じないはずだ。
これらのマクロが__
で始まっていることからも想像がつくと思うが、このやり方はあくまで過渡的なものだ。現状、NDKやプラットフォームの実装コードでしか使われていない。他に対応策があれば使うべきではない。しかしこのやり方でしか実現できないことはあるので、手段として知っておくのは悪くないだろう。筆者のようにaidlツールの自動生成コードなどでハマったときは、この回避策を使うしか無い(実際これはNDKチームから教えてもらったことだ)。
プラットフォームから消失するAPIへの対処: Oboeの事例
筆者はAPIレベルによって利用できるAPIに制約を受ける問題への対処方法をもうひとつ知っている。
AndroidプラットフォームAPIは常に変化している。新しいAPIが出てきては古いAPIが消えていくのというのは世の理だ。新しいAPI Levelでは古いAPIがdeprecated扱いになることがあり、それらを使っているとビルド時にwarning扱いになったりする。<del>Android Studioで新規C++ (NDK) プロジェクトを作成すると、CMakeLists.txt
ではC/C++コンパイラーの呼び出しに-Werror
が付いているので</del>CMakeLists.txt
ではC/C++コンパイラーの呼び出しに-Werror
を指定できるので、deprecatedなAPIを使うとエラーとなってビルドに失敗することもある。
この関係で、筆者はNDKでOboeを使おうとして面白い罠にハマったことがある。Oboeについて大まかに説明すると、AndroidのネイティブオーディオAPIにはちょっとした変遷があって、昔はOpenSL ESというAPIが(API Level 9から)使われていた。これが低レイテンシーオーディオを十分にサポートできなかったので、Androidプラットフォーム側で低レイテンシーオーディオをサポートする基盤が整備され、Android 8.0からはAAudioというAPIが使えるようになった(ただ8.0のAAudioには問題があって実質的に8.1以降のみがサポートされている)。Androidの常として、最新バージョンにしか含まれていないAAudioを使える環境は多くなかったので、「OpenSL ESでもAAudioでもシームレスに『低レイテンシーかもしれないオーディオ』を扱えるAPI」としてプラットフォームの外側でOboeが誕生した。
AAudioに比べてOboeのほうが直感的に使いやすいこともあって、Oboeは現在Androidでネイティブオーディオを扱う標準的なAPIの地位を占めることになった。そして、AAudioで低レイテンシーオーディオの基盤が固まったAndroidプラットフォームの状況とOboeがデファクトスタンダードになった状況では、もうOpenSLESは必要ないということになって、API Level 30からはdeprecatedという扱いになった。まだ存在はしているが、いずれ消えることになると想定しておいたほうがよいだろう。
ところでOboeをビルドするためにはOpenSLESとAAudioの両方が利用可能になっていなければならないが、android-30からはOpenSLESがdeprecatedとなるので、OboeをソースからビルドするようなアプリケーションでminSdk 30
を指定していると、deprecatedなAPIを使おうとしているのでwarningが出るし、-Werror
を指定していると実際にはエラーになってしまう。OboeはPrefabパッケージでもあるのでバイナリだけを参照することもできるが、ソースも含めてデバッグしたいときには困る。
厄介な問題だったのだけど、現在のOboeではこれが解消されている。どうやって解消されたかというと、OpenSLESのAPIを直接呼び出さずにdlopen()
とdlsym()
で動的にロードするようになったからだ。実のところ、OboeではもともとAAudioの呼び出しをこの手法で実現していた。そうしないとAndroid 8.0以前を対象とするアプリケーションでネイティブコードをリンクできなかったからだ。JavaやKotlin由来のコードを実行するDalvikバイトコードVM (ART)とは異なり、NDKのネイティブツールチェインでは、リンク時に外部ライブラリへの参照として解決したシンボルが、実行時にJNI経由でロードされるときに存在していなければならない。bionic libcのdlopen()
では、RTLD_LAZY
を指定してもRTLD_NOW
と同様にロード時シンボル解決が強制されてしまうためだ。
つまりOboeは、Android 8.1未満の端末のためにAAudioを強参照せず動的にロードし、Android 30以上の端末のためにOpenSLESを強参照せず動的にロードしていることになる。トリッキーな手法ではあるけど、Oboeはそこまで使用するネイティブAPIの数が多くなかったのでうまくいっているようだ。筆者もNdkBinderを使っているコードを「Android 10未満でも使えるようにdlopen()
を使った条件付きロードにしようかな」と思ったことがあるが、すぐに煩雑になりそうなので思いとどまった。
総括
今回は、Android SDK APIなら自然にできる、APIレベルによる実行コードの条件分岐を、Android NDKを使ったネイティブコードで実現する方法をふたつ紹介した。__ANDROID_UNAVAILABLE_SYMBOLS_ARE_WEAK__
を使うアプローチはAndroid SDK APIのやり方に近いけど、まだGoogleが公式にできない過渡的なやり方だ。dlopen()
とdlsym()
でやり過ごすのは標準APIでも出来る安定的なやり方だけど、すべて動的に書かなければならない茨の道だ。前者のやり方がAGPなどを通じて標準化されるのを待てるなら待ってもいいだろう。もっとうまいやり方があったらぜひ知見を共有してほしいと思う。
Discussion