Работа с внешними ресурсами в Unity3D

    Введение


    Здравствуйте уважаемые читатели, сегодня речь пойдет о работе с внешними ресурсами в среде 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 = 0f;
        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
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 7

      0
      Статья супер, есть даже ответ на вопрос который у меня возник в процессе чтения, раз уж используется C# 7+ (ответ в последнем примечании). Только дело не в любви, если мне не изменяет память Coroutines выполняются в Main Thread, в отличие от async/await, для закачки наверно лучше все таки задействовать другие потоки.
        0
        Спасибо). Про async/await, тут нужно понимать, что если их использовать в чистом виде, то работать все будет в том же потоке, именно переход с Coroutine на них простой, однако если хочется именно многопоточности, нужно использовать Task.Run и тут возникает сложность с невозможностью доступа к всем наследникам UnityEginge.Object в таких задачах. Вот так приблизительно будет выглядеть основная функция для сетевого запросе через async/await, в данном случае она в основном потоке выполняется
        private async Task<UnityWebRequest> WebRequest(CancellationTokenSource cancelationToken, UnityWebRequest request, Action<float> progress)
            {
                while (!Caching.ready)
                {
                    if (cancelationToken.IsCancellationRequested)
                    {                             
                        return null;
                    }
        
                    await new WaitForUpdate();
                }
        
        #pragma warning disable CS4014
                request.SendWebRequest();
        #pragma warning restore CS4014
        
                while (!request.isDone)
                {
                    if (cancelationToken.IsCancellationRequested)
                    {                
                        request.Abort();
                        request.Dispose();
        
                        return null;                
                    }
                    else
                    {
                        progress?.Invoke(request.downloadProgress);
        
                        await new WaitForUpdate();
                    }
                }
        
                progress?.Invoke(1f);
                   
                return request;        
            }
        

      +1

      В AssetBundleWrapper.LoadAsset условие должно быть инвертировано, судя по логике метода:

      if (string.IsNullOrEmpty(assetPath))
      {
          return AssetDatabase.LoadAssetAtPath<T>(assetPath);
      } else
      {
          return default;
      }
        0
        да спасибо, исправил в статье
        0
        Пробовал скачивать аудио клип с помощью
        DownloadHandlerAudioClip.GetContent(request)
        и упаковав его в AssetBundle:
        
        AssetBundle bundle = DownloadHandlerAssetBundle.GetContent(www);
        var audioClip = bundle.LoadAssetAsync<AudioClip>(bundle.GetAllAssetNames()[0]);
        

        Но в обоих случаях происходит заметное зависание приложения при попытке получить AudioClip, хоть во втором варианте и Async вызов.
        Также нельзя сделать это в отдельном треде, т.к. GetContent это вызов Unity API.

        Есть ли способы получить контент без заметных фризов?
          0
          var audioClip = bundle.LoadAssetAsync(bundle.GetAllAssetNames()[0]); Не очень понятная строчка, поскольку в таком виде вы получите не audioClip, а AssetBundleRequest, который явялется асинхронной операцией и в этом случае получить из него данно можно через yield return, соотвественно через запуск Coroutine.
          Выше в статье есть пример кода:
          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();
              }
          

        Only users with full accounts can post comments. Log in, please.