📌

Android NDKでAPIレベルによる条件分岐を実現する

2022/11/10に公開

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にもこのバージョン情報は反映されない。

minSdktargetSdkで指定されたバージョンは、そのアプリケーションのAndroidManifest.xmlに含まれる<uses-sdk>要素の属性minSdkVersiontargetSdkVersionとして反映される(ユーザーが作成するAndroidManifest.xmlには必要なく、Gradleがビルド時に自動生成するAndroidManifest.xmlに自動的に反映する)。Androidプラットフォーム(アプリケーションの実行環境)では、アプリケーションのAndroidManifest.xmlで指定されたtargetSdkVersionによって挙動が変わることがある。また、minSdkVersionの条件を満たさなければ、そのアプリケーションは実行できないとされる(AOSPのプラットフォーム実装を改造すれば実行できる可能性はある)。

Android SDKで新しいプラットフォームAPIが追加されるたびに、「このtargetSdkを指定したら挙動がこう変わる」という説明が書かれている項目が出てくるが、大抵は「モダンなAndroidの挙動」を含意する。重要なのは、Google Play Storeで公開できるアプリケーションの条件として毎年のように新規アプリケーション登録に要求されるtargetSdkの数値が引き上げられるという慣習があることだ。「新しいAPIは使いたいのでcompileSdkは最新にしたいが、同じ値をtargetSdkに指定すると挙動が変わるし、このアプリケーションはまだその準備(対応)が出来ていない」といった状況では、これらを使い分けることになる。また、あるアプリケーションやライブラリをビルドするとき、そのtargetSdkdependenciesで指定するライブラリの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.gradleabiFiltersなどで指定するときには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/includetoolchains/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