🪟 Windows TippsThe Gemini desktop app is now available for Windows(11.09.2026 um 17:06 Uhr)
⚠️ Malware / Trojaner / VirenWindows 11 just dropped the tool ransomware abused, Microsoft says don’t restore WMIC(10.09.2026 um 20:11 Uhr)
⚠️ Malware / Trojaner / VirenVorsicht: Android-Malware verschlüsselt Ihre Handys und nimmt heimlich Fotos auf(11.09.2026 um 09:35 Uhr)
🕵️ SicherheitslückenMicrosoft geht endlich eines der nervigsten Probleme von Windows 11 an(11.09.2026 um 11:58 Uhr)
💾 IT Security ToolsSysinternals Suite(11.09.2026 um 12:00 Uhr)
🕵️ SicherheitslückenDefender 0-Day ShieldBreak (CVE-2026-69414) nicht sauber gepatcht - BornCity(11.09.2026 um 12:52 Uhr)
🪟 Windows TippsThe Gemini desktop app is now available for Windows(11.09.2026 um 17:06 Uhr)
⚠️ Malware / Trojaner / VirenWindows 11 just dropped the tool ransomware abused, Microsoft says don’t restore WMIC(10.09.2026 um 20:11 Uhr)
⚠️ Malware / Trojaner / VirenVorsicht: Android-Malware verschlüsselt Ihre Handys und nimmt heimlich Fotos auf(11.09.2026 um 09:35 Uhr)
🕵️ SicherheitslückenMicrosoft geht endlich eines der nervigsten Probleme von Windows 11 an(11.09.2026 um 11:58 Uhr)
💾 IT Security ToolsSysinternals Suite(11.09.2026 um 12:00 Uhr)
🕵️ SicherheitslückenDefender 0-Day ShieldBreak (CVE-2026-69414) nicht sauber gepatcht - BornCity(11.09.2026 um 12:52 Uhr)

🔧 Programmierung 🕛 vor 2 Monaten 12 Min Lesezeit
0

Shrinking Your Flutter App: Dynamic Asset & Feature Delivery on Android with Play

↗ Quelle (dev.to)
🗣️ Stimme:
📑 Inhaltsübersicht




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:




CODE
// 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:




CODE
// 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:




CODE
// android/settings.gradle.kts
include(":app")
include(":video_assets")
include(":music_assets")
include(":image_assets")






Your actual media files then live at:




CODE
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_delivery package ships a generator:




CODE
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:




CODE
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:




CODE
@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>:




CODE
@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:




CODE
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):




CODE
@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 to COMPLETED. 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:




CODE
# 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:




CODE
// 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:




CODE
// android/app/build.gradle.kts
android {
// ...
dynamicFeatures += setOf(":zoom_sdk_kotlin")
}






And include it in settings:




CODE
// 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:




CODE
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:




CODE
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:




CODE
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:




CODE
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.invokeMethod must be called on the UI thread. Play's callbacks don't always arrive there, so the Looper check 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:




CODE
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:




CODE
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





  1. 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.


  2. Respect the main thread. Method‑channel calls from Play's background callbacks must be marshaled onto the UI looper.


  3. Keep large binaries out of Git. The Zoom .aar and the video file exceed GitHub's limits — track them with Git LFS or store them externally and drop them in before the build.


  4. You can't test on‑demand delivery with flutter run. Use bundletool --local-testing or 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.

Vollständiger Original-Bericht
Ausführliche Details, Code-Beispiele & Hersteller-Stellungnahme auf dev.to.
↗ Original-Artikel auf dev.to lesen
Wie bewertest du diesen Beitrag?
1 Klick Feedback
Teilen mit Netzwerk & Team:

Community-Analysen & Experten-Meinungen 0

Verfasse deine eigene Analyse, teile Workarounds oder diskutiere diesen Vorfall im Blog.
Noch keine Community-Analyse verfasst. Markiere einen Textabschnitt oder klicke oben auf Eigene Analyse verfassen“!
Community Pulse: Relevanz-Einschätzung
1 Klick Experten-Votum
🔴 Akute Relevanz 0%
🟡 In Evaluierung 0%
🟢 Keine Auswirkung 0%
Spannende Innovation 0%
Verwandte Story-Cluster & Quellen (Vektor-KI)
Port 8095 Engine
1 Quelle
The Gemini desktop app is now available for Windows
1 Quelle
Windows 11 just dropped the tool ransomware abused, Microsoft says don’t restore WMIC
1 Quelle
Vorsicht: Android-Malware verschlüsselt Ihre Handys und nimmt heimlich Fotos auf
Ähnliche Beiträge
🔍 Verwandte News

Auch interessante Nachrichten Shrinking Your Flutter App: Dynamic Asset & Feature Delivery on Android with Play

Thematisch verwandte Begriffe: Shrinking, Your, Flutter, Dynamic · 6 Treffer

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...

Laden...

Beiträge werden geladen ...

Laden...

Videos werden geladen ...