prefab: Android Studioの最新ネイティブライブラリ依存解決機構

Android Advent Calendar 202020日目はAndroid NDK方面で今開発が進んでいるPrefabパッケージ機構についていろいろまとめます。タイトルに書いている「ネイティブ」とはC/C++等のコンパイラが生成するCPUネイティブなコードの意味です*1

目次

Prefabとは?

Prefabとは、Android NDKを使ったネイティブコードを活用するアプリケーション開発の世界で新しく登場した、C/C++などのコードのビルドでサポートされるパッケージ解決のための仕組みです。Android Studio 4.0がリリースされた時にアナウンスされた新機能のひとつです。

android-developers.googleblog.com

その後Android Studio 4.1のリリースでもこれが大幅に改善されています。 ("Export C/C++ dependencies from AARs" のセクション)

android-developers.googleblog.com

Android Studioでは現在、(このPrefabに限らず)ネイティブコードサポートの機能がどんどん進化しており、現在Canaryビルドとして開発されているAndroid Studio 4.2 Canary…からArctic Foxこと2020.3.1 Canaryへと改名されたリリースでは、ついにCMakeを使っているプロジェクトでLLDBを使ったネイティブコードのデバッガーがちゃんと機能するようになりました*2。NDKを使っているアプリ開発者はArctic Foxを使うようにしたほうが多分ずっと開発体験が良いです(まあバグを踏む可能性はあります)。

AARパッケージの仕組みと、それだけでは足りない部分

Android SDKとKotlinでアプリ開発をやっている人は、日頃からAARやJARを使って(build.gradle上でdependenciesを指定して)開発していると思います(そうでない人は滅多にいないですよね…? *3)。これは全てMavenの仕組みに乗っかっています。

Mavenは元来JavaOracleなどのやつ)のJARパッケージの依存関係を解決するものでしたが、Androidのパッケージ依存関係も基本的には同様に解決できるので、Mavenが使われるようになりました。Androidのプラットフォームに近いJetpackなどのパッケージはmaven.google.comで指定しますが、他のライブラリはけっこうBintray jCenterから落としてきますね。

さて、これはあくまでKotlinあるいはJava言語のパッケージの話であり、Android NDKを中心とするC/C++その他のネイティブコードの世界に当てはまるものではありません。C/C++のビルド機構とKotlin/Javaのビルド機構は前提が全く異なります。

たとえば、C/C++ではヘッダファイルを#includeで指定する必要がありますが、これらは既存のAARには含まれません。AARにはjniLibsにネイティブ共有ライブラリそのもの(*.so)を含めることができますが、ヘッダファイルはアプリケーション開発者が別途何らかの手段で入手しないと、そのネイティブライブラリに依存するコードをビルドできません。また、共有ライブラリを参照してリンクする際にはその共有ライブラリのファイルも必要ですが、ライブラリのパスを適切に解決するやり方が、ndk-buildの仕組みから少しでも外れると存在しません。

Android NDKを使った開発はCMakeを使ったプロジェクトモデルにシフトしており、CMakeでまともにヘッダファイルやライブラリファイルのパスを指定する手順が無いことが、特にNDKで既存のC/C++資産を使う上での障害となっていました。そもそも、Android向けにライブラリのパッケージが存在するのであれば、clangに-Iだの-Lだのが渡されるようにアプリ開発者がいちいち指示せずとも、パッケージをdependenciesに指定するだけでよろしく解決してほしい、とアプリ開発者なら思うところです。

さすがにdependenciesを追加するだけで自動解決というのは無理(不適切)なのですが、dependenciesを追加してCMakeLists.txtにちょっと依存パッケージの記述を追加するだけで解決してしまう、というのが、今回のトピックであるPrefabの機能です*4

ちなみに、これまではじゃあどうしていたのかというと、ヘッダーファイルを直接リポジトリに取り込んだり、Androidでもビルドしやすいようにビルドスクリプトをndk-buildやAndroidのCMakeサポートでもビルドできるように書き換えたり、AGP (Android Gradle Plugin) が展開するビルド中間ファイルbuild/intermediates/... 以下のパスをCMakeLists.txt中でlink_directories(...)で指定したり(!)していたわけです。当然ながら本家がバージョンアップすると動かなくなることもよくあるわけで(ビルドエラーで済めばまだいいけど実行時に初めて気づくような問題になったりとか…)、メンテナンス性が下がるわけです。

Prefabツールによる状況の改善

prefabメタデータとprefabツールの役割

Prefabは、アプリケーションあるいはライブラリであるDerivedがライブラリであるBaseに依存するとき、BaseのAARに含まれるネイティブコードBase-nativeに依存するライブラリDerived-nativeを、その他の外部のファイルに依存せずにビルドできるようにします。そのためには、Base-nativeC/C++ヘッダーはDerived-nativeのコードに記述する#include ...を解決するためにほぼ間違いなく必要になるので、BaseのAARの中にバンドルします。

AARをビルドするAGPとしては、Base-nativeでビルドされるファイルのどれがDerived-nativeのビルドやパッケージングの際に必要となるかわかりません。ヘッダファイルにも、公開APIを定義するものとそうでない内部実装むけのものがあり、機械的には判別できません。内部実装用ヘッダーはよくincludeではなくsrcディレクトリに含まれているものですが、あくまでパターンであり、公開機能と非公開機能など他の分け方になっていることもあります。またpkg-configをサポートしているライブラリは、利用する側のライブラリなどでもpkg-configでパッケージを解決する前提でlib/pkgconfig/*.pcファイルを含める必要があります。つまりC/C++ヘッダーだけを含めれば良いというものでもありません。

そういうわけで、これを指示するprefabビルド用メタデータBaseのビルドのために必要となります。これはJSONで記述されるもので、その内容は、 https://google.github.io/prefab/ で詳しく説明されています。ただし、このgithub pagesで書かれている情報のほとんどは、prefabコンソールツールの使い方やメタデータのフォーマットに関するものであり、われわれアプリ開発者にとっては無関係ですアプリ開発者はむしろ(後述する)Android Studio 4.1以降でサポートされているbuild.gradleの書き方を知っている必要があります。今回ここでprefabのJSONフォーマットについて説明することもありません。

PrefabはAGPでサポートされていますが、prefabツールは独立して呼び出してビルドシステムが必要とするファイルを自動生成するように作られています。このツールにおける「ビルドシステム」とはCMakeとndk-buildのことであり、生成されるのはCMakeのfind_package()で参照できるパッケージの*.cmakeファイルであったり、ndk-buildでAndroid.mkからインクルードできるスクリプトであったりします。

AGPは、build.gradleに記述されているprefab関連のセクションの内容をもとに、cmakendk-buildを呼び出すより前にprefabツールを呼び出して、生成されたファイルをこれらのツールで参照できるようにコマンドライン引数などを調整します(cmake -DFIND_ROOT_PATH=... など)。

Prefabアーカイブの汎用性

ちなみにPrefabパッケージはAARですが、ディレクトリ構成としてはトップレベルにprefabというディレクトリが一定の内容・構造で存在し、必要なメタデータテキストさえあればよい、という程度で、JARファイルすら存在する必要がありません。実際、GoogleのオーディオI/O用ライブラリOboeのパッケージにはJavaやKotlinのライブラリコードが含まれません。

Prefabパッケージの配布形態はAARであり、AARは単なるzipなので、prefabツールなどを用いずに自分でヘッダー、ライブラリのバイナリ、メタデータなどをアーカイブしても良いです。例えば、Prefabを具体的に使っている例としておそらく最もポピュラーなのはOboeだと思いますが、Oboeのprefabパッケージングはシェルスクリプトで行われています。

https://github.com/google/oboe/blob/master/prefab_build.sh

また、Microsoft/vcpkgにはPrefabパッケージをビルドする機能も追加されています。

vcpkg.readthedocs.io

一方で、パッケージを消費する側を担うprefabツールはndk-buildとcmakeのみを対象としていますが、Prefabパッケージのファイル構成やメタデータの仕組みそのものは特定のビルドシステムを前提としない作りになっているので、prefabツールと同様にメタデータから各ビルドシステムが期待するパッケージ解決ファイル等を生成するツールを用意すれば、他のビルドシステム(IDE固有のものなど)からも利用できるかもしれません。

モジュール

1つのPrefab AARパッケージに複数のネイティブライブラリを含めることもできます。Prefabパッケージ1つには、1つ以上のモジュールが含まれ、この1つ1つがネイティブライブラリ1つ1つに対応します。パッケージのユーザーは、全部ではなく一部のモジュールのみをCMakeのfind_package()などで依存関係として使うことができます。

複数のライブラリの依存関係をスマートに解決するには、1つのPrefabパッケージの中に複数のモジュールを無用にバンドルしないほうが良いでしょう*5。たとえばGNOMEちほーで使われているglib/atk/pango/gtkを全部まとめて1つのパッケージとして配布するのは多分アンチパターンです。一方でglibとしてひとまとめで配布されているものをgobjectとgmoduleglibと…のように共有ライブラリ1つ1つに分けて配布するのもやり過ぎでしょう。

ちなみに本稿執筆時点でglibのPrefabパッケージは存在しません。glibをAndroid向けにビルドするだけでもそれなりに大変なので…参考までに、5年前に書いた記事を貼っておきます。(この頃からNDKとライブラリのパッケージ解決は問題だったという問題意識の共有も含めて…)

qiita.com

Android StudioでPrefabパッケージを使う・作る

現在のAndroid Studioの安定版であるAndroid Studio 4.0以降では、Prefab AARパッケージに含まれるヘッダーファイルやライブラリを展開してアプリケーションから(別途ヘッダーファイルを用意したり不安定なintermediatesのパスを指定したりすることなく)合理的なスクリプトでビルドできるようになっています。またAndroid Studio 4.1以降では、AndroidのライブラリプロジェクトをPrefabとしてビルドできるようにもなりました。

以下に、Prefabによるビルドを有効にしたい場合にbuild.gradleに追加すべき内容を説明します。Kotlin scriptで書いている場合は適宜読み替えてください。(1-a) (1-b)のいずれかと、(2)が必要になります。

(1-a) AGPでPrefabパッケージを使いたい(ネイティブコード依存関係を解決したい)場合は、buildFeaturesの一部としてprefabオプションを有効にする必要があります。

android {
    ...
    buildFeatures {
        prefab true
    }
}

これを有効にしない場合でも、アプリケーション側にネイティブビルドでこのライブラリに依存する部分が無いのであれば、そのprefabパッケージのAARにjniLibs含まれていれば特に問題なくビルドできます。この条件が後で割と問題になってくるのでですが、今はとりあえず気にしないでおきましょう。

(1-b) AGPでPrefabパッケージを作りたい(ライブラリを提供する側としてビルドしたい)場合は、今度はprefabPublishingという機能を有効にする必要があります。そして、prefabセクションでモジュールごとセクションを作り(以下の例ではmylibrary)、そのモジュール名をname、ヘッダーファイルを含むディレクトリをheadersとして指定します。

android {
    ...
    buildFeatures {  
        prefabPublishing true}  
    }
    prefab {
        mylibrary {
            name 'foobar'
            headers '../../external/foobar-master/include'
        }
    }
}

(2)そして、もう1つ実質的に必要になるのでやっておくべきこととして、CMakeビルドのオプションとしてANDROID_STL=c++_sharedを指定します。ライブラリ提供者と利用者の両方で行う必要があります。

android {
    ...
    externalNativeBuild {  
        cmake {  
            arguments "-DANDROID_STL=c++_shared"  
        }
    }  
}

パッケージの作成と参照の解決はこれで出来るようになりました。参照する側はさらにCMakeLists.txtAndroid.mkに修正を加える必要があります。Android StudioでCMakeサポートが正式に含まれるようになっている現在、ndk-buildを使う意義はほぼ無いので、ここではCMakeでのみ説明しますが、追加で必要になる記述はこの程度です。

find_package(mylib REQUIRED)
add_library(myapp SHARED app.cpp)
target_link_libraries(myapp mylib::mylib)

PrefabとAGPのPrefabサポートの現在の課題

AGPのPrefabサポートは、まともな問題意識と解決のためのアプローチを採用している側面があり、一見魅力的なのですが、現時点ではPrefab機構の設計に影響するレベルでいくつか問題を抱えており、安定版に含まれている機能だからといって全面的に依存できる気がしない…というのが、現時点でのわたしの評価です。

以下に代表的な問題点をいくつか出しておきます。全て、自分のプロジェクトをPrefabに移行しようとして直面した問題です。あくまで本稿執筆時点での問題なので、近い将来に解決する可能性があります。(解決してほしい…)

build.gradleでヘッダーディレクトリが1つしか指定できない

prefabPublishingのモジュール別のheadersプロパティは単一の文字列で、複数のディレクトリを指定することができません。これはUnix系ライブラリでいえば「利用者に提示できる-Iオプションを1つしか指定できない」というのに等しい機能制限です。

Prefabパッケージとnon-Prefabパッケージの排他的関係

現在、AGPのNDKサポートでは、あるファイル名をもつネイティブライブラリが複数のAARに含まれていると、内容の競合であるとしてビルドエラーとする仕組みになっています。

現在、Prefabパッケージには、prefabディレクトリ以下にCPUアーキテクチャ別に格納された共有ライブラリと、従来型のjniLibsディレクトリの両方に、同じファイル名をもつ、内容のリンクのされ方が少し異なるものが存在しています。実は、prefabbuildFeaturesで有効にしたアプリケーションでこのAARを参照すると、これらの2つのディレクトリそれぞれの内容が参照解決時に競合となってビルドエラーとなるという(割と信じられないレベルの)問題があります。

この問題を解決するには、build.gradleでjniLibsに一切共有ライブラリを含めない設定にする必要があります。

android {
    ...
    packagingOptions {  
        exclude '**.so'
    }
}

jniLibsに共有ライブラリが含まれないとどうなるのか? 当然ながらPrefabを有効にしていないアプリケーションでUnsatisfiedLinkErrorが起こることになります。あるいはビルド時に何らかの方法で-Lのパスを指定していたらリンク時にundefined referenceになります。

この問題は一見ささいな実装不足ですが、どう解決するのが妥当なのか…と考え出すと、割と面倒な問題でもあります。あと開発チームがNDKチームとAGPチームで分かれて全然問題意識が共有されていなさそうでもあります。絶賛放置されていますし。

全てのネイティブライブラリで同一のlibc++_sharedを使う必要がある

前項の問題と関連する…というよりはこちらのほうが問題の出発点だったのですが、Prefabを使う場合はSTLについてlibc++_sharedを使うことを前提として共有ライブラリをビルドすることが(実質的に)求められます。というのは、AGPlibc++_staticを使ってビルドされたライブラリを全てサポート外として却下するからです。なぜlibc++_staticがサポート外とされるかというと、簡単にC++ODR (One Definition Rule)に違反するような競合を引き起こすからです。

STLはCMakeの場合は-DANDROID_STL=...オプションで指定できます(externalNativeBuild.cmake.arguments)。ちなみにデフォルトの値はc++_staticです。つまり明示的にc++_sharedを指定する必要があります。*6

android {
    defaultConfig {
        externalNativeBuild {  
            cmake {
                arguments "-DANDROID_STL=c++_shared" (, ...)
            }
        }
    }
}

Android NDKにおけるSTLの扱いの詳細についてはこのページを見ると良いです。

developer.android.com

これはおそらくクロスコンパイルによってアプリケーションをビルドする開発環境では多かれ少なかれ同様の問題があるのだと思いますが、Prefabの場合は全てのAARにlibc++_shared.soが(packagingOptionsで排除しない限り)含まれることになるので、"one STL per app"のルールに照らして、1つのlibc++_shared.soを「選ぶ」必要が生じてきます。しかし、NDKのリビジョンが変わる度に別々のlibc++_shared.soが含まれてきて、それがAARにバンドルされてくるとなると、厄介な問題になります。

ライブラリはさまざまな開発者が提供しさまざまな状況のアプリ開発者が利用することになるので、何かしらの包括的な手当てが必要だと思うのですが、現状そういう手当ては何も実装されていません。このリンク先のドキュメントには「ReLinkerを使うとよい」とあるのですが、それならAGPのNDKサポートのビルドステップとして実装しておいてほしい…

staticビルドがサポートされない vs. JNIを利用するコードはstaticでビルドしなければならない

Prefabはlibc++_sharedを前提として設計されているのに、JNIを使っているライブラリではこれを使ってはならないという制約があるようです。

Caution: JNI libraries distributed with Java AARs must not use the shared runtime to avoid conflicting with other libraries and the app. The warnings below still apply. See the documentation for Middleware Vendors for more information.

もう完全にどうしたらいいのかわからない。

ndkports

この他に、GoogleではNdkPortsというプロジェクトで既存のUnix系ライブラリを移植して蓄積したいという目論見があるようです。ndkportsはPrefabの公開時点でAOSPで公開されており、jsoncpp, openssl, curlの3つが含まれていました。そして現在は…

https://android.googlesource.com/platform/tools/ndkports/+/refs/heads/master/ports/

jsoncpp, openssl, curlの3つが公開されています。つまり…開発が進んでもいないし、誰も貢献していません。これ、冒頭のAndroid Developers blogの投稿で「パッチを送ってください」って書いているくらい開放していて、わたしも一度これでglibをビルドしてみようとしたのですが、自分でコマンドを呼び出す以外で出来ることが何もなく、まともに依存関係が解決できなさそうだったので、これは忘れてもよさそうです。Googleは以前にもCDepというプロジェクトでポシャっています。

build.gradleで project(...)で参照している同一プロジェクト内のモジュールへの参照解決が実装されていない

Prefabモジュールを書いて、そのサンプルアプリのモジュールを同じプロジェクト上に作るのはとても一般的で、その際にはproject(...) でライブラリを参照することになりますが、現状のAGPではこのprefabの展開が実装されていません。つまりサンプルプロジェクトがまともに作れないわけです(!)

https://issuetracker.google.com/issues/175291074

こんな根本的な問題がなぜ放置されているのか理解に苦しむレベルですが、要はこれまでのネイティブコードのプロジェクトでは(多分)すでに力技で解決してきているので、破壊的に変更されているわけではないからでしょう。

補足: Prefabトラブルシューティング

…は、書ききれなかったので、この辺の知見は、もしちゃんとまとまったら技術書典10なりzennなりで小冊子にでもして出したいと思いますが、とりあえず困ったらbuild/intermediates/cmake/debug/obj/armeabi-v7a/prefab_stderr_cmake_armeabi-v7a.txtみたいなファイルを(自分のABIに合わせて)読んでみると良いでしょう。

*1:FlutterやXamarinなど一部界隈でKotlinやJavaのことを(プラットフォーム中立なバイトコードと対比する意味で)「プラットフォーム・ネイティブ」などと呼んでいるのとは全然違います。

*2:多分これ以前はCMakeLists.txtを辿ってデバッグ対象のソースファイルを発見することが出来なかったんじゃないかと想像します。

*3:Bazel使いとか、クロスプラットフォームSDK使いでビルドスクリプトをGradle無しで自作する必要がある人とか? 自分がかつて部分的にそうだったわけですが…

*4:Prefabは、prefabコマンドツールの名称でもあり、Android Gradle Plugin(やAndroid Studio)がサポートする機能の名称でもあるのですが、前者の意味で使われる場面はめったに無いので、以降は基本的に機能の名称と思っておけば大丈夫です

*5:複数の応用ライブラリが別々のモジュール依存関係のツリーを構築して、ライブラリのユーザーの手元でバージョンの異なるものが競合しやすくなります

*6:こんなデフォルト値に何の意味が??と思われるかもしれませんが、今デフォルト値を変えたら従来のアプリのビルドが阿鼻叫喚になるわけで…