В процессе разработки плагина для 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);//Cериализуем
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:UnityProjectsBlueprint_Editor_PluginAssetsResourcesHabrSerialisText");//Сериализуем
}
private void button2_Click(object sender, EventArgs e)//Десериализация
{
SaveLoad_Data _LoadNodes = new SaveLoad_Data();//Создаем экземпляр класс обработки десериализации
Nodes = (NodesV1)_LoadNodes.Load(@"C:UnityProjectsBlueprint_Editor_PluginAssetsResourcesHabrSerialisText"); //Десериализуем
}
Сохранять файл будем в папку проекта Unity: AssetsResources. Именно из папки 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);//Cериализуем
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 будет отображать имя и текст ноды. Так же можно будет изменить эти данные и сериализовать обратно.
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);//Cериализуем
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:UnityProjectsBlueprint_Editor_PluginAssetsResourcesHabrSerialisText");
}
private void button2_Click(object sender, EventArgs e)//Десериализуем
{
SaveLoad_Data _LoadNodes = new SaveLoad_Data();
Nodes = (NodesV2)_LoadNodes.Load(@"C:UnityProjectsBlueprint_Editor_PluginAssetsResourcesHabrSerialisText");
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);//Cериализуем
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;
}
}
Теперь можно изменять и сохранять бинарный файл в программе и в юнити:
Автор: Stridemann