Play Feature Deliveryを使ったオンデマンド配信
概要
この記事では、Play Feature Deliveryを使った動的な機能モジュールの配信と、ベースアプリへの統合方法について、簡単なサンプルを用いて示します。
Play Feature Deliveryとは?
Play Feature Delivery(以降、PFDと略す)はAndroid App Bundle(AAB)を用いた、Google Playでアプリの機能を追加配信するための仕組みです。導入することで以下のメリットが得られます。
- アプリの初回インストール時のサイズを小さく抑えることによる、ダウンロード時間の短縮。
- ユーザーは使う機能に応じて必要な部分だけをインストールするため、デバイスのストレージを効率的に利用できる。
Play Storeでは、アプリの最初のダウンロードサイズを3MB小さくすることで、インストール数が約1%増加することが確認されているそうです[1]。あまり使わない機能や、言語ごとのリソースなどをFeature Deliveryで追加配信するようにしておけば、基本APKのサイズが小さくなり、ユーザー体験を向上させることができるでしょう。
Android Developers Blog - I/O 2018: Everything new in the Google Play Console から
Play Asset Deliveryとの比較
Play Asset Delivery(PAD)も同様に、アプリデータを追加配信する機能です。こちらはアプリのアセット(main/assets
以下)をアセットパックという形で追加配信することができますが、Feature Deliveryとは異なり、実行コードは含まれません。また、サイズ制限にも違いがあり、アセットパックの方が大容量のデータを配信できます。
Play Consoleヘルプ - アプリのサイズを最適化して Google Play のアプリのサイズ上限内に収める から
Asset Deliveryは主にゲームアプリで使用されているようで、大容量の追加データ配信システムが必要ならAsset Delivery、小・中規模の機能やデータ配信システムが必要ならFeature Deliveryという使い分けになりそうです。
今回作るサンプル
今回は試しに、DFMをオンデマンドでインストールし、アセットの参照のみを行う単純なサンプルを作ってみました。ここでは、"Hello Feature Delivery!"の文字列を追加でインストールされるモジュールのsrc/main/assets
内から取得しています。
一応、githubにコードも公開しています。
導入方法
導入は、ざっくりと以下の手順で行います。
- 追加配信の対象モジュールである、dynamic feature module(以下、DFM)[2]を定義する。
- DFMの作成後、
app
モジュールのビルドスクリプトの記述を追加し、app側からDFMを認識させる。 -
app
モジュールからDFMを動的に解決し、各種機能を呼び出す。
以下のセクションで、具体的な手順について見ていきましょう。
DFM側の設定
作成
DFMは、Android Studioのモジュール作成ダイアログから簡単に作成することができます。左側のペインからDynamic Featureを選択し、進んでいくと作成できます。
ビルドスクリプト
DFMはただのモジュールではなく、com.android.dynamic-feature
プラグインが適用された、appモジュールに依存するモジュールでなければいけません。appモジュールへ依存することで、自動的にandroidの設定を引き継がれることとなります。そのため、署名設定、minifyEnabled
プロパティ、versionCode
とversionName
プロパティはDFMのビルドスクリプトから除外するべきです[3]。
今回はアセットのみでコードを含めないため、org.jetbrains.kotlin.android
プラグインやテスト用の依存関係などを削除しています。
plugins {
alias(libs.plugins.android.dynamic.feature)
}
android {
namespace = "com.example.feature.assets"
compileSdk = 35
defaultConfig{
minSdk = 24
}
}
dependencies {
implementation(project(":app"))
}
AndroidManifest
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:dist="http://schemas.android.com/apk/distribution">
<!-- 追加 -->
<application android:hasCode="false" />
<dist:module
dist:instant="false"
dist:title="@string/title_assets">
<dist:delivery>
<dist:on-demand />
</dist:delivery>
<dist:fusing dist:include="true" />
</dist:module>
</manifest>
ここでは、DFMの設定を記述します。ここの設定項目については、詳しくはこのページを参照してください。
dist:title="@string/title_assets"
は、どこのリソースから来たものなのだろうと思われるでしょうが、これはDFMをAndroid Studioから作成する際に自動的にappモジュールに追加されたものです。Feature Deliveryでは、appモジュール側のリソースにDFMモジュールのタイトルを保存しておくのが慣例になっているようです。
<resources>
<string name="app_name">dynamic-feature</string>
<string name="title_assets">Assets</string>
</resources>
また、今回はオンデマンド配信のテストなので、<dist:on-demand />
と設定しています。オンデマンド配信以外にも、<dist:install-time />
と設定すればBaseAPKと同時に即時インストールを指定でき、この場合に国やデバイスごとに条件付きで配信することもできます(https://developer.android.com/guide/playcore/feature-delivery/conditional )。
また、追加事項として、今回はコードを含まないので<application android:hasCode="false" />
を記述しました。この記述によってDFMにコードが含まれないことを示し、コンパイラにdexファイルの作成が必要ないことを知らせます。
アセットの追加
今回は、src/main/assets
以下に、asset.txt
というテキストファイルを定義し、"Hello Feature Delivery!"という内容を書き込みました。このファイルをappモジュール側から読み込ませることになります。
appモジュール側の設定
appモジュールにはcom.google.android.play:feature-delivery
ライブラリと、そのKotlin用の拡張であるcom.google.android.play:feature-delivery-ktx
ライブラリを追加しておきましょう。
その後、作成したDFM名をandroid
ブロックのdynamicFeatures: MutableSet<String>
プロパティに与えることで、appモジュールにDFMを認識させます。
// 中略
android {
// 中略
dynamicFeatures += setOf(":feature:assets")
}
dependencies {
// 中略
implementation(libs.feature.delivery)
implementation(libs.feature.delivery.ktx)
}
MainActivity
モジュール間の関係を定義できたので、あとはDFMをインストールし、アセットを読み込む処理を書けば完成です。以下に、MainActivity全文を、その後にコードの説明を示します。
MainActivity全文
package com.example.dynamic_feature
import android.content.Context
import android.os.Bundle
import android.util.Log
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ElevatedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.example.dynamic_feature.ui.theme.DynamicfeatureTheme
import com.google.android.play.core.ktx.moduleNames
import com.google.android.play.core.ktx.requestInstall
import com.google.android.play.core.ktx.requestProgressFlow
import com.google.android.play.core.splitcompat.SplitCompat
import com.google.android.play.core.splitinstall.SplitInstallManager
import com.google.android.play.core.splitinstall.SplitInstallManagerFactory
import com.google.android.play.core.splitinstall.model.SplitInstallErrorCode
import com.google.android.play.core.splitinstall.model.SplitInstallSessionStatus
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import java.io.BufferedReader
import java.io.InputStreamReader
const val TAG = "MainActivity"
sealed interface FeatureState<out T, out E> {
data object None : FeatureState<Nothing, Nothing>
data object Loading : FeatureState<Nothing, Nothing>
data class Success<T>(val data: T) : FeatureState<T, Nothing>
data class Error<E>(val err: E) : FeatureState<Nothing, E>
}
class MainActivity : ComponentActivity() {
private val assetModuleName = "assets"
private lateinit var splitInstallManager: SplitInstallManager
private val coroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val featureState by lazy {
splitInstallManager.requestProgressFlow()
.filter { state ->
state.moduleNames.contains(assetModuleName)
}
.map { state ->
Log.d(TAG, "status updated: ${state.status}")
when (state.status) {
SplitInstallSessionStatus.PENDING,
SplitInstallSessionStatus.DOWNLOADING,
SplitInstallSessionStatus.DOWNLOADED,
SplitInstallSessionStatus.INSTALLING -> FeatureState.Loading
SplitInstallSessionStatus.INSTALLED -> {
SplitCompat.installActivity(this)
FeatureState.Success(readAssetText("asset.txt"))
}
SplitInstallSessionStatus.FAILED -> {
Log.w(TAG, "install failed: ${state.errorCode()}")
FeatureState.Error(state.errorCode())
}
else -> FeatureState.None
}
}.catch {
Log.e(TAG, "error: $it")
}
.stateIn(
scope = coroutineScope,
started = SharingStarted.WhileSubscribed(),
initialValue = getCurrentState()
)
}
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
SplitCompat.installActivity(this)
}
private fun getCurrentState(): FeatureState<String, Nothing> {
if (splitInstallManager.installedModules.contains(assetModuleName)) {
return FeatureState.Success(readAssetText("asset.txt"))
}
return FeatureState.None
}
private fun readAssetText(fileName: String): String {
val inputStream = assets.open(fileName)
val bufferedReader = BufferedReader(InputStreamReader(inputStream))
return bufferedReader.use { it.readText() }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
splitInstallManager = SplitInstallManagerFactory.create(this)
Log.d(TAG, "installed modules: ${splitInstallManager.installedModules}")
enableEdgeToEdge()
setContent {
DynamicfeatureTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
DynamicFeatureScreen(
state = featureState.collectAsStateWithLifecycle().value,
onClickGetFeature = {
coroutineScope.launch {
splitInstallManager.requestInstall(listOf(assetModuleName))
}
}
)
}
}
}
}
}
}
@Composable
fun DynamicFeatureScreen(
state: FeatureState<String, Int>,
onClickGetFeature: () -> Unit,
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxSize()
)
{
Text(
text = when (state) {
FeatureState.None -> "None"
FeatureState.Loading -> "Loading..."
is FeatureState.Success -> "Success!\n${state.data}"
is FeatureState.Error -> {
val reason = when (state.err) {
// SplitInstallErrorCode.NO_ERROR ->
SplitInstallErrorCode.ACCESS_DENIED -> "ACCESS_DENIED"
SplitInstallErrorCode.ACTIVE_SESSIONS_LIMIT_EXCEEDED -> "ACTIVE_SESSIONS_LIMIT_EXCEEDED"
SplitInstallErrorCode.API_NOT_AVAILABLE -> "API_NOT_AVAILABLE"
SplitInstallErrorCode.APP_NOT_OWNED -> "APP_NOT_OWNED"
SplitInstallErrorCode.NETWORK_ERROR -> "NETWORK_ERROR"
SplitInstallErrorCode.INCOMPATIBLE_WITH_EXISTING_SESSION -> "INCOMPATIBLE_WITH_EXISTING_SESSION"
SplitInstallErrorCode.INTERNAL_ERROR -> "INTERNAL_ERROR"
SplitInstallErrorCode.INVALID_REQUEST -> "INVALID_REQUEST"
SplitInstallErrorCode.MODULE_UNAVAILABLE -> "MODULE_UNAVAILABLE"
SplitInstallErrorCode.INSUFFICIENT_STORAGE -> "INSUFFICIENT_STORAGE"
else -> "UNKNOWN"
}
"error: $reason"
}
}
)
ElevatedButton(
onClick = onClickGetFeature,
modifier = Modifier.padding(top = 8.dp)
) {
Text(text = "Get Feature Module")
}
}
}
SplitInstallManager
によるDFMの管理
DFMのインストールにはSplitInstallManager
クラスを利用します。SplitInstallManager
クラス自体はJavaで書かれており、複数のメソッドが定義されていますが、前述したcom.google.android.play:feature-delivery-ktx
ライブラリによってKotlin向けにいい感じの拡張関数が生えているので、こちらを使うほうが良いでしょう。
SplitInstallManager
の拡張関数をいくつか紹介しておきます。
requestInstall
suspend fun SplitInstallManager.requestInstall(
modules: List<String> = listOf(),
languages: List<String> = listOf()
): Int
即時、指定したDFMのインストールを開始します。modules
引数にはモジュール名を指定し、language
引数は言語コード名を指定し、インストールするDFMをまとめて指定します。
requestDeferredInstall
, requestDeferredLanguageInstall
suspend fun SplitInstallManager.requestDeferredInstall(
moduleNames: List<String>
): Unit
suspend fun SplitInstallManager.requestDeferredLanguageInstall(
languages: List<Locale>
): Unit
指定したモジュールのDFMの延期インストールを開始します。前者はモジュール名、後者は言語コード名を引数に取ります。即時インストールではDFMのインストールの進行状況をトラッキングすることができますが、延期インストールではプログレスを受け取ることができないことに注意です。
requestDeferredUninstall
, requestDeferredLanguageUninstall
suspend fun SplitInstallManager.requestDeferredUninstall(
moduleNames: List<String>
): Unit
suspend fun SplitInstallManager.requestDeferredLanguageUninstall(
languages: List<Locale>
): Unit
モジュールを即時アンインストールする関数は定義されておらず、すべてDeferred
と付くものになっています。おそらく、アンインストールを即時行えてしまったら、現在アプリがそのモジュールに依存している場合にクラッシュしてしまうからだと考えられます。
requestProgressFlow
fun SplitInstallManager.requestProgressFlow(): Flow<SplitInstallSessionState>
SplitInstallSessionState
を受け取るFlow
を返してくれます。アプリでこのFlowを受け取り、適切なロード・エラー・完了処理を行うことになるでしょう。
その他
その他にもインストールのキャンセルのための関数なども用意されており、以下のリファレンスから確認できます。
SplitInstallSessionState
によるインストールの追跡
requestProgressFlow
では、SplitInstallSessionState
を受け取ることができ、これによりDFMを追跡できます。複数のDFMをインストールする際も同じFlowに流れてくるため、コードではfilter
を使って、moduleNameから拾いたいDFMの進捗を選んでいます。また、具体的な進捗についてはstatus
プロパティのInt型の値から得ることが出来ます。これはSplitInstallSessionStatus
の定数値に対応しており、このstatus
に合わせて適切な進捗処理をすれば良いでしょう。
.filter { state ->
state.moduleNames.contains(assetModuleName)
}
.map { state ->
Log.d(TAG, "status updated: ${state.status}")
when (state.status) {
SplitInstallSessionStatus.PENDING,
SplitInstallSessionStatus.DOWNLOADING,
SplitInstallSessionStatus.DOWNLOADED,
SplitInstallSessionStatus.INSTALLING -> FeatureState.Loading
SplitInstallSessionStatus.INSTALLED -> {
SplitCompat.installActivity(this)
FeatureState.Success(readAssetText("asset.txt"))
}
SplitInstallSessionStatus.FAILED -> {
Log.w(TAG, "install failed: ${state.errorCode()}")
FeatureState.Error(state.errorCode())
}
else -> FeatureState.None
}
}
SplitCompatの対応
ただDFMをインストールするだけでは、appモジュールから利用することはできません。SplitCompat
クラスを利用します。SplitCompat
には以下の2つのメソッドが定義されています。
public static boolean install (Context context)
public static boolean installActivity (Context context)
まず、install
メソッドをApplication
のattachBaseContext
メソッドをオーバーライドして実行する必要があります。
class DynamicFeatureApplication: Application() {
override fun attachBaseContext(base: Context) {
super.attachBaseContext(base)
SplitCompat.install(this)
}
}
その後、適宜タイミングでActivity側でinstallActivity
を呼び出し、DFMのインストールを反映させればOKです。今回は、SplitInstallSessionStatus.INSTALLED
を受け取った際に呼び出し、DFMのインストールを反映させています。
ローカルでテスト
ここまででコード自体は完成ですが、PFD自体がPlay Store依存の機能なので、普通に実行するとオンデマンドではなく全てのDFMが入ったAPKがビルドされてしまいます。Play Consoleを利用してテストする方法もありますが、bundletoolを使えばもっと簡単にローカルでテストできます(いくつか制限はありますが)。
以下のサイトからbundletoolの最新バージョンをインストールし、OSの実行パスに加えましょう。
その後、APKではなくAABでビルドします。
次に、先ほどインストールしたbundletoolを使います。以下のコマンドを続けて実行してください。
bundletool build-apks --bundle=app/build/outputs/bundle/debug/app-debug.aab --output=output.apks --local-testing
bundletool install-apks --apks=output.apks
これで、端末にAPKが入り、ローカルでオンデマンド配信のテストができるはずです!🎉
おわりに
今回は、DFMをオンデマンドでインストールし、アセットの参照まで行ってみましたが、いかがだったでしょうか。単純な処理ではありますが、思ったより把握しなければいけないことが多く大変でした...。
実際のプロダクトではアセットだけでなくクラスを動的にロードしなければいけなかったり、条件付き配信であったり、またDIモジュールとの統合なども考えなければいけないでしょうから、さらに複雑化していきますが、ひとまずPFDを扱う上での大まかな流れについてまとめることができたのではないかと思います。この記事が皆さんの役に立てたなら幸いです。
参考文献
-
App Bundles: Configuring your app for Play Feature Delivery - MAD Skills 00:57~ ↩︎
-
この呼称は公式に定義されたものではないですが、Android Developersのブログでそう呼ばれていたので採用しました ↩︎
-
https://developer.android.com/guide/playcore/feature-delivery#what_to_omit ↩︎
Discussion