Посмотрел я накануне это видео и "вдохновился"… тем, что в видео показан очень сложный способ сделать очень простую вещь - миграцию пользовательских данных.
Если интересен контекст моего баттхерта, то стоит посмотреть оригинальное видео, если же нет - можно перейти сразу сюда к моей реализации.
В примере на каждую новую версию создается новая модель данных, что порождает пару проблем:
тип данных изменился, следовательно во всем коде вместо обращения в GameDataV1 мне придется переписывать тип данных на GameDataV2. А это, как правило, сотни ссылок даже на гипер/гибрид/гидра/гига/или-как-их-там-сейчас казуальных играх
в разы больше проблем, если весь игровой код будет обращаться к GameStateBase (больше я не вижу причин вводить этот родительский класс) и каждое обращение к игровым данным будет c явным привидением в виде int someData = ((GameDataV2)data).Value;
куча неиспользуемого кода - если я 100 раз изменю данные, то у меня в коде будет 100 "лишних" классов, которые использовались буквально один раз. Не считая кода самих миграций, но он в любом случае будет
Поэтому здесь я покажу более простой способ реализации миграций без лишних иерархий и написания новой модели данных под каждую версию. Конечно, способ "более просто" только относительно - для него надо уметь пользоваться не только JsonUtility, а еще Newtonsoft.Json (то есть пролистнуть документацию или посмотреть 10-минутный тутор). Но было бы странно браться за миграцию, если вам и такое не под силу.
Кстати, если сразу использовать Newtonsoft и (по какой-то причине) оставлять все типы данных при обновлении, то можно написать кастомные JsonConverter'ы и избежать всех велосипедов из видео.
Дисклеймер:
Текущая реализация написана исключительно для того, чтобы понять концепцию. В ней пропущены многие проверки и нюансы ради лаконичности. Конечная реализация обычно ориентируется на конкретный проект и сильнее защищается от возможных багов.
Также на реальном проекте, вместо написания очередных костылей, можно использовать готовые решения вроде FastMigrations.Json.Net. Которые сэкономят время и будут более оптимизированными. Хотя в данной реализации мне не нравится, что все миграции надо писать прямиком в модели.
Мне все еще очень нравится идея оригинального видео, так как она подсвечивает частую проблему на проектах и я не видел подобных роликов. Но кроме идеи, мне в нем ничего не нравится. Сама идея написания статья родилась из страха, с которым представляю толпу маслят, которые бездумно копипастят свои PlayerData/SaveData/GameData перед каждым обновлением проекта.
Оригинальный подход может подойти (и то, мне кажется, есть более удобные варианты), если вы делаете крошечные проекты под веб, у вас каждый килобайт на счету, а newtonsoft занимает целых 250 КБ в конечном билде.
Перед началом душной технички можете заскочить на мой телеграм-канал или, если от миграции игровых данных вас отделяет отсутствие работы, даже задуматься о менторстве.
Поехали
Пример возьмем аналогичный. Есть 3 версии игровых данных, ведь, как это часто бывает, мы не всегда готовы к предстоящим изменениям в ТЗ. Дальше отойдем от вырожденного примера в видео, создадим новую ветку и изменим в ней безвозвратно нашу модель. Затем повторим это для третьей версии. То есть, воспроизведем реальный рабочий кейс - мы выпустили первую версию и сделали две новых.
Таким образом, ваш гит будет выглядеть примерно так:

Следовательно, миграции будем проверять честно: сохраняя данные на нулевой версии, и перескакивая на ветки с версией 1 и 2. В конце проверим случай с "пользователем", который поиграл на нулевой версии, и обновился сразу на вторую, то есть перескочил одну версию. В таком случае должны отработать обе миграции: с версии 0 на версию 1 и с 1 на 2.
Как будет выглядеть модель игровых данных в разных версиях:
Храним в одном поле имя персонажа, количество дерева и камня, и ,конечно же , версию, по которой будем прогонять миграции:
[Serializable]
public class GameData
{
public string Version = "0.1.0";
public string PlayerName;
public int Wood;
public int Stone;
}
2. Затем бравые геймдизайнеры решили, что у персонажа имя и фамилия должны быть отдельно:
[Serializable]
public class GameData
{
public string Version = "0.1.0";
public string FirstName;
public string LastName;
public int Wood;
public int Stone;
}
3. Теперь добавляем больше ресурсов и храним их в списке, а не втупую отдельными полями. Ресурсы положим в отдельный класс, а не словарь, так как уже за 2 версии научились не верить ГД на слово и сразу готовимся к тому, что в ресурсы добавятся еще данные. А еще потому что в инспекторе словарь рисовать неудобно.
[Serializable]
public class GameData
{
public string Version = "0.1.0";
public string FirstName;
public string LastName;
public List<Resource> Resources = new();
}
[Serializable]
public class Resource
{
public string Name;
public int Amount;
}
Теперь напишем интерфейс для наших миграций и общий класс, который будет те самые миграции выполнять:
public interface IMigration
{
Version ToVersion { get; }
JObject Migrate(JObject data);
}
Подробнее про JObject можно прочитать здесь, здесь и здесь. Если по тупому (иначе и не могу), то это состояние данных где-то посередине между сырой строкой и конечным классом. Мы можем по ключам, как в словаре, получать доступы к полям в JSON объекте и менять их как нам захочется. И все что нам надо будет сделать в реализациях этого интерфейс - привести данные из одной версии модели в другую. То есть добавить новые поля, заполнить значениями из старых и удалить старые. Хотя последнее не обязательно - они и сами затрутся при следующем сохранении - но для красоты и уменьшения энтропии в коде удалим сразу.
В качестве версии во время миграций будем использовать System.Version. В нем сразу версия представлена в удобном формате (major.minor.build) и уже реализованы методы для сравнения (чтобы не пришлось самому определять, какая версия новее: "1.2.3" или "1.3.2")
Теперь сам сервис для миграций:
public class Migrator
{
private readonly IOrderedEnumerable<IMigration> _migrations;
public Migrator(params IMigration[] migrations) =>
_migrations = migrations.OrderBy(x => x.ToVersion);
public T Execute<T>(string rawData)
{
JObject data = JObject.Parse(rawData);
Version version = new(data["Version"]!.ToString());
if (version.CompareTo(_migrations.Last().ToVersion) >= 0)
return Parse<T>(data);
foreach (IMigration migration in _migrations)
{
if (version.CompareTo(migration.ToVersion) < 0)
{
data = migration.Migrate(data);
data["Version"] = migration.ToVersion.ToString();
version = migration.ToVersion;
}
}
return Parse<T>(data);
}
private T Parse<T>(JObject data) =>
JsonConvert.DeserializeObject<T>(data.ToString());
}
Тут все просто:
в конструктор подаем сами миграции (которые добавляем к необходимым версиям игры) и сортируем их по возрастанию версии. System.Version уже реализует интерфейс IComparable, поэтому OrderBy сам разберется какая ToVersion новее.
при выполнении метода Execute сначала проверим, что версия не старше последней, подлежащей миграции. Тогда данные будут считаться новыми и можем спокойно их распарсить и использовать дальше
Затем для всех миграций подряд делаем аналогичную проверку. Если версия указанная в сохранении младше той, что указана в миграции, то эту самую миграцию и выполняем. Затем обновляем версию в сохранении.
В конце точно также парсим из JSON наши, актуальные после миграций, данные.
Здесь стоит сразу рассказать как работает метод Version.CompareTo:
если версии равны, возвращает 0
если версия, которую сравнивают (для которой вызывается метод, которая как бы "слева" от CompareTo) новее, то возвращает 1
если старше - то -1
Наши миграции
Как мы помним, в первом измении было принято решение разделить имя и фамилию игрока в отдельные поля вместо общего PlayerName. Предположим, все игроки молодцы и изначально писали имя игрока просто в два слова, чтобы нам потом было удобно делить. Значит, наша первая миграция будет выглядеть так:
public class Migration_1_2 : IMigration
{
public Version ToVersion { get; } = new Version(0, 2, 0);
public JObject Migrate(JObject data)
{
string fullName = data["PlayerName"].ToString();
string[] names = fullName.Split(' ');
data["FirstName"] = names[0];
data["LastName"] = names[1];
data.Remove("PlayerName");
return data;
}
}
При следующем обновлении мы решили держать ресурсы не отдельно, а сразу списком. Тогда во время миграции нам надо будет всего лишь заполнить список Resources значениями из уже устаревших полей Wood и Stone:
public class Migration_2_3 : IMigration
{
public Version ToVersion { get; } = new Version(0, 3, 0);
public JObject Migrate(JObject data)
{
int woodAmount = data["Wood"]?.Value<int>() ?? 0;
int stoneAmount = data["Stone"]?.Value<int>() ?? 0;
JArray resources = data["Resources"] as JArray ?? new JArray();
resources.Add(JObject.FromObject(new { Name = "Wood", Amount = woodAmount }));
resources.Add(JObject.FromObject(new { Name = "Stone", Amount = stoneAmount }));
data["Resources"] = resources;
data.Remove("Wood");
data.Remove("Stone");
return data;
}
}
Получается, чтобы уметь парсить данные из самой первой версии в последнюю, надо всего лишь написать такие строчки кода:
string json = File.ReadAllText(Path);
GameData data = new Migrator(new Migration_1_2(), new Migration_2_3())
.Execute<GameData>(json);
Убедимся, что все работает
Без лишней верстки и запуска плеймода проверим работу миграций в инспекторе:

в Saved Data будет показываться сырой JSON, который лежит на устройстве
в Game Data будут парситься сырые данные, попутно проходя через миграции
Код GameDataEditor:
public class GameDataEditor : MonoBehaviour
{
[SerializeField, TextArea, ReadOnly] private string _savedData;
[SerializeField] private GameData _gameData;
private string Path => System.IO.Path.Combine(Application.streamingAssetsPath, "GameData.json");
[Button]
private void Save() =>
File.WriteAllText(Path, JsonConvert.SerializeObject(_gameData));
[Button]
private void Load()
{
string json = File.ReadAllText(Path);
_gameData = new Migrator(new Migration_1_2(), new Migration_2_3())
.Execute<GameData>(json);
}
[Button]
private void Show()
{
if (File.Exists(Path))
_savedData = File.ReadAllText(Path);
}
[Button]
private void Clean()
{
_savedData = null;
_gameData = null;
}
}
Для атрибутов Button и ReadOnly используется Odin Inspector, но вы можете использовать в качестве аналога Tri Inspector или просто встроенный атрибут ContextMenu (только последний так красиво не рисуется).
Введем рандомные данные в инспекторе, нажмем Save и Show. После этого мы должны увидеть также заполненное поле Saved Data:

Теперь перепрыгнем на ветку со следующей версией, очистим введенные данные и посмотрим как сработает миграция. При положительном результате мы увидем John и Doe в разных полях FirstName и SecondName соответственно:

Теперь переименуем файл сохранения, чтобы на него не "смотрел" GameDataEditor, и сохраним полученные данные. Таким образом мы сразу сможем проверить миграцию по последней версии с 1 и 2.

Сначала проверим миграцию с версии 2 на версию 3. Подход аналогичный: чистим инспектор и нажимаем Load:

Все работает. Теперь удаляем файл GameData и возвращаем оригинальное название файлу GameData old и делаем аналогичное действие:

Вуаля! Миграция с первой сразу на последнюю версию также работает.
Заключение
Оригинальный видос не плохой, просто такой подход, по моему скромному мнению, гораздо удобнее (хотя и принуждает пользоваться не только бедным JsonUtility).
Для желающих, все решение оформлено в отдельный репозиторий. Там же добавлены дополнительные проверки и собрана первая версия пакета для тех, что захочет применить это на своем проекте (хоть там всего под сотню строк кода).
Ссылки и литература
FastMigrations.Json.Net: https://github.com/vangogih/FastMigrations.Json.Net
JObject: