Бінарна серіалізація в Unity 3D/Visual Studio Application

У процесі розробки плагіна для Unity 3D знадобилося зробити зберігання великої кількості даних. У моєму випадку це зберігання даних нодів для візуального програмування (так само можна застосувати і до реалізації збереження гри). Спосіб зберігання має відповідати заданим вимогам:

  • Висока швидкість обробки;
  • Високий рівень стиснення даних;
  • Можливість збереження своїх класів і структур;
  • Читання\запису в Unity, а так само в окремій програмі Visual Studio Application, C#);
  • Працювати зі старими версіями збережених даних (при зміні структури);
  • Не повинен вимагати наявність додатково встановлених пакетів та ін. у користувачів;
  • Працювати на мобільних пристроях;
  • Мова: C#.
В результаті я зупинився на двійковій серіалізації. Даний спосіб відповідає всім заданим вимогам, але позбавляє можливості перегляду і редагування вже серіалізовать даних в текстовому редакторі. Але це не проблема, так як для цього призначена програма для редагування.


Першим завданням було зробити серіалізацію і десеріалізацію даних у програмі. Я написав простеньку програму, яка буде редагувати і сериализации дані нодів в кастомном класі Nodes у вигляді (ID, Об'єкт) в колекції Dictionary<short, дані>. Об'єктів буде дуже багато, тому ID ноди буде зберігатися 16-розрядним типом даних short.
Клас Nodes, для початку буде самий простий. Помічаємо його як Serializable.

[Serializable()]
public class NodesV1
{
public Dictionary<short, string> Name;//Ім'я ноди
public Dictionary<short, string> Text;//Текст
}


(код програми в кінці статті)

Нова створена нода повинна додаватися в першу вільну позицію в колекції, для цього я використовував код:

short CalcNewItemIndex()
{
short Index = -1; //Змінна позиції
while (Nodes.Name.ContainsKey(Index++)); //Інкрементуємо індекс поки знайдеться вільне місце
return Index; //Повертає індекс вільного місця
}

Серіалізація
Два кроки, які потрібно виконати на даному етапі, це змусити сериализатор працювати з нашим класом NodesV1 і зробити облік на те, що структура даних сериализуемого\ десериализуемого об'єкта буде змінюватися (в процесі розробки вона буде змінюватися не раз).

Другий крок не обов'язковий, але якщо змінити структуру — десериализовать файл з минулого структурою не вийде (але в деяких випадках якщо додати в кінець нові дані, старого файлу зазвичай проходить без проблем).

Для початку потрібен клас, який буде працювати над серіалізацією/десериализацией, в ньому ж змусимо сериализатор працювати з нашим класом.
Код класу серіалізації\десеріалізації
public class SaveLoad_Data
{
BinaryFormatter bformatter = new BinaryFormatter();
public void Save(object data, string filepath)//Функція серіалізації
{
Stream stream = File.Open(filepath + "txt", FileMode.Create);//Відкриваємо потік
BinaryFormatter bformatter = new BinaryFormatter();
bformatter.Binder = new ClassBinder();//Навчаємо сериализатор працювати з нашим класом 
bformatter.Serialize(stream, data);//Сериализуем
stream.Close();//Закриваємо потік 
}
public object Load(string filepath)//Функція десеріалізації
{
byte[] data = File.ReadAllBytes(filepath + "txt");//Читаємо наш файл
MemoryStream stream = new MemoryStream(data);//Створюємо потік з нашими даними
bformatter.Binder = new ClassBinder();//Навчаємо десериализатор
NodesV1 _NodesV1 = (NodesV1)bformatter.Deserialize(stream);//Десериализуем
stream.Close();//Закриваємо потік
return _NodesV1;//Повертаємо дані
}
}
public sealed class ClassBinder : SerializationBinder //
{
public override Type BindToType(string assemblyName, string typeName)
{
if (!string.IsNullOrEmpty(assemblyName) && !string.IsNullOrEmpty(typeName))
{
Type typeToDeserialize = null;
assemblyName = Assembly.GetExecutingAssembly().FullName;
typeToDeserialize = Type.GetType(String.Format("{0}, {1}", typeName, assemblyName));
return typeToDeserialize;
}
return null;
}
}


Запуск серіалізації\десеріалізації
NodesV1 Nodes;//Клас з даними нодів, які будемо сериализации
private void Form1_Load(object sender, EventArgs e) //При завантаженні програми
{
Nodes = new NodesV1();//Створюємо екземпляр класу нодів, з яким будемо працювати в програмі
//Ініціалізуємо змінні
Nodes.Name = new Dictionary<short, string>();
Nodes.Text = new Dictionary<short, string>();
}
private void button1_Click(object sender, EventArgs e)//Серіалізація
{
SaveLoad_Data _SaveNodes = new SaveLoad_Data();//Створюємо екземпляр клас обробки серіалізації
_SaveNodes.Save(Nodes, @"C:\UnityProjects\Blueprint_Editor_Plugin\Assets\Resources\HabrSerialisText");//Сериализуем
}
private void button2_Click(object sender, EventArgs e)//Десериализация
{
SaveLoad_Data _LoadNodes = new SaveLoad_Data();//Створюємо екземпляр клас обробки десеріалізації
Nodes = (NodesV1)_LoadNodes.Load(@"C:\UnityProjects\Blueprint_Editor_Plugin\Assets\Resources\HabrSerialisText"); //Десериализуем
}


Зберігати файл будемо в папку проекту Unity: Assets\Resources. Саме з папки Resources буде коректно працювати на Unity читання файлу на мобільних пристроях і т. д.

Тепер крок другий, вирішити питання з версією десериализатора. В перші два байти бінарного файлу ми будемо записувати версію сериализатора. При десеріалізації ми зчитуємо версію, прибираємо ці два байти і запускаємо десериализатор відповідної версії. Версія сериализатора буде визначатися по цифрам в кінці імені класу (NodesV1 — версія «1»).

Додамо перевірку версії:

public class SaveLoad_Data
{
BinaryFormatter bformatter = new BinaryFormatter();
public void Save(object data, string filepath)//Функція серіалізації
{
int Version;//Змінна версії сериализатора
BinaryFormatter bformatter = new BinaryFormatter();
bformatter.Binder = new ClassBinder();//Тут ми навчаємо сериализатор працювати з нашим класом
MemoryStream streamReader = new MemoryStream();
bformatter.Serialize(streamReader, data);//Сериализуем
Version = Convert.ToInt32(data.GetType().ToString().Replace("HabrSerialis.NodesV", ""));//Беремо номер версії сериализатора з імені класу
byte[] arr = streamReader.ToArray();//Байтовий масив даних
byte[] versionBytes = BitConverter.GetBytes(Version);//перетворимо версію в байти
byte[] result = new byte[arr.Length + 4]; // //зробимо масив, який ми запишемо дані і версію. int - 4 байти
Array.Copy(arr, 0, result, 4, arr.Length);//пишемо дані
Array.Copy(versionBytes, 0, result, 0, versionBytes.Length);//пишемо версію
File.WriteAllBytes(filepath + "txt", result);//пишемо в файл
streamReader.Close();//Закриваємо потік 
}
public object Load(string filepath)//Функція десеріалізації
{ 
byte[] back = File.ReadAllBytes(filepath + "txt");//Читаємо наш файл
int versionBack = BitConverter.ToInt32(back, 0);//Визначаємо версію
byte[] data = new byte[back.Length - 4]; // вирізаємо дані без версії 
Array.Copy(back, 4, data, 0, back.Length - 4);//копіюємо дані без версією в новий масив
MemoryStream stream = new MemoryStream(data);//Створюємо потік з нашими даними
bformatter.Binder = new ClassBinder();//Навчаємо десериализатор
if (versionBack == 1)//Якщо це версія 1
{
NodesV1 _NodesV1 = (NodesV1)bformatter.Deserialize(stream);//використовуємо десериализатор версії 1
stream.Close();//Закриваємо потік
return _NodesV1;
}
return null;
}
}
public sealed class ClassBinder :SerializationBinder
{
public override Type BindToType(string assemblyName, string typeName)
{
if (!string.IsNullOrEmpty(assemblyName) && !string.IsNullOrEmpty(typeName))
{
Type typeToDeserialize = null;
assemblyName = Assembly.GetExecutingAssembly().FullName;
typeToDeserialize = Type.GetType(String.Format("{0}, {1}", typeName, assemblyName));
return typeToDeserialize;
}
return null;
}
}


Тепер створимо в програмі кілька нодів і запустимо серіалізацію. Отриманий файл нам ще знадобиться.
Тепер перевіряємо, чи працює. Припустимо, наша структура змінилася, ми додали змінну Permission (Perm). Створимо клас з новою структурою:

[Serializable()]
public class NodesV2
{
public Dictionary<short, string> Name;
public Dictionary<short, string> Text;
public Dictionary<short, bool> Perm;
}

Змінюємо в коді програми клас NodesV1 на NodesV2. При запуску так само ініціалізуємо нову змінну:

Nodes.Perm = new Dictionary<short, bool>();

Тепер найцікавіше. У файлі зі старою структурою даних немає змінної Perm, а нам потрібно десериализовать у відповідності зі старою структурою і повернути до нової.

У кожному випадку буде відбуватися своя обробка цієї ситуації, але у мене буде просто створюватися ця колекція значення false.

Змінимо код перевірки версії в десериализаторе:

if (versionBack == 1)//Якщо версія 1
{
NodesV1 _NodesV1 = (NodesV1)bformatter.Deserialize(stream);//використовуємо десериализатор версії 1
stream.Close();//Закриваємо потік
NodesV2 NodeV2ret = new NodesV2();//Створюємо екземпляр класу який будемо повертати
NodeV2ret.Name = _NodesV1.Name; //Копіюємо ім'я
NodeV2ret.Text = _NodesV1.Text; //Копіюємо текст як є
NodeV2ret.Perm = new Dictionary<short, bool>(); //Ініціалізуємо не існуючу в версії 1 колекцію Perm
foreach (KeyValuePair<short, string> name in NodeV2ret.Name)
{
NodeV2ret.Perm.Add(name.Key, false);//Додаємо значення false
}
return NodeV2ret; //Повертаємо
}
else if (versionBack == 2)//Якщо версія 2 - використовуємо поточний (останній на даний момент) десериализатор
{
NodesV2 _NodesV2 = (NodesV2)bformatter.Deserialize(stream);//десериализуем і записуємо
stream.Close();//Закриваємо потік
return _NodesV2;
}

Після змін десериализация файлу зі старою структурою проходить успішно.

Unity
Створюємо C# скрипт, який десериализует бінарники і в GUI буде відображати ім'я і текст ноди. Так само можна буде змінити ці дані і сериализации назад.
Код Unity скрипта
using UnityEngine;
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Runtime.Serialization.Formatters.binary;
using System.Runtime.Serialization;
using System.Reflection;

public class HabrSerialis : MonoBehaviour
{
NodesV2 Nodes;
SaveLoad_Data _LoadNodes;
void Start()
{
Nodes = new NodesV2();
_LoadNodes = new SaveLoad_Data();
Nodes = (NodesV2)_LoadNodes.Load("HabrSerialisText");
}
float Offset;
void OnGUI()
{
Offset = 100;
for (short i = 0; i < Nodes.Name.Count; i++)
{
Nodes.Name[i] = GUI.TextField(new Rect(Offset, 100, 100, 30), Nodes.Name[i]);
Nodes.Text[i] = GUI.TextArea(new Rect(Offset, 130, 100, 200), Nodes.Text[i]);
Offset += 120;
}
if (GUI.Button(new Rect(10, 10, 70, 30), "Save"))
{
_LoadNodes.Save(Nodes, "HabrSerialisText");
}
}
}
[Serializable()]
public class NodesV1
{
public Dictionary<short, string> Name;
public Dictionary<short, string> Text;
}
[Serializable()]
public class NodesV2
{
public Dictionary<short, string> Name;
public Dictionary<short, string> Text;
public Dictionary<short, bool> Perm;
}
public class SaveLoad_Data
{
private int Version;
BinaryFormatter bformatter = new BinaryFormatter();
public void Save(object data, string filepath)//Функція серіалізації
{
BinaryFormatter bformatter = new BinaryFormatter();
bformatter.Binder = new ClassBinder();//Тут ми навчаємо сериализатор працювати з нашим класом
MemoryStream streamReader = new MemoryStream();
bformatter.Serialize(streamReader, data);//Сериализуем
Version = Convert.ToInt32(data.GetType().ToString().Replace("NodesV", ""));//Отримуємо номер версії сериализатора з імені класу
byte[] arr = streamReader.ToArray();
byte[] versionBytes = BitConverter.GetBytes(Version);//перетворимо версію в байти
byte[] result = new byte[arr.Length + 4]; // //зробимо масив, який ми запишемо дані і версію. int - 4 байти
Array.Copy(arr, 0, result, 4, arr.Length);//пишемо дані
Array.Copy(versionBytes, 0, result, 0, versionBytes.Length);//пишемо версію
File.WriteAllBytes("Assets/Resources/" + filepath + "txt", result);//пишемо в файл
streamReader.Close();//Закриваємо потік 
}
public object Load(string filepath)//Функція десеріалізації
{
TextAsset asset = Resources.Load(filepath) as TextAsset;//Читаємо наш файл ресурсів
byte[] back = asset.bytes;
int versionBack = BitConverter.ToInt32(back, 0);//Визначаємо версію
byte[] data = new byte[back.Length - 4]; // вирізаємо дані без версії 
Array.Copy(back, 4, data, 0, back.Length - 4);//копіюємо дані без версією в новий масив
Stream stream = new MemoryStream(data);//Створюємо потік з нашими даними
bformatter.Binder = new ClassBinder();//Навчаємо десериализатор
////////////////////////////////////////////////////////
if (versionBack == 1)//Якщо версія 1
{
NodesV1 _NodesV1 = (NodesV1)bformatter.Deserialize(stream);//використовуємо десериализатор версії 1
stream.Close();//Закриваємо потік
NodesV2 NodeV2ret = new NodesV2();//Створюємо клас який будемо повертати
NodeV2ret.Name = _NodesV1.Name; //Копіюємо ім'я
NodeV2ret.Text = _NodesV1.Text; //Копіюємо текст як є
NodeV2ret.Perm = new Dictionary<short, bool>(); //Инициализпуем не існуючу в версії 1 колекцію Perm
foreach (KeyValuePair<short, string> name in NodeV2ret.Name)
{
NodeV2ret.Perm.Add(name.Key, false);//Додаємо значення
}
return NodeV2ret;//Повертаємо дані 
}

else if (versionBack == 2)//Якщо версія 2 - використовуємо поточний (останній на даний момент) десериализатор
{
NodesV2 _NodesV2 = (NodesV2)bformatter.Deserialize(stream);//десериализуем і записуємо
stream.Close();//Закриваємо потік
return _NodesV2;
}
//////////////////////////////////////////////////////////////
return null;
}
}
public sealed class ClassBinder : SerializationBinder
{
public override Type BindToType(string assemblyName, string typeName)
{
if (!string.IsNullOrEmpty(assemblyName) && !string.IsNullOrEmpty(typeName))
{
Type typeToDeserialize = null;
assemblyName = Assembly.GetExecutingAssembly().FullName;
typeToDeserialize = Type.GetType(String.Format("{0}, {1}", typeName, assemblyName));
return typeToDeserialize;
}
return null;
}
}


Як бачимо, код класу обробний серіалізацію той же самий, тільки замість:

byte[] back = File.ReadAllBytes(filepath + "txt");

Ми будемо використовувати:

TextAsset asset = Resources.Load(filepath) as TextAsset;
byte[] back = asset.bytes;

Якщо скрипт не планується запускати на мобільних пристроях (або аналогічних), можна нічого не чіпати, тільки підправити шляху:

byte[] back = File.ReadAllBytes("Assets/Resources/" + filepath + "txt");

Після збереження об'єктів кнопкою Save потрібно згорнути або розгорнути Unity, щоб оновлений бінарний файл імпортувався і оновився.
Вихідний код програми
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Text;
using System.Windows.Forms;
using System.IO;
using System.Runtime.Serialization.Formatters.binary;
using System.Runtime.Serialization;
using System.Reflection;

namespace HabrSerialis
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
}
/////////////////////////////////////////////////////////////////////////////////////////////////
short _SelectedNodeID = 0; //Індекс вибраного елемента
NodesV2 Nodes;//Клас з даними нодів, які будемо сериализации
private void Form1_Load(object sender, EventArgs e) //При завантаженні програми
{
Nodes = new NodesV2();//Створюємо екземпляр класу нодів, з яким будемо працювати в програмі
//Ініціалізуємо
Nodes.Name = new Dictionary<short, string>();
Nodes.Text = new Dictionary<short, string>();
Nodes.Perm = new Dictionary<short, bool>();
}
private void button1_Click(object sender, EventArgs e)//Сериализуем
{
SaveLoad_Data _SaveNodes = new SaveLoad_Data();//Створюємо клас обробки серіалізації

_SaveNodes.Save(Nodes, @"C:\UnityProjects\Blueprint_Editor_Plugin\Assets\Resources\HabrSerialisText");
}
private void button2_Click(object sender, EventArgs e)//Десериализуем
{
SaveLoad_Data _LoadNodes = new SaveLoad_Data();

Nodes = (NodesV2)_LoadNodes.Load(@"C:\UnityProjects\Blueprint_Editor_Plugin\Assets\Resources\HabrSerialisText");
UpdateList();//Оновлюємо список
}
///////////////////////////////////////////////////////////////////////////////////
private void listBox1_SelectedIndexChanged(object sender, EventArgs e) //Вибір елемента в списку
{
_SelectedNodeID = (short)listBox1.SelectedIndex;

if (Nodes.Name.ContainsKey(_SelectedNodeID))//чи є такий об'єкт
{
textBox1.Text = Nodes.Name[_SelectedNodeID];//Виводимо ім'я об'єкта в текстове поле 1
textBox2.Text = Nodes.Text[_SelectedNodeID];//Виводимо текст об'єкта в текстовому полі 2 
}
}
///////////////////////////////////////////////////
private void button3_Click(object sender, EventArgs e)//Додавання нового об'єкта (ноди)
{
short _NewNodeID = CalcNewItemIndex();

Nodes.Name.Add(_NewNodeID, "New Node name");//Додаємо ім'я в колекцію
Nodes.Text.Add(_NewNodeID, "New Node Text");//Додаємо в колекцію
Nodes.Perm.Add(_NewNodeID, false);//Додаємо в колекцію
UpdateList();//Оновлюємо список об'єктів
listBox1.SelectedIndex = _NewNodeID;
}
///////////////////////////////////////////////////
private void textBox2_TextChanged(object sender, EventArgs e)
{
Nodes.Text[_SelectedNodeID] = textBox2.Text;//Зміна тексту виділеного об'єкта в колекції 
}
///////////////////////////////////////////////////
private void textBox1_TextChanged(object sender, EventArgs e)//Зміна імені 
{
Nodes.Name[_SelectedNodeID] = textBox1.Text;//Зміна імені вибраного об'єкту
listBox1.Items[_SelectedNodeID] = "ID: " + _SelectedNodeID + " " + textBox1.Text;//Зміна тексту виділеного об'єкта в списку
}
///////////////////////////////////////////////////
short CalcNewItemIndex()//Знаходимо вільну позицію в колекції
{
short Index = -1;
while (Nodes.Name.ContainsKey(Index++));
return Index;
}
///////////////////////////////////////////////////
void UpdateList()//Оновлюємо список об'єктів
{
listBox1.Items.Clear();

foreach (KeyValuePair<short, string> node in Nodes.Name)
{
listBox1.Items.Add("ID: " + node.Key + " " + node.Value);
}
}
} 
}
//////////////////////////////////////////////////
[Serializable()]
public class NodesV1
{
public Dictionary<short, string> Name;
public Dictionary<short, string> Text;
}
[Serializable()]
public class NodesV2
{
public Dictionary<short, string> Name;
public Dictionary<short, string> Text;
public Dictionary<short, bool> Perm; 
}
public class SaveLoad_Data
{
private int Version;
BinaryFormatter bformatter = new BinaryFormatter();
public void Save(object data, string filepath)
{
BinaryFormatter bformatter = new BinaryFormatter();
bformatter.Binder = new ClassBinder();//Тут ми навчаємо сериализатор працювати з нашим класом
MemoryStream streamReader = new MemoryStream();
bformatter.Serialize(streamReader, data);//Сериализуем
Version = Convert.ToInt32(data.GetType().ToString().Replace("NodesV", ""));//Отримуємо номер версії сериализатора з імені класу
byte[] arr = streamReader.ToArray();
byte[] versionBytes = BitConverter.GetBytes(Version);//перетворимо версію в байти
byte[] result = new byte[arr.Length + 4]; // //зробимо масив, який ми запишемо дані і версію. int - 4 байти
Array.Copy(arr, 0, result, 4, arr.Length);//пишемо дані
Array.Copy(versionBytes, 0, result, 0, versionBytes.Length);//пишемо версію
File.WriteAllBytes(filepath + "txt", result);//пишемо в файл
streamReader.Close();//Закриваємо потік 
}
public object Load(string filepath)
{
byte[] back = File.ReadAllBytes(filepath + "txt");//Читаємо наш файл
int versionBack = BitConverter.ToInt32(back, 0);//Визначаємо версію
byte[] data = new byte[back.Length - 4]; // вирізаємо дані без версії 
Array.Copy(back, 4, data, 0, back.Length - 4);//копіюємо дані без версією в новий масив
MemoryStream stream = new MemoryStream(data);//Створюємо потік з нашими даними
bformatter.Binder = new ClassBinder();//Навчаємо десериализатор

//////////////////// Перевірка версій////////////////////////////////////
if (versionBack == 1)//Якщо версія 1
{
NodesV1 _NodesV1 = (NodesV1)bformatter.Deserialize(stream);//використовуємо десериализатор версії 1
stream.Close();//Закриваємо потік
NodesV2 NodeV2ret = new NodesV2();//Створюємо клас який будемо повертати
NodeV2ret.Name = _NodesV1.Name; //Копіюємо ім'я
NodeV2ret.Text = _NodesV1.Text; //Копіюємо текст як є
NodeV2ret.Perm = new Dictionary<short, bool>(); //Инициализпуем не існуючу в версії 1 колекцію Perm

foreach (KeyValuePair<short, string> name in NodeV2ret.Name)
{
NodeV2ret.Perm.Add(name.Key, false);//Додаємо значення
}
return NodeV2ret;
}
else if (versionBack == 2)//Якщо версія 2 - використовуємо поточний (останній на даний момент) десериализатор
{
NodesV2 _NodesV2 = (NodesV2)bformatter.Deserialize(stream);//десериализуем і записуємо
stream.Close();//Закриваємо потік
return _NodesV2;
}
//////////////////////////////////////////////////////////////
return null;
}
}
public sealed class ClassBinder : SerializationBinder
{
public override Type BindToType(string assemblyName, string typeName)
{
if (!string.IsNullOrEmpty(assemblyName) && !string.IsNullOrEmpty(typeName))
{
Type typeToDeserialize = null;
assemblyName = Assembly.GetExecutingAssembly().FullName;
typeToDeserialize = Type.GetType(String.Format("{0}, {1}", typeName, assemblyName));
return typeToDeserialize;
}
return null;
}
}


Тепер можна змінювати і зберігати бінарний файл в програмі і в юніті:



Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.