Shrinking Your Flutter App: Dynamic Asset & Feature Delivery on Android with Play
How I shipped a 60 MB video, a full images/audio library, and an entire Zoom SDK without bloating the base APK — using Google Play Asset Delivery and Play Feature Delivery from Flutter.
The problem: apps are getting fat
Every megabyte you add to your app's base install costs you users. Google's own research shows that conversion rates drop measurably for every extra 6 MB of APK size. Yet modern apps ship things that are genuinely huge:
- High‑resolution video and audio
- Large image sets
- Heavyweight native SDKs (in my case, the Zoom Mobile RTC SDK — a ~297 MB
.aar)
If you bundle all of that into your base install, most users pay the download cost for features they may never touch.
Android's answer is two complementary technologies that ship inside the Android App Bundle (.aab):
| Play Asset Delivery (PAD) | Play Feature Delivery (PFD) | |
|---|---|---|
| Ships | Raw assets (video, audio, images) | Code + resources (a whole feature module) |
| Gradle plugin | com.android.asset-pack |
com.android.dynamic-feature |
| Runtime API | Asset Pack Manager | SplitInstallManager |
| Good for | Media too big for the base APK | Optional features, huge native SDKs |
This article walks through how I wired both into a single Flutter app: on‑demand asset packs for media, and an on‑demand dynamic feature module for the Zoom SDK. All the code below is from a working project.
Part 1 — Dynamic Asset Delivery
The mental model
An asset pack is a chunk of files that lives in your App Bundle but is delivered separately. There are three delivery modes:
- install-time — shipped with the app, available immediately.
- fast-follow — downloaded automatically right after install.
- on-demand — downloaded only when your app asks for it. This is the interesting one.
In this project I created three on‑demand asset packs: music_assets, image_assets, and video_assets (the last one holds a ~63 MB sea.mp4).
Step 1: Declare the asset packs in Gradle
Each asset pack is its own Gradle module with a tiny build.gradle.kts:
// android/video_assets/build.gradle.kts
plugins {
id("com.android.asset-pack")
}
assetPack {
packName.set("video_assets")
dynamicDelivery {
deliveryType.set("on-demand")
}
}
Then register the packs with the app module. Note the assetPacks += ... line:
// android/app/build.gradle.kts
android {
assetPacks += listOf(":music_assets", ":video_assets", ":image_assets")
namespace = "com.yajtech.dynamic_module"
// ...
}
And include them in settings.gradle.kts:
// android/settings.gradle.kts
include(":app")
include(":video_assets")
include(":music_assets")
include(":image_assets")
Your actual media files then live at:
android/video_assets/src/main/assets/sea.mp4
android/music_assets/src/main/assets/<audio files>
android/image_assets/src/main/assets/<images>
💡 Scaffolding tip: rather than hand‑writing these modules, the
asset_deliverypackage ships a generator:dart run asset_delivery:setup_asset_pack "video_assets"
Step 2: A thin Dart API over the platform channel
I wrapped the native Asset Pack Manager behind a clean static API. Here's the public surface:
class AssetDelivery {
/// Kicks off (or resumes) the download of an asset pack.
static Future<void> fetch(String assetPackName) =>
AssetDeliveryPlatform.instance.fetch(assetPackName);
/// Resolves the on-device path to the downloaded assets.
static Future<String?> getAssetPackPath({
required String assetPackName,
required int count,
required String namingPattern,
required String fileExtension,
}) {
return AssetDeliveryPlatform.instance.getAssetPackPath(
assetPackName: assetPackName,
count: count,
namingPattern: namingPattern,
fileExtension: fileExtension,
);
}
/// Streams live download status + progress.
static Stream<StatusMap> watchAssetPackStatus(String assetPackName) =>
AssetDeliveryPlatform.instance.watchAssetPackStatus(assetPackName);
}
Under the hood, the method‑channel implementation talks to Android and iOS differently — Android uses Play Asset Delivery, iOS uses On‑Demand Resources — but the Dart caller doesn't care:
@override
Future<void> fetch(String assetPackName) async {
try {
await methodChannel.invokeMethod('fetch', {'assetPack': assetPackName});
} on PlatformException catch (e) {
debugPrint("Failed to fetch asset pack: ${e.message}");
rethrow;
}
}
@override
Future<String?> getAssetPackPath({
required String assetPackName,
required int count,
required String namingPattern,
required String fileExtension,
}) async {
if (Platform.isAndroid) {
return await methodChannel
.invokeMethod('getAssets', {'assetPack': assetPackName});
} else if (Platform.isIOS) {
return await methodChannel.invokeMethod('getDownloadResources', {
'tag': assetPackName,
'namingPattern': namingPattern,
'assetRange': count,
'extension': fileExtension,
});
}
throw UnsupportedError('Platform not supported');
}
Step 3: Progress as a Stream
The most important UX detail is showing real download progress. On Android, the native listener pushes onAssetPackStatusChange events; I expose them as a broadcast Stream<StatusMap>:
@override
Stream<StatusMap> watchAssetPackStatus(String assetPackName) {
if (_statusController != null && !_statusController!.isClosed) {
return _statusController!.stream;
}
_statusController = StreamController<StatusMap>.broadcast(
onListen: _startListening,
onCancel: _stopListening,
);
return _statusController!.stream;
}
void _startListening() {
if (Platform.isAndroid) {
methodChannel.setMethodCallHandler((call) async {
if (call.method == 'onAssetPackStatusChange') {
final statusMap = Map<String, dynamic>.from(call.arguments);
_statusController?.add(StatusMap.fromJson(statusMap));
}
});
}
// iOS listens on a separate progress channel...
}
StatusMap is just a small value object:
class StatusMap {
String status; // e.g. "DOWNLOADING", "COMPLETED"
double downloadProgress; // 0.0 – 1.0
StatusMap({required this.status, required this.downloadProgress});
StatusMap.fromJson(Map<String, dynamic> json)
: status = json['status'],
downloadProgress = json['downloadProgress'];
}
Step 4: Wiring it into the UI
Now the screen almost writes itself. Fire fetch() on init, then combine a FutureBuilder (for the resolved path) with a StreamBuilder (for live progress):
@override
void initState() {
super.initState();
if (Platform.isAndroid) {
AssetDelivery.fetch(widget.assetPackName); // start the download
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Download $_assetDisplayName Assets')),
body: FutureBuilder<String>(
future: _getDownloadResourcesPath(),
builder: (context, pathSnapshot) {
return StreamBuilder(
stream: AssetDelivery.watchAssetPackStatus(widget.assetPackName),
builder: (context, statusSnapshot) {
final status = statusSnapshot.data?.status;
final progress = statusSnapshot.data?.downloadProgress ?? 0.0;
final isCompleted =
status == 'COMPLETED' || pathSnapshot.data != null;
return Center(
child: isCompleted
? CompletedState(
assetDisplayName: _assetDisplayName,
fileExtension: widget.fileExtension,
path: pathSnapshot.data!,
)
: DownloadingState(status: status, progress: progress),
);
},
);
},
),
);
}
Once getAssetPackPath resolves, you have a real filesystem path and can hand it straight to video_player, just_audio, or Image.file.
⚠️ Gotcha: if the assets are already on the device,
fetch()short‑circuits and you jump straight toCOMPLETED. Always treat "already downloaded" as a first‑class state.
Testing asset packs before publishing
Asset packs only download from Play. To test locally, build the bundle and use bundletool:
# Generate installable APKs from your .aab, with local asset packs
java -jar bundletool.jar build-apks \
--bundle=build/app/outputs/bundle/release/app-release.aab \
--output=/tmp/app.apks --local-testing
# Install on a connected device
java -jar bundletool.jar install-apks --apks=/tmp/app.apks
For a real end‑to‑end test of on‑demand delivery, upload to the Play Console's internal testing track.
Part 2 — Dynamic Feature Delivery
Asset packs are great for data. But what about code — an entire optional feature with its own Activities, resources, and a giant native SDK? That's where Play Feature Delivery and SplitInstallManager come in.
My use case: the Zoom Mobile RTC SDK. Bundling its ~297 MB .aar into every install is a non‑starter. Instead, it lives in an on‑demand dynamic feature module called zoom_sdk_kotlin that users download only when they open the video‑calling feature.
Step 1: Declare the dynamic feature module
The module uses the com.android.dynamic-feature plugin and depends on :app:
// android/zoom_sdk_kotlin/build.gradle.kts
plugins {
id("com.android.dynamic-feature")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.vivid_soda.zoom_sdk_kotlin"
compileSdk { version = release(36) }
defaultConfig { minSdk = 28 }
}
dependencies {
implementation(project(":app"))
api(files("../mobilertc/mobilertc.aar")) // the ~297 MB Zoom SDK
// ...exoplayer, crypto tink, appcompat, etc.
}
Register it with the base app under dynamicFeatures:
// android/app/build.gradle.kts
android {
// ...
dynamicFeatures += setOf(":zoom_sdk_kotlin")
}
And include it in settings:
// android/settings.gradle.kts
include(":zoom_sdk_kotlin")
Step 2: The Dart-side control surface
Same pattern as before — a thin static facade over a method channel:
class FeatureDelivery {
static Future<bool> checkIfModuleIsAvailable(String featureName) =>
DynamicFeaturePlatformImpl().checkIfModuleIsAvailable(featureName);
static void installDynamicFeature(String featureName) =>
DynamicFeaturePlatformImpl().installDynamicFeature(featureName);
static Future<Set<String>> getInstalledModules() =>
DynamicFeaturePlatformImpl().getInstalledModules();
static void startZoomHomeActivity() =>
DynamicFeaturePlatformImpl().startZoomHomeActivity();
static StreamController<StatusMap> downloadStream =
DynamicFeaturePlatformImpl().downloadStatusController;
}
The implementation sets up a single method‑call handler that turns native install events into a Stream:
class DynamicFeaturePlatformImpl extends FeatureDeliveryPlatformInterface {
final _methodChannel = const MethodChannel(
'com.example.dynamic_module/play-feature-delivery',
);
final _downloadStatusController = StreamController<StatusMap>.broadcast();
void _initializeMethodCallHandler() {
_methodChannel.setMethodCallHandler((call) async {
if (call.method == 'listenDownloadStatus') {
final statusMap = Map<String, dynamic>.from(call.arguments);
_downloadStatusController.add(StatusMap.fromJson(statusMap));
}
});
}
@override
Future<bool> checkIfModuleIsAvailable(String featureModuleName) async {
return await _methodChannel.invokeMethod<bool>(
'checkIfModuleIsAvailable', {'moduleName': featureModuleName}) ??
false;
}
@override
void installDynamicFeature(String featureModuleName) {
_methodChannel.invokeMethod<void>(
'installDynamicFeature', {'moduleName': featureModuleName});
}
}
Step 3: The native SplitInstallManager
This is the heart of Play Feature Delivery. In MainActivity I create a SplitInstallManager, register a status listener, and route method calls to it:
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.example.dynamic_module/play-feature-delivery"
private lateinit var splitInstallManager: SplitInstallManager
private lateinit var installStateUpdatedListener: DynamicFeatureListener
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
splitInstallManager = SplitInstallManagerFactory.create(this)
val methodChannel = MethodChannel(
flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
installStateUpdatedListener = DynamicFeatureListener(methodChannel)
splitInstallManager.registerListener(installStateUpdatedListener)
methodChannel.setMethodCallHandler { call, result ->
when (call.method) {
"checkIfModuleIsAvailable" -> checkIfModuleIsAvailable(call, result)
"installDynamicFeature" -> getDynamicFeature(call, result)
"getInstalledModules" -> getInstalledModules(result)
"startZoomHomeActivity" -> startZoomHomeActivity()
else -> result.notImplemented()
}
}
}
private fun getDynamicFeature(call: MethodCall, result: MethodChannel.Result) {
val moduleName = call.argument<String>("moduleName") ?: run {
result.error("MODULE_NOT_FOUND", "Module not found", null); return
}
val request = SplitInstallRequest.newBuilder()
.addModule(moduleName)
.build()
splitInstallManager.startInstall(request)
.addOnSuccessListener { sessionId -> mySessionId = sessionId }
.addOnFailureListener { /* handle error */ }
}
private fun getInstalledModules(result: MethodChannel.Result) {
result.success(splitInstallManager.installedModules.toList())
}
override fun onDestroy() {
super.onDestroy()
splitInstallManager.unregisterListener(installStateUpdatedListener)
}
}
Step 4: Streaming install progress back to Flutter
SplitInstallManager reports a rich state machine — PENDING → DOWNLOADING → DOWNLOADED → INSTALLING → INSTALLED (plus failure/cancel states). The listener translates each state into a normalized status + progress map and posts it to Flutter on the main thread:
class DynamicFeatureListener(
private val methodChannel: MethodChannel
) : SplitInstallStateUpdatedListener {
override fun onStateUpdate(state: SplitInstallSessionState) {
when (state.status()) {
SplitInstallSessionStatus.PENDING ->
sendStatusToFlutter("PENDING", 0.0)
SplitInstallSessionStatus.DOWNLOADING -> {
val percent = state.bytesDownloaded().toDouble() /
state.totalBytesToDownload().toDouble()
sendStatusToFlutter("DOWNLOADING", percent)
}
SplitInstallSessionStatus.INSTALLING ->
sendStatusToFlutter("INSTALLING", 0.99)
SplitInstallSessionStatus.INSTALLED ->
sendStatusToFlutter("INSTALLED", 1.0)
SplitInstallSessionStatus.FAILED ->
sendStatusToFlutter("FAILED", 0.0)
// ...REQUIRES_USER_CONFIRMATION, CANCELING, CANCELED, UNKNOWN
}
}
private fun sendStatusToFlutter(status: String, downloadProgress: Double) {
val payload = mapOf("status" to status, "downloadProgress" to downloadProgress)
// Method channels must be invoked on the main thread
if (Looper.getMainLooper() == Looper.myLooper()) {
methodChannel.invokeMethod("listenDownloadStatus", payload)
} else {
Handler(Looper.getMainLooper()).post {
methodChannel.invokeMethod("listenDownloadStatus", payload)
}
}
}
}
⚠️ The main‑thread rule bit me.
MethodChannel.invokeMethodmust be called on the UI thread. Play's callbacks don't always arrive there, so theLoopercheck above is not optional — without it you'll get intermittent crashes.
Step 5: The download screen
On the Flutter side it's the same FutureBuilder + StreamBuilder combo as the asset packs, which is exactly what makes this architecture pleasant — both delivery mechanisms present an identical StatusMap stream to the UI layer:
class DynamicFeaturesDownloadScreen extends StatefulWidget {
const DynamicFeaturesDownloadScreen({super.key});
@override
State<DynamicFeaturesDownloadScreen> createState() => _State();
}
class _State extends State<DynamicFeaturesDownloadScreen> {
final dynamicFeature = 'zoom_sdk_kotlin';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
FeatureDelivery.installDynamicFeature(dynamicFeature); // trigger install
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Download Features')),
body: FutureBuilder<bool>(
future: FeatureDelivery.checkIfModuleIsAvailable(dynamicFeature),
builder: (context, availableSnap) {
return StreamBuilder(
stream: FeatureDelivery.downloadStream.stream,
builder: (context, statusSnap) {
final status = statusSnap.data?.status;
final progress = statusSnap.data?.downloadProgress ?? 0.0;
final isCompleted =
status == 'INSTALLED' || availableSnap.data == true;
return isCompleted
? CompletedState(assetDisplayName: dynamicFeature)
: DownloadingState(status: status, progress: progress);
},
);
},
),
);
}
}
Step 6: Launching code from the freshly-installed module
Once the module is installed, its Activity becomes reachable. I launch it by fully‑qualified class name — note it runs under the base app's package, which is how Play merges the split into the running process:
private fun startZoomHomeActivity() {
val intent = Intent().setClassName(
"com.yajtech.dynamic_module", // base app package
"com.vivid_soda.zoom_sdk_kotlin.ZoomHomeActivity" // class in the module
)
startActivity(intent)
}
For calling functions (not just Activities) inside a freshly downloaded module, you reflectively load the module's entry point after install — the project exposes a loadModuleDirectly / executeFeatureFunction bridge for exactly that, so Dart can invoke module code like processData or calculateValue without a compile‑time dependency on the module.
The payoff
Here's what this architecture buys you:
- A lean base install. The ~297 MB Zoom SDK and ~63 MB video never touch the base APK. Users who never make a video call never download them.
-
One consistent UX pattern. Whether it's raw media (PAD) or a code module (PFD), the Flutter layer consumes the same
Stream<StatusMap>and renders the same downloading/completed states. - Clean separation of concerns. Dart orchestrates and renders; Kotlin owns the Play APIs. The method channel is the only seam.
Lessons learned
-
Always handle "already installed." Both
checkIfModuleIsAvailable(PFD) and a resolvable asset path (PAD) let you skip straight to the ready state. Don't force a re‑download. - Respect the main thread. Method‑channel calls from Play's background callbacks must be marshaled onto the UI looper.
-
Keep large binaries out of Git. The Zoom
.aarand the video file exceed GitHub's limits — track them with Git LFS or store them externally and drop them in before the build. -
You can't test on‑demand delivery with
flutter run. Usebundletool --local-testingor the Play Console internal track.
Wrapping up
Dynamic delivery isn't exotic anymore — it's how you keep a feature‑rich app installable on a mid‑range phone with limited storage and a slow connection. Flutter doesn't have first‑class support for Play Asset Delivery or Play Feature Delivery out of the box, but as this project shows, a thin method‑channel bridge plus a couple of Gradle modules is all it takes to get there.
The full pattern is reusable: declare a Gradle module, expose a static Dart facade over a method channel, normalize native progress into a Stream<StatusMap>, and drive the UI with FutureBuilder + StreamBuilder. Once that skeleton is in place, adding a new asset pack or feature module is almost mechanical.
If this helped, drop a clap 👏 and a comment — I'm happy to go deeper on the iOS On‑Demand Resources side or the reflective module‑function bridge in a follow‑up.
Top comments (0)