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.
| Approach | App size | First launch | Update strategy |
|---|---|---|---|
| All assets in APK/IPA | 500 MB – 3+ GB | Slow (huge download) | Full app update |
| Addressables + unity_kit | ~100 MB | Fast (base app only) | Download changed bundles only |
How it works
- 1Base app ships with minimal assets (UI, loading screens).
- 2Content bundles (characters, levels, textures) are hosted on a CDN.
- 3
StreamingControllerfetches a manifest, downloads bundles on demand, caches them locally with SHA-256 integrity, and tells Unity Addressables to load from the local cache. - 4Updates 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 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).
StreamingController (orchestrator)
├── CacheManager (shared — local disk cache with SHA-256)
├── ContentDownloader (shared — HTTP + retry + progress)
└── UnityAssetLoader (swappable strategy)
├── UnityAddressablesLoader → FlutterAddressablesManager.cs
└── UnityBundleLoader → FlutterAssetBundleManager.csTwo loader strategies
| Strategy | Class | C# Manager | Unity package required |
|---|---|---|---|
| Addressables (default) | UnityAddressablesLoader | FlutterAddressablesManager | com.unity.addressables |
| Raw AssetBundles | UnityBundleLoader | FlutterAssetBundleManager | None (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.addressablespackage and theADDRESSABLES_INSTALLEDscripting 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:
// 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
| UnityAddressablesLoader | UnityBundleLoader | |
|---|---|---|
| targetName | FlutterAddressablesManager | FlutterAssetBundleManager |
| Load method | LoadAsset with key | LoadBundle with bundleName |
| Scene method | LoadScene with sceneName | LoadScene with bundleName |
| Unload method | UnloadAsset with key | UnloadBundle with bundleName |
| Unity API | Unity Addressables API | raw AssetBundle.LoadFromFileAsync |
The Addressables loader builds messages with UnityMessage.routed(...) (routed through FlutterBridge → MessageRouter); the raw loader uses UnityMessage.to(...) (a direct GameObject SendMessage). You can also implement your own strategy by extending UnityAssetLoader:
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:
{
"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:
{
"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
| Field | Type | Required | Notes |
|---|---|---|---|
| version | String | yes | Semantic version of this manifest. |
| baseUrl | String | yes | Base URL that bundle URLs are relative to. |
| catalogUrl | String? | no | URL of the Addressables content catalog (.bin). When present, Unity loads the catalog directly and handles bundle downloads internally via Addressables. |
| bundles | List<ContentBundle> | yes | All content bundles described by this manifest. |
| metadata | Map<String,dynamic>? | no | Arbitrary metadata attached to this manifest. |
| buildTime | DateTime? | no | When this manifest was built (parsed via DateTime.parse). |
| platform | String? | no | Target 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
| Field | Type | Required | Default | Notes |
|---|---|---|---|---|
| name | String | yes | — | Unique name identifying this bundle within the manifest. |
| url | String | yes | — | Remote URL to download the bundle from. |
| sizeBytes | int | yes | — | Size of the bundle in bytes. |
| sha256 | String? | no | null | SHA-256 hash of the bundle content for integrity verification. |
| isBase | bool | no | false | Whether this is a base bundle that must be downloaded before streaming. |
| dependencies | List<String> | no | const [] | Names of other bundles that must be downloaded before this one. |
| group | String? | no | null | Optional group label for categorization (e.g., 'characters', 'levels'). |
| metadata | Map<String,dynamic>? | no | null | Arbitrary 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:
{
"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().
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 / Method | Description |
|---|---|
| assetLoader | The loader strategy in use. |
| state | Current StreamingState. |
| downloadProgress | Stream of DownloadProgress. |
| errors | Stream of StreamingError. |
| stateChanges | Stream 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 stateinitializing, GETs the manifest URL (throwshttp.ClientExceptionon non-200), parsesContentManifest.fromJson, initialises the cache, then sends the cache path to Unity viasetCachePath(bridge, cacheManager.cachePath). Ifmanifest.catalogUrl != nullit also sendsloadContentCatalog(url: catalogUrl, callbackId: 'catalog_load'). On success state →ready; on any error it emitsStreamingErrorType.initializationFailedand state →error.preloadContent({bundles, strategy})— ifbundlesis null, preloads allbaseBundles. Cached bundles emitDownloadProgress.cachedand are skipped. Thestrategyparameter is reserved for future network-aware logic and currently has no effect.loadBundle(name)— if the bundle is absent from the manifest it emitsStreamingErrorType.bundleNotFound. It branches onmanifest.catalogUrl != null: with a remote catalog set, Unity handles downloads via Addressables and the asset is loaded by its addressable key (extractAddressableKey, callbackIdload_$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 withcallbackId: '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
// 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:
- 1Metadata lookup — the first entry of
bundle.metadata['addressableKeys'](generated byAddressablesManifestBuilder). Most reliable. - 2Regex on the filename — for a name like
{group}_{address}_{32hexhash}.bundleit strips the trailing_[a-f0-9]{32}$hash, then the leading^[a-z]+_assets_prefix. - 3Raw 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.
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:
| Message | Effect |
|---|---|
| SetCachePath | Configure the local cache directory. |
| LoadAsset | Load a GameObject by Addressable key. |
| LoadScene | Load a scene (Single / Additive). |
| UnloadAsset | Release an asset by key. |
| UpdateCatalog | Refresh the Addressables catalog. |
| LoadContentCatalog | Load 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.".
// 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)
- 1Install the Addressables package — Unity Package Manager >
com.unity.addressables. - 2Add the scripting define symbol — Project Settings > Player > Scripting Define Symbols > Add
ADDRESSABLES_INSTALLED. - 3Configure 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.
- 4Build content — menu Flutter > Build Addressables. This generates
content_manifest.jsonin the remote build folder. - 5Upload to CDN — upload all
.bundlefiles andcontent_manifest.json. - 6Add FlutterAddressablesManager to your scene — create an empty GameObject, attach the
FlutterAddressablesManagercomponent; it auto-registers withMessageRouter. (The README variant suggests attaching it to the sameFlutterBridgeGameObject.)
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/withcontent_manifest.json. - Replace the
{BASE_URL}placeholder incontent_manifest.jsonwith your CDN URL. - Upload all bundle files and
content_manifest.json, then attachFlutterAssetBundleManagerto 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>/*.bundlehttps://cdn.example.com/bundles/*.bundleEvery built content bundle.
content_manifest.jsonhttps://cdn.example.com/bundles/content_manifest.jsonThis URL is the manifestUrl you pass to StreamingController.
catalog_*.bin (Addressables only)https://cdn.example.com/bundles/catalog_*.binReferenced 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:
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 memoryCache & 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 defaultStreamingController._downloadBundlepath. It writes the bytes and storeshash = 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. Ifsha256Hashis provided, the computed hash is verified after the stream completes; on mismatch the written file is deleted and it throwsStateError('SHA256 mismatch: expected $sha256Hash, got $computedHash').verifyCache()— an explicit integrity pass over every cached bundle. It recomputessha256.convert(bytes)per file and returns the names of bundles that are missing from disk or whose hash does not match the manifest entry (loggingCache 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).
// 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:
| Type | Message | When |
|---|---|---|
| initializationFailed | Initialization failed: $e | Manifest fetch or cache init threw during initialize(). |
| bundleNotFound | Bundle not found: $name | loadBundle was called with a name absent from the manifest. |
| downloadFailed | Download failed: $name | A bundle download threw on the default StreamingController path. |
Errors from Unity
Unity → Flutter errors arrive as ErrorResponse {callbackId, error, errorType}:
| error | errorType |
|---|---|
| FlutterAddressablesManager not initialized. Call SetCachePath first. | not_initialized |
| Addressables package not installed | not_installed |
| Catalog URL cannot be null or empty | invalid_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:
| Approach | Asset loading | iOS safe | Android safe | Complexity | Best for |
|---|---|---|---|---|---|
| Scene Loading | Built-in | Yes | Yes | Low | Switching game contexts |
| AssetBundles | Yes | Yes | Yes | High | Legacy projects, full control |
| Addressables + CCD | Yes | Yes | Yes | Medium | Primary 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.
