フューチャー技術ブログ

Androidのビルドバリアントをイチから理解する

はじめに

こんにちは、フューチャー2年目の松井です。

昨今のコロナ事情の中、「なかなか外出できない…。そうだ、Androidアプリを作ろう。」となるエンジニアの方は多いのではないでしょうか?アプリを作っていると、ソースコードを分けるまではいかなくとも、微妙なバージョンの違いを表現したい場面に度々出くわします。例えば、無料版と有料版を管理したい、テスト環境用のアプリを分離したい、などです。

今回はそんな場面での強い味方、ビルドバリアントについて具体的なコードを交えて解説していきます!

お時間があれば、過去のAndroidに関する記事(Firebase CrashlyticsでAndroidアプリのエラーログをさくっと収集する)もぜひご覧ください。

使用した環境

  • Android Studio 3.6
  • Android端末 (HUAWEI P9 lite)
  • Androidバージョン 7.0
  • gradle plugin 3.5.3

そもそものビルドの仕組み

Androidアプリを実行するには、リソースとソースコードをコンパイルしてAPKと呼ばれるパッケージを作成します。

その際にGradleを用いることでビルドプロセスの自動化、および柔軟なカスタムビルド設定ができるようになっています。
※以下の図は公式ドキュメントからの引用です。一連のソース群からAPKが作られるフローを示しています。

ビルド設定用のGradleファイル build.gradle はプロジェクトレベル、モジュールレベルで2種類存在しています。

プロジェクトレベルでは、プロジェクト配下の全てのモジュールに適用される、アプリのビルドに必要なリポジトリや依存関係を定義します。モジュールレベルでは、build.gradleの所属するモジュールに適用される依存関係や、カスタムビルドの設定を記述します。今回の主役であるビルドバリアントの設定もこちらで実施します。

複数モジュールにプロジェクトを分割するマルチモジュール構成の場合、水平方向・垂直方向の2通りの分割が考えられますが、軽く調べたところ、機能やレイヤーで分ける垂直方向の分割の実例が多いようです。

各モジュールがビルドファイルを持つことでモジュールごとのビルドやテストが可能なため、マルチモジュール構成では開発チームで役割分担しやすいなどのメリットがあります。

今回はシングルモジュール構成で話を進めます。
※以下の図は公式ドキュメントからの引用です。プロジェクトレベル、モジュールレベルごとにbuild.gradleが存在することがわかります。

ビルドバリアントとは

公式ドキュメントを読むと以下のように書かれています。

各ビルド バリアントは、ビルド可能なさまざまなバージョンのアプリを表しています。

ビルドバリアントを指定すると、ソースコードに変更を加えることなく1つのモジュールから無料版/有料版、本番環境用/開発環境用などを切り替えてアプリをビルドできるようになります。一般的なウェブアプリケーションでいう環境変数に近いかもしれません。この存在がなければ、いちいち別のソースを書き換えてビルドしなければならず、開発やテストに不都合となることが想像できます。

このビルドバリアントはさらにビルドタイププロダクトフレーバーの組み合わせで定義されています。

ビルドタイプは、本番用or開発用など、開発のライフサイクルに応じてバージョンを切り替えるために設定します。Android Studioで新規モジュールを作成する際は、releaseとdebugのビルドタイプが自動で作成されます。さらに独自のビルドタイプを設定して管理することも可能です。ビルド時には最低一つのビルドタイプを指定する必要があります。

プロダクトフレーバーは、ビルドタイプに加えてオプションでさらに柔軟なバージョン管理が必要な場合に使用します。

有料版or無料版の制御などに加え、公式ドキュメントでは、クライアントのAndroidSDKの最低バージョンに応じてプロダクトフレーバーを切り替える、というサンプルも記載されています。基本的な記述はビルドタイプと同様ですが、flavorDimensionsを指定する必要があります。後述しますが、このflavorDimensionsを用いることでさらに細かなバージョン管理を実現できます。最初に述べましたが、ビルドバリアントはビルドタイプとプロダクトフレーバーの組み合わせです。

releaseとdebugの2ビルドタイプ、有料版と無料版の2プロダクトフレーバーをもつモジュールでは、2×2の計4通りのビルドバリアントを利用できることを意味します。

ビルドバリアント ビルドタイプ プロダクトフレーバー
#1 release 有料版
#2 release 無料版
#3 debug 有料版
#4 debug 無料版

具体的な使い方とアプリの挙動は、実際にソースを追いAndroid Studioからビルドして確認していきます。

実際にビルドしてみる

さて、実際にコードとアプリの挙動を見ながらビルドバリアントへの理解を深めていきます。

今回は、新規作成したtestAppというモジュール配下に、メイン画面にヘッダーとテキストを表示するだけのシンプルなアプリを作成し、以下の3パターンについて確認していきます。
① ビルドタイプ2通り
② ビルドタイプ2通り、プロダクトフレーバー2通り(flavorDimensionsA: 2通り))
③ ビルドタイプ2通り、プロダクトフレーバー5通り(flavorDimensionsA: 2通り、flavorDimensionsB: 3通り))

アプリの初期画面は以下のようになっています。

① ビルドタイプ2通り

一番シンプルなパターンです。

releaseとdebugの2通りのビルドタイプを設定します。モジュール配下のbuild.gradleファイルは抜粋すると以下のようになっています。

testApp/build.gradle
apply plugin: 'com.android.application'

android {
// 省略...

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}

}
// 省略...

applicationIdSuffixは、applicationIDの末尾に指定した文字列を追加してくれるパラメータです。

AndroidはapplicationIDによって端末上でアプリが同一かどうかを識別しています。releaseとdebugでapplicationIDを変えることで、1つの端末上で2つのバージョンのアプリを共存させることができます。同一のapplcationIDであれば上書きされます。ユーザーの目には触れない点ですが、開発者側からするといちいちビルドしなくても端末上の操作だけで両方のバージョンを確認できるので、大切な設定になってきます。

そのほかのパラメータは、apkの軽量化、難読化、署名に関わるものですが、今回のスコープではないので割愛します。

さて、この設定でAndroid Studioでビルドしてみると、無事2つのビルドバリアント (release, debug) が作成されていことがわかります。プロダクトフレーバーは作成していないので、ビルドバリアント = ビルドタイプ となっています。

しかしこのままアプリを立ち上げてもなんら違いはありません。debugかreleaseかに応じてアプリの内容を切り替えるには、もう一手間必要になってきます。
結論から言うと、ビルドタイプの名称をもつディレクトリを作成し、mainディレクトリと構成の齟齬が無いように差分のファイルを配置することが必要です。

文章で書いてもよくわからないので、具体的に見ていきます。

まず、こちらが現在のモジュールtestAppのディレクトリ構成です。直下には先ほど編集したモジュールレベルのbuild.gradleがいます。

アプリのソースコードはsrc/main配下に格納されています。Main画面を表示するMainActivity.javaはJava配下へ、画面のレイアウトを定義するactivity_main.xmlはres配下へ格納されています。

このsrc配下に、mainと同じ階層で、ビルドタイプの名前をもつディレクトリを作成し、ファイルを配置します。そうすることで、ビルドタイプのディレクトリ配下にmainと同じファイルが存在すれば、そちらが優先して実行されるようになります。

以下がビルドタイプdebugのディレクトリ配下に作成されたactivity_main.xmlです。mainと同じ階層に配置されていることが確認できます。あとは、この新規作成したactivity_main.xmlを編集し、表示内容を変更します。

main/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
// 省略...

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!" // ここを書き換える
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>
debug/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
// 省略...

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="This is debug!" // ここを書き換えた
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

すると、ビルドバリアント(=ビルドタイプ)debugでビルドした際に、表示内容が変化したことが確認できます。 releaseは対応するディレクトリとソースを作成していないので、main配下のactivity_main.xmlが呼び出されます。

debugでビルド releaseでビルド
debugでビルド releaseでビルド

ここまでのまとめです。

ビルドバリアント ビルドタイプ プロダクトフレーバー
debug debug -
release release -

② ビルドタイプ2通り、プロダクトフレーバー2通り(flavorDimensionsA: 2通り)

次は、プロダクトフレーバーも組み合わせてビルドバリアントを構成してみます。
有料版と無料版でバージョンを切り替えられるようプロダクトフレーバーを設定してみましょう。
build.gradleは抜粋すると以下のようになっています。

build.gradle
apply plugin: 'com.android.application'

android {
// 省略...

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}
flavorDimensions "plan"
productFlavors {
paid {
dimension "plan"
}
free {
dimension "plan"
}
}

}
// 省略...

基本形はビルドタイプと同様ですが、flavorDimensionsが定義されているのが異なる点です。さらに各プロダクトフレーバーの要素がどのflavorDimensionsに属しているのかを、dimensionパラメータを用いて明示してやります。

flavorDimensionsはその名の通りプロダクトフレーバーの次元を管理するもので、同一次元の要素から一つずつ選択して、全体のプロダクトフレーバーを構成する、という使い方をします。
これは③の実例をみるともう少しわかりやすくなると思います。

このファイルをビルドすると、以下のようにビルドタイプ × プロダクトフレーバーの組み合わせで4パターンのビルドバリアントが作成されていることが確認できます。

ビルドバリアントの命名規則は、キャメルケースでプロダクトフレーバー + ビルドタイプとなります。

プロダクトフレーバーにおいても、ビルドタイプと同様に、プロダクトフレーバーの名称をもつディレクトリを作成し、mainディレクトリと構成の齟齬が無いように差分のファイルを配置することでソースとビルドバリアントを連携させることが可能です。

さて、先ほどと同様に、ディレクトリを切ってプロダクトフレーバー特有のソースを格納します。無料版のみヘッダータイトルに「free」と入るよう実装してみましょう。free/value/strings.xmlを作成し、タイトルの末尾に「free」を追記します。

main/value/strings.xml
<resources>
<string name="app_name">test</string> //ここを書き換える
</resources>
free/value/strings.xml
<resources>
<string name="app_name">test[free]</string> //ここを書き換えた
</resources>

freeDebugおよびfreeReleaseでビルドすると、タイトルに「free」がくっついていることが確認できました。
一方、paid側のビルドバリアントはディレクトリを切っていないため、main配下のリソースが呼ばれています。

freeDebugでビルド paidDebugでビルド paidDebugでビルド paidReleaseビルド
freeDebugでビルド freeReleaseでビルド paidDebugでビルド paidReleaseビルド

ここで、こんな疑問が生じるかもしれません。

ビルドタイプ、プロダクトフレーバーで同一のファイルを編集したらどのように表示されるのか?

検証してみましょう。
freeディレクトリ配下でも、activity_main.xmlを作成して編集します。これは先ほどdebug配下でも編集していたファイルです。

free/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
// 省略...

<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="This is free!" // ここを書き換えた
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

そしてfreeDebugのビルドバリアントでビルドすると…

「This is debug!」つまりビルドタイプ配下のファイルが優先されました。

実は同じソースについての優先度はAndroidで定義されておりビルドタイプ -> プロダクトフレーバー -> main のディレクトリ順でファイルが優先されます。

なるほど、ビルドタイプが優先されることはわかった、ただ、どうしても freeかつdebugの時には「This is free!」と表示させたい。そんな需要もあるかもしれません。
そんな場合は、freeDebugというビルドバリアント名称のディレクトリを作成することで、ビルドタイプに優先させることができます。
試しに先ほどのfree配下のactivity_main.xmlファイルをfreeDebugディレクトリ配下に移動してみましょう。ディレクトリ構成はこのようになります。

そして改めてfreeDebugでビルドすると…
無事に「This is free!」と表示され、ビルドタイプに優先することが確認できました。

改めてソースの優先順位は、
ビルドバリアント -> ビルドタイプ -> プロダクトフレーバー -> main
のディレクトリ順となります。

ここまでのまとめです。

ビルドバリアント ビルドタイプ プロダクトフレーバー
freeDebug debug free
freeRelease release free
paidDebug debug paid
paidRelease release paid

③ ビルドタイプ2通り、プロダクトフレーバー5通り(flavorDimensionsA: 2通り、flavorDimensionsB: 3通り)

最後のパターン、プロダクトフレーバーのdimensionが複数ある場合です。
そんな複雑な構成は実務で生じるのか、と疑問に思うかもしれませんが、今回この記事を書くに至った理由が、実際のプロジェクトの現場でこの構成のビルドが必要になったためでした。

当該のプロジェクトではプロダクトフレーバーを用いて開発、ステージング、本番環境ごとのアプリを作成していました。
そこに、アプリで扱う商品のブランドごとにバージョン管理したいと言う要件が生じたため、さらにブランドのdimensionを追加してバージョン管理していました。

今回の検証では、color dimensionを追加して、色ごとにバージョン管理できるように設定してみます。
build.gradleファイルは抜粋すると以下のようになっています。

testApp/build.gradle
apply plugin: 'com.android.application'

android {
//省略...

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}

flavorDimensions "plan", "color"
productFlavors {
paid {
dimension "plan"
}
free {
dimension "plan"
}
purple {
dimension "color"
}
green {
dimension "color"
}
red {
dimension "color"
}
}

}
// 省略...

このようにflavorDimensionsはカンマで区切ることで複数定義できます。
さて、これをビルドすると… だんだん複雑になってきましたが、ビルドタイプ × プロダクトフレーバー(plan) × プロダクトフレーバー(color) の組み合わせで12通りのビルドバリアントが作成されました。

ここまでくると、flavorDimensionsのいう次元の意味が見えてきたのではないかと思います。各dimensionから組み合わせで1つずつ選択して、プロダクトフレーバーが構成されています。

ビルドバリアントの名称は、キャメルケースでプロダクトフレーバー(dimensionA) + プロダクトフレーバー(dimensionB) + ビルドタイプ となっています。

では早速プロダクトフレーバーごとのディレクトリを作成していきましょう。 今回はgreen, redのバージョンのres/values/color.xmlを編集して、ヘッダーの色を変更します。

ディレクトリ構成はこのようになっています。

main/value/colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#6200EE</color> //ここを書き換える
<color name="colorPrimaryDark">#3700B3</color>
<color name="colorAccent">#03DAC5</color>
</resources>
green/value/colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#4CAF50</color> //ここを書き換えた to Green
<color name="colorPrimaryDark">#3700B3</color>
<color name="colorAccent">#03DAC5</color>
</resources>
red/value/colors.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPrimary">#F44336</color> //ここを書き換えた to Red
<color name="colorPrimaryDark">#3700B3</color>
<color name="colorAccent">#03DAC5</color>
</resources>

freeGreenDebug、freeRedDebug、paidGreenDebug、paidRedDebug、でビルドすることで、元の紫色のヘッダーがそれぞれ緑、赤になることが確認できました。
purpleについては、ディレクトリを作成していないためにmainのソースが呼ばれており、ヘッダーは元の色のままです。
※Releaseも同様のため割愛します。

freeGreenDebugでビルド freeRedDebugでビルド paidGreenDebugでビルド freeRedDebugでビルド paidPurpleDebugでビルド
freeGreenDebugでビルド freeRedDebugでビルド freePurpleDebugでビルド paidGreenDebugでビルド paidRedDebugでビルド paidPurpleDebugでビルド

さて、ここでも先ほどと同様の疑問が生じます。

異なるflavorDimensionsで同一のファイルを編集したらどのように表示されるのか?

先ほどからの類推で何となく検討はつきますが、検証していきましょう。
colordimensionのプロダクトフレーバーであるgreenディレクトリ配下で、strings.xmlを編集します。
これは先ほどplandimensionのプロダクトフレーバーであるfreeディレクトリ配下でも編集していた、ヘッダータイトルを決めていたファイルです。

green/value/strings.xml
<resources>
<string name="app_name">test[green]</string> //ここを書き換えた
</resources>

そしてfreeGreenDebugのビルドバリアントでビルドすると…

「test[free]」つまりfree (1つ目のdimension)ディレクトリ配下のファイルが優先されました。

プロダクトフレーバーでは、記述したdimensionの順でディレクトリの優先度が決まることが確認できました。仮にflavorDimensions "color", "plan" のように記載するdimensionの順序を逆転させれば、colorディレクトリ配下のファイルが優先されるようになります。先ほどと繰り返しになるので検証は割愛しますが、プロダクトフレーバー全体のディレクトリfreeGreenなどを作成することで、各dimensionのディレクトリに優先させることができます。

改めてリソースの優先順位は、
ビルドバリアント -> ビルドタイプ -> プロダクトフレーバー(全体) -> プロダクトフレーバー(個々のdimension) -> main
のディレクトリ順となります。

ここまでのまとめです。

ビルドバリアント ビルドタイプ プロダクトフレーバー dimensionA(plan) dimensionB(color)
freeGreenDebug debug freeGreen free green
freeGreenRelease release freeGreen free green
freeRedDebug debug freeRed free red
freeRedRelease release freeRed free red
freePurpleDebug debug freePurple free purple
freePurpleRelease release freePurple free purple
paidGreenDebug debug paidGreen paid green
paidGreenRelease release paidGreen paid green
paidRedDebug debug paidRed paid red
paidRedRelease release paidRed paid red
paidPurpleDebug debug paidPurple paid purple
paidPurpleRelease release paidPurple paid purple

おわりに

今回はAndroidのビルドバリアントについて実コードを交えながら解説しました。
ビルドバリアントの有用性、使い方について理解を深めていただけたなら幸いです。
最後に、参考として今回作成したtestAppモジュール配下のbuild.gradleの全体を載せておきます。

testApp/build.gradle
apply plugin: 'com.android.application'

android {
compileSdkVersion 29
buildToolsVersion "29.0.3"

defaultConfig {
applicationId "com.example.test"
minSdkVersion 24
targetSdkVersion 29
versionCode 1
versionName "1.0"

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}

buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
applicationIdSuffix ".debug"
debuggable true
}
}

flavorDimensions "plan", "color"
productFlavors {
paid {
dimension "plan"
}
free {
dimension "plan"
}
purple {
dimension "color"
}
green {
dimension "color"
}
red {
dimension "color"
}
}

}

dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])

implementation 'androidx.appcompat:appcompat:1.2.0'
implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
testImplementation 'junit:junit:4.12'
androidTestImplementation 'androidx.test.ext:junit:1.1.2'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0'
}

それではみなさま、良きAndroidライフを!

参考