18.10.2026 16 мин чтения Просмотров:0

Версионирование сохранений в Unity: миграции без потери прогресса

Как добавить номер версии в файл сохранения, написать цепочку миграций v1 → v2 → v3 на Json.NET, атомарно записывать сейв на диск и покрыть миграции тестами, чтобы патчи не стирали прогресс игроков.

Версионирование сохранений в Unity: миграции без потери прогресса

Эта статья — про то, как обновлять игру и не ломать игрокам старые сохранения. Вы добавите в файл сохранения номер версии, напишете цепочку миграций v1 → v2 → v3 на C#, научитесь безопасно записывать файл на диск так, чтобы обрыв питания не превратил сейв в мусор, и покроете всё это тестами. Код рассчитан на Unity 2021 LTS и новее, включая Unity 6, и использует Newtonsoft Json.NET из официального пакета Unity.

Почему сохранения ломаются после обновления

Первая версия системы сохранений почти всегда выглядит одинаково: класс SaveData, JsonUtility.ToJson, файл в Application.persistentDataPath. Пока игра не вышла, это работает идеально. Проблемы начинаются с первым патчем, когда меняется сам класс данных. Типичные изменения, каждое из которых тихо портит старые сейвы:

  • Переименование поля. Было gold, стало coins. Сериализатор не находит coins в старом файле и оставляет значение по умолчанию. Игрок заходит и видит ноль монет.
  • Смена типа. Инвентарь был списком строк ["sword", "potion", "potion"], а стал списком стопок с полями id и count. Старый массив либо не читается вовсе, либо превращается в пустой список.
  • Перенос поля во вложенный объект. level лежал в корне, а теперь живёт в player.level рядом с новым xp.
  • Порядок значений enum. Если enum сохранялся числом, а вы вставили новое значение в середину, все старые значения сдвинулись на единицу. Ошибки при чтении нет, данные просто стали другими.
  • Удаление поля. Само по себе безвредно, но если данные из него нужно было куда-то перенести, без миграции они теряются.

Хуже всего, что ни один из этих случаев не даёт исключения. JSON парсится, объект создаётся, игра запускается — и игрок обнаруживает потерю прогресса через пять минут. Отсюда главный принцип: формат сохранения — это публичный контракт, такой же, как API сервера. Менять его можно, но только вместе с кодом, который переводит старый формат в новый.

Номер версии формата, а не версия игры

Первое, что нужно сделать, — записывать в файл номер версии формата. Не Application.version, а отдельное целое число, которое вы увеличиваете вручную каждый раз, когда меняете структуру данных. Версия игры меняется в каждом патче, даже если формат сохранения остался прежним, и связывать миграции с ней неудобно: у вас появятся пустые «переходы» между версиями, в которых ничего не происходит.

Удобнее всего обернуть данные в конверт:

{
  "version": 3,
  "data": {
    "coins": 120,
    "player": { "level": 3, "xp": 0 },
    "inventory": [
      { "id": "sword", "count": 1 },
      { "id": "potion", "count": 2 }
    ]
  }
}

Конверт отделяет служебную информацию от игровых данных. Позже в него можно добавить дату сохранения, контрольную сумму или идентификатор слота, не трогая сам SaveData.

Отдельный вопрос — что делать с файлами, которые уже лежат у игроков и записаны без всякой версии. Ответ простой: считать их версией 1. Если в корне JSON нет поля version, значит, это старый формат без конверта, и весь корень — это данные первой версии. Этот случай мы обработаем в коде загрузки.

Подключаем Newtonsoft Json.NET

Миграции удобно писать на уровне JSON-дерева, а не готовых классов: старого класса уже нет в коде, а дерево можно читать и перестраивать как угодно. JsonUtility так не умеет, поэтому берём Json.NET. Unity поставляет его официальным пакетом com.unity.nuget.newtonsoft-json. Добавьте его через Package Manager по имени или строкой в Packages/manifest.json, указав версию, которую предлагает ваш редактор.

Если в проекте уже используется JsonUtility и переходить целиком не хочется, это не страшно: Json.NET понадобится только в загрузчике и миграциях. Финальное превращение дерева в объект можно сделать любым сериализатором.

Модель данных текущей версии

Начнём с того, как выглядит формат сейчас, в версии 3:

using System;
using System.Collections.Generic;

[Serializable]
public class SaveData
{
    public int coins;
    public PlayerData player = new PlayerData();
    public List<ItemStack> inventory = new List<ItemStack>();
}

[Serializable]
public class PlayerData
{
    public int level = 1;
    public int xp;
}

[Serializable]
public class ItemStack
{
    public string id;
    public int count;
}

Обратите внимание на инициализаторы полей. Json.NET создаёт объект через конструктор, поэтому если какого-то поля в файле нет, останется значение из инициализатора, а не null. Это первая линия обороны: новое поле с разумным значением по умолчанию часто вообще не требует миграции. Миграция нужна, когда данные надо перенести или преобразовать, а не просто добавить.

А вот как выглядели прошлые версии формата. Версия 1 — то, что было на релизе:

{ "gold": 120, "level": 3, "inventory": ["sword", "potion", "potion"] }

В версии 2 gold переименовали в coins, а инвентарь стал списком стопок. В версии 3 уровень переехал в объект player и появился опыт.

Интерфейс миграции

Каждая миграция умеет ровно одно: перевести данные из версии N в версию N+1. Никаких прыжков через версии — так каждый шаг остаётся маленьким и проверяемым, а путь от любой старой версии до текущей получается автоматически.

using Newtonsoft.Json.Linq;

public interface ISaveMigration
{
    // Версия, из которой мигрируем. Результат — FromVersion + 1.
    int FromVersion { get; }

    // Меняет дерево данных на месте.
    void Apply(JObject data);
}

Миграция получает только объект data, без конверта: номер версии — забота мигратора, а не отдельного шага.

Пишем миграции

v1 → v2: переименование и смена типа

using System.Linq;
using Newtonsoft.Json.Linq;

public sealed class SaveMigrationV1ToV2 : ISaveMigration
{
    public int FromVersion => 1;

    public void Apply(JObject data)
    {
        // gold -> coins с сохранением значения
        JProperty gold = data.Property("gold");
        if (gold != null)
        {
            gold.Replace(new JProperty("coins", gold.Value));
        }

        // ["sword", "potion", "potion"] -> [{ id, count }]
        if (data["inventory"] is JArray items && items.All(t => t.Type == JTokenType.String))
        {
            var stacks = new JArray();
            foreach (var group in items.Select(t => t.Value<string>()).GroupBy(id => id))
            {
                stacks.Add(new JObject
                {
                    ["id"] = group.Key,
                    ["count"] = group.Count()
                });
            }
            data["inventory"] = stacks;
        }
    }
}

Две детали, которые легко упустить. Во-первых, JProperty.Replace сохраняет позицию свойства в объекте — на работу это не влияет, но файл после миграции читается человеком так же, как раньше. Во-вторых, проверка items.All(...) делает шаг устойчивым к неожиданным данным: если массив уже состоит из объектов (например, файл частично отредактирован или пришёл из бета-ветки), миграция его не тронет. Пустой массив проходит проверку и превращается в пустой массив — тоже правильно.

v2 → v3: перенос поля во вложенный объект

using Newtonsoft.Json.Linq;

public sealed class SaveMigrationV2ToV3 : ISaveMigration
{
    public int FromVersion => 2;

    public void Apply(JObject data)
    {
        int level = data["level"]?.Value<int>() ?? 1;
        data.Remove("level");

        data["player"] = new JObject
        {
            ["level"] = level,
            ["xp"] = 0
        };
    }
}

Значение xp здесь можно было бы и не писать — инициализатор в PlayerData всё равно даст ноль. Но явная запись делает миграцию самодостаточной: глядя на неё, сразу видно, во что превращаются данные, без поиска по классам модели. Если же новое поле нужно вычислить из старых данных (скажем, начислить опыт по достигнутому уровню, чтобы ветераны не начинали с нуля), это делается именно здесь, а не в инициализаторе.

Мигратор: собираем цепочку

using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;

public static class SaveMigrator
{
    public const int CurrentVersion = 3;

    static readonly Dictionary<int, ISaveMigration> Migrations =
        new ISaveMigration[]
        {
            new SaveMigrationV1ToV2(),
            new SaveMigrationV2ToV3(),
        }.ToDictionary(m => m.FromVersion);

    // Принимает корень файла в любом известном формате,
    // возвращает конверт текущей версии.
    public static JObject Upgrade(JObject root, out int originalVersion)
    {
        JObject envelope = root["version"] == null
            ? new JObject { ["version"] = 1, ["data"] = root } // старый файл без конверта
            : root;

        int version = envelope["version"].Value<int>();
        originalVersion = version;

        if (version > CurrentVersion)
            throw new SaveFromFutureException(version, CurrentVersion);

        var data = (JObject)envelope["data"];
        while (version < CurrentVersion)
        {
            if (!Migrations.TryGetValue(version, out ISaveMigration migration))
                throw new InvalidOperationException($"Нет миграции сохранения с версии {version}");

            migration.Apply(data);
            version++;
        }

        envelope["version"] = version;
        return envelope;
    }

    // Для тестов: у каждой версии ниже текущей должна быть миграция.
    public static IEnumerable<int> MissingMigrations() =>
        Enumerable.Range(1, CurrentVersion - 1).Where(v => !Migrations.ContainsKey(v));
}

public sealed class SaveFromFutureException : Exception
{
    public int FileVersion { get; }

    public SaveFromFutureException(int fileVersion, int supported)
        : base($"Сохранение версии {fileVersion}, игра понимает максимум {supported}")
    {
        FileVersion = fileVersion;
    }
}

Миграции регистрируются в одном массиве. Можно собирать их рефлексией по всем реализациям ISaveMigration, но явный список лучше: он виден в одном месте, не зависит от того, что IL2CPP вырежет неиспользуемые типы при стриппинге, и не подхватит случайно тестовую миграцию из другой сборки.

Сохранение из будущего

Случай version > CurrentVersion выглядит экзотикой, пока не случится. А случается он регулярно: игрок поставил бету и вернулся на стабильную ветку, облачное сохранение пришло с устройства, где игра уже обновилась, вы откатили неудачный патч. Старый код не знает, как читать новый формат, и самое опасное, что он может сделать, — прочитать «как получится» и перезаписать файл, уничтожив прогресс.

Правило: сохранение из будущего никогда не перезаписывается. Покажите игроку сообщение «обновите игру», запустите её без автосохранения или в отдельном слоте, но не трогайте файл. Именно поэтому мигратор бросает отдельный тип исключения, а не общий InvalidOperationException: загрузчику нужно отличить этот случай от повреждённого файла.

Загрузка: от файла до объекта

using System.IO;
using System.Text;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;

public static class SaveSystem
{
    static string SavePath => Path.Combine(Application.persistentDataPath, "save.json");
    static string BackupPath => SavePath + ".bak";

    public static SaveData Load()
    {
        if (!File.Exists(SavePath))
            return new SaveData();

        try
        {
            return LoadFrom(SavePath);
        }
        catch (JsonException e) when (File.Exists(BackupPath))
        {
            Debug.LogWarning($"Сохранение повреждено, читаем резервную копию: {e.Message}");
            return LoadFrom(BackupPath);
        }
    }

    static SaveData LoadFrom(string path)
    {
        string json = File.ReadAllText(path, Encoding.UTF8);
        JObject envelope = SaveMigrator.Upgrade(JObject.Parse(json), out int originalVersion);

        if (originalVersion < SaveMigrator.CurrentVersion)
        {
            // Копия файла до миграции: если в миграции баг, данные можно восстановить.
            string archive = Path.Combine(
                Application.persistentDataPath, $"save.v{originalVersion}.json");
            if (!File.Exists(archive))
                File.Copy(path, archive);
        }

        return envelope["data"].ToObject<SaveData>();
    }
}

Несколько решений, которые здесь приняты сознательно.

  • Копия до миграции. Файл save.v1.json пишется один раз и больше не трогается. Если через неделю выяснится, что миграция v1 → v2 теряла предметы, у игроков останется оригинал, а у вас — возможность выпустить исправленную миграцию, которая читает именно его.
  • Мигрированный файл не перезаписывается сразу при загрузке. Новая версия ляжет на диск при первом обычном сохранении. Так загрузка остаётся операцией только для чтения, и её можно безопасно повторять.
  • Резервная копия ловит только ошибки парсинга. SaveFromFutureException через этот catch не проходит и должен обрабатываться выше — в коде, который показывает игроку сообщение.

Если сейвы у вас зашифрованы (например, AES), порядок всегда один: прочитать байты, расшифровать, распарсить, мигрировать. Версию формата данных не путайте с версией шифрования — если когда-нибудь поменяется ключ или алгоритм, для этого нужен свой номер в заголовке файла, снаружи зашифрованной части.

Атомарная запись: чтобы сейв не обнулился

Миграции защищают от изменений кода, но есть вторая классическая причина потери прогресса: файл оборвался на середине записи. Игру закрыли через диспетчер задач, телефон сел, ОС убила приложение в фоне. File.WriteAllText сначала обрезает файл до нуля, потом пишет новое содержимое, и между этими моментами на диске лежит пустой или недописанный JSON.

Стандартное решение — запись во временный файл и подмена. Оба метода ниже добавьте в тот же класс SaveSystem (для PlatformNotSupportedException понадобится using System;):

public static void Save(SaveData data)
{
    var envelope = new JObject
    {
        ["version"] = SaveMigrator.CurrentVersion,
        ["data"] = JObject.FromObject(data)
    };
    WriteAtomic(SavePath, envelope.ToString(Formatting.Indented));
}

static void WriteAtomic(string path, string contents)
{
    string tmp = path + ".tmp";

    using (var stream = new FileStream(tmp, FileMode.Create, FileAccess.Write, FileShare.None))
    using (var writer = new StreamWriter(stream, new UTF8Encoding(false)))
    {
        writer.Write(contents);
        writer.Flush();
        stream.Flush(true); // просим ОС сбросить данные на диск, а не держать в кэше
    }

    if (!File.Exists(path))
    {
        File.Move(tmp, path);
        return;
    }

    try
    {
        // Подменяет файл и сохраняет прежнюю версию в .bak одним действием
        File.Replace(tmp, path, path + ".bak");
    }
    catch (PlatformNotSupportedException)
    {
        File.Copy(path, path + ".bak", true);
        File.Delete(path);
        File.Move(tmp, path);
    }
}

Пока новый файл пишется, старый save.json остаётся целым. Если запись оборвётся, на диске останется недописанный save.json.tmp, который никто не читает, и прежний сейв. Подмена файла внутри одной папки — короткая операция, и вероятность попасть обрывом именно в неё несравнимо меньше, чем при перезаписи на месте. А если всё-таки попали, загрузчик откатится на .bak.

Про File.Move с флагом перезаписи: перегрузка File.Move(source, dest, overwrite) появилась в .NET Core 3.0 и в профиле .NET Standard 2.1, который используется в Unity, недоступна. Поэтому в запасной ветке явное удаление и перемещение. Не забудьте и про платформы со своими правилами хранения — WebGL, консоли, облачные сохранения магазинов: там файловый API может работать иначе, и запись стоит проверить отдельно на целевой сборке.

Как проектировать формат, чтобы миграций было меньше

Миграции — страховка, но каждую из них нужно написать, протестировать и поддерживать годами. Часть из них можно не писать вовсе, если с самого начала соблюдать несколько правил.

Сохраняйте идентификаторы, а не индексы и ссылки

Предмет в инвентаре должен храниться строковым id, а не индексом в массиве базы предметов и не именем ассета. Индекс сдвинется, как только вы добавите предмет в середину списка, имя ассета поменяет художник при наведении порядка в папках. Стабильный строковый идентификатор, который живёт в ScriptableObject предмета и никогда не меняется после релиза, переживёт любые реорганизации.

Enum — строками

По умолчанию Json.NET пишет enum числом. Подключите StringEnumConverter, и значение Difficulty.Hard окажется в файле как "Hard":

using Newtonsoft.Json;
using Newtonsoft.Json.Converters;

[Serializable]
public class SettingsData
{
    [JsonConverter(typeof(StringEnumConverter))]
    public Difficulty difficulty = Difficulty.Normal;
}

Теперь порядок значений в enum можно менять свободно. Переименование значения по-прежнему требует миграции, но это хотя бы явное действие, а не побочный эффект вставки новой строки.

Не сохраняйте то, что можно вычислить

Максимальное здоровье, урон оружия с учётом улучшений, цены в магазине — всё это производные от базовых данных и баланса. Если записать их в сейв, после патча с перебалансировкой у старых игроков останутся старые цифры, и понадобится миграция, которая их пересчитывает. Сохраняйте только первичные факты: какие улучшения куплены, какой уровень достигнут. Остальное считайте при загрузке.

Новые поля — с безопасным значением по умолчанию

Как уже говорилось, поле с инициализатором, которого нет в старом файле, получит значение по умолчанию без всякой миграции. По умолчанию Json.NET также игнорирует свойства JSON, которым нет соответствия в классе, поэтому удалённое из модели поле не ломает чтение. Используйте это: добавление поля с нейтральным значением не требует ни повышения версии, ни миграции. Версию стоит повышать, когда меняется смысл или расположение уже существующих данных.

Если нужен только номер версии

Бывает, что проект целиком на JsonUtility, и тащить Json.NET в него не хочется. Номер версии всё равно можно прочитать дёшево: JsonUtility игнорирует поля, которых нет в целевом типе.

[Serializable]
struct VersionProbe
{
    public int version;
}

int version = JsonUtility.FromJson<VersionProbe>(json).version; // 0, если поля нет

Дальше по номеру версии можно выбрать класс старого формата (SaveDataV1, SaveDataV2), прочитать файл в него и вручную перенести поля в текущий SaveData. Это рабочая альтернатива JSON-дереву с проверкой типов на этапе компиляции, но классы старых версий придётся хранить в проекте вечно, и с каждой новой версией их становится больше. Для небольшой игры с двумя-тремя изменениями формата за всю жизнь это нормальный компромисс, для игры-сервиса с регулярными патчами дерево удобнее.

Тестируем миграции

Миграция — тот код, который запускается на машине игрока ровно один раз, и если он ошибётся, второго шанса не будет. Поэтому тестировать её нужно до релиза, на настоящих файлах старых версий.

Практика, которая хорошо работает: перед каждым повышением CurrentVersion сохраняйте в репозиторий реальный сейв текущей версии как фикстуру. Не придуманный вручную, а сделанный игрой, лучше всего — с прогрессом, где задействованы все системы: полный инвентарь, пройденные квесты, нестандартные настройки. Фикстуры складывайте в папку тестов, например Assets/Tests/SaveFixtures/save_v1.json, save_v2.json и так далее. Эти файлы никогда не меняются: они изображают диски игроков, которые обновятся с этой версии.

using System.IO;
using System.Linq;
using Newtonsoft.Json.Linq;
using NUnit.Framework;

public class SaveMigrationTests
{
    const string Fixtures = "Assets/Tests/SaveFixtures";

    [Test]
    public void EveryVersionHasMigration()
    {
        CollectionAssert.IsEmpty(SaveMigrator.MissingMigrations());
    }

    [Test]
    public void V1_KeepsGoldAndStacksInventory()
    {
        var root = JObject.Parse(File.ReadAllText(Path.Combine(Fixtures, "save_v1.json")));

        JObject envelope = SaveMigrator.Upgrade(root, out int original);
        SaveData data = envelope["data"].ToObject<SaveData>();

        Assert.AreEqual(1, original);
        Assert.AreEqual(SaveMigrator.CurrentVersion, envelope["version"].Value<int>());
        Assert.AreEqual(120, data.coins);
        Assert.AreEqual(3, data.player.level);
        Assert.AreEqual(2, data.inventory.Single(s => s.id == "potion").count);
    }

    [TestCase("save_v1.json")]
    [TestCase("save_v2.json")]
    [TestCase("save_v3.json")]
    public void AnyFixture_SurvivesRoundTrip(string file)
    {
        var root = JObject.Parse(File.ReadAllText(Path.Combine(Fixtures, file)));
        JObject envelope = SaveMigrator.Upgrade(root, out _);

        SaveData first = envelope["data"].ToObject<SaveData>();
        SaveData second = JObject.FromObject(first).ToObject<SaveData>();

        Assert.AreEqual(first.coins, second.coins);
        Assert.AreEqual(first.inventory.Count, second.inventory.Count);
    }

    [Test]
    public void FutureVersion_Throws()
    {
        var root = new JObject
        {
            ["version"] = SaveMigrator.CurrentVersion + 1,
            ["data"] = new JObject()
        };

        Assert.Throws<SaveFromFutureException>(() => SaveMigrator.Upgrade(root, out _));
    }
}

Это Edit Mode тесты: им не нужна сцена и Play Mode, они выполняются за доли секунды и отлично подходят для CI. Путь к фикстурам относительный — в редакторе текущей папкой процесса является корень проекта. Сборке тестов понадобится ссылка на сборку с системой сохранений и, если вы используете Assembly Definitions, на Newtonsoft.Json.dll через Assembly References с включённым Override References.

Тест EveryVersionHasMigration выглядит тривиальным, но ловит самую частую ошибку: CurrentVersion увеличили, а новую миграцию забыли зарегистрировать в массиве. Без этого теста ошибка всплывёт у первого же игрока со старым сейвом.

Порядок действий при изменении формата

Когда процесс отлажен, каждое изменение структуры сохранения сводится к короткому списку.

  1. Сохраните из текущей сборки реальный сейв как фикстуру save_vN.json и закоммитьте его.
  2. Измените классы модели данных.
  3. Напишите класс миграции SaveMigrationVNToVN1 и добавьте его в массив мигратора.
  4. Увеличьте CurrentVersion на единицу.
  5. Добавьте тест на конкретные преобразования новой миграции и строку TestCase для новой фикстуры.
  6. Прогоните тесты, затем вручную загрузите в игре сейв предыдущей версии и убедитесь, что прогресс на месте.

И несколько вещей, которых делать не стоит. Не правьте опубликованные миграции задним числом, кроме исправления ошибок: у части игроков сейвы уже прошли через них, и поведение должно быть воспроизводимым. Не удаляйте старые миграции, даже если кажется, что все давно обновились: игроки возвращаются в игру через год и через три. И не повышайте версию ради добавления поля с безопасным значением по умолчанию — это лишний шаг в цепочке, который ничего не делает.

Итог

Система сохранений, которая переживает обновления, держится на четырёх вещах: номер версии формата в каждом файле, цепочка маленьких миграций N → N+1 над JSON-деревом, атомарная запись с резервной копией и тесты на реальных файлах старых версий. Всё это — несколько сотен строк кода, которые пишутся один раз. Добавить их лучше до релиза: тогда первый же патч, меняющий структуру данных, пройдёт для игроков незаметно, а вы не будете разбирать отзывы о пропавшем прогрессе.

Комментарии

Чтобы оставить комментарий, войдите в аккаунт.

Пока нет комментариев. Будьте первым!

Смотрите также