Из этой статьи вы узнаете, как перевести сохранения Unity-игры с JSON на компактный бинарный формат: написать свой формат на BinaryWriter и BinaryReader с заголовком, версией и контрольной суммой, безопасно записывать файл без риска получить «битое» сохранение и подключить MemoryPack — генератор сериализаторов, который избавляет от ручного кода. В конце — честное сравнение, когда бинарный формат действительно нужен, а когда достаточно JSON.
Зачем вообще уходить от JSON
JSON хорош для начала: файл читается глазами, его легко поправить руками при отладке, JsonUtility встроен в движок. Но на живом проекте у текстового формата всплывают минусы:
- Размер. Каждое число хранится строкой, у каждого поля есть имя в кавычках. Массив из 10 000 позиций
Vector3в JSON занимает сотни килобайт, в бинарном виде — ровно 120 000 байт (триfloatпо 4 байта). - Скорость. Парсинг текста — это разбор символов, поиск кавычек, преобразование строк в числа. Бинарное чтение — копирование байтов. На мобильных устройствах разница в загрузке сохранения бывает в разы.
- Мусор. Текстовая сериализация создаёт промежуточные строки, и сборщик мусора потом их убирает — как правило, в самый неудобный момент.
- Культура и формат чисел. Ручная сборка JSON через
ToString()на системе с запятой в качестве десятичного разделителя превращает1.5в1,5, и файл перестаёт читаться. В бинарном формате такой проблемы нет:floatвсегда занимает четыре байта.
При этом бинарный формат не шифрование и не защита от читеров. Он просто менее очевиден для глаз. Если нужна защита — это отдельная задача: подпись или шифрование поверх готовых байтов.
Основы: BinaryWriter и BinaryReader
В .NET есть пара классов из System.IO, которые пишут и читают примитивы в поток. Они доступны в Unity без всяких пакетов.
using System.IO;
using UnityEngine;
public static class BinaryBasics
{
public static void Write(string path)
{
using var stream = File.Create(path);
using var writer = new BinaryWriter(stream);
writer.Write(42); // int, 4 байта
writer.Write(3.14f); // float, 4 байта
writer.Write(true); // bool, 1 байт
writer.Write("Игрок"); // строка с префиксом длины, UTF-8
}
public static void Read(string path)
{
using var stream = File.OpenRead(path);
using var reader = new BinaryReader(stream);
int level = reader.ReadInt32();
float speed = reader.ReadSingle();
bool alive = reader.ReadBoolean();
string name = reader.ReadString();
Debug.Log($"{level} {speed} {alive} {name}");
}
}
Главное правило бинарного формата: читать нужно ровно в том же порядке и теми же типами, в каких писали. Здесь нет имён полей. Если записать int, а прочитать float, вы не получите ошибку — вы получите мусор, и последующие поля тоже «поплывут». Поэтому формат стоит описывать в одном месте, а запись и чтение держать рядом, чтобы их было легко сверить.
Несколько деталей, которые полезно знать:
BinaryWriterпишет числа в порядке little-endian независимо от платформы, поэтому файл с Windows прочитается на Android и iOS.Write(string)записывает длину строки в байтах компактным 7-битным кодом, а затем сами байты в UTF-8. Кодировку можно передать в конструктор, но менять её между версиями нельзя.- Через
BinaryWriterнельзя записатьnull-строку: будет исключение. Для необязательных значений пишите флаг наличия. using varгарантирует, что поток закроется и данные сбросятся на диск даже при исключении.
Запись Unity-типов
BinaryWriter ничего не знает о Vector3, Quaternion или Color. Удобнее всего добавить методы расширения и дальше пользоваться ими во всём коде сохранений.
using System.IO;
using UnityEngine;
public static class BinaryUnityExtensions
{
public static void Write(this BinaryWriter w, Vector3 v)
{
w.Write(v.x); w.Write(v.y); w.Write(v.z);
}
public static Vector3 ReadVector3(this BinaryReader r)
{
return new Vector3(r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
}
public static void Write(this BinaryWriter w, Quaternion q)
{
w.Write(q.x); w.Write(q.y); w.Write(q.z); w.Write(q.w);
}
public static Quaternion ReadQuaternion(this BinaryReader r)
{
return new Quaternion(r.ReadSingle(), r.ReadSingle(), r.ReadSingle(), r.ReadSingle());
}
public static void WriteNullableString(this BinaryWriter w, string s)
{
w.Write(s != null);
if (s != null) w.Write(s);
}
public static string ReadNullableString(this BinaryReader r)
{
return r.ReadBoolean() ? r.ReadString() : null;
}
}
Метод расширения с именем Write и параметром Vector3 не конфликтует с методами самого BinaryWriter: у класса нет перегрузки под этот тип, поэтому компилятор выберет расширение. Для чтения так не получится — перегрузку по возвращаемому типу C# не различает, поэтому методы чтения называются по типу: ReadVector3, ReadQuaternion.
Модель данных сохранения
Возьмём типичное сохранение: профиль игрока, инвентарь и состояние объектов на уровне.
using System.Collections.Generic;
using UnityEngine;
public sealed class SaveData
{
public string PlayerName;
public int Level;
public float Health;
public Vector3 Position;
public Quaternion Rotation;
public List<ItemStack> Inventory = new();
public List<WorldObjectState> World = new();
}
public struct ItemStack
{
public string ItemId;
public int Count;
}
public struct WorldObjectState
{
public string Id;
public bool Destroyed;
public Vector3 Position;
}
Обратите внимание: в модели только данные. Ни ссылок на GameObject, ни MonoBehaviour, ни ассетов — сохраняется то, что можно восстановить: идентификаторы предметов и объектов, числа, флаги. Ссылки на ассеты превращаются в строковые ID, которые при загрузке ищутся в базе предметов.
Формат файла: заголовок, версия, контрольная сумма
Голый поток полей — плохой формат. Через полгода разработки вы добавите поле, и старые сохранения начнут читаться криво. Минимальный набор, который стоит заложить с первого дня:
- Магическое число — несколько байтов в начале файла, по которым видно, что это ваш файл, а не пустой или чужой.
- Версия формата — целое число, которое вы увеличиваете при каждом несовместимом изменении.
- Длина и контрольная сумма тела — чтобы обнаружить обрезанный или повреждённый файл до того, как вы начнёте его разбирать.
- Тело — собственно данные.
Контрольную сумму удобно считать по CRC32. Готового класса в стандартной библиотеке Unity нет, но табличная реализация занимает полтора десятка строк:
using System;
public static class Crc32
{
private static readonly uint[] Table = CreateTable();
private static uint[] CreateTable()
{
var table = new uint[256];
for (uint i = 0; i < 256; i++)
{
uint c = i;
for (int k = 0; k < 8; k++)
c = (c & 1) != 0 ? 0xEDB88320u ^ (c >> 1) : c >> 1;
table[i] = c;
}
return table;
}
public static uint Compute(ReadOnlySpan<byte> data)
{
uint crc = 0xFFFFFFFFu;
foreach (byte b in data)
crc = Table[(crc ^ b) & 0xFF] ^ (crc >> 8);
return crc ^ 0xFFFFFFFFu;
}
}
CRC32 ловит случайные повреждения: недописанный файл, сбой диска, обрыв синхронизации облачных сохранений. От намеренной подделки он не защищает — пересчитать CRC может кто угодно. Для защиты нужен HMAC с ключом, но это уже другой разговор.
Сериализатор с версиями
Соберём всё вместе. Тело сначала пишется в MemoryStream, затем по нему считается CRC, и только потом всё уходит в файл.
using System;
using System.IO;
using System.Text;
public static class SaveSerializer
{
private const uint Magic = 0x56415355; // байты "USAV" в файле
public const int CurrentVersion = 3;
public static byte[] Serialize(SaveData data)
{
using var body = new MemoryStream();
using (var w = new BinaryWriter(body, Encoding.UTF8, leaveOpen: true))
{
WriteBody(w, data);
}
byte[] bodyBytes = body.ToArray();
using var result = new MemoryStream(bodyBytes.Length + 16);
using (var w = new BinaryWriter(result, Encoding.UTF8, leaveOpen: true))
{
w.Write(Magic);
w.Write(CurrentVersion);
w.Write(bodyBytes.Length);
w.Write(Crc32.Compute(bodyBytes));
w.Write(bodyBytes);
}
return result.ToArray();
}
private static void WriteBody(BinaryWriter w, SaveData d)
{
w.Write(d.PlayerName ?? string.Empty);
w.Write(d.Level);
w.Write(d.Health);
w.Write(d.Position);
w.Write(d.Rotation);
w.Write(d.Inventory.Count);
foreach (var item in d.Inventory)
{
w.Write(item.ItemId);
w.Write(item.Count);
}
w.Write(d.World.Count);
foreach (var obj in d.World)
{
w.Write(obj.Id);
w.Write(obj.Destroyed);
w.Write(obj.Position);
}
}
}
Перед каждой коллекцией пишется её длина — без неё при чтении невозможно понять, где кончается инвентарь и начинается мир.
Теперь чтение. Самое интересное здесь — ветвление по версии. Допустим, история формата такая: в версии 1 не было поворота игрока, в версии 2 появился Rotation, в версии 3 у объектов мира добавилось поле Position.
using System;
using System.IO;
using System.Text;
using UnityEngine;
public static class SaveDeserializer
{
public sealed class CorruptSaveException : Exception
{
public CorruptSaveException(string message) : base(message) { }
}
public static SaveData Deserialize(byte[] bytes)
{
using var stream = new MemoryStream(bytes, writable: false);
using var r = new BinaryReader(stream, Encoding.UTF8);
if (bytes.Length < 16 || r.ReadUInt32() != 0x56415355)
throw new CorruptSaveException("Не файл сохранения");
int version = r.ReadInt32();
if (version < 1 || version > SaveSerializer.CurrentVersion)
throw new CorruptSaveException($"Неизвестная версия {version}");
int length = r.ReadInt32();
uint crc = r.ReadUInt32();
if (length < 0 || length > bytes.Length - 16)
throw new CorruptSaveException("Файл обрезан");
var body = new ReadOnlySpan<byte>(bytes, 16, length);
if (Crc32.Compute(body) != crc)
throw new CorruptSaveException("Контрольная сумма не совпала");
return ReadBody(r, version);
}
private static SaveData ReadBody(BinaryReader r, int version)
{
var d = new SaveData
{
PlayerName = r.ReadString(),
Level = r.ReadInt32(),
Health = r.ReadSingle(),
Position = r.ReadVector3(),
Rotation = version >= 2 ? r.ReadQuaternion() : Quaternion.identity
};
int itemCount = ReadCount(r);
for (int i = 0; i < itemCount; i++)
d.Inventory.Add(new ItemStack { ItemId = r.ReadString(), Count = r.ReadInt32() });
int worldCount = ReadCount(r);
for (int i = 0; i < worldCount; i++)
{
var obj = new WorldObjectState
{
Id = r.ReadString(),
Destroyed = r.ReadBoolean()
};
if (version >= 3) obj.Position = r.ReadVector3();
d.World.Add(obj);
}
return d;
}
private static int ReadCount(BinaryReader r)
{
int count = r.ReadInt32();
if (count < 0 || count > 1_000_000)
throw new CorruptSaveException($"Подозрительная длина коллекции: {count}");
return count;
}
}
Заметьте две защиты. Первая — проверка длины коллекции: если файл повреждён и вместо длины прочитается, скажем, два миллиарда, без проверки игра попытается выделить гигантский список и упадёт с нехваткой памяти. Вторая — проверка CRC до разбора тела: повреждённый файл отсекается сразу, а не на середине чтения.
Правила эволюции такого формата простые:
- Новые поля добавляются в конец соответствующего блока, а при чтении оборачиваются в
if (version >= N)со значением по умолчанию для старых файлов. - Удалить поле нельзя — его всё равно придётся прочитать (и выбросить) в ветке для старых версий.
- Писать всегда нужно в текущей версии. Старые версии только читаются.
- Каждое изменение формата сопровождается тестом: старый файл из репозитория должен по-прежнему загружаться.
Если миграции становятся сложными (поле переехало в другой объект, поменялся смысл значения), лучше разделить этапы: сначала прочитать старую версию в старую модель, затем перевести модель в новую отдельной функцией миграции.
Атомарная запись: сохранение, которое не ломается
Самая частая причина потерянного прогресса — не баг в сериализации, а прерванная запись. Игрок закрыл игру, сел аккумулятор, ОС выгрузила приложение — и на диске остался файл, записанный наполовину. Старое сохранение к этому моменту уже перезаписано.
Лечится это классическим приёмом: пишем во временный файл, а затем подменяем им основной.
using System.IO;
using UnityEngine;
public static class SaveStorage
{
private static string MainPath;
// путь читается один раз в главном потоке: из фона persistentDataPath вызывать нельзя
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
private static void Init()
{
MainPath = Path.Combine(Application.persistentDataPath, "save.dat");
}
private static string TempPath => MainPath + ".tmp";
private static string BackupPath => MainPath + ".bak";
public static void Save(SaveData data)
{
byte[] bytes = SaveSerializer.Serialize(data);
using (var fs = new FileStream(TempPath, FileMode.Create, FileAccess.Write, FileShare.None))
{
fs.Write(bytes, 0, bytes.Length);
fs.Flush(flushToDisk: true);
}
if (File.Exists(MainPath))
{
File.Replace(TempPath, MainPath, BackupPath);
}
else
{
File.Move(TempPath, MainPath);
}
}
public static SaveData Load()
{
if (TryLoad(MainPath, out var data)) return data;
if (TryLoad(BackupPath, out data))
{
Debug.LogWarning("Основное сохранение повреждено, загружена резервная копия");
return data;
}
return null;
}
private static bool TryLoad(string path, out SaveData data)
{
data = null;
if (!File.Exists(path)) return false;
try
{
data = SaveDeserializer.Deserialize(File.ReadAllBytes(path));
return true;
}
catch (System.Exception e)
{
Debug.LogError($"Не удалось прочитать {path}: {e.Message}");
return false;
}
}
}
Как это работает: пока идёт запись, основной файл не тронут. Flush(true) просит ОС сбросить буферы на диск. File.Replace подменяет основной файл временным и одновременно сохраняет предыдущую версию в .bak. Если запись прервётся на любом шаге, на диске останется либо старое целое сохранение, либо новое целое, а CRC в заголовке поможет отличить целый файл от битого.
Несколько оговорок по платформам:
- На WebGL файловая система эмулируется поверх IndexedDB, и данные попадают в хранилище браузера асинхронно. Атомарность там обеспечивает уже браузер, а сама схема с временным файлом не вредит.
- На консолях есть собственные API сохранений с требованиями сертификации — там эту схему заменяет платформенный слой.
- Если
File.Replaceна какой-то платформе ведёт себя неожиданно, запасной вариант — удалить старый.bak, переименовать основной файл в.bak, а временный — в основной.
Не блокируем кадр
Сериализация небольшого сохранения занимает доли миллисекунды, а вот запись на диск на слабом телефоне может растянуться. Разумная схема: снимок данных собирается в главном потоке (там, где можно трогать объекты Unity), а сериализация и запись уходят в фон.
using System.Threading.Tasks;
using UnityEngine;
public sealed class SaveService : MonoBehaviour
{
private Task _pending = Task.CompletedTask;
public void SaveAsync(SaveData snapshot)
{
// snapshot — уже собранная копия данных, без ссылок на объекты сцены
_pending = _pending.ContinueWith(_ => SaveStorage.Save(snapshot), TaskScheduler.Default);
}
private void OnApplicationQuit()
{
_pending.Wait();
}
}
Цепочка через ContinueWith гарантирует, что два сохранения не будут писать файл одновременно. Важно, чтобы в фоновый поток уходила именно копия: если основной код продолжит менять тот же SaveData, пока идёт запись, в файл попадёт смесь старого и нового состояния. Именно поэтому SaveStorage выше читает Application.persistentDataPath один раз при старте: API Unity из фонового потока вызывать нельзя. Ещё одна деталь: Debug.Log из фона работает, а вот трогать объекты сцены — нет. На WebGL потоков нет, там запись делается синхронно.
MemoryPack: бинарный формат без ручного кода
Ручной формат даёт полный контроль, но каждое поле приходится писать дважды — в записи и в чтении — и держать порядок в голове. Когда моделей десятки, это становится источником ошибок. Здесь помогает MemoryPack от Cysharp (авторы UniTask и R3) — бинарный сериализатор, который генерирует код сериализации на этапе компиляции через Source Generator. Рефлексии во время работы нет, поэтому он корректно работает под IL2CPP и очень быстр: авторы приводят ускорение в 3–10 раз относительно JsonUtility.
Установка
Минимальная поддерживаемая версия Unity — 2022.3.12f1. Сам пакет ставится из NuGet, удобнее всего через NuGetForUnity: в окне управления NuGet-пакетами найдите MemoryPack и установите его. Если после установки появятся конфликты версий сборок, в Player Settings в разделе Configuration отключите Assembly Version Validation.
Для сериализации типов Unity, которые не являются простыми структурами (например, AnimationCurve и Gradient), нужно расширение MemoryPack.Unity. Оно подключается через Package Manager по git-адресу:
https://github.com/Cysharp/MemoryPack.git?path=src/MemoryPack.Unity/Assets/MemoryPack.Unity
Простые структуры вроде Vector3, Quaternion, Color и Rect — неуправляемые типы, и MemoryPack записывает их как есть, копированием памяти.
Разметка модели
using System.Collections.Generic;
using MemoryPack;
using UnityEngine;
[MemoryPackable]
public partial class SaveDataV2
{
public string PlayerName;
public int Level;
public float Health;
public Vector3 Position;
public Quaternion Rotation;
public List<ItemStackV2> Inventory = new();
[MemoryPackIgnore]
public bool IsDirty; // служебное поле, в файл не попадает
}
[MemoryPackable]
public partial struct ItemStackV2
{
public string ItemId;
public int Count;
}
Тип обязательно объявляется как partial: генератор дописывает в него вторую половину с кодом сериализации. Сериализуются публичные поля и свойства с геттером и сеттером. Всё лишнее помечается [MemoryPackIgnore].
Использование укладывается в две строки:
byte[] bytes = MemoryPackSerializer.Serialize(save);
SaveDataV2 loaded = MemoryPackSerializer.Deserialize<SaveDataV2>(bytes);
Эти байты можно поместить внутрь того же контейнера с магическим числом, версией и CRC, который мы написали выше, и сохранять тем же атомарным способом. MemoryPack заменяет только тело файла, а не всю инфраструктуру вокруг.
Версионирование в MemoryPack
По умолчанию (режим GenerateType.Object) поля идут в порядке объявления. Новые члены можно добавлять в конец и переименовывать существующие, но удалять, менять местами и менять тип нельзя. Для сохранений, которые живут годами, надёжнее режим с явной нумерацией:
[MemoryPackable(GenerateType.VersionTolerant)]
public partial class PlayerProfile
{
[MemoryPackOrder(0)] public string Name;
[MemoryPackOrder(1)] public int Level;
// [MemoryPackOrder(2)] было поле Gold — удалено, номер 2 больше не используем
[MemoryPackOrder(3)] public long Coins;
}
В режиме VersionTolerant поля можно добавлять и удалять, но номера нельзя переиспользовать и нельзя менять тип поля под тем же номером. Это похоже на правила Protocol Buffers. Плата за гибкость — немного больший размер и чуть более медленное чтение, но для сохранений это неважно.
Полиморфизм
Если в сохранении лежит список разнотипных объектов — например, разные виды квестовых целей, — используйте объединения:
[MemoryPackable]
[MemoryPackUnion(0, typeof(KillGoal))]
[MemoryPackUnion(1, typeof(CollectGoal))]
public partial interface IQuestGoal { }
[MemoryPackable]
public partial class KillGoal : IQuestGoal
{
public string EnemyId;
public int Required;
public int Current;
}
[MemoryPackable]
public partial class CollectGoal : IQuestGoal
{
public string ItemId;
public int Required;
}
Тег (0, 1 и так далее) записывается в файл перед объектом, поэтому его, как и номера полей, менять после релиза нельзя. Важный нюанс именно для Unity: в нём не работает ModuleInitializer, поэтому форматтер объединения нужно зарегистрировать вручную при старте игры, вызвав сгенерированный метод RegisterFormatter() у класса-инициализатора, который MemoryPack создаёт для интерфейса. Иначе десериализация такого списка упадёт.
Ограничения MemoryPack
- В Unity не поддерживаются пользовательские форматтеры. Для сложных типов Unity, которые пакет не умеет сериализовать, делайте обёртку-модель с простыми полями.
- Значения некоторых типов .NET с автоматической раскладкой в памяти (например,
ValueTupleиDateTimeOffset) могут кодироваться по-разному в Unity и на сервере на современном .NET. Если сохранения ходят между клиентом и сервером, такие типы лучше не использовать. - Авторы прямо предупреждают: MemoryPack рассчитан на доверенные данные. Для файлов, которые приходят от посторонних пользователей (обмен уровнями, моды), нужна дополнительная валидация, или стоит выбрать формат, рассчитанный на недоверенный ввод.
Тестируем формат
Бинарный формат без тестов — бомба замедленного действия. Минимальный набор тестов на Unity Test Framework:
using NUnit.Framework;
using UnityEngine;
public class SaveFormatTests
{
[Test]
public void RoundTrip_PreservesData()
{
var original = new SaveData
{
PlayerName = "Тест",
Level = 7,
Health = 55.5f,
Position = new Vector3(1, 2, 3),
Rotation = Quaternion.Euler(0, 90, 0)
};
original.Inventory.Add(new ItemStack { ItemId = "sword", Count = 1 });
var bytes = SaveSerializer.Serialize(original);
var loaded = SaveDeserializer.Deserialize(bytes);
Assert.AreEqual(original.PlayerName, loaded.PlayerName);
Assert.AreEqual(original.Level, loaded.Level);
Assert.AreEqual(original.Position, loaded.Position);
Assert.AreEqual(1, loaded.Inventory.Count);
}
[Test]
public void CorruptedByte_IsDetected()
{
var bytes = SaveSerializer.Serialize(new SaveData { PlayerName = "x" });
bytes[bytes.Length - 1] ^= 0xFF;
Assert.Throws<SaveDeserializer.CorruptSaveException>(
() => SaveDeserializer.Deserialize(bytes));
}
[Test]
public void TruncatedFile_IsDetected()
{
var bytes = SaveSerializer.Serialize(new SaveData { PlayerName = "x" });
System.Array.Resize(ref bytes, bytes.Length - 3);
Assert.Throws<SaveDeserializer.CorruptSaveException>(
() => SaveDeserializer.Deserialize(bytes));
}
}
И отдельно — «золотые» файлы: при каждом повышении версии формата сохраните пример файла предыдущей версии в папку с тестами и добавьте тест, который его загружает и проверяет ключевые поля. Это единственный надёжный способ убедиться, что игроки после обновления не потеряют прогресс.
Что выбрать
- JSON — прототип, небольшие сохранения, настройки, данные, которые удобно править руками или отдавать моддерам. Медленнее и больше по размеру, зато прозрачно.
- Свой формат на BinaryWriter — немного моделей, нужен полный контроль над каждым байтом, нет желания тянуть сторонние зависимости. Цена — ручная синхронизация записи и чтения и дисциплина с версиями.
- MemoryPack — много моделей, большие объёмы данных (мир на тысячи объектов, реплеи, кэш процедурной генерации), сетевой обмен между клиентом и сервером на C#. Цена — внешняя зависимость и требования к версии Unity.
Какой бы формат вы ни выбрали, обвязка вокруг него одна и та же: магическое число, версия, контрольная сумма, запись через временный файл и резервная копия. Именно она, а не выбор сериализатора, отделяет систему сохранений, которой можно доверять, от той, после которой в отзывах появляется «прогресс пропал после обновления».