Введение
Здравствуйте уважаемые читатели, сегодня речь пойдет о работе с внешними ресурсами в среде 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);
}
Аналогичным образом можно получать не только текстовые данные, но и другие:
- Байт-массив — www.bytes
- Текстура – www.texture
- Аудио – www.GetAudioClip()
- Ассет – www.assetBundle
Однако начиная с версии 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.
Кэширование
Следующей, и одной из самых важных проблем, является кэширование скачиваемых файлов. Для чего же нужно это кэширование:
- Экономия траффика (не скачивать уже скаченные данные)
- Обеспечение работы в отсутствии интернета (можно показать данные из кэша).
Что же нужно кэшировать? Ответ на этот вопрос – всё, все файлы, что вы качаете надо кэшировать. Как это делать, рассмотрим ниже, и начнем с простых текстовых файлов.
К сожалению, в 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 присутствует встроенный механизм кэширования. Рассмотрим его подробнее.
В своей основе этот механизм может использовать два подхода:
- Использование CRC и номера версии
- Использование Hash значения
В принципе можно использовать любой из них, но я для себя решил, что Hash наиболее приемлемый, поскольку система версий у меня своя и она учитывает не только версию AssetBundle, но и версию приложения, так как часто, бандл может быть не совместим с версией, представленной в магазинах.
Итак, каким образом осуществляется кэширование:
- Запрашиваем с сервера manifest файл бандла (данный файл создается автоматически при его создании и содержит описание ассетов, которые в нем содержаться, а также значения hash, crc, размера и т.п.). Файл имеет тоже самое имя, что и бандл плюс расширение .manifest.
- Получаем из manifest’a значение hash128
- Создаем запрос к серверу для получения 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
Igor_Sib
Статья супер, есть даже ответ на вопрос который у меня возник в процессе чтения, раз уж используется C# 7+ (ответ в последнем примечании). Только дело не в любви, если мне не изменяет память Coroutines выполняются в Main Thread, в отличие от async/await, для закачки наверно лучше все таки задействовать другие потоки.
Ichimitsu
Спасибо). Про async/await, тут нужно понимать, что если их использовать в чистом виде, то работать все будет в том же потоке, именно переход с Coroutine на них простой, однако если хочется именно многопоточности, нужно использовать Task.Run и тут возникает сложность с невозможностью доступа к всем наследникам UnityEginge.Object в таких задачах. Вот так приблизительно будет выглядеть основная функция для сетевого запросе через async/await, в данном случае она в основном потоке выполняется
Tutanhomon
del