Введение
Сегодня я хочу познакомить вас с плагином для Unity, который позволяет обновлять код игры, не загружая обновления в стор. Работает через модификацию il2cpp, превращая его в подобие Mono.
На рынке есть множество hot update решений, но все они имеют либо ограничения на взаимодействие hot update и AOT кода, либо могут обновлять только часть кода через атрибуты и через другой разного рода колхоз.
Как заявляют разработчики:
HybridCLR — это нативное решение для hot update C# кода. Проще говоря скомпилированный il2cpp код это эквивалентен aot модулю в mono, а HybridCLR эквивалентен интерпретатору, а их комбинация становится full mono. HybridCLR делает il2cpp полнофункциональной средой выполнения, изначально (то есть через System.Reflection.Assembly.Load) позволяющий динамически загружать dll, тем самым делая возможным hot update на ios.
Из-за того, что HybridCLR реализован на уровне native runtime level, типы из hot update библиотек и типы из AOT кода эквивалентны и бесшовно унифицированы. Вы можете вызывать, наследовать, пользоваться рефлексией и многопоточностью, без генерации кода или написания адаптеров.
Другие решения для hot update являются независимыми vm, и связь с il2cpp по сути эквивалентна связи встраивания lua в mono. Следовательно, система типов не является единообразной. Чтобы разрешить типу горячего обновления наследовать некоторые типы AOT, необходимо написать адаптер, а тип в интерпретаторе не может быть распознан системой типов основного проекта. Неполные функции, проблемная разработка и низкая эффективность работы.
Как заявляют разработчики:
HybridCLR очень популярен в Китае, на данный момент как минимум сотни игр используют HybridCLR, все они выложены в App Store и Google Play.
В основе HybridCLR прежнему лежат интерпретация и исполнение, и с этой точки зрения этот подход ничем не отличается от интеграцию интерпретатора lua в Unity. Поэтому он соответствует требованиям App Store и Google Play Store, и нет особого риска отклонения. А из-за высокой интеграции HybridCLR и il2cpp, он даже намного безопаснее схемы lua, отсюда вероятность отклонения игры из-за несоответствия правилам площадки очень низкая.
HybridCLR делает следующее:
-
Реализация эффективной библиотеки анализа метаданных (dll)
-
Изменения в модуль управления метаданными для реализации динамической регистрации метаданных
-
Реализация компилятора из набора инструкций IL в набор инструкций кастомных регистров
-
Реализация эффективного register interpreter
-
Предоставление большого количества инстинктивных функций для повышения производительности интерпретатора
Из интересного:
-
hot update dots кода, но нужно ставить их форк плагина. Юнитехи сделали раннюю инициализацию TypeManager без возможность вызвать ее вручную, ребятам пришлось дописывать самим. Пока не завезли поддержку Burst, но Jobs работает и обновляется
-
hot update асинхронного кода
-
zero learning и zero usage costs
Установка
https://hybridclr.doc.code-philosophy.com/en/docs/beginner/quickstart
Устанавливаем плагин через Package Manager:
Далее инициализируем плагин:
Настройка
В качестве примера возьму свой некро проект 2019 года:
В Unity весь код, который пишет разработчик и который не находится в других Assembly Definition находится в Assembly-CSharp. Код проекта должен быть разделен на сборку AOT (то есть скомпилированную в основной пакет игры) и сборку hot update. В HybridCLR нет никаких ограничений на то, как разделить сборку, и даже код в third-party project может быть использован в качестве hot update. Когда игра запускается, как минимум одна сборка AOT должна отвечать за работу, связанную с запуском hot update кода. Есть 2 способа настройки пользовательских AOT Assembly и Hot Update кода, зависящие от текущего сетапа вашего проекта:
-
Assembly-CSharp как AOT точка входа. Остальная часть кода сама разделена на N сборок AOT и M сборок hot update.
-
Assembly-CSharp как hot update сборка. Остальная часть кода сама разделена на N сборок AOT и M сборок hot update.
В примере создадим одну Assembly Definition Maikn, а hot update код будет в Assembly-CSharp, после чего будет загружаться сцена или префаб, на которой присутствует hot update код с точкой входа.
В качестве системы управления ресурсами и загрузки ассетов можно использовать что угодно: хоть голые AssetBundles, хоть Addressables, хоть свою систему управления ассетами с загрузкой со своего сервера.
В данном примере буду использовать Addressables, как самый простой вариант по интеграции и настройки, не занимающий много времени (чаще всего это неправда, конечно же).
Важное уточнение: если часть кода уносится в hot update, не должно быть никаких явных ссылок между AOT частью и hot update частью. Тут уже либо у вас в проекте четкое разбиение на зоны ответственности по сценам/префабам/assembly definition, либо весь ваш проект находится hot update и загружается с точки входа, содержащей только AOT код.
В примере Assembly-CSharp будет использоваться как AOT точка входа с загрузкой и инициализацией hot update часть.
Для этого создадим Main Assembly Definition и перетащим в нее весь код:
Далее создадим Main assembly и перетащим весь код внутрь папки:
Далее создадим 3 Addressables группы:
-
Группа для наших hot update библиотек (в данном случае Main)
-
Группа для метаданных. Метаданные - часть dll внешних зависимостей, которые сжирает stripping из-за того, что в моменте сборки в проекте нет зависимостей ни на какие из них (из-за того что код унесен в hot update из компилируемой в моменте сборке AOT части проекта).
-
Группа для игровой сцены, которую будет загружать AOT код из addressables
Далее нужно в Project Settings во вкладке HybridCLR settings добавить созданную ранее hot update библиотеку в список Hot Update Assembly Definitions:
Обновлять с помощью hot update можно только те библиотеки, которые били в списке на момент сборки проекта. Условно говоря. если мы соберем Apk под Android и захотим добавить еще одну Dll Core, то этого не получится сделать.
Для этого есть специальное поле Preserve Hot Update Assemblies, в которое можно писать названия библиотек, которые могут добавиться в будущем:
Обновлять можно не только пользовательский код, но и сторонние плагины, например, UniTask, Dotween и так далее, а так же обычные DLL, которые уже импортнуты в проект в скомпилированном виде.
Создадим Hot Reload сцену, на которой будет висеть загрузчик сцены и библиотек с метаданными:
Создаю на github pages простой репозиторий для
Выставляю в Addressables группах DLLS и DLLSMetadat Build & Load Paths на Remote:
На всякий случай выставляю Bundle Naming Mode в DLLS и DLLSMetadata Filename, чтобы хеш в нейминге постоянно не менялся:
Не забываем в Addressables Asset Settings выставить Build Remote Catalog:
Далее произведем изначальную генерацию всех данных Hybrid CLR. Процесс может занять время, не пугайтесь.
Далее в любой папке Editor создаем следующий скрипт, который будет выполнять сборку hot update библиотек, обновлять их в папках и собирать Addressables. В скрипте используются расширения, которые будут доступны по ссылке в репозитории в конце статьи.
[MenuItem("Finiki Games/Build/HybridCLR/Build hybrid clr fresh")]
public static void BuildHybridCLRFresh() {
// Создаем installer hybrid clr
var installerController = new HybridCLR.Editor.Installer.InstallerController();
// Проверяем, был ли он проинициализирован
if (!installerController.HasInstalledHybridCLR()) {
installerController.InstallDefaultHybridCLR();
}
// Вызываем основную сборку
MainBuild();
// Собираем Addressables
AddressableAssetSettings.BuildPlayerContent();
}
[MenuItem("Finiki Games/Build/HybridCLR/Build hybrid clr update")]
public static void BuildHybridCLRUpdate() {
// Вызываем основную сборку
MainBuild();
// Создаем входящие настройки для сборки и обновления Addressables
var input = new AddressablesDataBuilderInput(AddressableAssetSettingsDefaultObject.Settings);
var updateBuild = new AddressablesBuildMenuUpdateAPreviousBuild();
updateBuild.OnPrebuild(input);
AddressableAssetSettings.BuildPlayerContent(out AddressablesPlayerBuildResult _);
}
private static void MainBuild() {
// Генерируем всю необходимую информацию для Hybrid CLR
HybridCLRExtensions.GenerateAllLite(true, BuildTarget.Android);
string projectPath = Application.dataPath;
projectPath = projectPath.Replace($"/Assets", "");
var hybridCLRConfig = FindFirstAssetByType<HybridCLRConfig>();
// Получаем список DLLS
var assemblies = HybridCLR.Editor.SettingsUtil.HotUpdateAssemblyFilesExcludePreserved;
var assembliesUrl = HybridCLR.Editor.SettingsUtil.GetHotUpdateDllsOutputDirByTarget(BuildTarget.Android);
var settings = HybridCLR.Editor.SettingsUtil.HybridCLRSettings;
foreach (var assemblyName in assemblies) {
if (settings.hotUpdateAssemblies.Contains(assemblyName.Replace(".dll", ""))) continue;
var fullAssemblyPath = projectPath + "\" + assembliesUrl + "\" + assemblyName;
var newAssemblyPath = RenameFile(fullAssemblyPath, assemblyName + ".bytes");
var inProjectAssemblyPath =
projectPath + "\" + hybridCLRConfig.DllPath + "\" + assemblyName + ".bytes";
// Копируем каждый скомпилированный файл в проект
MoveFile(newAssemblyPath, inProjectAssemblyPath);
}
// Получаем список DLLSMetadata
var metadataAssemblies = hybridCLRConfig.MetadataAssemblyList;
var metadataUrl = HybridCLR.Editor.SettingsUtil.GetAssembliesPostIl2CppStripDir(BuildTarget.Android);
foreach (var metadataAssemblyName in metadataAssemblies) {
var fullAssemblyPath = projectPath + "\" + metadataUrl + "\" + metadataAssemblyName;
var newAssemblyPath = RenameFile(fullAssemblyPath, metadataAssemblyName + ".bytes");
var inProjectAssemblyPath =
projectPath + "\" + hybridCLRConfig.MetadataPath + "\" + metadataAssemblyName + ".bytes";
// Копируем каждый скомпилированный файл в проект
MoveFile(newAssemblyPath, inProjectAssemblyPath);
}
}
Создадим HotReloadEntry скрипт, в котором загрузим все DLLSи Metadata по Label, после чего загрузим сцену:
public class HotReloadEntry : MonoBehaviour {
public AssetReference StartSceneReference;
private void Awake() {
LoadDLLS().Forget();
}
public async UniTask LoadDLLS() {
try {
// Загружаем все библиотеки по Label
var dlls = await HotReloadAddressableAssetService.LoadByLabel<TextAsset>("DLLS");
foreach (var dll in dlls) {
#if !UNITY_EDITOR
// Загружаем библиотеки
Assembly hotUpdateAss = Assembly.Load(dll.bytes);
#endif
}
}
catch (Exception e) {
Debug.LogError($"Load DLLs error: {e.Message}");
}
try {
// Загружаем все метаданные по Label
var supplementaryMetadataDlls =
await HotReloadAddressableAssetService.LoadByLabel<TextAsset>("DLLSMetadata");
foreach (var dll in supplementaryMetadataDlls) {
#if !UNITY_EDITOR
// Загружаем метаданные через runtime библиотеку hybrid clr
var err = HybridCLR.RuntimeApi.LoadMetadataForAOTAssembly(dll.bytes, HomologousImageMode.SuperSet);
Debug.Log($"LoadMetadataForAOTAssembly");
#endif
}
}
catch (Exception e) {
Debug.LogError($"Load Metadata DLLs error: {e.Message}");
}
// Загружаем сцену, нак которой находится hot reload код
await StartSceneReference.LoadSceneAsync();
}
}
Повесим этот скрипт на объект, созданный на HotReload сцене и опрокинем ссылку на основную игровую сцену, в которой содержатся весь hot update код:
Для автоматического добавления ассетов библиотек и метаданных будем использовать Addressable importer, который по созданным правилам добавляет ассеты из указанной папки в Addressables группу. Избавит от необходимости писать обработчики в скрипте сборки для выставления группы вручную.
Создаем файл настроек:
Заполняем файл настроек, чтобы он добавлял все файлы из папок в определенную Addressables группу и помечал их Label. Нужный Label и папки нужно создать предварительно.
Далее вызываем код для сборки проекта, который мы добавили в любую папку Editor в проеке:
После успешной сборки, в проекте должны появиться DLLS и DLLSMetadata файлы. В папке ServerData (смотря куда вы выбрали сборку ассетов, которые должны загружаться удаленно), появится Remote catalog и ассеты, которые нужно будет загрузить на статический
После чего собираем обычный Apk файл и проверяем работоспособность. Если игра запустилась и скрипты скачались — то все будет функционировать так же, как и до этого.
В проекте, который я выбрал для перехода на Hybrid CLR, когда курица набегает на монетку, она исчезает. Сделаем так, чтобы при столкновении с монеткой она увеличивалась в 2 раза:
public void OnCoinGrab(GameObject coin) {
var position = coin.transform.position;
EffectManager.Instance.PlayCoinEffect(position);
AudioManager.Instance.PlayCoinEvent();
coin.transform.localScale *= 2;
//coin.SetActive(false);
}
После чего вызываем:
Заливаем свежие файлы из ServerData на статическое хранилище и видим обновленное поведение в игре без пересборки Apk:
Если у вас возникли трудности в процессе интеграции или остались вопросы, пишите мне в телеграм. Читайте документацию к плагину, там намного больше инфы, чем я предоставил в статье.
Ссылка на репозиторий со всем кодом из статьи
Всем до встречи )
Автор: antontidev