Миграции данных в Unity
Посмотрел я накануне это видео и "вдохновился"… тем, что в видео показан очень сложный способ сделать очень простую вещь - миграцию пользовательских данных.
Если интересен контекст моего баттхерта, то стоит посмотреть оригинальное видео, если же нет - можно перейти сразу сюда к моей реализации.
В примере на каждую новую версию создается новая модель данных, что порождает пару проблем:
- тип данных изменился, следовательно во всем коде вместо обращения в 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.
Как будет выглядеть модель игровых данных в разных версиях:
1. Храним в одном поле имя персонажа, количество дерева и камня, и, конечно же, версию, по которой будем прогонять миграции:
2. Затем бравые геймдизайнеры решили, что у персонажа имя и фамилия должны быть отдельно:
3. Теперь добавляем больше ресурсов и храним их в списке, а не втупую отдельными полями. Ресурсы положим в отдельный класс, а не словарь, так как уже за 2 версии научились не верить ГД на слово и сразу готовимся к тому, что в ресурсы добавятся еще данные. А еще потому что в инспекторе словарь рисовать неудобно.
Теперь напишем интерфейс для наших миграций и общий класс, который будет те самые миграции выполнять:
Подробнее про JObject можно прочитать здесь, здесь и здесь. Если по тупому (иначе и не могу), то это состояние данных где-то посередине между сырой строкой и конечным классом. Мы можем по ключам, как в словаре, получать доступы к полям в JSON объекте и менять их как нам захочется. И все что нам надо будет сделать в реализациях этого интерфейс - привести данные из одной версии модели в другую. То есть добавить новые поля, заполнить значениями из старых и удалить старые. Хотя последнее не обязательно - они и сами затрутся при следующем сохранении - но для красоты и уменьшения энтропии в коде удалим сразу.
В качестве версии во время миграций будем использовать System.Version. В нем сразу версия представлена в удобном формате (major.minor.build) и уже реализованы методы для сравнения (чтобы не пришлось самому определять, какая версия новее: "1.2.3" или "1.3.2")
Теперь сам сервис для миграций:
Тут все просто:
- в конструктор подаем сами миграции (которые добавляем к необходимым версиям игры) и сортируем их по возрастанию версии. System.Version уже реализует интерфейс IComparable, поэтому OrderBy сам разберется какая ToVersion новее.
- при выполнении метода Execute сначала проверим, что версия не не старше последней, подлежащей миграции. Тогда данные будут считаться новыми и можем спокойно их распарсить и использовать дальше
- Затем для всех миграций подряд делаем аналогичную проверку. Если версия указанная в сохранении младше той, что указана в миграции, то эту самую миграцию и выполняем. Затем обновляем версию в сохранении.
- В конце точно также парсим из JSON наши, актуальные после миграций, данные.
Здесь стоит сразу рассказать как работает метод Version.CompareTo:
- если версии равны, возвращает 0
- если версия, которую сравнивают (у которой вызывается метод, которая как бы "слева" от CompareTo) новее, то возвращает 1
- если старше - то -1
Наши миграции
Как мы помним, в первом измении было принято решение разделить имя и фамилию игрока в отдельные поля вместо общего PlayerName. Предположим, все игроки молодцы и изначально писали имя игрока просто в два слова, чтобы нам потом было удобно делить. Значит, наша первая миграция будет выглядеть так:
При следующем обновлении мы решили держать ресурсы не отдельно, а сразу списком. Тогда во время миграции нам надо будет всего лишь заполнить список Resources значениями из уже устаревших полей Wood и Stone:
Получается, чтобы уметь парсить данные из самой первой версии в последнюю, надо всего лишь написать такие строчки кода:
Убедимся, что все работает
Без лишней верстки и запуска плеймода проверим работу миграций в инспекторе:
- в Saved Data будет показываться сырой JSON, который лежит на устройстве
- в Game Data будут парситься сырые данные, попутно проходя через миграции
Код GameDataEditor:
Для атрибутов 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).
Для желающих, все решение оформлено в отдельный репозиторий. Там же добавлены дополнительные проверки и собрана первая версия пакета для тех, что захочет применить это на своем проекте (хоть там всего под сотню строк кода).
Ссылки и литература
Мой телеграм канал: https://t.me/GamedevForge
Менторство: https://teletype.in/@redhurt/mentoring
Оригинальное видео: https://youtu.be/d7K_77KRXHU?si=TNhqNlj9ebBvdOQk
NewtonSoft.Json: https://www.newtonsoft.com/json
Custom JsonConverter: https://www.newtonsoft.com/json/help/html/CustomJsonConverter.htm
JObject: