Why use App Bundle
- Reduce APK download size (average size savings of ~35%): just build as App Bundle, no extra code/configuration required
- Dynamic Features Modules: allow less frequently used features (but large file size) to be downloaded on demand
- Instant App: run app instantly without install
Android App Bundle
A new upload format that includes all your app's compiled code and resources, but defers APK generation and signing to Google Play.
Dynamic Delivery
It is Google Play's new app serving model, and it uses your app bundle to generate and serve optimized APKs for each user's device configuration, so they download only the code and resources they need to run your app. You no longer have to build, sign, and manage multiple APKs, and users get smaller, more optimized downloads.
Dynamic feature modules
These are modules you can add to your app project and include them in your app bundle. Through Dynamic Delivery, your app can download and install dynamic features on demand.
Instant App
Google Play Instant enables native apps and games to launch on devices running Android 5.0 (API level 21) without being installed.
Prerequisite
- Android Studio 3.5
- Enroll in Google Play App Singing (Later)
Compressed download size restriction
While publishing with Android App Bundles helps your users install your app with the smallest download possible, compressed downloads are limited to 150 MB. That is, when a user downloads your app, the total size of the compressed APKs required to install your app (for example, the base APK + configuration APK(s)) must be no more than 150 MB. Any subsequent downloads, such as downloading a dynamic feature (and its configuration APK(s)) on demand, must also meet this compressed download size restriction.
Code Shrinking
In order to reduce module size, you might want to enable R8 Code Shrinking.
Edit module build.gradle
.
android {
...
buildTypes {
release {
// shrinkResources true
// ERROR: Resource shrinker cannot be used for multi-apk applications: https://issuetracker.google.com/issues/120517460
// minifyEnabled will enable R8 Code Shrinking
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'),
'proguard-rules.pro'
}
}
}
Enable Dynamic Delivery (on demand)
The functionality that works well as an on demand module are those that aren't needed by the majority of your users at install time.
You base module is your app
module.
To create a new Dynamic Feature Module
Android Studio -> File -> New Module
, selectDynamic Feature Module
- Base application module:
app
- Module name (recommended camelCase or lowercase):
quotemaker
- Package name:
com.luasoftware.quotemaker
- Minimum API level: match the base module
- Click
Next
forModule Download Options
- Module title: The platform uses this title to identify the module to users when, for example, confirming whether the user wants to download the module.
- Install-time inclusion: Do not include module at install-time (on-demand only)
- Fusing (include module at install time for pre-Lollipop devices): check
- Click
Finish
build.gradle
Edit build.gradle
of the new Dynamic Feature Module. Below is an example of minimum setup.
apply plugin: 'com.android.dynamic-feature'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
android {
compileSdkVersion 28
defaultConfig {
minSdkVersion 17
targetSdkVersion 28
// I assume the following is not required for dynamic feature module
// versionCode 1
// versionName "1.0"
vectorDrawables.useSupportLibrary = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_1_8
}
}
androidExtensions {
// https://kotlinlang.org/docs/tutorials/android-plugin.html#experimental-mode
experimental = true
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar'])
implementation project(':app')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "androidx.core:core-ktx:$ktx_version"
implementation "androidx.fragment:fragment-ktx:$ktx_fragment_version"
implementation "androidx.appcompat:appcompat:$appcompat_version"
implementation "com.google.android.material:material:$material_version"
implementation "android.arch.lifecycle:extensions:$lifecycle_version"
implementation "androidx.constraintlayout:constraintlayout:$constraint_layout_version"
}
NOTE: Sharing variable between modules in Android Studio Gradle
Create Activity in Dynamic Feature Module
You can use Android Studio -> New -> Activity -> Basic Activity
, which will create the following files.
QuoteMakerXActivity.kt
import com.luasoftware.quotemaker.R // R from current moduleimport com.luasoftware.nuanxindan.R as appR // R from app moduleclass QuoteMakerXActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.quotemakerx) setSupportActionBar(toolbar) title = getString(R.string.title_activity_quote_makerx) } override fun attachBaseContext(base: Context) { super.attachBaseContext(base) // Emulates installation of on demand modules using SplitCompat. SplitCompat.installActivity(this) }}
res/layout/quotemakerx
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".view.QuoteMakerXActivity">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:theme="@style/AppTheme.AppBarOverlay">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
app:popupTheme="@style/AppTheme.PopupOverlay" />
</com.google.android.material.appbar.AppBarLayout>
<include layout="@layout/quotemakerx_content" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="@dimen/fab_margin"
app:srcCompat="@android:drawable/ic_dialog_email" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>
res/layout/quotemakerx_content
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:context=".view.TestActivity"
tools:showIn="@layout/test">
</androidx.constraintlayout.widget.ConstraintLayout>
AndroidManifest.xml
(in Dynamic Feature Module, not app
)
<?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"
package="com.luasoftware.quotemaker">
<dist:module
dist:instant="false"
dist:title="@string/title_quotemaker">
<dist:delivery>
<dist:on-demand />
</dist:delivery>
<dist:fusing dist:include="true" />
</dist:module>
<application>
<!-- android:label="@string/title_activity_quote_makerx" -->
<activity
android:name=".view.QuoteMakerXActivity"
android:screenOrientation="portrait"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
</intent-filter>
</activity>
</application>
</manifest>
NOTE: Notice that @string/title_quotemaker
or @string/title_activity_quote_makerx
must be specified in app
's res\values\strings.xml
, as it doesn't fetch the resource from current modules's strings.xml
. For activity, you still can update label/title during runtime by fetching the string from current modules's strings.xml
.
Load Activity of Dynamic Features Modules
Edit app
's build.gradle
.
dependencies {
implementation 'com.google.android.play:core:1.6.4'
}
Edit Application class.
class LuaApp: MultiDexApplication() { override fun onCreate() { super.onCreate() } override fun attachBaseContext(base: Context) { super.attachBaseContext(base) // Emulates installation of future on demand modules using SplitCompat. SplitCompat.install(this) }}
Launch Activity
fun launchActivity(id: String) { val packageName = "com.luasoftware.quotemaker" val activityClassName = "$packageName.view.QuoteMakerXActivity" val intent = Intent(Intent.ACTION_VIEW).setClassName( BuildConfig.APPLICATION_ID, // BuildConfig of app Module activityClassName ) .putExtra("id", id) startActivity(intent)}
Load Module
val moduleName = "quotemaker"fun loadModule() { val splitInstallManager = SplitInstallManagerFactory.create(context) if (!splitInstallManager.installedModules.contains(moduleName)) { Timber.d("Install module: $moduleName") val request = SplitInstallRequest .newBuilder() // You can download multiple on demand modules per // request by invoking the following method for each // module you want to install. .addModule(moduleName) // .addModule("promotionalFilters") .build() // TODO: move listener code and activeSessionId to ViewModel var activeSessionId: Int? = null var listener: SplitInstallStateUpdatedListener? = null listener = SplitInstallStateUpdatedListener { state -> if (state.sessionId() == activeSessionId) { when(state.status()) { SplitInstallSessionStatus.DOWNLOADING -> { val percentage = (state.bytesDownloaded() / state.totalBytesToDownload().toFloat() * 100).roundToInt() Timber.d("Downloading $percentage%") } SplitInstallSessionStatus.INSTALLED -> { launchActivity() splitInstallManager.unregisterListener(listener) } SplitInstallSessionStatus.FAILED -> { splitInstallManager.unregisterListener(listener) } else -> { } } } } splitInstallManager.registerListener(listener) // TODO: need to show some UI loading screen splitInstallManager // Submits the request to install the module through the // asynchronous startInstall() task. Your app needs to be // in the foreground to submit the request. .startInstall(request) // You should also be able to gracefully handle // request state changes and errors. To learn more, go to // the section about how to Monitor the request state. .addOnSuccessListener { sessionId -> // should not launch activity here, as success doesn't mean the module is installed // listen to SplitInstallStateUpdatedListener event instead // launchActivity("SAMPLE_ID") activeSessionId = sessionId } .addOnFailureListener { exception -> when ((exception as SplitInstallException).errorCode) { SplitInstallErrorCode.NETWORK_ERROR -> { Timber.d("Network Error") } else -> { Timber.e(exception, "splitInstallManager failed") } } } } else { Timber.d("Module found: $moduleName") launchActivity("SAMPLE_ID") }}
NOTE: When in development mode, splitInstallManager.installedModules.contains(moduleName)
seems to always return true.
Uninstall Module
splitInstallManager.deferredUninstall(listOf(moduleName))
NOTE: Seems like unintallation of module is not immediate, thus can't be used for testing purpose. The only way to uninstall a module for testing is to uninstall the app and re-install again.
Testing
Ways to test App Bundle
- bundletool: generate APKs from your app bundle and deploy them to a connected device.
- Internal App Sharing: upload your app bundle and share your app as a Google Play Store link with trusted testers
- Set up an open, closed, or internal test
In Android Studio -> Run -> Edit Configuration -> Android App -> App -> General -> Installation Options
, if Default APK
is selected, splitInstallManager.installedModules.contains(moduleName)
will always return true. If APK from app bundle
is selected, splitInstallManager.startInstall
will fail with .SplitInstallException: Split Install Error(-2): A requested module is not available (to this user/device, for the installed apk)
. I have checked the module under Dynamic features to deploy
.
NOTE: I am using Android Studio 3.5.2 and com.google.android.play:core:1.6.4
. I assume testing splitInstallManager.startInstall
is not possible during development with Android Studio (need to deploy via Google Play on Internal Test Track or Internal App Sharing, which is quite troublesome)
You need to publish your applicate using Google Play Console before you could deploy APK via Google Play for testing. If are not ready to launch yet:
- You can limit listing to one country only
- Don't upload production release
- At least
Internal test track
release is required before the App is considered published. The status will change fromReady to publish
toPending publication
(awaiting the review approval, might take between 30 minutes to 2 hours)
Internal test track
- Internal test track is available within minutes after upload.
- Support up to 100 testers per app
- Internal tester will still receive internal test even in country where your app is not available. Device exclusion rules don’t apply to internal testers as well.
Generate App Bundle: Android Studio -> Build -> Generate Signed Bundle / APK -> Android App Bundle
Goto Google Play Console -> Release management -> App releases -> Internal test track -> Internal test -> Manage
.
Click Create Release
to upload the generated and upload the generated .aab
file (in <PROJECT>\app\release
directory).
NOTE: You probably need to configure App Signing by Google Play.
NOTE: If you are using Google Cloud or Firebase, you probably want to add the App signing certificate
SHA-1 certificate fingerprint (Google Play Console -> Select App -> Release Management -> App signing -> App signing certificate
).
Expand Manager testers
to add Users, and also get the Opt-in URL
to be sent to tester.
Click on the Opt-in URL
on your Android device, click Join the Programme
.
Click on download it on Google Play
to start installing the App (there is an indication of Internal Early Access
after the App title on the Google Play page).
NOTE: Originally I install via APK (without Google Play). After I join the Internal test track/programme
, it is not possible to update the App even though a new version is available (only option available is Uninstall
and Open
, Update
is not available). The App is not listed in the Installed
section of Google Play App as well. I uninstalled the App and install via the Internal test track/programme
link. Now Update
is possible whenever a new update is available, and the App in listed in the Installed
section (and listed in Updates
section when update is available, with some delay) of Google Play App. Since I disable auto update for Google Play, I have to manually find the APP and click on Update
to perform an update.
NOTE: Set up an open, closed, or internal test
Internal app sharing
You need to add authorized testers.
- Goto Google Play Console and select your app.
- Goto
Development -> Internal app sharing -> Manager Testers
. You can choose either - Anyone that you share the link with can download
- Restrict access to email lists
- Click
Save
Generate App Bundle: Android Studio -> Build -> Generate Signed Bundle / APK -> Android App Bundle
Visit https://play.google.com/apps/publish/internalappsharing/ and upload the generated .aab
file (in <PROJECT>\app\release
directory).
Provide a Version name
and click Copy Link
when the upload is successful.
On your Android devices, Google Play Store app -> Settings -> About -> Play store version (tap 7 times)
. Internal app sharing
will appear under User control
, turn it on.
Click on the Internal app sharing
URL link.
NOTE: Internal app sharing
version of the app seems incompatible with the public version or the APK version (due to different certificate being used), thus require the public/APK version to be uninstalled before Internal app sharing
version could be installed.
NOTE: Share app bundles and APKs internally
Caveats
Each modules can't have layout file of the same name, else you might bump into the following error when generate Android App Bundle
.
Modules 'base' and 'quotemaker' contain entry 'res/layout/test.xml' with different content.
Initially the version code of my base module is 15. Later I upgraded the base module version code to 16 then the build app bundle process complaint for the above error.
App Bundle modules should have the same version code but found 15,16
NOTE: This is probably a bug, where do a Build -> Rebuild Project
will solve this problem.
A few more things
- I haven't research how Activity in Dynamic Feature Module work with Jetpack Navigation
- I haven't research how Dagger2 injection works in Dynamic Feature Module.
- If the module is larger than 10MB, there is a need to obtain user confirmation.
NOTE: Refer to Android App Bundle: Dyanmic Feature Modules ViewModel and Helper Class for more resuable code and helper class.
Refrences:
- https://developer.android.com/guide/app-bundle
- https://developer.android.com/studio/projects/dynamic-delivery/on-demand-delivery
- https://developer.android.com/guide/app-bundle/playcore
- https://codelabs.developers.google.com/codelabs/your-first-dynamic-app/index.html
- https://codelabs.developers.google.com/codelabs/on-demand-dynamic-delivery/index.html
- https://github.com/android/app-bundle-samples
- https://github.com/googlesamples/android-dynamic-code-loading
- https://www.youtube.com/watch?v=flhib2krW7U
- https://www.youtube.com/watch?v=bViNOUeFuiQ
- https://www.youtube.com/watch?v=rEuwVWpYBOY
- https://medium.com/androiddevelopers/a-patchwork-plaid-monolith-to-modularized-app-60235d9f212e
- https://github.com/android/plaid
- https://medium.com/androiddevelopers/patterns-for-accessing-code-from-dynamic-feature-modules-7e5dca6f9123