Unity Web Requests. Что это? Приходилось ли работать с клиент-серверным взаимодействием?

Введение

Здравствуйте уважаемые читатели, сегодня речь пойдет о работе с внешними ресурсами в среде Unity 3d.

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

  • Текстовый файл
  • Файл текстуры
  • Аудио файл
  • Байт-массив
  • AssetBundle (архив с ассетами проекта Unity 3d)

Ниже, мы рассмотрим подробнее встроенные механизмы работы с этими ресурсами, которые присутствуют в Unity 3d, а также напишем простые менеджеры для взаимодействия с веб-сервером и загрузки ресурсов в приложение.

Примечаниедалее в статье используется код с использованием C# 7+ и рассчитан на компилятор Roslyn используемый в Unity3d в версиях 2018.3+.

Возможности Unity 3d

До версии Unity 2017 года для работы с серверными данными и внешними ресурсами использовался один механизм (исключая самописные), который был включен в движок – это класс WWW. Данный класс позволял использовать различные http команды (get, post, put и т.п.) в синхронном или асинхронном виде (через Coroutine). Работа с данным классом была достаточно проста и незамысловата.

IEnumerator LoadFromServer(string url)
{
     var www = new WWW(url);

     yield return www;

     Debug.Log(www.text);
}

Аналогичным образом можно получать не только текстовые данные, но и другие:

Однако начиная с версии 2017 в Unity появилась новая система работы с сервером, представленная классом UnityWebRequest, который находится в пространстве имен Networking. До Unity 2018 она существовала вместе с WWW, но в последней версии движка WWW стал нерекомендуемым, а в дальнейшем будет полностью удален. Поэтому далее речь пойдет только о UnityWebRequest (в дальнейшем UWR).

Работа с UWR в целом схожа с WWW в своей основе, однако есть и отличия, речь о которых пойдет дальше. Ниже приведен аналогичный пример загрузки текста.

IEnumerator LoadFromServer(string url)
{
    var request = new UnityWebRequest(url);

    yield return request.SendWebRequest();

    Debug.Log(request.downloadHandler.text);

    request.Dispose();
}

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

Работа с ресурсами

Текст

Работа с текстом является одним из самых простых вариантов. Выше уже был описан способ его загрузки. Перепишем его немного с использование создания прямого http запроса Get.

IEnumerator LoadTextFromServer(string url, Action<string> response)
{
    var request = UnityWebRequest.Get(url);

    yield return request.SendWebRequest();

    if (!request.isHttpError && !request.isNetworkError)
    {
        response(uwr.downloadHandler.text);        
    }
    else
    {
    	Debug.LogErrorFormat("error request [{0}, {1}]", url, request.error);
       
        response(null);
    }

    request.Dispose();
}

Как видно из кода, здесь используется DownloadHandler по умолчанию. Свойство text это геттер, который преобразует byte массив в текст в кодировке UTF8. Основное применение загрузки текста с сервера — это получение json-файла (сериализованное представление данных в текстовом виде). Получить такие данные можно с использованием класса Unity JsonUtility.

var data = JsonUtility.FromJson<T>(value); //здесь T тип данных, которые хранятся в строке.

Аудио

Для работы с аудио необходимо использовать специальный метод создания запроса UnityWebRequestMultimedia.GetAudioClip, а также для получения представления данных в нужном для работы в Unity виде, необходимо использовать DownloadHandlerAudioClip. Помимо этого, при создании запроса необходимо указать тип аудиоданных, представленный перечислением AudioType, который задает формат (wav, aiff, oggvorbis и т.д.).

IEnumerator LoadAudioFromServer(string url, 
                                AudioType audioType, 
                                Action<AudioClip> response)
{
    var request = UnityWebRequestMultimedia.GetAudioClip(url, audioType);

    yield return request.SendWebRequest();

    if (!request.isHttpError && !request.isNetworkError)
    {
    	response(DownloadHandlerAudioClip.GetContent(request));    
    }
    else
    {
    	Debug.LogErrorFormat("error request [{0}, {1}]", url, request.error);

        response(null);
    }

    request.Dispose();
}

Текстура

Загрузка текстур схожа с таковой для аудио файлов. Запрос создается с помощью UnityWebRequestTexture.GetTexture. Для получения данных в нужном для Unity виде используется DownloadHandlerTexture.

IEnumerator LoadTextureFromServer(string url, Action<Texture2D> response)
{
    var request = UnityWebRequestTexture.GetTexture(url);

    yield return request.SendWebRequest();

    if (!request.isHttpError && !request.isNetworkError)
    {
    	response(DownloadHandlerTexture.GetContent(request));
    }
    else
    {
    	Debug.LogErrorFormat("error request [{0}, {1}]", url, request.error);

        response(null);
    }

    request.Dispose();
}

AssetBundle

Как было сказано ранее бандл – это, по сути, архив с ресурсами Unity, которые можно использовать в уже работающей игре. Этими ресурсами могут быть любые ассеты проекта, включая сцены. Исключение составляют C# скрипты, их нельзя передать. Для загрузки AssetBundle используется запрос, который создается с помощью UnityWebRequestAssetBundle.GetAssetBundle. Для получения данных в нужном для Unity виде используется DownloadHandlerAssetBundle.

IEnumerator LoadBundleFromServer(string url, Action<AssetBundle> response)
{
    var request = UnityWebRequestAssetBundle.GetAssetBundle(url);

    yield return request.SendWebRequest();

    if (!request.isHttpError && !request.isNetworkError)
    {
          response(DownloadHandlerAssetBundle.GetContent(request));
    }
    else
    {
    	Debug.LogErrorFormat("error request [{0}, {1}]", url, request.error);

        response(null);
    }

    request.Dispose();
}

Основные проблемы и решения при работе с веб-сервером и внешними данными

Выше были описаны простые способы взаимодействия приложения с сервером по части загрузки различных ресурсов. Однако на практике все обстоит гораздо сложнее. Рассмотрим основные проблемы, которые сопровождают разработчиков и остановимся на путях их решения.

Не хватает свободного места

Одной из первых проблем при загрузке данных с сервера является возможная нехватка свободного места на устройстве. Часто бывает, что пользователь использует для игр (особенно на Android) старые устройства, а также и сам размер скачиваемых файлов может быть достаточно большим (привет PC). В любом случае, эту ситуацию необходимо корректно обработать и заранее сообщить игроку, что места не хватает и сколько. Как это сделать? Первым дело необходимо узнать размер скачиваемого файла, это делается по средствам запроса UnityWebRequest.Head(). Ниже представлен код для получения размера.

IEnumerator GetConntentLength(string url, Action<int> response)
{
   var request = UnityWebRequest.Head(url);
   yield return request.SendWebRequest();
   if (!request.isHttpError && !request.isNetworkError)
   {
        var contentLength = request.GetResponseHeader("Content-Length");

        if (int.TryParse(contentLength, out int returnValue))
   	{
   	      response(returnValue);
        }
   	else
        {
   	      response(-1);
        }
    }
    else
    {
    	Debug.LogErrorFormat("error request [{0}, {1}]", url, request.error);

        response(-1);
    }
}

Здесь важно отметить одну вещь, для правильной работы запроса, сервер должен уметь возвращать размер контента, в противном случае (как, собственно, и для отображения прогресса) будет возвращаться неверное значение.

После того, как мы получили размер скачиваемых данных, мы можем сравнить его с размером свободного места на диске. Для получения последнего, я использую бесплатный плагин из Asset Store.

Примечаниеможно воcпользоваться классом Cache в Unity3d, он может показывать свободное и занятое место в кэше. Однако здесь стоит учесть момент, что эти данные являются относительными. Они рассчитываются исходя из размера самого кэша, по умолчанию он равен 4GB. Если у пользователя свободного места больше, чем размер кэша, то проблем никаких не будет, однако если это не так, то значения могут принимать неверные относительно реального положения дел значения.

Проверка доступа в интернет

Очень часто, перед тем, как что-либо скачивать с сервера необходимо обработать ситуацию отсутствия доступа в интернет. Существует несколько способов это сделать: от пингования адреса, до GET запроса к google.ru. Однако, на мой взгляд, наиболее правильный и дающий быстрый и стабильный результат — это скачивание со своего же сервера (того же, откуда будут качаться файлы) небольшого файла. Как это сделать, описано выше в разделе работы с текстом.
Помимо проверки самого факта наличия доступа в интернет, необходимо также определить его тип (mobile или WiFi), ведь вряд ли игроку захочется качать несколько сот мегабайт на мобильном траффике. Это можно сделать через свойство Application.internetReachability.

Кэширование

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

  1. Экономия траффика (не скачивать уже скаченные данные)
  2. Обеспечение работы в отсутствии интернета (можно показать данные из кэша).

Что же нужно кэшировать? Ответ на этот вопрос – всё, все файлы, что вы качаете надо кэшировать. Как это делать, рассмотрим ниже, и начнем с простых текстовых файлов.
К сожалению, в Unity нет встроенного механизма кэширования текста, а также текстур и аудио файлов. Поэтому для этих ресурсов необходимо писать свою систему, либо не писать, в зависимости от потребностей проекта. В самом простом варианте, мы просто пишем файл в кэш и в случае отсутствия интернета берем файл из него. В чуть более сложном варианте (именно его я использую в проектах) мы отправляем запрос на сервер, который возвращает json с указанием версий файлов, которые хранятся на сервере. Запись и чтение файлов из кэша можно осуществлять с помощью C# класса File или любым другим удобным и принятым в вашей команде способом.

private void CacheText(string fileName, string data)
{
    var cacheFilePath = Path.Combine("CachePath", "{0}.text".Fmt(fileName));

    File.WriteAllText(cacheFilePath, data);
}
private void CacheTexture(string fileName, byte[] data)
{
    var cacheFilePath = Path.Combine("CachePath", "{0}.texture".Fmt(fileName));

    File.WriteAllBytes(cacheFilePath, data);
}

Аналогично, получение данных из кэша.

private string GetTextFromCache(string fileName)
{
    var cacheFilePath = Path.Combine(Utils.Path.Cache, "{0}.text".Fmt(fileName));

    if (File.Exists(cacheFilePath))
    {
        return File.ReadAllText(cacheFilePath);
    }

    return null;
}

private Texture2D GetTextureFromCache(string fileName)
{
    var cacheFilePath = Path.Combine(Utils.Path.Cache, "{0}.texture".Fmt(fileName));

    Texture2D texture = null;

    if (File.Exists(cacheFilePath))
    {
        var data = File.ReadAllBytes(cacheFilePath);

        texture = new Texture2D(1, 1);
        texture.LoadImage(data, true);
    }

    return texture;
}

Примечаниепочему для загрузки текстур не используется тот же самый UWR с url вида file://. На данный момент наблюдается проблемы с этим, файл просто напросто не загружается, поэтому пришлось найти обходной путь.

Примечаниея не использую прямую загрузку AudioClip в проектах, все такие данные я храню в AssetBundle. Однако если необходимо, то это легко сделать используя функции класса AudioClip GetData и SetData.

В отличие от простых ресурсов для AssetBundle в Unity присутствует встроенный механизм кэширования. Рассмотрим его подробнее.

В своей основе этот механизм может использовать два подхода:

  1. Использование CRC и номера версии
  2. Использование Hash значения

В принципе можно использовать любой из них, но я для себя решил, что Hash наиболее приемлемый, поскольку система версий у меня своя и она учитывает не только версию AssetBundle, но и версию приложения, так как часто, бандл может быть не совместим с версией, представленной в магазинах.

Итак, каким образом осуществляется кэширование:

  1. Запрашиваем с сервера manifest файл бандла (данный файл создается автоматически при его создании и содержит описание ассетов, которые в нем содержаться, а также значения hash, crc, размера и т.п.). Файл имеет тоже самое имя, что и бандл плюс расширение .manifest.
  2. Получаем из manifest’a значение hash128
  3. Создаем запрос к серверу для получения AssetBundle, где помимо url, указываем полученное значение hash128

Код для описанного выше алгоритма:

IEnumerator LoadAssetBundleFromServerWithCache(string url, Action<AssetBundle> response)
{
    // Ждем, готовности системы кэширования
    while (!Caching.ready)
    {
        yield return null;
    }

    // получаем манифест с сервера
    var request = UnityWebRequest.Get(url + ".manifest");
    
    yield return request.SendWebRequest();

    if (!request.isHttpError && !request.isNetworkError)
    {
        Hash128 hash = default;

        //получаем hash
        var hashRow = request.downloadHandler.text.ToString().Split("\n".ToCharArray())[5];
        hash = Hash128.Parse(hashRow.Split(':')[1].Trim());

        if (hash.isValid == true)
        {
            request.Dispose();

            request = UnityWebRequestAssetBundle.GetAssetBundle(url, hash, 0);

            yield return request.SendWebRequest();
            
            if (!request.isHttpError && !request.isNetworkError)
            {
                response(DownloadHandlerAssetBundle.GetContent(request));
            }
            else
            {
                response(null);
            }
        }
        else
        {
            response(null);
        }
    }
    else
    {
        response(null);
    }

    request.Dispose();
}

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

Примечаниепочему такой странный способ получения hash значения? Это связано с тем, что получение hash128 способом, описанным в документации, требует загрузки всего бандла целиком, а затем получения из него AssetBundleManifest ассета и оттуда уже hash значения. Минус такого подхода в том, что качается весь AssetBundle, а нам как раз нужно, чтобы этого не было. Поэтому мы сначала скачиваем с сервера только файл манифеста, забираем из него hash128 и только потом, если надо скачаем файл бандла, при этом выдергивать значение hash128 придется через интерпретацию строк.

Работа с ресурсами в режиме редактора

Последней проблемой, а точнее вопросом удобства отладки и разработки является работа с загружаемыми ресурсами в режиме редактора, если с обычными файлами проблем нет, то с бандлами не все так просто. Можно, конечно, каждый раз делать их билд, заливать на сервер и запускать приложение в редакторе Unity и смотреть как всё работает, но это даже по описанию звучит как “костыль”. С этим надо что-то делать и для этого нам поможет класс AssetDatabase.

Для того, чтобы унифицировать работу с бандлами я сделал специальную обертку:

public class AssetBundleWrapper
{
    private readonly AssetBundle _assetBundle;
 
    public AssetBundleWrapper(AssetBundle assetBundle)
    {
         _assetBundle = assetBundle;
    }    
}

Теперь нам необходимо добавить два режима работы с ассетами в зависимости от того в редакторе мы или же в билде. Для билда мы используем обертки над функциями класса AssetBundle, а для редактора используем упомянутый выше класс AssetDatabase.

Таким образом получаем следующий код:

public class AssetBundleWrapper
{
    
#if UNITY_EDITOR
        private readonly List<string> _assets;

        public AssetBundleWrapper(string url)
        {
            var uri = new Uri(url);
            var bundleName = Path.GetFileNameWithoutExtension(uri.LocalPath);

            _assets = new List<string>(AssetDatabase.GetAssetPathsFromAssetBundle(bundleName));           
        }

        public T LoadAsset<T>(string name) where T : UnityEngine.Object
        {
            var assetPath = _assets.Find(item =>
            {
                var assetName = Path.GetFileNameWithoutExtension(item);

                return string.CompareOrdinal(name, assetName) == 0;                
            });

            if (!string.IsNullOrEmpty(assetPath))
            {
                return AssetDatabase.LoadAssetAtPath<T>(assetPath);
            } else
            {
                return default;
            }
        }

        public T[] LoadAssets<T>() where T : UnityEngine.Object
        {
            var returnedValues = new List<T>();

            foreach(var assetPath in _assets)
            {
                returnedValues.Add(AssetDatabase.LoadAssetAtPath<T>(assetPath));
            }

            return returnedValues.ToArray();
        }

        public void LoadAssetAsync<T>(string name, Action<T> result) where T : UnityEngine.Object
        {
            result(LoadAsset<T>(name));
        }

        public void LoadAssetsAsync<T>(Action<T[]> result) where T : UnityEngine.Object
        {
            result(LoadAssets<T>());
        }

        public string[] GetAllScenePaths()
        {
            return _assets.ToArray();
        }

        public void Unload(bool includeAllLoadedAssets = false)
        {
            _assets.Clear();
        }
#else
    private readonly AssetBundle _assetBundle;

    public AssetBundleWrapper(AssetBundle assetBundle)
    {
        _assetBundle = assetBundle;
    }

    public T LoadAsset<T>(string name) where T : UnityEngine.Object
    {
        return _assetBundle.LoadAsset<T>(name);
    }

    public T[] LoadAssets<T>() where T : UnityEngine.Object
    {
        return _assetBundle.LoadAllAssets<T>();
    }

    public void LoadAssetAsync<T>(string name, Action<T> result) where T : UnityEngine.Object
    {
        var request = _assetBundle.LoadAssetAsync<T>(name);

        TaskManager.Task.Create(request)
                        .Subscribe(() =>
                        {
                            result(request.asset as T);

                            Unload(false);
                        })
                        .Start();
    }

    public void LoadAssetsAsync<T>(Action<T[]> result) where T : UnityEngine.Object
    {
        var request = _assetBundle.LoadAllAssetsAsync<T>();

        TaskManager.Task.Create(request)
                        .Subscribe(() =>
                        {
                            var assets = new T[request.allAssets.Length];

                            for (var i = 0; i < request.allAssets.Length; i++)
                            {
                                assets[i] = request.allAssets[i] as T;
                            }

                            result(assets);

                            Unload(false);
                        })
                        .Start();
    }

    public string[] GetAllScenePaths()
    {
        return _assetBundle.GetAllScenePaths();
    }

    public void Unload(bool includeAllLoadedAssets = false)
    {
        _assetBundle.Unload(includeAllLoadedAssets);
    }
#endif
}

Примечание: в коде используется класс TaskManager, о нем пойдет речь ниже, если кратко, то это обертка для работы с Coroutine.

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

#if UNITY_EDITOR
var path = Path.Combine(Directory.GetParent(Application.dataPath).FullName, "_EditorCache");
#else
var path = Path.Combine(Application.persistentDataPath, "_AppCache");                                
#endif
Caching.currentCacheForWriting = Caching.AddCache(path);

Пишем менеджер сетевых запросов или работа с веб-сервером

Выше мы рассмотрели основные аспекты работы с внешними ресурсами в Unity, теперь бы мне хотелось остановиться на реализации API, которая обобщает и унифицирует все выше сказанное. И для начала остановимся на менеджере сетевых запросов.

Примечаниездесь и далее используется обертка над Coroutine в виде класса TaskManagerОб этой обертке я писал в другой статье.

Заведем соответствующий класс:

public class Network
{        
        public enum NetworkTypeEnum
        {
            None,
            Mobile,
            WiFi
        }

        public static NetworkTypeEnum NetworkType;
        
        private readonly TaskManager _taskManager = new TaskManager();  
}

Статическое поле NetworkType требуется для того, чтобы приложение могло получать сведения о типе интернет-соединения. В принципе это значение можно хранить, где угодно, я решил, что в классе Network ей самое место.

Добавим базовую функцию посылки запроса на сервер:

private IEnumerator WebRequest(UnityWebRequest request, Action<float> progress, Action<UnityWebRequest> response)
{
    while (!Caching.ready)
    {
        yield return null;
    }

    if (progress != null)
    {
        request.SendWebRequest(); _currentRequests.Add(request);

        while (!request.isDone)
        {
            progress(request.downloadProgress);

            yield return null;
        }

        progress(1f);
    }
    else
    {
        yield return request.SendWebRequest();
    }

    response(request);

    if (_currentRequests.Contains(request))
    {
        _currentRequests.Remove(request);
    }

    request.Dispose();
}

Как видно из кода, способ обработки завершения запроса изменен, по сравнению с кодом в предыдущих разделах. Это сделано с целью отображения прогресса загрузки данных. Также, все посылаемые запросы сохраняются в списке, с тем чтобы, если это необходимо, их можно было отменить.

Добавляем функцию создания запроса на основе ссылки для AssetBundle:

private IEnumerator WebRequestBundle(string url, Hash128 hash, Action<float> progress, Action<UnityWebRequest> response)
{
    var request = UnityWebRequestAssetBundle.GetAssetBundle(url, hash, 0);

    return WebRequest(request, progress, response);
}

Аналогичным образом создаются функции для текстуры, аудио, текста, байт-массива.

Теперь необходимо обеспечить отправку данных сервер через команду Post. Часто нужно, что-то передать серверу, и в зависимости от того, что именно, получить ответ. Добавим соответствующие функции.

Отправка данных в виде набор ключ-значение:

private IEnumerator WebRequestPost(string url, Dictionary<string, string> formFields, Action<float> progress, Action<UnityWebRequest> response)
{
    var request = UnityWebRequest.Post(url, formFields);

    return WebRequest(request, progress, response);
}

Отправка данных в виде json:

private IEnumerator WebRequestPost(string url, string data, Action<float> progress, Action<UnityWebRequest> response)
{
    var request = new UnityWebRequest(url, UnityWebRequest.kHttpVerbPOST)
    {
        uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(data)),
        downloadHandler = new DownloadHandlerBuffer()
    };

    request.uploadHandler.contentType = "application/json";

    return WebRequest(request, progress, response);
}

Теперь добавим публичные методы с помощью, которых мы будем осуществлять загрузку данных, в частности AssetBundle

public void Request(string url, Hash128 hash, Action<float> progress, Action<AssetBundle> response, TaskManager.TaskPriorityEnum priority = TaskManager.TaskPriorityEnum.Default)
{
        _taskManager.AddTask(WebRequestBundle(url, hash, progress, (uwr) =>
        {
            if (!uwr.isHttpError && !uwr.isNetworkError)
            {
                response(DownloadHandlerAssetBundle.GetContent(uwr));
            }
            else
            {
                Debug.LogWarningFormat("[Netowrk]: error request [{0}]", uwr.error);

                response(null);
            }
        }), priority);
}

Аналогично добавляются методы для текстуры, аудио-файла, текста и т.д.

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

public void Request(string url, Action<int> response, TaskManager.TaskPriorityEnum priority = TaskManager.TaskPriorityEnum.Default)
{
    var request = UnityWebRequest.Head(url);

        _taskManager.AddTask(WebRequest(request, null, uwr =>
        {
            var contentLength = uwr.GetResponseHeader("Content-Length");

            if (int.TryParse(contentLength, out int returnValue))
            {
                response(returnValue);
            }
            else
            {
                response(-1);
            }

        }), priority);
}

public void Clear()
{
    _taskManager.Clear();

    foreach (var request in _currentRequests)
    {
        request.Abort();
        request.Dispose();
    }

    _currentRequests.Clear();    
}

На этом наш менеджер для работы с сетевыми запроса завершен. По необходимости, каждая подсистема игры, которая требует работы с сервером может создавать свои экземпляры класса.

Пишем менеджер загрузки внешних ресурсов

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

Заводим соответствующий класс, который в моем случае является синглетоном

public class ExternalResourceManager
{
    public enum ResourceEnumType
    {
        Text,
        Texture,
        AssetBundle
    }
    private readonly Network _network = new Network();
    public void ExternalResourceManager()
    {
#if UNITY_EDITOR
       var path = Path.Combine(Directory.GetParent(Application.dataPath).FullName,   "_EditorCache");
#else
       var path = Path.Combine(Application.persistentDataPath, "_AppCache");                                
#endif

       if (!System.IO.Directory.Exists(path))
       {
           System.IO.Directory.CreateDirectory(path);

           #if UNITY_IOS
	    UnityEngine.iOS.Device.SetNoBackupFlag(path);			     		  
           #endif
       }

       Caching.currentCacheForWriting = Caching.AddCache(path);
    }
}

Как видно, в конструкторе задается папка для кэширования в зависимости от того в редакторе мы находимся или нет. Также, мы завели приватное поле для экземпляра класса Network, который мы описали ранее.

Теперь добавим вспомогательные функции для работы с кэшем, а также определения размера скачиваемого файла и проверки свободного места для него. Далее и ниже код приводится на примере работы с AssetBundle, для остальных ресурсов все делается по аналогии.

Код вспомогательных функций

public void ClearAssetBundleCache(string url)
{
    var fileName = GetFileNameFromUrl(url);            
            
     Caching.ClearAllCachedVersions(fileName);
}

public void ClearAllRequest()
{
    _network.Clear();
}

public void AssetBundleIsCached(string url, Action<bool> result)
{
var manifestFileUrl = "{0}.manifest".Fmt(url);

_network.Request(manifestFileUrl, null, (string manifest) =>
{
                var hash = string.IsNullOrEmpty(manifest) ? default : GetHashFromManifest(manifest);

                result(Caching.IsVersionCached(url, hash));
} , 
TaskManager.TaskPriorityEnum.RunOutQueue);
}

public void CheckFreeSpace(string url, Action<bool, float> result)
{
    GetSize(url, lengthInMb =>
    {

#if UNITY_EDITOR_WIN
        var logicalDrive = Path.GetPathRoot(Utils.Path.Cache);
        var availableSpace = SimpleDiskUtils.DiskUtils.CheckAvailableSpace(logicalDrive);
#elif UNITY_EDITOR_OSX
        var availableSpace = SimpleDiskUtils.DiskUtils.CheckAvailableSpace();
#elif UNITY_IOS
        var availableSpace = SimpleDiskUtils.DiskUtils.CheckAvailableSpace();
#elif UNITY_ANDROID
        var availableSpace = SimpleDiskUtils.DiskUtils.CheckAvailableSpace(true);
#endif
        result(availableSpace > lengthInMb, lengthInMb);
    });
}

public void GetSize(string url, Action<float> result)
{
    _network.Request(url, length => result(length / 1048576f));
}

private string GetFileNameFromUrl(string url)
{
    var uri = new Uri(url);
    var fileName = Path.GetFileNameWithoutExtension(uri.LocalPath);

    return fileName;
}

private Hash128 GetHashFromManifest(string manifest)
{
    var hashRow = manifest.Split("\n".ToCharArray())[5];
    var hash = Hash128.Parse(hashRow.Split(':')[1].Trim());

    return hash;
}

Добавим теперь функции загрузки данных на примере AssetBundle

public void GetAssetBundle(string url,
                           Action start,
                           Action<float> progress,
                           Action stop,
                           Action<AssetBundleWrapper> result,
                           TaskManager.TaskPriorityEnum taskPriority = TaskManager.TaskPriorityEnum.Default)
{
#if DONT_USE_SERVER_IN_EDITOR
    start?.Invoke();

    result(new AssetBundleWrapper(url));

    stop?.Invoke();
#else
void loadAssetBundle(Hash128 bundleHash)
{
    start?.Invoke();

    _network.Request(url, bundleHash, progress,
    (AssetBundle value) =>
    {   
        if(value != null)
        {
            _externalResourcesStorage.SetCachedHash(url, bundleHash);
        }
        
        result(new AssetBundleWrapper(value));

        stop?.Invoke();
    }, taskPriority);
};

var manifestFileUrl = "{0}.manifest".Fmt(url);

_network.Request(manifestFileUrl, null, (string manifest) =>
{
    var hash = string.IsNullOrEmpty(manifest) ? default : GetHashFromManifest(manifest);                                

    if (!hash.isValid || hash == default)
    {
        hash = _externalResourcesStorage.GetCachedHash(url);                    

        if (!hash.isValid || hash == default)
        {
            result(new AssetBundleWrapper(null));
        }
        else
        {
            loadAssetBundle(hash);
        }
    }
    else
    {                    
        if (Caching.IsVersionCached(url, hash))
        {
            loadAssetBundle(hash);
        }
        else
        {
            CheckFreeSpace(url, (spaceAvailable, length) =>
            {
                if (spaceAvailable)
                {
                    loadAssetBundle(hash);
                }
                else
                {
                     result(new AssetBundleWrapper(null));

                    NotEnoughDiskSpace.Call();
                }
             });
         }
    }
#endif
}

Итак, что происходит в данной функции:

  • Директива предкомпиляции DONT_USE_SERVER_IN_EDITOR используется для отключения реальной загрузки бандлов с сервера
  • Первым делом выполняется запрос на сервер для получения файла манифеста для бандла
  • Затем мы получаем хеш-значение и проверяем его валидность, в случае неудачи смотрим, есть ли хеш-значение в БД (_externalResourcesStorage) для бандла, если есть, то берем его и выполняем запрос на загрузку бандла без проверки на свободное место (в данном случае, бандл будет взят из кэша), если нет, то возвращаем null значение
  • Если предыдущий пункт не актуален, то проверяем через класс Caching находится ли в кэше файл бандла, который мы хотим скачать и если да, то выполняем запрос без проверки на свободное место (файл же уже скачан)
  • В случае, если файла нет в кэше, мы проверяем наличие свободного места и, если его хватает, отправляем запрос на получение уже непосредственно самого бандла с указанием полученного ранее хеш-значения и сохраняем это значение в БД (только после реальной загрузки). Если места нет, то мы очищаем список всех запросов и отправляем сообщение в систему любым способом (об этом можно почитать в соответствующей статье)

Примечаниеважно понять зачем отдельно сохраняется хеш-значение. Это нужно для случая, когда отсутствует интернет, либо связь нестабильна, либо произошла какая-либо сетевая ошибка и мы не смогли загрузить бандл с сервера, в этом случае мы гарантируем загрузку бандла из кэша, если он там присутствует.

Аналогично описанному выше методу в менеджере можно/нужно завести и другие функции работы с данными: GetJson, GetTexture, GetText, GetAudio и т.д.

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

public void GetPack(Dictionary < string, ResourceEnumType > urls,
  Action start,
  Action < float > progress,
  Action stop, Action < string, object, bool > result) {

  var commonProgress = (float) urls.Count;
  var currentProgress = 0 f;
  var completeCounter = 0;

  void progressHandler(float value) {
    currentProgress += value;

    progress?.Invoke(currentProgress / commonProgress);
  };

  void completeHandler() {
    completeCounter++;

    if (completeCounter == urls.Count) {
      stop?.Invoke();
    }
  };

  start?.Invoke();

  foreach(var url in urls.Keys) {
    var resourceType = urls[url];

    switch (resourceType) {
    case ResourceEnumType.Text: {
      GetText(url, null, progressHandler, completeHandler,
        (value, isCached) => {
          result(url, value, isCached);
        });
    }
    break;
    case ResourceEnumType.Texture: {
      GetTexture(url, null, progressHandler, completeHandler,
        (value, isCached) => {
          result(url, value, isCached);
        });
    }
    break;
    case ResourceEnumType.AssetBundle: {
      GetAssetBundle(url, null, progressHandler, completeHandler,
        (value) => {
          result(url, value, false);
        });
    }
    break;
    }
  }
}

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

Примечание: для тех, кто не любит Coroutine, все можно достаточно легко перевести на async/await, но в данном случае, в статье я решил использовать более понятный для новичков вариант (как мне кажется).

Заключение

В данной статье я постарался как можно более компактно описать работу с внешними ресурсами игровых приложений. Этот подход и код используется в проектах, которые были выпущены и разрабатываются при моем участии. Он достаточно прост и применим в несложных играх, где нет постоянного общения с сервером (ММО и другие сложные f2p игры), однако он сильно облегчает работу, в случае если нам надо скачать дополнительные материалы, языки, осуществить серверную валидацию покупок и другие данные, которые единовременно или не слишком часто используются в приложении.

Ссылки, указанные в статье:
assetstore.unity.com/packages/tools/simple-disk-utils-59382
habr.com/post/352296
habr.com/post/282524

Клиент-серверное взаимодействие на примере fast paced шутера

В предыдущих статьях цикла (все ссылки в конце статьи) о разработке нового fast paced шутера мы рассмотрели механизмы основной архитектуры игровой логики, базирующейся на ECS, и особенности работы с шутером на клиенте, в частности, реализация системы предсказания локальных действий игрока для повышения отзывчивости игры. В этот раз подробнее остановимся на вопросах клиент-серверного взаимодействия в условиях плохого соединения мобильных сетей и способы повышения качества игры для конечного пользователя. Также вкратце опишу архитектуру игрового сервера.

Во время разработки нового синхронного PvP для мобильных девайсов мы столкнулись с типичными для жанра проблемами:

  1. Качество соединения мобильных клиентов оставляет желать лучшего. Это и относительно высокий средний пинг в районе 200-250 мс, и нестабильное распределение пинга по времени с учётом смены точек доступа (хотя, вопреки расхожему мнению, процент потерь пакетов в мобильных сетях уровня 3G+ довольно низок — порядка 1%).
  2. Существующие технические решения — это монструозные фреймворки, которые загоняют разработчиков в жесткие рамки.

Первый прототип мы сделали на UNet, пусть это накладывало ограничения на масштабируемость, контроль над сетевой составляющей и прибавляло зависимость от капризного соединения мастер-клиентов. Потом перешли на самописный netcode поверх Photon Server, но об этом чуть позже.

Рассмотрим механизмы организации взаимодействия между клиентами в синхронных PvP-играх. Наиболее популярные из них:

  • P2P или peer-to-peer. Вся логика матча хостится на одном из клиентов и не требует от нас практически никаких затрат на трафик. Но простор для читеров и высокие требования к хостящему матч клиенту, а также ограничения NAT не позволили взять это решение для мобильной игры.
  • Client-server. Выделенный сервер, наоборот, позволяет полностью контролировать всё происходящее в матче (прощайте, читеры), а его производительность — рассчитывать некоторые специфичные для нашего проекта вещи. Также многие крупные хостинг-провайдеры имеют свою структуру подсетей, которая обеспечивает минимальную задержку для конечного пользователя.

Было принято решение писать авторитарный сервер.

Сетевое взаимодействие при peer-to-peer (слева) и client-server (справа)

Передача данных между клиентом и сервером

Мы используем Photon Server — это позволило быстро развернуть необходимую инфраструктуру для проекта на основе уже отработанной годами схемы (в War Robots используем её же).

Photon Server для нас исключительно транспортное решение, без high-level конструкций, которые сильно завязаны на конкретный игровой движок. Что дает некоторое преимущество, так как библиотека передачи данных может быть заменена в любой момент.

Игровой сервер представляет из себя многопоточное приложение в контейнере Photon. На каждый матч создается отдельный поток, который инкапсулирует всю логику работы и предотвращает влияние одного матча на другой. Всеми подключениями сервера управляет Photon, а данные, пришедшие в него от клиентов, складываются в очередь, которая затем разбирается в ECS.

Общая схема потоков матчей в контейнере Photon Server

Каждый матч состоит из нескольких стадий:

  1. Игровой клиент встаёт в очередь в так называемый сервис матчмейкинга. Как только в нем набирается необходимое количество игроков, удовлетворяющих определённым условиям, он сообщает об этом игровому серверу с помощью gRPC. В этот же момент передаются все необходимые для создания игры данные.


    Общая схема создания матча
  2. На игровом сервере начинается инициализация матча. Обрабатываются и подготавливаются все параметры матча, включая данные о карте, а также все данные о клиентах, поступившие от сервиса создания матчей. Обработка и подготовка данных подразумевает, что мы парсим все необходимые данные и записываем их в специальное подмножество сущностей, которое мы называем RuleBook. Оно хранит статистические данные матча (которые не изменяются в его ходе) и будет передано всем клиентам в процессе подключения и авторизации на игровом сервере один раз или при переподключении после потери соединения. К статическим данным матча относятся конфигурация карты (представление карты компонентами ECS, связывающими их с физическим движком), данные о клиентах (ники, набор оружия, которое у них есть и не меняется в течение боя и т.п).
  3. Запуск матча. Начинают работать ECS-системы, составляющие игру на сервере. Все системы тикают 30 кадров в секунду.
  4. Каждый кадр происходит считывание и распаковка вводов игроков или копирование, если игроки не присылали свой ввод в пределах некоторого интервала.
  5. Затем в этом же кадре происходит обработка ввода в системе ECS, а именно: изменение состояния игрока; мира, на который он влияет своим вводом; и состояния других игроков.
  6. В конце кадра происходит упаковка результирующего состояния мира для игрока и отправка его по сети.
  7. В конце матча результаты отправляются на клиенты и в микросервис, обрабатывающий награды за бой с использованием gRPC, а также аналитика по матчу.
  8. После происходит клинап потока матча и поток закрывается.

Последовательность действий на сервере внутри одного кадра

Со стороны клиента процесс подключения к матчу выглядит следующим образом:

  1. Сперва осуществляется запрос на постановку в очередь в сервис создания матчей посредством websocket с сериализацией через protobuf.
  2. При создании матча этот сервис сообщает клиенту адрес игрового сервера и передает дополнительный пейлоад, необходимый клиенту перед началом матча. Теперь клиент готов начать процесс авторизации на игровом сервере.
  3. Клиент создает UDP-сокет и начинает отправлять игровому серверу запрос на подключение к матчу вместе с некоторыми идентификационными данными. Сервер уже ожидает этот клиент. При подключении он передает ему все необходимые данные для начала игры и первичного отображения мира. Сюда входят: RuleBook (список статических данных для матча), а также именуемый нами StringIntMap (данные об использованных в геймплее строках, которые будут идентифицироваться целыми числами в процессе матча). Это нужно для экономии трафика, т.к. передача строк каждый кадр создает существенную нагрузку на сеть. Например, все имена игроков, названия классов, идентификаторы оружия, аккаунтов и тому подобная информация вся записывается в StringIntMap, где кодируется с помощью простых целочисленных данных.

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

Например, вы стреляете у себя на клиенте. Для вас это происходит моментально, но клиент уже «убежал» на некоторое время вперёд по сравнению с окружающим миром, который он отображает. Поэтому из-за локального предсказания поведения игрока, серверу необходимо понять, где и в каком состоянии находились противники в момент выстрела (возможно они были уже мертвы или, наоборот, неуязвимы). Сервер проверяет все факторы и выносит свой вердикт по нанесенному урону.

Схема запроса на создание матча, подключения к игровому серверу и авторизации

Сериализация и десериализация, упаковка и распаковка первых байт матча

У нас самописная бинарная сериализация данных, а для передачи данных мы используем UDP.

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

Для создания оптимального и удобного кода на основе декларативного описания структуры нашего ECS мы используем кодогенерацию. При создании компонент для них также генерируются правила сериализации и десериализации. В основе сериализации лежит кастомный бинарный упаковщик, который позволяет упаковать данные наиболее экономным образом. Набор байт, полученный при его работе, не является самым оптимальным, но позволяет создавать поток, из которого можно считывать некоторые данные пакета без необходимости его полной десериализации.

Ограничение передачи данных в 1500 байт (он же MTU) — это, на самом деле, максимальный размер пакета, который можно передать поверх Ethernet. Это свойство может быть сконфигурировано на каждом хопе сети и часто бывает даже ниже 1500 байт. Что будет, если послать пакет больше 1500 байт? Начинается фрагментация пакетов. Т.е. каждый пакет будет принудительно разбит на несколько фрагментов, которые будут отдельно отправлены с одного интерфейса на другой. Они могут быть отправлены совершенно разными маршрутами и время получения таких пакетов может существенно увеличиться, прежде чем сетевой уровень выдаст вашему приложению склеенный пакет.

В случае с Photon — библиотека принудительно начинает слать такие пакеты в режиме reliable UDP. Т.е. Photon будет дожидаться каждого фрагмента пакета, а также пересылать недостающие фрагменты, если они затерялись при пересылке. Но такая работа сетевой части недопустима в играх, где необходима минимальная задержка сети. Поэтому рекомендовано уменьшать размеры пересылаемых пакетов до минимума и не превышать рекомендуемых 1500 байт (в нашей игре размер одного полного состояния мира не превышает 1000 байт; размер пакета с дельта-компрессией — 200 байт).

Каждый пакет от сервера обладает кратким заголовком, который содержит несколько байт, описывающих тип пакета. Клиент сначала распаковывает этот набор байт и определяет, с каким пакетом мы имеем дело. На это свойство нашего механизма десериализации мы сильно полагаемся при авторизации: чтобы не превышать рекомендуемый размер пакета в 1500 байт, мы разбиваем посылку RuleBook и StringIntMap на несколько этапов; а чтобы понять, что именно мы получили от сервера — правила игры или само состояние — мы используем заголовок пакета.

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

Контекстно зависимая оптимизация сетевого трафика. Дельта-компрессия

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

Наша игра каждый тик производит новое состояние мира, которое необходимо упаковать и передать клиентам. Обычно дельта-компрессия заключается в том, чтобы сперва послать полный стейт со всеми необходимыми данными клиенту, а затем слать только изменения этих данных. Это можно представить так:

deltaGameState = newGameState — prevGameState

Но для каждого клиента посылаются разные данные и потеря всего лишь одного пакета может привести к тому, что придется пересылать полное состояние мира.

Пересылка полного состояния мира — задача довольно затратная для сети. Поэтому мы модифицировали подход и отсылаем разницу между текущим обработанным состоянием мира и тем, который точно получен клиентом. Для этого клиент в своём пакете с вводом также отсылает номер тика, являющийся уникальным идентификатором игрового состояния, которое он уже точно получил. Теперь сервер знает, на основе какого состояния необходимо строить дельта-компрессию. Клиент обычно не успевает послать серверу номер тика, который у него есть до того, как сервер подготовит следующий кадр с данными. Поэтому на клиенте существует история серверных состояний мира, к которым и применяется патч deltaGameState, сгенерированный сервером.

Иллюстрация частоты клиент-серверного взаимодействия в проекте

Остановимся подробнее на том, что посылает клиент. В классических шутерах такой пакет называется ClientCmd и содержит информацию о нажатых клавишах игрока и времени создания команды. Внутри пакета с вводом мы посылаем гораздо больше данных:

public sealed class InputSample
{
  // Время сервера, которое видит игрок в данный момент времени
  public uint WorldTick;
  // Время, в котором видит себя клиент локально, на основе системы предсказания
  public uint PlayerSimulationTick;
  // Ввод джойстика движения. Тип (idle, ходьба, бег)
  public MovementMagnitude MovementMagnitude;
  // Направление джойстика, управляющего движением
  public float MovementAngle;
  // Состояния джойстика прицеливания
  public AimMagnitude AimMagnitude;
  // Угол джойстика прицеливания
  public float AimAngle;
  // Цель для выстрелов, в которую хотел бы попасть клиент
  public uint ShotTarget;
  // Данные с джойстика прицеливания, сжатые для более экономной передачи по сети
  public float AimMagnitudeCompressed;
}

Тут есть несколько интересных моментов. Во-первых, клиент сообщает серверу, в каком тике он видит все окружающие его объекты игрового мира, которые он не способен предсказать (WorldTick). Может показаться, что клиент способен «остановить» время для мира, а сам бегать и расстреливать всех из-за локального предсказания. Это не так. Мы доверяем только ограниченному набору значений от клиента и не даём ему стрелять в прошлое на более чем 1 секунду. Также поле WorldTick используется в качестве acknowledgment-пакета, на основе которого строится дельта-компрессия.

В пакете можно обнаружить числа с плавающей запятой. Обычно такие величины часто используются для снятия показаний с джойстика игрока, но не очень хорошо передаются по сети, так как они обладают большим «дребезгом» и обычно чересчур точны. Мы квантуем такие числа и пакуем с помощью бинарного упаковщика, чтобы они не превышали целочисленное значение, которое может поместиться в несколько бит в зависимости от его величины. Таким образом разбивается упаковка ввода с джойстика прицеливания:

if (Math.Abs(s.AimMagnitudeCompressed) < float.Epsilon)
{
  packer.PackByte(0, 1);
}
else
{
  packer.PackByte(1, 1);
  float min = 0;
  float max = 1;
  float step = 0.001f;
  // Разбиваем величину ввода на 1000 и округляем до целого,
  // которое будет преобразовано в необходимое число бит и упаковано
  // для передачи по сети
  packer.PackUInt32((uint)((s.AimMagnitudeCompressed - min)/step), CalcFloatRangeBits(min, max, step));
}

Ещё одна интересная особенность при посылке ввода это то, что некоторые команды могут посылаться несколько раз. Очень часто нас спрашивают, что делать, если человек нажал ультимативную способность, а пакет с её вводом потерялся? Мы просто посылаем этот ввод несколько раз. Это похоже на работу гарантированной доставки, но более гибкой и быстрой. Т.к. размер пакета ввода очень маленький, мы можем упаковать в результирующий пакет несколько смежных вводов игрока. В данный момент размер окна, определяющий их количество равен пяти.

Пакеты ввода, формируемые на клиенте в каждый тик и отправляемые на сервер

Передача такого рода данных является наиболее быстрой и достаточно надежной для решения наших задач без использования reliable UDP. Мы исходим из того, что вероятность потерять такое количество пакетов подряд весьма низка и является индикатором показателем серьезной деградации качества сети в целом. Если такое случается, сервер просто копирует последний полученный ввод от игрока и применяет его, надеясь, что он остался неизменным.

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

Вместо заключения и ссылки

На игровом сервере существует много и других систем, отвечающих за обнаружение, отладку и правку матчей «наживую», обновление конфигурации геймдизайнерами без перезапуска, логирование и отслеживание состояния серверов. Об этом тоже хотим написать подробнее, но отдельно.

В первую очередь при разработке сетевой игры на мобильные платформы следует обращать внимание на корректность работы вашего клиента с высокими пингами (около 200 мс), чуть более частыми потерями данных, а также с размерами посылаемых данных. И необходимо четко укладываться в лимит пакета в 1500 байт для избежания фрагментации и задержек трафика.

Полезные ссылки:

Предыдущие статьи по проекту:

  1. «Как мы замахнулись на мобильный fast paced шутер: технологии и подходы».
  2. «Как и почему мы написали свой ECS».
  3. «Как мы писали сетевой код мобильного PvP шутера: синхронизация игрока на клиенте».

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *