Картинка с официального сайта
Картинка с официального сайта

Введение

Сегодня я хочу познакомить вас с плагином для 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, необходимо написать адаптер, а тип в интерпретаторе не может быть распознан системой типов основного проекта. Неполные функции, проблемная разработка и низкая эффективность работы.

Как заявляют про публикацию в Google Play и App Store:

HybridCLR очень популярен в Китае, на данный момент как минимум сотни игр используют HybridCLR, все они выложены в App Store и Google Play.

В основе HybridCLR лежат интерпретация и исполнение, и с этой точки зрения этот подход ничем не отличается от интеграциюи интерпретатора lua в Unity. Поэтому он соответствует требованиям App Store и Google Play Store, и нет особого риска отклонения. А из-за высокой интеграции HybridCLR и il2cpp, он даже намного безопаснее схемы lua, отсюда вероятность отклонения игры из-за несоответствия правилам площадки очень низкая.

Изменения в схеме запуска пользовательского кода
Изменения в схеме запуска пользовательского кода

HybridCLR делает следующее:

  1. Реализация эффективной библиотеки анализа метаданных (dll)

  2. Изменения в модуль управления метаданными для реализации динамической регистрации метаданных

  3. Реализация компилятора из набора инструкций IL в набор инструкций кастомных регистров

  4. Реализация эффективного register interpreter

  5. Предоставление большого количества инстинктивных функций для повышения производительности интерпретатора

Из интересного:

  1. hot update dots кода, но нужно ставить их форк плагина. Юнитехи сделали раннюю инициализацию TypeManager без возможность вызвать ее вручную, ребятам пришлось дописывать самим. Пока не завезли поддержку Burst, но Jobs работает и обновляется

  2. hot update асинхронного кода

  3. zero learning и zero usage costs

Установка

https://hybridclr.doc.code-philosophy.com/en/docs/beginner/quickstart

Устанавливаем плагин через Package Manager:

Ссылка на плагин

Далее инициализируем плагин:

Нужно проинициализировать плагин
Нужно проинициализировать плагин
Нажимаем install
Нажимаем install

Настройка

В качестве примера возьму свой некро проект 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
Создаем папку под Main assembly

Далее создадим Main assembly и перетащим весь код внутрь папки:

Создаем Main assembly
Создаем Main assembly

Далее создадим 3 Addressables группы:

  1. Группа для наших hot update библиотек (в данном случае Main)

  2. Группа для метаданных. Метаданные - часть dll внешних зависимостей, которые сжирает stripping из-за того, что в моменте сборки в проекте нет зависимостей ни на какие из них (из-за того что код унесен в hot update из компилируемой в моменте сборке AOT части проекта).

  3. Группа для игровой сцены, которую будет загружать AOT код из addressables

Созданные Addressable группы
Созданные Addressable группы

Далее нужно в Project Settings во вкладке HybridCLR settings добавить созданную ранее hot update библиотеку в список Hot Update Assembly Definitions:

Добавляем библиотеку Main в список
Добавляем библиотеку Main в список

Обновлять с помощью hot update можно только те библиотеки, которые были в списке на момент сборки проекта. Условно говоря, если мы соберем apk под Android и захотим добавить еще одну Dll Core, то этого не получится сделать.

Для этого есть специальное поле Preserve Hot Update Assemblies, в которое можно писать названия библиотек, которые могут добавиться в будущем:

Обновлять можно не только пользовательский код, но и сторонние плагины, например, UniTask, Dotween и так далее, а так же обычные DLL, которые уже импортнуты в проект в скомпилированном виде.

Создадим Hot Reload сцену, на которой будет висеть загрузчик сцены и библиотек с метаданными:

Создаем HotReload сцену
Создаем HotReload сцену

Создаю на github pages простой репозиторий для хостинга статических файлов (инструкцию писать не буду, легко находится), создаю Addressables Profile, делаю его текущим и выставляю ссылки в его настройках:

Выставленные пути до github pages хостинга в Addressables profile
Выставленные пути до github pages хостинга в Addressables profile

Выставляю в Addressables группах DLLS и DLLSMetadata Build & Load Paths на Remote:

Делаю так, чтобы ассеты из этой группы скачивались с remote адреса
Делаю так, чтобы ассеты из этой группы скачивались с 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 код:

Повесим скрипт на сцене HotReload на новый объект
Повесим скрипт на сцене HotReload на новый объект

Для автоматического добавления ассетов библиотек и метаданных будем использовать Addressable importer, который по созданным правилам добавляет ассеты из указанной папки в Addressables группу. Избавит от необходимости писать обработчики в скрипте сборки для выставления группы вручную.

Ссылка на addresable importer

Создаем файл настроек:

Создаем файл настроек
Создаем файл настроек

Заполняем файл настроек, чтобы он добавлял все файлы из папок в определенную Addressables группу и помечал их Label. Нужный Label и папки нужно создать предварительно.

Настраиваем правила добавления в группу
Настраиваем правила добавления в группу

Далее вызываем код для сборки проекта, который мы добавили в любую папку Editor в проеке:

Вызываем сборку

После успешной сборки, в проекте должны появиться DLLS и DLLSMetadata файлы. В папке ServerData (смотря куда вы выбрали сборку ассетов, которые должны загружаться удаленно), появится Remote catalog и ассеты, которые нужно будет загрузить на статический хостинг (в нашем случае github pages).

Метаданные после успешной сборки
Метаданные после успешной сборки
Библиотеки после успешной сборки
Библиотеки после успешной сборки
Эти файлы нужно загрузить на хостинг
Эти файлы нужно загрузить на хостинг

После чего собираем обычный 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:

Если у вас возникли трудности в процессе интеграции или остались вопросы, пишите мне в телеграм. Читайте документацию к плагину, там намного больше инфы, чем я предоставил в статье.

Ссылка на репозиторий со всем кодом из статьи

Всем до встречи )

Больше информации и анонсы статей в моем телеграмм канале, в котором рассказываю про Unity https://t.me/dedpilit.

Комментарии (2)


  1. LinarMast
    23.09.2024 13:11

    Крутая фича, спасибо за статью)

    Я правильно понимаю, что можно обновлять только код? Или можно также изменять UI/Prefab/Sprite?


    1. antontidev Автор
      23.09.2024 13:11
      +1

      Обновлять ассеты можно с помощью Addressables, который тут используется в качестве системы управления ресурсами