Open Source

Flutter package

unity_kit

Embed Unity 3D in Flutter with a typed, testable bridge — JSON or binary protocol, an enforced lifecycle, AR Foundation, and CDN asset streaming. Runs on Android and iOS (web/WebGL experimental).

v2.0.222.6k / month21 likesMITUnity 2022.3 LTS → Unity 6

Asset streaming & Addressables

Ship a ~100 MB app instead of gigabytes: CDN manifest, SHA-256 verified cache, and loading via Unity Addressables or raw AssetBundles.

source: unity_kit/doc/asset-streaming.md

Asset streaming

Why streaming

Traditional Flutter + Unity apps ship all 3D assets inside the APK/IPA, resulting in app sizes of 500 MB to several GB. With unity_kit's Addressables integration, your app binary can stay around ~100 MB while downloading content dynamically on demand. unity_kit ships a full asset-streaming pipeline that downloads content bundles from a CDN, caches them locally with SHA-256 integrity verification, and tells Unity Addressables to load from the local cache.

ApproachApp sizeFirst launchUpdate strategy
All assets in APK/IPA500 MB – 3+ GBSlow (huge download)Full app update
Addressables + unity_kit~100 MBFast (base app only)Download changed bundles only

How it works

  1. 1
    Base app ships with minimal assets (UI, loading screens).
  2. 2
    Content bundles (characters, levels, textures) are hosted on a CDN.
  3. 3
    StreamingController fetches a manifest, downloads bundles on demand, caches them locally with SHA-256 integrity, and tells Unity Addressables to load from the local cache.
  4. 4
    Updates only download changed bundles — no full app update required.

Runtime flow

The Flutter side downloads a bundle to the native cache; Unity Addressables then checks that local cache first before hitting the network, so a cached asset never re-downloads.

Flutter ↔ cache ↔ Unitytext
Flutter                          Native Cache                  Unity
  |                                  |                           |
  |-- download bundle ------------->|                           |
  |                                  |-- write to disk          |
  |-- sendWhenReady(LoadAsset) -----|-------------------------->|
  |                                  |                           |
  |                                  |<--- Addressables checks  |
  |                                  |     local cache first    |
  |                                  |                           |
  |<------- asset_loaded response --|---------------------------|

Where this fits

This page is the deep dive on streaming specifically. For the full menu of ways to get content into Unity at runtime — scenes, prefabs, AssetBundles, Addressables, glTF and AR — see Content loading.

Architecture

StreamingController is the orchestrator. It owns a shared cache and downloader, and delegates the actual "tell Unity to load" step to a swappable UnityAssetLoader strategy. Both strategies share the same download/cache infrastructure (ContentDownloader, CacheManager) and the same manifest format (ContentManifest).

Streaming componentstext
StreamingController (orchestrator)
├── CacheManager (shared — local disk cache with SHA-256)
├── ContentDownloader (shared — HTTP + retry + progress)
└── UnityAssetLoader (swappable strategy)
    ├── UnityAddressablesLoader → FlutterAddressablesManager.cs
    └── UnityBundleLoader       → FlutterAssetBundleManager.cs

Two loader strategies

StrategyClassC# ManagerUnity package required
Addressables (default)UnityAddressablesLoaderFlutterAddressablesManagercom.unity.addressables
Raw AssetBundlesUnityBundleLoaderFlutterAssetBundleManagerNone (built-in)

Which strategy to pick

  • Addressables (default) — pick this for new projects. Unity manages dependencies, reference counting and remote/local loading for you, and a remote catalog lets you push content updates without an app rebuild. Requires the com.unity.addressables package and the ADDRESSABLES_INSTALLED scripting define.
  • Raw AssetBundles — pick this when you cannot add the Addressables package or need full, manual control over packing. No extra Unity package (built-in), but you manage dependencies, versioning and cache invalidation yourself, and it does not support remote content catalogs.

Selecting the strategy is a one-line change on the constructor — the rest of the StreamingController API is identical:

Loader selectiondart
// Option A: Addressables (default)
final controller = StreamingController(
  bridge: bridge,
  manifestUrl: 'https://cdn.example.com/manifest.json',
);

// Option B: Raw AssetBundles
final controller = StreamingController(
  bridge: bridge,
  manifestUrl: 'https://cdn.example.com/manifest.json',
  assetLoader: const UnityBundleLoader(),
);

Loader property comparison

 UnityAddressablesLoaderUnityBundleLoader
targetNameFlutterAddressablesManagerFlutterAssetBundleManager
Load methodLoadAsset with keyLoadBundle with bundleName
Scene methodLoadScene with sceneNameLoadScene with bundleName
Unload methodUnloadAsset with keyUnloadBundle with bundleName
Unity APIUnity Addressables APIraw AssetBundle.LoadFromFileAsync

The Addressables loader builds messages with UnityMessage.routed(...) (routed through FlutterBridgeMessageRouter); the raw loader uses UnityMessage.to(...) (a direct GameObject SendMessage). You can also implement your own strategy by extending UnityAssetLoader:

Custom loaderdart
class MyCustomLoader extends UnityAssetLoader {
  const MyCustomLoader();

  @override
  String get targetName => 'MyCustomManager';

  @override
  UnityMessage setCachePathMessage(String cachePath) =>
    UnityMessage.to(targetName, 'SetCachePath', {'path': cachePath});

  @override
  UnityMessage loadAssetMessage({required String key, required String callbackId}) =>
    UnityMessage.to(targetName, 'Load', {'key': key, 'callbackId': callbackId});

  // ... implement remaining methods
}

The manifest

The manifest is a JSON file hosted on the CDN; its URL is what you pass to StreamingController(manifestUrl: ...). Both strategies use the same format. Here is the minimal single-bundle shape:

content_manifest.jsonjson
{
  "version": "1.0.0",
  "baseUrl": "https://cdn.example.com/bundles",
  "bundles": [
    {
      "name": "characters",
      "url": "https://cdn.example.com/bundles/characters",
      "sizeBytes": 5242880,
      "sha256": "a1b2c3...",
      "isBase": false,
      "dependencies": ["core"],
      "group": "streaming"
    }
  ],
  "buildTime": "2024-01-15T10:30:00Z",
  "platform": "Android"
}

A realistic manifest separates a base bundle (isBase: true, preloaded before streaming) from streamed bundles that declare their dependencies:

Base + streamed bundlesjson
{
  "version": "1.0.0",
  "baseUrl": "https://cdn.example.com/bundles",
  "platform": "android",
  "bundles": [
    {
      "name": "core",
      "url": "https://cdn.example.com/bundles/core.bin",
      "sizeBytes": 5242880,
      "sha256": "a1b2c3...",
      "isBase": true
    },
    {
      "name": "characters",
      "url": "https://cdn.example.com/bundles/characters.bin",
      "sizeBytes": 10485760,
      "sha256": "d4e5f6...",
      "isBase": false,
      "group": "characters",
      "dependencies": ["core"]
    }
  ]
}

Top-level fields — ContentManifest

FieldTypeRequiredNotes
versionStringyesSemantic version of this manifest.
baseUrlStringyesBase URL that bundle URLs are relative to.
catalogUrlString?noURL of the Addressables content catalog (.bin). When present, Unity loads the catalog directly and handles bundle downloads internally via Addressables.
bundlesList<ContentBundle>yesAll content bundles described by this manifest.
metadataMap<String,dynamic>?noArbitrary metadata attached to this manifest.
buildTimeDateTime?noWhen this manifest was built (parsed via DateTime.parse).
platformString?noTarget platform (e.g., 'android', 'ios').

ContentManifest also derives helpers from these fields: baseBundles and streamingBundles (split on isBase), totalSize, bundleCount, getBundlesByGroup(group), getBundleByName(name) and resolveDependencies(bundleName). The last returns dependencies in topological order and throws StateError('Circular dependency detected: $name') on a cycle.

Per-bundle fields — ContentBundle

FieldTypeRequiredDefaultNotes
nameStringyesUnique name identifying this bundle within the manifest.
urlStringyesRemote URL to download the bundle from.
sizeBytesintyesSize of the bundle in bytes.
sha256String?nonullSHA-256 hash of the bundle content for integrity verification.
isBaseboolnofalseWhether this is a base bundle that must be downloaded before streaming.
dependenciesList<String>noconst []Names of other bundles that must be downloaded before this one.
groupString?nonullOptional group label for categorization (e.g., 'characters', 'levels').
metadataMap<String,dynamic>?nonullArbitrary metadata; used for addressableKeys.

What the Unity Editor generates

The Addressables builder (AddressablesManifestBuilder.cs) emits a manifest where group is "addressables", a catalogUrl line is added when a catalog_*.bin is found, and a metadata.addressableKeys array is emitted per bundle:

AddressablesManifestBuilder outputtext
{
  "version": "1.0.0",
  "baseUrl": "{remoteLoadPath}",
  "catalogUrl": "{remoteLoadPath}/{catalogName}",   // only if a catalog_*.bin exists
  "bundles": [
    {
      "name": "{bundleName}",
      "url": "{remoteLoadPath}/{bundleName}",
      "sizeBytes": {fileInfo.Length},
      "sha256": "{sha256}",
      "isBase": false,
      "dependencies": [],
      "group": "addressables",
      "metadata": {"addressableKeys": ["{key}", ...]}   // or {} if none matched
    }
  ],
  "buildTime": "{DateTime.UtcNow:O}",
  "platform": "{EditorUserBuildSettings.activeBuildTarget}"
}

The raw-bundle builder (AssetBundleManifestBuilder.cs) instead emits "group": "default", no metadata, no catalogUrl, and a {BASE_URL} placeholder for both the top-level baseUrl and each bundle url — you replace it with your CDN URL before uploading. Both builders name the file content_manifest.json, compute lowercased-hex SHA-256, and stamp version "1.0.0".

Hosting & versioning

Host the manifest and every bundle on the same CDN. Bump the manifest version and the bundle sha256 values whenever content changes so clients know to re-download only what changed; the manifest platform field records which build target the bundles were built for (bundles are per-platform — see gotchas).

Dart side — StreamingController

StreamingController is the single entry point on the Dart side (exported from unity_kit.dart). You construct it with a bridge and a manifest URL; the assetLoader defaults to const UnityAddressablesLoader().

Constructordart
StreamingController({
  required UnityBridge bridge,
  required String manifestUrl,
  UnityAssetLoader? assetLoader,   // defaults to UnityAddressablesLoader
  http.Client? httpClient,
  CacheManager? cacheManager,
})

manifestUrl is validated: it must be http/https or the constructor throws ArgumentError.value(url, 'manifestUrl', 'Must be an HTTP or HTTPS URL').

Public members

Property / MethodDescription
assetLoaderThe loader strategy in use.
stateCurrent StreamingState.
downloadProgressStream of DownloadProgress.
errorsStream of StreamingError.
stateChangesStream of StreamingState.
initialize()Fetch manifest, init cache, notify Unity.
preloadContent({bundles, strategy})Download base bundles.
loadBundle(name)Download + tell Unity to load.
loadScene(name, {loadMode})Download + tell Unity to load scene.
getCachedBundles()List cached bundle names.
isBundleCached(name)Check if bundle is cached.
getCacheSize()Total cache size in bytes.
clearCache()Delete all cached content.
dispose()Release all resources.

Behaviour that matters

  • initialize() — sets state initializing, GETs the manifest URL (throws http.ClientException on non-200), parses ContentManifest.fromJson, initialises the cache, then sends the cache path to Unity via setCachePath(bridge, cacheManager.cachePath). If manifest.catalogUrl != null it also sends loadContentCatalog(url: catalogUrl, callbackId: 'catalog_load'). On success state → ready; on any error it emits StreamingErrorType.initializationFailed and state → error.
  • preloadContent({bundles, strategy}) — if bundles is null, preloads all baseBundles. Cached bundles emit DownloadProgress.cached and are skipped. The strategy parameter is reserved for future network-aware logic and currently has no effect.
  • loadBundle(name) — if the bundle is absent from the manifest it emits StreamingErrorType.bundleNotFound. It branches on manifest.catalogUrl != null: with a remote catalog set, Unity handles downloads via Addressables and the asset is loaded by its addressable key (extractAddressableKey, callbackId load_$bundleName); otherwise Flutter downloads the bundle (if uncached) then loads by bundle name.
  • loadScene(name, {loadMode}) — downloads the scene's bundle if present and uncached, then loads it with callbackId: 'scene_$sceneName'. 'Single' replaces the current scene, 'Additive' loads alongside it.
  • dispose() — closes the HTTP client and all three stream controllers; the controller is not reusable afterwards. cancelDownloads() is currently a no-op placeholder.

End-to-end usage

Streaming lifecycledart
// 1. Create streaming controller
final streaming = StreamingController(
  bridge: bridge,
  manifestUrl: 'https://cdn.example.com/manifest.json',
);

// 2. Initialize (fetches manifest, sets up cache, informs Unity)
await streaming.initialize();

// 3. Track progress
streaming.downloadProgress.listen((progress) {
  print('${progress.bundleName}: ${progress.percentageString}');
  print('Speed: ${progress.speedString}, ETA: ${progress.etaString}');
});

streaming.errors.listen((error) {
  print('Error: ${error.type} - ${error.message}');
});

// 4. Preload base content
await streaming.preloadContent();

// 5. Load a specific bundle on demand
await streaming.loadBundle('characters');

// 6. Load a Unity scene via Addressables
await streaming.loadScene('BattleArena', loadMode: 'Additive');

// 7. Cache management
final cached = streaming.getCachedBundles();
final size = streaming.getCacheSize();
final isCached = streaming.isBundleCached('characters');
await streaming.clearCache();

// 8. Dispose
await streaming.dispose();

Addressable key resolution

When a remote catalog is set, loadBundle loads by an addressable key. StreamingController.extractAddressableKey(bundleName, {bundle}) resolves it in three steps:

  1. 1
    Metadata lookup — the first entry of bundle.metadata['addressableKeys'] (generated by AddressablesManifestBuilder). Most reliable.
  2. 2
    Regex on the filename — for a name like {group}_{address}_{32hexhash}.bundle it strips the trailing _[a-f0-9]{32}$ hash, then the leading ^[a-z]+_assets_ prefix.
  3. 3
    Raw cleaned name — if neither produces a result, the cleaned bundle name is used as-is.

ContentDownloader (advanced)

The default StreamingController download path uses its own internal streamed GET, not ContentDownloader. ContentDownloader is a separate, advanced component: downloadBundle(bundle) returns a Stream<DownloadProgress>, fills bytesPerSecond and estimatedSecondsRemaining, and retries up to maxRetries (default 3) with exponential backoff of attempt * 2 seconds. Bundles are processed sequentially in the current implementation.

ContentDownloaderdart
final downloader = ContentDownloader(
  cacheManager: CacheManager(),
  maxRetries: 3,
  maxConcurrency: 3,
);

await for (final progress in downloader.downloadBundle(bundle)) {
  print('${progress.percentageString} - ${progress.speedString}');
}

// Cancel specific download
downloader.cancelDownload('characters');
downloader.cancelAllDownloads();

downloader.dispose();

Unity side

On the Unity side, a manager component receives the cache path and load commands from Flutter over the bridge. For the default strategy that is FlutterAddressablesManager; for raw bundles it is FlutterAssetBundleManager. Both are singletons (DontDestroyOnLoad) that auto-register with MessageRouter in OnEnable.

FlutterAddressablesManager

It receives cache paths from Flutter and loads assets / scenes from them, registering under the target name "FlutterAddressablesManager". It uses conditional compilation (#if ADDRESSABLES_INSTALLED) so the project compiles even without the Addressables package — when the package is absent, every load returns a descriptive error to Flutter instead of silently failing. Supported messages:

MessageEffect
SetCachePathConfigure the local cache directory.
LoadAssetLoad a GameObject by Addressable key.
LoadSceneLoad a scene (Single / Additive).
UnloadAssetRelease an asset by key.
UpdateCatalogRefresh the Addressables catalog.
LoadContentCatalogLoad a remote content catalog by URL.

SetCachePath must come first

SetCachePath(string path) rejects a null/empty path, stores System.IO.Path.GetFullPath(path), marks the manager initialised, and installs the cache-first redirect. It must be called before any load StreamingController.initialize() sends it automatically. Calling a load first returns "FlutterAddressablesManager not initialized. Call SetCachePath first.".

Inside SetCachePathcsharp
// Redirect remote Addressable URLs to the Flutter-managed cache.
Addressables.InternalIdTransformFunc = TransformInternalId;

TransformInternalId(IResourceLocation location) is that redirect: it only rewrites remote http/https URLs, extracts the filename, joins it to the cache path, and returns the local path only if File.Exists(cachedPath) (logging Redirecting '{fileName}' to cache) — otherwise it returns the original InternalId and Addressables downloads it normally. LoadAsset streams ProgressResponse with handle.PercentComplete while loading and replies with AssetLoadedResponse {callbackId, key, success, error}; LoadScene maps "Additive" LoadSceneMode.Additive (else Single). UnloadAsset only logs — Addressables uses reference counting, so callers must hold the handle and call Addressables.Release(handle) themselves.

FlutterAssetBundleManager (raw bundles)

The raw-bundle manager registers under "FlutterAssetBundleManager" and supports SetCachePath, LoadBundle, LoadScene and UnloadBundle. It loads via AssetBundle.LoadFromFileAsync and includes a path-traversal guard, SafeBundlePath(bundleName), which strips to Path.GetFileName, resolves under the cache dir, and returns null if the resolved path escapes it.

Editor setup — Addressables (Option A)

  1. 1
    Install the Addressables package — Unity Package Manager > com.unity.addressables.
  2. 2
    Add the scripting define symbol — Project Settings > Player > Scripting Define Symbols > Add ADDRESSABLES_INSTALLED.
  3. 3
    Configure Addressable Groups — base content on Local Build + Local Load paths; streaming content on Remote Build + Remote Load paths. Groups live under Window > Asset Management > Addressables > Groups. Enable Build Remote Catalog (key for remote updates) and, optionally, Only update catalogs manually.
  4. 4
    Build content — menu Flutter > Build Addressables. This generates content_manifest.json in the remote build folder.
  5. 5
    Upload to CDN — upload all .bundle files and content_manifest.json.
  6. 6
    Add FlutterAddressablesManager to your scene — create an empty GameObject, attach the FlutterAddressablesManager component; it auto-registers with MessageRouter. (The README variant suggests attaching it to the same FlutterBridge GameObject.)

Editor setup — raw AssetBundles (Option B)

  • Mark assets with AssetBundle labels — select assets in the Inspector and set the AssetBundle label at the bottom.
  • Build via menu Flutter > Build AssetBundles → output Builds/AssetBundles/ with content_manifest.json.
  • Replace the {BASE_URL} placeholder in content_manifest.json with your CDN URL.
  • Upload all bundle files and content_manifest.json, then attach FlutterAssetBundleManager to your scene.

These are the only two build menu items

Content is built exclusively from [MenuItem("Flutter/Build Addressables")] and [MenuItem("Flutter/Build AssetBundles")]. The separate Build.cs export tool (Export Android/iOS/WebGL, Deploy to Flutter Project) builds the Unity-as-a-Library artifact, not bundles. There is no batch-mode -executeMethod entry point for the bundle builders, and no CI workflow in the repo builds Unity content.

What goes to the CDN

<remote build folder>/*.bundle
https://cdn.example.com/bundles/*.bundle

Every built content bundle.

content_manifest.json
https://cdn.example.com/bundles/content_manifest.json

This URL is the manifestUrl you pass to StreamingController.

catalog_*.bin (Addressables only)
https://cdn.example.com/bundles/catalog_*.bin

Referenced by the manifest catalogUrl when present.

How Addressables resolves a load at runtime

For context, this is the standard Addressables load pipeline that the cache redirect above plugs into — the cache check in step 2 is exactly where TransformInternalId substitutes the local file:

Addressables runtimetext
1. Initialization (auto or manual):
   Addressables.InitializeAsync()
   → loads local settings
   → checks remote catalog hash
   → if new → downloads new catalog

2. Load asset:
   Addressables.LoadAssetAsync<T>("address")
   → resolves address → bundle location
   → checks cache (is bundle already downloaded)
   → downloads bundle if remote and not in cache
   → loads dependencies (other bundles)
   → deserializes asset
   → increments reference count
   → returns asset

3. Release:
   Addressables.Release(handle)
   → decrements reference count
   → if count = 0 → frees asset from memory
   → if no asset from bundle is used
     → frees bundle from memory

Cache & integrity

The cache is managed by CacheManager on the Dart side. On initialize() the cache path is getApplicationCacheDirectory() (from path_provider) plus /unity_kit_cache. A JSON index file, _cache_manifest.json, sits alongside the bundles and maps bundleName → CacheEntry {sha256, sizeBytes, cachedAt}. The cachePath getter returns exactly the directory that is sent to Unity via SetCachePath.

SHA-256 verification (differs per method)

  • cacheBundle(name, data, {sha256Hash}) — used by the default StreamingController._downloadBundle path. It writes the bytes and stores hash = sha256Hash ?? sha256.convert(data).toString() as-is; it does not compare the manifest hash against the computed hash on this path.
  • cacheBundleFromStream(name, stream, {expectedSize, sha256Hash}) — hashes chunks incrementally. If sha256Hash is provided, the computed hash is verified after the stream completes; on mismatch the written file is deleted and it throws StateError('SHA256 mismatch: expected $sha256Hash, got $computedHash').
  • verifyCache() — an explicit integrity pass over every cached bundle. It recomputes sha256.convert(bytes) per file and returns the names of bundles that are missing from disk or whose hash does not match the manifest entry (logging Cache verification found N invalid bundle(s)).
  • isCachedWithHash(name, sha256Hash) — returns whether the stored hash equals the given hash, with no recompute.

No automatic eviction, no size cap

There is no automatic eviction and no size limit in code. getCacheSize() sums sizeBytes over the manifest entries; the cache is managed only explicitly via removeBundle(name) (deletes file + index entry) and clearCache() (deletes the whole directory, recreates it, clears the index). The Unity side never deletes cache. Budget and prune the cache from your app if content can grow unbounded.

Progress & errors

Three broadcast streams expose the controller's state: downloadProgress (Stream<DownloadProgress>), errors (Stream<StreamingError>) and stateChanges (Stream<StreamingState>). The state machine moves uninitialized → initializing → ready → downloading → (ready | error).

Streamsdart
// Progress tracking
controller.downloadProgress.listen((progress) {
  print('${progress.bundleName}: ${progress.percentageString}');
  print('Speed: ${progress.speedString}');
  print('ETA: ${progress.etaString}');
});

// Error handling
controller.errors.listen((error) {
  print('Error: ${error.type} - ${error.message}');
});

// Preload base bundles
await controller.preloadContent(strategy: DownloadStrategy.wifiOnly);

// Cache management
final cached = controller.getCachedBundles();
final size = controller.getCacheSize();
await controller.clearCache();

Progress events

During a Flutter-managed download you get per-chunk DownloadProgress(state: downloading, downloadedBytes, totalBytes) events, DownloadProgress.cached for already-cached bundles, DownloadProgress.completed when done, and DownloadProgress.failed(error: ...) on failure. DownloadProgress exposes formatted getters: percentageString ("75%"), speedString ("1.2 MB/s") and etaString ("2m 30s"). On the Unity side, FlutterAddressablesManager streams ProgressResponse {callbackId, progress, status} with status "loading" / "loading_scene".

Errors from the controller

The full StreamingErrorType enum is notInitialized, initializationFailed, manifestFetchFailed, bundleNotFound, downloadFailed, networkUnavailable and cacheError. The ones you will actually see emitted:

TypeMessageWhen
initializationFailedInitialization failed: $eManifest fetch or cache init threw during initialize().
bundleNotFoundBundle not found: $nameloadBundle was called with a name absent from the manifest.
downloadFailedDownload failed: $nameA bundle download threw on the default StreamingController path.

Errors from Unity

Unity → Flutter errors arrive as ErrorResponse {callbackId, error, errorType}:

errorerrorType
FlutterAddressablesManager not initialized. Call SetCachePath first.not_initialized
Addressables package not installednot_installed
Catalog URL cannot be null or emptyinvalid_argument

Retry lives in ContentDownloader, not the default path

Only ContentDownloader.downloadBundle retries — up to maxRetries (default 3) with exponential backoff of attempt * 2 seconds. The default StreamingController._downloadBundle path is a single streamed GET and does not retry; on an exception it emits DownloadProgress.failed plus a downloadFailed error.

Comparison

Streaming via Addressables (or raw AssetBundles) is one of several content-loading approaches. At a glance, against scenes-in-build:

ApproachAsset loadingiOS safeAndroid safeComplexityBest for
Scene LoadingBuilt-inYesYesLowSwitching game contexts
AssetBundlesYesYesYesHighLegacy projects, full control
Addressables + CCDYesYesYesMediumPrimary content delivery system

Scenes-in-build are the simplest but increase APK/IPA size and only one scene is active at a time. AssetBundles and Addressables both stream content off the binary; Addressables is the recommended system for new projects because it is a higher-level layer on top of AssetBundles that manages dependencies, memory and remote/local loading automatically.

Addressables — pros

  • Automatic dependencies — no need to track manually.
  • Reference counting → no memory leaks (if you Release()).
  • Memory management (Release() frees automatically).
  • Content update without app rebuild! (remote catalog).
  • Labels → load groups of assets with a single call.
  • Integration with CCD (zero custom CDN).
  • Progress tracking (PercentComplete).
  • Predownload (DownloadDependenciesAsync).
  • Per-label, per-address, per-group control.

Addressables — cons

  • Abstraction adds debugging complexity.
  • Learning curve for the pipeline (groups, profiles, labels).
  • Automation does not fit ultra-granular control.
  • addressables_content_state.bin must be archived.
  • GetDownloadSizeAsync not always accurate.
  • First build is slower than raw AssetBundles.
  • Cache invalidation can be tricky.
  • Error messages can be cryptic.

Raw AssetBundles — pros

  • Full control over packing and strategy.
  • Any CDN/server — no vendor lock-in.
  • Mature system (10+ years in production).
  • Supports delta patching (changed bundles).
  • Can host scenes, prefabs, audio, everything.
  • LZ4 compression = fast loading on mobile.
  • Full control over cache (version, CRC).

Raw AssetBundles — cons

  • Manual dependency management — EVERY one must be tracked.
  • Manual versioning, hash checking, cache invalidation.
  • Bundle per platform (iOS != Android) — double build.
  • No built-in reference counting — memory leaks are easy.
  • Complicated variants/naming at large scale.
  • Manifest must be manually parsed for dependencies.
  • Debugging issues (missing dependencies) is difficult.

For the full matrix of loading approaches — prefabs, glTF/GLB runtime loading, runtime mesh generation, remote config, HybridCLR and AR Foundation — see Content loading.

Gotchas

The details below are the ones that break streaming most often. Each is verbatim behaviour from the source.

ADDRESSABLES_INSTALLED is mandatory

The whole AddressablesManifestBuilder.cs and all Addressables code in FlutterAddressablesManager.cs is guarded by #if ADDRESSABLES_INSTALLED. Add the define at Project Settings > Player > Scripting Define Symbols. Without it, every load returns "Addressables package not installed" (type not_installed).

SetCachePath before any load

SetCachePath must be called before any load operation — StreamingController.initialize() does this for you. Loading first returns "FlutterAddressablesManager not initialized. Call SetCachePath first.".

Addressable key must match the Address field

extractAddressableKey resolves from metadata.addressableKeys (generated by matching the bundle prefix against the entry address.ToLowerInvariant()), then a filename regex, then the raw name. For "Pack Separately" groups each bundle holds exactly one entry, and matching works by checking whether the bundle filename (lowercased, minus hash and extension) contains the entry's address — so the Address you set in Unity must line up with the built bundle name.

Rebuild Addressables after any asset change

After changing assets, rebuild via Flutter > Build Addressables (regenerates content_manifest.json in the remote build folder), then re-upload the .bundle files and content_manifest.json to the CDN. Keep Library/com.unity.addressables/addressables_content_state.bin archived — it is required for content-only updates.

Release for memory (reference counting)

Addressables uses reference counting; releasing by key is not directly supported, so UnloadAsset only logs. Callers must hold the handle and call Addressables.Release(handle), which decrements the count and frees the asset (and its bundle) at zero. Skipping this leaks memory.

Raw bundles: replace {BASE_URL} before uploading

The raw-bundle builder writes a {BASE_URL} placeholder into content_manifest.json and logs Replace '{BASE_URL}' in manifest with your CDN URL before uploading. Substitute your real CDN URL first.

Raw bundles do not support content catalogs

UnityBundleLoader.loadContentCatalogMessage throws UnsupportedError('LoadContentCatalog is not supported by raw AssetBundle loader'). Remote catalogs are an Addressables-only feature.

Bundles are per-platform

iOS and Android bundles differ — it is a double build, and each manifest stamps its platform. Serve the manifest and bundles that match the running platform.

iOS network limits

Apple blocks downloads >200 MB on LTE, so keep bundles per model <10 MB. iOS does not allow Unity to download in the background, and downloaded content should be saved under Application.persistentDataPath.

Supported platforms

Production platforms are Android and iOS only. Web/WebGL is not production-ready, there is no desktop target, and iOS runs on a physical device only (no iOS Simulator).

Path-traversal guard (raw bundles)

FlutterAssetBundleManager.SafeBundlePath strips a bundle name to Path.GetFileName and rejects any path that escapes the cache directory (returns null), preventing directory traversal from a malicious manifest.

Current limitations

manifestUrl must be http/https or the constructor throws ArgumentError. DownloadStrategy in preloadContent is reserved for future network-aware logic and currently has no effect. StreamingController.cancelDownloads() is a no-op placeholder.

Let's make something together.

If you have any questions about a new project or other inquiries, feel free to contact us. We will get back to you as soon as possible.

Codigee
We are using cookies. Learn more