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

в 8:32, , рубрики: .net, c#.net, serialization, unity3d, Visual Studio, Программирование, сериализация

В процессе разработки плагина для 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;//Текст
    }

Бинарная сериализация в Unity 3D Visual Studio Application
(код программы в конце статьи)

Новая созданная нода должна добавляться в первую свободную позицию в коллекции, для этого я использовал код:

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 будет отображать имя и текст ноды. Так же можно будет изменить эти данные и сериализовать обратно.

Код 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);//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;
    }
}

Теперь можно изменять и сохранять бинарный файл в программе и в юнити:

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

Автор: Stridemann

Источник

Поделиться

* - обязательные к заполнению поля