Serialização em Unity

LUCAS MEIJER / UNITY TECHNOLOGIESContributor
Jun 24, 2014|11 Min
Serialização em Unity
Esta página da Web foi automaticamente traduzida para sua conveniência. Não podemos garantir a precisão ou a confiabilidade do conteúdo traduzido. Se tiver dúvidas sobre a precisão do conteúdo traduzido, consulte a versão oficial em inglês da página da Web.

No espírito de compartilhar mais da tecnologia por trás dos bastidores e os motivos pelos quais algumas coisas são como são, esta postagem contém uma visão geral do sistema de serialização do Unity. Entender muito bem esse sistema pode ter um grande impacto na eficácia do seu desenvolvimento e no desempenho das coisas que o senhor faz. Aqui vamos nós.

A serialização de "coisas" está no cerne da Unity. Muitos de nossos recursos são construídos sobre o sistema de serialização:

  • Armazenamento de dados armazenados em seus scripts. Este é o que a maioria das pessoas provavelmente conhece.
  • Janela do inspetor. A janela do inspetor não se comunica com a API do C# para descobrir quais são os valores das propriedades do que está sendo inspecionado. Ele solicita que o objeto se serialize e, em seguida, exibe os dados serializados.
  • Pré-fabricados. Internamente, um prefab é o fluxo de dados serializado de um (ou mais) objetos e componentes do jogo. Uma instância de prefab é uma lista de modificações que devem ser feitas nos dados serializados para essa instância. Na verdade, o conceito de pré-fabricado só existe no momento do editor. As modificações do prefab são incorporadas em um fluxo de serialização normal quando a Unity faz uma compilação e, quando isso é instanciado, os GameObjects instanciados não fazem ideia de que eram um prefab quando estavam no editor.
  • Instanciação. Quando o senhor chama Instantiate() em um prefab ou em um GameObjects que vive na cena, ou em qualquer outra coisa (tudo o que deriva de UnityEngine.Object pode ser serializado), nós serializamos o objeto, criamos um novo objeto e, em seguida, "desserializamos" os dados no novo objeto. (Em seguida, executamos o mesmo código de serialização novamente em uma variante diferente, onde o usamos para informar quais outros UnityEngine.Object's estão sendo referenciados. Em seguida, verificamos se todos os UnityEngine.Object's referenciados fazem parte dos dados que estão sendo Instantiated(). Se a referência estiver apontando para algo "externo" (como uma textura), manteremos essa referência como está; se estiver apontando para algo "interno" (como um GameObjects filho), corrigiremos a referência para a cópia correspondente.)
  • Economizar. Se o senhor abrir um arquivo de cena .unity com um editor de texto e tiver configurado o unity para "forçar a serialização de texto", executaremos o serializador com um backend yaml.
  • Carregamento. Pode não parecer surpreendente, mas o carregamento compatível com versões anteriores é um sistema que também é construído sobre a serialização. O carregamento de yaml no editor usa o sistema de serialização, bem como o carregamento em tempo de execução de cenas e ativos. Os assetbundles também fazem uso do sistema de serialização.
  • Recarregamento a quente do código do editor. Quando o senhor altera um script de editor, nós serializamos todas as janelas do editor (elas derivam do UnityEngine.Object!), destruímos todas as janelas, descarregamos o código c# antigo, carregamos o novo código c#, recriamos as janelas e, finalmente, desserializamos os fluxos de dados das janelas de volta para as novas janelas.
  • Resource.GarbageCollectSharedAssets(). Esse é o nosso coletor de lixo nativo e é diferente do coletor de lixo do C#. É o procedimento que executamos depois que o senhor carrega uma cena para descobrir quais itens da cena anterior não são mais referenciados, para que possamos descarregá-los. O coletor de lixo nativo executa o serializador em um modo em que o usamos para que os objetos relatem todas as referências a UnityEngine.Objects. É isso que faz com que as texturas usadas pela cena1 sejam descarregadas quando o senhor carrega a cena2.

O sistema de serialização é escrito em C++ e o usamos para todos os nossos tipos de objetos internos (Texturas, AnimationClip, Câmera etc.). A serialização ocorre no nível do UnityEngine.Object, cada UnityEngine.Object é sempre serializado como um todo. Eles podem conter referências a outros UnityEngine.Objects e essas referências são serializadas corretamente.

Agora, o senhor pode dizer que nada disso o preocupa muito, que está apenas satisfeito com o fato de funcionar e que deseja continuar criando conteúdo. No entanto, isso é importante para o senhor, pois usamos esse mesmo serializador para serializar componentes MonoBehaviour, que são apoiados por seus scripts. Devido aos requisitos de alto desempenho do serializador, ele nem sempre se comporta exatamente como o que um desenvolvedor de C# esperaria de um serializador. Aqui, descreveremos como o serializador funciona e algumas práticas recomendadas sobre como fazer o melhor uso dele.

O que um campo do meu script precisa ter para ser serializado?

  • Ser público ou ter o atributo [SerializeField].
  • Não ser estático
  • Não ser const
  • Não ser readonly
  • O fieldtype precisa ser de um tipo que possa ser serializado.

Quais tipos de campo podemos serializar?

  • Classes personalizadas não abstratas com o atributo [Serializable].
  • Estruturas personalizadas com atributo [Serializable]. (novo no Unity4.5)
  • Referências a objetos que derivam de UntiyEngine.Object
  • Tipos de dados primitivos (int, float, double, bool, string, etc.)
  • Matriz de um tipo de campo que podemos serializar
  • List<T> de um tipo de campo que podemos serializar

Até agora, tudo bem. Então, quais são essas situações em que o serializador se comporta de forma diferente do que eu esperava?

As classes personalizadas se comportam como structs

[Serializável].
classe Animal
{
string pública name;
}

classe MyScript : MonoBehaviour
{
public Animal[] animals;
}

Se o senhor preencher a matriz animals com três referências a um único objeto Animal, no fluxo de serialização encontrará 3 objetos. Quando ele é desserializado, agora há três objetos diferentes. Se o senhor precisar serializar um gráfico de objetos complexo com referências, não poderá contar com o serializador da Unity para fazer tudo isso automaticamente e terá que fazer algum trabalho para serializar esse gráfico de objetos por conta própria. Veja o exemplo abaixo sobre como serializar coisas que a Unity não serializa por si só.

Observe que isso só é verdadeiro para classes personalizadas, pois elas são serializadas "em linha" porque seus dados se tornam parte dos dados de serialização completos do MonoBehaviour em que são usadas. Quando o usuário tem campos que fazem referência a algo que é uma classe derivada do UnityEngine.Object, como "public Camera myCamera", os dados dessa câmera não são serializados em linha, e uma referência real à câmera UnityEngine.Object é serializada.

Não há suporte para null para classes personalizadas

Questionário pop. Quantas alocações são feitas ao desserializar um MonoBehaviour que usa esse script:

Teste de classe : MonoBehaviour
{
problema público t;
}

[Serializável].
classe Problema
{
public Trouble t1;
public Trouble t2;
public Trouble t3;
}

Não seria estranho esperar uma alocação, a do objeto Test. Também não seria estranho esperar duas alocações, uma para o objeto Test e outra para um objeto Trouble. A resposta correta é 729. O serializador não é compatível com null. Se o senhor serializar um objeto e um campo for null, basta instanciar um novo objeto desse tipo e serializá-lo. Obviamente, isso poderia levar a ciclos infinitos, portanto, temos um limite de profundidade relativamente mágico de 7 níveis. Nesse ponto, simplesmente paramos de serializar campos que têm tipos de classes/estruturas personalizadas, listas e matrizes. [1]

Como muitos de nossos subsistemas são construídos sobre o sistema de serialização, esse fluxo de serialização inesperadamente grande para o MonoBehaviour de teste fará com que todos esses subsistemas tenham um desempenho mais lento do que o necessário. Quando investigamos problemas de desempenho em projetos de clientes, quase sempre encontramos esse problema e adicionamos um aviso para essa situação no Unity 4.5. Na verdade, bagunçamos a implementação dos avisos de tal forma que eles são emitidos em grande quantidade que o senhor não tem outra opção a não ser corrigi-los imediatamente. Em breve, enviaremos uma correção para isso em um lançamento de patch. O aviso não desapareceu, mas o senhor receberá apenas um por "entrar no modo de jogo", para que não receba um spam absurdo. O senhor ainda vai querer corrigir o código, mas deve poder fazer isso no momento que lhe for mais conveniente.

Não há suporte para polimorfismo

Se o senhor tiver um

public Animal[] animals

e o senhor colocar uma instância de um cachorro, um gato e uma girafa, após a serialização, terá três instâncias de Animal.

Uma maneira de lidar com essa limitação é perceber que ela só se aplica a "classes personalizadas", que são serializadas em linha. As referências a outros UnityEngine.Object's são serializadas como referências reais e, para elas, o polimorfismo realmente funciona. O senhor criaria uma classe derivada do ScriptableObject ou outra classe derivada do MonoBehaviour e faria referência a ela. A desvantagem de fazer isso é que o senhor precisa armazenar esse MonoBehaviour ou objeto de script em algum lugar e não pode serializá-lo em linha de forma adequada.

O motivo dessas limitações é que um dos fundamentos básicos do sistema de serialização é que o layout do fluxo de dados de um objeto é conhecido antecipadamente e depende dos tipos dos campos da classe, e não do que está armazenado dentro dos campos.

Quero serializar algo que o serializador da Unity não suporta. O que devo fazer?

Em muitos casos, a melhor abordagem é usar callbacks de serialização. Eles permitem que o senhor seja notificado antes de o serializador ler os dados dos seus campos e depois de terminar de gravar neles. O senhor pode usar isso para ter uma representação diferente dos seus dados difíceis de serializar em tempo de execução do que quando realmente serializa. O senhor as usaria para transformar seus dados em algo que a Unity entende logo antes de a Unity querer serializá-los; também as usaria para transformar o formulário serializado de volta no formulário em que gostaria de ter seus dados em tempo de execução, logo após a Unity ter escrito os dados em seus campos.

Digamos que o senhor queira ter uma estrutura de dados em árvore. Se o senhor permitir que o Unity serialize diretamente a estrutura de dados, a limitação "no support for null" faria com que o fluxo de dados se tornasse muito grande, levando à degradação do desempenho em muitos sistemas:

using UnityEngine;
usando System.Collections.Generic;
using System;

public class VerySlowBehaviourDoNotDoThis : MonoBehaviour
{
[Serializável].
classe pública Node
{
string pública interestingValue = "value";

/O campo abaixo é o que faz com que os dados de serialização se tornem enormes porque
//introduz um "ciclo de classe".
Lista pública <Node> children = new List<Node>();
}

//isso é serializado
public Node root = new Node();

void OnGUI()
{
Exibição (raiz);
}

void Display(Node node)
{
GUILayout.Label ("Value: ");
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200));

GUILayout.BeginHorizontal ();
GUILayout.Space (20);
GUILayout.BeginVertical ();

Foreach (var child in node.children)
Tela (criança);

Se (GUILayout.Button ("Add child"))
node.children.Add (new Node ());

GUILayout.EndVertical ();
GUILayout.EndHorizontal ();
}
}

Em vez disso, o senhor diz ao Unity para não serializar a árvore diretamente e cria um campo separado para armazenar a árvore em um formato serializado, adequado ao serializador do Unity:

using UnityEngine;
usando System.Collections.Generic;
using System;

classe pública BehaviourWithTree : MonoBehaviour, ISerializationCallbackReceiver
{
//classe de nó que é usada em tempo de execução
classe pública Node
{
string pública interestingValue = "value";
Lista pública <Node> children = new List<Node>();
}

//classe de nó que usaremos para serialização
[Serializável].
public struct SerializableNode
{
string pública interestingValue;
public int childCount;
public int indexOfFirstChild;
}

//a raiz do que usamos em tempo de execução. não serializado.
Nó raiz = novo Nó();

//o campo que damos à Unity para serializar.
public List<SerializableNode> serializedNodes;

public void OnBeforeSerialize()
{
A //unidade está prestes a ler o conteúdo do campo serializedNodes.
//escrevemos os dados corretos nesse campo "just in time".
serializedNodes.Clear();
AddNodeToSerializedNodes(root);
}

void AddNodeToSerializedNodes(Node n)
{
var serializedNode = new SerializableNode () {
interestingValue = n.interestingValue,
childCount = n.children.Count,
indexOfFirstChild = serializedNodes.Count+1
};

serializedNodes.Add (serializedNode);
Foreach (var child in n.children)
AddNodeToSerializedNodes (filho);
}

public void OnAfterDeserialize()
{
//A Unity acabou de gravar novos dados no campo serializedNodes.
//Vamos preencher nossos dados reais de tempo de execução com esses novos valores.

Se (serializedNodes.Count > 0)
root = ReadNodeFromSerializedNodes (0);
mais
root = new Node ();
}

Node ReadNodeFromSerializedNodes(int index)
{
var serializedNode = serializedNodes [index];
var children = new List<Node> ();
for(int i=0; i!= serializedNode.childCount; i++)
children.Add(ReadNodeFromSerializedNodes(serializedNode.indexOfFirstChild + i));

return new Node() {
interestingValue = serializedNode.interestingValue,
children = crianças
};
}

void OnGUI()
{
Exibição (raiz);
}

void Display(Node node)
{
GUILayout.Label ("Value: ");
node.interestingValue = GUILayout.TextField(node.interestingValue, GUILayout.Width(200));

GUILayout.BeginHorizontal ();
GUILayout.Space (20);
GUILayout.BeginVertical ();

Foreach (var child in node.children)
Tela (criança);

Se (GUILayout.Button ("Add child"))
node.children.Add (new Node ());

GUILayout.EndVertical ();
GUILayout.EndHorizontal ();
}
}

Observe que o serializador, incluindo esses retornos de chamada provenientes do serializador, geralmente não é executado no thread principal, portanto, o senhor está muito limitado no que pode fazer em termos de invocar a API do Unity. (A serialização que acontece como parte do carregamento de uma cena ocorre em um thread de carregamento. A serialização que acontece como parte da invocação do Instantiate() do script pelo senhor acontece no thread principal.) No entanto, o senhor pode fazer as transformações de dados necessárias para obter seus dados de um formato não compatível com o serializador Unity para um formato compatível com o serializador Unity.

O senhor chegou ao fim!

Obrigado por ler até aqui, espero que o senhor possa usar algumas dessas informações em seus projetos.

Tchau, Lucas.(@lucasmeijer)

PS: Também adicionaremos todas essas informações à documentação.

[1] Eu menti, a resposta correta não é realmente 729. Isso ocorre porque, antigamente, antes de termos esse limite de profundidade de 7 níveis, a Unity fazia um loop infinito e ficava sem memória se o senhor criasse um script como o Trouble que acabei de escrever. Nossa primeira correção para isso, há cinco anos, foi simplesmente não serializar os tipos de campo que eram do mesmo tipo que a própria classe. Obviamente, essa não foi a correção mais robusta, pois é fácil criar um ciclo usando a classe Trouble1->Trouble2->Trouble1->Trouble2. Então, pouco tempo depois, implementamos o limite de profundidade de 7 níveis para pegar esses casos também. No entanto, para o que estou tentando dizer, isso não importa, o que importa é que o senhor perceba que, se houver um ciclo, estará em apuros.