DEV Community

Prajwal Thapa (vivid soda)
Prajwal Thapa (vivid soda)

Posted on

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

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")
    }
}
Enter fullscreen mode Exit fullscreen mode

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"
    // ...
}
Enter fullscreen mode Exit fullscreen mode

And include them in settings.gradle.kts:

// android/settings.gradle.kts
include(":app")
include(":video_assets")
include(":music_assets")
include(":image_assets")
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

💡 Scaffolding tip: rather than hand‑writing these modules, the asset_delivery package 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);
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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...
}
Enter fullscreen mode Exit fullscreen mode

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'];
}
Enter fullscreen mode Exit fullscreen mode

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),
            );
          },
        );
      },
    ),
  );
}
Enter fullscreen mode Exit fullscreen mode

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:

# 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
Enter fullscreen mode Exit fullscreen mode

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.
}
Enter fullscreen mode Exit fullscreen mode

Register it with the base app under dynamicFeatures:

// android/app/build.gradle.kts
android {
    // ...
    dynamicFeatures += setOf(":zoom_sdk_kotlin")
}
Enter fullscreen mode Exit fullscreen mode

And include it in settings:

// android/settings.gradle.kts
include(":zoom_sdk_kotlin")
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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});
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

⚠️ 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:

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);
            },
          );
        },
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)