Serialización de Unity

TIM COOPER / UNITY TECHNOLOGIESContributor
Oct 25, 2012|14 minutos
Serialización de Unity
Para tu comodidad, tradujimos esta página mediante traducción automática. No podemos garantizar la precisión ni la confiabilidad del contenido traducido. Si tienes alguna duda sobre la precisión del contenido traducido, consulta la versión oficial en inglés de la página web.

Así que usted está escribiendo una extensión del editor realmente genial en Unity y las cosas parecen ir muy bien. Usted tiene todas sus estructuras de datos ordenadas y está realmente contento con el funcionamiento de la herramienta que ha escrito.

A continuación, entre y salga del modo de reproducción.

De repente, todos los datos que había introducido desaparecen y su herramienta vuelve al estado por defecto, recién inicializada. ¡Es muy frustrante! "¿Por qué ocurre esto?", se pregunta. La razón tiene que ver con el funcionamiento de la capa gestionada (Mono) de Unity. Una vez que lo entienda, entonces las cosas serán mucho más fáciles :)

¿Qué ocurre cuando se recarga un conjunto?
Cuando usted entra / sale del modo de juego o cambia un script Unity tiene que recargar los ensamblados Mono, es decir las dll's asociadas a Unity.

Por parte del usuario, se trata de un proceso de 3 pasos:

  • Saca todos los datos serializables del terreno gestionado, creando una representación interna de los datos en el lado C++ de Unity.
  • Destruya toda la memoria / información asociada con la parte gestionada de Unity, y vuelva a cargar los ensamblajes.
  • Reserialice los datos que se guardaron en C++ de nuevo en terreno gestionado.

Lo que esto significa es que para que sus estructuras de datos / información sobrevivan a una recarga del ensamblado necesita asegurarse de que pueden serializarse dentro y fuera de la memoria de c++ correctamente. Hacer esto también significa que (con algunas modificaciones menores) puede guardar esta estructura de datos en un archivo de activos y volver a cargarla en una fecha posterior.

¿Cómo trabajo con la serialización de Unity?
La forma más fácil de aprender sobre la serialización de Unity es trabajando a través de un ejemplo. Vamos a empezar con una simple ventana del editor, contiene una referencia a una clase que queremos hacer sobrevivir a una recarga del ensamblaje.

using UnityEngine;
using UnityEditor;

public class MyWindow : EditorWindow
{
	private SerializeMe m_SerialziedThing;

	[MenuItem ("Window/Serialization")]
	static void Init () {
		GetWindow ();
	}

	void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_SerialziedThing == null)
			m_SerialziedThing = new SerializeMe ();
	}

	void OnGUI () {
		GUILayout.Label ("Serialized Things", EditorStyles.boldLabel);
		m_SerialziedThing.OnGUI ();
	}
}

using UnityEditor;

public struct NestedStruct
{
	private float m_StructFloat;
	public void OnGUI ()
	{
		m_StructFloat = EditorGUILayout.FloatField("Struct Float", m_StructFloat);
	}
}

public class SerializeMe
{
	private string m_Name;
	private int m_Value;

	private NestedStruct m_Struct;

	public SerializeMe ()
	{
		m_Struct = new NestedStruct();
		m_Name = "";
	}

	public void OnGUI ()
	{
		m_Name = EditorGUILayout.TextField( "Name", m_Name);
		m_Value = EditorGUILayout.IntSlider ("Value", m_Value, 0, 10);

		m_Struct.OnGUI ();
	}
}

Cuando ejecute esto y fuerce una recarga del ensamblaje notará que cualquier valor de la ventana que haya cambiado no sobrevivirá. Esto se debe a que cuando se recarga el ensamblaje, la referencia a 'm_SerialziedThing' desaparece. No está marcado para ser serializado.

Hay que hacer algunas cosas para que esta serialización funcione correctamente:
En MyWindow.cs:

  • El campo 'm_SerializedThing' necesita que se le añada el atributo [SerializeField]. Lo que esto le dice a Unity es que debe intentar serializar este campo en la recarga del ensamblaje o en eventos similares.

En SerializeMe.cs:

  • La clase 'SerializeMe' necesita que se le añada el atributo [Serializable]. Esto indica a Unity que la clase es serializable.
  • La estructura 'NestedStruct' necesita que se le añada el atributo [Serializable].
  • Cada campo (no público) que desee serializar necesita que se le añada el atributo [SerializeField].

Después de añadir estas banderas abra la ventana y modifique los campos. Observará que tras una recarga del ensamblaje los campos conservan sus valores; es decir, aparte del campo procedente de la estructura. Esto nos lleva al primer punto importante, los structs no están muy bien soportados para la serialización. Cambiar 'NestedStruct' de una estructura a una clase soluciona este problema.

El código tiene ahora este aspecto:

using UnityEngine;
using UnityEditor;

public class MyWindow : EditorWindow
{
	private SerializeMe m_SerialziedThing;

	[MenuItem ("Window/Serialization")]
	static void Init () {
		GetWindow ();
	}

	void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_SerialziedThing == null)
			m_SerialziedThing = new SerializeMe ();
	}

	void OnGUI () {
		GUILayout.Label ("Serialized Things", EditorStyles.boldLabel);
		m_SerialziedThing.OnGUI ();
	}
}

using System;
using UnityEditor;
using UnityEngine;

[Serializable]
public class NestedStruct
{
	[SerializeField]
	private float m_StructFloat;
	public void OnGUI ()
	{
		m_StructFloat = EditorGUILayout.FloatField("Struct Float", m_StructFloat);
	}
}

[Serializable]
public class SerializeMe
{
	[SerializeField]
	private string m_Name;
	[SerializeField]
	private int m_Value;
	[SerializeField]
	private NestedStruct m_Struct;

	public SerializeMe ()
	{
		m_Struct = new NestedStruct();
		m_Name = "";
	}

	public void OnGUI ()
	{
		m_Name = EditorGUILayout.TextField( "Name", m_Name);
		m_Value = EditorGUILayout.IntSlider ("Value", m_Value, 0, 10);

		m_Struct.OnGUI ();
	}
}

Algunas reglas de serialización

  • Evite los structs
  • Las clases que desee que sean serializables deben estar marcadas con [Serializable].
  • Los campos públicos se serializan (siempre que hagan referencia a una clase [Serializable])
  • Los campos privados se serializan en algunas circunstancias (editor).
  • Marque los campos privados como [SerializeField] si desea que se serialicen.
  • [No serializado] existe para los campos que no desea serializar.

Objetos programables
Hasta ahora hemos visto el uso de clases normales cuando se trata de la serialización. Desafortunadamente usar clases simples tiene algunos problemas cuando se trata de serialización en Unity. Veamos un ejemplo.

using System;
using UnityEditor;
using UnityEngine;

[Serializable]
public class NestedClass
{
	[SerializeField]
	private float m_StructFloat;
	public void OnGUI()
	{
		m_StructFloat = EditorGUILayout.FloatField("Float", m_StructFloat);
	}
}

[Serializable]
public class SerializeMe
{
	[SerializeField]
	private NestedClass m_Class1;

	[SerializeField]
	private NestedClass m_Class2;

	public void OnGUI ()
	{
		if (m_Class1 == null)
			m_Class1 = new NestedClass ();
		if (m_Class2 == null)
			m_Class2 = m_Class1;

		m_Class1.OnGUI();
		m_Class2.OnGUI();
	}
}

Este es un ejemplo artificioso para mostrar un caso de esquina muy específico del sistema de serialización de Unity que puede atraparle si no tiene cuidado. Observará que tenemos dos campos de tipo NestedClass. La primera vez que se dibuje la ventana mostrará los dos campos, y como m_Class1 y m_Class2 apuntan a la misma referencia, al modificar uno se modificará el otro.

Ahora intente recargar el montaje entrando y saliendo del modo de reproducción... Las referencias se han desacoplado. Esto se debe a cómo funciona la serialización cuando se marca una clase como simplemente [Serializable].

Cuando usted está serializando clases estándar Unity recorre los campos de la clase y serializa cada uno individualmente, incluso si la referencia es compartida entre múltiples campos. Esto significa que podría tener el mismo objeto serializado varias veces, y en la deserialización el sistema no sabrá que son realmente el mismo objeto. Si está diseñando un sistema complejo, ésta es una limitación frustrante porque significa que las interacciones complejas entre clases no pueden capturarse adecuadamente.

¡Entre en ScriptableObjects! Los ScriptableObjects son un tipo de clase que se serializa correctamente como referencias, de modo que sólo se serializan una vez. Esto permite que las interacciones de clases complejas se almacenen de la forma que cabría esperar. Internamente en Unity ScriptableObjects y MonoBehaviours son lo mismo; en código userland usted puede tener un ScriptableObject que no esté unido a un GameObject; esto es diferente a como funciona MonoBehaviour. Son excelentes para la serialización general de estructuras de datos.

Modifiquemos el ejemplo para poder manejar la serialización correctamente:

using System;
using UnityEditor;
using UnityEngine;

[Serializable]
public class NestedClass : ScriptableObject
{
	[SerializeField]
	private float m_StructFloat;

	public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }

	public void OnGUI()
	{
		m_StructFloat = EditorGUILayout.FloatField("Float", m_StructFloat);
	}
}

[Serializable]
public class SerializeMe
{
	[SerializeField]
	private NestedClass m_Class1;

	[SerializeField]
	private NestedClass m_Class2;

	public SerializeMe ()
	{
		m_Class1 = ScriptableObject.CreateInstance ();
		m_Class2 = m_Class1;
	}

	public void OnGUI ()
	{
		m_Class1.OnGUI();
		m_Class2.OnGUI();
	}
}

Los tres cambios destacables son los siguientes:

  • NestedClass es ahora un ScriptableObject.
  • Creamos una instancia utilizando la función CreateInstance<> en lugar de llamar al constructor.
  • También configuramos las banderas de ocultación... esto se explicará más adelante

Estos sencillos cambios significan que la instancia de la NestedClass sólo se serializará una vez, con cada una de las referencias a la clase apuntando a la misma.

Inicialización de ScriptableObject
Así pues, ahora sabemos que para estructuras de datos complejas en las que es necesario hacer referencias externas es una buena idea utilizar ScriptableObjects. Pero, ¿cuál es la forma correcta de trabajar con ScriptableObjects desde el código de usuario? Lo primero que hay que examinar es CÓMO se inicializan los objetos scriptables, especialmente desde el sistema de serialización de Unity.

Se llama al constructor en el ScriptableObject.

Los datos se serializan en el objeto desde el lado c++ de Unity (si existen tales datos).

Se llama a OnEnable() en el ScriptableObject.

Trabajando con este conocimiento hay algunas cosas que podemos decir:

  • Hacer la inicialización en el constructor no es muy buena idea ya que los datos serán potencialmente anulados por el sistema de serialización.
  • La serialización ocurre DESPUÉS de la construcción, así que deberíamos hacer nuestras cosas de configuración después de la serialización.
  • OnEnable() parece el mejor candidato para la inicialización.

Hagamos algunos cambios en la clase 'SerializeMe' para que sea un ScriptableObject. Esto nos permitirá ver el patrón de inicialización correcto para ScriptableObjects.

// also updated the Window to call CreateInstance instead of the constructor
using System;
using UnityEngine;

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private NestedClass m_Class1;

	[SerializeField]
	private NestedClass m_Class2;

	public void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_Class1 == null)
		{
			m_Class1 = CreateInstance ();
			m_Class2 = m_Class1;
		}
	}

	public void OnGUI ()
	{
		m_Class1.OnGUI();
		m_Class2.OnGUI();
	}
}

A primera vista parece que no hemos cambiado mucho esta clase, ahora hereda de ScriptableObject y en lugar de utilizar un constructor tiene un OnEnable(). La parte importante a tener en cuenta es algo más sutil... OnEnable() se llama DESPUÉS de la serialización; gracias a ello podemos ver si los [SerializedFields] son null o no. Si son null indica que esta es la primera inicialización, y necesitamos construir las instancias. Si no son null, entonces se han cargado en memoria y NO es necesario construirlos. Es habitual en OnEnable() llamar también a una función de inicialización personalizada para configurar cualquier campo privado / no serializado del objeto, de forma muy similar a como se haría en un constructor.

HideFlags
En los ejemplos que utilizan ScriptableObjects observará que estamos estableciendo el 'hideFlags' en el objeto a HideFlags.HideAndDontSave. Esta es una configuración especial que se requiere cuando se escriben estructuras de datos personalizadas que no tienen raíz en la escena. Esto es para sortear cómo funciona la carga de escenas en Unity.

Cuando una escena se carga internamente Unity llama a Resources.UnloadUnusedAssets. Si nada hace referencia a un activo, el recolector de basura lo encontrará. El GC utiliza la escena como "la raíz" y recorre la jerarquía para ver qué se puede GC'd. Establecer la bandera HideAndDontSave en un ScriptableObject le dice a Unity que considere ese objeto como un objeto raíz. Por ello, no desaparecerá simplemente por una recarga del montaje. El objeto aún puede destruirse llamando a Destroy().

Algunas reglas de ScriptableObject

  • Los ScriptableObjects sólo se serializarán una vez, lo que le permitirá utilizar las referencias correctamente.
  • Utilice OnEnable para inicializar ScriptableObjects.
  • No llame nunca al constructor de un ScriptableObject, utilice en su lugar CreatInstance
  • Para estructuras de datos anidadas a las que sólo se hace referencia una vez no utilice ScriptableObject ya que tienen más sobrecarga.
  • Si su objeto scriptable no está arraigado en la escena establezca el hideFlags a HideAndDontSave.

Serialización de matrices concretas
Veamos un ejemplo sencillo que serializa una serie de clases concretas.

using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

[Serializable]
public class BaseClass
{
	[SerializeField]
	private int m_IntField;
	public void OnGUI() {m_IntField = EditorGUILayout.IntSlider ("IntField", m_IntField, 0, 10);}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		hideFlags = HideFlags.HideAndDontSave;
		if (m_Instances == null)
			m_Instances = new List ();
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Simple"))
			m_Instances.Add (new BaseClass ());
	}
}

Este ejemplo básico tiene una lista de BaseClasses, haciendo clic en el botón 'Añadir Simple' crea una instancia y la añade a la lista. Debido a que la clase SerializeMe está configurada correctamente para la serialización (como se ha comentado antes) "simplemente funciona". Unity ve que la Lista está marcada para la serialización y serializa cada uno de los elementos de la Lista.

Serialización general de matrices
Modifiquemos el ejemplo para serializar una lista que contenga miembros de una clase base y una clase hija:

using System;
using System.Collections.Generic;
using UnityEditor;
using UnityEngine;

[Serializable]
public class BaseClass
{
	[SerializeField]
	private int m_IntField;
	public virtual void OnGUI() { m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10); }
}

[Serializable]
public class ChildClass : BaseClass
{
	[SerializeField]
	private float m_FloatField;
	public override void OnGUI()
	{
		base.OnGUI ();
		m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
	}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		if (m_Instances == null)
			m_Instances = new List ();

		hideFlags = HideFlags.HideAndDontSave;
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Base"))
			m_Instances.Add (new BaseClass ());
		if (GUILayout.Button ("Add Child"))
			m_Instances.Add (new ChildClass ());
	}
}

El ejemplo se ha ampliado para que ahora haya una ChildClass, pero estamos serializando utilizando la BaseClass. Si crea varias instancias de la ChildClass y la BaseClass se renderizarán correctamente. Los problemas surgen cuando se colocan a través de una recarga de montaje. Una vez completada la recarga, cada instancia será una BaseClass, con toda la información de la ChildClass eliminada. Las instancias están siendo cizalladas por el sistema de serialización.

La forma de sortear esta limitación del sistema de serialización es utilizar de nuevo ScriptableObjects:

using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

[Serializable]
public class MyBaseClass : ScriptableObject
{
	[SerializeField]
	protected int m_IntField;

	public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }

	public virtual void OnGUI ()
	{
		m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10);
	}
}

[Serializable]
public class ChildClass : MyBaseClass
{
	[SerializeField]
	private float m_FloatField;

	public override void OnGUI()
	{
		base.OnGUI ();
		m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
	}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		if (m_Instances == null)
			m_Instances = new List();

		hideFlags = HideFlags.HideAndDontSave;
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Base"))
			m_Instances.Add(CreateInstance());
		if (GUILayout.Button ("Add Child"))
			m_Instances.Add(CreateInstance());
	}
}

Después de ejecutar esto, cambiar algunos valores y volver a cargar los ensamblajes notará que los ScriptableObjects son seguros de usar en los ensamblajes incluso si está serializando tipos derivados. La razón es que cuando se serializa una clase estándar [Serializable] se serializa 'in situ', pero un ScriptableObject se serializa externamente y la referencia se inserta en la colección. La cizalladura se produce porque el tipo no puede serializarse correctamente ya que el sistema de serialización piensa que es del tipo base.

Serialización de clases abstractas
Así que ahora hemos visto que es posible serializar una lista general (siempre que los miembros sean de tipo ScriptableObject). Veamos cómo se comportan las clases abstractas:

using System;
using UnityEditor;
using System.Collections.Generic;
using UnityEngine;

[Serializable]
public abstract class MyBaseClass : ScriptableObject
{
	[SerializeField]
	protected int m_IntField;

	public void OnEnable() { hideFlags = HideFlags.HideAndDontSave; }

	public abstract void OnGUI ();
}

[Serializable]
public class ChildClass : MyBaseClass
{
	[SerializeField]
	private float m_FloatField;

	public override void OnGUI()
	{
		m_IntField = EditorGUILayout.IntSlider("IntField", m_IntField, 0, 10);
		m_FloatField = EditorGUILayout.Slider("FloatField", m_FloatField, 0f, 10f);
	}
}

[Serializable]
public class SerializeMe : ScriptableObject
{
	[SerializeField]
	private List m_Instances;

	public void OnEnable ()
	{
		if (m_Instances == null)
			m_Instances = new List();

		hideFlags = HideFlags.HideAndDontSave;
	}

	public void OnGUI ()
	{
		foreach (var instance in m_Instances)
			instance.OnGUI ();

		if (GUILayout.Button ("Add Child"))
			m_Instances.Add(CreateInstance());
	}
}

Este código funciona de forma muy parecida al ejemplo anterior. Pero ES peligroso. Veamos por qué.

La función CreateInstance<>() espera un tipo que herede de ScriptableObject, la clase 'MyBaseClass' de hecho hereda de ScriptableObject. Esto significa que es posible añadir una instancia de la clase abstracta MyBaseClass a la matriz m_Instances. Si hace esto y luego intenta acceder a un método abstracto pasarán cosas malas porque no hay implementación de esa función. En este caso concreto sería el método OnGUI.

Utilizar clases abstractas como tipo serializado para listas y campos SÍ funciona, siempre que hereden de ScriptableObject, pero no es una práctica recomendada. Personalmente creo que es mejor utilizar clases concretas con métodos virtuales vacíos. Esto le garantiza que las cosas no le irán mal.

¿Cuándo se persisten los ScriptableObjects en los archivos de escena / prefab?
Los GameObjects y sus componentes se guardan en una escena por defecto. Los tipos de activos (Materiales / Mallas / AnimationClip / SerializedObject's) que se crean a partir de código se guardan en la escena siempre que un objeto del juego o sus componentes en la escena hagan referencia a él.

Los tipos de activos también pueden marcarse explícitamente como activos mediante AssetDatabase.CreateAsset. En ese caso no se guardarán en la escena sino que simplemente se hará referencia a ellos. Si un tipo de activo o de objeto de juego está marcado como HideAndDontSave tampoco se guardará en la escena.

¿Tienes alguna pregunta?