Serialização do Unity

Então o senhor está escrevendo uma extensão de editor muito legal no Unity e as coisas parecem estar indo muito bem. O senhor tem todas as suas estruturas de dados organizadas e está muito satisfeito com o funcionamento da ferramenta que escreveu.
Em seguida, o senhor entra e sai do modo de reprodução.
De repente, todos os dados que o senhor havia inserido desaparecem e a ferramenta é redefinida para o estado padrão, recém-inicializado. É muito frustrante! "Por que isso acontece?", o senhor se pergunta. O motivo tem a ver com a forma como a camada gerenciada (Mono) do Unity funciona. Quando o senhor entender isso, as coisas ficarão muito mais fáceis :)
O que acontece quando uma montagem é recarregada?
Quando o senhor entra/sai do modo de reprodução ou altera um script, o Unity precisa recarregar os mono assemblies, ou seja, as dlls associadas ao Unity.
No lado do usuário, esse é um processo de três etapas:
- Retire todos os dados serializáveis do terreno gerenciado, criando uma representação interna dos dados no lado C++ do Unity.
- Destrua toda a memória/informações associadas ao lado gerenciado do Unity e recarregue os assemblies.
- Re-serializar os dados que foram salvos em C++ de volta para o terreno gerenciado.
Isso significa que, para que suas estruturas de dados/informações sobrevivam a uma recarga do assembly, o senhor precisa garantir que elas possam ser serializadas para dentro e para fora da memória do c++ adequadamente. Fazer isso também significa que (com algumas pequenas modificações) o senhor pode salvar essa estrutura de dados em um arquivo de ativos e recarregá-la posteriormente.
Como faço para trabalhar com a serialização da Unity?
A maneira mais fácil de aprender sobre a serialização da Unity é trabalhar com um exemplo. Vamos começar com uma janela de editor simples, que contém uma referência a uma classe que queremos que sobreviva a uma recarga de assembly.
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 ();
}
}Ao executar isso e forçar a recarga do assembly, o senhor notará que qualquer valor na janela que tenha sido alterado não sobreviverá. Isso ocorre porque, quando o assembly é recarregado, a referência ao 'm_SerialziedThing' desaparece. Ele não está marcado para ser serializado.
Há algumas coisas que precisam ser feitas para que essa serialização funcione corretamente:
In MyWindow.cs:
- O campo 'm_SerializedThing' precisa ter o atributo [SerializeField] adicionado a ele. O que isso diz à Unity é que ela deve tentar serializar esse campo na recarga do assembly ou em eventos semelhantes.
In SerializeMe.cs:
- A classe 'SerializeMe' precisa ter o atributo [Serializable] adicionado a ela. Isso informa ao Unity que a classe é serializável.
- A estrutura 'NestedStruct' precisa ter o atributo [Serializable] adicionado a ela.
- Cada campo (não público) que o senhor deseja que seja serializado precisa ter o atributo [SerializeField] adicionado a ele.
Depois de adicionar esses sinalizadores, abra a janela e modifique os campos. O senhor perceberá que, após a recarga do assembly, os campos mantêm seus valores, exceto o campo que veio da estrutura. Isso traz à tona o primeiro ponto importante: os structs não são muito bem suportados para serialização. A alteração de 'NestedStruct' de uma estrutura para uma classe corrige esse problema.
O código agora tem a seguinte aparência:
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 ();
}
}Algumas regras de serialização
- Evitar structs
- As classes que o senhor deseja que sejam serializáveis precisam ser marcadas com [Serializable]
- Os campos públicos são serializados (desde que façam referência a uma classe [Serializable])
- Os campos privados são serializados em algumas circunstâncias (editor).
- Marque os campos privados como [SerializeField] se o senhor desejar que eles sejam serializados.
- [NonSerialized] existe para campos que o senhor não deseja serializar.
Objetos com script
Até agora, vimos como usar classes normais quando se trata de serialização. Infelizmente, o uso de classes simples tem alguns problemas quando se trata de serialização no Unity. Vamos dar uma olhada em um exemplo.
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();
}
}
Esse é um exemplo forçado para mostrar um caso muito específico do sistema de serialização do Unity que pode pegá-lo se o senhor não for cuidadoso. O senhor perceberá que temos dois campos do tipo NestedClass. Na primeira vez em que a janela for desenhada, ela mostrará os dois campos e, como m_Class1 e m_Class2 apontam para a mesma referência, a modificação de um modificará o outro.
Agora, tente recarregar a montagem entrando e saindo do modo de reprodução... As referências foram desacopladas. Isso se deve ao modo como a serialização funciona quando o senhor marca uma classe como simplesmente [Serializable]
Quando o senhor está serializando classes padrão, o Unity percorre os campos da classe e serializa cada um individualmente, mesmo que a referência seja compartilhada entre vários campos. Isso significa que o senhor pode ter o mesmo objeto serializado várias vezes e, na desserialização, o sistema não saberá que se trata realmente do mesmo objeto. Se o senhor estiver projetando um sistema complexo, essa é uma limitação frustrante, pois significa que as interações complexas entre as classes não podem ser capturadas adequadamente.
Entre nos ScriptableObjects! Os ScriptableObjects são um tipo de classe que são corretamente serializados como referências, de modo que são serializados apenas uma vez. Isso permite que interações complexas entre classes sejam armazenadas da maneira que o senhor espera. Internamente no Unity, os ScriptableObjects e os MonoBehaviours são iguais; no código de usuário, o senhor pode ter um ScriptableObject que não esteja anexado a um GameObject; isso é diferente de como o MonoBehaviour funciona. Eles são ótimos para a serialização de estruturas de dados em geral.
Vamos modificar o exemplo para poder lidar com a serialização corretamente:
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();
}
}As três mudanças de destaque aqui são as seguintes:
- A NestedClass agora é um ScriptableObject.
- Criamos uma instância usando a função CreateInstance<> em vez de chamar o construtor.
- Também definimos os sinalizadores de ocultação... isso será explicado mais tarde
Essas simples alterações significam que a instância da NestedClass será serializada apenas uma vez, com cada uma das referências à classe apontando para a mesma.
Inicialização do ScriptableObject
Portanto, agora sabemos que, para estruturas de dados complexas em que é necessário fazer referência externa, é uma boa ideia usar ScriptableObjects. Mas qual é a maneira correta de trabalhar com ScriptableObjects a partir do código do usuário? A primeira coisa a examinar é COMO os objetos com script são inicializados, especialmente a partir do sistema de serialização do Unity.
O construtor é chamado no ScriptableObject.
Os dados são serializados no objeto a partir do lado c++ da Unity (se esses dados existirem).
OnEnable() é chamado no ScriptableObject.
Trabalhando com esse conhecimento, há algumas coisas que podemos dizer:
- Fazer a inicialização no construtor não é uma ideia muito boa, pois os dados poderão ser substituídos pelo sistema de serialização.
- A serialização ocorre APÓS a construção, portanto, devemos fazer nossa configuração após a serialização.
- OnEnable() parece ser o melhor candidato para a inicialização.
Vamos fazer algumas alterações na classe 'SerializeMe' para que ela seja um ScriptableObject. Isso nos permitirá ver o padrão de inicialização correto 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();
}
}
À primeira vista, parece que não mudamos muito essa classe: ela agora herda do ScriptableObject e, em vez de usar um construtor, tem um OnEnable(). A parte importante a ser observada é um pouco mais sutil... OnEnable() é chamado DEPOIS da serialização; por isso, podemos ver se os [SerializedFields] são null ou não. Se forem null, isso indica que essa é a primeira inicialização e que precisamos construir as instâncias. Se não forem null, eles foram carregados na memória e NÃO precisam ser construídos. É comum que OnEnable() também chame uma função de inicialização personalizada para configurar quaisquer campos privados/não serializados no objeto, da mesma forma que o senhor faria em um construtor.
HideFlags
Nos exemplos que usam ScriptableObjects, o senhor notará que estamos definindo o 'hideFlags' no objeto como HideFlags.HideAndDontSave. Essa é uma configuração especial que é necessária ao escrever estruturas de dados personalizadas que não têm raiz na cena. Isso serve para contornar a forma como o carregamento de cenas funciona no Unity.
Quando uma cena é carregada internamente, a unidade chama Resources.UnloadUnusedAssets. Se nada estiver fazendo referência a um ativo, o coletor de lixo o encontrará. O GC usa a cena como "a raiz" e percorre a hierarquia para ver o que pode ser GC. A definição do sinalizador HideAndDontSave em um ScriptableObject diz à Unity para considerar esse objeto como um objeto raiz. Por isso, ele não desaparecerá simplesmente por causa de uma recarga de montagem. O objeto ainda pode ser destruído chamando Destroy().
Algumas regras do ScriptableObject
- Os ScriptableObjects serão serializados apenas uma vez, permitindo que o senhor use referências adequadamente.
- Use OnEnable para inicializar ScriptableObjects.
- Nunca chame o construtor de um ScriptableObject; em vez disso, use CreatInstance
- Para estruturas de dados aninhadas que são referenciadas apenas uma vez, não use ScriptableObject, pois elas têm mais sobrecarga.
- Se o objeto com script não estiver enraizado na cena, defina hideFlags como HideAndDontSave.
Serialização de matriz concreta
Vamos dar uma olhada em um exemplo simples que serializa uma série de classes 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 ());
}
}Esse exemplo básico tem uma lista de BaseClasses; ao clicar no botão "Add Simple", o senhor cria uma instância e a adiciona à lista. Como a classe SerializeMe está configurada corretamente para serialização (conforme discutido anteriormente), ela "simplesmente funciona". A Unity vê que a lista está marcada para serialização e serializa cada um dos elementos da lista.
Serialização geral de matrizes
Vamos modificar o exemplo para serializar uma lista que contém membros de uma classe base e de uma classe filha:
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 ());
}
}
O exemplo foi ampliado de modo que agora há uma ChildClass, mas estamos serializando usando a BaseClass. Se o senhor criar algumas instâncias da ChildClass e da BaseClass, elas serão renderizadas corretamente. Os problemas surgem quando eles são colocados em uma recarga de montagem. Após a conclusão da recarga, cada instância será uma BaseClass, com todas as informações da ChildClass removidas. As instâncias estão sendo cortadas pelo sistema de serialização.
A maneira de contornar essa limitação do sistema de serialização é usar novamente 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());
}
}
Depois de executar isso, alterar alguns valores e recarregar os assemblies, o senhor perceberá que é seguro usar ScriptableObjects em arrays, mesmo que esteja serializando tipos derivados. O motivo é que, quando o senhor serializa uma classe [Serializable] padrão, ela é serializada "no local", mas um ScriptableObject é serializado externamente e a referência é inserida na coleção. O cisalhamento ocorre porque o tipo não pode ser serializado corretamente, pois o sistema de serialização pensa que ele é do tipo base.
Serialização de classes abstratas
Portanto, agora vimos que é possível serializar uma lista geral (desde que os membros sejam do tipo ScriptableObject). Vamos ver como as classes abstratas se comportam:
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());
}
}
Esse código funciona de forma muito parecida com o exemplo anterior. Mas é perigoso. Vamos ver por quê.
A função CreateInstance<>() espera um tipo que herda de ScriptableObject, a classe 'MyBaseClass' de fato herda de ScriptableObject. Isso significa que é possível adicionar uma instância da classe abstrata MyBaseClass ao array m_Instances. Se o senhor fizer isso e depois tentar acessar um método abstrato, ocorrerão coisas ruins porque não há implementação dessa função. Nesse caso específico, esse seria o método OnGUI.
O uso de classes abstratas como o tipo serializado para listas e campos FUNCIONA, desde que herdem de ScriptableObject, mas não é uma prática recomendada. Pessoalmente, acho que é melhor usar classes concretas com métodos virtuais vazios. Isso garante que as coisas não vão dar errado para o senhor.
Quando os ScriptableObjects são mantidos nos arquivos de cena/prefab?
Os GameObjects e seus componentes são salvos em uma cena por padrão. Os tipos de ativos (Materials/Meshes/AnimationClip/SerializedObject's) criados a partir do código são salvos na cena enquanto um objeto de jogo ou seus componentes na cena fizerem referência a eles.
Os tipos de ativos também podem ser marcados explicitamente como ativos usando AssetDatabase.CreateAsset. Nesse caso, eles não serão salvos na cena, mas simplesmente referenciados. Se um tipo de ativo ou de objeto de jogo estiver marcado como HideAndDontSave, ele também não será salvo na cena.
Dúvidas?
