JUCE6はCMakeに対応していますが、実はCMake対応の恩恵を受けられないプラットフォームが存在します。Androidです。
「は?」というのが多分一番正しいでしょう。何しろCMakeはだいぶ前からAndroid Studioでサポートされているわけで、むしろ一番恩恵を受けるべきプラットフォームです。Win/Mac/Linux/iOSのいずれもCMakeネイティブなビルド環境ではないのに、むしろCMakeがネイティブのビルド環境としてサポートされているAndroidがなぜかサポート外になっているのです。このままではいけない。
目次
Missions
2021年1月にリリースされたJUCE 6.0.6の時点で、AndroidはProjucerによってのみサポートされています。ProjucerはAndroid StudioからビルドできるようなAndroidアプリケーションのGradleプロジェクトを生成します。アプリ開発者がProjucerからAndroid Studioを起動してそこからデバッグ実行できる状態のものができています。JUCEアプリケーション部分はAndroid NDKを用いたネイティブコードのアプリケーションとなり、Projucerが生成するプロジェクトはこれを独自のView上にネイティブコードで描画し、UIイベントをAndroidフレームワークとネイティブコードの間で相互運用します。
Projucerで生成されるAndroidアプリケーションでは、AndroidManifest.xmlやbuild.gradleのさまざまな情報がProjucerのAndroidExporterで規定されたプロパティとしてカスタマイズ可能な項目となっていますが、これらはAndroidアプリケーション開発者が自前で調整できるほうが、自然でメンテナビリティの高い高品質なコードになります。Androidプロジェクト上で何がカスタマイズ項目であるかを知っているだけで設定できる方が、それに加えてProjucerの何というプロパティでどのように設定する必要があるかを知らないといけないよりも簡単であるのは自明でしょう。Projucerが生成するプロジェクトは不自然で、古臭く、アンチパターンに陥っている箇所もあります。
JUCEチームがAndroid開発のエキスパートをかかえていて、常に最新のトレンドにキャッチアップして適切なプロジェクトモデル生成をProjucerに実装できるなら話は別ですが、これが現実化することはまず無いでしょう。そもそもProjucerで全て生成するというのは筋が悪いです。Projucerは切り捨てて、CMakeのモデルに移行していくのが今後の望ましい姿です。
CMakeサポートに求められるのは、Projucerに代わるアプリケーション全自動生成機構ではありません。C++とAndroid NDKをサポートするAndroidプロジェクトではCMakeサポートが組み込まれており、JUCE + Android + CMakeサポートに期待されるのは、Android Studio (Gradle) プロジェクトのCMakeサポートの部分に、いかに違和感なくJUCEのCMakeプロジェクトを適用できるか、にあります。
Projucerを使わないAndroidアプリケーションのビルド機構としては、アプリケーションのActivityから巡り巡ってJUCEアプリケーションのブートストラップ処理に入るネイティブコードのエントリーポイント関数を呼び出せれば、ミッション完了です。本当はそれに加えてAndroidのアプリケーション ライフサイクルに沿った状態管理なども必要になるのですが、どうせProjucerで生成されたアプリケーションでもそれなりにしか出来ていないですし、JUCEアプリケーションに固有の問題ではないので、ここではあまり気にしなくても良いでしょう。
これを実現するためには、ProjucerでどのようなAndroidアプリケーションのファイル群が生成されているのかを把握し、何をユーザー(Androidアプリ開発者)が作成し、どのようにJUCEアプリケーションを繋ぎ込むかを手順化して、実現可能なワークフローを確立する必要があります。
理想を言えば、「既存の」CMakeプロジェクトをそのまま取り込めれば、Androidサポートの可能性が格段に広がります。とはいえ現状では厳しいので、既存のCMakeプロジェクトとの差分を最小化する方向性のみを堅持していくのが良いでしょう。無理に既存コードをそのまま取り込めるようなtoolingの実装に開発コストをかけすぎると、そのツール自体のメンテナンス性が下がります。
以上のような前提で、今回は以下の2つを目標として設定しています。
完成品から(だけ?)見たい人は以下のリポジトリを見ると良いでしょう。
特に後者のプロジェクトには元のプロジェクトに対するパッチが含まれているので、どれくらいの差分でAndroid用ビルドが実現しているのかわかりやすいと思います。自作のandroid-audio-plugin-framework対応コードも含まれているので、実際にはさらに小さい差分で足ります。
分析編
このセクションでは今回のミッションを達成するために調べたことをまとめます。ソーセージの中身に興味がない人は飛ばして読むと良いでしょう。(何でこんなことをしているのか、を把握せずに読み進められるかな…?)
Android StudioでC++サポートの付いたプロジェクトを新規作成すると、この画像のような構成になっています。これが目指すべき状態です。res
以下には大量のファイルがあるので折りたたんでいます。
一方で、JUCEのAndroidプロジェクトはこうなっています。とはいってもこれは不完全なリストです。具体的には、C++のコードが表示されていません。このトップディレクトリの外側に存在しているためです。
いろいろ違うところはありますが、共通している部分が多いことも見て取れるでしょう。
ProjucerのAndroidExporterで生成されるファイル
この節ではProjucerが生成するAndroid Gradleプロジェクトの内容を読み解きます。
生成されるファイルは、GUI ApplicationやAudio Pluginなど、プロジェクト種別である程度は異なりますが、大枠では大差ないはずです。
build.gradle, local.properties, settings.gradle, gradlew(.bat), gradle/
これらは何も特別なところがなく、普通にAndroid Studioでプロジェクトを生成するのとほぼ変わりません。バージョン番号などが異なり、またAndroid Studioのプレビュー版などではMavenのリポジトリが追加されていることがありますが、Projucerの出力はシンプルです。
app/src/debug/res, app/src/release/res
@string/app_name
を定義するstring.xml
だけが含まれています。しかしProjucerのAndroidリソースの生成はややいびつで、debugビルドとreleaseビルドでディレクトリを分けており、これは一般的ではありません。Projucerでビルド設定ごとに異なる内容を指定できるようにしているのを愚直に再現しているのが悪いので、ゼロから作るCMakeプロジェクトで配慮する必要はありません。
app/src/main/AndroidManifest.xml
WifiやBluetoothなど、利用するモジュールによって必要になるpermissionなどのmanifest項目が増えることになります。この程度のことはアプリケーション開発者が自分で作業すべきでもあります。
あと、Manifestはマージできるので、JUCEモジュールごとにAARを構築してそれぞれにAndroidManifest.xmlを付けるという手がありますが、とりあえずそこまで求めなくても十分です。
app/build.gradle
一番特殊かつ意味があるのは、カスタムsourceSetsの追加部分で、JUCE標準モジュールの中に含まれるJavaのソースが追加されている部分です(これについては後述します)。基本的にはここで追加するのはあまり適切ではなく、削られるべきものもあり、残しておいてもまあ悪くないというレベルのものもあります。
他にも、signingConfigなど、一般的に必要ではないものが生成されており、これはProjucerにそういうオプションがあるのが悪いです。CMakeビルドでProjucerの負の遺産を引きずる必要は無いですし、productFlavorsもあえて生成する必要はありません。必要な開発者が自分たちの都合に合わせて自前で設定すべきです。
app/CMakeLists.txt
このファイルにはさまざまなオプションが使用しているJUCEモジュール次第で追加されます。
juce_audio_devices
モジュールが含まれていると、Oboeのビルドが追加されます:
set(OBOE_DIR "/media/atsushi/extssd0/sources/JUCE/modules/juce_audio_devices/native/oboe")
add_subdirectory (${OBOE_DIR} ./oboe)
これが必ず含まれていると思いますが、もしかしたらオプションかもしれません。add_definitions()
の内容はオプション次第です。<...>/JUCE/modules
は(個人の環境になっていますが)グローバルパス設定から来ています。プロジェクトのUIDのようなものが含まれているプロパティもあるのですが、さすがにいらないようです。
add_library("cpufeatures" STATIC "${ANDROID_NDK}/sources/android/cpufeatures/cpu-features.c")
set_source_files_properties("${ANDROID_NDK}/sources/android/cpufeatures/cpu-features.c"
PROPERTIES COMPILE_FLAGS "-Wno-sign-conversion -Wno-gnu-statement-expression")
add_definitions("-DJUCE_ANDROID=1" "-DJUCE_ANDROID_API_VERSION=16" "-DJUCE_PUSH_NOTIFICATIONS=1"
"-DJUCE_PUSH_NOTIFICATIONS_ACTIVITY=\"com/rmsl/juce/JuceActivity\"" "-DJUCER_ANDROIDSTUDIO_7F0E4A25=1"
"-DJUCE_APP_VERSION=1.0.0" "-DJUCE_APP_VERSION_HEX=0x10000")
include_directories( AFTER
"../../../JuceLibraryCode"
"/media/atsushi/extssd0/sources/JUCE/modules"
"${ANDROID_NDK}/sources/android/cpufeatures"
)
enable_language(ASM)
config次第でオプションが追加されますが、長いので省略します。ほとんどは標準のJUCE CMakeサポートが肩代わりして不要になります。
if(JUCE_BUILD_CONFIGURATION MATCHES "DEBUG")
add_definitions("-DJUCE_DISPLAY_SPLASH_SCREEN=1" "-DJUCE_USE_DARK_SPLASH_SCREEN=1"
"-DJUCE_PROJUCER_VERSION=0x60005" "-DJUCE_MODULE_AVAILABLE_juce_audio_basics=1" ...
"-DDEBUG=1" "-D_DEBUG=1")
elseif(JUCE_BUILD_CONFIGURATION MATCHES "RELEASE")
add_definitions("-DJUCE_DISPLAY_SPLASH_SCREEN=1" "-DJUCE_USE_DARK_SPLASH_SCREEN=1"
"-DJUCE_PROJUCER_VERSION=0x60005" "-DJUCE_MODULE_AVAILABLE_juce_audio_basics=1" ...
"-DNDEBUG=1")
else()
message( FATAL_ERROR "No matching build-configuration found." )
endif()
残りの大半はadd_library()
でソースを列挙し、プロパティを設定しています。実際にはソース列挙だけで十分でしょう。
最後にtarget_link_libraries()
などが記述されます。eglなどは多分juce_gui_basics
が無ければ不要でしょう。
target_compile_options( ${BINARY_NAME} PRIVATE "-fsigned-char" )
if( JUCE_BUILD_CONFIGURATION MATCHES "DEBUG" )
target_compile_options( ${BINARY_NAME} PRIVATE)
endif()
if( JUCE_BUILD_CONFIGURATION MATCHES "RELEASE" )
target_compile_options( ${BINARY_NAME} PRIVATE)
endif()
find_library(log "log")
find_library(android "android")
find_library(glesv2 "GLESv2")
find_library(egl "EGL")
target_link_libraries( ${BINARY_NAME}
${log}
${android}
${glesv2}
${egl}
"cpufeatures"
"oboe"
)
JUCE/modules/juce_core/native/javacore/init
init/com/rmsl/juce/Java.java
というネーミングがアレなファイルだけが入っています。中身は短い。
package com.rmsl.juce;
import android.content.Context;
public class Java
{
static
{
System.loadLibrary ("juce_jni");
}
public native static void initialiseJUCE (Context appContext);
}
この実体部分はjuce_core
モジュールの中でJNIで実装されています。このJNI呼び出しは存在している必要があり、JUCEモジュールの中に存在している必要はありません。同等のJava
クラスをKotlinで作ってしまえば不要になります。
JUCE/modules/juce_core/native/javacore/app
com/rmsl/juce/JuceApp.java
というファイルだけがあります。これも短い。
package com.rmsl.juce;
import com.rmsl.juce.Java;
import android.app.Application;
public class JuceApp extends Application
{
@Override
public void onCreate()
{
super.onCreate();
Java.initialiseJUCE (this);
}
}
実のところ、これはベストプラクティスに反するので削除すべきです。AndroidアプリケーションでApplicationクラスから派生できるのは1つしかないし、この内容でその希少な価値を独占するのは悪です。現代ではJetpack App Startupを使うべきだし、使わないとしてもContentProviderにできる(すべき)案件です。ちなみに、削除するといっても、build.gradleのsourceSetsの列挙から外すだけです。JUCEのソースから削除する必要はありません。
JUCE/modules/juce_gui_basics/native/javaopt/app
ここには2つソースがあります。com/rmsl/juce/JuceActivity.java
は、Activityに実装することの弊害(AppCompatActivityなどを利用できない等)のほうが遥かに大きいので廃止すべきですが、JNIシグネチャーが絡んでいることもあるので、このレールから外れるやり方でappNewIntent()
に相当する機能を呼び出せるか検証する必要があります。
package com.rmsl.juce;
import android.app.Activity;
import android.content.Intent;
//==============================================================================
public class JuceActivity extends Activity
{
//==============================================================================
private native void appNewIntent (Intent intent);
@Override
protected void onNewIntent (Intent intent)
{
super.onNewIntent(intent);
setIntent(intent);
appNewIntent (intent);
}
}
もうひとつ、com/rmsl/juce/JuceSharingContentProvider.java
のほうは、割と長い内容になっているので、そのままアプリケーションに取り込んだほうが良いでしょう。ContentProviderの独自実装であり、設計上も特に悪いところは無いはずです。(まあ、Javaで書かれていますが。)
ブートストラップ
AndroidでのJUCEアプリケーションのブートストラップは、次のような流れになっています。
- GUIアプリケーションの場合、
juce_gui_basics
に含まれるJuceActivity
がcom.rmsl.juce.Java.initialiseJUCE()
を呼び出す
- GUIアプリケーション以外は同様の手順を
Service.onCreate()
などで踏む必要がある
Java
クラスはlibjuce_jni.so
をloadLibrary()でロードする
Java.initialiseJUCE()
はJNI_OnLoad()
によってjuce_JavainitialiseJUCE()
にJNIEnv
のregisterNatives()
で関連付けられており、ネイティブのThread::initialiseJUCE()
を呼び出すように実装されている
- 何でわざわざそんな名前にしているのかは不明(デフォルトで
Java_com_rmsl_juce_Java_initialiseJUCE()
に関連付けられるはず)
Thread::initialiseJUCE()
は最後にjuce_juceEventsAndroidStartApp()
を呼び出す
juce_juceEventsAndroidStartApp()
は、juce_getExecutableFile()
で得られた実行中のアプリケーションの共有ライブラリのファイルを別途dlopen()
でロードし、その中からdlsym()
でjuce_CreateApplication()
を取得して呼び出されている
juce_events
モジュールにjuce_Initialisation.h
で定義されたjuce_CreateApplication()
が含まれている
juce_CreateApplication()
はマクロJUCE_CREATE_APPLICATION_DEFINE(AppClass)
で定義されるもので、プラグインフォーマットごとに規定されるが、Androidの場合はJUCEがサポートするプラグイン規格が存在しておらず、Standaloneのみ対応しており、その生成コードには含まれている。
実装編
Androidアプリケーションテンプレートとして作る
今回の目的を実現するために、まずは標準的なAndroidアプリケーションを作成して、そのapp/build.gradle
で指定されたCMakeLists.txt
がJUCEアプリケーションをビルドして、正しくロードできるように調整する、というステップで目標を達成することにします。
まずAndroid StudioでC++アプリケーションを作成します。筆者はゼロからファイルを作ります(正確には、既存のアプリからコピペしてきます)が、簡単ではないでしょう。Gradle関連のファイルはそのまま使えます。筆者はAndroid Studio Arctic Fox (Canary)を使っているのでgradle 6.8-rc-1とAndroid Gradle Plugin 7.0.0-alpha04を指定していますが、多少古いバージョンでも問題ありません。
app/build.gradle
app/build.gradle
には(部分的な内容ですが)次のように指定します。buildTypesやproductFlavorsなど不要なものをほとんど削ったので、Projucerが生成するものと比べるとかなり短い内容になっています。
defaultConfig {
applicationId "com.yourcompany.newproject"
minSdkVersion 16
targetSdkVersion 30
externalNativeBuild {
cmake { arguments "-DANDROID_STL=c++_static", "-DANDROID_CPP_FEATURES=exceptions rtti" }
}
}
sourceSets { main.java.srcDirs += [
"../JUCE/modules/juce_core/native/javacore/app",
"../JUCE/modules/juce_core/native/javacore/init",
"../JUCE/modules/juce_gui_basics/native/javaopt/app"
] }
sourceSets
は実のところandroidx Application Startupなどを使うことでもっと減らせますし、減らしたほうが適切なのですが、今回はそこまで説明しないことにします。(これを説明するとKotlinのコードも追加しないといけなくなるので。)
app/CMakeLists.txt
app/CMakeLists.txt
には次のような内容を指定しています。ちょっと長いですが全部載せます。前述のjuce_cmake_vscode_example
リポジトリから引っ張ってきたファイルに追記していったものなので、その名残がちょっとあります。
# Automatically generated makefile, created by the Projucer
# Don't edit this file! Your changes will be overwritten when you re-save the Projucer project!
cmake_minimum_required(VERSION 3.15)
PROJECT(JUCE_CMAKE_ANDROID_EXAMPLE
LANGUAGES C CXX
VERSION 0.0.1
)
# for clang-tidy(this enable to find system header files).
if(CMAKE_EXPORT_COMPILE_COMMANDS)
set(CMAKE_CXX_STANDARD_INCLUDE_DIRECTORIES ${CMAKE_CXX_IMPLICIT_INCLUDE_DIRECTORIES})
endif()
if (ANDROID)
# defs, some are specific to Android and need definisions in prior to `add_subdirectory(JUCE)`.
add_definitions(
"-DJUCE_ANDROID=1"
"-DJUCE_PUSH_NOTIFICATIONS=1"
"-DJUCE_PUSH_NOTIFICATIONS_ACTIVITY=\"com/rmsl/juce/JuceActivity\""
)
# Enable these lines if you use juce_audio_devices API
set(OBOE_DIR "../JUCE/modules/juce_audio_devices/native/oboe")
add_subdirectory (${OBOE_DIR} ./oboe)
# libcpufeatures
add_library("cpufeatures" STATIC "${ANDROID_NDK}/sources/android/cpufeatures/cpu-features.c")
set_source_files_properties("${ANDROID_NDK}/sources/android/cpufeatures/cpu-features.c"
PROPERTIES COMPILE_FLAGS "-Wno-sign-conversion -Wno-gnu-statement-expression")
enable_language(ASM)
endif (ANDROID)
# build JUCE
add_subdirectory("../JUCE" ./JUCE)
# build App code (e.g. libExamplePlugin_Standalone.so)
add_subdirectory(src/main/cpp)
if (ANDROID)
add_library(juce_jni
SHARED
dummy.cpp
)
target_link_libraries(juce_jni
ExamplePlugin_Standalone
)
target_compile_options(ExamplePlugin PRIVATE "-fsigned-char" )
endif (ANDROID)
if (ANDROID)
からendif (ANDROID)
まで囲まれた部分が2箇所ありますが、それ以外はデスクトップのCMakeLists.txt
と変わりません。前半ではProjucerが生成する定数をいくつかそのまま指定しています。このアプリケーションではpush notificationを使っていないと思いますが、指定しないとビルドに失敗するので残してあります。
後半のポイントのひとつはtarget_link_libraries()
で、今回はプラグインプロジェクトのStandaloneビルド(ExamplePlugin_Standalone
)をリンクしています。Android用のプラグインプロジェクトとしてビルドできるのは(Shared Codeのビルドを除けば)Standaloneのみで、これはAndroid上ではexecutableではなくshared libraryとしてビルドされます。これがJUCEアプリケーションの本体になりますが、一方でアプリケーションのブートストラップではlibjuce_jni.so
が名指しでロードされます。アプリケーションのCMakeLists.txtを書き換えて生成されるライブラリをExamplePlugin
からjuce_jni
にしても良いのですが、なるべく元ファイルに変更を加えずにそのままビルドできるようにしたいので、libjuce_jni.so
を別途ビルドするようにしています。
app/dummy.cpp
アプリケーションファイルには、もうひとつ追加が必要です。このCMakeLists.txt
でdummy.cpp
というファイルを指定していますが、これはadd_library()
に何もソースを指定しないとCMakeがビルドしてくれないためです。空っぽのファイルで十分なので適当に作成しておきます。
app/src/main/cpp/CMakeLists.txt
JUCEアプリケーション本体の部分(juce_cmake_vscode_example
でいえばsrc
ディレクトリの内容)は、今回のプロジェクトではsrc/main/cpp
といディレクトリにコピーします。そしてこの中のCMakeLists.txt
の内容を少しだけ追加してあります:
if (ANDROID)
# dependencies
find_library(log "log")
find_library(android "android")
find_library(glesv2 "GLESv2")
find_library(egl "EGL")
set(cpufeatures_lib "cpufeatures")
set(oboe_lib "oboe")
target_include_directories( ExamplePlugin PRIVATE
"${ANDROID_NDK}/sources/android/cpufeatures"
"${OBOE_DIR}/include"
)
endif (ANDROID)
target_link_libraries(ExamplePlugin PUBLIC
...
${log}
${android}
${glesv2}
${egl}
${cpufeatures_lib}
${oboe_lib}
)
最初にAndroid固有の追加ライブラリをfind_library()
で検索し、それらをtarget_link_libraries()
で追加しています。
あと、juce_cmake_vscode_example
ではバイナリアセットとしてSVGファイルの追加も指定されているのですが、assets
ディレクトリに置くとAndroid assetsと混同してしまうので、juce_add_binary_data()
の呼び出しではjuce-assets
という別のディレクトリを参照するように微修正してあります。
app/src/main/AndroidManifest.xml
アプリケーションに加える最後の変更はAndroidManifest.xmlです。<manifest>
要素の内容にいくつか変更を加えます。
<supports-screens android:smallScreens="true" android:normalScreens="true" android:largeScreens="true"
android:anyDensity="true" android:xlargeScreens="true"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-feature android:glEsVersion="0x00030000" android:required="true" />
<application android:label="@string/app_name" android:hardwareAccelerated="false">
<activity android:name="com.rmsl.juce.JuceActivity" android:label="@string/app_name"
android:configChanges="keyboardHidden|orientation|screenSize"
android:screenOrientation="userLandscape" android:launchMode="singleTask"
android:hardwareAccelerated="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
ApplicationやActivityのクラスがJUCEのもので固定になるのが特徴です(筆者のリポジトリではJuceAppは取り払っています)。一応<supports-screens>
や<uses-feature>
をProjucerが生成したままの内容で残してありますが、無くても動作するでしょう。<uses-permission>
は必要に応じて追加します。
JUCE本体の修正
ここまででアプリケーションはほぼ完成しているのですが、このままビルドして実行しても、何も表示されないブランクActivityが起動するだけです。これは、ブートストラップのセクションで説明したjuce_CreateApplication()
をJUCE本体がアプリケーションの共有ライブラリから発見できないのが原因です。
JUCEのモジュールは、どうやらODR (one definition rule)を維持する目的で、全てPRIVATEでリンクされており、これは-fvisibility=hidden
が指定されているのと同等です。juce_CreateApplication()
はビルドされたライブラリにコードとして含まれてはいますが、隠蔽されているのでdlsym()
で発見できません。JUCEはこの場合JUCEアプリケーションループを開始しないので、単に何も起きずにブートストラップ処理が終了します。この問題は次のone liner patchで修正できます。
https://gist.github.com/atsushieno/7da120ef87826c9d8fdf8ad6542a16f6
この程度の変更で、AndroidでもCMakeで構築したJUCEアプリケーションが実行できるようになります。
既存のJUCEプラグインアプリケーションを移植する
https://github.com/atsushieno/aap-juce-witte-eq には、witte/EqというCMakeで作られたプラグインのプロジェクトを取り込んでビルドしています。このアプリをsubmoduleで指定して、それに対するパッチを当てた上で、ここまで説明してきたテンプレート…に少し手を加えたもの…をCMakeLists.txt
からadd_subdirectory()
で追加しています。パッチファイルを見ると分かりますが、基本的にはここまでで説明してきたfind_library()
の追加などの変更を加えたものです。対象のプラグインのビルドにStandaloneが含まれていなかったのを追加していますが、これも前述の通りAndroidではJUCE本家でサポートされているフォーマットが他に無く、これをshared libraryとして参照する必要があるためです。
とはいえ、この移植は自作のAndroid用プラグインフレームワーク向けのプラグイン化したものであり、そのために必要なJUCEモジュールの追加などもこのパッチの中で行っています。
CMakeで作られたOSSのJUCEアプリケーションはまだそんなに無いのですが、他のアプリケーションもこんな感じで移植できるのではないかと期待されます。