Введение


Здравствуйте уважаемые читатели, сегодня речь пойдет о работе с внешними ресурсами в среде 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

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


  1. Igor_Sib
    15.01.2019 00:44

    Статья супер, есть даже ответ на вопрос который у меня возник в процессе чтения, раз уж используется C# 7+ (ответ в последнем примечании). Только дело не в любви, если мне не изменяет память Coroutines выполняются в Main Thread, в отличие от async/await, для закачки наверно лучше все таки задействовать другие потоки.


    1. Ichimitsu
      15.01.2019 08:52

      Спасибо). Про 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. Tutanhomon
        15.01.2019 09:02
        -1

        del


  1. KonH
    17.01.2019 11:17
    +1

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

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


    1. Ichimitsu Автор
      17.01.2019 11:27

      да спасибо, исправил в статье