🦓

Play Feature Deliveryを使ったオンデマンド配信

2024/12/25に公開

概要

この記事では、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にコードも公開しています。
https://github.com/algo3030/dynamic-feature

導入方法

導入は、ざっくりと以下の手順で行います。

  1. 追加配信の対象モジュールである、dynamic feature module(以下、DFM)[2]を定義する。
  2. DFMの作成後、appモジュールのビルドスクリプトの記述を追加し、app側からDFMを認識させる。
  3. appモジュールからDFMを動的に解決し、各種機能を呼び出す。

以下のセクションで、具体的な手順について見ていきましょう。

DFM側の設定

作成

DFMは、Android Studioのモジュール作成ダイアログから簡単に作成することができます。左側のペインからDynamic Featureを選択し、進んでいくと作成できます。

ビルドスクリプト

DFMはただのモジュールではなく、com.android.dynamic-featureプラグインが適用された、appモジュールに依存するモジュールでなければいけません。appモジュールへ依存することで、自動的にandroidの設定を引き継がれることとなります。そのため、署名設定、minifyEnabledプロパティ、versionCodeversionNameプロパティはDFMのビルドスクリプトから除外するべきです[3]

今回はアセットのみでコードを含めないため、org.jetbrains.kotlin.androidプラグインやテスト用の依存関係などを削除しています。

build.gradle.kts
plugins {
    alias(libs.plugins.android.dynamic.feature)
}
android {
    namespace = "com.example.feature.assets"
    compileSdk = 35

    defaultConfig{
        minSdk = 24
    }
}

dependencies {
    implementation(project(":app"))
}

AndroidManifest

AndroidManifest.xml
<?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モジュールのタイトルを保存しておくのが慣例になっているようです。

appモジュールのstrings.xml
<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を認識させます。

build.gradle.kts
// 中略

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を受け取り、適切なロード・エラー・完了処理を行うことになるでしょう。

その他

その他にもインストールのキャンセルのための関数なども用意されており、以下のリファレンスから確認できます。
https://developer.android.com/reference/com/google/android/play/core/ktx/package-summary

SplitInstallSessionStateによるインストールの追跡

requestProgressFlowでは、SplitInstallSessionStateを受け取ることができ、これによりDFMを追跡できます。複数のDFMをインストールする際も同じFlowに流れてくるため、コードではfilterを使って、moduleNameから拾いたいDFMの進捗を選んでいます。また、具体的な進捗についてはstatusプロパティのInt型の値から得ることが出来ます。これはSplitInstallSessionStatusの定数値に対応しており、このstatusに合わせて適切な進捗処理をすれば良いでしょう。
https://developer.android.com/reference/com/google/android/play/core/splitinstall/model/SplitInstallSessionStatus

MainActivityから抜粋
.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メソッドをApplicationattachBaseContextメソッドをオーバーライドして実行する必要があります。

DynamicModuleApplication.kt
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の実行パスに加えましょう。
https://github.com/google/bundletool/releases

その後、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を扱う上での大まかな流れについてまとめることができたのではないかと思います。この記事が皆さんの役に立てたなら幸いです。

参考文献

https://developer.android.com/guide/playcore/feature-delivery
https://medium.com/androiddevelopers/configuring-your-app-for-play-feature-delivery-f15a93856a0d
https://medium.com/julotech/implementing-dynamic-feature-modules-in-our-android-app-e9c7aa5db3e8
https://github.com/android/app-bundle-samples/tree/af4c60d440aa7e34fa90f36455f440753db3eb92
https://developer.android.com/reference/com/google/android/play/core/ktx/package-summary

脚注
  1. App Bundles: Configuring your app for Play Feature Delivery - MAD Skills 00:57~ ↩︎

  2. この呼称は公式に定義されたものではないですが、Android Developersのブログでそう呼ばれていたので採用しました ↩︎

  3. https://developer.android.com/guide/playcore/feature-delivery#what_to_omit ↩︎

Discussion