Unity Addressables на практике: как структурировать ассеты без ада
Addressables я подключал на трёх проектах разного масштаба: маленький мобильный F2P, средний ПК-инди и большой live-service на iOS+Android. Каждый раз через два-три месяца после внедрения я смотрел на структуру ассетов и думал «надо переделать». Получилось это сделать только на третьем проекте, и то не идеально. Расскажу, какие решения окупаются, какие — мина замедленного действия, и как настроить Addressables в Unity 6, чтобы потом не плакать.
Ориентируюсь на Addressables 2.x в Unity 6 (6000.x). Если у тебя 1.21 — основные понятия те же, но часть UI и API могут отличаться.
Зачем вообще Addressables, если есть Resources и AssetBundles
Если коротко: Resources — мина для производительности (всё содержимое грузится в память при первом обращении и сериализуется в одну большую кучу), AssetBundles — мощно, но ручной труд по 70%, со своим менеджером загрузок, версионированием и инвалидацией кэша. Addressables это, по сути, надстройка над AssetBundles с человеческим лицом: ты помечаешь ассет «адресом», Unity сама собирает бандлы, ты грузишь по адресу.
Что даёт это на практике:
- Динамическая загрузка по строке-адресу:
Addressables.LoadAssetAsync<GameObject>("Enemies/Goblin"). - Деплой ассетов отдельно от билда (CDN, OTA-обновления).
- Зависимости считаются автоматически: если префаб ссылается на материал, материал упакуется в нужный бандл.
- Профилирование загрузки и памяти из коробки.
Минус — кривая обучения. Первый раз настраиваешь Groups, Profiles, Build Pipelines, Catalogs — и ничего не работает. Это типично, нужно один раз пройти.
Группы: как разбивать ассеты
Группы (Groups) — это, по сути, рецепт сборки бандла. У каждой группы свой Schema: что собирать, куда складывать, как сжимать.
Распространённый антипаттерн: одна группа на всё. Получаешь один гигантский бандл, при загрузке любого ассета подтягивается весь, и over-the-air обновления идут на 200 мегабайт.
Другой антипаттерн: группа на каждый ассет. Получаешь сотни мелких бандлов, overhead на запросы CDN, медленный старт.
Что работает у меня:
- Local_Bootstrap — всё, что нужно сразу при запуске: лого, начальная сцена, базовый UI, небольшой набор шрифтов. Build & Load Path:
Local. - Local_Common — ассеты, используемые во всём проекте: общие шейдеры, материалы, иконки. Тоже Local.
- Remote_Levels_* — отдельная группа на каждый уровень или мир. Build Path:
Remote, грузится с CDN по требованию. - Remote_Characters — герои/враги. Если их много, разбиваю по фракциям:
Remote_Characters_Player,Remote_Characters_Enemies_Forest,Remote_Characters_Enemies_City. - Remote_UI_Skins — UI-темы, скины кнопок и панелей. Часто меняются маркетингом, удобно отдельно.
Идея простая: группа объединяет ассеты, которые загружаются вместе и обновляются вместе. Если игрок зашёл в локацию «лес» — он скачивает Remote_Levels_Forest + Remote_Characters_Enemies_Forest. Ничего лишнего.
Profiles: для разных окружений
Profiles — отдельная штука, которую часто игнорируют. Это набор переменных для путей: BuildTarget, RemoteBuildPath, RemoteLoadPath.
У меня минимум три профиля:
- Default — для разработчика на локальной машине. Remote =
http://localhost:8080, серверим бандлы простым Python http.server. - Staging — Remote =
https://stage-cdn.mygame.com/[BuildTarget]. - Production — Remote =
https://cdn.mygame.com/[BuildTarget].
Между ними переключаешься в Addressables Settings. Это спасает от классического бага «забыл переключить путь, отправил билд в ревью с staging-CDN».
Загрузка по адресу: коротко и больно
Базовый паттерн загрузки:
public class EnemyFactory : MonoBehaviour
{
[SerializeField] private AssetReferenceGameObject _goblinReference;
public async Awaitable<GameObject> SpawnGoblin(Vector3 at)
{
var handle = _goblinReference.LoadAssetAsync<GameObject>();
var prefab = await handle.Task;
return Instantiate(prefab, at, Quaternion.identity);
}
}
Несколько подводных камней:
Не теряй handle
Когда ты вызываешь LoadAssetAsync, возвращается AsyncOperationHandle. Если ты его не сохранил, не сможешь сделать Release и получишь утечку памяти.
private AsyncOperationHandle<GameObject> _goblinHandle;
public async Awaitable Init()
{
_goblinHandle = _goblinReference.LoadAssetAsync<GameObject>();
await _goblinHandle.Task;
}
private void OnDestroy()
{
if (_goblinHandle.IsValid())
Addressables.Release(_goblinHandle);
}
InstantiateAsync vs LoadAsset + Instantiate
У Addressables есть InstantiateAsync, который возвращает уже заинстанцированный объект. Удобно, но если ты хочешь спавнить десять врагов, это десять загрузок (с кэшем — десять обращений к менеджеру). Лучше один раз LoadAssetAsync, потом обычный Instantiate в цикле.
ReleaseInstance, не Destroy
Если ты использовал InstantiateAsync, удалять надо через Addressables.ReleaseInstance(go), а не Destroy. Иначе счётчик ссылок Addressables не обнулится, бандл не выгрузится.
AssetReference vs строки-адреса
Вопрос, который встаёт сразу: использовать AssetReference поля в инспекторе или грузить по строке?
AssetReference:
- Тип проверяется компилятором. Не загрузишь Texture2D как GameObject случайно.
- В инспекторе видишь, на что ссылка. Удобно искать через Find References.
- Связи между сценами и префабами — нативные.
Строки:
- Гибкость: можно собрать адрес динамически (например,
$"Enemies/{enemyType}/{level}"). - Удобно для data-driven: ScriptableObject с конфигом, а в нём строки.
- Минус: опечатки ловишь только в рантайме.
Я обычно делаю гибрид: системные ссылки (UI, главный персонаж) — через AssetReference, контентные (враги, лут, генерируемые объекты) — через строки. Со временем в проекте появляется свой helper:
public static class AddressableKeys
{
public static string Enemy(string type, int level) => $"Enemies/{type}/Lvl{level}";
public static string Item(string id) => $"Items/{id}";
}
Хотя бы опечатки в одном месте.
Сборка бандлов: что конфигурировать
В Unity 6 в группах есть Schema — Content Packing & Loading. Главные параметры:
- Bundle Mode. Pack Together — все ассеты группы в один бандл. Pack Separately — каждый ассет в свой бандл. Pack Together by Label — по лейблу. Для контентных групп почти всегда Pack Together.
- Compression. LZ4 — быстро декомпрессируется, средний размер. LZMA — лучшее сжатие, но медленнее. Для мобилок я ставлю LZ4 на горячее (бутстрап) и LZMA на редкое (DLC, новые сезоны).
- Bundle Naming Mode. Лучше Filename (без хеша). Тогда обновлять можно без полной пересборки каталога — это критично для OTA.
Build Pipeline почти всегда — Default Build Script. Кастомные пайплайны нужны редко: для собственных шифрований бандлов или интеграции с CI.
OTA-обновления: где обычно ломается
Главная фишка Addressables — отдельный билд ассетов от билда приложения. Игроки качают новый контент через CDN без захода в стор. На бумаге.
На практике натыкаешься на:
Mismatch версии каталога и приложения
Если в новой версии ассета изменилось API скрипта, который этот ассет использует, старое приложение не сможет инстанцировать его. Решение: ставить Player Version Override в Addressables Settings и проверять совместимость в рантайме перед загрузкой.
Обновление без Update a Previous Build
Кнопка Update a Previous Build (а не New Build → Default Build Script) — обязательна для OTA. Она пересобирает только изменившиеся бандлы и патчит каталог. Если жмёшь New Build, у пользователей все хеши изменились, нужно скачать всё заново.
Кэш CDN
CloudFront, Cloudflare, любой CDN кэширует catalog.json и catalog.bin. Каждый раз после деплоя нужно делать invalidate. Я однажды три часа искал баг, пока понял, что CDN раздаёт старый каталог, хотя в S3 уже новый.
Профилирование: что смотреть
В Window → Asset Management → Addressables → Event Viewer показывает, что грузится, когда, и как меняется reference count. Полезно прогонять перед релизом — видно, что-то висит загруженным дольше нужного.
Memory Profiler — другой инструмент, показывает память по бандлам. Если видишь, что после смены сцены бандл всё ещё в памяти — кто-то держит ссылку. Часто это статические менеджеры с кэшем префабов.
Build Layout Report (можно включить в настройках) — html-отчёт после билда, показывает, что в каждый бандл попало. Спасает от «ой, у меня в Local_Bootstrap половина игры, потому что один префаб случайно ссылается на ScriptableObject, который ссылается на огромный массив».
Типичные ошибки и как их ловить
Несколько граблей, на которые наступал я и видел у других:
- Цикл в зависимостях. Префаб A ссылается на префаб B, B на C, C на A. Addressables соберёт, но при загрузке любого из них поднимет всех троих. Если один из них тяжёлый — неприятный сюрприз.
- Дубликаты материалов. Build Layout показывает, как один материал попал в три разных бандла. Это значит, что три ассета его используют, и материал не вынесен в отдельную группу. Кладёшь в
Local_CommonилиRemote_Shared_Materials. - Сцены в Addressables vs Build Settings. Сцена, помеченная Addressable, не должна быть в Build Settings (галка в Build Settings). Иначе попадёт и туда, и в бандл.
- Сериализованные ссылки на не-Addressable объекты. Если в Addressable префабе поле ссылается на ассет, не помеченный как Addressable, — он автоматически попадает в бандл этого префаба. Может неожиданно раздуть бандл.
Что почитать и что попробовать
Начни с официального гайда: документация Addressables в Unity Manual + туториал на Learn. Дальше — серия статей Ryan Hipple про дизайн ассет-структур (он сейчас в Unity, был на консультациях у нескольких студий).
Если уже работаешь — попробуй один эксперимент. Сделай минимальный проект: одна сцена, один префаб, один материал. Помечай Addressable, выноси в группу, билдишь, грузишь по строке. Прогоняешь Event Viewer и Build Layout Report. Через час понимаешь поток данных лучше, чем после трёх статей.
Главное правило, к которому я в итоге пришёл: Addressables — это инструмент для проектов, где у тебя есть структура контента и понимание, что и когда грузится. На прототипах из 50 префабов это оверкилл, и обычные ссылки работают быстрее в разработке. На проекте с тысячами ассетов и OTA — без Addressables (или эквивалента) ты не справишься.