Альтернативы CDN: наша система доставки контента напрямую из стора в игру

в 8:00, , рубрики: CDN, dynamic delivery, play asset delivery, Блог компании Pixonic, разработка игр, Разработка под android, разработка под iOS, Сетевые технологии
Альтернативы CDN: наша система доставки контента напрямую из стора в игру - 1

War Robots всегда была игрой, насыщенной контентом, а потому места в памяти девайсов занимала немало. Раньше эти цифры колебались в районе 800 МБ, но масштабная переработка графики в ремастере игры, как бы ни хотелось, не смогла бы оставаться в тех же пределах и наверняка бы привела к увеличению размера приложения в несколько раз. Что и случилось в нашем случае: вес клиента достиг 2,3 ГБ — в три раза больше «ванильной» версии. В то же время мы понимали, что HD-пресет потянет далеко не каждый мобильный телефон, а значит — нет смысла заставлять игроков качать ненужные гигабайты и тем самым ломать себе воронку конверсий.

Выход? Базовое качество скачивать по умолчанию и выбирать при первом запуске, а HD-пресет предлагать пользователю позже — и только если устройство его поддерживает.

Все это подстегнуло нас на активное внедрение такой технологии в нашу игру.

Толчок для старта разработки

Два года назад Google презентовала Dynamic Feature — технологию, позволяющую устанавливать части приложения по требованию прямо во время работы. Благодаря этому пользователи могут изначально скачивать «легкие» билды приложений, а необходимые модули подгружать по мере необходимости — например, языковые пакеты и нативные архитектурно-зависимые библиотеки процессоров, которые выбираются автоматически при установке базовой части приложения.

Альтернативы CDN: наша система доставки контента напрямую из стора в игру - 2

Загрузка происходит системным сервисом Google Play, а потому не прекращается при остановке приложения. Это выгодно отличает ее от привычного и уже традиционного подхода CDN, когда скачивание происходит через http-клиент приложения. И все это звучало бы хорошо, если бы не «но»: если нужно скачать модуль размером более чем 150 МБ, всплывет системное окно с запросом на разрешение скачивания.

Альтернативы CDN: наша система доставки контента напрямую из стора в игру - 3

Как многих из новых пользователей это не испугает? Вот и нам тоже не хотелось проверять, как это может сказаться на метриках первой игровой сессии.

Годом позже Google анонсировала Play Asset Delivery. Это вариант Dynamic Feature, но без кода — только с файлами данных и без пугающего системного диалога перед стартом загрузки. Что-то подобное нам как раз и было нужно. К этому моменту мы уже начали разработку ремастера War Robots, так что необходимость загружать игровые ассеты не при установке игры, а позже, встала особенно остро.

Google предоставляла свой Unity-пакет для поддержки Play Asset Delivery, но у него хватало ограничений, так что пайплайн сборки подружить с тем, что уже было у нас, оказалось тяжело. На тот момент пакет предоставлял хранение только одного бандла на ассет-пак и сам же загружал его в память. Впоследствии появилась возможность запаковывать несколько бандлов из папки и контролировать их загрузку — но появилась она позже, чем нам того требовалось. К тому же, War Robots выходит не только на Android, но и на iOS, так что нужна была какая-то обертка, которая бы абстрагировала игровой код от платформы и стора, выдавая на выходе единое API. Это позволило бы в дальнейшем безболезненно использовать такую систему доставки не только на War Robots, но и на других наших проектах.

На iOS уже была система On-Demand Resources — в других же сторах без поддержки доставки контента можно было скачивать данные только через CDN. Еще мы хотели избежать дополнительных аллокаций и иметь возможность загружать бандлы напрямую без буфера данных, чего Unity обеспечить не могла.

Что у нас в итоге получилось и с какими проблемами мы столкнулись, читайте далее.

Разновидности доставки контента

Рассмотрим типы ассет-пакетов по доставке контента. На Android основные типы — это:

  • install time — установка вместе с основной частью приложения;

  • fast follow — установка сразу после основной части, но отдельным прогрессом с возможностью запуска игры до окончания загрузки;

  • on demand — установка по запросу из кода.

На iOS их по сути тоже три: on demand, initial install и prefetched. Фактически, это та же логика распространения, но просто с другими названиями.

Подытожим это в таблице:

Альтернативы CDN: наша система доставки контента напрямую из стора в игру - 4

Для себя мы выбрали более привычные Android-названия и сделали удобное окно в редакторе Unity, в котором можно указать имена пакетов, путь к папке с файлами и тип доставки.

Что касается fast follow — несмотря на то, что такие пакеты ставятся сразу после установки приложения автоматически, перед обращением к их ресурсам надо проверить, что установка прошла успешно. И, возможно, придется показать процесс закачки, если окажется, что данные еще не успели докачаться до конца.

Так выглядит окно настройки пакетов с ассетами (в нашем случае — с бандлами) из редактора Unity:

Альтернативы CDN: наша система доставки контента напрямую из стора в игру - 5

Это универсальный интерфейс под обе платформы. Мы добавили еще четвертый тип доставки — CDN only. Этот пакет не выкладывается в стор и скачивается напрямую с CDN. Другие общие для всех пакетов параметры мы рассмотрим позже.

Краткое описание системы

Прежде, чем перейти к описанию реализации в коде, вкратце пройдемся по тому, как система работает в целом:

Альтернативы CDN: наша система доставки контента напрямую из стора в игру - 6

Что мы видим на этой схеме:

  • Small build (APK/IPA) – маленький билд в сторе, размер которого не превышает 150 МБ;

  • Store Pack — набор контента (в нашем случае — пресеты качества графики), которые также выкладываются в стор, но отдельно от основного приложения;

  • Pack — набор бандлов, используемых Unity; обычно они объединены в архив;

  • CDN — удаленный сервер, на котором хранятся дубликаты наших пресетов графики.

Работает это следующим образом: «тонкий» билд игры при помощи Quality Manager распознает, что девайс игрока поддерживает, например, ULD-пресет, после чего обращается к стору и запрашивает нужный пак для скачивания. Если во время загрузки пресета происходит ошибка, запрос перенаправляется к CDN. Или же билд может вообще не запрашивать паки у стора и сразу обращаться к CDN за нужным контентом.

Проблема CDN в том что, он предоставляется различными провайдерами за плату, и если все игроки будут скачивать контент только оттуда, это окажется крайне дорогим решением для студии. Возможность скачивания пака со стора, в свою очередь, предоставляется платформами бесплатно.

Реализация на Android

В первую очередь для упаковки файлов в ассет-пак необходимо в экспортируемом Gradle проекте создать subproject — папку со следующим содержанием:

  • src/main/assets, куда складываются нужные нам файлы ассетов;

  • файл build.gradle в корне со следующим содержанием:

 apply plugin: 'com.android.asset-pack'
assetPack {
    packName ="asset_pack_name"
    dynamicDelivery {
        deliveryType = "[ install-time | fast-follow | on-demand ]"
    }
}

Кроме того, имя пака надо добавить в settings.gradle:

include ‘:asset_pack_name’

и в build.gradle основного приложения:

android {
assetPacks = [':asset_pack_name’]
}

Чтобы собрать бандл вместо APK, нужно вместо старых gradle тасков assembleDebug и assembleRelease выполнить новые:

./gradlew bundleRelease (или bundleDebug)

Установка происходит через AssetPackManager из пакета play-core. Пакет включается добавлением зависимости в build.gradle:

dependencies {
implementation 'com.google.android.play:core:1.9.1'
}

Для Unity мы пишем java-обертку с необходимыми вызовами API. Максимально упрощенно код выглядит так:

AssetPackManager assetPackManager = AssetPackManagerFactory.getInstance(UnityPlayer.currentActivity..getApplicationContext());
assetPackManager.registerListener(myStateUpdateListener);
List<String> assetPacks = Collections.singletonList("asset_pack_name");
assetPackManager.fetch(assetPacks).addOnCompleteListener(onCompleteListener);

Подробно с этим API можно ознакомиться на странице документации. Тут важно уточнить, что мы не используем никакую C++ или Unity-обертку, которые там предлагают. Java API позволяет нам отслеживать и прогресс закачки, и результат: все события мы отсылаем простым колл-бэком через Unity JNI.

Результат закачки — местоположение файлов — тоже можно передать строками и обращаться к ним стандартными средствами Unity.

Важный нюанс: путь распаковки файлов зависит от deliveryType пакета:

  • fast-follow и on-demand пакеты распаковываются во внутреннее хранилище Android — нам же возвращается абсолютный путь к этой папке, и файлы оттуда можно читать через стандартный шарповый System.IO либо передавать на вход AssetBundle.LoadFromFile(path), если это бандл Unity;

  • содержимое install time пакета складывается в assets напрямую в APK — а значит, его можно читать через Application.streamingAssetsPath. Этот путь по сути является магической строкой, заставляющей Unity обрабатывать такие пути внутри себя через андроидовский AssetManager. В этом случае бандл можно загрузить из файла путем вызова AssetBundle.LoadFromFile и тем самым избежать необходимости создавать поток данных с рандомным доступом, не поддерживаемый нативными API, а потому требующий выделения дополнительной памяти.

Получившийся код java-плагина:

public string getPackLocation(String packName, String streamingAssetsPath) {
AssetPackLocation location = assetPackManager.getPackLocation(packName); 
if (location == null) // пакет не стоит
	return null; 
return location.packStorageMethod() == AssetPackStorageMethod.STORAGE_FILES ? location.assetsPath() : streamingAssetsPath

И код загрузки бандла на Unity:

var bundle = AssetBundle.LoadFromFile(Path.Combine(packLocation, relativePath));

Заметим, что сейчас уже появился механизм API-configured asset packs в Unity-плагине, который также позволяет получать просто путь к файлу бандла, offset, размер и загружать его через AssetBundle.LoadFromFile. Возможно, с ним бы было проще, но в наше время его еще не было.

Реализация на iOS

Поддержка on-demand resources (ODR) уже встроена в редактор Unity — об этом подробнее можно почитать здесь. Но если процесс сборки оттуда получилось адаптировать под наш пайплайн, то с runtime частью все оказалось не так-то просто. В ней не было отчета о прогрессе загрузки, и все делалось через механизм coroutines. Поэтому мы сами написали обертку на ObjectiveC и получили API, аналогичный Android.

Сравнивая API двух платформ и наши изначальные требования к системе доставки, мы обнаружили еще одну проблему. Дело в том, что в случае iOS ресурс-пакет загружен и готов к использованию только тогда, когда держится ссылка на соответствующий NSBundleResourceRequest. Иными словами: запросил пакет, асинхронно получил ответ о готовности пакета, вытащил из него все необходимые ресурсы, отпустил обратно. Дальше iOS не гарантирует уничтожение пакета при нехватке места, как и не гарантирует, что следующее обращение не вызовет новую закачку из сети. Нам все это не подходило, ведь вытащить любой ресурс в пределах пресета нам могло понадобиться в любой момент времени. Поэтому пришлось держать ссылку в статической переменной в пределах всей игровой сессии или до ближайшей смены качества.

Получившийся код упрощенно можно представить в следующем виде:

static NSMutableArray<NSBundleResourceRequest*>* _DeliverySystemResourceRequests;
void LoadAssetPack(NSString* assetPack, void (^requestCompleted)(BOOL completed))
{
NSBundleResourceRequest* resourceRequest = [[NSBundleResourceRequest alloc] initWithTags:[NSSet setWithObject:@”asset_pack_name”]];
[resourceRequest conditionallyBeginAccessingResourcesWithCompletionHandler:^(BOOL resourcesAvailable) {
		if(resourcesAvailable) {
			// ресурс уже скачан на устройство, можно использовать
			// держим ссылку на ресурс
			[_DeliverySystemResourceRequests addObject:resourceRequest];
			requestCompleted(@YES);
		} else {
			[resourceRequest beginAccessingResourcesWithCompletionHandler:^(NSError * _Nullable error) {
				if(error == nil) {
					[_DeliverySystemResourceRequests addObject:resourceRequest];
					requestCompleted(@YES);
				} else {
					requestCompleted(@NO);
				}
			}];
		}
	}];
}

Если ресурс-пакет скачался, и ссылка на него держится, сам ресурс можно загрузить стандартными средствами Unity, используя специальный префикс "res://" в пути:

var bundle = AssetBundle.LoadFromFile( "res://" + relativePath);

По сути, это тот же вызов, что и на Android, но при этом все ресурс-паки лежат по пути "res:/". Поэтому в таком виде мы отдаем пути к ресурс-пакам другой нашей системе — ресурсной, а она независимо от платформы уже хранит у себя информацию, какой префикс к пути добавить в AssetBundle.LoadFromFile, когда потребуется поднять тот или иной бандл в памяти. Так мы разделили экраны загрузки ресурсов из интернета, которые происходят обычно при запуске игры или при смене качества графики, и экраны загрузки сцен, происходящие между боями и при выходе в ангар.

Поумолчанию ODR использует доставку on demand. Чтобы изменить тип доставки, нужно его тэг прописать в build property XCode проекта через пробел по ключу ON_DEMAND_RESOURCES_INITIAL_INSTALL_TAGS для Initial Install и ON_DEMAND_RESOURCES_PREFETCH_ORDER для Prefetched, соответственно. Все ресурсы, распределенные по пакетам, перечислены во вкладке Resource Tags в Xcode.

Альтернативы CDN: наша система доставки контента напрямую из стора в игру - 7

Так как Delivery System у нас не используется непосредственно игрой, а общается с другой системой — ресурсной, мы решили не использовать понятие ассет-пака внутри игры при формировании интерфейсов взаимодействия с системой. Ресурсная система запрашивает только список ассетов, которые ей нужны. Delivery System, в свою очередь, при сборке формирует манифест, в котором указывает, какие ассеты в какие пакеты попали, и по этому манифесту смотрит, каких ассетов не хватает, в каких пакетах они лежат, какие пакеты скачаны, а какие надо скачать, и на основе этого формирует список пакетов для запроса из стора. После скачивания дополнительно проверяются хеши файлов, и результат скачивания с указанием места хранения файлов (префиксы res://, streaminAssetsPath или абсолютные пути) отдается обратно в ресурсную систему. Также существует запрос без непосредственно скачивания с целью подсчитать объем данных, необходимых для скачивания и для хранения на диске, чтобы показать нужные диалоги пользователю перед скачиванием.

Трудности тестирования

Google предлагает несколько способов тестирования: закрытое и открытое бета-тестирование, internal app sharing и локальная установка на девайс с использованием bundle tool.

Бета-тест очень удобен для массового тестирования на внешнюю аудиторию, но у нас стояла необходимость быстрой и удобной установки большого количества билдов на устройства внутреннего QA, и это требовалось не только для тестирования Delivery System. Как оказалось, с выходом HD-качества сборки больше 2 ГБ требовали уже 2 OBB-файла, а если собирать все в одну большую APK, многие не сильно мощные устройства просто не могли поставить ее даже при наличии свободного места с запасом.

У нас сейчас используется специальная служебная программа-установщик PixLauncher, которая ставит сборки прямо с TeamCity без необходимости подключения к компьютеру. Мы захотели подружить PixLauncher и bundle tool, и для этого стали смотреть, что делает последний.

Первой командой он вытаскивает из bundle все объявленные части приложений и собирает их в виде самостоятельных APK:

bundletool build-apks --bundle=./имя_файла.aab --output=./имя_файла.apks 
--ks=путь_до_файла.jks --ks-pass=pass:пароль --ks-key-alias=алиас 
--key-pass=pass:пароль --local-testing

Apks — zip-архив с этими APK. Базовую часть с именем standalone.apk он просто устанавливает (туда же попадают install time пакеты), а ассет-паки из папки asset-slices копирует на устройство по пути Android/data/{packagename}/files/local_testing/. Все это осуществляется при подключенном устройстве командой:

bundletool install-apks --apks=имя_файла.apks

Чтобы избавиться от необходимости подключать устройство к компьютеру и устанавливать все через PixLauncher «по воздуху», первую команду мы выполняли на TeamCity, затем разархивировали apks-файл, и отдавали в PixLauncher отдельные APK: standalone.apk он просто ставил, а asset-slices копировал в папку Android/data/{packagename}/files/local_testing/ непосредственно на устройстве после установки.

Все заработало, но ненадолго.

Оказалось, если min sdk в манифесте равно 21 и выше, standalone.apk не создается. Чтобы он создавался, необходимо добавить флаг --mode=universal:

bundletool build-apks --bundle=./имя_файла.aab --output=./имя_файла.apks 
--ks=путь_до_файла.jks --ks-pass=pass:пароль --ks-key-alias=алиас 
--key-pass=pass:пароль --local-testing --mode=universal

Флаг --local-testing обязателен: он как-то отмечается в standalone.apk, что при запуске игры PlayCore API запускает тестовый режим, начинает смотреть в local_testing и не лезет сразу в стор. Такой командой можно получить standalone.apk, но не создаются ассет-паки. Поэтому команду приходится запускать повторно без этих флагов --mode и --local-testing, извлекая оттуда asset-slices, и уже тогда получить полный набор apk для установки и локального тестирования.

На iOS все проще: по умолчанию ODR включается в ipa-файл, и без каких-то манипуляций мы получаем локальное тестирование доставки контента. При выливке в TestFlight можно полноценно проверить механизм доставки с поддержкой initial install и prefetched.

После того, как Delivery System пришла к своему конечному виду, а процесс ее тестирования налажен, мы запустили массовое внешнее тестирование.

И тогда стали всплывать пикантные подробности, которые заставили нас полностью пересмотреть принцип работы Delivery System.

Проблемы и их решения

Первые проблемы появились на iOS. При использовании install time пакетов и установки с TestFlight в некоторых случаях ассеты из пакета не находятся. Самый точный метод воспроизведения этого бага — при начале установки с TestFlight поставить установку на паузу, нажав на ярлык приложения, а потом продолжить, нажав еще раз. Игра довольно быстро финиширует установку, и на старте говорит, что ассет-пак уже стоит, но файлы из него найти не удается. Так как в App Store нет такого ограничения на размер скачиваемой базовой части приложения, как в Google Play, пользу install time пакеты нам особую не дают, и мы просто решили отказаться от их использования, включая такие ассеты напрямую в билд.

С on demand у нас таких проблем не было — зато возникли другие. При монтировании нескольких пакетов ассетов и удержании на них ссылки, когда суммарно общее количество обращений на чтение ассетов из них достигало района 1 ГБ, оперативная память начинала резко утекать и довольно быстро приводила к крешу Out of Memory. То есть, в обычной ситуации расход ее незначителен (в районе десятков МБ), а при достижении порога — подскакивает, что видно на графике:

Альтернативы CDN: наша система доставки контента напрямую из стора в игру - 8

Достойного решения мы не нашли — Apple вообще рекомендует не использовать пакеты больше 50 МБ. В результате использовали наш запасной план: при монтировании пакета мы извлекаем все его содержимое в Application.persistentDataPath, размонтируем (отпускаем ссылку) и помечаем на удаление. Вот вызов извлечения данных из пакета на ObjectiveC:

NSDataAsset* dataAsset = [[NSDataAsset alloc] initWithName:assetName];
NSData* data = dataAsset.data;

И да, все верно: список файлов в пакете надо иметь при себе: Apple не сделала простой листинг содержимого.

Размер пакетов ассетов мы тоже сделали поменьше — 200 МБ вместо ограничения магазина в 500 МБ. Пакеты больше заданного размера у нас просто делятся на части.

Очевидный минус такого решения — нужно больше места на диске (сам процесс копирования занимает секунды, так что им можно пренебречь). К тому же, пакеты хоть и помечаются на удаление, но не удаляются сразу. По опыту — они вообще не удаляются, пока приложение запущено. Поэтому нужно закладывать х2 места, что в некоторых случаях может быть критично.

Чтобы как-то улучшить ситуацию, мы добавили компрессию складываемых в пакет ассетов. Алгоритм использовали Z-Std: он дает хорошие показатели компрессии и скорости распаковки.

Время поджимало, и мы в таком виде отправили сборку на внешнее тестирование.

По его итогам оказалось, у пользователей действительно основные проблемы связаны с сетью и нехваткой места. На Android иногда еще досаждала недоступность магазина или отсутствие поддержки play asset delivery в принципе. Для таких случаев мы застраховали себя переключением на дозакачивание с CDN: для пользователя это внешне никак не заметно, переключение происходит автоматически при возникновении ошибки. Поскольку таких устройств довольно мало, мы все равно выигрываем значительным снижением трафика на CDN.

Как мы выяснили, в среднем около 20% пользователей не дожидаются окончания загрузки и закрывают игру. Впрочем, приблизительно на столько же к нам пришло игроков после клика на ссылку в рекламе, так что конверсия в запуск первого боя почти не изменилась. А это значит, что запуск Delivery System можно считать успешным.

Подводя итоги

  • Если магазин бесплатно предлагает сервис по доставке контента — используйте его. Это выгодно как издателю, который таким образом может сэкономить трафик, так и пользователю, потому что данные скачиваются из одного места привычным образом. В целом что Google Play Asset Delivery, что On-Demand Resources ведут себя довольно стабильно и работают, как должны. Однако, если не хочется терять процент игроков, у которых возникают сложности, лучше все же предусмотреть запасной вариант в виде CDN: с ним количество фатальных ошибок, мешающих загрузить игру, оказывается минимальным.

  • Если не нравится Unity-обертка для какого-то API, предоставляемого платформой (или самим Unity) в виду каких-то ограничений или недостатков, не нужно бояться писать свою реализацию — ведь нативные API, написанные на Java, Kotlin, ObjectiveC или Swift, обычно более функциональны и продуманы. Правда, есть исключения: листинга содержимого пакета ODR нет ни в Unity, ни в ObjectiveC, и добавить его никак нельзя, поэтому обходные пути все равно приходится искать.

  • И самое главное: маленький вес приложения в сторе с дозакачкой контента — это хорошо. Пользователи охотнее запускают приложение, и дальше все уже в наших руках: можно показывать видео или интригующие скриншоты, или же давать вводную текстовую информацию на экране загрузки, пока происходит скачивание игровых ассетов. Сам объем данных тоже можно оптимизировать, чтобы как можно быстрее отправить нового игрока в первый бой. Вариантов для развития много, и мы обязательно будем пробовать и экспериментировать в поисках лучшего решения.

Автор: Андрей Грузинов

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js