Современное хранение игрового прогресса в контексте Unity. Часть 2

Сериализация
Сериализация — это процесс преобразования структуры данных в формат, который может быть записан в постоянную память или передан по сети. Это позволяет воссоздать (десериализовать) исходную структуру данных в другом месте или в другой момент времени.
Соответственно у модуля сериализации только две операции: сериализация и десериализация.
Поскольку данных может быть много, а алгоритмы сериализации могут быть затратными, целесообразно считать эти операции асинхронными.
Для записи в постоянную память чаще всего используется формат строки.
Учитывая эти условности, сформируем абстракцию для модуля:
public interface ISerializer
{
UniTask SerializeAsync(TData data);
UniTask DeserializeAsync(string serializedData);
}
Способов перевести структуру в строку довольно много. Наиболее популярные форматы сериализации:
- JSON;
- YAML;
- XML;
- BSON;
- Binary.
Есть другие. И есть проприетарные in-house решения.
Для популярных форматов существуют разнообразные готовые библиотеки. У каждой есть свои требования к сериализуемым данным. Но наиболее общим является использование атрибута [System.Serializable] для сериализуемых структур (он же используется и для инспектора в Unity).
Способ сериализации — это то, что обычно меняется в зависимости от режима сборки. Для внутреннего тестирования удобнее использовать одни форматы. Для продуктовых версий — более защищённые варианты. Обеспечение гибкости в этом вопросе потребуется с наибольшей вероятностью.
Binary:
public sealed class BinarySerializer : ISerializer
{
public UniTask SerializeAsync(TData data)
{
BinaryFormatter formatter = new();
MemoryStream stream = new();
formatter.Serialize(stream, data);
stream.Position = 0;
byte[] buffer = new byte[stream.Length];
stream.Read(buffer, 0, buffer.Length);
string serializedData = Encoding.UTF8.GetString(buffer);
return UniTask.FromResult(serializedData);
}
public UniTask DeserializeAsync(string serializedData)
{
byte[] buffer = Encoding.UTF8.GetBytes(serializedData);
MemoryStream stream = new(buffer);
BinaryFormatter formatter = new();
var data = (TData)formatter.Deserialize(stream);
return UniTask.FromResult(data);
}
}
JSON:
Этот формат сыскал наибольшую популярность. Он более эффективен и удобен для чтения, чем XML. И имеет больше готовых решений, чем YAML.
В Unity есть встроенный инструмент JsonUtility, который доступен сразу «из коробки». Однако он достаточно ограничен и использует те же правила сериализации, что сериализатор для ассетов внутри движка.
Т.е. он умеет сериализовывать ровно то, что можно отрисовать в инспекторе. А значит многие сложные структуры данных типа Dictionary не пройдут (но это можно решить через ISerializationCallbackReceiver).
public sealed class JsonUtilitySerializer : ISerializer
{
public UniTask SerializeAsync(TData data)
{
string json = JsonUtility.ToJson(data);
return UniTask.FromResult(json);
}
public UniTask DeserializeAsync(string json)
{
var data = JsonUtility.FromJson(json);
return UniTask.FromResult(data);
}
}
Популярной альтернативой является библиотека Newtonsoft, которая предоставляет значительно больше возможностей. Unity даже какое-то время назад добавили её в UPM. Но сейчас я её уже там не нашёл, поэтому придётся добывать библиотеку из NuGet, как раньше. К счастью, никаких лишних зависимостей она за собой не тянет.
public sealed class NewtonsoftSerializer : ISerializer
{
public UniTask SerializeAsync(TData data)
{
string json = JsonConvert.SerializeObject(data);
return UniTask.FromResult(json);
}
public UniTask DeserializeAsync(string json)
{
var data = JsonConvert.DeserializeObject(json);
return UniTask.FromResult(data);
}
}
Шифрование:
Шифрование сохранённых данных является популярной техникой для защиты данных от несанкционированного чтения или изменения.
Часто этот алгоритм используют как декоратор над другим сериализатором. Т.е. сначала перегоняем в JSON, а потом эти данные шифруем.
Для дешифровки необходимо знать алгоритм и пароль, при помощи которых проводилось шифрование. Эти данные известны самому приложению или узлу, с которого клиент получает данные. Соответственно, ни игрок, ни кто-либо другой в рядовой ситуации не сможет ни прочитать, ни что-то с данными сделать. Если только нарушить целостность и «сломать».
Само собой из-за дополнительных вычислительных издержек и нечитабельности этот трюк используют обычно только для продуктовых версий.
Пример зашифрованных сохранений:
SF0KeJbP+sX207x4d78frBrNN20lPDUumXktlq5dnFk0xz+xcqWaqxOhj
7xrtgQZ9irh/GiJNVVtktVT9pJh0VJN8rK2KX3W2LDPHCDxQwcA/g7epw
VVhhlI8bwyTs8pETfOhFbSJ5rihCehqvecww==
public sealed class AesSerializer : ISerializer
{
private readonly ISerializer _baseSerializer;
private readonly IPasswordProvider _passwordProvider;
public AesSerializer(
ISerializer baseSerializer, IPasswordProvider passwordProvider)
{
_baseSerializer = baseSerializer;
_passwordProvider = passwordProvider;
}
public async UniTask SerializeAsync(TData data)
{
string password = _passwordProvider.Provide();
string serializeData = await _baseSerializer.SerializeAsync(data);
return AesEncryption.Encrypt(serializeData, password);
}
public UniTask DeserializeAsync(string encryptedData)
{
string password = _passwordProvider.Provide();
string decryptedData = AesEncryption.Decrypt(encryptedData, password);
return _baseSerializer.DeserializeAsync(decryptedData);
}
}
Ключ-идентификатор
Чтобы отличать одни данные от других, нужен некий идентификатор, он же ключ. В качестве такого ключа может выступать название файла с сохранениями, путь до него или какое-то другое уникальное обозначение.
По указанному ключу система сохранений сохраняет входящие данные. И по этому же ключу обратно их возвращает.
Самый простой способ указать ключ — это передавать его в качестве аргумента:
public interface ISaveSystem
{
UniTask SaveAsync(string key, TData data);
UniTask LoadAsync(string key);
}
Это подойдёт, если есть необходимость сохранять несколько независимых однотипных структур, которые нужно как-то друг от друга отличать.
Например, нужно сохранить HealthData игрока и HealthData противника как отдельные структуры:
HealthData playerHealthData = new();
HealthData enemyHealthData = new();
await _saveSystem.SaveAsync("PlayerHealthData", playerHealthData);
await _saveSystem.SaveAsync("EnemyHealthData", enemyHealthData);
Но если все сохраняемые структуры имеют разный тип, то тип и может являться идентификатором, а значит — ключом. Тогда в явной передачи дополнительного аргумента нет необходимости — мы уже передаём структуру определённого уникального типа.
В примере выше можно тоже обойтись без явной передачи ключа. HealthData игрока не существует отдельно от самого игрока, у которого наверняка есть и другие параметры. Все эти параметры можно объединить в PlayerData.
HealthData противника тоже наверняка связана с неким EnemyData, который является частью WorldData.
WorldData и PlayerData, в обобщённом случае, существуют в единственном экземпляре. Поэтому можно их и сохранить:
HealthData playerHealthData = new();
PlayerData playerData = new(playerHealthData);
HealthData enemyHealthData = new();
WorldData worldData = new(enemyHealthData);
await _saveSystem.SaveAsync(playerData);
await _saveSystem.SaveAsync(worldData);
Тогда сформировать ключ можно следующим образом:
private static string GetKey() => typeof(TData).Name;
Неизменяемость ключа:
С получением ключа из типа существует нюанс: если разработчик при рефакторинге случайно переименует тип, то у этих данных изменится ключ. А значит данные, сохранённые под старым ключом, не будут подхватываться новым. Т.е. ключу хорошо бы обеспечить неизменяемость, от греха подальше.
Неизменяемость обеспечивается за счёт соответствия некому явно заданному параметру: числу, строке и т.д. Нужно только определить, где этот параметр хранить.
1. Свойство:
В абстракцию ISaveData можно добавить свойство Key:
public interface ISaveData
{
string Key { get; }
}
Тогда для получения ключа нужно иметь какой-то экземпляр структуры. А такой экземпляр есть только при сохранении (см. аргумент ISaveSystem.SaveAsync). На загрузке есть только ключ.
Можно создавать заглушечные экземпляры (и даже кэшировать их), но это лишние аллокации и даже звучит как «костыль». Также не любые экземпляры можно легко и просто создать вручную из-за доп. зависимостей, приватных конструкторов или иных осложнений.
Из плюсов — не удастся забыть указать ключ, ведь компилятор об этом тут же напомнит, т.к. это свойство требует интерфейс.
2. Константа:
Для каждой реализации ISaveData
можно объявить внутреннюю константу:
[Serializable]
public sealed class GameData : ISaveData
{
public const string Key = "GameData";
public PlayerData PlayerData;
public CampaignData CampaignData;
}
Но тогда для её получения нужно заранее знать тип, из которого эту константу можно достать. Есть простые системы, где под сохранение каждой структуры заведён отдельный метод — для таких решений константа подойдёт как нельзя кстати.
В этой статье мы рассматриваем обобщённый случай, где тип узнаем динамически, во время выполнения. И здесь не удастся обойтись без рефлексии, а это не желательно для Runtime-кода игры.
private static string GetKey()
{
Type type = typeof(TData);
FieldInfo fieldInfo = type.GetField("Key",
BindingFlags.Public |
BindingFlags.Static |
BindingFlags.FlattenHierarchy)!;
return (string)fieldInfo.GetValue(null);
}
Если такую константу забыть завести, то узнать об этом удастся только на этапе выполнения (если нет специализированного статического анализа кода), когда приложение выбросит исключение, что константа «Key» не найдена.
3. Атрибут:
Для каждой реализации ISaveData можно указать кастомный атрибут:
[Serializable, SaveDataKey("game_data")]
public sealed class GameData : ISaveData
{
public PlayerData PlayerData;
public CampaignData CampaignData;
}
Выглядит прикольнее. Но по сути это примерно ± то же, что и константа. Только в этот раз рефлексия будет нужна всегда, даже в простых системах.
4. Провайдер:
Для получения ключа мне нравится использовать выделенный провайдер, который не требует использования рефлексии.
public interface IKeysProvider
{
string Provide();
IEnumerable ProvideAll();
}
public sealed class SaveDataKeysProvider: IKeysProvider
{
private readonly IReadOnlyDictionary _map =
new Dictionary
{
{ typeof(PlayerData), "PlayerData" },
{ typeof(CampaignData), "CampaignData" },
};
public string Provide() => _map[typeof(TData)];
public IEnumerable ProvideAll() => _map.Values;
}
По началу такое решение может показаться оверинженирингом. Но в непосредственно использовании так не ощущается и даёт некоторый прирост в гибкости. Если в проекте про гибкость речи не идёт и рефлексия не внесёт импакта, то, конечно, можно обойтись и без этого.
Провайдер позволяет:
- Явно выразить намерение хранить ключ как отдельную от типа, но связанную с ним, строку;
- Явно выразить получение ключа как отдельный этап и модуль в системе сохранения;
- Централизованно хранить и получать список всех ключей;
- Удобно и динамически подменять стратегии формирования ключей, например, при смене типа хранилища данных;
- Разграничивать доступ к ключам через наличие нескольких провайдеров, каждый из которых используется в своей специализированной системе сохранений, если их несколько.
Мульти-пользователь:
Одной из фич, которую позволяет реализовать провайдер ключей, является реализация поддержки нескольких пользователей.
Проблема этой фичи: необходимость сохранять одни и те же данные у разных пользователей, не перетирая друг друга.
Решение: задекорировать провайдер ключей и модифицировать ключ идентификатором пользователя. Грубо говоря, к обычному ключу приклеить лычку конкретного игрока. Если хранилище данных — это файловая система, то таким образом можно хранить сохранения пользователей в разных папках.
public sealed class KeysProviderPrefixDecorator : IKeysProvider
{
private readonly string _prefix;
private readonly IKeysProvider _baseProvider;
public KeysRepositoryPrefixDecorator(
string prefix, IKeysProvider baseProvider)
{
_prefix = prefix;
_baseProvider = baseProvider;
}
public string Provide() =>
_prefix + _baseProvider.Provide();
public IEnumerable ProvideAll() =>
_baseProvider.ProvideAll().Select(key => _prefix + key);
}
Аналогично можно решить проблему хранения данных с разных площадок в одном общем хранилище данных. Тогда вместо идентификатора пользователя можно подставлять название площадки.
Хранилище данных
Локальные хранилища данных:
- Расположены непосредственно на самом устройстве, на котором запускается игра.
- Файловая система ОС или разнообразные реализации Баз Данных на устройстве.
Удалённые хранилища данных:
- Расположены где-то вне устройства, на котором запускается игра.
- Файловая система ОС или разнообразные реализации Баз Данных на сервере или специализированный облачный сервис типа Playfab, Unity CloudSave, GamePush и др.
Удалённые хранилища обеспечивают следующие преимущества:
- Возможность игры с разных устройств и платформ;
- Оперативная поддержка со стороны разработчиков, т.к. они могут получить доступ к данным и тут же их исправить, если проблема была в данных;
- Защита данных от потери, взлома или мошенничества.
Удалённые хранилища имеют некоторые особенности:
- Нужна авторизация (хотя бы по deviceId), чтобы идентифицировать игроков;
- Значительно более долгие операции с хранилищем;
- Нужен интернет;
- Аренда/покупка сервера или тарификация на запросы и размер данных у сервисов;
- Игрок не сможет сам сбрасывать прогресс, если в самой игре не предусмотрена такая опция.
-
Базовый набор операций:
- Загрузить по ключу;
- Записать данные по ключу;
- Удалить данные по ключу;
- Проверить наличие данные по ключу.
-
Окончательный набор операций формируется для каждого проекта индивидуально. Где-то из операций вовсе нужно только запись и чтение, где-то требуются реализации для множества ключей сразу, где-то идёт работа с разного рода мета-данными (права доступа, время изменения, время создания и пр.).
Операции с хранилищем данных, особенно удалённым, затратны по времени, поэтому их стоит делать асинхронными по умолчанию.
Учитывая всё это, получаем следующую абстракцию:
public interface IDataStorage
{
UniTask ReadAsync(string key);
UniTask WriteAsync(string key, string serializedData);
UniTask DeleteAsync(string key);
UniTask ExistsAsync(string key);
}
Разные платформы имеют разные ограничения и особенности, поэтому для разных платформ могут требоваться различные типы хранилища.
Для работы в редакторе часто не целесообразно использовать удалённое хранилище, особенно если его использование тарифицируется. Поэтому в этом режиме подключается только локальное.
Для продуктовых сборок используется комбинация из локального и удалённого.
А для тестовых, чтобы оперативно тестировать данные в удалённом хранилище, подключают только удалённые.
Возможность менять реализацию хранилища данных перед сборкой — очень полезное следствие гибкости.
Локальные хранилища данных в Unity. Файловая система:
public sealed class FileSystemDataStorage : IDataStorage
{
private readonly string _folderPath;
private readonly string _fileExtension;
public FileSystemDataStorage(string folderPath, string fileExtension)
{
_folderPath = folderPath;
_fileExtension = fileExtension;
}
public UniTask ExistsAsync(string key)
{
string filePath = GetFilePath(key);
bool exists = File.Exists(filePath);
return UniTask.FromResult(exists);
}
public UniTask DeleteAsync(string key)
{
string filePath = GetFilePath(key);
File.Delete(filePath);
return UniTask.CompletedTask;
}
public async UniTask ReadAsync(string key)
{
string filePath = GetFilePath(key);
return await File.ReadAllTextAsync(filePath);
}
public async UniTask WriteAsync(string key, string serializedData)
{
string filePath = GetFilePath(key);
await File.WriteAllTextAsync(filePath, serializedData);
}
private string GetFilePath(string key) =>
Path.Combine(_folderPath, key) + "." + _fileExtension;
}
- Подходящий вариант для объёмного User Generated Content.
- На ряде Android-смартфонов от пользователя требуется дополнительное разрешение на доступ к файловой системе. Пользователи такие разрешения давать не любят. Тогда прогресс не сохранится. А это — удаление игры и гневный отзыв.
- Для WebGL этот вариант не подойдёт, т.к. доступа к файловой системе браузер не предоставляет (или я пока о таких хитростях ещё не знаю). Есть вариант с LocalStorage на стороне JavaScript, но это уже другая история.
- У Unity есть свойство Application.persistentDataPath — это предустановленный путь до директории, где можно легально хранить данные, которые не будут удалены при обновлении или переустановки приложения. Для каждой платформы и проекта это свойство имеет свой вариант пути, и Unity сам подставит финальную реализацию.
- В самом редакторе вместо Application.persistentDataPath удобнее использовать Application.dataPath — это путь до рабочего проекта. Но записывать туда можно только в режиме редактора. Так что при сборке путь нужно заменить.
string storageFolder = Application.isEditor
? Application.dataPath : Application.persistentDataPath;
IDataStorage storage = new FileSystemDataStorage(storageFolder);
Локальные хранилища данных в Unity. PlayerPrefs:
public sealed class PlayerPrefsDataStorage : IDataStorage
{
public UniTask ReadAsync(string key)
{
string serializedData = PlayerPrefs.GetString(key);
return UniTask.FromResult(serializedData);
}
public UniTask WriteAsync(string key, string serializedData)
{
PlayerPrefs.SetString(key, serializedData);
return UniTask.CompletedTask;
}
public UniTask DeleteAsync(string key)
{
PlayerPrefs.DeleteKey(key);
return UniTask.CompletedTask;
}
public UniTask ExistsAsync(string key)
{
bool exists = PlayerPrefs.HasKey(key);
return UniTask.FromResult(exists);
}
}
- Хранилище по типу ключ-значение.
- Универсальное и очень простое в использовании.
- Для каждой платформы PlayerPrefs имеет свою реализацию, и Unity это всё разруливает самостоятельно без участия разработчика.
- Поддерживает работу с типами int, float и string, но для сериализуемых данных достаточно только string.
- Не требует специальных разрешений на Android и других платформах.
- Есть ограничения на размер данных. Но этого достаточно для сохранений, особенно, если они разбиты на несколько ключей. В моей практике даже на насыщенных мидкорных проектах сохранения умещались в PlayerPrefs.
- Из-за ограничений на размер данных User Generated Content не желательно направлять в данное хранилище.
- Не везде стабильно работает в WebGL. Для этой платформы Unity внутри использует браузерную IndexedDB. При необходимости можно сделать своё аналогичное более контролируемое решение.
Удалённые хранилища данных в Unity. CloudSave:
public sealed class CloudSaveDataStorage : IDataStorage
{
private static IPlayerDataService PlayerService =>
CloudSaveService.Instance.Data.Player;
public async UniTask ReadAsync(string key)
{
var requestData = new HashSet { key };
Dictionary responseData =
await PlayerService.LoadAsync(requestData);
return responseData[key].Value.GetAsString();
}
public async UniTask WriteAsync(string key, string serializedData)
{
var requestData = new Dictionary
{
{ key, serializedData }
};
await PlayerService.SaveAsync(requestData);
}
public async UniTask DeleteAsync(string key)
{
await PlayerService.DeleteAsync(key);
}
public async UniTask ExistsAsync(string key)
{
List responseData =
await PlayerService.ListAllKeysAsync();
return responseData.Select(d => d.Key).Any(k => k == key);
}
}
- Хранилище по типу ключ-значение.
- В первых версиях API было очень похоже на PlayerPrefs. Со временем сильно усложнилось, став похожим на другие облачные сервисы.
- Есть поддержка хранилищ данных, привязанных к конкретным пользователям, и общего хранилища для всего тайтла (если нужно сохранять общее игровое состояние для многопользовательского проекта).
- Есть достаточный free-tier по кол-ву запросов для бесплатного использования в личных проектах.